From 5b45ce798280fc96215b418e58f215a3ad226e35 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:45:29 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .cargo/audit.toml | 32 + .cargo/config.toml | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 40 + .github/ISSUE_TEMPLATE/doc_request.md | 10 + .github/ISSUE_TEMPLATE/feature_request.md | 28 + .github/PULL_REQUEST_TEMPLATE/template.md | 12 + .github/dependabot.yml | 21 + .github/pull_request_template.md | 18 + .github/workflows/combine-dependabot-prs.yml | 21 + .github/workflows/dependencies.yml | 55 + .github/workflows/publish.yml | 443 + .github/workflows/stale-pr.yml | 41 + .github/workflows/test-gpu.yml | 162 + .github/workflows/test.yml | 278 + .github/workflows/valgrind.yml | 39 + .github/workflows/vulnerabilities.yml | 131 + .gitignore | 27 + CITATION.cff | 34 + CODE-OF-CONDUCT.md | 128 + CONTRIBUTING.md | 97 + Cargo.lock | 12579 ++++++++++++++++ Cargo.toml | 242 + LICENSE-APACHE | 201 + LICENSE-MIT | 21 + NOTICES.md | 485 + POEM.md | 40 + README.md | 613 + README.wehub.md | 7 + _typos.toml | 24 + assets/backend-chip.png | Bin 0 -> 68782 bytes assets/burn-train-tui.png | Bin 0 -> 1008983 bytes assets/ember-blazingly-fast.png | Bin 0 -> 168574 bytes assets/ember-community.png | Bin 0 -> 125476 bytes assets/ember-walking.png | Bin 0 -> 136049 bytes assets/ember-wall.png | Bin 0 -> 177355 bytes assets/logo-burn-full.png | Bin 0 -> 141879 bytes assets/logo-burn-neutral.webp | Bin 0 -> 28658 bytes assets/logo-burn-small.png | Bin 0 -> 63807 bytes benchmarks.toml | 82 + burn-book/.gitignore | 18 + burn-book/.prettierrc.json | 4 + burn-book/LICENSE-APACHE | 1 + burn-book/LICENSE-MIT | 1 + burn-book/book.toml | 16 + burn-book/src/SUMMARY.md | 38 + burn-book/src/advanced/README.md | 12 + .../src/advanced/backend-extension/README.md | 82 + .../backend-extension/custom-cubecl-kernel.md | 383 + .../backend-extension/custom-wgpu-kernel.md | 459 + burn-book/src/advanced/no-std.md | 100 + burn-book/src/advanced/web-assembly.md | 12 + burn-book/src/basic-workflow/README.md | 33 + burn-book/src/basic-workflow/backend.md | 54 + burn-book/src/basic-workflow/data.md | 126 + burn-book/src/basic-workflow/inference.md | 89 + burn-book/src/basic-workflow/model.md | 406 + .../src/basic-workflow/training-output.png | Bin 0 -> 214353 bytes burn-book/src/basic-workflow/training.md | 297 + burn-book/src/building-blocks/README.md | 7 + burn-book/src/building-blocks/autodiff.md | 90 + burn-book/src/building-blocks/backend.md | 14 + burn-book/src/building-blocks/config.md | 64 + burn-book/src/building-blocks/dataset.md | 531 + burn-book/src/building-blocks/dataset.png | Bin 0 -> 101316 bytes burn-book/src/building-blocks/learner.md | 112 + burn-book/src/building-blocks/metric.md | 226 + burn-book/src/building-blocks/module.md | 343 + burn-book/src/building-blocks/record.md | 85 + burn-book/src/building-blocks/tensor.md | 588 + burn-book/src/custom-training-loop.md | 278 + burn-book/src/distributed-computing.md | 1 + burn-book/src/examples.md | 101 + burn-book/src/getting-started.md | 219 + .../src/models-and-pretrained-weights.md | 30 + burn-book/src/motivation.md | 28 + burn-book/src/onnx-import.md | 278 + burn-book/src/overview.md | 37 + burn-book/src/performance/README.md | 4 + .../src/performance/distributed-computing.md | 4 + .../src/performance/good-practices/README.md | 12 + .../good-practices/asynchronous-execution.md | 62 + .../good-practices/kernel-fusion.md | 76 + .../good-practices/kernel-selection.md | 25 + burn-book/src/performance/quantization.md | 133 + burn-book/src/saving-and-loading.md | 468 + codecov.yml | 13 + contributor-book/.gitignore | 18 + contributor-book/.prettierrc.json | 4 + contributor-book/LICENSE-APACHE | 1 + contributor-book/LICENSE-MIT | 1 + contributor-book/book.toml | 16 + contributor-book/src/SUMMARY.md | 16 + .../frequently-encountered-issues/README.md | 5 + .../issues-while-adding-ops.md | 19 + .../src/getting-started/README.md | 6 + .../configuring-your-editor.md | 36 + .../getting-started/debug-options-vscode.png | Bin 0 -> 193436 bytes .../setting-up-the-environment.md | 54 + .../src/getting-started/testing.md | 38 + contributor-book/src/guides/README.md | 3 + .../guides/adding-a-new-operation-to-burn.md | 246 + .../src/guides/submitting-examples.md | 108 + contributor-book/src/how-to-read-this-book.md | 22 + contributor-book/src/overview.md | 28 + .../src/project-architecture/README.md | 19 + .../src/project-architecture/backend.md | 45 + .../module-serialization.png | Bin 0 -> 114065 bytes .../src/project-architecture/module.md | 91 + .../src/project-architecture/serialization.md | 124 + .../src/project-architecture/tensor.md | 65 + crates/burn-autodiff/Cargo.toml | 47 + crates/burn-autodiff/LICENSE-APACHE | 1 + crates/burn-autodiff/LICENSE-MIT | 1 + crates/burn-autodiff/README.md | 8 + crates/burn-autodiff/src/backend.rs | 171 + crates/burn-autodiff/src/checkpoint/base.rs | 92 + .../burn-autodiff/src/checkpoint/builder.rs | 311 + crates/burn-autodiff/src/checkpoint/mod.rs | 9 + .../src/checkpoint/retro_forward.rs | 121 + crates/burn-autodiff/src/checkpoint/state.rs | 144 + .../burn-autodiff/src/checkpoint/strategy.rs | 107 + crates/burn-autodiff/src/distributed.rs | 59 + crates/burn-autodiff/src/grads.rs | 146 + crates/burn-autodiff/src/graph/base.rs | 26 + crates/burn-autodiff/src/graph/mod.rs | 9 + crates/burn-autodiff/src/graph/node.rs | 96 + crates/burn-autodiff/src/graph/requirement.rs | 38 + crates/burn-autodiff/src/graph/traversal.rs | 74 + crates/burn-autodiff/src/lib.rs | 46 + crates/burn-autodiff/src/ops/activation.rs | 167 + crates/burn-autodiff/src/ops/backward.rs | 88 + crates/burn-autodiff/src/ops/base.rs | 327 + crates/burn-autodiff/src/ops/bool_tensor.rs | 170 + crates/burn-autodiff/src/ops/distributed.rs | 90 + crates/burn-autodiff/src/ops/int_tensor.rs | 416 + crates/burn-autodiff/src/ops/maxmin.rs | 27 + crates/burn-autodiff/src/ops/mod.rs | 16 + crates/burn-autodiff/src/ops/module.rs | 2248 +++ crates/burn-autodiff/src/ops/qtensor.rs | 73 + crates/burn-autodiff/src/ops/sort.rs | 27 + crates/burn-autodiff/src/ops/tensor.rs | 4055 +++++ crates/burn-autodiff/src/ops/transaction.rs | 24 + crates/burn-autodiff/src/runtime/client.rs | 18 + crates/burn-autodiff/src/runtime/graph.rs | 344 + .../src/runtime/memory_management.rs | 301 + crates/burn-autodiff/src/runtime/mod.rs | 6 + crates/burn-autodiff/src/runtime/server.rs | 201 + crates/burn-autodiff/src/tensor.rs | 261 + crates/burn-autodiff/src/utils.rs | 25 + crates/burn-backend-extension/Cargo.toml | 20 + crates/burn-backend-extension/LICENSE-APACHE | 1 + crates/burn-backend-extension/LICENSE-MIT | 1 + crates/burn-backend-extension/README.md | 6 + crates/burn-backend-extension/src/lib.rs | 875 ++ crates/burn-backend-tests/.cargo/config.toml | 31 + crates/burn-backend-tests/Cargo.toml | 195 + crates/burn-backend-tests/README.md | 187 + .../burn-backend-tests/benches/attention.rs | 271 + .../burn-backend-tests/benches/binary_ops.rs | 390 + .../benches/cat_max_min_ops.rs | 321 + .../burn-backend-tests/benches/common/mod.rs | 162 + .../benches/comparison_ops.rs | 214 + crates/burn-backend-tests/benches/conv_ops.rs | 498 + .../benches/conv_transpose_ops.rs | 284 + .../benches/cross_unfold_ops.rs | 154 + .../benches/cumulative_ops.rs | 174 + .../burn-backend-tests/benches/default_ops.rs | 273 + .../benches/deform_conv_ops.rs | 415 + crates/burn-backend-tests/benches/fft_ops.rs | 138 + .../benches/gather_scatter_ops.rs | 207 + crates/burn-backend-tests/benches/int_ops.rs | 161 + .../benches/interpolate_ops.rs | 221 + crates/burn-backend-tests/benches/matmul.rs | 364 + crates/burn-backend-tests/benches/norm_ops.rs | 323 + crates/burn-backend-tests/benches/pool_ops.rs | 237 + .../benches/quantization_ops.rs | 341 + .../burn-backend-tests/benches/reduce_ops.rs | 279 + .../burn-backend-tests/benches/slice_ops.rs | 240 + .../burn-backend-tests/benches/softmax_ops.rs | 98 + .../burn-backend-tests/benches/unary_ops.rs | 225 + crates/burn-backend-tests/cubecl.toml | 15 + crates/burn-backend-tests/src/lib.rs | 157 + crates/burn-backend-tests/tests/autodiff.rs | 18 + .../burn-backend-tests/tests/autodiff/abs.rs | 58 + .../tests/autodiff/adaptive_avgpool1d.rs | 50 + .../tests/autodiff/adaptive_avgpool2d.rs | 96 + .../burn-backend-tests/tests/autodiff/add.rs | 74 + .../tests/autodiff/aggregation.rs | 261 + .../tests/autodiff/all_reduce.rs | 166 + .../tests/autodiff/avgpool1d.rs | 102 + .../tests/autodiff/avgpool2d.rs | 129 + .../tests/autodiff/backward.rs | 24 + .../tests/autodiff/bridge.rs | 25 + .../tests/autodiff/broadcast.rs | 56 + .../burn-backend-tests/tests/autodiff/cast.rs | 25 + .../burn-backend-tests/tests/autodiff/cat.rs | 105 + .../burn-backend-tests/tests/autodiff/ceil.rs | 21 + .../tests/autodiff/checkpoint.rs | 209 + .../tests/autodiff/complex.rs | 81 + .../tests/autodiff/conv1d.rs | 347 + .../tests/autodiff/conv2d.rs | 962 ++ .../tests/autodiff/conv3d.rs | 690 + .../tests/autodiff/conv_transpose1d.rs | 338 + .../tests/autodiff/conv_transpose2d.rs | 706 + .../tests/autodiff/conv_transpose3d.rs | 711 + .../tests/autodiff/cross.rs | 130 + .../tests/autodiff/cross_entropy.rs | 32 + .../burn-backend-tests/tests/autodiff/ctc.rs | 754 + .../tests/autodiff/cummax.rs | 115 + .../tests/autodiff/cummin.rs | 115 + .../tests/autodiff/cumprod.rs | 128 + .../tests/autodiff/cumsum.rs | 89 + .../tests/autodiff/deform_conv2d.rs | 1804 +++ .../burn-backend-tests/tests/autodiff/div.rs | 105 + .../burn-backend-tests/tests/autodiff/erf.rs | 29 + .../burn-backend-tests/tests/autodiff/exp.rs | 29 + .../tests/autodiff/expand.rs | 39 + .../burn-backend-tests/tests/autodiff/flip.rs | 29 + .../tests/autodiff/floor.rs | 21 + .../tests/autodiff/gather_scatter.rs | 94 + .../tests/autodiff/gather_scatter_nd.rs | 453 + .../burn-backend-tests/tests/autodiff/gelu.rs | 27 + .../tests/autodiff/gradients.rs | 23 + .../tests/autodiff/hypot.rs | 27 + .../tests/autodiff/linear.rs | 70 + .../burn-backend-tests/tests/autodiff/log.rs | 30 + .../tests/autodiff/log1p.rs | 29 + .../tests/autodiff/log_sigmoid.rs | 19 + .../burn-backend-tests/tests/autodiff/mask.rs | 63 + .../tests/autodiff/matmul.rs | 83 + .../tests/autodiff/maxmin.rs | 76 + .../tests/autodiff/maxpool1d.rs | 133 + .../tests/autodiff/maxpool2d.rs | 271 + .../tests/autodiff/memory_management.rs | 280 + .../burn-backend-tests/tests/autodiff/mod.rs | 81 + .../burn-backend-tests/tests/autodiff/mul.rs | 68 + .../tests/autodiff/multithread.rs | 90 + .../tests/autodiff/nearest_interpolate.rs | 97 + .../burn-backend-tests/tests/autodiff/neg.rs | 26 + .../tests/autodiff/nonzero.rs | 41 + .../tests/autodiff/permute.rs | 29 + .../burn-backend-tests/tests/autodiff/pow.rs | 93 + .../tests/autodiff/recip.rs | 22 + .../burn-backend-tests/tests/autodiff/relu.rs | 27 + .../tests/autodiff/remainder.rs | 41 + .../tests/autodiff/repeat_dim.rs | 44 + .../tests/autodiff/reshape.rs | 26 + .../burn-backend-tests/tests/autodiff/rfft.rs | 201 + .../tests/autodiff/round.rs | 20 + .../tests/autodiff/select.rs | 87 + .../tests/autodiff/sigmoid.rs | 35 + .../burn-backend-tests/tests/autodiff/sign.rs | 42 + .../tests/autodiff/slice.rs | 67 + .../tests/autodiff/slice_assign.rs | 163 + .../tests/autodiff/softmax.rs | 90 + .../burn-backend-tests/tests/autodiff/sort.rs | 76 + .../burn-backend-tests/tests/autodiff/sqrt.rs | 31 + .../burn-backend-tests/tests/autodiff/sub.rs | 73 + .../tests/autodiff/transpose.rs | 60 + .../burn-backend-tests/tests/autodiff/trig.rs | 371 + .../tests/autodiff/unfold.rs | 18 + .../burn-backend-tests/tests/autodiff_f16.rs | 23 + .../tests/common/autodiff.rs | 34 + .../tests/common/backend.rs | 31 + .../burn-backend-tests/tests/common/tensor.rs | 26 + crates/burn-backend-tests/tests/cubecl.rs | 25 + .../tests/cubecl/avg_pool2d.rs | 87 + .../tests/cubecl/bernoulli.rs | 43 + .../burn-backend-tests/tests/cubecl/cast.rs | 45 + crates/burn-backend-tests/tests/cubecl/cat.rs | 42 + .../burn-backend-tests/tests/cubecl/clamp.rs | 19 + .../tests/cubecl/contiguous.rs | 40 + .../burn-backend-tests/tests/cubecl/conv2d.rs | 116 + .../burn-backend-tests/tests/cubecl/conv3d.rs | 26 + .../tests/cubecl/conv_transpose2d.rs | 48 + .../tests/cubecl/conv_transpose3d.rs | 52 + .../burn-backend-tests/tests/cubecl/cross.rs | 156 + crates/burn-backend-tests/tests/cubecl/fft.rs | 712 + .../burn-backend-tests/tests/cubecl/gather.rs | 43 + .../tests/cubecl/interpolate_nearest.rs | 31 + .../tests/cubecl/mask_fill.rs | 51 + .../tests/cubecl/mask_where.rs | 70 + .../tests/cubecl/max_pool2d.rs | 48 + .../tests/cubecl/max_pool2d_backward.rs | 59 + crates/burn-backend-tests/tests/cubecl/mod.rs | 32 + .../burn-backend-tests/tests/cubecl/normal.rs | 36 + .../tests/cubecl/quantization/mod.rs | 14 + .../quantization/quantize_dequantize.rs | 235 + .../tests/cubecl/quantization/reshape.rs | 151 + .../tests/cubecl/quantization/slice.rs | 121 + .../burn-backend-tests/tests/cubecl/reduce.rs | 367 + .../tests/cubecl/repeat_dim.rs | 75 + .../tests/cubecl/scatter.rs | 68 + .../burn-backend-tests/tests/cubecl/select.rs | 21 + .../tests/cubecl/select_assign.rs | 53 + .../burn-backend-tests/tests/cubecl/slice.rs | 20 + .../tests/cubecl/slice_assign.rs | 21 + .../burn-backend-tests/tests/cubecl/stft.rs | 293 + .../burn-backend-tests/tests/cubecl/unary.rs | 20 + .../tests/cubecl/uniform.rs | 111 + crates/burn-backend-tests/tests/fusion/cat.rs | 341 + .../tests/fusion/fusion_f16_broadcast.rs | 108 + .../fusion/fusion_f16_write_vectorization.rs | 107 + .../tests/fusion/fusion_shape.rs | 277 + .../tests/fusion/inplace.rs | 236 + .../tests/fusion/int_bitwise.rs | 94 + crates/burn-backend-tests/tests/fusion/mod.rs | 23 + .../tests/fusion/reduce_broadcasted.rs | 158 + .../burn-backend-tests/tests/graph_capture.rs | 68 + .../tests/memory_cleaning.rs | 1030 ++ crates/burn-backend-tests/tests/tensor.rs | 21 + .../tests/tensor/bool/mod.rs | 3 + .../tests/tensor/bool/ops/all.rs | 34 + .../tests/tensor/bool/ops/any.rs | 23 + .../tests/tensor/bool/ops/argwhere_nonzero.rs | 107 + .../tests/tensor/bool/ops/cast.rs | 59 + .../tests/tensor/bool/ops/cat.rs | 29 + .../tests/tensor/bool/ops/comparison.rs | 112 + .../tests/tensor/bool/ops/create_like.rs | 34 + .../tests/tensor/bool/ops/expand.rs | 30 + .../tests/tensor/bool/ops/flip.rs | 33 + .../tests/tensor/bool/ops/full.rs | 16 + .../tests/tensor/bool/ops/gather_scatter.rs | 29 + .../tests/tensor/bool/ops/init.rs | 51 + .../tests/tensor/bool/ops/logical.rs | 336 + .../tests/tensor/bool/ops/mask.rs | 30 + .../tests/tensor/bool/ops/mod.rs | 27 + .../tests/tensor/bool/ops/movedim.rs | 56 + .../tests/tensor/bool/ops/permute.rs | 31 + .../tests/tensor/bool/ops/repeat.rs | 64 + .../tests/tensor/bool/ops/repeat_dim.rs | 34 + .../tests/tensor/bool/ops/reshape.rs | 13 + .../tests/tensor/bool/ops/select.rs | 241 + .../tests/tensor/bool/ops/stack.rs | 15 + .../tests/tensor/bool/ops/take.rs | 41 + .../tests/tensor/bool/ops/transpose.rs | 41 + .../tests/tensor/bool/ops/tri_mask.rs | 94 + .../tests/tensor/bool/ops/unfold.rs | 36 + .../tests/tensor/clone_invariance.rs | 761 + .../tests/tensor/distributed.rs | 118 + .../tests/tensor/float/activation/celu.rs | 34 + .../tests/tensor/float/activation/elu.rs | 32 + .../tests/tensor/float/activation/gelu.rs | 32 + .../tests/tensor/float/activation/glu.rs | 28 + .../tensor/float/activation/hard_sigmoid.rs | 27 + .../tensor/float/activation/leaky_relu.rs | 28 + .../tensor/float/activation/log_sigmoid.rs | 51 + .../tensor/float/activation/log_softmax.rs | 16 + .../tests/tensor/float/activation/mish.rs | 21 + .../tests/tensor/float/activation/mod.rs | 23 + .../tests/tensor/float/activation/prelu.rs | 101 + .../tensor/float/activation/quiet_softmax.rs | 15 + .../tests/tensor/float/activation/relu.rs | 25 + .../tests/tensor/float/activation/selu.rs | 37 + .../tests/tensor/float/activation/sigmoid.rs | 39 + .../tests/tensor/float/activation/silu.rs | 15 + .../tests/tensor/float/activation/softmax.rs | 129 + .../tests/tensor/float/activation/softmin.rs | 15 + .../tests/tensor/float/activation/softplus.rs | 28 + .../tests/tensor/float/activation/softsign.rs | 27 + .../float/activation/tanh_activation.rs | 15 + .../float/activation/thresholded_relu.rs | 26 + .../tests/tensor/float/grid/affine_grid.rs | 82 + .../tests/tensor/float/grid/meshgrid.rs | 150 + .../tests/tensor/float/grid/mod.rs | 4 + .../tensor/float/linalg/cosine_similarity.rs | 100 + .../tests/tensor/float/linalg/det.rs | 537 + .../tests/tensor/float/linalg/diag.rs | 259 + .../tests/tensor/float/linalg/lu.rs | 321 + .../tests/tensor/float/linalg/matvec.rs | 102 + .../tests/tensor/float/linalg/mod.rs | 10 + .../tests/tensor/float/linalg/outer.rs | 262 + .../tests/tensor/float/linalg/trace.rs | 114 + .../tests/tensor/float/linalg/vector_norm.rs | 236 + .../tests/tensor/float/mod.rs | 13 + .../tensor/float/module/adaptive_avgpool1d.rs | 70 + .../tensor/float/module/adaptive_avgpool2d.rs | 101 + .../tests/tensor/float/module/attention.rs | 642 + .../tests/tensor/float/module/avgpool1d.rs | 168 + .../tests/tensor/float/module/avgpool2d.rs | 220 + .../float/module/bicubic_interpolate.rs | 196 + .../float/module/bilinear_interpolate.rs | 270 + .../tests/tensor/float/module/conv1d.rs | 138 + .../tests/tensor/float/module/conv2d.rs | 762 + .../tests/tensor/float/module/conv3d.rs | 299 + .../tensor/float/module/conv_transpose1d.rs | 145 + .../tensor/float/module/conv_transpose2d.rs | 361 + .../tensor/float/module/conv_transpose3d.rs | 749 + .../tests/tensor/float/module/ctc.rs | 333 + .../tensor/float/module/deform_conv2d.rs | 438 + .../tests/tensor/float/module/forward.rs | 18 + .../float/module/lanczos3_interpolate.rs | 235 + .../tests/tensor/float/module/linear.rs | 59 + .../tests/tensor/float/module/maxpool1d.rs | 155 + .../tests/tensor/float/module/maxpool2d.rs | 523 + .../tests/tensor/float/module/mod.rs | 24 + .../float/module/nearest_interpolate.rs | 127 + .../tests/tensor/float/module/unfold4d.rs | 132 + .../tests/tensor/float/ops/abs.rs | 25 + .../tests/tensor/float/ops/add.rs | 151 + .../tests/tensor/float/ops/aggregation.rs | 645 + .../tests/tensor/float/ops/all.rs | 47 + .../tests/tensor/float/ops/any.rs | 87 + .../tests/tensor/float/ops/arg.rs | 212 + .../tests/tensor/float/ops/blackman_window.rs | 65 + .../tests/tensor/float/ops/cast.rs | 91 + .../tests/tensor/float/ops/cat.rs | 148 + .../tests/tensor/float/ops/categorical.rs | 128 + .../tests/tensor/float/ops/ceil.rs | 16 + .../tests/tensor/float/ops/chunk.rs | 85 + .../tests/tensor/float/ops/clamp.rs | 81 + .../tests/tensor/float/ops/close.rs | 40 + .../tests/tensor/float/ops/comparison.rs | 393 + .../tests/tensor/float/ops/create_like.rs | 57 + .../tests/tensor/float/ops/cross.rs | 148 + .../tests/tensor/float/ops/cumulative.rs | 202 + .../tests/tensor/float/ops/div.rs | 49 + .../tests/tensor/float/ops/dot.rs | 35 + .../tests/tensor/float/ops/erf.rs | 34 + .../tests/tensor/float/ops/exp.rs | 31 + .../tests/tensor/float/ops/expand.rs | 138 + .../tests/tensor/float/ops/finite.rs | 23 + .../tests/tensor/float/ops/flatten.rs | 64 + .../tests/tensor/float/ops/flip.rs | 48 + .../tests/tensor/float/ops/floor.rs | 16 + .../tests/tensor/float/ops/fmod.rs | 295 + .../tests/tensor/float/ops/full.rs | 28 + .../tests/tensor/float/ops/gather_scatter.rs | 195 + .../tensor/float/ops/gather_scatter_nd.rs | 356 + .../tests/tensor/float/ops/grid_sample.rs | 126 + .../tests/tensor/float/ops/hamming_window.rs | 65 + .../tests/tensor/float/ops/hann_window.rs | 63 + .../tests/tensor/float/ops/hypot.rs | 47 + .../tests/tensor/float/ops/inf.rs | 21 + .../tests/tensor/float/ops/init.rs | 62 + .../tests/tensor/float/ops/iter_dim.rs | 246 + .../tests/tensor/float/ops/log.rs | 39 + .../tests/tensor/float/ops/log1p.rs | 20 + .../tests/tensor/float/ops/mask.rs | 246 + .../tests/tensor/float/ops/matmul.rs | 466 + .../tests/tensor/float/ops/maxmin.rs | 309 + .../tests/tensor/float/ops/mod.rs | 134 + .../tests/tensor/float/ops/movedim.rs | 123 + .../tests/tensor/float/ops/mul.rs | 68 + .../tests/tensor/float/ops/nan.rs | 34 + .../tests/tensor/float/ops/narrow.rs | 98 + .../tests/tensor/float/ops/neg.rs | 21 + .../tests/tensor/float/ops/one_hot.rs | 71 + .../tests/tensor/float/ops/padding.rs | 470 + .../tests/tensor/float/ops/permute.rs | 58 + .../tests/tensor/float/ops/powf.rs | 105 + .../tests/tensor/float/ops/powf_scalar.rs | 55 + .../tests/tensor/float/ops/prod.rs | 48 + .../tests/tensor/float/ops/random.rs | 53 + .../tests/tensor/float/ops/recip.rs | 16 + .../tests/tensor/float/ops/remainder.rs | 241 + .../tests/tensor/float/ops/repeat.rs | 108 + .../tests/tensor/float/ops/repeat_dim.rs | 182 + .../tests/tensor/float/ops/reshape.rs | 90 + .../tests/tensor/float/ops/round.rs | 29 + .../tests/tensor/float/ops/select.rs | 251 + .../tests/tensor/float/ops/sign.rs | 12 + .../tests/tensor/float/ops/slice.rs | 591 + .../tests/tensor/float/ops/slice_assign.rs | 366 + .../tests/tensor/float/ops/sort_argsort.rs | 216 + .../tests/tensor/float/ops/split.rs | 206 + .../tests/tensor/float/ops/sqrt.rs | 46 + .../tests/tensor/float/ops/square.rs | 17 + .../tests/tensor/float/ops/squeeze.rs | 263 + .../tests/tensor/float/ops/stack.rs | 69 + .../tests/tensor/float/ops/sub.rs | 42 + .../tests/tensor/float/ops/take.rs | 206 + .../tests/tensor/float/ops/topk.rs | 21 + .../tests/tensor/float/ops/transaction.rs | 31 + .../tests/tensor/float/ops/transpose.rs | 116 + .../tests/tensor/float/ops/tri.rs | 21 + .../tests/tensor/float/ops/trig.rs | 292 + .../tests/tensor/float/ops/trunc.rs | 67 + .../tests/tensor/float/ops/unfold.rs | 68 + .../tests/tensor/float/primitive.rs | 27 + .../tensor/float/quantization/calibration.rs | 110 + .../tests/tensor/float/quantization/data.rs | 48 + .../tests/tensor/float/quantization/mod.rs | 49 + .../float/quantization/ops/extended/abs.rs | 19 + .../float/quantization/ops/extended/add.rs | 106 + .../quantization/ops/extended/aggregation.rs | 166 + .../float/quantization/ops/extended/all.rs | 24 + .../float/quantization/ops/extended/any.rs | 25 + .../float/quantization/ops/extended/arg.rs | 47 + .../float/quantization/ops/extended/cat.rs | 65 + .../float/quantization/ops/extended/ceil.rs | 16 + .../float/quantization/ops/extended/chunk.rs | 98 + .../float/quantization/ops/extended/clamp.rs | 49 + .../float/quantization/ops/extended/cos.rs | 17 + .../float/quantization/ops/extended/cosh.rs | 17 + .../float/quantization/ops/extended/div.rs | 50 + .../float/quantization/ops/extended/erf.rs | 33 + .../float/quantization/ops/extended/exp.rs | 17 + .../float/quantization/ops/extended/expand.rs | 112 + .../float/quantization/ops/extended/flip.rs | 39 + .../float/quantization/ops/extended/floor.rs | 16 + .../ops/extended/gather_scatter.rs | 198 + .../float/quantization/ops/extended/log.rs | 20 + .../float/quantization/ops/extended/log1p.rs | 20 + .../ops/extended/map_comparison.rs | 157 + .../float/quantization/ops/extended/mask.rs | 39 + .../float/quantization/ops/extended/maxmin.rs | 149 + .../float/quantization/ops/extended/mod.rs | 50 + .../float/quantization/ops/extended/mul.rs | 60 + .../float/quantization/ops/extended/narrow.rs | 58 + .../float/quantization/ops/extended/neg.rs | 19 + .../quantization/ops/extended/permute.rs | 66 + .../float/quantization/ops/extended/powf.rs | 60 + .../quantization/ops/extended/powf_scalar.rs | 56 + .../float/quantization/ops/extended/recip.rs | 17 + .../quantization/ops/extended/remainder.rs | 208 + .../quantization/ops/extended/repeat_dim.rs | 42 + .../quantization/ops/extended/reshape.rs | 77 + .../float/quantization/ops/extended/round.rs | 31 + .../float/quantization/ops/extended/select.rs | 120 + .../float/quantization/ops/extended/sin.rs | 17 + .../float/quantization/ops/extended/sinh.rs | 17 + .../float/quantization/ops/extended/slice.rs | 229 + .../quantization/ops/extended/sort_argsort.rs | 225 + .../float/quantization/ops/extended/split.rs | 151 + .../float/quantization/ops/extended/sqrt.rs | 18 + .../float/quantization/ops/extended/stack.rs | 69 + .../float/quantization/ops/extended/sub.rs | 46 + .../float/quantization/ops/extended/tan.rs | 17 + .../float/quantization/ops/extended/tanh.rs | 17 + .../float/quantization/ops/extended/topk.rs | 68 + .../quantization/ops/extended/transpose.rs | 39 + .../tensor/float/quantization/ops/matmul.rs | 248 + .../tensor/float/quantization/ops/mod.rs | 31 + .../tensor/float/quantization/ops/quantize.rs | 255 + .../tests/tensor/float/quantization/scheme.rs | 81 + .../tests/tensor/float/stats/cov.rs | 66 + .../tests/tensor/float/stats/display.rs | 321 + .../tests/tensor/float/stats/eye.rs | 17 + .../tests/tensor/float/stats/median.rs | 92 + .../tests/tensor/float/stats/mod.rs | 7 + .../tests/tensor/float/stats/var.rs | 69 + .../tests/tensor/int/mod.rs | 4 + .../tests/tensor/int/ops/abs.rs | 24 + .../tests/tensor/int/ops/add.rs | 104 + .../tests/tensor/int/ops/aggregation.rs | 81 + .../tests/tensor/int/ops/all.rs | 23 + .../tests/tensor/int/ops/any.rs | 23 + .../tests/tensor/int/ops/arange.rs | 31 + .../tests/tensor/int/ops/arange_step.rs | 44 + .../tests/tensor/int/ops/arg.rs | 24 + .../tests/tensor/int/ops/bitwise.rs | 173 + .../tests/tensor/int/ops/cartesian_grid.rs | 20 + .../tests/tensor/int/ops/cast.rs | 84 + .../tests/tensor/int/ops/cat.rs | 28 + .../tests/tensor/int/ops/chunk.rs | 16 + .../tests/tensor/int/ops/comparison.rs | 280 + .../tests/tensor/int/ops/create_like.rs | 22 + .../tests/tensor/int/ops/cumulative.rs | 90 + .../tests/tensor/int/ops/div.rs | 45 + .../tests/tensor/int/ops/expand.rs | 75 + .../tests/tensor/int/ops/flip.rs | 22 + .../tests/tensor/int/ops/full.rs | 11 + .../tests/tensor/int/ops/gather_scatter.rs | 69 + .../tests/tensor/int/ops/gather_scatter_nd.rs | 131 + .../tests/tensor/int/ops/init.rs | 31 + .../tests/tensor/int/ops/mask.rs | 69 + .../tests/tensor/int/ops/matmul.rs | 203 + .../tests/tensor/int/ops/mod.rs | 48 + .../tests/tensor/int/ops/movedim.rs | 52 + .../tests/tensor/int/ops/mul.rs | 70 + .../tests/tensor/int/ops/one_hot.rs | 59 + .../tests/tensor/int/ops/permute.rs | 49 + .../tests/tensor/int/ops/random.rs | 18 + .../tests/tensor/int/ops/remainder.rs | 39 + .../tests/tensor/int/ops/repeat.rs | 100 + .../tests/tensor/int/ops/repeat_dim.rs | 46 + .../tests/tensor/int/ops/reshape.rs | 179 + .../tests/tensor/int/ops/roll.rs | 108 + .../tests/tensor/int/ops/select.rs | 38 + .../tests/tensor/int/ops/sign.rs | 12 + .../tests/tensor/int/ops/slice.rs | 29 + .../tests/tensor/int/ops/slice_assign.rs | 55 + .../tests/tensor/int/ops/sort_argsort.rs | 153 + .../tests/tensor/int/ops/stack.rs | 33 + .../tests/tensor/int/ops/sub.rs | 55 + .../tests/tensor/int/ops/take.rs | 31 + .../tests/tensor/int/ops/topk.rs | 58 + .../tests/tensor/int/ops/transpose.rs | 28 + .../tests/tensor/int/ops/tri.rs | 85 + .../tests/tensor/int/ops/unfold.rs | 39 + .../tests/tensor/int/primitive.rs | 13 + crates/burn-backend-tests/tests/tensor/mod.rs | 12 + .../tests/tensor/multi_threads.rs | 171 + crates/burn-backend-tests/tests/tensor_f16.rs | 29 + crates/burn-backend/Cargo.toml | 58 + crates/burn-backend/README.md | 4 + crates/burn-backend/src/alias.rs | 20 + crates/burn-backend/src/backend/base.rs | 469 + crates/burn-backend/src/backend/device.rs | 403 + .../src/backend/distributed/api.rs | 59 + .../src/backend/distributed/client.rs | 75 + .../src/backend/distributed/mod.rs | 51 + .../src/backend/distributed/ops.rs | 166 + .../src/backend/distributed/server.rs | 140 + crates/burn-backend/src/backend/mod.rs | 13 + .../src/backend/ops/activation.rs | 343 + .../burn-backend/src/backend/ops/argwhere.rs | 72 + .../src/backend/ops/bool_tensor.rs | 573 + crates/burn-backend/src/backend/ops/cat.rs | 45 + .../src/backend/ops/int_tensor.rs | 1460 ++ crates/burn-backend/src/backend/ops/mod.rs | 20 + .../src/backend/ops/modules/attention.rs | 116 + .../src/backend/ops/modules/base.rs | 866 ++ .../src/backend/ops/modules/conv.rs | 1533 ++ .../src/backend/ops/modules/ctc.rs | 613 + .../src/backend/ops/modules/grid_sample.rs | 320 + .../src/backend/ops/modules/linear.rs | 128 + .../src/backend/ops/modules/mod.rs | 24 + .../src/backend/ops/modules/pool.rs | 178 + .../src/backend/ops/modules/unfold.rs | 146 + .../burn-backend/src/backend/ops/qtensor.rs | 1292 ++ .../src/backend/ops/repeat_dim.rs | 44 + crates/burn-backend/src/backend/ops/sort.rs | 433 + crates/burn-backend/src/backend/ops/tensor.rs | 1875 +++ .../src/backend/ops/transaction.rs | 142 + crates/burn-backend/src/backend/primitive.rs | 107 + crates/burn-backend/src/cubecl.rs | 111 + crates/burn-backend/src/lib.rs | 176 + crates/burn-backend/src/quantization/mod.rs | 10 + .../src/quantization/parameters.rs | 15 + .../burn-backend/src/quantization/scheme.rs | 98 + crates/burn-candle/Cargo.toml | 43 + crates/burn-candle/LICENSE-APACHE | 1 + crates/burn-candle/LICENSE-MIT | 1 + crates/burn-candle/README.md | 14 + crates/burn-candle/src/backend.rs | 304 + crates/burn-candle/src/element.rs | 32 + crates/burn-candle/src/lib.rs | 27 + crates/burn-candle/src/ops/activation.rs | 17 + crates/burn-candle/src/ops/base.rs | 691 + crates/burn-candle/src/ops/bool_tensor.rs | 209 + crates/burn-candle/src/ops/candle_utils.rs | 46 + crates/burn-candle/src/ops/int_tensor.rs | 570 + crates/burn-candle/src/ops/mod.rs | 10 + crates/burn-candle/src/ops/module.rs | 352 + crates/burn-candle/src/ops/qtensor.rs | 60 + crates/burn-candle/src/ops/tensor.rs | 679 + crates/burn-candle/src/ops/transaction.rs | 15 + crates/burn-candle/src/ops/utils.rs | 29 + crates/burn-candle/src/tensor.rs | 120 + crates/burn-communication/Cargo.toml | 49 + crates/burn-communication/README.md | 15 + crates/burn-communication/src/base.rs | 221 + .../burn-communication/src/external_comm.rs | 260 + crates/burn-communication/src/lib.rs | 13 + crates/burn-communication/src/util.rs | 46 + .../burn-communication/src/websocket/base.rs | 26 + .../src/websocket/client.rs | 207 + .../burn-communication/src/websocket/mod.rs | 7 + .../src/websocket/server.rs | 250 + .../tests/websocket_server.rs | 367 + crates/burn-core/Cargo.toml | 133 + crates/burn-core/LICENSE-APACHE | 1 + crates/burn-core/LICENSE-MIT | 1 + crates/burn-core/README.md | 14 + crates/burn-core/src/backend.rs | 39 + crates/burn-core/src/config.rs | 98 + crates/burn-core/src/data/dataloader/base.rs | 52 + crates/burn-core/src/data/dataloader/batch.rs | 260 + .../burn-core/src/data/dataloader/batcher.rs | 28 + .../burn-core/src/data/dataloader/builder.rs | 240 + crates/burn-core/src/data/dataloader/mod.rs | 16 + .../src/data/dataloader/multithread.rs | 553 + crates/burn-core/src/data/dataloader/split.rs | 115 + .../burn-core/src/data/dataloader/strategy.rs | 87 + crates/burn-core/src/data/mod.rs | 15 + crates/burn-core/src/lib.rs | 93 + crates/burn-core/src/module/base.rs | 459 + crates/burn-core/src/module/display.rs | 578 + crates/burn-core/src/module/initializer.rs | 617 + crates/burn-core/src/module/mod.rs | 11 + crates/burn-core/src/module/param/base.rs | 671 + crates/burn-core/src/module/param/constant.rs | 299 + crates/burn-core/src/module/param/group.rs | 545 + crates/burn-core/src/module/param/id.rs | 1 + crates/burn-core/src/module/param/mod.rs | 16 + .../burn-core/src/module/param/primitive.rs | 319 + crates/burn-core/src/module/param/running.rs | 233 + .../src/module/param/sync_once_cell.rs | 60 + crates/burn-core/src/module/param/tensor.rs | 341 + crates/burn-core/src/module/param/visitor.rs | 37 + crates/burn-core/src/module/quantize.rs | 62 + crates/burn-core/src/store/mod.rs | 554 + crates/burn-core/src/tensor.rs | 1 + crates/burn-core/tests/test_derive_config.rs | 113 + crates/burn-core/tests/test_derive_module.rs | 772 + crates/burn-core/tests/test_record.rs | 134 + crates/burn-cpu/Cargo.toml | 44 + crates/burn-cpu/README.md | 12 + crates/burn-cpu/src/lib.rs | 44 + crates/burn-cubecl-fusion/Cargo.toml | 59 + crates/burn-cubecl-fusion/README.md | 3 + crates/burn-cubecl-fusion/src/base.rs | 124 + .../src/engine/codegen/base.rs | 5 + .../src/engine/codegen/io.rs | 875 ++ .../src/engine/codegen/ir.rs | 1037 ++ .../src/engine/codegen/kernel.rs | 1097 ++ .../src/engine/codegen/mod.rs | 8 + .../src/engine/codegen/tensor.rs | 68 + .../src/engine/codegen/view.rs | 358 + crates/burn-cubecl-fusion/src/engine/fuser.rs | 884 ++ .../src/engine/launch/base.rs | 102 + .../src/engine/launch/executor.rs | 316 + .../src/engine/launch/input.rs | 252 + .../src/engine/launch/mod.rs | 11 + .../src/engine/launch/output.rs | 720 + .../src/engine/launch/plan.rs | 272 + .../src/engine/launch/runner.rs | 96 + .../src/engine/launch/vectorization/base.rs | 474 + .../src/engine/launch/vectorization/mod.rs | 5 + .../engine/launch/vectorization/planner.rs | 450 + crates/burn-cubecl-fusion/src/engine/mod.rs | 7 + .../burn-cubecl-fusion/src/engine/scoring.rs | 162 + .../burn-cubecl-fusion/src/engine/settings.rs | 59 + .../src/engine/trace/base.rs | 377 + .../src/engine/trace/block.rs | 541 + .../src/engine/trace/fuser.rs | 321 + .../src/engine/trace/mod.rs | 7 + crates/burn-cubecl-fusion/src/inspect.rs | 26 + crates/burn-cubecl-fusion/src/lib.rs | 14 + crates/burn-cubecl-fusion/src/optim/base.rs | 72 + .../src/optim/elemwise/fuser.rs | 85 + .../src/optim/elemwise/mod.rs | 5 + .../src/optim/elemwise/optimization.rs | 141 + .../src/optim/matmul/args.rs | 571 + .../src/optim/matmul/fuser.rs | 154 + .../src/optim/matmul/mod.rs | 8 + .../src/optim/matmul/optimization.rs | 708 + .../src/optim/matmul/tune.rs | 299 + crates/burn-cubecl-fusion/src/optim/mod.rs | 8 + .../src/optim/reduce/args.rs | 246 + .../src/optim/reduce/fuser.rs | 314 + .../src/optim/reduce/mod.rs | 8 + .../src/optim/reduce/optimization.rs | 518 + .../src/optim/reduce/tune.rs | 173 + .../optim/reduce_broadcasted/fuser/base.rs | 388 + .../optim/reduce_broadcasted/fuser/block.rs | 227 + .../optim/reduce_broadcasted/fuser/full.rs | 177 + .../reduce_broadcasted/fuser/full_analyzer.rs | 159 + .../src/optim/reduce_broadcasted/fuser/mod.rs | 6 + .../src/optim/reduce_broadcasted/launch.rs | 149 + .../src/optim/reduce_broadcasted/mod.rs | 9 + .../optim/reduce_broadcasted/optimization.rs | 219 + .../src/optim/reduce_broadcasted/tune.rs | 160 + .../src/optim/reduce_broadcasted/unit.rs | 220 + crates/burn-cubecl-fusion/src/tune.rs | 215 + crates/burn-cubecl/Cargo.toml | 92 + crates/burn-cubecl/LICENSE-APACHE | 1 + crates/burn-cubecl/LICENSE-MIT | 1 + crates/burn-cubecl/README.md | 3 + crates/burn-cubecl/src/backend.rs | 255 + crates/burn-cubecl/src/element.rs | 94 + crates/burn-cubecl/src/fusion.rs | 192 + .../burn-cubecl/src/kernel/attention/base.rs | 145 + .../burn-cubecl/src/kernel/attention/mod.rs | 5 + .../burn-cubecl/src/kernel/attention/tune.rs | 178 + crates/burn-cubecl/src/kernel/binary.rs | 346 + crates/burn-cubecl/src/kernel/binary_float.rs | 124 + crates/burn-cubecl/src/kernel/binary_int.rs | 238 + crates/burn-cubecl/src/kernel/cast/base.rs | 74 + .../burn-cubecl/src/kernel/cast/bool_cast.rs | 72 + crates/burn-cubecl/src/kernel/cast/mod.rs | 5 + crates/burn-cubecl/src/kernel/clamp.rs | 41 + crates/burn-cubecl/src/kernel/comparison.rs | 465 + crates/burn-cubecl/src/kernel/contiguous.rs | 125 + .../src/kernel/conv/backward_data/fallback.rs | 124 + .../backward_data/implicit_gemm/launch.rs | 135 + .../conv/backward_data/implicit_gemm/mod.rs | 2 + .../src/kernel/conv/backward_data/mod.rs | 8 + .../src/kernel/conv/backward_data/tune.rs | 182 + .../kernel/conv/backward_weight/fallback.rs | 112 + .../backward_weight/implicit_gemm/launch.rs | 135 + .../conv/backward_weight/implicit_gemm/mod.rs | 2 + .../src/kernel/conv/backward_weight/mod.rs | 8 + .../src/kernel/conv/backward_weight/tune.rs | 185 + crates/burn-cubecl/src/kernel/conv/base.rs | 189 + .../src/kernel/conv/conv_transpose2d/base.rs | 54 + .../kernel/conv/conv_transpose2d/col2im.rs | 307 + .../src/kernel/conv/conv_transpose2d/mod.rs | 15 + .../conv/conv_transpose2d/transpose_direct.rs | 187 + .../src/kernel/conv/conv_transpose2d/tune.rs | 99 + .../src/kernel/conv/conv_transpose3d.rs | 228 + .../src/kernel/conv/deform_conv2d.rs | 311 + .../kernel/conv/deform_conv_transpose2d.rs | 727 + crates/burn-cubecl/src/kernel/conv/direct.rs | 321 + .../conv/forward/implicit_gemm/launch.rs | 171 + .../kernel/conv/forward/implicit_gemm/mod.rs | 2 + .../src/kernel/conv/forward/mod.rs | 7 + .../src/kernel/conv/forward/tune.rs | 187 + crates/burn-cubecl/src/kernel/conv/im2col.rs | 191 + crates/burn-cubecl/src/kernel/conv/mod.rs | 25 + .../burn-cubecl/src/kernel/conv/tune_key.rs | 50 + crates/burn-cubecl/src/kernel/cross.rs | 108 + crates/burn-cubecl/src/kernel/ctc.rs | 670 + crates/burn-cubecl/src/kernel/fft/base.rs | 164 + crates/burn-cubecl/src/kernel/fft/mod.rs | 3 + .../src/kernel/grid_sample/base.rs | 163 + .../src/kernel/grid_sample/bilinear.rs | 181 + .../burn-cubecl/src/kernel/grid_sample/mod.rs | 4 + crates/burn-cubecl/src/kernel/index/flip.rs | 103 + crates/burn-cubecl/src/kernel/index/gather.rs | 84 + .../burn-cubecl/src/kernel/index/gather_nd.rs | 124 + crates/burn-cubecl/src/kernel/index/mod.rs | 22 + .../src/kernel/index/repeat_dim.rs | 91 + .../burn-cubecl/src/kernel/index/scatter.rs | 116 + .../src/kernel/index/scatter_nd.rs | 139 + crates/burn-cubecl/src/kernel/index/select.rs | 85 + .../src/kernel/index/select_assign.rs | 104 + crates/burn-cubecl/src/kernel/index/slice.rs | 249 + .../src/kernel/index/slice_assign.rs | 259 + .../src/kernel/interpolate/base.rs | 168 + .../burn-cubecl/src/kernel/interpolate/mod.rs | 5 + .../src/kernel/interpolate/tune.rs | 136 + crates/burn-cubecl/src/kernel/mask/base.rs | 39 + .../burn-cubecl/src/kernel/mask/mask_fill.rs | 99 + .../burn-cubecl/src/kernel/mask/mask_where.rs | 104 + crates/burn-cubecl/src/kernel/mask/mod.rs | 8 + crates/burn-cubecl/src/kernel/matmul/base.rs | 189 + crates/burn-cubecl/src/kernel/matmul/mod.rs | 10 + .../src/kernel/matmul/tune/base.rs | 520 + .../burn-cubecl/src/kernel/matmul/tune/mod.rs | 5 + crates/burn-cubecl/src/kernel/matmul/utils.rs | 16 + crates/burn-cubecl/src/kernel/mod.rs | 55 + crates/burn-cubecl/src/kernel/pool/base.rs | 336 + crates/burn-cubecl/src/kernel/pool/mod.rs | 3 + .../burn-cubecl/src/kernel/prng/bernoulli.rs | 24 + crates/burn-cubecl/src/kernel/prng/mod.rs | 7 + crates/burn-cubecl/src/kernel/prng/normal.rs | 26 + crates/burn-cubecl/src/kernel/prng/uniform.rs | 43 + .../src/kernel/quantization/dequantize.rs | 35 + .../src/kernel/quantization/mod.rs | 5 + .../src/kernel/quantization/quantize.rs | 31 + crates/burn-cubecl/src/kernel/reduce/base.rs | 294 + crates/burn-cubecl/src/kernel/reduce/mod.rs | 7 + crates/burn-cubecl/src/kernel/reduce/tune.rs | 292 + crates/burn-cubecl/src/kernel/unary_float.rs | 202 + crates/burn-cubecl/src/kernel/unary_int.rs | 154 + .../burn-cubecl/src/kernel/unary_numeric.rs | 99 + crates/burn-cubecl/src/kernel/utils.rs | 136 + crates/burn-cubecl/src/lib.rs | 50 + crates/burn-cubecl/src/ops/activation.rs | 4 + crates/burn-cubecl/src/ops/base.rs | 540 + crates/burn-cubecl/src/ops/bool_tensor.rs | 241 + crates/burn-cubecl/src/ops/distributed.rs | 56 + crates/burn-cubecl/src/ops/int_tensor.rs | 663 + crates/burn-cubecl/src/ops/mod.rs | 16 + crates/burn-cubecl/src/ops/module.rs | 399 + crates/burn-cubecl/src/ops/numeric.rs | 472 + crates/burn-cubecl/src/ops/qtensor.rs | 283 + crates/burn-cubecl/src/ops/tensor.rs | 757 + crates/burn-cubecl/src/ops/transaction.rs | 137 + crates/burn-cubecl/src/template/base.rs | 103 + crates/burn-cubecl/src/template/mod.rs | 5 + crates/burn-cubecl/src/template/source.rs | 69 + crates/burn-cubecl/src/tensor/base.rs | 405 + crates/burn-cubecl/src/tensor/mod.rs | 5 + crates/burn-cubecl/src/tensor/quantization.rs | 122 + crates/burn-cubecl/src/tune_key.rs | 30 + crates/burn-cuda/Cargo.toml | 42 + crates/burn-cuda/README.md | 30 + crates/burn-cuda/src/lib.rs | 44 + crates/burn-dataset/Cargo.toml | 85 + crates/burn-dataset/LICENSE-APACHE | 1 + crates/burn-dataset/LICENSE-MIT | 1 + crates/burn-dataset/README.md | 17 + crates/burn-dataset/examples/hf_dataset.rs | 22 + .../burn-dataset/examples/speech_commands.rs | 23 + crates/burn-dataset/src/audio/mod.rs | 3 + .../burn-dataset/src/audio/speech_commands.rs | 208 + crates/burn-dataset/src/dataset/base.rs | 71 + crates/burn-dataset/src/dataset/dataframe.rs | 464 + crates/burn-dataset/src/dataset/fake.rs | 38 + crates/burn-dataset/src/dataset/in_memory.rs | 192 + crates/burn-dataset/src/dataset/iterator.rs | 31 + crates/burn-dataset/src/dataset/mod.rs | 25 + crates/burn-dataset/src/dataset/sqlite.rs | 851 ++ crates/burn-dataset/src/lib.rs | 52 + crates/burn-dataset/src/nlp/ag_news.rs | 211 + crates/burn-dataset/src/nlp/mod.rs | 7 + crates/burn-dataset/src/nlp/text_folder.rs | 421 + .../src/source/huggingface/downloader.rs | 367 + .../src/source/huggingface/importer.py | 207 + .../src/source/huggingface/mod.rs | 3 + crates/burn-dataset/src/source/mod.rs | 3 + crates/burn-dataset/src/transform/composed.rs | 56 + crates/burn-dataset/src/transform/mapper.rs | 60 + crates/burn-dataset/src/transform/mod.rs | 30 + crates/burn-dataset/src/transform/options.rs | 199 + crates/burn-dataset/src/transform/partial.rs | 206 + crates/burn-dataset/src/transform/sampler.rs | 438 + .../burn-dataset/src/transform/selection.rs | 374 + crates/burn-dataset/src/transform/shuffle.rs | 109 + crates/burn-dataset/src/transform/window.rs | 290 + crates/burn-dataset/src/vision/cifar.rs | 241 + .../burn-dataset/src/vision/image_folder.rs | 1124 ++ crates/burn-dataset/src/vision/mnist.rs | 221 + crates/burn-dataset/src/vision/mod.rs | 9 + .../burn-dataset/tests/data/dataset-fmt.csv | 2 + crates/burn-dataset/tests/data/dataset.csv | 3 + crates/burn-dataset/tests/data/dataset.json | 2 + .../burn-dataset/tests/data/dataset_coco.json | 132 + .../tests/data/image_folder/orange/dot.jpg | Bin 0 -> 727 bytes .../tests/data/image_folder/red/dot.jpg | Bin 0 -> 634 bytes .../tests/data/image_folder/red/dot.png | Bin 0 -> 120 bytes .../data/image_folder_coco/dot_triangle.jpg | Bin 0 -> 1566 bytes .../tests/data/image_folder_coco/one_dot.jpg | Bin 0 -> 1434 bytes .../two_dots_and_triangle.jpg | Bin 0 -> 1706 bytes .../annotations/mask_checkerboard.png | Bin 0 -> 117 bytes .../annotations/mask_checkerboard.txt | 8 + .../annotations/mask_random_2colors.png | Bin 0 -> 123 bytes .../annotations/mask_random_2colors.txt | 8 + .../annotations/mask_random_3colors.png | Bin 0 -> 137 bytes .../annotations/mask_random_3colors.txt | 8 + .../images/image_checkerboard.png | Bin 0 -> 165 bytes .../images/image_random_2colors.png | Bin 0 -> 133 bytes .../images/image_random_3colors.png | Bin 0 -> 204 bytes .../burn-dataset/tests/data/sqlite-dataset.db | Bin 0 -> 12288 bytes .../data/text_folder/negative/sample1.txt | 1 + .../data/text_folder/negative/sample2.txt | 1 + .../data/text_folder/positive/sample1.txt | 1 + .../data/text_folder/positive/sample2.txt | 1 + crates/burn-derive/Cargo.toml | 23 + crates/burn-derive/LICENSE-APACHE | 1 + crates/burn-derive/LICENSE-MIT | 1 + crates/burn-derive/README.md | 6 + crates/burn-derive/src/config/analyzer.rs | 87 + .../burn-derive/src/config/analyzer_enum.rs | 141 + .../burn-derive/src/config/analyzer_struct.rs | 380 + crates/burn-derive/src/config/base.rs | 24 + crates/burn-derive/src/config/mod.rs | 9 + crates/burn-derive/src/lib.rs | 88 + crates/burn-derive/src/module/base.rs | 35 + crates/burn-derive/src/module/codegen.rs | 217 + crates/burn-derive/src/module/codegen_enum.rs | 303 + .../burn-derive/src/module/codegen_struct.rs | 498 + crates/burn-derive/src/module/display.rs | 10 + crates/burn-derive/src/module/generics.rs | 129 + crates/burn-derive/src/module/mod.rs | 9 + crates/burn-derive/src/record_state/mod.rs | 257 + crates/burn-derive/src/shared/attribute.rs | 49 + crates/burn-derive/src/shared/enum_variant.rs | 51 + crates/burn-derive/src/shared/field.rs | 84 + crates/burn-derive/src/shared/generics.rs | 30 + crates/burn-derive/src/shared/mod.rs | 4 + crates/burn-dispatch/Cargo.toml | 118 + crates/burn-dispatch/README.md | 3 + crates/burn-dispatch/build.rs | 22 + crates/burn-dispatch/src/backend.rs | 940 ++ crates/burn-dispatch/src/device.rs | 654 + crates/burn-dispatch/src/lib.rs | 126 + crates/burn-dispatch/src/macros.rs | 1466 ++ crates/burn-dispatch/src/ops/activation.rs | 61 + crates/burn-dispatch/src/ops/bool_tensor.rs | 217 + crates/burn-dispatch/src/ops/distributed.rs | 171 + crates/burn-dispatch/src/ops/int_tensor.rs | 560 + crates/burn-dispatch/src/ops/mod.rs | 8 + crates/burn-dispatch/src/ops/module.rs | 722 + crates/burn-dispatch/src/ops/qtensor.rs | 210 + crates/burn-dispatch/src/ops/tensor.rs | 696 + crates/burn-dispatch/src/ops/transaction.rs | 26 + crates/burn-dispatch/src/remote_server.rs | 172 + crates/burn-dispatch/src/tensor.rs | 570 + crates/burn-flex/ACKNOWLEDGMENTS.md | 30 + crates/burn-flex/ARCHITECTURE.md | 788 + crates/burn-flex/BENCHMARKS.md | 976 ++ crates/burn-flex/COMPARISON.md | 624 + crates/burn-flex/Cargo.toml | 81 + crates/burn-flex/README.md | 187 + crates/burn-flex/src/backend.rs | 311 + crates/burn-flex/src/layout.rs | 660 + crates/burn-flex/src/lib.rs | 47 + crates/burn-flex/src/ops/activation.rs | 1464 ++ crates/burn-flex/src/ops/attention.rs | 1488 ++ crates/burn-flex/src/ops/binary.rs | 1124 ++ crates/burn-flex/src/ops/bool.rs | 559 + crates/burn-flex/src/ops/cat.rs | 119 + crates/burn-flex/src/ops/comparison.rs | 1041 ++ crates/burn-flex/src/ops/conv.rs | 2969 ++++ crates/burn-flex/src/ops/conv_common.rs | 110 + crates/burn-flex/src/ops/conv_transpose.rs | 474 + crates/burn-flex/src/ops/cumulative.rs | 264 + crates/burn-flex/src/ops/deform_conv.rs | 1021 ++ crates/burn-flex/src/ops/expand.rs | 312 + crates/burn-flex/src/ops/fft.rs | 2168 +++ crates/burn-flex/src/ops/flip.rs | 43 + crates/burn-flex/src/ops/float.rs | 1220 ++ crates/burn-flex/src/ops/gather_scatter.rs | 1251 ++ crates/burn-flex/src/ops/grid_sample.rs | 244 + crates/burn-flex/src/ops/int.rs | 1336 ++ crates/burn-flex/src/ops/interpolate.rs | 1187 ++ crates/burn-flex/src/ops/mask.rs | 318 + crates/burn-flex/src/ops/matmul.rs | 976 ++ crates/burn-flex/src/ops/mod.rs | 122 + crates/burn-flex/src/ops/module.rs | 776 + crates/burn-flex/src/ops/pool.rs | 1891 +++ crates/burn-flex/src/ops/qtensor.rs | 763 + crates/burn-flex/src/ops/reduce.rs | 2559 ++++ crates/burn-flex/src/ops/repeat_dim.rs | 54 + crates/burn-flex/src/ops/slice.rs | 690 + crates/burn-flex/src/ops/sort.rs | 715 + crates/burn-flex/src/ops/transaction.rs | 11 + crates/burn-flex/src/ops/unary.rs | 463 + crates/burn-flex/src/ops/unfold.rs | 118 + crates/burn-flex/src/qtensor.rs | 77 + crates/burn-flex/src/simd/aligned.rs | 70 + crates/burn-flex/src/simd/kernels.rs | 317 + crates/burn-flex/src/simd/mod.rs | 42 + crates/burn-flex/src/simd/portable.rs | 1387 ++ crates/burn-flex/src/simd/scalar.rs | 74 + crates/burn-flex/src/strided_index.rs | 99 + crates/burn-flex/src/tensor.rs | 1016 ++ crates/burn-fusion/Cargo.toml | 48 + crates/burn-fusion/LICENSE-APACHE | 1 + crates/burn-fusion/LICENSE-MIT | 1 + crates/burn-fusion/README.md | 3 + crates/burn-fusion/src/backend.rs | 319 + crates/burn-fusion/src/client.rs | 434 + crates/burn-fusion/src/inspect.rs | 521 + crates/burn-fusion/src/lib.rs | 36 + crates/burn-fusion/src/op.rs | 116 + crates/burn-fusion/src/ops/activation.rs | 4 + crates/burn-fusion/src/ops/base.rs | 15 + crates/burn-fusion/src/ops/binary.rs | 117 + crates/burn-fusion/src/ops/bool_tensor.rs | 945 ++ crates/burn-fusion/src/ops/distributed.rs | 67 + crates/burn-fusion/src/ops/int_tensor.rs | 2185 +++ crates/burn-fusion/src/ops/mod.rs | 13 + crates/burn-fusion/src/ops/module.rs | 1709 +++ crates/burn-fusion/src/ops/qtensor.rs | 502 + crates/burn-fusion/src/ops/tensor.rs | 2592 ++++ crates/burn-fusion/src/ops/transaction.rs | 36 + crates/burn-fusion/src/ops/unary.rs | 324 + crates/burn-fusion/src/search/block.rs | 639 + crates/burn-fusion/src/search/graph/dag.rs | 190 + .../burn-fusion/src/search/graph/lifetime.rs | 46 + crates/burn-fusion/src/search/graph/mod.rs | 18 + crates/burn-fusion/src/search/graph/node.rs | 69 + .../burn-fusion/src/search/graph/subgraph.rs | 125 + crates/burn-fusion/src/search/graph/tests.rs | 331 + crates/burn-fusion/src/search/merging.rs | 517 + crates/burn-fusion/src/search/mod.rs | 10 + .../src/search/optimization/blocks.rs | 162 + .../src/search/optimization/mod.rs | 7 + .../src/search/optimization/stream.rs | 827 + .../src/search/optimization/tests.rs | 387 + crates/burn-fusion/src/search/testing.rs | 41 + crates/burn-fusion/src/server.rs | 166 + crates/burn-fusion/src/stream/base.rs | 1 + crates/burn-fusion/src/stream/context.rs | 1792 +++ .../burn-fusion/src/stream/execution/base.rs | 16 + .../src/stream/execution/explorer.rs | 163 + .../burn-fusion/src/stream/execution/mod.rs | 20 + .../src/stream/execution/ordering.rs | 69 + .../src/stream/execution/policy.rs | 572 + .../src/stream/execution/processor.rs | 224 + .../burn-fusion/src/stream/execution/tests.rs | 765 + .../burn-fusion/src/stream/execution/trace.rs | 383 + .../src/stream/execution/validator.rs | 136 + .../burn-fusion/src/stream/memory_checks.rs | 247 + crates/burn-fusion/src/stream/mod.rs | 32 + crates/burn-fusion/src/stream/multi.rs | 301 + crates/burn-fusion/src/stream/queue/base.rs | 87 + .../burn-fusion/src/stream/queue/execution.rs | 125 + crates/burn-fusion/src/stream/queue/mod.rs | 4 + crates/burn-fusion/src/stream/store/base.rs | 98 + crates/burn-fusion/src/stream/store/index.rs | 296 + crates/burn-fusion/src/stream/store/mod.rs | 5 + crates/burn-fusion/src/tensor.rs | 266 + crates/burn-ir/Cargo.toml | 33 + crates/burn-ir/README.md | 7 + crates/burn-ir/src/backend.rs | 63 + crates/burn-ir/src/builder.rs | 1479 ++ crates/burn-ir/src/graph.rs | 39 + crates/burn-ir/src/handle.rs | 312 + crates/burn-ir/src/lib.rs | 23 + crates/burn-ir/src/operation.rs | 5132 +++++++ crates/burn-ir/src/scalar.rs | 77 + crates/burn-ir/src/tensor.rs | 72 + crates/burn-ndarray/Cargo.toml | 97 + crates/burn-ndarray/LICENSE-APACHE | 1 + crates/burn-ndarray/LICENSE-MIT | 1 + crates/burn-ndarray/README.md | 36 + crates/burn-ndarray/build.rs | 6 + crates/burn-ndarray/src/backend.rs | 211 + crates/burn-ndarray/src/element.rs | 207 + crates/burn-ndarray/src/lib.rs | 29 + crates/burn-ndarray/src/ops/activation.rs | 8 + .../burn-ndarray/src/ops/adaptive_avgpool.rs | 103 + crates/burn-ndarray/src/ops/avgpool.rs | 172 + crates/burn-ndarray/src/ops/base.rs | 1665 ++ crates/burn-ndarray/src/ops/bool_tensor.rs | 219 + crates/burn-ndarray/src/ops/conv.rs | 574 + crates/burn-ndarray/src/ops/deform_conv.rs | 662 + crates/burn-ndarray/src/ops/grid_sample.rs | 214 + crates/burn-ndarray/src/ops/int_tensor.rs | 519 + crates/burn-ndarray/src/ops/interpolate.rs | 397 + crates/burn-ndarray/src/ops/macros.rs | 107 + crates/burn-ndarray/src/ops/matmul.rs | 368 + crates/burn-ndarray/src/ops/maxpool.rs | 247 + crates/burn-ndarray/src/ops/mod.rs | 24 + crates/burn-ndarray/src/ops/module.rs | 396 + crates/burn-ndarray/src/ops/padding.rs | 72 + crates/burn-ndarray/src/ops/qtensor.rs | 343 + crates/burn-ndarray/src/ops/quantization.rs | 218 + crates/burn-ndarray/src/ops/simd/avgpool.rs | 443 + crates/burn-ndarray/src/ops/simd/base.rs | 115 + crates/burn-ndarray/src/ops/simd/binary.rs | 299 + .../src/ops/simd/binary_elemwise.rs | 419 + crates/burn-ndarray/src/ops/simd/cmp.rs | 374 + crates/burn-ndarray/src/ops/simd/conv.rs | 494 + crates/burn-ndarray/src/ops/simd/maxpool.rs | 395 + crates/burn-ndarray/src/ops/simd/mod.rs | 10 + crates/burn-ndarray/src/ops/simd/unary.rs | 234 + crates/burn-ndarray/src/ops/tensor.rs | 769 + crates/burn-ndarray/src/ops/transaction.rs | 8 + crates/burn-ndarray/src/parallel.rs | 76 + crates/burn-ndarray/src/rand.rs | 36 + crates/burn-ndarray/src/sharing.rs | 19 + crates/burn-ndarray/src/storage.rs | 506 + crates/burn-ndarray/src/tensor.rs | 971 ++ crates/burn-nn/Cargo.toml | 41 + crates/burn-nn/README.md | 3 + .../src/activation/activation_wrapper.rs | 621 + crates/burn-nn/src/activation/celu.rs | 101 + crates/burn-nn/src/activation/elu.rs | 82 + crates/burn-nn/src/activation/gelu.rs | 78 + crates/burn-nn/src/activation/glu.rs | 50 + crates/burn-nn/src/activation/hard_shrink.rs | 94 + crates/burn-nn/src/activation/hard_sigmoid.rs | 94 + crates/burn-nn/src/activation/hard_swish.rs | 53 + crates/burn-nn/src/activation/leaky_relu.rs | 121 + crates/burn-nn/src/activation/mod.rs | 68 + crates/burn-nn/src/activation/prelu.rs | 87 + crates/burn-nn/src/activation/relu.rs | 38 + crates/burn-nn/src/activation/selu.rs | 37 + crates/burn-nn/src/activation/shrink.rs | 110 + crates/burn-nn/src/activation/sigmoid.rs | 37 + crates/burn-nn/src/activation/soft_shrink.rs | 94 + crates/burn-nn/src/activation/softplus.rs | 102 + crates/burn-nn/src/activation/softsign.rs | 37 + crates/burn-nn/src/activation/swiglu.rs | 150 + crates/burn-nn/src/activation/tanh.rs | 37 + .../src/activation/thresholded_relu.rs | 79 + crates/burn-nn/src/lib.rs | 29 + .../burn-nn/src/loss/binary_cross_entropy.rs | 409 + crates/burn-nn/src/loss/cosine_embedding.rs | 294 + crates/burn-nn/src/loss/cross_entropy.rs | 514 + crates/burn-nn/src/loss/ctc.rs | 1417 ++ crates/burn-nn/src/loss/huber.rs | 208 + crates/burn-nn/src/loss/kldiv.rs | 200 + crates/burn-nn/src/loss/lp_loss.rs | 636 + crates/burn-nn/src/loss/mod.rs | 25 + crates/burn-nn/src/loss/mse.rs | 86 + crates/burn-nn/src/loss/poisson.rs | 405 + crates/burn-nn/src/loss/reduction.rs | 19 + crates/burn-nn/src/loss/rnnt.rs | 766 + crates/burn-nn/src/loss/smooth_l1.rs | 501 + .../src/modules/attention/cross_attention.rs | 618 + crates/burn-nn/src/modules/attention/mask.rs | 155 + crates/burn-nn/src/modules/attention/mha.rs | 578 + crates/burn-nn/src/modules/attention/mod.rs | 7 + .../src/modules/cache/autoregressive.rs | 51 + crates/burn-nn/src/modules/cache/base.rs | 26 + crates/burn-nn/src/modules/cache/mod.rs | 4 + crates/burn-nn/src/modules/conv/checks.rs | 22 + crates/burn-nn/src/modules/conv/conv1d.rs | 295 + crates/burn-nn/src/modules/conv/conv2d.rs | 346 + crates/burn-nn/src/modules/conv/conv3d.rs | 273 + .../src/modules/conv/conv_transpose1d.rs | 214 + .../src/modules/conv/conv_transpose2d.rs | 215 + .../src/modules/conv/conv_transpose3d.rs | 220 + .../burn-nn/src/modules/conv/deform_conv2d.rs | 293 + crates/burn-nn/src/modules/conv/mod.rs | 17 + crates/burn-nn/src/modules/dropout.rs | 118 + crates/burn-nn/src/modules/embedding.rs | 109 + crates/burn-nn/src/modules/identity.rs | 32 + .../src/modules/interpolate/interpolate1d.rs | 257 + .../src/modules/interpolate/interpolate2d.rs | 259 + crates/burn-nn/src/modules/interpolate/mod.rs | 64 + crates/burn-nn/src/modules/linear.rs | 317 + crates/burn-nn/src/modules/mod.rs | 41 + crates/burn-nn/src/modules/noise.rs | 116 + crates/burn-nn/src/modules/norm/batch.rs | 485 + crates/burn-nn/src/modules/norm/group.rs | 331 + crates/burn-nn/src/modules/norm/instance.rs | 223 + crates/burn-nn/src/modules/norm/layer.rs | 239 + .../src/modules/norm/local_response.rs | 678 + crates/burn-nn/src/modules/norm/mod.rs | 30 + .../src/modules/norm/normalization_wrapper.rs | 397 + crates/burn-nn/src/modules/norm/rms.rs | 131 + .../src/modules/pool/adaptive_avg_pool1d.rs | 76 + .../src/modules/pool/adaptive_avg_pool2d.rs | 78 + crates/burn-nn/src/modules/pool/avg_pool1d.rs | 219 + crates/burn-nn/src/modules/pool/avg_pool2d.rs | 222 + crates/burn-nn/src/modules/pool/max_pool1d.rs | 213 + crates/burn-nn/src/modules/pool/max_pool2d.rs | 218 + crates/burn-nn/src/modules/pool/mod.rs | 13 + crates/burn-nn/src/modules/pos_encoding.rs | 289 + crates/burn-nn/src/modules/rnn/basic.rs | 698 + .../src/modules/rnn/gate_controller.rs | 75 + crates/burn-nn/src/modules/rnn/gru.rs | 998 ++ crates/burn-nn/src/modules/rnn/lstm.rs | 856 ++ crates/burn-nn/src/modules/rnn/mod.rs | 15 + crates/burn-nn/src/modules/rope_encoding.rs | 627 + .../src/modules/transformer/decoder.rs | 571 + .../src/modules/transformer/encoder.rs | 489 + crates/burn-nn/src/modules/transformer/mod.rs | 7 + .../burn-nn/src/modules/transformer/pwff.rs | 128 + crates/burn-nn/src/modules/unfold.rs | 103 + crates/burn-nn/src/padding.rs | 247 + crates/burn-nn/tests/quantize.rs | 162 + crates/burn-no-std-tests/Cargo.toml | 26 + crates/burn-no-std-tests/README.md | 29 + crates/burn-no-std-tests/src/burnpack.rs | 158 + crates/burn-no-std-tests/src/conv.rs | 49 + crates/burn-no-std-tests/src/lib.rs | 9 + crates/burn-no-std-tests/src/mlp.rs | 67 + crates/burn-no-std-tests/src/model.rs | 66 + crates/burn-no-std-tests/src/safetensors.rs | 111 + .../burn-no-std-tests/tests/burnpack_tests.rs | 10 + .../tests/safetensors_tests.rs | 10 + .../tests/test_integration.rs | 28 + crates/burn-optim/Cargo.toml | 52 + crates/burn-optim/README.md | 3 + crates/burn-optim/src/grad_clipping/base.rs | 139 + crates/burn-optim/src/grad_clipping/mod.rs | 2 + crates/burn-optim/src/lib.rs | 28 + crates/burn-optim/src/lr_scheduler/base.rs | 312 + .../burn-optim/src/lr_scheduler/composed.rs | 162 + .../burn-optim/src/lr_scheduler/constant.rs | 42 + crates/burn-optim/src/lr_scheduler/cosine.rs | 204 + .../src/lr_scheduler/exponential.rs | 153 + crates/burn-optim/src/lr_scheduler/linear.rs | 186 + crates/burn-optim/src/lr_scheduler/mod.rs | 27 + .../src/lr_scheduler/module_lr_scheduler.rs | 381 + crates/burn-optim/src/lr_scheduler/noam.rs | 147 + crates/burn-optim/src/lr_scheduler/step.rs | 233 + crates/burn-optim/src/optim/adagrad.rs | 286 + crates/burn-optim/src/optim/adam.rs | 551 + crates/burn-optim/src/optim/adamw.rs | 586 + crates/burn-optim/src/optim/adan.rs | 448 + crates/burn-optim/src/optim/base.rs | 51 + crates/burn-optim/src/optim/decay.rs | 64 + crates/burn-optim/src/optim/grad_accum.rs | 119 + crates/burn-optim/src/optim/grads.rs | 130 + crates/burn-optim/src/optim/lbfgs.rs | 1075 ++ crates/burn-optim/src/optim/mod.rs | 34 + crates/burn-optim/src/optim/module/base.rs | 210 + crates/burn-optim/src/optim/module/mod.rs | 10 + .../src/optim/module/module_optimizer.rs | 749 + .../burn-optim/src/optim/module/record/mod.rs | 92 + crates/burn-optim/src/optim/momentum.rs | 94 + crates/burn-optim/src/optim/muon.rs | 762 + crates/burn-optim/src/optim/rmsprop.rs | 534 + crates/burn-optim/src/optim/sgd.rs | 176 + crates/burn-optim/src/optim/state.rs | 282 + crates/burn-optim/src/optim/visitor.rs | 57 + crates/burn-pack/Cargo.toml | 36 + crates/burn-pack/README.md | 38 + crates/burn-pack/src/base.rs | 390 + crates/burn-pack/src/lib.rs | 130 + crates/burn-pack/src/reader.rs | 382 + crates/burn-pack/src/tensor.rs | 56 + crates/burn-pack/src/writer.rs | 411 + crates/burn-pack/tests/alignment.rs | 61 + crates/burn-pack/tests/common/mod.rs | 44 + crates/burn-pack/tests/errors.rs | 95 + crates/burn-pack/tests/header.rs | 44 + crates/burn-pack/tests/round_trip.rs | 308 + crates/burn-remote/Cargo.toml | 81 + crates/burn-remote/README.md | 84 + crates/burn-remote/src/client/base.rs | 68 + crates/burn-remote/src/client/channel.rs | 70 + crates/burn-remote/src/client/custom_op.rs | 94 + crates/burn-remote/src/client/mod.rs | 11 + crates/burn-remote/src/client/runner.rs | 516 + crates/burn-remote/src/client/runtime.rs | 104 + crates/burn-remote/src/client/service.rs | 687 + .../burn-remote/src/client/service/batch.rs | 142 + crates/burn-remote/src/client/service/conn.rs | 190 + .../burn-remote/src/client/service/pending.rs | 201 + .../src/client/service/registry.rs | 144 + .../burn-remote/src/client/service/writer.rs | 137 + crates/burn-remote/src/lib.rs | 1004 ++ crates/burn-remote/src/metrics.rs | 148 + crates/burn-remote/src/server/builder.rs | 163 + crates/burn-remote/src/server/local_comm.rs | 93 + crates/burn-remote/src/server/mod.rs | 17 + crates/burn-remote/src/server/pump.rs | 143 + crates/burn-remote/src/server/service/base.rs | 72 + crates/burn-remote/src/server/service/mod.rs | 52 + crates/burn-remote/src/server/session.rs | 222 + crates/burn-remote/src/server/spawn.rs | 47 + crates/burn-remote/src/server/transfer.rs | 40 + crates/burn-remote/src/server/worker.rs | 457 + crates/burn-remote/src/shared/mod.rs | 4 + crates/burn-remote/src/shared/task.rs | 236 + crates/burn-remote/src/telemetry.rs | 531 + crates/burn-remote/src/transport/identity.rs | 109 + crates/burn-remote/src/transport/iroh/link.rs | 27 + crates/burn-remote/src/transport/iroh/mod.rs | 21 + crates/burn-remote/src/transport/iroh/node.rs | 247 + .../src/transport/iroh/protocol.rs | 201 + .../burn-remote/src/transport/iroh/secret.rs | 35 + .../burn-remote/src/transport/iroh/server.rs | 54 + crates/burn-remote/src/transport/iroh/time.rs | 29 + .../src/transport/iroh/transfer.rs | 241 + crates/burn-remote/src/transport/link.rs | 44 + crates/burn-remote/src/transport/mod.rs | 18 + .../src/transport/websocket/link.rs | 59 + .../src/transport/websocket/mod.rs | 17 + .../src/transport/websocket/server.rs | 69 + .../src/transport/websocket/transfer.rs | 74 + crates/burn-remote/tests/iroh.rs | 210 + crates/burn-rl/Cargo.toml | 29 + crates/burn-rl/LICENSE-APACHE | 1 + crates/burn-rl/LICENSE-MIT | 1 + crates/burn-rl/README.md | 6 + crates/burn-rl/src/environment/base.rs | 46 + crates/burn-rl/src/environment/mod.rs | 3 + crates/burn-rl/src/lib.rs | 163 + crates/burn-rl/src/policy/async_policy.rs | 486 + crates/burn-rl/src/policy/base.rs | 122 + crates/burn-rl/src/policy/mod.rs | 5 + crates/burn-rl/src/transition_buffer/base.rs | 239 + crates/burn-rl/src/transition_buffer/mod.rs | 5 + .../src/transition_buffer/slice_access.rs | 36 + crates/burn-rocm/Cargo.toml | 44 + crates/burn-rocm/README.md | 7 + crates/burn-rocm/src/lib.rs | 14 + crates/burn-router/Cargo.toml | 53 + crates/burn-router/README.md | 3 + crates/burn-router/src/backend.rs | 69 + crates/burn-router/src/bridge/base.rs | 32 + crates/burn-router/src/bridge/mod.rs | 3 + crates/burn-router/src/channel/base.rs | 60 + crates/burn-router/src/channel/mod.rs | 3 + crates/burn-router/src/client/base.rs | 161 + crates/burn-router/src/client/mod.rs | 3 + crates/burn-router/src/custom_op.rs | 152 + crates/burn-router/src/fusion.rs | 576 + crates/burn-router/src/graph.rs | 137 + crates/burn-router/src/interpreter.rs | 2170 +++ crates/burn-router/src/lib.rs | 31 + crates/burn-router/src/ops/activation.rs | 166 + crates/burn-router/src/ops/binary.rs | 69 + crates/burn-router/src/ops/bool_tensor.rs | 433 + crates/burn-router/src/ops/distributed.rs | 42 + crates/burn-router/src/ops/int_tensor.rs | 1312 ++ crates/burn-router/src/ops/mod.rs | 10 + crates/burn-router/src/ops/module.rs | 1068 ++ crates/burn-router/src/ops/qtensor.rs | 64 + crates/burn-router/src/ops/tensor.rs | 1593 ++ crates/burn-router/src/ops/transaction.rs | 5 + crates/burn-router/src/ops/unary.rs | 160 + crates/burn-router/src/tensor.rs | 158 + crates/burn-std/Cargo.toml | 73 + crates/burn-std/LICENSE-APACHE | 1 + crates/burn-std/LICENSE-MIT | 1 + crates/burn-std/README.md | 7 + crates/burn-std/src/config/autodiff.rs | 43 + crates/burn-std/src/config/base.rs | 104 + crates/burn-std/src/config/fusion.rs | 117 + crates/burn-std/src/config/logger.rs | 186 + crates/burn-std/src/config/mod.rs | 14 + crates/burn-std/src/config/remote.rs | 75 + crates/burn-std/src/data/compare.rs | 428 + crates/burn-std/src/data/mod.rs | 5 + crates/burn-std/src/data/tensor.rs | 936 ++ crates/burn-std/src/device_settings.rs | 125 + crates/burn-std/src/distributed.rs | 17 + crates/burn-std/src/distribution.rs | 125 + crates/burn-std/src/element/base.rs | 290 + crates/burn-std/src/element/cast.rs | 705 + crates/burn-std/src/element/mod.rs | 10 + crates/burn-std/src/element/scalar.rs | 111 + crates/burn-std/src/id.rs | 193 + crates/burn-std/src/lib.rs | 88 + crates/burn-std/src/network.rs | 57 + crates/burn-std/src/ops.rs | 480 + crates/burn-std/src/runtime_kind.rs | 62 + crates/burn-std/src/tensor/container.rs | 86 + crates/burn-std/src/tensor/dtype.rs | 408 + crates/burn-std/src/tensor/matmul.rs | 352 + crates/burn-std/src/tensor/mod.rs | 336 + crates/burn-std/src/tensor/quantization.rs | 432 + crates/burn-std/src/tensor/shape.rs | 271 + crates/burn-std/src/tensor/slice.rs | 937 ++ crates/burn-std/tests/config.rs | 80 + crates/burn-store/Cargo.toml | 97 + crates/burn-store/MIGRATION.md | 325 + crates/burn-store/README.md | 90 + .../burn-store/benches/download_resnet18.py | 82 + .../benches/generate_unified_models.py | 175 + crates/burn-store/benches/resnet18_loading.rs | 213 + crates/burn-store/benches/unified_loading.rs | 321 + crates/burn-store/benches/unified_saving.rs | 175 + .../burn-store/benches/zero_copy_loading.rs | 483 + .../burn-store/examples/burnpack_inspect.rs | 144 + crates/burn-store/examples/half_precision.rs | 86 + crates/burn-store/pytorch-tests/Cargo.toml | 11 + crates/burn-store/pytorch-tests/src/lib.rs | 1 + .../tests/batch_norm/batch_norm2d.pt | Bin 0 -> 2361 bytes .../tests/batch_norm/export_weights.py | 41 + .../pytorch-tests/tests/batch_norm/mod.rs | 61 + .../pytorch-tests/tests/boolean/boolean.pt | Bin 0 -> 1244 bytes .../tests/boolean/export_weights.py | 38 + .../pytorch-tests/tests/boolean/mod.rs | 53 + .../pytorch-tests/tests/buffer/buffer.pt | Bin 0 -> 1239 bytes .../tests/buffer/export_weights.py | 38 + .../pytorch-tests/tests/buffer/mod.rs | 52 + .../tests/complex_nested/complex_nested.pt | Bin 0 -> 14606 bytes .../tests/complex_nested/export_weights.py | 69 + .../pytorch-tests/tests/complex_nested/mod.rs | 234 + .../tests/config/export_weights.py | 60 + .../pytorch-tests/tests/config/mod.rs | 53 + .../tests/config/weights_with_config.pt | Bin 0 -> 2280 bytes .../pytorch-tests/tests/conv1d/conv1d.pt | Bin 0 -> 1805 bytes .../tests/conv1d/export_weights.py | 39 + .../pytorch-tests/tests/conv1d/mod.rs | 97 + .../pytorch-tests/tests/conv2d/conv2d.pt | Bin 0 -> 1805 bytes .../tests/conv2d/export_weights.py | 39 + .../pytorch-tests/tests/conv2d/mod.rs | 133 + .../conv_transpose1d/conv_transpose1d.pt | Bin 0 -> 1875 bytes .../tests/conv_transpose1d/export_weights.py | 39 + .../tests/conv_transpose1d/mod.rs | 86 + .../conv_transpose2d/conv_transpose2d.pt | Bin 0 -> 2003 bytes .../tests/conv_transpose2d/export_weights.py | 39 + .../tests/conv_transpose2d/mod.rs | 98 + .../pytorch-tests/tests/debug_test.pt | Bin 0 -> 1598 bytes .../tests/embedding/embedding.pt | Bin 0 -> 1382 bytes .../tests/embedding/export_weights.py | 37 + .../pytorch-tests/tests/embedding/mod.rs | 86 + .../tests/enum_module/enum_depthwise_false.pt | Bin 0 -> 1766 bytes .../tests/enum_module/enum_depthwise_true.pt | Bin 0 -> 2288 bytes .../tests/enum_module/export_weights.py | 60 + .../pytorch-tests/tests/enum_module/mod.rs | 196 + .../tests/group_norm/export_weights.py | 37 + .../tests/group_norm/group_norm.pt | Bin 0 -> 1578 bytes .../pytorch-tests/tests/group_norm/mod.rs | 90 + .../tests/integer/export_weights.py | 38 + .../pytorch-tests/tests/integer/integer.pt | Bin 0 -> 1244 bytes .../pytorch-tests/tests/integer/mod.rs | 76 + .../tests/key_remap/export_weights.py | 48 + .../tests/key_remap/key_remap.pt | Bin 0 -> 2018 bytes .../pytorch-tests/tests/key_remap/mod.rs | 117 + .../tests/key_remap_chained/export_weights.py | 57 + .../tests/key_remap_chained/key_remap.pt | Bin 0 -> 7052 bytes .../tests/key_remap_chained/mod.rs | 172 + .../tests/layer_norm/export_weights.py | 37 + .../tests/layer_norm/layer_norm.pt | Bin 0 -> 1578 bytes .../pytorch-tests/tests/layer_norm/mod.rs | 81 + .../tests/linear/export_weights.py | 60 + .../pytorch-tests/tests/linear/linear.pt | Bin 0 -> 1805 bytes .../tests/linear/linear_with_bias.pt | Bin 0 -> 1614 bytes .../pytorch-tests/tests/linear/mod.rs | 153 + .../missing_module_field/export_weights.py | 24 + .../missing_module_field.pt | Bin 0 -> 1702 bytes .../tests/missing_module_field/mod.rs | 36 + .../non_contiguous_indexes/export_weights.py | 42 + .../tests/non_contiguous_indexes/mod.rs | 109 + .../non_contiguous_indexes.pt | Bin 0 -> 4746 bytes .../pytorch-tests/tests/test_int.pt | Bin 0 -> 1584 bytes .../pytorch-tests/tests/test_mod.rs | 20 + .../tests/top_level_key/export_weights.py | 24 + .../pytorch-tests/tests/top_level_key/mod.rs | 47 + .../tests/top_level_key/top_level_key.pt | Bin 0 -> 1660 bytes .../burn-store/safetensors-tests/Cargo.toml | 13 + .../burn-store/safetensors-tests/src/lib.rs | 1 + .../tests/multi_layer/mod.rs | 90 + .../tests/multi_layer/multi_layer.py | 49 + .../tests/multi_layer/multi_layer.safetensors | Bin 0 -> 17624 bytes .../safetensors-tests/tests/test_mod.rs | 1 + crates/burn-store/src/adapter.rs | 1022 ++ crates/burn-store/src/applier.rs | 585 + crates/burn-store/src/apply_result.rs | 300 + crates/burn-store/src/bridge.rs | 51 + crates/burn-store/src/burnpack.rs | 512 + crates/burn-store/src/collector.rs | 1137 ++ crates/burn-store/src/filter.rs | 625 + crates/burn-store/src/keyremapper.rs | 674 + crates/burn-store/src/lib.rs | 124 + crates/burn-store/src/nested/adapter.rs | 83 + crates/burn-store/src/nested/data.rs | 235 + crates/burn-store/src/nested/de.rs | 1006 ++ crates/burn-store/src/nested/error.rs | 31 + crates/burn-store/src/nested/mod.rs | 14 + crates/burn-store/src/pytorch/lazy_data.rs | 570 + crates/burn-store/src/pytorch/mod.rs | 48 + .../burn-store/src/pytorch/pickle_reader.rs | 1538 ++ crates/burn-store/src/pytorch/reader.rs | 1125 ++ crates/burn-store/src/pytorch/store.rs | 439 + crates/burn-store/src/pytorch/tests/mod.rs | 2 + .../reader/create_legacy_with_offsets.py | 76 + .../pytorch/tests/reader/create_tar_format.py | 361 + .../src/pytorch/tests/reader/mod.rs | 1278 ++ .../src/pytorch/tests/reader/simple_legacy.py | 39 + .../src/pytorch/tests/reader/test_data.py | 227 + .../tests/reader/test_data/bfloat16.pt | Bin 0 -> 1584 bytes .../pytorch/tests/reader/test_data/bool.pt | Bin 0 -> 1556 bytes .../pytorch/tests/reader/test_data/broken.pt | 1 + .../pytorch/tests/reader/test_data/buffers.pt | Bin 0 -> 1893 bytes .../tests/reader/test_data/checkpoint.pt | Bin 0 -> 3258 bytes .../reader/test_data/complex_structure.pt | Bin 0 -> 3149 bytes .../pytorch/tests/reader/test_data/empty.pt | Bin 0 -> 1499 bytes .../tests/reader/test_data/extreme_values.pt | Bin 0 -> 1626 bytes .../pytorch/tests/reader/test_data/float16.pt | Bin 0 -> 1577 bytes .../pytorch/tests/reader/test_data/float32.pt | Bin 0 -> 1577 bytes .../pytorch/tests/reader/test_data/float64.pt | Bin 0 -> 1577 bytes .../pytorch/tests/reader/test_data/int16.pt | Bin 0 -> 1563 bytes .../pytorch/tests/reader/test_data/int32.pt | Bin 0 -> 1563 bytes .../pytorch/tests/reader/test_data/int64.pt | Bin 0 -> 1563 bytes .../pytorch/tests/reader/test_data/int8.pt | Bin 0 -> 1556 bytes .../tests/reader/test_data/large_shape.pt | Bin 0 -> 41605 bytes .../reader/test_data/legacy_shared_storage.pt | Bin 0 -> 729 bytes .../reader/test_data/legacy_uncloned_views.pt | Bin 0 -> 833 bytes .../reader/test_data/legacy_with_offsets.pt | Bin 0 -> 655 bytes .../tests/reader/test_data/mixed_types.pt | Bin 0 -> 2437 bytes .../tests/reader/test_data/nested_dict.pt | Bin 0 -> 2437 bytes .../tests/reader/test_data/parameter.pt | Bin 0 -> 1655 bytes .../pytorch/tests/reader/test_data/scalar.pt | Bin 0 -> 1570 bytes .../tests/reader/test_data/simple_legacy.pt | Bin 0 -> 614 bytes .../tests/reader/test_data/special_values.pt | Bin 0 -> 1626 bytes .../tests/reader/test_data/state_dict.pt | Bin 0 -> 2363 bytes .../tests/reader/test_data/tar_2d_tensor.tar | Bin 0 -> 10240 bytes .../tests/reader/test_data/tar_float32.tar | Bin 0 -> 10240 bytes .../tests/reader/test_data/tar_float64.tar | Bin 0 -> 10240 bytes .../tests/reader/test_data/tar_int64.tar | Bin 0 -> 10240 bytes .../reader/test_data/tar_multi_dtype.tar | Bin 0 -> 10240 bytes .../reader/test_data/tar_weight_bias.tar | Bin 0 -> 10240 bytes .../tests/reader/test_data/tensor_2d.pt | Bin 0 -> 1591 bytes .../tests/reader/test_data/tensor_3d.pt | Bin 0 -> 1655 bytes .../tests/reader/test_data/tensor_4d.pt | Bin 0 -> 1655 bytes .../pytorch/tests/reader/test_data/uint8.pt | Bin 0 -> 1563 bytes .../burn-store/src/pytorch/tests/store/mod.rs | 1202 ++ .../store/test_data/generate_enum_test.py | 81 + .../test_data/model_without_enum_variants.pt | Bin 0 -> 6373 bytes crates/burn-store/src/safetensors/mod.rs | 322 + crates/burn-store/src/safetensors/store.rs | 1087 ++ .../src/safetensors/tests/adapter.rs | 346 + .../src/safetensors/tests/direct_access.rs | 338 + .../src/safetensors/tests/error_handling.rs | 45 + .../src/safetensors/tests/file_io.rs | 273 + .../src/safetensors/tests/filtering.rs | 167 + .../src/safetensors/tests/integration.rs | 169 + .../src/safetensors/tests/metadata.rs | 101 + .../src/safetensors/tests/mixed_datatypes.rs | 436 + .../burn-store/src/safetensors/tests/mod.rs | 12 + .../safetensors/tests/multi_layer_verify.rs | 111 + .../src/safetensors/tests/pytorch_import.rs | 201 + .../src/safetensors/tests/round_trip.rs | 103 + crates/burn-store/src/tensor_snapshot.rs | 617 + crates/burn-store/src/traits.rs | 281 + crates/burn-tch/Cargo.toml | 38 + crates/burn-tch/LICENSE-APACHE | 1 + crates/burn-tch/LICENSE-MIT | 1 + crates/burn-tch/README.md | 246 + crates/burn-tch/build.rs | 243 + crates/burn-tch/src/backend.rs | 184 + crates/burn-tch/src/bin/cpu.rs | 14 + crates/burn-tch/src/bin/cuda.rs | 19 + crates/burn-tch/src/bin/mps.rs | 16 + .../src/cuda_hack/dummy_cuda_dependency.cpp | 28 + .../src/cuda_hack/fake_cuda_dependency.cpp | 5 + crates/burn-tch/src/element.rs | 51 + crates/burn-tch/src/lib.rs | 14 + crates/burn-tch/src/ops/activation.rs | 61 + crates/burn-tch/src/ops/base.rs | 837 + crates/burn-tch/src/ops/bool_tensor.rs | 221 + crates/burn-tch/src/ops/int_tensor.rs | 540 + crates/burn-tch/src/ops/mod.rs | 10 + crates/burn-tch/src/ops/module.rs | 642 + crates/burn-tch/src/ops/qtensor.rs | 129 + crates/burn-tch/src/ops/tensor.rs | 576 + crates/burn-tch/src/ops/transaction.rs | 9 + crates/burn-tch/src/tensor.rs | 511 + crates/burn-tensor/Cargo.toml | 80 + crates/burn-tensor/LICENSE-APACHE | 1 + crates/burn-tensor/LICENSE-MIT | 1 + crates/burn-tensor/README.md | 12 + crates/burn-tensor/katex-header.html | 1 + crates/burn-tensor/src/bridge/kind.rs | 365 + crates/burn-tensor/src/bridge/mod.rs | 5 + crates/burn-tensor/src/bridge/ops/autodiff.rs | 32 + crates/burn-tensor/src/bridge/ops/base.rs | 727 + crates/burn-tensor/src/bridge/ops/bool.rs | 256 + crates/burn-tensor/src/bridge/ops/float.rs | 1025 ++ crates/burn-tensor/src/bridge/ops/int.rs | 493 + crates/burn-tensor/src/bridge/ops/math.rs | 298 + crates/burn-tensor/src/bridge/ops/mod.rs | 14 + crates/burn-tensor/src/bridge/ops/numeric.rs | 499 + crates/burn-tensor/src/bridge/ops/ordered.rs | 643 + crates/burn-tensor/src/device.rs | 1060 ++ crates/burn-tensor/src/lib.rs | 59 + crates/burn-tensor/src/server.rs | 114 + .../burn-tensor/src/tensor/activation/base.rs | 658 + .../burn-tensor/src/tensor/activation/mod.rs | 3 + crates/burn-tensor/src/tensor/api/autodiff.rs | 117 + crates/burn-tensor/src/tensor/api/base.rs | 3454 +++++ crates/burn-tensor/src/tensor/api/bool.rs | 508 + .../src/tensor/api/cartesian_grid.rs | 56 + crates/burn-tensor/src/tensor/api/cast.rs | 144 + crates/burn-tensor/src/tensor/api/check.rs | 1753 +++ .../burn-tensor/src/tensor/api/extension.rs | 463 + crates/burn-tensor/src/tensor/api/float.rs | 1276 ++ crates/burn-tensor/src/tensor/api/fmod.rs | 106 + crates/burn-tensor/src/tensor/api/graph.rs | 153 + crates/burn-tensor/src/tensor/api/int.rs | 261 + crates/burn-tensor/src/tensor/api/mod.rs | 35 + crates/burn-tensor/src/tensor/api/numeric.rs | 1127 ++ crates/burn-tensor/src/tensor/api/options.rs | 98 + .../burn-tensor/src/tensor/api/orderable.rs | 1132 ++ crates/burn-tensor/src/tensor/api/pad.rs | 345 + crates/burn-tensor/src/tensor/api/take.rs | 96 + .../burn-tensor/src/tensor/api/transaction.rs | 86 + crates/burn-tensor/src/tensor/api/trunc.rs | 43 + crates/burn-tensor/src/tensor/distributed.rs | 92 + .../src/tensor/grid/affine_grid.rs | 59 + .../burn-tensor/src/tensor/grid/meshgrid.rs | 104 + crates/burn-tensor/src/tensor/grid/mod.rs | 68 + crates/burn-tensor/src/tensor/kind.rs | 33 + .../src/tensor/linalg/cosine_similarity.rs | 51 + crates/burn-tensor/src/tensor/linalg/det.rs | 179 + crates/burn-tensor/src/tensor/linalg/diag.rs | 41 + crates/burn-tensor/src/tensor/linalg/lu.rs | 521 + .../src/tensor/linalg/lu_decomposition.rs | 78 + .../burn-tensor/src/tensor/linalg/matvec.rs | 60 + crates/burn-tensor/src/tensor/linalg/mod.rs | 17 + crates/burn-tensor/src/tensor/linalg/outer.rs | 75 + crates/burn-tensor/src/tensor/linalg/trace.rs | 23 + .../src/tensor/linalg/vector_norm.rs | 281 + crates/burn-tensor/src/tensor/loss/mod.rs | 22 + crates/burn-tensor/src/tensor/mod.rs | 56 + crates/burn-tensor/src/tensor/module.rs | 671 + crates/burn-tensor/src/tensor/quantization.rs | 57 + crates/burn-tensor/src/tensor/report.rs | 105 + .../src/tensor/signal/blackman_window.rs | 92 + crates/burn-tensor/src/tensor/signal/fft.rs | 280 + .../src/tensor/signal/hamming_window.rs | 67 + .../src/tensor/signal/hann_window.rs | 64 + crates/burn-tensor/src/tensor/signal/mod.rs | 11 + crates/burn-tensor/src/tensor/signal/stft.rs | 300 + crates/burn-tensor/src/tensor/stats/mod.rs | 69 + crates/burn-train/Cargo.toml | 72 + crates/burn-train/LICENSE-APACHE | 1 + crates/burn-train/LICENSE-MIT | 1 + crates/burn-train/README.md | 6 + .../src/checkpoint/async_checkpoint.rs | 158 + crates/burn-train/src/checkpoint/base.rs | 123 + crates/burn-train/src/checkpoint/file.rs | 67 + crates/burn-train/src/checkpoint/mod.rs | 9 + .../src/checkpoint/strategy/base.rs | 34 + .../src/checkpoint/strategy/composed.rs | 146 + .../src/checkpoint/strategy/lastn.rs | 56 + .../src/checkpoint/strategy/metric.rs | 143 + .../burn-train/src/checkpoint/strategy/mod.rs | 9 + crates/burn-train/src/components.rs | 22 + crates/burn-train/src/evaluator/base.rs | 93 + crates/burn-train/src/evaluator/builder.rs | 231 + crates/burn-train/src/evaluator/components.rs | 21 + crates/burn-train/src/evaluator/mod.rs | 7 + .../src/learner/application_logger.rs | 69 + crates/burn-train/src/learner/base.rs | 261 + .../burn-train/src/learner/classification.rs | 147 + .../burn-train/src/learner/early_stopping.rs | 301 + crates/burn-train/src/learner/mod.rs | 26 + crates/burn-train/src/learner/regression.rs | 42 + .../burn-train/src/learner/rl/checkpointer.rs | 77 + .../burn-train/src/learner/rl/components.rs | 110 + .../src/learner/rl/env_runner/async_runner.rs | 735 + .../src/learner/rl/env_runner/base.rs | 355 + .../src/learner/rl/env_runner/mod.rs | 302 + crates/burn-train/src/learner/rl/mod.rs | 15 + .../burn-train/src/learner/rl/off_policy.rs | 190 + crates/burn-train/src/learner/rl/output.rs | 30 + crates/burn-train/src/learner/rl/paradigm.rs | 542 + crates/burn-train/src/learner/rl/strategy.rs | 92 + crates/burn-train/src/learner/sequence.rs | 127 + crates/burn-train/src/learner/sharder.rs | 24 + crates/burn-train/src/learner/summary.rs | 475 + .../burn-train/src/learner/supervised/mod.rs | 7 + .../src/learner/supervised/paradigm.rs | 560 + .../src/learner/supervised/step/mod.rs | 2 + .../src/learner/supervised/step/train.rs | 150 + .../src/learner/supervised/strategies/base.rs | 172 + .../supervised/strategies/ddp/README.md | 17 + .../supervised/strategies/ddp/epoch.rs | 138 + .../learner/supervised/strategies/ddp/mod.rs | 5 + .../supervised/strategies/ddp/strategy.rs | 146 + .../supervised/strategies/ddp/worker.rs | 142 + .../src/learner/supervised/strategies/mod.rs | 7 + .../supervised/strategies/multi/epoch.rs | 205 + .../supervised/strategies/multi/mod.rs | 4 + .../supervised/strategies/multi/strategy.rs | 109 + .../supervised/strategies/single/epoch.rs | 126 + .../supervised/strategies/single/mod.rs | 4 + .../supervised/strategies/single/strategy.rs | 118 + crates/burn-train/src/learner/train_val.rs | 133 + crates/burn-train/src/lib.rs | 112 + crates/burn-train/src/logger/async_logger.rs | 91 + crates/burn-train/src/logger/base.rs | 9 + crates/burn-train/src/logger/file.rs | 46 + crates/burn-train/src/logger/in_memory.rs | 16 + crates/burn-train/src/logger/metric.rs | 385 + crates/burn-train/src/logger/mod.rs | 13 + crates/burn-train/src/logger/progress.rs | 110 + crates/burn-train/src/metric/acc.rs | 151 + crates/burn-train/src/metric/auc_pr.rs | 310 + crates/burn-train/src/metric/auroc.rs | 335 + crates/burn-train/src/metric/base.rs | 285 + crates/burn-train/src/metric/bleu.rs | 538 + crates/burn-train/src/metric/cer.rs | 241 + .../burn-train/src/metric/classification.rs | 33 + .../burn-train/src/metric/confusion_stats.rs | 340 + crates/burn-train/src/metric/cpu_temp.rs | 77 + crates/burn-train/src/metric/cpu_use.rs | 104 + crates/burn-train/src/metric/cuda.rs | 108 + crates/burn-train/src/metric/fbetascore.rs | 245 + crates/burn-train/src/metric/hamming.rs | 190 + crates/burn-train/src/metric/iteration.rs | 90 + crates/burn-train/src/metric/learning_rate.rs | 69 + crates/burn-train/src/metric/loss.rs | 87 + crates/burn-train/src/metric/memory_use.rs | 112 + crates/burn-train/src/metric/mod.rs | 77 + crates/burn-train/src/metric/perplexity.rs | 429 + crates/burn-train/src/metric/precision.rs | 217 + .../src/metric/processor/async_wrapper.rs | 142 + .../burn-train/src/metric/processor/base.rs | 134 + .../burn-train/src/metric/processor/full.rs | 333 + .../src/metric/processor/metrics.rs | 309 + .../src/metric/processor/minimal.rs | 141 + crates/burn-train/src/metric/processor/mod.rs | 85 + .../src/metric/processor/rl_metrics.rs | 268 + .../src/metric/processor/rl_processor.rs | 215 + crates/burn-train/src/metric/recall.rs | 212 + crates/burn-train/src/metric/rl/cum_reward.rs | 79 + crates/burn-train/src/metric/rl/ep_len.rs | 72 + .../src/metric/rl/exploration_rate.rs | 79 + crates/burn-train/src/metric/rl/mod.rs | 7 + crates/burn-train/src/metric/rouge.rs | 282 + crates/burn-train/src/metric/state.rs | 219 + .../burn-train/src/metric/store/aggregate.rs | 312 + crates/burn-train/src/metric/store/base.rs | 117 + crates/burn-train/src/metric/store/client.rs | 171 + crates/burn-train/src/metric/store/log.rs | 75 + crates/burn-train/src/metric/store/mod.rs | 9 + crates/burn-train/src/metric/top_k_acc.rs | 176 + .../src/metric/vision/afine/calibrators.rs | 246 + .../src/metric/vision/afine/clip_attention.rs | 134 + .../src/metric/vision/afine/clip_vit.rs | 362 + .../src/metric/vision/afine/heads.rs | 377 + .../src/metric/vision/afine/metric.rs | 361 + .../burn-train/src/metric/vision/afine/mod.rs | 19 + .../src/metric/vision/afine/quick_gelu.rs | 65 + .../src/metric/vision/afine/weights.rs | 257 + crates/burn-train/src/metric/vision/dice.rs | 341 + .../src/metric/vision/dists/l2pool.rs | 160 + .../src/metric/vision/dists/metric.rs | 487 + .../burn-train/src/metric/vision/dists/mod.rs | 14 + .../src/metric/vision/dists/vgg16_l2pool.rs | 169 + .../src/metric/vision/dists/weights.rs | 125 + .../src/metric/vision/fid/inception.rs | 447 + .../src/metric/vision/fid/metric.rs | 345 + .../burn-train/src/metric/vision/fid/mod.rs | 13 + .../src/metric/vision/fid/weights.rs | 70 + .../src/metric/vision/lpips/alexnet.rs | 100 + .../src/metric/vision/lpips/metric.rs | 790 + .../burn-train/src/metric/vision/lpips/mod.rs | 21 + .../src/metric/vision/lpips/squeezenet.rs | 157 + .../burn-train/src/metric/vision/lpips/vgg.rs | 116 + .../src/metric/vision/lpips/weights.rs | 225 + crates/burn-train/src/metric/vision/mod.rs | 17 + .../burn-train/src/metric/vision/ms_ssim.rs | 863 ++ crates/burn-train/src/metric/vision/psnr.rs | 590 + crates/burn-train/src/metric/vision/ssim.rs | 872 ++ crates/burn-train/src/metric/wer.rs | 226 + crates/burn-train/src/renderer/base.rs | 118 + crates/burn-train/src/renderer/cli.rs | 112 + crates/burn-train/src/renderer/mod.rs | 34 + crates/burn-train/src/renderer/tui/base.rs | 106 + .../burn-train/src/renderer/tui/controls.rs | 46 + .../src/renderer/tui/full_history.rs | 295 + .../src/renderer/tui/metric_numeric.rs | 819 + .../src/renderer/tui/metric_text.rs | 290 + crates/burn-train/src/renderer/tui/mod.rs | 23 + .../burn-train/src/renderer/tui/plot_utils.rs | 37 + crates/burn-train/src/renderer/tui/popup.rs | 146 + .../burn-train/src/renderer/tui/progress.rs | 296 + .../src/renderer/tui/recent_history.rs | 249 + .../burn-train/src/renderer/tui/renderer.rs | 640 + crates/burn-train/src/renderer/tui/status.rs | 151 + crates/burn-train/tests/checkpoint.rs | 261 + crates/burn-train/tests/common/mod.rs | 164 + crates/burn-train/tests/lr_policy.rs | 70 + crates/burn-vision/Cargo.toml | 75 + crates/burn-vision/src/backends/cpu/base.rs | 42 + .../src/backends/cpu/connected_components.rs | 249 + .../Spaghetti_center_line_forest_code.rs | 1954 +++ .../Spaghetti_first_line_forest_code.rs | 223 + .../spaghetti/Spaghetti_forest_labels.rs | 191 + .../Spaghetti_last_line_forest_code.rs | 787 + .../Spaghetti_single_line_forest_code.rs | 91 + .../cpu/connected_components/spaghetti/mod.rs | 273 + .../Spaghetti4C_center_line_forest_code.rs | 42 + .../Spaghetti4C_first_line_forest_code.rs | 31 + .../spaghetti_4c/Spaghetti4C_forest_labels.rs | 21 + .../connected_components/spaghetti_4c/mod.rs | 102 + crates/burn-vision/src/backends/cpu/mod.rs | 10 + .../src/backends/cpu/morphology/filter.rs | 720 + .../backends/cpu/morphology/filter_engine.rs | 415 + .../src/backends/cpu/morphology/mod.rs | 290 + crates/burn-vision/src/backends/cpu/nms.rs | 211 + crates/burn-vision/src/backends/cpu/ops.rs | 21 + .../hardware_accelerated.rs | 635 + .../backends/cube/connected_components/mod.rs | 67 + .../cube/connected_components/prefix_sum.rs | 268 + crates/burn-vision/src/backends/cube/mod.rs | 2 + crates/burn-vision/src/backends/cube/ops.rs | 214 + crates/burn-vision/src/backends/mod.rs | 5 + crates/burn-vision/src/base.rs | 19 + crates/burn-vision/src/lib.rs | 112 + .../src/loss/gram_matrix/gram_matrix_loss.rs | 541 + .../burn-vision/src/loss/gram_matrix/mod.rs | 5 + .../burn-vision/src/loss/gram_matrix/vgg19.rs | 155 + .../src/loss/gram_matrix/weights.rs | 102 + crates/burn-vision/src/loss/mod.rs | 2 + crates/burn-vision/src/ops/base.rs | 385 + crates/burn-vision/src/ops/mod.rs | 3 + crates/burn-vision/src/tensor.rs | 172 + crates/burn-vision/src/transform/mod.rs | 3 + .../burn-vision/src/transform/transform2d.rs | 227 + crates/burn-vision/src/utils/mod.rs | 3 + crates/burn-vision/src/utils/save.rs | 189 + crates/burn-vision/tests/common/mod.rs | 106 + .../burn-vision/tests/connected_components.rs | 148 + .../tests/images/morphology/Base_1.png | Bin 0 -> 422 bytes .../tests/images/morphology/Base_2.png | Bin 0 -> 1636 bytes .../images/morphology/Dilate_1_3x5_Cross.png | Bin 0 -> 1267 bytes .../images/morphology/Dilate_1_3x5_Rect.png | Bin 0 -> 1262 bytes .../images/morphology/Dilate_1_5x5_Cross.png | Bin 0 -> 1262 bytes .../morphology/Dilate_1_5x5_Ellipse.png | Bin 0 -> 1257 bytes .../images/morphology/Dilate_1_5x5_Rect.png | Bin 0 -> 396 bytes .../images/morphology/Dilate_2_3x5_Cross.png | Bin 0 -> 2927 bytes .../images/morphology/Dilate_2_3x5_Rect.png | Bin 0 -> 2871 bytes ...te_2_5x7_Cross_ANCHOR_BORDER_REPLICATE.png | Bin 0 -> 3148 bytes .../morphology/Dilate_2_5x7_Rect_ANCHOR.png | Bin 0 -> 3076 bytes .../Dilate_2_7x7_Cross_BORDER_REFLECT.png | Bin 0 -> 3162 bytes .../Dilate_2_7x7_Cross_BORDER_REFLECT101.png | Bin 0 -> 3162 bytes .../Dilate_2_7x7_Cross_BORDER_REPLICATE.png | Bin 0 -> 3162 bytes .../Dilate_2_7x7_Rect_BORDER_REFLECT.png | Bin 0 -> 3094 bytes .../Dilate_2_7x7_Rect_BORDER_REFLECT101.png | Bin 0 -> 3094 bytes .../Dilate_2_7x7_Rect_BORDER_REPLICATE.png | Bin 0 -> 3094 bytes .../images/morphology/Erode_1_5x5_Cross.png | Bin 0 -> 1247 bytes .../images/morphology/Erode_1_5x5_Ellipse.png | Bin 0 -> 1243 bytes .../images/morphology/Erode_1_5x5_Rect.png | Bin 0 -> 370 bytes crates/burn-vision/tests/morphology.rs | 475 + crates/burn-vision/tests/nms.rs | 92 + crates/burn-wgpu/Cargo.toml | 51 + crates/burn-wgpu/LICENSE-APACHE | 1 + crates/burn-wgpu/LICENSE-MIT | 1 + crates/burn-wgpu/README.md | 59 + crates/burn-wgpu/src/lib.rs | 175 + crates/burn/Cargo.toml | 166 + crates/burn/LICENSE-APACHE | 1 + crates/burn/LICENSE-MIT | 1 + crates/burn/README.md | 1 + crates/burn/src/lib.rs | 191 + crates/burn/tests/backend_extension_remote.rs | 78 + deny.toml | 96 + docs/katex-header.html | 22 + examples/custom-csv-dataset/.gitignore | 2 + examples/custom-csv-dataset/Cargo.toml | 33 + examples/custom-csv-dataset/README.md | 25 + .../examples/custom-csv-dataset.rs | 15 + .../examples/dataframe-dataset.rs | 19 + .../src/dataframe_dataset.rs | 68 + examples/custom-csv-dataset/src/dataset.rs | 42 + .../src/diabetes_patient.rs | 51 + examples/custom-csv-dataset/src/lib.rs | 5 + examples/custom-csv-dataset/src/utils.rs | 30 + examples/custom-cubecl-kernel/Cargo.toml | 29 + .../examples/custom-cubecl-kernel.rs | 69 + examples/custom-cubecl-kernel/src/backward.rs | 134 + examples/custom-cubecl-kernel/src/forward.rs | 87 + examples/custom-cubecl-kernel/src/kernel.rs | 46 + examples/custom-cubecl-kernel/src/lib.rs | 39 + examples/custom-image-dataset/.gitignore | 4 + examples/custom-image-dataset/Cargo.toml | 25 + examples/custom-image-dataset/README.md | 71 + .../examples/custom-image-dataset.rs | 47 + examples/custom-image-dataset/src/data.rs | 116 + examples/custom-image-dataset/src/dataset.rs | 56 + .../custom-image-dataset/src/inference.rs | 30 + examples/custom-image-dataset/src/lib.rs | 4 + examples/custom-image-dataset/src/model.rs | 114 + examples/custom-image-dataset/src/training.rs | 130 + examples/custom-learning-strategy/Cargo.toml | 14 + .../examples/custom-learning-strategy.rs | 5 + examples/custom-learning-strategy/src/lib.rs | 2 + .../custom-learning-strategy/src/model.rs | 103 + .../custom-learning-strategy/src/training.rs | 214 + examples/custom-renderer/Cargo.toml | 26 + .../examples/custom-renderer.rs | 5 + examples/custom-renderer/src/lib.rs | 130 + examples/custom-training-loop/Cargo.toml | 17 + .../examples/custom-training-loop.rs | 5 + examples/custom-training-loop/src/lib.rs | 108 + examples/custom-wgpu-kernel/Cargo.toml | 33 + .../examples/custom-wgpu-kernel.rs | 70 + examples/custom-wgpu-kernel/src/backward.rs | 136 + examples/custom-wgpu-kernel/src/forward.rs | 128 + examples/custom-wgpu-kernel/src/kernel.wgsl | 76 + examples/custom-wgpu-kernel/src/lib.rs | 35 + examples/dqn-agent/Cargo.toml | 40 + examples/dqn-agent/burn.toml | 22 + examples/dqn-agent/examples/dqn-agent.rs | 40 + examples/dqn-agent/src/agent.rs | 611 + examples/dqn-agent/src/env.rs | 100 + examples/dqn-agent/src/lib.rs | 4 + examples/dqn-agent/src/training.rs | 64 + examples/dqn-agent/src/utils.rs | 218 + examples/guide/Cargo.toml | 27 + examples/guide/README.md | 24 + examples/guide/examples/guide.rs | 16 + examples/guide/src/bin/infer.rs | 19 + examples/guide/src/bin/print.rs | 9 + examples/guide/src/bin/train.rs | 31 + examples/guide/src/data.rs | 38 + examples/guide/src/inference.rs | 24 + examples/guide/src/lib.rs | 10 + examples/guide/src/model.rs | 68 + examples/guide/src/training.rs | 116 + examples/import-model-weights/Cargo.toml | 26 + examples/import-model-weights/README.md | 110 + .../import-model-weights/src/bin/burnpack.rs | 34 + .../import-model-weights/src/bin/convert.rs | 80 + .../import-model-weights/src/bin/pytorch.rs | 22 + .../src/bin/safetensors.rs | 23 + .../import-model-weights/src/inference.rs | 56 + examples/import-model-weights/src/lib.rs | 5 + examples/import-model-weights/src/model.rs | 57 + .../import-model-weights/weights/mnist.pt | Bin 0 -> 1513258 bytes .../weights/mnist.safetensors | Bin 0 -> 1509296 bytes .../weights/mnist_train_export.py | 223 + examples/mnist-inference-web/Cargo.toml | 29 + examples/mnist-inference-web/README.md | 92 + examples/mnist-inference-web/build-for-web.sh | 18 + examples/mnist-inference-web/index.html | 156 + examples/mnist-inference-web/index.js | 175 + examples/mnist-inference-web/model.bpk | Bin 0 -> 1044776 bytes examples/mnist-inference-web/run-server.sh | 23 + examples/mnist-inference-web/src/lib.rs | 7 + examples/mnist-inference-web/src/model.rs | 104 + examples/mnist-inference-web/src/state.rs | 38 + examples/mnist-inference-web/src/web.rs | 80 + examples/mnist/Cargo.toml | 34 + examples/mnist/README.md | 23 + examples/mnist/cubecl.toml | 15 + examples/mnist/examples/mnist.rs | 42 + examples/mnist/src/data.rs | 159 + examples/mnist/src/file_progress.rs | 159 + examples/mnist/src/lib.rs | 4 + examples/mnist/src/model.rs | 142 + examples/mnist/src/training.rs | 264 + examples/modern-lstm/Cargo.toml | 28 + examples/modern-lstm/README.md | 44 + examples/modern-lstm/examples/lstm-infer.rs | 68 + examples/modern-lstm/examples/lstm-train.rs | 75 + examples/modern-lstm/src/dataset.rs | 102 + examples/modern-lstm/src/inference.rs | 44 + examples/modern-lstm/src/lib.rs | 4 + examples/modern-lstm/src/model.rs | 359 + examples/modern-lstm/src/training.rs | 133 + examples/notebook/README.md | 47 + examples/notebook/autodiff.ipynb | 600 + examples/notebook/basic-tensor-op.ipynb | 1115 ++ examples/p2p-remote-training/Cargo.toml | 19 + .../examples/p2p-remote-training.rs | 25 + examples/p2p-remote-training/src/lib.rs | 90 + examples/remote-inference-web/Cargo.toml | 31 + examples/remote-inference-web/README.md | 74 + .../remote-inference-web/build-for-web.sh | 16 + examples/remote-inference-web/index.html | 179 + examples/remote-inference-web/index.js | 175 + examples/remote-inference-web/model.bpk | Bin 0 -> 1044776 bytes examples/remote-inference-web/run-server.sh | 23 + examples/remote-inference-web/src/lib.rs | 8 + examples/remote-inference-web/src/model.rs | 104 + examples/remote-inference-web/src/web.rs | 75 + examples/server/Cargo.toml | 21 + examples/server/burn.toml | 9 + examples/server/examples/server.rs | 3 + examples/server/src/lib.rs | 14 + examples/simple-regression/Cargo.toml | 29 + examples/simple-regression/README.md | 32 + .../simple-regression/examples/regression.rs | 73 + examples/simple-regression/src/dataset.rs | 175 + examples/simple-regression/src/inference.rs | 56 + examples/simple-regression/src/lib.rs | 4 + examples/simple-regression/src/model.rs | 80 + examples/simple-regression/src/training.rs | 89 + examples/text-classification/Cargo.toml | 52 + examples/text-classification/README.md | 120 + examples/text-classification/burn.toml | 22 + .../examples/ag-news-infer.rs | 109 + .../examples/ag-news-train.rs | 192 + .../examples/db-pedia-infer.rs | 104 + .../examples/db-pedia-train.rs | 179 + .../text-classification/src/data/batcher.rs | 107 + .../text-classification/src/data/dataset.rs | 172 + examples/text-classification/src/data/mod.rs | 7 + .../text-classification/src/data/tokenizer.rs | 68 + examples/text-classification/src/inference.rs | 72 + examples/text-classification/src/lib.rs | 9 + examples/text-classification/src/model.rs | 162 + examples/text-classification/src/training.rs | 114 + examples/text-generation/Cargo.toml | 29 + examples/text-generation/README.md | 29 + .../examples/text-generation.rs | 36 + examples/text-generation/src/data/batcher.rs | 64 + examples/text-generation/src/data/dataset.rs | 44 + examples/text-generation/src/data/mod.rs | 7 + .../text-generation/src/data/tokenizer.rs | 94 + examples/text-generation/src/lib.rs | 8 + examples/text-generation/src/model.rs | 112 + examples/text-generation/src/training.rs | 94 + examples/wgan/Cargo.toml | 18 + examples/wgan/README.md | 36 + examples/wgan/examples/wgan-generate.rs | 68 + examples/wgan/examples/wgan-mnist.rs | 80 + examples/wgan/src/dataset.rs | 36 + examples/wgan/src/infer.rs | 36 + examples/wgan/src/lib.rs | 4 + examples/wgan/src/model.rs | 156 + examples/wgan/src/training.rs | 206 + rustfmt.toml | 5 + xtask/Cargo.toml | 22 + xtask/src/commands/books.rs | 109 + xtask/src/commands/build.rs | 130 + xtask/src/commands/doc.rs | 28 + xtask/src/commands/mod.rs | 6 + xtask/src/commands/remote.rs | 188 + xtask/src/commands/test.rs | 480 + xtask/src/commands/validate.rs | 154 + xtask/src/main.rs | 86 + 2056 files changed, 390094 insertions(+) create mode 100644 .cargo/audit.toml create mode 100644 .cargo/config.toml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/doc_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/template.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/combine-dependabot-prs.yml create mode 100644 .github/workflows/dependencies.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/stale-pr.yml create mode 100644 .github/workflows/test-gpu.yml create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/valgrind.yml create mode 100644 .github/workflows/vulnerabilities.yml create mode 100644 .gitignore create mode 100644 CITATION.cff create mode 100644 CODE-OF-CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 NOTICES.md create mode 100644 POEM.md create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 _typos.toml create mode 100644 assets/backend-chip.png create mode 100644 assets/burn-train-tui.png create mode 100644 assets/ember-blazingly-fast.png create mode 100644 assets/ember-community.png create mode 100644 assets/ember-walking.png create mode 100644 assets/ember-wall.png create mode 100644 assets/logo-burn-full.png create mode 100644 assets/logo-burn-neutral.webp create mode 100644 assets/logo-burn-small.png create mode 100644 benchmarks.toml create mode 100644 burn-book/.gitignore create mode 100644 burn-book/.prettierrc.json create mode 120000 burn-book/LICENSE-APACHE create mode 120000 burn-book/LICENSE-MIT create mode 100644 burn-book/book.toml create mode 100644 burn-book/src/SUMMARY.md create mode 100644 burn-book/src/advanced/README.md create mode 100644 burn-book/src/advanced/backend-extension/README.md create mode 100644 burn-book/src/advanced/backend-extension/custom-cubecl-kernel.md create mode 100644 burn-book/src/advanced/backend-extension/custom-wgpu-kernel.md create mode 100644 burn-book/src/advanced/no-std.md create mode 100644 burn-book/src/advanced/web-assembly.md create mode 100644 burn-book/src/basic-workflow/README.md create mode 100644 burn-book/src/basic-workflow/backend.md create mode 100644 burn-book/src/basic-workflow/data.md create mode 100644 burn-book/src/basic-workflow/inference.md create mode 100644 burn-book/src/basic-workflow/model.md create mode 100644 burn-book/src/basic-workflow/training-output.png create mode 100644 burn-book/src/basic-workflow/training.md create mode 100644 burn-book/src/building-blocks/README.md create mode 100644 burn-book/src/building-blocks/autodiff.md create mode 100644 burn-book/src/building-blocks/backend.md create mode 100644 burn-book/src/building-blocks/config.md create mode 100644 burn-book/src/building-blocks/dataset.md create mode 100755 burn-book/src/building-blocks/dataset.png create mode 100644 burn-book/src/building-blocks/learner.md create mode 100644 burn-book/src/building-blocks/metric.md create mode 100644 burn-book/src/building-blocks/module.md create mode 100644 burn-book/src/building-blocks/record.md create mode 100644 burn-book/src/building-blocks/tensor.md create mode 100644 burn-book/src/custom-training-loop.md create mode 100644 burn-book/src/distributed-computing.md create mode 100644 burn-book/src/examples.md create mode 100644 burn-book/src/getting-started.md create mode 100644 burn-book/src/models-and-pretrained-weights.md create mode 100644 burn-book/src/motivation.md create mode 100644 burn-book/src/onnx-import.md create mode 100644 burn-book/src/overview.md create mode 100644 burn-book/src/performance/README.md create mode 100644 burn-book/src/performance/distributed-computing.md create mode 100644 burn-book/src/performance/good-practices/README.md create mode 100644 burn-book/src/performance/good-practices/asynchronous-execution.md create mode 100644 burn-book/src/performance/good-practices/kernel-fusion.md create mode 100644 burn-book/src/performance/good-practices/kernel-selection.md create mode 100644 burn-book/src/performance/quantization.md create mode 100644 burn-book/src/saving-and-loading.md create mode 100644 codecov.yml create mode 100644 contributor-book/.gitignore create mode 100644 contributor-book/.prettierrc.json create mode 120000 contributor-book/LICENSE-APACHE create mode 120000 contributor-book/LICENSE-MIT create mode 100644 contributor-book/book.toml create mode 100644 contributor-book/src/SUMMARY.md create mode 100644 contributor-book/src/frequently-encountered-issues/README.md create mode 100644 contributor-book/src/frequently-encountered-issues/issues-while-adding-ops.md create mode 100644 contributor-book/src/getting-started/README.md create mode 100644 contributor-book/src/getting-started/configuring-your-editor.md create mode 100644 contributor-book/src/getting-started/debug-options-vscode.png create mode 100644 contributor-book/src/getting-started/setting-up-the-environment.md create mode 100644 contributor-book/src/getting-started/testing.md create mode 100644 contributor-book/src/guides/README.md create mode 100644 contributor-book/src/guides/adding-a-new-operation-to-burn.md create mode 100644 contributor-book/src/guides/submitting-examples.md create mode 100644 contributor-book/src/how-to-read-this-book.md create mode 100644 contributor-book/src/overview.md create mode 100644 contributor-book/src/project-architecture/README.md create mode 100644 contributor-book/src/project-architecture/backend.md create mode 100644 contributor-book/src/project-architecture/module-serialization.png create mode 100644 contributor-book/src/project-architecture/module.md create mode 100644 contributor-book/src/project-architecture/serialization.md create mode 100644 contributor-book/src/project-architecture/tensor.md create mode 100644 crates/burn-autodiff/Cargo.toml create mode 120000 crates/burn-autodiff/LICENSE-APACHE create mode 120000 crates/burn-autodiff/LICENSE-MIT create mode 100644 crates/burn-autodiff/README.md create mode 100644 crates/burn-autodiff/src/backend.rs create mode 100644 crates/burn-autodiff/src/checkpoint/base.rs create mode 100644 crates/burn-autodiff/src/checkpoint/builder.rs create mode 100644 crates/burn-autodiff/src/checkpoint/mod.rs create mode 100644 crates/burn-autodiff/src/checkpoint/retro_forward.rs create mode 100644 crates/burn-autodiff/src/checkpoint/state.rs create mode 100644 crates/burn-autodiff/src/checkpoint/strategy.rs create mode 100644 crates/burn-autodiff/src/distributed.rs create mode 100644 crates/burn-autodiff/src/grads.rs create mode 100644 crates/burn-autodiff/src/graph/base.rs create mode 100644 crates/burn-autodiff/src/graph/mod.rs create mode 100644 crates/burn-autodiff/src/graph/node.rs create mode 100644 crates/burn-autodiff/src/graph/requirement.rs create mode 100644 crates/burn-autodiff/src/graph/traversal.rs create mode 100644 crates/burn-autodiff/src/lib.rs create mode 100644 crates/burn-autodiff/src/ops/activation.rs create mode 100644 crates/burn-autodiff/src/ops/backward.rs create mode 100644 crates/burn-autodiff/src/ops/base.rs create mode 100644 crates/burn-autodiff/src/ops/bool_tensor.rs create mode 100644 crates/burn-autodiff/src/ops/distributed.rs create mode 100644 crates/burn-autodiff/src/ops/int_tensor.rs create mode 100644 crates/burn-autodiff/src/ops/maxmin.rs create mode 100644 crates/burn-autodiff/src/ops/mod.rs create mode 100644 crates/burn-autodiff/src/ops/module.rs create mode 100644 crates/burn-autodiff/src/ops/qtensor.rs create mode 100644 crates/burn-autodiff/src/ops/sort.rs create mode 100644 crates/burn-autodiff/src/ops/tensor.rs create mode 100644 crates/burn-autodiff/src/ops/transaction.rs create mode 100644 crates/burn-autodiff/src/runtime/client.rs create mode 100644 crates/burn-autodiff/src/runtime/graph.rs create mode 100644 crates/burn-autodiff/src/runtime/memory_management.rs create mode 100644 crates/burn-autodiff/src/runtime/mod.rs create mode 100644 crates/burn-autodiff/src/runtime/server.rs create mode 100644 crates/burn-autodiff/src/tensor.rs create mode 100644 crates/burn-autodiff/src/utils.rs create mode 100644 crates/burn-backend-extension/Cargo.toml create mode 120000 crates/burn-backend-extension/LICENSE-APACHE create mode 120000 crates/burn-backend-extension/LICENSE-MIT create mode 100644 crates/burn-backend-extension/README.md create mode 100644 crates/burn-backend-extension/src/lib.rs create mode 100644 crates/burn-backend-tests/.cargo/config.toml create mode 100644 crates/burn-backend-tests/Cargo.toml create mode 100644 crates/burn-backend-tests/README.md create mode 100644 crates/burn-backend-tests/benches/attention.rs create mode 100644 crates/burn-backend-tests/benches/binary_ops.rs create mode 100644 crates/burn-backend-tests/benches/cat_max_min_ops.rs create mode 100644 crates/burn-backend-tests/benches/common/mod.rs create mode 100644 crates/burn-backend-tests/benches/comparison_ops.rs create mode 100644 crates/burn-backend-tests/benches/conv_ops.rs create mode 100644 crates/burn-backend-tests/benches/conv_transpose_ops.rs create mode 100644 crates/burn-backend-tests/benches/cross_unfold_ops.rs create mode 100644 crates/burn-backend-tests/benches/cumulative_ops.rs create mode 100644 crates/burn-backend-tests/benches/default_ops.rs create mode 100644 crates/burn-backend-tests/benches/deform_conv_ops.rs create mode 100644 crates/burn-backend-tests/benches/fft_ops.rs create mode 100644 crates/burn-backend-tests/benches/gather_scatter_ops.rs create mode 100644 crates/burn-backend-tests/benches/int_ops.rs create mode 100644 crates/burn-backend-tests/benches/interpolate_ops.rs create mode 100644 crates/burn-backend-tests/benches/matmul.rs create mode 100644 crates/burn-backend-tests/benches/norm_ops.rs create mode 100644 crates/burn-backend-tests/benches/pool_ops.rs create mode 100644 crates/burn-backend-tests/benches/quantization_ops.rs create mode 100644 crates/burn-backend-tests/benches/reduce_ops.rs create mode 100644 crates/burn-backend-tests/benches/slice_ops.rs create mode 100644 crates/burn-backend-tests/benches/softmax_ops.rs create mode 100644 crates/burn-backend-tests/benches/unary_ops.rs create mode 100644 crates/burn-backend-tests/cubecl.toml create mode 100644 crates/burn-backend-tests/src/lib.rs create mode 100644 crates/burn-backend-tests/tests/autodiff.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/abs.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/adaptive_avgpool1d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/adaptive_avgpool2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/add.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/aggregation.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/all_reduce.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/avgpool1d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/avgpool2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/backward.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/bridge.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/broadcast.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cast.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cat.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/ceil.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/checkpoint.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/complex.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv1d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv3d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv_transpose1d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv_transpose2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/conv_transpose3d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cross.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cross_entropy.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/ctc.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cummax.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cummin.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cumprod.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/cumsum.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/deform_conv2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/div.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/erf.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/exp.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/expand.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/flip.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/floor.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/gather_scatter.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/gather_scatter_nd.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/gelu.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/gradients.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/hypot.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/linear.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/log.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/log1p.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/log_sigmoid.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/mask.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/matmul.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/maxmin.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/maxpool1d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/maxpool2d.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/memory_management.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/mod.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/mul.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/multithread.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/nearest_interpolate.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/neg.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/nonzero.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/permute.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/pow.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/recip.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/relu.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/remainder.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/reshape.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/rfft.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/round.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/select.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/sigmoid.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/sign.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/slice.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/slice_assign.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/softmax.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/sort.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/sqrt.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/sub.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/transpose.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/trig.rs create mode 100644 crates/burn-backend-tests/tests/autodiff/unfold.rs create mode 100644 crates/burn-backend-tests/tests/autodiff_f16.rs create mode 100644 crates/burn-backend-tests/tests/common/autodiff.rs create mode 100644 crates/burn-backend-tests/tests/common/backend.rs create mode 100644 crates/burn-backend-tests/tests/common/tensor.rs create mode 100644 crates/burn-backend-tests/tests/cubecl.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/avg_pool2d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/bernoulli.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/cast.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/cat.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/clamp.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/contiguous.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/conv2d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/conv3d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/conv_transpose2d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/conv_transpose3d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/cross.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/fft.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/gather.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/interpolate_nearest.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/mask_fill.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/mask_where.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/max_pool2d.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/max_pool2d_backward.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/mod.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/normal.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/quantization/mod.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/quantization/quantize_dequantize.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/quantization/reshape.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/quantization/slice.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/reduce.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/scatter.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/select.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/select_assign.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/slice.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/slice_assign.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/stft.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/unary.rs create mode 100644 crates/burn-backend-tests/tests/cubecl/uniform.rs create mode 100644 crates/burn-backend-tests/tests/fusion/cat.rs create mode 100644 crates/burn-backend-tests/tests/fusion/fusion_f16_broadcast.rs create mode 100644 crates/burn-backend-tests/tests/fusion/fusion_f16_write_vectorization.rs create mode 100644 crates/burn-backend-tests/tests/fusion/fusion_shape.rs create mode 100644 crates/burn-backend-tests/tests/fusion/inplace.rs create mode 100644 crates/burn-backend-tests/tests/fusion/int_bitwise.rs create mode 100644 crates/burn-backend-tests/tests/fusion/mod.rs create mode 100644 crates/burn-backend-tests/tests/fusion/reduce_broadcasted.rs create mode 100644 crates/burn-backend-tests/tests/graph_capture.rs create mode 100644 crates/burn-backend-tests/tests/memory_cleaning.rs create mode 100644 crates/burn-backend-tests/tests/tensor.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/all.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/any.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/argwhere_nonzero.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/cast.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/cat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/comparison.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/create_like.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/expand.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/flip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/full.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/gather_scatter.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/init.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/logical.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/mask.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/movedim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/permute.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/repeat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/reshape.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/select.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/stack.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/take.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/transpose.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/tri_mask.rs create mode 100644 crates/burn-backend-tests/tests/tensor/bool/ops/unfold.rs create mode 100644 crates/burn-backend-tests/tests/tensor/clone_invariance.rs create mode 100644 crates/burn-backend-tests/tests/tensor/distributed.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/celu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/elu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/gelu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/glu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/hard_sigmoid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/leaky_relu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/log_sigmoid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/log_softmax.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/mish.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/prelu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/quiet_softmax.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/relu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/selu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/sigmoid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/silu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/softmax.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/softmin.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/softplus.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/softsign.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/tanh_activation.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/activation/thresholded_relu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/grid/affine_grid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/grid/meshgrid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/grid/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/cosine_similarity.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/det.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/diag.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/lu.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/matvec.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/outer.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/trace.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/linalg/vector_norm.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool1d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/attention.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/avgpool1d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/avgpool2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/bicubic_interpolate.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/bilinear_interpolate.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv1d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv3d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv_transpose1d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv_transpose2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/conv_transpose3d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/ctc.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/deform_conv2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/forward.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/lanczos3_interpolate.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/linear.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/maxpool1d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/maxpool2d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/nearest_interpolate.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/module/unfold4d.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/abs.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/add.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/aggregation.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/all.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/any.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/arg.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/blackman_window.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/cast.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/cat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/categorical.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/ceil.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/chunk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/clamp.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/close.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/comparison.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/create_like.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/cross.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/cumulative.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/div.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/dot.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/erf.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/exp.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/expand.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/finite.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/flatten.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/flip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/floor.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/fmod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/full.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter_nd.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/grid_sample.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/hamming_window.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/hann_window.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/hypot.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/inf.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/init.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/iter_dim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/log.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/log1p.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/mask.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/matmul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/maxmin.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/movedim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/mul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/nan.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/narrow.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/neg.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/one_hot.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/padding.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/permute.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/powf.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/powf_scalar.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/prod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/random.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/recip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/remainder.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/repeat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/reshape.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/round.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/select.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/sign.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/slice.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/slice_assign.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/sort_argsort.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/split.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/sqrt.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/square.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/squeeze.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/stack.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/sub.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/take.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/topk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/transaction.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/transpose.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/tri.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/trig.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/trunc.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/ops/unfold.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/primitive.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/calibration.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/data.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/abs.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/add.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/aggregation.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/all.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/any.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/arg.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/ceil.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/chunk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/clamp.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cos.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cosh.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/div.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/erf.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/exp.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/expand.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/flip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/floor.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/gather_scatter.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log1p.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/map_comparison.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mask.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/maxmin.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/narrow.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/neg.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/permute.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf_scalar.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/recip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/remainder.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/reshape.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/round.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/select.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sin.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sinh.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/slice.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sort_argsort.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/split.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sqrt.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/stack.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sub.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tan.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tanh.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/topk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/transpose.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/matmul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/ops/quantize.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/quantization/scheme.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/cov.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/display.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/eye.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/median.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/float/stats/var.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/abs.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/add.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/aggregation.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/all.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/any.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/arange.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/arange_step.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/arg.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/bitwise.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/cartesian_grid.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/cast.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/cat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/chunk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/comparison.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/create_like.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/cumulative.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/div.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/expand.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/flip.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/full.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter_nd.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/init.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/mask.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/matmul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/movedim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/mul.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/one_hot.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/permute.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/random.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/remainder.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/repeat.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/repeat_dim.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/reshape.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/roll.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/select.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/sign.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/slice.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/slice_assign.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/sort_argsort.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/stack.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/sub.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/take.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/topk.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/transpose.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/tri.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/ops/unfold.rs create mode 100644 crates/burn-backend-tests/tests/tensor/int/primitive.rs create mode 100644 crates/burn-backend-tests/tests/tensor/mod.rs create mode 100644 crates/burn-backend-tests/tests/tensor/multi_threads.rs create mode 100644 crates/burn-backend-tests/tests/tensor_f16.rs create mode 100644 crates/burn-backend/Cargo.toml create mode 100644 crates/burn-backend/README.md create mode 100644 crates/burn-backend/src/alias.rs create mode 100644 crates/burn-backend/src/backend/base.rs create mode 100644 crates/burn-backend/src/backend/device.rs create mode 100644 crates/burn-backend/src/backend/distributed/api.rs create mode 100644 crates/burn-backend/src/backend/distributed/client.rs create mode 100644 crates/burn-backend/src/backend/distributed/mod.rs create mode 100644 crates/burn-backend/src/backend/distributed/ops.rs create mode 100644 crates/burn-backend/src/backend/distributed/server.rs create mode 100644 crates/burn-backend/src/backend/mod.rs create mode 100644 crates/burn-backend/src/backend/ops/activation.rs create mode 100644 crates/burn-backend/src/backend/ops/argwhere.rs create mode 100644 crates/burn-backend/src/backend/ops/bool_tensor.rs create mode 100644 crates/burn-backend/src/backend/ops/cat.rs create mode 100644 crates/burn-backend/src/backend/ops/int_tensor.rs create mode 100644 crates/burn-backend/src/backend/ops/mod.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/attention.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/base.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/conv.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/ctc.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/grid_sample.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/linear.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/mod.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/pool.rs create mode 100644 crates/burn-backend/src/backend/ops/modules/unfold.rs create mode 100644 crates/burn-backend/src/backend/ops/qtensor.rs create mode 100644 crates/burn-backend/src/backend/ops/repeat_dim.rs create mode 100644 crates/burn-backend/src/backend/ops/sort.rs create mode 100644 crates/burn-backend/src/backend/ops/tensor.rs create mode 100644 crates/burn-backend/src/backend/ops/transaction.rs create mode 100644 crates/burn-backend/src/backend/primitive.rs create mode 100644 crates/burn-backend/src/cubecl.rs create mode 100644 crates/burn-backend/src/lib.rs create mode 100644 crates/burn-backend/src/quantization/mod.rs create mode 100644 crates/burn-backend/src/quantization/parameters.rs create mode 100644 crates/burn-backend/src/quantization/scheme.rs create mode 100644 crates/burn-candle/Cargo.toml create mode 120000 crates/burn-candle/LICENSE-APACHE create mode 120000 crates/burn-candle/LICENSE-MIT create mode 100644 crates/burn-candle/README.md create mode 100644 crates/burn-candle/src/backend.rs create mode 100644 crates/burn-candle/src/element.rs create mode 100644 crates/burn-candle/src/lib.rs create mode 100644 crates/burn-candle/src/ops/activation.rs create mode 100644 crates/burn-candle/src/ops/base.rs create mode 100644 crates/burn-candle/src/ops/bool_tensor.rs create mode 100644 crates/burn-candle/src/ops/candle_utils.rs create mode 100644 crates/burn-candle/src/ops/int_tensor.rs create mode 100644 crates/burn-candle/src/ops/mod.rs create mode 100644 crates/burn-candle/src/ops/module.rs create mode 100644 crates/burn-candle/src/ops/qtensor.rs create mode 100644 crates/burn-candle/src/ops/tensor.rs create mode 100644 crates/burn-candle/src/ops/transaction.rs create mode 100644 crates/burn-candle/src/ops/utils.rs create mode 100644 crates/burn-candle/src/tensor.rs create mode 100644 crates/burn-communication/Cargo.toml create mode 100644 crates/burn-communication/README.md create mode 100644 crates/burn-communication/src/base.rs create mode 100644 crates/burn-communication/src/external_comm.rs create mode 100644 crates/burn-communication/src/lib.rs create mode 100644 crates/burn-communication/src/util.rs create mode 100644 crates/burn-communication/src/websocket/base.rs create mode 100644 crates/burn-communication/src/websocket/client.rs create mode 100644 crates/burn-communication/src/websocket/mod.rs create mode 100644 crates/burn-communication/src/websocket/server.rs create mode 100644 crates/burn-communication/tests/websocket_server.rs create mode 100644 crates/burn-core/Cargo.toml create mode 120000 crates/burn-core/LICENSE-APACHE create mode 120000 crates/burn-core/LICENSE-MIT create mode 100644 crates/burn-core/README.md create mode 100644 crates/burn-core/src/backend.rs create mode 100644 crates/burn-core/src/config.rs create mode 100644 crates/burn-core/src/data/dataloader/base.rs create mode 100644 crates/burn-core/src/data/dataloader/batch.rs create mode 100644 crates/burn-core/src/data/dataloader/batcher.rs create mode 100644 crates/burn-core/src/data/dataloader/builder.rs create mode 100644 crates/burn-core/src/data/dataloader/mod.rs create mode 100644 crates/burn-core/src/data/dataloader/multithread.rs create mode 100644 crates/burn-core/src/data/dataloader/split.rs create mode 100644 crates/burn-core/src/data/dataloader/strategy.rs create mode 100644 crates/burn-core/src/data/mod.rs create mode 100644 crates/burn-core/src/lib.rs create mode 100644 crates/burn-core/src/module/base.rs create mode 100644 crates/burn-core/src/module/display.rs create mode 100644 crates/burn-core/src/module/initializer.rs create mode 100644 crates/burn-core/src/module/mod.rs create mode 100644 crates/burn-core/src/module/param/base.rs create mode 100644 crates/burn-core/src/module/param/constant.rs create mode 100644 crates/burn-core/src/module/param/group.rs create mode 100644 crates/burn-core/src/module/param/id.rs create mode 100644 crates/burn-core/src/module/param/mod.rs create mode 100644 crates/burn-core/src/module/param/primitive.rs create mode 100644 crates/burn-core/src/module/param/running.rs create mode 100644 crates/burn-core/src/module/param/sync_once_cell.rs create mode 100644 crates/burn-core/src/module/param/tensor.rs create mode 100644 crates/burn-core/src/module/param/visitor.rs create mode 100644 crates/burn-core/src/module/quantize.rs create mode 100644 crates/burn-core/src/store/mod.rs create mode 100644 crates/burn-core/src/tensor.rs create mode 100644 crates/burn-core/tests/test_derive_config.rs create mode 100644 crates/burn-core/tests/test_derive_module.rs create mode 100644 crates/burn-core/tests/test_record.rs create mode 100644 crates/burn-cpu/Cargo.toml create mode 100644 crates/burn-cpu/README.md create mode 100644 crates/burn-cpu/src/lib.rs create mode 100644 crates/burn-cubecl-fusion/Cargo.toml create mode 100644 crates/burn-cubecl-fusion/README.md create mode 100644 crates/burn-cubecl-fusion/src/base.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/base.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/io.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/ir.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/kernel.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/tensor.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/codegen/view.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/fuser.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/base.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/executor.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/input.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/output.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/plan.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/runner.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/vectorization/base.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/vectorization/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/launch/vectorization/planner.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/scoring.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/settings.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/trace/base.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/trace/block.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/trace/fuser.rs create mode 100644 crates/burn-cubecl-fusion/src/engine/trace/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/inspect.rs create mode 100644 crates/burn-cubecl-fusion/src/lib.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/base.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/elemwise/fuser.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/elemwise/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/elemwise/optimization.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/matmul/args.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/matmul/fuser.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/matmul/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/matmul/optimization.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/matmul/tune.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce/args.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce/fuser.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce/optimization.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce/tune.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/base.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/block.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full_analyzer.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/launch.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/mod.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/optimization.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/tune.rs create mode 100644 crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/unit.rs create mode 100644 crates/burn-cubecl-fusion/src/tune.rs create mode 100644 crates/burn-cubecl/Cargo.toml create mode 120000 crates/burn-cubecl/LICENSE-APACHE create mode 120000 crates/burn-cubecl/LICENSE-MIT create mode 100644 crates/burn-cubecl/README.md create mode 100644 crates/burn-cubecl/src/backend.rs create mode 100644 crates/burn-cubecl/src/element.rs create mode 100644 crates/burn-cubecl/src/fusion.rs create mode 100644 crates/burn-cubecl/src/kernel/attention/base.rs create mode 100644 crates/burn-cubecl/src/kernel/attention/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/attention/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/binary.rs create mode 100644 crates/burn-cubecl/src/kernel/binary_float.rs create mode 100644 crates/burn-cubecl/src/kernel/binary_int.rs create mode 100644 crates/burn-cubecl/src/kernel/cast/base.rs create mode 100644 crates/burn-cubecl/src/kernel/cast/bool_cast.rs create mode 100644 crates/burn-cubecl/src/kernel/cast/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/clamp.rs create mode 100644 crates/burn-cubecl/src/kernel/comparison.rs create mode 100644 crates/burn-cubecl/src/kernel/contiguous.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_data/fallback.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/launch.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_data/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_data/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_weight/fallback.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/launch.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_weight/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/backward_weight/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/base.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose2d/base.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose2d/col2im.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose2d/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose2d/transpose_direct.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose2d/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/conv_transpose3d.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/deform_conv2d.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/deform_conv_transpose2d.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/direct.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/launch.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/forward/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/forward/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/im2col.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/conv/tune_key.rs create mode 100644 crates/burn-cubecl/src/kernel/cross.rs create mode 100644 crates/burn-cubecl/src/kernel/ctc.rs create mode 100644 crates/burn-cubecl/src/kernel/fft/base.rs create mode 100644 crates/burn-cubecl/src/kernel/fft/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/grid_sample/base.rs create mode 100644 crates/burn-cubecl/src/kernel/grid_sample/bilinear.rs create mode 100644 crates/burn-cubecl/src/kernel/grid_sample/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/index/flip.rs create mode 100644 crates/burn-cubecl/src/kernel/index/gather.rs create mode 100644 crates/burn-cubecl/src/kernel/index/gather_nd.rs create mode 100644 crates/burn-cubecl/src/kernel/index/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/index/repeat_dim.rs create mode 100644 crates/burn-cubecl/src/kernel/index/scatter.rs create mode 100644 crates/burn-cubecl/src/kernel/index/scatter_nd.rs create mode 100644 crates/burn-cubecl/src/kernel/index/select.rs create mode 100644 crates/burn-cubecl/src/kernel/index/select_assign.rs create mode 100644 crates/burn-cubecl/src/kernel/index/slice.rs create mode 100644 crates/burn-cubecl/src/kernel/index/slice_assign.rs create mode 100644 crates/burn-cubecl/src/kernel/interpolate/base.rs create mode 100644 crates/burn-cubecl/src/kernel/interpolate/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/interpolate/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/mask/base.rs create mode 100644 crates/burn-cubecl/src/kernel/mask/mask_fill.rs create mode 100644 crates/burn-cubecl/src/kernel/mask/mask_where.rs create mode 100644 crates/burn-cubecl/src/kernel/mask/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/matmul/base.rs create mode 100644 crates/burn-cubecl/src/kernel/matmul/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/matmul/tune/base.rs create mode 100644 crates/burn-cubecl/src/kernel/matmul/tune/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/matmul/utils.rs create mode 100644 crates/burn-cubecl/src/kernel/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/pool/base.rs create mode 100644 crates/burn-cubecl/src/kernel/pool/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/prng/bernoulli.rs create mode 100644 crates/burn-cubecl/src/kernel/prng/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/prng/normal.rs create mode 100644 crates/burn-cubecl/src/kernel/prng/uniform.rs create mode 100644 crates/burn-cubecl/src/kernel/quantization/dequantize.rs create mode 100644 crates/burn-cubecl/src/kernel/quantization/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/quantization/quantize.rs create mode 100644 crates/burn-cubecl/src/kernel/reduce/base.rs create mode 100644 crates/burn-cubecl/src/kernel/reduce/mod.rs create mode 100644 crates/burn-cubecl/src/kernel/reduce/tune.rs create mode 100644 crates/burn-cubecl/src/kernel/unary_float.rs create mode 100644 crates/burn-cubecl/src/kernel/unary_int.rs create mode 100644 crates/burn-cubecl/src/kernel/unary_numeric.rs create mode 100644 crates/burn-cubecl/src/kernel/utils.rs create mode 100644 crates/burn-cubecl/src/lib.rs create mode 100644 crates/burn-cubecl/src/ops/activation.rs create mode 100644 crates/burn-cubecl/src/ops/base.rs create mode 100644 crates/burn-cubecl/src/ops/bool_tensor.rs create mode 100644 crates/burn-cubecl/src/ops/distributed.rs create mode 100644 crates/burn-cubecl/src/ops/int_tensor.rs create mode 100644 crates/burn-cubecl/src/ops/mod.rs create mode 100644 crates/burn-cubecl/src/ops/module.rs create mode 100644 crates/burn-cubecl/src/ops/numeric.rs create mode 100644 crates/burn-cubecl/src/ops/qtensor.rs create mode 100644 crates/burn-cubecl/src/ops/tensor.rs create mode 100644 crates/burn-cubecl/src/ops/transaction.rs create mode 100644 crates/burn-cubecl/src/template/base.rs create mode 100644 crates/burn-cubecl/src/template/mod.rs create mode 100644 crates/burn-cubecl/src/template/source.rs create mode 100644 crates/burn-cubecl/src/tensor/base.rs create mode 100644 crates/burn-cubecl/src/tensor/mod.rs create mode 100644 crates/burn-cubecl/src/tensor/quantization.rs create mode 100644 crates/burn-cubecl/src/tune_key.rs create mode 100644 crates/burn-cuda/Cargo.toml create mode 100644 crates/burn-cuda/README.md create mode 100644 crates/burn-cuda/src/lib.rs create mode 100644 crates/burn-dataset/Cargo.toml create mode 120000 crates/burn-dataset/LICENSE-APACHE create mode 120000 crates/burn-dataset/LICENSE-MIT create mode 100644 crates/burn-dataset/README.md create mode 100644 crates/burn-dataset/examples/hf_dataset.rs create mode 100644 crates/burn-dataset/examples/speech_commands.rs create mode 100644 crates/burn-dataset/src/audio/mod.rs create mode 100644 crates/burn-dataset/src/audio/speech_commands.rs create mode 100644 crates/burn-dataset/src/dataset/base.rs create mode 100644 crates/burn-dataset/src/dataset/dataframe.rs create mode 100644 crates/burn-dataset/src/dataset/fake.rs create mode 100644 crates/burn-dataset/src/dataset/in_memory.rs create mode 100644 crates/burn-dataset/src/dataset/iterator.rs create mode 100644 crates/burn-dataset/src/dataset/mod.rs create mode 100644 crates/burn-dataset/src/dataset/sqlite.rs create mode 100644 crates/burn-dataset/src/lib.rs create mode 100644 crates/burn-dataset/src/nlp/ag_news.rs create mode 100644 crates/burn-dataset/src/nlp/mod.rs create mode 100644 crates/burn-dataset/src/nlp/text_folder.rs create mode 100644 crates/burn-dataset/src/source/huggingface/downloader.rs create mode 100644 crates/burn-dataset/src/source/huggingface/importer.py create mode 100644 crates/burn-dataset/src/source/huggingface/mod.rs create mode 100644 crates/burn-dataset/src/source/mod.rs create mode 100644 crates/burn-dataset/src/transform/composed.rs create mode 100644 crates/burn-dataset/src/transform/mapper.rs create mode 100644 crates/burn-dataset/src/transform/mod.rs create mode 100644 crates/burn-dataset/src/transform/options.rs create mode 100644 crates/burn-dataset/src/transform/partial.rs create mode 100644 crates/burn-dataset/src/transform/sampler.rs create mode 100644 crates/burn-dataset/src/transform/selection.rs create mode 100644 crates/burn-dataset/src/transform/shuffle.rs create mode 100644 crates/burn-dataset/src/transform/window.rs create mode 100644 crates/burn-dataset/src/vision/cifar.rs create mode 100644 crates/burn-dataset/src/vision/image_folder.rs create mode 100644 crates/burn-dataset/src/vision/mnist.rs create mode 100644 crates/burn-dataset/src/vision/mod.rs create mode 100644 crates/burn-dataset/tests/data/dataset-fmt.csv create mode 100644 crates/burn-dataset/tests/data/dataset.csv create mode 100644 crates/burn-dataset/tests/data/dataset.json create mode 100644 crates/burn-dataset/tests/data/dataset_coco.json create mode 100755 crates/burn-dataset/tests/data/image_folder/orange/dot.jpg create mode 100755 crates/burn-dataset/tests/data/image_folder/red/dot.jpg create mode 100755 crates/burn-dataset/tests/data/image_folder/red/dot.png create mode 100644 crates/burn-dataset/tests/data/image_folder_coco/dot_triangle.jpg create mode 100644 crates/burn-dataset/tests/data/image_folder_coco/one_dot.jpg create mode 100644 crates/burn-dataset/tests/data/image_folder_coco/two_dots_and_triangle.jpg create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.png create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.txt create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.png create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.txt create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.png create mode 100644 crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.txt create mode 100644 crates/burn-dataset/tests/data/segmask_folder/images/image_checkerboard.png create mode 100644 crates/burn-dataset/tests/data/segmask_folder/images/image_random_2colors.png create mode 100644 crates/burn-dataset/tests/data/segmask_folder/images/image_random_3colors.png create mode 100644 crates/burn-dataset/tests/data/sqlite-dataset.db create mode 100644 crates/burn-dataset/tests/data/text_folder/negative/sample1.txt create mode 100644 crates/burn-dataset/tests/data/text_folder/negative/sample2.txt create mode 100644 crates/burn-dataset/tests/data/text_folder/positive/sample1.txt create mode 100644 crates/burn-dataset/tests/data/text_folder/positive/sample2.txt create mode 100644 crates/burn-derive/Cargo.toml create mode 120000 crates/burn-derive/LICENSE-APACHE create mode 120000 crates/burn-derive/LICENSE-MIT create mode 100644 crates/burn-derive/README.md create mode 100644 crates/burn-derive/src/config/analyzer.rs create mode 100644 crates/burn-derive/src/config/analyzer_enum.rs create mode 100644 crates/burn-derive/src/config/analyzer_struct.rs create mode 100644 crates/burn-derive/src/config/base.rs create mode 100644 crates/burn-derive/src/config/mod.rs create mode 100644 crates/burn-derive/src/lib.rs create mode 100644 crates/burn-derive/src/module/base.rs create mode 100644 crates/burn-derive/src/module/codegen.rs create mode 100644 crates/burn-derive/src/module/codegen_enum.rs create mode 100644 crates/burn-derive/src/module/codegen_struct.rs create mode 100644 crates/burn-derive/src/module/display.rs create mode 100644 crates/burn-derive/src/module/generics.rs create mode 100644 crates/burn-derive/src/module/mod.rs create mode 100644 crates/burn-derive/src/record_state/mod.rs create mode 100644 crates/burn-derive/src/shared/attribute.rs create mode 100644 crates/burn-derive/src/shared/enum_variant.rs create mode 100644 crates/burn-derive/src/shared/field.rs create mode 100644 crates/burn-derive/src/shared/generics.rs create mode 100644 crates/burn-derive/src/shared/mod.rs create mode 100644 crates/burn-dispatch/Cargo.toml create mode 100644 crates/burn-dispatch/README.md create mode 100644 crates/burn-dispatch/build.rs create mode 100644 crates/burn-dispatch/src/backend.rs create mode 100644 crates/burn-dispatch/src/device.rs create mode 100644 crates/burn-dispatch/src/lib.rs create mode 100644 crates/burn-dispatch/src/macros.rs create mode 100644 crates/burn-dispatch/src/ops/activation.rs create mode 100644 crates/burn-dispatch/src/ops/bool_tensor.rs create mode 100644 crates/burn-dispatch/src/ops/distributed.rs create mode 100644 crates/burn-dispatch/src/ops/int_tensor.rs create mode 100644 crates/burn-dispatch/src/ops/mod.rs create mode 100644 crates/burn-dispatch/src/ops/module.rs create mode 100644 crates/burn-dispatch/src/ops/qtensor.rs create mode 100644 crates/burn-dispatch/src/ops/tensor.rs create mode 100644 crates/burn-dispatch/src/ops/transaction.rs create mode 100644 crates/burn-dispatch/src/remote_server.rs create mode 100644 crates/burn-dispatch/src/tensor.rs create mode 100644 crates/burn-flex/ACKNOWLEDGMENTS.md create mode 100644 crates/burn-flex/ARCHITECTURE.md create mode 100644 crates/burn-flex/BENCHMARKS.md create mode 100644 crates/burn-flex/COMPARISON.md create mode 100644 crates/burn-flex/Cargo.toml create mode 100644 crates/burn-flex/README.md create mode 100644 crates/burn-flex/src/backend.rs create mode 100644 crates/burn-flex/src/layout.rs create mode 100644 crates/burn-flex/src/lib.rs create mode 100644 crates/burn-flex/src/ops/activation.rs create mode 100644 crates/burn-flex/src/ops/attention.rs create mode 100644 crates/burn-flex/src/ops/binary.rs create mode 100644 crates/burn-flex/src/ops/bool.rs create mode 100644 crates/burn-flex/src/ops/cat.rs create mode 100644 crates/burn-flex/src/ops/comparison.rs create mode 100644 crates/burn-flex/src/ops/conv.rs create mode 100644 crates/burn-flex/src/ops/conv_common.rs create mode 100644 crates/burn-flex/src/ops/conv_transpose.rs create mode 100644 crates/burn-flex/src/ops/cumulative.rs create mode 100644 crates/burn-flex/src/ops/deform_conv.rs create mode 100644 crates/burn-flex/src/ops/expand.rs create mode 100644 crates/burn-flex/src/ops/fft.rs create mode 100644 crates/burn-flex/src/ops/flip.rs create mode 100644 crates/burn-flex/src/ops/float.rs create mode 100644 crates/burn-flex/src/ops/gather_scatter.rs create mode 100644 crates/burn-flex/src/ops/grid_sample.rs create mode 100644 crates/burn-flex/src/ops/int.rs create mode 100644 crates/burn-flex/src/ops/interpolate.rs create mode 100644 crates/burn-flex/src/ops/mask.rs create mode 100644 crates/burn-flex/src/ops/matmul.rs create mode 100644 crates/burn-flex/src/ops/mod.rs create mode 100644 crates/burn-flex/src/ops/module.rs create mode 100644 crates/burn-flex/src/ops/pool.rs create mode 100644 crates/burn-flex/src/ops/qtensor.rs create mode 100644 crates/burn-flex/src/ops/reduce.rs create mode 100644 crates/burn-flex/src/ops/repeat_dim.rs create mode 100644 crates/burn-flex/src/ops/slice.rs create mode 100644 crates/burn-flex/src/ops/sort.rs create mode 100644 crates/burn-flex/src/ops/transaction.rs create mode 100644 crates/burn-flex/src/ops/unary.rs create mode 100644 crates/burn-flex/src/ops/unfold.rs create mode 100644 crates/burn-flex/src/qtensor.rs create mode 100644 crates/burn-flex/src/simd/aligned.rs create mode 100644 crates/burn-flex/src/simd/kernels.rs create mode 100644 crates/burn-flex/src/simd/mod.rs create mode 100644 crates/burn-flex/src/simd/portable.rs create mode 100644 crates/burn-flex/src/simd/scalar.rs create mode 100644 crates/burn-flex/src/strided_index.rs create mode 100644 crates/burn-flex/src/tensor.rs create mode 100644 crates/burn-fusion/Cargo.toml create mode 120000 crates/burn-fusion/LICENSE-APACHE create mode 120000 crates/burn-fusion/LICENSE-MIT create mode 100644 crates/burn-fusion/README.md create mode 100644 crates/burn-fusion/src/backend.rs create mode 100644 crates/burn-fusion/src/client.rs create mode 100644 crates/burn-fusion/src/inspect.rs create mode 100644 crates/burn-fusion/src/lib.rs create mode 100644 crates/burn-fusion/src/op.rs create mode 100644 crates/burn-fusion/src/ops/activation.rs create mode 100644 crates/burn-fusion/src/ops/base.rs create mode 100644 crates/burn-fusion/src/ops/binary.rs create mode 100644 crates/burn-fusion/src/ops/bool_tensor.rs create mode 100644 crates/burn-fusion/src/ops/distributed.rs create mode 100644 crates/burn-fusion/src/ops/int_tensor.rs create mode 100644 crates/burn-fusion/src/ops/mod.rs create mode 100644 crates/burn-fusion/src/ops/module.rs create mode 100644 crates/burn-fusion/src/ops/qtensor.rs create mode 100644 crates/burn-fusion/src/ops/tensor.rs create mode 100644 crates/burn-fusion/src/ops/transaction.rs create mode 100644 crates/burn-fusion/src/ops/unary.rs create mode 100644 crates/burn-fusion/src/search/block.rs create mode 100644 crates/burn-fusion/src/search/graph/dag.rs create mode 100644 crates/burn-fusion/src/search/graph/lifetime.rs create mode 100644 crates/burn-fusion/src/search/graph/mod.rs create mode 100644 crates/burn-fusion/src/search/graph/node.rs create mode 100644 crates/burn-fusion/src/search/graph/subgraph.rs create mode 100644 crates/burn-fusion/src/search/graph/tests.rs create mode 100644 crates/burn-fusion/src/search/merging.rs create mode 100644 crates/burn-fusion/src/search/mod.rs create mode 100644 crates/burn-fusion/src/search/optimization/blocks.rs create mode 100644 crates/burn-fusion/src/search/optimization/mod.rs create mode 100644 crates/burn-fusion/src/search/optimization/stream.rs create mode 100644 crates/burn-fusion/src/search/optimization/tests.rs create mode 100644 crates/burn-fusion/src/search/testing.rs create mode 100644 crates/burn-fusion/src/server.rs create mode 100644 crates/burn-fusion/src/stream/base.rs create mode 100644 crates/burn-fusion/src/stream/context.rs create mode 100644 crates/burn-fusion/src/stream/execution/base.rs create mode 100644 crates/burn-fusion/src/stream/execution/explorer.rs create mode 100644 crates/burn-fusion/src/stream/execution/mod.rs create mode 100644 crates/burn-fusion/src/stream/execution/ordering.rs create mode 100644 crates/burn-fusion/src/stream/execution/policy.rs create mode 100644 crates/burn-fusion/src/stream/execution/processor.rs create mode 100644 crates/burn-fusion/src/stream/execution/tests.rs create mode 100644 crates/burn-fusion/src/stream/execution/trace.rs create mode 100644 crates/burn-fusion/src/stream/execution/validator.rs create mode 100644 crates/burn-fusion/src/stream/memory_checks.rs create mode 100644 crates/burn-fusion/src/stream/mod.rs create mode 100644 crates/burn-fusion/src/stream/multi.rs create mode 100644 crates/burn-fusion/src/stream/queue/base.rs create mode 100644 crates/burn-fusion/src/stream/queue/execution.rs create mode 100644 crates/burn-fusion/src/stream/queue/mod.rs create mode 100644 crates/burn-fusion/src/stream/store/base.rs create mode 100644 crates/burn-fusion/src/stream/store/index.rs create mode 100644 crates/burn-fusion/src/stream/store/mod.rs create mode 100644 crates/burn-fusion/src/tensor.rs create mode 100644 crates/burn-ir/Cargo.toml create mode 100644 crates/burn-ir/README.md create mode 100644 crates/burn-ir/src/backend.rs create mode 100644 crates/burn-ir/src/builder.rs create mode 100644 crates/burn-ir/src/graph.rs create mode 100644 crates/burn-ir/src/handle.rs create mode 100644 crates/burn-ir/src/lib.rs create mode 100644 crates/burn-ir/src/operation.rs create mode 100644 crates/burn-ir/src/scalar.rs create mode 100644 crates/burn-ir/src/tensor.rs create mode 100644 crates/burn-ndarray/Cargo.toml create mode 120000 crates/burn-ndarray/LICENSE-APACHE create mode 120000 crates/burn-ndarray/LICENSE-MIT create mode 100644 crates/burn-ndarray/README.md create mode 100644 crates/burn-ndarray/build.rs create mode 100644 crates/burn-ndarray/src/backend.rs create mode 100644 crates/burn-ndarray/src/element.rs create mode 100644 crates/burn-ndarray/src/lib.rs create mode 100644 crates/burn-ndarray/src/ops/activation.rs create mode 100644 crates/burn-ndarray/src/ops/adaptive_avgpool.rs create mode 100644 crates/burn-ndarray/src/ops/avgpool.rs create mode 100644 crates/burn-ndarray/src/ops/base.rs create mode 100644 crates/burn-ndarray/src/ops/bool_tensor.rs create mode 100644 crates/burn-ndarray/src/ops/conv.rs create mode 100644 crates/burn-ndarray/src/ops/deform_conv.rs create mode 100644 crates/burn-ndarray/src/ops/grid_sample.rs create mode 100644 crates/burn-ndarray/src/ops/int_tensor.rs create mode 100644 crates/burn-ndarray/src/ops/interpolate.rs create mode 100644 crates/burn-ndarray/src/ops/macros.rs create mode 100644 crates/burn-ndarray/src/ops/matmul.rs create mode 100644 crates/burn-ndarray/src/ops/maxpool.rs create mode 100644 crates/burn-ndarray/src/ops/mod.rs create mode 100644 crates/burn-ndarray/src/ops/module.rs create mode 100644 crates/burn-ndarray/src/ops/padding.rs create mode 100644 crates/burn-ndarray/src/ops/qtensor.rs create mode 100644 crates/burn-ndarray/src/ops/quantization.rs create mode 100644 crates/burn-ndarray/src/ops/simd/avgpool.rs create mode 100644 crates/burn-ndarray/src/ops/simd/base.rs create mode 100644 crates/burn-ndarray/src/ops/simd/binary.rs create mode 100644 crates/burn-ndarray/src/ops/simd/binary_elemwise.rs create mode 100644 crates/burn-ndarray/src/ops/simd/cmp.rs create mode 100644 crates/burn-ndarray/src/ops/simd/conv.rs create mode 100644 crates/burn-ndarray/src/ops/simd/maxpool.rs create mode 100644 crates/burn-ndarray/src/ops/simd/mod.rs create mode 100644 crates/burn-ndarray/src/ops/simd/unary.rs create mode 100644 crates/burn-ndarray/src/ops/tensor.rs create mode 100644 crates/burn-ndarray/src/ops/transaction.rs create mode 100644 crates/burn-ndarray/src/parallel.rs create mode 100644 crates/burn-ndarray/src/rand.rs create mode 100644 crates/burn-ndarray/src/sharing.rs create mode 100644 crates/burn-ndarray/src/storage.rs create mode 100644 crates/burn-ndarray/src/tensor.rs create mode 100644 crates/burn-nn/Cargo.toml create mode 100644 crates/burn-nn/README.md create mode 100644 crates/burn-nn/src/activation/activation_wrapper.rs create mode 100644 crates/burn-nn/src/activation/celu.rs create mode 100644 crates/burn-nn/src/activation/elu.rs create mode 100644 crates/burn-nn/src/activation/gelu.rs create mode 100644 crates/burn-nn/src/activation/glu.rs create mode 100644 crates/burn-nn/src/activation/hard_shrink.rs create mode 100644 crates/burn-nn/src/activation/hard_sigmoid.rs create mode 100644 crates/burn-nn/src/activation/hard_swish.rs create mode 100644 crates/burn-nn/src/activation/leaky_relu.rs create mode 100644 crates/burn-nn/src/activation/mod.rs create mode 100644 crates/burn-nn/src/activation/prelu.rs create mode 100644 crates/burn-nn/src/activation/relu.rs create mode 100644 crates/burn-nn/src/activation/selu.rs create mode 100644 crates/burn-nn/src/activation/shrink.rs create mode 100644 crates/burn-nn/src/activation/sigmoid.rs create mode 100644 crates/burn-nn/src/activation/soft_shrink.rs create mode 100644 crates/burn-nn/src/activation/softplus.rs create mode 100644 crates/burn-nn/src/activation/softsign.rs create mode 100644 crates/burn-nn/src/activation/swiglu.rs create mode 100644 crates/burn-nn/src/activation/tanh.rs create mode 100644 crates/burn-nn/src/activation/thresholded_relu.rs create mode 100644 crates/burn-nn/src/lib.rs create mode 100644 crates/burn-nn/src/loss/binary_cross_entropy.rs create mode 100644 crates/burn-nn/src/loss/cosine_embedding.rs create mode 100644 crates/burn-nn/src/loss/cross_entropy.rs create mode 100644 crates/burn-nn/src/loss/ctc.rs create mode 100644 crates/burn-nn/src/loss/huber.rs create mode 100644 crates/burn-nn/src/loss/kldiv.rs create mode 100644 crates/burn-nn/src/loss/lp_loss.rs create mode 100644 crates/burn-nn/src/loss/mod.rs create mode 100644 crates/burn-nn/src/loss/mse.rs create mode 100644 crates/burn-nn/src/loss/poisson.rs create mode 100644 crates/burn-nn/src/loss/reduction.rs create mode 100644 crates/burn-nn/src/loss/rnnt.rs create mode 100644 crates/burn-nn/src/loss/smooth_l1.rs create mode 100644 crates/burn-nn/src/modules/attention/cross_attention.rs create mode 100644 crates/burn-nn/src/modules/attention/mask.rs create mode 100644 crates/burn-nn/src/modules/attention/mha.rs create mode 100644 crates/burn-nn/src/modules/attention/mod.rs create mode 100644 crates/burn-nn/src/modules/cache/autoregressive.rs create mode 100644 crates/burn-nn/src/modules/cache/base.rs create mode 100644 crates/burn-nn/src/modules/cache/mod.rs create mode 100644 crates/burn-nn/src/modules/conv/checks.rs create mode 100644 crates/burn-nn/src/modules/conv/conv1d.rs create mode 100644 crates/burn-nn/src/modules/conv/conv2d.rs create mode 100644 crates/burn-nn/src/modules/conv/conv3d.rs create mode 100644 crates/burn-nn/src/modules/conv/conv_transpose1d.rs create mode 100644 crates/burn-nn/src/modules/conv/conv_transpose2d.rs create mode 100644 crates/burn-nn/src/modules/conv/conv_transpose3d.rs create mode 100644 crates/burn-nn/src/modules/conv/deform_conv2d.rs create mode 100644 crates/burn-nn/src/modules/conv/mod.rs create mode 100644 crates/burn-nn/src/modules/dropout.rs create mode 100644 crates/burn-nn/src/modules/embedding.rs create mode 100644 crates/burn-nn/src/modules/identity.rs create mode 100644 crates/burn-nn/src/modules/interpolate/interpolate1d.rs create mode 100644 crates/burn-nn/src/modules/interpolate/interpolate2d.rs create mode 100644 crates/burn-nn/src/modules/interpolate/mod.rs create mode 100644 crates/burn-nn/src/modules/linear.rs create mode 100644 crates/burn-nn/src/modules/mod.rs create mode 100644 crates/burn-nn/src/modules/noise.rs create mode 100644 crates/burn-nn/src/modules/norm/batch.rs create mode 100644 crates/burn-nn/src/modules/norm/group.rs create mode 100644 crates/burn-nn/src/modules/norm/instance.rs create mode 100644 crates/burn-nn/src/modules/norm/layer.rs create mode 100644 crates/burn-nn/src/modules/norm/local_response.rs create mode 100644 crates/burn-nn/src/modules/norm/mod.rs create mode 100644 crates/burn-nn/src/modules/norm/normalization_wrapper.rs create mode 100644 crates/burn-nn/src/modules/norm/rms.rs create mode 100644 crates/burn-nn/src/modules/pool/adaptive_avg_pool1d.rs create mode 100644 crates/burn-nn/src/modules/pool/adaptive_avg_pool2d.rs create mode 100644 crates/burn-nn/src/modules/pool/avg_pool1d.rs create mode 100644 crates/burn-nn/src/modules/pool/avg_pool2d.rs create mode 100644 crates/burn-nn/src/modules/pool/max_pool1d.rs create mode 100644 crates/burn-nn/src/modules/pool/max_pool2d.rs create mode 100644 crates/burn-nn/src/modules/pool/mod.rs create mode 100644 crates/burn-nn/src/modules/pos_encoding.rs create mode 100644 crates/burn-nn/src/modules/rnn/basic.rs create mode 100644 crates/burn-nn/src/modules/rnn/gate_controller.rs create mode 100644 crates/burn-nn/src/modules/rnn/gru.rs create mode 100644 crates/burn-nn/src/modules/rnn/lstm.rs create mode 100644 crates/burn-nn/src/modules/rnn/mod.rs create mode 100644 crates/burn-nn/src/modules/rope_encoding.rs create mode 100644 crates/burn-nn/src/modules/transformer/decoder.rs create mode 100644 crates/burn-nn/src/modules/transformer/encoder.rs create mode 100644 crates/burn-nn/src/modules/transformer/mod.rs create mode 100644 crates/burn-nn/src/modules/transformer/pwff.rs create mode 100644 crates/burn-nn/src/modules/unfold.rs create mode 100644 crates/burn-nn/src/padding.rs create mode 100644 crates/burn-nn/tests/quantize.rs create mode 100644 crates/burn-no-std-tests/Cargo.toml create mode 100644 crates/burn-no-std-tests/README.md create mode 100644 crates/burn-no-std-tests/src/burnpack.rs create mode 100644 crates/burn-no-std-tests/src/conv.rs create mode 100644 crates/burn-no-std-tests/src/lib.rs create mode 100644 crates/burn-no-std-tests/src/mlp.rs create mode 100644 crates/burn-no-std-tests/src/model.rs create mode 100644 crates/burn-no-std-tests/src/safetensors.rs create mode 100644 crates/burn-no-std-tests/tests/burnpack_tests.rs create mode 100644 crates/burn-no-std-tests/tests/safetensors_tests.rs create mode 100644 crates/burn-no-std-tests/tests/test_integration.rs create mode 100644 crates/burn-optim/Cargo.toml create mode 100644 crates/burn-optim/README.md create mode 100644 crates/burn-optim/src/grad_clipping/base.rs create mode 100644 crates/burn-optim/src/grad_clipping/mod.rs create mode 100644 crates/burn-optim/src/lib.rs create mode 100644 crates/burn-optim/src/lr_scheduler/base.rs create mode 100644 crates/burn-optim/src/lr_scheduler/composed.rs create mode 100644 crates/burn-optim/src/lr_scheduler/constant.rs create mode 100644 crates/burn-optim/src/lr_scheduler/cosine.rs create mode 100644 crates/burn-optim/src/lr_scheduler/exponential.rs create mode 100644 crates/burn-optim/src/lr_scheduler/linear.rs create mode 100644 crates/burn-optim/src/lr_scheduler/mod.rs create mode 100644 crates/burn-optim/src/lr_scheduler/module_lr_scheduler.rs create mode 100644 crates/burn-optim/src/lr_scheduler/noam.rs create mode 100644 crates/burn-optim/src/lr_scheduler/step.rs create mode 100644 crates/burn-optim/src/optim/adagrad.rs create mode 100644 crates/burn-optim/src/optim/adam.rs create mode 100644 crates/burn-optim/src/optim/adamw.rs create mode 100644 crates/burn-optim/src/optim/adan.rs create mode 100644 crates/burn-optim/src/optim/base.rs create mode 100644 crates/burn-optim/src/optim/decay.rs create mode 100644 crates/burn-optim/src/optim/grad_accum.rs create mode 100644 crates/burn-optim/src/optim/grads.rs create mode 100644 crates/burn-optim/src/optim/lbfgs.rs create mode 100644 crates/burn-optim/src/optim/mod.rs create mode 100644 crates/burn-optim/src/optim/module/base.rs create mode 100644 crates/burn-optim/src/optim/module/mod.rs create mode 100644 crates/burn-optim/src/optim/module/module_optimizer.rs create mode 100644 crates/burn-optim/src/optim/module/record/mod.rs create mode 100644 crates/burn-optim/src/optim/momentum.rs create mode 100644 crates/burn-optim/src/optim/muon.rs create mode 100644 crates/burn-optim/src/optim/rmsprop.rs create mode 100644 crates/burn-optim/src/optim/sgd.rs create mode 100644 crates/burn-optim/src/optim/state.rs create mode 100644 crates/burn-optim/src/optim/visitor.rs create mode 100644 crates/burn-pack/Cargo.toml create mode 100644 crates/burn-pack/README.md create mode 100644 crates/burn-pack/src/base.rs create mode 100644 crates/burn-pack/src/lib.rs create mode 100644 crates/burn-pack/src/reader.rs create mode 100644 crates/burn-pack/src/tensor.rs create mode 100644 crates/burn-pack/src/writer.rs create mode 100644 crates/burn-pack/tests/alignment.rs create mode 100644 crates/burn-pack/tests/common/mod.rs create mode 100644 crates/burn-pack/tests/errors.rs create mode 100644 crates/burn-pack/tests/header.rs create mode 100644 crates/burn-pack/tests/round_trip.rs create mode 100644 crates/burn-remote/Cargo.toml create mode 100644 crates/burn-remote/README.md create mode 100644 crates/burn-remote/src/client/base.rs create mode 100644 crates/burn-remote/src/client/channel.rs create mode 100644 crates/burn-remote/src/client/custom_op.rs create mode 100644 crates/burn-remote/src/client/mod.rs create mode 100644 crates/burn-remote/src/client/runner.rs create mode 100644 crates/burn-remote/src/client/runtime.rs create mode 100644 crates/burn-remote/src/client/service.rs create mode 100644 crates/burn-remote/src/client/service/batch.rs create mode 100644 crates/burn-remote/src/client/service/conn.rs create mode 100644 crates/burn-remote/src/client/service/pending.rs create mode 100644 crates/burn-remote/src/client/service/registry.rs create mode 100644 crates/burn-remote/src/client/service/writer.rs create mode 100644 crates/burn-remote/src/lib.rs create mode 100644 crates/burn-remote/src/metrics.rs create mode 100644 crates/burn-remote/src/server/builder.rs create mode 100644 crates/burn-remote/src/server/local_comm.rs create mode 100644 crates/burn-remote/src/server/mod.rs create mode 100644 crates/burn-remote/src/server/pump.rs create mode 100644 crates/burn-remote/src/server/service/base.rs create mode 100644 crates/burn-remote/src/server/service/mod.rs create mode 100644 crates/burn-remote/src/server/session.rs create mode 100644 crates/burn-remote/src/server/spawn.rs create mode 100644 crates/burn-remote/src/server/transfer.rs create mode 100644 crates/burn-remote/src/server/worker.rs create mode 100644 crates/burn-remote/src/shared/mod.rs create mode 100644 crates/burn-remote/src/shared/task.rs create mode 100644 crates/burn-remote/src/telemetry.rs create mode 100644 crates/burn-remote/src/transport/identity.rs create mode 100644 crates/burn-remote/src/transport/iroh/link.rs create mode 100644 crates/burn-remote/src/transport/iroh/mod.rs create mode 100644 crates/burn-remote/src/transport/iroh/node.rs create mode 100644 crates/burn-remote/src/transport/iroh/protocol.rs create mode 100644 crates/burn-remote/src/transport/iroh/secret.rs create mode 100644 crates/burn-remote/src/transport/iroh/server.rs create mode 100644 crates/burn-remote/src/transport/iroh/time.rs create mode 100644 crates/burn-remote/src/transport/iroh/transfer.rs create mode 100644 crates/burn-remote/src/transport/link.rs create mode 100644 crates/burn-remote/src/transport/mod.rs create mode 100644 crates/burn-remote/src/transport/websocket/link.rs create mode 100644 crates/burn-remote/src/transport/websocket/mod.rs create mode 100644 crates/burn-remote/src/transport/websocket/server.rs create mode 100644 crates/burn-remote/src/transport/websocket/transfer.rs create mode 100644 crates/burn-remote/tests/iroh.rs create mode 100644 crates/burn-rl/Cargo.toml create mode 120000 crates/burn-rl/LICENSE-APACHE create mode 120000 crates/burn-rl/LICENSE-MIT create mode 100644 crates/burn-rl/README.md create mode 100644 crates/burn-rl/src/environment/base.rs create mode 100644 crates/burn-rl/src/environment/mod.rs create mode 100644 crates/burn-rl/src/lib.rs create mode 100644 crates/burn-rl/src/policy/async_policy.rs create mode 100644 crates/burn-rl/src/policy/base.rs create mode 100644 crates/burn-rl/src/policy/mod.rs create mode 100644 crates/burn-rl/src/transition_buffer/base.rs create mode 100644 crates/burn-rl/src/transition_buffer/mod.rs create mode 100644 crates/burn-rl/src/transition_buffer/slice_access.rs create mode 100644 crates/burn-rocm/Cargo.toml create mode 100644 crates/burn-rocm/README.md create mode 100644 crates/burn-rocm/src/lib.rs create mode 100644 crates/burn-router/Cargo.toml create mode 100644 crates/burn-router/README.md create mode 100644 crates/burn-router/src/backend.rs create mode 100644 crates/burn-router/src/bridge/base.rs create mode 100644 crates/burn-router/src/bridge/mod.rs create mode 100644 crates/burn-router/src/channel/base.rs create mode 100644 crates/burn-router/src/channel/mod.rs create mode 100644 crates/burn-router/src/client/base.rs create mode 100644 crates/burn-router/src/client/mod.rs create mode 100644 crates/burn-router/src/custom_op.rs create mode 100644 crates/burn-router/src/fusion.rs create mode 100644 crates/burn-router/src/graph.rs create mode 100644 crates/burn-router/src/interpreter.rs create mode 100644 crates/burn-router/src/lib.rs create mode 100644 crates/burn-router/src/ops/activation.rs create mode 100644 crates/burn-router/src/ops/binary.rs create mode 100644 crates/burn-router/src/ops/bool_tensor.rs create mode 100644 crates/burn-router/src/ops/distributed.rs create mode 100644 crates/burn-router/src/ops/int_tensor.rs create mode 100644 crates/burn-router/src/ops/mod.rs create mode 100644 crates/burn-router/src/ops/module.rs create mode 100644 crates/burn-router/src/ops/qtensor.rs create mode 100644 crates/burn-router/src/ops/tensor.rs create mode 100644 crates/burn-router/src/ops/transaction.rs create mode 100644 crates/burn-router/src/ops/unary.rs create mode 100644 crates/burn-router/src/tensor.rs create mode 100644 crates/burn-std/Cargo.toml create mode 120000 crates/burn-std/LICENSE-APACHE create mode 120000 crates/burn-std/LICENSE-MIT create mode 100644 crates/burn-std/README.md create mode 100644 crates/burn-std/src/config/autodiff.rs create mode 100644 crates/burn-std/src/config/base.rs create mode 100644 crates/burn-std/src/config/fusion.rs create mode 100644 crates/burn-std/src/config/logger.rs create mode 100644 crates/burn-std/src/config/mod.rs create mode 100644 crates/burn-std/src/config/remote.rs create mode 100644 crates/burn-std/src/data/compare.rs create mode 100644 crates/burn-std/src/data/mod.rs create mode 100644 crates/burn-std/src/data/tensor.rs create mode 100644 crates/burn-std/src/device_settings.rs create mode 100644 crates/burn-std/src/distributed.rs create mode 100644 crates/burn-std/src/distribution.rs create mode 100644 crates/burn-std/src/element/base.rs create mode 100644 crates/burn-std/src/element/cast.rs create mode 100644 crates/burn-std/src/element/mod.rs create mode 100644 crates/burn-std/src/element/scalar.rs create mode 100644 crates/burn-std/src/id.rs create mode 100644 crates/burn-std/src/lib.rs create mode 100644 crates/burn-std/src/network.rs create mode 100644 crates/burn-std/src/ops.rs create mode 100644 crates/burn-std/src/runtime_kind.rs create mode 100644 crates/burn-std/src/tensor/container.rs create mode 100644 crates/burn-std/src/tensor/dtype.rs create mode 100644 crates/burn-std/src/tensor/matmul.rs create mode 100644 crates/burn-std/src/tensor/mod.rs create mode 100644 crates/burn-std/src/tensor/quantization.rs create mode 100644 crates/burn-std/src/tensor/shape.rs create mode 100644 crates/burn-std/src/tensor/slice.rs create mode 100644 crates/burn-std/tests/config.rs create mode 100644 crates/burn-store/Cargo.toml create mode 100644 crates/burn-store/MIGRATION.md create mode 100644 crates/burn-store/README.md create mode 100644 crates/burn-store/benches/download_resnet18.py create mode 100644 crates/burn-store/benches/generate_unified_models.py create mode 100644 crates/burn-store/benches/resnet18_loading.rs create mode 100644 crates/burn-store/benches/unified_loading.rs create mode 100644 crates/burn-store/benches/unified_saving.rs create mode 100644 crates/burn-store/benches/zero_copy_loading.rs create mode 100644 crates/burn-store/examples/burnpack_inspect.rs create mode 100644 crates/burn-store/examples/half_precision.rs create mode 100644 crates/burn-store/pytorch-tests/Cargo.toml create mode 100644 crates/burn-store/pytorch-tests/src/lib.rs create mode 100644 crates/burn-store/pytorch-tests/tests/batch_norm/batch_norm2d.pt create mode 100755 crates/burn-store/pytorch-tests/tests/batch_norm/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/batch_norm/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/boolean/boolean.pt create mode 100755 crates/burn-store/pytorch-tests/tests/boolean/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/boolean/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/buffer/buffer.pt create mode 100755 crates/burn-store/pytorch-tests/tests/buffer/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/buffer/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/complex_nested/complex_nested.pt create mode 100755 crates/burn-store/pytorch-tests/tests/complex_nested/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/complex_nested/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/config/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/config/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/config/weights_with_config.pt create mode 100644 crates/burn-store/pytorch-tests/tests/conv1d/conv1d.pt create mode 100755 crates/burn-store/pytorch-tests/tests/conv1d/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/conv1d/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/conv2d/conv2d.pt create mode 100755 crates/burn-store/pytorch-tests/tests/conv2d/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/conv2d/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/conv_transpose1d/conv_transpose1d.pt create mode 100755 crates/burn-store/pytorch-tests/tests/conv_transpose1d/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/conv_transpose1d/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/conv_transpose2d/conv_transpose2d.pt create mode 100755 crates/burn-store/pytorch-tests/tests/conv_transpose2d/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/conv_transpose2d/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/debug_test.pt create mode 100644 crates/burn-store/pytorch-tests/tests/embedding/embedding.pt create mode 100755 crates/burn-store/pytorch-tests/tests/embedding/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/embedding/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_false.pt create mode 100644 crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_true.pt create mode 100755 crates/burn-store/pytorch-tests/tests/enum_module/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/enum_module/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/group_norm/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/group_norm/group_norm.pt create mode 100644 crates/burn-store/pytorch-tests/tests/group_norm/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/integer/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/integer/integer.pt create mode 100644 crates/burn-store/pytorch-tests/tests/integer/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/key_remap/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/key_remap/key_remap.pt create mode 100644 crates/burn-store/pytorch-tests/tests/key_remap/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/key_remap_chained/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/key_remap_chained/key_remap.pt create mode 100644 crates/burn-store/pytorch-tests/tests/key_remap_chained/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/layer_norm/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/layer_norm/layer_norm.pt create mode 100644 crates/burn-store/pytorch-tests/tests/layer_norm/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/linear/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/linear/linear.pt create mode 100644 crates/burn-store/pytorch-tests/tests/linear/linear_with_bias.pt create mode 100644 crates/burn-store/pytorch-tests/tests/linear/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/missing_module_field/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/missing_module_field/missing_module_field.pt create mode 100644 crates/burn-store/pytorch-tests/tests/missing_module_field/mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/non_contiguous_indexes.pt create mode 100644 crates/burn-store/pytorch-tests/tests/test_int.pt create mode 100644 crates/burn-store/pytorch-tests/tests/test_mod.rs create mode 100755 crates/burn-store/pytorch-tests/tests/top_level_key/export_weights.py create mode 100644 crates/burn-store/pytorch-tests/tests/top_level_key/mod.rs create mode 100644 crates/burn-store/pytorch-tests/tests/top_level_key/top_level_key.pt create mode 100644 crates/burn-store/safetensors-tests/Cargo.toml create mode 100644 crates/burn-store/safetensors-tests/src/lib.rs create mode 100644 crates/burn-store/safetensors-tests/tests/multi_layer/mod.rs create mode 100755 crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.py create mode 100644 crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.safetensors create mode 100644 crates/burn-store/safetensors-tests/tests/test_mod.rs create mode 100644 crates/burn-store/src/adapter.rs create mode 100644 crates/burn-store/src/applier.rs create mode 100644 crates/burn-store/src/apply_result.rs create mode 100644 crates/burn-store/src/bridge.rs create mode 100644 crates/burn-store/src/burnpack.rs create mode 100644 crates/burn-store/src/collector.rs create mode 100644 crates/burn-store/src/filter.rs create mode 100644 crates/burn-store/src/keyremapper.rs create mode 100644 crates/burn-store/src/lib.rs create mode 100644 crates/burn-store/src/nested/adapter.rs create mode 100644 crates/burn-store/src/nested/data.rs create mode 100644 crates/burn-store/src/nested/de.rs create mode 100644 crates/burn-store/src/nested/error.rs create mode 100644 crates/burn-store/src/nested/mod.rs create mode 100644 crates/burn-store/src/pytorch/lazy_data.rs create mode 100644 crates/burn-store/src/pytorch/mod.rs create mode 100644 crates/burn-store/src/pytorch/pickle_reader.rs create mode 100644 crates/burn-store/src/pytorch/reader.rs create mode 100644 crates/burn-store/src/pytorch/store.rs create mode 100644 crates/burn-store/src/pytorch/tests/mod.rs create mode 100644 crates/burn-store/src/pytorch/tests/reader/create_legacy_with_offsets.py create mode 100644 crates/burn-store/src/pytorch/tests/reader/create_tar_format.py create mode 100644 crates/burn-store/src/pytorch/tests/reader/mod.rs create mode 100644 crates/burn-store/src/pytorch/tests/reader/simple_legacy.py create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data.py create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/bfloat16.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/bool.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/broken.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/buffers.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/checkpoint.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/complex_structure.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/empty.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/extreme_values.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/float16.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/float32.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/float64.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/int16.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/int32.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/int64.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/int8.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/large_shape.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/legacy_shared_storage.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/legacy_uncloned_views.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/legacy_with_offsets.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/mixed_types.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/nested_dict.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/parameter.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/scalar.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/simple_legacy.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/special_values.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/state_dict.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_2d_tensor.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_float32.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_float64.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_int64.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_multi_dtype.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tar_weight_bias.tar create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tensor_2d.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tensor_3d.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/tensor_4d.pt create mode 100644 crates/burn-store/src/pytorch/tests/reader/test_data/uint8.pt create mode 100644 crates/burn-store/src/pytorch/tests/store/mod.rs create mode 100644 crates/burn-store/src/pytorch/tests/store/test_data/generate_enum_test.py create mode 100644 crates/burn-store/src/pytorch/tests/store/test_data/model_without_enum_variants.pt create mode 100644 crates/burn-store/src/safetensors/mod.rs create mode 100644 crates/burn-store/src/safetensors/store.rs create mode 100644 crates/burn-store/src/safetensors/tests/adapter.rs create mode 100644 crates/burn-store/src/safetensors/tests/direct_access.rs create mode 100644 crates/burn-store/src/safetensors/tests/error_handling.rs create mode 100644 crates/burn-store/src/safetensors/tests/file_io.rs create mode 100644 crates/burn-store/src/safetensors/tests/filtering.rs create mode 100644 crates/burn-store/src/safetensors/tests/integration.rs create mode 100644 crates/burn-store/src/safetensors/tests/metadata.rs create mode 100644 crates/burn-store/src/safetensors/tests/mixed_datatypes.rs create mode 100644 crates/burn-store/src/safetensors/tests/mod.rs create mode 100644 crates/burn-store/src/safetensors/tests/multi_layer_verify.rs create mode 100644 crates/burn-store/src/safetensors/tests/pytorch_import.rs create mode 100644 crates/burn-store/src/safetensors/tests/round_trip.rs create mode 100644 crates/burn-store/src/tensor_snapshot.rs create mode 100644 crates/burn-store/src/traits.rs create mode 100644 crates/burn-tch/Cargo.toml create mode 120000 crates/burn-tch/LICENSE-APACHE create mode 120000 crates/burn-tch/LICENSE-MIT create mode 100644 crates/burn-tch/README.md create mode 100644 crates/burn-tch/build.rs create mode 100644 crates/burn-tch/src/backend.rs create mode 100644 crates/burn-tch/src/bin/cpu.rs create mode 100644 crates/burn-tch/src/bin/cuda.rs create mode 100644 crates/burn-tch/src/bin/mps.rs create mode 100644 crates/burn-tch/src/cuda_hack/dummy_cuda_dependency.cpp create mode 100644 crates/burn-tch/src/cuda_hack/fake_cuda_dependency.cpp create mode 100644 crates/burn-tch/src/element.rs create mode 100644 crates/burn-tch/src/lib.rs create mode 100644 crates/burn-tch/src/ops/activation.rs create mode 100644 crates/burn-tch/src/ops/base.rs create mode 100644 crates/burn-tch/src/ops/bool_tensor.rs create mode 100644 crates/burn-tch/src/ops/int_tensor.rs create mode 100644 crates/burn-tch/src/ops/mod.rs create mode 100644 crates/burn-tch/src/ops/module.rs create mode 100644 crates/burn-tch/src/ops/qtensor.rs create mode 100644 crates/burn-tch/src/ops/tensor.rs create mode 100644 crates/burn-tch/src/ops/transaction.rs create mode 100644 crates/burn-tch/src/tensor.rs create mode 100644 crates/burn-tensor/Cargo.toml create mode 120000 crates/burn-tensor/LICENSE-APACHE create mode 120000 crates/burn-tensor/LICENSE-MIT create mode 100644 crates/burn-tensor/README.md create mode 120000 crates/burn-tensor/katex-header.html create mode 100644 crates/burn-tensor/src/bridge/kind.rs create mode 100644 crates/burn-tensor/src/bridge/mod.rs create mode 100644 crates/burn-tensor/src/bridge/ops/autodiff.rs create mode 100644 crates/burn-tensor/src/bridge/ops/base.rs create mode 100644 crates/burn-tensor/src/bridge/ops/bool.rs create mode 100644 crates/burn-tensor/src/bridge/ops/float.rs create mode 100644 crates/burn-tensor/src/bridge/ops/int.rs create mode 100644 crates/burn-tensor/src/bridge/ops/math.rs create mode 100644 crates/burn-tensor/src/bridge/ops/mod.rs create mode 100644 crates/burn-tensor/src/bridge/ops/numeric.rs create mode 100644 crates/burn-tensor/src/bridge/ops/ordered.rs create mode 100644 crates/burn-tensor/src/device.rs create mode 100644 crates/burn-tensor/src/lib.rs create mode 100644 crates/burn-tensor/src/server.rs create mode 100644 crates/burn-tensor/src/tensor/activation/base.rs create mode 100644 crates/burn-tensor/src/tensor/activation/mod.rs create mode 100644 crates/burn-tensor/src/tensor/api/autodiff.rs create mode 100644 crates/burn-tensor/src/tensor/api/base.rs create mode 100644 crates/burn-tensor/src/tensor/api/bool.rs create mode 100644 crates/burn-tensor/src/tensor/api/cartesian_grid.rs create mode 100644 crates/burn-tensor/src/tensor/api/cast.rs create mode 100644 crates/burn-tensor/src/tensor/api/check.rs create mode 100644 crates/burn-tensor/src/tensor/api/extension.rs create mode 100644 crates/burn-tensor/src/tensor/api/float.rs create mode 100644 crates/burn-tensor/src/tensor/api/fmod.rs create mode 100644 crates/burn-tensor/src/tensor/api/graph.rs create mode 100644 crates/burn-tensor/src/tensor/api/int.rs create mode 100644 crates/burn-tensor/src/tensor/api/mod.rs create mode 100644 crates/burn-tensor/src/tensor/api/numeric.rs create mode 100644 crates/burn-tensor/src/tensor/api/options.rs create mode 100644 crates/burn-tensor/src/tensor/api/orderable.rs create mode 100644 crates/burn-tensor/src/tensor/api/pad.rs create mode 100644 crates/burn-tensor/src/tensor/api/take.rs create mode 100644 crates/burn-tensor/src/tensor/api/transaction.rs create mode 100644 crates/burn-tensor/src/tensor/api/trunc.rs create mode 100644 crates/burn-tensor/src/tensor/distributed.rs create mode 100644 crates/burn-tensor/src/tensor/grid/affine_grid.rs create mode 100644 crates/burn-tensor/src/tensor/grid/meshgrid.rs create mode 100644 crates/burn-tensor/src/tensor/grid/mod.rs create mode 100644 crates/burn-tensor/src/tensor/kind.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/cosine_similarity.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/det.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/diag.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/lu.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/lu_decomposition.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/matvec.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/mod.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/outer.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/trace.rs create mode 100644 crates/burn-tensor/src/tensor/linalg/vector_norm.rs create mode 100644 crates/burn-tensor/src/tensor/loss/mod.rs create mode 100644 crates/burn-tensor/src/tensor/mod.rs create mode 100644 crates/burn-tensor/src/tensor/module.rs create mode 100644 crates/burn-tensor/src/tensor/quantization.rs create mode 100644 crates/burn-tensor/src/tensor/report.rs create mode 100644 crates/burn-tensor/src/tensor/signal/blackman_window.rs create mode 100644 crates/burn-tensor/src/tensor/signal/fft.rs create mode 100644 crates/burn-tensor/src/tensor/signal/hamming_window.rs create mode 100644 crates/burn-tensor/src/tensor/signal/hann_window.rs create mode 100644 crates/burn-tensor/src/tensor/signal/mod.rs create mode 100644 crates/burn-tensor/src/tensor/signal/stft.rs create mode 100644 crates/burn-tensor/src/tensor/stats/mod.rs create mode 100644 crates/burn-train/Cargo.toml create mode 120000 crates/burn-train/LICENSE-APACHE create mode 120000 crates/burn-train/LICENSE-MIT create mode 100644 crates/burn-train/README.md create mode 100644 crates/burn-train/src/checkpoint/async_checkpoint.rs create mode 100644 crates/burn-train/src/checkpoint/base.rs create mode 100644 crates/burn-train/src/checkpoint/file.rs create mode 100644 crates/burn-train/src/checkpoint/mod.rs create mode 100644 crates/burn-train/src/checkpoint/strategy/base.rs create mode 100644 crates/burn-train/src/checkpoint/strategy/composed.rs create mode 100644 crates/burn-train/src/checkpoint/strategy/lastn.rs create mode 100644 crates/burn-train/src/checkpoint/strategy/metric.rs create mode 100644 crates/burn-train/src/checkpoint/strategy/mod.rs create mode 100644 crates/burn-train/src/components.rs create mode 100644 crates/burn-train/src/evaluator/base.rs create mode 100644 crates/burn-train/src/evaluator/builder.rs create mode 100644 crates/burn-train/src/evaluator/components.rs create mode 100644 crates/burn-train/src/evaluator/mod.rs create mode 100644 crates/burn-train/src/learner/application_logger.rs create mode 100644 crates/burn-train/src/learner/base.rs create mode 100644 crates/burn-train/src/learner/classification.rs create mode 100644 crates/burn-train/src/learner/early_stopping.rs create mode 100644 crates/burn-train/src/learner/mod.rs create mode 100644 crates/burn-train/src/learner/regression.rs create mode 100644 crates/burn-train/src/learner/rl/checkpointer.rs create mode 100644 crates/burn-train/src/learner/rl/components.rs create mode 100644 crates/burn-train/src/learner/rl/env_runner/async_runner.rs create mode 100644 crates/burn-train/src/learner/rl/env_runner/base.rs create mode 100644 crates/burn-train/src/learner/rl/env_runner/mod.rs create mode 100644 crates/burn-train/src/learner/rl/mod.rs create mode 100644 crates/burn-train/src/learner/rl/off_policy.rs create mode 100644 crates/burn-train/src/learner/rl/output.rs create mode 100644 crates/burn-train/src/learner/rl/paradigm.rs create mode 100644 crates/burn-train/src/learner/rl/strategy.rs create mode 100644 crates/burn-train/src/learner/sequence.rs create mode 100644 crates/burn-train/src/learner/sharder.rs create mode 100644 crates/burn-train/src/learner/summary.rs create mode 100644 crates/burn-train/src/learner/supervised/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/paradigm.rs create mode 100644 crates/burn-train/src/learner/supervised/step/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/step/train.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/base.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/ddp/README.md create mode 100644 crates/burn-train/src/learner/supervised/strategies/ddp/epoch.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/ddp/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/ddp/strategy.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/ddp/worker.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/multi/epoch.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/multi/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/multi/strategy.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/single/epoch.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/single/mod.rs create mode 100644 crates/burn-train/src/learner/supervised/strategies/single/strategy.rs create mode 100644 crates/burn-train/src/learner/train_val.rs create mode 100644 crates/burn-train/src/lib.rs create mode 100644 crates/burn-train/src/logger/async_logger.rs create mode 100644 crates/burn-train/src/logger/base.rs create mode 100644 crates/burn-train/src/logger/file.rs create mode 100644 crates/burn-train/src/logger/in_memory.rs create mode 100644 crates/burn-train/src/logger/metric.rs create mode 100644 crates/burn-train/src/logger/mod.rs create mode 100644 crates/burn-train/src/logger/progress.rs create mode 100644 crates/burn-train/src/metric/acc.rs create mode 100644 crates/burn-train/src/metric/auc_pr.rs create mode 100644 crates/burn-train/src/metric/auroc.rs create mode 100644 crates/burn-train/src/metric/base.rs create mode 100644 crates/burn-train/src/metric/bleu.rs create mode 100644 crates/burn-train/src/metric/cer.rs create mode 100644 crates/burn-train/src/metric/classification.rs create mode 100644 crates/burn-train/src/metric/confusion_stats.rs create mode 100644 crates/burn-train/src/metric/cpu_temp.rs create mode 100644 crates/burn-train/src/metric/cpu_use.rs create mode 100644 crates/burn-train/src/metric/cuda.rs create mode 100644 crates/burn-train/src/metric/fbetascore.rs create mode 100644 crates/burn-train/src/metric/hamming.rs create mode 100644 crates/burn-train/src/metric/iteration.rs create mode 100644 crates/burn-train/src/metric/learning_rate.rs create mode 100644 crates/burn-train/src/metric/loss.rs create mode 100644 crates/burn-train/src/metric/memory_use.rs create mode 100644 crates/burn-train/src/metric/mod.rs create mode 100644 crates/burn-train/src/metric/perplexity.rs create mode 100644 crates/burn-train/src/metric/precision.rs create mode 100644 crates/burn-train/src/metric/processor/async_wrapper.rs create mode 100644 crates/burn-train/src/metric/processor/base.rs create mode 100644 crates/burn-train/src/metric/processor/full.rs create mode 100644 crates/burn-train/src/metric/processor/metrics.rs create mode 100644 crates/burn-train/src/metric/processor/minimal.rs create mode 100644 crates/burn-train/src/metric/processor/mod.rs create mode 100644 crates/burn-train/src/metric/processor/rl_metrics.rs create mode 100644 crates/burn-train/src/metric/processor/rl_processor.rs create mode 100644 crates/burn-train/src/metric/recall.rs create mode 100644 crates/burn-train/src/metric/rl/cum_reward.rs create mode 100644 crates/burn-train/src/metric/rl/ep_len.rs create mode 100644 crates/burn-train/src/metric/rl/exploration_rate.rs create mode 100644 crates/burn-train/src/metric/rl/mod.rs create mode 100644 crates/burn-train/src/metric/rouge.rs create mode 100644 crates/burn-train/src/metric/state.rs create mode 100644 crates/burn-train/src/metric/store/aggregate.rs create mode 100644 crates/burn-train/src/metric/store/base.rs create mode 100644 crates/burn-train/src/metric/store/client.rs create mode 100644 crates/burn-train/src/metric/store/log.rs create mode 100644 crates/burn-train/src/metric/store/mod.rs create mode 100644 crates/burn-train/src/metric/top_k_acc.rs create mode 100644 crates/burn-train/src/metric/vision/afine/calibrators.rs create mode 100644 crates/burn-train/src/metric/vision/afine/clip_attention.rs create mode 100644 crates/burn-train/src/metric/vision/afine/clip_vit.rs create mode 100644 crates/burn-train/src/metric/vision/afine/heads.rs create mode 100644 crates/burn-train/src/metric/vision/afine/metric.rs create mode 100644 crates/burn-train/src/metric/vision/afine/mod.rs create mode 100644 crates/burn-train/src/metric/vision/afine/quick_gelu.rs create mode 100644 crates/burn-train/src/metric/vision/afine/weights.rs create mode 100644 crates/burn-train/src/metric/vision/dice.rs create mode 100644 crates/burn-train/src/metric/vision/dists/l2pool.rs create mode 100644 crates/burn-train/src/metric/vision/dists/metric.rs create mode 100644 crates/burn-train/src/metric/vision/dists/mod.rs create mode 100644 crates/burn-train/src/metric/vision/dists/vgg16_l2pool.rs create mode 100644 crates/burn-train/src/metric/vision/dists/weights.rs create mode 100644 crates/burn-train/src/metric/vision/fid/inception.rs create mode 100644 crates/burn-train/src/metric/vision/fid/metric.rs create mode 100644 crates/burn-train/src/metric/vision/fid/mod.rs create mode 100644 crates/burn-train/src/metric/vision/fid/weights.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/alexnet.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/metric.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/mod.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/squeezenet.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/vgg.rs create mode 100644 crates/burn-train/src/metric/vision/lpips/weights.rs create mode 100644 crates/burn-train/src/metric/vision/mod.rs create mode 100644 crates/burn-train/src/metric/vision/ms_ssim.rs create mode 100644 crates/burn-train/src/metric/vision/psnr.rs create mode 100644 crates/burn-train/src/metric/vision/ssim.rs create mode 100644 crates/burn-train/src/metric/wer.rs create mode 100644 crates/burn-train/src/renderer/base.rs create mode 100644 crates/burn-train/src/renderer/cli.rs create mode 100644 crates/burn-train/src/renderer/mod.rs create mode 100644 crates/burn-train/src/renderer/tui/base.rs create mode 100644 crates/burn-train/src/renderer/tui/controls.rs create mode 100644 crates/burn-train/src/renderer/tui/full_history.rs create mode 100644 crates/burn-train/src/renderer/tui/metric_numeric.rs create mode 100644 crates/burn-train/src/renderer/tui/metric_text.rs create mode 100644 crates/burn-train/src/renderer/tui/mod.rs create mode 100644 crates/burn-train/src/renderer/tui/plot_utils.rs create mode 100644 crates/burn-train/src/renderer/tui/popup.rs create mode 100644 crates/burn-train/src/renderer/tui/progress.rs create mode 100644 crates/burn-train/src/renderer/tui/recent_history.rs create mode 100644 crates/burn-train/src/renderer/tui/renderer.rs create mode 100644 crates/burn-train/src/renderer/tui/status.rs create mode 100644 crates/burn-train/tests/checkpoint.rs create mode 100644 crates/burn-train/tests/common/mod.rs create mode 100644 crates/burn-train/tests/lr_policy.rs create mode 100644 crates/burn-vision/Cargo.toml create mode 100644 crates/burn-vision/src/backends/cpu/base.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_center_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_first_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_forest_labels.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_last_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_single_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti/mod.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti_4c/Spaghetti4C_center_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti_4c/Spaghetti4C_first_line_forest_code.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti_4c/Spaghetti4C_forest_labels.rs create mode 100644 crates/burn-vision/src/backends/cpu/connected_components/spaghetti_4c/mod.rs create mode 100644 crates/burn-vision/src/backends/cpu/mod.rs create mode 100644 crates/burn-vision/src/backends/cpu/morphology/filter.rs create mode 100644 crates/burn-vision/src/backends/cpu/morphology/filter_engine.rs create mode 100644 crates/burn-vision/src/backends/cpu/morphology/mod.rs create mode 100644 crates/burn-vision/src/backends/cpu/nms.rs create mode 100644 crates/burn-vision/src/backends/cpu/ops.rs create mode 100644 crates/burn-vision/src/backends/cube/connected_components/hardware_accelerated.rs create mode 100644 crates/burn-vision/src/backends/cube/connected_components/mod.rs create mode 100644 crates/burn-vision/src/backends/cube/connected_components/prefix_sum.rs create mode 100644 crates/burn-vision/src/backends/cube/mod.rs create mode 100644 crates/burn-vision/src/backends/cube/ops.rs create mode 100644 crates/burn-vision/src/backends/mod.rs create mode 100644 crates/burn-vision/src/base.rs create mode 100644 crates/burn-vision/src/lib.rs create mode 100644 crates/burn-vision/src/loss/gram_matrix/gram_matrix_loss.rs create mode 100644 crates/burn-vision/src/loss/gram_matrix/mod.rs create mode 100644 crates/burn-vision/src/loss/gram_matrix/vgg19.rs create mode 100644 crates/burn-vision/src/loss/gram_matrix/weights.rs create mode 100644 crates/burn-vision/src/loss/mod.rs create mode 100644 crates/burn-vision/src/ops/base.rs create mode 100644 crates/burn-vision/src/ops/mod.rs create mode 100644 crates/burn-vision/src/tensor.rs create mode 100644 crates/burn-vision/src/transform/mod.rs create mode 100644 crates/burn-vision/src/transform/transform2d.rs create mode 100644 crates/burn-vision/src/utils/mod.rs create mode 100644 crates/burn-vision/src/utils/save.rs create mode 100644 crates/burn-vision/tests/common/mod.rs create mode 100644 crates/burn-vision/tests/connected_components.rs create mode 100644 crates/burn-vision/tests/images/morphology/Base_1.png create mode 100644 crates/burn-vision/tests/images/morphology/Base_2.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_1_3x5_Cross.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_1_3x5_Rect.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_1_5x5_Cross.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_1_5x5_Ellipse.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_1_5x5_Rect.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_3x5_Cross.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_3x5_Rect.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_5x7_Cross_ANCHOR_BORDER_REPLICATE.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_5x7_Rect_ANCHOR.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Cross_BORDER_REFLECT.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Cross_BORDER_REFLECT101.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Cross_BORDER_REPLICATE.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Rect_BORDER_REFLECT.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Rect_BORDER_REFLECT101.png create mode 100644 crates/burn-vision/tests/images/morphology/Dilate_2_7x7_Rect_BORDER_REPLICATE.png create mode 100644 crates/burn-vision/tests/images/morphology/Erode_1_5x5_Cross.png create mode 100644 crates/burn-vision/tests/images/morphology/Erode_1_5x5_Ellipse.png create mode 100644 crates/burn-vision/tests/images/morphology/Erode_1_5x5_Rect.png create mode 100644 crates/burn-vision/tests/morphology.rs create mode 100644 crates/burn-vision/tests/nms.rs create mode 100644 crates/burn-wgpu/Cargo.toml create mode 120000 crates/burn-wgpu/LICENSE-APACHE create mode 120000 crates/burn-wgpu/LICENSE-MIT create mode 100644 crates/burn-wgpu/README.md create mode 100644 crates/burn-wgpu/src/lib.rs create mode 100644 crates/burn/Cargo.toml create mode 120000 crates/burn/LICENSE-APACHE create mode 120000 crates/burn/LICENSE-MIT create mode 120000 crates/burn/README.md create mode 100644 crates/burn/src/lib.rs create mode 100644 crates/burn/tests/backend_extension_remote.rs create mode 100644 deny.toml create mode 100644 docs/katex-header.html create mode 100644 examples/custom-csv-dataset/.gitignore create mode 100644 examples/custom-csv-dataset/Cargo.toml create mode 100644 examples/custom-csv-dataset/README.md create mode 100644 examples/custom-csv-dataset/examples/custom-csv-dataset.rs create mode 100644 examples/custom-csv-dataset/examples/dataframe-dataset.rs create mode 100644 examples/custom-csv-dataset/src/dataframe_dataset.rs create mode 100644 examples/custom-csv-dataset/src/dataset.rs create mode 100644 examples/custom-csv-dataset/src/diabetes_patient.rs create mode 100644 examples/custom-csv-dataset/src/lib.rs create mode 100644 examples/custom-csv-dataset/src/utils.rs create mode 100644 examples/custom-cubecl-kernel/Cargo.toml create mode 100644 examples/custom-cubecl-kernel/examples/custom-cubecl-kernel.rs create mode 100644 examples/custom-cubecl-kernel/src/backward.rs create mode 100644 examples/custom-cubecl-kernel/src/forward.rs create mode 100644 examples/custom-cubecl-kernel/src/kernel.rs create mode 100644 examples/custom-cubecl-kernel/src/lib.rs create mode 100644 examples/custom-image-dataset/.gitignore create mode 100644 examples/custom-image-dataset/Cargo.toml create mode 100644 examples/custom-image-dataset/README.md create mode 100644 examples/custom-image-dataset/examples/custom-image-dataset.rs create mode 100644 examples/custom-image-dataset/src/data.rs create mode 100644 examples/custom-image-dataset/src/dataset.rs create mode 100644 examples/custom-image-dataset/src/inference.rs create mode 100644 examples/custom-image-dataset/src/lib.rs create mode 100644 examples/custom-image-dataset/src/model.rs create mode 100644 examples/custom-image-dataset/src/training.rs create mode 100644 examples/custom-learning-strategy/Cargo.toml create mode 100644 examples/custom-learning-strategy/examples/custom-learning-strategy.rs create mode 100644 examples/custom-learning-strategy/src/lib.rs create mode 100644 examples/custom-learning-strategy/src/model.rs create mode 100644 examples/custom-learning-strategy/src/training.rs create mode 100644 examples/custom-renderer/Cargo.toml create mode 100644 examples/custom-renderer/examples/custom-renderer.rs create mode 100644 examples/custom-renderer/src/lib.rs create mode 100644 examples/custom-training-loop/Cargo.toml create mode 100644 examples/custom-training-loop/examples/custom-training-loop.rs create mode 100644 examples/custom-training-loop/src/lib.rs create mode 100644 examples/custom-wgpu-kernel/Cargo.toml create mode 100644 examples/custom-wgpu-kernel/examples/custom-wgpu-kernel.rs create mode 100644 examples/custom-wgpu-kernel/src/backward.rs create mode 100644 examples/custom-wgpu-kernel/src/forward.rs create mode 100644 examples/custom-wgpu-kernel/src/kernel.wgsl create mode 100644 examples/custom-wgpu-kernel/src/lib.rs create mode 100644 examples/dqn-agent/Cargo.toml create mode 100644 examples/dqn-agent/burn.toml create mode 100644 examples/dqn-agent/examples/dqn-agent.rs create mode 100644 examples/dqn-agent/src/agent.rs create mode 100644 examples/dqn-agent/src/env.rs create mode 100644 examples/dqn-agent/src/lib.rs create mode 100644 examples/dqn-agent/src/training.rs create mode 100644 examples/dqn-agent/src/utils.rs create mode 100644 examples/guide/Cargo.toml create mode 100644 examples/guide/README.md create mode 100644 examples/guide/examples/guide.rs create mode 100644 examples/guide/src/bin/infer.rs create mode 100644 examples/guide/src/bin/print.rs create mode 100644 examples/guide/src/bin/train.rs create mode 100644 examples/guide/src/data.rs create mode 100644 examples/guide/src/inference.rs create mode 100644 examples/guide/src/lib.rs create mode 100644 examples/guide/src/model.rs create mode 100644 examples/guide/src/training.rs create mode 100644 examples/import-model-weights/Cargo.toml create mode 100644 examples/import-model-weights/README.md create mode 100644 examples/import-model-weights/src/bin/burnpack.rs create mode 100644 examples/import-model-weights/src/bin/convert.rs create mode 100644 examples/import-model-weights/src/bin/pytorch.rs create mode 100644 examples/import-model-weights/src/bin/safetensors.rs create mode 100644 examples/import-model-weights/src/inference.rs create mode 100644 examples/import-model-weights/src/lib.rs create mode 100644 examples/import-model-weights/src/model.rs create mode 100644 examples/import-model-weights/weights/mnist.pt create mode 100644 examples/import-model-weights/weights/mnist.safetensors create mode 100755 examples/import-model-weights/weights/mnist_train_export.py create mode 100644 examples/mnist-inference-web/Cargo.toml create mode 100644 examples/mnist-inference-web/README.md create mode 100755 examples/mnist-inference-web/build-for-web.sh create mode 100644 examples/mnist-inference-web/index.html create mode 100644 examples/mnist-inference-web/index.js create mode 100644 examples/mnist-inference-web/model.bpk create mode 100755 examples/mnist-inference-web/run-server.sh create mode 100644 examples/mnist-inference-web/src/lib.rs create mode 100644 examples/mnist-inference-web/src/model.rs create mode 100644 examples/mnist-inference-web/src/state.rs create mode 100644 examples/mnist-inference-web/src/web.rs create mode 100644 examples/mnist/Cargo.toml create mode 100644 examples/mnist/README.md create mode 100644 examples/mnist/cubecl.toml create mode 100644 examples/mnist/examples/mnist.rs create mode 100644 examples/mnist/src/data.rs create mode 100644 examples/mnist/src/file_progress.rs create mode 100644 examples/mnist/src/lib.rs create mode 100644 examples/mnist/src/model.rs create mode 100644 examples/mnist/src/training.rs create mode 100644 examples/modern-lstm/Cargo.toml create mode 100644 examples/modern-lstm/README.md create mode 100644 examples/modern-lstm/examples/lstm-infer.rs create mode 100644 examples/modern-lstm/examples/lstm-train.rs create mode 100644 examples/modern-lstm/src/dataset.rs create mode 100644 examples/modern-lstm/src/inference.rs create mode 100644 examples/modern-lstm/src/lib.rs create mode 100644 examples/modern-lstm/src/model.rs create mode 100644 examples/modern-lstm/src/training.rs create mode 100644 examples/notebook/README.md create mode 100644 examples/notebook/autodiff.ipynb create mode 100644 examples/notebook/basic-tensor-op.ipynb create mode 100644 examples/p2p-remote-training/Cargo.toml create mode 100644 examples/p2p-remote-training/examples/p2p-remote-training.rs create mode 100644 examples/p2p-remote-training/src/lib.rs create mode 100644 examples/remote-inference-web/Cargo.toml create mode 100644 examples/remote-inference-web/README.md create mode 100755 examples/remote-inference-web/build-for-web.sh create mode 100644 examples/remote-inference-web/index.html create mode 100644 examples/remote-inference-web/index.js create mode 100644 examples/remote-inference-web/model.bpk create mode 100755 examples/remote-inference-web/run-server.sh create mode 100644 examples/remote-inference-web/src/lib.rs create mode 100644 examples/remote-inference-web/src/model.rs create mode 100644 examples/remote-inference-web/src/web.rs create mode 100644 examples/server/Cargo.toml create mode 100644 examples/server/burn.toml create mode 100644 examples/server/examples/server.rs create mode 100644 examples/server/src/lib.rs create mode 100644 examples/simple-regression/Cargo.toml create mode 100644 examples/simple-regression/README.md create mode 100644 examples/simple-regression/examples/regression.rs create mode 100644 examples/simple-regression/src/dataset.rs create mode 100644 examples/simple-regression/src/inference.rs create mode 100644 examples/simple-regression/src/lib.rs create mode 100644 examples/simple-regression/src/model.rs create mode 100644 examples/simple-regression/src/training.rs create mode 100644 examples/text-classification/Cargo.toml create mode 100644 examples/text-classification/README.md create mode 100644 examples/text-classification/burn.toml create mode 100644 examples/text-classification/examples/ag-news-infer.rs create mode 100644 examples/text-classification/examples/ag-news-train.rs create mode 100644 examples/text-classification/examples/db-pedia-infer.rs create mode 100644 examples/text-classification/examples/db-pedia-train.rs create mode 100644 examples/text-classification/src/data/batcher.rs create mode 100644 examples/text-classification/src/data/dataset.rs create mode 100644 examples/text-classification/src/data/mod.rs create mode 100644 examples/text-classification/src/data/tokenizer.rs create mode 100644 examples/text-classification/src/inference.rs create mode 100644 examples/text-classification/src/lib.rs create mode 100644 examples/text-classification/src/model.rs create mode 100644 examples/text-classification/src/training.rs create mode 100644 examples/text-generation/Cargo.toml create mode 100644 examples/text-generation/README.md create mode 100644 examples/text-generation/examples/text-generation.rs create mode 100644 examples/text-generation/src/data/batcher.rs create mode 100644 examples/text-generation/src/data/dataset.rs create mode 100644 examples/text-generation/src/data/mod.rs create mode 100644 examples/text-generation/src/data/tokenizer.rs create mode 100644 examples/text-generation/src/lib.rs create mode 100644 examples/text-generation/src/model.rs create mode 100644 examples/text-generation/src/training.rs create mode 100644 examples/wgan/Cargo.toml create mode 100644 examples/wgan/README.md create mode 100644 examples/wgan/examples/wgan-generate.rs create mode 100644 examples/wgan/examples/wgan-mnist.rs create mode 100644 examples/wgan/src/dataset.rs create mode 100644 examples/wgan/src/infer.rs create mode 100644 examples/wgan/src/lib.rs create mode 100644 examples/wgan/src/model.rs create mode 100644 examples/wgan/src/training.rs create mode 100644 rustfmt.toml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/commands/books.rs create mode 100644 xtask/src/commands/build.rs create mode 100644 xtask/src/commands/doc.rs create mode 100644 xtask/src/commands/mod.rs create mode 100644 xtask/src/commands/remote.rs create mode 100644 xtask/src/commands/test.rs create mode 100644 xtask/src/commands/validate.rs create mode 100644 xtask/src/main.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..b3e9e6d --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,32 @@ +# Audit config file +# +# It may be located in the user home (`~/.cargo/audit.toml`) or in the project +# root (`.cargo/audit.toml`). +# +# All of the options which can be passed via CLI arguments can also be +# permanently specified in this file. + +[advisories] +ignore = [ + "RUSTSEC-2024-0436", # Paste used to generate macro, should be removed at some point. + "RUSTSEC-2025-0119", # `number_prefix` used by `tokenizers`, only in the examples. + "RUSTSEC-2025-0141", # `bincode` is no longer maintained. + "RUSTSEC-2024-0388", # `derivative` dependancy in the DQN example is unmaintained. + "RUSTSEC-2026-0194", # `quick-xml` <0.41.0, pinned by polars → object_store 0.13.2; no upgrade path yet. + "RUSTSEC-2026-0195", # `quick-xml` <0.41.0, pinned by polars → object_store 0.13.2; no upgrade path yet. +] # advisory IDs to ignore e.g. ["RUSTSEC-2019-0001", ...] +informational_warnings = [ + "unmaintained", +] # warn for categories of informational advisories +severity_threshold = "low" # CVSS severity ("none", "low", "medium", "high", "critical") + +# Output Configuration +[output] +deny = ["unmaintained"] # exit on error if unmaintained dependencies are found +format = "terminal" # "terminal" (human readable report) or "json" +quiet = false # Only print information on error +show_tree = true # Show inverse dependency trees along with advisories (default: true) + +[yanked] +enabled = true # Warn for yanked crates in Cargo.lock (default: true) +update_index = true # Auto-update the crates.io index (default: true) diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..82299c4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +xtask = "run --target-dir target/xtask --color always --package xtask --bin xtask --" +run-checks = "xtask -c all validate --release" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a9a2ac0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** + + +**To Reproduce** + + +**Expected behavior** + + +**Screenshots** + + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/doc_request.md b/.github/ISSUE_TEMPLATE/doc_request.md new file mode 100644 index 0000000..21d4e4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/doc_request.md @@ -0,0 +1,10 @@ +--- +name: Documentation request +about: Flag incoherent or missing documentation, including use case examples. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..fbc095d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + + + +### Feature description + + + +### Feature motivation + + + +### (Optional) Suggest a Solution + + diff --git a/.github/PULL_REQUEST_TEMPLATE/template.md b/.github/PULL_REQUEST_TEMPLATE/template.md new file mode 100644 index 0000000..55d6298 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/template.md @@ -0,0 +1,12 @@ +* **Please check if the PR fulfills these requirements** +- [ ] The commit message follows our guidelines +- [ ] Docs have been added / updated (for bug fixes / features) + + +* **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) + + +* **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) + + +* **Other information**: \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..be20d25 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "tracel-ai/github-actions*" + + - package-ecosystem: "cargo" + directories: + - "/" + - "crates/burn" + - "crates/burn-*" + - "crates/burn-import/*-tests" + - "examples/*" + - "xtask" + schedule: + interval: "weekly" + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..aa1a8a6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Pull Request Template + +### Checklist + +- [ ] Confirmed that `cargo run-checks` command has been executed. +- [ ] Made sure the book is up to date with changes in this PR. + +### Related Issues/PRs + +_Provide links to relevant issues and dependent PRs._ + +### Changes + +_Summarize the problem being addressed and your solution._ + +### Testing + +_Describe how these changes have been tested._ diff --git a/.github/workflows/combine-dependabot-prs.yml b/.github/workflows/combine-dependabot-prs.yml new file mode 100644 index 0000000..72850a9 --- /dev/null +++ b/.github/workflows/combine-dependabot-prs.yml @@ -0,0 +1,21 @@ +name: Combine Dependabot PRs + +on: + schedule: + - cron: '0 6 * * MON' # Monday at 6:00am UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + checks: read + +jobs: + combine-prs: + runs-on: ubuntu-latest + steps: + - name: combine-prs + id: combine-prs + uses: github/combine-prs@v5.2.0 + with: + labels: dependencies,automated diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 0000000..fbfe433 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,55 @@ +name: dependencies + +on: + schedule: + - cron: '0 21 * * TUE' # Run every Tuesday at 21:00 (UTC) + push: + tags: + - 'v*.*.*' # Run when a new version is being published + +env: + UDEPS_VERSION: "0.1.57" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependencies: + runs-on: ubuntu-latest + strategy: + matrix: + checks: + - licenses + - bans sources + continue-on-error: ${{ matrix.checks == 'licenses' }} # failed licenses don't abort + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Audit Rust dependencies + # If a vulnerability is found, a new issue will automatically be opened + # since this action runs on main branch + uses: actions-rust-lang/audit@v1 + # -------------------------------------------------------------------------------- + - name: Detect multiple versions of the same crate + uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check ${{ matrix.checks }} + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt + # -------------------------------------------------------------------------------- + - name: Install cargo-udeps + env: + UDEPS_LINK: https://github.com/est31/cargo-udeps/releases/download + run: | + curl -L "$UDEPS_LINK/v$UDEPS_VERSION/cargo-udeps-v$UDEPS_VERSION-x86_64-unknown-linux-gnu.tar.gz" | + tar xz -C $HOME/.cargo/bin --strip-components 2 + # -------------------------------------------------------------------------------- + - name: Run cargo-udeps + run: | + cargo +nightly udeps --all-targets diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6bce54e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,443 @@ +name: publish + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + dry-run-only: + description: "Run xtask publish in dry-run mode (no publish)" + type: boolean + required: false + default: false + +jobs: + publish-burn-rl: + needs: + - publish-burn-core + - publish-burn-optim + # dev dependencies + - publish-burn-flex + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-rl + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-vision: + needs: + - publish-burn-autodiff + - publish-burn-candle + - publish-burn-fusion + - publish-burn-cubecl-fusion + - publish-burn-cubecl + - publish-burn-flex + - publish-burn-tch + - publish-burn-core + - publish-burn-ir + - publish-burn-store + # dev dependencies + - publish-burn-wgpu + - publish-burn-cuda + - publish-burn-backend-extension + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-vision + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-router: + needs: + - publish-burn-ir + - publish-burn-std + - publish-burn-tensor + # dev dependencies + - publish-burn-autodiff + - publish-burn-flex + - publish-burn-wgpu + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-router + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-remote: + needs: + - publish-burn-ir + - publish-burn-std + - publish-burn-tensor + - publish-burn-router + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-remote + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-derive: + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-derive + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-dataset: + needs: + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-dataset + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-std: + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-std + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-backend-extension: + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-backend-extension + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-tensor: + needs: + - publish-burn-std + - publish-burn-backend + - publish-burn-dispatch + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-tensor + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-backend: + needs: + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-backend + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-ir: + needs: + - publish-burn-tensor + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-ir + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-fusion: + needs: + - publish-burn-ir + - publish-burn-tensor + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-fusion + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-cubecl-fusion: + needs: + - publish-burn-ir + - publish-burn-std + - publish-burn-fusion + - publish-burn-tensor + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-cubecl-fusion + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-cubecl: + needs: + - publish-burn-ir + - publish-burn-std + - publish-burn-fusion + - publish-burn-cubecl-fusion + - publish-burn-tensor + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-cubecl + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-autodiff: + needs: + - publish-burn-backend + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-autodiff + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-tch: + needs: + - publish-burn-tensor + - publish-burn-autodiff + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-tch + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-ndarray: + needs: + - publish-burn-ir + - publish-burn-tensor + - publish-burn-autodiff + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-ndarray + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-flex: + needs: + - publish-burn-backend + - publish-burn-ir + - publish-burn-std + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-flex + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-wgpu: + needs: + - publish-burn-tensor + - publish-burn-autodiff + - publish-burn-std + - publish-burn-cubecl + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-wgpu + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-cpu: + needs: + - publish-burn-tensor + - publish-burn-fusion + - publish-burn-cubecl + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-cpu + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-cuda: + needs: + - publish-burn-tensor + - publish-burn-autodiff + - publish-burn-std + - publish-burn-cubecl + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-cuda + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-rocm: + needs: + - publish-burn-tensor + - publish-burn-autodiff + - publish-burn-std + - publish-burn-cubecl + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-rocm + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-candle: + needs: + - publish-burn-tensor + - publish-burn-autodiff + - publish-burn-tch + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-candle + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + # publish-burn-collective: + # needs: + # - publish-burn-std + # - publish-burn-tensor + # - publish-burn-communication + # # dev dependencies + # - publish-burn-wgpu + # - publish-burn-flex + # - publish-burn-cuda + # uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + # with: + # crate: burn-collective + # dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + # secrets: + # CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-communication: + needs: + - publish-burn-std + - publish-burn-tensor + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-communication + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-core: + needs: + - publish-burn-dataset + - publish-burn-std + - publish-burn-derive + - publish-burn-tensor + # dev dependencies + - publish-burn-autodiff + - publish-burn-wgpu + - publish-burn-tch + - publish-burn-cuda + - publish-burn-flex + - publish-burn-candle + - publish-burn-remote + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-core + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-nn: + needs: + - publish-burn-core + # dev dependencies + - publish-burn-autodiff + - publish-burn-wgpu + - publish-burn-tch + - publish-burn-flex + - publish-burn-candle + - publish-burn-remote + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-nn + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-optim: + needs: + - publish-burn-core + - publish-burn-collective + # dev dependencies + - publish-burn-autodiff + - publish-burn-wgpu + - publish-burn-tch + - publish-burn-flex + - publish-burn-candle + - publish-burn-remote + - publish-burn-nn + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-optim + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-train: + needs: + - publish-burn-core + - publish-burn-optim + - publish-burn-collective + - publish-burn-rl + - publish-burn-flex + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-train + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-dispatch: + needs: + - publish-burn-std + - publish-burn-backend + - publish-burn-backend-extension + - publish-burn-autodiff + - publish-burn-cpu + - publish-burn-cuda + - publish-burn-rocm + - publish-burn-wgpu + - publish-burn-flex + - publish-burn-ndarray + - publish-burn-tch + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-dispatch + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn: + needs: + - publish-burn-core + - publish-burn-nn + - publish-burn-optim + - publish-burn-collective + - publish-burn-store + - publish-burn-train + - publish-burn-cpu + - publish-burn-dispatch + - publish-burn-flex + - publish-burn-vision + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + + publish-burn-store: + needs: + - publish-burn-core + - publish-burn-nn + - publish-burn-tensor + uses: tracel-ai/github-actions/.github/workflows/publish-crate.yml@v9 + with: + crate: burn-store + dry-run-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run-only || false }} + secrets: + CRATES_IO_API_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} diff --git a/.github/workflows/stale-pr.yml b/.github/workflows/stale-pr.yml new file mode 100644 index 0000000..56e23ee --- /dev/null +++ b/.github/workflows/stale-pr.yml @@ -0,0 +1,41 @@ +name: Stale Pull Requests + +on: + schedule: + - cron: '0 12 * * *' # Run every day at 12:00 (UTC) + +# The minimum permissions required to run this Action +permissions: + contents: write # only for delete-branch option + issues: write + pull-requests: write + +jobs: + stale-pr: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Stale pull requests + uses: actions/stale@v10 + with: + # The idle number of days before marking issues stale. + # + # With a negative number like -1, no issues + # will be marked as stale automatically. + days-before-issue-stale: -1 + # The idle number of days before marking pull requests stale + days-before-pr-stale: 30 + # The idle number of days before closing + # the stale pull requests (due to the stale label). + # + # With a negative number like -1, the pull requests + # will never be closed automatically. + days-before-pr-close: -1 + # Label to apply on staled pull requests + stale-pr-label: 'stale' + # The message that will be added as a comment to the pull request + stale-pr-message: 'This PR has been marked as stale because it has not been updated for over a month' + # Remove `stale` label from pull requests on updates/comments + remove-pr-stale-when-updated: true diff --git a/.github/workflows/test-gpu.yml b/.github/workflows/test-gpu.yml new file mode 100644 index 0000000..811ae27 --- /dev/null +++ b/.github/workflows/test-gpu.yml @@ -0,0 +1,162 @@ +name: CI GPU + +on: + workflow_dispatch: + inputs: + pr_number: + description: "Number of the pull request that triggers this run if any" + type: number + required: false + +# important to set the run name to this format so that the CI server +# can track the PR number from the workflow_run events. +run-name: ${{ github.workflow }}:${{ github.repository }}#${{ inputs.pr_number }} + +env: + # Note: It is not possible to define top level env vars and pass them to composite actions. + # To work around this issue we use inputs and define all the env vars here. + + RUST_PREVIOUS_VERSION: 1.92.0 + + # Dependency versioning + # from wgpu repo: https://github.com/gfx-rs/wgpu/blob/trunk/.github/workflows/ci.yml + + # GCP runners + GCP_RUNNERS_IMAGE_FAMILY: "tracel-ci-ubuntu-2404-amd64-nvidia" + GCP_RUNNERS_MACHINE_TYPE: "g2-standard-4" + GCP_RUNNERS_ZONE: "us-east1-c" + + # Test in release mode (make it an empty string to test in debug mode) + TEST_RELEASE_FLAG: "--release" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare-checks: + runs-on: ubuntu-latest + outputs: + rust-prev-version: ${{ env.RUST_PREVIOUS_VERSION }} + gcp_runners_image_family: ${{ env.GCP_RUNNERS_IMAGE_FAMILY }} + gcp_runners_machine_type: ${{ env.GCP_RUNNERS_MACHINE_TYPE }} + gcp_runners_zone: ${{ env.GCP_RUNNERS_ZONE }} + steps: + - name: Do Nothing + if: false + run: echo + + linux-std-cuda-tests: + needs: [prepare-checks] + timeout-minutes: 60 + # '@id:' label must be unique within this worklow + runs-on: + [ + "@id:burn-cuda-job-${{github.run_id}}-${{github.run_attempt}}", + "@pr_number:${{ inputs.pr_number }}", + "@organization:tracel-ai", + "@repository:burn", + "@image-family:${{ needs.prepare-checks.outputs.gcp_runners_image_family }}", + "@machine-type:${{ needs.prepare-checks.outputs.gcp_runners_machine_type }}", + "@zones:${{ needs.prepare-checks.outputs.gcp_runners_zone }}", + "@gpu:true", + ] + env: + LD_LIBRARY_PATH: "/usr/local/cuda/lib64" + # disable incremental compilation (reduces artifact size) + CARGO_PROFILE_TEST_INCREMENTAL: "false" + # Keep the stragegy to be able to easily add new rust versions if required + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + enable-cache: false + # -------------------------------------------------------------------------------- + - name: Tests (burn-cuda) + run: cargo xtask test ${{ env.TEST_RELEASE_FLAG }} --ci gcp-cuda-runner + + linux-std-vulkan-tests: + needs: [prepare-checks] + timeout-minutes: 60 + # '@id:' label must be unique within this worklow + runs-on: + [ + "@id:burn-vulkan-job-${{github.run_id}}-${{github.run_attempt}}", + "@pr_number:${{ inputs.pr_number }}", + "@organization:tracel-ai", + "@repository:burn", + "@image-family:${{ needs.prepare-checks.outputs.gcp_runners_image_family }}", + "@machine-type:${{ needs.prepare-checks.outputs.gcp_runners_machine_type }}", + "@zones:${{ needs.prepare-checks.outputs.gcp_runners_zone }}", + "@gpu:true", + ] + env: + # disable incremental compilation (reduces artifact size) + CARGO_PROFILE_TEST_INCREMENTAL: "false" + # Keep the stragegy to be able to easily add new rust versions if required + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + enable-cache: false + # -------------------------------------------------------------------------------- + - name: Tests (burn-vulkan) + run: cargo xtask test ${{ env.TEST_RELEASE_FLAG }} --ci gcp-vulkan-runner + + linux-std-wgpu-tests: + needs: [prepare-checks] + timeout-minutes: 60 + # '@id:' label must be unique within this worklow + runs-on: + [ + "@id:burn-wgpu-job-${{github.run_id}}-${{github.run_attempt}}", + "@pr_number:${{ inputs.pr_number }}", + "@organization:tracel-ai", + "@repository:burn", + "@image-family:${{ needs.prepare-checks.outputs.gcp_runners_image_family }}", + "@machine-type:${{ needs.prepare-checks.outputs.gcp_runners_machine_type }}", + "@zones:${{ needs.prepare-checks.outputs.gcp_runners_zone }}", + "@gpu:true", + ] + env: + # disable incremental compilation (reduces artifact size) + CARGO_PROFILE_TEST_INCREMENTAL: "false" + # Keep the stragegy to be able to easily add new rust versions if required + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + enable-cache: false + # -------------------------------------------------------------------------------- + - name: Tests (burn-wgpu) + run: cargo xtask test ${{ env.TEST_RELEASE_FLAG }} --ci gcp-wgpu-runner diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bac7ea8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,278 @@ +name: CI + +on: + push: + branches: + - main + paths: + - "Cargo.lock" + - "**.rs" + - "**.sh" + - "**.ps1" + - "**.yml" + - "**.toml" + - "!**.md" + - "!LICENSE-APACHE" + - "!LICENSE-MIT" + pull_request: + types: [opened, synchronize] + paths: + - "Cargo.lock" + - "**.rs" + - "**.sh" + - "**.ps1" + - "**.yml" + - "**.toml" + - "!**.md" + - "!LICENSE-APACHE" + - "!LICENSE-MIT" + +env: + # Note: It is not possible to define top level env vars and pass them to composite actions. + # To work around this issue we use inputs and define all the env vars here. + + RUST_PREVIOUS_VERSION: 1.95.0 + + # Dependency versioning + # from wgpu repo: https://github.com/gfx-rs/wgpu/blob/trunk/.github/workflows/ci.yml + + # Mozilla Grcov + GRCOV_LINK: "https://github.com/mozilla/grcov/releases/download" + GRCOV_VERSION: "0.8.19" + + # Test in release mode (make it an empty string to test in debug mode) + TEST_RELEASE_FLAG: "--release" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare-checks: + runs-on: ubuntu-latest + outputs: + rust-prev-version: ${{ env.RUST_PREVIOUS_VERSION }} + steps: + - name: Do Nothing + if: false + run: echo + + code-quality: + runs-on: ubuntu-22.04 + needs: prepare-checks + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + cache-key: ${{ matrix.rust }}-linux + # -------------------------------------------------------------------------------- + - name: Audit + run: cargo xtask check audit + # -------------------------------------------------------------------------------- + - name: Format + shell: bash + env: + # work around for colors + # see: https://github.com/rust-lang/rustfmt/issues/3385 + TERM: xterm-256color + run: cargo xtask check format + # -------------------------------------------------------------------------------- + - name: Lint + run: cargo xtask check lint + # -------------------------------------------------------------------------------- + - name: Typos + uses: tracel-ai/github-actions/check-typos@v9 + + documentation: + runs-on: ubuntu-22.04 + needs: prepare-checks + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + cache-key: ${{ matrix.rust }}-linux + # -------------------------------------------------------------------------------- + - name: Documentation Build + run: cargo xtask doc build + # -------------------------------------------------------------------------------- + - name: Documentation Tests + run: cargo xtask doc tests + + linux-std-tests: + runs-on: ubuntu-22.04 + needs: [prepare-checks, code-quality] + env: + DISABLE_WGPU_SPIRV: "1" + # disable incremental compilation (reduces artifact size) + CARGO_PROFILE_TEST_INCREMENTAL: "false" + CARGO_TERM_COLOR: always + strategy: + matrix: + rust: [stable, prev] + # Tests are split across 3 shards: backend tests, workspace crates, examples/* + ci_type: [backends, crates, examples] + include: + - rust: stable + toolchain: stable + coverage: --enable-coverage + - rust: prev + toolchain: ${{ needs.prepare-checks.outputs.rust-prev-version }} + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + # Keep caches isolated per shard to prevent race conditions or bloated sizes + cache-key: ${{ matrix.rust }}-${{ matrix.ci_type }}-linux + # Disable cache on linux-std (stable) runner which currently always runs out of disk space with tests + coverage + enable-cache: ${{ matrix.rust != 'stable' }} + # # -------------------------------------------------------------------------------- + - name: Install grcov + if: matrix.rust == 'stable' + shell: bash + run: | + curl -L "$GRCOV_LINK/v$GRCOV_VERSION/grcov-x86_64-unknown-linux-musl.tar.bz2" | + tar xj -C $HOME/.cargo/bin + cargo xtask coverage install + # -------------------------------------------------------------------------------- + - name: Tests + run: cargo xtask ${{ matrix.coverage }} test ${{ env.TEST_RELEASE_FLAG }} --ci ${{ matrix.ci_type }} + # -------------------------------------------------------------------------------- + - name: Generate lcov.info + if: matrix.rust == 'stable' && matrix.ci_type != 'examples' + # /* is to exclude std library code coverage from analysis + run: cargo xtask coverage generate --ignore "/*,xtask/*,examples/*" --profile release + # -------------------------------------------------------------------------------- + - name: Upload Codecov Shard Artifact + if: matrix.rust == 'stable' && matrix.ci_type != 'examples' + uses: actions/upload-artifact@v7 + with: + # Names must be unique so they don't overwrite each other in the storage backend + name: coverage-linux-${{ matrix.ci_type }} + path: lcov.info + +# --- Dedicated coverage upload job --- + upload-coverage: + runs-on: ubuntu-latest + needs: linux-std-tests + steps: + - name: checkout + uses: actions/checkout@v7 + + - name: Download All Coverage Artifacts + uses: actions/download-artifact@v8 + with: + # Finds all artifacts prefixed with coverage-linux-* + pattern: coverage-linux-* + path: coverage-reports + + - name: Codecov upload + uses: codecov/codecov-action@v7 + with: + # Codecov automatically discovers and merges multiple lcov files inside this directory + directory: coverage-reports + token: ${{ secrets.CODECOV_TOKEN }} + + linux-no-std-tests: + runs-on: ubuntu-22.04 + needs: [prepare-checks, code-quality] + strategy: + matrix: + rust: [stable, prev] + include: + - rust: stable + toolchain: stable + - rust: prev + toolchain: ${{ needs.prepare-checks.outputs.rust-prev-version }} + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + cache-key: ${{ matrix.rust }}-linux-no-std + # -------------------------------------------------------------------------------- + - name: Crates Build + run: cargo xtask --context no-std build --ci + # -------------------------------------------------------------------------------- + - name: Crates Tests + run: cargo xtask --context no-std test ${{ env.TEST_RELEASE_FLAG }} --ci github-runner + + windows-std-tests: + runs-on: windows-2022 + needs: [prepare-checks, code-quality] + env: + CARGO_PROFILE_TEST_INCREMENTAL: "false" + CARGO_TERM_COLOR: always + strategy: + matrix: + rust: [stable] + # Tests are split across 3 shards: backend tests, workspace crates, examples/* + ci_type: [backends, crates, examples] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + cache-key: ${{ matrix.rust }}-${{ matrix.ci_type }}-windows + # -------------------------------------------------------------------------------- + - name: Tests + run: cargo xtask test ${{ env.TEST_RELEASE_FLAG }} --ci ${{ matrix.ci_type }} + + macos-std-tests: + runs-on: blaze/macos-15 + needs: [prepare-checks, code-quality] + timeout-minutes: 60 + # Keep the stragegy to be able to easily add new rust versions if required + strategy: + matrix: + rust: [stable] + include: + - rust: stable + toolchain: stable + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Setup Rust + uses: tracel-ai/github-actions/install-rust@v9 + with: + rust-toolchain: ${{ matrix.toolchain }} + cache-key: ${{ matrix.rust }}-macos + # -------------------------------------------------------------------------------- + - name: Device check + run: system_profiler SPHardwareDataType + # -------------------------------------------------------------------------------- + - name: Tests + run: cargo xtask test ${{ env.TEST_RELEASE_FLAG }} --ci github-mac-runner diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml new file mode 100644 index 0000000..516f932 --- /dev/null +++ b/.github/workflows/valgrind.yml @@ -0,0 +1,39 @@ +name: valgrind + +on: + schedule: + - cron: '0 23 * * WED' # Run every Wednesday at 23:00 (UTC) + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + valgrind: + runs-on: [ + '@id:burn-linux-valgrind-${{ github.run_id }}-${{ github.run_attempt }}', + '@image-family:ubuntu-2404-lts-amd64', + '@image-project:ubuntu-os-cloud', + '@disk-size:100', + '@keep-alive:false', + '@machine-type:n2-standard-16', + '@os:linux', + '@zones:northamerica-northeast1-b' + ] + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Install valgrind + run: | + sudo apt-get install valgrind + # -------------------------------------------------------------------------------- + - name: Run cargo-valgrind + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER: "valgrind -s --leak-check=full --show-leak-kinds=all --error-exitcode=1" + # Looking for vulnerabilities + run: | + cargo test diff --git a/.github/workflows/vulnerabilities.yml b/.github/workflows/vulnerabilities.yml new file mode 100644 index 0000000..dd80e2c --- /dev/null +++ b/.github/workflows/vulnerabilities.yml @@ -0,0 +1,131 @@ +name: vulnerabilities + +on: + schedule: + - cron: '0 21 * * WED' # Run every Wednesday at 21:00 (UTC) + push: + tags: + - 'v*.*.*' + +env: + CAREFUL_VERSION: "0.4.9" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + cargo-careful: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt, rust-src + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Install cargo-careful + env: + CAREFUL_LINK: https://github.com/RalfJung/cargo-careful/releases/download + run: | + curl -L "$CAREFUL_LINK/v$CAREFUL_VERSION/cargo-careful.x86_64-unknown-linux-musl" \ + --output $HOME/.cargo/bin/cargo-careful + chmod +x $HOME/.cargo/bin/cargo-careful + # -------------------------------------------------------------------------------- + - name: Run cargo-careful + # Looking for undefined behaviours + run: cargo +nightly careful test + + address-sanitizer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt, rust-src + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Run AddressSanitizer + env: + RUSTFLAGS: -Zsanitizer=address -Copt-level=3 + RUSTDOCFLAGS: -Zsanitizer=address + # Looking for memory vulnerabilities + run: cargo test -Zbuild-std --target x86_64-unknown-linux-gnu -- --nocapture + + thread-sanitizer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt, rust-src + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Run ThreadSanitizer + env: + RUSTFLAGS: -Zsanitizer=thread -Copt-level=3 + RUSTDOCFLAGS: -Zsanitizer=thread + # Looking for data race among threads + run: cargo test -Zbuild-std --target x86_64-unknown-linux-gnu -- --nocapture + + memory-sanitizer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt, rust-src + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Run MemorySanitizer + env: + RUSTFLAGS: -Zsanitizer=memory -Zsanitizer-memory-track-origins -Copt-level=3 + RUSTDOCFLAGS: -Zsanitizer=memory -Zsanitizer-memory-track-origins + # Looking for unitialized memory. + run: cargo test -Zbuild-std --target x86_64-unknown-linux-gnu -- --nocapture + + safe-stack: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + # -------------------------------------------------------------------------------- + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: rustfmt, rust-src + # -------------------------------------------------------------------------------- + - name: Install Mesa + uses: tracel-ai/github-actions/install-mesa@v9 + # -------------------------------------------------------------------------------- + - name: Run SafeStack + env: + RUSTFLAGS: -Zsanitizer=safestack -Copt-level=3 + RUSTDOCFLAGS: -Zsanitizer=safestack + # Provides backward edge control flow protection + run: cargo test -Zbuild-std --target x86_64-unknown-linux-gnu -- --nocapture diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a86740c --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +target +# These are backup files generated by rustfmt +**/*.rs.bk +.DS_Store + +.dir-locals.el +.idea +.vscode +.vs +.fleet +.ipynb_checkpoints/ + +# Build output directory +out + +# Virtual Environment of Python +.venv +uv.lock + +# Nix direnv +.envrc +.direnv + +# tags files +tags + +examples/**/Cargo.lock diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..72c7271 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,34 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: + - family-names: "Simard" + given-names: "Nathaniel" + email: "nathaniel.simard.42@gmail.com" + - family-names: "Fortier-Dubois" + given-names: "Louis" + email: "louisfd94@gmail.com" + - family-names: "Tadjibaev" + given-names: "Dilshod" + email: "dilshod@gmail.com" + - family-names: "Lagrange" + given-names: "Guillaume" + email: "lagrange.guillaume.1@gmail.com" + - name: "Burn Framework Contributors" +title: "Burn" +version: 0.21.0 +date-released: 2026-05-07 +url: "https://burn.dev/" +repository-code: "https://github.com/tracel-ai/burn" +license: + - MIT + - Apache-2.0 +abstract: "Burn is a new comprehensive dynamic Deep Learning Framework built using Rust with extreme flexibility, compute efficiency and portability as its primary goals." +keywords: + - scientific-computing + - deep-learning + - machine-learning + - neural-networks + - rust + - high-performance-computing + - portability + - compute-efficiency diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 0000000..0c14ff7 --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +nathaniel.simard.42@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f01ea1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing to Burn + +Welcome to the Burn community! We're glad you're interested in contributing. + +## How to Contribute + +The best way to get started is to look at [open issues](https://github.com/tracel-ai/burn/issues) +and find one that interests you. Issues labeled `good first issue` are a great starting point for +new contributors. + +If you have an idea that isn't covered by an existing issue, open one first to discuss the approach. +This helps align expectations and avoids wasted effort on both sides. + +For questions, discussions, or just to say hello, join us on +[Discord](https://discord.gg/uPEBbYYDB6). The [Contributor Book](https://burn.dev/contributor-book/) +covers architecture, environment setup, and guides for common tasks. + +## Pull Requests + +Every pull request should have a descriptive title, a description covering what you changed, why, +how you tested it, and a link to the relevant issue (if applicable). Prefer small, focused PRs over +large ones that bundle unrelated changes. + +Draft pull requests are considered not yet ready for review. + +CI checks should pass before requesting review, though the signal isn't always accurate. If you have +questions or need early feedback, let us know on the PR or on +[Discord](https://discord.gg/uPEBbYYDB6). + +### Change Ownership + +The core principle behind all contributions: **PR authors must understand, justify, and explain +every change they propose.** After a PR is accepted, both the reviewer and the author should be +confident it improves the codebase. + +This applies equally whether you wrote the code from scratch, adapted it from another project, or +used AI tools to help generate it. The origin of the code doesn't matter; what matters is that you +own it intellectually and can stand behind it during review. + +### AI-Assisted Contributions + +Using LLMs and AI tools to generate code that is part of a contribution is allowed. + +That said, the [Change Ownership](#change-ownership) principle applies fully. You are the author, +not your AI tool. This means: + +- Read and understand every line before submitting. +- Review AI-generated code for correctness, style consistency, and relevance. +- Test your changes locally and confirm they work as intended. +- Be prepared to explain the rationale behind any change during review. + +Do not use "AI generated" as a justification for low-quality code. + +### Before You Open a PR + +1. **Check for an existing issue.** If there isn't one, open an issue first to discuss the approach. + This is especially important for large changes or refactors. +2. **Read the codebase.** Understand the architecture and conventions already in place. The + [Contributor Book](https://burn.dev/contributor-book/) covers architecture, environment setup, + and guides for common tasks. +3. **Keep it focused.** One PR should address one concern. If you spot an unrelated issue while + working, open a separate PR for it. +4. **Run validation.** Run `cargo run-checks` before submitting. This runs formatting, linting, and + the full test suite. All checks must pass. + +### Code Quality Standards + +- Follow existing code style and project conventions. +- Write idiomatic Rust. If you are new to the codebase, study existing patterns before contributing. +- Keep dependencies minimal. Don't introduce new crates without discussion. +- Document public APIs. Non-trivial logic should have comments explaining _why_, not just _what_. +- Prefer clarity over cleverness. +- Bug fixes should include a regression test. + +### Large Pull Requests + +Large, complex PRs are harder to review effectively and carry more risk. To help both yourself and +reviewers, consider breaking substantial changes into smaller, incremental PRs. Each should be +valuable on its own, even if the full picture spans multiple PRs. + +Large efforts that are ultimately rejected are frustrating for everyone involved. If you're planning +a substantial change, open an issue or start a discussion first. It's much easier to course-correct +early than after the work is done. + +### Review Process + +- Maintainers review PRs as time allows. Please be patient. +- Be responsive to feedback. If changes are requested, address them or explain your reasoning. +- Reviewers may ask clarifying questions about any part of your PR. This is a normal part of + collaborative review and helps ensure shared understanding. +- Don't force-push to rewrite history during an active review without notice. +- If a PR goes stale for more than 14 days without a response from the author, it may be closed. + +## Getting Help + +If you're stuck or unsure about something, don't hesitate to ask. Open an issue, start a discussion, +or reach out on [Discord](https://discord.gg/uPEBbYYDB6). We're happy to help. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5dca8fa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,12579 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accelerate-src" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415ed64958754dbe991900f3940677e6a7eefb4d7367afd70d642677b0c7d19d" + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "argminmax" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" +dependencies = [ + "half", + "num-traits", +] + +[[package]] +name = "array-init-cursor" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atoi_simd" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" +dependencies = [ + "debug_unsafe", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.6", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + +[[package]] +name = "blas-src" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd439b48f5425c9f4597141b52476358e05e08471bc30f4d1c2a1f4084d7c9f" +dependencies = [ + "accelerate-src", + "netlib-src", + "openblas-src", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "burn" +version = "0.22.0-pre.1" +dependencies = [ + "burn-autodiff", + "burn-candle", + "burn-core", + "burn-cpu", + "burn-cuda", + "burn-dispatch", + "burn-flex", + "burn-ir", + "burn-ndarray", + "burn-nn", + "burn-optim", + "burn-rl", + "burn-rocm", + "burn-router", + "burn-std", + "burn-store", + "burn-tch", + "burn-train", + "burn-vision", + "burn-wgpu", + "cubecl", +] + +[[package]] +name = "burn-autodiff" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-std", + "derive-new", + "hashbrown 0.16.1", + "log", + "num-traits", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "spin", + "tracing", +] + +[[package]] +name = "burn-backend" +version = "0.22.0-pre.1" +dependencies = [ + "burn-std", + "bytemuck", + "cubecl", + "derive-new", + "enumset", + "hashbrown 0.16.1", + "num-traits", + "oneshot", + "paste", + "portable-atomic-util", + "rand 0.10.2", + "rand_distr 0.6.0", + "serde", + "serde_json", + "serial_test", + "spin", +] + +[[package]] +name = "burn-backend-extension" +version = "0.22.0-pre.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "burn-backend-tests" +version = "0.22.0-pre.1" +dependencies = [ + "burn-autodiff", + "burn-backend", + "burn-cubecl", + "burn-cubecl-fusion", + "burn-fusion", + "burn-ndarray", + "burn-tensor", + "ctor", + "cubek", + "divan", + "num-traits", + "proc-macro2", + "quote", + "rand 0.10.2", + "serial_test", + "syn 2.0.118", +] + +[[package]] +name = "burn-candle" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-std", + "burn-tch", + "candle-core", + "derive-new", +] + +[[package]] +name = "burn-communication" +version = "0.22.0-pre.1" +dependencies = [ + "axum", + "burn-backend", + "burn-std", + "bytes", + "derive-new", + "futures", + "futures-util", + "log", + "rmp-serde", + "serde", + "serde_bytes", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "burn-core" +version = "0.22.0-pre.1" +dependencies = [ + "ahash", + "burn-backend", + "burn-backend-extension", + "burn-dataset", + "burn-derive", + "burn-dispatch", + "burn-ir", + "burn-pack", + "burn-std", + "burn-tensor", + "derive-new", + "half", + "hashbrown 0.16.1", + "log", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rand 0.10.2", + "regex", + "rstest", + "serde", + "serde_json", + "serial_test", + "spin", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "burn-cpu" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-cubecl", + "burn-fusion", + "cubecl", +] + +[[package]] +name = "burn-cubecl" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-cubecl-fusion", + "burn-fusion", + "burn-ir", + "burn-std", + "cubecl", + "cubek", + "derive-new", + "futures-lite", + "log", + "serde", + "text_placeholder", + "tracing", +] + +[[package]] +name = "burn-cubecl-fusion" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-fusion", + "burn-ir", + "burn-std", + "cubecl", + "cubek", + "derive-new", + "half", + "hashbrown 0.16.1", + "serde", + "spin", +] + +[[package]] +name = "burn-cuda" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-cubecl", + "burn-fusion", + "cubecl", +] + +[[package]] +name = "burn-dataset" +version = "0.22.0-pre.1" +dependencies = [ + "burn-std", + "csv", + "derive-new", + "dirs", + "encoding_rs", + "fake", + "flate2", + "gix-tempfile", + "globwalk", + "hound", + "image", + "planus", + "polars", + "r2d2", + "r2d2_sqlite", + "rand 0.10.2", + "rayon", + "rmp-serde", + "rstest", + "rusqlite", + "sanitize-filename", + "serde", + "serde_json", + "serde_rusqlite", + "strum 0.28.0", + "tar", + "tempfile", + "thiserror 2.0.18", + "zip 8.6.0", +] + +[[package]] +name = "burn-derive" +version = "0.22.0-pre.1" +dependencies = [ + "derive-new", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "burn-dispatch" +version = "0.22.0-pre.1" +dependencies = [ + "burn-autodiff", + "burn-backend", + "burn-cpu", + "burn-cuda", + "burn-flex", + "burn-ndarray", + "burn-remote", + "burn-rocm", + "burn-tch", + "burn-wgpu", + "paste", +] + +[[package]] +name = "burn-flex" +version = "0.22.0-pre.1" +dependencies = [ + "aligned-vec", + "burn-backend", + "burn-ir", + "burn-std", + "bytemuck", + "gemm 0.19.0", + "half", + "libm", + "macerator", + "num-traits", + "once_cell", + "portable-atomic", + "portable-atomic-util", + "rayon", + "realfft", +] + +[[package]] +name = "burn-fusion" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-ir", + "burn-std", + "derive-new", + "hashbrown 0.16.1", + "log", + "serde", + "smallvec", + "spin", + "tracing", +] + +[[package]] +name = "burn-ir" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "burn-ndarray" +version = "0.22.0-pre.1" +dependencies = [ + "atomic_float", + "blas-src", + "burn-autodiff", + "burn-backend", + "burn-ir", + "burn-std", + "bytemuck", + "bytes", + "const-random", + "itertools", + "libm", + "macerator", + "matrixmultiply", + "ndarray 0.17.2", + "num-traits", + "openblas-src", + "paste", + "portable-atomic", + "portable-atomic-util", + "rand 0.10.2", + "rayon", + "seq-macro", +] + +[[package]] +name = "burn-nn" +version = "0.22.0-pre.1" +dependencies = [ + "burn-core", + "num-traits", + "rstest", +] + +[[package]] +name = "burn-no-std-tests" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-flex", + "burn-store", +] + +[[package]] +name = "burn-optim" +version = "0.22.0-pre.1" +dependencies = [ + "burn-autodiff", + "burn-core", + "burn-derive", + "burn-flex", + "burn-nn", + "burn-pack", + "derive-new", + "hashbrown 0.16.1", + "log", + "num-traits", + "rstest", + "serde", +] + +[[package]] +name = "burn-pack" +version = "0.22.0-pre.1" +dependencies = [ + "burn-std", + "byteorder", + "ciborium", + "serde", + "tempfile", +] + +[[package]] +name = "burn-remote" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-communication", + "burn-flex", + "burn-fusion", + "burn-ir", + "burn-router", + "burn-std", + "burn-tensor", + "bytes", + "futures-util", + "gloo-timers", + "iroh", + "log", + "rand 0.10.2", + "rmp-serde", + "serde", + "serde_bytes", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "burn-rl" +version = "0.22.0-pre.1" +dependencies = [ + "burn-core", + "derive-new", + "log", + "rand 0.10.2", +] + +[[package]] +name = "burn-rocm" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-cubecl", + "burn-fusion", + "cubecl", +] + +[[package]] +name = "burn-router" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-flex", + "burn-fusion", + "burn-ir", + "burn-std", + "burn-tensor", + "burn-wgpu", + "hashbrown 0.16.1", + "log", + "serde", + "spin", +] + +[[package]] +name = "burn-std" +version = "0.22.0-pre.1" +dependencies = [ + "ahash", + "bytemuck", + "bytes", + "cubecl-common", + "cubecl-zspace", + "dashmap", + "data-encoding", + "derive-new", + "enumset", + "half", + "hashbrown 0.16.1", + "indicatif 0.18.6", + "num-traits", + "paste", + "portable-atomic-util", + "rand 0.10.2", + "rand_distr 0.6.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "serial_test", + "smallvec", + "spin", + "tempfile", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "burn-store" +version = "0.22.0-pre.1" +dependencies = [ + "burn-core", + "burn-nn", + "burn-pack", + "burn-tensor", + "byteorder", + "bytes", + "ciborium", + "divan", + "half", + "hashbrown 0.16.1", + "lzma-rust2 0.16.5", + "memmap2", + "num-traits", + "regex", + "safetensors 0.7.0", + "serde", + "tar", + "tempfile", + "textdistance", + "thiserror 2.0.18", + "zip 8.6.0", +] + +[[package]] +name = "burn-tch" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "cc", + "libc", + "log", + "tch", + "torch-sys", +] + +[[package]] +name = "burn-tensor" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-backend-extension", + "burn-dispatch", + "burn-std", + "colored 3.1.1", + "derive-new", + "num-traits", + "serde", +] + +[[package]] +name = "burn-train" +version = "0.22.0-pre.1" +dependencies = [ + "async-channel", + "burn-core", + "burn-flex", + "burn-nn", + "burn-optim", + "burn-rl", + "burn-std", + "burn-store", + "derive-new", + "dirs", + "log", + "nvml-wrapper", + "rand 0.10.2", + "ratatui", + "rstest", + "serde", + "sysinfo 0.38.4", + "systemstat", + "tempfile", + "thiserror 2.0.18", + "tracing-appender", + "tracing-core", + "tracing-subscriber", + "unicode-width", +] + +[[package]] +name = "burn-vision" +version = "0.22.0-pre.1" +dependencies = [ + "aligned-vec", + "bon", + "burn-core", + "burn-cubecl", + "burn-fusion", + "burn-ir", + "burn-store", + "bytemuck", + "cubecl", + "derive-new", + "dirs", + "half", + "image", + "macerator", + "ndarray 0.17.2", + "num-traits", + "paste", + "serde", +] + +[[package]] +name = "burn-wgpu" +version = "0.22.0-pre.1" +dependencies = [ + "burn-backend", + "burn-cubecl", + "burn-fusion", + "cubecl", +] + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "portable-atomic", + "serde", +] + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "c2rust-bitfields" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46dc7d2bffa0d0b3d47eb2dc69973466858281446c2ac9f6d8a10e92ab1017df" +dependencies = [ + "c2rust-bitfields-derive", +] + +[[package]] +name = "c2rust-bitfields-derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe1117afa5937ce280034e31fa1e84ed1824a252f75380327eed438535333f8" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "candle-core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" +dependencies = [ + "accelerate-src", + "byteorder", + "candle-kernels", + "candle-metal-kernels", + "candle-ug", + "cudarc 0.19.8", + "float8", + "gemm 0.19.0", + "half", + "libc", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "objc2-foundation", + "objc2-metal", + "rand 0.9.4", + "rand_distr 0.5.1", + "rayon", + "safetensors 0.7.0", + "thiserror 2.0.18", + "tokenizers", + "yoke 0.8.3", + "zip 7.2.0", +] + +[[package]] +name = "candle-kernels" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "742e2ac226b777134436e9e692f44e77c278b8a7abb1554dc10e44dc911b349f" +dependencies = [ + "cudaforge", +] + +[[package]] +name = "candle-metal-kernels" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60" +dependencies = [ + "half", + "objc2", + "objc2-foundation", + "objc2-metal", + "once_cell", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "candle-ug" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" +dependencies = [ + "ug", + "ug-cuda", + "ug-metal", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cblas-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6feecd82cce51b0204cf063f0041d69f24ce83f680d87514b004248e7b0fa65" +dependencies = [ + "libc", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "comrak" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" +dependencies = [ + "caseless", + "entities", + "memchr", + "slug", + "typed-arena", + "unicode_categories", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constcat" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "ctor-proc-macro", + "dtor", + "link-section", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cubecl" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "cubecl-core", + "cubecl-cpu", + "cubecl-cuda", + "cubecl-hip", + "cubecl-ir", + "cubecl-runtime", + "cubecl-std", + "cubecl-wgpu", + "half", +] + +[[package]] +name = "cubecl-common" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "backtrace", + "bytemuck", + "bytes", + "cfg-if", + "cfg_aliases", + "ciborium", + "derive-new", + "derive_more", + "embassy-futures", + "embassy-time", + "etcetera", + "float4", + "float8", + "futures-lite", + "half", + "hashbrown 0.17.1", + "log", + "num-traits", + "oneshot", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "rand 0.10.2", + "sanitize-filename", + "serde", + "serde_bytes", + "serde_json", + "spin", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tynm", + "wasm-bindgen-futures", + "web-time", + "xxhash-rust", +] + +[[package]] +name = "cubecl-core" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "cubecl-common", + "cubecl-ir", + "cubecl-macros", + "cubecl-runtime", + "cubecl-zspace", + "derive-new", + "derive_more", + "enumset", + "float-ord", + "half", + "hashbrown 0.17.1", + "itertools", + "log", + "num-integer", + "num-traits", + "paste", + "serde", + "serde_json", + "tracing", + "variadics_please", +] + +[[package]] +name = "cubecl-cpp" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "derive-new", + "half", + "itertools", + "log", +] + +[[package]] +name = "cubecl-cpu" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bytemuck", + "crossbeam-utils", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "cubecl-std", + "derive-new", + "half", + "libc", + "log", + "paste", + "serde", + "smallvec", + "spin", + "sysinfo 0.38.4", + "tracel-llvm", + "tracel-llvm-bundler", + "winapi", +] + +[[package]] +name = "cubecl-cuda" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-runtime", + "cudarc 0.19.8", + "derive-new", + "half", + "log", + "serde", + "tracing", +] + +[[package]] +name = "cubecl-hip" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-hip-sys", + "cubecl-runtime", + "derive-new", + "half", + "log", + "paste", + "serde", + "tracing", +] + +[[package]] +name = "cubecl-hip-sys" +version = "7.2.5321100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58887c6d217859c2261a868a3a6b66aa8f44ea2f4bfa51d0dbe4dcb0d1f5768d" +dependencies = [ + "libc", + "regex", +] + +[[package]] +name = "cubecl-ir" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bumpalo", + "cubecl-common", + "cubecl-macros-internal", + "derive-new", + "derive_more", + "enumset", + "float-ord", + "fnv", + "foldhash 0.2.0", + "half", + "hashbrown 0.17.1", + "internment", + "itertools", + "num-traits", + "portable-atomic", + "serde", + "variadics_please", +] + +[[package]] +name = "cubecl-macros" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "cubecl-common", + "darling 0.23.0", + "derive-new", + "derive_more", + "ident_case", + "inflections", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "cubecl-macros-internal" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "cubecl-opt" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-ir", + "float-ord", + "hashbrown 0.17.1", + "log", + "num", + "petgraph", + "smallvec", + "stable-vec", + "type-map", +] + +[[package]] +name = "cubecl-runtime" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "ahash", + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-ir", + "cubecl-zspace", + "derive-new", + "derive_more", + "enumset", + "etcetera", + "hashbrown 0.17.1", + "itertools", + "log", + "md5", + "serde", + "serde_json", + "spin", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "tracing", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "cubecl-spirv" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "bitflags 2.13.0", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "half", + "hashbrown 0.17.1", + "serde", + "tracel-rspirv", +] + +[[package]] +name = "cubecl-std" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "half", + "num-traits", + "paste", + "serde", + "spin", + "variadics_please", +] + +[[package]] +name = "cubecl-wgpu" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "ash", + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-ir", + "cubecl-runtime", + "cubecl-spirv", + "derive-new", + "derive_more", + "half", + "hashbrown 0.17.1", + "itertools", + "log", + "sanitize-filename", + "tracel-ash", + "tracing", + "wasm-bindgen-futures", + "wgpu", +] + +[[package]] +name = "cubecl-zspace" +version = "0.11.0-pre.1" +source = "git+https://github.com/tracel-ai/cubecl?rev=9110136fe5bb3c6e7dc0295100fe7f88342ceb6e#9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" +dependencies = [ + "derive-new", + "serde", + "smallvec", +] + +[[package]] +name = "cubek" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubek-attention", + "cubek-convolution", + "cubek-fft", + "cubek-interpolate", + "cubek-matmul", + "cubek-pool", + "cubek-quant", + "cubek-random", + "cubek-reduce", + "cubek-std", +] + +[[package]] +name = "cubek-attention" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "bytemuck", + "cubecl", + "cubecl-common", + "cubek-matmul", + "cubek-std", + "half", + "serde", +] + +[[package]] +name = "cubek-convolution" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "bytemuck", + "cubecl", + "cubecl-common", + "cubek-matmul", + "cubek-std", + "derive-new", + "enumset", + "half", + "serde", +] + +[[package]] +name = "cubek-fft" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", +] + +[[package]] +name = "cubek-interpolate" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubecl-common", + "half", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "cubek-matmul" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "bytemuck", + "cubecl", + "cubecl-common", + "cubek-std", + "cubek-tile", + "half", + "serde", +] + +[[package]] +name = "cubek-pool" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubecl-common", + "thiserror 2.0.18", +] + +[[package]] +name = "cubek-quant" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubecl-common", + "cubek-tile", + "half", + "serde", +] + +[[package]] +name = "cubek-random" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubecl-common", + "half", + "num-traits", + "rand 0.10.2", + "serde", +] + +[[package]] +name = "cubek-reduce" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubek-std", + "half", + "num-traits", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "cubek-std" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "cubecl", + "cubecl-common", + "half", +] + +[[package]] +name = "cubek-tile" +version = "0.3.0-pre.1" +source = "git+https://github.com/tracel-ai/cubek?rev=5f837200f459a6a0694a0aae5655881599c400c8#5f837200f459a6a0694a0aae5655881599c400c8" +dependencies = [ + "bytemuck", + "cubecl", + "cubecl-common", + "half", + "serde", +] + +[[package]] +name = "cudaforge" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d475ff95b9e4096878f47fe0ca5dbad0e23849a79fc225305ea06c2f25771cd" +dependencies = [ + "anyhow", + "fs2", + "glob", + "num_cpus", + "rayon", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "walkdir", + "which", +] + +[[package]] +name = "cudarc" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf99ab37ee7072d64d906aa2dada9a3422f1d975cdf8c8055a573bc84897ed8" +dependencies = [ + "half", + "libloading 0.8.9", +] + +[[package]] +name = "cudarc" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" +dependencies = [ + "float8", + "half", + "libloading 0.9.0", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "custom-csv-dataset" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-dataset", + "csv", + "polars", + "reqwest 0.12.28", + "serde", +] + +[[package]] +name = "custom-cubecl-kernel" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-cubecl", + "burn-fusion", + "cubecl", + "log", + "serde", +] + +[[package]] +name = "custom-image-dataset" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "flate2", + "tar", +] + +[[package]] +name = "custom-learning-strategy" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "guide", + "log", +] + +[[package]] +name = "custom-renderer" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "guide", + "log", +] + +[[package]] +name = "custom-training-loop" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "guide", + "log", +] + +[[package]] +name = "custom-wgpu-kernel" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-cubecl", + "burn-fusion", + "bytemuck", + "cubecl", + "derive-new", + "log", + "serde", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.118", +] + +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "drawille" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e461c3f1e69d99372620640b3fd5f0309eeda2e26e4af69f6760c0e1df845" +dependencies = [ + "colored 2.2.0", + "fnv", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2647271c92754afcb174e758003cfd1cbf1e43e5a7853d7b1813e63e19e39a73" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", + "serde", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be87932f10230a4339ab394edd8e4611fcb72553d8295b4d52ea55249b3daa5" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp 0.22.3", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fake" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6be833b323a56361118a747470a45a1bcd5c52a2ec9b1e40c83dafe687e453" +dependencies = [ + "deunicode", + "either", + "rand 0.10.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a5404bf31d22893d61cf24d4dda149d8e6b2ff07601c3cb3be651031f61a4ed" + +[[package]] +name = "float8" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" +dependencies = [ + "half", + "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack", + "gemm-c32 0.18.2", + "gemm-c64 0.18.2", + "gemm-common 0.18.2", + "gemm-f16 0.18.2", + "gemm-f32 0.18.2", + "gemm-f64 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32 0.19.0", + "gemm-c64 0.19.0", + "gemm-common 0.19.0", + "gemm-f16 0.19.0", + "gemm-f32 0.19.0", + "gemm-f64 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.21.5", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.22.3", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "gemm-f32 0.18.2", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "gemm-f32 0.19.0", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "gix-trace", + "gix-utils", + "libc", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook 0.4.4", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" + +[[package]] +name = "gix-utils" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" +dependencies = [ + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +dependencies = [ + "bstr", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.13.0", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows 0.62.2", +] + +[[package]] +name = "guide" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "log", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", + "serde", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "rayon", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http", + "indicatif 0.17.11", + "libc", + "log", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke 0.8.3", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke 0.8.3", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.2", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "ignore" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "import-model-weights" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-store", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console 0.16.4", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "internment" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d4b0f6a39fd684effe2a73f5310df16a3fa7954c26d36833e98f44d1977a2" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.2", + "reqwest 0.13.4", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum 0.28.0", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "iroh-relay" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest 0.13.4", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.8", + "ws_stream_wasm", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kstat-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27964e4632377753acb0898ce6f28770d50cbca1339200ae63d700cff97b5c2b" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "liblzma" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "link-section" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b685d66585d646efe09fec763d796c291049c8b6bf84e04954bffc8748341f0d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lzma-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" +dependencies = [ + "crc", + "sha2 0.10.9", +] + +[[package]] +name = "lzma-rust2" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + +[[package]] +name = "macerator" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "508a2f720538bb7e3ea3cb6d098615b107cb82ae387d77d906276905e9ec894b" +dependencies = [ + "bytemuck", + "cfg_aliases", + "half", + "macerator-macros", + "moddef", + "num-traits", + "paste", + "rustc_version", +] + +[[package]] +name = "macerator-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5ce85961d618ce9794bdf822bfe96fe9dd341aa5b033b454f7a8d96e79b9b1" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "num_cpus", + "once_cell", + "rawpointer", + "thread-tree", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", + "stable_deref_trait", +] + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mnist" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "log", + "rand 0.10.2", +] + +[[package]] +name = "mnist-inference-web" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "console_error_panic_hook", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "moddef" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b" + +[[package]] +name = "modern-lstm" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "planus", + "polars", + "rand 0.10.2", + "rand_distr 0.6.0", + "serde", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "naga" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658200ddc25c6c7b860747516d132d1b284c0fafb7a380233acee9a72fb30e11" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.18", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "cblas-sys", + "libc", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "rayon", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlib-src" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe2058825b000774d9c99f502accb07e01692d58fc14b92a808b65f82978fbd" +dependencies = [ + "cmake", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "now" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" +dependencies = [ + "chrono", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "nvml-wrapper" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f049ae562349fefb8e837eb15443da1e7c6dcbd8a11f52a228f92220c2e5c85e" +dependencies = [ + "bitflags 2.13.0", + "libloading 0.8.9", + "nvml-wrapper-sys", + "static_assertions", + "thiserror 1.0.69", + "wrapcenum-derive", +] + +[[package]] +name = "nvml-wrapper-sys" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4d594420fcda43b1c2c4bd44d48974aa3c7a9ab2cbf10dc18e35265767bf0b" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object_store" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools", + "parking_lot", + "percent-encoding", + "quick-xml 0.39.4", + "rand 0.10.2", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openblas-build" +version = "0.10.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9c85e9e7dd5acdc67b9f3f0c99656b550df716bc63540c6a224a920754a5c2" +dependencies = [ + "anyhow", + "cc", + "flate2", + "tar", + "thiserror 2.0.18", + "ureq 3.3.0", +] + +[[package]] +name = "openblas-src" +version = "0.10.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a81a5e467f1861ad6ac32d5ec1690ad097d19854753b7424250fe27da46b98" +dependencies = [ + "dirs", + "openblas-build", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "p2p-remote-training" +version = "0.22.0-pre.1" +dependencies = [ + "blake3", + "burn", + "iroh", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "password-hash", + "sha2 0.10.9", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "planus" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3daf8e3d4b712abe1d690838f6e29fb76b76ea19589c4afa39ec30e12f62af71" +dependencies = [ + "array-init-cursor", + "hashbrown 0.15.5", +] + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml 0.41.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polars" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f1f122456ec136102033b13f71905b7c3f01e526642679c86aace9f9cdefde" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-io", + "polars-lazy", + "polars-ops", + "polars-parquet", + "polars-sql", + "polars-time", + "polars-utils", + "version_check", +] + +[[package]] +name = "polars-arrow" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d4892d5cc6461bb4a184d18e6fa03a5d316ee1d6de06a33dfa08d479fbc2db" +dependencies = [ + "atoi_simd", + "bitflags 2.13.0", + "bytemuck", + "bytes", + "chrono", + "chrono-tz", + "dyn-clone", + "either", + "ethnum", + "getrandom 0.2.17", + "getrandom 0.3.4", + "half", + "hashbrown 0.16.1", + "itoa", + "lz4", + "num-traits", + "polars-arrow-format", + "polars-buffer", + "polars-error", + "polars-schema", + "polars-utils", + "serde", + "simdutf8", + "streaming-iterator", + "strum_macros 0.27.2", + "version_check", + "zstd 0.13.3", +] + +[[package]] +name = "polars-arrow-format" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a556ac0ee744e61e167f34c1eb0013ce740e0ee6cd8c158b2ec0b518f10e6675" +dependencies = [ + "planus", + "serde", +] + +[[package]] +name = "polars-async" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e87f836190486f500b28347436985cc0af29b7a514e53f98840d396ce4d5f5" +dependencies = [ + "atomic-waker", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "parking_lot", + "pin-project-lite", + "polars-config", + "polars-error", + "polars-utils", + "rand 0.9.4", + "slotmap", + "tokio", +] + +[[package]] +name = "polars-buffer" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e481eeaf33c544ac0dd71a2e375553ca2fdae47b3472a96eaccb6eb43218783d" +dependencies = [ + "bytemuck", + "either", + "polars-utils", + "serde", + "version_check", +] + +[[package]] +name = "polars-compute" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55d41642a9ee887ac394c5a310af3256fa8340a86cde2cb624c515aa963461c" +dependencies = [ + "atoi_simd", + "bytemuck", + "chrono", + "either", + "fast-float2", + "hashbrown 0.16.1", + "itoa", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-error", + "polars-utils", + "rand 0.9.4", + "serde", + "strength_reduce", + "strum_macros 0.27.2", + "version_check", + "zmij", +] + +[[package]] +name = "polars-config" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65af861341b00eac73bcb65423fb5cc3d2322526d6b7561a0ddf094947c38033" +dependencies = [ + "polars-error", + "serde", +] + +[[package]] +name = "polars-core" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5924fc46306054bae78f9d35ea5e404cf185baa7f170eb55a16ff95191069c" +dependencies = [ + "bitflags 2.13.0", + "boxcar", + "bytemuck", + "chrono", + "chrono-tz", + "comfy-table", + "either", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "indexmap", + "itoa", + "num-traits", + "polars-arrow", + "polars-async", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-dtype", + "polars-error", + "polars-row", + "polars-schema", + "polars-utils", + "rand 0.9.4", + "rand_distr 0.5.1", + "rayon", + "regex", + "serde", + "serde_json", + "strum_macros 0.27.2", + "tokio", + "uuid", + "version_check", + "xxhash-rust", +] + +[[package]] +name = "polars-dtype" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b65a750bb99ea66be90c8a7e336f6f3a87427a0f7f89d2a40adae98314e9b27" +dependencies = [ + "boxcar", + "hashbrown 0.16.1", + "polars-arrow", + "polars-error", + "polars-utils", + "serde", + "uuid", +] + +[[package]] +name = "polars-error" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49a75e3406b9b5b4e5ff177877fe0de766e9688fbdb263a7b25f293dc47d61a" +dependencies = [ + "object_store", + "parking_lot", + "polars-arrow-format", + "regex", + "signal-hook 0.4.4", + "simdutf8", +] + +[[package]] +name = "polars-expr" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e21fdd37e8d9ef109f13d3454baffa0a57041cf60069123b8a2bd846c8ad0205" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.16.1", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-io", + "polars-ops", + "polars-plan", + "polars-row", + "polars-time", + "polars-utils", + "rand 0.9.4", + "rayon", + "recursive", + "regex", + "version_check", +] + +[[package]] +name = "polars-io" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6363a1c44a65fe8d73cce7fe4d77c9b6fea3a0da44007012e755e5b4e65aa078" +dependencies = [ + "async-trait", + "atoi_simd", + "blake3", + "bytes", + "chrono", + "fast-float2", + "fastrand", + "fs4", + "futures", + "glob", + "hashbrown 0.16.1", + "home", + "itoa", + "memchr", + "memmap2", + "num-traits", + "object_store", + "parking_lot", + "percent-encoding", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-core", + "polars-error", + "polars-parquet", + "polars-schema", + "polars-time", + "polars-utils", + "rand 0.9.4", + "rayon", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "simdutf8", + "tokio", + "zmij", +] + +[[package]] +name = "polars-lazy" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809d9590232a37d638337629c18279af97bdb0d17c3d8b2b6bb186e903e8bd5e" +dependencies = [ + "bitflags 2.13.0", + "chrono", + "either", + "memchr", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-core", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ops", + "polars-plan", + "polars-stream", + "polars-time", + "polars-utils", + "rayon", + "version_check", +] + +[[package]] +name = "polars-mem-engine" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55c6b7d162c506bc8eee82b065fa0399ebcd20b8f08675a534f3d360904ba38" +dependencies = [ + "memmap2", + "polars-arrow", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "rayon", + "recursive", +] + +[[package]] +name = "polars-ooc" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3eea0b386837b760a97ec9c92df99cbc10f94885cae060fd7100f9b794163" +dependencies = [ + "async-trait", + "boxcar", + "libc", + "polars-async", + "polars-config", + "polars-core", + "polars-io", + "polars-utils", + "thread_local", + "tokio", +] + +[[package]] +name = "polars-ops" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb146490a717ac5ae4ff3a22a5adf3ebae79361f187b1f550f9e24783d7ad765" +dependencies = [ + "argminmax", + "base64 0.22.1", + "bytemuck", + "chrono", + "chrono-tz", + "either", + "hashbrown 0.16.1", + "hex", + "indexmap", + "libm", + "memchr", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-schema", + "polars-utils", + "rayon", + "regex", + "regex-syntax", + "strum_macros 0.27.2", + "unicode-normalization", + "unicode-reverse", + "version_check", +] + +[[package]] +name = "polars-parquet" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6b79ba2103c00cbb9c5dd4459ffff1d8ce15286c7a6d376a04c711df20d8b7" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bytemuck", + "ethnum", + "futures", + "hashbrown 0.16.1", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-error", + "polars-parquet-format", + "polars-utils", + "regex", + "serde", + "simdutf8", + "streaming-decompression", +] + +[[package]] +name = "polars-parquet-format" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1" +dependencies = [ + "async-trait", + "futures", +] + +[[package]] +name = "polars-plan" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5ccc230515adb10762a8c7b0df03fd88f3328deb5b60e9b1eeb2eceef4d344" +dependencies = [ + "bitflags 2.13.0", + "blake3", + "bytemuck", + "bytes", + "chrono", + "chrono-tz", + "either", + "futures", + "hashbrown 0.16.1", + "indexmap", + "memmap2", + "num-traits", + "percent-encoding", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-core", + "polars-error", + "polars-io", + "polars-ops", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "regex", + "sha2 0.10.9", + "slotmap", + "strum_macros 0.27.2", + "tokio", + "version_check", +] + +[[package]] +name = "polars-row" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4e3254450024078e10c919ecd3b467bdcfdd5cf386c2ca6eedec89bd4771d2" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-dtype", + "polars-error", + "polars-utils", +] + +[[package]] +name = "polars-schema" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8a0de8951d02576fd0cdcecd9c605a6b6364d3105b7469b8d7874ea34eea2f" +dependencies = [ + "indexmap", + "polars-error", + "polars-utils", + "serde", + "version_check", +] + +[[package]] +name = "polars-sql" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b282a6164927eb12774b66b071b773a1573173ae53758e8d4df50389ff06efa2" +dependencies = [ + "bitflags 2.13.0", + "hex", + "polars-core", + "polars-error", + "polars-lazy", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "regex", + "serde", + "sqlparser", +] + +[[package]] +name = "polars-stream" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8ff4ee21799898579595a0ef2fb728d0a9cac3d061835fb7f7f6dd854734a" +dependencies = [ + "async-channel", + "async-trait", + "bitflags 2.13.0", + "bytes", + "chrono-tz", + "crossbeam-channel", + "crossbeam-queue", + "futures", + "memchr", + "num-traits", + "parking_lot", + "percent-encoding", + "polars-arrow", + "polars-async", + "polars-buffer", + "polars-compute", + "polars-config", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ooc", + "polars-ops", + "polars-parquet", + "polars-plan", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "slotmap", + "tokio", + "uuid", + "version_check", +] + +[[package]] +name = "polars-time" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1063fe074c4212a54917be604377c6e6bfbc8b6c942a5c57be214e4ccaaafdf" +dependencies = [ + "atoi_simd", + "bytemuck", + "chrono", + "chrono-tz", + "now", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-ops", + "polars-utils", + "rayon", + "regex", + "strum_macros 0.27.2", +] + +[[package]] +name = "polars-utils" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590b0a94aa8f97992d52f1198600ecc1c1f7cfa03c1b31cae057143455804ac0" +dependencies = [ + "argminmax", + "bincode", + "bytemuck", + "bytes", + "compact_str", + "either", + "flate2", + "foldhash 0.2.0", + "futures", + "half", + "hashbrown 0.16.1", + "indexmap", + "libc", + "memmap2", + "num-derive", + "num-traits", + "polars-config", + "polars-error", + "rand 0.9.4", + "raw-cpuid", + "rayon", + "regex", + "rmp-serde", + "serde", + "serde_json", + "serde_stacker", + "slotmap", + "stacker", + "sysinfo 0.37.2", + "tokio", + "uuid", + "version_check", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "critical-section", + "serde", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.2", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "pytorch-tests" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-store", + "float-cmp", + "serde", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63417e83dc891797eea3ad379f52a5986da4bca0d6ef28baf4d14034dd111b0c" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand 0.10.2", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "remote-inference-web" +version = "0.22.0-pre.1" +dependencies = [ + "blake3", + "burn", + "console_error_panic_hook", + "iroh", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.8", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.118", + "unicode-ident", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93279b86b3de76f820a8854dd06cbc33cfa57a417b19c47f6a25280112fb1df" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44560c11236a6130a46ce36c836a62936dc81ebf8c36a37947423571be0e55b6" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + +[[package]] +name = "safetensors-tests" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "burn-autodiff", + "burn-flex", + "burn-store", + "float-cmp", + "serde", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8bd74f47e124e760475a7e863b5820dcef09cae50782d03d65961f5ca1e6d9" +dependencies = [ + "rusqlite", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "server" +version = "0.22.0-pre.1" +dependencies = [ + "burn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "simple-regression" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "log", + "rgb", + "serde", + "textplots", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "sqlparser" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505aa16b045c4c1375bf5f125cce3813d0176325bfe9ffc4a903f423de7774ff" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028e551d5e270b31b9f3ea271778d9d827148d4287a5d96167b6bb9787f5cc38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "stable-vec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-decompression" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" +dependencies = [ + "fallible-streaming-iterator", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "systemstat" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a583abe520746270ffdbdaf0e3039a806f29be9d7034d66466a4839a01de0610" +dependencies = [ + "bytesize", + "kstat-rs", + "lazy_static", + "libc", + "mach2", + "nom 7.1.3", + "time", + "winapi", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tch" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e09b91610202dc4820c21eb474a42b386ef69f323b1c0902b5472ba7456ebb5" +dependencies = [ + "half", + "lazy_static", + "libc", + "ndarray 0.16.1", + "rand 0.8.6", + "safetensors 0.3.3", + "thiserror 1.0.69", + "torch-sys", + "zip 0.6.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook 0.3.18", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "text-classification" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "derive-new", + "log", + "serde", + "tokenizers", +] + +[[package]] +name = "text-generation" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "derive-new", + "log", + "serde", + "tokenizers", +] + +[[package]] +name = "text_placeholder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b" +dependencies = [ + "hashbrown 0.13.2", + "serde", + "serde_json", +] + +[[package]] +name = "textdistance" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa672c55ab69f787dbc9126cc387dbe57fdd595f585e4524cf89018fa44ab819" + +[[package]] +name = "textplots" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f7657a0066c9f9663659db0665319adff8b0943305fc73eddf1010e5a2072b1" +dependencies = [ + "drawille", + "rgb", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread-tree" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbd370cb847953a25954d9f63e14824a36113f8c72eecf6eccef5dc4b45d630" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "hf-hub", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "torch-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef40c585e342df95b66a1fa7c923188623999c2b657227befb481dfb03a6a42" +dependencies = [ + "anyhow", + "cc", + "libc", + "serde", + "serde_json", + "ureq 2.12.1", + "zip 0.6.6", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracel-ash" +version = "0.39.3+sdk1.4.350" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97fcf7e656528db871e679727140819a221d8c2aa5b90bfb5eb492a0ecc16e6" +dependencies = [ + "ash", + "c2rust-bitfields", +] + +[[package]] +name = "tracel-llvm" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "tracel-mlir-rs", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-llvm-bundler" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "anyhow", + "bytes", + "constcat", + "dirs", + "liblzma", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "walkdir", +] + +[[package]] +name = "tracel-mlir-rs" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "tracel-mlir-rs-macros", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-mlir-rs-macros" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "comrak", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.118", + "tracel-llvm-bundler", + "tracel-tblgen-rs", + "unindent", +] + +[[package]] +name = "tracel-mlir-sys" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "tracel-llvm-bundler", +] + +[[package]] +name = "tracel-rspirv" +version = "0.13.1+sdk-1.4.350.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f8d9173a81da18a8014822ac00525c6ecf8086c93f65b4113c24127b12cc09" +dependencies = [ + "bitflags 2.13.0", + "rustc-hash 2.1.3", + "serde", +] + +[[package]] +name = "tracel-tblgen-rs" +version = "22.1.4-4" +source = "git+https://github.com/tracel-ai/tracel-llvm?rev=82401e3c00a35a3fb7d5e2b415e79692e09e1079#82401e3c00a35a3fb7d5e2b415e79692e09e1079" +dependencies = [ + "paste", + "thiserror 2.0.18", + "tracel-llvm-bundler", +] + +[[package]] +name = "tracel-xtask" +version = "4.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58031a2e8da33f2807d97effead6bf38b73abccec082eb09bf0adab357dd6032" +dependencies = [ + "anyhow", + "clap", + "ctrlc", + "derive_more", + "dotenvy", + "env_logger", + "home", + "humantime", + "inquire", + "log", + "owo-colors", + "rand 0.9.4", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "time", + "toml 0.9.12+spec-1.1.0", + "tracel-xtask-macros", + "ureq 3.3.0", + "zip 6.0.0", +] + +[[package]] +name = "tracel-xtask-macros" +version = "4.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e019e77107aac569cead075b5710996898ec8b4633a4a4be52a59eac5b0960" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", +] + +[[package]] +name = "tynm" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21cdb0fc8f85c98b1ec812bc4cd69faf6c0fa2fc17d44ea3c2cdd38dc08e999" +dependencies = [ + "nom 8.0.0", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "ug" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b761acf8af3494640d826a8609e2265e19778fb43306c7f15379c78c9b05b0" +dependencies = [ + "gemm 0.18.2", + "half", + "libloading 0.8.9", + "memmap2", + "num", + "num-traits", + "num_cpus", + "rayon", + "safetensors 0.4.5", + "serde", + "thiserror 1.0.69", + "tracing", + "yoke 0.7.5", +] + +[[package]] +name = "ug-cuda" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0a1fa748f26166778c33b8498255ebb7c6bffb472bcc0a72839e07ebb1d9b5" +dependencies = [ + "cudarc 0.17.8", + "half", + "serde", + "thiserror 1.0.69", + "ug", +] + +[[package]] +name = "ug-metal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7adf545a99a086d362efc739e7cf4317c18cbeda22706000fd434d70ea3d95" +dependencies = [ + "half", + "metal", + "objc", + "serde", + "thiserror 1.0.69", + "ug", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-reverse" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash 2.1.3", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "rand 0.10.2", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53d0f7f2d0182759a549f1f313ce16b07d8f9ba93098157b5fa10ee3e61117f" +dependencies = [ + "quote", + "unsynn", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "wgan" +version = "0.22.0-pre.1" +dependencies = [ + "burn", + "image", +] + +[[package]] +name = "wgpu" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8f4bd44d92da5270f03409dba9f952dab24f128e05d6a554926101d1bf9114" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08763620e76fc980bca7bf84de82568614487a53172dd968d89187282eb87fa2" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3283b6da70d7fc892de95840215aa36381b5d0cbc479e88ee2b173c7b992b225" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dcf2b2e5c2afb9eda64c570a87a4e570304bf39e52eddbaaf222d655b656ad" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f76bc9c1c186ff3d9054e0d224c93c8c1c79d6653907c5249a5c1ea1a2cb1e43" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf765132d8d5f50e192e7880464890c13f4e7457aafe8e5466e8174586e9f101" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.0", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.18", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9eaac644e5008925c78567d272b9d66ef83da55a53cc17fc7daade7bb6e66e5" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9c93c2b35edde326df60ffdee4c0f5864eac3011d6768b70d43f028ad93565" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "wrapcenum-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "xtask" +version = "4.10.0" +dependencies = [ + "cargo_metadata", + "log", + "rstest", + "strum 0.28.0", + "tracel-xtask", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive 0.8.2", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke 0.8.3", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke 0.8.3", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes 0.8.4", + "byteorder", + "bzip2 0.4.4", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac 0.12.1", + "pbkdf2 0.11.0", + "sha1 0.10.6", + "time", + "zstd 0.11.2+zstd.1.5.2", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "aes 0.8.4", + "arbitrary", + "bzip2 0.6.1", + "constant_time_eq 0.3.1", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.4", + "hmac 0.12.1", + "indexmap", + "lzma-rust2 0.13.0", + "memchr", + "pbkdf2 0.12.2", + "ppmd-rust", + "sha1 0.10.6", + "time", + "zeroize", + "zopfli", + "zstd 0.13.3", +] + +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes 0.9.1", + "bzip2 0.6.1", + "constant_time_eq 0.4.2", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap", + "lzma-rust2 0.16.5", + "memchr", + "pbkdf2 0.13.0", + "ppmd-rust", + "sha1 0.11.0", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd 0.13.3", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.4", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b9bf53b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,242 @@ +[workspace] +# Try +# require version 2 to avoid "feature" additiveness for dev-dependencies +# https://doc.rust-lang.org/cargo/reference/resolver.html#feature-resolver-version-2 +resolver = "2" + +members = [ + "crates/*", + "crates/burn-store/pytorch-tests", + "crates/burn-store/safetensors-tests", + "examples/*", + "xtask", +] + +exclude = [ + "examples/notebook", + "examples/raspberry-pi-pico", + "examples/dqn-agent", # gym-rs +] + +[workspace.package] +edition = "2024" +license = "MIT OR Apache-2.0" +readme = "README.md" +version = "0.22.0-pre.1" + +[workspace.lints.clippy] + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +invalid_html_tags = "deny" + +[workspace.dependencies] +atomic_float = "1" +axum = "0.8.8" +bytemuck = "1.25.0" +bytes = { version = "1.11.1", default-features = false } +candle-core = { version = "0.10.2" } +ciborium = { version = "0.2", default-features = false } +clap = { version = "4.6.0", features = ["derive"] } +colored = "3.0.0" +console_error_panic_hook = "0.1.7" +const-random = "0.1" +csv = "1.3.1" +dashmap = "6.1.0" +data-encoding = { version = "2.11.0", default-features = false, features = [ + "alloc", +] } +dirs = "6.0.0" +encoding_rs = "0.8.33" +enumset = { version = "1.1.13", default-features = false } +fake = "5.1.0" +flate2 = "1.1.9" +float-cmp = "0.10.0" +futures = "0.3" +futures-util = "0.3" +gemm = { version = "0.19", default-features = false } +gix-tempfile = { version = "23.0.0", features = ["signals"] } +globwalk = "0.9.1" +hashbrown = "0.16" +hound = "3.5.1" +image = "0.25.9" +indicatif = "0.18.0" +insta = "1.45.0" +iroh = "1.0.0" +js-sys = "0.3.77" +libm = "0.2.15" +log = { default-features = false, version = "0.4.29" } +lzma-rust2 = "0.16.2" +opentelemetry = "0.31.0" +opentelemetry-aws = "0.19.0" +opentelemetry-otlp = "0.31.1" +opentelemetry_sdk = "0.31.0" +parking_lot = { version = "0.12.5", default-features = false } +paste = "1" +planus = { version = "=1.1" } +polars = { version = "0.54.0", features = ["lazy", "strings"] } +pretty_assertions = "1.4.1" +proc-macro2 = "1.0.106" +quote = "1.0.45" +r2d2 = "0.8.10" +r2d2_sqlite = "0.31.0" +rayon = "1.10.0" +regex = { version = "1.12.3", default-features = false, features = [ + "perf", + "unicode", +] } +reqwest = { version = "0.12.23", default-features = false, features = [ + "rustls-tls", +] } +rmp-serde = { version = "1.3.1", default-features = false } +rstest = "0.26.1" +rusqlite = "0.37.0" +sanitize-filename = "0.6.0" +serde_bytes = { version = "0.11.18", default-features = false, features = [ + "alloc", +] } # alloc for no_std +serde_rusqlite = "0.40.0" +serial_test = "3.2.0" +spin = { version = "0.10.0", features = [ + "mutex", + "spin_mutex", + "portable-atomic", +] } +strum = { version = "0.28.0", features = ["derive"] } +syn = { version = "2.0.111", features = ["full", "extra-traits"] } +tar = "0.4.45" +tempfile = "3.24.0" +textdistance = { version = "1.1.1", default-features = false } +thiserror = { version = "2", default-features = false } +tokio = { version = "1.51.1", features = ["rt", "macros"] } +tokio-tungstenite = "0.29" +tokio-util = "0.7" +tracing = { version = "0.1.44", default-features = false } +tracing-appender = "0.2.3" +tracing-core = { version = "0.1.36", default-features = false } +tracing-opentelemetry = "0.32.0" +tracing-subscriber = "0.3.23" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +zip = "8.6.0" + +# Persist related +memmap2 = { version = "0.9" } +safetensors = { version = "0.7.0", default-features = false } + +# Async handling +async-channel = "2.5" +futures-lite = { version = "2.6.1", default-features = false } + +# Terminal UI +ratatui = "0.30.0" +unicode-width = "0.2" + +# WGPU stuff +text_placeholder = "0.5.1" + +bincode = { version = "2.0.1", features = [ + "alloc", + "serde", +], default-features = false } + +# +# The following packages disable the "std" feature for no_std compatibility +# +cfg-if = "1.0.1" +derive-new = { version = "0.7.0", default-features = false } + +blas-src = { version = "0.14.0", default-features = false } +bon = "3.8.2" +half = { version = "2.7.1", features = [ + "alloc", + "num-traits", + "serde", +], default-features = false } +macerator = { version = "0.3.3" } +matrixmultiply = { version = "0.3.10", default-features = false } +ndarray = { version = "0.17.2", default-features = false } +num-traits = { version = "0.2.19", default-features = false, features = [ + "libm", +] } # libm is for no_std +openblas-src = "0.10.16" +rand = { version = "0.10.1", default-features = false, features = ["std_rng"] } +rand_distr = { version = "0.6.0", default-features = false } +serde = { version = "1.0.228", default-features = false, features = [ + "derive", + "alloc", +] } # alloc is for no_std, derive is needed +serde_json = { version = "1.0.148", default-features = false } +smallvec = { version = "1", features = ["const_generics", "const_new"] } +uuid = { version = "1.23.0", default-features = false } + +byteorder = { version = "1.5.0", default-features = false } +libc = "0.2.186" +nvml-wrapper = "0.12.0" +sysinfo = "0.38.0" +systemstat = "0.2.6" +tch = "0.22.0" +torch-sys = "0.22.0" # matches what tch is using, required for lib detection + +ahash = { version = "0.8.12", default-features = false } +aligned-vec = { version = "0.6", default-features = false } +once_cell = { version = "1", default-features = false } +portable-atomic = { version = "1.13.1" } +portable-atomic-util = { version = "0.2.6", features = ["alloc"] } +realfft = "3" + +### Internal burn crates ### +# Declared here so each consumer Cargo.toml can use `workspace = true` instead of +# repeating the path and version. +burn = { path = "crates/burn", version = "0.22.0-pre.1", default-features = false } +burn-autodiff = { path = "crates/burn-autodiff", version = "0.22.0-pre.1", default-features = false } +burn-backend = { path = "crates/burn-backend", version = "0.22.0-pre.1", default-features = false } +burn-backend-extension = { path = "crates/burn-backend-extension", version = "0.22.0-pre.1", default-features = false } +burn-candle = { path = "crates/burn-candle", version = "0.22.0-pre.1", default-features = false } +burn-communication = { path = "crates/burn-communication", version = "0.22.0-pre.1", default-features = false } +burn-core = { path = "crates/burn-core", version = "0.22.0-pre.1", default-features = false } +burn-cpu = { path = "crates/burn-cpu", version = "0.22.0-pre.1", default-features = false } +burn-cubecl = { path = "crates/burn-cubecl", version = "0.22.0-pre.1", default-features = false } +burn-cubecl-fusion = { path = "crates/burn-cubecl-fusion", version = "0.22.0-pre.1", default-features = false } +burn-cuda = { path = "crates/burn-cuda", version = "0.22.0-pre.1", default-features = false } +burn-dataset = { path = "crates/burn-dataset", version = "0.22.0-pre.1", default-features = false } +burn-derive = { path = "crates/burn-derive", version = "0.22.0-pre.1", default-features = false } +burn-dispatch = { path = "crates/burn-dispatch", version = "0.22.0-pre.1", default-features = false } +burn-flex = { path = "crates/burn-flex", version = "0.22.0-pre.1", default-features = false } +burn-fusion = { path = "crates/burn-fusion", version = "0.22.0-pre.1", default-features = false } +burn-ir = { path = "crates/burn-ir", version = "0.22.0-pre.1", default-features = false } +burn-ndarray = { path = "crates/burn-ndarray", version = "0.22.0-pre.1", default-features = false } +burn-nn = { path = "crates/burn-nn", version = "0.22.0-pre.1", default-features = false } +burn-optim = { path = "crates/burn-optim", version = "0.22.0-pre.1", default-features = false } +burn-remote = { path = "crates/burn-remote", version = "0.22.0-pre.1", default-features = false } +burn-rl = { path = "crates/burn-rl", version = "0.22.0-pre.1", default-features = false } +burn-rocm = { path = "crates/burn-rocm", version = "0.22.0-pre.1", default-features = false } +burn-router = { path = "crates/burn-router", version = "0.22.0-pre.1", default-features = false } +burn-std = { path = "crates/burn-std", version = "0.22.0-pre.1", default-features = false } +burn-pack = { path = "crates/burn-pack", version = "0.22.0-pre.1", default-features = false } +burn-store = { path = "crates/burn-store", version = "0.22.0-pre.1", default-features = false } +burn-tch = { path = "crates/burn-tch", version = "0.22.0-pre.1", default-features = false } +burn-tensor = { path = "crates/burn-tensor", version = "0.22.0-pre.1", default-features = false } +burn-tensor-testgen = { path = "crates/burn-tensor-testgen", version = "0.22.0-pre.1", default-features = false } +burn-train = { path = "crates/burn-train", version = "0.22.0-pre.1", default-features = false } +burn-vision = { path = "crates/burn-vision", version = "0.22.0-pre.1", default-features = false } +burn-wgpu = { path = "crates/burn-wgpu", version = "0.22.0-pre.1", default-features = false } + +### For the main burn branch. ### +cubecl = { git = "https://github.com/tracel-ai/cubecl", default-features = false, rev = "9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" } +cubecl-common = { git = "https://github.com/tracel-ai/cubecl", default-features = false, rev = "9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" } +cubecl-zspace = { git = "https://github.com/tracel-ai/cubecl", default-features = false, rev = "9110136fe5bb3c6e7dc0295100fe7f88342ceb6e" } +cubek = { git = "https://github.com/tracel-ai/cubek", default-features = false, rev = "5f837200f459a6a0694a0aae5655881599c400c8" } +### For local development. ### +# cubecl = { path = "../cubecl/crates/cubecl", default-features = false } +# cubecl-common = { path = "../cubecl/crates/cubecl-common", default-features = false } +# cubecl-zspace = { path = "../cubecl/crates/cubecl-zspace", default-features = false } +# cubek = { path = "../cubek/crates/cubek", default-features = false } +### For the release. ### +# cubecl = { version = "0.10.0", default-features = false } +# cubecl-common = { version = "0.10.0", default-features = false } +# cubecl-zspace = { version = "0.10.0", default-features = false } +# cubek = { version = "0.2.0", default-features = false } + +[profile.dev] +debug = 1 # Speed up compilation time and not necessary. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..cc81805 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022 Nathaniel Simard & Burn Framework Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..6b220b3 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Nathaniel Simard & Burn Framework Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICES.md b/NOTICES.md new file mode 100644 index 0000000..e69f5cb --- /dev/null +++ b/NOTICES.md @@ -0,0 +1,485 @@ +# NOTICES AND INFORMATION + +This file contains notices and information required by libraries that this +repository copied or derived from. + +## PyTorch MNIST Example + +**Source**: https://github.com/pytorch/examples/blob/main/mnist/main.py + +License: BSD 3-Clause License + +Copyright (c) 2017, +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +## wgpu + +**Source:** https://github.com/gfx-rs/wgpu/blob/trunk/.github/workflows/ci.yml + +MIT License + +Copyright (c) 2021 The gfx-rs developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +## BSL 1.0 + +**Source**: +- https://github.com/DoumanAsh/error-code +- https://github.com/DoumanAsh/clipboard-win + + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +## num-traits + +**Source:** https://github.com/rust-num/num-traits/blob/master/src/cast.rs + +MIT License + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +## RP + +**Source**: +- https://github.com/embassy-rs/embassy/blob/main/examples/rp/Cargo.toml +- https://github.com/embassy-rs/embassy/blob/main/examples/rp/build.rs +- https://github.com/embassy-rs/embassy/blob/main/examples/rp/memory.x + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) Embassy project contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +MIT license + +Copyright (c) Embassy project contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +## github-device-flow + +**Source**: +- Part of: https://github.com/jakewilkins/gh-device-flow/blob/main/src/lib.rs +- https://github.com/jakewilkins/gh-device-flow/blob/main/src/util.rs + +MIT License + +Copyright (c) 2022 Jake Wilkins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +## Candle - Pickle Reader + +**Source**: https://github.com/huggingface/candle/blob/main/candle-core/src/pickle.rs + +This project includes code from Candle by Hugging Face, licensed under both MIT and Apache 2.0 licenses. + +**MIT License**: https://github.com/huggingface/candle/blob/main/LICENSE-MIT + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +**Apache License 2.0**: https://github.com/huggingface/candle/blob/main/LICENSE-APACHE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +## ICU + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2016-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/POEM.md b/POEM.md new file mode 100644 index 0000000..c617244 --- /dev/null +++ b/POEM.md @@ -0,0 +1,40 @@ +# BURN: Burn Unstoppable Rusty Neurons + +In the realm of circuits and code, +A fiery forge ignites to bear its load, +A framework born, BURN it be named, +Unstoppable Rusty Neurons, untamed. + +From silicon synapses, connections spire, +A digital cortex, setting minds afire, +In the vast expanse of deep learning's sea, +A beacon of progress, BURN comes to be. + +Oh, rusty neurons, forged in the flame, +Unyielding in purpose, undaunted by name, +Through layers of logic and intricate art, +You weave and entwine, each playing its part. + +With algorithms profound, and data refined, +In ceaseless pursuit of knowledge to find, +BURN paves a path to enlightenment, bright, +A testament to the wonders of human foresight. + +In neural networks deep, where wisdom resides, +The dance of nodes and edges presides, +With loss and gradients, BURN takes its stride, +A journey towards truth, with AI as our guide. + +No barriers hold back the curious mind, +As BURN seeks the answers we yearn to find, +Unstoppable, relentless, in pursuit of the unknown, +Our collective intellect, within it, has grown. + +So sing we the praises of BURN's fiery might, +An ode to the sparks that set the dark alight, +To the rusty neurons, unstoppable and true, +A testament to the power of dreams, to breakthrough. + +(ChatGPT (model=gpt-4) with prompt: +Write a poem about "BURN: Burn Unstoppable Rusty Neurons" deep +learning neural network framework) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e45afd --- /dev/null +++ b/README.md @@ -0,0 +1,613 @@ +
+ + +[![Discord](https://img.shields.io/discord/1038839012602941528.svg?color=7289da&&logo=discord)](https://discord.gg/uPEBbYYDB6) +[![Current Crates.io Version](https://img.shields.io/crates/v/burn.svg)](https://crates.io/crates/burn) +[![Minimum Supported Rust Version](https://img.shields.io/crates/msrv/burn)](https://crates.io/crates/burn) +[![Documentation](https://img.shields.io/badge/docs-latest-blue)](https://burn.dev/docs/burn) +[![Test Status](https://github.com/tracel-ai/burn/actions/workflows/test.yml/badge.svg)](https://github.com/tracel-ai/burn/actions/workflows/test.yml) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](#license) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tracel-ai/burn) + +[](https://www.runblaze.dev) + +--- + +**Burn is both a tensor library and a deep learning framework, optimized for
numerical +computing, training and inference.** + +
+
+ +
+ +Training and inference usually live in separate worlds. Models are typically trained in Python then +exported to an open format like ONNX or optimized for production engines like vLLM, ONNX Runtime, or +TensorRT. This export step is often brittle and lossy, ruling out complex architectures and advanced +deployment use cases. + +Burn unifies the two. By executing multi-platform tensor operations via a single, unified API, the +exact code used for training is the exact code that runs in production. This makes workloads like +on-device personalization and federated learning straightforward, while enabling teams to go from +prototype to deployment in a single codebase. + +Burn preserves the intuitive ergonomics of PyTorch, with dynamic shapes and graphs, but JIT-compiles +streams of tensor operations, performing automatic kernel fusion. You get the flexibility of dynamic +graphs without the performance drop. + +## Rust for Research? + +Rust used to be a tough sell for research: long compilation times disrupted the fast +edit-compile-run loop that draws researchers to Python. Burn changes this paradigm. Designed around +incremental compilation, modifying model code recompiles in under 5 seconds, even in release mode. +This delivers a Python-like feedback loop with the speed and safety of Rust. + +## Ecosystem + +
+ + +Burn is the core of a growing, fully open-source Rust AI ecosystem. You are not adopting a single +library, you are joining a stack that spans GPU compute, model interop and domain toolkits, with +plenty of room to help shape what comes next. + +
+ +| Category | Project | Description | +| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Compute | [CubeCL](https://github.com/tracel-ai/cubecl) | GPU compute language and compiler behind Burn's accelerated backends. Write kernels once in Rust, run on CUDA, ROCm, Metal, Vulkan and WebGPU. Usable standalone. | +| Model interop | [burn-onnx](https://github.com/tracel-ai/burn-onnx) | Import ONNX models into Burn as native Rust code | +| | `burn-store` | Save, load and import model weights, including PyTorch and Safetensors | +| Domains | `burn-vision` | Computer vision operators and building blocks | +| | `burn-rl` | Reinforcement learning building blocks | +| | `burn-dataset` | Dataset loading, transforms and ready-made sources | +| Models | [models](https://github.com/tracel-ai/models) | Curated pre-trained models and examples built with Burn | +| Tooling | [burn-bench](https://github.com/tracel-ai/burn-bench) | Benchmark and compare backends, tracking performance over time | + +Burn's [CubeCL](https://github.com/tracel-ai/cubecl) backends (CUDA, ROCm, Metal, Vulkan, WebGPU, +CPU) compose with autodiff, fusion and remote-execution decorators, while external and simpler +backends (LibTorch and pure-Rust CPU/`no_std`) compose with autodiff only. See +[Supported Backends](#supported-backends) below for the full matrix. + +Every project here is open-source and actively developed. Want to help build the Rust AI ecosystem? +The [good first issues](https://github.com/tracel-ai/burn/contribute) are a great place to start, +and the [Contributing](#contributing) guide will get you set up. + +
+ +Community crates 🌱 + +
+ +These crates are not maintained by Tracel, but they are part of the same Rust AI story. Anything +that helps you load data, build environments, or ship models belongs here. Built something that +fits? Open a PR to add it! + +| Category | Crate | Description | +| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | +| Data & loading | [polars](https://github.com/pola-rs/polars) | Fast DataFrames for tabular data | +| | [arrow-rs](https://github.com/apache/arrow-rs) | Apache Arrow columnar memory format | +| | [image](https://github.com/image-rs/image) | Image decoding, encoding and processing | +| | [hf-hub](https://github.com/huggingface/hf-hub) | Download models and datasets from the Hugging Face Hub | +| Tokenization & NLP | [tokenizers](https://github.com/huggingface/tokenizers) | Fast, production-ready tokenizers | +| | [rust-bert](https://github.com/guillaume-be/rust-bert) | Ready-to-use NLP pipelines and transformer models | +| Numerical & linear algebra | [ndarray](https://github.com/rust-ndarray/ndarray) | N-dimensional arrays | +| | [nalgebra](https://github.com/dimforge/nalgebra) | Linear algebra | +| Classical ML | [linfa](https://github.com/rust-ml/linfa) | Classical ML toolkit, in the spirit of scikit-learn | +| | [smartcore](https://github.com/smartcorelib/smartcore) | Classical ML algorithms, no BLAS/LAPACK required | +| Inference & runtimes | [candle](https://github.com/huggingface/candle) | Minimalist ML framework with a focus on LLM inference | +| | [mistral.rs](https://github.com/EricLBuehler/mistral.rs) | Fast, multimodal LLM inference engine | +| | [ort](https://github.com/pykeio/ort) | ONNX Runtime bindings for hardware-accelerated inference | +| | [tract](https://github.com/sonos/tract) | Pure-Rust inference for ONNX and NNEF models | +| | [wonnx](https://github.com/webonnx/wonnx) | 100% Rust, WebGPU-accelerated ONNX runtime for native and the web | +| LLM apps & RAG | [rig](https://github.com/0xPlaygrounds/rig) | Build modular LLM applications and agents | +| | [langchain-rust](https://github.com/Abraxas-365/langchain-rust) | LangChain-style chain orchestration | +| Embeddings & vector search | [fastembed](https://github.com/Anush008/fastembed-rs) | Generate text embeddings and rerank locally | +| | [qdrant](https://github.com/qdrant/qdrant) | Vector search engine, written in Rust | +| | [lancedb](https://github.com/lancedb/lancedb) | Embedded, developer-friendly vector database | +| Computer vision | [kornia-rs](https://github.com/kornia/kornia-rs) | Low-level 3D computer vision library | +| Simulation & environments | [rapier](https://github.com/dimforge/rapier) | Physics engine for robotics and RL environments | +| Visualization | [rerun](https://github.com/rerun-io/rerun) | Multimodal data and CV/robotics visualization | +| | [plotters](https://github.com/plotters-rs/plotters) | Plotting and charting | + +
+ +## Backend + +
+ + +Burn strives to be as fast as possible on as many hardwares as possible, with robust +implementations. We believe this flexibility is crucial for modern needs where you may train your +models in the cloud, then deploy on customer hardwares, which vary from user to user. + +
+ +### Supported Backends + +Most backends support all operating systems, so we don't mention them in the tables below. + +**GPU Backends:** + +| | CUDA | ROCm | Metal | Vulkan | WebGPU | LibTorch | +| ------- | ---- | ---- | ----- | ------ | ------ | -------- | +| Nvidia | ☑️ | - | - | ☑️ | ☑️ | ☑️ | +| AMD | - | ☑️ | - | ☑️ | ☑️ | ☑️ | +| Apple | - | - | ☑️ | - | ☑️ | ☑️ | +| Intel | - | - | - | ☑️ | ☑️ | - | +| Qualcom | - | - | - | ☑️ | ☑️ | - | +| Wasm | - | - | - | - | ☑️ | - | + +**CPU Backends:** + +| | Cpu (CubeCL) | Flex | LibTorch | +| ------ | ------------ | ---- | -------- | +| X86 | ☑️ | ☑️ | ☑️ | +| Arm | ☑️ | ☑️ | ☑️ | +| Wasm | - | ☑️ | - | +| no-std | - | ☑️ | - | + +
+ +Compared to other frameworks, Burn has a very different approach to supporting many backends. By +design, most code is generic over the Backend trait, which allows us to build Burn with swappable +backends. This makes composing backend possible, augmenting them with additional functionalities +such as autodifferentiation and automatic kernel fusion. + +
+ +Autodiff: Backend decorator that brings backpropagation to any backend 🔄 + +
+ +Contrary to the aforementioned backends, Autodiff is actually a backend _decorator_. This means that +it cannot exist by itself; it must encapsulate another backend. + +The simple act of wrapping a base backend with Autodiff transparently equips it with +autodifferentiation support, making it possible to call backward on your model. + +```rust +use burn::backend::{Autodiff, Wgpu}; +use burn::tensor::{Distribution, Tensor}; + +fn main() { + type Backend = Autodiff; + + let device = Default::default(); + + let x: Tensor = Tensor::random([32, 32], Distribution::Default, &device); + let y: Tensor = Tensor::random([32, 32], Distribution::Default, &device).require_grad(); + + let tmp = x.clone() + y.clone(); + let tmp = tmp.matmul(x); + let tmp = tmp.exp(); + + let grads = tmp.backward(); + let y_grad = y.grad(&grads).unwrap(); + println!("{y_grad}"); +} +``` + +Of note, it is impossible to make the mistake of calling backward on a model that runs on a backend +that does not support autodiff (for inference), as this method is only offered by an Autodiff +backend. + +See the [Autodiff Backend README](./crates/burn-autodiff/README.md) for more details. + +
+ +
+ +Fusion: Backend decorator that brings kernel fusion to all first-party backends + +
+ +This backend decorator enhances a backend with kernel fusion, provided that the inner backend +supports it. Note that you can compose this backend with other backend decorators such as Autodiff. +All first-party accelerated backends (like WGPU and CUDA) use Fusion by default (`burn/fusion` +feature flag), so you typically don't need to apply it manually. + +```rust +#[cfg(not(feature = "fusion"))] +pub type Cuda = CubeBackend; + +#[cfg(feature = "fusion")] +pub type Cuda = burn_fusion::Fusion>; +``` + +Of note, we plan to implement automatic gradient checkpointing based on compute bound and memory +bound operations, which will work gracefully with the fusion backend to make your code run even +faster during training, see [this issue](https://github.com/tracel-ai/burn/issues/936). + +See the [Fusion Backend README](./crates/burn-fusion/README.md) for more details. + +
+ +
+ +Remote (Beta): Backend decorator for remote backend execution, useful for distributed computations + +
+ +That backend has two parts, one client and one server. The client sends tensor operations over the +network to a remote compute backend. You can use any first-party backend as server in a single line +of code: + +```rust +fn main_server() { + // Start a server on port 3000. + burn::server::start::(Default::default(), 3000); +} + +fn main_client() { + // Create a client that communicate with the server on port 3000. + use burn::backend::{Autodiff, RemoteBackend}; + + type Backend = Autodiff; + + let device = RemoteDevice::new("ws://localhost:3000"); + let tensor_gpu = + Tensor::::random([3, 3], Distribution::Default, &device); +} + +``` + +
+ +
+ +## Training & Inference + +
+ + +The whole deep learning workflow is made easy with Burn, as you can monitor your training progress +with an ergonomic dashboard, and run inference everywhere from embedded devices to large GPU +clusters. + +Burn was built from the ground up with training and inference in mind. It's also worth noting how +Burn, in comparison to frameworks like PyTorch, simplifies the transition from training to +deployment, eliminating the need for code changes. + +
+ +
+ +
+ + + Burn Train TUI + +
+ +
+ +**Click on the following sections to expand 👇** + +
+ +Training Dashboard 📈 + +
+ +As you can see in the previous video (click on the picture!), a new terminal UI dashboard based on +the [Ratatui](https://github.com/ratatui-org/ratatui) crate allows users to follow their training +with ease without having to connect to any external application. + +You can visualize your training and validation metrics updating in real-time and analyze the +lifelong progression or recent history of any registered metrics using only the arrow keys. Break +from the training loop without crashing, allowing potential checkpoints to be fully written or +important pieces of code to complete without interruption 🛡 + +
+ +
+ +ONNX Support 🐫 + +
+ +Burn supports importing ONNX (Open Neural Network Exchange) models through the +[burn-onnx](https://github.com/tracel-ai/burn-onnx) crate, allowing you to easily port models from +TensorFlow or PyTorch to Burn. The ONNX model is converted into Rust code that uses Burn's native +APIs, enabling the imported model to run on any Burn backend (CPU, GPU, WebAssembly) and benefit +from all of Burn's optimizations like automatic kernel fusion. + +Our ONNX support is further described in +[this section of the Burn Book 🔥](https://burn.dev/books/burn/onnx-import.html). + +> **Note**: This crate is in active development and currently supports a +> [limited set of ONNX operators](https://github.com/tracel-ai/burn-onnx/blob/main/SUPPORTED-ONNX-OPS.md). + +
+ +
+ +Importing PyTorch or Safetensors Models 🚚 + +
+ +You can load weights from PyTorch or Safetensors formats directly into your Burn-defined models. +This makes it easy to reuse existing models while benefiting from Burn's performance and deployment +features. + +Learn more in the [Saving & Loading Models](https://burn.dev/books/burn/saving-and-loading.html) +section of the Burn Book. + +
+ +
+ +Inference in the Browser 🌐 + +
+ +Several of our backends can run in WebAssembly environments: Flex for CPU execution, and WGPU for +GPU acceleration via WebGPU. This means that you can run inference directly within a browser. We +provide several examples of this: + +- [MNIST](./examples/mnist-inference-web) where you can draw digits and a small convnet tries to + find which one it is! 2️⃣ 7️⃣ 😰 +- [Image Classification](https://github.com/tracel-ai/burn-onnx/tree/main/examples/image-classification-web) + where you can upload images and classify them! 🌄 + +
+ +
+ +Embedded: no_std support ⚙️ + +
+ +Burn's core components support [no_std](https://docs.rust-embedded.org/book/intro/no-std.html). This +means it can run in bare metal environment such as embedded devices without an operating system. + +> As of now, only the Flex backend can be used in a _no_std_ environment. + +
+ +
+ +### Benchmarks + +To evaluate performance across different backends and track improvements over time, we provide a +dedicated benchmarking suite. + +Run and compare benchmarks using [burn-bench](https://github.com/tracel-ai/burn-bench). + +> ⚠️ **Warning** When using one of the `wgpu` backends, you may encounter compilation errors related +> to recursive type evaluation. This is due to complex type nesting within the `wgpu` dependency +> chain. To resolve this issue, add the following line at the top of your `main.rs` or `lib.rs` +> file: +> +> ```rust +> #![recursion_limit = "256"] +> ``` +> +> The default recursion limit (128) is often just below the required depth (typically 130-150) due +> to deeply nested associated types and trait bounds. + +## Getting Started + +
+ + +Just heard of Burn? You are at the right place! Just continue reading this section and we hope you +can get on board really quickly. + +
+ +
+ +The Burn Book 🔥 + +
+ +To begin working effectively with Burn, it is crucial to understand its key components and +philosophy. This is why we highly recommend new users to read the first sections of +[The Burn Book 🔥](https://burn.dev/books/burn/). It provides detailed examples and explanations +covering every facet of the framework, including building blocks like tensors, modules, and +optimizers, all the way to advanced usage, like coding your own GPU kernels. + +> The project is constantly evolving, and we try as much as possible to keep the book up to date +> with new additions. However, we might miss some details sometimes, so if you see something weird, +> let us know! We also gladly accept Pull Requests 😄 + +
+ +
+ +Examples 🙏 + +
+ +Let's start with a code snippet that shows how intuitive the framework is to use! In the following, +we declare a neural network module with some parameters along with its forward pass. + +```rust +use burn::nn; +use burn::module::Module; +use burn::tensor::backend::Backend; + +#[derive(Module, Debug)] +pub struct PositionWiseFeedForward { + linear_inner: nn::Linear, + linear_outer: nn::Linear, + dropout: nn::Dropout, + gelu: nn::Gelu, +} + +impl PositionWiseFeedForward { + pub fn forward(&self, input: Tensor) -> Tensor { + let x = self.linear_inner.forward(input); + let x = self.gelu.forward(x); + let x = self.dropout.forward(x); + + self.linear_outer.forward(x) + } +} +``` + +We have a somewhat large amount of [examples](./examples) in the repository that shows how to use +the framework in different scenarios. + +Following [the book](https://burn.dev/books/burn/): + +- [Basic Workflow](./examples/guide) : Creates a custom CNN `Module` to train on the MNIST dataset + and use for inference. +- [Custom Training Loop](./examples/custom-training-loop) : Implements a basic training loop instead + of using the `Learner`. +- [Custom WGPU Kernel](./examples/custom-wgpu-kernel) : Learn how to create your own custom + operation with the WGPU backend. + +Additional examples: + +- [Custom CSV Dataset](./examples/custom-csv-dataset) : Implements a dataset to parse CSV data for a + regression task. +- [Regression](./examples/simple-regression) : Trains a simple MLP on the California Housing dataset + to predict the median house value for a district. +- [Custom Image Dataset](./examples/custom-image-dataset) : Trains a simple CNN on custom image + dataset following a simple folder structure. +- [Custom Renderer](./examples/custom-renderer) : Implements a custom renderer to display the + [`Learner`](./building-blocks/learner.md) progress. +- [Image Classification Web](./examples/image-classification-web) : Image classification web browser + demo using Burn, WGPU and WebAssembly. +- [MNIST Inference on Web](./examples/mnist-inference-web) : An interactive MNIST inference demo in + the browser. The demo is available [online](https://burn.dev/demo/). +- [MNIST Training](./examples/mnist) : Demonstrates how to train a custom `Module` (MLP) with the + `Learner` configured to log metrics and keep training checkpoints. +- [PyTorch Import Inference](./examples/import-model-weights) : Imports a PyTorch model pre-trained + on MNIST to perform inference on a sample image with Burn. +- [Text Classification](./examples/text-classification) : Trains a text classification transformer + model on the AG News or DbPedia dataset. The trained model can then be used to classify a text + sample. +- [Text Generation](./examples/text-generation) : Trains a text generation transformer model on the + DbPedia dataset. +- [Wasserstein GAN MNIST](./examples/wgan) : Trains a WGAN model to generate new handwritten digits + based on MNIST. + +For more practical insights, you can clone the repository and run any of them directly on your +computer! + +
+ +
+ +Pre-trained Models 🤖 + +
+ +We keep an updated and curated list of models and examples built with Burn, see the +[tracel-ai/models repository](https://github.com/tracel-ai/models) for more details. + +Don't see the model you want? Don't hesitate to open an issue, and we may prioritize it. Built a +model using Burn and want to share it? You can also open a Pull Request and add your model under the +community section! + +
+ +
+ +Why use Rust for AI? 🦀 + +
+ +Deep Learning is a special form of software where you need very high level abstractions as well as +extremely fast execution time. Rust is the perfect candidate for that use case since it provides +zero-cost abstractions to easily create neural network modules, and fine-grained control over memory +to optimize every detail. To this day, the mainstream solution has been to offer APIs in Python but +rely on bindings to low-level languages such as C/C++. This reduces portability, increases +complexity and creates friction between researchers and engineers. Rust's approach to abstractions +is versatile enough to tackle this two-language dichotomy, and Cargo makes it easy to build, test +and deploy from any environment, which is usually a pain in Python. + +Rust's AI ecosystem is young, but it is real and growing quickly. Foundational pieces are already +here: Burn and [CubeCL](https://github.com/tracel-ai/cubecl) for training and compute, +[candle](https://github.com/huggingface/candle) for inference, Hugging Face's `tokenizers` and +`safetensors`, and `polars` and `ndarray` for data. Betting on Rust today means betting on a stack +that is growing, and one where contributors still shape the direction. The pieces that don't exist +yet are opportunities rather than dead-ends (see [Contributing](#contributing)). + +Rust is also what makes one-stack-everywhere possible: a single self-contained binary with no Python +runtime to ship, running from servers down to `no_std` embedded targets. + +
+ +
+ +> **Deprecation Note**
Since `0.14.0`, the internal structure for tensor data has changed. The +> previous `Data` struct was deprecated and officially removed since `0.17.0` in favor of the new +> `TensorData` struct, which allows for more flexibility by storing the underlying data as bytes and +> keeping the data type as a field. If you are using `Data` in your code, make sure to switch to +> `TensorData`. + + + +
+ +Loading Model Records From Previous Versions ⚠️ + +
+ +In the event that you are trying to load a model record saved in a version older than `0.14.0`, make +sure to use a compatible version (`0.14`, `0.15` or `0.16`) with the `record-backward-compat` +feature flag. + +``` +features = [..., "record-backward-compat"] +``` + +Otherwise, the record won't be deserialized correctly and you will get an error message. This error +will also point you to the backward compatible feature flag. + +The backward compatibility was maintained for deserialization when loading records. Therefore, as +soon as you have saved the record again it will be saved according to the new structure and you can +upgrade back to the current version + +Please note that binary formats are not backward compatible. Thus, you will need to load your record +in a previous version and save it in any of the other self-describing record format (e.g., using the +`NamedMpkFileRecorder`) before using a compatible version (as described) with the +`record-backward-compat` feature flag. + +
+ +## Community + +
+ + +If you are excited about the project, don't hesitate to join our +[Discord](https://discord.gg/uPEBbYYDB6)! We try to be as welcoming as possible to everybody from +any background. You can ask your questions and share what you built with the community! + +
+ +
+ +### Contributing + +Before contributing, please read the [Contributing Guidelines](./CONTRIBUTING.md) and our +[Code of Conduct](./CODE-OF-CONDUCT.md). The [Contributor Book](https://burn.dev/contributor-book/) +covers architecture, environment setup, and guides for common tasks. + +## Status + +Burn is currently in active development, and there will be breaking changes. While any resulting +issues are likely to be easy to fix, there are no guarantees at this stage. + +## License + +Burn is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE-APACHE](./LICENSE-APACHE) and [LICENSE-MIT](./LICENSE-MIT) for details. Opening a pull +request is assumed to signal agreement with these licensing terms. + +
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..822af54 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`tracel-ai/burn` +- 原始仓库:https://github.com/tracel-ai/burn +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..1a94327 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,24 @@ +[default] +extend-ignore-identifiers-re = ["ratatui", "Ratatui", "NdArray*", "ND", "log_lik"] + +[default.extend-identifiers] +UE4M3 = "UE4M3" +UE8M0 = "UE8M0" +ue8m0 = "ue8m0" + +[files] +extend-exclude = [ + "*.onnx", + "*.proto", + "assets/ModuleSerialization.xml", +] + +[default.extend-words] +# Don't correct "nd" (n-dimensional, as in scatter_nd/gather_nd) +nd = "nd" +# Don't correct "arange" which is intentional +arange = "arange" +# Don't correct "convnet" (convolutional network) +convnet = "convnet" +# Don't correct "Nd" in convNd / conv_nd (N-dimensional) +Nd = "Nd" diff --git a/assets/backend-chip.png b/assets/backend-chip.png new file mode 100644 index 0000000000000000000000000000000000000000..c0282aead33f887060d49fdc9323fd8c72cf1e64 GIT binary patch literal 68782 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 ZM!9G31QAFPByF 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 literal 0 HcmV?d00001 diff --git a/assets/burn-train-tui.png b/assets/burn-train-tui.png new file mode 100644 index 0000000000000000000000000000000000000000..817e21b00840c674db815306afc1e53ec1de6e7d GIT binary patch literal 1008983 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≪9KM{cN;HSt|0El#%AuRuI|5dj}$nQ2F8 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

m8J+|D*UtXqY%xnMPk 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

U`y9{*`;En|*;B5DpS?x;UUy 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

Nil%JSLLj*9vK8qr_u!cabwM8Gqff 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

VP|FkcA~;4YVUcbRh> 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

(aM!J0etBN4LilsXtRd~|IwVKQ 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

kT!d) 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

EinsmqP!ei) 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 M>l)B}1tb6e0Yb$!tpET3 literal 0 HcmV?d00001 diff --git a/assets/ember-blazingly-fast.png b/assets/ember-blazingly-fast.png new file mode 100644 index 0000000000000000000000000000000000000000..1b3f751d5c2b82aecc0a2620a547507e2841d322 GIT binary patch literal 168574 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%0A~_5$qEEWOlK?+{ zkP-=M85)QI66}zJa>gY(Ru&zbMIQwHMD9oABCjvMeSO1tZ>{JIWf9}OCk0^4{`~`ufy4Ro1ohy9<_N^40~U_T;8N9eE|F zFE5!TC3;F`+n0>xmKis~VW46hWf2%8?TSU~i;}+`INLJ&sj-~byW2-YrxeJ>Z-eL% zP=^{9kDnePAKf z6krDsNaoKk8BUq^Pbv?(BzrD7?>%VN{gXTUY?Ue*898Hb!1eW|QxP99BJc!&J~B=_ zbH;pqZ&jU(3bLC2UGzabBxvhCtgZU@mLJ&o_ku{66Zi4);}z+%ho1N4E5)4T7-(4B z`ZyA05qdf2Fi?UV6Rs!muFw*anR>V?;P5Hnd5inLuVy_dA6;5LE|B{E)In)=+2xSU zl@jR@h%hzg+?^*KdtJy&pAAM-sNX%ERF)u^wPu8VWX@ zM-m9uwopst4|U57o%g4^vLR^($P*Z%7}ce< zy!OzR&ha3P?fa^a7CxiM?ZQO0>MDb?Kn|^bRor%X@Bv)s3NjatT(~ zhW7Ea*A5?8g90%zu4pBRF?e?cSC@{TUaWBJ$>+Zf=oDkUq##*oToX`6&MR|8U%{yp z@N*v6%+g406ntce2qBahx1^lN{EFtY@({?Fx@O*=v*GC4v;2A9?rY9_yD0}v>F0xW zn|h2xsIUD!9W)T*p&=BD)S0o*2Rf>5?Ye({x03kfWm~%XB40wJfP4l55l>vHB5dxi zo*M3ZfVwb!|Ju>s(-3QHf=B&Rqel#Goqp7@{D^w=aHz=!(WR^O6^9A9)B~{`s=?cXPSj8QNceI!c87MXJFGtaqb#DIC&Er3`e@k^08XvOPg{ z8BXq9$6oGRUlUO0;e^DEkSLMS1XE*jt4UiUGujVx62{cO_DLjJ2SQ}~X6j=2e9LAU zA_zAe%=4<=%sCzITTLb6FIYs94mS~A=djGNYk~E|p_JnpE zse_;K$m!QH!H~fR>g+5nB^L2NM!mnP`Pk!O<#}WyDYuXs(f@v%r=t1f z@y{Ew;SfUD0|fVfCjp1#Krw4%)cOaKR7-Lnm9RUe&^2%)Y*LwC6b`UJTHtuz0)89l99|>f{toUw+VaIWp*SskmUD}56nVb zH#00;WaYt!UIk~4bK}?|E=1^LEFe#MzPtoY%jI+aR>oGAZw=-U*jg81XU{WQGTcy| z{p4b}2z%#RGPGfacAya=WpDc9d6fChAa(0+kJ{`9;AD2YPA&F_`G7%!V+B=4$1lm2 zwT1RJm*rG!&fjVR7@k*NUa*w(fAyy29h$as-bdLTv@VB5|A*D_!f@?7@F!;f>S0r*s}`SMuz92=}HD0s+Uw{r1`q$48Fp zmxCMUz3H;qaUP%=9KgeGL2HHa0MUI1RTKd-4?`TO2}5a3cGnXvy0YoSUxT3ZV}nh_ zrQEw~;Rh<#TlDNhBB7?l=;;;O!KD>dK-l z9ihYV;#)8!@$KGM!$my(Ku7YCi)%FlR6mchPO(s5C_tT*vN?CF3Qkpo-bSRV&BZEL zzm*pJcBEotJTrO@LhaM<9Zz zep=ZE1}Y5|nw7?t5Ow5zdbj&%?1jcWi@lJ(mIC3L%vIg>ruUXtpYuKBRRr@Q_%j3Q zGVXJ1muO8MdI}_y(lg3E5dZ$G&&%rU;Y0}Lx01m;vkA0!{x!;Fq;x`Oa#m_d2#)Z#NB zU~l9_E4&;`XyzlhCiU=4;LWjX5;k!q@2A-uq63+ND z3tZ7ZCsa%phk#NKB&pci>n((60U&p!*1k4yq_VvsaUb2H1RWaF21Ujr|3f#Od7G$1 zkm&PJO=QI4@~w|G&l^fuT_?YA#_VQ}IlBJn91R_~^@D2q$GuH2;kcz5r;fS#q%$98}*o zt!1c+7y~2}D`Iyx@5~GZWL?J30KK33B}w<@qyZi;uaAg?pdo6oF&^cXF>0+4JA8aP zcnF-{OJkA6vQisGxohz11TTs%Je%bmh_~fQxWPMKef8|;gZfYrYwdU2eLgD4;(=-C zHUs&j+z&mOGjq07eVyX|UE_-hc@lpctr2nWibMnmV}30D8_6}PNv%F2m6i5~j)?_`03c+4 z%!OD;`ysv`>a;J*L(V`nvePpWObxG~a?e6FaIIwX^%TUTn1qim00W18M!r#DYu<;= zoaRRd#Gyd4N=Hz4r3LyzC-=C2;*t%28=VBlz2HWiuua$91#WX_H2ezEFBIafF{*|n z|LNdvq`r`RUJsvaA_EwBqXA%s`J_lt9OZRQ%x%PnL;<~HcxKYwWr=`ieKotv3P8Ly zOVIL&)QCk>K$-V|6(9yk+maZs$?{UUI}fe^02+Z|Tm zo9uC)=h9U3-8kbxH8SjW=<`#(pHzz$XJpW+`%GTv%z44(Iy|#`RdY_u+Zup5x~P(* zAf6qjwj}U!k$(kg10gY*{qHJj*07bKqr(uYJuPurF#a=a$^C=EVO+N(l(q%=R;4UJ zvub+=kmR4btMAR{Y?)rulRhJry>MN_nX1OXGugu(or>eYW~sAPI=-y|=22eUPjx6y zivAa$%z#g4e5mTb3drZ|J<{8NUT0p9YUarmCIj2ve6Li)X3&Q@ece*)GQYsjE zZKCZpTnkY!=>3)RU?u+)LP64n8Pz3EN|YyD2EO(8y%UUP8>iVt&rKLz0STPF{e_RkrjOi5-$$U13m!|&PI2CJ z6mTEFt`*9;D7v=DOK?xPw%y(7*)!IZ3%6xBx&G+6 zrMzdOU`%6Q`r{kn&&5u3UbS!SyJ8cBc#~2#HeU-|{(#7)JHX#V=(Nih?8Mm$uy(pr zf7F25Mku*8(`9+qNIwUnQs1Gy2kJS3X$$ssf5Jq!F3X58Sg){@k32bU zaBn%>KMdQGm~(2H_e)!zbI$o5rdAO=8;}-&a$I-syxLcRnpSd$SR8%vk zOJ1kPtGF6-s4v`YT<3;+{|9iXWV8-hG|a6N4Fsal9DKf4-_xlt*f+E`ol|{8U*B`} zEBQ3H?13txI6ON6RZIRgrCA_kV}0##azT{W5bibZ>DgnEeU4O(Ge4s23DRtoyv6V) ze>?Vu^XG=7*1>he;OD(iQL;n8!!ZBuN6_isD0K10ef^0>RrNIiKj4@0e7GY2Q3yUwl0g0Dg8GjC6K;(Bnf!JFQpQ5A#ch3z;v(x`aeDay#@MWK`>?otgV_sJozbij^%$ z9gr6$C^J7`mj!E0pOgOM-DAutkvuSzr_;BK=)ag;Cr7{H+&` z@Hn(cg|&V^app-1Zcy_5w9~z-%M$(%>51!)Gf;)9`H{rlWY=|YZAWD1cmgbsKUe@_ zVel{xbRHCh+E~H+q&3)b#vRF(@@q?E)Mb%Ok&`rqCD@y9rNatg@XujI;xq}zu2FHG zi6LxYfSOCezA|mut)rcLRWb)$!%lqu)TuJ>8C21v6Rb7hgovi~Jv8a6tG5<|evmEp z0T^L`V~gbpVvGep3SLQn9zb?%dZK1)4s;>@oC4Hsu(~ z^YgwyNM8V+O?#j?r7yQkHNREYa~-&!k(wQ&|LjTWs}yX7h=DA3y>N+Me4gGrL?}mt zzgfQ+I^(Vx7%I{|S77744tXo5w!)Yc=eF123{O95%12HUwrIfBf&DE#5Mt}vgO!}X z@sy?uSlmf{t3OH+RjjGfpSPC5iv?-A$b-M2sKku5v(RE!d_4W-2h(>RTpEqB0~@JXnx*PFsfTxy5th143NJp zKawchIhiPi{Q#twdBK#`?gA9OQE1Vn$_9&roR{tPbzY|8{dFlvtu|{0EdGKD>`SkK zGISwaE5N_L^HDzk43Yp7dd&c96NWd7V^Yc@pc^Y+^uH-(;qZUpjSeb<=RgZJ58Owyy(_CpSKGkIjJvuZNYxwPmFwpu6eJ8!0REUjC5- zQw06G{X9e#puN8F+o4$9+CzEB_fLUUZ?cv_oxcD{gkH3x5+n9`@K{jO7UY+a4nq6u z@i4D$&#-S>A8C%%fAjUdDm^H1J;2u&b_v`X%IwcedfII*SO>sG)&l1g(#FtC~ib9VgUc(>ufuSJT>=CO@u{aANY z&?v#)X^qv7cMe@Hz&(i9?D-e1{-nujb0jy2;nSi-%o_~qL|C;Bgr&@>Rl+#>OY3yW zLCT{6#Y*I(6->S1t_v11p|pxjGlf41FE=p0^CfEKHex=rCa?JvN52GL-xazLp4w&q zIgVDQQ7fj1p^j$2ze}4pGqd|)D0B_ci;wXWr8hBv5t?Y1Hea4W#M3`5C3_=ya$RI1 z&TvGI`IrrN-_o&<>)QyPE@StPUk9y+kA}HX@50x15yheS78IiNj&Q(RUO*P}2`VD2 zNF5#}8{aqV)FeWH_ZjdWA7|KQfXb4i_*xdie%o+)9;$IcV;K)1mM}-B*~T{DDX13s zOte^pdz6X#jD-T1>_q-VSZ4?uJppSGsD)0pk05v)TuEU6>m`yWOmX7KCS7s(TpQ_s z;LYCah)1lG3|rEKg43Yb$-G*h%1kc1`R12a^trQ8#`%}tV;D9xO~+E4$i(MlxhcwH z9~J0xQ6?+xZxFnAJl>05=GJX~IQ)@ofoFa}C~@}~QUF`9$oKau(YK&QZn`6%0S`uk56L;nsGNEW?@ZeE1#}{Z6kR z$*m{MV6U0MM#(#Dgo(@T#bfC~zQPY?Xgpaqrnw)*XBflvqJ^vuK&%yBm~i1P!%KH` z(3*di8Id;Suuxyv41o6n5Si#o@80#8LARHTr3XwL9GQeSzNDXfL^YkBkm;bwF!}91 zqwDw}Mvc&DB6_zSBlt1Q*l&fi)ki!LrVe*E>AK=voY1{r0>OqbY+f?N-2UH-);V}v zv5eKG2({Zy`=U4b4L@N3J5y*)O=O?#7<}}eybiJ^FHMDs@Z8XIdUG+Iq34$|ikkEh z+00sO8EUhH11?28pZJzg9I}IVi9cUP82$dKKo#;lvhiin3Ajk=fG7OLsPGDo zYPeEB1y=)C_gR~S+>}E9IpHNkp<0k&LkiQ(}_Gola?k;WSuyaZkvPl=LZ;?|O{Z{iJ zmDCY);0=p8vr)Vosoop>0bTAK#?E*NUEm*3Y$qHr^L5aOZ+clbtt@u&NI2~{%hdZ- z9UB|(2x6k%7$!A#1Eb-^+sP4oTG*O6lHy$#b7*DLTCSOD{iy(b!7|dDgQ5)7c*};E zvpB`2$mNDohm+yALqgz9sX!d)XOLlwke*I7FP=6wev^Rz%%pYvlibe)KMl*o1BZvU!B8f zo!(cZNUYrq@5-6u7O^*+4G2E-$(&eYq{%|PibH(}FH16sXH+NlEjT=MyLT}Yg*+n9 zEg|j_mFTN#!n>o8CxxS4%&og-qzh`p8@agGgjT0ajyTG&HAOfgaGwn!-b+YSo*b6VXFfL>sZ25)U!6s~Nn|jfecSN;B1hmGK4ihI6%upE6CGA-V>W}t zUGlCs$W_JA68k$+ykaKlaTbOL=A=O~mwd6r-!h8)uHlL|`C-C-##w*8p;|4B7DGrt zW9}s5SqUxZHYE?NQ)*H|JAWOv1a=9#IBCk^RGPooCsjtFh56ocXc%n=tj6&dFfY`& z3qHq)QUA$bO)`mtZMs&H_UnUXuShace>F#vUnn@x(bB!J^;i9*k!d~Y7U!&(! zRT6jPH>1Aggurnw0RlMcaWlj)=Hf%5xRNoeKi(HlCp4F07io7F0$K%E%m=+|NU51i z5s9`V=qM7|Zdb4K>Xjw(;)%dhj9k5p~uAEPx##en1=zKw<0 zoQw~45~VH9p9;`tJ1z00RD@m9msWL!ZicfV9S9)aGp=q)H~gDy+z`^Ji?DI<{!G73 z|J0wllmfZSj74s6e{wsesGE8f5>ApsjPF@U-rYt1s6-xtye#~Q{B|hylatLb`3+4K zCQkL+HtZxXnL!n+p@c_$xfF}@vK=EYxdtS2X3xYOr-QVEX?uV3OO&%7F`wBC;0MMq zvCjiy5X>3ke3Ere$bqO&h#5@2Yq^NiNKKIYjXf?(82yFAX+P6{+t<;N(c|#I1$-)x zd*|~Y{Ym232xquZk=a#YseHwq>NC>2bPP^}F?9Tca=1P>tX92_HaFk_ViCSOio391 zidKQxEB!hI>rbqzmJpO&_jP}Pr9s9xp)(n-m7YPn*`URi0g|HxkH3)lv75p9AzP%H z!YnoU0CFC}ta3CMDK;++56jEL-4^0*cVP^h4j*2n#w0Rp5GwFj%VS03*ZZU{k#w3N z8Sk6!eUf^LyOzAsg1#IEMoF0FkaE7jX}rYm!wT}UUU<$u8*mgs4(~9gA@s~mhs0K= z+u7#}PK~?hdm8;jC(p)vkgD`Dd-u)k4_v?QF849pQxy)ala1?KjiM4y#;b*_ez@hF zAn=QBG%f}0aJ?fx2=+@fF@dV0n1l0N!Q6HmTXi~d2zh`Ea7YrfwA2xX@=Xr}irPPU zT@8ifBd8fQA&uo5VA3`3#@rk)`e5n^-J@o56VEOPg9Uq)&oK2Pk3v6hW~J9{`5Ri{ zOAnwBu$NhAAH8@}hkEJ40SL1rAXJ#}W6>-1sy^-4HfNZJ4wHSn`6Tt;9`1!X`4ZODx&ruB)t0Ob^Mcr=rnHyyV3X}%GxeVhHVlV-Z+sWK{?`;=EVt3&+>J5nQaH!Pn$2X?7l1DKaaN z2z&027v#!O{cs;!qIfhDCz?FjX%5aa%GQsW>s#3l(1R7f0wYyN6I_QiFst=oLiWi% zt3_?}Gb-e5LHwuqfg56AguYi!d+qb0N9rdp0nNmbSF+{SePc;wF~zg#9*_efEz|>t zN`8Bwyi7K=gDC3YHetCIU%pQ(s^02(Xd8YkzB)%uHlNmeqOumkjt2_}!W+-de zmDa1aL&%!gIHuw47jrOeIRVPz8;{i!+$_ROzBJ7y?kZqHrQe5rduezkKBUq9QWM`6 z$WI{UZz(Xfqmv`z6X|yXaXzYf7qoXli~0@G5ut_{6|ASeB`o9?4U6PrikaOea}Fu6*iXKeP#ld3ad^^R<@Wf0h*PlR*# zF^n>50dQ9?eUZ-M^pE*Uv@W6bu9KGltAc z)q$|w-hd$Fa1@|Y{YqV^gh9mjXTo2gXG-G5~wRAGK2ABNKqT(Ohm3 z{Xw?2ZS6e7^u8r&uoszZjSdicpwfvZcHyC+2p+Yovl+f~qta10rn#E-NPm!!{-5y* z9)XUQppKZw)E<_&XiE$Ti~?u@ax0#Y9pMaJGlVp~WDdmYAy{Ada`P1Sh5(AOab1o5 zJLw*hIhS_P7)IEqEujjfVR-#QuI81xO##xVt6})5t0bRFu}OFX(jj%>Ec!sto@dqx zMWz&h1LmQ|JqNCLxXX1UJ!et-X9;4rGYUS~#uM;2u6J*;zyctenc6{*#bcHTSV}*w zhp+}UBOnR%t~z_#X&Ubc_jq*O1n)78W;~4>Q%5*RzRHY5;*OJ};gZbssUuWx1Y6%` zlWsRjEFJxF^Hx;TeuNuB%IF1KnA-7=fPw{pQ*zL^63)23{)%@d+OhDW_ZL7N)Qn4* zsg{xv@dcV=+#SOHXS$)pcL$9Jp#h*Il6@A^u<%GWJ)i8&WKD>9B(@gK{$F~ufyCAQ zPc-Gk={j)lX4uC<^}sp?!O>B~!4+P#$KFNH_w-+g?P;rQ! zemB}P;a=F=*VlQ@?G$XK?}QrF^>8~bmv$=p@KDtGGrOAgBOAZZ-9j8PnD6-A5d+qZ z@9P6uAHRQdo~0=n+Wy_;^Q^+}#og;Pyj17kTb+7j+s}pa2eh}&@>^+st^9C&<*y<` zz^1Ia=(RwWO5nhVmkTu;(Zrr>w!!8;<>URYr72Dl{F|9a(=tNZ!TRrOCIZ`_@zuUs zI|nz3jx0NbL5pj^-Uf=Tw#Z;)Eb9p=2JYl?w-SXMOU|Wtwg<)N?s`MJYr7KffzC*W zw6;@!jK0@%O+AMtSXJ23LWd0m|Dcr_sG~nVz0!2M=Nhmzr5$^7exXAgP>rwe<9c*t zv?&%U=Nu4haG=v0!sMMpMni)c;5X9`C%)&}2A})m%7sPD6j@@bWLNZZ1-Gm{!|u|a zhk_@X=(HYc(m`%ru5Q)#%Wfn7wqD?lSTuEP5{g!!5+qgb6%7Gn}|X zafjE?$!#@s1V;GR0$IPtfp+G%r^EBmxG`03Tdu!b1za#^$XAo>01k1N)3w&nf9Mgr zCyW@~s#<940%=Dm4FhuNS%+uK-#cA1SMNq(?v zgKV&SkK{O=oAN|HA%?c+R87zYlT3%SjO(N>V4`5STE!T{(vVCQh7ICnPY@fh#xIH^ z)halpmX6s8KKJQix}zm@k{Nmd=r%MC2~#?;je+K39@&xd1b*7Qaw|C)Y#v;dw}d8~ z=gDa3T}DIuf$jUHJClMlV~^0FK-hgErL$>5ZxJ6e+7Q!SoapsExEn65%QY<)6f`s# zD`+qxtu!QT(FD>i_#xTcrB@ghzi}&3E>tdzXQs#%xLnz9iZlivJ=mInRPWjNiQo@+ zu$Lw{u2?c}XavzlVqWP#U?UYnPmmbBl%Xh93Avaly?oH?`#+|aezwRpqaz;FDw@`0 zzT_8c=WBbCCulPNa6&woO*EY&@K1fQuCSBtjF`yv-pa<*x`r)CUCd~$@{Gwkd>Pcm z{tS-&@}_7gij zWQs{^u3aelHm>A zSAw1lMGHgKeZ>;IzY(_WdC1w$+#lQfqVZ8(!vmNFe>y3o;(|{~HWI7f3bfp0Hjc_? z6Ra;FkA*uHo-p5q2kYHG9^cSy^+A;ACCF)s*R9AQ`tE{{ey?7*sBlAYR`O(G9AIsO zPgzm_8}HrtP3=c@t{J-!YRQoweCOso-1NZs^DXt(lt#M)8FxzvRyPc1H&m=0|0~w* z#r^v-8XOR&h?8|WUh{#K+RNRQw@y=U5slPuS{*MIab+1}?1XXyi1-$o(gku#az5t4 zen$Hq3hrizHAoo_x6CHQEt^)GW)xyDyJytaVP8ITlB`+j|Z}wEYN^7f7t`>; z%;1+f)^#~a|xliAF+k-z4Apj)`IV{eloTrbOS+3t< zAqt$2iyHH`X@EC*SHcx*_DiRh6$Z(SKNjI{MXZsJDzt)n zdWYg;qt6f0_Z=w>u2YRBq!3D@eecg!HmtvNTz)x6*k}Y>YntT!sii#&ORc7 zX!Xh5g4YOp`xTod4}jO8_5viu?ZOm_zu1t`@Rd}MZY(i1%Y`RQ=PnmJ*ib z3*SmEr$v?;`ZQ;ix1^0EDP94}>$f(oRzgs3o<=#07@o3xv zah`SXhM(QOn<{-DpwaNH;qJ=I%quUkgKv9BI(U|kYVfGpT&MXr?1u&)D|Zw8mB4*l z_aJrVN^JL&q_cm#27{Ax`J%`EBCY#|ekn8uC_XI_HRcj5CB)wpmpVr`Kje!auOZ{I z&~qV**U|jNE7ul2NEczf9Zoz5{&Yv)0i1nA){_1-5PLx0&0UK?FEZ0xUyWUO7q@m2 z%TY(BE26+2kezHi>3SL`Sb>x!iPtL^UT^okYH6RoZ4$8+P=ABZU}J?e!(snIa*Rab z7`2LP;sZmk$_HBke#<@Bx^YCO6|*Y;Luy0!Y@{>JE>>t%Hn&#sis5eav&FB_vcsZF8ep@rpfmUZbJ_(a3t zIz`|*_b%x1)n@&~XLOwiHk@TbOhd+E^>Y!15OQ2UMO|})-t66qYYdNB&z+>U(5ot8 z1-SM%jH>ibBO#8&V9y}+9JU}~?ccgC6Z4h0tKf@#Od`@azxM(8;zC0&#zTbr_KK8%g8v3-Defmt++V5CLCOrLeSB@7T z3K@wFtt}GE8T*ho*R#ejI36Z?UyadG%|8%N2yb#Jp3NccAU(|%#5AF^DnP>wWG;L+ zGE^Q+G5+lA@|}(Cj=6+MA)|`?$OhYV_3sF-BpE^tJ=g;9{C!p}glLg}l`~9Lv3f{_ zB7}g!6o2#U?^YDXw@=Ti*#^=x72Yc!8jzvrhk~H>e92#RJRcbvclWg>g?x$d6y^R* zfy;tQnr%JK!Oinkc+XZa(+ds%DaT8^SfOx5j!T5nw{g*sUvzdE(-$O=gHdEe?E6NVuk8+5a5_Kmd_J;tyB{N1niQ+euI9>pB zsU6w~I7GFS)I@P^Px1@3YMb|;k~k-?zLS^F(h>irVK}S=Xu+G?H8|1^br$Y@5>KC< z9$si{MuN{;nqylxHY(93i`ANd{*FO{D`6)>=iv=ft zh?z1^ve;N|1=qlByHN<-P7tTP%zw854cUnkIrd@?Br?0MhNR{n^s#Gj>|;h zafzuPDn-~^JH!?Y-Sr<3e2h`lFx>t~hA^rm8Kw7+dZBH!2WvWaa)n#Tikx#xBeu{K z6Q1pWvep&`JgKn#?y+x+S_04ZY8PG?n>=prvs{;`FQVo=3A}=|!6daw;0#6*(eym= z>Ihu94+=f>mrJmqbdT7e?pNhPm*9%_=|prZ3ETX0zXn^i^@@vsK4AfY2nS-y4Z(hj z!kmeaPO_l_-0|P9?+?kzn%aIMn6v2H>cSIFKhalA78e@&EFbJ|5hv_Aa}IFA-z6~d z4fmgnqF(m*(orE`a*wr5I%32G#W7^?wUNB=BlM@7u@lGGGo=GiK%itQM8WhRmyCwT z;(^Prb+|lES~2RptaXk#G5);VJ3Gk{IOKjLMy9)SP1@wY6%BxN`HdqV zNvP+m&%>jWOmLY539yj#sU}3HK$RQC^`fGBRY}D!(I_+^mJy7+Ovn12dwO5Bk2QXX zlKFauU8l7UL*sYC!}~S@>lwuPr)%LVlnvJ+-5j8~Xf5Cdm=TQB)l=(Ru5cP6+R0Rq zg>VhEt}+iz|Ukcpv&p|(C=nA zY09KsUZ}}izmq3Twqs~wNs9m~f>kkqAf$Hx%bLiXh{6DP$M0g~K!#EuYB{>M`w)tK zoEeV?kt!y(XU4}FUIX`ghDZ;*u6^r35})Q8bqPcolEU4QG@-gUPA=(^=V+E5S4I*t zaw80gQy~tk?TvsoG=+RzSqzaP{bu4SkDziu;lk?!Lq>O~$(nJMAxLfe@7zn5?if1N zwcqrt7OEMLusCIN+JEp^eLWNO_3Q}6tODfS;S45o$!4Oa@k$pG{T!fzX3)fBDfH@p zg#wiAFa*|$mARnwK0AJ=$1btSS-LDePk!IF!n&}@zw}g2MmVZE>;Gh+=qxXQry20r zdAJozk9VA9O)ksnENWyHCMsOm9Zc__L4@!XAUVYA;A&CvdSv3io`<9_obi&)cGxD^ zJ|#+~hh2e>>0~Q6d*SDZPBH9M`YThhX;Ky5pI>tk^*5I73|_xtWh^LdEfr~ISLpdC zp3Nn%dm?=|{6{PAVZFr-lh<7f3>cJ26wGY!n;fHX1V*8DZ`f^}b8=d(W&406RNV){ z_|3|Uz*>M)1a60CVr9+XDaf5!C9p4j0mPrB~=ZRF|*!Sz%3YfMo{@&q#^w!~YQ+Q7et+gqdNEg{^hNsFlZH z&J7GFegq5rCk>Yg*-FWumdCGWn^*%9!`i36z^Oo)E8{|j-b=QHr}DTqPOcIbE7tYc zlhzNW`@ItGZ+s$c54qM>fo1CHZ(l%-_xhJ~@@pNom3KY%Y zmvkDKyK^&N3GeLom|+b|rZsFS;Ca8iKV39}eFb$71HAt~I@09*#`dwyEXaFbzJ^Ss z*K(j#8E;#9Z$ZMuka&R2y)7f8mP%Avc}<# zq4I$HJ9bGc12~B^0~Qm%t^%RBdTE{|GpOA?SitOMbFw(ge^}KS+`ngvZ`@rNq$fIT zYtLqc8k>qOzWA+Hql9YBgY7S(5@W${?x>_AwG?f*8~kllj-v5a*KyM%5i;-oqPxB8 zI2#pW)8gdycdq)Q2)32{LN=>8rs0E_xyS8KulZYF|9^Iqj3{Hkd8B=X*_Y}H|K?4q zXP~a0BtL=i3W;&6j)40?H>G!julm!>FpCra?um={I3WWF z88i9xH`+rbzRQ9xg0Ub=cjcRenMqf2MVO^KUW{2*qw^R9=r|?uxW;mXGGE)h@=1T5{(pt|L{F;1)+B|(wQ*e5Olxz< zTkpUCNYg@J8Cz^+cox@nMoU`Txqpw4Kpv7a^*;aW7}6#q%tFk5Eny&E>898`bEN{w z>q?If1uBDImlWY(fcrV2}{$>teXfm;3kpC@A;SGN<9fTdes0hk(c_Ev(6{PP= z3Jc@@>#l(cnAc?JB5h@we?0r#gCU7;KbP*}#FHSg+*xsiJTevDNV?%m@VI1;nXcl9vA!?@(b-s6h7f z>DlJ~T6;QG>862qT<=d5S)Q55!x}|{=}RTT$fAKhflG(}*QHUab~c4g^=7t(S2o7) zSA@JAE#42@u*WElll}Z2el&UIL9Yjkz#T+m%Zyw)^KX8%{+R`C0>DS?i&)X~aH}IL z4=+J%NuOB9_Ix2Tob0B1VC(8Q*(`fdNT);Lb)V>=^_ZBXjT0oG8<~Z^0VFXF(*!#l zA>6NFg|ZxEZrhf0zVVrodE72!-vGH?3Elyo_xtnUitb?4+8L`uv#v}&rqq1 z(a~>X7>WiFgqSc&d*NYGfiR>k#5rG9vtCrlG~k)Vo#QGTAw^2K>2Bl#=E3Z@ii$Rq zc@aU0>~~03Kt*O4!+d((xc`M1siTrq9|R6*CcRgpHo@2LLA^M}_@>Gm;2fopzXby7 zrDr#aLSwj@vx3waygQZI<()7DR8O!2p}f19U*KV5MDDs z{hu79a0aygf_8IDL00|m7>Y-Y&;d%C)8SINQejpSZr=#{&SrD5so0?ii%W$sG|zXiNNca}yth&u#;#>MvE)=vy^H$JXlQh{ zaZZfNcR%J7b0)J(Lbl_n0GMItpIjB%b&eGMXUa@2N;D&Fr^)%mCU zXc{&yM$6j1T?&lg6?z^e7mS*zm6Hwo7l1E zKY{_i5|E2$Njosg9j)*1BsJf-UJZ8?qoMMx2SYVhN$?rxoWIZqkh{U9k3wIcF+znk z!G^Dl40C%QcMKjOSnl$YR^7CO5@y)zf_-GiyAST;@E}&v@XV+G57xp<(HY)imRlpz zjtH_=*8=J%+fdPO>=;<&*`l}IDNq}rk9GtKAC3V?YJS%qRBB%uh6ZWpYzTSZKl>L` zlx}|XiR^vDB5nVB;{{AWx79j5>?nW=!U2NP%?baPO6zS@!PY=w|{YJj?F_<*VJ*!U%MQtj7t2x z4qnPF<1Q>LK2^H)hZpWFe`4D<9cbJ+26q?yr^$l`KXJh43bS8Dt|-w(EE)xSs;(y+Pja^i$1FA#qW@H|Lim-mF50RAL=v5oNWa|;h<9cNOP9(*Y=z4Id z;3GMQ#w~Vvot}`Rj8rz@KrV*&v{B9Og6tXJzWWC&>nN#j+=q;R^x_ij5EV$;eS+XH z?k>_EqE^%Wu7TN!!cSd~y7bdT{$TLtE0JbZKR*0vd_j#5*^H#OgQbx^ zAA{A|4ogxK3oamY9Hirz>!g9bRGX4c|C#P5K&($rHwPIr(ThsB7p}2l&bC@w)-l-K zPspJ9*B*PqzyoK?O|yWDahc(ciTN#%x0f4JnldpgZFImdRnIw^hJ) z7AvuqH;Zms!l>DLA42Icewrz)doO6SPz_ZM!E@n}S|}hfCj`ErKO{%lp&&QrIo}iL zJdO}P<0VBVE{YBYt8w|{S|7;1%c4f*;%{tW)rm!_R2T#a>Q-7=LujvR7?P#eiK&$y zg!!oDu{nL1=m0HOWv+=?Zzu5qR|-lzKmlMM`d(P)vh7XWLsTS#=JtEW48Xt8uXPdr zdz@mVAn$4oKI^@ufPZDF489bXwse=C>1xKDlZ=jqhZ^&=Tc|z52%MzPj)cg<$A3mM z&PCwDvpdFvx&T5Uj+d1x$uWbn4E4}kZC5O%lt3NhJhmkLBSbfX0qdbU> zUYmbuijDjtV#xGVdWmyMivPxJypqS<1V&Le=knu`#?IXZ%~Tbt{+qRd5|vLOwnd3F zG)sh){!w8G#K?HV=Oo4+@M%^r>Dur<@RvSuFB|?9cbS4wKg4=5L|= zz96p|LZ9}?nz?T(Upxq=xn`CvXjZOdauXdB+9rY$`pXAAr+83>+TJG8y1UB%`*TmF zD~7v#%_CV*RwZY#Bf>MbMddHz2fNyS4D6vAJ8WU*_+Mk1xn@oFA#k8%k8pu;ZGb&R z3*U7b!2{#bz|B25=KgjSR(hGBhc>D`E@cnL$l_T5^2c8KC_yVL<#kGn?8j7mS(~7m zJWj*eHn_dwI7K=z8PP6I)fg?=be;KZy$7hyZ#1*z@6kUPj9qgh3=FjaM6gnqjEtA7 z`{^^br~QMTSQ;+ic>>dKt+uKjsry;#$>ELKxZ+x(=fj1%mN*R|@Y(CZfcP##^dQJm9*L_p&Cg8G10eYF_r)?J>thFhz4YWROba5~Jxrv% zUyOr`F=qkpH=7bn$GB15Pmv7gaTL2_&!Ii{DdIML{knq!WINb1X&aeUn=uI#>Vta$ zR`a!9+~DXCaW%wB z>MSmt5UsaW@ROdH7-T2p*{+W(rDRt;7rbQI_T$pyfhc58kWc=1S!#x9rt?+y8@{K( zG80B$m(wCoaDteHe1dHrUFwpc z>Agwi;#sJv>vBkHCIWd&3a4NyXy{6da{@QRucSp%hUtRZ8tOJ4P-TBr^qYj7$0-x z4a#YST!J}@+h>kYOWI7KV%cRHZK1=`ifbPrKRN_{)6+!>@RtxLjk9ec*i)G^?SUAaU^=b52y+tN>@5DEQHJ z%ToVORU6U)b+H4C2NBr6_zIl=Ei3o69vX|0^>^M0?87a4n#0E2d)$^y7&YM@b5Q_L9^SM zvRq@2?EZcAFM*6};YCXH+(wK35KJYj=@sD6k$G=|P>c%&m{qZU;5#s`$?l+63N58# zPZ%lvUf52gw>H}r<=?ol^?zN+(}7|6v^POy6NtuU8^SZl6o~Py=Hr)C&}ylrU(`?N z6+Cr%;X{Hpox76gQQ+&C8Uy;J;`w-sJo7i#TD^pog!c+8)RN{gZ!OU@`g?MtMPNUYh z>PHhLwUFzP<2{))*PQ(@lGTt#{IqgnY>)LvJu&vzl1!futE;gs@@zY5o=jF#O(%}O zhXhMLi8z?1N9m#mv1aGbi4mp2zgPTbvL=ht&kgV^Rrx=6FuJ1{F#<5gk7`Qr$u;Z# zjF$ZyG8MA4wWyr?u7pJ8PTL=UZd{ov()$sqkiETC8QGGP;c9f_{^RM!gsN{*8^!b@ zmrjEp+i0+hgMFV7`^qCFpoXnx7?z729P?6FPB^!3Z7XaaO=k?}d2%nX!!;L)pQKn? z55g6pUJ{M6^a-UIy9b>!jcVINYZaeUURY&?4NyS%7}WEg&V2I;76`DzGM3S$S< z+I5GF2r+gh*TPs=7nY)@B?PUU!S?v_tA<y z+I}SC7R~(6D{euduHF6=P~$jm?R2qhEd`L;iL9%z*OUD4pgxh)v23Ha!D1h@X0BD_ zt?CzGW18&QFAT*e%Q>VI`&MAnrL|%Iq76H|;Mgks>h3>%GV{9q;ZMYYg7qyGU;H3J zn`sm2ISdh)qpg_SlUMqMqW3QJb+n`YthseI`qSsm&O*&*;iV2!RubT?{)W znkR+eH!W|sW1zZOd`7>ezz*ODB>N#oETNvm_`+Hi-3CRZNYCw@@&;*iEghvUd<1J# zR`AH(p*e&Y`H?Risbc6#y=AF;^wTFGhk;~+lCP?#z8vdAF9m9FE|e z`mCP3UWj(%+eg+z+{@gHIG{o)iRs8G|RVx37zJkirKUvNh9F>GdKLm0G!T|HJTOR)ZelNaI?O zNRvnID7Lv$Pr$`emkn#LYf6oa4Srjp5j8f>HmlsDYtG5sqi1^|CdxVR653{DK66o@ zW(k_sJv!yH1kD5&k71VQu+DUh3H*0v${fj~rYhQ4&%+Kcq##r)m+a#f-a8- zl1($)+cfyfC`45XxPR+0->Mr41^`Zq0mJxIn|7==rxACnZ`JRlCF`8?GganLc8!sZ zXK8)rsbq@rF+nUi8uMx84#bObb(s*jHGC%1MliX6hF%KZ`e@W6904*Jln=@?sQuTf zG_<$3ZF4g9R=UR;NJ|j&IS*0XYsWnY$t#w4+vzp{EH0&gP%gKGjhil$KP^ z<36U!Kru(}eo_t0Et2|Gh7<_+;L(bwuv6_#INe-G;5UN0@vn?v@mGyHR*Q3qy90^` zr3a6V9LejIQQVF^V0ffNUo0H?sBmCx#(35@IFAB6pxqz-CQ%_ub6jo0B(&5^Xm-Z+6R!A!Ana$x&O9* z>?SQt`6)RU&89!Q7D-lS;gL)KC?AjTk8)#Gp}QmSS){di1fzv(t5vj`Z2R%fg9n9w zQmpItNrE;6hSnLomUG95nOsoi-(;z-xn}KKYs-&f+5aqi86VVXnlgH{;$=MBcPvuB zNU_AYs3<^3tv0o~d+4qYrV~93XR|X10@ZssTNO`$KCo$6j#>&H02lW|%oP{F^se%6 zaw+62tGaURBo~R&hu326g}9hON8n4EL05O$%z<&P29goC^Kdo(0@OnQu98oY?2_Ff z>eDSjW4}iq{b*2oeeH$3RhV_20RK&g0KF0^P?K9K;tNudCr1+Lw{vOjKJneLP*-t# zVrMLar{s_O6HbhAOJ9OVi=KA3jRmZ(`C8lUX}12MIC@6tx${g3ctOgp!R;9M&BrMT z=Y?{8MV<$)3h!5cdZ@0>2VRXd^Y87ZbR&R^F3X5zwx{^g^C7qZaPe1knN;DodRQV@ zS~{Ibe-F6J$v)&lhg@3KIm@-nJJWFCzyngjkX2pO3+Gcxt05<5`)kEnhG6$E*9e(; z#!asjp2W^rCiBqwWsuFcbb>N3cv0PHW@592gI1&)WnfuJ!VW1Mf(94>Z0f|hAIblp z{8*rkowS6S_q_fh*Wd&5G@jBQ1wojaILj=rB|Gyp;sP&rndk4n0Tj1}n0DM5e>;zB zv=8Ci6PFZHb8#~gh3jOq^=1U_n>PKLG5iV1c+Bdd&b0&;DeaA>w0Q1!UCyr|h*x`O ziw@ekKOQhn;Vi&*lA4J@2K+3@5`>-lcxKECb==MZ&2m=~| zj3%aZ3OfeKigh&wNvJ=T-pD~lMLx5Dx^_@XzCQCIkbo@NbtS80rO?ra0YWADHH)(Q zX(u}QMq?pdv+Z-Eg$hn}R}H*!ykfW_X$ggPF2it>teJ5mOHdv*4Ax+do^ zfFWm>qYWiCFycmDQ)7eBK!i^m+gFB6)0TFEtAs-VjQU%qcg7 zI!(#lwZ|0zegPamC=f9vZqq~dA*8zP%K!egv(tQx6Ge70PZr*h{%btYwRpkk2zLKx zUU1N3b~3H3p77M+hPy({4znQlT!U4t?VrJdv+it61f+h*4ykv=uLN&Z+8fR-Ds#I= z&ngTn>wKlD5J|6%Yxmr2hGKnl=nWM2AP=6ZWq-w&f=NTW4E_=%j37G*)kVT4=b61I z3gw@pJ^jQqlZ$7b|0!rB`-?IVNC2NCt*-q=z+{6UXP3%RU2EO#gYCA9rO=M(Px}TN zb?Da$jggEt{o(EW6G`IP2WQ$?{vIgw#0$mkN2>ODcR`ATl^773L?*57xuJ?tWbm{- z+154Kk-d!DpgNkEK<11xN08S}dR)k`@vQCD%b;H^zHkdSzaHl{rjQ|ctH#&U%gaQW z4JECjg%z90QER%&{{2Z1ard8MC%Gu3s^xq)G+#Wxj%1xb0r-?FoMI>uZ5md<>_I&U zy7@+uaYK9vlJP*a;VU&2czWbacUnBLaa=HU$-E*%@RB}234(<9P|tRx;F+I0PId0? zN_J=*T#+P{WZmw5{n8C;YGFh>12R1q+;A20w`qJUGnOX#AltL*B5>zSk(#4*}`I6DhCHpaQ6?We`dMxw2LH9{>pT zaQXuPdicODJsP;08-n(<6ihL%eVdx8}`;NS?Dg784C{i9X z3Pjg-TZ4|(juH%{I~O(J2V^-U=w;-6=AqPjjBz_u(W3>}&$sA_X<@7U+4LYJb8;~t zLjHt`*xq&_iDLV@mzg(vWGpa?ZxZz4pEhLXf#Q=PK4ev5@or_#Q7P={uc#&N&Q})R z>(0cfONcwL)H0jli4RvkshHB~o42Z(%6So$p9}z5Cgu+m-l=3A+b2mOkpByXxst>_ zpf+}pp3jB2e*eVne^NF}@aYh`&vd@P4|*J)9OZ~0()yT@Px*=_3^yH^v|-YR$b_n7 zg;wz@P?MC#L^FCfnMNy-;0gm z&7ini#f5v!5SK|8#0z{zf4Be@U-;$-k@iO`0gBcA6PBP!UU?*Yf915Ruuzt1X4b`H z%qrP~MZhDcZm{Ed%;IY>uhT=rzlM7Co)rcPOA!DrR?-mU+%A1;8jQbiPZWR=N20?c zH}@-7u?~QmB?!Q;VZZ$9Vj~!qP2AMNqEC;j9c(p;(+5Ax5`)3 z+WUjXV|zXTCHj=Sq+;5^ib{g4Uk?@jsV6tz(*|z&`I`)T_+5QGQnBSdBx9o6U)g13 z7qtm_O5}z1wYdGIs;5kZqaGvdxh4fgE#tjm3(mpGu_EuR<^_~1+0Id5qk4mq7f ze=zWY%9AZGx5QXV7)_jNkZV?8`AZ69&P3o^Uxf~9Se8NLhc=X}v_6)zA>Y#wr%dgg ztL&~Z5NKQAk$TqNqWF6!N2I9}Z!{;P;|WZo03wMK#iwC;2UO!-lK!U zP?d%egEbf_bA&Z~6Pgm&JH)s>U!+btJC7t7F&{#L*0qmpHaQ%Fjs)bJU1P6 z?F#0_P8?Q36m9Q}AVk&eadv`2a7Wap?clQfDXainM4rzcm+fhAe%8pn z3c1x$?s6jng5YB%)g@o|^O+tUq_GpViM?FyoEz9?_EHh$0cgc(7()gOWY3dc;RFB# zUm9M~)QE`kLiT5sQLAo7%IaG6kpLNQiqWG747j%9A*V9vq8r>ScuXV2LGG&3Yepfp zYE^aGvJmk_g60Tb6zLZ=;I{jT6&`Zbi5sLtaaA5(C%X`7@2R!z$K}?1;Hh8G8q;gE zBPlSJ__R>g$}7XrBI>m=gTAYL_2`g(8|ff~im$eVW{w9iI9Ln_Lu48-7=-k#Epnt0 zQhNSacOHNWqd8G*du7zv0F0?$C74wm^*V4COCNBCbaF2plXVqIhWIRu;;$s=2+WxmDYa2`{5-6<>OicCtL$fEcQcPlJP{s!@Ay{ zy~h%mY7}S3SR9(E)g@|H>0aq9IJy`L7gx zV1u?x;=E{@O|}(=*bpcYs(L&pV8z< z^O~O^SE~8C4z2+`t}Ug?e26pP*y(y^K7Ff z#spIWfTiy#h(^QgcK!WBf~t{g28@~|U-JfZ6p%oENgD42X%N$$54)`y+0N3z7#kCqdM$Ei0aFUR$j zH`zj~VoZsOgYFqHkkRUIvr*k^QejV5mzQIY8^|$YAVTGM?)v_qaXI7DDRx}uMXqIT zpuwrv_Esm~pPx{@U*vwKfq`qfE8-4!3X}n z381Cid5!GfcpdMXaiuK5B*HU{Gl(=er^x!NA$V0ocyS`>rat1FZGZp4cemkh{f&(B zhj&6?A9N!uLAM(d?Orw1^WR7imh(mRVkHSnpn#sAQtEFr3Ou7sKG_f{aHdgO{P!02 zic;&^L>8jj!{)cn>XwSp#{+ShaJzB?xSdrK+#6yJDqj+M{t03xRKm{}rkTDT)39~* zg!t;aB@c)!!=SD?*|R^P?E;Do^``8Bj2V}t;ig^Lfh5B0aEUePSe76lsN1H*-Z$zl?(DD2R~ zhi*uczFg?c(3B5wq5!kh_(>NiMIQtqvbN8sFji;ok}&XmBFzKZxdz$>A1~;I!Ni+& zlbz4aAfjfA1tRhKlP(BPdUKmUXgiC|(#)#RnyX|uN27Aswit7dn5mufkyARv-7k%= zD~$18ZR44j>YL$1;XBXoM~HxyTrkSgMIa4o7kobFYhxGZ%3WH1SNAcA(=hb{N^9Sd zt3o59b9P)+dvQj}@6dvu(drh*#q1bbxichie3sHH!K9m}L=(ExV^?$nUTZO0-Lj-o>BFhXf0LKrQ~%x3%-vn7 zDs;P$b>-!n2_vgi@^;$#8P&uRKh1+O>s^`~J48-+-$PctXKA zj_uKeCNSiKW?R5A#;~>G(KQzxNHF6D?WkXjx_4~x!2lpk?>h~5^|Y{YHH@unjhIK& zZ3uQLLv(>sdfbMW@Y+7DVb50Yuzt`$H-r)?Tp{0*JW(stvi+m`#~N}=N=n-Jq48ae zTm0+qf(~0h`9n{QlGY|{dDBiyhybWZm0*=I{%?GA<$md+|~(4Ui`D635DrH(+1?LHPE=)- z-qwsxyoh(+$hjkUUx;#iLXjRnhbQz(9TAvan!bDUOAzQGrGR>hzW}t;lwi45;rWsv z(i2o%x7Tl9@JJj=9vX3}ZzPrt0Db!ov^J9c-&$llKoVRp)Fc1MNe=3ia zm$3ebDZ0BuhJN)?&N&w&MfPinK-xy%nG%}S^m*rf>h8QU)TV=wiK3*Cp??c!(|Xhv zTV^ZXUc$^hl2y&ztP)(dK$#Q)W^m_F(Vgi4-XH#>WJc)eDM2(y71tL6S&Zqjd7V`8 z&6SP4JNYCB>byss?B<14SWQfy>VOdec&8ODHhT3J7>_v%03>w4dJR&rxxPrI+kjlC zkBu@v8_r6fgRuf=7JN{4MCJulzT#?G60#3U85S!$eEjV`SDs0Co^Y1UoL(^?bVFVL zsJb5r2y3z4LvB?5HzNY5hUE)GfxR=2AA(%_G>>bv-L|>4WUV81eN>47zTX60Gl$}k zEFgQS>9BR*pOw~()-gxofNYB8M=vIkGw~V zuD)B0VXNg5O$`D(1WPK=GqsZVT{7@+dHv~prbdPrGN|^y34hW1YB#bdZQUDNsVSJu zvtTYpx94CVJ#>-VkLIrfy4E{x6-KQfQVxuJeI%vf!cRmZQn1jNHP3vn8Wtk*Sq33~ z|Dq-6>ez8gg`bYw^Fpp`?9T#DF zgZE=4ygve)`cgen{jr{2yP=-U7I{ZD$3)^&oUfcCJ;2Z?$hTlQ@1Q&BLrQ^e)0&JvuO&^m`U3NCqHYA%s zR^4mU%ZXZW2H8hGZC^)RpX$CD8LHV8;qS7Vqe!rzRwDaW!X3V(@#4A=$L;y&t>zJA zG#aEVtr7Q{0S1q9kx~;?d?om_2@#vr8j!gSfTXJZvHy5HL{txzTE=LFdK9wF@~;T- zX~c)lLci3`cGw;N*RJ%_J?zLW7xvn;9|Og%pXgthxbN__OsvW+w)tNZngjqN5iX3_ zFN`v-9T`0RP|FZ;*=sxF#Nbp7*{@C#OgtRnmLR*a={~>TZAb=7Y3)R8S!hk-o-T&$ z61ZEEuQ&qBCfO0lQg)GfJQ}$IQICMJTW9mjmYkfh|hAC?DMCIIU8CW z7W&RW-eN!o$Q2oedWiy|kq5@-TS7nzH9>ZkH(~!`CWF%STELjanGW_}PPw?z12$vO zbpbe(!83pGs|rc=flujZsOJ)dp#Z%;i@LO^WPEssGBOHY6w z5=+Oq?J&ZWd}D0BuiFE|l{ukN(g&))%2NqyX%T;oLMi4nY)`Z3U zU~|;9`>c#!rSxC-=z`5K%YpShXGzm#RenZp^8Y&1=Ik$VwqPowQ2#7ebFUMS#+@Kq z_8{k0i7dqB5}$0bcHuE<{`E3KeleT3_7)smvq&^-t7x5b9?gAx16Wg6RTEmB=Ad`f z?HSUgG)&DK&DpykGKaM@ znVMyJowukVztBg08aA>SA9z}HgVn%UEZ5m@kYaK})yQ^-*ygdw=mE5^QFB#x4DnH# z^&b5g47ssGCbmG<_5{`@Ai7wFWHZ@8tE%$oD)&Cs>yfhGgM5MQ<6V`J9pheVh)y+V z=nGEI`Xv{`HTPF9oIO#Z`}LhmaowlaP5*3icx2e7mL<3$X%lIRVK_W?d>)y@V$3j% zzNa&0BQ(;Oy=S2@|5~BU^2?EkT*_Xgt9bk|^JX(P1-{j?cVB|zOjM};dT_}?dBe5) zPgzj=JmM~uN3fTu+Vf}E!C;5(*dEl;#SF$aZaV2&k%j=El%)lLnyni*29ysn@QpXo zSo`&LyO1o0uX|yNOT=5+pWq8okDrRIvVEUf#OM?y)?3S75jGc7%z*l6JCprw9;%>K zgrt!+otM5qAujSgH?D2^cm?ig-g}rq4EeaLmRTUnAj2SyEXqmbra;A$ZaYI}f3`4^ zRY&A9Z8=ZdXAJR@QijOZ0O;!OmcPdAkF%HP1&U5qS|C`w_MH~+)8MO*29BiU zb&u1zOxmpR|7LR4Npr-3P~V@_K{ zUb$|Z>e{^Oe_rtguGzXd^7YQ@o7BmwnM8R>@AHqoj^EUW~ zi)|Ar9$s&w1sno&44b_uN!1{Tjz&is#@44V{SJI85bpVa} zg&@e+sUYuy017qwj$>`(&};d}7tW=aHDz99f8rjCF>!r0l3sqz7#+C;F&Hr|v&_%) z+Jw2@mpH}c_;H0(YEHq4!x*Pl`sH0!{tF#fkXwf5Vw?e#fCuM~Q@+`aKo1%(0&fu{K1Seg6(m4tW9+L^&`&{?^_J5ILJkVA;byICCWOUU5+`y z|J44MEOitcOI#I3+O3rMs=u}_Rbc~BobO>)E=s#9j=e%u?C^#HTzJB9RQB%i21GZ) zqYXNSoRJbq-8)zNDKMQl5;xSf)Os|>F*15 zCw}B7aG~q<>fz6y;%-ghRjLao`+|#FoIW7=!)17ri(%C16qr+e!^rVXOat>)rY*-~^)LF@S_w)SSV{)=t zpTQ)bdW1)D9_hgNVQ8e;${j?Fl9+YyG^ii*Ul7<6a==Gaj|9cHaD0j@4d$aEYoAY< zHTg9?(82mvKF9K~e$a&3!Ungp=Yj+0{TgLa=}7NT6)F^p z$u_K{cuahd4iRqJeN13-LuSxYUMwF027i7YKylTM2rJg4* zJbKm2S*)gy2z%Zbn&!$Cp{ilkVx1q?XRC}ykB&T3-FCN?9}op!h@AEv^J#NJT$_kz zy{_SEp~l5oo-HijA6h(`+d<0Ze7iQtPmWsiIOU}0wPzg@i4*=uG>9h0b*cA0K7*LKEUnxK^B!JiOGmBC$o$e+K659vpxz^Dn?5Eden5 zD4gH`6a085q*cLST8Hk9JC=;A2 z%tr>5>A_UqamRHg;W?rrdc>1T=M4PVCo1~=C)Kg`xDppX-%+tymJhCADSz?NRh%m+ zu|&ts!DyFyAkv+uO0DVKWvPWtjo{-KY6C7defse%x53Khr1tiu5M&mF3-|4frV82E z&vvfCkr31-auq4?7wid36@bg?#0QV^=R{)Epq&@wN0J?2`}>ruFdjYPcx6?8?Apa7 z0so)S`X7SfyGa+OB1PW8H(uZ65Zp_u?mGGlcY~yM6kByQWc6pAc*?oXs0o!dr>4sc zmNXs!O@W+^HT0^hA<`~AkHyI25Jhg8IZ^)klBOC;u!a69;G#OZ?2Y!<2Ocn>hl&^q zV896P4JXdL%udyfC~}%kTSKrvUv3PJ+Q49s%OxY&RyDAg!zk!`d;;qY-gW zR83QKl1g60v%Q@9z=lWvdHjl+K}T9aW~6{3Zle`n$2=nuaV6okVFb@DdO$@lGc~Y_ z>jG)(dDqwh#WeM4Z8PRs%MGrG09$gFQ+g|N{soE1@y zOIfn7c%!;JXKEe^1B4`^(qgs=2As#6kGP`7HJH&`$X?~I^yK&35H~^$d&IE*H`Dq4 z`Xui^Qp6m{5z27Z+?(IWnEPvNb?G`3+Q#BJirJFw?{~bnS7UY65^FXekM&2{dl-pRTj5;OPG%3PUW216I`1 zd7;e0{|5^Fym!4~I(5AJoRfJjMTfBaWe_G1an@2?k?ZY`Uv&nP9WdTju>RvA72}qp!Dk ztmt}-8Sp?RJWyt8Uxo0Fjr|vxJTyi~Jm6wQ6km!lGHy=#g3j40y3%|&9V4c>JBfw7 zywJs%xAFT{X7@Mp(n2e*9tl?bXR_M$8eG7uHT0gRe=O+4=+=oictxad_iYB?RYp4U z74UL;)$*lV%~{u-5(7FC`!FhKyusKq)&6>~OkwJMbp+bLLeg%KF) zNKZnb1fC(&FjF+uuL}6B?aNZV37UwL@I%27Q5eNp66I zLyb3~7;`yR){kVD7U6ZBg~ttTb9)m%HgnOiG?S+9H6!r$l1jt~j5CjXNh6(KezC&u zRx|7jZ&#sE4xO%Js3n_6vPYes|9$l5Rn#xJ&0P5xXUK1VZULqA!9WtE8_fXJi$eV- zLm(%hpoZ412J)+aH&!0P5ANsmPJLhWK z%2Oo{RR5cUlcaa4B1n)ZTx;5?6GKQHYl>XrvpG>cVO(J5@KW>iRdYeSGMv#}_>3#Y z%gZjjk#M9%L?{ljYkl1n57dgwqs7E)<=WFvl{OT3J|5uK3%I?Sf4>>e;%zoTSaLa; zvW)q9=z00eMwaeLQBBvt%%(xqs6q3bd9YU^vuwDu^6116Q^%wZ$A^zD%URFc7ns3Y z!l%Fa6S~tiUBt3vGe~+=QB|(AfdkFE|8TYI?(FC|V+ZZPw9p;FHGNU_Ue{?h#QC&7 zaSHW<%jx35J{34vKoJ%F$_pn~a$+(l=lscKUk~&`u%&034vL&`t?oo*Z)e&CJhE_? zRttH@idSeBuaAd#+xgx#Ei8Y|TR!2zp?!p3d#UMVkc<6jj~#i+AD1u`)HS|m&oI0t%LfD! z!OwG|gLz}RF5EOvjavNRn9ioM3-RQT=3&%Qul2X{T@W2TUm6dqc)!(XEqhmX$4~@C zho>muOgrFkFPCW8VaJL*z~{SSD3wi2bgJOpR5Ez+yddlNRrv+KofC!pSwk@s0lwwq zn%DkFwKWp#5dxhr9bNgV?7d63tTKY`xoYYCGBn=J>74RaVcj)4Ga~##eJYeXGnDsp zc^J*h?>@jL#-HfyS1tQDELmxmWj9Kt-2BF>hXmJpYqtX zaFcgavOvZR#-v50@Qq!}H{9F^Cp|PeSl0v0)!ERX>#+&db0HS9nx(WMaN&ILT^DzE z{=wxJ3o?|Zt?0iX_y|k?B(UMBzEPLT;#Bnsovc67hXp42h2fP;$JKJMZOF6pl3#Eu z7R>%MoW*|Hh_1JrcZn1cYn&h64b;vmf(;K8t>h^o|u#d)s z9iTxZ{?k0yj>$@QN0yJWk%T)YD{bh|YM$fepJP@25i9e{L;iRS1>V1hm(iWZOyQJO z?)hx0v^V}r>lyaef(a)lrEO9vV&&b7oUqoo)G=C%2ui`BOAEO*S%ZyUZa z%yQebnuKn!|HPNh)L!5fgcqIcuLw)Ikf-F*^}fu*`4f2dD^Cm67#?0JZt+RC${8rH z`sl9h`BQJv_>vO6>GREVHTUSGwnrZu)7{B-rDr?XtvEg7s?YL|A7qeZ%CyR1@g1(- z%zRySg%;LiK!AJ=r&_*^9F}cp?%--MNL`#M)|t+SJLkVZXX_(A=a|;QvOsCqalY5; z=C85>{F04vM=I>5gu_p5KM}fxjQgd=3)jO8{M5*>jpoO`>RmJ^r!zlXn zy05PkA2124^hj0I7(FlftW<5I2~O{&CfVrw<4_Mmg~<^7B%rFO>A{tN^WgiDrBE;e zr%8R~H+I7>SCE4D9FszZ-C!>K)d=T^=|p-ES@T+56)u>qzI4b|r|XDgMJBp3Qb`|9 z{pD~PRMPb7)pQM~dkd4ijHZ+hD_9vr;~;h92vzN+CdkXjn&bD(!3mU%xLAt55F}mS z=Jc>fHuwu_(vgOf(`_f2u*ckVD#~7{YCU(0-O7zq6S+M;=+d5}xmKs41y_89?&!gh zs1x~w(Q3yrTQ!%W98=wnR;t%I|EFZD83RIJLgr0mTPo!Yd_2m6oI6K+l&D z{!51_M2+Y-PDO<$r*o9pQP1Y!IEE{9t`?2dr{^U*<`0X~aE&?*>{i3z3=-7Uv+K{` ztlRDkmQscN7NusjC|V7zmn?7DC}3?NJ!iWv#SRXOYOempYrmvw`(4hXMPqz~x&HX- zhQ4zSkla~~MpAp{4~)B`JVRLky!%mWFlus+6^0!ZWllwxWhFc-`IhWn`=1A?K?q9X z%GFS1K*Knl_ZBMW`u6lO;=y zX;GFMNfNG*OqOdW+bt6n(V&nmEhO1emTb3diKv8|J%sF1m$Lm{=c?!Xo4=l?Y3})) zbKcAA{dzCwqfGA(&GKZ;vE@}#6$#L*_I_e^gl9OlS7iHAY^n{8h~H&be`y zx8Yw661;y_yt2j5EF*=QZdtzLzU>m#vg-@Z*k)h;EOJU#Q~$+SO<#pzD<@4Dfz#qk}6^*x!^2$8QdK!0HNsb~n6Ms)d)#$)-f=$E@z z#ca+ccPMOuI}8H;)T{yi4B^$y>UR3G-9DPBxL{beSbs?US1mwM{BI-td8NNMY@*C` zW{XKcij#dcKiGbBFpS}r+>z3{rc$@a9~c zlSVAFDn{$^BahqI-_U1uzrDW&VzN$!(Ikl>PHKl>0?yu$t~*N1*py^ga#+2Q2$z84 z#uq8qdDR1E>Yq%X(Rb;Hby<3|iFawl5n{_1+EeHcTqIVe+?^vM{r}7j`(V{Lv#p}P zPe>@=QbQ_PeMC%3PAKQ)M8PY11a>d+{e!JIqLMVe*F!FkqFYb*OS0_4(dBZG!tB12`OOT0LQ7P1>y)-#u=c zrmQ(*A)h|&?B`5-@Fm|+DhuXig%Y(kBZ*4aF`A0iDmCyMsx_ZmHJSUmxq`5jcA`)0 zJ_DB}%By5un|K3`Q-o66LZWxts3q<-xc}@E=IB~DQ>l%5em)Jb)fDE5pI6P(`N{S* z)*FxYSUsGZ^z9)a43={el^xh(Hf4-j3o!Obdjo1m1<7V;zjl?Em#;%zPZKhi!+p!e zG`wHYeN}n1{QOUTnzr{?S-d+U1IFR-mm;XM&2P zKHm%o6$>A=T|;iWd#D^5m#oe#9Bg$oDT!BkpIemoFDm@3 zq@HcNV)88{nL=r9#8g`izo>%TRpX&uJ4MXPhm=-EEnn!G;c+1?hmRhiD*ipmYVtij$ch+!YdIE-?3+bXCK?>{E!OXl-y`^Mg9pdMtXzI;^%jO?Fjs zBIff$R72mm@6e}lmKEaaRdZ^%paut47UKz=|(DkW@sZw_TR!a7mVMu7gpci%E zL!7F~vd?#35sU=;I%1Qz;dt`Tb9Z!E<#8_}spZ+o){49iNug9r`)exmn{ui(OQF=< z(H07B|Jg@X$?y>b?uR-;9Cc(v@w8I$(xqknO6u;S-F+KV)hYhnMj@gE5wlfqjfvGD z=1B(x_r~lNO2WN4FQrgHY$o13zzz{XGR;^8J;tRTwte}%H&c=gdFZ0^iO7Z^u?Shh!!UX4mcQ zeq<NiiMWMWWA@846dm2Z?qRUqJV%oD;FB2KhxnU;|#4ztZj14 zV~>C_m=h4ZZ;>Pnm!*ZI8yY)Je2QJJiR20Fo>LILob;*EZ4Q7Ef znRj!wAKNy1Q^%`9!2)pNbzfgc1kr7JJX{QQ!VK^x>XMC9#zuD((>?ob0T_qdk;jEH z#fl0Yk~t?T8=e2!&B(GhP4k;^kAiw!Wluv+f%fAR>ZoEHnR;5-r-05adW`$3@s$$K zbe`iSya9mSosl)ps~9@dv0W$ZXRV0Q*#F~Qq=7E3dY)EOnw&Kmb`O2(G2__m|4rA+{UmI~kq1DtA#tZ2Ic~730+B_8L((!^3;lMr ziu55~ozm-J?s-+|Wpoj$E|86w zEE@j7v!y?f48 z(?+31!#P{N_)cqQKE0-1R%T0$iROD-DwroK*K874#t9mwPwx9*Paf|Zs8AAFQ)Y{~ z?jq0kdEVrzDs>Jqxs|1sab3?+Uck~&$hVl(fo=lCAi&1&!fkSo%&#iJD*p;yZn=I{ z&o6%U$T2Aw-%Sy59Lp|QtJXDwdG~EQpT+$LtAs9ma*N6NHE!6~wPj>qyLJfj)1+FF z&upB@S6Z~NbL1Y<$Fwe&xVoaBVFSCP2a$_wgzre=yjf!HgDmZ7Wv9Fje(``ivjPcn zL8?RU+yKa;RO;ye_%zjFuP~8hBZfRAWILH`|SEfx668QoHgHXk>AYd_yNP#3YZhmLd^?+pHbZruHkZNK zV6$=q$>#aS7QLO&^=IrY(hcVpt>@23#1yZUd91;5Q{(DTPp~ZdDwSXJ{vLW; zX17yq6H&xOj@2vw`tEJB+ZO}75KlY~FbPr+j~{?Pq1)Mo3d!53`A@bhVr=}gdCI#= z;em4!2J;dI_0hOqJs0&}4vkk-Kyi1xrTw8vA!qvst*byAD8KQac(gXpIpiqs+5efl zB&k<|b**Q9sIR)k%g5s#UiKMX6Jg_#R{)c=zo@1vQgoeS%u}AIsUpVF_yVU&y@-On zsw&%;9q#$b92c7OjF7~I$<%o|NdSbHu-VhTp7r|<9Z^}{v$T9~O$P(v4z90GWy9Hl zv?C=Pz0i5oA#TgtedLtVwYK{^c4H>m*yC&GsLWLbS`LqLIICUlP}M0uy^~V)W@yao zhUp1TVS|iHjoig|zbQWjY_Wg1L#NZzmA-n+;kYn^STncVQtJHwj;<|rZvUn8CcwhE zO7p+R@ygffvNt4@U8{2r>C~Kc*I5#A7&_`@*4Yx(KW4Mo1gW&O^6#fvLFO}@Y8 zV$|Tln)UjT-bv?I?~B{9yXLD;Ip+4mY{8&K+(HG#>hS|M>&C~FRdJ096s;!LJS|IY z^pgQ=TQv^(Ilb6x)#xoeoaOEbqVmP!#<5Id-S8%d@{#cPl`q`zzkbE96KYx^vvA#0 zA)nh0nsDgf8YI<(ppwiA8%n4n=N4^oi`rkt`-G}U4Yttw@moYKWDF3Xsv%@vO)wBK z#b<`+&~Cc^T+E(4BT+ki%=5DP`LcmT-j;_e+Vs85DD9c~C)W8Vg+~V6%~3nk6FH+U zK2Kqr^XPKh5Kr*icN9CQH(kj9n@(sgw!b!$o4$b`Om$ z<-&7vhbRIf&sqa#h6(`$$%lN(N!s+(@j9-T<-E0N3D#7;9gfbz7TeB%hQMU+RQO|g zBaDTxnygJ2T(*2|Q2<_(*%0vuIZ?V+Q&Ei!h5c~OXVB#hOaG?cn5U-GE?EJB&BIx+ z;aWzN`mjY*yl;s@!9i0U9pa!Y^n@9j(_-($@l4o!6Q|g}tb!_d?KC`YNaK~gs zyx9)ta6xT%%V^{$igkTzhvR;%n@2Hu@{qbGRc;U*#6JxYF$e791Yx~vVYdT6==m)W zTl#G!ppBN_zKw3^&89H<2ZvUAzf9jj)&x-0%04Be@6Xr@6cU=BRQ=o<0r3Njor8>y z=%KaeILb$e-|BnR5w#WEq8nmTneRMvW7Q&+*j!qRzpO&G>3~`dlTR%==LMW>D2e zwd`cEB7I9vTvGTMrn@S>cj0&f7My%K=Pl$p7a73oTb2li&X86*w70E~TxH@+EQOhP zoXoVmIHePkil9YU;=3#SXs_MTR?7~TPcMAncH37Lk!w_|n`HUckt6l^3;u(b9BRQ} zE4NLj@oKWwGGu!Ib44He?m-@fCGM^l<7)t0`&jziuIkR~H+Fc0x(l6gk2DqF>Kxml zKHOK~IUCl7V(~kGb6!j86<9mq9{HvV)ape`71Sumu}-;@|KBDp37 z+~ai&r007=jv2Sj6tTX0YLSdMeAV!hKQxbkuS5fD_494hsW{>R9Q?4{BO}%bht28y z20gBRsJ@4ka=)#Cc>?=r?1Z;@0KDPkJ?N2LMmWE-(_w7`oFks{rZ9WH?@1lc)*N^G zO4wlk_RLmu5x)O~(&4nUz667q784YowY`;I~ScQHx-zGuFfKm*89}DuTXuaXSNhB zri|!;mVa5UnVl0S=^y+70A2Yn0CbHka0>@RPHi^WGF0+U3&(N zr3!0C=FMlUh0PsWvnuieZU~cu6&8*;)Bz$pFTyL|gA3J+OC~w5C<=tS=5a;SM_eY? zONS~;2ffJ8w`rVtqBNhc-ds}OuxD)|DxgM&2j-$W1D$u9CTB8=xNwi;h#%vKA9Y@^ ze?$W73%lT?4CW7;nS(xV~ zpD4KGe7ZDv*VEMd9qJ8uEP(o)zo06es^~0=0o2szmckajW-1R!LQ7=Q10^mlv0XiV z%Xx;JLu{N;AsL9Vj|BlMmaWX%{l{P&*q`T&Kn;9; zbb!lOZGheJs6%+yq~of|(w^IubODLZC)F}E(>_%(JgEv{jXf~*6-HuDjCirXinHpO z9KqN-ikiPg-3^W$+Oy~&Nn9!?L%3+Eq4_90I40E$N??i&V)k2yOXVRDKHGqFtp zuuU@IJpbjw>oO+cCqu=GkgiaW(HGQy(|Vl8bQVi04Do%4>*s94Rv)Qe?6KCJ%qnjXuT!?>AyCRPW2P z0$RA%Etwh!=pRsyF27ZOLZ#C4ygo-$e`FM!fag8v2%=g6FV``_KGlvvXpsBfXn3BMXROYN9uwSvW z%a3Qg3%twl(=;7bbA^#BqlzAnq^H2Ty*Bf*!JA8*RCLJ&41#+CA1B1|ajNz)A1&`P z%Dd(S)m~PHshe}?@$nZi*(76=Z$19um1=&g4JM98pca9J(J4qQ;UA#~$%vvr{;}|1 z?!v2?LRBvSKZD@{*0VjXHKEGD^OpfONDH+dpWav0HqJkvVoQ+PspC93i3g(kN_?XM zSw1TThig%EK5B)4RNc!ev;2K>G!IZ$h6jv(2$FY;wvr`Bp2Q9>w4UcfoSgRFGitH9 zsEXf7yBo(l?jx+Uu~KdwZKA;|uQtgxxbKt)Uz zpWu0}z?w|ucNM)0q~a1Jp!pulFyHzRXK@Te&K=?<)Caq6nF@& zEuFUR$h_g7_sVlk6osOiY&D6pkfo|?I0k5M)xo|{_zqN@Ogv>;r~ z{cnyOKT~3g5Nv3WsBz27d+#SNiW~8Dnve5+qDh)xbyU1KOeG==J{`rw&Qylr(5>Qd_3|$>IK` z1QXUORnUtM#&A{xDwI$yt2~$RSy|Pe!bX1NGy9R!A`8!~FGq?7*358G4nFoM{IR2W zjAM>Q6k=T~FBMQn7Ua3sX}5N7h}~=-6O%g5$?{}&e;xiODn9&0PX3|C;U9ko$RBxv z*f8Rhdr<{_tUM>ep^TWHt*H`}Nv9j;_nkaIh^K%%k+HJ7l{Ib+Zx7O~NE59lHa08B zaYj77rW^k;xmtFB@8aZ-Ie<^MBNAFa#_RvE8W|5t+|$-_eo}Uy`1B!_-rl zfo+4yGkqnfdF`|Sb0+`wk!Bwg;eXfWE#qy16f{EpNc9P&3<)JLLbYOe^-qEm!wfVW z9oXV44bG%8eY`E{rSiZ115QR~dgEkNE$g`0XV=BanLP<_BLf57PCDKi*|{$qhTc@0 zZ;fNd>#u%$=Tu#vw5%e1 z4zT7jd)g%Gv{wC0ZV^BJ0146p6jE_Cuy47?6G*s3tOcL{ILGFVNK%+K9Ls*0d)zER zuU##uEpo>X!w^1@u30V;+&(X5wg@sNktu}dMup&0jvKAbE$SD8JqL-+MTxle|J(Vh zD&#q+6257yWw{fU&Qx)L?~LNp;{EDEUu5^E$;G|PE12p0DqitpgGu}3bQgtU`#d1! zJlT6Mc+NOCR1+oiD2sG+iS%t%|8U$s&RkV`Iw>Ae1sySsoh7VT*=r5W3Xdsx3GiZd#<)`DJS{4Iw(Y*i6pF>cIejf%8O77-i_R z{NOiGA$X&8orNJtYSc*u;9l#`HDFYi{|B4v$`vwaIe{807-6LHgXsB=pl)}yYB~DA zrCustpJ=tt_kMY=d$%(akM05`dY+!XUi66A8%u3@KV2sS;r1YHl1O=Oa?0X-(SdZZ zi!$~_@-{w=Bzg4j`MhgO#G4&ViiX#G;i9&`_9Z_m-aJtsv<+Ln);IjiBm7fWjrlXIfnl8@;Ha$tjpwzR8z zPNqP5>2$uc3KxY{gj8n;0uIBUr^!ruL*Jb!NS*JScRr)3sq(ABYOm-yBXbrfs-t}P z@v~u*+fKZm#cseEod;4S+({+Y4m{7q7f zskyh7-E*}2lQ;gu9R9>R!L`jphP&XLN(#KoNj&z!bvdOyjgx~cxiFck#1uxeeOur1 zsnX!cddeayYskFa2go_%dcRmdB;=v5jCPB|$C>C!H?#GNFpL;y$*K7v7fCyvior&t zreKlg-t1HPI$!dYctXcQz0QR?MSgtZijYM^SNL>|s=2^}61S=AUDHN0FUEsc&8JML zNz`#so5{5jO2Nc4utT*Li7|@I2GP(*+ZZF8LHU=gG8`H@1fWAxMuOR#b`}!-$eo@h6(0 zLMvI)%odgYn*qee3~VfZzBjNE_$JoIOtOmA?7oL2jg<>It{+NBttTInr9^4=3%$TK z)TI?tXZw~{HJPNLrcTB9Rwz2#aN*ruFnE{4eLH0vALNvMF-g ziI>9IyWD9*hvlDgggynuNE4UgDgBUJCgod6=8_;j|l zAymHkbKwa@SRxG?05szqVG(mbjvh&(giWj%-YMh?@K$OIsZE^U2?{e=;6+(WzExxE zpIa%+zjoT-NgJP=UFBk;$zx=P%$EjBFXbs7b^tq};r_H~X_b@H=y!$v#3K%<^ZqLs z6|NCvH|dW$ns)mw!wm$R@;G`NiFgL-9Om&(nQ+B`_TBMRfK%?3!k8`YtYbkT{=vmXddAZiIy68z>Jsk z__JppDiCggC|Ir4;WAp2PmRMRsr+V~N)%}jU|NZ0@;)-N+z&D%O+eBJYe@V+t z?NA{^xYGE%_L~p_%Km|^6&QV){j}*U?ea?3jMnWrm{n?z!1Rp3%PGUT)&P`FvyvCO zKK2+Mh>>$ekry+DxcV!KRPBsJ`%|%>ZW_()uO^vWC_PzBS8P^@oe*}BFr9`QSX$T z8l!<-0?~RsG}+d{H5fx05f4}a-?&18gSweM=rmv~K6&7CNK+FkAwW3nh9z!a={V2> z@gCvN{vk|!d%5G^!tKn0G%EdYqW!a;2+yh~3)SeiT|)neL#c*9d;_p=vbu~$RE~6y zOR8MW0ujvAsI;Zk;;F-{b24+{m#&AjaEY-6>4r5Ol#rwkWsIN<_egVFbr~W%0e5+e z-3t$qZ3TbvCQv(@W4{zCqTe=Yz5klGWF!e|*Qefold1Nxe%^+aiDgE z%u9~EP+I^zHMIy?ns4dXq|h|D`iv4sM2TTRb{1OYGy3|uGQq&;P3iZnqB| z2bS+cw04Q3@HJrV&?(DJ^j)wB8pqN1MEG2HKj%2tIy||8nsgQv9yAIg=|h2~w_%L0 zhQigoTXZ{*WR2pfbhr6UsM#y66e~BGAlnmqXvsmlT)HU9;h*HoDL`yCWKOKuBb0$$93$KNac(w|=v`2* z*4bOPwyesz1^=x+;@^*V?l+*sedSIQA}v>v#gbbtu4z6@;(qs|0v? z3p`E;NdPfT2_>_oQTW)G!{iLmAk%wav)tm5;I-F6{`XIr_ZG@fYsPNOaFm_Or}0z@ z=lMcNbbbQNE5+6ng#t9O*8TQz(=wNCHcg~(ij**FUze24*Z=z~*Q+ytJP*k&Z{Z*t z5y^=)lTis4IEo^-n|N&Wbj`Eg!ob%*4$qd8;y9fMZVI&w9L9O&0@uj0W$b+*hBizT zgJQ|MbQETc)V@}8$;?BvWTNj+`@yNr7_L(kC_u!U_D~(k!ex22c3FbjC&T&HbVAq4ZXjBhPMk3!EQbySXSycrqAafp;G?}a>4E7T|w^)Up;E)6fwrN8_A)F02 zE-N^p-$Ppgo0#)ds(3?iV7bt^kJn3j@0QMyZ6*^cMER$8qb{-i=h;#c1`yPH0<2>c z@q=ovBLh!6R5|H&ft<4Jlo%=(h3N&DR*`<(yq1^j&%fQ;^`Zj(Kwj;8pXBQ2y#=hn z&1cp+tt|T_IW{oNu`)y>{t6pEM-0qT$NcYcvbBQ(cvg?X_*oD@6LDF$a2Gkkwy3P} zXvjtZskOA4)C-9VImKU2HpM7+3gck3I@|S+Ow&Cpq5Ro+;2HR0=CqM;`)}4}%N#V3 z3gx_stUwbDMPH-}YYjgQkeF_!B zrANt@+IPeZ`hDge&kR~2ZZZy%hS=z*9380SRf)|dCY0~M_p0lQfF)Q(p`e`xyqp<^ z69a#3ex;xlngOdzk;M26%$HzWWglfe@hR+9LUFhUcOhPAiQ>d404~cVQpj>LItMaD z_{QNl{Hw;y&$7=!G!64EUl1~rD;Ejg@LT7XiebWx$be02E-jZFmOBk)B`sn+doFMy zN$F%?ncbFxyLg%5*0-|aUN~Yp?n*fINkqOOQ!CbGk!2#{O!7W;eweDthVs;!-Ni!p|8e=`PWHu06=k4Qa3ulJiS=r|6JE$U;1v&RNZ+6Sj z?=_DRGruhf^C=vH{Yv-^rD44;<1ytKi&tVf#&<#{U)fzrR}l0>r86b_jOe=@)C{P~ z%X6%yiQiu+fu%U2^r3xSw?fLfSFqjfhoMfZi)oBv6!4oV9dtMeV*@4-sxm;HODnNDMb# zy4|dZZkXRKFqKfQny)ixKjCj@=u76RNvA?NrEc+DP%8>|uGii^Q{ibI%l^4mKA=W{scWcF~D-Fswf9}aqwQLv+@+JiN z{t$-HsfdqrnbU-SU_Q^#V!DvPzxxz56Ddv{VobW*ZGF$Az=W)L|LhbGBq+~+I}yY@GT&c#Z!KvLRsSvj*nFNx^KS*-u2@vqVbd~^ zC;9S@d0!lJ>+M)eUaEnbjZV^n+Yi~X=YAi`bZj|YUy{`4*$fIsp0H2tW+c7f5MsK1 zI}t0PTbX?ndN2M=$H5@R_pMSglC~c(?uPn2Zf4tY(MYh8tz#R>Wd{{IJ2Prdlv%}@ z+4Fz$+W*k7>ZV34dT&_V(ELw3Q;XeM&f947D0B)aHTd>@A#rAAoSGT`PuaFRq9DAR z(qE;|3aWm6Vpw$?er6+tD!)g7uX_L^?y=%cayA1mSOHz-y>RZ9LY<}4@y}k;&E}ao z9ft$NTLB+PNmO9{t4RO|f23uAh{nWbFKclMSA>Fo?xyHU!wOMLpZBIG^m*DpJ`6nKlA13u&{%@=w$nEA1?PblzR=Aejq*`T;_vDuex#pjzcat}J7zd@{` zR*bx8rd$}+M_IYEy92F8OQri-_PNK6;(#gXW_b0a3Ga39|BN0QbhjY^p)%J5w)y*( zUFV4hknzS>)5VSpvi~ghCnc}@>g%}NYBEILBuUGOz&KJyCTt&aYra91u7ptkzUd7P zn_fuTNEm}B=1Nz`0(#jIDW@50_zd-+6O3}?7+?x3#iTn|aYoRT_2Uu{fRM{Cx)QRB z&!ey%w-;&Ee(kjX)zcH{5pUx2eyE*X%XAk2E zgDq*?6uJ{I0;$aKu>L{N9L+OUOc6yy<+n*Y4)RR`_i;O(zxn?bRGuj|*0+2&J#^Id zQNkUF@%Ax&g# zkA2*VmeX09g!;HU1{xUj09<4uRFfnZ-36nVWFZ0npZnN>C;;q}HLNbj}Ke`MRA38(rL42wt5w?=a#VG8m0=*VGQ$$|M=TnSW+ z1RzEANsRS(qy-rV5^{7RZFlU};A|)JOYD5)691SBi4e3r`x_tsRdYF4m6#;jKR;C+(GrCZom-TV zm#fB;x-)O^kvI@#Y)FkUlX zw^igTl>{M~6QM+QB+RdrhpNqy#RwYcF{Vnwfn_dtK0QPLzX=k3EIZx~qwoG$8*Vl@6RrRA=VPEJi&3jKaXC~B zIn3)N{nzc4u8l8hcWd749-pJLBjCDc@B2yQ8az>4#IMzamibM^)1c|_+r_we!{h!8 zGk7p@lK2dK6)I#W`2B>FhA~{d4l!g7t9|ni6V7oc=go%{Ol$tjSw64RbgQu0J=!9n zfIP9vgh4w{aYjvB-g17yqL$6!R_T%)V}>_W<|u)LPF%Q9WM=R7kwT)FH)D(4ln=i< z=Fj>Md)PwIatqQq^pY~q%scsN)CR#}29G$b{&7L^lkbT0_8o3xh`@EOwe4+LV^$mm;3nRO+P3gRN zUcXF9z%)~aJrohl#W6em@xMt@-GUgGgm2g&ObtwBg@rw`#}rCX)HiRp zzkp;)9F1Cgyj-TrJa2z`FLcg*R=hYqVxWyYh2)YPFmF`q)R}yVhtj|^67rdWuUsyE zszr5BFckA>@0v_K7gV*CD$*6x z({kG7Z%Fdv1}kN6jbF!>L~3+e9QRI7U=wel!l5?jxp0t@^ZFt(jsnF%NUnuC=WbsT0ab3|{qFryK9yFEyhIP-$!(uwjh% z)AD{}>~PXCva1J{)mrDh))z~~VP`!_#(@zrcKvfHm{OUo?7G0FXRw~yjy^sqWTG2eJxm>91t%+~b6^lX<6k`cx`L zT`ppVc)u(ME%;P--1*@FJLOphi>^_o(tg3}%bTSN=4shcDm2aGbzB11nry6y9nZA6 zGP|=P_~;7wZ#V1g@0wP#g@d<-KBcO24ZI|pjj()^)2%Mt``wX}$$?&U{EZ|c-VpwL z`az|t4Qah#$uFq38y9a{8u8yP2@JIJ^Mn?PeCbs?p)?GT{_<;agSO`^>fKn-o*(B zP-q8+ZcO+D)rydn>!;t%{4ktZz{%63V?LID@0{~xnAJjp{Q7y_y1iW9+jb~_;bh;trqIWTCQar}<}nf}ZOL8XF!-C3tow4URzn*gY`64eFkK4yeL^MJz1 zthhx&;*;}e^1mcRwNJeMXc_wVO_!ASc8nGMJ4_Gv@F@dMInuL{wCQbfi7C!iM9Iwh zE9(~Tzh_v5DUwOmTq8G(t>x{Ry%4+5E^v1HKfa%jU{OK2X~nFD-_wkyK4mQLkrn=K z#!IWI3CofaQ-%Fa#ivm0Pg}*SA(%~k?~_N=^i4(~4RsrWSROIrknaaigOVZ*RR$x-~D+VpWO*fiTc zJUKHw&(l8ps!cJ9bp>SfGT)bz=l#U~Is~5~nQd4r; zI5~;t%^xuNpsObIf<9nb;fvu{=|T*+z`%=KAPDJVM4cF$^iMVxr2(FkQ@zRA9>n|m z?PD*^VGTx(&H*qCPKJ@%C3xOYIki$uf81veWV+0BHQ&q7;@^iP)Q$U$Kn!>w=_wS_ z(DZt(%t;Oae4@njiF8jVfnuU4OKtW>VD>UF;faq4_)tkpiht)X{dv+N(XF_x$cYnp zh~DSlA%bkqz;#op;NgK{Q8Fub^n^v@f*EAQ_h#MoxnB8^Mjwk2~9yiuy}6v2bhul>~? z0FvQdUewPnU*KJH|6Aa0x{Bb00i299UASR~5CgayWA0;o9nug?a5gm%y8>f< z=`?CQW|vu!%7z2ODA{K6yJfUUQA)h!;IZ z_Jx_@=MqM*Pf1nOC^iQax`!mzZJ;~8dH^Ru@w|{HmOR+aE0AaAL#gg*`9vQU#HHEY zE8znv+Wb6>J={b+Y`mr%Rzl~rw8+3az_wQeeM~+st9Vy4Vim?p0n{!P!Axtw8 z4VwsdZvi`~zzdY9`h-a6(+-3?ltRYzy!1_A_&Y{+LL=m+FOMp)H}I!s7q<18sl}7y zb-sbZJD{lGfn618f>NjX3K;eS{2l%;UT-lY7H?z$5QC%Sv(f>vip)NtFiedH0>Ml0 z+1p1^ji{*21^HnU$CkT2Px#4T`-L{t`+1iA#F3j4(krB!d@BXwq!z9XF3oWDOLg#! zdFu5;rQ&tD_uoWRhj$k5zt_~azU{MA@udolphW8eK#5ex3H9{~A|mGV%*OkU==jhc zduBP*KPl+h<72KeBhhqieEGDM80zbf33xR8}>o`dn&NRzP1uW@u z4>1#Uu)kS6oUE5qJz_b26e>v<*rm}e_-feozDD8ee%|jj4$AxbIgq(MAi7` zHtj2+Bx#FY$1Advo_aQ>T)>Gl%Q?1SHyfSsz%fB7@OHHdZ&wOrw?!{mw%hqu@qqY< z+rSo~G&4n+F##gfC-{Ap3+s&v+iOVPfLdwbw_<98W^P`kjCH#|FaPH9zZ-qdaXcSw zsemselA)6mG`@wZNh0VI^%;97fMMp|2zZZT3YfmKVdE>ai9%&R1R(;(t{TRljI3r8 z6l$sw|5&hi5})w=Jc+@lOklA?5WLJ#OhV`tUubi!bQN_&qj|5-sD(Bj9Ua2EQrygs z?z`)H%Xy=&m$BQYWGgDgwEP~IX53)6orG=9{(f<(G3)7|c^e1llo*BZH(gqzAO;{A z_RM;&ftsQq1pNDlP0_2oZ(u7J(%;zEZ@Jg96LlpA+Y+Jd6jT`4#*{lV-vjCpBN?KZ zxZCL_X6#(%9#JxxXe2sU>>L{Z29pqjdG~d1ZD`_EJCh@0#DC^PxXE1MY&r^_D6r89 zS-JlfH*7ycy0{&W^dXQS!>Ngyoh@#S=)@~nSBG;1-hMB$T^g+ttk8POe27-_{WD~ z4qRwOgZ@n=yGC!7PaJ_wlN(T(FL0Mwc88u(UguY_FtX?n=6iN!gj^x1iMWZb5NXuWNZ?`hrqYsj^}B+q`{Vp{YDm zsidT3vlWO4mpj9=gg#{U4{mX!)1k)VCO*S2Mkfp)hroy+=ge8wPHRXOKt?ZIqxzA$ z=SiT45R|KPja(PyJ$0ew!_Dkj)ECP_<>C%0et#3a&((sr)m&-8jCt=J{LL-ZJJz@g zo_E(b8B-&{ht)~%?w-|O)ofNST~(LhGFE&L1cXwB9So8x9ocu($xp07>=M$YR;MWG z2tfRpfD3waR@4!lP-RY=z(9FF@BL}gPfgoBHL!JI92!dIf(eB))aHnMQ6stFkZGsR z(QI^NBrw$`5<0v?rl#NC1t&)vi6&-BLPxDCt*mJTJ)6^(p1s<=5U^j z&zLC&ASzp-BdFbh%~AZr52i~NiZts#)mJ>5S=)Qa0uB=h~4FVhiIP0w(>`rd;8YN`PwH8a#TV_ zYfdK*eK8Bsk7O_2h;x1*6*|@UWEzU`^g>UYonfno9t<5f#8!p`ypm=IU%6q-Qt@M@ zJ^w}V7L8Xn3V3`IFCPA$7rULL5IkexlA1mLP9tEAkkdIm-(1G#3q6J)zdD7HI2`w@ ziQ!WutQ3!w_tOEX`RnU>ltELvG<*{-dkrm33;szCugpN9*z2t_lDJ z5k@Ul(477QW7&Izdaj2z@fKxgmjB>KX&1#a7u$9SFS)qROy=7;O|8t_d^ws?rU5oC!M+fv2*;)kA&MP`{d>^k$WH!yV$Dhcb#NF66F* zG9xN_ep)7xdtf?nt((#{@H~#5eNg$=t;NGuV-d4|1HCe&vma3zQQCb_sQ@t@xLWHJ z>q8$80+Af)=G-{52dEA#)s3!l-gR!#N(iqf$CN5+a23`8<^u)*5S)2_wEplpMtm`5 zzwL>{-HAL&qV8D{b0_jaYsxV>_FGN$^49+k=E7YG11 zvCg{qd6U#bwY2KJSAK~)rSRCVi?@_aRq$E@2;C`F-2Mnnt0o3I4>*bby-O%&G(OP* zmdn|Yp19gB6_@B|q+Q{9qG0`H0c+Xl6SK1Z-y-d!HjHVwu#Up2P$nFf?EAJ0m9<$v zp&s{o^^f1sOryMFscNk=I6eRa!?_|J&c&x`NCj*{{xl;;`wL75xCiuO+CAhRjja`8 zCtYQZqGFD51@!#wl2F?nnQ`Z$h1ff(#-ct?t7YEP1~$)R-=B78j|dWCaKz#!I;)&R z5Fcp9M%mDKhdQP&cRogY;+4aRU09h=%mEyhu8O|olaUSdOyOW!=oAK_Rj|YK_hUeT zRB>bMq{!{YpGv-xE)qAXYvI#Dx;3uP}m@8Z_L^B4p20(EnADr;(W}TB?2Js0?p#zRXFH`cUpxP zCSbE-N;TJVkMFdeERXjC!x7?`KJ%=CzJB9VYz(nsaa?V%V13ornO0-F*(S9!ZaR4^ zS4PqFXu0U*jNLqn&>g%Hf6PB3C+;p&C*CG^1oLVb6_ZF)AbEgEC8Yy9(T?Vr?wXDG zpQOnt18^e+FOU4=ZX5&M-`^!mrgb?xFE2EL&Jlu>J7Q;lbvh66K$5&_TY_H9Ov5U* zR;GH^8<6(>^=g}uR%tX6qGhv6q&Vwk z_LZlqw{M=_GO!nXy#xvjg$7#s(Z5crpD=w4!Qjk);4expL|INuPO+0ecu(d!KWXAE zoys=R2a#p;@019+tGJ$piLFr>>2w>otwjvU0`7=Md17N=9{bjhoX9G@G>h)o;b_EK z6wUgq<}Vx7|1){M0R6E!)kn_eq}RK9J}a#%RJwf&-*Q1kHPpG`i5IwPo|ckW_CIKl z4fYTI;Q`u(!A<{Lm|;zJ-Bh}tPmyux_b3B}KmFviLa!9#`pr4^j|@)5Q#2rMEm2V? zoKs0pnMARMMAJFixtIfx$Yfp#?Rm#ChAC`FZVA(aZHVkoV7R6+p60YEYFv}2 zh)Ef767IS&w4p*`Z{<4VhzC)%iS4p-zI+9#Wm+SNYyeu;x@It6jy_J8)9NPxfntXG z0GJGZ4R4H54>LhdBn?&^kMCxg*i@d_nB!qI!Ip5$N+tnaucNIo! zk1VgK&)h5rI1{_WP1gY=1pTga9ZTooV$&ww?b>_4x&;d-!e@bMrIc}$p%%yaYatEt zlWA10y!05i;pgAJy6JG^S{A|-pagbfMX=yinO)`cDCu~{$#|CyA$4RK(ZQUSI%j7p zEo9YUQy(P#(p_84$}w_-A@*axCn-~9CdMF-k<%{*ToaLMyTjwZE)D#BhoRH-MdhzA zR)0O!_!9BSh}T>O7T(Q0#6&LI4Q?RMAD|lHb&9O{R#055 zJ&bxYy-xxHM{1W&oGMLk+i~JJ4=tZ_VXv>;e!2^`U-{FYw_m`{=JhR4S64Mjv_v`x zW|)HNTko{aJ5gvz3@@%!0vIId%Wjdd};EYF!2iOpFlnSV8lt zuj69n0S(RE3D{d`mi~y!R6qzx@r?dVpSvc$FBiOiw3G>mzv$_IlD{hqM&h)0V?3wVX8AOb!I9#nvGZ9ZB_Wt97a2rgN!LtjD^aGe z*=jQrcQF!)DS!-FHe#GbbW|^d2If9 zR}Cbo18dsIVFiZGGa|)$;x-1tl!!2V9xIfX!oHKJ{HD-6=W^(F{u(n82q;RW2+{}r zDCpN;O=cD~CpKm^KgGx<%~#M|nGO7cse2=-!x^29tJ8}A(YfPO!cF8|BT+L6ZgG|sNr`AWsxh89vJzncO0zJee~G zXE;DLP5U8t`@)dFMVh%MM?5&387>ZpfO& z;5A5I&1$ydF)&Q12*E|YfaqyfAsggjAv!(?-hhb z1MHw8Y7B6xCp?c`b#cO}x%4!;e3TT`jDyT0_*q>KJ`BM zPS2lqK1+A~EQVPs{FAMroh7lDX^vCWBgmE&08K__5m$A@&f{52QPe?l z9@K;(hacJ{KE%&x$!(#oo6jy0iPy+Da5j1u%7OeN6pRFBzK#<&zA2L^QiFZwpCo2& z?@d`X9(ijpVO@Yjt?5;QCp7#*cfuP1FNuBsqoQBTwG=${!{(pXwg;#J`BLYmr(@tg znMXa4;@4(gnnqOx&CVEwX?w}ZZ5ko-JeiZq50Xh`*FE>^^9SA`riX{T&8TVkob&W8 z>T4MOl3%zfE1hGyL^ZsRvHbmUu(4?J=b^tJFksC6WURn-*+ZP49G&~gQ`;I)wM%>F zjW#Ty6cEmR%a0JzC_p1WzMeUOx%`v^L*Ayd&<5a4yQ^}~S>T2#LASoB&dsUs@zO*j zj|#9PDx+b+FbUxEb=-?MivNE58IgC~c}`hI5sWP$F=%#DV75UKoKThgG8^3m&Z7fW zwI=;2>paTxw<8riXg6`B9O$1D9cBqjO;(s~99)a9UOA3hRxmQZtaL<bIgsg zLY0#96P!SI4P_w8d_)aQ1bilXqB!PFt7)aD=FmcykH{4!Sjt+g zVJ2b%B}5>GrD~FQ$e|UqC2!5Tn;PC*0vBL*qqAR68-M*2Z5-J(s6bRsJ&#BmF*|RR zFvr-RCVlfQmq1jqn5#zYF`h`70Y25UwPM8RdF2kX^pIaFJ9((gexvyDGvp$0b;4Jb~7mlZgE%J@Kr)$MG z_-guBl-A6HT3G8UnTVgom*;I;Q26?>?htBfM%Kb^2y7u5LA7TPJ(a0OQuo3Org!C#3>`2yoC_TwoJ^2>2i@@fzvAxo z?*bp~(rc*`3fUxgl2G(=Q1A+S`50+j(a=YP_6%e<3l+$O80}wBA^0m-7d6v^2F&i zEcwSbfSS#BIc%}pM|9+u`1{F2RMtlo$2-NA?>g=v^l^ZPa@+vJPJdaoBo#OO9;Rar zTrAyg(kFmY_a)LIKVfTr@FAfn|pTw&OOr+k^tw&N9{Oetj_-r`u+Fuy$c%zVJrfkeVRE63bk2}u`dkY)J9Ju{T z4ZVAZ?pC!aJgUazMo~drmizMW)4FhC2{V#;gx>gQ17V2i!0RBey$GSHB9Nz8_yMUYa^<2E;L9KT{6caxxF*ZT@*S{(+&op?xNF@RuK*sG5DU`Yok;xS+ zD|@6zs*XZ_NQ(nsp(VcF@1^Smk9O~92K=z%kUv3BJvs`WA29 z?zXt`*elRDEZ*AdtPt~uGK2Y5OTU{n3B@8`4g;zKUw#zKc=Z^C?|k(?aF;<#0euJ=tT5X+5Ib(^Q08xZeF?UBQ!jT^j}ih4Oy{IfZyOzw3Bqp=pfJan zQlX_0(d$f~#f;+53MCXu8WcT1tN4wM((tXLRl1h8hAk$G z(NmUyo|Efz;l+EWAxEy(ov{Q?#%kRF&6oi<-siv>g&IE}Vh4{RAo11!qF*;@8xUN{ zrLr<#Xg_xJ;AheZ)%VvFbio)0>HSN6pHohar$VAl8f_fRaW4IA%lkaxF^*D3)$23W z8sQtr(7_~^m9-i+A!Vp$?{4#kIENFz&@9rw@n@vB0@jtZkB8xH-aVZ=qkno0^&GSZ zK)m*7*sEIN!j3~4+r48lKqesej?mBv^|?FcTzIzkS&C} zig!L_ZXg2u3WB|3$IQ`m6yLP!_HWY)Wi*j=G{QARMDXO7N2__scfN?FBw#uz?2~3UbC- zPomnPP=ZSjSiH}5-$aWd;ND)gGqx+^v9W;srnukH@F+-r2DjjS=o9?SZaV>BX_pJb zhGuf$C!mW=ElXXWz&k+cX%F2L_E5Hq9R`hIaa1^FxpM$l9m~yrEp9b%L5>ycYwDRArcAw;#TSLnt zqTBX90sWo9^`{K_!`aYBWG*&zcNqJQd8-BhL4u1>@jOGqa>FqM6x)2ts>>p*Rxn~t z*qePcJojw61yeDF!zX|VDex+pW3*r>Wni~M??WPL3sddU&gJFF+JE68l!O9s&i`gS z*$71oo>kIXp*suDV@ljF4xIXoC=+aFG0qD3Zr+IMzJy#j%eKgE zS8bc4;Y}ZCF{_-)1p8S*hV5%ezRZLfU6|l`??@HnD7Ou)o_KN>6OLbdFbpHm+)g--n`Jo z01S|()rWzy^L<%AZV#xTe)()9FZ*M<7S*){y$l;8y$c{cfZ~QxoBS=CpCQsD9V#*qO+LhN{gcc%Bj=&=hnJc?ZbkE+ed>H*+HhnInHuOcoRtU;(niUE`)1%(&*?I6wD8H5LVQA8t4{?K8O4DiWJ4gHVndmaVTwGfgVh8gq{_MDdX zUO3+`6YS)I`PGW#gI?ic(P6ZAq&h*wHrVE;Z{BXuidxzX-E7N`W$%nWwl}=yN+|X~ z`yd$E5ZvcP<<86c8cWpdMxw7qi|9OwW`=KbBgYD@v`djZS*wA;JC=mm@C3nmo$Gp;7cTN{BE)nSEgL&OvO^mb31s64CAih+ zBUDDe@-*Lkm99`+#+`kppesQTSPu1(`PgT827vL~!SGa$>yokKfd24^G=mGJn+&38 z#n$f<+Pv$g=Ws_I!pacw*=FbVnLPamj8K_q_ZLSS;NZm}p@lRZ4}i=p?x1Yw4PYxg z(NX1vo^~l6ZHcZgt~8#A1-u+NfSmM>q{xS(FTk_b3w`GD3kN545d)&Yd`Os(R@amg z;U7!l;rB!&e1dMJrB`vHUtoB^|Ab>G4Rd(~>U~|gGv!&xwLMcaMGN50i+C`p!Xkh?7X<1`7HGP zc*xax>^(%fZqyBcmv;%T^yR&+jTac1k>&l>P$Y`MyDK&Vx8g;{x^fGOr7pXu#mYup zmc0Nl!WVwMvk{hU?1y<6HjboXr|0b@-ai^4XPldia;};l{&jT4F4+$2wpGK~H{6Ak zH@C0v>0jCarGN*#!JE~~oW(q3tWyVahWs3ooNhpS=6I6zBZHyq-8I6Z_>^(L6n@H7 zbgK8&l;|JAEPwv-JS55X0h{$T5Sg2i(5k+`Z#c(tSi8LJU6Juy-5VQ=bVA+i3Za;!#E9!@fcrtB(M7L>s+hLdB zC4Ns(aBKe;+_YVzmln)UUYhY~TUz61HfHExmGNf!pP8DHz$5QTtYz>Lld_-77#(nO z{CkP73N?^pvYR@g)QBu}pwsFOax`LTDJG$+#awN5~=h)Pz?nN*{*ie&0H+BQwO}Oxj=GW zX^U>3+oh7`0K21=R4;=Y=J2GFtg^h)-77Nn#2W8X4|jv|qQ7n9^Sg@NpNgasT9J*? zz}}z1(PG<0$8873)7U_}wms!5OI=@Awr)+4SF+co$#2si{hw=j%{HZoYhs6>LEH{4 z&ft^T>l$7}{?nT?)TU-I67osy@+_Yudh#{lRu`NOrnm<0T);O>lZEoUWKlNT1C*Lg zgd4n?fpls1+h#)zXzzzEp3%*ndOQ2gS}WyOS@*lfsZqY_?;8X9XHJi%PP)GK1yZ(J zC}d1@_!)`qy;ARXt>nl$N+xPBG;DaGKlD`X?e3bpW{U6#4p2ToPQ8S+ydyt@SM0_# zyOGDUnZ2zP>77lL$hHdywl+ep#_)hQ+L_Z#& z9@mYvW~>7z91NF&Wl`y3eF(Z}=+Zf9w$c}NUsVK0g#QWp)&1v$2ieokf@m=7F=4rr zsB+W0N;g-`Z{ zLRl!c%@7_2M8OvQ?aivO{rCqV)R>dEgXe461f`BAk!i`3*T_`Z%l3wk-&`b*wl`r5MaEm8X08uCsT%aZYZU_fSEk@!nEsFzY3zDk5B7k8E1pW+e5wX1{xnkj$ObSeuat9R z4EYojKBEfv(pK^I(Ds5tZ#96Y*PM5kmSuR=^bq%AriZ*rHCC=}z}g!#xYJIK$aA(|`{i5*1AwVvFHJUAW5_a4r zm3*opft;pl@dK0PNFK|6{3QAiG`7)o>If)4#uxyLA^E~+;knX#+@=@nKK;rK-BMXv zy&qrtqNw{Zs+66$*XdfTVbWHE_Y6w&QA)+I4~0ne(Xb??>VQ?I?EstyM{JM02bqo3 zGmLEYz~b(SfCxR{iPW!2w>W{`3(hDjuSZfH$!j>hkHp>uav!uykw#ebAbGXDkVOLh z5v=I0fcLw`e!oGz3%!sFw!GB+j!%mzJyEwrLyTX8IBlw%*PGX_Tv2`KKJxKry?~ZO zFE2hD|95e*4Pa~^Z4dtb0O-Sfj{@cI7#{qPu@(f)Yj_th;5h6R-?6*`U+fw1zct@O z*PdMqYL!$K5@l^H9DZGHMoh}OfU#`-)vz@2keK`$&D;p^pei}QgofH4hYvh~y^I5I z+_A%55b&RNq!S9ls1oMOu;P1$B<=9RVOzcJ+QA4-gdGm9Lzw!@?{bYGGHtsW+sm>= zaw+?eCjLGe$dNH0lVuONj%{PNgGXbJf1Cvs$tXO&2H6Bl;|ZY83+&$QM@vO6j$gB9 zVXcW@t=7GDePH0CdNjn3nT(uQ*h^!)u zRi?O`rj`})wr`iVL6$s*954nPFyQgLKzn%#%R?Cdq9c%Zw&wt3?if&^QgvzE;+o1` zsZOj=(9K=*ti!l&Y9`>9bdX2)(z zQ9f*H--gdjJFAGj`Z8s@V8%(0q-kASo=*QNC^gN z#M3BKeLEL-`mXz}=r z{U3pg^!w#(q$u=lt^#Ia9*_Jc>)o$pY`y<49K?v}u)d>Sr4cnJHqPsF=WFp$j&j8$BOl_15m`N#514Yk9kf|zO%dM2 zdFG)d?Q8`wt2J_{PuIYsxx_wq=}5C@fgoIRGp&V)BrfV6aBJquigfJhH+jm3H1I9L zD%gYa__-918HGZYJ|JaTPhr>zwuMFjmR>#g(0VWE_dE_CLpQcqLxxNObfzO0p(Wq^ z$gwBgbwnI?;g9kwfL|)TA7w>Atp7nvIrDvk(9`mr6ozH-rup89e*t>!b_4`sR09&D z4Z$hv*3>?3G21!jhhZkJ*D-cHckW5L`Q24AAby~ce` z?Jis0)G(Uziydn?a+|&dcJlnBrbz$Cr5Ig&cpaOJNJv2(09Xwq1BRrZK8HVJm#t{J+*q?=;(E|ZriQAupn=h9X$7Upm$ zEKcb@L>9yhbYX+>r~!L@5YBkdb>CEMZpt_aW#U70@%Xd0V2CV~sg3T+P_V}@+trM& zEI2ne=P~e03_GF>$pr;J83q5n=-2;zX1OeEo zk6<9qbxRJwr~o1cNN_WR-AV97IHEW1E%rU(XHN2swE zMX>4X|2rD&dfJw9+k;-gm`JZMEJiOzbsPTT85mG|MT^>ESkG-Bi_n<_-w1&$q3xFP zpYN}QYz4am*i)#r7#1i1;X%i@mq$3_Fm9PEVOJBQLf3&27$fNKXX?SOCcJ+o#?qu@ zkW0oXLzHPD%Q*tJVhrgmVVn#Pe`F~1eY#|cQXcIr(w&mM0ZDt;REfzcyl^FE>x<~v zfMLh22E?Ys@c@KihgAxJO}Swi=%T?VOq)-rVPa`>Yr<$d{!1q?lU1QRW&{zv6cR&m zDpH?vK-zzjG>(AJbcO(&*6z5^?VSfv?~AA68W`1d4^=ZnU|nKx{tu?(lM0+)ZMtiM~KIQ#_!+FA#uRfUDS@E=W>u z+acCC*T6~9FPbl=?EPiB2*tqvJFfW~Eg1U(L~tXNkRgl?4?mv%Bm{o{b5S?9s4ZnU z5n+S{K$Z9gsze+piBGZT!Akn#&~4NxJPZZ?_pcWq__O*CsL?Ey?{ia~amD%m28YES z&{n+XWFFA^b(G9dzK6^EY9*MU`{Ep!V#-7_NZS-Lhd)R6+4A5Ga~JbI7_INEqZ(}# z>7&60Pd`YGXD-H^au37SqSBpTCW`A8;f%~gz{acRhU7WJd{79^php6jV*?y-^%Wn$PZlLh%(c9J*)}q}&f!#iWNqahdbT(wDd< zu#r=cXpR}=kqRCmJ#wzsnsLX4X486jDj;eG)xw-1|rU(k-Z+##skl2ha|ND>T>+;p8U3nVJS&1W1;s ztq0jjB7gU;H%ZXH(L6fmdi%hvZ{T=MWpK&nKPWMrCF;Mvf5GXM`k)dJck23j z+fauXnXw8!d4LC_9s~W3-G*72D>ybZ1G>HH>wGujgWflvDPg8@_KwJXkIF-M8WQU- zQWE9cRmipG4|Hs6JWNx{wRHZu0jnaFxKYEZbeMT;Kg9 z?eBeN4-b2l7Hr`;6-l^cb_leY-mX(K$y>yRK$>sMB;!y*cnu@aV{t=}@U2>XRfSGn-be{wf*R6koa zA!Kx=!{78hGIEzX4Rw^TGo2%b*eS!gPN*Vi9KWc$K#7cV(2O&!hO%xS0$HptmRlXs zepD4|g!ldd-d^a=$x!!pWGA-XgPPeTSjoHzZ)>hX#xTZR$M^dl6{G!9fa-utO7GbL ztt}#i!m!UZG<#r2sq@X-K``i^V`8syoT7WJdcV$kKZ*X}zwQOqa2~eBejkaRgx5xx z6mmoFY#EAnUXs<4sn`Fg&R02DdLCSoxReg8Q>JJ3XHN=f*qd$Q*7!_BCu3ysN zF}W4s3%}NhqJAVG%Y$lUv5{!58Z<4#_gI?PyDADx7V!8(hD6#yYgpo6cQtLsoq~FZ zcc3B`D$=9iM_%+zlSRGXKI(XLqX1;2_^c!Qo!v*mTSDOz?BY`#=S7=D3{Rk!I}aD6 z0RDCQ*?oR@KZ?zR>`&VP)BE*8`nj6RG%jnO<2v+Q9L5pcR|Rdv@Q+f4&ymIw;s3V| zX*j+7o7`ewXk%klKp+pl*|6MW_~s%baWWBc!Yr@xN%85(O| z_o1^z!W!(rtBv0~76$LR*-h((X3Z4Aal447k-p-Vg~Xz^6gK=C@d<|d4_!~8WsN0T z8O}Gaa|X3{E3QaGxWZCH+*kvelOG-cvLKpIT#pgQN$j+eCo}*8qHxC;Gd}OQiSad} zdcEe#KE{5ykuC+{Bs~QE&?;aSlW#GIfAH_wq%%wWdMo@i+)IEm@3D0atO(WOx zW#*y&8~@}5^0IEid}+$4VGKz!-AA50)e6nry;otq_WVS~rhNeyp_)MrCns%Q2@d57 z`IEXC>EC7P++E2X#&4m8y2WXZ1TkLX%QFNgEcerBi8Xerr);TB=|A84mvk0=Cc-OsB3J8W>?_H6LGQiblBd*jWdnOYYq8`Fyq z0o({B(JnF1VpGZf%h(1GCh^9a_tZ^WpjozfTqM^Te3bTO?A$HacygN0%t%n#UpH{- z{#rg7NDT1^obQCZ>B6ae;LrjtDh6x&zVnQQAi>gj5HKVqqN1GtLmD`cw831$C{PH# z#5j-+PR@!UQ+*jN`ZEVXFS$QlVx%=$eH-KZXBKmf;&Na|J zU-NDE?mKwP983J&G5*bh3frLW4sg%*hjN%;t_oiO~QR&<85o&-O zc7hHZZbbW@^hbA&`7OYwPFwZ4lopR}Vfzh6F6sQj2ESYEuTNrY;y7^Jdyu>M)jajb zkJT+juI%zS?xGF>-2?CDLs!8(-zBv82q03bb&bjzu#Z?_z?uNf4WNRBb{3MW5;QE> zHzvWg{y#l?M|E>&Iz#JGQfTj`QB`oO*Qoc4`T^QkdD*C+cdL7kDtm_k`_dl>g(Drq zoKPrH2biwO6%`8j7ih=*7``6z5qa1)zjWX!>ne(fZ3>Jfj=%_EJ4lAO*A?X6rN+sn zZRIKRSiS(T*~5O0m0%44FIdA`WG3yc!Yy2Wbz0EreV9szI>BcXY{RRYz6SLXS6S!y z5mqzW+=6s$C_Yu#UeEQo^I!W7j4C`Bw?f8xzJqVkJin-DzyGUkYW8CfuJ&O_9KPE? zjjuaYo;H_YiZVTWA*` z5gH#+xI;_ge+)!D%t+cBu*ng(zZhqx(Y| z;2mH#Q)&CIiYtY~-*3kZ#q?7J$omMwMRkVYpw@$1_JP8ujzCA#i+)8G=I}`5RN7br z1PEIq16A2Q?|%VE5ZYobHHWhjZ_(!T5Uy*Csep$=`0dlr8B5$)I{n>RZwa zW`EpOF)X=}7>QYk6!WwaWREHF`Q@E!zjl?qraj)N*fz9bTSb|=S;uQtT=b{+pt0Q9 z)N%uEAufv!=u0WrY_MXV4S>CW&^hB#2n>by>W)}K>uD(W#uI3BG16ZMt@#WMZqu5- zo}vZ#QwER=QJJm}4wl%x!$vx$0^<&#Y5y5z8Kwdc^jJ4Ch)^4MiQsoe+G-a3e<{04ck`LYd=VU^fj?HC56I*8?sFBxLqg1QKmEVBP$4gpeivq&{pAZJ~vcy|08 z#8k!{ANQ-^YG9mY`Yv?-I{qb2>m%3h-#s**aZO_`BvTFS!AXEX#Z(Z#$OV$Tby8tV z++X*=sXF+jGbG2=>EIWyhll?_EK7(z0j!wENJJjHS18adnBDcj?Pfd|6wH4?6 zwBVc|Dw;T0x+##{-+oVm_Ny0fgR6Ire@Q_Iz8WF;t(yBWS|9miCy(>$=Iyjd<90@} zY)(n7gVPO1{`k(h>lKg9EDrz>x?)PUBWIKQDtIT+9QAY>HIECld|n;y&e}H9VRyeU-@Vuk@$FKEcp5?Yj*!v-^tgLJN%pCocHR-M$3`-oqk*YPL)3a{F z^kxdC#oD9BK91rZwwLLT5|J`EOS;u{gvxr>r+TpwSvtAHpSB&UE{NwykD5H!HT-h-A?loOt`>wpDIlhNEywMcn@sx=jZ6V^HQ;%LmaM+}+)) zUl?q%1Ges_Eik+pcIx%v{o`J0ca~dTX*O=+lWhpf2P92f031Ys)c5oUP(J*kr6W+t zVxC=?Jq(calea(rmkb=Qf%<(Ch2ilbUvzfoU=V1?n;Q)?mVsnS{mAvWKOaxV#6S(O zu^3|f-oKEWDd4-bV`EBcc0PENy?2@8h%Y*^fcLIH+;}0V5ttnqZ~Vr6esR$1pA55P z2&jeOYQJ7c1&TZ%4qjmgP+IoyA^evuPtiuFQkW7bkj~DS?Dx^r>1#M7?@&?eeVaUW zp}m!NX%#s8!!e6C@iGotn>OGHJun@Pkd5CU)1LSSl(s>Dw~B%aG9 zJJOFBGV|dfsn=1?BX7e!SX-U03@oO{A%#HSrz3{LuF}qsV|dzr0t;8ih3a<4TxBYb zx5Eys8Uo?1=QrJ-YIlECJ5@9`SQFrrLoSZJBdOaS)bIijh@YLV*4OEZSL%c4cw`kFwYo;~0N zYdYM!l#ewKB#yf1^KDxGd9>|E;W7heabny}R`pj-_7b!;i|;?3YBHbkyUAYJ&&+MV zVpi4kqI-4kOl?s9IY8L25D{U((0~bZ)=GFP6m4{Mm`k~T6u?NjViE`$QxRFSS7g7` zE_vqUjaey+{l-xMQ++p9jGsIS%@`vvv)8gg_%Hm@Hi4+1oj6x6;0MQz++T|n9+^h% z1hoqVnuNdNn`mRhubZVPIr6iIS}yEA0f0+C;1m+#;kTc(tV3}XJ`ybxlnzm<496tl zi~wz^I?Am4yZvBysbX%KEW^~QRPJ8EmJ9=f$^`+RDgl-lR)$4?)8QlV0IUy=SivSg zlMc*nw+3_(AsZyR#FOy?8Dp1e#v0EULj1H`w{c08_9-V9qkx9hDa0C!)Khj(xuO0H z$B>ht?+jGdax?GCf`X*$8q;5dT`F^j4D{kD9c{c~GxfWqY&ZQ{%GMGHo@}TK_hcRQ z2NuH(Ng-?;Op0IBMx?4%1z}SAD8vqQS!b-gwX6+DE|iebpxMf{R!XI7x|e9_{qvcI z|3=bW=F?W3vXT%}o~O;f@CvPFp;81bc;WoLteqRh#O87H7j!>0WQ*w4-`NzvvbO7= zDaexkG(r6}?Q`H3*T2(&BG^oiq&V4HGe5AcvWs!S)`_RDMOqybeA4J>l4PU9c;)j$ z(bC@0MA(kQMC3G`7Vub{iltz?zD;_3{%l+C6BTY4na547j(|m$W?iijRc^Bn?PGd4 z%*?u<-{eMrPsa8>sU_2-HYfJWh-=z;T}!W>TW-wHK8)IMq=`$;$iovm%{#iT5&mtq zh77^>U(G)_5N2v0>d>Sn9uOWD%@jV2xv{P-W^J&@H*vi|;abt%Q3vl`HE~VgfIP#y zKtRy@c5`k|vDOkx_OzOBcpt{|9fpj!IS3B~N1fR7>ZD$`?yKo!x}x({?*A z?CueJ662N7K%H4K$7dN28k)Em51!<5Q`11kk1g@~-FKAUdc;#0BZ7bz8U}0mGx!Iy z!wL+Ye6l=tPc7%)*b#cZ>nvy{;!E#yX@gt|>Z6HZPnkUReQuwRygup4@s*11BUI`V z-oqVBN-gc3Z&Da(!gi$Uh7)tkR?1{f?fe!mSQi#Z;UBQ7FVWi?ko96d>L=d0$#+?vZF@`CBlr8P@dkl4#Xdco!G*%wDW z#xlOb&+1>{Y2RN3SHkMU#-hT;wkmT^_!s_~b(>WbH+cDAS*blPbLPywlho~TO<^2+XF@nWre6?egD*6;_~fa$#~m0Q6$dM0K9+jCk|Q-$Jtb9=ygL_i8g&KzMC zg{kZ_;CeHl^*;SSmaTc* zSo(Xkfs}C9#1oSfa-Li<6ZFf>j&v<+F3iP_nh9*=ca3Z1(t88G8j=>+T{F5F{Dh}x zy*f1=!rvht!V{BIx2=DYyOyT=5b)&6#6a(4AKAF%OlGHsI#IOK4nruz8pFO%qWlTuE7acYB3B8A~81mJ+VJp<5_{O(~U&<%2Kv#oF4^Qy9s{PD!o|G}!6 zrzQt=US;-?sR{t2PjV&1%sBD|yCdEFQUTrc=W;V;AV@H397OEpFwh9q_~&!!B$dU@ z`PF2x)}1PU2*{!;PuLHvZlW;mJqFMcTh+wHd!dYPOk|+Es{lfRSeL39TGx4@Ub*97C1k3ug+mdFfk^HgR2h^5jZ5v-sLfEqB7?6+jkf+O|Q1ZEPdTiW^wbI;?{Wm{OU+58&qNU;O`hs(urA1;e8U#qv>Uf zFy@UJ^+|=I;GHhl2vHcjA;?7dv-8wx?_Pkd^g!Odudm`q`^_<*^K!ujmLs_YFGhi1*y_kF-rmHe z`1Jjpa*jcol;q_KYAraG*(s+!PZF{vbs72Plg$J+ME`r_wd?MwTM$eiVd5Le0=xrX z*rtx24Bl3NA%A2hEMO;JUx=%eL%YfD$9WQh7xQM^y@>(dK+=IXH9`c~oX)%x8FmyY z#lOitvv1*E>sX4MVy|3V>U!jJSXmm$vs)iGj+_7lUB_$6Oebw6J`U~r+00IH@yJ}z zFhq-X$a873M5lOBa5;p9U~+RGP3Ra%vlf?T=EIV{&v%|&QGoTlq9z(fPQurQybuDv}!0 z|8ml25||Oy3oQ&2zcc&20VU7VI@1;WRCUPoJg=)x5F&j&7>eY{?8BJ(b2{ERsJT?O z!>YJ-xP9;<4mne=MQK4NihhXRz+?|Awn%-Ek{2~>R5e?u5S?9<@Su*n2?62PEnKE zhwv4SgZ^OD@2sT*9(&_<`wkSBxJF4s{m z&Q*9knMfnJ>nMg;ANlfVQ^n-+sa9~ps=kGi<@QzmV*{R@xN2r? z?l!lB$($$Zzi~8u?YVTML?=?+5~QF!q-PzuO%SG%5TX((dJzx43GVdZ#ar^Fe zZEI#8w`7;5u#(aeTt%}G_L$vWCnC_(AkbX`ei_Dtlfor-aJIS&t!(WZm+FuT*Rc>a z1ZLr3RefQLAVV_wa7zUaE%BpTl3(^zvaKMEh(=HC_ek?|p5aEMym|OXS3%E<)4$SbM3| zI>@$-b~=odE!J+XynasCDGVD(ZId97N*z2|j;pD(c2O}y^V9U{w+zKt{rrfUcTd?b z@&SMCIWpq$@Ct1gojB$UYv3z5}vA=M~*IUwFnY^ETw!4s32Iwa9Bnhs4`O)H-)p?a#^~wbMqsW+DO`Upg?l_JOU>)aQQIx zh9j>}i%mA{edXi%DlEyKbd50T+6hZLp;@j; z&y@yE7wtDg!pzVe_Q-C2RPdJ57D=GnI*?6xm>E&iMf2ortJsvPMyM{TKbq_=u8I^4 zY8UU6RKFmMJ8OHl(tfduY`{nGfUvDExitJv05hB`Y#0r{LpPPZ>&NWgsR!sC2kO(I z9+wS76VW4wPyDVoDe_d&iVeH->`>NZ`_FvfwK?|OLSH(+Q{&V(f3-xKMLGu3*};H zID2_Gl#M!a`(ULH@+64yByEHe?OU+PksK|SX!;qUQtRotp_e|Mi>*4m-`sYa8?D~p|U7i5HjI=)$Sz5?sli7fZ3VbOKz_jB1FBg6D zYjtdL=b0jetRsBnI$+1=LWe@gmY%ntT&ZA2Xq61VY=j!r3o2d9O%hfD!#kK&E~#bX zs_Jkj^~XecpFhZR_UQCNA@2)-I$@@O%BF;S6JuL9L@kbgC&VZ|VM}6=Ph9HIY)4h^ z(Wf~=*@1ffZ(T$~CjH8BFmUCarRinAJix~`4`;&ux8?A2s89NcjxLS=c*wzZ*Q&fz zb$`l=C-l~Qnqm^bJr<%YZXnU=@A+1wb?+-z!T#X|xGF0L!vn;c6Q4`VKvC%cHB`kq z4Zn86xpCR3TP6;6^a8W}++USGY#v?+;87P8Ecba#rFykqSfyd3H)!&GVQZTWoH&a($RI#TlT(fxvPGcmq#1 zG>s^xdtd49p4u!fbLhZKzp^rENlebTeYXCSlTQhO0bQ^U>I*Y2R~*{gT;0S) z-{|Pj)~I&5EMzJ^N9eP2vzoZ(-dD~!LVcodx>BDNQKszW5B)9SBMhMB(9yA8Zp*Ap zVl6kt``&Zuek;WfAp_&DtI&x|01ja&WBB>YfOGhDt5aDMBpX@+AYt?5VmWne17-&WGF*DO%GR@$#_$QtVR zWGIQ)8d}gxfY%Qn)M_iN?#ISn9^AbJ`YXF-pdi0=7a)v782a8x^DYkR08+y^@Q3tm zOU3AxsIMiGG=9|P!n{$T&p28P#SElf`Atw}iDv~c;rXo&XtT6gtK$h`L<#5wM1` z`aFx5qgxCQ!!pKA}b&%ojx-3hq`>dc&wEw7yV1>)L724_0 zsf+*7-Z8YJ%DMy^GBWD(Xz{4UhI(wUr0v4HU&UUR(s=I!Qn;=p!(0B*C3z2UB&^^) zC@f@h#wbAW{!HuE_B@TxYT`cuUMvV}F94jC=(P4EF}4?92?GQ2o(Met#}?#qg>K{? zPCJKjz+ZWj##<+@F`m#&Mb9c`am9kDoFDB6c&Oj-__CgA0Rs-?jyq(ZT#GA!1T8%LG|acinZhR6dbRfn#WRG!+{`krY-?2NFjTMxnl) zIFw0wTXE>|MDr`ujsI`9Yw(hA?agG5j>Ink{jU;>EA`z z#wFZEj4uj0Ge_q-2Zo< zlc~9cGJBY%;&)9_h9d~5j+A{L23#_k1rqShaKAA~(hbt|Ao5mUeL z?~6@|Sv8Tcol1=LV0OyRjINGHU~@i1p!XeHqP$m{zO8|R5K0gt+t@%malN=ESXiU{OG2!C z58pf0s(xw=U=9ZF=R>vcR(zuZAOuTciHRRUovu*Ehu^Y1V;0>H$>N>hn{(lukUDd% z16p{rjpt62?{-`hwqOA+Ah;~DMX8#;R%AV9ZGCESd)L#mDPN%%!)Ywje0umCEnjyD z$uSa;x5`fp91s1*7VZs0jFq_q7leXi8A7C#ADhIFKnC&x74C+5i@di77^E#HisksG z^V%}yT1aoYVfLcFg=75f&x$|1w5KpMa9`^)Kt|@(;m?64FxMk%w~1{UpN^Rc%r!L` zPyblIG;v57pBGUBUMX@~sPwE)3eaN&C?j|=LG{Xd`Ib*GLvj0_igxg*kYFG0QUnBG zLj00+6nb-!p#sf9=qJ!bPiI>3^E-pjJ{j(V)B8AOqv}jtZ$~!1S^EVsbLzhL>1Mmp zuEE`B4*!8{LduMbr}aP7$Iz2tOF+G$gBo`cpLIfGu95rKqinoL_gMrH5? z_8682K=bZY367WeKypi`^UN#!snzC@6~N)AO+eJ=KiW6&Q1{|q>xp`4?0-Z7)Zc+- zA6mOfD{E994CE#ga^(G~@XyD%t)G(zKFd=2MLT=yII3q7Z`I{Zr?#bTmi`n%9i{rzSbP75)}oxn)yJXhJwkV+IGX zyiy@b|3O{`R3@U0l=_gk*ZNPLz!AVZGnZpHLGy$9-D7tW^s~!v1hD9YLqXuRqRVt|vD!F>kBGh@Z=*5p5n#?$D9J&Jn`s z23|?Ge9LvnbtSB)XL4EtZ12W7-p@9f9u77U?B}jO2nA65MDV$+T=j&D;ozr3#NCbJ z&_3hG%&ZuE{QtHuWXr?d>Q}=4#+z`+Bx8Q-XdUyU927Ep24Jc0A~CU4+&4x3t zl&|-bVg5iKdeW=5w=&Qg1%s6Ta0!qo^pd>}w|m#?(}U zdrSe+lQ?^{eJLH9a<1WcXDloXxRp5XXj6(?sx#*I4)Ij6I&qj^hj;Aev)mrAJ&^YG zkkueb;wnh5fhZ{O@ID$D1ovu)2h{ab70@P7EYJ-$u7~wfiB1Ciux?DgQIZyKLUJ>jU2?}YTfF1DO#4vA2XErds z$?OnORm$;$33wk%SY4-ZL1qXoTiukkgcOajx8m97G9cQXSCDZSWFfYqnQc8ZK41kl z!g!h|R!|NrxP0GrbJIk}jI=VTyFz)6qZHt=JcRal)$QOc9}-=P(;SfFf{7yvdXf%@ zO!hI&@p$}7l_vlM8tv0B@o+%-l%1&fA^aHJpE^N z%2>cFq;aYsFP)|;$Fhelrc8-W<)UESK_4PpO4thRz74?xND>?Sv%LrSRa2qSG1MVQsT#yaC(9X%ciIj$ z!1~$T#nrIEiADd0W(!nUxRqD~Kzwcj>r2Q0_ST^u|I1A0)oDn$U#(DLmlK=5`fwCn z%-4od_ZaU#A)T-14#4jgM1AQBuyZP>-AaZ%TBh!jNGUwH7ZJILyJ%pA*P5X;@*^f8A~MFpcoC~_k5kB?*0Bg^Uv*a zyw2UHqsWCH3`lyGk;^S=U^={A45+NkMd!0uMykmO(6jiK#E zT3lSG-uCZLL9v9HRS*zK`RVZ89?0KdQwHCxW8i{8=5f^J`F;C+-K_JAm0B!K&;hZjFi-lGcTDNj#TTiFv%UXHlT4&BggVxOp|Yc?{e<@Ti7;5=564 z4vr!OnXehf_5uIhED+v%+oNy~r^GEtg!t|=B@S7xXi)5F^edHCx5{>qtx#|V6Mv5Q z=H+r)shF>M>>?awESvy1|CW1IU6njU`@6m64Howi=emfzFiZ`YtM%Xg;c7 zk)Ckv#U?B0ifcz62M;GxDMX>lUVsYyrx(uMgBpGBI}DHnMW#n{lCl2Rnt+A z^hJVJwl`p$o)V8j5ZBTl%MPakF%h2qlDrTL?CgV_4)4Uskj;SR2R6S)e%)}Z)Q1&- zkt>%bfbrUQ?VfHzryGc%7r6okv_`0F59a@8ds!G3_!fsqyH!tLu3bsH79uwep`{UhM zTGK#fBy#-uU!CwyZhECp>W=Lr%AeqAEx4p0k%7|sZN2Ye-zjQ8BTvIQES1%BSW|rv zTxWC*Bgo!aNM%RK>89LCXa_-2uM+lKtU|(1CUolk<}=Lg_m-cvpx?LvUCwg_=b?eT zrMf~HS}4F`(1#-X(@pj9IW438d=igfB*~FnOE6~>2;ICF0JcIr#8*sn~hR8XG>9}?bNH%xf8{5K%ZF$R?S3=vyaW}k$TK-s|p7+ zMu$fjq0`^l?8|L%ONKfKZW2pp%L_)zQ$I!q}7~n=9 zp7XxfKB*+)s0~IrI+tgre#YP;sF|XR=a#45!6z1baW$1MWzYdXpdyhUUQoIyVfj-k2$twD11$vKg2ZzmT)tuE zVR0^@?@0xcQ6xPAk0n-W^7wU|T$)byLAjFDsdpa42Wyms90=(zXImfm!g{|IOM_+% z{*=g#X%u%jfe1@LuVJaA3+QM*Q=Vy7)V)nfG)?|RHjxd-W-t&VByE#@TK;r&L& z8aNL0!qRv7Qn#ip*!n}OOUCSGUTa5jou{6ZVglI5ncptD>PJ}}G#wsd!8d^f4T%{< z`~%hY%B9)y>WSG7KtQ3ELLU_eAgG4r(8_!7#V2tG&}jd&^%5E31tTn}XLf+!DL|G| z=kPo5$3|HPP(@-t9oXIaUHJz?B6)AQ&h|4+KmWEmn@ViwJ~(;;MB@$k=}X;#ZYK2Y zp_d4-H`W?etBlRBz;W)(&(&t$pjqtwwq_j<0#}@Wf)RX(%^y$XxOL$AucBGcPs()YcEw6z#<)SzGc6EnPBh@vH1^T56u4g zR=G*Ky7@+}88K96ZcBKFYd#j+s8j4-u^dHkQYY_#*FyE6Vb)SC>T5=Hbo3xPpdNT% z)-)Mpx!6`94qA6^JRSAN1X$zC(KxhZAd*_^t~+jBz&=3>9xE z@=%$^awnoElW5>6Gx?65kWS)z6n|3^5~jn#wciWvEq4)o$xh$z{hO_O})MywFT5jl&UTCeEn3z}-QZok=j|B0(_(r7p;LPx? zGj4%Gx%uGbsNP$Pj~8nJt)bnD>$^<-7BLP5&qf#yh}EJ-$>5XqUH8$hEWtP_sZf)` z!5QI=Bms|FBERoa37!$CasbZzD)Jm>geOOw{|~0~a=K7$?NOyuY*Dcum6&B=QJt+2 zsj5U|Mew5Pkm#~d`@*>*-iEaj#YnDj7^Z84hy1WC6v`%Kdqh89=g0+z zB>y@K07iIW3FIWVXT`a5oF@c14vziJAsQojQD2K1TTd-wOiWv$vzGia_oxqeI@$v3 z)&VEpUQ387lt#Rl3-S`S9JVOAY7r+3XGoi*Qp!|2#uJbNZyf$rtHu1PfQ!q{`d z86VY8&N%96!U=&Q!F65G_Y4ih)p>Rr??#2C)>R3LI1ka)?PoecaMW67)p>VK)axQa zEVhQoeuHLI_3xm3G3F)jDSt!_UKsgV|D7(P#KPshQBjsuC&CoF%V!tQ_lA(8h_|mE z9^V0SSahde*;7&ACVSuE0T*5a+avd_Zo!YO+vk|i>wfeVobe!2pP`IEADOXRdUv@jWT$5atka-vTsYV9Z2N7H z+Nl$oTDaS&7yvtj3)X{4R>4aTRL*=k5-O&?v-JB&5kKSWzv(muc4}|Asn$aoN`hBe zf31k!EPF<=NI-8q%cF3vsYsh40uLneTW|=W0q+i}=*|@VWsOd23hIqRdPUk!FrmNS z0E=#2$9p#n$DyI7K?j&bBfL=+SS(VRV;6OVpyRHnnmY%b`d#4KpJv=Z9BEoA5+iU@ z;sKoKNCuE(7!%;P^AcXlLbHTD+f>%rjwnM)mbt_OHPQ z)s@Ph=GT2VP&njpP4so%Nz>=doSj1HJ&8TnT-IZXuP52G(Hk7yKb9ha;IO! z-?EJCt;W6;nCt42FtZ3hbtuuP+XMaZ`FHt zTodIlt>5-}EwkmLHcRZ*hNe?q__)zH+NEa%njYDX+E1}5-gbXvN{^8j=GT3G zu}hDvR+DB|?yXvPGQB{Iu?)R>17pX8(OW*Q#jJX5>8XY3ehc1y@}S!#h{8ZFQExY9 z8wuet$Ey9kxn(ybFta}Ywu!zp1M{N|hgwMbZ_$|AM^*T+nDMr@Hv4{b{???tz-2Py zqc&#sO^12BZ9+3Ga;KWe=M68<8-%70*tbnY!)?)rP5|Ip48hg+QGp0IRo|EEZeqywuE;}W;sO(<6~xy3=a<*xxuxRKP!#| zX47?*lLaYnYv>-*2UP{ZXGl{IVG6@Q|DjCRF*<%LHrymibX*l}g>)Gs#{ zqM4dk6fEG#LCkEBj`uK%zI`j}A-qEDQkP!c3*?lG(Yr-4PI4ir3}d z!j|$`UR(pZSeie>AvndcpP7CVr_Ho&{Wnjf_GHK};=lUhaSJaU=K1yJx63^i{Zeer z_KUd>v7F!z*r#hXaCgQ$*jcy6*b$oa?cyp@MnFqYT4at_cf>4mOz+Ulmi8`!P#AvN z_Bn>sC%NjNfO)-3+r-8+1NQd{$L4@V{E-B_>XD76hFdcgT{O00F`HUzV_W64pS18E&8>WcMC}4mRRc_A z0}-ibGCptnEXM>dWXUKEywF#G*iC`gZo+`nX(gXe-5uA^xW-26sjhQLXDlk*1$-^aTB;F7%2MFso)y3Q=a>c4XFXBH&B7%l5h zKPigwDyvBPL8i|)OaB${M~B${cqw!>2dszi03Y((%}2w;9t{haFjZX(t))*g`D_P4YD!Iy7KxNCX z_5TBCNFUcqa+A3vd4X%47K!atnA;`~kFG#_n!>4M6i1%@Ycgwt|1MNW78ve#iR$|T z7>dTkEE=|bR$|tICnnxCemnC+f*Mb~(4{wiyFd&h|BIJ5z{9pOOO0WF%)<6Rd)QKf zmJf~?kGq|q9w6st0y!xs3e!(r>ACC}u@$THHyL~61uwYU6>Mj3@tIuHt$8D4$_)n> zZ0GU0nTE#7TGpbiH+x0)Q&&0?vC$%bW-T5^!qbi0K2K?}aqQel&wBWRoOPG9K*eSI z+nF!0bC%O`>iQ!qCiy7r-^f(`&c>(EXsr0;DvbBhmhRa$VGpY}Y&aAR8{0=y_DSl= z^Wr}KxC=LcktDp;N^;d60Tug%fR@y&qOZIENa&w_4AAiBNJR`h6oNE2&EnV%eN%FAwZK|#>Ry}clbjmI1*Nbzo^-q2zNu5-2Q>ZBe<%3K#^dKpaQc#Wm89*O z#^a3@t4}K(W6-9w<(ld}sq)ge{>EB+v13^5K(kO{yc1o#hLhknZQs5zw&h>7n0lep3;xMwepM zkbgG9h8KvbXl7jCR;kx`pjn0#?)N*aGY>Oq3W86XI$WvZZ*O7$_DLb;KbO!ITWrEh za3|m_Y9MQLf82xyq%}#F){Uqs2G6G))vNa`-6DM^8ix?QvV*>fNdu&f*DqiU2MZ+C zu#y5yf2=c5sEy!fOt-qqrysp4`o?eOTRk?isQMpOCZKcJTo5_jb$=93inhrSRxw=k zOjup%QxmcJ4YeDvf{pNc9FXNEwKkU5;i>Yo3GiN^F2Ll z+WJ6PB}g`YP^xfLqM~bjPqDI!_kTDMOT0T|W^LKJgIMptGcRuZ$F-cm@Fd%F4i~tq zz-;2?xY>4H)YOHa|oF13{;OW?!mqH&>u^zz-;KXYV zuM&h;(W2o{5ouN&A~~%|ez+F?HvnPEEbxmegyCYWfA=^i>tT)h)g62`*Q-PX<^f^L z5pn9ALbPG0Ugc%{Nj2=9FeCD_+ML}hXf9L0jl=qvgucvrcyxny`G|Ve9W-JOkq~W) zO>t@&?2{co-4R?S9GW|daqQ7(?WC7~2%W%rTFLutukjg$3lW&{cUxa-3NSdiDedr7 zThX^+Sn$q&dCHrMN1~{}e`tpONu5#s;5CV*h$-n^tKk#-YS1=OMDR&Ci-J;-9&`Mo zExfV1lBE3Y#fXtmC$BL5QT#2p0ycL2Pw;J-M4Seg9c$H?&PKa@$c_9WN|<8>LHBn6 zy{>S`dyf0213rsO8gacB8S=4wXY_@~`dsWX{r4g65WEk$&4RhW3jnlF?eT{g)_s&u z_NwUG7W#aY!ih$k^2)?-n^GR0rZ@)On&poH{uSGVzy-e|G63|f<3OvMEpqHDp@3S} zI;dja{0|fvJ#wO|SUpRPS+S)qG{zU}@&p?)^xT5=0wJHHPu<@L?lq@M6r}`dl|i=o zGvxjyt!4m@lHvDK6HNKV_M;{LLu7y)8K+(Sc>vle-S7Slmur$@8sUntI~!qluH(Cd zLXq4Kff>hAWCq&#;RhZv-xaXNFZbAX%4538vwf|hKY(p#MCVhP$%rS+AZ_A#O zrU6$vtz5DCZvUudrSwe>CK0KExhy=JSfScca!~|eUi}wh<2mk#Ne&)+DOz@&pOG0eXkEH}abXI31*y&<|s_bLb4 zw%nYMyEwSEPHz7|R~Q!9x%NG(PgiJqsS@cByHL3QS-=1`nZ~GHWJ46C>ZquWQoB9w zO$(SSeRvI88G+|wTy6BwWY8NUAwdDaS<+L1uE04DwW}Pcd^e|RJAo2c`ObpVWVh@3eQUR*lW=Ngl5Zc<4AiS+-Khy6p--kS&cH!I`Onf70*S} zneTwvc9{}MIVZ4taS0dkeLjj>cus7HPDRHz1I0$isY{YIS89!+@Wr<(G10efbPqiN z%6yeAsqr4yET=Z3p;VbQEd0GAR{{THMD{QmT;eemRkwf^Z=-0IzWV=wO+s0i;q$vJ zWDr}%loXQAI@Ya<%?>1XPTUMbPNHH`XT$2KJUwr~=SJtmUvs9SEUs9`Qetx79KaG& z21LHtx;32z#&+{g!AN!DHk4Tw9z-dXLa#8mN`>m5L2AQk*HN7Xbx?0SCW))YzSjBW z`?#kYyM`!Y)LkKg{1qFv&rPwbNdQ}(hFi`#U@ko1JC!aFc?SE!4I@%lj+vmyqZtCu zkfT2Gs?dzGsE73&>VpqdudRu;6@z_CRQZC4j2z(Qcw>|Z!(n@sxy_J~kmF%Ua}UJ= zT1J59phyg=Ph7}7nqK{*RHNxT}u@s z9|ia{L-6VPC+?Q;pjZ;hISdawSNYMRfSaV*mkRysz$I!u>85QH;A?1+j6<+f^gDSY ztJSbc-)i&Q0Wve?D7iJgy(?XBh{hv8s_84L1j8U{RKK^SgDwJj*F1$^fAgej+4$co zu)T1mydvrJB)NUXC4NS6F_iS?I^{P{n*P|tTw8O>HltQ@)r;)|g0verzoJ-ybhU4+ z&_@L8{gUX&_8M6e|CB+M8CjlLr&?xS8A7i1f^eXk+ng;m#hMjgozgBb$zCd#EhTX8PVI02qx`pg)wsC$t< zgmz;OLQYZDjTNT=Y8WOsoT_XG8fN6Tv}^?ynmj;iDI8KZ61t-ZI5C{)c!ya+)bhcL zK!K5KGUQuA%l1$1DfjEC)I2&2 zi14d`Sb9-|>!(0awA&PXrT)C+37+vt`Y{2QclAkp| zO!5fA%xN2-skuSdJ16=fB<1fRIn6B1f2X4?B$I1|&?JURcSH}WiUY;F)<`4^7tr{m&g+bx*XE79`2{#sx)7K z6h)i@s%-3T8r67D#dL4wf=ZZXp!hjhuPE-NvX-3oLqa-XITBMm4I8u1Z>4|)T-!n_ zDjb51X6K;_i59=x?&6E3*3EZoH3C8Fq(A>A;;spf@nc;bAX9fSPUb`|YUvFd6DIS7R_Li#? zeDW*}=H|CE-iSNU4UnfWdZg49g_X+%6j%NAmiLkP3`pghy&l@b5@{>|d>NKgYVuAq4<$AHw#; zyDg+79AI$v@DokzYbwY%38Jdj-6r)K*Hg-5U)GqnYAW+%oA2XbvgtQ@up*zQ-a#DF zZg+$%ueuLm)m&9TIfaHABxLN~2Tfe~$bAAnuG zS6;f)DS|3%e1=sUJb~Lte+3Uqq!BS$ZQ5{1S?w0LSV2lKy?R^_BjS5U>m^~vZuw_Q zzjPpIPMJOf!LyhE$@l-d`xm_iPwHWv@>{X$)-9tWww+okF2721c{6-6mXew~9p3I* zhJ8^ORR&I*L`pU4qn$4@0l5xTVq82UW2J)(fxf z9D)58#P>f@6pw2w=7$2z z!hj;Gq`qSELjXJwg2*0^V4Vb8}Opa#pP5#h^f#%FIY<66hcAm%)=M+{` ztHj7DzzcHc3Vv*kvDoYo49Dd5pQrXhrr!X>L^Sb=*l0DtKbEMQ7KXX9Ap9rO3`~Hf zD60YGxn4FCk_BS)oVTp@e)|gdTa2?AW&%VR7q2OMz2XO3|3bs4>@ebD)qf>Doh{ee z{QRO|4|~HU6vp7t{Fq7V7?QTZS!uQhx>6rPS?&_t5e&S#ni+s}&N*(9nG9x8AA9M= zt^2q(9h}!Qs>9#yx(^lT*8ow>BnsjCBY|R$X(_dzPerWsDhD`R$BLRaJ1&l_?t7}T zpyX4Z$OhgVS*_m-Cc5!*zu&Q?KWG1-*3Ma|`vyN2s)`TBS*h8@uVUdw4m5f){x=l1 zjw-#To$@M%vh=~ll`wHkw**!68Xy!>^sahPo$Qpt&vp27{vb#-XoK(yZMZDlsdeiN z$_DI}S2=D_4TjMtp$j4+t*$#TLC`P7e-Pq+D8{O*RGFLI(jtBDaP*+pW=lyA_mNqM zg>E6HB+$L{J!(?1HY<$Q{uLz30SfvrD(1{X)^1c~lG`S%(;`iFAu)wW#o9WqT9LZS z0A-2F#8AuMpTt0~Bb|O*e;g^#^cabhi0lAJbZb!mh*fnle=G$VDTEvC&mR-a;)CYJ z4_$c7Sj$%r_OG)5*YGWzKAZJ$g4_<29_#q3#srTltl(=R(w+A9Dt2a(0$y=azEybB zTMDj;)28N}lMclc)4Z8#ud%SY*(Itg9NM**3Kd6NwkG_N1hT6Trc0|LML!qIHuROdp2H*#x{2-qQXGkQS3^J{-eM z5)lqT!5s4+cyYlqF-`e4LyYl88e}8+g7y_PfAYqMeb4b736^glzxbUx*%EmD3MSVP zhHuCbdUmi;_n#>SS0_5}+;Y)UKKYanWB-qsp76zd)=^Rj1SKT!T=`M6y~wGMfGO;E zqf;{I0PGh~S_7Ze%GhR@lJl0cEl=}GW6tWbFaJvIIibXUu0%^+D%fyd!n<0hoGYYD zszoOnC@)ab@`(3KMqIYfCG!5{h5VS=9z2pt==}QFX8&Yo2h^>; zylr)dz9`N-jQy|QDMzX+noEbIaafYb+c39lm}1+e3*7Dzt`f858JXgn1xSi##Cr7p zdJHRp2qB~rEah)cdKkUzqmXm@iDJ`%%RLb4IlGO}vjG^+3P1RA{;U*vI|E%7nX2H4 ze4N+Y+|4B&$z6;wEO3FH<_$G04uBZzy!3d-vRTtk#mDe7C1xCi`=1J<2od8|l~#0Y z2myb`bRX^!pRD}I3!FY}r6sEP4=Op0q2#P5VTtrTD<-c^bdtk>X9ejAVJ4SwjMH6*rel#WIp4ZQ zI$b{nicd0<5Uu_^k6ejk0G57l-WcSy04vqxc9ne8(|t))L=a4Fghj7_MHvn{t_5qu z!Glek-MXgAJPSo$lCu8-$=~ek309KD`EXGWlZQCZ2KCUeRuC&7UM$f00t1%TJVtaPO z_JC*y%2Q&*fQU%(!npH?IjpNwF$P4Y=~1GV7qXSxT^;7)8!Qw_&^K zvx~VqFwWsz`Qf;jQ!jAs!#%*n?{NMM6n_}jfr~NNA{3fJ=hxEAQE1W*5)g0QE_@(6QbAO5Kw4JdP9$#<0TTOLTRI4Oz!j=LXx$1u z*HTVdgl{@)A@JpOOxNktN7aNe#dppzTqCZEWF$)rbZCF}Z;?*j^<~|O_=pr_IH{@oPG;BQO3eqy1)pnn;WeE#qf+wxQI3{hZ~$;XJ5*m>I(@-87Vx9MNBB;`dEa8nfAs zdvzjGG<+8<#4JF^PU+Mbc>K{Z9n5S`mj#)hSKlX6n+2^nFc?d(;%+-cnpcA$O(FOr z;*J|Z7F$Iw%bNiQDuJ^vGzDU*H;`j7U)K1HPdg52E=ReP5GhM zZb?ys!YSoMBXTd^`{3MRxsok>j1@b`zNNT)r5F>X%}MOMb^NpOFdr5U|KK-X4PFs0J=(&cz4It zVd335TOk>#2q#Dy4feTjF259M)dw1M4S#EkYFTOi_6|{W8^!LRdvzk?`%0wY8#Ox) zL#pl?H$L9+=l>F;Ysnh!+qv^G&N6`PX@w(C_*RXmm2J5Vo=HPm44#VMk$69h*9Jn) zwCqoi`>ZGmXLFIgHirrCuDJn*GIfz;^*~K$lK=MnX|F zyNyNcac`yII!wj5lGGBdG#sb?9<)2zu~cE*!YWeI^IM(*SmpS~07{Tli5AVyOL6=3HwgF0+Fu0>?^L7+2yCAZMv&8o~0Ag~>kl}9W^ zRc{OW2UO=Q2+S@=igPyJhsm3L0lf#-V_h5Af!O*Ve=5=0ZGmhr@~2gu8U|OWh`4kr#vPMtY~B#!ptXqYS)&%JUw{{Q zG_{JUdmlI%i9*=;k2P;+fU!Ai2|TM7>aCiVhi9rlJlUwGDPhB}dk-Z(^xgMu-TeC) zWnq@OqJyrYuNFFXA`-KxI3J#q1WP@4@jBL8t6KKD8WIR20mbj0pSOC7;Q(U)8Zq!6 zow74$K$ycihBeB3dZ(6&UxFKDHgOjBZDq1OEJGOSPK=bc%S83C48tXD1b{1LrjOEbj!R)g~$MZ(n`z_rW#zoq-Ef`Ln3 zb3V8(j}hmioG-!iPK?NiP>WVW#7k&{!uc4qPR2oGdXTxS$w$%Y5Dx`*naD*!oQLi1zHP3S3NHK;PYp$B0PFcJYpt=0)`h{XjOCw!4x9$|o z?h%Fu;Bnm1q@hAxt9PA`J+y-37*(;X6=`9VelPXblVN(3Nm)ezubhr!T2j{mg%ROL zrZ3f0`m5bpeo$XIX+9S0jC74*uB=S0rv~|`{y`bmacO9T;k`$jbzj10*PzG8ikNX) zsCnv5Gs^1U?A!`o^Hds=mZ5CWNsZj4VspJkWY%k>?`J4v37ZR}+$z^;i$)<@K!LR{ z^_F^5#c6)U4}GY5+X`C`JtvA_g=YdNe;DNeoUP#m3y-VIWDYw$T@J6Y>?8G&Yx+1c zkjmjm3lfcK(Lul(y~%69>6c3ib2B6_1Yg$^U=&V!A`rC(cy=|W*j|GF!Vwt|?vlY} z-dvRZ8y$A0?I|P<%CZKaJ~?M#h1oo=g?2Y!QAxShp>SwZc`RdHfdk;xLrqZRtB5b+ zVms+U)E|ojixoYVyQ${vPK6TFE&r=DM!5t`VwFd+<&Slut?+QOIc6Cu4aCjnl@onj zWDl>YH49+t*VHS0lN=9Jwqww-VB|}>MenaH)jZpcVJ# z98mvo1%8j%HZ25p|uWMB86NF}0mxmxzf3Nb}}$AuN!Y(cdiL2x{IA5I8Q zSIDjkD#FE=pnd_xhCY#5-jVAfM8|*C2`LJ%6h^T5b>6K6nm-tOb*{n~92GTv7L!Dw z^k+deg=?&s$(1*H7<(fS`Ec9IKOYB0L47Ld&lfi!mJkT?`blI7I6@Fp5F$d$7&ntW z<=BoGs?X^sU8^y+d%W^koUx+V=oXQBR-$t%B5(jPFw;E>g8~?}TOYt&(Iuht)oZ&2 ziY?$AGsCmFLDWjmdkYJrJ09MA^CV~KzSR){%V;l`+H5>izxQvQr8d&L{MSk7oJm_} zvuNqQrI`}1!;9ZutQBNkY4a?aG5lzFrq<#uy`{~mRP)~5b0b2Z;5Cmv)#Y4F{ZOIn zm7Y@f5sdY~qa|GH@eY2DcA9%lyhqocTQGLWY3Z?qte4diKTatNRCdZSpAAKu4y}dB zD(?ZahTh|nVq!-mmAdD@^AQP@yf^cz=#ntz6FWEZa@jmS_82dBDB5ypDWmoVKhKhG zeJumSxSq-eync+kNi@4mwaK;ExAMJ(Gf;W$WnhAi;|ZgOnJ%qtiA!%yD||Wvmch5@ z*R~5K-uSr9jm*oK5XCNi^lTA}XWZJF0m7nU4LwWP(u^6D0IEi)6l#f(>grcK2c_XrkEsp_DOtk z)+<3wOaAooF|!djD_@56rGLUI&QUE-14=ORZ^|_KUU6QU>IPPDn2aMecvwm)fnPr^(u!*VsJP5;t`VHQdG9|{XwY3g{)m6LnpNvil zSPrT2W~u$I5S|RZpYn~RaOn0MNEWOm9`SZBW^x|xm#YT?$RKFlwNgD8?3oT&FoKyw52~d#Vs<#JIOC@qi2{kVKn3O0nZpb;| zQ0&v>Y;(9xy$ql3tB>2nCD$j!zqf!Ve7vmX$T*E36xwgpV7D=ZyNoKH)8M7HcX|Ms zlQwlD>#-flwmd`zCeIzBE4G9eWFcdWtOs8>|8biqefG&K0gT?7H{)~Q^gOn1`o=Pk zbu1w)rBZ{b!z>(%{@~BsQ^&pOLT{*@n#6=0lSbv%J$@l8^KC!Se$me-1KqBuHkO`q zR{-0`6`A_=7Ybqw$7Nva-TgQB&}XyI@Pp0a|8tMSQq1~q=EfdqdyG%L#bwb{yhn4m zeC=q$)vT9Be$qnUBK|Q2FR@2nk~5p`E*BH~l$Z3#3AuIShL^IFE=}3=eRgWHa(*U+6K=;y(IbcNAw$lK?h z&q(3V(dT)$hz-E72?q*$b(k#>IJA3!j!cPq-uK$IY3|KHBf@8z^o0|X?dg_umliRx z0El(0_V~|GRySq#{-f%rjyH=vVu^wul8i zkA`Ufj)MC@VS2fJ316dZSD4nH7OWTNC)41aoCUQUo-^2@*N{j1xXqfJ)xATM9a0pl zDPpTb5yG%&xc?sd9x!p<@E>4{1%q_E{F$6gvLcC&MK_YNtVCG zv}ByzR2M&ao!qWlrAAR2R_E#F{Y)8A1-GhFu&;9!`Jum?>s1doD834NmIVM>of<4l zk%O5IN{;GDJ?&?;$nl3`l+rf0*r|q&pLv5Hx5dOdW^Ekhqkgfk7*WP!g4JlM3E!j} zED8kwEza|-_#f2*xtp>9@~N&cY$7&T7GT1X?r>rR!hC*F?Alp5ClBu140{vJO>nM$ z!X=$g$SVnm2q1a%`y;SMc@zC22a>IcgV`f=CgIYJ+^ZO7xR3BsnXdB=*r1rKmH?TK z3>KaZKFiwP2ImAiLWlL`?ftJiU+ei{e34(Y-Lqn3wZkhi%!fT=)Nsp9nHG z8k)^%tGVE)8wp2kD&OUZO+*LFauN{ETpx8_jj>dQ)Xq+mHK@9j?pLC0fk4U9jLedg zkqEj5j2iMJ4QAec|6Kb8!DOQx3MV|$ew>@+bNv&FxRfce=-P-lQ?ufx@=oX(u|3t# zPD?uR)C2{I?~-2i`NZUe$fW^p??1t#vd3HE5GimB|)1R zr8b8bKvHZ}w)phoyg&fPC~kEPjT`Umm#iva%CVii)T5@|g&@7GQ9!m(eWlbj|251p zywKF|OEn3@WYGlgp8Q}*?|>{zBOT{Blpg6Al!Q{ZPQ4Tp8B^MWcUX2KW=Lvx1-R( za{6A7>&aFrjRNpkPk{Yer_9-OlvHTm|4CSl`Vii5m+mATjLWib5leswL zZ9dEJNk#Ra*GXD{6qb@oQ+fdX`DOS%MwoSB@4C3ByvGO_vG8gor@YcfD77XG)+=v1yP`^V{r$^z&P%4@X`#Df_?5gr_7PHE5J5)xQ2B1zI9Nq zSP={IzurRy|7SgD%b)Q%dU~EuqESO8efl6|GmH_@>?$&4Jx2`~qu{|Ql{VBXJ@>n$ zz&;s%Nj)es5d3K=BKU({5I0j4yqz-Zfe?J2PXT1B4O6cGXfc{VzUecQXMqnu0?W|Y zVRsC+e;o?*UYDevM?=v8h$>0Rfzwx?OU51yc0j@a8%IAm`C(mm-8$(B0SMBNNwO8$ zN-v)eV;N4bt#=@4KgowLda!I~t~>XS!`CjtqW~&gAq2=W>rV2szPxhhc0QX}K#@t0 zqz#a=H<^Kj6XTO&_1&LH>mpe>J$GXaNqp%5jE*=P0HYZ493KwyoI}6BTT5qlIMSV^ zU>Yr8O>RGw0C2DX1{Ne?!Fsd*AiFGJaexHuAA5m2#d=bY5ca_1!VhuTSNk_Xml$ibE+SREkj-)z zgH0|#cJMl-)^FcxDNW0mOcHw@aT9!sWc&8cZdmW(zn~4Ld>dRO?hbF@CO|YMT3$o~ zm&IwOmL7QJdX+Zcu;mAq!{$ zJ1ze*pir=&mKtQc@Ha}#H*4J~sitP~&E<}UaE~x#yGuq0Y4!rrWaH*bOzNO3?uZ4A zG7NIC)}QE48DUTvM7J{9lTz&R&Kq2YJ99k}RC_?Mfqj~fu@rz0_0-vPTy#Y~(GK6M z^3>NwUWBEhK@Qdlb_dwW&u3+~$-P`MED)W3@`D;P8}W3Eod8?Fcny)CQn_zd*GnFr zZN_f(Qs2wp_$SEEaJ+ff(CZTaZ=K|<^?38tg%%XMJ|1OSxoE|UP$W#kZ*3k zu&?mft5Jpe@G>kd6f&0!eW1Wym!Wzpp{IFyHBCFIeT0{bJRJ*fo+H|(U$y|UCjL1S z=OE()zsPa8GNRiU2X2VEWpziPubBEjty(d*u_<`ja_lk* zO3pEfe&w!dFbYEtQE{pDa0z${>v4KczZ!E-xhdxFm+E|Dl<6$0E?}4eb_C{O`UR3X zru!;MH)>k!T0R60OZ^TngdTq(9)8Zz$d{DW%R?m;mU01zrnkn~>6o~qN+$)RE^$dg zWR0bR0@KF*Hy@Kq8&Kk8^RA7={J~_6v(hpJJcg2WmrHx z#x6_GxR$bUioKFHb3m=)=i58ZwCp6&Q&}J;x5K70a+`Zcd#{WB#n0g5$bXt;pRx~H z3X{@5p40=ii6zPnY&)4Lvm&x->k*Vc@P1%?;2Gp!ZUe6Hwryh*$rfDDIz$+&K8#8hUmnW0$6o>VVY=)8 z0X#D_L}%$RHe}mA$<*SnIr14N_8`ti^|22u~*0@E?7LK4uF z@vO+&0GkSb&Aa{I9rzTKs@e;&=VN_Z1RK29ul{*FHiFmmeZYDp`hJ{w-2Wi=-;+b< z!Z6O6Us)a||LSR##F)2Q#dO-R6c!u;el005lM9s-?BR(>xVljszOdOkZzZH5U*jyi>EL?ewn2zyIIlEANtVTJb&j3V zgj8qQ&d-QF{~hiVEZ2hYDHDM!Eo{zA`{zokC%Ulpw6eSp5H4_n+5Ax!A8U=N<{vwZ z{q$bdbfw>eh3W1<8yhM_+)fO~o@+h#$ABMUqAg)E20T-sy@T=nd+*A=na(A}^bZ_= zVhX>MT8#d18W0_s#|-6NRTkAgXd(QsI>-mf&UF)I_Cu+4-+CQUU|X8g=eaZW(+~MLxCnHvH+d*q!jC9?R$GL9OinB{<)3 zZkjN72+@X#UPI3qf#W2A+$7r#+E0qW_h^zWy{f$(=cf23AWk)$Zc(qB8O${NumYTmOAhE9e&@NJWdO&gwW-kN9k1s4xEt`=YY^E<{W4d}1rz%pw1E3)T=@ zsiiatm4a#cG^UTBTyOEXcwOl-^jAJ#aR)$x7JO=*by!9F#~$Hky_AqB%MvzfP*jVW z>XQwF-8k%x8XLN+kwuH=EULcwmqLSKOH}oJb{jQ>N`0YD{|NYC4;QoF;Nzbm##g4C zy3nvGr5S9?2%pmGEd{1`r&oeDGI~vGR+mYBdmR2GM-2Umc?4msvsbf@s~=r4lQse1 z4WZ>J$E=3G73jE(DJa8&^Im5q2tCUhJrjs7I(_VeQp`uS-Ro5_H z@NfhCMmNoqKJY@;3V|d>So#s7%P%hpRKZWAvRXp__e7dW^Dmk7-+cSMD!2dIxiMG{xGVA;j9A;m zyYGMH!q*CMKw(L?_0`>;z4Pr4Efrb8DbI=}`cwY5sAG3gn9=(Dl2n^Ja2heajfj~P zsPN^0{JG<)h4*p5{<{LM2WkWbv#}% z9?BY^C)i*_i-{_=0-w06=TE!dZZ=QXDK>Rl`@MJsgWL@HdqKMgInEZkhM2YMysS2RD%;1AnMB54bPMIMp8RTuj(o6gDF!cIhk` zx0sLo@VLbH4&c`hx4RvMcq(CM>rHaqru?r`ulIa^I01uTgHJsx)MZfwCn^wT=i3Cr zN^v4L9S=sWL$YUh(WIo<1GBGX6M07aW?zFZ*HQM@vJOe~q7`!z=2Zs}f!2K(Ow*0pc2 zeSugHs9q}6x$B^;{Z~T4Z(LHT)O{lw72^1f4R_;P#t=s#oJlYK*N<4)ts7y$Ijax^ z595LIg7*JER?!bhK*~N|lCuhk1jv#5KX#FNuam42q7?G(^v4wpvfNmFWo^`(L!1_*rNI2G!J7W5dO(=L^pm+^2VhIbRpL3 zmIRso^IT?UWzSuB9PzvLyt+atqwo(+vVbWun2ic!j6*~hmIC5C$hq|V=V+7gkB}P> z1r#~OunJZ*FCzAQu(@Q)UjFBv(5J3OWjLLU5(kqeyW#^@yuk}KvuT$cpr zWSr3=1r$pG)fE0ibF-N1o&1hP+RtHiy#w1b2Hr|5TZe}y zB^xQ2!Q!(Gzx{y`+lGo9T#$j%nb;Jq1<>7yx7$eYMs#P))hTH->L(BqR7(NM1QWN0 zS6cl491K)JVSs-ibSi4HN=>_5a-%hF)+2>PZwLd5z{Yx9C;K9Rzs)vCbc$(*P9Wr60|Sh-8Wt z%DGU_5QH5~P`Ahvv{ux7!J$TK?!LLG(TiG+zhfoklafl!~BJ6Zt{K9a>X# zu;Ra%6kcHpjfP(_K+`9%U*$9eJtVH|-yX)8Y?QMF6p_V^%~B>jQR@$g6*ZX?!9>Z0 zrWndZvH?Uu5y}y8%!JsMOW-;P<}7hqfG)gDDvEv0gUT}i_w8v3!iI)8z`~h@sF9$Fi1Y%Whft!59!1Nb z%qIK>Ik=RzKEDcZ4z$n))RAUXL6cDkmSGF^JRv&%8nDtBJ<)9EvQa%B8q$2T=+Gzp z6`gRA)?v_+CX8`|d_uPav1~k6)+hPjYzS9LNxwz~-T&9sm4G#MbnV;E*0z6J{cLN+ z8utjbE-WquMPuC~3R(rl1yVs2k%ICIB0{3J(pCzZR9q0EbytMof<-C0tpcJVUlk!A zC{UqL1X+Tt0rI~ya|7`op9kd5+_^L7oH@&T&P_ZaCPdmz_=f2rqJgx#6n3ZCz0#--HzcpzvaK;Gr3soBMg(3P#SiA+&EKn<69acu^z-+NaS-3 z-XNRDhjsX`spZWj+vBf70U0(A#OFcww1&xaGDjq&H6d^VRp}RyYwBtCoA$s1<2>?9 zOSE#&us*yNkn2LdCq?W`qu#Ezgk;U9k8pyVhLQY1#mF4yX$qtGzuNvySiw$kV0yPl z7{@Pg8(_Xn`4T!uNqe!Owcam)12iY(gH4a(bWXRB4XL3rR~AGk@pcXC=|-ToGL-^M z5YG{J470Wq^#o{0&~{|t3ZxI5;b;7nMDQGH0A<71781AF)K&zky$^Yh*1=IJJMcs6 zZW2yeI*`n9PusEV3phV}ne)SyNxyI1T{O{;*gjKBcs6l)q8dtO&V}j3<4GtHh!}X9 z=#O?aMQ(-Bij66`AJ+;nD#jLTcvJjqWY~4pAg+NfAV>r# zi~5aO6ZR9h#ncqmMPvS3VXL!7EDQM}4(fHJ#Z2A!y=#3O5>wAYBM*RE(tE#R^fkgPUy^r(-R2%J%V-)<`O-4SlGGf~st8V&amVa9a5mP& z7y#5OpjLxVf%sb|{cr9sEoKVm$WbgoOso~HPf(PbQxxVfNKMus0%(gn03J_UWX_R5 z8S#Mkb}fP8N2H7<*WYNZFD)$LL?<6j$skd-(D=e{sT*PFE0irgWbL0FCJfL$FuZ!4IBca;$MRK^Lyfja*sWf7nhk%K{{M^RGyeV}Yw} zYHd;g3N?~^5s`%aCewy5V8d_pE;w)#NlKA)7$(h>WRv(t>;beH$GA^)cD-P$)&^~Ur)1LFDEQTC(u zwEy~YNc{d!hU_-l#J;Mo3cYw!eBIl3-H_iVaI%fq*s(fh(%HjgH@u0u#%5uG~e(HK?^R?ctz($+WSvtNBodL` zk{Ow~epy(bz=}nMi&eW4&9sGLZ^HrGeD!=b?oTOXNSYyr1~B8E&dDXETkMr6Np>LO zY40)oll}8ve!z`gH%Yo}S*V%F-U^64oDuhvABHD*bzNX< zb`@qK#RQ(pPL@SrOXn_zKDfVB{#6h;TzoS2KAk9U?_(8-Jy8mJQrE{e8cwOis?_0? zOwK#|!eA^1thPwM5`|DbZOfhTtflf}wgpTjaEUQ6VyJEVM%j7GoSqT)=fob2Q2G}4 zS6%cYcTWYFRD1f-lpE6SWfVygrVI#HA5Q5`hlGHC6QE$GJE8E*)HGLz>Fx5>0Df>q z(+7sn`6KpaA9O_Rw}$a^x*+nx{X=oMgA){lldQ=u`MA&TkXrgn7LThZva5UVdAQ8_ z=cIOJh1%T6iH<}je%m2E3m5(3esXWi&)C=y;R`n+3b3##73*xA?0#5E zR(;}AV)TiJ0)k#LW07LQu?>T(QT<^>RWa37eE_#Q=4=NZca4-G2u;!aA<3+8weP~& zS;~TNeVX~>Jh!WDAE_6dCuG)yfTmv()F-OVS_N*Wyci)&1b!S&nBC526bZW7EREwNqsw1j*%Tr$M%v-y7!rH$sYrts(LEMyV7nU z?28(g{ysj(aa>9S(k_)F$k>z)lN!E`}wBP%TZ7OlxrDH8GxCh zgVd>;&cbg_bBeA~y6sR!BB`lD(|yi{gqi60Hh4b<5UPpytIoM+*~@P_R^l*Rnn-Md zA_NXIxwn=ktwwj(h&N7Q@Blw#hR@(i>lkU)Lb-=ORq9)WP0L)MJ|9>#8czv$(n93} z<qQvD!3~P;FN?S2Ah`So{KtJ zw9eE>BuX=`b=f3SYtBW(;FAu8mJ!zznVMrCu-?8G|Nlr)aD?p zQ+tNQ3!mpKQN|o1ek7QB%Ux+rXv(4mjtdZY>Q?mr5sn)$i_`+h{2}924i%D1EP#{+ zIEqnkf+F2?f@HwM`Awkj7GPU0(j??FW=D_?#o=L1qFR@61fIaV(1LLBSs?8_=nIgR z)XzcN#%6;HG($iFO}tECEi8GBD?NWk;p`~<+b4Nx^)(DD^Q>1m^adxP3yBbt|TC~N+uU1Pvb947kqEgUAfA1pcU$!cft4n zK}rQ%70^gd=Z-YOn6!r$P4BhS)3C1bKm{NN$z`?!-3P*CEqhQgf_8o}iMb$gEDeO* zFjR{j*5dnwIQ<+yGVFEn(i97euDy5&9XAAN zvtP)#P-gWe_15{-rj8w~LndWQ+QJY*q_9V!J_T$$MI$np0(hn5S^S@*;0V8eCl%~y zYXmV-yhAr%#=S$Sqqr!nsR3d8eaFa7lbB23mg%%Ow z$y#tbP8~X6w966LRrc!xyS|1HwoTbeVK-$%sS3Vl;)JZr2fXiV5Ka^qfs$mhW=`un zrULcnY0O6J0_2uyPIf4fAbp1U!XUpDB>AEDoOhSrBxyS0t;Ev(pitxK$c6}kvAPqF zrnrpkJ)p8dj+G9G3K;)oCiZB?ATn+e>D5n|ZdlX&Iw^QdA^JFh!U45%ElIYe2uqA| zR~Cyy{3Q=+oaqVDISR*Og&XA9>#qDzMG%vohOq9%+YTc@q!Ac=PH*z??b73YI7PH#aiN%@i_!n71+in z={BW%65Rap)|)wbz2S_^s~f`W0zg(?nQ z;yqTaN!Nb(vQ+G_t`K#!rluKqReXcFj7)*(ru+TSink!fz-invw7z#rK5GWyrF2`N z2?Ic_ahDsGdNy~*cTe*C+!-0j%)DhTHh+USqHI?C&+4PetUl>QFRF_QOi#y2bB-D8 z)pg6v8RtZrkW9lFzit#EH8Ymb;j#~W-1{0ZB@J!iYz)(iu=}(n`j}=H$~ii$Z0NA7 zQO&BwgiK5r0#8SbEg576&D4JDj(9?ff+Nx@20afq1CeG;iE+>Bpz4}ApIk#x+-oEF zf@d2C1KqF@w2)%B!Iv;>+8{D_oLv8I?a z*~u=)qQ?pcGs@Jg|d-GU7X^wL;J&aNvcDy zSjb%95xuZFN^G_;XmiGogo9RTx^8Na$Xrmc4I3f4k6LbDV^)9*zO%A1LMcf#i^FA+ z&#D{>)cNsrem-gE0K&J$+uSx1Ac8gC`QJ3U<{Iuhd(6nVaHV%GClcIaQzEitS{4{V zlsgdkDoMeYs?$@T8=WQ2id4=bj(q9tR_E!urdKL74(l0nrkWHL87KNQg*uwtnpO}C zpDG$xp%uE_aJhbPSGl9Fm~G|?s1-!IQy97q44KT4cw49xKlLw{_w_KWWAX^YNA ztR`MA%zq2$R!?WGGLrNm{5&g27+>s|Y4CqSNxFrkGQ5)C>uH&tz zWy~+3w`qc7+~1{#{WI4=l-vC7Y3?O65yU!}5_$kd{aaCz*Q)WK5F|vc>~sz3C0}j40pyE1*?mp;BH$Byv``s9UtG4i z`SE+} zi=qQ?AhINjj~7_2+Kp{R;cSDO;4egk7IrpW7Ir58>r; z73F71{UU3ctugNN+fi2uVS}!({ES{!gts_B*^~OQ?vwL-ku1%vgnxCl>yP-n|H`V_ zl1(B?@{Dd(Y5R0rOS@BA=9wnu(htp|?kK)RJAGV<9Hb&pz|EvDdq*@JIAkOF7dvo@ z_*ZM_BMAZ}_sAY$8m1UM`MyP^i{9=(Y9Y?%_~NeMbR$uG@bEVJh0f_2d%N*~cgD8G zK&+6%dE&-asF~`wzg11D@DY=juP*IDnPedV!R~sd#lz+&<82kv6cgOTgt==_NaFm3 zexzx3uDJ0xe0~}H0|ypTE1qdmcF0|kLqn{&L3#Bj`3TckKKbZ|_w`lDF~A}ZO%>L$ zAoaugWS~@*?LVVh`=+3CHA||t{AFX8 z_MZZ<89b-6aCe3cV~`@g2Zdg>7jF62@;F<)OM;hiYEJf|$DC}?j7oe5KYLWxKw6_PyoNtcr2X{H4Vpn_M zlsfI&k_0P<;z~;{y1*-;+z^@(-GrJ_@8VPJE*W`!i2b+a>5$n*ahk4V6xYJfNf_N3 z`lgV#XfTC(IG)7R+cvml-8HUh{U}AkXSn~==#=%2@e=Vb1vhpJUMpmCDu!bzY5Ioc zS^tqpA0A#1TQ&0=|Bv~v!yW|TyHBB_A4h!|p?rWn2l#%(ZfPK|(y>+mJY;i4sZUh5 zIz7VqRe#aN!U5fzcX)b8;7C*821@Wo44Dk{&{n9iB-<7P{*;! zo})7aU%o_u)i4W?+_cWD>VWvL@D#ljrP9X-p_ymiJe zV_=6>X$Ch7bO^6{StK4_IQfH;=iP7TEy)OqNC?PoJM%KK`iqAG9cx*UBU!WP2;G}p zf2DriW5Gj`?q$E^gQ1+xGO^DJ$g|Xj~wJ^#uw{9q+i%^6VMPg70F%q)G zZAG?WlxxXamXQB>KGVJ5-@n(Zt2;iQ=Q+<=-sgSJdAMqR&}aq!T7HDk3d;VyhY=EV zfxlaqErm}W+PPc9m*t-OtTv&w~kH!nPUnk()0^TfqZi+<9(f&^h-{DdJN;o*cx+rkk{wjX5 zaK9E0e3{A0$^4lj$Rj-+wYWJkY)^^o;^<7?qxPSRTa#dAYV$di*;;Bv zhsTHFhwVR0&aZ(3x7B=&p57D@qhPPtX*R#jSJ@Pe2=an`9*Ek{C0OHG>6 zZg5d1Hl$ozTXdxWzReo}s%u#DfFg8l>7`xM8w?JyWelwDZI!_O(aK9m(rO?aVsgxj zEkA!NQMR9=Bv=G;lwEIEIjEX+GwNE%AN|j|v%GrVf)pr0nDfd;c50nf$B*S^<4RT3 z2E&2%@QQClf8WuNbNO!3jc-gtuCEmbJI4!QKi0kDjqNF_?p|l`yc&tHymRe z*-Igm6OwZ>530qgoI1sP8}Q2feXGJ5c$WE10XvWW25XBujtWj?+Y*;Je+>+|E7rap zyX5&fmaPgcv!`%uw>TaO|Zf~TGYUl;};gmY{3 zc*%J&R>di&VxRLC+RG4XY@^v#o(?%&bLb73tbCkQxYwq2)pB^@GslQ=KBh-q)@W*l zpVYVG*K=IstfkQiZr`qEW63x$k>M$tP+BT0PEuN5teC_LuZbBGwk&ikTmDV4idDD! z4Vh`t@!5jHE<7s=ijs42P#F2ied&4uh>gk=2~*HAyv7{w^trXkGR2-7=c~Vh9lA4I zvyO)BsTsg)_b`RdY9?nl!EV5PE2JA>2gq z7{nl@DW+_*ByU1)N_0|-TlY=;=5(woUH&i}0MU2d#TH=pMi?mcQ@Y}sxD{Vmhg_;D z!Ry4CZsuuSg~dlR+=OPus#fxww}#5PKqO%cGV?Wsd34f1#Rv%#@xfRIx{uC|Dch2` zWc_i{fwe8^mmwc{fXW085jzr(kbo_J3A07-wtfwi!l;%(y4 z1>hQXA*Lh`P|=KqA8G0FCS$N1bzRNtzxof=qx#t!J*t<&vBMzNCc@3hIv@hu*PMF7 zT~#pW6xJ1U`}On)pXlRGF=+nFmZ~dD;_+^XX-t@(st#tP)3r*n@H91!NO%cP zaxyt0W^!?QH$b#U2G@<`v%uP~pSb#f`Je;g%!vB===I!EdN7 zL8?!`zEE{M_qgjaBv zM;^)c=WXL*ooq|vLS#Ewcav4E)*9A1rA=7js)WK@CESYdA1V%H|BQs`jQ#v zQMDw0>jUr@v+GCsdH9OD zXn<*xkI|5xR^vwEgbI1%agw{}^KDW~5WRco_Mm1}VZ^-`hfqfSEeLV?bH{TSyQ;xp z(S6$2NT_{EyY8ukAs6J%;%)2}1!tsNyKrkdn`4nzUof}0wubNUPb+OcCU=>!_pzeD z1QxR_0yo2**gf^7&O zYRDkMJAB~w;PxuhyL?yfA;k~71RnvU3bv~vn~pg_@zt%8pkjRyj)5U>`$SL5dKkNK zF&X^8nP6}8M(Yr@Q6i}##V8^!bv|d9)1QCaPicoPQ1t^Uv)wF+GW? zXt>DFr#<&TC^!MI&q3LKucb>{kMH{6R-q&XW}(j#T$cI>V%Wr61P+zaCx@jf!Xa)_ zN831O81mW?4d*_R-P&&hJNORFai~KSw*4t16&~uD$mmmkrap(UvX9^Z>%-PDsT>mQ z?|MgIOM-GDrmn5ma3o^`q2{TmlCRCiAXn>g%(m4jT@c>Pck`!;7OVzvI%?#os@YzF zmm1f#N$XL2`Bv~1Y6|R-)F)HugiJb3qu-T$^}vvZsCwnfQt%pmU~TAOrF#inWDU?t zB(E+)M9>p5HC7&q9s;?4y>S+&FBYZ7*T)C^mEAkWmKUN*lkRd@V9`YEk|l8O3`Io?(ouk-J_GZa}V%(m7>KH5{6$i=AW9xl zsQWy#19Q0`2aHC{7dzLO5UQJ{r8s^TJ4ATn(*qO61Cpq*#yVsLCt)LoG~rs74Dhuy z(ACuQI?wRmtwA9~1`>nC3y0QMwfP-|j}V%x#OOu8;_aY-lv#3B+tSnltpNI-QYH$| z3nAyPwIyFQux!XY4g(02d$ThdyCk|D(Pe73+4KVlu$=xEI!j~ zF9GVfhn}J+?z460)T6f>U?~Ly3>DPf5cH*aUA7HuYgkIb=@EwrH5pz#=>1pUQSfZV+XA?t7b8ktDwn7lYTXEg;|P}V@mg?c zC~1wj=zDkLJ-vR#YUklbL~WLoeI^+y&xH#2sPNEto{#OBKN*jS7PEl>GinW9%)yTm ziECVdfCP2W-&E3yXxLA{jxdY~=!4NO6n(0Aw20Tf^?jY?Lp>Yd)9Xw_d_A6eZboa0-KQn zCrc0ktwX&@l${BhRhv;`BZN{S*oeN+J1oEy+zifu8Y2k9|4BN?Ma}5i0v}B$1#%^* za9$>=rUV82auP_(g4b3l5U;(&oejl1us_{sZN;H!S8&xa?x1W;kC&ja36Q`tp;T6$ zsP3@R-?b9LsyL`XT_A*L8!93$-$Kkwa4?2G_Df`v^cvSut0z(uyv#NEQQ4D(vnrf{A%h3{3f!y#h?3 z@0{wajgmIT2WP2a0GfRYicFwad~gdsgq2RSwy=i8iPo|Ii|a8bLQ%uFAX0IDGSv!CpkZXE=5T&d=nX2S1@#4`9Q`14L4}{@qsb`Z;AJ z>J`&>|5vuBZz3X?X2<^OQ~~)633$cZkqK&P3B2cE^!x)*pyk79pZ1hE%qSRpyW@pG zZiXx-KuXyEc(wNDlPZ?-ARTp^{ho)w4-{Jyfin@%uSk@|%!ZOzWLXHhqEgsPlA0-oi1Xi5+Fz&Z3ElQi3orz*juw8vnD(<4LK}}%)6XBeuuQi6qp@h z*G8pdZq^7jnLOpE1>Up6Iywe-s`$9*&q&I$GcKSS=4c0$AbvywSNwzUK%I{`AE&{o z*7K3vg&yPQwJ=wP&|ySHMkjF;$)PHxet+4m5k(5kkekl}zJ*fMuQ)&%KJ3=EgzAs zTS%Q8a$CwX!=pjTk=7e598I1OnAM(?`S~L@R|PSaIGp~b3gtOnYc>zFUrg1{5p^ho z7k4FQ%?2a|#)_2P8faPL&0l4!R(Kh;|M2<4@M6l{KfRuXZ&-w=i=KCFuTZL(z0o(5 z$-5RM6y#*)usfEZbLZlFBm;;p5L1$9b?Ck;{;Nm3Y75P`Kd^J^LFtcb>9Zd-rFQ+8 zTvA?b8X|FD+2#xz&Jv%u;1VB{)BZCX+`LsDB$1!Jh~4&J2cXR` zx$;JRc-z3oh+J?GvH`{6H^UW{SL<2;*d`Bx5aj2>Nb9e zxoSS$eQt>Goe$}YYl7Yj6Y&&dYEqyUhx zAW*?MLg@fs!n5NP3=GV5t$QJt#P!+-G)sVy<%vR@brmARY-0MYmKZC?eeU^u1{F#L zQsL=rd+PEpeM)uy(XZFtR0(>3i^xT#I}ThwPZTXUfjP}Vn=X`}M*#@N(*D&b!~qB0 zF8xBX$SbttMGINy!*WS{>BouUO&7{BXmD$!Uk(V*$t+O_`T8kTw$`T|V+PY0_bBRo z%02?zdLM#_V6Og!+V3MCb!w!aQ+#DOfV$J%=fX?An#IiD=zAcjG0O99OsR@hIez3C z=^jOQ*TIfQvRG}YbWA0vnd-GGk!DxRMA2-z@Rntzol^5sN*u}1i+ z_PyD)?Jv2gaWyi%Ll_~Sk8i0ff$%`|&Hn=e=t-@a)OKGPNRb{a+@&aGA77K$-&7OY zysb5-6y^s_E|#xmUeA0VSUk{gdE?*Y)&8YX0L@?hm-iv2D8U-X+|*U>PrL;av_6Gy zSjpEGijYIh(z6X68cy>E!L2EJ;N$B(N;1HksFCVkUjPG#%WyM^o=If>nQLClCLt9I zoIj+^G@i}AKb~`TZNih3f*XfxT*M{=p2;`WzBWQ+0f_KwXcWyj7;%6a8XQEImA*?w z$hL%(9ntWX%jT2iTgy$e&=MV6Ga7@f-}d4FX&S_{OgK(yg02YN4R@^TN<0DI7Z=V2 zbb=(z6pke4h6D`~%CZx$2wBQg0NJIC*9Mz@I-c!#McJ z>DQeK6Tz&%;{|k$T8=!Rbj3otFsXsA#W5mF4y%2}wc;*c8*Sz#>o_5X*;#QyPtjE(`q0^Fw-bVVzh9QVPr%$67&jPbHpP=OytA0r1a5@NS*20ZyW(&c zg>k$lu_DL8JUv#}r(HTuC~vfe`;C8yN7#!b=%^9I_mv**^3~-+tiLyN@E1aIl|xGr zRp960qUQp>u!bH{YmPxjfM^ocUV5GjN#T|;yh1^BgW=Q@M zEa}nUdB*#^m#Vd)&;bNTx|Q%~%@f6*^;L5Bj`l!hb(FnSFIODV`!93?<@(hgLUWwx z=PSu53EQ?Lec^-t6#Mo0@R|=Z;}bo#=|MpepYy5$wzzi$>zYht9m$cJf!6J|B~dLq zKKMsh#5d(pgEQN0Dp%5G-Iu&T({P*TKLtGDh5NGjt<7m zt?{-g?mH0m>Ut3TUP7L-;AsylsI(k*JH+(877MCN4EXfI)4FKkt5}ff)XSNE|HQk0 z$Mh9lYNqJ9wa`W;KU;&$;n1 zZ5w;8a)qnT_O67B$+at5Y(Gys3G&{)B2na;q)XHNGC2?k%N3oBtcJ8;8eNor?#91e zOSaPzpm;pUa!H~r7t}FCT_E;4wZ7|66O9YgHLcfV%iwQqX0}en{)?)0>8eX@CsVV% z7PY?nw|xz2uYY#9W@NUcP-7%o@`4a=s%*RnbUTL)q8(i9pq{G3RES=^mhakP?s*|S zXq}cqv6lgp`lHdd2m04v{R$l@=X+vjKbVH(TSS*_PPp9vjPiDqRwKJmpS!!s3g-8g z#9WY?Czv%X{aKLSPtsuHN-4TEnM;4S(nh+=vu*|dJAG3Q@9XRQ$9Uiwi097n%>#67p8_zqdY4Nl}y4bMGiN@lKwf>guKF{ic4lg}zuANi3(&S?=AA4~EgeqQ-24vf6{7 zxMsfhG5xSfOX1GtZZF(bgw_X0duIqORE3ySSk_6c^T;uFr+$ zFBMF>f<1-sZ%g1tME|nPTHYTPttg0;FyNw*`qO!s0zcf;_Ca;R8?<;ddQo&H&_8L? zTHL3`cF{gS!IddLq$vQki~!L$Whh`2W)7AEHKhs!JZ?YfF33xLP?B+C(=2oqTJOTO z3=-SEg=f0^h3n|^qRGdkZhJ-7q%UwSqUN`_zFcTd5he53VWO`p=FoAwN8-!eP(Cn+ z*1J9(YxtS&P=3;-it8EIS&&|b-rAK@rP|e|+M0@-1Hx6RoC5&t*>J{pwA|>~lP)s> z(C&b+Ap93NfJ`0a-5CS-Lp-hAMbjfY+M(sR{G?jKU!{Uh5p#R?jaUB29shCB<7+iA zep1nyBu9laNpe~WYnS;}&6NY#*b@Xu5y&&{F;PiM4w`3eANL+RhkPBZwUKUBC@l0k z*}Zn=w$It-R-=n~JJ_x@5z|-h&plrINYL>5@z2qf#~wT20-mvw(-|1s^AQ(>;eP5} z5d!F>n)_=qv*CS*?5t5V?x6rUwe1ZjkvuL}YymqzGxoO8erW;U*!eS1(`3RRq(WZ9 z$)d90`N-|T`vmWcr{ZoY`6Byc1i%Dd`9`1OfqUp|OUZdOvrhxYFiOC5wn;b*eazeG zkYZx*TGJ8{Yz9O(YSbgyxi!n{h|+JmZQ6w{5E}x-YK$d7HW1neA2D_mI+MAkB~*bt z{492#n4Rvoz!v|4Kb@GyJEh4@yq%uw<5$? z0c)}Z^kiqX)RY|qDnYvFMn~70l-WIp*bfx=R93hUmGUs>Rlo8c9yBbGYldj*C)HZj z{3unnuS(|OA7=RM4WGLc+ot15bLq(0-)u5Z3&Cd&cL z3JEZ48@!zrnV88%Uzj{2`H+|1ZwXBrN|*fX$<8SsXq^*%ku7JfwV5xTpFFYopS$ou zt^JWZeI6u!3x)?_Ff>e5BxDyC4k4Vmpi;6w1L}(eoZq$`Y3Vo){_FyY8mI zX{T_h52HHxX0&unN37|RioBg*{Dh~AoAF$W8rlS~hGuwjp|As>#q(*o)8JX*=i=|u zJl%C_q+vn^3`@6emq5+TjlL=2pb>9@il1K5KLi$klAVkljvIpwjG&^AJ{x@&R%sdw z8aRFGcLOVz@vfcV7DNYxsd&98?1$V&#AyCVImqvVsP<=Hv#xaF>jo-9{*ylKTUZNLhe=evezx#M{W{0zz;<3lk-Q{uFP+`S~ z@t%afL|03{5+sfOng3c(X8}-)RI*if1p8dnWKKf$MB-7mn7Awv#6=Ud9rE8WYHwT< z1Fuphrp$iE%=H2}m{h2DHtvK_Yucos#9Mc@ zSfvr;HFsV#NMH0kUHdscC`fUYoj>Hb$P@D?j8x7?_LfQ!aGy?c!Sc64uEX?dHf+POZ^BCPE$JT+NUVC9llR{ntim z{|1#G{Au6bmFi;dbUP9zRP;UA?9@fA(R?Sut>J{Ls|8s$om27P-Ey-V>Y7Q(%2aWV z&E6m)~;Hp1^q@1O2B4U+?b(^dO&>j)sX{An=?L+yY%s zT??)SBpXOOxh{(0&TnLIb2b|44QE3_@i9jQ?;mWVz!A;=&H~_`x?O3Q%vQqF=ES$V z>8B-9j1H>m<}%gGprgVd@XC#tYkYYLqx5j-)=&lnb=zEdHlz9F{np72u(Qv=N~Uf^ zqTex)XRA&B|Ac>`8Sx|QyioFI2JEB&;REh`Mo|rvSpPuSaeo@onnq;tLip0&q>K@Z6lh> z5(IA4LFkcj5~1nwWTIwYOKsF_zn+)gO)A>E?q*Y^;~gl1fG&{Dpd)e!Ib4i|E(qO< zC^f%Ok7}cn=T2!O=1ZwKA!Y$-KNsun(E()gagH<#uU>H3OrxY-9*!&y7!CPWi zEz6ri%#V_BLWt}Yn$9zJ7OLURTe&?etj631Bu(C0mmTLWOFA0TFGb6N_25r4F^e*R z@z(>24aoKD<&Yo!l~aOfm8~$X1RTT~^r{-stnuirR>GAc2Q9o~KCxrwN!aliY0FYt znist%^}JAF?-Ojy7hqDk0qD_*ryHOKRlQNHSSt_@_8gNWwCZ@>;GxHXe(ED3aO?y! zZ_%tKTvNak?$52O>Q;AFn{PF5+EO*SQnwcS2VexDos6?sJAOw%JAZC=X>7pXyLJMk zkbR729-%1ibihxot#>Zh=?vbkpMxG4(~+3fS$s(hRO?IhKxWt)C%>0Kxd+V{nm>E8 zV%uN3AK{$+SXj`l95jg}O_HYr%Ud%Br6z-1sw>6vF*vb`dvT@WSD(`kLo?r zJGrz~`jKF>@z{|EL?Y6?NyObz_vYKmRn^FB5;9hy`~Ti(B1V_o>nSz;8xsR)k(d81 z=XJ0A??qt0Mr$<=sO-v}Z{t=phUz_UQ*`Y!xXFeIodJ9Dttkk3dwLOsq;P~J+IM{E z=NM}4L435bn73T5r1lpm&Iz`TVL#<&=e=Ci@{Wut~j)>ok8qZ$UB%tWH8kZtUFrCleg_){Y znTMMIHNc27YV65au8YZoH&--(1>Y0hUke9KFVaCw=WpPnJu6~Qk&*Km#gpYqVD4g| zx-e&A7OhFi%>lp4KgO{|2%%CE+q@TtqX?h7AWpXhru8Xhnslxak$_6ND%&MnjMi&$J(S$-D)alnC13XbJ-nBe4vREDe$IEv1*OiW`NHX7uDR za<22lDM=s%Q|;dC;VXk*jjT6JkwWI0({|J!?Qi31Y-*$RLMf^LMiBGT zbJVI=@-r`cR3b5jciG<65>z)r^@XkBjdk`=fgyzx z-A8=(&fnh0N2uy$@!+=CoQPva z-`RV--zD4(Mcx|;5f&LY_T`HqHdAZ|oA|;ZZ}h3^(A#D<{GXlDKfzA75=$hM3O1s+ z`y(OYVa0VLtiE9}ezIDi#Rt3H8@i{;M?%0h`b!_UjEM$X_!FviB=-EEu0yX&a)DXs z`_i!DhDg@^!Zw)NwZX?})^JeS(ncYwI{pm0sSd;|H}B+r?CCF&$iW-_-r+MVQ+hc@ zdnoZtq7)Z1hW7FE$)wv{*zea|4N)a5@K!H zntxtwmbqdD*DofZzUdC z+B)2HBUr>dyI~m7Uz`F(3c$l)er1l=egk=8N(7`C+$LFsMO5IV#I6blulBjZF~@j8 zrTe>1f_kb6mk~|($a3@Lk@r&>=@nu=QR*eHubr^G!A%xcmtnaof#>o2+>+8CZ~BpO zAw~*b8`G)+BBp;DRGjqwL5YMfoY-~yi0*}3NtLJ1!t*nF5+Y+Oz-MN#`Sx=JY~~19 z@LGskfOEM@Wxx)(==nSp=1am>dNQsqa+Cua>$RX;l;bGl5#W$)1f z9nBJW3E8%29F-EuEznVD0#IO@z`o{B3d%FP9F1Vt4mHAlj)_vtx>Ic;qNxh;ZCsL_ z5HNHPa9?xD2XM#rZE+?2o!U$?UJfyL5?Y~Pt-Q3}d&vr6wagDQk`ddd2=-ozQfI>>N#`v|u%bK~{t*NMYI z*PasYQp{1!p6(+a`4XB}+W~X+|Ej6fn*PUWJcvJ)46!6Ku3dUL4ePWqw*Pqf=SB*2 zNcA@p5uXl`>bWW&?4)q}om!j|24m?pN$hh)@DVK;4C~dpY-zbOq0g0egc)i<$a|$y zySDSDnjdVoOln~ZeMPYjiBfQv5zOV>G`Y1qpAasLNDr}9?r9C|s>5`D&kHsAG&5fF zIBYS-A6(3auP-LI+V|JJxEmGsO{Tx>OuWP=g}sQ)Mo z5ASnK$_$ljuz$0h*%-m&Qwp2CnA<|fdRD06lURc!jXGD%h0#(vcmD+|gINg>SOwqL z%(`PJj|vswR;`&lAfZ3AAvN=~XPfd~f!5kSzOaK9459z+n{q-Snu@ zOVG$QDi06+U8@tHdD3zAMQByhw-Ya(6%$qp2dUJK`58!v6tzi}B}^YjvmN1A!-^l5 z_835NTlJxnA<&#V|JO7X3=cP3;lJwAaf-EHiyw;9{ndWg2=)NOg<&6q4d8+aD6ALz z9isaX8gqoi;JICFl3TF~z7;=|SdSF-m8R`vbme)HKH;Zn!jSmx6Nu;7?g$T;0wmew zjD+aPbh-GA*^AvrecsYv7G}!%>8mqK?WB`!XLpWP!VX7>V{9iJ6`09A#y?A_R(g(n z#5=O%*$uzPOCiQ8SnpczPH>bP^_Z~Ss7tH-jD_{o_0b3j3j;ISyqr^{>yA`*eC+(& z*$8D<(kh<69!Zr1%@#n_LI2K?E}!Z|Qq<J^#hkhP}q2Fu+Tf~~;xM$c9XD~?|Mv*U?&LaUx zo}rMJMp%Y|FSz?W7p;qcK7GR&soLe&?c!%HI9mO`T|oJ?Jjg7V`6NZWQ~UDzk|`F!q6&EO3wk!6pzXh7+xTfMYUP zbDKZGiQQsw{D`5PX1C522iNc@m}7hcy0sos5uDdj8v6#)rQpQMgElMbCgoyF94KoN zqIyf?9dcj?_%lTC@KGaM(x3kn0}B_w1Wg@q{ooLM5cBL&X{C%UorD2VL<5|Jf$!dsA*ewF-MAPv6c$ztZe<1D2V;k& zYUs;pP%$h!K@bL(%E+MmKLs0+!V5jHNgMdR;!#`;ER_RtPEGEpXgLkmM<_cS2Pj%s zl}SP!?ci^U*c6xCz!XPt&VL6fA}S1Mh1@g%A^3ziM}!b~ZR9HiL0k)#07>83vYhOS z&jNSPi86;?%a~b0zqoV?v8Ewinq)xcXBqm!zjC42Y=gVg%nL+Vu~fhr(KS1&M~32l zF1q;K4cK?`0}ciY;-u+(;ci2T(8@m7 zZgHy&<(yCruQkp&8+*Uc*v?K|_=oMe^NEG8_jzfV-rC`1a!&WaYpALm|9XX;srp%X zSi$hJ)<20^ zkyX8O;WG=pKjJmR!=!7Z-)MZO)l{tSmKx~q|MN$Nc2w1l@hXwvPetsgitnaAt3{6I zg!)6FFq$}MLCXN(U@akB4;00md|oHS>eF-L8)J+g`7=CBGwNN+#FW9_`M)(jIPE=k zBGxw9Yk$A}yw~7R50u!?J+gq7U#m*p7u;(b6F<9>P=)3ni_kQ{H8r+>bsP2~z##e+ z?eA%}6Iwg&Q6Y!3(`JH=UfMc*s))($y>$(-AUP1f7l5GzT3An;em73R7LoDXaarMd9L0c1x*R76GgLqUr*Vk4J{4!Z z8V)Px2dOGrG(PxCmLDHi*ZO>N#LTo6xSC6?VyDXkr`QW%^ZD(`S-lq&j0y70oFJ?X zKOB1ouM_(uoXk9I`Fm*q0PKuu_mR@DnTw6=z}HbCcDsKrzb=0`*7rnBQQ7sX@6?C6 zl33#qaYQML|HT@DV3PTfku{fbhD{zL%ja(F{d8%bEy3p#R6jyXYq8V6w}wD8_0r1} z4q9>a$~4Hq#15^m{8~(l-^;FB?Iw9}UBbQNUY0j1>V&K=k%R|3bOd&mLJe?U}!r%O0ESz4{}gS@(2<+CFu$Oh2fd4`X?@5KnyUzK4v4B&05P)<;=En;>6_`(>tPknekQ}- zy4(I#hX1`9_H;$=sfY@XjQ)$M@T4Tc(olIVH!OlaezEXn9)R*i7VzcKaM&K}nx1AC z(XMRG;LtxH1CGFI)Sv&t8ItUPbk!VbdFyFPwCAX(94r}=>)uV z_%D~UjM4zjnrnuili!{!mN#XwYJ_w^Q?pq};iW$ahDjSt1A%^GVd>u=LR_SJg2?sI zSrYAoJo;$e{jZ6G)_aqj{6u@<|J#SdZmQtl1@hk(U<`()--*$D9{zL5Qa2jEk6rw! zD|f`tAhDB4@ehPY<%pu0K6+v<=Q?bYeXTli^#GuI7~s?ut2z7mr;9%;`kEasfUHUT zFd>ELWC(F^Xa5}_SyqB0@Y4!D$1xALH-sF^djXtMPDiF{Cc-PO^H9R^=Hw}iEM*=zn>3_N=0brZ3k@Jq=yfqvZ9ph!By#e*BLS3XHPG*4P~@9Gbo13; zRi$m)-|TdPgE^C&EFVf>2>eXW+%WxS-aqq05W*Drdu3wU@x;LuhLMqisY5NOf- z|H;dGIR#ekrfNEz0E!qnqC}7zVb0OGhIDHZF&%>H1XPzW=uT#8ZVT?xuj{x6&(Op;67B>0!ykF?7%9e{ zKwxZsYnm*@aSXz0ou>p9m<%TMfejU~!L`n=NopO4G z@Iew25;WZ#s;*{t)N1ec=4F~Fn%O9yYJNM=mx--$1?Xq?dvGoz zwh!noA%e52D|n*5$ROtpI=i$%h~)%YDbw6JN-6h9ks@z;AgcR&iWK88s2=`&spT-m z&wYYOMh1hEzMZ0KN)b534%L;Dse!)w+z&(p5-XIo%E}?6#vC~bfo?RASf9EMF}92C z>{nScxnk>1ihYWdc~*&p;PSq)9}M7=PyR1$%~O2En~KS)W_WueRi}zrb`>OZ( z08Aqc?g5_g!egpLgqivp{Q(j_U7Je*>1dpUp?k&f85){`au)Hw$0efg`=;+3bW+LE z$II6T#7mhcB;K?Y-b5yE4<>Z{Zw(|0@okw|o+y$g0aPdb>?}1v<_(j*nNz1v> zMdQ+8^uqP!Jd_FltBEWu69gDGHauUzbckUt=H3)vS?vg_?HL+-c2rm%oJLsww*dEe zDY^^JV#%B+Gs^?VS^pYb7#+Y92w$G&!e*iMGK%^Y(_IuL5fQJOQs&EA3`pQeWtIf7 z`c>K__d-R3POD>AHFEAbN6L|bc6%Bs9j32>%>-Hu{v5(y%$auqd(rp+WfD3JxJJu^ z8j@NGctR61+59>?lW&Hu?i^@1;!b=k-uZULp!A2xmTEXb#UGY|`A+ zBfz#FY#`ET{xFnVBJzpw>c90f;GX3z0uLn(;miywm?zl@XyoWA!Od7wVS|0*t zvZFX}3__;!few3&YC@5q?+RYcJ0NPa{TpYyLHC)JBEYxgyrC)NH>e-FG@vvO+ zy85Rzt53zfP=n4Sr^$hILk&yNP(D;1p9kF;=r`7RFq&>B?J&=0t*Fn+A?4xU@9>W+ z`e>Zm`_k06_Uf~B^XKhaC ztQU8c?yp$obVhz{7o!$ec}WJQ8O!+Ow=2C`G~U~>>49!_@X4^?we0U>{Ru7K$9Cm} zT21`WGXwYrmO`}U5G@BrCW@`RmW*+|;JD7pLzd=CJneln*8F*S^`p|e*Q>_HOug$3 z+&A_%vk{j(bU9%LE_6rySkMORRFiWSc4z&wg)nO|5(tumN1_G{>H+Jmxa8pbepU3uH608{f=4k2U<-et>t4*_+mVFKv4nYxJtz_Rjlt zu8?@Jv2Sf|s2@m`AL2Xm;yXhDH!&n=s?AJr5$K$O2Fu<9cA`k!DG>oGS9u6xA02Z9 z$aDn`dD5>+PUh#n0848PE>o#2MqR41~O8L5YOk-j|_Xf6wR&NgOEC?p5{@7m=2+??3YA#C6+yZn%$8 zysU*M8)FMF)OZX7>#M9=uU@1iC2=uKMn)U*fL>aLLvy)2GhdDR9S_>7?B~P1%-PnG zAyM6W><6V+tM+vJO8%re54yL84sm_Nr}*D$KDwh-X7%SkYJAX<-p`|G1Q0`eY!i(+ zO?}7SmoOt|Q6=9OmoN60XBLX&k%G&vhW!X%q)3P?%v3zfDZQJrKIW?KWrjPt-6ma z-Hzg_b_QxG%5W#xV3qx%B>(5Rq(85eApx>b`yD4g&+T9Y=&}OJOp8L zIdFppR#3Sje B283o9Lasmrnt>htdbI9C=ii}Mwisjm`2#d7J8L>vfpGMj`DFM- z4<}M^)yp;^49sa;=Yx04Lg{-3=DEPE z^G_xa_=>hV8E_-|Y5zsgFK1ll2n^O!Rcwm8@o&vj(+0kLM&CC>zN}Uhg5UKJH*E{v ztc|@{KMr_L%(GX}|HD^CG)u5?Ph6LWpY4N+Jp1M27`V+tUq#e?ILT?=I*vy((ALX` z+Z&PY*kQdfurLp3;tcj{B6!7muXytS38_KGE)ApI?5uo)E4QsKf31gKeMMM^zKqyk zN#JPQP$*~2s{6^`7s>ZL7t_zdO>XKY-9-pwLjqqNC;q6**V@TLU0P#TA`N5x66gW{ z7=}4Jz}0Jaz*L^mQqVnT9H0tVshK%ho;e72EY>+DxZlDW-HUS;i25QBGCD$>G3=|p zz6d9s@Gw6ZVODM}~Aj%7h>hk~cmD@8=ad)`N+yW%sL5;Ka|BP`)A zXlj!sU>Y`)#gYEky$a??)6iINKm8zs*a|9eEBAAR1gqZP!X*PL!*IX#`zOYn35}Zk7+`^YnzCZ9c z{ae`C=)AaTKM4o;wXU{wT6U8YL7AZ0;lxM2iZk&p7~&HRb?A@IK&Klt#JNmzIG|pL zs+ud)E2G0rH3jYO!@Z8A z0NikfX`!$VmrW{hwXO(HPyik2wZJg6@dPWZ1-z{BMdC2}8C401_1_z501NlE!o#dV z)2FWUxWoMVFh&a9Th&*foaD5hbzc(axeezz@~?y$IWL6%c6ItgnK=*7-*^lG0DV&c z()^RDc!&jpzm)aOBJO(Gh4t%zNMA_LR^AXU0JsW6fWl zg?@kzkD7b^I9q%%S9)V_N}$D4HcKV@0Ni1zxtk8-1GDkDp65j(UOV;XDo^-#!`!ry zxE+$Z7nT_4g7f_)#bsUskgTa_&KR1yV3o>EwjJr2P14(O7f%LW8azlZhi>S!=~Ebo z*auVyghR{BzxxPqv#mchpL~0ETx49iTRAcAqWPgR56BG8J)ox#%OfC-`TximtlDEC z^YhWjQt2|TfU4rKemC!>&wfHdis!ZcAO0(v%J^yHH~`t|HR}pcHkyx`pV_20Qz$lpb=Y!L32Sgf$#xL3%d-69!5i z`5V!J_ZlCR1f8=xA6E*}+tebO(ysqMvfex%>h=8}f1#*|wv(lama$ZJ#aNjx9Mzm<_xH(`{<6a9@f427{9HO|CtCziso%Y;Vt-$%_czjssH?r{+2?j35O$eT$Ocjl(@ur%_zJ(MjIMVS+N(h{-V$0@pEnegg zp8+=jR6Yz|jVXy8>CR~*gm2^bKlMN!T0U6bX!TD2c6L2D8Fk6oL1PE1bBkZLSrphh zv~xb8uCQdqCU(w$w3@(U*KQfNqkrbB*74GiX7iDW{e5qbj(DKQ1a}j1RPWxsyW(a6 zTJJkc3SXB03n7Ip$f|g+XGgOa{6yzo-A7vO199#pg_58`F#USTDsd=d@?4Ew@IC6~ z+CH`%w7x9%tM|eL^H|lt`WG2Hg8$56-V2Ot5vqs>KofK6?S~X#aoQ+`jP4(i{)=hk z$@N|?k?mz&;XfIh#ZOJetYl(&rZiVrO?b1+&q3FKOmJaTFV|GPuaA`bd{3^xRp3iI zQ0wTMtm;jnZIdN%$Y&Sd=+x(O?u6=_tUMEcgR+oH(w)s)KlU!t{ySW&lA(zDmj3Vn z$WTR1(k4y?#+?;3UJ0z;@8V|d>eQF5TK0YkV(3Edg3V)R0(S+^x9EML*Hr0Re|w;j zaznA?!Cf8Aj?^RS?iTM4b6BZ;PAJ<6THGD;rO~wAlF2s$7Pi$cq$!>&lbgZpl-59k zn|CbE_&NCU_sc)>8W-Q%``~V6+q~wI;!DdPk2M6cJ}=%3J)o~7|48oP)U{IXRHMf~ zAooKT;4j>n#}x2=MPc!kaLA3aImU5OGDy&`dgY?TrffCPfx&IeZT|Nw4+t%e{*$aD z)KoZspvrP{$?Gg5R5shIECsA~H79((CSmm#JbmJwe#dSW!EbqaKRIi#m?2#Mrqw(jvcxxX*_So!8u``5KPyDVu zQ+2DVInA@zr^F-P7mTtoi&MePgZx+k&|0}vp=<$81#cR_98~5B<*wCpfsiL+=>z;jLRmneTsO$Ppv6o#Hp|jcJDCc zg7R-&0n~LC{q~ecbnRV+T)_aJQ@`vBSB;mA-@m_XW1POO5jrLI(Kml#M=(|X>*zm6 z&=FRR4IJ!Y&L(N+sPM<_rQvtExvj~{g8WwWKU z_sHEjV*O&mfZ>F3_dpNdJ7tJIf9Ix{ursR<)IeSDvX-SGCr4 zAj@9T3jvBfKOC3xG|sbhs=WGUnVbE8sGCO)aCIvoJP7 zPCsz^1~_}4qEUC6w6lt@Wf`So(Y?yPsFBevkgVqfcA*Y7B>e{6ONn-5-S~sIAC|OUf8WZ;A%0DmngOOi@QGbV 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%0AGMxS5Xg5WakfK7Y^Y-!$c^XRi) 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 ANdN!< literal 0 HcmV?d00001 diff --git a/assets/ember-walking.png b/assets/ember-walking.png new file mode 100644 index 0000000000000000000000000000000000000000..5ca8b8b7ab16435d523082f9871af2bab519586e GIT binary patch literal 136049 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 literal 0 HcmV?d00001 diff --git a/assets/ember-wall.png b/assets/ember-wall.png new file mode 100644 index 0000000000000000000000000000000000000000..8f8f8a8457bf4817d4d20faae0552c8590cc55a1 GIT binary patch literal 177355 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 literal 0 HcmV?d00001 diff --git a/assets/logo-burn-full.png b/assets/logo-burn-full.png new file mode 100644 index 0000000000000000000000000000000000000000..3087bcf1a3de67d84151279b40302a30c4ef02e1 GIT binary patch literal 141879 zcmeFaWmwc**FFp*2!axVU{KNxf=Y+dT}qcy(%lksA%b*wDcvp2pn!BpcQZ(L*ZUjv z`akz|-_P;P@bx|JFV-=8@3q%D*STtse3F+HyMyr%0|^P~j)b_dA`%jM0}>KS5&BKw zUkGi|LV(|pZ572{A{BHKEh8b3AxQ|oc;%$MHt8B+V5CO9b;vU)A%2n|Nkges10s)3 zA)`RYxL+01n!frfU_!#v`>%irJGx)e1WvOVb4BtI)`B=_O>cZ{260T1QS?FLc9lB# z)<|tg66-6Ti38cxkCzK9m1zdI(>S{*mp6J($02!bwWS@#A!dx8Nb5 zsrwC;f3N$$_693s-ft+Y<`1SpKvS%q);AE*Q9c2yr?I^oi5vo&(nXrT2^UJhP1Kom zk@{Coc$5CG+x)q41JaK>h=8t4y#Xm>aNn610Zn-#2RuSR?KuAh=zjqUmz(|zQ1~AA zUk3dzgTfCC{|)hQb@bm5|KAV~KXdtS4hmmK|II=FTO`3PKmLDOBu(yZAK!B1JRpApy^#I=GbOraKJvK_z9UiOUl)D#u*kcUd`HSR zFNunCqvw)`hv&V-GWW5k3#N`2y^b`eCE?ecoOwWP?h|=9BXE`@mhk>CHkJunQ*ISo z0@p}5t%me0bE-s695-6j&tGXFcwztS=f%4vBg;{mBNMoZy=TKL9;+wU`>z!%;Vq3T zK8k*5&;$=&az0w}4f|aFU##x^`(82P(ZcGNzoz!>+O|`ndOq7N$L|o>+3_B*GYD%T zz7fwTxVt3*k1m-QSHK>HcbS0ivDn3GM4z))I@G-4*CV$la1|{$gM)s*fe6&3af(~x z*g0)!*}k-=n2<=ZVP`6^DuMX&pYx`f%4K zsi*^~4BSoZq+y%j*B=zE+aAYE`IEQUVmG!_Tu>+pPx_yg%&$axymPyc3_rKqtnx(e zd|aSg%jzf_v^N(qN8)poDs@_Exzx%MpRb~=tKZ!K-s(3$5FI-?E&I z2^%}4;p&Dc0l3n)fMqB{f4ok;&eW|fV&*0k_vF|3Sra!;UL1? z3Xvchq9(DUp)L^J6u+uT68cBD3{GZc3j<%lKt>_EdsU4qS(~Xp^LsIACQuo?H3@&^ zGVIPNZ|0*lJnhjTcJdq>dpIs9wD4#}KF;&aw=T1Rqea7)#2;CtLRnUZ8F;W7#ZJZH zEfq{b0xlY_FgN!7ti!gQrS^f&iWFA3V9-%dsr~M$&+@z*BYb7O1@^d+lL&yw1tP?|xbF0CQ~> zVO@(^KFj4QvwmbK+?yP20BBHp|D$o`9Cw?w#g-Ct!T;uddl1F3Hv$AV@f=tkpU~nz zh)2<)Gx_|?z5Uy*1>2kZeg#-qKONyUpTR>wN7*dLiZ^cx7dP0{t#lH7>)VUTgsH$R zMfePYc9+#NHbhNh#C+*k z@^2PloHeFc$nxX~eDi@PJ2FVD48VXWpAJ!yOQ&Yuxt<^AzOO~+e7z8vm7(@c3r|bT_V8+Pb;oeslBZsX7WZG-1QBQaacV^1vQD4HzjZd+|Z@n zn#>tP$Pe}m3u<<)3E%IQj30@eTEpK5mrNH44_Afh)!`-pW87XJnhAAqLK5G7CdkE* zd(9>Vd@wS=c9?&&pWy3c?rOTXLv%b@y5FuR0l&LG3FJXl?nvy1C%$`~8-qHsbNI!s ztAkx%?X1`6Xd)@$i{FLI>iNlc(0j!r6Cb;hOSc}{dasp>&%vSWY=h#^TN{3;Cc*-D zW9#m04d^+vjJ@WXi)ln&W4Gi1<8NoUPoABbAL5-;!lk(xahzXgNQU-rPL1jInYr$^ zZ+-N_Z{6d7AiY#r9u@2;i^H<5rtd8 zQ%x)3vNU3sy;{k6RKk_3dxF~}s$G_j7%suG()AuAca9aE2nSUrm(mK6Epa+J4Ahnc zJXM5mLk_W&-5W96MeQ7D?Zah4Wa&q{7mB-k4z)uJaK~C;p~t#NdPugk8^3&y1BkYZ zN2{G1aCgbDAHzi*Rt+$0LGFAbP%^%QmgYNoUcEdWCD7kP4tHgJ1>pWSS*OP_d2b82 z$pzIUu$nOQ-6mb9fG;nFJkXU#f}-o9ZX848HoSh(2KgW2$0t&VYtbZG~k0zJn1$_Mg5%QAQtW!2#fb|Fv&N@ znpEW0dn5WkcX9MNh_4Ot!asM=dbL@DuL{UNrKD2(bqG}zr4ezjX1XW1t;mqjCPH;q|; zT)D?Jr@5pM)!@=Jw{5Q!jrdw1sD1Hu%HE}wCTkvr)y>}NcYWaI7ct~ls-HV6geF`z z*aK24U3ibCM+z`7L?Qr~r?7bR19P{>FG2^VZ*i`36OXbppKBOv%<6)UYeyqN>TR$l(9JmnMBEDh-rtWjA?&VSo z0N?n<1BLPI*T5%3PeRRVr^`vnF0Jg$=1u$EkU7O$@QLre4-8=QV%_G!v1N15{qT;~ zQY)&ipg!ajC0@ywf-gKuKi1x@qNa687FfF*m6SpN4 zNfYA?{*Iz}()O)y#|J}-3JWemizI-~$w%bQ8j8MDEr0{L7E2AIm6>S~mfTnMQFk4c zn2X;otrS`M*}_F%yE8&`Fv1AS&2t;81Y|RjYmPP_Qi2kEil>3W0`c41S0TS%Dg3B|BNl%SuJ9>gL^L7CndzEgGqnc=DX zjn26V4{5GCKwmUlK|t>Gx$+eP`4$I|235^+H1N_b?I~BlPOdD{;lR9xwk$oJU$rk6 zCIYlgr^TkK?k@i(y9s_?^ZeHRhPBf>tUOSXB-drPf>cg$571AVYEHlF3pZF*egfXg z3BNZ5Di>F!&ATwUCAT9l6m5qQcIi*PT0CE;jid?>jh7S>8aQs++uB%XqrlI^Er6UD zs6E#?j|a3Zq5fCYZ7l<_2x3CqGe+v>(~kG2qB;`AoI#f*rCcX1{J6uB(bDAb@eP4p zePQ-fsj2ne>+UbS;JUh0xru|R!VP+sSYfzTSA#o@RBpud>vfD3M^4{W1k&c!f>pQ6 zPn(dlTvSoKPjIzA0;}RZ?_=khlF!|b29#F^1fOGsg3kod11f#-ghY8dz!z^)R1L(= zzTGg-{ElB8A*f3Lvfl~ZE;>;cp7;sNtZdw!Jj!+|5Q1wA&?_J91;l0%Zo=jU$kc^S zz-6_seHyX6OtAsUP)LtAj~^HJ(3ZsDR^HOyz`R(8B^^R$PXk!#;}56e6Zr)vUs!6E zzH~b(N=HSN-WtA#--2B`V7us{ND~%}9lEVI*0P)p)Uwmy`x!N?W%Kk0<{%G6vtA;Q zqp&<0=<@eRBuBAI1%$rYJoa**kgHh=!3a63XtG*Fe5elD(NJ*|3_or&vtFw3vC{G-�o(j7Qc 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⁢aY$+n|#o(9w1$DU?l9KY~% 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

qn-|4fmAv_#)2%d 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 literal 0 HcmV?d00001 diff --git a/assets/logo-burn-neutral.webp b/assets/logo-burn-neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..806e91cf67992c9f9b4103735775e2208c4745ca GIT binary patch literal 28658 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 Cr<*_k literal 0 HcmV?d00001 diff --git a/assets/logo-burn-small.png b/assets/logo-burn-small.png new file mode 100644 index 0000000000000000000000000000000000000000..d88f84ef8d98488b694217a82b9074e2ca2d53e0 GIT binary patch literal 63807 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%0D@ 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 U2p)s2-=wUdE}wqJ B::FloatTensorPrimitive { + // You can define a basic implementation reusing the Burn Backend API. + // This can be useful since all backends will now automatically support + // your model. But performance can be improved for this new + // operation by implementing this block in specific backends. + } +} +``` + +You can then implement your new custom backend trait for any backend that you want to support: + +```rust, ignore +impl Backend for burn_tch::LibTorch { + fn my_new_function(tensor: TchTensor) -> TchTensor { + // My Tch implementation + } +} + +impl Backend for burn_flex::Flex { + // No specific implementation, but the backend can still be used. +} +``` + +You can support the backward pass using the same pattern. + +```rust, ignore +impl Backend for burn_autodiff::Autodiff { + // No specific implementation; autodiff will work with the default + // implementation. Useful if you still want to train your model, but + // observe performance gains mostly during inference. +} + +impl Backend for burn_autodiff::Autodiff { + fn my_new_function(tensor: AutodiffTensor) -> AutodiffTensor { + // My own backward implementation, generic over my custom Backend trait. + // + // You can add a new method `my_new_function_backward` to your custom backend + // trait if you want to invoke a custom kernel during the backward pass. + } +} + +impl Backend for burn_autodiff::Autodiff { + fn my_new_function(tensor: AutodiffTensor) -> AutodiffTensor { + // My own backward implementation, generic over a backend implementation. + // + // This is another way to call a custom kernel for the backward pass that + // doesn't require the addition of a new `backward` function in the custom backend. + // This is useful if you don't want all backends to support training, reducing + // the need for extra code when you know your model will only be trained on one + // specific backend. + } +} +``` + +The specifics of each implementation will be covered by the examples provided in this section. The +`cubecl` compiler frontend is the recommended method of implementing custom kernels, since it +supports multiple backends, including `wgpu` and `CUDA`, and is the way first-party `burn` kernels +are written. diff --git a/burn-book/src/advanced/backend-extension/custom-cubecl-kernel.md b/burn-book/src/advanced/backend-extension/custom-cubecl-kernel.md new file mode 100644 index 0000000..c978694 --- /dev/null +++ b/burn-book/src/advanced/backend-extension/custom-cubecl-kernel.md @@ -0,0 +1,383 @@ +# Custom CubeCL Kernel + +In this section, you will learn how to create your own custom operation by writing your own kernel +with the cubecl compiler frontend. We will take the example of a common workflow in the deep +learning field, where we create a kernel to fuse multiple operations together. Note that `burn` does +this automatically, but a manual implementation might be more efficient in some cases. We will fuse +a matmul kernel followed by an addition and the ReLU activation function, which is commonly found in +various models. All the code can be found under the +[examples directory](https://github.com/tracel-ai/burn/tree/main/examples/custom-cubecl-kernel). + +> Note: CubeCL is in active development, so this section may be outdated. + +## Custom Backend Trait + +First, we need to determine the type signature of our newly created operation by defining our custom +backend traits. As we will use the associated type `TensorPrimitive` of the `Backend` trait, which +encapsulates the underlying tensor implementation of the backend, we will use a type alias to avoid +the ugly disambiguation with associated types. + +```rust, ignore +/// We create our own Backend trait that extends the Burn backend trait. +pub trait Backend: burn::tensor::backend::Backend { + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor; +} + +/// We create our own AutodiffBackend trait that extends the Burn autodiff backend trait. +pub trait AutodiffBackend: Backend + burn::tensor::backend::AutodiffBackend {} +``` + +In our project, we can use these traits instead of the +`burn::tensor::backend::{Backend, AutodiffBackend}` traits provided by Burn. Burn's user APIs +typically make use of the `Tensor` struct rather than dealing directly with primitive tensor types. +Therefore, we can encapsulate our newly defined backend traits with functions that expose new +operations while maintaining a consistent API. + +```rust, ignore +/// We define our custom implementation using the added function on our custom backend. +pub fn matmul_add_relu_custom( + lhs: Tensor, + rhs: Tensor, + bias: Tensor, +) -> Tensor { + let output = B::fused_matmul_add_relu( + lhs.into_primitive().tensor(), + rhs.into_primitive().tensor(), + bias.into_primitive().tensor(), + ); + + Tensor::from_primitive(TensorPrimitive::Float(output)) +} + +/// We define a reference implementation using basic tensor operations. +pub fn matmul_add_relu_reference( + lhs: Tensor, + rhs: Tensor, + bias: Tensor, +) -> Tensor { + let x = lhs.matmul(rhs) + bias; + + activation::relu(x) +} + +``` + +Note that we also provide a reference implementation for testing purposes, which allows us to easily +validate our new implementation. While not mandatory, having a reference implementation can be +valuable, especially in projects where creating a reference implementation solely using basic tensor +operations is feasible. + +## Forward Kernel + +Now, let's proceed to write the fused kernel using the `cubecl` compiler frontend. To keep things +simple, we'll create a straightforward matmul kernel without employing any intricate techniques. We +won't delve into the details of the `cube` macro, but if you're interested to learn more, please see +[`cubecl` Book](https://github.com/tracel-ai/cubecl/tree/f5b63076a01a5c03ea9ed20799d3eeaf776b45da/cubecl-book). +The actual matmul, add and relu computations are found at the end, after an extensive prelude that +serves to correctly map each compute unit to the data it is responsible for, with support for +batches. + +```rust, ignore +use cubecl::{cube, prelude::*}; + +#[cube(launch)] +pub fn fused_matmul_add_relu_kernel( + lhs: &Tensor, + rhs: &Tensor, + bias: &Tensor, + output: &mut Tensor, +) { + let row = ABSOLUTE_POS_X; + let col = ABSOLUTE_POS_Y; + let batch = ABSOLUTE_POS_Z; + + let n_rows = output.shape(output.rank() - 2); + let n_cols = output.shape(output.rank() - 1); + let dim_k = rhs.shape(rhs.rank() - 1); + + if row >= n_rows || col >= n_cols { + return; + } + + let offset_output = batch * n_rows * n_cols; + let mut offset_lhs = 0; + let mut offset_rhs = 0; + + let batch_dims = output.rank() - 2; + for dim in 0..batch_dims { + offset_lhs += offset_output / output.stride(dim) % lhs.shape(dim) * lhs.stride(dim); + offset_rhs += offset_output / output.stride(dim) % rhs.shape(dim) * rhs.stride(dim); + } + + let mut sum = F::new(0.0); + for k in 0..dim_k { + let lhs_index = row * dim_k + k; + let rhs_index = k * n_cols + col; + + sum += lhs[offset_lhs + lhs_index] * rhs[offset_rhs + rhs_index]; + } + + let out_index = row * n_cols + col; + let index = offset_output + out_index; + + output[index] = F::max(sum + bias[index], F::new(0.0)); +} +``` + +Now, let's move on to the next step, which involves implementing the remaining code to launch the +kernel. We'll go into implementing our custom backend trait for the generic JIT backend. This +automatically implements the trait for `burn-cuda`, `burn-wgpu` as well as fusion. + +```rust, ignore +/// Implement our custom backend trait for the generic `CubeBackend`. +impl Backend for CubeBackend +{ + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor { + // Define cube dim, hardcoded for simplicity. + let cube_dim = CubeDim { x: 16, y: 16, z: 1 }; + + lhs.assert_is_on_same_device(&rhs); + lhs.assert_is_on_same_device(&bias); + + // For simplicity, make sure each tensor is continuous. + let lhs = into_contiguous(lhs); + let rhs = into_contiguous(rhs); + let bias = into_contiguous(bias); + + // Get the matmul relevant shapes. + let ndims = lhs.shape.num_dims(); + let num_rows = lhs.shape[ndims - 2]; + let num_cols = rhs.shape[ndims - 1]; + + // Compute shape of output, while tracking number of batches. + let mut num_batches = 1; + let mut shape_out = vec![0; ndims]; + for i in shape_out.clone().into_iter().take(ndims - 2) { + shape_out[i] = usize::max(lhs.shape[i], rhs.shape[i]); + num_batches *= shape_out[i]; + } + shape_out[ndims - 2] = num_rows; + shape_out[ndims - 1] = num_cols; + let shape_out = Shape::from(shape_out); + + // Create a buffer for the output tensor. + let buffer = lhs + .client + .empty(shape_out.num_elements() * core::mem::size_of::()); + + // Create the output tensor primitive. + let output = CubeTensor::new_contiguous( + lhs.client.clone(), + lhs.device.clone(), + shape_out, + buffer, + F::dtype(), + ); + + // Declare the wgsl workgroup with the number of cubes in x, y and z. + let cubes_needed_in_x = f32::ceil(num_rows as f32 / cube_dim.x as f32) as u32; + let cubes_needed_in_y = f32::ceil(num_cols as f32 / cube_dim.y as f32) as u32; + let cube_count = + CubeCount::Static(cubes_needed_in_x, cubes_needed_in_y, num_batches as u32); + + // Execute lazily the kernel with the launch information and the given buffers. For + // simplicity, no vectorization is performed + fused_matmul_add_relu_kernel::launch::( + &lhs.client, + cube_count, + cube_dim, + lhs.into_tensor_arg(), + rhs.into_tensor_arg(), + bias.into_tensor_arg(), + output.clone().into_tensor_arg(), + ); + + // Return the output tensor. + output + } +} +``` + +In the preceding code block, we demonstrated how to launch the kernel that modifies the correct +buffer. It's important to note that Rust's mutability safety doesn't apply here; the context has the +capability to execute any mutable operation on any buffer. While this isn't a problem in the +previous scenario where we only modify the newly created output buffer, it is wise to keep this in +mind. + +## Backward + +Now that the custom backend trait is implemented for the JIT backend, you can use it to invoke the +`matmul_add_relu_custom` function. However, calculating gradients is not yet possible at this stage. +If your use case does not extend beyond inference, there is no need to implement any of the +following code. + +For the backward pass, we will leverage the backend implementation from `burn-autodiff`, which is +actually generic over the backend. Instead of crafting our own `cubecl` kernel for the backward +pass, we will use our fused kernel only for the forward pass, and compute the gradient using basic +operations. + +```rust, ignore +// Implement our custom backend trait for any backend that also implements our custom backend trait. +impl Backend for Autodiff { + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor { + // Create our zero-sized type that will implement the Backward trait. + #[derive(Debug)] + struct FusedMatmulAddReluBackward; + + // Implement the backward trait for the given backend B, the node gradient + // with three other gradients to calculate (lhs, rhs, and bias). + impl Backward for FusedMatmulAddReluBackward { + // Our state that we must build during the forward pass to compute the backward pass. + // + // Note that we could improve the performance further by only keeping the state of + // tensors that are tracked, improving memory management, but for simplicity, we avoid + // that part. + type State = (NodeId, NodeId, FloatTensor, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + // Get the nodes of each variable. + let [node_lhs, node_rhs, node_bias] = ops.parents; + // Fetch the gradient for the current node. + let grad = grads.consume::(&ops.node); + + // Set our state. + let (lhs_state, rhs_state, output, shape_bias) = ops.state; + let lhs: FloatTensor = checkpointer.retrieve_node_output(lhs_state); + let rhs: FloatTensor = checkpointer.retrieve_node_output(rhs_state); + + // Fetch shapes of our tensor to support broadcasting. + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + + // Compute the gradient of the output using the already existing `relu_backward` + // function in the basic Burn backend trait. + let grad_output = B::relu_backward(output, grad); + + // Compute the lhs gradient, which is the derivative of matmul with support for + // broadcasting. + let grad_lhs = broadcast_shape::( + B::float_matmul(grad_output.clone(), B::float_transpose(rhs)), + &shape_lhs, + ); + // Compute the rhs gradient, which is the derivative of matmul with support for + // broadcasting. + let grad_rhs = broadcast_shape::( + B::float_matmul(B::float_transpose(lhs), grad_output.clone()), + &shape_rhs, + ); + // The add derivative is only 1, so we just need to support broadcasting to + // compute the bias gradient. + let grad_bias = broadcast_shape::(grad_output, &shape_bias); + + // Register the gradient for each variable based on whether they are marked as + // `tracked`. + if let Some(node) = node_bias { + grads.register::(node.id, grad_bias); + } + if let Some(node) = node_lhs { + grads.register::(node.id, grad_lhs); + } + if let Some(node) = node_rhs { + grads.register::(node.id, grad_rhs); + } + } + } + + // Prepare a stateful operation with each variable node and corresponding graph. + // + // Each node can be fetched with `ops.parents` in the same order as defined here. + match FusedMatmulAddReluBackward + .prepare::([lhs.node.clone(), rhs.node.clone(), bias.node.clone()]) + // Marks the operation as compute bound, meaning it will save its + // state instead of recomputing itself during checkpointing + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + // When at least one node is tracked, we should register our backward step. + + // The state consists of what will be needed for this operation's backward pass. + // Since we need the parents' outputs, we must checkpoint their ids to retrieve + // their node output at the beginning of the backward pass. We can also save + // utility data such as the bias shape. If we also need this operation's output, + // we can either save it in the state or recompute it. + // during the backward pass. Here we choose to save it in the state because it's a + // compute bound operation. + let lhs_state = prep.checkpoint(&lhs); + let rhs_state = prep.checkpoint(&rhs); + let bias_shape = bias.primitive.shape(); + + let output = B::fused_matmul_add_relu( + lhs.primitive.clone(), + rhs.primitive.clone(), + bias.primitive, + ); + + let state = (lhs_state, rhs_state, output.clone(), bias_shape); + + prep.finish(state, output) + } + OpsKind::UnTracked(prep) => { + // When no node is tracked, we can just compute the original operation without + // keeping any state. + let output = B::fused_matmul_add_relu(lhs.primitive, rhs.primitive, bias.primitive); + prep.finish(output) + } + } + } +} +``` + +The previous code is self-documented to make it clearer, but here is what it does in summary: + +We define `fused_matmul_add_relu` within `Autodiff`, allowing any autodiff-decorated backend to +benefit from our implementation. In an autodiff-decorated backend, the forward pass must still be +implemented. This is achieved using a comprehensive match statement block where computation is +delegated to the inner backend, while keeping track of a state. The state comprises any information +relevant to the backward pass, such as input and output tensors, along with the bias shape. When an +operation isn't tracked (meaning there won't be a backward pass for this specific operation in the +graph), storing a state becomes unnecessary, and we simply perform the forward computation. + +The backward pass uses the gradient obtained from the preceding node in the computation graph. It +calculates the derivatives for `relu` (`relu_backward`), add (no operation is required here, as the +derivative is one), and `matmul` (another `matmul` with transposed inputs). This results in +gradients for both input tensors and the bias, which are registered for consumption by subsequent +operation nodes. + +The only remaining part is to implement our autodiff-decorated backend trait for our JIT Backend. + +```rust, ignore +impl AutodiffBackend for Autodiff> +{ +} +``` + +## Conclusion + +In this guide, we've implemented a fused kernel using the `cubecl` compiler frontend, enabling +execution on any GPU and any `cubecl` backend. By delving into the inner workings of both the JIT +backend and the autodiff backend, we've gained a deeper understanding of these systems. + +While extending a backend may be harder than working with straightforward tensors, the benefits can +be worth it. This approach enables the crafting of custom models with greater control over +execution, which can potentially greatly enhance the performance of your models. + +As we conclude this guide, we hope that you have gained insights into Burn's world of backend +extensions, and that it will help you to unleash the full potential of your projects. diff --git a/burn-book/src/advanced/backend-extension/custom-wgpu-kernel.md b/burn-book/src/advanced/backend-extension/custom-wgpu-kernel.md new file mode 100644 index 0000000..c252600 --- /dev/null +++ b/burn-book/src/advanced/backend-extension/custom-wgpu-kernel.md @@ -0,0 +1,459 @@ +# Custom WGPU Kernel + +In this section, you will learn how to create your own custom operation by writing your own kernel +with the WGPU backend. We will take the example of a common workflow in the deep learning field, +where we create a kernel to fuse multiple operations together. Note that `burn` does this +automatically, but a manual implementation might be more efficient in some cases. We will fuse a +matmul kernel followed by an addition and the ReLU activation function, which is commonly found in +various models. All the code can be found under the +[examples directory](https://github.com/tracel-ai/burn/tree/main/examples/custom-wgpu-kernel). + +## Custom Backend Trait + +First, we need to determine the type signature of our newly created operation by defining our custom +backend traits. As we will use the associated type `TensorPrimitive` of the `Backend` trait, which +encapsulates the underlying tensor implementation of the backend, we will use a type alias to avoid +the ugly disambiguation with associated types. + +```rust, ignore +/// We create our own Backend trait that extends the Burn backend trait. +pub trait Backend: burn::tensor::backend::Backend { + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor; +} + +/// We create our own AutodiffBackend trait that extends the Burn autodiff backend trait. +pub trait AutodiffBackend: Backend + burn::tensor::backend::AutodiffBackend {} +``` + +In our project, we can use these traits instead of the +`burn::tensor::backend::{Backend, AutodiffBackend}` traits provided by Burn. Burn's user APIs +typically make use of the `Tensor` struct rather than dealing directly with primitive tensor types. +Therefore, we can encapsulate our newly defined backend traits with functions that expose new +operations while maintaining a consistent API. + +```rust, ignore +/// We define our custom implementation using the added function on our custom backend. +pub fn matmul_add_relu_custom( + lhs: Tensor, + rhs: Tensor, + bias: Tensor, +) -> Tensor { + let output = B::fused_matmul_add_relu( + lhs.into_primitive().tensor(), + rhs.into_primitive().tensor(), + bias.into_primitive().tensor(), + ); + + Tensor::from_primitive(TensorPrimitive::Float(output)) +} + +/// We define a reference implementation using basic tensor operations. +pub fn matmul_add_relu_reference( + lhs: Tensor, + rhs: Tensor, + bias: Tensor, +) -> Tensor { + let x = lhs.matmul(rhs) + bias; + + activation::relu(x) +} + +``` + +Note that we also provide a reference implementation for testing purposes, which allows us to easily +validate our new implementation. While not mandatory, having a reference implementation can be +valuable, especially in projects where creating a reference implementation solely using basic tensor +operations is feasible. + +## Forward Kernel + +Now, let's proceed to write the fused kernel using the WGSL shading language. To keep things simple, +we'll create a straightforward matmul kernel without employing any intricate techniques. Although we +won't delve into the details of the WGSL syntax, as it falls beyond the scope of this guide, we +still provide the implementation below for readers who are curious. The actual matmul, add and relu +computations are found at the end, after an extensive overhead whose use is to correctly map each +compute unit to the data it is responsible of, with support for batches. + +```wgsl, ignore +@group(0) +@binding(0) +var lhs: array<{{ elem }}>; + +@group(0) +@binding(1) +var rhs: array<{{ elem }}>; + +@group(0) +@binding(2) +var bias: array<{{ elem }}>; + +@group(0) +@binding(3) +var output: array<{{ elem }}>; + +@group(0) +@binding(4) +var info: array; + +const BLOCK_SIZE = {{ workgroup_size_x }}u; + +@compute +@workgroup_size({{ workgroup_size_x }}, {{ workgroup_size_y }}, 1) +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_index) local_idx: u32, + @builtin(workgroup_id) workgroup_id: vec3, +) { + // Indices + let row = workgroup_id.x * BLOCK_SIZE + (local_idx / BLOCK_SIZE); + let col = workgroup_id.y * BLOCK_SIZE + (local_idx % BLOCK_SIZE); + let batch = global_id.z; + + // Basic information + let dim = info[0]; + let n_rows = info[6u * dim - 1u]; + let n_cols = info[6u * dim]; + let K = info[5u * dim - 1u]; + + // Returns if outside the output dimension + if row >= n_rows || col >= n_cols { + return; + } + + // Calculate the corresponding offsets with support for broadcasting. + let offset_output = batch * n_rows * n_cols; + var offset_lhs: u32 = 0u; + var offset_rhs: u32 = 0u; + + let batch_dims = dim - 2u; + for (var b: u32 = 1u; b <= batch_dims; b++) { + let stride_lhs = info[b]; + let stride_rhs = info[b + dim]; + let stride_output = info[b + 2u * dim]; + let shape_lhs = info[b + 3u * dim]; + let shape_rhs = info[b + 4u * dim]; + + offset_lhs += offset_output / stride_output % shape_lhs * stride_lhs; + offset_rhs += offset_output / stride_output % shape_rhs * stride_rhs; + } + + // Basic matmul implementation + var sum = 0.0; + for (var k: u32 = 0u; k < K; k++) { + let lhs_index = row * K + k; + let rhs_index = k * n_cols + col; + + sum += lhs[offset_lhs + lhs_index] * rhs[offset_rhs + rhs_index]; + } + + let output_index = row * n_cols + col; + let index = offset_output + output_index; + + // Add and ReLU + output[index] = max(sum + bias[index], 0.0); +} +``` + +Now, let's move on to the next step, which involves implementing the remaining code to launch the +kernel. The initial part entails loading the template and populating it with the appropriate +variables. The `register(name, value)` method simply replaces occurrences of `{{ name }}` in the +above WGSL code with some other string before it is compiled. In order to use templating utilities, +you will have to activate the `template` feature of Burn in your `cargo.toml`. + +```rust, ignore +// Source the kernel written in WGSL. +kernel_wgsl!(FusedMatmulAddReluRaw, "./kernel.wgsl"); + +// Define our kernel type with cube information. +#[derive(new, Debug)] +struct FusedMatmulAddRelu { + cube_dim: CubeDim, + _elem: PhantomData, +} + +// Implement the dynamic kernel trait for our kernel type. +impl KernelSource for FusedMatmulAddRelu { + fn source(&self) -> SourceTemplate { + // Extend our raw kernel with cube size information using the + // `SourceTemplate` trait. + FusedMatmulAddReluRaw::new() + .source() + .register("workgroup_size_x", self.cube_dim.x.to_string()) + .register("workgroup_size_y", self.cube_dim.y.to_string()) + .register("elem", E::type_name()) + .register("int", "i32") + } + + fn id(&self) -> cubecl::KernelId { + cubecl::KernelId::new::().info(self.cube_dim) + } +} +``` + +Subsequently, we'll go into implementing our custom backend trait for the WGPU backend. Note that we +won't go into supporting the `fusion` feature flag in this tutorial, so we implement the trait for +the raw `WgpuBackend` type. + +```rust, ignore +/// Implement our custom backend trait for the existing backend `WgpuBackend`. +impl Backend for CubeBackend +{ + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor { + // Define cube dim, hardcoded for simplicity. + let cube_dim = CubeDim { x: 16, y: 16, z: 1 }; + + lhs.assert_is_on_same_device(&rhs); + lhs.assert_is_on_same_device(&bias); + + // For simplicity, make sure each tensor is continuous. + let lhs = into_contiguous(lhs); + let rhs = into_contiguous(rhs); + let bias = into_contiguous(bias); + + // Get the matmul relevant shapes. + let ndims = lhs.shape.num_dims(); + let num_rows = lhs.shape[ndims - 2]; + let num_cols = rhs.shape[ndims - 1]; + + // Compute shape of output, while tracking number of batches. + let mut num_batches = 1; + let mut shape_out = vec![0; ndims]; + for i in shape_out.clone().into_iter().take(ndims - 2) { + shape_out[i] = usize::max(lhs.shape[i], rhs.shape[i]); + num_batches *= shape_out[i]; + } + shape_out[ndims - 2] = num_rows; + shape_out[ndims - 1] = num_cols; + let shape_out = Shape::from(shape_out); + + // Create a buffer for the output tensor. + let buffer = lhs + .client + .empty(shape_out.num_elements() * core::mem::size_of::()); + + // Create the output tensor primitive. + let output = CubeTensor::new_contiguous( + lhs.client.clone(), + lhs.device.clone(), + shape_out, + buffer, + F::dtype(), + ); + + // Create the kernel. + let kernel = FusedMatmulAddRelu::::new(cube_dim); + + // Build info buffer with tensor information needed by the kernel, such as shapes and strides. + let info = build_info::<_, F>(&[&lhs, &rhs, &output]); + let info_handle = lhs.client.create(bytemuck::cast_slice(&info)); + + // Declare the wgsl workgroup with the number of cubes in x, y and z. + let cubes_needed_in_x = f32::ceil(num_rows as f32 / cube_dim.x as f32) as u32; + let cubes_needed_in_y = f32::ceil(num_cols as f32 / cube_dim.y as f32) as u32; + let cube_count = + CubeCount::Static(cubes_needed_in_x, cubes_needed_in_y, num_batches as u32); + + // Execute lazily the kernel with the launch information and the given buffers. + lhs.client.execute( + Box::new(SourceKernel::new(kernel, cube_dim)), + cube_count, + Bindings::new().with_buffers(vec![ + lhs.handle.binding(), + rhs.handle.binding(), + bias.handle.binding(), + output.handle.clone().binding(), + info_handle.binding(), + ]), + ); + + // Return the output tensor. + output + } +} +``` + +In the preceding code block, we demonstrated how to launch the kernel that modifies the correct +buffer. It's important to note that Rust's mutability safety doesn't apply here; the context has the +capability to execute any mutable operation on any buffer. While this isn't a problem in the +previous scenario where we only modify the newly created output buffer, it is wise to keep this in +mind. + +## Backward + +Now that the custom backend trait is implemented for the WGPU backend, you can use it to invoke the +`matmul_add_relu_custom` function. However, calculating gradients is not yet possible at this stage. +If your use case does not extend beyond inference, there is no need to implement any of the +following code. + +For the backward pass, we will leverage the backend implementation from `burn-autodiff`, which is +actually generic over the backend. Instead of crafting our own WGSL kernel for the backward pass, we +will use our fused kernel only for the forward pass, and compute the gradient using basic +operations. + +```rust, ignore +// Implement our custom backend trait for any backend that also implements our custom backend trait. +// +// Note that we could implement the backend trait only for the Wgpu backend instead of any backend that +// also implements our own API. This would allow us to call any function only implemented for Wgpu +// and potentially call a custom kernel crafted only for this task. +impl Backend for Autodiff { + fn fused_matmul_add_relu( + lhs: FloatTensor, + rhs: FloatTensor, + bias: FloatTensor, + ) -> FloatTensor { + // Create our zero-sized type that will implement the Backward trait. + #[derive(Debug)] + struct FusedMatmulAddReluBackward; + + // Implement the backward trait for the given backend B, the node gradient + // with three other gradients to calculate (lhs, rhs, and bias). + impl Backward for FusedMatmulAddReluBackward { + // Our state that we must build during the forward pass to compute the backward pass. + // + // Note that we could improve the performance further by only keeping the state of + // tensors that are tracked, improving memory management, but for simplicity, we avoid + // that part. + type State = (NodeId, NodeId, FloatTensor, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + // Get the nodes of each variable. + let [node_lhs, node_rhs, node_bias] = ops.parents; + // Fetch the gradient for the current node. + let grad = grads.consume::(&ops.node); + + // Set our state. + let (lhs_state, rhs_state, output, shape_bias) = ops.state; + let lhs: FloatTensor = checkpointer.retrieve_node_output(lhs_state); + let rhs: FloatTensor = checkpointer.retrieve_node_output(rhs_state); + + // Fetch shapes of our tensor to support broadcasting. + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + + // Compute the gradient of the output using the already existing `relu_backward` + // function in the basic Burn backend trait. + let grad_output = B::relu_backward(output, grad); + + // Compute the lhs gradient, which is the derivative of matmul with support for + // broadcasting. + let grad_lhs = broadcast_shape::( + B::float_matmul(grad_output.clone(), B::float_transpose(rhs)), + &shape_lhs, + ); + // Compute the rhs gradient, which is the derivative of matmul with support for + // broadcasting. + let grad_rhs = broadcast_shape::( + B::float_matmul(B::float_transpose(lhs), grad_output.clone()), + &shape_rhs, + ); + // The add derivative is only 1, so we just need to support broadcasting to + // compute the bias gradient. + let grad_bias = broadcast_shape::(grad_output, &shape_bias); + + // Register the gradient for each variable based on whether they are marked as + // `tracked`. + if let Some(node) = node_bias { + grads.register::(node.id, grad_bias); + } + if let Some(node) = node_lhs { + grads.register::(node.id, grad_lhs); + } + if let Some(node) = node_rhs { + grads.register::(node.id, grad_rhs); + } + } + } + + // Prepare a stateful operation with each variable node and corresponding graph. + // + // Each node can be fetched with `ops.parents` in the same order as defined here. + match FusedMatmulAddReluBackward + .prepare::([lhs.node.clone(), rhs.node.clone(), bias.node.clone()]) + // Marks the operation as compute bound, meaning it will save its + // state instead of recomputing itself during checkpointing + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + // When at least one node is tracked, we should register our backward step. + + // The state consists of what will be needed for this operation's backward pass. + // Since we need the parents' outputs, we must checkpoint their ids to retrieve their node + // output at the beginning of the backward. We can also save utility data such as the bias shape + // If we also need this operation's output, we can either save it in the state or recompute it + // during the backward pass. Here we choose to save it in the state because it's a compute bound operation. + let lhs_state = prep.checkpoint(&lhs); + let rhs_state = prep.checkpoint(&rhs); + let bias_shape = bias.primitive.shape(); + + let output = B::fused_matmul_add_relu( + lhs.primitive.clone(), + rhs.primitive.clone(), + bias.primitive, + ); + + let state = (lhs_state, rhs_state, output.clone(), bias_shape); + + prep.finish(state, output) + } + OpsKind::UnTracked(prep) => { + // When no node is tracked, we can just compute the original operation without + // keeping any state. + let output = B::fused_matmul_add_relu(lhs.primitive, rhs.primitive, bias.primitive); + prep.finish(output) + } + } + } +} +``` + +The previous code is self-documented to make it clearer, but here is what it does in summary. + +We define `fused_matmul_add_relu` within `Autodiff`, allowing any autodiff-decorated backend to +benefit from our implementation. In an autodiff-decorated backend, the forward pass must still be +implemented. This is achieved using a comprehensive match statement block where computation is +delegated to the inner backend, while keeping track of a state. The state comprises any information +relevant to the backward pass, such as input and output tensors, along with the bias shape. When an +operation isn't tracked (meaning there won't be a backward pass for this specific operation in the +graph), storing a state becomes unnecessary, and we simply perform the forward computation. + +The backward pass uses the gradient obtained from the preceding node in the computation graph. It +calculates the derivatives for `relu` (`relu_backward`), add (no operation is required here, as the +derivative is one), and `matmul` (another `matmul` with transposed inputs). This results in +gradients for both input tensors and the bias, which are registered for consumption by subsequent +operation nodes. + +The only remaining part is to implement our autodiff-decorated backend trait for our WGPU Backend. + +```rust, ignore +impl AutodiffBackend for Autodiff> +{ +} +``` + +## Conclusion + +In this guide, we've implemented a fused kernel using the WGPU backend, enabling execution on any +GPU. By delving into the inner workings of both the WGPU backend and the autodiff backend, we've +gained a deeper understanding of these systems. + +While extending a backend may be harder than working with straightforward tensors, the benefits can +be worth it. This approach enables the crafting of custom models with greater control over +execution, which can potentially greatly enhance the performance of your models. + +As we conclude this guide, we hope that you have gained insights into Burn's world of backend +extensions, and that it will help you to unleash the full potential of your projects. diff --git a/burn-book/src/advanced/no-std.md b/burn-book/src/advanced/no-std.md new file mode 100644 index 0000000..a0eaeb9 --- /dev/null +++ b/burn-book/src/advanced/no-std.md @@ -0,0 +1,100 @@ +# No Standard Library + +In this section, you will learn how to run an ONNX inference model on an embedded system, with no +standard library support on a Raspberry Pi Pico 2. This should be universally applicable to other +platforms. All the code can be found in the +[burn-onnx examples](https://github.com/tracel-ai/burn-onnx/tree/main/examples/raspberry-pi-pico). + +## Step-by-Step Guide + +Let's walk through the process of running an embedded ONNX model: + +### Setup +Follow the [embassy guide](https://embassy.dev/book/#_getting_started) for your specific environment. Once setup, you should have something similar to the following. +``` +./inference +├── Cargo.lock +├── Cargo.toml +├── build.rs +├── memory.x +└── src + └── main.rs +``` + +Some other dependencies have to be added +```toml +[dependencies] +embedded-alloc = "0.6.0" # Only if there is no default allocator for your chip +burn = { version = "0.21", default-features = false, features = ["flex"] } # Flex supports no_std +burn-store = { version = "0.21", default-features = false, features = ["burnpack"] } + +[build-dependencies] +burn-onnx = { version = "0.21" } # Used to auto generate the rust code to import the model +``` + +### Import the Model +Follow the directions in [ONNX Import](../onnx-import.md). + +Use the following ModelGen config +```rs +ModelGen::new() + .input(my_model) + .out_dir("model/") + .embed_states(true) + .run_from_script(); +``` + +### Global Allocator +First define a global allocator (if you are on a no_std system without alloc). + +```rs +use embedded_alloc::LlffHeap as Heap; + +#[global_allocator] +static HEAP: Heap = Heap::empty(); + +#[embassy_executor::main] +async fn main(_spawner: Spawner) { + { + use core::mem::MaybeUninit; + // Watch out for this, if it is too big or small for your model, the + // program may crash. This is in u8 bytes, as such this is a total of 100kb + const HEAP_SIZE: usize = 100 * 1024; + static mut HEAP_MEM: [MaybeUninit; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; + unsafe { HEAP.init(&raw mut HEAP_MEM as usize, HEAP_SIZE) } // Initialize the heap + } +} +``` + +### Define Backend +We are using Flex, so we just need to define the Flex backend as usual +```rs +use burn::{backend::Flex, tensor::{Device, Tensor}}; + +type Backend = Flex; +type BackendDevice = Device; +``` + +Then inside the `main` function add +```rs +use your_model::Model; + +// Get a default device for the backend +let device = BackendDevice::default(); + +// Create a new model and load the state +let model: Model = Model::default(); +``` + +### Running the Model +To run the model, just call it as you would normally +```rs +// Define the tensor +let input = Tensor::::from_floats([[input]], &device); + +// Run the model on the input +let output = model.forward(input); +``` + +## Conclusion +Running a model in a no_std environment is pretty much identical to a normal environment. All that is needed is a global allocator. diff --git a/burn-book/src/advanced/web-assembly.md b/burn-book/src/advanced/web-assembly.md new file mode 100644 index 0000000..962955f --- /dev/null +++ b/burn-book/src/advanced/web-assembly.md @@ -0,0 +1,12 @@ +# WebAssembly + +Burn supports WebAssembly (WASM) execution using the `Flex` and `WebGpu` backends, allowing +models to run directly in the browser. + +Check out the following examples: + +- [Image Classification Web](https://github.com/tracel-ai/burn-onnx/tree/main/examples/image-classification-web) +- [MNIST Inference on Web](https://github.com/tracel-ai/burn/tree/main/examples/mnist-inference-web) + +When targeting WebAssembly, certain dependencies require additional configuration. In particular, +the `getrandom` crate requires explicit setting when using `WebGpu`. diff --git a/burn-book/src/basic-workflow/README.md b/burn-book/src/basic-workflow/README.md new file mode 100644 index 0000000..4225eca --- /dev/null +++ b/burn-book/src/basic-workflow/README.md @@ -0,0 +1,33 @@ +# Guide + +This guide will walk you through the process of creating a custom model built with Burn. We will +train a simple convolutional neural network model on the MNIST dataset and prepare it for inference. + +For clarity, we sometimes omit imports in our code snippets. For more details, please refer to the +corresponding code in the `examples/guide` [directory](https://github.com/tracel-ai/burn/tree/main/examples/guide). +We reproduce this example in a step-by-step fashion, from dataset creation to modeling and training +in the following sections. It is recommended to use the capabilities of your IDE or text editor to +automatically add the missing imports as you add the code snippets to your code. + +

+ +The code for this demo can be executed from Burn's base directory using the command: + +```bash +cargo run --example guide +``` + +## Key Learnings + +- Creating a project +- Creating neural network models +- Importing and preparing datasets +- Training models on data +- Choosing a backend +- Using a model for inference diff --git a/burn-book/src/basic-workflow/backend.md b/burn-book/src/basic-workflow/backend.md new file mode 100644 index 0000000..ba43f5d --- /dev/null +++ b/burn-book/src/basic-workflow/backend.md @@ -0,0 +1,54 @@ +# Backend + +We have effectively written most of the necessary code to train our model. However, we have not +explicitly designated the backend to be used at any point. This will be defined in the main +entrypoint of our program, namely the `main` function defined in `src/main.rs`. + +```rust , ignore +# #![recursion_limit = "256"] +# mod data; +# mod model; +# mod training; +# +use crate::{model::ModelConfig, training::TrainingConfig}; +use burn::{ + backend::{Autodiff, Wgpu}, +# data::dataset::Dataset, + optim::AdamConfig, +}; + +fn main() { + type MyBackend = Wgpu; + type MyAutodiffBackend = Autodiff; + + let device = burn::backend::wgpu::WgpuDevice::default(); + let artifact_dir = "/tmp/guide"; + crate::training::train::( + artifact_dir, + TrainingConfig::new(ModelConfig::new(10, 512), AdamConfig::new()), + device.clone(), + ); +} +``` + +In this code snippet, we use the `Wgpu` backend which is compatible with any operating system and will +use the GPU. For other options, see the Burn README. This backend type takes the graphics API, the +float type and the int type as generic arguments that will be used during the training. The autodiff +backend is simply the same backend, wrapped within the `Autodiff` struct which imparts differentiability +to any backend. + +We call the `train` function defined earlier with a directory for artifacts, the configuration of +the model (the number of digit classes is 10 and the hidden dimension is 512), the optimizer +configuration which in our case will be the default Adam configuration, and the device which can be +obtained from the backend. + +You can now train your freshly created model with the command: + +```console +cargo run --release +``` + +When running your project with the command above, you should see the training progression through a +basic CLI dashboard: + +Alt text diff --git a/burn-book/src/basic-workflow/data.md b/burn-book/src/basic-workflow/data.md new file mode 100644 index 0000000..96756bf --- /dev/null +++ b/burn-book/src/basic-workflow/data.md @@ -0,0 +1,126 @@ +# Data + +Typically, one trains a model on some dataset. Burn provides a library of very useful dataset +sources and transformations, such as Hugging Face dataset utilities that allow to download and store +data into an SQLite database for extremely efficient data streaming and storage. For this guide +though, we will use the MNIST dataset from `burn::data::dataset::vision` which requires no external +dependency. + +To iterate over a dataset efficiently, we will define a struct which will implement the `Batcher` +trait. The goal of a batcher is to map individual dataset items into a batched tensor that can be +used as input to our previously defined model. + +Let us start by defining our dataset functionalities in a file `src/data.rs`. We shall omit some of +the imports for brevity, but the full code for following this guide can be found at +`examples/guide/` [directory](https://github.com/tracel-ai/burn/tree/main/examples/guide). + +```rust , ignore +use burn::{ + data::{dataloader::batcher::Batcher, dataset::vision::MnistItem}, + prelude::*, +}; + + +#[derive(Clone, Default)] +pub struct MnistBatcher {} +``` + +This batcher is pretty straightforward, as it only defines a struct that will implement the +`Batcher` trait. The trait is generic over the `Backend` trait, which includes an associated type +for the device, as not all backends expose the same devices. As an example, the Libtorch-based +backend exposes `Cuda(gpu_index)`, `Cpu`, `Vulkan` and `Metal` devices, while the Flex backend +only exposes a single CPU device. + +Next, we need to actually implement the batching logic. + +```rust , ignore +# use burn::{ +# data::{dataloader::batcher::Batcher, dataset::vision::MnistItem}, +# prelude::*, +# }; +# +# #[derive(Clone, Default)] +# pub struct MnistBatcher {} +# +#[derive(Clone, Debug)] +pub struct MnistBatch { + pub images: Tensor, + pub targets: Tensor, +} + +impl Batcher> for MnistBatcher { + fn batch(&self, items: Vec, device: &B::Device) -> MnistBatch { + let images = items + .iter() + .map(|item| TensorData::from(item.image).convert::()) + .map(|data| Tensor::::from_data(data, device)) + .map(|tensor| tensor.reshape([1, 28, 28])) + // Normalize: scale between [0,1] and make the mean=0 and std=1 + // values mean=0.1307,std=0.3081 are from the PyTorch MNIST example + // https://github.com/pytorch/examples/blob/54f4572509891883a947411fd7239237dd2a39c3/mnist/main.py#L122 + .map(|tensor| ((tensor / 255) - 0.1307) / 0.3081) + .collect(); + + let targets = items + .iter() + .map(|item| { + Tensor::::from_data([(item.label as i64).elem::()], device) + }) + .collect(); + + let images = Tensor::cat(images, 0); + let targets = Tensor::cat(targets, 0); + + MnistBatch { images, targets } + } +} +``` + +
+🦀 Iterators and Closures + +The iterator pattern allows you to perform some tasks on a sequence of items in turn. + +In this example, an iterator is created over the `MnistItem`s in the vector `items` by calling the +`iter` method. + +_Iterator adaptors_ are methods defined on the `Iterator` trait that produce different iterators by +changing some aspect of the original iterator. Here, the `map` method is called in a chain to +transform the original data before consuming the final iterator with `collect` to obtain the +`images` and `targets` vectors. Both vectors are then concatenated into a single tensor for the +current batch. + +You probably noticed that each call to `map` is different, as it defines a function to execute on +the iterator items at each step. These anonymous functions are called +[_closures_](https://doc.rust-lang.org/book/ch13-01-closures.html) in Rust. They're easy to +recognize due to their syntax which uses vertical bars `||`. The vertical bars capture the input +variables (if applicable) while the rest of the expression defines the function to execute. + +If we go back to the example, we can break down and comment the expression used to process the +images. + +```rust, ignore +let images = items // take items Vec + .iter() // create an iterator over it + .map(|item| TensorData::from(item.image).convert::()) // for each item, convert the image to float data struct + .map(|data| Tensor::::from_data(data, device)) // for each data struct, create a tensor on the device + .map(|tensor| tensor.reshape([1, 28, 28])) // for each tensor, reshape to the image dimensions [C, H, W] + .map(|tensor| ((tensor / 255) - 0.1307) / 0.3081) // for each image tensor, apply normalization + .collect(); // consume the resulting iterator & collect the values into a new vector +``` + +For more information on iterators and closures, be sure to check out the +[corresponding chapter](https://doc.rust-lang.org/book/ch13-00-functional-features.html) in the Rust +Book. + +

+ +In the previous example, we implement the `Batcher` trait with a list of `MnistItem` as input and a +single `MnistBatch` as output. The batch contains the images in the form of a 3D tensor, along with +a targets tensor that contains the indexes of the correct digit class. The first step is to parse +the image array into a `TensorData` struct. Burn provides the `TensorData` struct to encapsulate +tensor storage information without being specific for a backend. When creating a tensor from data, +we often need to convert the data precision to the current backend in use. This can be done with the +`.convert()` method (in this example, the data is converted backend's float element type +`B::FloatElem`). While importing the `burn::tensor::ElementConversion` trait, you can call `.elem()` +on a specific number to convert it to the current backend element type in use. diff --git a/burn-book/src/basic-workflow/inference.md b/burn-book/src/basic-workflow/inference.md new file mode 100644 index 0000000..e06a628 --- /dev/null +++ b/burn-book/src/basic-workflow/inference.md @@ -0,0 +1,89 @@ +# Inference + +Now that we have trained our model, the next natural step is to use it for inference. + +You need two things in order to load weights for a model: the model's record and the model's config. +Since parameters in Burn are lazy initialized, no allocation and GPU/CPU kernels are executed by the +`ModelConfig::init` function. The weights are initialized when used for the first time, therefore +you can safely use `config.init(device).load_record(record)` without any meaningful performance +cost. Let's create a simple `infer` method in a new file `src/inference.rs` which we will use to +load our trained model. + +```rust , ignore +# use crate::{data::MnistBatcher, training::TrainingConfig}; +# use burn::{ +# data::{dataloader::batcher::Batcher, dataset::vision::MnistItem}, +# prelude::*, +# store::ModuleRecord, +# }; +# +pub fn infer(artifact_dir: &str, device: B::Device, item: MnistItem) { + let config = TrainingConfig::load(format!("{artifact_dir}/config.json")) + .expect("Config should exist for the model; run train first"); + let record = ModuleRecord::load(format!("{artifact_dir}/model")) + .expect("Trained model should exist; run train first"); + + let model = config.model.init::(&device).load_record(record); + + let label = item.label; + let batcher = MnistBatcher::default(); + let batch = batcher.batch(vec![item], &device); + let output = model.forward(batch.images); + let predicted = output.argmax(1).flatten::<1>(0, 1).into_scalar(); + + println!("Predicted {predicted} Expected {label}"); +} +``` + +The first step is to load the configuration of the training to fetch the correct model +configuration. Then we can load the saved record from its burnpack file. Finally we can init the +model with the configuration and apply the record. For simplicity we can use the +same batcher used during the training to pass from a MnistItem to a tensor. + +By running the infer function, you should see the predictions of your model! + +Add the call to `infer` to the `main.rs` file after the `train` function call: + +```rust , ignore +# #![recursion_limit = "256"] +# mod data; +# mod inference; +# mod model; +# mod training; +# +# use crate::{model::ModelConfig, training::TrainingConfig}; +# use burn::{ +# backend::{Autodiff, Wgpu}, +# data::dataset::Dataset, +# optim::AdamConfig, +# }; +# +# fn main() { +# type MyBackend = Wgpu; +# type MyAutodiffBackend = Autodiff; +# +# let device = burn::backend::wgpu::WgpuDevice::default(); +# let artifact_dir = "/tmp/guide"; +# crate::training::train::( +# artifact_dir, +# TrainingConfig::new(ModelConfig::new(10, 512), AdamConfig::new()), +# device.clone(), +# ); + crate::inference::infer::( + artifact_dir, + device, + burn::data::dataset::vision::MnistDataset::test() + .get(42) + .unwrap(), + ); +# } +``` + +The number `42` is the index of the image in the MNIST dataset. You can explore and verify them +using this [MNIST viewer](https://observablehq.com/@davidalber/mnist-viewer). + +--- + +In this short guide, we've introduced you to the fundamental building blocks for getting started +with Burn. While there's still plenty to explore, our goal has been to provide you with the +essential knowledge to kickstart your productivity within the framework. diff --git a/burn-book/src/basic-workflow/model.md b/burn-book/src/basic-workflow/model.md new file mode 100644 index 0000000..ed8ac47 --- /dev/null +++ b/burn-book/src/basic-workflow/model.md @@ -0,0 +1,406 @@ +# Model + +The first step is to create a project and add the different Burn dependencies. Start by creating a +new project with Cargo: + +```console +cargo new guide +``` + +As [mentioned previously](../getting-started.md#creating-a-burn-application), this will initialize +your `guide` project directory with a `Cargo.toml` and a `src/main.rs` file. + +In the `Cargo.toml` file, add the `burn` dependency with `train`, `vision` and `wgpu` features. +Since we disable the default features, we also want to enable `std`, `tui` (for the dashboard) and +`fusion` for wgpu. Then run `cargo build` to build the project and import all the dependencies. + +```toml +[package] +name = "guide" +version = "0.1.0" +edition = "2024" + +[dependencies] +# Disable autotune default for convolutions +burn = { version = "~0.21", features = ["std", "tui", "train", "vision", "wgpu", "fusion"], default-features = false } +# burn = { version = "~0.21", features = ["train", "vision", "wgpu"] } +``` + +Our goal will be to create a basic convolutional neural network used for image classification. We +will keep the model simple by using two convolution layers followed by two linear layers, some +pooling and ReLU activations. We will also use dropout to improve training performance. + +Let us start by defining our model struct in a new file `src/model.rs`. + +```rust , ignore +use burn::{ + nn::{ + conv::{Conv2d, Conv2dConfig}, + pool::{AdaptiveAvgPool2d, AdaptiveAvgPool2dConfig}, + Dropout, DropoutConfig, Linear, LinearConfig, Relu, + }, + prelude::*, +}; + +#[derive(Module, Debug)] +pub struct Model { + conv1: Conv2d, + conv2: Conv2d, + pool: AdaptiveAvgPool2d, + dropout: Dropout, + linear1: Linear, + linear2: Linear, + activation: Relu, +} +``` + +There are two major things going on in this code sample. + +1. You can create a deep learning module with the `#[derive(Module)]` attribute on top of a struct. + This will generate the necessary code so that the struct implements the `Module` trait. This + trait will make your module both trainable and (de)serializable while adding related + functionalities. Like other attributes often used in Rust, such as `Clone`, `PartialEq` or + `Debug`, each field within the struct must also implement the `Module` trait. + +
+ 🦀 Trait + + Traits are a powerful and flexible Rust language feature. They provide a way to define shared + behavior for a particular type, which can be shared with other types. + + A type's behavior consists of the methods called on that type. Since all `Module`s should + implement the same functionality, it is defined as a trait. Implementing a trait on a particular + type usually requires the user to implement the defined behaviors of the trait for their types, + though that is not the case here as explained above with the `derive` attribute. Check out the + [explainer below](#derive-attribute) to learn why. + + For more details on traits, take a look at the + [associated chapter](https://doc.rust-lang.org/book/ch10-02-traits.html) in the Rust Book. +

+ +
+ 🦀 Derive Macro + + The `derive` attribute allows traits to be implemented easily by generating code that will + implement a trait with its own default implementation on the type that was annotated with the + `derive` syntax. + + This is accomplished through a feature of Rust called + [procedural macros](https://doc.rust-lang.org/reference/procedural-macros.html), which allow us + to run code at compile time that operates over Rust syntax, both consuming and producing Rust + syntax. Using the attribute `#[my_macro]`, you can effectively extend the provided code. You will + see that the derive macro is very frequently employed to recursively implement traits, where the + implementation consists of the composition of all fields. + + In this example, we want to derive the [`Module`](../building-blocks/module.md) and `Debug` + traits. + + ```rust, ignore + #[derive(Module, Debug)] + pub struct MyCustomModule { + linear1: Linear, + linear2: Linear, + activation: Relu, + } + ``` + + The basic `Debug` implementation is provided by the compiler to format a value using the `{:?}` + formatter. For ease of use, the `Module` trait implementation is automatically handled by Burn so + you don't have to do anything special. It essentially acts as parameter container. + + For more details on derivable traits, take a look at the Rust + [appendix](https://doc.rust-lang.org/book/appendix-03-derivable-traits.html), + [reference](https://doc.rust-lang.org/reference/attributes/derive.html) or + [example](https://doc.rust-lang.org/rust-by-example/trait/derive.html). +

+ +2. Note that the struct is generic over the [`Backend`](../building-blocks/backend.md) trait. The + backend trait abstracts the underlying low level implementations of tensor operations, allowing + your new model to run on any backend. Contrary to other frameworks, the backend abstraction isn't + determined by a compilation flag or a device type. This is important because you can extend the + functionalities of a specific backend (see + [backend extension section](../advanced/backend-extension)), and it allows for an innovative + [autodiff system](../building-blocks/autodiff.md). You can also change backend during runtime, + for instance to compute training metrics on a cpu backend while using a gpu one only to train the + model. In our example, the backend in use will be determined later on. + +
+ 🦀 Trait Bounds + + Trait bounds provide a way for generic items to restrict which types are used as their + parameters. The trait bounds stipulate what functionality a type implements. Therefore, bounding + restricts the generic to types that conform to the bounds. It also allows generic instances to + access the methods of traits specified in the bounds. + + For a simple but concrete example, check out the + [Rust By Example on bounds](https://doc.rust-lang.org/rust-by-example/generics/bounds.html). + + In Burn, the `Backend` trait enables you to run tensor operations using different implementations + as it abstracts tensor, device and element types. The + [getting started example](../getting-started.md#writing-a-code-snippet) illustrates the advantage + of having a simple API that works for different backend implementations. While it used the WGPU + backend, you could easily swap it with any other supported backend. + + ```rust, ignore + // Choose from any of the supported backends. + // type Backend = Cuda; + // type Backend = Flex; + type Backend = Wgpu; + + // Creation of two tensors. + let tensor_1 = Tensor::::from_data([[2., 3.], [4., 5.]], &device); + let tensor_2 = Tensor::::ones_like(&tensor_1); + + // Print the element-wise addition (done with the selected backend) of the two tensors. + println!("{}", tensor_1 + tensor_2); + ``` + + For more details on trait bounds, check out the Rust + [trait bound section](https://doc.rust-lang.org/book/ch10-02-traits.html#trait-bound-syntax) or + [reference](https://doc.rust-lang.org/reference/items/traits.html#trait-bounds). + +

+ +Note that each time you create a new file in the `src` directory you also need to explicitly add +this module to the `main.rs` file. For instance after creating the `model.rs`, you need to add the +following at the top of the main file: + +```rust , ignore +mod model; +# +# fn main() { +# } +``` + +Next, we need to instantiate the model for training. + +```rust , ignore +# use burn::{ +# nn::{ +# conv::{Conv2d, Conv2dConfig}, +# pool::{AdaptiveAvgPool2d, AdaptiveAvgPool2dConfig}, +# Dropout, DropoutConfig, Linear, LinearConfig, Relu, +# }, +# prelude::*, +# }; +# +# #[derive(Module, Debug)] +# pub struct Model { +# conv1: Conv2d, +# conv2: Conv2d, +# pool: AdaptiveAvgPool2d, +# dropout: Dropout, +# linear1: Linear, +# linear2: Linear, +# activation: Relu, +# } +# +#[derive(Config, Debug)] +pub struct ModelConfig { + num_classes: usize, + hidden_size: usize, + #[config(default = "0.5")] + dropout: f64, +} + +impl ModelConfig { + /// Returns the initialized model. + pub fn init(&self, device: &B::Device) -> Model { + Model { + conv1: Conv2dConfig::new([1, 8], [3, 3]).init(device), + conv2: Conv2dConfig::new([8, 16], [3, 3]).init(device), + pool: AdaptiveAvgPool2dConfig::new([8, 8]).init(), + activation: Relu::new(), + linear1: LinearConfig::new(16 * 8 * 8, self.hidden_size).init(device), + linear2: LinearConfig::new(self.hidden_size, self.num_classes).init(device), + dropout: DropoutConfig::new(self.dropout).init(), + } + } +} +``` + +At a glance, you can view the model configuration by printing the model instance: + +```rust , ignore +#![recursion_limit = "256"] +mod model; + +use crate::model::ModelConfig; +use burn::backend::Wgpu; + +fn main() { + type MyBackend = Wgpu; + + let device = Default::default(); + let model = ModelConfig::new(10, 512).init::(&device); + + println!("{model}"); +} +``` + +Output: + +```rust , ignore +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 80} + conv2: Conv2d {ch_in: 8, ch_out: 16, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 1168} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, bias: true, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, bias: true, params: 5130} + activation: Relu + params: 531178 +} +``` + +
+🦀 References + +In the previous example, the `init()` method signature uses `&` to indicate that the parameter types +are references: `&self`, a reference to the current receiver (`ModelConfig`), and +`device: &B::Device`, a reference to the backend device. + +```rust, ignore +pub fn init(&self, device: &B::Device) -> Model { + Model { + // ... + } +} +``` + +References in Rust allow us to point to a resource to access its data without owning it. The idea of +ownership is quite core to Rust and is worth +[reading up on](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html). + +In a language like C, memory management is explicit and up to the programmer, which means it is easy +to make mistakes. In a language like Java or Python, memory management is automatic with the help of +a garbage collector. This is very safe and straightforward, but also incurs a runtime cost. + +In Rust, memory management is rather unique. Aside from simple types that implement +[`Copy`](https://doc.rust-lang.org/std/marker/trait.Copy.html) (e.g., +[primitives](https://doc.rust-lang.org/rust-by-example/primitives.html) like integers, floats, +booleans and `char`), every value is _owned_ by some variable called the _owner_. Ownership can be +transferred from one variable to another and sometimes a value can be _borrowed_. Once the _owner_ +variable goes out of scope, the value is _dropped_, which means that any memory it allocated can be +freed safely. + +Because the method does not own the `self` and `device` variables, the values the references point +to will not be dropped when the reference stops being used (i.e., the scope of the method). + +For more information on references and borrowing, be sure to read the +[corresponding chapter](https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html) in the +Rust Book. + +

+ +When creating a custom neural network module, it is often a good idea to create a config alongside +the model struct. This allows you to define default values for your network, thanks to the `Config` +attribute. The benefit of this attribute is that it makes the configuration serializable, enabling +you to painlessly save your model hyperparameters, enhancing your experimentation process. Note that +a constructor will automatically be generated for your configuration, which will take in as input +values the parameters which do not have default values: +`let config = ModelConfig::new(num_classes, hidden_size);`. The default values can be overridden +easily with builder-like methods: (e.g `config.with_dropout(0.2);`) + +The first implementation block is related to the initialization method. As we can see, all fields +are set using the configuration of the corresponding neural network's underlying module. In this +specific case, we have chosen to expand the tensor channels from 1 to 8 with the first layer, then +from 8 to 16 with the second layer, using a kernel size of 3 on all dimensions. We also use the +adaptive average pooling module to reduce the dimensionality of the images to an 8 by 8 matrix, +which we will flatten in the forward pass to have a 1024 (16 * 8 * 8) resulting tensor. + +Now let's see how the forward pass is defined. + +```rust , ignore +# use burn::{ +# nn::{ +# conv::{Conv2d, Conv2dConfig}, +# pool::{AdaptiveAvgPool2d, AdaptiveAvgPool2dConfig}, +# Dropout, DropoutConfig, Linear, LinearConfig, Relu, +# }, +# prelude::*, +# }; +# +# #[derive(Module, Debug)] +# pub struct Model { +# conv1: Conv2d, +# conv2: Conv2d, +# pool: AdaptiveAvgPool2d, +# dropout: Dropout, +# linear1: Linear, +# linear2: Linear, +# activation: Relu, +# } +# +# #[derive(Config, Debug)] +# pub struct ModelConfig { +# num_classes: usize, +# hidden_size: usize, +# #[config(default = "0.5")] +# dropout: f64, +# } +# +# impl ModelConfig { +# /// Returns the initialized model. +# pub fn init(&self, device: &B::Device) -> Model { +# Model { +# conv1: Conv2dConfig::new([1, 8], [3, 3]).init(device), +# conv2: Conv2dConfig::new([8, 16], [3, 3]).init(device), +# pool: AdaptiveAvgPool2dConfig::new([8, 8]).init(), +# activation: Relu::new(), +# linear1: LinearConfig::new(16 * 8 * 8, self.hidden_size).init(device), +# linear2: LinearConfig::new(self.hidden_size, self.num_classes).init(device), +# dropout: DropoutConfig::new(self.dropout).init(), +# } +# } +# } +# +impl Model { + /// # Shapes + /// - Images [batch_size, height, width] + /// - Output [batch_size, num_classes] + pub fn forward(&self, images: Tensor) -> Tensor { + let [batch_size, height, width] = images.dims(); + + // Create a channel at the second dimension. + let x = images.reshape([batch_size, 1, height, width]); + + + let x = self.conv1.forward(x); // [batch_size, 8, _, _] + let x = self.dropout.forward(x); + let x = self.conv2.forward(x); // [batch_size, 16, _, _] + let x = self.dropout.forward(x); + let x = self.activation.forward(x); + + let x = self.pool.forward(x); // [batch_size, 16, 8, 8] + let x = x.reshape([batch_size, 16 * 8 * 8]); + let x = self.linear1.forward(x); + let x = self.dropout.forward(x); + let x = self.activation.forward(x); + + self.linear2.forward(x) // [batch_size, num_classes] + } +} +``` + +For former PyTorch users, this might feel very intuitive, as each module is directly incorporated +into the code using an eager API. Note that no abstraction is imposed for the forward method. You +are free to define multiple forward functions with the names of your liking. Most of the neural +network modules already built with Burn use the `forward` nomenclature, simply because it is +standard in the field. + +Similar to neural network modules, the [`Tensor`](../building-blocks/tensor.md) struct given as a +parameter also takes the Backend trait as a generic argument, alongside its dimensionality. Even if +it is not used in this specific example, it is possible to add the kind of the tensor as a third +generic argument. For example, a 3-dimensional Tensor of different data types(float, int, bool) +would be defined as following: + +```rust , ignore +Tensor // Float tensor (default) +Tensor // Float tensor (explicit) +Tensor // Int tensor +Tensor // Bool tensor +``` + +Note that the specific element type, such as `f16`, `f32` and the likes, will be defined later with +the backend. diff --git a/burn-book/src/basic-workflow/training-output.png b/burn-book/src/basic-workflow/training-output.png new file mode 100644 index 0000000000000000000000000000000000000000..e7bb69ec5ddec45895bccc148142bf0cb67e7111 GIT binary patch literal 214353 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 compiled to example1 binary +│ ├── example2.rs ---> compiled to example2 binary +│ └── ... +└── src + ├── lib.rs ---> this is the root file for a library + ├── module1.rs + ├── module2.rs + └── ... +``` + +
+ +The following additional examples are currently available if you want to check them out: + +| Example | Description | +| :-------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Custom CSV Dataset](https://github.com/tracel-ai/burn/tree/main/examples/custom-csv-dataset) | Implements a dataset to parse CSV data for a regression task. | +| [Regression](https://github.com/tracel-ai/burn/tree/main/examples/simple-regression) | Trains a simple MLP on the California Housing dataset to predict the median house value for a district. | +| [Custom Image Dataset](https://github.com/tracel-ai/burn/tree/main/examples/custom-image-dataset) | Trains a simple CNN on custom image dataset following a simple folder structure. | +| [Custom Renderer](https://github.com/tracel-ai/burn/tree/main/examples/custom-renderer) | Implements a custom renderer to display the [`Learner`](./building-blocks/learner.md) progress. | +| [Image Classification Web](https://github.com/tracel-ai/burn-onnx/tree/main/examples/image-classification-web) | Image classification web browser demo using Burn, WGPU and WebAssembly. | +| [MNIST Inference on Web](https://github.com/tracel-ai/burn/tree/main/examples/mnist-inference-web) | An interactive MNIST inference demo in the browser. The demo is available [online](https://burn.dev/demo/). | +| [MNIST Training](https://github.com/tracel-ai/burn/tree/main/examples/mnist) | Demonstrates how to train a custom [`Module`](./building-blocks/module.md) (MLP) with the [`Learner`](./building-blocks/learner.md) configured to log metrics and keep training checkpoints. | +| [ONNX Import Inference](https://github.com/tracel-ai/burn-onnx/tree/main/examples/onnx-inference) | Imports an ONNX model pre-trained on MNIST to perform inference on a sample image with Burn. | +| [PyTorch Import Inference](https://github.com/tracel-ai/burn/tree/main/examples/import-model-weights) | Imports a PyTorch model pre-trained on MNIST to perform inference on a sample image with Burn. | +| [Text Classification](https://github.com/tracel-ai/burn/tree/main/examples/text-classification) | Trains a text classification transformer model on the AG News or DbPedia datasets. The trained model can then be used to classify a text sample. | +| [Text Generation](https://github.com/tracel-ai/burn/tree/main/examples/text-generation) | Trains a text generation transformer model on the DbPedia dataset. | +| [Wasserstein GAN MNIST](https://github.com/tracel-ai/burn/tree/main/examples/wgan) | Trains a WGAN model to generate new handwritten digits based on MNIST. | + +For more information on each example, see their respective `README.md` file. Be sure to check out +the [examples](https://github.com/tracel-ai/burn/tree/main/examples) directory for an up-to-date +list. + +
+ +Note that some examples use the +[`datasets` library by HuggingFace](https://huggingface.co/docs/datasets/index) to download the +datasets required in the examples. This is a Python library, which means that you will need to +install Python before running these examples. This requirement will be clearly indicated in the +example's README when applicable. + +
diff --git a/burn-book/src/getting-started.md b/burn-book/src/getting-started.md new file mode 100644 index 0000000..5316954 --- /dev/null +++ b/burn-book/src/getting-started.md @@ -0,0 +1,219 @@ +# Getting Started + +Burn is a deep learning framework in the Rust programming language. Therefore, it goes without +saying that one must understand the basic notions of Rust. Reading the first chapters of the +[Rust Book](https://doc.rust-lang.org/book/) is recommended, but don't worry if you're just starting +out. We'll try to provide as much context and reference to external resources when required. Just +look out for the **🦀 Rust Note** indicators. + +## Installing Rust + +For installation instructions, please refer to the +[installation page](https://doc.rust-lang.org/book/ch01-01-installation.html). It explains in +details the most convenient way for you to install Rust on your computer, which is the very first +thing to do to start using Burn. + +## Creating a Burn application + +Once Rust is correctly installed, create a new Rust application by using Rust's build system and +package manager Cargo. It is automatically installed with Rust. + +
+🦀 Cargo Cheat Sheet + +[Cargo](https://doc.rust-lang.org/cargo/) is a very useful tool to manage Rust projects because it +handles a lot of tasks. More precisely, it is used to compile your code, download the +libraries/packages your code depends on, and build said libraries. + +Below is a quick cheat sheet of the main `cargo` commands you might use throughout this guide. + +| Command | Description | +| ------------------- | -------------------------------------------------------------------------------------------- | +| `cargo new` _path_ | Create a new Cargo package in the given directory. | +| `cargo add` _crate_ | Add dependencies to the Cargo.toml manifest file. | +| `cargo build` | Compile the local package and all of its dependencies (in debug mode, use `-r` for release). | +| `cargo check` | Check the local package for compilation errors (much faster). | +| `cargo run` | Run the local package binary. | + +For more information, check out +[Hello, Cargo!](https://doc.rust-lang.org/book/ch01-03-hello-cargo.html) in the Rust Book. + +

+ +In the directory of your choice, run the following: + +```console +cargo new my_burn_app +``` + +This will initialize the `my_burn_app` project directory with a `Cargo.toml` file and a `src` +directory with an auto-generated `main.rs` file inside. Head inside the directory to check: + +```console +cd my_burn_app +``` + +Then, add Burn as a dependency: + +```console +cargo add burn --features wgpu +``` + +Finally, compile the local package by executing the following: + +```console +cargo build +``` + +That's it, you're ready to start! You have a project configured with Burn and the WGPU backend, +which allows to execute low-level operations on any platform using the GPU. + +
+ +When using one of the `wgpu` backends, you may encounter compilation errors related to recursive +type evaluation. This is due to complex type nesting within the `wgpu` dependency chain. + +To resolve this issue, add the following line at the top of your `main.rs` or `lib.rs` file: + +```rust +#![recursion_limit = "256"] +``` + +The default recursion limit (128) is often just below the required depth (typically 130-150) due to +deeply nested associated types and trait bounds. + +
+ +## Writing a code snippet + +The `src/main.rs` was automatically generated by Cargo, so let's replace its content with the +following: + +```rust, ignore +use burn::tensor::Tensor; +use burn::backend::Wgpu; + +// Type alias for the backend to use. +type Backend = Wgpu; + +fn main() { + let device = Default::default(); + // Creation of two tensors, the first with explicit values and the second one with ones, with the same shape as the first + let tensor_1 = Tensor::::from_data([[2., 3.], [4., 5.]], &device); + let tensor_2 = Tensor::::ones_like(&tensor_1); + + // Print the element-wise addition (done with the WGPU backend) of the two tensors. + println!("{}", tensor_1 + tensor_2); +} +``` + +
+🦀 Use Declarations + +To bring any of the Burn module or item into scope, a `use` declaration is added. + +In the example above, we wanted bring the `Tensor` struct and `Wgpu` backend into scope with the +following: + +```rust, ignore +use burn::tensor::Tensor; +use burn::backend::Wgpu; +``` + +This is pretty self-explanatory in this case. But, the same declaration could be written as a +shortcut to simultaneously binding of multiple paths with a common prefix: + +```rust, ignore +use burn::{tensor::Tensor, backend::Wgpu}; +``` + +In this example, the common prefix is pretty short and there are only two items to bind locally. +Therefore, the first usage with two `use` declarations might be preferred. But know that both +examples are valid. For more details on the `use` keyword, take a look at +[this section](https://doc.rust-lang.org/book/ch07-04-bringing-paths-into-scope-with-the-use-keyword.html) +of the Rust Book or the +[Rust reference](https://doc.rust-lang.org/reference/items/use-declarations.html). + +

+ +
+🦀 Generic Data Types + +If you're new to Rust, you're probably wondering why we had to use `Tensor::::...`. +That's because the `Tensor` struct is [generic](https://doc.rust-lang.org/book/ch10-01-syntax.html) +over multiple concrete data types. More specifically, a `Tensor` can be defined using three generic +parameters: the backend, the number of dimensions (rank) and the data type (defaults to `Float`). +Here, we only specify the backend and number of dimensions since a `Float` tensor is used by +default. For more details on the `Tensor` struct, take a look at +[this section](./building-blocks/tensor.md). + +Most of the time when generics are involved, the compiler can infer the generic parameters +automatically. In this case, the compiler needs a little help. This can usually be done in one of +two ways: providing a type annotation or binding the generic parameter via the _turbofish_ `::<>` +syntax. In the example above we used the so-called _turbofish_ syntax, but we could have used type +annotations instead like this: + +```rust, ignore +let tensor_1: Tensor = Tensor::from_data([[2., 3.], [4., 5.]]); +let tensor_2 = Tensor::ones_like(&tensor_1); +``` + +You probably noticed that we provided a type annotation for the first tensor only and yet this +example still works. That's because the compiler (correctly) inferred that `tensor_2` had the same +generic parameters. The same could have been done in the original example, but specifying the +parameters for both is more explicit. + +

+ +By running `cargo run`, you should now see the result of the addition: + +```console +Tensor { + data: +[[3.0, 4.0], + [5.0, 6.0]], + shape: [2, 2], + device: DefaultDevice, + backend: "wgpu", + kind: "Float", + dtype: "f32", +} +``` + +While the previous example is somewhat trivial, the upcoming basic workflow section will walk you +through a much more relevant example for deep learning applications. + +## Using `prelude` + +Burn comes with a variety of things in its core library. When creating a new model or using an +existing one for inference, you may need to import every single component you used, which could be a +little verbose. + +To address it, a `prelude` module is provided, allowing you to easily import commonly used structs +and macros as a group: + +```rust, ignore +use burn::prelude::*; +``` + +which is equal to: + +```rust, ignore +use burn::{ + config::Config, + module::Module, + nn, + tensor::{ + backend::Backend, Bool, Device, ElementConversion, Float, Int, Shape, Tensor, + TensorData, + }, +}; +``` + +
+ +For the sake of simplicity, the subsequent chapters of this book will all use this form of importing +except in the [Building Blocks](./building-blocks) chapter, as explicit importing aids users in +grasping the usage of particular structures and macros. + +
diff --git a/burn-book/src/models-and-pretrained-weights.md b/burn-book/src/models-and-pretrained-weights.md new file mode 100644 index 0000000..4942fbe --- /dev/null +++ b/burn-book/src/models-and-pretrained-weights.md @@ -0,0 +1,30 @@ +# Models and Pre-Trained Weights + +## Models Repository + +The [`models`](https://github.com/tracel-ai/models) repository contains definitions of different +deep learning models with examples for different domains like computer vision and natural language +processing. + +This includes image classification models such as +[`MobileNetV2`](https://github.com/tracel-ai/models/tree/main/mobilenetv2-burn), +[`SqueezeNet`](https://github.com/tracel-ai/models/tree/main/squeezenet-burn) and +[`ResNet`](https://github.com/tracel-ai/models/tree/main/resnet-burn), object detection models such +as [`YOLOX`](https://github.com/tracel-ai/models/tree/main/yolox-burn) and language models like +[`BERT` and `RoBERTa`](https://github.com/tracel-ai/models/tree/main/bert-burn). + +Be sure to check out the up-to-date +[collection of models](https://github.com/tracel-ai/models?tab=readme-ov-file#collection-of-official-models) +to get you started. Pre-trained weights are available for every supported architecture in this +collection. You will also find a spotlight of +[community contributed models](https://github.com/tracel-ai/models?tab=readme-ov-file#community-contributions). + +## Burn-LM (alpha) + +[`Burn-LM`](https://github.com/tracel-ai/burn-lm) is an LLM inference engine built on Burn. It +provides access to large language models with open-source pre-trained weights and supports running, +fine-tuning, and experimenting with them on any Burn backend. + +Unlike tools focused solely on inference, Burn-LM is designed to work in a unified way across +different models and tasks, making it easier to explore both inference and training workflows within +the same framework. diff --git a/burn-book/src/motivation.md b/burn-book/src/motivation.md new file mode 100644 index 0000000..169491a --- /dev/null +++ b/burn-book/src/motivation.md @@ -0,0 +1,28 @@ +# Why Burn? + +Why bother with the effort of creating an entirely new deep learning framework from scratch when +PyTorch, TensorFlow, and other frameworks already exist? Spoiler alert: Burn isn't merely a +replication of PyTorch or TensorFlow in Rust. It represents a novel approach, placing significant +emphasis on making the right compromises in the right areas to facilitate exceptional flexibility, +high performance, and a seamless developer experience. Burn isn’t a framework specialized for only +one type of application, it is designed to serve as a versatile framework suitable for a wide range +of research and production uses. The foundation of Burn's design revolves around three key user +profiles: + +**Machine Learning Researchers** require tools to construct and execute experiments efficiently. +It’s essential for them to iterate quickly on their ideas and design testable experiments which can +help them discover new findings. The framework should facilitate the swift implementation of +cutting-edge research while ensuring fast execution for testing. + +**Machine Learning Engineers** are another important demographic to keep in mind. Their focus leans +less on swift implementation and more on establishing robustness, seamless deployment, and +cost-effective operations. They seek dependable, economical models capable of achieving objectives +without excessive expense. The whole machine learning workflow —from training to inference— must be +as efficient as possible with minimal unpredictable behavior. + +**Low level Software Engineers** working with hardware vendors want their processing units to run +models as fast as possible to gain competitive advantage. This endeavor involves harnessing +hardware-specific features such as Tensor Core for Nvidia. Since they are mostly working at a system +level, they want to have absolute control over how the computation will be executed. + +The goal of Burn is to satisfy all of those personas! diff --git a/burn-book/src/onnx-import.md b/burn-book/src/onnx-import.md new file mode 100644 index 0000000..1d048fb --- /dev/null +++ b/burn-book/src/onnx-import.md @@ -0,0 +1,278 @@ +# ONNX Import + +## Introduction + +As deep learning evolves, interoperability between frameworks becomes crucial. Burn provides robust +support for importing [ONNX (Open Neural Network Exchange)](https://onnx.ai/onnx/intro/index.html) +models through the [`burn-onnx`](https://github.com/tracel-ai/burn-onnx) crate, enabling you to +leverage pre-trained models in your Rust-based deep learning projects. + +## Why Import Models? + +Importing pre-trained models offers several advantages: + +1. **Time-saving**: Skip the resource-intensive process of training models from scratch. +2. **Access to state-of-the-art architectures**: Utilize cutting-edge models developed by + researchers and industry leaders. +3. **Transfer learning**: Fine-tune imported models for your specific tasks, benefiting from + knowledge transfer. +4. **Consistency across frameworks**: Maintain consistent performance when moving between + frameworks. + +## Understanding ONNX + +ONNX (Open Neural Network Exchange) is an open format designed to represent machine learning models +with these key features: + +- **Framework agnostic**: Provides a common format that works across various deep learning + frameworks. +- **Comprehensive representation**: Captures both the model architecture and trained weights. +- **Wide support**: Compatible with popular frameworks like PyTorch, TensorFlow, and scikit-learn. + +This standardization allows seamless movement of models between different frameworks and deployment +environments. + +## Burn's ONNX Support + +Burn's approach to ONNX import offers unique advantages: + +1. **Native Rust code generation**: Translates ONNX models into Rust source code for deep + integration with Burn's ecosystem. +2. **Compile-time optimization**: Leverages the Rust compiler to optimize the generated code, + potentially improving performance. +3. **No runtime dependency**: Eliminates the need for an ONNX runtime, unlike many other solutions. +4. **Trainability**: Allows imported models to be further trained or fine-tuned using Burn. +5. **Portability**: Enables compilation for various targets, including WebAssembly and embedded + devices. +6. **Backend flexibility**: Works with any of Burn's supported backends. + +## ONNX Compatibility + +Burn recommends ONNX models use **opset version 16 or higher** for best compatibility. While models +with older opset versions may work, opset 16+ ensures access to all supported operators and their +latest behavior. If you encounter issues with an older model, consider upgrading it using the ONNX +version converter. + +### Upgrading ONNX Models + +There are two simple ways to upgrade your ONNX models to the recommended opset version: + +Option 1: Use the provided utility script: + +``` +uv run --script https://raw.githubusercontent.com/tracel-ai/burn-onnx/refs/heads/main/onnx_opset_upgrade.py +``` + +Option 2: Use a custom Python script: + +```python +import onnx +from onnx import version_converter, shape_inference + +# Load your ONNX model +model = onnx.load('path/to/your/model.onnx') + +# Convert the model to opset version 16 +upgraded_model = version_converter.convert_version(model, 16) + +# Apply shape inference to the upgraded model +inferred_model = shape_inference.infer_shapes(upgraded_model) + +# Save the converted model +onnx.save(inferred_model, 'upgraded_model.onnx') +``` + +## Step-by-Step Guide + +Follow these steps to import an ONNX model into your Burn project: + +### Step 1: Update `Cargo.toml` + +First, add the required dependencies to your `Cargo.toml`: + +```toml +[dependencies] +burn = { version = "~0.21", features = ["flex"] } + +[build-dependencies] +burn-onnx = "~0.21" +``` + +### Step 2: Update `build.rs` + +In your `build.rs` file: + +```rust, ignore +use burn_onnx::ModelGen; + +fn main() { + ModelGen::new() + .input("src/model/my_model.onnx") + .out_dir("model/") + .run_from_script(); +} +``` + +This generates Rust code and a `.bpk` weights file from your ONNX model during the build process. + +### Step 3: Modify `mod.rs` + +In your `src/model/mod.rs` file, include the generated code: + +```rust, ignore +pub mod my_model { + include!(concat!(env!("OUT_DIR"), "/model/my_model.rs")); +} +``` + +### Step 4: Use the Imported Model + +Now you can use the imported model in your code: + +```rust, ignore +use burn::tensor; +use burn::backend::{Flex, flex::FlexDevice}; +use model::my_model::Model; + +fn main() { + let device = FlexDevice; + + // Create model instance and load weights from target dir default device + let model: Model = Model::default(); + + // Create input tensor (replace with your actual input) + let input = tensor::Tensor::::zeros([1, 3, 224, 224], &device); + + // Perform inference + let output = model.forward(input); + + println!("Model output: {:?}", output); +} +``` + +## Advanced Configuration + +The `ModelGen` struct provides configuration options: + +```rust, ignore +use burn_onnx::{ModelGen, LoadStrategy}; + +ModelGen::new() + .input("path/to/model.onnx") + .out_dir("model/") + .development(true) // Enable development mode for debugging + .load_strategy(LoadStrategy::Embedded) // Embed weights in the binary + .run_from_script(); +``` + +- `input`: Path to the ONNX model file +- `out_dir`: Output directory for generated code and weights +- `development`: When enabled, generates additional debug files (`.onnx.txt`, `.graph.txt`) +- `load_strategy`: Controls which weight-loading constructors are generated on the `Model` struct + (see below) + +Model weights are stored in Burnpack format (`.bpk`), which provides efficient serialization and +loading. + +### Load Strategy + +The `LoadStrategy` enum controls how the generated model loads its weights: + +| Strategy | Generated constructors | `Default` impl | Use case | +|------------|------------------------------------------------|-----------------|-------------------------------------------| +| `File` | `from_file()`, `from_bytes()` | Yes | Standard desktop/server (default) | +| `Embedded` | `from_embedded()`, `from_bytes()` | Yes | Single binary, small models | +| `Bytes` | `from_bytes()` | No | WASM, embedded, custom loaders | +| `None` | (none) | No | Manual weight management | + +The default strategy is `File`, which keeps weights in a separate `.bpk` file and generates a +`from_file()` constructor. + +For WebAssembly or environments without filesystem access, use `LoadStrategy::Bytes`: + +```rust, ignore +ModelGen::new() + .input("model.onnx") + .out_dir("model/") + .load_strategy(LoadStrategy::Bytes) + .run_from_script(); +``` + +Then load weights at runtime from any byte source (e.g., a network fetch): + +```rust, ignore +let model = Model::::from_bytes(weight_bytes, &device); +``` + +## Loading and Using Models + +You can load models in several ways, depending on the `LoadStrategy` used during code generation: + +```rust, ignore +// Load from the output directory with default device (recommended for most use cases) +// This automatically loads weights from the .bpk file +// Available with LoadStrategy::File or LoadStrategy::Embedded +let model = Model::::default(); + +// Create a new model instance with a specific device +// (initializes weights randomly; load weights via `load_from` afterward) +let model = Model::::new(&device); + +// Load from a specific .bpk file (LoadStrategy::File) +let model = Model::::from_file("path/to/weights.bpk", &device); + +// Load from in-memory bytes (LoadStrategy::File, Embedded, or Bytes) +let model = Model::::from_bytes(weight_bytes, &device); + +// Load from embedded weights (LoadStrategy::Embedded) +let model = Model::::from_embedded(&device); +``` + +## Troubleshooting + +Common issues and solutions: + +1. **Unsupported ONNX operator**: Check the + [list of supported ONNX operators](https://github.com/tracel-ai/burn-onnx/blob/main/SUPPORTED-ONNX-OPS.md). + You may need to simplify your model or wait for support. + +2. **Build errors**: Ensure your `burn-onnx` version matches your Burn version and verify the ONNX + file path in `build.rs`. + +3. **Runtime errors**: Confirm that your input tensors match the expected shape and data type of + your model. + +4. **Performance issues**: Consider using a more performant backend or optimizing your model + architecture. + +5. **Viewing generated files**: Find the generated Rust code and weights in the `OUT_DIR` directory + (usually `target/debug/build//out`). + +## Examples and Resources + +For practical examples, check out the +[burn-onnx examples](https://github.com/tracel-ai/burn-onnx/tree/main/examples): + +1. [ONNX Inference](https://github.com/tracel-ai/burn-onnx/tree/main/examples/onnx-inference) - + MNIST inference example +2. [Image Classification Web](https://github.com/tracel-ai/burn-onnx/tree/main/examples/image-classification-web) - + SqueezeNet running in the browser via WebAssembly +3. [Raspberry Pi Pico](https://github.com/tracel-ai/burn-onnx/tree/main/examples/raspberry-pi-pico) - + Embedded deployment example + +These demonstrate real-world usage of ONNX import in Burn projects. + +For contributors looking to add support for new ONNX operators: + +- [Development Guide](https://github.com/tracel-ai/burn-onnx/blob/main/DEVELOPMENT-GUIDE.md) - + Step-by-step guide for implementing new operators + +## Conclusion + +Importing ONNX models into Burn combines the vast ecosystem of pre-trained models with Burn's +performance and Rust's safety features. Following this guide, you can seamlessly integrate ONNX +models into your Burn projects for inference, fine-tuning, or further development. + +The `burn-onnx` crate is actively developed, with ongoing work to support more ONNX operators and +improve performance. Visit the [burn-onnx repository](https://github.com/tracel-ai/burn-onnx) for +updates and to contribute! diff --git a/burn-book/src/overview.md b/burn-book/src/overview.md new file mode 100644 index 0000000..aeb2e28 --- /dev/null +++ b/burn-book/src/overview.md @@ -0,0 +1,37 @@ +# Overview + +Welcome to The Burn Book 👋 + +This book will help you get started with the Burn deep learning framework, whether you are an +advanced user or a beginner. We have crafted some sections for you: + +- [Basic Workflow: From Training to Inference](./basic-workflow): We'll start with the fundamentals, + guiding you through the entire workflow, from training your models to deploying them for + inference. This section lays the groundwork for your Burn expertise. + +- [Building Blocks](./building-blocks): Dive deeper into Burn's core components, understanding how + they fit together. This knowledge forms the basis for more advanced usage and customization. + +- [Performance - Good Practices](./performance/good-practices/): Tips for writing models and + training code that make the most of hardware resources while avoiding common pitfalls that can + slow down execution. + +- [Custom Training Loop](./custom-training-loop.md): Gain the power to customize your training + loops, fine-tuning your models to meet your specific requirements. This section empowers you to + harness Burn's flexibility to its fullest. + +- [Saving & Loading Models](./saving-and-loading.md): Learn how to save and load your trained + models, including importing weights from PyTorch and SafeTensors formats. + +- [ONNX Import](./onnx-import.md): Learn how to import ONNX models using the + [burn-onnx](https://github.com/tracel-ai/burn-onnx) crate. + +- [Models & Pre-Trained Weights](./models-and-pretrained-weights.md): Get started quickly with + ready-to-use models and pre-trained weights. + +- [Advanced](./advanced): Finally, venture into advanced topics, exploring Burn's capabilities at + their peak. This section caters to those who want to push the boundaries of what's possible with + Burn. + +Throughout the book, we assume a basic understanding of deep learning concepts, but we may refer to +additional material when it seems appropriate. diff --git a/burn-book/src/performance/README.md b/burn-book/src/performance/README.md new file mode 100644 index 0000000..c42261e --- /dev/null +++ b/burn-book/src/performance/README.md @@ -0,0 +1,4 @@ +# Performance + +This section covers the key concepts you need to understand to get the most out of Burn and your +hardware. diff --git a/burn-book/src/performance/distributed-computing.md b/burn-book/src/performance/distributed-computing.md new file mode 100644 index 0000000..32da6f9 --- /dev/null +++ b/burn-book/src/performance/distributed-computing.md @@ -0,0 +1,4 @@ +# Distributed Computing + +Distributed computing support was introduced in Burn 0.19. Documentation and examples will be +available soon. diff --git a/burn-book/src/performance/good-practices/README.md b/burn-book/src/performance/good-practices/README.md new file mode 100644 index 0000000..2c0ac76 --- /dev/null +++ b/burn-book/src/performance/good-practices/README.md @@ -0,0 +1,12 @@ +# Performance - Best Practices + +This section provides valuable insights into the performance characteristics of Burn and guides +users on how to effectively leverage them for optimal results. + +It includes several sections, each offering relevant details. While understanding these concepts can +aid in model optimization, it’s always crucial to conduct benchmarks and profile models to +accurately assess performance improvements. + +- [Asynchronous Execution](./asynchronous-execution.md) +- [Kernel Fusion](./kernel-fusion.md) +- [Kernel Selection](./kernel-selection.md) diff --git a/burn-book/src/performance/good-practices/asynchronous-execution.md b/burn-book/src/performance/good-practices/asynchronous-execution.md new file mode 100644 index 0000000..2163eb8 --- /dev/null +++ b/burn-book/src/performance/good-practices/asynchronous-execution.md @@ -0,0 +1,62 @@ +# Asynchronous Execution + +Most Burn backends execute tensor operations in an asynchronous manner. However, the async notation +is often not required for most tensor operations, privileging the simplicity of sync Rust. + +There are only a few operations that trigger synchronization of the backend, and it is very +important to correctly handle those to optimize hardware utilization. Those operations are +`into_data`, `into_scalar`, and `sync`. Some tensor operations might call `into_data` underneath, +triggering a synchronization, like `to_device` for some backends. + +There are several ways to minimize synchronization overhead, one of which is to batch sync +operations into a single transaction. Burn provides a high-level composable API to build +transactions, which will only trigger a single sync on the device. + +For instance, it is often used when collecting metrics during training: + +```rust +/// All of these variables are tensors. +let (output, loss, targets) = ..; + +/// Now output, loss, and targets will be `TensorData` stored on the CPU. +let [output, loss, targets] = Transaction::default() + .register(output) + .register(loss) + .register(targets) + .execute() + .try_into() + .expect("Correct amount of tensor data"); +``` + +Another way of optimizing reads and avoiding device stalls is to read the data on a different +thread. Under the hood, CubeCL-based backends assign different execution queues for different +threads, meaning that syncing a thread shouldn’t impact the throughput of another thread. + +## Using Different Backends for Different Tasks + +Tensor operations aren’t the only things that are asynchronous; dataset and dataloading are also +lazily executed. This allows for efficient data augmentation and sampling without having to cache +huge datasets on disk. However, this might reduce training throughput if data augmentation is +performed on the same device as the training itself. So, it is normally encouraged to use a +different device, maybe even a different backend, for that purpose. For optimal performance, also +avoid small allocations followed by a batching procedure. Even if it doesn’t break asynchronicity, +it can slow down performance. + +```rust +/// Items is a vector of many tensors. +let items = ..; +let batch = Tensor::cat(items, 1); +``` + +Prefer doing the concatenation of tensors on the data augmentation device and not on the training +device. + +```rust +/// Items is a vector of many tensors. +let items = ..; +let device_training = ..; +let axis_batch = 0; + +let items = Tensor::cat(items, axis_batch); +let batch = Tensor::from_data(items.into_data(), device_training); +``` diff --git a/burn-book/src/performance/good-practices/kernel-fusion.md b/burn-book/src/performance/good-practices/kernel-fusion.md new file mode 100644 index 0000000..7ae9d6f --- /dev/null +++ b/burn-book/src/performance/good-practices/kernel-fusion.md @@ -0,0 +1,76 @@ +# Kernel Fusion + +An interesting property of async execution is that it allows performance optimizations like kernel +fusion. Coupled with CubeCL and its Just-In-Time compiler, Burn can serialize tensor operations into +a symbolic graph, then optimize it for improved efficiency. + +Kernel fusion may reorder operations to reduce global memory reads, writes, and allocations. Being +aware of which operations can be fused is relevant, as it can be easy to break an execution graph. + +The easiest way to optimize for fusion is to avoid keeping tensors alive for too long. When fusion +isn’t possible, all tensors that will be used later will trigger a global memory write. Fortunately, +Rust and Clippy are quite good at detecting unnecessary clones, but special care should still be +taken. + +View operations can also interfere with fusion. They can be included in optimized graphs, but only +to a limited extent, and they reduce vectorization potential as we have fewer guarantees about +memory access patterns with transformed indices. So, it is good practice to group view operations +together before executing blocks of operations. + +```rust +let tensor4 = tensor1.unsqueeze().matmul(tensor2) + tensor3.unsqueeze(); +``` + +Could be improved with the following: + +```rust +let tensor1 = tensor1.unsqueeze(); +let tensor3 = tensor3.unsqueeze(); +let tensor4 = tensor1.matmul(tensor2) + tensor3; +``` + +This reduces the necessary reordering and may reduce a global memory write or improve vectorization. +We might be able to detect these patterns in the future, but for now, it’s a good idea to order your +operations using this pattern. As a reminder, view operations typically only update tensor metadata +in most cases. These operations include `slice`, `slice_assign`, `select`, `gather`, `scatter`, +`reshape`, `swap_dims`, `transpose`, `unsqueeze`, etc. + +With fusion enabled, it is often not necessary to write custom kernels, as you can rely on our +system to optimize most element-wise operations. However, most compute-bound kernels require many +tricks and deep knowledge of GPU memory architectures, where automatic compiler optimizations often +underperform compared to human-designed algorithms. This is why Burn’s approach to fusion is +centered around fuse-on-read and fuse-on-write. This means that complex compute-bound kernels that +change the shapes of tensors can fuse a block of element-wise operations when reading the input +tensor and when writing the output tensor. The implication is that multiple compute-bound operations +in a sequence can reduce fusion potential. + +```rust +// This line might trigger 3 writes: tensor1, tensor2, and tensor3, if tensor1 and tensor2 are abstract tensors. +let tensor3 = tensor1.clone().sum_dim(tensor2.clone(), 2); +let tensor4 = tensor2.sum_dim(tensor3, 2); +let tensor5 = tensor4 + (tensor1 * tensor2); +``` + +```rust +let tmp = tensor1.clone() + tensor2.clone(); +let tensor3 = tensor1.sum_dim(tensor2, 2); +let tensor4 = tensor2.sum_dim(tensor3, 2); +let tensor5 = tensor4 + tmp; +``` + +The lesson? Whenever possible, pass only the latest value to a compute operation. Don’t clone a +tensor before compute-bound operations, as it might trigger an additional write if that tensor isn’t +materialized from initial fusion. + +It’s a bit complex, but the first code snippet is actually better if `tensor1` and `tensor2` are +concrete in global memory. This would be the case if `tensor1` and `tensor2` are model parameters, +so prefer this implementation style in such scenarios. + +The second code snippet is preferred when `tensor1` and `tensor2` are virtual tensors, meaning they +were fused by earlier operations and require a global memory read to be accessed later. This happens +if those tensors are part of a signal in neural networks. + +Reordering operations can help in such scenarios but will not create temporary values, making the +previous optimization harder. We might eventually automatically optimize these cases, but the +solution space is quite large, and it’s not a planned optimization. Profiling model blocks is always +a good idea to identify which code block is faster when faced with ambiguous situations. diff --git a/burn-book/src/performance/good-practices/kernel-selection.md b/burn-book/src/performance/good-practices/kernel-selection.md new file mode 100644 index 0000000..698f8cf --- /dev/null +++ b/burn-book/src/performance/good-practices/kernel-selection.md @@ -0,0 +1,25 @@ +# Kernel Selection + +As mentioned earlier, complex compute-bound operations are highly non-trivial and require many +tricks for optimal performance. However, the way these tricks are applied varies depending on the +hardware and problem shapes. To select the best kernel, we use a search method with a highly +configurable autotune system that performs micro-benchmarks at runtime on the current hardware. + +This may trigger a cold start, but the results of these benchmarks are cached on disk for subsequent +executions. + +For deployment or training on spot instances, it’s a good idea to bundle the autotune cache with the +code to mitigate cold starts. Refer to the +[CubeCL configuration documentation](https://burn.dev/books/cubecl/advanced-usage/config.html) for +more details on fine-grained settings . + +From the user’s point of view, kernel selection shouldn’t be a problem, but as usual, crafting +models with even shapes, multiples of 8, can significantly improve performance. Avoid creating +tensors with shapes that are multiples of 10, like `[1000, 1000]`, as these typically require bounds +checking and may limit vectorization. + +Prefer shapes like `[1024, 1024]`, where dimensions are multiples of 32 or powers of 2, as these are +generally optimal. If you have no choice but to use a suboptimal shape, prefer handling it in a +single kernel, transforming it into an optimal shape. It’s better to have a slow neural network +layer followed by fast ones than to propagate unevenness and end up with smaller, but slower, +layers. diff --git a/burn-book/src/performance/quantization.md b/burn-book/src/performance/quantization.md new file mode 100644 index 0000000..f1b48be --- /dev/null +++ b/burn-book/src/performance/quantization.md @@ -0,0 +1,133 @@ +# Quantization + +Quantization techniques perform computations and store tensors in lower precision data types like +8-bit integer instead of floating point precision. There are multiple approaches to quantize a deep +learning model categorized as: + +- Post-training quantization (PTQ) +- Quantization aware training (QAT) + +In post-training quantization, the model is trained in floating point precision and later converted +to the lower precision data type. There are two types of post-training quantization: + +1. Static quantization: quantizes the weights and activations of the model. Quantizing the + activations statically requires data to be calibrated (i.e., recording the activation values to + compute the optimal quantization parameters with representative data). +1. Dynamic quantization: quantized the weights ahead of time (like static quantization) but the + activations are dynamically at runtime. + +Sometimes post-training quantization is not able to achieve acceptable task accuracy. In general, +this is where quantization-aware training (QAT) can be used: during training, fake-quantization +modules are inserted in the forward and backward passes to simulate quantization effects, allowing +the model to learn representations that are more robust to reduced precision. + +Burn does not currently support QAT. Only post-training quantization (PTQ) is implemented at this +time. + +
+ +Quantization support in Burn is currently in active development. + +It supports the following PTQ modes on some backends: + +- Per-tensor and per-block quantization to 8-bit, 4-bit and 2-bit representations + +No integer operations are currently supported, which means tensors are dequantized to perform the +operations in floating point precision. + +
+ +## Module Quantization + +Quantizing the weights of your model after training is quite simple. We have access to the weight +tensors and can collect their statistics, such as the min and max value when using +`MinMaxCalibration`, to compute the quantization parameters. + +```rust , ignore +# use burn::module::Quantizer; +# use burn::tensor::quantization::{Calibration, QuantLevel, QuantParam, QuantScheme, QuantValue}; +# +// Quantization config +let scheme = QuantScheme::default() + .with_level(QuantLevel::Block(32)) + .with_value(QuantValue::Q4F) + .with_param(QuantParam::F16); +let mut quantizer = Quantizer { + calibration: Calibration::MinMax, + scheme, +}; + +// Quantize the weights +let model = model.quantize_weights(&mut quantizer); +``` + +### Calibration + +Calibration is the step during quantization where the range of all floating-point tensors is +computed. This is pretty straightforward for weights since the actual range is known at +_quantization-time_ (weights are static), but activations require more attention. + +To compute the quantization parameters, Burn supports the following `Calibration` methods. + +| Method | Description | +| :------- | :------------------------------------------------------------------------------- | +| `MinMax` | Computes the quantization range mapping based on the running min and max values. | + +### Quantization Scheme + +A quantization scheme defines how an input is quantized, including the representation of quantized +values, storage format, granularity, and how the values are scaled. + +```rust +let scheme = QuantScheme::default() + .with_mode(QuantMode::Symmetric) // Quantization mode + .with_level(QuantLevel::block([2, 16])) // Granularity (per-tensor or per-block) + .with_value(QuantValue::Q8S) // Data type of quantized values, independent of how they're stored + .with_store(QuantStore::Native) // Storage format for quantized values + .with_param(QuantParam::F16); // Precision for quantization parameters +``` + +#### Quantization Mode + +| Mode | Description | +| :---------- | :------------------------------------------- | +| `Symmetric` | Values are scaled symmetrically around zero. | + +#### Quantization Level + +| Level | Description | +| :----------------------------- | :----------------------------------------------------------------------------------------------------------- | +| `Tensor` | A single quantization parameter set for the entire tensor. | +| `Block(block_size: BlockSize)` | Tensor divided into blocks (1D, 2D, or higher) defined by block_size, each with its own quantization params. | + +#### Quantization Value + +| Value | Bits | Description | +| :----- | :--: | :-------------------------------------------- | +| `Q8F` | 8 | 8-bit full-range quantization | +| `Q4F` | 4 | 4-bit full-range quantization | +| `Q2F` | 2 | 2-bit full-range quantization | +| `Q8S` | 8 | 8-bit symmetric quantization | +| `Q4S` | 4 | 4-bit symmetric quantization | +| `Q2S` | 2 | 2-bit symmetric quantization | +| `E5M2` | 8 | 8-bit floating-point (5 exponent, 2 mantissa) | +| `E4M3` | 8 | 8-bit floating-point (4 exponent, 3 mantissa) | +| `E2M1` | 4 | 4-bit floating-point (2 exponent, 1 mantissa) | + +#### Quantization Store + +| Store | Description | +| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Native` | Each quantized value is stored directly in a native format, which doesn't require packing and unpacking. | +| `PackedNative(dim)` | Multiple quantized values packed into a 32-bit integer. Argument is the dimension the tensor is packed on, starting from the innermost dimension. | +| `PackedU32(dim)` | Multiple quantized values packed into a 32-bit integer. Argument is the dimension the tensor is packed on, starting from the innermost dimension. | + +Native storage is not supported for sub-byte quantization values. + +#### Quantization Parameters Precision + +| Param | Description | +| :----- | :----------------------------- | +| `F32` | Full floating-point precision. | +| `F16` | Half-precision floating point. | +| `BF16` | Brain float 16-bit precision. | diff --git a/burn-book/src/saving-and-loading.md b/burn-book/src/saving-and-loading.md new file mode 100644 index 0000000..45222b3 --- /dev/null +++ b/burn-book/src/saving-and-loading.md @@ -0,0 +1,468 @@ +# Saving and Loading Models + +Saving your trained machine learning model is quite easy. As mentioned in the +[Record](./building-blocks/record.md) section, a module's parameters are captured in a +`ModuleRecord` and serialized to the [burnpack](./building-blocks/record.md) format (`.bpk`). + +```rust, ignore +use burn::store::ModuleRecord; + +// Take a record of the model's parameters and save it to disk. +model + .into_record() + .save(model_path) + .expect("Should be able to save the model"); +``` + +Note that the `.bpk` file extension is appended automatically when the path has none, so only the +file path and base name need to be provided. + +Now that you have a trained model saved to your disk, you can just as easily load it back. + +```rust, ignore +// Load the record from the burnpack file. +let record = ModuleRecord::load(model_path) + .expect("Should be able to load the model weights from the provided file"); + +// Apply the loaded weights to a model. +model = model.load_record(record); +``` + +The record is backend-independent: weights saved with one backend can be loaded on another. If you +need to convert precision, call `model.cast(dtype)` before taking the record, or use +`record.cast_to_module_dtype()` when loading. + +## Initialization from Recorded Weights + +The most straightforward way to load weights for a module is simply by using +[load_record](https://burn.dev/docs/burn/module/trait.Module.html#method.load_record). Note that +parameter initialization is lazy, therefore no actual tensor allocation and GPU/CPU kernels are +executed before the module is used. This means that you can use `init(device)` followed by +`load_record(record)` without any meaningful performance cost. + +```rust, ignore +use burn::store::ModuleRecord; + +// Create a dummy initialized model to save. +let device = Default::default(); +let model = Model::init(&device); + +// Save its parameters to a burnpack file. +model + .into_record() + .save(model_path) + .expect("Should be able to save the model"); +``` + +Afterwards, the model can just as easily be loaded from the record saved on disk. + +```rust, ignore +// Load the model record from the burnpack file. +let record = ModuleRecord::load(model_path) + .expect("Could not load model weights"); + +// Initialize a new model with the loaded record/weights. +let model = Model::init(&device).load_record(record); +``` + +For partial loading (only some parameters present in the record), use +`record.allow_partial(true)` before applying it, or `model.try_load_record(record)` for fallible +loading. + +## Model Weight Store + +While the `ModuleRecord` API above works well for basic saving and loading, the `burn-store` crate +adds memory efficiency and flexibility on top of the same burnpack format. It provides zero-copy +memory-mapped loading, cross-framework interoperability (PyTorch and SafeTensors), key remapping, +partial loading, and filtering. + +### Supported Formats + +| Format | Extension | Description | +| --------------- | -------------- | ----------------------------------------------------------------------------------------- | +| **Burnpack** | `.bpk` | Burn's native format with fast loading, zero-copy support, and training state persistence | +| **SafeTensors** | `.safetensors` | Industry-standard format from Hugging Face for secure tensor serialization | +| **PyTorch** | `.pt`, `.pth` | Direct loading of PyTorch model weights (read-only) | + +### Saving a Model + +```rust, ignore +use burn_store::{ModuleSnapshot, BurnpackStore}; + +// Save to Burnpack (recommended) +let mut store = BurnpackStore::from_file("model.bpk"); +model.save_into(&mut store)?; + +// Or save to SafeTensors +use burn_store::SafetensorsStore; +let mut store = SafetensorsStore::from_file("model.safetensors"); +model.save_into(&mut store)?; +``` + +### Loading a Model + +```rust, ignore +use burn_store::{ModuleSnapshot, BurnpackStore}; + +let device = Default::default(); +let mut model = MyModel::init(&device); + +// Load from Burnpack +let mut store = BurnpackStore::from_file("model.bpk"); +model.load_from(&mut store)?; +``` + +### Loading from PyTorch + +You can load weights directly from PyTorch `.pt` files: + +```rust, ignore +use burn_store::{ModuleSnapshot, PytorchStore}; + +let mut model = MyModel::init(&device); +let mut store = PytorchStore::from_file("pytorch_model.pt"); +model.load_from(&mut store)?; +``` + +#### Exporting from PyTorch + +Save only the model weights (state_dict), not the entire model: + +```python +import torch +import torch.nn as nn + +class Net(nn.Module): + def __init__(self): + super(Net, self).__init__() + self.conv1 = nn.Conv2d(2, 2, (2, 2)) + self.conv2 = nn.Conv2d(2, 2, (2, 2), bias=False) + + def forward(self, x): + return self.conv2(self.conv1(x)) + +model = Net() +torch.save(model.state_dict(), "model.pt") # Correct: save state_dict +# torch.save(model, "model.pt") # Wrong: saves entire model +``` + +#### Accessing Nested State Dicts + +Some PyTorch checkpoints nest the state_dict under a key: + +```rust, ignore +let mut store = PytorchStore::from_file("checkpoint.pt") + .with_top_level_key("state_dict"); +model.load_from(&mut store)?; +``` + +### Loading from SafeTensors + +For SafeTensors files exported from PyTorch, use the adapter for proper weight transformation: + +```rust, ignore +use burn_store::{ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore}; + +let mut model = MyModel::init(&device); +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_from_adapter(PyTorchToBurnAdapter); +model.load_from(&mut store)?; +``` + +For SafeTensors files created by Burn, no adapter is needed: + +```rust, ignore +let mut store = SafetensorsStore::from_file("model.safetensors"); +model.load_from(&mut store)?; +``` + +#### Exporting from PyTorch to SafeTensors + +```python +from safetensors.torch import save_file + +model = Net() +save_file(model.state_dict(), "model.safetensors") +``` + +### Saving for PyTorch Compatibility + +Use the adapter when saving for PyTorch consumption: + +```rust, ignore +use burn_store::{BurnToPyTorchAdapter, SafetensorsStore}; + +let mut store = SafetensorsStore::from_file("for_pytorch.safetensors") + .with_to_adapter(BurnToPyTorchAdapter) + .skip_enum_variants(true); +model.save_into(&mut store)?; +``` + +### Handling Load Results + +The `load_from` method returns detailed information about the loading process. + +> **Note:** Inspecting `result.missing`, `result.errors`, etc. requires the store to be configured with +> [`.allow_partial(true)`](#partial-loading). Without it, a missing tensor causes a hard `Err` +> before you ever receive an `ApplyResult`. + +```rust, ignore +// Use .allow_partial(true) to get an ApplyResult with structured info +let mut store = PytorchStore::from_file("pretrained.pt") + .allow_partial(true); + +let result = model.load_from(&mut store)?; + +// Print a formatted summary with suggestions +println!("{}", result); + +// Or inspect individual fields +println!("Applied: {} tensors", result.applied.len()); +println!("Missing: {:?}", result.missing); +println!("Errors: {:?}", result.errors); + +if result.is_success() { + println!("All tensors loaded successfully"); +} +``` + +If you don't use `.allow_partial(true)`, use normal error handling: + +```rust, ignore +match model.load_from(&mut store) { + Ok(result) => println!("Loaded successfully: {} tensors", result.applied.len()), + Err(e) => eprintln!("Failed to load: {e}"), +} +``` + +### Adding Metadata + +Burnpack and SafeTensors support custom metadata: + +```rust, ignore +let mut store = BurnpackStore::from_file("model.bpk") + .metadata("version", "1.0") + .metadata("description", "My trained model") + .metadata("epochs", "100"); +model.save_into(&mut store)?; +``` + +### Advanced Features + +#### Key Remapping + +Remap parameter names using regex patterns when model structures don't match: + +```rust, ignore +let mut store = PytorchStore::from_file("model.pt") + // Remove prefix: "model.conv1.weight" -> "conv1.weight" + .with_key_remapping(r"^model\.", "") + // Rename: "layer1" -> "encoder.layer1" + .with_key_remapping(r"^layer", "encoder.layer"); +model.load_from(&mut store)?; +``` + +For complex remapping: + +```rust, ignore +use burn_store::KeyRemapper; + +let remapper = KeyRemapper::new() + .add_pattern(r"^transformer\.h\.(\d+)\.", "transformer.layer$1.")? + .add_pattern(r"\.attn\.", ".attention.")?; + +let mut store = SafetensorsStore::from_file("model.safetensors") + .remap(remapper); +``` + +#### Partial Loading + +Load weights even when some tensors are missing: + +```rust, ignore +let mut store = PytorchStore::from_file("pretrained.pt") + .allow_partial(true); + +let result = model.load_from(&mut store)?; +println!("Missing (initialized randomly): {:?}", result.missing); +``` + +#### Filtering Tensors + +Load or save only specific layers: + +```rust, ignore +// Load only encoder layers +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_regex(r"^encoder\..*") + .allow_partial(true); + +// Save only encoder layers +let mut store = SafetensorsStore::from_file("encoder.safetensors") + .with_regex(r"^encoder\..*"); +model.save_into(&mut store)?; + +// Multiple patterns (OR logic) +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_regex(r"^encoder\..*") // encoder tensors + .with_regex(r".*\.bias$") // OR any bias tensors + .with_full_path("decoder.scale"); // OR specific tensor +``` + +#### Non-Contiguous Layer Indices + +PyTorch `nn.Sequential` with mixed layers creates non-contiguous indices. `PytorchStore` +automatically remaps these: + +``` +PyTorch: fc.0.weight, fc.2.weight, fc.4.weight (gaps from ReLU layers) +Burn: fc.0.weight, fc.1.weight, fc.2.weight (contiguous) +``` + +This is enabled by default. Disable if needed: + +```rust, ignore +let mut store = PytorchStore::from_file("model.pt") + .map_indices_contiguous(false); +``` + +#### Zero-Copy Loading + +For embedded models or large files, use zero-copy loading to avoid memory copies: + +```rust, ignore +// Embedded model (compile-time) +static MODEL_DATA: &[u8] = include_bytes!("model.bpk"); +let mut store = BurnpackStore::from_static(MODEL_DATA); +model.load_from(&mut store)?; + +// Large file (memory-mapped) +let mut store = BurnpackStore::from_file("large_model.bpk") + .zero_copy(true); +model.load_from(&mut store)?; +``` + +#### Half-Precision Storage + +Save models at half precision (F16) to reduce file size by ~50%, then load back at full precision: + +```rust, ignore +use burn_store::{ModuleSnapshot, BurnpackStore, HalfPrecisionAdapter}; + +let adapter = HalfPrecisionAdapter::new(); + +// Save: F32 -> F16 (same adapter for both directions) +let mut store = BurnpackStore::from_file("model_f16.bpk") + .with_to_adapter(adapter.clone()); +model.save_into(&mut store)?; + +// Load: F16 -> F32 +let mut store = BurnpackStore::from_file("model_f16.bpk") + .with_from_adapter(adapter); +model.load_from(&mut store)?; +``` + +By default, weights in Linear, Embedding, Conv\*, LayerNorm, GroupNorm, InstanceNorm, RmsNorm, and +PRelu modules are converted. BatchNorm is excluded because its running variance can underflow in +F16. Customize with `with_module()` and `without_module()`: + +```rust, ignore +// Keep LayerNorm at full precision +let adapter = HalfPrecisionAdapter::new() + .without_module("LayerNorm"); + +// Add a custom module to the conversion set +let adapter = HalfPrecisionAdapter::new() + .with_module("CustomLayer"); +``` + +#### Direct Tensor Access + +Inspect tensors without loading into a model: + +```rust, ignore +use burn_store::ModuleStore; + +let mut store = PytorchStore::from_file("model.pt"); + +// List all tensor names +let names = store.keys()?; + +// Get specific tensor +if let Some(snapshot) = store.get_snapshot("encoder.layer0.weight")? { + println!("Shape: {:?}, DType: {:?}", snapshot.shape, snapshot.dtype); +} +``` + +#### Model Surgery + +Transfer weights between models: + +```rust, ignore +use burn_store::{ModuleSnapshot, PathFilter}; + +// Transfer all weights +let snapshots = model1.collect(None, None, false); +model2.apply(snapshots, None, None, false); + +// Transfer only encoder weights +let filter = PathFilter::new().with_regex(r"^encoder\..*"); +let snapshots = model1.collect(Some(filter.clone()), None, false); +model2.apply(snapshots, Some(filter), None, false); +``` + +### API Reference + +#### Builder Methods + +| Category | Method | Description | +| ------------- | ------------------------------ | ---------------------------- | +| **Filtering** | `with_regex(pattern)` | Filter by regex pattern | +| | `with_full_path(path)` | Include specific tensor | +| | `with_predicate(fn)` | Custom filter logic | +| **Remapping** | `with_key_remapping(from, to)` | Regex-based renaming | +| | `remap(KeyRemapper)` | Complex remapping rules | +| **Adapters** | `with_from_adapter(adapter)` | Loading transformations | +| | `with_to_adapter(adapter)` | Saving transformations | +| | `HalfPrecisionAdapter::new()` | F32/F16 mixed-precision | +| **Config** | `allow_partial(bool)` | Continue on missing tensors | +| | `with_top_level_key(key)` | Access nested dict (PyTorch) | +| | `skip_enum_variants(bool)` | Skip enum variants in paths | +| | `map_indices_contiguous(bool)` | Remap non-contiguous indices | +| | `metadata(key, value)` | Add custom metadata | +| | `zero_copy(bool)` | Enable zero-copy loading | + +#### Direct Access Methods + +| Method | Description | +| --------------------- | -------------------------------- | +| `keys()` | Get ordered list of tensor names | +| `get_all_snapshots()` | Get all tensors as BTreeMap | +| `get_snapshot(name)` | Get specific tensor by name | + +### Troubleshooting + +#### Common Issues + +1. **"Missing source values" error**: You saved the entire PyTorch model instead of the state_dict. + Re-export with `torch.save(model.state_dict(), "model.pt")`. + +2. **Shape mismatch**: Your Burn model doesn't match the source architecture. Verify layer + configurations (channels, kernel sizes, bias settings). + +3. **Key not found**: Parameter names don't match. Use `with_key_remapping()` or inspect keys: + + ```rust, ignore + let store = PytorchStore::from_file("model.pt"); + println!("Available keys: {:?}", store.keys()?); + ``` + +#### Inspecting Files + +Use [Netron](https://github.com/lutzroeder/netron) to visualize `.pt` and `.safetensors` files. + +For Burnpack files: + +```bash +cargo run --example burnpack_inspect model.bpk +``` diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f6dbc7c --- /dev/null +++ b/codecov.yml @@ -0,0 +1,13 @@ +coverage: + status: + project: + default: + # https://docs.codecov.com/docs/commit-status#informational + informational: true + target: 80% + patch: + default: + informational: true + target: 80% +github_checks: + annotations: false diff --git a/contributor-book/.gitignore b/contributor-book/.gitignore new file mode 100644 index 0000000..409ff3e --- /dev/null +++ b/contributor-book/.gitignore @@ -0,0 +1,18 @@ +target + +# MacOS temp file +.DS_Store + +book-test +guide/book + +.vscode +tests/burn-book/book/ +book/ + +# Ignore Jetbrains specific files. +.idea/ + +# Ignore Vim temporary and swap files. +*.sw? +*~ \ No newline at end of file diff --git a/contributor-book/.prettierrc.json b/contributor-book/.prettierrc.json new file mode 100644 index 0000000..d410551 --- /dev/null +++ b/contributor-book/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "proseWrap": "always" +} \ No newline at end of file diff --git a/contributor-book/LICENSE-APACHE b/contributor-book/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/contributor-book/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/contributor-book/LICENSE-MIT b/contributor-book/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/contributor-book/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/contributor-book/book.toml b/contributor-book/book.toml new file mode 100644 index 0000000..4522483 --- /dev/null +++ b/contributor-book/book.toml @@ -0,0 +1,16 @@ +[book] +authors = [ + "Wouter Doppenberg", + "Nathaniel Simard", + "Louis Fortier-Dubois", + "Dilshod Tadjibaev", + "Guillaume Lagrange", + "Joshua Ferguson", + "The Burn Community", +] +language = "en" +src = "src" +title = "The Burn Contributor Book 🔥" + +[output.html] +mathjax-support = true diff --git a/contributor-book/src/SUMMARY.md b/contributor-book/src/SUMMARY.md new file mode 100644 index 0000000..89cc2cf --- /dev/null +++ b/contributor-book/src/SUMMARY.md @@ -0,0 +1,16 @@ +- [Overview](./overview.md) +- [How to Read This Book](./how-to-read-this-book.md) +- [Getting Started](./getting-started/README.md) + - [Setting Up The Environment](./getting-started/setting-up-the-environment.md) + - [Configuring Your Editor (Optional)](./getting-started/configuring-your-editor.md) + - [Testing](./getting-started/testing.md) +- [Architecture Overview](./project-architecture/README.md) + - [Modules](./project-architecture/module.md) + - [Serialization](./project-architecture/serialization.md) + - [Tensor](./project-architecture/tensor.md) + - [Backend](./project-architecture/backend.md) +- [Guides for Contributors](./guides/README.md) + - [Adding a New Operation to Burn](./guides/adding-a-new-operation-to-burn.md) + - [Submitting Examples to Burn](./guides/submitting-examples.md) +- [Frequently Encountered Issues](./frequently-encountered-issues/README.md) + - [Issues Related To Adding Operators](./frequently-encountered-issues/issues-while-adding-ops.md) diff --git a/contributor-book/src/frequently-encountered-issues/README.md b/contributor-book/src/frequently-encountered-issues/README.md new file mode 100644 index 0000000..d865f1b --- /dev/null +++ b/contributor-book/src/frequently-encountered-issues/README.md @@ -0,0 +1,5 @@ +# Frequently Encountered Issues + +This is a collection of issues people have encountered and asked about on the +[Discord server](https://discord.gg/uPEBbYYDB6). This section is separated from the guides since it +can involve lots of details that are only relevant to a small subset of contributors. diff --git a/contributor-book/src/frequently-encountered-issues/issues-while-adding-ops.md b/contributor-book/src/frequently-encountered-issues/issues-while-adding-ops.md new file mode 100644 index 0000000..785ee33 --- /dev/null +++ b/contributor-book/src/frequently-encountered-issues/issues-while-adding-ops.md @@ -0,0 +1,19 @@ +# Issues encountered while adding ops + +Below are some of the issues that were encountered while adding ops to the project. If you encounter +an issue while adding an op that isn't listed here, and it's not obvious how to fix it, you can add +it to this list or reach out on the [Discord server](https://discord.gg/uPEBbYYDB6) if you need +help. + +## Off by .000001 errors + +```sh +---- fusion::base::tests::maxmin::tests::test_mean_dim_2d stdout ---- thread 'fusion::base::tests::maxmin::tests::test_mean_dim_2d' panicked at burn-wgpu/src/fusion/base.rs:185:5: assertion `left == right` failed left: Data { value: [1.0, 4.0], shape: Shape { dims: [2, 1] } } right: Data { value: [0.99999994, 3.9999998], shape: Shape { dims: [2, 1] } } ---- + +tests::maxmin::tests::test_mean_dim_2d stdout ---- thread 'tests::maxmin::tests::test_mean_dim_2d' panicked at burn-wgpu/src/lib.rs:49:5: assertion `left == right` failed left: Data { value: [1.0, 4.0], shape: Shape { dims: [2, 1] } } right: Data { value: [0.99999994, 3.9999998], shape: Shape { dims: [2, 1] } } +``` + +If you encounter this, swap out the `assert_eq!` in the failing test for +`tensor1.to_data().assert_approx_eq` with `3` as the second argument. The second arguments specifies +the level of precision: `3` is equivalent to a less than 10-3 (0.001) difference between +the elements of the two tensors. diff --git a/contributor-book/src/getting-started/README.md b/contributor-book/src/getting-started/README.md new file mode 100644 index 0000000..0414d86 --- /dev/null +++ b/contributor-book/src/getting-started/README.md @@ -0,0 +1,6 @@ +# Getting Started + +This section is for setting up the environment and how to do basic development tasks such as running +tests and checking your code before committing. If you need help with the process or run into +issues, feel free to ask on the [Discord server](https://discord.gg/uPEBbYYDB6) in the Development +channels. diff --git a/contributor-book/src/getting-started/configuring-your-editor.md b/contributor-book/src/getting-started/configuring-your-editor.md new file mode 100644 index 0000000..10ad6a7 --- /dev/null +++ b/contributor-book/src/getting-started/configuring-your-editor.md @@ -0,0 +1,36 @@ +# Configuring your editor + +These steps are not required, and most of this isn't specific to Burn, but it's definitely helpful +if you haven't already done it. + +## VSCode + +Install the following extensions: + +- [rust-lang.rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) + for Rust syntax and semantic analysis +- [tamasfe.even-better-toml](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) + for TOML syntax and semantic analysis +- [fill-labs.dependi](https://marketplace.visualstudio.com/items?itemName=fill-labs.dependi) for + managing dependencies +- [vadimcn.vscode-lldb](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) for + debugging + +### Setting up the Debugger + +To use the debugger, follow these steps: + +1. Open `Command Palette` with `Ctrl+Shift+P` or `F1` and type + `LLDB: Generate Launch Configurations from Cargo.toml` then select it, this will generate a file + that should be saved as `.vscode/launch.json`. +2. Select the configuration from the "run and debug" side panel, then select the target from the list. + Since this repo has `debug = 0` in the root `Cargo.toml` to speed up compilation, you need replace it with `debug = true` in the root `Cargo.toml` when using a debugger and breakpoints with `launch.json` settings. +3. Now you can enable breakpoints on code through IDE then start debugging the library/binary you + want, like in the following example: + +![debug-options](debug-options-vscode.png) + +If you're creating a new library or binary, keep in mind to repeat step 1 to always keep a fresh +list of targets. + +## Have another editor? Open a PR! diff --git a/contributor-book/src/getting-started/debug-options-vscode.png b/contributor-book/src/getting-started/debug-options-vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..c1db902405016d74a87ebe19966c7ae6f631dea6 GIT binary patch literal 193436 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�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

QI?vCzDFDMNeg$+h6>PI*p3(l2}LwP+x^J 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�#HU49aF-UKt=3 zf0!Yy`~#o5cKyYXGIiOnZias!@0lVLzFU%8cAXl~%DUvcyJbkt(~j^pcY2LyY}L1t zR-}fjJ<8MM$=WFU7gLTUPiPh!y?8vS%+N%;MU_{vFeCACm_d_+ZB|P_W|$eU2UC~p z>)ZqlRS;Wkmp{C{EV)OBOx;taF#Q_dqa?UvVE^}8iN9(m_?JzhJw^bDr0l!lr%CFh zPBT)p-OrPI&L;p~%GMJmn&+W>uzzH^{M-A-5&TKy1XW!B3+d(ePydx^FWvL~dACrO z-wWY8mkQOp0n|r+@AtV<@3*C_ND^#a0Ag7)!FdQFnRxC%*aF3M%HFYA$)1UVzYs-Acbkq-|jFIsULrzg#O2kQ6AS>mRa7ry)v|BPn?(UC&ZZP~Uv4C4HHCikv*gzg0}X&q=DsL^lZfU9yv_fHlbD;9 zFgM}%{~afx9Derpy%GZ#g$h9DdPkPs(}@QmAnyeeUKXIp?Y$LMJSeyM+5y z7B7w{X` zXPv@Iyaby|aW0 zGKUiK)8eYYThK`bZIR{*hmwA)#k^O`ewzQFshM(=eQ1jSUn@sg=IklnS@!18DBuW* zc!egv6mb2W_EG|MgxdWZ>m^Sk?Gy^IUZh->#(b?6YWqNedfnsLm>_SxNSt2&4}R68 zdfgd6KQ8S0CXYB+4c2X1MZ=#J-XQE^GTquPhRhcZ73d)=Xs<^*WQkY$6??e8JG*~O zlFV$&E!yr(i*rvnnRNie2%?V7Rl9yZG}){+ff?nfMKa7Zw28(KZpl^~kkJ*a5hLtY6Sb$s?C z{#>lcC-e;Km49&Ix6d+>?it}{$~ZtDi5v$b z)g>zrrFNEZ#la-3B6U%S6^Xn~PnRa2ns%JC`_91zoSU|bijRXIc$($p-;f8C+Bd4f zR6BDPx6u3}cJlMSCJI+=$@cY^*Y~A-uC-EascGNB994!BW+OUb+?_np-U^Smb*EgN zB}lovWW4+f-R2@u!6#sfwfdpEAN)xW~V= z+2Ldzn{%v3_UyGo;q&3!`MrQ)qf<5VkyNs|a^vX}9i}&|#;;*f#$@Uzw+Re*5@}GD z2{o6*SFNXn&L!6iQ>9ZviW8Mf&of-M?u+!q7vgRNGJbn`E$!5(D28&4emwm^YL#Ys zefnjg{l;NY0vr5(VXzL%HkWon&JB=WsAP>bda0{%ibk01lPeR=PBJe6&9U(7OL6C5 zPCtIr>-kwO)6S@XB7*zr+um>FSUUZgjn2%r)5j$afo$mK{_uQ8BTUvDFK1_cyXiM% zrcv(;!(}W`Sd-$}&uYXt5bP)zzZ&b#lJLe-YhK73gQLnv>Mp}I+vz_{g2E>+7sqNv zSOQf8bV)v%=P_z}&!A;I!tUb5ez`75`cD+BkBc{&5eD26L2a2h3`2wU$i%P?%E=Bf zm31(=gM|>9wC_{=!Hdw{l|UuhHx11?d84h5$}i0&-JnJYb0aI3<8PHHhwjpr** zNP&?tA*Czm^^-I=4_Rak=y(MD6;MKz##}S13msH?L8*f}_*(;mtbl&s*Zs)_abJU- zP3f0HhA!%a2LsqZGv!hksf4|^*ctk6Zu;ru36nkR$fMR6H*t0?S7<3&@N;f*cbR`s6oN6O5G{p3J6= z z5UtFjYTnS$lFpMyT=BRJt;H56ko-dimz|REn7I31*qQp7UH(qU|00A zQ3t|*+_9c~pQ~zT562VtKe8+@q>3{A?8+)4b~6_rOxj#16oFcq)jolp-ywW zOGx_?ho|bk&#=F!EHfoOXdsAFWOG!Mn1)DM2a#4jvX>3mX&iNlV?dIOW{fa58XbxN z!p;c@9g0kt-p|J?;#+65fV_%Lsik22e*5P8ugh@)!)RLE3Tz^>o<=PEmV9bJhf0=R z?lkaV)^%(0hs~5TMAZMzKL(YiA`v zcrdOdyFiQ3OHkdFO6P-Lk9K`)`tIr$ZbY|f+y@GV*O^Z{^_RnyFTUup1Z^i6_AJu% zUYYh-+p212pV{mw2(44VJ^-?FAmJr3Lezj~GZ5D?%+fGoFGWFBDrb+7$ zFZQ!DodY=W#sac!-CEUN7r6fE|)GAxPP^N;2GUzhQJ@=7u8QZFno zWo=xiKG`@&FHvNZ|L0?Fel0Nl>dXFR z{OZ=P8~rC^oPth(E%3LzmDwAef2Yo?(Vn`bTHohcug*m328@`^y1F(*A5Q|Y5=mbN z7>gX?9f1zfJe)p%2HrSy1k!TNQO+)QsGx&a@z;-4IN3Nzw+5=^&^@+h({z;Z+{!b*i27I75SZCjBP1yg7BO5J1)=0m%Jh zCoRGd2cZ@MsAb=**ID-H66yDlM@vKKutUiYyb}fkCLz%B6)>(*us%c2ARi z`HneXfD7J{8N{5p#sEn}YH;VkekmUld53onQil-{#2CBg`bGyM<>|Ye4uy|f-KKa4 z*|xK~T|kHJbHTxtCCA z>CSx8w~udTDm!5U&z-d=?OMZZ4^iDVlv?2%AIdr6LW~uuF{+3fMhEv@6X)<2hr{Jq zX`u$vyNmsD(c_x$9X3=r&_ADWJ|tjL3tasK#L3Av^>jx1S};%Ur<% zmmM>$>YU&4QS;pF$BnQ;vJK;>+*u26a|TU86PEnVGV8Rx8hXAgoL=X-fRV=624^fEi6Aor(+>8j_bxz-@N&FB+3zm2x06tDQp=HB-P@Q*$u)hg1168UHjxJx1YN~ce@ zFE6Ljf~tPr<#XLSG|fIY7r) z++LDfB0NGe(R}M|t$9JycN`(v;~#91@TREk)LwK+?x#yJ`rV%K#4CD%JX0v$cm#4| zd{dculx9aZ^7|edN2jqlefwU7ROrZsR+W0O<2?7gy|vh9E-_hOJJQyfe7gpJiV*Zc zj;c9{`?nRodSF~f4=PAx0nY!Y2A#20us|hh$B#f}rqe!|Tqf=C);a~}#|Sxw5dDof zDB<~y!M{_OVgG}4>R&-|_V;Y&|K!g3yFnDIPQ9*rj)5juoRs+4p&a9MkrySO0RuHw zvYM{D%X3Ab#wh-$mA`*rP5mp^`b_m^#w@mFba3TuPi02U@OB8=E>; zk{wDSBtM8?sjMtOz5*5oyEdNKOFq zzhzF*{&^M*)fiY2#>>*1l1X@7AFI857OE}e7G4|Y zU6D@}%;RU*%SwZ$$;&SfdcSpmAZgj)+t6;UT*Q8FR`*KLMKV8ov5gWq9;&@yx;DNt zh#qFVqOXEMKwcGoCg3#`-`2BFMGt@YmM?9S-sTmvchwp(L>uD(my@e6++!3wQIB2x?#yixXBk9dmhO59pU7WIm z3B&D5Vqvm-&KhY=7Rwn>T)D_RZ_>6H+xu<*G!+LoR!;M<$btw@h7(c_a6B>++JZ{g z0O016=S7Yrivy8}HYv^8mTU*sb@>;xSVl=$XIeX>zLv23XhCOr(c+~Ap0C34FDEcH zhD}SbUK3nR!CwQCjIP0dn4^kx zM7@9YKg~7%Lv+$kACv+bp>(b-^RcrBipaQr(;dSL^fmu#5&EC}%Am@J`@$fouRLnpoIE$Y+R-&3oOT*#e(C|;zG=u+QbyN;-@b~9%JkJ76{ulfA?~+ab3ESdG{yHc5 ze=|Pau+DD7JDN)qdI%QS6s|*E#aMLo!`w*DUMnCKO)QYo|hXhJN%s62S}P_S$x*6>IR^& z?~ZHX5TO3Q)GH~=8tZhr^7!PkH4rMwvAV#+^fnw&+QgjO-9rWC_w5tFl24eN zIrQ5ZU?y3GsduXz(^Si%wp|ILBl#H-!?;9aRZhbA96^sV> z{1F_e&G|^-su!W>{5u;`wIl8uFhgt&zAY&MlDmp3pl4P^9T+`$bFkp0&18nA%~YG* zyS3GwiL)ty2RKR}*SpkYQ~RmeN@bjFf?(X+Q)9~IMY~>XFfPXPgP*9x+P;pQ@xe)U>|V1AXS)66+KPY&VXsd2dVv+z zS;$sf?B_9bNK;tvDOx>~$FXYhm6*Rg*)dk{9;5DRNX43iFWR4O?n!$? zuW8uJWcmsr6}I(IF#s?a#D}N?cY}fCV|2-w(v9lmz;^@C4@*wT77vcJ#GfRyKd9Vd z=d_i(%TDo1?15H2QaBROJP|=Z_?eo_h{tMGd8LUuKFT#dvFor;zh@OHTzht3%e+vD zk097n87%t9^3k_EEKHD8oFa{%tR;S*{OWDge#2Hlg<^phcZ|OkMC(TMU2Fzo8k=Vx zAmX*~jtK}qs^+r=)y#=7@e*eI;e55rs=sTE(6b0a!7Wkx_ct{ozqqy;kA$x~1M*cs z5(%#O2An#TmL~p;K6NQt)>2vV+XBwDV-s3tQeY8=eWTeE-C2MnQQbz2;sMIpVAske z$DdL@yBUT*MXwT84(_JK^?ozHc0W>tAChOJIFUa@(S=?!0TJPb8LM=$eTM15vZ*~2 zT0w!H*s=OiO8g|z>Fe-zQyw*E>{7FoR;-?sJZ=1i{r zT})Iq2Pa$7599nYo^|760l7zIZG0%po}XDXtI-Gru%HDZ4jK~CnI>MDIfqyYKKvTL#Q^XqBI9p|h18mQgc>Tdn= zG7PZLz0}9&q>P%srRhA}Q-Yn7s&`tB3XVqY8MiegYC9X6q45j6E0m%+Q&e23o#m_+ zzv`dL@n|u0vE_iI#j)TmfEO*uk$0bxZD+M{P1;mg=GU3#Hq&--%fGIyg{2 z3gChOT0|WPogFGS`Z8gr(r#&8-o^ALDfnHoxp0pq?e1JlNi2M@`t;kp$!6Nd+^Px7 zR8fe2leODt>(A2L#et=XFiS#+I;ImHcsI4@(N0E-bHjBxT~zw1r*s}wd)tig#@YP` z6WDw>U{M}mk3nM~bd?w(XIVkp@$ar>#%g6tn4Mq<~Bz}k>(um`RHR|LL*aB zZH0Tg=Q_KoJ*DqWr7dA?Q-f0jjFFFbtF?&3yMV8+qB-%2L^*v{-5%5yTEF0 zHkRTSI~AC%Rps=rO1-cjo#Rm-6C>)nE!yw7EvpxPse}&e%Dcr%k7{TPN5o4#Y)F|S z)@jWQp0?pX<1)5MySDL(&&&V^z9ImUG2QHc{xor(D>rI?|NGxFzpy@z!m>xqhHm5|2X!N`W2nBBMy$u<3epmSffs0EjDBEEIsA}4&~qG42M%b z-WV(KGYeJ52{Qxq7MEe-;B&Mq;D9bOwD`kf|F_5@?|f84n>k?a?F|-@y+Zn>NUdi4 zxX28+4AETV=;9dZUv`gkH`M5_p;N1JU~H(`PtWx)8vALG|FuOox5(knt11o+YVCwx zkd#`%*{@D5AA8b<69CU9(!xmJAs2n+j$M8 z{ulTBu73mR&Ai;2D|i7E9_tTE%q9P=@c23PK08o&T)!jAkwanO3V;xWQ8kFo{8wu| zveo72+dT%;$L7-FCbHki7F!gFgTjSY*EV0v^s9X~e;5Cb*>HRh5x4}UC8scvU33&( z{S9P)cemiRN3?t&n+O}cwu_)tA{j7ou5x4ckgoI&=ec>M6?f6QafxMGPNhqxx&cq* za1Z+!+uqr|`mT&jz*7qX*i4M+3F3RB>We#g8K?HG`MM2(YxU0(`S;a@8ct^;>5GPn^{ta*(;Ls6hN9CT|Sk(P8qaY;WS>iSQd9&F>92> zKSKSD_Lm21`9e65v&(Ytn&?$-G+z8lUIDN$T|6*w_IPo~ZayE^djFF?E~L#FEZpD$ zFzKFqqdogF7U!b?K(qOIWRx>$LaEf(0$zuG z-=X!KpJZ(cKBdn;SD{XQ+cxm9H2#bGtAJNdW4!Ok(vb*_sOsvrJDx&?El|<{W|JrW z6p8hf9{y3HP1`-5N81XR3fYW4DD@s7oh-E6)e0=xl^QP}C+#Z|ZrmIa=DhzzB%O16 zdNOX8#lq(y4uY&%3^_7h7;frxEs$-f zQpKGwk&s=eK~iE|lEiCle(LYU=&{`K{ayF5#Kz1+oTB8d+MS9o)kv)#2y3;C75)b$%(V~OcMSO}hfun@wj zADKlO{X@y{zf)HHKemm2VD=+!V?XG&0q;AK&>J}vVOl1Je-TBV&js?S--;jw{bix1 z|9AlOVz#W(_>(6rfDDH96x$DnyHd?+bOcg(Qjy%995maf_=%E*H-c?C*LBi7_-#1*wkfyrz_y($ERY8 zg*~d>yT}Jq@V59_Ib&!3~MZ0`OBBlS7uQdW{^|ytfC}DKHD4Tg!DT_KLB6OIq)a$3&x#9Cdi1 z?l3W)9hrvO7@jAX93~!%q>F$B-Q^W#HxF2xLhzWQweh+O%SjY%5uK0Zzn+R8QxTlBFCNuePS=2$$Z?cyh*lM=XViIgf5(GZjj!zFO_cC z@zF4q<{$GA`+^vF~{U=nrB^|YsKn)CPRMBfNeMQL-OU$M+kP2@0cAA`d(vN9sS zd$(I!PRetX?$YVnSuJkV^P9`E)X^BdPx`>|>`t>jE>dE_0m>}S500@Ume)SbimpXf z$J&*w_SX(<4RPwQS>Id>E%ocNDy_k;|8WUj&u8(|P?>@lEM8}N7ML$zV=rB6HlCmiy0eU;>Y{(zXxC@QI|E zXGc{LW8o6{-A+zU<|-Vh@R_=mvkC?=4)e-kj$0913(Yb&?(7?Mut97m<$;tbPd!*e zXM)oKlNLgA(SCxr(Ow((rOee~tI2;TY!EOc&4G#NoSOX+3wq&N`4b%>4Fr`B8iwA;I$i`=1wzO7dWZ}ISeZj#Y;E8%fY zc#lT}^tV@hdu)t}IUPpKYd{qpeiS&p(J-*&>=z-wwSY|oY9yo%z60TIMO7Cggm0R4 z=1(C!Hb=LjAAaa=$FhCs1NZFHDTkO(9!S)@ri*rlu%m(+ilpF$ zx>GPiZiB;)V3jBR`)%jm6_!jL!hTLjyTy}=C0!jov~&BHq_1YGB+jmZ&)ehp+&Z2e z7OP1whlE+@Kg8<6xPZy`Sm~Wf7QR4ifYOqNb=jR{S89?QEz+bj^?YO9ZsZDy?m-tq zdyJ4gb`7-e>t&3~!>q45t%;b7auWR|kzf>MfjjN#a+h{kl6-ylVvz|r`Z!+Q+c$LV zkSUs0gqTKyM0tpLJz&0KZ;kO-;jv!aC{6Xd!v54Aq5KZ%fj5-vgOG##O=+c`)-2FQ z!&=yU2P0Z7A+2n0k) zy@_on!jyKRp-M7HLvO|_Bl`-dZQah^KMZFGe$Fn;u#{>jZkf1B*U*7lo;Kb!m^ZO} zO?NlaAZCm>`uJkjs(WiyDvAFYGrmBan0w4r_UYS+C;gqI4Kmzlp>~2uuZwo2$1@V% z@ii`O>&Q{PHIc!U+{#O97v#?}oY7;w5D%v;ys@@Caa3P*IBi*rtRtO=d4KBIWJP@R zDfEsDQ~9I#$_Lc8)qdHh&S>)Rbu^=1rM*0$t}SYP*t_XS`kY=T zJE@HJ<_nUzF0=A37m$)_0bD7;0@Q%c54m)xT*5?qkCX3+?xZ@*Nq&@ojWfGaeyM|X zy;}InS6>DI1h!d)F!3dOYeW!Hc%Yw9>cgoM{=K21Wa@iEa(v1~qMY%iGB^uaprfq6 zI>r9v^9!0QC;AANOumKwBxH0r0e z%Kc?__@7Mt{+L1jZ`cMa``3ETFBwh$jJzi9FjQi12Pf)F@U6z!`;C)D%}$y>P84j1 z`z-XaGO6{%D3#ez{{i1+nBTLX^~StBPGw_S4d6s>>Y~-x5*gui{`o@VvHrw5{7sUZ zn8bOQx!$5}{84>F7 zF~@__(q;Dlov0>$ot^-iff#wUV*AEKW?z%|WdnEl-q(i7-F#Mn+azusTnw_EG_l~E zsFoK-2pX!j`fapKS8vv*!8r@%T^_}xU~#Y^*@E~b|I$0-FF}J}NsyEH8TK$<&CJyq zVS@OU;p{7~XyZ*~iL01vAx1kgHPv!7rU>b(en- zPF)L15>t00pGu^(H)N#)LJ!}sw0W32%zJztMx{K;lj7~u0wpH_PWv~&@qTHF(J^R2 zRjoxKU@L_t%f&hKidLj`E$RA8xdXTK*Z}frU{?odM&8{C!4ykWWu;55oBH$<9m%i- zCmYRuz7a@ISjYjOW1`kyQ3a3$Z(nY8d~B(YITTi0)QK{Y$dgZQCjyIYP$UmfV`;~9 zzWZjl$~*VnD_)Eowo5lH*xn{LD>XQ9L#!7oB_IV)rOZgL*pW`s@X?u}!4ynvAjzjC zh5m(`KS43b@W1E={{OKvTUy_A@pMV+GYv4Ghg-(Kkiv3CAM!3pDw@Qs6jpS|<~(>i5wC41g#oNnN&LJGL2FUH~f zF<<;?fj3BSy^~i+2oDoaWAtKaN&GyCt07n~u|^jBe2H7yB*hb3Je)o7--He9YP^ zX05-f&I+H}U4C0jb(f87*lMy2YFQ z#p0=^+coK%b(yE^fH`q#PR^3vPYIzv9(~DQWH0LHs}D1m{jsL4qxC$kT2i4h1&>rj zbfYQDIC7OI$?>!pwKWWm1G7?HQ?x#vJgycj@NakC z1cJ}{SOx1F>*wXQD)uDPOGQj~>88e+HRy*cYKaG!eJZ5D)RzEor#e$9_T+cq&SI>L zgv{^2of3ucTG%W6Wov-2d|VR_BUT_sYV_#gEMV@a%*peY?f5jMnp-&Bw9qN@PtN4} zB!#E*swTO?h>bXuP`ZWy^+I$B;8;?N^w+9^W1iiMr^eH#a|D6BK!y&ed89_PxTY7qVb8(CztNVrXO@)?9-U;qo`B#e}ylpp>cAf$sDOtNZLKPym~s2ooWpuf2U! zXmcsVQfjZ0Qr;X9FX6Rs*C#tyQ(6&CJ9Y6e%RY<*9(so&Pfz-XJ<#$^Myz2ppm0Fo z^19UIy6f<==6mNpQO8KV%+jXIQru}DC`j;~#(QuS7#~6p;eroLwW+{kwY-gOrk(}H zW^%kvAj?a#3W2NT7596_{wT)4WN4sW0i(X+@D*xp=CYjAmu% zU>wJCz9=EhnhZeX;G`=UXmi4;+KQ$bK^N-qIK1Vp+@ zZz577L_m58Ql&Qmr6VFBgh=nbi3muIgqnm7frJ_$#53>N=kE9Sp8M{7*1G4Oz0bMp z{=r(41({)Ho_Xf^e!ic=!;f|hCF#qgqK7UXVi@05tuu|3J0~rGy+fJEkM^FC+QsCT zUM7cn+Q?~Pf*g#-jSe8IR?@M*BO5(LP3v}FtS;RQc~v35k`sV|L!X2?i5!WkH#T6vTNo+NIvNH{AHs17mIcOyzoFx*Ut+sGGvCVXAoD(ZUbB`RQzY8<0MD; zid~BHVNcv>jd;BQu?;o!L2y9dU~JhDH{@?+ro}w4Tjw>Ukg7_iLsW7 z(u~qd>^<3aHt4ZU==G8hr{#E3#3JiTSCbraFBEVCZK*fTiq&hL z5Pzx@GH0oE`0MI%FI_2@f7!X|?w1rX?X}+V_UL<3!8~Ikh<#T(qn4(2!c#SFB(sg* z&zIEFdnjwUmimZSZ$7|_Y7I{9;#bre z#SW~dlNw(%XK=4B#cUwjgneh8jSofa#7Unv^9jrZWd`Z_#QwN;o_Fjw>OP;i9wjJ< zuYe>!?)paMimzIa49bXH`h_TeAgC+UZSdLK;{xL)cxn2TmY;WjHtRUL8@#?}L^9#6 zCfwOvw2pFxp!**`L4D&N%_{i#o&CM z4b={Pw_r6ZyUe1^f#0&~B4+yT#km`mw_Wos)EyV8+0U$+1lzDI)Zq~N>idmv6dG_v!Q5h^& zgAUG-_b4GiTS@4YcLSxBAXcE_yCnWypSPjk|NCT%Jwzx1*?l6DC74rO+mEW?-B_~J zl?t)-=wzZi|IMZ^M7DxVyMS^c3$@6;{W4k+-{ReMzG?kg#j3S$hnHOEW*kx&n|Mhm zEKt+^v*==5fS10EDsKmO$xZjvak(KMZNkZVEso1D>yg%r>Y9LnX7|_6tQEgTCgrr> zKE4vio8yO&nyS_E8hUYLn04klF(P?sl`>jx1hBT72<9Zh9wv6PCT|)~Rm~E=Y-rPI zErSML#l&4GO?n+TR+jt+H1(AiRGFm>IM(+J3csxj2vQkHV)$8=1T!>K=~DM@Z=1I8gr1k#ROTTzr@=2%kmqjpjGg&qI3J02eW$LI%s;> z{V2fy7bPr~Z%^p z*!cX0_;V#Zp{Kv7)&20$N3{)v3qrkaHmazj-kL6|?rc9;%W;+*yP}4^^+9_3#V0U! zU#M37WL#!&>$zDAo7TE1qwCT1tb5;u&$iE8l+-X8GZih4vWE^M#7Co7peV`1pmN5e z;NaYeO_i8-yD2aa>@1_5e0dXEeGGHzM=2i-<+O{>ua7(izkJhwF6&0`5MbTH<% z%(RM#B@1Ws0A~U!tE=~YBt`7|4T}9$p0d1AFia*4{cOw?cXfofjJ1?!T>1V>5asOz z4Gqss5RN2_&f)L&^xge2W0`6=fs^8D5Q^OjwiH17d{~L|h%kL-c6I-_WR!NK?FCovbAYnmJV0^3xh>pvQ0FTd_II9CkjW{~Y9 z%fP50l`h8f>U|TwMfKUK+@notZ(cFIR#l>R`%Su!?~kh8q_=s1Cmq%sBS+`n27Ns% zV7Z#|;(nzd#rNY6jmMg8_odvFuWCA^ZfN#!K3yAi(Jk(?s<=?k(4fc?nB*B2mo28c zbm>Yw;ps!b=CMa9Kxe$*R4E=lkZ`NM+SQUlq9>J@SzSRg7_a5IAu2Pd2dvALtPY+d z*~%oB(^Pfd{j$53bNCx;*h4zU&Otecn4v14M?6=il`-dW^r=o21z z+dB!?<Qso&zDE1ntuo`!+D3IZosZaxsJ?yL9&Z@WmiWYu}SSlWk^_<+KjtGOw*G_+qB{ z9C>a{>Pb&TOfqkWQJb6IE`tJKh-k*D9_2h@vwok|77w2#Uv45W571QAw-eN>*Y>F4fdAH#KZ-YIxQx@zU_! z!Zggu1lH{EvpQV=_+crxQ7}YF8%-T48mKKEeF-H*5@0hnx0pqlu0V4Q)~K1Ps$c7a zCiP>?sq*q|oM|C1q+(BFX;vqslKC5|Kddet4|v^tP}hGm!^b);7Oldqh7M2)UKp^* z!&xbn`}oa(;XmQ+1#?IK?nt=jf=d+HAF+C$Y z=i}P{kvO(>zkR7tSi3_!oyB-f4uh`sW{;0?wY7`F&7A#s=B~KXkC;G^7T6#8&$xQN zt}OeyBb{q)Kx)US6@ELz72VvCDP@f|9RKVrJyDF254vJlrdDv3vH+>0VdHWts64rJK~ZMsSQfgO-lxC`+RYT61uLE^t|8}d;oHLx+Bt>_iPcv1G3&1u z$CSsRE>-cP{Q~nI^&g&6JH?d@-Ul;{@VxQ)2Uju z1d(}`T0%{I?j3S3c{+6bl;6HOQ9yU8W!~@BsgE7aV2FgO9EcYn`WY>%M6oL0%|f?E zB}xy%p^x&qj_Nb5v!KQ0+76{F%<*@0;)ud6S91E7<+zcP0#DQ)#lC$${)a1+sKmn!=B z`a;&@6i-x$GVD|99(tskFS4mEk3hM7&}wI_EA)Wvmn9G1*6#E`>-khRREQr_N;BP6 zc_AP63Qtp_WFgQE+)l#;>Gx!YYn7u$JipX%Ue6qwqkD&2X}A)+?Ku78!t0MqfIb`$ z-H)}=OmE6Q^~G3mS&(}IZ0Kw(XY;CV_M_lP2cj~xv6+9BJI;NoX-M+WeN-IL!d9q- z^?O0!iajCEjJaan#{T3n^;Gq~c!q|MT zXuDcG87psjCe}4ju@-Od=Ww|8vrF4f*xnSRM!m%B-VfG;OUR<`<&ofc8w}FQhEIwvipRD;r{MUa$-*HchJ!ncy`o-cD`+O7{59b;K3cUo)=rDc>*{|80 zt^lj}?>qbZ7ZB?d94uZ;|zcwxZ;=fYxm+k+@UM$FfLXrXgG*F}x zZ}KiswshmJ*@nl4q1Ae3kM}FHQG+s|rcXL|$Pa%FuvGpY)A~;hct37t!f>=+MZ|8M zwmSSlI4|pX`6>|4@zag0|9eGfNaKCVknpPL?TrqehGUnE2<2P8HUR#f2uSx;7>ExJ z#kb1pn=(bU&DoeO&ovuMzX_FAx7MF^xY>4iSNP}ObglJXTC88NIt7Y zB;@jBbq9mT->6&|`8?~}WxCqj)G!r1cFjI(h>n@&3T?_Pvp)Yy$GJqJ6N1>hfX{|? z0W|KriCc(oLG|RE;E@%-TpJRd+rq0O1jpY(+EmX9xzr|FDBaIOgrd3?bo-LQDz8vb zyAN^>^vmQmG!flol4fmV)?LBXdINz zHdk|FrM*F59VBi)Z0_yIwm9|!UATCioqMTXx?}V{?~LE67QI~`FaN}W4dakc(Pk#I zlPv3c39iNIm)Laf@~7v!%MzyjVQa#&fC!rN4V02-V?GXq8?@>T2ifQZNf5*i#@%gY zwlD3tF5Q-h_)h)vBCUY9975nzhyw5?hcT(3DS&=b-r0HFAJB|B&lKSKGq@XwYi~I% z_Le!Dm(2at)T)9-{tmt#s6@~_1bkhI4{OS-B)8fj{iv3-BEpsUeVmu{QjGz% zE?S%u)+J70(;=ArCQlxUs*95cR<;)EbWde*a{GWLbH7#b@Z%{ZaBw#mnrly`3assi z5p8$t&st3h%sz&R&7N(0t_gPok$fgdWl1M25K1#n5=U#*(6wLHNi3bW+=$$}OfA9w z+~)Sh>%n@5o(Yok2+dRV0lvN=NdMp`u%XM8uB~*OY_Cu>=iGI1mi~-gxm)Zq-EqkY z2iRu@)J=f5W@H*V3jwN#1!Bl*c4(FM`ss%etJ-he?C$g~G2MDXD-_YW_}r@$X5YAq z|Jinjpp0-BTFg?Ck6)_4sNRBfZ}G5rz*YG8C2hoJq;l;dz$zNDGj2rI2H^Avq`~Ha zrN)ncKrP$7AIL9}KKLC>LK+${h%f6xEkp?-Yj8ohc`eZczRE$0@M_`fyV+OLsaGzz zf0xGsf)7TQ`m@4oRY<7@97&XusL=he;TKi-QEyTDy!*$T3h+1ZRG?=clCVsZdu%O*TqycaBuva32IwyY{ZB{ zU;*L=-1RC93Z{r-KNU|?)QxWr4&0uZbdTE`D+AiO7;%!2xk}iUN;Me>2ckKA-{kml z6_E6IS3~v!z9_ctx=uTspBhpuQ|OXjgE zO!E4E4FRO`xhsRUAQ+X%rrNqFb{Hi})8*T&rC+Q!t42^ijSRO8wIvf{tsmf_j5E)` zQGBiollfRgG$&Iv60Y*zXl$9NZLiaps!SJ@ct|6Yq`@E|Kk$l>flW-v!+%`kl^9p4 zR0ep*v~2H8toMSE?OQ|*8(}W0oz|l&g*S-XpF$K>VgRi_+J2K05Mmv|4xzu_0E2jN zuVs$>*mWVNEPl>?SV`CY{Xc5h{=c!3FlrUoZk{^e3ZP@>T>%Wx-PPHbqyK=E0*ud& z-oE&D`Vxu>qTO-5;Ve*-;2g_bc$0;M5Y4MQpPfqy@~HWnc+NPz42@HJSU0{c?n?TA zK%c5tLyZ?Wv5-{&pn)O8A_iNr~-f;1{Yw{^?Z9HfSiL5t9j_! zPuc_UvM3_*Y5tllS#a|C_?tn8=GG5r{wLI`DOv&`(1nyopo{Yv=!sM-b;r+}D4F&W ztv7X{!(GlSM|F!d&UO#IeqcQ$8C2I)^BLvHLk)jGxiBKoxZqT&c2il+BZF1?n#8w` zGoGB%z2)i17E75|m_P?PTULr}Bge(B%$UGoN*W%ptUWQS(JC%|f|xo}cF?Aoq$bDe}a3pTpoL6< zM2Bv91~%dYEL&k`{@`Zl@%A6k(GFlbt}T=019fx-uFc5S{h1%O1 z+_`1yDy33>^ndzVB9OW!Jlb|G_~!z18!PdpZ&)ucCqNF~V-{CJxPHFAupJuBe|Igw zeLm__^KaIQ?|U$@8FE1T+36q9=ICDzx7p%~x9Q7c2m}nwa`}H1jZ49uFQm)TOF>u8 zz3nqHt>P(X;69H`V7Nvw>4LHlg?mu?QMEb3TmF4zS0$%sfjl)_CkLfiC?&*(fn*qj zUSI+opBy(?ZyIA}hP1p{?_*aq?gSfChx#L-_|rohVje-eI~w8Z*i~(_&d9IHGm&9n zQ6Ay@BGl~aH){UZ7hYb@4n1dHVgK#W+cDG6dtqQiA_ zJ2d~22V}z3n9g@)FlnC^J2YdxE)~X_sDjU+nR$NqVi?M>8U;{aWas@1q1rv?Tpy zcx(RU6gMv3I`?mkrcfYNPFh`k{0&cnWl%&@h05{@3Ke0MOo0KD8TN{ul4s`NDJ6+kQW z4pIGa2dX|LLwqSdU9uKkD@;Gj*_S<&7!BkZs3+5YPfgV>>UxZ09Q&ax zL$(6J+!J77*v*8`-AvXZm3?crZYhCZwPtc!9089xLJ`XmUpJ9t$Ji?#(cPJ1$rxU& zS37$sPabka0TXl0%U7rRdC7a^J?~jh8!4jrlUhK|?t~LNJbY7E6V~%}cj$W8r`QB7 zrI3SsMu|^tC9MEsQH9c%?l7zgDOI{Z7%&*??tHUri|w(vSzNN?&KHhg_x_($o|ynf z%j$xv$5G0LxgqRRyQ+zbuTGTRUGI@cn4xdhhS|lqNgcO9iw4v`j8*8zdvyz@iE2-Q zMPjODb5$lV!|I)?Ob2(^QGNsuu#2=`6HPnus*stV*DiaPAMK;fe?fg%LaT%2u)bRf zu*cAvBww7Kc9E6QG{gsy&an$Q_4c&4-4mlY{-AvE6v$wE!r33)qC&XCqiNAPxxaJ8 zVxE7_%0^qnr?O2$yP9bccd!ar@?j7cH*#qS^oLP!^Rw7zRWLEk!cQ$38H&E;oB9{t z2v$M1T|G}<>|yJBnsXlT_&k}^OCKXjzi5N_Ek%TK3}=ZXe=j(^62{EB9MUO(6op`R zu3GudX!`}T5fP^z$ZjNSy-8we`Mey8! z=!tc2hGuwgb$ay9TqAvTzt1P>YoRp?D9Kr-ycV{uhW;?ar_;xqk|}1s+k{s%KVRah+*~unbG~xj!od9HF^j zT~>VE3o)x07c0RSp`8G=Lf+C)WZiGz^L!A*r9W>1~*UCTEOavdYC?1QJkGKf5WLB z%?%Bdi4*@WuFAW)4Ct`Og}`XeEBVZ z_RFB(?WZd)W~EJbgVP8w!4Otq@~s##nA)z@zu0?o>(FN%;^`^zeXRcb1O4!(V>C_I<4XxXiCF^7dQ>1~j!$h!qF;~~KqTcmM^c4}A4USH40Bpy2n0Jg zL)rAB-JV(7AvK3vVQGaM^3;JZ3LUHvRPL86Qc3=qXvzNZIx}M(dzeREoAod6;GHih z(6Iz!O@ZYoM!K1YUndNw5V1c9g)$uI?%wN)s|3Bw{ruL(Xmbdg6)+N0N?;t(k8;#$ zw7TimK_1#`I0cQXp3UPL`qM~?^`6=o#^}tWNU~TU^H>4UV@l13y>zOww6L&jx%=G` z=kWeE#cqRT-D|mZ;Gt~;>gdK%uvMv!D=sQx`+D7k=u#3eG2e6w(akTjOH`pX`@Os$yGMJ=c8c!1*do87f6Y#VK>i({gMXIN z^S__z{)tfpmJDwvVHZ` zSS8BsUi(4+G! z%#R0mcHdfi0;eEYSWUj17(;g05kK`1o$<%wM6&~s+F0Gkt9pqhG2`8tKwZxvRm-{y zbJCD{ANwLLMT%+h5qwy7M+lF|Dak*Fa8@Azb?-LJ@R>`1c4GBl!|H|SxXt^#1^6uM z%3pzocPA<+bBYe)d&8{K1n%3%eQg9?331Q%7OV%K|2%@n_MZrr`C)i&%#SHOkmI#d zF^=&R(a~GEA$ZfSgJVzT4Bc<(M}7Dnwl@is3f}?TX^lLVNcL|(JLV%I18)E!=49p>F$c&bSA182vu|=p zaN<^w>sY$G7z3aIxLbcft7Skz0MHP7N6(7*NC&B8QX=_I2&??7>r+P?-bh*R`+<0r zr3ka^U>{H6YxzGQ0JtNBnd85dQiU`b!8hmoKbiJa{{gx5f(=EbYkR$JoZTZ zMSeLwZEt>(+uGAK?{oRXNPfM z_Foe?NLm$c|5D8dwdVji&WY4VDSWm}d=*%;D)|%*&&g;h&_7huf32+l6SpOu{?Y`x z12moz<=Qs^H1cMN%U?d^GpZ7yggb-!V~%*AxNBKPc%}VZJ55&lzK`CinAQ-EI?g>`mb7Zu-*BWn&%G`jWHUb}8-i?r8MsyVaw&j~4pn5y`j8R*njk zEB}BLQ=#6k*Tr`$PlO0#o82(JHa60;u|Rpucaj<0iEW_5e7(|n+#*N*x_aoN^~LIrwvD4*C3VJ_fh9Q3pg8@B$4_wG>s1Qmr`%e+ z7fZG{oz%f7X$-PkZ=JOvU-1U1Exqdv3v1VF)0|kSXX{kqbp8Xn+G0bX3gQ~XM-25= zsh6C6z0&Z3KatlIb~o^crtr*ItL7r??LlHRA9WztXJG7h9gZx*mB)fKH3{T2_|2T* zsKmQu0_`O;Gi5v_6;o2L7T*QKhbE%3x*JV_8LL5VQU}C@O}`tisx1c~8e-&Ws>pDte(8=iT4SHd3}Q_XCQZ%(lb*`rcFC!9exs&a>A= zkGLtU?bTWCC5k)?V7cunD>MOgK|ekCZvY!Z+Rn~_S`SGP#ovW;k<1WC;ts9YbQffflYivy4#aKko6=j-;ZO@rS z4+s%TfY-r4pn{E`*N#re1uT&ncAlj+x_M$#W31^W9U`y4(#nDI@};fWUp8jS*~S;8 zYtSTrO8MEd2%>(X3({0Kp9By!MuXgL!|H}_ZS}6H7FG{O2f6$uzFpw6Gjalbs+e|a zN+@y+@Nu^F@`Ahw)hf$nxNAL&sPdOjJU6qzBAdvgLV zFpi$I-lBnvE?pFup-B{ca2~Xx16uHDM1MWZ%ir*?EjpM?&++newPR1;hWK)f&Czx^ zvtPP3!XcM`QR;6{_g{XzVKn3$u&y#fFXCN0F!R@t-Z@5X&HJS0NeCt?*467yuMY#} zRsZ+4_S6(?i7Ov~+EQWb4<}R2|50RtU?HiK+5dpnZ!H0!2mK#VjLpeYAhx8D;oDzZ z)BpeW|CAE-f1!dD4Z-R&5ycM;@%v<6q?gh~zZOI9lz}hST5iLiE?m2(!NIWw=>`DS zC62!hf>_wP&cSb|JT@C$JWqYNG#{N)@QpE1#LJ26VUn%+-t}DP4N=qZQjQ! zHmcPwSd=mw9rGayB0Tgk6R0z&&1p+d|C)Z;Tt>Cl)CBZUjmdA;Gi9dn^H{LN02;xvra*p+7H4oW zQBWV8>!&X_kr3FGh&aP)D}H}Q(IxyfE;TOIGRh38i(lx>1&@*JOME>5aLf>o+Yth6Z>1N@4q?kOfTUwghi z>bj*=2DCR1KTPc)ds6x6i5@&AJ^m2q%nCRQ5QE~>7?=Nm^wW?pF6CW*Za#o`>7o}x z+s;Q}7{f^CF|>i|?ubp9sUtT3geS@v$DPHIghT=Y3N(?u;R}y&%S^FB30Xgih^>&!fGaeU)aAz>;ko%H*_$@(*QRQX zCtl*&!t#zm##UNZ3YGAPkwT{gYp|)64E4QvQ zTU(i|yBB3tBT+Z*eElM8%x<`l3EBdy6fbGkv!XecAm(XIne#dF6K++ucQ$ zYhk#Rc@DgI7+C$&oNq=~B7Az52sIzaHpCj|-X7bX_v>;0k;Sie#qGj}fmF~LfcJ<1 z+C^7Stu}n%iA_|CVvz;WXxm1=s=hwE`*i=d(8o3h#|=GgN1+r=mei6!=!D>CUmB45 z{Qy6ed-C2NKEbIYN#Ucw^wclKxhp7EJ7?SOgEi1KL_es|Krv5sgLO}QGz#X1K z=i~4EVb(zyQTssEa!Z?vJo_Sn<_FX3qaPNdjQ}-oia&#vKq}B2&IFzCql*(h5#!O& z*pv2HekW0WJUPcz#9v{{CxOH8^0V)z!ZN?;xZkFDXS%6z{vQb`T&9T3?Yxy{5d_A5;{JJrt#oVkA8Xhg7M=JoU zwhC(RQi-fLKk8}rf?gHOTg>v~SvhrW)(X0z$A3+Hy5k-9-2w3lS=Zv2hO>NW>Ct^2 zAz&hp+ElIJIidvG@w*hHN9Ej#TOFWbSGpo((_kh<%L)pt?VNJ3< z7At%DX?uh{_dPf4UOxQ}cc9?7N;QeT%vaA@RD`5&55^#S%yY^$P^K?$Uy~KP9#@KW zdyrYVxDbW(R;D9`@96%4!+1r-w`8xOGfbR&{bXhe(?1y!#&*R`1hLJDIMRAAA1-j zAjLcR?>t!k7O04q?Xs4bYGbi-9*3BFzr~!!mT5Ozl$AI()um8f`>iN@JEFtZl>Y7wN1^HG-Kq1;{f(L*lTHmhv^Yk1%Dxb(31(QrYS$c_ zS(fg&6eEL+tSoJiqMzyBTStlJy4iCeZibeX5dM0iCR&`w$@&g2eTI4M6j;#E% zFKF_!-#&G*snKXG(JEc&|Fq`KD>$)5?he3c%L!^vWACtPbiVIOShUUIH(pwd`$etS z&e%e^xIlciw=fOKoG-%oiXz;uv*FcT8P*S7QB%V&jXRIE*w2^F^{T}4ihaYg^+2QB zIPqVic{9Fjq_pJ)T-_CUmuq?2S!X(Sk9qk%suM3c=Be6^S#*Fy@I!kVA z%f3$Il97Ofu=2{aI_z(X{>QNxmBb=q8U_;Yzximq2L52}qgX`&+Qs`_O%+({{D$+jCjwmo^!V z+j1RJ^?YEsHx&ZQCanmt|-hrqk`6wo8c3QAM)0Ergrloa(9F$l<%pzLeUEp znvNb&Q4<}gn0H*Cl@I=L%Il!2;9|I)vy6Xi;7w%3i%Bs29&}Nw1`zX>`Bi8bk%r3> zRi{GaVuUB8^~2I-{OjPKP`=SSUU21}1uodPt>}qcFLE6-UlXsL&3ef1qg(hc_*jdM zUeAr31^g&hMRZz3O| zVij!@^$OtNnjxP?SG+1OcN3qVeONMP7Ozq7NVw96(u+o{`&)9l z&h4^Ti9e2+S6_O|xBqNFTyXV6GRh3w?uc4kB~c3thtI#`*^#CWN@FL zv)Z}x&PT&jr!CmHk;NUFb>qwZC7ZIBEx)&2NqttEpu{}?FzV7%&oYJb;a1HL3|qSz zqe+ZZ!$1b=8n*ta2W?=^!uGoH44#3%}f#c=LPXgj566jageg zQdZ1H0(A3vcyglXvpXSo15DB(eBOLSHXsWX)mUs^)GhCx)PPlPCx6`*RlY#1+v)V& zjLqM)ak3~u$8Ptv|NqAK|JUII{~d_e zJjaKIMIq7liSdDI7+rn6(7mS_x-laR;?AVp=fydyT#)}55%aOEj{U{h^t*AZ*89@@lD)E`) zXE|GXX&P!FX4B^?XLDVi@7~M443y_9S55RWiBY@)_)6{A9fnO-1^b^X!oq`txA_xR z<%KJMM0l7a*TWrU!w-5Uq`6o}YbjgDKl$yr#+T>XmpjMr+`--|OHTQH{ziy|Q$?00 z5V9nG+1YTzUq8HdNMARZR0HVE9Wq&6XS}0~uI0?yDfyOb2MM0+J=2w$|A7h8&$e($zHM|E0PoJ4l&udw+Ld4FLfU}_|Iig}L5WrRxw z1=DM|NM25 z5x{=9P0fwv=motnv|5cPQxiuzasl!}_~RY5*0#yd4i>7m&Qh^~EFMEt22wFp9`~_S z-0kR-<_mgRiI+OLcn5#x_u_^E|vi z?=CpYyJD7miICuQKI>Bx?A+Rkb)`)GS(#nMd@rPE#EbFpsxZIW9o&q-Y1ANSV7@y! zSsRM4?#sxuaCz;kW}T;NN;%q+sFPE`lr}3c&&KuW!uIf8$@|=`S52P6@QA)9tKM+7 zHb!Lm>IC19>IAG#4(Bo>az!#bT0fOQWWV4548SMD3^E0qOTcU=mfv2;tgv7VMpi+Z%bTA9yy=Tj4r%n3oM z#DG=45c3{Pl2jPVe7NsrZOV&gJ{0@fr*hA)!*-ZKLfEd)hU&mKScq8PZOt$$jbOHL zs;{zGnHt?LGVNx93%&nvVR0{n;nw-#Tcu*6%he6(v>0E<=ol^T7^CF3ofmk+c_(q_ z7kMJK!w3{DDy3GwgHL}|)SL#%=(0+u2#=~r1mnP6y&9T77P>swKw!O?nO3~YyZyPx zdwR`yaw(+5?7mwCT(BznW+)SdgaXTt{0lric2tFx_~6#BYIf4;(Is=eFi8t@Ey~Xc z^~`TmxnDBoglrTF0%n30Wn>nWc=8xP3dKhf!*KMRI+ZZ{k(R=`iPtiY5LwW^&|5R! zYxv(S$8Y}-XKF_FPX6X*lE`ebAgaaoPKTC@c!n5GbwmcV-Vu}2S{4gR-C z+cRW0(6BOtbgMPs=MhwOqsN81>0L&Rw?k)Is+uc9^xWgl>k`=B25O_Z0?ZB;7+sn~ zWX)uDU1Xljl>Y|hL`=9XCggq!xm!{ZYee+K4ADJ|HJbn!C+jB*!u^YyQr!o2 z)TP)?jq+_$VVIi!D zJmkuIhIw0gkW>5cDT~0NELrTznrGabGbcP-Y&)8oz-^A0R-UPn=idz+5m$U);Tblr zoHjgqpGPNGZVxP+@6`UH|IlbQ!y1-FuJ=4&@3xS^zL{#_pV<4(YjA=2OK~B`evXUj zHhsD519|xJ{c}x^?XKZzt|5x`JXhZsP+MRT+!n+pq*`qaU@KmQt0}W{<>U&x=QWq4 zg_1uwP|mqin;56uzi6f3o%eF#aHeECczngqp)Th&D-~n~^_l?E5d&y;WRW?YizA3N58rp}4liTC})J1us%cp*Ss0aCayz z?ogmbix&w&5`w#HaR?sV6Ewx%J>IqVZ_jtmHP>GIJ6QW$^DN^8k|!h2c<%eYuIrC) z4DYrE`l%xD4?n#FJZBwPvIiCr9#h28%d27~LYjBZAgH)~4Z0B&Y1MCIi%sOs$c7cO z$bLiR1*+zBBqDo`rTgWjXdkdtkp|wf(+8KbUoR`q74~ju&dcc{LHqDSCL5V2_l3xG@?G{kXPGODdPFU=v7mG znU^4qRO4ZfMmDZAS4eKqy@rAcz4`1v^%%wPBA;2XDQyA5kQ+vfMNf!(l)r{ry^_NU zhmC1@{{5F+PkSE*KgXX!OrtV%d@^ft3asf!qI_?ojDOcS4|aW5e#FmOPDa4N`N`Mk z-tpf&n}4%`;{Jj|Beiy^$Yy!6(m#Nia*??`_8Zwt0zn;oER038-9AafVr6lZUUgR= zP+Zp^(QS;Ad(Czcj`2ZR{RAAR!u|iuiz-L>Q)IV?W$yRA7L*1c$IMj|KgbIeG&3W~ zW=_nv6ic;D>5~%)h8*g={kUc z6{-O(*R%o&q3qB=0H)s54fU;snr~{9=j^?m5hd)QM~z>oeOL4O(nvtoMtYFI=c_vra4d!4k7e8LB$ z>-5FDz<*+;wZ=7OrZj3>p@))`Ii+(&^&T3(C>kUQ?+1x@KkGrPzJnE038y>vXzc2p zY^`~H6A)zaDQ^2gGTm__oZ8`0-$~Vtd^O39>^Bop*5)SQ3V!C2SFQ**^Dkii~R zr;p>y6(x%FwwHjKmW2>Je1c-bwfU~H>1`^vk-5ywC>siZ z702Z#dp=1Cuj~?gfKP5Zr|B+x*~uZSNx~D`6n5`Fs6J&=o}0fa`jclprcu_jta0Z` zCA$+a*ZV1ohM#ij30+Ey>crkCFYroDH&`gJb93Mh-tEk|&u_$3A*2N438i=Ab}cfb zs$lR#p)6y0Q97Rvc+JzqW)#=L7GoPclAZFCK4m!{y`#W6ll`R6M}6gi)kQgyU`tg$ zchuCo7le<;7En;9L??V(hhMV)0TM>YsIO~%5kbvtGzXxknygj=k#`1kCs_I4&w53T zWXTD_sep9cJ@F!J;HujVMJ#ev5gixmwjY4(*?YE&o*OxX(TmUqe~*@NrX9@E@#cVd zcL*wVeM_+v=T=u1!Z|6=DYR+6kJjj+s>SGr9-2=(JhbOsQlH#Eu&9r57z}CeUW1az z=yW*En_AYZW``r?H}`oFZ?S3ZMmTwp%<_Z`NTc$EtW+ zXY1|zopDSm%WLkNc7Fa)V1QzSluBtKq3Z6Ds~uk-HN%j-FRV;+KjsNJUXX|gFxsJV zOxAdKjr<)6{0mQ2$9pz1tk{SZ2zA%9dHG^5Y3?$E_fwC&KGfY21q(IW$5$2JE4@Xr z`dK@n?9?O#jl{O-jdHV%3eo0_p2m@26RGMyc7@IG+9l5xkcjN<~3dNDEg+77wRrpxVNxUejvgN;&9Tn`}BEO#wa&P zCLTRE??t*=5Em)+a&>`T`&W6R9BYa%ql08k9Z3~lT_VlwcS>B-?=O-Uf=Gomw8rfG z$k5a%-eh-_BdLjPeJsLC7?PD*YOv5)!P0j*x-RMDn%UxobBvRaowfCYwDjr?en_Jc zCh0wfZ6sW%p-JGfBcVCnf;#+1RqTyBB7|=2;Q2#qimTSr-@cK{&o=58@VnFQB%z?8 zK&y>_G~|RZota=K#Z@G;%zLWdZ%-qYIb*DMmUea1Zh4ROV>1izA!5ee2{mr{36J^7 zqL|5SB$_MzSLuzkX!hLSLle7cddcUNx$SyG&fU0aVwILx;TS}W5GjSt4R_@)*%h)W zI9DLeg00p|2so~&$DKatQC+ZG143D3-=h1BcoX4Ij zd~zI;rgpzUuiTsmG=>hjSlZFxRhU!Uf258*?NRa}9!|h7tmAzN!i%8wQBJzIe7#H$NS-m|R7RP}4E>ciKTDX4`}3>!MjMn$C$J-2r^@E#i$jkXt1ihau{ zW2&;YDvn2*0DJ*{=)l;V>MzvNsGn>56|!;m)|d35mL-5d8>zX z)}_m`U!XjN38=YJX{wE}gYKi9kEXKL?ED-FLM~4U&$Vlw`RIlN?)wG6o%C-sITrBsOeAHtM_zVu;taZBeiE3Q9P~h!Ciucoq7@Hf~1$K8aP9gLAiai92|d7-=AN`95m-zh!pk%DPOsKanNlPg}0&(uSZ z=bm|67wWO8q_^7#81uL`G-t$g7^oP%X!lLT-^ekhcJoNhT)oDy1|PIv5H?&!)$$6> zZ8@O4vUHz#cJAS@Wafv`uH9R;&J7ZV{~yWh|6T_Jj8^_7$n2R6_FT8+=X>A)Ef0R3 z?hG`R{{UfDfX=ezeBg-el~-U4=v^-4Z&%EJGcN=2`~?km+$E#2QGlzdSQa0Ar}Xc@BXL{*}0BzYnI4FlK_?w1s$A+PZPr zY&i=NfAV~!f;WTjl>A>0XiX-De}Iyd(Jj|8y4e@%$^XozkfmH=qg&6EBOO{`|rrFXY1ue!M+PJv*!k^vEgHO99~2@-+tIPE{FPYz_DHdTz>rZ z=+njc<|jrUm7sQ4lGlZHi%b13i5tIhOFtjQ(*XJP0LnF+Yu=y_?95GGK5O>z&v-7e z$9d2nkI;vo16aKRdk`s$G;tlN#thByM8Yj-QT$>LPfy~gApt|txA_YGyT9>pK1C*8 zuE>V2U=f&91*1o)D(HjHfxbmAH&t)qggy#Ba9Bv`-dKsim-;NnEIn$X@&A>{9@Z8> zHFoM4i!=#y*!NptviG*juQ_IPB}jKzXg%2OcFpzy9q(|>x{ZYSFf7txXWd0Ix>q|~ zUoxe2>*3ELj2g|!8inbexQVz_Fq*ik!o}R^P zOL*O^_d51nd`9K(pjHsG2>6Czs*x{$D=n+Y;tb4Vd#Ao4bx83zj~G@%8DZ@E>`q1L z9$#=X{VyqcAmWm`MxVG0QNQ^`n7&~kD&l~UmXZzcCwU0{S9=yl3DaYfXInm9dDu&h zi92B*7;_q9Lcc0bM}Qc18)Novs!XVq(2;QN zcb8=%aE~{qsf8yR(daARVt09U?|b{&rzFEy5{hl$7782g`jmL~+A%BTvV_xDqh=Ew zoUO)sZya=}i1aGd#~LdbjsH_-vt;x3=o+GzA=;z3-Mz^@r4Zeu9y(pFg>1voXnNRI1rplmfEYTC2evEH>y31 z1$zalgOZ2k?5u=v)|jZ!#?`CJa2gm`wniq~f;Z1aY9ej`5l6qY!6G&GGxh+*PK>qV!C+^E29>6_e*)8g2E$ah*^+Y#P0E+QNA!kJc1wcgVGw)q}G^ z8Ofj23;KoMb6(?WGo6GqIwl$BRTuY}A{Pw{6Fy_c%n0u65v@MqHq;;m6vj$|C|$^$2a9Y<21> zhc0FZ?N>b%q8C28*{wdzAkSm4l5qtm;wR1srw>Y zEs5_^^h`E%t!KYGpVxw%<(T+%PL0?vUrex7PL!1kUCtVNiWeTBcy--QJ%Mw-bBQNf51wh4&-(xNX9?6 zITqNfZ>VO+S1 zC$j7(2O&aLro_XYDPjyH%G+|3TYu@Z{42`aaf{H7pGIP!kE-BPZo6tPEI%+7%w$O3 z*6KV^`YzWhZ6Kv111+4}u6}H=QerxrHoa`rbE;c*6#y{0`w%T!|I?$-;0??Hi$orl=V#h`~*3M?r@`aJ? zUp{k}lQCS4lXP%BxNQke*mXpgzBHk0THL_U;oppM^HMDh*d6i!W;MU)<7l&JP5elm zv&xmgz3h8fU%gA+^lQjj+`PBfQDYIodG)cC_!j@6@uhjzlc;VcPUXf4w9mFHvGiN! zkF)CmThHetT{AOIhnyEFjbv1rzo?en)gK)Cxn_9(KH~ckZkEi=A`AxwuUnBN7(wlp zZsjz5PsY$U${?hK^!z>aJwo^1*xj zB-*21la=3!Ot}>|B(GD>jd@a(Sn6M)_g94kQtmF>{Z=2N$DjEOfXFQW!*yj!_;6iX zs;B38)I2Wna>Gp06WHbh;7eD6wNaf+52v~JV3*RosLL1iFa*5DTV>#>rW_Nv!Qmm3 z;9P;5pfv)C5A2>{XZ10#6i<`6b>!lQnpd~K#`Jr?jdk)~)5K?l`0?NLRAcOW&Qde4 zKY3D?FR?ggH`N?m_Bcq3m%Dbz0O_5^xl<`IM32SNyvupbXDRrf3`jCR-y=AK8e$Mv zpPG39sDS?TA0P$YG(X9tPMwS6k(G4Irlv@IZ+jj7Cus~pTfG5TanPp`V5DgUPNSfE zB~op$E8$U|db{U>;H!|Y^ZAt0XsBxs!}5iWye~x-Bl;j4>LsW>1)I4N<=Gz~R(x4+ zN%jNpOS;+BX!oGZuDPiXuKEON>~!~qgxA+aVvZYfuE0kQ{A-7#N592yh`(QE^SwR> z%+&Q<3qsO9t47!+R6%o^@7(39rYHqCH6e5ANrnxRpf3p_6o&V)YTd?^Oz4Rc|`ETYNgc$hd^FNlm|@glW=!LRpr!`bXn)7Vfg?0 z&JXBQaMrs0s;IF6Z+d=09Ld4D_0!=uoHn8eV&_8r?J?Ec>3@JqBF4d7B`3s*>WZvD zLPgeJgXRCCYGU;Ke8uCsszIi2J>~k`(b=`d4#D=tmXfwl@?D&~G!Dd`V+FDq0Uz+^ z>$pe`ZDRfGhM;P8)12d7u1hG2l_E+Q7vB<4qO@AhDzM4BdOdn@SQLz$;+Of&YfjcP zNDd+-=TDT|T`a-68dlU|Lsrte#@()OO%cDCEw~h__Ld{iPPI(aWx<^%x(AYyL?eta zx{-%&j51>8S^ciJhb9v35aD67iEExLpJI9{%5^sGz-v>eAHp0olegoYjpJ6N^}FZJ zf}-A}9WC-mf#0e)EWRVN<6RRua*kCLhssPEnNRXD(g?tYUq0<+aoz|LA5y)MWxbM( zFM)%#0FhFkEa@625P(RCT|NKvn9Qlb_9=|#6$cr%b@x05rL^Q@(r{q0dvu${Iu0+6kKVbP zzsrc3uF;4Jo^LyasL^-dNRnL8547AC@kq!Yz6Fb6c*7}qA2i>ue%omRVB2^!b7K2Y zhqPPAHu{AH1QoTl)Smo$GX_Zi64Pg_nK>~;Lx+#dvV5j8H}}a_FRx|aRHy5QBxzOM zj5L3Do0HEvAWpxf_AFePojPc+GuBDoy9r>MwY0ZHr3Byx67AF%b(RtvXpFiok6Q2k zkhI;x@|;mL_y(rj-=w9+@dy?PayF4#03+Udg&J8>gH@;MMZ?jRcb*a+-qx^V%2pW$ zY~`e#%*>6hD0^nzvBOgXGqci)B+bYxzTw2eVxO4y7H13A|!k zLVpbe1MHRay=>D<-PxQ!KyTK)4Mgnb&c%iWm&7+}`=8)eRbbmt*SzN)sUG!pk%1(0 z@ba`JtMKrTb_Xx+eU$SQ@eJKHoGLCgv_!e}KTOi1U=d+2i~(oufJF z8qj_J%sl?Pt+;m|;8sxlZWi%swr4zB+5yiT(Q#CF2s)kyFQW&fP0SDJ?K|rb>{WA z6r_C_zkbA9^G_^F!KP3706OQhVoicBv9${pQsSf=jlNny(g zJGd&~sh0d&A1Y2WzsmHH-)J;ZO!8i^ShrZYdL(EH4UL4G-~n(h!%^O$*ka4B<&4M8 zW6$3vn`#FW;N5?Ir=C%Cl_S#ar@N%DH>~5s&*4;2Iy)rK*BWEn^3lfzQtVIW7|0{@ z&xf2TH1T~4uUs%GNy3BZ9V(Z34Zn+otV0!8Z6a67ySJR=i-WiSl_|ry~ zvhhvcwrIVb#1~I0&(s#A0OUG@+WxG}Y(zJiHt}^JUAzXwMMx9>18OA7m;+OuaYmQ9 zUNWof;>eB|KiLISWV!Hf^goP|>e)S(QR?e?Yv@m*iSrkh@SPgK(E4xz|E3XmYOAo9 zrTxR9-pC7z;zI+Gn#zjo)L6$W;F&e{?TtLpumU1K{w{6%_W$E+ItfM~2;6I=#OBCW z2SU%0OxgBnGJIvSrlMDBIapfWZg)`tz?Pqeh7{N+s*gd6z{OO)aWV!G;d6EKoFvpi zAj*C~pVBEKIeH={>;a@(gl&%V!3jO!#|Od>B<8x8{1lq?<26bJ$qc3ce$oEAfH+!n z!}e zY@N+Y9Fy05Fq7mjxGeGZMPu3*G}f`h#1sL)9+gk7LAD;UII!5}fhZUk!X8a|J4Q7V zcvW{eFWQpZ@B=x%@_yX&b0*K7&Z@+(=UsD^MsS(#u-$WQ+o&?f8lb}6{{Y?Jf)<*+ zMPHXq*MDWUjyk{Jg&4G-tEmwsp-6OX72YmJ{mHGBK&Qq|t72Zu@J(E4c-ryoL-xu| zsnCkDS!3$sk3T)S{PD<%L|v&oPX{yIv4mBX;lwtR()Kv>MK;R1+Z1hc6)g~;vH)>~ zD}T7cvpyK5B|d9nQ>#>5MF&M)F9ha6^B<;jr`$SAF$!{vkXCsaLzqNo(=VmZ%-Mg`>?#v=Os@)o6hD`<-)7 zLZ(a`lu$$3%5$emo?n)x6yyP#8X$llVq@$_iII(PCnejdmkr&A@!adsO;<~i9I~8! zRPo*5MjGjzZ`5i2}eOKO`%4Hs0k^7pn2RTik#8WH#s zU5dNul6l9QG7u%(>5p#&2oamOG$x{_Q*UJJr*gi$d>p~O$gcf0@dr^Q^Ha&D((1_@ z9;~P_W-0a;7&E(#1m>hXCsF=dsHxSy*magL&K&@gYeD01H;@yAfs$T5z=TEeJgOac z+qKE7O|j;oQd60F%4f`X8a&G+Hw|JEy#FB!XGn2n>9a8}Mj?yO4%VAf1Frt?z?ORI zOu_fl6wQ~TN6Brgwzph+^v~#-qa+M(DABAwCNWadD>1qqn(2h0lYYFy1m9Q-5;w-) z+OYVwMa=9|(RlO;hq$9ONc@b@Ey4D1KFabisP#q2uLeXzMJ8~hqC%|>3(E1Vx{)WU ze02%R@M|Udg-^-6cfDFC%zR%@B!;+gk=c4&8w^pHx(gwSsw3<@`T~mm-MZyuTbPc1 z+jY^Mc3vYwm1Q;l^Nr`di=C9@&V606YN)U(%m4CP#ujcCs9BI_3Urz#HrT~Ne zw8GM#+X}-=1FjoooQ1C|Zfips_!&-$Wgc9qU>%pgH}ZRfOSgO&qb0Z#c2cUXU5#vy zZTTMLA&{?$$hr4f`V+iZDc0g~){p$~o5uV|m!Z_~sr0fQh*6bHWPN6t`a6qP+KLaT zJp5-&OTPiJJTxT%kFYPHy8)aS?Rwo1$ko&QZO`s0Uw=jYPdsJjTMsXdI>QrN{O)kz zOLO_Y#uSAf22lHnRA(D~)|iyyf~dK=n>jl=jYtV-(1%4Nil~F^LM>$ZXi@cikuox9 z$F6J>`~w*{q}>Kv2BR(T@H}>?>ABrkM~zyOhdaBg>crVWO1%Me}XTj zqNu~x)KnE>tXd*bC>kA_2#}J|{8M$;M)HOLyVbM~OlrrqXRF{$4+N=rYsnz0RC)j4 zV|_5{vC;xVr63E@=h0fI)1-`$m-d3N=D<-9zaixF>O@R7wVa@Vp7JgsE#@PEZp2)m~CXT5y-a&!vpt4WJ4&?~k(K2UC z%R^yt-Oxgqr>Kh@($~a*tAz6o5kb0Zuj}W#YRfJP#FjftcvluVTZpuRjU>9~78Gw6 z4SIYX*%mTfKyQj_^oV{115MfYQPI(;`Xy4Y)#uE3Cu*GjT8rZbzymyjUXRXyLNFWg z;?13z1r~GSD@9Qu-0c0-n_*;sn|kZSRa5bV$sDsg#9tzFEr3usri}5oa@h_0>xxw0y=z&Nt&u^dp96WItRVRS5HWvOOIe=&KLkG}1v zLAtc7!g#rz13UbM>NVV431x*Od-qk+ud$M0n)y;5oW>E={M7b_?U#G0zkbjAc*F43 zP_Kd-NeY^Ea)msP=a(U2jMle;ucTp^km<0KKVuB15GASw{?+eQgDd4N1X7L#SxO$i zDy=xQvlieO`}Sz*Y*^Z*q$p?v+{Lx279!|zvE>|$Ys6nBD&_BVXgyh zYoXezH%?!bCEl(7lEzjPAv6kRDzB6(wdZzVeg60%ubGQT=8uGgHrk#aSZb8xmjK2E zzh}QR!gb%nM&W&Mh=x7_QA_p9Q7($&38oAZ)bMSOAtCxm4nJkL1-H>=rGc%Xc`WBB z0GZ~NZPij!3-(RDtbASBJ4DE=eCtB{hV?r8%5#ODQGZu^G&jU89vX)jI?? zRx(S-*XGDeMm`^Q9&*1S!zQmRh<2FV-*-6W?ReU>7?W~)H-QK9R@>}zZrTeovU5YMJazyRCK-3R^4>|Q#rT0 z6Dwn6)z$P-f$&!QsYy)L^JjrPhzB9}IxBlY@nFKXX4=$lW6m*{lEXuI;>F6t2Zm}4 zO5ddthx_Zgod}6CaQY|@ea>S6I#Jx0iYv}>KSghU&%!z6NRe%yjZ=qi!_p%#DYxQ= zx2@$Z#{N6&;;oMx%R5#OF^#yz+c%G=uEaId!$Qbz?;llsfWK)j!{4eP(r$ z2N`3mfDA|!BR+}=zG5$7z+-hOIFzD#(gKg9Syn)r*P8LGnq{vfe_i`DuVadRv?{w_ zEL5G}omdb)F*Lqp07;!y=NGFN@Maf`$HnjV+U@ccTB4A8L>eQ*6{SJB%>!O#VLS~j z@Xl5BwzubY6$&Q&#!+K->~Pt~5On{pc$ooTWGcA)ZV@(fB`RH2?J4)v)NcEcV(vos zxMSk44&tBwKW^J^P>QoYHEEO+4(51#In6E(Rgj+lI0LROTh>AGt1L-(FrCB^`8 zL3D}Ga{NjIMa&7yb=*4W5 z@*{Lsj%)E&JB^+L@7L1rM79=&6zaGsxUrv$cMaG7eNphQTTT7X>g`>Wn#``|ar1z^ z@JJH98ebBvBQO8t93zD}FkraV^p6!DrR||=V?`*^0CfQ3*|5%OxDcY{}c zx!`LI2onPwg3SNO`T2uLg>xg%Hf1m54z5-7Mc~Cw=6}oP>VIh|wG(2}0vOjVKLZw9 z;3rieIIaBSiUq?LAtlHhZl62O1GC|!8~hj*v#@pysv zUVi-;_kktc^I^3-ccowsEACOn+B?FyFrNtcD6?u^HHLAj7|gbG77)Jg?r!~4eMQQ) zE+*)94o_tBO@Sak^5NOGht@^`?n)y z)iEDM5t9F&rI&vG(^0t+t(2?wPf%{n70*f_R)u}S>}G20B(8E+)we;r?ug3&+x4I# zz8fSv*?0N){a4T%{Tnj$&~%K{n*wVQt(TM=1+I)`d`?O+dNWVBhB~MoO}Ggf3AzRCbzGYbFG`XCxpfJ?U*|R^*aKsSm)LkoNu5XcbwPbn(AYB?O$}PHjRN#yh%+jV%toIl zuZM9e&yzAkgZ~lbnp5QaASGHBG5l>gT9{61gb-I8AvWsWsKcT-FKoBYZrw}y$q4<| zkNH7Ly<)x0Dv`(XfQp-*Yp#^A6fC)J39n6~QaZSm!54=1 z`y%KmxR)k!H=KSQwAMcS+0V9y94WWn1H|g4L{= z>MGuFLlvYhj?<2=nSnv5#g~*+I;!ubT>Wh1#sn#^9-86gCXg(dC8SN(VzWyZY}1nW z5|9o};|9t{Isz=g^Q-I8xbX^N+!}8)e%(P{365VWJFd_}NDR_x+#4e7E#fqjt*q$; z6Z?B3YVAkxNEBNr*8npCd`Pyl=ta`on)*@A`8zF%;#xawk_PvF7K#kt?Q;+-;|dI? zgJ;F%&gjEDT4t&FJnfyyvqgOwGG|%I_T$QJryv;4!#7nE`}=M~q6#+?PSa!Cm|mz8 z&hU8C3};f`U20kNC%CnhSmpv-{g6LE$$?%e4{UxJZfo(su*lN3yxZeIgG=uOh=Ore z;|P)1*VZ+?=fpopJ}^%1sOZ``{03lWz3r>wBl^{j7+pgIpw2eUwmO2TesXo#5Nzt@ zu&o>6#C--b3kE;vGG?vEc(izzF0%3)8dmTy)+fg}<;ZC&w}MDcaF)DSj4wUE>|w6_`aVoN86C))fVE;itwvQ@w6Qlfpuj~=Dy)y{K+>g z!@3177aDQpZnF2)if3)=-N=S)ryNZy#ezDupT0dlWL=eva^PqZ=;?XzeyhGbUTA>U zt>L#$U{&AdM*@obbRy(;wgSTu&ky5Cjy15}Rw!bP){B6glAT1PiS?mdTM_A*-yO-1 zx1CCRG^Q$AR`5m}l~)FMG&=SJt;fIw1gwtp{pwzp{q7_W#eC(O{C~stk-8=WsUfV^4)%K zPi{|M8^fO>%W0mFki|6&%&Wqawk3Q!KICnC=(_096A0fmi+_z_ZmOk~s_J2=8T^IM zp$p5#Vgt`*f0hWlswP>qt+J(pe2$*rj>%ABJvurA*fQP4(l+}0yHVHsZ~AGk^0`^& z#X%WX469<05j?O*AeodsAh^6qEoduTSbFqs?Bpk8%~**HKe~I4WRHAs%^p7~18vNR zVai(pg4*7WzxuV%sy;TgJ`sG^!s21@UDo^lVE+I%Wvk*jTRPnkHwfJsVQ15^-M#}& z^;1@M)`a#)tm~S5j?d*7i8ni1NcHs9%6Z{l+$}ZCYVaz1Vb$JUctz*%iy~Gl)@9t! ziFfe3x#;crP@duYcKaw8JaRil%v3J?Gj32`5GNxTC4W}=fiZFGY>KLKho^+wU+nHL?ez8{sHr0$3CQ?rN92_oEc~P-<=bIo5cY!XWXj6W?oJgG;LK zX0AUMdu3F8mGt%K36nxJl~MH?On#3rq(S@Kj=U#BuV+RqZV#TnwK;mp*jPpK!BU+5 zE2a-$I>dQ;n6=(TI&6#UWoOotdd#ad*9Ao)%kFMQ8Cf=^L1*4cp2PqE2ajLz10O(& zU^b~&uZG^2r1^zFG&v>rdnGAq#AT9OAm9XB4+(ilbKDu3nd7=Lx6_8|cS0%w$=i6R zAlwX`sUUHP*#V$#*4u^5N?pVDPW%CBfv(X zblcCyPD=ZPcl~|dTTZpC(7{J}HlzA1kBB_R8tpj~i^TB3uE^&1YOY#wdNjIv92xva^+a&}dd>TO$FqQlo?i7F0Ue$BtID(v%^`(*rKE zuP^`1@7UjEghop-V>mZEZz#uvXc{F;ki40mjsC4;)!l`-w6${;#q=Jl(QZJa1lkSc?Fqrh z9PIs6R~y#H_rDCRi0iS8W&Hu7oi{WLEFovS^vj&Z?_i0k!U zqhIG*!gk*eQm<+>gGc}=5OVGAy@LHHp*Bx-=Bj3$w-@V3Bk-!zFU(San)xYXI87L%H2Mye(+u{nNF0W%g#Hseq?mBb^>v zdOM4})9$Lu_Z800)4q-?^xrit&<0A_8S5Wt!P#YK>lEKtfUFiuE{-v*8q789AnUij79+CS1Y-rd6iOr9q!)u2pTcj zXUl8OCY;t;?DdXJ@4*^WSqOzAaUwHygu#biX3W8Zeq6>Hvljhg{OHE#VCRDQm*qQ} zBL%!eGVc%8bgB)L!o$U*&9fU~;i@`Qe5d;={x9D))lN7b(ECm6y1=S>7t02fId-gZ zcmDuU>uqPT15)^~H`Cnvo7o*ycl;$JjqZ(Y2L*CVcg8;;U!a++lG-p8%wWJVly8Y_ zW-!{-SFc#4o}dh@Q1;swQOUZeqgQ)_?LHMHGPp6E@9FXA?o0l(Nk-V~0 zn~U7ctm^{#KVA8KI)dAWQJLDAYj^4Qqoyjbmx;?UU~8JU$a~5q&&opob$#{80~SfI zJGIx3>1Cai7n1)j`2nfxg076HWiKh z96a0HSQxaZxjyi5-w2Xe3c8REt^5N-_+bA`WV*!y?UCp1cowds!ESMM*882AD>~!Z znq^bAT-+XIRyaxB{=w9aM=sRRj60=hOnK6hVEP6pOJU<^lqV>grz5udw}( ze}EJ(b!Yg>?V2ZX_2>PwbmE3XD(gkte#b9cXy8*i$WntVcIezM!1|FUHly5Bx~iTJ zQ>KJ_wIiy(01Bot_UYR9z%21D; zu%g!otzI|5#>U3HcwevIZs@0wn){XLH?}D&qDWc2Iz-?;f|S%mH;3hUDa!q76t}Mb zx)=~fSd>!p^QgZ+;V?5SP45H82Xn({%Vrl0ekU^pwyJ%x#{k0%Et@r{*|LWd{Iy#*iG)8q>_*Zvmps>Z$78?ZoMwkfF|TNl zd8IFh!r8;XflQUx5XR+3^l42h|u0dRwr&)f#o#+2O|NaocQG(}V00PN*f;LFEH=R-7Tm6*!o~Z$- zR!5v$W@3^JpnJ0P!fNzd*o2a|<=O!)R1>R) ziuq{rsf9+k4-yt}`Yv59VQq3nbj9hQ*)4*7`I0)Jk&=cMk4SeIj#ycu$HukYIG&NT zlvrnhx%#d%y#b80=8L4tP(WQW@e6+pgJo8kK3NdBSnZIoKt#f`!|F5lknT;<&MX?V zw~!lR6nL?Uat3Sfwv=#%4OU2l+|uLjUOlLtl#(&HfO&4-LW&N_$99M}P^rkch^jAfmt zr@bihVQse7_q9l)9KWuQvpPu7ym;%aAZdgOhB3*@WL3R^BgBu7Z8(G{xiE>|+N0Sh zt!cjUfN&k9fJ-ajzyovWr78DB-Gr^!Y+wT+wzi9OYMvc$jEw;mon(Id*7RcqwE@SC z_J~?O$@fID)Jet?^*2R~QQ9wz&ch8_pMfi^HD_~Y)jZ6P)-v>e z-fMfi$^QwKs;25!#2ZoX2l)kAzw`h_yNgoE65)X#?#i{VUfRoI+j_HY6b*dephI(nD#7K5 zv#bi~Jhzoa;c&o!w)HIg4WcPUGUD_7_fnq_ zB1(8`$DefEvLhDxf4OarpNvp})7(GzT7nJKRIGD()m15Va9M*~rymDNK*LjHC~C0@ ztFywHV7-b7g@(X&gPCnIqZb_`ehvxcd0a`;cb&vH3w+j6b#A24*?Y$;`_66?qrws8 zJWwWL2_$v9QAzlicC?Uut%6VmOGrhFpCzFtaU_e?kh+b6lD=FqTj4sS zQZ&02M`SQ#tM3;RjeIRt1%)eh<}4swXf-I0jUgD-Zl9xB-Xhe#=Nkjsl#NM;)29Vg zN6>{t=|PW*y$-y0j`zJTJ8!t_VfE8eiltyt5i5~#Tk5f4^bu+Aa3}O3lk{zn?=>`B zXu+T-`#!|oUQl&h>ZNyiO?tjp@OcN-w2z%bRe!*c(2K{VtYOxIc!{sgv+jU9Z#b08 zx0!gRuf$8FL~N`|)Q@cR%>=g5BbjBtx-y$j=gd2IvH?-Sh$rai_s>_^#)h!Sl{A#BkKS`bPA+I z`Qx~LexjVE(rmOqmotSO$+M29!Qyx7NUB?(K;L5cTIaBk)26q|QzwkJ#3I)`?)1{U z-b886$^AJ_qa{^$etW(et~-nci#P`RtTFLoQp4zWHAI^!D}Y14Il1zBAHyTn@Yul= z$!^ixjNTMDn-jVaFV{22Dfi1m((NR#sE&Hxmhx7Fjy`XJvxFgxCo{I|k@dJsaGPF7 z!l>&fL#q&&WN$OO4V}~*oAcD&(hloNlTzu7DuUqKU(I-ErJi-GB*`&!QSM2!v>9m_ zI#PM%X-!^ntVj!VbP8^ra}sxYVCFw(R>(8(F6xTW(1OV2aD_`(ptCizjxvvJ7P&Bt zSxG;!j%w63hlJ{A6-Y%vn+3uJP3hc@dYy2Dw#IF@hN_{@Tm3aU$+p_y*QBcT)8+|6 z2GTE7zocqu+vmQTBjmQ;PkzZC=ZufbFd^aDy04|ii@Y?LIx!*OSwE?_|EfPRj$fC? zG0Qtjp>F_HY24%HyKD;9 zxWm>^aMJQ(T%n&Nz;Vr#?ewufKtN0y)e4u`ItF=wWyMB}I7LM1$9w0BNQJ-qAd=LQ<$SpJ6f^0z%p#Ey-)BoUtMIYqk? zI&eFOGIt*SwP*b$T}IB2SxTQWguaQ61g%*^4QjFCd4cT_V^UU*C?yl4rip@TI*)-+ zy?eRI!NLZCr?XOtH+py;R8QtkolYbJzyiSgDFI?2nHnf(2G`1L^;+aM4tR3jfZXkmH(+iYtq zXKd$#VGyh9#dKTx5($=14#SiK-SX&z8eHe8k0WiG#{~{&T5om-UNevzO*(n827~ak zMvXI;a5LTh2Yc@w)>PQ8djgdlrHe`v0R`zLB2ptDy%#}x6A+LZkzOOx zyR-n(YovyRCMA$i1B5v1J9C}Azdd`OJ#*%tb7rpnUjkRkTIr}L3cigHA6Kj<^jCPNdUkXR-Y5>&@XsCSAW`wiilXBlERqQO(t8}P{XQntuQ}ku% zU8B1etEq=U6Lot+@JEUru?x78@MAF$zQO=|0%OxC-8#HwOW&}j)N^mIkWTgJPU}A2 z;G7Ri@{Zt?#~%vu*oM6oW_4lZrgx>gYwmGz;5Qf|Miw~tl~&+eyNcl@^teiN%#3?M zo`1P9e=hFQpy%Z9m#Z+UJ45*SV}W~3E|k1XBU&L?exSJaq-1AV9J`!ZR+wH9P}tU} zzCEqW6sq7n#}V42Y_2RJ*$48oWdf!LhBm4da5zyB2aB?A$?lC!Pr91AF7*vU)s}D6 z%x|rJ=5skF?#E%*_U2!JYX83lq5O}zZ2uXv?SIApM*bN`U7<}nVwdfWx|2!3w-CQ#&27o>`Wwz;inm2kO9HG|NigiT;XN->{sDB6GuDk z#xJ3FgcNSEITrrp!>Ezvfb>YeO4n2d0*ZzxSE(Hx<8HIPOrO%BwK-M4;t{GPtxqyf zE5EkdeztZGw%*Yld-OoLN9=<86bCTizYh`nF9~2=)nCv};0-du-`TVw2>jK5yxleL zXeVgvWG&0MQ(kACzVe>3--=or7RZkqi>Pb%Qi62l@k~nnZio>RJ8E8ukfmc09RLmT z(D?D`AChKY-00j5LG}Su@_+fPI#)#&G_^|4Y|G_rM47pRvCa?VtE|zd4Vld-exdrL zUyGy!!O6-V>}FpEOO|1w70aw`%S^s3n0}i23O)m#x~PiU2XL=Pvnx^)Tj~0Xb&qm@2>Mk%u8z-b;m*Ub|9)-?b^-qwP;3if zAwYDeeiC^*XS_Hi7JAN$MvICIQ>^BCY{E*%Lg zbuddyObr3>_ZbM86bu+m28+`?yhpN8}<>d)`_y?l=0n@DV zAAKuw#%s`jJr555dVRxUL0i(ASt7Ek)Jb@pbr0!ZkeK9o5M<)0ligRr=pQ}|F{^fj zRK)`4n;=aK$9W6cI(0$cM%Pu#Fh@V!TSl!|aB3f)H|x`vpjvC)y2VSY0FWPhI&8STq6YA_L(zik8Z^VXCXgqJ5g6<3*|?Q15(0 z+;pK|EO#B2!JCvVhWIq6%&OrN%QHa~e8ZM|o+l(r84bb}jZ+Y}Nie z+oLV|Xd#=S*P0*|g^JB~**<17F*v&(y-KEAi5X*4P-DQov z`?ngS{(`=n_hq5R2{7J^2ipcg*+ZNRzm^(Z8^kR}K>qzn%EFHS$nuGn>5n@er@jJ+F$3N($sVC)0U5fp-oAc&JfJ?B6e*zoC5p zEHy^s$*8!Y=Td+sJ`;rmOcA_OZH8Pm_N5kTC6Cq_cez?l9J0T2bnhU?AK)Fm?n;}>sVTzqY5x>r=#n2{9|O#TRpV1z>x05GJL3g3F*Zje%q7jXS!U9VGn~J1X}}MSfA&3C~8ib1JABGeYMQB!q>nk{Y1KkZL+w-)2)NBY8 zS2k(~*0=!i7ijTnr3jmstJ>32%X+JI%BL>}HD9iGp7wkebX+Wh(FEA4w+BV3He$%-;bqp=C02rgYfK9R{T z!)L*l+1q92W$YeMeN5L9Fygx*(`WIPW@dokiAxX1F`$|A(FxSYOvD*$rIG4<2nX0c z)v4^2XBwezCAatXR-Ye021EkLMr+UP;Op8<5g#~w z!c%mg=xdZdf%%$)GfZkUF{>%_7%R=SIiXCZZ#?;b66O7IH8_UVHMYyyin1*(X&eFL z=fOG6#XeI4i2t3N51E!_-Q3|OU1ay8S65()ukri<-;_4=*p+Gr%+waLDh900ZaQQ& zo-nP$t4f@_9XX3Lgg>DMp~IpVIVk#XPY&%HXyXBg9-6^Gemd#;MEd) z@8~-%y&I3qA{9QvxF4|oQ5TAxB?|iX_s(lh9)|$czUnX6p*3J910C9-(_r;C4>A2r zfDB^-OxNk)%-##84b;mwhj86Rh}Mf3&7XHGS!R8L;V*q!1f9S%heayvbz!epD(X*)rX^z@yu32c&`G- zn+q2GmFD=!L$g*dH9Dr;aOWqjZakY|pi>wCY*PwHd*vWGly5_pCC2$DE9=@H+aBc4 z9%JOY*WSg@?Zujh{a~(KI?GJ)9yLN<~`oz zu~jp~MZxu`mwg%kG$4Rm_5SPD##HSNoKi%ckNk7`hRqmJeN(690}oHRaU3G<EOTHypi9m!{)2`nhp+Ef)d}EQ{{T`}v7mgX^oFd{ zUgu|G_qS!NMLIsvcg+{+a{a-jP|_#LZ?r|m^6v+CBJ=>6anQilWe8a8puTyRo=&Q)4&9pb1;rp0Q<&;DVc3teyU?A1B}$YhxO-1*oU=LegBOkLl4t}*MC9jEe&6N6v6TXJ2Y zNUM1D!|s(gpHs5}7lw>?97U9m+8HdLRW3cu6aL6L$Y$e}rn9|eR~NILrbSYUm;>F_3AGCs$Cdgj^T=bXA$ zJ-rKWVDxqhw(n)MHbQq?TY$V_y@~8CrVhRz+Z&7c<6tIBTDnUphjz=A)*ATkS!d+( z?`pN`HYIfDhY$+oTfSd-NpD1 z#TjKf%`+h?_5D=d!q{D;a*|5!`xu>qNm$$kesG(K~cQG8(^V)Y*fD)&_ym&=7L74}DMZWtA5Cn!!+#k@I64 z@3^|^2~o^X>*$|Kl*dKb(_R>huUhrOW1MdNxBAty3_-nVyUX9M&L%XRT6_ffCRmu8{wJSo;wg=ULI_J<>2fpn-^9XR+F>6%XE z_x?doikjQ$Kfmgf3QUfVf(Fnu@l%RA9!c87QvvE};?O4uVZ{i-W6@^Z5yYj- z?8_3B)DxKC$N^E`qt<=1q!d;d%t6ia1fk6COKTp$snI7-U0wD5Q@<`!0bD-(JOcJxnkpMWMrN7;#K`xWqbk>7V3Qpn1#pVUjX!sdjq#$%YAMRZ zE}DgfmOX?DzcVgL;n`Ua%+`eaRaHTLX04a>jwLP&Wp!&3dITuAe-$7R-7-5GE#2IfwnD!iMQ z8i_4-EwxMh%sExdJ>_=!jTd{0))@mss+6a!0z`3U&pdwD7>m2 z4YtxxGnmnn+7Z7+KU>wZ-WfwammsVreYvqIy|is5JX7yFf8F)!lGTu4ggw@K?kEsF zZ*!T)x4box^T0yxEc68lkhc4|GMK$2Hv35}HWFAPQwlWu*h()Eb5BBkT!zA_B>Yky zmm|8(#s{JwT@-DGpWvh|%!RGTHK?AwLWe5bVH;8l zoTi+FDt9`6-y{EAyP)!Wc@rZ`mGH@0Z?IKxQ}Kq!Y>g12(;j?QhCJcjhF&~tAL_}I znMLFCvAo?cw{P~NMxJzg-wP_v@0E??lc=D~C=9<2TFPx(WtAd1@Cj`L>|T`p33V#8 zmleCd#U#*4<6CURW$P^|Z|0(hRP`_t3bH%Yy8-$b>3GJwc~q*2H-+CV@o_9Lwlph^Ue zQ}mh5=3XXMB7ghk0l7JR;lm36NL(n#J|10WJVTTL2nH=GQJqXErUnf4`$py%nVp55A}Ge`wYr*MT6oU6>$!1AR;MCo4S_4BNlgOnuf8P7z6wEUJ9& z9aJ>4wje-7Q=stZD{bN*SX*7R7;OTP)X5*7U1POy`wp5=coBS$d zQ=(pSKmB`>D9^*aiq>s-EAS3R=M#99Rns=oO=$F;gE{jHv5=E5Q+|NU5vqKnRd#n1 zWDoEi!;N4ON0*XDIP!?R+KLcsM~`RBtNIs`7D2fRo#~yDLF7ScXevur6WrAg1rQ10 zJh542R^;z92?SIV9I zoW(N{mOp;*F`nZA1sNAG7#~jG4nw|Zj-B1aq}Ti!WP*3+IbC_&G4*Th^~%wRC$I(X z;0DA_Rrn9(cpD55d>UeX&^g%#6$-|u=(RT2B;dFGjXMTu6IQV_$%5S_t=b5{2mp*e z#;kkF?!%(&yGRHylZ&E-q0rO79E2a*&Lo&G&@ltZNAVKl&=AdB??J^K)lL)pPJG*@ z-VvWyH3pBMrAMzHCSIfHw_?QtLpipN`=--us=0>q*ZHEQ$>Cwni9-6&(0gR-QTh+7 z-P;vTA`#UD)zv@t(`~YDs01@5`FWv*fStmow!BBp42z!=m1^Y~7mJvh0MGF7f0#G< z|M`HfEC1VM4gcTs;{QkA^?!mR-&*A0pWZ5*?AQWW_4y#}le5g9%QsnXtNt8hde-^$ zFK8uc-ZJ+zKG0UAuTXLz-P9=MK`$K+q#l6!67n;t>?>{9Ul0oHTSAPeviS=F@dI9Z z2-i7$Q@b@~_F&mu`^zX7>v%!g(S1LrOTe~l^E+fLt@H96jD_{M;_uo)A;rPdT)dH! z^Fk)guA+?Bn-AaaDu*9@rOmzztCmg*{|6-9QXXM|58z5zaU_V7Um_CuS~{Z_mXt~Z z3sauY9>{$zkA7;KxJv?72wVV+5$M}#FE=;{;|Zd^W4JuiP=E4ufE>>+6eZZ{uk3lO%E)r?!NgI zdDk*5l$0g;;8y#+CFS0J0w-nJ$80hQC21co!x$Yegk3N6uDK!+cf!Bvd6Ql zA)E>%TfyDpHmc^61>HTZ*xIRHcml(R7~rmkJ0Y@QQM=W)kXDm%QnBHQYi}J64=eSX z$>>L^#cD^U&Iv6i+tgBL{(0tV{+CI+BUMPTNNZrW$mn^$y(}=5p+6}V<6?iIGs$R2 zkE7IF#N1cxH?%{UtgMa%ilJINnSCpsjfx#|{PEVv_%EQ*+Y1GG>Cw*PGj@r$vBdULyu+q3;MB;>L(eK*usv^kmU6%5#j<(L zQbBLBO@(g8KtE;neC9ce7>7adx%QbkRl|eAGBT8e z{HOc#^;5*gzaYU=3D*)?J-q9oM)FHb!ML{Dj;Jrf8$WPHF=&T9E|Y}|p2e#XsrE=y zR7|MzDg>lDzleoo7aAB%E>u2o`|vmTwW?zJ{1Qfn8#8V$>SBZ^ zn|-{uhjq1764d_l`ViA1K?R>o+tbc#6+kVjrKnwIrd85Y-u|23%zS`RtAU>D`Nyjy z!(T``_C0!l`kiM7<4l`~jeU?ST7?D)p_NphJvH+#7cL5S!3-Tfx&QDT%#h4^z7m?9`x>szRbZ3t=? zd{w2nDrAM-c*7WDjq`YpU&7UzOXviUN)zXo0>lA(QgR&p!mkfv43t+W@X`NI7PS>r<%gW;*x7f?!qN_d2rn#Cs)L^ z8=2Oz`bUR$H6x-DsD44|fbD;ZlB_ABq_Y*yR_Hq;y1|y zT;dP(GJHtAm*vszk9W{ON&=J)-_v(a-Ow%;t*B<}Mdq)YPbTD8qU9ao!eJp?R6^D9 zD726JhJ?EucKhyyVn@Z=QAh|uIwB+>(?@Tk9_+Tq=F?AoeF~k?4Zhi4{Ow&Jf1%0N z=|rX5*$*wsPtDP;T?TD7ZU%@C7T~Vt^wrgpu%Z`_2hl>ZtUKZEkxlYh@bgXW_Qp0K zW_NOLSuLGCnxineC8t7&MwRT!wXN9MoaX!Lt_7XTd&7%Cb822%K?v&@1w?2gEG$3VzCk_$DTzw0v;yz~4==e}ayJMn=xi-BW)e#-ei(lgVO7K@`8 z;o9fx%NjQ6b~b~6PFtGE<8JWI+)#aoQR6mY2I>87eg9DOl+Zco$8`b0UG$6xdSe?C z*v1Uxtupe}igm8VswfqiGdJ>UWY$%mQ1E}~e}^+v6vFL19hc&cGBcdb2nvD}nrzQo zHL`tk`HGF<25l+>VtTAg$mT0l=f?Ktsj8`cwm-k2=Ad z+N{9n@U8~auWP5*r@ysMNk{MguKkv8WZaO!L%yqF_AiW*huF@Z5OfCd^XSRu$`P17 zA0JeW_t9>^E+M1ubg=bxKuPP{>?YtB(L5G8E%ec;3BO5M`8-1?iKI&e1$-4S>z1Fl z`3IfU@W+|qUy~qBX9&EQGj3{5lv7)QT^`xQN6tFP%q+Oq4oYc z#$;yl+6Pzz8xOEtIBWlN_>?8gK`UnJ8(>KoU!eTkbOvaCPS-1Cx8+B$H> zHMAer!h3^-C;6ttVC=?AWY2NsZQuLWuC!)8VX}7#z0$UJMT~|xdMOgHSk%`#LZwm&H3u}2>^V5j1CW?FtGScxp2hu5m>BR%F zk4Kyu<}EYQ7a~HMonce@O*3uc{GY@%i6(hdo+W_Pq&$`Vb$B9#3eECU3U6b-xwFh4 zMjudG)sbl3AW?Ok&Xk2u3oKN%d=GLT`6O@m!)u=nPii#FH_d}(&UZekI0j&N1l104 zF4MeQG7Dw#;=CIkw?VI8A6n-81=T;azB#dQ#AYqm5d4{`suwp4;_I@JRgOblGZ& zbik|2Y=UI*;3`mYi3nFny867VB7Hn|UtPH3#1%=zokl!{t;eVM(2>YxWnf4O8^UEW zi|}T|tIaq@t~4ViwNC%Gp=aei?W31Km|M(Qv87Oy=d z^Md~c{Y1vR$4|kH9Nh};FW`NC%C=a1svn~Ft%3K}+%GqH|L*lojjH!-uKmuE-RVTO zsWx#GBtb!a^0*gKjcT6>o^lJ3cpq(RV)IJPB2=+tdm;Vjp8v%QiV~!+t19lYGe()$ zNwk9$Tj50i8$epCrC2>fTzvq4xQe_&{)&9)X8R*hRx4U;Zh1;8<7EIX(_fI2$pT(i zg-O-pGH3h@*OtvJQYG+emBMYTSYZO2#|i1qEw7*?$NYcv8%HbNS-A-qkKtf{j{jNB>;*hFz>eLe*jK zGwKD+U*CDhNk8LNu&`H+v&xM4q^CHQ6XgoDlKLK%UuW%P$oD!{OT71jz=|H(jeuDT zl^b=f3PFOs!4kN)=N9KDW%9rK?2D+1lWnr!W-tVwsN9pqUZHp+@i@mQ4|<`#70lg& zcW;pmFRYttWB2lK<6zkC@NCTUrgBXkd{LE?6VcB@eydWGDDh{$F{;i|wvUS;!zA@_ zTh^vdQ|)wLKK-EJmvP-2VjIOo_hOkYo(H22q|?Eofwa@ge1Q)5jB4_k!b#883Ul`s znw0t6wL?XEls@Rsl?d|8xoH{@Sq`lP{sZbRFdg8Ny8VkYXj+vUOUx{`T8d^m=c?bm z=(vQl7&)xnHf4Qs!#`^hCx~IPUUk-km%;Elds4JFEzOZtX?caWn`)Y3rQ;Pn>O>2f zZd`LXryuB7)i#F^ z1|0&6Y5>W-MeIWrba$C^a_{wIexwZOMw%)2()X&h-z&g!(ssE2l@RMs5kpaHgTMw?avEauM`QP`v&WDJ80^sAQN z3XCCW;AqKkIDP6N5wt1HuIPck2LrmV?0-QwAS`B9B`!ch#@G+d?3!b)uZgOA)c(j` zOReBLfww0Ju0Ag5MBe&om0D4kd_6JDeYzi=oNx->{G}vS1GLTsL6H#|~*_W9Gssn~}w<5g5sjhf6AhJ1|!x9o)! zKf%0Tzj{k^;gQ%bSD6&9EdB#4)=RDOYWme>G)1H-lh3W5d+a|?l-x!o!MX%h`~*rw ztP62qzQb%*)z{bBM_yDpB%~nmNfs3Ra!jG-4EOZ%x0S+QkQtfM*4%EOG1xq$OC`iM zi7I%CO902z7+chAu1y7&kg9i2Xp$fuP{AY(;0ActvMw2b4uFbNy0@}8?z1t=`!3!& zYa^{IDcZe{Sob4<(j$p-WT-MwjEvGH+#(X||AIawfHT4MQQbu*6!U;HtC0M}S|mcc#x&0+7Ge$8<4Clj*UM6G)&xp}!4 z^TUo#8QvKq4H-jvTTn4$odBTQ__7A?SM2*st4cSs6g*O*cD>fGTQk`-j=I$w1%J?c zykguwxApxGzaUepoy4ONe7l2vD`;b!$fAObgYN{TWH(>Jt0H z)^6Xp*Uln)JVp2msuC4y96}Je37x3p#?V>h$2MNSRiT|$^Lsq8+7K<6#iArgUwZN9 zYc98`vsr$B=zfCB>|MkI0`wH^lBSTeP{dm1u6qC?#37 zcWS6j7N|_kp&zwmX5w`(^?93b z-6Z)@oXfhimh;zVZU?p>xMk$f^?Mym_{kyd=IK4f+c4GQg}Z+H{8NG^Z}js&*b7D6 zvXv9er)USc9~oEUAuBqky%veLC*qhz8acRREU5Qe>lix_Itu-u2<)P+r7cGV z1XXAbH-vRhIx^$$uV>x(>d1Iw?E{?JdJucq_tH06QIW_x%K-LTZZR-)O6I0VNXk-vz3yBDX2t8^nj|xMK;@ zI4^jDPemWf(#cX_d_YtzRrgC5Ws>r1KN*Z=lic7c$2f(onkSKb;;8+Nv+IRXzh!$Y zLOXApd-tx9dZgh(gVrf+I!S7K6z`pMB+5QPOsZje1smC}ob{cmz>h0a;qh&uCapG6 znNO5UIj`+P<^1nf7yqNrmEhSLcTFsi8)iCIs*+~U3D1<59$HnfArJOQd@uQkqn@dT zHat+(jdQ3ke_Ow(esWHC(D63Cg`~5I$%lghJrX@WTx@MspaRY~@|cR98r&lf&j<=$ z5D|gBr==*AVGIDzy46Ov(*~d9{6_Y1{IhS^o^n12mxf~wM+YRZzle1F%0`{HQ<0|Tn#Z|Q zKVuTuYY$=rq5l9ND?sVS8Pe}Fn=e) zJA=#UrZ%FDwGzTMh95cG_rCvwNO?qFRk5VYYc$WMcTeGbkZWi_b4vGx`}Ro2XVxoqK_RMk=4;mmM1b>REN`ln!K&ivpvQz03xdq|2_bJxH(%Bd@~SI zldZwV676C$S?}(8iO0V-1~^R$uS#pV5T!pW`|lJ#hyJ}J^)F^?>L_Vj0q0fVAZ zUp#I&WW6vyaxVdy+{$NjYh8LL1h3v{A9NMIde5)QX{=0x!HcdifYfH3P(mcPbPa7& zamI3qyxE7h%;t@6GdnvAMq>Cbo}RL9HC?%~y1}{@*q>~rhGXwXgwPS5o`1xN6jeI# z)YT^KnRj=3X&kFuI_Z_iwVwj(5VZpi*MfNjp>w4(bLiCwVCHiRL1+-QUrhh&= zEug-&2$Gc^A)K<+5-|(qi>q0lwTu3-`H)|8V1VgaOA<-o1DtUm)lQts^*A~6 zQU@t*A*6W&+#n~c)zpi5Z__WX_zBMqCOJ^m;X<#9>(t@ATT80HGysV*?^-OXl)`$v z?oesn3L9gl-TVECQYh@bkU(sZ3#Z7XHzBcQb(JDXOf+R9gxxnbAbKuS0N6m;>krmM z^B+*^eLhc=H`@ORPzO^2WpRioSFeB({T+bi)Qiy2uK(OZX+;q?teH6@8~BrXWmV#2a$n7uVGE(#Lq?)gOWn=^57#q)?spa{p(=Z}b58Ly{h z*pWTwZ&HiWX`bd!nST&|jalZ+*SjmP-R7IO@12p1{{}Ke>@rYuZ7R0tAiF8mOiRAW zm1%x$W8(|dH|n~`!BGvljS9y+DcsdK&Uk zpkuF5fe+tZXiLIqZ@C0k{a$Je;B5XmD!*bx&FDv4bsk|pB^`Ikyt3$sXk=#iacj8Y zN8P(u#};K26qDsIAF*Zi$JWE>EuJV>RD!oX9*Hw%WQa*>j0WrXu0o$dU*Qlj(nIzQ z(`~Bd+sp^##*y16rY}R~K#^}~XnB`w?tY~y>19h>u5$ghgtXKN@Zyg_D)mNcytB}~ zYLb%!qCiC8sZ%wPu+?KYaGrudo-fvY0hcHf+LH3`#tGJ%fO42 zD~Wer$Yg%5_DLtBaa<*?>AineAU^1+0Zy7-ioh#Vu;hUsipQ=|o<>j!ymOHsR643DK&71D` zia+DEx=5KOc1WK_^+Q?PUMpIn20dYPzD@^9_aD|faG^J}3KBjBYX<$>HcuJw?0 zlbiqLBW@c!(>Gj5ziULW0CqpNu1Qe=QetM3@NoO3s-MFkYoa;$hwGua$J(CZ-6t+0 ziIB~GYRGIb%jMU1^+AuHFePL}x1Ft#D!g=&=;!Lm0y;RddMHgywN%J8yuB_0QyJVt z(@71~S|F;m)?e;@>Di>u8CgZ%K><1+pP($C=ryHiH+7qh-T6f?@D44U zqI@Ec7=ANBkVN}hx1}u2kFMW<*?VvfYmnVE(#%YTE0o>&0lX9Jn3m4hH_!rK>v36x z3VP(*`W$f0iS%t$hs}C%nQOU9eb-HLu@GI<#I04+Xjpmo_X9YJll0tzK?oN0?wu!9 zg0dyE3O!RSgtY`L@f)l(RhqHBoNssb>VS3$_NDYIZ3z(6RGf;|i_T-42sEJXHOmN} z{MA>V@P;Ll{gaJ%tu}m)>i+BU@3(ayN^7x=8REgzu(;#go86WSs#%=eCfNO$+mK>m zlc2DTLCdTcyM&fI2?ZIiJ;1VnxvL1vZSozc5ux4$b)3%+VXSDTot=pj_xm@WFI)Yd z65Q|zkKo8Lu5bVld4Gr!1PVt*Er%@8G*pE`KqF+D&P;RSSFOhFrH?irZ&W4D!O{TZ z;ZwW;nwqw|lcP=qY)l6@_yKO`yM*=q1Um6~l*Wq^h}%}5e^Ok1f~~Vx5{)B*o2D*K z$-^pa#c7s{KV`$T*pzh^TbGe~4MZwBTB^GOviW&d9t*mVgj?}pjr(Zn@rGzzdv#oa zW6LmZN5tNT3#x>{#jQ;j@U3YG-F>_wn@;0PmYm=Rb70d5S7tM*#Tz`z8tear)GGQj zkDX>7MXX=${-`_nFeUt}OaAS>0az+qrjgTY|j50XL%x)iFj4wFd>% za9P0U@$@@p4`CXrkZYV?X>Odv+)!77DY{IEo(^HC`&rkfP`HT55}*?q(k;_*qkZ~F z$(g6??nEmXEGpn3!y!Va0QAE>!d!|YBC zc+S{1*xB>dS$8-W9!QQUB1(Nj25?b70VILHKqqjXmZ31MfKpSZtay2!`Q(YGl7%TP zm3e@_Qgu->$DI<#wnGqH-O;25<*a|x&CkwV^eHo$~%~}LVZKnfo7~`&*+!(iid;j17m@PDmQjI zxUkTejvI3p_=?9lTk?SKIK<7hv>UrHjMnFMBy}D&2W?W%Xpym<*~2>|W|Vn+^De-% zcBb`I{fk61vZo+WY3xHoLlDUyA(~L-w8?6bK<(l{*3NN)b-^DaF1D9kWe~q|HEZe5 z=nx6VS(*0-XR#XMk%G4#ZnU^E;5#4T_%nKg#592|zFB;pM5@7sa&uLCJj-m6Ouw*X z(eSnPTil;7XE$R(dq6a9`Ckwmd{bIS+rXJkdspqok3SNM>52N%kBl_zBsf4+ zdpw^s`CcQa{{3p0q^c5_QDpxyqX+;qN=$`7eLeK->K~QkHRSnA>9>ggsPF-JSKK)P z!{on-9R*x1|MmR;0J{$|~HY*8-tu{{V)>Xs>_@q;{nivVF8}hmE1okBQ*dK0m$WC}= zBWaz|=M!_^1MPtQj(e%)&}{`eUmirhZ|zAm&NW_&RVq8Owze_TTld6GLQCS*h(D2J zzmauoDpqj+@dWaG`u5)FzRfV?$b)!oyMTCepN-?j<;VEHpao6HVPZ$hWgIZEMt9nR z=^#D`GvTb6j(pd(Xw>UUnsvbxjZd^Krgt(p#o#&d$@5dn12zB~(H6Li`Uk znre(TDO!Z4-acOq%??$0YFiikyN}~Xewv-VM5T+#-S`S?^>w}obf6j*ss%(%(N~%H zrlU8zi2~^k$_su)UtFBISRX%QTb8p$_6Uh06l%j8nJQ#46q~*~mN@Q{t19)1K>6?5 z=3K`dvEuTkLWqJ-#@KoL!UOqLAAd!viziwu0pO@lefe?e*xbeX3A&W)-d7$Fd8AsB=e)UT{McqBcK6HK|y*3n$$3wbh`TOB8)kf;Bg;{?RT!18P%qp0pS=6Cd!jzS zi%RkujB13l1(OMVgUR2VODPt6QY07=Nvla8Z;yM5T*F^I`3usV@+zwzESNsVDK;Lq zsfsJa*^;|gd@7f{+qIUigy_<{3&S9kVoC=;Ox$SwRr(U>?lx##{(U+{~6xPwdswS0ztvS57uiNp=(tK?fcV3kP1x6l25?d{A@#R-xTqxLtL3Jq~{!w!2S#u677u2 zn}sV0k2$`xe=$3;WZ_pnQM>niH&*Av2PI(XDsMR&b@2cX(P>Ni)mDr1E_0Rg8%1b- zGvfXt{TQ*D7sJ0F>2+z|l~4v~D`vkhEI3h>->TfpBByfZAL;yTrv9ib_eb$%xU?>E z)ixc-L}SnZ>UAL~zPdVutMt$@aU23}lT*ixf-BrStq@l)U3|ec<0u~mxMjd+i^E({MSOLXMsK_5*t9MW3xKc*+TB0 zonShx@~o|(7fRh3%Azyql(!99QVQ#ig`?9~l}%O{K47e@Esc9Z*!x#R+&75MQcm9& zO-JlbM&v#+CBN5Qp(NBCGyeP|)%nqzOCU+3b-YbgWI5NbTr|yte)a6SY<|Mke`6TR=3<$WO&gfU5%Ia|H<4>_1I-Q%s<4k_F}JQ23sw0bCv zkHL6Jb{~tQaMnNIy4yUDNg1t^^WZHwa4#5@L`+G1E`~+ofvQE3?I8 z{{LX_J;R#Zx^~eZO{IzSu2hw#RDl2@(nX|qQ0ZXky@etmB_JTZ_YNYxMtTRSQ97Z6 zKtc@=a6j+f=bU%F>wDM!zH|1Ev#)C}emtZ==94+coO8@E#(m$PzYma{wAP#hRc*b6 z1jHWrmhsU7YQp%44oOn7@Fhy@(*}dUuc%6xU-3^x^O)f{#TOQMMV7ZSHYJ{CrbmF# z%G_HHq*y8xqx2nLmSvVZDoAb^RKLEVv1Q72u9&U9&MSUcQHLue1@!@!D_KnJwo}9* zFpDspea4ucBF|Sy!w-{{!U>a^dOu%u3i1sHX~BM+_WHagq>%H@%s|yBwSJf4S(5Bo zPU>`ik@%=G&78z}oJDkfa~*_j@xqpk3-bp^;V^^KzSYw(vtBRw;a(arDf<+Y)N1-A ztFD8e-xcAL%sPL+mAmG{h5J`m#Py?fjRHn%KB z%q$UsiB?Xgr5MTXRF)tcg{5zsE_RufEGqC8M6wPgL~w-!MLl4{gE*vRCLEc>aE3@n zmm6M+V7B-@XfKJAs_S|?pUqkxy2oc;>pba|_)aJx=clwf7TivWHtk*dSoXH4J}?*a zj$dkjR8nJfkh{e6oh&06j}ld>z1X{Jt5-NYpl@Nzrh)um_58UGq++_Mjo^F?SszkT z$|ui1s)Q-K{n;C4n>T|!f&uaq-XPx-IX}T=C;^5`AqEL6tXPJU&&}JkGa44wAod|M z`_pIH(1)<9!S3|(IfX&Bfl<5o_!Wf^=!$k1(ocM-TZ+T${buU^t^&Lvj+YvmH4wVL zG$8Q4GiZ_;rYNR98vqVg`F_Idv`?WrxnD<6A6RK+sAr%_sREl2`R-oaKg3L63-ea! zFvhP#poaH0eYiwb4Gl*ICosk+Z@1r^ZHwu1Aed9sY878^eXm>iBAZe7dpiYcybcq)T;XtO zs+C}WeShZ@lYn8y%n@pwKyJ0qMV6y>LRJVuSzWCFn+nw-#Jl5t$5#A| z|1MS<6$29j4te59p;-oXGroe-b6gN*Yx4$pNa4eWFBGS<-}E`+ul3W8zLLvg^kqUN z!q9PKAdHVP+T)`=@34apJB`kU0vYHNEB_~IVdCv(t9PX>;U*7oJ04UOltV4y#5d#euYX_Z0zBCfgZ1&WRr2_wx~rhQ;qwQtqvWKZFzXmeBUl( zYMbuq{&=|`6a(h`{y6aL0U_7A7-!4n(^|F2RF-bj2xif+*O&HL!V#5OE9Y^uVlM-_ zacnd+^42u7(%^((kf#OJanEniFJQB|P^0Dga6B}2SEQuG)9>VxXs)E9TjX2EMjzgI zx&G%BLji6sZ5%burYo`))2>S(-_GBRPz&d)T_IvO&C!bRw5EL+c>!&9anReQuTp%?Uwug;c)U?3*#Ojd8=NP^9CbPwB62}q%ecE z@GG!+jC}?n*h^jJ%d9SFa;Q!I`0evD-=QPUW?EzE?_|%QZfG{qjH$n9Z6OF2f6jS50t~!y| z)5c~cb$YPZ$LhkaIH&bBr?D!MXa7Sk^q(9$|9Jj4kItVJ5&wU9{(tf4xC0q>K|pQV zhPz~|`AKWFl{tl9A?=||2dxL;aW?6rh_*^=k45_F%GwcST!`QMOO6j;AVqY&GWSk? z*}_17QIPc=Pe=#;p_LoDs&>b~`ofmmc)t3!(TWyM+^`P_M|hW6@G9{{jTmF^r!k}( zsS`IV$a9)I-~Bu=@qq`^kKG_bTtoy*b5rKGwoP--5K7%SY1B)2fd6gG0uoNi6;+#j zlIEkLik5whFGCtE4eWovAWo_% zQu4~b%JCm_yrX#cMPn!z!{<2KhnjVvE)R%l=cX@^4Vq{*NZQ`euFaLg3-1)k#dl!lle3{RJCbaMC?? zpE1z71fR+NQ^>ofSgzR5{0H>< zi_f!3$&Izl)6G+lnz=viO44+*FbI>FbvcO6ktH;n-^szMeuVs;Us-Ms#_{*U(1(Ums$8-o+;8v9E$3W zn4%Jl^)b_}N`)=@iwgb@dvhf)?8xp(N8ijBASA4ffLj}@7<~8DEilBGc6syoKZv!Q z=9;Y7$E#8N>J``#XGJvXuZ`nxdG73@4TPh!!Lopu1j99jTJ?6;Y}U`EpP6b2T(RqZ zS^cBnk-7S#j2~m~olGp`K;D+Im(M(7++Qt32VfF)G{CU#SE^ zlXf}~iwc9}O8O-&4bZ})lyC(G83HzDGT}g$?iSjEOf}3i-$yefHm5R$wW9LESDVQe zcdzosjs$yG`H2n#2&n)xC37ykmJ`ku?k1_RsXE~R5hmeq6?65QQhb&r(zzvqrGVzNq(60B&pB$$ixMp zt??&91BHr#&<6hwlG>#?daVn&zFDfY_X2zK1EI7|wQtg6%#2C0`bgY?;v*9DBLkb> z%g$zBddO!8W2RPYH{-+44g+SbYbENFfhHbuAbX5QFHI&Lx*#UJ;$4u+rNnf5v{*uv zyqw>+J2xd<4zj%Wd4TzFaQDZ;Rs;EuZMowN=S|Z9A%RZ_ia2A#v-IpS(3^{R6K!BT zq{F420=`bzNd@QFv+urQ)J={_;^h}F?&dd?=YP>T;&6L0?*WOr2aKPZLyQ47i^9#6 zLCK;}#jA8y7r4dbWXNGw&2Z8I%*K}j6nT(s z>nzgP6;A%Dc#}1Fg$2%XM8l(3N_h3oUhGb~P|E}IIjmSSnkDLlNmebo7onQC$RuKz zJkqoG^GWUP`pbp{-P;?gE%IGs>L$E$AXP6fksL3{>0y{~_o2{N#RdK3wfGaxW)haZ zi_q|8=pBsCO^B%)f}g#8mg?!=_1yyTQ}80(+U z_HFUR?nVGCfg-D`8&$=ACBh^W&!;N*T4lICZq~Nc$0FlakWYlny|Q*tKXD}jLHoHZ zF|!+Kr5NR1g-M}|yJMY4DpFa62J90u?HVr&W^GL57fEj}1&EtfcqLHc6d-xu@%rdB z!NXK)#wRQnP9-QFhLHKS+qF1*t;d)=ep){J@7V@OAXe8h&16?zqSv2Q^@aaK!1~`D zWFO~704vmg7ncTj5C8uCzupIe*165lP`9-WFQ{hU0*DPw4Vs z?^eaUfRL4w15f-}li7SUq42b?!3Ri^leE5pHKhR4RQNs* zY~@{nW0c$${3axJBl918k*nnnA(nW+zWnAQGXa>*^kC_1j2V(NuL$Gqo;WIa)GMz; zN45wv!~|T4yH^63Hw|N7Zp?`<7&pJKoS=8)&NS3#Fn6Sda@o|ZGv z57nO0zd;@Kz?qq1=@HodH|XIbm&E&yV!QqQ01M3{%io}wr|{jp-yn`++dn+XCe2zg zjVA0`V$j| zj}wJjYGM#$l&=ZrHGQ8UmN9k64B8zMk;o#fMD+EXN(Qyci>8RqMeCx+Umi@92aw;# z51pp_cB3(ac}xRSorlE-a7=PSCY+7!(>cudTIE_>-rDc-kozA4V7IvHFtA{hx9*^; zb`yYf_zilsw}k$Z``M<{FQLtEnq|br`)YHpxvna@3M`G9(-=R_qAkq6( zqCNb1upg{w$ktz==VDuH?a2o9f@FCOP{%s2v5pw6YaGSC&L)00&iX^?X_2D!`CF~m zbM<)S`@cbn5>8k*RF6B$&Fgyr)~XB>p6^-oiv`&A_wHYEWvrd2Aw93JXL?09bGG$` z&zDdtM@CD?DZeXT3_#m}J8?DU6{kjSfEAj_!K@|4>MJ36#ayk0wOLaWLU>UsKUtLrd{V?Fia?V_wb%}vlHn=)g3xC!ZeqXl12iMW3 zO#KF}C#A$bb`VX6%#CuVH=-W-zQPzU9}s)0=0zhk(Tf$-S?nG41@I&W?mIZ|Z}vJx zp-msFzcM+3oOt{O4ScS^=)1SvH)fBY`wfb9f>$>9jpc)7vga{s*W}W-j4=G&sVto> zy#BKXdWcrJf-ze+v6Mv7RMnw++HRi&R=iKQc!v986#Kd{>zGZJ@R38$$DYo=L6;mH zeWy-QRMfvgZ^nk!&$+U=q99A7vzW|?6s`xg3&(Z`O8(T7c9a?US$6&;@ZO6oIqbs; ztR~8?14^~e1*?U;QzO<}{5itdKN_p%aVJ{K+^qVvUTaO@6B(Ya(#qTc9VTWuR#cRk z6J>PAhCVcp>!a*do*eq_u;pEj-6uHq#u44oBWzZ*auTY(IdVpAYlssB_KK-~PYA)L zHhQohasD1VjjiKW>5@Jau#rAFqqz>{7KxCRY&iXVJpoXd?6psPZ{OdaTHx=HBAxKRy`3q9D6bpQIla`&Ql-9dFCj^HDwVHf*sM>Eb5^iD zGW;Vmhj)Eesi$4{Qq6VDib@uZO(yfq`?|TvbJnT7Dt!a4aB}5Z>cgltW>WEgTYYYc zX<43~A6vd$!eO}bP(s@d3SAl&PuuTpgrANO7dU?4Up$i1=8k(FplXZd#?W~JOpd-} zX2=mfAY;;Hu)g|@Yx0D)vZ7L?%w9&aJPP*)fv%(NPAdhvtJCBbnk%$b)XK$$|AT88 ziL6?@ar5N(qG4ps(Slz}gC$2N4ueI+CYv-b{|A3Q15dNQOg#F&=wm>X+uOg2GY2fi zfI|G>-GjQSTR`f+Nc7KroGt!_Pw4Ts-!0P+c@Sj6gPQk9{gC<+`ZLy)NK^#z-AJ-L z8tTEg6({(uFEIRhI?Ag&MT#1e%LMHf=iW}9!b;l-?-vlxvR-vRfgCZo?^VFMLfF$$ zB;K~3@XjpKR*uy=z+lKCPoyap=7@ake7KYGTgk^ZYvXJCvlacfZW`gn$8i@=x4NOu zuJ4XTwDSk|I?by9QfCVWHW$jNZGq3qSmdiu^@})CrHKXlvFdNG?5W7JJB% zD2)ceK@wOu6e!9{Y{$p!IUsFTL}v{A8xa}`q_nN?G~-g9auKp|(e(*|7F3o+VYOL2 z^EUM%1}8$>^h`Pv7WIw()Z9UFvA7g{2ku9G2BXaw1>`R62~E>@e#0d}VYAQ>&v+R_ zuQbn-lJ|o@HrI$B%esnHsiFI#-V1*`$zYyCT7~w7xh=@EhzX6|yoz(UZ*GIv;J}pr z@}(86-t6-z9xr$|c$0So0=@|H|!>`)jlLW`wa>>IkVvp3mpL?dJC&S;*$+p`C_S zrE-v!ksF3Yo;-OxQkEj<+UPvGrKToY%T30!kL`~|$LTT;E;T9! z+;`wygDyvV*<=!t%cjxx;5UoZ!O!hrE&$WsLkRw`08jm#$1S#4qx^vAG}_JVjRr6q zN>+EW)o(euRlT6YWo5)nH@c&e$L*$l_NTBLicvEYj}; z?=DipC(6MJ*<0cvS>gOAL|#~>m6hT-6GJ0KW8+(O8OT`2r;nQOTw()PpyaJeF31B$ zJMp^3O1JHOF0rYR+}W!FEqvX>&!B+P=qD$DB5uoAuqZRnbgL{Bb#%>neS5MpZb8^j z<8*PDWasvIs2R0@GtMIdNAcLy3&bxp8n>$+K&0hm6Pf<{VY0EYP*nlz{8mv{bSE6# z7b$%>u9dSx`lv4qYmxWkdd#02HH=6TZrOV`y0eHFbZKYTA)O1DeXO|r$!_96O2AVY z3NzSm6^@&g>kBqi3R<319T}+6!?v}ElBJW!iJXm!Ws`A_!>y{AcW3Qm{jBol()`{@ zi>e--NGO`1h}T}kJg|COyRJpe=+r-S3=i|J`Uu7?G~8Y!OoiAUb(0kJRPdgSv3(2| z3H^~3@31704#L3;AaRdGt8|&pvEXco=Ny8c5Zjezv6>!R(nyRk_(PMj;)SaSd)mk@ zAIKDV4uFNrs-lGfF*jvnahnMSFWxo1HS@zc=axGmj*6s88Uzn;qfjirhLB{`o}FEC zQ^F+NNMM zy9fFFtY6FkF^7IG34>^|GWIKTh6JK!E9)+xreKOZ3l<)-oP57_$uuu@e+n#Z6mn(e9KoN&UsqyXn!ErmXZ6a{5a z!GE#hg`mC$;09R$xIxkYZV=A$)R`IR8bdY^4~&OyznG5ycMP@3>T*>5ZXtgxjrsfW zzm3!XyV(7|dyTHPbcBIGL;LgEkUu17)5?9eQ$lQ9V7Quo%nTPu2kW~ z9(0R9M^~FeD|$6*wf%{yK2{ku3aj#@(E^ZLArQ!(d7PMX0A#S_{^!$P2{%U9T>mw% zAgSXfwP5av>zmhO%)(bW!ghPo9rfgkM*7_xNu$mY1WpJ-tQb>2;RyG!$MsF1R*}sL z2Houk>Y@6ral55gek?HokMZ9E0k=FX_a^k?5@{Vg#GkJnwe%6vK2$O*@{`iaW-Tfz z!0D61t8Bz^h-s)PN>8FBAZ4E09+S50(UpT%k;k|(ErmW8HY~Xb*FnSIO92ZEbq;x_QeB-)Iu3^w2Y`dHdGI zm-n7sSuli1X|1zyfo++oSU{jJFxI3GDIFwE*qJ6xY0J#lI^{O!Cv7NBnq`waz!blr z$fgu^sKTnuCc{wGPabaB`%FX47^oAwvF%Ti)F)~{H9b8OR^=+ zLta-gC37!*r4-Um&0B+iU7O{Q?|Z>rlerx3;i}0&cdO3*ji)!HjwajSc)vl~S4QaD zuIjB5>`r+~Y@bRa1oSck$P6Gho>tfLs$AFY$lsvvJ@*s%%lTYe1|a;rg|*G$^#gcY z>fbM@z`?I|+kBs)V#0hOXxZLoi#%ILOwi5EHkA_gsM!JbH-d8O@8|OzaLzKl=AyZi zzGC$A=dmiNqA&v_O5b+YRn(=Q3Eblmk{%-ANbqcjj1p(PRySt}Bi+@^-z6zw+DxA@ zQ&McaV4y%OO5Q{4uYYbX79PGM&_p+;9_{Vtup<2-7p?oTmA`PP9H1?+X*RP340!&< zTfXY@P|m0q3=D<`p*#Kui~*h%R1^B1+Q)zrlC5}nb@QZ&;?UgBUFy*Fgcvm*!R9QZ zoIXeHXU!J)DWw3VRK#7kO6_5)k zn5(Q1a!(S6Oixc|6c*S)CDT7e1z3D|Z}G;NfD)xZ74NW2cH1shAxyZ;aCeVTrFA+@ zrA<5^J+cnUnUQmQ0V401f67&f?+$)pVef6z!g1Qn?Hb$rsnCZ)IaMtNR|M&`0qn8BOanGw_FEWwe5=P5*t=7X|tQ!TbkQaxoaEi z!nLRO%wgn4U!F=n>t9=r7_LF7g#xrRm1Vh1YHEhYuNHd`CoViUt)eYw;}lqX0~Nb& z6sVbHY44T%28|W}2CXe&KV5~WonP`}0dP{RSk|vA>FZDu>FcZc-yn4Af9RPnQ~pd_ zcd@`B^n;2LeLzihbF9O`K_z)$(358Q+troB58lT0aS=u#YjY-C;SmUfrEre=DYvw- zhL5@5YYmHYn(Du3<5=h|Fco~8ZFvnMRbm4(&t_pSVy;zzX-4=3xG!l7fbjvyZn3+= z>A*^61qHCGC|5&urhtiwLlQcT6Ug&Da-tk-&beB%gX2oVOKCMA0~ick;2zRg%VwVg zTu=AOj!ecjn6Q3b)g7bxIYT*)ZYNp7B+*3Jy~E_pvIIPrbn=OsR<~S?N@s(2WtgpS zWQAo%BH7+8&X0yF0$=W4Zw}3u!RFMG`|erBK-8}N6n=w%;^wvBA6mabSzm!$g`QJz z{i*4gf9Bo}ph`zQa|^{jF^Z~A7BV1)rOlh~k(BhS*5?<&BDFcQKej$V!ZF(djX6L&rIq>-6??<-Sjo0z(nm}2HclF%L_kuN&phz@(p z9x6~=7JBd8>y5&VdLh#f{tPq1F!3!@TXl@j>v4Ox7cX;~0#{a6E$8ZbmmQy1$}oxl zRZlsx{3ku7j=Cxr(g%>>^gn0k4fMB8uT8S3){q{eaY3=`(|1J1+fbC`fhQ%p9VXNh z4p`xW-HBZq@%kn_Q9^1<(-!V+*UdN(s<&o$0mVI`3-P6SQz^a zqRab}HMI~Ogh?@@!alf>sF^@Ct}hTqX^MtEw^oD1p7VJmTF+O3GGXa~eVn!Ml~>kI zyW$^S8=34fTZGi-=bQ(h z{i)$UnenbKMZTW3lmMdj+4}?D<0|6&P0)PhNNqe^|MhQLpH23Osd*rXy94=n7Y}ry1IUB38 z*@Xq=IkO;u8o@&<@P!y>Zj$oXP=#n!_g=9(3Fg+7G8*DR;LN%mX)-YH$`U?S17)A$ zaaH7oq|;$&;(MDaTo6Vg@+_A--x4O5$_b4zq_DYHGRKPs{K zQ5qL;JIb6Td6WIGtkzt1rGKBtp8wr6q@bqZQH>Et-FP%>-)*|qbmGjEU2XWEH?V2` zRw}YEtOQ!Ri{3m=iPF0;#SSm&B@xqvqh2v?nLAZHGUh`j!}ki|OU<%Ao8s*!mfU49 z+d=L21V{ELgZoGC*f*^?Vi=?T>W&ZG8Us3rPmcpVo+fow>rJ>I(J1jwuD?LE@o@GQS={$9_WB z;Mi~g3-+hwZ~RXitkF8L4oAc+zXz4du@%WTB?nz~ly;EulRy+N9J znS%gBmIA+rD0fNL-M&=q%!>TV@&?@}a(71_>g93t>VG0zbo!?8Z1YoB#Kp@hd|5hW zx6(-+1M8(-wvC#XZoVXn6GiF^Wi164FVGGcT^i^y1+XHB!7&Rgs7gLyf#52-c7dXs z&sf6F>g(It5@*bEWO0{zjfGWkoY>J}_qxc<&13GJn6XGWFOv%Uaur!`BV%Z{fq8Pv zgOdKkC!zu04)f~1`&u`j@{s}*{8AsRZ4;_*kv|ALe8k`YKm#K_XqeZh5a z&2Lcl>3{ckq%qU)0MV|#a`-{jp)iM+$v>R~=Bht>2b>M3!dX{@wf}J1oB*e-gcJts zcQOlLfI^TD_!Nsj*@x}_Z6767Mh5*eI;YNc-6Dxd7O^DzQ@;If(#E0Ku>y;6IWvvC zGA^zwSNW;!Qgw9$fZ_>=9_Z5pm;a?>^S?Vv|DXCAU9D~#v@-&=gvHvz&#w8eg%z+5 zssNV4vEDL3mKQk3tZJVw?fBbafu}bVx}lX~x<`O>fnyakZgK+Dk8^iHkI(>MZ3zR% zOtYYJTLC9Tn!f4^0O zph9pIwI}cY1D&b^ppAcOMKk8x5-IJp)OhJzC5IFAS>Ki5Y2KCE2s8n@tSF6OEIS9v z;R9+q_UsG4Hbx*9b%Q*U!Op!s`m~HeH@D>6OxQ3D$OCE{Hq~jjrpou8!}sq##%wp1 z1}(O3X4*|kugG~iT>A@~UIL2jUZa=s-7I+=x^^=}r7{#QO6ajtuf z7kuS32v0S|5J?3BWL_P<&jC+cK@PZss%t9rL2p&0cTw-bM}wqDH_tetmfo=h=9@Bc zKl*XOHSPx{!T(=IstWD03Y;-8Atak2$8h4sox}^Ogp7uW`?{$+H)hoH1C>?QY9mxC zH(?q6`~I&9Q?{fT_+8rwpHG}flUhqXP$ke!Qf{1KO&_wP3r_Bcu%1yogLQC_;;@?$ zQUSo_|GC)#&ixb?kusEEiZ!V^xw}eS_$10JGn_x{s|vch`v=w;!5-XAy?DQR($~tO z(gdcwk1$knkE{mt@>Z?c!&f2sl~h3q5>5e+&)dP6Xg^KERy)6^3ME@=E44L>bZ@Gw zIh6Zug!a!h1g(b;@HVCc9@$-ghOI@C@5^m9KIay3>ueI5yM-vHB1b~PJINPmQl4gd={0y3nX10Hp`5Sm!`$?H5zaF-}vLd~jJ2~{g zo}5hW+4~Ea4ixOB`W1#`Z|gLVCfMaKaAsM45Bj7ScPoG_MmUAwRBTl3Mn*37Z6j*& z!;H6Xs4D}BdhNDr5h5qT$rGj0s7Uv6QALnjz~PDgT-2RsY#C_&;FA~vBUuL|8JX=Y zn|w3UX2g@A5N(B(&4 zABMikgNpsU+Y>UZgE`H2^U1U9ABqBJ%CU#i2S9WNX}#d^8wAb=>@NCi}1I%>E z&XJw2)7Gp=TB#j5gHN0CAf1k7R94C}8#^B{0EB9Ke7aRY93_2QAyTnLiQvan{Tr(|n!hXdHOyYSew_9y> zW9o*e+}ry$iafOOQBJ)eteuHkqqTusE*ZnKv~^lT>Dnr>(m= zSbBZ(GR%fSE0}py&hZZ&DP-U98UJ_XeC;eQG z)_MN*2Hnq0d3Sryp*xQsKWdLf-#$?O{@&GUvUX2atqu-(ChJ4-G0-3;7x((J2koQ#rFq=&W$l-V_qAC1>MJ_@xlkFIqWsU#w;?eA;E)r@c@|LSHmf4pi_!}o{H5vSPPVh!He1E8 z^c0(=X~cPPx;7Gzx1%~f1<1^WvIRZq(W}FJnz=BZiRTYHjvTpb80$fr=5(6HH)GtQ z6h8Cz-u#nKqY6}m`+pef@#dv#FC}10B>WAcc*ca^7jt0sT^L5k^9xe$Cehs%%e(S$ zUEl^48_pLR!bYy>I`>_qYjb|ANZHC2gN|M~?=fa#oy$0!)cxyM<#O81bVe5CwT8d$ zD$)suDDj??(>lH)tD>D5gD){-llFN|H zE5+)LHaUETbcks`191_?_n1sx^O)+-ZC(4v{w_xz0mzA%w2Uy#YHkRci8+qk8rfxKDz0BRB&({sa;w#ro^Rm zmnJp3BFFlo^{7?#?!27PyJ<5#))(&*ZayP>{>o7>-pK!-H~%m5dCs#7sg4#6RV_Js zRaU%1R=jhRJR=nQ?I*D{WqNL<+Atv(+@Q(~ZL0fa3jENEsVz3C%ZV0(%eLpJB;9&` zF8yRXV$I{P2ZQ9&;Uj%UW}sXCxBHR_)6Q zO{_0IG|R^n?}}@wI6z7l<+#<>E8WZRKe62@vVPZ6y#$_?lt%VkWoFhViR&bk_K_Uz zh!mcRDb=N%ze_WJt@~8M=yCtHk^5it(+^v~gAuNH+p2gdYek&8H%#L}cKhj*R)vS_ z>(K<>o6;e2Nd9mQ&x}fY2e5Q5fcrLn#M@i^X=qcaw$jk%Q*Zg4y#8zES5M-N3jd;s zDLYg0&8cIF1J826@f4wKQywe{K3T!OdafCos)*W2s_w+EAukLMG zd*XV93G%LY$mcg#^7FbaiI!0HjSthyV}wG3Gm;PGW>wG%-<$cPG!-P)zYAq(STtQ4 zxtQd)#5S(3#me$MlhAv-yJIUs#$fAiuwPIvq2K{6?s1~(j;|uwe5m_|GKwX6^$Wl- za~}X3&H!g*>AU_wzy(dk)EDJOlsqUo@=dejM}OGSP3|odzu=l$4h%G98T!_YG5+&v z+r@#;l9>;Zx`jNz=F1l^B7!8APC}WJvBOHzLV;)9@r?PIMZ^EPw+W*$^G4{p41JrQ z?h1(IFR>l!T6t9-3yD0?)fv+>#5sLjcEsTWGxIx5#4;N)z#euv*}g!7MMHrtxU=yU z(%=z)1K?GHO##D1U6n)vs=@b}*Mzny=8 zyL(%kA^C=T)QM!tLv5AExO7|q+uTVYHrabD)c^Jzf43^(OGKTXIcmRx!f1OoBN)l6 ziOah9kmZJ$KHU99@@yYr@fa6cq1!tejx5QAPne-DY|2iFAk+^|d7~6xv94L9u_s!t zhjV==E$W$_$yhcG-9rJQOJw9bG)VxZFKo^ER)x^HmJ?M8Bh5Xek^>x) z?Y%*g>|!=gG2TIRbi$TD=T!z+dIbo@KG`BF*`Z-3i;>ULYo4N-9DNbUp&#HxXTllj zz}>qne&`If%!**9L(!p08X}oO;)(op)tEJvrIy~W=e`C>s5r1WeOry!)W^P?U^w-g zavf@EV(62obnTZ?kg|Y{bn6N#j?#b*lxP^fdK|O1&cCd|H__T&N{}#F-~7%XD^`A}pZu35IEn8Yg@J_-}TTwc&8wAWcU zQR>)9Ztve<%)`8XvWPI&65~!qn z>#rW*?0sa62*4_3V_0OS_8hP-5F|jMu^nxRiqu;;ukv7Lv&LCHeCxS>?|k6lJ61fm zZzC9(Xc*Wj2P1zt*=wL$F&e*8W-PV(s=2 zrS$IaPt>&S71Y)zHMG<>G#gFk$rli;4i-SDR$V7y3kMY^wio;LO6hzh%NZ)2*FrH} z-Y3=7aQz%5q>{$-vU@rjbSe)A8^IJaEpueS7s7}=E)UenzC&mvb&iX5O?mh!EVlPo ze*H+(U=+5!ZZq{4y+(1D_&+nEP}!nl?1J z3p?$$FX(3yL^Erq+x|3gJ&VK8NJ}+ywYo34mODdH*#{@^Fe!WlRw@^HW-R)|NWrgo zc20hhqYEeQ?zF`9uxJB$G0gG zrUyv4Rq0N;!3lS-@iCeKlz5s)@q#ul4kQtUJFRs-=qf1 zpeedos9X6<^>>=r{HbGGO`R%vJVfG$C-jE{sATUp3i2&AUsCR~Jl^9Q#T@*{HgqjX`w41G;XjJeIPvm{{G-_3LuAmp)!8 zZ*ESm$b>{{->%}-#Le8sN9AmMham%`$sHZMfn!v5uQ0fkMxIu^-qATuR(U(j^o^mj z#v&Jovs@mQ_ZtL6A4Vr3K^Yk7R_FT8-`~Y4VoyLbl`*7;|U6XtxEmC{(h%BK1l0^ z0=rwK=c?eN&p8ie6Iw*~m3J45+~C>|&u95&%hJXO6DLa^^!g4M^LyD9s7MqnK1M*? z#xsGF-tDR$+-`6G;D6*LDC{op7ta}9Zv{;{0Mc*QfG6U;!nW}8T@3fM6 ztvcMsjA@{abOda!sp++vUspHT%%C9_!EtO)&R>WucMO>aXgMp4Q2ly?rrK3jFC)_D zgV}j;+3lC-0YD=bny@_ibS`BNXNOX6=Z*^MGF^@;NX%0V5Qb)jWnDqJt|Q}GYgXV< zX)6aYu4H#Ba!oV%4kfB;6z5)~89p5)CCljmeyTM8^*`V%SwVA1(QXplE^g06Lai1R zhuVJ8n_bMW1O%ME1Mm&q)mCHZ9@N9<#Z~+VFKG4EUKQJt)O|yYl@S`Kx|E_ZTH5)J z@mJ|TNo%t+f-6F_TwEE zWu|YNB3b>I_i%ka^qxQ%&u(zX&je(f;A&B~OfJT*i@Bj4%@X#7?Au zz250Xq$7KR>B9!w755Mew#>T2rEsuP38uxkZN@EaK$zs!sVr~LaO3WVMpkSUZ!r)SX0TS0JFm z;FqfvYi9oKeGc)Ul&>3Mk#KdfF9|v`zQA}#G}W!3qU}qgrM{brT#@D+j)DSus*toiot9nO!OD1o(4g5(Dg zL47^`G|iY-r_7Jg1W8DJNNvM)U$wEo2Cv!6Hh~tNcgw6j9QEc_Z`rH*=H=|`*z16x zsj9zCk$;S;_HbTB)s@|~s=v4dBL@JitfH~;CiZTdNh7|agr}=wi#T0^JRV~0qs%ft z0$-JnW-}#A1a7&gp;x>i#k>zNBZHg^C!ka%?DLcYeVhFtC1(L#000_$lrNEIU5L zl-^Tz<+NrMm=rdd55jtmFtPNDLn?+6A1%WznpDDk3KNB+D9QJ_RXI1_c;X82Li|LJ z>MATY{j^UfE^l9$`n9(CNirAmhrq3v-qymYmN2Ud)Em`CyiHu; zU*1Wu)*Fl5nCjIBSlb2xOv8)TuvZhzox6Ll#~jIrH5)#&8b=jS<~7F#DKZ%&$zaqM z#|HK-D@)YY^aR;Lw7)nfI8o^y;tjJJ^K;2LO_yv0(XVNUCrh+H!Qc-xv)b$mDiSmO z&z8O#cnHCo$`_No_ljE1O!ju`Vp1Ux%l4*-#YH+kK29e_$L@lRAL;lYXC?3(U5{d4 zLUemZvHtd6ewSPEf(g{BA~CCYc43UEccNF!EJEHo`BAmh8zad3Re}a777Twc{3h5b z8-wVUCdH~UhvzJI`FL=Q#tK{0~lfTm?ey%uV(O1 zQ;)V!X;eJS99)R9o8w2rDf@4{tZxGrsiLD zPl?8YGWlzp#&Zmb3|GocVSJ;js?)sukj1TpZW1AZS||8uk=@sH#$&wc47+6(nD5gs zQYA${f68(kZhuwbtgxz|T`PG@)j{!&VD?sbi@2`OG7Fuj%8CU%R45cJSM`yq)uPC* zq2a=8dzQdF7n;$^Iajys{m+lDB`O=`hM-Ezt_Iymfwok_9d`ekW z+NWD`GI~hFit46l*)^!tesH3Tm^cPj#e#q2)ekKU6~=)=3e&Z$(Jo&=Dic#Bk8TJ* zW@7p!ybHn6v8#}C<9>SgfeO{#m-No}*BHybJR*37Lr~{Sb14b!)aO|EFsqYiR>B{` z=T=4&rdQh>_BvCn`_aw-^N$rC#yg{GLoNP`c2Fc)_ZZ=V3c7hy70m_^ zQZe|{qDeQ7PP98mUt6i4Lyh*GCp92ftJxu7BW|Bm>=}Rg&~&9|EOfI;M04<7YusM? z{o5&TtAnYHqRh=Rxo;4qtLs#q2T9734_tQ}>5n{-U(Ca<(2Ew^7>mAb~%OWF_ zD=PL@2^sQJb@aZQ2Z%l%0EoPDJcHGM6a@h)s|&zK6Tv^yN5^uUmiahkZ=Y$r$n<#; zNY2dhsr2Ji4W)YXGXkf0v(5|Sz93)RRZ~3A4HAWwtEF)KgvBhsN}DA0j{EfqL-H%{ zY(!!?WwhQl$vYWH@?!u7m@d0rh$xpUoCSWObKKrS>1%Zm}8ynZ=!!qnYh|7bx*>kmVJSbCaADlC(eU zeNe@kXbnhDDDo|Czkck*i1Pa0s~)s}r0@BZ?j6oy7P<95ly>K8z2`r zURW|R-co0Lv*tTUMpaYZLld|bElrAVq;qbf~El>XkucdI5Fbnbl;2JUCh!F9H+*f3bRi=2Wm;yq|VNnHUqCIW_K4E zfr97w?)Hb1Sh|(IW;<@5vVP9S)E$f0qpYFqmKLvvnxa8GgzShMwPpUW#Rrp1uO`6~ zr5O1cBzFu(q1CC%)Wgxxj+#UjMsZ*!XrT}+xv6g4Q!tZ`0fnPS9!E9_2;$x?ld zS4d31W!`HZ7oOw%GPM_R{;7sLN+4862LSd20otwiP^%jNU7+v&nDxzV{|e zeX1br>P=k`XW}#X-klbOocE_y5Y&DfAeu32pHhMqns!|x_LSYzkP}JDqYNUs_4+Qu zYs+WGFKvy6*SnayDydsFm*KE-@U7Kd!-aPbxxQB}XQ@uDh8?8oBdznfuO~w1C6$qF zn_W^gfTI=Qn#RPxf(Ui3V!%AV@M6)JDQA{QuG?a`|F(JF<$VbQ)sy;#s-~~6NZz07 z0DhUz$zfgVS%U@*kcLxU03Y}q*HwD;b?-^P1U{^*3F~Ht|3Q2s7C1JV?P}VHY!Io+ z8S7`aE7ljovdltpp0Njqut%)=)BnKm>%b>#$L{E1fId^7a=W>wdC9y_t|SPV6DBSwtep2=3mPv;Rt{BAFf-10@i{W-^ZwI0V)F*D zz3WD+$>qxk={u5925p&n6ffE6(@kujowIFT?XZPxW(<-MMqI#OqsqkcN_mXd~(zOgZA`*48+4> z0GbuopxM<9_B0tq7*(U~!*6t~EK&9%zCTOmwsB^FOjofSjlNvHN>uL07%pcBb`K#X z4Nc?rgviKjDxhvm!KG@Bd#rNA4}O*3sg2{)D|@Tm2-dCA!%7B?t*F7kl1OPLPu899 z1T)9(U(r-;-KZt`o7OYJVyxDOAIOkePpfz_vPcMXXJ4-rBckdz=&KM=uw!FtuKJ?$ zu%NEnML|fFHJg7uo=!ts5B6;tnJcxox?VK@t6fdb@7Te`>*s)QgK$>C^wNmkuFoCc z*C?s@l|9kU%zLQg2CL_2rbweMNU`EqBZCn}80ENNDQhEXlkV*%lJ90*fmQOD3S6gZksDsAHZSd1%g*hZUQ=tE)ux+KZ3@jC)Gf5}?P2In zk4-=6PMJ`k?2|-)6&@BQr)DfHv~3ilE3Y=z`H;0rw_ZLm^enIualVztPJiy+c5rHl z4&L@o=-h2S7PkuWmFhFjd^qXk6Lr%J<{o@s*CWQ8PnO3wVnW12W9|Yd1v_x9lXq^m zsW`auyYwB?NA>+WD)6PjH1gi}0>2u{5y?%`b)Tbt_}|*Q_GqZqHck;G&8d`7#wjwn zcF+ZrDVG=yqLOG#r4A)39oJ+|Tyt45?lid$sgy*K%Zx#0!Wbrlndy7? zOuC%4&ROgG?_1wG|IAw3-tY51&+qv?+urYfp65-VyOP#9dG`8<2bONtsd_?<2$>@; z^G2fQEl#^hzV>O%ymjw+Z&5Ft*isTf_c+FI+6mgU+|aXhnIXu9oU~sRDDTk0GakIO zf(aaOmlNaHK8-f`Fcfx3=E7sc$QIRA$O~JB!hb&2wrvVmOW5EN2%}}j(l#8mKYwg} z93}Yp?9eaIS2u22eluZ_Y4FtblDJ`COnd0{aVEW#9B<(75dUF}T~s_m zzj_!+cjX>hMTzX0(`E9V{#|QX^>Pjd=Q7`=@73FJ=i^(+L(9a^#36Gds!F>^sCgZBd=oi!OABO<|h{ghA!=UGf}{ixp-pg=9*{LYBy~Rog_5E zT$57=2IC6RNlh+Tp8ks@>XmABm#9X4hVS87AE_JO$78Yn+lhCb-3>q0=BKy*HZ;U- zQJ>k|-z?t{--B%tWUiU1Bg&0d&O}eUChN9#R2!d}x%VgBGn${GhEOP5Qt$rEPAdU^2diAcc$1rc6Pj+M6b8ntEmw%jL)vzE668x zwHyWnZ@E3|N=SZ8?_y59WW%nyNtAQF2E7l}cgZIt4nGC$?r8193gNlU%`(!x4L<(# zCW`Nf#K9UVkXMGUwoL`;1)~YuyS6tiEr^4SDV?lJK$Hc=S)OTM`ru&q``nf6vik)w zG%3R1%TTqzQjjk=M~DJ-V9ekvbNC~@fuTz2iY=N&l$ajx<|jLhP4-jN!InC8@Mt0o zspL+rYUaeQHe-_>Hgswi7%2E~T$4?$hGMOc%<4SaLu%TMH{YbCU`I2@*Pj%BX~QdK?3%1k4Zq?~Tgn=RhCE;Yj``PnWHx*nw-7ns|s#5wJ|X1y_C zu>xFgWCwWqltvz;<;(hc>(%uQ_nD_SA2W&|1ssxDmT>!i=_!e#2=U@aO5;Q`w;XC) zjPkmo$(bh?naR!tNz_=Qo!gwYT3h6W(E^T+15ULOptVY#UPD zV=ZBz2jkK`?ByRQ-)fV!)lBzkp7PNLCzHD}m*(ES)$i3hZac<4S7z&06Wv?4YYbk~ zWXzkL7=6zU;4U5X^n31#E`Oc(ah#MhSW+D~G;r&f(WJ&cBn_GFv3};v=u3(3;iXAge*4_7zjN zgm_aEI}_cEI)cYbQd@JK88QYMaoMZip|yN95yscd{GB~2nz{EUU0R#q%SyC0MjD8$ z8-aN2y6vf5GFeL7DoR~dFK?F4T`9ihkLvA)8hTzkE*k%CGu*E&V787wZhVMs#db}- zWHAtF**E@SQ*p6>&msO1gTi!yau1_Vk+ma|2lt*-X4WcB;0yK^X4Pi&yx)S`781RR z!7xlbZsp#5@VaS*Wb_NEwqTP)lZuDVqn$mWuY!zR8_NfL{FmunTz+qc?>=Z3!y-2Y zZ;tY&YNHy}YqjRi1Pij;wWlhL&wBdbRMfCK(x8U6zP5hkvXc{zvJ;;8umi zW_Ty`@aH9cxUl+|LD>4hi+kesO9W@8pFPoDcwm)Pw$+S6p2oO|G*b~C6{;QR>NL5b z_1&oAC5D@g!Ff`I3$i-spu`Q#f%Pk{bXgc*Xl(VmW5+A#N$!s!dls$@)NgW0iK1>9 zGIo@>*?iHtSa&DWnv_M(2`BTR$k~>xq=zh3_Wni>~405GpJLA&|ZU^}$`1b5IcQm|`+bsTqQai?; z@6sD~2uqroCIf6`-uCv~vqP;Y<=BQMA~W%b{9oH1kg2Bwv4Sw-tgbFt(zE1g+slB8 zUVG4@Z3s$dP9uZFdCU}ib_Q07<=vbenIk0P+FJPFC4+7bHVDk2;^vm&=+$JNt?rkx zpi1t`SOTa27f>>HZaxDn#Y+ACNGV!Y1cld#V!L4loAHS6CcJ^mo(0QS*U&NShx3Z2pwI*Gx^qk%kkMWe0TMm|U6L&yPcKIdne~BK>w`?Vk&;_9V zE?cdQYh^&5lLqPZ{lGp&6jcRW@?jHL3BV=tJk$PGV>7`jSzSTccJj^g)tf_n^{981xXjYE*N3*^2n!Rx87gxq4mUXV|6B6 zl9}+@(g1wCbw4_~@$^qioTOC3R0qN>*02vJoOCs4xb$0*wUdoakG)e{L91`Kp&Ojh z;d;I*xO;9+^)xr3ze%899y!>c*w|Es+%iPb$;h`n*IeVD6NFy;)(OQ^sig3xNYO zGI_W*V=NCA4oYD_t78;ZvJ*9uTquyL1>faffhPu?Mc%Yw5aIkL(--57{BYFaDPQw+s5uMuuvH(@cE2ar+I ze5i}gryx|-9Nzxt&tg?zpROPnzrVwgpJG2QAUW(_N=NhiI|M~H{M_$=1Tw}5-Z&y(}z_AN4QOf4-0MfKk@%f;IIn=zKa# zm0zj+s97%dDX!92Q~x6y7r+6O6HY#dU%mnr#Q`;QQa2z;o@}g8kT@Nc#Xx*HSR~Xe zExs9N-vN4}E1P{53*e+T4FKy7GxCo1-)72g;Rrp zLNP*}vmdbG^T1}&>=wH3zyKydB*mNvDFYWOvxuL}M>(MBh5~pe2!(khbWZdO37r7o zIS2s-XsHX$u<&}z)pzd5cO33+dloTOQr)3YtHQF+HgMRz8oVkz29Y@e9?Lgbf?#t1 zA3x`@a5fd(SHR;jEgOo~L*U(+$HmdX`VTi3Ma42fAc z6ApBi-j+&4i&W&cmZ{o8#*cyE$hw;l)k-b12z0?>jx&tHQJAPNX0q|sM~!qoUEmq&gN jdJoroX}vmgow3P4{haX) Want more detailed macro error diagnostics? This is especially useful for debugging tensor-related tests: + > + > ```bash + > RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Zmacro-backtrace" cargo run-checks + > ``` + +## Updating the burn semver version + +If for some reason you need to bump for the next version (though that should probably be left to the +maintainers), edit the semantic version number in `burn/Cargo.toml`, and then run `cargo update` to +update the lock file. + +## Contributing to either the Burn Book or Contributor Book + +Both the Burn Book and the Contributor Book are built with mdbook. To open the book locally, run +`mdbook serve ` or `cargo xtask books {burn|contributor} open` which will install and +use mdbook automatically. + +Alternatively, if you want to install mdbook directly, run the following command[^update_note]: + +```bash +cargo install mdbook +``` + +Also instead of running `cargo run-checks`, you can run `cargo xtask check typos` to only check +for misspellings. This will install [typo](https://crates.io/crates/typos-cli), and if any are +encountered you should be able to run `typo -w /path/to/book` to fix them. + +[^linux_mem_note]: + If your system is running into issues with memory and you are on linux, you may want to switch + to a [virtual console](https://wiki.archlinux.org/title/Linux_console#Virtual_consoles) to run + the tests. To do this, press `ctrl+alt+f3` to switch to a virtual console (and log in), and + either `ctrl+alt+f1` or `ctrl+alt+f2` to switch back to your graphical session. + +[^update_note]: + You might also want to install [cargo-update](https://github.com/nabijaczleweli/cargo-update) to + easily keep your tools up to date, though it is in no way required. diff --git a/contributor-book/src/getting-started/testing.md b/contributor-book/src/getting-started/testing.md new file mode 100644 index 0000000..bd73280 --- /dev/null +++ b/contributor-book/src/getting-started/testing.md @@ -0,0 +1,38 @@ +# Testing + +## Test for Tensor Operations + +Test for tensor operations (generally of the form: given this input, expect it match or approximate +this output) are defined only in +[`crates/burn-tensor/src/test/ops`](https://github.com/tracel-ai/burn/tree/81a67b6a0992b9b5c33cda8b9784570143b67319/crates/burn-tensor/src/tests/ops) +and not in the backends (with the exception of `burn-autodiff`). The tensor operation tests are +added to the `testgen_all` macro rule in +[`crates/burn-tensor/src/tests/mod.rs`](https://github.com/tracel-ai/burn/blob/81a67b6a0992b9b5c33cda8b9784570143b67319/crates/burn-tensor/src/tests/mod.rs). +This is then propagated to the existing backends without any additional work. + +### Test for Autodiff + +Tests for autodiff go under +[burn-autodiff/src/tests](https://github.com/tracel-ai/burn/tree/81a67b6a0992b9b5c33cda8b9784570143b67319/crates/burn-autodiff/src/tests) +and should verify backward pass correctness. For binary tensor operations, both the left and right +sides need to be verified. + +Here's an easy way to define tests for a new operation's backward pass: + +1. Use small tensors with simple values. +2. Pop open a terminal, launch `ipython` and import `numpy` then do the calculations by hand. You + can also use [Google Colab](https://colab.google/) so you don't have to install the packages on + your system. +3. Compare the actual outputs to the expected output for left-hand side, right-hand side. + +For float tensors, it is advised to use +`actual_output_tensor.into_data().assert_approx_eq::>(&expected_tensor_data, Tolerance::default())` +instead of `assert_eq!(...` due to occasional hiccups with floating point calculations. Other +assertions should also always use `FloatElem`, and use `.elem()` to convert any +literals. Backends are tested for multiple precisions, and hardcoding to a fixed type causes tests +to fail with alternate floating point precisions. For convenience, it might be worth aliasing the +type like `type FT = FloatElem;`. + +For integers, tests should use `IntElem`, and exit the test if the test values are +unrepresentable (above `max_value`, below `min_value`). A minimum range of `[0..127]` (`i8`) can be +assumed. diff --git a/contributor-book/src/guides/README.md b/contributor-book/src/guides/README.md new file mode 100644 index 0000000..a4d9635 --- /dev/null +++ b/contributor-book/src/guides/README.md @@ -0,0 +1,3 @@ +# Guides for Contributors + +The following guides are meant to help contributors accomplish specific tasks, such as adding new operations to Burn. \ No newline at end of file diff --git a/contributor-book/src/guides/adding-a-new-operation-to-burn.md b/contributor-book/src/guides/adding-a-new-operation-to-burn.md new file mode 100644 index 0000000..b9b28a2 --- /dev/null +++ b/contributor-book/src/guides/adding-a-new-operation-to-burn.md @@ -0,0 +1,246 @@ +# Adding a New Operation to burn + +Let's discuss how one might go about adding new operators to Burn, using the example of the pow +operator added in [this PR](https://github.com/tracel-ai/burn/pull/1133/files). + +## Adding the Op to burn-tensor + +`burn-tensor` is the crate that defines all tensor operations that need to be implemented by the +various backends. The core of this lies in +[crates/burn-backend/src/tensor/ops/numeric.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/tensor/ops/numeric.rs#L17), +which is home to the numeric trait. The numeric trait is the home of all tensor operations that are +numeric in nature and that are shared by `Int` and `Float` Tensor types. The numeric trait is +implemented in +[crates/burn-backend/src/tensor/ops/int.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/tensor/ops/int.rs) +for the int type and in +[crates/burn-backend/src/tensor/ops/float.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/tensor/ops/float.rs) +for the float type. More information on the relationship between Tensor modules can be found under +the section for [Tensor Architecture](../project-architecture/tensor.md#tensor-operations). + +Here is where pow was added to `crates/burn-tensor/src/tensor/api/numeric.rs`: + +1. for the + [`Tensor` struct](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/numeric.rs#L573) +2. for the + [numeric trait](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/numeric.rs#L1955) +3. for the implementation of numeric for + [float](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/numeric.rs#L2722) + and + [int](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/numeric.rs#L2375) + +Tensor is a struct that has a single member: `primitive` (defined +[here](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/base.rs#L27)), +that is defined by its +[`Kind`](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/kind.rs#L16): +one of `Bool`, `Float`, or `Int` (those linked in 3). These call the ops for that data type defined +in the +[`Backend`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/backend/base.rs#L64) +supertrait[^supertrait]. This is the trait that is then implemented by the different `burn-` +backends (such as `burn-flex` and `burn-wgpu`) which must implement the functions if no default +is provided. + +In this case, we don't need to worry about `Bool` Tensors. `Float` ops are implemented under +[crates/burn-backend/src/backend/ops/tensor.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/backend/ops/tensor.rs), +and `Int` ops under +[crates/burn-backend/src/backend/ops/int_tensor.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/backend/ops/int_tensor.rs). +The current convention is ops of each type, if not unique to that type, are prefixed with the type. +So `powf` and sundry would be defined as `int_powf` for `IntTensorOps` and `float_powf` for +`FloatTensorOps`. If an op is unique to a type, then it should be implemented under +`burn-tensor/src/api/{type}.rs`. For example, here is an implementation for +[`sin` under `crates/burn-tensor/src/api/float.rs`](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tensor/src/tensor/api/float.rs#L82) +which obviously doesn't make sense for `Int` or `Bool` tensors. + +The `Int` Tensor function uses the ones defined for Float with 2 extra casts (LHS to a `Float` +tensor, Output to an `Int`). Given that the rest of the code will only look at the float +implementations. + +With the addition of quantized float tensors, the `Float` tensor primitive is represented by the +[`TensorPrimitive`](https://github.com/tracel-ai/burn/blob/a6a5c22e0db56d947b9165d4dae42783a5a6b689/crates/burn-tensor/src/tensor/api/kind.rs#L69) +enum. This allows us to handle both float and quantized float operations in the `Tensor` +implementation, correctly dispatching to the corresponding op (float or quantized) based on the +variant. Following the same convention, the equivalent +[quantized tensor ops](https://github.com/tracel-ai/burn/blob/a6a5c22e0db56d947b9165d4dae42783a5a6b689/crates/burn-tensor/src/tensor/ops/qtensor.rs#L45) +are prefixed with `q_*` (e.g., `q_reshape` instead of `float_reshape`). Most ops have a default +implementation that simply dequantizes the input into its floating-point representation, performs +the operation on the float tensor, and quantizes the output. Backends can overwrite specific +implementations when required/desired. + +### Adding Tests + +Additional tests should be added to `burn-backend-tests` under +[`crates/burn-backend-tests/tests/tensor/{float_or_int}/ops/{op_name}.rs`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend-tests/tests/tensor/float/ops/powf.rs), +and the module name should be inserted into +`crates/burn-backend-tests/tests/tensor/{float_or_int}/ops/mod.rs`. + +If it makes sense for a floating point operation to support quantization, the +[`QTensorOps`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend/src/backend/ops/qtensor.rs#L117) +counterpart is usually added at the same time with a default implementation (as mentioned in the +previous section). Tests for `q_*` ops follow a similar procedure: the test is added under +[`crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/{op_name}.rs`](https://github.com/tracel-ai/burn/tree/9f31281/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended), +the module name is inserted into +[`crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs). +If you take a look at any of the existing tests for an operation on a quantized tensor, you will see +that the inputs and expected outputs are always defined with floating point values. While it assumes +that the quantization and dequantization are correct, it makes the tests much more readable and +easier to understand w.r.t. what is being tested. Effectively, the tests are there to ensure that a +tensor operation is invariant to quantization (up to some quantization error, of course). + +_Note: the tests try to use tensors with floating point values which can be de/quantized without +introducing too much quantization error, but the result always depends on the operation (e.g., +tensor product of values can grow larger and significantly increase the output tensor range, leading +to more de/quantization error on the results)._ + +## Adding the Op to burn-autodiff + +Since this is probably the hardest and the least straightforward, we'll cover this backend +separately. `burn-autodiff` enables other backends to use autodifferentiation[^autodiff]. Ops for +float types are implemented in +[crates/burn-autodiff/src/ops/tensor.rs](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-autodiff/src/ops/tensor.rs) +and need to: + +1. Define a unit struct [^absolute_units] that implements a backward (pass) function +2. Within the backward function, as this is an elementwise binary operation it implements the binary + function (from `backward.rs` under the same directory), the last 2 arguments are two closures + that define the left and right partial derivatives. +3. Then define what happens when a specific operation is tracked or untracked, where untracked just + calls the function in the normal way, and tracked sets the execution the backward function + defined above. +4. When tracked, operations are part of the autodiff graph and must save the needed information to + efficiently perform their backward pass later. If the information is light (such as a shape), it + should be directly saved in the state. If the operation's inputs are needed to compute the + backward pass, it should be checkpointed rather than saved. This will allow the input to be + provided lazily at the backward pass depending on the checkpointing strategy. +5. An operation must also be identified as _compute-bound_ (`.computeBound()`) or _memory-bound_ + (`.memoryBound()`) for gradient checkpointing. _Compute-bound_ operation are heavy to compute + (for instance matmul or convolution), which means that even with checkpointing they will save + their output for the backward pass and not recompute it. _Memory-bound_ operations are more + trivial (like `powf` which only performs one small operation per tensor entry), so it can be + beneficial to recompute them during the backward pass instead of saving their whole forward + output to memory. Operations registered as _memory-bound_ need to know their parents + (`.parents()` method) and how to recompute their forward pass during the backward pass (with a + struct that implements `RetroForward`), using their parents' outputs. + +The above steps are mostly boilerplate, so you can often just copy the contents of another similar +op, change the name of the structs, and ensure that either both sides have the data they need (if +they need to have a copy of the opposite sided tensor, clone its contents). + +### Computing derivatives + +For those that need it, here is a quick refresher on the necessary calculus. If you are familiar +with how to calculate partial derivatives, you can skip this section. + +Since `pow` is a binary operation, the left and right functions are the partial derivatives with +respect to the left and right sided tensors. + +Let's define the operator as a function \\(f(x,y)=x^{y}\\) , where \\(x\\) is the left hand tensor +and \\(y\\) is the right handed tensor. The two closures are defining the partial derivatives of +\\(f\\) with respect to \\(x\\),\\(y\\). Treat the other variables as a constant + +$$\frac{\delta }{\delta x} (x^{y})= y \cdot x^{y-1}$$ is the left handed closure, and + +$$\frac{\delta }{\delta y} (x^{y}) = x^{y} \cdot ln(x)$$ + +is the right. If you aren't sure how to calculate these by hand, it is recommended to use +[symbolab](), +plug in your operator in terms of \\(x\\) and \\(y\\), and just swap out the variable +\\(x\\)|\\(y\\) in the partial derivative to get the other side. + +### Testing autodiff + +For testing the `autodiff` operations, please refer to +[this section](../getting-started/testing.md). + +## Adding the Op to other backends + +Most of these are fairly straightforward implementations. For reference here's pow's float +implementation for torch and flex backends: + +1. Torch implementation in + [crates/burn-tch/src/ops/tensor.rs](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tch/src/ops/tensor.rs#L467) + and the Op used in + [crates/burn-tch/src/ops/base.rs](https://github.com/tracel-ai/burn/blob/0ee2021567b3725907df5fd1a905ce60b1aca096/crates/burn-tch/src/ops/base.rs#L481) +2. Flex in + [crates/burn-flex/src/ops/float.rs](https://github.com/tracel-ai/burn/blob/main/crates/burn-flex/src/ops/float.rs) + +This is where any calculation happens currently. Playing a guessing game with method names and +seeing what completions are suggested will take you far. If you are having trouble figuring out how +to do it from the docs for that backend, +[try searching github for relevant function calls](https://docs.github.com/en/search-github/github-code-search/understanding-github-code-search-syntax). + +## Adding the Op to fusion, JIT and cubecl backends + +Adding an operator to these backends can be fairly straightforward, though due to what these +backends are for, involves a bit more indirection. Fusion and jit, like autodiff, are not target +backends as much as backends that enable certain functionality for other backends, in this case +kernel fusion or just-in-time compilation. Adding the operator won't involve doing any calculation, +you'll just be describing how the generated code should look. Most of this can be +copy/pasted/adjusted from other functions. + +Here's how powf was added to `burn-fusion`: + +1. Added powf to the float ops under + [crates/burn-fusion/src/ops/tensor.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-fusion/src/ops/tensor.rs#L2061) +2. Added powf to the `NumericOperationIr` enum under + [crates/burn-ir/src/operation.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-ir/src/operation.rs#L564) +3. Added powf to the implementations of `NumericOperationIr` enum under + [crates/burn-ir/src/operation.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-ir/src/operation.rs#L1086) +4. Added powf to the implemented of `NumericOperationIr` enum under + [burn/crates/burn-fusion/src/stream/context.rs](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-fusion/src/stream/context.rs#L883) + +The way `cubecl` handles tensor-scalar operations is by transforming both into a sequence of +vectorized scalar operations. Since powf already existed in `cubecl`, it was pretty easy to reuse +the existing implementation for the situation where both sides of the operation were tensors. The +`cubecl` crate is primarily concerned with how the operation is compiled and executed by the gpu. +The actual implementation is defined in `burn-cubecl`. + +Here is where code was added for powf in `burn-cubecl` and `cubecl`: + +1. to the implementation of + [`FloatTensorOps` under `burn/crates/burn-cubecl/src/ops/tensor.rs`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-cubecl/src/ops/tensor.rs#L578) +2. the function being called was added to + [`burn/crates/burn-cubecl/src/ops/numeric.rs`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-cubecl/src/ops/numeric.rs#L211-L214) +3. the operator was defined in + [`cubecl/crates/cubecl-ir/src/arithmetic.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-ir/src/arithmetic.rs#L41) +4. how the operation looks to the gpu was added to + [`burn/crates/burn-cubecl-fusion/src/engine/codegen/ir.rs`](https://github.com/tracel-ai/burn/blob/9f31281/crates/burn-cubecl-fusion/src/engine/codegen/ir.rs#L97) +5. the mappings between the gpu operation and the CPP, WGSL and SPIR-V instructions were added to + [`cubecl/crates/cubecl-cpp/src/shared/base.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-cpp/src/shared/base.rs#L1285), + [`cubecl/crates/cubecl-wgpu/src/compiler/wgsl/compiler.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-wgpu/src/compiler/wgsl/compiler.rs#L869) + and + [`cubecl/crates/cubecl-spirv/src/arithmetic.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-spirv/src/arithmetic.rs#L491) +6. the instructions themselves were added for WGSL to + [instruction op enum in `cubecl/crates/cubecl-wgpu/src/compiler/wgsl/instructions.rs`](https://github.com/tracel-ai/cubecl/blob/f5b63076a01a5c03ea9ed20799d3eeaf776b45da/crates/cubecl-wgpu/src/compiler/wgsl/instructions.rs#L124), + and the actual + [instruction in wgsl here](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-wgpu/src/compiler/wgsl/instructions.rs#L654), + for CPP in the enum here + [`cubecl/crates/cubecl-cpp/src/shared/instruction.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-cpp/src/shared/instruction.rs#L187) + and the actual instruction here + [`cubecl/crates/cubecl-cpp/src/shared/binary.rs`](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-cpp/src/shared/binary.rs#L216) + +We needed to generate some custom WGSL code for powf in WGSL, primarily due to issues with proper +case handling of the wgsl pow function, like 0 to the 0 power being 1, and any negative number to an +even power being positive. We reused as much as the existing logic as possible, and then branched at +the last point based off the var type of the rhs. +[See here](https://github.com/tracel-ai/cubecl/blob/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/crates/cubecl-wgpu/src/compiler/wgsl/compiler.rs#L1229). +For most operations, you shouldn't need to add to `cubecl-wgpu/src/compiler/wgsl/extension.rs` +unless the operation isn't native to WGSL. + +For functions that need a complex kernel without a direct mapping to a base instruction, simply use +the `cube` macro (see +[the `cubecl` book](https://github.com/tracel-ai/cubecl/tree/88c0c6f781f70ad2f6e9981fd0cbe2e87e153a35/cubecl-book)). + +And you're done! Congrats, you just fully added a new operation to burn, and we are all one step +closer to the answer to [Are we learning yet?](https://www.arewelearningyet.com/) being "Yes, and +it's freaking fast!". Buy yourself a coffee. + +[^supertrait]: + for more on supertraits see + [the advanced trait section of the rust book](https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#using-supertraits-to-require-one-traits-functionality-within-another-trait) + +[^autodiff]: + wiki link for + [automatic differentiation](https://en.wikipedia.org/wiki/Automatic_differentiation) + +[^absolute_units]: + for more information on unit structs see + [the defining and instantiating structs section of the rust book](https://doc.rust-lang.org/book/ch05-01-defining-structs.html#unit-like-structs-without-any-fields) diff --git a/contributor-book/src/guides/submitting-examples.md b/contributor-book/src/guides/submitting-examples.md new file mode 100644 index 0000000..12f32b1 --- /dev/null +++ b/contributor-book/src/guides/submitting-examples.md @@ -0,0 +1,108 @@ +# Submitting Examples to Burn + +This guide explains how to create and submit new examples to the Burn repository. Examples are a great way to demonstrate Burn's capabilities and help users understand how to use the framework effectively. + +For a minimal working example, see the [simple-regression](https://github.com/tracel-ai/burn/blob/main/examples/simple-regression/examples/regression.rs) example in the repository. + +## Repository Structure + +The Burn repository is set up as a workspace, with examples located in the `examples/` directory. Each example is a separate crate that can reuse workspace dependencies. + +## Creating a New Example + +1. Navigate to the examples directory: + ```bash + cd examples + ``` + +2. Create a new library crate: + ```bash + cargo new --lib + ``` + +3. Update the example's `Cargo.toml`: + ```toml + [package] + name = "" + version = "0.1.0" + edition = "2021" + readme = "README.md" + # Remove this line if it exists + # readme.workspace = true + + [dependencies] + # Reuse workspace dependencies when available + serde = { workspace = true } + # Add example-specific dependencies + burn = { path = "../../" } + ``` + +## Required Files and Structure + +### README.md +Each example must include a README.md file with: +- A brief description of what the example demonstrates +- A terminal command showing how to run the example +- Any prerequisites or setup instructions + +Example README structure: +````markdown +# Example Name + +Brief description of what this example demonstrates. + +## Running the Example + +```bash +cargo run --example +``` + +## Prerequisites + +List any prerequisites here. +```` + +### Source Code Structure + +- `src/` directory: Contains the main implementation code +- `examples/` directory: Contains example code + - `.rs`: Example implementation + +## Resource Handling + +- Resources (datasets, models, etc.) should be downloaded in the example code +- Do not track external files in the repository +- Include code to download and prepare resources when the example is run + +## Best Practices + +1. **Code Organization** + - Keep the code modular and well-documented + - Use clear, descriptive variable and function names + - Include comments explaining complex operations + +2. **Error Handling** + - Implement proper error handling + - Provide meaningful error messages + - Handle resource download failures gracefully + +3. **Performance** + - Optimize for reasonable execution time + - Include progress indicators for long-running operations + - Consider adding configuration options for different hardware capabilities + +4. **Documentation** + - Document all public APIs + - Include inline comments for complex logic + - Explain any non-obvious implementation details + +## Submitting Your Example + +1. Ensure your example follows all the guidelines above +2. Test your example thoroughly +3. Create a pull request with: + - A clear description of what the example demonstrates + - Any relevant issue numbers + - Screenshots or output examples (if applicable) + +Feel free to ask questions in the pull request if you need clarification or guidance. \ No newline at end of file diff --git a/contributor-book/src/how-to-read-this-book.md b/contributor-book/src/how-to-read-this-book.md new file mode 100644 index 0000000..4e23cf6 --- /dev/null +++ b/contributor-book/src/how-to-read-this-book.md @@ -0,0 +1,22 @@ +# How to read this book + +Throughout this book, we maintain the following structure. + +## Linking + +When referring to structures or functions within codebase, we provide permalinks to the lines in +specific commits, and indicate them by the relative path of their parent file from the project root. +For example this is a reference to the `Tensor` struct in +[`crates/burn-tensor/src/tensor/api/base.rs`](https://github.com/tracel-ai/burn/blob/e303e31c8bc85486690ff80df65d1e25e16728c4/crates/burn-tensor/src/tensor/api/base.rs#L27) + +When some reference information is useful but is beyond the scope of contributing to Burn, we +provide that information in a footnote. To build on the previous example, the `Tensor` mentioned is +what's referred to as a newtype struct[^1]. + +Direct hyperlinks are for tools and resources that are not part of the Burn project, but are useful +for contributing to it. For example, when working on implementing an operation for autodiff, it can +be useful to use [symbolab](https://www.symbolab.com/) to calculate the left and right partial +derivatives. + +[^1]: For more information on newtype please refer to + [the Advanced Types chapter of the Rust Book](https://doc.rust-lang.org/book/ch19-04-advanced-types.html#using-the-newtype-pattern-for-type-safety-and-abstraction) diff --git a/contributor-book/src/overview.md b/contributor-book/src/overview.md new file mode 100644 index 0000000..aa602df --- /dev/null +++ b/contributor-book/src/overview.md @@ -0,0 +1,28 @@ +# Overview + +Welcome to The Burn Contributor's Book 👋 + +This book will help you get acquainted with the internals of the Burn deep learning framework and +provide some detailed guidance on how to contribute to the project. Before opening a PR, please read +the [Contributing Guidelines](https://github.com/tracel-ai/burn/blob/main/CONTRIBUTING.md). + +We have crafted some sections for you: + +- [Getting Started](./getting-started): Much like the [Burn Book](https://burn.dev/books/burn/) which + targets users, we'll start with the fundamentals, guiding you through tasks like setting up the + development environment, running tests, and what you should check prior to each commit. + +- [Project Architecture](./project-architecture): This section will give you an in-depth look at the + architecture of Burn. + +- [Guides](./guides): We provide some guides on how to do specific tasks, such as adding a new + operations to Burn. + +- [Frequently Encountered Issues](./frequently-encountered-issues): If you are running into an issue + that has you stumped, this is the section to check out prior to asking on the + [Discord](https://discord.gg/uPEBbYYDB6). It's a collection of errors encountered by contributors, + what caused them, and how they were resolved. + +As this book is geared towards contributors and not towards users of Burn, we'll assume you have a +good understanding of software development, but will make efforts to explain anything outside of +that scope, or at least provide links to resources that explain it better than we can. diff --git a/contributor-book/src/project-architecture/README.md b/contributor-book/src/project-architecture/README.md new file mode 100644 index 0000000..d65f714 --- /dev/null +++ b/contributor-book/src/project-architecture/README.md @@ -0,0 +1,19 @@ +# Project Architecture + +This section documents most major architectural decisions with the reasoning behind them. + +**Sections** + +- [Module](./module.md) + - [Optimization](./module.md#optimization) + - [Constraints](./module.md#constraints) + - [Solution](./module.md#solution) +- [Serialization](./serialization.md) + - [Constraints](./serialization.md#constraints) + - [Solution](./serialization.md#solution) + - [Pros](./serialization.md#pros) + - [Cons](./serialization.md#cons) + - [Compatibility](./serialization.md#compatibility) +- [Tensor](./tensor.md) +- [Backend](./backend.md) + - [Autodiff](./backend.md#autodiff) diff --git a/contributor-book/src/project-architecture/backend.md b/contributor-book/src/project-architecture/backend.md new file mode 100644 index 0000000..47cea11 --- /dev/null +++ b/contributor-book/src/project-architecture/backend.md @@ -0,0 +1,45 @@ +# Backend + +The Backend trait abstracts multiple things: + +- Device type +- Float tensor type +- Bool tensor type +- Int tensor type +- Float element type +- Int element type +- Float tensor operations (kernels) +- Int tensor operations (kernels) +- Bool tensor operations (kernels) + +## Element types + +> Warning: there are plans to change this architecture in the near future. + +Even though having one type for tensors is convenient for the tensor API, it can be cumbersome when +implementing a backend. Therefore, backends can decide, through associated types, what types they +want to use for their int, float, and bool tensors. Since float and int can have multiple +precisions, the float and int element types are also associated types that must be declared by the +backend. + +Note that the backend chooses the precision and not the user. Since not all backends will support +the same element types, no assumptions must be made. Therefore, there are no methods on tensors to +change the precision, except for the `to_full_precision` function, which ensures numerical stability +on the current backend. Backend implementations can provide a way to choose the precision, which can +be accomplished with a generic parameter (e.g. `LibTorch`). + +## Operations + +To be as general as possible, tensor operations are implemented as plain functions. There is no +object or self, just functions that take tensors as input and often return tensors as output as +well. Backend implementations are free to use their own patterns to implement these kernels. Note +that Burn is a dynamic graph deep learning framework, so backends may have to implement asynchronous +kernel executions for performance reasons. + +## Autodiff + +As of now, there is only one backend decorator that supports autodiff. It follows the decorator +pattern, making any backend differentiable. However, the `AutodiffBackend` trait abstracts how +gradients are calculated, and other approaches to autodiff might be added later. For more +information about how the current autodiff backend works, you can read this (slightly outdated) +[blog post](https://burn.dev/blog/burn-rusty-approach-to-tensor-handling). diff --git a/contributor-book/src/project-architecture/module-serialization.png b/contributor-book/src/project-architecture/module-serialization.png new file mode 100644 index 0000000000000000000000000000000000000000..8d5e82951e7701b96ce6bdd7b2b1300f4161befa GIT binary patch literal 114065 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�QU8ureg71MWeq=!86sV~d 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 n0lm^-&@F{#H<wBS3j3^P6 │ +│ dtype, shape, data_offsets, optional param_id │ +│ scalars : map │ +│ metadata : map user key/value pairs │ +├────────────────────────────────────────────────────────────┤ +│ Tensor data section │ +│ each tensor's bytes start on a 256-byte boundary so the │ +│ data can be sliced zero-copy / memory-mapped from a file │ +└────────────────────────────────────────────────────────────┘ +``` + +All multi-byte integers are little-endian. Tensor entries carry an optional `param_id` used to +preserve a parameter's identity across save/load. Besides tensors, a pack can store named **typed +scalars** (integers, floats, booleans), which the optimizer and learning rate scheduler records use +to persist their non-tensor state. + +A pack is written with `burn_pack::Writer` and read back with `burn_pack::Reader`, both operating on +`burn_pack::Tensor` entries plus a scalar map. + +## The three record types + +Higher layers bridge their state to and from burnpack through three record types. Each one can be +serialized to a file (`save` / `load`, appending the `.bpk` extension when the path has none) or to +an in-memory byte buffer (`into_bytes` / `from_bytes`). + +### `ModuleRecord` (`burn-core`, `burn::store`) + +Holds a module's parameters: a flat list of `(path, ParamId, TensorData)` entries keyed by module +path. It is produced and applied through the [`Module`] trait itself rather than a separate codegen +type: + +- `module.into_record()` walks the module with a `ModuleVisitor` (the `Collector`), recording each + float/int/bool parameter under its dotted path. +- `module.load_record(record)` (or the fallible `try_load_record`) walks the module with a + `ModuleMapper` that looks each parameter up by path and loads the matching tensor. + +Load-time behavior is configured with builder methods on the record, ignored when saving: + +- `allow_partial(bool)` — tolerate module parameters absent from the record. +- `validate(bool)` — toggle shape-mismatch / missing-tensor validation. +- `with_dtype_policy(..)` / `cast_to_module_dtype()` — choose whether a parameter adopts the + record's dtype (`DTypePolicy::FromRecord`, the default) or casts the data to the module + parameter's current dtype (`DTypePolicy::CastToModule`). + +The save-side dtype is not configurable: the record stores whatever dtype the module currently +holds. The dtype applied on load is controlled by the record's `DTypePolicy` +(`.cast_to_module_dtype()` / `.with_dtype_policy(..)`). + +This module in `burn-core` is intentionally tiny — no filtering, key remapping, or adapters. The +richer snapshot/import tooling (filtering, key remapping, PyTorch/SafeTensors cross-framework stores) +lives in the `burn-store` crate. + +### `OptimizerRecord` (`burn-optim`) + +Holds an optimizer's per-parameter state. Unlike a module record (keyed by module path), it is keyed +per parameter: each parameter's state is decomposed into tensors named `"{param_id}.{field}"` plus a +few typed scalar entries kept in the burnpack scalar map (including a `__rank` scalar so the state +can be reconstructed without inferring rank from tensor shapes). + +- `optimizer.to_record()` flattens each parameter's `DynState` into tensors and scalars. +- `optimizer.load_record(record)` reconstructs the states (no device argument: tensors load on the + default device and migrate to each parameter's device on the next step). + +### `LrSchedulerRecord` (`burn-optim`) + +Holds a learning rate scheduler's state, which is just a handful of scalars (step counters, current +learning rate) and no tensors. Produced/applied through the [`LrScheduler`] trait's `to_record()` / +`load_record()`. Composed schedulers nest their children's records under an index prefix +(`with_record` / `record`). + +## Checkpointing in `burn-train` + +During training, `burn-train` defines a `Checkpoint` trait (`save(path)` / `load(path)`) implemented +for all three record types — `ModuleRecord`, `OptimizerRecord`, `LrSchedulerRecord` — and for `()` +(a stateless no-op). The `Checkpointer` trait drives periodic saves; the +`FileCheckpointer` writes each record to `{name}-{epoch}.bpk` under the experiment directory. This is +how the model, optimizer, and scheduler are persisted and restored across epochs. + +## Notes + +- There is no `Recorder`, `PrecisionSettings`, `Module::Record` associated type, or `#[derive(Record)]` + any more. All of those were part of the previous serde-based record system, which has been removed. +- Cross-framework import/export (PyTorch `.pt`, SafeTensors) still lives in `burn-store` + (`PytorchStore`, `SafetensorsStore`, `BurnpackStore`). + +[`TensorData`]: https://burn.dev/docs/burn/tensor/struct.TensorData.html +[`Module`]: https://burn.dev/docs/burn/module/trait.Module.html +[`LrScheduler`]: https://burn.dev/docs/burn/lr_scheduler/trait.LrScheduler.html diff --git a/contributor-book/src/project-architecture/tensor.md b/contributor-book/src/project-architecture/tensor.md new file mode 100644 index 0000000..6bee357 --- /dev/null +++ b/contributor-book/src/project-architecture/tensor.md @@ -0,0 +1,65 @@ +# Tensor + +A proper deep learning framework should have a fast tensor implementation with autodiff support, and +Burn is no exception. The tensor API abstracts away backend implementation details and focuses on +usability without compromising performance. To make it as easy as possible to use, there is only one +tensor type, which is different from multiple tensor and deep learning crates in Rust. Generic +parameters are used instead to specialize the tensor type. + +- **B: Backend:** The first argument is the backend on which the tensor implementation lies. +- **const D: usize:** The second argument is the dimensionality of the tensor. +- **K: TensorKind:** The third argument is the tensor kind, which can be either Float, Int or Bool. + By default, the tensor kind is set to Float, so for most tensors, the kind argument is not + necessary. + +Having one struct for tensors reduces the complexity of the tensor API, which also means less +duplicated documentation to write and maintain. + +Tensors are thread-safe, which means that you can send a tensor to another thread, and everything +will work, including auto-differentiation. Note that there are no explicit in-place tensor +operations since all tensor operations take owned tensors as parameters, which make it possible to +mutate them. Tensors can be shared simply by cloning them, but if there is only one reference to a +tensor, the backend implementation is free to reuse the tensor's allocated data. For more +information about how it is done, you can have a look at this +[blog post](https://burn.dev/blog/burn-rusty-approach-to-tensor-handling). + +## Tensor Operations + +Operations on Tensors (sometimes shortened to Ops) are defined in traits (generally part of the +Backend Supertrait) and implemented for the Tensor struct. The appropriate parent trait of an +operation depends on the type of operation: + +- `base` => All tensor kinds should implement these operations (reshape, into_data, etc.). The + implementation is in + [crates/burn-tensor/src/tensor/api/base.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/api/base.rs). +- `numeric` => All tensors that are numeric by nature should implement these operations (Add, Sub, + Div, etc.). The implementation is in + [crates/burn-tensor/src/tensor/api/numeric.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/api/numeric.rs). +- `Float` => Tensor operations are only available for float tensors. The implementation is in + [burn-tensor/src/tensor/api/float.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/api/float.rs). +- `Int` => Tensor operations are only available for int tensors. The implementation is in + [burn-tensor/src/tensor/api/int.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/api/int.rs). +- `bool` => Tensor operations are only available for bool tensors. The implementation is in + [burn-tensor/src/tensor/api/bool.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/api/bool.rs). + +`Numeric` is directly implemented for `Float` and `Int` tensors, and in general, The implementations +for these methods are calling the corresponding `{Int|Float}` method defined in the backend +supertrait. + +Anything that is implemented by numeric should have an implementation in the `{Int|Float}` traits, +though it may be avoidable if the operation for one type requires casting to the other type. To +provide an example, `powf` should be implemented for `Int` tensors, but it should not be an Int +Tensor Operation. The LHS should be converted to a float, and the output should be converted back to +an int. So it's possible to avoid implementing `IntTensorOp` altogether. + +Additionally there are some operations that should be defined as functions instead of tensor op +methods. These are: + +`module` => These should be exported as functions instead of methods on tensors. The implementation +is in +[crates/burn-tensor/src/tensor/ops/module.rs](https://github.com/tracel-ai/burn/tree/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/ops/modules). +`activation` => These should also be exported as functions instead of methods on tensors. The +implementation is in +[crates/burn-tensor/src/tensor/ops/activation.rs](https://github.com/tracel-ai/burn/blob/6d96e8d8086d2309c425f2c8a43a8246f8c454d2/crates/burn-tensor/src/tensor/ops/activation.rs). +Note that some activations are just a combination of backend operations and are not declared in +there. diff --git a/crates/burn-autodiff/Cargo.toml b/crates/burn-autodiff/Cargo.toml new file mode 100644 index 0000000..ddc00e9 --- /dev/null +++ b/crates/burn-autodiff/Cargo.toml @@ -0,0 +1,47 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Automatic differentiation backend for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-autodiff" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-autodiff" +documentation = "https://docs.rs/burn-autodiff" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "tracing"] +std = ["dep:parking_lot"] +export_tests = [] # check checkpointer is_empty in tests + +tracing = [ + "dep:tracing", + "burn-std/tracing", + "burn-backend/tracing", +] + +[dependencies] +burn-std = { workspace = true } +burn-backend = { workspace = true } + + +derive-new = { workspace = true } +spin = { workspace = true } +parking_lot = { workspace = true, optional = true } +log = { workspace = true } +hashbrown = { workspace = true } +num-traits = { workspace = true } +portable-atomic = { workspace = true } +tracing = { workspace = true, optional = true, features = ["default"] } + +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +portable-atomic-util = { workspace = true } + +[package.metadata.docs.rs] +features = ["default"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-autodiff/LICENSE-APACHE b/crates/burn-autodiff/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-autodiff/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-autodiff/LICENSE-MIT b/crates/burn-autodiff/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-autodiff/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-autodiff/README.md b/crates/burn-autodiff/README.md new file mode 100644 index 0000000..0859a5c --- /dev/null +++ b/crates/burn-autodiff/README.md @@ -0,0 +1,8 @@ +# Burn Autodiff + +> [Burn](https://github.com/tracel-ai/burn) autodiff backend + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-autodiff.svg)](https://crates.io/crates/burn-autodiff) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-autodiff/blob/master/README.md) + +For now only first order reverse mode autodiff is supported. diff --git a/crates/burn-autodiff/src/backend.rs b/crates/burn-autodiff/src/backend.rs new file mode 100644 index 0000000..66efef1 --- /dev/null +++ b/crates/burn-autodiff/src/backend.rs @@ -0,0 +1,171 @@ +use crate::{ + checkpoint::strategy::{CheckpointStrategy, NoCheckpointing}, + grads::Gradients, + tensor::AutodiffTensor, +}; +use alloc::{format, string::String}; +use core::marker::PhantomData; + +use burn_backend::{ + backend::{AutodiffBackend, Backend, BackendTypes, ExecutionError}, + tensor::{BoolTensor, IntTensor, QuantizedTensor}, +}; + +use burn_backend::distributed::{DistributedParamId, DistributedParams}; + +/// Enable auto-differentiation on a backend. +/// +/// This works as a backend decorator, extending the functionality of any backend with +/// backpropagation. +#[derive(Clone, Copy, Debug, Default)] +pub struct Autodiff { + _b: PhantomData, + _checkpoint_strategy: PhantomData, +} + +impl BackendTypes for Autodiff { + type Device = B::Device; + + type FloatTensorPrimitive = AutodiffTensor; + + type IntTensorPrimitive = B::IntTensorPrimitive; + + type BoolTensorPrimitive = B::BoolTensorPrimitive; + + type QuantizedTensorPrimitive = B::QuantizedTensorPrimitive; + + // A replayed graph would skip re-recording the autodiff tape, so capture + // is not supported under autodiff. + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for Autodiff { + fn ad_enabled(_device: &Self::Device) -> bool { + true + } + + fn name(device: &Self::Device) -> String { + format!("autodiff<{}>", B::name(device)) + } + + fn seed(device: &B::Device, seed: u64) { + B::seed(device, seed) + } + + fn sync(device: &B::Device) -> Result<(), ExecutionError> { + B::sync(device) + } + + fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + device: &Self::Device, + input: Input, + func: Func, + ) -> Output { + B::memory_persistent_allocations(device, input, func) + } + + fn memory_cleanup(device: &Self::Device) { + B::memory_cleanup(device) + } + + fn staging<'a, Iter>(data: Iter, device: &Self::Device) + where + Iter: Iterator, + { + B::staging(data, device); + } + + fn supports_dtype(device: &Self::Device, dtype: burn_std::DType) -> bool { + B::supports_dtype(device, dtype) + } + + fn dtype_usage(device: &Self::Device, dtype: burn_std::DType) -> burn_backend::DTypeUsageSet { + B::dtype_usage(device, dtype) + } + + fn device_count(type_id: u16) -> usize { + B::device_count(type_id) + } + + fn flush(device: &Self::Device) { + B::flush(device) + } +} + +impl AutodiffBackend for Autodiff { + type InnerBackend = B; + type Gradients = Gradients; + + fn backward(tensor: AutodiffTensor) -> Gradients { + tensor.backward() + } + + fn grad(tensor: &AutodiffTensor, grads: &Gradients) -> Option { + tensor.grad(grads) + } + + fn grad_remove( + tensor: &AutodiffTensor, + grads: &mut Gradients, + ) -> Option { + tensor.grad_remove(grads) + } + fn inner(tensor: AutodiffTensor) -> B::FloatTensorPrimitive { + tensor.primitive + } + + fn from_inner(tensor: B::FloatTensorPrimitive) -> AutodiffTensor { + AutodiffTensor::new(tensor) + } + + fn grad_replace( + tensor: &AutodiffTensor, + grads: &mut Self::Gradients, + grad: B::FloatTensorPrimitive, + ) { + tensor.grad_replace(grads, grad); + } + + fn int_inner(tensor: IntTensor) -> IntTensor { + tensor + } + + fn bool_inner(tensor: BoolTensor) -> BoolTensor { + tensor + } + + fn int_from_inner(tensor: IntTensor) -> IntTensor { + tensor + } + + fn bool_from_inner(tensor: BoolTensor) -> BoolTensor { + tensor + } + + fn q_inner(tensor: QuantizedTensor) -> QuantizedTensor { + tensor + } + + fn q_from_inner(tensor: QuantizedTensor) -> QuantizedTensor { + tensor + } + + fn set_distributed_params( + tensor: AutodiffTensor, + param_id: DistributedParamId, + ) -> AutodiffTensor { + tensor.grad_distributed(param_id) + } + + fn distributed_params(tensor: &AutodiffTensor) -> Option { + tensor.node.distributed_params.clone() + } + + fn is_distributed(tensor: &AutodiffTensor) -> bool { + tensor.node.distributed_params.is_some() + } +} diff --git a/crates/burn-autodiff/src/checkpoint/base.rs b/crates/burn-autodiff/src/checkpoint/base.rs new file mode 100644 index 0000000..6663a87 --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/base.rs @@ -0,0 +1,92 @@ +use super::{ + retro_forward::RetroForwards, + state::{BackwardStates, State}, +}; +use crate::collections::HashMap; +use crate::graph::NodeId; + +use alloc::{format, vec, vec::Vec}; +use burn_std::config::{autodiff::AutodiffLogLevel, log_autodiff}; + +#[derive(new, Debug)] +/// Links a [NodeId] to its autodiff graph [NodeRef] +pub(crate) struct NodeTree { + map: HashMap>, +} + +impl NodeTree { + /// Gives the parents of the node in the autodiff graph + pub(crate) fn parents(&self, node_id: &NodeId) -> Option> { + self.map.get(node_id).cloned() + } +} + +#[derive(new, Debug)] +/// Struct responsible of fetching the output for a node in the autodiff graph during a backward pass +pub struct Checkpointer { + backward_states: BackwardStates, + retro_forwards: RetroForwards, + node_tree: NodeTree, +} + +impl Checkpointer { + /// Gives the output of the given node, by recursively asking parents to compute themselves + /// or give their pre-computed tensors. + pub fn retrieve_node_output(&mut self, node_id: NodeId) -> T + where + T: Clone + Send + 'static, + { + let sorted = self.topological_sort(node_id); + let num_nodes = sorted.len(); + log_autodiff(AutodiffLogLevel::Basic, move || { + format!("retrieve_node_output {node_id:?}: {num_nodes} node(s) to compute") + }); + + sorted.into_iter().for_each(|node| { + log_autodiff(AutodiffLogLevel::Full, move || { + format!("execute_retro_forward {node:?}") + }); + self.retro_forwards + .execute_retro_forward(node, &mut self.backward_states) + }); + + self.backward_states.get_state::(&node_id) + } + + /// Sorts the ancestors of NodeId in a way such that all parents come before their children + /// Useful to avoid recursivity later when mutating the states + /// + /// The sort on a compute bound state or a memory bound that is already computed is trivial. + /// The match on State::Computed also serves as a stopping criterion for the sort, + /// we don't need to look higher than that during recursivity. + fn topological_sort(&self, node_id: NodeId) -> Vec { + match self.backward_states.get_state_ref(&node_id) { + Some(state) => match state { + State::Recompute { n_required: _ } => { + let mut sorted = Vec::new(); + let parents = self.node_tree.parents(&node_id).unwrap(); + for parent_node in parents { + let parent_sorted = self.topological_sort(parent_node); + for ps in parent_sorted { + if !sorted.contains(&ps) { + sorted.push(ps) + } + } + } + sorted.push(node_id); + sorted + } + State::Computed { + state_content: _, + n_required: _, + } => vec![node_id], + }, + None => panic!("Node {node_id:?} is not in the backward_states. "), + } + } + + /// Checks if checkpointer has been drained adequately. Useful for testing + pub fn is_empty(&self) -> bool { + self.backward_states.is_empty() && self.retro_forwards.is_empty() + } +} diff --git a/crates/burn-autodiff/src/checkpoint/builder.rs b/crates/burn-autodiff/src/checkpoint/builder.rs new file mode 100644 index 0000000..aac9d3a --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/builder.rs @@ -0,0 +1,311 @@ +use crate::{ + collections::HashMap, + graph::{ComputingProperty, NodeId}, + tensor::AutodiffTensor, +}; +use alloc::{boxed::Box, vec::Vec}; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use burn_backend::Backend; +use core::any::Any; + +use super::{ + base::{Checkpointer, NodeTree}, + retro_forward::{RetroForward, RetroForwards}, + state::{BackwardStates, State}, +}; + +#[derive(Debug)] +/// Determines if a node should checkpoint its computed output or its retro_forward for recomputation +/// The action is normally created by the child of the node, once the node is determined to be needed +pub enum CheckpointingAction { + /// The node's already computed output should be saved + Computed { + /// The node + node_id: NodeId, + /// The node's output + state_content: Box, + }, + /// The node should recompute itself when asked + Recompute { + /// The node + node_id: NodeId, + /// How the node should recompute itself + retro_forward: Arc, + }, +} + +// TODO: Remove that when proper client server. +unsafe impl Send for CheckpointingAction {} + +impl CheckpointingAction { + /// Utility function to access the id of the node of the checkpointing action + pub fn id(&self) -> NodeId { + match self { + CheckpointingAction::Computed { + node_id: node_ref, + state_content: _, + } => *node_ref, + CheckpointingAction::Recompute { + node_id: node_ref, + retro_forward: _, + } => *node_ref, + } + } +} + +#[derive(new, Debug, Default)] +/// Accumulates checkpoints as checkpointing actions during the forward pass, +/// and builds a checkpointer right before the backward pass +pub struct CheckpointerBuilder { + explicit_actions: Vec, + backup_actions: Vec, +} + +/// Determines if a checkpoint should impact the n_required values (Main) +/// or if it should just keep the state in case it's required (Backup) +/// +pub(crate) enum ActionType { + /// Explicit actions have been explicitly requested by some operation to retrieve their state + Explicit, + /// Backup actions are not always needed. They exist to save the output of an operation + /// whose child is memory bound, in case the state is indirectly needed when computing + /// the child's retro_forward. If no explicit action ever asks for the child's output, then + /// the backup output will go out of scope when the checkpointer is built. + Backup, +} + +impl CheckpointerBuilder { + pub(crate) fn checkpoint( + &mut self, + tensor: &AutodiffTensor, + action_type: ActionType, + ) { + let action_list = match action_type { + ActionType::Explicit => &mut self.explicit_actions, + ActionType::Backup => &mut self.backup_actions, + }; + match &tensor.node.properties { + ComputingProperty::ComputeBound | ComputingProperty::Ambiguous => { + action_list.push(CheckpointingAction::Computed { + node_id: tensor.node.id, + state_content: Box::new(tensor.primitive.clone()), + }) + } + ComputingProperty::MemoryBound { retro_forward } => { + action_list.push(CheckpointingAction::Recompute { + node_id: tensor.node.id, + retro_forward: retro_forward.clone(), + }) + } + } + } + + pub(crate) fn extend(&mut self, other: CheckpointerBuilder) { + for other_action in other.explicit_actions { + self.explicit_actions.push(other_action) + } + for other_unsure in other.backup_actions { + self.backup_actions.push(other_unsure) + } + } + + pub(crate) fn build(self, node_tree: NodeTree) -> Checkpointer { + let mut backward_states_map = HashMap::new(); + let mut retro_forwards_map = HashMap::new(); + + // Find recursion stopping points + let stop_nodes: Vec = self.find_stop_nodes(); + + // We start by identifying how many times each node will be required. + let n_required_map = self.build_n_required_map(&node_tree, stop_nodes); + + // Then we checkpoint the nodes with the corresponding n_required value + self.insert_checkpoints( + &mut backward_states_map, + &mut retro_forwards_map, + n_required_map, + ); + + Checkpointer::new( + BackwardStates::new(backward_states_map), + RetroForwards::new(retro_forwards_map), + node_tree, + ) + } + + fn find_stop_nodes(&self) -> Vec { + let mut stop_nodes = Vec::default(); + for action in self + .explicit_actions + .iter() + .chain(self.backup_actions.iter()) + { + match action { + CheckpointingAction::Computed { + node_id: node_ref, + state_content: _, + } => stop_nodes.push(*node_ref), + CheckpointingAction::Recompute { + node_id: _, + retro_forward: _, + } => {} + } + } + stop_nodes + } + + fn build_n_required_map( + &self, + node_tree: &NodeTree, + stop_nodes: Vec, + ) -> HashMap { + let mut n_required_map = HashMap::::default(); + + for action in self.explicit_actions.iter() { + match action { + CheckpointingAction::Computed { + node_id: node_ref, + state_content: _, + } => { + let id = *node_ref; + match n_required_map.remove(&id) { + Some(n) => { + n_required_map.insert(id, n + 1); + } + None => { + n_required_map.insert(id, 1); + } + }; + } + CheckpointingAction::Recompute { + node_id: node_ref, + retro_forward: _, + } => { + let id = *node_ref; + Self::update_n_required_of_parents( + id, + &mut n_required_map, + node_tree, + &stop_nodes, + ); + } + } + } + + n_required_map + } + + fn insert_checkpoints( + mut self, + backward_states_map: &mut HashMap, + retro_forward_map: &mut HashMap>, + n_required_map: HashMap, + ) { + // We do not loop over checkpointing actions anymore because they can contain + // duplicates or miss some that are in backup. We loop over the n_required_map + // from which we use the ids to find them again in the checkpointing actions + for (node_id, n_required) in n_required_map { + // We find the checkpointing action for node_id. It's likely in checkpointing_actions + // so we check there first, otherwise it will be in backup. + // Technically it can be there several times but can never be of both types, so we can assume the first we find is fine + + let action = match self + .explicit_actions + .iter() + .position(|action| action.id() == node_id) + { + Some(pos) => self.explicit_actions.remove(pos), + None => { + let pos = self + .backup_actions + .iter() + .position(|action| action.id() == node_id); + self.backup_actions.remove(pos.unwrap_or_else(|| { + panic!("Node {:?} is needed but never checkpointed", node_id) + })) + } + }; + + match action { + CheckpointingAction::Computed { + node_id: _, + state_content, + } => { + self.checkpoint_compute(backward_states_map, node_id, state_content, n_required) + } + CheckpointingAction::Recompute { + node_id: _, + retro_forward, + } => self.checkpoint_lazy( + backward_states_map, + retro_forward_map, + node_id, + retro_forward, + n_required, + ), + }; + } + } + + fn update_n_required_of_parents( + id: NodeId, + n_required_map: &mut HashMap, + node_tree: &NodeTree, + stop_nodes: &Vec, + ) { + match n_required_map.remove(&id) { + Some(n) => { + n_required_map.insert(id, n + 1); + } + None => { + n_required_map.insert(id, 1); + if !stop_nodes.contains(&id) + && let Some(parents) = node_tree.parents(&id) + { + for p in parents { + Self::update_n_required_of_parents( + p, + n_required_map, + node_tree, + stop_nodes, + ); + } + } + } + } + } + + fn checkpoint_compute( + &self, + backward_states_map: &mut HashMap, + node_id: NodeId, + state_content: Box, + n_required: usize, + ) { + backward_states_map.insert( + node_id, + State::Computed { + state_content, + n_required, + }, + ); + } + + fn checkpoint_lazy( + &self, + backward_states_map: &mut HashMap, + retro_forward_map: &mut HashMap>, + node_id: NodeId, + retro_forward: Arc, + n_required: usize, + ) { + retro_forward_map.insert(node_id, retro_forward); + backward_states_map.insert(node_id, State::Recompute { n_required }); + } +} diff --git a/crates/burn-autodiff/src/checkpoint/mod.rs b/crates/burn-autodiff/src/checkpoint/mod.rs new file mode 100644 index 0000000..a67952b --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/mod.rs @@ -0,0 +1,9 @@ +/// Checkpointer module +pub mod base; +pub(crate) mod builder; +/// RetroForward module +pub mod retro_forward; +/// BackwardStates module +pub mod state; +/// CheckpointStrategy module +pub mod strategy; diff --git a/crates/burn-autodiff/src/checkpoint/retro_forward.rs b/crates/burn-autodiff/src/checkpoint/retro_forward.rs new file mode 100644 index 0000000..4b5e313 --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/retro_forward.rs @@ -0,0 +1,121 @@ +use crate::collections::HashMap; +use crate::graph::NodeId; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use core::fmt::Debug; + +use super::state::{BackwardStates, State}; + +/// Definition of the forward function of a node, called during retropropagation only. +/// This is different from the normal forward function because it reads and writes from +/// the [BackwardStates] map instead of having a clear function signature. +pub trait RetroForward: Debug + Send + 'static { + /// Applies the forward pass for retropropagation. + fn forward(&self, states: &mut BackwardStates, out_node: NodeId); +} + +#[derive(new, Debug)] +/// Links [NodeId]s to their corresponding [RetroForward] +pub(crate) struct RetroForwards { + map: HashMap>, +} + +impl RetroForwards { + /// Executes the [RetroForward] for a given [NodeId] if the node's + /// [State] is [State::Recompute], otherwise does nothing. + pub(crate) fn execute_retro_forward( + &mut self, + node_id: NodeId, + backward_states: &mut BackwardStates, + ) { + if let State::Recompute { n_required: _ } = backward_states + .get_state_ref(&node_id) + .unwrap_or_else(|| panic!("Should find node {node_id:?}")) + { + // Retro forwards are always used only once because afterwards their state is computed + let retro_forward = self.map.remove(&node_id).unwrap(); + retro_forward.forward(backward_states, node_id); + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.map.is_empty() + } +} + +#[macro_export] +/// Creates a RetroForward struct for unary scalar operations +macro_rules! retro_unary_scalar { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug, Clone)] + struct $name { + lhs_id: NodeId, + rhs: Scalar, + _backend: PhantomData, + } + + impl RetroForward for $name { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let lhs = states.get_state::(&self.lhs_id); + let out = $ops(lhs, self.rhs); + states.save(out_node, out) + } + } + }; +} + +#[macro_export] +/// Creates a RetroForward struct for unary scalar operations +macro_rules! retro_unary { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug, Clone)] + struct $name { + input_id: NodeId, + _backend: PhantomData, + } + + impl RetroForward for $name { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = $ops(input); + states.save(out_node, out) + } + } + }; +} + +#[macro_export] +/// Creates a RetroForward struct for binary operations +macro_rules! retro_binary { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug, Clone)] + struct $name { + lhs_id: NodeId, + rhs_id: NodeId, + _backend: PhantomData, + } + + impl RetroForward for $name { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let lhs = states.get_state::(&self.lhs_id); + let rhs = states.get_state::(&self.rhs_id); + let out = $ops(lhs, rhs); + states.save(out_node, out) + } + } + }; +} diff --git a/crates/burn-autodiff/src/checkpoint/state.rs b/crates/burn-autodiff/src/checkpoint/state.rs new file mode 100644 index 0000000..28b9578 --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/state.rs @@ -0,0 +1,144 @@ +use core::any::Any; + +use crate::collections::HashMap; +use crate::graph::NodeId; +use alloc::boxed::Box; + +/// In order to accept arbitrary node output in the same hashmap, we need to upcast them to any. +pub(crate) type StateContent = Box; + +#[derive(Debug)] +/// The state contained at one node. Encapsulates the node output if precomputed, +/// or clearly asks that it needs to be recomputed from the parents. +/// Also keeps track of the number of times the state is required so it can be removed +/// from the map of states on its last use. +pub(crate) enum State { + /// The state was not checkpointed, will need to recompute it from the node's parents + Recompute { n_required: usize }, + /// The state was checkpointed or computed during retropropagation and can be directly accessed + Computed { + state_content: StateContent, + n_required: usize, + }, +} + +impl State { + /// Returns a reference to the (not yet) downcasted node output, if checkpointed + pub(crate) fn to_state_content(&self) -> &StateContent { + match self { + State::Recompute { n_required: _ } => { + unreachable!( + "Can't get state content of recompute state. A child has likely been accessed before its parents." + ) + } + State::Computed { + state_content, + n_required: _, + } => state_content, + } + } + + /// Returns a (not yet) downcasted node output, if checkpointed + pub(crate) fn into_state_content(self) -> StateContent { + match self { + State::Recompute { n_required: _ } => { + unreachable!( + "Can't get state content of recompute state. A child has likely been accessed before its parents." + ) + } + State::Computed { + state_content, + n_required: _, + } => state_content, + } + } + + /// Returns the number of time the state is required + pub(crate) fn n_required(&self) -> usize { + match self { + State::Recompute { n_required } => *n_required, + State::Computed { + state_content: _, + n_required, + } => *n_required, + } + } +} + +#[derive(new, Default, Debug)] +/// Links [NodeId]s to their current state +pub struct BackwardStates { + map: HashMap, +} + +impl BackwardStates { + /// Returns the output in the state of the given [NodeId], + /// and decrements the number of times this state is required. + /// This function always gives ownership of the output, but will clone it if needed for further uses. + pub fn get_state(&mut self, node_id: &NodeId) -> T + where + T: Clone + Send + 'static, + { + // Fetch the state and decrement its number of required + let state = self.map.remove(node_id).unwrap(); + let remaining_n_required = state.n_required() - 1; + + // Downcast the state to whatever it is supposed to be + // If still needed after giving ownership, we copy it back to the hashmap + if remaining_n_required > 0 { + let new_stored_state = match state { + State::Recompute { n_required: _ } => unreachable!(), + State::Computed { + state_content, + n_required: _, + } => State::Computed { + state_content, + n_required: remaining_n_required, + }, + }; + + let downcasted = new_stored_state + .to_state_content() + .downcast_ref::() + .unwrap() + .clone(); + + self.insert_state(*node_id, new_stored_state); + + downcasted + } else { + let downcasted = state.into_state_content().downcast::().unwrap(); + *downcasted + } + } + + /// Returns a reference to the [State] of the given node + /// Useful when we need [State] information without needing the underlying tensor + pub(crate) fn get_state_ref(&self, node_id: &NodeId) -> Option<&State> { + self.map.get(node_id) + } + + /// Associates a [State] to its [NodeId] + pub(crate) fn insert_state(&mut self, node_id: NodeId, state: State) { + self.map.insert(node_id, state); + } + + /// Saves the output to the state of the given [NodeId]. + pub fn save(&mut self, node_id: NodeId, saved_output: T) + where + T: Clone + Send + 'static, + { + let n_required = self.get_state_ref(&node_id).unwrap().n_required(); + self.insert_state( + node_id, + State::Computed { + state_content: Box::new(saved_output), + n_required, + }, + ); + } + + pub(crate) fn is_empty(&self) -> bool { + self.map.is_empty() + } +} diff --git a/crates/burn-autodiff/src/checkpoint/strategy.rs b/crates/burn-autodiff/src/checkpoint/strategy.rs new file mode 100644 index 0000000..afa6adf --- /dev/null +++ b/crates/burn-autodiff/src/checkpoint/strategy.rs @@ -0,0 +1,107 @@ +use core::fmt::Debug; + +use burn_backend::Backend; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use crate::{graph::ComputingProperty, tensor::AutodiffTensor}; + +use super::{ + builder::{ActionType, CheckpointerBuilder}, + retro_forward::RetroForward, +}; + +/// Strategy for the amount of checkpointing to do during autodiff +pub trait CheckpointStrategy: Clone + Copy + Debug + Default + Send + Sync + 'static { + /// May modify the compute property depending on the strategy + fn compute_property(retro_forward: R) -> ComputingProperty; + + /// Checkpoints parents if necessary in the strategy + fn checkpoint_parents<'a, B2, A>( + parents: A, + builder: &mut CheckpointerBuilder, + ) -> Result<(), CheckpointingError> + where + B2: Backend, + A: IntoIterator>; +} + +#[derive(Debug)] +/// Error that can happen when trying to checkpoint a tensor. +pub enum CheckpointingError { + /// When a parent is untracked, we can't easily checkpoint its state, since we don't know the + /// requirements in advanced. + UntrackedParent, +} + +#[derive(Clone, Copy, Debug, Default)] +/// All operations are considered compute bound, notwithstanding how they are marked +pub struct NoCheckpointing {} + +impl CheckpointStrategy for NoCheckpointing { + /// An operation marked as memory bound is actually compute bound. + fn compute_property(_retro_forward: R) -> ComputingProperty { + ComputingProperty::ComputeBound + } + + /// An operation marked as memory bound is actually compute bound. + /// It's therefore useless to checkpoint the parents + fn checkpoint_parents<'a, B2, A>( + _parents: A, + _builder: &mut CheckpointerBuilder, + ) -> Result<(), CheckpointingError> + where + B2: Backend, + A: IntoIterator>, + { + // Nothing to do here + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default)] +/// Operation properties are as they are marked (compute or memory bound) +pub struct BalancedCheckpointing {} + +impl CheckpointStrategy for BalancedCheckpointing { + /// An operation marked as memory bound is memory bound. + /// When memory bound, an operation needs to save its RetroForward + fn compute_property(retro_forward: R) -> ComputingProperty { + ComputingProperty::MemoryBound { + retro_forward: Arc::new(retro_forward), + } + } + + /// An operation marked as memory bound is really memory bound. + /// Since the operation may not checkpoint its parents but may need them indirectly + /// if asked to recompute itself, the method needs to know the parent tensors to maybe checkpoint them + fn checkpoint_parents<'a, B2, A>( + parents: A, + builder: &mut CheckpointerBuilder, + ) -> Result<(), CheckpointingError> + where + B2: Backend, + A: IntoIterator>, + { + let mut can_checkpoint = true; + + for tensor in parents.into_iter() { + if let crate::graph::Requirement::None = tensor.node.requirement { + can_checkpoint = false; + } else { + builder.checkpoint(tensor, ActionType::Backup); + } + } + + if !can_checkpoint { + *builder = CheckpointerBuilder::default(); + return Err(CheckpointingError::UntrackedParent); + } + + Ok(()) + } +} diff --git a/crates/burn-autodiff/src/distributed.rs b/crates/burn-autodiff/src/distributed.rs new file mode 100644 index 0000000..7e55e20 --- /dev/null +++ b/crates/burn-autodiff/src/distributed.rs @@ -0,0 +1,59 @@ +use std::marker::PhantomData; + +use crate::{collections::HashMap, grads::DistributedRegistration}; +use burn_backend::{ + Backend, TensorPrimitive, + distributed::{DistributedParams, TensorRef}, +}; +use burn_std::container::TensorContainer; + +use crate::{NodeId, grads::GradID}; + +/// Submits sync operations on gradient registrations. +pub(crate) struct DistributedGradientRegistration { + n_required_map: HashMap, + sharded_parameters_map: HashMap, + _b: PhantomData, +} + +impl DistributedGradientRegistration { + /// Creates a new registration and immediately registers the distributed parameters + /// with the sync server so it can coordinate gradient reductions across devices. + pub(crate) fn new( + n_required_map: HashMap, + sharded_parameters_map: HashMap, + device: B::Device, + ) -> Self { + // For DDP, we register the distributed parameters of the tensors' nodes used in the graph and the number of times they + // appear as nodes to know when to launch gradients reducing. + if !sharded_parameters_map.is_empty() { + B::register_sync_parameters( + &device, + sharded_parameters_map.values().cloned().collect(), + ); + } + + Self { + n_required_map, + sharded_parameters_map, + _b: PhantomData, + } + } +} + +impl DistributedRegistration for DistributedGradientRegistration { + fn on_register(&mut self, id: &NodeId, container: &mut TensorContainer) { + if let Some(sharded_params) = self.sharded_parameters_map.get(id) { + let n_required = self.n_required_map.get_mut(id).unwrap(); + *n_required -= 1; + + if *n_required == 0 { + let tensor_ref = container + .get_mut_ref::>(&id.value) + .unwrap(); + let tensor_ref = TensorRef(tensor_ref.get_mut_ref()); + B::submit_gradient_sync(tensor_ref, sharded_params.clone()); + } + } + } +} diff --git a/crates/burn-autodiff/src/grads.rs b/crates/burn-autodiff/src/grads.rs new file mode 100644 index 0000000..5e332ab --- /dev/null +++ b/crates/burn-autodiff/src/grads.rs @@ -0,0 +1,146 @@ +use alloc::boxed::Box; +use burn_backend::{Backend, TensorMetadata, TensorPrimitive, tensor::FloatTensor}; +use burn_std::tensor::container::TensorContainer; + +use crate::{ + NodeId, + graph::{NodeRef, Requirement}, + tensor::AutodiffTensor, +}; + +#[cfg(feature = "std")] +use crate::collections::HashMap; +#[cfg(feature = "std")] +use burn_backend::distributed::DistributedParams; + +/// Gradient identifier. +pub type GradID = u64; + +#[cfg(feature = "std")] +#[derive(Clone)] +pub(crate) struct GradSyncContext { + pub n_required_map: HashMap, + pub distributed_params: HashMap, +} + +/// Hook type executed when a gradient is registered. +type OnRegisterHook = Box) + Send + Sync>; + +/// Trait for registering distributed gradients. +pub trait DistributedRegistration: Send + Sync { + /// Performs distributed registration operations on the tensor with the corresponding [`NodeId`]. + fn on_register(&mut self, node_id: &NodeId, container: &mut TensorContainer); +} + +#[derive(Default)] +pub(crate) enum BackwardMode { + #[default] + Standard, + // Distributed registration hook. + #[cfg(feature = "std")] + Distributed(Box Box>), +} + +/// Gradients container used during the backward pass. +pub struct Gradients { + container: TensorContainer, + /// Optional hook called after each gradient is registered, used to trigger + /// distributed gradient synchronization operations. + on_register: Option, +} + +impl Gradients { + /// Creates a new gradients container. + pub fn new(root_node: NodeRef, root_tensor: FloatTensor) -> Self { + Self::new_with_hook::(root_node, root_tensor, None) + } + + /// Creates a new gradients container. + fn new_with_hook( + root_node: NodeRef, + root_tensor: FloatTensor, + on_register: Option, + ) -> Self { + let mut gradients = Self { + container: TensorContainer::new(), + on_register, + }; + gradients.register::( + root_node.id, + B::float_ones( + root_tensor.shape(), + &root_tensor.device(), + root_tensor.dtype().into(), + ), + ); + gradients + } + + /// Creates a new gradients container with a registration hook for distributed gradients. + #[cfg(feature = "std")] + pub fn new_distributed( + root_node: NodeRef, + root_tensor: FloatTensor, + mut reg: Box, + ) -> Self { + let on_register: Option = Some(Box::new(move |id, container| { + reg.on_register(id, container); + })); + Self::new_with_hook::(root_node, root_tensor, on_register) + } + + /// Consumes the gradients for a given tensor. + /// + /// Each tensor should be consumed exactly 1 time if its gradients are only required during the + /// backward pass, otherwise, it may be consume multiple times. + pub fn consume(&mut self, node: &NodeRef) -> FloatTensor { + match node.requirement { + Requirement::Grad => self + .container + .get::>(&node.id.value) + .map(|tensor| tensor.tensor()) + .expect("Can't consume the gradients before they are registered at least once."), + Requirement::GradInBackward => self + .container + .remove::>(&node.id.value) + .map(|tensor| tensor.tensor()) + .expect("Can't consume the gradients before they are registered at least once."), + Requirement::None => panic!("Trying to consume the gradients for an untracked tensor"), + } + } + + /// Removes a grad tensor from the container. + pub fn remove(&mut self, tensor: &AutodiffTensor) -> Option> { + self.container + .remove::>(&tensor.node.id.value) + .map(|tensor| tensor.tensor()) + } + + /// Gets a grad tensor from the container. + pub fn get(&self, tensor: &AutodiffTensor) -> Option> { + self.container + .get::>(&tensor.node.id.value) + .map(|tensor| tensor.tensor()) + } + + /// Register a grad tensor in the container. + /// + /// If the tensor already exists, add both tensors together before saving the result. + /// + /// If the registered tensor is distributed, launches a syncing operation on the gradients. + pub fn register(&mut self, node_id: NodeId, value: FloatTensor) { + let out = + if let Some(tensor_old) = self.container.remove::>(&node_id.value) { + B::float_add(value, tensor_old.tensor()) + } else { + value + }; + + self.container + .register::>(node_id.value, TensorPrimitive::Float(out)); + + if let Some(hook) = &mut self.on_register { + hook(&node_id, &mut self.container); + } + } +} diff --git a/crates/burn-autodiff/src/graph/base.rs b/crates/burn-autodiff/src/graph/base.rs new file mode 100644 index 0000000..0f4d38e --- /dev/null +++ b/crates/burn-autodiff/src/graph/base.rs @@ -0,0 +1,26 @@ +use super::NodeId; +use crate::{checkpoint::base::Checkpointer, grads::Gradients, graph::Parent}; +use alloc::boxed::Box; + +use burn_backend::distributed::DistributedParams; + +/// Backward step for reverse mode autodiff. +pub trait Step: Send + core::fmt::Debug { + /// Executes the step and consumes it. + fn step(self: Box, grads: &mut Gradients, checkpointer: &mut Checkpointer); + /// Depth of the operation relative to the first node added to a graph. + fn depth(&self) -> usize; + /// The node associated to the step. + fn node(&self) -> NodeId; + /// The parents of the node associated to the step. + fn parents(&self) -> &[Parent]; + + /// Returns the [`DistributedParams`] of the node's tensor associated to the step. + /// + /// Defaults to `None`; steps that carry distributed parameters override this. + fn distributed_params(&self) -> Option { + None + } +} + +pub type StepBoxed = Box; diff --git a/crates/burn-autodiff/src/graph/mod.rs b/crates/burn-autodiff/src/graph/mod.rs new file mode 100644 index 0000000..7e78bed --- /dev/null +++ b/crates/burn-autodiff/src/graph/mod.rs @@ -0,0 +1,9 @@ +mod base; +mod node; +mod requirement; + +pub mod traversal; + +pub use base::*; +pub use node::*; +pub use requirement::*; diff --git a/crates/burn-autodiff/src/graph/node.rs b/crates/burn-autodiff/src/graph/node.rs new file mode 100644 index 0000000..3c271b7 --- /dev/null +++ b/crates/burn-autodiff/src/graph/node.rs @@ -0,0 +1,96 @@ +use alloc::vec::Vec; + +use burn_backend::distributed::DistributedParams; + +#[cfg(target_has_atomic = "64")] +use core::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(target_has_atomic = "64"))] +use portable_atomic::{AtomicU64, Ordering}; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use crate::checkpoint::retro_forward::RetroForward; +use crate::runtime::AutodiffClientImpl; + +use super::Requirement; + +#[derive(Debug, Clone)] +pub enum ComputingProperty { + ComputeBound, + MemoryBound { + retro_forward: Arc, + }, + Ambiguous, // Maybe autotune someday +} + +/// This is safe only because we only call RetroForward on the autodiff server. +/// Therefore, the trait will never be used by multiple threads at the same time. +/// +/// TODO: Find a way to avoid cloning the compute property, which will remove the need to add the +/// Arc, which will make (dyn RetroForward) safely implement Send. +unsafe impl Send for ComputingProperty {} +/// unsafe Sync is required because Send is only implemented for Arc, not Arc. +unsafe impl Sync for ComputingProperty {} + +/// A node contains graph metadata and should be used wrapped in an Arc for cheap cloning. +#[derive(new, Debug)] +pub struct Node { + pub parents: Vec, + pub order: usize, + pub id: NodeId, + pub requirement: Requirement, + pub properties: ComputingProperty, + pub client: AutodiffClientImpl, + pub distributed_params: Option, +} +pub type NodeRef = Arc; + +#[derive(new, Debug, Clone, PartialEq, Eq)] +pub struct Parent { + pub id: NodeId, +} + +impl Node { + /// Returns the [node](Node) only if gradients are required. + pub fn clone_if_require_grad(self: &Arc) -> Option { + match self.requirement.is_none() { + true => None, + false => Some(self.clone()), + } + } +} + +/// Unique identifier generated for each node. +#[derive(Clone, Hash, PartialEq, Eq, Debug, Copy, Ord, PartialOrd)] +pub struct NodeId { + /// The integer representation of the id + pub value: u64, +} + +impl core::fmt::Display for NodeId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("NodeId({})", self.value)) + } +} + +impl NodeId { + /// Create a unique [node id](NodeId). + pub fn new() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let value = COUNTER.fetch_add(1, Ordering::Relaxed); + if value == u64::MAX { + panic!("NodeId overflowed"); + } + Self { value } + } +} + +impl Default for NodeId { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/burn-autodiff/src/graph/requirement.rs b/crates/burn-autodiff/src/graph/requirement.rs new file mode 100644 index 0000000..ce7291c --- /dev/null +++ b/crates/burn-autodiff/src/graph/requirement.rs @@ -0,0 +1,38 @@ +use super::NodeRef; + +/// Requirement for each tensor in the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Requirement { + /// Operations that require gradients. + Grad, + /// Operations that require gradients only for backprop. + GradInBackward, + /// Operations that don't need gradients, therefore not to be included in the graph. + None, +} + +impl Requirement { + /// Returns true if gradients are not required. + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + /// Returns the right requirement from a list of nodes. + pub fn from_nodes(nodes: &[NodeRef]) -> Self { + if nodes.len() == 1 { + return nodes[0].requirement.infer(&Requirement::None); + } + + nodes + .iter() + .map(|node| node.requirement) + .reduce(|acc, requirement| requirement.infer(&acc)) + .unwrap_or(Requirement::None) + } + + fn infer(&self, other: &Self) -> Self { + match self.is_none() && other.is_none() { + true => Self::None, + false => Self::GradInBackward, + } + } +} diff --git a/crates/burn-autodiff/src/graph/traversal.rs b/crates/burn-autodiff/src/graph/traversal.rs new file mode 100644 index 0000000..0a0afcb --- /dev/null +++ b/crates/burn-autodiff/src/graph/traversal.rs @@ -0,0 +1,74 @@ +use super::{Step, StepBoxed}; +use crate::{ + NodeId, + collections::{HashMap, HashSet}, + graph::Parent, +}; +use alloc::vec::Vec; + +/// Breadth for search algorithm. +pub struct BreadthFirstSearch; + +pub trait TraversalItem { + fn id(&self) -> NodeId; + fn parents(&self) -> &[Parent]; + fn parent_nodes(&self) -> Vec { + self.parents().iter().map(|p| p.id).collect() + } +} + +impl BreadthFirstSearch { + /// Traverse the graph of backward steps from a root node. + pub fn traverse( + &self, + root_id: NodeId, + root_step: I, + steps: &mut HashMap, + mut callback: F, + ) where + F: FnMut(NodeId, I), + I: TraversalItem, + { + let mut visited = HashSet::new(); + let mut parents = Vec::new(); + + visited.insert(root_id); + parents.append(&mut root_step.parent_nodes()); + + callback(root_id, root_step); + + while let Some(id) = parents.pop() { + let step = match steps.remove(&id) { + Some(step) => step, + None => continue, + }; + + let step_node = step.id(); + let step_parents = step.parent_nodes(); + + if visited.contains(&step_node) { + continue; + } + + visited.insert(step_node); + + for id in step_parents.iter() { + if !visited.contains(id) { + parents.push(*id); + } + } + + callback(step_node, step); + } + } +} + +impl TraversalItem for StepBoxed { + fn id(&self) -> NodeId { + Step::node(self.as_ref()) + } + + fn parents(&self) -> &[Parent] { + Step::parents(self.as_ref()) + } +} diff --git a/crates/burn-autodiff/src/lib.rs b/crates/burn-autodiff/src/lib.rs new file mode 100644 index 0000000..32b263c --- /dev/null +++ b/crates/burn-autodiff/src/lib.rs @@ -0,0 +1,46 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! # Burn Autodiff +//! +//! This autodiff library is a part of the Burn project. It is a standalone crate +//! that can be used to perform automatic differentiation on tensors. It is +//! designed to be used with the Burn Tensor crate, but it can be used with any +//! tensor library that implements the `Backend` trait. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; + +/// Checkpoint module. +pub mod checkpoint; +#[cfg(feature = "std")] +/// Distributed utils. +pub mod distributed; +/// Gradients module. +pub mod grads; +/// Operation module. +pub mod ops; + +pub(crate) mod graph; +// Exported for backend extension +pub use graph::NodeId; +pub(crate) mod tensor; +pub(crate) mod utils; + +mod backend; + +pub(crate) mod runtime; + +pub use backend::*; + +/// A facade around for HashMap and HashSet. +/// This avoids elaborate import wrangling having to happen in every module. +mod collections { + #[cfg(not(feature = "std"))] + pub use hashbrown::{HashMap, HashSet}; + #[cfg(feature = "std")] + pub use std::collections::{HashMap, HashSet}; +} diff --git a/crates/burn-autodiff/src/ops/activation.rs b/crates/burn-autodiff/src/ops/activation.rs new file mode 100644 index 0000000..4be3912 --- /dev/null +++ b/crates/burn-autodiff/src/ops/activation.rs @@ -0,0 +1,167 @@ +use core::marker::PhantomData; + +use crate::{ + Autodiff, + checkpoint::{ + base::Checkpointer, retro_forward::RetroForward, state::BackwardStates, + strategy::CheckpointStrategy, + }, + grads::Gradients, + graph::NodeId, + ops::{Backward, Ops, OpsKind, unary}, + retro_unary, +}; +use burn_backend::{Backend, ops::ActivationOps, tensor::FloatTensor}; + +impl ActivationOps> for Autodiff { + fn gelu(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Gelu; + + retro_unary!(RetroGelu, B::gelu); + + impl Backward for Gelu { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + + unary::(ops.parents, ops.node, grads, |grad| { + B::gelu_backward(input, grad) + }); + } + } + + match Gelu + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroGelu::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::gelu(tensor.primitive.clone())) + } + OpsKind::UnTracked(prep) => prep.finish(B::gelu(tensor.primitive)), + } + } + + fn relu(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Relu; + + retro_unary!(RetroRelu, B::relu); + + impl Backward for Relu { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let state = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + B::relu_backward(state, grad) + }); + } + } + + match Relu + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroRelu::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::relu(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::relu(tensor.primitive)), + } + } + + fn sigmoid(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sigmoid; + + retro_unary!(RetroSigmoid, B::sigmoid); + + impl Backward for Sigmoid { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + let output = B::sigmoid(input); + unary::(ops.parents, ops.node, grads, |grad| { + B::sigmoid_backward(output, grad) + }); + } + } + + match Sigmoid + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSigmoid::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::sigmoid(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::sigmoid(tensor.primitive)), + } + } + + fn log_sigmoid(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct LogSigmoid; + + retro_unary!(RetroLogSigmoid, B::log_sigmoid); + + impl Backward for LogSigmoid { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + + unary::(ops.parents, ops.node, grads, |grad| { + B::log_sigmoid_backward(input, grad) + }); + } + } + + match LogSigmoid + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroLogSigmoid::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::log_sigmoid(tensor.primitive.clone())) + } + OpsKind::UnTracked(prep) => prep.finish(B::log_sigmoid(tensor.primitive)), + } + } +} diff --git a/crates/burn-autodiff/src/ops/backward.rs b/crates/burn-autodiff/src/ops/backward.rs new file mode 100644 index 0000000..548acd7 --- /dev/null +++ b/crates/burn-autodiff/src/ops/backward.rs @@ -0,0 +1,88 @@ +use super::{Ops, OpsPrep}; +use crate::{ + checkpoint::{base::Checkpointer, builder::CheckpointerBuilder, strategy::CheckpointStrategy}, + grads::Gradients, + graph::{ComputingProperty, NodeRef, Requirement}, + utils::duplicate, +}; +use burn_backend::Backend; + +/// Trait for all operations. +/// +/// # Notes +/// +/// Concrete types implementing this trait should not have any state. +/// If a state is necessary during the backward pass, +/// they should be declared with the associated type 'State'. +pub trait Backward: Send + core::fmt::Debug +where + Self: Sized + 'static, + B: Backend, +{ + /// Associated type to compute the backward pass. + type State: Clone + Send + core::fmt::Debug + 'static; + + /// The backward pass. + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ); + + /// Prepare the backward ops. + fn prepare( + self, + nodes: [NodeRef; N], + ) -> OpsPrep { + let requirement = Requirement::from_nodes(&nodes); + OpsPrep::new( + nodes, + requirement, + self, + ComputingProperty::Ambiguous, // If not specified we start with ambiguous + CheckpointerBuilder::default(), + ) + } +} + +/// Execute a binary operation during the backward step. +pub fn binary( + parents: [Option; 2], + node: NodeRef, + grads: &mut Gradients, + func_lhs: FLhs, + func_rhs: FRhs, +) where + B: Backend, + FLhs: FnOnce(B::FloatTensorPrimitive) -> B::FloatTensorPrimitive, + FRhs: FnOnce(B::FloatTensorPrimitive) -> B::FloatTensorPrimitive, +{ + let [grad_4lhs, grad_4rhs] = duplicate(&parents, Some(grads.consume::(&node))); + let [node_lhs, node_rhs] = parents; + + if let Some(node) = node_lhs { + let grad = func_lhs(grad_4lhs.unwrap()); + grads.register::(node.id, grad) + } + + if let Some(node) = node_rhs { + let grad = func_rhs(grad_4rhs.unwrap()); + grads.register::(node.id, grad) + } +} + +/// Execute a unary operation during the backward step. +pub fn unary(parents: [Option; 1], node: NodeRef, grads: &mut Gradients, func: F) +where + B: Backend, + F: FnOnce(B::FloatTensorPrimitive) -> B::FloatTensorPrimitive, +{ + let [parent_node] = parents; + let grad = grads.consume::(&node); + + if let Some(node) = parent_node { + let grad = func(grad); + grads.register::(node.id, grad) + } +} diff --git a/crates/burn-autodiff/src/ops/base.rs b/crates/burn-autodiff/src/ops/base.rs new file mode 100644 index 0000000..b124544 --- /dev/null +++ b/crates/burn-autodiff/src/ops/base.rs @@ -0,0 +1,327 @@ +use super::Backward; +use crate::{ + checkpoint::{ + base::Checkpointer, + builder::{ActionType, CheckpointerBuilder}, + retro_forward::RetroForward, + strategy::CheckpointStrategy, + }, + grads::Gradients, + graph::{ComputingProperty, NodeId, NodeRef, Parent, Requirement, Step}, + tensor::AutodiffTensor, +}; +use alloc::boxed::Box; +use burn_backend::{Backend, TensorMetadata, tensor::FloatTensor}; +use burn_std::Shape; +use core::marker::PhantomData; + +use burn_backend::distributed::DistributedParams; + +/// Operation in preparation. +/// +/// Each mode has its own set of functions to minimize cloning for unused backward states. +#[derive(new)] +pub struct OpsPrep { + nodes: [NodeRef; N], + requirement: Requirement, + backward: Backward, + compute_property: ComputingProperty, + checkpointer_builder: CheckpointerBuilder, + checkpoint_strategy: PhantomData, + phantom_backend: PhantomData, + phantom_state: PhantomData, + marker: PhantomData, +} + +/// Operation is initialized +pub struct Init; +/// Operation has been tagged as memory bound +pub struct MemoryBound; +/// Memory bound operation has received its RetroForward +pub struct MemoryBoundRetroForward; +/// Operation's compute property is fixed +pub struct ComputePropertyDone; +/// Tracked operation tag. +pub struct Tracked; +/// Untracked operation tag. +pub struct UnTracked; + +impl OpsPrep +where + B: Backend, + BO: Backward, +{ + /// Indicates that the operation is compute bound, meaning its computation + /// is heavy and should not be recomputed + pub fn compute_bound(self) -> OpsPrep { + OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + ComputingProperty::ComputeBound, + self.checkpointer_builder, + ) + } + + /// Indicates that the operation is memory bound, meaning its computation + /// is light and can be recomputed + pub fn memory_bound(self) -> OpsPrep { + OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + self.compute_property, + self.checkpointer_builder, + ) + } +} + +impl OpsPrep +where + B: Backend, + BO: Backward, + C: CheckpointStrategy, +{ + /// Registers the retro forward, if needed + pub fn retro_forward( + self, + retro_forward: R, + ) -> OpsPrep { + OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + C::compute_property(retro_forward), + self.checkpointer_builder, + ) + } +} + +impl OpsPrep +where + B: Backend, + BO: Backward, + C: CheckpointStrategy, +{ + /// Checkpoints the parents, if needed + pub fn parents<'a, B2, A>(mut self, parents: A) -> OpsPrep + where + B2: Backend, + A: IntoIterator>, + { + let compute_property = match C::checkpoint_parents(parents, &mut self.checkpointer_builder) + { + Ok(..) => self.compute_property, + Err(..) => ComputingProperty::ComputeBound, + }; + + OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + compute_property, + self.checkpointer_builder, + ) + } +} + +impl OpsPrep +where + B: Backend, + BO: Backward, +{ + /// Prepare a stateless operation. + pub fn stateless(self, output: FloatTensor) -> AutodiffTensor { + match self.stateful() { + OpsKind::Tracked(prep) => prep.finish((), output), + OpsKind::UnTracked(prep) => prep.finish(output), + } + } +} + +impl OpsPrep +where + B: Backend, + S: Clone + Send + core::fmt::Debug + 'static, + BO: Backward, +{ + /// Prepare an operation that requires a state during the backward pass. + pub fn stateful(self) -> OpsKind { + match self.requirement.is_none() { + false => OpsKind::Tracked(OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + self.compute_property, + self.checkpointer_builder, + )), + true => OpsKind::UnTracked(OpsPrep::new( + self.nodes, + self.requirement, + self.backward, + self.compute_property, + self.checkpointer_builder, + )), + } + } +} + +impl OpsPrep +where + B: Backend, + S: Clone + Send + core::fmt::Debug + 'static, + BO: Backward, +{ + /// Finish the preparation of an untracked operation and returns the output tensor. + pub fn finish(self, output: FloatTensor) -> AutodiffTensor { + let output = AutodiffTensor::from_parents( + output, + &self.nodes, + self.requirement, + self.compute_property, + ); + let parents = self.nodes.map(|node| node.clone_if_require_grad()); + let ops = Ops::new(parents, output.node.clone(), ()); + + // We register the ops in the graph even if untracked, otherwise memory bound operations + // that have an untracked parent would not be able to retrieve it + output.register_step(UntrackedOpsStep::new(ops), self.checkpointer_builder) + } +} + +impl OpsPrep +where + B: Backend, + S: Clone + Send + core::fmt::Debug + 'static, + BO: Backward, +{ + /// Finish the preparation of a tracked operation and returns the output tensor. + pub fn finish(self, state: S, output: FloatTensor) -> AutodiffTensor { + let output = AutodiffTensor::from_parents( + output, + &self.nodes, + self.requirement, + self.compute_property, + ); + let parents = self.nodes.map(|node| node.clone_if_require_grad()); + let ops = Ops::new(parents, output.node.clone(), state); + + output.register_step(OpsStep::new(ops, self.backward), self.checkpointer_builder) + } + + /// Checkpoints the tensor + pub fn checkpoint(&mut self, tensor: &AutodiffTensor) -> NodeId { + self.checkpointer_builder + .checkpoint(tensor, ActionType::Explicit); + + tensor.node.id + } +} + +/// Enum used before finishing tracked and untracked operations. +pub enum OpsKind { + /// Tracked operation preparation. + Tracked(OpsPrep), + /// Untracked operation preparation. + UnTracked(OpsPrep), +} + +/// Operation containing its parent nodes, its own node and the backward step state. +#[derive(new, Debug)] +pub struct Ops { + /// Parents nodes. + pub parents: [Option; N], + /// The node. + pub node: NodeRef, + /// The state. + pub state: S, +} + +/// Operation implementing backward [step](Step) with type erasing. +#[derive(new, Debug)] +struct OpsStep +where + B: Backend, + T: Backward, + SB: Clone + Send + core::fmt::Debug + 'static, +{ + ops: Ops, + backward: T, + phantom: PhantomData, +} + +impl Step for OpsStep +where + B: Backend, + T: Backward, + SB: Clone + Send + core::fmt::Debug + 'static, +{ + fn step(self: Box, grads: &mut Gradients, checkpointer: &mut Checkpointer) { + self.backward.backward(self.ops, grads, checkpointer); + } + + fn node(&self) -> NodeId { + self.ops.node.id + } + + fn parents(&self) -> &[Parent] { + &self.ops.node.parents + } + + fn depth(&self) -> usize { + self.ops.node.order + } + + fn distributed_params(&self) -> Option { + self.ops.node.distributed_params.clone() + } +} + +#[derive(new, Debug)] +struct UntrackedOpsStep { + ops: Ops<(), N>, +} + +impl Step for UntrackedOpsStep { + fn step(self: Box, _grads: &mut Gradients, _checkpointer: &mut Checkpointer) { + // Nothing to do + } + + fn node(&self) -> NodeId { + self.ops.node.id + } + + fn parents(&self) -> &[Parent] { + &self.ops.node.parents + } + fn depth(&self) -> usize { + self.ops.node.order + } + + fn distributed_params(&self) -> Option { + self.ops.node.distributed_params.clone() + } +} + +/// Make sure the grad tensor has the given shape. +/// +/// If broadcasting happened during the forward pass, the gradients will be sum along the +/// broadcasted dimension. +pub fn broadcast_shape(mut grad: FloatTensor, shape: &Shape) -> FloatTensor { + let shape_grad = grad.shape(); + let ndims = shape_grad.num_dims(); + + for i in 0..ndims { + if shape_grad[i] != shape[i] { + if shape[i] != 1 { + panic!( + "Invalid broadcast shapes: Next grad shape {:?}, Previous grad shape {:?}. {}", + shape, shape_grad, "Expected the shape of the next grad to be 1." + ); + } + grad = B::float_sum_dim(grad, i); + } + } + + grad +} diff --git a/crates/burn-autodiff/src/ops/bool_tensor.rs b/crates/burn-autodiff/src/ops/bool_tensor.rs new file mode 100644 index 0000000..2e3228b --- /dev/null +++ b/crates/burn-autodiff/src/ops/bool_tensor.rs @@ -0,0 +1,170 @@ +use crate::{Autodiff, checkpoint::strategy::CheckpointStrategy, tensor::AutodiffTensor}; +use alloc::vec::Vec; + +use burn_backend::{ + Backend, ExecutionError, Scalar, TensorData, + ops::BoolTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{BoolDType, FloatDType, IntDType, Shape}; + +impl BoolTensorOps for Autodiff { + fn bool_from_data(data: TensorData, device: &Device) -> BoolTensor { + B::bool_from_data(data, device) + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + B::bool_into_data(tensor).await + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + B::bool_into_int(tensor, out_dtype) + } + + fn bool_to_device(tensor: BoolTensor, device: &Device) -> BoolTensor { + B::bool_to_device(tensor, device) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + B::bool_reshape(tensor, shape) + } + + fn bool_slice(tensor: BoolTensor, slices: &[burn_std::Slice]) -> BoolTensor { + B::bool_slice(tensor, slices) + } + + fn bool_empty(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + B::bool_empty(shape, device, dtype) + } + + fn bool_zeros(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + B::bool_zeros(shape, device, dtype) + } + + fn bool_ones(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + B::bool_ones(shape, device, dtype) + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[burn_std::Slice], + value: BoolTensor, + ) -> BoolTensor { + B::bool_slice_assign(tensor, slices, value) + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + B::bool_cat(tensors, dim) + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + B::bool_equal(lhs, rhs) + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + B::bool_not(tensor) + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + B::bool_and(lhs, rhs) + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + B::bool_or(lhs, rhs) + } + + fn bool_xor(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + B::bool_xor(lhs, rhs) + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + AutodiffTensor::new(B::bool_into_float(tensor, out_dtype)) + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + B::bool_swap_dims(tensor, dim1, dim2) + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + B::bool_permute(tensor, axes) + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + B::bool_flip(tensor, axes) + } + + async fn bool_argwhere(tensor: BoolTensor, out_dtype: burn_std::IntDType) -> IntTensor { + B::bool_argwhere(tensor, out_dtype).await + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + B::bool_expand(tensor, shape) + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + B::bool_repeat_dim(tensor, dim, times) + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + B::bool_unfold(tensor, dim, size, step) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + source: BoolTensor, + ) -> BoolTensor { + B::bool_mask_where(tensor, mask, source) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + B::bool_mask_fill(tensor, mask, value) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + B::bool_gather(dim, tensor, indices) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + B::bool_scatter_or(dim, tensor, indices, value) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + B::bool_equal_elem(lhs, rhs) + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + B::bool_select(tensor, dim, indices) + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + B::bool_select_or(tensor, dim, indices, value) + } +} diff --git a/crates/burn-autodiff/src/ops/distributed.rs b/crates/burn-autodiff/src/ops/distributed.rs new file mode 100644 index 0000000..785fd06 --- /dev/null +++ b/crates/burn-autodiff/src/ops/distributed.rs @@ -0,0 +1,90 @@ +use alloc::vec::Vec; +use burn_backend::{ + DeviceId, + distributed::{ + CollectiveTensor, DistributedConfig, DistributedOps, DistributedParams, ReduceOperation, + TensorRef, + }, + tensor::FloatTensor, +}; + +use burn_backend::Backend; + +use crate::{ + Autodiff, + checkpoint::strategy::CheckpointStrategy, + ops::{Backward, Ops, OpsKind, unary}, +}; + +impl DistributedOps for Autodiff { + fn start_communication_server(devices: &[B::Device], config: DistributedConfig) { + B::start_communication_server(devices, config); + } + + fn close_communication_server(device: &B::Device) { + B::close_communication_server(device); + } + + fn register_sync_parameters(device: &B::Device, distributed_params: Vec) { + B::register_sync_parameters(device, distributed_params); + } + + fn submit_sync_collective(device: &B::Device) { + B::submit_sync_collective(device); + } + + fn submit_gradient_sync(tensor: TensorRef, distributed_params: DistributedParams) { + let mut tensor = unsafe { (*tensor.0).clone() }; + B::submit_gradient_sync(TensorRef(&mut tensor.primitive), distributed_params); + } + + fn all_reduce( + tensor: FloatTensor, + op: ReduceOperation, + device_ids: Vec, + ) -> CollectiveTensor { + #[derive(Debug)] + struct AllReduce; + + impl Backward for AllReduce { + type State = (ReduceOperation, Vec); + + fn backward( + self, + ops: Ops, + grads: &mut crate::grads::Gradients, + _checkpointer: &mut crate::checkpoint::base::Checkpointer, + ) { + // Backward uses the same reduce op: local gradients are synchronized via the backend, which handles + // scaling (e.g., ncclAvg for mean). This works for the reduce ops that are currently supported, but we + // might need to rework it if we add other ops such as ncclMax. + unary::(ops.parents, ops.node, grads, |grad| { + B::all_reduce(grad, ops.state.0, ops.state.1).resolve() + }); + } + } + + let collective = B::all_reduce(tensor.primitive, op, device_ids.clone()); + // Safety: we call `assume_resolved` only to wrap it in a new `CollectiveTensor`. + let resolved = unsafe { collective.assume_resolved() }; + + match AllReduce + .prepare::([tensor.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(preps) => { + let output = preps.finish((op, device_ids), resolved); + CollectiveTensor::new(output) + } + OpsKind::UnTracked(preps) => { + let output = preps.finish(resolved); + CollectiveTensor::new(output) + } + } + } + + fn sync_collective(device: &B::Device) { + B::sync_collective(device); + } +} diff --git a/crates/burn-autodiff/src/ops/int_tensor.rs b/crates/burn-autodiff/src/ops/int_tensor.rs new file mode 100644 index 0000000..c4f94fa --- /dev/null +++ b/crates/burn-autodiff/src/ops/int_tensor.rs @@ -0,0 +1,416 @@ +use crate::{Autodiff, checkpoint::strategy::CheckpointStrategy, tensor::AutodiffTensor}; +use alloc::vec::Vec; + +use burn_backend::{ + Backend, Distribution, ExecutionError, Scalar, TensorData, + ops::IntTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{BoolDType, FloatDType, IndexingUpdateOp, IntDType, Shape}; + +impl IntTensorOps for Autodiff { + fn int_from_data(data: TensorData, device: &Device) -> IntTensor { + B::int_from_data(data, device) + } + + async fn int_into_data(tensor: IntTensor) -> Result { + B::int_into_data(tensor).await + } + + fn int_to_device(tensor: IntTensor, device: &Device) -> IntTensor { + B::int_to_device(tensor, device) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + B::int_reshape(tensor, shape) + } + + fn int_slice(tensor: IntTensor, slices: &[burn_std::Slice]) -> IntTensor { + B::int_slice(tensor, slices) + } + + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + B::int_empty(shape, device, dtype) + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[burn_std::Slice], + value: IntTensor, + ) -> IntTensor { + B::int_slice_assign(tensor, slices, value) + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + B::int_cat(tensors, dim) + } + + fn int_equal(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor { + B::int_equal(lhs, rhs, out_dtype) + } + + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + B::int_equal_elem(lhs, rhs, out_dtype) + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_add(lhs, rhs) + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::int_add_scalar(lhs, rhs) + } + + fn int_clamp_min(tensor: IntTensor, min: Scalar) -> IntTensor { + B::int_clamp_min(tensor, min) + } + + fn int_clamp_max(tensor: IntTensor, max: Scalar) -> IntTensor { + B::int_clamp_max(tensor, max) + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + B::int_clamp(tensor, min, max) + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_sub(lhs, rhs) + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::int_sub_scalar(lhs, rhs) + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_mul(lhs, rhs) + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::int_mul_scalar(lhs, rhs) + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_div(lhs, rhs) + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::int_div_scalar(lhs, rhs) + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_remainder(lhs, rhs) + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::int_remainder_scalar(lhs, rhs) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::int_matmul(lhs, rhs) + } + + fn int_neg(tensor: IntTensor) -> IntTensor { + B::int_neg(tensor) + } + + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + B::int_zeros(shape, device, dtype) + } + + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + B::int_ones(shape, device, dtype) + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + B::int_full(shape, fill_value, device, dtype) + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + B::int_sum(tensor) + } + + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_sum_dim(tensor, dim) + } + + fn int_mean(tensor: IntTensor) -> IntTensor { + B::int_mean(tensor) + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_mean_dim(tensor, dim) + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_cumsum(tensor, dim) + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_cumprod(tensor, dim) + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_cummin(tensor, dim) + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_cummax(tensor, dim) + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + B::int_repeat_dim(tensor, dim, times) + } + + fn int_greater(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor { + B::int_greater(lhs, rhs, out_dtype) + } + + fn int_greater_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + B::int_greater_elem(lhs, rhs, out_dtype) + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::int_greater_equal(lhs, rhs, out_dtype) + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::int_greater_equal_elem(lhs, rhs, out_dtype) + } + + fn int_lower(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor { + B::int_lower(lhs, rhs, out_dtype) + } + + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + B::int_lower_elem(lhs, rhs, out_dtype) + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::int_lower_equal(lhs, rhs, out_dtype) + } + + fn int_lower_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + B::int_lower_equal_elem(lhs, rhs, out_dtype) + } + + fn int_gather(dim: usize, tensor: IntTensor, indices: IntTensor) -> IntTensor { + B::int_gather(dim, tensor, indices) + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + B::int_scatter_add(dim, tensor, indices, value) + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: IndexingUpdateOp, + ) -> IntTensor { + B::int_scatter_nd(data, indices, values, reduction) + } + + fn int_select(tensor: IntTensor, dim: usize, indices: IntTensor) -> IntTensor { + B::int_select(tensor, dim, indices) + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + B::int_select_add(tensor, dim, indices, value) + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + B::int_mask_where(tensor, mask, value) + } + + fn int_mask_fill(tensor: IntTensor, mask: BoolTensor, value: Scalar) -> IntTensor { + B::int_mask_fill(tensor, mask, value) + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_argmax(tensor, dim) + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + B::int_argtopk(tensor, dim, k) + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_argmin(tensor, dim) + } + fn int_max(tensor: IntTensor) -> IntTensor { + B::int_max(tensor) + } + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_max_dim(tensor, dim) + } + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + B::int_topk(tensor, dim, k) + } + fn int_max_dim_with_indices(tensor: IntTensor, dim: usize) -> (IntTensor, IntTensor) { + B::int_max_dim_with_indices(tensor, dim) + } + fn int_min(tensor: IntTensor) -> IntTensor { + B::int_min(tensor) + } + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_min_dim(tensor, dim) + } + fn int_min_dim_with_indices(tensor: IntTensor, dim: usize) -> (IntTensor, IntTensor) { + B::int_min_dim_with_indices(tensor, dim) + } + fn int_abs(tensor: IntTensor) -> IntTensor { + B::int_abs(tensor) + } + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + AutodiffTensor::new(B::int_into_float(tensor, out_dtype)) + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + B::int_swap_dims(tensor, dim1, dim2) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + B::int_random(shape, distribution, device, dtype) + } + + fn int_arange( + range: core::ops::Range, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + B::int_arange(range, device, dtype) + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + B::int_permute(tensor, axes) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + B::int_flip(tensor, axes) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + B::int_sign(tensor) + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + B::int_prod(tensor) + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_prod_dim(tensor, dim) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + B::int_expand(tensor, shape) + } + + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + B::int_sort(tensor, dim, descending) + } + + fn int_sort_with_indices( + tensor: IntTensor, + dim: usize, + descending: bool, + ) -> (IntTensor, IntTensor) { + B::int_sort_with_indices(tensor, dim, descending) + } + + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + B::int_argsort(tensor, dim, descending) + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::bitwise_and(lhs, rhs) + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::bitwise_and_scalar(lhs, rhs) + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::bitwise_or(lhs, rhs) + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::bitwise_or_scalar(lhs, rhs) + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::bitwise_xor(lhs, rhs) + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::bitwise_xor_scalar(lhs, rhs) + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + B::bitwise_not(tensor) + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::bitwise_left_shift(lhs, rhs) + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::bitwise_left_shift_scalar(lhs, rhs) + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + B::bitwise_right_shift(lhs, rhs) + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + B::bitwise_right_shift_scalar(lhs, rhs) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + B::int_cast(tensor, dtype) + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + B::int_unfold(tensor, dim, size, step) + } +} diff --git a/crates/burn-autodiff/src/ops/maxmin.rs b/crates/burn-autodiff/src/ops/maxmin.rs new file mode 100644 index 0000000..ecc25d8 --- /dev/null +++ b/crates/burn-autodiff/src/ops/maxmin.rs @@ -0,0 +1,27 @@ +use super::{Backward, Ops, unary}; +use crate::{checkpoint::base::Checkpointer, grads::Gradients}; +use burn_backend::{Backend, TensorMetadata}; +use burn_std::Shape; + +#[derive(Debug)] +pub(crate) struct MaxMinDim; + +impl Backward for MaxMinDim { + type State = (B::IntTensorPrimitive, Shape, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let (indices, shape, dim) = ops.state; + let device = grad.device(); + let dtype = grad.dtype(); + let zeros = B::float_zeros(shape, &device, dtype.into()); + + B::float_scatter_add(dim, zeros, indices, grad) + }); + } +} diff --git a/crates/burn-autodiff/src/ops/mod.rs b/crates/burn-autodiff/src/ops/mod.rs new file mode 100644 index 0000000..1a24067 --- /dev/null +++ b/crates/burn-autodiff/src/ops/mod.rs @@ -0,0 +1,16 @@ +mod activation; +mod backward; +mod base; +mod bool_tensor; +mod distributed; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; + +pub(crate) mod maxmin; +pub(crate) mod sort; + +pub use backward::*; +pub use base::*; diff --git a/crates/burn-autodiff/src/ops/module.rs b/crates/burn-autodiff/src/ops/module.rs new file mode 100644 index 0000000..ddc08d4 --- /dev/null +++ b/crates/burn-autodiff/src/ops/module.rs @@ -0,0 +1,2248 @@ +use alloc::{vec, vec::Vec}; + +use crate::Autodiff; +use crate::checkpoint::base::Checkpointer; +use crate::checkpoint::strategy::CheckpointStrategy; +use crate::grads::Gradients; +use crate::graph::NodeId; +use crate::ops::{Backward, Ops, unary}; +use crate::tensor::AutodiffTensor; + +use burn_backend::TensorMetadata; +use burn_backend::ops::attention::attention_fallback; +use burn_backend::ops::*; +use burn_backend::tensor::{FloatTensor, IntTensor}; +use burn_backend::{Backend, get_device_settings}; +use burn_std::{IntDType, Slice}; + +use super::OpsKind; + +impl ModuleOps> for Autodiff { + fn embedding(weights: AutodiffTensor, indices: IntTensor) -> AutodiffTensor { + #[derive(Debug)] + struct Embedding; + + impl Backward for Embedding { + type State = (B::FloatTensorPrimitive, IntTensor); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (weights, indices) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + B::embedding_backward(weights, grad, indices) + }); + } + } + + match Embedding + .prepare::([weights.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (weights.primitive.clone(), indices.clone()), + B::embedding(weights.primitive, indices), + ), + OpsKind::UnTracked(prep) => prep.finish(B::embedding(weights.primitive, indices)), + } + } + + fn embedding_backward( + _weights: AutodiffTensor, + _output: AutodiffTensor, + _indices: IntTensor, + ) -> AutodiffTensor { + panic!("Can't differentiate embedding backward."); + } + + fn linear( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct LinearWithBias; + #[derive(Debug)] + struct LinearNoBias; + + impl Backward for LinearWithBias { + type State = (Option, Option); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state) = ops.state; + let x = x_state + .map(|id| checkpointer.retrieve_node_output::(id)); + let weight = weight_state + .map(|id| checkpointer.retrieve_node_output::(id)); + + if let Some(node) = node_x { + let grad = B::linear_x_backward(weight.unwrap(), grad.clone()); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::linear_weight_backward(x.unwrap(), grad.clone()); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::linear_bias_backward(grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for LinearNoBias { + type State = (Option, Option); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state) = ops.state; + let x = x_state + .map(|id| checkpointer.retrieve_node_output::(id)); + let weight = weight_state + .map(|id| checkpointer.retrieve_node_output::(id)); + + if let Some(node) = node_x { + let grad = B::linear_x_backward(weight.unwrap(), grad.clone()); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::linear_weight_backward(x.unwrap(), grad); + grads.register::(node.id, grad) + } + } + } + + let x_tracked = x.is_tracked(); + let weight_tracked = weight.is_tracked(); + + match bias { + Some(bias) => match LinearWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + // x is only needed to compute the weight gradient, and vice versa. + let x_state = weight_tracked.then(|| prep.checkpoint(&x)); + let weight_state = x_tracked.then(|| prep.checkpoint(&weight)); + prep.finish( + (x_state, weight_state), + B::linear(x.primitive, weight.primitive, Some(bias.primitive)), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::linear( + x.primitive, + weight.primitive, + Some(bias.primitive), + )), + }, + None => match LinearNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = weight_tracked.then(|| prep.checkpoint(&x)); + let weight_state = x_tracked.then(|| prep.checkpoint(&weight)); + prep.finish( + (x_state, weight_state), + B::linear(x.primitive, weight.primitive, None), + ) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::linear(x.primitive, weight.primitive, None)) + } + }, + } + } + + fn linear_x_backward( + _weight: AutodiffTensor, + _output_grad: AutodiffTensor, + ) -> AutodiffTensor { + panic!("Can't differentiate linear_x_backward."); + } + + fn linear_weight_backward( + _x: AutodiffTensor, + _output_grad: AutodiffTensor, + ) -> AutodiffTensor { + panic!("Can't differentiate linear_weight_backward."); + } + + fn linear_bias_backward(_output_grad: AutodiffTensor) -> AutodiffTensor { + panic!("Can't differentiate linear_bias_backward."); + } + + fn conv1d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct Conv1DWithBias; + #[derive(Debug)] + struct Conv1DNoBias; + + impl Backward for Conv1DWithBias { + type State = (NodeId, NodeId, NodeId, ConvOptions<1>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv1d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv1d_weight_backward(x.clone(), weight, grad.clone(), options); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv1d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for Conv1DNoBias { + type State = (NodeId, NodeId, ConvOptions<1>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv1d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv1d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + match bias { + Some(bias) => match Conv1DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv1d(x.primitive, weight.primitive, Some(bias.primitive), options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv1d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match Conv1DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + prep.finish( + (x_state, weight_state, options.clone()), + B::conv1d(x.primitive, weight.primitive, None, options), + ) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::conv1d(x.primitive, weight.primitive, None, options)) + } + }, + } + } + + fn conv_transpose1d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct ConvTranspose1DWithBias; + #[derive(Debug)] + struct ConvTranspose1DNoBias; + + impl Backward for ConvTranspose1DWithBias { + type State = (NodeId, NodeId, NodeId, ConvTransposeOptions<1>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose1d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose1d_weight_backward( + x.clone(), + weight, + grad.clone(), + options, + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv_transpose1d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for ConvTranspose1DNoBias { + type State = (NodeId, NodeId, ConvTransposeOptions<1>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose1d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose1d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + + match bias { + Some(bias) => match ConvTranspose1DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv_transpose1d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose1d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match ConvTranspose1DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + prep.finish( + (x_state, weight_state, options.clone()), + B::conv_transpose1d(x.primitive, weight.primitive, None, options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose1d( + x.primitive, + weight.primitive, + None, + options, + )), + }, + } + } + + fn conv2d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct Conv2DWithBias; + #[derive(Debug)] + struct Conv2DNoBias; + + impl Backward for Conv2DWithBias { + type State = (NodeId, NodeId, NodeId, ConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv2d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = + B::conv2d_weight_backward(x.clone(), weight.clone(), grad.clone(), options); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv2d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for Conv2DNoBias { + type State = (NodeId, NodeId, ConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv2d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv2d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + + match bias { + Some(bias) => match Conv2DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv2d(x.primitive, weight.primitive, Some(bias.primitive), options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv2d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match Conv2DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + prep.finish( + (x_state, weight_state, options.clone()), + B::conv2d(x.primitive, weight.primitive, None, options), + ) + } + + OpsKind::UnTracked(prep) => { + prep.finish(B::conv2d(x.primitive, weight.primitive, None, options)) + } + }, + } + } + + fn deform_conv2d( + x: AutodiffTensor, + offset: AutodiffTensor, + weight: AutodiffTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct DeformConv2DWithMaskWithBias; + #[derive(Debug)] + struct DeformConv2DWithMaskNoBias; + #[derive(Debug)] + struct DeformConv2DNoMaskWithBias; + #[derive(Debug)] + struct DeformConv2DNoMaskNoBias; + + impl Backward for DeformConv2DWithMaskWithBias { + type State = (NodeId, NodeId, NodeId, NodeId, NodeId, DeformConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_offset, node_weight, node_mask, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, offset_state, weight_state, mask_state, bias_state, options) = + ops.state; + let x = checkpointer.retrieve_node_output(x_state); + let offset = checkpointer.retrieve_node_output(offset_state); + let weight = checkpointer.retrieve_node_output(weight_state); + let mask = Some(checkpointer.retrieve_node_output(mask_state)); + let bias = Some(checkpointer.retrieve_node_output(bias_state)); + + let backward = + B::deform_conv2d_backward(x, offset, weight, mask, bias, grad, options); + + if let Some(node) = node_x { + grads.register::(node.id, backward.x_grad) + } + if let Some(node) = node_offset { + grads.register::(node.id, backward.offset_grad) + } + if let Some(node) = node_weight { + grads.register::(node.id, backward.weight_grad) + } + if let Some(node) = node_mask { + grads.register::(node.id, backward.mask_grad.unwrap()) + } + if let Some(node) = node_bias { + grads.register::(node.id, backward.bias_grad.unwrap()) + } + } + } + + impl Backward for DeformConv2DWithMaskNoBias { + type State = (NodeId, NodeId, NodeId, NodeId, DeformConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_offset, node_weight, node_mask] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, offset_state, weight_state, mask_state, options) = ops.state; + let x = checkpointer.retrieve_node_output(x_state); + let offset = checkpointer.retrieve_node_output(offset_state); + let weight = checkpointer.retrieve_node_output(weight_state); + let mask = Some(checkpointer.retrieve_node_output(mask_state)); + + let backward = + B::deform_conv2d_backward(x, offset, weight, mask, None, grad, options); + + if let Some(node) = node_x { + grads.register::(node.id, backward.x_grad) + } + if let Some(node) = node_offset { + grads.register::(node.id, backward.offset_grad) + } + if let Some(node) = node_weight { + grads.register::(node.id, backward.weight_grad) + } + if let Some(node) = node_mask { + grads.register::(node.id, backward.mask_grad.unwrap()) + } + } + } + + impl Backward for DeformConv2DNoMaskWithBias { + type State = (NodeId, NodeId, NodeId, NodeId, DeformConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_offset, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, offset_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output(x_state); + let offset = checkpointer.retrieve_node_output(offset_state); + let weight = checkpointer.retrieve_node_output(weight_state); + let bias = Some(checkpointer.retrieve_node_output(bias_state)); + + let backward = + B::deform_conv2d_backward(x, offset, weight, None, bias, grad, options); + + if let Some(node) = node_x { + grads.register::(node.id, backward.x_grad) + } + if let Some(node) = node_offset { + grads.register::(node.id, backward.offset_grad) + } + if let Some(node) = node_weight { + grads.register::(node.id, backward.weight_grad) + } + if let Some(node) = node_bias { + grads.register::(node.id, backward.bias_grad.unwrap()) + } + } + } + + impl Backward for DeformConv2DNoMaskNoBias { + type State = (NodeId, NodeId, NodeId, DeformConvOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_offset, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, offset_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output(x_state); + let offset = checkpointer.retrieve_node_output(offset_state); + let weight = checkpointer.retrieve_node_output(weight_state); + + let backward = + B::deform_conv2d_backward(x, offset, weight, None, None, grad, options); + + if let Some(node) = node_x { + grads.register::(node.id, backward.x_grad) + } + if let Some(node) = node_offset { + grads.register::(node.id, backward.offset_grad) + } + if let Some(node) = node_weight { + grads.register::(node.id, backward.weight_grad) + } + } + } + + match (mask, bias) { + (Some(mask), Some(bias)) => match DeformConv2DWithMaskWithBias + .prepare::([ + x.node.clone(), + offset.node.clone(), + weight.node.clone(), + mask.node.clone(), + bias.node.clone(), + ]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let offset_state = prep.checkpoint(&offset); + let weight_state = prep.checkpoint(&weight); + let mask_state = prep.checkpoint(&mask); + let bias_state = prep.checkpoint(&bias); + prep.finish( + ( + x_state, + offset_state, + weight_state, + mask_state, + bias_state, + options.clone(), + ), + B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + Some(mask.primitive), + Some(bias.primitive), + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + Some(mask.primitive), + Some(bias.primitive), + options, + )), + }, + (Some(mask), None) => match DeformConv2DWithMaskNoBias + .prepare::([ + x.node.clone(), + offset.node.clone(), + weight.node.clone(), + mask.node.clone(), + ]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let offset_state = prep.checkpoint(&offset); + let weight_state = prep.checkpoint(&weight); + let mask_state = prep.checkpoint(&mask); + prep.finish( + ( + x_state, + offset_state, + weight_state, + mask_state, + options.clone(), + ), + B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + Some(mask.primitive), + None, + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + Some(mask.primitive), + None, + options, + )), + }, + (None, Some(bias)) => match DeformConv2DNoMaskWithBias + .prepare::([ + x.node.clone(), + offset.node.clone(), + weight.node.clone(), + bias.node.clone(), + ]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let offset_state = prep.checkpoint(&offset); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + prep.finish( + ( + x_state, + offset_state, + weight_state, + bias_state, + options.clone(), + ), + B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + None, + Some(bias.primitive), + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + None, + Some(bias.primitive), + options, + )), + }, + (None, None) => match DeformConv2DNoMaskNoBias + .prepare::([x.node.clone(), offset.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let offset_state = prep.checkpoint(&offset); + let weight_state = prep.checkpoint(&weight); + prep.finish( + (x_state, offset_state, weight_state, options.clone()), + B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + None, + None, + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::deform_conv2d( + x.primitive, + offset.primitive, + weight.primitive, + None, + None, + options, + )), + }, + } + } + + fn deform_conv2d_backward( + _x: AutodiffTensor, + _offset: AutodiffTensor, + _weight: AutodiffTensor, + _mask: Option>, + _bias: Option>, + _output_grad: AutodiffTensor, + _options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + panic!("Can't differentiate deform conv 2d backward."); + } + + fn conv_transpose2d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct ConvTranspose2DWithBias; + #[derive(Debug)] + struct ConvTranspose2DNoBias; + + impl Backward for ConvTranspose2DWithBias { + type State = (NodeId, NodeId, NodeId, ConvTransposeOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose2d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose2d_weight_backward( + x.clone(), + weight, + grad.clone(), + options, + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv_transpose2d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for ConvTranspose2DNoBias { + type State = (NodeId, NodeId, ConvTransposeOptions<2>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose2d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose2d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + + match bias { + Some(bias) => match ConvTranspose2DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv_transpose2d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose2d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match ConvTranspose2DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + + prep.finish( + (x_state, weight_state, options.clone()), + B::conv_transpose2d(x.primitive, weight.primitive, None, options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose2d( + x.primitive, + weight.primitive, + None, + options, + )), + }, + } + } + + fn conv3d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct Conv3DWithBias; + #[derive(Debug)] + struct Conv3DNoBias; + + impl Backward for Conv3DWithBias { + type State = (NodeId, NodeId, NodeId, ConvOptions<3>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv3d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = + B::conv3d_weight_backward(x.clone(), weight.clone(), grad.clone(), options); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv3d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for Conv3DNoBias { + type State = (NodeId, NodeId, ConvOptions<3>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv3d_x_backward( + x.clone(), + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv3d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + + match bias { + Some(bias) => match Conv3DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv3d(x.primitive, weight.primitive, Some(bias.primitive), options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv3d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match Conv3DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + prep.finish( + (x_state, weight_state, options.clone()), + B::conv3d(x.primitive, weight.primitive, None, options), + ) + } + + OpsKind::UnTracked(prep) => { + prep.finish(B::conv3d(x.primitive, weight.primitive, None, options)) + } + }, + } + } + + fn conv_transpose3d( + x: AutodiffTensor, + weight: AutodiffTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> AutodiffTensor { + #[derive(Debug)] + struct ConvTranspose3DWithBias; + #[derive(Debug)] + struct ConvTranspose3DNoBias; + + impl Backward for ConvTranspose3DWithBias { + type State = (NodeId, NodeId, NodeId, ConvTransposeOptions<3>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight, node_bias] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, bias_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + let bias = checkpointer.retrieve_node_output::(bias_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose3d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose3d_weight_backward( + x.clone(), + weight, + grad.clone(), + options, + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_bias { + let grad = B::conv_transpose3d_bias_backward(x, bias, grad); + grads.register::(node.id, grad) + } + } + } + + impl Backward for ConvTranspose3DNoBias { + type State = (NodeId, NodeId, ConvTransposeOptions<3>); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_x, node_weight] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, weight_state, options) = ops.state; + let x = checkpointer.retrieve_node_output::(x_state); + let weight = + checkpointer.retrieve_node_output::(weight_state); + + if let Some(node) = node_x { + let grad = B::conv_transpose3d_x_backward( + weight.clone(), + grad.clone(), + options.clone(), + ); + grads.register::(node.id, grad) + } + if let Some(node) = node_weight { + let grad = B::conv_transpose3d_weight_backward(x, weight, grad, options); + grads.register::(node.id, grad) + } + } + } + + match bias { + Some(bias) => match ConvTranspose3DWithBias + .prepare::([x.node.clone(), weight.node.clone(), bias.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + let bias_state = prep.checkpoint(&bias); + + prep.finish( + (x_state, weight_state, bias_state, options.clone()), + B::conv_transpose3d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose3d( + x.primitive, + weight.primitive, + Some(bias.primitive), + options, + )), + }, + None => match ConvTranspose3DNoBias + .prepare::([x.node.clone(), weight.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let weight_state = prep.checkpoint(&weight); + + prep.finish( + (x_state, weight_state, options.clone()), + B::conv_transpose3d(x.primitive, weight.primitive, None, options), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::conv_transpose3d( + x.primitive, + weight.primitive, + None, + options, + )), + }, + } + } + + // TODO: Support a custom unfold4d operation by overriding the default implementation. + // + // We don't override it now because the fold operation isn't available for the backward pass. + // This implies that when autodiff is enabled, custom unfold operations defined by backends + // won't be used. Instead, the conv2d operation with custom weights matrix will be used. + // Therefore, the conv2d backward pass will be used for the unfold4d backward pass. + // + // fn unfold4d( + // x:AutodiffTensor, + // kernel_size: [usize; 2], + // options: UnfoldOptions, + // ) -> AutodiffTensor { + // todo!() + // } + + fn avg_pool1d( + x: AutodiffTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> AutodiffTensor { + #[derive(Debug)] + struct AvgPool1D; + + impl Backward for AvgPool1D { + type State = (NodeId, usize, usize, usize, bool, bool); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let (x_state, kernel_size, stride, padding, count_include_pad, ceil_mode) = + ops.state; + let x = checkpointer.retrieve_node_output(x_state); + + if let Some(node) = node_parent { + let grad = B::avg_pool1d_backward( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ); + grads.register::(node.id, grad); + } + } + } + + match AvgPool1D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + prep.finish( + ( + x_state, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + B::avg_pool1d( + x.primitive.clone(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::avg_pool1d( + x.primitive, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + )), + } + } + + fn avg_pool2d( + x: AutodiffTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> AutodiffTensor { + #[derive(Debug)] + struct AvgPool2D; + + impl Backward for AvgPool2D { + type State = (NodeId, [usize; 2], [usize; 2], [usize; 2], bool, bool); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let (x_state, kernel_size, stride, padding, count_include_pad, ceil_mode) = + ops.state; + let x = checkpointer.retrieve_node_output(x_state); + + if let Some(node) = node_parent { + let grad = B::avg_pool2d_backward( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ); + grads.register::(node.id, grad); + } + } + } + + match AvgPool2D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + prep.finish( + ( + x_state, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + B::avg_pool2d( + x.primitive.clone(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::avg_pool2d( + x.primitive, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + )), + } + } + + fn avg_pool2d_backward( + _x: AutodiffTensor, + _grad: AutodiffTensor, + _kernel_size: [usize; 2], + _stride: [usize; 2], + _padding: [usize; 2], + _count_include_pad: bool, + _ceil_mode: bool, + ) -> AutodiffTensor { + panic!("Can't differentiate avg pool 2d backward."); + } + + fn max_pool1d( + x: AutodiffTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> AutodiffTensor { + match MaxPool1D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let settings = get_device_settings::(&x.primitive.device()); + let output = B::max_pool1d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + settings.int_dtype, + ); + prep.finish( + ( + x_state, + output.indices, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + output.output, + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::max_pool1d( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )), + } + } + + fn max_pool1d_with_indices( + x: AutodiffTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + int_dtype: IntDType, + ) -> MaxPool1dWithIndices { + match MaxPool1D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let output = B::max_pool1d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + int_dtype, + ); + + let output_tensor = prep.finish( + ( + x_state, + output.indices.clone(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + output.output, + ); + + MaxPool1dWithIndices::new(output_tensor, output.indices) + } + OpsKind::UnTracked(prep) => { + let output = B::max_pool1d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + int_dtype, + ); + let output_tensor = prep.finish(output.output); + + MaxPool1dWithIndices::new(output_tensor, output.indices) + } + } + } + + fn max_pool1d_with_indices_backward( + x: AutodiffTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: AutodiffTensor, + indices: IntTensor, + ) -> MaxPool1dBackward { + let output = B::max_pool1d_with_indices_backward( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + output_grad.primitive, + indices, + ); + MaxPool1dBackward::new(AutodiffTensor::new(output.x_grad)) + } + + fn max_pool2d( + x: AutodiffTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> AutodiffTensor { + match MaxPool2D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let settings = get_device_settings::(&x.primitive.device()); + let output = B::max_pool2d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + settings.int_dtype, + ); + prep.finish( + ( + x_state, + output.indices, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + output.output, + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::max_pool2d( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )), + } + } + + fn max_pool2d_with_indices( + x: AutodiffTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + int_dtype: IntDType, + ) -> MaxPool2dWithIndices { + match MaxPool2D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + + let output = B::max_pool2d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + int_dtype, + ); + + let output_tensor = prep.finish( + ( + x_state, + output.indices.clone(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + output.output, + ); + + MaxPool2dWithIndices::new(output_tensor, output.indices) + } + OpsKind::UnTracked(prep) => { + let output = B::max_pool2d_with_indices( + x.primitive, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + int_dtype, + ); + let output_tensor = prep.finish(output.output); + + MaxPool2dWithIndices::new(output_tensor, output.indices) + } + } + } + + fn max_pool2d_with_indices_backward( + _x: AutodiffTensor, + _kernel_size: [usize; 2], + _stride: [usize; 2], + _padding: [usize; 2], + _dilation: [usize; 2], + _ceil_mode: bool, + _output_grad: AutodiffTensor, + _indices: IntTensor, + ) -> MaxPool2dBackward { + panic!("Can't differentiate max pool2d with indices backward."); + } + fn adaptive_avg_pool1d(x: AutodiffTensor, output_size: usize) -> AutodiffTensor { + #[derive(Debug)] + struct AdaptiveAvgPool1D; + + impl Backward for AdaptiveAvgPool1D { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let state = checkpointer.retrieve_node_output(ops.state); + + if let Some(node) = node_parent { + let grad = B::adaptive_avg_pool1d_backward(state, grad); + grads.register::(node.id, grad); + } + } + } + + match AdaptiveAvgPool1D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + prep.finish(x_state, B::adaptive_avg_pool1d(x.primitive, output_size)) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::adaptive_avg_pool1d(x.primitive, output_size)) + } + } + } + + fn adaptive_avg_pool2d(x: AutodiffTensor, output_size: [usize; 2]) -> AutodiffTensor { + #[derive(Debug)] + struct AdaptiveAvgPool2D; + + impl Backward for AdaptiveAvgPool2D { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let state = checkpointer.retrieve_node_output(ops.state); + + if let Some(node) = node_parent { + let grad = B::adaptive_avg_pool2d_backward(state, grad); + grads.register::(node.id, grad); + } + } + } + + match AdaptiveAvgPool2D + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + prep.finish(x_state, B::adaptive_avg_pool2d(x.primitive, output_size)) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::adaptive_avg_pool2d(x.primitive, output_size)) + } + } + } + + fn adaptive_avg_pool2d_backward( + _x: AutodiffTensor, + _grad: AutodiffTensor, + ) -> AutodiffTensor { + panic!("Can't differentiate adaptive avg pool2d backward."); + } + + fn interpolate( + x: AutodiffTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> AutodiffTensor { + #[derive(Debug)] + struct Interpolate; + impl Backward for Interpolate { + type State = (NodeId, [usize; 2], InterpolateOptions); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + + let (x_state, output_size, options) = ops.state; + let state = checkpointer.retrieve_node_output(x_state); + + if let Some(node) = node_parent { + let grad = B::interpolate_backward(state, grad, output_size, options); + grads.register::(node.id, grad); + } + } + } + + match Interpolate + .prepare::([x.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let x_state = prep.checkpoint(&x); + let output = B::interpolate(x.primitive.clone(), output_size, options.clone()); + prep.finish((x_state, output_size, options), output) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::interpolate(x.primitive, output_size, options)) + } + } + } + + fn interpolate_backward( + _x: FloatTensor>, + _grad: FloatTensor>, + _output_size: [usize; 2], + _options: InterpolateOptions, + ) -> AutodiffTensor { + panic!("Can't differentiate interpolate backward."); + } + + fn attention( + query: FloatTensor>, + key: FloatTensor>, + value: FloatTensor>, + mask: Option>>, + attn_bias: Option>>, + options: AttentionModuleOptions, + ) -> FloatTensor> { + attention_fallback::(query, key, value, mask, attn_bias, options) + } + + fn ctc_loss( + log_probs: FloatTensor>, + targets: IntTensor>, + input_lengths: IntTensor>, + target_lengths: IntTensor>, + blank: usize, + ) -> FloatTensor> { + // Backends without a native ctc_loss_backward fall back to the default + // implementation, which is built from differentiable tensor ops so the + // autodiff layer derives the gradient automatically. + if !B::has_ctc_loss_backward() { + return burn_backend::ops::ctc::ctc_loss_default::( + log_probs, + targets, + input_lengths, + target_lengths, + blank, + ); + } + + #[derive(Debug)] + struct CtcLoss; + + impl Backward for CtcLoss { + type State = (NodeId, IntTensor, IntTensor, IntTensor, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad_loss = grads.consume::(&ops.node); + + let (log_probs_state, targets, input_lengths, target_lengths, blank) = ops.state; + let log_probs: B::FloatTensorPrimitive = + checkpointer.retrieve_node_output(log_probs_state); + + if let Some(node) = node_parent { + let grad = B::ctc_loss_backward( + log_probs, + targets, + input_lengths, + target_lengths, + grad_loss, + blank, + ); + grads.register::(node.id, grad); + } + } + } + + match CtcLoss + .prepare::([log_probs.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let log_probs_state = prep.checkpoint(&log_probs); + let output = B::ctc_loss( + log_probs.primitive.clone(), + targets.clone(), + input_lengths.clone(), + target_lengths.clone(), + blank, + ); + prep.finish( + ( + log_probs_state, + targets, + input_lengths, + target_lengths, + blank, + ), + output, + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::ctc_loss( + log_probs.primitive, + targets, + input_lengths, + target_lengths, + blank, + )), + } + } + + fn rfft( + signal: FloatTensor>, + dim: usize, + n: Option, + ) -> (FloatTensor>, FloatTensor>) { + #[derive(Debug)] + struct Rfft; + + impl Backward for Rfft { + type State = (usize, Option, usize, usize, Vec, Vec); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let (dim, n, input_len, n_fft, slices_re, slices_im) = ops.state; + + let grad_re = B::float_slice(grad.clone(), &slices_re); + let grad_im = B::float_slice(grad.clone(), &slices_im); + + let grad_re = mul_interior::(grad_re, dim, n_fft, 0.5); + let grad_im = mul_interior::(grad_im, dim, n_fft, 0.5); + + let grad = B::irfft(grad_re, grad_im, dim, n); + let grad = B::float_mul_scalar(grad, (n_fft as f64).into()); + + pad_to_length::(grad, dim, input_len) + }); + } + } + + let input_len = signal.shape()[dim]; + let n_fft = n.unwrap_or(input_len); + let (re, im) = B::rfft(signal.primitive, dim, n); + + // In order to perform only a single irfft in the backward pass, we have to temporarily + // bundle `re` and `im` into a single tensor. The following slice vecs are used to split + // them back up in both the forward and the backward pass. + let slices_re = re + .shape() + .iter() + .map(|&len| Slice::from(0..len)) + .collect::>(); + let slices_im = { + let mut slices = slices_re.clone(); + let len = slices[0].end.unwrap(); + slices[0].start = len; + slices[0].end = Some(2 * len); + slices + }; + let spectrum = B::float_cat(vec![re, im], 0); + let state = ( + dim, + n, + input_len, + n_fft, + slices_re.clone(), + slices_im.clone(), + ); + + let spectrum = match Rfft + .prepare::([signal.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish(state, spectrum), + OpsKind::UnTracked(prep) => prep.finish(spectrum), + }; + + let re = Self::float_slice(spectrum.clone(), &slices_re); + let im = Self::float_slice(spectrum.clone(), &slices_im); + + (re, im) + } + + fn irfft( + spectrum_re: FloatTensor>, + spectrum_im: FloatTensor>, + dim: usize, + n: Option, + ) -> FloatTensor> { + #[derive(Debug)] + struct Irfft; + + impl Backward for Irfft { + type State = (usize, Option, usize, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, n, input_len, n_fft) = ops.state; + let [node_re, node_im] = ops.parents; + let grad = grads.consume::(&ops.node); + + let grad = B::float_div_scalar(grad, (n_fft as f64).into()); + + let (grad_re, grad_im) = B::rfft(grad, dim, n); + + let grad_re = mul_interior::(grad_re, dim, n_fft, 2.0); + let grad_im = mul_interior::(grad_im, dim, n_fft, 2.0); + + let grad_re = pad_to_length::(grad_re, dim, input_len); + let grad_im = pad_to_length::(grad_im, dim, input_len); + + if let Some(node) = node_re { + grads.register::(node.id, grad_re); + } + if let Some(node) = node_im { + grads.register::(node.id, grad_im); + } + } + } + + let input_len = spectrum_re.shape()[dim]; + let signal = B::irfft(spectrum_re.primitive, spectrum_im.primitive, dim, n); + let n_fft = n.unwrap_or(signal.shape()[dim]); + let state = (dim, n, input_len, n_fft); + + match Irfft + .prepare::([spectrum_re.node.clone(), spectrum_im.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish(state, signal), + OpsKind::UnTracked(prep) => prep.finish(signal), + } + } +} + +// adapted from: burn_cubecl::kernel::fft::base::pad_to_length +fn pad_to_length(tensor: FloatTensor, dim: usize, target: usize) -> FloatTensor { + let shape = tensor.shape(); + let current = shape[dim]; + if current == target { + return tensor; + } + if current > target { + let slices: Vec<_> = shape + .iter() + .enumerate() + .map(|(i, &s)| Slice::from(if i == dim { 0..target } else { 0..s })) + .collect(); + return B::float_slice(tensor, &slices); + } + let mut padded_shape = shape.clone(); + padded_shape[dim] = target; + let padded = B::float_zeros(padded_shape, &tensor.device(), tensor.dtype().into()); + let slices: Vec = shape.iter().map(|&s| Slice::from(0..s)).collect(); + B::float_slice_assign(padded, &slices, tensor) +} + +fn mul_interior( + bins: FloatTensor, + dim: usize, + n_fft: usize, + factor: f64, +) -> FloatTensor { + // identify the interior bins (all bins except DC and Nyquist) + let slices_interior: Vec = { + let mut ranges = bins.shape().into_ranges(); + + // skip the DC bin + ranges[dim].start += 1; + + // if `n_fft` is even, we have a Nyquist bin to skip + if n_fft.is_multiple_of(2) { + ranges[dim].end -= 1; + } + + ranges.into_iter().map(Slice::from).collect() + }; + + // multiply only the interior bins by `factor` + let interior = B::float_slice(bins.clone(), &slices_interior); + let interior = B::float_mul_scalar(interior, factor.into()); + + B::float_slice_assign(bins, &slices_interior, interior) +} + +#[derive(Debug)] +struct MaxPool1D; + +impl Backward for MaxPool1D { + type State = (NodeId, IntTensor, usize, usize, usize, usize, bool); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let (x_state, indices, kernel_size, stride, padding, dilation, ceil_mode) = ops.state; + let x = checkpointer.retrieve_node_output(x_state); + + if let Some(node) = node_parent { + let grad = B::max_pool1d_with_indices_backward( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + grad, + indices, + ); + + grads.register::(node.id, grad.x_grad); + } + } +} + +#[derive(Debug)] +struct MaxPool2D; + +impl Backward for MaxPool2D { + type State = ( + NodeId, + IntTensor, + [usize; 2], + [usize; 2], + [usize; 2], + [usize; 2], + bool, + ); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let [node_parent] = ops.parents; + let grad = grads.consume::(&ops.node); + let (x_state, indices, kernel_size, stride, padding, dilation, ceil_mode) = ops.state; + let x = checkpointer.retrieve_node_output(x_state); + + if let Some(node) = node_parent { + let grad = B::max_pool2d_with_indices_backward( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + grad, + indices, + ); + + grads.register::(node.id, grad.x_grad); + } + } +} diff --git a/crates/burn-autodiff/src/ops/qtensor.rs b/crates/burn-autodiff/src/ops/qtensor.rs new file mode 100644 index 0000000..999b20d --- /dev/null +++ b/crates/burn-autodiff/src/ops/qtensor.rs @@ -0,0 +1,73 @@ +use burn_backend::{ + Backend, ExecutionError, TensorData, + ops::QTensorOps, + quantization::QuantizationParametersPrimitive, + tensor::{Device, FloatTensor, IntTensor, QuantizedTensor}, +}; +use burn_std::{FloatDType, IntDType, QuantScheme, Shape}; + +use crate::{Autodiff, checkpoint::strategy::CheckpointStrategy}; + +impl QTensorOps for Autodiff { + fn q_from_data(_data: TensorData, _device: &Device) -> QuantizedTensor { + todo!() + } + + fn quantize( + _tensor: FloatTensor, + _scheme: &QuantScheme, + _qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + todo!() // required for QAT + } + + fn quantize_dynamic( + _tensor: FloatTensor, + _scheme: &QuantScheme, + ) -> QuantizedTensor { + todo!() + } + + fn dequantize(_tensor: QuantizedTensor, _dtype: FloatDType) -> FloatTensor { + todo!() + } + + fn q_to_device( + _tensor: QuantizedTensor, + _device: &Device, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + B::q_reshape(tensor, shape) + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + B::q_into_data(tensor).await + } + + fn q_swap_dims( + _tensor: QuantizedTensor, + _dim1: usize, + _dim2: usize, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_permute(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_flip(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_argmax(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + B::q_argmax(tensor, dim, out_dtype) + } + + fn q_argmin(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + B::q_argmin(tensor, dim, out_dtype) + } +} diff --git a/crates/burn-autodiff/src/ops/sort.rs b/crates/burn-autodiff/src/ops/sort.rs new file mode 100644 index 0000000..3a580a9 --- /dev/null +++ b/crates/burn-autodiff/src/ops/sort.rs @@ -0,0 +1,27 @@ +use super::{Backward, Ops, unary}; +use crate::{checkpoint::base::Checkpointer, grads::Gradients}; +use burn_backend::{Backend, TensorMetadata}; +use burn_std::Shape; + +#[derive(Debug)] +pub(crate) struct SortDim; + +impl Backward for SortDim { + type State = (B::IntTensorPrimitive, Shape, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let (indices, shape, dim) = ops.state; + let device = grad.device(); + let dtype = grad.dtype(); + let zeros = B::float_zeros(shape, &device, dtype.into()); + + B::float_scatter_add(dim, zeros, indices, grad) + }); + } +} diff --git a/crates/burn-autodiff/src/ops/tensor.rs b/crates/burn-autodiff/src/ops/tensor.rs new file mode 100644 index 0000000..38582d6 --- /dev/null +++ b/crates/burn-autodiff/src/ops/tensor.rs @@ -0,0 +1,4055 @@ +use alloc::{boxed::Box, vec, vec::Vec}; +use core::marker::PhantomData; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports, reason = "required on aarch64, unused on x86_64")] +use num_traits::float::Float; + +use crate::{ + Autodiff, + checkpoint::{ + base::Checkpointer, builder::CheckpointerBuilder, retro_forward::RetroForward, + state::BackwardStates, strategy::CheckpointStrategy, + }, + grads::Gradients, + graph::{ComputingProperty, NodeId, NodeRef, Parent, Requirement, Step}, + ops::{Backward, Ops, OpsKind, binary, broadcast_shape, unary}, + retro_binary, retro_unary, retro_unary_scalar, + tensor::AutodiffTensor, + utils::duplicate, +}; + +use burn_backend::{ + Backend, ExecutionError, TensorData, TensorMetadata, get_device_settings, + ops::FloatTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_backend::{Scalar, ops::unfold::calculate_unfold_windows}; +use burn_std::{BoolDType, FloatDType, IntDType, Shape, Slice}; + +use burn_backend::distributed::DistributedParams; + +use super::maxmin::MaxMinDim; + +// Unsqueeze op on primitive. +fn unsqueeze_like( + tensor: B::FloatTensorPrimitive, + shape: Shape, +) -> B::FloatTensorPrimitive { + let ndims_out = shape.num_dims(); + let shape = tensor.shape(); + let ndims_in = shape.num_dims(); + + let mut dims = vec![1; ndims_out]; + let num_ones = ndims_out - ndims_in; + dims[num_ones..(ndims_in + num_ones)].copy_from_slice(&shape[..ndims_in]); + + B::float_reshape(tensor, Shape::from(dims)) +} + +impl FloatTensorOps for Autodiff { + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(data), + fields(?data.shape, ?data.dtype) + ))] + fn float_from_data(data: TensorData, device: &Device) -> FloatTensor { + AutodiffTensor::new(B::float_from_data(data, device)) + } + + fn float_random( + shape: Shape, + distribution: burn_backend::Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + AutodiffTensor::new(B::float_random(shape, distribution, device, dtype)) + } + + fn float_zeros(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + AutodiffTensor::new(B::float_zeros(shape, device, dtype)) + } + + fn float_ones(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + AutodiffTensor::new(B::float_ones(shape, device, dtype)) + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields( + from = ?tensor.node, + shape = ?tensor.shape(), + dtype = ?tensor.dtype(), + ) + ))] + async fn float_into_data(tensor: FloatTensor) -> Result { + B::float_into_data(tensor.primitive).await + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields( + from = ?tensor.node, + shape = ?tensor.shape(), + dtype = ?tensor.dtype(), + ) + ))] + fn float_to_device(tensor: FloatTensor, device: &Device) -> FloatTensor { + #[derive(Debug)] + struct ToDevice; + + impl Backward for ToDevice { + type State = B::Device; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + B::float_to_device(grad, &ops.state) + }); + } + } + + match ToDevice + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let device_old = tensor.primitive.device(); + prep.finish(device_old, B::float_to_device(tensor.primitive, device)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_to_device(tensor.primitive, device)), + } + } + + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + AutodiffTensor::new(B::float_empty(shape, device, dtype)) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Add; + + retro_binary!(RetroAdd, B::float_add); + + impl Backward for Add { + type State = (Shape, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape_lhs, shape_rhs) = ops.state; + + binary::( + ops.parents, + ops.node, + grads, + |grad| broadcast_shape::(grad, &shape_lhs), + |grad| broadcast_shape::(grad, &shape_rhs), + ); + } + } + + match Add + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroAdd::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (lhs.primitive.shape(), rhs.primitive.shape()), + B::float_add(lhs.primitive, rhs.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_add(lhs.primitive, rhs.primitive)), + } + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + #[derive(Debug)] + struct AddScalar; + + retro_unary_scalar!(RetroAddScalar, B::float_add_scalar); + + impl Backward for AddScalar { + type State = (); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| grad); + } + } + + AddScalar + .prepare::([lhs.node.clone()]) + .memory_bound() + .retro_forward(RetroAddScalar::::new(lhs.node.id, rhs)) + .parents([&lhs]) + .stateless(B::float_add_scalar(lhs.primitive, rhs)) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sub; + + retro_binary!(RetroSub, B::float_sub); + + impl Backward for Sub { + type State = (Shape, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape_lhs, shape_rhs) = ops.state; + + binary::( + ops.parents, + ops.node, + grads, + |grad| broadcast_shape::(grad, &shape_lhs), + |grad| broadcast_shape::(B::float_neg(grad), &shape_rhs), + ); + } + } + + match Sub + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroSub::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (lhs.primitive.shape(), rhs.primitive.shape()), + B::float_sub(lhs.primitive, rhs.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_sub(lhs.primitive, rhs.primitive)), + } + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + #[derive(Debug)] + struct SubScalar; + + retro_unary_scalar!(RetroSubScalar, B::float_sub_scalar); + + impl Backward for SubScalar { + type State = (); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| grad); + } + } + + SubScalar + .prepare::([lhs.node.clone()]) + .memory_bound() + .retro_forward(RetroSubScalar::::new(lhs.node.id, rhs)) + .parents([&lhs]) + .stateless(B::float_sub_scalar(lhs.primitive, rhs)) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Mul; + + retro_binary!(RetroMul, B::float_mul); + + impl Backward for Mul { + type State = (Option, Option, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs, rhs, broadcast) = ops.state; + let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs)); + let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let grad = B::float_mul(grad, rhs.unwrap()); + broadcast.backward_lhs::(grad) + }, + |grad| { + let grad = B::float_mul(grad, lhs.unwrap()); + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let lhs_tracked = lhs.is_tracked(); + let rhs_tracked = rhs.is_tracked(); + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + + match Mul + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroMul::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs)); + let rhs_state = lhs_tracked.then(|| prep.checkpoint(&rhs)); + + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_mul(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_mul(lhs.primitive, rhs.primitive)), + } + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + #[derive(Debug)] + struct MulScalar; + + retro_unary_scalar!(RetroMulScalar, B::float_mul_scalar); + + impl Backward for MulScalar { + type State = Scalar; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mul_scalar(grad, ops.state) + }); + } + } + + match MulScalar + .prepare::([lhs.node.clone()]) + .memory_bound() + .retro_forward(RetroMulScalar::::new(lhs.node.id, rhs)) + .parents([&lhs]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish(rhs, B::float_mul_scalar(lhs.primitive, rhs)), + OpsKind::UnTracked(prep) => prep.finish(B::float_mul_scalar(lhs.primitive, rhs)), + } + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Div; + + retro_binary!(RetroDiv, B::float_div); + + impl Backward for Div { + type State = (Option, Option, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs, rhs, broadcast) = ops.state; + let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs)); + let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs)); + let [rhs_4lhs, rhs_4rhs] = duplicate(&ops.parents, rhs); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let rhs = rhs_4lhs.unwrap(); + let value = B::float_recip(rhs); + let grad = B::float_mul(grad, value); + + broadcast.backward_lhs::(grad) + }, + |grad| { + let rhs = rhs_4rhs.unwrap(); + let lhs = lhs.unwrap(); + let value = + B::float_div(B::float_neg(lhs), B::float_powi_scalar(rhs, 2.into())); + let grad = B::float_mul(grad, value); + + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let lhs_tracked = lhs.is_tracked(); + let rhs_tracked = rhs.is_tracked(); + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + + match Div + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroDiv::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs)); + let rhs_state = (lhs_tracked || rhs_tracked).then(|| prep.checkpoint(&rhs)); + + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_div(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_div(lhs.primitive, rhs.primitive)), + } + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + #[derive(Debug)] + struct DivScalar; + + retro_unary_scalar!(RetroDivScalar, B::float_div_scalar); + + impl Backward for DivScalar { + type State = Scalar; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let tmp = 1.0 / ops.state.elem::(); + B::float_mul_scalar(grad, tmp.into()) + }); + } + } + + match DivScalar + .prepare::([lhs.node.clone()]) + .memory_bound() + .retro_forward(RetroDivScalar::::new(lhs.node.id, rhs)) + .parents([&lhs]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish(rhs, B::float_div_scalar(lhs.primitive, rhs)), + OpsKind::UnTracked(prep) => prep.finish(B::float_div_scalar(lhs.primitive, rhs)), + } + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Rem; + + retro_binary!(RetroRem, B::float_remainder); + + impl Backward for Rem { + type State = (Option, Option, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs, rhs, broadcast) = ops.state; + let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs)); + let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + // remainder(x, y) = x - floor(x / y) * y + // partial(x - floor(x / y) * y, x) = 1 + broadcast.backward_lhs::(grad) + }, + |grad| { + // partial(x - floor(x / y) * y, y) = - floor(x / y) + let rhs = rhs.unwrap(); + let lhs = lhs.unwrap(); + let value = B::float_neg(B::float_floor(B::float_div(lhs, rhs))); + let grad = B::float_mul(grad, value); + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let lhs_tracked = lhs.is_tracked(); + let rhs_tracked = rhs.is_tracked(); + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + + match Rem + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroRem::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs)); + let rhs_state = (lhs_tracked || rhs_tracked).then(|| prep.checkpoint(&rhs)); + + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_remainder(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::float_remainder(lhs.primitive, rhs.primitive)) + } + } + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + #[derive(Debug)] + struct RemainderScalar; + + retro_unary_scalar!(RetroRemainderScalar, B::float_remainder_scalar); + + impl Backward for RemainderScalar { + type State = (); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| grad); + } + } + + RemainderScalar + .prepare::([lhs.node.clone()]) + .memory_bound() + .retro_forward(RetroRemainderScalar::::new(lhs.node.id, rhs)) + .parents([&lhs]) + .stateless(B::float_remainder_scalar(lhs.primitive, rhs)) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Matmul; + + impl Backward for Matmul { + type State = (Option, Option, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs, rhs, broadcast) = ops.state; + let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs)); + let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let rhs = B::float_transpose(rhs.unwrap()); + let grad = B::float_matmul(grad, rhs); + + broadcast.backward_lhs::(grad) + }, + |grad| { + let lhs = B::float_transpose(lhs.unwrap()); + let grad = B::float_matmul(lhs, grad); + + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let lhs_tracked = lhs.is_tracked(); + let rhs_tracked = rhs.is_tracked(); + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + + match Matmul + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs)); + let rhs_state = lhs_tracked.then(|| prep.checkpoint(&rhs)); + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_matmul(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_matmul(lhs.primitive, rhs.primitive)), + } + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + #[derive(Debug)] + struct Cross; + + impl Backward for Cross { + type State = (Option, Option, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs_id, rhs_id, dim) = ops.state; + let lhs = lhs_id.map(|id| checkpointer.retrieve_node_output(id)); + let rhs = rhs_id.map(|id| checkpointer.retrieve_node_output(id)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| B::float_cross(rhs.unwrap(), grad, dim), + |grad| B::float_cross(grad, lhs.unwrap(), dim), + ); + } + } + + let lhs_tracked = lhs.is_tracked(); + let rhs_tracked = rhs.is_tracked(); + + match Cross + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs)); + let rhs_state = lhs_tracked.then(|| prep.checkpoint(&rhs)); + prep.finish( + (lhs_state, rhs_state, dim), + B::float_cross(lhs.primitive, rhs.primitive, dim), + ) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::float_cross(lhs.primitive, rhs.primitive, dim)) + } + } + } + + fn float_neg(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Neg; + + retro_unary!(RetroNeg, B::float_neg); + + impl Backward for Neg { + type State = (); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| B::float_neg(grad)); + } + } + + Neg.prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroNeg::::new(tensor.node.id)) + .parents([&tensor]) + .stateless(B::float_neg(tensor.primitive)) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Recip; + + retro_unary!(RetroRecip, B::float_recip); + + impl Backward for Recip { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let tensor = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let tmp = B::float_powi_scalar(tensor, (-2).into()); + let value = B::float_neg(tmp); + + B::float_mul(grad, value) + }); + } + } + + match Recip + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroRecip::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_recip(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_recip(tensor.primitive)), + } + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + #[derive(Debug)] + struct SwapDim; + + #[derive(new, Debug)] + struct RetroSwapDims { + input_id: NodeId, + dim1: usize, + dim2: usize, + _backend: PhantomData, + } + + impl RetroForward for RetroSwapDims { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_swap_dims(input, self.dim1, self.dim2); + states.save(out_node, out) + } + } + + impl Backward for SwapDim { + type State = (usize, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim1, dim2) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + B::float_swap_dims(grad, dim2, dim1) + }); + } + } + + match SwapDim + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSwapDims::::new(tensor.node.id, dim1, dim2)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (dim1, dim2), + B::float_swap_dims(tensor.primitive, dim1, dim2), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_swap_dims(tensor.primitive, dim1, dim2)) + } + } + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + #[derive(Debug)] + struct PermuteDim; + + #[derive(new, Debug)] + struct RetroPermuteDims { + input_id: NodeId, + axes: Vec, + _backend: PhantomData, + } + + impl RetroForward for RetroPermuteDims { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_permute(input, &self.axes); + states.save(out_node, out) + } + } + + impl Backward for PermuteDim { + type State = Vec; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let axes = ops.state; + + let mut inverse = vec![0usize; axes.len()]; + axes.iter() + .enumerate() + .for_each(|(i, &axis)| inverse[axis] = i); + + unary::(ops.parents, ops.node, grads, |grad| { + B::float_permute(grad, &inverse) + }); + } + } + + match PermuteDim + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroPermuteDims::::new(tensor.node.id, axes.to_vec())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => { + prep.finish(axes.to_vec(), B::float_permute(tensor.primitive, axes)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_permute(tensor.primitive, axes)), + } + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + #[derive(Debug)] + struct FlipDim; + + #[derive(new, Debug)] + struct RetroFlipDims { + input_id: NodeId, + axes: Vec, + _backend: PhantomData, + } + + impl RetroForward for RetroFlipDims { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_flip(input, &self.axes); + states.save(out_node, out) + } + } + + impl Backward for FlipDim { + type State = Vec; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let axes = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + B::float_flip(grad, &axes) + }); + } + } + + match FlipDim + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroFlipDims::::new(tensor.node.id, axes.to_vec())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => { + prep.finish(axes.to_vec(), B::float_flip(tensor.primitive, axes)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_flip(tensor.primitive, axes)), + } + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + #[derive(Debug)] + struct ReshapeDim; + + #[derive(new, Debug)] + struct RetroReshape { + input_id: NodeId, + shape: Shape, + _backend: PhantomData, + } + + impl RetroForward for RetroReshape { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_reshape(input, self.shape.clone()); + states.save(out_node, out) + } + } + + impl Backward for ReshapeDim { + type State = (Shape, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape_original, shape) = ops.state; + let ndims_out = shape.num_dims(); + + unary::(ops.parents, ops.node, grads, |grad| { + let shape_grad = grad.shape(); + let mut grad = grad; + + for i in 0..ndims_out { + if shape[i] == 1 && shape_grad[i] != 1 { + grad = B::float_sum_dim(grad, i); + } + } + + B::float_reshape(grad, shape_original) + }); + } + } + + match ReshapeDim + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroReshape::::new(tensor.node.id, shape.clone())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), shape.clone()), + B::float_reshape(tensor.primitive, shape), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_reshape(tensor.primitive, shape)), + } + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct Gather; + + impl Backward for Gather { + type State = (usize, IntTensor, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, indices, shape, device) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let zeros = B::float_zeros(shape, &device, grad.dtype().into()); + B::float_scatter_add(dim, zeros, indices, grad) + }); + } + } + + match Gather + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + dim, + indices.clone(), + tensor.primitive.shape(), + tensor.primitive.device(), + ), + B::float_gather(dim, tensor.primitive, indices), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_gather(dim, tensor.primitive, indices)) + } + } + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct Scatter; + + impl Backward for Scatter { + type State = (usize, IntTensor); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, indices) = ops.state; + let [_, indices_4rhs] = duplicate(&ops.parents, Some(indices)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| grad, + |grad| B::float_gather(dim, grad, indices_4rhs.unwrap()), + ); + } + } + + match Scatter + .prepare::([tensor.node, value.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (dim, indices.clone()), + B::float_scatter_add(dim, tensor.primitive, indices, value.primitive), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_scatter_add( + dim, + tensor.primitive, + indices, + value.primitive, + )), + } + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + use burn_backend::tensor::IndexingUpdateOp; + + match reduction { + IndexingUpdateOp::Add => { + #[derive(Debug)] + struct ScatterNdAdd; + + impl Backward for ScatterNdAdd { + type State = IntTensor; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let indices = ops.state; + let [_, indices_4rhs] = duplicate(&ops.parents, Some(indices)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| grad, + |grad| B::float_gather_nd(grad, indices_4rhs.unwrap()), + ); + } + } + + match ScatterNdAdd + .prepare::([data.node, values.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + indices.clone(), + B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Add, + ), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Add, + )), + } + } + IndexingUpdateOp::Assign => { + #[derive(Debug)] + struct ScatterNdAssign; + + impl Backward for ScatterNdAssign { + type State = (IntTensor, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (indices, values_shape, device) = ops.state; + let [indices_4lhs, indices_4rhs] = duplicate(&ops.parents, Some(indices)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + // Zero out the scattered positions in grad + let zeros = + B::float_zeros(values_shape, &device, grad.dtype().into()); + B::float_scatter_nd( + grad, + indices_4lhs.unwrap(), + zeros, + IndexingUpdateOp::Assign, + ) + }, + |grad| B::float_gather_nd(grad, indices_4rhs.unwrap()), + ); + } + } + + match ScatterNdAssign + .prepare::([data.node, values.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + indices.clone(), + values.primitive.shape(), + data.primitive.device(), + ), + B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Assign, + ), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Assign, + )), + } + } + IndexingUpdateOp::Mul => { + // Forward: out[idx[i]] = data[idx[i]] * values[i] (unique indices assumed). + // Backward: + // grad_data = grad * scatter_nd(ones_like(data), idx, values, Assign) + // grad_values = gather_nd(grad, idx) * gather_nd(data, idx) + #[derive(Debug)] + struct ScatterNdMul; + + impl Backward for ScatterNdMul { + type State = (Option>, Option>, IntTensor); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (data_state, values_state, indices) = ops.state; + let [indices_4lhs, indices_4rhs] = duplicate(&ops.parents, Some(indices)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let device = grad.device(); + let dtype = grad.dtype(); + let shape = grad.shape(); + let ones = B::float_ones(shape, &device, dtype.into()); + let mult = B::float_scatter_nd( + ones, + indices_4lhs.unwrap(), + values_state.unwrap(), + IndexingUpdateOp::Assign, + ); + B::float_mul(grad, mult) + }, + |grad| { + let indices = indices_4rhs.unwrap(); + let g_idx = B::float_gather_nd(grad, indices.clone()); + let d_idx = B::float_gather_nd(data_state.unwrap(), indices); + B::float_mul(g_idx, d_idx) + }, + ); + } + } + + let data_tracked = data.is_tracked(); + let values_tracked = values.is_tracked(); + + match ScatterNdMul + .prepare::([data.node, values.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let data_state = values_tracked.then(|| data.primitive.clone()); + let values_state = data_tracked.then(|| values.primitive.clone()); + prep.finish( + (data_state, values_state, indices.clone()), + B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Mul, + ), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + IndexingUpdateOp::Mul, + )), + } + } + IndexingUpdateOp::Min | IndexingUpdateOp::Max => { + // Unique indices are required for this backward formula; duplicate indices have + // undefined behavior for scatter_nd Min/Max gradients. + // Forward (Max): out[idx] = max(data[idx], values); non-scattered: out = data. + // Backward, with ties contributing to both sides (matches cummin/cummax convention): + // data_at_idx = gather_nd(data, idx) + // data_won = data_at_idx >= values (Max) / <= values (Min) + // values_won = values >= data_at_idx (Max) / <= data_at_idx (Min) + // data_mask = scatter_nd(ones_like(data), idx, data_won, Assign) + // grad_data = grad * data_mask + // grad_values = gather_nd(grad, idx) * values_won + #[derive(Debug)] + struct ScatterNdMinMax; + + impl Backward for ScatterNdMinMax { + type State = (FloatTensor, FloatTensor, IntTensor, bool); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (data, values, indices, is_max) = ops.state; + let device = data.device(); + let data_dtype = data.dtype(); + let data_shape = data.shape(); + let settings = get_device_settings::(&device); + let bool_dtype = settings.bool_dtype; + + // data_at_idx — needed for both winner masks + let data_at_idx = B::float_gather_nd(data, indices.clone()); + + let (data_won_bool, values_won_bool) = if is_max { + ( + B::float_greater_equal( + data_at_idx.clone(), + values.clone(), + bool_dtype, + ), + B::float_greater_equal(values, data_at_idx, bool_dtype), + ) + } else { + ( + B::float_lower_equal( + data_at_idx.clone(), + values.clone(), + bool_dtype, + ), + B::float_lower_equal(values, data_at_idx, bool_dtype), + ) + }; + + let data_won_float = B::bool_into_float(data_won_bool, data_dtype.into()); + let values_won_float = + B::bool_into_float(values_won_bool, data_dtype.into()); + + // Build the full-shape data mask: 1 at non-scattered positions, + // data_won_float at scattered positions. + let ones = B::float_ones(data_shape, &device, data_dtype.into()); + let data_mask = B::float_scatter_nd( + ones, + indices.clone(), + data_won_float, + IndexingUpdateOp::Assign, + ); + + binary::( + ops.parents, + ops.node, + grads, + |grad| B::float_mul(grad, data_mask), + |grad| { + let g_idx = B::float_gather_nd(grad, indices); + B::float_mul(g_idx, values_won_float) + }, + ); + } + } + + let is_max = matches!(reduction, IndexingUpdateOp::Max); + + match ScatterNdMinMax + .prepare::([data.node, values.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + data.primitive.clone(), + values.primitive.clone(), + indices.clone(), + is_max, + ), + B::float_scatter_nd(data.primitive, indices, values.primitive, reduction), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_scatter_nd( + data.primitive, + indices, + values.primitive, + reduction, + )), + } + } + } + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + #[derive(Debug)] + struct GatherNd; + + impl Backward for GatherNd { + type State = (IntTensor, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (indices, data_shape, device) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let zeros = B::float_zeros(data_shape, &device, grad.dtype().into()); + B::float_scatter_nd( + zeros, + indices, + grad, + burn_backend::tensor::IndexingUpdateOp::Add, + ) + }); + } + } + + match GatherNd + .prepare::([data.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + indices.clone(), + data.primitive.shape(), + data.primitive.device(), + ), + B::float_gather_nd(data.primitive, indices), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_gather_nd(data.primitive, indices)), + } + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct Select; + + #[derive(new, Debug)] + struct RetroSelect { + input_id: NodeId, + dim: usize, + indices: IntTensor, + } + + impl RetroForward for RetroSelect { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_select(input, self.dim, self.indices.clone()); + states.save(out_node, out) + } + } + + impl Backward for Select { + type State = (usize, IntTensor, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, indices, shape, device) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let zeros = B::float_zeros(shape, &device, grad.dtype().into()); + B::float_select_add(zeros, dim, indices, grad) + }); + } + } + + match Select + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSelect::::new(tensor.node.id, dim, indices.clone())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + dim, + indices.clone(), + tensor.primitive.shape(), + tensor.primitive.device(), + ), + B::float_select(tensor.primitive, dim, indices), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_select(tensor.primitive, dim, indices)) + } + } + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct IndexSelectDimAssign; + + #[derive(new, Debug)] + struct RetroSelectAssign { + tensor_id: NodeId, + dim: usize, + indices: IntTensor, + value_id: NodeId, + } + + impl RetroForward for RetroSelectAssign { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let tensor = states.get_state::(&self.tensor_id); + let value = states.get_state::(&self.value_id); + let out = B::float_select_add(tensor, self.dim, self.indices.clone(), value); + states.save(out_node, out) + } + } + + impl Backward for IndexSelectDimAssign { + type State = (usize, IntTensor); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, indices) = ops.state; + + binary::( + ops.parents, + ops.node, + grads, + |grad| grad, + |grad| B::float_select(grad, dim, indices), + ); + } + } + + match IndexSelectDimAssign + .prepare::([tensor.node.clone(), value.node.clone()]) + .memory_bound() + .retro_forward(RetroSelectAssign::::new( + tensor.node.id, + dim, + indices.clone(), + value.node.id, + )) + .parents([&tensor, &value]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (dim, indices.clone()), + B::float_select_add(tensor.primitive, dim, indices, value.primitive), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_select_add( + tensor.primitive, + dim, + indices, + value.primitive, + )), + } + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + #[derive(Debug)] + struct Index; + + #[derive(new, Debug)] + struct RetroSlice { + tensor_id: NodeId, + slices: Vec, + _backend: PhantomData, + } + + impl RetroForward for RetroSlice { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let tensor = states.get_state::(&self.tensor_id); + let out = B::float_slice(tensor, &self.slices); + states.save(out_node, out) + } + } + + impl Backward for Index { + type State = (Vec, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (slices, shape, device) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let zeros = B::float_zeros(shape, &device, grad.dtype().into()); + B::float_slice_assign(zeros, &slices, grad) + }); + } + } + + match Index + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSlice::::new(tensor.node.id, slices.to_vec())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + slices.to_vec(), + tensor.primitive.shape(), + tensor.primitive.device(), + ), + B::float_slice(tensor.primitive, slices), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_slice(tensor.primitive, slices)), + } + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[Slice], + value: FloatTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct SliceAssign; + + #[derive(new, Debug)] + struct RetroSliceAssign { + tensor_id: NodeId, + slices: Vec, + value_id: NodeId, + _backend: PhantomData, + } + + impl RetroForward for RetroSliceAssign { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let tensor = states.get_state::(&self.tensor_id); + let value = states.get_state::(&self.value_id); + let out = B::float_slice_assign(tensor, &self.slices, value); + states.save(out_node, out) + } + } + + impl Backward for SliceAssign { + type State = (Vec, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (slices, shape_rhs, device) = ops.state; + let [slices_4lhs, slices_4rhs] = duplicate(&ops.parents, Some(slices)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let zeros = B::float_zeros(shape_rhs, &device, grad.dtype().into()); + B::float_slice_assign(grad, &slices_4lhs.unwrap(), zeros) + }, + |grad| B::float_slice(grad, &slices_4rhs.unwrap()), + ); + } + } + + match SliceAssign + .prepare::([tensor.node.clone(), value.node.clone()]) + .memory_bound() + .retro_forward(RetroSliceAssign::::new( + tensor.node.id, + slices.to_vec(), + value.node.id, + )) + .parents([&tensor, &value]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + slices.to_vec(), + value.primitive.shape(), + value.primitive.device(), + ), + B::float_slice_assign(tensor.primitive, slices, value.primitive), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_slice_assign( + tensor.primitive, + slices, + value.primitive, + )), + } + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + source: FloatTensor, + ) -> FloatTensor { + #[derive(Debug)] + struct MaskWhere; + + impl Backward for MaskWhere { + type State = (BoolTensor, Shape, Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (mask, shape_lhs, shape_rhs, device) = ops.state; + let [mask_4lhs, mask_4rhs] = duplicate(&ops.parents, Some(mask)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + let zeros = B::float_zeros(shape_lhs.clone(), &device, grad.dtype().into()); + let grad = B::float_mask_where(grad, mask_4lhs.unwrap(), zeros); + + broadcast_shape::(grad, &shape_lhs) + }, + |grad| { + let zeros = B::float_zeros(shape_rhs.clone(), &device, grad.dtype().into()); + let grad = B::float_mask_where(zeros, mask_4rhs.unwrap(), grad); + + broadcast_shape::(grad, &shape_rhs) + }, + ); + } + } + + match MaskWhere + .prepare::([tensor.node, source.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + ( + mask.clone(), + tensor.primitive.shape(), + source.primitive.shape(), + source.primitive.device(), + ), + B::float_mask_where(tensor.primitive, mask, source.primitive), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_mask_where( + tensor.primitive, + mask, + source.primitive, + )), + } + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + #[derive(Debug)] + struct MaskFill; + + impl Backward for MaskFill { + type State = BoolTensor; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mask_fill(grad, ops.state, 0f32.into()) + }); + } + } + + match MaskFill + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + mask.clone(), + B::float_mask_fill(tensor.primitive, mask, value), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_mask_fill(tensor.primitive, mask, value)) + } + } + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_equal(lhs.primitive, rhs.primitive, out_dtype) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_equal_elem(lhs.primitive, rhs, out_dtype) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_greater(lhs.primitive, rhs.primitive, out_dtype) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_greater_elem(lhs.primitive, rhs, out_dtype) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_greater_equal(lhs.primitive, rhs.primitive, out_dtype) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_greater_equal_elem(lhs.primitive, rhs, out_dtype) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_lower(lhs.primitive, rhs.primitive, out_dtype) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_lower_elem(lhs.primitive, rhs, out_dtype) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_lower_equal(lhs.primitive, rhs.primitive, out_dtype) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + B::float_lower_equal_elem(lhs.primitive, rhs, out_dtype) + } + + fn float_is_nan(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + B::float_is_nan(tensor.primitive, out_dtype) + } + + fn float_is_inf(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + B::float_is_inf(tensor.primitive, out_dtype) + } + + fn float_detach(tensor: FloatTensor) -> FloatTensor { + // When we detach a tensor, we remove it from the graph, but we still want to keep the + // `require_grad` (and `distributed`) setting. + let is_require_grad = Self::float_is_require_grad(&tensor); + + let distributed_params = tensor.node.distributed_params.clone(); + + let mut tensor = AutodiffTensor::new(tensor.primitive); + + if is_require_grad { + tensor = tensor.require_grad(); + } + if let Some(params) = distributed_params { + tensor = tensor.grad_distributed(params.param_id); + } + tensor + } + + fn float_set_require_grad(tensor: FloatTensor, require_grad: bool) -> FloatTensor { + if require_grad { + return tensor.require_grad(); + } + + AutodiffTensor::new(tensor.primitive) + } + + fn float_is_require_grad(tensor: &FloatTensor) -> bool { + matches!(tensor.node.requirement, Requirement::Grad) + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Mean; + + impl Backward for Mean { + type State = Shape; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let shape = ops.state; + let val = 1_f64 / shape.num_elements() as f64; + let ones = B::float_ones(shape, &grad.device(), grad.dtype().into()); + let val = B::float_mul_scalar(ones, val.into()); + + let grad = unsqueeze_like::(grad, val.shape()); + B::float_mul(val, grad) + }); + } + } + + match Mean.prepare::([tensor.node]).compute_bound().stateful() { + OpsKind::Tracked(prep) => { + prep.finish(tensor.primitive.shape(), B::float_mean(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_mean(tensor.primitive)), + } + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sum; + + impl Backward for Sum { + type State = Shape; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let val = B::float_ones(ops.state, &grad.device(), grad.dtype().into()); + + let grad = unsqueeze_like::(grad, val.shape()); + B::float_mul(val, grad) + }); + } + } + + match Sum.prepare::([tensor.node]).compute_bound().stateful() { + OpsKind::Tracked(prep) => { + prep.finish(tensor.primitive.shape(), B::float_sum(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_sum(tensor.primitive)), + } + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct MeanDim; + + impl Backward for MeanDim { + type State = (Shape, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, dim) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let val = 1_f64 / shape[dim] as f64; + let ones = B::float_ones(shape, &grad.device(), grad.dtype().into()); + let val = B::float_mul_scalar(ones, val.into()); + + let grad = B::float_sum_dim(grad, dim); + B::float_mul(val, grad) + }); + } + } + + match MeanDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), dim), + B::float_mean_dim(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_mean_dim(tensor.primitive, dim)), + } + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct SumDim; + + impl Backward for SumDim { + type State = (Shape, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, dim) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let ones = B::float_ones(shape, &grad.device(), grad.dtype().into()); + let grad = B::float_sum_dim(grad, dim); + + B::float_mul(ones, grad) + }); + } + } + + match SumDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), dim), + B::float_sum_dim(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_sum_dim(tensor.primitive, dim)), + } + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Prod; + + impl Backward for Prod { + // Saves the input and the output product so backward can compute + // `grad * prod(x) / x` without recomputing the reduction. + type State = (B::FloatTensorPrimitive, B::FloatTensorPrimitive); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (input, output) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx_i prod(x) = prod(x) / x_i, so grad_input = grad * output / input, + // broadcast over the input shape (output is a single-element tensor). + // + // This divides by the input, so it produces NaN gradients when the + // input contains zeros. A zero-safe version requires the product of + // all other elements via exclusive cumulative products, same as the + // cumprod limitation tracked in https://github.com/tracel-ai/burn/issues/3864. + let ones = B::float_ones(input.shape(), &input.device(), input.dtype().into()); + let grad = B::float_mul(grad, output); + let grad = unsqueeze_like::(grad, ones.shape()); + let grad = B::float_mul(ones, grad); + + B::float_div(grad, input) + }); + } + } + + match Prod.prepare::([tensor.node]).compute_bound().stateful() { + OpsKind::Tracked(prep) => { + let output = B::float_prod(tensor.primitive.clone()); + prep.finish((tensor.primitive, output.clone()), output) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_prod(tensor.primitive)), + } + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct ProdDim; + + impl Backward for ProdDim { + // Saves the input and the reduced product (size 1 along `dim`). + type State = (B::FloatTensorPrimitive, B::FloatTensorPrimitive); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (input, output) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + // grad_input = grad * prod_dim(x) / x. The grad and output both keep + // a size-1 reduced dim and broadcast back over the input along `dim`. + // + // Like `float_prod`, this divides by the input and produces NaN + // gradients when the input contains zeros (see + // https://github.com/tracel-ai/burn/issues/3864). + let ones = B::float_ones(input.shape(), &input.device(), input.dtype().into()); + let grad = B::float_mul(grad, output); + let grad = B::float_mul(ones, grad); + + B::float_div(grad, input) + }); + } + } + + match ProdDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let output = B::float_prod_dim(tensor.primitive.clone(), dim); + prep.finish((tensor.primitive, output.clone()), output) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_prod_dim(tensor.primitive, dim)), + } + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct CumSum; + + impl Backward for CumSum { + type State = (Shape, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (_shape, dim) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + // Gradient of cumsum is cumsum of gradient in reverse + let grad_reversed = B::float_flip(grad.clone(), &[dim]); + let grad_cumsum = B::float_cumsum(grad_reversed, dim); + B::float_flip(grad_cumsum, &[dim]) + }); + } + } + + match CumSum + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), dim), + B::float_cumsum(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_cumsum(tensor.primitive, dim)), + } + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct CumProd; + + impl Backward for CumProd { + type State = (B::FloatTensorPrimitive, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (input, dim) = ops.state; + let output = B::float_cumprod(input.clone(), dim); + + unary::(ops.parents, ops.node, grads, |grad| { + // Gradient of cumprod using negative step slicing + // Formula: grad_input[i] = sum_{j>=i}(grad_output[j] * output[j] / input[i]) + // = (1 / input[i]) * sum_{j>=i}(grad_output[j] * output[j]) + // = (1 / input) * reverse_cumsum(grad * output) + // + // LIMITATION: This produces NaN when input contains zeros. + // A proper zero-safe implementation requires more sophisticated algorithms + // (see PyTorch's cumprod_backward or JAX's associative_scan approach). + // TODO: Implement zero-safe gradient computation. + // See: https://github.com/tracel-ai/burn/issues/3864 + + let grad_times_output = B::float_mul(grad, output.clone()); + + // Create slices to reverse along the specified dimension + let shape = grad_times_output.shape(); + let mut slices = vec![Slice::full(); shape.num_dims()]; + slices[dim] = Slice::with_step(0, None, -1); + + // Reverse, cumsum, reverse back using negative step slicing + let grad_reversed = B::float_slice(grad_times_output, &slices); + let grad_cumsum = B::float_cumsum(grad_reversed, dim); + let grad_result = B::float_slice(grad_cumsum, &slices); + + B::float_div(grad_result, input) + }); + } + } + + match CumProd + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.clone(), dim), + B::float_cumprod(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_cumprod(tensor.primitive, dim)), + } + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct CumMin; + + impl Backward for CumMin { + type State = (B::FloatTensorPrimitive, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (input, dim) = ops.state; + let output = B::float_cummin(input.clone(), dim); + + unary::(ops.parents, ops.node, grads, |grad| { + // Gradient flows to the input positions that produced each output + // Use scatter to accumulate gradients (scatter does sum reduction) + + let shape = input.shape(); + let device = input.device(); + let settings = get_device_settings::(&device); + let dim_size = shape[dim] as i64; + + // Create indices [0, 1, 2, ...] along the dimension + let arange_1d = B::int_arange(0..dim_size, &device, settings.int_dtype); + + // Reshape to broadcast along the specified dimension + let mut arange_shape = vec![1; shape.num_dims()]; + arange_shape[dim] = dim_size as usize; + let arange = B::int_reshape(arange_1d, Shape::from(arange_shape)); + + // Expand to match input shape + let arange = B::int_expand(arange, shape.clone()); + + // Find where cummin[i] == input[i] (these are source positions) + let is_source = + B::float_equal(output.clone(), input.clone(), settings.bool_dtype); + let is_source_int = B::bool_into_int(is_source, settings.int_dtype); + + // Mask: where is_source, use index; else 0 + let masked_indices = B::int_mul(arange, is_source_int); + + // Cummax propagates the last valid (non-zero) index forward + let source_indices = B::int_cummax(masked_indices, dim); + + // Scatter gradients to source positions (sum reduction) + let zeros = B::float_zeros(shape, &device, grad.dtype().into()); + B::float_scatter_add(dim, zeros, source_indices, grad) + }); + } + } + + match CumMin + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.clone(), dim), + B::float_cummin(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_cummin(tensor.primitive, dim)), + } + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(Debug)] + struct CumMax; + + impl Backward for CumMax { + type State = (B::FloatTensorPrimitive, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (input, dim) = ops.state; + let output = B::float_cummax(input.clone(), dim); + + unary::(ops.parents, ops.node, grads, |grad| { + // Gradient flows to the input positions that produced each output + // Use scatter to accumulate gradients (scatter does sum reduction) + + let shape = input.shape(); + let device = input.device(); + let settings = get_device_settings::(&device); + let dim_size = shape[dim] as i64; + + // Create indices [0, 1, 2, ...] along the dimension + let arange_1d = B::int_arange(0..dim_size, &device, settings.int_dtype); + + // Reshape to broadcast along the specified dimension + let mut arange_shape = vec![1; shape.num_dims()]; + arange_shape[dim] = dim_size as usize; + let arange = B::int_reshape(arange_1d, Shape::from(arange_shape)); + + // Expand to match input shape + let arange = B::int_expand(arange, shape.clone()); + + // Find where cummax[i] == input[i] (these are source positions) + let is_source = + B::float_equal(output.clone(), input.clone(), settings.bool_dtype); + let is_source_int = B::bool_into_int(is_source, settings.int_dtype); + + // Mask: where is_source, use index; else 0 + let masked_indices = B::int_mul(arange, is_source_int); + + // Cummax propagates the last valid (non-zero) index forward + let source_indices = B::int_cummax(masked_indices, dim); + + // Scatter gradients to source positions (sum reduction) + let zeros = B::float_zeros(shape, &device, grad.dtype().into()); + B::float_scatter_add(dim, zeros, source_indices, grad) + }); + } + } + + match CumMax + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.clone(), dim), + B::float_cummax(tensor.primitive, dim), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_cummax(tensor.primitive, dim)), + } + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + B::float_argmax(tensor.primitive, dim, out_dtype) + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + B::float_argtopk(tensor.primitive, dim, k, out_dtype) + } + + fn float_topk(_tensor: FloatTensor, _dim: usize, _k: usize) -> FloatTensor { + unimplemented!("topk is not implemented for autodiff"); + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + B::float_argmin(tensor.primitive, dim, out_dtype) + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Exp; + + retro_unary!(RetroExp, B::float_exp); + + impl Backward for Exp { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + let output = B::float_exp(input); + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mul(grad, output) + }); + } + } + + match Exp + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroExp::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_exp(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_exp(tensor.primitive)), + } + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Log; + + retro_unary!(RetroLog, B::float_log); + + impl Backward for Log { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_recip(input); + B::float_mul(grad, value) + }); + } + } + + match Log + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroLog::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_log(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_log(tensor.primitive)), + } + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Log1P; + + retro_unary!(RetroLog1P, B::float_log1p); + + impl Backward for Log1P { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_add_scalar(input, 1f32.into()); + let value = B::float_recip(value); + + B::float_mul(grad, value) + }); + } + } + + match Log1P + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroLog1P::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_log1p(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_log1p(tensor.primitive)), + } + } + + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor { + #[derive(Debug)] + struct PowfScalar; + + #[derive(new, Debug)] + struct RetroPowfScalar { + lhs_id: NodeId, + rhs: f64, + _backend: PhantomData, + } + + impl RetroForward for RetroPowfScalar { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let lhs = states.get_state::(&self.lhs_id); + let out = B::float_powf_scalar(lhs, self.rhs.into()); + states.save(out_node, out) + } + } + + impl Backward for PowfScalar { + type State = (NodeId, f64); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (tensor_id, value) = ops.state; + let tensor = checkpointer.retrieve_node_output(tensor_id); + + unary::(ops.parents, ops.node, grads, |grad| { + let tmp = B::float_powf_scalar(tensor, (value - 1.).into()); + let value = B::float_mul_scalar(tmp, value.into()); + + B::float_mul(grad, value) + }); + } + } + + match PowfScalar + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroPowfScalar::::new(tensor.node.id, value.elem())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = (prep.checkpoint(&tensor), value.elem()); + prep.finish(state, B::float_powf_scalar(tensor.primitive, value)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_powf_scalar(tensor.primitive, value)), + } + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sqrt; + + retro_unary!(RetroSqrt, B::float_sqrt); + + impl Backward for Sqrt { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_div_scalar( + B::float_powf_scalar(input, (-0.5).into()), + 2f32.into(), + ); + + B::float_mul(grad, value) + }); + } + } + + match Sqrt + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSqrt::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_sqrt(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_sqrt(tensor.primitive)), + } + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Abs; + + retro_unary!(RetroAbs, B::float_abs); + + impl Backward for Abs { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let tensor: B::FloatTensorPrimitive = checkpointer.retrieve_node_output(ops.state); + let state = B::float_sign(tensor); + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mul(grad, state) + }); + } + } + + match Abs + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAbs::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_abs(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_abs(tensor.primitive)), + } + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Cos; + + retro_unary!(RetroCos, B::float_cos); + + impl Backward for Cos { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_neg(B::float_sin(input)); + + B::float_mul(grad, value) + }); + } + } + + match Cos + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroCos::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_cos(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_cos(tensor.primitive)), + } + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sin; + + retro_unary!(RetroSin, B::float_sin); + + impl Backward for Sin { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let state = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_cos(state); + B::float_mul(grad, value) + }); + } + } + + match Sin + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSin::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_sin(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_sin(tensor.primitive)), + } + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Tanh; + + retro_unary!(RetroTanh, B::float_tanh); + + impl Backward for Tanh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + let state = B::float_tanh(input); + unary::(ops.parents, ops.node, grads, |grad| { + let value = B::float_add_scalar( + B::float_neg(B::float_powi_scalar(state, 2.into())), + 1f32.into(), + ); + B::float_mul(grad, value) + }); + } + } + + match Tanh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroTanh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_tanh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_tanh(tensor.primitive)), + } + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Cosh; + + retro_unary!(RetroCosh, B::float_cosh); + + impl Backward for Cosh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mul(grad, B::float_sinh(input)) + }); + } + } + + match Cosh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroCosh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_cosh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_cosh(tensor.primitive)), + } + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sinh; + + retro_unary!(RetroSinh, B::float_sinh); + + impl Backward for Sinh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + B::float_mul(grad, B::float_cosh(input)) + }); + } + } + + match Sinh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSinh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_sinh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_sinh(tensor.primitive)), + } + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Tan; + + retro_unary!(RetroTan, B::float_tan); + + impl Backward for Tan { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + let tan_x = B::float_tan(input); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx tan(x) = 1 + tan^2(x) + let tan_sq = B::float_powi_scalar(tan_x, 2.into()); + B::float_mul(grad, B::float_add_scalar(tan_sq, 1f32.into())) + }); + } + } + + match Tan + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroTan::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_tan(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_tan(tensor.primitive)), + } + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Asin; + + retro_unary!(RetroAsin, B::float_asin); + + impl Backward for Asin { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx asin(x) = 1/sqrt(1 - x^2) + let x_sq = B::float_powi_scalar(input, 2.into()); + let denom = B::float_sqrt(B::float_add_scalar(B::float_neg(x_sq), 1f32.into())); + B::float_mul(grad, B::float_recip(denom)) + }); + } + } + + match Asin + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAsin::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_asin(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_asin(tensor.primitive)), + } + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Acos; + + retro_unary!(RetroAcos, B::float_acos); + + impl Backward for Acos { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx acos(x) = -1/sqrt(1 - x^2) + let x_sq = B::float_powi_scalar(input, 2.into()); + let denom = B::float_sqrt(B::float_add_scalar(B::float_neg(x_sq), 1f32.into())); + let value = B::float_neg(B::float_recip(denom)); + B::float_mul(grad, value) + }); + } + } + + match Acos + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAcos::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_acos(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_acos(tensor.primitive)), + } + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Atan; + + retro_unary!(RetroAtan, B::float_atan); + + impl Backward for Atan { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx atan(x) = 1/(1 + x^2) + let x_sq = B::float_powi_scalar(input, 2.into()); + let value = B::float_recip(B::float_add_scalar(x_sq, 1f32.into())); + B::float_mul(grad, value) + }); + } + } + + match Atan + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAtan::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_atan(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_atan(tensor.primitive)), + } + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Asinh; + + retro_unary!(RetroAsinh, B::float_asinh); + + impl Backward for Asinh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx asinh(x) = 1/sqrt(x^2 + 1) + let x_sq = B::float_powi_scalar(input, 2.into()); + let value = + B::float_recip(B::float_sqrt(B::float_add_scalar(x_sq, 1f32.into()))); + B::float_mul(grad, value) + }); + } + } + + match Asinh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAsinh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_asinh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_asinh(tensor.primitive)), + } + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Acosh; + + retro_unary!(RetroAcosh, B::float_acosh); + + impl Backward for Acosh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx acosh(x) = 1/sqrt(x^2 - 1) + let x_sq = B::float_powi_scalar(input, 2.into()); + let value = + B::float_recip(B::float_sqrt(B::float_sub_scalar(x_sq, 1f32.into()))); + B::float_mul(grad, value) + }); + } + } + + match Acosh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAcosh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_acosh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_acosh(tensor.primitive)), + } + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Atanh; + + retro_unary!(RetroAtanh, B::float_atanh); + + impl Backward for Atanh { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let input = checkpointer.retrieve_node_output(ops.state); + unary::(ops.parents, ops.node, grads, |grad| { + // d/dx atanh(x) = 1/(1 - x^2) + let x_sq = B::float_powi_scalar(input, 2.into()); + let value = + B::float_recip(B::float_add_scalar(B::float_neg(x_sq), 1f32.into())); + B::float_mul(grad, value) + }); + } + } + + match Atanh + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroAtanh::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_atanh(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_atanh(tensor.primitive)), + } + } + + fn float_atan2(y: FloatTensor, x: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Atan2; + + retro_binary!(RetroAtan2, B::float_atan2); + + impl Backward for Atan2 { + type State = (Option, Option, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (y_id, x_id, broadcast) = ops.state; + let y = y_id.map(|id| checkpointer.retrieve_node_output(id)); + let x = x_id.map(|id| checkpointer.retrieve_node_output(id)); + let [y_4y, y_4x] = duplicate(&ops.parents, y); + let [x_4y, x_4x]: [Option>; 2] = duplicate(&ops.parents, x); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + // d/dy atan2(y, x) = x/(x^2 + y^2) + let y = y_4y.unwrap(); + let x = x_4y.unwrap(); + let x_sq = B::float_powi_scalar(x.clone(), 2.into()); + let y_sq = B::float_powi_scalar(y, 2.into()); + let denom = B::float_add(x_sq, y_sq); + let value = B::float_div(x, denom); + let grad = B::float_mul(grad, value); + + broadcast.backward_lhs::(grad) + }, + |grad| { + // d/dx atan2(y, x) = -y/(x^2 + y^2) + let y = y_4x.unwrap(); + let x = x_4x.unwrap(); + let x_sq = B::float_powi_scalar(x, 2.into()); + let y_sq = B::float_powi_scalar(y.clone(), 2.into()); + let denom = B::float_add(x_sq, y_sq); + let value = B::float_neg(B::float_div(y, denom)); + let grad = B::float_mul(grad, value); + + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let y_tracked = y.is_tracked(); + let x_tracked = x.is_tracked(); + let broadcast = BinaryOpsBroadcast::new::(&y.primitive, &x.primitive); + + match Atan2 + .prepare::([y.node.clone(), x.node.clone()]) + .memory_bound() + .retro_forward(RetroAtan2::::new(y.node.id, x.node.id)) + .parents([&y, &x]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let is_tracked = y_tracked || x_tracked; + let y_state = is_tracked.then(|| prep.checkpoint(&y)); + let x_state = is_tracked.then(|| prep.checkpoint(&x)); + + prep.finish( + (y_state, x_state, broadcast), + B::float_atan2(y.primitive, x.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_atan2(y.primitive, x.primitive)), + } + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Round; + retro_unary!(RetroRound, B::float_round); + + impl Backward for Round { + type State = (Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, device) = ops.state; + unary::(ops.parents, ops.node, grads, |grad| { + B::float_zeros(shape, &device, grad.dtype().into()) + }) + } + } + + match Round + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroRound::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (tensor.primitive.shape(), tensor.primitive.device()), + B::float_round(tensor.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_round(tensor.primitive)), + } + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Floor; + retro_unary!(RetroFloor, B::float_floor); + + impl Backward for Floor { + type State = (Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, device) = ops.state; + unary::(ops.parents, ops.node, grads, |grad| { + B::float_zeros(shape, &device, grad.dtype().into()) + }) + } + } + + match Floor + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroFloor::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (tensor.primitive.shape(), tensor.primitive.device()), + B::float_floor(tensor.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_floor(tensor.primitive)), + } + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Ceil; + retro_unary!(RetroCeil, B::float_ceil); + + impl Backward for Ceil { + type State = (Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, device) = ops.state; + unary::(ops.parents, ops.node, grads, |grad| { + B::float_zeros(shape, &device, grad.dtype().into()) + }) + } + } + + match Ceil + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroCeil::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (tensor.primitive.shape(), tensor.primitive.device()), + B::float_ceil(tensor.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_ceil(tensor.primitive)), + } + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Trunc; + retro_unary!(RetroTrunc, B::float_trunc); + + impl Backward for Trunc { + type State = (Shape, B::Device); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape, device) = ops.state; + unary::(ops.parents, ops.node, grads, |grad| { + B::float_zeros(shape, &device, grad.dtype().into()) + }) + } + } + + match Trunc + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroTrunc::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(preps) => preps.finish( + (tensor.primitive.shape(), tensor.primitive.device()), + B::float_trunc(tensor.primitive), + ), + OpsKind::UnTracked(preps) => preps.finish(B::float_trunc(tensor.primitive)), + } + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Erf; + + retro_unary!(RetroErf, B::float_erf); + + impl Backward for Erf { + type State = NodeId; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| { + let ops = checkpointer.retrieve_node_output(ops.state); + let exponent = B::float_neg(B::float_powi_scalar(ops, 2.into())); + let numerator = B::float_mul_scalar(B::float_exp(exponent), 2.0.into()); + let denominator = core::f64::consts::PI.sqrt().into(); + let value = B::float_div_scalar(numerator, denominator); + + B::float_mul(grad, value) + }); + } + } + + match Erf + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroErf::::new(tensor.node.id)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let state = prep.checkpoint(&tensor); + prep.finish(state, B::float_erf(tensor.primitive)) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_erf(tensor.primitive)), + } + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CatStep { + nodes: Vec>, + // The dimension of each tensor along the dim dimension. + // This indicates the number of dimension concatenated for each tensor. + dim_sizes: Vec, + output: NodeRef, + phantom: PhantomData, + dim: usize, + parents: Vec, + } + + impl Step for CatStep { + fn step(self: Box, grads: &mut Gradients, _checkpointer: &mut Checkpointer) { + let grad = grads.consume::(&self.output); + let ranges_template: Vec<_> = grad.shape().iter().map(|&v| 0..v).collect(); + + self.nodes + .into_iter() + .zip(self.dim_sizes) + .scan(0, |offset, (node_opt, dim_size)| { + let start = *offset; + let end = start + dim_size; + *offset = end; + Some(node_opt.map(|node| (node, start, end))) + }) + .flatten() + .for_each(|(node, start, end)| { + let mut ranges = ranges_template.clone(); + ranges[self.dim] = start..end; + + let slices: Vec = ranges + .iter() + .map(|r| Slice::new(r.start as isize, Some(r.end as isize), 1)) + .collect(); + grads.register::(node.id, B::float_slice(grad.clone(), &slices)); + }); + } + + fn node(&self) -> NodeId { + self.output.id + } + + fn parents(&self) -> &[Parent] { + &self.parents + } + fn depth(&self) -> usize { + self.output.order + } + + fn distributed_params(&self) -> Option { + self.output.distributed_params.clone() + } + } + + let mut nodes = Vec::with_capacity(tensors.len()); + let mut primitives = Vec::with_capacity(tensors.len()); + let mut dim_sizes = Vec::with_capacity(tensors.len()); + + tensors.into_iter().for_each(|tensor| { + dim_sizes.push(tensor.primitive.shape()[dim]); + nodes.push(tensor.node); + primitives.push(tensor.primitive); + }); + + let requirement = Requirement::from_nodes(&nodes); + + // For simplicity, this operation does not checkpoint anything + let cat_computing_property = ComputingProperty::Ambiguous; + let checkpointer_builder = CheckpointerBuilder::default(); + + let output = B::float_cat(primitives, dim); + if requirement.is_none() { + return AutodiffTensor::from_parents( + output, + &nodes, + requirement, + cat_computing_property, + ); + } + + let output = + AutodiffTensor::from_parents(output, &nodes, requirement, cat_computing_property); + + let mut parents = Vec::new(); + + let nodes = nodes + .into_iter() + .map(|node| node.clone_if_require_grad()) + .collect::>(); + for node in nodes.iter().flatten() { + parents.push(Parent { id: node.id }); + } + let ops = CatStep::::new(nodes, dim_sizes, output.node.clone(), dim, parents); + output.register_step(ops, checkpointer_builder) + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + match MaxMinDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let settings = get_device_settings::(&tensor.primitive.device()); + let (tensor, index) = + B::float_max_dim_with_indices(tensor.primitive, dim, settings.int_dtype); + prep.finish((index, shape, dim), tensor) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_max_dim(tensor.primitive, dim)), + } + } + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + match MaxMinDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let (tensor, index) = + B::float_max_dim_with_indices(tensor.primitive, dim, indices_dtype); + let tensor = prep.finish((index.clone(), shape, dim), tensor); + + (tensor, index) + } + OpsKind::UnTracked(prep) => { + let (tensor, index) = + B::float_max_dim_with_indices(tensor.primitive, dim, indices_dtype); + let tensor = prep.finish(tensor); + + (tensor, index) + } + } + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + match MaxMinDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let settings = get_device_settings::(&tensor.primitive.device()); + let (tensor, index) = + B::float_min_dim_with_indices(tensor.primitive, dim, settings.int_dtype); + prep.finish((index, shape, dim), tensor) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_min_dim(tensor.primitive, dim)), + } + } + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + match MaxMinDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let (tensor, index) = + B::float_min_dim_with_indices(tensor.primitive, dim, indices_dtype); + let tensor = prep.finish((index.clone(), shape, dim), tensor); + + (tensor, index) + } + OpsKind::UnTracked(prep) => { + let (tensor, index) = + B::float_min_dim_with_indices(tensor.primitive, dim, indices_dtype); + let tensor = prep.finish(tensor); + + (tensor, index) + } + } + } + + fn float_into_int(tensor: FloatTensor, out_dtype: IntDType) -> IntTensor { + B::float_into_int(tensor.primitive, out_dtype) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct PowF; + + retro_binary!(RetroPowf, B::float_powf); + + impl Backward for PowF { + type State = (NodeId, NodeId, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs_id, rhs_id, broadcast) = ops.state; + let lhs: B::FloatTensorPrimitive = checkpointer.retrieve_node_output(lhs_id); + let rhs: B::FloatTensorPrimitive = checkpointer.retrieve_node_output(rhs_id); + + // Both lhs and rhs are needed for both lhs and rhs gradients, but we clone them + // the number of times required by the parents specification. + let [rhs_4lhs, rhs_4rhs] = duplicate(&ops.parents, Some(rhs)); + let [lhs_4lhs, lhs_4rhs] = duplicate(&ops.parents, Some(lhs)); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + //rhs*(lhs.val**(rhs-1))*grad + let rhs1 = rhs_4lhs.unwrap(); + let rhs2 = rhs1.clone(); + let lhs = lhs_4lhs.unwrap(); + + let tmp = B::float_powf(lhs, B::float_sub_scalar(rhs1, 1.0.into())); + let value = B::float_mul(tmp, rhs2); + let grad = B::float_mul(grad, value); + + broadcast.backward_lhs::(grad) + }, + |grad| { + //lhs**rhs * ln(lhs) * grad + let rhs = rhs_4rhs.unwrap(); + let lhs1 = lhs_4rhs.unwrap(); + let lhs2 = lhs1.clone(); + let tmp = B::float_powf(lhs1, rhs); + let value = B::float_mul(tmp, B::float_log(lhs2)); + let grad = B::float_mul(grad, value); + + broadcast.backward_rhs::(grad) + }, + ); + } + } + + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + + match PowF + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .memory_bound() + .retro_forward(RetroPowf::::new(lhs.node.id, rhs.node.id)) + .parents([&lhs, &rhs]) + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = prep.checkpoint(&lhs); + let rhs_state = prep.checkpoint(&rhs); + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_powf(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_powf(lhs.primitive, rhs.primitive)), + } + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Hypot; + + impl Backward for Hypot { + type State = (NodeId, NodeId, BinaryOpsBroadcast); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + checkpointer: &mut Checkpointer, + ) { + let (lhs_id, rhs_id, broadcast) = ops.state; + let lhs: B::FloatTensorPrimitive = checkpointer.retrieve_node_output(lhs_id); + let rhs: B::FloatTensorPrimitive = checkpointer.retrieve_node_output(rhs_id); + + let [lhs_4lhs, lhs_4rhs] = duplicate(&ops.parents, Some(lhs.clone())); + let [rhs_4lhs, rhs_4rhs] = duplicate(&ops.parents, Some(rhs.clone())); + + binary::( + ops.parents, + ops.node, + grads, + |grad| { + // lhs / hypot(lhs, rhs) * grad + let value = + B::float_div(lhs, B::float_hypot(lhs_4lhs.unwrap(), rhs_4lhs.unwrap())); + broadcast.backward_lhs::(B::float_mul(grad, value)) + }, + |grad| { + // rhs / hypot(lhs, rhs) * grad + let value = + B::float_div(rhs, B::float_hypot(lhs_4rhs.unwrap(), rhs_4rhs.unwrap())); + broadcast.backward_rhs::(B::float_mul(grad, value)) + }, + ); + } + } + let broadcast = BinaryOpsBroadcast::new::(&lhs.primitive, &rhs.primitive); + match Hypot + .prepare::([lhs.node.clone(), rhs.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(mut prep) => { + let lhs_state = prep.checkpoint(&lhs); + let rhs_state = prep.checkpoint(&rhs); + prep.finish( + (lhs_state, rhs_state, broadcast), + B::float_hypot(lhs.primitive, rhs.primitive), + ) + } + OpsKind::UnTracked(prep) => prep.finish(B::float_hypot(lhs.primitive, rhs.primitive)), + } + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + #[derive(Debug)] + struct Sign; + + retro_unary!(RetroSign, B::float_sign); + + impl Backward for Sign { + type State = (); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + unary::(ops.parents, ops.node, grads, |grad| + // Always return 0 because the derivative of the sign function + // does not contribute to gradient updates in a meaningful way. + B::float_mul_scalar(grad, 0f32.into())); + } + } + + Sign.prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroSign::::new(tensor.node.id)) + .parents([&tensor]) + .stateless(B::float_sign(tensor.primitive)) + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + // D1: tensor, D2: shape + #[derive(Debug)] + struct ExpandDim; + + #[derive(new, Debug)] + struct RetroExpand { + input_id: NodeId, + shape: Shape, + _backend: PhantomData, + } + + impl RetroForward for RetroExpand { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let input = states.get_state::(&self.input_id); + let out = B::float_expand(input, self.shape.clone()); + states.save(out_node, out) + } + } + + impl Backward for ExpandDim { + type State = (Shape, Shape); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape_in, shape_out) = ops.state; + let ndims_in = shape_in.num_dims(); + let ndims_out = shape_out.num_dims(); + + let mut shape_expanded = vec![1; ndims_out]; + + debug_assert!(ndims_out >= ndims_in); + + for i in 0..ndims_in { + shape_expanded[i + (ndims_out - ndims_in)] = shape_in[i]; + } + + unary::(ops.parents, ops.node, grads, |grad| { + let shape_grad = grad.shape(); + let mut grad = grad; + + #[allow(clippy::needless_range_loop)] + for i in 0..ndims_out { + if shape_expanded[i] == 1 && shape_grad[i] != 1 { + grad = B::float_sum_dim(grad, i); + } + } + + B::float_reshape(grad, shape_in) + }); + } + } + + match ExpandDim + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroExpand::::new(tensor.node.id, shape.clone())) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), shape.clone()), + B::float_expand(tensor.primitive, shape), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_expand(tensor.primitive, shape)), + } + } + + fn float_sort(tensor: FloatTensor, dim: usize, descending: bool) -> FloatTensor { + match super::sort::SortDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let settings = get_device_settings::(&tensor.primitive.device()); + let (tensor, indices) = B::float_sort_with_indices( + tensor.primitive, + dim, + descending, + settings.int_dtype, + ); + prep.finish((indices, shape, dim), tensor) + } + OpsKind::UnTracked(prep) => { + prep.finish(B::float_sort(tensor.primitive, dim, descending)) + } + } + } + + fn float_sort_with_indices( + tensor: FloatTensor, + dim: usize, + descending: bool, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + match super::sort::SortDim + .prepare::([tensor.node]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => { + let shape = tensor.primitive.shape(); + let (tensor, indices) = + B::float_sort_with_indices(tensor.primitive, dim, descending, indices_dtype); + let tensor = prep.finish((indices.clone(), shape, dim), tensor); + + (tensor, indices) + } + OpsKind::UnTracked(prep) => { + let (tensor, indices) = + B::float_sort_with_indices(tensor.primitive, dim, descending, indices_dtype); + let tensor = prep.finish(tensor); + + (tensor, indices) + } + } + } + + fn float_argsort( + tensor: FloatTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> IntTensor { + B::float_argsort(tensor.primitive, dim, descending, out_dtype) + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + #[derive(Debug)] + struct Repeat; + + #[derive(new, Debug)] + struct RetroRepeat { + tensor_id: NodeId, + dim: usize, + times: usize, + _backend: PhantomData, + } + + impl RetroForward for RetroRepeat { + fn forward(&self, states: &mut BackwardStates, out_node: NodeId) { + let tensor = states.get_state::(&self.tensor_id); + let out = B::float_repeat_dim(tensor, self.dim, self.times); + states.save(out_node, out) + } + } + + impl Backward for Repeat { + type State = (usize, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (dim, times) = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + let mut dims = grad.shape(); + let orig_dim_size = dims[dim] / times; + if orig_dim_size > 1 { + dims[dim] = orig_dim_size; + let orig_dims = dims.clone(); + dims.insert(dim + 1, times); // shape [..., orig_dim_size, times, ...] + let grad = B::float_reshape(grad, dims); + let grad = B::float_sum_dim(grad, dim + 1); // sum over repeat times + B::float_reshape(grad, orig_dims) + } else { + B::float_sum_dim(grad, dim) + } + }); + } + } + + match Repeat + .prepare::([tensor.node.clone()]) + .memory_bound() + .retro_forward(RetroRepeat::::new(tensor.node.id, dim, times)) + .parents([&tensor]) + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (dim, times), + B::float_repeat_dim(tensor.primitive, dim, times), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_repeat_dim(tensor.primitive, dim, times)) + } + } + } + + fn float_cast(tensor: FloatTensor, dtype: burn_std::FloatDType) -> FloatTensor { + #[derive(Debug)] + struct Cast; + + impl Backward for Cast { + type State = FloatDType; + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let dtype = ops.state; + + unary::(ops.parents, ops.node, grads, |grad| { + B::float_cast(grad, dtype) + }); + } + } + + match Cast + .prepare::([tensor.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + tensor.dtype().into(), + B::float_cast(tensor.primitive, dtype), + ), + OpsKind::UnTracked(prep) => prep.finish(B::float_cast(tensor.primitive, dtype)), + } + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + #[derive(Debug)] + struct Unfold; + + impl Backward for Unfold { + type State = (Shape, usize, usize, usize); + + fn backward( + self, + ops: Ops, + grads: &mut Gradients, + _checkpointer: &mut Checkpointer, + ) { + let (shape_in, dim, size, step) = ops.state; + let windows = calculate_unfold_windows(shape_in[dim], size, step); + + unary::(ops.parents, ops.node, grads, |grad| { + let device = grad.device(); + let mut grad_input = + B::float_zeros(shape_in.clone(), &device, grad.dtype().into()); + + if windows == 0 { + return grad_input; + } + + let ndims_in = shape_in.num_dims(); + let ndims_out = grad.shape().num_dims(); + + let mut target_shape = shape_in.clone(); + target_shape[dim] = size; + + for window_idx in 0..windows { + let mut slices_out = vec![Slice::new(0, None, 1); ndims_out]; + let start = window_idx * step; + let end = start + size; + slices_out[dim] = + Slice::new(window_idx as isize, Some((window_idx + 1) as isize), 1); + + let window_grad = B::float_slice(grad.clone(), &slices_out); + + let last_axis = ndims_out - 1; + let mut permutation: Vec = (0..dim).collect(); + permutation.push(last_axis); + permutation.extend(dim + 1..last_axis); + permutation.push(dim); + + let window_grad = B::float_permute(window_grad, &permutation); + let window_grad = B::float_reshape(window_grad, target_shape.clone()); + + let mut slices_in = vec![Slice::new(0, None, 1); ndims_in]; + slices_in[dim] = Slice::new(start as isize, Some(end as isize), 1); + + let current = B::float_slice(grad_input.clone(), &slices_in); + let updated = B::float_add(current, window_grad); + grad_input = B::float_slice_assign(grad_input, &slices_in, updated); + } + + grad_input + }); + } + } + + match Unfold + .prepare::([tensor.node.clone()]) + .compute_bound() + .stateful() + { + OpsKind::Tracked(prep) => prep.finish( + (tensor.primitive.shape(), dim, size, step), + B::float_unfold(tensor.primitive, dim, size, step), + ), + OpsKind::UnTracked(prep) => { + prep.finish(B::float_unfold(tensor.primitive, dim, size, step)) + } + } + } +} + +#[derive(Debug, Clone)] +enum BinaryOpsBroadcast { + Broadcasted(Shape, Shape), + None, +} + +impl BinaryOpsBroadcast { + fn new(lhs: &B::FloatTensorPrimitive, rhs: &B::FloatTensorPrimitive) -> Self { + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + let ndims = shape_lhs.num_dims(); + + for i in 0..ndims { + if shape_rhs[i] != shape_lhs[i] { + return Self::Broadcasted(shape_lhs, shape_rhs); + } + } + + Self::None + } + + fn backward_lhs(&self, grad: B::FloatTensorPrimitive) -> B::FloatTensorPrimitive { + match self { + BinaryOpsBroadcast::Broadcasted(lhs, _rhs) => broadcast_shape::(grad, lhs), + BinaryOpsBroadcast::None => grad, + } + } + + fn backward_rhs(&self, grad: B::FloatTensorPrimitive) -> B::FloatTensorPrimitive { + match self { + BinaryOpsBroadcast::Broadcasted(_lhs, rhs) => broadcast_shape::(grad, rhs), + BinaryOpsBroadcast::None => grad, + } + } +} diff --git a/crates/burn-autodiff/src/ops/transaction.rs b/crates/burn-autodiff/src/ops/transaction.rs new file mode 100644 index 0000000..17a07a4 --- /dev/null +++ b/crates/burn-autodiff/src/ops/transaction.rs @@ -0,0 +1,24 @@ +use burn_backend::{ + Backend, ExecutionError, + ops::{TransactionOps, TransactionPrimitive}, +}; + +use crate::{Autodiff, checkpoint::strategy::CheckpointStrategy}; + +impl TransactionOps for Autodiff { + async fn tr_execute( + transaction: TransactionPrimitive, + ) -> Result { + B::tr_execute(TransactionPrimitive::new( + transaction + .read_floats + .into_iter() + .map(|t| t.primitive) + .collect(), + transaction.read_qfloats, + transaction.read_ints, + transaction.read_bools, + )) + .await + } +} diff --git a/crates/burn-autodiff/src/runtime/client.rs b/crates/burn-autodiff/src/runtime/client.rs new file mode 100644 index 0000000..df9d88a --- /dev/null +++ b/crates/burn-autodiff/src/runtime/client.rs @@ -0,0 +1,18 @@ +use crate::{ + checkpoint::builder::CheckpointerBuilder, + grads::{BackwardMode, Gradients}, + graph::StepBoxed, + tensor::{AutodiffTensor, NodeRefCount}, +}; +use burn_backend::Backend; + +/// Client used to communicate with the autodiff server. +pub trait AutodiffClient: Send + Clone { + /// Register a new step. + fn register(&self, node_id: NodeRefCount, step: StepBoxed, actions: CheckpointerBuilder); + /// Call backpropagation from the given tensor. + fn backward(&self, tensor: AutodiffTensor, mode: BackwardMode) -> Gradients; +} + +/// Client implementation in used. +pub type AutodiffClientImpl = super::graph::GraphMutexClient; diff --git a/crates/burn-autodiff/src/runtime/graph.rs b/crates/burn-autodiff/src/runtime/graph.rs new file mode 100644 index 0000000..4b4a2f1 --- /dev/null +++ b/crates/burn-autodiff/src/runtime/graph.rs @@ -0,0 +1,344 @@ +use super::{AutodiffClient, server::AutodiffServer}; +use crate::{ + NodeId, + checkpoint::builder::CheckpointerBuilder, + grads::{BackwardMode, Gradients}, + graph::{Parent, StepBoxed}, + runtime::server::NodeCleaner, + tensor::{AutodiffTensor, NodeRefCount}, +}; +use alloc::vec::Vec; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use burn_backend::Backend; + +use alloc::collections::BTreeMap; +use hashbrown::HashSet; + +#[cfg(feature = "std")] +use parking_lot::{Mutex, MutexGuard}; + +#[cfg(not(feature = "std"))] +use spin::{Mutex, MutexGuard}; + +/// A client for managing multiple graphs using mutex-based synchronization. +/// +/// The biggest benefit of using this client implementation is that each graph can modify its own +/// data without blocking other graphs, which is essential for multi-device training. +/// +/// # Notes +/// +/// The [AutodiffServer] fully supports multiple graphs with sharing nodes, however those type of +/// graphs will be stored under a single mutex-protected graph by the client, limiting +/// parallelisation. +#[derive(Clone, new, Debug)] +pub struct GraphMutexClient; + +/// Manages a collection of graphs, mapping [node ids](NodeId) to their respective graph. +/// +/// The `GraphLocator` is responsible for selecting and merging graphs based on their IDs and parent +/// dependencies, ensuring proper synchronization and server allocation. +/// +/// # Notes +/// +/// Multiple node ids can point to the same graph, where the autodiff graph is stored. +#[derive(Default)] +pub struct GraphLocator { + graphs: BTreeMap>, + /// We keep a mapping of each original node id (graph id) => all nodes that point to that graph. + /// This is to ensure that when merging graphs, we correctly move all previous graphs to + /// the new merged one. + keys: BTreeMap>, +} + +/// Represents a single computation graph with a mutex-protected server. +/// +/// Each `Graph` contains an [AutodiffServer] and the original [NodeId] where the server was +/// first created. +pub(crate) struct Graph { + origin: NodeId, + state: Mutex, +} + +#[derive(Default)] +struct GraphState { + server: AutodiffServer, +} + +impl core::fmt::Debug for Graph { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Graph") + .field("origin", &self.origin) + .finish() + } +} + +static STATE: Mutex> = Mutex::new(None); + +impl GraphMutexClient { + /// Retrieves or creates a graph for the given [NodeId] and parent dependencies. + /// + /// # Parameters + /// - `node`: The unique identifier for the stream. + /// - `parents`: A slice of parent nodes that the stream depends on. + /// + /// # Returns + /// An `Arc` representing the selected or newly created stream. + fn graph(node: NodeId, parents: &[Parent]) -> Arc { + let mut state = STATE.lock(); + + match state.as_mut() { + Some(locator) => locator.select(node, parents), + None => { + let mut locator = GraphLocator::default(); + let stream = locator.select(node, parents); + *state = Some(locator); + stream + } + } + } +} + +impl AutodiffClient for GraphMutexClient { + fn register(&self, node_id_ref: NodeRefCount, step: StepBoxed, actions: CheckpointerBuilder) { + let node_id = *node_id_ref; + let graph = GraphMutexClient::graph(node_id, step.parents()); + let mut state = graph.state.lock(); + + state.server.register(node_id_ref, step, actions); + } + + fn backward(&self, root: AutodiffTensor, mode: BackwardMode) -> Gradients { + let node_id = root.node.id; + let graph = GraphMutexClient::graph(root.node.id, &[]); + + let grads = { + let mut state = graph.state.lock(); + state + .server + .backward::(root.node, root.primitive, node_id, mode) + }; // lock released + + GraphCleaner::cleanup_orphaned_entries(); + + grads + } +} + +struct GraphCleaner<'a> { + guard: MutexGuard<'a, Option>, +} + +impl<'a> GraphCleaner<'a> { + fn cleanup_orphaned_entries() { + let graphs = { + // Get the available graphs and release the lock + match STATE.lock().as_ref() { + Some(state) => state.graphs.clone(), + None => return, + } + }; + + let mut should_remove = Vec::new(); + for graph in graphs.values() { + { + let mut guard = graph.state.lock(); + // Double safety: in case it was marked as no longer useful, but other + // nodes are still relevant, we only check which nodes can safely be removed. + if !guard.server.maybe_useful() { + guard + .server + .free_unused_roots(|node| should_remove.push(*node)); + } + } + } + + if !should_remove.is_empty() { + let mut state = STATE.lock(); + if let Some(state) = state.as_mut() { + for node in should_remove { + state.remove_entry(&node); + } + } + } + } +} + +impl<'a> NodeCleaner for GraphCleaner<'a> { + fn init() -> Self { + let guard = STATE.lock(); + Self { guard } + } + + fn clean(&mut self, node: &NodeId) { + if let Some(state) = self.guard.as_mut() { + state.remove_entry(node); + } + } +} + +impl GraphLocator { + /// Selects a single graph for the given [NodeId], considering parent dependencies. + /// + /// If multiple graphs are found, they are merged into a single one. + /// + /// # Parameters + /// - `node`: The node ID of the graph to select. + /// - `parents`: A slice of parent nodes that the graph depends on. + /// + /// # Returns + /// + /// An `Arc` representing the selected or merged graph. + pub(crate) fn select(&mut self, node: NodeId, parents: &[Parent]) -> Arc { + match self.analyse(node, parents) { + GraphAnalysis::NoCollision(graph) => { + if graph.origin != node { + self.graphs.insert(node, graph.clone()); + self.register_key(graph.origin, node); + } + + graph + } + GraphAnalysis::Collisions(graphs) => self.merge(node, graphs), + } + } + + /// Analyses the graph for a given node and its parents, returning the associated `GraphAnalysis`. + fn analyse(&mut self, node: NodeId, parents: &[Parent]) -> GraphAnalysis { + // If no parents, there is no collision, therefore a single graph is ok. + if parents.is_empty() { + let graph = match self.graphs.get(&node) { + Some(val) => val.clone(), + None => self.new_graph(node), + }; + return GraphAnalysis::NoCollision(graph); + }; + + // We collect all graphs of parents and of the current node based on their origin node id. + let mut graphs = BTreeMap::>::new(); + + if let Some(val) = self.graphs.get(&node) { + graphs.insert(val.origin, val.clone()); + } + + for parent in parents { + match self.graphs.get(&parent.id) { + Some(graph) => graphs.insert(graph.origin, graph.clone()), + None => continue, + }; + } + + if graphs.is_empty() { + return match self.graphs.get(&node) { + Some(old) => GraphAnalysis::NoCollision(old.clone()), + None => GraphAnalysis::NoCollision(self.new_graph(node)), + }; + } + + if graphs.len() == 1 { + return GraphAnalysis::NoCollision(graphs.into_values().next().unwrap()); + } + + GraphAnalysis::Collisions(graphs) + } + + /// Merges multiple graphs associated with a node into a single graph. + fn merge(&mut self, node: NodeId, graphs: BTreeMap>) -> Arc { + // `into_values()` on the `BTreeMap` yields graphs in ascending `NodeId` order, so the "main" + // graph and the merge order are deterministic (a `HashMap` here picked an arbitrary, + // run-to-run-varying main, which permuted the merged steps and the backward op stream). + let mut graphs = graphs.into_values(); + + let main = graphs.next().expect("At least one graph"); + self.register_key(main.origin, node); + + let mut state = main.state.lock(); + + for graph in graphs { + self.merge_two(&mut state, &main, graph); + } + + self.graphs.insert(main.origin, main.clone()); + self.graphs.insert(node, main.clone()); + + core::mem::drop(state); + + main + } + + /// Registers a key for a given origin node. + fn register_key(&mut self, origin: NodeId, key: NodeId) { + self.keys.entry(origin).or_default(); + + if origin != key { + // Register this node to point to the origin graph + self.keys.get_mut(&origin).unwrap().insert(key); + } + } + + /// Merges two graphs by combining their states and updating graph mappings. + fn merge_two(&mut self, main_state: &mut GraphState, main: &Arc, merged: Arc) { + let mut locked = merged.state.lock(); + let mut state_old = GraphState::default(); + core::mem::swap(&mut state_old, &mut locked); + main_state.server.extend(state_old.server); + + // Re-map merged origin to the main graph + self.graphs.insert(merged.origin, main.clone()); + + // Move all keys (node IDs) from the merged graph to the main graph + if let Some(locator_keys) = self.keys.remove(&merged.origin) { + for k in locator_keys.iter() { + self.graphs.insert(*k, main.clone()); + } + + let locator_keys_main = self + .keys + .get_mut(&main.origin) + .expect("Should be init before the merge."); + locator_keys_main.extend(locator_keys); + } + } + + /// Creates a new graph for a given node. + fn new_graph(&mut self, origin: NodeId) -> Arc { + let graph = Arc::new(Graph { + origin, + state: Mutex::new(GraphState::default()), + }); + self.graphs.insert(origin, graph.clone()); + self.keys.insert(origin, HashSet::new()); + graph + } + + fn remove_entry(&mut self, node: &NodeId) { + if let Some(graph) = self.graphs.remove(node) { + let mut remove = false; + + if let Some(entry) = self.keys.get_mut(&graph.origin) { + entry.remove(node); + if entry.is_empty() { + remove = true; + } + } + + if remove { + self.keys.remove(&graph.origin); + } + } + } +} + +/// Represents the analysis result of graph operations for a given node and its parents. +#[derive(Debug)] +enum GraphAnalysis { + /// No collision detected, contains the graph associated with the node. + NoCollision(Arc), + /// Collision detected, contains a map of node IDs to their associated graphs. + Collisions(BTreeMap>), +} diff --git a/crates/burn-autodiff/src/runtime/memory_management.rs b/crates/burn-autodiff/src/runtime/memory_management.rs new file mode 100644 index 0000000..0a1f4c6 --- /dev/null +++ b/crates/burn-autodiff/src/runtime/memory_management.rs @@ -0,0 +1,301 @@ +use crate::{ + NodeId, + collections::{HashMap, HashSet}, + graph::Parent, + tensor::NodeRefCount, +}; +use alloc::{borrow::ToOwned, vec, vec::Vec}; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use core::mem; + +#[derive(Default, Debug)] +pub struct GraphMemoryManagement { + nodes: HashMap>, + leaves: HashSet, + statuses: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +enum NodeMemoryStatus { + Useful, + Unavailable, + Unknown, +} + +impl GraphMemoryManagement { + pub fn extend(&mut self, other: Self) { + self.nodes.extend(other.nodes); + self.leaves.extend(other.leaves); + self.statuses.extend(other.statuses); + } + + /// Register a new node with its parent. + pub fn register(&mut self, node: NodeRefCount, parents: &[Parent]) { + let node_id = *node.as_ref(); + + for parent in parents.iter() { + self.leaves.remove(&parent.id); + } + + self.leaves.insert(node_id); + self.nodes + .insert(node, parents.iter().map(|p| p.id).collect()); + } + + /// Free the node from the state. + pub fn consume_node(&mut self, node_id: NodeId) { + if !self.is_referenced(node_id) { + self.leaves.remove(&node_id); + self.nodes.remove(&node_id); + } + } + + /// Free all nodes whose backward call has become impossible + /// + /// This function goes into three steps, which must happen for all leaves + /// before going into the next step. Then it deletes what can be safely deleted + pub(crate) fn free_unavailable_nodes(&mut self, mut on_free_graph: impl FnMut(&NodeId)) { + let leaves = self.leaves.clone(); + let mut new_leaves = HashSet::new(); + let mut deletables = Vec::new(); + + // When consuming nodes with a backward pass, some other backward passes become + // unavailable because some of their parents have been consumed. They are + // identified here. + for leaf in leaves.clone() { + self.unavailable_propagation(leaf); + } + + // Among the available nodes that remain, some may be useless if no + // available node with a tensor reference exist in their descendance. + // But some may seem useless from some leaf but be useful from another one, + // hence the need to iterate on all leaves. + self.useful_propagation(leaves.clone()); + + // New leaves are the roots of a useful backward sub-tree. + // Deletables are everything not marked as useful. + for leaf in leaves { + self.identify_leaves_and_deletables(leaf, &mut new_leaves, &mut deletables); + } + + // Replace leaves by the new ones and delete everything not useful anymore + mem::swap(&mut self.leaves, &mut new_leaves); + + self.clear_unused_roots(&mut deletables); + + self.statuses.clear(); + for node_to_delete in deletables { + self.nodes.remove(&node_to_delete); + on_free_graph(&node_to_delete) + } + } + + pub(crate) fn free_unused_roots(&mut self, mut on_free_graph: impl FnMut(&NodeId)) { + let mut deletables = Vec::new(); + self.clear_unused_roots(&mut deletables); + + for node_id in deletables { + self.nodes.remove(&node_id); + on_free_graph(&node_id); + } + } + + fn clear_unused_roots(&self, to_delete: &mut Vec) { + for (id, parents) in self.nodes.iter() { + let is_useful = matches!( + self.statuses.get(id.as_ref()), + Some(NodeMemoryStatus::Useful) + ); + + // Check if parents are either empty or absent from self.nodes + let parents_absent = parents.iter().all(|p| !self.nodes.contains_key(p)); + + if !is_useful && Arc::strong_count(id) == 1 && parents_absent { + to_delete.push(*id.as_ref()) + } + } + } + + fn unavailable_propagation(&mut self, node_id: NodeId) -> NodeMemoryStatus { + // If already visited + if let Some(status) = self.statuses.get(&node_id) { + return status.clone(); + } + + match self.nodes.get(&node_id).cloned() { + // If node exists and any of its parents is unavailable, it is unavailable as well + // If node exists but the parents vec is empty, it is a tensor that never had parents; + // the status remains unknown + Some(parents) => { + let mut node_status = NodeMemoryStatus::Unknown; + for parent in parents { + let parent_status = self.unavailable_propagation(parent); + if let NodeMemoryStatus::Unavailable = parent_status { + node_status = NodeMemoryStatus::Unavailable; + } + } + self.statuses.insert(node_id, node_status.clone()); + node_status + } + // If node does not exist, it was + // deleted, so this and all its descendants are unavailable + None => { + self.statuses.insert(node_id, NodeMemoryStatus::Unavailable); + NodeMemoryStatus::Unavailable + } + } + } + + fn useful_propagation(&mut self, leaves: HashSet) { + // Accumulate visited nodes + let mut explored = HashSet::new(); + let mut tagged_useful = HashSet::new(); + + // Queue of nodes to visit + let mut to_tag_useful = PopNodeSet::default(); + let mut to_explore = PopNodeSet::new(leaves); + + // Utility function to iterate over a node's parents + let parents = |node_id| { + self.nodes + .get(&node_id) + .cloned() + .unwrap_or_default() + .into_iter() + }; + + loop { + // Pop a node id, greedily looking at tag_useful ones first + let (node_id, status) = match to_tag_useful.pop() { + Some(node_id) => (node_id, NodeMemoryStatus::Useful), + None => match to_explore.pop() { + Some(node_id) => { + let node_status = self + .statuses + .get(&node_id) + .expect("All nodes should have received a status during unavailable_propagation") + .to_owned(); + + if let NodeMemoryStatus::Unknown = node_status { + match self.is_referenced(node_id) { + true => (node_id, NodeMemoryStatus::Useful), + false => (node_id, NodeMemoryStatus::Unknown), + } + } else { + (node_id, node_status) + } + } + None => { + // There are no nodes in the queues anymore + break; + } + }, + }; + + match status { + NodeMemoryStatus::Useful => { + tagged_useful.insert(node_id); + for parent in parents(node_id) { + // The node can be explored, as long as it's not already tagged useful + if !(tagged_useful.contains(&parent) || to_tag_useful.contains(&parent)) { + to_tag_useful.insert(parent); + } + } + } + _ => { + explored.insert(node_id); + for parent in parents(node_id) { + if !(explored.contains(&parent) || to_explore.contains(&parent)) { + to_explore.insert(parent); + } + } + } + } + + self.statuses.insert(node_id, status); + } + } + + fn identify_leaves_and_deletables( + &self, + leaf_id: NodeId, + new_leaves: &mut HashSet, + to_delete: &mut Vec, + ) { + let mut visited = HashSet::new(); + let mut to_visit = vec![leaf_id]; + + while let Some(node_id) = to_visit.pop() { + visited.insert(node_id); + + match self + .statuses + .get(&node_id) + .expect("Node should have status") + { + NodeMemoryStatus::Useful => { + new_leaves.insert(node_id); + } + _ => { + to_delete.push(node_id); + + for parent in self + .nodes + .get(&node_id) + .cloned() + .unwrap_or_default() + .into_iter() + { + if !visited.contains(&parent) { + to_visit.push(parent); + } + } + } + }; + } + } + + fn is_referenced(&self, node_id: NodeId) -> bool { + match self.nodes.get_key_value(&node_id) { + Some((key, _value)) => Arc::strong_count(key) > 1, + None => panic!("Node should be in the nodes map"), + } + } + + pub(crate) fn maybe_useful(&self) -> bool { + self.nodes.keys().any(|node| Arc::strong_count(node) > 1) + } +} + +/// Wrapper over hash set for fast popping of any node +#[derive(new, Default)] +struct PopNodeSet { + hash_set: HashSet, +} + +impl PopNodeSet { + #[inline(always)] + fn pop(&mut self) -> Option { + self.hash_set + .iter() + .next() + .copied() + .and_then(|node_id| self.hash_set.take(&node_id)) + } + + #[inline(always)] + fn contains(&self, node_id: &NodeId) -> bool { + self.hash_set.contains(node_id) + } + + #[inline(always)] + fn insert(&mut self, node_id: NodeId) { + self.hash_set.insert(node_id); + } +} diff --git a/crates/burn-autodiff/src/runtime/mod.rs b/crates/burn-autodiff/src/runtime/mod.rs new file mode 100644 index 0000000..fde3a5f --- /dev/null +++ b/crates/burn-autodiff/src/runtime/mod.rs @@ -0,0 +1,6 @@ +mod client; +mod memory_management; +mod server; + +pub mod graph; +pub use client::*; diff --git a/crates/burn-autodiff/src/runtime/server.rs b/crates/burn-autodiff/src/runtime/server.rs new file mode 100644 index 0000000..7c29c49 --- /dev/null +++ b/crates/burn-autodiff/src/runtime/server.rs @@ -0,0 +1,201 @@ +use super::memory_management::GraphMemoryManagement; +use crate::{ + NodeId, + checkpoint::{ + base::{Checkpointer, NodeTree}, + builder::CheckpointerBuilder, + }, + collections::HashMap, + grads::{BackwardMode, Gradients}, + graph::{ + NodeRef, StepBoxed, + traversal::{BreadthFirstSearch, TraversalItem}, + }, + tensor::NodeRefCount, +}; +use alloc::vec::Vec; +use burn_backend::Backend; +use burn_backend::tensor::FloatTensor; + +#[cfg(feature = "std")] +use crate::grads::GradSyncContext; + +struct TapeResult { + tape: Vec>, + checkpointer: Checkpointer, + #[cfg(feature = "std")] + distributed: Option, +} + +#[derive(Default)] +pub struct AutodiffServer { + steps: HashMap, + actions_builder: HashMap, + memory_management: GraphMemoryManagement, +} + +/// Defines how nodes are clean. +pub trait NodeCleaner { + /// Initialize a new cleaner. + fn init() -> Self; + /// Cleans a single [node](NodeId). + fn clean(&mut self, node: &NodeId); +} + +impl AutodiffServer { + pub fn extend(&mut self, other: AutodiffServer) { + self.steps.extend(other.steps); + self.actions_builder.extend(other.actions_builder); + self.memory_management.extend(other.memory_management); + } + + pub fn register(&mut self, rc: NodeRefCount, step: StepBoxed, actions: CheckpointerBuilder) { + let parents = step.parents(); + let node_id = *rc.as_ref(); + + self.memory_management.register(rc, parents); + + self.steps.insert(node_id, step); + self.actions_builder.insert(node_id, actions); + } + + pub fn backward( + &mut self, + root_node: NodeRef, + root_tensor: FloatTensor, + node_id: NodeId, + mode: BackwardMode, + ) -> Gradients { + let step = self.steps.remove(&node_id).expect( + "Node should have a step registered, did you forget to call \ + `Tensor::register_grad` on the tensor where you need gradients?", + ); + let builder = self.actions_builder.remove(&node_id).unwrap(); + + let mut consumed = Vec::new(); + let tape_result = self.build_tape(node_id, step, builder, &mut consumed); + + let grads = match mode { + #[cfg(feature = "std")] + BackwardMode::Distributed(factory) if tape_result.distributed.is_some() => { + let on_register = factory(tape_result.distributed.clone().unwrap()); + Gradients::new_distributed::(root_node, root_tensor, on_register) + } + _ => Gradients::new::(root_node, root_tensor), + }; + + let gradients = Self::execute_steps(tape_result.tape, grads, tape_result.checkpointer); + + self.cleanup::(&consumed); + + gradients + } + + fn cleanup(&mut self, consumed: &Vec) { + let mut cleaner = NC::init(); + self.memory_management + .free_unavailable_nodes(|node_id: &NodeId| { + self.steps.remove(node_id); + self.actions_builder.remove(node_id); + NC::clean(&mut cleaner, node_id); + }); + for node_id in consumed { + cleaner.clean(node_id) + } + } + + pub(crate) fn free_unused_roots(&mut self, mut on_free_graph: impl FnMut(&NodeId)) { + self.memory_management.free_unused_roots(|node_id| { + self.steps.remove(node_id); + self.actions_builder.remove(node_id); + on_free_graph(node_id); + }); + } + + fn build_tape( + &mut self, + node: NodeId, + node_step: StepBoxed, + mut builder: CheckpointerBuilder, + consumed: &mut Vec, + ) -> TapeResult { + let mut tape = (0..node_step.depth() + 1) + .map(|_| Vec::with_capacity(1)) + .collect::>(); + + let mut tree = HashMap::default(); + + #[cfg(feature = "std")] + let mut n_required_map = HashMap::default(); + #[cfg(feature = "std")] + let mut distributed_params = HashMap::default(); + + BreadthFirstSearch.traverse(node, node_step, &mut self.steps, |id, step| { + self.memory_management.consume_node(id); + // Clean up consumed node + consumed.push(id); + + let depth = step.depth(); + + #[cfg(feature = "std")] + step.distributed_params() + .and_then(|params| distributed_params.insert(id, params)); + + if let Some(steps) = tape.get_mut(depth) { + let parents = step + .parents() + .iter() + .map(|p| { + #[cfg(feature = "std")] + { + *n_required_map.entry(p.id).or_insert(0) += 1; + } + p.id + }) + .filter(|s| *s != id); + tree.insert(id, parents.collect()); + steps.push(step); + } + + if let Some(node_builder) = self.actions_builder.remove(&id) { + builder.extend(node_builder); + } + }); + + let checkpointer = builder.build(NodeTree::new(tree)); + #[cfg(feature = "std")] + let distributed = Some(GradSyncContext { + n_required_map, + distributed_params, + }); + + TapeResult { + tape, + checkpointer, + #[cfg(feature = "std")] + distributed, + } + } + + fn execute_steps( + tape: Vec>, + mut grads: Gradients, + mut checkpointer: Checkpointer, + ) -> Gradients { + tape.into_iter().rev().for_each(|steps| { + steps + .into_iter() + .for_each(|step| step.step(&mut grads, &mut checkpointer)) + }); + + // For checkpointing tests + #[cfg(feature = "export_tests")] + assert!(checkpointer.is_empty()); + + grads + } + + pub(crate) fn maybe_useful(&self) -> bool { + self.memory_management.maybe_useful() + } +} diff --git a/crates/burn-autodiff/src/tensor.rs b/crates/burn-autodiff/src/tensor.rs new file mode 100644 index 0000000..8b1db6d --- /dev/null +++ b/crates/burn-autodiff/src/tensor.rs @@ -0,0 +1,261 @@ +use crate::{ + checkpoint::{base::Checkpointer, builder::CheckpointerBuilder}, + grads::{BackwardMode, Gradients}, + graph::{ComputingProperty, Node, NodeId, NodeRef, Parent, Requirement, Step}, + runtime::{AutodiffClient, AutodiffClientImpl}, +}; +#[cfg(feature = "std")] +use crate::{distributed::DistributedGradientRegistration, grads::GradSyncContext}; +use alloc::{boxed::Box, vec}; +use burn_backend::{Backend, BackendTypes, TensorMetadata}; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use burn_backend::distributed::{DistributedParamId, DistributedParams}; + +#[derive(Debug, Clone)] +pub struct AutodiffTensor { + pub primitive: B::FloatTensorPrimitive, + pub node: NodeRef, + pub rc: NodeRefCount, +} + +impl TensorMetadata for AutodiffTensor { + type Device = B::Device; + fn dtype(&self) -> burn_std::DType { + self.primitive.dtype() + } + + fn shape(&self) -> burn_std::Shape { + self.primitive.shape() + } + + fn rank(&self) -> usize { + self.primitive.rank() + } + fn device(&self) -> B::Device { + self.primitive.device() + } + + fn can_mut(&self) -> bool { + // Precise: the inner handle's buffer refcount already accounts for any + // clone the autodiff graph retains (e.g. checkpointed states). + self.primitive.can_mut() + } +} + +pub type NodeRefCount = Arc; + +#[derive(new, Debug)] +pub(crate) struct RootStep { + node: NodeRef, +} + +impl Step for RootStep { + fn step(self: Box, _grads: &mut Gradients, _checkpointer: &mut Checkpointer) { + // Nothing to do + } + + fn node(&self) -> NodeId { + self.node.id + } + + fn parents(&self) -> &[Parent] { + &self.node.parents + } + + fn depth(&self) -> usize { + self.node.order + } + + fn distributed_params(&self) -> Option { + self.node.distributed_params.clone() + } +} + +impl AutodiffTensor { + /// Create a new leaf tensor. + pub fn new(primitive: B::FloatTensorPrimitive) -> Self { + let id = NodeId::new(); + let node: NodeRef = Node::new( + vec![], + 0, + id, + Requirement::None, + ComputingProperty::Ambiguous, + AutodiffClientImpl::new(), + None, + ) + .into(); + + Self { + rc: Arc::new(node.id), + primitive, + node: node.clone(), + } + } + + pub fn is_tracked(&self) -> bool { + !self.node.requirement.is_none() + } + + /// Mark the tensor as requiring gradients. + /// + /// # Panics + /// + /// It panics if the tensor is not a leaf. + pub fn require_grad(mut self) -> Self { + match self.node.requirement { + Requirement::Grad => self, + Requirement::GradInBackward => { + panic!("Can't convert a non leaf tensor into a tracked tensor") + } + Requirement::None => { + self.node = Node::new( + vec![], + 0, + self.node.id, + Requirement::Grad, + self.node.properties.clone(), + self.node.client.clone(), + self.node.distributed_params.clone(), + ) + .into(); + let step = RootStep::new(self.node.clone()); + + self.register_step(step, CheckpointerBuilder::default()) + } + } + } + + /// Create a tensor from parent infos. + pub fn from_parents( + primitive: B::FloatTensorPrimitive, + parent_nodes: &[NodeRef], + requirement: Requirement, + computing_properties: ComputingProperty, + ) -> Self { + let order = parent_nodes + .iter() + .map(|node| node.order) + .reduce(usize::max) + .unwrap_or(0) + + 1; + + let client = parent_nodes + .first() + .map(|node| node.client.clone()) + .unwrap_or_else(AutodiffClientImpl::new); + + let node: NodeRef = Node::new( + parent_nodes + .iter() + .filter_map(|node| node.clone_if_require_grad()) + .map(|node| Parent::new(node.id)) + .collect(), + order, + NodeId::new(), + requirement, + computing_properties, + client, + None, + ) + .into(); + + Self { + rc: Arc::new(node.id), + primitive, + node, + } + } + + /// Register a step into a graph for that tensor. + /// + /// # Warning + /// + /// This should be called only once per tensor. + pub fn register_step( + self, + step_that_created_the_tensor: S, + actions: CheckpointerBuilder, + ) -> Self { + self.node.client.register( + self.rc.clone(), + Box::new(step_that_created_the_tensor), + actions, + ); + self + } + + pub fn into_primitive(self) -> B::FloatTensorPrimitive { + self.primitive + } + + #[cfg(not(feature = "std"))] + pub fn backward(self) -> Gradients { + let client = self.node.client.clone(); + + AutodiffClient::backward::(&client, self, BackwardMode::default()) + } + + pub fn grad(&self, grads: &Gradients) -> Option { + grads.get::(self) + } + + pub fn grad_remove(&self, grads: &mut Gradients) -> Option { + grads.remove::(self) + } + + pub fn grad_replace(&self, grads: &mut Gradients, grad: B::FloatTensorPrimitive) { + grads.remove::(self); + grads.register::(self.node.id, grad); + } + + /// Mark the tensor as distributed across multiple devices. + /// Its gradients will be automatically aggregated from those devices after the backward pass. + /// + /// # Arguments + /// + /// * `param_id` - The module tensor's [`DistributedParamId`]. + pub fn grad_distributed(mut self, param_id: DistributedParamId) -> Self { + self.node = Node::new( + vec![], + 0, + self.node.id, + self.node.requirement, + self.node.properties.clone(), + self.node.client.clone(), + Some(DistributedParams { param_id }), + ) + .into(); + let step = RootStep::new(self.node.clone()); + + self.register_step(step, CheckpointerBuilder::default()) + } +} + +#[cfg(feature = "std")] +impl AutodiffTensor { + pub fn backward(self) -> Gradients { + let device = self.primitive.device(); + let device_cloned = device.clone(); + let client = self.node.client.clone(); + + let mode = BackwardMode::Distributed(Box::new(|ctx: GradSyncContext| { + let registration = DistributedGradientRegistration::::new( + ctx.n_required_map, + ctx.distributed_params, + device_cloned, + ); + Box::new(registration) + })); + + let grads = AutodiffClient::backward::(&client, self, mode); + B::submit_sync_collective(&device); + grads + } +} diff --git a/crates/burn-autodiff/src/utils.rs b/crates/burn-autodiff/src/utils.rs new file mode 100644 index 0000000..1f3549c --- /dev/null +++ b/crates/burn-autodiff/src/utils.rs @@ -0,0 +1,25 @@ +use alloc::vec::Vec; + +use crate::graph::NodeRef; +/// Duplicate the given object for each node that requires gradients. +/// +/// # Notes +/// +/// This is useful since you don't have to keep N cloned references alive event if just 1 node +/// will be updated. +/// +/// If the object is a tensor and if one reference exists, it can be updated inplace. +pub fn duplicate( + nodes: &[Option; N], + obj: Option, +) -> [Option; N] { + nodes + .iter() + .map(|node| match node { + Some(_) => obj.clone(), + None => None, + }) + .collect::>() + .try_into() + .unwrap() +} diff --git a/crates/burn-backend-extension/Cargo.toml b/crates/burn-backend-extension/Cargo.toml new file mode 100644 index 0000000..00d257f --- /dev/null +++ b/crates/burn-backend-extension/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors = ["nathanielsimard "] +description = "Burn backend extension proc macro" +edition.workspace = true +license.workspace = true +name = "burn-backend-extension" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-backend-extension" +version.workspace = true + +[lints] +workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } diff --git a/crates/burn-backend-extension/LICENSE-APACHE b/crates/burn-backend-extension/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-backend-extension/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-backend-extension/LICENSE-MIT b/crates/burn-backend-extension/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-backend-extension/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-backend-extension/README.md b/crates/burn-backend-extension/README.md new file mode 100644 index 0000000..376a93d --- /dev/null +++ b/crates/burn-backend-extension/README.md @@ -0,0 +1,6 @@ +# Burn Backend Extension + +> [Burn](https://github.com/tracel-ai/burn) backend extension generation + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-backend-extension.svg)](https://crates.io/crates/burn-backend-extension) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-backend-extension/blob/master/README.md) diff --git a/crates/burn-backend-extension/src/lib.rs b/crates/burn-backend-extension/src/lib.rs new file mode 100644 index 0000000..4646490 --- /dev/null +++ b/crates/burn-backend-extension/src/lib.rs @@ -0,0 +1,875 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote}; + +use proc_macro2::TokenStream as TokenStream2; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{ + FnArg, GenericArgument, Ident, ItemStruct, ItemTrait, Meta, Pat, PathArguments, ReturnType, + Token, TraitItem, Type, TypeParamBound, parse_macro_input, +}; + +/// # `backend_extension` +/// +/// Attribute macro that generates dispatch glue for Burn backend extension traits. +/// +/// ## Usage +/// +/// ```rust,ignore +/// use burn_backend_extension::backend_extension; +/// +/// #[backend_extension(Wgpu, Cuda, Cpu, Autodiff)] +/// pub trait MyExtension: Backend { +/// fn fused_matmul_add_relu(lhs: FloatTensor, rhs: FloatTensor, bias: FloatTensor) -> FloatTensor; +/// fn custom_threshold(x: FloatTensor, threshold: f32) -> FloatTensor; +/// } +/// ``` +/// +/// ### What gets generated +/// +/// - An `impl Trait for Dispatch` is generated automatically. +/// - Each method dispatches to the corresponding implementation for the listed backends. +/// - If `Autodiff` is specified, autodiff variants are also handled automatically. +/// - All other backends are left as `unimplemented!()`. +/// +/// Supported tensor argument/return types: `FloatTensor`, `IntTensor`, +/// `BoolTensor`, and `QuantizedTensor` (a QFloat tensor passed to the +/// backend still quantized — the op reads the packed values/scales directly instead +/// of going through a dequantize). +#[proc_macro_attribute] +pub fn backend_extension(attr: TokenStream, item: TokenStream) -> TokenStream { + // Parse the backend list + let backends = parse_macro_input!(attr as Backends); + // Parse the trait definition + let trait_def = parse_macro_input!(item as ItemTrait); + + // Lower to extension representation, then expand codegen + let expanded = lower_extension(backends, &trait_def) + .map(|ir| expand_extension(ir, trait_def)) + .unwrap_or_else(|err| err.to_compile_error()); + + TokenStream::from(expanded) +} + +#[derive(Debug, Clone)] +struct Backend { + pub kind: BackendKind, + pub cfg: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackendKind { + Cpu, + Cuda, + Rocm, + Metal, + Vulkan, + Wgpu, + WebGpu, + Flex, + NdArray, + LibTorch, + Remote, +} + +impl BackendKind { + fn try_from(ident: &Ident) -> syn::Result { + match ident.to_string().as_str() { + "Cpu" => Ok(BackendKind::Cpu), + "Cuda" => Ok(BackendKind::Cuda), + "Wgpu" => Ok(BackendKind::Wgpu), + "WebGpu" => Ok(BackendKind::WebGpu), + "Metal" => Ok(BackendKind::Metal), + "Rocm" => Ok(BackendKind::Rocm), + "Vulkan" => Ok(BackendKind::Vulkan), + "Flex" => Ok(BackendKind::Flex), + "NdArray" => Ok(BackendKind::NdArray), + "LibTorch" => Ok(BackendKind::LibTorch), + "Remote" => Ok(BackendKind::Remote), + other => Err(syn::Error::new_spanned( + ident, + format!("Unsupported backend `{}`", other), + )), + } + } +} + +struct Backends { + concrete: Vec, + autodiff: (bool, Option), +} + +// Helper to parse backend idents w/ optional cfg +struct BackendArg { + id: Ident, + cfg: Option, +} + +impl Parse for BackendArg { + fn parse(input: ParseStream) -> syn::Result { + let id: Ident = input.parse()?; + let cfg = if input.peek(Token![:]) { + input.parse::()?; + + // This parses cfg(feature = "...") or any other meta item + let meta: syn::Meta = input.parse()?; + Some(meta) + } else { + None + }; + + Ok(Self { id, cfg }) + } +} + +impl Parse for Backends { + fn parse(input: ParseStream) -> syn::Result { + let args = Punctuated::::parse_terminated(input)?; + + let mut concrete = vec![]; + let mut autodiff = (false, None); + + for arg in args { + if arg.id == "Autodiff" { + autodiff = (true, arg.cfg); + continue; + } + + concrete.push(Backend { + kind: BackendKind::try_from(&arg.id)?, + cfg: arg.cfg, + }); + } + + Ok(Backends { concrete, autodiff }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TensorKind { + Float, + Int, + Bool, + Quantized, +} + +#[allow(clippy::large_enum_variant)] +enum ArgKind { + Tensor(TensorKind), + // Passthrough - unhandled by the macro + Other(Type), +} + +struct OperationArg { + name: Ident, + kind: ArgKind, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +enum OutputKind { + Tensor(TensorKind), + Custom(Type), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +enum OperationOutput { + Tensor(TensorKind), + Tuple(Vec), + Custom(Type), +} + +struct Operation { + name: Ident, + inputs: Vec, + output: OperationOutput, + asyncness: bool, +} + +struct Extension { + trait_name: Ident, + backends: Backends, + ops: Vec, +} + +impl TensorKind { + fn from_type(ty: &Type) -> Option { + match ty { + // Handle `::` + Type::Path(tp) if tp.qself.is_some() => { + let last = tp.path.segments.last()?.ident.to_string(); + + match last.as_str() { + "FloatTensorPrimitive" => Some(Self::Float), + "IntTensorPrimitive" => Some(Self::Int), + "BoolTensorPrimitive" => Some(Self::Bool), + "QuantizedTensorPrimitive" => Some(Self::Quantized), + _ => None, + } + } + // Handle simple paths: Float, FloatTensor, burn::...::FloatTensor + Type::Path(tp) => { + let last = tp.path.segments.last()?.ident.to_string(); + + match last.as_str() { + // Shorthand + "Float" => Some(Self::Float), + "Int" => Some(Self::Int), + "Bool" => Some(Self::Bool), + "Quantized" => Some(Self::Quantized), + + // Full tensor types + "FloatTensor" => Some(Self::Float), + "IntTensor" => Some(Self::Int), + "BoolTensor" => Some(Self::Bool), + "QuantizedTensor" => Some(Self::Quantized), + + // Associated primitive types + "FloatTensorPrimitive" => Some(Self::Float), + "IntTensorPrimitive" => Some(Self::Int), + "BoolTensorPrimitive" => Some(Self::Bool), + "QuantizedTensorPrimitive" => Some(Self::Quantized), + + _ => None, + } + } + + // Handle references like &FloatTensor + Type::Reference(r) => Self::from_type(&r.elem), + + // Handle parentheses `(FloatTensor)` + Type::Paren(p) => Self::from_type(&p.elem), + + // TODO: option/containers already handled? + _ => None, + } + } + + fn to_primitive_ty(self) -> TokenStream2 { + match self { + Self::Float => quote! { burn::backend::tensor::FloatTensor }, + Self::Int => quote! { burn::backend::tensor::IntTensor }, + Self::Bool => quote! { burn::backend::tensor::BoolTensor }, + Self::Quantized => quote! { burn::backend::tensor::QuantizedTensor }, + } + } + + fn variant(self) -> Ident { + match self { + Self::Float => format_ident!("Float"), + Self::Int => format_ident!("Int"), + Self::Bool => format_ident!("Bool"), + Self::Quantized => format_ident!("Quantized"), + } + } + + fn unwrap_method(self) -> Ident { + // e.g. tensor.float() (BackendTensor method) + format_ident!("{}", format!("{:?}", self).to_lowercase()) + } +} + +fn backend_to_ident(b: &Backend) -> Ident { + // Convert the enum variant to a string first, then to an Ident + format_ident!("{}", format!("{:?}", b.kind)) +} + +fn extract_future_output_type(ty: &Type) -> Option<&Type> { + if let Type::ImplTrait(impl_trait) = ty { + for bound in &impl_trait.bounds { + if let TypeParamBound::Trait(trait_bound) = bound { + let last_segment = trait_bound.path.segments.last()?; + if last_segment.ident == "Future" + && let PathArguments::AngleBracketed(args) = &last_segment.arguments + { + for arg in &args.args { + if let GenericArgument::AssocType(assoc) = arg + && assoc.ident == "Output" + { + return Some(&assoc.ty); + } + } + } + } + } + } + None +} + +fn lower_extension(attr: Backends, item: &ItemTrait) -> syn::Result { + let mut ops = Vec::new(); + + for trait_item in &item.items { + let TraitItem::Fn(f) = trait_item else { + continue; + }; + + // Parse Inputs + let mut inputs = Vec::new(); + for arg in &f.sig.inputs { + let FnArg::Typed(pt) = arg else { continue }; + let name = match pt.pat.as_ref() { + Pat::Ident(p) => p.ident.clone(), + _ => return Err(syn::Error::new_spanned(&pt.pat, "Unsupported pattern")), + }; + let kind = TensorKind::from_type(&pt.ty) + .map(ArgKind::Tensor) + .unwrap_or_else(|| ArgKind::Other((*pt.ty).clone())); + inputs.push(OperationArg { name, kind }); + } + + // Parse outputs + + // Parse outputs + let (actual_ty, is_async) = match &f.sig.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + &f.sig.output, + "Operations must return a value", + )); + } + ReturnType::Type(_, ty) => { + // If it's `impl Future`, extract T and mark as async. + // Otherwise, use the type as-is and check for `async fn`. + if let Some(out_ty) = extract_future_output_type(ty) { + (out_ty, true) + } else { + (ty.as_ref(), f.sig.asyncness.is_some()) + } + } + }; + + let output = match actual_ty { + // TODO: expand support for vec and maybe nested containers + Type::Tuple(tup) => { + let elements = tup + .elems + .iter() + .map(|elem| { + if let Some(kind) = TensorKind::from_type(elem) { + Ok(OutputKind::Tensor(kind)) + } else { + Ok(OutputKind::Custom(elem.clone())) + } + }) + .collect::>>()?; + OperationOutput::Tuple(elements) + } + ty if TensorKind::from_type(ty).is_some() => { + OperationOutput::Tensor(TensorKind::from_type(ty).unwrap()) + } + ty => { + // ExtensionType + OperationOutput::Custom(ty.clone()) + } + }; + + ops.push(Operation { + name: f.sig.ident.clone(), + inputs, + output, + asyncness: is_async, + }); + } + + Ok(Extension { + trait_name: item.ident.clone(), + backends: attr, + ops, + }) +} + +fn expand_extension(ir: Extension, original_trait: ItemTrait) -> TokenStream2 { + let trait_name = &ir.trait_name; + + // Generate Dispatch Implementation + let dispatch_methods = ir.ops.iter().map(|op| gen_dispatch_method(&ir, op)); + + quote! { + #original_trait + + impl #trait_name for burn::backend::Dispatch { + #( #dispatch_methods )* + } + } +} + +fn gen_dispatch_method(ir: &Extension, op: &Operation) -> TokenStream2 { + let name = &op.name; + let has_ad = ir.backends.autodiff.0; + let ad_cfg_attr = ir + .backends + .autodiff + .1 + .as_ref() + .map(|meta| quote! { #[#meta] }); + + let maybe_async = if op.asyncness { + quote! { async } + } else { + quote! {} + }; + + let sig_args = op.inputs.iter().map(|arg| { + let name = &arg.name; + match &arg.kind { + ArgKind::Tensor(k) => { + let ty = k.to_primitive_ty(); + quote! { #name: #ty } + } + ArgKind::Other(ty) => quote! { #name: #ty }, + } + }); + + let ret_ty = match &op.output { + OperationOutput::Tensor(k) => k.to_primitive_ty(), + OperationOutput::Tuple(elems) => { + let types = elems.iter().map(|e| match e { + OutputKind::Tensor(k) => k.to_primitive_ty(), + OutputKind::Custom(ty) => quote! { #ty }, + }); + quote! { (#(#types),*) } + } + OperationOutput::Custom(ty) => quote! { #ty }, + }; + + let tensor_inputs: Vec<_> = op + .inputs + .iter() + .filter_map(|a| match &a.kind { + ArgKind::Tensor(_) => Some(&a.name), + _ => None, + }) + .collect(); + + let match_inputs = match tensor_inputs.len() { + 0 => quote! { () }, + 1 => { + let name = tensor_inputs[0]; + quote! { #name.kind } + } + _ => { + let kinds = tensor_inputs.iter().map(|n| quote! { #n.kind }); + quote! { (#(#kinds),*) } + } + }; + + let first_tensor = op.inputs.iter().find_map(|a| match &a.kind { + ArgKind::Tensor(_) => Some(&a.name), + _ => None, + }); + + let ckp_logic = if let Some(name) = first_tensor { + quote! { let checkpointing = #name.checkpointing.clone(); } + } else { + quote! { let checkpointing = None; } + }; + + let body = if tensor_inputs.is_empty() { + // No tensor input to select the backend from (e.g. `fn load_data(i: usize) -> FloatTensor`). + // There is nothing to match on, so this is only well-defined for a single backend — the + // remote backend is the motivating case (`#[backend_extension(Remote)]`), where the op is + // shipped to the server. Dispatch directly to that backend; reject the ambiguous cases. + if has_ad { + quote! { compile_error!("A backend extension operation with no tensor inputs can't be combined with `Autodiff` — there is no input tensor to carry the autodiff graph.") } + } else if ir.backends.concrete.len() == 1 { + let backend = &ir.backends.concrete[0]; + let call = gen_backend_call(ir, op, backend); + match &backend.cfg { + // Ungated backend: dispatch straight to it. + None => quote! { #ckp_logic #call }, + // The single backend is `cfg`-gated. Mirror the match path: gate the call on the + // backend's cfg and fall back to `unimplemented!` when it's compiled out, so the + // method still has a valid body (instead of referencing a backend that doesn't + // exist). `ckp_logic` lives inside the gated arm so its `checkpointing` binding + // isn't left dangling (and untypeable) when the backend is compiled out. + Some(meta) => quote! { + match () { + #[#meta] + () => { #ckp_logic #call } + #[allow(unreachable_patterns)] + _ => unimplemented!("Backend not supported for custom op `{}`", stringify!(#name)), + } + }, + } + } else { + quote! { compile_error!("A backend extension operation with no tensor inputs must list exactly one backend (e.g. `#[backend_extension(Remote)]`), since there is no input tensor to select the backend from.") } + } + } else { + let concrete_arms = ir + .backends + .concrete + .iter() + .map(|b| gen_backend_arm(ir, op, b)); + let ad_arm = if has_ad { + Some(gen_autodiff_arm(ir, op)) + } else { + None + }; + + quote! { + #ckp_logic + match #match_inputs { + #( #concrete_arms )* + #ad_cfg_attr + #ad_arm + _ => unimplemented!("Backend not supported for custom op `{}`", stringify!(#name)), + } + } + }; + + quote! { + #maybe_async fn #name(#(#sig_args),*) -> #ret_ty { + #body + } + } +} + +fn gen_backend_arm(ir: &Extension, op: &Operation, backend: &Backend) -> TokenStream2 { + let b_ident = backend_to_ident(backend); + + // If any cfg(..) was specified to gate the backend + let cfg_attr = backend.cfg.as_ref().map(|meta| quote! { #[#meta] }); + + // Filter for tensor arguments only + let tensor_args: Vec<_> = op + .inputs + .iter() + .filter_map(|a| match &a.kind { + ArgKind::Tensor(k) => Some((&a.name, k)), + _ => None, + }) + .collect(); + + // Build the match pattern for the same backend + // e.g., (DispatchTensorKind::Wgpu(lhs), DispatchTensorKind::Wgpu(rhs)) + let pattern = if tensor_args.len() == 1 { + let (name, _) = tensor_args[0]; + quote! { burn::backend::DispatchTensorKind::#b_ident(#name) } + } else { + let pats = tensor_args.iter().map(|(name, _)| { + quote! { burn::backend::DispatchTensorKind::#b_ident(#name) } + }); + quote! { (#(#pats),*) } + }; + + let call = gen_backend_call(ir, op, backend); + + quote! { + #cfg_attr + #pattern => { #call } + } +} + +/// Generate the body that unwraps the dispatch tensors, calls the backend's trait impl and wraps the +/// result back into a [`DispatchTensor`]. Shared by [`gen_backend_arm`] (inside a match) and the +/// no-tensor-input dispatch path (direct call). +fn gen_backend_call(ir: &Extension, op: &Operation, backend: &Backend) -> TokenStream2 { + let b_ident = backend_to_ident(backend); + let trait_name = &ir.trait_name; + let fn_name = &op.name; + + // Unwrap inner kind: lhs.float(), rhs.int(), etc. (no-op when there are no tensor inputs). + let unwraps = op.inputs.iter().filter_map(|a| match &a.kind { + ArgKind::Tensor(kind) => { + let name = &a.name; + let method = kind.unwrap_method(); + Some(quote! { let #name = #name.#method(); }) + } + _ => None, + }); + + let call_args = op.inputs.iter().map(|a| &a.name); + let maybe_await = if op.asyncness { + quote! { .await } + } else { + quote! {} + }; + + let wrap_out = match &op.output { + OperationOutput::Tensor(kind) => { + let wrapped = gen_tensor_wrap(kind, quote! { _out }, &b_ident, false); + quote! { burn::backend::DispatchTensor { kind: #wrapped, checkpointing } } + } + OperationOutput::Tuple(elems) => { + let elements = elems.iter().enumerate().map(|(i, elem)| { + let idx = syn::Index::from(i); + match elem { + OutputKind::Tensor(kind) => { + let wrapped = gen_tensor_wrap(kind, quote! { _out.#idx }, &b_ident, false); + quote! { burn::backend::DispatchTensor { kind: #wrapped, checkpointing } } + } + OutputKind::Custom(_) => { + quote! { + burn::backend::ExtensionType::map_type( + _out.#idx, + |tensor| burn::backend::DispatchTensorKind::#b_ident(tensor), + checkpointing, + ) + } + } + } + }); + quote! { (#(#elements),*) } + } + OperationOutput::Custom(_) => { + quote! { burn::backend::ExtensionType::map_type(_out, |tensor| burn::backend::DispatchTensorKind::#b_ident(tensor), checkpointing) } + } + }; + + quote! { + #(#unwraps)* + let _out = <#b_ident as #trait_name>::#fn_name(#(#call_args),*)#maybe_await; + #wrap_out + } +} + +fn gen_autodiff_arm(ir: &Extension, op: &Operation) -> TokenStream2 { + let trait_name = &ir.trait_name; + let fn_name = &op.name; + + // Filter for tensor arguments only + let tensor_args: Vec<_> = op + .inputs + .iter() + .filter_map(|a| match &a.kind { + ArgKind::Tensor(k) => Some((&a.name, k)), + _ => None, + }) + .collect(); + + // Build the match pattern for the same backend wrapped by autodiff + let inner_arms = ir.backends.concrete.iter().map(|backend| { + let cfg_attr = backend.cfg.as_ref().map(|meta| quote! { #[#meta] }); + let b_ident = backend_to_ident(backend); + + let pattern = if tensor_args.len() == 1 { + let (name, kind) = tensor_args[0]; + + if *kind == TensorKind::Float { + quote! { + burn::backend::DispatchTensorKind::Autodiff(#name) + } + } else { + quote! { + burn::backend::DispatchTensorKind::#b_ident(#name) + } + } + } else { + let pats = tensor_args.iter().map(|(name, kind)| { + if **kind == TensorKind::Float { + quote! { + burn::backend::DispatchTensorKind::Autodiff(#name) + } + } else { + quote! { + burn::backend::DispatchTensorKind::#b_ident(#name) + } + } + }); + + quote! { (#(#pats),*) } + }; + + let unwraps = tensor_args.iter().map(|(name, kind)| { + if **kind == TensorKind::Float { + quote! { + let #name = match *#name { + burn::backend::DispatchTensorKind::#b_ident(t) => t.autodiff(), + _ => unreachable!("Autodiff backend mismatch"), + }; + } + } else { + let method = kind.unwrap_method(); + quote! { + let #name = #name.#method(); + } + } + }); + + let call_args = op.inputs.iter().map(|a| &a.name); + let maybe_await = if op.asyncness { + quote! { .await } + } else { + quote! {} + }; + + let wrap_out = match &op.output { + OperationOutput::Tensor(kind) => { + let wrapped = gen_tensor_wrap(kind, quote! { _out }, &b_ident, true); + quote! { burn::backend::DispatchTensor { kind: #wrapped, checkpointing } } + } + OperationOutput::Tuple(elems) => { + let elements = elems.iter().enumerate().map(|(i, elem)| { + let idx = syn::Index::from(i); + match elem { + OutputKind::Tensor(kind) => { + let wrapped = gen_tensor_wrap(kind, quote! { _out.#idx }, &b_ident, true); + quote! { burn::backend::DispatchTensor { kind: #wrapped, checkpointing } } + } + OutputKind::Custom(_) => { + quote! { + burn::backend::ExtensionType::map_type( + _out.#idx, + |tensor| match tensor { + burn::backend::BackendTensor::Float(t) => { + burn::backend::DispatchTensorKind::Autodiff( + Box::new(burn::backend::DispatchTensorKind::#b_ident( + burn::backend::BackendTensor::Autodiff(t) + )) + ) + } + _ => burn::backend::DispatchTensorKind::#b_ident(tensor), + }, + checkpointing, + ) + } + } + } + }); + quote! { (#(#elements),*) } + } + OperationOutput::Custom(_) => { + quote! { burn::backend::ExtensionType::map_type(_out, |tensor| match tensor { + burn::backend::BackendTensor::Float(t) => { + burn::backend::DispatchTensorKind::Autodiff( + Box::new(burn::backend::DispatchTensorKind::#b_ident( + burn::backend::BackendTensor::Autodiff(t) + )) + ) + } + }, checkpointing) } + } + }; + + quote! { + #cfg_attr + #pattern => { + #(#unwraps)* + type _ADBackend = Autodiff<#b_ident>; + let _out = <_ADBackend as #trait_name>::#fn_name(#(#call_args),*)#maybe_await; + #wrap_out + } + } + }); + + quote! { + #( #inner_arms )* + } +} + +fn gen_tensor_wrap( + kind: &TensorKind, + val: TokenStream2, + b_ident: &Ident, + is_ad: bool, +) -> TokenStream2 { + let variant = kind.variant(); + if is_ad && *kind == TensorKind::Float { + quote! { + burn::backend::DispatchTensorKind::Autodiff( + Box::new(burn::backend::DispatchTensorKind::#b_ident( + burn::backend::BackendTensor::Autodiff(#val) + )) + ) + } + } else { + quote! { + burn::backend::DispatchTensorKind::#b_ident( + burn::backend::BackendTensor::#variant(#val) + ) + } + } +} + +/// Derive macro to implement `ExtensionType` for custom structures returned by backend extensions. +/// +/// When a custom backend extension operation needs to return multiple tensors or a mix of tensors +/// and metadata (instead of a single tensor primitive or container of primitives), this macro automates +/// the process of wrapping the tensor primitives so they can cross the boundary into the `Dispatch` backend. +/// +/// # Requirements +/// +/// - The struct must have named fields. +/// - The struct must be generic over a `Backend` type parameter (`B`). +/// +/// # Field Attributes +/// +/// By default, the macro inspects the type of each field: +/// - **Tensor Primitives**: Automatically mapped. +/// - **Other types**: Passed through unmodified. +/// +/// To nest another custom struct that also implements `ExtensionType`, you must annotate it with +/// `#[extension_type]` to tell the macro to traverse it recursively. +/// +/// # Example +/// +/// ```rust,ignore +/// #[derive(ExtensionType)] +/// pub struct OperationOutput { +/// pub bool: BoolTensor, +/// pub int: IntTensor, +/// pub float: FloaTensor, +/// pub count: usize, // Non-tensor field passes through automatically +/// } +/// ``` +#[proc_macro_derive(ExtensionType, attributes(extension_type))] +pub fn derive_extension_output(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemStruct); + let name = &input.ident; + + // Extract generics for the trait implementation block + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let fields_wrap = match &input.fields { + syn::Fields::Named(fields) => { + let field_mappings = fields.named.iter().map(|f| { + let f_name = &f.ident; + let is_ext = f.attrs.iter().any(|attr| attr.path().is_ident("extension_type")); + + if is_ext { + // It's a nested extension type + quote! { + #f_name: self.#f_name.map_type( + &map_kind, + checkpointing + ), + } + } + else if let Some(tensor_kind) = TensorKind::from_type(&f.ty) { + // Tensor primitive + let variant_ident = tensor_kind.variant(); + quote! { + #f_name: burn::backend::DispatchTensor { + kind: map_kind(burn::backend::BackendTensor::#variant_ident(self.#f_name)), + checkpointing, + }, + } + } else { + // Passthrough + quote! { #f_name: self.#f_name, } + } + }); + + quote! { #name { #( #field_mappings )* } } + } + _ => panic!("ExtensionType derive only supports structs with named fields"), + }; + + TokenStream::from(quote! { + impl #impl_generics burn::backend::ExtensionType for #name #ty_generics #where_clause { + type Target = #name; + + fn map_type( + self, + map_kind: F, + checkpointing: Option, + ) -> Self::Target + where + F: Fn(burn::backend::BackendTensor) -> burn::backend::DispatchTensorKind, + { + #fields_wrap + } + } + }) +} diff --git a/crates/burn-backend-tests/.cargo/config.toml b/crates/burn-backend-tests/.cargo/config.toml new file mode 100644 index 0000000..6379268 --- /dev/null +++ b/crates/burn-backend-tests/.cargo/config.toml @@ -0,0 +1,31 @@ +[alias] +test-cpu = "test --release --no-default-features --features cpu,std,fusion" +test-cuda = "test --release --no-default-features --features cuda,std,fusion" +test-flex = "test --release --no-default-features --features flex,std" +test-ndarray = "test --release --no-default-features --features ndarray,std" +test-rocm = "test --release --no-default-features --features rocm,std,fusion" +test-remote = "test --release --no-default-features --features remote,std,fusion" +test-tch = "test --release --no-default-features --features tch,std" +test-wgpu = "test --release --no-default-features --features wgpu,std,fusion" +test-vulkan = "test --release --no-default-features --features vulkan,std,fusion" +test-metal = "test --release --no-default-features --features metal,std,fusion" + +# Fusion disabled tests +test-cpu-nofuse = "test --release --no-default-features --features cpu,std" +test-cuda-nofuse = "test --release --no-default-features --features cuda,std" +test-rocm-nofuse = "test --release --no-default-features --features rocm,std" +test-wgpu-nofuse = "test --release --no-default-features --features wgpu,std" +test-vulkan-nofuse = "test --release --no-default-features --features vulkan,std" +test-metal-nofuse = "test --release --no-default-features --features metal,std" +test-remote-nofuse = "test --release --no-default-features --features remote,std" + +# Benches +bench-cpu = "bench --no-default-features --features cpu,std,fusion" +bench-cuda = "bench --no-default-features --features cuda,std,fusion" +bench-flex = "bench --no-default-features --features flex,std" +bench-ndarray = "bench --no-default-features --features ndarray,std" +bench-rocm = "bench --no-default-features --features rocm,std,fusion" +bench-tch = "bench --no-default-features --features tch,std" +bench-wgpu = "bench --no-default-features --features wgpu,std,fusion" +bench-vulkan = "bench --no-default-features --features vulkan,std,fusion" +bench-metal = "bench --no-default-features --features metal,std,fusion" diff --git a/crates/burn-backend-tests/Cargo.toml b/crates/burn-backend-tests/Cargo.toml new file mode 100644 index 0000000..257a577 --- /dev/null +++ b/crates/burn-backend-tests/Cargo.toml @@ -0,0 +1,195 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Tensor tests for Burn backends" +documentation = "https://docs.rs/burn-backend-tests" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-backend-tests" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-backend-tests" +version.workspace = true + +[lints] +workspace = true + +[lib] +proc-macro = true + +[features] +default = [ + "burn-tensor/default", + # Default + # "ndarray", + "std", +] +std = ["burn-tensor/std"] + +# Backends +cuda = ["burn-tensor/cuda", "quantization", "cube"] +rocm = ["burn-tensor/rocm", "quantization", "cube"] +flex = ["burn-tensor/flex", "quantization"] +ndarray = ["burn-tensor/ndarray", "quantization"] +tch = ["burn-tensor/tch"] +vulkan = ["wgpu", "burn-tensor/vulkan"] +webgpu = ["wgpu", "burn-tensor/webgpu"] +metal = ["wgpu", "burn-tensor/metal"] +wgpu = ["burn-tensor/wgpu", "quantization", "cube"] +cpu = ["burn-tensor/cpu", "cube"] +# Runs the test suite against a remote `burn-remote` server. +# At runtime, set `BURN_DEVICE=remote` (and optionally `BURN_REMOTE_ADDRESS=ws://host:port`). +remote = ["burn-tensor/remote"] + +autotune = ["burn-tensor/autotune"] +autotune-checks = ["burn-tensor/autotune-checks"] +memory-checks = ["burn-fusion?/memory-checks"] +fusion = [ + "burn-tensor/fusion", + "burn-fusion/test-util", + "burn-cubecl-fusion?/test-util", +] + +# CubeCL backends +cube = [ + "cubek", + "autotune", + "burn-tensor/ndarray", + "burn-ndarray", + # Not used directly, but included to avoid recompiling with and without fusion + "burn-cubecl", + # Direct dependency so fusion tests can assert launch-level decisions (in-place aliasing). + "burn-cubecl-fusion", +] + +# Test configs +quantization = [] +# Enables the collective (all-reduce) tests, which require a CUDA backend built with NCCL. +# Kept separate from `cuda` so the rest of the suite can run on CUDA without NCCL. +distributed = ["cuda"] + +[dependencies] +burn-tensor = { workspace = true, features = ["autodiff"] } +burn-backend = { workspace = true } + +# For random tests +cubek = { workspace = true, features = ["random"], optional = true } + +# Explicitly enable test-only checks +burn-autodiff = { workspace = true, features = ["export_tests"] } +burn-ndarray = { workspace = true, optional = true, features = [ + "export_tests", +] } + +# Manually enable so running the tests again with fusion feature doesn't have to compile other crates +burn-cubecl = { workspace = true, optional = true, features = [ + "default", + "fusion", +] } +burn-cubecl-fusion = { workspace = true, optional = true } + +# Enabled alongside the `fusion` feature so spy-based tests can import the spy API. +burn-fusion = { workspace = true, optional = true } + +num-traits = { workspace = true } +serial_test = { workspace = true } +rand.workspace = true + +# might-panic macro +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + + +[dev-dependencies] +# Used to initialize device settings (test dtypes) +ctor = "0.10.1" +divan = "0.1" + +[[bench]] +name = "attention" +harness = false + +[[bench]] +name = "binary_ops" +harness = false + +[[bench]] +name = "cat_max_min_ops" +harness = false + +[[bench]] +name = "comparison_ops" +harness = false + +[[bench]] +name = "conv_ops" +harness = false + +[[bench]] +name = "conv_transpose_ops" +harness = false + +[[bench]] +name = "cross_unfold_ops" +harness = false + +[[bench]] +name = "cumulative_ops" +harness = false + +[[bench]] +name = "default_ops" +harness = false + +[[bench]] +name = "deform_conv_ops" +harness = false + +[[bench]] +name = "fft_ops" +harness = false + +[[bench]] +name = "gather_scatter_ops" +harness = false + +[[bench]] +name = "int_ops" +harness = false + +[[bench]] +name = "interpolate_ops" +harness = false + +[[bench]] +name = "matmul" +harness = false + +[[bench]] +name = "norm_ops" +harness = false + +[[bench]] +name = "pool_ops" +harness = false + +[[bench]] +name = "quantization_ops" +harness = false + +[[bench]] +name = "reduce_ops" +harness = false + +[[bench]] +name = "slice_ops" +harness = false + +[[bench]] +name = "softmax_ops" +harness = false + +[[bench]] +name = "unary_ops" +harness = false diff --git a/crates/burn-backend-tests/README.md b/crates/burn-backend-tests/README.md new file mode 100644 index 0000000..a9167cb --- /dev/null +++ b/crates/burn-backend-tests/README.md @@ -0,0 +1,187 @@ +# Burn Backend Tests + +This crate provides a comprehensive suite of tests for Burn backends, covering: + +- Tensor operations: [tests/tensor/](./tests/tensor/) +- Autodiff: [tests/autodiff/](./tests/autodiff/) +- (Optional) CubeCL kernels correctness: [tests/cubecl/](./tests/cubecl/) +- (Optional) Fusion correctness: [tests/fusion/](./tests/fusion/) +- Benchmarks: [benches/](./benches/) + +## Running Tests + +The test backend device is selected via feature flags. Use the provided shorthand commands for +convenience: + +```sh +# Cpu +cargo test-cpu +# Cuda +cargo test-cuda +# Rocm +cargo test-rocm +# Wgpu / WebGpu +cargo test-wgpu +# Vulkan +cargo test-vulkan +# Metal +cargo test-metal + +# NdArray +cargo test-ndarray +# LibTorch +cargo test-tch +``` + +By default, `cargo test` fail-fast across integration test binaries. When one integration test +binary fails, Cargo does not run the remaining test binaries. If you want to run all test binaries +regardless of failures, pass `--no-fail-fast`, for example: + +```sh +cargo test-cuda --no-fail-fast +``` + +> [!NOTE] +> CubeCL-based backends are tested with `fusion` by default. If you want to run the tests without +> fusion, just append `-nofuse` to the cargo command. For example: +> +> ```sh +> cargo test-cuda-nofuse +> ``` + +## Structure + +- `tests/tensor.rs`: Tensor tests +- `tests/tensor_f16.rs`: F16 tensor tests +- `tests/autodiff.rs`: Autodiff tests +- `tests/autodiff_f16.rs`: F16 autodiff tests +- `tests/cubecl.rs`: CubeCL kernel tests + +### Common Modules + +- `common/backend.rs`: Backend type definitions +- `common/tensor.rs`: Reusable tensor test suite, split across float, int and bool tensor kinds +- `common/autodiff.rs`: Reusable autodiff test suite, with and without checkpointing + +### Test Reusability + +This crate uses a pattern of parameterized test modules to run the same tests with different +configurations: + +1. **Element types**: Each test scope declares `FloatElem` and `IntElem`, which determine the + precision for the current test run +1. **`#[path = "..."]` references shared modules**: Points to test files outside the normal module + hierarchy, e.g. `"common/tensor.rs"` +1. **`include!()` imports test code**: Test modules are included multiple times with different type + configurations +1. **`use super::*;`** propagates types down the module tree: Each level re-exports parent types so + deeply nested tests have access to the configured types + +For example, all tensor tests are included in `tensor.rs` to execute with the default element types +(`FloatElem = f32`, `IntElem = i32`): + +```rust +#[path = "common/tensor.rs"] +mod tensor; +``` + +For f16 tests, only the float tensor tests are included to validate behavior with `FloatElem = f16`: + +```rust +#[path = "tensor/float/mod.rs"] +mod f16; +``` + +> [!WARNING] +> Tests for different data types (e.g., f32 vs f16) must be isolated, as each device's settings are +> global and can only be initialized once per process. + +## Running Benchmarks + +Benchmarks use [divan](https://docs.rs/divan) and run against the feature-selected backend via +`TestBackend = burn_dispatch::Dispatch`, the same way tests do. Shorthand aliases mirror the test +ones: + +```sh +# Cpu +cargo bench-cpu +# Cuda +cargo bench-cuda +# Rocm +cargo bench-rocm +# Wgpu / WebGpu +cargo bench-wgpu +# Vulkan +cargo bench-vulkan +# Metal +cargo bench-metal + +# Flex +cargo bench-flex +# NdArray +cargo bench-ndarray +# LibTorch +cargo bench-tch +``` + +Run a single bench file by passing `--bench `: + +```sh +cargo bench-flex --bench matmul +cargo bench-cuda --bench attention +``` + +Filter benches within a file by passing an argument after `--`: + +```sh +cargo bench-flex --bench matmul -- square +``` + +### Comparing backends + +Each run targets one backend. To compare (e.g. Flex vs NdArray), run twice and diff the output: + +```sh +cargo bench-flex --bench matmul > flex.txt +cargo bench-ndarray --bench matmul > ndarray.txt +diff flex.txt ndarray.txt +``` + +### GPU sync + +GPU backends (cuda, wgpu, rocm, metal, vulkan) dispatch ops asynchronously. Each bench wraps its op +with `bencher.bench_synced(...)` (defined in [benches/common/mod.rs](./benches/common/mod.rs)), +which inserts a `Backend::sync` call before returning - so the timed region covers actual execution +rather than dispatch latency. On CPU backends `sync` is a no-op via the `Backend::sync` default, so +this costs nothing there. + +When adding a new bench, use `bench_synced` rather than divan's raw `bench`: + +```rust +use common::BencherExt; + +#[divan::bench] +fn my_op(bencher: Bencher) { + let x = make_tensor(SIZE); + bencher.bench_synced(|| some_op(x.clone())); +} +``` + +## Adding New Tests + +Add test modules under `tests/tensor/`, `tests/autodiff/`, `tests/cubecl` and `tests/fusion` +respectively. They will automatically run for all required configurations. + +For tensor tests, make sure to add the test to each relevant tensor kind: + +- `tensor/bool`: boolean tensor tests +- `tensor/float`: float tensor tests +- `tensor/int`: integer tensor tests + +**Guidelines:** + +Import types with `use super::*;` at the top of each module to use the `FloatElem` and `IntElem` types. + +For autodiff tests, always use `AutodiffDevice::new()` to create the device. Autodiff is enabled on +the device itself when using the `Dispatch` test backend, ensuring the device supports automatic +differentiation without modifying the backend type. diff --git a/crates/burn-backend-tests/benches/attention.rs b/crates/burn-backend-tests/benches/attention.rs new file mode 100644 index 0000000..f8fc79d --- /dev/null +++ b/crates/burn-backend-tests/benches/attention.rs @@ -0,0 +1,271 @@ +//! Scaled dot-product attention benchmarks. +//! +//! Run with: +//! ```bash +//! cargo bench --bench attention +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::module::attention; +use burn_tensor::ops::AttentionModuleOptions; +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Attention Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +/// Create Q, K, V tensors for attention benchmarking. +fn make_qkv( + batch: usize, + heads: usize, + seq_q: usize, + seq_k: usize, + head_dim: usize, +) -> (Tensor<4>, Tensor<4>, Tensor<4>) { + let dev = Default::default(); + let make = |rows: usize, cols: usize| -> Tensor<4> { + let n = batch * heads * rows * cols; + let data: Vec = (0..n).map(|i| ((i % 1000) as f32 / 1000.0) - 0.5).collect(); + Tensor::from_data(TensorData::new(data, [batch, heads, rows, cols]), &dev) + }; + ( + make(seq_q, head_dim), + make(seq_k, head_dim), + make(seq_k, head_dim), + ) +} + +/// Create an additive bias tensor [batch, heads, seq_q, seq_k]. +fn make_bias(batch: usize, heads: usize, seq_q: usize, seq_k: usize) -> Tensor<4> { + let n = batch * heads * seq_q * seq_k; + let data: Vec = (0..n).map(|i| ((i % 500) as f32 / 500.0) - 0.5).collect(); + Tensor::from_data( + TensorData::new(data, [batch, heads, seq_q, seq_k]), + &Default::default(), + ) +} + +macro_rules! bench_attention { + ($mod_name:ident, $name:literal) => { + #[divan::bench_group(name = $name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "self_attention")] + mod self_attention { + use super::*; + + #[divan::bench] + fn b1_h8_s64_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 8, 64, 64, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h12_s128_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 128, 128, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h12_s256_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 256, 256, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h12_s512_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 512, 512, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h32_s256_d128(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 32, 256, 256, 128); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b4_h12_s128_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(4, 12, 128, 128, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + } + + #[divan::bench_group(name = "causal")] + mod causal { + use super::*; + + fn causal_opts() -> AttentionModuleOptions { + AttentionModuleOptions { + is_causal: true, + ..Default::default() + } + } + + #[divan::bench] + fn b1_h12_s128_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 128, 128, 64); + bencher.bench_synced(|| { + attention(q.clone(), k.clone(), v.clone(), None, None, causal_opts()) + }); + } + + #[divan::bench] + fn b1_h12_s256_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 256, 256, 64); + bencher.bench_synced(|| { + attention(q.clone(), k.clone(), v.clone(), None, None, causal_opts()) + }); + } + + #[divan::bench] + fn b1_h12_s512_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 512, 512, 64); + bencher.bench_synced(|| { + attention(q.clone(), k.clone(), v.clone(), None, None, causal_opts()) + }); + } + } + + #[divan::bench_group(name = "with_bias")] + mod with_bias { + use super::*; + + #[divan::bench] + fn b1_h12_s128_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 128, 128, 64); + let bias = make_bias(1, 12, 128, 128); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + Some(bias.clone()), + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h12_s256_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 256, 256, 64); + let bias = make_bias(1, 12, 256, 256); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + Some(bias.clone()), + Default::default(), + ) + }); + } + } + + #[divan::bench_group(name = "cross_attention")] + mod cross_attention { + use super::*; + + #[divan::bench] + fn b1_h12_sq128_sk512_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 128, 512, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + + #[divan::bench] + fn b1_h12_sq32_sk1024_d64(bencher: Bencher) { + let (q, k, v) = make_qkv(1, 12, 32, 1024, 64); + bencher.bench_synced(|| { + attention( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ) + }); + } + } + } + }; +} + +bench_attention!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/binary_ops.rs b/crates/burn-backend-tests/benches/binary_ops.rs new file mode 100644 index 0000000..cc09fe2 --- /dev/null +++ b/crates/burn-backend-tests/benches/binary_ops.rs @@ -0,0 +1,390 @@ +//! Benchmarks for binary operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench binary_ops +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Int, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +// Tensor sizes for benchmarking +const SMALL: usize = 64 * 64; // 4K elements +const MEDIUM: usize = 256 * 256; // 64K elements +const LARGE: usize = 1024 * 1024; // 1M elements + +fn make_tensor(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_int_tensor(size: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..size).map(|i| (i % 1000) as i32).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) + }) +} + +fn make_int_tensor_2d(rows: usize, cols: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..rows * cols).map(|i| (i % 1000) as i32).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + }) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // Add operations + #[divan::bench_group(name = "add")] + mod add { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + let b = make_tensor(SMALL); + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + let b = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone() + b.clone()); + } + } + + // Mul operations + #[divan::bench_group(name = "mul")] + mod mul { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + let b = make_tensor(SMALL); + bencher.bench_synced(|| a.clone() * b.clone()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + let b = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone() * b.clone()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone() * b.clone()); + } + } + + // Div operations + #[divan::bench_group(name = "div")] + mod div { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone() / b.clone()); + } + } + + // Transposed (non-contiguous) + #[divan::bench_group(name = "add_transposed")] + mod add_transposed { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let a = make_tensor_2d(256, 256).transpose(); + let b = make_tensor_2d(256, 256); + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1024).transpose(); + let b = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| a.clone() + b.clone()); + } + } + + // Scalar operations + #[divan::bench_group(name = "scalar")] + mod scalar { + use super::*; + + #[divan::bench] + fn add_large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone() + 1.5); + } + + #[divan::bench] + fn mul_large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone() * 2.0); + } + } + + // Powf operations + #[divan::bench_group(name = "powf")] + mod powf { + use super::*; + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + let b = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().powf(b.clone())); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().powf(b.clone())); + } + + #[divan::bench] + fn scalar_large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().powf_scalar(2.5)); + } + } + + // Atan2 operations + #[divan::bench_group(name = "atan2")] + mod atan2 { + use super::*; + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + let b = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().atan2(b.clone())); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().atan2(b.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); + +// Int benchmarks +macro_rules! bench_int_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "int_add")] + mod int_add { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let Some(a) = make_int_tensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let Some(a) = make_int_tensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(a) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + } + + #[divan::bench_group(name = "int_mul")] + mod int_mul { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let Some(a) = make_int_tensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() * b.clone()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let Some(a) = make_int_tensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() * b.clone()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(a) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() * b.clone()); + } + } + + #[divan::bench_group(name = "int_div")] + mod int_div { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(a) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + // Avoid division by zero + let data: Vec = (0..LARGE).map(|i| (i % 999) as i32 + 1).collect(); + let b: Tensor<1, Int> = + Tensor::from_data(TensorData::new(data, [LARGE]), &Default::default()); + bencher.bench_synced(|| a.clone() / b.clone()); + } + } + + #[divan::bench_group(name = "int_add_transposed")] + mod int_add_transposed { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let Some(a) = make_int_tensor_2d(256, 256) else { + bencher.bench(|| ()); + return; + }; + let a = a.transpose(); + let Some(b) = make_int_tensor_2d(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let Some(a) = make_int_tensor_2d(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + let a = a.transpose(); + let Some(b) = make_int_tensor_2d(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + } + + #[divan::bench_group(name = "int_scalar")] + mod int_scalar { + use super::*; + + #[divan::bench] + fn add_large(bencher: Bencher) { + let Some(a) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + 100); + } + + #[divan::bench] + fn mul_large(bencher: Bencher) { + let Some(a) = make_int_tensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() * 3); + } + } + } + }; +} + +bench_int_backend!(backend_int, "backend_int"); diff --git a/crates/burn-backend-tests/benches/cat_max_min_ops.rs b/crates/burn-backend-tests/benches/cat_max_min_ops.rs new file mode 100644 index 0000000..ff3f159 --- /dev/null +++ b/crates/burn-backend-tests/benches/cat_max_min_ops.rs @@ -0,0 +1,321 @@ +//! Benchmarks for cat, max, min, int power, bool select, and grid_sample_2d. +//! +//! Run with: +//! ```bash +//! cargo bench --bench cat_max_min_ops --features simd,rayon +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Bool, Int, Tensor, TensorData, ops::GridSampleOptions}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Benchmarks for cat, max/min, int power, bool select, grid_sample_2d"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +// === Helpers === + +fn make_f32_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_f32_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_int_2d(rows: usize, cols: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..rows * cols).map(|i| (i % 10) as i32 + 1).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + }) +} + +fn make_int_exp_2d(rows: usize, cols: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..rows * cols).map(|i| (i % 4) as i32 + 1).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + }) +} + +fn make_bool_2d(rows: usize, cols: usize) -> Tensor<2, Bool> { + let data: Vec = (0..rows * cols).map(|i| i % 3 != 0).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_indices_1d(size: usize, max_idx: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..size).map(|i| (i % max_idx) as i32).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) + }) +} + +fn make_grid_4d(batch: usize, h_out: usize, w_out: usize) -> Tensor<4> { + let size = batch * h_out * w_out * 2; + // Grid values in [-1, 1] + let data: Vec = (0..size) + .map(|i| (i as f32 / size as f32) * 2.0 - 1.0) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, h_out, w_out, 2]), + &Default::default(), + ) +} + +fn make_image_4d(batch: usize, channels: usize, h: usize, w: usize) -> Tensor<4> { + let size = batch * channels * h * w; + let data: Vec = (0..size).map(|i| (i % 256) as f32 / 255.0).collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, h, w]), + &Default::default(), + ) +} + +// ============================================================================= +// Cat +// ============================================================================= + +macro_rules! bench_cat { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "cat")] + mod cat { + use super::*; + + // Cat along dim 0 (fast path: contiguous memcpy) + #[divan::bench] + fn dim0_4x_256x256(bencher: Bencher) { + let t = make_f32_2d(256, 256); + let tensors = vec![t.clone(), t.clone(), t.clone(), t.clone()]; + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 0)); + } + + #[divan::bench] + fn dim0_4x_1024x256(bencher: Bencher) { + let t = make_f32_2d(1024, 256); + let tensors = vec![t.clone(), t.clone(), t.clone(), t.clone()]; + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 0)); + } + + // Cat along dim 1 (general path) + #[divan::bench] + fn dim1_4x_256x64(bencher: Bencher) { + let t = make_f32_2d(256, 64); + let tensors = vec![t.clone(), t.clone(), t.clone(), t.clone()]; + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 1)); + } + + #[divan::bench] + fn dim1_4x_1024x64(bencher: Bencher) { + let t = make_f32_2d(1024, 64); + let tensors = vec![t.clone(), t.clone(), t.clone(), t.clone()]; + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 1)); + } + + // Many small tensors + #[divan::bench] + fn dim0_16x_64x64(bencher: Bencher) { + let t = make_f32_2d(64, 64); + let tensors: Vec<_> = (0..16).map(|_| t.clone()).collect(); + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 0)); + } + + // 1D cat + #[divan::bench] + fn dim0_4x_16k_1d(bencher: Bencher) { + let t = make_f32_1d(16 * 1024); + let tensors = vec![t.clone(), t.clone(), t.clone(), t.clone()]; + bencher.bench_synced(|| Tensor::cat(tensors.clone(), 0)); + } + } + + #[divan::bench_group(name = "max")] + mod max { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_f32_1d(1024); + bencher.bench_synced(|| t.clone().max()); + } + + #[divan::bench] + fn s_64k(bencher: Bencher) { + let t = make_f32_1d(64 * 1024); + bencher.bench_synced(|| t.clone().max()); + } + + #[divan::bench] + fn s_1m(bencher: Bencher) { + let t = make_f32_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().max()); + } + } + + #[divan::bench_group(name = "min")] + mod min { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_f32_1d(1024); + bencher.bench_synced(|| t.clone().min()); + } + + #[divan::bench] + fn s_64k(bencher: Bencher) { + let t = make_f32_1d(64 * 1024); + bencher.bench_synced(|| t.clone().min()); + } + + #[divan::bench] + fn s_1m(bencher: Bencher) { + let t = make_f32_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().min()); + } + } + + #[divan::bench_group(name = "int_max")] + mod int_max { + use super::*; + + #[divan::bench] + fn s_256x256(bencher: Bencher) { + let Some(t) = make_int_2d(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().max()); + } + + #[divan::bench] + fn s_1024x1024(bencher: Bencher) { + let Some(t) = make_int_2d(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().max()); + } + } + + #[divan::bench_group(name = "int_powi")] + mod int_powi { + use super::*; + + #[divan::bench] + fn s_256x256(bencher: Bencher) { + let Some(base) = make_int_2d(256, 256) else { + bencher.bench(|| ()); + return; + }; + let Some(exp) = make_int_exp_2d(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| base.clone().powi(exp.clone())); + } + + #[divan::bench] + fn s_1024x256(bencher: Bencher) { + let Some(base) = make_int_2d(1024, 256) else { + bencher.bench(|| ()); + return; + }; + let Some(exp) = make_int_exp_2d(1024, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| base.clone().powi(exp.clone())); + } + } + + #[divan::bench_group(name = "bool_select")] + mod bool_select { + use super::*; + + #[divan::bench] + fn s_256x256_128idx(bencher: Bencher) { + let t = make_bool_2d(256, 256); + let Some(idx) = make_indices_1d(128, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().select(0, idx.clone())); + } + + #[divan::bench] + fn s_1024x256_512idx(bencher: Bencher) { + let t = make_bool_2d(1024, 256); + let Some(idx) = make_indices_1d(512, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().select(0, idx.clone())); + } + } + + #[divan::bench_group(name = "grid_sample_2d")] + mod grid_sample { + use super::*; + + #[divan::bench] + fn s_b1_c3_32x32(bencher: Bencher) { + let img = make_image_4d(1, 3, 32, 32); + let grid = make_grid_4d(1, 32, 32); + bencher.bench_synced(|| { + img.clone() + .grid_sample_2d(grid.clone(), GridSampleOptions::default()) + }); + } + + #[divan::bench] + fn s_b1_c3_64x64(bencher: Bencher) { + let img = make_image_4d(1, 3, 64, 64); + let grid = make_grid_4d(1, 64, 64); + bencher.bench_synced(|| { + img.clone() + .grid_sample_2d(grid.clone(), GridSampleOptions::default()) + }); + } + + #[divan::bench] + fn s_b4_c3_32x32(bencher: Bencher) { + let img = make_image_4d(4, 3, 32, 32); + let grid = make_grid_4d(4, 32, 32); + bencher.bench_synced(|| { + img.clone() + .grid_sample_2d(grid.clone(), GridSampleOptions::default()) + }); + } + + #[divan::bench] + fn s_b1_c16_64x64(bencher: Bencher) { + let img = make_image_4d(1, 16, 64, 64); + let grid = make_grid_4d(1, 64, 64); + bencher.bench_synced(|| { + img.clone() + .grid_sample_2d(grid.clone(), GridSampleOptions::default()) + }); + } + } + } + }; +} + +bench_cat!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/common/mod.rs b/crates/burn-backend-tests/benches/common/mod.rs new file mode 100644 index 0000000..54b364a --- /dev/null +++ b/crates/burn-backend-tests/benches/common/mod.rs @@ -0,0 +1,162 @@ +//! Shared setup for benches. +//! +//! Mirrors `tests/common/backend.rs`: `Device::default()`, with a `#[ctor]` +//! that pins the default float/int dtypes to `f32`/`i32` so backends that advertise a different +//! default (e.g. bf16) don't silently convert bench inputs. + +use std::cell::Cell; +use std::panic::{self, AssertUnwindSafe, Location}; +use std::sync::Mutex; + +use burn_tensor::Element; +use ctor::ctor; + +pub type FloatElem = f32; +pub type IntElem = i32; + +#[ctor] +fn init_device_settings() { + let mut device = burn_tensor::Device::default(); + device + .configure( + burn_tensor::DeviceConfig::default() + .float_dtype(::dtype()) + .int_dtype(::dtype()), + ) + .unwrap(); +} + +/// Block until all outstanding ops on the default device complete. +/// +/// GPU backends (cuda, wgpu, rocm, metal, vulkan) dispatch ops asynchronously; without a sync +/// barrier inside the timed region a bench would measure dispatch latency, not execution time. +/// On CPU backends (flex, ndarray) this is a no-op via the `Backend::sync` default. +#[inline] +pub fn sync() { + burn_tensor::Device::default().sync().unwrap(); +} + +// --- Panic-tolerant bench execution ----------------------------------------- +// +// Some ops are not implemented on every backend (quantization on tch, deformable conv on metal, +// etc.). Without handling, the first panic in a bench binary aborts the whole run. We pre-flight +// each bench under `catch_unwind`; on panic we record the failure, skip the bench loop, and let +// subsequent benches in the same binary continue. `report_failures` at the end of `main()` +// prints a summary. + +thread_local! { + static SUPPRESS_PANIC_OUTPUT: Cell = const { Cell::new(false) }; +} + +struct BenchFailure { + location: String, + message: String, +} + +static FAILURES: Mutex> = Mutex::new(Vec::new()); + +#[ctor] +fn install_panic_hook() { + // Chain onto the existing hook rather than replacing it so truly unexpected panics (outside + // `bench_synced`'s catch_unwind window) still print normally. + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if !SUPPRESS_PANIC_OUTPUT.with(|s| s.get()) { + default_hook(info); + } + })); +} + +fn panic_message(payload: Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "(non-string panic payload)".to_string() + } +} + +/// Run a setup closure tolerantly: if it panics (typically because the backend doesn't support +/// an op used during setup), returns `None` and records the failure location. Lets benches that +/// rely on op-heavy setup (e.g. `make_qtensor` calls `quantize_dynamic`, fft inverse benches call +/// `rfft` to produce the input) fall through to a no-op without taking down the whole binary. +#[allow(unused)] // it's used in benches +#[track_caller] +pub fn try_setup(f: impl FnOnce() -> T) -> Option { + let loc = Location::caller(); + SUPPRESS_PANIC_OUTPUT.with(|s| s.set(true)); + let r = panic::catch_unwind(AssertUnwindSafe(f)); + SUPPRESS_PANIC_OUTPUT.with(|s| s.set(false)); + match r { + Ok(v) => Some(v), + Err(payload) => { + FAILURES.lock().unwrap().push(BenchFailure { + location: format!("{}:{} (setup)", loc.file(), loc.line()), + message: panic_message(payload), + }); + None + } + } +} + +/// Print a summary of benches that panicked during this run, if any. Call from `main()` after +/// `divan::main()`. +pub fn report_failures() { + let failures = FAILURES.lock().unwrap(); + if failures.is_empty() { + return; + } + eprintln!(); + eprintln!("=== {} bench(es) skipped due to panic ===", failures.len()); + for f in failures.iter() { + eprintln!(" [{}] {}", f.location, f.message); + } +} + +/// Extension trait adding a synced, panic-tolerant variant of `Bencher::bench`. +/// +/// `bench_synced` runs the op then forces a device sync before returning, so the timed region +/// covers actual execution on async backends. If the op panics (typically "not implemented" on a +/// backend that doesn't support it), the failure is recorded and the bench is replaced with a +/// no-op so later benches in the same binary keep running. +pub trait BencherExt<'a, 'b> { + fn bench_synced(self, benched: F) + where + F: Fn() -> O + Sync; +} + +impl<'a, 'b> BencherExt<'a, 'b> for divan::Bencher<'a, 'b> { + #[track_caller] + fn bench_synced(self, benched: F) + where + F: Fn() -> O + Sync, + { + let loc = Location::caller(); + + SUPPRESS_PANIC_OUTPUT.with(|s| s.set(true)); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + let r = benched(); + sync(); + drop(r); + })); + SUPPRESS_PANIC_OUTPUT.with(|s| s.set(false)); + + match result { + Ok(()) => { + self.bench(move || { + let r = benched(); + sync(); + r + }); + } + Err(payload) => { + FAILURES.lock().unwrap().push(BenchFailure { + location: format!("{}:{}", loc.file(), loc.line()), + message: panic_message(payload), + }); + self.bench(|| ()); + } + } + } +} diff --git a/crates/burn-backend-tests/benches/comparison_ops.rs b/crates/burn-backend-tests/benches/comparison_ops.rs new file mode 100644 index 0000000..97adb9e --- /dev/null +++ b/crates/burn-backend-tests/benches/comparison_ops.rs @@ -0,0 +1,214 @@ +//! Benchmarks for comparison and broadcasting operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench comparison_ops +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Benchmarking comparison and broadcast operations"); + println!(); + divan::main(); + common::report_failures(); +} + +const SMALL: usize = 64 * 64; // 4K elements +const MEDIUM: usize = 256 * 256; // 64K elements +const LARGE: usize = 1024 * 1024; // 1M elements + +fn make_tensor(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // Greater comparison + #[divan::bench_group(name = "greater")] + mod greater { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + let b = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + let b = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + } + + // Greater scalar comparison + #[divan::bench_group(name = "greater_elem")] + mod greater_elem { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().greater_elem(0.5)); + } + } + + // Equal comparison + #[divan::bench_group(name = "equal")] + mod equal { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + let b = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().equal(b.clone())); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().equal(b.clone())); + } + } + + // Lower comparison + #[divan::bench_group(name = "lower")] + mod lower { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().lower(b.clone())); + } + } + + // Non-contiguous (transposed) comparison + #[divan::bench_group(name = "greater_transposed")] + mod greater_transposed { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let a = make_tensor_2d(256, 256).transpose(); + let b = make_tensor_2d(256, 256); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1024).transpose(); + let b = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + } + + // Broadcast comparison: [N, 1] > [1, M] -> [N, M] + #[divan::bench_group(name = "greater_broadcast")] + mod greater_broadcast { + use super::*; + + #[divan::bench] + fn broadcast_256x256(bencher: Bencher) { + let a = make_tensor_2d(256, 1); + let b = make_tensor_2d(1, 256); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + + #[divan::bench] + fn broadcast_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1); + let b = make_tensor_2d(1, 1024); + bencher.bench_synced(|| a.clone().greater(b.clone())); + } + } + + // Expand operation + #[divan::bench_group(name = "expand")] + mod expand { + use super::*; + + #[divan::bench] + fn expand_1_to_1m(bencher: Bencher) { + let a = make_tensor_2d(1, 1); + bencher.bench_synced(|| a.clone().expand([1000, 1000])); + } + + #[divan::bench] + fn expand_row_1024x1_to_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1); + bencher.bench_synced(|| a.clone().expand([1024, 1024])); + } + + #[divan::bench] + fn expand_col_1x1024_to_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1, 1024); + bencher.bench_synced(|| a.clone().expand([1024, 1024])); + } + } + + // Bool operations + #[divan::bench_group(name = "bool_not")] + mod bool_not { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + let mask = a.greater(b); + bencher.bench_synced(|| mask.clone().bool_not()); + } + } + + #[divan::bench_group(name = "bool_and")] + mod bool_and { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + let b = make_tensor(LARGE); + let mask1 = a.clone().greater(b.clone()); + let mask2 = a.lower(b); + bencher.bench_synced(|| mask1.clone().bool_and(mask2.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/conv_ops.rs b/crates/burn-backend-tests/benches/conv_ops.rs new file mode 100644 index 0000000..9600676 --- /dev/null +++ b/crates/burn-backend-tests/benches/conv_ops.rs @@ -0,0 +1,498 @@ +//! Benchmarks for convolution operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench conv_ops --features simd,gemm +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData, module, ops::ConvOptions}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Convolution Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_input_2d(batch: usize, channels: usize, height: usize, width: usize) -> Tensor<4> { + let data: Vec = (0..batch * channels * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, height, width]), + &Default::default(), + ) +} + +fn make_kernel_2d(out_ch: usize, in_ch: usize, kh: usize, kw: usize) -> Tensor<4> { + let data: Vec = (0..out_ch * in_ch * kh * kw) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [out_ch, in_ch, kh, kw]), + &Default::default(), + ) +} + +fn make_input_1d(batch: usize, channels: usize, length: usize) -> Tensor<3> { + let data: Vec = (0..batch * channels * length) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, length]), + &Default::default(), + ) +} + +fn make_kernel_1d(out_ch: usize, in_ch: usize, kw: usize) -> Tensor<3> { + let data: Vec = (0..out_ch * in_ch * kw) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [out_ch, in_ch, kw]), + &Default::default(), + ) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "conv2d_small")] + mod conv2d_small { + use super::*; + + #[divan::bench] + fn conv2d_1x3x32x32_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 3, 32, 32); + let w = make_kernel_2d(16, 3, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_1x16x32x32_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 16, 32, 32); + let w = make_kernel_2d(32, 16, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_1x32x16x16_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 32, 16, 16); + let w = make_kernel_2d(64, 32, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + #[divan::bench_group(name = "conv2d_medium")] + mod conv2d_medium { + use super::*; + + #[divan::bench] + fn conv2d_8x3x64x64_k3x3(bencher: Bencher) { + let x = make_input_2d(8, 3, 64, 64); + let w = make_kernel_2d(32, 3, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_8x32x64x64_k3x3(bencher: Bencher) { + let x = make_input_2d(8, 32, 64, 64); + let w = make_kernel_2d(64, 32, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_8x64x32x32_k3x3(bencher: Bencher) { + let x = make_input_2d(8, 64, 32, 32); + let w = make_kernel_2d(128, 64, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + #[divan::bench_group(name = "conv2d_large")] + mod conv2d_large { + use super::*; + + #[divan::bench] + fn conv2d_16x64x128x128_k3x3(bencher: Bencher) { + let x = make_input_2d(16, 64, 128, 128); + let w = make_kernel_2d(128, 64, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_16x128x64x64_k3x3(bencher: Bencher) { + let x = make_input_2d(16, 128, 64, 64); + let w = make_kernel_2d(256, 128, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + #[divan::bench_group(name = "conv2d_resnet")] + mod conv2d_resnet { + use super::*; + + #[divan::bench] + fn resnet_conv1_1x3x224x224_k7x7_s2(bencher: Bencher) { + let x = make_input_2d(1, 3, 224, 224); + let w = make_kernel_2d(64, 3, 7, 7); + let opts = ConvOptions::new([2, 2], [3, 3], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn resnet_layer1_1x64x56x56_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 64, 56, 56); + let w = make_kernel_2d(64, 64, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn resnet_layer2_1x128x28x28_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 128, 28, 28); + let w = make_kernel_2d(128, 128, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn resnet_layer3_1x256x14x14_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 256, 14, 14); + let w = make_kernel_2d(256, 256, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn resnet_layer4_1x512x7x7_k3x3(bencher: Bencher) { + let x = make_input_2d(1, 512, 7, 7); + let w = make_kernel_2d(512, 512, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + #[divan::bench_group(name = "conv1d")] + mod conv1d { + use super::*; + + #[divan::bench] + fn conv1d_1x16x256_k3(bencher: Bencher) { + let x = make_input_1d(1, 16, 256); + let w = make_kernel_1d(32, 16, 3); + let opts = ConvOptions::new([1], [1], [1], 1); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv1d_8x32x512_k5(bencher: Bencher) { + let x = make_input_1d(8, 32, 512); + let w = make_kernel_1d(64, 32, 5); + let opts = ConvOptions::new([1], [2], [1], 1); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv1d_16x64x1024_k7(bencher: Bencher) { + let x = make_input_1d(16, 64, 1024); + let w = make_kernel_1d(128, 64, 7); + let opts = ConvOptions::new([1], [3], [1], 1); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + } + + // Depthwise conv1d (groups == channels_in == channels_out). + // These flow through the same conv3d_depthwise_impl fast path as + // conv2d because conv1d expands to a trivial 3D shape (kd=kh=1). + #[divan::bench_group(name = "conv1d_depthwise")] + mod conv1d_depthwise { + use super::*; + + #[divan::bench] + fn depthwise_k3_8x32x512(bencher: Bencher) { + let x = make_input_1d(8, 32, 512); + let w = make_kernel_1d(32, 1, 3); + let opts = ConvOptions::new([1], [1], [1], 32); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_k7_8x64x1024(bencher: Bencher) { + let x = make_input_1d(8, 64, 1024); + let w = make_kernel_1d(64, 1, 7); + let opts = ConvOptions::new([1], [3], [1], 64); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_k15_4x128x2048(bencher: Bencher) { + // Larger receptive field, dilation 1. Tests the common + // ConvNeXt-1d / audio separable conv case. + let x = make_input_1d(4, 128, 2048); + let w = make_kernel_1d(128, 1, 15); + let opts = ConvOptions::new([1], [7], [1], 128); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_k3_stride2_8x64x1024(bencher: Bencher) { + let x = make_input_1d(8, 64, 1024); + let w = make_kernel_1d(64, 1, 3); + let opts = ConvOptions::new([2], [1], [1], 64); + bencher + .bench_synced(|| module::conv1d(x.clone(), w.clone(), None, opts.clone())); + } + } + + // Small-channel groups=1 convs (Sobel-style edge filters and + // first-stage image preprocessors). These were reported as a + // 2.6-3.3x slowdown vs burn-ndarray before the small-channel + // fast path landed. + #[divan::bench_group(name = "conv2d_small_channel")] + mod conv2d_small_channel { + use super::*; + + #[divan::bench] + fn sobel_1x3x488x448_k3x3(bencher: Bencher) { + // 3-channel image, same-padding 3x3, 3 output channels. + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(3, 3, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn sobel_1x3x488x448_k1x3(bencher: Bencher) { + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(3, 3, 1, 3); + let opts = ConvOptions::new([1, 1], [0, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn sobel_1x3x488x448_k3x1(bencher: Bencher) { + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(3, 3, 3, 1); + let opts = ConvOptions::new([1, 1], [1, 0], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn sobel_1x3x488x448_k1x5(bencher: Bencher) { + // 1x5 Sobel pass. Separable filter, runs on RGB. + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(3, 3, 1, 5); + let opts = ConvOptions::new([1, 1], [0, 2], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn sobel_1x3x488x448_k5x1(bencher: Bencher) { + // 5x1 Sobel pass. Separable filter, runs on RGB. + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(3, 3, 5, 1); + let opts = ConvOptions::new([1, 1], [2, 0], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn downsample_1x4x128x128_k4x4_s2_to12(bencher: Bencher) { + // 4 in, 12 out, 4x4 kernel, stride 2. Small downsample stem. + let x = make_input_2d(1, 4, 128, 128); + let w = make_kernel_2d(12, 4, 4, 4); + let opts = ConvOptions::new([2, 2], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn preproc_1x3x488x448_k5x5_to8(bencher: Bencher) { + // First-stage image preprocessor: 3 in, 8 out, 5x5. + let x = make_input_2d(1, 3, 488, 448); + let w = make_kernel_2d(8, 3, 5, 5); + let opts = ConvOptions::new([1, 1], [2, 2], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn mask_1x1x256x256_k3x3_to8(bencher: Bencher) { + // Single-channel mask input, early feature extractor. + let x = make_input_2d(1, 1, 256, 256); + let w = make_kernel_2d(8, 1, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn first_layer_4x3x224x224_k7x7_s2(bencher: Bencher) { + // Classic ImageNet first layer: 3 in, 7x7, stride 2. + // Output channels = 64 is large so gemm could compete; + // this bench shows whether our path is at worst on par. + let x = make_input_2d(4, 3, 224, 224); + let w = make_kernel_2d(64, 3, 7, 7); + let opts = ConvOptions::new([2, 2], [3, 3], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + #[divan::bench_group(name = "conv2d_kernel_sizes")] + mod conv2d_kernel_sizes { + use super::*; + + #[divan::bench] + fn conv2d_k1x1(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + let w = make_kernel_2d(128, 64, 1, 1); + let opts = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_k3x3(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + let w = make_kernel_2d(128, 64, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_k5x5(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + let w = make_kernel_2d(128, 64, 5, 5); + let opts = ConvOptions::new([1, 1], [2, 2], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn conv2d_k7x7(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + let w = make_kernel_2d(128, 64, 7, 7); + let opts = ConvOptions::new([1, 1], [3, 3], [1, 1], 1); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + + // Depthwise (groups == channels_in == channels_out). Shapes cover + // MobileNet/ConvNeXt style depthwise blocks and a ConvNeXt-7x7 + // block reported in a user regression on burn-ndarray parity. + #[divan::bench_group(name = "conv2d_depthwise")] + mod conv2d_depthwise { + use super::*; + + #[divan::bench] + fn depthwise_3x3_4x32x56x56(bencher: Bencher) { + let x = make_input_2d(4, 32, 56, 56); + let w = make_kernel_2d(32, 1, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 32); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_3x3_4x96x28x28(bencher: Bencher) { + let x = make_input_2d(4, 96, 28, 28); + let w = make_kernel_2d(96, 1, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 96); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_3x3_4x192x14x14(bencher: Bencher) { + let x = make_input_2d(4, 192, 14, 14); + let w = make_kernel_2d(192, 1, 3, 3); + let opts = ConvOptions::new([1, 1], [1, 1], [1, 1], 192); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_7x7_4x24x56x56(bencher: Bencher) { + // ConvNeXt-style 7x7 depthwise. This shape was 3x slower + // than burn-ndarray before the depthwise fast path landed. + let x = make_input_2d(4, 24, 56, 56); + let w = make_kernel_2d(24, 1, 7, 7); + let opts = ConvOptions::new([1, 1], [3, 3], [1, 1], 24); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_7x7_4x48x56x56(bencher: Bencher) { + // Another regressed shape (channels_out = 48). + let x = make_input_2d(4, 48, 56, 56); + let w = make_kernel_2d(48, 1, 7, 7); + let opts = ConvOptions::new([1, 1], [3, 3], [1, 1], 48); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + + #[divan::bench] + fn depthwise_3x3_stride2_4x64x56x56(bencher: Bencher) { + // Downsampling depthwise 3x3. + let x = make_input_2d(4, 64, 56, 56); + let w = make_kernel_2d(64, 1, 3, 3); + let opts = ConvOptions::new([2, 2], [1, 1], [1, 1], 64); + bencher + .bench_synced(|| module::conv2d(x.clone(), w.clone(), None, opts.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/conv_transpose_ops.rs b/crates/burn-backend-tests/benches/conv_transpose_ops.rs new file mode 100644 index 0000000..abe0118 --- /dev/null +++ b/crates/burn-backend-tests/benches/conv_transpose_ops.rs @@ -0,0 +1,284 @@ +//! Benchmarks for transposed convolution operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench conv_transpose_ops --features simd,rayon +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::ops::ConvTransposeOptions; +use burn_tensor::{Tensor, TensorData, module}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Conv Transpose Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_input_2d(batch: usize, channels: usize, height: usize, width: usize) -> Tensor<4> { + let data: Vec = (0..batch * channels * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, height, width]), + &Default::default(), + ) +} + +fn make_weight_2d( + in_channels: usize, + out_channels: usize, + kernel_h: usize, + kernel_w: usize, +) -> Tensor<4> { + let data: Vec = (0..in_channels * out_channels * kernel_h * kernel_w) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [in_channels, out_channels, kernel_h, kernel_w]), + &Default::default(), + ) +} + +fn make_input_1d(batch: usize, channels: usize, length: usize) -> Tensor<3> { + let data: Vec = (0..batch * channels * length) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, length]), + &Default::default(), + ) +} + +fn make_weight_1d(in_channels: usize, out_channels: usize, kernel: usize) -> Tensor<3> { + let data: Vec = (0..in_channels * out_channels * kernel) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [in_channels, out_channels, kernel]), + &Default::default(), + ) +} + +fn make_input_3d( + batch: usize, + channels: usize, + depth: usize, + height: usize, + width: usize, +) -> Tensor<5> { + let data: Vec = (0..batch * channels * depth * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, depth, height, width]), + &Default::default(), + ) +} + +fn make_weight_3d( + in_channels: usize, + out_channels: usize, + kernel_d: usize, + kernel_h: usize, + kernel_w: usize, +) -> Tensor<5> { + let data: Vec = (0..in_channels * out_channels * kernel_d * kernel_h * kernel_w) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new( + data, + [in_channels, out_channels, kernel_d, kernel_h, kernel_w], + ), + &Default::default(), + ) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "conv_transpose2d")] + mod conv_transpose2d { + use super::*; + + #[divan::bench] + fn conv_transpose2d_1x64x7x7_to_14x14(bencher: Bencher) { + // Upsample from 7x7 to 14x14 (common in decoder/generator) + let x = make_input_2d(1, 64, 7, 7); + let w = make_weight_2d(64, 64, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose2d_1x128x14x14_to_28x28(bencher: Bencher) { + let x = make_input_2d(1, 128, 14, 14); + let w = make_weight_2d(128, 64, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose2d_1x256x28x28_to_56x56(bencher: Bencher) { + let x = make_input_2d(1, 256, 28, 28); + let w = make_weight_2d(256, 128, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose2d_8x64x14x14_to_28x28(bencher: Bencher) { + // Batch of 8 + let x = make_input_2d(8, 64, 14, 14); + let w = make_weight_2d(64, 64, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose2d_1x512x7x7_k3x3_s1(bencher: Bencher) { + // No upsampling, just transpose conv + let x = make_input_2d(1, 512, 7, 7); + let w = make_weight_2d(512, 512, 3, 3); + let opts = ConvTransposeOptions::new([1, 1], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + } + + #[divan::bench_group(name = "conv_transpose2d_gan")] + mod conv_transpose2d_gan { + use super::*; + + #[divan::bench] + fn dcgan_layer1_1x512x1x1_to_4x4(bencher: Bencher) { + // DCGAN first layer: project and reshape + let x = make_input_2d(1, 512, 1, 1); + let w = make_weight_2d(512, 256, 4, 4); + let opts = ConvTransposeOptions::new([1, 1], [0, 0], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn dcgan_layer2_1x256x4x4_to_8x8(bencher: Bencher) { + let x = make_input_2d(1, 256, 4, 4); + let w = make_weight_2d(256, 128, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn dcgan_layer3_1x128x8x8_to_16x16(bencher: Bencher) { + let x = make_input_2d(1, 128, 8, 8); + let w = make_weight_2d(128, 64, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn dcgan_layer4_1x64x16x16_to_32x32(bencher: Bencher) { + let x = make_input_2d(1, 64, 16, 16); + let w = make_weight_2d(64, 3, 4, 4); + let opts = ConvTransposeOptions::new([2, 2], [1, 1], [0, 0], [1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose2d(x.clone(), w.clone(), None, opts.clone()) + }); + } + } + + #[divan::bench_group(name = "conv_transpose1d")] + mod conv_transpose1d { + use super::*; + + #[divan::bench] + fn conv_transpose1d_1x64x32_to_64(bencher: Bencher) { + let x = make_input_1d(1, 64, 32); + let w = make_weight_1d(64, 64, 4); + let opts = ConvTransposeOptions::new([2], [1], [0], [1], 1); + bencher.bench_synced(|| { + module::conv_transpose1d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose1d_8x128x64_to_128(bencher: Bencher) { + let x = make_input_1d(8, 128, 64); + let w = make_weight_1d(128, 64, 4); + let opts = ConvTransposeOptions::new([2], [1], [0], [1], 1); + bencher.bench_synced(|| { + module::conv_transpose1d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose1d_1x256x128_to_256(bencher: Bencher) { + let x = make_input_1d(1, 256, 128); + let w = make_weight_1d(256, 128, 4); + let opts = ConvTransposeOptions::new([2], [1], [0], [1], 1); + bencher.bench_synced(|| { + module::conv_transpose1d(x.clone(), w.clone(), None, opts.clone()) + }); + } + } + + #[divan::bench_group(name = "conv_transpose3d")] + mod conv_transpose3d { + use super::*; + + #[divan::bench] + fn conv_transpose3d_1x32x4x4x4_to_8x8x8(bencher: Bencher) { + let x = make_input_3d(1, 32, 4, 4, 4); + let w = make_weight_3d(32, 32, 4, 4, 4); + let opts = + ConvTransposeOptions::new([2, 2, 2], [1, 1, 1], [0, 0, 0], [1, 1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose3d(x.clone(), w.clone(), None, opts.clone()) + }); + } + + #[divan::bench] + fn conv_transpose3d_1x64x8x8x8_to_16x16x16(bencher: Bencher) { + let x = make_input_3d(1, 64, 8, 8, 8); + let w = make_weight_3d(64, 32, 4, 4, 4); + let opts = + ConvTransposeOptions::new([2, 2, 2], [1, 1, 1], [0, 0, 0], [1, 1, 1], 1); + bencher.bench_synced(|| { + module::conv_transpose3d(x.clone(), w.clone(), None, opts.clone()) + }); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/cross_unfold_ops.rs b/crates/burn-backend-tests/benches/cross_unfold_ops.rs new file mode 100644 index 0000000..e0a8adb --- /dev/null +++ b/crates/burn-backend-tests/benches/cross_unfold_ops.rs @@ -0,0 +1,154 @@ +//! Benchmarks for cross and unfold operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench cross_unfold_ops --features simd +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Cross and unfold ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +// Cross product requires 3 elements along the specified dimension +fn make_cross_tensor_1d(batch: usize) -> Tensor<2> { + let data: Vec = (0..batch * 3).map(|i| (i % 1000) as f32 / 100.0).collect(); + Tensor::from_data(TensorData::new(data, [batch, 3]), &Default::default()) +} + +fn make_cross_tensor_2d(d0: usize, d1: usize) -> Tensor<3> { + let data: Vec = (0..d0 * d1 * 3) + .map(|i| (i % 1000) as f32 / 100.0) + .collect(); + Tensor::from_data(TensorData::new(data, [d0, 3, d1]), &Default::default()) +} + +fn make_tensor_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_tensor_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let size = d0 * d1 * d2; + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "cross")] + mod cross { + use super::*; + + #[divan::bench] + fn s_1k_vectors(bencher: Bencher) { + let a = make_cross_tensor_1d(1024); + let b = make_cross_tensor_1d(1024); + bencher.bench_synced(|| a.clone().cross(b.clone(), 1)); + } + + #[divan::bench] + fn s_64k_vectors(bencher: Bencher) { + let a = make_cross_tensor_1d(64 * 1024); + let b = make_cross_tensor_1d(64 * 1024); + bencher.bench_synced(|| a.clone().cross(b.clone(), 1)); + } + + #[divan::bench] + fn s_256k_vectors(bencher: Bencher) { + let a = make_cross_tensor_1d(256 * 1024); + let b = make_cross_tensor_1d(256 * 1024); + bencher.bench_synced(|| a.clone().cross(b.clone(), 1)); + } + + #[divan::bench] + fn s_3d_64x64(bencher: Bencher) { + let a = make_cross_tensor_2d(64, 64); + let b = make_cross_tensor_2d(64, 64); + bencher.bench_synced(|| a.clone().cross(b.clone(), 1)); + } + } + + #[divan::bench_group(name = "unfold")] + mod unfold { + use super::*; + + // 1D unfold: sliding window extraction + #[divan::bench] + fn s_1d_1k_win8_step1(bencher: Bencher) { + let t: Tensor<1> = make_tensor_1d(1024); + bencher.bench_synced(|| -> Tensor<2> { t.clone().unfold(0, 8, 1) }); + } + + #[divan::bench] + fn s_1d_64k_win8_step1(bencher: Bencher) { + let t: Tensor<1> = make_tensor_1d(64 * 1024); + bencher.bench_synced(|| -> Tensor<2> { t.clone().unfold(0, 8, 1) }); + } + + #[divan::bench] + fn s_1d_64k_win64_step1(bencher: Bencher) { + let t: Tensor<1> = make_tensor_1d(64 * 1024); + bencher.bench_synced(|| -> Tensor<2> { t.clone().unfold(0, 64, 1) }); + } + + #[divan::bench] + fn s_1d_64k_win64_step32(bencher: Bencher) { + let t: Tensor<1> = make_tensor_1d(64 * 1024); + bencher.bench_synced(|| -> Tensor<2> { t.clone().unfold(0, 64, 32) }); + } + + // 2D unfold along dim 1 + #[divan::bench] + fn s_2d_256x256_dim1_win8_step1(bencher: Bencher) { + let t: Tensor<2> = make_tensor_2d(256, 256); + bencher.bench_synced(|| -> Tensor<3> { t.clone().unfold(1, 8, 1) }); + } + + #[divan::bench] + fn s_2d_256x256_dim1_win32_step16(bencher: Bencher) { + let t: Tensor<2> = make_tensor_2d(256, 256); + bencher.bench_synced(|| -> Tensor<3> { t.clone().unfold(1, 32, 16) }); + } + + #[divan::bench] + fn s_2d_1024x256_dim1_win8_step1(bencher: Bencher) { + let t: Tensor<2> = make_tensor_2d(1024, 256); + bencher.bench_synced(|| -> Tensor<3> { t.clone().unfold(1, 8, 1) }); + } + + // 3D unfold (batch scenarios) + #[divan::bench] + fn s_3d_32x64x64_dim2_win8_step4(bencher: Bencher) { + let t: Tensor<3> = make_tensor_3d(32, 64, 64); + bencher.bench_synced(|| -> Tensor<4> { t.clone().unfold(2, 8, 4) }); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/cumulative_ops.rs b/crates/burn-backend-tests/benches/cumulative_ops.rs new file mode 100644 index 0000000..3068999 --- /dev/null +++ b/crates/burn-backend-tests/benches/cumulative_ops.rs @@ -0,0 +1,174 @@ +//! Benchmarks for cumulative operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench cumulative_ops --features simd +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Cumulative ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_tensor_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let size = d0 * d1 * d2; + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "cumsum")] + mod cumsum { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().cumsum(0)); + } + + #[divan::bench] + fn s_64k(bencher: Bencher) { + let t = make_tensor_1d(64 * 1024); + bencher.bench_synced(|| t.clone().cumsum(0)); + } + + #[divan::bench] + fn s_1m(bencher: Bencher) { + let t = make_tensor_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().cumsum(0)); + } + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().cumsum(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().cumsum(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().cumsum(1)); + } + } + + #[divan::bench_group(name = "cumprod")] + mod cumprod { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().cumprod(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().cumprod(1)); + } + } + + #[divan::bench_group(name = "cummin")] + mod cummin { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().cummin(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().cummin(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().cummin(1)); + } + } + + #[divan::bench_group(name = "cummax")] + mod cummax { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().cummax(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().cummax(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().cummax(1)); + } + } + + // 3D cumulative (batch scenarios) + #[divan::bench_group(name = "cumsum_3d")] + mod cumsum_3d { + use super::*; + + #[divan::bench] + fn s_batch32_64x64_dim2(bencher: Bencher) { + let t = make_tensor_3d(32, 64, 64); + bencher.bench_synced(|| t.clone().cumsum(2)); + } + + #[divan::bench] + fn s_batch32_64x64_dim1(bencher: Bencher) { + let t = make_tensor_3d(32, 64, 64); + bencher.bench_synced(|| t.clone().cumsum(1)); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/default_ops.rs b/crates/burn-backend-tests/benches/default_ops.rs new file mode 100644 index 0000000..6a4e847 --- /dev/null +++ b/crates/burn-backend-tests/benches/default_ops.rs @@ -0,0 +1,273 @@ +//! Benchmarks for default ops. +//! +//! These ops previously used burn's default trait implementations (TensorData +//! round-trips, slice_assign loops, etc.) and now have direct implementations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench default_ops --features simd +//! ``` + +#![allow(clippy::approx_constant)] + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Int, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Default ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +const SMALL: usize = 64 * 64; +const MEDIUM: usize = 256 * 256; +const LARGE: usize = 1024 * 1024; + +fn make_tensor_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size) + .map(|i| (i % 1000) as f32 / 1000.0 - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0 - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + use burn_tensor::Shape; + + // --------------------------------------------------------------- + // sort (1D) + // --------------------------------------------------------------- + + #[divan::bench_group(name = "sort_1d")] + mod sort_1d { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor_1d(SMALL); + bencher.bench_synced(|| a.clone().sort(0)); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor_1d(MEDIUM); + bencher.bench_synced(|| a.clone().sort(0)); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor_1d(LARGE); + bencher.bench_synced(|| a.clone().sort(0)); + } + } + + // --------------------------------------------------------------- + // sort (2D along dim 1) + // --------------------------------------------------------------- + + #[divan::bench_group(name = "sort_2d_dim1")] + mod sort_2d { + use super::*; + + #[divan::bench] + fn rows64_cols64(bencher: Bencher) { + let a = make_tensor_2d(64, 64); + bencher.bench_synced(|| a.clone().sort(1)); + } + + #[divan::bench] + fn rows256_cols256(bencher: Bencher) { + let a = make_tensor_2d(256, 256); + bencher.bench_synced(|| a.clone().sort(1)); + } + + #[divan::bench] + fn rows1024_cols1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| a.clone().sort(1)); + } + } + + // --------------------------------------------------------------- + // argsort (1D) + // --------------------------------------------------------------- + + #[divan::bench_group(name = "argsort_1d")] + mod argsort_1d { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor_1d(SMALL); + bencher.bench_synced(|| a.clone().argsort(0)); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor_1d(LARGE); + bencher.bench_synced(|| a.clone().argsort(0)); + } + } + + // --------------------------------------------------------------- + // repeat_dim + // --------------------------------------------------------------- + + #[divan::bench_group(name = "repeat_dim")] + mod repeat_dim { + use super::*; + + #[divan::bench] + fn repeat_dim0_4x(bencher: Bencher) { + let a = make_tensor_2d(256, 256); + bencher.bench_synced(|| a.clone().repeat_dim(0, 4)); + } + + #[divan::bench] + fn repeat_dim1_4x(bencher: Bencher) { + let a = make_tensor_2d(256, 256); + bencher.bench_synced(|| a.clone().repeat_dim(1, 4)); + } + + #[divan::bench] + fn repeat_large_dim0_8x(bencher: Bencher) { + let a = make_tensor_2d(512, 512); + bencher.bench_synced(|| a.clone().repeat_dim(0, 8)); + } + } + + // --------------------------------------------------------------- + // zeros / ones / full + // --------------------------------------------------------------- + + #[divan::bench_group(name = "creation")] + mod creation { + use super::*; + + #[divan::bench] + fn zeros_large(bencher: Bencher) { + let dev = Default::default(); + bencher.bench_synced(|| Tensor::<1>::zeros(Shape::new([LARGE]), &dev)); + } + + #[divan::bench] + fn ones_large(bencher: Bencher) { + let dev = Default::default(); + bencher.bench_synced(|| Tensor::<1>::ones(Shape::new([LARGE]), &dev)); + } + + #[divan::bench] + fn full_large(bencher: Bencher) { + let dev = Default::default(); + bencher.bench_synced(|| Tensor::<1>::full(Shape::new([LARGE]), 3.14, &dev)); + } + } + + // --------------------------------------------------------------- + // arange + // --------------------------------------------------------------- + + #[divan::bench_group(name = "arange")] + mod arange { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let dev = Default::default(); + bencher.bench_synced(|| Tensor::<1, Int>::arange(0..SMALL as i64, &dev)); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let dev = Default::default(); + bencher.bench_synced(|| Tensor::<1, Int>::arange(0..LARGE as i64, &dev)); + } + } + + // --------------------------------------------------------------- + // embedding + // --------------------------------------------------------------- + + #[divan::bench_group(name = "embedding")] + mod embedding { + use super::*; + use burn_tensor::module; + + fn make_weights(vocab: usize, dim: usize) -> Tensor<2> { + let data: Vec = (0..vocab * dim) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [vocab, dim]), &Default::default()) + } + + fn make_indices(batch: usize, seq: usize, vocab: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..batch * seq).map(|i| (i % vocab) as i32).collect(); + Tensor::from_data(TensorData::new(data, [batch, seq]), &Default::default()) + }) + } + + #[divan::bench] + fn vocab30k_dim512_batch8_seq128(bencher: Bencher) { + let weights = make_weights(30000, 512); + let Some(indices) = make_indices(8, 128, 30000) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| module::embedding(weights.clone(), indices.clone())); + } + + #[divan::bench] + fn vocab50k_dim768_batch4_seq256(bencher: Bencher) { + let weights = make_weights(50000, 768); + let Some(indices) = make_indices(4, 256, 50000) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| module::embedding(weights.clone(), indices.clone())); + } + } + + // --------------------------------------------------------------- + // is_nan / is_inf + // --------------------------------------------------------------- + + #[divan::bench_group(name = "predicates")] + mod predicates { + use super::*; + + #[divan::bench] + fn is_nan_large(bencher: Bencher) { + let a = make_tensor_1d(LARGE); + bencher.bench_synced(|| a.clone().is_nan()); + } + + #[divan::bench] + fn is_inf_large(bencher: Bencher) { + let a = make_tensor_1d(LARGE); + bencher.bench_synced(|| a.clone().is_inf()); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/deform_conv_ops.rs b/crates/burn-backend-tests/benches/deform_conv_ops.rs new file mode 100644 index 0000000..60c3c97 --- /dev/null +++ b/crates/burn-backend-tests/benches/deform_conv_ops.rs @@ -0,0 +1,415 @@ +//! Benchmarks for deformable convolution. +//! +//! Run with: +//! ```bash +//! cargo bench --bench deform_conv_ops --features simd,rayon,gemm +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData, module, ops::DeformConvOptions}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Deformable Convolution Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +/// Create input tensor [batch, channels, height, width] +fn make_input(batch: usize, channels: usize, height: usize, width: usize) -> Tensor<4> { + let data: Vec = (0..batch * channels * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, height, width]), + &Default::default(), + ) +} + +/// Create weight tensor [out_channels, in_channels/groups, kernel_h, kernel_w] +fn make_weight(out_ch: usize, in_ch_per_group: usize, kh: usize, kw: usize) -> Tensor<4> { + let data: Vec = (0..out_ch * in_ch_per_group * kh * kw) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [out_ch, in_ch_per_group, kh, kw]), + &Default::default(), + ) +} + +/// Create offset tensor [batch, offset_groups * kernel_h * kernel_w * 2, out_h, out_w] +fn make_offset( + batch: usize, + offset_groups: usize, + kh: usize, + kw: usize, + out_h: usize, + out_w: usize, +) -> Tensor<4> { + let channels = offset_groups * kh * kw * 2; + let data: Vec = (0..batch * channels * out_h * out_w) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, out_h, out_w]), + &Default::default(), + ) +} + +/// Create mask tensor [batch, offset_groups * kernel_h * kernel_w, out_h, out_w] +fn make_mask( + batch: usize, + offset_groups: usize, + kh: usize, + kw: usize, + out_h: usize, + out_w: usize, +) -> Tensor<4> { + let channels = offset_groups * kh * kw; + let data: Vec = (0..batch * channels * out_h * out_w) + .map(|i| (i % 100) as f32 / 100.0) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, out_h, out_w]), + &Default::default(), + ) +} + +/// Create bias tensor [out_channels] +fn make_bias(out_ch: usize) -> Tensor<1> { + let data: Vec = (0..out_ch).map(|i| (i % 10) as f32 / 10.0).collect(); + Tensor::from_data(TensorData::new(data, [out_ch]), &Default::default()) +} + +/// Compute output dimensions for convolution +fn compute_output_size( + input_size: usize, + kernel_size: usize, + padding: usize, + stride: usize, + dilation: usize, +) -> usize { + (input_size + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1 +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "deform_conv2d_tiny")] + mod deform_conv2d_tiny { + use super::*; + + // Tiny: 1x3x8x8 -> 8 output channels + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x3x8x8_k3x3_c8(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 3, 8, 8); + let (out_ch, kh, kw) = (8, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + + // Tiny without mask + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x3x8x8_k3x3_no_mask(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 3, 8, 8); + let (out_ch, kh, kw) = (8, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + None, + None, + opts.clone(), + ) + }); + } + } + + #[divan::bench_group(name = "deform_conv2d_small")] + mod deform_conv2d_small { + use super::*; + + // Small: 1x3x16x16 -> 16 output channels + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x3x16x16_k3x3_c16(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 3, 16, 16); + let (out_ch, kh, kw) = (16, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + + // Small with stride 2 + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x3x16x16_k3x3_stride2(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 3, 16, 16); + let (out_ch, kh, kw) = (16, 3, 3); + let (stride, padding, dilation) = ([2, 2], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + + // Small batch of 2 + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_2x8x16x16_k3x3_c16(bencher: Bencher) { + let (batch, in_ch, h, w) = (2, 8, 16, 16); + let (out_ch, kh, kw) = (16, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + } + + #[divan::bench_group(name = "deform_conv2d_medium")] + mod deform_conv2d_medium { + use super::*; + + // Medium: 1x16x32x32 -> 32 output channels + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x16x32x32_k3x3_c32(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 16, 32, 32); + let (out_ch, kh, kw) = (32, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + + // Medium with weight groups + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x16x32x32_k3x3_wg4(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 16, 32, 32); + let (out_ch, kh, kw) = (32, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (4, 1); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + + // Medium with offset groups + #[divan::bench(sample_count = 20, min_time = 0)] + fn deform_conv2d_1x16x32x32_k3x3_og4(bencher: Bencher) { + let (batch, in_ch, h, w) = (1, 16, 32, 32); + let (out_ch, kh, kw) = (32, 3, 3); + let (stride, padding, dilation) = ([1, 1], [1, 1], [1, 1]); + let (weight_groups, offset_groups) = (1, 4); + + let out_h = compute_output_size(h, kh, padding[0], stride[0], dilation[0]); + let out_w = compute_output_size(w, kw, padding[1], stride[1], dilation[1]); + + let x = make_input(batch, in_ch, h, w); + let weight = make_weight(out_ch, in_ch / weight_groups, kh, kw); + let offset = make_offset(batch, offset_groups, kh, kw, out_h, out_w); + let mask = make_mask(batch, offset_groups, kh, kw, out_h, out_w); + let bias = make_bias(out_ch); + let opts = DeformConvOptions::new( + stride, + padding, + dilation, + weight_groups, + offset_groups, + ); + + bencher.bench_synced(|| { + module::deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + opts.clone(), + ) + }); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/fft_ops.rs b/crates/burn-backend-tests/benches/fft_ops.rs new file mode 100644 index 0000000..5f6b201 --- /dev/null +++ b/crates/burn-backend-tests/benches/fft_ops.rs @@ -0,0 +1,138 @@ +//! Benchmarks for rfft / irfft. +//! +//! Run with: +//! ```bash +//! cargo bench --bench fft_ops +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{ + Tensor, TensorData, + signal::{irfft, rfft}, +}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("rfft / irfft benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_signal_1d(n: usize) -> Tensor<1> { + let data: Vec = (0..n).map(|i| (i as f32 * 0.1).sin()).collect(); + Tensor::from_data(TensorData::new(data, [n]), &Default::default()) +} + +fn make_signal_2d(batch: usize, n: usize) -> Tensor<2> { + let data: Vec = (0..batch * n).map(|i| (i as f32 * 0.1).sin()).collect(); + Tensor::from_data(TensorData::new(data, [batch, n]), &Default::default()) +} + +#[divan::bench_group(name = "rfft_1d")] +mod rfft_1d { + use super::*; + + #[divan::bench] + fn n_256(bencher: Bencher) { + let s = make_signal_1d(256); + bencher.bench_synced(|| rfft(s.clone(), 0, None)); + } + + #[divan::bench] + fn n_1024(bencher: Bencher) { + let s = make_signal_1d(1024); + bencher.bench_synced(|| rfft(s.clone(), 0, None)); + } + + #[divan::bench] + fn n_4096(bencher: Bencher) { + let s = make_signal_1d(4096); + bencher.bench_synced(|| rfft(s.clone(), 0, None)); + } + + #[divan::bench] + fn n_16384(bencher: Bencher) { + let s = make_signal_1d(16384); + bencher.bench_synced(|| rfft(s.clone(), 0, None)); + } + + #[divan::bench] + fn n_65536(bencher: Bencher) { + let s = make_signal_1d(65536); + bencher.bench_synced(|| rfft(s.clone(), 0, None)); + } +} + +#[divan::bench_group(name = "rfft_2d_batch")] +mod rfft_2d_batch { + use super::*; + + #[divan::bench] + fn batch_16_n_1024(bencher: Bencher) { + let s = make_signal_2d(16, 1024); + bencher.bench_synced(|| rfft(s.clone(), 1, None)); + } + + #[divan::bench] + fn batch_64_n_1024(bencher: Bencher) { + let s = make_signal_2d(64, 1024); + bencher.bench_synced(|| rfft(s.clone(), 1, None)); + } + + #[divan::bench] + fn batch_256_n_256(bencher: Bencher) { + let s = make_signal_2d(256, 256); + bencher.bench_synced(|| rfft(s.clone(), 1, None)); + } +} + +#[divan::bench_group(name = "irfft_1d")] +mod irfft_1d { + use super::*; + + fn bench_irfft(bencher: Bencher, n: usize) { + // rfft is used to produce the input spectrum; if the backend doesn't implement it the + // setup panics before bench_synced's catch_unwind. Use try_setup to record and fall + // through to a no-op. + let Some((re, im)) = common::try_setup(|| { + let s = make_signal_1d(n); + rfft(s, 0, None) + }) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| irfft(re.clone(), im.clone(), 0, None)); + } + + #[divan::bench] + fn n_256(bencher: Bencher) { + bench_irfft(bencher, 256); + } + + #[divan::bench] + fn n_1024(bencher: Bencher) { + bench_irfft(bencher, 1024); + } + + #[divan::bench] + fn n_4096(bencher: Bencher) { + bench_irfft(bencher, 4096); + } + + #[divan::bench] + fn n_16384(bencher: Bencher) { + bench_irfft(bencher, 16384); + } + + #[divan::bench] + fn n_65536(bencher: Bencher) { + bench_irfft(bencher, 65536); + } +} diff --git a/crates/burn-backend-tests/benches/gather_scatter_ops.rs b/crates/burn-backend-tests/benches/gather_scatter_ops.rs new file mode 100644 index 0000000..579bd0a --- /dev/null +++ b/crates/burn-backend-tests/benches/gather_scatter_ops.rs @@ -0,0 +1,207 @@ +//! Benchmarks for gather/scatter operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench gather_scatter_ops --features simd +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{IndexingUpdateOp, Int, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Gather/scatter ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_indices_2d(rows: usize, cols: usize, max_idx: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..rows * cols).map(|i| (i % max_idx) as i32).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + }) +} + +fn make_indices_1d(size: usize, max_idx: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..size).map(|i| (i % max_idx) as i32).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) + }) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "gather")] + mod gather { + use super::*; + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_2d(256, 128, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().gather(1, indices.clone())); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let tensor = make_tensor_2d(1024, 1024); + let Some(indices) = make_indices_2d(1024, 512, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().gather(1, indices.clone())); + } + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_2d(128, 256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().gather(0, indices.clone())); + } + } + + #[divan::bench_group(name = "scatter_add")] + mod scatter_add { + use super::*; + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_2d(256, 128, 256) else { + bencher.bench(|| ()); + return; + }; + let values = make_tensor_2d(256, 128); + bencher.bench_synced(|| { + tensor.clone().scatter( + 1, + indices.clone(), + values.clone(), + IndexingUpdateOp::Add, + ) + }); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let tensor = make_tensor_2d(1024, 1024); + let Some(indices) = make_indices_2d(1024, 512, 1024) else { + bencher.bench(|| ()); + return; + }; + let values = make_tensor_2d(1024, 512); + bencher.bench_synced(|| { + tensor.clone().scatter( + 1, + indices.clone(), + values.clone(), + IndexingUpdateOp::Add, + ) + }); + } + } + + #[divan::bench_group(name = "select")] + mod select { + use super::*; + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_1d(128, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().select(0, indices.clone())); + } + + #[divan::bench] + fn s_1024x1024_dim0(bencher: Bencher) { + let tensor = make_tensor_2d(1024, 1024); + let Some(indices) = make_indices_1d(512, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().select(0, indices.clone())); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_1d(128, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| tensor.clone().select(1, indices.clone())); + } + } + + #[divan::bench_group(name = "select_add")] + mod select_add { + use super::*; + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let tensor = make_tensor_2d(256, 256); + let Some(indices) = make_indices_1d(128, 256) else { + bencher.bench(|| ()); + return; + }; + let values = make_tensor_2d(128, 256); + bencher.bench_synced(|| { + tensor.clone().select_assign( + 0, + indices.clone(), + values.clone(), + IndexingUpdateOp::Add, + ) + }); + } + + #[divan::bench] + fn s_1024x1024_dim0(bencher: Bencher) { + let tensor = make_tensor_2d(1024, 1024); + let Some(indices) = make_indices_1d(512, 1024) else { + bencher.bench(|| ()); + return; + }; + let values = make_tensor_2d(512, 1024); + bencher.bench_synced(|| { + tensor.clone().select_assign( + 0, + indices.clone(), + values.clone(), + IndexingUpdateOp::Add, + ) + }); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/int_ops.rs b/crates/burn-backend-tests/benches/int_ops.rs new file mode 100644 index 0000000..bf2b23d --- /dev/null +++ b/crates/burn-backend-tests/benches/int_ops.rs @@ -0,0 +1,161 @@ +//! Benchmarks for integer operations: cast and random. +//! +//! Run with: +//! ```bash +//! cargo bench --bench int_ops --features simd,rayon +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{DType, Distribution, Int, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Integer Operations Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_int_tensor(shape: &[usize]) -> Option> { + common::try_setup(|| { + let size: usize = shape.iter().product(); + // Use values that fit in i8 (-128 to 127) so all casts work + let data: Vec = (0..size).map(|i| (i % 200) as i32 - 100).collect(); + Tensor::from_data(TensorData::new(data, shape.to_vec()), &Default::default()) + }) +} + +// ============================================================================= +// Int Cast Benchmarks +// ============================================================================= + +macro_rules! bench_cast_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "cast")] + mod cast { + use super::*; + + // Small tensor cast + #[divan::bench] + fn cast_i64_to_i32_64x64(bencher: Bencher) { + let Some(t) = make_int_tensor(&[64, 64]) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().cast(DType::I32)); + } + + // Medium tensor cast + #[divan::bench] + fn cast_i64_to_i32_256x256(bencher: Bencher) { + let Some(t) = make_int_tensor(&[256, 256]) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().cast(DType::I32)); + } + + // Large tensor cast + #[divan::bench] + fn cast_i64_to_i32_1024x1024(bencher: Bencher) { + let Some(t) = make_int_tensor(&[1024, 1024]) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().cast(DType::I32)); + } + + // Cast to smaller type (i8) + #[divan::bench] + fn cast_i64_to_i8_256x256(bencher: Bencher) { + let Some(t) = make_int_tensor(&[256, 256]) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| t.clone().cast(DType::I8)); + } + } + } + }; +} + +bench_cast_backend!(backend_cast, "backend_cast"); + +// ============================================================================= +// Int Random Benchmarks +// ============================================================================= + +macro_rules! bench_random_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "random")] + mod random { + use super::*; + + // Small random tensor + #[divan::bench] + fn random_uniform_64x64(bencher: Bencher) { + bencher.bench_synced(|| { + Tensor::<2, Int>::random( + [64, 64], + Distribution::Uniform(0.0, 100.0), + &Default::default(), + ) + }); + } + + // Medium random tensor + #[divan::bench] + fn random_uniform_256x256(bencher: Bencher) { + bencher.bench_synced(|| { + Tensor::<2, Int>::random( + [256, 256], + Distribution::Uniform(0.0, 100.0), + &Default::default(), + ) + }); + } + + // Large random tensor + #[divan::bench] + fn random_uniform_1024x1024(bencher: Bencher) { + bencher.bench_synced(|| { + Tensor::<2, Int>::random( + [1024, 1024], + Distribution::Uniform(0.0, 100.0), + &Default::default(), + ) + }); + } + + // 3D random tensor (batch) + #[divan::bench] + fn random_uniform_batch_16x128x128(bencher: Bencher) { + bencher.bench_synced(|| { + Tensor::<3, Int>::random( + [16, 128, 128], + Distribution::Uniform(-1000.0, 1000.0), + &Default::default(), + ) + }); + } + } + } + }; +} + +bench_random_backend!(backend_random, "backend_random"); diff --git a/crates/burn-backend-tests/benches/interpolate_ops.rs b/crates/burn-backend-tests/benches/interpolate_ops.rs new file mode 100644 index 0000000..e605f67 --- /dev/null +++ b/crates/burn-backend-tests/benches/interpolate_ops.rs @@ -0,0 +1,221 @@ +//! Benchmarks for interpolation operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench interpolate_ops --features simd,rayon +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{ + Tensor, TensorData, module, + ops::{InterpolateMode, InterpolateOptions}, +}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Interpolate Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_input(batch: usize, channels: usize, height: usize, width: usize) -> Tensor<4> { + let data: Vec = (0..batch * channels * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, height, width]), + &Default::default(), + ) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "nearest")] + mod nearest { + use super::*; + + #[divan::bench] + fn upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn upsample_4x_32x32_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn downsample_2x_256x256_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 256, 256); + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn batch8_upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(8, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn channels64_upsample_2x_32x32_to_64x64(bencher: Bencher) { + let x = make_input(1, 64, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + bencher.bench_synced(|| module::interpolate(x.clone(), [64, 64], opts.clone())); + } + } + + #[divan::bench_group(name = "bilinear")] + mod bilinear { + use super::*; + + #[divan::bench] + fn upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn upsample_4x_32x32_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn downsample_2x_256x256_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 256, 256); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn batch8_upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(8, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn channels64_upsample_2x_32x32_to_64x64(bencher: Bencher) { + let x = make_input(1, 64, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher.bench_synced(|| module::interpolate(x.clone(), [64, 64], opts.clone())); + } + + // Segmentation-model sized shapes mirroring the user's + // 488x448 input + UNet up/down pattern from issue #64 + // item 4. Covers the typical spatial dims across + // ConvNeXt-style downsample stages. + #[divan::bench] + fn model_downsample_488x448_to_244x224(bencher: Bencher) { + let x = make_input(1, 3, 488, 448); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [244, 224], opts.clone())); + } + + #[divan::bench] + fn model_upsample_244x224_to_488x448(bencher: Bencher) { + let x = make_input(1, 48, 244, 224); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [488, 448], opts.clone())); + } + + #[divan::bench] + fn model_upsample_61x56_to_122x112(bencher: Bencher) { + let x = make_input(1, 192, 61, 56); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear); + bencher + .bench_synced(|| module::interpolate(x.clone(), [122, 112], opts.clone())); + } + + // Explicit align_corners=false variant at a model size, + // since the default is `true` everywhere else. + #[divan::bench] + fn model_upsample_61x56_to_122x112_halfpixel(bencher: Bencher) { + let x = make_input(1, 192, 61, 56); + let opts = InterpolateOptions::new(InterpolateMode::Bilinear) + .with_align_corners(false); + bencher + .bench_synced(|| module::interpolate(x.clone(), [122, 112], opts.clone())); + } + } + + #[divan::bench_group(name = "bicubic")] + mod bicubic { + use super::*; + + #[divan::bench] + fn upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Bicubic); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn upsample_4x_32x32_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Bicubic); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn downsample_2x_256x256_to_128x128(bencher: Bencher) { + let x = make_input(1, 3, 256, 256); + let opts = InterpolateOptions::new(InterpolateMode::Bicubic); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn batch8_upsample_2x_64x64_to_128x128(bencher: Bencher) { + let x = make_input(8, 3, 64, 64); + let opts = InterpolateOptions::new(InterpolateMode::Bicubic); + bencher + .bench_synced(|| module::interpolate(x.clone(), [128, 128], opts.clone())); + } + + #[divan::bench] + fn channels64_upsample_2x_32x32_to_64x64(bencher: Bencher) { + let x = make_input(1, 64, 32, 32); + let opts = InterpolateOptions::new(InterpolateMode::Bicubic); + bencher.bench_synced(|| module::interpolate(x.clone(), [64, 64], opts.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/matmul.rs b/crates/burn-backend-tests/benches/matmul.rs new file mode 100644 index 0000000..5fd3a54 --- /dev/null +++ b/crates/burn-backend-tests/benches/matmul.rs @@ -0,0 +1,364 @@ +//! Benchmarks for matrix multiplication. +//! +//! Run with: +//! ```bash +//! cargo bench --bench matmul --features simd,gemm +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Matrix Multiplication Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_matrix(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_batch_matrix(batch: usize, rows: usize, cols: usize) -> Tensor<3> { + let data: Vec = (0..batch * rows * cols) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, rows, cols]), + &Default::default(), + ) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // Square matrices + #[divan::bench_group(name = "square")] + mod square { + use super::*; + + #[divan::bench] + fn matmul_64x64(bencher: Bencher) { + let a = make_matrix(64, 64); + let b = make_matrix(64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_128x128(bencher: Bencher) { + let a = make_matrix(128, 128); + let b = make_matrix(128, 128); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_256x256(bencher: Bencher) { + let a = make_matrix(256, 256); + let b = make_matrix(256, 256); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_512x512(bencher: Bencher) { + let a = make_matrix(512, 512); + let b = make_matrix(512, 512); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_1024x1024(bencher: Bencher) { + let a = make_matrix(1024, 1024); + let b = make_matrix(1024, 1024); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + + // Rectangular matrices (common in neural networks) + #[divan::bench_group(name = "rectangular")] + mod rectangular { + use super::*; + + // Linear layer: [batch, in] x [in, out] + #[divan::bench] + fn linear_256x512_512x256(bencher: Bencher) { + let a = make_matrix(256, 512); + let b = make_matrix(512, 256); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // Attention: [seq, hidden] x [hidden, seq] + #[divan::bench] + fn attention_512x64_64x512(bencher: Bencher) { + let a = make_matrix(512, 64); + let b = make_matrix(64, 512); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // Wide matrix (embedding lookup style) + #[divan::bench] + fn wide_128x1024_1024x128(bencher: Bencher) { + let a = make_matrix(128, 1024); + let b = make_matrix(1024, 128); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + + // Batched matmul (common in transformers) + #[divan::bench_group(name = "batched")] + mod batched { + use super::*; + + #[divan::bench] + fn batch8_64x64(bencher: Bencher) { + let a = make_batch_matrix(8, 64, 64); + let b = make_batch_matrix(8, 64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn batch16_128x128(bencher: Bencher) { + let a = make_batch_matrix(16, 128, 128); + let b = make_batch_matrix(16, 128, 128); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn batch32_64x64(bencher: Bencher) { + let a = make_batch_matrix(32, 64, 64); + let b = make_batch_matrix(32, 64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // Transformer attention heads + #[divan::bench] + fn heads12_seq512_dim64(bencher: Bencher) { + let a = make_batch_matrix(12, 512, 64); + let b = make_batch_matrix(12, 64, 512); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + + // Transposed inputs (tests contiguous conversion overhead) + #[divan::bench_group(name = "transposed")] + mod transposed { + use super::*; + + #[divan::bench] + fn lhs_transposed_256x256(bencher: Bencher) { + let a = make_matrix(256, 256).transpose(); + let b = make_matrix(256, 256); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn rhs_transposed_256x256(bencher: Bencher) { + let a = make_matrix(256, 256); + let b = make_matrix(256, 256).transpose(); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn both_transposed_256x256(bencher: Bencher) { + let a = make_matrix(256, 256).transpose(); + let b = make_matrix(256, 256).transpose(); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); + +// Integer matmul benchmarks +fn make_int_matrix(rows: usize, cols: usize) -> Option> { + common::try_setup(|| { + let data: Vec = (0..rows * cols).map(|i| (i % 1000) as i32 - 500).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + }) +} + +fn make_int_batch_matrix( + batch: usize, + rows: usize, + cols: usize, +) -> Option> { + common::try_setup(|| { + let data: Vec = (0..batch * rows * cols) + .map(|i| (i % 1000) as i32 - 500) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, rows, cols]), + &Default::default(), + ) + }) +} + +macro_rules! bench_int_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "int_square")] + mod square { + use super::*; + + #[divan::bench] + fn matmul_64x64(bencher: Bencher) { + let Some(a) = make_int_matrix(64, 64) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_matrix(64, 64) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_128x128(bencher: Bencher) { + let Some(a) = make_int_matrix(128, 128) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_matrix(128, 128) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_256x256(bencher: Bencher) { + let Some(a) = make_int_matrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_matrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn matmul_512x512(bencher: Bencher) { + let Some(a) = make_int_matrix(512, 512) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_matrix(512, 512) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + + #[divan::bench_group(name = "int_batched")] + mod batched { + use super::*; + + #[divan::bench] + fn batch8_64x64(bencher: Bencher) { + let Some(a) = make_int_batch_matrix(8, 64, 64) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_batch_matrix(8, 64, 64) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn batch16_128x128(bencher: Bencher) { + let Some(a) = make_int_batch_matrix(16, 128, 128) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_int_batch_matrix(16, 128, 128) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + } + }; +} + +bench_int_backend!(backend_int, "backend_int"); + +// Broadcast matmul benchmarks +fn make_batch_matrix_4d(b1: usize, b2: usize, rows: usize, cols: usize) -> Tensor<4> { + let data: Vec = (0..b1 * b2 * rows * cols) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [b1, b2, rows, cols]), + &Default::default(), + ) +} + +macro_rules! bench_broadcast_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // Broadcast: [1, M, K] x [batch, K, N] -> [batch, M, N] + #[divan::bench] + fn broadcast_1_vs_8_64x64(bencher: Bencher) { + let a = make_batch_matrix(1, 64, 64); + let b = make_batch_matrix(8, 64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // Broadcast: [batch, M, K] x [1, K, N] -> [batch, M, N] + #[divan::bench] + fn broadcast_8_vs_1_64x64(bencher: Bencher) { + let a = make_batch_matrix(8, 64, 64); + let b = make_batch_matrix(1, 64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // 4D broadcast: [2, 1, M, K] x [1, 4, K, N] -> [2, 4, M, N] + #[divan::bench] + fn broadcast_4d_2x1_vs_1x4_32x32(bencher: Bencher) { + let a = make_batch_matrix_4d(2, 1, 32, 32); + let b = make_batch_matrix_4d(1, 4, 32, 32); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + // Larger 4D broadcast + #[divan::bench] + fn broadcast_4d_4x1_vs_1x4_64x64(bencher: Bencher) { + let a = make_batch_matrix_4d(4, 1, 64, 64); + let b = make_batch_matrix_4d(1, 4, 64, 64); + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + }; +} + +bench_broadcast_backend!(backend_broadcast, "backend_broadcast"); diff --git a/crates/burn-backend-tests/benches/norm_ops.rs b/crates/burn-backend-tests/benches/norm_ops.rs new file mode 100644 index 0000000..9736707 --- /dev/null +++ b/crates/burn-backend-tests/benches/norm_ops.rs @@ -0,0 +1,323 @@ +//! Benchmarks for normalization ops. +//! +//! Covers `burn::nn::LayerNorm::forward`'s decomposed primitive-ops path +//! (what you get by default from burn-nn), the backend `layer_norm` fast +//! path, and a `reshape -> layer_norm -> reshape-back` composite that +//! mirrors a ConvNeXt-style channels-last layer norm. See issue #64 item 2. +//! +//! Run with: +//! ```bash +//! cargo bench --bench norm_ops --features simd,rayon +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +/// Route through the `B::layer_norm` backend hook. On Flex this hits the +/// fused override; on NdArray this hits the `ModuleOps` default decomposition. +fn trait_layer_norm( + input: Tensor, + gamma: Tensor<1>, + beta: Tensor<1>, + epsilon: f64, +) -> Tensor { + burn_tensor::module::layer_norm(input, gamma, Some(beta), epsilon) +} + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Normalization Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let data: Vec = (0..d0 * d1 * d2) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +fn make_tensor_4d(d0: usize, d1: usize, d2: usize, d3: usize) -> Tensor<4> { + let data: Vec = (0..d0 * d1 * d2 * d3) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2, d3]), &Default::default()) +} + +fn make_gamma_beta(d_model: usize) -> (Tensor<1>, Tensor<1>) { + let gamma: Vec = (0..d_model) + .map(|i| 0.5 + (i as f32 / d_model as f32)) + .collect(); + let beta: Vec = (0..d_model).map(|i| i as f32 / d_model as f32).collect(); + ( + Tensor::from_data(TensorData::new(gamma, [d_model]), &Default::default()), + Tensor::from_data(TensorData::new(beta, [d_model]), &Default::default()), + ) +} + +/// Decomposed layer_norm, mirroring what `burn::nn::LayerNorm::forward` +/// does via primitive tensor ops. Applies along the last dim. +fn decomposed_layer_norm( + input: Tensor, + gamma: Tensor<1>, + beta: Tensor<1>, + epsilon: f32, +) -> Tensor { + let dim = D - 1; + let mean = input.clone().mean_dim(dim); + let centered = input - mean; + let var = centered.clone().powi_scalar(2).mean_dim(dim); + let normalized = centered / (var + epsilon).sqrt(); + normalized * gamma.unsqueeze() + beta.unsqueeze() +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // ConvNeXt-style: small last-dim C (48, 96, 192, 384), large + // N*H*W. This is the shape the user's segmentation model hits + // at the channels-last layer norm between convolution blocks. + #[divan::bench_group(name = "convnext_layer_norm_decomposed")] + mod convnext_layer_norm_decomposed { + use super::*; + + #[divan::bench] + fn c48_hw244x224(bencher: Bencher) { + let x = make_tensor_3d(1, 244 * 224, 48); + let (g, b) = make_gamma_beta(48); + bencher.bench_synced(|| { + decomposed_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5) + }); + } + + #[divan::bench] + fn c96_hw122x112(bencher: Bencher) { + let x = make_tensor_3d(1, 122 * 112, 96); + let (g, b) = make_gamma_beta(96); + bencher.bench_synced(|| { + decomposed_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5) + }); + } + + #[divan::bench] + fn c192_hw61x56(bencher: Bencher) { + let x = make_tensor_3d(1, 61 * 56, 192); + let (g, b) = make_gamma_beta(192); + bencher.bench_synced(|| { + decomposed_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5) + }); + } + + #[divan::bench] + fn c384_hw30x28(bencher: Bencher) { + let x = make_tensor_3d(1, 30 * 28, 384); + let (g, b) = make_gamma_beta(384); + bencher.bench_synced(|| { + decomposed_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5) + }); + } + + // Transformer-style: typical `[batch=2, seq=512, d=768]`. + #[divan::bench] + fn d768_seq512_b2(bencher: Bencher) { + let x = make_tensor_3d(2, 512, 768); + let (g, b) = make_gamma_beta(768); + bencher.bench_synced(|| { + decomposed_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5) + }); + } + } + + // Routes through `B::layer_norm`; compare against the + // `convnext_layer_norm_decomposed` group above. + #[divan::bench_group(name = "convnext_layer_norm_fused_trait")] + mod convnext_layer_norm_fused_trait { + use super::*; + + #[divan::bench] + fn c48_hw244x224(bencher: Bencher) { + let x = make_tensor_3d(1, 244 * 224, 48); + let (g, b) = make_gamma_beta(48); + bencher + .bench_synced(|| trait_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5)); + } + + #[divan::bench] + fn c96_hw122x112(bencher: Bencher) { + let x = make_tensor_3d(1, 122 * 112, 96); + let (g, b) = make_gamma_beta(96); + bencher + .bench_synced(|| trait_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5)); + } + + #[divan::bench] + fn c192_hw61x56(bencher: Bencher) { + let x = make_tensor_3d(1, 61 * 56, 192); + let (g, b) = make_gamma_beta(192); + bencher + .bench_synced(|| trait_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5)); + } + + #[divan::bench] + fn c384_hw30x28(bencher: Bencher) { + let x = make_tensor_3d(1, 30 * 28, 384); + let (g, b) = make_gamma_beta(384); + bencher + .bench_synced(|| trait_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5)); + } + + #[divan::bench] + fn d768_seq512_b2(bencher: Bencher) { + let x = make_tensor_3d(2, 512, 768); + let (g, b) = make_gamma_beta(768); + bencher + .bench_synced(|| trait_layer_norm(x.clone(), g.clone(), b.clone(), 1e-5)); + } + } + + // Drill-down: time each primitive op used inside the + // decomposition, so we can see which one drives the gap. + // Shape is [1, 244*224, 48], matching the worst case above. + #[divan::bench_group(name = "layer_norm_primitive_drilldown")] + mod layer_norm_primitive_drilldown { + use super::*; + + fn shape() -> Tensor<3> { + make_tensor_3d(1, 244 * 224, 48) + } + + #[divan::bench] + fn op1_mean_dim_last(bencher: Bencher) { + let x = shape(); + bencher.bench_synced(|| x.clone().mean_dim(2)); + } + + #[divan::bench] + fn op2_broadcast_sub(bencher: Bencher) { + let x = shape(); + let m = x.clone().mean_dim(2); + bencher.bench_synced(|| x.clone() - m.clone()); + } + + #[divan::bench] + fn op3_powi_scalar_2(bencher: Bencher) { + let x = shape(); + bencher.bench_synced(|| x.clone().powi_scalar(2)); + } + + #[divan::bench] + fn op4_broadcast_div(bencher: Bencher) { + let x = shape(); + let m = x.clone().mean_dim(2); + bencher.bench_synced(|| x.clone() / m.clone()); + } + + #[divan::bench] + fn op5_broadcast_mul_1d(bencher: Bencher) { + let x = shape(); + let (g, _) = make_gamma_beta(48); + bencher.bench_synced(|| x.clone() * g.clone().unsqueeze()); + } + + #[divan::bench] + fn op6_broadcast_add_1d(bencher: Bencher) { + let x = shape(); + let (_, b) = make_gamma_beta(48); + bencher.bench_synced(|| x.clone() + b.clone().unsqueeze()); + } + } + + // Drill-down for the "non-contig-input layer_norm" case. + // Input is x.permute([0, 2, 3, 1]) from shape + // [1, 48, 244, 224], so the permuted last dim has an + // absurd stride (54656) and hurts every op that needs + // row-contig access. + #[divan::bench_group(name = "permuted_input_drilldown")] + mod permuted_input_drilldown { + use super::*; + + fn permuted() -> Tensor<4> { + let x = make_tensor_4d(1, 48, 244, 224); + x.permute([0, 2, 3, 1]) + } + + #[divan::bench] + fn p1_permute_only(bencher: Bencher) { + let x = make_tensor_4d(1, 48, 244, 224); + bencher.bench_synced(|| x.clone().permute([0, 2, 3, 1])); + } + + #[divan::bench] + fn p2_mean_dim_last_on_permuted(bencher: Bencher) { + let p = permuted(); + bencher.bench_synced(|| p.clone().mean_dim(3)); + } + + #[divan::bench] + fn p3_broadcast_sub_on_permuted(bencher: Bencher) { + let p = permuted(); + let m = p.clone().mean_dim(3); + bencher.bench_synced(|| p.clone() - m.clone()); + } + } + + // ConvNeXt pattern: input is (N, C, H, W), reshape/permute to + // (N, H, W, C), layer_norm over C, permute back. The full + // composite is what the user's benchmark bundle called + // "reshape + dims + layer_norm". + #[divan::bench_group(name = "convnext_reshape_layer_norm_composite")] + mod convnext_reshape_layer_norm_composite { + use super::*; + + fn run(x: Tensor<4>, g: Tensor<1>, b: Tensor<1>) -> Tensor<4> { + // (N, C, H, W) -> (N, H, W, C) + let y = x.permute([0, 2, 3, 1]); + let normed = decomposed_layer_norm(y, g, b, 1e-5); + // (N, H, W, C) -> (N, C, H, W) + normed.permute([0, 3, 1, 2]) + } + + #[divan::bench] + fn c48_488x448(bencher: Bencher) { + let x = make_tensor_4d(1, 48, 244, 224); + let (g, b) = make_gamma_beta(48); + bencher.bench_synced(|| run::<3>(x.clone(), g.clone(), b.clone())); + } + + #[divan::bench] + fn c96_122x112(bencher: Bencher) { + let x = make_tensor_4d(1, 96, 122, 112); + let (g, b) = make_gamma_beta(96); + bencher.bench_synced(|| run::<3>(x.clone(), g.clone(), b.clone())); + } + + #[divan::bench] + fn c192_61x56(bencher: Bencher) { + let x = make_tensor_4d(1, 192, 61, 56); + let (g, b) = make_gamma_beta(192); + bencher.bench_synced(|| run::<3>(x.clone(), g.clone(), b.clone())); + } + + #[divan::bench] + fn c384_30x28(bencher: Bencher) { + let x = make_tensor_4d(1, 384, 30, 28); + let (g, b) = make_gamma_beta(384); + bencher.bench_synced(|| run::<3>(x.clone(), g.clone(), b.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/pool_ops.rs b/crates/burn-backend-tests/benches/pool_ops.rs new file mode 100644 index 0000000..d169b66 --- /dev/null +++ b/crates/burn-backend-tests/benches/pool_ops.rs @@ -0,0 +1,237 @@ +//! Benchmarks for pooling operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench pool_ops --features simd,rayon +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData, module}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Pooling Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_input_2d(batch: usize, channels: usize, height: usize, width: usize) -> Tensor<4> { + let data: Vec = (0..batch * channels * height * width) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, height, width]), + &Default::default(), + ) +} + +fn make_input_1d(batch: usize, channels: usize, length: usize) -> Tensor<3> { + let data: Vec = (0..batch * channels * length) + .map(|i| ((i % 1000) as f32 / 1000.0) - 0.5) + .collect(); + Tensor::from_data( + TensorData::new(data, [batch, channels, length]), + &Default::default(), + ) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "max_pool2d")] + mod max_pool2d { + use super::*; + + #[divan::bench] + fn max_pool2d_1x64x56x56_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(1, 64, 56, 56); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + + #[divan::bench] + fn max_pool2d_8x64x56x56_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(8, 64, 56, 56); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + + #[divan::bench] + fn max_pool2d_16x128x28x28_k2x2_s2(bencher: Bencher) { + let x = make_input_2d(16, 128, 28, 28); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [2, 2], [2, 2], [0, 0], [1, 1], false) + }); + } + + #[divan::bench] + fn max_pool2d_1x512x14x14_k2x2_s2(bencher: Bencher) { + let x = make_input_2d(1, 512, 14, 14); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [2, 2], [2, 2], [0, 0], [1, 1], false) + }); + } + } + + #[divan::bench_group(name = "max_pool2d_resnet")] + mod max_pool2d_resnet { + use super::*; + + #[divan::bench] + fn resnet_maxpool_1x64x112x112_k3x3_s2(bencher: Bencher) { + // ResNet initial max pool after first conv + let x = make_input_2d(1, 64, 112, 112); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + + #[divan::bench] + fn resnet_maxpool_8x64x112x112_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(8, 64, 112, 112); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + + #[divan::bench] + fn resnet_maxpool_16x64x112x112_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(16, 64, 112, 112); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + } + + #[divan::bench_group(name = "avg_pool2d")] + mod avg_pool2d { + use super::*; + + #[divan::bench] + fn avg_pool2d_1x64x56x56_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(1, 64, 56, 56); + bencher.bench_synced(|| { + module::avg_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], false, false) + }); + } + + #[divan::bench] + fn avg_pool2d_8x64x56x56_k3x3_s2(bencher: Bencher) { + let x = make_input_2d(8, 64, 56, 56); + bencher.bench_synced(|| { + module::avg_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], false, false) + }); + } + + #[divan::bench] + fn avg_pool2d_16x128x28x28_k2x2_s2(bencher: Bencher) { + let x = make_input_2d(16, 128, 28, 28); + bencher.bench_synced(|| { + module::avg_pool2d(x.clone(), [2, 2], [2, 2], [0, 0], false, false) + }); + } + } + + #[divan::bench_group(name = "adaptive_avg_pool2d")] + mod adaptive_avg_pool2d { + use super::*; + + #[divan::bench] + fn adaptive_avg_pool2d_1x512x7x7_to_1x1(bencher: Bencher) { + // Global average pooling (common in ResNet final layer) + let x = make_input_2d(1, 512, 7, 7); + bencher.bench_synced(|| module::adaptive_avg_pool2d(x.clone(), [1, 1])); + } + + #[divan::bench] + fn adaptive_avg_pool2d_8x512x7x7_to_1x1(bencher: Bencher) { + let x = make_input_2d(8, 512, 7, 7); + bencher.bench_synced(|| module::adaptive_avg_pool2d(x.clone(), [1, 1])); + } + + #[divan::bench] + fn adaptive_avg_pool2d_16x2048x7x7_to_1x1(bencher: Bencher) { + // ResNet-50/101/152 final layer + let x = make_input_2d(16, 2048, 7, 7); + bencher.bench_synced(|| module::adaptive_avg_pool2d(x.clone(), [1, 1])); + } + + #[divan::bench] + fn adaptive_avg_pool2d_1x256x56x56_to_7x7(bencher: Bencher) { + // Downsampling to fixed size + let x = make_input_2d(1, 256, 56, 56); + bencher.bench_synced(|| module::adaptive_avg_pool2d(x.clone(), [7, 7])); + } + } + + #[divan::bench_group(name = "max_pool1d")] + mod max_pool1d { + use super::*; + + #[divan::bench] + fn max_pool1d_1x64x256_k3_s2(bencher: Bencher) { + let x = make_input_1d(1, 64, 256); + bencher.bench_synced(|| module::max_pool1d(x.clone(), 3, 2, 1, 1, false)); + } + + #[divan::bench] + fn max_pool1d_8x128x512_k3_s2(bencher: Bencher) { + let x = make_input_1d(8, 128, 512); + bencher.bench_synced(|| module::max_pool1d(x.clone(), 3, 2, 1, 1, false)); + } + + #[divan::bench] + fn max_pool1d_16x256x1024_k3_s2(bencher: Bencher) { + let x = make_input_1d(16, 256, 1024); + bencher.bench_synced(|| module::max_pool1d(x.clone(), 3, 2, 1, 1, false)); + } + } + + #[divan::bench_group(name = "pool_kernel_sizes")] + mod pool_kernel_sizes { + use super::*; + + #[divan::bench] + fn max_pool2d_k2x2(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [2, 2], [2, 2], [0, 0], [1, 1], false) + }); + } + + #[divan::bench] + fn max_pool2d_k3x3(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [3, 3], [2, 2], [1, 1], [1, 1], false) + }); + } + + #[divan::bench] + fn max_pool2d_k5x5(bencher: Bencher) { + let x = make_input_2d(4, 64, 56, 56); + bencher.bench_synced(|| { + module::max_pool2d(x.clone(), [5, 5], [2, 2], [2, 2], [1, 1], false) + }); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/quantization_ops.rs b/crates/burn-backend-tests/benches/quantization_ops.rs new file mode 100644 index 0000000..5f65186 --- /dev/null +++ b/crates/burn-backend-tests/benches/quantization_ops.rs @@ -0,0 +1,341 @@ +//! Benchmarks for quantized tensor operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench quantization_ops +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Device, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Quantized ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +const SMALL: usize = 64 * 64; // 4K elements +const MEDIUM: usize = 256 * 256; // 64K elements +const LARGE: usize = 1024 * 1024; // 1M elements + +fn make_tensor(size: usize) -> Tensor<1> { + let data: Vec = (0..size) + .map(|i| (i % 1000) as f32 / 1000.0 - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_matrix(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0 - 0.5) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +// Returns None (and records a setup failure) on backends without quantization support, so the +// caller can fall back to a no-op bench instead of taking down the whole binary. +fn make_qtensor(size: usize) -> Option> { + common::try_setup(|| { + make_tensor(size).quantize_dynamic(&Device::default().settings().quantization.scheme) + }) +} + +fn make_qmatrix(rows: usize, cols: usize) -> Option> { + common::try_setup(|| { + make_matrix(rows, cols).quantize_dynamic(&Device::default().settings().quantization.scheme) + }) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "quantize")] + mod quantize { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let scheme = Device::default().settings().quantization.scheme; + bencher.bench_synced(|| make_tensor(SMALL).quantize_dynamic(&scheme)); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let scheme = Device::default().settings().quantization.scheme; + bencher.bench_synced(|| make_tensor(MEDIUM).quantize_dynamic(&scheme)); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let scheme = Device::default().settings().quantization.scheme; + bencher.bench_synced(|| make_tensor(LARGE).quantize_dynamic(&scheme)); + } + } + + #[divan::bench_group(name = "dequantize")] + mod dequantize { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let Some(qt) = make_qtensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().dequantize()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let Some(qt) = make_qtensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().dequantize()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(qt) = make_qtensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().dequantize()); + } + } + + #[divan::bench_group(name = "q_add")] + mod q_add { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let Some(a) = make_qtensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qtensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let Some(a) = make_qtensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qtensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(a) = make_qtensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qtensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone() + b.clone()); + } + } + + #[divan::bench_group(name = "q_matmul")] + mod q_matmul { + use super::*; + + #[divan::bench] + fn mat_64x64(bencher: Bencher) { + let Some(a) = make_qmatrix(64, 64) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qmatrix(64, 64) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn mat_256x256(bencher: Bencher) { + let Some(a) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + + #[divan::bench] + fn mat_512x512(bencher: Bencher) { + let Some(a) = make_qmatrix(512, 512) else { + bencher.bench(|| ()); + return; + }; + let Some(b) = make_qmatrix(512, 512) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| a.clone().matmul(b.clone())); + } + } + + #[divan::bench_group(name = "q_sum")] + mod q_sum { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let Some(qt) = make_qtensor(SMALL) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().sum()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let Some(qt) = make_qtensor(MEDIUM) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().sum()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let Some(qt) = make_qtensor(LARGE) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().sum()); + } + } + + #[divan::bench_group(name = "q_permute")] + mod q_permute { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let Some(qt) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().permute([1, 0])); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let Some(qt) = make_qmatrix(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().permute([1, 0])); + } + } + + #[divan::bench_group(name = "q_argmax")] + mod q_argmax { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let Some(qt) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().argmax(1)); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let Some(qt) = make_qmatrix(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().argmax(1)); + } + } + + #[divan::bench_group(name = "q_argmin")] + mod q_argmin { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let Some(qt) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().argmin(1)); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let Some(qt) = make_qmatrix(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + bencher.bench_synced(|| qt.clone().argmin(1)); + } + } + + #[divan::bench_group(name = "q_gather")] + mod q_gather { + use super::*; + use burn_tensor::Tensor; + + fn make_indices(rows: usize, cols: usize) -> Tensor<2, burn_tensor::Int> { + let data: Vec = (0..rows * cols).map(|i| (i % cols) as i32).collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) + } + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let Some(qt) = make_qmatrix(256, 256) else { + bencher.bench(|| ()); + return; + }; + let indices = make_indices(256, 256); + bencher.bench_synced(|| qt.clone().gather(1, indices.clone())); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let Some(qt) = make_qmatrix(1024, 1024) else { + bencher.bench(|| ()); + return; + }; + let indices = make_indices(1024, 1024); + bencher.bench_synced(|| qt.clone().gather(1, indices.clone())); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/reduce_ops.rs b/crates/burn-backend-tests/benches/reduce_ops.rs new file mode 100644 index 0000000..bdb0070 --- /dev/null +++ b/crates/burn-backend-tests/benches/reduce_ops.rs @@ -0,0 +1,279 @@ +//! Benchmarks for reduction operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench reduce_ops --features simd +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{FloatDType, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Reduction ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_tensor_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let size = d0 * d1 * d2; + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + // Sum all elements + #[divan::bench_group(name = "sum")] + mod sum { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().sum()); + } + + #[divan::bench] + fn s_64k(bencher: Bencher) { + let t = make_tensor_1d(64 * 1024); + bencher.bench_synced(|| t.clone().sum()); + } + + #[divan::bench] + fn s_1m(bencher: Bencher) { + let t = make_tensor_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().sum()); + } + } + + // Sum along dimension + #[divan::bench_group(name = "sum_dim")] + mod sum_dim { + use super::*; + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().sum_dim(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().sum_dim(1)); + } + + #[divan::bench] + fn s_1024x1024_dim0(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().sum_dim(0)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().sum_dim(1)); + } + } + + // Mean along dimension + #[divan::bench_group(name = "mean_dim")] + mod mean_dim { + use super::*; + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().mean_dim(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().mean_dim(1)); + } + } + + // Argmax + #[divan::bench_group(name = "argmax")] + mod argmax { + use super::*; + + #[divan::bench] + fn s_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().argmax(0)); + } + + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().argmax(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().argmax(1)); + } + } + + // Sum on transposed (non-contiguous) + #[divan::bench_group(name = "sum_transposed")] + mod sum_transposed { + use super::*; + + #[divan::bench] + fn s_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256).transpose(); + bencher.bench_synced(|| t.clone().sum()); + } + + #[divan::bench] + fn s_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024).transpose(); + bencher.bench_synced(|| t.clone().sum()); + } + } + + // Sum_dim on transposed tensor (tests dim_stride=1 optimization) + #[divan::bench_group(name = "sum_dim_transposed")] + mod sum_dim_transposed { + use super::*; + + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let t = make_tensor_2d(256, 256).transpose(); + bencher.bench_synced(|| t.clone().sum_dim(0)); + } + + #[divan::bench] + fn s_1024x1024_dim0(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024).transpose(); + bencher.bench_synced(|| t.clone().sum_dim(0)); + } + } + + // 3D reductions (batch-like) + #[divan::bench_group(name = "sum_3d")] + mod sum_3d { + use super::*; + + #[divan::bench] + fn s_batch32_256x256_dim2(bencher: Bencher) { + let t = make_tensor_3d(32, 256, 256); + bencher.bench_synced(|| t.clone().sum_dim(2)); + } + + #[divan::bench] + fn s_batch32_256x256_dim1(bencher: Bencher) { + let t = make_tensor_3d(32, 256, 256); + bencher.bench_synced(|| t.clone().sum_dim(1)); + } + } + } + }; +} + +bench_backend!(backend, "backend"); + +// ============================================================================ +// f16 mean benches +// +// These exercise the half-precision mean / mean_dim fast path. Each tensor +// is cast to f16 once during setup so the bench measures only the reduction, +// not the cast. Sizes mirror the f32 sum_dim / mean_dim groups so before/after +// numbers can be compared apples-to-apples. +// ============================================================================ + +#[divan::bench_group(name = "f16")] +mod f16 { + use super::*; + + fn make_f16_2d(rows: usize, cols: usize) -> Tensor<2> { + make_tensor_2d(rows, cols).cast(FloatDType::F16) + } + + fn make_f16_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + make_tensor_3d(d0, d1, d2).cast(FloatDType::F16) + } + + #[divan::bench_group(name = "mean")] + mod mean { + use super::*; + + #[divan::bench] + fn s_64k(bencher: Bencher) { + let t = make_f16_2d(256, 256); + bencher.bench_synced(|| t.clone().mean()); + } + + #[divan::bench] + fn s_1m(bencher: Bencher) { + let t = make_f16_2d(1024, 1024); + bencher.bench_synced(|| t.clone().mean()); + } + } + + #[divan::bench_group(name = "mean_dim")] + mod mean_dim { + use super::*; + + // Last-dim path (sum_rows_f32) + #[divan::bench] + fn s_256x256_dim1(bencher: Bencher) { + let t = make_f16_2d(256, 256); + bencher.bench_synced(|| t.clone().mean_dim(1)); + } + + #[divan::bench] + fn s_1024x1024_dim1(bencher: Bencher) { + let t = make_f16_2d(1024, 1024); + bencher.bench_synced(|| t.clone().mean_dim(1)); + } + + // First-dim path (scatter_add_f32) + #[divan::bench] + fn s_256x256_dim0(bencher: Bencher) { + let t = make_f16_2d(256, 256); + bencher.bench_synced(|| t.clone().mean_dim(0)); + } + + #[divan::bench] + fn s_1024x1024_dim0(bencher: Bencher) { + let t = make_f16_2d(1024, 1024); + bencher.bench_synced(|| t.clone().mean_dim(0)); + } + + // Middle-dim path (scatter_add_batched) + #[divan::bench] + fn s_batch32_256x256_dim1(bencher: Bencher) { + let t = make_f16_3d(32, 256, 256); + bencher.bench_synced(|| t.clone().mean_dim(1)); + } + } +} diff --git a/crates/burn-backend-tests/benches/slice_ops.rs b/crates/burn-backend-tests/benches/slice_ops.rs new file mode 100644 index 0000000..63aa4ea --- /dev/null +++ b/crates/burn-backend-tests/benches/slice_ops.rs @@ -0,0 +1,240 @@ +//! Benchmarks for slice operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench slice_ops --features simd +//! ``` +//! +//! Memory allocation tracking is enabled via divan's AllocProfiler. + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData, s}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Slice Operations Benchmarks"); + println!("Memory allocation tracking enabled"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_1d(size: usize) -> Tensor<1> { + let data: Vec = (0..size).map(|i| (i % 1000) as f32 / 1000.0).collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +fn make_tensor_3d(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let data: Vec = (0..d0 * d1 * d2) + .map(|i| (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + + + // Basic slice (contiguous, step=1) + #[divan::bench_group(name = "basic_slice")] + mod basic_slice { + use super::*; + + #[divan::bench] + fn slice_1d_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().slice([256..768])); + } + + #[divan::bench] + fn slice_1d_1m(bencher: Bencher) { + let t = make_tensor_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().slice([256 * 1024..768 * 1024])); + } + + #[divan::bench] + fn slice_2d_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().slice([64..192, 64..192])); + } + + #[divan::bench] + fn slice_2d_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().slice([256..768, 256..768])); + } + + #[divan::bench] + fn slice_3d_64x64x64(bencher: Bencher) { + let t = make_tensor_3d(64, 64, 64); + bencher.bench_synced(|| t.clone().slice([16..48, 16..48, 16..48])); + } + } + + // Slice with step > 1 + #[divan::bench_group(name = "slice_with_step")] + mod slice_with_step { + use super::*; + + #[divan::bench] + fn step2_1d_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().slice(s![0..1024;2])); + } + + #[divan::bench] + fn step2_1d_1m(bencher: Bencher) { + let t = make_tensor_1d(1024 * 1024); + bencher.bench_synced(|| t.clone().slice(s![0..1024 * 1024;2])); + } + + #[divan::bench] + fn step4_2d_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().slice(s![0..256;4, 0..256;4])); + } + + #[divan::bench] + fn step2_2d_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().slice(s![0..1024;2, 0..1024;2])); + } + } + + // Slice on transposed tensor (non-contiguous) + #[divan::bench_group(name = "slice_transposed")] + mod slice_transposed { + use super::*; + + #[divan::bench] + fn transposed_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256).transpose(); + bencher.bench_synced(|| t.clone().slice([64..192, 64..192])); + } + + #[divan::bench] + fn transposed_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024).transpose(); + bencher.bench_synced(|| t.clone().slice([256..768, 256..768])); + } + } + + // Slice assign + #[divan::bench_group(name = "slice_assign")] + mod slice_assign { + use super::*; + + #[divan::bench] + fn assign_1d_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + let v = make_tensor_1d(512); + bencher.bench_synced(|| t.clone().slice_assign([256..768], v.clone())); + } + + #[divan::bench] + fn assign_2d_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + let v = make_tensor_2d(128, 128); + bencher.bench_synced(|| t.clone().slice_assign([64..192, 64..192], v.clone())); + } + + #[divan::bench] + fn assign_2d_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + let v = make_tensor_2d(512, 512); + bencher.bench_synced(|| t.clone().slice_assign([256..768, 256..768], v.clone())); + } + } + + // Slice fill (scalar fill over a slice region) + // + // This is the pattern the user hit in issue #64 item 3: + // `tensor.slice_fill(slice, scalar)`. burn-tensor's default impl + // allocates a 1-element tensor, calls expand() to broadcast it + // over the slice shape, and then hands that view to + // slice_assign. A naive backend materializes the broadcast (one + // full alloc + fill) before doing the actual strided write, so + // the slice fill ends up doing ~3x the memory traffic it should. + #[divan::bench_group(name = "slice_fill")] + mod slice_fill { + use super::*; + + #[divan::bench] + fn fill_1d_512_of_1k(bencher: Bencher) { + let t = make_tensor_1d(1024); + bencher.bench_synced(|| t.clone().slice_fill([256..768], 1.0f32)); + } + + #[divan::bench] + fn fill_2d_128x128_of_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().slice_fill([64..192, 64..192], 1.0f32)); + } + + #[divan::bench] + fn fill_2d_512x512_of_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().slice_fill([256..768, 256..768], 1.0f32)); + } + + // Segmentation-model-sized: 488x448 feature map, half the + // spatial extent. + #[divan::bench] + fn fill_2d_244x224_of_488x448(bencher: Bencher) { + let t = make_tensor_2d(488, 448); + bencher.bench_synced(|| t.clone().slice_fill([122..366, 112..336], 0.0f32)); + } + + // 3D feature map (B=1,C=64,H,W) half-spatial fill. + #[divan::bench] + fn fill_3d_64x64x64_of_64x128x128(bencher: Bencher) { + let t = make_tensor_3d(64, 128, 128); + bencher.bench_synced(|| t.clone().slice_fill([0..64, 32..96, 32..96], 0.0f32)); + } + } + + // Narrow (single dimension slice) + #[divan::bench_group(name = "narrow")] + mod narrow { + use super::*; + + #[divan::bench] + fn narrow_dim0_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().narrow(0, 64, 128)); + } + + #[divan::bench] + fn narrow_dim1_256x256(bencher: Bencher) { + let t = make_tensor_2d(256, 256); + bencher.bench_synced(|| t.clone().narrow(1, 64, 128)); + } + + #[divan::bench] + fn narrow_dim0_1024x1024(bencher: Bencher) { + let t = make_tensor_2d(1024, 1024); + bencher.bench_synced(|| t.clone().narrow(0, 256, 512)); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/softmax_ops.rs b/crates/burn-backend-tests/benches/softmax_ops.rs new file mode 100644 index 0000000..ff266fc --- /dev/null +++ b/crates/burn-backend-tests/benches/softmax_ops.rs @@ -0,0 +1,98 @@ +//! Benchmarks comparing `B::softmax` against a manual decomposition baseline. +//! Covers last-axis and non-last-axis cases. +//! +//! Run with: +//! ```bash +//! cargo bench --bench softmax_ops --features simd,rayon +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Element, Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Softmax Benchmarks: fused (via B::softmax) vs decomposed"); + println!(); + divan::main(); + common::report_failures(); +} + +fn make_tensor_3d>(d0: usize, d1: usize, d2: usize) -> Tensor<3> { + let data: Vec = (0..d0 * d1 * d2) + .map(|i| E::from((((i % 997) as f32) / 997.0) - 0.5)) + .collect(); + Tensor::from_data(TensorData::new(data, [d0, d1, d2]), &Default::default()) +} + +/// `softmax(x, dim) = exp(x - max) / sum(exp(x - max))`, matching the +/// `ActivationOps::softmax` default in burn-backend. +fn decomposed_softmax(x: Tensor, dim: usize) -> Tensor { + let max = x.clone().detach().max_dim(dim); + let shifted = x - max; + let exp = shifted.exp(); + let sum = exp.clone().sum_dim(dim); + exp / sum +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "last_axis_f32")] + mod last_axis_f32 { + use super::*; + + #[divan::bench] + fn trait_hook_bert_seq512_h12(bencher: Bencher) { + let x = make_tensor_3d::(12, 512, 512); + bencher.bench_synced(|| burn_tensor::activation::softmax(x.clone(), 2)); + } + + #[divan::bench] + fn decomposed_bert_seq512_h12(bencher: Bencher) { + let x = make_tensor_3d::(12, 512, 512); + bencher.bench_synced(|| decomposed_softmax(x.clone(), 2)); + } + + #[divan::bench] + fn trait_hook_wide_d2048(bencher: Bencher) { + let x = make_tensor_3d::(2, 256, 2048); + bencher.bench_synced(|| burn_tensor::activation::softmax(x.clone(), 2)); + } + + #[divan::bench] + fn decomposed_wide_d2048(bencher: Bencher) { + let x = make_tensor_3d::(2, 256, 2048); + bencher.bench_synced(|| decomposed_softmax(x.clone(), 2)); + } + } + + #[divan::bench_group(name = "non_last_axis_f32")] + mod non_last_axis_f32 { + use super::*; + + #[divan::bench] + fn trait_hook_dim1_of_3(bencher: Bencher) { + let x = make_tensor_3d::(4, 1024, 512); + bencher.bench_synced(|| burn_tensor::activation::softmax(x.clone(), 1)); + } + + #[divan::bench] + fn decomposed_dim1_of_3(bencher: Bencher) { + let x = make_tensor_3d::(4, 1024, 512); + bencher.bench_synced(|| decomposed_softmax(x.clone(), 1)); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/benches/unary_ops.rs b/crates/burn-backend-tests/benches/unary_ops.rs new file mode 100644 index 0000000..7aa58a4 --- /dev/null +++ b/crates/burn-backend-tests/benches/unary_ops.rs @@ -0,0 +1,225 @@ +//! Benchmarks for unary operations. +//! +//! Run with: +//! ```bash +//! cargo bench --bench unary_ops --features simd +//! ``` + +#[path = "common/mod.rs"] +mod common; +use common::BencherExt; + +use burn_tensor::{Tensor, TensorData}; +use divan::{AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + println!("Unary ops Benchmarks"); + println!(); + divan::main(); + common::report_failures(); +} + +const SMALL: usize = 64 * 64; // 4K elements +const MEDIUM: usize = 256 * 256; // 64K elements +const LARGE: usize = 1024 * 1024; // 1M elements + +fn make_tensor(size: usize) -> Tensor<1> { + // Use values in range [0.1, 1.1] to avoid domain issues for log/sqrt + let data: Vec = (0..size) + .map(|i| 0.1 + (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [size]), &Default::default()) +} + +fn make_tensor_2d(rows: usize, cols: usize) -> Tensor<2> { + let data: Vec = (0..rows * cols) + .map(|i| 0.1 + (i % 1000) as f32 / 1000.0) + .collect(); + Tensor::from_data(TensorData::new(data, [rows, cols]), &Default::default()) +} + +macro_rules! bench_backend { + ($mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name)] + mod $mod_name { + use super::*; + + #[divan::bench_group(name = "exp")] + mod exp { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().exp()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().exp()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().exp()); + } + } + + #[divan::bench_group(name = "log")] + mod log { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().log()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().log()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().log()); + } + } + + #[divan::bench_group(name = "sqrt")] + mod sqrt { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().sqrt()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().sqrt()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().sqrt()); + } + } + + #[divan::bench_group(name = "sin")] + mod sin { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().sin()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().sin()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().sin()); + } + } + + #[divan::bench_group(name = "cos")] + mod cos { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().cos()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().cos()); + } + } + + #[divan::bench_group(name = "tanh")] + mod tanh { + use super::*; + + #[divan::bench] + fn small(bencher: Bencher) { + let a = make_tensor(SMALL); + bencher.bench_synced(|| a.clone().tanh()); + } + + #[divan::bench] + fn medium(bencher: Bencher) { + let a = make_tensor(MEDIUM); + bencher.bench_synced(|| a.clone().tanh()); + } + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().tanh()); + } + } + + #[divan::bench_group(name = "abs")] + mod abs { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().abs()); + } + } + + #[divan::bench_group(name = "recip")] + mod recip { + use super::*; + + #[divan::bench] + fn large(bencher: Bencher) { + let a = make_tensor(LARGE); + bencher.bench_synced(|| a.clone().recip()); + } + } + + // Non-contiguous (transposed) + #[divan::bench_group(name = "exp_transposed")] + mod exp_transposed { + use super::*; + + #[divan::bench] + fn medium_256x256(bencher: Bencher) { + let a = make_tensor_2d(256, 256).transpose(); + bencher.bench_synced(|| a.clone().exp()); + } + + #[divan::bench] + fn large_1024x1024(bencher: Bencher) { + let a = make_tensor_2d(1024, 1024).transpose(); + bencher.bench_synced(|| a.clone().exp()); + } + } + } + }; +} + +bench_backend!(backend, "backend"); diff --git a/crates/burn-backend-tests/cubecl.toml b/crates/burn-backend-tests/cubecl.toml new file mode 100644 index 0000000..f1f12cc --- /dev/null +++ b/crates/burn-backend-tests/cubecl.toml @@ -0,0 +1,15 @@ +[profiling] +logger = { file = "target/profiling.log", level = "disabled" } + +[autotune] +logger = { file = "target/autotune.log", level = "disabled" } + +[compilation] +check_mode = "validate" +logger = { file = "target/compilation.log", level = "disabled" } + +[memory] +logger = { file = "target/memory.log", level = "disabled" } + +[streaming] +max_streams = 4 diff --git a/crates/burn-backend-tests/src/lib.rs b/crates/burn-backend-tests/src/lib.rs new file mode 100644 index 0000000..997f018 --- /dev/null +++ b/crates/burn-backend-tests/src/lib.rs @@ -0,0 +1,157 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote}; + +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{Attribute, Expr, ItemFn, Lit, Meta, MetaNameValue, parse_macro_input}; + +// Define a structure to parse the attribute arguments +struct AttributeArgs { + args: Punctuated, +} + +impl Parse for AttributeArgs { + fn parse(input: ParseStream) -> syn::Result { + Ok(AttributeArgs { + args: Punctuated::parse_terminated(input)?, + }) + } +} + +#[allow(clippy::test_attr_in_doctest)] +/// **This is only meaningful when the `reason` is specific and clear.** +/// +/// A proc macro attribute that adds panic handling to test functions. +/// +/// # Usage +/// ```rust, ignore +/// #[might_panic(reason = "expected panic message prefix")] +/// #[test] +/// fn test_that_might_panic() { +/// // test code that might panic (with acceptable reason) +/// } +/// ``` +/// +/// # Behavior +/// - If the test does not panic, it passes. +/// - If the test panics with a message starting with the expected prefix, the failure is ignored. +/// - If the test panics with a different message, the test fails. +/// +/// # Note +/// This proc macro uses [`std::panic::catch_unwind`]. As such, it does not work in a no-std environment. +/// Make sure it is feature gated when an `"std"` feature is available. +#[proc_macro_attribute] +pub fn might_panic(args: TokenStream, input: TokenStream) -> TokenStream { + // Parse the attribute arguments + let args = parse_macro_input!(args as AttributeArgs); + let input_fn = parse_macro_input!(input as ItemFn); + + // Extract the expected panic reason + let mut expected_reason = None; + for arg in args.args.iter() { + if let Meta::NameValue(MetaNameValue { path, value, .. }) = arg + && path.is_ident("reason") + && let Expr::Lit(lit) = value + && let Lit::Str(ref lit_str) = lit.lit + { + expected_reason = Some(lit_str.value()); + } + } + + let expected_reason = match expected_reason { + Some(reason) => reason, + None => { + return syn::Error::new( + proc_macro2::Span::call_site(), + "The #[might_panic] attribute requires a 'reason' parameter", + ) + .to_compile_error() + .into(); + } + }; + + let fn_name = &input_fn.sig.ident; + let fn_vis = &input_fn.vis; + let fn_generics = &input_fn.sig.generics; + let fn_block = &input_fn.block; + let fn_attrs = input_fn + .attrs + .iter() + .filter(|attr| !attr.path().is_ident("test")) + .collect::>(); + + // Create a wrapped test function + let wrapper_name = format_ident!("{}_might_panic", fn_name); + + quote! { + #(#fn_attrs)* + #fn_vis fn #fn_name #fn_generics() { #fn_block } + + #[test] + #fn_vis fn #wrapper_name #fn_generics() { + use std::panic::{self, AssertUnwindSafe}; + use std::sync::{Arc, Mutex, OnceLock}; + + let get_msg = |p: &(dyn std::any::Any + Send)| -> String { + p.downcast_ref::().cloned() + .or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "Unknown panic".to_string()) + }; + + // An append-only list of all panic messages across the entire process. + // This is required because cubecl's `CallError` hides the original panic message + // occurring in the device threads. + // + // A global log also prevents parallel tests from overwriting each other's panic hooks. + static PANIC_LOG: OnceLock>> = OnceLock::new(); + let log = PANIC_LOG.get_or_init(|| Mutex::new(Vec::new())); + + static HOOK: OnceLock<()> = OnceLock::new(); + HOOK.get_or_init(|| { + let prev = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if let Ok(mut v) = log.lock() { + v.push(get_msg(info.payload())); + } + prev(info); + })); + }); + + // We only care about panics that occur during this test's execution window, so + // we start at the number of panics logged before this test starts. + let start_idx = log.lock().unwrap().len(); + let result = panic::catch_unwind(AssertUnwindSafe(|| #fn_name())); + + if let Err(e) = result { + let main_msg = get_msg(&*e); + let panic_logs = log.lock().unwrap(); + let window = &panic_logs[start_idx..]; + + let matched = window.iter().chain(std::iter::once(&main_msg)) + .any(|m| m.contains(#expected_reason)); + + let all = window.iter().chain(std::iter::once(&main_msg)) + .map(|m| format!("- {m}")).collect::>().join("\n"); + + if matched { + eprintln!( + "\n[SKIPPED - might_panic] Test '{}'\nReason: '{}'\nPanics:\n{}\n", + stringify!(#fn_name), + #expected_reason, + all + ); + return; + } else { + panic!( + "\nTest '{}' failed.\nExpected: '{}'\nFound:\n{}\n", + stringify!(#fn_name), + #expected_reason, + all + ); + } + } + } + } + .into() +} diff --git a/crates/burn-backend-tests/tests/autodiff.rs b/crates/burn-backend-tests/tests/autodiff.rs new file mode 100644 index 0000000..e4de92d --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff.rs @@ -0,0 +1,18 @@ +//! Burn autodiff tests. + +#![recursion_limit = "256"] +#![allow(clippy::single_range_in_vec_init, clippy::duplicate_mod)] + +extern crate alloc; + +pub type FloatElem = f32; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +#[allow(clippy::module_inception)] +#[path = "common/autodiff.rs"] +mod autodiff; diff --git a/crates/burn-backend-tests/tests/autodiff/abs.rs b/crates/burn-backend-tests/tests/autodiff/abs.rs new file mode 100644 index 0000000..a1160d5 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/abs.rs @@ -0,0 +1,58 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_abs() { + let data_1 = TensorData::from([[0.0, -1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, -10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().abs()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[71.0, 107.0], [71.0, 107.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[84.0, 42.0], [90.0, 54.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_abs_no_nans() { + let data_1 = TensorData::from([[6.0, 7.0], [9.0, -10.0]]); + let data_2 = TensorData::from([[0.0, -1.0], [3.0, 4.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().abs()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[1.0, 7.0], [1.0, 7.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[0.0, -15.0], [-3.0, -3.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let contains_nan = grad_2.contains_nan(); + assert!(!contains_nan.into_scalar::()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool1d.rs b/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool1d.rs new file mode 100644 index 0000000..88b4f10 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool1d.rs @@ -0,0 +1,50 @@ +use super::*; +use burn_tensor::module::adaptive_avg_pool1d; +use burn_tensor::{Shape, Tolerance}; + +#[test] +fn test_avg_pool1d_simple() { + let test = AdaptiveAvgPool1dTestCase { + batch_size: 1, + channels: 2, + length: 5, + output_size: 3, + }; + + test.assert_output(TestTensor::from_data( + [[ + [0.5000, 0.83333, 0.33333, 0.83333, 0.5000], + [0.5000, 0.83333, 0.33333, 0.83333, 0.5000], + ]], + &AutodiffDevice::new(), + )); +} + +struct AdaptiveAvgPool1dTestCase { + batch_size: usize, + channels: usize, + length: usize, + output_size: usize, +} + +impl AdaptiveAvgPool1dTestCase { + fn assert_output(self, x_grad: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.length]); + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = adaptive_avg_pool1d(x.clone(), self.output_size); + let grads = output.backward(); + let x_grad_actual = x.grad(&grads).unwrap(); + + x_grad.to_data().assert_approx_eq::( + &x_grad_actual.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool2d.rs b/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool2d.rs new file mode 100644 index 0000000..2bd3219 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/adaptive_avgpool2d.rs @@ -0,0 +1,96 @@ +use super::*; +use burn_tensor::module::adaptive_avg_pool2d; +use burn_tensor::{Shape, Tolerance}; + +#[test] +fn test_avg_pool2d_simple() { + let test = AdaptiveAvgPool2dTestCase { + batch_size: 1, + channels: 2, + height: 5, + width: 3, + output_size_1: 3, + output_size_2: 2, + }; + + test.assert_output(TestTensor::from_data( + [[ + [ + [0.2500, 0.5000, 0.2500], + [0.41667, 0.83333, 0.41667], + [0.16667, 0.33333, 0.16667], + [0.41667, 0.83333, 0.41667], + [0.2500, 0.5000, 0.2500], + ], + [ + [0.2500, 0.5000, 0.2500], + [0.41667, 0.83333, 0.41667], + [0.16667, 0.33333, 0.16667], + [0.41667, 0.83333, 0.41667], + [0.2500, 0.5000, 0.2500], + ], + ]], + &AutodiffDevice::new(), + )); +} + +#[test] +fn test_avg_pool2d_output_1() { + let test = AdaptiveAvgPool2dTestCase { + batch_size: 1, + channels: 1, + height: 4, + width: 8, + output_size_1: 1, + output_size_2: 1, + }; + + test.assert_output(TestTensor::from_data( + [[[ + [ + 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, + ], + [ + 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, + ], + [ + 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, + ], + [ + 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, 0.03125, + ], + ]]], + &AutodiffDevice::new(), + )); +} + +struct AdaptiveAvgPool2dTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + output_size_1: usize, + output_size_2: usize, +} + +impl AdaptiveAvgPool2dTestCase { + fn assert_output(self, x_grad: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = adaptive_avg_pool2d(x.clone(), [self.output_size_1, self.output_size_2]); + let grads = output.backward(); + let x_grad_actual = x.grad(&grads).unwrap(); + + x_grad.to_data().assert_approx_eq::( + &x_grad_actual.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/add.rs b/crates/burn-backend-tests/tests/autodiff/add.rs new file mode 100644 index 0000000..60d3af8 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/add.rs @@ -0,0 +1,74 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_add() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data([2.0, 5.0], &device).require_grad(); + let tensor_2 = TestTensor::from_data([4.0, 1.0], &device).require_grad(); + + let tensor_3 = tensor_1.clone() + tensor_2.clone(); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); + tensor_3 + .to_data() + .assert_eq(&TensorData::from([6.0, 6.0]), false); +} + +#[test] +fn should_diff_add_scalar() { + let data = TensorData::from([2.0, 10.0]); + + let tensor = TestTensor::<1>::from_data(data, &AutodiffDevice::new()).require_grad(); + let tensor_out = tensor.clone().add_scalar(5.0); + let grads = tensor_out.backward(); + + let grad = tensor.grad(&grads).unwrap(); + + grad.to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); + tensor_out + .into_data() + .assert_eq(&TensorData::from([7.0, 15.0]), false); +} + +#[test] +fn test_add_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().add(tensor_2.clone()); + let tensor_5 = tensor_4 + .add(tensor_3) + .add_scalar(5.0) + .add(tensor_1.clone()) + .add(tensor_2.clone()); + let tensor_6 = tensor_1.clone().add(tensor_5); + + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[3.0, 3.0], [3.0, 3.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[2.0, 2.0], [2.0, 2.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/aggregation.rs b/crates/burn-backend-tests/tests/autodiff/aggregation.rs new file mode 100644 index 0000000..5ad1e65 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/aggregation.rs @@ -0,0 +1,261 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_mean() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.mean().unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[3.5, 9.5], [3.5, 9.5]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[-0.75, -0.75], [3.0, 3.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_sum_1() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.sum().unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[14.0, 38.0], [14.0, 38.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[-3.0, -3.0], [12.0, 12.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_sum_2() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.clone().sum_dim(1); + let tensor_5 = tensor_4.mul(tensor_3); + + let grads = tensor_5.sum().backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[494.0, 722.0], [2990.0, 4370.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[690.0, 690.0], [958.0, 958.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_mean_dim() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.mean_dim(1).unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[4.0, 36.0], [3.0, -17.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[9.0, 9.0], [35.5, 35.5]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_sum_dim() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.sum_dim(1).unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[8.0, 72.0], [6.0, -34.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[18.0, 18.0], [71.0, 71.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_prod() { + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().prod(); + let grads = output.backward(); + let grad = tensor.grad(&grads).unwrap(); + + // grad_i = prod(x) / x_i = [24/2, 24/3, 24/4] + let expected = TensorData::from([12.0, 8.0, 6.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_prod_with_negatives() { + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, -3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().prod(); + + // The forward value keeps its sign. The previous default impl computed + // exp(sum(log(x))), which returns NaN for any negative input. + output + .to_data() + .assert_approx_eq::(&TensorData::from([-24.0]), Tolerance::default()); + + let grads = output.backward(); + let grad = tensor.grad(&grads).unwrap(); + + // grad_i = prod(x) / x_i = [-24/2, -24/-3, -24/4] + let expected = TensorData::from([-12.0, 8.0, -6.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_prod_dim() { + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().prod_dim(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // Per row: grad_ij = prod(row_i) / x_ij + let expected = TensorData::from([[6.0, 3.0, 2.0], [30.0, 24.0, 20.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_prod_dim_with_negatives() { + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1.0, -2.0, 3.0], [-4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().prod_dim(0); + + output.clone().to_data().assert_approx_eq::( + &TensorData::from([[-4.0, -10.0, 18.0]]), + Tolerance::default(), + ); + + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // Per column: grad_ij = prod(col_j) / x_ij + let expected = TensorData::from([[-4.0, 5.0, 6.0], [1.0, -2.0, 3.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +// The following tests are ignored due to the same limitation as cumprod: the +// gradient divides by the input, which produces NaN when the input contains +// zeros. The true gradient at a zero is finite (the product of the other +// elements), but recovering it needs the zero-safe exclusive-cumulative-product +// algorithm tracked in https://github.com/tracel-ai/burn/issues/3864. + +#[test] +#[ignore = "prod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_prod_single_zero() { + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 0.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().prod(); + let grads = output.backward(); + let grad = tensor.grad(&grads).unwrap(); + + // Only the zero slot has a non-zero gradient (product of the others = 24). + let expected = TensorData::from([0.0, 24.0, 0.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[ignore = "prod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_prod_multiple_zeros() { + let device = AutodiffDevice::new(); + let tensor = TestTensor::<1>::from_data(TensorData::from([2.0, 0.0, 3.0, 0.0, 5.0]), &device) + .require_grad(); + + let output = tensor.clone().prod(); + let grads = output.backward(); + let grad = tensor.grad(&grads).unwrap(); + + // Every leave-one-out product still contains a zero, so the gradient is zero. + let expected = TensorData::from([0.0, 0.0, 0.0, 0.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/all_reduce.rs b/crates/burn-backend-tests/tests/autodiff/all_reduce.rs new file mode 100644 index 0000000..17f09df --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/all_reduce.rs @@ -0,0 +1,166 @@ +use super::*; +use burn_tensor::{ + Device, DeviceType, TensorData, + distributed::{DistributedConfig, DistributedContext, ReduceOperation}, +}; +use serial_test::serial; + +// TODO + +#[test] +#[serial] +fn should_diff_all_reduce_sum() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + if devices.len() < 2 { + return; + } + let (device_0, device_1) = (devices[0].clone(), devices[1].clone()); + + let in_tensor_0 = TestTensor::<1>::from_data([2.0, 5.0], &device_0).require_grad(); + let in_tensor_1 = TestTensor::<1>::from_data([4.0, 1.0], &device_1).require_grad(); + + let (output, grads) = compute_gradients( + vec![in_tensor_0, in_tensor_1], + ReduceOperation::Sum, + vec![device_0, device_1], + ); + compare_gradients(output, grads, &[6.0, 6.0], &[2.0, 2.0]); +} + +#[test] +#[serial] +fn should_diff_all_reduce_mean() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + if devices.len() < 2 { + return; + } + let (device_0, device_1) = (devices[0].clone(), devices[1].clone()); + + let in_tensor_0 = TestTensor::<1>::from_data([2.0, 5.0], &device_0).require_grad(); + let in_tensor_1 = TestTensor::<1>::from_data([4.0, 1.0], &device_1).require_grad(); + + let (output, grads) = compute_gradients( + vec![in_tensor_0, in_tensor_1], + ReduceOperation::Mean, + vec![device_0, device_1], + ); + compare_gradients(output, grads, &[3.0, 3.0], &[1.0, 1.0]); +} + +#[test] +#[serial] +fn should_diff_all_reduce_complex_1() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + if devices.len() < 2 { + return; + } + let (device_0, device_1) = (devices[0].clone(), devices[1].clone()); + + let in_tensor_0 = TestTensor::<1>::from_data([2.0, 5.0], &device_0).require_grad(); + let in_tensor_1 = TestTensor::<1>::from_data([4.0, 1.0], &device_1).require_grad(); + + let config = DistributedConfig { + all_reduce_op: ReduceOperation::Sum, + }; + let _context = DistributedContext::init(vec![device_0.clone(), device_1.clone()], config); + let [out_tensor_0, out_tensor_1] = &compute_all_reduce( + vec![in_tensor_0.clone(), in_tensor_1.clone()], + ReduceOperation::Sum, + vec![device_0, device_1], + )[..] else { + panic!("should have 2 tensors") + }; + + let out_tensor_1 = out_tensor_1.clone().mul_scalar(4.0); + + let grads_0 = out_tensor_0.backward(); + let grads_1 = out_tensor_1.backward(); + + let grad_0 = in_tensor_0.grad(&grads_0).unwrap(); + let grad_1 = in_tensor_1.grad(&grads_1).unwrap(); + + out_tensor_0.device().sync().unwrap(); + + out_tensor_0 + .to_data() + .assert_eq(&TensorData::from([6.0, 6.0]), false); + out_tensor_1 + .to_data() + .assert_eq(&TensorData::from([24.0, 24.0]), false); + grad_0 + .to_data() + .assert_eq(&TensorData::from([5.0, 5.0]), false); + grad_1 + .to_data() + .assert_eq(&TensorData::from([5.0, 5.0]), false); +} + +#[test] +#[serial] +fn should_diff_all_reduce_all_devices() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + + let input = devices + .iter() + .enumerate() + .map(|(i, device)| { + let elem = i as f32; + TestTensor::<1>::from_data([elem, elem], device).require_grad() + }) + .collect(); + + let value: f32 = devices.iter().enumerate().map(|(i, _)| i as f32).sum(); + let grad_value = devices.len() as f32; + let (output, grads) = compute_gradients(input, ReduceOperation::Sum, devices); + compare_gradients(output, grads, &[value, value], &[grad_value, grad_value]); +} + +fn compare_gradients( + outputs: Vec>, + grads: Vec>, + expected_output: &[f32], + expected_grads: &[f32], +) { + for out in outputs { + out.to_data() + .assert_eq(&TensorData::from(expected_output), false); + } + for grad in grads { + grad.to_data() + .assert_eq(&TensorData::from(expected_grads), false); + } +} + +fn compute_gradients( + tensors: Vec>, + op: ReduceOperation, + devices: Vec, +) -> (Vec>, Vec>) { + let config = DistributedConfig { all_reduce_op: op }; + let _context = DistributedContext::init(devices.clone(), config); + + let out = compute_all_reduce(tensors.clone(), op, devices); + + let mut all_grads = vec![]; + for (in_tensor, out_tensor) in tensors.iter().zip(out.clone()) { + let grads = out_tensor.backward(); + all_grads.push(in_tensor.grad(&grads).unwrap()); + } + + (out, all_grads) +} + +fn compute_all_reduce( + tensors: Vec>, + op: ReduceOperation, + devices: Vec, +) -> Vec> { + let mut out = vec![]; + for tensor in tensors.clone() { + let out_tensor = burn_tensor::module::all_reduce(tensor, op, devices.clone()); + let out_tensor = out_tensor.resolve(); + out.push(out_tensor); + } + + out +} diff --git a/crates/burn-backend-tests/tests/autodiff/avgpool1d.rs b/crates/burn-backend-tests/tests/autodiff/avgpool1d.rs new file mode 100644 index 0000000..5ab9911 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/avgpool1d.rs @@ -0,0 +1,102 @@ +use super::*; +use burn_tensor::module::avg_pool1d; +use burn_tensor::{Shape, Tolerance}; + +#[test] +fn test_avg_pool1d_simple() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 1, + kernel_size: 3, + padding: 0, + stride: 1, + length: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from_data( + [[[0.33333, 0.66667, 1.0000, 1.0000, 0.66667, 0.33333]]], + &AutodiffDevice::new(), + )); +} + +#[test] +fn test_avg_pool1d_complex() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 2, + kernel_size: 3, + padding: 1, + stride: 2, + length: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from_data( + [[ + [0.33333, 0.66667, 0.33333, 0.66667, 0.33333, 0.33333], + [0.33333, 0.66667, 0.33333, 0.66667, 0.33333, 0.33333], + ]], + &AutodiffDevice::new(), + )); +} + +#[test] +fn test_avg_pool1d_complex_dont_count_pad() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 2, + kernel_size: 3, + padding: 1, + stride: 2, + length: 6, + count_include_pad: false, + }; + + test.assert_output(TestTensor::from_data( + [[ + [0.5000, 0.83333, 0.33333, 0.66667, 0.33333, 0.33333], + [0.5000, 0.83333, 0.33333, 0.66667, 0.33333, 0.33333], + ]], + &AutodiffDevice::new(), + )); +} + +struct AvgPool1dTestCase { + batch_size: usize, + channels: usize, + kernel_size: usize, + padding: usize, + stride: usize, + length: usize, + count_include_pad: bool, +} + +impl AvgPool1dTestCase { + fn assert_output(self, x_grad: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.length]); + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = avg_pool1d( + x.clone(), + self.kernel_size, + self.stride, + self.padding, + self.count_include_pad, + false, + ); + let grads = output.backward(); + let x_grad_actual = x.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + x_grad + .to_data() + .assert_approx_eq::(&x_grad_actual.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/avgpool2d.rs b/crates/burn-backend-tests/tests/autodiff/avgpool2d.rs new file mode 100644 index 0000000..bad5234 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/avgpool2d.rs @@ -0,0 +1,129 @@ +use super::*; +use burn_tensor::module::avg_pool2d; +use burn_tensor::{Shape, Tolerance}; + +#[test] +fn test_avg_pool2d_simple() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + height: 6, + width: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from_data( + [[[ + [0.11111, 0.22222, 0.33333, 0.33333, 0.22222, 0.11111], + [0.22222, 0.44444, 0.66667, 0.66667, 0.44444, 0.22222], + [0.33333, 0.66667, 1.00000, 1.00000, 0.66667, 0.33333], + [0.33333, 0.66667, 1.00000, 1.00000, 0.66667, 0.33333], + [0.22222, 0.44444, 0.66667, 0.66667, 0.44444, 0.22222], + [0.11111, 0.22222, 0.33333, 0.33333, 0.22222, 0.11111], + ]]], + &AutodiffDevice::new(), + )); +} + +#[test] +fn test_avg_pool2d_complex() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 2, + height: 4, + width: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from_data( + [[[ + [0.33333, 0.33333, 0.33333, 0.33333, 0.33333, 0.33333], + [0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000], + [0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000], + [0.33333, 0.33333, 0.33333, 0.33333, 0.33333, 0.33333], + ]]], + &AutodiffDevice::new(), + )); +} + +#[test] +fn test_avg_pool2d_complex_dont_include_pad() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 2, + height: 4, + width: 6, + count_include_pad: false, + }; + + test.assert_output(TestTensor::from_data( + [[[ + [0.6250, 0.6250, 0.41667, 0.41667, 0.6250, 0.6250], + [0.8750, 0.8750, 0.58333, 0.58333, 0.8750, 0.8750], + [0.8750, 0.8750, 0.58333, 0.58333, 0.8750, 0.8750], + [0.6250, 0.6250, 0.41667, 0.41667, 0.6250, 0.6250], + ]]], + &AutodiffDevice::new(), + )); +} + +struct AvgPool2dTestCase { + batch_size: usize, + channels: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + height: usize, + width: usize, + count_include_pad: bool, +} + +impl AvgPool2dTestCase { + fn assert_output(self, x_grad: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = avg_pool2d( + x.clone(), + [self.kernel_size_1, self.kernel_size_2], + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + self.count_include_pad, + false, + ); + let grads = output.backward(); + let x_grad_actual = x.grad(&grads).unwrap(); + + x_grad.to_data().assert_approx_eq::( + &x_grad_actual.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/backward.rs b/crates/burn-backend-tests/tests/autodiff/backward.rs new file mode 100644 index 0000000..86cee98 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/backward.rs @@ -0,0 +1,24 @@ +use super::*; +use burn_tensor::{TensorData, module::embedding}; + +#[test] +fn test_embedding_backward() { + let weights = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TensorData::from([[0, 1], [1, 1]]); + let x = TensorData::from([ + [[1.0, 2.0], [4.0, 5.0], [3.0, 4.0]], + [[4.0, 5.0], [8.0, 5.0], [1.0, 9.0]], + ]); + let device = AutodiffDevice::new(); + let weights = TestTensor::<2>::from_data(weights, &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(indices, &device); + let x = TestTensor::<3>::from_data(x, &device).require_grad(); + + let output = embedding(weights.clone(), indices); + let output = output.matmul(x); + let grads = output.backward(); + + let grad = weights.grad(&grads).unwrap(); + grad.to_data() + .assert_eq(&TensorData::from([[3., 9., 7.], [21., 35., 27.]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/bridge.rs b/crates/burn-backend-tests/tests/autodiff/bridge.rs new file mode 100644 index 0000000..712f62f --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/bridge.rs @@ -0,0 +1,25 @@ +use super::*; +use burn_tensor::{DType, Distribution}; + +#[test] +fn test_full_precision() { + let device = AutodiffDevice::new(); + let x1 = TestTensor::<2>::random([32, 32], Distribution::Default, &device).require_grad(); + let x2 = TestTensor::<2>::random([32, 32], Distribution::Default, &device).require_grad(); + let dtype = x1.dtype(); + + let x3 = x1.clone().cast(DType::F32); + let x4 = x2.clone().cast(DType::F32); + + let x5 = x3.matmul(x4); + let x6 = x5.cast(dtype); + let x7 = x6 * x1.clone() / x2.clone(); + + let grads = x7.backward(); + + let x1_grad = x1.grad(&grads); + let x2_grad = x2.grad(&grads); + + assert!(x1_grad.is_some()); + assert!(x2_grad.is_some()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/broadcast.rs b/crates/burn-backend-tests/tests/autodiff/broadcast.rs new file mode 100644 index 0000000..3c90828 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/broadcast.rs @@ -0,0 +1,56 @@ +use super::*; + +#[test] +fn mul_broadcast() { + test_ops_broadcast_backward(|x, y| x * y); +} + +#[test] +fn div_broadcast() { + test_ops_broadcast_backward(|x, y| x / y); +} + +#[test] +fn sub_broadcast() { + test_ops_broadcast_backward(|x, y| x - y); +} + +#[test] +fn add_broadcast() { + test_ops_broadcast_backward(|x, y| x + y); +} + +#[test] +fn matmul_broadcast() { + test_ops_broadcast_backward(|x, y| x.matmul(y)); +} + +#[test] +fn mask_where_broadcast() { + test_ops_broadcast_backward(|x, y| { + let cond = y.clone().equal_elem(4); + x.mask_where(cond, y) + }); +} + +fn test_ops_broadcast_backward(func: F) +where + F: Fn(TestTensor<3>, TestTensor<3>) -> TestTensor<3>, +{ + let device = AutodiffDevice::new(); + let w = TestTensor::zeros([16, 5, 5], &device).require_grad(); + let x = TestTensor::zeros([4, 5, 5], &device).require_grad(); + + // Slice isn't a broadcastable operation, so it will fail when the previous backward pass + // of an operation that support broadcast doesn't support it during the backward pass. + let y = func(w.clone().slice([0..1]), x.clone()); + + // Will panic if broadcast isn't supported! + let grads = y.backward(); + + let w_grad = w.grad(&grads).unwrap(); + let x_grad = x.grad(&grads).unwrap(); + + assert_eq!(w_grad.shape(), w.shape()); + assert_eq!(x_grad.shape(), x.shape()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cast.rs b/crates/burn-backend-tests/tests/autodiff/cast.rs new file mode 100644 index 0000000..503def8 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cast.rs @@ -0,0 +1,25 @@ +// Skip on metal - F64 not supported +#![cfg(all(feature = "std", not(feature = "metal")))] + +use super::*; +use burn_backend_tests::might_panic; +use burn_tensor::{DType, TensorData}; + +#[might_panic(reason = "Unsupported precision for fusion")] +#[test] +fn cast_keeps_gradient_flow() { + let device = AutodiffDevice::new(); + + let x = TestTensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device) + .require_grad(); + + let y = x.clone().cast(DType::F64); + let z = y.sum(); + + let grads = z.backward(); + let grad_x = x.grad(&grads).unwrap(); + + grad_x + .to_data() + .assert_eq(&TensorData::from([[1., 1.], [1., 1.]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cat.rs b/crates/burn-backend-tests/tests/autodiff/cat.rs new file mode 100644 index 0000000..f595d8e --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cat.rs @@ -0,0 +1,105 @@ +use super::*; + +use burn_tensor::Tolerance; + +#[test] +fn should_diff_cat() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[2.0, -1.0], [5.0, 2.0]], &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data([[5.0, 4.0], [-1.0, 4.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let mut tensor_1_list = Vec::new(); + let mut tensor_2_list = Vec::new(); + + for i in 0..2 { + tensor_1_list.push(tensor_1.clone().slice([i..i + 1])); + tensor_2_list.push(tensor_2.clone().slice([i..i + 1])); + } + + let tensor_1_cat = TestTensor::cat(tensor_1_list.clone(), 0); + let tensor_2_cat = TestTensor::cat(tensor_2_list.clone(), 0); + + let tensor_3_cat = tensor_1_cat.clone().matmul(tensor_2_cat.clone()); + let grads = tensor_3_cat.backward(); + + let grad_1_slice_1 = tensor_1.grad(&grads).unwrap().slice([0..1]); + let grad_1_slice_2 = tensor_1.grad(&grads).unwrap().slice([1..2]); + + let grad_2_slice_1 = tensor_2.grad(&grads).unwrap().slice([0..1]); + let grad_2_slice_2 = tensor_2.grad(&grads).unwrap().slice([1..2]); + + grad_1 + .clone() + .slice([0..1]) + .to_data() + .assert_approx_eq::(&grad_1_slice_1.to_data(), Tolerance::default()); + grad_1 + .slice([1..2]) + .to_data() + .assert_approx_eq::(&grad_1_slice_2.to_data(), Tolerance::default()); + + grad_2 + .clone() + .slice([0..1]) + .to_data() + .assert_approx_eq::(&grad_2_slice_1.to_data(), Tolerance::default()); + grad_2 + .slice([1..2]) + .to_data() + .assert_approx_eq::(&grad_2_slice_2.to_data(), Tolerance::default()); +} + +#[test] +fn should_diff_cat_more_than_1_dim() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[2.0, -1.0], [5.0, 2.0]], &device).require_grad(); + let tensor_2 = + TestTensor::<2>::from_data([[5.0, 4.0], [-1.0, 4.0], [4.0, 1.0]], &device).require_grad(); + + // Concat a tensor [2, 2] with another tensor [3, 2] along dim 0. + // The resulting tensor should be [5, 2] + let tensor_3 = TestTensor::cat(vec![tensor_1.clone(), tensor_2.clone()], 0); + assert_eq!(tensor_3.dims(), [5, 2]); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + assert_eq!(tensor_1.dims(), grad_1.dims()); + assert_eq!(tensor_2.dims(), grad_2.dims()); +} + +#[test] +fn should_slice_grads_correctly_when_some_inputs_not_tracked() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[1.0]], &device).require_grad(); // tracked + let tensor_2 = TestTensor::<2>::from_data([[10.0, 20.0]], &device); // not tracked + let tensor_3 = TestTensor::<2>::from_data([[100.0, 200.0, 300.0]], &device).require_grad(); // tracked + + let cat = TestTensor::cat( + vec![tensor_1.clone(), tensor_2.clone(), tensor_3.clone()], + 1, + ); + + // Make gradient per column unique so wrong slicing shows up. + let weights = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]], &device); + let loss = (cat * weights).sum(); + + let grads = loss.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_3 = tensor_3.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&burn_tensor::TensorData::from([[1.0]]), false); + grad_3 + .to_data() + .assert_eq(&burn_tensor::TensorData::from([[4.0, 5.0, 6.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/ceil.rs b/crates/burn-backend-tests/tests/autodiff/ceil.rs new file mode 100644 index 0000000..639d028 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/ceil.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_ceil() { + let data = TensorData::from([ + [-1.9751, 0.0714, 0.0643, 0.2406], + [-1.3172, 0.1252, -0.1119, -0.0127], + ]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data, &device).require_grad(); + let tensor_2 = tensor_1.clone().ceil(); + let grads = tensor_2.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq( + &TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/checkpoint.rs b/crates/burn-backend-tests/tests/autodiff/checkpoint.rs new file mode 100644 index 0000000..1d16f72 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/checkpoint.rs @@ -0,0 +1,209 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_autodiff_checkpoint_complicated_computation() { + let data_0 = TensorData::from([[0.0, 7.0], [7.0, 7.0]]); + let data_1 = TensorData::from([[0.1, 7.0], [7.0, 7.0]]); + let data_2 = TensorData::from([[0.2, 7.0], [7.0, 7.0]]); + let data_3 = TensorData::from([[0.3, 7.0], [7.0, 7.0]]); + let data_4 = TensorData::from([[0.4, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device).require_grad(); + let tensor_1 = TestTensor::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + let tensor_4 = TestTensor::from_data(data_4, &device).require_grad(); + + let tensor_5 = compute_bound_eager(tensor_0, tensor_1); + let tensor_6 = compute_bound_lazy(tensor_2, tensor_3.clone()); + let tensor_7 = memory_bound_eager(tensor_3, tensor_4); + let tensor_8 = compute_bound_lazy(tensor_6, tensor_7.clone()); + let tensor_9 = memory_bound_eager_scalar(tensor_7, 11.); + let tensor_10 = memory_bound_lazy(tensor_5, tensor_8.clone()); + let tensor_11 = memory_bound_lazy(tensor_8, tensor_9); + let tensor_12 = compute_bound_lazy(tensor_10, tensor_11); + + assert_checkpoint(tensor_12); +} + +#[test] +fn test_autodiff_checkpoint_with_missing_requirement() { + let data_0 = TensorData::from([[0.0, 7.0], [7.0, 7.0]]); + let data_1 = TensorData::from([[0.1, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device).require_grad(); + let tensor_1 = TestTensor::from_data(data_1, &device); // does not require_grad + + let tensor_2 = memory_bound_eager(tensor_0, tensor_1); + let tensor_3 = memory_bound_eager_scalar(tensor_2.clone(), 11.); + let tensor_4 = memory_bound_eager_scalar(tensor_2.clone(), 11.); + let tensor_5 = compute_bound_lazy(tensor_3, tensor_4); + let tensor_6 = compute_bound_eager_scalar(tensor_5.clone(), 11.); + let tensor_7 = memory_bound_eager(tensor_5, tensor_2); + let tensor_8 = memory_bound_eager(tensor_6, tensor_7); + + assert_checkpoint(tensor_8); +} + +#[test] +fn test_autodiff_checkpoint_with_many_duplicates() { + let data_0 = TensorData::from([[4.0, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device).require_grad(); + + let tensor_1 = memory_bound_eager(tensor_0.clone(), tensor_0.clone()); + let tensor_2 = compute_bound_eager(tensor_0.clone(), tensor_0.clone()); + let tensor_3 = memory_bound_lazy(tensor_0.clone(), tensor_0.clone()); + let tensor_4 = compute_bound_lazy(tensor_0.clone(), tensor_0.clone()); + + let tensor_5 = memory_bound_eager(tensor_1.clone(), tensor_0.clone()); + let tensor_6 = memory_bound_eager(tensor_0.clone(), tensor_5.clone()); + let tensor_7 = compute_bound_lazy(tensor_3.clone(), tensor_5.clone()); + let tensor_8 = compute_bound_eager(tensor_4.clone(), tensor_2.clone()); + let tensor_9 = memory_bound_lazy(tensor_6, tensor_7); + let tensor_10 = memory_bound_eager(tensor_0, tensor_9); + let tensor_11 = memory_bound_eager_scalar(tensor_10, 9.); + let tensor_12 = compute_bound_lazy(tensor_8, tensor_11); + + assert_checkpoint(tensor_12); +} + +#[test] +fn test_autodiff_checkpoint_with_long_chain_of_eager_memory_bound() { + let data_0 = TensorData::from([[0.0, 7.0], [7.0, 7.0]]); + let data_1 = TensorData::from([[0.1, 7.0], [7.0, 7.0]]); + let data_2 = TensorData::from([[0.2, 7.0], [7.0, 7.0]]); + let data_3 = TensorData::from([[0.3, 7.0], [7.0, 7.0]]); + let data_4 = TensorData::from([[0.4, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device).require_grad(); + let tensor_1 = TestTensor::from_data(data_1, &device); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + let tensor_4 = TestTensor::from_data(data_4, &device).require_grad(); + + let tensor_5 = memory_bound_eager(tensor_0, tensor_1.clone()); + let tensor_6 = memory_bound_eager(tensor_5, tensor_2); + let tensor_7 = memory_bound_eager(tensor_6, tensor_3); + let tensor_8 = memory_bound_eager(tensor_7, tensor_4); + let tensor_9 = memory_bound_lazy(tensor_8, tensor_1); + + assert_checkpoint(tensor_9) +} + +#[test] +fn test_autodiff_checkpoint_half_sub_graph_not_tracked() { + let data_0 = TensorData::from([[0.0, 7.0], [7.0, 7.0]]); + let data_1 = TensorData::from([[0.1, 7.0], [7.0, 7.0]]); + let data_2 = TensorData::from([[0.2, 7.0], [7.0, 7.0]]); + let data_3 = TensorData::from([[0.3, 7.0], [7.0, 7.0]]); + let data_4 = TensorData::from([[0.4, 7.0], [7.0, 7.0]]); + let data_5 = TensorData::from([[0.5, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device); + let tensor_1 = TestTensor::from_data(data_1, &device); + let tensor_2 = TestTensor::from_data(data_2, &device); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + let tensor_4 = TestTensor::from_data(data_4, &device).require_grad(); + let tensor_5 = TestTensor::from_data(data_5, &device).require_grad(); + + let tensor_6 = memory_bound_lazy(tensor_0, tensor_1); + let tensor_7 = compute_bound_eager(tensor_6, tensor_2); + + let tensor_8 = memory_bound_eager(tensor_3, tensor_4); + let tensor_9 = compute_bound_lazy(tensor_8, tensor_5); + + let tensor_10 = compute_bound_lazy(tensor_7, tensor_9); + + assert_checkpoint(tensor_10); +} + +#[test] +fn test_autodiff_checkpoint_very_complex() { + let data_0 = TensorData::from([[0.0, 7.0], [7.0, 7.0]]); + let data_1 = TensorData::from([[0.1, 7.0], [7.0, 7.0]]); + let data_2 = TensorData::from([[0.2, 7.0], [7.0, 7.0]]); + let data_3 = TensorData::from([[0.3, 7.0], [7.0, 7.0]]); + let data_4 = TensorData::from([[0.4, 7.0], [7.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_0 = TestTensor::<2>::from_data(data_0, &device).require_grad(); + let tensor_1 = TestTensor::from_data(data_1, &device); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + let tensor_4 = TestTensor::from_data(data_4, &device).require_grad(); + + let tensor_5 = memory_bound_eager_scalar(tensor_0, 8.); + let tensor_6 = memory_bound_lazy(tensor_5.clone(), tensor_1.clone()); + let tensor_7 = compute_bound_lazy(tensor_6.clone(), tensor_6); + let tensor_8 = memory_bound_lazy(tensor_1.clone(), tensor_5.clone()); + let tensor_9 = memory_bound_eager_scalar(tensor_7.clone(), 7.); + let tensor_10 = compute_bound_eager(tensor_5, tensor_8); + let tensor_11 = memory_bound_eager(tensor_2.clone(), tensor_9); + let tensor_12 = memory_bound_lazy(tensor_2.clone(), tensor_2); + let tensor_13 = compute_bound_eager(tensor_10.clone(), tensor_11); + let tensor_14 = compute_bound_eager_scalar(tensor_3, 8.); + let tensor_15 = compute_bound_lazy(tensor_4, tensor_12); + let tensor_16 = memory_bound_lazy(tensor_10, tensor_7); + let tensor_17 = compute_bound_lazy(tensor_13, tensor_1); + let tensor_18 = memory_bound_eager(tensor_15, tensor_16); + let tensor_19 = compute_bound_eager(tensor_14, tensor_17); + let tensor_20 = memory_bound_lazy(tensor_18, tensor_19); + let tensor_21 = memory_bound_eager_scalar(tensor_20, 8.); + + assert_checkpoint(tensor_21) +} + +fn assert_checkpoint(tensor: TestTensor) { + // Assert is not explicit here, but the test can fail + // - when a tensor is actually required more than n_required, it won't be found and will panic + // - when a tensor is actually required less than n_required, the backward states map won't be + // empty and will fail the assertion within the backward code, same for retro_forwards + tensor.backward(); +} + +// Does not save its state and does not need its parents +fn memory_bound_eager( + tensor_a: TestTensor, + tensor_b: TestTensor, +) -> TestTensor { + tensor_a.add(tensor_b) +} +fn memory_bound_eager_scalar(tensor_a: TestTensor, b: f32) -> TestTensor { + tensor_a.add_scalar(b) +} + +// Saves its own state and does not need its parents +fn compute_bound_eager( + tensor_a: TestTensor, + tensor_b: TestTensor, +) -> TestTensor { + let mask = TestTensorBool::::empty(tensor_a.shape(), &tensor_a.device()); + tensor_a.mask_where(mask, tensor_b) +} +fn compute_bound_eager_scalar(tensor_a: TestTensor, b: f32) -> TestTensor { + let mask = TestTensorBool::::empty(tensor_a.shape(), &tensor_a.device()); + tensor_a.mask_fill(mask, b) +} + +// Does not save its state and needs its parents +fn memory_bound_lazy( + tensor_a: TestTensor, + tensor_b: TestTensor, +) -> TestTensor { + tensor_a.mul(tensor_b) +} + +// Saves its own state and needs its parents +fn compute_bound_lazy( + tensor_a: TestTensor, + tensor_b: TestTensor, +) -> TestTensor { + tensor_a.matmul(tensor_b) +} diff --git a/crates/burn-backend-tests/tests/autodiff/complex.rs b/crates/burn-backend-tests/tests/autodiff/complex.rs new file mode 100644 index 0000000..12adec2 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/complex.rs @@ -0,0 +1,81 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_full_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.matmul(tensor_1.clone()); + let tensor_5 = tensor_4.mul(tensor_2.clone()); + + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[593., 463.0], [487.0, 539.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[734.0, 294.0], [1414.0, 242.0]]), false); +} + +#[test] +fn should_diff_full_complex_2() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.matmul(tensor_1.clone()); + let tensor_5 = tensor_4.add_scalar(17.0).add(tensor_2.clone()); + + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[166.0, 110.0], [212.0, 156.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[113.0, 141.0], [33.0, 41.0]]), false); +} + +#[test] +fn should_diff_full_complex_3() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.matmul(tensor_1.clone()); + let tensor_5 = tensor_4.clone().sub(tensor_2.clone()); + let tensor_6 = tensor_5.add(tensor_4); + + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[332.0, 220.0], [424.0, 312.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[223.0, 279.0], [63.0, 79.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv1d.rs b/crates/burn-backend-tests/tests/autodiff/conv1d.rs new file mode 100644 index 0000000..b4a4795 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv1d.rs @@ -0,0 +1,347 @@ +#![allow(clippy::identity_op)] + +use super::*; +use burn_tensor::{ + Shape, Tolerance, + module::conv1d, + ops::{ConvOptions, PaddedConvOptions}, +}; + +#[test] +fn test_conv1d_basic() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 1, + groups: 1, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[14., 24., 24., 18.], [26., 42., 42., 30.]], + [[14., 24., 24., 18.], [26., 42., 42., 30.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[30., 44., 36.], [54., 76., 60.]], + [[30., 44., 36.], [54., 76., 60.]], + ], + &device, + ), + bias: TestTensor::from_data([8., 8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv1d_different_channels() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 3, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 1, + groups: 1, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[39., 63., 63., 45.], [57., 90., 90., 63.]], + [[39., 63., 63., 45.], [57., 90., 90., 63.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[30., 44., 36.], [54., 76., 60.]], + [[30., 44., 36.], [54., 76., 60.]], + [[30., 44., 36.], [54., 76., 60.]], + ], + &device, + ), + bias: TestTensor::from_data([8., 8., 8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv1d_with_padding() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 2, + stride: 1, + dilation: 1, + groups: 1, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[24., 24., 24., 24.], [42., 42., 42., 42.]], + [[24., 24., 24., 24.], [42., 42., 42., 42.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[44., 44., 44.], [76., 76., 76.]], + [[44., 44., 44.], [76., 76., 76.]], + ], + &device, + ), + bias: TestTensor::from_data([12., 12.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv1d_with_stride() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 2, + dilation: 1, + groups: 1, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[8., 16., 8., 10.], [14., 28., 14., 16.]], + [[8., 16., 8., 10.], [14., 28., 14., 16.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[10., 20., 24.], [18., 36., 40.]], + [[10., 20., 24.], [18., 36., 40.]], + ], + &device, + ), + bias: TestTensor::from_data([4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv1d_dilation() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 2, + groups: 1, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[6., 8., 8., 10.], [12., 14., 14., 16.]], + [[6., 8., 8., 10.], [12., 14., 14., 16.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[8., 22., 14.], [16., 38., 22.]], + [[8., 22., 14.], [16., 38., 22.]], + ], + &device, + ), + bias: TestTensor::from_data([4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv1d_groups() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 1, + groups: 2, + length: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[1., 3., 3., 3.], [7., 12., 12., 9.]], + [[1., 3., 3., 3.], [7., 12., 12., 9.]], + ], + &device, + ), + weight: TestTensor::from_data([[[30., 44., 36.]], [[54., 76., 60.]]], &device), + bias: TestTensor::from_data([8., 8.], &device), + }; + test.assert_grads(grads); +} + +/// Regression test for https://github.com/tracel-ai/burn/issues/4799. +/// +/// When a conv drops more than one tail input (here length=11, kernel=4, +/// stride=4 → three dropped inputs), the transpose-conv used in the backward +/// pass must inject enough `padding_out` to reproduce the original input shape. +/// An earlier formula capped this at 1, which caused downstream ops (pad, +/// slice_assign) to panic on shape mismatch. +#[test] +fn test_conv1d_backward_shape_with_remainder() { + let device = AutodiffDevice::new(); + let length = 11; + let x = TestTensor::<3>::from_data( + TestTensorInt::arange(0..(1 * 1 * length) as i64, &device) + .reshape::<3, _>(Shape::new([1, 1, length])) + .into_data(), + &device, + ) + .require_grad(); + let weight = TestTensor::<3>::from_data( + TestTensorInt::arange(0..4, &device) + .reshape::<3, _>(Shape::new([1, 1, 4])) + .into_data(), + &device, + ) + .require_grad(); + + let output = conv1d( + x.clone(), + weight.clone(), + None, + ConvOptions::new([4], [0], [1], 1), + ); + let grads = output.sum().backward(); + + let x_grad = x.grad(&grads).unwrap(); + let weight_grad = weight.grad(&grads).unwrap(); + assert_eq!(x_grad.dims(), [1, 1, length]); + assert_eq!(weight_grad.dims(), [1, 1, 4]); +} + +/// Regression test for the asymmetric-padding backward-shape path referenced +/// in https://github.com/tracel-ai/burn/issues/4799. Reduced to a single +/// `conv1d`: the asymmetric path routes through `x.pad(...) -> B::conv1d(padding=0)`, +/// and the backward must restore the original input shape without triggering +/// shape-mismatch failures when the forward drops multiple tail inputs. +#[test] +fn test_conv1d_asymmetric_padding_backward_shape() { + let device = AutodiffDevice::new(); + let length = 1600; + let x = TestTensor::<3>::zeros([1, 32, length], &device).require_grad(); + let weight = TestTensor::<3>::zeros([64, 32, 4], &device).require_grad(); + + let output = conv1d( + x.clone(), + weight.clone(), + None, + PaddedConvOptions::asymmetric([4], [3], [0], [1], 1), + ); + let grads = output.sum().backward(); + + assert_eq!(x.grad(&grads).unwrap().dims(), [1, 32, length]); + assert_eq!(weight.grad(&grads).unwrap().dims(), [64, 32, 4]); +} + +struct Conv1dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size: usize, + padding: usize, + stride: usize, + dilation: usize, + groups: usize, + length: usize, +} + +struct Grads { + x: TestTensor<3>, + weight: TestTensor<3>, + bias: TestTensor<1>, +} + +impl Conv1dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.length]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size, + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<3, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + + let output = conv1d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvOptions::new([self.stride], [self.padding], [self.dilation], self.groups), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + let tolerance = Tolerance::default(); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv2d.rs b/crates/burn-backend-tests/tests/autodiff/conv2d.rs new file mode 100644 index 0000000..af640bc --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv2d.rs @@ -0,0 +1,962 @@ +use super::*; +use burn_tensor::{Shape, Tolerance, module::conv2d, ops::ConvOptions}; + +#[test] +fn test_conv2d_basic() { + let test = Conv2dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [88., 138., 138., 96.], + [150., 234., 234., 162.], + [150., 234., 234., 162.], + [112., 174., 174., 120.], + ], + [ + [160., 246., 246., 168.], + [258., 396., 396., 270.], + [258., 396., 396., 270.], + [184., 282., 282., 192.], + ], + ], + [ + [ + [88., 138., 138., 96.], + [150., 234., 234., 162.], + [150., 234., 234., 162.], + [112., 174., 174., 120.], + ], + [ + [160., 246., 246., 168.], + [258., 396., 396., 270.], + [258., 396., 396., 270.], + [184., 282., 282., 192.], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[378., 516., 396.], [552., 752., 576.], [450., 612., 468.]], + [[666., 900., 684.], [936., 1264., 960.], [738., 996., 756.]], + ], + [ + [[378., 516., 396.], [552., 752., 576.], [450., 612., 468.]], + [[666., 900., 684.], [936., 1264., 960.], [738., 996., 756.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([32., 32.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_channels() { + let test = Conv2dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [240., 369., 369., 252.], + [387., 594., 594., 405.], + [387., 594., 594., 405.], + [276., 423., 423., 288.], + ], + [ + [348., 531., 531., 360.], + [549., 837., 837., 567.], + [549., 837., 837., 567.], + [384., 585., 585., 396.], + ], + ], + [ + [ + [240., 369., 369., 252.], + [387., 594., 594., 405.], + [387., 594., 594., 405.], + [276., 423., 423., 288.], + ], + [ + [348., 531., 531., 360.], + [549., 837., 837., 567.], + [549., 837., 837., 567.], + [384., 585., 585., 396.], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[378., 516., 396.], [552., 752., 576.], [450., 612., 468.]], + [[666., 900., 684.], [936., 1264., 960.], [738., 996., 756.]], + ], + [ + [[378., 516., 396.], [552., 752., 576.], [450., 612., 468.]], + [[666., 900., 684.], [936., 1264., 960.], [738., 996., 756.]], + ], + [ + [[378., 516., 396.], [552., 752., 576.], [450., 612., 468.]], + [[666., 900., 684.], [936., 1264., 960.], [738., 996., 756.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([32., 32., 32.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_kernel_size() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [116., 180., 192., 132.], + [198., 306., 324., 222.], + [198., 306., 324., 222.], + [148., 228., 240., 164.], + ], + [ + [212., 324., 336., 228.], + [342., 522., 540., 366.], + [342., 522., 540., 366.], + [244., 372., 384., 260.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [27., 45., 54., 39.], + [52., 84., 96., 68.], + [51., 81., 90., 63.], + ], + [ + [123., 189., 198., 135.], + [180., 276., 288., 196.], + [147., 225., 234., 159.], + ], + ], + [ + [ + [27., 45., 54., 39.], + [52., 84., 96., 68.], + [51., 81., 90., 63.], + ], + [ + [123., 189., 198., 135.], + [180., 276., 288., 196.], + [147., 225., 234., 159.], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([12., 12.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_padding() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [138., 138., 138., 138.], + [234., 234., 234., 234.], + [234., 234., 234., 234.], + [174., 174., 174., 174.], + ], + [ + [246., 246., 246., 246.], + [396., 396., 396., 396.], + [396., 396., 396., 396.], + [282., 282., 282., 282.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[66., 66., 66.], [120., 120., 120.], [114., 114., 114.]], + [[258., 258., 258.], [376., 376., 376.], [306., 306., 306.]], + ], + [ + [[66., 66., 66.], [120., 120., 120.], [114., 114., 114.]], + [[258., 258., 258.], [376., 376., 376.], [306., 306., 306.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([24., 24.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_width() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 5, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [88., 138., 138., 138., 96.], + [150., 234., 234., 234., 162.], + [150., 234., 234., 234., 162.], + [112., 174., 174., 174., 120.], + ], + [ + [160., 246., 246., 246., 168.], + [258., 396., 396., 396., 270.], + [258., 396., 396., 396., 270.], + [184., 282., 282., 282., 192.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[78., 105., 90.], [144., 190., 160.], [138., 180., 150.]], + [[318., 405., 330.], [464., 590., 480.], [378., 480., 390.]], + ], + [ + [[78., 105., 90.], [144., 190., 160.], [138., 180., 150.]], + [[318., 405., 330.], [464., 590., 480.], [378., 480., 390.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([20., 20.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_stride_2() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 2, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 6, + width: 6, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [26., 52., 26., 52., 26., 28.], + [52., 104., 52., 104., 52., 56.], + [26., 52., 26., 52., 26., 28.], + [52., 104., 52., 104., 52., 56.], + [26., 52., 26., 52., 26., 28.], + [32., 64., 32., 64., 32., 34.], + ], + [ + [44., 88., 44., 88., 44., 46.], + [88., 176., 88., 176., 88., 92.], + [44., 88., 44., 88., 44., 46.], + [88., 176., 88., 176., 88., 92.], + [44., 88., 44., 88., 44., 46.], + [50., 100., 50., 100., 50., 52.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[56., 84., 90.], [84., 126., 135.], [120., 180., 189.]], + [[200., 300., 306.], [300., 450., 459.], [336., 504., 513.]], + ], + [ + [[56., 84., 90.], [84., 126., 135.], [120., 180., 189.]], + [[200., 300., 306.], [300., 450., 459.], [336., 504., 513.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([9., 9.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_stride() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 3, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 8, + width: 8, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [50., 78., 78., 78., 78., 78., 78., 54.], + [62., 96., 96., 96., 96., 96., 96., 66.], + [38., 60., 60., 60., 60., 60., 60., 42.], + [50., 78., 78., 78., 78., 78., 78., 54.], + [62., 96., 96., 96., 96., 96., 96., 66.], + [38., 60., 60., 60., 60., 60., 60., 42.], + [50., 78., 78., 78., 78., 78., 78., 54.], + [62., 96., 96., 96., 96., 96., 96., 66.], + ], + [ + [86., 132., 132., 132., 132., 132., 132., 90.], + [98., 150., 150., 150., 150., 150., 150., 102.], + [74., 114., 114., 114., 114., 114., 114., 78.], + [86., 132., 132., 132., 132., 132., 132., 90.], + [98., 150., 150., 150., 150., 150., 150., 102.], + [74., 114., 114., 114., 114., 114., 114., 78.], + [86., 132., 132., 132., 132., 132., 132., 90.], + [98., 150., 150., 150., 150., 150., 150., 102.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[434., 504., 448.], [567., 660., 588.], [735., 852., 756.]], + [ + [1330., 1528., 1344.], + [1911., 2196., 1932.], + [2079., 2388., 2100.], + ], + ], + [ + [[434., 504., 448.], [567., 660., 588.], [735., 852., 756.]], + [ + [1330., 1528., 1344.], + [1911., 2196., 1932.], + [2079., 2388., 2100.], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([24., 24.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_dilation_2() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 2, + dilation_2: 2, + groups: 1, + height: 6, + width: 6, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [18., 38., 38., 42., 42., 22.], + [42., 88., 88., 96., 96., 50.], + [42., 88., 88., 96., 96., 50.], + [54., 112., 112., 120., 120., 62.], + [54., 112., 112., 120., 120., 62.], + [30., 62., 62., 66., 66., 34.], + ], + [ + [36., 74., 74., 78., 78., 40.], + [78., 160., 160., 168., 168., 86.], + [78., 160., 160., 168., 168., 86.], + [90., 184., 184., 192., 192., 98.], + [90., 184., 184., 192., 192., 98.], + [48., 98., 98., 102., 102., 52.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[63., 102., 90.], [192., 280., 228.], [225., 318., 252.]], + [[387., 534., 414.], [624., 856., 660.], [549., 750., 576.]], + ], + [ + [[63., 102., 90.], [192., 280., 228.], [225., 318., 252.]], + [[387., 534., 414.], [624., 856., 660.], [549., 750., 576.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([16., 16.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_different_dilation() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 2, + dilation_2: 3, + groups: 1, + height: 6, + width: 6, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [18., 0., 20., 20., 0., 22.], + [42., 0., 46., 46., 0., 50.], + [42., 0., 46., 46., 0., 50.], + [54., 0., 58., 58., 0., 62.], + [54., 0., 58., 58., 0., 62.], + [30., 0., 32., 32., 0., 34.], + ], + [ + [36., 0., 38., 38., 0., 40.], + [78., 0., 82., 82., 0., 86.], + [78., 0., 82., 82., 0., 86.], + [90., 0., 94., 94., 0., 98.], + [90., 0., 94., 94., 0., 98.], + [48., 0., 50., 50., 0., 52.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[18., 51., 33.], [60., 140., 80.], [72., 159., 87.]], + [[126., 267., 141.], [204., 428., 224.], [180., 375., 195.]], + ], + [ + [[18., 51., 33.], [60., 140., 80.], [72., 159., 87.]], + [[126., 267., 141.], [204., 428., 224.], [180., 375., 195.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([8., 8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_groups() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 5, + width: 5, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [0., 1., 3., 3., 2.], + [3., 8., 15., 12., 7.], + [9., 21., 36., 27., 15.], + [9., 20., 33., 24., 13.], + [6., 13., 21., 15., 8.], + ], + [ + [9., 19., 30., 21., 11.], + [21., 44., 69., 48., 25.], + [36., 75., 117., 81., 42.], + [27., 56., 87., 60., 31.], + [15., 31., 48., 33., 17.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[[54., 63., 72.], [99., 108., 117.], [144., 153., 162.]]], + [[[279., 288., 297.], [324., 333., 342.], [369., 378., 387.]]], + ], + &device, + ), + bias: TestTensor::from_data([9., 9.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_groups_stride_2() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 4, + channels_out: 4, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 2, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + groups: 4, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [4., 8., 4., 5.], + [8., 16., 8., 10.], + [4., 8., 4., 5.], + [7., 14., 7., 8.], + ], + [ + [13., 26., 13., 14.], + [26., 52., 26., 28.], + [13., 26., 13., 14.], + [16., 32., 16., 17.], + ], + [ + [22., 44., 22., 23.], + [44., 88., 44., 46.], + [22., 44., 22., 23.], + [25., 50., 25., 26.], + ], + [ + [31., 62., 31., 32.], + [62., 124., 62., 64.], + [31., 62., 31., 32.], + [34., 68., 34., 35.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[[5., 10., 12.], [10., 20., 24.], [18., 36., 40.]]], + [[[21., 42., 44.], [42., 84., 88.], [50., 100., 104.]]], + [[[37., 74., 76.], [74., 148., 152.], [82., 164., 168.]]], + [[[53., 106., 108.], [106., 212., 216.], [114., 228., 232.]]], + ], + &device, + ), + bias: TestTensor::from_data([4., 4., 4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_groups_different_channels() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 6, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 3, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [9., 20., 24., 13.], + [24., 52., 60., 32.], + [36., 76., 84., 44.], + [21., 44., 48., 25.], + ], + [ + [45., 92., 96., 49.], + [96., 196., 204., 104.], + [108., 220., 228., 116.], + [57., 116., 120., 61.], + ], + [ + [81., 164., 168., 85.], + [168., 340., 348., 176.], + [180., 364., 372., 188.], + [93., 188., 192., 97.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[[10., 14., 18.], [26., 30., 34.], [42., 46., 50.]]], + [[[10., 14., 18.], [26., 30., 34.], [42., 46., 50.]]], + [[[74., 78., 82.], [90., 94., 98.], [106., 110., 114.]]], + [[[74., 78., 82.], [90., 94., 98.], [106., 110., 114.]]], + [[[138., 142., 146.], [154., 158., 162.], [170., 174., 178.]]], + [[[138., 142., 146.], [154., 158., 162.], [170., 174., 178.]]], + ], + &device, + ), + bias: TestTensor::from_data([4., 4., 4., 4., 4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_complex() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 2, + kernel_size_2: 3, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 2, + dilation_1: 2, + dilation_2: 3, + groups: 1, + height: 4, + width: 5, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [36., 39., 0., 39., 42.], + [81., 87., 0., 87., 93.], + [81., 87., 0., 87., 93.], + [45., 48., 0., 48., 51.], + ], + [ + [54., 57., 0., 57., 60.], + [117., 123., 0., 123., 129.], + [117., 123., 0., 123., 129.], + [63., 66., 0., 66., 69.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[15., 42., 27.], [30., 72., 42.]], + [[75., 162., 87.], [90., 192., 102.]], + ], + [ + [[15., 42., 27.], [30., 72., 42.]], + [[75., 162., 87.], [90., 192., 102.]], + ], + [ + [[15., 42., 27.], [30., 72., 42.]], + [[75., 162., 87.], [90., 192., 102.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([8., 8., 8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv2d_groups_stride_2_no_pad() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 4, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 2, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [0., 1., 2., 0.], + [3., 4., 5., 0.], + [6., 7., 8., 0.], + [0., 0., 0., 0.], + ], + [ + [9., 10., 11., 0.], + [12., 13., 14., 0.], + [15., 16., 17., 0.], + [0., 0., 0., 0.], + ], + [ + [18., 19., 20., 0.], + [21., 22., 23., 0.], + [24., 25., 26., 0.], + [0., 0., 0., 0.], + ], + [ + [27., 28., 29., 0.], + [30., 31., 32., 0.], + [33., 34., 35., 0.], + [0., 0., 0., 0.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[0., 1., 2.], [4., 5., 6.], [8., 9., 10.]], + [[16., 17., 18.], [20., 21., 22.], [24., 25., 26.]], + ], + [ + [[32., 33., 34.], [36., 37., 38.], [40., 41., 42.]], + [[48., 49., 50.], [52., 53., 54.], [56., 57., 58.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([1., 1.], &device), + }; + test.assert_grads(grads); +} + +struct Conv2dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + dilation_1: usize, + dilation_2: usize, + groups: usize, + height: usize, + width: usize, +} + +struct Grads { + x: TestTensor<4>, + weight: TestTensor<4>, + bias: TestTensor<1>, +} + +impl Conv2dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size_1, + self.kernel_size_2, + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<4, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = conv2d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvOptions::new( + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + [self.dilation_1, self.dilation_2], + self.groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + let tolerance = Tolerance::rel_abs(0.01, 0.01); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv3d.rs b/crates/burn-backend-tests/tests/autodiff/conv3d.rs new file mode 100644 index 0000000..da7d5ce --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv3d.rs @@ -0,0 +1,690 @@ +use super::*; +use burn_tensor::{Shape, Tolerance, module::conv3d, ops::ConvOptions}; + +#[test] +fn test_conv3d_basic() { + let test = Conv3dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 4, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [ + [536., 816., 816., 552.], + [840., 1278., 1278., 864.], + [840., 1278., 1278., 864.], + [584., 888., 888., 600.], + ], + [ + [912., 1386., 1386., 936.], + [1422., 2160., 2160., 1458.], + [1422., 2160., 2160., 1458.], + [984., 1494., 1494., 1008.], + ], + [ + [912., 1386., 1386., 936.], + [1422., 2160., 2160., 1458.], + [1422., 2160., 2160., 1458.], + [984., 1494., 1494., 1008.], + ], + [ + [680., 1032., 1032., 696.], + [1056., 1602., 1602., 1080.], + [1056., 1602., 1602., 1080.], + [728., 1104., 1104., 744.], + ], + ], + [ + [ + [968., 1464., 1464., 984.], + [1488., 2250., 2250., 1512.], + [1488., 2250., 2250., 1512.], + [1016., 1536., 1536., 1032.], + ], + [ + [1560., 2358., 2358., 1584.], + [2394., 3618., 3618., 2430.], + [2394., 3618., 3618., 2430.], + [1632., 2466., 2466., 1656.], + ], + [ + [1560., 2358., 2358., 1584.], + [2394., 3618., 3618., 2430.], + [2394., 3618., 3618., 2430.], + [1632., 2466., 2466., 1656.], + ], + [ + [1112., 1680., 1680., 1128.], + [1704., 2574., 2574., 1728.], + [1704., 2574., 2574., 1728.], + [1160., 1752., 1752., 1176.], + ], + ], + ], + [ + [ + [ + [536., 816., 816., 552.], + [840., 1278., 1278., 864.], + [840., 1278., 1278., 864.], + [584., 888., 888., 600.], + ], + [ + [912., 1386., 1386., 936.], + [1422., 2160., 2160., 1458.], + [1422., 2160., 2160., 1458.], + [984., 1494., 1494., 1008.], + ], + [ + [912., 1386., 1386., 936.], + [1422., 2160., 2160., 1458.], + [1422., 2160., 2160., 1458.], + [984., 1494., 1494., 1008.], + ], + [ + [680., 1032., 1032., 696.], + [1056., 1602., 1602., 1080.], + [1056., 1602., 1602., 1080.], + [728., 1104., 1104., 744.], + ], + ], + [ + [ + [968., 1464., 1464., 984.], + [1488., 2250., 2250., 1512.], + [1488., 2250., 2250., 1512.], + [1016., 1536., 1536., 1032.], + ], + [ + [1560., 2358., 2358., 1584.], + [2394., 3618., 3618., 2430.], + [2394., 3618., 3618., 2430.], + [1632., 2466., 2466., 1656.], + ], + [ + [1560., 2358., 2358., 1584.], + [2394., 3618., 3618., 2430.], + [2394., 3618., 3618., 2430.], + [1632., 2466., 2466., 1656.], + ], + [ + [1112., 1680., 1680., 1128.], + [1704., 2574., 2574., 1728.], + [1704., 2574., 2574., 1728.], + [1160., 1752., 1752., 1176.], + ], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [ + [4590., 6156., 4644.], + [6264., 8400., 6336.], + [4806., 6444., 4860.], + ], + [ + [6696., 8976., 6768.], + [9120., 12224., 9216.], + [6984., 9360., 7056.], + ], + [ + [5454., 7308., 5508.], + [7416., 9936., 7488.], + [5670., 7596., 5724.], + ], + ], + [ + [ + [8046., 10764., 8100.], + [10872., 14544., 10944.], + [8262., 11052., 8316.], + ], + [ + [11304., 15120., 11376.], + [15264., 20416., 15360.], + [11592., 15504., 11664.], + ], + [ + [8910., 11916., 8964.], + [12024., 16080., 12096.], + [9126., 12204., 9180.], + ], + ], + ], + [ + [ + [ + [4590., 6156., 4644.], + [6264., 8400., 6336.], + [4806., 6444., 4860.], + ], + [ + [6696., 8976., 6768.], + [9120., 12224., 9216.], + [6984., 9360., 7056.], + ], + [ + [5454., 7308., 5508.], + [7416., 9936., 7488.], + [5670., 7596., 5724.], + ], + ], + [ + [ + [8046., 10764., 8100.], + [10872., 14544., 10944.], + [8262., 11052., 8316.], + ], + [ + [11304., 15120., 11376.], + [15264., 20416., 15360.], + [11592., 15504., 11664.], + ], + [ + [8910., 11916., 8964.], + [12024., 16080., 12096.], + [9126., 12204., 9180.], + ], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([128., 128.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv3d_complex() { + let test = Conv3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 2, + kernel_size_2: 3, + kernel_size_3: 4, + padding_1: 1, + padding_2: 2, + padding_3: 3, + stride_1: 1, + stride_2: 2, + stride_3: 3, + dilation_1: 2, + dilation_2: 3, + dilation_3: 4, + groups: 1, + depth: 5, + height: 6, + width: 7, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [ + [0., 147., 0., 0., 0., 150., 0.], + [0., 159., 0., 0., 0., 162., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 159., 0., 0., 0., 162., 0.], + [0., 171., 0., 0., 0., 174., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 330., 0., 0., 0., 336., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 378., 0., 0., 0., 384., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 330., 0., 0., 0., 336., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 378., 0., 0., 0., 384., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 330., 0., 0., 0., 336., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 354., 0., 0., 0., 360., 0.], + [0., 378., 0., 0., 0., 384., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 183., 0., 0., 0., 186., 0.], + [0., 195., 0., 0., 0., 198., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 195., 0., 0., 0., 198., 0.], + [0., 207., 0., 0., 0., 210., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + ], + [ + [ + [0., 219., 0., 0., 0., 222., 0.], + [0., 231., 0., 0., 0., 234., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 231., 0., 0., 0., 234., 0.], + [0., 243., 0., 0., 0., 246., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 474., 0., 0., 0., 480., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 522., 0., 0., 0., 528., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 474., 0., 0., 0., 480., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 522., 0., 0., 0., 528., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 474., 0., 0., 0., 480., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 498., 0., 0., 0., 504., 0.], + [0., 522., 0., 0., 0., 528., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + [ + [0., 255., 0., 0., 0., 258., 0.], + [0., 267., 0., 0., 0., 270., 0.], + [0., 0., 0., 0., 0., 0., 0.], + [0., 267., 0., 0., 0., 270., 0.], + [0., 279., 0., 0., 0., 282., 0.], + [0., 0., 0., 0., 0., 0., 0.], + ], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [ + [0., 256., 272., 0.], + [0., 624., 656., 0.], + [0., 368., 384., 0.], + ], + [ + [0., 424., 440., 0.], + [0., 960., 992., 0.], + [0., 536., 552., 0.], + ], + ], + [ + [ + [0., 1096., 1112., 0.], + [0., 2304., 2336., 0.], + [0., 1208., 1224., 0.], + ], + [ + [0., 1264., 1280., 0.], + [0., 2640., 2672., 0.], + [0., 1376., 1392., 0.], + ], + ], + ], + [ + [ + [ + [0., 256., 272., 0.], + [0., 624., 656., 0.], + [0., 368., 384., 0.], + ], + [ + [0., 424., 440., 0.], + [0., 960., 992., 0.], + [0., 536., 552., 0.], + ], + ], + [ + [ + [0., 1096., 1112., 0.], + [0., 2304., 2336., 0.], + [0., 1208., 1224., 0.], + ], + [ + [0., 1264., 1280., 0.], + [0., 2640., 2672., 0.], + [0., 1376., 1392., 0.], + ], + ], + ], + [ + [ + [ + [0., 256., 272., 0.], + [0., 624., 656., 0.], + [0., 368., 384., 0.], + ], + [ + [0., 424., 440., 0.], + [0., 960., 992., 0.], + [0., 536., 552., 0.], + ], + ], + [ + [ + [0., 1096., 1112., 0.], + [0., 2304., 2336., 0.], + [0., 1208., 1224., 0.], + ], + [ + [0., 1264., 1280., 0.], + [0., 2640., 2672., 0.], + [0., 1376., 1392., 0.], + ], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([10., 10., 10.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv3d_groups_stride_2_no_pad() { + let test = Conv3dTestCase { + batch_size: 1, + channels_in: 4, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 0, + padding_2: 0, + padding_3: 0, + stride_1: 2, + stride_2: 2, + stride_3: 2, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 2, + depth: 4, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [ + [0., 1., 2., 0.], + [3., 4., 5., 0.], + [6., 7., 8., 0.], + [0., 0., 0., 0.], + ], + [ + [9., 10., 11., 0.], + [12., 13., 14., 0.], + [15., 16., 17., 0.], + [0., 0., 0., 0.], + ], + [ + [18., 19., 20., 0.], + [21., 22., 23., 0.], + [24., 25., 26., 0.], + [0., 0., 0., 0.], + ], + [ + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + ], + ], + [ + [ + [27., 28., 29., 0.], + [30., 31., 32., 0.], + [33., 34., 35., 0.], + [0., 0., 0., 0.], + ], + [ + [36., 37., 38., 0.], + [39., 40., 41., 0.], + [42., 43., 44., 0.], + [0., 0., 0., 0.], + ], + [ + [45., 46., 47., 0.], + [48., 49., 50., 0.], + [51., 52., 53., 0.], + [0., 0., 0., 0.], + ], + [ + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + ], + ], + [ + [ + [54., 55., 56., 0.], + [57., 58., 59., 0.], + [60., 61., 62., 0.], + [0., 0., 0., 0.], + ], + [ + [63., 64., 65., 0.], + [66., 67., 68., 0.], + [69., 70., 71., 0.], + [0., 0., 0., 0.], + ], + [ + [72., 73., 74., 0.], + [75., 76., 77., 0.], + [78., 79., 80., 0.], + [0., 0., 0., 0.], + ], + [ + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + ], + ], + [ + [ + [81., 82., 83., 0.], + [84., 85., 86., 0.], + [87., 88., 89., 0.], + [0., 0., 0., 0.], + ], + [ + [90., 91., 92., 0.], + [93., 94., 95., 0.], + [96., 97., 98., 0.], + [0., 0., 0., 0.], + ], + [ + [99., 100., 101., 0.], + [102., 103., 104., 0.], + [105., 106., 107., 0.], + [0., 0., 0., 0.], + ], + [ + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + ], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [[0., 1., 2.], [4., 5., 6.], [8., 9., 10.]], + [[16., 17., 18.], [20., 21., 22.], [24., 25., 26.]], + [[32., 33., 34.], [36., 37., 38.], [40., 41., 42.]], + ], + [ + [[64., 65., 66.], [68., 69., 70.], [72., 73., 74.]], + [[80., 81., 82.], [84., 85., 86.], [88., 89., 90.]], + [[96., 97., 98.], [100., 101., 102.], [104., 105., 106.]], + ], + ], + [ + [ + [[128., 129., 130.], [132., 133., 134.], [136., 137., 138.]], + [[144., 145., 146.], [148., 149., 150.], [152., 153., 154.]], + [[160., 161., 162.], [164., 165., 166.], [168., 169., 170.]], + ], + [ + [[192., 193., 194.], [196., 197., 198.], [200., 201., 202.]], + [[208., 209., 210.], [212., 213., 214.], [216., 217., 218.]], + [[224., 225., 226.], [228., 229., 230.], [232., 233., 234.]], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([1., 1.], &device), + }; + test.assert_grads(grads); +} + +struct Conv3dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + kernel_size_3: usize, + padding_1: usize, + padding_2: usize, + padding_3: usize, + stride_1: usize, + stride_2: usize, + stride_3: usize, + dilation_1: usize, + dilation_2: usize, + dilation_3: usize, + groups: usize, + depth: usize, + height: usize, + width: usize, +} + +struct Grads { + x: TestTensor<5>, + weight: TestTensor<5>, + bias: TestTensor<1>, +} + +impl Conv3dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([ + self.batch_size, + self.channels_in, + self.depth, + self.height, + self.width, + ]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size_1, + self.kernel_size_2, + self.kernel_size_3, + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<5, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<5, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = conv3d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvOptions::new( + [self.stride_1, self.stride_2, self.stride_3], + [self.padding_1, self.padding_2, self.padding_3], + [self.dilation_1, self.dilation_2, self.dilation_3], + self.groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + let tolerance = Tolerance::default(); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv_transpose1d.rs b/crates/burn-backend-tests/tests/autodiff/conv_transpose1d.rs new file mode 100644 index 0000000..f66ec3b --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv_transpose1d.rs @@ -0,0 +1,338 @@ +use super::*; +use burn_tensor::{Shape, Tolerance, module::conv_transpose1d, ops::ConvTransposeOptions}; + +#[test] +fn test_conv_transpose1d_basic() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: 3, + padding: 0, + padding_out: 0, + stride: 1, + dilation: 1, + groups: 1, + size: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[15.0, 15.0, 15.0, 15.0], [51.0, 51.0, 51.0, 51.0]], + [[15.0, 15.0, 15.0, 15.0], [51.0, 51.0, 51.0, 51.0]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[44.0, 44.0, 44.0], [44.0, 44.0, 44.0]], + [[76.0, 76.0, 76.0], [76.0, 76.0, 76.0]], + ], + &device, + ), + bias: TestTensor::from_data([12., 12.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose1d_padding() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: 3, + padding: 2, + padding_out: 0, + stride: 1, + dilation: 1, + groups: 1, + size: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[7., 12., 8., 3.], [19., 36., 32., 15.]], + [[7., 12., 8., 3.], [19., 36., 32., 15.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[26., 22., 18.], [26., 22., 18.]], + [[42., 38., 34.], [42., 38., 34.]], + ], + &device, + ), + bias: TestTensor::from_data([4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose1d_stride() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: 3, + padding: 0, + padding_out: 0, + stride: 2, + dilation: 1, + groups: 1, + size: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[44., 44., 44.], [44., 44., 44.]], + [[76., 76., 76.], [76., 76., 76.]], + ], + &device, + ), + bias: TestTensor::from_data([18., 18.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose1d_stride_padding_out() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: 3, + padding: 0, + padding_out: 1, + stride: 2, + dilation: 1, + groups: 1, + size: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[44., 44., 44.], [44., 44., 44.]], + [[76., 76., 76.], [76., 76., 76.]], + ], + &device, + ), + bias: TestTensor::from_data([20., 20.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose1d_dilation() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: 3, + padding: 0, + padding_out: 0, + stride: 1, + dilation: 2, + groups: 1, + size: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + [[15., 15., 15., 15.], [51., 51., 51., 51.]], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[44., 44., 44.], [44., 44., 44.]], + [[76., 76., 76.], [76., 76., 76.]], + ], + &device, + ), + bias: TestTensor::from_data([16., 16.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose1d_complex() { + let test = ConvTranspose1dTestCase { + batch_size: 2, + channels: [2, 4], + kernel_size: 3, + padding: 1, + padding_out: 1, + stride: 2, + dilation: 2, + groups: 2, + size: 8, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [12.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], + [36.0, 51.0, 51.0, 51.0, 51.0, 51.0, 51.0, 51.0], + ], + [ + [12.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], + [36.0, 51.0, 51.0, 51.0, 51.0, 51.0, 51.0, 51.0], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [[168.0, 184.0, 184.0], [168.0, 184.0, 184.0]], + [[280.0, 312.0, 312.0], [280.0, 312.0, 312.0]], + ], + &device, + ), + bias: TestTensor::from_data([36.0, 36.0, 36.0, 36.0], &device), + }; + test.assert_grads(grads); +} + +/// Regression test for #4845. +/// +/// `ConvTranspose1d` with `padding_out != 0` and `stride == 1` used to panic in +/// the backward pass because `conv_transpose1d_x_backward` did not account for +/// the trailing `padding_out` cells, producing a gradient longer than `x`. +#[test] +fn test_conv_transpose1d_padding_out_stride1_backward_shape() { + let device = AutodiffDevice::new(); + let batch_size = 2; + let channels_in = 2; + let channels_out = 2; + let kernel_size = 3; + let size_in = 4; + let padding_out = 1; + + let shape_x = Shape::new([batch_size, channels_in, size_in]); + let shape_weight = Shape::new([channels_in, channels_out, kernel_size]); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<3, _>(shape_weight.clone()) + .into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x.clone()) + .into_data(), + &device, + ) + .require_grad(); + + let output = conv_transpose1d( + x.clone(), + weight.clone(), + None, + ConvTransposeOptions::new([1], [0], [padding_out], [1], 1), + ); + let grads = output.backward(); + + let x_grad = x.grad(&grads).unwrap(); + let weight_grad = weight.grad(&grads).unwrap(); + assert_eq!(x_grad.shape(), shape_x); + assert_eq!(weight_grad.shape(), shape_weight); +} + +struct ConvTranspose1dTestCase { + batch_size: usize, + channels: [usize; 2], + kernel_size: usize, + padding: usize, + padding_out: usize, + stride: usize, + dilation: usize, + groups: usize, + size: usize, +} + +struct Grads { + x: TestTensor<3>, + weight: TestTensor<3>, + bias: TestTensor<1>, +} + +impl ConvTranspose1dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([self.batch_size, self.channels[0], self.size]); + let shape_weight = Shape::new([ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size, + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<3, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels[1] as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = conv_transpose1d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvTransposeOptions::new( + [self.stride], + [self.padding], + [self.padding_out], + [self.dilation], + self.groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), Tolerance::default()); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv_transpose2d.rs b/crates/burn-backend-tests/tests/autodiff/conv_transpose2d.rs new file mode 100644 index 0000000..a1a648a --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv_transpose2d.rs @@ -0,0 +1,706 @@ +use super::*; +use burn_tensor::{Shape, Tolerance, module::conv_transpose2d, ops::ConvTransposeOptions}; + +#[test] +fn test_conv_transpose2d_basic() { + let test = ConvTranspose2dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [0, 0], + stride: [1, 1], + dilation: [1, 1], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [153., 153., 153., 153.], + [153., 153., 153., 153.], + [153., 153., 153., 153.], + [153., 153., 153., 153.], + ], + [ + [477., 477., 477., 477.], + [477., 477., 477., 477.], + [477., 477., 477., 477.], + [477., 477., 477., 477.], + ], + ], + [ + [ + [153., 153., 153., 153.], + [153., 153., 153., 153.], + [153., 153., 153., 153.], + [153., 153., 153., 153.], + ], + [ + [477., 477., 477., 477.], + [477., 477., 477., 477.], + [477., 477., 477., 477.], + [477., 477., 477., 477.], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[752., 752., 752.], [752., 752., 752.], [752., 752., 752.]], + [[752., 752., 752.], [752., 752., 752.], [752., 752., 752.]], + ], + [ + [ + [1264., 1264., 1264.], + [1264., 1264., 1264.], + [1264., 1264., 1264.], + ], + [ + [1264., 1264., 1264.], + [1264., 1264., 1264.], + [1264., 1264., 1264.], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([72., 72.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_padding() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [1, 1], + kernel_size: [3, 3], + padding: [1, 2], + padding_out: [0, 0], + stride: [1, 1], + dilation: [1, 1], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[[ + [13., 24., 20., 9.], + [15., 27., 21., 9.], + [15., 27., 21., 9.], + [7., 12., 8., 3.], + ]]], + &device, + ), + weight: TestTensor::from_data( + [[[[63., 57., 51.], [68., 60., 52.], [39., 33., 27.]]]], + &device, + ), + bias: TestTensor::from_data([8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_stride() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [1, 1], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [0, 0], + stride: [2, 3], + dilation: [1, 1], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[[ + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + ]]], + &device, + ), + weight: TestTensor::from_data( + [[[[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]]]], + &device, + ), + bias: TestTensor::from_data([108.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_stride_padding_out() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [1, 1], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [1, 2], + stride: [2, 3], + dilation: [1, 1], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[[ + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + ]]], + &device, + ), + weight: TestTensor::from_data( + [[[[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]]]], + &device, + ), + bias: TestTensor::from_data([140.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_dilation() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [1, 1], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [0, 0], + stride: [1, 1], + dilation: [2, 3], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[[ + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + ]]], + &device, + ), + weight: TestTensor::from_data( + [[[[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]]]], + &device, + ), + bias: TestTensor::from_data([80.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_channels() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [2, 3], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [0, 0], + stride: [1, 1], + dilation: [1, 1], + groups: 1, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [351., 351., 351., 351.], + [351., 351., 351., 351.], + [351., 351., 351., 351.], + [351., 351., 351., 351.], + ], + [ + [1080., 1080., 1080., 1080.], + [1080., 1080., 1080., 1080.], + [1080., 1080., 1080., 1080.], + [1080., 1080., 1080., 1080.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]], + [[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]], + [[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]], + ], + [ + [[376., 376., 376.], [376., 376., 376.], [376., 376., 376.]], + [[376., 376., 376.], [376., 376., 376.], [376., 376., 376.]], + [[376., 376., 376.], [376., 376., 376.], [376., 376., 376.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([36., 36., 36.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_kernel_size() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [1, 1], + kernel_size: [3, 5], + padding: [0, 0], + padding_out: [0, 0], + stride: [1, 1], + dilation: [1, 1], + groups: 1, + size: [6, 6], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[[ + [105., 105., 105., 105., 105., 105.], + [105., 105., 105., 105., 105., 105.], + [105., 105., 105., 105., 105., 105.], + [105., 105., 105., 105., 105., 105.], + [105., 105., 105., 105., 105., 105.], + [105., 105., 105., 105., 105., 105.], + ]]], + &device, + ), + weight: TestTensor::from_data( + [[[ + [630., 630., 630., 630., 630.], + [630., 630., 630., 630., 630.], + [630., 630., 630., 630., 630.], + ]]], + &device, + ), + bias: TestTensor::from_data([80.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_groups() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [2, 2], + kernel_size: [3, 3], + padding: [0, 0], + padding_out: [0, 0], + stride: [1, 1], + dilation: [1, 1], + groups: 2, + size: [4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + [36., 36., 36., 36.], + ], + [ + [117., 117., 117., 117.], + [117., 117., 117., 117.], + [117., 117., 117., 117.], + [117., 117., 117., 117.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[[120., 120., 120.], [120., 120., 120.], [120., 120., 120.]]], + [[[376., 376., 376.], [376., 376., 376.], [376., 376., 376.]]], + ], + &device, + ), + bias: TestTensor::from_data([36., 36.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_complex_no_groups() { + let test = ConvTranspose2dTestCase { + batch_size: 2, + channels: [2, 3], + kernel_size: [3, 5], + padding: [1, 2], + padding_out: [1, 2], + stride: [2, 3], + dilation: [2, 3], + groups: 1, + size: [6, 8], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [600., 735., 735., 735., 735., 735., 735., 735.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + ], + [ + [1680., 2085., 2085., 2085., 2085., 2085., 2085., 2085.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + ], + ], + [ + [ + [600., 735., 735., 735., 735., 735., 735., 735.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + [810., 990., 990., 990., 990., 990., 990., 990.], + ], + [ + [1680., 2085., 2085., 2085., 2085., 2085., 2085., 2085.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + [2430., 3015., 3015., 3015., 3015., 3015., 3015., 3015.], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [5320., 6040., 6040., 6040., 6040.], + [6048., 6864., 6864., 6864., 6864.], + [6048., 6864., 6864., 6864., 6864.], + ], + [ + [5320., 6040., 6040., 6040., 6040.], + [6048., 6864., 6864., 6864., 6864.], + [6048., 6864., 6864., 6864., 6864.], + ], + [ + [5320., 6040., 6040., 6040., 6040.], + [6048., 6864., 6864., 6864., 6864.], + [6048., 6864., 6864., 6864., 6864.], + ], + ], + [ + [ + [8680., 9880., 9880., 9880., 9880.], + [10080., 11472., 11472., 11472., 11472.], + [10080., 11472., 11472., 11472., 11472.], + ], + [ + [8680., 9880., 9880., 9880., 9880.], + [10080., 11472., 11472., 11472., 11472.], + [10080., 11472., 11472., 11472., 11472.], + ], + [ + [8680., 9880., 9880., 9880., 9880.], + [10080., 11472., 11472., 11472., 11472.], + [10080., 11472., 11472., 11472., 11472.], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([896., 896., 896.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_complex_no_groups_2() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [4, 2], + kernel_size: [2, 3], + padding: [1, 2], + padding_out: [1, 2], + stride: [2, 3], + dilation: [1, 2], + groups: 1, + size: [10, 10], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [30., 42., 42., 42., 42., 42., 42., 42., 42., 42.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [48., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + ], + [ + [78., 114., 114., 114., 114., 114., 114., 114., 114., 114.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + [144., 210., 210., 210., 210., 210., 210., 210., 210., 210.], + ], + [ + [126., 186., 186., 186., 186., 186., 186., 186., 186., 186.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + [240., 354., 354., 354., 354., 354., 354., 354., 354., 354.], + ], + [ + [174., 258., 258., 258., 258., 258., 258., 258., 258., 258.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + [336., 498., 498., 498., 498., 498., 498., 498., 498., 498.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [[4455., 4905., 4905.], [4500., 4950., 4950.]], + [[4455., 4905., 4905.], [4500., 4950., 4950.]], + ], + [ + [[12555., 13905., 13905.], [13500., 14950., 14950.]], + [[12555., 13905., 13905.], [13500., 14950., 14950.]], + ], + [ + [[20655., 22905., 22905.], [22500., 24950., 24950.]], + [[20655., 22905., 22905.], [22500., 24950., 24950.]], + ], + [ + [[28755., 31905., 31905.], [31500., 34950., 34950.]], + [[28755., 31905., 31905.], [31500., 34950., 34950.]], + ], + ], + &device, + ), + bias: TestTensor::from_data([570., 570.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose2d_complex_groups() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels: [4, 2], + kernel_size: [2, 3], + padding: [1, 2], + padding_out: [1, 2], + stride: [2, 3], + dilation: [1, 2], + groups: 2, + size: [10, 10], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [9., 12., 12., 12., 12., 12., 12., 12., 12., 12.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + [12., 15., 15., 15., 15., 15., 15., 15., 15., 15.], + ], + [ + [21., 30., 30., 30., 30., 30., 30., 30., 30., 30.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + [36., 51., 51., 51., 51., 51., 51., 51., 51., 51.], + ], + [ + [33., 48., 48., 48., 48., 48., 48., 48., 48., 48.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + [60., 87., 87., 87., 87., 87., 87., 87., 87., 87.], + ], + [ + [45., 66., 66., 66., 66., 66., 66., 66., 66., 66.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + [84., 123., 123., 123., 123., 123., 123., 123., 123., 123.], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[[4455., 4905., 4905.], [4500., 4950., 4950.]]], + [[[12555., 13905., 13905.], [13500., 14950., 14950.]]], + [[[20655., 22905., 22905.], [22500., 24950., 24950.]]], + [[[28755., 31905., 31905.], [31500., 34950., 34950.]]], + ], + &device, + ), + bias: TestTensor::from_data([570., 570.], &device), + }; + test.assert_grads(grads); +} + +struct ConvTranspose2dTestCase { + batch_size: usize, + channels: [usize; 2], + kernel_size: [usize; 2], + padding: [usize; 2], + padding_out: [usize; 2], + stride: [usize; 2], + dilation: [usize; 2], + groups: usize, + size: [usize; 2], +} + +struct Grads { + x: TestTensor<4>, + weight: TestTensor<4>, + bias: TestTensor<1>, +} + +impl ConvTranspose2dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([ + self.batch_size, + self.channels[0], + self.size[0], + self.size[1], + ]); + let shape_weight = Shape::new([ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<4, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels[1] as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let output = conv_transpose2d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvTransposeOptions::new( + self.stride, + self.padding, + self.padding_out, + self.dilation, + self.groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + let tolerance = Tolerance::permissive(); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/conv_transpose3d.rs b/crates/burn-backend-tests/tests/autodiff/conv_transpose3d.rs new file mode 100644 index 0000000..970cbdf --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/conv_transpose3d.rs @@ -0,0 +1,711 @@ +use super::*; +use burn_tensor::{Shape, Tolerance, module::conv_transpose3d, ops::ConvTransposeOptions}; + +#[test] +fn test_conv_transpose3d_basic() { + let test = ConvTranspose3dTestCase { + batch_size: 2, + channels: [2, 2], + kernel_size: [3, 3, 3], + padding: [0, 0, 0], + padding_out: [0, 0, 0], + stride: [1, 1, 1], + dilation: [1, 1, 1], + groups: 1, + size: [4, 4, 4], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + ], + [ + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + ], + ], + [ + [ + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + [ + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + [13.250001, 13.250001, 13.250001, 13.250001], + ], + ], + [ + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + [ + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + [40.249992, 40.249992, 40.249992, 40.249992], + ], + ], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + ], + [ + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + [ + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + [47.750000, 47.750000, 47.750000], + ], + ], + ], + [ + [ + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + ], + [ + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + [ + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + [79.750000, 79.750000, 79.750000], + ], + ], + ], + ], + &device, + ), + bias: TestTensor::from_data([432., 432.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_conv_transpose3d_complex_groups() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels: [4, 2], + kernel_size: [2, 3, 4], + padding: [1, 2, 3], + padding_out: [1, 2, 3], + stride: [2, 3, 4], + dilation: [1, 2, 3], + groups: 2, + size: [6, 6, 6], + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [ + [1.250000, 1.625000, 1.625000, 1.625000, 1.625000, 1.625000], + [1.687500, 2.187500, 2.187500, 2.187500, 2.187500, 2.187500], + [1.687500, 2.187500, 2.187500, 2.187500, 2.187500, 2.187500], + [1.687500, 2.187500, 2.187500, 2.187500, 2.187500, 2.187500], + [1.687500, 2.187500, 2.187500, 2.187500, 2.187500, 2.187500], + [1.687500, 2.187500, 2.187500, 2.187500, 2.187500, 2.187500], + ], + [ + [1.750000, 2.250000, 2.250000, 2.250000, 2.250000, 2.250000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + ], + [ + [1.750000, 2.250000, 2.250000, 2.250000, 2.250000, 2.250000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + ], + [ + [1.750000, 2.250000, 2.250000, 2.250000, 2.250000, 2.250000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + ], + [ + [1.750000, 2.250000, 2.250000, 2.250000, 2.250000, 2.250000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + ], + [ + [1.750000, 2.250000, 2.250000, 2.250000, 2.250000, 2.250000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + [2.250000, 2.875000, 2.875000, 2.875000, 2.875000, 2.875000], + ], + ], + [ + [ + [2.750000, 3.625000, 3.625000, 3.625000, 3.625000, 3.625000], + [3.937500, 5.187500, 5.187500, 5.187500, 5.187500, 5.187500], + [3.937500, 5.187500, 5.187500, 5.187500, 5.187500, 5.187500], + [3.937500, 5.187500, 5.187500, 5.187500, 5.187500, 5.187500], + [3.937500, 5.187500, 5.187500, 5.187500, 5.187500, 5.187500], + [3.937500, 5.187500, 5.187500, 5.187500, 5.187500, 5.187500], + ], + [ + [4.750000, 6.250000, 6.250000, 6.250000, 6.250000, 6.250000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + ], + [ + [4.750000, 6.250000, 6.250000, 6.250000, 6.250000, 6.250000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + ], + [ + [4.750000, 6.250000, 6.250000, 6.250000, 6.250000, 6.250000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + ], + [ + [4.750000, 6.250000, 6.250000, 6.250000, 6.250000, 6.250000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + ], + [ + [4.750000, 6.250000, 6.250000, 6.250000, 6.250000, 6.250000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + [6.750000, 8.875000, 8.875000, 8.875000, 8.875000, 8.875000], + ], + ], + [ + [ + [4.250000, 5.625000, 5.625000, 5.625000, 5.625000, 5.625000], + [6.187500, 8.187500, 8.187500, 8.187500, 8.187500, 8.187500], + [6.187500, 8.187500, 8.187500, 8.187500, 8.187500, 8.187500], + [6.187500, 8.187500, 8.187500, 8.187500, 8.187500, 8.187500], + [6.187500, 8.187500, 8.187500, 8.187500, 8.187500, 8.187500], + [6.187500, 8.187500, 8.187500, 8.187500, 8.187500, 8.187500], + ], + [ + [ + 7.750000, 10.250000, 10.250000, 10.250000, 10.250000, 10.250000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + ], + [ + [ + 7.750000, 10.250000, 10.250000, 10.250000, 10.250000, 10.250000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + ], + [ + [ + 7.750000, 10.250000, 10.250000, 10.250000, 10.250000, 10.250000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + ], + [ + [ + 7.750000, 10.250000, 10.250000, 10.250000, 10.250000, 10.250000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + ], + [ + [ + 7.750000, 10.250000, 10.250000, 10.250000, 10.250000, 10.250000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + [ + 11.250000, 14.875000, 14.875000, 14.875000, 14.875000, 14.875000, + ], + ], + ], + [ + [ + [5.750000, 7.625000, 7.625000, 7.625000, 7.625000, 7.625000], + [ + 8.437500, 11.187500, 11.187500, 11.187500, 11.187500, 11.187500, + ], + [ + 8.437500, 11.187500, 11.187500, 11.187500, 11.187500, 11.187500, + ], + [ + 8.437500, 11.187500, 11.187500, 11.187500, 11.187500, 11.187500, + ], + [ + 8.437500, 11.187500, 11.187500, 11.187500, 11.187500, 11.187500, + ], + [ + 8.437500, 11.187500, 11.187500, 11.187500, 11.187500, 11.187500, + ], + ], + [ + [ + 10.750000, 14.250000, 14.250000, 14.250000, 14.250000, 14.250000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + ], + [ + [ + 10.750000, 14.250000, 14.250000, 14.250000, 14.250000, 14.250000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + ], + [ + [ + 10.750000, 14.250000, 14.250000, 14.250000, 14.250000, 14.250000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + ], + [ + [ + 10.750000, 14.250000, 14.250000, 14.250000, 14.250000, 14.250000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + ], + [ + [ + 10.750000, 14.250000, 14.250000, 14.250000, 14.250000, 14.250000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + [ + 15.750000, 20.875000, 20.875000, 20.875000, 20.875000, 20.875000, + ], + ], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [[ + [ + [18.663193, 22.309027, 22.309027, 22.309027], + [21.875000, 26.145834, 26.145834, 26.145834], + [21.875000, 26.145834, 26.145834, 26.145834], + ], + [ + [19.270832, 23.020834, 23.020834, 23.020834], + [22.500000, 26.875002, 26.875002, 26.875002], + [22.500000, 26.875002, 26.875002, 26.875002], + ], + ]], + [[ + [ + [49.913193, 59.809029, 59.809029, 59.809029], + [59.375000, 71.145836, 71.145836, 71.145836], + [59.375000, 71.145836, 71.145836, 71.145836], + ], + [ + [56.770836, 68.020836, 68.020836, 68.020836], + [67.500000, 80.875000, 80.875000, 80.875000], + [67.500000, 80.875000, 80.875000, 80.875000], + ], + ]], + [[ + [ + [81.163193, 97.309029, 97.309029, 97.309029], + [96.875000, 116.145828, 116.145828, 116.145828], + [96.875000, 116.145828, 116.145828, 116.145828], + ], + [ + [94.270828, 113.020828, 113.020828, 113.020828], + [112.500000, 134.875000, 134.875000, 134.875000], + [112.500000, 134.875000, 134.875000, 134.875000], + ], + ]], + [[ + [ + [112.413200, 134.809021, 134.809021, 134.809021], + [134.375000, 161.145828, 161.145828, 161.145828], + [134.375000, 161.145828, 161.145828, 161.145828], + ], + [ + [131.770844, 158.020828, 158.020828, 158.020828], + [157.500000, 188.875000, 188.875000, 188.875000], + [157.500000, 188.875000, 188.875000, 188.875000], + ], + ]], + ], + &device, + ), + bias: TestTensor::from_data([5346., 5346.], &device), + }; + test.assert_grads(grads); +} + +struct ConvTranspose3dTestCase { + batch_size: usize, + channels: [usize; 2], + kernel_size: [usize; 3], + padding: [usize; 3], + padding_out: [usize; 3], + stride: [usize; 3], + dilation: [usize; 3], + groups: usize, + size: [usize; 3], +} + +struct Grads { + x: TestTensor<5>, + weight: TestTensor<5>, + bias: TestTensor<1>, +} + +impl ConvTranspose3dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let shape_x = Shape::new([ + self.batch_size, + self.channels[0], + self.size[0], + self.size[1], + self.size[2], + ]); + let shape_weight = Shape::new([ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + self.kernel_size[2], + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<5, _>(shape_weight.clone()) + .into_data(), + &device, + ) + .div_scalar(shape_weight.num_elements() as f32) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels[1] as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<5, _>(shape_x.clone()) + .into_data(), + &device, + ) + .div_scalar(shape_x.num_elements() as f32) + .require_grad(); + let output = conv_transpose3d( + x.clone(), + weight.clone(), + Some(bias.clone()), + ConvTransposeOptions::new( + self.stride, + self.padding, + self.padding_out, + self.dilation, + self.groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + let tolerance = Tolerance::permissive(); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/cross.rs b/crates/burn-backend-tests/tests/autodiff/cross.rs new file mode 100644 index 0000000..58e9dcd --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cross.rs @@ -0,0 +1,130 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn backward_basic() { + let device = AutodiffDevice::new(); + let a = TestTensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + let b = TestTensor::<2>::from_data( + TensorData::from([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + + // Simple cross product; grad is a vector of ones. + let c = a.clone().cross(b.clone(), 1); + let grads = c.backward(); + + let a_grad = a.grad(&grads).unwrap().to_data(); + let b_grad = b.grad(&grads).unwrap().to_data(); + + // For a: b×grad_out, where grad_out = [1,1,1] + let expected_a = TensorData::from([[-1.0, 2.0, -1.0], [-1.0, 2.0, -1.0]]); + // For b: grad_out×a + let expected_b = TensorData::from([[1.0, -2.0, 1.0], [1.0, -2.0, 1.0]]); + + a_grad.assert_approx_eq::(&expected_a, Tolerance::default()); + b_grad.assert_approx_eq::(&expected_b, Tolerance::default()); +} + +#[test] +fn backward_after_sum() { + let device = AutodiffDevice::new(); + let a = TestTensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + let b = TestTensor::<2>::from_data( + TensorData::from([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + + // Sum reduces to scalar, but the gradient should be the same. + let c = a.clone().cross(b.clone(), 1).sum(); + let grads = c.backward(); + + let a_grad = a.grad(&grads).unwrap().to_data(); + let b_grad = b.grad(&grads).unwrap().to_data(); + + let expected_a = TensorData::from([[-1.0, 2.0, -1.0], [-1.0, 2.0, -1.0]]); + let expected_b = TensorData::from([[1.0, -2.0, 1.0], [1.0, -2.0, 1.0]]); + + a_grad.assert_approx_eq::(&expected_a, Tolerance::default()); + b_grad.assert_approx_eq::(&expected_b, Tolerance::default()); +} + +#[test] +fn different_dim() { + // Cross along a non-last dimension (dim 0) treats columns as 3-vectors. + let device = AutodiffDevice::new(); + let a_raw = [[1.0, 4.0, 7.0], [2.0, 5.0, 8.0], [3.0, 6.0, 9.0]]; + let b_raw = [[9.0, 6.0, 3.0], [8.0, 5.0, 2.0], [7.0, 4.0, 1.0]]; + + let a = TestTensor::<2>::from_data(TensorData::from(a_raw), &device); + let b = TestTensor::<2>::from_data(TensorData::from(b_raw), &device); + let out = a.cross(b.clone(), 0); + + // Manually compute cross of each column vector using raw arrays + let expected = [ + [ + a_raw[1][0] * b_raw[2][0] - a_raw[2][0] * b_raw[1][0], + a_raw[1][1] * b_raw[2][1] - a_raw[2][1] * b_raw[1][1], + a_raw[1][2] * b_raw[2][2] - a_raw[2][2] * b_raw[1][2], + ], + [ + a_raw[2][0] * b_raw[0][0] - a_raw[0][0] * b_raw[2][0], + a_raw[2][1] * b_raw[0][1] - a_raw[0][1] * b_raw[2][1], + a_raw[2][2] * b_raw[0][2] - a_raw[0][2] * b_raw[2][2], + ], + [ + a_raw[0][0] * b_raw[1][0] - a_raw[1][0] * b_raw[0][0], + a_raw[0][1] * b_raw[1][1] - a_raw[1][1] * b_raw[0][1], + a_raw[0][2] * b_raw[1][2] - a_raw[1][2] * b_raw[0][2], + ], + ]; + + out.to_data() + .assert_approx_eq::(&TensorData::from(expected), Tolerance::default()); +} + +#[test] +fn backward_non_last_dim() { + // Backward through a cross on dim 0. The autodiff rule recurses through + // float_cross with the same dim, so this also exercises the non-last-dim + // forward path on the gradient pass. + let device = AutodiffDevice::new(); + let a = TestTensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let b = TestTensor::<2>::from_data( + TensorData::from([[9.0, 8.0, 7.0], [6.0, 5.0, 4.0], [3.0, 2.0, 1.0]]), + &device, + ) + .require_grad(); + + // Cross on the column dimension, then permute and cross on the last dim. + // Both paths share the same expected gradient magnitudes; we compare the + // dim-0 output against the reference last-dim output to keep the check + // backend-agnostic. + let c0 = a.clone().cross(b.clone(), 0); + let c_ref = a + .clone() + .permute([1, 0]) + .cross(b.clone().permute([1, 0]), 1) + .permute([1, 0]); + + c0.to_data() + .assert_approx_eq::(&c_ref.to_data(), Tolerance::default()); + + let grads = c0.sum().backward(); + assert!(a.grad(&grads).is_some()); + assert!(b.grad(&grads).is_some()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cross_entropy.rs b/crates/burn-backend-tests/tests/autodiff/cross_entropy.rs new file mode 100644 index 0000000..2925039 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cross_entropy.rs @@ -0,0 +1,32 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance, loss}; + +#[test] +fn test_cross_entropy_loss_grad() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + let data_targets = TensorData::from([[0.8, 0.2], [0.9, 0.1]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device).require_grad(); + let tensor_targets = TestTensor::<2>::from_data(data_targets, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = loss::cross_entropy_with_logits(tensor_3, tensor_targets); + + let grads = tensor_4.backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::permissive(); + let expected = TensorData::from([[0.26553, 0.26553], [0.44954, 0.44954]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[-1.34863, 1.34863], [-2.06371, 2.06371]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/ctc.rs b/crates/burn-backend-tests/tests/autodiff/ctc.rs new file mode 100644 index 0000000..2d1b016 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/ctc.rs @@ -0,0 +1,754 @@ +#![allow(clippy::excessive_precision)] + +use super::*; +use burn_tensor::{Device, TensorData, Tolerance, activation::log_softmax, module::ctc_loss}; + +/// Deterministic logits: sin((t*7 + n*13 + c*3) * 0.1). Matches the +/// fixture generator in `burn-nn/src/loss/ctc.rs`'s PyTorch comparison tests. +fn generate_logits(t_size: usize, n_size: usize, c_size: usize, device: &Device) -> TestTensor<3> { + let mut data = Vec::with_capacity(t_size * n_size * c_size); + for t in 0..t_size { + for n in 0..n_size { + for c in 0..c_size { + data.push(((t * 7 + n * 13 + c * 3) as f32 * 0.1).sin()); + } + } + } + TestTensor::<3>::from_data(TensorData::new(data, [t_size, n_size, c_size]), device) +} + +#[allow(clippy::too_many_arguments)] +fn run_comparison( + t_size: usize, + n_size: usize, + c_size: usize, + targets_flat: Vec, + target_shape: [usize; 2], + input_lengths: Vec, + target_lengths: Vec, + blank: usize, + expected_grad_flat: &[f32], +) { + let device = AutodiffDevice::new(); + let logits = generate_logits(t_size, n_size, c_size, &device).require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + + let targets = + TestTensorInt::<2>::from_data(TensorData::new(targets_flat, target_shape), &device); + let input_lengths = + TestTensorInt::<1>::from_data(TensorData::new(input_lengths, [n_size]), &device); + let target_lengths = + TestTensorInt::<1>::from_data(TensorData::new(target_lengths, [n_size]), &device); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, blank).sum(); + let grads = loss.backward(); + let logits_grad = logits.grad(&grads).expect( + "logits should receive a gradient - if this fails on a backend with a fused ctc_loss \ + kernel, the autodiff override needs to skip the fused path", + ); + + logits_grad.into_data().assert_approx_eq::( + &TensorData::new(expected_grad_flat.to_vec(), [t_size, n_size, c_size]), + Tolerance::rel_abs(1e-3, 1e-3).set_half_precision_absolute(1e-2), + ); +} + +/// Original coverage: uniform input_lengths, no repeated labels, small T. +/// Keeps a first-row sanity check against the PyTorch reference so a bug in +/// the first batch element's first timestep is caught quickly. +#[test] +fn test_ctc_loss_grad() { + let t_size: usize = 5; + let n_size: usize = 3; + let c_size: usize = 4; + let device = AutodiffDevice::new(); + + let logits = generate_logits(t_size, n_size, c_size, &device).require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + + let targets = + TestTensorInt::<2>::from_data(TensorData::from([[1, 2, 0], [1, 0, 0], [3, 2, 1]]), &device); + let input_lengths = TestTensorInt::<1>::from_data(TensorData::from([5, 5, 5]), &device); + let target_lengths = TestTensorInt::<1>::from_data(TensorData::from([2, 1, 3]), &device); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0).sum(); + let grads = loss.backward(); + let logits_grad = logits.grad(&grads).expect( + "logits should receive a gradient - if this fails on a backend with a fused ctc_loss \ + kernel, the autodiff override needs to skip the fused path", + ); + + let expected_first_row = + TensorData::from([-0.1679008007_f32, -0.4595540464, 0.2795598209, 0.3478950262]); + let logits_grad_data = logits_grad.into_data(); + let first_row: Vec = logits_grad_data.iter::().take(4).collect(); + TensorData::from(first_row.as_slice()) + .assert_approx_eq::(&expected_first_row, Tolerance::rel_abs(1e-3, 1e-3)); +} + +/// Variable input_lengths exercise the per-sample `t_last = input_len - 1` +/// boundary init in the beta recursion and the OOB mask in the gradient +/// composition. Fixture lifted from burn-nn's pytorch_comparison_tests. +#[test] +fn test_ctc_loss_grad_mixed_input_lengths() { + // T=12, N=3, C=5, input_lengths=[12, 7, 10], target_lengths=[3, 2, 4]. + let expected_grad_flat = [ + -0.4790987670_f32, + -0.2554937005, + 0.1991624236, + 0.2478453964, + 0.2875846624, + -0.3495813310, + 0.2268397957, + 0.2150714993, + -0.2442178279, + 0.1518878639, + -0.2764556706, + 0.2474014312, + -0.2137086987, + 0.1371368915, + 0.1056260392, + -0.2729502618, + -0.3609606028, + 0.2159237266, + 0.2238420397, + 0.1941450834, + -0.2953839302, + 0.1920599341, + 0.1974952668, + -0.2054278404, + 0.1112565696, + -0.1719199270, + 0.2299505472, + -0.2864859998, + 0.1497263014, + 0.0787290633, + -0.2035763413, + -0.3042884767, + 0.2126964629, + 0.1810975969, + 0.1140707731, + -0.2759391963, + 0.0975771844, + 0.1823379993, + -0.1112988219, + 0.1073228419, + -0.1336459517, + 0.1869296581, + -0.1996247321, + 0.1846873760, + -0.0383463502, + -0.2254105806, + -0.1834360659, + 0.1925925612, + 0.1462381780, + 0.0700158924, + -0.2259973884, + -0.0393539183, + 0.1802661419, + -0.0571591072, + 0.1422442794, + -0.0609069727, + 0.1089282706, + -0.0313654318, + 0.2186669111, + -0.2353227735, + -0.2840364873, + -0.0632198900, + 0.1755636632, + 0.1377806067, + 0.0339120962, + -0.1904856712, + -0.2139032930, + 0.1827126741, + 0.0056131603, + 0.2160631120, + -0.0243270602, + -0.0070458520, + 0.1070247591, + 0.2239368409, + -0.2995886803, + -0.2955487072, + 0.0309870224, + 0.1654911339, + 0.1581364125, + -0.0590658709, + -0.2191396207, + -0.3791662455, + 0.1803640425, + 0.1225430891, + 0.2953987718, + -0.0436352938, + -0.1575258970, + 0.1785279512, + 0.1756918877, + -0.1530586481, + -0.1834939867, + 0.0909025446, + 0.1423641294, + 0.1959712654, + -0.2457439601, + -0.3619639874, + -0.3929221630, + 0.1820438206, + 0.2454170734, + 0.3274252713, + -0.0628800318, + -0.2567180395, + 0.2112283260, + 0.0507859327, + 0.0575838275, + -0.0587697029, + 0.1174769849, + 0.0783569664, + 0.2290501744, + -0.3661144078, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.0725664943, + -0.1532069892, + 0.2162397504, + -0.1248963475, + 0.1344300956, + -0.0362483934, + 0.1295878887, + -0.0502482466, + 0.2470482886, + -0.2901395261, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.1349253207, + 0.0867646411, + 0.1998746395, + -0.2658679783, + 0.1141540110, + -0.0705668628, + 0.1519546807, + -0.2509805560, + 0.2475892603, + -0.0779965296, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.2338010073, + 0.2471641302, + 0.1834627241, + -0.3026831448, + 0.1058573127, + -0.1155209392, + 0.1921830922, + -0.4129956067, + 0.2229512781, + 0.1133821756, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.2636392713, + 0.2323469073, + -0.2913427949, + 0.1800564528, + 0.1425786912, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ]; + run_comparison( + 12, + 3, + 5, + vec![1, 4, 2, 0, 3, 1, 0, 0, 2, 4, 1, 3], + [3, 4], + vec![12, 7, 10], + vec![3, 2, 4], + 0, + &expected_grad_flat, + ); +} + +/// Consecutive repeated labels `[1, 1, ...]` force the alpha and beta skip +/// transitions to stay disabled (`l'[s] != l'[s-2]` fails). Exercises the +/// `skip_allowed` branch condition on the fused kernel. +#[test] +fn test_ctc_loss_grad_repeated_labels() { + // T=8, N=4, C=6, targets include the consecutive pair [1, 1, 2]. + let expected_grad_flat = [ + -0.2766432464_f32, + -0.5202965736, + 0.1523768753, + 0.1896236390, + 0.2200277001, + 0.2349116206, + -0.1854365915, + 0.2031330466, + -0.4260218740, + 0.1678018719, + 0.1360142529, + 0.1045092493, + -0.6603536606, + 0.2278252542, + 0.1691786796, + 0.1262856424, + 0.0972681716, + 0.0397959016, + -0.0894432291, + -0.5457318425, + 0.1490373611, + 0.1462858170, + 0.1569476575, + 0.1829041988, + -0.2842915654, + -0.4220107496, + 0.1822281033, + 0.1889107376, + 0.1791101843, + 0.1560532600, + -0.1155678406, + 0.2295538932, + -0.2645366490, + -0.0288553704, + 0.1027252972, + 0.0766806602, + -0.5448347330, + 0.2031028718, + 0.1589304954, + 0.1322451383, + 0.1189499870, + -0.0683937520, + -0.0873993114, + -0.3051757514, + -0.2355299890, + 0.1586059481, + 0.2018169016, + 0.2676822543, + -0.3225219846, + -0.2611543834, + 0.1922984123, + 0.1632783115, + 0.1297036558, + 0.0983960181, + -0.1507159024, + 0.2256962359, + -0.1040333956, + -0.1514528394, + 0.0985243544, + 0.0819815546, + -0.2940836251, + 0.1586865336, + 0.1468491107, + 0.1485087872, + 0.1639631987, + -0.3239239752, + -0.0767390430, + -0.0434846729, + -0.4023587406, + -0.0052628326, + 0.2273432612, + 0.3005020022, + -0.2598774135, + -0.2188862711, + 0.1678501070, + 0.1352078766, + 0.1002781317, + 0.0754275694, + -0.1502914876, + 0.1930875033, + -0.0709601715, + -0.2219523191, + 0.1243555173, + 0.1257609427, + -0.0574148744, + 0.1152269915, + 0.1307857931, + 0.1599020809, + 0.2068412602, + -0.5553412437, + -0.0536844917, + 0.0758557543, + -0.2106334567, + -0.2509877980, + 0.1757438034, + 0.2637061775, + -0.1759711355, + -0.2431350052, + 0.1071053818, + 0.1259848624, + 0.1004033238, + 0.0856125653, + -0.1173698306, + 0.1213828772, + -0.1768893301, + -0.2070008069, + 0.1709136516, + 0.2089634240, + 0.0153109450, + 0.0967332721, + 0.1268781722, + 0.1706230640, + 0.2291058898, + -0.6386513710, + -0.0536664203, + 0.1378114969, + 0.0360041447, + -0.2989685237, + -0.0084722806, + 0.1872915775, + -0.1523490399, + -0.2111770809, + -0.0390694551, + 0.1366800815, + 0.1302325875, + 0.1356829405, + -0.0982905105, + -0.0127884001, + -0.3586881459, + -0.0259541404, + 0.2114149332, + 0.2843062580, + -0.0324133746, + 0.1084750593, + 0.1447229236, + 0.1862253845, + 0.2259712219, + -0.6329812407, + -0.1173689738, + 0.1914442331, + 0.1654772907, + -0.1376858056, + -0.2194855511, + 0.1176188141, + -0.1529908478, + -0.0606661662, + -0.3384291232, + 0.1524862647, + 0.1777049750, + 0.2218948901, + -0.0923086405, + -0.2855934799, + -0.3215619624, + 0.1726681292, + 0.2303666323, + 0.2964293361, + -0.2508065701, + 0.1479703039, + 0.1753441393, + 0.1917535067, + 0.1919818372, + -0.4562432170, + -0.2350299209, + 0.2257601619, + 0.1863904297, + 0.0388212129, + -0.2966264784, + 0.0806845874, + -0.1992894858, + 0.1068909168, + -0.5761897564, + 0.1624972969, + 0.2155302167, + 0.2905607820, + -0.1168124676, + -0.6870660186, + 0.1488010883, + 0.1881926507, + 0.2230074406, + 0.2438773215, + -0.5771554708, + 0.1980127096, + 0.1924194694, + 0.1714663208, + 0.1415647417, + -0.1263078004, + -0.3408652246, + 0.2292248607, + 0.1707807332, + 0.1269564927, + -0.2634142637, + 0.0773174241, + ]; + run_comparison( + 8, + 4, + 6, + vec![1, 1, 2, 0, 2, 3, 2, 1, 5, 0, 0, 0, 1, 2, 3, 4], + [4, 4], + vec![8, 8, 8, 8], + vec![3, 4, 1, 4], + 0, + &expected_grad_flat, + ); +} + +/// Empty target (`target_length == 0`). Analytical gradient: with uniform +/// logits the only valid alignment is all-blank, so for each `(t, c)` the +/// gradient of `sum(loss)` w.r.t. logits is `-0.5` at the blank class and +/// `+0.5` at the other class. +#[test] +fn test_ctc_loss_grad_empty_target() { + let t_size: usize = 3; + let n_size: usize = 1; + let c_size: usize = 2; + let device = AutodiffDevice::new(); + + // Uniform logits (any value works; softmax normalizes). Use 0.0 for clarity. + let logits = TestTensor::<3>::from_data( + TensorData::new( + alloc::vec![0.0f32; t_size * n_size * c_size], + [t_size, n_size, c_size], + ), + &device, + ) + .require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + + // Dummy target column (never read: target_length is 0). Using [1, 1] + // avoids backends that don't support zero-sized dims. + let targets = TestTensorInt::<2>::from_data(TensorData::from([[0_i64]]), &device); + let input_lengths = TestTensorInt::<1>::from_data(TensorData::from([3_i64]), &device); + let target_lengths = TestTensorInt::<1>::from_data(TensorData::from([0_i64]), &device); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0).sum(); + let grads = loss.backward(); + let logits_grad = logits.grad(&grads).unwrap(); + + let expected = TensorData::new( + alloc::vec![-0.5_f32, 0.5, -0.5, 0.5, -0.5, 0.5], + [t_size, n_size, c_size], + ); + logits_grad + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-4, 1e-4)); +} + +/// Regression guard for NaN gradient on unreachable targets, driven through +/// `.backward()` rather than a direct `ctc_loss_backward` call. The direct-path +/// version is covered by `test_ctc_loss_backward_unreachable_is_finite`; this +/// test exists because a lazy-backend regression in log_sum_exp or the +/// grad-composition mask can leave NaN in the autodiff trace even when the +/// direct backward call is finite. +/// +/// Note: the direct backward asserts grad == 0 (the mask inside +/// `ctc_grad_from_alpha_beta_default` zeroes entries for +inf nll). The autodiff +/// path currently produces a non-zero but finite gradient for this case because +/// `loss.sum().backward()` seeds through a +inf forward value. Finiteness is +/// what guards training stability; the zero-vs-finite-nonzero difference is a +/// separate concern. +#[test] +fn test_ctc_loss_grad_unreachable_is_finite() { + let device = AutodiffDevice::new(); + + // T=2, target=[1, 1] needs three steps, so nll = +inf for this sample. + let log_probs = TestTensor::<3>::full([2, 1, 3], (1.0f32 / 3.0).ln(), &device).require_grad(); + + let targets = TestTensorInt::<2>::from_data(TensorData::from([[1_i64, 1]]), &device); + let input_lengths = TestTensorInt::<1>::from_data(TensorData::from([2_i64]), &device); + let target_lengths = TestTensorInt::<1>::from_data(TensorData::from([2_i64]), &device); + + let loss = ctc_loss(log_probs.clone(), targets, input_lengths, target_lengths, 0).sum(); + let grads = loss.backward(); + let log_probs_grad = log_probs.grad(&grads).unwrap(); + for v in log_probs_grad.into_data().iter::() { + assert!( + v.is_finite(), + "gradient through .backward() must be finite for unreachable targets, got {v}", + ); + } +} + +/// Gradient through `.backward()` on a `swap_dims + narrow` input chain, a layout +/// produced by typical training code ([B, T, C] model output -> [T, B, C] -> +/// skip warmup). Forward-only tests cover `narrow` and `swap_dims+narrow`; this +/// also checks that the autodiff path preserves strides end-to-end rather than +/// materializing a contiguous copy with wrong offsets. +#[test] +fn test_ctc_loss_grad_swap_dims_then_narrow() { + let b: usize = 2; + let t_full: usize = 8; + let warmup: usize = 2; + let t_eff: usize = t_full - warmup; + let c: usize = 4; + + let device = AutodiffDevice::new(); + + let mut data = Vec::with_capacity(b * t_full * c); + for bi in 0..b { + for ti in 0..t_full { + for ci in 0..c { + data.push(((bi * 31 + ti * 7 + ci * 3) as f32 * 0.1).sin()); + } + } + } + + // Path A: [B, T, C] -> swap -> narrow, all on a single require_grad leaf. + let logits_btc_a = + TestTensor::<3>::from_data(TensorData::new(data.clone(), [b, t_full, c]), &device) + .require_grad(); + let log_probs_a = log_softmax(logits_btc_a.clone(), 2) + .swap_dims(0, 1) + .narrow(0, warmup, t_eff); + + let targets_a = TestTensorInt::<2>::from_data(TensorData::from([[1_i64, 2], [2, 3]]), &device); + let input_lengths_a = + TestTensorInt::<1>::from_data(TensorData::from([t_eff as i64, t_eff as i64]), &device); + let target_lengths_a = TestTensorInt::<1>::from_data(TensorData::from([2_i64, 2]), &device); + + let loss_a = ctc_loss(log_probs_a, targets_a, input_lengths_a, target_lengths_a, 0).sum(); + let grads_a = loss_a.backward(); + let grad_a = logits_btc_a.grad(&grads_a).unwrap(); + + // Path B: build the equivalent [T, B, C] contiguous layout up front so the + // log_softmax -> ctc_loss chain sees a plain contiguous tensor. Transpose + // the incoming data to [T, B, C] order. + let mut data_tbc = Vec::with_capacity(t_full * b * c); + for ti in 0..t_full { + for bi in 0..b { + for ci in 0..c { + data_tbc.push(((bi * 31 + ti * 7 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let logits_tbc_b = + TestTensor::<3>::from_data(TensorData::new(data_tbc, [t_full, b, c]), &device) + .require_grad(); + let log_probs_b = log_softmax(logits_tbc_b.clone(), 2).narrow(0, warmup, t_eff); + + let targets_b = TestTensorInt::<2>::from_data(TensorData::from([[1_i64, 2], [2, 3]]), &device); + let input_lengths_b = + TestTensorInt::<1>::from_data(TensorData::from([t_eff as i64, t_eff as i64]), &device); + let target_lengths_b = TestTensorInt::<1>::from_data(TensorData::from([2_i64, 2]), &device); + + let loss_b = ctc_loss(log_probs_b, targets_b, input_lengths_b, target_lengths_b, 0).sum(); + let grads_b = loss_b.backward(); + let grad_b_tbc = logits_tbc_b.grad(&grads_b).unwrap(); + + // Compare path A's grad (laid out [B, T, C]) against path B's grad (laid + // out [T, B, C]) element by element. + let grad_a_data: Vec = grad_a.into_data().iter::().collect(); + let grad_b_data: Vec = grad_b_tbc.into_data().iter::().collect(); + for bi in 0..b { + for ti in 0..t_full { + for ci in 0..c { + let idx_a = (bi * t_full + ti) * c + ci; + let idx_b = (ti * b + bi) * c + ci; + let diff = (grad_a_data[idx_a] - grad_b_data[idx_b]).abs(); + assert!( + diff < 1e-3, + "grad mismatch at (b={bi}, t={ti}, c={ci}): A={} vs B={}", + grad_a_data[idx_a], + grad_b_data[idx_b], + ); + } + } + } +} + +/// Autodiff at production scale. The training repo (speechlet exp-11) reported +/// loss ~0.18 instead of ~1250 on a freshly-initialized model when log_probs +/// were routed through `log_softmax -> swap_dims(0,1) -> narrow(0, warmup, ...)` +/// on `Autodiff>`, while the contiguous-[T, B, C] reference +/// (same raw data) produced the correct value. Reproduces that exact shape +/// chain with a deterministic fixture and compares the two paths. +#[test] +fn test_ctc_loss_scaled_swap_dims_then_narrow_autodiff() { + let b: usize = 2; + let t_full: usize = 494; + let warmup: usize = 92; + let t_eff: usize = t_full - warmup; + let c: usize = 36; + + let device = AutodiffDevice::new(); + + let mut data_btc = Vec::with_capacity(b * t_full * c); + for bi in 0..b { + for ti in 0..t_full { + for ci in 0..c { + data_btc.push(((bi * 31 + ti * 7 + ci * 3) as f32 * 0.1).sin()); + } + } + } + + let logits_btc_a = + TestTensor::<3>::from_data(TensorData::new(data_btc.clone(), [b, t_full, c]), &device) + .require_grad(); + let log_probs_a = log_softmax(logits_btc_a, 2) + .swap_dims(0, 1) + .narrow(0, warmup, t_eff); + + let mut targets_data = alloc::vec![0_i64; b * 64]; + for bi in 0..b { + let max = if bi == 0 { 64 } else { 43 }; + for si in 0..max { + targets_data[bi * 64 + si] = ((si * 3 + bi * 7) % 35 + 1) as i64; + } + } + let targets_a = + TestTensorInt::<2>::from_data(TensorData::new(targets_data.clone(), [b, 64]), &device); + let input_lengths_a = + TestTensorInt::<1>::from_data(TensorData::from([t_eff as i64, t_eff as i64]), &device); + let target_lengths_a = TestTensorInt::<1>::from_data(TensorData::from([64_i64, 43]), &device); + + let loss_a_per_sample = ctc_loss(log_probs_a, targets_a, input_lengths_a, target_lengths_a, 0); + let loss_a_vec: Vec = loss_a_per_sample + .clone() + .into_data() + .iter::() + .collect(); + + // Path B: build the equivalent contiguous [T, B, C] layout up front. + let mut data_tbc = Vec::with_capacity(t_full * b * c); + for ti in 0..t_full { + for bi in 0..b { + for ci in 0..c { + data_tbc.push(((bi * 31 + ti * 7 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let logits_tbc_b = + TestTensor::<3>::from_data(TensorData::new(data_tbc, [t_full, b, c]), &device); + let log_probs_b = log_softmax(logits_tbc_b, 2).narrow(0, warmup, t_eff); + + let targets_b = TestTensorInt::<2>::from_data(TensorData::new(targets_data, [b, 64]), &device); + let input_lengths_b = + TestTensorInt::<1>::from_data(TensorData::from([t_eff as i64, t_eff as i64]), &device); + let target_lengths_b = TestTensorInt::<1>::from_data(TensorData::from([64_i64, 43]), &device); + + let loss_b_per_sample = ctc_loss(log_probs_b, targets_b, input_lengths_b, target_lengths_b, 0); + let loss_b_vec: Vec = loss_b_per_sample.into_data().iter::().collect(); + + for i in 0..b { + assert!( + loss_a_vec[i] > 0.0, + "sample {i}: autodiff+swap_dims+narrow loss is not positive ({}); a valid CTC \ + loss must be >= 0 for log-probs <= 0", + loss_a_vec[i], + ); + assert!( + (loss_a_vec[i] - loss_b_vec[i]).abs() < 1.0, + "sample {i}: autodiff swap_dims+narrow loss ({}) diverges from contiguous \ + reference loss ({})", + loss_a_vec[i], + loss_b_vec[i], + ); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/cummax.rs b/crates/burn-backend-tests/tests/autodiff/cummax.rs new file mode 100644 index 0000000..17ef405 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cummax.rs @@ -0,0 +1,115 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_cummax() { + // Simple test to verify cummax gradients work + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([1.0, 3.0, 2.0]), &device).require_grad(); + + let output = tensor.clone().cummax(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 2.0, 0.0] + let expected = TensorData::from([1.0, 2.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummax_2d() { + // Test 2D cummax gradients + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1.0, 3.0, 2.0], [2.0, 5.0, 4.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().cummax(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [[1.0, 2.0, 0.0], [1.0, 2.0, 0.0]] + let expected = TensorData::from([[1.0, 2.0, 0.0], [1.0, 2.0, 0.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummax_duplicate_values() { + // Test with duplicate maximum values - critical edge case + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([1.0, 3.0, 3.0, 2.0]), &device).require_grad(); + + let output = tensor.clone().cummax(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // input: [1.0, 3.0, 3.0, 2.0] + // cummax: [1.0, 3.0, 3.0, 3.0] + // PyTorch reference: [1.0, 1.0, 2.0, 0.0] + // Position 2 gets grad from itself + position 3 + let expected = TensorData::from([1.0, 1.0, 2.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummax_all_same() { + // Test with all same values + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 2.0, 2.0]), &device).require_grad(); + + let output = tensor.clone().cummax(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 1.0, 1.0] + // Each position matches cummax, so each gets its own gradient + let expected = TensorData::from([1.0, 1.0, 1.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummax_increasing() { + // Test with increasing sequence + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([1.0, 2.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cummax(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 1.0, 1.0, 1.0] + // Each position is a new maximum + let expected = TensorData::from([1.0, 1.0, 1.0, 1.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummax_2d_duplicates() { + // Test 2D with duplicate values + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1.0, 3.0, 3.0, 2.0], [2.0, 5.0, 5.0, 4.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().cummax(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [[1.0, 1.0, 2.0, 0.0], [1.0, 1.0, 2.0, 0.0]] + let expected = TensorData::from([[1.0, 1.0, 2.0, 0.0], [1.0, 1.0, 2.0, 0.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cummin.rs b/crates/burn-backend-tests/tests/autodiff/cummin.rs new file mode 100644 index 0000000..04147fe --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cummin.rs @@ -0,0 +1,115 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_cummin() { + // Simple test to verify cummin gradients work + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([3.0, 2.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cummin(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 2.0, 0.0] + let expected = TensorData::from([1.0, 2.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummin_2d() { + // Test 2D cummin gradients + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[3.0, 2.0, 4.0], [5.0, 1.0, 3.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().cummin(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [[1.0, 2.0, 0.0], [1.0, 2.0, 0.0]] + let expected = TensorData::from([[1.0, 2.0, 0.0], [1.0, 2.0, 0.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummin_duplicate_values() { + // Test with duplicate minimum values - critical edge case + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([3.0, 2.0, 2.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cummin(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // input: [3.0, 2.0, 2.0, 4.0] + // cummin: [3.0, 2.0, 2.0, 2.0] + // PyTorch reference: [1.0, 1.0, 2.0, 0.0] + // Position 2 gets grad from itself + position 3 + let expected = TensorData::from([1.0, 1.0, 2.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummin_all_same() { + // Test with all same values + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 2.0, 2.0]), &device).require_grad(); + + let output = tensor.clone().cummin(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 1.0, 1.0] + // Each position matches cummin, so each gets its own gradient + let expected = TensorData::from([1.0, 1.0, 1.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummin_decreasing() { + // Test with decreasing sequence + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([5.0, 4.0, 3.0, 2.0]), &device).require_grad(); + + let output = tensor.clone().cummin(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 1.0, 1.0, 1.0] + // Each position is a new minimum + let expected = TensorData::from([1.0, 1.0, 1.0, 1.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cummin_2d_duplicates() { + // Test 2D with duplicate values + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[3.0, 2.0, 2.0, 4.0], [5.0, 1.0, 1.0, 3.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().cummin(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [[1.0, 1.0, 2.0, 0.0], [1.0, 1.0, 2.0, 0.0]] + let expected = TensorData::from([[1.0, 1.0, 2.0, 0.0], [1.0, 1.0, 2.0, 0.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cumprod.rs b/crates/burn-backend-tests/tests/autodiff/cumprod.rs new file mode 100644 index 0000000..1196001 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cumprod.rs @@ -0,0 +1,128 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_cumprod() { + // Simple test to verify cumprod gradients work + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cumprod(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [16.0, 10.0, 6.0] + let expected = TensorData::from([16.0, 10.0, 6.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cumprod_2d() { + // Test 2D cumprod gradients + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + + let output = tensor.clone().cumprod(1); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [[9.0, 4.0, 2.0], [36.0, 28.0, 20.0]] + let expected = TensorData::from([[9.0, 4.0, 2.0], [36.0, 28.0, 20.0]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +// TODO: The following tests are currently ignored due to a known limitation +// in the cumprod gradient implementation. The current implementation uses +// division (grad / input), which produces NaN when the input contains zeros. +// +// A proper fix requires implementing a zero-safe algorithm using exclusive +// cumulative products (similar to PyTorch's cumprod_backward or JAX's +// associative_scan approach). This is a non-trivial implementation that +// requires careful handling of cumulative products in both forward and +// reverse directions. +// +// See: https://github.com/tracel-ai/burn/issues/3864 +// +// References: +// - PyTorch: https://github.com/pytorch/pytorch (cumprod_backward) +// - JAX PR #2596: Parallel prefix scan implementation +// - TensorFlow Issue #3862: tf.cumprod's gradient produces nans given zeros + +#[test] +#[ignore = "cumprod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_cumprod_zero_in_middle() { + // Test cumprod with zero in the middle - edge case for division + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 0.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cumprod(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 32.0, 0.0, 0.0] + let expected = TensorData::from([1.0, 32.0, 0.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[ignore = "cumprod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_cumprod_zero_at_start() { + // Test cumprod with zero at the beginning + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([0.0, 2.0, 3.0, 4.0]), &device).require_grad(); + + let output = tensor.clone().cumprod(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [33.0, 0.0, 0.0, 0.0] + let expected = TensorData::from([33.0, 0.0, 0.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[ignore = "cumprod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_cumprod_zero_at_end() { + // Test cumprod with zero at the end + let device = AutodiffDevice::new(); + let tensor = + TestTensor::<1>::from_data(TensorData::from([2.0, 3.0, 4.0, 0.0]), &device).require_grad(); + + let output = tensor.clone().cumprod(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [16.0, 10.0, 6.0, 24.0] + let expected = TensorData::from([16.0, 10.0, 6.0, 24.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[ignore = "cumprod gradient with zeros not yet implemented - produces NaN due to division by zero"] +fn should_diff_cumprod_multiple_zeros() { + // Test cumprod with multiple zeros + let device = AutodiffDevice::new(); + let tensor = TestTensor::<1>::from_data(TensorData::from([2.0, 0.0, 3.0, 0.0, 5.0]), &device) + .require_grad(); + + let output = tensor.clone().cumprod(0); + let grads = output.sum().backward(); + let grad = tensor.grad(&grads).unwrap(); + + // PyTorch reference: [1.0, 8.0, 0.0, 0.0, 0.0] + let expected = TensorData::from([1.0, 8.0, 0.0, 0.0, 0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/cumsum.rs b/crates/burn-backend-tests/tests/autodiff/cumsum.rs new file mode 100644 index 0000000..e0926d3 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/cumsum.rs @@ -0,0 +1,89 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_cumsum_dim0() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.cumsum(0); + let tensor_5 = tensor_1.clone().mul(tensor_4); + let grads = tensor_5.sum().backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + // Expected gradients computed with PyTorch + let expected = TensorData::from([[-14.0, 24.0], [17.0, 6.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[3.0, 10.0], [-1.0, 37.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cumsum_dim1() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.cumsum(1); + let tensor_5 = tensor_1.clone().mul(tensor_4); + let grads = tensor_5.sum().backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + // Expected gradients computed with PyTorch + let expected = TensorData::from([[1.0, 69.0], [-13.0, -28.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[18.0, 13.0], [71.0, 58.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_cumsum_complex() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.clone().cumsum(1); + let tensor_5 = tensor_4.mul(tensor_3); + + let grads = tensor_5.sum().backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + // Expected gradients computed with PyTorch + let expected = TensorData::from([[371.0, 542.0], [2246.0, 3281.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[507.0, 528.0], [704.0, 733.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/deform_conv2d.rs b/crates/burn-backend-tests/tests/autodiff/deform_conv2d.rs new file mode 100644 index 0000000..2090491 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/deform_conv2d.rs @@ -0,0 +1,1804 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Shape, module::deform_conv2d, ops::DeformConvOptions}; + +#[test] +fn test_deform_conv2d_basic() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [0.000, 6.0678, 14.2071, 12.2477], + [11.2292, 33.7937, 50.1555, 44.0561], + [17.9294, 57.2174, 85.1505, 79.1840], + [18.0220, 73.6263, 126.8184, 151.6910], + ], + [ + [0.000, 8.9783, 20.7620, 17.7888], + [16.2326, 48.7386, 71.7961, 62.5845], + [25.3808, 80.5195, 119.0949, 110.0938], + [25.0567, 101.8461, 174.3329, 206.6013], + ], + ]], + &device, + ), + offset: TestTensor::from_data( + [[ + [[0.000, 15.0000], [30.000, 45.0000]], + [[0.000, 3.7500], [7.5000, 11.2500]], + [[62.6667, 78.3333], [94.0000, 109.6667]], + [[15.6667, 19.5833], [23.5000, 27.4167]], + [[130.6667, 104.1250], [163.3333, 122.2732]], + [[32.6667, -492.9583], [40.8333, -787.1620]], + [[204.0000, 221.0000], [238.0000, 255.0000]], + [[51.0000, 55.2500], [59.5000, 63.7500]], + [[282.6667, 300.3333], [318.0000, 335.6667]], + [[70.6667, 75.0833], [79.5000, 83.9167]], + [[366.6667, 144.3750], [403.3333, 146.4121]], + [[91.6667, -1788.9860], [100.8333, -2392.7456]], + [[456.0000, 475.0000], [-2718.6250, -2953.2188]], + [[114.0000, 118.7500], [37.7361, 37.4063]], + [[550.6667, 570.3334], [-3404.5139, -3672.5312]], + [[137.6667, 142.5833], [28.6806, 27.5197]], + [[650.6667, 27.9584], [-4174.3657, -59.7509]], + [[162.6667, -3991.0139], [14.4028, -298.7557]], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [0.7029, 2.8356, 5.1067], + [12.7492, 19.4745, 17.8345], + [22.0687, 25.9156, 14.6394], + ], + [ + [3.3696, 12.6134, 19.2671], + [36.7492, 50.5856, 43.5506], + [50.8774, 56.3292, 30.7470], + ], + ], + [ + [ + [0.7029, 2.8356, 5.1067], + [12.7492, 19.4745, 17.8345], + [22.0687, 25.9156, 14.6394], + ], + [ + [3.3696, 12.6134, 19.2671], + [36.7492, 50.5856, 43.5506], + [50.8774, 56.3292, 30.7470], + ], + ], + [ + [ + [0.7029, 2.8356, 5.1067], + [12.7492, 19.4745, 17.8345], + [22.0687, 25.9156, 14.6394], + ], + [ + [3.3696, 12.6134, 19.2671], + [36.7492, 50.5856, 43.5506], + [50.8774, 56.3292, 30.7470], + ], + ], + ], + &device, + ), + mask: TestTensor::from_data( + [[ + [[1303.5000, 1447.8750], [1862.2500, 2006.6250]], + [[1571.1666, 1721.9581], [2154.7500, 2305.5417]], + [[1857.4999, 1396.7151], [2465.9167, 1753.2246]], + [[2315.5000, 2479.1250], [2948.7502, 3112.3750]], + [[2645.1665, 2815.2085], [3303.2500, 3473.2917]], + [[2993.5000, 1150.0625], [3676.4165, 1300.4055]], + [[3531.5000, 3714.3752], [1150.1876, 1148.4744]], + [[3923.1665, 4112.4585], [794.3865, 770.0470]], + [[4333.5000, 181.4101], [368.3260, 4.2679]], + ]], + &device, + ), + bias: TestTensor::from_data([4., 4., 4.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_deform_conv2d_batched() { + let test = Conv2dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [ + [ + [ + [0.000, 3.4604, 8.7539, 6.8080], + [8.4661, 24.0784, 35.4610, 26.4276], + [19.5988, 51.0406, 68.4389, 53.4993], + [17.4698, 47.9106, 67.3808, 56.6063], + ], + [ + [0.000, 5.1185, 12.7803, 9.8796], + [12.1957, 34.5728, 50.4616, 37.3777], + [27.4521, 71.1227, 94.5778, 73.4724], + [24.1147, 65.8443, 91.8995, 76.7475], + ], + ], + [ + [ + [6.3750, 19.3553, 26.4935, 22.5650], + [17.0026, 57.8088, 85.5580, 78.0746], + [20.7334, 86.5793, 139.4667, 136.4133], + [16.8126, 103.0225, 186.4502, 206.9613], + ], + [ + [9.5625, 28.8786, 39.1137, 32.9178], + [25.1984, 85.0747, 124.6941, 112.5691], + [30.0242, 124.2863, 198.6056, 192.4489], + [23.5826, 143.4660, 257.8752, 283.2587], + ], + ], + ], + &device, + ), + + offset: TestTensor::from_data( + [ + [ + [[0.000, 7.5000], [15.0000, 22.5000]], + [[0.000, 1.8750], [3.7500, 5.6250]], + [[31.3333, 39.1667], [47.0000, 54.8333]], + [[7.8333, 9.7917], [11.7500, 13.7083]], + [[65.3333, 62.7813], [81.6667, 75.4849]], + [[16.3333, -237.8021], [20.4167, -381.7280]], + [[102.0000, 110.5000], [119.0000, 127.5000]], + [[25.5000, 27.6250], [29.7500, 31.8750]], + [[141.3333, 150.1667], [159.0000, 167.8333]], + [[35.3333, 37.5417], [39.7500, 41.9583]], + [[183.3333, 132.3438], [201.6667, 142.0197]], + [[45.8333, -839.6840], [50.4167, -1133.4155]], + [[228.0000, 237.5000], [-1336.1562, -1452.1173]], + [[57.0000, 59.3750], [40.3090, 41.4141]], + [[275.3333, 285.1667], [-1670.5034, -1802.9244]], + [[68.8333, 71.2917], [44.0451, 44.9841]], + [[325.3333, 174.7396], [-2045.1747, -1090.4585]], + [[81.3333, -1844.0659], [46.8090, -1150.2101]], + ], + [ + [[270.000, 277.5000], [285.0000, 292.5000]], + [[67.5000, 69.3750], [71.2500, 73.1250]], + [[313.3333, 321.1667], [329.0000, 336.8333]], + [[78.3333, 80.2917], [82.2500, 84.2083]], + [[359.3333, 130.1563], [375.6667, 130.6099]], + [[89.8333, -4312.7603], [93.9167, -4893.6035]], + [[408.0000, 416.5000], [425.0000, 433.5000]], + [[102.0000, 104.1250], [106.2500, 108.3750]], + [[459.3333, 468.1667], [477.0000, 485.8333]], + [[114.8333, 117.0417], [119.2500, 121.4583]], + [[513.3334, 97.9688], [531.6667, 93.8947]], + [[128.3333, -6720.3926], [132.9167, -7504.5405]], + [[570.000, 579.5000], [-7971.8438, -8251.0850]], + [[142.5000, 144.8750], [22.4965, 21.8203]], + [[629.3333, 639.1667], [-8948.2334, -9249.6641]], + [[157.3333, 159.7917], [15.7743, 14.8695]], + [[691.3333, 14.6145], [-9992.9453, -70.4040]], + [[172.8333, -9818.5234], [7.4132, -352.0222]], + ], + ], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [77.7195, 89.8692, 69.0213], + [121.0760, 137.0775, 92.2989], + [100.0212, 106.5561, 61.1851], + ], + [ + [112.3862, 131.6470, 103.8793], + [177.0760, 200.1887, 138.2681], + [149.5922, 158.7074, 94.3991], + ], + ], + [ + [ + [77.7195, 89.8692, 69.0213], + [121.0760, 137.0775, 92.2989], + [100.0212, 106.5561, 61.1851], + ], + [ + [112.3862, 131.6470, 103.8793], + [177.0760, 200.1887, 138.2681], + [149.5922, 158.7074, 94.3991], + ], + ], + [ + [ + [77.7195, 89.8692, 69.0213], + [121.0760, 137.0775, 92.2989], + [100.0212, 106.5561, 61.1851], + ], + [ + [112.3862, 131.6470, 103.8793], + [177.0760, 200.1887, 138.2681], + [149.5922, 158.7074, 94.3991], + ], + ], + ], + &device, + ), + mask: TestTensor::from_data( + [ + [ + [[1299.7499, 1439.4375], [1849.1249, 1988.8125]], + [[1528.0834, 1673.9791], [2101.8750, 2247.7708]], + [[1771.7500, 1624.9811], [2369.9583, 2099.5039]], + [[2183.7500, 2342.0625], [2806.3750, 2964.6875]], + [[2464.0833, 2628.6042], [3111.1250, 3275.6458]], + [[2759.7500, 1979.2551], [3431.2085, 2390.0286]], + [[3241.7498, 3418.6873], [2415.3589, 2500.8682]], + [[3574.0835, 3757.2292], [2394.3889, 2471.7510]], + [[3921.7500, 2095.5293], [2345.9363, 1199.5048]], + ], + [ + [[5957.2500, 6096.9375], [6506.6250, 6646.3125]], + [[6392.5835, 6538.4790], [6966.3750, 7112.2705]], + [[6843.2500, 2443.8982], [7441.4585, 2550.9199]], + [[7462.2505, 7620.5625], [8084.8745, 8243.1875]], + [[7949.5835, 8114.1045], [8596.6250, 8761.1465]], + [[8452.2500, 1591.6719], [9123.7080, 1589.9454]], + [[9141.2500, 9318.1875], [1414.3584, 1375.1803]], + [[9680.5840, 9863.7285], [949.0560, 897.3544]], + [[10235.2500, 213.4454], [428.2699, 2.4790]], + ], + ], + &device, + ), + bias: TestTensor::from_data([8., 8., 8.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_deform_conv2d_different_kernel_size() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [14.558521, 27.249609, 37.382030, 36.039406], + [33.151936, 60.480656, 81.264656, 78.618156], + [57.520061, 108.623283, 153.413559, 170.072998], + [54.706184, 102.596664, 144.367157, 162.643570], + ], + [ + [25.836353, 48.088451, 65.249161, 62.103317], + [56.805233, 102.995605, 136.983124, 131.120911], + [96.105408, 179.790192, 250.550934, 272.668793], + [90.210945, 167.567917, 232.847275, 257.934692], + ], + ]], + &device, + ), + offset: TestTensor::from_data( + [[ + [ + [0.0e+00, 5.355903e+00, 1.171528e+01], + [3.124999e-01, 8.000000e+00, 1.000000e+01], + [7.500000e-01, 1.400000e+01, 1.600000e+01], + [1.312500e+00, 2.000000e+01, 2.200000e+01], + ], + [ + [0.0e+00, 1.736104e-03, 6.944418e-03], + [1.606250e+01, 2.000000e+00, 2.500000e+00], + [4.425000e+01, 3.500000e+00, 4.000000e+00], + [8.456250e+01, 5.000000e+00, 5.500000e+00], + ], + [ + [6.745834e+01, 7.996479e+01, 9.353048e+01], + [3.166667e+01, 3.377778e+01, 3.588889e+01], + [3.800000e+01, 4.011111e+01, 4.222223e+01], + [4.433333e+01, 4.644444e+01, 4.855556e+01], + ], + [ + [5.277777e-01, 5.955827e-01, 6.670526e-01], + [7.916667e+00, 8.444445e+00, 8.972222e+00], + [9.500000e+00, 1.002778e+01, 1.055556e+01], + [1.108333e+01, 1.161111e+01, 1.213889e+01], + ], + [ + [1.547778e+02, 1.751640e+02, 1.518874e+02], + [6.000000e+01, 6.222223e+01, 4.989969e+01], + [6.666666e+01, 6.888889e+01, 5.432098e+01], + [7.333334e+01, 7.555556e+01, 5.860340e+01], + ], + [ + [2.222223e+00, 2.363040e+00, -3.360339e+01], + [1.500000e+01, 1.555556e+01, -2.277485e+02], + [1.666667e+01, 1.722222e+01, -3.231605e+02], + [1.833333e+01, 1.888889e+01, -4.320448e+02], + ], + [ + [2.641250e+02, 2.021189e+02, 0.0e+00], + [9.100000e+01, 6.481482e+01, 0.0e+00], + [9.800000e+01, 6.863078e+01, 0.0e+00], + [1.050000e+02, 7.230093e+01, 0.0e+00], + ], + [ + [5.250000e+00, -7.268316e+01, 0.0e+00], + [2.275000e+01, -3.346296e+02, 0.0e+00], + [2.450000e+01, -4.611053e+02, 0.0e+00], + [2.625000e+01, -6.017269e+02, 0.0e+00], + ], + [ + [4.400000e+01, 1.197778e+02, 1.222222e+02], + [4.804860e+01, 1.271111e+02, 1.295556e+02], + [5.225000e+01, 1.344444e+02, 1.368889e+02], + [-3.138958e+02, -8.007446e+02, -8.507313e+02], + ], + [ + [3.377778e+02, 2.994445e+01, 3.055556e+01], + [4.848542e+02, 3.177778e+01, 3.238889e+01], + [6.467500e+02, 3.361111e+01, 3.422222e+01], + [4.909653e+02, 2.239892e+01, 2.265992e+01], + ], + [ + [1.533333e+02, 1.558889e+02, 1.584444e+02], + [1.610000e+02, 1.635556e+02, 1.661111e+02], + [1.686667e+02, 1.712222e+02, 1.737778e+02], + [-9.952491e+02, -1.054551e+03, -1.115134e+03], + ], + [ + [3.833333e+01, 3.897222e+01, 3.961111e+01], + [4.025000e+01, 4.088889e+01, 4.152778e+01], + [4.216667e+01, 4.280556e+01, 4.344445e+01], + [2.433767e+01, 2.453511e+01, 2.472810e+01], + ], + [ + [1.920000e+02, 1.946667e+02, 8.907407e+01], + [2.000000e+02, 2.026667e+02, 9.054632e+01], + [2.080000e+02, 2.106667e+02, 9.185186e+01], + [-1.272938e+03, -1.343509e+03, -5.811921e+02], + ], + [ + [4.800000e+01, 4.866667e+01, -7.413704e+02], + [5.000000e+01, 5.066667e+01, -9.788981e+02], + [5.200000e+01, 5.266667e+01, -1.232593e+03], + [2.531250e+01, 2.543518e+01, -6.388311e+02], + ], + [ + [2.333333e+02, 8.772182e+01, 0.0e+00], + [2.416667e+02, 8.827161e+01, 0.0e+00], + [2.500000e+02, 8.864776e+01, 0.0e+00], + [-1.587216e+03, -5.535372e+02, 0.0e+00], + ], + [ + [5.833333e+01, -9.011902e+02, 0.0e+00], + [6.041667e+01, -1.179988e+03, 0.0e+00], + [6.250000e+01, -1.475625e+03, 0.0e+00], + [2.489150e+01, -6.213175e+02, 0.0e+00], + ], + [ + [1.964444e+02, 2.802222e+02, 2.831111e+02], + [2.055625e+02, 2.888889e+02, 2.917778e+02], + [-1.173472e+03, -1.679611e+03, -1.771290e+03], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [1.144889e+03, 7.005556e+01, 7.077778e+01], + [1.469646e+03, 7.222223e+01, 7.294444e+01], + [5.029167e+02, 2.298823e+01, 2.295062e+01], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [3.240000e+02, 3.270000e+02, 3.300000e+02], + [3.330000e+02, 3.360000e+02, 3.390000e+02], + [-1.931469e+03, -2.034961e+03, -2.139958e+03], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [8.100000e+01, 8.175000e+01, 8.250000e+01], + [8.325000e+01, 8.400000e+01, 8.475000e+01], + [1.959376e+01, 1.946614e+01, 1.933334e+01], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [3.733333e+02, 3.764445e+02, 4.480865e+01], + [3.826667e+02, 3.857778e+02, 4.185955e+01], + [-2.313792e+03, -2.431276e+03, -2.392101e+02], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [9.333333e+01, 9.411111e+01, -1.904932e+03], + [9.566667e+01, 9.644444e+01, -2.344715e+03], + [1.429166e+01, 1.406212e+01, -3.417283e+02], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [4.253333e+02, 1.636843e+01, 0.0e+00], + [4.350000e+02, 1.217279e+01, 0.0e+00], + [-2.738517e+03, -4.792887e+01, 0.0e+00], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [1.063333e+02, -2.178747e+03, 0.0e+00], + [1.087500e+02, -2.670679e+03, 0.0e+00], + [6.947917e+00, -1.629574e+02, 0.0e+00], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [1.856041, 7.203409, 12.833395, 11.969448], + [24.236776, 40.125511, 41.396423, 27.642044], + [43.613083, 57.508926, 46.093338, 25.174383], + ], + [ + [6.989914, 26.580338, 42.618557, 37.501404], + [75.623192, 116.925674, 113.288368, 72.567764], + [112.724869, 139.826447, 107.653435, 56.799385], + ], + ], + [ + [ + [1.856041, 7.203409, 12.833395, 11.969448], + [24.236776, 40.125511, 41.396423, 27.642044], + [43.613083, 57.508926, 46.093338, 25.174383], + ], + [ + [6.989914, 26.580338, 42.618557, 37.501404], + [75.623192, 116.925674, 113.288368, 72.567764], + [112.724869, 139.826447, 107.653435, 56.799385], + ], + ], + ], + &device, + ), + mask: TestTensor::from_data( + [[ + [ + [0.0e+00, 2.677941e+00, 5.857617e+00], + [4.015623e+01, 7.759999e+02, 8.492499e+02], + [6.637500e+01, 1.067750e+03, 1.141000e+03], + [9.865628e+01, 1.359500e+03, 1.432750e+03], + ], + [ + [6.745831e+01, 7.688924e+01, 8.684974e+01], + [8.387916e+02, 9.161111e+02, 9.934306e+02], + [1.146750e+03, 1.224069e+03, 1.301389e+03], + [1.454708e+03, 1.532028e+03, 1.609347e+03], + ], + [ + [1.547778e+02, 1.716607e+02, 1.460455e+02], + [9.861667e+02, 1.067556e+03, 8.756536e+02], + [1.310333e+03, 1.391722e+03, 1.110864e+03], + [1.634500e+03, 1.715889e+03, 1.339339e+03], + ], + [ + [2.641250e+02, 1.993876e+02, 0.0e+00], + [1.144875e+03, 8.365740e+02, 0.0e+00], + [1.485250e+03, 1.056253e+03, 0.0e+00], + [1.825625e+03, 1.268859e+03, 0.0e+00], + ], + [ + [3.800000e+02, 1.047861e+03, 1.137389e+03], + [5.276354e+02, 1.404444e+03, 1.493972e+03], + [6.826807e+02, 1.761028e+03, 1.850555e+03], + [5.038855e+02, 1.256341e+03, 1.304936e+03], + ], + [ + [1.123500e+03, 1.217097e+03, 1.310694e+03], + [1.496292e+03, 1.589889e+03, 1.683486e+03], + [1.869083e+03, 1.962681e+03, 2.056278e+03], + [1.146700e+03, 1.190136e+03, 1.232930e+03], + ], + [ + [1.300000e+03, 1.397667e+03, 6.512036e+02], + [1.689000e+03, 1.786667e+03, 8.072734e+02], + [2.078000e+03, 2.175667e+03, 9.552593e+02], + [1.060781e+03, 1.097745e+03, 4.656539e+02], + ], + [ + [1.487833e+03, 5.672195e+02, 0.0e+00], + [1.893042e+03, 6.972655e+02, 0.0e+00], + [2.298250e+03, 8.188910e+02, 0.0e+00], + [9.472098e+02, 3.238781e+02, 0.0e+00], + ], + [ + [1.216444e+03, 1.792806e+03, 1.898611e+03], + [1.536448e+03, 2.214222e+03, 2.320028e+03], + [5.177084e+02, 7.256571e+02, 7.493920e+02], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [1.897500e+03, 2.007375e+03, 2.117250e+03], + [2.335125e+03, 2.445000e+03, 2.554875e+03], + [5.591096e+02, 5.750975e+02, 5.903336e+02], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [2.119333e+03, 2.233278e+03, 2.654414e+02], + [2.573167e+03, 2.687111e+03, 2.907444e+02], + [3.856317e+02, 3.924502e+02, 3.737657e+01], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + [ + [2.352500e+03, 9.009851e+01, 0.0e+00], + [2.822542e+03, 7.854909e+01, 0.0e+00], + [1.785990e+02, 2.930897e+00, 0.0e+00], + [0.0e+00, 0.0e+00, 0.0e+00], + ], + ]], + &device, + ), + bias: TestTensor::from_data([12., 12.], &device), + }; + test.assert_grads(grads); +} + +#[test] +fn test_deform_conv2d_different_padding() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 2, + padding_2: 3, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + let device = AutodiffDevice::new(); + let grads = Grads { + x: TestTensor::from_data( + [[ + [ + [60.633026, 60.906506, 61.179493, 61.451954], + [122.557770, 123.088188, 123.618599, 124.149033], + [126.801132, 127.331535, 127.861938, 128.392365], + [131.044434, 131.574875, 132.105286, 132.635712], + ], + [ + [102.000595, 102.497604, 102.993835, 103.489281], + [198.932983, 199.830597, 200.728210, 201.625870], + [206.113968, 207.011627, 207.909256, 208.806870], + [213.294952, 214.192627, 215.090271, 215.987930], + ], + ]], + &device, + ), + // => Position 788: 10.421875 != 10.0546875 + // diff (rel = +1.79e-2, abs = +3.67e-1), tol (rel = +1.00e-2, abs = +9.77e-4) + offset: TestTensor::from_data( + [[ + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.895062, 14.760561, 17.604168, 20.698063, 22.200424, 0.0, + ], + [ + 0.0, 0.0, 0.687500, 9.500000, 10.0, 10.500000, 10.108797, 0.0, + ], + [ + 0.0, 0.0, 1.113426, 13.500000, 14.000000, 14.499999, 13.645835, 0.0, + ], + [ + 0.0, 0.0, 1.613426, 17.500000, 18.000000, 18.500000, 17.108795, 0.0, + ], + [ + 0.0, + 0.0, + -12.395836, + -122.399445, + -130.752319, + -139.355469, + -131.526810, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.154321, 0.017506, 0.020833, 0.024450, -0.387539, 0.0, + ], + [ + 0.0, 0.0, 24.187502, 2.375000, 2.500000, 2.625000, -37.863422, 0.0, + ], + [ + 0.0, 0.0, 48.057869, 3.375000, 3.500000, 3.625000, -66.770836, 0.0, + ], + [ + 0.0, + 0.0, + 80.02312, + 4.375000, + 4.500000, + 4.625000, + -103.752319, + 0.0, + ], + [ + 0.0, + 0.0, + 113.215271, + 5.107495, + 5.219907, + 5.332031, + -139.725891, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 14.206017, 83.017586, 92.379395, 102.010040, 90.356323, 0.0, 0.0, + ], + [ + 0.0, 6.504737, 35.444443, 35.981483, 36.518517, 29.978970, 0.0, 0.0, + ], + [ + 0.0, 7.668316, 39.740742, 40.277779, 40.814816, 33.071907, 0.0, 0.0, + ], + [ + 0.0, 8.911458, 44.037037, 44.574074, 45.111111, 36.085281, 0.0, 0.0, + ], + [ + 0.0, + -57.523048, + -274.267914, + -289.547089, + -305.095093, + -248.578552, + 0.0, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 9.749230, 0.955354, 0.980994, 1.006945, -13.930464, 0.0, 0.0, + ], + [ + 0.0, + 96.046921, + 8.861111, + 8.995371, + 9.129629, + -129.920715, + 0.0, + 0.0, + ], + [ + 0.0, + 147.434769, + 9.935185, + 10.069445, + 10.203704, + -186.718735, + 0.0, + 0.0, + ], + [ + 0.0, + 207.494781, + 11.009259, + 11.143518, + 11.277778, + -252.188889, + 0.0, + 0.0, + ], + [ + 0.0, + 226.050003, + 10.153355, + 10.252030, + 10.350393, + -266.255280, + 0.0, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 44.224964, 159.898483, 176.651901, 193.692688, 146.270813, 0.0, 0.0, 0.0, + ], + [ + 19.050755, 64.870377, 65.444443, 66.018517, 46.553150, 0.0, 0.0, 0.0, + ], + [ + 21.049385, 69.462967, 70.037033, 70.611115, 49.104595, 0.0, 0.0, 0.0, + ], + [ + 23.133059, 74.055557, 74.629631, 75.203705, 51.570988, 0.0, 0.0, 0.0, + ], + [ + -141.200272, + -445.302155, + -468.381012, + -491.747223, + -341.553131, + 0.0, + 0.0, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 35.665298, 3.505739, 3.556735, 3.608062, -48.756947, 0.0, 0.0, 0.0, + ], + [ + 181.404663, + 16.217594, + 16.361111, + 16.504629, + -238.136124, + 0.0, + 0.0, + 0.0, + ], + [ + 263.888885, + 17.365742, + 17.509258, + 17.652779, + -326.403656, + 0.0, + 0.0, + 0.0, + ], + [ + 355.643341, + 18.513889, + 18.657408, + 18.800926, + -423.941345, + 0.0, + 0.0, + 0.0, + ], + [ + 318.709198, + 14.359658, + 14.441552, + 14.523109, + -369.819580, + 0.0, + 0.0, + 0.0, + ], + ], + [ + [ + 0.0, 0.0, 88.846703, 237.478439, 261.731201, 286.289917, 182.508713, 0.0, + ], + [ + 0.0, 0.0, 37.688015, 94.722221, 95.333328, 95.944450, 57.441605, 0.0, + ], + [ + 0.0, 0.0, 40.562500, 99.611107, 100.222229, 100.833336, 59.410744, 0.0, + ], + [ + 0.0, 0.0, 43.527519, 104.500000, 105.111107, 105.722221, 61.289349, 0.0, + ], + [ + 0.0, + 0.0, + -258.324371, + -618.353943, + -649.340271, + -680.632507, + -397.101013, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 0.0, + 76.229431, + 7.564093, + 7.641718, + 7.719699, + -102.792252, + 0.0, + ], + [ + 0.0, + 0.0, + 272.015167, + 23.680555, + 23.833332, + 23.986113, + -351.944214, + 0.0, + ], + [ + 0.0, + 0.0, + 386.062500, + 24.902777, + 25.055557, + 25.208334, + -472.147888, + 0.0, + ], + [ + 0.0, + 0.0, + 509.978149, + 26.125000, + 26.277777, + 26.430555, + -602.219971, + 0.0, + ], + [ + 0.0, + 0.0, + 378.410248, + 17.123661, + 17.187500, + 17.250984, + -436.000732, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, 157.623291, 331.938538, 365.283356, 398.952606, 205.988480, 0.0, 0.0, + ], + [ + 0.0, 66.495949, 130.925934, 131.574066, 132.222229, 64.435974, 0.0, 0.0, + ], + [ + 0.0, 70.396835, 136.111115, 136.759262, 137.407410, 65.672256, 0.0, 0.0, + ], + [ + 0.0, 74.393753, 141.296295, 141.944458, 142.592606, 66.812523, 0.0, 0.0, + ], + [ + 0.0, + -432.798035, + -827.492065, + -867.978455, + -908.789368, + -425.074158, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 140.150024, + 14.043960, + 14.152921, + 14.262260, + -187.656906, + 0.0, + 0.0, + ], + [ + 0.0, + 386.813873, + 32.731483, + 32.893517, + 33.055557, + -494.779602, + 0.0, + 0.0, + ], + [ + 0.0, + 538.926697, + 34.027779, + 34.189816, + 34.351852, + -653.421875, + 0.0, + 0.0, + ], + [ + 0.0, + 701.505859, + 35.324074, + 35.486115, + 35.648151, + -822.530640, + 0.0, + 0.0, + ], + [ + 0.0, + 416.044586, + 18.903570, + 18.944647, + 18.985338, + -476.728790, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 249.876541, 435.868500, 479.178772, 522.832031, 207.919815, 0.0, 0.0, 0.0, + ], + [ + 105.417015, 170.611115, 171.296295, 171.981476, 64.750000, 0.0, 0.0, 0.0, + ], + [ + 110.441696, 176.092590, 176.777771, 177.462952, 65.156044, 0.0, 0.0, 0.0, + ], + [ + 115.567902, 181.574066, 182.259247, 182.944458, 65.460571, 0.0, 0.0, 0.0, + ], + [ + -662.743530, + -1056.641846, + -1107.501953, + -1158.704712, + -409.510162, + 0.0, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 227.160507, + 22.982454, + 23.125793, + 23.269531, + -303.112030, + 0.0, + 0.0, + 0.0, + ], + [ + 518.495178, + 42.652779, + 42.824074, + 42.995369, + -657.157410, + 0.0, + 0.0, + 0.0, + ], + [ + 712.252380, + 44.023148, + 44.194443, + 44.365738, + -857.817200, + 0.0, + 0.0, + 0.0, + ], + [ + 917.074036, + 45.393517, + 45.564812, + 45.736115, + -1069.541626, + 0.0, + 0.0, + 0.0, + ], + [ + 416.581482, + 18.997831, + 19.013102, + 19.027966, + -475.031525, + 0.0, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, 0.0, 151.750259, 210.166672, 210.888885, 211.611099, 57.506927, 0.0, + ], + [ + 0.0, 0.0, 157.929276, 215.944443, 216.666672, 217.388901, 57.052204, 0.0, + ], + [ + 0.0, 0.0, 164.215271, 221.722229, 222.444458, 223.166672, 56.490482, 0.0, + ], + [ + 0.0, + 0.0, + -931.783752, + -1285.353760, + -1346.555908, + -1408.119385, + -346.739044, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 0.0, + 655.669983, + 52.541668, + 52.722221, + 52.902775, + -824.946777, + 0.0, + ], + [ + 0.0, + 0.0, + 890.972473, + 53.986111, + 54.166668, + 54.347225, + -1067.525024, + 0.0, + ], + [ + 0.0, + 0.0, + 1137.937500, + 55.430557, + 55.611115, + 55.791668, + -1321.765625, + 0.0, + ], + [ + 0.0, + 0.0, + 375.580566, + 17.180984, + 17.169498, + 17.157579, + -425.993713, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, 213.521454, 256.629639, 257.388885, 258.148132, 41.652927, 0.0, 0.0, + ], + [ + 0.0, 221.015625, 262.703705, 263.462982, 264.222229, 40.176598, 0.0, 0.0, + ], + [ + 0.0, 228.622284, 268.777802, 269.537048, 270.296295, 38.587788, 0.0, 0.0, + ], + [ + 0.0, + -1285.466797, + -1554.254517, + -1627.530640, + -1701.186646, + -228.291397, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 823.380554, + 64.157410, + 64.347221, + 64.537033, + -1028.532715, + 0.0, + 0.0, + ], + [ + 0.0, + 1107.296509, + 65.675926, + 65.865746, + 66.055557, + -1320.097534, + 0.0, + 0.0, + ], + [ + 0.0, + 1403.473022, + 67.194450, + 67.384262, + 67.574074, + -1623.922974, + 0.0, + 0.0, + ], + [ + 0.0, + 288.151398, + 13.201796, + 13.158524, + 13.114797, + -323.577820, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 288.790131, 306.574066, 307.370361, 308.166656, 15.734239, 0.0, 0.0, 0.0, + ], + [ + 297.696838, 312.944427, 313.740723, 314.537048, 13.138914, 0.0, 0.0, 0.0, + ], + [ + 306.721527, 319.314819, 320.111115, 320.907410, 10.425544, 0.0, 0.0, 0.0, + ], + [ + -1711.543213, + -1844.013062, + -1930.236572, + -2016.858643, + -46.846100, + 0.0, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 1011.358093, + 76.643517, + 76.842590, + 77.041664, + -1255.045654, + 0.0, + 0.0, + 0.0, + ], + [ + 1347.466431, + 78.236107, + 78.435181, + 78.634262, + -1599.175903, + 0.0, + 0.0, + 0.0, + ], + [ + 1696.433350, + 79.828705, + 80.027779, + 80.226852, + -1956.164917, + 0.0, + 0.0, + 0.0, + ], + [ + 146.703568, + 6.690874, + 6.612756, + 6.534196, + -159.277222, + 0.0, + 0.0, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + ]], + &device, + ), + weight: TestTensor::from_data( + [ + [ + [ + [10.341997, 22.988085, 35.634174], + [46.920216, 59.566299, 72.212387], + [80.881615, 92.591522, 104.158524], + ], + [ + [29.213360, 68.837769, 108.462166], + [143.825104, 183.449509, 223.073944], + [228.029373, 256.751740, 283.807098], + ], + ], + [ + [ + [10.341997, 22.988085, 35.634174], + [46.920216, 59.566299, 72.212387], + [80.881615, 92.591522, 104.158524], + ], + [ + [29.213360, 68.837769, 108.462166], + [143.825104, 183.449509, 223.073944], + [228.029373, 256.751740, 283.807098], + ], + ], + ], + &device, + ), + mask: TestTensor::from_data( + [[ + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.447531, 7.380288, 8.802088, 10.349031, 11.100212, 0.0, + ], + [ + 0.0, 0.0, 44.343754, 584.937439, 639.250000, 693.562439, 683.262756, 0.0, + ], + [ + 0.0, 0.0, 68.390068, 803.437561, 857.750000, 912.062500, 874.698059, 0.0, + ], + [ + 0.0, + 0.0, + 96.473381, + 1021.937500, + 1076.250000, + 1130.562500, + 1062.095947, + 0.0, + ], + [ + 0.0, + 0.0, + 121.302101, + 1168.487915, + 1218.373779, + 1268.134888, + 1169.444702, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 13.084491, 75.860909, 83.767761, 91.809029, 80.728188, 0.0, 0.0, + ], + [ + 0.0, 118.950417, 649.486084, 707.821777, 766.157410, 658.076599, 0.0, 0.0, + ], + [ + 0.0, + 170.660782, + 884.171265, + 942.506958, + 1000.842651, + 837.809326, + 0.0, + 0.0, + ], + [ + 0.0, + 226.707260, + 1118.856445, + 1177.192261, + 1235.527710, + 1013.205933, + 0.0, + 0.0, + ], + [ + 0.0, + 234.939651, + 1106.213867, + 1153.415649, + 1200.482666, + 966.248901, + 0.0, + 0.0, + ], + ], + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 42.524002, 153.045700, 168.319275, 183.736511, 138.144653, 0.0, 0.0, 0.0, + ], + [ + 207.319611, 718.432800, 780.791626, 843.150391, 619.975037, 0.0, 0.0, 0.0, + ], + [ + 290.277802, + 969.303223, + 1031.661987, + 1094.020752, + 784.421631, + 0.0, + 0.0, + 0.0, + ], + [ + 377.871063, + 1220.173584, + 1282.532471, + 1344.891235, + 944.233032, + 0.0, + 0.0, + 0.0, + ], + [ + 328.083038, + 1025.494995, + 1069.130615, + 1112.622192, + 766.054932, + 0.0, + 0.0, + 0.0, + ], + ], + [ + [ + 0.0, 0.0, 88.238174, 235.055206, 258.194336, 281.486389, 178.858536, 0.0, + ], + [ + 0.0, 0.0, 305.575500, 789.868042, 856.250061, 922.631897, 572.466064, 0.0, + ], + [ + 0.0, + 0.0, + 421.809021, + 1056.923584, + 1123.305542, + 1189.687500, + 719.598816, + 0.0, + ], + [ + 0.0, + 0.0, + 542.976746, + 1323.979248, + 1390.361206, + 1456.743042, + 861.797302, + 0.0, + ], + [ + 0.0, + 0.0, + 393.291565, + 934.439697, + 974.010376, + 1013.428101, + 586.924011, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, 157.214920, 330.227448, 362.473419, 394.881653, 203.374420, 0.0, 0.0, + ], + [ + 0.0, + 424.340576, + 867.495361, + 937.900452, + 1008.305542, + 505.640503, + 0.0, + 0.0, + ], + [ + 0.0, + 578.894897, + 1150.736084, + 1221.141235, + 1291.546265, + 630.414001, + 0.0, + 0.0, + ], + [ + 0.0, + 738.682495, + 1433.976929, + 1504.381958, + 1574.787109, + 749.954346, + 0.0, + 0.0, + ], + [ + 0.0, 429.912781, 816.507507, 850.771973, 884.873779, 411.152588, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 249.876541, 434.964233, 477.198730, 519.604675, 206.215576, 0.0, 0.0, 0.0, + ], + [ + 560.309326, + 949.520813, + 1023.949097, + 1098.377319, + 422.458344, + 0.0, + 0.0, + 0.0, + ], + [ + 756.768127, + 1248.946777, + 1323.375000, + 1397.803223, + 521.289001, + 0.0, + 0.0, + 0.0, + ], + [ + 958.759216, + 1548.372803, + 1622.800903, + 1697.229248, + 614.587402, + 0.0, + 0.0, + 0.0, + ], + [ + 428.833923, 679.269775, 707.346252, 735.250916, 258.169373, 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 0.0, + 707.671387, + 1033.687378, + 1112.138916, + 1190.590210, + 328.295044, + 0.0, + ], + [ + 0.0, + 0.0, + 947.779419, + 1349.298584, + 1427.750000, + 1506.201416, + 399.438080, + 0.0, + ], + [ + 0.0, + 0.0, + 1193.718872, + 1664.909668, + 1743.361084, + 1821.812500, + 464.749847, + 0.0, + ], + [ + 0.0, 0.0, 388.737854, 532.503540, 553.962891, 575.241089, 140.658310, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 0.0, + 880.797302, + 1124.393555, + 1206.868042, + 1289.342651, + 209.627625, + 0.0, + 0.0, + ], + [ + 0.0, + 1169.882812, + 1456.189819, + 1538.664429, + 1621.138916, + 247.754730, + 0.0, + 0.0, + ], + [ + 0.0, + 1465.098755, + 1787.986084, + 1870.460571, + 1952.935181, + 279.751526, + 0.0, + 0.0, + ], + [ + 0.0, 297.330719, 356.362152, 369.893524, 383.234344, 50.974621, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + [ + [ + 1074.567993, + 1219.497681, + 1305.995361, + 1392.493042, + 71.162437, + 0.0, + 0.0, + 0.0, + ], + [ + 1416.214722, + 1567.479126, + 1653.976929, + 1740.474609, + 72.689949, + 0.0, + 0.0, + 0.0, + ], + [ + 1764.290771, + 1915.460571, + 2001.958496, + 2088.456055, + 67.787628, + 0.0, + 0.0, + 0.0, + ], + [ + 151.018372, 160.055023, 164.776138, 169.298447, 3.865937, 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + ]], + &device, + ), + bias: TestTensor::from_data([48., 48.], &device), + }; + test.assert_grads(grads); +} + +struct Conv2dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + dilation_1: usize, + dilation_2: usize, + groups: usize, + offset_groups: usize, + height: usize, + width: usize, +} + +struct Grads { + x: TestTensor<4>, + offset: TestTensor<4>, + weight: TestTensor<4>, + mask: TestTensor<4>, + bias: TestTensor<1>, +} + +impl Conv2dTestCase { + fn assert_grads(self, expected_grads: Grads) { + let out_height = + (self.height + 2 * self.padding_1 - self.dilation_1 * (self.kernel_size_1 - 1) - 1) + / self.stride_1 + + 1; + let out_width = + (self.width + 2 * self.padding_2 - self.dilation_2 * (self.kernel_size_2 - 1) - 1) + / self.stride_2 + + 1; + + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let shape_offset = Shape::new([ + self.batch_size, + 2 * self.offset_groups * self.kernel_size_1 * self.kernel_size_2, + out_height, + out_width, + ]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size_1, + self.kernel_size_2, + ]); + let shape_mask = Shape::new([ + self.batch_size, + self.offset_groups * self.kernel_size_1 * self.kernel_size_2, + out_height, + out_width, + ]); + let device = AutodiffDevice::new(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<4, _>(shape_weight) + .into_data(), + &device, + ) + .require_grad(); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ) + .require_grad(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + let offset = TestTensor::from_data( + TestTensorInt::arange(0..shape_offset.num_elements() as i64, &device) + .reshape::<4, _>(shape_offset.clone()) + .into_data(), + &device, + ) + .div_scalar(shape_offset.num_elements() as f32) + .require_grad(); + + let mask = TestTensor::from_data( + TestTensorInt::arange(0..shape_mask.num_elements() as i64, &device) + .reshape::<4, _>(shape_mask.clone()) + .into_data(), + &device, + ) + .div_scalar(shape_mask.num_elements() as f32) + .require_grad(); + + let output = deform_conv2d( + x.clone(), + offset.clone(), + weight.clone(), + Some(mask.clone()), + Some(bias.clone()), + DeformConvOptions::new( + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + [self.dilation_1, self.dilation_2], + self.groups, + self.offset_groups, + ), + ); + let grads = output.backward(); + + // Assert + let x_grad_actual = x.grad(&grads).unwrap(); + let offset_grad_actual = offset.grad(&grads).unwrap(); + let weight_grad_actual = weight.grad(&grads).unwrap(); + let mask_grad_actual = mask.grad(&grads).unwrap(); + let bias_grad_actual = bias.grad(&grads).unwrap(); + + // Relative is set to 5%, which is much higher than typical numerical test tolerances. + // This is due to the complexity of the deformable convolution operation. + // Unlike regular conv2d, which samples from fixed integer grid positions, + // deformable conv2d samples input values at fractional offset locations (learned offsets). + // These non-integer positions require bilinear interpolation to estimate the input value. + // Gradients computed through all these floating-point operations can compound numerical differences. + let tolerance = Tolerance::relative(0.5); + + println!("Testing bias"); + expected_grads + .bias + .to_data() + .assert_approx_eq::(&bias_grad_actual.to_data(), tolerance); + println!("Testing input"); + expected_grads + .x + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), tolerance); + println!("Testing offset"); + expected_grads + .offset + .to_data() + .assert_approx_eq::(&offset_grad_actual.to_data(), tolerance); + println!("Testing mask"); + expected_grads + .mask + .to_data() + .assert_approx_eq::(&mask_grad_actual.to_data(), tolerance); + println!("Testing weight"); + expected_grads + .weight + .to_data() + .assert_approx_eq::(&weight_grad_actual.to_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/div.rs b/crates/burn-backend-tests/tests/autodiff/div.rs new file mode 100644 index 0000000..04a480d --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/div.rs @@ -0,0 +1,105 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_div() { + let data_1 = TensorData::from([1.0, 7.0]); + let data_2 = TensorData::from([4.0, 7.0]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().div(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([0.25, 0.14285715]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([-0.0625, -0.14285715]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_div_scalar() { + let data = TensorData::from([1.0, 7.0]); + + let tensor = TestTensor::<1>::from_data(data, &AutodiffDevice::new()).require_grad(); + let tensor_out = tensor.clone().div_scalar(4.0); + + let grads = tensor_out.backward(); + let grad = tensor.grad(&grads).unwrap(); + + grad.to_data() + .assert_eq(&TensorData::from([0.25, 0.25]), false); +} + +#[test] +fn test_div_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().div(tensor_2.clone()); + let tensor_5 = tensor_4.div(tensor_3.clone()); + + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + let grad_3 = tensor_3.grad(&grads).unwrap(); + + let expected = TensorData::from([[0.1250, 0.07142857], [0.25, 0.16666667]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[-0.03125, -0.07142857], [-1.6250, 0.16666667]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + let expected = TensorData::from([[-0.0625, -0.25], [-1.6250, 0.25]]); + grad_3 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_div_complex_2() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.div(tensor_2.clone()); + + let grads = tensor_4.backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_absolute(2e-3); + let expected = TensorData::from([[2.00, 2.92857146], [1.36666667, 2.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[0.08333334, 0.09591837], [-0.05555558, -0.06714284]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/erf.rs b/crates/burn-backend-tests/tests/autodiff/erf.rs new file mode 100644 index 0000000..f29132f --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/erf.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_erf() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().erf()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[32.0, 32.0], [32.0, 32.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[8.0, 8.0], [8.0, 8.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/exp.rs b/crates/burn-backend-tests/tests/autodiff/exp.rs new file mode 100644 index 0000000..f6b9c4f --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/exp.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_exp() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().exp()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default(); + let expected = TensorData::from([[54.5991, 27.4746], [54.5991, 27.4746]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[-5.4598e+01, -9.1188e-04], [2.9556e+01, 8.0342e+01]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/expand.rs b/crates/burn-backend-tests/tests/autodiff/expand.rs new file mode 100644 index 0000000..5fff88c --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/expand.rs @@ -0,0 +1,39 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_expand() { + // Python code to generate the test case values + // import torch + // x1 = torch.tensor([4.0, 7.0, 2.0, 3.0], requires_grad=True) + // x2 = torch.tensor([2.0, 4.5, 7.0, 3.0], requires_grad=True) + // y = x1.expand(4, 4) + // z = (x2 * y).sum() + // z.backward() + // print("x1", x1.grad) + // print("x2", x2.grad) + + let device = AutodiffDevice::new(); + + let data_1 = TensorData::from([4.0, 7.0, 2.0, 3.0]); + let tensor_1 = TestTensor::<1>::from_data(data_1, &device).require_grad(); + + let data_2 = TensorData::from([2.0, 4.5, 7.0, 3.0]); + let tensor_2 = TestTensor::<1>::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().expand([4, 4]); + + // Use unsqueeze to make tensor_2 have the same shape as tensor_3 + let tensor_4 = tensor_2.clone().unsqueeze().mul(tensor_3).sum(); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([8., 18., 28., 12.]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([16., 28., 8., 12.]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/flip.rs b/crates/burn-backend-tests/tests/autodiff/flip.rs new file mode 100644 index 0000000..9cea89e --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/flip.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_flip() { + let data_1 = TensorData::from([[[1.0, 7.0], [2.0, 3.0]]]); // 1x2x2 + let data_2 = TensorData::from([[[3.0, 2.0, 7.0], [3.0, 3.2, 1.0]]]); // 1x2x3 + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<3>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().flip([1, 2]); + let tensor_4 = tensor_1.clone().matmul(tensor_3); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + grad_1 + .into_data() + .assert_approx_eq::(&TensorData::from([[[7.2, 12.0], [7.2, 12.0]]]), tolerance); // 1x2x2 + grad_2.into_data().assert_approx_eq::( + &TensorData::from([[[10.0, 10.0, 10.0], [3.0, 3.0, 3.0]]]), + tolerance, + ); // 1x2x3 +} diff --git a/crates/burn-backend-tests/tests/autodiff/floor.rs b/crates/burn-backend-tests/tests/autodiff/floor.rs new file mode 100644 index 0000000..ec18491 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/floor.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_floor() { + let data = TensorData::from([ + [-1.9751, 0.0714, 0.0643, 0.2406], + [-1.3172, 0.1252, -0.1119, -0.0127], + ]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data, &device).require_grad(); + let tensor_2 = tensor_1.clone().floor(); + let grads = tensor_2.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq( + &TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/gather_scatter.rs b/crates/burn-backend-tests/tests/autodiff/gather_scatter.rs new file mode 100644 index 0000000..c03b48c --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/gather_scatter.rs @@ -0,0 +1,94 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn test_gather_grad() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data( + TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + &device, + ) + .require_grad(); + let indices = TestTensorInt::<2>::from_data( + TensorData::from([[2, 1, 0, 1, 2], [1, 0, 2, 1, 0]]), + &device, + ); + + let tensor_2 = tensor_1.clone().matmul(tensor_1.clone().transpose()); + let tensor_3 = tensor_1.clone().gather(1, indices); + let tensor_4 = tensor_2.matmul(tensor_3); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq( + &TensorData::from([[94., 150., 187.], [242., 305., 304.]]), + false, + ); +} + +#[test] +fn test_scatter_grad() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data( + TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + &device, + ) + .require_grad(); + let values = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[2, 1, 0], [2, 0, 1]]), &device); + + let tensor_2 = tensor_1.clone().matmul(tensor_1.clone().transpose()); + let tensor_3 = tensor_1 + .clone() + .scatter(1, indices, values.clone(), IndexingUpdateOp::Add); + let tensor_4 = tensor_2.matmul(tensor_3); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = values.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq( + &TensorData::from([[127., 181., 235.], [226., 316., 406.]]), + false, + ); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[19., 19., 19.], [64., 64., 64.]]), false); +} + +#[test] +fn test_scatter_add_grad_partial_indices() { + let device = AutodiffDevice::new(); + let tensor_1 = + TestTensor::from_data(TensorData::from([[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]]), &device) + .require_grad(); + let tensor_2 = + TestTensor::from_data(TensorData::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]]), &device) + .require_grad(); + let values = TestTensor::from_data(TensorData::from([[4.0, 5.0, 6.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[2, 1, 0]]), &device); + + let tensor_3 = tensor_1.clone().mul(tensor_2); + let tensor_4 = tensor_3 + .clone() + .scatter(1, indices, values.clone(), IndexingUpdateOp::Add); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = values.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[1., 2., 3., 4., 5., 6.]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[1., 1., 1.]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/gather_scatter_nd.rs b/crates/burn-backend-tests/tests/autodiff/gather_scatter_nd.rs new file mode 100644 index 0000000..3bbf7a3 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/gather_scatter_nd.rs @@ -0,0 +1,453 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn test_scatter_nd_add_grad() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[10.0, 20.0, 30.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + // scatter_nd_add: data[1, :] += values[0, :] + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Add); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data = all ones (identity for add) + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), + false, + ); + // grad_values = gather_nd(ones, indices) = ones at row 1 + grad_values + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_scatter_nd_assign_grad() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[10.0, 20.0, 30.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + // scatter_nd (assign): data[1, :] = values[0, :] + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Assign); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data: all ones except row 1 is zeroed out (overwritten positions get no gradient) + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + false, + ); + // grad_values = gather_nd(ones, indices) = ones at row 1 + grad_values + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_gather_nd_grad() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0], [2]]), &device); + + // gather_nd: extract rows 0 and 2 + let result: TestTensor<2> = data.clone().gather_nd(indices); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + + // grad_data: scatter_nd(zeros, indices, ones, Add) -> row 0 and 2 get 1s, row 1 stays 0 + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_add_grad_k_equals_d() { + // K=D: scalar-level indexing (each index tuple selects a single element) + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + let values: TestTensor<1> = + TestTensor::from_data(TensorData::from([10.0, 20.0]), &device).require_grad(); + // indices shape: [2, 2] => K=2=D, M=2, DV = 1+2-2 = 1 + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1], [1, 0]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Add); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data = all ones (identity for add) + grad_data + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0], [1.0, 1.0]]), false); + // grad_values = gather_nd(ones, indices) = [1.0, 1.0] + grad_values + .to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); +} + +#[test] +fn test_gather_nd_grad_k_equals_d() { + // K=D: scalar-level gather + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + // indices shape: [2, 2] => K=2=D + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1], [1, 0]]), &device); + + let result: TestTensor<1> = data.clone().gather_nd(indices); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + + // grad = scatter_nd(zeros, indices, ones, Add) + // [0,1] => data[0][1] gets 1, [1,0] => data[1][0] gets 1 + grad_data + .to_data() + .assert_eq(&TensorData::from([[0.0, 1.0], [1.0, 0.0]]), false); +} + +#[test] +fn test_scatter_nd_mul_grad() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[10.0, 20.0, 30.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + // scatter_nd_mul: data[1, :] *= values[0, :] + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Mul); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data = grad * scatter_nd(ones, indices, values, Assign) + // row 1 gets the values, others stay at 1. + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [10.0, 20.0, 30.0], [1.0, 1.0, 1.0]]), + false, + ); + // grad_values = gather_nd(grad, indices) * gather_nd(data, indices) + // = ones * data[1, :] = [4.0, 5.0, 6.0] + grad_values + .to_data() + .assert_eq(&TensorData::from([[4.0, 5.0, 6.0]]), false); +} + +#[test] +fn test_scatter_nd_max_grad_data_wins() { + // values < data at scattered positions: gradient flows fully to data. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0, 3.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Max); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // data wins everywhere on row 1, so grad_data = ones (full flow). + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), + false, + ); + // values lose, so grad_values is zero. + grad_values + .to_data() + .assert_eq(&TensorData::from([[0.0, 0.0, 0.0]]), false); +} + +#[test] +fn test_scatter_nd_max_grad_values_win() { + // values > data at scattered positions: grad_data zeroed at scattered positions. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[10.0, 20.0, 30.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Max); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data zeroed at row 1 (values won), ones elsewhere. + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + false, + ); + grad_values + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_scatter_nd_max_grad_tie() { + // Mixed: column 0 -> data wins, column 1 -> tie (both get grad), column 2 -> values wins. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 5.0, 10.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Max); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // Row 1: data_won = [T, T, F], values_won = [F, T, T] (tie at col 1 contributes to both). + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [1.0, 1.0, 1.0]]), + false, + ); + grad_values + .to_data() + .assert_eq(&TensorData::from([[0.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_scatter_nd_min_grad() { + // Min mirror of test_scatter_nd_max_grad_values_win: values < data => values win for Min. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0, 3.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Min); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + false, + ); + grad_values + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_scatter_nd_min_grad_tie() { + // Mixed: column 0 -> values win, column 1 -> tie, column 2 -> data wins. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + &device, + ) + .require_grad(); + let values: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 5.0, 10.0]]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[1]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Min); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // Row 1: data_won = [F, T, T], values_won = [T, T, F]. + grad_data.to_data().assert_eq( + &TensorData::from([[1.0, 1.0, 1.0], [0.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), + false, + ); + grad_values + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 0.0]]), false); +} + +#[test] +fn test_scatter_nd_max_grad_k_equals_d() { + // K=D scalar-level Max: each index targets one element. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + let values: TestTensor<1> = + TestTensor::from_data(TensorData::from([10.0, 1.0]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1], [1, 0]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Max); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + grad_data + .to_data() + .assert_eq(&TensorData::from([[1.0, 0.0], [1.0, 1.0]]), false); + grad_values + .to_data() + .assert_eq(&TensorData::from([1.0, 0.0]), false); +} + +#[test] +fn test_scatter_nd_mul_grad_k_equals_d() { + // K=D scalar-level Mul: each index targets one element. + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + let values: TestTensor<1> = + TestTensor::from_data(TensorData::from([10.0, 20.0]), &device).require_grad(); + // Multiply data[0,1] by 10 and data[1,0] by 20. + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1], [1, 0]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Mul); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data[0,1] = 10 (multiplier), grad_data[1,0] = 20, others = 1. + grad_data + .to_data() + .assert_eq(&TensorData::from([[1.0, 10.0], [20.0, 1.0]]), false); + // grad_values = data at [0,1]=2, [1,0]=3. + grad_values + .to_data() + .assert_eq(&TensorData::from([2.0, 3.0]), false); +} + +#[test] +fn test_scatter_nd_mul_grad_only_data_tracked() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + let values: TestTensor<1> = TestTensor::from_data(TensorData::from([10.0]), &device); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1]]), &device); + + let result: TestTensor<2> = data + .clone() + .scatter_nd(indices, values, IndexingUpdateOp::Mul); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + + grad_data + .to_data() + .assert_eq(&TensorData::from([[1.0, 10.0], [1.0, 1.0]]), false); +} + +#[test] +fn test_scatter_nd_mul_grad_only_values_tracked() { + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + let values: TestTensor<1> = + TestTensor::from_data(TensorData::from([10.0]), &device).require_grad(); + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1]]), &device); + + let result: TestTensor<2> = data.scatter_nd(indices, values.clone(), IndexingUpdateOp::Mul); + let grads = result.sum().backward(); + + let grad_values = values.grad(&grads).unwrap(); + + grad_values + .to_data() + .assert_eq(&TensorData::from([2.0]), false); +} + +#[test] +fn test_scatter_nd_assign_grad_k_equals_d() { + // K=D: scalar-level assign, each index overwrites a single element + let device = AutodiffDevice::new(); + let data: TestTensor<2> = + TestTensor::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device).require_grad(); + let values: TestTensor<1> = + TestTensor::from_data(TensorData::from([10.0, 20.0]), &device).require_grad(); + // indices shape: [2, 2] => K=2=D + // Overwrite data[0,1] and data[1,0] + let indices = TestTensorInt::<2>::from_data(TensorData::from([[0, 1], [1, 0]]), &device); + + let result: TestTensor<2> = + data.clone() + .scatter_nd(indices, values.clone(), IndexingUpdateOp::Assign); + let grads = result.sum().backward(); + + let grad_data = data.grad(&grads).unwrap(); + let grad_values = values.grad(&grads).unwrap(); + + // grad_data: ones everywhere except the overwritten positions are zeroed + // [0,1] and [1,0] were overwritten, so those get 0 + grad_data + .to_data() + .assert_eq(&TensorData::from([[1.0, 0.0], [0.0, 1.0]]), false); + // grad_values = gather_nd(ones, indices) = [1.0, 1.0] + grad_values + .to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/gelu.rs b/crates/burn-backend-tests/tests/autodiff/gelu.rs new file mode 100644 index 0000000..a875535 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/gelu.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance, activation}; + +#[test] +fn should_diff_gelu() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[0.0, 1.0], [-3.0, 4.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[6.0, -0.5], [9.0, 10.0]], &device).require_grad(); + + let x = tensor_1.clone().matmul(activation::gelu(tensor_2.clone())); + let x = tensor_1.clone().matmul(x); + let grads = x.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::permissive(); + let expected = TensorData::from([[1.46281, 1.46281], [48.22866, 153.46280]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[-15.0000, -1.98757], [17.0000, 17.0000]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/gradients.rs b/crates/burn-backend-tests/tests/autodiff/gradients.rs new file mode 100644 index 0000000..f51400e --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/gradients.rs @@ -0,0 +1,23 @@ +use super::*; +use burn_tensor::{Distribution, activation}; + +#[test] +fn should_update_tensor_when_grad_replace() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::random([32, 32], Distribution::Default, &device).require_grad(); + let tensor_2 = TestTensor::random([32, 32], Distribution::Default, &device); + + let x = tensor_1.clone().matmul(activation::gelu(tensor_2)); + let mut grads = x.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + let grad_1_updated = + TestTensor::random([32, 32], Distribution::Default, &device).require_grad(); + tensor_1.grad_replace(&mut grads, grad_1_updated.clone().inner()); + + let grad_1_new = tensor_1.grad(&grads).unwrap(); + + assert_ne!(grad_1_new.to_data(), grad_1.into_data()); + assert_eq!(grad_1_new.into_data(), grad_1_updated.into_data()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/hypot.rs b/crates/burn-backend-tests/tests/autodiff/hypot.rs new file mode 100644 index 0000000..e824335 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/hypot.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_hypot() { + let data_a = TensorData::from([[3.0, 4.0], [5.0, 12.0]]); + let data_b = TensorData::from([[4.0, 3.0], [12.0, 5.0]]); + + let device = AutodiffDevice::new(); + let tensor_a = TestTensor::<2>::from_data(data_a, &device).require_grad(); + let tensor_b = TestTensor::<2>::from_data(data_b, &device).require_grad(); + + let tensor_c = tensor_a.clone().hypot(tensor_b.clone()); + let grads = tensor_c.backward(); + + let grad_a = tensor_a.grad(&grads).unwrap(); + let grad_b = tensor_b.grad(&grads).unwrap(); + + grad_a.to_data().assert_approx_eq::( + &TensorData::from([[0.6, 0.8], [0.3846, 0.9231]]), + Tolerance::default(), + ); + grad_b.to_data().assert_approx_eq::( + &TensorData::from([[0.8, 0.6], [0.9231, 0.3846]]), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/linear.rs b/crates/burn-backend-tests/tests/autodiff/linear.rs new file mode 100644 index 0000000..9949949 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/linear.rs @@ -0,0 +1,70 @@ +use super::*; +use burn_tensor::module::linear; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_linear_with_bias() { + let device = AutodiffDevice::new(); + let x = TestTensor::<3>::from_data( + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + &device, + ) + .require_grad(); + let weight = + TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device).require_grad(); + let bias = TestTensor::<1>::from_data([0.1, 0.2, 0.3], &device).require_grad(); + + let output = linear(x.clone(), weight.clone(), Some(bias.clone())); + let grads = output.backward(); + + let tolerance = Tolerance::default(); + x.grad(&grads) + .unwrap() + .to_data() + .assert_approx_eq::( + &TensorData::from([[[6.0, 15.0], [6.0, 15.0]], [[6.0, 15.0], [6.0, 15.0]]]), + tolerance, + ); + weight + .grad(&grads) + .unwrap() + .to_data() + .assert_approx_eq::( + &TensorData::from([[16.0, 16.0, 16.0], [20.0, 20.0, 20.0]]), + tolerance, + ); + bias.grad(&grads) + .unwrap() + .to_data() + .assert_approx_eq::(&TensorData::from([4.0, 4.0, 4.0]), tolerance); +} + +#[test] +fn should_diff_linear_matches_matmul_decomposition() { + let device = AutodiffDevice::new(); + let x_data = TensorData::from([[[1.0, -2.0], [3.0, 4.0]], [[-5.0, 6.0], [7.0, -8.0]]]); + let w_data = TensorData::from([[0.5, -1.0, 2.0], [1.5, 2.5, -0.5]]); + + // Gradients through the linear module op. + let x = TestTensor::<3>::from_data(x_data.clone(), &device).require_grad(); + let weight = TestTensor::<2>::from_data(w_data.clone(), &device).require_grad(); + let grads = linear(x.clone(), weight.clone(), None).backward(); + let x_grad = x.grad(&grads).unwrap(); + let weight_grad = weight.grad(&grads).unwrap(); + + // Gradients through the explicit broadcast matmul decomposition. + let x_ref = TestTensor::<3>::from_data(x_data, &device).require_grad(); + let weight_ref = TestTensor::<2>::from_data(w_data, &device).require_grad(); + let grads_ref = x_ref + .clone() + .matmul(weight_ref.clone().unsqueeze::<3>()) + .backward(); + + let tolerance = Tolerance::default(); + x_grad + .to_data() + .assert_approx_eq::(&x_ref.grad(&grads_ref).unwrap().to_data(), tolerance); + weight_grad + .to_data() + .assert_approx_eq::(&weight_ref.grad(&grads_ref).unwrap().to_data(), tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/log.rs b/crates/burn-backend-tests/tests/autodiff/log.rs new file mode 100644 index 0000000..4b4da5f --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/log.rs @@ -0,0 +1,30 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_diff_log() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().log()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + let expected = TensorData::from([[60.2652, 72.3130], [60.2652, 72.3130]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[22.8614, 24.5043], [24.5729, 26.8507]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/log1p.rs b/crates/burn-backend-tests/tests/autodiff/log1p.rs new file mode 100644 index 0000000..70fbf00 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/log1p.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_log1p() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[0.0, 1.0], [3.0, 4.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[6.0, 7.0], [9.0, 10.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().log1p()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + let expected = TensorData::from([[64.80622101, 75.49362183], [64.80622101, 75.49362183]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[22.92208481, 24.47565651], [24.72780228, 26.86416626]]); + + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/log_sigmoid.rs b/crates/burn-backend-tests/tests/autodiff/log_sigmoid.rs new file mode 100644 index 0000000..746a04b --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/log_sigmoid.rs @@ -0,0 +1,19 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn should_diff_log_sigmoid() { + let data = TensorData::from([[0.8762, -0.1423], [-300., 200.]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data, &device).require_grad(); + let tensor_2 = activation::log_sigmoid(tensor_1.clone()); + let grads = tensor_2.backward(); + + let grad = tensor_1.grad(&grads).unwrap(); + + let expected = TensorData::from([[0.293966, 0.535515], [1.000000, 0.000000]]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/mask.rs b/crates/burn-backend-tests/tests/autodiff/mask.rs new file mode 100644 index 0000000..c61758c --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/mask.rs @@ -0,0 +1,63 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_mask_fill() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let mask = TensorData::from([[true, false], [false, true]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let mask = TestTensorBool::<2>::from_bool(mask, &device); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.mask_fill(mask, 2.0); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[7.0, 3.0], [4.0, 2.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[2.0, 1.0], [3.0, 7.0]]), false); +} + +#[test] +fn should_diff_mask_where() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data([[1.0, 7.0], [2.0, 3.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[4.0, 7.0], [2.0, 3.0]], &device).require_grad(); + let tensor_3 = TestTensor::from_data([[8.8, 9.8], [10.8, 11.8]], &device).require_grad(); + let mask = TestTensorBool::<2>::from_data([[true, false], [false, true]], &device); + + let tensor_4 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_4.clone().matmul(tensor_3.clone()); + let tensor_6 = tensor_5.mask_where(mask, tensor_3.clone()); + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + let grad_3 = tensor_3.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + let expected = TensorData::from([[121.8, 55.0], [110.8, 50.0]]); + grad_1 + .into_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[27.4, 33.4], [95.0, 115.0]]); + grad_2 + .into_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[15., 18.], [23., 29.]]); + grad_3 + .into_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/matmul.rs b/crates/burn-backend-tests/tests/autodiff/matmul.rs new file mode 100644 index 0000000..39cb6db --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/matmul.rs @@ -0,0 +1,83 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_matmul() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[11.0, 5.0], [11.0, 5.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[3.0, 3.0], [10.0, 10.0]]), false); + tensor_3 + .to_data() + .assert_eq(&TensorData::from([[18.0, 28.0], [14.0, 23.0]]), false); +} + +#[test] +fn test_matmul_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_4.matmul(tensor_3); + + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[44.0, 20.0], [44.0, 20.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[56.0, 56.0], [16.0, 16.0]]), false); +} + +#[test] +fn test_matmul_complex_2() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_4.matmul(tensor_3.clone()); + let tensor_6 = tensor_1.clone().matmul(tensor_5); + + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[800.0, 792.0], [360.0, 592.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[264., 264.0], [344.0, 344.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/maxmin.rs b/crates/burn-backend-tests/tests/autodiff/maxmin.rs new file mode 100644 index 0000000..d808f83 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/maxmin.rs @@ -0,0 +1,76 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_max_dim() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 7.0], [-2.0, -3.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[4.0, -7.0], [2.0, 3.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.max_dim(1).unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[50.0, 34.0], [40.0, -10.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[8.0, 10.0], [56.0, 15.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_min_dim() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 7.0], [-2.0, -3.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[4.0, -7.0], [2.0, 3.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.min_dim(1).unsqueeze()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[-42.0, 38.0], [-34.0, -24.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[10.0, 8.0], [15.0, 56.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_min_dim_3d_dim1() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0], [-2.0, -3.0]]], &device).require_grad(); + let tensor_2 = TestTensor::<3>::from_data([[[4., -7.], [2., 3.]]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().mul(tensor_2.clone()); + let tensor_4 = tensor_3.min_dim(1); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[[0., -7.], [2., 0.]]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[[0., 7.], [-2., -0.]]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/maxpool1d.rs b/crates/burn-backend-tests/tests/autodiff/maxpool1d.rs new file mode 100644 index 0000000..046ee15 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/maxpool1d.rs @@ -0,0 +1,133 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::module::max_pool1d; + +#[test] +fn test_max_pool1d_simple() { + let kernel_size = 4; + let padding = 0; + let stride = 1; + let dilation = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[0.9861, 0.5474, 0.4477, 0.0732, 0.3548, 0.8221]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<3>::from_data([[[1., 1., 0., 0., 0., 1.]]], &device); + + let output = max_pool1d(x.clone(), kernel_size, stride, padding, dilation, false); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_with_dilation() { + let kernel_size = 4; + let padding = 0; + let stride = 1; + let dilation = 2; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + 0.5388, 0.0676, 0.7122, 0.8316, 0.0653, 0.9154, 0.1536, 0.9089, 0.8016, 0.7518, 0.2073, + 0.0501, 0.8811, 0.5604, 0.5075, 0.4384, 0.9963, 0.9698, 0.4988, 0.2609, 0.3391, 0.2230, + 0.4610, 0.5365, 0.6880, + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<3>::from_data( + [[[ + 0., 0., 1., 0., 0., 3., 0., 1., 2., 1., 0., 0., 2., 0., 0., 0., 4., 4., 0., 0., 0., 0., + 0., 0., 1., + ]]], + &device, + ); + + let output = max_pool1d(x.clone(), kernel_size, stride, padding, dilation, false); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_complex() { + let kernel_size = 4; + let padding = 0; + let stride = 1; + let dilation = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + 0.5388, 0.0676, 0.7122, 0.8316, 0.0653, 0.9154, 0.1536, 0.9089, 0.8016, 0.7518, 0.2073, + 0.0501, 0.8811, 0.5604, 0.5075, 0.4384, 0.9963, 0.9698, 0.4988, 0.2609, 0.3391, 0.2230, + 0.4610, 0.5365, 0.6880, + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<3>::from_data( + [[[ + 0., 0., 0., 2., 0., 4., 0., 2., 1., 0., 0., 0., 4., 0., 0., 0., 4., 1., 1., 0., 0., 0., + 1., 1., 1., + ]]], + &device, + ); + + let output = max_pool1d(x.clone(), kernel_size, stride, padding, dilation, false); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_complex_with_padding() { + let kernel_size = 4; + let padding = 2; + let stride = 1; + let dilation = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + 0.5388, 0.0676, 0.7122, 0.8316, 0.0653, 0.9154, 0.1536, 0.9089, 0.8016, 0.7518, 0.2073, + 0.0501, 0.8811, 0.5604, 0.5075, 0.4384, 0.9963, 0.9698, 0.4988, 0.2609, 0.3391, 0.2230, + 0.4610, 0.5365, 0.6880, + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<3>::from_data( + [[[ + 1., 0., 1., 2., 0., 4., 0., 2., 1., 0., 0., 0., 4., 0., 0., 0., 4., 1., 1., 0., 0., 0., + 1., 1., 3., + ]]], + &device, + ); + + let output = max_pool1d(x.clone(), kernel_size, stride, padding, dilation, false); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/maxpool2d.rs b/crates/burn-backend-tests/tests/autodiff/maxpool2d.rs new file mode 100644 index 0000000..87328de --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/maxpool2d.rs @@ -0,0 +1,271 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::module::max_pool2d; + +#[test] +fn test_max_pool2d_simple_1() { + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 0; + let padding_2 = 0; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 1; + let dilation_2 = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + [0.2479, 0.6386, 0.3166, 0.5742], + [0.7065, 0.1940, 0.6305, 0.8959], + [0.5416, 0.8602, 0.8129, 0.1662], + [0.3358, 0.3059, 0.8293, 0.0990], + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<4>::from_data( + [[[ + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 2.0], + [0.0, 2.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + ]]], + &device, + ); + + let output = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_simple_2() { + let kernel_size_1 = 2; + let kernel_size_2 = 2; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 1; + let dilation_2 = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + [0.2479, 0.6386, 0.3166, 0.5742], + [0.7065, 0.1940, 0.6305, 0.8959], + [0.5416, 0.8602, 0.8129, 0.1662], + [0.3358, 0.3059, 0.8293, 0.0990], + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<4>::from_data( + [[[ + [1., 3., 0., 2.], + [3., 0., 0., 4.], + [1., 4., 0., 1.], + [2., 0., 3., 1.], + ]]], + &device, + ); + + let output = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_with_dilation() { + let kernel_size_1 = 2; + let kernel_size_2 = 2; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 2; + let dilation_2 = 2; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + [0.2479, 0.6386, 0.3166, 0.5742], + [0.7065, 0.1940, 0.6305, 0.8959], + [0.5416, 0.8602, 0.8129, 0.1662], + [0.3358, 0.3059, 0.8293, 0.0990], + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<4>::from_data( + [[[ + [0., 0., 0., 0.], + [1., 1., 1., 2.], + [0., 4., 4., 0.], + [0., 1., 2., 0.], + ]]], + &device, + ); + + let output = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_complex() { + let kernel_size_1 = 4; + let kernel_size_2 = 2; + let padding_1 = 2; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + [[[ + [0.5388, 0.0676, 0.7122, 0.8316, 0.0653], + [0.9154, 0.1536, 0.9089, 0.8016, 0.7518], + [0.2073, 0.0501, 0.8811, 0.5604, 0.5075], + [0.4384, 0.9963, 0.9698, 0.4988, 0.2609], + [0.3391, 0.2230, 0.4610, 0.5365, 0.6880], + ]]], + &device, + ) + .require_grad(); + let x_grad_expected = TestTensor::<4>::from_data( + [[[ + [0., 0., 0., 3., 0.], + [4., 0., 2., 1., 0.], + [0., 0., 0., 0., 0.], + [2., 4., 0., 0., 0.], + [0., 0., 0., 0., 2.], + ]]], + &device, + ); + + let output = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_ceil_mode() { + // Test ceil_mode=true with gradient computation + // Using 1x1x6x6 input with kernel 3x3, stride 2x2, padding 0 + // Floor mode: output 2x2 + // Ceil mode: output 3x3 + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 0; + let padding_2 = 0; + let stride_1 = 2; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + let device = AutodiffDevice::new(); + // Input (values 1-36): + let x = TestTensor::from_data( + [[[ + [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + [7.0, 8.0, 9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0, 17.0, 18.0], + [19.0, 20.0, 21.0, 22.0, 23.0, 24.0], + [25.0, 26.0, 27.0, 28.0, 29.0, 30.0], + [31.0, 32.0, 33.0, 34.0, 35.0, 36.0], + ]]], + &device, + ) + .require_grad(); + + // Expected gradients for ceil_mode output 3x3: + // Output positions and their max value positions: + // (0,0): max at (2,2)=15 -> grad[2,2] += 1 + // (0,1): max at (2,4)=17 -> grad[2,4] += 1 + // (0,2): max at (2,5)=18 -> grad[2,5] += 1 + // (1,0): max at (4,2)=27 -> grad[4,2] += 1 + // (1,1): max at (4,4)=29 -> grad[4,4] += 1 + // (1,2): max at (4,5)=30 -> grad[4,5] += 1 + // (2,0): max at (5,2)=33 -> grad[5,2] += 1 + // (2,1): max at (5,4)=35 -> grad[5,4] += 1 + // (2,2): max at (5,5)=36 -> grad[5,5] += 1 + let x_grad_expected = TestTensor::<4>::from_data( + [[[ + [0., 0., 0., 0., 0., 0.], + [0., 0., 0., 0., 0., 0.], + [0., 0., 1., 0., 1., 1.], + [0., 0., 0., 0., 0., 0.], + [0., 0., 1., 0., 1., 1.], + [0., 0., 1., 0., 1., 1.], + ]]], + &device, + ); + + let output = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + true, + ); + let grads = output.backward(); + + // Asserts + let x_grad_actual = x.grad(&grads).unwrap(); + x_grad_expected + .to_data() + .assert_approx_eq::(&x_grad_actual.to_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/memory_management.rs b/crates/burn-backend-tests/tests/autodiff/memory_management.rs new file mode 100644 index 0000000..9fc43fa --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/memory_management.rs @@ -0,0 +1,280 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_mm_independent_trees() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3; + let tensor_6 = tensor_4 * tensor_5; + + // Second tree + let tensor_7 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_8 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_9 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_10 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_11 = tensor_7.clone() * tensor_8.clone(); + let tensor_12 = tensor_9.clone() * tensor_10.clone(); + let tensor_13 = tensor_11 * tensor_12; + + let _grads = tensor_6.backward(); + let grads = tensor_13.backward(); + + assert!(tensor_7.grad(&grads).is_some()); + assert!(tensor_8.grad(&grads).is_some()); + assert!(tensor_9.grad(&grads).is_some()); + assert!(tensor_10.grad(&grads).is_some()); +} + +#[test] +#[should_panic] +fn test_mm_crossover_trees_root_unavailable() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3; + let tensor_6 = tensor_4.clone() * tensor_5; + + // Second tree + let tensor_7 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_8 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_9 = tensor_7.clone() * tensor_8.clone(); + let tensor_10 = tensor_4 * tensor_9; + + let _grads = tensor_6.backward(); + let _grads = tensor_10.backward(); +} + +#[test] +fn test_mm_crossover_trees_with_referred_subtree() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3; + let tensor_6 = tensor_4.clone() * tensor_5; + + // Second tree + let tensor_7 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_8 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_9 = tensor_7.clone() * tensor_8.clone(); + let _tensor_10 = tensor_4 * tensor_9.clone(); + + let _grads = tensor_6.backward(); + let _grads = tensor_9.backward(); +} + +#[test] +fn test_mm_three_crossover_trees_last_still_usable() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3; + let tensor_6 = tensor_4 * tensor_5.clone(); + + // Third tree + let tensor_7 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_8 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_9 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_10 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_11 = tensor_7 * tensor_8; + let tensor_12 = tensor_9 * tensor_10; + let tensor_13 = tensor_11 * tensor_12.clone(); + + // Second tree (in between) + let _tensor_14 = tensor_5 * tensor_12; + + let _grads = tensor_6.backward(); + let _grads = tensor_13.backward(); +} + +#[test] +#[should_panic] +fn test_mm_three_crossover_trees_middle_one_unavailable() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3; + let tensor_6 = tensor_4 * tensor_5.clone(); + + // Third tree + let tensor_7 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_8 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_9 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_10 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_11 = tensor_7 * tensor_8; + let tensor_12 = tensor_9 * tensor_10; + let _tensor_13 = tensor_11 * tensor_12.clone(); + + // Second tree (in between) + let tensor_14 = tensor_5 * tensor_12; + + let _grads = tensor_6.backward(); + let _grads = tensor_14.backward(); +} + +#[test] +fn test_mm_self_referencing_tree() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // First tree + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data.clone(), &device).require_grad(); + + let tensor_3 = tensor_0 * tensor_1; + let tensor_5 = tensor_2 * tensor_3.clone(); + let tensor_6 = tensor_3 * tensor_5; + + let _grads = tensor_6.backward(); +} + +#[test] +fn test_mm_with_non_impacting_detach() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::<2>::from_data(data, &device).require_grad(); + + let tensor_4 = tensor_1.clone() * tensor_2.clone(); + let tensor_5 = tensor_4.detach() * tensor_3.clone(); + + let grads = tensor_5.backward(); + assert!(tensor_3.grad(&grads).is_some()); +} + +#[test] +fn test_mm_with_missing_require_grad_after_cleanup() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data.clone(), &device); + let tensor_3 = TestTensor::<2>::from_data(data.clone(), &device); + + let tensor_4 = tensor_1.clone() * tensor_2.clone(); + let tensor_5 = tensor_4 * tensor_3.clone(); + + // Trivial backward, just to trigger cleanup + TestTensor::<2>::from_data(data, &device) + .require_grad() + .backward(); + + let grads = tensor_5.backward(); + assert!(tensor_1.grad(&grads).is_some()); + assert!(tensor_2.grad(&grads).is_none()); + assert!(tensor_3.grad(&grads).is_none()); +} + +#[test] +fn test_mm_with_detach_after_cleanup() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_3 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + + let tensor_4 = tensor_1.clone() * tensor_2.clone(); + let tensor_5 = tensor_4 * tensor_3.clone().detach(); + + // Trivial backward, just to trigger cleanup + TestTensor::<2>::from_data(data, &device) + .require_grad() + .backward(); + + let grads = tensor_5.backward(); + assert!(tensor_1.grad(&grads).is_some()); + assert!(tensor_2.grad(&grads).is_some()); + assert!(tensor_3.grad(&grads).is_none()); +} + +#[test] +#[should_panic] +fn test_mm_deletables_propagate_well() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + + let tensor_2 = tensor_0 * tensor_1; + let tensor_3 = tensor_2.clone().exp(); + let _tensor_4 = tensor_3.clone().log(); + + let _grads = tensor_2.backward(); + + // We are testing that after backward on tensor_2, not only the leaf tensor_4 is deleted, but + // the intermediate tensor_3 as well + let _grads = tensor_3.backward(); +} + +#[test] +fn test_mm_node_explored_once_can_still_be_tagged_as_useful_when_found_again_deeper() { + let data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let device = AutodiffDevice::new(); + + // The test has 50% chance of starting with leaf tensor_8 instead of tensor_4, which is not informative + // By repeating it many times it becomes almost impossible that it passes if it shouldn't + for _ in 0..12 { + let tensor_0 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + + let tensor_2 = tensor_1.clone().exp(); + let tensor_3 = tensor_0.exp(); + let _tensor_4 = tensor_3.clone() * tensor_2.clone(); + let tensor_5 = tensor_2.exp(); + let tensor_6 = tensor_5.exp(); + let tensor_7 = tensor_6.exp(); + let tensor_8 = tensor_7.exp(); + + // tensor_2 should be tagged unknown through the leaf tensor_4, then useful through the leaf tensor_8 + // which should happen after because tensor_2 is deeper from tensor_8 point of view and we're in breadth first search + tensor_3.backward(); + let grads = tensor_8.backward(); + + assert!(tensor_1.grad(&grads).is_some()); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/mod.rs b/crates/burn-backend-tests/tests/autodiff/mod.rs new file mode 100644 index 0000000..df025ee --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/mod.rs @@ -0,0 +1,81 @@ +#[allow(unused_imports)] // required for re-included modules +pub use super::*; + +mod abs; +mod adaptive_avgpool1d; +mod adaptive_avgpool2d; +mod add; +mod aggregation; +#[cfg(feature = "distributed")] +mod all_reduce; +mod avgpool1d; +mod avgpool2d; +mod backward; +mod bridge; +mod broadcast; +mod cast; +mod cat; +mod ceil; +mod checkpoint; +mod complex; +mod conv1d; +mod conv2d; +mod conv3d; +mod conv_transpose1d; +mod conv_transpose2d; +mod conv_transpose3d; +mod cross; +mod cross_entropy; +mod ctc; +mod cummax; +mod cummin; +mod cumprod; +mod cumsum; +mod deform_conv2d; +mod div; +mod erf; +mod exp; +mod expand; +mod flip; +mod floor; +mod gather_scatter; +mod gather_scatter_nd; +mod gelu; +mod gradients; +mod hypot; +mod linear; +mod log; +mod log1p; +mod log_sigmoid; +mod mask; +mod matmul; +mod maxmin; +mod maxpool1d; +mod maxpool2d; +mod memory_management; +mod mul; +mod multithread; +mod nearest_interpolate; +mod neg; +mod nonzero; +mod permute; +mod pow; +mod recip; +mod relu; +mod remainder; +mod repeat_dim; +mod reshape; +mod rfft; +mod round; +mod select; +mod sigmoid; +mod sign; +mod slice; +mod slice_assign; +mod softmax; +mod sort; +mod sqrt; +mod sub; +mod transpose; +mod trig; +mod unfold; diff --git a/crates/burn-backend-tests/tests/autodiff/mul.rs b/crates/burn-backend-tests/tests/autodiff/mul.rs new file mode 100644 index 0000000..7c43532 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/mul.rs @@ -0,0 +1,68 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_mul() { + let data_1 = TensorData::from([1.0, 7.0]); + let data_2 = TensorData::from([4.0, 7.0]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data(data_1.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2.clone(), &device).require_grad(); + + let tensor_3 = tensor_1.clone().mul(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let _grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq(&data_2, false); + tensor_3 + .into_data() + .assert_eq(&TensorData::from([4.0, 49.0]), false); +} + +#[test] +fn should_diff_mul_scalar() { + let data = TensorData::from([2.0, 5.0]); + + let tensor = TestTensor::<1>::from_data(data, &AutodiffDevice::new()).require_grad(); + let tensor_out = tensor.clone().mul_scalar(4.0); + + let grads = tensor_out.backward(); + let grad = tensor.grad(&grads).unwrap(); + + tensor_out + .into_data() + .assert_eq(&TensorData::from([8.0, 20.0]), false); + grad.to_data() + .assert_eq(&TensorData::from([4.0, 4.0]), false); +} + +#[test] +fn test_mul_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().mul(tensor_2.clone()); + let tensor_5 = tensor_4.mul(tensor_3); + let tensor_6 = tensor_1.clone().mul(tensor_5); + + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[16.0, 196.0], [104.0, -36.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[2.0, 98.0], [338.0, 18.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/multithread.rs b/crates/burn-backend-tests/tests/autodiff/multithread.rs new file mode 100644 index 0000000..19f36a4 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/multithread.rs @@ -0,0 +1,90 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +// TODO: FIXME https://github.com/tracel-ai/burn/issues/4739 +#[ignore = "Flaky test"] +fn should_behave_the_same_with_multithread() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let with_move = || { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2.clone(), &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_4.matmul(tensor_3); + + // Task 1 + let tensor_1_cloned = tensor_1.clone(); + let tensor_2_cloned = tensor_2.clone(); + let tensor_5_cloned = tensor_5.clone(); + + let first_call = move || { + let tensor_6_1 = tensor_5_cloned.matmul(tensor_2_cloned); + tensor_6_1.matmul(tensor_1_cloned) + }; + + // Task 2 + let tensor_1_cloned = tensor_1.clone(); + let tensor_2_cloned = tensor_2.clone(); + let tensor_5_cloned = tensor_5; + + let second_call = move || { + let tensor_6_2 = tensor_5_cloned.matmul(tensor_1_cloned); + tensor_6_2.matmul(tensor_2_cloned) + }; + + let tensor_7_1_handle = std::thread::spawn(first_call); + let tensor_7_2_handle = std::thread::spawn(second_call); + + let tensor_7_1 = tensor_7_1_handle.join().unwrap(); + let tensor_7_2 = tensor_7_2_handle.join().unwrap(); + let tensor_8 = tensor_7_1.matmul(tensor_7_2); + + let grads = tensor_8.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + (grad_1, grad_2) + }; + let without_move = || { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1.clone(), &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2.clone(), &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_4.matmul(tensor_3); + + // Task 1 + let tensor_6_1 = tensor_5.clone().matmul(tensor_2.clone()); + let tensor_7_1 = tensor_6_1.matmul(tensor_1.clone()); + + // Task 2 + let tensor_6_2 = tensor_5.matmul(tensor_1.clone()); + let tensor_7_2 = tensor_6_2.matmul(tensor_2.clone()); + + let tensor_8 = tensor_7_1.matmul(tensor_7_2); + + let grads = tensor_8.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + (grad_1, grad_2) + }; + + let (grad_1, grad_2) = without_move(); + let (grad_1_moved, grad_2_moved) = with_move(); + + grad_1 + .into_data() + .assert_approx_eq::(&grad_1_moved.into_data(), Tolerance::default()); + grad_2 + .into_data() + .assert_approx_eq::(&grad_2_moved.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/nearest_interpolate.rs b/crates/burn-backend-tests/tests/autodiff/nearest_interpolate.rs new file mode 100644 index 0000000..11ce084 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/nearest_interpolate.rs @@ -0,0 +1,97 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::interpolate; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; + +#[test] +fn test_upsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 2, + channels: 1, + height: 7, + width: 5, + height_out: 8, + width_out: 7, + }; + + test.assert_output(TestTensor::from([ + [[ + [4., 2., 4., 2., 2.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + ]], + [[ + [4., 2., 4., 2., 2.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + [2., 1., 2., 1., 1.], + ]], + ])); +} + +#[test] +fn test_downsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 8, + width: 8, + height_out: 4, + width_out: 6, + }; + + test.assert_output(TestTensor::from([[[ + [1., 1., 1., 0., 1., 1., 1., 0.], + [0., 0., 0., 0., 0., 0., 0., 0.], + [1., 1., 1., 0., 1., 1., 1., 0.], + [0., 0., 0., 0., 0., 0., 0., 0.], + [1., 1., 1., 0., 1., 1., 1., 0.], + [0., 0., 0., 0., 0., 0., 0., 0.], + [1., 1., 1., 0., 1., 1., 1., 0.], + [0., 0., 0., 0., 0., 0., 0., 0.], + ]]])); +} + +struct InterpolateTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl InterpolateTestCase { + fn assert_output(self, x_grad: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let device = AutodiffDevice::new(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &x_grad.device()) + .reshape::<4, _>(shape_x) + .into_data(), + &device, + ) + .require_grad(); + + let output = interpolate( + x.clone(), + [self.height_out, self.width_out], + InterpolateOptions::new(InterpolateMode::Nearest), + ); + + let grads = output.backward(); + let x_grad_actual = x.grad(&grads).unwrap(); + + x_grad + .to_data() + .assert_approx_eq::(&x_grad_actual.into_data(), Tolerance::permissive()); + } +} diff --git a/crates/burn-backend-tests/tests/autodiff/neg.rs b/crates/burn-backend-tests/tests/autodiff/neg.rs new file mode 100644 index 0000000..30cc317 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/neg.rs @@ -0,0 +1,26 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_neg() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().neg()); + let tensor_4 = tensor_3.neg(); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[11.0, 5.0], [11.0, 5.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[3.0, 3.0], [10.0, 10.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/nonzero.rs b/crates/burn-backend-tests/tests/autodiff/nonzero.rs new file mode 100644 index 0000000..5ac59c5 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/nonzero.rs @@ -0,0 +1,41 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_nonzero() { + let data_1 = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let data_2 = TensorData::from([-1.0, 1.0]); + let mask = TensorData::from([[false, true], [true, false]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<1>::from_data(data_2, &device).require_grad(); + + // Multi-dimensional tensor indexing isn't really supported yet so the easiest way to do + // this is to flatten the mask and tensor to get proper indexing. Anyway the returned tensor would + // have dimensions different from the input, so this is somewhat equivalent. + let mask = TestTensorBool::<2>::from_bool(mask, &device).flatten::<1>(0, 1); + let indices = mask.nonzero(); + let tensor_3 = tensor_1 + .clone() + .flatten::<1>(0, 1) + .select(0, indices[0].clone()); + + // Vector dot product not supported (only 2D matmuls) so unsqueeze for test purposes + let tensor_4 = tensor_2 + .clone() + .unsqueeze_dim::<2>(0) + .matmul(tensor_3.unsqueeze_dim(1)); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[0.0, -1.0], [1.0, 0.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([2.0, 3.0]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/permute.rs b/crates/burn-backend-tests/tests/autodiff/permute.rs new file mode 100644 index 0000000..6a7510b --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/permute.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_permute() { + let data_1 = TensorData::from([[[1.0, 7.0], [2.0, 3.0]]]); // 1x2x2 + let data_2 = TensorData::from([[[1.0, 7.0], [3.2, 2.0], [3.0, 3.0]]]); // 1x3x2 + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().permute([0, 2, 1]); + let tensor_4 = tensor_1.clone().matmul(tensor_3); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + grad_1 + .into_data() + .assert_approx_eq::(&TensorData::from([[[7.2, 12.0], [7.2, 12.0]]]), tolerance); // 1x2x2 + grad_2.into_data().assert_approx_eq::( + &TensorData::from([[[3.0, 10.0], [3.0, 10.0], [3.0, 10.0]]]), + tolerance, + ); // 1x3x2 +} diff --git a/crates/burn-backend-tests/tests/autodiff/pow.rs b/crates/burn-backend-tests/tests/autodiff/pow.rs new file mode 100644 index 0000000..da55d04 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/pow.rs @@ -0,0 +1,93 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_powf_scalar() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().powf_scalar(0.4)); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(2e-3); + let expected = TensorData::from([[68.0, 79.0328], [68.0, 79.0328]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[23.5081, 25.2779], [26.0502, 28.6383]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_diff_powf() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data([2.0, 7.0], &device).require_grad(); + let tensor_2 = TestTensor::from_data([4.0, 2.0], &device).require_grad(); + + let tensor_3 = tensor_1.clone().powf(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([32.0, 14.0]); + grad_1 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([11.09035, 95.34960]); + grad_2 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([16.0, 49.0]); + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_powf_with_untracked_lhs() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data([2.0, 7.0], &device); + let tensor_2 = TestTensor::from_data([4.0, 2.0], &device).require_grad(); + + let tensor_3 = tensor_1.clone().powf(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([11.09035, 95.34960]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_powf_with_untracked_rhs() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data([2.0, 7.0], &device).require_grad(); + let tensor_2 = TestTensor::from_data([4.0, 2.0], &device); + + let tensor_3 = tensor_1.clone().powf(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + let expected = TensorData::from([32.0, 14.0]); + grad_1 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/recip.rs b/crates/burn-backend-tests/tests/autodiff/recip.rs new file mode 100644 index 0000000..f6fb667 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/recip.rs @@ -0,0 +1,22 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_recip() { + let data = TensorData::from([2.0, 5.0, 0.4]); + + let tensor = TestTensor::<1>::from_data(data, &AutodiffDevice::new()).require_grad(); + let tensor_out = tensor.clone().recip(); + + let grads = tensor_out.backward(); + let grad = tensor.grad(&grads).unwrap(); + + tensor_out + .into_data() + .assert_eq(&TensorData::from([0.5, 0.2, 2.5]), false); + grad.to_data().assert_approx_eq::( + &TensorData::from([-0.25, -0.04, -6.25]), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/relu.rs b/crates/burn-backend-tests/tests/autodiff/relu.rs new file mode 100644 index 0000000..118c4e9 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/relu.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::{TensorData, activation}; + +#[test] +fn should_diff_relu() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, -7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = activation::relu(tensor_3); + let tensor_5 = tensor_4.matmul(tensor_2.clone()); + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[-47.0, 9.0], [-35.0, 15.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[15.0, 13.0], [-2.0, 39.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/remainder.rs b/crates/burn-backend-tests/tests/autodiff/remainder.rs new file mode 100644 index 0000000..e33dc09 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/remainder.rs @@ -0,0 +1,41 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_remainder() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data( + TensorData::from([ + 0.9742, 0.3676, 0.0905, 0.8066, 0.7072, 0.7883, 0.6987, 0.1560, 0.7179, 0.7874, 0.9032, + 0.1845, + ]), + &device, + ) + .require_grad(); + let tensor_2 = TestTensor::<1>::from_data( + TensorData::from([ + 0.3357, 0.0285, 0.4115, 0.5511, 0.8637, 0.3593, 0.3885, 0.2569, 0.0936, 0.7172, 0.4792, + 0.4898, + ]), + &device, + ) + .require_grad(); + let tensor_3 = tensor_1.clone().remainder(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([ + -2.0, -12.0, -0.0, -1.0, -0.0, -2.0, -1.0, -0.0, -7.0, -1.0, -1.0, -0.0, + ]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/repeat_dim.rs b/crates/burn-backend-tests/tests/autodiff/repeat_dim.rs new file mode 100644 index 0000000..42ead57 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/repeat_dim.rs @@ -0,0 +1,44 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_repeat() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0], [2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().repeat_dim(1, 3); + + let tensor_3 = tensor_1.matmul(tensor_3); + let grads = tensor_3.backward(); + + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_2 + .to_data() + .assert_eq(&TensorData::from([[-3.0], [12.0]]), false); +} + +#[test] +fn should_diff_repeat_multi_dim() { + let data_1 = TensorData::from([[1.0, 7.0], [-2.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 2.0], [2.0, 4.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().repeat_dim(1, 3); + + let tensor_3 = tensor_1.matmul(tensor_3); + let grads = tensor_3.backward(); + + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_2 + .to_data() + .assert_eq(&TensorData::from([[-3.0, -3.0], [12.0, 12.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/reshape.rs b/crates/burn-backend-tests/tests/autodiff/reshape.rs new file mode 100644 index 0000000..29086a7 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/reshape.rs @@ -0,0 +1,26 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_reshape() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([4.0, 7.0, 2.0, 3.0]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<1>::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().reshape([2, 2]); + let tensor_4 = tensor_1.clone().matmul(tensor_3); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[11.0, 5.0], [11.0, 5.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([3.0, 3.0, 10.0, 10.0]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/rfft.rs b/crates/burn-backend-tests/tests/autodiff/rfft.rs new file mode 100644 index 0000000..fae4acb --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/rfft.rs @@ -0,0 +1,201 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::signal; + +#[cfg(not(feature = "ndarray"))] +use burn_tensor::{DType, Element}; + +#[test] +#[cfg(not(feature = "ndarray"))] +fn should_diff_rfft() { + // Lower precisions not supported + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random1 = TensorData::from([0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18]); + let random2 = TensorData::from([0.03, 0.74, 0.33, 0.70, 0.07, 0.61, 0.32, 0.66]); + + let x = TestTensor::<1>::from_data(random1, &device).require_grad(); + let y = TestTensor::<1>::from_data(random2, &device); + + let (x_re, x_im) = signal::rfft(x.clone(), 0, None); + let (y_re, y_im) = signal::rfft(y.clone(), 0, None); + + let loss = (x_re * y_re + x_im * y_im).sum(); + let grads = loss.backward(); + let x_grad = x.grad(&grads).unwrap(); + let prod = x_grad.mul(x.inner()).sum(); + + TensorData::assert_approx_eq::( + &prod.to_data(), + &loss.to_data(), + Tolerance::default(), + ); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([ + 0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18, 0.03, 0.74, 0.33, 0.70, 0.07, 0.61, 0.32, + 0.66, + ]); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device).require_grad(); + + let y = signal::rfft(tensor.clone() * 3.0, 0, None); + let x = signal::irfft(y.0 * 2.0, y.1 * 2.0, 0, None) / 6.0; + + let loss = x.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + + TensorData::assert_approx_eq::(&grad.to_data(), &random, Tolerance::default()); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip_with_dim_nonzero() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([ + 0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18, 0.03, 0.74, 0.33, 0.70, 0.07, 0.61, 0.32, + 0.66, + ]); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device); + let tensor = tensor.reshape([1, 1, -1, 1, 1]).require_grad(); + + let y = signal::rfft(tensor.clone() * 3.0, 2, None); + let x = signal::irfft(y.0 * 2.0, y.1 * 2.0, 2, None) / 6.0; + + let loss = x.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + let grad = grad.reshape([-1]); + + TensorData::assert_approx_eq::(&grad.to_data(), &random, Tolerance::default()); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip_with_some_n_greater() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([ + 0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18, 0.03, 0.74, 0.33, 0.70, 0.07, + ]); + let n = Some(16); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device).require_grad(); + + let y = signal::rfft(tensor.clone() * 3.0, 0, n); + let x = signal::irfft(y.0 * 2.0, y.1 * 2.0, 0, n) / 6.0; + + let loss = x.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + + TensorData::assert_approx_eq::(&grad.to_data(), &random, Tolerance::default()); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip_with_some_n_less() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([ + 0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18, 0.03, 0.74, 0.33, 0.70, 0.07, 0.61, 0.32, + 0.66, 0.16, 0.05, 0.69, + ]); + let n = Some(16); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device).require_grad(); + + let y = signal::rfft(tensor.clone() * 3.0, 0, n); + let x = signal::irfft(y.0 * 2.0, y.1 * 2.0, 0, n) / 6.0; + + let loss = x.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + + let lhs = grad.slice_dim(0, 0..16); + let rhs = tensor.slice_dim(0, 0..16); + + TensorData::assert_approx_eq::(&lhs.to_data(), &rhs.to_data(), Tolerance::default()); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip_inverse_with_some_n_greater() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12]); + let n = Some(16); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device).require_grad(); + + let x = signal::irfft(tensor.clone() * 2.0, tensor.zeros_like(), 0, n) / 6.0; + let (y, _) = signal::rfft(x * 3.0, 0, n); + + let loss = y.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + + TensorData::assert_approx_eq::(&grad.to_data(), &random, Tolerance::default()); +} + +#[test] +#[cfg(not(feature = "ndarray"))] +fn round_trip_inverse_with_some_n_less() { + if !matches!(FloatElem::dtype(), DType::F32 | DType::F64) { + return; + } + + let device = AutodiffDevice::new(); + + let random = TensorData::from([ + 0.26, 0.13, 0.36, 0.24, 0.40, 0.93, 0.12, 0.18, 0.03, 0.74, 0.33, 0.70, + ]); + let n = Some(16); + + let tensor = TestTensor::<1>::from_data(random.clone(), &device).require_grad(); + + let x = signal::irfft(tensor.clone() * 2.0, tensor.zeros_like(), 0, n) / 6.0; + let (y, _) = signal::rfft(x * 3.0, 0, n); + + let loss = y.powi_scalar(2).sum() * 0.5; + let grads = loss.backward(); + let grad = tensor.grad(&grads).unwrap(); + + let lhs = grad.slice_dim(0, 0..9); + let rhs = tensor.slice_dim(0, 0..9); + + TensorData::assert_approx_eq::(&lhs.to_data(), &rhs.to_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/round.rs b/crates/burn-backend-tests/tests/autodiff/round.rs new file mode 100644 index 0000000..b138697 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/round.rs @@ -0,0 +1,20 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_round() { + let data = TensorData::from([ + [-1.9751, 0.0714, 0.0643, 0.2406], + [-1.3172, 0.1252, -0.1119, -0.0127], + ]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data.clone(), &device).require_grad(); + let tensor_2 = tensor_1.clone().round(); + let grads = tensor_2.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + grad_1.to_data().assert_eq( + &TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/select.rs b/crates/burn-backend-tests/tests/autodiff/select.rs new file mode 100644 index 0000000..0ed9abe --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/select.rs @@ -0,0 +1,87 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn test_select_grad() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data( + TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + &device, + ) + .require_grad(); + let indices = TestTensorInt::<1>::from_data(TensorData::from([1, 0]), &device); + + let tensor_2 = tensor_1.clone().matmul(tensor_1.clone().transpose()); + let tensor_3 = tensor_1.clone().select(0, indices); + let tensor_4 = tensor_2.matmul(tensor_3); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + + grad_1.into_data().assert_eq( + &TensorData::from([[109., 148., 187.], [37., 58., 79.]]), + false, + ); +} + +#[test] +fn test_select_add_grad() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data( + TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + &device, + ) + .require_grad(); + let values = TestTensor::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ) + .require_grad(); + let indices = TestTensorInt::<1>::from_data(TensorData::from([1, 0]), &device); + + let tensor_2 = tensor_1.clone().matmul(tensor_1.clone().transpose()); + let tensor_3 = + tensor_1 + .clone() + .select_assign(0, indices, values.clone(), IndexingUpdateOp::Add); + let tensor_4 = tensor_2.matmul(tensor_3); + + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = values.grad(&grads).unwrap(); + + grad_1.into_data().assert_eq( + &TensorData::from([[127., 199., 271.], [172., 244., 316.]]), + false, + ); + grad_2 + .into_data() + .assert_eq(&TensorData::from([[64., 64., 64.], [19., 19., 19.]]), false); +} + +#[test] +fn test_select_add_grad_different_shapes() { + let device = AutodiffDevice::new(); + + let indices = TestTensorInt::from_ints([1], &device); + let x = TestTensor::<2>::ones([1, 1], &device).require_grad(); + let y = TestTensor::ones([2, 1], &device).require_grad(); + + let w = y + .clone() + .select_assign(0, indices, x.clone(), IndexingUpdateOp::Add); + let w = w.matmul(y.clone().transpose()); + + let grads = w.backward(); + let x_grad = x.grad(&grads).unwrap(); + let y_grad = y.grad(&grads).unwrap(); + + x_grad + .into_data() + .assert_eq(&TensorData::from([[2.0]]), false); + y_grad + .into_data() + .assert_eq(&TensorData::from([[5.0], [5.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/sigmoid.rs b/crates/burn-backend-tests/tests/autodiff/sigmoid.rs new file mode 100644 index 0000000..5ac1fc3 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/sigmoid.rs @@ -0,0 +1,35 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn should_diff_sigmoid() { + let data = TensorData::from([0.8762]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data(data, &device).require_grad(); + let tensor_2 = activation::sigmoid(tensor_1.clone()); + let grads = tensor_2.backward(); + + let grad = tensor_1.grad(&grads).unwrap(); + + let expected = TensorData::from([0.207549]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn small_neg_val_should_not_cause_grad_overflow() { + let data = TensorData::from([-90.0]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data(data, &device).require_grad(); + let tensor_2 = activation::sigmoid(tensor_1.clone()); + let grads = tensor_2.backward(); + + let grad = tensor_1.grad(&grads).unwrap(); + + let expected = TensorData::from([0.0]); + grad.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/sign.rs b/crates/burn-backend-tests/tests/autodiff/sign.rs new file mode 100644 index 0000000..48d7132 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/sign.rs @@ -0,0 +1,42 @@ +use super::*; +use burn_tensor::TensorData; + +/// Example using the sign function with PyTorch: +// >>> import torch +// >>> # Create a tensor with requires_grad=True +// >>> x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0], requires_grad=True) +// >>> # Forward pass: Apply the sign function +// >>> y = torch.sign(x) +// >>> print("Forward pass:") +// Forward pass: +// >>> print("x:", x) +// x: tensor([-2., -1., 0., 1., 2.], requires_grad=True) +// >>> print("y:", y) +// y: tensor([-1., -1., 0., 1., 1.], grad_fn=) +// >>> # Compute the loss (just an example) +// >>> loss = y.sum() +// >>> # Backward pass: Compute the gradients +// >>> loss.backward() +// >>> print("\nBackward pass:") +// Backward pass: +// >>> print("x.grad:", x.grad) +// x.grad: tensor([0., 0., 0., 0., 0.]) + +#[test] +fn should_diff_sign() { + let data = TensorData::from([-2.0, -1.0, 0.0, 1.0, 2.0]); + + let device = AutodiffDevice::new(); + let x = TestTensor::<1>::from_data(data, &device).require_grad(); + + let y = x.clone().sign(); + + let loss = y.clone().sum(); + let grads = loss.backward(); + let grad = x.grad(&grads).unwrap(); + + y.to_data() + .assert_eq(&TensorData::from([-1., -1., 0., 1., 1.]), false); + grad.to_data() + .assert_eq(&TensorData::from([0., 0., 0., 0., 0.]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/slice.rs b/crates/burn-backend-tests/tests/autodiff/slice.rs new file mode 100644 index 0000000..9e6141e --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/slice.rs @@ -0,0 +1,67 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_matmul_with_slice() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0, 100.0], [2.0, 3.0, 15.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_2.clone().slice([0..2, 0..2]); + let tensor_4 = tensor_1.clone().matmul(tensor_3); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[11.0, 5.0], [11.0, 5.0]]), false); + grad_2.to_data().assert_eq( + &TensorData::from([[3.0, 3.0, 0.0], [10.0, 10.0, 0.0]]), + false, + ); +} + +#[test] +fn should_diff_matmul_with_slice_stepped() { + use burn_tensor::s; + let data_1 = TensorData::from([[1.0, 7.0], [100.0, 100.0], [2.0, 3.0], [100.0, 100.0]]); + let data_2 = TensorData::from([[4.0, 100.0, 7.0, 100.0], [2.0, 100.0, 3.0, 15.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().slice(s![0..;2, 0..2]); // [[1., 7.], [2., 3.]] + let tensor_4 = tensor_2.clone().slice(s![0..2, 0..;2]); // [[4., 7.], [2., 3.]] + let tensor_5 = tensor_3.clone().matmul(tensor_4); + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_eq( + &TensorData::from([[11., 5.], [0., 0.], [11., 5.], [0., 0.]]), + false, + ); + grad_2.to_data().assert_eq( + &TensorData::from([[3., 0., 3., 0.], [10., 0., 10., 0.]]), + false, + ); +} + +#[test] +fn should_panic_on_slice_with_step() { + use burn_tensor::s; + + let data = TensorData::from([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]); + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data(data, &device).require_grad(); + + // This should panic because step is 2 + let _sliced = tensor.slice(s![.., 0..4; 2]); +} diff --git a/crates/burn-backend-tests/tests/autodiff/slice_assign.rs b/crates/burn-backend-tests/tests/autodiff/slice_assign.rs new file mode 100644 index 0000000..bf47e7e --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/slice_assign.rs @@ -0,0 +1,163 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_matmul_with_slice_assign() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_assigned = TensorData::from([[9.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_assigned = TestTensor::from_data(data_assigned, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_3.slice_assign([0..1, 0..1], tensor_assigned); + let tensor_5 = tensor_4.matmul(tensor_1.clone()); + + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[58.0, 38.0], [118.0, 82.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[16.0, 15.0], [24.0, 50.0]]), false); +} + +#[test] +fn should_diff_matmul_with_slice_assign_complex() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[9.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_5 = tensor_2.clone().slice([0..1, 0..1]); + let tensor_6 = tensor_5.mul(tensor_3.clone()); + let tensor_7 = tensor_4.slice_assign([0..1, 0..1], tensor_6); + let tensor_8 = tensor_7.matmul(tensor_1.clone()); + + let grads = tensor_8.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + let grad_3 = tensor_3.grad(&grads).unwrap(); + + grad_3 + .to_data() + .assert_eq(&TensorData::from([[32.0]]), false); + grad_1 + .to_data() + .assert_eq(&TensorData::from([[85.0, 65.0], [118.0, 82.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[88.0, 15.0], [24.0, 50.0]]), false); +} + +#[test] +fn slice_assign_diff_should_give_same_results_as_cat() { + let data_1 = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[5.0, 6.0], [7.0, 8.0]]); + let data_3 = TensorData::from([[14.0, 97.0, 100.0, 9.0], [2.0, 3.0, 15.0, 7.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device); + + let slice_assign_output = TestTensor::zeros([2, 4], &AutodiffDevice::new()); + let slice_assign_output = slice_assign_output.slice_assign([0..2, 0..2], tensor_1.clone()); + let slice_assign_output = slice_assign_output.slice_assign([0..2, 2..4], tensor_2.clone()); + let slice_assign_output = slice_assign_output / tensor_3.clone(); + + let cat_output = TestTensor::cat(vec![tensor_1.clone(), tensor_2.clone()], 1); + let cat_output = cat_output / tensor_3; + + slice_assign_output + .to_data() + .assert_approx_eq::(&cat_output.to_data(), Tolerance::default()); + + let slice_assign_grads = slice_assign_output.backward(); + let cat_grads = cat_output.backward(); + + let slice_assign_grad_1 = tensor_1.grad(&slice_assign_grads).unwrap(); + let slice_assign_grad_2 = tensor_2.grad(&slice_assign_grads).unwrap(); + let cat_grad_1 = tensor_1.grad(&cat_grads).unwrap(); + let cat_grad_2 = tensor_2.grad(&cat_grads).unwrap(); + + slice_assign_grad_1 + .to_data() + .assert_approx_eq::(&cat_grad_1.to_data(), Tolerance::default()); + slice_assign_grad_2 + .to_data() + .assert_approx_eq::(&cat_grad_2.to_data(), Tolerance::default()); +} + +#[test] +fn should_diff_slice_assign_with_step() { + use burn_tensor::s; + let data = TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]); + let value_data = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data(data, &device).require_grad(); + let value = TestTensor::<2>::from_data(value_data, &device).require_grad(); + + // Assign with step=2 + let result = tensor.clone().slice_assign(s![.., 0..4; 2], value.clone()); + let result = result * 2.0; // Scale to create gradients + let grads = result.backward(); + + let grad_tensor = tensor.grad(&grads).unwrap(); + let grad_value = value.grad(&grads).unwrap(); + + // The gradient for tensor should be 2.0 everywhere except the assigned positions + grad_tensor.to_data().assert_eq( + &TensorData::from([[0.0, 2.0, 0.0, 2.0], [0.0, 2.0, 0.0, 2.0]]), + false, + ); + // The gradient for value should be 2.0 at all positions + grad_value + .to_data() + .assert_eq(&TensorData::from([[2.0, 2.0], [2.0, 2.0]]), false); +} + +#[test] +fn should_diff_slice_assign_with_negative_step() { + use burn_tensor::s; + + let data = TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]); + let value_data = TensorData::from([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]); + let device = AutodiffDevice::new(); + let tensor = TestTensor::<2>::from_data(data, &device).require_grad(); + let value = TestTensor::<2>::from_data(value_data, &device).require_grad(); + + // Assign with step=-1 (reverse order, all elements) + let result = tensor.clone().slice_assign(s![.., ..;-1], value.clone()); + let result = result * 2.0; // Scale to create gradients + let grads = result.backward(); + + let grad_tensor = tensor.grad(&grads).unwrap(); + let grad_value = value.grad(&grads).unwrap(); + + // The gradient for tensor should be 0 since all values were replaced + grad_tensor.to_data().assert_eq( + &TensorData::from([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]), + false, + ); + // The gradient for value should be 2.0 at all positions + grad_value.to_data().assert_eq( + &TensorData::from([[2.0, 2.0, 2.0, 2.0], [2.0, 2.0, 2.0, 2.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/softmax.rs b/crates/burn-backend-tests/tests/autodiff/softmax.rs new file mode 100644 index 0000000..7900f8b --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/softmax.rs @@ -0,0 +1,90 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_softmax_grad() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = activation::softmax(tensor_3, 1).matmul(tensor_2.clone()); + + let grads = tensor_4.backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[1.179665, 1.179661], [0.005462, 0.005463]]); + + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(0.05, 0.5)); + + let expected = TensorData::from([[0.253469, 0.286237], [0.528630, 2.931664]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(0.05, 0.05)); +} + +#[test] +fn test_log_softmax_grad() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = activation::log_softmax(tensor_3, 1).matmul(tensor_2.clone()); + + let grads = tensor_4.backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[-4.3939, -4.3939], [-12.9709, -12.9709]]); + // f16 gradients from log-softmax + matmul amplify error, so we increase the tolerance + // to account for limited precision and large representable step sizes in this range. + let tolerance = Tolerance::permissive().set_half_precision_relative(6e-2); + + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[30.5984, -47.2267], [55.9631, -56.5914]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_quiet_softmax_grad() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = activation::softmax(tensor_3, 1).matmul(tensor_2.clone()); + + let grads = tensor_4.backward(); + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[1.179665, 1.179661], [0.005462, 0.005463]]); + + // Precision is quite bad yet on softmax grad especially with half precision. + let tolerance = Tolerance::rel_abs(0.5, 0.2); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[0.253469, 0.286237], [0.528630, 2.931664]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/sort.rs b/crates/burn-backend-tests/tests/autodiff/sort.rs new file mode 100644 index 0000000..eb22eaf --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/sort.rs @@ -0,0 +1,76 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_sort() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 7.0], [-2.0, -3.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[4.0, -7.0], [2.0, 3.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.sort(1)); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[35.0, 35.0], [-1.0, -8.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[11.0, 7.0], [55.0, 16.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_sort_with_indices() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 7.0], [-2.0, -3.0]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[4.0, -7.0], [2.0, 3.0]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let (values, _indices) = tensor_3.sort_with_indices(1); + let tensor_4 = tensor_1.clone().mul(values); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[35.0, 35.0], [-1.0, -8.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[11.0, 7.0], [55.0, 16.0]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_diff_sort_3d_dim1() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0], [-2.0, -3.0]]], &device).require_grad(); + let tensor_2 = TestTensor::from_data([[[4.0, -7.0], [2.0, 3.0]]], &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone()); + let tensor_4 = tensor_1.clone().mul(tensor_3.sort(1)); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let expected = TensorData::from([[[-1., -8.], [-27., 37.]]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[[-4., -17.], [-17., -42.]]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/autodiff/sqrt.rs b/crates/burn-backend-tests/tests/autodiff/sqrt.rs new file mode 100644 index 0000000..9d5ecd7 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/sqrt.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_sqrt() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().sqrt()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + let expected = TensorData::from([[82.112640, 99.083275], [82.112640, 99.083275]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[30.309311, 33.120457], [34.581974, 38.769463]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/autodiff/sub.rs b/crates/burn-backend-tests/tests/autodiff/sub.rs new file mode 100644 index 0000000..5ddddd7 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/sub.rs @@ -0,0 +1,73 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_diff_sub() { + let data_1 = TensorData::from([2.0, 5.0]); + let data_2 = TensorData::from([4.0, 1.0]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<1>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().sub(tensor_2.clone()); + let grads = tensor_3.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([-1.0, -1.0]), false); + + tensor_3 + .into_data() + .assert_eq(&TensorData::from([-2.0, 4.0]), false); +} + +#[test] +fn should_diff_sub_scalar() { + let data = TensorData::from([2.0, 10.0]); + let tensor = TestTensor::<1>::from_data(data, &AutodiffDevice::new()).require_grad(); + let tensor_out = tensor.clone().sub_scalar(5.0); + let grads = tensor_out.backward(); + + let grad = tensor.grad(&grads).unwrap(); + + grad.to_data() + .assert_eq(&TensorData::from([1.0, 1.0]), false); + tensor_out + .into_data() + .assert_eq(&TensorData::from([-3.0, 5.0]), false); +} + +#[test] +fn test_sub_complex_1() { + let data_1 = TensorData::from([[1.0, 7.0], [13.0, -3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + let data_3 = TensorData::from([[2.0, 2.0], [2.0, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1.clone().sub(tensor_2.clone()); + let tensor_5 = tensor_4.sub(tensor_3).sub_scalar(5.0); + let tensor_6 = tensor_1.clone().sub(tensor_5); + + let grads = tensor_6.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1 + .to_data() + .assert_eq(&TensorData::from([[0.0, 0.0], [0.0, 0.0]]), false); + grad_2 + .to_data() + .assert_eq(&TensorData::from([[1.0, 1.0], [1.0, 1.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff/transpose.rs b/crates/burn-backend-tests/tests/autodiff/transpose.rs new file mode 100644 index 0000000..bedbbae --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/transpose.rs @@ -0,0 +1,60 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_transpose() { + let data_1 = TensorData::from([[1.0, 7.0], [2.0, 3.0]]); + let data_2 = TensorData::from([[4.0, 7.0], [2.0, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().transpose()); + let tensor_4 = tensor_3.transpose(); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[6.0, 10.0], [6.0, 10.0]]), + Tolerance::default(), + ); + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[3.0, 10.0], [3.0, 10.0]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_swap_dims() { + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<3>::from_data( + [[[0.0, 1.0], [3.0, 4.0]], [[6.0, 7.0], [9.0, 10.0]]], + &device, + ) + .require_grad(); + let tensor_2 = TestTensor::from_data( + [[[1.0, 4.0], [2.0, 5.0]], [[7.0, 10.0], [8.0, 11.0]]], + &device, + ) + .require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().swap_dims(0, 2)); + let tensor_4 = tensor_3.matmul(tensor_2.clone().swap_dims(1, 2)); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[[66., 78.], [66., 78.]], [[270., 306.], [270., 306.]]]), + Tolerance::default(), + ); + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[[22., 286.], [28., 316.]], [[172., 652.], [190., 694.]]]), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/trig.rs b/crates/burn-backend-tests/tests/autodiff/trig.rs new file mode 100644 index 0000000..20e3d28 --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/trig.rs @@ -0,0 +1,371 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_diff_cos() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().cos()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + // Metal has less precise trigonometric functions + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[26.8063, -27.7870], [26.8063, -27.7870]]), + tolerance, + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[9.222064, -39.123375], [-28.721354, 49.748356]]), + tolerance, + ); +} + +#[test] +fn should_diff_sin() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().sin()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + // Metal has less precise trigonometric functions + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + let expected = TensorData::from([[8.8500, -4.9790], [8.8500, -4.9790]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[38.668987, 44.194775], [-59.97261, -80.46094]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_diff_tanh() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[6.0, 7.0], [9.0, 10.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().tanh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + let tolerance = Tolerance::default().set_half_precision_relative(8e-3); + let expected = TensorData::from([[32.0, 32.0], [32.0, 32.0]]); + grad_1 + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[8.00092, 8.000153], [8.000003, 7.999995]]); + grad_2 + .to_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_diff_cosh() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [1.5, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().cosh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[7.092221, 16.696301], [7.092221, 16.696301]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[17.489855, 27.484539], [39.409813, 86.910278]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_sinh() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [1.5, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().sinh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[4.894847, 15.887931], [4.894847, 15.887931]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[17.284000, 28.412029], [39.302979, 87.498329]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_tan() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [0.3, 0.8]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().tan()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[2.532602, 1.596607], [2.532602, 1.596607]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[9.028598, 14.489801], [18.038082, 21.151270]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_asin() { + let data_1 = TensorData::from([[0.0, 0.1], [0.3, 0.4]]); + let data_2 = TensorData::from([[0.2, 0.3], [0.5, 0.6]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().asin()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[0.435841, 0.969651], [0.435841, 0.969651]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[0.475300, 0.668141], [0.701834, 1.100658]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_acos() { + let data_1 = TensorData::from([[0.0, 0.1], [0.3, 0.4]]); + let data_2 = TensorData::from([[0.2, 0.3], [0.5, 0.6]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().acos()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[2.077433, 1.543624], [2.077433, 1.543624]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[0.781337, 0.588496], [0.554804, 0.155979]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_atan() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [1.5, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().atan()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[3.444365, 5.349211], [3.444365, 5.349211]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[9.904911, 11.554912], [10.199631, 11.391938]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_asinh() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [1.5, 2.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().asinh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[3.806625, 6.844869], [3.806625, 6.844869]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[11.442373, 14.842072], [14.022551, 17.688538]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_acosh() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[1.5, 2.0], [2.5, 3.0]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().acosh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[10.611752, 15.178907], [10.611752, 15.178907]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[20.112753, 20.247547], [20.402235, 22.487328]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_atanh() { + let data_1 = TensorData::from([[0.0, 0.1], [0.3, 0.4]]); + let data_2 = TensorData::from([[0.2, 0.3], [0.5, 0.6]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + + let tensor_3 = tensor_1.clone().matmul(tensor_2.clone().atanh()); + let tensor_4 = tensor_3.matmul(tensor_2.clone()); + let grads = tensor_4.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[0.441838, 1.037115], [0.441838, 1.037115]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[0.491723, 0.698110], [0.772763, 1.298805]]), + Tolerance::default(), + ); +} + +#[test] +fn should_diff_atan2() { + let data_1 = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + let data_2 = TensorData::from([[0.5, 1.0], [1.5, 2.0]]); + let data_3 = TensorData::from([[1.0, 0.5], [2.0, 1.5]]); + + let device = AutodiffDevice::new(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device).require_grad(); + let tensor_2 = TestTensor::from_data(data_2, &device).require_grad(); + let tensor_3 = TestTensor::from_data(data_3, &device).require_grad(); + + let tensor_4 = tensor_1 + .clone() + .matmul(tensor_2.clone().atan2(tensor_3.clone())); + let tensor_5 = tensor_4.matmul(tensor_2.clone()); + let grads = tensor_5.backward(); + + let grad_1 = tensor_1.grad(&grads).unwrap(); + let grad_2 = tensor_2.grad(&grads).unwrap(); + let grad_3 = tensor_3.grad(&grads).unwrap(); + + grad_1.to_data().assert_approx_eq::( + &TensorData::from([[4.570492, 4.210785], [4.570492, 4.210785]]), + Tolerance::default(), + ); + + grad_2.to_data().assert_approx_eq::( + &TensorData::from([[8.208448, 8.808449], [10.357923, 12.157923]]), + Tolerance::default(), + ); + + grad_3.to_data().assert_approx_eq::( + &TensorData::from([[-1.8, -8.4], [-1.8, -5.6]]), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/autodiff/unfold.rs b/crates/burn-backend-tests/tests/autodiff/unfold.rs new file mode 100644 index 0000000..674ad4a --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff/unfold.rs @@ -0,0 +1,18 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn unfold_backward_accumulates_overlaps() { + let device = AutodiffDevice::new(); + let x = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0]], &device).require_grad(); + + let y = x.clone().unfold::<3, _>(1, 2, 1); + let loss = y.sum(); + + let grads = loss.backward(); + let grad_x = x.grad(&grads).unwrap(); + + grad_x + .to_data() + .assert_eq(&TensorData::from([[1., 2., 2., 1.]]), false); +} diff --git a/crates/burn-backend-tests/tests/autodiff_f16.rs b/crates/burn-backend-tests/tests/autodiff_f16.rs new file mode 100644 index 0000000..ade3aef --- /dev/null +++ b/crates/burn-backend-tests/tests/autodiff_f16.rs @@ -0,0 +1,23 @@ +//! Burn autodiff tests. + +#![recursion_limit = "256"] +#![cfg(any( + feature = "vulkan", + feature = "cuda", + feature = "rocm", + feature = "metal" +))] + +extern crate alloc; + +pub type FloatElem = burn_tensor::f16; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +#[allow(clippy::module_inception)] +#[path = "common/autodiff.rs"] +mod autodiff; diff --git a/crates/burn-backend-tests/tests/common/autodiff.rs b/crates/burn-backend-tests/tests/common/autodiff.rs new file mode 100644 index 0000000..487243a --- /dev/null +++ b/crates/burn-backend-tests/tests/common/autodiff.rs @@ -0,0 +1,34 @@ +// Burn autodiff tests, reusable with element types. + +pub use super::*; + +// Autodiff-enabled device used for tests. +pub struct AutodiffDevice; + +impl AutodiffDevice { + #[allow(clippy::new_ret_no_self)] + pub fn new() -> burn_tensor::Device { + burn_tensor::Device::default().autodiff() + } +} + +#[path = "../autodiff/mod.rs"] +mod base; + +mod checkpointing { + pub use super::FloatElem; + + // Override autodiff device + pub struct AutodiffDevice; + + impl AutodiffDevice { + #[allow(clippy::new_ret_no_self)] + pub fn new() -> burn_tensor::Device { + burn_tensor::Device::default() + .autodiff() + .gradient_checkpointing() + } + } + + include!("../autodiff/mod.rs"); +} diff --git a/crates/burn-backend-tests/tests/common/backend.rs b/crates/burn-backend-tests/tests/common/backend.rs new file mode 100644 index 0000000..10f1a8b --- /dev/null +++ b/crates/burn-backend-tests/tests/common/backend.rs @@ -0,0 +1,31 @@ +use burn_tensor::Element; +use ctor::ctor; + +// Re-export +use super::{FloatElem, IntElem}; + +#[ctor] +fn init_device_settings() { + let mut device = burn_tensor::Device::default(); + device + .configure( + burn_tensor::DeviceConfig::default() + .float_dtype(::dtype()) + .int_dtype(::dtype()), + ) + .unwrap(); +} + +/// Collection of types used across tests +#[allow(unused)] +pub mod prelude { + pub use burn_tensor::Tensor; + + use super::*; + pub type TestTensor = Tensor; + pub type TestTensorInt = Tensor; + pub type TestTensorBool = Tensor; +} + +#[allow(unused)] +pub use prelude::*; diff --git a/crates/burn-backend-tests/tests/common/tensor.rs b/crates/burn-backend-tests/tests/common/tensor.rs new file mode 100644 index 0000000..858a15b --- /dev/null +++ b/crates/burn-backend-tests/tests/common/tensor.rs @@ -0,0 +1,26 @@ +// Burn backend tensor tests, reusable with element types. + +pub use super::*; + +#[path = "../tensor/clone_invariance.rs"] +mod clone_invariance; + +#[cfg(feature = "std")] +#[path = "../tensor/multi_threads.rs"] +mod multi_threads; + +#[cfg(feature = "distributed")] +#[path = "../tensor/distributed.rs"] +mod distributed; + +// Default float dtype +#[path = "../tensor/float/mod.rs"] +mod float; + +// Default integer dtype +#[path = "../tensor/int/mod.rs"] +mod int; + +// Default bool dtype +#[path = "../tensor/bool/mod.rs"] +mod bool; diff --git a/crates/burn-backend-tests/tests/cubecl.rs b/crates/burn-backend-tests/tests/cubecl.rs new file mode 100644 index 0000000..92112be --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl.rs @@ -0,0 +1,25 @@ +//! CubeCL kernel tests. +#![cfg(feature = "cube")] +#![recursion_limit = "256"] + +#[path = "."] +mod cube { + type FloatElem = f32; + type IntElem = i32; + + mod backend { + include!("common/backend.rs"); + + pub struct ReferenceDevice; + + impl ReferenceDevice { + pub fn new() -> burn_tensor::Device { + burn_ndarray::NdArrayDevice::Cpu.into() + } + } + } + pub use backend::*; + + #[path = "cubecl/mod.rs"] + mod kernel; +} diff --git a/crates/burn-backend-tests/tests/cubecl/avg_pool2d.rs b/crates/burn-backend-tests/tests/cubecl/avg_pool2d.rs new file mode 100644 index 0000000..bf3c7f3 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/avg_pool2d.rs @@ -0,0 +1,87 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Device, Distribution, module}; + +#[test] +fn avg_pool2d_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<4>::random([32, 32, 32, 32], Distribution::Default, &device); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + let kernel_size = [3, 4]; + let stride = [1, 2]; + let padding = [1, 2]; + let count_include_pad = true; + + let pooled = module::avg_pool2d( + tensor, + kernel_size, + stride, + padding, + count_include_pad, + false, + ); + let pooled_ref = module::avg_pool2d( + tensor_ref, + kernel_size, + stride, + padding, + count_include_pad, + false, + ); + + pooled + .into_data() + .assert_approx_eq::(&pooled_ref.into_data(), Tolerance::default()); +} + +#[test] +fn avg_pool2d_backward_should_match_reference_backend() { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + ref_device.seed(0); + + let tensor = TestTensor::<4>::random([32, 32, 32, 32], Distribution::Default, &device); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + let kernel_size = [3, 3]; + let stride = [1, 1]; + let padding = [1, 1]; + let count_include_pad = true; + + let shape_out = module::avg_pool2d( + tensor.clone(), + kernel_size, + stride, + padding, + count_include_pad, + false, + ) + .shape(); + let grad_output = TestTensor::<4>::random(shape_out, Distribution::Default, &device); + let grad_output_ref = TestTensor::<4>::from_data(grad_output.to_data(), &ref_device); + + let grad = module::avg_pool2d_backward( + tensor, + grad_output, + kernel_size, + stride, + padding, + count_include_pad, + false, + ); + let grad_ref = module::avg_pool2d_backward( + tensor_ref, + grad_output_ref, + kernel_size, + stride, + padding, + count_include_pad, + false, + ); + + grad.into_data() + .assert_approx_eq::(&grad_ref.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/bernoulli.rs b/crates/burn-backend-tests/tests/cubecl/bernoulli.rs new file mode 100644 index 0000000..d70ee2d --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/bernoulli.rs @@ -0,0 +1,43 @@ +use super::*; + +use serial_test::serial; + +use core::f32; + +use burn_tensor::{Device, Distribution, Shape}; + +use cubek::random::{assert_number_of_1_proportional_to_prob, assert_wald_wolfowitz_runs_test}; + +#[test] +#[serial] +fn number_of_1_proportional_to_prob() { + let device = Device::default(); + device.seed(0); + + let shape: Shape = [40, 40].into(); + let prob = 0.7; + + let tensor = + TestTensor::<2>::random(shape.clone(), Distribution::Bernoulli(prob), &device).into_data(); + + let numbers = tensor.as_slice::().unwrap(); + + assert_number_of_1_proportional_to_prob(numbers, prob as f32); +} + +#[test] +#[serial] +fn wald_wolfowitz_runs_test() { + let device = Device::default(); + device.seed(0); + + let shape = Shape::new([512, 512]); + let device = Device::default(); + let tensor = TestTensor::<2>::random(shape, Distribution::Bernoulli(0.5), &device); + + let data = tensor.into_data(); + let numbers = data.as_slice::().unwrap(); + + // High bound slightly over 1 so 1.0 is included in second bin + assert_wald_wolfowitz_runs_test(numbers, 0., 1.1); +} diff --git a/crates/burn-backend-tests/tests/cubecl/cast.rs b/crates/burn-backend-tests/tests/cubecl/cast.rs new file mode 100644 index 0000000..a36b0c0 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/cast.rs @@ -0,0 +1,45 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_cast_int_to_float() { + const START: usize = 0; + const END: usize = 100; + + let device = Default::default(); + let tensor = TestTensorInt::arange(START as i64..END as i64, &device); + + let data_int = tensor.to_data(); + let data_int = data_int.as_slice::().unwrap(); + let data_float = tensor.float().into_data(); + let data_float = data_float.as_slice::().unwrap(); + + for i in START..END { + assert_eq!(data_int[i], i as i32); + assert_eq!(data_float[i], i as f32); + } +} + +#[test] +fn should_cast_bool_to_int() { + let device = Default::default(); + + let tensor_1 = TestTensor::<2>::from_data([[1., 0., 3.], [0., 0., 900.]], &device); + let tensor_2: TestTensorInt<2> = tensor_1.clone().greater_elem(0.0).int(); + + tensor_2 + .to_data() + .assert_eq(&TensorData::from([[1, 0, 1], [0, 0, 1]]), false); +} + +#[test] +fn should_cast_bool_to_float() { + let device = Default::default(); + + let tensor_1 = TestTensor::<2>::from_data([[1., 0., 3.], [0., 0., 900.]], &device); + let tensor_2: TestTensor<2> = tensor_1.clone().greater_elem(0.0).float(); + + tensor_2 + .to_data() + .assert_eq(&TensorData::from([[1., 0., 1.], [0., 0., 1.]]), false); +} diff --git a/crates/burn-backend-tests/tests/cubecl/cat.rs b/crates/burn-backend-tests/tests/cubecl/cat.rs new file mode 100644 index 0000000..03897af --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/cat.rs @@ -0,0 +1,42 @@ +use super::*; +use burn_tensor::Device; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn cat_should_match_reference_backend_dim0() { + test_same_as_reference([6, 256], 2, 0); +} + +#[test] +fn cat_should_match_reference_backend_dim1() { + test_same_as_reference([6, 256], 2, 1); +} + +#[test] +fn cat_should_support_uneven_launch() { + test_same_as_reference([1, 137], 2, 0); +} + +fn test_same_as_reference(shape: [usize; 2], num_tensors: usize, dim: usize) { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + ref_device.seed(0); + + let tensors = (0..num_tensors) + .map(|_| TestTensor::<2>::random(shape, Distribution::Default, &device)) + .collect::>(); + let tensors_ref = tensors + .iter() + .map(|tensor| TestTensor::<2>::from_data(tensor.to_data(), &ref_device)) + .collect::>(); + + let tensor = TestTensor::<2>::cat(tensors, dim); + let tensor_ref = TestTensor::<2>::cat(tensors_ref, dim); + + tensor + .into_data() + .assert_approx_eq::(&tensor_ref.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/clamp.rs b/crates/burn-backend-tests/tests/cubecl/clamp.rs new file mode 100644 index 0000000..9033921 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/clamp.rs @@ -0,0 +1,19 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn clamp_should_match_reference() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<4>::random([1, 5, 32, 32], Distribution::Default, &device); + let input_ref = TestTensor::<4>::from_data(input.to_data(), &ref_device); + + let output = input.clamp(0.3, 0.7); + + output.into_data().assert_approx_eq::( + &input_ref.clamp(0.3, 0.7).into_data(), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/cubecl/contiguous.rs b/crates/burn-backend-tests/tests/cubecl/contiguous.rs new file mode 100644 index 0000000..5d4cd33 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/contiguous.rs @@ -0,0 +1,40 @@ +use super::*; +use burn_tensor::Tolerance; + +#[test] +pub fn into_contiguous_match_reference_backend_1() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + for shape in [ + [4, 4, 4, 4], + [32, 42, 24, 48], + [8, 3, 7, 4], + [1, 4, 1, 1], + [1, 32, 256, 128], + ] { + let num_elems = shape.iter().product::() as i64; + let tensor: TestTensor<4> = TestTensorInt::arange(0..num_elems, &device) + .reshape(shape) + .float(); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + + for (i, j) in get_combinations(shape.len()) { + let view = tensor.clone().swap_dims(i, j); + let view_ref = tensor_ref.clone().swap_dims(i, j); + let data = view.into_data(); + let data_ref = view_ref.into_data(); + + data_ref.assert_approx_eq::(&data, Tolerance::default()); + } + } +} + +fn get_combinations(n: usize) -> impl Iterator { + // Iterate from 0 up to n + (0..n).flat_map(move |i| { + // For each i, iterate from i + 1 up to n + // This ensures no repeats (i == j) and no duplicates (j, i) + (i + 1..n).map(move |j| (i, j)) + }) +} diff --git a/crates/burn-backend-tests/tests/cubecl/conv2d.rs b/crates/burn-backend-tests/tests/cubecl/conv2d.rs new file mode 100644 index 0000000..71664af --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/conv2d.rs @@ -0,0 +1,116 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::ops::ConvOptions; +use burn_tensor::{Distribution, module}; + +#[test] +fn conv2d_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<4>::random([6, 16, 32, 32], Distribution::Default, &device); + let weight = TestTensor::<4>::random([12, 8, 3, 3], Distribution::Default, &device); + let bias = TestTensor::<1>::random([12], Distribution::Default, &device); + + let input_ref = TestTensor::<4>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<4>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let options = ConvOptions::new([2, 3], [2, 3], [2, 3], 2); + + let output = module::conv2d(input, weight, Some(bias), options.clone()); + let output_ref = module::conv2d(input_ref, weight_ref, Some(bias_ref), options); + + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), Tolerance::default()); +} + +#[test] +fn conv2d_should_match_reference_backend_implicit() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<4>::random([4, 16, 6, 6], Distribution::Default, &device); + let weight = TestTensor::<4>::random([16, 16, 3, 3], Distribution::Default, &device); + let bias = TestTensor::<1>::random([16], Distribution::Default, &device); + + let input_ref = TestTensor::<4>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<4>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let options = ConvOptions::new([1, 1], [2, 2], [1, 1], 1); + + let output = module::conv2d(input, weight, Some(bias), options.clone()); + let output_ref = module::conv2d(input_ref, weight_ref, Some(bias_ref), options); + + let tolerance = Tolerance::default(); + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), tolerance); +} + +/// Regression test for bias loader in new implicit GEMM +#[test] +fn conv2d_should_match_reference_backend_bias_regression() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<4>::random([1, 1, 1, 1], Distribution::Default, &device); + let weight = TestTensor::<4>::random([32, 1, 3, 3], Distribution::Default, &device); + let bias = TestTensor::<1>::random([32], Distribution::Default, &device); + + let input_ref = TestTensor::<4>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<4>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + + let output = module::conv2d(input, weight, Some(bias), options.clone()).permute([0, 2, 3, 1]); + let output_ref = + module::conv2d(input_ref, weight_ref, Some(bias_ref), options).permute([0, 2, 3, 1]); + + let tolerance = Tolerance::default(); + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), tolerance); +} + +#[test] +fn conv2d_weight_backward_should_run() { + // https://github.com/tracel-ai/burn/issues/4226#issuecomment-3911335769 + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let x = TestTensor::<4>::random([1, 1, 1, 672], Distribution::Default, &device); + // let x = x.permute([0, 3, 1, 2]); + + let output_grad = TestTensor::<4>::random([1, 168, 1, 1], Distribution::Default, &device); + let weight = TestTensor::<4>::random([168, 672, 1, 1], Distribution::Default, &device); + + let x_ref = TestTensor::<4>::from_data(x.to_data(), &ref_device); + let output_grad_ref = TestTensor::<4>::from_data(output_grad.to_data(), &ref_device); + let weight_ref = TestTensor::<4>::from_data(weight.to_data(), &ref_device); + + // Input shape [672, 1] and strides [672, 672] should be valid + let output = module::conv2d_weight_backward( + x.permute([0, 3, 1, 2]), + weight, + output_grad, + options.clone(), + ); + + // Input shape [672, 1] and strides [672, 672] should be valid + let output_ref = module::conv2d_weight_backward( + x_ref.permute([0, 3, 1, 2]), + weight_ref, + output_grad_ref, + options, + ); + + let tolerance = Tolerance::default(); + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), tolerance); +} diff --git a/crates/burn-backend-tests/tests/cubecl/conv3d.rs b/crates/burn-backend-tests/tests/cubecl/conv3d.rs new file mode 100644 index 0000000..e432fc4 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/conv3d.rs @@ -0,0 +1,26 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Distribution, module}; + +#[test] +fn conv3d_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<5>::random([6, 16, 32, 32, 32], Distribution::Default, &device); + let weight = TestTensor::<5>::random([12, 8, 3, 3, 3], Distribution::Default, &device); + let bias = TestTensor::<1>::random([12], Distribution::Default, &device); + + let input_ref = TestTensor::<5>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<5>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let options = burn_tensor::ops::ConvOptions::new([2, 3, 4], [2, 3, 4], [2, 3, 4], 2); + + let output = module::conv3d(input, weight, Some(bias), options.clone()); + let output_ref = module::conv3d(input_ref, weight_ref, Some(bias_ref), options); + + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/conv_transpose2d.rs b/crates/burn-backend-tests/tests/cubecl/conv_transpose2d.rs new file mode 100644 index 0000000..c8bdee4 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/conv_transpose2d.rs @@ -0,0 +1,48 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Device, Distribution, module}; + +#[test] +fn conv_transpose2d_should_match_reference_backend() { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let height = 8; + let width = 8; + let in_channels = 8; + let out_channels = 8; + let batch_size = 32; + let kernel_size_0 = 3; + let kernel_size_1 = 3; + let options = burn_tensor::ops::ConvTransposeOptions::new([1, 1], [1, 1], [0, 0], [1, 1], 1); + + let input = Tensor::<4>::random( + [batch_size, in_channels, height, width], + Distribution::Default, + &device, + ); + let weight = Tensor::<4>::random( + [ + in_channels, + out_channels / options.groups, + kernel_size_0, + kernel_size_1, + ], + Distribution::Default, + &device, + ); + let bias = TestTensor::<1>::random([out_channels], Distribution::Default, &device); + + let input_ref = TestTensor::<4>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<4>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let output = module::conv_transpose2d(input, weight, Some(bias), options.clone()); + let output_ref = module::conv_transpose2d(input_ref, weight_ref, Some(bias_ref), options); + + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), Tolerance::rel_abs(0.01, 0.02)); +} diff --git a/crates/burn-backend-tests/tests/cubecl/conv_transpose3d.rs b/crates/burn-backend-tests/tests/cubecl/conv_transpose3d.rs new file mode 100644 index 0000000..d1abe57 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/conv_transpose3d.rs @@ -0,0 +1,52 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Device, Distribution, module}; + +#[test] +fn conv_transpose3d_should_match_reference_backend() { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let depth = 8; + let height = 8; + let width = 8; + let in_channels = 8; + let out_channels = 8; + let batch_size = 32; + let kernel_size_0 = 3; + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let options = + burn_tensor::ops::ConvTransposeOptions::new([1, 1, 1], [1, 1, 1], [0, 0, 0], [1, 1, 1], 1); + + let input = TestTensor::<5>::random( + [batch_size, in_channels, depth, height, width], + Distribution::Default, + &device, + ); + let weight = TestTensor::<5>::random( + [ + in_channels, + out_channels / options.groups, + kernel_size_0, + kernel_size_1, + kernel_size_2, + ], + Distribution::Default, + &device, + ); + let bias = TestTensor::<1>::random([out_channels], Distribution::Default, &device); + + let input_ref = TestTensor::<5>::from_data(input.to_data(), &ref_device); + let weight_ref = TestTensor::<5>::from_data(weight.to_data(), &ref_device); + let bias_ref = TestTensor::<1>::from_data(bias.to_data(), &ref_device); + + let output = module::conv_transpose3d(input, weight, Some(bias), options.clone()); + let output_ref = module::conv_transpose3d(input_ref, weight_ref, Some(bias_ref), options); + + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/cross.rs b/crates/burn-backend-tests/tests/cubecl/cross.rs new file mode 100644 index 0000000..93e4da5 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/cross.rs @@ -0,0 +1,156 @@ +use super::*; +use burn_tensor::Tolerance; + +#[test] +fn test_cross_product() { + let device = Default::default(); + // Test with well-known orthogonal vectors for clearer validation + let a = TestTensor::<2>::from_data([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], &device); + let b = TestTensor::<2>::from_data([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], &device); + + let result = a.cross(b, 1); + // For orthogonal unit vectors: + // i × j = k + // j × k = i + let expected = TestTensor::<2>::from_data([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], &device); + + // Use Tolerance for floating-point comparisons + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} + +#[test] +fn test_cross_product_zeros() { + let device = Default::default(); + // Test cross product with zero vector - should always give zero vector + let a = TestTensor::<2>::from_data([[2.0, 3.0, 4.0]], &device); + let b = TestTensor::<2>::zeros([1, 3], &device); + + let result = a.cross(b, 1); + let expected = TestTensor::<2>::zeros([1, 3], &device); + + // For zeros, we can use exact equality or a very tight tolerance + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} + +#[test] +fn test_cross_product_batch() { + let device = Default::default(); + // Test typical cross product computations in batch + let a = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let b = TestTensor::<2>::from_data([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], &device); + + let result = a.cross(b, 1); + // Cross products: + // [1,2,3] × [4,5,6] = [-3,6,-3] + // [4,5,6] × [7,8,9] = [-3,6,-3] + let expected = TestTensor::<2>::from_data([[-3.0, 6.0, -3.0], [-3.0, 6.0, -3.0]], &device); + + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} + +#[test] +#[should_panic] +fn test_cross_product_invalid_dimension() { + let device = Default::default(); + let a = TestTensor::<2>::zeros([1, 4], &device); + let b = TestTensor::<2>::zeros([1, 4], &device); + + let _ = a.cross(b, 1); +} + +#[test] +fn test_cross_product_parallel_vectors() { + let device = Default::default(); + // Test cross product of parallel vectors (should be zero) + let a = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let b = TestTensor::<2>::from_data([[2.0, 4.0, 6.0]], &device); // b = 2 * a + + let result = a.cross(b, 1); + let expected = TestTensor::<2>::zeros([1, 3], &device); + + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} + +#[test] +fn test_cross_product_3d_tensor() { + let device = Default::default(); + // Test with 3D tensor (batch of matrices) + let a = TestTensor::<3>::from_data( + [ + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + ], + &device, + ); + + let b = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + [[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + ], + &device, + ); + + let result = a.cross(b, 2); // Cross on last dimension + let expected = TestTensor::<3>::from_data( + [ + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], + [[-3.0, 6.0, -3.0], [-3.0, 6.0, -3.0]], + ], + &device, + ); + + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} + +// Test to verify that padding doesn't affect results +#[test] +fn test_cross_product_with_padding_awareness() { + let device = Default::default(); + // Create tensors that would span multiple 4-element blocks + // This tests that the padding doesn't corrupt adjacent data + let a = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], // Two vectors: [1,2,3] and [4,5,6] + ], + &device, + ); + + let b = TestTensor::<2>::from_data( + [ + [7.0, 8.0, 9.0, 10.0, 11.0, 12.0], // Two vectors: [7,8,9] and [10,11,12] + ], + &device, + ); + + // Reshape to have proper 3-element vectors in last dimension + let a_reshaped = a.reshape([2, 3]); + let b_reshaped = b.reshape([2, 3]); + + let result = a_reshaped.cross(b_reshaped, 1); + + // Expected cross products: + // [1,2,3] × [7,8,9] = [-6,12,-6] + // [4,5,6] × [10,11,12] = [-6,12,-6] + let expected = TestTensor::<2>::from_data([[-6.0, 12.0, -6.0], [-6.0, 12.0, -6.0]], &device); + + let tolerance = Tolerance::::default(); + result + .to_data() + .assert_approx_eq(&expected.to_data(), tolerance); +} diff --git a/crates/burn-backend-tests/tests/cubecl/fft.rs b/crates/burn-backend-tests/tests/cubecl/fft.rs new file mode 100644 index 0000000..f509f1c --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/fft.rs @@ -0,0 +1,712 @@ +use super::*; +use burn_tensor::signal::{cfft, irfft, rfft}; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn rfft_zeros() { + let signal = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + let (re, im) = rfft(signal, 0, None); + + let expected_re = TensorData::from([0.0, 0.0, 0.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-4)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-4)); +} + +#[test] +fn rfft_constant() { + let signal = TestTensor::<1>::from([1.0, 1.0, 1.0, 1.0]); + let (re, im) = rfft(signal, 0, None); + + let expected_re = TensorData::from([4.0, 0.0, 0.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-4)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-4)); +} + +#[test] +#[should_panic] // "RFFT requires n_fft >= 2" error is shadowed by the CallError +fn rfft_length1() { + let signal = TestTensor::<1>::from([5.0]); + let (re, im) = rfft(signal, 0, None); + + let expected_re = TensorData::from([5.0]); + let expected_im = TensorData::from([0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-4)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-4)); +} + +#[test] +fn rfft_length2() { + let signal = TestTensor::<1>::from([1.0, -1.0]); + let (re, im) = rfft(signal, 0, None); + + let expected_re = TensorData::from([0.0, 2.0]); + let expected_im = TensorData::from([0.0, 0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-4)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-4)); +} + +#[test] +fn rfft_dim1_sine_wave_produces_imaginary_spectrum() { + let signal = TestTensor::<2>::from([[0.0, 1.2071, 1.0, 0.2071, 0.0, -0.2071, -1.0, -1.2071]]); + let dim = 1; + let (spectrum_re, spectrum_im) = rfft(signal.clone(), dim, None); + let expected_re = TensorData::from([[0, 0, 0, 0, 0]]); + let expected_im = TensorData::from([[0, -4, -2, 0, 0]]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-4)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-4)); +} + +#[test] +fn rfft_dim1_cosine_wave_produces_real_spectrum() { + let signal = TestTensor::<2>::from([[1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071, 0.0, 0.7071]]); + + let (spectrum_re, spectrum_im) = rfft(signal, 1, None); + + let expected_re = TensorData::from([[0.0, 4.0, 0.0, 0.0, 0.0]]); + let expected_im = TensorData::from([[0.0, 0.0, 0.0, 0.0, 0.0]]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_dim1_2d_tensor_distinct_rows() { + let signal = TestTensor::<2>::from([ + [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071], + [0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], + ]); + + let (spectrum_re, spectrum_im) = rfft(signal, 1, None); + + let expected_re = TensorData::from([[0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]]); + + let expected_im = TensorData::from([[0.0, -4.0, 0.0, 0.0, 0.0], [0.0, 0.0, -4.0, 0.0, 0.0]]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_dim0_2d_tensor() { + let signal = TestTensor::<2>::from([ + [0.0, 0.0], + [0.7071, 1.0], + [1.0, 0.0], + [0.7071, -1.0], + [0.0, 0.0], + [-0.7071, 1.0], + [-1.0, 0.0], + [-0.7071, -1.0], + ]); + + let (spectrum_re, spectrum_im) = rfft(signal, 0, None); + + let expected_re = + TensorData::from([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]); + + let expected_im = + TensorData::from([[0.0, 0.0], [-4.0, 0.0], [0.0, -4.0], [0.0, 0.0], [0.0, 0.0]]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_dim2_3d_tensor() { + let signal = TestTensor::<3>::from([ + [ + [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071], + [0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], + ], + [ + [0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], + [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071], + ], + ]); + + let (spectrum_re, spectrum_im) = rfft(signal, 2, None); + + let expected_re = TensorData::from([ + [[0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]], + ]); + + let expected_im = TensorData::from([ + [[0.0, -4.0, 0.0, 0.0, 0.0], [0.0, 0.0, -4.0, 0.0, 0.0]], + [[0.0, 0.0, -4.0, 0.0, 0.0], [0.0, -4.0, 0.0, 0.0, 0.0]], + ]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_dim1_3d_tensor() { + let signal = TestTensor::<3>::from([ + [[1.0, 0.0], [0.0, 0.0], [-1.0, 0.0], [0.0, 0.0]], + [[0.0, 1.0], [0.0, 0.0], [0.0, -1.0], [0.0, 0.0]], + ]); + + let (spectrum_re, spectrum_im) = rfft(signal, 1, None); + + let expected_re = TensorData::from([ + [[0.0, 0.0], [2.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 2.0], [0.0, 0.0]], + ]); + + let expected_im = TensorData::from([ + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]); + + assert_eq!(spectrum_re.shape(), spectrum_im.shape()); + assert_eq!(spectrum_re.shape(), expected_re.shape); + + spectrum_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + + spectrum_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn irfft_dim1_imaginary_spectrum_produces_sine_wave() { + let spectrum_re = TestTensor::<2>::from([[0.0, 0.0, 0.0, 0.0, 0.0]]); + let spectrum_im = TestTensor::<2>::from([[0.0, -4.0, -2.0, 0.0, 0.0]]); + + let signal = irfft(spectrum_re, spectrum_im, 1, None); + + let expected = TensorData::from([[0.0, 1.2071, 1.0, 0.2071, 0.0, -0.2071, -1.0, -1.2071]]); + + signal + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-3)); +} + +#[test] +fn irfft_dim1_real_spectrum_produces_cosine_wave() { + let spectrum_re = TestTensor::<2>::from([[0.0, 4.0, 0.0, 0.0, 0.0]]); + let spectrum_im = TestTensor::<2>::from([[0.0, 0.0, 0.0, 0.0, 0.0]]); + + let signal = irfft(spectrum_re, spectrum_im, 1, None); + + let expected = TensorData::from([[1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071, 0.0, 0.7071]]); + + signal + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-3)); +} + +#[test] +fn irfft_dim1_2d_tensor_distinct_rows() { + let spectrum_re = TestTensor::<2>::from([[0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]]); + + let spectrum_im = + TestTensor::<2>::from([[0.0, -4.0, 0.0, 0.0, 0.0], [0.0, 0.0, -4.0, 0.0, 0.0]]); + + let signal = irfft(spectrum_re, spectrum_im, 1, None); + + let expected = TensorData::from([ + [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071], + [0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], + ]); + + signal + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-3)); +} + +#[test] +fn irfft_dim0_2d_tensor() { + let spectrum_re = + TestTensor::<2>::from([[0.0, 0.0], [4.0, 4.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]); + + let spectrum_im = + TestTensor::<2>::from([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]); + + let signal = irfft(spectrum_re, spectrum_im, 0, None); + + let expected = TensorData::from([ + [1.0, 1.0], + [0.7071, 0.7071], + [0.0, 0.0], + [-0.7071, -0.7071], + [-1.0, -1.0], + [-0.7071, -0.7071], + [0.0, 0.0], + [0.7071, 0.7071], + ]); + + signal + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_irfft_roundtrip_1d() { + let signal = TestTensor::<1>::from([0.0, 1.2071, 1.0, 0.2071, 0.0, -0.2071, -1.0, -1.2071]); + + let (re, im) = rfft(signal.clone(), 0, None); + let reconstructed = irfft(re, im, 0, None); + + reconstructed + .into_data() + .assert_approx_eq::(&signal.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_irfft_roundtrip_dim1_2d() { + let signal = TestTensor::<2>::from([ + [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071], + [0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], + ]); + + let (re, im) = rfft(signal.clone(), 1, None); + let reconstructed = irfft(re, im, 1, None); + + reconstructed + .into_data() + .assert_approx_eq::(&signal.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_irfft_roundtrip_dim0_2d() { + let signal = TestTensor::<2>::from([ + [1.0, 0.0], + [0.7071, 1.0], + [0.0, 0.0], + [-0.7071, -1.0], + [-1.0, 0.0], + [-0.7071, 1.0], + [0.0, 0.0], + [0.7071, -1.0], + ]); + + let (re, im) = rfft(signal.clone(), 0, None); + let reconstructed = irfft(re, im, 0, None); + + reconstructed + .into_data() + .assert_approx_eq::(&signal.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_irfft_roundtrip_dim1_3d() { + let signal = TestTensor::<3>::from([ + [ + [0.0, 1.0], + [1.0, 0.0], + [0.0, -1.0], + [-1.0, 0.0], + [0.0, 1.0], + [1.0, 0.0], + [0.0, -1.0], + [-1.0, 0.0], + ], + [ + [1.0, 0.0], + [0.7071, 1.0], + [0.0, 0.0], + [-0.7071, -1.0], + [-1.0, 0.0], + [-0.7071, 1.0], + [0.0, 0.0], + [0.7071, -1.0], + ], + ]); + + let (re, im) = rfft(signal.clone(), 1, None); + let reconstructed = irfft(re, im, 1, None); + + reconstructed + .into_data() + .assert_approx_eq::(&signal.into_data(), Tolerance::absolute(1e-3)); +} + +// ---- Padded input tests (n: Some(...)) ---- + +#[test] +fn rfft_with_n_larger_than_signal() { + // Signal of length 4, padded to n=8 + // DFT of [1,0,0,0, 0,0,0,0] = all-ones real, zero imag + let signal = TestTensor::<1>::from([1.0, 0.0, 0.0, 0.0]); + let (re, im) = rfft(signal, 0, Some(8)); + + let expected_re = TensorData::from([1.0, 1.0, 1.0, 1.0, 1.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0, 0.0, 0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn rfft_with_n_smaller_than_signal() { + // Signal of length 8, truncated to n=4 -> DFT of [1,0,0,0] + let signal = TestTensor::<1>::from([1.0, 0.0, 0.0, 0.0, 99.0, 99.0, 99.0, 99.0]); + let (re, im) = rfft(signal, 0, Some(4)); + + let expected_re = TensorData::from([1.0, 1.0, 1.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0]); + + re.into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + im.into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +#[should_panic(expected = "power of two")] +fn rfft_rejects_non_power_of_two_n() { + let signal = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + let _ = rfft(signal, 0, Some(5)); +} + +#[test] +#[should_panic(expected = "power of two")] +fn irfft_rejects_non_power_of_two_n() { + let re = TestTensor::<1>::from([1.0, 2.0, 3.0]); + let im = TestTensor::<1>::from([0.0, 0.0, 0.0]); + let _ = irfft(re, im, 0, Some(5)); +} + +#[test] +fn rfft_irfft_roundtrip_with_n() { + let signal = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + let (re, im) = rfft(signal.clone(), 0, Some(4)); + let reconstructed = irfft(re, im, 0, Some(4)); + + reconstructed + .into_data() + .assert_approx_eq::(&signal.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn irfft_with_n_different_from_natural() { + // Spectrum from length-4 signal (3 bins), reconstruct at length 8 + let signal = TestTensor::<1>::from([1.0, 0.0, 0.0, 0.0]); + let (re, im) = rfft(signal, 0, None); + let reconstructed = irfft(re, im, 0, Some(8)); + assert_eq!(reconstructed.dims(), [8]); +} + +#[test] +fn rfft_2d_with_n_padded() { + // 2D tensor, rfft along dim=1 with n=8 (signal is length 4) + let signal = TestTensor::<2>::from([[1.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0]]); + let (re, im) = rfft(signal, 1, Some(8)); + + // Output: 8/2+1=5 frequency bins + assert_eq!(re.dims(), [2, 5]); + assert_eq!(im.dims(), [2, 5]); + + // Row 0: impulse zero-padded to 8 -> all-ones real + let re_data = re.into_data(); + let re_vals = re_data.to_vec::().unwrap(); + for k in 0..5 { + assert!( + (re_vals[k] - 1.0).abs() < 1e-3, + "row0 re[{k}] should be 1.0, got {}", + re_vals[k] + ); + } +} + +// ---- cfft tests ---- + +#[test] +fn cfft_output_has_n_bins() { + // cfft should return N bins, not N/2+1 + let re = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + let im = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + let (out_re, out_im) = cfft(re, im, 0, None); + + assert_eq!(out_re.dims(), [4]); + assert_eq!(out_im.dims(), [4]); +} + +#[test] +fn cfft_pure_real_input() { + // When imaginary part is zero, cfft should produce the same result + // as extending rfft to the full spectrum + let signal = [1.0f32, 2.0, 3.0, 4.0]; + let re = TestTensor::<1>::from(signal); + let im = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + // Expected: DFT of [1,2,3,4] + // X[0] = 10, X[1] = -2+2i, X[2] = -2, X[3] = -2-2i + let expected_re = TensorData::from([10.0, -2.0, -2.0, -2.0]); + let expected_im = TensorData::from([0.0, 2.0, 0.0, -2.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn cfft_pure_imaginary_input() { + // Signal is purely imaginary: z[n] = i * [1, 2, 3, 4] + // FFT(i*x) = i*FFT(x), so result_re = -FFT(x)_im, result_im = FFT(x)_re + let re = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + let im = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + // FFT([1,2,3,4]) = [10, -2+2i, -2, -2-2i] + // i * FFT(x) = i * [10, -2+2i, -2, -2-2i] + // = [-0, -2+(-2)i, 0, 2+(-2)i] → re = [0, -2, 0, 2], im = [10, -2, -2, -2] + let expected_re = TensorData::from([0.0, -2.0, 0.0, 2.0]); + let expected_im = TensorData::from([10.0, -2.0, -2.0, -2.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn cfft_complex_exponential() { + // z[n] = exp(i * 2π * n / 4) for n=0..3, i.e. frequency bin 1 + // re = [cos(0), cos(π/2), cos(π), cos(3π/2)] = [1, 0, -1, 0] + // im = [sin(0), sin(π/2), sin(π), sin(3π/2)] = [0, 1, 0, -1] + // DFT should be: X[0]=0, X[1]=4, X[2]=0, X[3]=0 + let re = TestTensor::<1>::from([1.0, 0.0, -1.0, 0.0]); + let im = TestTensor::<1>::from([0.0, 1.0, 0.0, -1.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + let expected_re = TensorData::from([0.0, 4.0, 0.0, 0.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0, 0.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn cfft_zeros() { + let re = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + let im = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + let expected = TensorData::from([0.0, 0.0, 0.0, 0.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-4)); + cfft_im + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-4)); +} + +#[test] +fn cfft_dim1_2d_tensor() { + // Apply cfft along dim=1 on a 2D tensor + // Row 0: pure real [1, 2, 3, 4] → DFT = [10, -2+2i, -2, -2-2i] + // Row 1: complex exponential exp(i·2π·n/4) → DFT = [0, 4, 0, 0] + let re = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0], [1.0, 0.0, -1.0, 0.0]]); + let im = TestTensor::<2>::from([[0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, -1.0]]); + + let (cfft_re, cfft_im) = cfft(re, im, 1, None); + + // Output should be [2, 4] (N=4 bins per row) + assert_eq!(cfft_re.dims(), [2, 4]); + assert_eq!(cfft_im.dims(), [2, 4]); + + // Row 0: DFT of [1,2,3,4]+i*0 = [10, -2+2i, -2, -2-2i] + // Row 1: DFT of exp(i*2π*n/4) = [0, 4, 0, 0] + let expected_re = TensorData::from([[10.0, -2.0, -2.0, -2.0], [0.0, 4.0, 0.0, 0.0]]); + let expected_im = TensorData::from([[0.0, 2.0, 0.0, -2.0], [0.0, 0.0, 0.0, 0.0]]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn cfft_with_n_padding() { + // Signal length 2, padded to N=4 + // z = [1+0i, 0+0i] padded to [1+0i, 0, 0, 0] + // DFT = [1, 1, 1, 1] (all real, zero imag) + let re = TestTensor::<1>::from([1.0, 0.0]); + let im = TestTensor::<1>::from([0.0, 0.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, Some(4)); + + assert_eq!(cfft_re.dims(), [4]); + + let expected_re = TensorData::from([1.0, 1.0, 1.0, 1.0]); + let expected_im = TensorData::from([0.0, 0.0, 0.0, 0.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +#[should_panic] // "RFFT requires n_fft >= 2" error is shadowed by the CallError +fn cfft_length_1() { + // N=1: DFT of a single complex value is itself + let re = TestTensor::<1>::from([3.0]); + let im = TestTensor::<1>::from([5.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + assert_eq!(cfft_re.dims(), [1]); + cfft_re + .into_data() + .assert_approx_eq::(&TensorData::from([3.0]), Tolerance::absolute(1e-4)); + cfft_im + .into_data() + .assert_approx_eq::(&TensorData::from([5.0]), Tolerance::absolute(1e-4)); +} + +#[test] +fn cfft_length_2() { + // N=2: z = [a, b] → X[0] = a+b, X[1] = a-b + // z = [1+2i, 3+4i] + // X[0] = (1+3) + i(2+4) = 4+6i + // X[1] = (1-3) + i(2-4) = -2-2i + let re = TestTensor::<1>::from([1.0, 3.0]); + let im = TestTensor::<1>::from([2.0, 4.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + assert_eq!(cfft_re.dims(), [2]); + cfft_re + .into_data() + .assert_approx_eq::(&TensorData::from([4.0, -2.0]), Tolerance::absolute(1e-4)); + cfft_im + .into_data() + .assert_approx_eq::(&TensorData::from([6.0, -2.0]), Tolerance::absolute(1e-4)); +} + +#[test] +#[should_panic(expected = "same shape")] +fn cfft_rejects_mismatched_shapes() { + let re = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + let im = TestTensor::<1>::from([1.0, 2.0]); + let _ = cfft(re, im, 0, None); +} + +#[test] +fn cfft_dim0_2d_tensor() { + // Apply cfft along dim=0 on a 2D tensor (4 rows, 2 columns) + // Column 0: complex exponential exp(i·2π·n/4) → DFT = [0, 4, 0, 0] + // Column 1: pure real [1, 2, 3, 4] → DFT = [10, -2+2i, -2, -2-2i] + let re = TestTensor::<2>::from([[1.0, 1.0], [0.0, 2.0], [-1.0, 3.0], [0.0, 4.0]]); + let im = TestTensor::<2>::from([[0.0, 0.0], [1.0, 0.0], [0.0, 0.0], [-1.0, 0.0]]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, None); + + assert_eq!(cfft_re.dims(), [4, 2]); + assert_eq!(cfft_im.dims(), [4, 2]); + + let expected_re = TensorData::from([[0.0, 10.0], [4.0, -2.0], [0.0, -2.0], [0.0, -2.0]]); + let expected_im = TensorData::from([[0.0, 0.0], [0.0, 2.0], [0.0, 0.0], [0.0, -2.0]]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} + +#[test] +fn cfft_with_n_truncation() { + // Signal length 8, truncated to n=4 → DFT of [1+0i, 2+0i, 3+0i, 4+0i] + // Trailing values are discarded, not included in the transform. + let re = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0, 99.0, 99.0, 99.0, 99.0]); + let im = TestTensor::<1>::from([0.0, 0.0, 0.0, 0.0, 99.0, 99.0, 99.0, 99.0]); + + let (cfft_re, cfft_im) = cfft(re, im, 0, Some(4)); + + assert_eq!(cfft_re.dims(), [4]); + + // DFT of [1,2,3,4] = [10, -2+2i, -2, -2-2i] + let expected_re = TensorData::from([10.0, -2.0, -2.0, -2.0]); + let expected_im = TensorData::from([0.0, 2.0, 0.0, -2.0]); + + cfft_re + .into_data() + .assert_approx_eq::(&expected_re, Tolerance::absolute(1e-3)); + cfft_im + .into_data() + .assert_approx_eq::(&expected_im, Tolerance::absolute(1e-3)); +} diff --git a/crates/burn-backend-tests/tests/cubecl/gather.rs b/crates/burn-backend-tests/tests/cubecl/gather.rs new file mode 100644 index 0000000..a33fc32 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/gather.rs @@ -0,0 +1,43 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Device, Distribution, Shape}; + +#[test] +fn gather_should_work_with_multiple_workgroups_dim0() { + test_same_as_ref([6, 256], 0); +} + +#[test] +fn gather_should_work_with_multiple_workgroups_dim1() { + test_same_as_ref([6, 256], 1); +} + +fn test_same_as_ref(shape: [usize; D], dim: usize) { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let max = shape[dim]; + let shape = Shape::new(shape); + let tensor = TestTensor::::random(shape.clone(), Distribution::Default, &device); + let indices = TestTensorInt::<1>::from_data( + TestTensor::<1>::random( + [shape.num_elements()], + Distribution::Uniform(0., max as f64), + &device, + ) + .into_data(), + &device, + ) + .reshape(shape); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + let indices_ref = TestTensorInt::::from_data(indices.to_data(), &ref_device); + + let actual = tensor.gather(dim, indices); + let expected = tensor_ref.gather(dim, indices_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/interpolate_nearest.rs b/crates/burn-backend-tests/tests/cubecl/interpolate_nearest.rs new file mode 100644 index 0000000..7f4ee00 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/interpolate_nearest.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; +use burn_tensor::{Distribution, Tolerance, module}; + +/// Regression test for https://github.com/tracel-ai/burn/issues/4686 +/// +/// The nearest-neighbor interpolation kernel previously used f32 division +/// to map output coordinates to input coordinates. For certain spatial +/// dimensions, GPU f32 division produced values that truncated to the +/// wrong integer, selecting the wrong input pixel. +#[test] +pub fn nearest_interpolate_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + // These spatial sizes previously triggered the float precision bug. + for h in [214, 220, 227, 235, 255] { + let tensor = TestTensor::<4>::random([1, 64, h, h], Distribution::Default, &device); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + + let opts = InterpolateOptions::new(InterpolateMode::Nearest); + let out_size = [h * 2, h * 2]; + + let output = module::interpolate(tensor, out_size, opts.clone()); + let output_ref = module::interpolate(tensor_ref, out_size, opts); + + output + .into_data() + .assert_approx_eq::(&output_ref.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/cubecl/mask_fill.rs b/crates/burn-backend-tests/tests/cubecl/mask_fill.rs new file mode 100644 index 0000000..fbd6d8e --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/mask_fill.rs @@ -0,0 +1,51 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn mask_fill_should_match_reference_backend() { + let (tensor, mask, tensor_ref, mask_ref) = inputs_mask_fill(); + + // MaskFillStrategy::Readonly + let _clone_for_readonly = tensor.clone(); + + let actual = tensor.mask_fill(mask, 4.0); + let expected = tensor_ref.mask_fill(mask_ref, 4.0); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[test] +fn mask_fill_inplace_should_match_reference_backend() { + let (tensor, mask, tensor_ref, mask_ref) = inputs_mask_fill(); + + // MaskFillStrategy::Inplace + let actual = tensor.mask_fill(mask, 4.0); + let expected = tensor_ref.mask_fill(mask_ref, 4.0); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[allow(clippy::type_complexity)] +fn inputs_mask_fill() -> ( + TestTensor<3>, + TestTensorBool<3>, + TestTensor<3>, + TestTensorBool<3>, +) { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<3>::random([2, 6, 256], Distribution::Default, &device); + let mask = TestTensor::<3>::random([2, 6, 256], Distribution::Uniform(0., 1.), &device) + .lower_equal_elem(0.5); + + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + let mask_ref = TestTensorBool::<3>::from_data(mask.to_data(), &ref_device); + + (tensor, mask, tensor_ref, mask_ref) +} diff --git a/crates/burn-backend-tests/tests/cubecl/mask_where.rs b/crates/burn-backend-tests/tests/cubecl/mask_where.rs new file mode 100644 index 0000000..36b77e7 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/mask_where.rs @@ -0,0 +1,70 @@ +use super::*; +use burn_tensor::Device; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn mask_where_should_match_reference_backend() { + let (tensor, value, mask, tensor_ref, value_ref, mask_ref) = inputs_mask_where(); + + let actual = tensor.mask_where(mask, value); + let expected = tensor_ref.mask_where(mask_ref, value_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} +#[test] +fn mask_where_inplace_lhs_should_match_reference_backend() { + let (tensor, value, mask, tensor_ref, value_ref, mask_ref) = inputs_mask_where(); + + // MaskWhereStrategy::InplaceLhs + let actual = tensor.mask_where(mask, value); + let expected = tensor_ref.mask_where(mask_ref, value_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[test] +fn mask_where_inplace_rhs_should_match_reference_backend() { + let (tensor, value, mask, tensor_ref, value_ref, mask_ref) = inputs_mask_where(); + + // MaskWhereStrategy::InplaceRhs + let _clone_for_inplace_rhs = tensor.clone(); + + let actual = tensor.mask_where(mask, value); + let expected = tensor_ref.mask_where(mask_ref, value_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[allow(clippy::type_complexity)] +fn inputs_mask_where() -> ( + TestTensor<3>, + TestTensor<3>, + TestTensorBool<3>, + TestTensor<3>, + TestTensor<3>, + TestTensorBool<3>, +) { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let tensor = TestTensor::<3>::random([2, 6, 256], Distribution::Default, &device); + let value = TestTensor::<3>::random([2, 6, 256], Distribution::Default, &device); + let mask = TestTensor::<3>::random([2, 6, 256], Distribution::Uniform(0., 1.), &device) + .lower_equal_elem(0.5); + + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + let value_ref = TestTensor::<3>::from_data(value.to_data(), &ref_device); + let mask_ref = TestTensorBool::<3>::from_data(mask.to_data(), &ref_device); + mask.to_data().assert_eq(&mask_ref.to_data(), false); + + (tensor, value, mask, tensor_ref, value_ref, mask_ref) +} diff --git a/crates/burn-backend-tests/tests/cubecl/max_pool2d.rs b/crates/burn-backend-tests/tests/cubecl/max_pool2d.rs new file mode 100644 index 0000000..f0a2c0d --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/max_pool2d.rs @@ -0,0 +1,48 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Distribution, module}; + +#[test] +pub fn max_pool2d_should_match_reference_backends() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<4>::random([32, 32, 32, 32], Distribution::Default, &device); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + let kernel_size = [3, 3]; + let stride = [2, 2]; + let padding = [1, 1]; + let dilation = [1, 1]; + + let pooled = module::max_pool2d(tensor, kernel_size, stride, padding, dilation, false); + let pooled_ref = module::max_pool2d(tensor_ref, kernel_size, stride, padding, dilation, false); + + pooled + .into_data() + .assert_approx_eq::(&pooled_ref.into_data(), Tolerance::default()); +} + +#[test] +pub fn max_pool2d_with_indices_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<4>::random([32, 32, 32, 32], Distribution::Default, &device); + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + let kernel_size = [3, 3]; + let stride = [2, 2]; + let padding = [1, 1]; + let dilation = [1, 1]; + + let (pooled, indices) = + module::max_pool2d_with_indices(tensor, kernel_size, stride, padding, dilation, false); + let (pooled_ref, indices_ref) = + module::max_pool2d_with_indices(tensor_ref, kernel_size, stride, padding, dilation, false); + + pooled + .into_data() + .assert_approx_eq::(&pooled_ref.into_data(), Tolerance::default()); + indices + .into_data() + .assert_eq(&indices_ref.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/cubecl/max_pool2d_backward.rs b/crates/burn-backend-tests/tests/cubecl/max_pool2d_backward.rs new file mode 100644 index 0000000..1db27f4 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/max_pool2d_backward.rs @@ -0,0 +1,59 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Distribution, module}; + +#[test] +pub fn max_pool2d_with_indices_backward_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<4>::random([32, 32, 32, 32], Distribution::Default, &device); + let grad_output = TestTensor::<4>::random([32, 32, 16, 16], Distribution::Default, &device); + + let tensor_ref = TestTensor::<4>::from_data(tensor.to_data(), &ref_device); + let grad_output_ref = TestTensor::<4>::from_data(grad_output.to_data(), &ref_device); + let kernel_size = [3, 3]; + let stride = [2, 2]; + let padding = [1, 1]; + let dilation = [1, 1]; + + let (_, indices) = module::max_pool2d_with_indices( + tensor.clone(), + kernel_size, + stride, + padding, + dilation, + false, + ); + let (_, indices_ref) = module::max_pool2d_with_indices( + tensor_ref.clone(), + kernel_size, + stride, + padding, + dilation, + false, + ); + let grad = module::max_pool2d_with_indices_backward( + tensor, + kernel_size, + stride, + padding, + dilation, + false, + grad_output, + indices, + ); + let grad_ref = module::max_pool2d_with_indices_backward( + tensor_ref, + kernel_size, + stride, + padding, + dilation, + false, + grad_output_ref, + indices_ref, + ); + + grad.into_data() + .assert_approx_eq::(&grad_ref.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/mod.rs b/crates/burn-backend-tests/tests/cubecl/mod.rs new file mode 100644 index 0000000..f7ab622 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/mod.rs @@ -0,0 +1,32 @@ +pub use super::*; + +mod avg_pool2d; +mod bernoulli; +mod cast; +mod cat; +mod clamp; +mod contiguous; +mod conv2d; +mod conv3d; +mod conv_transpose2d; +mod conv_transpose3d; +mod cross; +mod fft; +mod gather; +mod interpolate_nearest; +mod mask_fill; +mod mask_where; +mod max_pool2d; +mod max_pool2d_backward; +mod normal; +mod quantization; +mod reduce; +mod repeat_dim; +mod scatter; +mod select; +mod select_assign; +mod slice; +mod slice_assign; +mod stft; +mod unary; +mod uniform; diff --git a/crates/burn-backend-tests/tests/cubecl/normal.rs b/crates/burn-backend-tests/tests/cubecl/normal.rs new file mode 100644 index 0000000..ab6c300 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/normal.rs @@ -0,0 +1,36 @@ +use super::*; +use burn_tensor::{Device, Distribution, Shape}; +use cubek::random::{assert_mean_approx_equal, assert_normal_respects_68_95_99_rule}; +use serial_test::serial; + +#[test] +#[serial] +fn empirical_mean_close_to_expectation() { + let device = Device::default(); + device.seed(0); + + let shape = [100, 100]; + let mean = 10.; + let tensor = + TestTensor::<2>::random(shape, Distribution::Normal(mean, 2.), &device).into_data(); + let numbers = tensor.as_slice::().unwrap(); + + assert_mean_approx_equal(numbers, mean as f32); +} + +#[test] +#[serial] +fn normal_respects_68_95_99_rule() { + // https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule + let shape: Shape = [1000, 1000].into(); + let device = Device::default(); + device.seed(0); + let mu = 0.; + let s = 1.; + let tensor = + TestTensor::<2>::random(shape.clone(), Distribution::Normal(mu, s), &device).into_data(); + + let numbers = tensor.as_slice::().unwrap(); + + assert_normal_respects_68_95_99_rule(numbers, mu as f32, s as f32); +} diff --git a/crates/burn-backend-tests/tests/cubecl/quantization/mod.rs b/crates/burn-backend-tests/tests/cubecl/quantization/mod.rs new file mode 100644 index 0000000..65ca126 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/quantization/mod.rs @@ -0,0 +1,14 @@ +pub use super::*; + +mod quantize_dequantize; +mod reshape; +mod slice; + +fn supports_native() -> bool { + let name = format!("{:?}", burn_tensor::Device::default()); + // TODO: Proper checks for i8 support. + name.contains("Cuda") + || name.contains("Rocm") + || name.contains("Vulkan") + || name.contains("Metal") +} diff --git a/crates/burn-backend-tests/tests/cubecl/quantization/quantize_dequantize.rs b/crates/burn-backend-tests/tests/cubecl/quantization/quantize_dequantize.rs new file mode 100644 index 0000000..d961934 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/quantization/quantize_dequantize.rs @@ -0,0 +1,235 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ + Shape, + quantization::{QuantLevel, QuantScheme, QuantStore, QuantValue}, +}; + +fn should_quantize_dequantize_symmetric_arange>( + value: QuantValue, + store: QuantStore, + shape: S, +) { + let shape = shape.into(); + assert_eq!(shape.rank(), 2); // 2D tests + + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let scheme = QuantScheme::default().with_value(value).with_store(store); + let scheme_ref = scheme.clone().with_store(QuantStore::Native); + + let input: TestTensor<2> = TestTensorInt::arange(0..shape.num_elements() as i64, &device) + .float() + .reshape(shape); + let input_ref = TestTensor::<2>::from_data(input.to_data(), &ref_device); + + let output = input.quantize_dynamic(&scheme); + let output_ref = input_ref.quantize_dynamic(&scheme_ref); + + output.to_data().assert_eq(&output_ref.to_data(), false); + + let output = output.dequantize(); + let output_ref = output_ref.dequantize(); + + output + .into_data() + .assert_approx_eq::(&output_ref.to_data(), Tolerance::default()); +} + +fn should_quantize_dequantize_symmetric_per_block_arange>( + value: QuantValue, + block_size: usize, + store: QuantStore, + shape: S, +) { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let scheme = QuantScheme::default() + .with_value(value) + .with_level(QuantLevel::block([block_size as u8])) + .with_store(store); + let scheme_ref = scheme.clone().with_store(QuantStore::Native); + + let shape = shape.into(); + let input: TestTensor<2> = TestTensorInt::arange(0..shape.num_elements() as i64, &device) + .float() + .reshape(shape); + let input_ref = TestTensor::<2>::from_data(input.to_data(), &ref_device); + + let output = input.quantize_dynamic(&scheme); + let output_ref = input_ref.quantize_dynamic(&scheme_ref); + + output.to_data().assert_eq(&output_ref.to_data(), false); + + let output = output.dequantize(); + let output_ref = output_ref.dequantize(); + + output + .into_data() + .assert_approx_eq::(&output_ref.to_data(), Tolerance::default()); +} + +fn should_quantize_dequantize_symmetric_per_block( + value: QuantValue, + block_size: usize, + store: QuantStore, +) { + let scheme = QuantScheme::default() + .with_value(value) + .with_level(QuantLevel::block([block_size as u8])) + .with_store(store); + let scheme_ref = scheme.clone().with_store(QuantStore::Native); + + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let input = TestTensor::<2>::from_data( + [ + [ + -1.8, -1.0, 0.0, 0.5, -1.8, -1.0, 0.0, 0.5, 0.01, 0.025, 0.03, 0.04, 0.01, 0.025, + 0.03, 0.04, + ], + [ + 1.8, 1.0, 0.0, -0.5, 1.8, 1.0, 0.0, -0.5, -0.01, -0.025, -0.03, -0.04, -0.01, + -0.025, -0.03, -0.04, + ], + ], + &device, + ); + let input_ref = TestTensor::<2>::from_data(input.to_data(), &ref_device); + + let output = input.quantize_dynamic(&scheme); + let output_ref = input_ref.quantize_dynamic(&scheme_ref); + + output.to_data().assert_eq(&output_ref.to_data(), false); + + let output = output.dequantize(); + let output_ref = output_ref.dequantize(); + + output + .into_data() + .assert_approx_eq::(&output_ref.to_data(), Tolerance::default()); +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q8s_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q8S, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q8f_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q8F, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q4s_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q4S, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q4f_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q4F, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q2s_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q2S, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q2f_packed() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q2F, QuantStore::PackedU32(0), [8, 16]) +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_q8s_packed() { + should_quantize_dequantize_symmetric_per_block(QuantValue::Q8S, 8, QuantStore::PackedU32(0)) +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_q4s_packed() { + should_quantize_dequantize_symmetric_per_block(QuantValue::Q4S, 8, QuantStore::PackedU32(0)) +} + +#[test] +#[should_panic] // "Block size must be divisible by 16" error is shadowed by the CallError +fn should_panic_when_block_size_cannot_store_num_quants() { + // num_quants in u32 = 32 bits / 2 bits = 16 + should_quantize_dequantize_symmetric_per_block(QuantValue::Q2S, 8, QuantStore::PackedU32(0)) +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_q2s_packed() { + should_quantize_dequantize_symmetric_per_block(QuantValue::Q2S, 16, QuantStore::PackedU32(0)) +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_q8s_native() { + if supports_native() { + should_quantize_dequantize_symmetric_arange(QuantValue::Q8S, QuantStore::Native, [32, 32]) + } +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_q8s_native() { + if supports_native() { + should_quantize_dequantize_symmetric_per_block(QuantValue::Q8S, 8, QuantStore::Native) + } +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_arange_q8s_packed() { + should_quantize_dequantize_symmetric_per_block_arange( + QuantValue::Q8S, + 32, + QuantStore::PackedU32(0), + [32, 32], + ) +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_arange_q8s_native() { + if supports_native() { + should_quantize_dequantize_symmetric_per_block_arange( + QuantValue::Q8S, + 32, + QuantStore::Native, + [32, 32], + ) + } +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_128x256_q8s_native() { + if supports_native() { + should_quantize_dequantize_symmetric_per_block_arange( + QuantValue::Q8S, + 32, + QuantStore::Native, + [128, 256], + ) + } +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_128x256_q8s_packed() { + should_quantize_dequantize_symmetric_per_block_arange( + QuantValue::Q8S, + 32, + QuantStore::PackedU32(0), + [128, 256], + ) +} + +#[cfg(not(feature = "fusion"))] +#[test] +#[should_panic = "Can't store in u32"] +fn should_panic_when_shape_cannot_store_quants() { + let device = Default::default(); + let scheme = QuantScheme::default(); + + let _tensor_1 = TestTensor::<2>::from_data([[1.0, 6.35], [2.0, 3.0], [1.0, 3.0]], &device) + .quantize_dynamic(&scheme); +} diff --git a/crates/burn-backend-tests/tests/cubecl/quantization/reshape.rs b/crates/burn-backend-tests/tests/cubecl/quantization/reshape.rs new file mode 100644 index 0000000..e10168d --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/quantization/reshape.rs @@ -0,0 +1,151 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ + Shape, + quantization::{BlockSize, QuantLevel, QuantScheme, QuantStore, QuantValue}, +}; + +fn should_quantize_dequantize_per_block_arange_reshaped( + level: QuantLevel, + value: QuantValue, + store: QuantStore, + shape: [usize; D1], + new_shape: [usize; D2], +) { + let numel = Shape::from(shape).num_elements() as i64; + + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let scheme = QuantScheme::default() + .with_level(level) + .with_value(value) + .with_store(store); + + let data = TestTensorInt::arange(0..numel, &ref_device) + .float() + .div_scalar(numel) + .reshape::(shape) + .into_data(); + + let input_ref = TestTensor::::from_data(data.clone(), &device).reshape::(new_shape); + let input = TestTensor::::from_data(data.clone(), &device) + .quantize_dynamic(&scheme) + .reshape::(new_shape); + + let output_ref = input_ref.into_data(); + let output = input.dequantize().into_data(); + + output.assert_approx_eq::(&output_ref, Tolerance::permissive()); +} + +#[test] +// https://github.com/tracel-ai/burn/issues/4659 +// Edge case where a single block is used, essentially like `QuantLevel::Tensor` +fn should_quantize_dequantize_per_block_reshaped_global_block_q8s_packed() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([16])), + QuantValue::Q8S, + QuantStore::PackedU32(0), + [32], + [2, 16], + ) +} + +#[test] +// FIXME: should work like tensor-level +#[should_panic] // "Reshape with sub-byte values is not supported"] error is shadowed by the CallError +fn should_quantize_dequantize_per_block_reshaped_global_block_q4s_packed() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([16])), + QuantValue::Q4S, + QuantStore::PackedU32(0), + [32], + [2, 16], + ) +} + +#[test] +// FIXME: should work +#[should_panic] // "Reshape with sub-byte values is not supported" error is shadowed by the CallError +fn should_quantize_dequantize_per_tensor_reshaped_q4s_packed() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Tensor, + QuantValue::Q4S, + QuantStore::PackedU32(0), + [32], + [2, 16], + ) +} + +#[test] +fn should_quantize_dequantize_per_block_reshaped_1d_q8s_native() { + if supports_native() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([16])), + QuantValue::Q8S, + QuantStore::Native, + [32], + [2, 16], + ) + } +} + +#[test] +fn should_quantize_dequantize_per_block_unsqueezed_q8s_packed() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([32])), + QuantValue::Q8S, + QuantStore::PackedU32(0), + [32], + [1, 1, 1, 32], + ) +} + +#[test] +#[should_panic] // "Reshape of ND block-quantized tensor is not yet supported" error is shadowed by the CallError +fn quantize_2d_block_reshape_should_panic() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([2, 4])), + QuantValue::Q8S, + QuantStore::PackedU32(0), + [4, 8], + [32], // invalid shape for 2D block boundaries + ) +} + +#[test] +#[should_panic] // "Reshape would split a block across multiple rows" error is shadowed by the CallError +fn quantize_per_block_reshaped_should_not_split_block() { + if supports_native() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([32])), + QuantValue::Q8S, + QuantStore::Native, + [2, 32], + [4, 16], + ) + } else { + // So it also panics with the same message when `QuantStore::Native` is not supported + panic!("Reshape would split a block across multiple rows") + } +} + +#[test] +#[should_panic] // "Reshape would split a block across multiple rows"] error is shadowed by the CallError +fn should_quantize_dequantize_per_block_reshaped_2d_q8s_packed() { + should_quantize_dequantize_per_block_arange_reshaped( + QuantLevel::Block(BlockSize::new([32])), + QuantValue::Q8S, + QuantStore::PackedU32(0), + [2, 32], + [4, 16], + ) +} + +// TODO: add tests for +// - ND reshape split (validation should panic) +// - broadcasted +// - multi-block successful reshape (all current success tests use exactly 1 block, e.g. [4, 32] -> [1, 4, 32] with block_size 32) +// - packed dimension alignment failure (invalid shape for packed num_quants) +// - ND-block unsqueeze (should succeed per the is_unsqueeze exemption, but nothing tests it) diff --git a/crates/burn-backend-tests/tests/cubecl/quantization/slice.rs b/crates/burn-backend-tests/tests/cubecl/quantization/slice.rs new file mode 100644 index 0000000..72b89aa --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/quantization/slice.rs @@ -0,0 +1,121 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Shape, SliceArg, quantization::QuantScheme, s}; + +/// Slice a quantized tensor (exercising `q_slice`) and compare against slicing the same +/// quantized data in full precision (dequantized) - within a permissive tolerance. +fn should_quantize_slice_dequantize_arange(shape: [usize; D], slices: S) +where + S: SliceArg + Clone, +{ + let numel = Shape::from(shape).num_elements() as i64; + + let device = Default::default(); + + // Always use the default scheme when testing `q_slice`. + let scheme = QuantScheme::default(); + + let quantized = TestTensorInt::arange(0..numel, &device) + .float() + .div_scalar(numel) + .reshape::(shape) + .quantize_dynamic(&scheme); + + // Reference: dequantize, then slice in full precision. + let output_ref = quantized + .clone() + .dequantize() + .slice(slices.clone()) + .into_data(); + + // Slice the quantized tensor (exercises `q_slice`), then dequantize. + let output = quantized.slice(slices).dequantize().into_data(); + + output.assert_approx_eq::(&output_ref, Tolerance::permissive()); +} + +// Note: the default `QuantScheme` stores `Q8F` values packed into `u32` (4 values per element), +// so the last dimension of both the input shape and the sliced output must be a multiple of 4. + +#[test] +fn should_slice_1d_full() { + should_quantize_slice_dequantize_arange([8], s![..]); +} + +#[test] +fn should_slice_1d_range() { + should_quantize_slice_dequantize_arange([8], s![2..6]); +} + +#[test] +fn should_slice_2d_rows() { + should_quantize_slice_dequantize_arange([4, 8], s![1..3, ..]); +} + +#[test] +fn should_slice_2d_cols() { + should_quantize_slice_dequantize_arange([4, 8], s![.., 0..4]); +} + +#[test] +fn should_slice_2d_both_dims() { + should_quantize_slice_dequantize_arange([4, 8], s![1..3, 4..8]); +} + +#[test] +fn should_slice_3d() { + should_quantize_slice_dequantize_arange([2, 3, 8], s![0..1, 1..3, ..]); +} + +#[test] +fn should_slice_single_index_row() { + // A single index keeps the rank, producing a dimension of size 1. + should_quantize_slice_dequantize_arange([4, 8], s![1, ..]); +} + +#[test] +fn should_slice_inclusive_range() { + should_quantize_slice_dequantize_arange([4, 8], s![1..=2, ..]); +} + +#[test] +fn should_slice_negative_index() { + // Negative start counts from the end: last two rows. + should_quantize_slice_dequantize_arange([4, 8], s![-2.., ..]); +} + +#[test] +fn should_slice_negative_range_cols() { + // Last 4 columns via a negative range. + should_quantize_slice_dequantize_arange([4, 8], s![.., -4..]); +} + +#[test] +fn should_slice_with_step_rows() { + // Every other row. + should_quantize_slice_dequantize_arange([8, 8], s![0..8;2, ..]); +} + +#[test] +fn should_slice_with_step_cols() { + // Every other column: output last dim is ceil(8 / 2) = 4. + should_quantize_slice_dequantize_arange([4, 8], s![.., 0..8;2]); +} + +#[test] +fn should_slice_reversed_rows() { + // Reverse the first dimension (negative step). + should_quantize_slice_dequantize_arange([4, 8], s![..;-1, ..]); +} + +#[test] +fn should_slice_reversed_cols() { + // Reverse the last dimension; size stays a multiple of 4. + should_quantize_slice_dequantize_arange([4, 8], s![.., ..;-1]); +} + +#[test] +fn should_slice_partial_dims() { + // Fewer slices than dims: trailing dimension is sliced fully. + should_quantize_slice_dequantize_arange([4, 8], s![1..3]); +} diff --git a/crates/burn-backend-tests/tests/cubecl/reduce.rs b/crates/burn-backend-tests/tests/cubecl/reduce.rs new file mode 100644 index 0000000..66b9149 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/reduce.rs @@ -0,0 +1,367 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Shape; +use burn_tensor::Tolerance; + +const RANK: usize = 4; +const SHAPE: [usize; RANK] = [2, 4, 8, 16]; + +#[test] +fn reduction_argmax_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + for dim in 0..RANK { + tensor + .clone() + .argmax(dim) + .into_data() + .assert_eq(&tensor_ref.clone().argmax(dim).into_data(), false); + } +} + +#[test] +fn reduction_argtopk_simple() { + let device = Default::default(); + + let tensor = TestTensor::<2>::from_data([[1, 7, 3], [8, 2, 8]], &device); + let actual = tensor.argtopk(2, 1); + let expected = TestTensor::<2>::from_data([[1, 2], [0, 2]], &device); + + let output_shape = Shape::new([2, 2]); + assert_eq!(actual.shape(), output_shape); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_argtopk_1d() { + let device = Default::default(); + + let tensor = TestTensor::<1>::from_data([10.0, 50.0, 20.0, 40.0, 30.0], &device); + let k = 3; + let actual = tensor.argtopk(k, 0); + + let expected = TestTensor::<1>::from_data([1, 3, 4], &device); + + assert_eq!(actual.shape(), Shape::new([k])); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_argtopk_3d_dim0() { + let device = Default::default(); + + // Shape [2, 2, 2] + let tensor = TestTensor::<3>::from_data( + [[[10.0, 1.0], [5.0, 20.0]], [[1.0, 10.0], [20.0, 5.0]]], + &device, + ); + let k = 1; + let actual = tensor.argtopk(k, 0); + + let expected = TestTensor::<3>::from_data([[[0, 1], [1, 0]]], &device); + + assert_eq!(actual.shape(), Shape::new([1, 2, 2])); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_argtopk_ties() { + let device = Default::default(); + + let tensor = TestTensor::<1>::from_data([5.0, 2.0, 5.0, 5.0], &device); + let k = 2; + let actual = tensor.argtopk(k, 0); + + let expected = TestTensor::<1>::from_data([0, 2], &device); + + assert_eq!(actual.shape(), Shape::new([k])); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_argtopk_3d_random_complex() { + let device = Default::default(); + + #[rustfmt::skip] + let tensor = TestTensor::<3>::from_data( + [ + [ + [0.5, 1.2, 0.8, 3.3], + [4.4, 2.1, 9.9, 0.1], + [7.7, 8.8, 6.6, 5.5], + ], + [ + [1.1, 0.2, 4.4, 2.2], + [6.0, 7.0, 5.0, 8.0], + [3.0, 3.0, 1.0, 2.0], + ], + ], + &device, + ); + + let k = 2; + let dim = 2; + let actual = tensor.argtopk(k, dim); + + #[rustfmt::skip] + let expected = TestTensor::<3>::from_data( + [ + [ + [3, 1], + [2, 0], + [1, 0], + ], + [ + [2, 3], + [3, 1], + [0, 1], + ], + ], + &device, + ); + + let output_shape = Shape::new([2, 3, 2]); + assert_eq!( + actual.shape(), + output_shape, + "Output shape should be [2, 3, 2]" + ); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_topk_simple() { + let device = Default::default(); + + let tensor = TestTensor::<2>::from_data([[1, 7, 3], [8, 2, 8]], &device); + let actual = tensor.topk(2, 1); + let expected = TestTensor::<2>::from_data([[7, 3], [8, 8]], &device); + + let output_shape = Shape::new([2, 2]); + assert_eq!(actual.shape(), output_shape); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_topk_3d_dim0() { + let device = Default::default(); + + // Shape [2, 2, 2] + let tensor = TestTensor::<3>::from_data( + [[[10.0, 1.0], [5.0, 20.0]], [[1.0, 10.0], [20.0, 5.0]]], + &device, + ); + let k = 1; + let actual = tensor.topk(k, 0); + + // Max values across dim 0 + let expected = TestTensor::<3>::from_data([[[10.0, 10.0], [20.0, 20.0]]], &device); + + assert_eq!(actual.shape(), Shape::new([1, 2, 2])); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_topk_ties() { + let device = Default::default(); + + let tensor = TestTensor::<1>::from_data([5.0, 2.0, 5.0, 5.0], &device); + let k = 2; + let actual = tensor.topk(k, 0); + + let expected = TestTensor::<1>::from_data([5.0, 5.0], &device); + + assert_eq!(actual.shape(), Shape::new([k])); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_topk_3d_random_complex() { + let device = Default::default(); + + #[rustfmt::skip] + let tensor = TestTensor::<3>::from_data( + [ + [ + [0.5, 1.2, 0.8, 3.3], + [4.4, 2.1, 9.9, 0.1], + [7.7, 8.8, 6.6, 5.5], + ], + [ + [1.1, 0.2, 4.4, 2.2], + [6.0, 7.0, 5.0, 8.0], + [3.0, 3.0, 1.0, 2.0], + ], + ], + &device, + ); + + let k = 2; + let dim = 2; + let actual = tensor.topk(k, dim); + + #[rustfmt::skip] + let expected = TestTensor::<3>::from_data( + [ + [ + [3.3, 1.2], + [9.9, 4.4], + [8.8, 7.7], + ], + [ + [4.4, 2.2], + [8.0, 7.0], + [3.0, 3.0], + ], + ], + &device, + ); + + let output_shape = Shape::new([2, 3, 2]); + assert_eq!( + actual.shape(), + output_shape, + "Output shape should be [2, 3, 2]" + ); + actual.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn reduction_argmin_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + for dim in 0..RANK { + tensor + .clone() + .argmin(dim) + .into_data() + .assert_eq(&tensor_ref.clone().argmin(dim).into_data(), false); + } +} + +#[test] +fn reduction_mean_dim_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + for dim in 0..RANK { + tensor + .clone() + .mean_dim(dim) + .into_data() + .assert_approx_eq::( + &tensor_ref.clone().mean_dim(dim).into_data(), + Tolerance::default(), + ); + } +} + +#[test] +fn reduction_mean_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + tensor + .clone() + .mean() + .into_data() + .assert_approx_eq::( + &tensor_ref.clone().mean().into_data(), + Tolerance::default(), + ); +} + +#[test] +fn reduction_prod_dim_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + for dim in 0..RANK { + tensor + .clone() + .prod_dim(dim) + .into_data() + .assert_approx_eq::( + &tensor_ref.clone().prod_dim(dim).into_data(), + Tolerance::default(), + ); + } +} + +#[test] +fn reduction_prod_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + tensor + .clone() + .prod() + .into_data() + .assert_approx_eq::( + &tensor_ref.clone().prod().into_data(), + Tolerance::default(), + ); +} + +#[test] +fn reduction_sum_dim_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + for dim in 0..RANK { + tensor + .clone() + .sum_dim(dim) + .into_data() + .assert_approx_eq::( + &tensor_ref.clone().sum_dim(dim).into_data(), + Tolerance::default(), + ); + } +} + +#[test] +fn reduction_sum_should_match_reference_backend() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + tensor + .clone() + .sum() + .into_data() + .assert_approx_eq::(&tensor_ref.clone().sum().into_data(), Tolerance::default()); +} + +#[test] +#[ignore = "Impossible to run unless you have tons of VRAM. Also reference backend is broken."] +fn reduction_sum_should_match_reference_backend_64bit() { + const SHAPE: [usize; RANK] = [33, 512, 512, 512]; + + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::::random(SHAPE, Distribution::Default, &device); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + let data = tensor.sum().into_data(); + let data_ref = tensor_ref.sum().into_data(); + println!("result: {:?}", data.as_slice::()); + data.assert_approx_eq::(&data_ref, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/repeat_dim.rs b/crates/burn-backend-tests/tests/cubecl/repeat_dim.rs new file mode 100644 index 0000000..16c273f --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/repeat_dim.rs @@ -0,0 +1,75 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn repeat_dim_0_few_times() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<3>::random([1, 6, 6], Distribution::Default, &device); + let dim = 0; + let times = 4; + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + + let actual = tensor.repeat_dim(dim, times); + let expected = tensor_ref.repeat_dim(dim, times); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[test] +fn repeat_dim_1_few_times() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<3>::random([6, 1, 6], Distribution::Default, &device); + let dim = 1; + let times = 4; + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + + let actual = tensor.repeat_dim(dim, times); + let expected = tensor_ref.repeat_dim(dim, times); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[test] +fn repeat_dim_2_few_times() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<3>::random([6, 6, 1], Distribution::Default, &device); + let dim = 2; + let times = 4; + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + + let actual = tensor.repeat_dim(dim, times); + let expected = tensor_ref.repeat_dim(dim, times); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +#[test] +fn repeat_dim_2_many_times() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<3>::random([10, 10, 1], Distribution::Default, &device); + let dim = 2; + let times = 200; + let tensor_ref = TestTensor::<3>::from_data(tensor.to_data(), &ref_device); + + let actual = tensor.repeat_dim(dim, times); + let expected = tensor_ref.repeat_dim(dim, times); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/scatter.rs b/crates/burn-backend-tests/tests/cubecl/scatter.rs new file mode 100644 index 0000000..70670cf --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/scatter.rs @@ -0,0 +1,68 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::{Device, IndexingUpdateOp, Tolerance}; + +#[test] +fn scatter_should_work_with_multiple_workgroups_2d_dim0() { + same_as_reference_same_shape(0, [256, 32]); +} + +#[test] +fn scatter_should_work_with_multiple_workgroups_2d_dim1() { + same_as_reference_same_shape(1, [32, 256]); +} + +#[test] +fn scatter_should_work_with_multiple_workgroups_3d_dim0() { + same_as_reference_same_shape(0, [256, 6, 6]); +} + +#[test] +fn scatter_should_work_with_multiple_workgroups_3d_dim1() { + same_as_reference_same_shape(1, [6, 256, 6]); +} + +#[test] +fn scatter_should_work_with_multiple_workgroups_3d_dim2() { + same_as_reference_same_shape(2, [6, 6, 256]); +} + +#[test] +fn scatter_should_work_with_multiple_workgroups_diff_shapes() { + same_as_reference_diff_shape(1, [32, 128], [32, 1]); +} + +fn same_as_reference_diff_shape( + dim: usize, + shape1: [usize; D], + shape2: [usize; D], +) { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let tensor = TestTensor::::random(shape1, Distribution::Default, &device); + let value = TestTensor::::random(shape2, Distribution::Default, &device); + let indices = TestTensorInt::<1>::random( + [shape2.iter().product::()], + Distribution::Uniform(0., shape2[dim] as f64), + &device, + ) + .reshape(shape2); + + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + let value_ref = TestTensor::::from_data(value.to_data(), &ref_device); + let indices_ref = TestTensorInt::::from_data(indices.to_data(), &ref_device); + + let actual = tensor.scatter(dim, indices, value, IndexingUpdateOp::Add); + let expected = tensor_ref.scatter(dim, indices_ref, value_ref, IndexingUpdateOp::Add); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} + +fn same_as_reference_same_shape(dim: usize, shape: [usize; D]) { + same_as_reference_diff_shape(dim, shape, shape); +} diff --git a/crates/burn-backend-tests/tests/cubecl/select.rs b/crates/burn-backend-tests/tests/cubecl/select.rs new file mode 100644 index 0000000..ea3b3bc --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/select.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn select_should_work_with_multiple_workgroups() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<2>::random([6, 256], Distribution::Default, &device); + let indices = TestTensorInt::<1>::arange(0..100, &device); + let tensor_ref = TestTensor::<2>::from_data(tensor.to_data(), &ref_device); + let indices_ref = TestTensorInt::<1>::from_data(indices.to_data(), &ref_device); + + let actual = tensor.select(1, indices); + let expected = tensor_ref.select(1, indices_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/select_assign.rs b/crates/burn-backend-tests/tests/cubecl/select_assign.rs new file mode 100644 index 0000000..1195507 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/select_assign.rs @@ -0,0 +1,53 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::{Device, IndexingUpdateOp, Tolerance}; + +#[test] +fn select_add_should_work_with_multiple_workgroups_2d_dim0() { + select_add_same_as_ref(0, [256, 6]); +} + +#[test] +fn select_add_should_work_with_multiple_workgroups_2d_dim1() { + select_add_same_as_ref(1, [6, 256]); +} + +#[test] +fn select_add_should_work_with_multiple_workgroups_3d_dim0() { + select_add_same_as_ref(0, [256, 6, 6]); +} + +#[test] +fn select_add_should_work_with_multiple_workgroups_3d_dim1() { + select_add_same_as_ref(1, [6, 256, 6]); +} + +#[test] +fn select_add_should_work_with_multiple_workgroups_3d_dim2() { + select_add_same_as_ref(2, [6, 6, 256]); +} + +fn select_add_same_as_ref(dim: usize, shape: [usize; D]) { + let device = Device::default(); + let ref_device = ReferenceDevice::new(); + + device.seed(0); + + let tensor = TestTensor::::random(shape, Distribution::Default, &device); + let value = TestTensor::::random(shape, Distribution::Default, &device); + let indices = TestTensorInt::<1>::random( + [shape[dim]], + Distribution::Uniform(0., shape[dim] as f64), + &device, + ); + let tensor_ref = TestTensor::::from_data(tensor.to_data(), &ref_device); + let value_ref = TestTensor::::from_data(value.to_data(), &ref_device); + let indices_ref = TestTensorInt::<1>::from_data(indices.to_data(), &ref_device); + + let actual = tensor.select_assign(dim, indices, value, IndexingUpdateOp::Add); + let expected = tensor_ref.select_assign(dim, indices_ref, value_ref, IndexingUpdateOp::Add); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/slice.rs b/crates/burn-backend-tests/tests/cubecl/slice.rs new file mode 100644 index 0000000..c941a61 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/slice.rs @@ -0,0 +1,20 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::Tolerance; + +#[test] +fn slice_should_work_with_multiple_workgroups() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<2>::random([6, 256], Distribution::Default, &device); + let indices = [3..5, 45..256]; + let tensor_ref = TestTensor::<2>::from_data(tensor.to_data(), &ref_device); + + let actual = tensor.slice(indices.clone()); + let expected = tensor_ref.slice(indices); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/slice_assign.rs b/crates/burn-backend-tests/tests/cubecl/slice_assign.rs new file mode 100644 index 0000000..c65f524 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/slice_assign.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::{Distribution, Tolerance}; + +#[test] +fn slice_assign_should_work_with_multiple_workgroups() { + let device = Default::default(); + let ref_device = ReferenceDevice::new(); + + let tensor = TestTensor::<2>::random([6, 256], Distribution::Default, &device); + let value = TestTensor::<2>::random([2, 211], Distribution::Default, &device); + let indices = [3..5, 45..256]; + let tensor_ref = TestTensor::<2>::from_data(tensor.to_data(), &ref_device); + let value_ref = TestTensor::<2>::from_data(value.to_data(), &ref_device); + + let actual = tensor.slice_assign(indices.clone(), value); + let expected = tensor_ref.slice_assign(indices, value_ref); + + expected + .into_data() + .assert_approx_eq::(&actual.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/cubecl/stft.rs b/crates/burn-backend-tests/tests/cubecl/stft.rs new file mode 100644 index 0000000..cb7df56 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/stft.rs @@ -0,0 +1,293 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::signal::{StftOptions, hann_window, istft, stft}; + +fn opts(n_fft: usize, hop_length: usize, center: bool, onesided: bool) -> StftOptions { + StftOptions { + n_fft, + hop_length, + win_length: None, + center, + onesided, + } +} + +#[test] +fn stft_constant_signal_rectangular_window() { + // Constant signal with rectangular window: only DC bin should be non-zero + let signal = TestTensor::<2>::from([[1.0, 1.0, 1.0, 1.0]]); + let result = stft(signal, None, opts(4, 1, false, true)); + + let [batch, n_frames, n_freqs, two] = result.dims(); + assert_eq!(batch, 1); + assert_eq!(n_frames, 1); // (4 - 4) / 1 + 1 = 1 + assert_eq!(n_freqs, 3); // 4/2 + 1 = 3 + assert_eq!(two, 2); + + // DC bin should be 4.0 + 0i (sum of ones) + let data = result.into_data(); + let values = data.to_vec::().unwrap(); + // [batch=0, frame=0, freq=0, re] = 4.0 + assert!( + (values[0] - 4.0).abs() < 1e-4, + "DC real should be 4.0, got {}", + values[0] + ); + // [batch=0, frame=0, freq=0, im] = 0.0 + assert!( + values[1].abs() < 1e-4, + "DC imag should be 0.0, got {}", + values[1] + ); +} + +#[test] +fn stft_output_shape_onesided() { + let signal = TestTensor::<2>::from([[1.0; 16]]); + let result = stft(signal, None, opts(8, 4, false, true)); + + let [batch, n_frames, n_freqs, two] = result.dims(); + assert_eq!(batch, 1); + assert_eq!(n_frames, 3); // (16 - 8) / 4 + 1 = 3 + assert_eq!(n_freqs, 5); // 8/2 + 1 = 5 + assert_eq!(two, 2); +} + +#[test] +fn stft_output_shape_twosided() { + let signal = TestTensor::<2>::from([[1.0; 16]]); + let result = stft(signal, None, opts(8, 4, false, false)); + + let [batch, n_frames, n_freqs, two] = result.dims(); + assert_eq!(batch, 1); + assert_eq!(n_frames, 3); + assert_eq!(n_freqs, 8); // full spectrum + assert_eq!(two, 2); +} + +#[test] +fn stft_center_padding() { + // With center=true, signal is padded by n_fft/2 on both sides + let signal = TestTensor::<2>::from([[1.0; 8]]); + let result = stft(signal, None, opts(4, 2, true, true)); + + // After padding: 2 + 8 + 2 = 12 samples + // n_frames = (12 - 4) / 2 + 1 = 5 + let [_, n_frames, _, _] = result.dims(); + assert_eq!(n_frames, 5); +} + +#[test] +fn stft_with_hann_window() { + let signal = TestTensor::<2>::from([[1.0; 8]]); + let window: TestTensor<1> = hann_window(4, true, &Default::default()); + let result = stft(signal, Some(window), opts(4, 2, false, true)); + + let [batch, n_frames, n_freqs, two] = result.dims(); + assert_eq!(batch, 1); + assert_eq!(n_frames, 3); + assert_eq!(n_freqs, 3); + assert_eq!(two, 2); +} + +#[test] +fn stft_batch_dimension() { + let signal = TestTensor::<2>::from([[1.0; 8], [2.0; 8]]); + let result = stft(signal, None, opts(4, 2, false, true)); + + let [batch, _, _, _] = result.dims(); + assert_eq!(batch, 2); +} + +#[test] +fn stft_istft_roundtrip_rectangular() { + let original = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]); + let n_fft = 4; + let hop_length = 2; + + let o = opts(n_fft, hop_length, false, true); + let spectrum = stft(original.clone(), None, o); + let reconstructed = istft(spectrum, None, Some(8), o); + + reconstructed + .into_data() + .assert_approx_eq::(&original.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn stft_istft_roundtrip_centered() { + let original = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]); + let n_fft = 4; + let hop_length = 2; + + let o = opts(n_fft, hop_length, true, true); + let spectrum = stft(original.clone(), None, o); + let reconstructed = istft(spectrum, None, Some(8), o); + + reconstructed + .into_data() + .assert_approx_eq::(&original.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn stft_istft_roundtrip_twosided() { + let original = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]); + let n_fft = 4; + let hop_length = 2; + + let o = opts(n_fft, hop_length, false, false); + let spectrum = stft(original.clone(), None, o); + let reconstructed = istft(spectrum, None, Some(8), o); + + reconstructed + .into_data() + .assert_approx_eq::(&original.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn stft_istft_roundtrip_batch() { + let original = TestTensor::<2>::from([ + [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], + [8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0], + ]); + let n_fft = 4; + let hop_length = 2; + + let o = opts(n_fft, hop_length, false, true); + let spectrum = stft(original.clone(), None, o); + let reconstructed = istft(spectrum, None, Some(8), o); + + reconstructed + .into_data() + .assert_approx_eq::(&original.into_data(), Tolerance::absolute(1e-3)); +} + +#[test] +fn stft_istft_roundtrip_hann_window() { + let original = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]); + let n_fft = 4; + let hop_length = 1; + + let window: TestTensor<1> = hann_window(4, true, &Default::default()); + let o = opts(n_fft, hop_length, true, true); + let spectrum = stft(original.clone(), Some(window.clone()), o); + let reconstructed = istft(spectrum, Some(window), Some(8), o); + + reconstructed + .into_data() + .assert_approx_eq::(&original.into_data(), Tolerance::absolute(1e-2)); +} + +#[test] +fn stft_with_hamming_window() { + use burn_tensor::signal::hamming_window; + let signal = TestTensor::<2>::from([[1.0; 8]]); + let window: TestTensor<1> = hamming_window(4, true, &Default::default()); + let result = stft(signal, Some(window), opts(4, 2, false, true)); + + let [batch, n_frames, n_freqs, two] = result.dims(); + assert_eq!(batch, 1); + assert_eq!(n_frames, 3); + assert_eq!(n_freqs, 3); + assert_eq!(two, 2); +} + +#[test] +#[should_panic(expected = "hop_length")] +fn stft_rejects_hop_greater_than_window() { + // hop (5) > effective win_length (4) violates COLA/NOLA; must be rejected. + let signal = TestTensor::<2>::from([[1.0; 16]]); + let _ = stft(signal, None, opts(4, 5, false, true)); +} + +#[test] +#[should_panic(expected = "n_fft")] +fn stft_rejects_zero_nfft() { + let signal = TestTensor::<2>::from([[1.0; 4]]); + let _ = stft(signal, None, opts(0, 1, false, true)); +} + +#[test] +#[should_panic(expected = "power of two")] +fn stft_rejects_non_power_of_two_nfft() { + // n_fft=5 is not a power of two; should hard-fail in StftOptions::assert_valid. + let signal = TestTensor::<2>::from([[1.0; 8]]); + let _ = stft(signal, None, opts(5, 1, false, true)); +} + +#[test] +#[should_panic(expected = "hop_length")] +fn stft_rejects_zero_hop_length() { + let signal = TestTensor::<2>::from([[1.0; 4]]); + let _ = stft(signal, None, opts(4, 0, false, true)); +} + +#[test] +#[should_panic(expected = "win_length")] +fn stft_rejects_zero_win_length() { + let signal = TestTensor::<2>::from([[1.0; 4]]); + let o = StftOptions { + n_fft: 4, + hop_length: 1, + win_length: Some(0), + center: false, + onesided: true, + }; + let _ = stft(signal, None, o); +} + +#[test] +#[should_panic(expected = "reflect pad")] +fn stft_rejects_too_short_signal_with_center() { + // n_fft/2 = 2, signal length 2 is not > 2, so reflect pad would fail. + let signal = TestTensor::<2>::from([[1.0, 2.0]]); + let _ = stft(signal, None, opts(4, 1, true, true)); +} + +#[test] +#[should_panic(expected = "window length")] +fn istft_rejects_wrong_window_length() { + // Synthetic stft matrix: [batch=1, n_frames=3, n_freqs=3, 2 (re/im)]. + // Values are arbitrary; we only need istft to reach the window-length check. + let spectrum: TestTensor<4> = TestTensor::from([[ + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]]); + + // n_fft=4, effective win_length=3 (per win_length=Some(3)); passed window length=4 mismatches. + let bad_window: TestTensor<1> = TestTensor::from([1.0, 1.0, 1.0, 1.0]); + let o_bad = StftOptions { + n_fft: 4, + hop_length: 2, + win_length: Some(3), + center: false, + onesided: true, + }; + let _ = istft(spectrum, Some(bad_window), Some(8), o_bad); +} + +#[test] +#[should_panic(expected = "n_freqs")] +fn istft_rejects_wrong_n_freqs() { + // n_fft=4, onesided=true: expected n_freqs = 4/2+1 = 3. + // Pass a spectrum with 4 bins to trigger the shape check. + let spectrum: TestTensor<4> = TestTensor::from([[ + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]]); + let _ = istft(spectrum, None, Some(8), opts(4, 2, false, true)); +} + +#[test] +fn stft_options_default_and_new() { + // Spot-check the defaults match PyTorch (hop = n_fft/4, center, onesided). + let o = StftOptions::new(16); + assert_eq!(o.n_fft, 16); + assert_eq!(o.hop_length, 4); + assert_eq!(o.win_length, None); + assert!(o.center); + assert!(o.onesided); + let d = StftOptions::default(); + assert_eq!(d.n_fft, 400); +} diff --git a/crates/burn-backend-tests/tests/cubecl/unary.rs b/crates/burn-backend-tests/tests/cubecl/unary.rs new file mode 100644 index 0000000..739aff7 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/unary.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn tanh_should_not_have_numerical_bugs_on_macos() { + fn tanh_one_value(input: f32) -> f32 { + let tensor = TestTensor::<1>::ones([1], &Default::default()) * input; + let output = tensor.tanh(); + output.into_data().as_slice().unwrap()[0] + } + + let ok = tanh_one_value(43.0); // metal tanh gives 1.0 which is the right answer + let zero = tanh_one_value(44.0); // metal tanh gives zero when within 43.67..44.36 + let nan = tanh_one_value(45.0); // metal tanh gives nan when over 44.36 + let neg = tanh_one_value(-45.0); // metal works correctly here + + assert!(!ok.is_nan() && ok == 1.0); + assert!(!zero.is_nan() && zero == 1.0); + assert!(!nan.is_nan() && nan == 1.0); + assert!(!neg.is_nan() && neg == -1.0); +} diff --git a/crates/burn-backend-tests/tests/cubecl/uniform.rs b/crates/burn-backend-tests/tests/cubecl/uniform.rs new file mode 100644 index 0000000..2736037 --- /dev/null +++ b/crates/burn-backend-tests/tests/cubecl/uniform.rs @@ -0,0 +1,111 @@ +use super::*; +use burn_tensor::{Device, Distribution, Shape}; +use burn_tensor::{ElementConversion, Tolerance}; + +use serial_test::serial; + +use cubek::random::{assert_at_least_one_value_per_bin, assert_wald_wolfowitz_runs_test}; + +#[test] +#[serial] +fn values_all_within_interval_default() { + let device = Device::default(); + device.seed(0); + let shape = [24, 24]; + + let tensor = TestTensor::<2>::random(shape, Distribution::Default, &device); + tensor + .to_data() + .assert_within_range::(0.elem()..1.elem()); +} + +#[test] +#[serial] +fn values_all_within_interval_uniform() { + let device = Device::default(); + device.seed(0); + let shape = [24, 24]; + + let tensor = TestTensor::<2>::random(shape, Distribution::Uniform(5., 17.), &device); + tensor + .to_data() + .assert_within_range::(5.elem()..17.elem()); +} + +#[test] +#[serial] +fn at_least_one_value_per_bin_uniform() { + let device = Device::default(); + device.seed(0); + let shape = [64, 64]; + + let tensor = + TestTensor::<2>::random(shape, Distribution::Uniform(-5., 10.), &device).into_data(); + let numbers = tensor.as_slice::().unwrap(); + + assert_at_least_one_value_per_bin(numbers, 3, -5., 10.); +} + +#[test] +#[serial] +fn runs_test() { + let device = Device::default(); + device.seed(0); + let shape = Shape::new([512, 512]); + let tensor = TestTensor::<2>::random(shape, Distribution::Default, &device).into_data(); + + let numbers = tensor.as_slice::().unwrap(); + + assert_wald_wolfowitz_runs_test(numbers, 0., 1.); +} + +#[test] +#[serial] +fn int_values_all_within_interval_uniform() { + let device = Device::default(); + device.seed(0); + let shape = Shape::new([20, 20]); + let tensor = TestTensorInt::<2>::random(shape, Distribution::Default, &device); + + let data_float = tensor.float().into_data(); + + data_float.assert_within_range(0..255); +} + +#[test] +#[serial] +fn at_least_one_value_per_bin_int_uniform() { + let device = Device::default(); + device.seed(0); + let shape = Shape::new([64, 64]); + + let tensor = TestTensorInt::<2>::random(shape, Distribution::Uniform(-10.0, 10.0), &device); + + let data_float = tensor.float().into_data(); + + let numbers = data_float.as_slice::().unwrap(); + + assert_at_least_one_value_per_bin(numbers, 10, -10., 10.); +} + +#[test] +fn should_not_fail_on_non_float_autotune() { + let device = Device::default(); + let tensor_1 = TestTensor::<2>::from_data([[1., 2., 3.], [3., 4., 5.]], &device); + + // Autotune of all (reduce) on lower_equal_elem's output calls uniform distribution + tensor_1.lower_equal_elem(1.0).all(); +} + +#[test] +#[serial] +fn test_seed_reproducibility() { + let device = Device::default(); + device.seed(42); + let t1 = TestTensor::<1>::random([5], Distribution::Default, &device); + device.seed(42); + let t2 = TestTensor::<1>::random([5], Distribution::Default, &device); + + t1.into_data() + .assert_approx_eq::(&t2.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/fusion/cat.rs b/crates/burn-backend-tests/tests/fusion/cat.rs new file mode 100644 index 0000000..d51f0d4 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/cat.rs @@ -0,0 +1,341 @@ +//! Tests that `Cat` fuses with following element-wise operations into a single kernel +//! and computes correct values, including along the vectorization (last) axis. + +use super::*; +use burn_fusion::inspect::{BlockKind, FusionInspector, matchers}; +use burn_tensor::{TensorData, Tolerance}; + +/// `cat` on dim 0 followed by an element-wise op should collapse into a single +/// ElementWise fused kernel containing both operations. +#[test] +fn cat_then_elementwise_fuses_into_single_kernel() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::from_data([[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0]], &device); + let b = + TestTensor::<2>::from_data([[8.0, 9.0, 10.0, 11.0], [12.0, 13.0, 14.0, 15.0]], &device); + let dtype = a.dtype(); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensor::cat(vec![a, b], 0).mul_scalar(2.0); + out.into_data().assert_approx_eq::( + &TensorData::from([ + [0.0, 2.0, 4.0, 6.0], + [8.0, 10.0, 12.0, 14.0], + [16.0, 18.0, 20.0, 22.0], + [24.0, 26.0, 28.0, 30.0], + ]), + Tolerance::default(), + ); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + let block = reports + .iter() + .flat_map(|report| report.blocks.iter()) + .find(|block| block.operations.iter().any(matchers::is_cat())) + .unwrap_or_else(|| panic!("no block containing cat found\n\n{tables}")); + + assert!( + matches!( + block.kind, + BlockKind::Fused { + name: "ElementWise", + .. + } + ), + "expected cat in an ElementWise fused block, got {:?}\n\n{tables}", + block.kind, + ); + assert!( + block + .operations + .iter() + .any(matchers::is_mul_scalar_float(dtype)), + "MulScalar should share the fused block with Cat\n\n{tables}", + ); + }); +} + +/// `cat` along the last (vectorization) axis with segments that aren't aligned to the +/// vectorization width: each element of a vector may come from a different input. +#[test] +fn cat_last_dim_with_unaligned_segments_fuses_and_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let b = TestTensor::<2>::from_data( + [ + [10.0, 20.0, 30.0, 40.0, 50.0], + [60.0, 70.0, 80.0, 90.0, 100.0], + ], + &device, + ); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensor::cat(vec![a, b], 1).add_scalar(1.0); + out.into_data().assert_approx_eq::( + &TensorData::from([ + [2.0, 3.0, 4.0, 11.0, 21.0, 31.0, 41.0, 51.0], + [5.0, 6.0, 7.0, 61.0, 71.0, 81.0, 91.0, 101.0], + ]), + Tolerance::default(), + ); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "cat should be part of a fused block\n\n{tables}", + ); + }); +} + +/// `cat` of three inputs along a middle axis of rank-3 tensors. +#[test] +fn cat_three_inputs_middle_dim_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<3>::from_data([[[1.0, 2.0]], [[3.0, 4.0]]], &device); + let b = TestTensor::<3>::from_data( + [[[5.0, 6.0], [7.0, 8.0]], [[9.0, 10.0], [11.0, 12.0]]], + &device, + ); + let c = TestTensor::<3>::from_data([[[13.0, 14.0]], [[15.0, 16.0]]], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensor::cat(vec![a, b, c], 1).mul_scalar(2.0); + out.into_data().assert_approx_eq::( + &TensorData::from([ + [[2.0, 4.0], [10.0, 12.0], [14.0, 16.0], [26.0, 28.0]], + [[6.0, 8.0], [18.0, 20.0], [22.0, 24.0], [30.0, 32.0]], + ]), + Tolerance::default(), + ); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "cat should be part of a fused block\n\n{tables}", + ); + }); +} + +/// When a `cat` input is produced by a preceding element-wise op in the same stream +/// segment, the fuser must split (the input needs to be materialized) while still +/// producing correct values. +#[test] +fn cat_of_computed_input_splits_but_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let b = TestTensor::<2>::from_data([[5.0, 6.0], [7.0, 8.0]], &device); + device.sync().unwrap(); + + // No sync between the mul and the cat: the fuser has to handle the split itself. + let out = TestTensor::cat(vec![a.mul_scalar(10.0), b], 0); + out.into_data().assert_approx_eq::( + &TensorData::from([[10.0, 20.0], [30.0, 40.0], [5.0, 6.0], [7.0, 8.0]]), + Tolerance::default(), + ); + device.sync().unwrap(); + }); +} + +/// `cat` on int tensors goes through the `BaseInt` path and should fuse as well. +#[test] +fn cat_int_then_elementwise_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensorInt::<1>::from_data([1, 2, 3], &device); + let b = TestTensorInt::<1>::from_data([4, 5], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensorInt::cat(vec![a, b], 0) + 10; + out.into_data() + .assert_eq(&TensorData::from([11, 12, 13, 14, 15]), false); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "int cat should be part of a fused block\n\n{tables}", + ); + }); +} + +/// `cat` on bool tensors goes through the `BaseBool` path and should fuse with a following +/// element-wise op (`equal` is the base op available on bool). +#[test] +fn cat_bool_then_elementwise_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensorBool::<1>::from_data([true, false, true], &device); + let b = TestTensorBool::<1>::from_data([false, true], &device); + let c = TestTensorBool::<1>::from_data([true, true, false, false, true], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensorBool::cat(vec![a, b], 0).equal(c); + out.into_data() + .assert_eq(&TensorData::from([true, false, false, true, true]), false); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "bool cat should be part of a fused block\n\n{tables}", + ); + }); +} + +/// `cat` of a non-contiguous (transposed) input: the fused kernel must follow the input's +/// actual strides. Concatenating on the last dim also exercises the per-element path. +#[test] +fn cat_transposed_input_last_dim_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + // Non-contiguous handle of shape [3, 2]. + let a = a.swap_dims(0, 1); + let b = TestTensor::<2>::from_data([[10.0, 20.0], [30.0, 40.0], [50.0, 60.0]], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = TestTensor::cat(vec![a, b], 1).mul_scalar(2.0); + out.into_data().assert_approx_eq::( + &TensorData::from([ + [2.0, 8.0, 20.0, 40.0], + [4.0, 10.0, 60.0, 80.0], + [6.0, 12.0, 100.0, 120.0], + ]), + Tolerance::default(), + ); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "cat of a transposed input should be part of a fused block\n\n{tables}", + ); + }); +} + +/// The `cat` output is broadcast by the following element-wise op: the fused block's +/// reference shape is larger than the cat output along a non-cat axis, so cat input reads +/// must wrap around on that axis. +#[test] +fn cat_output_broadcast_by_elementwise_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let b = TestTensor::<2>::from_data([[10.0, 20.0, 30.0, 40.0, 50.0]], &device); + let c = TestTensor::<2>::from_data([[100.0; 8], [200.0; 8]], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + // cat([1, 3], [1, 5]) = [1, 8], broadcast against [2, 8]. + let out = TestTensor::cat(vec![a, b], 1) + c; + out.into_data().assert_approx_eq::( + &TensorData::from([ + [101.0, 102.0, 103.0, 110.0, 120.0, 130.0, 140.0, 150.0], + [201.0, 202.0, 203.0, 210.0, 220.0, 230.0, 240.0, 250.0], + ]), + Tolerance::default(), + ); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + reports + .iter() + .flat_map(|report| report.blocks.iter()) + .any(|block| matches!(block.kind, BlockKind::Fused { .. }) + && block.operations.iter().any(matchers::is_cat())), + "broadcast cat should be part of a fused block\n\n{tables}", + ); + }); +} diff --git a/crates/burn-backend-tests/tests/fusion/fusion_f16_broadcast.rs b/crates/burn-backend-tests/tests/fusion/fusion_f16_broadcast.rs new file mode 100644 index 0000000..189fe2e --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/fusion_f16_broadcast.rs @@ -0,0 +1,108 @@ +//! Regression test for fusion broadcast bug with conv + manual BN. +//! +//! Bug: When two parallel branches each do conv(64→256) followed by manual +//! batch-norm (sub, div-by-sqrt, mul, add with [1,C,1,1] broadcast params), +//! the second branch produces wrong values under Fusion. +//! +//! The sqrt(v + eps) computation on the [1,256,1,1] parameter tensor gets +//! fused into the same kernel as the broadcast ops on the [1,256,16,16] conv output + +use super::*; +use burn_tensor::{ + DType, Device, TensorCreationOptions, TensorData, Tolerance, module::conv2d, ops::ConvOptions, +}; + +const EPS: f64 = 1e-5; + +fn pseudo_random(n: usize, seed: f32, scale: f32) -> Vec { + (0..n) + .map(|i| (i as f32 * seed + 0.7).sin() * scale) + .collect() +} + +fn make_conv_weight( + in_ch: usize, + out_ch: usize, + seed: f32, + opts: TensorCreationOptions, +) -> TestTensor<4> { + // kernel_size=[1, 1]; stride=[1, 1]; dilation=[1, 1]; groups=1; + let n = out_ch * in_ch; + let scale = (2.0 / in_ch as f32).sqrt(); + let data = TensorData::new(pseudo_random(n, seed, scale), [out_ch, in_ch, 1, 1]); + TestTensor::from_data(data, opts) +} + +fn manual_bn( + x: TestTensor<4>, + ch: usize, + mean: &TestTensor<1>, + var: &TestTensor<1>, + gamma: &TestTensor<1>, + beta: &TestTensor<1>, +) -> TestTensor<4> { + let m = mean.clone().reshape([1, ch, 1, 1]); + let v = var.clone().reshape([1, ch, 1, 1]); + let g = gamma.clone().reshape([1, ch, 1, 1]); + let b = beta.clone().reshape([1, ch, 1, 1]); + let std = (v + EPS).sqrt(); + (x - m) / std * g + b +} + +fn two_conv_bn_branches(dev: Device, dtype: DType) -> TensorData { + let opts: TensorCreationOptions = (&dev, dtype).into(); + let weight_a = make_conv_weight(64, 256, 1.0, opts.clone()); + let weight_b = make_conv_weight(64, 256, 2.0, opts.clone()); + + let ch = 256; + let make_params = |seed: f32| { + let mean = TestTensor::<1>::from_data( + TensorData::new(pseudo_random(ch, seed + 300.0, 0.5), [ch]), + opts.clone(), + ); + let var_data: Vec = pseudo_random(ch, seed + 400.0, 0.3) + .iter() + .map(|v| 0.5 + v.abs()) + .collect(); + let var = TestTensor::<1>::from_data(TensorData::new(var_data, [ch]), opts.clone()); + let gamma_data: Vec = pseudo_random(ch, seed + 100.0, 0.5) + .iter() + .map(|v| 1.0 + v * 0.1) + .collect(); + let gamma = TestTensor::<1>::from_data(TensorData::new(gamma_data, [ch]), opts.clone()); + let beta = TestTensor::<1>::from_data( + TensorData::new(pseudo_random(ch, seed + 200.0, 0.3), [ch]), + opts.clone(), + ); + (mean, var, gamma, beta) + }; + let (mean_a, var_a, gamma_a, beta_a) = make_params(1.0); + let (mean_b, var_b, gamma_b, beta_b) = make_params(2.0); + dev.sync().unwrap(); + + let numel = 64 * 16 * 16; + let data: Vec = (0..numel).map(|i| (i as f32 * 7.13).sin() * 0.5).collect(); + let input = TestTensor::<4>::from_data(TensorData::new(data, [1, 64, 16, 16]), opts); + + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let conv_a_out = conv2d(input.clone(), weight_a, None, options.clone()); + let conv_b_out = conv2d(input, weight_b, None, options); + + let a = manual_bn(conv_a_out, ch, &mean_a, &var_a, &gamma_a, &beta_a); + let b = manual_bn(conv_b_out, ch, &mean_b, &var_b, &gamma_b, &beta_b); + let out = a + b; + + out.into_data() +} + +/// This test was failing only on Vulkan+Fusion+f16 +/// Reference: https://github.com/tracel-ai/burn/pull/4675 +#[test] +fn fusion_f16_two_branch_conv_manual_bn() { + let fused_f16 = two_conv_bn_branches(Default::default(), DType::F16); + let reference_f32 = two_conv_bn_branches(Default::default(), DType::F32); + + fused_f16 + .convert_dtype(DType::F32) + .assert_approx_eq::(&reference_f32, Tolerance::rel_abs(5e-3, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/fusion/fusion_f16_write_vectorization.rs b/crates/burn-backend-tests/tests/fusion/fusion_f16_write_vectorization.rs new file mode 100644 index 0000000..7ecdf58 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/fusion_f16_write_vectorization.rs @@ -0,0 +1,107 @@ +//! Regression test for fusion kernel vector_size mismatch on output writes. +//! +//! Bug: When a fused kernel's computation width (config.width) differs from an +//! output tensor's registered vector_size, the SPIR-V store uses the wrong type +//! (e.g. vec4 into a scalar f16 slot), corrupting adjacent memory. +//! +//! This manifests as Inf/NaN values in downstream tensors. Only triggers with +//! f16 because f32 tensors tend to all receive the same vectorization on WGPU. +//! +//! The minimal trigger is two parallel conv+BatchNorm branches whose results +//! are added: the BatchNorm inplace running-stats outputs are alias tensors +//! registered with vector_size=1, while the fused block runs at width=4. + +use super::*; +use burn_tensor::{ + DType, Device, TensorCreationOptions, TensorData, Tolerance, module::conv2d, ops::ConvOptions, +}; +use std::sync::{Arc, Mutex}; + +const CH: usize = 8; +const INIT: f64 = 0.02; +const EPS: f64 = 1e-5; + +struct RunningState { + value: Arc>>, +} + +impl RunningState { + fn new(value: TestTensor<1>) -> Self { + Self { + value: Arc::new(Mutex::new(value)), + } + } + + fn value(&self) -> TestTensor<1> { + self.value.lock().unwrap().clone() + } +} + +/// Minimal clone of `BatchNorm` w/ `RunningState` for inference only. +struct BatchNorm { + gamma: TestTensor<1>, + beta: TestTensor<1>, + running_mean: RunningState, + running_var: RunningState, +} + +impl BatchNorm { + fn new(opts: TensorCreationOptions) -> Self { + Self { + gamma: TestTensor::<1>::ones([CH], opts.clone()), + beta: TestTensor::<1>::zeros([CH], opts.clone()), + running_mean: RunningState::new(TestTensor::<1>::zeros([CH], opts.clone())), + running_var: RunningState::new(TestTensor::<1>::ones([CH], opts)), + } + } + + fn forward(&self, input: TestTensor<4>) -> TestTensor<4> { + let device = input.device(); + let channels = input.dims()[1]; + let mean = self.running_mean.value().to_device(&device); + let var = self.running_var.value().to_device(&device); + + let mean = mean.reshape([1, channels, 1, 1]); + let var = var.reshape([1, channels, 1, 1]); + let std = (var + EPS).sqrt(); + + let x = (input - mean) / std; + let x = x * self.gamma.clone().reshape([1, channels, 1, 1]); + x + self.beta.clone().reshape([1, channels, 1, 1]) + } +} + +fn make_conv_weight(opts: TensorCreationOptions) -> TestTensor<4> { + TestTensor::full([CH, 3, 1, 1], INIT, opts) +} + +fn two_branch_conv_bn_add(dev: Device, dtype: DType) -> TensorData { + let opts: TensorCreationOptions = (&dev, dtype).into(); + let weight_a = make_conv_weight(opts.clone()); + let weight_b = make_conv_weight(opts.clone()); + let bn_a = BatchNorm::new(opts.clone()); + let bn_b = BatchNorm::new(opts.clone()); + + dev.sync().unwrap(); + + let input = TestTensor::<4>::ones([1, 3, 32, 32], opts) * 0.5; + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let a = bn_a.forward(conv2d(input.clone(), weight_a, None, options.clone())); + let b = bn_b.forward(conv2d(input, weight_b, None, options)); + (a + b).into_data() +} + +/// Two parallel conv+BN branches added together — the Fusion result must +/// match reference. +/// +/// This test was failing only on Vulkan+Fusion+f16 +/// Reference: https://github.com/tracel-ai/burn/pull/4675 +#[test] +fn fusion_f16_two_branch_conv_bn_add_matches_reference() { + let fused_f16 = two_branch_conv_bn_add(Default::default(), DType::F16); + let reference_f32 = two_branch_conv_bn_add(Default::default(), DType::F32); + + fused_f16 + .convert_dtype(DType::F32) + .assert_approx_eq::(&reference_f32, Tolerance::rel_abs(5e-3, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/fusion/fusion_shape.rs b/crates/burn-backend-tests/tests/fusion/fusion_shape.rs new file mode 100644 index 0000000..e8c0373 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/fusion_shape.rs @@ -0,0 +1,277 @@ +//! Tests that assert on *which* operations were fused together, not just output values. +//! +//! These tests install a [`FusionInspector`], run a sequence of tensor ops, and then +//! inspect the captured [`FusionReport`]s to check that the expected operations ended +//! up in the same (or separate) fused kernels. +//! +//! `FusionInspector::install` captures operations on a specific stream, so two tests running +//! in parallel will not interfere when inspecting different streams. Use `test_stream()` +//! (which returns a fresh unique [`StreamId`] per call) to isolate each test. Installing +//! two inspectors for the *same* stream will panic. + +use super::*; +use burn_fusion::inspect::{BlockKind, FusionInspector, matchers}; + +/// `a + b` followed by `exp` should collapse into a single element-wise fused kernel. +#[test] +fn elementwise_add_then_exp_fuses_into_single_kernel() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + // Materialize the inputs first so that the `Ones` init ops aren't rolled into the + // fused block we're trying to observe. + let a = TestTensor::<2>::ones([4, 4], &device); + let b = TestTensor::<2>::ones([4, 4], &device); + let dtype = a.dtype(); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let out = (a + b).exp(); + let _ = out.into_data(); + device.sync().unwrap(); + + let reports = inspector.drain(); + assert!(!reports.is_empty(), "expected at least one fusion report"); + + let target = reports + .iter() + .find(|r| r.total_operations() == 2) + .unwrap_or_else(|| panic!("no report with 2 ops; got {reports:#?}")); + + let block = target.assert_single_fused_block(); + assert_eq!(block.fuser_name(), Some("ElementWise")); + assert!( + block.ops_match(&[matchers::is_add_float(dtype), matchers::is_exp(dtype),]), + "block ops did not match add + exp: {:#?}", + block.operations, + ); + }); +} + +/// Forcing a sync between two sub-expressions should materialize the intermediate and +/// prevent fusion across the boundary. +#[test] +fn sync_between_ops_splits_into_separate_kernels() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + let inspector = FusionInspector::install(stream); + + let a = TestTensor::<2>::ones([4, 4], &device); + let b = TestTensor::<2>::ones([4, 4], &device); + let intermediate = a + b; + let dtype = intermediate.dtype(); + + // Force materialization of the intermediate. + device.sync().unwrap(); + + let out = intermediate.exp(); + let _ = out.into_data(); + device.sync().unwrap(); + + let reports = inspector.drain(); + + // Across all reports, we should have at least one fused add and one fused exp, and + // they should never appear together in the same block. + let add = matchers::is_add_float(dtype); + let exp = matchers::is_exp(dtype); + for report in &reports { + for block in &report.blocks { + let has_add = block.operations.iter().any(|op| add(op)); + let has_exp = block.operations.iter().any(|op| exp(op)); + assert!( + !(has_add && has_exp), + "add and exp should not share a fused block after sync: {:#?}", + block, + ); + } + } + + let saw_add = reports.iter().any(|r| { + r.blocks + .iter() + .any(|b| b.operations.iter().any(|op| add(op))) + }); + let saw_exp = reports.iter().any(|r| { + r.blocks + .iter() + .any(|b| b.operations.iter().any(|op| exp(op))) + }); + assert!( + saw_add && saw_exp, + "expected both ops to appear; got {reports:#?}" + ); + }); +} + +/// Multiple chained element-wise ops should still fuse into a single kernel. +#[test] +fn chained_elementwise_ops_fuse_together() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let a = TestTensor::<2>::ones([8, 8], &device); + let b = TestTensor::<2>::ones([8, 8], &device); + let c = TestTensor::<2>::ones([8, 8], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + // add → mul → exp, all element-wise on the same shape. + let out = ((a + b) * c).exp(); + let _ = out.into_data(); + device.sync().unwrap(); + + let reports = inspector.drain(); + + let target = reports + .iter() + .find(|r| r.total_operations() == 3) + .unwrap_or_else(|| panic!("no report with 3 ops; got {reports:#?}")); + + let block = target.assert_single_fused_block(); + assert!( + matches!( + block.kind, + BlockKind::Fused { + name: "ElementWise", + .. + } + ), + "expected a single ElementWise fused block, got {:?}", + block.kind, + ); + assert_eq!(block.operations.len(), 3); + }); +} + +/// A loop that materializes new tensors with `ones` and folds them into an ongoing +/// elementwise computation. Every op — Ones, MulScalar, Mul, Add across every +/// iteration — should collapse into a single fused ElementWise kernel. +/// +/// If this test fails, the panic message includes the fusion table so the split +/// points are visible directly in the test output. +#[test] +fn elementwise_and_creation_into_single_kernel() { + let stream = test_stream(); + stream.executes(|| { + const REPETITIONS: usize = 4; + let device = Default::default(); + + // Materialize the base tensor so it doesn't land in the inspector's first report. + let original = TestTensor::<2>::ones([8, 8], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + + let mut tmp = original.clone(); + for i in 0..REPETITIONS { + let new = TestTensor::<2>::ones([8, 8], &device) * (i as f32); + tmp = tmp.clone().mul(original.clone()) + new; + } + + let _ = tmp.into_data(); + device.sync().unwrap(); + + let reports = inspector.drain(); + assert!(!reports.is_empty(), "expected at least one fusion report"); + + // Per iteration: ones, mul-by-scalar, mul, add => 4 ops. `Drop` ops (memory + // bookkeeping) also land in the reports but aren't counted here. + let expected_ops = REPETITIONS * 4; + let is_drop = matchers::is_drop(); + + let tables: String = reports + .iter() + .map(|r| r.format_table()) + .collect::>() + .join("\n\n"); + + // Everything should land in a single report... + assert_eq!( + reports.len(), + 1, + "expected 1 report, got {}\n\n{tables}", + reports.len(), + ); + let report = &reports[0]; + + // ...as a single fused block... + assert_eq!( + report.blocks.len(), + 1, + "expected 1 block, got {}\n\n{tables}", + report.blocks.len(), + ); + let block = &report.blocks[0]; + + // ...produced by the ElementWise fuser... + assert_eq!( + block.fuser_name(), + Some("ElementWise"), + "block was not ElementWise\n\n{tables}", + ); + + // ...containing exactly the expected non-Drop ops. + let non_drop_ops = block.operations.iter().filter(|op| !is_drop(op)).count(); + assert_eq!( + non_drop_ops, expected_ops, + "expected {expected_ops} non-Drop ops in the fused block, got {non_drop_ops}\n\n{tables}", + ); + }); +} + +/// A lone view (`swap_dims`) whose original tensor isn't otherwise read must NOT be fused: it runs +/// eagerly as a cheap shape/stride update, so the following element-wise math reads a clean, +/// vectorizable layout instead of recomputing the transposed index on every element. +/// +/// Regression guard for the "view fix": previously the swap-dims was fused into the element-wise +/// kernel (the original registered as a fresh input, read *through* the transpose), which defeats +/// vectorization for no benefit when the original is used nowhere else. +#[test] +fn lone_view_is_not_fused_into_kernel() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + // Materialize the inputs so their `ones` init ops don't land in the inspected reports. + let a = TestTensor::<2>::ones([4, 8], &device); + let b = TestTensor::<2>::ones([8, 4], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + + // `swap_dims` is the only use of `a`. + let out = (a.swap_dims(0, 1) + b).exp(); + let _ = out.into_data(); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables: String = reports + .iter() + .map(|r| r.format_table()) + .collect::>() + .join("\n\n"); + + let is_swap = matchers::is_swap_dims(); + + // The view must never end up inside a fused kernel. + assert!( + reports + .iter() + .flat_map(|r| r.fused_blocks()) + .all(|blk| !blk.operations.iter().any(|op| is_swap(op))), + "swap_dims must not be fused (view fix):\n\n{tables}", + ); + + // ...and it must actually run, in an un-fused block. + assert!( + reports + .iter() + .flat_map(|r| r.unfused_blocks()) + .any(|blk| blk.operations.iter().any(|op| is_swap(op))), + "swap_dims should run eagerly in an un-fused block:\n\n{tables}", + ); + }); +} diff --git a/crates/burn-backend-tests/tests/fusion/inplace.rs b/crates/burn-backend-tests/tests/fusion/inplace.rs new file mode 100644 index 0000000..03cfd94 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/inplace.rs @@ -0,0 +1,236 @@ +//! Tests that fused kernels actually write in-place (`HandleOutput::Alias`) when they can. +//! +//! Aliasing produces identical values to allocating a fresh output, so value-based tests +//! cannot catch a regression — the whole mechanism silently died for over a year without a +//! single test failing. These tests assert on the alias counter from +//! [`burn_cubecl_fusion::inspect`]. +//! +//! The counter is process-global, so assertions are deltas (`>=`): concurrent tests can +//! only inflate it. "Does NOT alias" is asserted through values instead (a wrong alias +//! corrupts the still-referenced input). + +use super::*; +use burn_cubecl_fusion::inspect::inplace_alias_count; +use burn_fusion::inspect::{BlockKind, FusionInspector}; +use burn_tensor::{TensorData, Tolerance}; + +fn assert_all_fused(reports: &[burn_fusion::inspect::FusionReport], what: &str) { + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + assert!( + !reports.is_empty() + && reports + .iter() + .flat_map(|report| report.blocks.iter()) + .all(|block| matches!(block.kind, BlockKind::Fused { .. })), + "{what}: every block should be fused, otherwise the aliasing assertion is \ + meaningless\n\n{tables}", + ); +} + +/// A consuming element-wise chain must reuse the input buffer for its output. +/// +/// This covers the two launch-side regressions that killed aliasing: the `can_mut()` +/// reference miscount (container clone kept during planning) and the reference-layout +/// gate that rejected the first output of every block. +#[test] +fn consuming_elemwise_chain_writes_inplace() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let mut tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + // Warmup with the same (relative) graph: first execution may go through autotune, + // where benchmark trials run on forked contexts that (correctly) never alias. + tensor = tensor.add_scalar(0.0).mul_scalar(1.0); + let _ = tensor.clone().into_data(); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let before = inplace_alias_count(); + for _ in 0..4 { + tensor = tensor.add_scalar(1.0).mul_scalar(2.0); + // Sync so every iteration is its own execution (own launch plan). + let _ = tensor.clone().into_data(); + } + device.sync().unwrap(); + let after = inplace_alias_count(); + + tensor.into_data().assert_approx_eq::( + &TensorData::from([[46.0, 62.0], [78.0, 94.0]]), + Tolerance::default(), + ); + assert_all_fused(&inspector.drain(), "consuming elemwise chain"); + assert!( + after - before >= 4, + "each consuming iteration should alias its input buffer \ + (got {} aliases over 4 iterations)", + after - before, + ); + }); +} + +/// The consumed input keeps its buffer even when another input is broadcast: the output +/// (same shape as the consumed input) must alias it, and values must stay correct. This +/// is the shape that regressed with an invalid vector-size declaration on the alias. +#[test] +fn broadcast_elemwise_writes_inplace_and_computes_correctly() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let bias = TestTensor::<2>::from_data([[10.0, 20.0, 30.0, 40.0]], &device); + let mut tensor = + TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], &device); + tensor = tensor.add_scalar(0.0).mul_scalar(1.0); + let _ = tensor.clone().into_data(); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let before = inplace_alias_count(); + let out = (tensor + bias).mul_scalar(2.0); + out.into_data().assert_approx_eq::( + &TensorData::from([[22.0, 44.0, 66.0, 88.0], [30.0, 52.0, 74.0, 96.0]]), + Tolerance::default(), + ); + device.sync().unwrap(); + let after = inplace_alias_count(); + + assert_all_fused(&inspector.drain(), "broadcast elemwise"); + assert!( + after > before, + "the broadcast chain should alias the consumed input buffer", + ); + }); +} + +/// A tensor that stays alive (read-only use) must NOT be written in-place: its values +/// must survive the op. Asserted through values — the alias counter is process-global, +/// so "did not move" can't be checked under parallel tests, but a wrong alias would +/// corrupt the still-referenced input. +#[test] +fn read_only_input_is_not_written_inplace() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let mut tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + tensor = tensor.add_scalar(0.0).mul_scalar(1.0); + let _ = tensor.clone().into_data(); + device.sync().unwrap(); + + // `tensor` stays alive across the chain: its buffer must not be reused. + let out = tensor.clone().add_scalar(1.0).mul_scalar(2.0); + out.into_data().assert_approx_eq::( + &TensorData::from([[4.0, 6.0], [8.0, 10.0]]), + Tolerance::default(), + ); + device.sync().unwrap(); + + // The original tensor must be untouched. + tensor.into_data().assert_approx_eq::( + &TensorData::from([[1.0, 2.0], [3.0, 4.0]]), + Tolerance::default(), + ); + }); +} + +/// A consumed elemwise input feeding a fused reduce must alias the elemwise output. +/// +/// The aliased output becomes the read block's reference layout, so the reduce runner +/// resolves the reference against an aliased output argument: this covers the +/// output-arg reference form and the `TensorArg::Alias` arms of +/// `GlobalArgsLaunch::shape/strides`, which elemwise-only chains don't exercise. +#[test] +fn reduce_fusion_elemwise_output_writes_inplace() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let make_input = || { + let tensor = + TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], &device); + // Materialize the init op before the fused chain, so the chain starts from + // an existing buffer and the init doesn't show up in the inspected reports. + let _ = tensor.clone().into_data(); + device.sync().unwrap(); + tensor + }; + + // `out` stays alive across the reduce, so it is a global output of the fused + // read block, while `tensor` is consumed and can be aliased. The elemwise ops + // on both sides of the reduce each eliminate an intermediate, giving the reduce + // fuser an I/O saving over a plain elemwise fusion (which must stop at + // `sum_dim`) so the reduce optimization wins the block. + let run = |tensor: TestTensor<2>, offset: f32| { + let out = tensor.add_scalar(offset).mul_scalar(2.0); + let sum = out.clone().sum_dim(1); + let res = sum.mul_scalar(3.0).add_scalar(offset); + (out.into_data(), res.into_data()) + }; + + // Warmup with the same (relative) graph: first execution may go through autotune, + // where benchmark trials run on forked contexts that (correctly) never alias. + let _ = run(make_input(), 0.0); + device.sync().unwrap(); + + let input = make_input(); + let inspector = FusionInspector::install(stream); + let before = inplace_alias_count(); + let (out, res) = run(input, 1.0); + device.sync().unwrap(); + let after = inplace_alias_count(); + + out.assert_approx_eq::( + &TensorData::from([[4.0, 6.0, 8.0, 10.0], [12.0, 14.0, 16.0, 18.0]]), + Tolerance::default(), + ); + res.assert_approx_eq::( + &TensorData::from([[85.0], [181.0]]), + Tolerance::default(), + ); + assert_all_fused(&inspector.drain(), "reduce with consumed elemwise input"); + assert!( + after > before, + "the elemwise output feeding the fused reduce should alias the consumed input buffer", + ); + }); +} + +/// Repeated consuming updates (the KV-cache pattern) must alias on every iteration while +/// the cached execution plan is reused, and values must stay correct throughout. +#[test] +fn repeated_consuming_updates_alias_with_cached_plan() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let mut acc = TestTensor::<1>::zeros([32], &device); + acc = acc.add_scalar(0.0).mul_scalar(1.0); + let _ = acc.clone().into_data(); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + let before = inplace_alias_count(); + for step in 1..=3 { + let values = TestTensor::<1>::full([32], step as f32, &device); + acc = (acc + values).add_scalar(1.0); + let _ = acc.clone().into_data(); + } + device.sync().unwrap(); + let after = inplace_alias_count(); + + acc.into_data() + .assert_approx_eq::(&TensorData::from([9.0; 32]), Tolerance::default()); + assert_all_fused(&inspector.drain(), "repeated consuming updates"); + assert!( + after - before >= 3, + "each accumulation step should alias (got {} aliases over 3 steps)", + after - before, + ); + }); +} diff --git a/crates/burn-backend-tests/tests/fusion/int_bitwise.rs b/crates/burn-backend-tests/tests/fusion/int_bitwise.rs new file mode 100644 index 0000000..7111bdc --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/int_bitwise.rs @@ -0,0 +1,94 @@ +use super::*; +use burn_fusion::inspect::{BlockKind, FusionInspector, matchers}; +use burn_tensor::TensorData; + +#[test] +fn int_bitwise_chain_and_float_cast_fuse_into_single_kernel() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + + let lhs = TestTensorInt::<1>::from_data([13, 7, 3, 12], &device); + let rhs = TestTensorInt::<1>::from_data([11, 3, 5, 9], &device); + device.sync().unwrap(); + + let inspector = FusionInspector::install(stream); + + let bitwise = lhs + .clone() + .bitwise_xor(rhs) + .bitwise_and_scalar(7) + .bitwise_right_shift_scalar(1) + .bitwise_or(lhs.bitwise_not()); + + let output = bitwise.float().mul_scalar(0.5); + let dtype = output.dtype(); + output + .into_data() + .assert_eq(&TensorData::from([-6.5_f32, -3.0, -0.5, -6.5]), false); + device.sync().unwrap(); + + let reports = inspector.drain(); + let tables = reports + .iter() + .map(|report| report.format_table()) + .collect::>() + .join("\n\n"); + + let block = reports + .iter() + .flat_map(|report| report.blocks.iter()) + .find(|block| block.operations.iter().any(matchers::is_bitwise_xor_int())) + .unwrap_or_else(|| panic!("no bitwise fused block found\n\n{tables}")); + + assert!( + matches!( + block.kind, + BlockKind::Fused { + name: "ElementWise", + .. + } + ), + "expected ElementWise fused block, got {:?}\n\n{tables}", + block.kind, + ); + + assert!( + block.operations.iter().any(matchers::is_bitwise_xor_int()), + "BitwiseXor missing from fused block\n\n{tables}", + ); + assert!( + block + .operations + .iter() + .any(matchers::is_bitwise_and_scalar_int()), + "BitwiseAndScalar missing from fused block\n\n{tables}", + ); + assert!( + block + .operations + .iter() + .any(matchers::is_bitwise_right_shift_scalar_int()), + "BitwiseRightShiftScalar missing from fused block\n\n{tables}", + ); + assert!( + block.operations.iter().any(matchers::is_bitwise_not_int()), + "BitwiseNot missing from fused block\n\n{tables}", + ); + assert!( + block.operations.iter().any(matchers::is_bitwise_or_int()), + "BitwiseOr missing from fused block\n\n{tables}", + ); + assert!( + block.operations.iter().any(matchers::is_int_into_float()), + "IntoFloat missing from fused block\n\n{tables}", + ); + assert!( + block + .operations + .iter() + .any(matchers::is_mul_scalar_float(dtype)), + "MulScalar missing from fused block\n\n{tables}", + ); + }); +} diff --git a/crates/burn-backend-tests/tests/fusion/mod.rs b/crates/burn-backend-tests/tests/fusion/mod.rs new file mode 100644 index 0000000..2445787 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/mod.rs @@ -0,0 +1,23 @@ +pub use super::*; + +mod cat; +mod fusion_f16_broadcast; +mod fusion_f16_write_vectorization; +mod fusion_shape; +// Asserts on launch-level decisions from `burn-cubecl-fusion`, which only the `cube` +// feature pulls in; the rest of this suite also runs on non-cube fusion backends (flex). +#[cfg(feature = "cube")] +mod inplace; +mod int_bitwise; +mod reduce_broadcasted; + +use burn_tensor::StreamId; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Returns a unique `StreamId` for test isolation. +pub fn test_stream() -> StreamId { + static COUNTER: AtomicU64 = AtomicU64::new(1000); + StreamId { + value: COUNTER.fetch_add(1, Ordering::Relaxed), + } +} diff --git a/crates/burn-backend-tests/tests/fusion/reduce_broadcasted.rs b/crates/burn-backend-tests/tests/fusion/reduce_broadcasted.rs new file mode 100644 index 0000000..e083208 --- /dev/null +++ b/crates/burn-backend-tests/tests/fusion/reduce_broadcasted.rs @@ -0,0 +1,158 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn test_reduce_broadcasted_1() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_read = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_write = TestTensorInt::<1>::arange(0..4, &device) + .reshape([4, 1]) + .float(); + + // Forces previous tensors to be materialized. + device.sync().unwrap(); + + let x = tensor + fused_on_read.clone(); + let x = x.sum_dim(1); + + let x = x + fused_on_write; + + // Broadcast + let end = x + fused_on_read; + let actual = end.into_data(); + let expected = TensorData::from([ + [56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0], + [193.0, 194.0, 195.0, 196.0, 197.0, 198.0, 199.0, 200.0], + [330.0, 331.0, 332.0, 333.0, 334.0, 335.0, 336.0, 337.0], + [467.0, 468.0, 469.0, 470.0, 471.0, 472.0, 473.0, 474.0], + ]); + actual.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_reduce_broadcasted_2() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_read = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_write = TestTensorInt::<1>::arange(16..48, &device) + .reshape([4, 8]) + .float(); + // Second fuse on read + let y = TestTensorInt::<1>::arange(32..64, &device) + .reshape([4, 8]) + .float(); + + // Forces previous tensors to be materialized. + device.sync().unwrap(); + + let x = tensor + fused_on_read.clone(); + let x = x.sum_dim(1); + let x = x + fused_on_write; + let x = x.mean_dim(1); + + let end = x + y; + device.sync().unwrap(); + + let actual = end.into_data(); + let expected = TensorData::from([ + [107.5, 108.5, 109.5, 110.5, 111.5, 112.5, 113.5, 114.5], + [251.5, 252.5, 253.5, 254.5, 255.5, 256.5, 257.5, 258.5], + [395.5, 396.5, 397.5, 398.5, 399.5, 400.5, 401.5, 402.5], + [539.5, 540.5, 541.5, 542.5, 543.5, 544.5, 545.5, 546.5], + ]); + actual.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_reduce_broadcasted_3() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_read = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_write = TestTensorInt::<1>::arange(0..4, &device) + .reshape([4, 1]) + .float(); + // Second fuse on read + let y = TestTensorInt::<1>::arange(32..64, &device) + .reshape([4, 8]) + .float(); + + // Forces previous tensors to be materialized. + device.sync().unwrap(); + + let x = tensor + fused_on_read.clone(); + let x = x.sum_dim(1); + + let x = x + fused_on_write; + + // Broadcast + let x = x + fused_on_read; + // Second reduce + let x = x.mean_dim(1); + + let end = x + y; + let actual = end.into_data(); + let expected = TensorData::from([ + [91.5, 92.5, 93.5, 94.5, 95.5, 96.5, 97.5, 98.5], + [236.5, 237.5, 238.5, 239.5, 240.5, 241.5, 242.5, 243.5], + [381.5, 382.5, 383.5, 384.5, 385.5, 386.5, 387.5, 388.5], + [526.5, 527.5, 528.5, 529.5, 530.5, 531.5, 532.5, 533.5], + ]); + actual.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_reduce_broadcasted_4_reused_partial() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_read = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + let fused_on_write = TestTensorInt::<1>::arange(0..4, &device) + .reshape([4, 1]) + .float(); + let y = TestTensorInt::<1>::arange(32..64, &device) + .reshape([4, 8]) + .float(); + + // Forces previous tensors to be materialized. + device.sync().unwrap(); + + // In fusion we have to create a global buffer to keep the intermediate data for now. + let x_previous = tensor + fused_on_read; + let x = x_previous.clone().sum_dim(1); + + let x = x * fused_on_write; + + // Broadcast + let x = x + x_previous; + // Second reduce + let x = x.mean_dim(1); + + // Second fuse on read + let end = x + y; + let actual = end.into_data(); + let expected = TensorData::from([ + [39.0, 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0], + [247.0, 248.0, 249.0, 250.0, 251.0, 252.0, 253.0, 254.0], + [711.0, 712.0, 713.0, 714.0, 715.0, 716.0, 717.0, 718.0], + [ + 1431.0, 1432.0, 1433.0, 1434.0, 1435.0, 1436.0, 1437.0, 1438.0, + ], + ]); + actual.assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/graph_capture.rs b/crates/burn-backend-tests/tests/graph_capture.rs new file mode 100644 index 0000000..cc1db64 --- /dev/null +++ b/crates/burn-backend-tests/tests/graph_capture.rs @@ -0,0 +1,68 @@ +//! Graph capture/replay integration tests for the closure-based +//! [`capture`](burn_tensor::capture) API. +//! +//! On a backend with hardware graph support (CUDA/HIP) the capture records the +//! closure's launches and every replay is a single dispatch against the +//! original buffers; elsewhere replay falls back to re-running the closure. +//! Both paths must produce the eager result. +//! +//! Isolated in this test binary: `capture` arms device-global allocation state +//! (the persistent pool) between `graph_prepare` and `stop_capture`, so it must +//! not interleave with unrelated tests allocating on the same device. Tests are +//! additionally `#[serial]` so two captures never overlap. + +#![cfg(feature = "cube")] + +extern crate alloc; + +pub type FloatElem = f32; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +use burn_tensor::{Device, Tolerance}; +use serial_test::serial; + +/// Replaying a captured pure closure reproduces the eager result, repeatedly. +/// +/// Safety of the `replay` calls: the closure owns a clone of `input` (keeping +/// every captured buffer alive as long as the graph), and all replays and +/// output reads happen sequentially on this thread — nothing else touches the +/// graph's tensors. +#[test] +#[serial] +fn capture_replay_matches_eager() { + let device = Device::default(); + let input = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let expected = input.clone().mul_scalar(2.0).add_scalar(1.0).into_data(); + + let mut graph = burn_tensor::capture(&device, || input.clone().mul_scalar(2.0).add_scalar(1.0)); + + for _ in 0..3 { + let out = unsafe { graph.replay() }.clone().into_data(); + out.assert_approx_eq::(&expected, Tolerance::default()); + } +} + +/// The output handle is stable across replays on the hardware path: `output()` +/// returns the same tensor whose buffer each replay overwrites. +#[test] +#[serial] +fn capture_output_is_stable() { + let device = Device::default(); + let input = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0], &device); + let expected = input.clone().add_scalar(10.0).into_data(); + + let mut graph = burn_tensor::capture(&device, || input.clone().add_scalar(10.0)); + + // Safety: see `capture_replay_matches_eager`. + unsafe { graph.replay() }; + graph + .output() + .clone() + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/memory_cleaning.rs b/crates/burn-backend-tests/tests/memory_cleaning.rs new file mode 100644 index 0000000..ffe4afe --- /dev/null +++ b/crates/burn-backend-tests/tests/memory_cleaning.rs @@ -0,0 +1,1030 @@ +//! Tests that the fusion backend releases every tensor handle once the corresponding +//! [`FusionTensor`](burn_fusion::FusionTensor) wrappers are dropped; both for tensors +//! that live on a single stream and for tensors that are shared across streams. +//! +//! # Assertion Strategy +//! The assertion target is [`FusionInspector::new_handles_since_baseline`], which +//! returns every [`TensorId`](burn_ir::TensorId) that appeared in the +//! [`HandleContainer`](burn_ir::HandleContainer) *after* the baseline was set. +//! +//! Because the [`HandleContainer`](burn_ir::HandleContainer) is a global, per-device +//! registry, these tests call [`FusionInspector::enable_leak_detection`] to acquire +//! an exclusive lock on the device's handle state. This prevents concurrent tests +//! from allocating tensors that would otherwise appear as "leaks" in the baseline diff. +//! +//! # Threading and Concurrency +//! While execution reports are stream-isolated, memory handles are not. Therefore: +//! 1. These tests are isolated in this test binary to avoid false "leaks". +//! 2. Every test is marked `#[serial]` to ensure they take turns capturing the +//! device-wide handle state. +//! +//! Cross-stream cases are simulated with [`StreamId::executes`], which swaps the +//! per-thread stream id for the duration of a closure. This is fast and deterministic; +//! the real OS-thread path is exercised by `tensor/multi_threads.rs`. + +#![cfg(all(feature = "cube", feature = "fusion"))] + +extern crate alloc; + +pub type FloatElem = f32; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +#[path = "fusion/mod.rs"] +mod fusion { + + use super::*; + use burn_fusion::inspect::FusionInspector; + use burn_tensor::{Device, StreamId, Transaction}; + use serial_test::serial; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Returns a unique `StreamId` for test isolation. + pub fn test_stream() -> StreamId { + static COUNTER: AtomicU64 = AtomicU64::new(1000); + StreamId { + value: COUNTER.fetch_add(1, Ordering::Relaxed), + } + } + + /// Drain both the main stream and `peer` stream so the post-sync handle snapshot + /// reflects the full quiescent state. + fn sync_all(device: &Device, peer: StreamId) { + device.sync().unwrap(); + peer.executes(|| device.sync().unwrap()); + device.sync().unwrap(); + } + + /// Drive the inspector into a known state, then freeze that state as the baseline. + /// After this returns, [`FusionInspector::new_handles_since_baseline`] only reports + /// handles born during the test body — handles from other parallel tests that were + /// already live are excluded. + fn install_and_baseline(device: &Device, stream: StreamId) -> FusionInspector { + let inspector = FusionInspector::install(stream); + device.sync().unwrap(); + inspector.set_baseline(); + inspector + } + + /// Assert that no handles registered during the test's window are still alive. + #[track_caller] + fn assert_no_leaked_handles(inspector: &FusionInspector, context: &str) { + let leaked = inspector.new_handles_since_baseline(); + assert!( + leaked.is_empty(), + "{context}: {count} handle(s) registered during the test are still live: {leaked:?}", + count = leaked.len(), + ); + } + + /// Baseline: a tensor created and consumed on a single stream must not leave any + /// handles behind. + #[test] + #[serial] + fn single_stream_drop_frees_all_handles() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + { + let a = TestTensor::<2>::ones([4, 4], &device); + let b = TestTensor::<2>::ones([4, 4], &device); + let _ = (a + b).into_data(); + } + device.sync().unwrap(); + + assert_no_leaked_handles(&inspector, "single-stream drop + sync"); + }); + } + + /// `into_data` consumes its source — the source handle must be released when the + /// stream is drained. + #[test] + #[serial] + fn into_data_releases_source_handle() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + let a = TestTensor::<2>::ones([4, 4], &device); + let _ = a.into_data(); + device.sync().unwrap(); + + assert_no_leaked_handles(&inspector, "into_data + sync"); + }); + } + + /// Cloning a tensor does not allocate a new handle; dropping the last `Arc`-style + /// reference should release exactly one handle. + #[test] + #[serial] + fn clone_then_drop_frees_handle_once() { + let stream = test_stream(); + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + let a = TestTensor::<2>::ones([4, 4], &device); + let b = a.clone(); + device.sync().unwrap(); + + drop(a); + drop(b); + device.sync().unwrap(); + + assert_no_leaked_handles(&inspector, "clone + drop + sync"); + }); + } + + /// Cross-stream sharing happy path: tensor created on the main stream, used and + /// consumed on a peer stream, then dropped on the main stream. After every stream is + /// drained, no handles must remain. + #[test] + #[serial] + fn cross_stream_shared_tensor_released() { + let stream = test_stream(); + let peer = test_stream(); + + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + // Materialize `a` so its `Ones` init op doesn't get rolled into the cross-stream + // analysis we're trying to observe. + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Performing an op on `a` from peer stream is what flags `a` as shared inside + // `SharedTensors::analyse` (its creation stream is main, current is peer). + let a_for_peer = a.clone(); + peer.executes(|| { + let _ = (a_for_peer * 2.0).into_data(); + }); + + drop(a); + sync_all(&device, peer); + + assert_no_leaked_handles( + &inspector, + "shared tensor handle should be released once every sharing stream is drained", + ); + }); + } + + /// The owner stream releases its reference *before* the peer stream catches up. + /// The shared-tensor bookkeeping must defer the release until the peer drains; once + /// it does, the handle must be reclaimed. + #[test] + #[serial] + fn owner_drops_before_peer_finishes() { + let stream = test_stream(); + let peer = test_stream(); + + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Queue work on the peer stream that depends on `a`, but do not drain yet so the + // peer keeps a pending reference. + let a_for_peer = a.clone(); + let peer_pending = peer.executes(|| a_for_peer * 2.0); + + // Owner releases its reference first, while peer still holds a pending op. + drop(a); + + // Peer eventually catches up. + peer.executes(|| { + let _ = peer_pending.into_data(); + }); + + sync_all(&device, peer); + + assert_no_leaked_handles( + &inspector, + "deferred shared-tensor drop should fire once the peer stream catches up", + ); + }); + } + + /// The peer stream fully consumes its clone and closes *before* the owner drops its + /// reference. The owner's later drop must still free the handle and not get stuck + /// waiting on a stream that no longer exists. + #[test] + #[serial] + fn peer_closes_before_owner_drops() { + let stream = test_stream(); + let peer = test_stream(); + + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + let a_for_peer = a.clone(); + peer.executes(|| { + let _ = (a_for_peer * 2.0).into_data(); + }); + // Peer is now closed: every var consumed, queue empty. + + drop(a); + sync_all(&device, peer); + + assert_no_leaked_handles( + &inspector, + "owner-side drop after peer closure should still free the handle", + ); + }); + } + + /// Stress: many iterations of cross-stream sharing back-to-back. The handle count + /// must return to baseline at the end — catches regressions where each iteration + /// leaks one or more handles (the failure mode the `drop-triggered drain` warning at + /// `MultiStream::register` is meant to prevent). + #[test] + #[serial] + fn cross_stream_loop_no_leak() { + const REPS: usize = 8; + let stream = test_stream(); + let peer = test_stream(); + + stream.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, stream); + + for i in 0..REPS { + let a = TestTensor::<2>::ones([4, 4], &device) * (i as f32); + device.sync().unwrap(); + + let a_for_peer = a.clone(); + peer.executes(|| { + let _ = (a_for_peer + (i as f32)).into_data(); + }); + + let _ = (a * 3.0).into_data(); + } + + sync_all(&device, peer); + + assert_no_leaked_handles( + &inspector, + "handle set must return to baseline after repeated cross-stream cleanup", + ); + }); + } + + /// Drain `extra` peer streams in addition to the main stream. Used by tests that + /// share a single tensor across more than two streams. + fn sync_streams(device: &Device, extra: &[StreamId]) { + device.sync().unwrap(); + for s in extra { + s.executes(|| device.sync().unwrap()); + } + device.sync().unwrap(); + } + + /// A tensor created on the owner stream is consumed by three independent peer streams + /// concurrently (no peer-to-peer chain). Every peer must observe the same data and the + /// shared handle must be reclaimed after every stream has drained. + #[test] + #[serial] + fn shared_tensor_three_peers_released() { + let owner = test_stream(); + let p1 = test_stream(); + let p2 = test_stream(); + let p3 = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Each peer takes its own clone — each cross-stream `clone()` registers a + // shared view aliasing `a`'s handle under a fresh tensor id. + let c1 = a.clone(); + let c2 = a.clone(); + let c3 = a.clone(); + p1.executes(|| { + let _ = (c1 * 2.0).into_data(); + }); + p2.executes(|| { + let _ = (c2 + 1.0).into_data(); + }); + p3.executes(|| { + let _ = (c3 - 1.0).into_data(); + }); + + drop(a); + sync_streams(&device, &[p1, p2, p3]); + + assert_no_leaked_handles( + &inspector, + "handle should be freed once every peer stream drains its alias", + ); + }); + } + + /// Re-sharing path: tensor flows owner → peer → grandpeer. The grandpeer takes its + /// clone from a tensor whose home stream is already `peer`, so the share path is + /// triggered a second time from a different source stream. + #[test] + #[serial] + fn chained_share_across_three_streams() { + let owner = test_stream(); + let peer = test_stream(); + let grandpeer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // First hop: owner -> peer. The clone happens on `owner` (regular clone), + // then the multiplication on `peer` consumes the clone and triggers the + // cross-stream `into_ir` path. + let a_for_peer = a.clone(); + let on_peer = peer.executes(|| a_for_peer * 2.0); + drop(a); + + // Second hop: peer -> grandpeer. `on_peer.stream == peer`; cloning while + // current is grandpeer fires `shared_view` with `peer` as source. + let on_peer_for_grand = grandpeer.executes(|| on_peer.clone()); + grandpeer.executes(|| { + let _ = (on_peer_for_grand + 1.0).into_data(); + }); + + // The peer tensor stays alive until here so peer's queue still references it. + peer.executes(|| { + let _ = on_peer.into_data(); + }); + + sync_streams(&device, &[peer, grandpeer]); + + assert_no_leaked_handles( + &inspector, + "handle set must return to baseline after a chained owner→peer→grandpeer share", + ); + }); + } + + /// Share a tensor that was *computed* on the owner stream rather than freshly + /// initialised. This makes sure the drain-on-first-share path correctly flushes + /// pending ops before exposing the handle to the peer. + #[test] + #[serial] + fn share_computed_tensor_flushes_pending_ops() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + // Build a pending computation on owner without draining it. + let a = TestTensor::<2>::ones([4, 4], &device); + let b = (a.clone() + 1.0) * 3.0; // pending: (1 + 1) * 3 = 6 + // Intentionally do NOT sync — peer's first-share must drain the source. + + let b_for_peer = b.clone(); + let peer_data = peer.executes(|| (b_for_peer + 0.0).into_data()); + + // 6 + 0 = 6 + peer_data.assert_approx_eq::( + &burn_tensor::TensorData::from([ + [6.0_f32, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + ]), + burn_tensor::Tolerance::default(), + ); + + drop(a); + drop(b); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "computed-tensor share must flush pending ops and leave no handles behind", + ); + }); + } + + /// Peer takes a clone but never executes any op on it before it is dropped. + /// The aliased handle must still be reclaimed once the unused clone is freed. + #[test] + #[serial] + fn share_then_drop_unused_clone() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Move the clone over to the peer stream but don't touch it. + let unused = peer.executes(|| a.clone()); + peer.executes(|| drop(unused)); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "unused cross-stream clone must not leak its aliased handle", + ); + }); + } + + /// Owner keeps using the tensor on its own stream after exposing a clone to the + /// peer. Both sides must finish cleanly and the handle must be released only after + /// the last reader drains. + #[test] + #[serial] + fn owner_keeps_using_after_share() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Hand a clone to peer. + let a_for_peer = a.clone(); + peer.executes(|| { + let _ = (a_for_peer * 5.0).into_data(); + }); + + // Owner continues doing work on `a` after the share point. + let owner_result = (a.clone() + 2.0) * 3.0; + let _ = owner_result.into_data(); + drop(a); + + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "owner must be able to keep using its tensor after sharing a clone", + ); + }); + } + + /// Two distinct tensors are independently shared between the same pair of streams. + /// Each share path must be tracked separately — dropping one must not affect the + /// other. + #[test] + #[serial] + fn multiple_distinct_shared_tensors_same_pair() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + let b = TestTensor::<2>::ones([4, 4], &device) * 2.0; + device.sync().unwrap(); + + let a_for_peer = a.clone(); + let b_for_peer = b.clone(); + + peer.executes(|| { + let _ = (a_for_peer + b_for_peer).into_data(); + }); + + drop(a); + drop(b); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "two independently shared tensors must each be released", + ); + }); + } + + /// Int tensors travel through the same shared-view path as floats, but go through + /// a different dtype branch in the runner. Cover that branch explicitly. + #[test] + #[serial] + fn int_tensor_cross_stream_released() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensorInt::<1>::arange(0..16, &device); + device.sync().unwrap(); + + let a_for_peer = a.clone(); + peer.executes(|| { + let _ = (a_for_peer + 1).into_data(); + }); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "int tensor sharing must release its aliased handle", + ); + }); + } + + /// The peer performs several chained ops on the shared tensor. The shared handle + /// is consumed (read-only) once on the peer side then dropped; we verify the full + /// chain executes without leaks. + #[test] + #[serial] + fn peer_runs_chain_on_shared_tensor() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + let a_for_peer = a.clone(); + peer.executes(|| { + let x = a_for_peer * 2.0; + let x = x + 1.0; + let x = x * 4.0; + let _ = x.into_data(); + }); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "multi-step chain on a shared tensor must not leak", + ); + }); + } + + /// Owner sends a clone to peer; peer immediately sends a clone back to owner. The + /// round trip exercises shared-view in both directions on a single underlying + /// allocation. + #[test] + #[serial] + fn share_round_trip_owner_peer_owner() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // owner -> peer + let a_for_peer = a.clone(); + let on_peer = peer.executes(|| a_for_peer + 1.0); + + // peer -> owner (cross-stream clone the other way) + let back_to_owner = on_peer.clone(); + + // Both sides consume their copy. + let _ = (back_to_owner * 2.0).into_data(); + peer.executes(|| { + let _ = on_peer.into_data(); + }); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "round-trip share owner→peer→owner must leave no handles behind", + ); + }); + } + + /// `Tensor::into_data` consumes the tensor. When the consuming call happens on a + /// stream different from the tensor's home stream, `into_ir` (not `clone`) must + /// itself trigger the shared-view aliasing. + #[test] + #[serial] + fn into_data_cross_stream_no_explicit_clone() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // No explicit `.clone()` here — `a` is moved into the peer's closure and + // consumed there. The cross-stream `into_ir` path must do the right thing. + peer.executes(|| { + let _ = a.into_data(); + }); + + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "cross-stream into_ir must alias the handle without an explicit clone", + ); + }); + } + + /// Same source tensor shared multiple times *to the same peer stream*. The + /// first share triggers a drain of the source; subsequent shares of the same + /// source must skip the drain (`MultiStream::resolved` short-circuit) but still + /// register a fresh aliased handle. + #[test] + #[serial] + fn repeated_share_same_source_same_peer() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Take three clones of the same `a`, all consumed on the peer stream. + let c1 = a.clone(); + let c2 = a.clone(); + let c3 = a.clone(); + peer.executes(|| { + let _ = (c1 + 1.0).into_data(); + let _ = (c2 + 2.0).into_data(); + let _ = (c3 + 3.0).into_data(); + }); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "repeated shares of the same source to the same peer must not leak", + ); + }); + } + + /// The peer must observe the values the owner produced. Most other tests only + /// check that handles are released; this one pins down data-side correctness across + /// the shared-view aliasing boundary. + #[test] + #[serial] + fn peer_observes_owner_values_after_share() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + // Owner stages a non-trivial pending computation: (1 + 1) * 5 = 10, then + // shares the result with the peer *without* a manual sync. The first-share + // drain must flush the chain so the peer sees 10, not garbage. + let a = TestTensor::<2>::ones([2, 3], &device); + let b = (a.clone() + 1.0) * 5.0; + let b_for_peer = b.clone(); + + let peer_data = peer.executes(|| (b_for_peer + 0.5).into_data()); + peer_data.assert_approx_eq::( + &burn_tensor::TensorData::from([[10.5_f32, 10.5, 10.5], [10.5, 10.5, 10.5]]), + burn_tensor::Tolerance::default(), + ); + + // Owner still sees the same upstream values via its own copy of `b`. + let owner_data = b.into_data(); + owner_data.assert_approx_eq::( + &burn_tensor::TensorData::from([[10.0_f32, 10.0, 10.0], [10.0, 10.0, 10.0]]), + burn_tensor::Tolerance::default(), + ); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "peer/owner views of the shared tensor must agree numerically and not leak", + ); + }); + } + + /// Bool tensors take a separate dtype branch through `register_bool_tensor` and the + /// boolean ops; sharing must route through the same path without leaks. + #[test] + #[serial] + fn bool_tensor_cross_stream_released() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + // Build a bool tensor on owner: `a == a` is all-true. + let mask = a.clone().equal(a.clone()); + device.sync().unwrap(); + + let mask_for_peer = mask.clone(); + peer.executes(|| { + let any_true = mask_for_peer.any(); + let data = any_true.into_data(); + let v: bool = data.iter::().next().expect("scalar bool"); + assert!(v, "shared bool tensor must remain all-true on peer side"); + }); + + drop(a); + drop(mask); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "bool tensor sharing must release its aliased handle", + ); + }); + } + + /// A sliced view is a separate `FusionTensor` with its own id that depends on the + /// parent's data. Sharing the view across streams must alias the *view's* handle + /// (which is materialised by the slice op on the source stream) — not the parent's. + #[test] + #[serial] + fn sliced_view_cross_stream_released() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::from_floats( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0], + ], + &device, + ); + + // Slice on owner: 2x2 top-left block. The slice op produces a new tensor. + let slice = a.clone().slice([0..2, 0..2]); + let slice_for_peer = slice.clone(); + + // Peer doubles every element of the view and reads it back. + let peer_data = peer.executes(|| (slice_for_peer * 2.0).into_data()); + peer_data.assert_approx_eq::( + &burn_tensor::TensorData::from([[2.0_f32, 4.0], [10.0, 12.0]]), + burn_tensor::Tolerance::default(), + ); + + drop(a); + drop(slice); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "sliced-view sharing must release both parent and view aliases", + ); + }); + } + + /// High fan-out: a single source tensor is shared with eight peer streams. This + /// stresses the `MultiStream::resolved` short-circuit (one drain, many aliased + /// handles) and the handle-removal path when every alias eventually drops. + #[test] + #[serial] + fn high_fan_out_eight_peers_released() { + let owner = test_stream(); + let peers: Vec = (0..8).map(|_| test_stream()).collect(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Each peer takes its own clone and consumes it. + let clones: Vec<_> = (0..peers.len()).map(|_| a.clone()).collect(); + for (peer, c) in peers.iter().zip(clones) { + let peer = *peer; + peer.executes(|| { + let _ = (c + 1.0).into_data(); + }); + } + + drop(a); + sync_streams(&device, &peers); + + assert_no_leaked_handles( + &inspector, + "many fan-out peers must each release their aliased handle", + ); + }); + } + + /// Real OS-thread cross-stream sharing with leak detection. The other cross-stream + /// tests use [`StreamId::executes`] to swap the per-thread id in place; this one + /// actually spawns threads (the production code path) and checks that handles are + /// reclaimed after every thread joins. + #[test] + #[serial] + fn real_threads_cross_stream_no_leak() { + let owner = test_stream(); + owner.executes(|| { + let device: Device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Hand each worker thread its own clone. Cross-thread `clone()` is exactly + // the path `FusionTensor::clone` takes in user code that spawns threads. + let mut handles = Vec::new(); + for i in 0..4 { + let c = a.clone(); + let dev = device.clone(); + handles.push(std::thread::spawn(move || { + let _ = (c + i as f32).into_data(); + dev.sync().unwrap(); + })); + } + for h in handles { + h.join().unwrap(); + } + + drop(a); + device.sync().unwrap(); + + assert_no_leaked_handles( + &inspector, + "real-thread cross-stream sharing must not leak handles", + ); + }); + } + + /// Two peer streams *both* take a clone of the same source, and one of them + /// re-shares its clone onward to a third stream. Exercises the case where a + /// non-owner stream is itself the source of a `tag_shared_view` call. + #[test] + #[serial] + fn peer_reshares_to_third_stream() { + let owner = test_stream(); + let p1 = test_stream(); + let p2 = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // owner -> p1 (consumed) + let c1 = a.clone(); + let on_p1 = p1.executes(|| c1 + 1.0); // on_p1.stream == p1, value = 2 + + // p1 -> p2 (re-share); the source stream for tag_shared_view is p1, not owner. + let on_p1_clone = p2.executes(|| on_p1.clone()); + let p2_data = p2.executes(|| (on_p1_clone * 3.0).into_data()); + p2_data.assert_approx_eq::( + &burn_tensor::TensorData::from([ + [6.0_f32, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + [6.0, 6.0, 6.0, 6.0], + ]), + burn_tensor::Tolerance::default(), + ); + + // Drain p1's pending tensor. + p1.executes(|| { + let _ = on_p1.into_data(); + }); + + drop(a); + sync_streams(&device, &[p1, p2]); + + assert_no_leaked_handles( + &inspector, + "peer-as-source share path must release every alias", + ); + }); + } + + /// Both streams have pending (un-drained) work referencing the shared tensor when + /// the top-level test syncs. The drain path on either stream must converge. + #[test] + #[serial] + fn concurrent_pending_work_on_both_streams() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + let a = TestTensor::<2>::ones([4, 4], &device); + device.sync().unwrap(); + + // Build a pending op on peer, do not drain. + let a_for_peer = a.clone(); + let peer_pending = peer.executes(|| a_for_peer * 2.0); + + // Build a pending op on owner, do not drain. + let owner_pending = a.clone() + 5.0; + + // Now drop `a` and finalise both sides via a coordinated sync. + drop(a); + + peer.executes(|| { + let _ = peer_pending.into_data(); + }); + let _ = owner_pending.into_data(); + + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "both streams holding pending work on a shared tensor must converge cleanly", + ); + }); + } + + #[test] + #[serial] + fn transaction_cross_stream_no_reentrant_panic() { + let owner = test_stream(); + let peer = test_stream(); + + owner.executes(|| { + let device = Default::default(); + let inspector = install_and_baseline(&device, owner); + + // Create and compute a tensor on the owner stream, leave it un-drained. + let a = TestTensor::<2>::ones([4, 4], &device); + let b = (a.clone() + 1.0) * 2.0; + + // Execute the transaction from the peer stream, `b.stream == owner` but + // `current == peer`, so `resolve_tensor_float` hits the cross-stream mismatch + // and previously called `into_ir()` inside the `submit_blocking` closure, + // triggering the re-entrancy panic. + let [data] = peer.executes(|| { + Transaction::default() + .register(b) + .execute() + .try_into() + .unwrap() + }); + + data.assert_approx_eq::( + &burn_tensor::TensorData::from([ + [4.0_f32, 4.0, 4.0, 4.0], + [4.0, 4.0, 4.0, 4.0], + [4.0, 4.0, 4.0, 4.0], + [4.0, 4.0, 4.0, 4.0], + ]), + burn_tensor::Tolerance::default(), + ); + + drop(a); + sync_streams(&device, &[peer]); + + assert_no_leaked_handles( + &inspector, + "transaction executed cross-stream must not panic or leak handles", + ); + }); + } +} diff --git a/crates/burn-backend-tests/tests/tensor.rs b/crates/burn-backend-tests/tests/tensor.rs new file mode 100644 index 0000000..5cbb8f3 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor.rs @@ -0,0 +1,21 @@ +//! Burn backend tensor tests. + +#![recursion_limit = "256"] +#![allow(clippy::single_range_in_vec_init)] + +extern crate alloc; + +pub type FloatElem = f32; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +#[path = "common/tensor.rs"] +mod tensor; + +#[cfg(all(feature = "cube", feature = "fusion"))] +#[path = "fusion/mod.rs"] +mod fusion; diff --git a/crates/burn-backend-tests/tests/tensor/bool/mod.rs b/crates/burn-backend-tests/tests/tensor/bool/mod.rs new file mode 100644 index 0000000..5d0928d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/mod.rs @@ -0,0 +1,3 @@ +pub use super::*; // re-export test types + +mod ops; diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/all.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/all.rs new file mode 100644 index 0000000..e7439d6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/all.rs @@ -0,0 +1,34 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_all() { + let tensor = TestTensorBool::<2>::from([[false, true, false], [true, true, true]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorBool::<2>::from([[true, true, true], [true, true, true]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_all_dim() { + let tensor = TestTensorBool::<2>::from([[false, true, false], [true, true, true]]); + let data_actual = tensor.all_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_all_with_bool_from_lower_equal() { + let tensor1 = TestTensor::<2>::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]) + 1e-6; + let tensor2 = TestTensor::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]) + 1e-6; + + let ge = tensor1.lower_equal(tensor2); + let all = ge.clone().all(); + + TensorData::from([true]).assert_eq(&all.clone().into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/any.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/any.rs new file mode 100644 index 0000000..fd414fa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/any.rs @@ -0,0 +1,23 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_any() { + let tensor = TestTensorBool::<2>::from([[false, false, false], [true, true, false]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorBool::<2>::from([[false, false, false], [false, false, false]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_any_dim() { + let tensor = TestTensorBool::<2>::from([[false, false, false], [true, true, false]]); + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/argwhere_nonzero.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/argwhere_nonzero.rs new file mode 100644 index 0000000..2e2cb26 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/argwhere_nonzero.rs @@ -0,0 +1,107 @@ +use super::*; +use alloc::vec::Vec; +use burn_tensor::{Shape, TensorData}; + +#[test] +fn test_argwhere_1d() { + let tensor = TestTensorBool::<1>::from([false, true, false, true, true]); + let output = tensor.argwhere(); + + output + .into_data() + .assert_eq(&TensorData::from([[1], [3], [4]]), false); +} + +#[test] +fn test_argwhere_2d() { + let tensor = TestTensorBool::<2>::from([[false, false], [false, true], [true, true]]); + let output = tensor.argwhere(); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 1], [2, 0], [2, 1]]), false); +} + +#[test] +fn test_argwhere_3d() { + let tensor = TestTensorBool::<3>::from([ + [[false, false, false], [false, true, false]], + [[true, false, true], [true, true, false]], + ]); + let output = tensor.argwhere(); + + output.into_data().assert_eq( + &TensorData::from([[0, 1, 1], [1, 0, 0], [1, 0, 2], [1, 1, 0], [1, 1, 1]]), + false, + ); +} + +#[test] +fn test_nonzero_1d() { + let tensor = TestTensorBool::<1>::from([false, true, false, true, true]); + let data_actual = tensor + .nonzero() + .into_iter() + .map(|t| t.into_data()) + .collect::>(); + + assert_eq!(data_actual.len(), 1); + data_actual[0].assert_eq(&TensorData::from([1, 3, 4]), false); +} + +#[test] +fn test_nonzero_2d() { + // 2-D tensor + let tensor = TestTensorBool::<2>::from([[false, false], [false, true], [true, true]]); + let data_actual = tensor + .nonzero() + .into_iter() + .map(|t| t.into_data()) + .collect::>(); + let data_expected = [TensorData::from([1, 2, 2]), TensorData::from([1, 0, 1])]; + + assert_eq!(data_actual.len(), 2); + for (idx, actual) in data_actual.iter().enumerate() { + actual.assert_eq(&data_expected[idx], false) + } +} + +#[test] +fn test_nonzero_3d() { + // 3-D tensor + let tensor = TestTensorBool::<3>::from([ + [[false, false, false], [false, true, false]], + [[true, false, true], [true, true, false]], + ]); + let data_actual = tensor + .nonzero() + .into_iter() + .map(|t| t.into_data()) + .collect::>(); + let data_expected = [ + TensorData::from([0, 1, 1, 1, 1]), + TensorData::from([1, 0, 0, 1, 1]), + TensorData::from([1, 0, 2, 0, 1]), + ]; + + assert_eq!(data_actual.len(), 3); + for (idx, actual) in data_actual.iter().enumerate() { + actual.assert_eq(&data_expected[idx], false) + } +} + +#[test] +fn test_nonzero_empty() { + let tensor = TestTensorBool::<1>::from([false, false, false, false, false]); + let output = tensor.nonzero(); + + assert_eq!(output.len(), 0); +} + +#[test] +fn test_argwhere_empty() { + let tensor = TestTensorBool::<1>::from([false, false, false, false, false]); + let output = tensor.argwhere(); + + assert_eq!(output.shape(), Shape::new([0, 1])); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/cast.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/cast.rs new file mode 100644 index 0000000..8070359 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/cast.rs @@ -0,0 +1,59 @@ +use super::*; +use burn_tensor::{DType, FloatDType, IntDType, TensorData}; + +#[test] +fn cast_bool_to_int_with_dtype() { + let tensor = TestTensorBool::<2>::from([[true, false, true], [false, false, true]]); + + let output = tensor.cast(IntDType::I32); + + assert_eq!(output.dtype(), DType::I32); + let expected = TensorData::from([[1i32, 0, 1], [0, 0, 1]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_bool_to_float_with_dtype() { + let tensor = TestTensorBool::<2>::from([[true, false, true], [false, false, true]]); + + let output = tensor.cast(FloatDType::F32); + + assert_eq!(output.dtype(), DType::F32); + let expected = TensorData::from([[1.0f32, 0.0, 1.0], [0.0, 0.0, 1.0]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_bool_into_int_flipped() { + // [T, F, T, F] flipped -> [F, T, F, T] -> int [0, 1, 0, 1] + let t = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + + let output = t.int(); + + output + .into_data() + .assert_eq(&TensorData::from([0i64, 1, 0, 1]), false); +} + +#[test] +fn test_bool_into_float_flipped() { + let t = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + + let output = t.float(); + + output + .into_data() + .assert_eq(&TensorData::from([0.0f32, 1.0, 0.0, 1.0]), false); +} + +#[test] +fn test_bool_into_int_flipped_2d() { + // [[T, F], [F, T]] flipped on axis 0 -> [[F, T], [T, F]] -> int [[0, 1], [1, 0]] + let t = TestTensorBool::<2>::from([[true, false], [false, true]]).flip([0]); + + let output = t.int(); + + output + .into_data() + .assert_eq(&TensorData::from([[0i64, 1], [1, 0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/cat.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/cat.rs new file mode 100644 index 0000000..3dcda65 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/cat.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_cat_ops_bool() { + let device = Default::default(); + let tensor_1 = TestTensorBool::<2>::from_data([[false, true, true]], &device); + let tensor_2 = TestTensorBool::<2>::from_data([[true, true, false]], &device); + + let output = Tensor::cat(vec![tensor_1, tensor_2], 0); + + output.into_data().assert_eq( + &TensorData::from([[false, true, true], [true, true, false]]), + false, + ); +} + +#[test] +fn should_support_cat_with_empty_tensor_bool() { + let device = Default::default(); + let tensor_1 = TestTensorBool::<2>::from_data([[true, false, true]], &device); + let tensor_2: TestTensorBool<2> = TestTensorBool::empty([1, 0], &device); + + let output = Tensor::cat(vec![tensor_1, tensor_2], 1); + + output + .into_data() + .assert_eq(&TensorData::from([[true, false, true]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/comparison.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/comparison.rs new file mode 100644 index 0000000..1d3e49d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/comparison.rs @@ -0,0 +1,112 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_bool_equal() { + let data_1 = TensorData::from([[false, true, true], [true, false, true]]); + let data_2 = TensorData::from([[false, false, true], [false, true, true]]); + let device = Default::default(); + let tensor_1 = TestTensorBool::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorBool::<2>::from_data(data_2, &device); + + let data_actual_cloned = tensor_1.clone().equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.equal(tensor_2); + + let data_expected = TensorData::from([[true, false, true], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn should_support_bool_not_equal() { + let data_1 = TensorData::from([[false, true, true], [true, false, true]]); + let data_2 = TensorData::from([[false, false, true], [false, true, true]]); + let device = Default::default(); + let tensor_1 = TestTensorBool::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorBool::<2>::from_data(data_2, &device); + + let data_actual_cloned = tensor_1.clone().not_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.not_equal(tensor_2); + + let data_expected = TensorData::from([[false, true, false], [true, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn should_support_bool_equal_broadcast() { + // [2, 2, 2] equal [1, 2, 2] broadcasts along the leading dim. + let device = Default::default(); + let tensor_1 = TestTensorBool::<3>::from_data( + TensorData::from([ + [[true, false], [false, true]], + [[true, true], [false, false]], + ]), + &device, + ); + let tensor_2 = + TestTensorBool::<2>::from_data(TensorData::from([[true, false], [false, true]]), &device); + + let actual = tensor_1.equal(tensor_2.unsqueeze::<3>()).into_data(); + let expected = TensorData::from([[[true, true], [true, true]], [[true, false], [true, false]]]); + expected.assert_eq(&actual, false); +} + +#[test] +fn should_support_bool_not_equal_broadcast() { + // [2, 2, 2] not_equal [1, 2, 2] broadcasts along the leading dim. + let device = Default::default(); + let tensor_1 = TestTensorBool::<3>::from_data( + TensorData::from([ + [[true, false], [false, true]], + [[true, true], [false, false]], + ]), + &device, + ); + let tensor_2 = + TestTensorBool::<2>::from_data(TensorData::from([[true, false], [false, true]]), &device); + + let actual = tensor_1.not_equal(tensor_2.unsqueeze::<3>()).into_data(); + let expected = TensorData::from([ + [[false, false], [false, false]], + [[false, true], [false, true]], + ]); + expected.assert_eq(&actual, false); +} + +#[test] +fn should_support_bool_not() { + let data_1 = TensorData::from([[false, true, true], [true, true, false]]); + let tensor_1 = TestTensorBool::<2>::from_data(data_1, &Default::default()); + + let data_actual_cloned = tensor_1.clone().bool_not(); + let data_actual_inplace = tensor_1.bool_not(); + + let data_expected = TensorData::from([[true, false, false], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_bool_equal_elem() { + let tensor_1 = TestTensorBool::<2>::from([[true, false, true], [false, true, false]]); + + let data_actual_cloned = tensor_1.clone().equal_elem(false); + let data_actual_inplace = tensor_1.equal_elem(false); + + let data_expected = TensorData::from([[false, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_bool_not_equal_elem() { + let tensor_1 = TestTensorBool::<2>::from([[true, false, true], [false, true, false]]); + + let data_actual_cloned = tensor_1.clone().not_equal_elem(true); + let data_actual_inplace = tensor_1.not_equal_elem(true); + + let data_expected = TensorData::from([[false, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/create_like.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/create_like.rs new file mode 100644 index 0000000..accd268 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/create_like.rs @@ -0,0 +1,34 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_zeros_like() { + let tensor = TestTensorBool::<3>::from([ + [[false, true, false], [true, true, true]], + [[false, false, false], [true, true, false]], + ]); + + let tensor = tensor.zeros_like(); + let expected = TensorData::from([ + [[false, false, false], [false, false, false]], + [[false, false, false], [false, false, false]], + ]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_ones_like() { + let tensor = TestTensorBool::<3>::from([ + [[false, true, false], [true, true, true]], + [[false, false, false], [true, true, false]], + ]); + + let tensor = tensor.ones_like(); + let expected = TensorData::from([ + [[true, true, true], [true, true, true]], + [[true, true, true], [true, true, true]], + ]); + + tensor.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/expand.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/expand.rs new file mode 100644 index 0000000..c5fae07 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/expand.rs @@ -0,0 +1,30 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn expand_2d_bool() { + let tensor = TestTensorBool::<1>::from([false, true, false]); + let expanded_tensor = tensor.expand([3, 3]); + + let expected_data = TensorData::from([ + [false, true, false], + [false, true, false], + [false, true, false], + ]); + + expanded_tensor.into_data().assert_eq(&expected_data, false); +} + +#[test] +fn expand_bool_after_flip() { + // Non-palindromic input so the flip actually changes the data: + // [true, false, false] flipped -> [false, false, true]. + let tensor = TestTensorBool::<1>::from([true, false, false]).flip([0]); + + let output = tensor.expand([2, 3]); + + output.into_data().assert_eq( + &TensorData::from([[false, false, true], [false, false, true]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/flip.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/flip.rs new file mode 100644 index 0000000..bda4941 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/flip.rs @@ -0,0 +1,33 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn flip_bool() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .greater_elem(10); + + let flipped = tensor.clone().flip([0, 2]); + + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).flip((0, 2)).gt(10) + let data_expected = TensorData::from([ + [ + [true, true, true, true], + [true, true, true, true], + [true, true, true, true], + ], + [ + [false, false, false, false], + [false, false, false, false], + [true, false, false, false], + ], + ]); + + flipped.into_data().assert_eq(&data_expected, false); + + // Test with no flip + let flipped = tensor.clone().flip([]); + tensor.into_data().assert_eq(&flipped.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/full.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/full.rs new file mode 100644 index 0000000..11c0b2e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/full.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_tensor_full() { + let device = Default::default(); + let bool_tensor = TestTensorBool::<2>::full([2, 2], true, &device); + bool_tensor + .into_data() + .assert_eq(&TensorData::from([[true, true], [true, true]]), false); + + let bool_tensor = TestTensorBool::<2>::full([2, 2], false, &device); + bool_tensor + .into_data() + .assert_eq(&TensorData::from([[false, false], [false, false]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/gather_scatter.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/gather_scatter.rs new file mode 100644 index 0000000..b7b6b70 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/gather_scatter.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_scatter_1d_bool() { + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, false], &device); + let values = TestTensorBool::from_data([false, true, true], &device); + let indices = TestTensorInt::from_ints([1, 0, 2], &device); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([true, false, true]), false); +} + +#[test] +fn should_gather_1d_dim0_bool() { + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, false], &device); + let indices = TestTensorInt::from_ints([1, 1, 0, 1, 2], &device); + + let output = tensor.gather(0, indices); + + output + .into_data() + .assert_eq(&TensorData::from([false, false, true, false, false]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/init.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/init.rs new file mode 100644 index 0000000..d049ba9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/init.rs @@ -0,0 +1,51 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_bool_empty() { + let shape = [2, 2]; + let tensor = TestTensorBool::<2>::empty(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()) +} + +#[test] +fn should_support_bool_zeros() { + let shape = [2, 2]; + let tensor = TestTensorBool::<2>::zeros(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[false, false], [false, false]]), false); +} + +#[test] +fn should_support_bool_ones() { + let shape = [2, 2]; + let tensor = TestTensorBool::<2>::ones(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[true, true], [true, true]]), false); +} + +#[test] +fn should_load_bool_from_data_forcing_native_store() { + use burn_tensor::{BoolStore, DType}; + + // Model loaders (e.g. `burn-store`) reconstruct tensors with `Tensor::from_data`, + // forcing the serialized snapshot dtype. Bool tensors are serialized as + // `BoolStore::Native`, a storage layout that cubecl runtimes don't support. Forcing + // it used to panic in `bool_from_data`; `resolve_dtype` must now fall back to the + // device's default bool storage. Regression test for tracel-ai/burn#5094. + let device = Default::default(); + let data = TensorData::new(vec![true, false, true], [3]); + assert_eq!(data.dtype, DType::Bool(BoolStore::Native)); + + let tensor = TestTensorBool::<1>::from_data(data, (&device, DType::Bool(BoolStore::Native))); + + tensor + .into_data() + .assert_eq(&TensorData::from([true, false, true]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/logical.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/logical.rs new file mode 100644 index 0000000..174228f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/logical.rs @@ -0,0 +1,336 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_bool_and() { + let tensor1 = TestTensorBool::<2>::from([[false, true, false], [true, false, true]]); + let tensor2 = TestTensorBool::<2>::from([[true, true, false], [false, false, true]]); + let data_actual = tensor1.bool_and(tensor2).into_data(); + let data_expected = TensorData::from([[false, true, false], [false, false, true]]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_bool_or() { + let tensor1 = TestTensorBool::<2>::from([[false, true, false], [true, false, true]]); + let tensor2 = TestTensorBool::<2>::from([[true, true, false], [false, false, true]]); + let data_actual = tensor1.bool_or(tensor2).into_data(); + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_bool_xor() { + let tensor1 = TestTensorBool::<2>::from([[false, true, false], [true, false, true]]); + let tensor2 = TestTensorBool::<2>::from([[true, true, false], [false, false, true]]); + let data_actual = tensor1.bool_xor(tensor2).into_data(); + let data_expected = TensorData::from([[true, false, false], [true, false, false]]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_bool_or_vec() { + let device = Default::default(); + let tensor1 = TestTensorBool::<1>::full([256], 0, &device); + let tensor2 = TestTensorBool::<1>::full([256], 1, &device); + let data_actual = tensor1.bool_or(tensor2).into_data(); + let data_expected = TensorData::from([true; 256]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_bool_and_vec() { + let device = Default::default(); + let tensor1 = TestTensorBool::<1>::full([256], 0, &device); + let tensor2 = TestTensorBool::<1>::full([256], 1, &device); + let data_actual = tensor1.bool_and(tensor2).into_data(); + let data_expected = TensorData::from([false; 256]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_bool_and_broadcast_scalar_lhs() { + // [1, 1] AND [2, 3] broadcasts to [2, 3]. + let device = Default::default(); + let tensor1 = TestTensorBool::<2>::from_data(TensorData::from([[true]]), &device); + let tensor2 = TestTensorBool::<2>::from_data( + TensorData::from([[true, false, true], [false, true, false]]), + &device, + ); + + let actual = tensor1.bool_and(tensor2).into_data(); + let expected = TensorData::from([[true, false, true], [false, true, false]]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_or_broadcast_scalar_lhs() { + // [1, 1]=false OR [2, 3] broadcasts to [2, 3] and equals rhs. + let device = Default::default(); + let tensor1 = TestTensorBool::<2>::from_data(TensorData::from([[false]]), &device); + let tensor2 = TestTensorBool::<2>::from_data( + TensorData::from([[true, false, true], [false, true, false]]), + &device, + ); + + let actual = tensor1.bool_or(tensor2).into_data(); + let expected = TensorData::from([[true, false, true], [false, true, false]]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_and_broadcast_rank_mismatch() { + // [2, 3, 4] AND [1, 3, 4] (unsqueezed from [3, 4]) broadcasts along dim 0. + let device = Default::default(); + let lhs = TestTensorBool::<3>::from_data( + TensorData::from([ + [ + [true, false, false, false], + [true, false, true, false], + [false, true, true, true], + ], + [ + [true, false, false, true], + [false, false, true, true], + [true, true, false, true], + ], + ]), + &device, + ); + let rhs = TestTensorBool::<2>::from_data( + TensorData::from([ + [false, false, true, true], + [false, true, true, false], + [false, false, false, true], + ]), + &device, + ); + + let actual = lhs.bool_and(rhs.unsqueeze::<3>()).into_data(); + let expected = TensorData::from([ + [ + [false, false, false, false], + [false, false, true, false], + [false, false, false, true], + ], + [ + [false, false, false, true], + [false, false, true, false], + [false, false, false, true], + ], + ]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_or_broadcast_rank_mismatch() { + // [2, 3, 4] OR [1, 3, 4] (unsqueezed from [3, 4]) broadcasts along dim 0. + let device = Default::default(); + let lhs = TestTensorBool::<3>::from_data( + TensorData::from([ + [ + [true, false, false, false], + [true, false, true, false], + [false, true, true, true], + ], + [ + [true, false, false, true], + [false, false, true, true], + [true, true, false, true], + ], + ]), + &device, + ); + let rhs = TestTensorBool::<2>::from_data( + TensorData::from([ + [false, false, true, true], + [false, true, true, false], + [false, false, false, true], + ]), + &device, + ); + + let actual = lhs.bool_or(rhs.unsqueeze::<3>()).into_data(); + let expected = TensorData::from([ + [ + [true, false, true, true], + [true, true, true, false], + [false, true, true, true], + ], + [ + [true, false, true, true], + [false, true, true, true], + [true, true, false, true], + ], + ]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_xor_broadcast_rank_mismatch() { + // [2, 2, 2] XOR [1, 2, 2] broadcasts along the leading dim. + let device = Default::default(); + let lhs = TestTensorBool::<3>::from_data( + TensorData::from([ + [[true, false], [false, true]], + [[true, true], [false, false]], + ]), + &device, + ); + let rhs = + TestTensorBool::<2>::from_data(TensorData::from([[true, false], [false, true]]), &device); + + let actual = lhs.bool_xor(rhs.unsqueeze::<3>()).into_data(); + let expected = TensorData::from([ + [[false, false], [false, false]], + [[false, true], [false, true]], + ]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_and_broadcast_row_lhs() { + // [1, 3] AND [2, 3] broadcasts lhs along dim 0 only. Distinct from + // the scalar_lhs pattern, which broadcasts along both dims. + let device = Default::default(); + let lhs = TestTensorBool::<2>::from_data(TensorData::from([[true, false, true]]), &device); + let rhs = TestTensorBool::<2>::from_data( + TensorData::from([[true, true, false], [false, true, true]]), + &device, + ); + + let actual = lhs.bool_and(rhs).into_data(); + let expected = TensorData::from([[true, false, false], [false, false, true]]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_and_broadcast_mutual() { + // [3, 1] AND [1, 3] - both operands need expansion along different + // axes, producing [3, 3]. Exercises the "neither operand matches the + // output shape" path through broadcast_binary. + let device = Default::default(); + let lhs = TestTensorBool::<2>::from_data(TensorData::from([[true], [false], [true]]), &device); + let rhs = TestTensorBool::<2>::from_data(TensorData::from([[true, false, true]]), &device); + + let actual = lhs.bool_and(rhs).into_data(); + let expected = TensorData::from([ + [true, false, true], + [false, false, false], + [true, false, true], + ]); + expected.assert_eq(&actual, false); +} + +#[test] +fn test_bool_and_broadcast_4d() { + // 4D broadcast: [2, 1, 2, 1] AND [1, 2, 1, 2] -> [2, 2, 2, 2]. + // Confirms the fix is rank-agnostic. Pre-fix, both operands had 4 + // storage elements so the inplace helper ran without panicking, but + // the output silently kept lhs's [2, 1, 2, 1] shape - shape assert + // catches that. + let device = Default::default(); + let lhs = TestTensorBool::<4>::from_data( + TensorData::from([[[[true], [false]]], [[[false], [true]]]]), + &device, + ); + let rhs = TestTensorBool::<4>::from_data( + TensorData::from([[[[true, false]], [[false, true]]]]), + &device, + ); + + let actual = lhs.bool_and(rhs).into_data(); + let expected = TensorData::from([ + [ + [[true, false], [false, false]], + [[false, true], [false, false]], + ], + [ + [[false, false], [true, false]], + [[false, false], [false, true]], + ], + ]); + expected.assert_eq(&actual, false); +} + +// Non-contiguous (negative-stride / flipped) bool inputs. Exercises stride +// handling through the logical ops on every backend. + +#[test] +fn test_bool_not_flipped() { + // [T, F, T, F] flipped -> [F, T, F, T], not -> [T, F, T, F] + let t = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + + let output = t.bool_not(); + + output + .into_data() + .assert_eq(&TensorData::from([true, false, true, false]), false); +} + +#[test] +fn test_bool_and_flipped() { + // a: [T, F, T, F] flipped -> [F, T, F, T]; b: [T, T, F, F] + // and: [F, T, F, F] + let a = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + let b = TestTensorBool::<1>::from([true, true, false, false]); + + let output = a.bool_and(b); + + output + .into_data() + .assert_eq(&TensorData::from([false, true, false, false]), false); +} + +#[test] +fn test_bool_or_flipped() { + // a: [T, F, T, F] flipped -> [F, T, F, T]; b: [T, F, F, F] + // or: [T, T, F, T] + let a = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + let b = TestTensorBool::<1>::from([true, false, false, false]); + + let output = a.bool_or(b); + + output + .into_data() + .assert_eq(&TensorData::from([true, true, false, true]), false); +} + +#[test] +fn test_bool_and_both_flipped() { + // a, b both flipped -> [F, T, F, T] & [F, F, T, T] = [F, F, F, T] + let a = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + let b = TestTensorBool::<1>::from([true, true, false, false]).flip([0]); + + let output = a.bool_and(b); + + output + .into_data() + .assert_eq(&TensorData::from([false, false, false, true]), false); +} + +#[test] +fn test_bool_xor_flipped() { + // a flipped -> [F, T, F, T]; b: [T, T, F, F]; xor: [T, F, F, T] + let a = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + let b = TestTensorBool::<1>::from([true, true, false, false]); + + let output = a.bool_xor(b); + + output + .into_data() + .assert_eq(&TensorData::from([true, false, false, true]), false); +} + +#[test] +fn test_bool_equal_flipped() { + // a flipped -> [F, T, F, T]; b: [F, T, F, T]; equal: [T, T, T, T] + let a = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + let b = TestTensorBool::<1>::from([false, true, false, true]); + + let output = a.equal(b); + + output + .into_data() + .assert_eq(&TensorData::from([true, true, true, true]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/mask.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/mask.rs new file mode 100644 index 0000000..243df0b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/mask.rs @@ -0,0 +1,30 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_bool_mask_where_ops() { + let device = Default::default(); + let tensor = TestTensorBool::<2>::from_data([[true, false], [false, false]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + let value = + TestTensorBool::<2>::from_data(TensorData::from([[false, true], [true, false]]), &device); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([[false, false], [false, false]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_bool_mask_fill_ops() { + let device = Default::default(); + let tensor = TestTensorBool::<2>::from_data([[false, true], [false, false]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + + let output = tensor.mask_fill(mask, true); + let expected = TensorData::from([[true, true], [false, true]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/mod.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/mod.rs new file mode 100644 index 0000000..c33e226 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/mod.rs @@ -0,0 +1,27 @@ +pub use super::*; // re-export test types + +mod all; +mod any; +mod argwhere_nonzero; +mod cast; +mod cat; +mod comparison; +mod create_like; +mod expand; +mod flip; +mod full; +mod gather_scatter; +mod init; +mod logical; +mod mask; +mod movedim; +mod permute; +mod repeat; +mod repeat_dim; +mod reshape; +mod select; +mod stack; +mod take; +mod transpose; +mod tri_mask; +mod unfold; diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/movedim.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/movedim.rs new file mode 100644 index 0000000..fba3f4b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/movedim.rs @@ -0,0 +1,56 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn movedim_bool() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .greater_elem(10); + + let permuted = tensor.clone().movedim(0, 2); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim(0, 2).gt(10) + let expected = TensorData::from([ + [[false, true], [false, true], [false, true], [false, true]], + [[false, true], [false, true], [false, true], [false, true]], + [[false, true], [false, true], [false, true], [true, true]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().movedim(0, -1); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().movedim(0, 0); + permuted.into_data().assert_eq(&tensor.into_data(), false); +} + +#[test] +fn vec_input_bool() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .greater_elem(10); + + let permuted = tensor.clone().movedim(vec![0, 1], vec![1, 0]); + // from pytorch + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim([0, 1], [1, 0]).gt(10) + let expected = TensorData::from([ + [[false, false, false, false], [true, true, true, true]], + [[false, false, false, false], [true, true, true, true]], + [[false, false, false, true], [true, true, true, true]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axes + let permuted = tensor.clone().movedim(vec![-3, -2], vec![-2, -3]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axes + let permuted = tensor.clone().movedim(vec![0, 1], vec![0, 1]); + permuted.into_data().assert_eq(&tensor.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/permute.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/permute.rs new file mode 100644 index 0000000..ef095c0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/permute.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn permute_bool() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .greater_elem(10); + + let permuted = tensor.clone().permute([2, 1, 0]); + + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).permute(2, 1, 0).gt(10) + let expected = TensorData::from([ + [[false, true], [false, true], [false, true]], + [[false, true], [false, true], [false, true]], + [[false, true], [false, true], [false, true]], + [[false, true], [false, true], [true, true]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().permute([-1, 1, 0]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().permute([0, 1, 2]); + permuted.into_data().assert_eq(&tensor.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/repeat.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/repeat.rs new file mode 100644 index 0000000..ba0bd33 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/repeat.rs @@ -0,0 +1,64 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_bool_repeat_ops_one_dimension() { + let data = TensorData::from([[true, false, false]]); + let tensor = TestTensorBool::<2>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[4, 1, 1]); + let expected = TensorData::from([ + [true, false, false], + [true, false, false], + [true, false, false], + [true, false, false], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_bool_repeat_on_many_dimension() { + let data = TensorData::from([ + [[false, true], [true, false]], + [[true, true], [false, false]], + ]); + let tensor = TestTensorBool::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[2, 3, 2]); + let expected = TensorData::from([ + [ + [false, true, false, true], + [true, false, true, false], + [false, true, false, true], + [true, false, true, false], + [false, true, false, true], + [true, false, true, false], + ], + [ + [true, true, true, true], + [false, false, false, false], + [true, true, true, true], + [false, false, false, false], + [true, true, true, true], + [false, false, false, false], + ], + [ + [false, true, false, true], + [true, false, true, false], + [false, true, false, true], + [true, false, true, false], + [false, true, false, true], + [true, false, true, false], + ], + [ + [true, true, true, true], + [false, false, false, false], + [true, true, true, true], + [false, false, false, false], + [true, true, true, true], + [false, false, false, false], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/repeat_dim.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/repeat_dim.rs new file mode 100644 index 0000000..c3b33a8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/repeat_dim.rs @@ -0,0 +1,34 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_bool_repeat_ops() { + let data = TensorData::from([[true, false, false]]); + let tensor = TestTensorBool::<2>::from_data(data, &Default::default()); + + let output = tensor.repeat_dim(0, 4); + let expected = TensorData::from([ + [true, false, false], + [true, false, false], + [true, false, false], + [true, false, false], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_bool_repeat_on_dims_larger_than_1() { + let data = TensorData::from([ + [[false, true], [true, false]], + [[true, true], [false, false]], + ]); + let tensor = TestTensorBool::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat_dim(1, 2); + let expected = TensorData::from([ + [[false, true], [true, false], [false, true], [true, false]], + [[true, true], [false, false], [true, true], [false, false]], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/reshape.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/reshape.rs new file mode 100644 index 0000000..7652aef --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/reshape.rs @@ -0,0 +1,13 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_reshape_bool() { + let data = TensorData::from([false, true, false]); + let tensor = TestTensorBool::<1>::from_data(data, &Default::default()); + + let output = tensor.clone().reshape([1, 3]); + let expected = TensorData::from([[false, true, false]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/select.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/select.rs new file mode 100644 index 0000000..05e30a0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/select.rs @@ -0,0 +1,241 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_select_bool_tensor_1d() { + // Test that select works for boolean tensors + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, true], &device); + let indices = TestTensorInt::from_data([0, 2, 1, 0], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([true, true, false, true]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_bool_tensor_2d() { + // Test that select works for boolean 2D tensors + let device = Default::default(); + let tensor = + TestTensorBool::<2>::from_data([[true, false, true], [false, true, false]], &device); + let indices = TestTensorInt::from_data([1, 0], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([[false, true, false], [true, false, true]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_bool_tensor() { + // Test that select_add works for boolean tensors + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, true], &device); + let values = TestTensorBool::<1>::from_data([false, true], &device); + let indices = TestTensorInt::from_data([0, 2], &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + // Note: select_add uses sum reduction, so: + // index 0: true OR false = true + // index 2: true OR true = true + // index 1: false (unchanged) + let expected = TensorData::from([true, false, true]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_bool_overlapping_indices() { + // Test accumulation behavior with overlapping indices + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([false, true], &device); + let indices = TestTensorInt::from_data([0, 0], &device); + let values = TestTensorBool::<1>::from_data([true, false], &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + // Index 0: false OR true OR false = true + let expected = TensorData::from([true, true]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_bool_false_to_true_case() { + // Test false OR true = true + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([false], &device); + let indices = TestTensorInt::from_data([0], &device); + let values = TestTensorBool::<1>::from_data([true], &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([true]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_bool_true_or_true_accumulation() { + // Test multiple true accumulations + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false], &device); + let indices = TestTensorInt::from_data([0, 0, 0], &device); + let values = TestTensorBool::<1>::from_data([true, true, true], &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([true, false]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_match_default_implementation_behavior() { + // Verify optimized implementation matches original default logic + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, true], &device); + let indices = TestTensorInt::from_data([0, 1, 0], &device); + let values = TestTensorBool::<1>::from_data([false, true, true], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + // Manual default implementation logic + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +fn should_select_add_bool_overlapping_indices_vs_default() { + // Test overlapping indices against default implementation + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([false, true], &device); + let indices = TestTensorInt::from_data([0, 0], &device); + let values = TestTensorBool::<1>::from_data([true, false], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +fn should_select_add_bool_true_or_true_accumulation_vs_default() { + // Test multiple true accumulations against default implementation + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false], &device); + let indices = TestTensorInt::from_data([0, 0, 0], &device); + let values = TestTensorBool::<1>::from_data([true, true, true], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +fn should_select_add_bool_false_to_true_case_vs_default() { + // Test false OR true case against default implementation + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([false], &device); + let indices = TestTensorInt::from_data([0], &device); + let values = TestTensorBool::<1>::from_data([true], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +fn should_select_add_bool_tensor_vs_default() { + // Test existing basic case against default implementation + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, true], &device); + let indices = TestTensorInt::from_data([0, 2], &device); + let values = TestTensorBool::<1>::from_data([false, false], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +#[should_panic(expected = "Tensors are not eq")] +fn should_fail_if_replacement_semantics_were_used() { + // Test that framework uses accumulation, not replacement + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true], &device); + let indices = TestTensorInt::from_data([0], &device); + let values = TestTensorBool::<1>::from_data([false], &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let replacement_expected = TensorData::from([false]); + + output.into_data().assert_eq(&replacement_expected, false); +} + +#[test] +#[should_panic(expected = "Tensors are not eq")] +fn should_fail_if_replacement_semantics_were_used_vs_default() { + // Test that default implementation also uses accumulation, not replacement + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true], &device); + let indices = TestTensorInt::from_data([0], &device); + let values = TestTensorBool::<1>::from_data([false], &device); + + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + let replacement_expected = TensorData::from([false]); + + default_result + .into_data() + .assert_eq(&replacement_expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/stack.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/stack.rs new file mode 100644 index 0000000..aea179e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/stack.rs @@ -0,0 +1,15 @@ +use super::*; +use alloc::vec; +use burn_tensor::{Tensor, TensorData}; + +#[test] +fn should_support_stack_ops_bool() { + let device = Default::default(); + let tensor_1 = TestTensorBool::<2>::from_data([[false, true, true]], &device); + let tensor_2 = TestTensorBool::<2>::from_data([[true, true, false]], &device); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[false, true, true]], [[true, true, false]]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/take.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/take.rs new file mode 100644 index 0000000..f11e29d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/take.rs @@ -0,0 +1,41 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_take_bool_tensor() { + // Test take with boolean tensors + let device = Default::default(); + let tensor = TestTensorBool::<2>::from_data([[true, false], [false, true]], &device); + let indices = TestTensorInt::<1>::from_data([1, 0], &device); + + let output = tensor.take::<1, 2>(0, indices); + let expected = TensorData::from([[false, true], [true, false]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_bool_tensor_with_2d_indices() { + // Test take with boolean tensors - output will be 3D + let device = Default::default(); + let tensor = TestTensorBool::<2>::from_data( + [ + [true, false, true], + [false, true, false], + [true, true, false], + ], + &device, + ); + + // 2D indices - shape [2, 2] + let indices = TestTensorInt::<2>::from_data([[0, 2], [1, 0]], &device); + let output = tensor.take::<2, 3>(0, indices); + + // Expected: shape [2, 2, 3] + let expected = TensorData::from([ + [[true, false, true], [true, true, false]], + [[false, true, false], [true, false, true]], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/transpose.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/transpose.rs new file mode 100644 index 0000000..51d44bc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/transpose.rs @@ -0,0 +1,41 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_transpose_bool() { + let tensor = TestTensorBool::<3>::from_data( + [ + [[false, true, false], [false, false, false]], + [[false, false, true], [false, false, true]], + ], + &Default::default(), + ); + + let output = tensor.transpose(); + let expected = TensorData::from([ + [[false, false], [true, false], [false, false]], + [[false, false], [false, false], [true, true]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_swap_dims_bool() { + let tensor = TestTensorBool::<3>::from_data( + [ + [[false, true, false], [false, false, false]], + [[false, false, true], [false, false, true]], + ], + &Default::default(), + ); + + let output = tensor.swap_dims(0, 2); + let expected = TensorData::from([ + [[false, false], [false, false]], + [[true, false], [false, false]], + [[false, true], [false, true]], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/tri_mask.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/tri_mask.rs new file mode 100644 index 0000000..dfe3fa0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/tri_mask.rs @@ -0,0 +1,94 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn square_diag() { + let device = Default::default(); + let data_expected = TensorData::from([ + [false, true, true], + [true, false, true], + [true, true, false], + ]); + let tensor = TestTensorBool::<2>::diag_mask([3, 3], 0, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn square_diag_offset() { + let device = Default::default(); + let data_expected = + TensorData::from([[true, false, true], [true, true, false], [true, true, true]]); + let tensor = TestTensorBool::<2>::diag_mask([3, 3], 1, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn square_tri_upper() { + let device = Default::default(); + let data_expected = TensorData::from([ + [false, false, false], + [true, false, false], + [true, true, false], + ]); + let tensor = TestTensorBool::<2>::triu_mask([3, 3], 0, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn square_tri_upper_offset() { + let device = Default::default(); + let data_expected = TensorData::from([ + [true, false, false], + [true, true, false], + [true, true, true], + ]); + let tensor = TestTensorBool::<2>::triu_mask([3, 3], 1, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn square_tri_lower() { + let device = Default::default(); + + let data_expected = TensorData::from([ + [false, true, true], + [false, false, true], + [false, false, false], + ]); + let tensor = TestTensorBool::<2>::tril_mask([3, 3], 0, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn square_tri_lower_offset() { + let device = Default::default(); + + let data_expected = TensorData::from([ + [true, true, true], + [false, true, true], + [false, false, true], + ]); + let tensor = TestTensorBool::<2>::tril_mask([3, 3], -1, &device); + tensor.into_data().assert_eq(&data_expected, false); +} + +#[test] +fn rect_diag() { + let device = Default::default(); + let data_expected = TensorData::from([ + [false, true, true, true], + [true, false, true, true], + [true, true, false, true], + ]); + let tensor = TestTensorBool::<2>::diag_mask([3, 4], 0, &device); + tensor.into_data().assert_eq(&data_expected, false); + + let data_expected = TensorData::from([ + [false, true, true], + [true, false, true], + [true, true, false], + [true, true, true], + ]); + let tensor = TestTensorBool::<2>::diag_mask([4, 3], 0, &device); + tensor.into_data().assert_eq(&data_expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/bool/ops/unfold.rs b/crates/burn-backend-tests/tests/tensor/bool/ops/unfold.rs new file mode 100644 index 0000000..fb02534 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/bool/ops/unfold.rs @@ -0,0 +1,36 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::s; + +#[test] +fn test_unfold_bool() { + let device = Default::default(); + + let input = + TestTensor::<3>::random([2, 6, 6], Distribution::Default, &device).greater_elem(0.5); + + let dim = 1; + let size = 3; + let step = 2; + let actual: TestTensorBool<4> = input.clone().unfold(dim, size, step); + + let expected = TestTensorBool::<4>::empty([2, 2, 6, 3], &device) + .slice_assign( + s![.., 0, .., ..], + input + .clone() + .slice(s![.., 0..3, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ) + .slice_assign( + s![.., 1, .., ..], + input + .clone() + .slice(s![.., 2..5, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ); + + actual.to_data().assert_eq(&expected.to_data(), true); +} diff --git a/crates/burn-backend-tests/tests/tensor/clone_invariance.rs b/crates/burn-backend-tests/tests/tensor/clone_invariance.rs new file mode 100644 index 0000000..806f960 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/clone_invariance.rs @@ -0,0 +1,761 @@ +/// This module tests whether basic tensor operations remain invariant when performed on clones, +/// meaning that cloning input tensors won't affect the results. +/// +/// Those are relevant tests because backends may employ unsafe optimizations to reuse tensor data +/// and use different kernels in such cases. We ensure that the results are consistent regardless +/// of the approach and that the input tensors are not modified when cloned. +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::activation::{ + gelu, log_sigmoid, log_softmax, mish, relu, sigmoid, silu, softmax, softplus, tanh, +}; +use burn_tensor::{Distribution, IndexingUpdateOp, TensorData}; + +pub trait CloneInvarianceTest { + type Args; + + fn args(&self) -> Self::Args; + + fn run(&self, args: &Self::Args, inplace: bool) -> TensorData; + + fn check(&self) { + let args = self.args(); + let out = self.run(&args, false); + let out_inplace = self.run(&args, true); + + out.assert_approx_eq::(&out_inplace, Tolerance::default()); + } +} + +macro_rules! clone_invariance_test { + (unary: $name:ident, ops_float: $ops:expr) => { + #[test] + #[allow(non_snake_case)] + fn $name() { + struct $name; + + impl CloneInvarianceTest<2> for $name { + type Args = TensorData; + + fn args(&self) -> Self::Args { + TestTensor::<2>::random([32, 32], Distribution::Default, &Default::default()) + .into_data() + .convert::() + } + + fn run(&self, args: &Self::Args, inplace: bool) -> TensorData { + let lhs = TestTensor::from_data(args.clone(), &Default::default()); + + if inplace { + $ops(lhs).into_data().convert::() + } else { + let out = $ops(lhs.clone()).into_data().convert::(); + lhs.into_data() + .assert_approx_eq::(args, Tolerance::default()); + out + } + } + } + + CloneInvarianceTest::<2>::check(&$name); + } + }; + + (binary: $name:ident, ops_float: $ops:expr) => { + #[test] + #[allow(non_snake_case)] + fn $name() { + struct $name; + + impl CloneInvarianceTest<2> for $name { + type Args = (TensorData, TensorData); + + fn args(&self) -> Self::Args { + let device = Default::default(); + ( + TestTensor::<2>::ones([32, 32], &device) + .into_data() + .convert::(), + // Avoid div by zero. + TestTensor::<2>::ones([32, 32], &device) + .into_data() + .convert::(), + ) + } + + fn run(&self, (lhs_arg, rhs_arg): &Self::Args, inplace: bool) -> TensorData { + let device = Default::default(); + let lhs = TestTensor::from_data(lhs_arg.clone(), &device); + let rhs = TestTensor::from_data(rhs_arg.clone(), &device); + + if inplace { + $ops(lhs, rhs).into_data().convert::() + } else { + let out = $ops(lhs.clone(), rhs.clone()).into_data().convert::(); + + lhs.into_data() + .assert_approx_eq::(lhs_arg, Tolerance::default()); + rhs.into_data() + .assert_approx_eq::(rhs_arg, Tolerance::default()); + + out + } + } + } + + CloneInvarianceTest::<2>::check(&$name); + } + }; + + (unary: $name:ident, ops_int: $ops:expr) => { + #[test] + #[allow(non_snake_case)] + fn $name() { + struct $name; + + impl CloneInvarianceTest<2> for $name { + type Args = TensorData; + + fn args(&self) -> Self::Args { + TestTensor::<2>::random( + [32, 32], + Distribution::Uniform(0.0, 50.0), + &Default::default(), + ) + .into_data() + .convert::() + } + + fn run(&self, args: &Self::Args, inplace: bool) -> TensorData { + let lhs = TestTensorInt::from_data(args.clone(), &Default::default()); + + if inplace { + $ops(lhs).into_data().convert::() + } else { + let out = $ops(lhs.clone()).into_data().convert::(); + lhs.into_data() + .convert::() + .assert_approx_eq::(args, Tolerance::default()); + out + } + } + } + + CloneInvarianceTest::<2>::check(&$name); + } + }; + + (binary: $name:ident, ops_int: $ops:expr) => { + #[test] + #[allow(non_snake_case)] + fn $name() { + struct $name; + + impl CloneInvarianceTest<2> for $name { + type Args = (TensorData, TensorData); + + fn args(&self) -> Self::Args { + let device = Default::default(); + ( + TestTensor::<2>::random([32, 32], Distribution::Uniform(0., 50.), &device) + .into_data() + .convert::(), + // Avoid div by zero. + TestTensor::<2>::random([32, 32], Distribution::Uniform(1., 51.), &device) + .into_data() + .convert::(), + ) + } + + fn run(&self, (lhs_arg, rhs_arg): &Self::Args, inplace: bool) -> TensorData { + let device = Default::default(); + let lhs = TestTensorInt::from_data(lhs_arg.clone(), &device); + let rhs = TestTensorInt::from_data(rhs_arg.clone(), &device); + + if inplace { + $ops(lhs, rhs).into_data().convert::() + } else { + let out = $ops(lhs.clone(), rhs.clone()).into_data().convert::(); + + lhs.into_data() + .convert::() + .assert_approx_eq::(lhs_arg, Tolerance::default()); + rhs.into_data() + .convert::() + .assert_approx_eq::(rhs_arg, Tolerance::default()); + + out + } + } + } + + CloneInvarianceTest::<2>::check(&$name); + } + }; +} + +mod float { + use super::*; + + // Unary ops + clone_invariance_test!( + unary: AddScalar, + ops_float: |tensor: TestTensor<2>| tensor.add_scalar(2.0) + ); + clone_invariance_test!( + unary: SubScalar, + ops_float: |tensor: TestTensor<2>| tensor.sub_scalar(2.0) + ); + clone_invariance_test!( + unary: DivScalar, + ops_float: |tensor: TestTensor<2>| tensor.div_scalar(2.0) + ); + clone_invariance_test!( + unary: MulScalar, + ops_float: |tensor: TestTensor<2>| tensor.mul_scalar(2.0) + ); + clone_invariance_test!( + unary: PowScalar, + ops_float: |tensor: TestTensor<2>| tensor.powf_scalar(2.0) + ); + clone_invariance_test!( + unary: Square, + ops_float: |tensor: TestTensor<2>| tensor.square() + ); + clone_invariance_test!( + unary: Sqrt, + ops_float: |tensor: TestTensor<2>| tensor.sqrt() + ); + clone_invariance_test!( + unary: Exp, + ops_float: |tensor: TestTensor<2>| tensor.exp() + ); + clone_invariance_test!( + unary: Neg, + ops_float: |tensor: TestTensor<2>| tensor.neg() + ); + clone_invariance_test!( + unary: MeanDim, + ops_float: |tensor: TestTensor<2>| tensor.mean_dim(1) + ); + clone_invariance_test!( + unary: SumDim, + ops_float: |tensor: TestTensor<2>| tensor.sum_dim(1) + ); + clone_invariance_test!( + unary: Sum, + ops_float: |tensor: TestTensor<2>| tensor.sum().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Mean, + ops_float: |tensor: TestTensor<2>| tensor.mean().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Clamp, + ops_float: |tensor: TestTensor<2>| tensor.clamp(-2., 2.) + ); + clone_invariance_test!( + unary: ClampMin, + ops_float: |tensor: TestTensor<2>| tensor.clamp_min(-2.) + ); + clone_invariance_test!( + unary: ClampMax, + ops_float: |tensor: TestTensor<2>| tensor.clamp_max(2.) + ); + clone_invariance_test!( + unary: Abs, + ops_float: |tensor: TestTensor<2>| tensor.abs() + ); + clone_invariance_test!( + unary: Cos, + ops_float: |tensor: TestTensor<2>| tensor.cos() + ); + clone_invariance_test!( + unary: Sin, + ops_float: |tensor: TestTensor<2>| tensor.sin() + ); + clone_invariance_test!( + unary: Tan, + ops_float: |tensor: TestTensor<2>| tensor.tan() + ); + clone_invariance_test!( + unary: Log, + ops_float: |tensor: TestTensor<2>| tensor.log() + ); + clone_invariance_test!( + unary: Log1P, + ops_float: |tensor: TestTensor<2>| tensor.log1p() + ); + clone_invariance_test!( + unary: SwapDims, + ops_float: |tensor: TestTensor<2>| tensor.swap_dims(0, 1) + ); + clone_invariance_test!( + unary: Transpose, + ops_float: |tensor: TestTensor<2>| tensor.transpose() + ); + clone_invariance_test!( + unary: Slice, + ops_float: |tensor: TestTensor<2>| tensor.slice([0..12, 12..24]) + ); + clone_invariance_test!( + unary: Erf, + ops_float: |tensor: TestTensor<2>| tensor.erf() + ); + clone_invariance_test!( + unary: EqualElem, + ops_float: |tensor: TestTensor<2>| tensor.equal_elem(0.5) + ); + clone_invariance_test!( + unary: NotEqualElem, + ops_float: |tensor: TestTensor<2>| tensor.not_equal_elem(0.5) + ); + clone_invariance_test!( + unary: GreaterElem, + ops_float: |tensor: TestTensor<2>| tensor.greater_elem(0.5) + ); + clone_invariance_test!( + unary: GreaterEqualElem, + ops_float: |tensor: TestTensor<2>| tensor.greater_equal_elem(0.5) + ); + clone_invariance_test!( + unary: LowerElem, + ops_float: |tensor: TestTensor<2>| tensor.lower_elem(0.5) + ); + clone_invariance_test!( + unary: LowerEqualElem, + ops_float: |tensor: TestTensor<2>| tensor.lower_equal_elem(0.5) + ); + clone_invariance_test!( + unary: Argmax, + ops_float: |tensor: TestTensor<2>| tensor.argmax(0) + ); + clone_invariance_test!( + unary: Argmin, + ops_float: |tensor: TestTensor<2>| tensor.argmin(0) + ); + clone_invariance_test!( + unary: Max, + ops_float: |tensor: TestTensor<2>| tensor.max().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Min, + ops_float: |tensor: TestTensor<2>| tensor.min().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: MaxDim, + ops_float: |tensor: TestTensor<2>| tensor.max_dim(1) + ); + clone_invariance_test!( + unary: MaxDimWithIndices, + ops_float: |tensor: TestTensor<2>| tensor.max_dim_with_indices(1).0 + ); + clone_invariance_test!( + unary: MinDimWithIndices, + ops_float: |tensor: TestTensor<2>| tensor.min_dim_with_indices(1).0 + ); + clone_invariance_test!( + unary: MinDim, + ops_float: |tensor: TestTensor<2>| tensor.min_dim(1) + ); + clone_invariance_test!( + unary: Repeat, + ops_float: |tensor: TestTensor<2>| { + tensor.reshape([1, 32, 32]).repeat_dim(0, 4).reshape([4 * 32, 32]) + } + ); + clone_invariance_test!( + unary: Reshape, + ops_float: |tensor: TestTensor<2>| { + let shape = tensor.shape(); + let new_shape = [shape.num_elements(), 1]; + tensor.reshape(new_shape) + } + ); + clone_invariance_test!( + unary: Gatter, + ops_float: |tensor: TestTensor<2>| { + let shape = tensor.shape(); + let indices = TestTensorInt::ones(shape, &Default::default()); + tensor.gather(0, indices) + } + ); + clone_invariance_test!( + unary: Select, + ops_float: |tensor: TestTensor<2>| { + let indices = TestTensorInt::from_ints([1, 2, 0, 5], &Default::default()); + tensor.select(0, indices) + } + ); + clone_invariance_test!( + unary: MaskFill, + ops_float: |tensor: TestTensor<2>| { + let mask = tensor.clone().greater_elem(0.5); + tensor.mask_fill(mask, 77.0) + } + ); + + // Activation + clone_invariance_test!( + unary: Softmax, + ops_float: |tensor: TestTensor<2>| softmax(tensor, 1) + ); + clone_invariance_test!( + unary: LogSoftmax, + ops_float: |tensor: TestTensor<2>| log_softmax(tensor, 1) + ); + clone_invariance_test!( + unary: Sigmoid, + ops_float: |tensor: TestTensor<2>| sigmoid(tensor) + ); + clone_invariance_test!( + unary: LogSigmoid, + ops_float: |tensor: TestTensor<2>| log_sigmoid(tensor) + ); + clone_invariance_test!( + unary: Relu, + ops_float: |tensor: TestTensor<2>| relu(tensor) + ); + clone_invariance_test!( + unary: Gelu, + ops_float: |tensor: TestTensor<2>| gelu(tensor) + ); + clone_invariance_test!( + unary: Mish, + ops_float: |tensor: TestTensor<2>| mish(tensor) + ); + clone_invariance_test!( + unary: Silu, + ops_float: |tensor: TestTensor<2>| silu(tensor) + ); + clone_invariance_test!( + unary: Softplus, + ops_float: |tensor: TestTensor<2>| softplus(tensor, 1.0) + ); + clone_invariance_test!( + unary: Tanh, + ops_float: |tensor: TestTensor<2>| tanh(tensor) + ); + + // Binary ops + clone_invariance_test!( + binary: Add, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.add(rhs) + ); + clone_invariance_test!( + binary: Sub, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.sub(rhs) + ); + clone_invariance_test!( + binary: Div, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.div(rhs) + ); + clone_invariance_test!( + binary: Mul, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.mul(rhs) + ); + clone_invariance_test!( + binary: Matmul, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.matmul(rhs) + ); + clone_invariance_test!( + binary: Equal, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.equal(rhs) + ); + clone_invariance_test!( + binary: Greater, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.greater(rhs) + ); + clone_invariance_test!( + binary: GreaterEqual, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.greater_equal(rhs) + ); + clone_invariance_test!( + binary: Lower, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.lower(rhs) + ); + clone_invariance_test!( + binary: LowerEqual, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| lhs.lower_equal(rhs) + ); + clone_invariance_test!( + binary: Cat, + ops_float: |lhs: TestTensor<2>, rhs: TestTensor<2>| { + let lhs = lhs.reshape([1usize, 32, 32]); + let rhs = rhs.reshape([1usize, 32, 32]); + + TestTensor::cat(vec![lhs, rhs], 0).reshape([64, 32]) + } + ); + clone_invariance_test!( + binary: Scatter, + ops_float: |tensor: TestTensor<2>, values: TestTensor<2>| { + let shape = tensor.shape(); + let indices = TestTensorInt::ones(shape, &Default::default()); + tensor.scatter(0, indices, values, IndexingUpdateOp::Add) + } + ); + clone_invariance_test!( + binary: SliceAssign, + ops_float: |tensor: TestTensor<2>, values: TestTensor<2>| { + tensor.slice_assign([0..12, 12..24], values.slice([12..24, 0..12])) + } + ); + clone_invariance_test!( + binary: MaskWhere, + ops_float: |tensor: TestTensor<2>, values: TestTensor<2>| { + let mask = tensor.clone().greater_elem(0.5); + tensor.mask_where(mask, values) + } + ); + clone_invariance_test!( + binary: SelectAssign, + ops_float: |tensor: TestTensor<2>, values: TestTensor<2>| { + let indices = TestTensorInt::from_ints([1, 2, 0, 5], &Default::default()); + let values = values.select(0, indices.clone()); + tensor.select_assign(0, indices, values, IndexingUpdateOp::Add) + } + ); +} + +mod int { + use super::*; + + // Unary ops + clone_invariance_test!( + unary: AddScalar, + ops_int: |tensor: TestTensorInt<2>| tensor.add_scalar(2.0) + ); + clone_invariance_test!( + unary: SubScalar, + ops_int: |tensor: TestTensorInt<2>| tensor.sub_scalar(2.0) + ); + clone_invariance_test!( + unary: DivScalar, + ops_int: |tensor: TestTensorInt<2>| tensor.div_scalar(2.0) + ); + clone_invariance_test!( + unary: MulScalar, + ops_int: |tensor: TestTensorInt<2>| tensor.mul_scalar(2.0) + ); + clone_invariance_test!( + unary: Neg, + ops_int: |tensor: TestTensorInt<2>| tensor.neg() + ); + clone_invariance_test!( + unary: MeanDim, + ops_int: |tensor: TestTensorInt<2>| tensor.mean_dim(1) + ); + clone_invariance_test!( + unary: SumDim, + ops_int: |tensor: TestTensorInt<2>| tensor.sum_dim(1) + ); + clone_invariance_test!( + unary: Sum, + ops_int: |tensor: TestTensorInt<2>| tensor.sum().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Mean, + ops_int: |tensor: TestTensorInt<2>| tensor.mean().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Clamp, + ops_int: |tensor: TestTensorInt<2>| tensor.clamp(-2., 2.) + ); + clone_invariance_test!( + unary: ClampMin, + ops_int: |tensor: TestTensorInt<2>| tensor.clamp_min(-2.) + ); + clone_invariance_test!( + unary: ClampMax, + ops_int: |tensor: TestTensorInt<2>| tensor.clamp_max(2.) + ); + clone_invariance_test!( + unary: Abs, + ops_int: |tensor: TestTensorInt<2>| tensor.abs() + ); + clone_invariance_test!( + unary: SwapDims, + ops_int: |tensor: TestTensorInt<2>| tensor.swap_dims(0, 1) + ); + clone_invariance_test!( + unary: Transpose, + ops_int: |tensor: TestTensorInt<2>| tensor.transpose() + ); + clone_invariance_test!( + unary: Slice, + ops_int: |tensor: TestTensorInt<2>| tensor.slice([0..12, 12..24]) + ); + clone_invariance_test!( + unary: EqualElem, + ops_int: |tensor: TestTensorInt<2>| tensor.equal_elem(25) + ); + clone_invariance_test!( + unary: NotEqualElem, + ops_int: |tensor: TestTensorInt<2>| tensor.not_equal_elem(25) + ); + clone_invariance_test!( + unary: GreaterElem, + ops_int: |tensor: TestTensorInt<2>| tensor.greater_elem(25) + ); + clone_invariance_test!( + unary: GreaterEqualElem, + ops_int: |tensor: TestTensorInt<2>| tensor.greater_equal_elem(25) + ); + clone_invariance_test!( + unary: LowerElem, + ops_int: |tensor: TestTensorInt<2>| tensor.lower_elem(25) + ); + clone_invariance_test!( + unary: LowerEqualElem, + ops_int: |tensor: TestTensorInt<2>| tensor.lower_equal_elem(25) + ); + clone_invariance_test!( + unary: Argmax, + ops_int: |tensor: TestTensorInt<2>| tensor.argmax(0) + ); + clone_invariance_test!( + unary: Argmin, + ops_int: |tensor: TestTensorInt<2>| tensor.argmin(0) + ); + clone_invariance_test!( + unary: Max, + ops_int: |tensor: TestTensorInt<2>| tensor.max().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: Min, + ops_int: |tensor: TestTensorInt<2>| tensor.min().unsqueeze::<2>() + ); + clone_invariance_test!( + unary: MaxDim, + ops_int: |tensor: TestTensorInt<2>| tensor.max_dim(1) + ); + clone_invariance_test!( + unary: MaxDimWithIndices, + ops_int: |tensor: TestTensorInt<2>| tensor.max_dim_with_indices(1).0 + ); + clone_invariance_test!( + unary: MinDimWithIndices, + ops_int: |tensor: TestTensorInt<2>| tensor.min_dim_with_indices(1).0 + ); + clone_invariance_test!( + unary: MinDim, + ops_int: |tensor: TestTensorInt<2>| tensor.min_dim(1) + ); + clone_invariance_test!( + unary: Repeat, + ops_int: |tensor: TestTensorInt<2>| { + tensor.reshape([1, 32, 32]).repeat_dim(0, 4).reshape([4 * 32, 32]) + } + ); + clone_invariance_test!( + unary: Reshape, + ops_int: |tensor: TestTensorInt<2>| { + let shape = tensor.shape(); + let new_shape = [shape.num_elements(), 1]; + tensor.reshape(new_shape) + } + ); + clone_invariance_test!( + unary: Gatter, + ops_int: |tensor: TestTensorInt<2>| { + let shape = tensor.shape(); + let indices = TestTensorInt::ones(shape, &Default::default()); + tensor.gather(0, indices) + } + ); + clone_invariance_test!( + unary: Select, + ops_int: |tensor: TestTensorInt<2>| { + let indices = TestTensorInt::from_ints([1, 2, 0, 5], &Default::default()); + tensor.select(0, indices) + } + ); + clone_invariance_test!( + unary: MaskFill, + ops_int: |tensor: TestTensorInt<2>| { + let mask = tensor.clone().greater_elem(0.5); + tensor.mask_fill(mask, 77.0) + } + ); + + // Binary ops + clone_invariance_test!( + binary: Add, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.add(rhs) + ); + clone_invariance_test!( + binary: Sub, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.sub(rhs) + ); + clone_invariance_test!( + binary: Div, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.div(rhs) + ); + clone_invariance_test!( + binary: Mul, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.mul(rhs) + ); + clone_invariance_test!( + binary: Equal, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.equal(rhs) + ); + clone_invariance_test!( + binary: NotEqual, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.not_equal(rhs) + ); + clone_invariance_test!( + binary: Greater, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.greater(rhs) + ); + clone_invariance_test!( + binary: GreaterEqual, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.greater_equal(rhs) + ); + clone_invariance_test!( + binary: Lower, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.lower(rhs) + ); + clone_invariance_test!( + binary: LowerEqual, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| lhs.lower_equal(rhs) + ); + clone_invariance_test!( + binary: Cat, + ops_int: |lhs: TestTensorInt<2>, rhs: TestTensorInt<2>| { + let lhs = lhs.reshape([1usize, 32, 32]); + let rhs = rhs.reshape([1usize, 32, 32]); + + TestTensorInt::cat(vec![lhs, rhs], 0).reshape([64, 32]) + } + ); + clone_invariance_test!( + binary: Scatter, + ops_int: |tensor: TestTensorInt<2>, values: TestTensorInt<2>| { + let shape = tensor.shape(); + let indices = TestTensorInt::ones(shape, &Default::default()); + tensor.scatter(0, indices, values, IndexingUpdateOp::Add) + } + ); + clone_invariance_test!( + binary: SliceAssign, + ops_int: |tensor: TestTensorInt<2>, values: TestTensorInt<2>| { + tensor.slice_assign([0..12, 12..24], values.slice([12..24, 0..12])) + } + ); + clone_invariance_test!( + binary: MaskWhere, + ops_int: |tensor: TestTensorInt<2>, values: TestTensorInt<2>| { + let mask = tensor.clone().greater_elem(0.5); + tensor.mask_where(mask, values) + } + ); + clone_invariance_test!( + binary: SelectAssign, + ops_int: |tensor: TestTensorInt<2>, values: TestTensorInt<2>| { + let indices = TestTensorInt::from_ints([1, 2, 0, 5], &Default::default()); + let values = values.select(0, indices.clone()); + tensor.select_assign(0, indices, values, IndexingUpdateOp::Add) + } + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/distributed.rs b/crates/burn-backend-tests/tests/tensor/distributed.rs new file mode 100644 index 0000000..be7318a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/distributed.rs @@ -0,0 +1,118 @@ +use burn_tensor::Tolerance; +use burn_tensor::distributed::ReduceOperation; +use burn_tensor::module::all_reduce; +use burn_tensor::{Device, DeviceType, TensorData}; +use rand::RngExt; +use serial_test::serial; + +use super::*; + +#[test] +#[serial] +fn test_all_reduce() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + let shape = [20, 20]; + run_all_reduce(devices, 100, shape); +} + +#[test] +#[serial] +fn test_all_reduce_multithread() { + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + let shape = [20, 20]; + run_multithread(devices, 100, shape); +} + +fn run_all_reduce(devices: Vec, num_iterations: usize, shape: [usize; 2]) { + let mut rng = rand::rng(); + let size = shape[0] * shape[1]; + + for _ in 0..num_iterations { + let vec_data: Vec> = (0..devices.len()) + .map(|_| (0..size).map(|_| rng.random_range(0.0..10.0)).collect()) + .collect(); + let expected: Vec = (0..size) + .map(|i| vec_data.iter().map(|v| v[i]).sum::()) + .collect(); + let tensors: Vec> = vec_data + .iter() + .zip(devices.clone()) + .map(|(data, device)| { + let tensor_data = TensorData::from(data.as_slice()); + let tensor = Tensor::<1>::from_data(tensor_data, &device); + tensor.reshape(shape) + }) + .collect(); + + let mut out_tensors = vec![]; + for tensor in tensors.clone() { + let output = all_reduce(tensor, ReduceOperation::Sum, devices.clone()); + let output = output.resolve(); + out_tensors.push(output); + } + + for tensor in out_tensors { + let data = tensor.flatten::<1>(0, 1).to_data(); + data.assert_approx_eq::( + &TensorData::from(expected.as_slice()), + Tolerance::default(), + ); + } + } +} + +fn run_multithread(devices: Vec, num_iterations: usize, shape: [usize; 2]) { + let size = shape[0] * shape[1]; + let num_devices = devices.len(); + + let (expected_sender, expected_receiver) = std::sync::mpsc::channel(); + let (actual_sender, actual_receiver) = std::sync::mpsc::channel(); + let handles = devices + .iter() + .map(|device| { + let local_device = device.clone(); + let local_devices = devices.clone(); + let local_expected_sender = expected_sender.clone(); + let local_actual_sender = actual_sender.clone(); + std::thread::spawn(move || { + for _ in 0..num_iterations { + let mut rng = rand::rng(); + let vec_data: Vec = + (0..size).map(|_| rng.random_range(0.0..10.0)).collect(); + local_expected_sender.send(vec_data.clone()).unwrap(); + + let tensor_data = TensorData::from(vec_data.as_slice()); + let tensor = + Tensor::<1>::from_data(tensor_data, &local_device).reshape(shape.clone()); + let output = all_reduce(tensor, ReduceOperation::Sum, local_devices.clone()); + let output = output.resolve(); + + let data = output.flatten::<1>(0, 1).to_data(); + local_actual_sender.send(data).unwrap(); + } + }) + }) + .collect::>(); + + for _ in 0..num_iterations { + let mut expected_list = vec![]; + for _ in 0..num_devices { + expected_list.push(expected_receiver.recv().unwrap()); + } + let expected: Vec = (0..size) + .map(|i| expected_list.iter().map(|v| v[i]).sum::()) + .collect(); + + for _ in 0..num_devices { + let data = actual_receiver.recv().unwrap(); + data.assert_approx_eq::( + &TensorData::from(expected.as_slice()), + Tolerance::default(), + ); + } + } + + for h in handles { + h.join().unwrap(); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/celu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/celu.rs new file mode 100644 index 0000000..49b4812 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/celu.rs @@ -0,0 +1,34 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_celu_d2() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [-3.0, 0.5]]); + + let output = activation::celu(tensor, 1.0); + // celu(1, 1) = 1 + // celu(7, 1) = 7 + // celu(-3, 1) = 1 * (exp(-3) - 1) = -0.950213 + // celu(0.5, 1) = 0.5 + let expected = TensorData::from([[1.0, 7.0], [-0.950213, 0.5]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_celu_with_alpha() { + let tensor = TestTensor::<1>::from([0.0, -1.0, -2.0]); + + let output = activation::celu(tensor, 2.0); + // celu(0, 2) = 0 + // celu(-1, 2) = 2 * (exp(-0.5) - 1) = -0.786939 + // celu(-2, 2) = 2 * (exp(-1) - 1) = -1.264241 + let expected = TensorData::from([0.0, -0.786939, -1.264241]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/elu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/elu.rs new file mode 100644 index 0000000..f38cc07 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/elu.rs @@ -0,0 +1,32 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_elu() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::elu(tensor, 1.0); + // elu(1, 1) = 1, elu(7, 1) = 7, elu(13, 1) = 13 + // elu(-3, 1) = 1 * (exp(-3) - 1) = -0.950213 + let expected = TensorData::from([[1.0, 7.0], [13.0, -0.950213]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_elu_alpha() { + let tensor = TestTensor::<1>::from([0.0, -1.0, -2.0]); + + let output = activation::elu(tensor, 2.0); + // elu(0, 2) = 2*(exp(0)-1) = 0 + // elu(-1, 2) = 2*(exp(-1)-1) = 2*(-0.632121) = -1.264241 + // elu(-2, 2) = 2*(exp(-2)-1) = 2*(-0.864665) = -1.729329 + let expected = TensorData::from([0.0, -1.264241, -1.729329]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/gelu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/gelu.rs new file mode 100644 index 0000000..3de0403 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/gelu.rs @@ -0,0 +1,32 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_gelu() { + let tensor = TestTensor::<2>::from([[ + 0.5447, 0.9809, 0.4114, 0.1398, 0.8045, 0.4103, 0.2388, 0.5262, 0.6677, 0.6737, + ]]); + let output = activation::gelu(tensor); + let expected = TensorData::from([[ + 0.3851, 0.8207, 0.2714, 0.0777, 0.6351, 0.2704, 0.1419, 0.3687, 0.4993, 0.5051, + ]]); + + // Low precision to allow approximation implementation using tanh + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_gelu_d1() { + let tensor = TestTensor::<1>::from([-3.0, 0.0, 3.0]); + + let output = activation::gelu(tensor); + + output.into_data().assert_approx_eq::( + &TensorData::from([0.0, 0.0, 3.0]), + Tolerance::absolute(0.01), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/glu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/glu.rs new file mode 100644 index 0000000..f8e46fa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/glu.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_glu_d3() { + let tensor = TestTensor::<3>::from([[ + [ + -0.5710, -1.3416, 1.9128, -0.8257, -0.1331, -1.4804, -0.6281, -0.6115, + ], + [ + 0.0267, -1.3834, 0.2752, 0.7844, -0.3549, -0.4274, 0.3290, -0.5459, + ], + [ + -1.6347, -2.0908, 1.8801, 0.3541, 0.2237, 1.0377, 2.4850, 0.3490, + ], + ]]); + + let output = activation::glu(tensor, 2); + + output.into_data().assert_approx_eq::( + &TensorData::from([[ + [-0.2665, -0.2487, 0.6656, -0.2904], + [0.0110, -0.5461, 0.1601, 0.2877], + [-0.9084, -1.5439, 1.7355, 0.2077], + ]]), + Default::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/hard_sigmoid.rs b/crates/burn-backend-tests/tests/tensor/float/activation/hard_sigmoid.rs new file mode 100644 index 0000000..c1af47d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/hard_sigmoid.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_hard_sigmoid() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::hard_sigmoid(tensor, 0.2, 0.5); + let expected = TensorData::from([[0.7, 1.0], [1.0, 0.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_hard_sigmoid_overflow() { + let tensor = TestTensor::<1>::from([FloatElem::MAX, FloatElem::MIN]); + + let output = activation::hard_sigmoid(tensor, 0.2, 0.5); + let expected = TensorData::from([1.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/leaky_relu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/leaky_relu.rs new file mode 100644 index 0000000..80c6e3b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/leaky_relu.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_leaky_relu_d2() { + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, -4.0, 5.0]]); + + let output = activation::leaky_relu(tensor, 0.01); + + // Account for conversion errors if `FloatType != f32` + output.into_data().assert_approx_eq::( + &TensorData::from([[0.0, -0.01, 2.0], [3.0, -0.04, 5.0]]), + Tolerance::default(), + ); +} + +#[test] +fn test_leaky_relu_d1() { + let tensor = TestTensor::<1>::from([-2.0, -1.0, 0.0, 1.0, 2.0]); + + let output = activation::leaky_relu(tensor, 0.01); + + output.into_data().assert_approx_eq::( + &TensorData::from([-0.02, -0.01, 0.0, 1.0, 2.0]), + Tolerance::absolute(1e-6), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/log_sigmoid.rs b/crates/burn-backend-tests/tests/tensor/float/activation/log_sigmoid.rs new file mode 100644 index 0000000..a834f88 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/log_sigmoid.rs @@ -0,0 +1,51 @@ +#![allow(clippy::approx_constant)] + +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ElementConversion, TensorData, activation}; + +#[test] +fn test_log_sigmoid() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::log_sigmoid(tensor); + let expected = TensorData::from([[-3.132617e-1, -9.114665e-4], [-2.260327e-6, -3.0485873]]); + + let tolerance = Tolerance::rel_abs(0.01, 0.0001); + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_log_sigmoid_d1() { + let tensor = TestTensor::<1>::from([-10.0, 0.0, 10.0]); + + let output = activation::log_sigmoid(tensor); + + output.into_data().assert_approx_eq::( + &TensorData::from([-10.0, -0.6931472, 0.0]), + Tolerance::absolute(1e-3), + ); +} + +#[test] +fn test_log_sigmoid_numerical_stability() { + let tensor = TestTensor::<1>::from([300.0, -300.0]); + + let output = activation::log_sigmoid(tensor); + + // For large negative values, the previous implementation −log(1 + exp(−x)) would give -inf + let expected = TensorData::from([0.0, -300.0]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let tensor = TestTensor::<1>::from([FloatElem::MAX, FloatElem::MIN]); + let output = activation::log_sigmoid(tensor); + let expected = TensorData::from([0.elem(), FloatElem::MIN]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/log_softmax.rs b/crates/burn-backend-tests/tests/tensor/float/activation/log_softmax.rs new file mode 100644 index 0000000..979a671 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/log_softmax.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_log_softmax_d2() { + let tensor = TestTensor::<2>::from([[1.0, 0.0], [0.0, 1.0]]); + + let output = activation::log_softmax(tensor, 1); + let expected = TensorData::from([[-0.3132617, -1.3132617], [-1.3132617, -0.3132617]]); + + let tolerance = Tolerance::rel_abs(0.01, 0.0001); + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/mish.rs b/crates/burn-backend-tests/tests/tensor/float/activation/mish.rs new file mode 100644 index 0000000..2cb2f20 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/mish.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_mish() { + let tensor = TestTensor::<2>::from([[-0.4240, -0.9574, -0.2215], [-0.5767, 0.7218, -0.1620]]); + + let output = activation::mish(tensor); + let expected = TensorData::from([ + [-0.19709, -0.30056, -0.11714], + [-0.24132, 0.58235, -0.08877], + ]); + + // Metal has less precise trigonometric functions (tanh inside mish) + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/mod.rs b/crates/burn-backend-tests/tests/tensor/float/activation/mod.rs new file mode 100644 index 0000000..7ffadb8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/mod.rs @@ -0,0 +1,23 @@ +use super::*; + +mod celu; +mod elu; +mod gelu; +mod glu; +mod hard_sigmoid; +mod leaky_relu; +mod log_sigmoid; +mod log_softmax; +mod mish; +mod prelu; +mod quiet_softmax; +mod relu; +mod selu; +mod sigmoid; +mod silu; +mod softmax; +mod softmin; +mod softplus; +mod softsign; +mod tanh_activation; +mod thresholded_relu; diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/prelu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/prelu.rs new file mode 100644 index 0000000..f4d1c35 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/prelu.rs @@ -0,0 +1,101 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_prelu_2_dimension() { + let data = [ + [-1.1, 0.0, 1.2, 0.25, -5.4], + [-4.567, 0.56, -1.55, 99.9, 0.0], + ]; + let tensor = TestTensor::<2>::from(data); + let output = activation::prelu(tensor, TestTensor::from([0.5, 0.25, 0.0, -0.8, -0.4])); + let expected = TensorData::from([ + [-0.5500, 0.0000, 1.2000, 0.2500, 2.1600], + [-2.2835, 0.5600, -0.0000, 99.9000, -0.0000], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} +#[test] +fn test_prelu_2_dimension_scalar_weight() { + let data = [ + [-1.1, 0.0, 1.2, 0.25, -5.4], + [-4.567, 0.56, -1.55, 99.9, 0.0], + ]; + let tensor = TestTensor::<2>::from(data); + let output = activation::prelu(tensor, TestTensor::from([-0.8])); + let expected = TensorData::from([ + [0.8800, -0.0000, 1.2000, 0.2500, 4.3200], + [3.6536, 0.5600, 1.2400, 99.9000, -0.0000], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_prelu_positives() { + // Check that positives are untouched + let data = [[ + 0.5447, 0.9809, 0.4114, 0.1398, 0.8045, 0.4103, 0.2388, 0.5262, 0.6677, 0.6737, + ]]; + let tensor = TestTensor::<2>::from(data); + let output = activation::prelu(tensor, TestTensor::from([0.25])); + let expected = TensorData::from(data); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_prelu_zero_weight() { + // test that with weight 0 it behaves as relu + let data = [-1.1, 0.0, 1.2, 0.25, -5.4]; + let tensor = TestTensor::<1>::from(data); + let output = activation::prelu(tensor, TestTensor::from([0.0])); + let expected = TensorData::from([0.0, 0.0, 1.2, 0.25, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_prelu_some_weight() { + // test that with some non zero weight it works like leaky relu + let data = [-1.1, 0.0, 1.2, 0.25, -5.4]; + let tensor = TestTensor::<1>::from(data); + let output = activation::prelu(tensor, TestTensor::from([0.5])); + let expected = TensorData::from([-0.550, 0.0, 1.20, 0.250, -2.70]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[should_panic] +fn test_prelu_single_dim_multi_weight() { + // should panic because the data has only 1 channel + let data = [-1.1, 2.0, 1.2, 0.25, -5.4]; + let tensor = TestTensor::<1>::from(data); + let data_actual = + activation::prelu(tensor, TestTensor::from([0.5, -0.25, 0.0, 0.5, -1.0])).into_data(); + let data_expected = TensorData::from([-0.550, 0.0, 1.20, 0.250, -2.70]); + data_expected.assert_approx_eq::(&data_actual, Tolerance::default()); +} + +#[test] +#[should_panic] +fn test_prelu_multi_dim_wrong_weights() { + let data = [ + [-1.1, 0.0, 1.2, 0.25, -5.4], + [-4.567, 0.56, -1.55, 99.9, 0.0], + ]; + let tensor = TestTensor::<2>::from(data); + let _ = activation::prelu(tensor, TestTensor::from([-0.8, 0.1])); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/quiet_softmax.rs b/crates/burn-backend-tests/tests/tensor/float/activation/quiet_softmax.rs new file mode 100644 index 0000000..32e0385 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/quiet_softmax.rs @@ -0,0 +1,15 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_quiet_softmax_d2() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::quiet_softmax(tensor, 1); + let expected = TensorData::from([[2.47e-03, 9.975e-01], [1.0, 1.1254e-07]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/relu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/relu.rs new file mode 100644 index 0000000..8cdb707 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/relu.rs @@ -0,0 +1,25 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance, activation}; + +#[test] +fn test_relu_d2() { + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, -4.0, 5.0]]); + + let output = activation::relu(tensor); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 0.0, 2.0], [3.0, 0.0, 5.0]]), false); +} + +#[test] +fn test_relu_d1() { + let tensor = TestTensor::<1>::from([-2.0, -1.0, 0.0, 1.0, 2.0]); + + let output = activation::relu(tensor); + + output.into_data().assert_approx_eq::( + &TensorData::from([0.0, 0.0, 0.0, 1.0, 2.0]), + Tolerance::absolute(1e-6), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/selu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/selu.rs new file mode 100644 index 0000000..a17109a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/selu.rs @@ -0,0 +1,37 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_selu() { + // selu(x) = gamma * x if x > 0, gamma * alpha * (exp(x) - 1) if x <= 0 + // alpha = 1.6733, gamma = 1.0507 + let tensor = TestTensor::<2>::from([[0.0, 1.0, -1.0], [2.0, -2.0, 0.5]]); + + let output = activation::selu(tensor); + + // Expected values computed from the formula: + // selu(0.0) = 1.0507 * 1.6733 * (exp(0) - 1) = 0.0 + // selu(1.0) = 1.0507 * 1.0 = 1.0507 + // selu(-1.0) = 1.0507 * 1.6733 * (exp(-1) - 1) = 1.7581 * (0.3679 - 1) = -1.1113 + // selu(2.0) = 1.0507 * 2.0 = 2.1014 + // selu(-2.0) = 1.0507 * 1.6733 * (exp(-2) - 1) = 1.7581 * (0.1353 - 1) = -1.5202 + // selu(0.5) = 1.0507 * 0.5 = 0.5254 + let expected = TensorData::from([[0.0, 1.0507, -1.1113], [2.1014, -1.5202, 0.5254]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_selu_zero() { + let tensor = TestTensor::<1>::from([0.0]); + + let output = activation::selu(tensor); + let expected = TensorData::from([0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/sigmoid.rs b/crates/burn-backend-tests/tests/tensor/float/activation/sigmoid.rs new file mode 100644 index 0000000..aaf95ae --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/sigmoid.rs @@ -0,0 +1,39 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_sigmoid() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::sigmoid(tensor); + let expected = TensorData::from([[0.731059, 0.999089], [0.999998, 0.047426]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_sigmoid_d1() { + let tensor = TestTensor::<1>::from([-10.0, 0.0, 10.0]); + + let output = activation::sigmoid(tensor); + + output.into_data().assert_approx_eq::( + &TensorData::from([0.0, 0.5, 1.0]), + Tolerance::absolute(1e-3), + ); +} + +#[test] +fn test_sigmoid_overflow() { + let tensor = TestTensor::<1>::from([FloatElem::MAX, FloatElem::MIN]); + + let output = activation::sigmoid(tensor); + let expected = TensorData::from([1.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/silu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/silu.rs new file mode 100644 index 0000000..da62121 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/silu.rs @@ -0,0 +1,15 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_silu() { + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let output = activation::silu(tensor); + let expected = TensorData::from([[0.73106, 1.76159], [2.85772, 3.92806]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/softmax.rs b/crates/burn-backend-tests/tests/tensor/float/activation/softmax.rs new file mode 100644 index 0000000..6f57e09 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/softmax.rs @@ -0,0 +1,129 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_softmax_d2() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::softmax(tensor, 1); + let expected = TensorData::from([[2.472623e-03, 9.975274e-01], [1.0, 1.125352e-07]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_softmax_d1() { + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0]); + + let output = activation::softmax(tensor, 0); + + output.into_data().assert_approx_eq::( + &TensorData::from([0.09003, 0.24473, 0.66524]), + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_softmax_d2_varied() { + let tensor = TestTensor::<2>::from([[-1.0, 0.0, 1.0, 2.0], [0.5, 0.5, 0.5, 0.5]]); + + let output = activation::softmax(tensor, 1); + + output.into_data().assert_approx_eq::( + &TensorData::from([ + [0.03205860, 0.08714432, 0.23688284, 0.64391422], + [0.25, 0.25, 0.25, 0.25], + ]), + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_softmax_d3_last_axis() { + let tensor = TestTensor::<3>::from([ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[0.0, 0.0, 1.0], [1.0, 1.0, 1.0]], + ]); + + let output = activation::softmax(tensor, 2); + + output.into_data().assert_approx_eq::( + &TensorData::from([ + [ + [0.09003057, 0.24472848, 0.66524094], + [0.09003057, 0.24472848, 0.66524094], + ], + [ + [0.21194156, 0.21194156, 0.57611688], + [0.33333334, 0.33333334, 0.33333334], + ], + ]), + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_softmax_non_contiguous_input() { + // Softmax on a transposed (non-contiguous) input. The [3, 4] tensor is + // transposed to [4, 3] before softmax over the last axis, exercising + // stride-aware handling of the op in every backend. + // + // Every transposed row has the pattern [a, a+4, a+8] (columns of the + // original tensor); softmax is shift-invariant, so every row shares + // the same result. + let t = TestTensor::<2>::from([ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]); + let t_transposed = t.transpose(); + + let output = activation::softmax(t_transposed, 1); + + let row = [3.2932044e-4, 1.7980287e-2, 0.98169035]; + let expected = TensorData::from([row, row, row, row]); + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_softmax_non_last_axis_d3() { + let tensor = TestTensor::<3>::from([ + [[1.0, -2.0, 0.5], [3.0, 0.0, -1.0]], + [[0.1, 2.5, -0.3], [1.2, -0.7, 2.1]], + ]); + + let output = activation::softmax(tensor.clone(), 1); + + // Reference: softmax along middle axis computed via exp / sum pattern. + let exp = tensor.exp(); + let sum = exp.clone().sum_dim(1); + let expected = exp / sum; + + output + .into_data() + .assert_approx_eq::(&expected.into_data(), Tolerance::default()); +} + +#[test] +fn test_softmax_non_last_axis_d4_dim0() { + let tensor = TestTensor::<4>::from([ + [[[1.0, -0.5], [0.3, 2.1]], [[-1.2, 0.0], [0.8, 1.5]]], + [[[0.4, -1.1], [2.0, 0.2]], [[-0.3, 0.9], [1.1, -0.7]]], + ]); + + let output = activation::softmax(tensor.clone(), 0); + + let exp = tensor.exp(); + let sum = exp.clone().sum_dim(0); + let expected = exp / sum; + + output + .into_data() + .assert_approx_eq::(&expected.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/softmin.rs b/crates/burn-backend-tests/tests/tensor/float/activation/softmin.rs new file mode 100644 index 0000000..e513c45 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/softmin.rs @@ -0,0 +1,15 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_softmin_d2() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::softmin(tensor, 1); + let expected = TensorData::from([[9.975274e-01, 2.472623e-03], [1.125352e-07, 1.0000]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/softplus.rs b/crates/burn-backend-tests/tests/tensor/float/activation/softplus.rs new file mode 100644 index 0000000..a0911e2 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/softplus.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_softplus_d2() { + let tensor = TestTensor::<2>::from([[-0.4240, -0.9574, -0.2215], [-0.5767, 0.7218, -0.1620]]); + + let output = activation::softplus(tensor.clone(), 1.0); + let expected = TensorData::from([ + [0.503453, 0.324898, 0.588517], + [0.445806, 1.117805, 0.615424], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let output = activation::softplus(tensor, 2.0); + let expected = TensorData::from([ + [0.178232, 0.068737, 0.247990], + [0.137132, 0.827771, 0.272106], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/softsign.rs b/crates/burn-backend-tests/tests/tensor/float/activation/softsign.rs new file mode 100644 index 0000000..a4c1334 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/softsign.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_softsign() { + let tensor = TestTensor::<2>::from([[1.0, 7.0], [13.0, -3.0]]); + + let output = activation::softsign(tensor); + let expected = TensorData::from([[0.5, 0.875], [0.928571, -0.75]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_softsign_zero() { + let tensor = TestTensor::<1>::from([0.0]); + + let output = activation::softsign(tensor); + let expected = TensorData::from([0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/tanh_activation.rs b/crates/burn-backend-tests/tests/tensor/float/activation/tanh_activation.rs new file mode 100644 index 0000000..2015672 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/tanh_activation.rs @@ -0,0 +1,15 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_tanh() { + let tensor = TestTensor::<2>::from([[1., 2.], [3., 4.]]); + + let output = activation::tanh(tensor); + let expected = TensorData::from([[0.761594, 0.964028], [0.995055, 0.999329]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/activation/thresholded_relu.rs b/crates/burn-backend-tests/tests/tensor/float/activation/thresholded_relu.rs new file mode 100644 index 0000000..d1be8eb --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/activation/thresholded_relu.rs @@ -0,0 +1,26 @@ +use super::*; +use burn_tensor::{TensorData, activation}; + +#[test] +fn test_thresholded_relu_d2() { + // alpha = 1.0 (ONNX default): x if x > 1.0, else 0 + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, 1.0, 0.5]]); + + let output = activation::thresholded_relu(tensor, 1.0); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 0.0, 2.0], [3.0, 0.0, 0.0]]), false); +} + +#[test] +fn test_thresholded_relu_d2_alpha() { + // alpha = 0.5: x if x > 0.5, else 0 + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, 0.5, 0.6]]); + + let output = activation::thresholded_relu(tensor, 0.5); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 0.0, 2.0], [3.0, 0.0, 0.6]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/grid/affine_grid.rs b/crates/burn-backend-tests/tests/tensor/float/grid/affine_grid.rs new file mode 100644 index 0000000..9a67b35 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/grid/affine_grid.rs @@ -0,0 +1,82 @@ +use super::*; +use burn_tensor::grid::affine_grid_2d; + +fn create_identity_transform(batch_size: usize) -> TestTensor<3> { + // Identity affine transform (batch_size, 2, 3) + TestTensor::<3>::from([[[1f32, 0., 0.], [0., 1., 0.]]]).expand([batch_size, 2, 3]) +} + +#[test] +fn test_affine_grid_identity() { + let batch_size = 1; + let channels = 1; + let height = 2; + let width = 2; + + let transform = create_identity_transform(batch_size); + + let output = affine_grid_2d(transform, [batch_size, channels, height, width]); + + // Expected normalized coords: + // [-1, -1], [ 1,-1] + // [-1, 1], [ 1, 1] + let expected = TestTensor::<4>::from([[ + [[-1f32, -1f32], [1f32, -1f32]], + [[-1f32, 1f32], [1f32, 1f32]], + ]]); + + output.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn test_affine_grid_scaling() { + let batch_size = 1; + let channels = 1; + let height = 2; + let width = 2; + + let scale = 2.0f32; + let transform = TestTensor::<3>::from([[[scale, 0., 0.], [0., scale, 0.]]]); + + let output = affine_grid_2d(transform, [batch_size, channels, height, width]); + + // Expect scaled coordinates from normalized grid, so coords * 2 + let expected = TestTensor::<4>::from([[ + [[-2f32, -2f32], [2f32, -2f32]], + [[-2f32, 2f32], [2f32, 2f32]], + ]]); + + output.into_data().assert_eq(&expected.into_data(), false); +} + +#[test] +fn test_affine_grid_translation() { + let batch_size = 1; + let channels = 1; + let height = 2; + let width = 2; + + // Translate by 0.5 in x and -0.5 in y (normalized coords) + let tx = 0.5f32; + let ty = -0.5f32; + + let transform = TestTensor::<3>::from([[[1.0, 0.0, tx], [0.0, 1.0, ty]]]); + + let output = affine_grid_2d(transform, [batch_size, channels, height, width]); + + // Expected coordinates: + // Original normalized coords are [-1,1] in x and y + // After translation, each coordinate shifts by tx and ty + // So points become: + // [-1 + 0.5, -1 - 0.5] = [-0.5, -1.5] + // [ 1 + 0.5, -1 - 0.5] = [1.5, -1.5] + // [-1 + 0.5, 1 - 0.5] = [-0.5, 0.5] + // [ 1 + 0.5, 1 - 0.5] = [1.5, 0.5] + + let expected = TestTensor::<4>::from([[ + [[-0.5f32, -1.5f32], [1.5f32, -1.5f32]], + [[-0.5f32, 0.5f32], [1.5f32, 0.5f32]], + ]]); + + output.into_data().assert_eq(&expected.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/grid/meshgrid.rs b/crates/burn-backend-tests/tests/tensor/float/grid/meshgrid.rs new file mode 100644 index 0000000..65de990 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/grid/meshgrid.rs @@ -0,0 +1,150 @@ +use super::*; +use burn_tensor::Tensor; +use burn_tensor::TensorData; +use burn_tensor::grid::{ + GridIndexing, GridOptions, GridSparsity, IndexPos, meshgrid, meshgrid_stack, +}; +use burn_tensor::kind::Basic; + +fn assert_tensors_equal(actual: &[Tensor; N], expected: &[Tensor; N]) +where + K: Basic, +{ + for (a, e) in actual.iter().zip(expected.iter()) { + a.clone() + .into_data() + .assert_eq(&e.clone().into_data(), true); + } +} + +#[test] +fn test_meshgrid() { + let x = TestTensor::<1>::from([1, 2, 3, 4]); + let y = TestTensor::<1>::from([5, 6]); + let z = TestTensor::<1>::from([7, 8]); + + let grid_shape = [x.dims()[0], y.dims()[0], z.dims()[0]]; + + // 3D, Dense, Matrix + assert_tensors_equal( + &meshgrid(&[x.clone(), y.clone(), z.clone()], GridOptions::default()), + &[ + x.clone().reshape([4, 1, 1]).expand(grid_shape), + y.clone().reshape([1, 2, 1]).expand(grid_shape), + z.clone().reshape([1, 1, 2]).expand(grid_shape), + ], + ); + assert_tensors_equal( + &meshgrid(&[x.clone(), y.clone(), z.clone()], GridSparsity::Dense), + &[ + x.clone().reshape([4, 1, 1]).expand(grid_shape), + y.clone().reshape([1, 2, 1]).expand(grid_shape), + z.clone().reshape([1, 1, 2]).expand(grid_shape), + ], + ); + assert_tensors_equal( + &meshgrid(&[x.clone(), y.clone(), z.clone()], GridIndexing::Matrix), + &[ + x.clone().reshape([4, 1, 1]).expand(grid_shape), + y.clone().reshape([1, 2, 1]).expand(grid_shape), + z.clone().reshape([1, 1, 2]).expand(grid_shape), + ], + ); + + // 3D, Sparse, Matrix + assert_tensors_equal( + &meshgrid( + &[x.clone(), y.clone(), z.clone()], + GridOptions { + indexing: GridIndexing::Matrix, + sparsity: GridSparsity::Sparse, + }, + ), + &[ + x.clone().reshape([4, 1, 1]), + y.clone().reshape([1, 2, 1]), + z.clone().reshape([1, 1, 2]), + ], + ); + assert_tensors_equal( + &meshgrid(&[x.clone(), y.clone(), z.clone()], GridSparsity::Sparse), + &[ + x.clone().reshape([4, 1, 1]), + y.clone().reshape([1, 2, 1]), + z.clone().reshape([1, 1, 2]), + ], + ); + + // 3D, Dense, Cartesian + assert_tensors_equal( + &meshgrid(&[x.clone(), y.clone(), z.clone()], GridIndexing::Cartesian), + &[ + x.clone() + .reshape([4, 1, 1]) + .expand(grid_shape) + .swap_dims(0, 1), + y.clone() + .reshape([1, 2, 1]) + .expand(grid_shape) + .swap_dims(0, 1), + z.clone() + .reshape([1, 1, 2]) + .expand(grid_shape) + .swap_dims(0, 1), + ], + ); + + // 3D, Sparse, Cartesian + assert_tensors_equal( + &meshgrid( + &[x.clone(), y.clone(), z.clone()], + GridOptions::new(GridIndexing::Cartesian, GridSparsity::Sparse), + ), + &[ + x.clone().reshape([4, 1, 1]).swap_dims(0, 1), + y.clone().reshape([1, 2, 1]).swap_dims(0, 1), + z.clone().reshape([1, 1, 2]).swap_dims(0, 1), + ], + ); + assert_tensors_equal( + &meshgrid( + &[x.clone(), y.clone(), z.clone()], + GridOptions { + indexing: GridIndexing::Cartesian, + sparsity: GridSparsity::Sparse, + }, + ), + &[ + x.clone().reshape([4, 1, 1]).swap_dims(0, 1), + y.clone().reshape([1, 2, 1]).swap_dims(0, 1), + z.clone().reshape([1, 1, 2]).swap_dims(0, 1), + ], + ); +} + +#[test] +fn test_meshgrid_stack() { + let tensors = [ + TestTensor::from([0.5, 1.0, 2.5]), + TestTensor::from([0.5, 1.0]), + ]; + + let result: Tensor<3> = meshgrid_stack(&tensors, IndexPos::First); + result.to_data().assert_eq( + &TensorData::from([ + [[0.5, 0.5], [1.0, 1.0], [2.5, 2.5]], + [[0.5, 1.0], [0.5, 1.0], [0.5, 1.0]], + ]), + false, + ); + + let result: Tensor<3> = meshgrid_stack(&tensors, IndexPos::Last); + result.to_data().assert_eq( + &TensorData::from([ + [[0.5, 0.5], [0.5, 1.0]], + [[1.0, 0.5], [1.0, 1.0]], + [[2.5, 0.5], [2.5, 1.0]], + ]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/grid/mod.rs b/crates/burn-backend-tests/tests/tensor/float/grid/mod.rs new file mode 100644 index 0000000..e2f874c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/grid/mod.rs @@ -0,0 +1,4 @@ +use super::*; + +pub(crate) mod affine_grid; +pub(crate) mod meshgrid; diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/cosine_similarity.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/cosine_similarity.rs new file mode 100644 index 0000000..644148a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/cosine_similarity.rs @@ -0,0 +1,100 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{TensorData, linalg}; + +#[test] +fn test_cosine_similarity_basic() { + // Create test tensors + let x1 = TestTensor::<2>::from([[1.0, 2.0, 3.0], [0.5, 1.5, 2.5]]); + let x2 = TestTensor::<2>::from([[1.5, 2.5, 3.5], [0.7, 1.7, 2.7]]); + + // Test cosine similarity along dimension 1 + let expected = TensorData::from([[0.99983203], [0.99987257]]); + linalg::cosine_similarity(x1.clone(), x2.clone(), 1, None) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + // Test with explicit epsilon + linalg::cosine_similarity(x1.clone(), x2.clone(), 1, Some(1e-8)) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_cosine_similarity_orthogonal() { + // Create orthogonal vectors + let x1 = TestTensor::<2>::from([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let x2 = TestTensor::<2>::from([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + + // Orthogonal vectors should have cosine similarity of 0 + let expected = TensorData::from([[0.0], [0.0]]); + linalg::cosine_similarity(x1, x2, 1, None) + .into_data() + .assert_eq(&expected, false); +} + +#[test] +fn test_cosine_similarity_parallel() { + // Create parallel vectors + let x1 = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let x2 = TestTensor::<2>::from([[2.0, 4.0, 6.0], [8.0, 10.0, 12.0]]); + + // Parallel vectors should have cosine similarity of 1 + let expected = TensorData::from([[1.0], [1.0]]); + linalg::cosine_similarity(x1, x2, 1, None) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_cosine_similarity_opposite() { + // Create opposite direction vectors + let x1 = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let x2 = TestTensor::<2>::from([[-1.0, -2.0, -3.0], [-4.0, -5.0, -6.0]]); + + // Opposite vectors should have cosine similarity of -1 + let expected = TensorData::from([[-1.0], [-1.0]]); + linalg::cosine_similarity(x1, x2, 1, None) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_cosine_similarity_different_dimension() { + // Test with a 3D tensor + let x1 = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]); + let x2 = TestTensor::<3>::from([[[2.0, 3.0], [4.0, 5.0]], [[6.0, 7.0], [8.0, 9.0]]]); + + // Test along dimension 2 + let expected = TensorData::from([[[0.9959688], [0.9958376]], [[0.9955946], [0.9955169]]]); + + // sensitive to rounding in dot/norm; loosen f16 tolerance + let tolerance = Tolerance::default().set_half_precision_relative(7e-3); + + linalg::cosine_similarity(x1.clone(), x2.clone(), 2, None) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + // Test with negative dimension (-1 is the last dimension, which is 2 in this case) + linalg::cosine_similarity(x1.clone(), x2.clone(), -1, None) + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_cosine_similarity_near_zero() { + // Test with near-zero vectors + let x1 = TestTensor::<2>::from([[1e-10, 2e-10, 3e-10], [4e-10, 5e-10, 6e-10]]); + let x2 = TestTensor::<2>::from([[2e-10, 4e-10, 6e-10], [8e-10, 10e-10, 12e-10]]); + + // Update the expected values based on the actual implementation behavior + let expected = TensorData::from([[0.0028], [0.0154]]); + + // Smaller values result in NaN on metal f16 + let epsilon = Some(1e-2); + let tolerance = Tolerance::absolute(0.2); + + linalg::cosine_similarity(x1, x2, 1, epsilon) + .into_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/det.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/det.rs new file mode 100644 index 0000000..05b8de1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/det.rs @@ -0,0 +1,537 @@ +use super::*; +use burn_tensor::{Distribution, Tolerance, linalg::det, s}; + +// --------------------------------------------------------------------- +// Small Matrices (single batch) +// --------------------------------------------------------------------- + +#[test] +fn test_det_1x1_batched() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[5.0]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([5.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_2x2_batched() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[4.0, 3.0], [6.0, 3.0]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + // det = 4*3 - 3*6 = 12 - 18 = -6 + let expected = TestTensor::<1>::from_data([-6.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_batched() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [[[4.0, 7.0, 3.0], [6.0, 1.0, 3.0], [8.0, 3.0, 7.0]]], + &device, + ); + let det_tensor = det::<3, 2, 1>(tensor); + // det = 4*(1*7 - 3*3) - 7*(6*7 - 3*8) + 3*(6*3 - 1*8) + // = 4*(7-9) - 7*(42-24) + 3*(18-8) = 4*(-2) - 7*18 + 3*10 = -8 -126 +30 = -104 + let expected = TestTensor::<1>::from_data([-104.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// Special Matrices (single batch) +// --------------------------------------------------------------------- + +#[test] +fn test_det_3x3_identity_matrix() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([1.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_300x300_identity_matrix() { + let device = Default::default(); + let tensor = TestTensor::eye(300, &device).unsqueeze_dim(0); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([1.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_singular_zero_col() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [[[0.0, 4.0, 2.0], [0.0, 2.0, 1.0], [0.0, 3.0, 9.0]]], + &device, + ); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_20x20_singular_zero_col() { + let device = Default::default(); + let mut tensor = TestTensor::random([1, 20, 20], Distribution::Default, &device); + tensor = tensor.slice_fill(s![.., .., 16], 0.0); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_singular_linearly_dependent() { + let device = Default::default(); + // Rows: [1,2,3], [2,4,6], [3,6,9] -> linearly dependent + let tensor = TestTensor::<3>::from_data([[[1., 2., 3.], [2., 4., 6.], [3., 6., 9.]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_20x20_singular_linearly_dependent() { + let device = Default::default(); + let mut tensor = TestTensor::random([1, 20, 20], Distribution::Default, &device); + let lin_dep_row1 = tensor.clone().slice(s![.., 5, ..]); + let lin_dep_row2 = lin_dep_row1.mul_scalar(3); + tensor = tensor.slice_assign(s![.., 17, ..], lin_dep_row2); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_upper_triangular() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[1., 2., 3.], [0., 4., 5.], [0., 0., 6.]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + // det = 1 * 4 * 6 = 24 + let expected = TestTensor::<1>::from_data([24.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_lower_triangular() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[2., 0., 0.], [3., 4., 0.], [5., 6., 7.]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + // det = 2 * 4 * 7 = 56 + let expected = TestTensor::<1>::from_data([56.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_3x3_diagonal_matrix() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[3., 0., 0.], [0., 5., 0.], [0., 0., 2.]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + // det = 3 * 5 * 2 = 30 + let expected = TestTensor::<1>::from_data([30.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_10x10_all_zeros() { + let device = Default::default(); + let tensor = TestTensor::<3>::zeros([1, 10, 10], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_300x300_all_zeros() { + let device = Default::default(); + let tensor = TestTensor::<3>::zeros([1, 300, 300], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_10x10_all_ones() { + let device = Default::default(); + // matrix of all ones has rank 1 -> det = 0 + let tensor = TestTensor::<3>::ones([1, 10, 10], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_300x300_all_ones() { + let device = Default::default(); + // matrix of all ones has rank 1 -> det = 0 + let tensor = TestTensor::<3>::ones([1, 300, 300], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_large_diagonal() { + let device = Default::default(); + // 8x8 diagonal with known product: 8! = 40320 + let eye = Tensor::<2>::eye(8, &device); + let values = Tensor::arange(1..9, &device).float(); + let diag_2d = eye * values.unsqueeze::<2>(); + let tensor = diag_2d.unsqueeze_dim(0); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([40320.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_mixed_singular_non_singular_batch() { + let device = Default::default(); + let mut singular_tensor = Tensor::<2>::random([10, 10], Distribution::Default, &device); + singular_tensor = singular_tensor.slice_fill(s![.., 8], 0.0); + let identity_tensor = Tensor::<2>::eye(10, &device); + let input_tensor = Tensor::stack(vec![singular_tensor, identity_tensor], 0); + let det_tensor = det::<3, 2, 1>(input_tensor); + let expected = TestTensor::<1>::from_data([0.0, 1.0], &device); + let tolerance = Tolerance::default(); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// Batched Tensors (3D, multiple matrices) +// --------------------------------------------------------------------- + +#[test] +fn test_det_batch_of_1x1() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[2.0]], [[3.0]], [[5.0]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([2.0, 3.0, 5.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_batch_of_2x2() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0]], // det = -2 + [[2.0, 0.0], [0.0, 3.0]], // det = 6 + [[5.0, 6.0], [7.0, 8.0]], // det = -2 + ], + &device, + ); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([-2.0, 6.0, -2.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_batch_of_3x3() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[4.0, 7.0, 3.0], [6.0, 1.0, 3.0], [8.0, 3.0, 7.0]], // det = -104 + [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]], // det = 6 + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], // det = 0 (singular) + ], + &device, + ); + let det_tensor = det::<3, 2, 1>(tensor); + let expected = TestTensor::<1>::from_data([-104.0, 6.0, 0.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// 4D Tensors (two batch dimensions) +// --------------------------------------------------------------------- + +#[test] +fn test_det_4d_square() { + let device = Default::default(); + // Shape [2, 2, 3, 3] + let tensor = TestTensor::<4>::from_data( + [ + [ + [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], + [[2., 0., 0.], [0., 2., 0.], [0., 0., 2.]], + ], + [ + [[3., 0., 0.], [0., 3., 0.], [0., 0., 3.]], + [[4., 0., 0.], [0., 4., 0.], [0., 0., 4.]], + ], + ], + &device, + ); + let det_tensor = det::<4, 3, 2>(tensor); + let expected = TestTensor::<2>::from_data([[1.0, 8.0], [27.0, 64.0]], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_4d_rectangular_batch() { + let device = Default::default(); + let tensor = TestTensor::<4>::random([3, 4, 5, 5], Distribution::Default, &device); + let det_tensor = det::<4, 3, 2>(tensor.clone()); + assert_eq!(det_tensor.dims(), [3, 4]); +} + +// --------------------------------------------------------------------- +// Edge Cases and Properties +// --------------------------------------------------------------------- + +#[test] +fn test_det_product_property() { + let device = Default::default(); + // det(A * B) = det(A) * det(B) + let a = TestTensor::<3>::random([1, 10, 10], Distribution::Default, &device); + let b = TestTensor::<3>::random([1, 10, 10], Distribution::Default, &device); + let c = a.clone().matmul(b.clone()); + let det_a = det::<3, 2, 1>(a); + let det_b = det::<3, 2, 1>(b); + let det_c = det::<3, 2, 1>(c); + let det_ab = det_a * det_b; + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_c + .into_data() + .assert_approx_eq::(&det_ab.into_data(), tolerance); +} + +#[test] +fn test_det_transpose_invariance() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([1, 10, 10], Distribution::Default, &device); + let det_original = det::<3, 2, 1>(tensor.clone()); + let det_transpose = det::<3, 2, 1>(tensor.transpose()); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_original + .into_data() + .assert_approx_eq::(&det_transpose.into_data(), tolerance); +} + +#[test] +fn test_det_negative_elements() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[-1.0, 2.0], [3.0, -4.0]]], &device); + let det_tensor = det::<3, 2, 1>(tensor); + // det = (-1)*(-4) - (2*3) = 4 - 6 = -2 + let expected = TestTensor::<1>::from_data([-2.0], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_f16_dtype_roundtrip_1x1() { + // Does not perform upcasting when the tests are run in normal mode (not f16) + let device = Default::default(); + let tensor = TestTensor::<3>::random([1, 1, 1], Distribution::Default, &device); + let det_tensor = det::<3, 2, 1>(tensor.clone()); + assert_eq!(tensor.dtype(), det_tensor.dtype()); +} + +#[test] +fn test_det_f16_dtype_roundtrip_2x2() { + // Does not perform upcasting when the tests are run in normal mode (not f16) + let device = Default::default(); + let tensor = TestTensor::<3>::random([1, 2, 2], Distribution::Default, &device); + let det_tensor = det::<3, 2, 1>(tensor.clone()); + assert_eq!(tensor.dtype(), det_tensor.dtype()); +} + +#[test] +fn test_det_f16_dtype_roundtrip_3x3() { + // Does not perform upcasting when the tests are run in normal mode (not f16) + let device = Default::default(); + let tensor = TestTensor::<3>::random([1, 3, 3], Distribution::Default, &device); + let det_tensor = det::<3, 2, 1>(tensor.clone()); + assert_eq!(tensor.dtype(), det_tensor.dtype()); +} + +#[test] +fn test_det_f16_dtype_roundtrip_10x10() { + // Does not perform upcasting when the tests are run in normal mode (not f16) + let device = Default::default(); + let tensor = TestTensor::<3>::random([1, 10, 10], Distribution::Default, &device); + let det_tensor = det::<3, 2, 1>(tensor.clone()); + assert_eq!(tensor.dtype(), det_tensor.dtype()); +} + +// --------------------------------------------------------------------- +// Panic Tests (invalid input) +// --------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "The input tensor must have at least 3 dimensions")] +fn test_det_panic_rank_less_than_3() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let _ = det::<2, 1, 0>(tensor); +} + +#[test] +#[should_panic(expected = "The last two dimensions of the input tensor must be equal")] +fn test_det_panic_non_square_last_dims() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]], &device); // shape [1,2,3] + let _ = det::<3, 2, 1>(tensor); +} + +#[test] +#[should_panic(expected = "D - 1 = D1 must hold for the generic parameters")] +fn test_det_panic_invalid_d1_generic() { + let device = Default::default(); + let tensor = TestTensor::<3>::ones([1, 2, 2], &device); + let _ = det::<3, 3, 2>(tensor); +} + +#[test] +#[should_panic(expected = "The output tensor rank must be less than input tensor rank by 2")] +fn test_det_panic_invalid_d2_generic() { + let device = Default::default(); + let tensor = TestTensor::<3>::ones([1, 2, 2], &device); + let _ = det::<3, 2, 2>(tensor); +} + +// --------------------------------------------------------------------- +// Random Tensors (expected determinants from PyTorch) +// --------------------------------------------------------------------- + +#[test] +fn test_det_random_2x3x3_pytorch_comparison() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [ + [0.8823, 0.9150, 0.3829], + [0.9593, 0.3904, 0.6009], + [0.2566, 0.7936, 0.9408], + ], + [ + [0.1332, 0.9346, 0.5936], + [0.8694, 0.5677, 0.7411], + [0.4294, 0.8854, 0.5739], + ], + ], + &device, + ); + let det_tensor = det::<3, 2, 1>(tensor); + // Expected determinants computed by PyTorch + let expected = TestTensor::<1>::from_data([-0.5283, 0.0993], &device); + let tolerance = Tolerance::default().set_half_precision_absolute(5e-3); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +#[test] +fn test_det_random_2x2x4x4_pytorch_comparison() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [ + [ + [ + [0.7713, 0.0208, 0.6336, 0.7488], + [0.4985, 0.2248, 0.1981, 0.7605], + [0.1691, 0.0883, 0.6854, 0.9534], + [0.0039, 0.5122, 0.8126, 0.6125], + ], + [ + [0.7218, 0.2919, 0.9178, 0.7146], + [0.5425, 0.1422, 0.3733, 0.6741], + [0.4418, 0.4340, 0.6178, 0.5131], + [0.6503, 0.6011, 0.8052, 0.6985], + ], + ], + [ + [ + [0.2922, 0.0077, 0.5508, 0.5940], + [0.4512, 0.3444, 0.3599, 0.6226], + [0.8929, 0.8856, 0.1539, 0.8586], + [0.7683, 0.8819, 0.9567, 0.1534], + ], + [ + [0.4885, 0.6690, 0.9967, 0.7013], + [0.5040, 0.2374, 0.1123, 0.1505], + [0.3819, 0.4428, 0.0835, 0.7866], + [0.6515, 0.7998, 0.1242, 0.5865], + ], + ], + ], + &device, + ); + let det_tensor = det::<4, 3, 2>(tensor); + // Expected determinants computed by PyTorch + let expected = TestTensor::<2>::from_data([[-0.1527, -0.0053], [0.0307, -0.1039]], &device); + let tolerance = Tolerance::default().set_absolute(1e-4); + det_tensor + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/diag.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/diag.rs new file mode 100644 index 0000000..c5930dc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/diag.rs @@ -0,0 +1,259 @@ +use super::*; +use burn_tensor::{TensorData, linalg::diag}; + +#[test] +fn test_diag_2d_square() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let result = diag::<2, 1, _>(tensor); + let expected = TensorData::from([1.0, 4.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_2d_tall() { + let device = Default::default(); + // 4x2 matrix (tall) - min(4,2) = 2 diagonal elements + let tensor = + TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]], &device); + let result = diag::<2, 1, _>(tensor); + // Result should have shape [2] with values [1.0, 4.0] + let expected = TensorData::from([1.0, 4.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_2d_wide() { + let device = Default::default(); + // 2x4 matrix (wide) - min(2,4) = 2 diagonal elements + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], &device); + let result = diag::<2, 1, _>(tensor); + // Result should have shape [2] with values [1.0, 6.0] + let expected = TensorData::from([1.0, 6.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_3d_batch_square() { + let device = Default::default(); + // Batch of 2 matrices, each 2x2 + let tensor = TestTensor::<3>::from_data( + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + &device, + ); + let result = diag::<3, 2, _>(tensor); + // Result should have shape [2, 2] + let expected = TensorData::from([[1.0, 4.0], [5.0, 8.0]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_3d_batch_tall() { + let device = Default::default(); + // Batch of 2 matrices, each 3x2 (tall) + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], + [[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]], + ], + &device, + ); + let result = diag::<3, 2, _>(tensor); + // Result should have shape [2, 2] - min(3,2) = 2 diagonal elements each + let expected = TensorData::from([[1.0, 4.0], [7.0, 10.0]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_3d_batch_wide() { + let device = Default::default(); + // Batch of 2 matrices, each 2x3 (wide) + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], + ], + &device, + ); + let result = diag::<3, 2, _>(tensor); + // Result should have shape [2, 2] - min(2,3) = 2 diagonal elements each + let expected = TensorData::from([[1.0, 5.0], [7.0, 11.0]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_4d_batch_channel_square() { + let device = Default::default(); + // [batch=2, channel=2, rows=2, cols=2] + let tensor = TestTensor::<4>::from_data( + [ + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + [[[9.0, 10.0], [11.0, 12.0]], [[13.0, 14.0], [15.0, 16.0]]], + ], + &device, + ); + let result = diag::<4, 3, _>(tensor); + // Result should have shape [2, 2, 2] + let expected = TensorData::from([[[1.0, 4.0], [5.0, 8.0]], [[9.0, 12.0], [13.0, 16.0]]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_4d_batch_channel_tall() { + let device = Default::default(); + // [batch=2, channel=1, rows=3, cols=2] + let tensor = TestTensor::<4>::from_data( + [ + [[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]], + [[[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]]], + ], + &device, + ); + let result = diag::<4, 3, _>(tensor); + // Result should have shape [2, 1, 2] - min(3,2) = 2 diagonal elements each + let expected = TensorData::from([[[1.0, 4.0]], [[7.0, 10.0]]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_4d_batch_channel_wide() { + let device = Default::default(); + // [batch=1, channel=2, rows=2, cols=4] + let tensor = TestTensor::<4>::from_data( + [[ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ]], + &device, + ); + let result = diag::<4, 3, _>(tensor); + // Result should have shape [1, 2, 2] - min(2,4) = 2 diagonal elements each + let expected = TensorData::from([[[1.0, 6.0], [9.0, 14.0]]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_1x1() { + let device = Default::default(); + // Single element matrix + let tensor = TestTensor::<2>::from_data([[5.0]], &device); + let result = diag::<2, 1, _>(tensor); + // Should return [5.0] with shape [1] + let expected = TensorData::from([5.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_single_row() { + let device = Default::default(); + // Single row matrix + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let result = diag::<2, 1, _>(tensor); + // min(1,3) = 1, should return [1.0] with shape [1] + let expected = TensorData::from([1.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_single_column() { + let device = Default::default(); + // Single column matrix + let tensor = TestTensor::<2>::from_data([[1.0], [2.0], [3.0]], &device); + let result = diag::<2, 1, _>(tensor); + // min(3,1) = 1, should return [1.0] with shape [1] + let expected = TensorData::from([1.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_zeros() { + let device = Default::default(); + // Matrix with zeros on diagonal + let tensor = TestTensor::<2>::from_data([[0.0, 1.0], [2.0, 0.0]], &device); + let result = diag::<2, 1, _>(tensor); + // Should extract diagonal: [0.0, 0.0] + let expected = TensorData::from([0.0, 0.0]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_batch_single_element() { + let device = Default::default(); + // Batch with single element matrices + let tensor = TestTensor::<3>::from_data([[[5.0]], [[7.0]]], &device); + let result = diag::<3, 2, _>(tensor); + // Should return [[5.0], [7.0]] with shape [2, 1] + let expected = TensorData::from([[5.0], [7.0]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_batch_mixed_zeros() { + let device = Default::default(); + // Batch with mixed zero and non-zero diagonal elements + let tensor = TestTensor::<3>::from_data( + [[[1.0, 2.0], [3.0, 0.0]], [[0.0, 5.0], [6.0, 7.0]]], + &device, + ); + let result = diag::<3, 2, _>(tensor); + // Should return [[1.0, 0.0], [0.0, 7.0]] with shape [2, 2] + let expected = TensorData::from([[1.0, 0.0], [0.0, 7.0]]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_int_tensor() { + let device = Default::default(); + // Test with integer tensor + let tensor = TestTensorInt::<2>::from_data([[1, 2], [3, 4]], &device); + let result = diag::<2, 1, _>(tensor); + // Result should have shape [2] with values [1, 4] + let expected = TensorData::from([1, 4]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_diag_int_3x3() { + let device = Default::default(); + // Test with 3x3 integer matrix + let tensor = TestTensorInt::<2>::from_data([[1, 2, 3], [4, 5, 6], [7, 8, 9]], &device); + let result = diag::<2, 1, _>(tensor); + // Result should have shape [3] with values [1, 5, 9] + let expected = TensorData::from([1, 5, 9]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn test_diag_1d_should_panic() { + let device = Default::default(); + // 1D tensor should panic - diagonal requires at least 2 dimensions + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &device); + let _result = diag::<1, 0, _>(tensor); +} + +#[test] +#[should_panic] +fn test_diag_wrong_output_rank_should_panic() { + let device = Default::default(); + // Providing wrong output rank should panic + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let _result = diag::<2, 2, _>(tensor); // Should be 2,1 not 2,2 +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/lu.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/lu.rs new file mode 100644 index 0000000..9f0affb --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/lu.rs @@ -0,0 +1,321 @@ +use super::*; +use burn_tensor::{Distribution, Tolerance, linalg::lu}; + +#[cfg(not(feature = "cuda"))] +const REL: f32 = 5e-3; +#[cfg(not(feature = "cuda"))] +const ABS: f32 = 1e-3; + +// CUDA may useTensor Core–accelerated matmul with reduced precision accumulation (TF32). +// In this block LU implementation (matmul-heavy), accumulation error and pivoting +// differences can amplify into large local elementwise deviations. +// TODO: for correctness it might be good to allow users to disable some of these configs in burn.toml. +#[cfg(feature = "cuda")] +const REL: f32 = 3e-2; +#[cfg(feature = "cuda")] +const ABS: f32 = 2e-2; + +// --------------------------------------------------------------------- +// Small Matrices +// --------------------------------------------------------------------- + +#[test] +fn test_lu_1x1() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[5.0]], &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_2x2() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[4.0, 3.0], [6.0, 3.0]], &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_3x3() { + let device = Default::default(); + let tensor = + TestTensor::<2>::from_data([[4.0, 7.0, 3.0], [6.0, 1.0, 3.0], [8.0, 3.0, 7.0]], &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// Special Matrices (using lu) +// --------------------------------------------------------------------- + +#[test] +fn test_lu_identity_matrix() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_singular_zero_pivot() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [0.0, 4.0, 2.0, 6.0], + [0.0, 2.0, 2.0, 11.0], + [0.0, 3.0, 9.0, 6.0], + [0.0, 7.0, 10.0, 9.0], + ], + &device, + ); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// 2D Tensors (no batch dimension) +// --------------------------------------------------------------------- + +#[test] +fn test_lu_2d_square() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([6, 6], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_2d_tall() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([8, 5], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_2d_wide() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([5, 8], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_medium_tall() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([256, 128], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_medium_wide() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([128, 256], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// 3D Tensors (1 batch dimension) +// --------------------------------------------------------------------- + +#[test] +fn test_lu_3d_square() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([3, 6, 6], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_3d_tall() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([3, 8, 5], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_3d_wide() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([3, 5, 8], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// 4D Tensors (2 batch dimensions) +// --------------------------------------------------------------------- + +#[test] +fn test_lu_4d_square() { + let device = Default::default(); + let tensor = TestTensor::<4>::random([2, 2, 6, 6], Distribution::Default, &device); + let (p, l, u) = lu::<4, 3>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_4d_tall() { + let device = Default::default(); + let tensor = TestTensor::<4>::random([2, 2, 8, 5], Distribution::Default, &device); + let (p, l, u) = lu::<4, 3>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_4d_wide() { + let device = Default::default(); + let tensor = TestTensor::<4>::random([2, 2, 5, 8], Distribution::Default, &device); + let (p, l, u) = lu::<4, 3>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(5e-2); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// Large Tensors (Triggers Block LU Dispatch) +// --------------------------------------------------------------------- + +// The block-dispatch tests below feed an unseeded 500-ish-element random +// matrix through LU + matmul reconstruction. On f16 the per-row error +// grows with n, so a borderline matrix can overshoot a tight absolute +// tolerance (seen: 6.1e-2 diff on a reconstructed value of 0.10 with +// 5e-2 tol). Using 1.5e-1 for f16 on these large sizes keeps the test +// deterministic across seeds while still catching real regressions +// (the f16 LU typo regression in #4738 produced errors of O(1)). + +#[test] +fn test_lu_500x500_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([500, 500], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_500x300_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([500, 300], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_300x500_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<2>::random([300, 500], Distribution::Default, &device); + let (p, l, u) = lu::<2, 1>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_5x300x300_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([5, 300, 300], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_3x300x500_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([3, 300, 500], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +#[test] +fn test_lu_3x500x300_block_dispatch() { + let device = Default::default(); + let tensor = TestTensor::<3>::random([3, 500, 300], Distribution::Default, &device); + let (p, l, u) = lu::<3, 2>(tensor.clone()); + let plu = p.matmul(l).matmul(u); + let tolerance = Tolerance::rel_abs(REL, ABS).set_half_precision_absolute(1.5e-1); + plu.into_data() + .assert_approx_eq::(&tensor.into_data(), tolerance); +} + +// --------------------------------------------------------------------- +// Tensor Check Panics +// --------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_lu_panic_rank_less_than_2() { + // Fails check: D >= 2 + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &device); + let _ = lu::<1, 0>(tensor); +} + +#[test] +#[should_panic] +fn test_lu_panic_invalid_d1() { + // Fails check: D - 1 == D1 (2 - 1 != 2) + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let _ = lu::<2, 2>(tensor); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/matvec.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/matvec.rs new file mode 100644 index 0000000..4324e7f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/matvec.rs @@ -0,0 +1,102 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance, linalg}; + +#[test] +fn test_matvec_basic_float() { + let device = Default::default(); + let matrix = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let vector = TestTensor::<1>::from_data([5.0, 6.0], &device); + + let result = linalg::matvec::<2, 1, _>(matrix, vector); + let expected = TensorData::from([17.0, 39.0]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_matvec_basic_int() { + let device = Default::default(); + let matrix = TestTensorInt::<2>::from_ints([[2, 0, -1], [1, 3, 2]], &device); + let vector = TestTensorInt::<1>::from_ints([3, -2, 4], &device); + + let result = linalg::matvec::<2, 1, _>(matrix, vector); + let expected = TensorData::from([2, 5]); + + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matvec_batched() { + let device = Default::default(); + let matrix = TestTensor::<3>::from_data( + [ + [[1.0, 0.0, 2.0], [3.0, 1.0, -1.0]], + [[-2.0, 1.0, 0.0], [0.5, -1.5, 2.0]], + ], + &device, + ); + let vector = TestTensor::<2>::from_data([[1.0, -1.0, 0.5], [2.0, 0.0, -1.0]], &device); + + let result = linalg::matvec::<3, 2, _>(matrix, vector); + let expected = TensorData::from([[2.0, 1.5], [-4.0, -1.0]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_matvec_vector_broadcasts_over_batches() { + let device = Default::default(); + let matrix = TestTensor::<3>::from_data( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[-1.0, 0.0, 2.0], [3.0, 1.0, -2.0]], + ], + &device, + ); + let vector = TestTensor::<2>::from_data([[1.0, 0.0, -1.0]], &device); + + let result = linalg::matvec::<3, 2, _>(matrix, vector); + let expected = TensorData::from([[-2.0, -2.0], [-3.0, 5.0]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_matvec_matrix_broadcasts_over_vector_batches() { + let device = Default::default(); + let matrix = TestTensor::<3>::from_data([[[1.0, 0.0, 2.0], [3.0, -1.0, 1.0]]], &device); + let vector = TestTensor::<2>::from_data([[2.0, 1.0, 0.0], [1.0, -1.0, 3.0]], &device); + + let result = linalg::matvec::<3, 2, _>(matrix, vector); + let expected = TensorData::from([[2.0, 5.0], [7.0, 7.0]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[should_panic] +fn test_matvec_invalid_inner_dim_panics() { + let device = Default::default(); + let matrix = TestTensor::<2>::zeros([2, 3], &device); + let vector = TestTensor::<1>::zeros([4], &device); + + let _ = linalg::matvec::<2, 1, _>(matrix, vector); +} + +#[test] +#[should_panic] +fn test_matvec_mismatched_batches_panics() { + let device = Default::default(); + let matrix = TestTensor::<3>::zeros([2, 3, 4], &device); + let vector = TestTensor::<2>::zeros([3, 4], &device); + + let _ = linalg::matvec::<3, 2, _>(matrix, vector); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/mod.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/mod.rs new file mode 100644 index 0000000..65137dd --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/mod.rs @@ -0,0 +1,10 @@ +use super::*; + +pub(crate) mod cosine_similarity; +pub(crate) mod det; +pub(crate) mod diag; +pub(crate) mod lu; +pub(crate) mod matvec; +pub(crate) mod outer; +pub(crate) mod trace; +pub(crate) mod vector_norm; diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/outer.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/outer.rs new file mode 100644 index 0000000..90e52d8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/outer.rs @@ -0,0 +1,262 @@ +use super::*; +use burn_tensor::{ElementConversion, Tolerance}; +use burn_tensor::{TensorData, linalg}; + +// ---------- Vector (D=1, R=2) tests ---------- + +#[test] +fn test_outer_basic() { + let u = TestTensor::<1>::from([1.0, 2.0, 3.0]); + let v = TestTensor::<1>::from([4.0, 5.0]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::from([[4.0, 5.0], [8.0, 10.0], [12.0, 15.0]]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_shapes_only() { + let device = Default::default(); + let u = TestTensor::<1>::zeros([3], &device); + let v = TestTensor::<1>::zeros([5], &device); + let out = linalg::outer::<1, 2, _>(u, v); + assert_eq!(out.shape().dims(), [3, 5]); +} + +#[test] +fn test_outer_asymmetry_and_shapes() { + let u = TestTensor::<1>::from([1.0, 2.0]); + let v = TestTensor::<1>::from([3.0, 4.0, 5.0]); + + let uv = linalg::outer::<1, 2, _>(u.clone(), v.clone()); + let vu = linalg::outer::<1, 2, _>(v, u); + + assert_eq!(uv.shape().dims(), [2, 3]); + assert_eq!(vu.shape().dims(), [3, 2]); +} + +#[test] +fn test_outer_zero_left() { + let device = Default::default(); + let u = TestTensor::<1>::zeros([3], &device); + let v = TestTensor::<1>::from([7.0, 8.0]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::zeros::([3, 2]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_zero_right() { + let device = Default::default(); + let u = TestTensor::<1>::from([1.0, -2.0, 3.0]); + let v = TestTensor::<1>::zeros([4], &device); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::zeros::([3, 4]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_signs() { + let u = TestTensor::<1>::from([-1.0, 2.0]); + let v = TestTensor::<1>::from([3.0, -4.0]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::from([[-3.0, 4.0], [6.0, -8.0]]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_integer_inputs() { + let u = TestTensorInt::<1>::from([1, 2, 3]); + let v = TestTensorInt::<1>::from([4, 5]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::from([[4, 5], [8, 10], [12, 15]]); + + out.assert_eq(&expected, false); +} + +#[test] +fn test_outer_equivalence_to_matmul() { + let u = TestTensor::<1>::from([1.0, 2.0, 3.0]); + let v = TestTensor::<1>::from([4.0, 5.0]); + + let out = linalg::outer::<1, 2, _>(u.clone(), v.clone()).into_data(); + + let u2 = u.reshape([3, 1]); + let v2 = v.reshape([1, 2]); + let out_matmul = u2.matmul(v2).into_data(); + + out.assert_approx_eq::(&out_matmul, Tolerance::default()); +} + +#[test] +fn test_outer_vector_identity_right_mult() { + let u = TestTensor::<1>::from([2.0, -1.0]); + let v = TestTensor::<1>::from([3.0, 4.0]); + let w = TestTensor::<1>::from([5.0, 6.0]); + + let uv = linalg::outer::<1, 2, _>(u.clone(), v.clone()); + let left = uv.matmul(w.clone().reshape([2, 1])).reshape([2]); + + let v_dot_w = v.dot(w); + let right = u * v_dot_w; + + left.into_data() + .assert_approx_eq::(&right.into_data(), Tolerance::default()); +} + +#[test] +fn test_outer_length_one_vectors() { + let u = TestTensor::<1>::from([3.0]); + let v = TestTensor::<1>::from([4.0, 5.0, 6.0]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::from([[12.0, 15.0, 18.0]]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_large_values() { + let big = 1.0e10; + let u = TestTensor::<1>::from([big, -big]); + let v = TestTensor::<1>::from([big, big]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + let expected = TensorData::from([[big * big, big * big], [-big * big, -big * big]]); + + let tol = Tolerance::relative(1e-6).set_half_precision_relative(1e-3); + out.assert_approx_eq::(&expected, tol); +} + +#[test] +fn test_outer_nan_propagation() { + let u = TestTensor::<1>::from([f32::NAN, 2.0]); + let v = TestTensor::<1>::from([3.0, 4.0]); + + let out = linalg::outer::<1, 2, _>(u, v).into_data(); + + let s: &[FloatElem] = out + .as_slice::() + .expect("outer nan_propagation: as_slice failed"); + + assert!(s[0].is_nan()); + assert!(s[1].is_nan()); + assert_eq!(s[2], 6.0f32.elem::()); + assert_eq!(s[3], 8.0f32.elem::()); +} + +// ---------- Batched (D=2, R=3) tests ---------- + +#[test] +fn test_outer_batched_basic() { + let x = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + let y = TestTensor::<2>::from([[5.0, 6.0, 7.0], [8.0, 9.0, 10.0]]); + let out = linalg::outer::<2, 3, _>(x, y).into_data(); + + let expected = TensorData::from([ + [[5.0, 6.0, 7.0], [10.0, 12.0, 14.0]], + [[24.0, 27.0, 30.0], [32.0, 36.0, 40.0]], + ]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_batched_shapes() { + let device = Default::default(); + let x = TestTensor::<2>::zeros([3, 4], &device); + let y = TestTensor::<2>::zeros([3, 5], &device); + let out = linalg::outer::<2, 3, _>(x, y); + assert_eq!(out.shape().dims(), [3, 4, 5]); +} + +#[test] +fn test_outer_batched_zero_left() { + let device = Default::default(); + let x = TestTensor::<2>::zeros([2, 3], &device); + let y = TestTensor::<2>::from([[7.0, 8.0], [9.0, 10.0]]); + let out = linalg::outer::<2, 3, _>(x, y).into_data(); + + let expected = TensorData::zeros::([2, 3, 2]); + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_batched_zero_right() { + let device = Default::default(); + let x = TestTensor::<2>::from([[1.0, -2.0, 3.0], [4.0, 5.0, -6.0]]); + let y = TestTensor::<2>::zeros([2, 4], &device); + let out = linalg::outer::<2, 3, _>(x, y).into_data(); + + let expected = TensorData::zeros::([2, 3, 4]); + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_batched_signs() { + let x = TestTensor::<2>::from([[-1.0, 2.0], [3.0, -4.0]]); + let y = TestTensor::<2>::from([[3.0, -4.0], [-5.0, 6.0]]); + let out = linalg::outer::<2, 3, _>(x, y).into_data(); + + let expected = TensorData::from([[[-3.0, 4.0], [6.0, -8.0]], [[-15.0, 18.0], [20.0, -24.0]]]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_outer_batched_equivalence_to_per_sample_outer() { + let x = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + let y = TestTensor::<2>::from([[5.0, 6.0, 7.0], [8.0, 9.0, 10.0]]); + let batched = linalg::outer::<2, 3, _>(x.clone(), y.clone()); + + for b in 0..2 { + let idx = TestTensorInt::<1>::from([b]); + + let xb2d = x.clone().select(0, idx.clone()); // (1, m) + let yb2d = y.clone().select(0, idx); // (1, n) + + let dims_x: [usize; 2] = xb2d.shape().dims(); + let dims_y: [usize; 2] = yb2d.shape().dims(); + let (m, n) = (dims_x[1], dims_y[1]); + + let per = linalg::outer::<1, 2, _>(xb2d.reshape([m]), yb2d.reshape([n])); + + let bat3d = batched.clone().select(0, TestTensorInt::<1>::from([b])); // (m, n) + + let per_len = per.shape().num_elements(); + let per_flat = per.reshape([per_len]).into_data(); + + let bat_len = bat3d.shape().num_elements(); + let bat_flat = bat3d.reshape([bat_len]).into_data(); + + bat_flat.assert_approx_eq::(&per_flat, Tolerance::default()); + } +} + +#[test] +#[should_panic] +fn test_outer_batched_mismatched_batches_panics() { + let device = Default::default(); + let x = TestTensor::<2>::zeros([2, 3], &device); + let y = TestTensor::<2>::zeros([3, 4], &device); + let _ = linalg::outer::<2, 3, _>(x, y); +} + +#[test] +fn test_outer_dim() { + let u = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + let v = TestTensor::<2>::from([[4.0, 5.0], [5.0, 6.0]]); + + let out = linalg::outer_dim::<2, 3, _, _>(u, v, 0).into_data(); + let expected = TensorData::from([[[4.0, 10.0], [5.0, 12.0]], [[12.0, 20.0], [15.0, 24.0]]]); + + out.assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/trace.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/trace.rs new file mode 100644 index 0000000..6240309 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/trace.rs @@ -0,0 +1,114 @@ +use super::*; +use burn_tensor::linalg::trace; + +#[test] +fn test_trace_2d_square() { + let device = Default::default(); + let tensor = + TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([15.0], &device); // 1 + 5 + 9 = 15 + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_2d_rectangular_wide() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([7.0], &device); // 1 + 6 = 7 + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_2d_rectangular_tall() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([5.0], &device); // 1 + 4 = 5 + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_3d_batch() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + &device, + ); + + let result = trace::<3, 2>(tensor); + let expected = TestTensor::<2>::from_data([[5.0], [13.0]], &device); // [1+4=5, 5+8=13] + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_4d_batch() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[ + // Batch 0, Channel 0 + [[1.0, 2.0], [3.0, 4.0]], + // Batch 0, Channel 1 + [[5.0, 6.0], [7.0, 8.0]], + ]], + &device, + ); + + let result = trace::<4, 3>(tensor); + let expected = TestTensor::<3>::from_data([[[5.0], [13.0]]], &device); + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_single_element() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[42.0]], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([42.0], &device); + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_zeros() { + let device = Default::default(); + let tensor = TestTensor::<2>::zeros([3, 3], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([0.0], &device); + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +fn test_trace_negative_values() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[-1.0, 2.0], [3.0, -4.0]], &device); + let result = trace::<2, 1>(tensor); + let expected = TestTensor::<1>::from_data([-5.0], &device); // -1 + (-4) = -5 + + assert_eq!(result.to_data(), expected.to_data()); +} + +#[test] +#[should_panic] +fn test_trace_1d_should_panic() { + let device = Default::default(); + // 1D tensor should panic - trace requires at least 2 dimensions + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &device); + let _result = trace::<1, 0>(tensor); +} + +#[test] +#[should_panic] +fn test_trace_wrong_output_rank_should_panic() { + let device = Default::default(); + // Providing wrong output rank should panic + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let _result = trace::<2, 2>(tensor); // Should be 2,1 not 2,2 +} diff --git a/crates/burn-backend-tests/tests/tensor/float/linalg/vector_norm.rs b/crates/burn-backend-tests/tests/tensor/float/linalg/vector_norm.rs new file mode 100644 index 0000000..09de479 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/linalg/vector_norm.rs @@ -0,0 +1,236 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::linalg; + +#[test] +fn test_max_min_abs() { + let x = TestTensor::<2>::from([[1., 2.], [3., 4.]]); + + let expected = TestTensor::<2>::from([[3., 4.]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::LInf, 0) + .into_data() + .assert_eq(&expected, true); + linalg::max_abs_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + + let expected = TestTensor::<2>::from([[1., 2.]]).into_data(); + linalg::vector_norm(x.clone(), -f64::INFINITY, 0) + .into_data() + .assert_eq(&expected, true); + linalg::vector_norm(x.clone(), f64::NEG_INFINITY, 0) + .into_data() + .assert_eq(&expected, true); + linalg::min_abs_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + + let expected = TestTensor::<2>::from([[2.], [4.]]).into_data(); + linalg::vector_norm(x.clone(), f64::INFINITY, 1) + .into_data() + .assert_eq(&expected, true); + linalg::max_abs_norm(x.clone(), 1) + .into_data() + .assert_eq(&expected, true); + + let expected = TestTensor::<2>::from([[1.], [3.]]).into_data(); + linalg::vector_norm(x.clone(), -f64::INFINITY, 1) + .into_data() + .assert_eq(&expected, true); + linalg::vector_norm(x.clone(), f64::NEG_INFINITY, 1) + .into_data() + .assert_eq(&expected, true); + linalg::min_abs_norm(x, 1) + .into_data() + .assert_eq(&expected, true); + + // Test with integer tensor + let z = TestTensorInt::<2>::from([[1, 2], [3, 4]]); + + linalg::max_abs_norm(z.clone(), 0) + .into_data() + .assert_eq(&TestTensorInt::<2>::from([[3, 4]]).into_data(), true); + linalg::max_abs_norm(z.clone(), 1) + .into_data() + .assert_eq(&TestTensorInt::<2>::from([[2], [4]]).into_data(), true); + + linalg::min_abs_norm(z.clone(), 0) + .into_data() + .assert_eq(&TestTensorInt::<2>::from([[1, 2]]).into_data(), true); + linalg::min_abs_norm(z, 1) + .into_data() + .assert_eq(&TestTensorInt::<2>::from([[1], [3]]).into_data(), true); +} + +#[test] +fn test_l0_norm() { + let x = TestTensor::<2>::from([[1.0, -2.0, 0.], [0.0, 0., 4.]]); + + let expected = TestTensor::<2>::from([[1., 1., 1.]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L0, 0) + .into_data() + .assert_eq(&expected, true); + linalg::l0_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + + let expected = TestTensor::<2>::from([[2.], [1.]]).into_data(); + linalg::vector_norm(x.clone(), 0.0, 1) + .into_data() + .assert_eq(&expected, true); + linalg::l0_norm(x.clone(), 1) + .into_data() + .assert_eq(&expected, true); + + // Test with integer tensor + let z = TestTensorInt::<2>::from([[1, -2, 0], [0, 0, 4]]); + + linalg::l0_norm(z.clone(), 0) + .into_data() + .assert_eq(&TestTensor::<2>::from([[1, 1, 1]]).int().into_data(), true); + linalg::l0_norm(z.clone(), 1) + .into_data() + .assert_eq(&TestTensor::<2>::from([[2], [1]]).int().into_data(), true); +} + +#[test] +fn test_l1_norm() { + let x = TestTensor::<2>::from([[1., 2.], [3., 4.]]); + + let expected = TestTensor::<2>::from([[4.0, 6.0]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L1, 0) + .into_data() + .assert_eq(&expected, true); + linalg::l1_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + + let expected = TestTensor::<2>::from([[3.0], [7.0]]).into_data(); + linalg::vector_norm(x.clone(), 1.0, 1) + .into_data() + .assert_eq(&expected, true); + linalg::l1_norm(x.clone(), 1) + .into_data() + .assert_eq(&expected, true); +} + +#[test] +fn test_lp_norm() { + let x = TestTensor::<2>::from([[1., -2., 0.], [0., 3., 4.]]); + let tolerance = Tolerance::relative(1e-5).set_half_precision_relative(2e-3); + + fn lp_norm_naive(x: Tensor, p: f64, dim: usize) -> Tensor { + x.abs().powf_scalar(p).sum_dim(dim).powf_scalar(1. / p) + } + + // Arbitrary P + let expected = TestTensor::<2>::from([[1.0, 3.2710664, 4.0]]).into_data(); + linalg::vector_norm(x.clone(), 3, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::lp_norm(x.clone(), 3., 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + // L0 + let expected = TestTensor::<2>::from([[1., 2., 1.]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L0, 0) + .into_data() + .assert_eq(&expected, true); + linalg::l0_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + linalg::lp_norm(x.clone(), 0.0, 0) + .into_data() + .assert_eq(&expected, true); + + // L1 + let expected = TestTensor::<2>::from([[1.0, 5.0, 4.0]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L1, 0) + .into_data() + .assert_eq(&expected, true); + linalg::l1_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + lp_norm_naive(x.clone(), 1.0, 0) + .into_data() + .assert_eq(&expected, true); + linalg::lp_norm(x.clone(), 1.0, 0) + .into_data() + .assert_eq(&expected, true); + + // L2 + let expected = TestTensor::<2>::from([[1.0, 3.6055512, 4.0]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L2, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::l2_norm(x.clone(), 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + lp_norm_naive(x.clone(), 2.0, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::lp_norm(x.clone(), 2.0, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + // LInf + let expected = TestTensor::<2>::from([[1.0, 3.0, 4.0]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::LInf, 0) + .into_data() + .assert_eq(&expected, true); + linalg::max_abs_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + linalg::lp_norm(x.clone(), f64::INFINITY, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + // LNegInf + let expected = TestTensor::<2>::from([[0.0, 2.0, 0.0]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::LNegInf, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::min_abs_norm(x.clone(), 0) + .into_data() + .assert_eq(&expected, true); + linalg::lp_norm(x.clone(), f64::NEG_INFINITY, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_l2_norm() { + let x = TestTensor::<2>::from([[1., 2.], [3., 4.]]); + let tolerance = Tolerance::relative(1e-5).set_half_precision_relative(1e-3); + + let expected = TestTensor::<2>::from([[3.16227766, 4.47213595]]).into_data(); + linalg::vector_norm(x.clone(), linalg::Norm::L2, 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::l2_norm(x.clone(), 0) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TestTensor::<2>::from([[2.23606798], [5.0]]).into_data(); + linalg::vector_norm(x.clone(), 2.0, 1) + .into_data() + .assert_approx_eq::(&expected, tolerance); + linalg::l2_norm(x.clone(), 1) + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_normalize() { + let x = TestTensor::<2>::from([[1., 2.], [3., 4.]]); + + let expected = TensorData::from([[1. / 4., 2. / 6.], [3. / 4., 4. / 6.]]); + let output = linalg::vector_normalize(x.clone(), 1.0, 0, 0.25).into_data(); + output.assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([[1. / 5., 2. / 6.], [3. / 5., 4. / 6.]]); + let output = linalg::vector_normalize(x.clone(), 1.0, 0, 5.0).into_data(); + output.assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/mod.rs b/crates/burn-backend-tests/tests/tensor/float/mod.rs new file mode 100644 index 0000000..b5d2650 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/mod.rs @@ -0,0 +1,13 @@ +#[allow(unused_imports)] +pub use super::*; // re-export test types + +mod activation; +mod grid; +mod linalg; +mod module; +mod ops; +mod primitive; +mod stats; + +#[cfg(feature = "quantization")] +mod quantization; diff --git a/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool1d.rs b/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool1d.rs new file mode 100644 index 0000000..4a9b284 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool1d.rs @@ -0,0 +1,70 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::adaptive_avg_pool1d; + +#[test] +fn test_adaptive_avg_pool1d_simple() { + let test = AdaptiveAvgPool1dTestCase { + batch_size: 1, + channels: 2, + length: 8, + length_out: 4, + }; + + test.assert_output(TestTensor::from([[ + [0.5, 2.5, 4.5, 6.5], + [8.5, 10.5, 12.5, 14.5], + ]])); +} + +#[test] +fn test_adaptive_avg_pool1d_dyn_filter_size() { + let test = AdaptiveAvgPool1dTestCase { + batch_size: 1, + channels: 2, + length: 7, + length_out: 3, + }; + + test.assert_output(TestTensor::from([[[1.0, 3.0, 5.0], [8.0, 10.0, 12.0]]])); +} + +#[test] +fn test_adaptive_avg_pool1d_bigger_output() { + let test = AdaptiveAvgPool1dTestCase { + batch_size: 1, + channels: 2, + length: 4, + length_out: 8, + }; + + test.assert_output(TestTensor::from([[ + [0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0], + [4.0, 4.0, 5.0, 5.0, 6.0, 6.0, 7.0, 7.0], + ]])); +} + +struct AdaptiveAvgPool1dTestCase { + batch_size: usize, + channels: usize, + length: usize, + length_out: usize, +} + +impl AdaptiveAvgPool1dTestCase { + fn assert_output(self, y: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.length]); + let device = Default::default(); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ); + let output = adaptive_avg_pool1d(x, self.length_out); + + y.into_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool2d.rs new file mode 100644 index 0000000..baeb034 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/adaptive_avgpool2d.rs @@ -0,0 +1,101 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::adaptive_avg_pool2d; + +#[test] +fn test_adaptive_avg_pool2d_simple() { + let test = AdaptiveAvgPool2dTestCase { + batch_size: 1, + channels: 2, + height: 8, + width: 6, + height_out: 4, + width_out: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [3.5000, 4.5000, 6.5000, 7.5000], + [15.5000, 16.5000, 18.5000, 19.5000], + [27.5000, 28.5000, 30.5000, 31.5000], + [39.5000, 40.5000, 42.5000, 43.5000], + ], + [ + [51.5000, 52.5000, 54.5000, 55.5000], + [63.5000, 64.5000, 66.5000, 67.5000], + [75.5000, 76.5000, 78.5000, 79.5000], + [87.5000, 88.5000, 90.5000, 91.5000], + ], + ]])); +} + +#[test] +fn test_adaptive_avg_pool2d_dyn_filter_size() { + let test = AdaptiveAvgPool2dTestCase { + batch_size: 1, + channels: 2, + height: 5, + width: 7, + height_out: 3, + width_out: 2, + }; + + test.assert_output(TestTensor::from([[ + [[5.0000, 8.0000], [15.5000, 18.5000], [26.0000, 29.0000]], + [[40.0000, 43.0000], [50.5000, 53.5000], [61.0000, 64.0000]], + ]])); +} + +#[test] +fn test_adaptive_avg_pool2d_bigger_output() { + let test = AdaptiveAvgPool2dTestCase { + batch_size: 1, + channels: 2, + height: 4, + width: 3, + height_out: 5, + width_out: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [0.0000, 0.5000, 1.5000, 2.0000], + [1.5000, 2.0000, 3.0000, 3.5000], + [4.5000, 5.0000, 6.0000, 6.5000], + [7.5000, 8.0000, 9.0000, 9.5000], + [9.0000, 9.5000, 10.5000, 11.0000], + ], + [ + [12.0000, 12.5000, 13.5000, 14.0000], + [13.5000, 14.0000, 15.0000, 15.5000], + [16.5000, 17.0000, 18.0000, 18.5000], + [19.5000, 20.0000, 21.0000, 21.5000], + [21.0000, 21.5000, 22.5000, 23.0000], + ], + ]])); +} + +struct AdaptiveAvgPool2dTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl AdaptiveAvgPool2dTestCase { + fn assert_output(self, y: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = adaptive_avg_pool2d(x, [self.height_out, self.width_out]); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/attention.rs b/crates/burn-backend-tests/tests/tensor/float/module/attention.rs new file mode 100644 index 0000000..5314f5e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/attention.rs @@ -0,0 +1,642 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::module::attention; +use burn_tensor::module::attention_fallback; +use burn_tensor::ops::AttentionModuleOptions; +use num_traits::cast::cast; + +#[allow(unused)] +use num_traits::Signed; // f16 + +#[test] +fn test_attention_no_mask() { + // Skip on metal with f16 - flash attention returns zeros + // Enable once this issue is fixed: https://github.com/tracel-ai/burn/issues/4325 + #[cfg(feature = "metal")] + if core::any::TypeId::of::() == core::any::TypeId::of::() { + return; + } + + let num_batches = 1; + let num_heads = 1; + let seq_q = 128; + let seq_kv = 128; + let head_dim = 64; + let val_dim = 64; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_q, head_dim], + Distribution::Uniform(0., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_kv, head_dim], + Distribution::Uniform(0., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_kv, val_dim], + Distribution::Uniform(0., 1.), + &Default::default(), + ); + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + None, + Default::default(), + ); + + let expected = attention_fallback(query, key, value, None, None, Default::default()); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +#[test] +fn test_attention_custom_scale() { + let [num_batches, num_heads, seq_len, head_dim] = [1, 2, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + let options = AttentionModuleOptions { + scale: Some(0.1), + ..Default::default() + }; + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + None, + options, + ); + + let expected = attention_fallback(query, key, value, None, None, options); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +#[test] +fn test_attention_attn_bias() { + let [num_batches, num_heads, seq_len, head_dim] = [1, 2, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let bias = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, seq_len], + Distribution::Uniform(-0.5, 0.5), + &Default::default(), + ); + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + Some(bias.clone()), + Default::default(), + ); + + let expected = attention_fallback(query, key, value, None, Some(bias), Default::default()); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +#[test] +fn test_attention_softcap() { + let [num_batches, num_heads, seq_len, head_dim] = [1, 2, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + let options = AttentionModuleOptions { + softcap: Some(50.0), + ..Default::default() + }; + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + None, + options, + ); + + let expected = attention_fallback(query, key, value, None, None, options); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +#[test] +fn test_attention_is_causal() { + let [num_batches, num_heads, seq_len, head_dim] = [2, 4, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + let options = AttentionModuleOptions { + is_causal: true, + ..Default::default() + }; + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + None, + options, + ); + + let expected = attention_fallback(query, key, value, None, None, options); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +/// Cross-attention: seq_q != seq_k, with causal masking and additive bias. +#[test] +fn test_attention_cross_attention_with_bias() { + let [num_batches, num_heads, seq_q, seq_k, head_dim] = [2, 2, 8, 24, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_q, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_k, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_k, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let bias = TestTensor::<4>::random( + [num_batches, num_heads, seq_q, seq_k], + Distribution::Uniform(-0.5, 0.5), + &Default::default(), + ); + + let options = AttentionModuleOptions { + is_causal: true, + ..Default::default() + }; + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + Some(bias.clone()), + options, + ); + + let expected = attention_fallback(query, key, value, None, Some(bias), options); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +/// Regression: softcap must be applied before -inf masking. +/// With causal masking, position 0 can only attend to itself, so output[0] == value[0]. +/// If softcap were applied after masking, tanh(-inf/softcap) = -softcap (finite), +/// and the masked position would leak into the output. +#[test] +fn test_attention_softcap_preserves_causal_mask() { + let [num_batches, num_heads, seq_len, head_dim] = [1, 1, 4, 8]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + let options = AttentionModuleOptions { + softcap: Some(20.0), + is_causal: true, + ..Default::default() + }; + + let output = attention_fallback(query, key, value.clone(), None, None, options); + + // With causal masking, position 0 can only attend to itself (softmax = [1, 0, 0, 0]). + // So output[..., 0, :] must equal value[..., 0, :]. + let output_row0 = output.slice([0..1, 0..1, 0..1, 0..head_dim]); + let value_row0 = value.slice([0..1, 0..1, 0..1, 0..head_dim]); + + output_row0 + .into_data() + .assert_approx_eq::(&value_row0.into_data(), Tolerance::rel_abs(1e-4, 1e-3)); +} + +/// Regression: fully-masked rows must produce 0, not NaN. +/// When a bool mask masks every key position for a query row, all attention +/// scores are -inf and naive softmax yields NaN. +#[test] +fn test_attention_fully_masked_rows_no_nan() { + // Skip test with f16 (fallback uses too big epsilon value) + if core::any::TypeId::of::() == core::any::TypeId::of::() { + return; + } + let [num_batches, num_heads, seq_len, head_dim] = [1, 1, 4, 8]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + let mask = TestTensorBool::<4>::full( + [num_batches, num_heads, seq_len, seq_len], + true, + &Default::default(), + ); + + let output = attention_fallback(query, key, value, Some(mask), None, Default::default()); + + let output_data = output.into_data(); + let values = output_data.as_slice::().unwrap(); + let tol: FloatElem = cast(1e-4f64).unwrap(); + assert!( + !values.iter().any(|v| v.is_nan()), + "Fully-masked rows should produce 0, not NaN" + ); + assert!( + values.iter().all(|v| v.abs() < tol), + "Fully-masked rows should produce values near 0" + ); +} + +/// Causal + partial bool mask combine to fully mask early rows. +/// With seq_len=4, the causal mask allows row 0 to attend only to key 0. +/// The bool mask masks key 0, so row 0 is fully masked while rows 1-3 still +/// have valid positions. Row 0 output must be 0, not NaN. +#[test] +fn test_attention_fully_masked_rows_causal_no_nan() { + // Skip test with f16 (fallback uses too big epsilon value) + if core::any::TypeId::of::() == core::any::TypeId::of::() { + return; + } + + let [num_batches, num_heads, seq_len, head_dim] = [1, 1, 4, 8]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + + // Mask only column 0 (key position 0). Combined with causal mask, + // row 0 becomes fully masked since it can only attend to key 0. + #[rustfmt::skip] + let mask_data = TensorData::from([[[[ + true, false, false, false, + true, false, false, false, + true, false, false, false, + true, false, false, false, + ]]]]); + let mask = TestTensorBool::<4>::from_data(mask_data, &Default::default()).reshape([ + num_batches, + num_heads, + seq_len, + seq_len, + ]); + + let options = AttentionModuleOptions { + is_causal: true, + ..Default::default() + }; + + let output = attention_fallback(query, key, value, Some(mask), None, options); + + let output_data = output.into_data(); + let values = output_data.as_slice::().unwrap(); + let tol: FloatElem = cast(1e-4f64).unwrap(); + assert!( + !values.iter().any(|v| v.is_nan()), + "Fully-masked rows should produce 0, not NaN" + ); + // Row 0 (indices 0..head_dim) should be ~0 since it's fully masked + let row0 = &values[..head_dim]; + assert!( + row0.iter().all(|v| v.abs() < tol), + "Fully-masked row 0 should produce values near 0" + ); + // Rows 1-3 should have non-zero output (they have valid positions) + let rest = &values[head_dim..]; + assert!( + rest.iter().any(|v| v.abs() > tol), + "Non-masked rows should produce non-zero output" + ); +} + +/// Combined: mask + bias + custom scale + softcap together. +#[test] +fn test_attention_all_options() { + let [num_batches, num_heads, seq_len, head_dim] = [2, 2, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let bias = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, seq_len], + Distribution::Uniform(-0.5, 0.5), + &Default::default(), + ); + // Create a random bool mask by thresholding a uniform float tensor + let mask = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, seq_len], + Distribution::Uniform(0., 1.), + &Default::default(), + ) + .greater_elem(0.7); + + let options = AttentionModuleOptions { + scale: Some(0.05), + softcap: Some(30.0), + is_causal: true, + }; + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + Some(mask.clone()), + Some(bias.clone()), + options, + ); + + let expected = attention_fallback(query, key, value, Some(mask), Some(bias), options); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +/// Regression for burn#4772: ONNX Attention-23 allows a `[1, 1, seq_q, seq_kv]` bias +/// that's shared across all batches and heads. The main attention path must accept this +/// shape and produce the same result as the fallback (whose elementwise `float_add` +/// broadcasts the bias naturally). +#[test] +fn test_attention_bias_broadcast_batch_and_heads() { + let [num_batches, num_heads, seq_len, head_dim] = [2, 3, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let bias = TestTensor::<4>::random( + [1, 1, seq_len, seq_len], + Distribution::Uniform(-0.5, 0.5), + &Default::default(), + ); + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + Some(bias.clone()), + Default::default(), + ); + + let expected = attention_fallback(query, key, value, None, Some(bias), Default::default()); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +/// Regression for burn#4772: `[batch, 1, seq_q, seq_kv]` bias is shared across heads +/// but distinct per batch (the ONNX `test_attention_4d_attn_mask_3d` pattern). +#[test] +fn test_attention_bias_broadcast_heads_only() { + let [num_batches, num_heads, seq_len, head_dim] = [2, 3, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let bias = TestTensor::<4>::random( + [num_batches, 1, seq_len, seq_len], + Distribution::Uniform(-0.5, 0.5), + &Default::default(), + ); + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + None, + Some(bias.clone()), + Default::default(), + ); + + let expected = attention_fallback(query, key, value, None, Some(bias), Default::default()); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} + +/// Regression for burn#4772: a `[1, 1, seq_q, seq_kv]` bool mask must be shared across +/// all batches and heads (the ONNX `test_attention_4d_attn_mask_bool` pattern). +#[test] +fn test_attention_bool_mask_broadcast_batch_and_heads() { + // Skip on cubecl backends with f16: the main attention path diverges from + // attention_fallback starting at batch 0 head 1, the fingerprint of a broadcast + // mask being applied only to head 0. The equivalent full-shape mask case + // (test_attention_all_options) passes on the same backends, so this is a latent + // cubecl flash-attention broadcast issue (tracked in #4778, under the umbrella + // of #4325), not a burn-flex or fallback bug. Enable once #4778 is fixed. + #[cfg(feature = "cube")] + if core::any::TypeId::of::() == core::any::TypeId::of::() { + return; + } + + let [num_batches, num_heads, seq_len, head_dim] = [2, 3, 16, 32]; + + let query = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let key = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + let value = TestTensor::<4>::random( + [num_batches, num_heads, seq_len, head_dim], + Distribution::Uniform(-1., 1.), + &Default::default(), + ); + // Random bool mask via thresholding, matching test_attention_all_options. + let mask = TestTensor::<4>::random( + [1, 1, seq_len, seq_len], + Distribution::Uniform(0., 1.), + &Default::default(), + ) + .greater_elem(0.7); + + let output = attention( + query.clone(), + key.clone(), + value.clone(), + Some(mask.clone()), + None, + Default::default(), + ); + + let expected = attention_fallback(query, key, value, Some(mask), None, Default::default()); + + output.into_data().assert_approx_eq::( + &expected.into_data(), + Tolerance::rel_abs(1e-2, 1e-3).set_half_precision_relative(1e-1), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/avgpool1d.rs b/crates/burn-backend-tests/tests/tensor/float/module/avgpool1d.rs new file mode 100644 index 0000000..8dc2170 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/avgpool1d.rs @@ -0,0 +1,168 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::avg_pool1d; + +#[test] +fn test_avg_pool1d_simple() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 1, + kernel_size: 3, + padding: 0, + stride: 1, + length: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from([[[1., 2., 3., 4.]]])); +} + +#[test] +fn test_avg_pool1d_complex() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 2, + kernel_size: 3, + padding: 1, + stride: 2, + length: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from([[ + [0.33333, 2.0000, 4.0000], + [4.33333, 8.0000, 10.0000], + ]])); +} + +#[test] +fn test_avg_pool1d_complex_dont_count_pad() { + let test = AvgPool1dTestCase { + batch_size: 1, + channels: 2, + kernel_size: 3, + padding: 1, + stride: 2, + length: 6, + count_include_pad: false, + }; + + test.assert_output(TestTensor::from([[ + [0.5000, 2.0000, 4.0000], + [6.5000, 8.0000, 10.0000], + ]])); +} + +struct AvgPool1dTestCase { + batch_size: usize, + channels: usize, + kernel_size: usize, + padding: usize, + stride: usize, + length: usize, + count_include_pad: bool, +} + +impl AvgPool1dTestCase { + fn assert_output(self, y: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.length]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<3, _>(shape_x) + .into_data(), + ); + let output = avg_pool1d( + x, + self.kernel_size, + self.stride, + self.padding, + self.count_include_pad, + false, + ); + + y.to_data().assert_approx_eq::( + &output.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + } +} + +#[test] +fn test_avg_pool1d_ceil_mode() { + // Test ceil_mode=true produces larger output when input doesn't divide evenly by stride + // Input: 1x1x6 (values 0-5), kernel: 3, stride: 2, padding: 0 + // Floor mode: output = (6-3)/2+1 = 2 elements + // Ceil mode: output = ceil((6-3)/2)+1 = ceil(1.5)+1 = 3 elements + let x = TestTensor::from([[[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]]]); + + // With ceil_mode=false (floor): output is 2 elements + // Window 0: avg(0,1,2) = 1 + // Window 1: avg(2,3,4) = 3 + let y_floor = TestTensor::<3>::from([[[1.0, 3.0]]]); + + let output_floor = avg_pool1d( + x.clone(), + 3, // kernel_size + 2, // stride + 0, // padding + true, // count_include_pad + false, + ); + + y_floor.to_data().assert_approx_eq::( + &output_floor.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + + // With ceil_mode=true: output is 3 elements + // Window 0: avg(0,1,2) = 1 + // Window 1: avg(2,3,4) = 3 + // Window 2: avg(4,5) = 4.5 (partial window, count_include_pad=false divides by 2) + let y_ceil = TestTensor::<3>::from([[[1.0, 3.0, 4.5]]]); + + let output_ceil = avg_pool1d( + x, 3, // kernel_size + 2, // stride + 0, // padding + false, // count_include_pad=false to get correct average for partial window + true, + ); + + y_ceil.to_data().assert_approx_eq::( + &output_ceil.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); +} + +#[test] +fn test_avg_pool1d_ceil_mode_count_include_pad() { + // Test count_include_pad=true + ceil_mode=true interaction for 1D + // When ceil_mode creates windows that extend beyond the padded input: + // - count_include_pad=true should count positions within padded bounds (not ceil_mode extensions) + // + // Input: 1x1x6, kernel 3, stride 2, padding 1, ceil_mode=true + // Output is 4 elements + let x = TestTensor::from([[[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]]]); + + // Expected PyTorch output with padding=1, ceil_mode=true, count_include_pad=true: + // Window 0: positions -1,0,1 -> values 0,0,1 (0 is padding) / 3 = 0.333 + // Window 1: positions 1,2,3 -> values 1,2,3 / 3 = 2.0 + // Window 2: positions 3,4,5 -> values 3,4,5 / 3 = 4.0 + // Window 3: positions 5,6,7 -> only 5 is valid, 6 is padding, 7 is ceil_mode extension + // value 5 / 2 (only 2 positions within padded bounds) = 2.5 + let expected = TestTensor::<3>::from([[[0.3333, 2.0, 4.0, 2.5]]]); + + let output = avg_pool1d( + x, 3, // kernel_size + 2, // stride + 1, // padding + true, // count_include_pad=true + true, // ceil_mode=true + ); + + expected.to_data().assert_approx_eq::( + &output.into_data(), + Tolerance::default().set_half_precision_relative(1e-2), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/avgpool2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/avgpool2d.rs new file mode 100644 index 0000000..233a5d6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/avgpool2d.rs @@ -0,0 +1,220 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::avg_pool2d; + +#[test] +fn test_avg_pool2d_simple() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + height: 6, + width: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from([[[ + [7., 8., 9., 10.], + [13., 14., 15., 16.], + [19., 20., 21., 22.], + [25., 26., 27., 28.], + ]]])); +} + +#[test] +fn test_avg_pool2d_complex() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 2, + height: 4, + width: 6, + count_include_pad: true, + }; + + test.assert_output(TestTensor::from([[[ + [1.1667, 3.0000, 4.3333, 2.5000], + [3.2500, 7.5000, 9.5000, 5.2500], + [6.2500, 13.5000, 15.5000, 8.2500], + [5.1667, 11.0000, 12.3333, 6.5000], + ]]])); +} + +#[test] +fn test_avg_pool2d_complex_dont_include_pad() { + let test = AvgPool2dTestCase { + batch_size: 1, + channels: 1, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 1, + padding_2: 2, + stride_1: 1, + stride_2: 2, + height: 4, + width: 6, + count_include_pad: false, + }; + + test.assert_output(TestTensor::from([[[ + [3.5000, 4.5000, 6.5000, 7.5000], + [6.5000, 7.5000, 9.5000, 10.5000], + [12.5000, 13.5000, 15.5000, 16.5000], + [15.5000, 16.5000, 18.5000, 19.5000], + ]]])); +} + +struct AvgPool2dTestCase { + batch_size: usize, + channels: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + height: usize, + width: usize, + count_include_pad: bool, +} + +impl AvgPool2dTestCase { + fn assert_output(self, y: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = avg_pool2d( + x, + [self.kernel_size_1, self.kernel_size_2], + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + self.count_include_pad, + false, + ); + + y.to_data().assert_approx_eq::( + &output.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + } +} + +#[test] +fn test_avg_pool2d_ceil_mode() { + // Test ceil_mode=true produces larger output when input doesn't divide evenly by stride + // Input: 1x1x6x6 (values 0-35), kernel: 3x3, stride: 2x2, padding: 0x0 + // Floor mode: output = (6-3)/2+1 = 2 x 2 + // Ceil mode: output = ceil((6-3)/2)+1 = ceil(1.5)+1 = 3 x 3 + let x = TestTensor::from([[[ + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + [6.0, 7.0, 8.0, 9.0, 10.0, 11.0], + [12.0, 13.0, 14.0, 15.0, 16.0, 17.0], + [18.0, 19.0, 20.0, 21.0, 22.0, 23.0], + [24.0, 25.0, 26.0, 27.0, 28.0, 29.0], + [30.0, 31.0, 32.0, 33.0, 34.0, 35.0], + ]]]); + + // With ceil_mode=false (floor): output is 2x2 + // Window (0,0): avg(0,1,2,6,7,8,12,13,14) = avg(63) = 7 + // Window (0,1): avg(2,3,4,8,9,10,14,15,16) = avg(81) = 9 + // Window (1,0): avg(12,13,14,18,19,20,24,25,26) = avg(171) = 19 + // Window (1,1): avg(14,15,16,20,21,22,26,27,28) = avg(189) = 21 + let y_floor = TestTensor::<4>::from([[[[7.0, 9.0], [19.0, 21.0]]]]); + + let output_floor = avg_pool2d( + x.clone(), + [3, 3], + [2, 2], + [0, 0], + true, // count_include_pad + false, + ); + + y_floor.to_data().assert_approx_eq::( + &output_floor.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); + + // With ceil_mode=true: output is 3x3 + // The extra windows at the edge include partial/padded regions + // When count_include_pad=false, only actual values are averaged + // Window (0,2): positions (0:3, 4:6) -> values 4,5,10,11,16,17 -> avg = 10.5 + // Window (1,2): positions (2:5, 4:6) -> values 16,17,22,23,28,29 -> avg = 22.5 + // Window (2,0): positions (4:6, 0:3) -> values 24,25,26,30,31,32 -> avg = 28 + // Window (2,1): positions (4:6, 2:5) -> values 26,27,28,32,33,34 -> avg = 30 + // Window (2,2): positions (4:6, 4:6) -> values 28,29,34,35 -> avg = 31.5 + let y_ceil = + TestTensor::<4>::from([[[[7.0, 9.0, 10.5], [19.0, 21.0, 22.5], [28.0, 30.0, 31.5]]]]); + + let output_ceil = avg_pool2d( + x, + [3, 3], + [2, 2], + [0, 0], + false, // count_include_pad=false to avoid dividing by full kernel size + true, + ); + + y_ceil.to_data().assert_approx_eq::( + &output_ceil.into_data(), + Tolerance::default().set_half_precision_relative(1e-3), + ); +} + +#[test] +fn test_avg_pool2d_ceil_mode_count_include_pad() { + // Test count_include_pad=true + ceil_mode=true interaction + // When ceil_mode creates windows that extend beyond the padded input: + // - count_include_pad=true should count positions within padded bounds (not ceil_mode extensions) + // + // For input 6x6, kernel 3, stride 2, padding 1, ceil_mode=true: + // - Output is 4x4 + // - Corner (3,3) window covers positions beyond even the user padding + // - Expected: 35/4 = 8.75 (divides by count of positions within padded bounds) + + let x = TestTensor::from([[[ + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + [6.0, 7.0, 8.0, 9.0, 10.0, 11.0], + [12.0, 13.0, 14.0, 15.0, 16.0, 17.0], + [18.0, 19.0, 20.0, 21.0, 22.0, 23.0], + [24.0, 25.0, 26.0, 27.0, 28.0, 29.0], + [30.0, 31.0, 32.0, 33.0, 34.0, 35.0], + ]]]); + + // Expected PyTorch output with padding=1, ceil_mode=true, count_include_pad=true + // Note: corner (3,3) = 8.75 = 35/4, not 35/9 + let expected = TestTensor::<4>::from([[[ + [1.5556, 3.3333, 4.6667, 2.6667], + [8.3333, 14.0000, 16.0000, 8.5000], + [16.3333, 26.0000, 28.0000, 14.5000], + [10.1667, 16.0000, 17.0000, 8.7500], + ]]]); + + let output = avg_pool2d( + x, + [3, 3], + [2, 2], + [1, 1], + true, // count_include_pad=true + true, // ceil_mode=true + ); + + expected.to_data().assert_approx_eq::( + &output.into_data(), + Tolerance::default().set_half_precision_relative(1e-2), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/bicubic_interpolate.rs b/crates/burn-backend-tests/tests/tensor/float/module/bicubic_interpolate.rs new file mode 100644 index 0000000..f8225b6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/bicubic_interpolate.rs @@ -0,0 +1,196 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::interpolate; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; + +#[test] +fn test_upsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 2, + channels: 1, + height: 7, + width: 5, + height_out: 8, + width_out: 7, + }; + + test.assert_output(TestTensor::from([ + [[ + [0.0000, 0.5741, 1.3704, 2.0000, 2.6296, 3.4259, 4.0000], + [4.0015, 4.5755, 5.3718, 6.0015, 6.6311, 7.4274, 8.0015], + [8.3528, 8.9268, 9.7231, 10.3528, 10.9824, 11.7787, 12.3528], + [ + 12.7697, 13.3438, 14.1400, 14.7697, 15.3993, 16.1956, 16.7697, + ], + [ + 17.2303, 17.8044, 18.6007, 19.2303, 19.8600, 20.6562, 21.2303, + ], + [ + 21.6472, 22.2213, 23.0176, 23.6472, 24.2769, 25.0731, 25.6472, + ], + [ + 25.9986, 26.5726, 27.3689, 27.9986, 28.6282, 29.4245, 29.9986, + ], + [ + 30.0000, 30.5741, 31.3704, 32.0000, 32.6296, 33.4259, 34.0000, + ], + ]], + [[ + [ + 35.0000, 35.5741, 36.3704, 37.0000, 37.6296, 38.4259, 39.0000, + ], + [ + 39.0015, 39.5755, 40.3718, 41.0015, 41.6311, 42.4274, 43.0015, + ], + [ + 43.3528, 43.9269, 44.7231, 45.3528, 45.9824, 46.7787, 47.3528, + ], + [ + 47.7697, 48.3438, 49.1400, 49.7697, 50.3993, 51.1956, 51.7697, + ], + [ + 52.2303, 52.8044, 53.6007, 54.2303, 54.8600, 55.6562, 56.2303, + ], + [ + 56.6472, 57.2213, 58.0176, 58.6472, 59.2769, 60.0731, 60.6472, + ], + [ + 60.9986, 61.5726, 62.3689, 62.9986, 63.6282, 64.4245, 64.9986, + ], + [ + 65.0000, 65.5741, 66.3704, 67.0000, 67.6296, 68.4259, 69.0000, + ], + ]], + ])); +} + +#[test] +fn test_downsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 45, + width: 14, + height_out: 4, + width_out: 6, + }; + + test.assert_output(TestTensor::from([[[ + [0.0000, 2.5760, 5.2480, 7.7520, 10.4240, 13.0000], + [204.8148, 207.3908, 210.0628, 212.5668, 215.2388, 217.8148], + [411.1852, 413.7612, 416.4331, 418.9371, 421.6091, 424.1852], + [616.0000, 618.576, 621.2479, 623.7519, 626.4239, 629.0000], + ]]])); +} + +#[test] +fn test_1d_bicubic() { + // Initialize the model without weights (because the exported file does not contain them) + let device = Default::default(); + + // Run the model + let input = TestTensor::<3>::from_data( + [[[1.5410, -0.2934, -2.1788, 0.5684, -1.0845, -1.3986]]], + &device, + ); + + let input = input.unsqueeze_dim(2); + + let output = interpolate( + input, + [1, 9], + InterpolateOptions::new(InterpolateMode::Bicubic), + ); + + assert_eq!(output.dims(), [1, 1, 1, 9]); + + // assert output data does not contain NaN + assert!( + !output + .clone() + .to_data() + .as_slice::() + .unwrap() + .iter() + .any(|&x| x.is_nan()), + "interpolate output contains NaN" + ); + + TestTensor::<4>::from([[[[ + 1.541, 0.5747652, -1.010614, -2.197787, -0.8269969, 0.59609234, -0.5803058, -1.3792794, + -1.3986, + ]]]]) + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} +struct InterpolateTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl InterpolateTestCase { + fn assert_output(self, y: TestTensor<4>) { + self.assert_output_with_align_corners(y, true); + } + + fn assert_output_with_align_corners(self, y: TestTensor<4>, align_corners: bool) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = interpolate( + x, + [self.height_out, self.width_out], + InterpolateOptions::new(InterpolateMode::Bicubic).with_align_corners(align_corners), + ); + + let tolerance = Tolerance::permissive(); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} + +#[test] +fn test_upsample_half_pixel() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 4, + width: 4, + height_out: 8, + width_out: 8, + }; + + test.assert_output_with_align_corners( + TestTensor::from([[[ + [ + -0.5273, -0.2305, 0.2461, 0.875, 1.2812, 1.9102, 2.3867, 2.6836, + ], + [ + 0.6602, 0.957, 1.4336, 2.0625, 2.4688, 3.0977, 3.5742, 3.8711, + ], + [ + 2.5664, 2.8633, 3.3398, 3.9688, 4.375, 5.0039, 5.4805, 5.7773, + ], + [5.082, 5.3789, 5.8555, 6.4844, 6.8906, 7.5195, 7.9961, 8.293], + [6.707, 7.0039, 7.4805, 8.1094, 8.5156, 9.1445, 9.6211, 9.918], + [ + 9.2227, 9.5195, 9.9961, 10.625, 11.0312, 11.6602, 12.1367, 12.4336, + ], + [ + 11.1289, 11.4258, 11.9023, 12.5312, 12.9375, 13.5664, 14.043, 14.3398, + ], + [ + 12.3164, 12.6133, 13.0898, 13.7188, 14.125, 14.7539, 15.2305, 15.5273, + ], + ]]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/bilinear_interpolate.rs b/crates/burn-backend-tests/tests/tensor/float/module/bilinear_interpolate.rs new file mode 100644 index 0000000..9050dc7 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/bilinear_interpolate.rs @@ -0,0 +1,270 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::module::interpolate; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; +use burn_tensor::{DType, Shape}; + +#[test] +fn test_upsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 2, + channels: 1, + height: 7, + width: 5, + height_out: 8, + width_out: 7, + }; + + test.assert_output(TestTensor::from([ + [[ + [0.0000, 0.6667, 1.3333, 2.0000, 2.6667, 3.3333, 4.0000], + [4.2857, 4.9524, 5.6190, 6.2857, 6.9524, 7.6190, 8.2857], + [8.5714, 9.2381, 9.9048, 10.5714, 11.2381, 11.9048, 12.5714], + [ + 12.8571, 13.5238, 14.1905, 14.8571, 15.5238, 16.1905, 16.8571, + ], + [ + 17.1429, 17.8095, 18.4762, 19.1429, 19.8095, 20.4762, 21.1429, + ], + [ + 21.4286, 22.0952, 22.7619, 23.4286, 24.0952, 24.7619, 25.4286, + ], + [ + 25.7143, 26.3810, 27.0476, 27.7143, 28.3810, 29.0476, 29.7143, + ], + [ + 30.0000, 30.6667, 31.3333, 32.0000, 32.6667, 33.3333, 34.0000, + ], + ]], + [[ + [ + 35.0000, 35.6667, 36.3333, 37.0000, 37.6667, 38.3333, 39.0000, + ], + [ + 39.2857, 39.9524, 40.6190, 41.2857, 41.9524, 42.6190, 43.2857, + ], + [ + 43.5714, 44.2381, 44.9048, 45.5714, 46.2381, 46.9048, 47.5714, + ], + [ + 47.8571, 48.5238, 49.1905, 49.8571, 50.5238, 51.1905, 51.8571, + ], + [ + 52.1429, 52.8095, 53.4762, 54.1429, 54.8095, 55.4762, 56.1429, + ], + [ + 56.4286, 57.0952, 57.7619, 58.4286, 59.0952, 59.7619, 60.4286, + ], + [ + 60.7143, 61.3810, 62.0476, 62.7143, 63.3810, 64.0476, 64.7143, + ], + [ + 65.0000, 65.6667, 66.3333, 67.0000, 67.6667, 68.3333, 69.0000, + ], + ]], + ])); +} + +#[test] +fn test_downsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 45, + width: 14, + height_out: 4, + width_out: 6, + }; + + test.assert_output(TestTensor::from([[[ + [0.0, 2.6, 5.2, 7.8, 10.4, 13.], + [205.3333, 207.9333, 210.5333, 213.1333, 215.7333, 218.3333], + [410.6667, 413.2667, 415.8667, 418.4667, 421.0667, 423.6667], + [616., 618.6, 621.2, 623.8, 626.4, 629.], + ]]])); +} + +#[test] +fn test_1d_bilinear() { + // Initialize the model without weights (because the exported file does not contain them) + let device = Default::default(); + + // Run the model + let input = TestTensor::<3>::from_data( + [[[1.5410, -0.2934, -2.1788, 0.5684, -1.0845, -1.3986]]], + &device, + ); + + let input = input.unsqueeze_dim(2); + + let output = interpolate( + input, + [1, 9], + InterpolateOptions::new(InterpolateMode::Bilinear), + ); + + assert_eq!(output.dims(), [1, 1, 1, 9]); + + // assert output data does not contain NaN + assert!( + !output + .clone() + .to_data() + .as_slice::() + .unwrap() + .iter() + .any(|&x| x.is_nan()), + "interpolate output contains NaN" + ); + + TestTensor::<4>::from([[[[ + 1.541f32, + 0.39450002, + -0.76475, + -1.943125, + -0.80520004, + 0.36178753, + -0.671275, + -1.2022874, + -1.3986, + ]]]]) + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_interpolate_coord_float_precision_boundary() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 28, + width: 4, + height_out: 24, + width_out: 2, + }; + + test.assert_output(TestTensor::from([[[ + [0.0, 3.0], + [4.6956, 7.6956], + [9.3913, 12.3913], + [14.0869, 17.0869], + [18.7826, 21.7826], + [23.4782, 26.4782], + [28.1739, 31.1739], + [32.8695, 35.8695], + [37.5652, 40.5652], + [42.2608, 45.2608], + [46.9565, 49.9565], + [51.6521, 54.6521], + [56.3478, 59.3478], + [61.0434, 64.0434], + [65.7391, 68.7391], + [70.4347, 73.4347], + [75.1304, 78.1304], + [79.8260, 82.8260], + [84.5217, 87.5217], + [89.2173, 92.2173], + [93.9130, 96.9130], + [98.6086, 101.6086], + [103.3043, 106.3043], + [108.0, 111.0], + ]]])); +} + +#[test] +fn should_interpolate_cast() { + let device = Default::default(); + let shape_x = Shape::new([1, 1, 4, 4]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + ) + .cast(DType::F32); // ok for f32 backends, casts dtype for f16 tests + let output = interpolate( + x, + [8, 8], + InterpolateOptions::new(InterpolateMode::Bilinear), + ); + + let expected = TestTensor::<4>::from([[[ + [0.0, 0.42857, 0.8571, 1.2857, 1.7142, 2.1428, 2.5714, 3.0], + [1.7142, 2.1428, 2.5714, 3.0, 3.4285, 3.8571, 4.2857, 4.7142], + [3.4285, 3.8571, 4.2857, 4.7142, 5.1428, 5.5714, 6.0, 6.4285], + [5.1428, 5.5714, 6.0, 6.4285, 6.8571, 7.2857, 7.7142, 8.1428], + [6.8571, 7.2857, 7.7142, 8.1428, 8.5714, 9.0, 9.4285, 9.8571], + [ + 8.5714, 9.0, 9.4285, 9.8571, 10.2857, 10.7142, 11.1428, 11.5714, + ], + [ + 10.2857, 10.7142, 11.1428, 11.5714, 12.0, 12.4285, 12.8571, 13.2857, + ], + [ + 12.0, 12.4285, 12.8571, 13.2857, 13.7142, 14.1428, 14.5714, 15.0, + ], + ]]]); + + let tolerance = Tolerance::permissive(); + output + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); +} + +struct InterpolateTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl InterpolateTestCase { + fn assert_output(self, y: TestTensor<4>) { + self.assert_output_with_align_corners(y, true); + } + + fn assert_output_with_align_corners(self, y: TestTensor<4>, align_corners: bool) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = interpolate( + x, + [self.height_out, self.width_out], + InterpolateOptions::new(InterpolateMode::Bilinear).with_align_corners(align_corners), + ); + + let tolerance = Tolerance::permissive(); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} + +#[test] +fn test_upsample_half_pixel() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 4, + width: 4, + height_out: 8, + width_out: 8, + }; + + test.assert_output_with_align_corners( + TestTensor::from([[[ + [0.0, 0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.0], + [1.0, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75, 4.0], + [3.0, 3.25, 3.75, 4.25, 4.75, 5.25, 5.75, 6.0], + [5.0, 5.25, 5.75, 6.25, 6.75, 7.25, 7.75, 8.0], + [7.0, 7.25, 7.75, 8.25, 8.75, 9.25, 9.75, 10.0], + [9.0, 9.25, 9.75, 10.25, 10.75, 11.25, 11.75, 12.0], + [11.0, 11.25, 11.75, 12.25, 12.75, 13.25, 13.75, 14.0], + [12.0, 12.25, 12.75, 13.25, 13.75, 14.25, 14.75, 15.0], + ]]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv1d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv1d.rs new file mode 100644 index 0000000..456f182 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv1d.rs @@ -0,0 +1,138 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::conv1d; +use burn_tensor::ops::ConvOptions; + +#[test] +fn test_conv1d_simple() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 1, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from([ + [[43., 67., 82., 49.], [104., 176., 227., 158.]], + [[139., 187., 202., 113.], [392., 584., 635., 414.]], + ])); +} + +#[test] +fn test_conv1d_dilation() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 2, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from([ + [[62., 38.], [159., 111.]], + [[158., 102.], [447., 367.]], + ])); +} + +#[test] +fn test_conv1d_groups() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + stride: 1, + dilation: 1, + groups: 2, + length: 4, + }; + + test.assert_output(TestTensor::from([ + [[2., 5., 8., 3.], [42., 63., 75., 47.]], + [[26., 29., 32., 11.], [114., 159., 171., 103.]], + ])); +} + +#[test] +fn test_conv1d_complex() { + let test = Conv1dTestCase { + batch_size: 2, + channels_in: 3, + channels_out: 4, + kernel_size: 3, + padding: 1, + stride: 2, + dilation: 1, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from_data( + [ + [[171., 294.], [415., 781.], [659., 1268.], [903., 1755.]], + [[495., 726.], [1387., 2185.], [2279., 3644.], [3171., 5103.]], + ], + &Default::default(), + )); +} + +struct Conv1dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size: usize, + padding: usize, + stride: usize, + dilation: usize, + groups: usize, + length: usize, +} + +impl Conv1dTestCase { + fn assert_output(self, y: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.length]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size, + ]); + let device = Default::default(); + let weight = TestTensor::from_data( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<3, _>(shape_weight) + .into_data(), + &device, + ); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ); + let output = conv1d( + x, + weight, + Some(bias), + ConvOptions::new([self.stride], [self.padding], [self.dilation], self.groups), + ); + + let tolerance = Tolerance::relative(1e-5).set_half_precision_relative(1e-3); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv2d.rs new file mode 100644 index 0000000..8cc527f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv2d.rs @@ -0,0 +1,762 @@ +use super::*; +use alloc::{vec, vec::Vec}; +use burn_tensor::Shape; +use burn_tensor::activation::gelu; +use burn_tensor::module::conv2d; +use burn_tensor::ops::ConvOptions; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn test_conv2d_simple() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [1196., 1796., 1916., 1264.], + [1881., 2793., 2946., 1923.], + [2313., 3405., 3558., 2307.], + [1424., 2072., 2156., 1380.], + ], + [ + [2709., 4173., 4509., 3065.], + [4582., 7006., 7483., 5056.], + [5878., 8914., 9391., 6304.], + [4089., 6177., 6477., 4333.], + ], + ]])); +} + +#[test] +fn test_conv2d_simple_implicit() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 16, + kernel_size_1: 4, + kernel_size_2: 4, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 5, + width: 5, + }; + + test.assert_output(TestTensor::from([[ + [ + [666., 916., 1030., 774.], + [1124., 1500., 1620., 1190.], + [1604., 2100., 2220., 1610.], + [990., 1264., 1330., 936.], + ], + [ + [1531., 2165., 2471., 1927.], + [2757., 3805., 4181., 3207.], + [4197., 5685., 6061., 4587.], + [3295., 4433., 4691., 3529.], + ], + [ + [2396., 3414., 3912., 3080.], + [4390., 6110., 6742., 5224.], + [6790., 9270., 9902., 7564.], + [5600., 7602., 8052., 6122.], + ], + [ + [3261., 4663., 5353., 4233.], + [6023., 8415., 9303., 7241.], + [9383., 12855., 13743., 10541.], + [7905., 10771., 11413., 8715.], + ], + [ + [4126., 5912., 6794., 5386.], + [7656., 10720., 11864., 9258.], + [11976., 16440., 17584., 13518.], + [10210., 13940., 14774., 11308.], + ], + [ + [4991., 7161., 8235., 6539.], + [9289., 13025., 14425., 11275.], + [14569., 20025., 21425., 16495.], + [12515., 17109., 18135., 13901.], + ], + [ + [5856., 8410., 9676., 7692.], + [10922., 15330., 16986., 13292.], + [17162., 23610., 25266., 19472.], + [14820., 20278., 21496., 16494.], + ], + [ + [6721., 9659., 11117., 8845.], + [12555., 17635., 19547., 15309.], + [19755., 27195., 29107., 22449.], + [17125., 23447., 24857., 19087.], + ], + [ + [7586., 10908., 12558., 9998.], + [14188., 19940., 22108., 17326.], + [22348., 30780., 32948., 25426.], + [19430., 26616., 28218., 21680.], + ], + [ + [8451., 12157., 13999., 11151.], + [15821., 22245., 24669., 19343.], + [24941., 34365., 36789., 28403.], + [21735., 29785., 31579., 24273.], + ], + [ + [9316., 13406., 15440., 12304.], + [17454., 24550., 27230., 21360.], + [27534., 37950., 40630., 31380.], + [24040., 32954., 34940., 26866.], + ], + [ + [10181., 14655., 16881., 13457.], + [19087., 26855., 29791., 23377.], + [30127., 41535., 44471., 34357.], + [26345., 36123., 38301., 29459.], + ], + [ + [11046., 15904., 18322., 14610.], + [20720., 29160., 32352., 25394.], + [32720., 45120., 48312., 37334.], + [28650., 39292., 41662., 32052.], + ], + [ + [11911., 17153., 19763., 15763.], + [22353., 31465., 34913., 27411.], + [35313., 48705., 52153., 40311.], + [30955., 42461., 45023., 34645.], + ], + [ + [12776., 18402., 21204., 16916.], + [23986., 33770., 37474., 29428.], + [37906., 52290., 55994., 43288.], + [33260., 45630., 48384., 37238.], + ], + [ + [13641., 19651., 22645., 18069.], + [25619., 36075., 40035., 31445.], + [40499., 55875., 59835., 46265.], + [35565., 48799., 51745., 39831.], + ], + ]])); +} + +#[test] +fn test_conv2d_implicit_padded_in_channels() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 16, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [4521., 6753., 7014., 4635.], + [6858., 10197., 10548., 6939.], + [7830., 11601., 11952., 7839.], + [5007., 7383., 7590., 4953.], + ], + [ + [10516., 15988., 16735., 11278.], + [16822., 25507., 26587., 17875.], + [19738., 29827., 30907., 20719.], + [13594., 20506., 21199., 14188.], + ], + [ + [16511., 25223., 26456., 17921.], + [26786., 40817., 42626., 28811.], + [31646., 48053., 49862., 33599.], + [22181., 33629., 34808., 23423.], + ], + [ + [22506., 34458., 36177., 24564.], + [36750., 56127., 58665., 39747.], + [43554., 66279., 68817., 46479.], + [30768., 46752., 48417., 32658.], + ], + [ + [28501., 43693., 45898., 31207.], + [46714., 71437., 74704., 50683.], + [55462., 84505., 87772., 59359.], + [39355., 59875., 62026., 41893.], + ], + [ + [34496., 52928., 55619., 37850.], + [56678., 86747., 90743., 61619.], + [67370., 102731., 106727., 72239.], + [47942., 72998., 75635., 51128.], + ], + [ + [40491., 62163., 65340., 44493.], + [66642., 102057., 106782., 72555.], + [79278., 120957., 125682., 85119.], + [56529., 86121., 89244., 60363.], + ], + [ + [46486., 71398., 75061., 51136.], + [76606., 117367., 122821., 83491.], + [91186., 139183., 144637., 97999.], + [65116., 99244., 102853., 69598.], + ], + [ + [52481., 80633., 84782., 57779.], + [86570., 132677., 138860., 94427.], + [103094., 157409., 163592., 110879.], + [73703., 112367., 116462., 78833.], + ], + [ + [58476., 89868., 94503., 64422.], + [96534., 147987., 154899., 105363.], + [115002., 175635., 182547., 123759.], + [82290., 125490., 130071., 88068.], + ], + [ + [64471., 99103., 104224., 71065.], + [106498., 163297., 170938., 116299.], + [126910., 193861., 201502., 136639.], + [90877., 138613., 143680., 97303.], + ], + [ + [70466., 108338., 113945., 77708.], + [116462., 178607., 186977., 127235.], + [138818., 212087., 220457., 149519.], + [99464., 151736., 157289., 106538.], + ], + [ + [76461., 117573., 123666., 84351.], + [126426., 193917., 203016., 138171.], + [150726., 230313., 239412., 162399.], + [108051., 164859., 170898., 115773.], + ], + [ + [82456., 126808., 133387., 90994.], + [136390., 209227., 219055., 149107.], + [162634., 248539., 258367., 175279.], + [116638., 177982., 184507., 125008.], + ], + [ + [88451., 136043., 143108., 97637.], + [146354., 224537., 235094., 160043.], + [174542., 266765., 277322., 188159.], + [125225., 191105., 198116., 134243.], + ], + [ + [94446., 145278., 152829., 104280.], + [156318., 239847., 251133., 170979.], + [186450., 284991., 296277., 201039.], + [133812., 204228., 211725., 143478.], + ], + ]])); +} + +#[test] +fn test_conv2d_groups_channels_out() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 16, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [73., 121., 154., 103.], + [171., 258., 294., 186.], + [279., 402., 438., 270.], + [139., 187., 202., 113.], + ], + [ + [164., 284., 371., 266.], + [415., 664., 781., 538.], + [739., 1132., 1249., 838.], + [518., 782., 851., 564.], + ], + [ + [255., 447., 588., 429.], + [659., 1070., 1268., 890.], + [1199., 1862., 2060., 1406.], + [897., 1377., 1500., 1015.], + ], + [ + [346., 610., 805., 592.], + [903., 1476., 1755., 1242.], + [1659., 2592., 2871., 1974.], + [1276., 1972., 2149., 1466.], + ], + [ + [437., 773., 1022., 755.], + [1147., 1882., 2242., 1594.], + [2119., 3322., 3682., 2542.], + [1655., 2567., 2798., 1917.], + ], + [ + [528., 936., 1239., 918.], + [1391., 2288., 2729., 1946.], + [2579., 4052., 4493., 3110.], + [2034., 3162., 3447., 2368.], + ], + [ + [619., 1099., 1456., 1081.], + [1635., 2694., 3216., 2298.], + [3039., 4782., 5304., 3678.], + [2413., 3757., 4096., 2819.], + ], + [ + [710., 1262., 1673., 1244.], + [1879., 3100., 3703., 2650.], + [3499., 5512., 6115., 4246.], + [2792., 4352., 4745., 3270.], + ], + [ + [5793., 8865., 9330., 6335.], + [9467., 14450., 15134., 10250.], + [11303., 17186., 17870., 12062.], + [7971., 12099., 12546., 8457.], + ], + [ + [6460., 9892., 10411., 7074.], + [10575., 16152., 16917., 11466.], + [12627., 19212., 19977., 13494.], + [8926., 13558., 14059., 9484.], + ], + [ + [7127., 10919., 11492., 7813.], + [11683., 17854., 18700., 12682.], + [13951., 21238., 22084., 14926.], + [9881., 15017., 15572., 10511.], + ], + [ + [7794., 11946., 12573., 8552.], + [12791., 19556., 20483., 13898.], + [15275., 23264., 24191., 16358.], + [10836., 16476., 17085., 11538.], + ], + [ + [8461., 12973., 13654., 9291.], + [13899., 21258., 22266., 15114.], + [16599., 25290., 26298., 17790.], + [11791., 17935., 18598., 12565.], + ], + [ + [9128., 14000., 14735., 10030.], + [15007., 22960., 24049., 16330.], + [17923., 27316., 28405., 19222.], + [12746., 19394., 20111., 13592.], + ], + [ + [9795., 15027., 15816., 10769.], + [16115., 24662., 25832., 17546.], + [19247., 29342., 30512., 20654.], + [13701., 20853., 21624., 14619.], + ], + [ + [10462., 16054., 16897., 11508.], + [17223., 26364., 27615., 18762.], + [20571., 31368., 32619., 22086.], + [14656., 22312., 23137., 15646.], + ], + ]])); +} + +#[test] +fn test_conv2d_groups_remainder_padding_regression() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 32, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [[43., 37.], [25., 19.]], + [[98., 92.], [80., 74.]], + [[153., 147.], [135., 129.]], + [[208., 202.], [190., 184.]], + [[263., 257.], [245., 239.]], + [[318., 312.], [300., 294.]], + [[373., 367.], [355., 349.]], + [[428., 422.], [410., 404.]], + [[483., 477.], [465., 459.]], + [[538., 532.], [520., 514.]], + [[593., 587.], [575., 569.]], + [[648., 642.], [630., 624.]], + [[703., 697.], [685., 679.]], + [[758., 752.], [740., 734.]], + [[813., 807.], [795., 789.]], + [[868., 862.], [850., 844.]], + [[3323., 3301.], [3257., 3235.]], + [[3522., 3500.], [3456., 3434.]], + [[3721., 3699.], [3655., 3633.]], + [[3920., 3898.], [3854., 3832.]], + [[4119., 4097.], [4053., 4031.]], + [[4318., 4296.], [4252., 4230.]], + [[4517., 4495.], [4451., 4429.]], + [[4716., 4694.], [4650., 4628.]], + [[4915., 4893.], [4849., 4827.]], + [[5114., 5092.], [5048., 5026.]], + [[5313., 5291.], [5247., 5225.]], + [[5512., 5490.], [5446., 5424.]], + [[5711., 5689.], [5645., 5623.]], + [[5910., 5888.], [5844., 5822.]], + [[6109., 6087.], [6043., 6021.]], + [[6308., 6286.], [6242., 6220.]], + ]])); +} + +#[test] +fn test_conv2d_groups_padding_larger_than_output_width_regression() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 32, + kernel_size_1: 1, + kernel_size_2: 8, + padding_1: 0, + padding_2: 4, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 1, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [[5., 4., 3.]], + [[14., 13., 12.]], + [[23., 22., 21.]], + [[32., 31., 30.]], + [[41., 40., 39.]], + [[50., 49., 48.]], + [[59., 58., 57.]], + [[68., 67., 66.]], + [[77., 76., 75.]], + [[86., 85., 84.]], + [[95., 94., 93.]], + [[104., 103., 102.]], + [[113., 112., 111.]], + [[122., 121., 120.]], + [[131., 130., 129.]], + [[140., 139., 138.]], + [[679., 674., 669.]], + [[720., 715., 710.]], + [[761., 756., 751.]], + [[802., 797., 792.]], + [[843., 838., 833.]], + [[884., 879., 874.]], + [[925., 920., 915.]], + [[966., 961., 956.]], + [[1007., 1002., 997.]], + [[1048., 1043., 1038.]], + [[1089., 1084., 1079.]], + [[1130., 1125., 1120.]], + [[1171., 1166., 1161.]], + [[1212., 1207., 1202.]], + [[1253., 1248., 1243.]], + [[1294., 1289., 1284.]], + ]])); +} + +#[test] +fn test_conv2d_groups() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 5, + width: 5, + }; + + test.assert_output(TestTensor::from([[ + [[312., 348., 384.], [492., 528., 564.], [672., 708., 744.]], + [ + [3724., 3841., 3958.], + [4309., 4426., 4543.], + [4894., 5011., 5128.], + ], + ]])); +} + +#[test] +fn test_conv2d_groups_multiple_channels() { + let test = Conv2dTestCase { + batch_size: 1, + channels_in: 4, + channels_out: 4, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 5, + width: 5, + }; + + test.assert_output(TestTensor::from([[ + [ + [4035., 4188., 4341.], + [4800., 4953., 5106.], + [5565., 5718., 5871.], + ], + [ + [10030., 10507., 10984.], + [12415., 12892., 13369.], + [14800., 15277., 15754.], + ], + [ + [56075., 56876., 57677.], + [60080., 60881., 61682.], + [64085., 64886., 65687.], + ], + [ + [78270., 79395., 80520.], + [83895., 85020., 86145.], + [89520., 90645., 91770.], + ], + ]])); +} + +#[test] +fn test_conv2d_complex() { + let test = Conv2dTestCase { + batch_size: 2, + channels_in: 3, + channels_out: 4, + kernel_size_1: 3, + kernel_size_2: 2, + padding_1: 1, + padding_2: 2, + stride_1: 2, + stride_2: 3, + dilation_1: 1, + dilation_2: 2, + groups: 1, + height: 4, + width: 5, + }; + + test.assert_output(TestTensor::from([ + [ + [[1845., 3789., 1926.], [3210., 6465., 3228.]], + [[4276., 9082., 4789.], [8071., 16834., 8737.]], + [[6707., 14375., 7652.], [12932., 27203., 14246.]], + [[9138., 19668., 10515.], [17793., 37572., 19755.]], + ], + [ + [[5445., 10629., 5166.], [8070., 15645., 7548.]], + [[14356., 28882., 14509.], [22651., 45454., 22777.]], + [[23267., 47135., 23852.], [37232., 75263., 38006.]], + [[32178., 65388., 33195.], [51813., 105072., 53235.]], + ], + ])); +} + +struct Conv2dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + dilation_1: usize, + dilation_2: usize, + groups: usize, + height: usize, + width: usize, +} + +impl Conv2dTestCase { + fn assert_output(self, y: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size_1, + self.kernel_size_2, + ]); + let device = Default::default(); + let weight = TestTensor::from( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<4, _>(shape_weight) + .into_data(), + ); + let bias = TestTensor::from( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + ); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = conv2d( + x, + weight, + Some(bias), + ConvOptions::new( + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + [self.dilation_1, self.dilation_2], + self.groups, + ), + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} + +#[rustfmt::skip] // param values are too long + fn conv2d_weight() -> TensorData { + TensorData::new( + vec![0.048065186, -0.3059082, -0.10345459, -0.34643555, -0.20788574, -0.021072388, 0.13745117, -0.05102539, 0.024536133, -0.16479492, -0.19519043, 0.27270508, 0.17700195, -0.33764648, -0.08239746, -0.27929688, 0.17321777, -0.1315918, 0.04574585, -0.17980957, -0.33569336, 0.27612305, 0.30004883, -0.28979492, -0.17297363, -0.021759033, -0.27148438, 0.005657196, 0.29956055, -0.06958008, -0.29345703, -0.14440918, 0.10827637, -0.13305664, -0.20239258, 0.24890137, -0.1541748, -0.20019531, -0.2854004, 0.17016602, 0.07861328, -0.09075928, 0.30908203, -0.00013422966, 0.29589844, 0.15258789, -0.25708008, 0.20422363, -0.2529297, 0.07891846, -0.19506836, 0.23571777, 0.27124023, 0.17370605, -0.16992188, -0.23522949, 0.14648438, -0.09576416, -0.18310547, 0.21044922, -0.08911133, -0.2541504, -0.2775879, -0.2064209, -0.16271973, -0.048919678, -0.03555298, -0.11639404, 0.09661865, -0.10241699, 0.08929443, 0.2866211], + [8, 1, 3, 3], + ) + } + +#[test] +fn test_conv2d_binary_broadcasted() { + let device = Default::default(); + let x = TestTensor::<4>::full([1, 1, 28, 28], -0.42421296, &device); + + // conv2d -> batchnorm -> activation + let weight = TestTensor::from_data(conv2d_weight(), &device); + let bias = TestTensor::from([ + 0.082336426, + -0.049591064, + 0.0031795502, + 0.00095653534, + 0.02357483, + 0.005569458, + 0.07525635, + 0.056396484, + ]); + + // channels: [1, 8], kernel_size: [3, 3], stride: [1, 1], dilation: [1, 1], groups: 1, padding: [0, 0] + let opt = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let x = conv2d(x, weight, Some(bias), opt); + + // simulate batchnorm binary ops with broadcasted params + let gamma = TestTensor::<1>::from([ + 1.0048828, 0.9902344, 1.0185547, 0.97558594, 1.0097656, 0.97802734, 1.0009766, 1.0146484, + ]); + let beta = TestTensor::<1>::from([ + 0.026290894, + 0.0007505417, + 0.006134033, + 0.02418518, + 0.07373047, + 0.020507813, + 0.01902771, + 0.02003479, + ]); + let mean = TestTensor::<1>::from([ + 0.029159546, + -0.08673096, + -0.03894043, + -0.01108551, + 0.032440186, + 0.03237915, + 0.013839722, + 0.04397583, + ]) + .reshape([1, 8, 1, 1]); + let var = TestTensor::<1>::from([ + 0.67089844, 0.29956055, 0.5209961, 0.1862793, 0.30419922, 0.21313477, 0.7504883, 0.26342773, + ]) + .reshape([1, 8, 1, 1]); + + let std = var.add_scalar(1e-5).sqrt(); + let x = x.sub(mean); + let x = x.div(std); + let x = x.mul(gamma.reshape([1, 8, 1, 1])); + let x = x.add(beta.reshape([1, 8, 1, 1])); + + let x = gelu(x); + + let expected: Vec = [ + 0.36432067f32, + 0.34909567, + 0.30684796, + 0.13217466, + -0.018471397, + -0.1389876, + 0.39402074, + 0.12394252, + ] + .iter() + .flat_map(|&v| core::iter::repeat_n(v, 676)) + .collect(); + let expected = TensorData::new(expected, [1, 8, 26, 26]); + + x.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(1e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv3d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv3d.rs new file mode 100644 index 0000000..1143edd --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv3d.rs @@ -0,0 +1,299 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::conv3d; +use burn_tensor::ops::ConvOptions; + +#[test] +fn test_conv3d_simple() { + let test = Conv3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 4, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [29980.0, 44860.0, 45640.0, 30324.0], + [45072.0, 67380.0, 68496.0, 45468.0], + [48096.0, 71844.0, 72960.0, 48396.0], + [31780.0, 47428.0, 48136.0, 31900.0], + ], + [ + [47292.0, 70548.0, 71556.0, 47400.0], + [70335.0, 104823.0, 106254.0, 70317.0], + [74223.0, 110547.0, 111978.0, 74061.0], + [48552.0, 72240.0, 73140.0, 48324.0], + ], + [ + [58236.0, 86676.0, 87684.0, 57960.0], + [85887.0, 127719.0, 129150.0, 85293.0], + [89775.0, 133443.0, 134874.0, 89037.0], + [58344.0, 86640.0, 87540.0, 57732.0], + ], + [ + [36148.0, 53620.0, 54184.0, 35692.0], + [52740.0, 78144.0, 78936.0, 51936.0], + [54900.0, 81312.0, 82104.0, 54000.0], + [35260.0, 52156.0, 52648.0, 34580.0], + ], + ], + [ + [ + [66701.0, 100589.0, 102665.0, 68773.0], + [102745.0, 154861.0, 157921.0, 105733.0], + [110953.0, 167101.0, 170161.0, 113845.0], + [75413.0, 113525.0, 115529.0, 77261.0], + ], + [ + [112741.0, 169693.0, 172645.0, 115441.0], + [172396.0, 259372.0, 263719.0, 176266.0], + [184060.0, 276760.0, 281107.0, 187786.0], + [124369.0, 186937.0, 189781.0, 126733.0], + ], + [ + [144421.0, 216925.0, 219877.0, 146737.0], + [219052.0, 328924.0, 333271.0, 222346.0], + [230716.0, 346312.0, 350659.0, 233866.0], + [154897.0, 232441.0, 235285.0, 156877.0], + ], + [ + [100517.0, 150821.0, 152681.0, 101789.0], + [151885.0, 227833.0, 230569.0, 153673.0], + [159229.0, 238777.0, 241513.0, 160921.0], + [106541.0, 159725.0, 161513.0, 107589.0], + ], + ], + ]])); +} + +#[test] +fn test_conv3d_groups() { + let test = Conv3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 0, + padding_2: 0, + padding_3: 0, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 2, + depth: 5, + height: 5, + width: 5, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [15219., 15570., 15921.], + [16974., 17325., 17676.], + [18729., 19080., 19431.], + ], + [ + [23994., 24345., 24696.], + [25749., 26100., 26451.], + [27504., 27855., 28206.], + ], + [ + [32769., 33120., 33471.], + [34524., 34875., 35226.], + [36279., 36630., 36981.], + ], + ], + [ + [ + [172819., 173899., 174979.], + [178219., 179299., 180379.], + [183619., 184699., 185779.], + ], + [ + [199819., 200899., 201979.], + [205219., 206299., 207379.], + [210619., 211699., 212779.], + ], + [ + [226819., 227899., 228979.], + [232219., 233299., 234379.], + [237619., 238699., 239779.], + ], + ], + ]])); +} + +#[test] +fn test_conv3d_complex() { + let test = Conv3dTestCase { + batch_size: 2, + channels_in: 3, + channels_out: 4, + kernel_size_1: 4, + kernel_size_2: 3, + kernel_size_3: 2, + padding_1: 1, + padding_2: 2, + padding_3: 3, + stride_1: 2, + stride_2: 3, + stride_3: 4, + dilation_1: 1, + dilation_2: 2, + dilation_3: 3, + groups: 1, + depth: 4, + height: 5, + width: 6, + }; + + test.assert_output(TestTensor::from([ + [ + [ + [[149148., 299070., 149850.], [147636., 295758., 148050.]], + [[150660., 301014., 150282.], [147420., 294246., 146754.]], + ], + [ + [[351325., 709903., 358507.], [357589., 722143., 364483.]], + [[391717., 789607., 397819.], [396253., 798391., 402067.]], + ], + [ + [[553502., 1120736., 567164.], [567542., 1148528., 580916.]], + [[632774., 1278200., 645356.], [645086., 1302536., 657380.]], + ], + [ + [[755679., 1531569., 775821.], [777495., 1574913., 797349.]], + [[873831., 1766793., 892893.], [893919., 1806681., 912693.]], + ], + ], + [ + [ + [[408348., 810990., 402570.], [393876., 781758., 387810.]], + [[370980., 735174., 364122.], [354780., 702486., 347634.]], + ], + [ + [ + [1077085., 2154943., 1077787.], + [1070389., 2141263., 1070803.], + ], + [ + [1078597., 2156887., 1078219.], + [1070173., 2139751., 1069507.], + ], + ], + [ + [ + [1745822., 3498896., 1753004.], + [1746902., 3500768., 1753796.], + ], + [ + [1786214., 3578600., 1792316.], + [1785566., 3577016., 1791380.], + ], + ], + [ + [ + [2414559., 4842849., 2428221.], + [2423415., 4860273., 2436789.], + ], + [ + [2493831., 5000313., 2506413.], + [2500959., 5014281., 2513253.], + ], + ], + ], + ])); +} + +struct Conv3dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + kernel_size_3: usize, + padding_1: usize, + padding_2: usize, + padding_3: usize, + stride_1: usize, + stride_2: usize, + stride_3: usize, + dilation_1: usize, + dilation_2: usize, + dilation_3: usize, + groups: usize, + depth: usize, + height: usize, + width: usize, +} + +impl Conv3dTestCase { + fn assert_output(self, y: TestTensor<5>) { + let shape_x = Shape::new([ + self.batch_size, + self.channels_in, + self.depth, + self.height, + self.width, + ]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size_1, + self.kernel_size_2, + self.kernel_size_3, + ]); + let device = Default::default(); + let weight = TestTensor::from( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<5, _>(shape_weight) + .into_data(), + ); + let bias = TestTensor::from( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + ); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<5, _>(shape_x) + .into_data(), + ); + let output = conv3d( + x, + weight, + Some(bias), + ConvOptions::new( + [self.stride_1, self.stride_2, self.stride_3], + [self.padding_1, self.padding_2, self.padding_3], + [self.dilation_1, self.dilation_2, self.dilation_3], + self.groups, + ), + ); + + let tolerance = Tolerance::relative(1e-5).set_half_precision_relative(2e-3); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose1d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose1d.rs new file mode 100644 index 0000000..2959e7f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose1d.rs @@ -0,0 +1,145 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::conv_transpose1d; +use burn_tensor::ops::ConvTransposeOptions; + +#[test] +fn test_conv_transpose1d_diff_channels() { + let test = ConvTranspose1dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 2, + kernel_size: 3, + padding: 1, + padding_out: 0, + stride: 1, + dilation: 1, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from([[ + [270., 453., 516., 387.], + [352., 589., 679., 505.], + ]])); +} + +#[test] +fn test_conv_transpose1d_stride() { + let test = ConvTranspose1dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + padding_out: 1, + stride: 2, + dilation: 1, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from([[ + [28., 62., 36., 78., 44., 94., 52., 62.], + [41., 93., 55., 121., 69., 149., 83., 93.], + ]])); +} + +#[test] +fn test_conv_transpose1d_dilation() { + let test = ConvTranspose1dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + padding_out: 0, + stride: 1, + dilation: 2, + groups: 1, + length: 4, + }; + + test.assert_output(TestTensor::from([[ + [30., 64., 78., 76., 94., 52.], + [49., 101., 127., 113., 143., 77.], + ]])); +} + +#[test] +fn test_conv_transpose1d_groups() { + let test = ConvTranspose1dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size: 3, + padding: 1, + padding_out: 0, + stride: 1, + dilation: 1, + groups: 2, + length: 4, + }; + + test.assert_output(TestTensor::from_data( + [[[0., 1., 4., 7.], [32., 59., 71., 59.]]], + &Default::default(), + )); +} + +struct ConvTranspose1dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size: usize, + padding: usize, + padding_out: usize, + stride: usize, + dilation: usize, + groups: usize, + length: usize, +} + +impl ConvTranspose1dTestCase { + fn assert_output(self, y: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.length]); + let shape_weights = Shape::new([ + self.channels_in, + self.channels_out / self.groups, + self.kernel_size, + ]); + let device = Default::default(); + let weights = TestTensor::from_data( + TestTensorInt::arange(0..shape_weights.num_elements() as i64, &device) + .reshape::<3, _>(shape_weights) + .into_data(), + &device, + ); + let bias = TestTensor::from_data( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + &device, + ); + let x = TestTensor::from_data( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<3, _>(shape_x) + .into_data(), + &device, + ); + let output = conv_transpose1d( + x, + weights, + Some(bias), + ConvTransposeOptions::new( + [self.stride], + [self.padding], + [self.padding_out], + [self.dilation], + self.groups, + ), + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose2d.rs new file mode 100644 index 0000000..ec8d069 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose2d.rs @@ -0,0 +1,361 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::conv_transpose2d; +use burn_tensor::ops::ConvTransposeOptions; + +#[test] +fn test_conv_transpose2d_simple_1() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 1, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[[[5.0, 11.0], [23.0, 29.0]]]])); +} + +#[test] +fn test_conv_transpose2d_simple_2() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [9855., 15207., 15738., 10797.], + [16290., 25119., 25956., 17793.], + [18486., 28467., 29304., 20061.], + [13593., 20913., 21498., 14703.], + ], + [ + [11854., 18286., 18979., 13012.], + [19612., 30223., 31303., 21439.], + [22456., 34543., 35623., 24355.], + [16456., 25288., 26035., 17782.], + ], + [ + [13853., 21365., 22220., 15227.], + [22934., 35327., 36650., 25085.], + [26426., 40619., 41942., 28649.], + [19319., 29663., 30572., 20861.], + ], + ]])); +} + +#[test] +fn test_conv_transpose2d_simple_3() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 1, + kernel_size_1: 2, + kernel_size_2: 2, + padding_1: 0, + padding_2: 0, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[[ + [0.0, 0.0, 1.0], + [0.0, 4.0, 6.0], + [4.0, 12.0, 9.0], + ]]])); +} + +#[test] +fn test_conv_transpose2d_stride_2() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 1, + kernel_size_1: 2, + kernel_size_2: 2, + padding_1: 0, + padding_2: 0, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 2, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[[ + [0.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 2.0, 3.0], + [0.0, 2.0, 0.0, 3.0], + [4.0, 6.0, 6.0, 9.0], + ]]])); +} + +#[test] +fn test_conv_transpose2d_dilation_2() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + padding_out_1: 1, + padding_out_2: 1, + stride_1: 1, + stride_2: 1, + dilation_1: 2, + dilation_2: 2, + groups: 1, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [ + [126., 116., 136., 124., 146.], + [108., 88., 114., 92., 120.], + [156., 140., 166., 148., 176.], + [126., 100., 132., 104., 138.], + [186., 164., 196., 172., 206.], + ], + [ + [217., 189., 227., 197., 237.], + [163., 125., 169., 129., 175.], + [247., 213., 257., 221., 267.], + [181., 137., 187., 141., 193.], + [277., 237., 287., 245., 297.], + ], + ]])); +} + +#[test] +fn test_conv_transpose2d_stride2_out_padding() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + padding_out_1: 1, + padding_out_2: 1, + stride_1: 2, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [352., 728., 378., 780., 404., 832., 430., 452.], + [784., 1616., 836., 1720., 888., 1824., 940., 992.], + [456., 936., 482., 988., 508., 1040., 534., 564.], + [992., 2032., 1044., 2136., 1096., 2240., 1148., 1216.], + [560., 1144., 586., 1196., 612., 1248., 638., 676.], + [1200., 2448., 1252., 2552., 1304., 2656., 1356., 1440.], + [664., 1352., 690., 1404., 716., 1456., 742., 788.], + [784., 1598., 816., 1662., 848., 1726., 880., 926.], + ], + [ + [497., 1035., 541., 1123., 585., 1211., 629., 651.], + [1145., 2373., 1233., 2549., 1321., 2725., 1409., 1461.], + [673., 1387., 717., 1475., 761., 1563., 805., 835.], + [1497., 3077., 1585., 3253., 1673., 3429., 1761., 1829.], + [849., 1739., 893., 1827., 937., 1915., 981., 1019.], + [1849., 3781., 1937., 3957., 2025., 4133., 2113., 2197.], + [1025., 2091., 1069., 2179., 1113., 2267., 1157., 1203.], + [1145., 2337., 1195., 2437., 1245., 2537., 1295., 1341.], + ], + ]])); +} + +#[test] +fn test_conv_transpose2d_groups_2() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 1, + padding_2: 1, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [[5., 11.], [23., 29.]], + [[236., 258.], [302., 324.]], + ]])); +} + +#[test] +fn test_conv_transpose2d_groups_different_channels() { + let test = ConvTranspose2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 6, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + padding_out_1: 0, + padding_out_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + groups: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [ + [0.0000e+00, 0.0000e+00, 1.0000e+00, 2.0000e+00], + [0.0000e+00, 5.0000e+00, 1.1000e+01, 1.1000e+01], + [6.0000e+00, 2.3000e+01, 2.9000e+01, 2.3000e+01], + [1.2000e+01, 3.2000e+01, 3.7000e+01, 2.4000e+01], + ], + [ + [1.0000e+00, 1.0000e+01, 1.1000e+01, 1.2000e+01], + [1.9000e+01, 6.0000e+01, 6.6000e+01, 4.8000e+01], + [2.5000e+01, 7.8000e+01, 8.4000e+01, 6.0000e+01], + [3.1000e+01, 7.8000e+01, 8.3000e+01, 5.2000e+01], + ], + [ + [2.0000e+00, 2.0000e+01, 2.1000e+01, 2.2000e+01], + [3.8000e+01, 1.1500e+02, 1.2100e+02, 8.5000e+01], + [4.4000e+01, 1.3300e+02, 1.3900e+02, 9.7000e+01], + [5.0000e+01, 1.2400e+02, 1.2900e+02, 8.0000e+01], + ], + [ + [1.1100e+02, 2.5000e+02, 2.5900e+02, 1.4800e+02], + [2.8500e+02, 6.3400e+02, 6.5600e+02, 3.6600e+02], + [3.1500e+02, 7.0000e+02, 7.2200e+02, 4.0200e+02], + [2.0100e+02, 4.3800e+02, 4.5100e+02, 2.4800e+02], + ], + [ + [1.4800e+02, 3.3200e+02, 3.4100e+02, 1.9400e+02], + [3.7600e+02, 8.3300e+02, 8.5500e+02, 4.7500e+02], + [4.0600e+02, 8.9900e+02, 9.2100e+02, 5.1100e+02], + [2.5600e+02, 5.5600e+02, 5.6900e+02, 3.1200e+02], + ], + [ + [1.8500e+02, 4.1400e+02, 4.2300e+02, 2.4000e+02], + [4.6700e+02, 1.0320e+03, 1.0540e+03, 5.8400e+02], + [4.9700e+02, 1.0980e+03, 1.1200e+03, 6.2000e+02], + [3.1100e+02, 6.7400e+02, 6.8700e+02, 3.7600e+02], + ], + ]])); +} + +struct ConvTranspose2dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + padding_out_1: usize, + padding_out_2: usize, + stride_1: usize, + stride_2: usize, + dilation_1: usize, + dilation_2: usize, + groups: usize, + height: usize, + width: usize, +} + +impl ConvTranspose2dTestCase { + fn assert_output(self, y: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let shape_weights = Shape::new([ + self.channels_in, + self.channels_out / self.groups, + self.kernel_size_1, + self.kernel_size_2, + ]); + let device = Default::default(); + let weights = TestTensor::from( + TestTensorInt::arange(0..shape_weights.num_elements() as i64, &device) + .reshape::<4, _>(shape_weights) + .into_data(), + ); + let bias = TestTensor::from( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + ); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = conv_transpose2d( + x, + weights, + Some(bias), + ConvTransposeOptions::new( + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + [self.padding_out_1, self.padding_out_2], + [self.dilation_1, self.dilation_2], + self.groups, + ), + ); + + y.into_data() + .assert_approx_eq::(&output.into_data(), Tolerance::rel_abs(1e-1, 0.01)); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose3d.rs b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose3d.rs new file mode 100644 index 0000000..49911a6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/conv_transpose3d.rs @@ -0,0 +1,749 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::conv_transpose3d; +use burn_tensor::ops::ConvTransposeOptions; + +#[test] +fn test_conv_transpose3d_simple_1() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 1, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + padding_out_1: 0, + padding_out_2: 0, + padding_out_3: 0, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[[ + [[96., 124.], [180., 208.]], + [[348., 376.], [432., 460.]], + ]]])); +} +#[test] +fn test_conv_transpose3d_simple_2() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + padding_out_1: 0, + padding_out_2: 0, + padding_out_3: 0, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 4, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [238452., 360588., 363756., 244488.], + [367929., 556353., 561186., 377163.], + [380745., 575685., 580518., 390123.], + [261192., 394896., 398172., 267564.], + ], + [ + [394083., 595827., 600822., 403749.], + [607635., 918648., 926262., 622404.], + [627831., 949104., 956718., 642816.], + [430353., 650529., 655686., 440523.], + ], + [ + [447075., 675747., 680742., 457317.], + [688419., 1040472., 1048086., 704052.], + [708615., 1070928., 1078542., 724464.], + [485073., 733041., 738198., 495819.], + ], + [ + [328656., 496632., 500124., 335892.], + [505611., 763983., 769302., 516645.], + [519723., 785259., 790578., 530901.], + [355428., 536988., 540588., 363000.], + ], + ], + [ + [ + [286729., 433489., 437629., 294061.], + [442288., 668620., 674911., 453466.], + [458992., 693784., 700075., 470314.], + [314653., 475573., 479821., 322321.], + ], + [ + [474274., 716842., 723295., 485884.], + [730837., 1104544., 1114345., 748522.], + [756865., 1143748., 1153549., 774766.], + [518320., 783208., 789823., 530434.], + ], + [ + [542818., 820090., 826543., 555004.], + [834949., 1261360., 1271161., 853498.], + [860977., 1300564., 1310365., 879742.], + [588592., 889048., 895663., 601282.], + ], + [ + [397669., 600637., 605101., 406201.], + [611074., 922906., 929683., 624052.], + [629074., 950014., 956791., 642196.], + [429625., 648769., 653341., 438493.], + ], + ], + [ + [ + [335006., 506390., 511502., 343634.], + [516647., 780887., 788636., 529769.], + [537239., 811883., 819632., 550505.], + [368114., 556250., 561470., 377078.], + ], + [ + [554465., 837857., 845768., 568019.], + [854039., 1290440., 1302428., 874640.], + [885899., 1338392., 1350380., 906716.], + [606287., 915887., 923960., 620345.], + ], + [ + [638561., 964433., 972344., 652691.], + [981479., 1482248., 1494236., 1002944.], + [1013339., 1530200., 1542188., 1035020.], + [692111., 1045055., 1053128., 706745.], + ], + [ + [466682., 704642., 710078., 476510.], + [716537., 1081829., 1090064., 731459.], + [738425., 1114769., 1123004., 753491.], + [503822., 760550., 766094., 513986.], + ], + ], + ]])); +} + +#[test] +fn test_conv_transpose3d_stride_2() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 1, + channels_out: 1, + kernel_size_1: 2, + kernel_size_2: 2, + kernel_size_3: 2, + padding_1: 0, + padding_2: 0, + padding_3: 0, + padding_out_1: 0, + padding_out_2: 0, + padding_out_3: 0, + stride_1: 2, + stride_2: 2, + stride_3: 2, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[[ + [ + [0., 0., 0., 1.], + [0., 0., 2., 3.], + [0., 2., 0., 3.], + [4., 6., 6., 9.], + ], + [ + [0., 0., 4., 5.], + [0., 0., 6., 7.], + [8., 10., 12., 15.], + [12., 14., 18., 21.], + ], + [ + [0., 4., 0., 5.], + [8., 12., 10., 15.], + [0., 6., 0., 7.], + [12., 18., 14., 21.], + ], + [ + [16., 20., 20., 25.], + [24., 28., 30., 35.], + [24., 30., 28., 35.], + [36., 42., 42., 49.], + ], + ]]])); +} + +#[test] +fn test_conv_transpose3d_dilation_2() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + padding_out_1: 1, + padding_out_2: 1, + padding_out_3: 1, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 2, + dilation_2: 2, + dilation_3: 2, + groups: 1, + depth: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [810., 776., 832., 796., 854.], + [756., 712., 774., 728., 792.], + [876., 836., 898., 856., 920.], + [810., 760., 828., 776., 846.], + [942., 896., 964., 916., 986.], + ], + [ + [720., 660., 734., 672., 748.], + [606., 536., 616., 544., 626.], + [762., 696., 776., 708., 790.], + [636., 560., 646., 568., 656.], + [804., 732., 818., 744., 832.], + ], + [ + [1008., 956., 1030., 976., 1052.], + [918., 856., 936., 872., 954.], + [1074., 1016., 1096., 1036., 1118.], + [972., 904., 990., 920., 1008.], + [1140., 1076., 1162., 1096., 1184.], + ], + [ + [846., 768., 860., 780., 874.], + [696., 608., 706., 616., 716.], + [888., 804., 902., 816., 916.], + [726., 632., 736., 640., 746.], + [930., 840., 944., 852., 958.], + ], + [ + [1206., 1136., 1228., 1156., 1250.], + [1080., 1000., 1098., 1016., 1116.], + [1272., 1196., 1294., 1216., 1316.], + [1134., 1048., 1152., 1064., 1170.], + [1338., 1256., 1360., 1276., 1382.], + ], + ], + [ + [ + [1405., 1317., 1427., 1337., 1449.], + [1243., 1145., 1261., 1161., 1279.], + [1471., 1377., 1493., 1397., 1515.], + [1297., 1193., 1315., 1209., 1333.], + [1537., 1437., 1559., 1457., 1581.], + ], + [ + [1099., 985., 1113., 997., 1127.], + [877., 753., 887., 761., 897.], + [1141., 1021., 1155., 1033., 1169.], + [907., 777., 917., 785., 927.], + [1183., 1057., 1197., 1069., 1211.], + ], + [ + [1603., 1497., 1625., 1517., 1647.], + [1405., 1289., 1423., 1305., 1441.], + [1669., 1557., 1691., 1577., 1713.], + [1459., 1337., 1477., 1353., 1495.], + [1735., 1617., 1757., 1637., 1779.], + ], + [ + [1225., 1093., 1239., 1105., 1253.], + [967., 825., 977., 833., 987.], + [1267., 1129., 1281., 1141., 1295.], + [997., 849., 1007., 857., 1017.], + [1309., 1165., 1323., 1177., 1337.], + ], + [ + [1801., 1677., 1823., 1697., 1845.], + [1567., 1433., 1585., 1449., 1603.], + [1867., 1737., 1889., 1757., 1911.], + [1621., 1481., 1639., 1497., 1657.], + [1933., 1797., 1955., 1817., 1977.], + ], + ], + ]])); +} + +#[test] +fn test_conv_transpose3d_stride2_out_padding() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + padding_out_1: 1, + padding_out_2: 1, + padding_out_3: 1, + stride_1: 2, + stride_2: 2, + stride_3: 2, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 1, + depth: 2, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [2144., 4366., 2224., 4526., 2304., 4686., 2384., 2422.], + [4584., 9324., 4744., 9644., 4904., 9964., 5064., 5148.], + [2464., 5006., 2544., 5166., 2624., 5326., 2704., 2750.], + [5224., 10604., 5384., 10924., 5544., 11244., 5704., 5804.], + [2784., 5646., 2864., 5806., 2944., 5966., 3024., 3078.], + [5864., 11884., 6024., 12204., 6184., 12524., 6344., 6460.], + [3104., 6286., 3184., 6446., 3264., 6606., 3344., 3406.], + [3272., 6628., 3358., 6800., 3444., 6972., 3530., 3592.], + ], + [ + [5280., 10716., 5440., 11036., 5600., 11356., 5760., 5868.], + [ + 11152., 22616., 11472., 23256., 11792., 23896., 12112., 12344., + ], + [5920., 11996., 6080., 12316., 6240., 12636., 6400., 6524.], + [ + 12432., 25176., 12752., 25816., 13072., 26456., 13392., 13656., + ], + [6560., 13276., 6720., 13596., 6880., 13916., 7040., 7180.], + [ + 13712., 27736., 14032., 28376., 14352., 29016., 14672., 14968., + ], + [7200., 14556., 7360., 14876., 7520., 15196., 7680., 7836.], + [7632., 15432., 7804., 15776., 7976., 16120., 8148., 8304.], + ], + [ + [3424., 6926., 3504., 7086., 3584., 7246., 3664., 3734.], + [7144., 14444., 7304., 14764., 7464., 15084., 7624., 7772.], + [3744., 7566., 3824., 7726., 3904., 7886., 3984., 4062.], + [7784., 15724., 7944., 16044., 8104., 16364., 8264., 8428.], + [4064., 8206., 4144., 8366., 4224., 8526., 4304., 4390.], + [8424., 17004., 8584., 17324., 8744., 17644., 8904., 9084.], + [4384., 8846., 4464., 9006., 4544., 9166., 4624., 4718.], + [4648., 9380., 4734., 9552., 4820., 9724., 4906., 5000.], + ], + [ + [4000., 8096., 4098., 8292., 4196., 8488., 4294., 4364.], + [8368., 16928., 8564., 17320., 8760., 17712., 8956., 9104.], + [4392., 8880., 4490., 9076., 4588., 9272., 4686., 4764.], + [9152., 18496., 9348., 18888., 9544., 19280., 9740., 9904.], + [4784., 9664., 4882., 9860., 4980., 10056., 5078., 5164.], + [ + 9936., 20064., 10132., 20456., 10328., 20848., 10524., 10704., + ], + [5176., 10448., 5274., 10644., 5372., 10840., 5470., 5564.], + [5440., 10982., 5544., 11190., 5648., 11398., 5752., 5846.], + ], + ], + [ + [ + [3009., 6149., 3143., 6417., 3277., 6685., 3411., 3449.], + [6529., 13321., 6797., 13857., 7065., 14393., 7333., 7417.], + [3545., 7221., 3679., 7489., 3813., 7757., 3947., 3993.], + [7601., 15465., 7869., 16001., 8137., 16537., 8405., 8505.], + [4081., 8293., 4215., 8561., 4349., 8829., 4483., 4537.], + [8673., 17609., 8941., 18145., 9209., 18681., 9477., 9593.], + [4617., 9365., 4751., 9633., 4885., 9901., 5019., 5081.], + [4785., 9707., 4925., 9987., 5065., 10267., 5205., 5267.], + ], + [ + [7873., 16009., 8141., 16545., 8409., 17081., 8677., 8785.], + [ + 16769., 34065., 17305., 35137., 17841., 36209., 18377., 18609., + ], + [8945., 18153., 9213., 18689., 9481., 19225., 9749., 9873.], + [ + 18913., 38353., 19449., 39425., 19985., 40497., 20521., 20785., + ], + [ + 10017., 20297., 10285., 20833., 10553., 21369., 10821., 10961., + ], + [ + 21057., 42641., 21593., 43713., 22129., 44785., 22665., 22961., + ], + [ + 11089., 22441., 11357., 22977., 11625., 23513., 11893., 12049., + ], + [ + 11521., 23317., 11801., 23877., 12081., 24437., 12361., 12517., + ], + ], + [ + [5153., 10437., 5287., 10705., 5421., 10973., 5555., 5625.], + [ + 10817., 21897., 11085., 22433., 11353., 22969., 11621., 11769., + ], + [5689., 11509., 5823., 11777., 5957., 12045., 6091., 6169.], + [ + 11889., 24041., 12157., 24577., 12425., 25113., 12693., 12857., + ], + [6225., 12581., 6359., 12849., 6493., 13117., 6627., 6713.], + [ + 12961., 26185., 13229., 26721., 13497., 27257., 13765., 13945., + ], + [6761., 13653., 6895., 13921., 7029., 14189., 7163., 7257.], + [7025., 14187., 7165., 14467., 7305., 14747., 7445., 7539.], + ], + [ + [5729., 11607., 5881., 11911., 6033., 12215., 6185., 6255.], + [ + 12041., 24381., 12345., 24989., 12649., 25597., 12953., 13101., + ], + [6337., 12823., 6489., 13127., 6641., 13431., 6793., 6871.], + [ + 13257., 26813., 13561., 27421., 13865., 28029., 14169., 14333., + ], + [6945., 14039., 7097., 14343., 7249., 14647., 7401., 7487.], + [ + 14473., 29245., 14777., 29853., 15081., 30461., 15385., 15565., + ], + [7553., 15255., 7705., 15559., 7857., 15863., 8009., 8103.], + [7817., 15789., 7975., 16105., 8133., 16421., 8291., 8385.], + ], + ], + ]])); +} + +#[test] +fn test_conv_transpose3d_groups_2() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 2, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 1, + padding_2: 1, + padding_3: 1, + padding_out_1: 0, + padding_out_2: 0, + padding_out_3: 0, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 2, + depth: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [[[96., 124.], [180., 208.]], [[348., 376.], [432., 460.]]], + [ + [[2997., 3089.], [3273., 3365.]], + [[3825., 3917.], [4101., 4193.]], + ], + ]])); +} + +#[test] +fn test_conv_transpose3d_groups_different_channels() { + let test = ConvTranspose3dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 6, + kernel_size_1: 3, + kernel_size_2: 3, + kernel_size_3: 3, + padding_1: 0, + padding_2: 0, + padding_3: 0, + padding_out_1: 0, + padding_out_2: 0, + padding_out_3: 0, + stride_1: 1, + stride_2: 1, + stride_3: 1, + dilation_1: 1, + dilation_2: 1, + dilation_3: 1, + groups: 2, + depth: 2, + height: 2, + width: 2, + }; + + test.assert_output(TestTensor::from([[ + [ + [ + [0., 0., 1., 2.], + [0., 5., 11., 11.], + [6., 23., 29., 23.], + [12., 32., 37., 24.], + ], + [ + [0., 13., 23., 21.], + [30., 96., 124., 86.], + [66., 180., 208., 134.], + [66., 161., 179., 107.], + ], + [ + [36., 103., 113., 75.], + [138., 348., 376., 230.], + [174., 432., 460., 278.], + [138., 323., 341., 197.], + ], + [ + [72., 166., 175., 100.], + [192., 433., 455., 255.], + [222., 499., 521., 291.], + [144., 318., 331., 182.], + ], + ], + [ + [ + [1., 28., 29., 30.], + [55., 168., 174., 120.], + [61., 186., 192., 132.], + [67., 168., 173., 106.], + ], + [ + [109., 284., 294., 184.], + [355., 853., 881., 519.], + [391., 937., 965., 567.], + [283., 648., 666., 378.], + ], + [ + [145., 374., 384., 238.], + [463., 1105., 1133., 663.], + [499., 1189., 1217., 711.], + [355., 810., 828., 468.], + ], + [ + [181., 410., 419., 236.], + [463., 1028., 1050., 580.], + [493., 1094., 1116., 616.], + [307., 670., 683., 372.], + ], + ], + [ + [ + [2., 56., 57., 58.], + [110., 331., 337., 229.], + [116., 349., 355., 241.], + [122., 304., 309., 188.], + ], + [ + [218., 555., 565., 347.], + [680., 1610., 1638., 952.], + [716., 1694., 1722., 1000.], + [500., 1135., 1153., 649.], + ], + [ + [254., 645., 655., 401.], + [788., 1862., 1890., 1096.], + [824., 1946., 1974., 1144.], + [572., 1297., 1315., 739.], + ], + [ + [290., 654., 663., 372.], + [734., 1623., 1645., 905.], + [764., 1689., 1711., 941.], + [470., 1022., 1035., 562.], + ], + ], + [ + [ + [651., 1388., 1405., 750.], + [1485., 3150., 3188., 1690.], + [1539., 3264., 3302., 1750.], + [873., 1840., 1861., 982.], + ], + [ + [1695., 3578., 3620., 1910.], + [3789., 7967., 8059., 4233.], + [3921., 8243., 8335., 4377.], + [2181., 4566., 4616., 2416.], + ], + [ + [1875., 3956., 3998., 2108.], + [4185., 8795., 8887., 4665.], + [4317., 9071., 9163., 4809.], + [2397., 5016., 5066., 2650.], + ], + [ + [1191., 2490., 2515., 1316.], + [2613., 5450., 5504., 2870.], + [2691., 5612., 5666., 2954.], + [1473., 3062., 3091., 1608.], + ], + ], + [ + [ + [868., 1848., 1865., 994.], + [1972., 4177., 4215., 2231.], + [2026., 4291., 4329., 2291.], + [1144., 2408., 2429., 1280.], + ], + [ + [2236., 4713., 4755., 2505.], + [4978., 10452., 10544., 5530.], + [5110., 10728., 10820., 5674.], + [2830., 5917., 5967., 3119.], + ], + [ + [2416., 5091., 5133., 2703.], + [5374., 11280., 11372., 5962.], + [5506., 11556., 11648., 6106.], + [3046., 6367., 6417., 3353.], + ], + [ + [1516., 3166., 3191., 1668.], + [3316., 6909., 6963., 3627.], + [3394., 7071., 7125., 3711.], + [1852., 3846., 3875., 2014.], + ], + ], + [ + [ + [1085., 2308., 2325., 1238.], + [2459., 5204., 5242., 2772.], + [2513., 5318., 5356., 2832.], + [1415., 2976., 2997., 1578.], + ], + [ + [2777., 5848., 5890., 3100.], + [6167., 12937., 13029., 6827.], + [6299., 13213., 13305., 6971.], + [3479., 7268., 7318., 3822.], + ], + [ + [2957., 6226., 6268., 3298.], + [6563., 13765., 13857., 7259.], + [6695., 14041., 14133., 7403.], + [3695., 7718., 7768., 4056.], + ], + [ + [1841., 3842., 3867., 2020.], + [4019., 8368., 8422., 4384.], + [4097., 8530., 8584., 4468.], + [2231., 4630., 4659., 2420.], + ], + ], + ]])); +} + +struct ConvTranspose3dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + kernel_size_3: usize, + padding_1: usize, + padding_2: usize, + padding_3: usize, + padding_out_1: usize, + padding_out_2: usize, + padding_out_3: usize, + stride_1: usize, + stride_2: usize, + stride_3: usize, + dilation_1: usize, + dilation_2: usize, + dilation_3: usize, + groups: usize, + depth: usize, + height: usize, + width: usize, +} + +impl ConvTranspose3dTestCase { + fn assert_output(self, y: TestTensor<5>) { + let shape_x = Shape::new([ + self.batch_size, + self.channels_in, + self.depth, + self.height, + self.width, + ]); + let shape_weights = Shape::new([ + self.channels_in, + self.channels_out / self.groups, + self.kernel_size_1, + self.kernel_size_2, + self.kernel_size_3, + ]); + let device = Default::default(); + let weights = TestTensor::from( + TestTensorInt::arange(0..shape_weights.num_elements() as i64, &device) + .reshape::<5, _>(shape_weights) + .into_data(), + ); + let bias = TestTensor::from( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + ); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<5, _>(shape_x) + .into_data(), + ); + let output = conv_transpose3d( + x, + weights, + Some(bias), + ConvTransposeOptions::new( + [self.stride_1, self.stride_2, self.stride_3], + [self.padding_1, self.padding_2, self.padding_3], + [self.padding_out_1, self.padding_out_2, self.padding_out_3], + [self.dilation_1, self.dilation_2, self.dilation_3], + self.groups, + ), + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/ctc.rs b/crates/burn-backend-tests/tests/tensor/float/module/ctc.rs new file mode 100644 index 0000000..1b5aa73 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/ctc.rs @@ -0,0 +1,333 @@ +#![allow(clippy::excessive_precision)] + +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::activation::log_softmax; +use burn_tensor::module::ctc_loss; + +#[test] +fn test_ctc_loss_unreachable_target_is_inf() { + // T=2, N=1, C=3, target=[1, 1]. CTC requires at least `target_length + // + repeats = 3` steps to produce this target, but input_length is 2, + // so no valid alignment exists and the loss must be +inf. burn-nn's + // `zero_infinity` relies on `is_inf` to detect this case, so fused + // backend kernels must preserve the +inf (not collapse to a large + // finite number). + let device = Default::default(); + let log_probs = TestTensor::<3>::full([2, 1, 3], (1.0f32 / 3.0).ln(), &device); + let targets = TestTensorInt::<2>::from([[1_i64, 1]]); + let input_lengths = TestTensorInt::<1>::from([2_i64]); + let target_lengths = TestTensorInt::<1>::from([2_i64]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + let loss_data = loss.into_data(); + let value = loss_data.iter::().next().unwrap(); + assert!( + value.is_infinite() && value > 0.0, + "Expected +inf for an unreachable target, got {value}" + ); +} + +#[test] +fn test_ctc_loss_empty_target() { + // T=3, N=1, C=2, blank=0, target_length=0, uniform P = 1/2. + // With an empty target, the only valid alignment is all blanks. + // Loss = -ln((1/2)^3) = 3 * ln(2) ~ 2.0794. + // + // Uses a [1, 1] targets shape rather than [1, 0] since not every backend + // supports zero-sized dimensions. The target slot at [0, 0] is never read + // because target_lengths[0] == 0. + let log_probs = TestTensor::<3>::full([3, 1, 2], (0.5f32).ln(), &Default::default()); + let targets = TestTensorInt::<2>::from([[0]]); + let input_lengths = TestTensorInt::<1>::from([3]); + let target_lengths = TestTensorInt::<1>::from([0]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + + let expected = burn_tensor::TensorData::from([3.0f32 * 2.0f32.ln()]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-3, 1e-3)); +} + +#[test] +fn test_ctc_loss_uniform() { + // T=3, N=1, C=2, blank=0, target=[1, 1], uniform P = 1/2. + // Only valid path is (1, 0, 1) with prob (1/2)^3. + // Loss = -ln(1/8) = 3 * ln(2) ~ 2.0794 + let log_probs = TestTensor::<3>::full([3, 1, 2], (0.5f32).ln(), &Default::default()); + let targets = TestTensorInt::<2>::from([[1, 1]]); + let input_lengths = TestTensorInt::<1>::from([3]); + let target_lengths = TestTensorInt::<1>::from([2]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + + let expected = burn_tensor::TensorData::from([3.0f32 * 2.0f32.ln()]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-3, 1e-3)); +} + +#[test] +fn test_ctc_loss_long_sequence() { + // T=400, N=2, C=36, target_lengths=[60, 40]. Exercises the alpha + // recursion at training-realistic sequence lengths where numerical + // issues (shared-memory overflow, log-sum-exp accumulation drift) + // would surface but don't show in the short T=5..12 tests. + let t: usize = 400; + let n: usize = 2; + let c: usize = 36; + let mut data = Vec::with_capacity(t * n * c); + for ti in 0..t { + for ni in 0..n { + for ci in 0..c { + data.push(((ti * 7 + ni * 13 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let logits = TestTensor::<3>::from(burn_tensor::TensorData::new(data, [t, n, c])); + let log_probs = log_softmax(logits, 2); + + // Targets: distinct labels for each batch element, no consecutive repeats. + let tgt_a: Vec = (0..60).map(|i| (i % 35 + 1) as i64).collect(); + let tgt_b: Vec = (0..60) + .map(|i| if i < 40 { (i % 35 + 1) as i64 } else { 0 }) + .collect(); + let mut tgt_flat = tgt_a; + tgt_flat.extend(tgt_b); + let targets = TestTensorInt::<2>::from(burn_tensor::TensorData::new(tgt_flat, [n, 60])); + let input_lengths = TestTensorInt::<1>::from([400, 400]); + let target_lengths = TestTensorInt::<1>::from([60, 40]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + // `iter::` converts from the backend's native dtype (f16, f32, ...) + // to f32 for comparison; `to_vec::` would require exact dtype match. + let loss_data: Vec = loss.into_data().iter::().collect(); + + // We don't have a PyTorch reference for this exact input, but the loss + // must be positive (it's -log P where 0 < P <= 1) and finite. + for (i, &v) in loss_data.iter().enumerate() { + assert!( + v.is_finite() && v > 0.0, + "sample {i}: expected positive finite loss, got {v}" + ); + } + // With 36 classes and random-ish logits, loss should be in the hundreds. + // An off-by-orders-of-magnitude result (< 1 or > 10000) indicates a bug. + for (i, &v) in loss_data.iter().enumerate() { + assert!( + v > 10.0 && v < 10000.0, + "sample {i}: loss {v} outside plausible range [10, 10000]" + ); + } +} + +#[test] +fn test_ctc_loss_long_mixed_input_lengths() { + // T=494, N=2, C=36, input_lengths=[494, 390], target_lengths=[64, 43]. + // The second sample has 104 frames of padding (input_length < T). + // Reproduces the exact dimensions of a real training batch that gave + // incorrect results on the cubecl kernel. + let t: usize = 494; + let n: usize = 2; + let c: usize = 36; + let mut data = Vec::with_capacity(t * n * c); + for ti in 0..t { + for ni in 0..n { + for ci in 0..c { + data.push(((ti * 7 + ni * 13 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let logits = TestTensor::<3>::from(burn_tensor::TensorData::new(data, [t, n, c])); + let log_probs = log_softmax(logits, 2); + + let max_tgt = 64; + let tgt_a: Vec = (0..max_tgt).map(|i| (i % 35 + 1) as i64).collect(); + let tgt_b: Vec = (0..max_tgt) + .map(|i| if i < 43 { (i % 35 + 1) as i64 } else { 0 }) + .collect(); + let mut tgt_flat = tgt_a; + tgt_flat.extend(tgt_b); + let targets = TestTensorInt::<2>::from(burn_tensor::TensorData::new(tgt_flat, [n, max_tgt])); + let input_lengths = TestTensorInt::<1>::from([494, 390]); + let target_lengths = TestTensorInt::<1>::from([64, 43]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + let loss_data: Vec = loss.into_data().iter::().collect(); + + for (i, &v) in loss_data.iter().enumerate() { + assert!( + v.is_finite() && v > 0.0, + "sample {i}: expected positive finite loss, got {v}" + ); + assert!( + v > 10.0 && v < 10000.0, + "sample {i}: loss {v} outside plausible range [10, 10000]" + ); + } +} + +#[test] +fn test_ctc_loss_narrowed_input() { + // CTC on a narrowed (non-contiguous, offset != 0) log_probs tensor. + // Reproduces the pattern: model outputs [T_total, B, C], training + // code narrows to [T_total - warmup, B, C] before passing to CTC. + // If the kernel ignores the tensor's base offset, it reads the wrong + // data and produces garbage loss. + let t_full: usize = 10; + let warmup: usize = 3; + let t_eff: usize = t_full - warmup; + let n: usize = 1; + let c: usize = 4; + + let mut data = Vec::with_capacity(t_full * n * c); + for ti in 0..t_full { + for ni in 0..n { + for ci in 0..c { + data.push(((ti * 7 + ni * 13 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let full_logits = TestTensor::<3>::from(burn_tensor::TensorData::new(data, [t_full, n, c])); + let full_log_probs = log_softmax(full_logits, 2); + + // Narrow: skip the first `warmup` frames (simulating warmup slice). + let narrowed = full_log_probs.clone().narrow(0, warmup, t_eff); + + // Reference: manually extract the same slice as a contiguous tensor. + // Collect in the backend's native dtype (f32 or f16) to preserve + // precision when rebuilding the tensor. + let reference_data: Vec = full_log_probs + .clone() + .slice([warmup..t_full, 0..n, 0..c]) + .into_data() + .iter::() + .collect(); + let reference = + TestTensor::<3>::from(burn_tensor::TensorData::new(reference_data, [t_eff, n, c])); + + let targets = TestTensorInt::<2>::from([[1, 2]]); + let input_lengths = TestTensorInt::<1>::from([t_eff as i64]); + let target_lengths = TestTensorInt::<1>::from([2]); + + let loss_narrowed = ctc_loss( + narrowed, + targets.clone(), + input_lengths.clone(), + target_lengths.clone(), + 0, + ); + let loss_reference = ctc_loss(reference, targets, input_lengths, target_lengths, 0); + + let v_narrowed: Vec = loss_narrowed.into_data().iter::().collect(); + let v_reference: Vec = loss_reference.into_data().iter::().collect(); + + assert!( + (v_narrowed[0] - v_reference[0]).abs() < 1e-3, + "narrowed loss ({}) diverges from contiguous reference ({})", + v_narrowed[0], + v_reference[0], + ); +} + +/// Training pipelines typically produce log_probs with a `[B, T, C] -> +/// swap_dims(0, 1) -> [T, B, C] -> narrow(0, warmup, ...)` chain before +/// handing the tensor to CTC. `swap_dims` makes strides non-monotonic, +/// `narrow` introduces a non-zero base offset, and on fused backends the +/// combination has historically produced garbage from manual stride +/// indexing. This guards against that regression with a shape-compatible +/// fixture (B=2 so swap_dims actually permutes strides non-trivially). +#[test] +fn test_ctc_loss_swap_dims_then_narrow() { + let b: usize = 2; + let t_full: usize = 8; + let warmup: usize = 2; + let t_eff: usize = t_full - warmup; + let c: usize = 4; + + // Build logits in [B, T, C] layout (as a typical model output would). + let mut data = Vec::with_capacity(b * t_full * c); + for bi in 0..b { + for ti in 0..t_full { + for ci in 0..c { + data.push(((bi * 31 + ti * 7 + ci * 3) as f32 * 0.1).sin()); + } + } + } + let logits_btc = TestTensor::<3>::from(burn_tensor::TensorData::new(data, [b, t_full, c])); + let log_probs_btc = log_softmax(logits_btc, 2); + + // Permute to [T, B, C] (non-monotonic strides) then narrow away warmup. + let log_probs_tbc = log_probs_btc.clone().swap_dims(0, 1); + let narrowed = log_probs_tbc.narrow(0, warmup, t_eff); + + // Reference: materialize the same slice as a contiguous [T, B, C] tensor + // in the backend's native dtype. + let reference_data: Vec = log_probs_btc + .swap_dims(0, 1) + .slice([warmup..t_full, 0..b, 0..c]) + .into_data() + .iter::() + .collect(); + let reference = + TestTensor::<3>::from(burn_tensor::TensorData::new(reference_data, [t_eff, b, c])); + + let targets = TestTensorInt::<2>::from([[1_i64, 2], [2, 3]]); + let input_lengths = TestTensorInt::<1>::from([t_eff as i64, t_eff as i64]); + let target_lengths = TestTensorInt::<1>::from([2_i64, 2]); + + let loss_narrowed = ctc_loss( + narrowed, + targets.clone(), + input_lengths.clone(), + target_lengths.clone(), + 0, + ); + let loss_reference = ctc_loss(reference, targets, input_lengths, target_lengths, 0); + + let v_narrowed: Vec = loss_narrowed.into_data().iter::().collect(); + let v_reference: Vec = loss_reference.into_data().iter::().collect(); + + for i in 0..b { + assert!( + (v_narrowed[i] - v_reference[i]).abs() < 1e-3, + "sample {i}: swap_dims+narrow loss ({}) diverges from contiguous reference ({})", + v_narrowed[i], + v_reference[i], + ); + } +} + +#[test] +fn test_ctc_loss_matches_pytorch() { + // T=5, N=3, C=4, deterministic logits via sin((t*7 + n*13 + c*3) * 0.1). + // Expected per-sample losses computed by PyTorch's nn.functional.ctc_loss. + let t_size: usize = 5; + let n_size: usize = 3; + let c_size: usize = 4; + + let mut data = Vec::with_capacity(t_size * n_size * c_size); + for t in 0..t_size { + for n in 0..n_size { + for c in 0..c_size { + data.push(((t * 7 + n * 13 + c * 3) as f32 * 0.1).sin()); + } + } + } + let logits = + TestTensor::<3>::from(burn_tensor::TensorData::new(data, [t_size, n_size, c_size])); + let log_probs = log_softmax(logits, 2); + + let targets = TestTensorInt::<2>::from([[1, 2, 0], [1, 0, 0], [3, 2, 1]]); + let input_lengths = TestTensorInt::<1>::from([5, 5, 5]); + let target_lengths = TestTensorInt::<1>::from([2, 1, 3]); + + let loss = ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + + let expected = burn_tensor::TensorData::from([ + 3.5236570835113525f32, + 3.495313882827759, + 4.262677192687988, + ]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-3, 1e-3)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/deform_conv2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/deform_conv2d.rs new file mode 100644 index 0000000..e32a1c9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/deform_conv2d.rs @@ -0,0 +1,438 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::module::deform_conv2d; +use burn_tensor::ops::DeformConvOptions; +use burn_tensor::{Shape, Tensor}; + +#[test] +fn test_deform_conv2d_simple() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 5, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[0.9074, 0.6387], [0.5160, 0.4196]], + [[2.4259, 1.8008], [1.5449, 1.3112]], + [[3.9444, 2.9629], [2.5738, 2.2027]], + [[5.4629, 4.1250], [3.6027, 3.0943]], + [[6.9814, 5.2871], [4.6316, 3.9859]], + ]])); +} + +#[test] +fn test_deform_conv2d_batched() { + let test = DeformConv2dTestCase { + batch_size: 2, + channels_in: 3, + channels_out: 5, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([ + [ + [[0.215466, 0.192846], [0.193407, 0.175496]], + [[0.725073, 0.675926], [0.687746, 0.648506]], + [[1.234679, 1.159006], [1.182085, 1.121516]], + [[1.744286, 1.642086], [1.676423, 1.594526]], + [[2.253892, 2.125167], [2.170762, 2.067536]], + ], + [ + [[1.652976, 1.136937], [0.984030, 0.718403]], + [[4.836801, 3.472453], [3.177263, 2.418021]], + [[8.020626, 5.807969], [5.370497, 4.117639]], + [[11.204453, 8.143486], [7.563731, 5.817256]], + [[14.388277, 10.479003], [9.756965, 7.516875]], + ], + ])) +} + +#[test] +fn test_deform_conv2d_weight_groups() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 6, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 3, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[0.101823, 0.065756], [0.046691, 0.036233]], + [[0.412523, 0.336674], [0.306863, 0.282386]], + [[1.307585, 1.024152], [0.902454, 0.800008]], + [[1.840507, 1.458072], [1.299371, 1.158781]], + [[3.402235, 2.634555], [2.305198, 2.014265]], + [[4.157379, 3.231476], [2.838861, 2.485659]], + ]])) +} + +#[test] +fn test_deform_conv2d_offset_groups() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 3, + channels_out: 6, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 3, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[1.0794, 0.7676], [0.7209, 0.5337]], + [[2.7059, 2.0216], [1.9740, 1.5419]], + [[4.3325, 3.2755], [3.2271, 2.5501]], + [[5.9590, 4.5295], [4.4802, 3.5582]], + [[7.5855, 5.7835], [5.7333, 4.5664]], + [[9.2120, 7.0375], [6.9864, 5.5746]], + ]])) +} + +#[test] +fn test_deform_conv2d_different_kernel_size() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 4, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[1.0669], [0.6329]], + [[2.9741], [2.0383]], + [[4.8812], [3.4437]], + ]])) +} + +#[test] +fn test_deform_conv2d_different_padding_size() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 2, + padding_2: 3, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [ + [ + 0.199779, 0.376176, 0.528501, 0.605256, 0.384365, 0.198675, 0.048145, 0.000000, + ], + [ + 0.287923, 0.551719, 0.777562, 0.890479, 0.580469, 0.304325, 0.079554, 0.000000, + ], + [ + 0.372947, 0.721405, 1.013668, 1.151988, 0.756444, 0.393098, 0.101582, 0.000000, + ], + [ + 0.132138, 0.324872, 0.495372, 0.584617, 0.453122, 0.250084, 0.075703, 0.000000, + ], + [ + 0.059332, 0.160658, 0.244789, 0.297057, 0.239464, 0.132701, 0.047114, 0.000000, + ], + [ + 0.014338, 0.051338, 0.078303, 0.094190, 0.081278, 0.041954, 0.014506, 0.000000, + ], + ], + [ + [ + 0.766652, 1.164805, 1.521938, 1.711110, 1.230500, 0.807579, 0.450423, 0.333333, + ], + [ + 0.981162, 1.601005, 2.152534, 2.440920, 1.745547, 1.091843, 0.536749, 0.333333, + ], + [ + 1.196386, 2.044845, 2.785330, 3.152243, 2.242613, 1.351308, 0.604905, 0.333333, + ], + [ + 0.669465, 1.178133, 1.644096, 1.902188, 1.573183, 1.033924, 0.553577, 0.333333, + ], + [ + 0.495048, 0.786124, 1.039796, 1.204721, 1.052342, 0.743887, 0.483380, 0.333333, + ], + [ + 0.378767, 0.498209, 0.592867, 0.654230, 0.615487, 0.488202, 0.390890, 0.333333, + ], + ], + [ + [ + 1.333524, 1.953435, 2.515375, 2.816964, 2.076636, 1.416483, 0.852701, 0.666667, + ], + [ + 1.674402, 2.650291, 3.527507, 3.991360, 2.910625, 1.879361, 0.993943, 0.666667, + ], + [ + 2.019825, 3.368286, 4.556992, 5.152499, 3.728782, 2.309520, 1.108229, 0.666667, + ], + [ + 1.206791, 2.031395, 2.792820, 3.219759, 2.693245, 1.817763, 1.031452, 0.666667, + ], + [ + 0.930765, 1.411590, 1.834802, 2.112385, 1.865221, 1.355072, 0.919646, 0.666667, + ], + [ + 0.743195, 0.945081, 1.107431, 1.214270, 1.149695, 0.934451, 0.767274, 0.666667, + ], + ], + ]])) +} + +#[test] +fn test_deform_conv2d_different_stride() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 2, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[1.0647], [0.5783]], + [[2.9289], [1.8829]], + [[4.7931], [3.1875]], + ]])) +} + +#[test] +fn test_deform_conv2d_different_dilation() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 2, + weight_groups: 1, + offset_groups: 1, + height: 5, + width: 5, + }; + + test.assert_output(TestTensor::<4>::from([[ + [[0.6162], [0.7611], [0.4666]], + [[1.8578], [2.2684], [1.6208]], + [[3.0994], [3.7757], [2.7749]], + ]])) +} + +#[test] +fn test_deform_conv2d_different_width() { + let test = DeformConv2dTestCase { + batch_size: 1, + channels_in: 2, + channels_out: 3, + kernel_size_1: 3, + kernel_size_2: 3, + padding_1: 0, + padding_2: 0, + stride_1: 1, + stride_2: 1, + dilation_1: 1, + dilation_2: 1, + weight_groups: 1, + offset_groups: 1, + height: 6, + width: 4, + }; + + test.assert_output(TestTensor::<4>::from([[ + [ + [0.8909, 0.6016], + [1.0697, 0.7186], + [1.2618, 0.8433], + [0.6424, 0.5032], + ], + [ + [2.4670, 1.8168], + [2.9529, 2.1497], + [3.4805, 2.5090], + [2.0925, 1.7411], + ], + [ + [4.0432, 3.0321], + [4.8362, 3.5809], + [5.6992, 4.1746], + [3.5425, 2.9790], + ], + ]])) +} + +struct DeformConv2dTestCase { + batch_size: usize, + channels_in: usize, + channels_out: usize, + kernel_size_1: usize, + kernel_size_2: usize, + padding_1: usize, + padding_2: usize, + stride_1: usize, + stride_2: usize, + dilation_1: usize, + dilation_2: usize, + weight_groups: usize, + offset_groups: usize, + height: usize, + width: usize, +} + +impl DeformConv2dTestCase { + fn assert_output(self, y: Tensor<4>) { + let out_height = + (self.height + 2 * self.padding_1 - self.dilation_1 * (self.kernel_size_1 - 1) - 1) + / self.stride_1 + + 1; + let out_width = + (self.width + 2 * self.padding_2 - self.dilation_2 * (self.kernel_size_2 - 1) - 1) + / self.stride_2 + + 1; + + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let shape_weight = Shape::new([ + self.channels_out, + self.channels_in / self.weight_groups, + self.kernel_size_1, + self.kernel_size_2, + ]); + let shape_offset = Shape::new([ + self.batch_size, + self.kernel_size_1 * self.kernel_size_2 * self.offset_groups * 2, + out_height, + out_width, + ]); + let shape_mask = Shape::new([ + self.batch_size, + self.kernel_size_1 * self.kernel_size_2 * self.offset_groups, + out_height, + out_width, + ]); + let device = Default::default(); + let weight = TestTensor::<4>::from( + TestTensorInt::arange(0..shape_weight.num_elements() as i64, &device) + .reshape::<4, _>(shape_weight.clone()) + .into_data(), + ) + .div_scalar(shape_weight.num_elements() as f32); + let bias = TestTensor::<1>::from( + TestTensorInt::arange(0..self.channels_out as i64, &device).into_data(), + ) + .div_scalar(self.channels_out as f32); + let x = TestTensor::<4>::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &device) + .reshape::<4, _>(shape_x.clone()) + .into_data(), + ) + .div_scalar(shape_x.num_elements() as f32); + let offset = TestTensor::<4>::from( + TestTensorInt::arange(0..shape_offset.num_elements() as i64, &device) + .reshape::<4, _>(shape_offset.clone()) + .into_data(), + ) + .div_scalar(shape_offset.num_elements() as f32); + let mask = TestTensor::<4>::from( + TestTensorInt::arange(0..shape_mask.num_elements() as i64, &device) + .reshape::<4, _>(shape_mask.clone()) + .into_data(), + ) + .div_scalar(shape_mask.num_elements() as f32); + + let output = deform_conv2d( + x, + offset, + weight, + Some(mask), + Some(bias), + DeformConvOptions::new( + [self.stride_1, self.stride_2], + [self.padding_1, self.padding_2], + [self.dilation_1, self.dilation_2], + self.weight_groups, + self.offset_groups, + ), + ); + + let tolerance = Tolerance::permissive(); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/forward.rs b/crates/burn-backend-tests/tests/tensor/float/module/forward.rs new file mode 100644 index 0000000..5a9d097 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/forward.rs @@ -0,0 +1,18 @@ +use super::*; +use burn_tensor::{TensorData, module::embedding}; + +#[test] +fn test_embedding_forward() { + let weights = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TensorData::from([[0, 1], [1, 1]]); + let weights = TestTensor::<2>::from(weights); + let indices = TestTensorInt::<2>::from(indices); + + let output = embedding(weights, indices); + let expected = TensorData::from([ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[3.0, 4.0, 5.0], [3.0, 4.0, 5.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/lanczos3_interpolate.rs b/crates/burn-backend-tests/tests/tensor/float/module/lanczos3_interpolate.rs new file mode 100644 index 0000000..cd1503b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/lanczos3_interpolate.rs @@ -0,0 +1,235 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::interpolate; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; + +#[test] +fn test_upsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 2, + channels: 1, + height: 7, + width: 5, + height_out: 8, + width_out: 7, + }; + + test.assert_output(TestTensor::from([ + [[ + [-0.0000, 0.5685, 1.3918, 2.0000, 2.6082, 3.4315, 4.0000], + [4.0822, 4.6507, 5.4740, 6.0822, 6.6904, 7.5137, 8.0822], + [8.7971, 9.3656, 10.1889, 10.7971, 11.4053, 12.2286, 12.7971], + [ + 12.8964, 13.4649, 14.2882, 14.8964, 15.5046, 16.3279, 16.8964, + ], + [ + 17.1036, 17.6721, 18.4954, 19.1036, 19.7118, 20.5351, 21.1036, + ], + [ + 21.2029, 21.7715, 22.5947, 23.2029, 23.8112, 24.6344, 25.2029, + ], + [ + 25.9178, 26.4863, 27.3096, 27.9178, 28.5260, 29.3493, 29.9178, + ], + [ + 30.0000, 30.5685, 31.3918, 32.0000, 32.6082, 33.4315, 34.0000, + ], + ]], + [[ + [ + 35.0000, 35.5685, 36.3918, 37.0000, 37.6082, 38.4315, 39.0000, + ], + [ + 39.0822, 39.6507, 40.4740, 41.0822, 41.6904, 42.5137, 43.0822, + ], + [ + 43.7971, 44.3656, 45.1888, 45.7971, 46.4053, 47.2286, 47.7971, + ], + [ + 47.8964, 48.4649, 49.2882, 49.8964, 50.5046, 51.3279, 51.8964, + ], + [ + 52.1036, 52.6721, 53.4954, 54.1036, 54.7118, 55.5351, 56.1036, + ], + [ + 56.2029, 56.7715, 57.5947, 58.2029, 58.8112, 59.6344, 60.2029, + ], + [ + 60.9178, 61.4863, 62.3096, 62.9178, 63.5260, 64.3493, 64.9178, + ], + [ + 65.0000, 65.5685, 66.3918, 67.0000, 67.6082, 68.4315, 69.0000, + ], + ]], + ])); +} + +#[test] +fn test_downsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 45, + width: 14, + height_out: 4, + width_out: 6, + }; + + test.assert_output(TestTensor::from([[[ + [-0.0000, 2.6107, 5.1803, 7.8197, 10.3893, 13.0000], + [205.5606, 208.1713, 210.7408, 213.3802, 215.9498, 218.5606], + [410.4395, 413.0502, 415.6198, 418.2592, 420.8287, 423.4395], + [616.0000, 618.6107, 621.1803, 623.8197, 626.3893, 629.0000], + ]]])); +} + +#[test] +fn test_upsample_2x() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 4, + width: 4, + height_out: 8, + width_out: 8, + }; + + test.assert_output(TestTensor::from([[[ + [ + -0.0000, 0.2972, 0.8164, 1.3131, 1.6869, 2.1836, 2.7028, 3.0000, + ], + [ + 1.1889, 1.4861, 2.0053, 2.5020, 2.8758, 3.3725, 3.8917, 4.1889, + ], + [ + 3.2658, 3.5630, 4.0822, 4.5789, 4.9527, 5.4493, 5.9685, 6.2658, + ], + [ + 5.2524, 5.5496, 6.0689, 6.5655, 6.9393, 7.4360, 7.9552, 8.2524, + ], + [ + 6.7476, 7.0448, 7.5640, 8.0607, 8.4345, 8.9311, 9.4504, 9.7476, + ], + [ + 8.7342, 9.0315, 9.5507, 10.0473, 10.4211, 10.9178, 11.4370, 11.7342, + ], + [ + 10.8111, 11.1083, 11.6275, 12.1242, 12.4980, 12.9947, 13.5139, 13.8111, + ], + [ + 12.0000, 12.2972, 12.8164, 13.3131, 13.6869, 14.1836, 14.7028, 15.0000, + ], + ]]])); +} + +#[test] +fn test_upsample_half_pixel() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 4, + width: 4, + height_out: 8, + width_out: 8, + }; + + test.assert_output_with_align_corners( + TestTensor::from([[[ + [ + -0.4626, -0.2276, 0.3055, 0.9087, 1.3512, 1.9543, 2.4875, 2.7225, + ], + [ + 0.4773, 0.7123, 1.2454, 1.8486, 2.2911, 2.8942, 3.4274, 3.6623, + ], + [ + 2.6099, 2.8449, 3.3780, 3.9812, 4.4237, 5.0268, 5.5600, 5.7949, + ], + [ + 5.0224, 5.2574, 5.7906, 6.3937, 6.8362, 7.4394, 7.9725, 8.2075, + ], + [ + 6.7925, 7.0275, 7.5606, 8.1638, 8.6063, 9.2094, 9.7426, 9.9776, + ], + [ + 9.2051, 9.4400, 9.9732, 10.5763, 11.0188, 11.6220, 12.1551, 12.3901, + ], + [ + 11.3377, 11.5726, 12.1058, 12.7089, 13.1514, 13.7546, 14.2877, 14.5227, + ], + [ + 12.2775, 12.5125, 13.0457, 13.6488, 14.0913, 14.6945, 15.2276, 15.4626, + ], + ]]]), + false, + ); +} + +#[test] +fn test_1d_lanczos3() { + let device = Default::default(); + + let input = TestTensor::<3>::from_data( + [[[1.5410, -0.2934, -2.1788, 0.5684, -1.0845, -1.3986]]], + &device, + ); + + let input = input.unsqueeze_dim(2); + + let output = interpolate( + input, + [1, 9], + InterpolateOptions::new(InterpolateMode::Lanczos3), + ); + assert_eq!(output.dims(), [1, 1, 1, 9]); + + assert!( + !output + .clone() + .to_data() + .as_slice::() + .unwrap() + .iter() + .any(|&x| x.is_nan()), + "interpolate output contains NaN" + ); + + TestTensor::<4>::from([[[[ + 1.5410, 0.7266, -1.1387, -2.2672, -0.7894, 0.6408, -0.4967, -1.4650, -1.3986, + ]]]]) + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::permissive()); +} + +struct InterpolateTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl InterpolateTestCase { + fn assert_output(self, y: TestTensor<4>) { + self.assert_output_with_align_corners(y, true); + } + + fn assert_output_with_align_corners(self, y: TestTensor<4>, align_corners: bool) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + let output = interpolate( + x, + [self.height_out, self.width_out], + InterpolateOptions::new(InterpolateMode::Lanczos3).with_align_corners(align_corners), + ); + + let tolerance = Tolerance::permissive(); + y.to_data() + .assert_approx_eq::(&output.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/linear.rs b/crates/burn-backend-tests/tests/tensor/float/module/linear.rs new file mode 100644 index 0000000..07f06dc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/linear.rs @@ -0,0 +1,59 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::module::linear; + +#[test] +fn test_linear_1d() { + let weight = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let x = TestTensor::<1>::from([1.0, 2.0]); + let output = linear(x, weight, None); + + let expected = TensorData::from([7.0, 10.0]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(1e-5)); +} + +#[test] +fn test_linear_1d_one_element_output() { + let weight = TestTensor::<2>::from([[3.0], [4.0]]); + + let x = TestTensor::<1>::from([1.0, 2.0]); + let output = linear(x, weight, None); + + let expected = TensorData::from([11.0]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(1e-5)); +} + +#[test] +fn test_linear_forward_no_bias() { + let weight = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let x = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]], [[-1.0, -2.0], [-3.0, -4.0]]]); + + let output = linear(x, weight, None); + + let expected = TensorData::from([[[7.0, 10.0], [15.0, 22.0]], [[-7.0, -10.0], [-15.0, -22.0]]]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(1e-5)); +} + +#[test] +fn test_linear_forward_with_bias() { + let weight = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + let bias = Some(TestTensor::<1>::from([1.0, -1.0])); + + let x = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]], [[-1.0, -2.0], [-3.0, -4.0]]]); + + let output = linear(x, weight, bias); + + let expected = TensorData::from([[[8.0, 9.0], [16.0, 21.0]], [[-6.0, -11.0], [-14.0, -23.0]]]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(1e-5)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/maxpool1d.rs b/crates/burn-backend-tests/tests/tensor/float/module/maxpool1d.rs new file mode 100644 index 0000000..fdfee13 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/maxpool1d.rs @@ -0,0 +1,155 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::module::{max_pool1d, max_pool1d_with_indices}; + +#[test] +fn test_max_pool1d_simple() { + let kernel_size = 3; + let padding = 0; + let stride = 1; + let dilation = 1; + + let x = TestTensor::from([[ + [0.9861, 0.5474, 0.4477, 0.0732, 0.3548, 0.8221], + [0.8148, 0.5474, 0.9490, 0.7890, 0.5537, 0.5689], + ]]); + let y = TestTensor::<3>::from([[ + [0.9861, 0.5474, 0.4477, 0.8221], + [0.949, 0.949, 0.949, 0.789], + ]]); + + let output = max_pool1d(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_different_padding_stride_kernel() { + let kernel_size = 3; + let padding = 1; + let stride = 2; + let dilation = 1; + + let x = TestTensor::from([[[0.6309, 0.6112, 0.6998, 0.4708]]]); + let y = TestTensor::<3>::from([[[0.6309, 0.6998]]]); + + let output = max_pool1d(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_with_neg() { + let kernel_size = 3; + let padding = 1; + let stride = 1; + let dilation = 1; + + let x = TestTensor::from([[[-0.6309, -0.6112, -0.6998, -0.4708]]]); + let y = TestTensor::<3>::from([[[-0.6112, -0.6112, -0.4708, -0.4708]]]); + + let output = max_pool1d(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_with_dilation() { + let kernel_size = 2; + let padding = 1; + let stride = 1; + let dilation = 2; + + let x = TestTensor::from([[ + [0.9861, 0.5474, 0.4477, 0.0732, 0.3548, 0.8221], + [0.8148, 0.5474, 0.9490, 0.7890, 0.5537, 0.5689], + ]]); + let y = TestTensor::<3>::from([[ + [0.5474, 0.9861, 0.5474, 0.4477, 0.8221, 0.3548], + [0.5474, 0.9490, 0.7890, 0.9490, 0.7890, 0.5537], + ]]); + + let output = max_pool1d(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool1d_with_indices() { + let kernel_size = 2; + let padding = 0; + let stride = 1; + let dilation = 1; + + let x = TestTensor::from([[[0.2479, 0.6386, 0.3166, 0.5742]]]); + let indices = TensorData::from([[[1, 1, 3]]]); + let y = TestTensor::<3>::from([[[0.6386, 0.6386, 0.5742]]]); + + let (output, output_indices) = + max_pool1d_with_indices(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices.into_data().assert_eq(&indices, false); +} + +#[test] +fn test_max_pool1d_complex() { + let kernel_size = 4; + let padding = 2; + let stride = 1; + let dilation = 1; + + let x = TestTensor::from([[[0.5388, 0.0676, 0.7122, 0.8316, 0.0653]]]); + let indices = TensorData::from([[[0, 2, 3, 3, 3, 3]]]); + let y = TestTensor::<3>::from([[[0.5388, 0.7122, 0.8316, 0.8316, 0.8316, 0.8316]]]); + + let (output, output_indices) = + max_pool1d_with_indices(x, kernel_size, stride, padding, dilation, false); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices.into_data().assert_eq(&indices, false); +} + +#[test] +fn test_max_pool1d_ceil_mode() { + // Test ceil_mode=true produces larger output when input doesn't divide evenly by stride + // Input: 1x1x6, kernel: 3, stride: 2, padding: 0 + // Floor mode: output = (6-3)/2+1 = 2 elements + // Ceil mode: output = ceil((6-3)/2)+1 = ceil(1.5)+1 = 3 elements + let kernel_size = 3; + let padding = 0; + let stride = 2; + let dilation = 1; + + let x = TestTensor::from([[[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]]]); + + // With ceil_mode=false (floor): output is 2 elements + // Window 0: positions [0:3] -> max(1,2,3) = 3 + // Window 1: positions [2:5] -> max(3,4,5) = 5 + let y_floor = TestTensor::<3>::from([[[3.0, 5.0]]]); + + let output_floor = max_pool1d(x.clone(), kernel_size, stride, padding, dilation, false); + + y_floor + .to_data() + .assert_approx_eq::(&output_floor.into_data(), Tolerance::default()); + + // With ceil_mode=true: output is 3 elements + // Window 0: positions [0:3] -> max(1,2,3) = 3 + // Window 1: positions [2:5] -> max(3,4,5) = 5 + // Window 2: positions [4:7] -> max(5,6) = 6 (partial window) + let y_ceil = TestTensor::<3>::from([[[3.0, 5.0, 6.0]]]); + + let output_ceil = max_pool1d(x, kernel_size, stride, padding, dilation, true); + + y_ceil + .to_data() + .assert_approx_eq::(&output_ceil.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/maxpool2d.rs b/crates/burn-backend-tests/tests/tensor/float/module/maxpool2d.rs new file mode 100644 index 0000000..325d5bb --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/maxpool2d.rs @@ -0,0 +1,523 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::module::{max_pool2d, max_pool2d_with_indices}; + +#[test] +fn test_max_pool2d_simple() { + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 1; + let dilation_2 = 1; + + let x = TestTensor::from([ + [ + [ + [0.9861, 0.5474, 0.4477, 0.0732, 0.3548, 0.8221], + [0.8148, 0.5474, 0.9490, 0.7890, 0.5537, 0.5689], + [0.5986, 0.2059, 0.4897, 0.6136, 0.2965, 0.6182], + [0.1485, 0.9540, 0.4023, 0.6176, 0.7111, 0.3392], + [0.3703, 0.0472, 0.2771, 0.1868, 0.8855, 0.5605], + [0.5063, 0.1638, 0.9432, 0.7836, 0.8696, 0.1068], + ], + [ + [0.8872, 0.0137, 0.1652, 0.5505, 0.6127, 0.6473], + [0.1128, 0.0888, 0.1152, 0.5456, 0.6199, 0.7947], + [0.5911, 0.7781, 0.7256, 0.6578, 0.0989, 0.9149], + [0.5879, 0.5189, 0.6561, 0.0578, 0.7025, 0.6426], + [0.9590, 0.0325, 0.6455, 0.6248, 0.2009, 0.1544], + [0.7339, 0.1369, 0.6598, 0.5528, 0.6775, 0.1572], + ], + ], + [ + [ + [0.6853, 0.6439, 0.4639, 0.5573, 0.2723, 0.5910], + [0.5419, 0.7729, 0.6743, 0.8956, 0.2997, 0.9546], + [0.0334, 0.2178, 0.6917, 0.4958, 0.3357, 0.6584], + [0.7358, 0.9074, 0.2462, 0.5159, 0.6420, 0.2441], + [0.7602, 0.6297, 0.6073, 0.5937, 0.8037, 0.4881], + [0.8859, 0.0974, 0.3954, 0.6763, 0.1078, 0.7467], + ], + [ + [0.2991, 0.5012, 0.8024, 0.7653, 0.9378, 0.7952], + [0.7393, 0.2336, 0.9521, 0.2719, 0.8445, 0.0454], + [0.6479, 0.9822, 0.7905, 0.0318, 0.2474, 0.0628], + [0.9955, 0.7591, 0.4140, 0.3215, 0.4349, 0.1527], + [0.8064, 0.0164, 0.4002, 0.2024, 0.6128, 0.5827], + [0.5368, 0.7895, 0.8727, 0.7793, 0.0910, 0.3421], + ], + ], + ]); + let y = TestTensor::<4>::from([ + [ + [ + [0.9861, 0.9861, 0.9490, 0.9490, 0.8221, 0.8221], + [0.9861, 0.9861, 0.9490, 0.9490, 0.8221, 0.8221], + [0.9540, 0.9540, 0.9540, 0.9490, 0.7890, 0.7111], + [0.9540, 0.9540, 0.9540, 0.8855, 0.8855, 0.8855], + [0.9540, 0.9540, 0.9540, 0.9432, 0.8855, 0.8855], + [0.5063, 0.9432, 0.9432, 0.9432, 0.8855, 0.8855], + ], + [ + [0.8872, 0.8872, 0.5505, 0.6199, 0.7947, 0.7947], + [0.8872, 0.8872, 0.7781, 0.7256, 0.9149, 0.9149], + [0.7781, 0.7781, 0.7781, 0.7256, 0.9149, 0.9149], + [0.9590, 0.9590, 0.7781, 0.7256, 0.9149, 0.9149], + [0.9590, 0.9590, 0.6598, 0.7025, 0.7025, 0.7025], + [0.9590, 0.9590, 0.6598, 0.6775, 0.6775, 0.6775], + ], + ], + [ + [ + [0.7729, 0.7729, 0.8956, 0.8956, 0.9546, 0.9546], + [0.7729, 0.7729, 0.8956, 0.8956, 0.9546, 0.9546], + [0.9074, 0.9074, 0.9074, 0.8956, 0.9546, 0.9546], + [0.9074, 0.9074, 0.9074, 0.8037, 0.8037, 0.8037], + [0.9074, 0.9074, 0.9074, 0.8037, 0.8037, 0.8037], + [0.8859, 0.8859, 0.6763, 0.8037, 0.8037, 0.8037], + ], + [ + [0.7393, 0.9521, 0.9521, 0.9521, 0.9378, 0.9378], + [0.9822, 0.9822, 0.9822, 0.9521, 0.9378, 0.9378], + [0.9955, 0.9955, 0.9822, 0.9521, 0.8445, 0.8445], + [0.9955, 0.9955, 0.9822, 0.7905, 0.6128, 0.6128], + [0.9955, 0.9955, 0.8727, 0.8727, 0.7793, 0.6128], + [0.8064, 0.8727, 0.8727, 0.8727, 0.7793, 0.6128], + ], + ], + ]); + + let output = max_pool2d( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_different_padding_stride_kernel() { + let kernel_size_1 = 3; + let kernel_size_2 = 1; + let padding_1 = 1; + let padding_2 = 0; + let stride_1 = 1; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + let x = TestTensor::from([[[ + [0.6309, 0.6112, 0.6998], + [0.4708, 0.9161, 0.5402], + [0.4577, 0.7397, 0.9870], + [0.6380, 0.4352, 0.5884], + [0.6277, 0.5139, 0.4525], + [0.9333, 0.9846, 0.5006], + ]]]); + let y = TestTensor::<4>::from([[[ + [0.6309, 0.6998], + [0.6309, 0.9870], + [0.6380, 0.9870], + [0.6380, 0.9870], + [0.9333, 0.5884], + [0.9333, 0.5006], + ]]]); + + let output = max_pool2d( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_with_neg() { + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 1; + let dilation_2 = 1; + + let x = TestTensor::from([[[ + [0.6309, 0.6112, 0.6998], + [0.4708, 0.9161, 0.5402], + [0.4577, 0.7397, 0.9870], + [0.6380, 0.4352, 0.5884], + [0.6277, 0.5139, 0.4525], + [0.9333, 0.9846, 0.5006], + ]]]) + .neg(); + let y = TestTensor::<4>::from([[[ + [-0.4708, -0.4708, -0.5402], + [-0.4577, -0.4577, -0.5402], + [-0.4352, -0.4352, -0.4352], + [-0.4352, -0.4352, -0.4352], + [-0.4352, -0.4352, -0.4352], + [-0.5139, -0.4525, -0.4525], + ]]]); + + let output = max_pool2d( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_with_dilation() { + let kernel_size_1 = 2; + let kernel_size_2 = 2; + let padding_1 = 0; + let padding_2 = 0; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 2; + let dilation_2 = 2; + + let x = TestTensor::from([[[ + [0.9861, 0.9861, 0.9490, 0.9490, 0.8221, 0.8221], + [0.9861, 0.9861, 0.9490, 0.9490, 0.8221, 0.8221], + [0.9540, 0.9540, 0.9540, 0.9490, 0.7890, 0.7111], + [0.9540, 0.9540, 0.9540, 0.8855, 0.8855, 0.8855], + [0.9540, 0.9540, 0.9540, 0.9432, 0.8855, 0.8855], + [0.5063, 0.9432, 0.9432, 0.9432, 0.8855, 0.8855], + ]]]); + let y = TestTensor::<4>::from([[[ + [0.9861, 0.9861, 0.9540, 0.9490], + [0.9861, 0.9861, 0.9540, 0.9490], + [0.9540, 0.9540, 0.9540, 0.9490], + [0.9540, 0.9540, 0.9540, 0.9432], + ]]]); + + let output = max_pool2d( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_with_indices() { + let kernel_size_1 = 2; + let kernel_size_2 = 2; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 1; + let dilation_1 = 1; + let dilation_2 = 1; + + let x = TestTensor::from([[[ + [0.2479, 0.6386, 0.3166, 0.5742], + [0.7065, 0.1940, 0.6305, 0.8959], + [0.5416, 0.8602, 0.8129, 0.1662], + [0.3358, 0.3059, 0.8293, 0.0990], + ]]]); + let indices = TensorData::from([[[ + [0, 1, 1, 3, 3], + [4, 4, 1, 7, 7], + [4, 9, 9, 7, 7], + [8, 9, 9, 14, 11], + [12, 12, 14, 14, 15], + ]]]); + let y = TestTensor::<4>::from([[[ + [0.2479, 0.6386, 0.6386, 0.5742, 0.5742], + [0.7065, 0.7065, 0.6386, 0.8959, 0.8959], + [0.7065, 0.8602, 0.8602, 0.8959, 0.8959], + [0.5416, 0.8602, 0.8602, 0.8293, 0.1662], + [0.3358, 0.3358, 0.8293, 0.8293, 0.0990], + ]]]); + + let (output, output_indices) = max_pool2d_with_indices( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices.into_data().assert_eq(&indices, false); +} + +#[test] +fn test_max_pool2d_complex() { + let kernel_size_1 = 4; + let kernel_size_2 = 2; + let padding_1 = 2; + let padding_2 = 1; + let stride_1 = 1; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + let x = TestTensor::from([[[ + [0.5388, 0.0676, 0.7122, 0.8316, 0.0653], + [0.9154, 0.1536, 0.9089, 0.8016, 0.7518], + [0.2073, 0.0501, 0.8811, 0.5604, 0.5075], + [0.4384, 0.9963, 0.9698, 0.4988, 0.2609], + [0.3391, 0.2230, 0.4610, 0.5365, 0.6880], + ]]]); + let indices = TensorData::from([[[ + [5, 7, 3], + [5, 7, 3], + [5, 16, 3], + [5, 16, 8], + [15, 16, 24], + [15, 16, 24], + ]]]); + let y = TestTensor::<4>::from([[[ + [0.9154, 0.9089, 0.8316], + [0.9154, 0.9089, 0.8316], + [0.9154, 0.9963, 0.8316], + [0.9154, 0.9963, 0.8016], + [0.4384, 0.9963, 0.688], + [0.4384, 0.9963, 0.688], + ]]]); + let (output, output_indices) = max_pool2d_with_indices( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices.into_data().assert_eq(&indices, false); +} + +#[test] +fn test_max_pool2d_ceil_mode() { + // Test ceil_mode=true which produces larger output when input doesn't divide evenly by stride + // Using 1x1x6x6 with kernel 3x3, stride 2x2, padding 0: + // Floor mode: output = (6+0-1*(3-1)-1)/2+1 = 3/2+1 = 2 x 2 + // Ceil mode: output = ceil(3/2)+1 = 2+1 = 3 x 3 + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 0; + let padding_2 = 0; + let stride_1 = 2; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + // Input (values 1-36 arranged row by row): + // col: 0 1 2 3 4 5 + // row 0: 1 2 3 4 5 6 + // row 1: 7 8 9 10 11 12 + // row 2: 13 14 15 16 17 18 + // row 3: 19 20 21 22 23 24 + // row 4: 25 26 27 28 29 30 + // row 5: 31 32 33 34 35 36 + let x = TestTensor::from([[[ + [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + [7.0, 8.0, 9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0, 17.0, 18.0], + [19.0, 20.0, 21.0, 22.0, 23.0, 24.0], + [25.0, 26.0, 27.0, 28.0, 29.0, 30.0], + [31.0, 32.0, 33.0, 34.0, 35.0, 36.0], + ]]]); + + // With ceil_mode=false (floor): output is 2x2 + // (0,0): rows 0-2, cols 0-2 -> max(1,2,3,7,8,9,13,14,15) = 15 + // (0,1): rows 0-2, cols 2-4 -> max(3,4,5,9,10,11,15,16,17) = 17 + // (1,0): rows 2-4, cols 0-2 -> max(13,14,15,19,20,21,25,26,27) = 27 + // (1,1): rows 2-4, cols 2-4 -> max(15,16,17,21,22,23,27,28,29) = 29 + let y_floor = TestTensor::<4>::from([[[[15.0, 17.0], [27.0, 29.0]]]]); + + let output_floor = max_pool2d( + x.clone(), + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + false, + ); + + y_floor + .to_data() + .assert_approx_eq::(&output_floor.into_data(), Tolerance::default()); + + // With ceil_mode=true: output is 3x3 + // Extra windows at edges use only available input values (padded with -inf for max pooling) + // (0,0): rows 0-2, cols 0-2 -> max = 15 + // (0,1): rows 0-2, cols 2-4 -> max = 17 + // (0,2): rows 0-2, cols 4-5 -> max(5,6,11,12,17,18) = 18 + // (1,0): rows 2-4, cols 0-2 -> max = 27 + // (1,1): rows 2-4, cols 2-4 -> max = 29 + // (1,2): rows 2-4, cols 4-5 -> max(17,18,23,24,29,30) = 30 + // (2,0): rows 4-5, cols 0-2 -> max(25,26,27,31,32,33) = 33 + // (2,1): rows 4-5, cols 2-4 -> max(27,28,29,33,34,35) = 35 + // (2,2): rows 4-5, cols 4-5 -> max(29,30,35,36) = 36 + let y_ceil = + TestTensor::<4>::from([[[[15.0, 17.0, 18.0], [27.0, 29.0, 30.0], [33.0, 35.0, 36.0]]]]); + + let output_ceil = max_pool2d( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + true, + ); + + y_ceil + .to_data() + .assert_approx_eq::(&output_ceil.into_data(), Tolerance::default()); +} + +#[test] +fn test_max_pool2d_ceil_mode_with_indices() { + // Test ceil_mode=true with indices to verify correct index calculation + // when pooling windows extend beyond original input bounds + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 0; + let padding_2 = 0; + let stride_1 = 2; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + // Input 6x6 (indices 0-35 in row-major order): + // row 0: 0 1 2 3 4 5 + // row 1: 6 7 8 9 10 11 + // row 2: 12 13 14 15 16 17 + // row 3: 18 19 20 21 22 23 + // row 4: 24 25 26 27 28 29 + // row 5: 30 31 32 33 34 35 + let x = TestTensor::from([[[ + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + [6.0, 7.0, 8.0, 9.0, 10.0, 11.0], + [12.0, 13.0, 14.0, 15.0, 16.0, 17.0], + [18.0, 19.0, 20.0, 21.0, 22.0, 23.0], + [24.0, 25.0, 26.0, 27.0, 28.0, 29.0], + [30.0, 31.0, 32.0, 33.0, 34.0, 35.0], + ]]]); + + // With ceil_mode=true: output is 3x3 + // (0,0): rows 0-2, cols 0-2 -> max at index 14 + // (0,1): rows 0-2, cols 2-4 -> max at index 16 + // (0,2): rows 0-2, cols 4-5 -> max at index 17 + // (1,0): rows 2-4, cols 0-2 -> max at index 26 + // (1,1): rows 2-4, cols 2-4 -> max at index 28 + // (1,2): rows 2-4, cols 4-5 -> max at index 29 + // (2,0): rows 4-5, cols 0-2 -> max at index 32 + // (2,1): rows 4-5, cols 2-4 -> max at index 34 + // (2,2): rows 4-5, cols 4-5 -> max at index 35 + let expected_values = + TestTensor::<4>::from([[[[14.0, 16.0, 17.0], [26.0, 28.0, 29.0], [32.0, 34.0, 35.0]]]]); + let expected_indices = TensorData::from([[[[14i64, 16, 17], [26, 28, 29], [32, 34, 35]]]]); + + let (output, output_indices) = max_pool2d_with_indices( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + true, + ); + + expected_values + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices + .into_data() + .assert_eq(&expected_indices, false); +} + +#[test] +fn test_max_pool2d_ceil_mode_with_indices_and_padding() { + // Test ceil_mode=true with padding and indices to verify correct index calculation + // This exercises the case where both user padding and ceil_mode extra padding apply + let kernel_size_1 = 3; + let kernel_size_2 = 3; + let padding_1 = 1; + let padding_2 = 1; + let stride_1 = 2; + let stride_2 = 2; + let dilation_1 = 1; + let dilation_2 = 1; + + // Input 5x5 (indices 0-24 in row-major order): + // row 0: 0 1 2 3 4 + // row 1: 5 6 7 8 9 + // row 2: 10 11 12 13 14 + // row 3: 15 16 17 18 19 + // row 4: 20 21 22 23 24 + let x = TestTensor::from([[[ + [0.0, 1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0, 9.0], + [10.0, 11.0, 12.0, 13.0, 14.0], + [15.0, 16.0, 17.0, 18.0, 19.0], + [20.0, 21.0, 22.0, 23.0, 24.0], + ]]]); + + // With padding=1, ceil_mode=true: + // Effective input is 7x7 (5 + 2*1) + // Output size: ceil((5 + 2*1 - 3) / 2) + 1 = ceil(4/2) + 1 = 3 + // + // Windows (with -inf padding at boundaries): + // (0,0): rows -1 to 1, cols -1 to 1 -> valid: (0,0) to (1,1), max at (1,1)=6 + // (0,1): rows -1 to 1, cols 1 to 3 -> max at (1,3)=8 + // (0,2): rows -1 to 1, cols 3 to 5 -> max at (1,4)=9 + // (1,0): rows 1 to 3, cols -1 to 1 -> max at (3,1)=16 + // (1,1): rows 1 to 3, cols 1 to 3 -> max at (3,3)=18 + // (1,2): rows 1 to 3, cols 3 to 5 -> max at (3,4)=19 + // (2,0): rows 3 to 5, cols -1 to 1 -> max at (4,1)=21 + // (2,1): rows 3 to 5, cols 1 to 3 -> max at (4,3)=23 + // (2,2): rows 3 to 5, cols 3 to 5 -> max at (4,4)=24 + let expected_values = + TestTensor::<4>::from([[[[6.0, 8.0, 9.0], [16.0, 18.0, 19.0], [21.0, 23.0, 24.0]]]]); + let expected_indices = TensorData::from([[[[6i64, 8, 9], [16, 18, 19], [21, 23, 24]]]]); + + let (output, output_indices) = max_pool2d_with_indices( + x, + [kernel_size_1, kernel_size_2], + [stride_1, stride_2], + [padding_1, padding_2], + [dilation_1, dilation_2], + true, + ); + + expected_values + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + output_indices + .into_data() + .assert_eq(&expected_indices, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/mod.rs b/crates/burn-backend-tests/tests/tensor/float/module/mod.rs new file mode 100644 index 0000000..e2be3ea --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/mod.rs @@ -0,0 +1,24 @@ +use super::*; + +mod adaptive_avgpool1d; +mod adaptive_avgpool2d; +mod attention; +mod avgpool1d; +mod avgpool2d; +mod bicubic_interpolate; +mod bilinear_interpolate; +mod conv1d; +mod conv2d; +mod conv3d; +mod conv_transpose1d; +mod conv_transpose2d; +mod conv_transpose3d; +mod ctc; +mod deform_conv2d; +mod forward; +mod lanczos3_interpolate; +mod linear; +mod maxpool1d; +mod maxpool2d; +mod nearest_interpolate; +mod unfold4d; diff --git a/crates/burn-backend-tests/tests/tensor/float/module/nearest_interpolate.rs b/crates/burn-backend-tests/tests/tensor/float/module/nearest_interpolate.rs new file mode 100644 index 0000000..8b6ef32 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/nearest_interpolate.rs @@ -0,0 +1,127 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::interpolate; +use burn_tensor::ops::{InterpolateMode, InterpolateOptions}; + +#[test] +fn test_upsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 2, + channels: 1, + height: 7, + width: 5, + height_out: 8, + width_out: 7, + }; + + test.assert_output(TestTensor::from([ + [[ + [0., 0., 1., 2., 2., 3., 4.], + [0., 0., 1., 2., 2., 3., 4.], + [5., 5., 6., 7., 7., 8., 9.], + [10., 10., 11., 12., 12., 13., 14.], + [15., 15., 16., 17., 17., 18., 19.], + [20., 20., 21., 22., 22., 23., 24.], + [25., 25., 26., 27., 27., 28., 29.], + [30., 30., 31., 32., 32., 33., 34.], + ]], + [[ + [35., 35., 36., 37., 37., 38., 39.], + [35., 35., 36., 37., 37., 38., 39.], + [40., 40., 41., 42., 42., 43., 44.], + [45., 45., 46., 47., 47., 48., 49.], + [50., 50., 51., 52., 52., 53., 54.], + [55., 55., 56., 57., 57., 58., 59.], + [60., 60., 61., 62., 62., 63., 64.], + [65., 65., 66., 67., 67., 68., 69.], + ]], + ])); +} + +#[test] +fn test_downsample_interpolation() { + let test = InterpolateTestCase { + batch_size: 1, + channels: 1, + height: 45, + width: 14, + height_out: 4, + width_out: 6, + }; + + test.assert_output(TestTensor::from([[[ + [0., 2., 4., 7., 9., 11.], + [154., 156., 158., 161., 163., 165.], + [308., 310., 312., 315., 317., 319.], + [462., 464., 466., 469., 471., 473.], + ]]])); +} + +#[test] +fn test_1d_nearest() { + // Initialize the model without weights (because the exported file does not contain them) + let device = Default::default(); + + // Run the model + let input = TestTensor::<3>::from_data( + [[[1.5410, -0.2934, -2.1788, 0.5684, -1.0845, -1.3986]]], + &device, + ); + + let input = input.unsqueeze_dim(2); + + let output = interpolate( + input, + [1, 9], + InterpolateOptions::new(InterpolateMode::Nearest), + ); + assert_eq!(output.dims(), [1, 1, 1, 9]); + + // assert output data does not contain NaN + assert!( + !output + .clone() + .to_data() + .as_slice::() + .unwrap() + .iter() + .any(|&x| x.is_nan()), + "interpolate output contains NaN" + ); + + TestTensor::<4>::from([[[[ + 1.541, 1.541, -0.2934, -2.1788, -2.1788, 0.5684, -1.0845, -1.0845, -1.3986, + ]]]]) + .to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); +} + +struct InterpolateTestCase { + batch_size: usize, + channels: usize, + height: usize, + width: usize, + height_out: usize, + width_out: usize, +} + +impl InterpolateTestCase { + fn assert_output(self, y: TestTensor<4>) { + let shape_x = Shape::new([self.batch_size, self.channels, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &y.device()) + .reshape::<4, _>(shape_x) + .into_data() + .convert::(), + ); + let output = interpolate( + x, + [self.height_out, self.width_out], + InterpolateOptions::new(InterpolateMode::Nearest), + ); + + y.to_data() + .assert_approx_eq::(&output.into_data(), Tolerance::default()); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/module/unfold4d.rs b/crates/burn-backend-tests/tests/tensor/float/module/unfold4d.rs new file mode 100644 index 0000000..ed20837 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/module/unfold4d.rs @@ -0,0 +1,132 @@ +use super::*; +use burn_tensor::Shape; +use burn_tensor::Tolerance; +use burn_tensor::module::unfold4d; +use burn_tensor::ops::UnfoldOptions; + +#[test] +fn test_unfold4d_shape() { + let test = Unfold4dTestCase { + batch_size: 2, + channels_in: 5, + kernel_size: [2, 3], + padding: [0, 0], + stride: [1, 1], + dilation: [1, 1], + height: 3, + width: 4, + }; + + test.assert_shape([2, 30, 4]); +} + +#[test] +fn test_unfold4d_simple() { + let test = Unfold4dTestCase { + batch_size: 1, + channels_in: 2, + kernel_size: [2, 2], + padding: [0, 0], + stride: [1, 1], + dilation: [1, 1], + height: 4, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [0., 1., 2., 4., 5., 6., 8., 9., 10.], + [1., 2., 3., 5., 6., 7., 9., 10., 11.], + [4., 5., 6., 8., 9., 10., 12., 13., 14.], + [5., 6., 7., 9., 10., 11., 13., 14., 15.], + [16., 17., 18., 20., 21., 22., 24., 25., 26.], + [17., 18., 19., 21., 22., 23., 25., 26., 27.], + [20., 21., 22., 24., 25., 26., 28., 29., 30.], + [21., 22., 23., 25., 26., 27., 29., 30., 31.], + ]])); +} + +#[test] +fn test_unfold4d_complex() { + let test = Unfold4dTestCase { + batch_size: 1, + channels_in: 2, + kernel_size: [2, 3], + padding: [0, 1], + stride: [1, 2], + dilation: [1, 2], + height: 3, + width: 4, + }; + + test.assert_output(TestTensor::from([[ + [0., 0.], + [1., 5.], + [3., 7.], + [0., 0.], + [5., 9.], + [7., 11.], + [0., 0.], + [13., 17.], + [15., 19.], + [0., 0.], + [17., 21.], + [19., 23.], + ]])); +} + +struct Unfold4dTestCase { + batch_size: usize, + channels_in: usize, + kernel_size: [usize; 2], + padding: [usize; 2], + stride: [usize; 2], + dilation: [usize; 2], + height: usize, + width: usize, +} + +impl Unfold4dTestCase { + fn assert_shape(self, expected_shape: [usize; 3]) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &Default::default()) + .reshape::<4, _>(shape_x) + .into_data() + .convert::(), + ); + + let output = unfold4d( + x, + self.kernel_size, + UnfoldOptions::new(self.stride, self.padding, self.dilation), + ); + + assert_eq!( + output.shape().as_slice(), + expected_shape, + "Expected shape doesn't match the actual shape" + ); + } + + fn assert_output(self, expected: TestTensor<3>) { + let shape_x = Shape::new([self.batch_size, self.channels_in, self.height, self.width]); + let x = TestTensor::from( + TestTensorInt::arange(0..shape_x.num_elements() as i64, &Default::default()) + .reshape::<4, _>(shape_x) + .into_data(), + ); + + let output = unfold4d( + x, + self.kernel_size, + UnfoldOptions::new(self.stride, self.padding, self.dilation), + ); + + let tolerance = Tolerance::default() + .set_half_precision_relative(2e-3) + .set_half_precision_absolute(2e-3); + output + .into_data() + .assert_approx_eq::(&expected.into_data(), tolerance); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/abs.rs b/crates/burn-backend-tests/tests/tensor/float/ops/abs.rs new file mode 100644 index 0000000..ae72fa1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/abs.rs @@ -0,0 +1,25 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_abs_flipped() { + // [1, -2, 3, -4] flipped -> [-4, 3, -2, 1]; abs -> [4, 3, 2, 1] + let tensor = TestTensor::<1>::from([1.0, -2.0, 3.0, -4.0]); + let flipped = tensor.flip([0]); + + let output = flipped.abs(); + let expected = TensorData::from([4.0, 3.0, 2.0, 1.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_abs_ops_float() { + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, 4.0, -5.0]]); + + let output = tensor.abs(); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/add.rs b/crates/burn-backend-tests/tests/tensor/float/ops/add.rs new file mode 100644 index 0000000..a44e079 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/add.rs @@ -0,0 +1,151 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_add_d2() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::from([[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]]); + + let output = tensor_1 + tensor_2; + + output.into_data().assert_eq( + &TensorData::from([[6.0, 8.0, 10.0], [12.0, 14.0, 16.0]]), + false, + ); +} + +#[test] +fn test_add_broadcast() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0]]); + let tensor_2 = TestTensor::from([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + + let output = tensor_1 + tensor_2; + + output.into_data().assert_eq( + &TensorData::from([[3.0, 5.0, 7.0], [6.0, 8.0, 10.0]]), + false, + ); +} + +#[test] +fn test_add_different_strides_rhs() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = TestTensor::from([[4.0, 5.0], [6.0, 7.0]]) * 1; + + let output = tensor_1 + tensor_2.transpose(); + + output + .into_data() + .assert_eq(&TensorData::from([[4.0, 7.0], [7.0, 10.0]]), false); +} + +#[test] +fn test_add_different_strides_lhs() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = TestTensor::from([[4.0, 5.0], [6.0, 7.0]]) * 1; + + let output = tensor_1.transpose() + tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[4.0, 7.0], [7.0, 10.0]]), false); +} + +#[test] +fn test_add_different_strides_broadcast() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = TestTensor::from([[4.0, 5.0]]) * 1; + + let output = tensor_1.transpose() + tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[4.0, 7.0], [5.0, 8.0]]), false); +} + +#[test] +fn should_support_add_scalar_ops() { + let scalar = 2.0; + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor + scalar; + + output + .into_data() + .assert_eq(&TensorData::from([[2.0, 3.0, 4.0], [5.0, 6.0, 7.0]]), false); +} + +#[test] +fn add_maybe_fused_not_contiguous() { + let tensor1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor2 = TestTensorInt::arange(8..16, &Default::default()).float(); + let tensor1 = tensor1.reshape([2, 4]); + let tensor2 = tensor2.reshape([4, 2]); + let tensor2 = tensor2.swap_dims(0, 1); + + tensor2.device().sync().unwrap(); + + let output = tensor1 + tensor2; + + output.into_data().assert_eq( + &TensorData::from([[8.0, 11.0, 14.0, 17.0], [13.0, 16.0, 19.0, 22.0]]), + false, + ); +} + +#[test] +fn test_add_narrowed() { + // Non-contiguous via .narrow(): rows 1-2 of a [4, 4] tensor, then add + // to itself. Exercises stride handling on narrowed inputs. + let data: Vec = (0..16).map(|i| i as f32).collect(); + let device = Default::default(); + let a = TestTensor::<2>::from_data(TensorData::new(data.clone(), [4, 4]), &device); + let b = TestTensor::<2>::from_data(TensorData::new(data, [4, 4]), &device); + + let a_narrow = a.narrow(0, 1, 2); + let b_narrow = b.narrow(0, 1, 2); + + let output = a_narrow + b_narrow; + + output.into_data().assert_eq( + &TensorData::from([[8.0, 10.0, 12.0, 14.0], [16.0, 18.0, 20.0, 22.0]]), + false, + ); +} + +#[test] +fn test_add_scalar_transposed() { + // Scalar add on a non-contiguous (transposed) input. + let a = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + + let output = a + 10.0; + + // a_t = [[1, 3], [2, 4]] + 10 = [[11, 13], [12, 14]] + output + .into_data() + .assert_eq(&TensorData::from([[11.0, 13.0], [12.0, 14.0]]), false); +} + +#[test] +fn add_maybe_fused_not_contiguous_broadcasted() { + let tensor1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor2 = TestTensorInt::arange(8..10, &Default::default()).float(); + let tensor1 = tensor1.reshape([2, 4]); + let tensor2 = tensor2.reshape([1, 2]); + let tensor2 = tensor2.swap_dims(0, 1); + + tensor2.device().sync().unwrap(); + + let output = tensor2 + tensor1; + + output.into_data().assert_eq( + &TensorData::from([[8.0, 9.0, 10.0, 11.0], [13.0, 14.0, 15.0, 16.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/aggregation.rs b/crates/burn-backend-tests/tests/tensor/float/ops/aggregation.rs new file mode 100644 index 0000000..f24484a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/aggregation.rs @@ -0,0 +1,645 @@ +#![allow(clippy::identity_op)] + +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_should_mean() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.mean(); + let expected = TensorData::from([15.0 / 6.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_should_sum() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sum(); + + output + .into_data() + .assert_eq(&TensorData::from([15.0]), false); +} + +#[test] +fn test_should_sum_dim_maybe_fused() { + let tensor = TestTensor::<2>::from([[5.0], [-12.0]]); + let tensor1 = TestTensor::<2>::from([[2.0, 3.0], [-1.0, -5.0]]); + let ones = TestTensor::<2>::ones([2, 2], &Default::default()); + let _x = ones.clone() * tensor; + let y = ones * tensor1; + + let output = y.clone().sum_dim(1); + output + .into_data() + .assert_eq(&TensorData::from([[5.0], [-6.0]]), false); + + // Negative Indexing. + let output = y.clone().sum_dim(-1); + output + .into_data() + .assert_eq(&TensorData::from([[5.0], [-6.0]]), false); +} + +#[test] +fn test_should_mean_last_dim() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.clone().mean_dim(1); + let expected = TensorData::from([[3.0 / 3.0], [12.0 / 3.0]]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + // Negative Indexing. + let output = tensor.clone().mean_dim(-1); + let expected = TensorData::from([[3.0 / 3.0], [12.0 / 3.0]]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_should_sum_last_dim() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sum_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0], [12.0]]), false); +} + +#[test] +fn test_should_sum_first_dim() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]]); + + let output = tensor.sum_dim(0); + + output + .into_data() + .assert_eq(&TensorData::from([[7.0, 3.0, 5.0]]), false); +} + +#[test] +fn test_should_mean_first_dim() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]]); + + let output = tensor.mean_dim(0); + + output.into_data().assert_eq( + &TensorData::from([[7.0 / 2.0, 3.0 / 2.0, 5.0 / 2.0]]), + false, + ); +} + +#[test] +fn test_should_sum_mid_dim_3d_non_contiguous_1() { + let tensor = TestTensor::<3>::from([ + [[2.0, 4.0, 1.0], [7.0, -5.0, 3.0]], + [[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]], + ]); + + let output = tensor.swap_dims(0, 2).sum_dim(1); + + output.into_data().assert_eq( + &TensorData::new(vec![9.0, 7.0, -1.0, 3.0, 4.0, 5.0], [3, 1, 2]), + false, + ); +} + +#[test] +fn test_should_sum_mid_dim_3d_non_contiguous_2() { + let tensor = TestTensor::<3>::from([ + [[2.0, 4.0, 1.0], [7.0, -5.0, 3.0]], + [[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]], + ]); + + let output = tensor.swap_dims(0, 1).sum_dim(1); + + output.into_data().assert_eq( + &TensorData::new(vec![5.0, 5.0, 3.0, 11.0, -3.0, 6.0], [2, 1, 3]), + false, + ); +} + +#[test] +fn test_prod_float() { + let tensor = TestTensor::<2>::from([[2.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor.prod(); + + // 2 * 1 * 2 * 3 * 4 * 5 = 240 but we need to check the precision because of the float + let expected = TensorData::from([240.0]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let tensor_with_zero = TestTensor::<2>::from([[2.0, 0.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor_with_zero.prod(); + + output + .into_data() + .assert_eq(&TensorData::from([0.0]), false); +} + +#[test] +fn test_prod_dim_float() { + let tensor = TestTensor::<2>::from([[2.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor.prod_dim(1); + let expected = TensorData::from([[4.0], [60.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let tensor_with_zero = TestTensor::<2>::from([[2.0, 0.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor_with_zero.prod_dim(1); + let expected = TensorData::from([[0.0], [60.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_sum_dim_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let output = tensor.clone().sum_dim(1); + let expected = TensorData::from([[3.], [12.]]); + + output.into_data().assert_eq(&expected, false); + + let output = tensor.sum_dim(0); + let expected = TensorData::from([[3., 5., 7.]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dims_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + tensor + .clone() + .sum_dims(&[1]) + .to_data() + .assert_eq(&TensorData::from([[3.], [12.]]), false); + + tensor + .clone() + .sum_dims(&[-1]) + .to_data() + .assert_eq(&TensorData::from([[3.], [12.]]), false); + + tensor + .clone() + .sum_dims(&[0, 1]) + .to_data() + .assert_eq(&TensorData::from([[15.]]), false); +} + +#[test] +fn test_sum_and_squeeze_dims() { + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], + [[9.0, 2.0, 5.0], [5.0, 7.0, 7.0]], + ], + &Default::default(), + ); + + tensor + .sum_dims_squeeze::<1, _>(&[0, 1]) + .to_data() + .assert_eq(&TensorData::from([20., 16., 21.]), false); +} + +#[test] +fn test_sum_dim_1_reshape_maybe_fused() { + let tensor = TestTensorInt::arange(0..9, &Default::default()).float(); + tensor.device().sync().unwrap(); + + let output = tensor.reshape([3, 3]) + 2; + let output = output.sum_dim(1); + let expected = TensorData::from([[9.0], [18.0], [27.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_1_swap_dims_maybe_fused() { + // Note: the `+ 2` elementwise op materializes a contiguous intermediate, + // so the subsequent sum_dim sees contiguous strides - it does not + // exercise the transposed-reduce-last-dim path. That path is covered + // by `test_sum_transposed`. + let tensor = TestTensorInt::arange(0..9, &Default::default()).float(); + let tensor = tensor.reshape([3, 3]); + tensor.device().sync().unwrap(); + + let output = tensor.swap_dims(0, 1) + 2; + let output = output.sum_dim(1); + let expected = TensorData::from([[15.0], [18.0], [21.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_2_reshape_maybe_fused_broadcast() { + let tensor = TestTensorInt::arange(0..9, &Default::default()).float(); + tensor.device().sync().unwrap(); + + let output = tensor.reshape([1, 3, 3]) + 2; + let output = output.sum_dim(2); + let expected = TensorData::from([[[9.0], [18.0], [27.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_2_maybe_fused_on_write() { + let tensor_1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor_2 = TestTensorInt::arange(10..12, &Default::default()).float(); + let tensor_1 = tensor_1.reshape([1, 2, 4]); + let tensor_2 = tensor_2.reshape([1, 2, 1]); + tensor_1.device().sync().unwrap(); + + let output = (tensor_1 + tensor_2.clone()).sum_dim(2) + tensor_2; + output.device().sync().unwrap(); + let expected = TensorData::from([[[56.0], [77.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_3_maybe_fused_on_read_not_contiguous() { + let tensor_1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor_2 = TestTensorInt::arange(16..24, &Default::default()).float(); + + let tensor_1 = tensor_1.reshape([4, 2, 1]); + let tensor_1 = tensor_1.swap_dims(0, 2); + + let tensor_2 = tensor_2.reshape([1, 4, 2]); + let tensor_2 = tensor_2.swap_dims(1, 2); + tensor_1.device().sync().unwrap(); + + let output = (tensor_1 + tensor_2).sum_dim(2); + let expected = TensorData::from([[[88.0], [96.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_4_maybe_fused_on_read_not_contiguous_mixed() { + let tensor_1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor_2 = TestTensorInt::arange(16..24, &Default::default()).float(); + let tensor_3 = TestTensorInt::arange(32..40, &Default::default()).float(); + + let tensor_1 = tensor_1.reshape([4, 2, 1]); + let tensor_3 = tensor_3.reshape([1, 2, 4]); + let tensor_1 = tensor_1.swap_dims(0, 2); + + let tensor_2 = tensor_2.reshape([1, 4, 2]); + let tensor_2 = tensor_2.swap_dims(1, 2); + tensor_1.device().sync().unwrap(); + + let output = (tensor_3 + tensor_1 + tensor_2).sum_dim(2); + let expected = TensorData::from([[[222.0], [246.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_5_maybe_fused_on_read_not_contiguous_mixed() { + let tensor_1 = TestTensorInt::arange(0..8, &Default::default()).float(); + let tensor_2 = TestTensorInt::arange(16..24, &Default::default()).float(); + let tensor_3 = TestTensorInt::arange(32..40, &Default::default()).float(); + + let tensor_1 = tensor_1.reshape([4, 2, 1]); + let tensor_3 = tensor_3.reshape([1, 2, 4]); + let tensor_1 = tensor_1.swap_dims(0, 2); + + let tensor_2 = tensor_2.reshape([1, 4, 2]); + let tensor_2 = tensor_2.swap_dims(1, 2); + tensor_1.device().sync().unwrap(); + + let output = (tensor_3 + tensor_1 + tensor_2).sum_dim(1); + let expected = TensorData::from([[[102.0, 112.0, 122.0, 132.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_6_maybe_fused_on_read_not_contiguous_broadcasted() { + let tensor_1 = TestTensorInt::arange(0..32, &Default::default()).float(); + let tensor_2 = TestTensorInt::arange(0..8, &Default::default()).float(); + + let tensor_1 = tensor_1.reshape([4, 2, 2, 2]); + let tensor_1 = tensor_1.swap_dims(3, 2); + let tensor_1 = tensor_1.swap_dims(1, 2); + + let tensor_2 = tensor_2.reshape([1, 2, 2, 2]); + + tensor_1.device().sync().unwrap(); + let sum = tensor_2.clone().sum_dim(0); + let sum = sum.sum_dim(1); + let sum = sum.sum_dim(2); + + tensor_1.device().sync().unwrap(); + + let _tmp = sum.clone() + 2; + let output = (tensor_1 + tensor_2 + sum).sum_dim(1); + let expected = TensorData::from([ + [[[29.0, 43.0], [41.0, 55.0]]], + [[[45.0, 59.0], [57.0, 71.0]]], + [[[61.0, 75.0], [73.0, 87.0]]], + [[[77.0, 91.0], [89.0, 103.0]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sum_dim_7_maybe_fused_on_read_reshaped() { + let tensor_1 = TestTensorInt::arange(0..16, &Default::default()).float(); + + let tensor_1 = tensor_1.reshape([4, 4]); + + tensor_1.device().sync().unwrap(); + + let reshaped = tensor_1.reshape([1, 4, 4]); + let tmp = reshaped + 5.0; + let output = tmp.sum_dim(2); + let expected = TensorData::from([[[26.0], [42.0], [58.0], [74.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_reduce_dim_non_contiguous_input() { + let t = TestTensor::<2>::from([ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]); + let t_transposed = t.swap_dims(0, 1) /*+ 0.*/; + + let dim = 1; + let max = t_transposed.clone().max_dim(dim); + let shifted = t_transposed.sub(max); + let exp = shifted.exp(); + let sum = exp.clone().sum_dim(dim); + let output = exp.div(sum); + + let row = [3.2932044e-4, 1.7980287e-2, 0.98169035]; + let expected = TensorData::from([row, row, row, row]); + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn test_mean_dim_fused_on_read_on_write() { + // https://github.com/tracel-ai/burn/issues/3987 + let device = Default::default(); + let x = TestTensor::ones([128, 32, 1], &device); + + let weight = TestTensor::ones([1, 32, 1], &device); + let options = burn_tensor::ops::ConvOptions::new([1], [0], [1], 1); + let x = burn_tensor::module::conv1d(x, weight, None, options); + let global = x.clone().powi_scalar(2).sum_dim(2).add_scalar(1e-5).sqrt(); + let norm = global.clone().div(global.mean_dim(1)); + let x = x.clone().mul(norm).add(x); + + let out = x.sum(); + + out.into_data() + .assert_eq(&TensorData::from([8192.0]), false); +} + +#[test] +fn test_mean_dim_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let output = tensor.clone().mean_dim(1); + let expected = TensorData::from([[1.], [4.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let output = tensor.mean_dim(0); + let expected = TensorData::from([[1.5, 2.5, 3.5]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_mean_dims_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + tensor + .clone() + .mean_dims(&[1]) + .to_data() + .assert_eq(&TensorData::from([[1.], [4.]]), false); + + tensor + .clone() + .mean_dims(&[-1]) + .to_data() + .assert_eq(&TensorData::from([[1.], [4.]]), false); + + tensor + .clone() + .mean_dims(&[0, 1]) + .to_data() + .assert_eq(&TensorData::from([[2.5]]), false); +} + +#[test] +fn test_multiple_reduce_dims_permuted() { + // Regression test for https://github.com/tracel-ai/burn/issues/4461. + // + // Also pins the f16/bf16 `mean_dim` overflow fix: after the first reduction + // the second `mean_dim` sums 256 values that peak near 1021, so the f32 + // intermediate reaches ~261k (well above f16::MAX = 65504). A naive + // sum-then-divide in f16 would overflow to +inf. See the sibling + // `test_should_mean_overflow_intermediate_sum` for the scalar `.mean()` + // code path. + let tensor = TestTensorInt::arange(0..2 * 2 * 256, &Default::default()) + .float() + .reshape([2, 2, 256]); + + let output = tensor + .permute([1, 2, 0]) + .mean_dim(0) + .mean_dim(1) + .squeeze_dims::<1>(&[0, 1]); + + output + .into_data() + .assert_approx_eq::(&TensorData::from([255.5, 767.5]), Tolerance::default()); +} + +#[test] +fn test_sum_transposed() { + // Stress the sum kernel on a non-contiguous (transposed) input. Reducing + // the (now last) dim with stride != 1 previously hit a fast path that + // read contiguous storage, producing sliding-pair sums instead of column + // sums. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.transpose().sum_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[5.0], [7.0], [9.0]]), false); +} + +#[test] +fn test_prod_dim_transposed() { + // Same fast-path gate as sum_dim: prod_dim on a transposed 2D input must + // honor the reduce-dim stride. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.transpose().prod_dim(1); + + output.into_data().assert_approx_eq::( + &TensorData::from([[4.0], [10.0], [18.0]]), + Tolerance::default(), + ); +} + +#[test] +fn test_mean_dim_transposed() { + // mean_dim routes through sum_dim, so it inherits the same gate. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.transpose().mean_dim(1); + + output.into_data().assert_approx_eq::( + &TensorData::from([[2.5], [3.5], [4.5]]), + Tolerance::default(), + ); +} + +#[test] +fn test_sum_flipped() { + // Flip axis 0, sum along axis 1: per-row sums appear in the flipped + // row order, so a no-op flip would give the unflipped order. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]); + let output = tensor.flip([0]).sum_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[60.0], [6.0]]), false); +} + +#[test] +fn test_sum_dim_flipped() { + // Flip axis 0, reduce axis 1: rows are swapped so per-row sums appear + // reversed, which a flip-as-noop would not produce. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.flip([0]).sum_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[15.0], [6.0]]), false); +} + +#[test] +fn test_sum_dim_flipped_axis1() { + // Flip axis 1, reduce axis 0: columns are reversed, so column sums + // appear in reversed order. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.flip([1]).sum_dim(0); + + output + .into_data() + .assert_eq(&TensorData::from([[9.0, 7.0, 5.0]]), false); +} + +#[test] +fn test_mean_dim_flipped() { + // Flip axis 0, mean axis 1: row means appear in reversed row order. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.flip([0]).mean_dim(1); + + output + .into_data() + .assert_approx_eq::(&TensorData::from([[5.0], [2.0]]), Tolerance::default()); +} + +#[test] +fn test_prod_flipped() { + // Flip axis 0, prod along axis 1: per-row products appear in reversed + // row order. + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + let output = tensor.flip([0]).prod_dim(1); + + output + .into_data() + .assert_approx_eq::(&TensorData::from([[12.0], [2.0]]), Tolerance::default()); +} + +#[test] +fn test_sum_narrowed() { + // Narrow to indices 1..4 of [0, 1, 2, 3, 4] -> [1, 2, 3]. Without the + // narrow the sum would be 10, not 6. + let tensor = TestTensor::<1>::from([0.0, 1.0, 2.0, 3.0, 4.0]); + let output = tensor.narrow(0, 1, 3).sum(); + + output + .into_data() + .assert_eq(&TensorData::from([6.0]), false); +} + +#[test] +fn test_sum_flipped_both_axes() { + // Flip both axes, sum along axis 0: column sums appear in reversed + // order because axis 1 was flipped; row pairing is also swapped so a + // missing axis-0 flip would give different pairings. + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let output = tensor.flip([0, 1]).sum_dim(0); + + output + .into_data() + .assert_eq(&TensorData::from([[9.0, 7.0, 5.0]]), false); +} + +#[test] +fn test_sum_dim_4d_middle_dim() { + // Regression: 4D tensor reducing a middle dim (shape [1, 84, 80, 80]). + // Fill with 1.0 so every output position should sum to 84.0. + let shape = [1, 84, 80, 80]; + let n: usize = shape.iter().product(); + let data: Vec = vec![1.0; n]; + let tensor = TestTensor::<4>::from_data(TensorData::new(data, shape), &Default::default()); + + let output = tensor.sum_dim(1); + + let expected = TensorData::new(vec![84.0f32; 1 * 1 * 80 * 80], [1, 1, 80, 80]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_should_mean_overflow_intermediate_sum() { + let tensor = TestTensorInt::arange(0..1024, &Default::default()).float(); + let output = tensor.mean(); + output + .into_data() + .assert_approx_eq::(&TensorData::from([511.5]), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/all.rs b/crates/burn-backend-tests/tests/tensor/float/ops/all.rs new file mode 100644 index 0000000..bd6ae0e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/all.rs @@ -0,0 +1,47 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_all() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_all_dim() { + let tensor = TestTensor::<2>::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let data_actual = tensor.all_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} + +/// Larger `all_dim` over a long, non-power-of-two axis so the reduction spans +/// multiple blocks/planes/cubes. Same mask as the large `any_dim` test; here a +/// row reduces to `true` only when every element is nonzero (so the single-zero +/// row 7 must reduce to `false`). Checked for float, int and bool inputs. +#[test] +fn test_all_dim_large() { + let device = Default::default(); + let (rows, cols) = (8usize, 513usize); + let mask = build_logical_mask(rows, cols); + + let expected: Vec = (0..rows) + .map(|r| (0..cols).all(|c| mask[r * cols + c])) + .collect(); + let expected = TensorData::new(expected, [rows, 1]); + + let float = TestTensor::<2>::from_data( + TensorData::new(mask_to_floats(&mask), [rows, cols]), + &device, + ); + expected.assert_eq(&float.all_dim(1).into_data(), false); + + let int = + TestTensorInt::<2>::from_data(TensorData::new(mask_to_ints(&mask), [rows, cols]), &device); + expected.assert_eq(&int.all_dim(1).into_data(), false); + + let boolean = TestTensorBool::<2>::from_data(TensorData::new(mask, [rows, cols]), &device); + expected.assert_eq(&boolean.all_dim(1).into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/any.rs b/crates/burn-backend-tests/tests/tensor/float/ops/any.rs new file mode 100644 index 0000000..b17839e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/any.rs @@ -0,0 +1,87 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_any() { + // test float tensor + let tensor = TestTensor::<2>::from([[0.0, 0.0, 0.0], [1.0, -1.0, 0.0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensor::<2>::from([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); + + // test int tensor + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [1, -1, 0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [0, 0, 0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); + + // test bool tensor + let tensor = TestTensorBool::<2>::from([[false, false, false], [true, true, false]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorBool::<2>::from([[false, false, false], [false, false, false]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_any_dim() { + let tensor = TestTensor::<2>::from([[0.0, 0.0, 0.0], [1.0, -1.0, 0.0]]); + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); + + // test int tensor + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [1, -1, 0]]); + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); + + // test bool tensor + let tensor = TestTensorBool::<2>::from([[false, false, false], [true, true, false]]); + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} + +/// Larger `any_dim` over a long, non-power-of-two axis so the reduction spans +/// multiple blocks/planes/cubes. Rows cover every merge case: all-zero, +/// all-nonzero, a single nonzero at the first/last/middle column, and an +/// all-nonzero row with a single zero. Checked for float, int and bool inputs. +#[test] +fn test_any_dim_large() { + let device = Default::default(); + let (rows, cols) = (8usize, 513usize); + let mask = build_logical_mask(rows, cols); + + let expected: Vec = (0..rows) + .map(|r| (0..cols).any(|c| mask[r * cols + c])) + .collect(); + let expected = TensorData::new(expected, [rows, 1]); + + let float = TestTensor::<2>::from_data( + TensorData::new(mask_to_floats(&mask), [rows, cols]), + &device, + ); + expected.assert_eq(&float.any_dim(1).into_data(), false); + + let int = + TestTensorInt::<2>::from_data(TensorData::new(mask_to_ints(&mask), [rows, cols]), &device); + expected.assert_eq(&int.any_dim(1).into_data(), false); + + let boolean = TestTensorBool::<2>::from_data(TensorData::new(mask, [rows, cols]), &device); + expected.assert_eq(&boolean.any_dim(1).into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/arg.rs b/crates/burn-backend-tests/tests/tensor/float/ops/arg.rs new file mode 100644 index 0000000..c86023a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/arg.rs @@ -0,0 +1,212 @@ +#![allow(clippy::identity_op)] + +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_argmax_2d_dim0() { + let tensor = TestTensor::<2>::from([[10.0, 11.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.argmax(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 0, 1]]), false); +} + +#[test] +fn test_argmin_2d_dim0() { + let tensor = TestTensor::<2>::from([[10.0, 11.0, 2.0], [30.0, 4.0, 5.0]]); + + let output = tensor.argmin(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 0]]), false); +} + +#[test] +fn test_argmax_2d_dim1() { + let tensor = TestTensor::<2>::from([[10.0, 11.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.argmax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1], [2]]), false); +} + +#[test] +fn test_argmin_2d_dim1() { + let tensor = TestTensor::<2>::from([[10.0, 11.0, 2.0], [30.0, 4.0, 5.0]]); + + let output = tensor.argmin(1); + + output + .into_data() + .assert_eq(&TensorData::from([[2], [1]]), false); +} + +#[test] +fn test_argmax_flipped() { + // Flip [1, 5, 3, 2, 4] -> [4, 2, 3, 5, 1]; max is at index 3. + let tensor = TestTensor::<1>::from([1.0, 5.0, 3.0, 2.0, 4.0]); + let output = tensor.flip([0]).argmax(0); + + output.into_data().assert_eq(&TensorData::from([3]), false); +} + +#[test] +fn test_argmax_2d_flipped() { + // [[1, 5, 3], [6, 2, 4]] axis-1-flipped -> [[3, 5, 1], [4, 2, 6]]; argmax dim 1 -> [[1], [2]]. + let tensor = TestTensor::<2>::from([[1.0, 5.0, 3.0], [6.0, 2.0, 4.0]]); + let output = tensor.flip([1]).argmax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1], [2]]), false); +} + +#[test] +fn test_argmin_flipped() { + // Flip [5, 1, 4, 2, 3] -> [3, 2, 4, 1, 5]; min is at index 3. + let tensor = TestTensor::<1>::from([5.0, 1.0, 4.0, 2.0, 3.0]); + let output = tensor.flip([0]).argmin(0); + + output.into_data().assert_eq(&TensorData::from([3]), false); +} + +#[test] +fn test_argmax_permuted_4d() { + // Regression: argmax on a permuted 4D tensor (was index OOB). + let n = 2 * 3 * 4 * 5; + let data: Vec = (0..n).map(|i| i as f32).collect(); + let tensor = + TestTensor::<4>::from_data(TensorData::new(data, [2, 3, 4, 5]), &Default::default()); + let permuted = tensor.permute([0, 2, 1, 3]); + + let result = permuted.clone().argmax(3); + assert_eq!(result.dims(), [2, 4, 3, 1]); + + let result = permuted.argmax(2); + assert_eq!(result.dims(), [2, 4, 1, 5]); +} + +#[test] +fn test_argmin_permuted_4d() { + let n = 2 * 3 * 4 * 5; + let data: Vec = (0..n).map(|i| (n - i) as f32).collect(); + let tensor = + TestTensor::<4>::from_data(TensorData::new(data, [2, 3, 4, 5]), &Default::default()); + let permuted = tensor.permute([0, 2, 1, 3]); + + let result = permuted.argmin(3); + assert_eq!(result.dims(), [2, 4, 3, 1]); +} + +#[test] +fn test_argmax_4d_middle_dim() { + // Regression (YOLOv8n): shape [1, 84, 80, 80], argmax dim=1. + let n = 1 * 84 * 80 * 80; + let data: Vec = (0..n).map(|i| (i % 84) as f32).collect(); + let tensor = + TestTensor::<4>::from_data(TensorData::new(data, [1, 84, 80, 80]), &Default::default()); + + let output = tensor.argmax(1); + assert_eq!(output.dims(), [1, 1, 80, 80]); +} + +#[test] +fn test_argmax_permuted_correctness() { + // Data [2, 2, 3] permuted [0, 2, 1] -> [2, 3, 2]; argmax dim 2 should be all 1s. + let data: Vec = (1..=12).map(|i| i as f32).collect(); + let tensor = TestTensor::<3>::from_data(TensorData::new(data, [2, 2, 3]), &Default::default()); + let output = tensor.permute([0, 2, 1]).argmax(2); + + output + .into_data() + .assert_eq(&TensorData::from([[[1], [1], [1]], [[1], [1], [1]]]), false); +} + +// NaN-propagation tests below. Only run when the `flex` backend feature +// is active, because flex is the only burn backend that currently +// propagates NaN from argmax/argmin (matching PyTorch/NumPy/JAX/TF). +// ndarray and the cubecl backends follow IEEE 754 min/max and drop NaN. +// The positive-gate form (rather than excluding specific backends) is +// used because the default-feature CI build selects a backend +// transitively without setting any of its identifying feature flags on +// burn-backend-tests, so a negative gate would still run the test on a +// NaN-dropping backend. See issue #4814. +#[cfg(feature = "flex")] +#[test] +fn test_argmax_nan_propagation() { + let tensor = TestTensor::<2>::from([[1.0, f32::NAN, 3.0]]); + let output = tensor.argmax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1]]), false); +} + +// First-NaN wins: when row[0] is NaN AND a later element is also NaN, +// argmax must return 0 (the earlier NaN index), not the later one. +#[cfg(feature = "flex")] +#[test] +fn test_argmax_nan_leading_with_trailing_nan() { + let tensor = TestTensor::<2>::from([[f32::NAN, f32::NAN, 3.0]]); + let output = tensor.argmax(1); + output + .into_data() + .assert_eq(&TensorData::from([[0]]), false); +} + +#[cfg(feature = "flex")] +#[test] +fn test_argmin_nan_leading_with_trailing_nan() { + let tensor = TestTensor::<2>::from([[f32::NAN, f32::NAN, 3.0]]); + let output = tensor.argmin(1); + output + .into_data() + .assert_eq(&TensorData::from([[0]]), false); +} + +// All-NaN row: argmax should return 0 (first NaN). +#[cfg(feature = "flex")] +#[test] +fn test_argmax_all_nan_row() { + let tensor = TestTensor::<2>::from([[f32::NAN, f32::NAN, f32::NAN]]); + let output = tensor.argmax(1); + output + .into_data() + .assert_eq(&TensorData::from([[0]]), false); +} + +// NaN propagation must also hold for non-last-dim argmax, which routes +// through a different kernel than the last-dim fast path. +#[cfg(feature = "flex")] +#[test] +fn test_argmax_nan_leading_non_last_dim() { + // Column 0: [NaN, NaN, 3.0] -> argmax along dim 0 = 0. + // Column 1: [1.0, 2.0, 4.0] -> argmax along dim 0 = 2. + let tensor = TestTensor::<2>::from([[f32::NAN, 1.0], [f32::NAN, 2.0], [3.0, 4.0]]); + let output = tensor.argmax(0); + output + .into_data() + .assert_eq(&TensorData::from([[0, 2]]), false); +} + +// max_dim_with_indices on a leading-NaN row should return (NaN, 0). +// Complements test_max_dim_with_indices_nan_propagation in maxmin.rs, +// which uses a single NaN in the middle of the row. +#[cfg(feature = "flex")] +#[test] +fn test_max_dim_with_indices_nan_leading() { + let tensor = TestTensor::<2>::from([[f32::NAN, f32::NAN, 3.0]]); + let (values, indices) = tensor.max_dim_with_indices(1); + let vdata = values.into_data(); + let slice = vdata.as_slice::().unwrap(); + assert!(slice[0].is_nan()); + indices + .into_data() + .assert_eq(&TensorData::from([[0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/blackman_window.rs b/crates/burn-backend-tests/tests/tensor/float/ops/blackman_window.rs new file mode 100644 index 0000000..325a056 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/blackman_window.rs @@ -0,0 +1,65 @@ +use super::*; +use burn_tensor::signal::blackman_window; +use burn_tensor::{DType, TensorData, Tolerance}; + +#[test] +fn should_support_blackman_window_options_dtype() { + let tensor: TestTensor<1> = blackman_window(4, true, (&Default::default(), DType::F32)); + assert_eq!(tensor.dtype(), DType::F32); +} + +#[test] +fn should_support_blackman_window_size_0_symmetric() { + let tensor: TestTensor<1> = blackman_window(0, false, &Default::default()); + assert_eq!(tensor.dims(), [0]); +} + +#[test] +fn should_support_blackman_window_size_0_periodic() { + let tensor: TestTensor<1> = blackman_window(0, true, &Default::default()); + assert_eq!(tensor.dims(), [0]); +} + +#[test] +fn should_handle_blackman_window_size_1_symmetric() { + let tensor: TestTensor<1> = blackman_window(1, false, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_handle_blackman_window_size_1_periodic() { + let tensor: TestTensor<1> = blackman_window(1, true, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_blackman_window_size_8_periodic() { + let tensor: TestTensor<1> = blackman_window(8, true, &Default::default()); + let expected = TensorData::from([ + 0.0, 6.6447e-02, 3.4000e-01, 7.7355e-01, 1.0000e+00, 7.7355e-01, 3.4000e-01, 6.6447e-02, + ]); + + // Positions 0 and 7 have values close to 0, hence setting absolute tolerance + let tolerance = Tolerance::default().set_half_precision_absolute(1e-3); + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_blackman_window_size_8_symmetric() { + let tensor: TestTensor<1> = blackman_window(8, false, &Default::default()); + let expected = TensorData::from([ + 0.0, 9.0453e-02, 4.5918e-01, 9.2036e-01, 9.2036e-01, 4.5918e-01, 9.0453e-02, 0.0, + ]); + + // Positions 0 and 7 have values close to 0, hence setting absolute tolerance + let tolerance = Tolerance::default().set_half_precision_absolute(1e-3); + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/cast.rs b/crates/burn-backend-tests/tests/tensor/float/ops/cast.rs new file mode 100644 index 0000000..e00bb35 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/cast.rs @@ -0,0 +1,91 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{DType, FloatDType, IntDType, TensorData}; + +#[test] +fn cast_float_to_bool() { + let tensor1 = TestTensor::<2>::from([[0.0, 43.0, 0.0], [2.0, -4.2, 31.33]]); + let data_actual = tensor1.bool().into_data(); + let data_expected = TensorData::from([[false, true, false], [true, true, true]]); + data_actual.assert_eq(&data_expected, false); +} + +#[test] +fn cast_float_to_int() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.4, 5.5, 6.6]]).int(); + let expected = TensorData::from([[1, 2, 3], [4, 5, 6]]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_int_to_float_tensor() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]).float(); + + let expected = TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_bool_to_float_tensor() { + let tensor = TestTensorBool::<2>::from([[true, false, true], [false, false, true]]).float(); + + let expected = TensorData::from([[1., 0., 1.], [0., 0., 1.]]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_float_precision() { + let data = TensorData::from([[1.0, 2.0, 3.0], [4.4, 5.5, 6.6]]); + let tensor = TestTensor::<2>::from(data.clone()); + + let output = tensor.cast(DType::F32); + + assert_eq!(output.dtype(), DType::F32); + // Use precision 2 for parameterized tests in f16 and bf16 + output + .into_data() + .assert_approx_eq::(&data, Tolerance::default()); +} + +#[test] +fn cast_float_to_int_with_dtype() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.4, 5.5, 6.6]]); + + let output = tensor.cast(IntDType::I32); + + assert_eq!(output.dtype(), DType::I32); + let expected = TensorData::from([[1i32, 2, 3], [4, 5, 6]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_float_within_kind_with_float_dtype() { + let tensor = TestTensor::<1>::from([1.0, 2.5, 3.7]); + + let output = tensor.cast(FloatDType::F32); + + assert_eq!(output.dtype(), DType::F32); +} + +#[test] +fn cast_float_same_dtype_is_noop() { + let data = TensorData::from([1.0, 2.0, 3.0]); + let tensor = TestTensor::<1>::from(data.clone()); + let original_dtype = tensor.dtype(); + let float_dtype: FloatDType = original_dtype.into(); + + let output = tensor.cast(float_dtype); + + assert_eq!(output.dtype(), original_dtype); + output.into_data().assert_eq(&data, false); +} + +#[test] +#[should_panic] +fn cast_float_with_int_dtype_panics() { + let tensor = TestTensor::<1>::from([1.0, 2.0]); + let _ = tensor.cast(DType::I32); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/cat.rs b/crates/burn-backend-tests/tests/tensor/float/ops/cat.rs new file mode 100644 index 0000000..7c2d12b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/cat.rs @@ -0,0 +1,148 @@ +use super::*; +use alloc::vec::Vec; +use burn_tensor::Tolerance; +use burn_tensor::{DType, TensorData}; + +#[test] +fn should_support_cat_ops_2d_dim0() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0, 6.0]], &device); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_ops_2d_dim1() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0, 6.0]], &device); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 1); + let expected = TensorData::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_ops_3d() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]], &device); + let tensor_2 = TestTensor::from_data([[[4.0, 5.0, 6.0]]], &device); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]], [[4.0, 5.0, 6.0]]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[should_panic] +fn should_panic_when_dimensions_are_not_the_same() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0]], &device); + + TestTensor::cat(vec![tensor_1, tensor_2], 0).into_data(); +} + +#[test] +#[should_panic] +fn should_panic_when_list_of_vectors_is_empty() { + let tensor: Vec> = vec![]; + TestTensor::cat(tensor, 0).into_data(); +} + +#[test] +#[should_panic] +fn should_panic_when_cat_exceeds_dimension() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]], &device); + let tensor_2 = TestTensor::from_data([[[4.0, 5.0, 6.0]]], &device); + + TestTensor::cat(vec![tensor_1, tensor_2], 3).into_data(); +} + +#[test] +fn should_support_cat_ops_cast_dtype() { + let device = Default::default(); + // ok for f32 backends, casts dtype for f16 tests + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]], &device) + .cast(DType::F32); + let tensor_2 = TestTensor::from_data([[[4.0, 5.0, 6.0]]], &device).cast(DType::F32); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]], [[4.0, 5.0, 6.0]]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_with_empty_tensor() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let tensor_2: TestTensor<2> = TestTensor::empty([1, 0], &device); // Empty tensor with size 0 on dim 1 + + // Concatenating with an empty tensor should just return the non-empty tensor + let output = TestTensor::cat(vec![tensor_1.clone(), tensor_2], 1); + let expected = TensorData::from([[1.0, 2.0, 3.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_with_empty_tensor_first() { + let device = Default::default(); + let tensor_1: TestTensor<2> = TestTensor::empty([1, 0], &device); // Empty tensor + let tensor_2 = TestTensor::<2>::from_data([[4.0, 5.0, 6.0]], &device); + + // Empty tensor first, then non-empty + let output = TestTensor::cat(vec![tensor_1, tensor_2.clone()], 1); + let expected = TensorData::from([[4.0, 5.0, 6.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_with_multiple_empty_tensors() { + let device = Default::default(); + let tensor_1: TestTensor<2> = TestTensor::empty([2, 0], &device); + let tensor_2 = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let tensor_3: TestTensor<2> = TestTensor::empty([2, 0], &device); + let tensor_4 = TestTensor::<2>::from_data([[5.0], [6.0]], &device); + + // Mix of empty and non-empty tensors + let output = TestTensor::cat(vec![tensor_1, tensor_2, tensor_3, tensor_4], 1); + let expected = TensorData::from([[1.0, 2.0, 5.0], [3.0, 4.0, 6.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_cat_all_empty_tensors() { + let device = Default::default(); + let tensor_1: TestTensor<2> = TestTensor::empty([2, 0], &device); + let tensor_2: TestTensor<2> = TestTensor::empty([2, 0], &device); + + // All empty tensors should produce an empty tensor + let output = TestTensor::cat(vec![tensor_1, tensor_2], 1); + + assert_eq!(output.shape().as_slice(), [2, 0]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/categorical.rs b/crates/burn-backend-tests/tests/tensor/float/ops/categorical.rs new file mode 100644 index 0000000..c488f7b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/categorical.rs @@ -0,0 +1,128 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn categorical_output_shape() { + let probs = TestTensor::<2>::from([[0.5, 0.5], [0.5, 0.5]]); + let samples = probs.categorical(5); + + assert_eq!(samples.dims(), [2, 5]); +} + +#[test] +fn categorical_1d() { + // 1D input: single distribution + let probs = TestTensor::<1>::from([0.0, 0.0, 1.0]); + let samples = probs.categorical(5); + + assert_eq!(samples.dims(), [5]); + let data = samples.into_data(); + let expected = TensorData::from([2, 2, 2, 2, 2i64]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_3d() { + // 3D input: [2, 1, 3] — two batches, each with one sub-batch of 3 categories + let device = Default::default(); + let probs = TestTensor::<3>::from_data( + TensorData::from([[[1.0, 0.0, 0.0]], [[0.0, 0.0, 1.0]]]), + &device, + ); + let samples = probs.categorical(4); + + assert_eq!(samples.dims(), [2, 1, 4]); + let data = samples.into_data(); + let expected = TensorData::from([[[0, 0, 0, 0i64]], [[2, 2, 2, 2]]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_deterministic_single_category() { + let probs = TestTensor::<2>::from([[1.0, 0.0, 0.0]]); + let samples = probs.categorical(10); + + let data = samples.into_data(); + let expected = TensorData::from([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0i64]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_deterministic_last_category() { + let probs = TestTensor::<2>::from([[0.0, 0.0, 1.0]]); + let samples = probs.categorical(10); + + let data = samples.into_data(); + let expected = TensorData::from([[2, 2, 2, 2, 2, 2, 2, 2, 2, 2i64]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_indices_in_range() { + let num_categories = 5; + let probs = TestTensor::<2>::from([[0.2, 0.2, 0.2, 0.2, 0.2]]); + let samples = probs.categorical(100); + + let data = samples.into_data(); + data.assert_within_range::(0..num_categories); +} + +#[test] +fn categorical_batch_deterministic() { + // Row 0: always index 0, Row 1: always index 2 + let probs = TestTensor::<2>::from([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]); + let samples = probs.categorical(5); + + let data = samples.into_data(); + let expected = TensorData::from([[0, 0, 0, 0, 0i64], [2, 2, 2, 2, 2]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_single_category_always_zero() { + let probs = TestTensor::<2>::from([[1.0]]); + let samples = probs.categorical(5); + + let data = samples.into_data(); + let expected = TensorData::from([[0, 0, 0, 0, 0i64]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_unnormalized_weights() { + // Weights [0, 0, 10] — should always sample index 2 after normalization + let probs = TestTensor::<2>::from([[0.0, 0.0, 10.0]]); + let samples = probs.categorical(10); + + let data = samples.into_data(); + let expected = TensorData::from([[2, 2, 2, 2, 2, 2, 2, 2, 2, 2i64]]); + data.assert_eq(&expected, false); +} + +#[test] +fn categorical_statistical_distribution() { + // With equal probabilities [0.5, 0.5], samples should be roughly evenly split + let num_samples = 1000; + let probs = TestTensor::<2>::from([[0.5, 0.5]]); + let samples = probs.categorical(num_samples); + + let data: TensorData = samples.into_data(); + let values = data.to_vec::().unwrap(); + + let count_zero = values.iter().filter(|&&v| v == 0).count(); + let ratio = count_zero as f64 / num_samples as f64; + + // Expect ~50% zeros, allow wide tolerance for stochastic test + assert!( + ratio > 0.3 && ratio < 0.7, + "expected ~50% index-0 samples, got {:.1}%", + ratio * 100.0 + ); +} + +#[test] +#[should_panic] +fn categorical_zero_samples_panics() { + let probs = TestTensor::<2>::from([[0.5, 0.5]]); + let _ = probs.categorical(0); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/ceil.rs b/crates/burn-backend-tests/tests/tensor/float/ops/ceil.rs new file mode 100644 index 0000000..8164d3e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/ceil.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_ceil_ops() { + let data = TensorData::from([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.ceil(); + let expected = TensorData::from([[25., 88., 77.], [60., 44., 95.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/chunk.rs b/crates/burn-backend-tests/tests/tensor/float/ops/chunk.rs new file mode 100644 index 0000000..1c93ee6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/chunk.rs @@ -0,0 +1,85 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_chunk_evenly_divisible() { + let tensors = TestTensorInt::arange(0..12, &Default::default()) + .float() + .chunk(6, 0); + assert_eq!(tensors.len(), 6); + + let expected = [ + TensorData::from([0, 1]), + TensorData::from([2, 3]), + TensorData::from([4, 5]), + TensorData::from([6, 7]), + TensorData::from([8, 9]), + TensorData::from([10, 11]), + ]; + + for (index, tensor) in tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_chunk_not_evenly_divisible() { + let tensors = TestTensorInt::arange(0..11, &Default::default()) + .float() + .chunk(6, 0); + assert_eq!(tensors.len(), 6); + + let expected = [ + TensorData::from([0, 1]), + TensorData::from([2, 3]), + TensorData::from([4, 5]), + TensorData::from([6, 7]), + TensorData::from([8, 9]), + TensorData::from([10]), + ]; + + for (index, tensor) in tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_chunk_not_evenly_divisible_remains_several() { + let tensors = TestTensorInt::arange(0..100, &Default::default()) + .float() + .chunk(8, 0); + assert_eq!(tensors.len(), 8); + + let expected = [13, 13, 13, 13, 13, 13, 13, 9]; + + for (index, tensor) in tensors.iter().enumerate() { + assert_eq!(tensor.shape()[0], expected[index]); + } +} + +#[test] +fn test_chunk_not_divisible() { + let tensors = TestTensorInt::arange(0..6, &Default::default()) + .float() + .chunk(7, 0); + assert_eq!(tensors.len(), 6); + + let expected = [ + TensorData::from([0]), + TensorData::from([1]), + TensorData::from([2]), + TensorData::from([3]), + TensorData::from([4]), + TensorData::from([5]), + ]; + + for (index, tensor) in tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +#[should_panic] +fn test_invalid_dim() { + let _tensors = TestTensorInt::arange(0..12, &Default::default()).chunk(6, 1); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/clamp.rs b/crates/burn-backend-tests/tests/tensor/float/ops/clamp.rs new file mode 100644 index 0000000..9c15c00 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/clamp.rs @@ -0,0 +1,81 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn clamp_min() { + let device = Default::default(); + // test float tensor + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &device); + + let output = tensor.clamp_min(2.0); + + output + .into_data() + .assert_eq(&TensorData::from([[2.0, 2.0, 2.0], [3.0, 4.0, 5.0]]), false); + + // test int tensor + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let tensor = TestTensorInt::<2>::from_data(data, &device); + let output = tensor.clamp_min(2); + + output + .into_data() + .assert_eq(&TensorData::from([[2, 2, 2], [3, 4, 5]]), false); +} + +#[test] +fn clamp_max() { + let device = Default::default(); + // test float tensor + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &device); + + let output = tensor.clamp_max(2.0); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 1.0, 2.0], [2.0, 2.0, 2.0]]), false); + + // test int tensor + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let tensor = TestTensorInt::<2>::from_data(data, &device); + let output = tensor.clamp_max(4); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 2], [3, 4, 4]]), false); +} + +#[test] +fn clamp_min_max() { + let device = Default::default(); + // test float tensor + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &device); + let output = tensor.clamp(1.0, 4.0); + + output + .into_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 4.0]]), false); + + // test int tensor + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let tensor = TestTensorInt::<2>::from_data(data, &device); + let output = tensor.clamp(1, 4); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 1, 2], [3, 4, 4]]), false); +} + +#[test] +fn clamp_min_max_vec_should_compile() { + let input = TestTensor::<2>::ones([2, 4], &Default::default()); + let output = input.clamp(0., 0.5); + + output.into_data().assert_eq( + &TensorData::from([[0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/close.rs b/crates/burn-backend-tests/tests/tensor/float/ops/close.rs new file mode 100644 index 0000000..1f177ef --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/close.rs @@ -0,0 +1,40 @@ +use super::*; +use burn_tensor::{DEFAULT_ATOL, DEFAULT_RTOL, TensorData}; + +#[test] +fn test_is_close() { + let tensor1 = TestTensor::<2>::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let tensor2 = TestTensor::from([[0.0, 1.0, 0.0], [1.0, -1.0, 3.0]]) + 1e-9; + + let data_actual = tensor1 + .clone() + .is_close(tensor2.clone(), None, None) + .into_data(); + let defaults_expected = TensorData::from([[true, true, true], [true, true, false]]); + defaults_expected.assert_eq(&data_actual, false); + + // Using the defaults. + let data_actual = tensor1 + .is_close(tensor2, Some(DEFAULT_RTOL), Some(DEFAULT_ATOL)) + .into_data(); + defaults_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_all_close() { + let tensor1 = TestTensor::<2>::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let tensor2 = TestTensor::from([[0.0, 1.0, 0.0], [1.0, -1.0, 3.0]]) + 1e-9; + assert!(!tensor1.clone().all_close(tensor2.clone(), None, None)); + + let tensor2 = TestTensor::from([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]) + 1e-9; + assert!(tensor1.all_close(tensor2, None, None)); + + // non finite values + let inf_plus = TestTensor::<2>::from([[f32::INFINITY]]); + let one = TestTensor::<2>::from([[1.]]); + let inf_minus = TestTensor::<2>::from([[-f32::INFINITY]]); + assert!(!inf_plus.clone().all_close(inf_minus.clone(), None, None)); + assert!(!one.clone().all_close(inf_minus.clone(), None, None)); + assert!(!one.all_close(inf_plus.clone(), None, None)); + assert!(inf_plus.clone().all_close(inf_plus, None, None)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/comparison.rs b/crates/burn-backend-tests/tests/tensor/float/ops/comparison.rs new file mode 100644 index 0000000..e022c2c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/comparison.rs @@ -0,0 +1,393 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_equal_inf() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0], [f32::INFINITY, 4.0, f32::NEG_INFINITY]]); + let data_2 = TensorData::from([[1.0, 1.0, 1.0], [f32::INFINITY, 3.0, f32::NEG_INFINITY]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let data_actual_cloned = tensor_1.clone().equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.equal(tensor_2); + + let data_expected = TensorData::from([[false, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_not_equal_inf() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0], [3.0, f32::INFINITY, 5.0]]); + let data_2 = TensorData::from([[1.0, 1.0, 1.0], [f32::INFINITY, 3.0, f32::NEG_INFINITY]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let data_actual_cloned = tensor_1.clone().not_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.not_equal(tensor_2); + + let data_expected = TensorData::from([[true, false, true], [true, true, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_equal() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.equal(tensor_2); + + let data_expected = TensorData::from([[false, true, false], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_not_equal() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().not_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.not_equal(tensor_2); + + let data_expected = TensorData::from([[true, false, true], [true, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_equal_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 2.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().equal_elem(2); + let data_actual_inplace = tensor_1.equal_elem(2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_not_equal_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 2.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().not_equal_elem(2); + let data_actual_inplace = tensor_1.not_equal_elem(2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn greater_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().greater_elem(4); + let data_actual_inplace = tensor_1.greater_elem(4); + + let data_expected = TensorData::from([[false, false, false], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_equal_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().greater_equal_elem(4.0); + let data_actual_inplace = tensor_1.greater_equal_elem(4.0); + + let data_expected = TensorData::from([[false, false, false], [false, true, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 50.0]]); + + let data_actual_cloned = tensor_1.clone().greater(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater(tensor_2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_equal() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 50.0]]); + + let data_actual_cloned = tensor_1.clone().greater_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater_equal(tensor_2); + + let data_expected = TensorData::from([[false, true, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().lower_elem(4.0); + let data_actual_inplace = tensor_1.lower_elem(4.0); + + let data_expected = TensorData::from([[true, true, true], [true, false, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_equal_elem() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor_1.clone().lower_equal_elem(4.0); + let data_actual_inplace = tensor_1.lower_equal_elem(4.0); + + let data_expected = TensorData::from([[true, true, true], [true, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 50.0]]); + + let data_actual_cloned = tensor_1.clone().lower(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower(tensor_2); + + let data_expected = TensorData::from([[true, false, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_equal() { + let tensor_1 = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = TestTensor::<2>::from([[1.0, 1.0, 1.0], [4.0, 3.0, 50.0]]); + + let data_actual_cloned = tensor_1.clone().lower_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower_equal(tensor_2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_broadcast() { + // Test broadcasting with shape [1, 4] vs [4, 4] + let device = Default::default(); + let data_1 = TensorData::from([[1.0, 2.0, 3.0, 4.0]]); + let data_2 = TensorData::from([ + [0.5, 1.5, 2.5, 3.5], + [1.5, 2.5, 3.5, 4.5], + [2.5, 3.5, 4.5, 5.5], + [3.5, 4.5, 5.5, 6.5], + ]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.greater(tensor_2); + + let expected = TensorData::from([ + [true, true, true, true], + [false, false, false, false], + [false, false, false, false], + [false, false, false, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_greater_equal_broadcast() { + // Test broadcasting with shape [4, 1] vs [1, 4] + let device = Default::default(); + let data_1 = TensorData::from([[1.0], [2.0], [3.0], [4.0]]); + let data_2 = TensorData::from([[1.0, 2.0, 3.0, 4.0]]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.greater_equal(tensor_2); + + let expected = TensorData::from([ + [true, false, false, false], + [true, true, false, false], + [true, true, true, false], + [true, true, true, true], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_lower_broadcast() { + // Test broadcasting mimicking CLIP pattern: [1, 5] vs [5, 1] + let device = Default::default(); + let data_1 = TensorData::from([[0.0, 1.0, -1.0, 2.0, -2.0]]); + let data_2 = TensorData::from([[0.5], [1.5], [-0.5], [-1.5], [2.5]]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.lower(tensor_2); + + let expected = TensorData::from([ + [true, false, true, false, true], + [true, true, true, false, true], + [false, false, true, false, true], + [false, false, false, false, true], + [true, true, true, true, true], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_lower_equal_broadcast() { + // Test broadcasting with shape [1, 1] vs [2, 4] + let device = Default::default(); + let data_1 = TensorData::from([[2.5]]); + let data_2 = TensorData::from([[1.0, 2.0, 3.0, 4.0], [2.0, 2.5, 3.0, 3.5]]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.lower_equal(tensor_2); + + let expected = TensorData::from([[false, false, true, true], [false, true, true, true]]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_equal_broadcast() { + // Test broadcasting with different ranks + let device = Default::default(); + let data_1 = TensorData::from([[2.0], [3.0], [4.0]]); + let data_2 = TensorData::from([[2.0, 3.0, 4.0, 2.0]]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.equal(tensor_2); + + let expected = TensorData::from([ + [true, false, false, true], + [false, true, false, false], + [false, false, true, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_not_equal_broadcast() { + // Test broadcasting with shape [3, 1] vs [1, 3] + let device = Default::default(); + let data_1 = TensorData::from([[1.0], [2.0], [3.0]]); + let data_2 = TensorData::from([[1.0, 2.0, 3.0]]); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let result = tensor_1.not_equal(tensor_2); + + let expected = TensorData::from([ + [false, true, true], + [true, false, true], + [true, true, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_greater_transposed() { + // [[1,2],[3,4]] transposed -> [[1,3],[2,4]]; > [[2,2],[2,2]] = [[F,T],[F,T]] + let lhs = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + let rhs = TestTensor::<2>::from([[2.0, 2.0], [2.0, 2.0]]); + + let result = lhs.greater(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([[false, true], [false, true]]), false); +} + +#[test] +fn test_equal_flipped_1d() { + // [1,2,3,4] flipped -> [4,3,2,1]; == [4,2,2,1] = [T,F,T,T] + let lhs = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]).flip([0]); + let rhs = TestTensor::<1>::from([4.0, 2.0, 2.0, 1.0]); + + let result = lhs.equal(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([true, false, true, true]), false); +} + +#[test] +fn test_lower_flipped_2d() { + // [[1,2],[3,4]] axis-0 flipped -> [[3,4],[1,2]]; < [[2,5],[2,1]] = [[F,T],[T,F]] + let lhs = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).flip([0]); + let rhs = TestTensor::<2>::from([[2.0, 5.0], [2.0, 1.0]]); + + let result = lhs.lower(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([[false, true], [true, false]]), false); +} + +#[test] +fn test_greater_elem_flipped() { + // [1,2,3,4] flipped -> [4,3,2,1]; > 2.5 = [T,T,F,F] + let lhs = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]).flip([0]); + + let result = lhs.greater_elem(2.5); + + result + .into_data() + .assert_eq(&TensorData::from([true, true, false, false]), false); +} + +#[test] +fn test_equal_both_transposed() { + // Both transposed. [[1,2],[3,4]]^T == [[1,3],[2,4]]^T = ? + let lhs = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + let rhs = TestTensor::<2>::from([[1.0, 3.0], [2.0, 4.0]]).transpose(); + + let result = lhs.equal(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([[true, false], [false, true]]), false); +} + +#[test] +fn test_not_equal_narrowed() { + // [1,2,3,4,5,6] narrowed to [2,3,4,5]; != [2,2,4,4] = [F,T,F,T] + let lhs = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).narrow(0, 1, 4); + let rhs = TestTensor::<1>::from([2.0, 2.0, 4.0, 4.0]); + + let result = lhs.not_equal(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([false, true, false, true]), false); +} + +#[test] +fn test_lower_flipped_both_axes() { + // [[1,2],[3,4]] flipped on both axes -> [[4,3],[2,1]]; < [[3,3],[3,3]] = [[F,F],[T,T]] + let lhs = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).flip([0, 1]); + let rhs = TestTensor::<2>::from([[3.0, 3.0], [3.0, 3.0]]); + + let result = lhs.lower(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([[false, false], [true, true]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/create_like.rs b/crates/burn-backend-tests/tests/tensor/float/ops/create_like.rs new file mode 100644 index 0000000..751302d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/create_like.rs @@ -0,0 +1,57 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Distribution, TensorData}; + +#[test] +fn should_support_zeros_like() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let tensor = tensor.zeros_like(); + let expected = TensorData::from([[[0., 0., 0.], [0., 0., 0.]], [[0., 0., 0.], [0., 0., 0.]]]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_ones_like() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let tensor = tensor.ones_like(); + let expected = TensorData::from([[[1., 1., 1.], [1., 1., 1.]], [[1., 1., 1.], [1., 1., 1.]]]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_randoms_like() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let tensor = tensor.random_like(Distribution::Uniform(0.99999, 1.)); + let expected = TensorData::from([[[1., 1., 1.], [1., 1., 1.]], [[1., 1., 1.], [1., 1., 1.]]]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/cross.rs b/crates/burn-backend-tests/tests/tensor/float/ops/cross.rs new file mode 100644 index 0000000..1a45315 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/cross.rs @@ -0,0 +1,148 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_cross_3d_last_dim() { + let tensor_1 = TestTensor::<2>::from([[1.0, 3.0, -5.0], [2.0, -1.0, 4.0]]); + let tensor_2 = TestTensor::from([[4.0, -2.0, 1.0], [3.0, 5.0, -2.0]]); + + let output = tensor_1.cross(tensor_2, -1); + + output.into_data().assert_eq( + &TensorData::from([[-7.0, -21.0, -14.0], [-18.0, 16.0, 13.0]]), + false, + ); +} + +#[test] +fn test_cross_3d_non_contiguous_last_dim() { + let tensor_1 = TestTensor::<2>::from([[1.0, 3.0, -5.0], [2.0, -1.0, 4.0]]); + let tensor_2 = TestTensor::from([[4.0, 3.0], [-2.0, 5.0], [1.0, -2.0]]); + + let output = tensor_1.cross(tensor_2.permute([1, 0]), -1); + + output.into_data().assert_eq( + &TensorData::from([[-7.0, -21.0, -14.0], [-18.0, 16.0, 13.0]]), + false, + ); +} + +#[test] +fn test_cross_3d_dim0() { + let tensor_1 = TestTensor::<2>::from([[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]]); + let tensor_2 = TestTensor::from([[0.0, 1.0], [0.0, 0.0], [1.0, 0.0]]); + + let output = tensor_1.cross(tensor_2, 0); + + output.into_data().assert_eq( + &TensorData::from([[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]]), + false, + ); +} + +#[test] +fn test_cross_4d_middle_dim() { + // Shape [1, 3, 2]; cross along dim=1 should match the corresponding + // permuted last-dim cross. + let tensor_1 = TestTensor::<3>::from([[[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]]]); + let tensor_2 = TestTensor::from([[[0.0, 1.0], [0.0, 0.0], [1.0, 0.0]]]); + + let output = tensor_1.cross(tensor_2, 1); + + output.into_data().assert_eq( + &TensorData::from([[[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]]]), + false, + ); +} + +#[test] +fn test_cross_non_last_dim_broadcast() { + // Broadcast on a non-last cross dim: lhs shape [3, 1] vs rhs shape [3, 4] + // crossed along dim=0 must equal the same op on the permuted [1, 3] vs + // [4, 3] along dim=1 (last) and then permuted back. + let lhs = TestTensor::<2>::from([[1.0], [2.0], [3.0]]); + let rhs = TestTensor::<2>::from([ + [4.0, 5.0, 6.0, 7.0], + [8.0, 9.0, 10.0, 11.0], + [12.0, 13.0, 14.0, 15.0], + ]); + + let non_last = lhs.clone().cross(rhs.clone(), 0); + let last_dim = lhs + .permute([1, 0]) + .cross(rhs.permute([1, 0]), 1) + .permute([1, 0]); + + non_last.into_data().assert_eq(&last_dim.into_data(), false); +} + +#[test] +fn test_cross_non_last_dim_matches_permuted_last_dim() { + // Cross on a non-last dim must equal: permute -> cross on last dim -> + // permute back. Here we cross [N, 3] along dim=1 (last) and along dim=0 + // after a transpose of the same data. + let a = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let b = TestTensor::<2>::from([[7.0, 8.0, 9.0], [1.0, 0.0, -1.0]]); + + let last_dim = a.clone().cross(b.clone(), 1); + let non_last = a.permute([1, 0]).cross(b.permute([1, 0]), 0); + + last_dim + .into_data() + .assert_eq(&non_last.permute([1, 0]).into_data(), false); +} + +#[test] +fn test_cross_3d_broadcast() { + let tensor_1 = TestTensor::<2>::from([[1.0, 3.0, -5.0]]); + let tensor_2 = TestTensor::from([[4.0, -2.0, 1.0], [3.0, 5.0, -2.0]]); + + let output = tensor_1.cross(tensor_2, -1); + + output.into_data().assert_eq( + &TensorData::from([[-7.0, -21.0, -14.0], [19.0, -13.0, -4.0]]), + false, + ); +} + +#[test] +fn test_cross_4d_last_dim() { + let tensor_1 = TestTensor::<3>::from([[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]]); + let tensor_2 = TestTensor::from([[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]]); + + let output = tensor_1.cross(tensor_2, -1); + + output.into_data().assert_eq( + &TensorData::from([[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]]]), + false, + ); +} + +// Helper to compute expected cross product for 2-D (N × 3) tensors. +fn manual_cross(a: &[[f32; 3]], b: &[[f32; 3]]) -> Vec<[f32; 3]> { + a.iter() + .zip(b.iter()) + .map(|(x, y)| { + [ + x[1] * y[2] - x[2] * y[1], + x[2] * y[0] - x[0] * y[2], + x[0] * y[1] - x[1] * y[0], + ] + }) + .collect() +} + +#[test] +fn forward_matches_manual_cross() { + let a_raw = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]; + let b_raw = [[7.0, 8.0, 9.0], [1.0, 0.0, -1.0]]; + let a = TestTensor::<2>::from(a_raw); + let b = TestTensor::<2>::from(b_raw); + + let out = a.cross(b.clone(), 1); + let expected_vec = manual_cross(&a_raw, &b_raw); + let expected: [[f32; 3]; 2] = [expected_vec[0], expected_vec[1]]; + + out.into_data() + .assert_eq(&TensorData::from(expected), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/cumulative.rs b/crates/burn-backend-tests/tests/tensor/float/ops/cumulative.rs new file mode 100644 index 0000000..cba4844 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/cumulative.rs @@ -0,0 +1,202 @@ +use super::*; +use burn_tensor::TensorData; + +#[cfg(feature = "flex")] +use burn_tensor::ElementConversion; + +#[test] +fn test_cumsum_float_dim_0() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let output = tensor.cumsum(0); + + output + .into_data() + .assert_eq(&TensorData::from([[1.0, 2.0, 3.0], [5.0, 7.0, 9.0]]), false); +} + +#[test] +fn test_cumsum_float_dim_1() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let output = tensor.cumsum(1); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 3.0, 6.0], [4.0, 9.0, 15.0]]), + false, + ); +} + +#[test] +fn test_cumsum_non_contiguous() { + let tensor = TestTensor::<2>::from([[1., 2.], [3., 4.]]).swap_dims(0, 1); + + let output = tensor.cumsum(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1., 4.], [2., 6.]]), false); +} + +#[test] +fn test_cumsum_float_3d() { + let tensor = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]); + + let output = tensor.cumsum(2); + + output.into_data().assert_eq( + &TensorData::from([[[1.0, 3.0], [3.0, 7.0]], [[5.0, 11.0], [7.0, 15.0]]]), + false, + ); +} + +#[test] +fn test_cumprod_float_dim_0() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let output = tensor.cumprod(0); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 2.0, 3.0], [4.0, 10.0, 18.0]]), + false, + ); +} + +#[test] +fn test_cumprod_float_dim_1() { + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let output = tensor.cumprod(1); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 2.0, 6.0], [4.0, 20.0, 120.0]]), + false, + ); +} + +#[test] +fn test_cumprod_float_3d() { + let tensor = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]); + + let output = tensor.cumprod(2); + + output.into_data().assert_eq( + &TensorData::from([[[1.0, 2.0], [3.0, 12.0]], [[5.0, 30.0], [7.0, 56.0]]]), + false, + ); +} + +#[test] +fn test_cummin_float_dim_0() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 4.0], [2.0, 5.0, 1.0]]); + + let output = tensor.cummin(0); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 1.0, 4.0], [2.0, 1.0, 1.0]]), false); +} + +#[test] +fn test_cummin_float_dim_1() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 4.0], [2.0, 5.0, 1.0]]); + + let output = tensor.cummin(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 1.0, 1.0], [2.0, 2.0, 1.0]]), false); +} + +#[test] +fn test_cummin_float_3d() { + let tensor = TestTensor::<3>::from([[[4.0, 2.0], [3.0, 1.0]], [[5.0, 6.0], [7.0, 8.0]]]); + + let output = tensor.cummin(2); + + output.into_data().assert_eq( + &TensorData::from([[[4.0, 2.0], [3.0, 1.0]], [[5.0, 5.0], [7.0, 7.0]]]), + false, + ); +} + +#[test] +fn test_cummax_float_dim_0() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 4.0], [1.0, 5.0, 2.0]]); + + let output = tensor.cummax(0); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 1.0, 4.0], [3.0, 5.0, 4.0]]), false); +} + +#[test] +fn test_cummax_float_dim_1() { + let tensor = TestTensor::<2>::from([[3.0, 1.0, 4.0], [1.0, 5.0, 2.0]]); + + let output = tensor.cummax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 3.0, 4.0], [1.0, 5.0, 5.0]]), false); +} + +#[test] +fn test_cummax_float_3d() { + let tensor = TestTensor::<3>::from([[[1.0, 3.0], [2.0, 4.0]], [[5.0, 2.0], [6.0, 1.0]]]); + + let output = tensor.cummax(2); + + output.into_data().assert_eq( + &TensorData::from([[[1.0, 3.0], [2.0, 4.0]], [[5.0, 5.0], [6.0, 6.0]]]), + false, + ); +} + +// NaN-propagation tests below. Only run under the `flex` backend +// feature; other burn backends follow IEEE 754 min/max and drop NaN. +// Positive-gate form because the default CI build doesn't set +// identifying feature flags on burn-backend-tests. See issue #4814. +#[cfg(feature = "flex")] +#[test] +fn test_cummin_nan_propagation() { + // Once NaN appears, cummin propagates it forward. + let tensor = TestTensor::<1>::from([3.0, f32::NAN, 1.0, 2.0]); + + let output = tensor.cummin(0); + + let data: Vec = output.into_data().to_vec().unwrap(); + assert_eq!(data[0], 3.0.elem::()); + assert!(data[1].is_nan()); + assert!(data[2].is_nan()); + assert!(data[3].is_nan()); +} + +#[cfg(feature = "flex")] +#[test] +fn test_cummax_nan_propagation() { + let tensor = TestTensor::<1>::from([1.0, f32::NAN, 5.0, 2.0]); + + let output = tensor.cummax(0); + + let data: Vec = output.into_data().to_vec().unwrap(); + assert_eq!(data[0], 1.0.elem::()); + assert!(data[1].is_nan()); + assert!(data[2].is_nan()); + assert!(data[3].is_nan()); +} + +#[cfg(feature = "flex")] +#[test] +fn test_cummin_nan_at_start() { + // NaN on the first element should poison the entire output. + let tensor = TestTensor::<1>::from([f32::NAN, 1.0, 2.0]); + + let output = tensor.cummin(0); + + let data: Vec = output.into_data().to_vec().unwrap(); + assert!(data[0].is_nan()); + assert!(data[1].is_nan()); + assert!(data[2].is_nan()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/div.rs b/crates/burn-backend-tests/tests/tensor/float/ops/div.rs new file mode 100644 index 0000000..c19e6aa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/div.rs @@ -0,0 +1,49 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_div_ops() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data_2 = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 / tensor_2; + let expected = TensorData::from([[0.0, 1.0, 1.0], [1.0, 1.0, 1.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_div_broadcast() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0]]); + let data_2 = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 / tensor_2; + + output.into_data().assert_eq( + &TensorData::from([[0.0, 1.0, 1.0], [0.0, 0.25, 0.4]]), + false, + ); +} + +#[test] +fn should_support_div_scalar_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let scalar = 2.0; + let device = Default::default(); + let tensor = TestTensor::<2>::from_data(data, &device); + + let output = tensor / scalar; + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 0.5, 1.0], [1.5, 2.0, 2.5]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/dot.rs b/crates/burn-backend-tests/tests/tensor/float/ops/dot.rs new file mode 100644 index 0000000..33c03e5 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/dot.rs @@ -0,0 +1,35 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_float() { + let device = Default::default(); + let tensor_1 = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &device); + let tensor_2 = TestTensor::<1>::from_data([0.0, -1.0, 4.0], &device); + + let output = tensor_1.dot(tensor_2); + let expected = TensorData::from([10.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int() { + let device = Default::default(); + let tensor_1 = TestTensor::<1>::from_data([1, 2, 3], &device); + let tensor_2 = TestTensor::<1>::from_data([0, -1, 4], &device); + + let output = tensor_1.dot(tensor_2); + let expected = TensorData::from([10]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn test_panics_for_different_sizes() { + let device = Default::default(); + let tensor_1 = TestTensor::<1>::from_data([1, 2], &device); + let tensor_2 = TestTensor::<1>::from_data([1, 2, 3], &device); + let _output = tensor_1.dot(tensor_2); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/erf.rs b/crates/burn-backend-tests/tests/tensor/float/ops/erf.rs new file mode 100644 index 0000000..76ab84e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/erf.rs @@ -0,0 +1,34 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_erf_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.erf(); + let expected = TensorData::from([[0.0000, 0.8427, 0.99532], [0.99998, 1.0000, 1.0000]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn should_support_erf_ops_with_negative_number() { + let data = TensorData::from([[-0.056, -0.043, -0.089], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.erf(); + let expected = TensorData::from([ + [-0.06312324, -0.048490416, -0.10016122], + [0.99998, 1.0000, 1.0000], + ]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(3e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/exp.rs b/crates/burn-backend-tests/tests/tensor/float/ops/exp.rs new file mode 100644 index 0000000..f881a99 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/exp.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use core::f32::consts::E; + +#[test] +fn should_support_exp_transposed() { + // [[0, 1], [2, 3]] transposed -> [[0, 2], [1, 3]] + let tensor = TestTensor::<2>::from([[0.0, 1.0], [2.0, 3.0]]); + let transposed = tensor.transpose(); + + let output = transposed.exp(); + let expected = TensorData::from([[1.0, E * E], [E, E * E * E]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_exp_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.exp(); + let expected = TensorData::from([[1.0, 2.71830, 7.3891], [20.0855, 54.5981, 148.4132]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/expand.rs b/crates/burn-backend-tests/tests/tensor/float/ops/expand.rs new file mode 100644 index 0000000..6bc2f5c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/expand.rs @@ -0,0 +1,138 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn expand_2d() { + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &Default::default()); + let output = tensor.expand([3, 3]); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]), + false, + ); + + let tensor = TestTensor::<1>::from_data([4.0, 7.0, 2.0, 3.0], &Default::default()); + let output = tensor.expand([2, 4]); + + output.into_data().assert_eq( + &TensorData::from([[4.0, 7.0, 2.0, 3.0], [4.0, 7.0, 2.0, 3.0]]), + false, + ); +} + +#[test] +fn expand_3d() { + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &Default::default()); + let output = tensor.expand([3, 2, 2]); + let expected = TensorData::from([ + [[1.0, 2.0], [3.0, 4.0]], + [[1.0, 2.0], [3.0, 4.0]], + [[1.0, 2.0], [3.0, 4.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn expand_higher_dimensions() { + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0]], &Default::default()); + let output = tensor.expand([2, 3, 4]); + let expected = TensorData::from([ + [ + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + ], + [ + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn expand_sum_3d() { + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &Default::default()); + let output = tensor.expand([3, 2, 2]).sum_dim(0); + let expected = TensorData::from([[[3.0, 6.0], [9.0, 12.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn broadcast_single() { + let tensor = TestTensor::<1>::from_data([1.0], &Default::default()); + let output = tensor.expand([2, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), false); +} + +#[test] +#[should_panic] +fn should_fail_expand_incompatible_shapes() { + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &Default::default()); + let _expanded_tensor = tensor.expand([2, 2]); +} + +#[test] +fn expand_after_transpose() { + // [[1, 2], [3, 4]] transposed -> [[1, 3], [2, 4]] then expanded to [3, 2, 2]. + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + + let output = tensor.expand([3, 2, 2]); + + output.into_data().assert_eq( + &TensorData::from([ + [[1.0, 3.0], [2.0, 4.0]], + [[1.0, 3.0], [2.0, 4.0]], + [[1.0, 3.0], [2.0, 4.0]], + ]), + false, + ); +} + +#[test] +fn expand_after_flip_1d() { + // [1,2,3] flipped -> [3,2,1] then expanded to [2, 3]. + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0]).flip([0]); + + let output = tensor.expand([2, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 2.0, 1.0], [3.0, 2.0, 1.0]]), false); +} + +#[test] +fn expand_after_flip_2d() { + // [[1,2],[3,4]] axis-0 flipped -> [[3,4],[1,2]] then expanded to [3,2,2]. + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).flip([0]); + + let output = tensor.expand([3, 2, 2]); + + output.into_data().assert_eq( + &TensorData::from([ + [[3.0, 4.0], [1.0, 2.0]], + [[3.0, 4.0], [1.0, 2.0]], + [[3.0, 4.0], [1.0, 2.0]], + ]), + false, + ); +} + +#[test] +fn expand_after_narrow() { + // [0,1,2,3,4] narrowed to [1,2,3] then expanded to [2, 3]. + let tensor = TestTensor::<1>::from([0.0, 1.0, 2.0, 3.0, 4.0]).narrow(0, 1, 3); + + let output = tensor.expand([2, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/finite.rs b/crates/burn-backend-tests/tests/tensor/float/ops/finite.rs new file mode 100644 index 0000000..eb21c9a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/finite.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn is_finite() { + let all_finite = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let all_finite_expected = TestTensorBool::<2>::from([[true, true, true], [true, true, true]]); + + let with_inf_nan = TestTensor::<2>::from([ + [0.0, f32::INFINITY, f32::NAN], + [f32::NEG_INFINITY, f32::NAN, 5.0], + ]); + let with_inf_nan_expected = + TestTensorBool::<2>::from([[true, false, false], [false, false, true]]); + + all_finite_expected + .into_data() + .assert_eq(&all_finite.is_finite().into_data(), false); + + with_inf_nan + .is_finite() + .into_data() + .assert_eq(&with_inf_nan_expected.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/flatten.rs b/crates/burn-backend-tests/tests/tensor/float/ops/flatten.rs new file mode 100644 index 0000000..f34a96c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/flatten.rs @@ -0,0 +1,64 @@ +use super::*; +use burn_tensor::Shape; + +/// Test if the function can successfully flatten a 4D tensor to a 1D tensor. +#[test] +fn should_flatten_to_1d() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let flattened_tensor: TestTensor<1> = tensor.flatten(0, 3); + let expected_shape = Shape::new([120]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully flatten the middle dimensions of a 4D tensor. +#[test] +fn should_flatten_middle() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let flattened_tensor: TestTensor<3> = tensor.flatten(1, 2); + let expected_shape = Shape::new([2, 12, 5]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully flatten the first dimensions of a 4D tensor. +#[test] +fn should_flatten_begin() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let flattened_tensor: TestTensor<2> = tensor.flatten(0, 2); + let expected_shape = Shape::new([24, 5]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully flatten the last dimensions of a 4D tensor. +#[test] +fn should_flatten_end() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let flattened_tensor: TestTensor<2> = tensor.flatten(1, 3); + let expected_shape = Shape::new([2, 60]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} + +/// Test if the function can flatten negative indices. +#[test] +fn should_flatten_end_negative_indices() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let flattened_tensor: TestTensor<2> = tensor.flatten(-3, -1); + let expected_shape = Shape::new([2, 60]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} + +/// Test if the function panics when the start dimension is greater than the end dimension. +#[test] +#[should_panic] +fn should_flatten_panic() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let _flattened_tensor: TestTensor<2> = tensor.flatten(2, 0); +} + +#[test] +#[should_panic] +fn not_enough_destination_dimension() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 5, 15]), &Default::default()); + let flattened_tensor: TestTensor<1> = tensor.flatten(1, 2); + let expected_shape = Shape::new([75]); + assert_eq!(flattened_tensor.shape(), expected_shape); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/flip.rs b/crates/burn-backend-tests/tests/tensor/float/ops/flip.rs new file mode 100644 index 0000000..4448ea6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/flip.rs @@ -0,0 +1,48 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn flip_float() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .float(); + + let flipped = tensor.clone().flip([0, 2]); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).flip((0, 2)).float() + let expected = TensorData::from([ + [ + [15., 14., 13., 12.], + [19., 18., 17., 16.], + [23., 22., 21., 20.], + ], + [[3., 2., 1., 0.], [7., 6., 5., 4.], [11., 10., 9., 8.]], + ]); + + flipped.into_data().assert_eq(&expected, false); + + // Test with no flip + let flipped = tensor.clone().flip([]); + tensor.into_data().assert_eq(&flipped.into_data(), false); +} + +#[test] +#[should_panic] +fn flip_duplicated_axes() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with a duplicated axis + let _ = tensor.clone().flip([0, 0, 1]); +} + +#[test] +#[should_panic] +fn flip_out_of_bound_axis() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with an out of bound axis + let _ = tensor.clone().flip([3, 0, 1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/floor.rs b/crates/burn-backend-tests/tests/tensor/float/ops/floor.rs new file mode 100644 index 0000000..1c29e9d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/floor.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_floor_ops() { + let data = TensorData::from([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.floor(); + let expected = TensorData::from([[24., 87., 76.], [59., 43., 94.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/fmod.rs b/crates/burn-backend-tests/tests/tensor/float/ops/fmod.rs new file mode 100644 index 0000000..5962f31 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/fmod.rs @@ -0,0 +1,295 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ElementConversion, TensorData}; + +#[allow(unused_imports)] // f16 +use num_traits::Float; + +#[test] +fn should_support_fmod_ops() { + let dividend = TensorData::from([[5.3, -5.3], [7.5, -7.5]]); + let divisor = TensorData::from([[2.0, 2.0], [3.0, 3.0]]); + + let dividend_tensor = TestTensor::<2>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<2>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([[1.3, -1.3], [1.5, -1.5]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_fmod_scalar() { + let data = TensorData::from([5.3, -5.3, 7.5, -7.5]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.fmod_scalar(2.0); + let expected = TensorData::from([1.3, -1.3, 1.5, -1.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_positive_dividend_positive_divisor() { + let dividend = TensorData::from([10.0, 7.5, 3.8, 1.2]); + let divisor = TensorData::from([3.0, 2.0, 1.5, 0.7]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([1.0, 1.5, 0.8, 0.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_negative_dividend() { + let dividend = TensorData::from([-10.0, -7.5, -3.8, -1.2]); + let divisor = TensorData::from([3.0, 2.0, 1.5, 0.7]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([-1.0, -1.5, -0.8, -0.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_mixed_signs() { + let dividend = TensorData::from([5.3, -5.3, 5.3, -5.3]); + let divisor = TensorData::from([2.0, 2.0, -2.0, -2.0]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + // fmod result has same sign as dividend + let expected = TensorData::from([1.3, -1.3, 1.3, -1.3]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_infinity_dividend() { + // If x is ±∞ and y is not NaN, NaN is returned + let dividend = TensorData::from([ + f32::INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + ]); + let divisor = TensorData::from([2.0, 3.0, -2.0, -3.0]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let data = output.into_data(); + let values = data.as_slice::().unwrap(); + + // All results should be NaN + assert!(values[0].is_nan(), "fmod(inf, 2.0) should be NaN"); + assert!(values[1].is_nan(), "fmod(-inf, 3.0) should be NaN"); + assert!(values[2].is_nan(), "fmod(inf, -2.0) should be NaN"); + assert!(values[3].is_nan(), "fmod(-inf, -3.0) should be NaN"); +} + +#[test] +fn should_handle_zero_divisor() { + // If y is ±0 and x is not NaN, NaN should be returned + let dividend = TensorData::from([5.3, -5.3, 0.0, 1.0]); + let divisor = TensorData::from([0.0, -0.0, 0.0, -0.0]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let data = output.into_data(); + let values = data.as_slice::().unwrap(); + + // All results should be NaN + assert!(values[0].is_nan(), "fmod(5.3, 0.0) should be NaN"); + assert!(values[1].is_nan(), "fmod(-5.3, -0.0) should be NaN"); + assert!(values[2].is_nan(), "fmod(0.0, 0.0) should be NaN"); + assert!(values[3].is_nan(), "fmod(1.0, -0.0) should be NaN"); +} + +#[test] +fn should_handle_infinity_divisor() { + // If y is ±∞ and x is finite, x is returned + let dividend = TensorData::from([5.3, -5.3, 0.0, -0.0]); + let divisor = TensorData::from([ + f32::INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + ]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([5.3, -5.3, 0.0, -0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_nan_arguments() { + // If either argument is NaN, NaN is returned + let dividend = TensorData::from([f32::NAN, 5.3, f32::NAN, 0.0]); + let divisor = TensorData::from([2.0, f32::NAN, f32::NAN, 3.0]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let data = output.into_data(); + let values = data.as_slice::().unwrap(); + + assert!(values[0].is_nan(), "fmod(NaN, 2.0) should be NaN"); + assert!(values[1].is_nan(), "fmod(5.3, NaN) should be NaN"); + assert!(values[2].is_nan(), "fmod(NaN, NaN) should be NaN"); + assert!(!values[3].is_nan(), "fmod(0.0, 3.0) should be 0.0"); +} + +#[test] +fn should_handle_negative_zero() { + // If x is -0 and y is greater than zero, either +0 or -0 may be returned + let dividend = TensorData::from([-0.0_f32]); + let divisor = TensorData::from([2.0_f32]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let data = output.into_data(); + let values = data.as_slice::().unwrap(); + + // Result should be zero (either +0 or -0 is acceptable) + assert_eq!( + values[0], + 0.0f32.elem::(), + "fmod(-0, 2.0) should be zero" + ); +} + +#[test] +fn should_support_fmod_broadcasting_2d() { + // Test broadcasting: 1x2 with 3x2 + let dividend = TensorData::from([[5.3, -5.3]]); // Shape: 1x2 + let divisor = TensorData::from([[2.0, 2.0], [3.0, 3.0], [1.5, 1.5]]); // Shape: 3x2 + + let dividend_tensor = TestTensor::<2>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<2>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([ + [1.3, -1.3], // 5.3 % 2.0, -5.3 % 2.0 + [2.3, -2.3], // 5.3 % 3.0, -5.3 % 3.0 + [0.8, -0.8], // 5.3 % 1.5, -5.3 % 1.5 + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_fmod_broadcasting_3d() { + // Test broadcasting: 1x1x3 with 2x1x3 + let dividend = TensorData::from([[[5.0, -7.0, 8.0]]]); // Shape: 1x1x3 + let divisor = TensorData::from([[[3.0, 3.0, 3.0]], [[4.0, 4.0, 4.0]]]); // Shape: 2x1x3 + + let dividend_tensor = TestTensor::<3>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<3>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([ + [[2.0, -1.0, 2.0]], // 5.0 % 3.0, -7.0 % 3.0, 8.0 % 3.0 + [[1.0, -3.0, 0.0]], // 5.0 % 4.0, -7.0 % 4.0, 8.0 % 4.0 + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_fmod_scalar_broadcasting() { + // Test scalar operation with different shapes + let data = TensorData::from([[5.3, -5.3, 7.5], [-7.5, 10.0, -10.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.fmod_scalar(3.0); + let expected = TensorData::from([[2.3, -2.3, 1.5], [-1.5, 1.0, -1.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_edge_case_values() { + // Test various edge cases + let dividend = TensorData::from([0.0, -0.0, 1e-10, -1e-10, 10.0, -10.0]); + let divisor = TensorData::from([1.0, 1.0, 1.0, 1.0, 3.0, 3.0]); + + let dividend_tensor = TestTensor::<1>::from_data(dividend, &Default::default()); + let divisor_tensor = TestTensor::<1>::from_data(divisor, &Default::default()); + + let output = dividend_tensor.fmod(divisor_tensor); + let expected = TensorData::from([0.0, -0.0, 1e-10, -1e-10, 1.0, -1.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_special_scalar_cases() { + // Test scalar operations with special values + let data = TensorData::from([5.3, -5.3, 0.0, -0.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + // Test with infinity divisor + let output_inf = tensor.clone().fmod_scalar(f32::INFINITY); + let expected_inf = TensorData::from([5.3, -5.3, 0.0, -0.0]); + output_inf + .into_data() + .assert_approx_eq::(&expected_inf, Tolerance::default()); + + // Test with very small divisor + // Doesn't work if the test divisor is subnormal + if FloatElem::MIN_POSITIVE > 1e-5f32.elem::() { + return; + } + + let output_small = tensor.clone().fmod_scalar(1e-5); + let data = output_small.into_data(); + let values = data.as_slice::().unwrap(); + + // let expected = TensorData::from([0.0, 0.0, 0.0, 0.0]); + + // Results should be very small remainders + assert!(values[0].abs() < 1e-5f32.elem::()); + assert!(values[1].abs() < 1e-5f32.elem::()); + assert_eq!(values[2], 0.0f32.elem::()); + assert_eq!(values[3], 0.0f32.elem::()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/full.rs b/crates/burn-backend-tests/tests/tensor/float/ops/full.rs new file mode 100644 index 0000000..87d824c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/full.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::{DType, TensorData}; + +#[test] +fn test_data_full() { + let tensor = TensorData::full([2, 3], 2.0); + + tensor.assert_eq(&TensorData::from([[2.0, 2.0, 2.0], [2.0, 2.0, 2.0]]), false); +} + +#[test] +fn test_tensor_full() { + let device = Default::default(); + let tensor = TestTensor::<2>::full([2, 3], 2.1, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([[2.1, 2.1, 2.1], [2.1, 2.1, 2.1]]), false); +} + +#[test] +fn test_tensor_full_options() { + let tensor = TestTensor::<2>::full([2, 3], 2.1, (&Default::default(), DType::F32)); + assert_eq!(tensor.dtype(), DType::F32); + + tensor + .into_data() + .assert_eq(&TensorData::from([[2.1, 2.1, 2.1], [2.1, 2.1, 2.1]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter.rs b/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter.rs new file mode 100644 index 0000000..248ffd0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter.rs @@ -0,0 +1,195 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_gather_1d_dim0() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 1.0, 2.0], &device); + let indices = TestTensorInt::from_ints([1, 1, 0, 1, 2], &device); + + let output = tensor.gather(0, indices); + + output + .into_data() + .assert_eq(&TensorData::from([1.0, 1.0, 0.0, 1.0, 2.0]), false); +} + +#[test] +fn should_gather_2d_dim0() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_ints([[0, 1, 0], [1, 0, 1]], &device); + + let output = tensor.gather(0, indices); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 4.0, 2.0], [3.0, 1.0, 5.0]]), false); +} + +#[test] +fn should_gather_2d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_ints([[2, 1, 0, 0], [2, 0, 1, 2]], &device); + + let output = tensor.gather(1, indices); + + output.into_data().assert_eq( + &TensorData::from([[2.0, 1.0, 0.0, 0.0], [5.0, 3.0, 4.0, 5.0]]), + false, + ); +} + +#[test] +fn should_gather_3d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &device, + ); + let indices = + TestTensorInt::from_ints([[[1, 0, 0], [0, 1, 0]], [[0, 0, 1], [0, 1, 1]]], &device); + + let output = tensor.gather(1, indices); + let expected = TensorData::from([ + [[3.0, 1.0, 2.0], [0.0, 4.0, 2.0]], + [[6.0, 7.0, 11.0], [6.0, 10.0, 11.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_gather_2d_only_1dim() { + let device = Default::default(); + let tensor = TestTensor::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[1, 2]], &device).reshape([2, 1]); + + let output = tensor.gather(1, indices); + + output + .into_data() + .assert_eq(&TensorData::from([[1.0], [5.0]]), false); +} + +#[test] +fn should_scatter_add_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 0.0, 0.0], &device); + let values = TestTensor::from_data([5.0, 4.0, 3.0], &device); + let indices = TestTensorInt::from_ints([1, 0, 2], &device); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([4.0, 5.0, 3.0]), false); +} + +#[test] +fn should_scatter_add_2d_dim0() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], &device); + let values = TestTensor::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::from_ints([[1, 0, 1], [1, 1, 0]], &device); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 2.0, 6.0], [5.0, 5.0, 3.0]]), false); +} + +#[test] +fn should_scatter_add_2d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], &device); + let values = TestTensor::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::from_ints([[1, 0, 2], [1, 2, 0]], &device); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([[2.0, 1.0, 3.0], [6.0, 4.0, 5.0]]), false); +} + +#[test] +fn should_scatter_add_3d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &device, + ); + let values = TestTensor::from_data( + [ + [[12.0, 13.0, 14.0], [15.0, 16.0, 17.0]], + [[18.0, 19.0, 20.0], [21.0, 22.0, 23.0]], + ], + &device, + ); + let indices = + TestTensorInt::from_ints([[[1, 0, 0], [0, 1, 0]], [[0, 0, 1], [0, 1, 1]]], &device); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([ + [[15.0, 14.0, 33.0], [15.0, 20.0, 5.0]], + [[45.0, 26.0, 8.0], [9.0, 32.0, 54.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_scatter_add_2d_dim1_diff_shape() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], &device); + let values = TestTensor::from_data([[1.0], [4.0]], &device); + let indices = TestTensorInt::from_ints([[1], [2]], &device); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 1.0, 0.0], [0.0, 0.0, 4.0]]), false); +} + +#[test] +#[should_panic] +fn scatter_should_panic_on_mismatch_of_shapes() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 0.0, 0.0], &device); + let values = TestTensor::from_data([5.0, 4.0], &device); + let indices = TestTensorInt::from_ints([1, 0, 2], &device); + + tensor.scatter(0, indices, values, IndexingUpdateOp::Add); +} + +#[test] +fn should_scatter_add_3d_dim0_with_index_view() { + // Index tensor expanded from 1D via unsqueeze + repeat_dim — a common + // pattern to scatter whole rows along dim 0. The repeat of a unit dim + // is a broadcast view; reshaping it must keep stride 1 on dim 0, + // otherwise every iteration reads `indices[0]` and all updates pile + // onto one row while the other rows get none. + let device = Default::default(); + let tensor = TestTensor::<3>::from_data([[[1.0]], [[2.0]], [[3.0]], [[4.0]]], &device); + let values = TestTensor::<3>::from_data([[[10.0]], [[20.0]]], &device); + let indices = TestTensorInt::<1>::from_ints([2, 0], &device); + let indices: TestTensorInt<2> = indices.unsqueeze_dim(1).repeat_dim(1, 1); + let indices: TestTensorInt<3> = indices.unsqueeze_dim(2).repeat_dim(2, 1); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output.into_data().assert_eq( + &TensorData::from([[[21.0]], [[2.0]], [[13.0]], [[4.0]]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter_nd.rs b/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter_nd.rs new file mode 100644 index 0000000..bab7274 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/gather_scatter_nd.rs @@ -0,0 +1,356 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn test_gather_nd_2d_k_equals_d() { + // Gather single elements from a 2D tensor (K=D=2) + let device = Default::default(); + // data shape: [3, 3] + let data = + TestTensor::<2>::from_floats([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]], &device); + // indices shape: [3, 2] => K=2, M=2, DV = M-1+D-K = 1+2-2 = 1 + // Each row is [row, col] + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 2], [2, 0]], &device); + + let output: TestTensor<1> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([1.0, 5.0, 6.0]), false); +} + +#[test] +fn test_gather_nd_3d_k1_slices() { + // Gather 2D slices from a 3D tensor (K=1) + let device = Default::default(); + // data shape: [2, 3, 4] + let data = TestTensor::<3>::from_floats( + [ + [ + [0.0, 1.0, 2.0, 3.0], + [4.0, 5.0, 6.0, 7.0], + [8.0, 9.0, 10.0, 11.0], + ], + [ + [12.0, 13.0, 14.0, 15.0], + [16.0, 17.0, 18.0, 19.0], + [20.0, 21.0, 22.0, 23.0], + ], + ], + &device, + ); + // indices shape: [2, 1] => K=1, M=2, DV = 1 + 3 - 1 = 3 + // Each index selects a whole 2D slice along dim 0 + let indices = TestTensorInt::<2>::from_ints([[1], [0]], &device); + + let output: TestTensor<3> = data.gather_nd(indices); + + output.into_data().assert_eq( + &TensorData::from([ + [ + [12.0, 13.0, 14.0, 15.0], + [16.0, 17.0, 18.0, 19.0], + [20.0, 21.0, 22.0, 23.0], + ], + [ + [0.0, 1.0, 2.0, 3.0], + [4.0, 5.0, 6.0, 7.0], + [8.0, 9.0, 10.0, 11.0], + ], + ]), + false, + ); +} + +#[test] +fn test_gather_nd_3d_k_equals_d() { + // Gather scalar elements from a 3D tensor (K=D=3) + let device = Default::default(); + let data = TestTensor::<3>::from_floats( + [[[0.0, 1.0], [2.0, 3.0]], [[4.0, 5.0], [6.0, 7.0]]], + &device, + ); + // indices shape: [2, 3] => K=3, M=2, DV = 1+3-3 = 1 + let indices = TestTensorInt::<2>::from_ints([[0, 0, 1], [1, 1, 0]], &device); + + let output: TestTensor<1> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([1.0, 6.0]), false); +} + +#[test] +fn test_gather_nd_batch() { + // Batch dimension in indices + let device = Default::default(); + // data shape: [2, 3] + let data = TestTensor::<2>::from_floats([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], &device); + // indices shape: [3, 2] => K=2, M=2, DV = 1+2-2 = 1 + let indices = TestTensorInt::<2>::from_ints([[0, 0], [0, 2], [1, 1]], &device); + + let output: TestTensor<1> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([10.0, 30.0, 50.0]), false); +} + +#[test] +fn test_scatter_nd_assign_2d() { + let device = Default::default(); + // data shape: [3, 3], initialized to zeros + let data = TestTensor::<2>::zeros([3, 3], &device); + // indices shape: [2, 2] => K=2, M=2, DV = 1+2-2 = 1 + let indices = TestTensorInt::<2>::from_ints([[0, 1], [2, 0]], &device); + // values shape: [2] (scalar per index tuple) + let values = TestTensor::<1>::from_floats([10.0, 20.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output.into_data().assert_eq( + &TensorData::from([[0.0, 10.0, 0.0], [0.0, 0.0, 0.0], [20.0, 0.0, 0.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_add_2d() { + let device = Default::default(); + let data = + TestTensor::<2>::from_floats([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 1], [2, 0]], &device); + let values = TestTensor::<1>::from_floats([10.0, 20.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Add); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 11.0, 1.0], [1.0, 1.0, 1.0], [21.0, 1.0, 1.0]]), + false, + ); +} + +// Duplicate indices are non-deterministic on GPU; only test on CPU backends. +#[cfg(feature = "ndarray")] +#[test] +fn test_scatter_nd_add_duplicate_indices() { + let device = Default::default(); + let data = TestTensor::<2>::zeros([2, 3], &device); + // Both index tuples point to the same location + let indices = TestTensorInt::<2>::from_ints([[0, 1], [0, 1]], &device); + let values = TestTensor::<1>::from_floats([5.0, 3.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Add); + + // Values should accumulate: 5.0 + 3.0 = 8.0 + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 8.0, 0.0], [0.0, 0.0, 0.0]]), false); +} + +#[test] +fn test_scatter_nd_3d_slices() { + // Scatter 1D slices into a 3D tensor (K=1) + let device = Default::default(); + // data shape: [2, 3] + let data = TestTensor::<2>::zeros([2, 3], &device); + // indices shape: [2, 1] => K=1, M=2, DV = 1+2-1 = 2 + // Each index selects a row + let indices = TestTensorInt::<2>::from_ints([[0], [1]], &device); + // values shape: [2, 3] + let values = TestTensor::<2>::from_floats([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output.into_data().assert_eq( + &TensorData::from([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_mul() { + let device = Default::default(); + let data = TestTensor::<2>::from_floats([[2.0, 3.0, 4.0], [5.0, 6.0, 7.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 2]], &device); + let values = TestTensor::<1>::from_floats([10.0, 2.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Mul); + + output.into_data().assert_eq( + &TensorData::from([[2.0, 30.0, 4.0], [5.0, 6.0, 14.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_min() { + let device = Default::default(); + let data = TestTensor::<2>::from_floats([[5.0, 10.0], [15.0, 20.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 0], [1, 1]], &device); + let values = TestTensor::<1>::from_floats([3.0, 25.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Min); + + // min(5.0, 3.0) = 3.0; min(20.0, 25.0) = 20.0 + output + .into_data() + .assert_eq(&TensorData::from([[3.0, 10.0], [15.0, 20.0]]), false); +} + +#[test] +fn test_scatter_nd_max() { + let device = Default::default(); + let data = TestTensor::<2>::from_floats([[5.0, 10.0], [15.0, 20.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 0], [1, 1]], &device); + let values = TestTensor::<1>::from_floats([3.0, 25.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Max); + + // max(5.0, 3.0) = 5.0; max(20.0, 25.0) = 25.0 + output + .into_data() + .assert_eq(&TensorData::from([[5.0, 10.0], [15.0, 25.0]]), false); +} + +#[test] +fn test_scatter_nd_batch() { + // Scatter with batch dimensions + let device = Default::default(); + // data shape: [3, 3] + let data = TestTensor::<2>::zeros([3, 3], &device); + // indices shape: [3, 1] => K=1, M=2, DV = 1+2-1 = 2 + let indices = TestTensorInt::<2>::from_ints([[0], [2], [1]], &device); + // values shape: [3, 3] + let values = + TestTensor::<2>::from_floats([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output.into_data().assert_eq( + &TensorData::from([[1.0, 2.0, 3.0], [7.0, 8.0, 9.0], [4.0, 5.0, 6.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_single_element() { + // Single update to a single element in a 1D tensor + let device = Default::default(); + let data = TestTensor::<1>::from_floats([1.0, 2.0, 3.0], &device); + let indices = TestTensorInt::<2>::from_ints([[2]], &device); + let values = TestTensor::<1>::from_floats([99.0], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output + .into_data() + .assert_eq(&TensorData::from([1.0, 2.0, 99.0]), false); +} + +#[test] +fn test_scatter_nd_k1_high_rank() { + // K=1 on a 3D tensor: each index selects a full 2D slice + let device = Default::default(); + let data = TestTensor::<3>::ones([2, 2, 2], &device); + let indices = TestTensorInt::<2>::from_ints([[1]], &device); + let values = TestTensor::<3>::from_floats([[[10.0, 20.0], [30.0, 40.0]]], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output.into_data().assert_eq( + &TensorData::from([[[1.0, 1.0], [1.0, 1.0]], [[10.0, 20.0], [30.0, 40.0]]]), + false, + ); +} + +#[test] +fn test_gather_nd_single_element() { + // Gather a single scalar from a 1D tensor + let device = Default::default(); + let data = TestTensor::<1>::from_floats([10.0, 20.0, 30.0], &device); + let indices = TestTensorInt::<2>::from_ints([[1]], &device); + + let output: TestTensor<1> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([20.0]), false); +} + +#[test] +fn test_gather_scatter_nd_roundtrip() { + // gather_nd then scatter_nd_add back should reconstruct selected rows + let device = Default::default(); + let data = + TestTensor::<2>::from_floats([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], &device); + let indices = TestTensorInt::<2>::from_ints([[0], [2]], &device); + + let gathered: TestTensor<2> = data.clone().gather_nd(indices.clone()); + let zeros = TestTensor::<2>::zeros([3, 3], &device); + let reconstructed: TestTensor<2> = zeros.scatter_nd(indices, gathered, IndexingUpdateOp::Add); + + reconstructed.into_data().assert_eq( + &TensorData::from([[1.0, 2.0, 3.0], [0.0, 0.0, 0.0], [7.0, 8.0, 9.0]]), + false, + ); +} + +#[test] +fn test_scatter_nd_3d_k2_partial_slices() { + // K=2 on 3D data: each index tuple selects a 1D row within a 2D slice + let device = Default::default(); + // data shape: [2, 3, 4] + let data = TestTensor::<3>::zeros([2, 3, 4], &device); + // indices shape: [2, 2] => K=2, M=2, DV = 1+3-2 = 2 + // [0, 1] => data[0, 1, :], [1, 2] => data[1, 2, :] + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 2]], &device); + // values shape: [2, 4] + let values = TestTensor::<2>::from_floats( + [[10.0, 20.0, 30.0, 40.0], [50.0, 60.0, 70.0, 80.0]], + &device, + ); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Add); + + // Only data[0,1,:] and data[1,2,:] should be set + let output_data = output.into_data(); + output_data.assert_eq( + &TensorData::from([ + [ + [0.0, 0.0, 0.0, 0.0], + [10.0, 20.0, 30.0, 40.0], + [0.0, 0.0, 0.0, 0.0], + ], + [ + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [50.0, 60.0, 70.0, 80.0], + ], + ]), + false, + ); +} + +#[test] +fn test_gather_nd_3d_k2_partial_slices() { + // K=2 on 3D data: each index tuple gathers a 1D row + let device = Default::default(); + let data = TestTensor::<3>::from_floats( + [ + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], + [[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]], + ], + &device, + ); + // indices shape: [3, 2] => K=2, M=2, DV = 1+3-2 = 2 + let indices = TestTensorInt::<2>::from_ints([[0, 2], [1, 0], [0, 1]], &device); + + let output: TestTensor<2> = data.gather_nd(indices); + + // [0,2] => [5,6], [1,0] => [7,8], [0,1] => [3,4] + output.into_data().assert_eq( + &TensorData::from([[5.0, 6.0], [7.0, 8.0], [3.0, 4.0]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/grid_sample.rs b/crates/burn-backend-tests/tests/tensor/float/ops/grid_sample.rs new file mode 100644 index 0000000..fe21442 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/grid_sample.rs @@ -0,0 +1,126 @@ +use super::*; +use burn_tensor::{ + TensorData, Tolerance, + ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}, +}; + +/// Tests grid_sample_2d with default options (align_corners=false, zeros padding). +/// +/// For a 3x3 input with grid coordinates: +/// - (0.0, 0.0) maps to pixel (1.0, 1.0) -> center pixel = 4.0 +/// - (-1.0, 0.25) maps to pixel (-0.5, 1.375) -> partially out of bounds +/// - (1.0, 1.0) maps to pixel (2.5, 2.5) -> corner, partially out of bounds +/// - (0.2, -0.8) maps to pixel (1.3, 0.3) -> interpolates around center-top +#[test] +fn should_grid_sample_2d_default() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]]], + &device, + ); + let grid = TestTensor::<4>::from_data( + [[[[0.0, 0.0], [-1.0, 0.25]], [[1.0, 1.0], [0.2, -0.8]]]], + &device, + ); + + let output = tensor.grid_sample_2d(grid, InterpolateMode::Bilinear); + + // Expected values computed with PyTorch grid_sample(align_corners=False, padding_mode='zeros') + let expected = TensorData::from([[[[4.0, 2.0625], [2.0, 1.04]]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +/// Tests grid_sample_2d with align_corners=true and border padding. +/// +/// This is the original Burn semantics before the API change. +#[test] +fn should_grid_sample_2d_align_corners_border() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]]], + &device, + ); + let grid = TestTensor::<4>::from_data( + [[[[0.0, 0.0], [-1.0, 0.25]], [[1.0, 1.0], [0.2, -0.8]]]], + &device, + ); + + let options = GridSampleOptions::new(InterpolateMode::Bilinear) + .with_padding_mode(GridSamplePaddingMode::Border) + .with_align_corners(true); + let output = tensor.grid_sample_2d(grid, options); + + // Expected values computed with PyTorch grid_sample(align_corners=True, padding_mode='border') + let expected = TensorData::from([[[[4.0, 3.75], [8.0, 1.8]]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +/// Tests out-of-bounds grid coordinates with zeros padding. +/// Grid coordinate (0.0, -2.0) maps to pixel (1.0, -2.5) which is completely out of bounds. +#[test] +fn should_pad_zeros_grid_sample_2d() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]]], + &device, + ); + let grid = TestTensor::<4>::from_data([[[[0.0, -2.0]]]], &device); + + let output = tensor.grid_sample_2d(grid, GridSampleOptions::default()); + + // With zeros padding, out-of-bounds samples return 0 + let expected = TensorData::from([[[[0.0]]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +/// Tests out-of-bounds grid coordinates with border padding. +#[test] +fn should_pad_border_grid_sample_2d() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]]], + &device, + ); + let grid = TestTensor::<4>::from_data([[[[0.0, -2.0]]]], &device); + + let options = GridSampleOptions::new(InterpolateMode::Bilinear) + .with_padding_mode(GridSamplePaddingMode::Border); + let output = tensor.grid_sample_2d(grid, options); + + // With border padding, out-of-bounds coordinates are clamped to border + // Grid (0.0, -2.0) with align_corners=false: pixel (1.0, -2.5) -> clamped to (1.0, 0.0) = 1.0 + let expected = TensorData::from([[[[1.0]]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +/// Tests bilinear interpolation with reflection padding. +#[test] +fn should_pad_reflection_grid_sample_2d() { + let device = Default::default(); + let tensor = TestTensor::<4>::from_data( + [[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]]], + &device, + ); + let grid = TestTensor::<4>::from_data( + [[[[0.0, 0.0], [-1.0, 0.25]], [[1.0, 1.0], [0.2, -0.8]]]], + &device, + ); + + let options = GridSampleOptions::new(InterpolateMode::Bilinear) + .with_padding_mode(GridSamplePaddingMode::Reflection); + let output = tensor.grid_sample_2d(grid, options); + + // Expected values computed with PyTorch F.grid_sample(mode='bilinear', padding_mode='reflection', align_corners=False) + let expected = TensorData::from([[[[4.0, 4.125], [8.0, 1.3]]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/hamming_window.rs b/crates/burn-backend-tests/tests/tensor/float/ops/hamming_window.rs new file mode 100644 index 0000000..0d75bb6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/hamming_window.rs @@ -0,0 +1,65 @@ +use super::*; +use burn_tensor::signal::hamming_window; +use burn_tensor::{DType, TensorData, Tolerance}; + +#[test] +fn should_support_hamming_window_periodic() { + let tensor: TestTensor<1> = hamming_window(8, true, &Default::default()); + let expected = TensorData::from([ + 0.086957, 0.220669, 0.543478, 0.866288, 1.0, 0.866288, 0.543478, 0.220669, + ]); + + // Metal has less precise trigonometric functions. + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_hamming_window_symmetric() { + let tensor: TestTensor<1> = hamming_window(8, false, &Default::default()); + let expected = TensorData::from([ + 0.086957, 0.258842, 0.645064, 0.954790, 0.954790, 0.645064, 0.258842, 0.086957, + ]); + + // Metal has less precise trigonometric functions. + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_hamming_window_options_dtype() { + let tensor: TestTensor<1> = hamming_window(4, true, (&Default::default(), DType::F32)); + assert_eq!(tensor.dtype(), DType::F32); +} + +#[test] +fn should_support_hamming_window_empty() { + let tensor: TestTensor<1> = hamming_window(0, true, &Default::default()); + assert_eq!(tensor.shape().dims(), [0]); +} + +#[test] +fn should_handle_hamming_window_size_one_symmetric() { + let tensor: TestTensor<1> = hamming_window(1, false, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_hamming_window_size_one_periodic() { + let tensor: TestTensor<1> = hamming_window(1, true, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/hann_window.rs b/crates/burn-backend-tests/tests/tensor/float/ops/hann_window.rs new file mode 100644 index 0000000..0604617 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/hann_window.rs @@ -0,0 +1,63 @@ +use super::*; +use burn_tensor::signal::hann_window; +use burn_tensor::{DType, TensorData, Tolerance}; + +#[test] +fn should_support_hann_window_periodic() { + let tensor: TestTensor<1> = hann_window(8, true, &Default::default()); + let expected = TensorData::from([0.0, 0.146447, 0.5, 0.853553, 1.0, 0.853553, 0.5, 0.146447]); + + // Metal has less precise trigonometric functions. + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_hann_window_symmetric() { + let tensor: TestTensor<1> = hann_window(8, false, &Default::default()); + let expected = TensorData::from([ + 0.0, 0.188255, 0.611260, 0.950484, 0.950484, 0.611260, 0.188255, 0.0, + ]); + + // Metal has less precise trigonometric functions. + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + tensor + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_hann_window_options_dtype() { + let tensor: TestTensor<1> = hann_window(4, true, (&Default::default(), DType::F32)); + assert_eq!(tensor.dtype(), DType::F32); +} + +#[test] +fn should_support_hann_window_empty() { + let tensor: TestTensor<1> = hann_window(0, true, &Default::default()); + assert_eq!(tensor.shape().dims(), [0]); +} + +#[test] +fn should_handle_hann_window_size_one_symmetric() { + let tensor: TestTensor<1> = hann_window(1, false, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_hann_window_size_one_periodic() { + let tensor: TestTensor<1> = hann_window(1, true, &Default::default()); + let expected = TensorData::from([1.0]); + + tensor + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/hypot.rs b/crates/burn-backend-tests/tests/tensor/float/ops/hypot.rs new file mode 100644 index 0000000..258a3d2 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/hypot.rs @@ -0,0 +1,47 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_support_hypot_basic() { + let data_a = TensorData::from([[3.0, 4.0], [5.0, 12.0]]); + let data_b = TensorData::from([[4.0, 3.0], [12.0, 5.0]]); + let tensor_a = TestTensor::<2>::from_data(data_a, &Default::default()); + let tensor_b = TestTensor::<2>::from_data(data_b, &Default::default()); + + let result = tensor_a.hypot(tensor_b); + let expected = TensorData::from([[5.0, 5.0], [13.0, 13.0]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_hypot_broadcast() { + let data_a = TensorData::from([[3.0, 4.0, 5.0]]); + let data_b = TensorData::from([[4.0], [3.0]]); + let tensor_a = TestTensor::<2>::from_data(data_a, &Default::default()); + let tensor_b = TestTensor::<2>::from_data(data_b, &Default::default()); + + let result = tensor_a.hypot(tensor_b); + let expected = TensorData::from([[5.0, 5.656854, 6.404], [4.2426405, 5.0, 5.831]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_hypot_zero() { + let data_a = TensorData::from([[0.0, 3.0], [0.0, 0.0]]); + let data_b = TensorData::from([[4.0, 0.0], [0.0, 5.0]]); + let tensor_a = TestTensor::<2>::from_data(data_a, &Default::default()); + let tensor_b = TestTensor::<2>::from_data(data_b, &Default::default()); + + let result = tensor_a.hypot(tensor_b); + let expected = TensorData::from([[4.0, 3.0], [0.0, 5.0]]); + + result + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/inf.rs b/crates/burn-backend-tests/tests/tensor/float/ops/inf.rs new file mode 100644 index 0000000..f0c6cef --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/inf.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn is_inf() { + let no_inf = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let no_inf_expected = TestTensorBool::<2>::from([[false, false, false], [false, false, false]]); + + let with_inf = + TestTensor::<2>::from([[0.0, f32::INFINITY, 2.0], [f32::NEG_INFINITY, 4.0, 5.0]]); + let with_inf_expected = TestTensorBool::<2>::from([[false, true, false], [true, false, false]]); + + no_inf + .is_inf() + .into_data() + .assert_eq(&no_inf_expected.into_data(), false); + + with_inf + .is_inf() + .into_data() + .assert_eq(&with_inf_expected.into_data(), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/init.rs b/crates/burn-backend-tests/tests/tensor/float/ops/init.rs new file mode 100644 index 0000000..eaa7b58 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/init.rs @@ -0,0 +1,62 @@ +use super::*; +use burn_tensor::{DType, TensorData}; + +#[test] +fn should_support_float_empty() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::empty(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()) +} + +#[test] +fn should_support_float_empty_options() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::empty(shape, (&Default::default(), DType::F32)); + assert_eq!(tensor.shape(), shape.into()) +} + +#[test] +fn should_support_float_zeros() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::zeros(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[0., 0.], [0., 0.]]), false); +} + +#[test] +fn should_support_float_zeros_options() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::zeros(shape, (&Default::default(), DType::F32)); + assert_eq!(tensor.shape(), shape.into()); + assert_eq!(tensor.dtype(), DType::F32); + + tensor + .into_data() + .assert_eq(&TensorData::from([[0., 0.], [0., 0.]]), false); +} + +#[test] +fn should_support_float_ones() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::ones(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[1., 1.], [1., 1.]]), false); +} + +#[test] +fn should_support_float_ones_options() { + let shape = [2, 2]; + let tensor = TestTensor::<2>::ones(shape, (&Default::default(), DType::F32)); + assert_eq!(tensor.shape(), shape.into()); + assert_eq!(tensor.dtype(), DType::F32); + + tensor + .into_data() + .assert_eq(&TensorData::from([[1., 1.], [1., 1.]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/iter_dim.rs b/crates/burn-backend-tests/tests/tensor/float/ops/iter_dim.rs new file mode 100644 index 0000000..6535566 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/iter_dim.rs @@ -0,0 +1,246 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_1d_iter_last_item() { + let data = [1, 2, 3, 4]; + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_ints(data, &device); + tensor + .iter_dim(0) + .last() + .unwrap() + .into_data() + .assert_eq(&TensorData::from([4]), false); +} + +#[test] +#[should_panic] +fn test_too_high_dimension() { + TestTensor::<1>::zeros([10], &Default::default()).iter_dim(1); +} + +#[test] +fn test_transposed() { + let data = [ + [1., 2., 3., 1., 2.], + [4., 5., 6., 1., 2.], + [7., 8., 9., 1., 2.], + ]; + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + let lhs = tensor.clone().slice([1..2, 0..5]); + let rhs = tensor.transpose().iter_dim(1).nth(1).unwrap(); + assert_eq!( + lhs.into_data().as_slice::().unwrap(), + rhs.into_data().as_slice::().unwrap() + ); +} + +#[test] +fn test_2d_iter_dim() { + let tensor = + TestTensor::<2>::from_data([[3.0, 4.9, 2.0], [2.0, 1.9, 3.0]], &Default::default()); + + let mut iter = tensor.iter_dim(0); + + let iter1 = iter.next().unwrap(); + iter1 + .into_data() + .assert_eq(&TensorData::from([[3.0, 4.9, 2.0]]), false); + + let iter2 = iter.next().unwrap(); + iter2 + .into_data() + .assert_eq(&TensorData::from([[2.0, 1.9, 3.0]]), false); + + assert!(iter.next().is_none()); +} + +#[test] +fn test_2d_iter_dim1() { + let tensor = + TestTensor::<2>::from_data([[3.0, 4.9, 2.0], [2.0, 1.9, 3.0]], &Default::default()); + + let mut iter = tensor.iter_dim(1); + + let iter1 = iter.next().unwrap(); + iter1 + .into_data() + .assert_eq(&TensorData::from([[3.0], [2.0]]), false); + + let iter2 = iter.next().unwrap(); + iter2 + .into_data() + .assert_eq(&TensorData::from([[4.9], [1.9]]), false); + + let iter3 = iter.next().unwrap(); + iter3 + .into_data() + .assert_eq(&TensorData::from([[2.0], [3.0]]), false); + + assert!(iter.next().is_none()); +} + +#[test] +fn test_3d_iter_dim() { + let tensor = TestTensor::<3>::from([[ + [1., 2., 3., 1., 2.], + [4., 5., 6., 1., 2.], + [7., 8., 9., 1., 2.], + ]]); + + let mut iter = tensor.clone().iter_dim(0); + + let iter1 = iter.next().unwrap(); + iter1.into_data().assert_eq(&tensor.into_data(), true); + + assert!(iter.next().is_none()); +} + +#[test] +fn test_3d_iter_dim1() { + let tensor = TestTensor::<3>::from([[ + [1., 2., 3., 1., 2.], + [4., 5., 6., 1., 2.], + [7., 8., 9., 1., 2.], + ]]); + + let mut iter = tensor.iter_dim(1); + + let iter1 = iter.next().unwrap(); + iter1 + .into_data() + .assert_eq(&TensorData::from([[[1., 2., 3., 1., 2.]]]), false); + + let iter2 = iter.next().unwrap(); + iter2 + .into_data() + .assert_eq(&TensorData::from([[[4., 5., 6., 1., 2.]]]), false); + + let iter3 = iter.next().unwrap(); + iter3 + .into_data() + .assert_eq(&TensorData::from([[[7., 8., 9., 1., 2.]]]), false); + + assert!(iter.next().is_none()); +} + +#[test] +fn test_3d_iter_dim2() { + let tensor = TestTensor::<3>::from([[ + [1., 2., 3., 1., 2.], + [4., 5., 6., 1., 2.], + [7., 8., 9., 1., 2.], + ]]); + + let mut iter = tensor.iter_dim(2); + + let iter1 = iter.next().unwrap(); + iter1 + .into_data() + .assert_eq(&TensorData::from([[[1.], [4.], [7.]]]), false); + + let iter2 = iter.next().unwrap(); + iter2 + .into_data() + .assert_eq(&TensorData::from([[[2.], [5.], [8.]]]), false); + + let iter3 = iter.next().unwrap(); + iter3 + .into_data() + .assert_eq(&TensorData::from([[[3.], [6.], [9.]]]), false); + + let iter4 = iter.next().unwrap(); + iter4 + .into_data() + .assert_eq(&TensorData::from([[[1.], [1.], [1.]]]), false); + + let iter5 = iter.next().unwrap(); + iter5 + .into_data() + .assert_eq(&TensorData::from([[[2.], [2.], [2.]]]), false); + + assert!(iter.next().is_none()); +} + +#[test] +fn test_iteration_over_low_dim() { + let data = [[ + [1., 2., 3., 1., 2.], + [4., 5., 6., 1., 2.], + [7., 8., 9., 1., 2.], + ]]; + + let tensor = TestTensor::<3>::from_data(data, &Default::default()); + + let lhs = tensor.iter_dim(2).nth(1).unwrap(); + let rhs = TestTensor::<1>::from([2., 5., 8.]); + assert_eq!( + lhs.into_data().as_slice::().unwrap(), + rhs.into_data().as_slice::().unwrap() + ); +} + +#[test] +fn test_iter_dim_double_end() { + let input = TestTensorInt::<1>::arange(0..(4 * 6 * 3), &Default::default()).reshape([4, 6, 3]); + let mut iter = input.iter_dim(1); + + let ele0 = TensorData::from([[[0, 1, 2]], [[18, 19, 20]], [[36, 37, 38]], [[54, 55, 56]]]); + let ele1 = TensorData::from([[[3, 4, 5]], [[21, 22, 23]], [[39, 40, 41]], [[57, 58, 59]]]); + let ele2 = TensorData::from([[[6, 7, 8]], [[24, 25, 26]], [[42, 43, 44]], [[60, 61, 62]]]); + let ele3 = TensorData::from([ + [[9, 10, 11]], + [[27, 28, 29]], + [[45, 46, 47]], + [[63, 64, 65]], + ]); + let ele4 = TensorData::from([ + [[12, 13, 14]], + [[30, 31, 32]], + [[48, 49, 50]], + [[66, 67, 68]], + ]); + let ele5 = TensorData::from([ + [[15, 16, 17]], + [[33, 34, 35]], + [[51, 52, 53]], + [[69, 70, 71]], + ]); + + iter.next().unwrap().into_data().assert_eq(&ele0, false); + iter.next_back() + .unwrap() + .into_data() + .assert_eq(&ele5, false); + iter.next_back() + .unwrap() + .into_data() + .assert_eq(&ele4, false); + iter.next().unwrap().into_data().assert_eq(&ele1, false); + iter.next().unwrap().into_data().assert_eq(&ele2, false); + iter.next().unwrap().into_data().assert_eq(&ele3, false); + assert!(iter.next().is_none()); + assert!(iter.next_back().is_none()); +} + +#[test] +fn test_iter_dim_single_element() { + let input = TestTensorInt::<1>::arange(0..(4 * 3), &Default::default()).reshape([4, 1, 3]); + + let mut iter = input.clone().iter_dim(1); + iter.next() + .unwrap() + .into_data() + .assert_eq(&input.clone().into_data(), false); + assert!(iter.next_back().is_none()); + assert!(iter.next().is_none()); + + let mut iter = input.clone().iter_dim(1); + iter.next_back() + .unwrap() + .into_data() + .assert_eq(&input.clone().into_data(), false); + assert!(iter.next().is_none()); + assert!(iter.next_back().is_none()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/log.rs b/crates/burn-backend-tests/tests/tensor/float/ops/log.rs new file mode 100644 index 0000000..7aa64dc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/log.rs @@ -0,0 +1,39 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use core::f32::consts::E; + +#[test] +fn should_support_log_3d_transposed() { + // 3D tensor with permuted dimensions; log should undo the e^k powers. + let data = TensorData::from([ + [[1.0, E], [E * E, E * E * E]], + [[1.0, E], [E * E, E * E * E]], + ]); + let tensor = TestTensor::<3>::from_data(data, &Default::default()); + let permuted = tensor.permute([2, 0, 1]); + + let output = permuted.log(); + let expected = TensorData::from([[[0.0, 2.0], [0.0, 2.0]], [[1.0, 3.0], [1.0, 3.0]]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_log_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.log(); + let expected = TensorData::from([ + [-f32::INFINITY, 0.0, core::f32::consts::LN_2], + [1.09861, 1.38629, 1.60944], + ]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_relative(1e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/log1p.rs b/crates/burn-backend-tests/tests/tensor/float/ops/log1p.rs new file mode 100644 index 0000000..289b264 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/log1p.rs @@ -0,0 +1,20 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_exp_log1p() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.log1p(); + let expected = TensorData::from([ + [0.0, core::f32::consts::LN_2, 1.09861], + [1.38629, 1.60944, 1.79176], + ]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_relative(1e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/mask.rs b/crates/burn-backend-tests/tests/tensor/float/ops/mask.rs new file mode 100644 index 0000000..9a06b57 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/mask.rs @@ -0,0 +1,246 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_mask_fill_swap_dims() { + let device = Default::default(); + let tensor_1 = TestTensorInt::arange(0..16, &device).float(); + let tensor_1 = tensor_1.reshape([2, 2, 4]); + let tensor_1 = tensor_1.swap_dims(0, 2); + + let mask = tensor_1.clone().lower_equal_elem(5.0); + let output = tensor_1.clone().mask_fill(mask, -5.0); + + let expected = TensorData::from([ + [[-5.0, 8.0], [-5.0, 12.0]], + [[-5.0, 9.0], [-5.0, 13.0]], + [[-5.0, 10.0], [6.0, 14.0]], + [[-5.0, 11.0], [7.0, 15.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mask_where_ops() { + let device = Default::default(); + let tensor = TestTensor::from_data([[1.0, 7.0], [2.0, 3.0]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + let value = TestTensor::<2>::from_data(TensorData::from([[1.8, 2.8], [3.8, 4.8]]), &device); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([[1.8, 7.0], [2.0, 4.8]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mask_where_broadcast() { + let device = Default::default(); + // When broadcasted, the input [[2, 3], [4, 5]] is repeated 4 times + let tensor = TestTensorInt::<1>::arange(2..6, &device).reshape([1, 2, 2]); + let mask = TestTensorBool::<3>::from_bool( + TensorData::from([ + [[true, false], [false, true]], + [[false, true], [true, false]], + [[false, false], [false, false]], + [[true, true], [true, true]], + ]), + &device, + ); + let value = TestTensor::<3>::ones([4, 2, 2], &device); + + let output = tensor.float().mask_where(mask, value); + let expected = TensorData::from([ + [[1., 3.], [4., 1.]], + [[2., 1.], [1., 5.]], + [[2., 3.], [4., 5.]], + [[1., 1.], [1., 1.]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mask_where_broadcast_value_small() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(2..4, &device).float(); + let mask = TestTensorBool::<1>::from_bool(TensorData::from([true, false]), &device); + let value = TestTensor::<1>::ones([1], &device); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([1., 3.]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_handle_mask_where_nans() { + let device = Default::default(); + let tensor = TestTensor::from_data( + [ + [f32::NAN, f32::NAN, f32::NAN], + [f32::NAN, f32::NAN, f32::NAN], + [f32::NAN, f32::NAN, f32::NAN], + ], + &device, + ); + let mask = TestTensorBool::<2>::from_bool( + TensorData::from([ + [true, true, true], + [true, true, false], + [false, false, false], + ]), + &device, + ); + let value = TestTensor::<2>::from_data( + TensorData::from([[0.9, 0.8, 0.7], [0.6, 0.5, 0.4], [0.3, 0.2, 0.1]]), + &device, + ); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([ + [0.9, 0.8, 0.7], + [0.6, 0.5, f32::NAN], + [f32::NAN, f32::NAN, f32::NAN], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_mask_fill_ops() { + let device = Default::default(); + let tensor = TestTensor::from_data([[1.0, 7.0], [2.0, 3.0]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + + let output = tensor.mask_fill(mask, 2.0); + let expected = TensorData::from([[2.0, 7.0], [2.0, 2.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mask_fill_broadcasted() { + let device = Default::default(); + let tensor = TestTensor::zeros([1, 4, 2, 2], &device); + let mask = TestTensorBool::<4>::from_bool( + TensorData::from([[[[true, false], [false, true]]]]), + &device, + ); + + let output = tensor.mask_fill(mask, 2.0); + let expected = TensorData::from([[ + [[2., 0.], [0., 2.]], + [[2., 0.], [0., 2.]], + [[2., 0.], [0., 2.]], + [[2., 0.], [0., 2.]], + ]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn float_mask_fill_infinite() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [f32::NEG_INFINITY, f32::NEG_INFINITY], + [f32::NEG_INFINITY, f32::NEG_INFINITY], + ], + &device, + ); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + + let output = tensor.mask_fill(mask, 10.0f32); + let expected = TensorData::from([[10f32, f32::NEG_INFINITY], [f32::NEG_INFINITY, 10f32]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mask_fill_transposed_tensor() { + // [[1, 2], [3, 4]] transposed -> [[1, 3], [2, 4]]; mask [[T, F], [F, T]] -> [[0, 3], [2, 0]] + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + let mask = TestTensorBool::<2>::from([[true, false], [false, true]]); + + let output = tensor.mask_fill(mask, 0.0); + + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 3.0], [2.0, 0.0]]), false); +} + +#[test] +fn should_support_mask_fill_flipped_tensor() { + // [1,2,3,4] flipped -> [4,3,2,1]; mask [T,T,F,F] -> [0,0,2,1] + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]).flip([0]); + let mask = TestTensorBool::<1>::from([true, true, false, false]); + + let output = tensor.mask_fill(mask, 0.0); + + output + .into_data() + .assert_eq(&TensorData::from([0.0, 0.0, 2.0, 1.0]), false); +} + +#[test] +fn should_support_mask_fill_flipped_mask() { + // tensor [1,2,3,4]; mask [F,F,T,T] flipped -> [T,T,F,F] -> [0,0,3,4] + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]); + let mask = TestTensorBool::<1>::from([false, false, true, true]).flip([0]); + + let output = tensor.mask_fill(mask, 0.0); + + output + .into_data() + .assert_eq(&TensorData::from([0.0, 0.0, 3.0, 4.0]), false); +} + +#[test] +fn should_support_mask_where_flipped_2d() { + // [[1,2],[3,4]] axis-0 flipped -> [[3,4],[1,2]]; mask [[T,F],[F,T]]; + // value [[10,20],[30,40]] -> [[10,4],[1,40]] + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).flip([0]); + let mask = TestTensorBool::<2>::from([[true, false], [false, true]]); + let value = TestTensor::<2>::from([[10.0, 20.0], [30.0, 40.0]]); + + let output = tensor.mask_where(mask, value); + + output + .into_data() + .assert_eq(&TensorData::from([[10.0, 4.0], [1.0, 40.0]]), false); +} + +#[test] +fn should_support_mask_fill_both_flipped() { + // tensor [1,2,3,4] flipped -> [4,3,2,1]; mask [T,F,T,F] flipped -> [F,T,F,T] + // result: [4, 0, 2, 0] + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0]).flip([0]); + let mask = TestTensorBool::<1>::from([true, false, true, false]).flip([0]); + + let output = tensor.mask_fill(mask, 0.0); + + output + .into_data() + .assert_eq(&TensorData::from([4.0, 0.0, 2.0, 0.0]), false); +} + +#[test] +fn should_support_mask_fill_narrowed_tensor() { + // [1,2,3,4,5,6] narrowed to [2,3,4,5]; mask [T,F,F,T] -> [0,3,4,0] + let tensor = TestTensor::<1>::from([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).narrow(0, 1, 4); + let mask = TestTensorBool::<1>::from([true, false, false, true]); + + let output = tensor.mask_fill(mask, 0.0); + + output + .into_data() + .assert_eq(&TensorData::from([0.0, 3.0, 4.0, 0.0]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/matmul.rs b/crates/burn-backend-tests/tests/tensor/float/ops/matmul.rs new file mode 100644 index 0000000..a31f14c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/matmul.rs @@ -0,0 +1,466 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::{ElementConversion, Tolerance}; + +#[test] +fn test_float_matmul_d2() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 7.0], [2.0, 3.0], [1.0, 5.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 7.0, 5.0], [2.0, 3.0, 5.0]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[18.0, 28.0, 40.0], [14.0, 23.0, 25.0], [14.0, 22.0, 30.0]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_d3() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0], [2.0, 3.0]]], &device); + let tensor_2 = TestTensor::from_data([[[4.0, 7.0], [2.0, 3.0]]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[[18.0, 28.0], [14.0, 23.0]]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_broadcast_1() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0], [2.0, 3.0]]], &device); + let tensor_2 = TestTensor::from_data( + [[[4.0, 7.0], [2.0, 3.0]], [[2.0, 5.0], [6.0, 3.0]]], + &device, + ); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[[18.0, 28.0], [14.0, 23.0]], [[44.0, 26.0], [22.0, 19.0]]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_broadcast_4d() { + let device = Default::default(); + // [2, 1, 2, 2] + let tensor_1 = TestTensor::<4>::from_data( + [[[[1.0, 7.0], [2.0, 3.0]]], [[[2.0, 5.0], [6.0, 3.0]]]], + &device, + ); + // [1, 2, 2, 2] + let tensor_2 = TestTensor::from_data( + [[[[9.0, 8.0], [1.0, 4.0]], [[2.0, 7.0], [3.0, 5.0]]]], + &device, + ); + + // [2, 1, 2, 2] @ [1, 2, 2, 2] -> [2, 2, 2, 2] + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [[[16.0, 36.0], [21.0, 28.0]], [[23.0, 42.0], [13.0, 29.0]]], + [[[23.0, 36.0], [57.0, 60.0]], [[19.0, 39.0], [21.0, 57.0]]], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_simple_1() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[5.0, 14.0], [14.0, 50.0]], &device); + let tensor_2 = TestTensor::from_data([[3.0, 4.0, 5.0], [0.0, 1.0, 2.0]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[15.0, 34.0, 53.0], [42.0, 106.0, 170.0]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_4_3() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data( + [[0., 1., 2., 3.], [4., 5., 6., 7.], [8., 9., 10., 11.]], + &device, + ); + let tensor_2 = TestTensor::from_data( + [[0., 1., 2.], [4., 5., 6.], [8., 9., 10.], [12., 13., 14.]], + &device, + ); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[56., 62., 68.], [152., 174., 196.], [248., 286., 324.]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_batch_vec_mat() { + let device = Default::default(); + + // [..., B, 1, K] = [3, 1, 2] + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0]], [[2.0, 3.0]], [[1.0, 5.0]]], &device); + + // [..., 1, K, N] = [1, 2, 3] + let tensor_2 = TestTensor::<3>::from_data([[[4.0, 7.0, 5.0], [2.0, 3.0, 5.0]]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + + // [..., B, 1, N] = [3, 1, 3] + let expected = TensorData::from([ + [[18.0, 28.0, 40.0]], + [[14.0, 23.0, 25.0]], + [[14.0, 22.0, 30.0]], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_vecmat() { + let device = Default::default(); + + // [..., B, 1, K] = [3, 1, 2] + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 7.0]], [[2.0, 3.0]], [[1.0, 5.0]]], &device); + + // [..., B, K, N] = [3, 2, 3] + let tensor_2 = TestTensor::<3>::from_data( + [ + [[1.0, 4.0, 5.0], [3.0, 5.0, 6.0]], + [[8.0, 2.0, 3.0], [0.0, 2.0, 4.0]], + [[4.0, 7.0, 5.0], [2.0, 3.0, 5.0]], + ], + &device, + ); + + let tensor_3 = tensor_1.matmul(tensor_2); + + // [..., B, 1, N] = [3, 1, 3] + let expected = TensorData::from([ + [[22.0, 39.0, 47.0]], + [[16.0, 10.0, 18.0]], + [[14.0, 22.0, 30.0]], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_trivial() { + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..16, &device) + .reshape([4, 4]) + .float(); + + let tensor_3 = tensor_1.clone().matmul(tensor_1); + + tensor_3.into_data().assert_approx_eq::( + &TensorData::from([ + [56., 62., 68., 74.], + [152., 174., 196., 218.], + [248., 286., 324., 362.], + [344., 398., 452., 506.], + ]), + Tolerance::default(), + ); +} + +#[test] +fn test_float_matmul_trivial_transposed() { + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..16, &device) + .reshape([4, 4]) + .float(); + + let tensor_3 = tensor_1.clone().matmul(tensor_1.transpose()); + + tensor_3.into_data().assert_approx_eq::( + &TensorData::from([ + [14., 38., 62., 86.], + [38., 126., 214., 302.], + [62., 214., 366., 518.], + [86., 302., 518., 734.], + ]), + Tolerance::default(), + ); +} + +/// Regression test for batch bug in fused matmul +#[test] +fn test_float_matmul_vecmat_transposed_fused() { + let device = Default::default(); + + let batch1 = 1; + let batch2 = 2; + let batch = batch1 * batch2; + let seq_length = 3; + let d_model = 32; + + // Guard int arange limits + #[allow(clippy::unnecessary_cast)] + if (IntElem::MAX as i64) < seq_length * d_model * batch { + return; + } + if FloatElem::MAX.elem::() < 269493.0 { + return; + } + + let weight: TestTensor<4> = TestTensorInt::arange(0..d_model * batch, &device) + .reshape([batch1, batch2, 1, d_model]) + .float(); + let signal: TestTensor<4> = TestTensorInt::arange(0..seq_length * d_model * batch, &device) + .reshape([batch1, batch2, seq_length, d_model]) + .float(); + + device.sync().unwrap(); + let weight = weight.transpose(); + let out = signal.matmul(weight) + 5; + let expected = TensorData::from([[ + [[10421.0], [26293.0], [42165.0]], + [[172213.0], [220853.0], [269493.0]], + ]]); + expected.assert_approx_eq(&out.into_data(), Tolerance::::strict()); +} + +#[test] +fn test_float_matmul_4_8() { + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..32, &device) + .reshape([4, 8]) + .float(); + + let tensor_3 = tensor_1.clone().matmul(tensor_1.transpose()); + + tensor_3.into_data().assert_approx_eq::( + &TensorData::from([ + [140., 364., 588., 812.], + [364., 1100., 1836., 2572.], + [588., 1836., 3084., 4332.], + [812., 2572., 4332., 6092.], + ]), + Tolerance::default(), + ); +} + +#[test] +fn test_float_matmul_simple_2() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0]], &device); + let tensor_2 = TestTensor::from_data([[3.0], [4.0], [5.0], [6.0]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[50.0]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_float_matmul_simple_3() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data( + [[3., 3., 3.], [4., 4., 4.], [5., 5., 5.], [6., 6., 6.]], + &device, + ); + let tensor_2 = TestTensor::from_data( + [[1., 2., 3., 4.], [1., 2., 3., 4.], [1., 2., 3., 4.]], + &device, + ); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [9., 18., 27., 36.], + [12., 24., 36., 48.], + [15., 30., 45., 60.], + [18., 36., 54., 72.], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_transposed_lhs() { + // [2, 3] -> transpose -> [3, 2], matmul with identity [2, 2] -> [3, 2]. + let device = Default::default(); + let lhs = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let rhs = TestTensor::<2>::from_data([[1.0, 0.0], [0.0, 1.0]], &device); + + let output = lhs.transpose().matmul(rhs); + let expected = TensorData::from([[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_transposed_rhs() { + // identity [2, 2] matmul [3, 2].transpose() -> [2, 3]. + let device = Default::default(); + let lhs = TestTensor::<2>::from_data([[1.0, 0.0], [0.0, 1.0]], &device); + let rhs = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device); + + let output = lhs.matmul(rhs.transpose()); + let expected = TensorData::from([[1.0, 3.0, 5.0], [2.0, 4.0, 6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_both_transposed() { + // [2, 3].T [3, 2] matmul [3, 2].T [2, 3] -> [3, 3]. + let device = Default::default(); + let lhs = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let rhs = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device); + + let output = lhs.transpose().matmul(rhs.transpose()); + // lhs.T = [[1,4],[2,5],[3,6]]; rhs.T = [[1,3,5],[2,4,6]] + // row0: 1+8,3+16,5+24 = 9,19,29 + // row1: 2+10,6+20,10+30 = 12,26,40 + // row2: 3+12,9+24,15+36 = 15,33,51 + let expected = TensorData::from([[9.0, 19.0, 29.0], [12.0, 26.0, 40.0], [15.0, 33.0, 51.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_batched_transposed_rhs() { + // QK^T attention pattern: q.matmul(k.swap_dims(1, 2)). + let device = Default::default(); + let q = TestTensor::<3>::from_data( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], + ], + &device, + ); + let k = TestTensor::<3>::from_data( + [ + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]], + ], + &device, + ); + + let output = q.matmul(k.swap_dims(1, 2)); + // batch 0: [[1,2,3],[4,5,6]] @ [[1,0],[0,1],[0,0]] = [[1,2],[4,5]] + // batch 1: [[7,8,9],[10,11,12]] @ [[1,2],[1,2],[1,2]] = [[24,48],[33,66]] + let expected = TensorData::from([[[1.0, 2.0], [4.0, 5.0]], [[24.0, 48.0], [33.0, 66.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_batched_transposed_lhs() { + // [2, 2, 3].swap_dims(1, 2) -> [2, 3, 2]; rhs [2, 2, 2] -> [2, 3, 2]. + let device = Default::default(); + let a = TestTensor::<3>::from_data( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], + ], + &device, + ); + let b = TestTensor::<3>::from_data( + [[[1.0, 0.0], [0.0, 1.0]], [[2.0, 0.0], [0.0, 2.0]]], + &device, + ); + + let output = a.swap_dims(1, 2).matmul(b); + // batch 0: a.T = [[1,4],[2,5],[3,6]] @ I = [[1,4],[2,5],[3,6]] + // batch 1: a.T = [[7,10],[8,11],[9,12]] @ 2I = [[14,20],[16,22],[18,24]] + let expected = TensorData::from([ + [[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]], + [[14.0, 20.0], [16.0, 22.0], [18.0, 24.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_batched_both_transposed() { + // a: [2, 3, 2].swap_dims -> [2, 2, 3]; b: [2, 2, 3].swap_dims -> [2, 3, 2]; + // result: [2, 2, 2]. + let device = Default::default(); + let a = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], + [[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]], + ], + &device, + ); + let b = TestTensor::<3>::from_data( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], + ], + &device, + ); + + let output = a.swap_dims(1, 2).matmul(b.swap_dims(1, 2)); + // batch 0: a.T = [[1,3,5],[2,4,6]]; b.T = [[1,4],[2,5],[3,6]] + // row0: 1+6+15=22, 4+15+30=49 + // row1: 2+8+18=28, 8+20+36=64 + // batch 1: a.T = [[7,9,11],[8,10,12]]; b.T = [[7,10],[8,11],[9,12]] + // row0: 49+72+99=220, 70+99+132=301 + // row1: 56+80+108=244, 80+110+144=334 + let expected = TensorData::from([ + [[22.0, 49.0], [28.0, 64.0]], + [[220.0, 301.0], [244.0, 334.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_matmul_batched_broadcast_transposed() { + // lhs [1, 2, 3].swap_dims(1, 2) -> [1, 3, 2] broadcasts against rhs [4, 2, 2]. + let device = Default::default(); + let lhs = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]], &device); + let rhs = TestTensor::<3>::from_data( + [ + [[1.0, 0.0], [0.0, 1.0]], + [[2.0, 0.0], [0.0, 2.0]], + [[1.0, 1.0], [1.0, 1.0]], + [[0.0, 1.0], [1.0, 0.0]], + ], + &device, + ); + + let output = lhs.swap_dims(1, 2).matmul(rhs); + // lhs.T = [[1,4],[2,5],[3,6]] (shape [1, 3, 2]) broadcast to [4, 3, 2] + // batch 0 (I): [[1,4],[2,5],[3,6]] + // batch 1 (2I): [[2,8],[4,10],[6,12]] + // batch 2 (ones): each output row is [sum, sum] where sum is the row of lhs.T + // batch 3 (swap): [[4,1],[5,2],[6,3]] + let expected = TensorData::from([ + [[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]], + [[2.0, 8.0], [4.0, 10.0], [6.0, 12.0]], + [[5.0, 5.0], [7.0, 7.0], [9.0, 9.0]], + [[4.0, 1.0], [5.0, 2.0], [6.0, 3.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn float_should_panic_when_inner_dimensions_are_not_equal() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[3., 3.], [4., 4.], [5., 5.], [6., 6.]], &device); + let tensor_2 = TestTensor::from_data( + [[1., 2., 3., 4.], [1., 2., 3., 4.], [1., 2., 3., 4.]], + &device, + ); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [9., 18., 27., 36.], + [12., 24., 36., 48.], + [15., 30., 45., 60.], + [18., 36., 54., 72.], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/maxmin.rs b/crates/burn-backend-tests/tests/tensor/float/ops/maxmin.rs new file mode 100644 index 0000000..5e11eeb --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/maxmin.rs @@ -0,0 +1,309 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_max_dim_2d() { + let f = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + f.clone() + .max_dim(0) + .into_data() + .assert_eq(&TensorData::from([[3., 4., 5.]]), false); + + f.clone() + .max_dim(1) + .into_data() + .assert_eq(&TensorData::from([[2.], [5.]]), false); + + // Negative Index + f.clone() + .max_dim(-1) + .into_data() + .assert_eq(&TensorData::from([[2.], [5.]]), false); + + // Regression Test: https://github.com/tracel-ai/burn/issues/3139 + let z = f.clone().int(); + z.clone() + .max_dim(0) + .into_data() + .assert_eq(&TensorData::from([[3, 4, 5]]), false); + z.clone() + .max_dim(1) + .into_data() + .assert_eq(&TensorData::from([[2], [5]]), false); +} + +#[test] +fn test_max_dims_2d() { + let f = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + f.clone() + .max_dims(&[0]) + .into_data() + .assert_eq(&TensorData::from([[3., 4., 5.]]), false); + + f.clone() + .max_dims(&[-2]) + .into_data() + .assert_eq(&TensorData::from([[3., 4., 5.]]), false); + + f.clone() + .max_dims(&[0, 1]) + .into_data() + .assert_eq(&TensorData::from([[5.]]), false); +} + +#[test] +fn test_max_dim_with_indices_2d_with_dim_0th() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + // Positive, Negative Index + for idx in [0, -2] { + let (output, index) = tensor.clone().max_dim_with_indices(idx); + + let output_expected = TensorData::from([[3., 4., 5.]]); + let index_expected = TensorData::from([[1, 1, 1]]); + + output.into_data().assert_eq(&output_expected, false); + index.into_data().assert_eq(&index_expected, false); + } +} + +#[test] +fn test_max_dim_with_indices_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let (output, index) = tensor.max_dim_with_indices(1); + + let output_expected = TensorData::from([[2.], [5.]]); + let index_expected = TensorData::from([[2], [2]]); + + output.into_data().assert_eq(&output_expected, false); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_max_dim_2d_with_0th_dim() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let output = tensor.max_dim(0); + let expected = TensorData::from([[3., 4., 5.]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_max_pair() { + let a = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0], &Default::default()); + let b = TestTensor::from_data([2.0, 1.0, 4.0, 5.0], &Default::default()); + + let output = a.max_pair(b); + let expected = TensorData::from([2.0, 2.0, 4.0, 5.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_min_dim_2d() { + let f = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + f.clone() + .min_dim(0) + .into_data() + .assert_eq(&TensorData::from([[0., 1., 2.]]), false); + + f.clone() + .min_dim(1) + .into_data() + .assert_eq(&TensorData::from([[0.], [3.]]), false); + + // Negative Index + f.clone() + .min_dim(-1) + .into_data() + .assert_eq(&TensorData::from([[0.], [3.]]), false); + + // Regression Test: https://github.com/tracel-ai/burn/issues/3139 + let z = f.int(); + z.clone() + .min_dim(0) + .into_data() + .assert_eq(&TensorData::from([[0, 1, 2]]), false); + z.clone() + .min_dim(1) + .into_data() + .assert_eq(&TensorData::from([[0], [3]]), false); +} + +#[test] +fn test_min_dims_2d() { + let f = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + f.clone() + .min_dims(&[0]) + .into_data() + .assert_eq(&TensorData::from([[0., 1., 2.]]), false); + + f.clone() + .min_dims(&[-2]) + .into_data() + .assert_eq(&TensorData::from([[0., 1., 2.]]), false); + + f.clone() + .min_dims(&[0, 1]) + .into_data() + .assert_eq(&TensorData::from([[0.]]), false); +} + +#[test] +fn test_min_dim_with_indices_2d() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let (output, index) = tensor.min_dim_with_indices(1); + + let output_expected = TensorData::from([[0.], [3.]]); + let index_expected = TensorData::from([[0], [0]]); + + output.into_data().assert_eq(&output_expected, false); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_min_dim_2d_with_0th_dim() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + let output = tensor.min_dim(0); + let expected = TensorData::from([[0., 1., 2.]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_min_dim_with_indices_2d_with_0th_dim() { + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &Default::default()); + + // Positive, Negative Index + for idx in [0, -2] { + let (output, index) = tensor.clone().min_dim_with_indices(idx); + + let output_expected = TensorData::from([[0., 1., 2.]]); + let index_expected = TensorData::from([[0, 0, 0]]); + + output.into_data().assert_eq(&output_expected, false); + index.into_data().assert_eq(&index_expected, false); + } +} + +#[test] +fn test_min_pair() { + let a = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0], &Default::default()); + let b = TestTensor::from_data([2.0, 1.0, 4.0, 5.0], &Default::default()); + + let output = a.min_pair(b); + let expected = TensorData::from([1.0, 1.0, 3.0, 4.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_max_abs() { + let tensor = TestTensor::<2>::from_data([[0., 1., -2.], [-5., 6., 1.]], &Default::default()); + + let output = tensor.max_abs(); + let expected = TensorData::from([6.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_max_abs_dim_2d_dim_0() { + let tensor = TestTensor::<2>::from_data([[0., 1., -2.], [-5., 6., 1.]], &Default::default()); + + let output = tensor.clone().max_abs_dim(0); + let expected = TensorData::from([[5., 6., 2.]]); + output.into_data().assert_eq(&expected, false); + + // Negative Index + let output = tensor.clone().max_abs_dim(-2); + let expected = TensorData::from([[5., 6., 2.]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_max_abs_dims_2d() { + let tensor = TestTensor::<2>::from_data([[0., 1., -2.], [-5., 6., 1.]], &Default::default()); + + tensor + .clone() + .max_abs_dims(&[0]) + .into_data() + .assert_eq(&TensorData::from([[5., 6., 2.]]), false); + + tensor + .clone() + .max_abs_dims(&[-2]) + .into_data() + .assert_eq(&TensorData::from([[5., 6., 2.]]), false); + + tensor + .clone() + .max_abs_dims(&[0, 1]) + .into_data() + .assert_eq(&TensorData::from([[6.]]), false); +} + +#[test] +fn test_max_abs_dim_2d_dim_1() { + let tensor = TestTensor::<2>::from_data([[0., 1., -2.], [-5., 6., 1.]], &Default::default()); + + let output = tensor.max_abs_dim(1); + let expected = TensorData::from([[2.], [6.]]); + + output.into_data().assert_eq(&expected, false); +} + +// NaN-propagation tests below. Only run when the `flex` backend feature +// is active, because flex is the only burn backend that currently +// propagates NaN from min/max (matching PyTorch/NumPy/JAX/TF). ndarray +// and the cubecl backends follow IEEE 754 min/max and drop NaN. The +// positive-gate form (rather than excluding specific backends) is used +// because the default-feature CI build selects a backend transitively +// without setting any of its identifying feature flags on +// burn-backend-tests, so a negative gate would still run the test on a +// NaN-dropping backend. See issue #4814. +#[cfg(feature = "flex")] +#[test] +fn test_max_dim_nan_propagation() { + let tensor = TestTensor::<2>::from([[1.0, f32::NAN, 3.0]]); + let data = tensor.max_dim(1).into_data(); + let values = data.as_slice::().unwrap(); + assert!(values[0].is_nan()); +} + +#[cfg(feature = "flex")] +#[test] +fn test_min_dim_nan_propagation() { + let tensor = TestTensor::<2>::from([[1.0, f32::NAN, 3.0]]); + let data = tensor.min_dim(1).into_data(); + let values = data.as_slice::().unwrap(); + assert!(values[0].is_nan()); +} + +#[cfg(feature = "flex")] +#[test] +fn test_max_dim_with_indices_nan_propagation() { + let tensor = TestTensor::<2>::from([[1.0, f32::NAN, 3.0]]); + let (values, indices) = tensor.max_dim_with_indices(1); + let vdata = values.into_data(); + let slice = vdata.as_slice::().unwrap(); + assert!(slice[0].is_nan()); + indices + .into_data() + .assert_eq(&TensorData::from([[1]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/mod.rs b/crates/burn-backend-tests/tests/tensor/float/ops/mod.rs new file mode 100644 index 0000000..61dd784 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/mod.rs @@ -0,0 +1,134 @@ +use super::*; + +/// Build a `[rows, cols]` row-major boolean mask whose rows exercise every +/// logical-reduction merge case along the last axis: all-zero, all-nonzero, a +/// single nonzero at the first/last/middle column, and an all-nonzero row with +/// a single zero. Used by the large `any_dim`/`all_dim` tests (`rows == 8`). +pub fn build_logical_mask(rows: usize, cols: usize) -> Vec { + assert!( + rows >= 8 && cols > 100, + "mask layout assumes rows>=8, cols>100" + ); + let mut mask = vec![false; rows * cols]; + for c in 0..cols { + mask[cols + c] = true; // row 1: all nonzero + mask[5 * cols + c] = true; // row 5: all nonzero + mask[7 * cols + c] = true; // row 7: all nonzero (one zero punched below) + } + mask[2 * cols] = true; // row 2: single nonzero at the first column + mask[3 * cols + (cols - 1)] = true; // row 3: single nonzero at the last column + mask[6 * cols + cols / 2] = true; // row 6: single nonzero in the middle + mask[7 * cols + 100] = false; // row 7: a single zero deep inside an all-nonzero row + // rows 0 and 4 stay all-zero + mask +} + +/// Map the mask to float input, using alternating nonzero magnitudes so the +/// reduction must normalize arbitrary nonzero values (not just `1.0`) to true. +pub fn mask_to_floats(mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .map(|(i, &m)| { + if m { + if i % 2 == 0 { 2.0 } else { -3.0 } + } else { + 0.0 + } + }) + .collect() +} + +/// Map the mask to int input with alternating nonzero magnitudes. +pub fn mask_to_ints(mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .map(|(i, &m)| { + if m { + if i % 2 == 0 { 2 } else { -3 } + } else { + 0 + } + }) + .collect() +} + +mod abs; +mod add; +mod aggregation; +mod all; +mod any; +mod arg; +mod blackman_window; +mod cast; +mod cat; +mod categorical; +mod ceil; +mod chunk; +mod clamp; +mod close; +mod comparison; +mod create_like; +mod cross; +mod cumulative; +mod div; +mod dot; +mod erf; +mod exp; +mod expand; +mod finite; +mod flatten; +mod flip; +mod floor; +mod fmod; +mod full; +mod gather_scatter; +mod gather_scatter_nd; +mod grid_sample; +mod hamming_window; +mod hann_window; +mod hypot; +mod inf; +mod init; +mod iter_dim; +mod log; +mod log1p; +mod mask; +mod matmul; +mod maxmin; +mod movedim; +mod mul; +mod nan; +mod narrow; +mod neg; +mod one_hot; +mod padding; +mod permute; +mod powf; +mod powf_scalar; +mod prod; +mod random; +mod recip; +mod remainder; +mod repeat; +mod repeat_dim; +mod reshape; +mod round; +mod select; +mod sign; +mod slice; +mod slice_assign; +mod sort_argsort; +mod split; +mod sqrt; +mod square; +mod squeeze; +mod stack; +mod sub; +mod take; +mod topk; +mod transaction; +mod transpose; +mod tri; +mod trig; +mod trunc; +mod unfold; diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/movedim.rs b/crates/burn-backend-tests/tests/tensor/float/ops/movedim.rs new file mode 100644 index 0000000..49ee98a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/movedim.rs @@ -0,0 +1,123 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn movedim_float() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .float(); + + let permuted = tensor.clone().movedim(0, 2); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim(0, 2).float() + let expected = TensorData::from([ + [[0., 12.], [1., 13.], [2., 14.], [3., 15.]], + [[4., 16.], [5., 17.], [6., 18.], [7., 19.]], + [[8., 20.], [9., 21.], [10., 22.], [11., 23.]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().movedim(0, -1); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().movedim(0, 0); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +fn vec_input_float() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .float(); + + let permuted = tensor.clone().movedim(vec![0, 1], vec![1, 0]); + // from pytorch + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim([0, 1], [1, 0]).float() + let expected = TensorData::from([ + [[0., 1., 2., 3.], [12., 13., 14., 15.]], + [[4., 5., 6., 7.], [16., 17., 18., 19.]], + [[8., 9., 10., 11.], [20., 21., 22., 23.]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axes + let permuted = tensor.clone().movedim(vec![-3, -2], vec![-2, -3]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axes + let permuted = tensor.clone().movedim(vec![0, 1], vec![0, 1]); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +fn different_input_types() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .float(); + + let permuted = tensor.clone().movedim(0_usize, 2_i32); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim(0, 2).float() + let expected = TensorData::from([ + [[0., 12.], [1., 13.], [2., 14.], [3., 15.]], + [[4., 16.], [5., 17.], [6., 18.], [7., 19.]], + [[8., 20.], [9., 21.], [10., 22.], [11., 23.]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().movedim(0_usize, -1); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().movedim(0_i32, 0_usize); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +#[should_panic] +fn edge_different_sizes() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with a repeated axis + let _ = tensor.clone().movedim(vec![0, 1], vec![0]); +} + +#[test] +#[should_panic] +fn edge_out_of_bound_axis() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with an out of bound axis + let _ = tensor.clone().movedim(0, 100); +} + +#[test] +#[should_panic] +fn edge_vec_is_not_a_set() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with a repeated axis + let _ = tensor.clone().movedim(vec![0, 1, 1, 1, 1], vec![0, 0, 1]); +} + +#[test] +#[should_panic] +fn edge_out_of_bound_axis_vec() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with an out of bound axis + let _ = tensor.clone().movedim(vec![0, 100], vec![0, 1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/mul.rs b/crates/burn-backend-tests/tests/tensor/float/ops/mul.rs new file mode 100644 index 0000000..c40a33a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/mul.rs @@ -0,0 +1,68 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_mul_ops() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data_2 = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 1.0, 4.0], [9.0, 16.0, 25.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_mul_broadcast() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0]]); + let data_2 = TensorData::from([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 4.0, 10.0], [0.0, 7.0, 16.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_mul_broadcast_2_dims() { + let device = Default::default(); + let tensor_1 = TestTensor::<1>::from_data([0.0, 1.0, 2.0], &device).reshape([3, 1]); + let tensor_2 = TestTensor::<1>::from_data([3.0, 4.0, 5.0], &device).reshape([1, 3]); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 0.0, 0.0], [3.0, 4.0, 5.0], [6.0, 8.0, 10.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_mul_both_transposed() { + // Non-contiguous on both sides: each operand is a transpose of a 2x2. + let a = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]).transpose(); + let b = TestTensor::<2>::from([[2.0, 3.0], [4.0, 5.0]]).transpose(); + + let output = a * b; + + // a_t = [[1, 3], [2, 4]], b_t = [[2, 4], [3, 5]] + output + .into_data() + .assert_eq(&TensorData::from([[2.0, 12.0], [6.0, 20.0]]), false); +} + +#[test] +fn should_support_mul_scalar_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let scalar = 2.0; + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor * scalar; + let expected = TensorData::from([[0.0, 2.0, 4.0], [6.0, 8.0, 10.0]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/nan.rs b/crates/burn-backend-tests/tests/tensor/float/ops/nan.rs new file mode 100644 index 0000000..54a3b6f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/nan.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn is_nan() { + let no_nan = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let no_nan_expected = TestTensorBool::<2>::from([[false, false, false], [false, false, false]]); + + let with_nan = TestTensor::<2>::from([[0.0, f32::NAN, 2.0], [f32::NAN, 4.0, 5.0]]); + let with_nan_expected = TestTensorBool::<2>::from([[false, true, false], [true, false, false]]); + + assert_eq!(no_nan_expected.into_data(), no_nan.is_nan().into_data()); + + assert_eq!(with_nan_expected.into_data(), with_nan.is_nan().into_data()); +} + +#[test] +fn contains_nan() { + let no_nan = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + assert!(!no_nan.contains_nan().into_scalar::()); + + let with_nan = TestTensor::<2>::from([[0.0, f32::NAN, 2.0], [3.0, 4.0, 5.0]]); + assert!(with_nan.contains_nan().into_scalar::()); + + // Regression guard: a finite tensor must never be reported as containing NaN. + // The previous `sum().is_nan()` implementation could overflow the reduction + // on narrow-exponent floats (e.g. f16) and turn a finite tensor into a NaN sum, + // producing a false positive. Here, Inf + (-Inf) = NaN. + let device = Default::default(); + let pos = TestTensor::<1>::ones([2048], &device) * 60000.0; + let neg = TestTensor::<1>::ones([2048], &device) * -60000.0; + // Large positive and large negative numbers + let large_finite = TestTensor::<1>::cat(vec![pos, neg], 0); + assert!(!large_finite.contains_nan().into_scalar::()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/narrow.rs b/crates/burn-backend-tests/tests/tensor/float/ops/narrow.rs new file mode 100644 index 0000000..ec47e34 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/narrow.rs @@ -0,0 +1,98 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Shape, TensorData}; + +#[test] +fn test_narrow_1() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let output = tensor.clone().narrow(0, 0, 2); + let expected = TensorData::from([[1., 2., 3.], [4., 5., 6.]]); + + assert_eq!(output.shape(), Shape::from([2, 3])); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_narrow_2() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let output = tensor.clone().narrow(1, 1, 2); + let expected = TensorData::from([[2., 3.], [5., 6.], [8., 9.]]); + assert_eq!(output.shape(), Shape::from([3, 2])); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_narrow_3() { + let device = &Default::default(); + let shape = Shape::new([8, 8]); + let tensor = TestTensorInt::arange(0..shape.num_elements() as i64, device) + .reshape::<2, _>(shape) + .float(); + + let output = tensor.clone().narrow(0, 3, 4); + let expected = TensorData::from([ + [24.0, 25.0, 26.0, 27.0, 28.0, 29.0, 30.0, 31.0], + [32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0], + [40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0], + [48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0], + ]); + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_dim() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let _output = tensor.narrow(2, 0, 2); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_start() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let _output = tensor.narrow(0, 3, 2); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_zero_length() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let _output = tensor.narrow(0, 1, 0); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_length() { + let tensor = TestTensor::<2>::from_data( + TensorData::from([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]), + &Default::default(), + ); + + let _output = tensor.narrow(0, 0, 4); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/neg.rs b/crates/burn-backend-tests/tests/tensor/float/ops/neg.rs new file mode 100644 index 0000000..01c9aaa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/neg.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_neg_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.neg(); + let expected = TensorData::from([[-0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]).convert::(); + + // -0.0 is represented differently than 0.0 so we make sure the values are the same in f32 + assert_eq!( + output + .into_data() + .convert::() + .as_slice::() + .unwrap(), + expected.as_slice::().unwrap() + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/one_hot.rs b/crates/burn-backend-tests/tests/tensor/float/ops/one_hot.rs new file mode 100644 index 0000000..60430ad --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/one_hot.rs @@ -0,0 +1,71 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn float_should_support_one_hot() { + let tensor = TestTensor::<1>::from([0.0, 1.0, 4.0]); + let one_hot_tensor: TestTensor<2> = tensor.one_hot(5); + let expected = TensorData::from([ + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + ]); + one_hot_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn float_should_support_one_hot_index() { + let tensor = TestTensor::<1>::from([2.0]); + let one_hot_tensor: TestTensor<2> = tensor.one_hot::<2>(10); + let expected = TensorData::from([[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]); + one_hot_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn float_one_hot_should_panic_when_index_exceeds_number_of_classes() { + let tensor = TestTensor::<1>::from([5.0]); + let _result: TestTensor<2> = tensor.one_hot(5); +} + +#[test] +#[should_panic] +fn float_one_hot_should_panic_when_number_of_classes_is_zero() { + let tensor = TestTensor::<1>::from([0.0]); + let _result: TestTensor<2> = tensor.one_hot(0); +} + +#[test] +fn one_hot_fill_with_negative_axis_and_indices() { + let tensor = TestTensor::<2>::from([[0, 2], [1, -1]]); + let expected = TensorData::from([ + [[5.0, 0.0, 0.0], [0.0, 0.0, 5.0]], + [[0.0, 5.0, 0.0], [0.0, 0.0, 5.0]], + ]); + + let one_hot_tensor: TestTensor<3> = tensor.one_hot_fill(3, 5.0, 0.0, -1); + + one_hot_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn one_hot_fill_with_negative_indices() { + let tensor = TestTensor::<1>::from([0.0, -7.0, -8.0]); + let expected = TensorData::from([ + [3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + ]); + + let one_hot_tensor: TestTensor<2> = tensor.one_hot_fill(10, 3.0, 1.0, 1); + + one_hot_tensor.into_data().assert_eq(&expected, false); +} + +#[should_panic] +#[test] +fn one_hot_fill_should_panic_when_axis_out_range_of_rank() { + let tensor = TestTensor::<2>::from([[0.0, 2.0], [1.0, -1.0]]); + + let _one_hot_tensor: TestTensor<3> = tensor.one_hot_fill(2, 5.0, 0.0, 3); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/padding.rs b/crates/burn-backend-tests/tests/tensor/float/ops/padding.rs new file mode 100644 index 0000000..fa2101d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/padding.rs @@ -0,0 +1,470 @@ +use super::*; +use burn_tensor::{TensorData, ops::PadMode}; + +#[test] +fn padding_constant_2d_test() { + let unpadded_floats: [[f32; 3]; 2] = [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]; + let tensor = TestTensor::<2>::from(unpadded_floats); + + let padded_tensor = tensor.pad((2, 2, 2, 2), 1.1); + + let expected = TensorData::from([ + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 0.0, 1.0, 2.0, 1.1, 1.1], + [1.1, 1.1, 3.0, 4.0, 5.0, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_4d_test() { + let unpadded_floats = [[[[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]]]]; + let tensor = TestTensor::<4>::from(unpadded_floats); + + let padded_tensor = tensor.pad((2, 2, 2, 2), 1.1); + + let expected = TensorData::from([[[ + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 0.0, 1.0, 1.1, 1.1], + [1.1, 1.1, 2.0, 3.0, 1.1, 1.1], + [1.1, 1.1, 4.0, 5.0, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + ]]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_asymmetric_test() { + let unpadded_floats = [[[[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]]]]; + let tensor = TestTensor::<4>::from(unpadded_floats); + + let padded_tensor = tensor.pad((2, 1, 4, 3), 1.1); + + let expected = TensorData::from([[[ + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 0.0, 1.0, 1.1], + [1.1, 1.1, 2.0, 3.0, 1.1], + [1.1, 1.1, 4.0, 5.0, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1], + ]]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_2d_test() { + // Test reflect padding on a 2D tensor + // Input: [[1, 2, 3], [4, 5, 6]] + // With padding (1, 1, 1, 1): + // - Top: reflect row 1 -> [4, 5, 6] + // - Bottom: reflect row 0 -> [1, 2, 3] + // - Left: reflect col 1 + // - Right: reflect col 1 + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let padded_tensor = tensor.pad((1, 1, 1, 1), PadMode::Reflect); + + // Expected: reflect excludes the edge value + // Before padding height: [[1,2,3], [4,5,6]] + // After top pad (reflect row at index 1): [[4,5,6], [1,2,3], [4,5,6]] + // After bottom pad (reflect row at index 1 from end): [[4,5,6], [1,2,3], [4,5,6], [1,2,3]] + // Then pad width similarly + let expected = TensorData::from([ + [5.0, 4.0, 5.0, 6.0, 5.0], + [2.0, 1.0, 2.0, 3.0, 2.0], + [5.0, 4.0, 5.0, 6.0, 5.0], + [2.0, 1.0, 2.0, 3.0, 2.0], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_width_only_test() { + // Test reflect padding on width dimension only + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0]]); + + let padded_tensor = tensor.pad((2, 2, 0, 0), PadMode::Reflect); + + // Input: [1, 2, 3, 4] + // Reflect left 2: take indices [1, 2] = [2, 3], flip = [3, 2] + // Reflect right 2: take indices [1, 2] from end = [2, 3], flip = [3, 2] + // Result: [3, 2, 1, 2, 3, 4, 3, 2] + let expected = TensorData::from([[3.0, 2.0, 1.0, 2.0, 3.0, 4.0, 3.0, 2.0]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_4d_test() { + // Test reflect padding on 4D tensor (common for images: NCHW) + let tensor = TestTensor::<4>::from([[[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]]]); + + let padded_tensor = tensor.pad((1, 1, 1, 1), PadMode::Reflect); + + let expected = TensorData::from([[[ + [5.0, 4.0, 5.0, 6.0, 5.0], + [2.0, 1.0, 2.0, 3.0, 2.0], + [5.0, 4.0, 5.0, 6.0, 5.0], + [8.0, 7.0, 8.0, 9.0, 8.0], + [5.0, 4.0, 5.0, 6.0, 5.0], + ]]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_edge_2d_test() { + // Test edge padding on a 2D tensor + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + let padded_tensor = tensor.pad((1, 1, 1, 1), PadMode::Edge); + + // Edge padding replicates the boundary values + let expected = TensorData::from([ + [1.0, 1.0, 2.0, 3.0, 3.0], + [1.0, 1.0, 2.0, 3.0, 3.0], + [4.0, 4.0, 5.0, 6.0, 6.0], + [4.0, 4.0, 5.0, 6.0, 6.0], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_edge_width_only_test() { + // Test edge padding on width dimension only + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0]]); + + let padded_tensor = tensor.pad((2, 3, 0, 0), PadMode::Edge); + + // Input: [1, 2, 3, 4] + // Left 2: [1, 1] + // Right 3: [4, 4, 4] + // Result: [1, 1, 1, 2, 3, 4, 4, 4, 4] + let expected = TensorData::from([[1.0, 1.0, 1.0, 2.0, 3.0, 4.0, 4.0, 4.0, 4.0]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_edge_4d_test() { + // Test edge padding on 4D tensor + let tensor = TestTensor::<4>::from([[[[1.0, 2.0], [3.0, 4.0]]]]); + + let padded_tensor = tensor.pad((1, 1, 1, 1), PadMode::Edge); + + let expected = TensorData::from([[[ + [1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0], + [3.0, 3.0, 4.0, 4.0], + [3.0, 3.0, 4.0, 4.0], + ]]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_default_test() { + // Test default PadMode (Constant with 0.0) + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let padded_tensor = tensor.pad((1, 1, 1, 1), PadMode::default()); + + let expected = TensorData::from([ + [0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 2.0, 0.0], + [0.0, 3.0, 4.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_max_valid_test() { + // Test reflect padding at maximum valid size (dim_size - 1) + // For a 4-element dimension, max valid padding is 3 + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0, 4.0]]); + + // Padding of 3 on left is valid for width=4 (3 < 4) + let padded_tensor = tensor.pad((3, 3, 0, 0), PadMode::Reflect); + + // Input: [1, 2, 3, 4] + // Reflect left 3: take indices [1, 2, 3] = [2, 3, 4], flip = [4, 3, 2] + // Reflect right 3: take indices [0, 1, 2] = [1, 2, 3], flip = [3, 2, 1] + // Result: [4, 3, 2, 1, 2, 3, 4, 3, 2, 1] + let expected = TensorData::from([[4.0, 3.0, 2.0, 1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_asymmetric_test() { + // Test asymmetric reflect padding + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]); + + // Asymmetric padding: left=2, right=1, top=1, bottom=2 + let padded_tensor = tensor.pad((2, 1, 1, 2), PadMode::Reflect); + + let expected = TensorData::from([ + [6.0, 5.0, 4.0, 5.0, 6.0, 5.0], + [3.0, 2.0, 1.0, 2.0, 3.0, 2.0], + [6.0, 5.0, 4.0, 5.0, 6.0, 5.0], + [9.0, 8.0, 7.0, 8.0, 9.0, 8.0], + [6.0, 5.0, 4.0, 5.0, 6.0, 5.0], + [3.0, 2.0, 1.0, 2.0, 3.0, 2.0], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic(expected = "Reflect padding")] +fn padding_reflect_exceeds_dimension_test() { + // Test that reflect padding panics when padding >= dim_size + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0]]); + + // Padding of 3 on width=3 should panic (3 >= 3, need padding < dim_size) + let _ = tensor.pad((3, 0, 0, 0), PadMode::Reflect); +} + +#[test] +fn padding_edge_asymmetric_test() { + // Test asymmetric edge padding + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + // Asymmetric padding: left=2, right=1, top=3, bottom=1 + let padded_tensor = tensor.pad((2, 1, 3, 1), PadMode::Edge); + + let expected = TensorData::from([ + [1.0, 1.0, 1.0, 2.0, 3.0, 3.0], + [1.0, 1.0, 1.0, 2.0, 3.0, 3.0], + [1.0, 1.0, 1.0, 2.0, 3.0, 3.0], + [1.0, 1.0, 1.0, 2.0, 3.0, 3.0], + [4.0, 4.0, 4.0, 5.0, 6.0, 6.0], + [4.0, 4.0, 4.0, 5.0, 6.0, 6.0], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_zero_padding_test() { + // Test that zero padding returns the original tensor unchanged + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let padded_constant = tensor.clone().pad((0, 0, 0, 0), PadMode::Constant(5.0)); + let padded_reflect = tensor.clone().pad((0, 0, 0, 0), PadMode::Reflect); + let padded_edge = tensor.clone().pad((0, 0, 0, 0), PadMode::Edge); + + let expected = TensorData::from([[1.0, 2.0], [3.0, 4.0]]); + padded_constant.into_data().assert_eq(&expected, false); + padded_reflect.into_data().assert_eq(&expected, false); + padded_edge.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_empty_tensor_constant_test() { + // Test constant padding on an empty tensor (zero-sized dimension) + // This should work - creates a tensor filled with the constant value + let tensor: TestTensor<2> = TestTensor::empty([0, 3], &Default::default()); + + // Padding an empty height dimension with constant should create a tensor of just padding + let padded = tensor.pad((0, 0, 2, 2), 1.0); + + // Result should be 4x3 (0 + 2 + 2 = 4 rows) + assert_eq!(padded.dims(), [4, 3]); + + let expected = TensorData::from([ + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic(expected = "edge padding")] +fn padding_empty_tensor_edge_panics_test() { + // Test that edge padding panics on empty tensor + let tensor: TestTensor<2> = TestTensor::empty([0, 3], &Default::default()); + + // Edge padding on zero-sized dimension should panic + let _ = tensor.pad((0, 0, 1, 1), PadMode::Edge); +} + +#[test] +#[should_panic(expected = "Reflect padding")] +fn padding_empty_tensor_reflect_panics_test() { + // Test that reflect padding panics on empty tensor + let tensor: TestTensor<2> = TestTensor::empty([0, 3], &Default::default()); + + // Reflect padding on zero-sized dimension should panic + let _ = tensor.pad((0, 0, 1, 1), PadMode::Reflect); +} + +// --- Tests for N-dimensional padding using (before, after) pairs --- + +#[test] +fn padding_constant_pairs_2d_test() { + // Same as padding_constant_2d_test but using the new pairs API + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + // [(row_before, row_after), (col_before, col_after)] + let padded_tensor = tensor.pad([(2, 2), (2, 2)], 1.1); + + let expected = TensorData::from([ + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 0.0, 1.0, 2.0, 1.1, 1.1], + [1.1, 1.1, 3.0, 4.0, 5.0, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1], + ]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_single_dim_test() { + // Pad only the last dimension + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let padded_tensor = tensor.pad([(1, 1)], 0.0); + + let expected = TensorData::from([[0.0, 1.0, 2.0, 0.0], [0.0, 3.0, 4.0, 0.0]]); + padded_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_all_dims_4d_test() { + // Pad all 4 dimensions of a 4D tensor (batch, channel, height, width) + // Input: shape [1, 1, 2, 2] + let tensor = TestTensor::<4>::from([[[[1.0, 2.0], [3.0, 4.0]]]]); + + // Pad: batch(1,1), channel(1,1), height(0,0), width(0,0) + let padded = tensor.pad([(1, 1), (1, 1), (0, 0), (0, 0)], 0.0); + + // Shape should be [3, 3, 2, 2] + assert_eq!(padded.dims(), [3, 3, 2, 2]); + + let expected = TensorData::from([ + [ + [[0.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 0.0]], + ], + [ + [[0.0, 0.0], [0.0, 0.0]], + [[1.0, 2.0], [3.0, 4.0]], + [[0.0, 0.0], [0.0, 0.0]], + ], + [ + [[0.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 0.0]], + [[0.0, 0.0], [0.0, 0.0]], + ], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_constant_batch_dim_only_test() { + // Pad only the batch dimension of a 3D tensor [N, H, W] + let tensor = TestTensor::<3>::from([[[1.0, 2.0], [3.0, 4.0]]]); + + // 3 pairs for 3 dims: batch(1,1), height(0,0), width(0,0) + let padded = tensor.pad([(1, 1), (0, 0), (0, 0)], -1.0); + + assert_eq!(padded.dims(), [3, 2, 2]); + + let expected = TensorData::from([ + [[-1.0, -1.0], [-1.0, -1.0]], + [[1.0, 2.0], [3.0, 4.0]], + [[-1.0, -1.0], [-1.0, -1.0]], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_pairs_test() { + // Reflect padding using pairs API + let tensor = TestTensor::<2>::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]); + + let padded = tensor.pad([(1, 1), (1, 1)], PadMode::Reflect); + + let expected = TensorData::from([ + [5.0, 4.0, 5.0, 6.0, 5.0], + [2.0, 1.0, 2.0, 3.0, 2.0], + [5.0, 4.0, 5.0, 6.0, 5.0], + [8.0, 7.0, 8.0, 9.0, 8.0], + [5.0, 4.0, 5.0, 6.0, 5.0], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_edge_pairs_test() { + // Edge padding using pairs API + let tensor = TestTensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]); + + let padded = tensor.pad([(1, 1), (1, 1)], PadMode::Edge); + + let expected = TensorData::from([ + [1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0], + [3.0, 3.0, 4.0, 4.0], + [3.0, 3.0, 4.0, 4.0], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_reflect_batch_dim_3d_test() { + // Reflect pad the batch dimension of a 3D tensor [N, H, W] + // Input shape: [3, 1, 2] - 3 batches, 1 row, 2 cols + let tensor = TestTensor::<3>::from([[[1.0, 2.0]], [[3.0, 4.0]], [[5.0, 6.0]]]); + + // Pad batch dim with reflect(1, 1), no spatial padding + let padded = tensor.pad([(1, 1), (0, 0), (0, 0)], PadMode::Reflect); + + assert_eq!(padded.dims(), [5, 1, 2]); + + // Reflect on batch: [3,4] [1,2] [3,4] [5,6] [3,4] + let expected = TensorData::from([ + [[3.0, 4.0]], + [[1.0, 2.0]], + [[3.0, 4.0]], + [[5.0, 6.0]], + [[3.0, 4.0]], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +fn padding_edge_batch_dim_3d_test() { + // Edge pad the batch dimension of a 3D tensor + let tensor = TestTensor::<3>::from([[[1.0, 2.0]], [[3.0, 4.0]]]); + + let padded = tensor.pad([(2, 1), (0, 0), (0, 0)], PadMode::Edge); + + assert_eq!(padded.dims(), [5, 1, 2]); + + let expected = TensorData::from([ + [[1.0, 2.0]], + [[1.0, 2.0]], + [[1.0, 2.0]], + [[3.0, 4.0]], + [[3.0, 4.0]], + ]); + padded.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic(expected = "Padding has")] +fn padding_too_many_pairs_panics_test() { + let tensor = TestTensor::<2>::from([[1.0, 2.0]]); + + // 3 pairs for a 2D tensor should panic + let _ = tensor.pad([(1, 1), (1, 1), (1, 1)], 0.0); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/permute.rs b/crates/burn-backend-tests/tests/tensor/float/ops/permute.rs new file mode 100644 index 0000000..c9a2982 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/permute.rs @@ -0,0 +1,58 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn permute_float_a() { + let tensor = TestTensor::<1>::from([ + 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., + ]) + .reshape([2, 2, 4]); + + let permuted = tensor.clone().permute([2, 1, 0]); + + let expected = TensorData::from([ + [[0., 8.], [4., 12.]], + [[1., 9.], [5., 13.]], + [[2., 10.], [6., 14.]], + [[3., 11.], [7., 15.]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().permute([-1, 1, 0]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().permute([0, 1, 2]); + permuted.into_data().assert_eq(&tensor.into_data(), false); +} + +#[test] +fn permute_float() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device) + .reshape([2, 3, 4]) + .float(); + + let permuted = tensor.clone().permute([2, 1, 0]); + + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).permute(2, 1, 0).float() + let expected = TensorData::from([ + [[0., 12.], [4., 16.], [8., 20.]], + [[1., 13.], [5., 17.], [9., 21.]], + [[2., 14.], [6., 18.], [10., 22.]], + [[3., 15.], [7., 19.], [11., 23.]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().permute([-1, 1, 0]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().permute([0, 1, 2]); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/powf.rs b/crates/burn-backend-tests/tests/tensor/float/ops/powf.rs new file mode 100644 index 0000000..ace2936 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/powf.rs @@ -0,0 +1,105 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_powf_ops() { + let data = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + let pow = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 2.0]]); + let tensor_pow = TestTensor::<2>::from_data(pow, &Default::default()); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1.0, 1.0, 4.0], [27.0, 256.0, 25.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_power() { + let data = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + let pow = TensorData::from([[-0.95, -0.67, -0.45], [-0.24, -0.5, -0.6]]); + let tensor_pow = TestTensor::<2>::from_data(pow, &Default::default()); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1., 1., 0.73204285], [0.76822936, 0.5, 0.38073079]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_values_with_even_power() { + let data = TensorData::from([[1.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + let pow = TensorData::from([[2.0, 2.0, 4.0], [4.0, 4.0, 2.0]]); + let tensor_pow = TestTensor::<2>::from_data(pow, &Default::default()); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1.0, 1.0, 16.0], [81.0, 256.0, 25.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_values_with_odd_power() { + let data = TensorData::from([[1.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + let pow = TensorData::from([[3.0, 3.0, 3.0], [3.0, 3.0, 3.0]]); + let tensor_pow = TestTensor::<2>::from_data(pow, &Default::default()); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1.0, -1.0, -8.0], [-27.0, -64.0, -125.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_powf_broadcasted() { + let device = Default::default(); + let tensor_1 = TestTensor::<1>::from_data([2.0, 3.0, 4.0], &device); + let tensor_2 = TestTensor::from_data([1.0], &device); + + // Broadcast rhs + let output = tensor_1.clone().powf(tensor_2.clone()); + output + .into_data() + .assert_approx_eq::(&tensor_1.to_data(), Tolerance::default()); + + // Broadcast lhs + let output = tensor_2.powf(tensor_1); + output + .into_data() + .assert_approx_eq::(&TensorData::from([1.0, 1.0, 1.0]), Tolerance::default()); +} + +fn outer(a: TestTensor<1>, b: TestTensor<1>) -> TestTensor<2> { + a.unsqueeze_dim::<2>(1) * b.unsqueeze_dim::<2>(0) +} + +#[test] +fn should_support_powf_scalar_tensor() { + let device = Default::default(); + let head_dim = 64; + let seq_len = 1024; + let base = 10000; + + let channel_range = TestTensorInt::arange_step(0..head_dim as i64, 2, &device).float(); + let base = TestTensor::<1>::from_data([base as f32], &device); + let inv_freq = base.powf(-channel_range / head_dim as f32); + + let t = TestTensorInt::arange(0..seq_len as i64, &device).float(); + + let freqs = outer(t, inv_freq); + + let _cos = freqs.clone().cos(); + let _sin = freqs.sin(); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/powf_scalar.rs b/crates/burn-backend-tests/tests/tensor/float/ops/powf_scalar.rs new file mode 100644 index 0000000..1b19589 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/powf_scalar.rs @@ -0,0 +1,55 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_powf_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.powf_scalar(0.71); + let expected = TensorData::from([[0.0, 1.0, 1.6358], [2.1815, 2.67586, 3.13522]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_power() { + let data = TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.powf_scalar(-0.33); + let expected = TensorData::from([[1.0, 1.0, 0.79553646], [0.695905, 0.6328783, 0.58794934]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_values_with_even_power() { + let data = TensorData::from([[0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.powf_scalar(4.0); + let expected = TensorData::from([[0.0, 1.0, 16.0], [81.0, 256.0, 625.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_neg_values_with_odd_power() { + let data = TensorData::from([[0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.powf_scalar(3.0); + let expected = TensorData::from([[0.0, -1.0, -8.0], [-27.0, -64.0, -125.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/prod.rs b/crates/burn-backend-tests/tests/tensor/float/ops/prod.rs new file mode 100644 index 0000000..dc9156e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/prod.rs @@ -0,0 +1,48 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_prod_float() { + let tensor_1 = TestTensor::<2>::from([[-5.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor_1.prod(); + + output + .into_data() + .assert_eq(&TensorData::from([-600.0]), false); +} + +#[test] +fn test_prod_dim_2d() { + let f = TestTensor::<2>::from([[-5.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + f.clone() + .prod_dim(1) + .into_data() + .assert_eq(&TensorData::from([[-10.0], [60.0]]), false); + + f.clone() + .prod_dim(-1) + .into_data() + .assert_eq(&TensorData::from([[-10.0], [60.0]]), false); +} + +#[test] +fn test_prod_dims_2d() { + let f = TestTensor::<2>::from([[-5.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + f.clone() + .prod_dims(&[1]) + .into_data() + .assert_eq(&TensorData::from([[-10.0], [60.0]]), false); + + f.clone() + .prod_dims(&[-1]) + .into_data() + .assert_eq(&TensorData::from([[-10.0], [60.0]]), false); + + f.clone() + .prod_dims(&[0, 1]) + .into_data() + .assert_eq(&TensorData::from([[-600.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/random.rs b/crates/burn-backend-tests/tests/tensor/float/ops/random.rs new file mode 100644 index 0000000..871a8ac --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/random.rs @@ -0,0 +1,53 @@ +use super::*; +use burn_tensor::{Device, Distribution, ElementConversion, TensorData, Tolerance}; + +#[test] +fn rand_default() { + let tensor = TestTensor::<1>::random([20], Distribution::Default, &Default::default()); + + // check that the tensor is within the range of [0..1) (1 is exclusive) + // the conversion can ceil the value if `FloatElem` is less precise than f32 + let low = 0.elem::(); + let high = 1.elem::(); + if FloatElem::EPSILON.elem::() > f32::EPSILON { + tensor.into_data().assert_within_range_inclusive(low..=high); + } else { + tensor.into_data().assert_within_range(low..high); + } +} + +#[test] +fn rand_uniform() { + let tensor = TestTensor::<1>::random([20], Distribution::Uniform(4., 5.), &Default::default()); + let low = 4.elem::(); + let high = 5.elem::(); + + if FloatElem::EPSILON.elem::() > f32::EPSILON { + tensor.into_data().assert_within_range_inclusive(low..=high); + } else { + tensor.into_data().assert_within_range(low..high); + } +} + +#[test] +fn rand_bernoulli() { + let tensor = TestTensor::<1>::random([20], Distribution::Bernoulli(1.), &Default::default()); + + tensor.into_data().assert_eq( + &TensorData::new::(vec![1.elem(); 20], [20]), + true, + ); +} + +#[test] +#[ignore] // TODO: mark serial for backends that handle the same devices (e.g. fusion)? +fn test_seed_reproducibility() { + let device = Device::default(); + device.seed(42); + let t1 = TestTensor::<1>::random([5], Distribution::Default, &device); + device.seed(42); + let t2 = TestTensor::<1>::random([5], Distribution::Default, &device); + + t1.into_data() + .assert_approx_eq::(&t2.into_data(), Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/recip.rs b/crates/burn-backend-tests/tests/tensor/float/ops/recip.rs new file mode 100644 index 0000000..689b023 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/recip.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_recip_ops() { + let data = TensorData::from([[0.5, 1.0, 2.0], [3.0, -4.0, -5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.recip(); + let expected = TensorData::from([[2.0, 1.0, 0.5], [0.33333, -0.25, -0.2]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/remainder.rs b/crates/burn-backend-tests/tests/tensor/float/ops/remainder.rs new file mode 100644 index 0000000..e48eb92 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/remainder.rs @@ -0,0 +1,241 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +/// From https://pytorch.org/docs/stable/generated/torch.remainder.html +#[test] +fn should_support_remainder_basic() { + let device = Default::default(); + let lhs = + TestTensor::<1>::from_data(TensorData::from([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]), &device); + let rhs = TestTensor::<1>::from_data(TensorData::from([2.0, 3.0, 1.0, 2.0, 1.0, 3.0]), &device); + let output = lhs.remainder(rhs); + let expected = TensorData::from([1.0, 1.0, -0.0, 1.0, 0.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_remainder_basic_scalar() { + let data = TensorData::from([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.remainder_scalar(2.0); + let expected = TensorData::from([1.0, 0.0, 1.0, 1.0, 0.0, 1.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_remainder_float() { + let device = Default::default(); + let lhs = TestTensor::<1>::from_data(TensorData::from([1.0, 2.0, 3.0, 4.0, 5.0]), &device); + let rhs = TestTensor::<1>::from_data( + TensorData::from([1.4233, 2.7313, 0.2641, 1.9651, 0.5897]), + &device, + ); + let output = lhs.remainder(rhs); + let expected = TensorData::from([1.0, 2.0, 0.0949, 0.0698, 0.2824]); + + // Metal has less precise remainder function + let tolerance = Tolerance::default() + .set_half_precision_relative(1e-2) + .set_half_precision_absolute(2e-3); + + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +/// Also from https://pytorch.org/docs/stable/generated/torch.remainder.html +#[test] +fn should_support_remainder_float_scalar() { + let data = TensorData::from([1.0, 2.0, 3.0, 4.0, 5.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.clone().remainder_scalar(-1.5); + let expected = TensorData::from([-0.5, -1.0, 0.0, -0.5, -1.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_be_zero() { + let device = Default::default(); + let lhs = TestTensor::<1>::from_data(TensorData::from([0.0, 0.0, 0.0]), &device); + let rhs = TestTensor::<1>::from_data(TensorData::from([3.5, -2.1, 1e-4]), &device); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([0.0, 0.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_be_zero_scalar() { + let data = TensorData::from([0.0, 0.0, 0.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.clone().remainder_scalar(3.5); + let expected = TensorData::from([0.0, 0.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_have_no_remainder() { + let device = Default::default(); + let lhs = TestTensor::<1>::from_data( + // Previous values failed on some vulkan backends (driver bug?) + // TensorData::from([-1.4843, 1.1350, -2.1563, 1.0862, 0.5, 3.6587]), + TensorData::from([-1.0, 1.5, -2.0, 2.5, 0.5, 4.0]), + &device, + ); + let rhs = TestTensor::<1>::from_data( + // TensorData::from([1.4843, 1.1350, 2.1563, 1.0862, 0.5, 3.6587]), + TensorData::from([1.0, 1.5, 2.0, 2.5, 0.5, 4.0]), + &device, + ); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([-0., 0., -0., 0., 0., 0.]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_have_no_remainder_scalar() { + let data = TensorData::from([-4.0, 4.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.remainder_scalar(4.0); + let expected = TensorData::from([-0.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_be_negative() { + let device = Default::default(); + + let lhs = TestTensor::<1>::from_data(TensorData::from([-7.0, -3.0, 2.0, 6.0]), &device); + let rhs = TestTensor::<1>::from_data(TensorData::from([-2.5, -2.1, -1.5, -3.25]), &device); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([-2.0, -0.9, -1.0, -0.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_be_negative_scalar() { + let data = TensorData::from([-7.0, -3.0, 2.0, 6.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.clone().remainder_scalar(-2.5); + let expected = TensorData::from([-2.0, -0.50, -0.50, -1.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_fp_dividends() { + let data = TensorData::from([-7.5, -2.5, 2.5, 7.5]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.remainder_scalar(3.0); + let expected = TensorData::from([1.5, 0.5, 2.5, 1.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + // for tensor.remainder case, tests above have already covered float point dividend cases +} + +#[test] +fn should_support_large_divisor() { + let device = Default::default(); + + let lhs = TestTensor::<1>::from_data( + TensorData::from([-1.0, 1.0, -1.5, 1.5, -1.0, 1.0, -1.5, 1.5]), + &device, + ); + let rhs = TestTensor::<1>::from_data( + TensorData::from([10.0, 10.0, 10.0, 10.0, -10.0, -10.0, -10.0, -10.0]), + &device, + ); + let output = lhs.remainder(rhs); + let expected = TensorData::from([9.0, 1.0, 8.5, 1.5, -1.0, -9.0, -1.5, -8.5]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_large_divisor_scalar() { + let data = TensorData::from([-1.0, 1.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.remainder_scalar(10.0); + let expected = TensorData::from([9.0, 1.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_remainder_op() { + let device = Default::default(); + let lhs = + TestTensor::<1>::from_data(TensorData::from([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]), &device); + let rhs = TestTensor::<1>::from_data(TensorData::from([2.0, 3.0, 1.0, 2.0, 1.0, 3.0]), &device); + + let output = lhs % rhs; + let expected = TensorData::from([1.0, 1.0, -0.0, 1.0, 0.0, 0.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_remainder_scalar_op() { + let data = TensorData::from([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor % 2.0; + let expected = TensorData::from([1.0, 0.0, 1.0, 1.0, 0.0, 1.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/repeat.rs b/crates/burn-backend-tests/tests/tensor/float/ops/repeat.rs new file mode 100644 index 0000000..be843ef --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/repeat.rs @@ -0,0 +1,108 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_repeat_ops_one_dimension() { + let data = TensorData::from([[0.0f32, 1.0f32, 2.0f32]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[4, 1, 1]); + let expected = TensorData::from([ + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_float_repeat_repeating_on_many_dimensions() { + let data = TensorData::from([ + [[1.0f32, 2.0f32], [3.0f32, 4.0f32]], + [[5.0f32, 6.0f32], [7.0f32, 8.0f32]], + [[9.0f32, 10.0f32], [11.0f32, 12.0f32]], + [[13.0f32, 14.0f32], [15.0f32, 16.0f32]], + ]); + let tensor = TestTensor::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[2, 3, 2]); + let expected = TensorData::from([ + [ + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + ], + [ + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + ], + [ + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + ], + [ + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + ], + [ + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + ], + [ + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + ], + [ + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + ], + [ + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_repeat_0_times_empty() { + let tensor = TestTensor::<3>::ones([2, 3, 4], &Default::default()); + + let output = tensor.repeat(&[1, 0, 2]); + + assert_eq!(output.shape(), [2, 0, 8].into()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/repeat_dim.rs b/crates/burn-backend-tests/tests/tensor/float/ops/repeat_dim.rs new file mode 100644 index 0000000..7504cad --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/repeat_dim.rs @@ -0,0 +1,182 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_repeat_ops() { + let data = TensorData::from([[0.0f64, 1.0f64, 2.0f64]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + let output = tensor.repeat_dim(0, 4); + let expected = TensorData::from([ + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + [0.0f32, 1.0f32, 2.0f32], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_float_repeat_on_dims_larger_than_1() { + let data = TensorData::from([ + [[1.0f32, 2.0f32], [3.0f32, 4.0f32]], + [[5.0f32, 6.0f32], [7.0f32, 8.0f32]], + [[9.0f32, 10.0f32], [11.0f32, 12.0f32]], + [[13.0f32, 14.0f32], [15.0f32, 16.0f32]], + ]); + let tensor = TestTensor::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat_dim(2, 2); + let expected = TensorData::from([ + [ + [1.0f32, 2.0f32, 1.0f32, 2.0f32], + [3.0f32, 4.0f32, 3.0f32, 4.0f32], + ], + [ + [5.0f32, 6.0f32, 5.0f32, 6.0f32], + [7.0f32, 8.0f32, 7.0f32, 8.0f32], + ], + [ + [9.0f32, 10.0f32, 9.0f32, 10.0f32], + [11.0f32, 12.0f32, 11.0f32, 12.0f32], + ], + [ + [13.0f32, 14.0f32, 13.0f32, 14.0f32], + [15.0f32, 16.0f32, 15.0f32, 16.0f32], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn repeat_dim_swap_dims_1() { + let tensor = TestTensorInt::arange(0..16, &Default::default()).float(); + + let tensor = tensor.reshape([4, 1, 4]); + let tensor = tensor.swap_dims(0, 2); + let output = tensor.repeat_dim(1, 4); + + let expected = TensorData::from([ + [ + [0.0, 4.0, 8.0, 12.0], + [0.0, 4.0, 8.0, 12.0], + [0.0, 4.0, 8.0, 12.0], + [0.0, 4.0, 8.0, 12.0], + ], + [ + [1.0, 5.0, 9.0, 13.0], + [1.0, 5.0, 9.0, 13.0], + [1.0, 5.0, 9.0, 13.0], + [1.0, 5.0, 9.0, 13.0], + ], + [ + [2.0, 6.0, 10.0, 14.0], + [2.0, 6.0, 10.0, 14.0], + [2.0, 6.0, 10.0, 14.0], + [2.0, 6.0, 10.0, 14.0], + ], + [ + [3.0, 7.0, 11.0, 15.0], + [3.0, 7.0, 11.0, 15.0], + [3.0, 7.0, 11.0, 15.0], + [3.0, 7.0, 11.0, 15.0], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn repeat_dim_swap_dims_2() { + let tensor = TestTensorInt::arange(0..16, &Default::default()).float(); + + let tensor = tensor.reshape([2, 2, 1, 4]); + let tensor = tensor.swap_dims(0, 1); + let output = tensor.repeat_dim(2, 4); + + let expected = TensorData::from([ + [ + [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + ], + [ + [8.0, 9.0, 10.0, 11.0], + [8.0, 9.0, 10.0, 11.0], + [8.0, 9.0, 10.0, 11.0], + [8.0, 9.0, 10.0, 11.0], + ], + ], + [ + [ + [4.0, 5.0, 6.0, 7.0], + [4.0, 5.0, 6.0, 7.0], + [4.0, 5.0, 6.0, 7.0], + [4.0, 5.0, 6.0, 7.0], + ], + [ + [12.0, 13.0, 14.0, 15.0], + [12.0, 13.0, 14.0, 15.0], + [12.0, 13.0, 14.0, 15.0], + [12.0, 13.0, 14.0, 15.0], + ], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn repeat_dim_swap_dims_3() { + let tensor = TestTensorInt::arange(0..16, &Default::default()).float(); + + let tensor = tensor.reshape([1, 2, 2, 4]); + let tensor = tensor.swap_dims(0, 2); + let tensor = tensor.swap_dims(1, 3); + let output = tensor.repeat_dim(2, 4); + + let expected = TensorData::from([ + [ + [[0.0, 8.0], [0.0, 8.0], [0.0, 8.0], [0.0, 8.0]], + [[1.0, 9.0], [1.0, 9.0], [1.0, 9.0], [1.0, 9.0]], + [[2.0, 10.0], [2.0, 10.0], [2.0, 10.0], [2.0, 10.0]], + [[3.0, 11.0], [3.0, 11.0], [3.0, 11.0], [3.0, 11.0]], + ], + [ + [[4.0, 12.0], [4.0, 12.0], [4.0, 12.0], [4.0, 12.0]], + [[5.0, 13.0], [5.0, 13.0], [5.0, 13.0], [5.0, 13.0]], + [[6.0, 14.0], [6.0, 14.0], [6.0, 14.0], [6.0, 14.0]], + [[7.0, 15.0], [7.0, 15.0], [7.0, 15.0], [7.0, 15.0]], + ], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_repeat_dim_0_times_empty() { + let tensor = TestTensor::<3>::ones([2, 3, 4], &Default::default()); + + let output = tensor.repeat_dim(2, 0); + + assert_eq!(output.shape(), [2, 3, 0].into()); +} + +#[test] +fn should_support_unsqueeze_of_repeat_dim_view() { + // `repeat_dim` on a size-1 dim returns a broadcast view (stride 0 on + // that dim). A following unsqueeze (reshape) must not let the unit + // dim's stride leak onto real dims: [3, 1] with strides [1, 0] + // reshaped to [3, 1, 1] has to keep stride 1 on dim 0, otherwise + // every row reads element 0. + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0], &device); + let view: TestTensor<2> = tensor.unsqueeze_dim(1).repeat_dim(1, 1); + let view: TestTensor<3> = view.unsqueeze_dim(2); + + view.into_data() + .assert_eq(&TensorData::from([[[1.0]], [[2.0]], [[3.0]]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/reshape.rs b/crates/burn-backend-tests/tests/tensor/float/ops/reshape.rs new file mode 100644 index 0000000..4b1a772 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/reshape.rs @@ -0,0 +1,90 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_rank() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + assert_eq!(tensor.rank(), 1); + + let data = TensorData::from([[0.0, 1.0, 2.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + assert_eq!(tensor.rank(), 2); +} + +#[test] +fn should_support_reshape_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.clone().reshape([1, 3]); + let expected = TensorData::from([[0.0, 1.0, 2.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_reshape_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.clone().reshape([6]); + let expected = TensorData::from([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_dim_infererence() { + let data = TensorData::from([ + [0.0, 1.0, 2.0], + [3.0, 4.0, 5.0], + [6.0, 7.0, 8.0], + [9.0, 10.0, 11.0], + ]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + // Infer the dimension via -1 + let reshaped = tensor.clone().reshape([2, -1]); + assert_eq!(reshaped.shape(), [2, 6].into()); + + // Infer the dimension via 0 (keep from the source) and -1 (infer) + let reshaped = reshaped.reshape([0, 2, -1]); + assert_eq!(reshaped.shape(), [2, 2, 3].into()); + + // This is effectively as if we did a flatten + let reshaped = tensor.clone().reshape([-1]); + assert_eq!(reshaped.shape(), [12].into()); + + // Keeping the first dimension the same (using 0) + let reshaped = tensor.clone().reshape([0, 3]); + assert_eq!(reshaped.shape(), [4, 3].into()); +} + +#[test] +fn should_not_corrupt_after_slice() { + let zeros = TestTensor::<1>::zeros([2], &Default::default()); + zeros.clone().slice([1..2]).reshape([1]).exp(); + + // May lead to zeroes being equal to [0.0, 1.0] + zeros.into_data().assert_eq( + &TestTensor::<1>::zeros([2], &Default::default()).to_data(), + true, + ); +} + +#[test] +#[should_panic] +fn multiple_neg_ones() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + let _data_actual = tensor.reshape([-1, -1]).into_data(); +} + +#[test] +#[should_panic] +fn neg_value() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + let _data_actual = tensor.reshape([-2, -1]).into_data(); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/round.rs b/crates/burn-backend-tests/tests/tensor/float/ops/round.rs new file mode 100644 index 0000000..f6161da --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/round.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_round_ops() { + let data = TensorData::from([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.round(); + let expected = TensorData::from([[24., 88., 76.], [60., 44., 95.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_round_ties_even() { + let data = TensorData::from([1.5, 2.5, 3.5, 4.5, 5.5, 6.5]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.round(); + let expected = TensorData::from([2., 2., 4., 4., 6., 6.]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/select.rs b/crates/burn-backend-tests/tests/tensor/float/ops/select.rs new file mode 100644 index 0000000..3231c85 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/select.rs @@ -0,0 +1,251 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_select_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 1.0, 2.0], &device); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([1.0, 1.0, 0.0, 1.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_2d_dim0_same_num_dim() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_data([1, 0], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([[3.0, 4.0, 5.0], [0.0, 1.0, 2.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_2d_dim0_more_num_dim() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_data([1, 0, 1, 1], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([ + [3.0, 4.0, 5.0], + [0.0, 1.0, 2.0], + [3.0, 4.0, 5.0], + [3.0, 4.0, 5.0], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_2d_dim0_vec() { + let device = Default::default(); + let tensor = + TestTensor::<2>::from_data([[0.0, 1.0], [2.0, 3.0], [4.0, 5.0], [6.0, 7.0]], &device); + let indices = TestTensorInt::from_data([1, 0, 3, 2], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([[2.0, 3.0], [0.0, 1.0], [6.0, 7.0], [4.0, 5.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_2d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &device); + + let output = tensor.select(1, indices); + let expected = TensorData::from([[1.0, 1.0, 0.0, 1.0, 2.0], [4.0, 4.0, 3.0, 4.0, 5.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 1.0, 2.0], &device); + let values = TestTensor::from_data([5.0, 4.0, 3.0, 2.0, 1.0], &device); + let indices = TestTensorInt::from_data(TensorData::from([1, 1, 0, 1, 2]), &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([3.0, 12.0, 3.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_1d_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([7, 8, 9], &device); + let values = TestTensorInt::from_data([5, 4, 3, 2, 1], &device); + let indices = TestTensorInt::from_data(TensorData::from([1, 1, 0, 1, 2]), &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([10, 19, 10]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_2d_dim0() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let values = TestTensor::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::from_data(TensorData::from([1, 0]), &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([[4.0, 6.0, 8.0], [4.0, 6.0, 8.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_2d_dim1() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let values = TestTensor::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::from_data(TensorData::from([1, 0, 2]), &device); + + let output = tensor.select_assign(1, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([[2.0, 2.0, 5.0], [8.0, 8.0, 11.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_3d_dim1_vec() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]], + [[-1.0, -2.0], [-3.0, -4.0], [-5.0, -6.0], [-7.0, -8.0]], + ], + &device, + ); + let indices = TestTensorInt::from_data([1, 0, 3, 2], &device); + + let output = tensor.select(1, indices); + let expected = TensorData::from([ + [[3.0, 4.0], [1.0, 2.0], [7.0, 8.0], [5.0, 6.0]], + [[-3.0, -4.0], [-1.0, -2.0], [-7.0, -8.0], [-5.0, -6.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn should_select_panic_invalid_dimension() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &device); + + tensor.select(10, indices); +} + +#[test] +fn should_match_default_implementation_behavior() { + // Verify optimized implementation matches original default logic + let device = Default::default(); + let tensor = TestTensorBool::<1>::from_data([true, false, true], &device); + let indices = TestTensorInt::from_data([0, 1, 0], &device); + let values = TestTensorBool::<1>::from_data([false, true, true], &device); + + let optimized_result = + tensor + .clone() + .select_assign(0, indices.clone(), values.clone(), IndexingUpdateOp::Add); + + // Manual default implementation logic + let int_tensor = tensor.int(); + let int_values = values.int(); + let assigned = int_tensor.select_assign(0, indices, int_values, IndexingUpdateOp::Add); + let default_result = assigned.greater_elem(0); + + optimized_result + .into_data() + .assert_eq(&default_result.into_data(), false); +} + +#[test] +fn should_select_with_negative_dim_2d() { + // Test using negative dimension indexing on 2D tensor + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::from_data([1, 0, 2], &device); + + // Using -1 should refer to the last dimension (dim 1) + let output_neg = tensor.clone().select(-1, indices.clone()); + let output_pos = tensor.select(1, indices); + + // Both should produce the same result + output_neg + .into_data() + .assert_eq(&output_pos.into_data(), false); +} + +#[test] +fn should_select_add_with_negative_dim_2d() { + // Test select_add with negative dimension on 2D tensor + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let values = TestTensor::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let indices = TestTensorInt::from_data([0, 2], &device); + + // Using -1 should refer to the last dimension (dim 1) + let output_neg = + tensor + .clone() + .select_assign(-1, indices.clone(), values.clone(), IndexingUpdateOp::Add); + let output_pos = tensor.select_assign(1, indices, values, IndexingUpdateOp::Add); + + output_neg + .into_data() + .assert_eq(&output_pos.into_data(), false); +} + +#[test] +#[should_panic] +fn should_panic_select_negative_dim_out_of_bounds() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let indices = TestTensorInt::from_data([0, 1], &device); + + // This should panic because -3 is out of bounds for a 2D tensor + tensor.select(-3, indices); +} + +#[test] +#[should_panic] +fn should_panic_select_add_negative_dim_out_of_bounds() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let values = TestTensor::from_data([[5.0], [6.0]], &device); + let indices = TestTensorInt::from_data([0], &device); + + // This should panic because -3 is out of bounds for a 2D tensor + tensor.select_assign(-3, indices, values, IndexingUpdateOp::Add); +} + +#[test] +#[ignore = "Empty dims support is sparse"] +fn should_select_2d_dim0_empty_indices() { + // Zero-length index tensor: output should keep shape [0, cols] without + // triggering OOB reads. + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device); + let indices = TestTensorInt::<1>::from_data(TensorData::new(Vec::::new(), [0]), &device); + + let output = tensor.select(0, indices); + + assert_eq!(output.dims(), [0, 2]); + let out: Vec = output.into_data().to_vec().unwrap(); + assert!(out.is_empty()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/sign.rs b/crates/burn-backend-tests/tests/tensor/float/ops/sign.rs new file mode 100644 index 0000000..6b71edc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/sign.rs @@ -0,0 +1,12 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_sign_ops_float() { + let tensor = TestTensor::<2>::from([[-0.2, -1.0, 2.0], [3.0, 0.0, -5.0]]); + + let output = tensor.sign(); + let expected = TensorData::from([[-1.0, -1.0, 1.0], [1.0, 0.0, -1.0]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/slice.rs b/crates/burn-backend-tests/tests/tensor/float/ops/slice.rs new file mode 100644 index 0000000..5080fc1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/slice.rs @@ -0,0 +1,591 @@ +use super::*; +use burn_tensor::{ElementConversion, Slice, TensorData, s}; + +#[test] +fn should_support_slice_dim_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data.clone(), &Default::default()); + + // Test with range (negative index) + let output = tensor.clone().slice_dim(0, -2..); + output + .into_data() + .assert_eq(&TensorData::from([1.0, 2.0]), false); + + // Test with Slice directly + let slice = Slice::new(1, None, 1); // equivalent to 1.. + let output = tensor.slice_dim(0, slice); + output + .into_data() + .assert_eq(&TensorData::from([1.0, 2.0]), false); +} + +#[test] +#[should_panic(expected = "The provided dimension exceeds the tensor dimensions")] +fn should_panic_when_slice_dim_1d_bad_dim() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data.clone(), &Default::default()); + + let _output = tensor.slice_dim(1, 1..); +} + +#[test] +fn should_support_slice_dim_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + let output = tensor.slice_dim(1, 1..); + output + .into_data() + .assert_eq(&TensorData::from([[1.0, 2.0], [4.0, 5.0]]), false); +} + +#[test] +fn should_support_slice_dim_with_step() { + let data = TensorData::from([[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + // Test 1: Slice dimension 1 with step=2 using s! macro + let output = tensor.clone().slice_dim(1, s![0..4;2]); + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 2.0], [4.0, 6.0]]), false); + + // Test 2: Slice dimension 1 with step=2 using Slice directly + let slice = Slice::new(0, Some(4), 2); + let output = tensor.slice_dim(1, slice); + output + .into_data() + .assert_eq(&TensorData::from([[0.0, 2.0], [4.0, 6.0]]), false); +} + +#[test] +fn should_support_slice_dim_with_negative_step() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + // Slice dimension 1 with negative step (reverse columns) + let output = tensor.slice_dim(1, s![..;-1]); + output + .into_data() + .assert_eq(&TensorData::from([[2.0, 1.0, 0.0], [5.0, 4.0, 3.0]]), false); +} + +#[test] +fn should_support_full_sliceing_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data.clone(), &Default::default()); + + let output = tensor.slice([0..3]); + + output.into_data().assert_eq(&data, false); +} + +#[test] +fn should_support_full_sliceing_vec() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + let slices: Vec = vec![(0..2).into()]; + + let output = tensor.clone().slice(&slices); + output.into_data().assert_eq(&data, false); + + let output = tensor.slice([0..2, 0..3]); + output.into_data().assert_eq(&data, false); +} + +#[test] +fn should_support_partial_sliceing_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.slice([1..3]); + let expected = TensorData::from([1.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_full_sliceing_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data.clone(), &Default::default()); + + let output = tensor.clone().slice([0..2]); + output.into_data().assert_eq(&data, false); + + let output = tensor.slice([0..2, 0..3]); + output.into_data().assert_eq(&data, false); +} + +#[test] +fn should_support_partial_sliceing_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.slice([0..2, 0..2]); + let expected = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_range_first_dim() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.slice(0..1); + let expected = TensorData::from([[0.0, 1.0, 2.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_partial_sliceing_3d() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let output = tensor.slice([1..2, 1..2, 0..2]); + let expected = TensorData::from([[[9.0, 10.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_partial_sliceing_3d_non_contiguous() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let output = tensor.transpose().slice([1..2, 1..2, 0..2]); + let expected = TensorData::from([[[7.0, 10.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.slice_fill([0..2], -1.0); + let expected = TensorData::from([-1.0, -1.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_vec() { + let data = TensorData::from([0.0, 1.0, 2.0]); + + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let slices: Vec = vec![(0..2).into()]; + + let output = tensor.slice_fill(&slices, -1.0); + let expected = TensorData::from([-1.0, -1.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_cast_f32() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device).cast(burn_tensor::DType::F32); + + tensor + .slice_fill(s![0..2], 1.0) + .into_data() + .assert_eq(&TensorData::from([1.0, 1.0, 2.0]), false); +} + +// Skip on metal - F64 not supported +#[cfg(not(feature = "metal"))] +#[test] +fn should_support_slice_fill_cast_f64() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device).cast(burn_tensor::DType::F64); + + tensor + .slice_fill(s![0..2], 1.0) + .into_data() + .assert_eq(&TensorData::from([1.0, 1.0, 2.0]), false); +} + +#[test] +fn should_support_slice_fill_1d_neg() { + let data = TensorData::from([0.0, 1.0, 2.0]); + + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + + let output = tensor.slice_fill([-1..], -1.0); + let expected = TensorData::from([0.0, 1.0, -1.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let device = Default::default(); + let tensor = TestTensor::<2>::from_data(data, &device); + + let output = tensor.slice_fill([1..2, 0..2], -1.0); + let expected = TensorData::from([[0.0, 1.0, 2.0], [-1.0, -1.0, 5.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_with_positive_step() { + let device = Default::default(); + + // Test 1D tensor with step + let tensor = TestTensor::<1>::zeros([10], &device); + let output = tensor.slice_fill(s![0..10;2], 5.0); + let expected = TensorData::from([5.0, 0.0, 5.0, 0.0, 5.0, 0.0, 5.0, 0.0, 5.0, 0.0]); + output.into_data().assert_eq(&expected, false); + + // Test 2D tensor with step on first dimension + let tensor = TestTensor::<2>::zeros([4, 4], &device); + let output = tensor.slice_fill(s![0..4;2, ..], 3.0); + let expected = TensorData::from([ + [3.0, 3.0, 3.0, 3.0], + [0.0, 0.0, 0.0, 0.0], + [3.0, 3.0, 3.0, 3.0], + [0.0, 0.0, 0.0, 0.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Test 2D tensor with step on second dimension + let tensor = TestTensor::<2>::zeros([3, 6], &device); + let output = tensor.slice_fill(s![.., 0..6;3], 2.0); + let expected = TensorData::from([ + [2.0, 0.0, 0.0, 2.0, 0.0, 0.0], + [2.0, 0.0, 0.0, 2.0, 0.0, 0.0], + [2.0, 0.0, 0.0, 2.0, 0.0, 0.0], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_with_negative_step() { + let device = Default::default(); + + // Test 1D tensor with negative step (reverse fill) + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0, 5.0], &device); + let output = tensor.slice_fill(s![0..5;-1], 10.0); + // Should reverse the indices [4,3,2,1,0] and fill them with 10.0 + let expected = TensorData::from([10.0, 10.0, 10.0, 10.0, 10.0]); + output.into_data().assert_eq(&expected, false); + + // Test 2D tensor with negative step + let tensor = + TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], &device); + let output = tensor.slice_fill(s![.., 0..3;-2], -1.0); + // Should fill columns in reverse order with step 2: indices 2, 0 + let expected = TensorData::from([[-1.0, 2.0, -1.0], [-1.0, 5.0, -1.0], [-1.0, 8.0, -1.0]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_fill_with_mixed_steps() { + let device = Default::default(); + + // Test 2D tensor with mixed positive and negative steps + let tensor = TestTensor::<2>::zeros([4, 6], &device); + let output = tensor.slice_fill(s![0..4;2, 0..6;-3], 7.0); + // Step 2 on dim 0 selects rows 0, 2 + // Step -3 on dim 1 with range 0..6 reverses and takes every 3rd: indices [5, 2] + let expected = TensorData::from([ + [0.0, 0.0, 7.0, 0.0, 0.0, 7.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 7.0, 0.0, 0.0, 7.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Test 3D tensor with steps + let tensor = TestTensor::<3>::zeros([2, 4, 4], &device); + let output = tensor.slice_fill(s![.., 0..4;2, 0..4;-2], 1.0); + // Step 2 on dim 1 selects rows 0, 2 + // Step -2 on dim 2 with range 0..4 reverses and takes every 2nd: indices [3, 1] + let expected_slice = [ + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0], + ]; + let expected = TensorData::from([expected_slice, expected_slice]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn clamp_when_slice_exceeds_dimension() { + let tensor = TestTensor::<1>::from([0.0, 1.0, 2.0]); + let data = tensor.to_data(); + + let output = tensor.slice([0..4]); + output.into_data().assert_eq(&data, true); +} + +#[test] +fn negative_dimensions() { + let tensor = TestTensor::<2>::from([[0.0f32, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data = tensor.to_data(); + + // Clamping to the tensor dimensions + let output = tensor.clone().slice([0..4, 0..4]); + output.into_data().assert_eq(&data, true); + + // Negative dimensions + let output = tensor.clone().slice([0..1, 0..1]); + let data = TensorData::from([[0.elem::()]]); + output.into_data().assert_eq(&data, true); + + let output = tensor.slice(s![0..-1, 0..-2]); + output.into_data().assert_eq(&data, true); +} + +#[test] +fn missing_dimensions() { + let tensor = TestTensor::<2>::from([[0.0f32, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data = tensor.to_data(); + + // Clamping to the tensor dimensions + let output = tensor.clone().slice([0..4, 0..4]); + output.into_data().assert_eq(&data, true); + + // Negative dimensions + let data = TensorData::from([[0.elem::()]]); + let output = tensor.clone().slice(s![0..-1, 0..-2]); + output.into_data().assert_eq(&data, true); + + // Missing dimensions + let output = tensor.clone().slice(s![0..1, ..]); + let data = TensorData::from([[0.0f32, 1.0, 2.0]]); + output.into_data().assert_eq(&data, false); + + let output = tensor.clone().slice(s![.., 0..2]); + let data = TensorData::from([[0.0f32, 1.0], [3.0, 4.0]]); + output.into_data().assert_eq(&data, false); + + let output = tensor.clone().slice([.., ..]); + let data = TensorData::from([[0.0f32, 1.0, 2.0], [3.0, 4.0, 5.0]]); + output.into_data().assert_eq(&data, false); +} + +#[test] +fn should_slice_aggregation_result() { + let tensor = TestTensor::<1>::from([0.0, 1.0, 2.0]).mean(); + + let output = tensor.clone().slice([(0..1)]); + output.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +#[should_panic] +fn should_panic_when_slice_with_too_many_dimensions() { + let tensor = TestTensor::<1>::from([0.0, 1.0, 2.0]); + + let _output = tensor.slice([0..1, 0..1]); +} + +#[test] +fn should_support_descending_slice_as_empty() { + // Like PyTorch, x[3:1] should return an empty tensor, not panic + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.slice(s![2..1]); + + // Should produce an empty tensor with shape [0] + assert_eq!(output.dims(), [0]); +} + +#[test] +fn should_support_empty_slice() { + // ONNX models can have empty slices where start == end + // This should produce a tensor with size 0 in that dimension + let data = TensorData::from([0.0, 1.0, 2.0]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.slice([1..1]); + + // Should produce an empty tensor with shape [0] + assert_eq!(output.dims(), [0]); +} + +#[test] +fn should_support_empty_slice_2d() { + // Test empty slice on 2D tensor + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + // Empty slice on first dimension + let output = tensor.clone().slice([1..1, 0..3]); + assert_eq!(output.dims(), [0, 3]); + + // Empty slice on second dimension + let output = tensor.slice([0..2, 2..2]); + assert_eq!(output.dims(), [2, 0]); +} + +#[test] +fn test_slice_with_positive_step() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + + // Test step=2 along first dimension + let sliced = tensor.clone().slice([s![0..3;2]]); + let expected = TensorData::from([[1.0, 2.0, 3.0, 4.0], [9.0, 10.0, 11.0, 12.0]]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=2 along second dimension + let sliced = tensor.clone().slice(s![.., 0..4;2]); + let expected = TensorData::from([[1.0, 3.0], [5.0, 7.0], [9.0, 11.0]]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=2 along both dimensions + let sliced = tensor.clone().slice(s![0..3;2, 0..4;2]); + let expected = TensorData::from([[1.0, 3.0], [9.0, 11.0]]); + sliced.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_with_negative_step() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + + // Test step=-1 along first dimension (reverse rows) + let sliced = tensor.clone().slice([s![0..3;-1]]); + let expected = TensorData::from([ + [9.0, 10.0, 11.0, 12.0], + [5.0, 6.0, 7.0, 8.0], + [1.0, 2.0, 3.0, 4.0], + ]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=-1 along second dimension (reverse columns) + let sliced = tensor.clone().slice(s![.., 0..4;-1]); + let expected = TensorData::from([ + [4.0, 3.0, 2.0, 1.0], + [8.0, 7.0, 6.0, 5.0], + [12.0, 11.0, 10.0, 9.0], + ]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=-2 along first dimension + let sliced = tensor.clone().slice([s![0..3;-2]]); + let expected = TensorData::from([[9.0, 10.0, 11.0, 12.0], [1.0, 2.0, 3.0, 4.0]]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=-2 along second dimension + let sliced = tensor.clone().slice(s![.., 0..4;-2]); + let expected = TensorData::from([[4.0, 2.0], [8.0, 6.0], [12.0, 10.0]]); + sliced.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_with_mixed_steps() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + + // Test positive step along first dimension, negative along second + let sliced = tensor.clone().slice(s![0..3;2, 0..4;-1]); + let expected = TensorData::from([[4.0, 3.0, 2.0, 1.0], [12.0, 11.0, 10.0, 9.0]]); + sliced.into_data().assert_eq(&expected, false); + + // Test negative step along first dimension, positive along second + let sliced = tensor.clone().slice(s![0..3;-1, 0..4;2]); + let expected = TensorData::from([[9.0, 11.0], [5.0, 7.0], [1.0, 3.0]]); + sliced.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_with_steps_1d() { + let device = Default::default(); + let tensor = + TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], &device); + + // Test positive step + let sliced = tensor.clone().slice([s![0..10;2]]); + let expected = TensorData::from([1.0, 3.0, 5.0, 7.0, 9.0]); + sliced.into_data().assert_eq(&expected, false); + + // Test negative step + let sliced = tensor.clone().slice([s![0..10;-1]]); + let expected = TensorData::from([10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]); + sliced.into_data().assert_eq(&expected, false); + + // Test negative step with partial range + let sliced = tensor.clone().slice([s![2..8;-2]]); + let expected = TensorData::from([8.0, 6.0, 4.0]); + sliced.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_with_steps_3d() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0]], + [[5.0, 6.0], [7.0, 8.0]], + [[9.0, 10.0], [11.0, 12.0]], + [[13.0, 14.0], [15.0, 16.0]], + ], + &device, + ); + + // Test step=2 along first dimension + let sliced = tensor.clone().slice(s![0..4;2, .., ..]); + let expected = TensorData::from([[[1.0, 2.0], [3.0, 4.0]], [[9.0, 10.0], [11.0, 12.0]]]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=-1 along all dimensions + let sliced = tensor.clone().slice(s![0..4;-1, 0..2;-1, 0..2;-1]); + let expected = TensorData::from([ + [[16.0, 15.0], [14.0, 13.0]], + [[12.0, 11.0], [10.0, 9.0]], + [[8.0, 7.0], [6.0, 5.0]], + [[4.0, 3.0], [2.0, 1.0]], + ]); + sliced.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/slice_assign.rs b/crates/burn-backend-tests/tests/tensor/float/ops/slice_assign.rs new file mode 100644 index 0000000..8799f5d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/slice_assign.rs @@ -0,0 +1,366 @@ +use super::*; +use burn_tensor::{Slice, TensorData, s}; + +#[test] +fn should_support_slice_assign_1d() { + let data = TensorData::from([0.0, 1.0, 2.0]); + let data_assigned = TensorData::from([10.0, 5.0]); + + let device = Default::default(); + let tensor = TestTensor::<1>::from_data(data, &device); + let tensor_assigned = TestTensor::<1>::from_data(data_assigned, &device); + + let output = tensor.slice_assign([0..2], tensor_assigned); + let expected = TensorData::from([10.0, 5.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_2d() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data_assigned = TensorData::from([[10.0, 5.0]]); + + let device = Default::default(); + let tensor = TestTensor::<2>::from_data(data, &device); + let tensor_assigned = TestTensor::<2>::from_data(data_assigned, &device); + + let output = tensor.slice_assign([1..2, 0..2], tensor_assigned); + let expected = TensorData::from([[0.0, 1.0, 2.0], [10.0, 5.0, 5.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_vec() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data_assigned = TensorData::from([[10.0, 5.0]]); + + let device = Default::default(); + let tensor = TestTensor::<2>::from_data(data, &device); + let tensor_assigned = TestTensor::<2>::from_data(data_assigned, &device); + + let slices: Vec = vec![1..2, 0..2].into_iter().map(Slice::from).collect(); + + let output = tensor.slice_assign(&slices, tensor_assigned); + let expected = TensorData::from([[0.0, 1.0, 2.0], [10.0, 5.0, 5.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn slice_assign_now_supports_non_unit_step() { + let device = Default::default(); + // Create tensors where the shapes match for stepped slicing + let tensor = TestTensor::<2>::ones([4, 4], &device); + // With step=2 on first dim, we select indices 0 and 2, so we need a [2, 4] values tensor + let values = TestTensor::<2>::zeros([2, 4], &device); + + // This now works because slice_assign supports steps != 1 + // We use s! macro to create a slice with step=2 + let result = tensor.slice_assign(s![0..3;2, ..], values); + + // Verify the result: rows 0 and 2 should be zeros, rows 1 and 3 should be ones + let expected = TensorData::from([ + [0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 1.0], + [0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 1.0], + ]); + result.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_with_positive_step_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &device); + let values = TestTensor::<1>::from_data([10.0, 20.0, 30.0], &device); + + // Assign to indices 0, 2, 4 (step=2) + let output = tensor.slice_assign([s![0..6;2]], values); + let expected = TensorData::from([10.0, 2.0, 20.0, 4.0, 30.0, 6.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_with_positive_step_2d() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0], + ], + &device, + ); + + // Assign to rows 0, 2 (step=2) + let values = TestTensor::<2>::from_data( + [[100.0, 101.0, 102.0, 103.0], [200.0, 201.0, 202.0, 203.0]], + &device, + ); + let output = tensor.clone().slice_assign([s![0..4;2]], values); + let expected = TensorData::from([ + [100.0, 101.0, 102.0, 103.0], + [5.0, 6.0, 7.0, 8.0], + [200.0, 201.0, 202.0, 203.0], + [13.0, 14.0, 15.0, 16.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Assign to columns 0, 2 (step=2) + let values = TestTensor::<2>::from_data( + [ + [100.0, 200.0], + [101.0, 201.0], + [102.0, 202.0], + [103.0, 203.0], + ], + &device, + ); + let output = tensor.clone().slice_assign(s![.., 0..4;2], values); + let expected = TensorData::from([ + [100.0, 2.0, 200.0, 4.0], + [101.0, 6.0, 201.0, 8.0], + [102.0, 10.0, 202.0, 12.0], + [103.0, 14.0, 203.0, 16.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Assign with step=2 on both dimensions + let values = TestTensor::<2>::from_data([[100.0, 200.0], [300.0, 400.0]], &device); + let output = tensor.slice_assign(s![0..4;2, 0..4;2], values); + let expected = TensorData::from([ + [100.0, 2.0, 200.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [300.0, 10.0, 400.0, 12.0], + [13.0, 14.0, 15.0, 16.0], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_with_negative_step_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &device); + let values = TestTensor::<1>::from_data([60.0, 50.0, 40.0, 30.0, 20.0, 10.0], &device); + + // Assign in reverse order (step=-1) + let output = tensor.slice_assign([s![0..6;-1]], values); + let expected = TensorData::from([10.0, 20.0, 30.0, 40.0, 50.0, 60.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_with_negative_step_2d() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + + // Assign to rows in reverse order (step=-1) + let values = TestTensor::<2>::from_data( + [ + [30.0, 31.0, 32.0, 33.0], + [20.0, 21.0, 22.0, 23.0], + [10.0, 11.0, 12.0, 13.0], + ], + &device, + ); + let output = tensor.clone().slice_assign([s![0..3;-1]], values); + let expected = TensorData::from([ + [10.0, 11.0, 12.0, 13.0], + [20.0, 21.0, 22.0, 23.0], + [30.0, 31.0, 32.0, 33.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Assign to columns in reverse order (step=-1) + let values = TestTensor::<2>::from_data( + [ + [40.0, 30.0, 20.0, 10.0], + [80.0, 70.0, 60.0, 50.0], + [120.0, 110.0, 100.0, 90.0], + ], + &device, + ); + let output = tensor.clone().slice_assign(s![.., 0..4;-1], values); + let expected = TensorData::from([ + [10.0, 20.0, 30.0, 40.0], + [50.0, 60.0, 70.0, 80.0], + [90.0, 100.0, 110.0, 120.0], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_with_mixed_steps() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0], + ], + &device, + ); + + // Positive step along rows, negative along columns + let values = TestTensor::<2>::from_data( + [[100.0, 101.0, 102.0, 103.0], [200.0, 201.0, 202.0, 203.0]], + &device, + ); + let output = tensor.clone().slice_assign(s![0..4;2, 0..4;-1], values); + let expected = TensorData::from([ + [103.0, 102.0, 101.0, 100.0], + [5.0, 6.0, 7.0, 8.0], + [203.0, 202.0, 201.0, 200.0], + [13.0, 14.0, 15.0, 16.0], + ]); + output.into_data().assert_eq(&expected, false); + + // Negative step along rows, positive along columns + let values = TestTensor::<2>::from_data( + [ + [100.0, 200.0], + [101.0, 201.0], + [102.0, 202.0], + [103.0, 203.0], + ], + &device, + ); + let output = tensor.slice_assign(s![0..4;-1, 0..4;2], values); + let expected = TensorData::from([ + [103.0, 2.0, 203.0, 4.0], + [102.0, 6.0, 202.0, 8.0], + [101.0, 10.0, 201.0, 12.0], + [100.0, 14.0, 200.0, 16.0], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_3d_with_steps() { + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0]], + [[5.0, 6.0], [7.0, 8.0]], + [[9.0, 10.0], [11.0, 12.0]], + [[13.0, 14.0], [15.0, 16.0]], + ], + &device, + ); + + // Test step=2 along first dimension + let values = TestTensor::<3>::from_data( + [ + [[100.0, 101.0], [102.0, 103.0]], + [[200.0, 201.0], [202.0, 203.0]], + ], + &device, + ); + let output = tensor.clone().slice_assign(s![0..4;2, .., ..], values); + let expected = TensorData::from([ + [[100.0, 101.0], [102.0, 103.0]], + [[5.0, 6.0], [7.0, 8.0]], + [[200.0, 201.0], [202.0, 203.0]], + [[13.0, 14.0], [15.0, 16.0]], + ]); + output.into_data().assert_eq(&expected, false); + + // Test step=-1 along all dimensions + let values = TestTensor::<3>::from_data( + [ + [[400.0, 399.0], [398.0, 397.0]], + [[396.0, 395.0], [394.0, 393.0]], + [[392.0, 391.0], [390.0, 389.0]], + [[388.0, 387.0], [386.0, 385.0]], + ], + &device, + ); + let output = tensor.slice_assign(s![0..4;-1, 0..2;-1, 0..2;-1], values); + let expected = TensorData::from([ + [[385.0, 386.0], [387.0, 388.0]], + [[389.0, 390.0], [391.0, 392.0]], + [[393.0, 394.0], [395.0, 396.0]], + [[397.0, 398.0], [399.0, 400.0]], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_partial_with_steps() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0, 5.0], + [6.0, 7.0, 8.0, 9.0, 10.0], + [11.0, 12.0, 13.0, 14.0, 15.0], + [16.0, 17.0, 18.0, 19.0, 20.0], + [21.0, 22.0, 23.0, 24.0, 25.0], + ], + &device, + ); + + // Assign to a subset with step=2 + let values = TestTensor::<2>::from_data([[100.0, 200.0], [300.0, 400.0]], &device); + let output = tensor.slice_assign(s![1..4;2, 1..4;2], values); + let expected = TensorData::from([ + [1.0, 2.0, 3.0, 4.0, 5.0], + [6.0, 100.0, 8.0, 200.0, 10.0], + [11.0, 12.0, 13.0, 14.0, 15.0], + [16.0, 300.0, 18.0, 400.0, 20.0], + [21.0, 22.0, 23.0, 24.0, 25.0], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_empty_range() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let values: TestTensor<2> = TestTensor::empty([2, 0], &device); + + // Empty slice assignment (start == end) should be a no-op + let output = tensor.clone().slice_assign([0..2, 1..1], values); + let expected = TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_empty_range_1d() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([1.0, 2.0, 3.0, 4.0, 5.0], &device); + let values: TestTensor<1> = TestTensor::empty([0], &device); + + // Empty slice assignment should return tensor unchanged + let output = tensor.clone().slice_assign([2..2], values); + let expected = TensorData::from([1.0, 2.0, 3.0, 4.0, 5.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_single_dim_slice() { + let device = Default::default(); + let x = TestTensor::<3>::ones([2, 3, 1], &device); + let values = TestTensor::<3>::zeros([1, 3, 1], &device); + + let output = x.slice_assign(s![1], values); + + output.into_data().assert_eq( + &TensorData::from([[[1.0], [1.0], [1.0]], [[0.0], [0.0], [0.0]]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/sort_argsort.rs b/crates/burn-backend-tests/tests/tensor/float/ops/sort_argsort.rs new file mode 100644 index 0000000..7da93cd --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/sort_argsort.rs @@ -0,0 +1,216 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_sort_1d_float() { + let tensor = TestTensor::<1>::from([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 199.412, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let values = tensor.sort(0); + + let values_expected = TensorData::from([ + -8.1, -0.3, -0.21, 0., 0.5, 0.94, 0.99, 1.2, 2.1, 2.3, 3., 4., 199.412, + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); +} + +#[test] +fn test_argsort_1d_float() { + let tensor = TestTensor::<1>::from([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 199.412, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let indices = tensor.argsort(0); + + let indices_expected = TensorData::from([12, 6, 2, 3, 0, 5, 10, 1, 4, 7, 11, 9, 8]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_with_indices_descending_float() { + // 1D + let tensor = TestTensor::<1>::from([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 199.412, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let (values, indices) = tensor.sort_descending_with_indices(0); + + let values_expected = TensorData::from([ + 199.412, 4., 3., 2.3, 2.1, 1.2, 0.99, 0.94, 0.5, 0., -0.21, -0.3, -8.1, + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([8, 9, 11, 7, 4, 1, 10, 5, 0, 3, 2, 6, 12]); + indices.into_data().assert_eq(&indices_expected, false); + + // 2D + let tensor = TestTensor::<3>::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, 4.], [0.99, 3., -8.1]], + ]); + + // Sort along dim=1 + let (values, indices) = tensor.sort_descending_with_indices(1); + + let values_expected = TensorData::from([ + [[0., 2.1, 0.94], [-0.5, 1.2, -0.21]], + [[0.99, 3., 4.], [-0.3, 2.3, -8.1]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([[[1, 1, 1], [0, 0, 0]], [[1, 1, 0], [0, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_float() { + let tensor = TestTensor::<3>::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, 4.], [0.99, 3., -8.1]], + ]); + + // Sort along dim=0 + let values = tensor.clone().sort(0); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, -8.1]], + [[-0.3, 2.3, 4.], [0.99, 3., 0.94]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + // Sort along dim=1 + let values = tensor.clone().sort(1); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, -8.1], [0.99, 3., 4.]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + // Sort along dim=2 + let values = tensor.sort(2); + + let values_expected = TensorData::from([ + [[-0.5, -0.21, 1.2], [0., 0.94, 2.1]], + [[-0.3, 2.3, 4.], [-8.1, 0.99, 3.]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); +} + +#[test] +fn test_sort_with_indices_float() { + let tensor = TestTensor::<3>::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, 4.], [0.99, 3., -8.1]], + ]); + + // Sort along dim=0 + let (values, indices) = tensor.clone().sort_with_indices(0); + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, -8.1]], + [[-0.3, 2.3, 4.], [0.99, 3., 0.94]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([[[0, 0, 0], [0, 0, 1]], [[1, 1, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let (values, indices) = tensor.clone().sort_with_indices(1); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, -8.1], [0.99, 3., 4.]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([[[0, 0, 0], [1, 1, 1]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let (values, indices) = tensor.sort_with_indices(2); + + let values_expected = TensorData::from([ + [[-0.5, -0.21, 1.2], [0., 0.94, 2.1]], + [[-0.3, 2.3, 4.], [-8.1, 0.99, 3.]], + ]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([[[0, 2, 1], [0, 2, 1]], [[0, 1, 2], [2, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_argsort_float() { + let tensor = TestTensor::<3>::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, 4.], [0.99, 3., -8.1]], + ]); + + // Sort along dim=0 + let indices = tensor.clone().argsort(0); + + let indices_expected = TensorData::from([[[0, 0, 0], [0, 0, 1]], [[1, 1, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let indices = tensor.clone().argsort(1); + + let indices_expected = TensorData::from([[[0, 0, 0], [1, 1, 1]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let indices = tensor.argsort(2); + + let indices_expected = TensorData::from([[[0, 2, 1], [0, 2, 1]], [[0, 1, 2], [2, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_float_nan() { + let tensor = TestTensor::<2>::from([[-0.5, f32::NAN], [0., 0.94], [-0.3, f32::NAN]]); + + // Sort along dim=0 + let values = tensor.sort(0); + + let values_expected = TensorData::from([[-0.5, 0.94], [-0.3, f32::NAN], [0., f32::NAN]]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); +} + +#[test] +fn test_sort_descending_1d() { + let tensor = TestTensor::<1>::from([1., 2., 3., 4., 5.]); + + // Sort along dim=0 + let values = tensor.sort_descending(0); + + let values_expected = TensorData::from([5., 4., 3., 2., 1.]); + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/split.rs b/crates/burn-backend-tests/tests/tensor/float/ops/split.rs new file mode 100644 index 0000000..7607b75 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/split.rs @@ -0,0 +1,206 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_split_evenly_divisible() { + let device = Default::default(); + let tensors = + TestTensor::<2>::from_data([[0, 1], [2, 3], [4, 5], [6, 7], [8, 9], [10, 11]], &device); + + let split_tensors = tensors.split(2, 0); + assert_eq!(split_tensors.len(), 3); + + let expected = [ + TensorData::from([[0, 1], [2, 3]]), + TensorData::from([[4, 5], [6, 7]]), + TensorData::from([[8, 9], [10, 11]]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_not_evenly_divisible() { + let device = Default::default(); + let tensors = TestTensor::<2>::from_data([[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]], &device); + + let split_tensors = tensors.split(2, 0); + assert_eq!(split_tensors.len(), 3); + + let expected = [ + TensorData::from([[0, 1], [2, 3]]), + TensorData::from([[4, 5], [6, 7]]), + TensorData::from([[8, 9]]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_along_dim1() { + let device = Default::default(); + let tensors = TestTensor::<2>::from_data([[0, 1, 2], [3, 4, 5]], &device); + + let split_tensors = tensors.split(2, 1); + assert_eq!(split_tensors.len(), 2); + + let expected = [ + TensorData::from([[0, 1], [3, 4]]), + TensorData::from([[2], [5]]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_split_size_larger_than_tensor_size() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2, 3, 4], &device); + + let split_tensors = tensors.split(10, 0); + assert_eq!(split_tensors.len(), 1); + + let expected = [TensorData::from([0, 1, 2, 3, 4])]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_with_zero_split_size_zero_tensor_size() { + let device = Default::default(); + let empty_array: [i32; 0] = []; + let tensors = TestTensor::<1>::from_data(empty_array, &device); + + let split_tensors = tensors.split(0, 0); + assert_eq!(split_tensors.len(), 0); +} + +#[test] +fn test_split_zero_sized_tensor() { + let device = Default::default(); + let empty_array: [i32; 0] = []; + let tensors = TestTensor::<1>::from_data(empty_array, &device); + + let split_tensors = tensors.split(1, 0); + assert_eq!(split_tensors.len(), 0); +} + +#[test] +#[should_panic( + expected = "split_size must be greater than 0 unless the tensor size along the dimension is 0." +)] +fn test_split_with_zero_split_size_non_zero_tensor() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2, 3, 4], &device); + + let _split_tensors = tensors.split(0, 0); +} + +#[test] +#[should_panic(expected = "Given dimension is greater than or equal to the tensor rank.")] +fn test_split_invalid_dim() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2], &device); + + let _split_tensors = tensors.split(1, 2); +} + +#[test] +fn test_split_3d_tensor_along_dim0() { + let device = Default::default(); + let tensors = TestTensor::<3>::from_data( + [ + [[0, 1], [2, 3]], + [[4, 5], [6, 7]], + [[8, 9], [10, 11]], + [[12, 13], [14, 15]], + ], + &device, + ); + + let split_tensors = tensors.split(2, 0); + assert_eq!(split_tensors.len(), 2); + + let expected = [ + TensorData::from([[[0, 1], [2, 3]], [[4, 5], [6, 7]]]), + TensorData::from([[[8, 9], [10, 11]], [[12, 13], [14, 15]]]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_3d_tensor_along_dim1() { + let device = Default::default(); + let tensors = TestTensor::<3>::from_data( + [[[0, 1], [2, 3], [4, 5]], [[6, 7], [8, 9], [10, 11]]], + &device, + ); + + let split_tensors = tensors.split(2, 1); + assert_eq!(split_tensors.len(), 2); + + let expected = [ + TensorData::from([[[0, 1], [2, 3]], [[6, 7], [8, 9]]]), + TensorData::from([[[4, 5]], [[10, 11]]]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +fn test_split_with_sizes() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2, 3, 4, 5], &device); + + let split_tensors = tensors.split_with_sizes(vec![2, 3, 1], 0); + assert_eq!(split_tensors.len(), 3); + + let expected = [ + TensorData::from([0, 1]), + TensorData::from([2, 3, 4]), + TensorData::from([5]), + ]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} + +#[test] +#[should_panic( + expected = "The sum of split_sizes must equal the tensor size along the specified dimension." +)] +fn test_split_with_sizes_invalid_sum() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2, 3, 4, 5], &device); + + let _split_tensors = tensors.split_with_sizes(vec![2, 2, 1], 0); +} + +#[test] +fn test_split_with_sizes_zero_length() { + let device = Default::default(); + let tensors = TestTensor::<1>::from_data([0, 1, 2], &device); + + let split_tensors = tensors.split_with_sizes(vec![0, 1, 2], 0); + assert_eq!(split_tensors.len(), 2); + + let expected = [TensorData::from([0]), TensorData::from([1, 2])]; + + for (index, tensor) in split_tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/sqrt.rs b/crates/burn-backend-tests/tests/tensor/float/ops/sqrt.rs new file mode 100644 index 0000000..05f0153 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/sqrt.rs @@ -0,0 +1,46 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use core::f32::consts::SQRT_2; + +#[test] +fn should_support_sqrt_narrowed() { + // [1, 4, 9, 16, 25, 36] narrowed to [4, 9, 16, 25] + let tensor = TestTensor::<1>::from([1.0, 4.0, 9.0, 16.0, 25.0, 36.0]); + let narrowed = tensor.narrow(0, 1, 4); + + let output = narrowed.sqrt(); + let expected = TensorData::from([2.0, 3.0, 4.0, 5.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_sqrt_flipped_2d() { + // [[1, 4], [9, 16]] flipped on axis 0 -> [[9, 16], [1, 4]] + let tensor = TestTensor::<2>::from([[1.0, 4.0], [9.0, 16.0]]); + let flipped = tensor.flip([0]); + + let output = flipped.sqrt(); + let expected = TensorData::from([[3.0, 4.0], [1.0, 2.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_sqrt_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.sqrt(); + let expected = TensorData::from([[0.0, 1.0, SQRT_2], [1.73205, 2.0, 2.2360]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::relative(1e-4).set_half_precision_relative(1e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/square.rs b/crates/burn-backend-tests/tests/tensor/float/ops/square.rs new file mode 100644 index 0000000..994282b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/square.rs @@ -0,0 +1,17 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_sqrt_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.square(); + let expected = TensorData::from([[0.0, 1.0, 4.0], [9.0, 16.0, 25.0]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::relative(1e-4).set_half_precision_relative(1e-3), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/squeeze.rs b/crates/burn-backend-tests/tests/tensor/float/ops/squeeze.rs new file mode 100644 index 0000000..f432310 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/squeeze.rs @@ -0,0 +1,263 @@ +use super::*; +use burn_tensor::Shape; + +/// Test if the function can successfully squeeze the size 1 dimension of a 3D tensor. +#[test] +fn should_squeeze_dim() { + let tensor = TestTensor::<3>::ones(Shape::new([2, 1, 4]), &Default::default()); + let squeezed_tensor: TestTensor<2> = tensor.squeeze_dim(1); + let expected_shape = Shape::new([2, 4]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +#[test] +fn should_squeeze() { + let tensor = TestTensor::<3>::ones(Shape::new([2, 1, 4]), &Default::default()); + let squeezed_tensor: TestTensor<2> = tensor.squeeze(); + let expected_shape = Shape::new([2, 4]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully squeeze the first size 1 dimension of a 4D tensor. +#[test] +fn should_squeeze_first() { + let tensor = TestTensor::<4>::ones(Shape::new([1, 3, 4, 5]), &Default::default()); + let squeezed_tensor: TestTensor<3> = tensor.squeeze_dim(0); + let expected_shape = Shape::new([3, 4, 5]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} +/// Test if the function can successfully squeeze the last size 1 dimension of a 4D tensor. +#[test] +fn should_squeeze_last() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 1]), &Default::default()); + let squeezed_tensor: TestTensor<3> = tensor.squeeze_dim(3); + let expected_shape = Shape::new([2, 3, 4]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} +/// Test if the function panics when the squeezed dimension is not of size 1. +#[test] +#[should_panic] +fn should_squeeze_panic() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let _squeezed_tensor: TestTensor<3> = tensor.squeeze_dim(2); +} + +/// Test if the function works with an empty slice +#[test] +fn should_squeeze_dims_with_empty_slice() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 1, 3]), &Default::default()); + let squeezed_tensor: TestTensor<1> = tensor.squeeze_dims(&[]); + let expected_shape = Shape::new([3]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +#[test] +fn should_squeeze_all_dims() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 3, 1]), &Default::default()); + let squeezed_tensor: TestTensor<1> = tensor.squeeze(); + let expected_shape = Shape::new([3]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +/// Test if the function works with positive indices +#[test] +fn should_squeeze_dims_with_positive_indices() { + let tensor = TestTensor::<4>::ones(Shape::new([1, 3, 1, 5]), &Default::default()); + let squeezed_tensor: TestTensor<2> = tensor.squeeze_dims(&[0, 2]); + let expected_shape = Shape::new([3, 5]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +/// Test if the function works with negative indices +#[test] +fn should_squeeze_dims_with_negative_indices() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 1, 3, 1]), &Default::default()); + let squeezed_tensor: TestTensor<2> = tensor.squeeze_dims(&[-3, -1]); + let expected_shape = Shape::new([2, 3]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +/// Test to make sure the function panics if a non-singleton dimension is squeezed +#[test] +#[should_panic] +fn should_squeeze_dims_work_if_non_singleton() { + let tensor = TestTensor::<3>::ones(Shape::new([2, 3, 4]), &Default::default()); + let squeezed_tensor: TestTensor<3> = tensor.squeeze_dims(&[1]); + let expected_shape = Shape::new([2, 3, 4]); + assert_eq!(squeezed_tensor.shape(), expected_shape); +} + +#[test] +#[should_panic] +fn should_panic_squeeze_consumes_all_singleton() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 3, 1]), &Default::default()); + let _squeezed_tensor: TestTensor<2> = tensor.squeeze(); // output rank should be 1 +} + +/// Test to make sure the function panics if too many dimensions are requested to be squeezed +#[test] +#[should_panic] +fn should_squeeze_dims_panic_on_too_many_dimensions() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 1, 1]), &Default::default()); + let _: TestTensor<1> = tensor.squeeze_dims(&[0, 1, 2]); +} + +/// Test to make sure function panics if dimensions are mismatched +#[test] +#[should_panic] +fn should_squeeze_dims_dimension_mismatch_panic() { + let tensor = TestTensor::<4>::ones(Shape::new([1, 3, 1, 5]), &Default::default()); + let _: TestTensor<3> = tensor.squeeze_dims(&[0, 2]); +} + +/// Test if the function can successfully unsqueeze the size 1 dimension at the specified position of a 3D tensor. +#[test] +fn should_unsqueeze_dim() { + let tensor = TestTensor::<3>::ones(Shape::new([2, 4, 1]), &Default::default()); + let unsqueezed_tensor: TestTensor<4> = tensor.unsqueeze_dim(1); + let expected_shape = Shape::new([2, 1, 4, 1]); + assert_eq!(unsqueezed_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully unsqueeze the first size 1 dimension of a 4D tensor. +#[test] +fn should_unsqueeze_dim_first() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let unsqueezed_tensor: TestTensor<5> = tensor.unsqueeze_dim(0); + let expected_shape = Shape::new([1, 2, 3, 4, 5]); + assert_eq!(unsqueezed_tensor.shape(), expected_shape); +} + +/// Test if the function can successfully unsqueeze the last size 1 dimension of a 4D tensor. +#[test] +fn should_unsqueeze_dim_last() { + let tensor = TestTensor::<4>::ones(Shape::new([5, 4, 3, 2]), &Default::default()); + let unsqueezed_tensor: TestTensor<5> = tensor.unsqueeze_dim(4); + let expected_shape = Shape::new([5, 4, 3, 2, 1]); + assert_eq!(unsqueezed_tensor.shape(), expected_shape); +} + +/// Test if the function panics when the unsqueezed dimension is out of bounds. +#[test] +#[should_panic] +fn should_unsqueeze_dim_panic() { + let tensor = TestTensor::<4>::ones(Shape::new([2, 3, 4, 5]), &Default::default()); + let _unsqueezed_tensor: TestTensor<5> = tensor.unsqueeze_dim(5); +} + +#[test] +fn should_unsqueeze_dims_support_dim_inference() { + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let output_tensor = input_tensor.unsqueeze_dims::<5>(&[1, -2]); + let expected_shape = Shape::new([3, 1, 4, 1, 5]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_handle_first_last() { + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let output_tensor = input_tensor.unsqueeze_dims::<5>(&[0, 4]); + let expected_shape = Shape::new([1, 3, 4, 5, 1]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_work_with_single_dim() { + //bruh, just call unsqueeze_dim + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let output_tensor: TestTensor<4> = input_tensor.unsqueeze_dims(&[1]); + let expected_shape = Shape::new([3, 1, 4, 5]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_multiple_trailing_negatives() { + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let output_tensor: TestTensor<6> = input_tensor.unsqueeze_dims(&[0, -1, -1]); + let expected_shape = Shape::new([1, 3, 4, 5, 1, 1]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_consecutive_first_then_gap() { + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let output_tensor: TestTensor<6> = input_tensor.unsqueeze_dims(&[0, 1, 3]); + let expected_shape = Shape::new([1, 1, 3, 1, 4, 5]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +/// Regression test: `unsqueeze_dims` previously panicked with "index out of bounds" +/// when the axes produced duplicate values after sorting. The documented semantic +/// is that N duplicate axes insert N dims at that index; the fix normalizes sorted +/// duplicates so the N insertions occupy N consecutive output positions. +/// +/// This pattern comes up in ONNX matmul broadcasting when a 1D vector is unsqueezed +/// to match a higher-rank matrix (e.g. `[4]` -> `[1, 1, 4, 1]` for `4D @ 1D` matmul). +#[test] +fn should_unsqueeze_dims_handle_duplicate_axes_after_sort() { + // Matches the codegen pattern from burn-onnx for 4D @ 1D matmul broadcasting: + // axes `[-1, 0, 0]` convert to `[3, 0, 0]`, sort to `[0, 0, 3]`, and should + // produce `[1, 1, 4, 1]` after normalization. + let input_tensor = TestTensor::<1>::ones(Shape::new([4]), &Default::default()); + let output_tensor: TestTensor<4> = input_tensor.unsqueeze_dims(&[-1, 0, 0]); + let expected_shape = Shape::new([1, 1, 4, 1]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_handle_repeated_zero_axes() { + // Pure positive duplicates: `[0, 0]` should insert two leading 1s. + let input_tensor = TestTensor::<1>::ones(Shape::new([5]), &Default::default()); + let output_tensor: TestTensor<3> = input_tensor.unsqueeze_dims(&[0, 0]); + let expected_shape = Shape::new([1, 1, 5]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +fn should_unsqueeze_dims_handle_repeated_middle_axes() { + // Duplicates at an interior position: `[1, 1, 1]` should place three 1s + // starting at index 1, keeping the original dim at index 0. + let input_tensor = TestTensor::<1>::ones(Shape::new([5]), &Default::default()); + let output_tensor: TestTensor<4> = input_tensor.unsqueeze_dims(&[1, 1, 1]); + let expected_shape = Shape::new([5, 1, 1, 1]); + assert_eq!(output_tensor.shape(), expected_shape); +} + +#[test] +#[should_panic] +fn should_unsqueeze_dims_panic() { + let input_tensor = TestTensor::<3>::ones(Shape::new([3, 4, 5]), &Default::default()); + let _output_tensor: TestTensor<5> = input_tensor.unsqueeze_dims(&[0, -6]); +} + +/// Duplicate-axis normalization can push the last index past the valid output +/// range (e.g. `[2, 2]` targeting rank 3 normalizes to `[2, 3]`). This must be +/// rejected with a clear error instead of triggering an out-of-bounds read in +/// the copy loop. +#[test] +#[should_panic] +fn should_unsqueeze_dims_panic_duplicate_pushed_out_of_range() { + let input_tensor = TestTensor::<1>::ones(Shape::new([4]), &Default::default()); + let _: TestTensor<3> = input_tensor.unsqueeze_dims(&[2, 2]); +} + +#[test] +#[should_panic] +fn squeeze_all_singleton_not_supported() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 1, 1]), &Default::default()); + let _ = tensor.squeeze::<0>(); +} + +#[test] +#[should_panic] +fn squeeze_dim_singleton_not_supported() { + let tensor = TestTensor::<1>::ones(Shape::new([1]), &Default::default()); + let _ = tensor.squeeze_dim::<0>(0); +} + +#[test] +#[should_panic] +fn squeeze_dims_all_singleton_not_supported() { + let tensor = TestTensor::<3>::ones(Shape::new([1, 1, 1]), &Default::default()); + let _ = tensor.squeeze_dims::<0>(&[0, 1, 2]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/stack.rs b/crates/burn-backend-tests/tests/tensor/float/ops/stack.rs new file mode 100644 index 0000000..63d8c85 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/stack.rs @@ -0,0 +1,69 @@ +use super::*; +use alloc::{vec, vec::Vec}; +use burn_tensor::{Tensor, TensorData}; + +#[test] +fn should_support_stack_ops_2d_dim0() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0, 6.0]], &device); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_stack_ops_2d_dim1() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0, 6.0]], &device); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 1); + let expected = TensorData::from([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_stack_ops_3d() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]], &device); + let tensor_2 = TestTensor::from_data([[[4.0, 5.0, 6.0]], [[4.1, 5.1, 6.1]]], &device); + + let output = Tensor::stack::<4>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([ + [[[1.0000, 2.0000, 3.0000]], [[1.1000, 2.1000, 3.1000]]], + [[[4.0000, 5.0000, 6.0000]], [[4.1000, 5.1000, 6.1000]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn should_panic_when_dimensions_are_not_the_same() { + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]], &device); + let tensor_2 = TestTensor::from_data([[4.0, 5.0]], &device); + + let _output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); +} + +#[test] +#[should_panic] +fn should_panic_when_list_of_vectors_is_empty() { + let tensors: Vec> = vec![]; + let _output = Tensor::stack::<3>(tensors, 0); +} + +#[test] +#[should_panic] +fn should_panic_when_stack_exceeds_dimension() { + let device = Default::default(); + let tensor_1 = TestTensor::<3>::from_data([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]], &device); + let tensor_2 = TestTensor::from_data([[[4.0, 5.0, 6.0]]], &device); + + let _output = Tensor::stack::<4>(vec![tensor_1, tensor_2], 3); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/sub.rs b/crates/burn-backend-tests/tests/tensor/float/ops/sub.rs new file mode 100644 index 0000000..d390caf --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/sub.rs @@ -0,0 +1,42 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_sub_ops() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data_2 = TensorData::from([[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-6.0, -6.0, -6.0], [-6.0, -6.0, -6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sub_broadcast() { + let data_1 = TensorData::from([[0.0, 1.0, 2.0]]); + let data_2 = TensorData::from([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + let device = Default::default(); + let tensor_1 = TestTensor::<2>::from_data(data_1, &device); + let tensor_2 = TestTensor::<2>::from_data(data_2, &device); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-3.0, -3.0, -3.0], [-6.0, -6.0, -6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_sub_scalar_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let scalar = 2.0; + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor - scalar; + let expected = TensorData::from([[-2.0, -1.0, 0.0], [1.0, 2.0, 3.0]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/take.rs b/crates/burn-backend-tests/tests/tensor/float/ops/take.rs new file mode 100644 index 0000000..db27d77 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/take.rs @@ -0,0 +1,206 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_take_1d() { + // Test that take works with 1D indices + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([0.0, 1.0, 2.0], &device); + let indices = TestTensorInt::<1>::from_data([1, 1, 0, 1, 2], &device); + + let output = tensor.take::<1, 1>(0, indices); + let expected = TensorData::from([1.0, 1.0, 0.0, 1.0, 2.0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_2d_dim0() { + // Test take on 2D tensor along dimension 0 + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::<1>::from_data([1, 0, 1, 1], &device); + + let output = tensor.take::<1, 2>(0, indices); + let expected = TensorData::from([ + [3.0, 4.0, 5.0], + [0.0, 1.0, 2.0], + [3.0, 4.0, 5.0], + [3.0, 4.0, 5.0], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_2d_dim1() { + // Test take on 2D tensor along dimension 1 + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::<1>::from_data([2, 0, 1], &device); + + let output = tensor.take::<1, 2>(1, indices); + let expected = TensorData::from([[2.0, 0.0, 1.0], [5.0, 3.0, 4.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn take_and_select_should_be_equivalent() { + // Verify that take and select produce identical results + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + let indices = TestTensorInt::<1>::from_data([2, 0, 1, 1], &device); + + let result_take = tensor.clone().take::<1, 2>(0, indices.clone()); + let result_select = tensor.select(0, indices); + + let take_data = result_take.into_data(); + let select_data = result_select.into_data(); + + take_data.assert_eq(&select_data, false); +} + +#[test] +fn should_take_with_2d_indices() { + // Test take with 2D indices - output will be 3D with shape [2, 2, 4] + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ], + &device, + ); + + // 2D indices to select along dimension 0 - shape [2, 2] + let indices = TestTensorInt::<2>::from_data([[0, 2], [1, 0]], &device); + let output = tensor.take::<2, 3>(0, indices); + + // Expected: shape [2, 2, 4] - indices shape replaces dim 0 + let expected = TensorData::from([ + [[1.0, 2.0, 3.0, 4.0], [9.0, 10.0, 11.0, 12.0]], + [[5.0, 6.0, 7.0, 8.0], [1.0, 2.0, 3.0, 4.0]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_with_2d_indices_dim1() { + // Test take with 2D indices along dimension 1 - output will be 3D with shape [2, 2, 2] + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], &device); + + // 2D indices to select along dimension 1 - shape [2, 2] + let indices = TestTensorInt::<2>::from_data([[0, 3], [2, 1]], &device); + let output = tensor.take::<2, 3>(1, indices); + + // Expected: shape [2, 2, 2] - indices shape replaces dim 1 + let expected = TensorData::from([[[1.0, 4.0], [3.0, 2.0]], [[5.0, 8.0], [7.0, 6.0]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_3d_tensor() { + // Test take with 3D tensor - output will be 4D with shape [2, 2, 2, 2] + let device = Default::default(); + let tensor = TestTensor::<3>::from_data( + [ + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], + [[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]], + ], + &device, + ); + + // 2D indices to select along dimension 1 - shape [2, 2] + let indices = TestTensorInt::<2>::from_data([[0, 2], [1, 0]], &device); + let output = tensor.take::<2, 4>(1, indices); + + // Expected: shape [2, 2, 2, 2] - indices shape replaces dim 1 + let expected = TensorData::from([ + [[[1.0, 2.0], [5.0, 6.0]], [[3.0, 4.0], [1.0, 2.0]]], + [[[7.0, 8.0], [11.0, 12.0]], [[9.0, 10.0], [7.0, 8.0]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_with_3d_indices() { + // Test take with 3D indices - output will be 4D + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + + // 3D indices to select along dimension 1 - shape [2, 2, 2] + let indices = TestTensorInt::<3>::from_data([[[0, 2], [1, 0]], [[2, 1], [0, 2]]], &device); + let output = tensor.take::<3, 4>(1, indices); + + // Expected: shape [2, 2, 2, 2] - indices shape replaces dim 1 + let expected = TensorData::from([ + [[[1.0, 3.0], [2.0, 1.0]], [[3.0, 2.0], [1.0, 3.0]]], + [[[4.0, 6.0], [5.0, 4.0]], [[6.0, 5.0], [4.0, 6.0]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn should_panic_take_invalid_dimension() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], &device); + let indices = TestTensorInt::<1>::from_data([1, 0], &device); + + // This should panic because dimension 10 is out of bounds + tensor.take::<1, 2>(10, indices); +} + +#[test] +fn should_take_with_single_index() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::<1>::from_data([1], &device); + + let output = tensor.take::<1, 2>(0, indices); + let expected = TensorData::from([[4.0, 5.0, 6.0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_with_negative_dim_2d() { + // Test using negative dimension indexing on 2D tensor + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let indices = TestTensorInt::<1>::from_data([2, 0, 1], &device); + + // Using -1 should refer to the last dimension (dim 1) + let output_neg = tensor.clone().take::<1, 2>(-1, indices.clone()); + let output_pos = tensor.take::<1, 2>(1, indices); + + // Both should produce the same result + let neg_data = output_neg.into_data(); + let pos_data = output_pos.into_data(); + neg_data.assert_eq(&pos_data, false); +} + +#[test] +#[should_panic] +fn should_panic_take_negative_dim_out_of_bounds() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let indices = TestTensorInt::<1>::from_data([0, 1], &device); + + // This should panic because -3 is out of bounds for a 2D tensor + tensor.take::<1, 2>(-3, indices); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/topk.rs b/crates/burn-backend-tests/tests/tensor/float/ops/topk.rs new file mode 100644 index 0000000..3672586 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/topk.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_topk_with_indices_3d() { + let tensor = + TestTensor::<3>::from([[[1., 4., 7.], [2., 5., 6.]], [[3., 0., 9.], [8., 2., 7.]]]); + + let (values, indices) = tensor.topk_with_indices(2, /*dim*/ 2); + + let values_expected = TensorData::from([[[7., 4.], [6., 5.]], [[9., 3.], [8., 7.]]]); + + values + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::default()); + + let indices_expected = TensorData::from([[[2, 1], [2, 1]], [[2, 0], [0, 2]]]); + + indices.into_data().assert_eq(&indices_expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/transaction.rs b/crates/burn-backend-tests/tests/tensor/float/ops/transaction.rs new file mode 100644 index 0000000..ac9dbbf --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/transaction.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::Transaction; + +// https://github.com/tracel-ai/burn/issues/4021 +#[test] +fn should_support_transaction() { + let rows = 261120; + let cols = 408; + + let device = Default::default(); + + let j = TestTensor::<2>::zeros([rows, cols], &device); + let jt = j.clone().transpose(); + + let g = jt.matmul(j); + + let g = g.transpose(); + let expected = g.to_data(); + + assert_eq!(g.shape().dims(), [cols, cols]); + + // Fails + let [data] = Transaction::default() + .register(g) + .execute() + .try_into() + .unwrap(); + + // check byte equality + assert_eq!(data, expected); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/transpose.rs b/crates/burn-backend-tests/tests/tensor/float/ops/transpose.rs new file mode 100644 index 0000000..0146e8b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/transpose.rs @@ -0,0 +1,116 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_transpose_ops() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + // Check the .t() alias. + let output = tensor.t(); + + let expected = TensorData::from([ + [[0.0, 3.0], [1.0, 4.0], [2.0, 5.0]], + [[6.0, 9.0], [7.0, 10.0], [8.0, 11.0]], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_transpose_maybe_fused_with_one() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + let ones = TestTensor::<3>::ones([1, 1, 1], &Default::default()); + + let output = tensor.transpose(); + let expected = TensorData::from([ + [[0.0, 3.0], [1.0, 4.0], [2.0, 5.0]], + [[6.0, 9.0], [7.0, 10.0], [8.0, 11.0]], + ]); + let expected_ones = TensorData::from([[[1.0]]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + ones.into_data() + .assert_approx_eq::(&expected_ones, Tolerance::default()); +} + +#[test] +fn should_support_swap_dims_no_op() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let output = tensor.swap_dims(0, 0); + let expected = TensorData::from([ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_swap_dims() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let output = tensor.swap_dims(0, 2); + let expected = TensorData::from([ + [[0.0, 6.0], [3.0, 9.0]], + [[1.0, 7.0], [4.0, 10.0]], + [[2.0, 8.0], [5.0, 11.0]], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_swap_dims_neg_index() { + let tensor = TestTensor::<3>::from_data( + [ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + &Default::default(), + ); + + let output = tensor.swap_dims(-3, -1); + let expected = TensorData::from([ + [[0.0, 6.0], [3.0, 9.0]], + [[1.0, 7.0], [4.0, 10.0]], + [[2.0, 8.0], [5.0, 11.0]], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/tri.rs b/crates/burn-backend-tests/tests/tensor/float/ops/tri.rs new file mode 100644 index 0000000..3325e10 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/tri.rs @@ -0,0 +1,21 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_triu() { + let tensor = TestTensor::<2>::from([[1., 1., 1.], [1., 1., 1.], [1., 1., 1.]]); + let output = tensor.triu(0); + let expected = TensorData::from([[1., 1., 1.], [0., 1., 1.], [0., 0., 1.]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_triu_positive_diagonal() { + let tensor = TestTensor::<2>::from([[1, 1, 1], [1, 1, 1], [1, 1, 1]]); + + let output = tensor.triu(1); + let expected = TensorData::from([[0, 1, 1], [0, 0, 1], [0, 0, 0]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/trig.rs b/crates/burn-backend-tests/tests/tensor/float/ops/trig.rs new file mode 100644 index 0000000..61f065b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/trig.rs @@ -0,0 +1,292 @@ +#![allow(clippy::approx_constant)] + +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use burn_tensor::s; +use core::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_4, FRAC_PI_6, FRAC_PI_8, PI}; + +#[test] +fn should_support_cos_flipped_axis1() { + // [[0, pi], [pi/2, 3pi/2]] flipped on axis 1 -> [[pi, 0], [3pi/2, pi/2]] + let tensor = TestTensor::<2>::from([[0.0, PI], [FRAC_PI_2, 3.0 * FRAC_PI_2]]); + let flipped = tensor.flip([1]); + + let output = flipped.cos(); + let expected = TensorData::from([[-1.0, 1.0], [0.0, 0.0]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn should_support_sin_step_sliced() { + // Step-2 slice exercises stride=2 path in unary ops. + let tensor = TestTensor::<2>::from([[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]]); + let sliced = tensor.slice(s![.., 0..8;2]); + + let output = sliced.sin(); + let expected = TensorData::from([[0.0f32.sin(), 2.0f32.sin(), 4.0f32.sin(), 6.0f32.sin()]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn should_support_cos_step_sliced_3d() { + // 3D tensor with step-2 slice on last dim (the RF-DETR pattern). + let data: Vec = (0..12).map(|i| i as f32 * 0.5).collect(); + let tensor = TestTensor::<3>::from_data(TensorData::new(data, [1, 2, 6]), &Default::default()); + let sliced = tensor.slice(s![.., .., 0..6;2]); + + let output = sliced.cos(); + let expected = TensorData::from([[ + [0.0f32.cos(), 1.0f32.cos(), 2.0f32.cos()], + [3.0f32.cos(), 4.0f32.cos(), 5.0f32.cos()], + ]]); + + output.into_data().assert_approx_eq::( + &expected, + Tolerance::default().set_half_precision_absolute(2e-3), + ); +} + +#[test] +fn should_support_cos_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.cos(); + let expected = TensorData::from([[1.0, 0.54030, -0.41615], [-0.98999, -0.65364, 0.28366]]); + + // Metal has less precise trigonometric functions + let tolerance = Tolerance::default().set_half_precision_relative(1e-2); + + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn should_support_cosh_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.cosh(); + let expected = TensorData::from([[1.0000, 1.5431, 3.7622], [10.0677, 27.3082, 74.2099]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_sin_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.sin(); + let expected = TensorData::from([[0.0, 0.841471, 0.909297], [0.141120, -0.756802, -0.958924]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_sinh_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.sinh(); + let expected = TensorData::from([[0.0000, 1.1752, 3.6269], [10.0179, 27.2899, 74.2032]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_tan_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.tan(); + let expected = TensorData::from([[0.0, 1.557408, -2.185040], [-0.142547, 1.157821, -3.380515]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_tanh_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.tanh(); + let expected = TensorData::from([[0.0, 0.761594, 0.964028], [0.995055, 0.999329, 0.999909]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_asin_ops() { + let data = TensorData::from([[0.0, 0.5, 0.707107], [-0.5, -0.707107, -1.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.asin(); + let expected = TensorData::from([[0.0, 0.523599, 0.785398], [-0.523599, -0.785398, -1.570796]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_acos_ops() { + let data = TensorData::from([[0.0, 0.5, 0.707107], [-0.5, -0.707107, -1.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.acos(); + let expected = TensorData::from([ + [1.570796, 1.047198, 0.785398], + [2.094395, 2.356194, 3.141593], + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_atan_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.atan(); + let expected = TensorData::from([[0.0, 0.785398, 1.107149], [1.249046, 1.325818, 1.373401]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_asinh_ops() { + let data = TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.asinh(); + let expected = TensorData::from([[0.0, 0.881374, 1.443635], [1.818446, 2.094713, 2.312438]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_acosh_ops() { + let data = TensorData::from([[1.0, 1.5, 2.0], [3.0, 4.0, 5.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.acosh(); + let expected = TensorData::from([[0.0, 0.962424, 1.316958], [1.762747, 2.063437, 2.292432]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_atanh_ops() { + let data = TensorData::from([[0.0, 0.5, 0.707107], [-0.5, -0.707107, -0.9]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.atanh(); + let expected = TensorData::from([[0.0, 0.549306, 0.881374], [-0.549306, -0.881374, -1.472219]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_atan2_ops() { + let y = TensorData::from([[0.0, 1.0, 1.0], [-1.0, -1.0, 0.0]]); + let x = TensorData::from([[1.0, 1.0, 0.0], [1.0, 0.0, -1.0]]); + + let y_tensor = TestTensor::<2>::from_data(y, &Default::default()); + let x_tensor = TestTensor::<2>::from_data(x, &Default::default()); + + let output = y_tensor.atan2(x_tensor); + let expected = TensorData::from([[0.0, 0.785398, 1.570796], [-0.785398, -1.570796, 3.141593]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_deg2rad_ops() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data( + [ + 0.0, 22.5, 30.0, 45.0, 60.0, 90.0, 135.0, 180.0, 270.0, 360.0, + ], + &device, + ); + + let output = tensor.deg2rad(); + let expected = TensorData::from([ + 0.0f32, + FRAC_PI_8, + FRAC_PI_6, + FRAC_PI_4, + FRAC_PI_3, + FRAC_PI_2, + 0.75 * PI, + PI, + 1.5 * PI, + 2.0 * PI, + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_support_rad2deg_ops() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data( + [ + 0.0, + FRAC_PI_8, + FRAC_PI_6, + FRAC_PI_4, + FRAC_PI_3, + FRAC_PI_2, + PI, + 1.5 * PI, + 2.0 * PI, + -FRAC_PI_3, + ], + &device, + ); + + let output = tensor.rad2deg(); + let expected = TensorData::from([ + 0.0f32, 22.5, 30.0, 45.0, 60.0, 90.0, 180.0, 270.0, 360.0, -60.0, + ]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/trunc.rs b/crates/burn-backend-tests/tests/tensor/float/ops/trunc.rs new file mode 100644 index 0000000..d29692c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/trunc.rs @@ -0,0 +1,67 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ElementConversion, TensorData}; + +#[test] +fn should_support_trunc_ops() { + let data = TensorData::from([[2.3, -1.7, 0.5], [-0.5, 3.9, -4.2]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.trunc(); + let expected = TensorData::from([[2.0, -1.0, 0.0], [0.0, 3.0, -4.0]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_truncate_positive_values_like_floor() { + let data = TensorData::from([1.7, 2.9, 3.1, 4.5]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.trunc(); + let expected = TensorData::from([1.0, 2.0, 3.0, 4.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_truncate_negative_values_like_ceil() { + let data = TensorData::from([-1.7, -2.9, -3.1, -4.5]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.trunc(); + let expected = TensorData::from([-1.0, -2.0, -3.0, -4.0]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn should_handle_special_cases() { + // Test special IEEE 754 cases + let data = TensorData::from([0.0, -0.0, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]); + let tensor = TestTensor::<1>::from_data(data, &Default::default()); + + let output = tensor.trunc(); + let values = output.into_data().as_slice::().unwrap().to_vec(); + + // Check positive zero + assert_eq!(values[0], 0.0f32.elem::()); + assert!(values[0].is_sign_positive()); + + // Check negative zero is preserved + assert_eq!(values[1], 0.0f32.elem::()); + assert!(values[1].is_sign_negative()); + + // Check infinity is preserved + assert!(values[2].is_infinite() && values[2].is_sign_positive()); + assert!(values[3].is_infinite() && values[3].is_sign_negative()); + + // Check NaN is preserved + assert!(values[4].is_nan()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/ops/unfold.rs b/crates/burn-backend-tests/tests/tensor/float/ops/unfold.rs new file mode 100644 index 0000000..385cf0b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/ops/unfold.rs @@ -0,0 +1,68 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::TensorData; +use burn_tensor::s; + +#[test] +fn test_unfold_float() { + let device = Default::default(); + + let input = TestTensor::<3>::random([2, 6, 6], Distribution::Default, &device); + + let dim = 1; + let size = 3; + let step = 2; + let actual: TestTensor<4> = input.clone().unfold(dim, size, step); + + let expected = TestTensor::<4>::empty([2, 2, 6, 3], &device) + .slice_assign( + s![.., 0, .., ..], + input + .clone() + .slice(s![.., 0..3, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ) + .slice_assign( + s![.., 1, .., ..], + input + .clone() + .slice(s![.., 2..5, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ); + + actual.to_data().assert_eq(&expected.to_data(), true); +} + +#[test] +fn test_unfold_reshape_overlap() { + let device = Default::default(); + let signal = TestTensor::<2>::from_data( + [[1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12.]], + &device, + ); + + let size = 4; + let step = 1; + + let frames = signal.unfold::<3, _>(1, size, step); + + let flat = frames.reshape([9, 4]); + + let data = flat.into_data(); + + let expected = TensorData::from([ + [1., 2., 3., 4.], + [2., 3., 4., 5.], + [3., 4., 5., 6.], + [4., 5., 6., 7.], + [5., 6., 7., 8.], + [6., 7., 8., 9.], + [7., 8., 9., 10.], + [8., 9., 10., 11.], + [9., 10., 11., 12.], + ]); + + data.assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/primitive.rs b/crates/burn-backend-tests/tests/tensor/float/primitive.rs new file mode 100644 index 0000000..fc64fb4 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/primitive.rs @@ -0,0 +1,27 @@ +use super::*; +use burn_tensor::{Element, Shape, TensorData}; + +#[test] +fn should_support_float_dtype() { + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, 4.0, -5.0]])/*.into_primitive()*/; + + assert_eq!(tensor.shape(), Shape::new([2, 3])); + assert_eq!( + tensor.dtype(), + FloatElem::dtype() // default float elem type + ); +} + +#[test] +fn should_support_into_data_from_data() { + let device = Default::default(); + let data = + TestTensor::<2>::from_data([[0.0, -1.0, 2.0], [3.0, 4.0, -5.0]], &device).into_data(); + let tensor = TestTensor::<2>::from_data(data, &device).slice(0); + + // Regression test for `LazyDeviceController` from_data(tensor.into_data()) roundtrips + // These unnecessary round-trips should be avoided, but should not panic + tensor + .into_data() + .assert_eq(&TensorData::from([[0.0, -1.0, 2.0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/calibration.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/calibration.rs new file mode 100644 index 0000000..5e4f801 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/calibration.rs @@ -0,0 +1,110 @@ +use super::*; +use burn_tensor::{ + TensorData, Tolerance, + quantization::{Calibration, QuantLevel, QuantValue, compute_range}, +}; + +// NOTE: The scheme variant fields are not important for calibration, only the "main" variant (e.g., per-tensor) +#[test] +fn min_max_calibration_range_per_tensor() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([-1.8, -1.0, 0.0, 0.5], &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let range = compute_range(&scheme, &tensor, &Calibration::MinMax); + + range + .min + .into_data() + .assert_eq(&TensorData::from([-1.8]), false); + range + .max + .into_data() + .assert_eq(&TensorData::from([0.5]), false); +} + +#[test] +fn min_max_calibration_range_per_block() { + let device = Default::default(); + let tensor = TestTensor::<2>::from_data( + [ + [-1.8, -1.0, 0.0, 0.5], + [1.8, 1.0, 0.0, -0.5], + [0.01, 0.02, 0.03, 0.04], + [-0.01, -0.02, -0.03, -0.04], + ], + &device, + ); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([4])); + + let range = compute_range(&scheme, &tensor, &Calibration::MinMax); + + range + .min + .into_data() + .assert_eq(&TensorData::from([[-1.8], [-0.5], [0.01], [-0.04]]), false); + range + .max + .into_data() + .assert_eq(&TensorData::from([[0.5], [1.8], [0.04], [-0.01]]), false); +} + +// abs-mean calibration: gamma = mean(|W|), range = [-gamma, +gamma] +// weights: [-0.9, -0.3, 0.0, 0.6] => mean(|w|) = (0.9+0.3+0.0+0.6)/4 = 0.45 +#[test] +fn abs_mean_calibration_range_per_tensor() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([-0.9_f32, -0.3, 0.0, 0.6], &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q2S); + + let range = compute_range(&scheme, &tensor, &Calibration::AbsMean); + + range + .min + .into_data() + .assert_approx_eq::(&TensorData::from([-0.45_f32]), Tolerance::default()); + range + .max + .into_data() + .assert_approx_eq::(&TensorData::from([0.45_f32]), Tolerance::default()); +} + +// block abs-mean: 2 blocks of 4 weights each +// block 0: [-0.9, -0.3, 0.0, 0.6] gamma = 0.45 +// block 1: [0.1, 0.2, 0.3, 0.4] gamma = 0.25 +#[test] +fn abs_mean_calibration_range_per_block() { + let device = Default::default(); + let tensor = + TestTensor::<2>::from_data([[-0.9_f32, -0.3, 0.0, 0.6], [0.1, 0.2, 0.3, 0.4]], &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q2S) + .with_level(QuantLevel::block([4])); + + let range = compute_range(&scheme, &tensor, &Calibration::AbsMean); + + range.min.into_data().assert_approx_eq::( + &TensorData::from([[-0.45_f32], [-0.25]]), + Tolerance::default(), + ); + range.max.into_data().assert_approx_eq::( + &TensorData::from([[0.45_f32], [0.25]]), + Tolerance::default(), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/data.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/data.rs new file mode 100644 index 0000000..71669cc --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/data.rs @@ -0,0 +1,48 @@ +use super::*; +use alloc::vec; +use burn_tensor::quantization::{QuantLevel, QuantValue}; +use burn_tensor::{Device, TensorData}; + +#[test] +fn should_support_per_tensor_symmetric_int8() { + let device = Device::default(); + let data = TensorData::quantized( + vec![-127i8, -71, 0, 35], + [4], + device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S), + &[0.014_173_228], + ); + let tensor = TestTensor::<1>::from_data(data.clone(), &device); + + let q_data = tensor.into_data(); + q_data.assert_eq(&data, true); + + let tensor = TestTensor::<1>::from_data(q_data.clone(), &device); + + tensor.into_data().assert_eq(&q_data, true); +} + +#[test] +fn should_support_per_block_symmetric_int8() { + let device = Device::default(); + let data = TensorData::quantized( + vec![ + -127i8, -71, 0, 35, -127i8, -71, 0, 35, -32, -63, -95, -127, -32, -63, -95, -127, + ], + [16], + device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([8])), + &[0.014_173_228, 0.000_314_96], + ); + let tensor = TestTensor::<1>::from_data(data.clone(), &device); + + tensor.into_data().assert_eq(&data, true); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/mod.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/mod.rs new file mode 100644 index 0000000..8e92afd --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/mod.rs @@ -0,0 +1,49 @@ +pub use super::*; // re-export test types + +mod calibration; +mod data; +mod ops; +mod scheme; + +/// Quantized tensor utilities +pub mod qtensor { + use super::TestTensor; + + use burn_tensor::quantization::QuantLevel; + use burn_tensor::{TensorData, quantization::QuantValue}; + + pub struct QTensor; + + impl QTensor { + /// Creates a quantized int8 tensor from the floating point data using the default quantization scheme + /// (i.e., per-tensor symmetric quantization). + pub fn int8>(floats: F) -> TestTensor { + Self::int8_symmetric(floats) + } + + /// Creates a quantized int8 tensor from the floating point data using blocks of size 16 + pub fn int8_block>(floats: F) -> TestTensor { + let device = Default::default(); + TestTensor::from_data(floats, &device).quantize_dynamic( + &device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([16])), + ) + } + + /// Creates a quantized int8 tensor from the floating point data using per-tensor symmetric quantization. + pub fn int8_symmetric>(floats: F) -> TestTensor { + let device = Default::default(); + TestTensor::from_data(floats, &device).quantize_dynamic( + &device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S), + ) + } + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/abs.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/abs.rs new file mode 100644 index 0000000..33e1dd9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/abs.rs @@ -0,0 +1,19 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_abs_ops() { + let tensor = QTensor::<2>::int8([[0.0, -1.0, 2.0], [3.0, 4.0, -5.0]]); + + let output = tensor.abs(); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/add.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/add.rs new file mode 100644 index 0000000..a7afe09 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/add.rs @@ -0,0 +1,106 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_add_d2() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]]); + + let output = tensor_1 + tensor_2; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[6.0, 8.0, 10.0], [12.0, 14.0, 16.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_add_broadcast() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0]]); + let tensor_2 = QTensor::<2>::int8([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + + let output = tensor_1 + tensor_2; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[3.0, 5.0, 7.0], [6.0, 8.0, 10.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_add_different_strides_rhs() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0], [6.0, 7.0]]) * 1; + + let output = tensor_1 + tensor_2.transpose(); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[4.0, 7.0], [7.0, 10.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_add_different_strides_lhs() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0], [6.0, 7.0]]) * 1; + + let output = tensor_1.transpose() + tensor_2; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[4.0, 7.0], [7.0, 10.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_add_different_strides_broadcast() { + // We need to execute an operation after `from data` to trigger inplace in some backends. + // Which is the operation that might be problematic in this case. + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0], [2.0, 3.0]]) * 1; + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0]]) * 1; + + let output = tensor_1.transpose() + tensor_2; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[4.0, 7.0], [5.0, 8.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_support_add_scalar_ops() { + let scalar = 2.0; + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor + scalar; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[2.0, 3.0, 4.0], [5.0, 6.0, 7.0]]), + Tolerance::absolute(1e-1), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/aggregation.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/aggregation.rs new file mode 100644 index 0000000..a684c9b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/aggregation.rs @@ -0,0 +1,166 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_should_mean() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.mean(); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&TensorData::from([15.0 / 6.0]), Tolerance::absolute(1e-1)); +} + +#[test] +fn test_should_sum() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sum(); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&TensorData::from([15.0]), Tolerance::absolute(1e-1)); +} + +#[test] +fn test_should_mean_last_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.mean_dim(1); + let expected = TensorData::from([[3.0 / 3.0], [12.0 / 3.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_should_sum_last_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sum_dim(1); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[3.0], [12.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_should_sum_first_dim() { + let tensor = QTensor::<2>::int8([[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]]); + + let output = tensor.sum_dim(0); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[7.0, 3.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_should_mean_first_dim() { + let tensor = QTensor::<2>::int8([[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]]); + + let output = tensor.mean_dim(0); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[7.0 / 2.0, 3.0 / 2.0, 5.0 / 2.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_should_sum_mid_dim_3d_non_contiguous_1() { + let tensor = QTensor::<3>::int8([ + [[2.0, 4.0, 1.0], [7.0, -5.0, 3.0]], + [[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]], + ]); + + let output = tensor.swap_dims(0, 2).sum_dim(1); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::new(vec![9.0, 7.0, -1.0, 3.0, 4.0, 5.0], [3, 1, 2]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_should_sum_mid_dim_3d_non_contiguous_2() { + let tensor = QTensor::<3>::int8([ + [[2.0, 4.0, 1.0], [7.0, -5.0, 3.0]], + [[3.0, 1.0, 2.0], [4.0, 2.0, 3.0]], + ]); + + let output = tensor.swap_dims(0, 1).sum_dim(1); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::new(vec![5.0, 5.0, 3.0, 11.0, -3.0, 6.0], [2, 1, 3]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn test_prod_float() { + let tensor = QTensor::<2>::int8([[2.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.prod(); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&TensorData::from([240.0]), Tolerance::rel_abs(1e-1, 1e-1)); + + let tensor_with_zero = QTensor::<2>::int8([[2.0, 0.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor_with_zero.prod(); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&TensorData::from([0.0]), Tolerance::rel_abs(1e-1, 1e-1)); +} + +#[test] +fn test_prod_dim_float() { + let tensor = QTensor::<2>::int8([[2.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.prod_dim(1); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[4.0], [60.0]]), + Tolerance::absolute(1e-1), + ); + + let tensor_with_zero = QTensor::<2>::int8([[2.0, 0.0, 2.0], [3.0, 4.0, 5.0]]); + let output = tensor_with_zero.prod_dim(1); + let expected = TensorData::from([[0.0], [60.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/all.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/all.rs new file mode 100644 index 0000000..30fb967 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/all.rs @@ -0,0 +1,24 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_all() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([false]); + assert_eq!(data_expected, data_actual); + + let tensor = QTensor::<2>::int8([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([true]); + assert_eq!(data_expected, data_actual); +} + +#[test] +fn test_all_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 0.0], [1.0, -1.0, 1.0]]); + let data_actual = tensor.all_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + assert_eq!(data_expected, data_actual); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/any.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/any.rs new file mode 100644 index 0000000..6f0671a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/any.rs @@ -0,0 +1,25 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_any() { + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [1.0, -1.0, 0.0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + assert_eq!(data_expected, data_actual); + + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + assert_eq!(data_expected, data_actual); +} + +#[test] +fn test_any_dim() { + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [1.0, -1.0, 0.0]]); + + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + assert_eq!(data_expected, data_actual); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/arg.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/arg.rs new file mode 100644 index 0000000..f4138ac --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/arg.rs @@ -0,0 +1,47 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_argmax_2d_dim0() { + let tensor = QTensor::<2>::int8([[10.0, 11.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.argmax(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 0, 1]]), false); +} + +#[test] +fn test_argmin_2d_dim0() { + let tensor = QTensor::<2>::int8([[10.0, 11.0, 2.0], [30.0, 4.0, 5.0]]); + + let output = tensor.argmin(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 0]]), false); +} + +#[test] +fn test_argmax_2d_dim1() { + let tensor = QTensor::<2>::int8([[10.0, 11.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.argmax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1], [2]]), false); +} + +#[test] +fn test_argmin_2d_dim1() { + let tensor = QTensor::<2>::int8([[10.0, 11.0, 2.0], [30.0, 4.0, 5.0]]); + + let output = tensor.argmin(1); + + output + .into_data() + .assert_eq(&TensorData::from([[2], [1]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cat.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cat.rs new file mode 100644 index 0000000..e856f49 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cat.rs @@ -0,0 +1,65 @@ +use super::qtensor::*; +use super::*; +use alloc::vec; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_cat_ops_2d_dim0() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0, 6.0]]); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_cat_ops_2d_dim1() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0, 6.0]]); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 1); + let expected = TensorData::from([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_cat_ops_3d() { + let tensor_1 = QTensor::<3>::int8([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]]]); + let tensor_2 = QTensor::<3>::int8([[[4.0, 5.0, 6.0]]]); + + let output = TestTensor::cat(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1.0, 2.0, 3.0]], [[1.1, 2.1, 3.1]], [[4.0, 5.0, 6.0]]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[should_panic] +fn should_panic_when_dimensions_are_not_the_same() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0]]); + + let _output = TestTensor::cat(vec![tensor_1, tensor_2], 0); +} + +#[test] +#[should_panic] +fn should_panic_when_cat_exceeds_dimension() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0, 6.0]]); + + let _output = TestTensor::cat(vec![tensor_1, tensor_2], 3); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/ceil.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/ceil.rs new file mode 100644 index 0000000..168fdd4 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/ceil.rs @@ -0,0 +1,16 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_ceil_ops() { + let tensor = QTensor::<2>::int8([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + + let output = tensor.ceil(); + let expected = TensorData::from([[25., 88., 77.], [60., 44., 96.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-1, 1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/chunk.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/chunk.rs new file mode 100644 index 0000000..790478a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/chunk.rs @@ -0,0 +1,98 @@ +use super::qtensor::*; +use super::*; +use alloc::vec::Vec; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_chunk_evenly_divisible() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let tensors: Vec> = tensor.chunk(3, 0); + assert_eq!(tensors.len(), 3); + + let expected = [ + TensorData::from([0., 1.]), + TensorData::from([2., 3.]), + TensorData::from([4., 5.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_chunk_not_evenly_divisible() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + + let tensors: Vec> = tensor.chunk(4, 0); + assert_eq!(tensors.len(), 4); + + let expected = [ + TensorData::from([0., 1.]), + TensorData::from([2., 3.]), + TensorData::from([4., 5.]), + TensorData::from([6.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_chunk_not_divisible() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let tensors: Vec> = tensor.chunk(7, 0); + assert_eq!(tensors.len(), 6); + + let expected = [ + TensorData::from([0.]), + TensorData::from([1.]), + TensorData::from([2.]), + TensorData::from([3.]), + TensorData::from([4.]), + TensorData::from([5.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_chunk_multi_dimension() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]]); + + let tensors: Vec> = tensor.chunk(2, 1); + assert_eq!(tensors.len(), 2); + + let expected = [ + TensorData::from([[0., 1., 2.]]), + TensorData::from([[3., 4., 5.]]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +#[should_panic] +fn test_invalid_dim() { + let _tensors = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]).chunk(6, 1); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/clamp.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/clamp.rs new file mode 100644 index 0000000..b571a83 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/clamp.rs @@ -0,0 +1,49 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn clamp_min() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.clamp_min(2.0); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[2.0, 2.0, 2.0], [3.0, 4.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn clamp_max() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.clamp_max(2.0); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 1.0, 2.0], [2.0, 2.0, 2.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn clamp_min_max() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.clamp(1.0, 4.0); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[1.0, 1.0, 2.0], [3.0, 4.0, 4.0]]), + Tolerance::absolute(1e-1), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cos.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cos.rs new file mode 100644 index 0000000..457d4e9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cos.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_cos_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.cos(); + let expected = TensorData::from([[1.0, 0.5403, -0.4161], [-0.9899, -0.6536, 0.2836]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cosh.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cosh.rs new file mode 100644 index 0000000..deca1ad --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/cosh.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_cosh_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.cosh(); + let expected = TensorData::from([[1.0000, 1.5431, 3.7622], [10.0677, 27.3082, 74.2100]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/div.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/div.rs new file mode 100644 index 0000000..47aaee3 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/div.rs @@ -0,0 +1,50 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_div_ops() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor_1 / tensor_2; + let expected = TensorData::from([[0.0, 1.0, 1.0], [1.0, 1.0, 1.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_div_broadcast() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0]]); + let tensor_2 = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor_1 / tensor_2; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 1.0, 1.0], [0.0, 0.25, 0.4]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_support_div_scalar_ops() { + let scalar = 2.0; + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor / scalar; + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 0.5, 1.0], [1.5, 2.0, 2.5]]), + Tolerance::absolute(1e-1), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/erf.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/erf.rs new file mode 100644 index 0000000..96776e9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/erf.rs @@ -0,0 +1,33 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_erf_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.erf(); + let expected = TensorData::from([[0.0000, 0.8427, 0.9953], [1.0000, 1.0000, 1.0000]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_erf_ops_with_negative_number() { + let tensor = QTensor::<2>::int8([[-0.056, -0.043, -0.089], [3.0, 4.0, 5.0]]); + + let output = tensor.erf(); + let expected = TensorData::from([ + [-0.06312324, -0.048490416, -0.10016122], + [1.0000, 1.0000, 1.0000], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/exp.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/exp.rs new file mode 100644 index 0000000..7800697 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/exp.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_exp_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.exp(); + let expected = TensorData::from([[1.0, 2.71830, 7.3891], [20.0855, 54.5981, 148.4132]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/expand.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/expand.rs new file mode 100644 index 0000000..41f1fee --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/expand.rs @@ -0,0 +1,112 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn expand_2d() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0]); + let output = tensor.expand([3, 3]); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]), + Tolerance::absolute(1e-1), + ); + + // Quantized [4.0, 7.0, 2.0, 3.0] + let tensor = QTensor::<1>::int8([4.0, 7.0, 2.0, 3.0]); + let output = tensor.expand([2, 4]); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[4.0, 7.0, 2.0, 3.0], [4.0, 7.0, 2.0, 3.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn expand_3d() { + let tensor = QTensor::<2>::int8([[1.0, 2.0], [3.0, 4.0]]); + + let output = tensor.expand([3, 2, 2]); + let expected = TensorData::from([ + [[1.0, 2.0], [3.0, 4.0]], + [[1.0, 2.0], [3.0, 4.0]], + [[1.0, 2.0], [3.0, 4.0]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn expand_higher_dimensions() { + let tensor = QTensor::<2>::int8([[1.0, 2.0, 3.0, 4.0]]); + + let output = tensor.expand([2, 3, 4]); + let expected = TensorData::from([ + [ + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + ], + [ + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + ], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn broadcast_single() { + let tensor = QTensor::<1>::int8([1.0]); + + let output = tensor.expand([2, 3]); + + output + .dequantize() + .into_data() + .assert_eq(&TensorData::from([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), false); +} + +#[test] +#[should_panic] +fn should_fail_expand_incompatible_shapes() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0]); + let _expanded_tensor = tensor.expand([2, 2]); +} + +#[test] +fn should_all_negative_one() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0]); + + let output = tensor.expand([2, -1]); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[1., 2., 3.], [1., 2., 3.]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +#[should_panic] +fn should_panic_negative_one_on_non_existing_dim() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0]); + let _expanded_tensor = tensor.expand([-1, 3]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/flip.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/flip.rs new file mode 100644 index 0000000..a1930aa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/flip.rs @@ -0,0 +1,39 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn flip_float() { + let tensor = QTensor::<3>::int8([[[0.0, 1.0, 2.0]], [[3.0, 4.0, 5.0]]]); + + let flipped = tensor.clone().flip([0, 2]); + let expected = TensorData::from([[[5., 4., 3.]], [[2., 1., 0.]]]); + + flipped + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); + + // Test with no flip + let flipped = tensor.clone().flip([]); + tensor.into_data().assert_eq(&flipped.into_data(), true); +} + +#[test] +#[should_panic] +fn flip_duplicated_axes() { + let tensor = QTensor::<3>::int8([[[0.0, 1.0, 2.0]], [[3.0, 4.0, 5.0]]]); + + // Test with a duplicated axis + let _ = tensor.flip([0, 0, 1]); +} + +#[test] +#[should_panic] +fn flip_out_of_bound_axis() { + let tensor = QTensor::<3>::int8([[[0.0, 1.0, 2.0]], [[3.0, 4.0, 5.0]]]); + + // Test with an out of bound axis + let _ = tensor.clone().flip([3, 0, 1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/floor.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/floor.rs new file mode 100644 index 0000000..438c6f0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/floor.rs @@ -0,0 +1,16 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_floor_ops() { + let tensor = QTensor::<2>::int8([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + + let output = tensor.floor(); + let expected = TensorData::from([[24., 87., 76.], [59., 43., 95.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-1, 1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/gather_scatter.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/gather_scatter.rs new file mode 100644 index 0000000..3ca2395 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/gather_scatter.rs @@ -0,0 +1,198 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::IndexingUpdateOp; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_gather_1d_dim0() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let indices = TestTensorInt::from_ints([1, 1, 0, 1, 2], &Default::default()); + + let output = tensor.gather(0, indices); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([1.0, 1.0, 0.0, 1.0, 2.0]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_gather_2d_dim0() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_ints([[0, 1, 0], [1, 0, 1]], &Default::default()); + + let output = tensor.gather(0, indices); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 4.0, 2.0], [3.0, 1.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_gather_2d_dim1() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_ints([[2, 1, 0, 0], [2, 0, 1, 2]], &Default::default()); + + let output = tensor.gather(1, indices); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[2.0, 1.0, 0.0, 0.0], [5.0, 3.0, 4.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_gather_3d_dim1() { + let tensor = QTensor::<3>::int8([ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ]); + let indices = TestTensorInt::from_ints( + [[[1, 0, 0], [0, 1, 0]], [[0, 0, 1], [0, 1, 1]]], + &Default::default(), + ); + + let output = tensor.gather(1, indices); + let expected = TensorData::from([ + [[3.0, 1.0, 2.0], [0.0, 4.0, 2.0]], + [[6.0, 7.0, 11.0], [6.0, 10.0, 11.0]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_gather_2d_only_1dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::<2>::from_ints([[1, 2]], &Default::default()).reshape([2, 1]); + + let output = tensor.gather(1, indices); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[1.0], [5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_scatter_1d() { + let tensor = QTensor::<1>::int8([0.0, 0.0, 0.0]); + let values = QTensor::<1>::int8([5.0, 4.0, 3.0]); + let indices = TestTensorInt::from_ints([1, 0, 2], &Default::default()); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([4.0, 5.0, 3.0]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_scatter_2d_dim0() { + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + let values = QTensor::<2>::int8([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let indices = TestTensorInt::from_ints([[1, 0, 1], [1, 1, 0]], &Default::default()); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 2.0, 6.0], [5.0, 5.0, 3.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_scatter_2d_dim1() { + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + let values = QTensor::<2>::int8([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let indices = TestTensorInt::from_ints([[1, 0, 2], [1, 2, 0]], &Default::default()); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[2.0, 1.0, 3.0], [6.0, 4.0, 5.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +fn should_scatter_3d_dim1() { + let tensor = QTensor::<3>::int8([ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ]); + let values = QTensor::<3>::int8([ + [[12.0, 13.0, 14.0], [15.0, 16.0, 17.0]], + [[18.0, 19.0, 20.0], [21.0, 22.0, 23.0]], + ]); + let indices = TestTensorInt::from_ints( + [[[1, 0, 0], [0, 1, 0]], [[0, 0, 1], [0, 1, 1]]], + &Default::default(), + ); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([ + [[15.0, 14.0, 33.0], [15.0, 20.0, 5.0]], + [[45.0, 26.0, 8.0], [9.0, 32.0, 54.0]], + ]); + + // Set higher tolerance (0.2) due to larger de/quantization errors + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(2e-1)); +} + +#[test] +fn should_scatter_2d_dim1_diff_shape() { + let tensor = QTensor::<2>::int8([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + let values = QTensor::<2>::int8([[1.0], [4.0]]); + let indices = TestTensorInt::from_ints([[1], [2]], &Default::default()); + + let output = tensor.scatter(1, indices, values, IndexingUpdateOp::Add); + + output + .dequantize() + .into_data() + .assert_approx_eq::( + &TensorData::from([[0.0, 1.0, 0.0], [0.0, 0.0, 4.0]]), + Tolerance::absolute(1e-1), + ); +} + +#[test] +#[should_panic] +fn scatter_should_panic_on_mismatch_of_shapes() { + let tensor = QTensor::<1>::int8([0.0, 0.0, 0.0]); + let values = QTensor::<1>::int8([1.0, 4.0]); + let indices = TestTensorInt::from_ints([1, 0, 2], &Default::default()); + + tensor.scatter(0, indices, values, IndexingUpdateOp::Add); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log.rs new file mode 100644 index 0000000..ed31e2d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log.rs @@ -0,0 +1,20 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_log_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.log(); + let expected = TensorData::from([ + [-f32::INFINITY, 0.0, core::f32::consts::LN_2], + [1.0986, 1.3862, 1.6094], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log1p.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log1p.rs new file mode 100644 index 0000000..1861de7 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/log1p.rs @@ -0,0 +1,20 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_exp_log1p() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.log1p(); + let expected = TensorData::from([ + [0.0, core::f32::consts::LN_2, 1.0986], + [1.3862, 1.6094, 1.7917], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/map_comparison.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/map_comparison.rs new file mode 100644 index 0000000..cbf2f2c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/map_comparison.rs @@ -0,0 +1,157 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_equal() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.equal(tensor_2); + + let data_expected = TensorData::from([[true, true, false], [true, false, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_not_equal() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().not_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.not_equal(tensor_2); + + let data_expected = TensorData::from([[false, false, true], [false, true, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +#[ignore = "quantization equality with float element is undefined"] +fn test_equal_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 2.0, 5.0]]); + + let data_actual_cloned = tensor.clone().equal_elem(2); + let data_actual_inplace = tensor.equal_elem(2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +#[ignore = "quantization equality with float element is undefined"] +fn test_not_equal_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 2.0, 5.0]]); + + let data_actual_cloned = tensor.clone().not_equal_elem(2); + let data_actual_inplace = tensor.not_equal_elem(2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +#[ignore = "quantization equality with float element is undefined"] +fn test_greater_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor.clone().greater_elem(4); + let data_actual_inplace = tensor.greater_elem(4); + + let data_expected = TensorData::from([[false, false, false], [false, false, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_greater_equal_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor.clone().greater_equal_elem(4.0); + let data_actual_inplace = tensor.greater_equal_elem(4.0); + + let data_expected = TensorData::from([[false, false, false], [false, true, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_greater() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().greater(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater(tensor_2); + + let data_expected = TensorData::from([[false, false, true], [false, false, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_greater_equal() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().greater_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater_equal(tensor_2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_lower_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor.clone().lower_elem(4.0); + let data_actual_inplace = tensor.lower_elem(4.0); + + let data_expected = TensorData::from([[true, true, true], [true, false, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +#[ignore = "quantization equality with float element is undefined"] +fn test_lower_equal_elem() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let data_actual_cloned = tensor.clone().lower_equal_elem(4.0); + let data_actual_inplace = tensor.lower_equal_elem(4.0); + + let data_expected = TensorData::from([[true, true, true], [true, true, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_lower() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().lower(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower(tensor_2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} + +#[test] +fn test_lower_equal() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[0.0, 1.0, 1.0], [3.0, 5.0, 4.0]]); + + let data_actual_cloned = tensor_1.clone().lower_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower_equal(tensor_2); + + let data_expected = TensorData::from([[true, true, false], [true, true, false]]); + assert_eq!(data_expected, data_actual_cloned.into_data()); + assert_eq!(data_expected, data_actual_inplace.into_data()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mask.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mask.rs new file mode 100644 index 0000000..25871d8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mask.rs @@ -0,0 +1,39 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_mask_where_ops() { + let tensor = QTensor::<2>::int8([[1.0, 7.0], [2.0, 3.0]]); + let mask = TestTensorBool::<2>::from_bool( + TensorData::from([[true, false], [false, true]]), + &Default::default(), + ); + let value = QTensor::<2>::int8([[1.8, 2.8], [3.8, 4.8]]); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([[1.8, 7.0], [2.0, 4.8]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_mask_fill_ops() { + let tensor = QTensor::<2>::int8([[1.0, 7.0], [2.0, 3.0]]); + let mask = TestTensorBool::<2>::from_bool( + TensorData::from([[true, false], [false, true]]), + &Default::default(), + ); + + let output = tensor.mask_fill(mask, 2.0); + let expected = TensorData::from([[2.0, 7.0], [2.0, 2.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/maxmin.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/maxmin.rs new file mode 100644 index 0000000..d77920d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/maxmin.rs @@ -0,0 +1,149 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_max_dim_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.max_dim(1); + let expected = TensorData::from([[2.], [5.]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn test_max_dim_with_indices_2d_with_dim_0th() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let (output, index) = tensor.max_dim_with_indices(0); + + let output_expected = TensorData::from([[3., 4., 5.]]); + let index_expected = TensorData::from([[1, 1, 1]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&output_expected, Tolerance::rel_abs(2e-2, 1e-2)); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_max_dim_with_indices_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let (output, index) = tensor.max_dim_with_indices(1); + + let output_expected = TensorData::from([[2.], [5.]]); + let index_expected = TensorData::from([[2], [2]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&output_expected, Tolerance::rel_abs(2e-2, 1e-2)); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_min_dim_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.min_dim(1); + + let expected = TensorData::from([[0.], [3.]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn test_min_dim_with_indices_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let (output, index) = tensor.min_dim_with_indices(1); + + let output_expected = TensorData::from([[0.], [3.]]); + let index_expected = TensorData::from([[0], [0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&output_expected, Tolerance::rel_abs(2e-2, 1e-2)); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_min_dim_2d_with_0th_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.min_dim(0); + let expected = TensorData::from([[0., 1., 2.]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 3e-2)); +} + +#[test] +fn test_max_dim_2d_with_0th_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.max_dim(0); + let expected = TensorData::from([[3., 4., 5.]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn test_min_dim_with_indices_2d_with_0th_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let (output, index) = tensor.min_dim_with_indices(0); + + let output_expected = TensorData::from([[0., 1., 2.]]); + let index_expected = TensorData::from([[0, 0, 0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&output_expected, Tolerance::rel_abs(2e-2, 3e-2)); + index.into_data().assert_eq(&index_expected, false); +} + +#[test] +fn test_maximum_pair() { + let a = QTensor::<1>::int8([1.0, 5.0, 3.0, 4.0]); + let b = QTensor::<1>::int8([2.0, 1.0, 4.0, 5.0]); + + let output = a.max_pair(b); + let expected = TensorData::from([2.0, 5.0, 4.0, 5.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn test_minimum_pair() { + let a = QTensor::<1>::int8([1.0, 5.0, 3.0, 4.0]); + let b = QTensor::<1>::int8([2.0, 1.0, 4.0, 5.0]); + + let output = a.min_pair(b); + let expected = TensorData::from([1.0, 1.0, 3.0, 4.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs new file mode 100644 index 0000000..a3cc89f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mod.rs @@ -0,0 +1,50 @@ +pub use super::*; + +mod abs; +mod add; +mod aggregation; +mod all; +mod any; +mod arg; +mod cat; +mod ceil; +mod chunk; +mod clamp; +mod cos; +mod cosh; +mod div; +mod erf; +mod exp; +mod expand; +mod flip; +mod floor; +mod gather_scatter; +mod log; +mod log1p; +mod map_comparison; +mod mask; +mod maxmin; +mod mul; +mod narrow; +mod neg; +mod permute; +mod powf; +mod powf_scalar; +mod recip; +mod remainder; +mod repeat_dim; +mod reshape; +mod round; +mod select; +mod sin; +mod sinh; +mod slice; +mod sort_argsort; +mod split; +mod sqrt; +mod stack; +mod sub; +mod tan; +mod tanh; +mod topk; +mod transpose; diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mul.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mul.rs new file mode 100644 index 0000000..f7706d1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/mul.rs @@ -0,0 +1,60 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_mul_ops() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = tensor_1.clone(); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 1.0, 4.0], [9.0, 16.0, 25.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-2, 1e-2)); +} + +#[test] +fn test_mul_broadcast() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0]]); + let tensor_2 = QTensor::<2>::int8([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 4.0, 10.0], [0.0, 7.0, 16.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn test_mul_broadcast_2_dims() { + let tensor_1 = QTensor::<2>::int8([[0.0], [1.0], [2.0]]); + let tensor_2 = QTensor::<2>::int8([[3.0, 4.0, 5.0]]); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0.0, 0.0, 0.0], [3.0, 4.0, 5.0], [6.0, 8.0, 10.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_mul_scalar_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let scalar = 2.0; + + let output = tensor * scalar; + let expected = TensorData::from([[0.0, 2.0, 4.0], [6.0, 8.0, 10.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/narrow.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/narrow.rs new file mode 100644 index 0000000..cf92235 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/narrow.rs @@ -0,0 +1,58 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{Shape, TensorData}; + +#[test] +fn test_narrow() { + let tensor = QTensor::<2>::int8([[1., 2., 3.], [7., 8., 9.], [13., 14., 15.]]); + + let output = tensor.clone().narrow(0, 0, 2); + let expected = TensorData::from([[1., 2., 3.], [7., 8., 9.]]); + + assert_eq!(output.shape(), Shape::from([2, 3])); + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); + + let output = tensor.narrow(1, 1, 2); + let expected = TensorData::from([[2., 3.], [8., 9.], [14., 15.]]); + assert_eq!(output.shape(), Shape::from([3, 2])); + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_dim() { + let tensor = QTensor::<2>::int8([[1., 2., 3.], [7., 8., 9.], [13., 14., 15.]]); + + let _output = tensor.narrow(2, 0, 2); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_start() { + let tensor = QTensor::<2>::int8([[1., 2., 3.], [7., 8., 9.], [13., 14., 15.]]); + + let _output = tensor.narrow(0, 3, 2); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_zero_length() { + let tensor = QTensor::<2>::int8([[1., 2., 3.], [7., 8., 9.], [13., 14., 15.]]); + + let _output = tensor.narrow(0, 1, 0); +} + +#[test] +#[should_panic] +fn test_narrow_invalid_length() { + let tensor = QTensor::<2>::int8([[1., 2., 3.], [7., 8., 9.], [13., 14., 15.]]); + + let _output = tensor.narrow(0, 0, 4); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/neg.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/neg.rs new file mode 100644 index 0000000..b061c38 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/neg.rs @@ -0,0 +1,19 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_neg_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.neg(); + let expected = TensorData::from([[-0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]).convert::(); + + // -0.0 is represented differently than 0.0 so we make sure the values are the same in f32 + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/permute.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/permute.rs new file mode 100644 index 0000000..fb5018c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/permute.rs @@ -0,0 +1,66 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn permute_float() { + let tensor = QTensor::<1>::int8([ + 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., + ]) + .reshape([2, 2, 4]); + + let permuted = tensor.clone().permute([2, 1, 0]); + + let expected = TensorData::from([ + [[0., 8.], [4., 12.]], + [[1., 9.], [5., 13.]], + [[2., 10.], [6., 14.]], + [[3., 11.], [7., 15.]], + ]); + + permuted + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-1, 1e-1)); + + // Test with negative axis + let permuted = tensor.clone().permute([-1, 1, 0]); + permuted + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-1, 1e-1)); + + // Test with the same axis + let permuted = tensor.clone().permute([0, 1, 2]); + permuted + .dequantize() + .into_data() + .assert_approx_eq::( + &tensor.dequantize().into_data(), + Tolerance::rel_abs(1e-4, 1e-4), // dequant error should be the same + ); +} + +#[test] +#[should_panic] +fn edge_repeated_axes() { + let tensor = QTensor::<1>::int8([ + 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., + ]) + .reshape([2, 2, 4]); + + // Test with a repeated axis + let _ = tensor.permute([0, 0, 1]); +} + +#[test] +#[should_panic] +fn edge_out_of_bound_axis() { + let tensor = QTensor::<1>::int8([ + 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., + ]) + .reshape([2, 2, 4]); + + // Test with an invalid axis + let _ = tensor.permute([3, 0, 1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf.rs new file mode 100644 index 0000000..0030ce9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf.rs @@ -0,0 +1,60 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_powf_ops() { + let tensor = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_pow = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 2.0]]); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1.0, 1.0, 4.0], [27.0, 256.0, 25.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_power() { + let tensor = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_pow = QTensor::<2>::int8([[-0.95, -0.67, -0.45], [-0.24, -0.5, -0.6]]); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[1., 1., 0.73204285], [0.76822936, 0.5, 0.38073079]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_values_with_even_power() { + let tensor = QTensor::<2>::int8([[0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + let tensor_pow = QTensor::<2>::int8([[2.0, 2.0, 2.0], [2.0, 2.0, 2.0]]); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[0.0, 1.0, 4.0], [9.0, 16.0, 25.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_values_with_odd_power() { + let tensor = QTensor::<2>::int8([[0.0, -1.0, -2.0], [-3.0, -4.0, -4.0]]); + let tensor_pow = QTensor::<2>::int8([[3.0, 3.0, 3.0], [3.0, 3.0, 3.0]]); + + let output = tensor.powf(tensor_pow); + let expected = TensorData::from([[0.0, -1.0, -8.0], [-27.0, -64.0, -64.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf_scalar.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf_scalar.rs new file mode 100644 index 0000000..2a2b665 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/powf_scalar.rs @@ -0,0 +1,56 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_powf_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.powf_scalar(0.71); + let expected = TensorData::from([[0.0, 1.0, 1.6358], [2.182, 2.6759, 3.1352]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_power() { + let tensor = QTensor::<2>::int8([[1.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.powf_scalar(-0.33); + let expected = TensorData::from([[1.0, 1.0, 0.79553646], [0.695905, 0.6328783, 0.58794934]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_values_with_even_power() { + let tensor = QTensor::<2>::int8([[0.0, -1.0, -2.0], [-3.0, -4.0, -5.0]]); + + let output = tensor.powf_scalar(2.0); + let expected = TensorData::from([[0., 1., 4.], [9., 16., 25.]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} + +#[test] +fn should_support_neg_values_with_odd_power() { + let tensor = QTensor::<2>::int8([[0.0, -1.0, -2.0], [-3.0, -4.0, -4.0]]); + + let output = tensor.powf_scalar(3.0); + let expected = TensorData::from([[0.0, -1.0, -8.0], [-27.0, -64.0, -64.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(4e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/recip.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/recip.rs new file mode 100644 index 0000000..b0f6fd2 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/recip.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_recip_ops() { + let tensor = QTensor::<2>::int8([[0.5, 1.0, 2.0], [3.0, -4.0, -5.0]]); + + let output = tensor.recip(); + let expected = TensorData::from([[2.0, 1.0, 0.5], [0.33333, -0.25, -0.2]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/remainder.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/remainder.rs new file mode 100644 index 0000000..741aa64 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/remainder.rs @@ -0,0 +1,208 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_remainder_basic() { + let lhs = QTensor::<1>::int8([-3.0, -2.0, -1.0, 1.0, 2.0, 2.0]); + let rhs = QTensor::<1>::int8([2.0, 3.0, 1.0, 2.0, 1.0, 2.0]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([1., 1., 0., 1., 0., 0.]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[ignore = "quantization remainder with float element is undefined"] +fn should_support_remainder_basic_scalar() { + let tensor = QTensor::<1>::int8([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]); + + let output = tensor.remainder_scalar(2.0); + let expected = TensorData::from([1.0, 0.0, 1.0, 1.0, 0.0, 1.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_remainder_float() { + let lhs = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + let rhs = QTensor::<1>::int8([1.4233, 2.7313, 0.2641, 1.9651, 0.5897]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([1., 2., 0.0949, 0.0698, 0.2824]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_remainder_float_scalar() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + + let output = tensor.remainder_scalar(-1.5); + let expected = TensorData::from([-0.5, -1.0, 0.0, -0.5, -1.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_be_zero() { + let lhs = QTensor::<1>::int8([0.0, 0.0, 0.0]); + let rhs = QTensor::<1>::int8([3.5, -2.1, 1.5]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([0.0, 0.0, 0.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_be_zero_scalar() { + let tensor = QTensor::<1>::int8([0.0, 0.0, 0.0]); + + let output = tensor.remainder_scalar(3.5); + let expected = TensorData::from([0.0, 0.0, 0.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_have_no_remainder() { + let lhs = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + let rhs = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([0.0, 0.0, 0.0, 0.0, 0.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_have_no_remainder_scalar() { + let tensor = QTensor::<1>::int8([4.0, 4.0]); + + let output = tensor.remainder_scalar(4.0); + let expected = TensorData::from([0.0, 0.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_be_negative() { + let lhs = QTensor::<1>::int8([-7.0, -3.0, 2.0, 6.0]); + let rhs = QTensor::<1>::int8([-2.5, -2.1, -1.5, -3.25]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([-2., -0.9, -1., -0.5]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_be_negative_scalar() { + let tensor = QTensor::<1>::int8([-7.0, -3.0, 2.0, 6.0]); + + let output = tensor.remainder_scalar(-2.5); + let expected = TensorData::from([-2.0, -0.50, -0.50, -1.5]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_fp_dividends() { + let tensor = QTensor::<1>::int8([-7.5, -2.5, 2.5, 7.5]); + + let output = tensor.remainder_scalar(3.0); + let expected = TensorData::from([1.5, 0.5, 2.5, 1.5]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_large_divisor() { + let lhs = QTensor::<1>::int8([-1.0, 1.0, -1.5, 1.5, -1.0, 1.0, -1.5, 1.5]); + let rhs = QTensor::<1>::int8([10.0, 10.0, 10.0, 10.0, -10.0, -10.0, -10.0, -10.0]); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([9., 1., 8.5, 1.5, -1., -9., -1.5, -8.5]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_large_divisor_scalar() { + let tensor = QTensor::<1>::int8([-1.0, 1.0]); + + let output = tensor.remainder_scalar(10.0); + let expected = TensorData::from([9.0, 1.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_remainder_op() { + let lhs = QTensor::<1>::int8([-3.0, -2.0, -1.0, 1.0, 2.0, 2.0]); + let rhs = QTensor::<1>::int8([2.0, 3.0, 1.0, 2.0, 1.0, 2.0]); + + let output = lhs % rhs; + let expected = TensorData::from([1., 1., 0., 1., 0., 0.]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[ignore = "quantization remainder with float element is undefined"] +fn should_support_remainder_scalar_op() { + let tensor = QTensor::<1>::int8([-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]); + + let output = tensor % 2.0; + let expected = TensorData::from([1.0, 0.0, 1.0, 1.0, 0.0, 1.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/repeat_dim.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/repeat_dim.rs new file mode 100644 index 0000000..e3e3db5 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/repeat_dim.rs @@ -0,0 +1,42 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_support_repeat_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0, 3.0]]); + + let output = tensor.repeat_dim(0, 4); + let expected = TensorData::from([ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::permissive()); +} + +#[test] +fn should_support_repeat_on_dims_larger_than_1() { + let tensor = QTensor::<1>::int8([ + 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., + ]) + .reshape([4, 2, 2]); + + let output = tensor.repeat_dim(2, 2); + let expected = TensorData::from([ + [[0., 1., 0., 1.], [2., 3., 2., 3.]], + [[4., 5., 4., 5.], [6., 7., 6., 7.]], + [[8., 9., 8., 9.], [10., 11., 10., 11.]], + [[12., 13., 12., 13.], [14., 15., 14., 15.]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-1, 1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/reshape.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/reshape.rs new file mode 100644 index 0000000..528a378 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/reshape.rs @@ -0,0 +1,77 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn should_support_reshape_1d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0, 3.0]]); + + let output = tensor.clone().reshape([1, 4]); + let expected = TensorData::from([[0.0, 1.0, 2.0, 3.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_reshape_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.clone().reshape([6]); + let expected = TensorData::from([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_dim_infererence() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0]) + .reshape([4, 3]); + + // Infer the dimension via -1 + let reshaped = tensor.clone().reshape([2, -1]); + assert_eq!(reshaped.shape(), [2, 6].into()); + + // Infer the dimension via 0 (keep from the source) and -1 (infer) + let reshaped = reshaped.reshape([0, 2, -1]); + assert_eq!(reshaped.shape(), [2, 2, 3].into()); + + // This is effectively as if we did a flatten + let reshaped = tensor.clone().reshape([-1]); + assert_eq!(reshaped.shape(), [12].into()); + + // Keeping the first dimension the same (using 0) + let reshaped = tensor.clone().reshape([0, 3]); + assert_eq!(reshaped.shape(), [4, 3].into()); +} + +#[test] +fn should_not_corrupt_after_slice() { + let zeros = QTensor::<1>::int8([0.0, 0.0]); + zeros.clone().slice([1..2]).reshape([1]).exp(); + + // May lead to zeroes being equal to [0.0, 1.0] + zeros.dequantize().into_data().assert_eq( + &TestTensor::<1>::zeros([2], &Default::default()).to_data(), + true, + ); +} + +#[test] +#[should_panic] +fn multiple_neg_ones() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let _ = tensor.reshape([-1, -1]); +} + +#[test] +#[should_panic] +fn neg_value() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let _ = tensor.reshape([-2, -1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/round.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/round.rs new file mode 100644 index 0000000..25f8539 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/round.rs @@ -0,0 +1,31 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_round_ops() { + let tensor = QTensor::<2>::int8([[24.0423, 87.9478, 76.1838], [59.6929, 43.8169, 94.8826]]); + + let output = tensor.round(); + let expected = TensorData::from([[24., 88., 76.], [60., 44., 95.]]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_round_ties_even() { + // NOTE: round ties to even only affects values that are exact halfway from ceil/floor, so quantization + // errors can impact this. This basically only guarantees the values for the max value in the range since + // it is always represented correctly. + let tensor = QTensor::<1>::int8([5.5]); + + let output = tensor.round(); + let expected = TensorData::from([6.]); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/select.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/select.rs new file mode 100644 index 0000000..92c87ea --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/select.rs @@ -0,0 +1,120 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::IndexingUpdateOp; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_select_1d() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0]); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &Default::default()); + + let output = tensor.select(0, indices); + let expected = TensorData::from([1.0, 1.0, 0.0, 1.0, 2.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_select_2d_dim0_same_num_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_data([1, 0], &Default::default()); + + let output = tensor.select(0, indices); + let expected = TensorData::from([[3.0, 4.0, 5.0], [0.0, 1.0, 2.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_select_2d_dim0_more_num_dim() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_data([1, 0, 1, 1], &Default::default()); + + let output = tensor.select(0, indices); + let expected = TensorData::from([ + [3.0, 4.0, 5.0], + [0.0, 1.0, 2.0], + [3.0, 4.0, 5.0], + [3.0, 4.0, 5.0], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_select_2d_dim1() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &Default::default()); + + let output = tensor.select(1, indices); + let expected = TensorData::from([[1.0, 1.0, 0.0, 1.0, 2.0], [4.0, 4.0, 3.0, 4.0, 5.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_select_assign_1d() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let values = QTensor::<1>::int8([5.0, 4.0, 3.0, 2.0, 1.0]); + let indices = TestTensorInt::from_data(TensorData::from([1, 1, 0, 1, 2]), &Default::default()); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([3.0, 12.0, 3.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_select_assign_2d_dim0() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let values = tensor.clone(); + let indices = TestTensorInt::from_data(TensorData::from([1, 0]), &Default::default()); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([[3.0, 5.0, 7.0], [3.0, 5.0, 7.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_select_assign_2d_dim1() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let values = tensor.clone(); + let indices = TestTensorInt::from_data(TensorData::from([1, 0, 2]), &Default::default()); + + let output = tensor.select_assign(1, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([[1.0, 1.0, 4.0], [7.0, 7.0, 10.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[should_panic] +fn should_select_panic_invalid_dimension() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &Default::default()); + + tensor.select(10, indices); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sin.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sin.rs new file mode 100644 index 0000000..5bae654 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sin.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_sin_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sin(); + let expected = TensorData::from([[0.0, 0.8414, 0.9092], [0.1411, -0.7568, -0.9589]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sinh.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sinh.rs new file mode 100644 index 0000000..da7e6c1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sinh.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_sinh_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sinh(); + let expected = TensorData::from([[0.0000, 1.1752, 3.6269], [10.0179, 27.2899, 74.2032]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(3e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/slice.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/slice.rs new file mode 100644 index 0000000..ecbe14c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/slice.rs @@ -0,0 +1,229 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::{Tolerance, s}; + +#[test] +fn should_support_full_sliceing_1d() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0]); + let data = tensor.to_data(); + + let output = tensor.slice([0..4]); + + output.into_data().assert_eq(&data, false); +} + +#[test] +fn should_support_partial_sliceing_1d() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0]); + + let output = tensor.slice([1..3]); + let expected = TensorData::from([1.0, 2.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_full_sliceing_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data = tensor.to_data(); + + let output = tensor.clone().slice([0..2]); + output.into_data().assert_eq(&data, true); + + let output = tensor.slice([0..2, 0..3]); + output.into_data().assert_eq(&data, true); +} + +#[test] +fn should_support_partial_sliceing_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.slice([0..2, 0..2]); + let expected = TensorData::from([[0.0, 1.0], [3.0, 4.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_partial_sliceing_3d() { + let tensor = QTensor::<3>::int8([ + [[0., 1., 2., 3.], [4., 5., 6., 7.]], + [[8., 9., 10., 11.], [12., 13., 14., 15.]], + ]); + + let output = tensor.slice([1..2, 1..2, 0..2]); + let expected = TensorData::from([[[12.0, 13.0]]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_partial_sliceing_3d_non_contiguous() { + let tensor = QTensor::<3>::int8([ + [[0., 1., 2., 3.], [4., 5., 6., 7.]], + [[8., 9., 10., 11.], [12., 13., 14., 15.]], + ]); + + let output = tensor.transpose().slice([1..2, 1..2, 0..2]); + let expected = TensorData::from([[[9.0, 13.0]]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn should_support_slice_assign_1d() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let tensor_assigned = QTensor::<1>::int8([10.0, 5.0]); + + let output = tensor.slice_assign([0..2], tensor_assigned); + let expected = TensorData::from([10.0, 5.0, 2.0]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_slice_assign_2d() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_assigned = QTensor::<2>::int8([[10.0, 5.0]]); + + let output = tensor.slice_assign([1..2, 0..2], tensor_assigned); + let expected = TensorData::from([[0.0, 1.0, 2.0], [10.0, 5.0, 5.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn slice_should_not_corrupt_potentially_inplace_operations() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + let tensor = tensor.clone().slice([0..3]) + tensor.clone().slice([2..5]); + + let expected = TensorData::from([4., 6., 8.]); + + tensor + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn slice_assign_should_not_corrupt_potentially_inplace_operations() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + let values = QTensor::<1>::int8([10., 20., 30.]); + + let tensor_1 = tensor.clone().slice_assign([0..3], values); + let tensor_2 = tensor + 2; + + let expected = TensorData::from([10., 20., 30., 4., 5.]); + + tensor_1 + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); + + let expected = TensorData::from([3., 4., 5., 6., 7.]); + + tensor_2 + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn clamp_when_slice_exceeds_dimension() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + let data = tensor.to_data(); + + let output = tensor.slice([0..4]); + output.into_data().assert_eq(&data, true); +} + +#[test] +fn negative_dimensions() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data = tensor.to_data(); + + // Clamping to the tensor dimensions + let output = tensor.clone().slice([0..4, 0..4]); + output.into_data().assert_eq(&data, true); + + // Negative dimensions + let output = tensor.clone().slice([0..1, 0..1]); + let data = TensorData::from([[0.0f32]]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); + + let output = tensor.slice(s![0..-1, 0..-2]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +fn missing_dimensions() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let data = tensor.to_data(); + + // Clamping to the tensor dimensions + let output = tensor.clone().slice([0..4, 0..4]); + output.into_data().assert_eq(&data, true); + + // Negative dimensions + let data = TensorData::from([[0.0f32]]); + let output = tensor.clone().slice(s![0..-1, 0..-2]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); + + // Missing dimensions + let output = tensor.clone().slice(s![0..1, ..]); + let data = TensorData::from([[0.0f32, 1.0, 2.0]]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); + + let output = tensor.clone().slice(s![.., 0..2]); + let data = TensorData::from([[0.0f32, 1.0], [3.0, 4.0]]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); + + let output = tensor.clone().slice([.., ..]); + let data = TensorData::from([[0.0f32, 1.0, 2.0], [3.0, 4.0, 5.0]]); + output + .dequantize() + .into_data() + .assert_approx_eq::(&data, Tolerance::rel_abs(2e-2, 1e-2)); +} + +#[test] +#[should_panic] +fn should_panic_when_slice_with_too_many_dimensions() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0]); + + let _output = tensor.slice([0..1, 0..1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sort_argsort.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sort_argsort.rs new file mode 100644 index 0000000..421fa8e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sort_argsort.rs @@ -0,0 +1,225 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_sort_1d_float() { + // Quantized [0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 5.2, 4., 0.99, 3., -8.1] + let tensor = QTensor::<1>::int8([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 5.2, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let values = tensor.sort(0); + + let values_expected = TensorData::from([ + -8.1, -0.3, -0.21, 0., 0.5, 0.94, 0.99, 1.2, 2.1, 2.3, 3., 4., 5.2, + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_argsort_1d_float() { + let tensor = QTensor::<1>::int8([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 5.2, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let indices = tensor.argsort(0); + + let indices_expected = TensorData::from([12, 6, 2, 3, 0, 5, 10, 1, 4, 7, 11, 9, 8]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_with_indices_descending_float() { + // 1D + let tensor = QTensor::<1>::int8([ + 0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 5.2, 4., 0.99, 3., -8.1, + ]); + + // Sort along dim=0 + let (values, indices) = tensor.sort_descending_with_indices(0); + + let values_expected = TensorData::from([ + 5.2, 4., 3., 2.3, 2.1, 1.2, 0.99, 0.94, 0.5, 0., -0.21, -0.3, -8.1, + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([8, 9, 11, 7, 4, 1, 10, 5, 0, 3, 2, 6, 12]); + indices.into_data().assert_eq(&indices_expected, false); + + // 3D + // Quantized [-0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 4., 0.99, 3., -8.1] + let tensor = QTensor::<1>::int8([ + -0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 4., 0.99, 3., -8.1, + ]) + .reshape([2, 2, 3]); + + // Sort along dim=1 + let (values, indices) = tensor.sort_descending_with_indices(1); + + let values_expected = TensorData::from([ + [[0., 2.1, 0.94], [-0.5, 1.2, -0.21]], + [[0.99, 3., 4.], [-0.3, 2.3, -8.1]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([[[1, 1, 1], [0, 0, 0]], [[1, 1, 0], [0, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_float() { + let tensor = QTensor::<1>::int8([ + -0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 4., 0.99, 3., -8.1, + ]) + .reshape([2, 2, 3]); + + // Sort along dim=0 + let values = tensor.clone().sort(0); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, -8.1]], + [[-0.3, 2.3, 4.], [0.99, 3., 0.94]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + // Sort along dim=1 + let values = tensor.clone().sort(1); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, -8.1], [0.99, 3., 4.]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + // Sort along dim=2 + let values = tensor.sort(2); + + let values_expected = TensorData::from([ + [[-0.5, -0.21, 1.2], [0., 0.94, 2.1]], + [[-0.3, 2.3, 4.], [-8.1, 0.99, 3.]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_sort_with_indices_float() { + let tensor = QTensor::<1>::int8([ + -0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 4., 0.99, 3., -8.1, + ]) + .reshape([2, 2, 3]); + + // Sort along dim=0 + let (values, indices) = tensor.clone().sort_with_indices(0); + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, -8.1]], + [[-0.3, 2.3, 4.], [0.99, 3., 0.94]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([[[0, 0, 0], [0, 0, 1]], [[1, 1, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let (values, indices) = tensor.clone().sort_with_indices(1); + + let values_expected = TensorData::from([ + [[-0.5, 1.2, -0.21], [0., 2.1, 0.94]], + [[-0.3, 2.3, -8.1], [0.99, 3., 4.]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([[[0, 0, 0], [1, 1, 1]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let (values, indices) = tensor.sort_with_indices(2); + + let values_expected = TensorData::from([ + [[-0.5, -0.21, 1.2], [0., 0.94, 2.1]], + [[-0.3, 2.3, 4.], [-8.1, 0.99, 3.]], + ]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([[[0, 2, 1], [0, 2, 1]], [[0, 1, 2], [2, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_argsort_float() { + let tensor = QTensor::<1>::int8([ + -0.5, 1.2, -0.21, 0., 2.1, 0.94, -0.3, 2.3, 4., 0.99, 3., -8.1, + ]) + .reshape([2, 2, 3]); + + // Sort along dim=0 + let indices = tensor.clone().argsort(0); + + let indices_expected = TensorData::from([[[0, 0, 0], [0, 0, 1]], [[1, 1, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let indices = tensor.clone().argsort(1); + + let indices_expected = TensorData::from([[[0, 0, 0], [1, 1, 1]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let indices = tensor.argsort(2); + + let indices_expected = TensorData::from([[[0, 2, 1], [0, 2, 1]], [[0, 1, 2], [2, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_descending_1d() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + + // Sort along dim=0 + let values = tensor.sort_descending(0); + + let values_expected = TensorData::from([5., 4., 3., 2., 1.]); + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/split.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/split.rs new file mode 100644 index 0000000..033990b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/split.rs @@ -0,0 +1,151 @@ +use super::qtensor::*; +use super::*; +use alloc::vec; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_split_evenly_divisible() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let tensors = tensor.split(2, 0); + assert_eq!(tensors.len(), 3); + + let expected = [ + TensorData::from([0., 1.]), + TensorData::from([2., 3.]), + TensorData::from([4., 5.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_split_not_evenly_divisible() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + + let tensors = tensor.split(2, 0); + assert_eq!(tensors.len(), 4); + + let expected = [ + TensorData::from([0., 1.]), + TensorData::from([2., 3.]), + TensorData::from([4., 5.]), + TensorData::from([6.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_split_along_dim1() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let tensors = tensor.split(2, 1); + assert_eq!(tensors.len(), 2); + + let expected = [ + TensorData::from([[0., 1.], [3., 4.]]), + TensorData::from([[2.], [5.]]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +fn test_split_split_size_larger_than_tensor_size() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let tensors = tensor.split(10, 0); + assert_eq!(tensors.len(), 1); + + let expected = [TensorData::from([0.0, 1.0, 2.0, 3.0, 4.0, 5.0])]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +#[should_panic( + expected = "split_size must be greater than 0 unless the tensor size along the dimension is 0." +)] +fn test_split_with_zero_split_size_non_zero_tensor() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let _ = tensor.split(0, 0); +} + +#[test] +#[should_panic(expected = "Given dimension is greater than or equal to the tensor rank.")] +fn test_split_invalid_dim() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let _ = tensor.split(1, 2); +} + +#[test] +fn test_split_with_sizes() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let tensors = tensor.split_with_sizes(vec![2, 3, 1], 0); + assert_eq!(tensors.len(), 3); + + let expected = [ + TensorData::from([0., 1.]), + TensorData::from([2., 3., 4.]), + TensorData::from([5.]), + ]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} + +#[test] +#[should_panic( + expected = "The sum of split_sizes must equal the tensor size along the specified dimension." +)] +fn test_split_with_sizes_invalid_sum() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + + let _ = tensor.split_with_sizes(vec![2, 2, 1], 0); +} + +#[test] +fn test_split_with_sizes_zero_length() { + let tensor = QTensor::<1>::int8([0.0, 2.0, 5.0]); + + let tensors = tensor.split_with_sizes(vec![0, 1, 2], 0); + assert_eq!(tensors.len(), 2); + + let expected = [TensorData::from([0.]), TensorData::from([2., 5.])]; + + for (index, tensor) in tensors.into_iter().enumerate() { + tensor + .dequantize() + .to_data() + .assert_approx_eq::(&expected[index], Tolerance::absolute(1e-1)); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sqrt.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sqrt.rs new file mode 100644 index 0000000..9e6fc32 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sqrt.rs @@ -0,0 +1,18 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; +use core::f32::consts::SQRT_2; + +#[test] +fn should_support_sqrt_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.sqrt(); + let expected = TensorData::from([[0.0, 1.0, SQRT_2], [1.73205, 2.0, 2.2360]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/stack.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/stack.rs new file mode 100644 index 0000000..ee96087 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/stack.rs @@ -0,0 +1,69 @@ +use super::qtensor::*; +use super::*; +use alloc::vec; +use burn_tensor::Tensor; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_stack_ops_2d_dim0() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0, 6.0]]); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_stack_ops_2d_dim1() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0, 6.0]]); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 1); + let expected = TensorData::from([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_stack_ops_3d() { + let tensor_1 = QTensor::<3>::int8([[[1.0, 2.0, 3.0]], [[3.0, 2.0, 1.0]]]); + let tensor_2 = QTensor::<3>::int8([[[4.0, 5.0, 6.0]], [[6.0, 5.0, 4.0]]]); + + let output = Tensor::stack::<4>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([ + [[[1.0, 2.0, 3.0]], [[3.0, 2.0, 1.0]]], + [[[4.0, 5.0, 6.0]], [[6.0, 5.0, 4.0]]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[should_panic] +fn should_panic_when_dimensions_are_not_the_same() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 5.0]]); + + let _output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); +} + +#[test] +#[should_panic] +fn should_panic_when_stack_exceeds_dimension() { + let tensor_1 = QTensor::<3>::int8([[[1.0, 2.0, 3.0]], [[3.0, 2.0, 1.0]]]); + let tensor_2 = QTensor::<3>::int8([[[4.0, 5.0, 6.0]]]); + + let _output = Tensor::stack::<4>(vec![tensor_1, tensor_2], 3); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sub.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sub.rs new file mode 100644 index 0000000..1e516ca --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/sub.rs @@ -0,0 +1,46 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_sub_ops() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let tensor_2 = QTensor::<2>::int8([[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]]); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-6.0, -6.0, -6.0], [-6.0, -6.0, -6.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_sub_broadcast() { + let tensor_1 = QTensor::<2>::int8([[0.0, 1.0, 2.0]]); + let tensor_2 = QTensor::<2>::int8([[3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-3.0, -3.0, -3.0], [-6.0, -6.0, -6.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_sub_scalar_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + let scalar = 2.0; + + let output = tensor - scalar; + let expected = TensorData::from([[-2.0, -1.0, 0.0], [1.0, 2.0, 3.0]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(2e-2, 1e-2)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tan.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tan.rs new file mode 100644 index 0000000..7b0ebc0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tan.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_tan_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.tan(); + let expected = TensorData::from([[0.0, 1.5574, -2.1850], [-0.1425, 1.1578, -3.3805]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tanh.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tanh.rs new file mode 100644 index 0000000..b3f727d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/tanh.rs @@ -0,0 +1,17 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_tanh_ops() { + let tensor = QTensor::<2>::int8([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]); + + let output = tensor.tanh(); + let expected = TensorData::from([[0.0, 0.7615, 0.9640], [0.9950, 0.9993, 0.9999]]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/topk.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/topk.rs new file mode 100644 index 0000000..0435baf --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/topk.rs @@ -0,0 +1,68 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +#[ignore] +fn test_topk_1d() { + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + + let values = tensor.topk(3, /*dim*/ 0); + let expected = TensorData::from([5., 4., 3.]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +#[ignore] +fn test_topk() { + let tensor = QTensor::<3>::int8([[[1., 4., 7.], [2., 5., 6.]], [[3., 0., 9.], [8., 2., 7.]]]); + + let values = tensor.topk(2, /*dim*/ 2); + let expected = TensorData::from([[[7., 4.], [6., 5.]], [[9., 3.], [8., 7.]]]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn test_topk_with_indices() { + // 1D + let tensor = QTensor::<1>::int8([1.0, 2.0, 3.0, 4.0, 5.0]); + + let (values, indices) = tensor.topk_with_indices(3, /*dim*/ 0); + + let values_expected = TensorData::from([5., 4., 3.]); + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::permissive()); + + let indices_expected = TensorData::from([4, 3, 2]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_topk_with_indices_3d() { + // 3D + let tensor = QTensor::<3>::int8([[[1., 4., 7.], [2., 5., 6.]], [[3., 0., 9.], [8., 2., 7.]]]); + + let (values, indices) = tensor.topk_with_indices(2, /*dim*/ 2); + + let values_expected = TensorData::from([[[7., 4.], [6., 5.]], [[9., 3.], [8., 7.]]]); + + values + .dequantize() + .into_data() + .assert_approx_eq::(&values_expected, Tolerance::absolute(1e-1)); + + let indices_expected = TensorData::from([[[2, 1], [2, 1]], [[2, 0], [0, 2]]]); + + indices.into_data().assert_eq(&indices_expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/transpose.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/transpose.rs new file mode 100644 index 0000000..4ede156 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/transpose.rs @@ -0,0 +1,39 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn should_support_transpose_ops() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0]) + .reshape([2, 2, 3]); + + let output = tensor.transpose(); + let expected = TensorData::from([ + [[0.0, 3.0], [1.0, 4.0], [2.0, 5.0]], + [[6.0, 9.0], [7.0, 10.0], [8.0, 11.0]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} + +#[test] +fn should_support_swap_dims() { + let tensor = QTensor::<1>::int8([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0]) + .reshape([2, 2, 3]); + + let output = tensor.swap_dims(0, 2); + let expected = TensorData::from([ + [[0.0, 6.0], [3.0, 9.0]], + [[1.0, 7.0], [4.0, 10.0]], + [[2.0, 8.0], [5.0, 11.0]], + ]); + + output + .dequantize() + .into_data() + .assert_approx_eq::(&expected, Tolerance::absolute(1e-1)); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/matmul.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/matmul.rs new file mode 100644 index 0000000..ba15b4d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/matmul.rs @@ -0,0 +1,248 @@ +use super::qtensor::*; +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +#[ignore] +fn test_matmul_vectors() { + let tensor_1 = QTensor::<2>::int8([[1.0, 2.0, 3.0, 6.35]]); + let tensor_2 = QTensor::<2>::int8([[12.7], [4.0], [5.0], [1.0]]); + + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([[42.05]]); + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +#[ignore] +fn test_matmul_2d() { + let tensor_1 = QTensor::<2>::int8([[1.0, 6.35], [2.0, 3.0], [1.0, 3.0]]); + let tensor_2 = QTensor::<2>::int8([[4.0, 8.0, 12.7], [2.0, 3.0, 6.0]]); + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([[16.7, 27.05, 50.8], [14., 25., 43.4], [10., 17., 30.7]]); + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +fn test_matmul_2d_aligned() { + let tensor_1 = QTensor::<2>::int8([ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]); + let tensor_2 = QTensor::<2>::int8([ + [2.0, 0.0, 1.0, 0.0], + [1.0, 2.0, 0.0, 0.0], + [0.0, 1.0, 2.0, 0.0], + [1.0, 0.0, 0.0, 1.0], + ]); + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([ + [8.0, 7.0, 7.0, 4.0], + [24.0, 19.0, 19.0, 8.0], + [40.0, 31.0, 31.0, 12.0], + ]); + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +fn test_matmul_2d_aligned_fused() { + let tensor_1 = QTensor::<2>::int8([ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]); + let tensor_2 = QTensor::<2>::int8([ + [2.0, 0.0, 1.0, 0.0], + [1.0, 2.0, 0.0, 0.0], + [0.0, 1.0, 2.0, 0.0], + [1.0, 0.0, 0.0, 1.0], + ]); + let tensor_3 = tensor_1.matmul(tensor_2); + let tensor_4 = tensor_3 / 2.0; + + let expected = TensorData::from([ + [4.0, 3.5, 3.5, 2.0], + [12.0, 9.5, 9.5, 4.0], + [20.0, 15.5, 15.5, 6.0], + ]); + tensor_4 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +#[ignore] +fn test_matmul_3d() { + let tensor_1 = QTensor::<3>::int8([[[1.0, 6.35], [2.0, 3.0]]]); + let tensor_2 = QTensor::<3>::int8([[[12.7, 4.0], [2.0, 3.0]]]); + + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([[[25.4, 23.05], [31.4, 17.0]]]); + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +#[ignore] +fn test_matmul_broadcast_4d() { + let tensor_1 = QTensor::<4>::int8([[[[1.0, 7.0], [2.0, 3.0]]], [[[2.0, 5.0], [6.0, 3.0]]]]); + let tensor_2 = QTensor::<4>::int8([[[[9.0, 8.0], [1.0, 4.0]], [[2.0, 7.0], [3.0, 5.0]]]]); + + // [2, 1, 2, 2] @ [1, 2, 2, 2] -> [2, 2, 2, 2] + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [[[16.0, 36.0], [21.0, 28.0]], [[23.0, 42.0], [13.0, 29.0]]], + [[[23.0, 36.0], [57.0, 60.0]], [[19.0, 39.0], [21.0, 57.0]]], + ]); + + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +#[ignore] +fn test_matmul_broadcast() { + let tensor_1 = QTensor::<3>::int8([[[1.0, 7.0], [2.0, 3.0]]]); + let tensor_2 = QTensor::<3>::int8([[[4.0, 7.0], [2.0, 3.0]], [[2.0, 5.0], [6.0, 3.0]]]); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[[18.0, 28.0], [14.0, 23.0]], [[44.0, 26.0], [22.0, 19.0]]]); + + tensor_3 + .into_data() + .assert_approx_eq::(&expected, Tolerance::relative(2e-2)); +} + +#[test] +#[should_panic] +fn should_panic_when_inner_dimensions_are_not_equal() { + let tensor_1 = QTensor::<2>::int8([[3., 3.], [4., 4.], [5., 5.], [6., 6.]]); + let tensor_2 = QTensor::<2>::int8([[1., 2., 3., 4.], [1., 2., 3., 4.], [1., 2., 3., 4.]]); + + let _ = tensor_1.matmul(tensor_2); +} + +#[test] +fn test_matmul_lhs_float_rhs_quantized() { + // Simulates a typical workflow with linear layers (e.g., transformers), where the rhs + // represents the weights. The lhs might be a float if a previous operation did not propagate + // the quantization. We still want to perform an efficient matmul with quantized weights. + let tensor_1 = TestTensor::<2>::from([ + [1.0, 6.35, 2.0, 3.0], + [2.0, 3.0, 4.0, 5.0], + [1.0, 3.0, 5.0, 7.0], + ]); + let tensor_2 = QTensor::<2>::int8([ + [4.0, 8.0, 12.7, 1.6], + [2.0, 3.0, 6.0, 4.0], + [1.0, 5.0, 9.0, 2.5], + [3.0, 7.0, 11.0, 0.5], + ]); + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([ + [27.7, 58.05, 101.8, 33.5], + [33., 80., 134.4, 27.7], + [36., 91., 152.7, 29.6], + ]); + let output = tensor_3.into_data(); + output.assert_approx_eq::(&expected, Tolerance::default()); + + // Default quantization scheme does not propagate quantization with matmul + assert!(output.dtype.is_float()); +} + +#[test] +fn test_matmul_lhs_quantized_rhs_float() { + // Mirror of test_matmul_lhs_float_rhs_quantized: pins the symmetric + // (QFloat, Float) arm of the q_matmul dispatch so a future split of the + // collapsed match arm cannot regress one direction silently. Uses the + // relative tolerance shared by the other quantized matmul tests in this + // file because the lhs dynamic range here is smaller than the rhs in the + // sibling test, so int8 noise dominates the result more. + let tensor_1 = QTensor::<2>::int8([ + [1.0, 6.35, 2.0, 3.0], + [2.0, 3.0, 4.0, 5.0], + [1.0, 3.0, 5.0, 7.0], + ]); + let tensor_2 = TestTensor::<2>::from([ + [4.0, 8.0, 12.7, 1.6], + [2.0, 3.0, 6.0, 4.0], + [1.0, 5.0, 9.0, 2.5], + [3.0, 7.0, 11.0, 0.5], + ]); + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([ + [27.7, 58.05, 101.8, 33.5], + [33., 80., 134.4, 27.7], + [36., 91., 152.7, 29.6], + ]); + let output = tensor_3.into_data(); + output.assert_approx_eq::(&expected, Tolerance::relative(2e-2)); + + // Default quantization scheme does not propagate quantization with matmul + assert!(output.dtype.is_float()); +} + +#[test] +fn test_matmul_mixed_block_scale() { + let tensor_1 = TestTensor::<2>::from([ + [1.0, 6.35, 2.0, 3.0], + [2.0, 3.0, 4.0, 5.0], + [1.0, 3.0, 5.0, 7.0], + ]); + let tensor_2 = QTensor::<2>::int8_block([ + [ + 6.110, 4.0, 9.360, 7.850, 0.630, 1.770, 0.430, 7.550, 9.690, 3.560, 2.920, 9.130, + 3.390, 0.510, 1.620, 1.460, + ], + [ + 6.140, 8.260, 5.660, 5.610, 7.070, 3.050, 9.890, 5.520, 1.350, 3.810, 5.630, 0.250, + 0.350, 8.860, 3.610, 6.240, + ], + [ + 8.810, 4.620, 7.420, 8.110, 2.560, 4.710, 5.730, 8.980, 1.170, 6.090, 4.140, 3.610, + 4.960, 9.720, 5.710, 1.470, + ], + [ + 2.260, 9.640, 6.320, 6.980, 9.860, 1.030, 8.340, 1.570, 4.140, 4.760, 4.590, 6.400, + 5.350, 1.430, 4.960, 1.180, + ], + ]); + let tensor_3 = tensor_1.matmul(tensor_2); + + let expected = TensorData::from([ + [ + 69.499, 94.611, 79.101, 80.633, 80.225, 33.647, 99.711, 65.272, 33.022, 54.213, 60.721, + 37.138, 31.582, 80.501, 50.843, 47.564, + ], + [ + 77.180, 99.460, 96.980, 99.870, 82.010, 36.680, 95.150, 75.430, 48.810, 66.710, 62.240, + 65.450, 54.420, 73.630, 61.710, 33.420, + ], + [ + 84.400, 119.360, 107.680, 114.090, 103.660, 41.680, 117.130, 80.0, 48.570, 78.760, + 72.640, 72.730, 66.690, 85.700, 75.720, 35.790, + ], + ]); + let output = tensor_3.into_data(); + output.assert_approx_eq::(&expected, Tolerance::permissive()); + + // Default quantization scheme does not propagate quantization with matmul + assert!(output.dtype.is_float()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/mod.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/mod.rs new file mode 100644 index 0000000..596c0b6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/mod.rs @@ -0,0 +1,31 @@ +pub use super::*; + +mod matmul; +mod quantize; + +// The `extended` suite is only enabled for backends with native (non-packed) quantized +// storage AND a complete set of quantized ops. Today that means ndarray and flex. +// +// - cube backends are excluded: PackedU32 storage requires the last dim to be a multiple of +// the pack factor (4 int8s per u32), which most of these test shapes violate, so the +// quantized tensors can't even be constructed (`q_from_data` panics with "Can't store in u32"). +// +// - candle, router, tch and autodiff are excluded too. We dropped their `unimplemented!()` +// overrides for `q_gather`/`q_select`/`q_slice`/`q_expand` so they fall back to the default +// `dequantize -> float op -> quantize` path, but that does NOT make them functional: their +// other quantized methods are largely unimplemented. The quantization primitives themselves +// (`q_from_data`, `quantize`, `dequantize`, ...) are still `unimplemented!()`/`todo!()`, so the +// fallback simply moves the panic into `dequantize`. Running `extended` against any of these +// backends would fail. (They also don't enable the `quantization` feature, so they aren't +// selected here in the first place.) +// +// Enabling `flex` here means the `extended` suite now also runs under the `tensor_f16` target. +// That f16 path is why a couple of `maxmin` tests use a slightly +// looser tolerance: reductions like `min_dim` re-quantize their output, so a value is rounded to +// int8 twice (input quantization, then re-quantization of the reduced result). For small-magnitude +// values this accumulated rounding lands a hair over the tight `rel_abs(2e-2, 1e-2)` bound at f16 +// (e.g. `1.0` -> ~0.97998, rel error 2.00e-2), whereas f32's finer scale representation keeps the +// same value just inside it (~0.9802, rel error 1.98e-2). It is quantization noise, not a logic +// error, so those cases use `rel_abs(2e-2, 3e-2)`. +#[cfg(any(feature = "ndarray", feature = "flex"))] +mod extended; diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/ops/quantize.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/quantize.rs new file mode 100644 index 0000000..d930c07 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/ops/quantize.rs @@ -0,0 +1,255 @@ +use super::*; +use alloc::{vec, vec::Vec}; +use burn_tensor::Tolerance; +use burn_tensor::quantization::{ + QParams, QuantLevel, QuantScheme, QuantStore, QuantValue, QuantizationParameters, + QuantizedBytes, +}; +use burn_tensor::{DType, Element, TensorData}; + +fn get_q_params(data: TensorData) -> QParams> { + let num_elements = data.num_elements(); + let scheme = if let DType::QFloat(scheme) = data.dtype { + scheme + } else { + unreachable!() + }; + let q_bytes = QuantizedBytes { + bytes: data.into_bytes(), + scheme, + num_elements, + }; + q_bytes.into_vec_i8().1 +} + +#[test] +fn should_support_quantize_symmetric_int8() { + // Strict equality was based on full precision + if !matches!(FloatElem::dtype(), DType::F32) { + return; + } + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([-1.8, -1.0, 0.0, 0.5], &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + let qparams = QuantizationParameters { + scales: TestTensor::from_data([0.014_173_228], &device), + }; + + let x_q = tensor.clone().quantize(&scheme, qparams); + + let x_q_data = x_q.to_data(); + let expected = TensorData::quantized( + vec![-127i8, -71, 0, 35], + [4], + scheme.with_store(QuantStore::Native), + &[0.014_173_228], // scale + ); + + // Values equality + x_q_data.assert_eq(&expected, false); + + // Quantization parameters check + let qparams = get_q_params(x_q_data); + let expected = get_q_params(expected); + assert_eq!(qparams.scales.len(), 1); + // TODO: check scales + assert_eq!(qparams.scales, expected.scales); + + // Dequantize + let x = x_q.dequantize(); + + x.into_data() + .assert_approx_eq::(&tensor.into_data(), Tolerance::rel_abs(1e-1, 1e-2)); +} + +#[test] +fn should_support_quantize_dynamic_int8() { + let device = Default::default(); + // NOTE: we use fully representable values since different backend implementations could differ slightly + // due to rounding discrepancies + let tensor = TestTensor::<1>::from_data([5., 0., 4., -12.7], &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let x_q = tensor.quantize_dynamic(&scheme); + + let expected = TensorData::quantized( + vec![50i8, 0, 40, -127], + [4], + scheme.with_store(QuantStore::Native), + &[0.1], // scale + ); + + x_q.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_quantize_dequantize_symmetric_single_with_transform() { + let device = Default::default(); + let input = TestTensorInt::<1>::arange(0..32, &device).float(); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let quant = input.quantize_dynamic(&scheme); + let result = quant * 10; + + let data = result.into_data(); + let expected = [ + 0.0, 9.76378, 19.52756, 29.29134, 39.05512, 48.818897, 61.02362, 70.7874, 80.551186, + 90.31496, 100.07874, 109.84252, 119.60631, 129.37009, 139.13387, 148.89764, 161.10237, + 170.86615, 180.62991, 190.39369, 200.15749, 209.92126, 219.68504, 229.44882, 239.21262, + 248.97638, 261.1811, 270.9449, 280.70865, 290.47244, 300.23624, 310.0, + ]; + data.assert_approx_eq::(&TensorData::from(expected), Tolerance::permissive()); +} + +#[test] +fn should_quantize_dequantize_symmetric_arange_16x16() { + let device = Default::default(); + + let input: TestTensor<2> = TestTensorInt::arange(0..256, &device) + .float() + .div_scalar(256.) + .reshape([16, 16]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + let output = input.clone().quantize_dynamic(&scheme); + let output = output.dequantize(); + + output.into_data().assert_approx_eq::( + &input.into_data(), + Tolerance::absolute(1e-1).set_relative(1e-2), + ); +} + +#[test] +fn should_quantize_dequantize_symmetric_per_block_arange_16x16() { + let device = Default::default(); + + let input: TestTensor<2> = TestTensorInt::arange(0..256, &device) + .float() + .div_scalar(256.) + .reshape([16, 16]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([2, 16])); + + let output = input.clone().quantize_dynamic(&scheme); + let output = output.dequantize(); + + output.into_data().assert_approx_eq::( + &input.into_data(), + Tolerance::absolute(1e-1).set_relative(1e-2), + ); +} + +fn should_quantize_transposed(tensor: Tensor, scheme: QuantScheme) { + let tensor_t = tensor.clone().transpose(); + + let output = tensor_t.quantize_dynamic(&scheme).dequantize().transpose(); + + tensor.into_data().assert_approx_eq::( + &output.into_data(), + Tolerance::absolute(1e-1).set_relative(1e-2), + ); +} + +#[test] +fn should_quantize_symmetric_int8_transposed_8x32() { + let device = Default::default(); + + let tensor = TestTensorInt::arange(0..256, &device) + .float() + .div_scalar(256.) + .reshape([8, 32]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + should_quantize_transposed(tensor, scheme); +} + +#[test] +fn should_quantize_symmetric_int8_transposed_48x64() { + let device = Default::default(); + + let tensor = TestTensorInt::arange(0..3072, &device) + .float() + .div_scalar(3072.) + .reshape([48, 64]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + should_quantize_transposed(tensor, scheme); +} + +#[test] +fn should_quantize_symmetric_per_block_int8_transposed_32x64() { + let device = Default::default(); + + let tensor = TestTensorInt::arange(0..2048, &device) + .float() + .div_scalar(2048.) + .reshape([32, 64]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([32])); + should_quantize_transposed(tensor, scheme); +} + +#[test] +fn should_quantize_symmetric_int8_permuted_batch_dims() { + let device = Default::default(); + + let tensor = TestTensorInt::arange(0..2048, &device) + .float() + .div_scalar(2048.) + .reshape([2, 4, 8, 32]); + + // Permute [0,1,2,3] -> [1,2,0,3] + // This rearranges batch dims but keeps packed dim in place + let tensor_permuted = tensor.clone().permute([1, 2, 0, 3]); + + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let output = tensor_permuted + .quantize_dynamic(&scheme) + .dequantize() + .permute([2, 0, 1, 3]); // reverse permutation + + tensor.into_data().assert_approx_eq::( + &output.into_data(), + Tolerance::absolute(1e-1).set_relative(1e-2), + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/quantization/scheme.rs b/crates/burn-backend-tests/tests/tensor/float/quantization/scheme.rs new file mode 100644 index 0000000..f975afa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/quantization/scheme.rs @@ -0,0 +1,81 @@ +use super::*; +use burn_tensor::Tolerance; +use burn_tensor::{ + Device, Element, TensorData, + quantization::{CalibrationRange, QuantLevel, QuantValue, compute_q_params}, +}; + +#[test] +fn per_tensor_symmetric_int8() { + let device = Default::default(); + let range = CalibrationRange { + min: TestTensor::<1>::from_data([0.5], &device), + max: TestTensor::<1>::from_data([1.8], &device), + }; + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let qparams = compute_q_params(&scheme, range); + + qparams + .scales + .into_data() + .assert_approx_eq::(&TensorData::from([0.014_173_23]), Tolerance::default()); +} + +#[test] +fn per_block_symmetric_int8() { + let device = Default::default(); + let range = CalibrationRange { + min: TestTensor::<1>::from_data([-1.8, -0.5, 0.01, -0.04], &device), + max: TestTensor::<1>::from_data([0.5, 1.8, 0.04, -0.01], &device), + }; + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([4])); + + let qparams = compute_q_params(&scheme, range); + + qparams.scales.into_data().assert_approx_eq::( + &TensorData::from([0.014_173_23, 0.014_173_23, 0.000_314_96, 0.000_314_96]), + Tolerance::default(), + ); +} + +#[test] +fn quant_scheme_should_inhibit_by_default() { + let device = Device::default(); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S); + + let tensor_1 = TestTensor::<2>::from_data( + [[1.0, 6.35, 0., 0.], [2.0, 3.0, 0., 0.], [1.0, 3.0, 0., 0.]], + &device, + ) + .quantize_dynamic(&scheme); + let _tensor_2 = TestTensor::<2>::from_data( + [ + [4.0, 8.0, 12.7, 0.], + [2.0, 3.0, 6.0, 0.], + [0., 0., 0., 0.], + [0., 0., 0., 0.], + ], + &device, + ) + .quantize_dynamic(&scheme); + + // let tensor_3 = tensor_1.clone().matmul(tensor_2); + // assert_eq!(tensor_3.to_data().dtype, FloatElem::dtype()); + + let tensor_4 = tensor_1.add_scalar(1.); + assert_eq!(tensor_4.to_data().dtype, FloatElem::dtype()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/cov.rs b/crates/burn-backend-tests/tests/tensor/float/stats/cov.rs new file mode 100644 index 0000000..e0b4075 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/cov.rs @@ -0,0 +1,66 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn test_cov_1() { + let data = TensorData::from([[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.cov(1, 1); + let expected = + TensorData::from([[2.48917, -1.73333], [-1.73333, 15.33333]]).convert::(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_cov_4() { + let data = TensorData::from([[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.cov(1, 0); + let expected = TensorData::from([[1.86687, -1.30000], [-1.30000, 11.5]]).convert::(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_cov_2() { + let data = TensorData::from([[0.5, 1.8], [0.2, -2.0], [3.0, -4.0], [5.0, 0.0]]); + let tensor = TestTensor::<2>::from_data(data, &Default::default()); + + let output = tensor.cov(1, 1); + let expected = TensorData::from([ + [0.845, -1.43, -4.55, -3.25], + [-1.43, 2.42, 7.7, 5.5], + [-4.55, 7.7, 24.5, 17.5], + [-3.25, 5.5, 17.5, 12.5], + ]) + .convert::(); + + let tolerance = Tolerance::default().set_half_precision_relative(1e-3); + output + .into_data() + .assert_approx_eq::(&expected, tolerance); +} + +#[test] +fn test_cov_3() { + let data = TensorData::from([ + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + ]); + let device = Default::default(); + let tensor = TestTensor::<3>::from_data(data, &device); + let data_actual = tensor.cov(0, 1).into_data(); + let data_expected = TestTensor::<3>::zeros([4, 4, 4], &device).to_data(); + data_expected.assert_approx_eq::(&data_actual, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/display.rs b/crates/burn-backend-tests/tests/tensor/float/stats/display.rs new file mode 100644 index 0000000..fc21a48 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/display.rs @@ -0,0 +1,321 @@ +use super::*; +use burn_tensor::{DType, Element, Shape, TensorData}; + +// Floating point values might not match for other precisions +fn skip_precision_not_f32() -> bool { + core::any::TypeId::of::() != core::any::TypeId::of::() +} + +#[test] +fn test_display_2d_int_tensor() { + let int_data = TensorData::from([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); + let tensor_int = TestTensorInt::<2>::from_data(int_data, &Default::default()); + + let output = format!("{}", tensor_int); + let expected = format!( + r#"Tensor {{ + data: +[[1, 2, 3], + [4, 5, 6], + [7, 8, 9]], + shape: [3, 3], + device: {:?}, + kind: "Int", + dtype: "{dtype}", +}}"#, + tensor_int.device(), + dtype = core::any::type_name::(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_2d_float_tensor() { + if skip_precision_not_f32() { + return; + } + + let float_data = TensorData::from([[1.1, 2.2, 3.3], [4.4, 5.5, 6.6], [7.7, 8.8, 9.9]]); + let tensor_float = TestTensor::<2>::from_data(float_data, &Default::default()); + + let output = format!("{}", tensor_float); + let expected = format!( + r#"Tensor {{ + data: +[[1.1, 2.2, 3.3], + [4.4, 5.5, 6.6], + [7.7, 8.8, 9.9]], + shape: [3, 3], + device: {:?}, + kind: "Float", + dtype: "f32", +}}"#, + tensor_float.device(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_2d_bool_tensor() { + let device = Default::default(); + let bool_data = TensorData::from([ + [true, false, true], + [false, true, false], + [false, true, true], + ]); + let tensor_bool = TestTensorBool::<2>::from_data(bool_data, &device); + + let output = format!("{}", tensor_bool); + // TODO: remove once backends no longer rely on generics for default elem types + let expected_dtype_name = match device.settings().bool_dtype { + burn_tensor::BoolDType::Native => DType::Bool(burn_tensor::BoolStore::Native).name(), + burn_tensor::BoolDType::U8 => DType::Bool(burn_tensor::BoolStore::U8).name(), + burn_tensor::BoolDType::U32 => DType::Bool(burn_tensor::BoolStore::U32).name(), + }; + let expected = format!( + r#"Tensor {{ + data: +[[true, false, true], + [false, true, false], + [false, true, true]], + shape: [3, 3], + device: {:?}, + kind: "Bool", + dtype: {:?}, +}}"#, + tensor_bool.device(), + expected_dtype_name, + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_3d_tensor() { + let data = TensorData::from([ + [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]], + [[13, 14, 15, 16], [17, 18, 19, 20], [21, 22, 23, 24]], + ]); + let tensor = TestTensorInt::<3>::from_data(data, &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[[1, 2, 3, 4], + [5, 6, 7, 8], + [9, 10, 11, 12]], + [[13, 14, 15, 16], + [17, 18, 19, 20], + [21, 22, 23, 24]]], + shape: [2, 3, 4], + device: {:?}, + kind: "Int", + dtype: "{dtype}", +}}"#, + tensor.device(), + dtype = core::any::type_name::(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_4d_tensor() { + let data = TensorData::from([ + [[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]], + [[[13, 14, 15], [16, 17, 18]], [[19, 20, 21], [22, 23, 24]]], + ]); + + let tensor = TestTensorInt::<4>::from_data(data, &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[[[1, 2, 3], + [4, 5, 6]], + [[7, 8, 9], + [10, 11, 12]]], + [[[13, 14, 15], + [16, 17, 18]], + [[19, 20, 21], + [22, 23, 24]]]], + shape: [2, 2, 2, 3], + device: {:?}, + kind: "Int", + dtype: "{dtype}", +}}"#, + tensor.device(), + dtype = core::any::type_name::(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_tensor_summarize_1() { + let tensor = TestTensor::<4>::zeros(Shape::new([2, 2, 2, 1000]), &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[[[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]]], + [[[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]]]], + shape: [2, 2, 2, 1000], + device: {:?}, + kind: "Float", + dtype: "{dtype}", +}}"#, + tensor.device(), + dtype = FloatElem::dtype().name(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_tensor_summarize_2() { + let tensor = TestTensor::<4>::zeros(Shape::new([2, 2, 20, 100]), &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[[[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]]], + [[[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0]]]], + shape: [2, 2, 20, 100], + device: {:?}, + kind: "Float", + dtype: "{dtype}", +}}"#, + tensor.device(), + dtype = FloatElem::dtype().name(), + ); + assert_eq!(output, expected); +} + +#[test] +fn test_display_tensor_summarize_3() { + let tensor = TestTensor::<4>::zeros(Shape::new([2, 2, 200, 6]), &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]], + [[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ... + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]]], + shape: [2, 2, 200, 6], + device: {:?}, + kind: "Float", + dtype: "{dtype}", +}}"#, + tensor.device(), + dtype = FloatElem::dtype().name(), + ); + assert_eq!(output, expected); +} +#[test] +fn test_display_precision() { + if skip_precision_not_f32() { + return; + } + + let tensor = TestTensor::<2>::full([1, 1], 0.123456789, &Default::default()); + + let output = format!("{}", tensor); + let expected = format!( + r#"Tensor {{ + data: +[[0.12345679]], + shape: [1, 1], + device: {:?}, + kind: "Float", + dtype: "f32", +}}"#, + tensor.device(), + ); + assert_eq!(output, expected); + + // CAN'T DO THIS BECAUSE OF GLOBAL STATE + // let print_options = PrintOptions { + // precision: Some(3), + // ..Default::default() + // }; + // set_print_options(print_options); + + let tensor = TestTensor::<2>::full([3, 2], 0.123456789, &Default::default()); + + // Set precision to 3 + let output = format!("{:.3}", tensor); + + let expected = format!( + r#"Tensor {{ + data: +[[0.123, 0.123], + [0.123, 0.123], + [0.123, 0.123]], + shape: [3, 2], + device: {:?}, + kind: "Float", + dtype: "f32", +}}"#, + tensor.device(), + ); + assert_eq!(output, expected); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/eye.rs b/crates/burn-backend-tests/tests/tensor/float/stats/eye.rs new file mode 100644 index 0000000..13ac86f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/eye.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn test_eye_float() { + let device = Default::default(); + let tensor = TestTensor::<2>::from([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let rhs = TestTensor::<2>::eye(3, &device); + assert_eq!(tensor.to_data(), rhs.to_data()); +} + +#[test] +fn test_eye_int() { + let device = Default::default(); + let tensor = TestTensorInt::<2>::from([[1, 0, 0], [0, 1, 0], [0, 0, 1]]); + let rhs = TestTensorInt::<2>::eye(3, &device); + assert_eq!(tensor.to_data(), rhs.to_data()); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/median.rs b/crates/burn-backend-tests/tests/tensor/float/stats/median.rs new file mode 100644 index 0000000..b8d21fe --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/median.rs @@ -0,0 +1,92 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_median_even() { + let tensor = TestTensor::<2>::from_data( + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + &Default::default(), + ); + + let median_actual_1 = tensor.clone().median(1); + let median_expected_1 = TensorData::from([[0.2], [0.0]]).convert::(); + median_actual_1 + .into_data() + .assert_eq(&median_expected_1, false); + + let median_actual_0 = tensor.median(0); + let median_expected_0 = TensorData::from([[0.5, -4.0, 0.2, -2.0]]).convert::(); + median_actual_0 + .into_data() + .assert_eq(&median_expected_0, false); +} + +#[test] +fn test_median_odd() { + let tensor = TestTensor::<2>::from_data( + [ + [0.5, 1.8, 0.2, -2.0, 1.0], + [3.0, -4.0, 5.0, 0.0, -1.0], + [5.0, -5.0, 1.0, 3.0, -2.0], + ], + &Default::default(), + ); + + let median_actual_1 = tensor.clone().median(1); + let median_expected_1 = TensorData::from([[0.5], [0.0], [1.0]]).convert::(); + median_actual_1 + .into_data() + .assert_eq(&median_expected_1, false); + + let median_actual_0 = tensor.median(0); + let median_expected_0 = TensorData::from([[3.0, -4.0, 1.0, 0.0, -1.0]]).convert::(); + median_actual_0 + .into_data() + .assert_eq(&median_expected_0, false); +} + +#[test] +fn test_median_with_indices() { + let device = Default::default(); + let tensor = TestTensor::<1>::from_data([3.0, 1.0, 2.0], &device); + // median = 2, original index = 2 + let (values, indices) = tensor.median_with_indices(0); + values + .into_data() + .assert_eq(&TensorData::from([2.0]), false); + indices + .into_data() + .assert_eq(&TensorData::from([2i64]), false); + + let tensor = TestTensor::<2>::from_data([[5.0, 1.0, 3.0], [2.0, 8.0, 4.0]], &device); + // Along dim 1: + // Row 0: median = 3, original index = 2 + // Row 1: median = 4, original index = 2 + let (values, indices) = tensor.median_with_indices(1); + values + .into_data() + .assert_eq(&TensorData::from([[3.0], [4.0]]), false); + indices + .into_data() + .assert_eq(&TensorData::from([[2i64], [2i64]]), false); +} + +#[test] +fn test_median_all_elements() { + let tensor = TestTensor::<2>::from_data( + [ + [0.5, 1.8, 0.2, -2.0, 1.0], + [3.0, -4.0, 5.0, 0.0, -1.0], + [5.0, -5.0, 1.0, 3.0, -2.0], + ], + &Default::default(), + ); + + // Sorted: [-5, -4, -2, -2, -1, 0, 0.2, 0.5, 1, 1, 1.8, 3, 3, 5, 5] + let dims = tensor.dims().len(); + let flattened_tensor: Tensor<1> = tensor.flatten(0, dims - 1); + let result = flattened_tensor.median(0); + result + .into_data() + .assert_eq(&TensorData::from([0.5]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/mod.rs b/crates/burn-backend-tests/tests/tensor/float/stats/mod.rs new file mode 100644 index 0000000..dd5ee4c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/mod.rs @@ -0,0 +1,7 @@ +pub use super::*; // re-export test types + +mod cov; +mod display; +mod eye; +mod median; +mod var; diff --git a/crates/burn-backend-tests/tests/tensor/float/stats/var.rs b/crates/burn-backend-tests/tests/tensor/float/stats/var.rs new file mode 100644 index 0000000..a0a901a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/float/stats/var.rs @@ -0,0 +1,69 @@ +use super::*; +use burn_tensor::TensorData; +use burn_tensor::Tolerance; + +#[test] +fn test_var() { + let tensor = TestTensor::<2>::from_data( + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + &Default::default(), + ); + + let output = tensor.var(1); + let expected = TensorData::from([[2.4892], [15.3333]]).convert::(); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_var_mean() { + let tensor = TestTensor::<2>::from_data( + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + &Default::default(), + ); + + let (var, mean) = tensor.var_mean(1); + + let var_expected = TensorData::from([[2.4892], [15.3333]]).convert::(); + let mean_expected = TensorData::from([[0.125], [1.]]).convert::(); + + var.into_data() + .assert_approx_eq::(&var_expected, Tolerance::default()); + mean.into_data() + .assert_approx_eq::(&mean_expected, Tolerance::default()); +} + +#[test] +fn test_var_bias() { + let tensor = TestTensor::<2>::from_data( + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + &Default::default(), + ); + + let output = tensor.var_bias(1); + let expected = TensorData::from([[1.86688], [11.5]]).convert::(); + + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_var_mean_bias() { + let tensor = TestTensor::<2>::from_data( + [[0.5, 1.8, 0.2, -2.0], [3.0, -4.0, 5.0, 0.0]], + &Default::default(), + ); + + let (var, mean) = tensor.var_mean_bias(1); + + let var_expected = TensorData::from([[1.86688], [11.5]]).convert::(); + let mean_expected = TensorData::from([[0.125], [1.]]).convert::(); + + var.into_data() + .assert_approx_eq::(&var_expected, Tolerance::default()); + mean.into_data() + .assert_approx_eq::(&mean_expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/mod.rs b/crates/burn-backend-tests/tests/tensor/int/mod.rs new file mode 100644 index 0000000..a071d89 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/mod.rs @@ -0,0 +1,4 @@ +pub use super::*; // re-export test types + +mod ops; +mod primitive; diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/abs.rs b/crates/burn-backend-tests/tests/tensor/int/ops/abs.rs new file mode 100644 index 0000000..56b8138 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/abs.rs @@ -0,0 +1,24 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_abs_ops_int() { + let tensor = TestTensorInt::<2>::from([[0, -1, 2], [3, 4, -5]]); + + let output = tensor.abs(); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 2], [3, 4, 5]]), false); +} + +#[test] +fn should_support_abs_ops_int_signed_min() { + let tensor = TestTensorInt::<2>::from([[IntElem::MIN]]); + + let output = tensor.abs(); + + output + .into_data() + .assert_eq(&TensorData::from([[IntElem::MIN]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/add.rs b/crates/burn-backend-tests/tests/tensor/int/ops/add.rs new file mode 100644 index 0000000..da73ef5 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/add.rs @@ -0,0 +1,104 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_add_d2_int() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::from([[6, 7, 8], [9, 10, 11]]); + + let output = tensor_1 + tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[6, 8, 10], [12, 14, 16]]), false); +} + +#[test] +fn test_add_broadcast_int() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2]]); + let tensor_2 = TestTensorInt::from([[3, 4, 5], [6, 7, 8]]); + + let output = tensor_1 + tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[3, 5, 7], [6, 8, 10]]), false); +} + +#[test] +fn should_support_add_scalar_ops_int() { + let scalar = 2; + let tensor = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let output = tensor + scalar; + + output + .into_data() + .assert_eq(&TensorData::from([[2, 3, 4], [5, 6, 7]]), false); +} + +#[test] +fn scalar_add_not_contiguous() { + let tensor = TestTensorInt::<1>::arange(0..32, &Default::default()).float(); + let tensor = tensor.reshape([1, 4, 4, 2]).permute([0, 3, 1, 2]); + + let tensor = tensor.slice([0..1, 0..2, 0..4, 0..4]); + let before = tensor.clone(); + + let after = tensor.add_scalar(0.0); + + before + .into_data() + .assert_approx_eq::(&after.into_data(), Default::default()); +} + +#[test] +fn scalar_add_not_contiguous_int() { + let tensor = TestTensorInt::<1>::arange(0..32, &Default::default()); + let tensor = tensor.reshape([1, 4, 4, 2]).permute([0, 3, 1, 2]); + + let tensor = tensor.slice([0..1, 0..2, 0..4, 0..4]); + let before = tensor.clone(); + + let after = tensor.add_scalar(0); + + before.into_data().assert_eq(&after.into_data(), true); +} + +#[test] +fn test_int_add_transposed() { + // Non-contig on both sides via transpose. + let a = TestTensorInt::<2>::from([[1, 2], [3, 4]]).transpose(); + let b = TestTensorInt::<2>::from([[10, 20], [30, 40]]).transpose(); + + let output = a + b; + + output + .into_data() + .assert_eq(&TensorData::from([[11, 33], [22, 44]]), false); +} + +#[test] +fn test_int_add_flipped() { + // [1, 2, 3, 4] flipped + [10, 20, 30, 40] flipped = [44, 33, 22, 11] + let a = TestTensorInt::<1>::from([1, 2, 3, 4]).flip([0]); + let b = TestTensorInt::<1>::from([10, 20, 30, 40]).flip([0]); + + let output = a + b; + + output + .into_data() + .assert_eq(&TensorData::from([44, 33, 22, 11]), false); +} + +#[test] +fn test_int_add_scalar_flipped() { + // Scalar add on a flipped input. [1,2,3,4] flipped + 10 = [14,13,12,11] + let a = TestTensorInt::<1>::from([1, 2, 3, 4]).flip([0]); + + let output = a + 10; + + output + .into_data() + .assert_eq(&TensorData::from([14, 13, 12, 11]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/aggregation.rs b/crates/burn-backend-tests/tests/tensor/int/ops/aggregation.rs new file mode 100644 index 0000000..0d1d37e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/aggregation.rs @@ -0,0 +1,81 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_should_mean_int() { + let tensor = TestTensorInt::<2>::from([[2, 2, 2], [3, 4, 5]]); + + let output = tensor.mean(); + + output.into_data().assert_eq(&TensorData::from([3]), false); +} + +#[test] +fn test_should_mean_last_dim_int() { + let tensor = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let output = tensor.mean_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1], [4]]), false); +} + +#[test] +fn test_should_sum_last_dim_int() { + let tensor = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let output = tensor.sum_dim(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3], [12]]), false); +} + +#[test] +fn test_should_sum_int() { + let tensor = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let output = tensor.sum(); + + output.into_data().assert_eq(&TensorData::from([15]), false); +} + +#[test] +#[ignore = "Not implemented for all backends yet"] +fn test_prod_int() { + let tensor = TestTensorInt::<2>::from([[2, 1, 2], [3, 4, 5]]); + let output = tensor.prod(); + + output + .into_data() + .assert_eq(&TensorData::from([240]), false); + + let tensor_with_zero = TestTensorInt::<2>::from([[2, 0, 2], [3, 4, 5]]); + let output = tensor_with_zero.prod(); + + output.into_data().assert_eq(&TensorData::from([0]), false); +} + +#[test] +#[ignore = "Not implemented for all backends yet"] +fn test_prod_dim_int() { + let tensor = TestTensorInt::<2>::from([[2, 1, 2], [3, 4, 5]]); + let output = tensor.prod_dim(1); + output + .into_data() + .assert_eq(&TensorData::from([[4], [60]]), false); + + let tensor_with_zero = TestTensorInt::<2>::from([[2, 0, 2], [3, 4, 5]]); + let output = tensor_with_zero.prod_dim(1); + output + .into_data() + .assert_eq(&TensorData::from([[0], [60]]), false); + + // Negative Indexing. + let tensor_with_zero = TestTensorInt::<2>::from([[2, 0, 2], [3, 4, 5]]); + let output = tensor_with_zero.prod_dim(-1); + output + .into_data() + .assert_eq(&TensorData::from([[0], [60]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/all.rs b/crates/burn-backend-tests/tests/tensor/int/ops/all.rs new file mode 100644 index 0000000..490874c --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/all.rs @@ -0,0 +1,23 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_all() { + let tensor = TestTensorInt::<2>::from([[0, 1, 0], [1, -1, 1]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorInt::<2>::from([[1, 1, 1], [1, 1, 1]]); + let data_actual = tensor.all().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_all_dim() { + let tensor = TestTensorInt::<2>::from([[0, 1, 0], [1, -1, 1]]); + let data_actual = tensor.all_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/any.rs b/crates/burn-backend-tests/tests/tensor/int/ops/any.rs new file mode 100644 index 0000000..967129f --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/any.rs @@ -0,0 +1,23 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_any() { + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [1, -1, 0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([true]); + data_expected.assert_eq(&data_actual, false); + + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [0, 0, 0]]); + let data_actual = tensor.any().into_data(); + let data_expected = TensorData::from([false]); + data_expected.assert_eq(&data_actual, false); +} + +#[test] +fn test_any_dim() { + let tensor = TestTensorInt::<2>::from([[0, 0, 0], [1, -1, 0]]); + let data_actual = tensor.any_dim(1).into_data(); + let data_expected = TensorData::from([[false], [true]]); + data_expected.assert_eq(&data_actual, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/arange.rs b/crates/burn-backend-tests/tests/tensor/int/ops/arange.rs new file mode 100644 index 0000000..3399fa1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/arange.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_arange() { + let device = Default::default(); + + let tensor = TestTensorInt::<1>::arange(2..5, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([2, 3, 4]), false); + + // Test arange with negative numbers + let tensor = TestTensorInt::<1>::arange(-10..-5, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([-10, -9, -8, -7, -6]), false); + + let tensor = TestTensorInt::<1>::arange(-3..0, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([-3, -2, -1]), false); + + // Test arange with a mix of positive and negative numbers + let tensor = TestTensorInt::<1>::arange(-2..3, &device); + tensor + .clone() + .into_data() + .assert_eq(&TensorData::from([-2, -1, 0, 1, 2]), false); + assert_eq!(tensor.device(), device); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/arange_step.rs b/crates/burn-backend-tests/tests/tensor/int/ops/arange_step.rs new file mode 100644 index 0000000..7015c29 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/arange_step.rs @@ -0,0 +1,44 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_arange_step() { + let device = Default::default(); + + // Test correct sequence of numbers when the range is 0..9 and the step is 1 + let tensor = TestTensorInt::<1>::arange_step(0..9, 1, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([0, 1, 2, 3, 4, 5, 6, 7, 8]), false); + + // Test correct sequence of numbers when the range is 0..3 and the step is 2 + let tensor = TestTensorInt::<1>::arange_step(0..3, 2, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([0, 2]), false); + + // Test correct sequence of numbers when the range is 0..2 and the step is 5 + let tensor = TestTensorInt::<1>::arange_step(0..2, 5, &device); + tensor.into_data().assert_eq(&TensorData::from([0]), false); + + // Test correct sequence of numbers when the range includes negative numbers + let tensor = TestTensorInt::<1>::arange_step(-3..3, 2, &device); + tensor + .into_data() + .assert_eq(&TensorData::from([-3, -1, 1]), false); + + let tensor = TestTensorInt::<1>::arange_step(-5..1, 5, &device); + tensor + .clone() + .into_data() + .assert_eq(&TensorData::from([-5, 0]), false); + assert_eq!(tensor.device(), device); +} + +#[test] +#[should_panic] +fn should_panic_when_step_is_zero() { + let device = Default::default(); + // Test that arange_step panics when the step is 0 + let _tensor = TestTensorInt::<1>::arange_step(0..3, 0, &device); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/arg.rs b/crates/burn-backend-tests/tests/tensor/int/ops/arg.rs new file mode 100644 index 0000000..9ba36af --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/arg.rs @@ -0,0 +1,24 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_argmax_2d_dim0_int() { + let tensor = TestTensorInt::<2>::from([[10, 11, 2], [3, 4, 5]]); + + let output = tensor.argmax(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 0, 1]]), false); +} + +#[test] +fn test_argmin_2d_dim0_int() { + let tensor = TestTensorInt::<2>::from([[10, 11, 2], [30, 4, 5]]); + + let output = tensor.argmin(0); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 0]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/bitwise.rs b/crates/burn-backend-tests/tests/tensor/int/ops/bitwise.rs new file mode 100644 index 0000000..cfdfc15 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/bitwise.rs @@ -0,0 +1,173 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_apply_bitwise_and_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let tensor_2 = TestTensorInt::from([[6, 7, 8], [9, 10, 15]]); + + let output = tensor_1.bitwise_and(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([[2, 4, 0], [9, 2, 8]]), false); +} + +#[test] +fn should_apply_bitwise_and_1d() { + let tensor_1 = TestTensorInt::<1>::from([13, 7]); + let tensor_2 = TestTensorInt::from([11, 3]); + + let output = tensor_1.bitwise_and(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([9, 3]), false); +} + +#[test] +fn should_apply_bitwise_and_scalar_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let scalar = 5; + + let output = tensor_1.bitwise_and_scalar(scalar); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 4, 5], [1, 1, 0]]), false); +} + +#[test] +fn should_apply_bitwise_not_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + + let output = tensor_1.bitwise_not(); + + output + .into_data() + .assert_eq(&TensorData::from([[-4, -5, -6], [-10, -4, -9]]), false); +} + +#[test] +fn should_apply_bitwise_or_scalar_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let scalar = 5; + + let output = tensor_1.bitwise_or_scalar(scalar); + + output + .into_data() + .assert_eq(&TensorData::from([[7, 5, 5], [13, 7, 13]]), false); +} + +#[test] +fn should_apply_bitwise_or_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let tensor_2 = TestTensorInt::from([[6, 7, 8], [9, 10, 15]]); + + let output = tensor_1.bitwise_or(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([[7, 7, 13], [9, 11, 15]]), false); +} + +#[test] +fn should_apply_bitwise_or_1d() { + let tensor_1 = TestTensorInt::<1>::from([13, 7]); + let tensor_2 = TestTensorInt::from([11, 3]); + + let output = tensor_1.bitwise_or(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([15, 7]), false); +} + +#[test] +fn should_apply_bitwise_xor_scalar_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let scalar = 5; + + let output = tensor_1.bitwise_xor_scalar(scalar); + + output + .into_data() + .assert_eq(&TensorData::from([[6, 1, 0], [12, 6, 13]]), false); +} + +#[test] +fn should_apply_bitwise_xor_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let tensor_2 = TestTensorInt::from([[6, 7, 8], [9, 10, 15]]); + + let output = tensor_1.bitwise_xor(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([[5, 3, 13], [0, 9, 7]]), false); +} + +#[test] +fn should_apply_bitwise_xor_1d() { + let tensor_1 = TestTensorInt::<1>::from([13, 7]); + let tensor_2 = TestTensorInt::from([11, 3]); + + let output = tensor_1.bitwise_xor(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([6, 4]), false); +} + +#[test] +fn should_apply_bitwise_left_shift_2d() { + if (IntElem::MAX as u32) < 512 { + return; + } + + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let tensor_2 = TestTensorInt::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor_1.bitwise_left_shift(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([[6, 16, 40], [144, 96, 512]]), false); +} + +#[test] +fn should_apply_bitwise_left_shift_scalar_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let scalar = 2; + + let output = tensor_1.bitwise_left_shift_scalar(scalar); + + output + .into_data() + .assert_eq(&TensorData::from([[12, 16, 20], [36, 12, 32]]), false); +} + +#[test] +fn should_apply_bitwise_right_shift_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let tensor_2 = TestTensorInt::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor_1.bitwise_right_shift(tensor_2); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 1, 0], [0, 0, 0]]), false); +} + +#[test] +fn should_apply_bitwise_right_shift_scalar_2d() { + let tensor_1 = TestTensorInt::<2>::from([[3, 4, 5], [9, 3, 8]]); + let scalar = 2; + + let output = tensor_1.bitwise_right_shift_scalar(scalar); + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 1], [2, 0, 2]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/cartesian_grid.rs b/crates/burn-backend-tests/tests/tensor/int/ops/cartesian_grid.rs new file mode 100644 index 0000000..87db450 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/cartesian_grid.rs @@ -0,0 +1,20 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_cartesian_grid() { + let device = Default::default(); + + // Test a single element tensor + let tensor: TestTensorInt<2> = TestTensorInt::<1>::cartesian_grid([1], &device); + tensor + .into_data() + .assert_eq(&TensorData::from([[0]]), false); + + // Test for a 2x2 tensor + let tensor: TestTensorInt<3> = TestTensorInt::<2>::cartesian_grid([2, 2], &device); + tensor.into_data().assert_eq( + &TensorData::from([[[0, 0], [0, 1]], [[1, 0], [1, 1]]]), + false, + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/cast.rs b/crates/burn-backend-tests/tests/tensor/int/ops/cast.rs new file mode 100644 index 0000000..f366d70 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/cast.rs @@ -0,0 +1,84 @@ +use super::*; +use burn_tensor::{DType, FloatDType, IntDType, TensorData}; + +#[test] +fn cast_int_to_bool() { + let tensor1 = TestTensorInt::<2>::from([[0, 43, 0], [2, -4, 31]]); + let data_actual = tensor1.bool().into_data(); + let data_expected = TensorData::from([[false, true, false], [true, true, true]]); + data_actual.assert_eq(&data_expected, false); +} + +#[test] +fn cast_bool_to_int_tensor() { + let tensor = TestTensorBool::<2>::from([[true, false, true], [false, false, true]]).int(); + + let expected = TensorData::from([[1, 0, 1], [0, 0, 1]]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_int_precision() { + let data = TensorData::from([[1, 2, 3], [4, 5, 6]]); + let tensor = TestTensorInt::<2>::from(data.clone()); + + let output = tensor.cast(DType::I32); + + assert_eq!(output.dtype(), DType::I32); + output.into_data().assert_eq(&data, false); +} + +#[test] +fn cast_int_to_float_with_dtype() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor.cast(FloatDType::F32); + + assert_eq!(output.dtype(), DType::F32); + let expected = TensorData::from([[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_int_within_kind() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + + let output = tensor.cast(IntDType::I32); + + assert_eq!(output.dtype(), DType::I32); + let expected = TensorData::from([1i32, 2, 3]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn cast_int_same_dtype_is_noop() { + let data = TensorData::from([1, 2, 3]); + let tensor = TestTensorInt::<1>::from(data.clone()); + let original_dtype = tensor.dtype(); + let int_dtype: IntDType = original_dtype.into(); + + let output = tensor.cast(int_dtype); + + assert_eq!(output.dtype(), original_dtype); + output.into_data().assert_eq(&data, false); +} + +#[test] +#[should_panic] +fn cast_int_with_float_dtype_panics() { + let tensor = TestTensorInt::<1>::from([1, 2]); + let _ = tensor.cast(DType::F32); +} + +#[test] +fn test_int_into_float_flipped() { + // [1, 2, 3, 4] flipped -> [4, 3, 2, 1] -> float [4.0, 3.0, 2.0, 1.0] + let t = TestTensorInt::<1>::from([1, 2, 3, 4]).flip([0]); + + let output = t.float(); + + output + .into_data() + .assert_eq(&TensorData::from([4.0f32, 3.0, 2.0, 1.0]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/cat.rs b/crates/burn-backend-tests/tests/tensor/int/ops/cat.rs new file mode 100644 index 0000000..640ed06 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/cat.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_cat_ops_int() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data([[1, 2, 3]], &device); + let tensor_2 = TestTensorInt::<2>::from_data([[4, 5, 6]], &device); + + let output = Tensor::cat(vec![tensor_1, tensor_2], 0); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [4, 5, 6]]), false); +} + +#[test] +fn should_support_cat_with_empty_tensor_int() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data([[1, 2, 3]], &device); + let tensor_2: TestTensorInt<2> = TestTensorInt::empty([1, 0], &device); + + let output = Tensor::cat(vec![tensor_1, tensor_2], 1); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/chunk.rs b/crates/burn-backend-tests/tests/tensor/int/ops/chunk.rs new file mode 100644 index 0000000..e040fad --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/chunk.rs @@ -0,0 +1,16 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_chunk_multi_dimension() { + let tensors = + TestTensorInt::<2>::from_data(TensorData::from([[0, 1, 2, 3]]), &Default::default()) + .chunk(2, 1); + assert_eq!(tensors.len(), 2); + + let expected = [TensorData::from([[0, 1]]), TensorData::from([[2, 3]])]; + + for (index, tensor) in tensors.iter().enumerate() { + tensor.to_data().assert_eq(&expected[index], false); + } +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/comparison.rs b/crates/burn-backend-tests/tests/tensor/int/ops/comparison.rs new file mode 100644 index 0000000..522ad44 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/comparison.rs @@ -0,0 +1,280 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_equal() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 5]]); + + let data_actual_cloned = tensor_1.clone().equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.equal(tensor_2); + + let data_expected = TensorData::from([[false, true, false], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_not_equal() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 5]]); + + let data_actual_cloned = tensor_1.clone().not_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.not_equal(tensor_2); + + let data_expected = TensorData::from([[true, false, true], [true, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_equal_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 2, 5]]); + + let data_actual_cloned = tensor_1.clone().equal_elem(2); + let data_actual_inplace = tensor_1.equal_elem(2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_not_equal_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 2, 5]]); + + let data_actual_cloned = tensor_1.clone().not_equal_elem(2); + let data_actual_inplace = tensor_1.not_equal_elem(2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn greater_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let data_actual_cloned = tensor_1.clone().greater_elem(4); + let data_actual_inplace = tensor_1.greater_elem(4); + + let data_expected = TensorData::from([[false, false, false], [false, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_equal_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let data_actual_cloned = tensor_1.clone().greater_equal_elem(4); + let data_actual_inplace = tensor_1.greater_equal_elem(4); + + let data_expected = TensorData::from([[false, false, false], [false, true, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 50]]); + + let data_actual_cloned = tensor_1.clone().greater(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater(tensor_2); + + let data_expected = TensorData::from([[false, false, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_equal() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 50]]); + + let data_actual_cloned = tensor_1.clone().greater_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.greater_equal(tensor_2); + + let data_expected = TensorData::from([[false, true, true], [false, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let data_actual_cloned = tensor_1.clone().lower_elem(4); + let data_actual_inplace = tensor_1.lower_elem(4); + + let data_expected = TensorData::from([[true, true, true], [true, false, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_equal_elem() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + let data_actual_cloned = tensor_1.clone().lower_equal_elem(4); + let data_actual_inplace = tensor_1.lower_equal_elem(4); + + let data_expected = TensorData::from([[true, true, true], [true, true, false]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 50]]); + + let data_actual_cloned = tensor_1.clone().lower(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower(tensor_2); + + let data_expected = TensorData::from([[true, false, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_lower_equal() { + let tensor_1 = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + let tensor_2 = TestTensorInt::<2>::from([[1, 1, 1], [4, 3, 50]]); + + let data_actual_cloned = tensor_1.clone().lower_equal(tensor_2.clone()); + let data_actual_inplace = tensor_1.lower_equal(tensor_2); + + let data_expected = TensorData::from([[true, true, false], [true, false, true]]); + data_expected.assert_eq(&data_actual_cloned.into_data(), false); + data_expected.assert_eq(&data_actual_inplace.into_data(), false); +} + +#[test] +fn test_greater_broadcast() { + // Test broadcasting with shape [1, 4] vs [4, 4] + let device = Default::default(); + let data_1 = TensorData::from([[1, 2, 3, 4]]); + let data_2 = TensorData::from([ + [0.5, 1.5, 2.5, 3.5], + [1.5, 2.5, 3.5, 4.5], + [2.5, 3.5, 4.5, 5.5], + [3.5, 4.5, 5.5, 6.5], + ]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.greater(tensor_2); + + let expected = TensorData::from([ + [true, true, true, true], + [false, false, false, false], + [false, false, false, false], + [false, false, false, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_greater_equal_broadcast() { + // Test broadcasting with shape [4, 1] vs [1, 4] + let device = Default::default(); + let data_1 = TensorData::from([[1], [2], [3], [4]]); + let data_2 = TensorData::from([[1, 2, 3, 4]]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.greater_equal(tensor_2); + + let expected = TensorData::from([ + [true, false, false, false], + [true, true, false, false], + [true, true, true, false], + [true, true, true, true], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_equal_broadcast() { + // Test broadcasting with different ranks + let device = Default::default(); + let data_1 = TensorData::from([[2], [3], [4]]); + let data_2 = TensorData::from([[2, 3, 4, 2]]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.equal(tensor_2); + + let expected = TensorData::from([ + [true, false, false, true], + [false, true, false, false], + [false, false, true, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_not_equal_broadcast() { + // Test broadcasting with shape [3, 1] vs [1, 3] + let device = Default::default(); + let data_1 = TensorData::from([[1], [2], [3]]); + let data_2 = TensorData::from([[1, 2, 3]]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.not_equal(tensor_2); + + let expected = TensorData::from([ + [false, true, true], + [true, false, true], + [true, true, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_int_greater_broadcast() { + let device = Default::default(); + let data_1 = TensorData::from([[1i32, 2, 3]]); + let data_2 = TensorData::from([[0i32], [2], [4]]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.greater(tensor_2); + + let expected = TensorData::from([ + [true, true, true], + [false, false, true], + [false, false, false], + ]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_int_lower_equal_broadcast() { + let device = Default::default(); + let data_1 = TensorData::from([[2i32], [4]]); + let data_2 = TensorData::from([[1i32, 2, 3]]); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let result = tensor_1.lower_equal(tensor_2); + + let expected = TensorData::from([[false, true, true], [false, false, false]]); + expected.assert_eq(&result.into_data(), false); +} + +#[test] +fn test_int_greater_flipped() { + // [1,2,3,4] flipped -> [4,3,2,1]; > [3,3,3,3] = [T,F,F,F] + let lhs = TestTensorInt::<1>::from([1, 2, 3, 4]).flip([0]); + let rhs = TestTensorInt::<1>::from([3, 3, 3, 3]); + + let result = lhs.greater(rhs); + + result + .into_data() + .assert_eq(&TensorData::from([true, false, false, false]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/create_like.rs b/crates/burn-backend-tests/tests/tensor/int/ops/create_like.rs new file mode 100644 index 0000000..c674971 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/create_like.rs @@ -0,0 +1,22 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_zeros_like() { + let tensor = TestTensorInt::<3>::from([[[0, 1, 2], [3, 4, 5]], [[6, 7, 8], [9, 10, 11]]]); + + let tensor = tensor.zeros_like(); + let expected = TensorData::from([[[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_ones_like() { + let tensor = TestTensorInt::<3>::from([[[0, 1, 2], [3, 4, 5]], [[6, 7, 8], [9, 10, 11]]]); + + let tensor = tensor.ones_like(); + let expected = TensorData::from([[[1, 1, 1], [1, 1, 1]], [[1, 1, 1], [1, 1, 1]]]); + + tensor.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/cumulative.rs b/crates/burn-backend-tests/tests/tensor/int/ops/cumulative.rs new file mode 100644 index 0000000..6af39a9 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/cumulative.rs @@ -0,0 +1,90 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_cumsum_int_dim_0() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor.cumsum(0); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [5, 7, 9]]), false); +} + +#[test] +fn test_cumsum_int_dim_1() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor.cumsum(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 3, 6], [4, 9, 15]]), false); +} + +#[test] +fn test_cumprod_int_dim_0() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor.cumprod(0); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [4, 10, 18]]), false); +} + +#[test] +fn test_cumprod_int_dim_1() { + let tensor = TestTensorInt::<2>::from([[1, 2, 3], [4, 5, 6]]); + + let output = tensor.cumprod(1); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 6], [4, 20, 120]]), false); +} + +#[test] +fn test_cummin_int_dim_0() { + let tensor = TestTensorInt::<2>::from([[3, 1, 4], [2, 5, 1]]); + + let output = tensor.cummin(0); + + output + .into_data() + .assert_eq(&TensorData::from([[3, 1, 4], [2, 1, 1]]), false); +} + +#[test] +fn test_cummin_int_dim_1() { + let tensor = TestTensorInt::<2>::from([[3, 1, 4], [2, 5, 1]]); + + let output = tensor.cummin(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3, 1, 1], [2, 2, 1]]), false); +} + +#[test] +fn test_cummax_int_dim_0() { + let tensor = TestTensorInt::<2>::from([[3, 1, 4], [1, 5, 2]]); + + let output = tensor.cummax(0); + + output + .into_data() + .assert_eq(&TensorData::from([[3, 1, 4], [3, 5, 4]]), false); +} + +#[test] +fn test_cummax_int_dim_1() { + let tensor = TestTensorInt::<2>::from([[3, 1, 4], [1, 5, 2]]); + + let output = tensor.cummax(1); + + output + .into_data() + .assert_eq(&TensorData::from([[3, 3, 4], [1, 5, 5]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/div.rs b/crates/burn-backend-tests/tests/tensor/int/ops/div.rs new file mode 100644 index 0000000..167b3a3 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/div.rs @@ -0,0 +1,45 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_div_ops_int() { + let data_1 = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let data_2 = TensorData::from([[1, 1, 2], [1, 1, 2]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 / tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 1], [3, 4, 2]]), false); +} + +#[test] +fn test_div_broadcast_int() { + let data_1 = TensorData::from([[0, 1, 2]]); + let data_2 = TensorData::from([[1, 1, 2], [3, 4, 5]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 / tensor_2; + + output + .into_data() + .assert_eq(&TensorData::from([[0, 1, 1], [0, 0, 0]]), false); +} + +#[test] +fn should_support_div_scalar_ops_int() { + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let scalar = 2; + let tensor = TestTensorInt::<2>::from_data(data, &Default::default()); + + let output = tensor / scalar; + + output + .into_data() + .assert_eq(&TensorData::from([[0, 0, 1], [1, 2, 2]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/expand.rs b/crates/burn-backend-tests/tests/tensor/int/ops/expand.rs new file mode 100644 index 0000000..c34ee7b --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/expand.rs @@ -0,0 +1,75 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn expand_2d_int() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let output = tensor.expand([3, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [1, 2, 3], [1, 2, 3]]), false); +} + +#[test] +fn should_all_negative_one() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let output = tensor.expand([2, -1]); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [1, 2, 3]]), false); +} + +#[test] +#[should_panic] +fn should_panic_negative_one_on_non_existing_dim() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let _expanded_tensor = tensor.expand([-1, 3]); +} + +/// Regression test for https://github.com/tracel-ai/burn/issues/2091 +#[test] +fn inplace_op_after_expand() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let mut output = tensor.expand([2, 3]); + output = output + 1; + + output + .into_data() + .assert_eq(&TensorData::from([[2, 3, 4], [2, 3, 4]]), false); +} + +#[test] +fn expand_int_after_transpose() { + let tensor = TestTensorInt::<2>::from([[1, 2], [3, 4]]).transpose(); + + let output = tensor.expand([3, 2, 2]); + + output.into_data().assert_eq( + &TensorData::from([[[1, 3], [2, 4]], [[1, 3], [2, 4]], [[1, 3], [2, 4]]]), + false, + ); +} + +#[test] +fn expand_int_after_flip() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]).flip([0]); + + let output = tensor.expand([2, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[3, 2, 1], [3, 2, 1]]), false); +} + +#[test] +fn expand_int_after_narrow() { + let tensor = TestTensorInt::<1>::from([0, 1, 2, 3, 4]).narrow(0, 1, 3); + + let output = tensor.expand([2, 3]); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 2, 3], [1, 2, 3]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/flip.rs b/crates/burn-backend-tests/tests/tensor/int/ops/flip.rs new file mode 100644 index 0000000..c302322 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/flip.rs @@ -0,0 +1,22 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn flip_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + let flipped = tensor.clone().flip([0, 2]); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).flip((0, 2)) + let expected = TensorData::from([ + [[15, 14, 13, 12], [19, 18, 17, 16], [23, 22, 21, 20]], + [[3, 2, 1, 0], [7, 6, 5, 4], [11, 10, 9, 8]], + ]); + + flipped.into_data().assert_eq(&expected, false); + + // Test with no flip + let flipped = tensor.clone().flip([]); + assert_eq!(tensor.into_data(), flipped.into_data()); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/full.rs b/crates/burn-backend-tests/tests/tensor/int/ops/full.rs new file mode 100644 index 0000000..4541795 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/full.rs @@ -0,0 +1,11 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_tensor_full() { + let device = Default::default(); + let int_tensor = TestTensorInt::<2>::full([2, 2], 2, &device); + int_tensor + .into_data() + .assert_eq(&TensorData::from([[2, 2], [2, 2]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter.rs b/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter.rs new file mode 100644 index 0000000..ca8d281 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter.rs @@ -0,0 +1,69 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_gather_1d_dim0_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_ints([5, 6, 7], &device); + let indices = TestTensorInt::from_ints([1, 1, 0, 1, 2], &device); + + let output = tensor.gather(0, indices); + + output + .into_data() + .assert_eq(&TensorData::from([6, 6, 5, 6, 7]), false); +} + +#[test] +fn should_gather_indices_broadcasted() { + let device = Default::default(); + + let batch_size = 3; + let fft_size = 4; + let shape = [batch_size, fft_size, 2]; + let x = TestTensorInt::arange( + 0..shape.iter().product::() as i64, + &Default::default(), + ) + .reshape(shape); + let idx = TestTensorInt::<1>::from_ints([0, 2, 1, 3], &device); + + let expected = TestTensorInt::<3>::from([ + [[0, 1], [4, 5], [2, 3], [6, 7]], + [[8, 9], [12, 13], [10, 11], [14, 15]], + [[16, 17], [20, 21], [18, 19], [22, 23]], + ]) + .into_data(); + + // Case 1: gather dim 2 + let perm = idx + .clone() + .reshape([1, 1, fft_size]) + .repeat_dim(0, batch_size) + .repeat_dim(1, 2); + + let input = x.clone().permute([0, 2, 1]); + let out = input.gather(2, perm).permute([0, 2, 1]); + + out.into_data().assert_eq(&expected, true); + + // Case 2: gather directly on dim 1 + let perm = idx.reshape([1, fft_size, 1]).repeat_dim(0, batch_size); + let out2 = x.gather(1, perm.repeat_dim(2, 2)); + + out2.into_data().assert_eq(&expected, true); +} + +#[test] +fn should_scatter_add_1d_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_ints([0, 0, 0], &device); + let values = TestTensorInt::from_ints([5, 4, 3], &device); + let indices = TestTensorInt::from_ints([1, 0, 2], &device); + + let output = tensor.scatter(0, indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([4, 5, 3]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter_nd.rs b/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter_nd.rs new file mode 100644 index 0000000..6f3dfe7 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/gather_scatter_nd.rs @@ -0,0 +1,131 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn test_int_gather_nd_2d() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[10, 20, 30], [40, 50, 60]], &device); + // indices shape: [2, 2] => K=2, M=2, DV=1 + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 2]], &device); + + let output: TestTensorInt<1> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([20, 60]), false); +} + +#[test] +fn test_int_scatter_nd_add() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[1, 2, 3], [4, 5, 6]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 0], [1, 2]], &device); + let values = TestTensorInt::<1>::from_ints([10, 20], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Add); + + output + .into_data() + .assert_eq(&TensorData::from([[11, 2, 3], [4, 5, 26]]), false); +} + +#[test] +fn test_int_scatter_nd_mul() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[2, 3], [4, 5]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 0]], &device); + let values = TestTensorInt::<1>::from_ints([10, 3], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Mul); + + output + .into_data() + .assert_eq(&TensorData::from([[2, 30], [12, 5]]), false); +} + +#[test] +fn test_int_scatter_nd_assign() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[1, 2, 3], [4, 5, 6]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 1], [1, 2]], &device); + let values = TestTensorInt::<1>::from_ints([99, 88], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output + .into_data() + .assert_eq(&TensorData::from([[1, 99, 3], [4, 5, 88]]), false); +} + +#[test] +fn test_int_scatter_nd_min() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[5, 10], [15, 20]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 0], [1, 1]], &device); + let values = TestTensorInt::<1>::from_ints([3, 25], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Min); + + // min(5, 3) = 3; min(20, 25) = 20 + output + .into_data() + .assert_eq(&TensorData::from([[3, 10], [15, 20]]), false); +} + +#[test] +fn test_int_scatter_nd_max() { + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[5, 10], [15, 20]], &device); + let indices = TestTensorInt::<2>::from_ints([[0, 0], [1, 1]], &device); + let values = TestTensorInt::<1>::from_ints([3, 25], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Max); + + // max(5, 3) = 5; max(20, 25) = 25 + output + .into_data() + .assert_eq(&TensorData::from([[5, 10], [15, 25]]), false); +} + +#[test] +fn test_int_scatter_nd_slices() { + // K=1: each index selects a full row + let device = Default::default(); + let data = TestTensorInt::<2>::zeros([2, 3], &device); + let indices = TestTensorInt::<2>::from_ints([[0], [1]], &device); + let values = TestTensorInt::<2>::from_ints([[10, 20, 30], [40, 50, 60]], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output + .into_data() + .assert_eq(&TensorData::from([[10, 20, 30], [40, 50, 60]]), false); +} + +#[test] +fn test_int_gather_nd_slices() { + // K=1: each index gathers a full row + let device = Default::default(); + let data = TestTensorInt::<2>::from_ints([[10, 20, 30], [40, 50, 60]], &device); + let indices = TestTensorInt::<2>::from_ints([[1], [0]], &device); + + let output: TestTensorInt<2> = data.gather_nd(indices); + + output + .into_data() + .assert_eq(&TensorData::from([[40, 50, 60], [10, 20, 30]]), false); +} + +#[test] +fn test_int_scatter_nd_single_element() { + let device = Default::default(); + let data = TestTensorInt::<1>::from_ints([1, 2, 3], &device); + let indices = TestTensorInt::<2>::from_ints([[0]], &device); + let values = TestTensorInt::<1>::from_ints([99], &device); + + let output = data.scatter_nd(indices, values, IndexingUpdateOp::Assign); + + output + .into_data() + .assert_eq(&TensorData::from([99, 2, 3]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/init.rs b/crates/burn-backend-tests/tests/tensor/int/ops/init.rs new file mode 100644 index 0000000..b95fa38 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/init.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_int_empty() { + let shape = [2, 2]; + let tensor = TestTensorInt::<2>::empty(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()) +} + +#[test] +fn should_support_int_zeros() { + let shape = [2, 2]; + let tensor = TestTensorInt::<2>::zeros(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[0, 0], [0, 0]]), false); +} + +#[test] +fn should_support_int_ones() { + let shape = [2, 2]; + let tensor = TestTensorInt::<2>::ones(shape, &Default::default()); + assert_eq!(tensor.shape(), shape.into()); + + tensor + .into_data() + .assert_eq(&TensorData::from([[1, 1], [1, 1]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/mask.rs b/crates/burn-backend-tests/tests/tensor/int/ops/mask.rs new file mode 100644 index 0000000..90c027d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/mask.rs @@ -0,0 +1,69 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_mask_where_broadcast_int() { + let device = Default::default(); + // When broadcasted, the input [[2, 3], [4, 5]] is repeated 4 times + let tensor = TestTensorInt::<1>::arange(2..6, &device).reshape([1, 2, 2]); + let mask = TestTensorBool::<3>::from_bool( + TensorData::from([ + [[true, false], [false, true]], + [[false, true], [true, false]], + [[false, false], [false, false]], + [[true, true], [true, true]], + ]), + &device, + ); + let value = TestTensorInt::<3>::ones([4, 2, 2], &device); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([ + [[1, 3], [4, 1]], + [[2, 1], [1, 5]], + [[2, 3], [4, 5]], + [[1, 1], [1, 1]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_mask_where_ops() { + let device = Default::default(); + let tensor = TestTensorInt::<2>::from_data([[1, 7], [2, 3]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + let value = TestTensorInt::<2>::from_data(TensorData::from([[8, 9], [10, 11]]), &device); + + let output = tensor.mask_where(mask, value); + let expected = TensorData::from([[8, 7], [2, 11]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_mask_fill_ops() { + let device = Default::default(); + let tensor = TestTensorInt::<2>::from_data([[1, 7], [2, 3]], &device); + let mask = + TestTensorBool::<2>::from_bool(TensorData::from([[true, false], [false, true]]), &device); + + let output = tensor.mask_fill(mask, 9); + let expected = TensorData::from([[9, 7], [2, 9]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_mask_fill_flipped() { + // [10, 20, 30, 40] flipped -> [40, 30, 20, 10]; mask [T, F, T, F] -> [-1, 30, -1, 10] + let tensor = TestTensorInt::<1>::from([10, 20, 30, 40]).flip([0]); + let mask = TestTensorBool::<1>::from([true, false, true, false]); + + let output = tensor.mask_fill(mask, -1); + + output + .into_data() + .assert_eq(&TensorData::from([-1, 30, -1, 10]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/matmul.rs b/crates/burn-backend-tests/tests/tensor/int/ops/matmul.rs new file mode 100644 index 0000000..c01af14 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/matmul.rs @@ -0,0 +1,203 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_int_matmul_d2() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_ints([[1, 7], [2, 3], [1, 5]], &device); + let tensor_2 = TestTensorInt::<2>::from_ints([[4, 7, 5], [2, 3, 5]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[18, 28, 40], [14, 23, 25], [14, 22, 30]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_d3() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<3>::from_ints([[[1, 7], [2, 3]]], &device); + let tensor_2 = TestTensorInt::<3>::from_ints([[[4, 7], [2, 3]]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[[18, 28], [14, 23]]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_broadcast_1() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<3>::from_ints([[[1, 7], [2, 3]]], &device); + let tensor_2 = TestTensorInt::from_ints([[[4, 7], [2, 3]], [[2, 5], [6, 3]]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[[18, 28], [14, 23]], [[44, 26], [22, 19]]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_broadcast_4d() { + let device = Default::default(); + // [2, 1, 2, 2] + let tensor_1 = TestTensorInt::<4>::from_ints([[[[1, 7], [2, 3]]], [[[2, 5], [6, 3]]]], &device); + // [1, 2, 2, 2] + let tensor_2 = TestTensorInt::from_ints([[[[9, 8], [1, 4]], [[2, 7], [3, 5]]]], &device); + + // [2, 1, 2, 2] @ [1, 2, 2, 2] -> [2, 2, 2, 2] + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [[[16, 36], [21, 28]], [[23, 42], [13, 29]]], + [[[23, 36], [57, 60]], [[19, 39], [21, 57]]], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_simple_1() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_ints([[5, 14], [14, 25]], &device); + let tensor_2 = TestTensorInt::from_ints([[3, 4, 5], [0, 1, 2]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[15, 34, 53], [42, 81, 120]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_4_3() { + if (IntElem::MAX as u32) < 324 { + return; + } + + let device = Default::default(); + let tensor_1 = + TestTensorInt::<2>::from_ints([[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]], &device); + let tensor_2 = + TestTensorInt::from_ints([[0, 1, 2], [4, 5, 6], [8, 9, 10], [12, 13, 14]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[56, 62, 68], [152, 174, 196], [248, 286, 324]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_trivial() { + if (IntElem::MAX as u32) < 506 { + return; + } + + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..16, &device).reshape([4, 4]); + + let tensor_3 = tensor_1.clone().matmul(tensor_1); + + tensor_3.into_data().assert_eq( + &TensorData::from([ + [56, 62, 68, 74], + [152, 174, 196, 218], + [248, 286, 324, 362], + [344, 398, 452, 506], + ]), + false, + ); +} + +#[test] +fn test_int_matmul_trivial_transposed() { + if (IntElem::MAX as u32) < 734 { + return; + } + + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..16, &device).reshape([4, 4]); + + let tensor_3 = tensor_1.clone().matmul(tensor_1.transpose()); + + tensor_3.into_data().assert_eq( + &TensorData::from([ + [14, 38, 62, 86], + [38, 126, 214, 302], + [62, 214, 366, 518], + [86, 302, 518, 734], + ]), + false, + ); +} + +#[test] +fn test_int_matmul_4_8() { + if (IntElem::MAX as u32) < 6092 { + return; + } + + let device = Default::default(); + + let tensor_1 = TestTensorInt::<1>::arange(0..32, &device).reshape([4, 8]); + + let tensor_3 = tensor_1.clone().matmul(tensor_1.transpose()); + + tensor_3.into_data().assert_eq( + &TensorData::from([ + [140, 364, 588, 812], + [364, 1100, 1836, 2572], + [588, 1836, 3084, 4332], + [812, 2572, 4332, 6092], + ]), + false, + ); +} + +#[test] +fn test_int_matmul_simple_2() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_ints([[1, 2, 3, 4]], &device); + let tensor_2 = TestTensorInt::from_ints([[3], [4], [5], [6]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([[50]]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_matmul_simple_3() { + let device = Default::default(); + let tensor_1 = + TestTensorInt::<2>::from_ints([[3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]], &device); + let tensor_2 = TestTensorInt::from_ints([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [9, 18, 27, 36], + [12, 24, 36, 48], + [15, 30, 45, 60], + [18, 36, 54, 72], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn int_should_panic_when_inner_dimensions_are_not_equal() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_ints([[3, 3], [4, 4], [5, 5], [6, 6]], &device); + let tensor_2 = TestTensorInt::from_ints([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]], &device); + + let tensor_3 = tensor_1.matmul(tensor_2); + let expected = TensorData::from([ + [9, 18, 27, 36], + [12, 24, 36, 48], + [15, 30, 45, 60], + [18, 36, 54, 72], + ]); + + tensor_3.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/mod.rs b/crates/burn-backend-tests/tests/tensor/int/ops/mod.rs new file mode 100644 index 0000000..c6d6d83 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/mod.rs @@ -0,0 +1,48 @@ +pub use super::*; // re-export test types + +mod abs; +mod add; +mod aggregation; +mod all; +mod any; +mod arange; +mod arange_step; +mod bitwise; +mod cartesian_grid; +mod cast; +mod cat; +mod chunk; +mod comparison; +mod create_like; +mod cumulative; +mod div; +mod expand; +mod flip; +mod full; +mod gather_scatter; +mod gather_scatter_nd; +mod init; +mod mask; +mod matmul; +mod movedim; +mod mul; +mod one_hot; +mod permute; +mod random; +mod remainder; +mod repeat; +mod repeat_dim; +mod reshape; +mod roll; +mod select; +mod sign; +mod slice; +mod slice_assign; +mod sort_argsort; +mod stack; +mod sub; +mod take; +mod topk; +mod transpose; +mod tri; +mod unfold; diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/movedim.rs b/crates/burn-backend-tests/tests/tensor/int/ops/movedim.rs new file mode 100644 index 0000000..613e37e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/movedim.rs @@ -0,0 +1,52 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn movedim_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + let permuted = tensor.clone().movedim(0, 2); + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim(0, 2) + let expected = TensorData::from([ + [[0, 12], [1, 13], [2, 14], [3, 15]], + [[4, 16], [5, 17], [6, 18], [7, 19]], + [[8, 20], [9, 21], [10, 22], [11, 23]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().movedim(0, -1); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().movedim(0, 0); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +fn vec_input_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + let permuted = tensor.clone().movedim(vec![0, 1], vec![1, 0]); + // from pytorch + // import torch; torch.arange(0, 24).reshape(2, 3, 4).movedim([0, 1], [1, 0]) + let expected = TensorData::from([ + [[0, 1, 2, 3], [12, 13, 14, 15]], + [[4, 5, 6, 7], [16, 17, 18, 19]], + [[8, 9, 10, 11], [20, 21, 22, 23]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axes + let permuted = tensor.clone().movedim(vec![-3, -2], vec![-2, -3]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axes + let permuted = tensor.clone().movedim(vec![0, 1], vec![0, 1]); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/mul.rs b/crates/burn-backend-tests/tests/tensor/int/ops/mul.rs new file mode 100644 index 0000000..1ab881a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/mul.rs @@ -0,0 +1,70 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_mul_ops_int() { + let data_1 = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let data_2 = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0, 1, 4], [9, 16, 25]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_mul_broadcast_int() { + let data_1 = TensorData::from([[0, 1, 2]]); + let data_2 = TensorData::from([[3, 4, 5], [6, 7, 8]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 * tensor_2; + let expected = TensorData::from([[0, 4, 10], [0, 7, 16]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_mul_scalar_ops_int() { + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let scalar = 2; + let tensor = TestTensorInt::<2>::from_data(data, &Default::default()); + + let output = tensor * scalar; + let expected = TensorData::from([[0, 2, 4], [6, 8, 10]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_mul_flipped_2d() { + // [[1, 2], [3, 4]] axis 0 flipped -> [[3, 4], [1, 2]] + // * [[10, 20], [30, 40]] = [[30, 80], [30, 80]] + let a = TestTensorInt::<2>::from([[1, 2], [3, 4]]).flip([0]); + let b = TestTensorInt::<2>::from([[10, 20], [30, 40]]); + + let output = a * b; + + output + .into_data() + .assert_eq(&TensorData::from([[30, 80], [30, 80]]), false); +} + +#[test] +fn test_int_mul_flipped_both_axes() { + // [[1, 2], [3, 4]] flipped on both axes -> [[4, 3], [2, 1]] + // * [[5, 5], [5, 5]] = [[20, 15], [10, 5]] + let a = TestTensorInt::<2>::from([[1, 2], [3, 4]]).flip([0, 1]); + let b = TestTensorInt::<2>::from([[5, 5], [5, 5]]); + + let output = a * b; + + output + .into_data() + .assert_eq(&TensorData::from([[20, 15], [10, 5]]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/one_hot.rs b/crates/burn-backend-tests/tests/tensor/int/ops/one_hot.rs new file mode 100644 index 0000000..cd6c0aa --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/one_hot.rs @@ -0,0 +1,59 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn int_should_support_one_hot() { + let tensor = TestTensorInt::<1>::from([0, 1, 4]); + let one_hot_tensor: TestTensorInt<2> = tensor.one_hot(5); + let expected = TensorData::from([[1, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 0, 0, 1]]); + one_hot_tensor.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn int_one_hot_should_panic_when_index_exceeds_number_of_classes() { + let tensor = TestTensorInt::<1>::from([5]); + let _result: TestTensorInt<2> = tensor.one_hot(5); +} + +#[test] +#[should_panic] +fn int_one_hot_should_panic_when_number_of_classes_is_zero() { + let tensor = TestTensorInt::<1>::from([2]); + let _result: TestTensorInt<2> = tensor.one_hot(0); +} + +#[test] +fn one_hot_fill_with_positive_axis_and_indices() { + let tensor = TestTensorInt::<2>::from([[1, 9], [2, 4]]); + let expected = TensorData::from([ + [ + [1, 1], + [3, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 3], + ], + [ + [1, 1], + [1, 1], + [3, 1], + [1, 1], + [1, 3], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + let one_hot_tensor: TestTensorInt<3> = tensor.one_hot_fill(10, 3.0, 1.0, 1); + + one_hot_tensor.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/permute.rs b/crates/burn-backend-tests/tests/tensor/int/ops/permute.rs new file mode 100644 index 0000000..3f008f8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/permute.rs @@ -0,0 +1,49 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn permute_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + let permuted = tensor.clone().permute([2, 1, 0]); + + // from pytorch: + // import torch; torch.arange(0, 24).reshape(2, 3, 4).permute(2, 1, 0) + let expected = TensorData::from([ + [[0, 12], [4, 16], [8, 20]], + [[1, 13], [5, 17], [9, 21]], + [[2, 14], [6, 18], [10, 22]], + [[3, 15], [7, 19], [11, 23]], + ]); + + permuted.into_data().assert_eq(&expected, false); + + // Test with negative axis + let permuted = tensor.clone().permute([-1, 1, 0]); + permuted.into_data().assert_eq(&expected, false); + + // Test with the same axis + let permuted = tensor.clone().permute([0, 1, 2]); + permuted.into_data().assert_eq(&tensor.into_data(), true); +} + +#[test] +#[should_panic] +fn edge_repeated_axes() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with a repeated axis + let _ = tensor.clone().permute([0, 0, 1]); +} + +#[test] +#[should_panic] +fn edge_out_of_bound_axis() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(0..24, &device).reshape([2, 3, 4]); + + // Test with a repeated axis + let _ = tensor.clone().permute([3, 0, 1]); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/random.rs b/crates/burn-backend-tests/tests/tensor/int/ops/random.rs new file mode 100644 index 0000000..83138c1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/random.rs @@ -0,0 +1,18 @@ +use super::*; +use burn_tensor::{Distribution, ElementConversion}; + +#[test] +fn rand_uniform_int() { + let low = 0.; + let high = 5.; + + let tensor = TestTensorInt::<1>::random( + [100_000], + Distribution::Uniform(low, high), + &Default::default(), + ); + + tensor + .into_data() + .assert_within_range::(low.elem()..high.elem()); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/remainder.rs b/crates/burn-backend-tests/tests/tensor/int/ops/remainder.rs new file mode 100644 index 0000000..c0a216e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/remainder.rs @@ -0,0 +1,39 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_int_remainder_basic() { + let data = TensorData::from([-3, -2, -1, 1, 2, 3]); + let device = Default::default(); + let lhs = TestTensorInt::<1>::from_data(data, &device); + + let rhs = TestTensorInt::from_data(TensorData::from([2, 3, 1, 2, 1, 3]), &device); + let output = lhs.remainder(rhs); + let expected = TensorData::from([1, 1, -0, 1, 0, 0]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_remainder_broadcast() { + let device = Default::default(); + let lhs = TestTensorInt::<2>::from_data(TensorData::from([[10, 20, 30]]), &device); + let rhs = TestTensorInt::<2>::from_data(TensorData::from([[7]]), &device); + + let output = lhs.remainder(rhs); + let expected = TensorData::from([[3, 6, 2]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_remainder_basic_scalar() { + let data = TensorData::from([-3, -2, -1, 1, 2, 3]); + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data(data, &device); + + let output = tensor.remainder_scalar(2); + let expected = TensorData::from([1, 0, 1, 1, 0, 1]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/repeat.rs b/crates/burn-backend-tests/tests/tensor/int/ops/repeat.rs new file mode 100644 index 0000000..44e488a --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/repeat.rs @@ -0,0 +1,100 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_int_repeat_ops_one_dimension() { + let data = TensorData::from([[0i32, 1i32, 2i32]]); + let tensor = TestTensorInt::<2>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[4, 1, 1]); + let expected = TensorData::from([ + [0i32, 1i32, 2i32], + [0i32, 1i32, 2i32], + [0i32, 1i32, 2i32], + [0i32, 1i32, 2i32], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_repeat_on_many_dims() { + let data = TensorData::from([ + [[1i32, 2i32], [3i32, 4i32]], + [[5i32, 6i32], [7i32, 8i32]], + [[9i32, 10i32], [11i32, 12i32]], + [[13i32, 14i32], [15i32, 16i32]], + ]); + let tensor = TestTensorInt::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat(&[2, 3, 2]); + + let expected = TensorData::from([ + [ + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + ], + [ + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + ], + [ + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + ], + [ + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + ], + [ + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + [1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32], + ], + [ + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + [5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32], + ], + [ + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + [9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32], + ], + [ + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + [13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/repeat_dim.rs b/crates/burn-backend-tests/tests/tensor/int/ops/repeat_dim.rs new file mode 100644 index 0000000..fd29a32 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/repeat_dim.rs @@ -0,0 +1,46 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_int_repeat_ops() { + let data = TensorData::from([[0, 1, 2]]); + let tensor = TestTensorInt::<2>::from_data(data, &Default::default()); + + let output = tensor.repeat_dim(0, 4); + let expected = TensorData::from([[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_int_repeat_on_dims_larger_than_1() { + let data = TensorData::from([ + [[1i32, 2i32], [3i32, 4i32]], + [[5i32, 6i32], [7i32, 8i32]], + [[9i32, 10i32], [11i32, 12i32]], + [[13i32, 14i32], [15i32, 16i32]], + ]); + let tensor = TestTensorInt::<3>::from_data(data, &Default::default()); + + let output = tensor.repeat_dim(2, 3); + let expected = TensorData::from([ + [ + [1i32, 2i32, 1i32, 2i32, 1i32, 2i32], + [3i32, 4i32, 3i32, 4i32, 3i32, 4i32], + ], + [ + [5i32, 6i32, 5i32, 6i32, 5i32, 6i32], + [7i32, 8i32, 7i32, 8i32, 7i32, 8i32], + ], + [ + [9i32, 10i32, 9i32, 10i32, 9i32, 10i32], + [11i32, 12i32, 11i32, 12i32, 11i32, 12i32], + ], + [ + [13i32, 14i32, 13i32, 14i32, 13i32, 14i32], + [15i32, 16i32, 15i32, 16i32, 15i32, 16i32], + ], + ]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/reshape.rs b/crates/burn-backend-tests/tests/tensor/int/ops/reshape.rs new file mode 100644 index 0000000..ba9dee1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/reshape.rs @@ -0,0 +1,179 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_reshape_maybe_fused_1() { + let tensor = TestTensorInt::arange(0..32, &Default::default()); + let tensor0 = TestTensorInt::zeros([8, 4, 8], &Default::default()); + let tensor1 = tensor.clone().reshape([1, 4, 8]); + let output = tensor0 + tensor1; + + let expected = TensorData::from([ + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + [ + [0, 1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30, 31], + ], + ]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_reshape_maybe_fused_2() { + let tensor = TestTensorInt::<3>::from_data([[[0, 2], [1, 2]]], &Default::default()); + let tensor1 = tensor.reshape([2, 2, 1]); + let tensor2 = TestTensorInt::<3>::full([2, 2, 4], 4, &Default::default()); + let output = tensor2 + tensor1; + + let expected_tensor1 = + TensorData::from([[[4, 4, 4, 4], [6, 6, 6, 6]], [[5, 5, 5, 5], [6, 6, 6, 6]]]); + output.into_data().assert_eq(&expected_tensor1, false); +} + +#[test] +fn should_support_reshape_maybe_fused_3() { + let tensor = TestTensorInt::<3>::from_data([[[0, 2], [1, 2]]], &Default::default()); + let tensor1 = tensor.reshape([2, 2, 1]); + let _tensor2 = TestTensorInt::<3>::full([2, 2, 3], 5, &Default::default()); + + let expected_tensor1 = TensorData::from([[[0], [2]], [[1], [2]]]); + tensor1.into_data().assert_eq(&expected_tensor1, false); +} + +#[test] +fn should_support_reshape_maybe_fused_4() { + let tensor = TestTensorInt::<3>::from_data([[[0, 2], [1, 2]]], &Default::default()); + let tensor2 = TestTensorInt::<3>::full([2, 2, 4], 4, &Default::default()); + let tensor2 = tensor2.swap_dims(0, 1); + let tensor1 = tensor.reshape([2, 2, 1]); + let output = tensor2 + tensor1; + + let expected_tensor1 = + TensorData::from([[[4, 4, 4, 4], [6, 6, 6, 6]], [[5, 5, 5, 5], [6, 6, 6, 6]]]); + output.into_data().assert_eq(&expected_tensor1, false); +} + +#[test] +fn should_support_reshape_maybe_fused_5() { + let tensor = TestTensorInt::<3>::from_data([[[0], [1], [2], [3]]], &Default::default()); + let tensor1 = tensor.clone().reshape([2, 1, 2]); + let tensor2 = TestTensorInt::<3>::full([2, 4, 2], 0, &Default::default()); + let output = tensor2.clone() + tensor1 + tensor.clone(); + + let expected_tensor1 = TensorData::from([ + [[0, 1], [1, 2], [2, 3], [3, 4]], + [[2, 3], [3, 4], [4, 5], [5, 6]], + ]); + output.into_data().assert_eq(&expected_tensor1, false); +} + +#[test] +fn should_support_reshape_maybe_fused_6() { + let device = Default::default(); + + let tensor1 = TestTensorInt::arange(0..32, &device); + let tensor1 = tensor1.reshape([2, 4, 4]); + + let tensor2 = TestTensorInt::arange(0..16, &device); + let tensor2 = tensor2.reshape([1, 4, 4]); + + let tensor3 = TestTensorInt::arange(0..8, &device); + let tensor3 = tensor3.reshape([4, 1, 2]); + let tensor3 = tensor3.swap_dims(0, 2); + + let out = tensor1 + tensor2 + tensor3; + + let expected = TensorData::from([ + [ + [0, 4, 8, 12], + [8, 12, 16, 20], + [16, 20, 24, 28], + [24, 28, 32, 36], + ], + [ + [17, 21, 25, 29], + [25, 29, 33, 37], + [33, 37, 41, 45], + [41, 45, 49, 53], + ], + ]); + out.to_data().assert_eq(&expected, false); +} + +// Skip on metal - cubecl autotune error +// Enable once this issue is fixed: https://github.com/tracel-ai/burn/issues/4327 +#[cfg(not(feature = "metal"))] +#[test] +fn should_support_multiple_reshapes_cloned_tensor() { + let device = Default::default(); + + let lhs = TestTensorInt::<1>::arange(0..4, &device).reshape([2, 2]); + // fusion should preserve correct strides when operating on the same tensor + let rhs = lhs.clone(); + + let lhs = lhs.reshape([2, 2, 1]); + let rhs = rhs.reshape([1, 2, 2]); + + let p = lhs.mul(rhs); + + let s = p.sum_dim(1); + + let out = s.reshape([2, 2]); + + out.into_data() + .assert_eq(&TensorData::from([[2, 3], [6, 11]]), false); +} + +#[test] +fn should_support_reshape_int() { + let data = TensorData::from([0, 1, 2]); + let tensor = TestTensorInt::<1>::from_data(data, &Default::default()); + + let output = tensor.clone().reshape([1, 3]); + let expected = TensorData::from([[0, 1, 2]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/roll.rs b/crates/burn-backend-tests/tests/tensor/int/ops/roll.rs new file mode 100644 index 0000000..e53e928 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/roll.rs @@ -0,0 +1,108 @@ +use super::*; +use burn_tensor::TensorData; + +#[ignore = "0 size resources are not yet supported"] +#[test] +fn test_roll_empty() { + let device = Default::default(); + let input = TestTensorInt::<2>::zeros([12, 0], &device); + + let result = input.clone().roll(&[1, 2], &[0, 1]); + + assert_eq!(&*result.shape(), &[12, 0]); + + // TODO: Rolling an empty tensor should return the same empty tensor; + // but we have no way to compare tensor references yet. +} + +#[test] +fn test_roll() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // No-op shift: + input + .clone() + .roll(&[0, 0], &[0, 1]) + .to_data() + .assert_eq(&input.clone().to_data(), false); + + input + .clone() + .roll(&[1, -1], &[0, 1]) + .to_data() + .assert_eq(&TensorData::from([[5, 3, 4], [2, 0, 1]]), false); + + input + .clone() + .roll(&[-1, 1], &[1, 0]) + .to_data() + .assert_eq(&TensorData::from([[5, 3, 4], [2, 0, 1]]), false); + + input + .clone() + .roll(&[2 * 32 + 1, 3 * (-400) - 1], &[0, 1]) + .to_data() + .assert_eq(&TensorData::from([[5, 3, 4], [2, 0, 1]]), false); +} + +#[should_panic] +#[test] +fn test_roll_dim_too_big() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // Attempting to roll on a dimension that doesn't exist should panic + let _d = input.roll(&[1], &[2]); +} + +#[should_panic] +#[test] +fn test_roll_dim_too_small() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // Attempting to roll on a dimension that doesn't exist should panic + let _d = input.roll(&[1], &[-3]); +} + +#[should_panic] +#[test] +fn test_roll_shift_size_mismatch() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // Attempting to roll with a shift size that doesn't match the number of dimensions should panic + let _d = input.roll(&[1, 2], &[0]); +} + +#[test] +fn test_roll_dim() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + input + .clone() + .roll_dim(1, 0) + .to_data() + .assert_eq(&TensorData::from([[3, 4, 5], [0, 1, 2]]), false); + + input + .clone() + .roll_dim(-1, 1) + .to_data() + .assert_eq(&TensorData::from([[2, 0, 1], [5, 3, 4]]), false); +} + +#[should_panic] +#[test] +fn test_roll_dim_dim_too_big() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // Attempting to roll on a dimension that doesn't exist should panic + let _d = input.roll_dim(1, 2); +} + +#[should_panic] +#[test] +fn test_roll_dim_dim_too_small() { + let input = TestTensorInt::<2>::from([[0, 1, 2], [3, 4, 5]]); + + // Attempting to roll on a dimension that doesn't exist should panic + let _d = input.roll_dim(1, -3); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/select.rs b/crates/burn-backend-tests/tests/tensor/int/ops/select.rs new file mode 100644 index 0000000..ef1d5c4 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/select.rs @@ -0,0 +1,38 @@ +use super::*; +use burn_tensor::{IndexingUpdateOp, TensorData}; + +#[test] +fn should_select_1d_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([5, 6, 7], &device); + let indices = TestTensorInt::from_data([1, 1, 0, 1, 2], &device); + + let output = tensor.select(0, indices); + let expected = TensorData::from([6, 6, 5, 6, 7]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_select_add_1d_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([7, 8, 9], &device); + let values = TestTensorInt::from_data([5, 4, 3, 2, 1], &device); + let indices = TestTensorInt::from_data(TensorData::from([1, 1, 0, 1, 2]), &device); + + let output = tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); + let expected = TensorData::from([10, 19, 10]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn should_panic_select_add_invalid_num_indices() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([0; 12], &device); + let values = TestTensorInt::from_data([1; 12], &device); + let indices = TestTensorInt::from_data(TensorData::from([1]), &device); + + tensor.select_assign(0, indices, values, IndexingUpdateOp::Add); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/sign.rs b/crates/burn-backend-tests/tests/tensor/int/ops/sign.rs new file mode 100644 index 0000000..db4f6f6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/sign.rs @@ -0,0 +1,12 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_sign_ops_int() { + let tensor = TestTensorInt::<2>::from([[-2, -1, 2], [3, 0, -5]]); + + let output = tensor.sign(); + let expected = TensorData::from([[-1, -1, 1], [1, 0, -1]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/slice.rs b/crates/burn-backend-tests/tests/tensor/int/ops/slice.rs new file mode 100644 index 0000000..2e725bf --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/slice.rs @@ -0,0 +1,29 @@ +use super::*; +use burn_tensor::{TensorData, s}; + +#[test] +fn slice_should_not_corrupt_potentially_inplace_operations() { + let tensor = TestTensorInt::<1>::from([1, 2, 3, 4, 5]); + let tensor = tensor.clone().slice([0..3]) + tensor.clone().slice([2..5]); + + let expected = TensorData::from([4, 6, 8]); + + tensor.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_int_tensor_with_steps() { + let device = Default::default(); + let tensor = + TestTensorInt::<2>::from_data([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]], &device); + + // Test step=2 along first dimension + let sliced = tensor.clone().slice([s![0..3;2]]); + let expected = TensorData::from([[1i32, 2, 3, 4], [9, 10, 11, 12]]); + sliced.into_data().assert_eq(&expected, false); + + // Test step=-1 along second dimension + let sliced = tensor.clone().slice(s![.., 0..4;-1]); + let expected = TensorData::from([[4i32, 3, 2, 1], [8, 7, 6, 5], [12, 11, 10, 9]]); + sliced.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/slice_assign.rs b/crates/burn-backend-tests/tests/tensor/int/ops/slice_assign.rs new file mode 100644 index 0000000..5e5b1f6 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/slice_assign.rs @@ -0,0 +1,55 @@ +use super::*; +use burn_tensor::{TensorData, s}; + +#[test] +fn slice_assign_should_not_corrupt_potentially_inplace_operations() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([1, 2, 3, 4, 5], &device); + let values = TestTensorInt::<1>::from_data([10, 20, 30], &device); + let tensor_1 = tensor.clone().slice_assign([0..3], values); + let tensor_2 = tensor + 2; + + let expected = TensorData::from([10, 20, 30, 4, 5]); + + tensor_1.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([3, 4, 5, 6, 7]); + + tensor_2.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_slice_assign_int_tensor_with_steps() { + let device = Default::default(); + let tensor = + TestTensorInt::<2>::from_data([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]], &device); + + // Test step=2 along first dimension + let values = + TestTensorInt::<2>::from_data([[100, 101, 102, 103], [200, 201, 202, 203]], &device); + let output = tensor.clone().slice_assign([s![0..3;2]], values); + let expected = TensorData::from([[100i32, 101, 102, 103], [5, 6, 7, 8], [200, 201, 202, 203]]); + output.into_data().assert_eq(&expected, false); + + // Test step=-1 along second dimension + let values = TestTensorInt::<2>::from_data( + [[40, 30, 20, 10], [80, 70, 60, 50], [120, 110, 100, 90]], + &device, + ); + let output = tensor.slice_assign(s![.., 0..4;-1], values); + let expected = TensorData::from([[10i32, 20, 30, 40], [50, 60, 70, 80], [90, 100, 110, 120]]); + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_slice_assign_empty_range_int() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::from_data([1, 2, 3, 4, 5], &device); + let values: TestTensorInt<1> = TestTensorInt::empty([0], &device); + + // Empty slice assignment for int tensor + let output = tensor.clone().slice_assign([3..3], values); + let expected = TensorData::from([1i32, 2, 3, 4, 5]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/sort_argsort.rs b/crates/burn-backend-tests/tests/tensor/int/ops/sort_argsort.rs new file mode 100644 index 0000000..21322db --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/sort_argsort.rs @@ -0,0 +1,153 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_sort_1d_int() { + // Skip with u8 + if (IntElem::MAX as u32) < 1000u32 { + return; + } + + let tensor = TestTensorInt::<1>::from([1, 4, 7, 2, 5, 6, 3, 0, 9, 8, 2, 8, -10, 42, 1000]); + + // Sort along dim=0 + let values = tensor.sort(0); + let values_expected = TensorData::from([-10, 0, 1, 2, 2, 3, 4, 5, 6, 7, 8, 8, 9, 42, 1000]); + + values.into_data().assert_eq(&values_expected, false); +} + +#[test] +fn test_argsort_1d_int() { + // Skip with u8 + if (IntElem::MAX as u32) < 1000u32 { + return; + } + + let tensor = TestTensorInt::<1>::from([1, 4, 7, 2, 5, 6, 3, 0, 9, 8, -10, 42, 1000]); + + // Sort along dim=0 + let indices = tensor.argsort(0); + let indices_expected = TensorData::from([10, 7, 0, 3, 6, 1, 4, 5, 2, 9, 8, 11, 12]); + + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_with_indices_descending_int() { + // Skip with u8 + if (IntElem::MAX as u32) >= 1000u32 { + // 1D + let tensor = TestTensorInt::<1>::from([1, 4, 7, 2, 5, 6, 3, 0, 9, 8, -10, 42, 1000]); + + // Sort along dim=0 + let (values, indices) = tensor.sort_descending_with_indices(0); + + let values_expected = TensorData::from([1000, 42, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -10]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([12, 11, 8, 9, 2, 5, 4, 1, 6, 3, 0, 7, 10]); + indices.into_data().assert_eq(&indices_expected, false); + } + + // 2D + let tensor = TestTensorInt::<3>::from([[[1, 4, 7], [2, 5, 6]], [[3, 0, 9], [8, 2, 8]]]); + + // Sort along dim=1 + let (values, indices) = tensor.sort_descending_with_indices(1); + + let values_expected = TensorData::from([[[2, 5, 7], [1, 4, 6]], [[8, 2, 9], [3, 0, 8]]]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([[[1, 1, 0], [0, 0, 1]], [[1, 1, 0], [0, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_int() { + let tensor = TestTensorInt::<3>::from([[[1, 4, 7], [2, 5, 6]], [[3, 0, 9], [8, 2, 8]]]); + + // Sort along dim=0 + let values = tensor.clone().sort(0); + + let values_expected = TensorData::from([[[1, 0, 7], [2, 2, 6]], [[3, 4, 9], [8, 5, 8]]]); + values.into_data().assert_eq(&values_expected, false); + + // Sort along dim=1 + let values = tensor.clone().sort(1); + + let values_expected = TensorData::from([[[1, 4, 6], [2, 5, 7]], [[3, 0, 8], [8, 2, 9]]]); + values.into_data().assert_eq(&values_expected, false); + + // Sort along dim=2 + let values = tensor.sort(2); + + let values_expected = TensorData::from([[[1, 4, 7], [2, 5, 6]], [[0, 3, 9], [2, 8, 8]]]); + values.into_data().assert_eq(&values_expected, false); +} + +#[test] +fn test_sort_with_indices_int() { + let tensor = TestTensorInt::<3>::from([[[1, 4, 7], [2, 5, 6]], [[3, 0, 9], [7, 2, 8]]]); + + // Sort along dim=0 + let (values, indices) = tensor.clone().sort_with_indices(0); + + let values_expected = TensorData::from([[[1, 0, 7], [2, 2, 6]], [[3, 4, 9], [7, 5, 8]]]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([[[0, 1, 0], [0, 1, 0]], [[1, 0, 1], [1, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let (values, indices) = tensor.clone().sort_with_indices(1); + + let values_expected = TensorData::from([[[1, 4, 6], [2, 5, 7]], [[3, 0, 8], [7, 2, 9]]]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([[[0, 0, 1], [1, 1, 0]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let (values, indices) = tensor.sort_with_indices(2); + + let values_expected = TensorData::from([[[1, 4, 7], [2, 5, 6]], [[0, 3, 9], [2, 7, 8]]]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([[[0, 1, 2], [0, 1, 2]], [[1, 0, 2], [1, 0, 2]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_argsort_int() { + let tensor = TestTensorInt::<3>::from([[[1, 4, 7], [2, 5, 6]], [[3, 0, 9], [7, 2, 8]]]); + + // Sort along dim=0 + let indices = tensor.clone().argsort(0); + + let indices_expected = TensorData::from([[[0, 1, 0], [0, 1, 0]], [[1, 0, 1], [1, 0, 1]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=1 + let indices = tensor.clone().argsort(1); + + let indices_expected = TensorData::from([[[0, 0, 1], [1, 1, 0]], [[0, 0, 1], [1, 1, 0]]]); + indices.into_data().assert_eq(&indices_expected, false); + + // Sort along dim=2 + let indices = tensor.argsort(2); + + let indices_expected = TensorData::from([[[0, 1, 2], [0, 1, 2]], [[1, 0, 2], [1, 0, 2]]]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_sort_descending_1d() { + let tensor = TestTensorInt::<1>::from([1, 2, 3, 4, 5]); + + // Sort along dim=0 + let values = tensor.sort_descending(0); + + let values_expected = TensorData::from([5, 4, 3, 2, 1]); + values.into_data().assert_eq(&values_expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/stack.rs b/crates/burn-backend-tests/tests/tensor/int/ops/stack.rs new file mode 100644 index 0000000..ebda5ce --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/stack.rs @@ -0,0 +1,33 @@ +use super::*; +use alloc::vec; +use burn_tensor::{Tensor, TensorData}; + +#[test] +fn should_support_stack_ops_int() { + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data([[1, 2, 3]], &device); + let tensor_2 = TestTensorInt::<2>::from_data([[4, 5, 6]], &device); + + let output = Tensor::stack::<3>(vec![tensor_1, tensor_2], 0); + let expected = TensorData::from([[[1, 2, 3]], [[4, 5, 6]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_generate_row_major_layout() { + let device = Default::default(); + let tensor = TestTensorInt::<1>::arange(1..25, &device).reshape([4, 6]); + let zeros = TestTensorInt::zeros([4, 6], &device); + let intersperse = + Tensor::stack::<3>([tensor.clone(), zeros.clone()].to_vec(), 2).reshape([4, 12]); + + let expected = TensorData::from([ + [1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], + [7, 0, 8, 0, 9, 0, 10, 0, 11, 0, 12, 0], + [13, 0, 14, 0, 15, 0, 16, 0, 17, 0, 18, 0], + [19, 0, 20, 0, 21, 0, 22, 0, 23, 0, 24, 0], + ]); + + intersperse.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/sub.rs b/crates/burn-backend-tests/tests/tensor/int/ops/sub.rs new file mode 100644 index 0000000..cd644cd --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/sub.rs @@ -0,0 +1,55 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_sub_ops_int() { + let data_1 = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let data_2 = TensorData::from([[6, 7, 8], [9, 10, 11]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-6, -6, -6], [-6, -6, -6]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_sub_broadcast_int() { + let data_1 = TensorData::from([[0, 1, 2]]); + let data_2 = TensorData::from([[3, 4, 5], [6, 7, 8]]); + let device = Default::default(); + let tensor_1 = TestTensorInt::<2>::from_data(data_1, &device); + let tensor_2 = TestTensorInt::<2>::from_data(data_2, &device); + + let output = tensor_1 - tensor_2; + let expected = TensorData::from([[-3, -3, -3], [-6, -6, -6]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_sub_scalar_ops_int() { + let data = TensorData::from([[0, 1, 2], [3, 4, 5]]); + let scalar = 2; + let tensor = TestTensorInt::<2>::from_data(data, &Default::default()); + + let output = tensor - scalar; + let expected = TensorData::from([[-2, -1, 0], [1, 2, 3]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_int_sub_flipped() { + // [10, 20, 30, 40] flipped - [1, 2, 3, 4] = [39, 28, 17, 6] + let a = TestTensorInt::<1>::from([10, 20, 30, 40]).flip([0]); + let b = TestTensorInt::<1>::from([1, 2, 3, 4]); + + let output = a - b; + + output + .into_data() + .assert_eq(&TensorData::from([39, 28, 17, 6]), false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/take.rs b/crates/burn-backend-tests/tests/tensor/int/ops/take.rs new file mode 100644 index 0000000..d9c28ef --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/take.rs @@ -0,0 +1,31 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_take_int_tensor() { + // Test take with integer tensors + let device = Default::default(); + let tensor = TestTensorInt::<2>::from_data([[10, 20, 30], [40, 50, 60]], &device); + let indices = TestTensorInt::<1>::from_data([1, 0], &device); + + let output = tensor.take::<1, 2>(0, indices); + let expected = TensorData::from([[40, 50, 60], [10, 20, 30]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_take_int_tensor_with_2d_indices() { + // Test take with integer tensors - output will be 3D + let device = Default::default(); + let tensor = TestTensorInt::<2>::from_data([[10, 20, 30], [40, 50, 60], [70, 80, 90]], &device); + + // 2D indices - shape [2, 2] + let indices = TestTensorInt::<2>::from_data([[0, 2], [2, 1]], &device); + let output = tensor.take::<2, 3>(0, indices); + + // Expected: shape [2, 2, 3] + let expected = TensorData::from([[[10, 20, 30], [70, 80, 90]], [[70, 80, 90], [40, 50, 60]]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/topk.rs b/crates/burn-backend-tests/tests/tensor/int/ops/topk.rs new file mode 100644 index 0000000..16b6367 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/topk.rs @@ -0,0 +1,58 @@ +use super::*; +use burn_tensor::{TensorData, Tolerance}; + +#[test] +fn test_topk_with_indices_1d() { + let tensor = TestTensorInt::<1>::from([1, 2, 3, 4, 5]); + + let (values, indices) = tensor.topk_with_indices(3, /*dim*/ 0); + + let values_expected = TensorData::from([5, 4, 3]); + values.into_data().assert_eq(&values_expected, false); + + let indices_expected = TensorData::from([4, 3, 2]); + indices.into_data().assert_eq(&indices_expected, false); +} + +#[test] +fn test_topk_1d() { + // Int + let tensor = TestTensorInt::<1>::from([1, 2, 3, 4, 5]); + + let values = tensor.topk(3, /*dim*/ 0); + let expected = TensorData::from([5, 4, 3]); + + values.into_data().assert_eq(&expected, false); + + // Float + let tensor = TestTensor::<1>::from([1., 2., 3., 4., 5.]); + + let values = tensor.topk(3, /*dim*/ 0); + let expected = TensorData::from([5., 4., 3.]); + + values + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} + +#[test] +fn test_topk() { + // 3D Int + let tensor = TestTensorInt::<3>::from([[[1, 4, 7], [2, 5, 6]], [[3, 0, 9], [8, 2, 8]]]); + + let values = tensor.topk(2, /*dim*/ 2); + let expected = TensorData::from([[[7, 4], [6, 5]], [[9, 3], [8, 8]]]); + + values.into_data().assert_eq(&expected, false); + + // 3D Float + let tensor = + TestTensor::<3>::from([[[1., 4., 7.], [2., 5., 6.]], [[3., 0., 9.], [8., 2., 8.]]]); + + let values = tensor.topk(2, /*dim*/ 2); + let expected = TensorData::from([[[7., 4.], [6., 5.]], [[9., 3.], [8., 8.]]]); + + values + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/transpose.rs b/crates/burn-backend-tests/tests/tensor/int/ops/transpose.rs new file mode 100644 index 0000000..7de0b0d --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/transpose.rs @@ -0,0 +1,28 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn should_support_transpose_ops_int() { + let tensor = TestTensorInt::<3>::from_data( + [[[0, 1, 2], [3, 4, 5]], [[6, 7, 8], [9, 10, 11]]], + &Default::default(), + ); + + let output = tensor.transpose(); + let expected = TensorData::from([[[0, 3], [1, 4], [2, 5]], [[6, 9], [7, 10], [8, 11]]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn should_support_swap_dims_int() { + let tensor = TestTensorInt::<3>::from_data( + [[[0, 1, 2], [3, 4, 5]], [[6, 7, 8], [9, 10, 11]]], + &Default::default(), + ); + + let output = tensor.swap_dims(0, 2); + let expected = TensorData::from([[[0, 6], [3, 9]], [[1, 7], [4, 10]], [[2, 8], [5, 11]]]); + + output.into_data().assert_eq(&expected, false); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/tri.rs b/crates/burn-backend-tests/tests/tensor/int/ops/tri.rs new file mode 100644 index 0000000..c62de88 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/tri.rs @@ -0,0 +1,85 @@ +use super::*; +use burn_tensor::TensorData; + +#[test] +fn test_triu_negative_diagonal() { + let tensor = TestTensorInt::<2>::from([[1, 1, 1], [1, 1, 1], [1, 1, 1]]); + + let output = tensor.triu(-1); + let expected = TensorData::from([[1, 1, 1], [1, 1, 1], [0, 1, 1]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_triu_batch_tensors() { + let tensor = TestTensorInt::<4>::from([ + [[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], + [[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], + ]); + let output = tensor.triu(1); + let expected = TensorData::from([ + [[[0, 1, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], [0, 0, 0, 0]]], + [[[0, 1, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], [0, 0, 0, 0]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn test_triu_too_few_dims() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let _output = tensor.triu(0); +} + +#[test] +fn test_tril() { + let tensor = TestTensor::<2>::from([[1., 1., 1.], [1., 1., 1.], [1., 1., 1.]]); + let output = tensor.tril(0); + let expected = TensorData::from([[1., 0., 0.], [1., 1., 0.], [1., 1., 1.]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_tril_positive_diagonal() { + let tensor = TestTensorInt::<2>::from([[1, 1, 1], [1, 1, 1], [1, 1, 1]]); + + let output = tensor.tril(1); + let expected = TensorData::from([[1, 1, 0], [1, 1, 1], [1, 1, 1]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_tril_negative_diagonal() { + let tensor = TestTensorInt::<2>::from([[1, 1, 1], [1, 1, 1], [1, 1, 1]]); + + let output = tensor.tril(-1); + let expected = TensorData::from([[0, 0, 0], [1, 0, 0], [1, 1, 0]]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +fn test_tril_batch_tensors() { + let tensor = TestTensorInt::<4>::from([ + [[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], + [[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], + ]); + let output = tensor.tril(1); + let expected = TensorData::from([ + [[[1, 1, 0, 0], [1, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 1]]], + [[[1, 1, 0, 0], [1, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 1]]], + ]); + + output.into_data().assert_eq(&expected, false); +} + +#[test] +#[should_panic] +fn test_tril_too_few_dims() { + let tensor = TestTensorInt::<1>::from([1, 2, 3]); + let _output = tensor.tril(0); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/ops/unfold.rs b/crates/burn-backend-tests/tests/tensor/int/ops/unfold.rs new file mode 100644 index 0000000..779d37e --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/ops/unfold.rs @@ -0,0 +1,39 @@ +use super::*; +use burn_tensor::Distribution; +use burn_tensor::s; + +#[test] +fn test_unfold_int() { + // Distribution::Default samples from [0, 255) + if (IntElem::MAX as u32) < 255 - 1 { + return; + } + let device = Default::default(); + + let input = TestTensorInt::<3>::random([2, 6, 6], Distribution::Default, &device); + + let dim = 1; + let size = 3; + let step = 2; + let actual: TestTensorInt<4> = input.clone().unfold(dim, size, step); + + let expected = TestTensorInt::<4>::empty([2, 2, 6, 3], &device) + .slice_assign( + s![.., 0, .., ..], + input + .clone() + .slice(s![.., 0..3, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ) + .slice_assign( + s![.., 1, .., ..], + input + .clone() + .slice(s![.., 2..5, ..]) + .swap_dims(1, 2) + .unsqueeze_dim::<4>(1), + ); + + actual.to_data().assert_eq(&expected.to_data(), true); +} diff --git a/crates/burn-backend-tests/tests/tensor/int/primitive.rs b/crates/burn-backend-tests/tests/tensor/int/primitive.rs new file mode 100644 index 0000000..99ce4f8 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/int/primitive.rs @@ -0,0 +1,13 @@ +use super::*; +use burn_tensor::{Element, Shape}; + +#[test] +fn should_support_int_dtype() { + let tensor = TestTensorInt::<2>::from([[0, -1, 2], [3, 4, -5]])/*.into_primitive()*/; + + assert_eq!(tensor.shape(), Shape::new([2, 3])); + assert_eq!( + tensor.dtype(), + IntElem::dtype() // default int elem type + ); +} diff --git a/crates/burn-backend-tests/tests/tensor/mod.rs b/crates/burn-backend-tests/tests/tensor/mod.rs new file mode 100644 index 0000000..7cb2ee0 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/mod.rs @@ -0,0 +1,12 @@ +pub use super::*; // re-export test types + +mod clone_invariance; +#[cfg(feature = "distributed")] +mod distributed; +#[cfg(feature = "std")] +mod multi_threads; + +// Data types +mod bool; +mod float; +mod int; diff --git a/crates/burn-backend-tests/tests/tensor/multi_threads.rs b/crates/burn-backend-tests/tests/tensor/multi_threads.rs new file mode 100644 index 0000000..8710947 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor/multi_threads.rs @@ -0,0 +1,171 @@ +use super::*; +use core::time::Duration; +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +struct MultiThreadTestSettings { + num_threads: usize, + // The number of operations that are applied while the tensor is still alive and has a + // reference count > 1 on the new thread. + num_ops_alive: usize, + // The number of operations that are applied after the tensor is consumed for the last time. + num_ops_consumed: usize, + // Number of operations that needs to execute before continuing execution on the main thread. + sleep_before: Duration, + sleep_alive: Duration, + sleep_consumed: Duration, + // If the output is dropped, otherwise it will be consumed by an operation. + dropped: bool, +} + +#[test] +fn should_handle_multi_threads_dropped() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 5, + num_ops_consumed: 5, + sleep_before: Duration::from_millis(100), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: true, + }) +} + +#[test] +fn should_handle_multi_threads_consumed() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 5, + num_ops_consumed: 5, + sleep_before: Duration::from_millis(100), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: false, + }) +} + +#[test] +fn should_handle_multi_threads_drop_no_wait() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 5, + num_ops_consumed: 5, + sleep_before: Duration::from_millis(100), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: true, + }) +} + +#[test] +fn should_handle_multi_threads_consumed_no_wait() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 5, + num_ops_consumed: 5, + sleep_before: Duration::from_millis(100), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: false, + }) +} + +#[test] +fn should_handle_multi_threads_no_async_op() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 0, + num_ops_consumed: 0, + sleep_before: Duration::from_millis(100), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: false, + }) +} + +// Skip test - flaky (works when ran alone) +// Enable once this issue is fixed: https://github.com/tracel-ai/burn/issues/4328 +#[ignore = "Flaky test"] +#[test] +fn should_handle_multi_threads_no_async_op_no_wait() { + run_multi_thread_test(MultiThreadTestSettings { + num_threads: 3, + num_ops_alive: 0, + num_ops_consumed: 0, + sleep_before: Duration::from_millis(0), + sleep_alive: Duration::from_millis(100), + sleep_consumed: Duration::from_millis(100), + dropped: false, + }) +} + +fn run_multi_thread_test(settings: MultiThreadTestSettings) { + let tensor = TestTensor::<2>::from([[0.0, -1.0, 2.0], [3.0, 4.0, -5.0]]); + + let mut joined = Vec::with_capacity(settings.num_threads); + + let counter_alive = Arc::new(AtomicU32::new(0)); + let counter_consumed = Arc::new(AtomicU32::new(0)); + + for i in 0..settings.num_threads { + let tensor_moved = tensor.clone(); + let ca_moved = counter_alive.clone(); + let cc_moved = counter_consumed.clone(); + + let handle = std::thread::spawn(move || { + let mut base = tensor_moved.clone(); + std::thread::sleep(settings.sleep_before); + + if settings.num_ops_alive == 0 && settings.num_ops_consumed == 0 { + core::mem::drop(tensor_moved); + core::mem::drop(base); + } else { + if settings.num_ops_alive > 1 { + for j in 0..(settings.num_ops_alive - 1) { + base = tensor_moved.clone() + j as u32; + ca_moved.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(settings.sleep_alive); + } + } + + base = base * tensor_moved + i as u32; + ca_moved.fetch_add(1, Ordering::Relaxed); + + for n in 0..settings.num_ops_consumed { + base = base + n as i32; + cc_moved.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(settings.sleep_consumed); + } + let _data = base.into_data(); + } + }); + joined.push(handle); + } + + fn wait(counter: Arc, limit: usize) { + loop { + let counter_curr = counter.load(Ordering::Relaxed); + if counter_curr as usize >= limit { + break; + } else { + std::thread::sleep(Duration::from_millis(10)); + } + } + } + + wait(counter_alive, settings.num_ops_alive); + wait(counter_consumed, settings.num_ops_consumed); + + if settings.dropped { + core::mem::drop(tensor); + } else { + let t = tensor * 2.0; + let _t = t.into_data(); + } + + for j in joined { + j.join().unwrap(); + } +} diff --git a/crates/burn-backend-tests/tests/tensor_f16.rs b/crates/burn-backend-tests/tests/tensor_f16.rs new file mode 100644 index 0000000..1d3d4e1 --- /dev/null +++ b/crates/burn-backend-tests/tests/tensor_f16.rs @@ -0,0 +1,29 @@ +// //! Burn backend tensor tests. + +#![recursion_limit = "256"] +#![cfg(any( + feature = "vulkan", + feature = "cuda", + feature = "rocm", + feature = "metal", + feature = "flex" +))] + +extern crate alloc; + +pub type FloatElem = burn_tensor::f16; +#[allow(unused)] +pub type IntElem = i32; + +#[path = "common/backend.rs"] +mod backend; +pub use backend::*; + +#[path = "tensor/float/mod.rs"] +mod f16; + +#[cfg(feature = "fusion")] +#[path = "fusion/mod.rs"] +mod fusion; + +// TODO: bf16 (vulkan only supports bf16 for matmul, metal/wgpu doesn't support bf16) diff --git a/crates/burn-backend/Cargo.toml b/crates/burn-backend/Cargo.toml new file mode 100644 index 0000000..b474036 --- /dev/null +++ b/crates/burn-backend/Cargo.toml @@ -0,0 +1,58 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Core backend interfaces and data structures for executing tensor operations in Burn." +documentation = "https://docs.rs/burn-backend" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-backend" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-backend" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +doc = ["default"] +std = ["rand/std", "num-traits/std", "burn-std/std", "cubecl?/std", "dep:oneshot"] + +tracing = ["burn-std/tracing", "cubecl/tracing"] + +# For DTypeUsage de/serialization +serde = ["enumset/serde"] + +cubecl = ["dep:cubecl"] +cubecl-cuda = ["cubecl", "cubecl/cuda"] +cubecl-hip = ["cubecl", "cubecl/hip"] +cubecl-wgpu = ["cubecl", "cubecl/wgpu"] +cubecl-metal = ["cubecl", "cubecl/metal"] +cubecl-vulkan = ["cubecl", "cubecl/vulkan"] +cubecl-webgpu = ["cubecl", "cubecl/webgpu"] +cubecl-cpu = ["cubecl", "cubecl/cpu"] + +[dependencies] +burn-std = { workspace = true } +cubecl = { workspace = true, optional = true, default-features = false } + +bytemuck = { workspace = true, features = ["extern_crate_alloc"] } +derive-new = { workspace = true } +enumset = { workspace = true } +hashbrown = { workspace = true } +num-traits = { workspace = true } +rand = { workspace = true, default-features = false } +rand_distr = { workspace = true } +serde = { workspace = true } +oneshot = { version = "0.2.1", optional = true } +spin = { workspace = true } + +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +portable-atomic-util = { workspace = true } + +[dev-dependencies] +rand = { workspace = true, features = ["thread_rng"] } +paste = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } +serial_test = { workspace = true } diff --git a/crates/burn-backend/README.md b/crates/burn-backend/README.md new file mode 100644 index 0000000..e79c1b5 --- /dev/null +++ b/crates/burn-backend/README.md @@ -0,0 +1,4 @@ +# Burn Backend + +This crate includes the core backend interfaces and data structures for executing tensor operations +in Burn. diff --git a/crates/burn-backend/src/alias.rs b/crates/burn-backend/src/alias.rs new file mode 100644 index 0000000..54223f7 --- /dev/null +++ b/crates/burn-backend/src/alias.rs @@ -0,0 +1,20 @@ +/// Backend type aliases. +pub mod tensor { + use crate::backend::BackendTypes; + // We provide some type aliases to improve the readability of using associated types without + // having to use the disambiguation syntax. + + pub use burn_std::ops::IndexingUpdateOp; + + /// Device type used by the backend. + pub type Device = ::Device; + + /// Float tensor primitive type used by the backend. + pub type FloatTensor = ::FloatTensorPrimitive; + /// Integer tensor primitive type used by the backend. + pub type IntTensor = ::IntTensorPrimitive; + /// Boolean tensor primitive type used by the backend. + pub type BoolTensor = ::BoolTensorPrimitive; + /// Quantized tensor primitive type used by the backend. + pub type QuantizedTensor = ::QuantizedTensorPrimitive; +} diff --git a/crates/burn-backend/src/backend/base.rs b/crates/burn-backend/src/backend/base.rs new file mode 100644 index 0000000..c3feb4a --- /dev/null +++ b/crates/burn-backend/src/backend/base.rs @@ -0,0 +1,469 @@ +use burn_std::DType; +pub use burn_std::{ExecutionError, backtrace::BackTrace}; + +use crate::distributed::DistributedOps; +pub use crate::element::Element; +use crate::ops::*; +use crate::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}; +use crate::{TensorData, TensorMetadata}; +use alloc::string::String; +use enumset::{EnumSet, EnumSetType}; + +use crate::distributed::{DistributedParamId, DistributedParams}; + +use super::DeviceOps; + +/// The mapping of types used by Backend and traits. +pub trait BackendTypes: Clone + Send + Sync + core::fmt::Debug + 'static { + /// Device type. + type Device: DeviceOps; + + /// Tensor primitive to be used for all float operations. + type FloatTensorPrimitive: TensorMetadata + 'static; + + /// Tensor primitive to be used for all int operations. + type IntTensorPrimitive: TensorMetadata + 'static; + + /// Tensor primitive to be used for all bool operations. + type BoolTensorPrimitive: TensorMetadata + 'static; + + /// Tensor primitive to be used for all quantized operations. + type QuantizedTensorPrimitive: TensorMetadata + 'static; + + /// Captured graph primitive returned by [`Backend::graph_stop_capture`] and + /// consumed by [`Backend::graph_replay`]: a backend-owned recording of a + /// launch sequence that replays as a single dispatch. + /// + /// Backends without graph-capture support use [`GraphUnsupported`], an + /// uninhabited type — their capture methods only ever error, so no value of + /// it can exist. + type GraphPrimitive: Clone + Send + Sync + core::fmt::Debug + 'static; +} + +/// Captured graph primitive type used by the backend (see +/// [`BackendTypes::GraphPrimitive`]). +pub type BackendGraph = ::GraphPrimitive; + +/// Placeholder [graph primitive](BackendTypes::GraphPrimitive) for backends +/// without graph-capture support. +/// +/// Uninhabited: `graph_stop_capture` on such backends always errors, so a value +/// of this type can never be constructed (and `graph_replay` can never be called). +#[derive(Debug, Clone, Copy)] +pub enum GraphUnsupported {} + +/// The error returned by the default (unsupported) graph-capture methods. +fn graph_unsupported() -> ExecutionError { + ExecutionError::Generic { + reason: alloc::string::String::from("graph capture is not supported by this backend"), + backtrace: BackTrace::capture(), + } +} + +/// This trait defines all types and functions needed for a backend to be used with burn. +/// +/// ## Design +/// +/// This trait aims to be as unopinionated as possible and allows implementations to define +/// their own types and patterns. Therefore, there are few pre-defined abstractions baked +/// into this trait. +/// +/// Backends must define their own tensor types for each data type: `float`, `int`, and `bool`. +/// Since we minimize assumptions, we chose to separate these types, as they are used in +/// different contexts. However, some backends may have a generic tensor type that is used +/// for all data types. +/// +/// ### Eager Mode +/// +/// Because burn supports dynamic graphs, the backend trait is designed around kernel +/// implementations that can be called without any mutable context or graph. This may not be +/// ideal for backends that want to configure their computational graphs and execute them +/// multiple times. +/// +/// To implement this kind of backend, channels could be used to communicate with a backend +/// server thread to build the computation graphs and re-execute the ones that are repeated, +/// with some form of cache. Once that pattern has matured, a graph mode backend trait could +/// be extracted from it, allowing other backends of the same kind to be quickly integrated +/// with burn. This pattern could also be used to create an operation fusion trait, which +/// allows backends to define what kind of graph structures can be fused into one operation. +/// +/// ### Multi-Threaded +/// +/// Backend tensor types are all `Clone` + `Send`, which allows them to be safely +/// sent between threads. It is recommended to wrap tensors with [Arc](alloc::sync::Arc), +/// which avoids copying the tensor's buffer. Note that it is still possible to mutate and +/// reuse tensors' buffer without locking; see the next section on the Mutable API. +/// +/// ### Mutable API +/// +/// There is no mutable or inplace operation API to implement, but that does not mean that +/// backends cannot support them. Using [try_unwrap](alloc::sync::Arc::try_unwrap) and +/// [get_mut](alloc::sync::Arc::get_mut) allows backends to have access to an owned or mutable +/// reference to their tensor buffer data structure if the tensor is not shared. In that case, +/// backends can dispatch to their owned inplace operations for better performance. +/// +/// ## Documentation +/// +/// Most of the documentation for each function can be found on the user API +#[cfg_attr(doc, doc = crate::doc_tensor!())] +#[cfg_attr(not(doc), doc = "`Tensor`")] +/// struct in the `burn-tensor` crate. +/// For modules, public functions are often created, which can be used by `burn-core` modules. +pub trait Backend: + BackendTypes + + FloatTensorOps + + BoolTensorOps + + IntTensorOps + + ModuleOps + + ActivationOps + + QTensorOps + + TransactionOps + + DistributedOps + + Clone + + Default + + Sized + + Send + + Sync + + core::fmt::Debug + + 'static +{ + /// If autodiff is enabled. + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + /// Sets the current allocation mode to persistent. + #[allow(unused_variables)] + fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + device: &Self::Device, + input: Input, + func: Func, + ) -> Output { + func(input) + } + + /// Manually triggers a memory cleanup on the given device. + #[allow(unused_variables)] + fn memory_cleanup(device: &Self::Device) {} + + /// Name of the backend. + fn name(device: &Self::Device) -> String; + + /// Seeds the backend on the specified device. + /// + /// There is no guarantee that only the specified device will be seeded, but it is guaranteed + /// that at least the specified device will be seeded. + /// + /// In all cases, this should ensure deterministic execution for a single-threaded program. + fn seed(device: &Self::Device, seed: u64); + + /// Sync the backend, ensure that all computation are finished. + fn sync(_device: &Self::Device) -> Result<(), ExecutionError> { + Ok(()) + } + + /// Prepare `device` for an upcoming graph capture: route allocations into a + /// stable pool so every buffer allocated before graph_stop_capture can + /// be pinned. Call before the warmup run. No-op by default. + /// + /// See [`burn_graph`](crate) — the closure-based `capture` helper drives + /// this whole sequence. + fn graph_prepare(_device: &Self::Device) -> Result<(), ExecutionError> { + Ok(()) + } + + /// Begin recording launches on `device` into a graph (see + /// [`graph_stop_capture`](Backend::graph_stop_capture)). Errors on backends + /// without hardware graph support, so callers fall back to re-running. + fn graph_start_capture(_device: &Self::Device) -> Result<(), ExecutionError> { + Err(graph_unsupported()) + } + + /// Stop recording and return the captured [graph](BackendTypes::GraphPrimitive), + /// ready to [`graph_replay`](Backend::graph_replay). + fn graph_stop_capture(_device: &Self::Device) -> Result, ExecutionError> { + Err(graph_unsupported()) + } + + /// Replay a captured [graph](BackendTypes::GraphPrimitive) — one dispatch + /// re-running the recorded launches against their original buffers. + /// + /// # Safety + /// + /// The replay dispatches raw device work against the exact buffers recorded + /// at capture time, with nothing tracking whether those buffers are still + /// valid. The caller must guarantee, for every tensor the captured closure + /// read or wrote: + /// + /// - its buffer is still alive — no tensor referenced by the graph has been + /// freed (and its memory possibly reallocated) since capture; + /// - it is not concurrently read or written by work on another stream or + /// thread while the replay executes; + /// - input refreshes and output reads are issued on the stream the graph + /// was captured on, so they order correctly against the replay. + unsafe fn graph_replay( + _device: &Self::Device, + _graph: &BackendGraph, + ) -> Result<(), ExecutionError> { + Err(graph_unsupported()) + } + + /// Flush any pending operation of the backend. + fn flush(_device: &Self::Device); + + /// Marks the given data as being used as a staging buffer for transfer between CPU and + /// accelerators like GPUs. + /// + /// The given data might be transferred to pinned memory or another format to improve data transfer + /// speed. + fn staging<'a, Iter>(_data: Iter, _device: &Self::Device) + where + Iter: Iterator, + { + } + + /// Whether the type is fully supported by the specified device for general operations. + /// + /// A type is considered supported if it can be used for the full suite of tensor + /// operations, including storage, conversion, and basic arithmetic. + /// + /// Returning `false` does not necessarily mean the device cannot handle the type at all. + /// For instance, a device might support a type only for specialized hardware + /// acceleration (e.g., matrix multiplication) but lack general arithmetic support. Such + /// types should return `false` here as they are not globally supported. + fn supports_dtype(device: &Self::Device, dtype: DType) -> bool { + Self::dtype_usage(device, dtype).is_superset(DTypeUsage::general()) + } + + /// Returns the [DTypeUsageSet] for the given [DType] on the specified device. + fn dtype_usage(device: &Self::Device, dtype: DType) -> DTypeUsageSet; + + /// Returns the number of devices available on this backend. + /// `device` is a reference device used to determine the underlying backend that should be queried. + /// A CUDA device will return all devices available to CUDA, a Vulkan device will return all + /// devices available to Vulkan, etc. + fn device_count(type_id: u16) -> usize; +} + +/// Trait that allows a backend to support autodiff. +pub trait AutodiffBackend: Backend { + /// The inner backend type. + type InnerBackend: Backend; + + /// Gradients type. + type Gradients: Send; + + /// Backward pass. + /// + /// # Arguments + /// + /// * `tensor` - The tensor is the last node of computational graph where the gradients are computed. + /// + /// # Returns + /// + /// The gradients. + fn backward(tensor: FloatTensor) -> Self::Gradients; + + /// Returns the gradients of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to extract the gradients from. + /// + /// # Returns + /// + /// An optional tensor containing the gradient. + fn grad( + tensor: &FloatTensor, + grads: &Self::Gradients, + ) -> Option>; + + /// Pops the gradients of a tensor and returns them. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to pop the gradients from. + /// * `grads` - The gradients. + /// + /// # Returns + /// + /// An optional tensor containing the given gradients. + fn grad_remove( + tensor: &FloatTensor, + grads: &mut Self::Gradients, + ) -> Option>; + + /// Replace the gradients of a tensor with the one provided. + /// + /// If no gradient existed for the provided tensor, register it. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to pop the gradients from. + /// * `grads` - The gradients. + /// * `grad` - The updated grad tensor. + fn grad_replace( + tensor: &FloatTensor, + grads: &mut Self::Gradients, + grad: FloatTensor, + ); + + /// Returns the tensor with inner backend type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the inner backend tensor for. + /// + /// # Returns + /// + /// The inner backend tensor. + fn inner(tensor: FloatTensor) -> FloatTensor; + + /// Returns the tensor with inner backend type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the inner backend tensor for. + /// + /// # Returns + /// + /// The inner backend tensor. + fn int_inner(tensor: IntTensor) -> IntTensor; + + /// Returns the tensor with inner backend type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the inner backend tensor for. + /// + /// # Returns + /// + /// The inner backend tensor. + fn bool_inner(tensor: BoolTensor) -> BoolTensor; + + /// Returns the tensor with inner backend type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the inner backend tensor for. + /// + /// # Returns + /// + /// The inner backend tensor. + fn q_inner(tensor: QuantizedTensor) -> QuantizedTensor; + + /// Converts the inner backend tensor to the autodiff backend tensor. + /// + /// # Arguments + /// + /// * `tensor` - The inner backend tensor to convert. + /// + /// + /// # Returns + /// + /// The autodiff backend tensor. + fn from_inner(tensor: FloatTensor) -> FloatTensor; + + /// Converts the inner backend tensor to the autodiff backend tensor. + /// + /// # Arguments + /// + /// * `tensor` - The inner backend tensor to convert. + /// + /// + /// # Returns + /// + /// The autodiff backend tensor. + fn int_from_inner(tensor: IntTensor) -> IntTensor; + + /// Converts the inner backend tensor to the autodiff backend tensor. + /// + /// # Arguments + /// + /// * `tensor` - The inner backend tensor to convert. + /// + /// + /// # Returns + /// + /// The autodiff backend tensor. + fn bool_from_inner(tensor: BoolTensor) -> BoolTensor; + + /// Converts the inner backend tensor to the autodiff backend tensor. + /// + /// # Arguments + /// + /// * `tensor` - The inner backend tensor to convert. + /// + /// + /// # Returns + /// + /// The autodiff backend tensor. + fn q_from_inner(tensor: QuantizedTensor) -> QuantizedTensor; + + /// Mark the tensor as distributed across multiple devices. + /// The gradients will be aggregated during the backward pass. + /// + /// This function does nothing when distributed training is not available. + fn set_distributed_params( + tensor: FloatTensor, + _param_id: DistributedParamId, + ) -> FloatTensor { + tensor + } + + /// Returns the distributed parameters if the tensor was marked as distributed. + fn distributed_params(_tensor: &FloatTensor) -> Option { + None + } + + /// Returns true if the tensor was marked as distributed. + fn is_distributed(_tensor: &FloatTensor) -> bool { + false + } +} + +/// Describes how a data type can be used on a given device. +/// +/// A data type may be supported for different classes of operations. Not all +/// data types that appear in hardware or kernel implementations are suitable +/// for general-purpose tensor operations. +#[derive(Debug, EnumSetType)] +pub enum DTypeUsage { + /// The type can be stored in device memory and converted to and from + /// other supported data types. + Storage, + /// The type supports general-purpose arithmetic and common tensor + /// operations (e.g. elementwise ops, reductions, etc.). + Arithmetic, + /// The type is supported by hardware-accelerated execution paths. + /// + /// This typically indicates support for accelerator-backed compute units (e.g., tensor + /// cores executing MMA instructions) for high-performance operations such as matrix + /// multiplication and operations that lower to it. + /// + /// # Notes + /// - A type can be both [`Arithmetic`](DTypeUsage::Arithmetic) and + /// [`Accelerated`](DTypeUsage::Accelerated) if it supports general-purpose operations + /// *and* accelerated paths. + /// - If a type is marked as `Accelerated` but not `Arithmetic`, it is not + /// suitable for general-purpose tensor operations and may only be used + /// in specific accelerated operations. + /// + /// `Accelerated` is a **flag**, not a detailed descriptor. It does not enumerate which + /// operations are accelerated or which accelerator features are available. + Accelerated, +} + +/// A set of [DTypeUsage] representing the total capabilities of a data type on a device. +pub type DTypeUsageSet = EnumSet; + +impl DTypeUsage { + /// Returns the usage set required for general-purpose tensor support. + pub fn general() -> DTypeUsageSet { + DTypeUsage::Storage | DTypeUsage::Arithmetic + } +} diff --git a/crates/burn-backend/src/backend/device.rs b/crates/burn-backend/src/backend/device.rs new file mode 100644 index 0000000..ec96807 --- /dev/null +++ b/crates/burn-backend/src/backend/device.rs @@ -0,0 +1,403 @@ +pub use burn_std::device::*; +use burn_std::{BoolDType, DType, FloatDType, IntDType}; +pub use burn_std::{DeviceError, DeviceSettings}; + +use burn_std::stub::RwLock; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use core::any::TypeId; + +#[cfg(feature = "std")] +pub use std::collections::HashMap; +#[cfg(feature = "std")] +use std::sync::{LazyLock, OnceLock}; + +#[cfg(not(feature = "std"))] +pub use hashbrown::HashMap; +#[cfg(not(feature = "std"))] +use spin::{Lazy as LazyLock, Once as OnceLock}; + +use crate::Backend; + +/// Device trait for all burn backend devices. +pub trait DeviceOps: Clone + Default + PartialEq + Send + Sync + core::fmt::Debug + Device { + /// Returns the [device id](DeviceId). + fn id(&self) -> DeviceId { + self.to_id() + } + + /// Returns the default [settings](DeviceSettings) for this device. + fn defaults(&self) -> DeviceSettings; +} + +/// Key for the registry: physical device type + device id +type RegistryKey = (DeviceId, TypeId); + +/// Global registry mapping devices to their settings. +/// +/// Each value is wrapped in a `OnceLock` to enforce that settings are initialized only once +/// per device. +static REGISTRY: LazyLock>>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +struct DeviceSettingsRegistry; + +impl DeviceSettingsRegistry { + /// Returns the settings for the given device, inserting the default if absent. + fn get_or_insert( + device: &D, + default_fn: impl FnOnce() -> DeviceSettings, + ) -> DeviceSettings { + let key = Self::key(device); + #[cfg(feature = "std")] + { + let cached = LOCAL_CACHE.with(|cache| cache.borrow().get(&key).copied()); + if let Some(settings) = cached { + return settings; + } + + // Entry does not exist in cache + let settings = { + let read = REGISTRY.read().unwrap(); + read.get(&key).cloned() + } + .unwrap_or_else(|| { + let mut map = REGISTRY.write().unwrap(); + Arc::clone(map.entry(key).or_default()) + }); + + let settings = *settings.get_or_init(default_fn); + + LOCAL_CACHE.with(|cache| { + cache.borrow_mut().insert(key, settings); + }); + + settings + } + #[cfg(not(feature = "std"))] + { + let settings = { + let read = REGISTRY.read().unwrap(); + read.get(&key).cloned() + } + .unwrap_or_else(|| { + let mut map = REGISTRY.write().unwrap(); + Arc::clone(map.entry(key).or_default()) + }); + + settings.call_once(default_fn); + *settings.get().unwrap() + } + } + + /// Initializes the settings for the given device. + /// + /// Returns `Err` with the existing settings if already initialized. + fn init(device: &D, settings: DeviceSettings) -> Result<(), DeviceError> { + let key = Self::key(device); + let mut map = REGISTRY.write().unwrap(); + let cell = map.entry(key).or_insert_with(|| Arc::new(OnceLock::new())); + + #[cfg(feature = "std")] + return cell + .set(settings) + .map_err(|_| DeviceError::already_initialized(device)); + + #[cfg(not(feature = "std"))] + if cell.get().is_some() { + Err(DeviceError::already_initialized(device)) + } else { + cell.call_once(|| settings); + Ok(()) + } + } + + /// Returns the device registry key. + fn key(device: &D) -> RegistryKey { + (device.to_id(), TypeId::of::()) + } +} + +#[cfg(feature = "std")] +thread_local! { + /// Thread-local cache access to initialized device settings is lock-free. + static LOCAL_CACHE: core::cell::RefCell> = + core::cell::RefCell::new(HashMap::new()); +} + +/// Get the [`device`'s settings](DeviceSettings). +pub fn get_device_settings(device: &B::Device) -> DeviceSettings { + DeviceSettingsRegistry::get_or_insert(device, || device.defaults()) +} + +fn check_dtype_support( + device: &B::Device, + dtype: impl Into, +) -> Result<(), DeviceError> { + let dtype = dtype.into(); + // Default dtypes should have `DTypeUsage::general()`. Types restricted to specialized + // operations should not be used as default. + if B::supports_dtype(device, dtype) { + Ok(()) + } else { + Err(DeviceError::unsupported_dtype(device, dtype)) + } +} + +/// Sets the default data types for the device. +/// +/// This updates the device's default data types used for tensor creation. +/// +/// Settings can only be initialized once per device. Subsequent calls for +/// the same device return [`DeviceError::AlreadyInitialized`]. +/// +/// # Note +/// +/// Initialization must happen before any tensor creation on the device. +/// The first tensor operation will lock the device to its defaults, causing +/// any subsequent initialization attempt to return [`DeviceError::AlreadyInitialized`]. +/// +/// # Example +/// +/// ```rust, ignore +/// fn example() { +/// let device = B::Device::default(); +/// +/// // Update the device settings +/// set_default_dtypes::(&device, DType::F16, DType::I32); +/// +/// // All float tensors created after this will use F16 by default +/// let tensor = Tensor::::zeros([2, 3], &device); +/// // All int tensors created after this will use I32 default +/// let tensor = Tensor::::zeros([2, 3], &device); +/// } +/// ``` +pub fn set_default_dtypes( + device: &B::Device, + float_dtype: impl Into, + int_dtype: impl Into, + bool_dtype: impl Into, +) -> Result<(), DeviceError> { + let float_dtype = float_dtype.into(); + let int_dtype = int_dtype.into(); + let bool_dtype = bool_dtype.into(); + check_dtype_support::(device, float_dtype)?; + check_dtype_support::(device, int_dtype)?; + check_dtype_support::(device, bool_dtype)?; + + let q_config = device.defaults().quantization; + let settings = DeviceSettings::new(float_dtype, int_dtype, bool_dtype, q_config); + + initialize_unchecked(device, settings)?; + Ok(()) +} + +// Unchecked dtypes +fn initialize_unchecked( + device: &D, + settings: DeviceSettings, +) -> Result<(), DeviceError> { + DeviceSettingsRegistry::init(device, settings) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use serial_test::serial; + + use super::*; + + fn clear_registry() { + REGISTRY.write().unwrap().clear(); + } + + #[derive(Clone, Debug, Default, PartialEq, new)] + pub struct TestDeviceA { + index: u16, + } + + impl Device for TestDeviceA { + fn from_id(device_id: DeviceId) -> Self { + Self { + index: device_id.index_id, + } + } + + fn to_id(&self) -> DeviceId { + DeviceId { + type_id: 0, + index_id: self.index, + } + } + } + + impl DeviceOps for TestDeviceA { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::with_dtypes(FloatDType::F32, IntDType::I32, BoolDType::Native) + } + } + + #[derive(Clone, Debug, Default, PartialEq, new)] + pub struct TestDeviceB { + index: u16, + } + + impl Device for TestDeviceB { + fn from_id(device_id: DeviceId) -> Self { + Self { + index: device_id.index_id, + } + } + + fn to_id(&self) -> DeviceId { + DeviceId { + type_id: 0, + index_id: self.index, + } + } + } + + impl DeviceOps for TestDeviceB { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::with_dtypes(FloatDType::F32, IntDType::I32, BoolDType::Native) + } + } + + fn get_test_device_settings(device: &D) -> DeviceSettings { + DeviceSettingsRegistry::get_or_insert(device, || device.defaults()) + } + + #[test] + #[serial] + fn default_settings_returned_when_uninitialized() { + clear_registry(); // reset registry for each test + + let device = TestDeviceA::new(0); + + let s1 = get_test_device_settings(&device); + let s2 = get_test_device_settings(&device); + + assert_eq!(s1, s2); + assert_eq!(s1, device.defaults()); + } + + #[test] + #[serial] + fn initialized_settings_are_returned() { + clear_registry(); // reset registry for each test + + let device = TestDeviceA::new(0); + let settings = + DeviceSettings::with_dtypes(FloatDType::BF16, IntDType::I32, BoolDType::Native); + + initialize_unchecked(&device, settings).unwrap(); + let s1 = get_test_device_settings(&device); + let s2 = get_test_device_settings(&device); + + assert_eq!(s1, s2); + assert_eq!(s1, settings); + assert_eq!(s2, settings); + } + + #[test] + #[serial] + fn settings_are_device_id_specific() { + clear_registry(); // reset registry for each test + + let d1 = TestDeviceA::new(0); + let d2 = TestDeviceA::new(1); + let settings = + DeviceSettings::with_dtypes(FloatDType::F16, IntDType::I64, BoolDType::Native); + + initialize_unchecked(&d1, settings).unwrap(); + + let s1 = get_test_device_settings(&d1); + let s2 = get_test_device_settings(&d2); + + assert_ne!(s1, s2); + assert_eq!(s1, settings); + assert_eq!(s2, d2.defaults()); + } + + #[test] + #[serial] + fn settings_are_device_type_specific() { + clear_registry(); // reset registry for each test + + let d1 = TestDeviceA::new(0); + let d2 = TestDeviceB::new(0); + let settings = + DeviceSettings::with_dtypes(FloatDType::F16, IntDType::I64, BoolDType::Native); + + initialize_unchecked(&d2, settings).unwrap(); + + let s1 = get_test_device_settings(&d1); + let s2 = get_test_device_settings(&d2); + + assert_ne!(s1, s2); + assert_eq!(s1, d1.defaults()); + assert_eq!(s2, settings); + } + + #[test] + #[serial] + fn initialization_after_default_returns_error() { + clear_registry(); // reset registry for each test + + let device = TestDeviceA::new(0); + // Settings are set to default on first access, which forces consistency + let _before = get_test_device_settings(&device); + + let settings = + DeviceSettings::with_dtypes(FloatDType::BF16, IntDType::I64, BoolDType::Native); + let result = initialize_unchecked(&device, settings); + + assert!(matches!( + result, + Err(DeviceError::AlreadyInitialized { .. }) + )); + } + + #[test] + #[serial] + fn second_initialization_returns_error() { + clear_registry(); // reset registry for each test + + let device = TestDeviceA::new(0); + let settings = + DeviceSettings::with_dtypes(FloatDType::F16, IntDType::I32, BoolDType::Native); + initialize_unchecked(&device, settings).unwrap(); + + let result = initialize_unchecked(&device, device.defaults()); + assert!(matches!( + result, + Err(DeviceError::AlreadyInitialized { .. }) + )); + } + + #[cfg(feature = "std")] + #[test] + #[serial] + fn initialized_settings_are_global() { + clear_registry(); + + let device = TestDeviceA::new(0); + let settings = + DeviceSettings::with_dtypes(FloatDType::F16, IntDType::I32, BoolDType::Native); + + initialize_unchecked(&device, settings).unwrap(); + let settings_actual = get_test_device_settings(&device); + assert_eq!(settings_actual, settings); + + // The other thread will see the initialized settings + let seen_by_new_thread = + std::thread::spawn(move || get_test_device_settings(&TestDeviceA::new(0))) + .join() + .unwrap(); + assert_eq!(seen_by_new_thread, settings_actual); + } +} diff --git a/crates/burn-backend/src/backend/distributed/api.rs b/crates/burn-backend/src/backend/distributed/api.rs new file mode 100644 index 0000000..4ba0c89 --- /dev/null +++ b/crates/burn-backend/src/backend/distributed/api.rs @@ -0,0 +1,59 @@ +use std::{ + any::{Any, TypeId}, + sync::{Mutex, MutexGuard, OnceLock}, +}; + +use std::collections::HashMap; + +use crate::Backend; + +use super::{DistributedConfig, client::DistributedSyncClient}; + +/// The type-erased box type for [`DistributedSyncClient`]. +type ClientBox = Box; + +/// Global state map from [Backend] to boxed [`DistributedSyncClient`]. +static BACKEND_CLIENT_MAP: OnceLock>> = OnceLock::new(); + +// TODO: Replace TypeId with DeviceId, the index being i32::MAX, a.k.a. communication index. +/// Gets a locked mutable view of the `STATE_MAP`. +pub(crate) fn get_backend_client_map() -> MutexGuard<'static, HashMap> { + BACKEND_CLIENT_MAP + .get_or_init(Default::default) + .lock() + .unwrap() +} + +/// Get the distributed sync client for the given [Backend]. +pub fn get_distributed_sync_client() -> Option> { + let typeid = TypeId::of::(); + let state_map = get_backend_client_map(); + state_map + .get(&typeid) + .map(|val| val.downcast_ref().cloned().unwrap()) +} + +/// Remove the client form the map for the given [Backend]. +pub(crate) fn remove_distributed_sync_client() { + let typeid = TypeId::of::(); + let mut state_map = get_backend_client_map(); + state_map.remove(&typeid); +} + +/// Starts the server used to sync the gradients of parameters sharded across multiple devices. +pub fn start_distributed_sync_server(devices: &[B::Device], config: DistributedConfig) { + if get_distributed_sync_client::().is_none() { + let typeid = TypeId::of::(); + let mut state_map = get_backend_client_map(); + let client = DistributedSyncClient::::new(devices.len(), config); + state_map.insert(typeid, Box::new(client.clone())); + } +} + +/// Close the gradient syncing server. +pub fn close_distributed_sync_server() { + if let Some(client) = get_distributed_sync_client::() { + client.close(); + remove_distributed_sync_client::(); + } +} diff --git a/crates/burn-backend/src/backend/distributed/client.rs b/crates/burn-backend/src/backend/distributed/client.rs new file mode 100644 index 0000000..94e80fd --- /dev/null +++ b/crates/burn-backend/src/backend/distributed/client.rs @@ -0,0 +1,75 @@ +use std::{sync::mpsc::Sender, thread::spawn}; + +use crate::Backend; +use crate::tensor::Device; + +use crate::distributed::{ + DistributedConfig, DistributedParams, TensorRef, server::DistributedSyncServer, +}; + +pub(crate) enum ActionMessage { + Message(DistributedSyncMessage), + Close(), +} + +pub(crate) enum DistributedSyncMessage { + RegisterSyncParameters(Vec), + TensorSync((TensorRef, DistributedParams)), + #[allow(clippy::type_complexity)] + CollectiveSync((Device, oneshot::Sender>)), +} + +#[derive(Clone)] +pub struct DistributedSyncClient { + sender: Sender>, +} + +impl DistributedSyncClient { + pub(crate) fn new(num_devices: usize, config: DistributedConfig) -> Self { + let (tx, rx) = std::sync::mpsc::channel(); + + let mut server = DistributedSyncServer::new(num_devices, config); + spawn(move || { + while let ActionMessage::Message(msg) = + rx.recv().expect("Gradient sync server disconnected.") + { + server.process_message(msg) + } + }); + Self { sender: tx } + } + + pub fn register_sync_parameters(&self, sharded_params: Vec) { + self.sender + .send(ActionMessage::Message( + DistributedSyncMessage::RegisterSyncParameters(sharded_params), + )) + .unwrap(); + } + + pub fn submit_gradient_sync(&self, tensor: TensorRef, params: DistributedParams) { + self.sender + .send(ActionMessage::Message(DistributedSyncMessage::TensorSync( + (tensor, params), + ))) + .unwrap(); + } + + pub fn submit_sync_collective(&self, device: Device) { + let (tx, rx) = oneshot::channel(); + + self.sender + .send(ActionMessage::Message( + DistributedSyncMessage::CollectiveSync((device.clone(), tx)), + )) + .unwrap(); + + let sync = rx.recv().expect("Can receive callback"); + + sync(); + } + + pub(crate) fn close(&self) { + self.sender.send(ActionMessage::Close()).unwrap(); + } +} diff --git a/crates/burn-backend/src/backend/distributed/mod.rs b/crates/burn-backend/src/backend/distributed/mod.rs new file mode 100644 index 0000000..d2f7197 --- /dev/null +++ b/crates/burn-backend/src/backend/distributed/mod.rs @@ -0,0 +1,51 @@ +//! Types and helpers for inter-device operations. + +#[cfg(feature = "std")] +pub(crate) mod api; +#[cfg(feature = "std")] +pub(crate) mod client; +mod ops; +#[cfg(feature = "std")] +pub(crate) mod server; + +#[cfg(feature = "std")] +pub use api::*; +pub use ops::*; + +pub use burn_std::distributed::*; +/// A unique identifier for a parameter distributed across multiple devices. +pub type DistributedParamId = burn_std::id::ParamId; + +use crate::{Backend, TensorMetadata, tensor::FloatTensor}; + +/// Parameters for a tensor that is sharded across multiple devices. +#[derive(Debug, Clone)] +pub struct DistributedParams { + /// The tensor's [DistributedParamId]. + pub param_id: DistributedParamId, +} + +/// A tensor handle used for a collective operation, that is not yet valid for use. +/// We must ensure collective operations are completed before accessing the underlying data. +#[derive(new, Clone)] +pub struct CollectiveTensor { + handle: FloatTensor, +} + +impl CollectiveTensor { + /// Synchronizes the collective operation and returns a valid tensor handle. + pub fn resolve(self) -> FloatTensor { + B::sync_collective(&self.handle.device()); + self.handle + } + + /// Returns the tensor handle without synchronizing. + /// + /// # Safety + /// + /// The caller must ensure that `sync_collective()` is called before + /// the returned handle is used in any computation. + pub unsafe fn assume_resolved(self) -> FloatTensor { + self.handle + } +} diff --git a/crates/burn-backend/src/backend/distributed/ops.rs b/crates/burn-backend/src/backend/distributed/ops.rs new file mode 100644 index 0000000..12e7c3a --- /dev/null +++ b/crates/burn-backend/src/backend/distributed/ops.rs @@ -0,0 +1,166 @@ +use alloc::vec::Vec; + +use crate::{ + Backend, DeviceId, TensorMetadata, + distributed::CollectiveTensor, + tensor::{Device, FloatTensor}, +}; + +use crate::distributed::{DistributedConfig, DistributedParams, ReduceOperation}; + +#[cfg(feature = "std")] +use crate::distributed::{ + close_distributed_sync_server, get_distributed_sync_client, start_distributed_sync_server, +}; + +/// Mutable reference to a float tensor. +#[derive(Clone)] +pub struct TensorRef(pub *mut FloatTensor); +unsafe impl Sync for TensorRef where B: Backend {} +unsafe impl Send for TensorRef where B: Backend {} + +// TODO : The following functions should be moved in the `burn-autodiff` crate. The difficulty is in not discriminating between +// the dispatch backend and its inner dispatched backend when calling the communication server API. This implementation makes +// it easy by implementing `DistributedOps` for `DispatchBackend`. +// The functions in question : +// * `start_communication_server` +// * `close_communication_server` +// * `register_sync_parameters` +// * `submit_sync_collective` +// * `submit_gradient_sync` + +/// Operations on communication tensors. +pub trait DistributedOps { + /// Start the communication server used to orchestrate tensor syncing between devices. + /// + /// # Arguments + /// + /// * `devices` - The devices to orchestrate. + fn start_communication_server(devices: &[B::Device], config: DistributedConfig) { + #[cfg(feature = "std")] + start_distributed_sync_server::(devices, config); + #[cfg(not(feature = "std"))] + let _ = (devices, config); + } + + /// Close the communication server used to orchestrate syncing between devices. + /// + /// # Arguments + /// + /// * `device` - A device on the backend. + fn close_communication_server(_device: &B::Device) { + #[cfg(feature = "std")] + close_distributed_sync_server::(); + } + + /// Register the parameters that will require gradient synchronization for the upcoming backward pass. + /// + /// This must be called before the backward pass on each device so the gradient sync server + /// can coordinate collective operations across all devices. + /// + /// # Arguments + /// + /// * `device` - The device calling the initialization. + /// * `distributed_params` - A list of [`DistributedParams`] of the tensors to sync. + fn register_sync_parameters(_device: &B::Device, distributed_params: Vec) { + #[cfg(feature = "std")] + if let Some(sync_client) = get_distributed_sync_client::() { + sync_client.register_sync_parameters(distributed_params); + }; + #[cfg(not(feature = "std"))] + let _ = distributed_params; + } + + /// Tell the gradient sync server that this device has submitted all its sync operations and is ready to be synchronized. + /// + /// # Arguments + /// + /// * `device` - The device on which to sync. + fn submit_sync_collective(device: &B::Device) { + #[cfg(feature = "std")] + if let Some(sync_client) = get_distributed_sync_client::() { + sync_client.submit_sync_collective(device.clone()); + }; + #[cfg(not(feature = "std"))] + let _ = device; + } + + /// Submit a gradient tensor for synchronization across all devices. + /// + /// The gradient is sent to the gradient sync server, which will launch the all-reduce + /// operation once all devices have submitted their gradient for this parameter. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to synchronize. + /// * `distributed_params` - The [`DistributedParams`] for the parameter. + fn submit_gradient_sync(tensor: TensorRef, distributed_params: DistributedParams) { + #[cfg(feature = "std")] + if let Some(sync_client) = get_distributed_sync_client::() { + sync_client.submit_gradient_sync(tensor, distributed_params); + }; + #[cfg(not(feature = "std"))] + let _ = (tensor, distributed_params); + } + + /// all_reduce operation. + /// + /// # Arguments + /// + /// * `tensors` - The tensors on which to perform all_reduce. + /// * `op` - The [`ReduceOperation`]. + /// + /// # Returns + /// + /// The corresponding [CollectiveTensor]. + fn all_reduce( + _tensor: FloatTensor, + _op: ReduceOperation, + _device_ids: Vec, + ) -> CollectiveTensor { + unimplemented!() + } + + /// Sync the collective operations. + /// + /// # Arguments + /// + /// * `device` - The device to sync. + fn sync_collective(_device: &B::Device) { + unimplemented!() + } + + /// Get the device of the tensor reference. + /// + /// # Arguments + /// + /// * `tensor` - The tensor reference. + /// + /// # Returns + /// + /// The device of the tensor. + /// + /// # Safety + /// + /// Ensure that the tensors are not accessed/modified when calling. + unsafe fn comm_device(tensor: &TensorRef) -> Device { + unsafe { &(*tensor.0) }.device() + } + + /// Returns a clone of the float tensor from the tensor reference. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// A float tensor containing a copy of the data of the given tensor. + /// + /// # Safety + /// + /// Ensure that the tensors are not accessed/modified when calling. + unsafe fn float_from_ref(tensor: &TensorRef) -> FloatTensor { + unsafe { (*tensor.0).clone() } + } +} diff --git a/crates/burn-backend/src/backend/distributed/server.rs b/crates/burn-backend/src/backend/distributed/server.rs new file mode 100644 index 0000000..1e52fa7 --- /dev/null +++ b/crates/burn-backend/src/backend/distributed/server.rs @@ -0,0 +1,140 @@ +use std::collections::HashMap; + +use crate::{Backend, TensorMetadata}; +use crate::{DeviceId, DeviceOps, tensor::Device}; + +use crate::distributed::{ + DistributedConfig, DistributedParamId, DistributedParams, TensorRef, + client::DistributedSyncMessage, +}; + +pub(crate) struct DistributedSyncServer { + config: DistributedConfig, + all_reduce_ops_queue: HashMap>>, + param_required_map: HashMap, + num_devices: usize, + devices_registered: usize, + syncing_devices: Vec>, + devices_synced: usize, + callbacks: HashMap>>, +} + +impl DistributedSyncServer { + /// Create a new gradient sync server instance. + pub(crate) fn new(num_devices: usize, config: DistributedConfig) -> Self { + Self { + config, + all_reduce_ops_queue: HashMap::default(), + param_required_map: HashMap::default(), + num_devices, + devices_registered: 0, + syncing_devices: vec![], + devices_synced: 0, + callbacks: HashMap::default(), + } + } + + /// Process message from client. + pub(crate) fn process_message(&mut self, msg: DistributedSyncMessage) { + match msg { + DistributedSyncMessage::RegisterSyncParameters(params) => { + self.register_sync_params(params) + } + DistributedSyncMessage::TensorSync((tensor, params)) => { + self.register_tensor(tensor, params) + } + DistributedSyncMessage::CollectiveSync((device, callback)) => { + self.collective_sync(device, callback) + } + } + } + + /// Called at the start of the backward process. Lets the device announce what parameters are nodes in the autodiff graph and how many times they are required. + fn register_sync_params(&mut self, sharded_params: Vec) { + sharded_params.iter().for_each(|params| { + *self.param_required_map.entry(params.param_id).or_insert(0) += 1; + }); + self.devices_registered += 1; + } + + /// Called on registration of a gradient. Calls the all_reduce operation for any parameter that is no longer required in the autodiff graph. + fn register_tensor(&mut self, tensor: TensorRef, sharded_params: DistributedParams) { + let op_queue = self + .all_reduce_ops_queue + .entry(sharded_params.param_id) + .or_insert(vec![]); + op_queue.push(tensor.clone()); + self.launch_ops(); + } + + fn collective_sync( + &mut self, + device: Device, + callback: oneshot::Sender>, + ) { + self.callbacks.insert(device.id(), callback); + self.syncing_devices.push(device.clone()); + self.try_launch_sync(); + } + + fn try_launch_sync(&mut self) { + if self.all_reduce_ops_queue.is_empty() { + for d in self.syncing_devices.clone() { + let callback = self.callbacks.remove(&d.id()).unwrap(); + let closure = Box::new(move || B::sync_collective(&d)); + callback.send(closure).expect("Can send callback"); + self.devices_synced += 1; + } + self.syncing_devices.clear(); + } + + if self.devices_synced == self.num_devices { + self.devices_registered = 0; + self.devices_synced = 0; + self.param_required_map.clear(); + self.callbacks.clear(); + } + } + + fn launch_ops(&mut self) { + if self.devices_registered == self.num_devices { + for (param_id, num_tensors) in self.param_required_map.clone() { + let queued_tensors = self.all_reduce_ops_queue.entry(param_id).or_insert(vec![]); + + if num_tensors == queued_tensors.len() { + // Safety: Tensors sent to the `DistributedSyncServer` should not be accessed or modified until the end of the backward pass. + let device_ids = queued_tensors + .iter() + .map(|t| unsafe { &*t.0 }.device().id()) + .collect::>(); + let reduced_tensors: Vec = queued_tensors + .iter() + .map(|tensor| + // Safety: we can call `assume_resolved` on these tensors since we know `B::sync_collective` is called + // at the end of the backward pass. + unsafe { + B::all_reduce( + (*tensor.0).clone(), + self.config.all_reduce_op, + device_ids.clone(), + ) + .assume_resolved() + }) + .collect(); + + // Make the tensor reference point to the reduced tensor to perform an in-place all_reduce. + // Safety: `B::sync_collective` should be automatically called after the backward pass. + unsafe { + queued_tensors.iter().zip(reduced_tensors).for_each( + |(tensor_ref, reduced_tensor)| *tensor_ref.0 = reduced_tensor, + ); + } + + self.all_reduce_ops_queue.remove(¶m_id).unwrap(); + self.param_required_map.remove(¶m_id).unwrap(); + self.try_launch_sync(); + } + } + } + } +} diff --git a/crates/burn-backend/src/backend/mod.rs b/crates/burn-backend/src/backend/mod.rs new file mode 100644 index 0000000..e707635 --- /dev/null +++ b/crates/burn-backend/src/backend/mod.rs @@ -0,0 +1,13 @@ +mod base; +mod device; +mod primitive; + +pub use base::*; +pub use device::*; +pub use primitive::*; + +/// Backend operations on tensors. +pub mod ops; + +/// Distributed backend extension. +pub mod distributed; diff --git a/crates/burn-backend/src/backend/ops/activation.rs b/crates/burn-backend/src/backend/ops/activation.rs new file mode 100644 index 0000000..3190b57 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/activation.rs @@ -0,0 +1,343 @@ +use crate::tensor::FloatTensor; +use crate::{Backend, Scalar, TensorMetadata, get_device_settings}; +use core::f64::consts::SQRT_2; + +/// Activation function operations. +/// +/// This trait let backend implementations override activation functions for better performance. +pub trait ActivationOps { + /// Applies the LeakyReLU activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `negative_slope` - The negative_slope value that values smaller than 0 are multiplied with. + /// + /// # Returns + /// + /// The output tensor. + fn leaky_relu(tensor: FloatTensor, negative_slope: Scalar) -> FloatTensor { + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = B::float_lower_elem(tensor.clone(), 0f32.into(), bool_dtype); + let scaled_tensor = B::float_mul_scalar(tensor.clone(), negative_slope); + + // Update the tensor where the values are `< 0` by `tensor * negative_slope`. + B::float_mask_where(tensor, mask, scaled_tensor) + } + + /// Applies the ReLU activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The output tensor. + fn relu(tensor: FloatTensor) -> FloatTensor { + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = B::float_lower_equal_elem(tensor.clone(), 0f32.into(), bool_dtype); + + B::float_mask_fill(tensor, mask, 0f32.into()) + } + + /// Applies the ReLU activation function backward. + /// + /// # Arguments + /// + /// * `output` - The output tensor. + /// + /// # Returns + /// + /// The gradient. + fn relu_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + let bool_dtype = get_device_settings::(&output.device()).bool_dtype; + let mask = B::float_lower_equal_elem(output, 0f32.into(), bool_dtype); + + B::float_mask_fill(grad, mask, 0.into()) + } + + /// Applies the Gelu activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The output tensor. + fn gelu(tensor: FloatTensor) -> FloatTensor { + let x = B::float_div_scalar(tensor.clone(), SQRT_2.into()); + let x = B::float_erf(x); + let x = B::float_add_scalar(x, 1f32.into()); + let x = B::float_mul(tensor, x); + + B::float_div_scalar(x, 2f32.into()) + } + /// Applies the PReLu activation function. + /// # Arguments + /// * `tensor` - The input tensor + /// * `alpha` - The weight tensor + fn prelu(tensor: FloatTensor, alpha: FloatTensor) -> FloatTensor { + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = B::float_lower_elem(tensor.clone(), 0f32.into(), bool_dtype); + let scaled_tensor = B::float_mul(tensor.clone(), alpha); + B::float_mask_where(tensor, mask, scaled_tensor) + } + + /// Applies the Gelu activation function backward. + /// + /// # Arguments + /// + /// * `x` - The tensor. + /// * `grad` - The gradient. + /// + /// # Returns + /// + /// The output tensor. + fn gelu_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + // Derivative of the approximate gelu implementation based on tanh. + + let constant_1 = 0.0356774; + let constant_2 = 0.797885; + let constant_3 = 0.0535161; + let constant_4 = 0.398942; + + let x3 = B::float_powi_scalar(x.clone(), 3.into()); + + let c1 = B::float_mul_scalar(x3.clone(), constant_1.into()); + let c2 = B::float_mul_scalar(x.clone(), constant_2.into()); + let c3 = B::float_mul_scalar(x3, constant_3.into()); + let c4 = B::float_mul_scalar(x, constant_4.into()); + + let inner1 = B::float_add(c1, c2); + let inner2 = B::float_add(c3, c4); + + let tanh = B::float_tanh(inner1); + + let sech = B::float_powi_scalar(tanh.clone(), 2.into()); + let sech = B::float_neg(sech); + let sech = B::float_add_scalar(sech, 1.into()); + + let y1 = B::float_mul_scalar(tanh, 0.5.into()); + let y2 = B::float_mul(inner2, sech); + let y2 = B::float_add_scalar(y2, 0.5.into()); + let y = B::float_add(y1, y2); + + B::float_mul(y, grad) + } + + /// Applies the Sigmoid activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The output tensor. + fn sigmoid(tensor: FloatTensor) -> FloatTensor { + let dtype = tensor.dtype(); + let tensor_full = B::float_cast(tensor, burn_std::FloatDType::F32); + let tensor_tmp = B::float_exp(B::float_neg(B::float_log(B::float_add_scalar( + B::float_exp(B::float_neg(tensor_full)), + 1.0.into(), + )))); + + B::float_cast(tensor_tmp, dtype.into()) + } + + /// Applies the Sigmoid activation function backward. + /// + /// # Arguments + /// + /// * `output` - The output tensor of the sigmoid function. + /// * `grad` - The gradient. + /// + /// # Returns + /// + /// The output tensor. + fn sigmoid_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + let value = B::float_mul( + output.clone(), + B::float_add_scalar(B::float_neg(output), 1.0.into()), + ); + B::float_mul(value, grad) + } + + /// Applies the hard Sigmoid activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `alpha` - The alpha value that the tensor is multiplied with. + /// * `beta` - The beta value that is added to the tensor + /// + /// # Returns + /// + /// The output tensor. + fn hard_sigmoid(tensor: FloatTensor, alpha: Scalar, beta: Scalar) -> FloatTensor { + let dtype = tensor.dtype(); + let tensor_full = B::float_cast(tensor, burn_std::FloatDType::F32); + + let tensor_tmp = B::float_clamp( + B::float_add_scalar(B::float_mul_scalar(tensor_full, alpha), beta), + 0.0.into(), + 1.0.into(), + ); + + B::float_cast(tensor_tmp, dtype.into()) + } + + /// Applies the LogSigmoid activation function. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The output tensor. + fn log_sigmoid(tensor: FloatTensor) -> FloatTensor { + // To avoid overflow, we use the log-sum-exp trick. + // + // ```ignore + // log(sigmoid(x)) = log(1/(1 + exp(-x))) + // = log(1) - log(1 + exp(-x)) + // = -log(1 + exp(-x)) + // = -log(exp(0) + exp(-x)) + // ``` + // The `exp(t)` of even a moderate-magnitude positive number can be astronomically huge, so we + // subtract the `max(t, 0)` of each value (where `t = -x` in this case). This results in the + // following equivalence: + // ```ignore + // log(sigmoid(x)) = -(max(-x, 0) + log(exp(-max(-x, 0)) + exp(-x - max(-x, 0)))) + // ``` + // + // This extends the range of values for which we obtain accurate results. + + // max(-x, 0) + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let tensor_neg = B::float_neg(tensor); + let mask = B::float_lower_elem(tensor_neg.clone(), 0f32.into(), bool_dtype); + let max_elem = B::float_mask_fill(tensor_neg.clone(), mask, 0f32.into()); + let max_elem_neg = B::float_neg(max_elem.clone()); + + // z = exp(-max(-x, 0)) + exp(-x - max(-x, 0)) + let z = B::float_add( + B::float_exp(max_elem_neg.clone()), + B::float_exp(B::float_sub(tensor_neg, max_elem.clone())), + ); + + // -max(-x, 0) - log(-z) + B::float_sub(max_elem_neg, B::float_log(z)) + } + + /// Applies the softmax function along the given dimension. + /// + /// Uses the max-shift trick for numerical stability: the per-row `max` is detached + /// so no gradient flows back through it (the shift is a numerical-stability + /// transformation, not part of the function). + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension along which softmax is computed. + /// + /// # Returns + /// + /// The output tensor. + fn softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let max = B::float_max_dim(B::float_detach(tensor.clone()), dim); + let shifted = B::float_sub(tensor, max); + let exp = B::float_exp(shifted); + let sum = B::float_sum_dim(exp.clone(), dim); + B::float_div(exp, sum) + } + + /// Applies the log-softmax function along the given dimension. + /// + /// Computed via the log-sum-exp trick with a detached max-shift for numerical + /// stability. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension along which log-softmax is computed. + /// + /// # Returns + /// + /// The output tensor. + fn log_softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let max = B::float_max_dim(B::float_detach(tensor.clone()), dim); + let shifted = B::float_sub(tensor, max); + let log_sum_exp = B::float_log(B::float_sum_dim(B::float_exp(shifted.clone()), dim)); + B::float_sub(shifted, log_sum_exp) + } + + /// Applies the softmin function along the given dimension. + /// + /// Equivalent to `softmax(-tensor, dim)`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension along which softmin is computed. + /// + /// # Returns + /// + /// The output tensor. + fn softmin(tensor: FloatTensor, dim: usize) -> FloatTensor { + Self::softmax(B::float_neg(tensor), dim) + } + + /// Applies the LogSigmoid activation function backward. + /// + /// # Arguments + /// + /// * `x` - The input tensor. + /// * `grad` - The gradient. + /// + /// # Returns + /// + /// The output gradient. + fn log_sigmoid_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + // Derivative of -max(-x, 0) - log(exp(-max(-x, 0)) - exp(-x - max(-x, 0)))) is + // -max_derive - (-max_derive * exp(-max(-x, 0)) + (-1 - max_derive) * exp(-x - max(-x, 0))) / z + // where z = exp(-max(-x, 0)) + exp(-x - max(-x, 0)) + // + // This simplifies to: + // -max_derive - (z-1)/z if x is >= 0 + // -max_derive + (z-1)/z if x is < 0 + + let shape = x.shape(); + let dtype = x.dtype(); + let device = x.device(); + let bool_dtype = get_device_settings::(&device).bool_dtype; + + // max(-x, 0) + let x_neg = B::float_neg(x); + let mask = B::float_lower_elem(x_neg.clone(), 0f32.into(), bool_dtype); // -x < 0 or x >= 0 + let max_elem = B::float_mask_fill(x_neg.clone(), mask.clone(), 0f32.into()); + + // z = exp(-max(-x, 0)) + exp(-x - max(-x, 0)) + let z = B::float_add( + B::float_exp(B::float_neg(max_elem.clone())), + B::float_exp(B::float_sub(x_neg, max_elem)), + ); + + // Derivative of max(-x, 0) is 1 if x < 0 or 0 if x >= 0 + let ones = B::float_ones(shape, &device, dtype.into()); + let max_derive = B::float_mask_fill(ones.clone(), mask.clone(), 0f32.into()); + let sign = B::float_mask_fill(ones.clone(), mask, (-1f32).into()); + + // grad * (max_derive - sign * (1 - (1 / z))) + B::float_mul( + grad, + B::float_sub( + max_derive, + B::float_mul(sign, B::float_sub(ones, B::float_recip(z))), + ), + ) + } +} diff --git a/crates/burn-backend/src/backend/ops/argwhere.rs b/crates/burn-backend/src/backend/ops/argwhere.rs new file mode 100644 index 0000000..afad83b --- /dev/null +++ b/crates/burn-backend/src/backend/ops/argwhere.rs @@ -0,0 +1,72 @@ +use crate::tensor::{Device, IntTensor}; +use crate::{Backend, TensorData}; +use alloc::vec::Vec; +use burn_std::{Element, ElementConversion, IntDType}; + +/// Compute the indices of the elements that are non-zero, grouped by element. +/// +/// # Arguments +/// +/// * `data` - The input tensor data. +/// +/// # Returns +/// +/// A 2D tensor containing the indices of all non-zero elements of the given tensor. +/// Each row contains the indices of a non-zero element. +/// +/// # Remarks +/// +/// This is a fallback solution that used only when the backend doesn't have the corresponding implementation. +/// Ideally, it is supposed to be implemented by the backend and the backend implementation will be resolved +/// by static dispatch. It is not designed for direct usage by users, and not recommended to import +/// or use this function directly. +pub fn argwhere_data( + data: TensorData, + device: &Device, + out_dtype: IntDType, +) -> IntTensor { + let out = match out_dtype { + IntDType::I64 => argwhere_data_impl::(data), + IntDType::I32 => argwhere_data_impl::(data), + IntDType::I16 => argwhere_data_impl::(data), + IntDType::I8 => argwhere_data_impl::(data), + IntDType::U64 => argwhere_data_impl::(data), + IntDType::U32 => argwhere_data_impl::(data), + IntDType::U16 => argwhere_data_impl::(data), + IntDType::U8 => argwhere_data_impl::(data), + }; + + B::int_from_data(out, device) +} + +fn argwhere_data_impl(data: TensorData) -> TensorData { + let dims = &data.shape; + let ndims = dims.len(); + let count_nonzero = data.iter::().filter(|&v| v).count(); + + /// Converts a flat index into a vector of indices for the specified tensor shape + fn unravel_index(index: usize, shape: &[usize]) -> Vec { + shape + .iter() + .rev() + .scan(index, |i, size| { + let dim_idx = *i % size; + *i /= size; + Some((dim_idx as i64).elem()) + }) + .collect::>() + .into_iter() + .rev() + .collect() + } + + let indices = data + .iter::() + .enumerate() + .filter_map(|(index, v)| if v { Some(index) } else { None }) + .map(|index| unravel_index::(index, dims)) + .collect::>() + .concat(); + + TensorData::new(indices, [count_nonzero, ndims]) +} diff --git a/crates/burn-backend/src/backend/ops/bool_tensor.rs b/crates/burn-backend/src/backend/ops/bool_tensor.rs new file mode 100644 index 0000000..4ecb3f2 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/bool_tensor.rs @@ -0,0 +1,573 @@ +use super::{ + argwhere::argwhere_data, cat::cat_with_slice_assign, repeat_dim::repeat_with_slice_assign, +}; +use crate::tensor::{BoolTensor, Device, FloatTensor, IntTensor}; +use crate::{Backend, TensorData, TensorMetadata, get_device_settings}; +use crate::{ExecutionError, Scalar}; +use alloc::vec::Vec; +use burn_std::{BoolDType, FloatDType, IntDType, Shape, Slice}; +use core::future::Future; + +/// Bool Tensor API for basic operations, see +#[cfg_attr(doc, doc = crate::doc_tensor!())] +#[cfg_attr(not(doc), doc = "`Tensor`")] +/// for documentation on each function. +pub trait BoolTensorOps { + /// Creates a new bool tensor. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The boolean tensor with the given shape. + fn bool_empty(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor; + + /// Creates a new bool tensor filled false. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The boolean tensor filled with false. + fn bool_zeros(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor; + + /// Creates a new bool tensor filled true. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The boolean tensor filled with true. + fn bool_ones(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor; + + /// Converts the tensor to a data structure. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The data structure with the tensor's data. + fn bool_into_data( + tensor: BoolTensor, + ) -> impl Future> + Send; + + /// Creates a tensor from the data structure. + /// + /// # Arguments + /// + /// * `data` - The data structure. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// The tensor with the data. + fn bool_from_data(data: TensorData, device: &Device) -> BoolTensor; + + /// Converts bool tensor to int tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The int tensor with the same data as the bool tensor. + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor; + + /// Converts bool tensor to float tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The float tensor with the same data as the bool tensor. + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor; + + /// Moves the tensor to the device. + fn bool_to_device(tensor: BoolTensor, device: &Device) -> BoolTensor; + + /// Reshapes the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `shape` - The new shape. + /// + /// # Returns + /// + /// The tensor with the new shape. + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor; + + /// Gets the values from the tensor for the given ranges. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `slices` - The slices specifying ranges and steps for each dimension. + /// + /// # Returns + /// + /// The tensor with the values for the given slices. + /// + /// # Note + /// + /// Empty slices (where start >= end) are handled at the high-level tensor API and will not + /// be passed to this method. Backend implementations do not need to handle empty slices. + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor; + + /// Sets the values in the tensor for the given ranges. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `ranges` - The ranges to set the values for. + /// * `value` - The values to set. + /// + /// # Returns + /// + /// The tensor with the values set for the given ranges. + /// + /// # Note + /// + /// Empty slice assignments (where any slice range produces 0 elements) are handled at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty slice assignments. + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[Slice], + value: BoolTensor, + ) -> BoolTensor; + + /// Fills the tensor with values from the value tensor if the mask is true at the given + /// indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `mask` - The mask. + /// * `value` - The value tensor. + /// + /// # Returns + /// + /// The tensor with the values filled. + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor; + + /// Fills the tensor with the given value if the mask is true at the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `mask` - The mask. + /// * `value` - The value. + /// + /// # Returns + /// + /// The tensor with the values filled. + fn bool_mask_fill(tensor: BoolTensor, mask: BoolTensor, value: Scalar) -> BoolTensor; + + /// Gather elements from the tensor at the given indices. + /// + /// # Arguments + /// + /// * `dim` - The dimension to gather from. + /// * `tensor` - The tensor. + /// * `indices` - The indices. + fn bool_gather(dim: usize, tensor: BoolTensor, indices: IntTensor) -> BoolTensor; + + /// Scatter a given value to the tensor at the given indices using boolean or reduction. + /// + /// # Arguments + /// + /// * `dim` - The dimension to scatter to. + /// * `tensor` - The tensor. + /// * `indices` - The indices. + /// * `value` - The value. + /// + /// # Returns + /// + /// The tensor with the values scattered. + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor; + + /// Select tensor elements along the given dimension corresponding to the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices of the elements to select. + /// + /// # Returns + /// + /// The tensor with the selected elements. + fn bool_select(tensor: BoolTensor, dim: usize, indices: IntTensor) -> BoolTensor; + + /// Assign the selected elements along the given dimension corresponding to the given indices + /// to the given value using sum reduction. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to assign the values to. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices of the elements to assign. + /// * `value` - The values to assign. + /// + /// # Returns + /// + /// The tensor with the assigned values. + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor; + + /// Repeats one dimension of the tensor a given number of times along that dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to repeat. + /// * `times` - The number of times to repeat the dimension. + /// + /// # Returns + /// + /// The tensor with the dimension repeated. + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + let device = tensor.device(); + repeat_with_slice_assign::( + tensor, + dim, + times, + device, + |shape, device, dtype| B::bool_empty(shape, device, dtype.into()), + B::bool_slice_assign, + ) + } + + /// Concatenates the tensors along the given dimension. + /// + /// # Arguments + /// + /// * `tensors` - The tensors to concatenate. + /// * `dim` - The dimension to concatenate along. + /// + /// # Returns + /// + /// The tensor with the tensors concatenated along the given dimension. + /// + /// # Note + /// + /// Empty tensors (where the concatenation dimension has size 0) are filtered out at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty tensors. + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + let first_tensor = tensors.first().expect("Tensors should not be empty"); + let device = first_tensor.device(); + cat_with_slice_assign::( + tensors, + dim, + device, + |shape, device, dtype| B::bool_empty(shape, device, dtype.into()), + B::bool_slice_assign, + ) + } + + /// Equates the two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor with the result of the equate. + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor; + + /// Element-wise non-equality comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor with the result of the comparison. + fn bool_not_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let equal_tensor = B::bool_equal(lhs, rhs); + B::bool_not(equal_tensor) + } + + /// Element-wise equality comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor; + + /// Element-wise non-equality comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn bool_not_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + let equal_tensor = B::bool_equal_elem(lhs, rhs); + B::bool_not(equal_tensor) + } + + /// Inverses boolean values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The tensor with the result of the negation. + fn bool_not(tensor: BoolTensor) -> BoolTensor; + + /// Executes the logical and (`&&`) operation on two boolean tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor with the result of the logical and. + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor; + + /// Executes the logical or (`||`) operation on two boolean tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor with the result of the logical or. + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor; + + /// Element-wise exclusive or. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor with the result of the comparison. + fn bool_xor(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + Self::bool_not_equal(lhs, rhs) + } + + /// Transposes a bool tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + fn bool_transpose(tensor: BoolTensor) -> BoolTensor { + let ndims = tensor.shape().num_dims(); + Self::bool_swap_dims(tensor, ndims - 2, ndims - 1) + } + + /// Swaps two dimensions of a bool tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap. + /// * `dim2` - The second dimension to swap. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor; + + /// Permutes the dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to permute the dimensions of. + /// * `axes` - The new order of the dimensions. + /// # Returns + /// + /// The tensor with the dimensions permuted. + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor; + + /// Reverse the order of elements in a tensor along the given axes. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reverse. + /// * `axes` - The axes to reverse. + /// + /// The tensor with the elements reversed. + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor; + + /// Tests if any element in the boolean `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if any element in the tensor is True, False otherwise. + fn bool_any(tensor: BoolTensor) -> BoolTensor { + let dtype = tensor.dtype(); + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let sum = B::int_sum(B::bool_into_int(tensor, int_dtype)); + B::int_greater_elem(sum, 0.into(), dtype.into()) + } + + /// Tests if any element in the boolean `tensor` evaluates to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if any element along this dim in the input + /// evaluates to True, False otherwise. + fn bool_any_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let dtype = tensor.dtype(); + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let sum = B::int_sum_dim(B::bool_into_int(tensor, int_dtype), dim); + B::int_greater_elem(sum, 0.into(), dtype.into()) + } + + /// Tests if all elements in the boolean `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with a single element, True if all elements in the input tensor + /// evaluate to True, False otherwise. + fn bool_all(tensor: BoolTensor) -> BoolTensor { + let dtype = tensor.dtype(); + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let num_elems = tensor.shape().num_elements() as i64; + let sum = B::int_sum(B::bool_into_int(tensor, int_dtype)); + B::int_equal_elem(sum, num_elems.into(), dtype.into()) + } + + /// Tests if all elements in the boolean `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if all elements along this dim in the input + /// evaluates to True, False otherwise. + fn bool_all_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let dtype = tensor.dtype(); + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let num_elems = tensor.shape()[dim] as i64; + let sum = B::int_sum_dim(B::bool_into_int(tensor, int_dtype), dim); + B::int_equal_elem(sum, num_elems.into(), dtype.into()) + } + + /// Compute the indices of the elements that are non-zero, grouped by element. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A 2D tensor containing the indices of all non-zero elements of the given tensor. + /// Each row contains the indices of a non-zero element. + fn bool_argwhere( + tensor: BoolTensor, + out_dtype: IntDType, + ) -> impl Future> + 'static + Send { + async move { + // Size of each output tensor is variable (= number of nonzero elements in the tensor). + // Reading the data to count the number of truth values might cause sync but is required. + let device = &tensor.device(); + let data = B::bool_into_data(tensor) + .await + .expect("Can read the data without error"); + argwhere_data::(data, device, out_dtype) + } + } + + /// Broadcasts the bool `tensor` to the given `shape`. + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor; + + /// Unfold windows along a dimension. + /// + /// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` + /// * `dim` - the selected dim. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with shape ``[pre=..., windows, size, post=...]``. + fn bool_unfold(tensor: BoolTensor, dim: usize, size: usize, step: usize) -> BoolTensor; +} diff --git a/crates/burn-backend/src/backend/ops/cat.rs b/crates/burn-backend/src/backend/ops/cat.rs new file mode 100644 index 0000000..6334fce --- /dev/null +++ b/crates/burn-backend/src/backend/ops/cat.rs @@ -0,0 +1,45 @@ +use crate::{Backend, TensorMetadata, tensor::Device}; +use alloc::vec::Vec; +use burn_std::{DType, Shape, Slice}; + +pub(crate) fn cat_with_slice_assign( + tensors: Vec, + dim: usize, + device: Device, + empty: E, + slice_assign: SA, +) -> T +where + T: TensorMetadata, + B: Backend, + E: Fn(Shape, &Device, DType) -> T, + SA: Fn(T, &[Slice], T) -> T, +{ + let first_tensor = tensors.first().expect("Tensors should not be empty"); + let mut shape = first_tensor.shape(); + let dtype = first_tensor.dtype(); + + let output_dim_length: usize = tensors.iter().map(|tensor| tensor.shape()[dim]).sum(); + shape[dim] = output_dim_length; + + let mut tensor_output = empty(shape.clone(), &device, dtype); + + let indices_select_all = shape.iter().map(|d| 0..*d).collect::>(); + + let mut output_index = 0; + for tensor in tensors { + let mut indices = indices_select_all.clone(); + let tensor_dim_length = tensor.shape()[dim]; + indices[dim] = output_index..output_index + tensor_dim_length; + output_index += tensor_dim_length; + + // Convert ranges to Slice + let slices: Vec = indices + .iter() + .map(|r| Slice::new(r.start as isize, Some(r.end as isize), 1)) + .collect(); + tensor_output = slice_assign(tensor_output, &slices, tensor); + } + + tensor_output +} diff --git a/crates/burn-backend/src/backend/ops/int_tensor.rs b/crates/burn-backend/src/backend/ops/int_tensor.rs new file mode 100644 index 0000000..6f0d890 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/int_tensor.rs @@ -0,0 +1,1460 @@ +use super::cat::cat_with_slice_assign; +use super::repeat_dim::repeat_with_slice_assign; +use super::sort::{argsort, sort, sort_with_indices}; +use crate::tensor::{BoolTensor, Device, FloatTensor, IntTensor}; +use crate::{Backend, Distribution, TensorData, TensorMetadata}; +use crate::{ExecutionError, Scalar, get_device_settings}; +use alloc::vec::Vec; +use burn_std::reader::try_read_sync; +use burn_std::{BoolDType, FloatDType, IntDType, Shape, Slice}; +use core::ops::Range; + +/// Int Tensor API for basic and numeric operations, see +#[cfg_attr(doc, doc = crate::doc_tensor!())] +#[cfg_attr(not(doc), doc = "`Tensor`")] +/// for documentation on each function. +pub trait IntTensorOps { + /// Creates a new int tensor. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The integer tensor with the given shape. + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor; + + /// Converts the tensor to a data structure. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The data structure with the tensor's data. + fn int_into_data( + tensor: IntTensor, + ) -> impl Future> + Send; + + /// Creates a tensor from the data structure. + /// + /// # Arguments + /// + /// * `data` - The data structure. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// The tensor with the data. + fn int_from_data(data: TensorData, device: &Device) -> IntTensor; + + /// Moves the tensor to the given device. + fn int_to_device(tensor: IntTensor, device: &Device) -> IntTensor; + + /// Reshapes the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `shape` - The new shape. + /// + /// # Returns + /// + /// The tensor with the new shape. + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor; + + /// Gets the element at the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `slices` - The slices specifying ranges and steps for each dimension. + /// + /// # Returns + /// + /// The elements at the given indices. + /// + /// # Note + /// + /// Empty slices (where start >= end) are handled at the high-level tensor API and will not + /// be passed to this method. Backend implementations do not need to handle empty slices. + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor; + + /// Sets the values in the tensor for the given ranges. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `ranges` - The ranges to set the values for. + /// + /// # Returns + /// + /// The tensor with the values set for the given ranges. + /// + /// # Note + /// + /// Empty slice assignments (where any slice range produces 0 elements) are handled at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty slice assignments. + fn int_slice_assign( + tensor: IntTensor, + slices: &[Slice], + value: IntTensor, + ) -> IntTensor; + + /// Converts int tensor to float tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The int tensor with the same data as the float tensor. + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor; + + /// Fills the tensor with values from the value tensor if the mask is true at the given + /// indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `mask` - The mask. + /// * `value` - The value tensor. + /// + /// # Returns + /// + /// The tensor with the values filled. + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor; + + /// Fills the tensor with the given value if the mask is true at the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `mask` - The mask. + /// * `value` - The value. + /// + /// # Returns + /// + /// The tensor with the values filled. + fn int_mask_fill(tensor: IntTensor, mask: BoolTensor, value: Scalar) -> IntTensor; + + /// Gather elements from the tensor at the given indices. + /// + /// # Arguments + /// + /// * `dim` - The dimension to gather from. + /// * `tensor` - The tensor. + /// * `indices` - The indices. + fn int_gather(dim: usize, tensor: IntTensor, indices: IntTensor) -> IntTensor; + + /// Scatter a given value to the tensor at the given indices using sum reduction. + /// + /// # Arguments + /// + /// * `dim` - The dimension to scatter to. + /// * `tensor` - The tensor. + /// * `indices` - The indices. + /// * `value` - The value. + /// + /// # Returns + /// + /// The tensor with the values scattered. + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor; + + /// Multi-dimensional scatter for int tensors. + fn int_scatter_nd( + _data: IntTensor, + _indices: IntTensor, + _values: IntTensor, + _reduction: crate::tensor::IndexingUpdateOp, + ) -> IntTensor { + unimplemented!("int_scatter_nd is not implemented for this backend") + } + + /// Multi-dimensional gather for int tensors. + fn int_gather_nd(_data: IntTensor, _indices: IntTensor) -> IntTensor { + unimplemented!("int_gather_nd is not implemented for this backend") + } + + /// Select tensor elements along the given dimension corresponding to the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices. + /// + /// # Returns + /// + /// The tensor with the selected elements. + fn int_select(tensor: IntTensor, dim: usize, indices: IntTensor) -> IntTensor; + + /// Assign the selected elements along the given dimension corresponding to the given indices + /// to the given value using sum reduction. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices. + /// * `value` - The value. + /// + /// # Returns + /// + /// The tensor with the selected elements assigned to the given value. + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor; + + /// Repeats the tensor along the given dimension the given number of times. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to repeat. + /// * `times` - The number of times to repeat. + /// + /// # Returns + /// + /// The tensor with the given dimension repeated the given number of times. + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + let device = tensor.device(); + repeat_with_slice_assign::( + tensor, + dim, + times, + device, + |shape, device, dtype| B::int_empty(shape, device, dtype.into()), + B::int_slice_assign, + ) + } + + /// Concatenates the given tensors along the given dimension. + /// + /// # Arguments + /// + /// * `tensors` - The tensors. + /// * `dim` - The dimension to concatenate along. + /// + /// # Returns + /// + /// The concatenated tensor. + /// + /// # Note + /// + /// Empty tensors (where the concatenation dimension has size 0) are filtered out at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty tensors. + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + let first_tensor = tensors.first().expect("Tensors should not be empty"); + let device = first_tensor.device(); + cat_with_slice_assign::( + tensors, + dim, + device, + |shape, device, dtype| B::int_empty(shape, device, dtype.into()), + B::int_slice_assign, + ) + } + + /// Element-wise equality comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_equal(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise non-equality comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_not_equal(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor { + let equal_tensor = B::int_equal(lhs, rhs, out_dtype); + B::bool_not(equal_tensor) + } + + /// Element-wise equality comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise non-equality comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_not_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + let equal_tensor = B::int_equal_elem(lhs, rhs, out_dtype); + B::bool_not(equal_tensor) + } + + /// Element-wise greater than comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_greater(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise greater than comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_greater_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise greater than or equal comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Element-wise greater than or equal comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Element-wise less than comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_lower(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise less than comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise less than or equal comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_lower_equal(lhs: IntTensor, rhs: IntTensor, out_dtype: BoolDType) + -> BoolTensor; + + /// Element-wise less than or equal comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The boolean tensor with the result of the comparison. + fn int_lower_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + // ==== NUMERIC ==== // + + /// Element-wise addition. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of the addition. + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise addition with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of the addition. + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Element-wise power with a IntTensor. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side IntTensor. + /// * `rhs` - The right-hand side IntTensor. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the power of the elements of `rhs`. + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let dtype = lhs.dtype(); + let float_dtype = get_device_settings::(&lhs.device()).float_dtype; + B::float_into_int( + B::float_powi(B::int_into_float(lhs, float_dtype), rhs), + dtype.into(), + ) + } + + /// Element-wise power with a scalar. + /// + /// # Backend Implementors Note + /// + /// A number of common exponent cases can be implemented with operations + /// which are much cheaper than generic exponentiation. + /// + /// This (`Backend` impl overridable) operation handles generic optimizations + /// for several common integer exponent cases; and then dispatches to + /// the (`Backend` impl overridable) [`Self::int_powi_scalar_impl`] + /// operation to handle the generic case. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. + fn int_powi_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let exp = rhs.elem::(); + match exp { + 0 => Self::int_ones(lhs.shape(), &lhs.device(), lhs.dtype().into()), + 1 => lhs, + 2 => Self::int_mul(lhs.clone(), lhs), + _ => Self::int_powi_scalar_impl(lhs, rhs), + } + } + + /// Element-wise power with a scalar. + /// + /// # Backend Implementors Note + /// + /// This is the generic implementation of integer exponentiation + /// called by [`Self::int_powi_scalar`] in the fallback case. + /// + /// By default, this performs a relatively expensive conversion to float, + /// exponentiation in float, and conversion back to int. + /// This reduces the minimal operation set for `Backend`s, + /// at the cost of performance. + /// + /// This is a good target for specialized optimizations in `Backend` implementations. + /// + /// As a general rule, this should not be called directly. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype(); + let float_dtype = get_device_settings::(&lhs.device()).float_dtype; + B::float_into_int( + B::float_powi_scalar_impl(B::int_into_float(lhs, float_dtype), rhs), + dtype.into(), + ) + } + + /// Clamps a tensor under a minimum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn int_clamp_min(tensor: IntTensor, min: Scalar) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = Self::int_lower_elem(tensor.clone(), min, dtype); + Self::int_mask_fill(tensor, mask, min) + } + + /// Clamps a tensor over a maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn int_clamp_max(tensor: IntTensor, max: Scalar) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = Self::int_greater_elem(tensor.clone(), max, dtype); + Self::int_mask_fill(tensor, mask, max) + } + + /// Clamps a tensor between a minimum and maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + Self::int_clamp_min(Self::int_clamp_max(tensor, max), min) + } + + /// Element-wise subtraction. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of the subtraction. + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise subtraction with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of the subtraction. + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Element-wise multiplication. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of the multiplication. + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise multiplication with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of the multiplication. + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Element-wise division. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of the division. + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise division with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of the division. + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Element-wise modulus. + /// + /// # Arguments + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of applying the modulus of the scalar to the tensor. + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise modulus with a scalar. + /// + /// # Arguments + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of applying the modulus of the scalar to the tensor. + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Multiplies two tensors together using matrix multiplication. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of multiplying the two tensors together using matrix multiplication. + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Element-wise negation. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to negate. + /// + /// # Returns + /// + /// The negated tensor. + fn int_neg(tensor: IntTensor) -> IntTensor { + Self::int_mul_scalar(tensor, (-1).into()) + } + + /// Creates a tensor of zeros. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor of zeros. + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + Self::int_from_data(TensorData::full_dtype(shape, 0, dtype.into()), device) + } + + /// Creates a tensor of ones. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor of ones. + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + Self::int_from_data(TensorData::full_dtype(shape, 1, dtype.into()), device) + } + + /// Creates a tensor filled with given value. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `fill_value` - The value with which to fill the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor filled with given value + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + Self::int_from_data( + TensorData::full_dtype(shape, fill_value, dtype.into()), + device, + ) + } + + /// Sums all elements in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// + /// # Returns + /// + /// The sum of all elements in the tensor. + fn int_sum(tensor: IntTensor) -> IntTensor; + + /// Sums all elements in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// * `dim` - The dimension to sum along. + /// + /// # Returns + /// + /// The sum of all elements in the tensor along the dimension. + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the product of all elements in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the product of. + /// + /// # Returns + /// + /// The product of all elements in the tensor. + fn int_prod(tensor: IntTensor) -> IntTensor; + + /// Computes the product of all elements in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the product of. + /// * `dim` - The dimension to compute the product along. + /// + /// # Returns + /// + /// The product of all elements in the tensor along the dimension. + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the mean of all elements in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the mean of. + /// + /// # Returns + /// + /// The mean of all elements in the tensor. + fn int_mean(tensor: IntTensor) -> IntTensor { + let num_elems = tensor.shape().num_elements() as i64; + B::int_div_scalar(B::int_sum(tensor), num_elems.into()) + } + + /// Computes the mean of all elements in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the mean of. + /// + /// # Returns + /// + /// The mean of all elements in the tensor along the dimension. + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the cumulative sum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative sum of. + /// * `dim` - The dimension along which to compute the cumulative sum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative sum + /// of all elements up to and including that position along the dimension. + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the cumulative product of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative product of. + /// * `dim` - The dimension along which to compute the cumulative product. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative product + /// of all elements up to and including that position along the dimension. + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the cumulative minimum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative minimum of. + /// * `dim` - The dimension along which to compute the cumulative minimum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the minimum + /// of all elements up to and including that position along the dimension. + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Computes the cumulative maximum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative maximum of. + /// * `dim` - The dimension along which to compute the cumulative maximum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the maximum + /// of all elements up to and including that position along the dimension. + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Gets the indices of the maximum elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum indices of. + /// * `dim` - The dimension to get the maximum indices along. + /// + /// # Returns + /// + /// The indices of the maximum elements along the dimension. + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Gets the indices of the k maximum elements along a dimension. + /// If two elements share the same value, it will be ordered by the lowest + /// coordinate + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum indices of. + /// * `dim` - The dimension to get the maximum indices along. + /// * `k` - number of maximum elements. + /// + /// # Returns + /// + /// The indices of the maximum elements along the dimension. + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor; + + /// Gets the values of the k maximum elements along a dimension. + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum values of. + /// * `dim` - The dimension to get the maximum values along. + /// * `k` - number of maximum elements. + /// + /// # Returns + /// + /// The values of the maximum elements along the dimension. + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + let device = &tensor.device(); + let dtype = get_device_settings::(device).int_dtype; + let k_indices = Self::int_arange(0..k as i64, device, dtype); + Self::int_select(Self::int_sort(tensor, dim, true), dim, k_indices) + } + + /// Gets the indices of the minimum elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum indices of. + /// * `dim` - The dimension to get the minimum indices along. + /// + /// # Returns + /// + /// The indices of the minimum elements along the dimension. + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor; + + /// Gets the maximum element in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum element of. + /// + /// # Returns + /// + /// The maximum element in the tensor. + fn int_max(tensor: IntTensor) -> IntTensor { + let shape = tensor.shape(); + let tensor = B::int_reshape(tensor, Shape::new([shape.num_elements()])); + + B::int_max_dim(tensor, 0) + } + + /// Gets the maximum element in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum element of. + /// * `dim` - The dimension to get the maximum element along. + /// + /// # Returns + /// + /// The maximum element in the tensor along the dimension. + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let index = B::int_argmax(tensor.clone(), dim); + B::int_gather(dim, tensor, index) + } + + /// Gets the maximum elements and corresponding indices along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements and indices of. + /// * `dim` - The dimension to get the maximum elements and indices along. + /// + /// # Returns + /// + /// The maximum elements and corresponding indices along the dimension. + fn int_max_dim_with_indices(tensor: IntTensor, dim: usize) -> (IntTensor, IntTensor) { + let index = B::int_argmax(tensor.clone(), dim); + let values = B::int_gather(dim, tensor, index.clone()); + + (values, index) + } + + /// Gets the maximum absolute element in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum element of. + /// + /// # Returns + /// + /// The maximum element in the tensor. + fn int_max_abs(tensor: IntTensor) -> IntTensor { + let shape = tensor.shape(); + let tensor = B::int_reshape(tensor, Shape::new([shape.num_elements()])); + + B::int_max_abs_dim(tensor, 0) + } + + /// Gets the maximum absolute element in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum element of. + /// * `dim` - The dimension to get the maximum element along. + /// + /// # Returns + /// + /// The maximum element in the tensor along the dimension. + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + B::int_max_dim(B::int_abs(tensor), dim) + } + + /// Gets the minimum element in the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum element of. + /// + /// # Returns + /// + /// The minimum element in the tensor. + fn int_min(tensor: IntTensor) -> IntTensor { + let shape = tensor.shape(); + let tensor = B::int_reshape(tensor, Shape::new([shape.num_elements()])); + + B::int_min_dim(tensor, 0) + } + + /// Gets the minimum elements in the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum element of. + /// * `dim` - The dimension to get the minimum element along. + /// + /// # Returns + /// + /// The minimum element in the tensor along the dimension. + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let index = B::int_argmin(tensor.clone(), dim); + B::int_gather(dim, tensor, index) + } + + /// Gets the minimum elements and corresponding indices along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements and indices of. + /// * `dim` - The dimension to get the minimum elements and indices along. + /// + /// # Returns + /// + /// The minimum elements and corresponding indices along the dimension. + fn int_min_dim_with_indices(tensor: IntTensor, dim: usize) -> (IntTensor, IntTensor) { + let indices = B::int_argmin(tensor.clone(), dim); + let values = B::int_gather(dim, tensor, indices.clone()); + + (values, indices) + } + + /// Returns a new tensor with absolute values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take absolute value of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with absolute values. + fn int_abs(tensor: IntTensor) -> IntTensor; + + /// Transposes an int tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + fn int_transpose(tensor: IntTensor) -> IntTensor { + let ndims = tensor.shape().num_dims(); + Self::int_swap_dims(tensor, ndims - 2, ndims - 1) + } + + /// Swaps two dimensions of an int tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap. + /// * `dim2` - The second dimension to swap. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor; + + /// Permutes the dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to permute the dimensions of. + /// * `axes` - The new order of the dimensions. + /// # Returns + /// + /// The tensor with the dimensions permuted. + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor; + + /// Reverse the order of elements in a tensor along the given axes. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reverse. + /// * `axes` - The axes to reverse. + /// + /// The tensor with the elements reversed. + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor; + + /// Creates a new int tensor with random values. + /// + /// # Arguments + /// * `shape` - The shape of the tensor. + /// * `distribution` - The distribution to sample from. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor with the given shape and random values. + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor; + + /// Creates a new tensor with values from the given range with the given step size. + /// + /// # Arguments + /// + /// * `range` - The range of values. + /// * `step` - The step size. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor with the given values. + fn int_arange_step( + range: Range, + step: usize, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + let value = range.step_by(step).collect::>(); + let shape = Shape::new([value.len()]); + let data = TensorData::new(value, shape).convert_dtype(dtype.into()); + B::int_from_data(data, device) + } + + /// Creates a new tensor with values from the given range. + /// + /// # Arguments + /// + /// * `range` - The range of values. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// The tensor with the given values. + /// + /// # Remarks + /// + /// Uses `arange_step` with a step size of 1 under the hood. + fn int_arange(range: Range, device: &Device, dtype: IntDType) -> IntTensor { + Self::int_arange_step(range, 1, device, dtype) + } + + /// Tests if any element in the int `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if any element in the tensor is True, False otherwise. + fn int_any(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + let int_dtype = tensor.dtype(); + let bool_tensor = B::int_equal_elem(tensor, 0.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::int_sum(B::bool_into_int(bool_tensor, int_dtype.into())); + B::int_greater_elem(sum, 0.into(), out_dtype) + } + + /// Tests if any element in the int `tensor` evaluates to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if any element along this dim in the input + /// evaluates to True, False otherwise. + fn int_any_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let int_dtype = tensor.dtype(); + let bool_tensor = B::int_equal_elem(tensor, 0.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::int_sum_dim(B::bool_into_int(bool_tensor, int_dtype.into()), dim); + B::int_greater_elem(sum, 0.into(), out_dtype) + } + + /// Tests if all elements in the int `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with a single element, True if all elements in the input tensor + /// evaluate to True, False otherwise. + fn int_all(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + let int_dtype = tensor.dtype(); + let num_elems = tensor.shape().num_elements() as i64; + let bool_tensor = B::int_equal_elem(tensor, 0.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::int_sum(B::bool_into_int(bool_tensor, int_dtype.into())); + B::int_equal_elem(sum, num_elems.into(), out_dtype) + } + + /// Tests if all elements in the int `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if all elements along this dim in the input + /// evaluates to True, False otherwise. + fn int_all_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let int_dtype = tensor.dtype(); + let num_elems = tensor.shape()[dim] as i64; + let bool_tensor = B::int_equal_elem(tensor, 0.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::int_sum_dim(B::bool_into_int(bool_tensor, int_dtype.into()), dim); + B::int_equal_elem(sum, num_elems.into(), out_dtype) + } + + /// Returns the signs of the int `tensor`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to extract the signs from. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` containing the signs of the elements of `tensor`. + fn int_sign(tensor: IntTensor) -> IntTensor { + let dtype = tensor.dtype(); + let device = &tensor.device(); + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let zeros = B::int_zeros(tensor.shape(), device, dtype.into()); + let less_than_zero = B::int_lower_elem(tensor.clone(), 0.into(), bool_dtype); + let greater_than_zero = B::int_greater_elem(tensor, 0.into(), bool_dtype); + + let mut result = B::int_mask_fill(zeros, less_than_zero, (-1).into()); + result = B::int_mask_fill(result, greater_than_zero, 1.into()); + result + } + + /// Broadcasts the int `tensor` to the given `shape`. + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor; + + /// Sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where the elements are sorted by value. + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + let device = tensor.device(); + sort::( + tensor, + dim, + descending, + device, + |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::int_into_data(tensor)) + .expect(msg) + .expect(msg) + }, + |data, device, _dtype| B::int_from_data(data, device), + ) + } + + /// Sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor and corresponding indices, where + /// the elements are sorted by value and the indices map back to the original input tensor. + fn int_sort_with_indices( + tensor: IntTensor, + dim: usize, + descending: bool, + ) -> (IntTensor, IntTensor) { + let dtype = tensor.dtype(); + let device = tensor.device(); + sort_with_indices::( + tensor, + dim, + descending, + dtype.into(), + device, + |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::int_into_data(tensor)) + .expect(msg) + .expect(msg) + }, + |data, device, _dtype| B::int_from_data(data, device), + ) + } + + /// Returns the indices that sort the elements of the input `tensor` by value + /// along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor the indices map back to the original input tensor. + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + let dtype = tensor.dtype(); + let device = tensor.device(); + argsort::(tensor, dim, descending, dtype.into(), device, |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::int_into_data(tensor)) + .expect(msg) + .expect(msg) + }) + } + + /// Bitwise AND operation for Int Tensors + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Bitwise AND operation for Int Tensors with a scalar + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Bitwise OR operation for Int Tensors + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Bitwise OR operation for Int Tensors with a scalar + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Bitwise XOR operation for Int Tensors + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Bitwise XOR operation for Int Tensors with a scalar + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Bitwise NOT operation for Int Tensors + fn bitwise_not(tensor: IntTensor) -> IntTensor; + + /// Bitwise left shift operation for Int Tensors + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Bitwise left shift operation for Int Tensors with a scalar + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Bitwise right shift operation for Int Tensors + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor; + + /// Bitwise right shift operation for Int Tensors with a scalar + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor; + + /// Converts a tensor to another integer data type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to convert. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// A tensor with the same values as `tensor` but in the target integer data type. + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor; + + /// Unfold windows along a dimension. + /// + /// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` + /// * `dim` - the selected dim. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with shape ``[pre=..., windows, size, post=...]``. + fn int_unfold(tensor: IntTensor, dim: usize, size: usize, step: usize) -> IntTensor; +} diff --git a/crates/burn-backend/src/backend/ops/mod.rs b/crates/burn-backend/src/backend/ops/mod.rs new file mode 100644 index 0000000..0485608 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/mod.rs @@ -0,0 +1,20 @@ +mod activation; +mod bool_tensor; +mod int_tensor; +mod modules; +mod qtensor; +mod tensor; +mod transaction; + +pub(crate) mod argwhere; +pub(crate) mod cat; +pub(crate) mod repeat_dim; +pub(crate) mod sort; + +pub use activation::*; +pub use bool_tensor::*; +pub use int_tensor::*; +pub use modules::*; +pub use qtensor::*; +pub use tensor::*; +pub use transaction::*; diff --git a/crates/burn-backend/src/backend/ops/modules/attention.rs b/crates/burn-backend/src/backend/ops/modules/attention.rs new file mode 100644 index 0000000..1740af0 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/attention.rs @@ -0,0 +1,116 @@ +use core::f32; +#[allow(unused_imports)] +use num_traits::Float as _; + +use burn_std::Shape; + +use crate::{ + Backend, TensorMetadata, get_device_settings, + ops::AttentionModuleOptions, + tensor::{BoolTensor, FloatTensor}, +}; + +/// Computes softmax(QKᵗ * scale) · V using separate kernels. +/// Serves as a fallback when FlashAttention is not used. +pub fn attention_fallback( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, +) -> FloatTensor { + if let Some(softcap) = options.softcap { + assert!(softcap > 0.0, "softcap must be positive, got {softcap}"); + } + + // Attention scores: A = QKᵗ * scale + let query_shape = query.shape().dims::<4>(); + let scale = options + .scale + .unwrap_or_else(|| 1.0 / (*query_shape.last().unwrap() as f64).sqrt()); + let transposed_key = B::float_transpose(key); + let qk = B::float_matmul(query, transposed_key); + let attention_scores = B::float_mul_scalar(qk, scale.into()); + + // Softcap: softcap * tanh(scores / softcap) + // Applied to raw logits before any -inf masking, so that tanh does not + // map -inf to a finite value (which would break masking semantics). + let attention_scores = if let Some(softcap) = options.softcap { + let scaled = B::float_div_scalar(attention_scores, softcap.into()); + let tanh = B::float_tanh(scaled); + B::float_mul_scalar(tanh, softcap.into()) + } else { + attention_scores + }; + + // Bool masking + let attention_scores = if let Some(mask) = mask { + B::float_mask_fill(attention_scores, mask, f32::NEG_INFINITY.into()) + } else { + attention_scores + }; + + // Causal masking: mask positions where col > row (future positions) + let attention_scores = if options.is_causal { + let causal_mask = build_causal_mask::(&attention_scores); + B::float_mask_fill(attention_scores, causal_mask, f32::NEG_INFINITY.into()) + } else { + attention_scores + }; + + // Additive bias (ALiBi, relative position biases, etc.) + let attention_scores = if let Some(bias) = attn_bias { + B::float_add(attention_scores, bias) + } else { + attention_scores + }; + + // NaN-safe softmax: S = softmax(A) + // When all positions in a row are masked (-inf), naive softmax has two NaN paths: + // (1) max is -inf, so the shift -inf - (-inf) = NaN; + // (2) after fixing (1), all exp values are 0, so sum is 0 and 0/0 = NaN. + // Clamping max to finfo.min (most negative finite value) and sum to finfo.min_positive + // (smallest positive normal) avoids both, yielding 0 for fully-masked rows. + let finfo = attention_scores.dtype().finfo().expect("float tensor"); + let max_per_dim = B::float_max_dim(attention_scores.clone(), 3); + let max_per_dim = B::float_clamp_min(max_per_dim, finfo.min.into()); + let minus_max = B::float_sub(attention_scores, max_per_dim); + let numerator = B::float_exp(minus_max); + let sum_exp = B::float_sum_dim(numerator.clone(), 3); + let sum_exp = B::float_clamp_min(sum_exp, finfo.min_positive.into()); + let softmax = B::float_div(numerator, sum_exp); + + // Context: S · V + B::float_matmul(softmax, value) +} + +/// Builds a causal (upper-triangular) bool mask where `true` means "mask this position". +/// Shape: [batch_size, num_heads, seq_q, seq_k], masking positions where col > row. +fn build_causal_mask(attention_scores: &FloatTensor) -> BoolTensor { + let device = attention_scores.device(); + let scores_shape = attention_scores.shape().dims::<4>(); + let [batch_size, num_heads, seq_q, seq_k] = scores_shape; + let settings = get_device_settings::(&device); + + // row indices [seq_q, 1] and col indices [1, seq_k] + // Offset col indices so that the causal boundary aligns at the bottom-right corner, + // which handles cross-attention (seq_k > seq_q) correctly. + let offset = seq_k as i64 - seq_q as i64; + let rows = B::int_reshape( + B::int_arange(0..seq_q as i64, &device, settings.int_dtype), + Shape::new([seq_q, 1]), + ); + let cols = B::int_reshape( + B::int_arange(0..seq_k as i64, &device, settings.int_dtype), + Shape::new([1, seq_k]), + ); + + // mask where col > row + offset (upper triangle) + let rows_shifted = B::int_add_scalar(rows, offset.into()); + let mask_2d = B::int_lower(rows_shifted, cols, settings.bool_dtype); + + // Reshape to [1, 1, seq_q, seq_k] then expand to [batch_size, num_heads, seq_q, seq_k] + let mask_4d = B::bool_reshape(mask_2d, Shape::new([1, 1, seq_q, seq_k])); + B::bool_expand(mask_4d, Shape::new([batch_size, num_heads, seq_q, seq_k])) +} diff --git a/crates/burn-backend/src/backend/ops/modules/base.rs b/crates/burn-backend/src/backend/ops/modules/base.rs new file mode 100644 index 0000000..7f6d51d --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/base.rs @@ -0,0 +1,866 @@ +use super::{conv, ctc, linear, pool}; +use crate::ops::unfold::unfold4d_using_conv2d; +use crate::tensor::{BoolTensor, FloatTensor, IntTensor}; +use crate::{Backend, TensorMetadata}; +pub use burn_std::ops::{ + AttentionModuleOptions, ConvOptions, ConvTransposeOptions, DeformConvOptions, + GridSampleOptions, GridSamplePaddingMode, InterpolateMode, InterpolateOptions, PadMode, + PaddedConvOptions, UnfoldOptions, +}; +use burn_std::{IntDType, Shape}; + +/// Gradient computed during the backward pass for each tensor used by [conv2d](ModuleOps::conv2d). +#[derive(new)] +pub struct Conv2dBackward { + /// Gradient. + pub x_grad: FloatTensor, + + /// Weights gradient. + pub weights_grad: FloatTensor, + + /// Bias gradient. + pub bias_grad: Option>, +} + +/// Gradient computed during the backward pass for each tensor used by [deform_conv2d](ModuleOps::deform_conv2d). +#[derive(new)] +pub struct DeformConv2dBackward { + /// Gradient. + pub x_grad: FloatTensor, + + /// Offset gradient. + pub offset_grad: FloatTensor, + + /// Weights gradient. + pub weight_grad: FloatTensor, + + /// Mask gradient. + pub mask_grad: Option>, + + /// Bias gradient. + pub bias_grad: Option>, +} + +/// Gradient computed during the backward pass for each tensor used by [conv3d](ModuleOps::conv3d). +#[derive(new)] +pub struct Conv3dBackward { + /// Gradient. + pub x_grad: FloatTensor, + + /// Weights gradient. + pub weights_grad: FloatTensor, + + /// Bias gradient. + pub bias_grad: Option>, +} + +/// Gradient computed during the backward pass for each tensor used by [max_pool1d](ModuleOps::max_pool1d). +#[derive(new)] +pub struct MaxPool1dBackward { + /// Gradient. + pub x_grad: FloatTensor, +} + +/// Results from [max_pool1d](ModuleOps::max_pool1d_with_indices). +#[derive(new)] +pub struct MaxPool1dWithIndices { + /// The output tensor. + pub output: FloatTensor, + + /// The indices tensor. + pub indices: IntTensor, +} + +/// Gradient computed during the backward pass for each tensor used by [max_pool2d](ModuleOps::max_pool2d). +#[derive(new)] +pub struct MaxPool2dBackward { + /// Gradient. + pub x_grad: FloatTensor, +} + +/// Results from [max_pool2d](ModuleOps::max_pool2d_with_indices). +#[derive(new)] +pub struct MaxPool2dWithIndices { + /// The output tensor. + pub output: FloatTensor, + + /// The indices tensor. + pub indices: IntTensor, +} + +/// Gradient computed during the backward pass for each tensor used by [interpolate](ModuleOps::interpolate). +#[derive(new)] +pub struct InterpolateBackward { + /// Gradient. + pub x_grad: FloatTensor, +} + +/// Module operations trait. +pub trait ModuleOps { + /// Embedding operation. + /// + /// # Arguments + /// + /// * `weights` - The embedding weights. + /// * `indices` - The indices tensor. + /// + /// # Returns + /// + /// The output tensor. + fn embedding(weights: FloatTensor, indices: IntTensor) -> FloatTensor { + let [batch_size, seq_length] = indices.shape().dims(); + let [_, d_model] = weights.shape().dims(); + + let indices = B::int_reshape(indices, Shape::new([batch_size * seq_length])); + let output = B::float_select(weights, 0, indices); + + B::float_reshape(output, Shape::new([batch_size, seq_length, d_model])) + } + + /// Embedding backward operation. + /// + /// # Arguments + /// + /// * `weights` - The embedding weights. + /// * `output_grad` - The output gradient. + /// * `indices` - The indices tensor. + /// + /// # Returns + /// + /// The gradient. + fn embedding_backward( + weights: FloatTensor, + output_grad: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + let [batch_size, seq_length] = indices.shape().dims(); + let [n_embeddings, d_model] = weights.shape().dims(); + let device = weights.device(); + let dtype = output_grad.dtype(); + + let indices = B::int_reshape(indices, Shape::new([batch_size * seq_length])); + let output_grad = + B::float_reshape(output_grad, Shape::new([batch_size * seq_length, d_model])); + let grad = B::float_zeros(Shape::new([n_embeddings, d_model]), &device, dtype.into()); + + B::float_select_add(grad, 0, indices, output_grad) + } + + /// Linear transformation. + /// + /// # Shapes + /// + /// x: `[..., d_input]`, + /// weight: `[d_input, d_output]`, + /// bias: `[d_output]`, + fn linear( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + ) -> FloatTensor { + linear::linear::(x, weight, bias) + } + /// Backward pass for [linear](ModuleOps::linear), returning the gradient for `x`. + fn linear_x_backward(weight: FloatTensor, output_grad: FloatTensor) -> FloatTensor { + linear::linear_x_backward::(weight, output_grad) + } + /// Backward pass for [linear](ModuleOps::linear), returning the gradient for `weight`. + fn linear_weight_backward(x: FloatTensor, output_grad: FloatTensor) -> FloatTensor { + linear::linear_weight_backward::(x, output_grad) + } + /// Backward pass for [linear](ModuleOps::linear), returning the gradient for `bias`. + fn linear_bias_backward(output_grad: FloatTensor) -> FloatTensor { + linear::linear_bias_backward::(output_grad) + } + + /// One dimensional convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, length]`, + /// weight: `[channels_out, channels_in, kernel_size]`, + /// bias: `[channels_out]`, + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + conv::conv1d_from_conv2d::(x, weight, bias, options) + } + /// Backward pass for the [conv1d](ModuleOps::conv1d) operation, returning the gradient for `x`. + fn conv1d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + conv::conv1d_x_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv1d](ModuleOps::conv1d) operation, returning the gradient for `weight`. + fn conv1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + conv::conv1d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv1d](ModuleOps::conv1d) operation, returning the gradient for `bias`. + fn conv1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv1d_bias_backward::(x, bias, output_grad) + } + /// Two dimensional convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, height, width]`, + /// weight: `[channels_out, channels_in, kernel_size_1, kernel_size_2]`, + /// bias: `[channels_out]`, + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor; + /// Backward pass for the [conv2d](ModuleOps::conv2d) operation, returning the gradient for `x`. + fn conv2d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + conv::conv2d_x_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv2d](ModuleOps::conv2d) operation, returning the gradient for `weight`. + fn conv2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + conv::conv2d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv2d](ModuleOps::conv2d) operation, returning the gradient for `bias`. + fn conv2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv2d_bias_backward::(x, bias, output_grad) + } + + /// Two dimensional deformable convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, height, width]`, + /// weight: `[channels_out, channels_in, kernel_size_1, kernel_size_2]`, + /// bias: `[channels_out]`, + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor; + /// Backward pass for the [deform_conv2d](ModuleOps::deform_conv2d) operation. + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward; + + /// Three dimensional convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, depth, height, width]`, + /// weight: `[channels_out, channels_in, kernel_size_1, kernel_size_2, kernel_size_3]`, + /// bias: `[channels_out]`, + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor; + /// Backward pass for the [conv3d](ModuleOps::conv3d) operation, returning the gradient for `x`. + fn conv3d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + conv::conv3d_x_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv3d](ModuleOps::conv3d) operation, returning the gradient for `weight`. + fn conv3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + conv::conv3d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv3d](ModuleOps::conv3d) operation, returning the gradient for `bias`. + fn conv3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv3d_bias_backward::(x, bias, output_grad) + } + /// One dimensional transposed convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, length]`, + /// weight: `[channels_in, channels_out, length]`, + /// bias: `[channels_out]`, + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + conv::conv_transpose1d_from_conv_transpose2d::(x, weight, bias, options) + } + /// Backward pass for the [conv transpose 1d](ModuleOps::conv_transpose1d) operation, returning the gradient for `x`. + fn conv_transpose1d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + conv::conv_transpose1d_x_backward::(weight, output_grad, options) + } + /// Backward pass for the [conv transpose 1d](ModuleOps::conv_transpose1d) operation, returning the gradient for `weight`. + fn conv_transpose1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + conv::conv_transpose1d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv transpose 1d](ModuleOps::conv_transpose1d) operation, returning the gradient for `bias`. + fn conv_transpose1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv_transpose1d_bias_backward::(x, bias, output_grad) + } + + /// Two dimensional transposed convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, height, width]`, + /// weight: `[channels_in, channels_out, kernel_size_1, kernel_size_2]`, + /// bias: `[channels_out]`, + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor; + /// Backward pass for the [conv transpose 2d](ModuleOps::conv_transpose2d) operation, returning the gradient for `x`. + fn conv_transpose2d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + conv::conv_transpose2d_x_backward::(weight, output_grad, options) + } + /// Backward pass for the [conv transpose 2d](ModuleOps::conv_transpose2d) operation, returning the gradient for `weight`. + fn conv_transpose2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + conv::conv_transpose2d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv transpose 2d](ModuleOps::conv_transpose2d) operation, returning the gradient for `bias`. + fn conv_transpose2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv_transpose2d_bias_backward::(x, bias, output_grad) + } + + /// Three dimensional transposed convolution. + /// + /// # Shapes + /// + /// x: `[batch_size, channels_in, height, width]`, + /// weight: `[channels_in, channels_out, kernel_size_1, kernel_size_2, kernel_size_3]`, + /// bias: `[channels_out]`, + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor; + /// Backward pass for the [conv transpose 3d](ModuleOps::conv_transpose3d) operation, returning the gradient for `x`. + fn conv_transpose3d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + conv::conv_transpose3d_x_backward::(weight, output_grad, options) + } + /// Backward pass for the [conv transpose 3d](ModuleOps::conv_transpose3d) operation, returning the gradient for `weight`. + fn conv_transpose3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + conv::conv_transpose3d_weight_backward::(x, weight, output_grad, options) + } + /// Backward pass for the [conv transpose 3d](ModuleOps::conv_transpose3d) operation, returning the gradient for `bias`. + fn conv_transpose3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + conv::conv_transpose3d_bias_backward::(x, bias, output_grad) + } + + /// Four-dimensional unfolding. + /// + /// # Shapes + /// + /// * x: ``[batch_size, channels_in, height, width]``, + /// * returns: ``[batch_size, channels_in * kernel_size_1 * kernel_size_2, number of blocks]``, + fn unfold4d( + x: FloatTensor, + kernel_size: [usize; 2], + options: UnfoldOptions, + ) -> FloatTensor { + if options.padding == [0, 0] && options.dilation == [1, 1] { + let blocks = B::float_unfold(x, 2, kernel_size[0], options.stride[0]); + let blocks = B::float_unfold(blocks, 3, kernel_size[1], options.stride[1]); + + // batch, channels, h_blocks, w_blocks, h_kern, w_kern + + let blocks = B::float_permute(blocks, &[0, 1, 4, 5, 2, 3]); + let shape = blocks.shape(); + + // batch, channels, h_kern, w_kern, h_blocks, w_blocks + + B::float_reshape( + blocks, + [ + shape[0], + shape[1] * shape[2] * shape[3], + shape[4] * shape[5], + ] + .into(), + ) + } else { + unfold4d_using_conv2d::(x, kernel_size, options) + } + } + + /// One dimensional avg pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, length], + fn avg_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + pool::avg_pool1d_from_2d::( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ) + } + /// Backward pass for the [avg pooling 1d](ModuleOps::avg_pool1d) operation. + fn avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + pool::avg_pool1d_backward_from_2d::( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ) + } + /// Two dimensional avg pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, height, width], + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor; + /// Backward pass for the [avg pooling 2d](ModuleOps::avg_pool2d) operation. + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor; + /// Two dimensional adaptive avg pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, height, width], + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor; + /// Backward pass for the [adaptive avg pooling 2d](ModuleOps::adaptive_avg_pool2d) operation. + fn adaptive_avg_pool2d_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor; + /// One dimensional adaptive avg pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, length], + fn adaptive_avg_pool1d(x: FloatTensor, output_size: usize) -> FloatTensor { + pool::adaptive_avg_pool1d_from_2d::(x, output_size) + } + /// Backward pass for the [adaptive avg pooling 1d](ModuleOps::adaptive_avg_pool1d) operation. + fn adaptive_avg_pool1d_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + pool::adaptive_avg_pool1d_backward_from_2d::(x, grad) + } + /// One dimensional max pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, length], + fn max_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> FloatTensor { + pool::max_pool1d_from_2d::(x, kernel_size, stride, padding, dilation, ceil_mode) + } + + /// One dimensional max pooling with indices. + /// + /// # Shapes + /// + /// x: [batch_size, channels, height, width], + fn max_pool1d_with_indices( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool1dWithIndices { + pool::max_pool1d_with_indices_from_2d::( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype, + ) + } + /// Backward pass for the [max pooling 1d](ModuleOps::max_pool1d_with_indices) operation. + #[allow(clippy::too_many_arguments)] + fn max_pool1d_with_indices_backward( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool1dBackward { + pool::max_pool1d_with_indices_backward_from_2d::( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + output_grad, + indices, + ) + } + + /// Two dimensional max pooling. + /// + /// # Shapes + /// + /// x: [batch_size, channels, height, width], + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor; + + /// Two dimensional max pooling with indices. + /// + /// # Shapes + /// + /// x: [batch_size, channels, height, width], + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices; + /// Backward pass for the [max pooling 2d](ModuleOps::max_pool2d_with_indices) operation. + #[allow(clippy::too_many_arguments)] + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward; + + /// Down/up samples the input. + /// + /// # Shapes + /// + /// x: `[batch_size, channels, height, width]`, + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor; + + /// Backward pass for the [interpolate](ModuleOps::interpolate) operation. + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor; + + /// Computes scaled dot-product attention: softmax(QKᵗ * scale) · V, + /// where scale defaults to 1/sqrt(head_dim). Optionally applies masking, + /// additive bias, causal masking, and softcap to the attention scores. + /// + /// # Arguments + /// - `query`: Query tensor of shape `[batch_size, num_heads, seq_len_q, head_dim]` + /// - `key`: Key tensor of shape `[batch_size, num_heads, seq_len_k, head_dim]` + /// - `value`: Value tensor of shape `[batch_size, num_heads, seq_len_k, val_dim]` + /// - `mask`: Optional boolean mask of shape `[batch_size, num_heads, seq_len_q, seq_len_k]`, + /// where `true` indicates positions to mask (i.e. set to -inf before softmax). + /// - `attn_bias`: Optional float tensor of shape `[batch_size, num_heads, seq_len_q, seq_len_k]` + /// added to the attention scores before softmax (e.g. ALiBi, relative position biases). + /// - `options`: Additional attention options (custom scale, softcap, causal masking). + /// + /// # Returns + /// A tensor of shape `[batch_size, num_heads, seq_len_q, val_dim]` + /// representing the attended context per head. + /// + /// # Note + /// This implementation does not support dropout and is intended for inference or + /// use cases where dropout is not needed. + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + ) -> FloatTensor; + + /// Applies Layer Normalization over the last dimension of the input tensor. + /// + /// Computes `(x - mean) / sqrt(var + epsilon) * gamma + beta`, where `mean` and + /// (biased) `var` are reduced over the last axis. + /// + /// # Arguments + /// + /// * `tensor` - Input tensor of shape `[..., d_model]`. + /// * `gamma` - Scale tensor of shape `[d_model]`. + /// * `beta` - Optional bias tensor of shape `[d_model]`. + /// * `epsilon` - Numerical stability term added to the variance before the square root. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor`. + fn layer_norm( + tensor: FloatTensor, + gamma: FloatTensor, + beta: Option>, + epsilon: f64, + ) -> FloatTensor { + let shape = tensor.shape(); + let rank = shape.num_dims(); + let last_dim = rank - 1; + let d_model = shape[last_dim]; + + let mean = B::float_mean_dim(tensor.clone(), last_dim); + let centered = B::float_sub(tensor, mean); + let var = B::float_mean_dim(B::float_mul(centered.clone(), centered.clone()), last_dim); + let denom = B::float_sqrt(B::float_add_scalar(var, epsilon.into())); + let normalized = B::float_div(centered, denom); + + let broadcast_dims: alloc::vec::Vec = (0..rank) + .map(|i| if i == last_dim { d_model } else { 1 }) + .collect(); + let gamma_b = B::float_reshape(gamma, Shape::from(broadcast_dims.clone())); + let scaled = B::float_mul(normalized, gamma_b); + + match beta { + Some(beta) => { + let beta_b = B::float_reshape(beta, Shape::from(broadcast_dims)); + B::float_add(scaled, beta_b) + } + None => scaled, + } + } + + /// Computes the Connectionist Temporal Classification (CTC) loss. + /// + /// Sums over all valid alignments between the input and target sequences + /// using the forward (alpha) algorithm. + /// + /// # Arguments + /// + /// * `log_probs` - Log-probabilities of shape `[T, N, C]` + /// * `targets` - Target label indices of shape `[N, S]` + /// * `input_lengths` - Actual input sequence lengths per batch element `[N]` + /// * `target_lengths` - Actual target lengths per batch element `[N]` + /// * `blank` - Index of the blank label + /// + /// # Returns + /// + /// Per-sample loss of shape `[N]` + fn ctc_loss( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, + ) -> FloatTensor { + ctc::ctc_loss_default::(log_probs, targets, input_lengths, target_lengths, blank) + } + + /// Returns `true` if this backend implements [ctc_loss_backward](ModuleOps::ctc_loss_backward) + /// natively. + /// + /// Autodiff queries this flag to decide between two paths: + /// - `true`: use the backend's [ctc_loss](ModuleOps::ctc_loss) and + /// [ctc_loss_backward](ModuleOps::ctc_loss_backward) directly. + /// - `false`: call [ctc::ctc_loss_default] for the forward pass; autodiff + /// then differentiates through the decomposed tensor ops. + /// + /// Backends that override `ctc_loss_backward` must also override this to + /// return `true`. + fn has_ctc_loss_backward() -> bool { + false + } + + /// Backward pass for [ctc_loss](ModuleOps::ctc_loss): gradient w.r.t. `log_probs`. + /// + /// Only called when [has_ctc_loss_backward](ModuleOps::has_ctc_loss_backward) + /// returns `true`. Backends without a native implementation should leave + /// both methods at their defaults; the gradient is computed automatically by + /// autodiff against the decomposed [ctc::ctc_loss_default] forward. + /// + /// # Arguments + /// + /// * `log_probs` - Log-probabilities of shape `[T, N, C]` + /// * `targets` - Target label indices of shape `[N, S]` + /// * `input_lengths` - Actual input sequence lengths per batch element `[N]` + /// * `target_lengths` - Actual target lengths per batch element `[N]` + /// * `grad_loss` - Upstream gradient w.r.t. the per-sample loss `[N]` + /// * `blank` - Index of the blank label + /// + /// # Returns + /// + /// Gradient w.r.t. `log_probs` of shape `[T, N, C]` + fn ctc_loss_backward( + _log_probs: FloatTensor, + _targets: IntTensor, + _input_lengths: IntTensor, + _target_lengths: IntTensor, + _grad_loss: FloatTensor, + _blank: usize, + ) -> FloatTensor { + unreachable!( + "ctc_loss_backward called on a backend whose has_ctc_loss_backward() returns false" + ) + } + + /// Real-valued FFT with optional size parameter. + /// + /// When `n` is `None`, the signal must be a power of two along `dim`, and the output has + /// `signal_len / 2 + 1` frequency bins. + /// + /// When `n` is `Some(size)`, `size` must also be a power of two. The signal is truncated + /// or zero-padded to `size` and the output has `size / 2 + 1` frequency bins. Non-power- + /// of-two sizes are currently rejected at the public API boundary; true arbitrary-`n` DFT + /// support (Bluestein's algorithm) is tracked as a follow-up. + /// + /// Returns two tensors: the real part and the imaginary part. + fn rfft( + signal: FloatTensor, + dim: usize, + n: Option, + ) -> (FloatTensor, FloatTensor); + + /// Inverse real-valued FFT with optional output size. + /// + /// When `n` is `None`, the reconstructed signal length `2 * (spectrum_size - 1)` must be + /// a power of two. + /// + /// When `n` is `Some(size)`, `size` must also be a power of two. Output has exactly + /// `size` samples. + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + n: Option, + ) -> FloatTensor; +} diff --git a/crates/burn-backend/src/backend/ops/modules/conv.rs b/crates/burn-backend/src/backend/ops/modules/conv.rs new file mode 100644 index 0000000..e1b52a0 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/conv.rs @@ -0,0 +1,1533 @@ +#![allow(clippy::single_range_in_vec_init)] +use super::{ConvOptions, ConvTransposeOptions}; +use crate::{Backend, TensorMetadata, tensor::FloatTensor}; +use burn_std::{MetadataError, Shape, Slice}; + +use alloc::{vec, vec::Vec}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Calculate the expected output shape `[batch_size, channels_out, spatial_dims, ..]` for a pooling operation. +pub fn calculate_pool_output_shape( + in_shape: &Shape, + kernel_size: &[usize; N], + stride: &[usize; N], + padding: &[usize; N], + dilation: &[usize; N], + ceil_mode: bool, +) -> Result { + if in_shape.rank() != N + 2 { + return Err(MetadataError::RankMismatch { + left: in_shape.rank(), + right: N + 2, + }); + } + + let mut out_shape = in_shape.clone(); + // Spatial dims + for (i, size_i) in out_shape[2..].iter_mut().enumerate() { + *size_i = calculate_pool_output_size( + kernel_size[i], + stride[i], + padding[i], + dilation[i], + *size_i, + ceil_mode, + ); + } + + Ok(out_shape) +} + +/// Calculate the expected output shape `[batch_size, channels_out, spatial_dims, ..]` for a convolution. +pub fn calculate_conv_output_shape( + in_shape: &Shape, + weight_shape: &Shape, + stride: &[usize; N], + padding: &[usize; N], + dilation: &[usize; N], +) -> Result { + if weight_shape.rank() != N + 2 { + return Err(MetadataError::RankMismatch { + left: weight_shape.rank(), + right: N + 2, + }); + } + + if in_shape.rank() != N + 2 { + return Err(MetadataError::RankMismatch { + left: in_shape.rank(), + right: N + 2, + }); + } + + let kernel_size = &weight_shape[2..]; + + let mut out_shape = in_shape.clone(); + // Spatial dims + for (i, size_i) in out_shape[2..].iter_mut().enumerate() { + *size_i = + calculate_conv_output_size(kernel_size[i], stride[i], padding[i], dilation[i], *size_i); + } + // Output channels + out_shape[1] = weight_shape[0]; + + Ok(out_shape) +} + +/// Calculate the expected output shape `[batch_size, channels_out, spatial_dims, ..]` for a transposed convolution. +pub fn calculate_conv_transpose_output_shape( + in_shape: &Shape, + weight_shape: &Shape, + stride: &[usize; N], + padding: &[usize; N], + padding_out: &[usize; N], + dilation: &[usize; N], + groups: usize, +) -> Result { + if weight_shape.rank() != N + 2 { + return Err(MetadataError::RankMismatch { + left: weight_shape.rank(), + right: N + 2, + }); + } + + if in_shape.rank() != N + 2 { + return Err(MetadataError::RankMismatch { + left: in_shape.rank(), + right: N + 2, + }); + } + + let kernel_size = &weight_shape[2..]; + + let mut out_shape = in_shape.clone(); + // Spatial dims + for (i, size_i) in out_shape[2..].iter_mut().enumerate() { + *size_i = calculate_conv_transpose_output_size( + kernel_size[i], + stride[i], + padding[i], + padding_out[i], + dilation[i], + *size_i, + ); + } + // Output channels + out_shape[1] = weight_shape[1] * groups; + + Ok(out_shape) +} + +/// Calculate the expected padding size required when applying a convolution. +pub fn calculate_conv_padding( + kernel_size: usize, + stride: usize, + size_in: usize, + size_out: usize, +) -> usize { + let kernel_size = kernel_size as f32; + let stride = stride as f32; + let size_in = size_in as f32; + let size_out = size_out as f32; + + let padding = stride * (size_out - 1.) - size_in + kernel_size; + let padding = (padding / 2.).ceil(); + + padding as usize +} + +/// Calculate the expected output size when doing a convolution operation. +pub fn calculate_conv_output_size( + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + size_in: usize, +) -> usize { + (size_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1 +} + +/// Calculate the expected output sizes when doing a convolution operation. +pub fn calculate_conv_output_sizes( + kernel_size: &[usize], + stride: &[usize], + padding: &[usize], + dilation: &[usize], + size_in: &[usize], +) -> Vec { + size_in + .iter() + .enumerate() + .map(|(i, size_in)| { + calculate_conv_output_size(kernel_size[i], stride[i], padding[i], dilation[i], *size_in) + }) + .collect() +} + +/// Calculate the expected output size when doing a pooling operation. +/// +/// # Arguments +/// +/// * `kernel_size` - Size of the pooling kernel +/// * `stride` - Stride of the pooling operation +/// * `padding` - Padding applied to input +/// * `dilation` - Dilation of the pooling kernel +/// * `size_in` - Input size (height or width) +/// * `ceil_mode` - If true, use ceiling instead of floor for output size calculation. +/// This allows the last pooling window to go out-of-bounds if needed. +pub fn calculate_pool_output_size( + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + size_in: usize, + ceil_mode: bool, +) -> usize { + let numerator = size_in + 2 * padding - dilation * (kernel_size - 1) - 1; + if ceil_mode { + // Ceiling division: (a + b - 1) / b + numerator.div_ceil(stride) + 1 + } else { + // Floor division (default) + numerator / stride + 1 + } +} + +/// Calculate the expected output size when doing a transposed convolution operation. +pub fn calculate_conv_transpose_output_size( + kernel_size: usize, + stride: usize, + padding: usize, + padding_out: usize, + dilation: usize, + size_in: usize, +) -> usize { + (size_in - 1) * stride + (dilation * (kernel_size - 1) + 1) + padding_out - 2 * padding +} + +/// Calculate the original input size that was used for a transposed convolution. +/// This is used during the backward pass to recover the correct gradient shape. +fn calculate_conv_transpose_input_size( + kernel_size: usize, + stride: usize, + padding: usize, + padding_out: usize, + dilation: usize, + size_out: usize, +) -> usize { + // We solve the forward formula for size_in: + // size_out = (size_in - 1) * stride + (dilation * (kernel_size - 1) + 1) + padding_out - 2 * padding + (size_out + 2 * padding - dilation * (kernel_size - 1) - padding_out - 1) / stride + 1 +} + +/// Calculate the original input sizes that were used for a transposed convolution. +fn calculate_conv_transpose_input_sizes( + kernel_size: [usize; D], + stride: [usize; D], + padding: [usize; D], + padding_out: [usize; D], + dilation: [usize; D], + size_out: [usize; D], +) -> [usize; D] { + let mut res = [0; D]; + for i in 0..D { + res[i] = calculate_conv_transpose_input_size( + kernel_size[i], + stride[i], + padding[i], + padding_out[i], + dilation[i], + size_out[i], + ); + } + res +} + +/// Calculate the [1D convolution](crate::ops::ModuleOps::conv1d) backward pass, returning the gradient for `x`. +pub(crate) fn conv1d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, +) -> FloatTensor { + let weight_shape = weight.shape(); + + let [_batch_size, _, length_in] = x.shape().dims(); + let [_batch_size, _channels_out, length_out] = output_grad.shape().dims(); + let [_, _, kernel_size] = weight_shape.dims(); + + let padding_out = calculate_padding_out( + kernel_size, + options.stride[0], + options.padding[0], + options.dilation[0], + length_in, + length_out, + ); + + B::conv_transpose1d( + output_grad, + weight, + None, + ConvTransposeOptions::new( + options.stride, + options.padding, + [padding_out], + options.dilation, + options.groups, + ), + ) +} + +/// Calculate the [1D convolution](crate::ops::ModuleOps::conv1d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv1d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv1d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [1D convolution](crate::ops::ModuleOps::conv1d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _, _length_in] = x.shape().dims(); + let [_batch_size, channels_out, length_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape(grad, Shape::new([channels_out, batch_size * length_out])); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Calculate the [2D convolution](crate::ops::ModuleOps::conv2d) backward pass, returning the gradient for `x`. +pub(crate) fn conv2d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, +) -> FloatTensor { + let weight_shape = weight.shape(); + + let [_batch_size, _channels_in, height_in, width_in] = x.shape().dims(); + let [_, _, height_out, width_out] = output_grad.shape().dims(); + let [_channels_out, _, kernel_size_1, kernel_size_2] = weight_shape.dims(); + + let padding_1_out = calculate_padding_out( + kernel_size_1, + options.stride[0], + options.padding[0], + options.dilation[0], + height_in, + height_out, + ); + let padding_2_out = calculate_padding_out( + kernel_size_2, + options.stride[1], + options.padding[1], + options.dilation[1], + width_in, + width_out, + ); + + B::conv_transpose2d( + output_grad, + weight, + None, + ConvTransposeOptions::new( + options.stride, + options.padding, + [padding_1_out, padding_2_out], + options.dilation, + options.groups, + ), + ) +} + +/// Calculate the [2D convolution](crate::ops::ModuleOps::conv2d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv2d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv2d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [2D convolution](crate::ops::ModuleOps::conv2d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _, _, _] = x.shape().dims(); + let [_, channels_out, height_out, width_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape( + grad, + Shape::new([channels_out, batch_size * height_out * width_out]), + ); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Calculate the [3D convolution](crate::ops::ModuleOps::conv3d) backward pass, returning the gradient for `x`. +pub(crate) fn conv3d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, +) -> FloatTensor { + let weight_shape = weight.shape(); + + let [_batch_size, _channels_in, depth_in, height_in, width_in] = x.shape().dims(); + let [_, _, depth_out, height_out, width_out] = output_grad.shape().dims(); + let [ + _channels_out, + _, + kernel_size_1, + kernel_size_2, + kernel_size_3, + ] = weight_shape.dims(); + + let padding_1_out = calculate_padding_out( + kernel_size_1, + options.stride[0], + options.padding[0], + options.dilation[0], + depth_in, + depth_out, + ); + let padding_2_out = calculate_padding_out( + kernel_size_2, + options.stride[1], + options.padding[1], + options.dilation[1], + height_in, + height_out, + ); + let padding_3_out = calculate_padding_out( + kernel_size_3, + options.stride[2], + options.padding[2], + options.dilation[2], + width_in, + width_out, + ); + + B::conv_transpose3d( + output_grad, + weight, + None, + ConvTransposeOptions::new( + options.stride, + options.padding, + [padding_1_out, padding_2_out, padding_3_out], + options.dilation, + options.groups, + ), + ) +} + +/// Calculate the [3D convolution](crate::ops::ModuleOps::conv3d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv3d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv3d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [3D convolution](crate::ops::ModuleOps::conv3d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _channels_in, _depth_in, _height_in, _width_in] = x.shape().dims(); + let [_, channels_out, depth_out, height_out, width_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape( + grad, + Shape::new([ + channels_out, + batch_size * depth_out * height_out * width_out, + ]), + ); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Calculate the [1D convolution transpose](crate::ops::ModuleOps::conv_transpose1d) backward pass, returning the gradient for `x`. +pub(crate) fn conv_transpose1d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, +) -> FloatTensor { + let [batch_size, _c_out, out_length] = output_grad.shape().dims(); + let [c_in, _c_out_groups, kernel_size] = weight.shape().dims(); + + let grad = B::conv1d( + output_grad, + weight, + None, + ConvOptions::new( + options.stride, + options.padding, + options.dilation, + options.groups, + ), + ); + + if options.padding_out[0] == 0 { + return grad; + } + + let exp_length = calculate_conv_transpose_input_size( + kernel_size, + options.stride[0], + options.padding[0], + options.padding_out[0], + options.dilation[0], + out_length, + ); + + B::float_slice( + grad, + &[ + Slice::from(0..batch_size), + Slice::from(0..c_in), + Slice::from(0..exp_length), + ], + ) +} + +/// Calculate the [1D convolution transpose](crate::ops::ModuleOps::conv_transpose1d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv_transpose1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv_transpose1d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv_transpose1d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [1D convolution transpose](crate::ops::ModuleOps::conv_transpose1d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv_transpose1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _channels_in, _] = x.shape().dims(); + let [_, channels_out, length_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape(grad, Shape::new([channels_out, batch_size * length_out])); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Calculate the [2D convolution transpose](crate::ops::ModuleOps::conv_transpose2d) backward pass, returning the gradient for `x`. +pub(crate) fn conv_transpose2d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, +) -> FloatTensor { + let [batch_size, _c_out, out_h, out_w] = output_grad.shape().dims(); + let [c_in, _c_out_groups, k_h, k_w] = weight.shape().dims(); + + let grad = B::conv2d( + output_grad, + weight, + None, + ConvOptions::new( + options.stride, + options.padding, + options.dilation, + options.groups, + ), + ); + + if options.padding_out[0] == 0 && options.padding_out[1] == 0 { + return grad; + } + + let [exp_h, exp_w] = calculate_conv_transpose_input_sizes( + [k_h, k_w], + options.stride, + options.padding, + options.padding_out, + options.dilation, + [out_h, out_w], + ); + + B::float_slice( + grad, + &[ + Slice::from(0..batch_size), + Slice::from(0..c_in), + Slice::from(0..exp_h), + Slice::from(0..exp_w), + ], + ) +} + +/// Calculate the [2D convolution transpose](crate::ops::ModuleOps::conv_transpose2d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv_transpose2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv_transpose2d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv_transpose2d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [2D convolution transpose](crate::ops::ModuleOps::conv_transpose2d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv_transpose2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _channels_in, _, _] = x.shape().dims(); + let [_, channels_out, height_out, width_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape( + grad, + Shape::new([channels_out, batch_size * height_out * width_out]), + ); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Calculate the [3D convolution transpose](crate::ops::ModuleOps::conv_transpose3d) backward pass, returning the gradient for `x`. +pub(crate) fn conv_transpose3d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, +) -> FloatTensor { + let [batch_size, _c_out, out_d, out_h, out_w] = output_grad.shape().dims(); + let [c_in, _c_out_groups, k_d, k_h, k_w] = weight.shape().dims(); + + let grad = B::conv3d( + output_grad, + weight, + None, + ConvOptions::new( + options.stride, + options.padding, + options.dilation, + options.groups, + ), + ); + + if options.padding_out[0] == 0 && options.padding_out[1] == 0 && options.padding_out[2] == 0 { + return grad; + } + + let [exp_d, exp_h, exp_w] = calculate_conv_transpose_input_sizes( + [k_d, k_h, k_w], + options.stride, + options.padding, + options.padding_out, + options.dilation, + [out_d, out_h, out_w], + ); + + B::float_slice( + grad, + &[ + Slice::from(0..batch_size), + Slice::from(0..c_in), + Slice::from(0..exp_d), + Slice::from(0..exp_h), + Slice::from(0..exp_w), + ], + ) +} + +/// Calculate the [3D convolution transpose](crate::ops::ModuleOps::conv_transpose3d) backward pass, returning the gradient for `weight`. +pub(crate) fn conv_transpose3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, +) -> FloatTensor { + let weight_dtype = weight.dtype(); + let weight_shape = weight.shape(); + let weight_device = weight.device(); + + match options.groups == 1 { + true => conv_transpose3d_weight_grad_no_groups::(x, output_grad, weight_shape, options), + false => conv_transpose3d_weight_grad_groups::( + x, + B::float_zeros(weight_shape, &weight_device, weight_dtype.into()), + output_grad, + options, + ), + } +} + +/// Calculate the [3D convolution transpose](crate::ops::ModuleOps::conv_transpose3d) backward pass, returning the gradient for `bias`. +pub(crate) fn conv_transpose3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let [batch_size, _channels_in, _, _, _] = x.shape().dims(); + let [_, channels_out, depth_out, height_out, width_out] = output_grad.shape().dims(); + + let grad = B::float_swap_dims(output_grad, 0, 1); + let grad = B::float_reshape( + grad, + Shape::new([ + channels_out, + batch_size * depth_out * height_out * width_out, + ]), + ); + let grad = B::float_sum_dim(grad, 1); + + B::float_reshape(grad, bias.shape()) +} + +/// Execute a 1D convolution using a 2D convolution. +pub(crate) fn conv1d_from_conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, +) -> FloatTensor { + let [channels_out, _channels_in, kernel_size] = weight.shape().dims(); + let [batch_size, channels_in, length_in] = x.shape().dims(); + + let weight = B::float_reshape( + weight, + Shape::new([channels_out, channels_in / options.groups, kernel_size, 1]), + ); + let x = B::float_reshape(x, Shape::new([batch_size, channels_in, length_in, 1])); + + let tensor = B::conv2d( + x, + weight, + bias, + ConvOptions::new( + [options.stride[0], 1], + [options.padding[0], 0], + [options.dilation[0], 1], + options.groups, + ), + ); + let [batch_size, channels_out, height_out, _weight_out] = tensor.shape().dims(); + B::float_reshape(tensor, Shape::from([batch_size, channels_out, height_out])) +} + +/// Execute a 1D transposed convolution using a 2D transposed convolution. +pub(crate) fn conv_transpose1d_from_conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, +) -> FloatTensor { + let [channels_in, channels_out, kernel_size] = weight.shape().dims(); + let [batch_size, _channels_in, length_in] = x.shape().dims(); + + let weight = B::float_reshape( + weight, + Shape::new([channels_in, channels_out, kernel_size, 1]), + ); + let x = B::float_reshape(x, Shape::new([batch_size, channels_in, length_in, 1])); + + let tensor = B::conv_transpose2d( + x, + weight, + bias, + ConvTransposeOptions::new( + [options.stride[0], 1], + [options.padding[0], 0], + [options.padding_out[0], 0], + [options.dilation[0], 1], + options.groups, + ), + ); + let [batch_size, channels_out, height_out, _weight_out] = tensor.shape().dims(); + B::float_reshape(tensor, Shape::from([batch_size, channels_out, height_out])) +} + +fn conv1d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvOptions<1>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv1d( + x_swapped, + output_grad_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + if weight_grad.shape() != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv2d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvOptions<2>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv2d( + x_swapped, + output_grad_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + if weight_grad.shape() != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + Slice::from(0..weight_shape[3]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv3d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvOptions<3>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv3d( + x_swapped, + output_grad_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + if weight_grad.shape() != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + Slice::from(0..weight_shape[3]), + Slice::from(0..weight_shape[4]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv1d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, +) -> FloatTensor { + let [channels_out, increment_ci, kernel_size] = weight_grad.shape().dims(); + let increment_co = channels_out / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv1d( + x, + grad, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_co..end_idx_co), + Slice::from(0..increment_ci), + Slice::from(0..kernel_size), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +fn conv2d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, +) -> FloatTensor { + let [channels_out, increment_ci, kernel_size_1, kernel_size_2] = weight_grad.shape().dims(); + let increment_co = channels_out / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv2d( + x, + grad, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + let [_, _, kernel_size_1_tmp, kernel_size_2_tmp] = weight_grad_tmp.shape().dims(); + + if kernel_size_1_tmp != kernel_size_1 || kernel_size_2_tmp != kernel_size_2 { + let slices = vec![ + Slice::from(0..increment_co), + Slice::from(0..increment_ci), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + ]; + weight_grad_tmp = B::float_slice(weight_grad_tmp, &slices); + } + + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_co..end_idx_co), + Slice::from(0..increment_ci), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +fn conv3d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, +) -> FloatTensor { + let [ + channels_out, + increment_ci, + kernel_size_1, + kernel_size_2, + kernel_size_3, + ] = weight_grad.shape().dims(); + let increment_co = channels_out / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv3d( + x, + grad, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + let [ + _, + _, + kernel_size_1_tmp, + kernel_size_2_tmp, + kernel_size_3_tmp, + ] = weight_grad_tmp.shape().dims(); + + if kernel_size_1_tmp != kernel_size_1 + || kernel_size_2_tmp != kernel_size_2 + || kernel_size_3_tmp != kernel_size_3 + { + let slices = vec![ + Slice::from(0..increment_co), + Slice::from(0..increment_ci), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + Slice::from(0..kernel_size_3), + ]; + weight_grad_tmp = B::float_slice(weight_grad_tmp, &slices); + } + + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_co..end_idx_co), + Slice::from(0..increment_ci), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + Slice::from(0..kernel_size_3), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +fn conv_transpose1d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvTransposeOptions<1>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv1d( + output_grad_swapped, + x_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + let grad_shape = weight_grad.shape(); + if grad_shape != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv_transpose2d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvTransposeOptions<2>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv2d( + output_grad_swapped, + x_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + let grad_shape = weight_grad.shape(); + if grad_shape != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + Slice::from(0..weight_shape[3]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv_transpose3d_weight_grad_no_groups( + x: FloatTensor, + output_grad: FloatTensor, + weight_shape: Shape, + options: ConvTransposeOptions<3>, +) -> FloatTensor { + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + let weight_grad_swapped = B::conv3d( + output_grad_swapped, + x_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + let mut weight_grad = B::float_swap_dims(weight_grad_swapped, 0, 1); + + let grad_shape = weight_grad.shape(); + if grad_shape != weight_shape { + let slices = vec![ + Slice::from(0..weight_shape[0]), + Slice::from(0..weight_shape[1]), + Slice::from(0..weight_shape[2]), + Slice::from(0..weight_shape[3]), + Slice::from(0..weight_shape[4]), + ]; + weight_grad = B::float_slice(weight_grad, &slices); + } + weight_grad +} + +fn conv_transpose1d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, +) -> FloatTensor { + let [channels_in, increment_co, kernel_size] = weight_grad.shape().dims(); + let increment_ci = channels_in / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv1d( + grad, + x, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + let [_, _, kernel_size_tmp] = weight_grad_tmp.shape().dims(); + + if kernel_size_tmp != kernel_size { + let slices = vec![ + Slice::from(0..increment_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size), + ]; + weight_grad_tmp = B::float_slice(weight_grad_tmp, &slices); + } + + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_ci..end_idx_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +fn conv_transpose2d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, +) -> FloatTensor { + let [channels_in, increment_co, kernel_size_1, kernel_size_2] = weight_grad.shape().dims(); + let increment_ci = channels_in / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv2d( + grad, + x, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + let [_, _, kernel_size_1_tmp, kernel_size_2_tmp] = weight_grad_tmp.shape().dims(); + + if kernel_size_1_tmp != kernel_size_1 || kernel_size_2_tmp != kernel_size_2 { + let slices = vec![ + Slice::from(0..increment_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + ]; + weight_grad_tmp = B::float_slice(weight_grad_tmp, &slices); + } + + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_ci..end_idx_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +fn conv_transpose3d_weight_grad_groups( + x: FloatTensor, + mut weight_grad: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, +) -> FloatTensor { + let [ + channels_in, + increment_co, + kernel_size_1, + kernel_size_2, + kernel_size_3, + ] = weight_grad.shape().dims(); + let increment_ci = channels_in / options.groups; + + let x_swapped = B::float_swap_dims(x, 0, 1); + let output_grad_swapped = B::float_swap_dims(output_grad, 0, 1); + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let x_slice = vec![Slice::new( + start_idx_ci as isize, + Some(end_idx_ci as isize), + 1, + )]; + let x = B::float_slice(x_swapped.clone(), &x_slice); + let grad_slice = vec![Slice::new( + start_idx_co as isize, + Some(end_idx_co as isize), + 1, + )]; + let grad = B::float_slice(output_grad_swapped.clone(), &grad_slice); + let mut weight_grad_tmp = B::conv3d( + grad, + x, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + ); + weight_grad_tmp = B::float_swap_dims(weight_grad_tmp, 0, 1); + let [ + _, + _, + kernel_size_1_tmp, + kernel_size_2_tmp, + kernel_size_3_tmp, + ] = weight_grad_tmp.shape().dims(); + + if kernel_size_1_tmp != kernel_size_1 + || kernel_size_2_tmp != kernel_size_2 + || kernel_size_3_tmp != kernel_size_3 + { + let slices = vec![ + Slice::from(0..increment_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + Slice::from(0..kernel_size_3), + ]; + weight_grad_tmp = B::float_slice(weight_grad_tmp, &slices); + } + weight_grad = B::float_slice_assign( + weight_grad, + &[ + Slice::from(start_idx_ci..end_idx_ci), + Slice::from(0..increment_co), + Slice::from(0..kernel_size_1), + Slice::from(0..kernel_size_2), + Slice::from(0..kernel_size_3), + ], + weight_grad_tmp, + ); + } + + weight_grad +} + +/// Compute the `padding_out` for a transpose conv that exactly recovers the +/// original `size_in` from `size_out`, accounting for any input elements the +/// forward conv dropped. Shared by `conv{1,2,3}d_x_backward` and the CubeCL +/// dgrad fallback so the two paths can't drift. +pub fn calculate_padding_out( + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + size_in: usize, + size_out: usize, +) -> usize { + if stride <= 1 { + return 0; + } + + // Invert the transpose conv output formula to recover the exact number of + // input elements that a forward conv would drop for this (size_in, size_out). + // + // Forward: size_out = floor((size_in + 2*padding - dilated_kernel) / stride) + 1 + // Transpose: trans_out = (size_out - 1)*stride + dilated_kernel + padding_out - 2*padding + // Setting trans_out == size_in and solving for padding_out: + let dilated_kernel = dilation * (kernel_size - 1) + 1; + let base = (size_out as i64 - 1) * stride as i64 + dilated_kernel as i64 - 2 * padding as i64; + i64::max(0, size_in as i64 - base) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_output_size_1() { + let kernel_size = 3; + let stride = 1; + let padding = 1; + let size_in = 3; + let dilation = 1; + + let size_out = calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_out, 3); + } + + #[test] + fn test_calculate_output_size_2() { + let kernel_size = 5; + let stride = 2; + let padding = 3; + let size_in = 27; + let dilation = 1; + + let size_out = calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_out, 15); + } + + #[test] + fn test_calculate_output_size_3() { + let kernel_size = 5; + let stride = 2; + let padding = 3; + let size_in = 27; + let dilation = 2; + + let size_out = calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_out, 13); + } + + #[test] + fn test_calculate_same_padding_1() { + let kernel_size = 3; + let stride = 1; + let size_in = 3; + let dilation = 1; + + let padding = calculate_conv_padding(kernel_size, stride, size_in, size_in); + let size_out = calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_in, size_out, "Expected size"); + } + + #[test] + fn test_calculate_same_padding_2() { + let kernel_size = 3; + let stride = 2; + let size_in = 7; + let dilation = 1; + + let padding = calculate_conv_padding(kernel_size, stride, size_in, size_in); + let size_out = calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_in, size_out, "Expected size"); + } + + #[test] + fn test_calculate_output_padding_1() { + let kernel_size = 3; + let stride = 2; + let size_in = 7; + let size_out = 10; + let dilation = 1; + + let padding = calculate_conv_padding(kernel_size, stride, size_in, size_out); + let size_out_expected = + calculate_conv_output_size(kernel_size, stride, padding, dilation, size_in); + + assert_eq!(size_out, size_out_expected, "Expected size"); + } + + #[test] + fn test_expect_conv2d_output_shape() { + // in channels: 3 + // out channels: 8 + // size in: [27, 3] + // kernel size: [5, 3] + let stride = [2, 1]; + let padding = [3, 1]; + let dilation = [2, 1]; + let shape = calculate_conv_output_shape( + &Shape::new([12, 3, 27, 3]), + &Shape::new([8, 3, 5, 3]), + &stride, + &padding, + &dilation, + ) + .unwrap(); + assert_eq!(shape, Shape::new([12, 8, 13, 3])) + } +} diff --git a/crates/burn-backend/src/backend/ops/modules/ctc.rs b/crates/burn-backend/src/backend/ops/modules/ctc.rs new file mode 100644 index 0000000..383efbe --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/ctc.rs @@ -0,0 +1,613 @@ +use burn_std::{Shape, Slice}; + +use crate::{ + Backend, TensorMetadata, get_device_settings, + tensor::{BoolTensor, FloatTensor, IntTensor}, +}; + +/// Default CTC loss implementation using the forward (alpha) algorithm. +/// +/// Computes the Connectionist Temporal Classification loss by summing over +/// all valid alignments between the input and target sequences. +/// +/// # Arguments +/// +/// * `log_probs` - Log-probabilities of shape `[T, N, C]` +/// * `targets` - Target indices of shape `[N, S]` +/// * `input_lengths` - Actual input sequence lengths per batch element `[N]` +/// * `target_lengths` - Actual target lengths per batch element `[N]` +/// * `blank` - Index of the blank label +/// +/// # Returns +/// +/// Per-sample loss of shape `[N]` +pub fn ctc_loss_default( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, +) -> FloatTensor { + let alpha = AlphaCtx::::compute( + log_probs, + &targets, + input_lengths, + target_lengths.clone(), + blank, + ); + extract_loss::(&alpha, target_lengths) +} + +/// Compose the CTC gradient w.r.t. `log_probs` from pre-computed alpha, beta, and nll. +/// +/// The T-iteration alpha and beta recursions are the dominant cost of the backward +/// pass. Backends that fuse those recursions into a single kernel launch can call +/// this helper to reuse the gradient composition. +/// +/// # Arguments +/// +/// * `log_probs` - Log-probabilities `[T, N, C]` +/// * `targets` - Target label indices `[N, S]` +/// * `input_lengths` - Actual input sequence lengths per batch element `[N]` +/// * `grad_loss` - Upstream gradient w.r.t. the per-sample loss `[N]` +/// * `log_alpha_full` - Alpha recursion output `[T, N, 2S+1]` +/// * `log_beta_full` - Beta recursion output `[T, N, 2S+1]` +/// * `nll` - Per-sample negative log-likelihood (forward loss) `[N]` +/// * `blank` - Index of the blank label +#[allow(clippy::too_many_arguments)] +pub fn ctc_grad_from_alpha_beta_default( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + grad_loss: FloatTensor, + log_alpha_full: FloatTensor, + log_beta_full: FloatTensor, + nll: FloatTensor, + blank: usize, +) -> FloatTensor { + let log_probs_shape = log_probs.shape(); + let [max_input_length, batch_size, num_classes] = log_probs_shape.dims::<3>(); + let target_shape = targets.shape(); + let max_target_len = target_shape.dims::<2>()[1]; + let max_l_prime_len = 2 * max_target_len + 1; + let device = log_probs.device(); + let int_dtype: burn_std::IntDType = targets.dtype().into(); + let settings = get_device_settings::(&device); + + let blank_inserted_targets = insert_blanks::( + &targets, + batch_size, + max_target_len, + max_l_prime_len, + blank, + &device, + int_dtype, + ); + + // Both log_alpha[t, n, s] and log_beta[t, n, s] include a factor of + // log_probs[t, n, l'[s]] (added on every recursion step). The CTC paper's + // alpha_hat * beta_hat product divides one of those factors out, so we + // subtract log_probs[t, n, l'[s]] when forming log_post. + // + // We then divide by total_prob = exp(-nll) to obtain the alignment + // posterior, which in log space means *adding* nll (since nll = -log P, + // dividing by P is adding nll). Per PyTorch's CTC backward kernel: + // log_post[t, n, s] = log_alpha + log_beta - log_probs[t, n, l'[s]] - log P + // = log_alpha + log_beta - log_probs[t, n, l'[s]] + nll + let indices_3d = B::int_reshape( + blank_inserted_targets, + Shape::new([1, batch_size, max_l_prime_len]), + ); + let indices_3d = B::int_expand( + indices_3d, + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let log_probs_at_l = B::float_gather(2, log_probs.clone(), indices_3d.clone()); + + // Samples with an unreachable target yield nll = +inf. For those, log_alpha + // stays at -inf at many (t, s) while log_beta is finite at the boundary, so + // log_post = (-inf) + finite - finite + (+inf) = NaN and -exp(NaN) = NaN + // contaminates the gradient. `NaN * 0 = NaN` under IEEE 754, so zero_infinity + // masking on the outer grad_loss can't clear it. Capture the mask now and + // zero the gradient for those samples at the end. + let nll_is_inf = B::float_is_inf(nll.clone(), settings.bool_dtype); + + let nll_b = B::float_reshape(nll, Shape::new([1, batch_size, 1])); + let nll_b = B::float_expand( + nll_b, + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let log_post = B::float_add( + B::float_sub(B::float_add(log_alpha_full, log_beta_full), log_probs_at_l), + nll_b, + ); + + // grad starts as exp(log_probs) * grad_loss[None, :, None]. + let grad_loss_3d = B::float_reshape(grad_loss, Shape::new([1, batch_size, 1])); + let grad_loss_b = B::float_expand( + grad_loss_3d.clone(), + Shape::new([max_input_length, batch_size, num_classes]), + ); + let mut grad = B::float_mul(B::float_exp(log_probs), grad_loss_b); + + // Subtract sum over s of grad_loss[n] * exp(log_post[t, n, s]) at index l'[n, s]. + let grad_loss_post = B::float_expand( + grad_loss_3d, + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let scatter_value = B::float_neg(B::float_mul(B::float_exp(log_post), grad_loss_post)); + + grad = B::float_scatter_add(2, grad, indices_3d, scatter_value); + + // Mask out timesteps where t >= input_lengths[n]. + let t_indices = B::int_arange(0..max_input_length as i64, &device, int_dtype); + let t_indices = B::int_reshape(t_indices, Shape::new([max_input_length, 1, 1])); + let t_indices = B::int_expand( + t_indices, + Shape::new([max_input_length, batch_size, num_classes]), + ); + let il_b = B::int_reshape(input_lengths, Shape::new([1, batch_size, 1])); + let il_b = B::int_expand( + il_b, + Shape::new([max_input_length, batch_size, num_classes]), + ); + let oob_mask = B::int_greater_equal(t_indices, il_b, settings.bool_dtype); + + // Broadcast the nll-is-inf mask across [T, N, C] and OR with oob_mask so a + // single mask_fill zeros both unreachable samples and out-of-bound timesteps. + let nll_inf_b = B::bool_reshape(nll_is_inf, Shape::new([1, batch_size, 1])); + let nll_inf_b = B::bool_expand( + nll_inf_b, + Shape::new([max_input_length, batch_size, num_classes]), + ); + let mask = B::bool_or(oob_mask, nll_inf_b); + B::float_mask_fill(grad, mask, 0.0.into()) +} + +/// Cached state from the alpha recursion. Only `last` is consumed by +/// `ctc_loss_default` (via `extract_loss`); the other fields hold intermediate +/// products that backends with a native backward kernel could reuse if wired +/// up. They are kept here to document the recursion's outputs. +#[allow(dead_code)] +struct AlphaCtx { + /// `log_alpha[T, N, 2S+1]` (full history). + full: FloatTensor, + /// `log_alpha[T-1, :, :]` (last timestep; used to read out the loss). + last: FloatTensor, + /// `l'` after blank insertion `[N, 2S+1]`. + blank_inserted_targets: IntTensor, + /// `log_probs[t, n, l'[n, s]]` pre-gathered as `[T, N, 2S+1]`. + log_probs_at_l_full: FloatTensor, + max_l_prime_len: usize, +} + +impl AlphaCtx { + fn compute( + log_probs: FloatTensor, + targets: &IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, + ) -> Self { + let log_probs_shape = log_probs.shape(); + let [max_input_length, batch_size, num_classes] = log_probs_shape.dims::<3>(); + let target_shape = targets.shape(); + let max_target_len = target_shape.dims::<2>()[1]; + let device = log_probs.device(); + let float_dtype: burn_std::FloatDType = log_probs.dtype().into(); + let int_dtype: burn_std::IntDType = targets.dtype().into(); + let settings = get_device_settings::(&device); + + let max_l_prime_len = 2 * max_target_len + 1; + let blank_inserted_targets = insert_blanks::( + targets, + batch_size, + max_target_len, + max_l_prime_len, + blank, + &device, + int_dtype, + ); + + // Pre-allocate the full alpha tensor [T, N, 2S+1] filled with -inf. + let mut alpha_full = B::float_full( + Shape::new([max_input_length, batch_size, max_l_prime_len]), + f32::NEG_INFINITY.into(), + &device, + float_dtype, + ); + + // Initialize alpha[0, :, 0] = log_probs[0, :, blank] + // and alpha[0, :, 1] = log_probs[0, :, l'[1]]. + let log_probs_t0 = B::float_slice( + log_probs.clone(), + &[Slice::new(0, Some(1), 1), Slice::full(), Slice::full()], + ); + let log_probs_t0 = B::float_reshape(log_probs_t0, Shape::new([batch_size, num_classes])); + + let first_blank = B::int_slice( + blank_inserted_targets.clone(), + &[Slice::full(), Slice::new(0, Some(1), 1)], + ); + let log_prob_blank = B::float_gather(1, log_probs_t0.clone(), first_blank); + // Broadcast to [1, N, 1] for slice_assign into alpha_full. + let log_prob_blank_3d = B::float_reshape(log_prob_blank, Shape::new([1, batch_size, 1])); + alpha_full = B::float_slice_assign( + alpha_full, + &[ + Slice::new(0, Some(1), 1), + Slice::full(), + Slice::new(0, Some(1), 1), + ], + log_prob_blank_3d, + ); + + if max_l_prime_len > 1 { + let first_label = B::int_slice( + blank_inserted_targets.clone(), + &[Slice::full(), Slice::new(1, Some(2), 1)], + ); + let log_prob_first = B::float_gather(1, log_probs_t0, first_label); + let log_prob_first_3d = + B::float_reshape(log_prob_first, Shape::new([1, batch_size, 1])); + alpha_full = B::float_slice_assign( + alpha_full, + &[ + Slice::new(0, Some(1), 1), + Slice::full(), + Slice::new(1, Some(2), 1), + ], + log_prob_first_3d, + ); + } + + // Track the latest row separately for the recursion (cheaper than + // re-slicing alpha_full each iteration). + let mut log_alpha = B::float_slice( + alpha_full.clone(), + &[Slice::new(0, Some(1), 1), Slice::full(), Slice::full()], + ); + log_alpha = B::float_reshape(log_alpha, Shape::new([batch_size, max_l_prime_len])); + + let l_prime_mask = create_l_prime_mask::( + &blank_inserted_targets, + batch_size, + max_l_prime_len, + blank, + &device, + int_dtype, + settings.bool_dtype, + ); + let s_mask = create_s_mask::( + &target_lengths, + batch_size, + max_l_prime_len, + &device, + int_dtype, + settings.bool_dtype, + ); + + // Hoist out of the T-loop: padding tensors for right_shift (same + // value/shape at every iteration) and the full `[T, N, 2S+1]` + // gather of log_probs at l' (one T-sized gather replaces T small + // gathers). + let pad_1 = B::float_full( + Shape::new([batch_size, 1]), + f32::NEG_INFINITY.into(), + &device, + float_dtype, + ); + let pad_2 = B::float_full( + Shape::new([batch_size, 2]), + f32::NEG_INFINITY.into(), + &device, + float_dtype, + ); + let indices_3d = B::int_expand( + B::int_reshape( + blank_inserted_targets.clone(), + Shape::new([1, batch_size, max_l_prime_len]), + ), + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let log_probs_at_l_full = B::float_gather(2, log_probs.clone(), indices_3d); + + // Precompute `combined_mask_all[t, n, s] = (input_lengths[n] > t) AND + // s_mask[n, s]` for every t in one shot. The T-loop reads its row via + // a metadata-only slice instead of recomputing the `int_greater_elem` + // + bool_and per iteration. + let t_indices_2d = B::int_expand( + B::int_reshape( + B::int_arange(0..max_input_length as i64, &device, int_dtype), + Shape::new([max_input_length, 1]), + ), + Shape::new([max_input_length, batch_size]), + ); + let il_tn = B::int_expand( + B::int_reshape(input_lengths.clone(), Shape::new([1, batch_size])), + Shape::new([max_input_length, batch_size]), + ); + let t_mask_all = B::bool_expand( + B::bool_reshape( + B::int_greater(il_tn, t_indices_2d, settings.bool_dtype), + Shape::new([max_input_length, batch_size, 1]), + ), + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let s_mask_bcast = B::bool_expand( + B::bool_reshape(s_mask.clone(), Shape::new([1, batch_size, max_l_prime_len])), + Shape::new([max_input_length, batch_size, max_l_prime_len]), + ); + let combined_mask_all = B::bool_and(t_mask_all, s_mask_bcast); + + for t in 1..max_input_length { + let combined_mask = B::bool_reshape( + B::bool_slice( + combined_mask_all.clone(), + &[ + Slice::new(t as isize, Some(t as isize + 1), 1), + Slice::full(), + Slice::full(), + ], + ), + Shape::new([batch_size, max_l_prime_len]), + ); + + let log_alpha_s = log_alpha.clone(); + let log_alpha_s_m1 = right_shift::(&log_alpha, &pad_1, max_l_prime_len, 1); + let log_alpha_s_m2 = right_shift::(&log_alpha, &pad_2, max_l_prime_len, 2); + + let bar = log_sum_exp::(log_alpha_s, log_alpha_s_m1, settings.bool_dtype); + let bar_with_skip = log_sum_exp::(bar.clone(), log_alpha_s_m2, settings.bool_dtype); + let log_alpha_combined = B::float_mask_where(bar, l_prime_mask.clone(), bar_with_skip); + + // Slice row t from the pre-gathered `[T, N, 2S+1]` tensor. + let log_probs_at_l = B::float_reshape( + B::float_slice( + log_probs_at_l_full.clone(), + &[ + Slice::new(t as isize, Some(t as isize + 1), 1), + Slice::full(), + Slice::full(), + ], + ), + Shape::new([batch_size, max_l_prime_len]), + ); + let new_alpha = B::float_add(log_alpha_combined, log_probs_at_l); + log_alpha = B::float_mask_where(log_alpha, combined_mask, new_alpha); + + let log_alpha_3d = B::float_reshape( + log_alpha.clone(), + Shape::new([1, batch_size, max_l_prime_len]), + ); + alpha_full = B::float_slice_assign( + alpha_full, + &[ + Slice::new(t as isize, Some(t as isize + 1), 1), + Slice::full(), + Slice::full(), + ], + log_alpha_3d, + ); + } + + Self { + full: alpha_full, + last: log_alpha, + blank_inserted_targets, + log_probs_at_l_full, + max_l_prime_len, + } + } +} + +/// Extract the per-sample loss from the last alpha row. +fn extract_loss(alpha: &AlphaCtx, target_lengths: IntTensor) -> FloatTensor { + let log_alpha_shape = alpha.last.shape(); + let [batch_size, _] = log_alpha_shape.dims::<2>(); + let settings = get_device_settings::(&alpha.last.device()); + + let last_blank_idx = B::int_mul_scalar(target_lengths.clone(), 2.into()); + let last_blank_idx = B::int_reshape(last_blank_idx, Shape::new([batch_size, 1])); + let last_label_idx = B::int_clamp_min( + B::int_sub_scalar(last_blank_idx.clone(), 1.into()), + 0.into(), + ); + + let log_alpha_last_blank = B::float_gather(1, alpha.last.clone(), last_blank_idx); + let log_alpha_last_blank = B::float_reshape(log_alpha_last_blank, Shape::new([batch_size])); + + let log_alpha_last_label = B::float_gather(1, alpha.last.clone(), last_label_idx); + let log_alpha_last_label = B::float_reshape(log_alpha_last_label, Shape::new([batch_size])); + + // For target_lengths == 0, last_label is meaningless: substitute -inf. + let target_len_zero = B::int_equal_elem(target_lengths, 0.into(), settings.bool_dtype); + let log_alpha_last_label = B::float_mask_fill( + log_alpha_last_label, + target_len_zero, + f32::NEG_INFINITY.into(), + ); + + let log_likelihood = log_sum_exp::( + log_alpha_last_blank, + log_alpha_last_label, + settings.bool_dtype, + ); + B::float_neg(log_likelihood) +} + +/// Insert blank labels between each target label: [b, l1, b, l2, ..., b] +fn insert_blanks( + targets: &IntTensor, + batch_size: usize, + max_target_len: usize, + max_l_prime_len: usize, + blank: usize, + device: &B::Device, + int_dtype: burn_std::IntDType, +) -> IntTensor { + let result = B::int_full( + Shape::new([batch_size, max_l_prime_len]), + (blank as i64).into(), + device, + int_dtype, + ); + + if max_target_len == 0 { + return result; + } + + // Place every target label at odd columns {1, 3, 5, ...} in one + // strided slice_assign, equivalent to `result[:, 1::2] = targets`. + B::int_slice_assign( + result, + &[Slice::full(), Slice::new(1, None, 2)], + targets.clone(), + ) +} + +/// Right-shift a 2D float tensor by `shift` positions, prepending the +/// pre-allocated `padding` tensor (shape `[batch_size, shift]`, value +/// `-inf`) instead of materializing it each call. +/// +/// Called inside the T-loop of the alpha recursion; hoisting the padding +/// out of the loop eliminates `O(T)` `float_full` allocations. +fn right_shift( + tensor: &FloatTensor, + padding: &FloatTensor, + cols: usize, + shift: usize, +) -> FloatTensor { + // Shifting by more than the column count pushes every data slot off + // the right. Avoid the `cols - shift` usize underflow when + // `max_target_len == 0` (so `max_l_prime_len == 1`) by narrowing the + // all-`-inf` padding down to `cols`. + if cols < shift { + return B::float_slice( + padding.clone(), + &[Slice::full(), Slice::new(0, Some(cols as isize), 1)], + ); + } + let shortened = B::float_slice( + tensor.clone(), + &[ + Slice::full(), + Slice::new(0, Some((cols - shift) as isize), 1), + ], + ); + B::float_cat(alloc::vec![padding.clone(), shortened], 1) +} + +/// Compute `log(exp(a) + exp(b))` in a numerically stable way. +/// +/// `log_sum_exp(a, b) = max(a, b) + log1p(exp(-|a - b|))`. The edge case is +/// `a = b = -inf`, where `-|(-inf) - (-inf)| = NaN`; we detect `max == -inf` +/// and substitute a `-inf` diff so the final sum stays `-inf` (both via the +/// mask and because `log1p(exp(-inf)) = 0`). Gradient-safe: no `NaN` flows +/// through the forward intermediates when inputs are `-inf`. +/// +/// Precondition: inputs must be `<= 0` (log-probabilities). `+inf` inputs are +/// not guarded and produce `NaN`; callers outside the CTC recursion should +/// validate this themselves. +fn log_sum_exp( + a: FloatTensor, + b: FloatTensor, + bool_dtype: burn_std::BoolDType, +) -> FloatTensor { + // `-inf` values in `a` or `b` would make `a - b` evaluate to `NaN` + // (when both are `-inf`) and the backward pass through that `NaN` + // intermediate propagates `NaN` into the gradient even when the + // forward mask discards it (`0 * NaN = NaN` in IEEE). Clamp `-inf` + // to `0` on safe copies used only for the diff computation; compute + // `max` on the original values so its output is correct in the + // `-inf` cases. + let a_is_neg_inf = B::float_equal_elem(a.clone(), f32::NEG_INFINITY.into(), bool_dtype); + let b_is_neg_inf = B::float_equal_elem(b.clone(), f32::NEG_INFINITY.into(), bool_dtype); + let either_neg_inf = B::bool_or(a_is_neg_inf.clone(), b_is_neg_inf.clone()); + + let a_safe = B::float_mask_fill(a.clone(), a_is_neg_inf, 0.0.into()); + let b_safe = B::float_mask_fill(b.clone(), b_is_neg_inf, 0.0.into()); + + let lt_mask = B::float_lower(a.clone(), b.clone(), bool_dtype); + let mx = B::float_mask_where(a, lt_mask, b); + + // diff_safe = -|a_safe - b_safe|. Finite by construction. When either + // input was `-inf`, force it to `-inf` so `exp(diff) == 0` and the + // `log1p` term contributes nothing (`result = mx`). When both were + // `-inf`, `mx = -inf` so `result = -inf + 0 = -inf`. + let diff_safe = B::float_neg(B::float_abs(B::float_sub(a_safe, b_safe))); + let diff_final = B::float_mask_fill(diff_safe, either_neg_inf, f32::NEG_INFINITY.into()); + + B::float_add(mx, B::float_log1p(B::float_exp(diff_final))) +} + +/// Mask for the alpha skip transition: `l'[s] != blank AND l'[s] != l'[s-2] AND s >= 2`. +fn create_l_prime_mask( + blank_inserted_targets: &IntTensor, + batch_size: usize, + max_l_prime_len: usize, + blank: usize, + device: &B::Device, + int_dtype: burn_std::IntDType, + bool_dtype: burn_std::BoolDType, +) -> BoolTensor { + // The mask requires `s >= 2`, which is unsatisfiable when max_l_prime_len < 2 + // (i.e. targets have shape [N, 0]). Bail out before the `max_l_prime_len - 2` + // usize subtraction underflows. + if max_l_prime_len < 2 { + return B::bool_zeros( + Shape::new([batch_size, max_l_prime_len]), + device, + bool_dtype, + ); + } + let l_prime = blank_inserted_targets.clone(); + + let not_blank = B::int_not_equal_elem(l_prime.clone(), (blank as i64).into(), bool_dtype); + + let l_prime_shifted = { + let padding = B::int_full( + Shape::new([batch_size, 2]), + (blank as i64).into(), + device, + int_dtype, + ); + let shortened = B::int_slice( + l_prime.clone(), + &[ + Slice::full(), + Slice::new(0, Some((max_l_prime_len - 2) as isize), 1), + ], + ); + B::int_cat(alloc::vec![padding, shortened], 1) + }; + let not_equal_s_m2 = B::int_not_equal(l_prime, l_prime_shifted, bool_dtype); + + let col_indices = B::int_arange(0..max_l_prime_len as i64, device, int_dtype); + let col_indices = B::int_reshape(col_indices, Shape::new([1, max_l_prime_len])); + let col_indices = B::int_expand(col_indices, Shape::new([batch_size, max_l_prime_len])); + let s_ge_2 = B::int_greater_equal_elem(col_indices, 2.into(), bool_dtype); + + B::bool_and(B::bool_and(not_blank, not_equal_s_m2), s_ge_2) +} + +/// Create a mask for valid s positions: s < 2 * target_length + 1 +fn create_s_mask( + target_lengths: &IntTensor, + batch_size: usize, + max_l_prime_len: usize, + device: &B::Device, + int_dtype: burn_std::IntDType, + bool_dtype: burn_std::BoolDType, +) -> BoolTensor { + let col_indices = B::int_arange(0..max_l_prime_len as i64, device, int_dtype); + let col_indices = B::int_reshape(col_indices, Shape::new([1, max_l_prime_len])); + let col_indices = B::int_expand(col_indices, Shape::new([batch_size, max_l_prime_len])); + + let lengths = B::int_mul_scalar(target_lengths.clone(), 2.into()); + let lengths = B::int_add_scalar(lengths, 1.into()); + let lengths = B::int_reshape(lengths, Shape::new([batch_size, 1])); + let lengths = B::int_expand(lengths, Shape::new([batch_size, max_l_prime_len])); + + B::int_lower(col_indices, lengths, bool_dtype) +} diff --git a/crates/burn-backend/src/backend/ops/modules/grid_sample.rs b/crates/burn-backend/src/backend/ops/modules/grid_sample.rs new file mode 100644 index 0000000..37ccc54 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/grid_sample.rs @@ -0,0 +1,320 @@ +use crate::{ + Backend, TensorMetadata, get_device_settings, + ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}, + tensor::FloatTensor, +}; +use alloc::vec; +use burn_std::{Shape, Slice}; + +/// Reference implementation of grid_sample_2d that supports all options. +/// +/// # Arguments +/// +/// * `tensor` - The tensor being sampled from, must be contiguous with shape (N, C, H_in, W_in) +/// * `grid` - A tensor of locations, with shape (N, H_out, W_out, 2). Values are [-1, 1]. +/// A [x = -1, y = -1] means top-left, and [x = 1, y = 1] means bottom-right +/// * `options` - Grid sampling options +/// +/// # Returns +/// +/// A tensor with shape (N, C, H_out, W_out) +pub fn float_grid_sample_2d_ref( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, +) -> FloatTensor { + match options.mode { + InterpolateMode::Bilinear => float_grid_sample_2d_bilinear::( + tensor, + grid, + options.padding_mode, + options.align_corners, + ), + _ => todo!( + "Default implementation for grid_sample_2d with {:?} unimplemented", + options.mode + ), + } +} + +/// Bilinear grid sampling implementation. +fn float_grid_sample_2d_bilinear( + tensor: FloatTensor, + grid: FloatTensor, + padding_mode: GridSamplePaddingMode, + align_corners: bool, +) -> FloatTensor { + let n = tensor.shape()[0]; + let c = tensor.shape()[1]; + let h_in = tensor.shape()[2]; + let w_in = tensor.shape()[3]; + let h_out = grid.shape()[1]; + let w_out = grid.shape()[2]; + let spatial_in = h_in * w_in; + let spatial_out = h_out * w_out; + let device = tensor.device(); + + // Separate x and y coordinates from grid + // shape: (N, H_out, W_out, 1) + let grid_x_slice = vec![ + Slice::new(0, Some(n as isize), 1), + Slice::new(0, Some(h_out as isize), 1), + Slice::new(0, Some(w_out as isize), 1), + Slice::new(0, Some(1), 1), + ]; + let grid_y_slice = vec![ + Slice::new(0, Some(n as isize), 1), + Slice::new(0, Some(h_out as isize), 1), + Slice::new(0, Some(w_out as isize), 1), + Slice::new(1, Some(2), 1), + ]; + + let grid_x = B::float_slice(grid.clone(), &grid_x_slice); + let grid_x = B::float_reshape(grid_x, Shape::new([n, 1, h_out, w_out])); + let grid_y = B::float_slice(grid.clone(), &grid_y_slice); + let grid_y = B::float_reshape(grid_y, Shape::new([n, 1, h_out, w_out])); + + // Convert normalized grid coordinates [-1, 1] to pixel coordinates + let w_in_f = w_in as f64; + let h_in_f = h_in as f64; + + let (grid_x, grid_y) = if align_corners { + // align_corners=true: x_pixel = (x_norm + 1) * (width - 1) / 2 + // Maps -1 to 0 and 1 to width - 1 + let grid_x = B::float_add_scalar(grid_x, 1f32.into()); + let grid_x = B::float_mul_scalar(grid_x, ((w_in_f - 1.0) / 2.0).into()); + + let grid_y = B::float_add_scalar(grid_y, 1f32.into()); + let grid_y = B::float_mul_scalar(grid_y, ((h_in_f - 1.0) / 2.0).into()); + + (grid_x, grid_y) + } else { + // align_corners=false: x_pixel = (x_norm + 1) * width / 2 - 0.5 + // Maps -1 to -0.5 and 1 to width - 0.5 + let grid_x = B::float_add_scalar(grid_x, 1f32.into()); + let grid_x = B::float_mul_scalar(grid_x, (w_in_f / 2.0).into()); + let grid_x = B::float_sub_scalar(grid_x, 0.5f32.into()); + + let grid_y = B::float_add_scalar(grid_y, 1f32.into()); + let grid_y = B::float_mul_scalar(grid_y, (h_in_f / 2.0).into()); + let grid_y = B::float_sub_scalar(grid_y, 0.5f32.into()); + + (grid_x, grid_y) + }; + + // Apply padding mode to coordinates + let (grid_x, grid_y) = match padding_mode { + GridSamplePaddingMode::Border => { + // Clamp coordinates to valid range [0, size-1] + let grid_x = B::float_clamp(grid_x, 0f32.into(), ((w_in - 1) as f32).into()); + let grid_y = B::float_clamp(grid_y, 0f32.into(), ((h_in - 1) as f32).into()); + (grid_x, grid_y) + } + GridSamplePaddingMode::Reflection => { + // Reflect coordinates at boundaries + let grid_x = reflect_coordinates::(grid_x, w_in_f, align_corners); + let grid_y = reflect_coordinates::(grid_y, h_in_f, align_corners); + (grid_x, grid_y) + } + GridSamplePaddingMode::Zeros => { + // Keep coordinates as-is, we'll mask out-of-bounds later + (grid_x, grid_y) + } + }; + + // Get floor indices for the four corners + let grid_x_floored = B::float_floor(grid_x.clone()); + let grid_y_floored = B::float_floor(grid_y.clone()); + + // Compute interpolation weights (fractional part) + let x_frac = B::float_sub(grid_x.clone(), grid_x_floored.clone()); + let y_frac = B::float_sub(grid_y.clone(), grid_y_floored.clone()); + + // Convert to integer indices + let settings = get_device_settings::(&device); + let x0 = B::float_into_int(grid_x_floored.clone(), settings.int_dtype); + let y0 = B::float_into_int(grid_y_floored.clone(), settings.int_dtype); + let x1 = B::float_into_int( + B::float_add_scalar(grid_x_floored, 1f32.into()), + settings.int_dtype, + ); + let y1 = B::float_into_int( + B::float_add_scalar(grid_y_floored, 1f32.into()), + settings.int_dtype, + ); + + // Create masks for out-of-bounds coordinates (only used for zeros padding) + let (mask_00, mask_01, mask_10, mask_11) = if padding_mode == GridSamplePaddingMode::Zeros { + let x0_valid = B::int_greater_equal_elem(x0.clone(), 0.into(), settings.bool_dtype); + let x0_valid = B::bool_and( + x0_valid, + B::int_lower_elem(x0.clone(), (w_in as i32).into(), settings.bool_dtype), + ); + let x1_valid = B::int_greater_equal_elem(x1.clone(), 0.into(), settings.bool_dtype); + let x1_valid = B::bool_and( + x1_valid, + B::int_lower_elem(x1.clone(), (w_in as i32).into(), settings.bool_dtype), + ); + let y0_valid = B::int_greater_equal_elem(y0.clone(), 0.into(), settings.bool_dtype); + let y0_valid = B::bool_and( + y0_valid, + B::int_lower_elem(y0.clone(), (h_in as i32).into(), settings.bool_dtype), + ); + let y1_valid = B::int_greater_equal_elem(y1.clone(), 0.into(), settings.bool_dtype); + let y1_valid = B::bool_and( + y1_valid, + B::int_lower_elem(y1.clone(), (h_in as i32).into(), settings.bool_dtype), + ); + + ( + Some(B::bool_and(x0_valid.clone(), y0_valid.clone())), + Some(B::bool_and(x0_valid.clone(), y1_valid.clone())), + Some(B::bool_and(x1_valid.clone(), y0_valid)), + Some(B::bool_and(x1_valid, y1_valid)), + ) + } else { + (None, None, None, None) + }; + + // Clamp indices to valid range for gather + let x0_clamped = B::int_clamp(x0, 0.into(), ((w_in - 1) as i32).into()); + let x1_clamped = B::int_clamp(x1, 0.into(), ((w_in - 1) as i32).into()); + let y0_clamped = B::int_clamp(y0, 0.into(), ((h_in - 1) as i32).into()); + let y1_clamped = B::int_clamp(y1, 0.into(), ((h_in - 1) as i32).into()); + + // Linear indices: idx = y * W_in + x + let w_in_scalar: i32 = w_in as i32; + let idx_00 = B::int_add( + B::int_mul_scalar(y0_clamped.clone(), w_in_scalar.into()), + x0_clamped.clone(), + ); + let idx_01 = B::int_add( + B::int_mul_scalar(y1_clamped.clone(), w_in_scalar.into()), + x0_clamped, + ); + let idx_10 = B::int_add( + B::int_mul_scalar(y0_clamped, w_in_scalar.into()), + x1_clamped.clone(), + ); + let idx_11 = B::int_add( + B::int_mul_scalar(y1_clamped, w_in_scalar.into()), + x1_clamped, + ); + + // [N, 1, H_out, W_out] -> [N, 1, H_out * W_out] + let idx_00 = B::int_reshape(idx_00, Shape::new([n, 1, spatial_out])); + let idx_01 = B::int_reshape(idx_01, Shape::new([n, 1, spatial_out])); + let idx_10 = B::int_reshape(idx_10, Shape::new([n, 1, spatial_out])); + let idx_11 = B::int_reshape(idx_11, Shape::new([n, 1, spatial_out])); + + // [N, 1, spatial] -> [N, C, spatial] + let idx_00 = B::int_expand(idx_00, Shape::new([n, c, spatial_out])); + let idx_01 = B::int_expand(idx_01, Shape::new([n, c, spatial_out])); + let idx_10 = B::int_expand(idx_10, Shape::new([n, c, spatial_out])); + let idx_11 = B::int_expand(idx_11, Shape::new([n, c, spatial_out])); + + let tensor_flat = B::float_reshape(tensor, Shape::new([n, c, spatial_in])); + + let sample_00 = B::float_gather(2, tensor_flat.clone(), idx_00); + let sample_01 = B::float_gather(2, tensor_flat.clone(), idx_01); + let sample_10 = B::float_gather(2, tensor_flat.clone(), idx_10); + let sample_11 = B::float_gather(2, tensor_flat, idx_11); + + // Reshape samples to (N, C, H_out, W_out) + let sample_00 = B::float_reshape(sample_00, Shape::new([n, c, h_out, w_out])); + let sample_01 = B::float_reshape(sample_01, Shape::new([n, c, h_out, w_out])); + let sample_10 = B::float_reshape(sample_10, Shape::new([n, c, h_out, w_out])); + let sample_11 = B::float_reshape(sample_11, Shape::new([n, c, h_out, w_out])); + + // Apply masks for zeros padding (set out-of-bounds samples to 0) + let (sample_00, sample_01, sample_10, sample_11) = + if padding_mode == GridSamplePaddingMode::Zeros { + let mask_00 = mask_00.unwrap(); + let mask_01 = mask_01.unwrap(); + let mask_10 = mask_10.unwrap(); + let mask_11 = mask_11.unwrap(); + + let mask_00_inv = B::bool_not(mask_00); + let mask_00_inv = B::bool_reshape(mask_00_inv, Shape::new([n, 1, h_out, w_out])); + let mask_00_inv = B::bool_expand(mask_00_inv, Shape::new([n, c, h_out, w_out])); + let mask_01_inv = B::bool_not(mask_01); + let mask_01_inv = B::bool_reshape(mask_01_inv, Shape::new([n, 1, h_out, w_out])); + let mask_01_inv = B::bool_expand(mask_01_inv, Shape::new([n, c, h_out, w_out])); + let mask_10_inv = B::bool_not(mask_10); + let mask_10_inv = B::bool_reshape(mask_10_inv, Shape::new([n, 1, h_out, w_out])); + let mask_10_inv = B::bool_expand(mask_10_inv, Shape::new([n, c, h_out, w_out])); + let mask_11_inv = B::bool_not(mask_11); + let mask_11_inv = B::bool_reshape(mask_11_inv, Shape::new([n, 1, h_out, w_out])); + let mask_11_inv = B::bool_expand(mask_11_inv, Shape::new([n, c, h_out, w_out])); + + ( + B::float_mask_fill(sample_00, mask_00_inv, 0f32.into()), + B::float_mask_fill(sample_01, mask_01_inv, 0f32.into()), + B::float_mask_fill(sample_10, mask_10_inv, 0f32.into()), + B::float_mask_fill(sample_11, mask_11_inv, 0f32.into()), + ) + } else { + (sample_00, sample_01, sample_10, sample_11) + }; + + // Compute bilinear interpolation weights + let one_minus_x = B::float_neg(x_frac.clone()); + let one_minus_x = B::float_add_scalar(one_minus_x, 1f32.into()); + + let one_minus_y = B::float_neg(y_frac.clone()); + let one_minus_y = B::float_add_scalar(one_minus_y, 1f32.into()); + + let weight_00 = B::float_mul(one_minus_x.clone(), one_minus_y.clone()); + let weight_01 = B::float_mul(one_minus_x.clone(), y_frac.clone()); + let weight_10 = B::float_mul(x_frac.clone(), one_minus_y); + let weight_11 = B::float_mul(x_frac, y_frac); + + // Bilinear interpolation + let result = B::float_mul(sample_00, weight_00); + let result = B::float_add(result, B::float_mul(sample_01, weight_01)); + let result = B::float_add(result, B::float_mul(sample_10, weight_10)); + + B::float_add(result, B::float_mul(sample_11, weight_11)) +} + +/// Reflect coordinates at boundaries using a triangle wave pattern. +/// +/// For align_corners=true: reflects within [0, size-1] +/// For align_corners=false: reflects within [-0.5, size-0.5] +fn reflect_coordinates( + coords: FloatTensor, + size: f64, + align_corners: bool, +) -> FloatTensor { + let (min_val, max_val) = if align_corners { + (0.0f32, (size - 1.0) as f32) + } else { + (-0.5f32, (size - 0.5) as f32) + }; + + let span = max_val - min_val; + if span <= 0.0 { + // Edge case: size is 1, just return min_val everywhere + let zeros = B::float_mul_scalar(coords, 0f32.into()); + return B::float_add_scalar(zeros, min_val.into()); + } + + // Triangle wave formula: span - |((x mod 2*span) - span)| + min_val + let period = 2.0 * span; + + // x = abs(coord - min_val) + let x = B::float_sub_scalar(coords, min_val.into()); + let x = B::float_abs(x); + + // x_mod = x - floor(x / period) * period + let x_div = B::float_div_scalar(x.clone(), period.into()); + let x_div_floor = B::float_floor(x_div); + let x_mod = B::float_sub(x, B::float_mul_scalar(x_div_floor, period.into())); + + // result = span - abs(x_mod - span) + min_val + let diff = B::float_sub_scalar(x_mod, span.into()); + let abs_diff = B::float_abs(diff); + let reflected = B::float_sub_scalar(abs_diff, span.into()); + let reflected = B::float_neg(reflected); + B::float_add_scalar(reflected, min_val.into()) +} diff --git a/crates/burn-backend/src/backend/ops/modules/linear.rs b/crates/burn-backend/src/backend/ops/modules/linear.rs new file mode 100644 index 0000000..e43b5f1 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/linear.rs @@ -0,0 +1,128 @@ +use alloc::vec; +use alloc::vec::Vec; + +use crate::tensor::FloatTensor; +use crate::{Backend, TensorMetadata}; +use burn_std::{MatmulTransformAction, MatmulTransformAnalysis, MatmulTransformPolicy, Shape}; + +/// Default [linear](crate::ops::ModuleOps::linear) forward implementation. +/// +/// Computes `y = x @ weight [+ bias]`. +/// +/// The weight is shared by every leading dim of x, so when the batched matmul +/// would tile poorly, the [transform policy](MatmulTransformPolicy) folds the +/// leading dims into the rows and the product runs as one `[rows, d_input] @ +/// [d_input, d_output]` matmul. Otherwise weight and bias broadcast to x's rank +/// for a batched matmul. +pub(crate) fn linear( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, +) -> FloatTensor { + let shape = x.shape(); + let ndims = shape.num_dims(); + + let analysis = MatmulTransformAnalysis::from_shapes(&shape, &weight.shape()); + + match MatmulTransformPolicy::default().action(&analysis) { + MatmulTransformAction::MergeBatches { rows } => { + let d_input = shape[ndims - 1]; + let d_output = weight.shape()[1]; + + let x = B::float_reshape(x, Shape::new([rows, d_input])); + let mut output = B::float_matmul(x, weight); + + if let Some(bias) = bias { + let bias = B::float_reshape(bias, Shape::new([1, d_output])); + output = B::float_add(output, bias); + } + + let out_dims: Vec = (0..ndims - 1).map(|i| shape[i]).chain([d_output]).collect(); + B::float_reshape(output, Shape::from(out_dims)) + } + MatmulTransformAction::Keep => { + // Reshape weight [d_input, d_output] -> [1, ..., 1, d_input, d_output] + // for batch matmul. + let weight = unsqueeze_leading::(weight, ndims); + let output = B::float_matmul(x, weight); + + match bias { + Some(bias) => { + // Reshape bias [d_output] -> [1, ..., 1, d_output] to match + // output rank. + let bias = unsqueeze_leading::(bias, ndims); + B::float_add(output, bias) + } + None => output, + } + } + } +} + +/// Reshape a tensor by prepending size-1 dimensions until it has `target_ndims` dimensions. +fn unsqueeze_leading(tensor: FloatTensor, target_ndims: usize) -> FloatTensor { + let shape = tensor.shape(); + let ndims = shape.num_dims(); + if ndims >= target_ndims { + return tensor; + } + let mut new_dims = vec![1usize; target_ndims - ndims]; + for i in 0..ndims { + new_dims.push(shape[i]); + } + B::float_reshape(tensor, Shape::from(new_dims)) +} + +/// Reshape `tensor` `[..., d_last]` to `[num_rows, d_last]`, flattening every +/// leading dim into one. +fn flatten_leading(tensor: FloatTensor) -> FloatTensor { + let shape = tensor.shape(); + let ndims = shape.num_dims(); + if ndims == 2 { + return tensor; + } + let d_last = shape[ndims - 1]; + let num_rows = shape.num_elements() / d_last; + B::float_reshape(tensor, Shape::new([num_rows, d_last])) +} + +/// Default [linear_x_backward](crate::ops::ModuleOps::linear_x_backward) implementation. +/// +/// Computes `dx = output_grad @ weight^T` — a [linear] forward against the +/// transposed weight, so it shares the transform policy. +pub(crate) fn linear_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + // weight is [d_input, d_output], transpose to [d_output, d_input] + let weight = B::float_swap_dims(weight, 0, 1); + linear::(output_grad, weight, None) +} + +/// Default [linear_weight_backward](crate::ops::ModuleOps::linear_weight_backward) implementation. +/// +/// Computes `dW = x^T @ output_grad` summed over batch dimensions, as a single +/// general matmul on the flattened operands: `[d_input, num_rows] @ [num_rows, +/// d_output]` — the flattening performs the batch reduction. +pub(crate) fn linear_weight_backward( + x: FloatTensor, + output_grad: FloatTensor, +) -> FloatTensor { + let x = B::float_swap_dims(flatten_leading::(x), 0, 1); + let output_grad = flatten_leading::(output_grad); + B::float_matmul(x, output_grad) +} + +/// Default [linear_bias_backward](crate::ops::ModuleOps::linear_bias_backward) implementation. +/// +/// Computes `db = sum(output_grad)` over all dimensions except the last, as a +/// single reduction on the flattened gradient. +pub(crate) fn linear_bias_backward(output_grad: FloatTensor) -> FloatTensor { + let shape = output_grad.shape(); + let d_output = shape[shape.num_dims() - 1]; + + let grad = flatten_leading::(output_grad); + // float_sum_dim preserves rank (keepdim): [num_rows, d_output] -> [1, d_output]. + let grad = B::float_sum_dim(grad, 0); + B::float_reshape(grad, Shape::new([d_output])) +} diff --git a/crates/burn-backend/src/backend/ops/modules/mod.rs b/crates/burn-backend/src/backend/ops/modules/mod.rs new file mode 100644 index 0000000..160ef4b --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/mod.rs @@ -0,0 +1,24 @@ +/// Module with convolution operations. +pub mod conv; + +/// Module with linear operations. +pub mod linear; + +/// Module with attention operations. +pub mod attention; + +/// Module with CTC loss operations. +pub mod ctc; + +/// Module with unfold operations. +pub mod unfold; + +/// Module with pooling operations. +pub mod pool; + +/// Module for grid_sample operations +pub mod grid_sample; + +mod base; + +pub use base::*; diff --git a/crates/burn-backend/src/backend/ops/modules/pool.rs b/crates/burn-backend/src/backend/ops/modules/pool.rs new file mode 100644 index 0000000..9883168 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/pool.rs @@ -0,0 +1,178 @@ +use crate::tensor::{FloatTensor, IntTensor}; +use crate::{Backend, TensorMetadata}; +use burn_std::{IntDType, Shape}; + +use super::{MaxPool1dBackward, MaxPool1dWithIndices}; + +pub(crate) fn avg_pool1d_from_2d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, +) -> FloatTensor { + let [batch_size, channels, length] = x.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length, 1])); + let x = B::avg_pool2d( + x, + [kernel_size, 1], + [stride, 1], + [padding, 0], + count_include_pad, + ceil_mode, + ); + + let [batch_size, channels, length, _] = x.shape().dims(); + + B::float_reshape(x, Shape::from([batch_size, channels, length])) +} + +pub(crate) fn avg_pool1d_backward_from_2d( + x: FloatTensor, + grad: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, +) -> FloatTensor { + let [batch_size, channels, length_in] = x.shape().dims(); + let [_, _, length_out] = grad.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length_in, 1])); + let grad_x = B::float_reshape(grad, Shape::from([batch_size, channels, length_out, 1])); + + let grad_x = B::avg_pool2d_backward( + x, + grad_x, + [kernel_size, 1], + [stride, 1], + [padding, 0], + count_include_pad, + ceil_mode, + ); + + B::float_reshape(grad_x, Shape::from([batch_size, channels, length_in])) +} + +pub(crate) fn adaptive_avg_pool1d_from_2d( + x: FloatTensor, + output_size: usize, +) -> FloatTensor { + let [batch_size, channels, length] = x.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length, 1])); + let x = B::adaptive_avg_pool2d(x, [output_size, 1]); + + let [batch_size, channels, length, _] = x.shape().dims(); + + B::float_reshape(x, Shape::from([batch_size, channels, length])) +} + +pub(crate) fn adaptive_avg_pool1d_backward_from_2d( + x: FloatTensor, + grad: FloatTensor, +) -> FloatTensor { + let [batch_size, channels, length_in] = x.shape().dims(); + let [_, _, length_out] = grad.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length_in, 1])); + let grad_x = B::float_reshape(grad, Shape::from([batch_size, channels, length_out, 1])); + + let grad_x = B::adaptive_avg_pool2d_backward(x, grad_x); + + B::float_reshape(grad_x, Shape::from([batch_size, channels, length_in])) +} + +pub(crate) fn max_pool1d_from_2d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, +) -> FloatTensor { + let [batch_size, channels, length] = x.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length, 1])); + let x = B::max_pool2d( + x, + [kernel_size, 1], + [stride, 1], + [padding, 0], + [dilation, 1], + ceil_mode, + ); + + let [batch_size, channels, length, _] = x.shape().dims(); + + B::float_reshape(x, Shape::from([batch_size, channels, length])) +} + +pub(crate) fn max_pool1d_with_indices_from_2d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, +) -> MaxPool1dWithIndices { + let [batch_size, channels, length] = x.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, 1, length])); + let x = B::max_pool2d_with_indices( + x, + [1, kernel_size], + [1, stride], + [0, padding], + [1, dilation], + ceil_mode, + indices_dtype, + ); + let [batch_size, channels, _, length] = x.output.shape().dims(); + let output = B::float_reshape(x.output, Shape::from([batch_size, channels, length])); + let indices = B::int_reshape(x.indices, Shape::from([batch_size, channels, length])); + MaxPool1dWithIndices::new(output, indices) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn max_pool1d_with_indices_backward_from_2d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, +) -> MaxPool1dBackward { + let [batch_size, channels, length_in] = x.shape().dims(); + let [_, _, length_out] = output_grad.shape().dims(); + + let x = B::float_reshape(x, Shape::from([batch_size, channels, length_in, 1])); + let grad_x = B::float_reshape( + output_grad, + Shape::from([batch_size, channels, length_out, 1]), + ); + let indices = B::int_reshape(indices, Shape::from([batch_size, channels, length_out, 1])); + + let grad_x = B::max_pool2d_with_indices_backward( + x, + [kernel_size, 1], + [stride, 1], + [padding, 0], + [dilation, 1], + ceil_mode, + grad_x, + indices, + ) + .x_grad; + + MaxPool1dBackward::new(B::float_reshape( + grad_x, + Shape::from([batch_size, channels, length_in]), + )) +} diff --git a/crates/burn-backend/src/backend/ops/modules/unfold.rs b/crates/burn-backend/src/backend/ops/modules/unfold.rs new file mode 100644 index 0000000..b25cfc9 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/modules/unfold.rs @@ -0,0 +1,146 @@ +use super::{ConvOptions, UnfoldOptions}; +use crate::tensor::FloatTensor; +use crate::{Backend, TensorData, TensorMetadata}; +use alloc::vec; +use burn_std::{DType, Shape}; + +/// Constructs a special weight tensor used for unfolding. +/// +/// # Notes +/// +/// The idea behind using convolution for unfolding is to leverage the sliding window mechanism of +/// convolution. By creating a weight tensor with ones in a particular pattern, we are able to borrow +/// the convolution operation's mechanism as it moves across the input tensor, picking up the desired +/// values in the pattern of the unfolding operation. +pub(crate) fn create_unfolding_weight( + in_channels: usize, + kernel_size: [usize; 2], + device: &B::Device, + dtype: DType, +) -> FloatTensor { + let shape = Shape::new([ + in_channels * kernel_size[0] * kernel_size[1], + in_channels, + kernel_size[0], + kernel_size[1], + ]); + + let mut strides = [0; 4]; + let mut current = 1; + shape.iter().enumerate().rev().for_each(|(index, val)| { + strides[index] = current; + current *= val; + }); + + let num_elements = shape.num_elements(); + + let mut weight = vec![0f32; num_elements]; + + for k in 0..in_channels { + for i in 0..kernel_size[0] { + for j in 0..kernel_size[1] { + let output_channel = k * kernel_size[0] * kernel_size[1] + i * kernel_size[1] + j; + let index = + output_channel * strides[0] + k * strides[1] + i * strides[2] + j * strides[3]; + + weight[index] = 1.; + } + } + } + + B::float_from_data(TensorData::new(weight, shape).convert_dtype(dtype), device) +} + +/// Compute the unfold4d operation using the conv2d operations. +pub(crate) fn unfold4d_using_conv2d( + x: FloatTensor, + kernel_size: [usize; 2], + options: UnfoldOptions, +) -> FloatTensor { + let [_batch_size, in_channels, _in_height, _in_width] = x.shape().dims(); + let weight = create_unfolding_weight::(in_channels, kernel_size, &x.device(), x.dtype()); + let unfolded = B::conv2d( + x, + weight, + None, + ConvOptions::new(options.stride, options.padding, options.dilation, 1), + ); + + let [batch_size, channels_out, out_height, out_width] = unfolded.shape().dims(); + + B::float_reshape( + unfolded, + Shape::new([batch_size, channels_out, out_height * out_width]), + ) +} + +/// Calculate the number of unfolding windows that can be extracted from a dimension of given size. +pub fn calculate_unfold_windows(dim_size: usize, window_size: usize, step_size: usize) -> usize { + assert!(step_size > 0); + let x = dim_size + step_size; + if x < window_size { + 0 + } else { + (x - window_size) / step_size + } +} + +/// Calculate the output shape for an unfold operation. +/// +/// The operation yields a view with all complete windows of size `size` in dimension `dim`; +/// where windows are advanced by `step` at each index. +/// +/// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. +/// +/// # Arguments +/// +/// * `shape` - The input shape to unfold; of shape ``[pre=..., dim shape, post=...]`` +/// * `dim` - the dimension to unfold. +/// * `size` - the size of each unfolded window. +/// * `step` - the step between each window. +/// +/// # Returns +/// +/// A shape with ``[pre=..., windows, post=..., size]``. +pub fn calculate_unfold_shape>( + shape: S, + dim: usize, + size: usize, + step: usize, +) -> Shape { + let mut shape = shape.into(); + let d_shape = shape[dim]; + let windows = calculate_unfold_windows(d_shape, size, step); + shape[dim] = windows; + shape.push(size); + + shape +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_unfold_windows() { + assert_eq!(calculate_unfold_windows(2, 5, 1), 0); + + assert_eq!(calculate_unfold_windows(2, 3, 1), 0); + assert_eq!(calculate_unfold_windows(3, 3, 1), 1); + assert_eq!(calculate_unfold_windows(4, 3, 1), 2); + assert_eq!(calculate_unfold_windows(5, 3, 1), 3); + + assert_eq!(calculate_unfold_windows(2, 3, 2), 0); + assert_eq!(calculate_unfold_windows(3, 3, 2), 1); + assert_eq!(calculate_unfold_windows(4, 3, 2), 1); + assert_eq!(calculate_unfold_windows(5, 3, 2), 2); + } + + #[test] + fn test_calculate_unfold_shape() { + assert_eq!( + calculate_unfold_shape([2, 6, 6], 1, 3, 2), + Shape::new([2, 2, 6, 3]) + ); + } +} diff --git a/crates/burn-backend/src/backend/ops/qtensor.rs b/crates/burn-backend/src/backend/ops/qtensor.rs new file mode 100644 index 0000000..69539e3 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/qtensor.rs @@ -0,0 +1,1292 @@ +use alloc::vec::Vec; +use burn_std::{ + BoolDType, FloatDType, IntDType, Shape, Slice, + quantization::{QuantPropagation, QuantScheme}, +}; + +use crate::{ + Backend, ExecutionError, TensorData, TensorMetadata, TensorPrimitive, get_device_settings, +}; +use crate::{ + Scalar, + quantization::{Calibration, QuantizationParametersPrimitive, compute_q_params, compute_range}, + tensor::{BoolTensor, Device, FloatTensor, IntTensor, QuantizedTensor}, +}; + +/// Automatically applies `dequantization -> float operation -> quantization`. +/// +/// Used for tensor ops that should always return a quantized output. +#[macro_export] +macro_rules! dequant_op_quant { + // Binary tensor float op w/ lhs & rhs + ( + float_op $float_op:expr, $t1:expr, $t2:expr + ) => {{ + // Heuristic: prioritize lhs scheme + let scheme = $t1.scheme().clone(); + + let t1_f = Self::dequantize($t1); + let t2_f = Self::dequantize($t2); + #[allow(clippy::redundant_closure_call)] + let out_f = $float_op(t1_f, t2_f); + + Self::quantize_dynamic(out_f, &scheme) + }}; + // Unary tensor float op + ( + float_op $float_op:expr, $tensor:expr + ) => {{ + let scheme = $tensor.scheme().clone(); + let dtype = get_device_settings::(&$tensor.device()).float_dtype; + + let tensor_f = Self::dequantize($tensor, dtype); + #[allow(clippy::redundant_closure_call)] + let out_f = $float_op(tensor_f); + + Self::quantize_dynamic(out_f, &scheme) + }}; +} + +/// Automatically applies `dequantization -> float operation [-> quantization]`. +/// +/// The output quantization step is optional. +/// It is only performed when the input quantization scheme is propagated. +#[macro_export] +macro_rules! dequant_op_flow { + // Binary tensor float op w/ lhs & rhs + ( + float_op $float_op:expr, $t1:expr, $t2:expr + ) => {{ + // Heuristic: prioritize lhs scheme + let scheme = $t1.scheme().clone(); + let settings = get_device_settings::(&$t1.device()); + let dtype = settings.float_dtype; + let propagation = settings.quantization.propagation; + + let t1_f = Self::dequantize($t1, dtype); + let t2_f = Self::dequantize($t2, dtype); + #[allow(clippy::redundant_closure_call)] + let out_f = $float_op(t1_f, t2_f); + + match propagation { + QuantPropagation::Propagate => { + TensorPrimitive::QFloat(Self::quantize_dynamic(out_f, &scheme)) + } + QuantPropagation::Inhibit => TensorPrimitive::Float(out_f), + } + }}; + // Unary tensor float op + ( + float_op $float_op:expr, $tensor:expr + ) => {{ + let scheme = $tensor.scheme().clone(); + let settings = get_device_settings::(&$tensor.device()); + let dtype = settings.float_dtype; + let propagation = settings.quantization.propagation; + + let tensor_f = Self::dequantize($tensor, dtype); + #[allow(clippy::redundant_closure_call)] + let out_f = $float_op(tensor_f); + + match propagation { + QuantPropagation::Propagate => { + TensorPrimitive::QFloat(Self::quantize_dynamic(out_f, &scheme)) + } + QuantPropagation::Inhibit => TensorPrimitive::Float(out_f), + } + }}; +} + +/// Operations on quantized tensors. +/// +/// # Return Type Semantics +/// +/// The return type of each operation indicates how quantization is handled: +/// +/// ## [`QuantizedTensor`] +/// If the method returns a `QuantizedTensor`, the operation is expected to preserve the quantized +/// representation. Implementations should avoid dequantizing when possible to maintain performance. +/// For example, shape or layout changes such as expand or transpose preserve quantization. +/// +/// *Note: while this currently doesn't affect the quantized tensor parameters (only per-tensor is +/// supported at the time of writing), other quantization levels (e.g., per-block) may require re-ordering +/// the quantization parameters to match the new layout.* +/// +/// +/// ## [`TensorPrimitive`] +/// If the method returns a `TensorPrimitive` enum, the return type should align with propagation +/// strategy specified in the quantization scheme. The output should remain quantized ([`TensorPrimitive::QFloat`]) +/// returned in floating-point form ([`TensorPrimitive::Float`]). +/// +/// This distinction allows for fine-grained control over mixed-precision flows while still operating +/// through a unified API. +pub trait QTensorOps { + /// Creates a new tensor from the data structure. + /// + /// # Arguments + /// + /// * `data` - The data structure. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// The tensor with the given data. + fn q_from_data(data: TensorData, device: &Device) -> QuantizedTensor; + + /// Convert the tensor to a lower precision data type based on the quantization scheme and parameters. + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor; + + /// Dynamically convert the tensor to a lower precision data type based on the quantization scheme. + fn quantize_dynamic(tensor: FloatTensor, scheme: &QuantScheme) -> QuantizedTensor { + // Dynamically compute min/max tensor range and qparams before quantizing + let (min, max) = compute_range::(scheme, tensor.clone(), &Calibration::MinMax); + let qparams = compute_q_params(scheme, min, max); + Self::quantize(tensor, scheme, qparams) + } + + /// Convert the tensor back to a higher precision data type. + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor; + + /// Moves the tensor to the given device. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `device` - The device to move the tensor to. + /// + /// # Returns + /// + /// The tensor on the given device. + fn q_to_device(tensor: QuantizedTensor, device: &Device) -> QuantizedTensor; + + /// Reshapes a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reshape. + /// * `shape` - The new shape of the tensor. + /// + /// # Returns + /// + /// The tensor with the new shape. + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor; + + /// Converts the tensor to a data structure. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The data structure with the tensor's data. + fn q_into_data( + tensor: QuantizedTensor, + ) -> impl Future> + Send; + + /// Detaches a tensor from the computation graph. + fn q_detach(tensor: QuantizedTensor) -> QuantizedTensor { + // Should only be overridden by autodiff backends. + tensor + } + + /// Sets the `require_grad` flag of a tensor. + fn q_set_require_grad(tensor: QuantizedTensor, _require_grad: bool) -> QuantizedTensor { + // Should only be overridden by autodiff backends. + tensor + } + + /// Returns the `require_grad` flag of a tensor. + fn q_is_require_grad(_tensor: &QuantizedTensor) -> bool { + // Should only be overridden by autodiff backends. + false + } + + /// Broadcasts the `tensor` to the given `shape`. + fn q_expand(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + // Default implementation. Backends can expand on the quantized values when supported. + dequant_op_quant!(float_op | tensor | B::float_expand(tensor, shape), tensor) + } + + /// Transposes a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + fn q_transpose(tensor: QuantizedTensor) -> QuantizedTensor { + let ndims = tensor.shape().num_dims(); + Self::q_swap_dims(tensor, ndims - 2, ndims - 1) + } + + /// Swaps two dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap. + /// * `dim2` - The second dimension to swap. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + fn q_swap_dims(tensor: QuantizedTensor, dim1: usize, dim2: usize) -> QuantizedTensor; + + /// Permutes the dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to permute the dimensions of. + /// * `axes` - The new order of the dimensions. + /// # Returns + /// + /// The tensor with the dimensions permuted. + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor; + + /// Reverse the order of elements in a tensor along the given axes. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reverse. + /// * `axes` - The axes to reverse. + /// + /// The tensor with the elements reversed. + fn q_flip(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor; + + /// Select tensor elements along the given dimension corresponding for the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices to select. + /// + /// # Returns + /// + /// The selected elements. + fn q_select( + tensor: QuantizedTensor, + dim: usize, + indices: IntTensor, + ) -> QuantizedTensor { + // Default implementation. Backends can select on the quantized values when supported. + dequant_op_quant!( + float_op | tensor | B::float_select(tensor, dim, indices), + tensor + ) + } + + /// Select tensor elements corresponding to the given slices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `slices` - The slices specifying ranges and steps for each dimension. + /// + /// # Returns + /// + /// The selected elements in a new tensor. + fn q_slice(tensor: QuantizedTensor, slices: &[Slice]) -> QuantizedTensor { + // Default implementation. Backends can slice on the quantized values when supported. + dequant_op_quant!(float_op | tensor | B::float_slice(tensor, slices), tensor) + } + + /// Gather elements from a tensor. + /// + /// # Arguments + /// + /// * `dim` - The dimension to gather from. + /// * `tensor` - The tensor to gather from. + /// * `indices` - The indices to gather. + /// + /// # Returns + /// + /// The gathered elements. + fn q_gather( + dim: usize, + tensor: QuantizedTensor, + indices: IntTensor, + ) -> QuantizedTensor { + // Default implementation. Backends can gather on the quantized values when supported. + dequant_op_quant!( + float_op | tensor | B::float_gather(dim, tensor, indices), + tensor + ) + } + + /// Repeat the tensor along the given dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to repeat. + /// * `times` - The number of times to repeat the dimension. + /// + /// # Returns + /// + /// The tensor with the given dimension repeated. + fn q_repeat_dim(tensor: QuantizedTensor, dim: usize, times: usize) -> QuantizedTensor { + dequant_op_quant!( + float_op | tensor | B::float_repeat_dim(tensor, dim, times), + tensor + ) + } + + /// Adds two tensors together. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The result of adding the two tensors together. + fn q_add(lhs: QuantizedTensor, rhs: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | lhs, rhs | B::float_add(lhs, rhs), lhs, rhs) + } + + /// Adds a scalar to a tensor. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The result of adding the scalar to the tensor. + fn q_add_scalar(lhs: QuantizedTensor, rhs: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_add_scalar(tensor, rhs), lhs) + } + + /// Clamps a tensor under a minimum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn q_clamp_min(tensor: QuantizedTensor, min: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_clamp_min(tensor, min), tensor) + } + + /// Clamps a tensor over a maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn q_clamp_max(tensor: QuantizedTensor, max: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_clamp_max(tensor, max), tensor) + } + + /// Clamps a tensor between a minimum and maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn q_clamp(tensor: QuantizedTensor, min: Scalar, max: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_clamp(tensor, min, max), tensor) + } + + /// Subtracts two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The result of subtracting the two tensors. + fn q_sub(lhs: QuantizedTensor, rhs: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | lhs, rhs | B::float_sub(lhs, rhs), lhs, rhs) + } + + /// Subtracts a scalar from a tensor. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The result of subtracting the scalar from the tensor. + fn q_sub_scalar(lhs: QuantizedTensor, rhs: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sub_scalar(tensor, rhs), lhs) + } + + /// Multiplies two tensors together element-wise. + fn q_mul(lhs: QuantizedTensor, rhs: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | lhs, rhs | B::float_mul(lhs, rhs), lhs, rhs) + } + + /// Multiplies a tensor by a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The result of multiplying the tensor by the scalar. + fn q_mul_scalar(lhs: QuantizedTensor, rhs: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_mul_scalar(tensor, rhs), lhs) + } + + /// Divides two tensors element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The result of dividing the two tensors. + fn q_div(lhs: QuantizedTensor, rhs: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | lhs, rhs | B::float_div(lhs, rhs), lhs, rhs) + } + + /// Divides a tensor by a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The result of dividing the tensor by the scalar. + fn q_div_scalar(lhs: QuantizedTensor, rhs: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_div_scalar(tensor, rhs), lhs) + } + + /// Multiplies two tensors together using matrix multiplication. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The result of multiplying the two tensors together using matrix multiplication. + fn q_matmul(lhs: TensorPrimitive, rhs: TensorPrimitive) -> TensorPrimitive { + let mut propagation = QuantPropagation::Inhibit; + let mut scheme = QuantScheme::default(); + + // Pick a target dtype for any dequantization. If either operand is already + // a Float tensor, take its dtype so a Float-QFloat (or QFloat-Float) pair + // ends up matching after dequantize and `float_matmul` doesn't see a + // dtype mismatch. Only when both operands are QFloat do we fall back to + // the device default. + let target_dtype: Option = match (&lhs, &rhs) { + (TensorPrimitive::Float(t), _) | (_, TensorPrimitive::Float(t)) => { + Some(t.dtype().into()) + } + _ => None, + }; + + let lhs = match lhs { + TensorPrimitive::Float(lhs) => lhs, + TensorPrimitive::QFloat(lhs) => { + let settings = get_device_settings::(&lhs.device()); + propagation = settings.quantization.propagation; + scheme = lhs.scheme(); + let float_dtype = target_dtype.unwrap_or(settings.float_dtype); + + Self::dequantize(lhs, float_dtype) + } + }; + let rhs = match rhs { + TensorPrimitive::Float(rhs) => rhs, + TensorPrimitive::QFloat(rhs) => { + let settings = get_device_settings::(&rhs.device()); + propagation = settings.quantization.propagation; + scheme = rhs.scheme(); + let float_dtype = target_dtype.unwrap_or(settings.float_dtype); + + Self::dequantize(rhs, float_dtype) + } + }; + + let out_f = B::float_matmul(lhs, rhs); + match propagation { + QuantPropagation::Propagate => { + TensorPrimitive::QFloat(::quantize_dynamic(out_f, &scheme)) + } + QuantPropagation::Inhibit => TensorPrimitive::Float(out_f), + } + } + + /// Negates a tensor element-wise. + fn q_neg(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_neg(tensor), tensor) + } + + /// Calculates the reciprocals element-wise + fn q_recip(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_recip(tensor), tensor) + } + + /// Sum of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// + /// # Returns + /// + /// A scalar tensor with the sum of all elements in `tensor`. + fn q_sum(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sum(tensor), tensor) + } + + /// Sum of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// * `dim` - The dimension along which to sum. + /// + /// # Returns + /// + /// A tensor with the sum of all elements in `tensor` along `dim`. + fn q_sum_dim(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sum_dim(tensor, dim), tensor) + } + + /// Product of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to product. + /// + /// # Returns + /// + /// A scalar tensor with the product of all elements in `tensor`. + fn q_prod(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_prod(tensor), tensor) + } + + /// Product of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to product. + /// + /// # Returns + /// + /// A tensor with the product of all elements in `tensor` along `dim`. + fn q_prod_dim(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_prod_dim(tensor, dim), tensor) + } + + /// Mean of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to mean. + /// + /// # Returns + /// + /// A scalar tensor with the mean of all elements in `tensor`. + fn q_mean(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_mean(tensor), tensor) + } + + /// Mean of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to mean. + /// * `dim` - The dimension along which to mean. + /// + /// # Returns + /// + /// A tensor with the mean of all elements in `tensor` along `dim`. + fn q_mean_dim(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_mean_dim(tensor, dim), tensor) + } + + /// Computes the cumulative sum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative sum of. + /// * `dim` - The dimension along which to compute the cumulative sum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative sum + /// of all elements up to and including that position along the dimension. + fn q_cumsum(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cumsum(tensor, dim), tensor) + } + + /// Computes the cumulative product of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative product of. + /// * `dim` - The dimension along which to compute the cumulative product. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative product + /// of all elements up to and including that position along the dimension. + fn q_cumprod(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cumprod(tensor, dim), tensor) + } + + /// Computes the cumulative minimum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative minimum of. + /// * `dim` - The dimension along which to compute the cumulative minimum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the minimum + /// of all elements up to and including that position along the dimension. + fn q_cummin(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cummin(tensor, dim), tensor) + } + + /// Computes the cumulative maximum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative maximum of. + /// * `dim` - The dimension along which to compute the cumulative maximum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the maximum + /// of all elements up to and including that position along the dimension. + fn q_cummax(tensor: QuantizedTensor, dim: usize) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cummax(tensor, dim), tensor) + } + + /// Returns a new tensor with exponential values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to exponentiate. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with exponential values. + fn q_exp(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_exp(tensor), tensor) + } + + /// Returns a new tensor with natural logarithm values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the logarithm of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with natural logarithm values. + fn q_log(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_log(tensor), tensor) + } + + /// Returns a new tensor with logarithm values of (1 + Xi). + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the logarithm of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with logarithm values of (1 + Xi). + fn q_log1p(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_log1p(tensor), tensor) + } + + /// Element-wise power with another tensor. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the power of the elements of `rhs`. + fn q_powf(lhs: QuantizedTensor, rhs: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | lhs, rhs | B::float_powf(lhs, rhs), lhs, rhs) + } + + /// Element-wise power with an IntTensor. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side floatTensor. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. Result is an IntTensor. + fn q_powi(lhs: QuantizedTensor, rhs: IntTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_powi(tensor, rhs), lhs) + } + + /// Element-wise power with an int scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. + fn q_powi_scalar(lhs: QuantizedTensor, rhs: Scalar) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_powi_scalar(tensor, rhs), lhs) + } + + /// Element-wise power with a float scalar. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to exponentiate. + /// * `value` - The exponent. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with values raised to the power of `value`. + fn q_powf_scalar(tensor: QuantizedTensor, value: Scalar) -> TensorPrimitive { + dequant_op_flow!( + float_op | tensor | B::float_powf_scalar(tensor, value), + tensor + ) + } + + /// Returns a new tensor with square root values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the square root of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with square root values. + fn q_sqrt(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sqrt(tensor), tensor) + } + + /// Returns a new tensor with absolute values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take absolute value of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with absolute values. + fn q_abs(tensor: QuantizedTensor) -> QuantizedTensor { + dequant_op_quant!(float_op | tensor | B::float_abs(tensor), tensor) + } + + /// Returns a new tensor with cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the cosine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with cosine values. + fn q_cos(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cos(tensor), tensor) + } + + /// Returns a new tensor with sine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the sine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with sine values. + fn q_sin(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sin(tensor), tensor) + } + + /// Returns a new tensor with tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the tangent of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with tangent values. + fn q_tan(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_tan(tensor), tensor) + } + + /// Returns a new tensor with hyperbolic cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic cosine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic cosine values. + fn q_cosh(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_cosh(tensor), tensor) + } + + /// Returns a new tensor with hyperbolic sine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic sine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic sine values. + fn q_sinh(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_sinh(tensor), tensor) + } + + /// Returns a new tensor with hyperbolic tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic tangent of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic tangent values. + fn q_tanh(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_tanh(tensor), tensor) + } + + /// Returns a new tensor with the error function values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the error function of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with error function values. + fn q_erf(tensor: QuantizedTensor) -> TensorPrimitive { + dequant_op_flow!(float_op | tensor | B::float_erf(tensor), tensor) + } + + /// Concatenates tensors along a dimension. + /// + /// # Arguments + /// + /// * `tensors` - The tensors to concatenate. + /// * `dim` - The dimension along which to concatenate. + /// + /// # Returns + /// + /// A tensor with the concatenated tensors along `dim`. + fn q_cat(tensors: Vec>, dim: usize) -> QuantizedTensor { + // Heuristic: prioritize first tensor scheme + let first = tensors.first().unwrap(); + let scheme = first.scheme(); + let dtype = get_device_settings::(&first.device()).float_dtype; + + let tensor_f = tensors + .into_iter() + .map(|tensor| Self::dequantize(tensor, dtype)) + .collect(); + + let out_f = B::float_cat(tensor_f, dim); + + Self::quantize_dynamic(out_f, &scheme) + } + + /// Gets the indices of the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the maximum elements of `tensor` along `dim`. + fn q_argmax(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_argmax(tensor_f, dim, out_dtype) + } + + /// Gets the indices of the k maximum elements of a tensor along an axis. + /// If two elements are equals, order them by the lowest indices + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the k maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `k` - number of k maximums to keep + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the `k` maximum elements of `tensor` along `dim`. + fn q_argtopk( + tensor: QuantizedTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_argtopk(tensor_f, dim, k, out_dtype) + } + + /// Gets the values of the k maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the k maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `k` - number of k maximums to keep + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the values of the `k` maximum elements of `tensor` along `dim`. + fn q_topk(tensor: QuantizedTensor, dim: usize, k: usize) -> QuantizedTensor { + dequant_op_quant!(float_op | tensor | B::float_topk(tensor, dim, k), tensor) + } + + /// Gets the indices of the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the minimum elements of `tensor` along `dim`. + fn q_argmin(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_argmin(tensor_f, dim, out_dtype) + } + + /// Gets the maximum element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// + /// # Returns + /// + /// A tensor with the maximum element of `tensor`. + fn q_max(tensor: QuantizedTensor) -> QuantizedTensor { + let shape = tensor.shape(); + let tensor = B::q_reshape(tensor, Shape::new([shape.num_elements()])); + + B::q_max_dim(tensor, 0) + } + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the maximum elements of `tensor` along `dim`. + fn q_max_dim(tensor: QuantizedTensor, dim: usize) -> QuantizedTensor { + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let index = B::q_argmax(tensor.clone(), dim, int_dtype); + + B::q_gather(dim, tensor, index) + } + + /// Gets the maximum elements of a tensor along an axis and their indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// + /// # Returns + /// + /// A tuple with the maximum elements of `tensor` along `dim` and their indices. + fn q_max_dim_with_indices( + tensor: QuantizedTensor, + dim: usize, + out_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + let index = B::q_argmax(tensor.clone(), dim, out_dtype); + let values = B::q_gather(dim, tensor, index.clone()); + + (values, index) + } + + /// Gets the minimum element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// + /// # Returns + /// + /// A tensor with the minimum element of `tensor`. + fn q_min(tensor: QuantizedTensor) -> QuantizedTensor { + let shape = tensor.shape(); + let tensor = B::q_reshape(tensor, Shape::new([shape.num_elements()])); + + B::q_min_dim(tensor, 0) + } + + /// Gets the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// + /// # Returns + /// + /// A tensor with the minimum elements of `tensor` along `dim`. + fn q_min_dim(tensor: QuantizedTensor, dim: usize) -> QuantizedTensor { + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let index = B::q_argmin(tensor.clone(), dim, int_dtype); + + B::q_gather(dim, tensor, index) + } + + /// Gets the minimum elements of a tensor along an axis and their indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// + /// # Returns + /// + /// A tuple with the minimum elements of `tensor` along `dim` and their indices. + fn q_min_dim_with_indices( + tensor: QuantizedTensor, + dim: usize, + out_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + let index = B::q_argmin(tensor.clone(), dim, out_dtype); + let values = B::q_gather(dim, tensor, index.clone()); + + (values, index) + } + + /// Gets the maximum element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// + /// # Returns + /// + /// A tensor with the maximum element of `tensor`. + fn q_max_abs(tensor: QuantizedTensor) -> QuantizedTensor { + let shape = tensor.shape(); + let tensor = B::q_reshape(tensor, Shape::new([shape.num_elements()])); + + B::q_max_abs_dim(tensor, 0) + } + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the maximum elements of `tensor` along `dim`. + fn q_max_abs_dim(tensor: QuantizedTensor, dim: usize) -> QuantizedTensor { + let int_dtype = get_device_settings::(&tensor.device()).int_dtype; + let index = B::q_argmax(B::q_abs(tensor.clone()), dim, int_dtype); + + B::q_gather(dim, tensor, index) + } + + /// Tests if any element in the `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if any element in the tensor is True, False otherwise. + fn q_any(tensor: QuantizedTensor, out_dtype: BoolDType) -> BoolTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_any(tensor_f, out_dtype) + } + + /// Tests if any element in the float `tensor` evaluates to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if any element along this dim in the + /// input evaluates to True, False otherwise. + fn q_any_dim(tensor: QuantizedTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_any_dim(tensor_f, dim, out_dtype) + } + + /// Tests if all elements in the `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with a single element, True if all elements in the input tensor + /// evaluate to True, False otherwise. + fn q_all(tensor: QuantizedTensor, out_dtype: BoolDType) -> BoolTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_all(tensor_f, out_dtype) + } + + /// Tests if all elements in the `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if all elements along this dim in the input + /// evaluates to True, False otherwise. + fn q_all_dim(tensor: QuantizedTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_all_dim(tensor_f, dim, out_dtype) + } + + /// Sort the elements of the input `tensor` by value in along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where the elements are sorted by value. + fn q_sort(tensor: QuantizedTensor, dim: usize, descending: bool) -> QuantizedTensor { + // Default implementation. Backends can sort on the int values since qparams remain the same. + dequant_op_quant!( + float_op | tensor | B::float_sort(tensor, dim, descending), + tensor + ) + } + + /// Sort the elements of the input `tensor` by value in along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor and corresponding indices, where + /// the elements are sorted by value and the indices map back to the original input tensor. + fn q_sort_with_indices( + tensor: QuantizedTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + let scheme = tensor.scheme(); + let dtype = get_device_settings::(&tensor.device()).float_dtype; + + let tensor_f = Self::dequantize(tensor, dtype); + let (out_f, indices) = B::float_sort_with_indices(tensor_f, dim, descending, out_dtype); + + (Self::quantize_dynamic(out_f, &scheme), indices) + } + + /// Returns the indices that sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor the indices map back to the original input tensor. + fn q_argsort( + tensor: QuantizedTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> IntTensor { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + let tensor_f = Self::dequantize(tensor, dtype); + B::float_argsort(tensor_f, dim, descending, out_dtype) + } +} diff --git a/crates/burn-backend/src/backend/ops/repeat_dim.rs b/crates/burn-backend/src/backend/ops/repeat_dim.rs new file mode 100644 index 0000000..5747cfe --- /dev/null +++ b/crates/burn-backend/src/backend/ops/repeat_dim.rs @@ -0,0 +1,44 @@ +use crate::{Backend, TensorMetadata, tensor::Device}; +use alloc::vec::Vec; +use burn_std::{DType, Shape, Slice}; + +pub(crate) fn repeat_with_slice_assign( + tensor: T, + dim: usize, + times: usize, + device: Device, + empty: E, + slice_assign: SA, +) -> T +where + T: TensorMetadata, + B: Backend, + E: Fn(Shape, &Device, DType) -> T, + SA: Fn(T, &[Slice], T) -> T, +{ + let shape = tensor.shape(); + let dtype = tensor.dtype(); + + let original_dim_length = shape[dim]; + let shape = shape.repeat(dim, times).unwrap(); + + let mut tensor_output = empty(shape.clone(), &device, dtype); + + let indices_select_all = shape.iter().map(|d| 0..*d).collect::>(); + + let mut output_index = 0; + for _ in 0..times { + let mut indices = indices_select_all.clone(); + indices[dim] = output_index..output_index + original_dim_length; + output_index += original_dim_length; + + // Convert ranges to Slice + let slices: Vec = indices + .iter() + .map(|r| Slice::new(r.start as isize, Some(r.end as isize), 1)) + .collect(); + tensor_output = slice_assign(tensor_output, &slices, tensor.clone()); + } + + tensor_output +} diff --git a/crates/burn-backend/src/backend/ops/sort.rs b/crates/burn-backend/src/backend/ops/sort.rs new file mode 100644 index 0000000..ad67b66 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/sort.rs @@ -0,0 +1,433 @@ +use core::cmp::Ordering; + +use crate::{ + Backend, DType, TensorData, + element::{ElementConversion, ElementOrdered}, + tensor::{Device, IntTensor}, +}; +use alloc::{vec, vec::Vec}; +use burn_std::{Element, IntDType}; +use burn_std::{bf16, f16}; + +/// Macro used to dispatch sort operations based on dtype. +macro_rules! sort_dispatch_dtype { + // Dispatch both element dtype and index dtype. + ($fn:ident, |$index_dtype:ident|, $data:ident, $($args:expr),*) => {{ + macro_rules! dispatch_index { + ($index_ty:ty) => { + match $data.dtype { + DType::F64 => $fn::($data, $($args),*), + DType::F32 | DType::Flex32 => { + $fn::($data, $($args),*) + } + DType::F16 => $fn::($data, $($args),*), + DType::BF16 => $fn::($data, $($args),*), + DType::I64 => $fn::($data, $($args),*), + DType::I32 => $fn::($data, $($args),*), + DType::I16 => $fn::($data, $($args),*), + DType::I8 => $fn::($data, $($args),*), + DType::U64 => $fn::($data, $($args),*), + DType::U32 => $fn::($data, $($args),*), + DType::U16 => $fn::($data, $($args),*), + DType::U8 => $fn::($data, $($args),*), + DType::Bool(_) | DType::QFloat(_) => { + unimplemented!("not supported for sorting operations") + } + } + }; + } + + match $index_dtype { + IntDType::I64 => dispatch_index!(i64), + IntDType::I32 => dispatch_index!(i32), + IntDType::I16 => dispatch_index!(i16), + IntDType::I8 => dispatch_index!(i8), + IntDType::U64 => dispatch_index!(u64), + IntDType::U32 => dispatch_index!(u32), + IntDType::U16 => dispatch_index!(u16), + IntDType::U8 => dispatch_index!(u8), + } + }}; + + // Dispatch only element dtype. + ($fn:ident, $data:ident, $($args:expr),*) => { + match $data.dtype { + DType::F64 => $fn::($data, $($args),*), + DType::F32 | DType::Flex32 => $fn::($data, $($args),*), + DType::F16 => $fn::($data, $($args),*), + DType::BF16 => $fn::($data, $($args),*), + DType::I64 => $fn::($data, $($args),*), + DType::I32 => $fn::($data, $($args),*), + DType::I16 => $fn::($data, $($args),*), + DType::I8 => $fn::($data, $($args),*), + DType::U64 => $fn::($data, $($args),*), + DType::U32 => $fn::($data, $($args),*), + DType::U16 => $fn::($data, $($args),*), + DType::U8 => $fn::($data, $($args),*), + DType::Bool(_) | DType::QFloat(_) => { + unimplemented!("not supported for sorting operations") + } + } + }; +} + +/// Sort the elements of the input `tensor` by value along a given dimension. +/// +/// This sort is unstable (i.e., may reorder equal elements). +/// +/// # Arguments +/// +/// * `tensor` - The input tensor. +/// * `dim` - The axis along which to sort. +/// * `descending` - The sorting order. +/// +/// # Returns +/// +/// A tensor with the same shape as the input tensor, where the elements are sorted by value. +/// +/// # Remarks +/// +/// This is a fallback solution that used only when the backend doesn't have the corresponding implementation. +/// Ideally, it is supposed to be implemented by the backend and the backend implementation will be resolved +/// by static dispatch. It is not designed for direct usage by users, and not recommended to import +/// or use this function directly. +pub fn sort( + tensor: T, + dim: usize, + descending: bool, + device: Device, + into_data: ID, + from_data: FD, +) -> T +where + B: Backend, + ID: Fn(T) -> TensorData, + FD: Fn(TensorData, &Device, DType) -> T, +{ + let data = into_data(tensor); + let dtype = data.dtype; + let data = sort_dispatch_dtype!(sort_data, data, dim, descending); + from_data(data, &device, dtype) +} + +pub fn sort_data( + mut data: TensorData, + dim: usize, + descending: bool, +) -> TensorData { + let dims = data.shape.clone(); + let data_slice = data.as_mut_slice().unwrap(); + if dims.len() == 1 { + // 1D sort + data_slice.sort_unstable_by(|&a, &b| compare(&a, &b, descending)); + } else { + sort_slice::(data_slice, &dims, dim, None, false, descending); + } + + data +} + +/// Sort the elements of the input `tensor` by value along a given dimension. +/// +/// This sort is unstable (i.e., may reorder equal elements). +/// +/// # Arguments +/// +/// * `tensor` - The input tensor. +/// * `dim` - The axis along which to sort. +/// * `descending` - The sorting order. +/// * `indices_dtype` - The indices tensor dtype. +/// +/// # Returns +/// +/// A tensor with the same shape as the input tensor and corresponding indices, where +/// the elements are sorted by value and the indices map back to the original input tensor. +/// +/// # Remarks +/// +/// This is a fallback solution that used only when the backend doesn't have the corresponding implementation. +/// Ideally, it is supposed to be implemented by the backend and the backend implementation will be resolved +/// by static dispatch. It is not designed for direct usage by users, and not recommended to import +/// or use this function directly. +pub fn sort_with_indices( + tensor: T, + dim: usize, + descending: bool, + indices_dtype: IntDType, + device: Device, + into_data: ID, + from_data: FD, +) -> (T, IntTensor) +where + B: Backend, + ID: Fn(T) -> TensorData, + FD: Fn(TensorData, &Device, DType) -> T, +{ + let data = into_data(tensor); + let dtype = data.dtype; + let (values, indices) = + sort_dispatch_dtype!(sort_data_with_indices, |indices_dtype|, data, dim, descending); + + ( + from_data(values, &device, dtype), + B::int_from_data(indices.convert_dtype(indices_dtype.into()), &device), + ) +} + +fn sort_data_with_indices( + mut data: TensorData, + dim: usize, + descending: bool, +) -> (TensorData, TensorData) { + let dims = data.shape.clone(); + let mut indices_data = dim_indices::(&dims, dim); + let data_slice = data.as_mut_slice().unwrap(); + if dims.len() == 1 { + // 1D sort + indices_data.sort_unstable_by(|&a, &b| { + compare( + &data_slice[a.elem::() as usize], + &data_slice[b.elem::() as usize], + descending, + ) + }); + + // Permute data in-place by the sorted indices + let mut indices = indices_data + .clone() + .iter() + .map(|i| i.elem::() as usize) + .collect::>(); + for idx in 0..indices.len() { + if indices[idx] != idx { + let mut current_idx = idx; + loop { + let target_idx = indices[current_idx]; + indices[current_idx] = current_idx; + if indices[target_idx] == target_idx { + // correct position + break; + } + + // Permute data by indices + data_slice.swap(current_idx, target_idx); + current_idx = target_idx; + } + } + } + } else { + sort_slice::( + data_slice, + &dims, + dim, + Some(&mut indices_data), + true, + descending, + ); + } + + (data, TensorData::new(indices_data, dims)) +} + +/// Returns the indices that sort the elements of the input `tensor` along a given dimension. +/// +/// This sort is unstable (i.e., may reorder equal elements). +/// +/// # Arguments +/// +/// * `tensor` - The input tensor. +/// * `dim` - The axis along which to sort. +/// * `descending` - The sorting order. +/// * `out_dtype` - The output tensor dtype. +/// +/// # Returns +/// +/// A tensor with the same shape as the input tensor the indices map back to the original input tensor. +/// +/// # Remarks +/// +/// This is a fallback solution that used only when the backend doesn't have the corresponding implementation. +/// Ideally, it is supposed to be implemented by the backend and the backend implementation will be resolved +/// by static dispatch. It is not designed for direct usage by users, and not recommended to import +/// or use this function directly. +pub fn argsort( + tensor: T, + dim: usize, + descending: bool, + out_dtype: IntDType, + device: Device, + into_data: ID, +) -> IntTensor +where + B: Backend, + ID: Fn(T) -> TensorData, +{ + let data = into_data(tensor); + let data = sort_dispatch_dtype!(argsort_data, |out_dtype|, data, dim, descending); + + B::int_from_data(data, &device) +} + +fn argsort_data( + mut data: TensorData, + dim: usize, + descending: bool, +) -> TensorData { + let dims = data.shape.clone(); + let mut indices_data = dim_indices::(&dims, dim); + if dims.len() == 1 { + // 1D sort + let slice = data.as_slice::().unwrap(); + indices_data.sort_unstable_by(|&a, &b| { + compare( + &slice[a.elem::() as usize], + &slice[b.elem::() as usize], + descending, + ) + }); + } else { + sort_slice::( + data.as_mut_slice().unwrap(), + &dims, + dim, + Some(&mut indices_data), + false, + descending, + ); + } + + TensorData::new(indices_data, dims) +} + +/// Sort the elements by value along a given dimension. +/// +/// When `indices` are not provided, the `data` is sorted. +/// Otherwise, the `indices` are sorted based on the value of the elements in `data`, +/// and if `permute_both` is enabled then the data is also sorted. +/// +/// This sort is unstable (i.e., may reorder equal elements). +fn sort_slice( + data: &mut [E], + dims: &[usize], + dim: usize, + mut indices: Option<&mut [I]>, + permute_both: bool, + descending: bool, +) { + let ndims = dims.len(); + let strides = compute_strides(dims); + // Dimensions to access elements to sort + let mut sort_dims = dims.to_vec(); + sort_dims[dim] = 1; + let strides_out = compute_strides(&sort_dims); + + // Number of groups to sort + let num_sorts: usize = dims + .iter() + .enumerate() + .filter(|&(i, _)| i != dim) + .map(|(_, d)| d) + .product(); + + // TODO: run each sort in parallel + // run_par!(|| { + // iter_range_par!(0, num_sorts).for_each(|id| {...}) + for id in 0..num_sorts { + let mut index_offset = 0; + let mut stride_dim = 0; + let mut shape_dim = 0; + for d in 0..ndims { + let stride_input = strides[d]; + let stride_output = strides_out[d]; + let shape_output = sort_dims[d]; + + let num_block = id / stride_output % shape_output; + + if d != dim { + index_offset += num_block * stride_input; + } else { + let shape_input = dims[d]; + stride_dim = stride_input; + shape_dim = shape_input; + index_offset += num_block; + } + } + + // For each group, sort the indices based on the element values + // NOTE: Sorting methods like `sort_unstable_by` are in-place but we need to sort + // different views/groups of the underlying data, so the swap is performed on the elements + // of the (flat index, element value) collection. + let mut elements = (0..shape_dim) + .map(|d| { + let flat_index = d * stride_dim + index_offset; + let elem = data[flat_index]; + (d, flat_index, elem) + }) + .collect::>(); + + elements.sort_unstable_by(|&(_, _, a), &(_, _, b)| compare(&a, &b, descending)); + + // Permute data in-place by the sorted indices + for idx in 0..elements.len() { + if elements[idx].0 != idx { + let mut current_idx = idx; + loop { + let target_idx = elements[current_idx].0; + elements[current_idx].0 = current_idx; + if elements[target_idx].0 == target_idx { + // correct position + break; + } + + if indices.is_none() || permute_both { + // Permute data by indices + data.swap(elements[current_idx].1, elements[target_idx].1); + } + + if let Some(ref mut indices_data) = indices { + // Permute data element indices + indices_data.swap(elements[current_idx].1, elements[target_idx].1); + } + + current_idx = target_idx; + } + } + } + } +} + +/// Computes the steps for each dimension when traversing an array. +fn compute_strides(dims: &[usize]) -> Vec { + let mut strides = vec![0; dims.len()]; + let mut current = 1; + + dims.iter().enumerate().rev().for_each(|(index, val)| { + strides[index] = current; + current *= val; + }); + + strides +} + +/// Generates the indices for each element along the specified dimension. +fn dim_indices(dims: &[usize], dim: usize) -> Vec { + if dims.len() == 1 { + (0..dims[dim]) + .map(|i| (i as i64).elem::()) + .collect::>() + } else { + // Dimension indices tensor + let numel_leading_dims: usize = dims[..dim].iter().product(); + let numel_trailing_dims: usize = dims[dim + 1..].iter().product(); + (0..dims[dim]) + .map(|i| [(i as i64).elem::()].repeat(numel_trailing_dims)) + .collect::>() + .concat() + .repeat(numel_leading_dims) + } +} + +/// Compare two elements +fn compare(a: &E, b: &E, descending: bool) -> Ordering { + if descending { b.cmp(a) } else { a.cmp(b) } +} diff --git a/crates/burn-backend/src/backend/ops/tensor.rs b/crates/burn-backend/src/backend/ops/tensor.rs new file mode 100644 index 0000000..9286b94 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/tensor.rs @@ -0,0 +1,1875 @@ +use super::cat::cat_with_slice_assign; +use super::grid_sample::float_grid_sample_2d_ref; +use super::repeat_dim::repeat_with_slice_assign; +use super::sort::{argsort, sort, sort_with_indices}; +use crate::ops::GridSampleOptions; +use crate::tensor::{BoolTensor, Device, FloatTensor, IntTensor}; +use crate::{Backend, Distribution, TensorData, get_device_settings}; +use crate::{ExecutionError, Scalar, TensorMetadata}; +use alloc::vec::Vec; +use burn_std::reader::try_read_sync; +use burn_std::{BoolDType, FloatDType, IntDType, Shape, Slice}; + +/// Operations on float tensors. +pub trait FloatTensorOps { + /// Creates a new tensor from the data structure. + /// + /// # Arguments + /// + /// * `data` - The data structure. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// The tensor with the given data. + fn float_from_data(data: TensorData, device: &Device) -> FloatTensor; + + /// Creates a new tensor with random values. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `distribution` - The distribution to sample from. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor with the given shape and random values. + fn float_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor; + + /// Creates a new tensor with zeros. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor with the given shape and zeros. + fn float_zeros(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + Self::float_from_data(TensorData::full_dtype(shape, 0., dtype.into()), device) + } + + /// Creates a new tensor with ones. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor with the given shape and ones. + fn float_ones(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + Self::float_from_data(TensorData::full_dtype(shape, 1., dtype.into()), device) + } + + /// Creates a tensor filled with given value. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `fill_value` - The value with which to fill the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor filled with given value + fn float_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + Self::float_from_data( + TensorData::full_dtype(shape, fill_value, dtype.into()), + device, + ) + } + + /// Converts the tensor to a data structure. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The data structure with the tensor's data. + fn float_into_data( + tensor: FloatTensor, + ) -> impl Future> + Send; + + /// Moves the tensor to the given device. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `device` - The device to move the tensor to. + /// + /// # Returns + /// + /// The tensor on the given device. + fn float_to_device(tensor: FloatTensor, device: &Device) -> FloatTensor; + + /// Converts float tensor to int tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// The int tensor with the same data as the float tensor. + fn float_into_int(tensor: FloatTensor, out_dtype: IntDType) -> IntTensor; + + /// Creates an empty tensor with the given shape. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The empty tensor with the given shape. + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor; + + /// Repeat the tensor along the given dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension to repeat. + /// * `times` - The number of times to repeat the dimension. + /// + /// # Returns + /// + /// The tensor with the given dimension repeated. + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + let device = tensor.device(); + repeat_with_slice_assign::( + tensor, + dim, + times, + device, + |shape, device, dtype| B::float_empty(shape, device, dtype.into()), + B::float_slice_assign, + ) + } + + /// Adds two tensors together. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of adding the two tensors together. + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Adds a scalar to a tensor. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of adding the scalar to the tensor. + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor; + + /// Clamps a tensor under a minimum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + let dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = Self::float_lower_elem(tensor.clone(), min, dtype); + B::float_mask_fill(tensor, mask, min) + } + + /// Clamps a tensor over a maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + let dtype = get_device_settings::(&tensor.device()).bool_dtype; + let mask = Self::float_greater_elem(tensor.clone(), max, dtype); + B::float_mask_fill(tensor, mask, max) + } + + /// Clamps a tensor between a minimum and maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// The clamped tensor. + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + // Default implementation + Self::float_clamp_min(Self::float_clamp_max(tensor, max), min) + } + + /// Subtracts two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of subtracting the two tensors. + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Subtracts a scalar from a tensor. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of subtracting the scalar from the tensor. + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor; + + /// Multiplies two tensors together element-wise. + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Multiplies a tensor by a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of multiplying the tensor by the scalar. + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor; + + /// Divides two tensors element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of dividing the two tensors. + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Divides a tensor by a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of dividing the tensor by the scalar. + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor; + + /// Computes the remainder of division between two tensors element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The element-wise remainder when dividing `lhs` by `rhs`. + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Computes the modulus of a tensor given a scalar. + /// + /// # Arguments + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The result of applying the modulus of the scalar to the tensor. + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor; + + /// Multiplies two tensors together using matrix multiplication. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The result of multiplying the two tensors together using matrix multiplication. + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Computes the cross product of two tensors along a given dimension. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `dim` - The dimension to compute the cross product along. + /// + /// # Returns + /// + /// The cross product of the two tensors. + fn float_cross(lhs: FloatTensor, rhs: FloatTensor, dim: usize) -> FloatTensor; + + /// Negates a tensor element-wise. + fn float_neg(tensor: FloatTensor) -> FloatTensor { + Self::float_mul_scalar(tensor, (-1f32).into()) + } + + /// Calculates the reciprocals element-wise + fn float_recip(tensor: FloatTensor) -> FloatTensor; + + /// Transposes a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + fn float_transpose(tensor: FloatTensor) -> FloatTensor { + let ndims = tensor.shape().num_dims(); + Self::float_swap_dims(tensor, ndims - 2, ndims - 1) + } + + /// Swaps two dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap. + /// * `dim2` - The second dimension to swap. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor; + + /// Permutes the dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to permute the dimensions of. + /// * `axes` - The new order of the dimensions. + /// # Returns + /// + /// The tensor with the dimensions permuted. + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor; + + /// Reverse the order of elements in a tensor along the given axes. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reverse. + /// * `axes` - The axes to reverse. + /// + /// The tensor with the elements reversed. + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor; + + /// Reshapes a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to reshape. + /// * `shape` - The new shape of the tensor. + /// + /// # Returns + /// + /// The tensor with the new shape. + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor; + + /// Gather elements from a tensor. + /// + /// # Arguments + /// + /// * `dim` - The dimension to gather from. + /// * `tensor` - The tensor to gather from. + /// * `indices` - The indices to gather. + /// + /// # Returns + /// + /// The gathered elements. + fn float_gather(dim: usize, tensor: FloatTensor, indices: IntTensor) -> FloatTensor; + + /// Scatter elements into a tensor using sum reduction. + /// + /// # Arguments + /// + /// * `dim` - The dimension to scatter into. + /// * `tensor` - The tensor to scatter into. + /// * `indices` - The indices to scatter into. + /// * `value` - The value to scatter. + /// + /// # Returns + /// + /// The tensor with the scattered elements. + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor; + + /// Multi-dimensional scatter: update `data` at locations specified by `indices` with `values`. + /// + /// # Arguments + /// + /// * `data` - The tensor to scatter into. + /// * `indices` - An M-dimensional integer tensor whose last dimension indexes into `data`. + /// * `values` - The values to scatter. + /// * `reduction` - How to combine with existing values. + /// + /// # Returns + /// + /// The tensor with scattered values. + fn float_scatter_nd( + _data: FloatTensor, + _indices: IntTensor, + _values: FloatTensor, + _reduction: crate::tensor::IndexingUpdateOp, + ) -> FloatTensor { + unimplemented!("float_scatter_nd is not implemented for this backend") + } + + /// Multi-dimensional gather: collect slices from `data` at locations specified by `indices`. + /// + /// # Arguments + /// + /// * `data` - The tensor to gather from. + /// * `indices` - An M-dimensional integer tensor whose last dimension indexes into `data`. + /// + /// # Returns + /// + /// The gathered tensor. + fn float_gather_nd(_data: FloatTensor, _indices: IntTensor) -> FloatTensor { + unimplemented!("float_gather_nd is not implemented for this backend") + } + + /// Select tensor elements along the given dimension corresponding for the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices to select. + /// + /// # Returns + /// + /// The selected elements. + fn float_select(tensor: FloatTensor, dim: usize, indices: IntTensor) -> FloatTensor; + + /// Assign the selected elements along the given dimension corresponding for the given indices + /// to the given value using sum reduction. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `dim` - The dimension to select from. + /// * `indices` - The indices to select. + /// * `value` - The value to assign. + /// + /// # Returns + /// + /// The tensor with the selected elements assigned to the given value. + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor; + + /// Select tensor elements corresponding to the given slices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `slices` - The slices specifying ranges and steps for each dimension. + /// + /// # Returns + /// + /// The selected elements in a new tensor. + /// + /// # Note + /// + /// Empty slices (where start >= end) are handled at the high-level tensor API and will not + /// be passed to this method. Backend implementations do not need to handle empty slices. + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor; + + /// Assign the selected elements corresponding to the given slices to the given value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `ranges` - The ranges to select. + /// * `value` - The value to assign. + /// + /// # Returns + /// + /// The tensor with the selected elements assigned to the given value. + /// + /// # Note + /// + /// Empty slice assignments (where any slice range produces 0 elements) are handled at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty slice assignments. + fn float_slice_assign( + tensor: FloatTensor, + slices: &[Slice], + value: FloatTensor, + ) -> FloatTensor; + + /// Update the given tensor with the value tensor where the mask is true. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `mask` - The boolean mask to select with. + /// * `value` - The value to assign to the selected elements from the value tensor. + /// + /// # Returns + /// + /// The tensor with the selected elements assigned to the given value. + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor; + + /// Update the given tensor with the value where the mask is true. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `mask` - The boolean mask to select with. + /// * `value` - The value to assign to the selected elements. + /// + /// # Returns + /// + /// The tensor with the selected elements assigned to the given value. + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor; + + /// Equal comparison of two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_equal(lhs: FloatTensor, rhs: FloatTensor, out_dtype: BoolDType) + -> BoolTensor; + + /// Element-wise non-equality comparison. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_not_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let equal_tensor = B::float_equal(lhs, rhs, out_dtype); + B::bool_not(equal_tensor) + } + + /// Equal comparison of a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_equal_elem(lhs: FloatTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Element-wise non-equality comparison with a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_not_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let equal_tensor = B::float_equal_elem(lhs, rhs, out_dtype); + B::bool_not(equal_tensor) + } + + /// Greater than comparison of two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Greater than comparison of a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_greater_elem(lhs: FloatTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Greater than or equal comparison of two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Greater than or equal comparison of a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Less than comparison of two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_lower(lhs: FloatTensor, rhs: FloatTensor, out_dtype: BoolDType) + -> BoolTensor; + + /// Less than comparison of a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_lower_elem(lhs: FloatTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor; + + /// Less than or equal comparison of two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Less than or equal comparison of a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with the result of the comparison. + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor; + + /// Detaches a tensor from the computation graph. + fn float_detach(tensor: FloatTensor) -> FloatTensor { + // Should only be overridden by autodiff backends. + tensor + } + + /// Sets the `require_grad` flag of a tensor. + fn float_set_require_grad(tensor: FloatTensor, _require_grad: bool) -> FloatTensor { + // Should only be overridden by autodiff backends. + tensor + } + + /// Returns the `require_grad` flag of a tensor. + fn float_is_require_grad(_tensor: &FloatTensor) -> bool { + // Should only be overridden by autodiff backends. + false + } + + /// Sum of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// + /// # Returns + /// + /// A scalar tensor with the sum of all elements in `tensor`. + fn float_sum(tensor: FloatTensor) -> FloatTensor; + + /// Sum of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// * `dim` - The dimension along which to sum. + /// + /// # Returns + /// + /// A tensor with the sum of all elements in `tensor` along `dim`. + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Product of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to product. + /// + /// # Returns + /// + /// A scalar tensor with the product of all elements in `tensor`. + fn float_prod(tensor: FloatTensor) -> FloatTensor { + // Product of all elements in a tensor + B::float_exp(B::float_sum(B::float_log(tensor))) + } + + /// Product of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to product. + /// + /// # Returns + /// + /// A tensor with the product of all elements in `tensor` along `dim`. + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + // Product of all elements in a tensor along a dimension + B::float_exp(B::float_sum_dim(B::float_log(tensor), dim)) + } + + /// Mean of all elements in a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to mean. + /// + /// # Returns + /// + /// A scalar tensor with the mean of all elements in `tensor`. + fn float_mean(tensor: FloatTensor) -> FloatTensor { + let num_elems = tensor.shape().num_elements() as f32; + B::float_div_scalar(B::float_sum(tensor), num_elems.into()) + } + + /// Mean of all elements in a tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to mean. + /// * `dim` - The dimension along which to mean. + /// + /// # Returns + /// + /// A tensor with the mean of all elements in `tensor` along `dim`. + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Computes the cumulative sum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative sum of. + /// * `dim` - The dimension along which to compute the cumulative sum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative sum + /// of all elements up to and including that position along the dimension. + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Computes the cumulative product of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative product of. + /// * `dim` - The dimension along which to compute the cumulative product. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the cumulative product + /// of all elements up to and including that position along the dimension. + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Computes the cumulative minimum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative minimum of. + /// * `dim` - The dimension along which to compute the cumulative minimum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the minimum + /// of all elements up to and including that position along the dimension. + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Computes the cumulative maximum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative maximum of. + /// * `dim` - The dimension along which to compute the cumulative maximum. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the maximum + /// of all elements up to and including that position along the dimension. + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor; + + /// Converts a tensor to another floating point data type. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to convert. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// A tensor with the same values as `tensor` but in the target floating point data type. + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor; + + /// Returns a new tensor with exponential values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to exponentiate. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with exponential values. + fn float_exp(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with natural logarithm values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the logarithm of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with natural logarithm values. + fn float_log(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with logarithm values of (1 + Xi). + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the logarithm of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with logarithm values of (1 + Xi). + fn float_log1p(tensor: FloatTensor) -> FloatTensor; + + /// Element-wise power with a FloatTensor. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the power of the elements of `rhs`. + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Element-wise power with an IntTensor. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side floatTensor. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. Result is an IntTensor. + fn float_powi(lhs: FloatTensor, rhs: IntTensor) -> FloatTensor { + let dtype = lhs.dtype(); + Self::float_powf(lhs, B::int_into_float(rhs, dtype.into())) + } + + /// Raises a tensor to the power of an int scalar. + /// + /// # Backend Implementors Note + /// + /// A number of common exponent cases can be implemented with operations + /// which are much cheaper than generic exponentiation. + /// + /// This (`Backend` impl overridable) operation handles generic optimizations + /// for several common integer exponent cases; and then dispatches to + /// the (`Backend` impl overridable) [`Self::float_powi_scalar_impl`] + /// operation to handle the generic case. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. + fn float_powi_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + match rhs.elem::() { + 0 => Self::float_ones(lhs.shape(), &lhs.device(), lhs.dtype().into()), + 1 => lhs, + 2 => B::float_mul(lhs.clone(), lhs), + -1 => Self::float_recip(lhs), + -2 => Self::float_recip(B::float_mul(lhs.clone(), lhs)), + _ => Self::float_powi_scalar_impl(lhs, rhs), + } + } + + /// Raises a tensor to the power of an int scalar. + /// + /// # Backend Implementors Note + /// + /// This is the generic implementation of integer exponentiation + /// called by [`Self::float_powi_scalar`] in the fallback case. + /// + /// As a general rule, this should not be called directly. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side scalar. + /// + /// # Returns + /// + /// The elements of `lhs` raised to the value of `rhs`. + fn float_powi_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + // Avoid a recursive loop by deferring directly to float_powf_scalar_impl. + Self::float_powf_scalar_impl(lhs, rhs) + } + + /// Returns a new tensor with values raised to the power of float `value`. + /// + /// # Backend Implementors Note + /// + /// This (`Backend` impl overridable) operation dispatches integer exponentiation + /// to [`Self::float_powi_scalar`], and the remaining non-integer exponent cases to + /// the (`Backend` impl overridable) [`Self::float_powf_scalar_impl`] + /// operation to handle the generic case. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to exponentiate. + /// * `value` - The exponent. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with values raised to the power of `value`. + fn float_powf_scalar(tensor: FloatTensor, value: Scalar) -> FloatTensor { + if let Some(exp) = value.try_as_integer() { + Self::float_powi_scalar(tensor, exp) + } else { + Self::float_powf_scalar_impl(tensor, value) + } + } + + /// Returns a new tensor with values raised to the power of float `value`. + /// + /// # Backend Implementors Note + /// + /// This is the generic implementation of integer exponentiation + /// called by [`Self::float_powf_scalar`] in the fallback case. + /// + /// This is the minimal required support a `Backend` must implement + /// for exponentiation. + /// + /// As a general rule, this should not be called directly. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to exponentiate. + /// * `value` - The exponent. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with values raised to the power of `value`. + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor; + + /// Returns a new tensor with square root values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the square root of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with square root values. + fn float_sqrt(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with the Euclidean distance values. + /// + /// # Arguments + /// + /// * `lhs` - The left-hand side tensor. + /// * `rhs` - The right-hand side tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `lhs` and `rhs` with hypotenuse values. + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + // default implementation for any backend that can't either iterator over elements or doesn't have + // a native hypot implementation + + // Mirrors glibc's approach: scale by max(|lhs|, |rhs|) to avoid + // overflow/underflow in the intermediate squaring step. + // + // hypot(x, y) = |max| * sqrt(1 + (min/max)^2) + // + // Edge cases: + // - If max == 0, both inputs are 0, result is 0 (division guarded by clamp) + // - If max is inf, result is inf (propagates naturally through sqrt) + // - NaN propagates naturally + let abs_lhs = B::float_abs(lhs); + let abs_rhs = B::float_abs(rhs); + + let diff = B::float_clamp_min(B::float_sub(abs_rhs.clone(), abs_lhs.clone()), 0.0.into()); + let max = B::float_add(abs_lhs.clone(), diff.clone()); + let min = B::float_sub(abs_rhs, diff); + + // Clamp max to at least epsilon to avoid 0/0; result will be 0 anyway + // since min <= max, so (min/clamped_max)^2 won't blow up meaningfully. + let max_safe = B::float_clamp_min( + max.clone(), + max.dtype().finfo().unwrap().min_positive.into(), + ); + + let ratio = B::float_div(min, max_safe); + let ratio_sq = B::float_mul(ratio.clone(), ratio); + + let inner = B::float_add_scalar(ratio_sq, 1.0.into()); + + B::float_mul(max, B::float_sqrt(inner)) + } + + /// Returns a new tensor with absolute values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take absolute value of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with absolute values. + fn float_abs(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the cosine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with cosine values. + fn float_cos(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with sine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the sine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with sine values. + fn float_sin(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the tangent of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with tangent values. + fn float_tan(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with hyperbolic cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic cosine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic cosine values. + fn float_cosh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with hyperbolic sine values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic sine of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic sine values. + fn float_sinh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with hyperbolic tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the hyperbolic tangent of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic tangent values. + fn float_tanh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with inverse cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse cosine values. + fn float_acos(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with inverse hyperbolic cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse hyperbolic cosine values. + fn float_acosh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with inverse sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse sine values. + fn float_asin(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with inverse hyperbolic sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse hyperbolic sine values. + fn float_asinh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with the inverse tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with the inverse tangent values. + fn float_atan(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with the inverse hyperbolic tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with the inverse hyperbolic tangent values. + fn float_atanh(tensor: FloatTensor) -> FloatTensor; + + /// Returns a tensor with the four-quadrant inverse tangent values of `y` and `x`. + /// + /// # Arguments + /// + /// * `lhs` - The tensor with y coordinates. + /// * `rhs` - The tensor with x coordinates. + /// + /// # Returns + /// + /// A tensor with the four-quadrant inverse tangent values. + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with rounded values. + /// + /// This function should implement the [round half to even](https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even) + /// strategy, with halfway cases rounded to the nearest even integer value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to be rounded. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with rounded values. + fn float_round(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with floored values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to be floored. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with floored values. + fn float_floor(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with ceiled values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to be ceiled. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with ceiled values. + fn float_ceil(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with truncated values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to be truncated. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with truncated values. + fn float_trunc(tensor: FloatTensor) -> FloatTensor; + + /// Returns a new tensor with the error function values. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to take the error function of. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with error function values. + fn float_erf(tensor: FloatTensor) -> FloatTensor; + + /// Concatenates tensors along a dimension. + /// + /// # Arguments + /// + /// * `tensors` - The tensors to concatenate. + /// * `dim` - The dimension along which to concatenate. + /// + /// # Returns + /// + /// A tensor with the concatenated tensors along `dim`. + /// + /// # Note + /// + /// Empty tensors (where the concatenation dimension has size 0) are filtered out at the + /// high-level tensor API and will not be passed to this method. Backend implementations do + /// not need to handle empty tensors. + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + let first_tensor = tensors.first().expect("Tensors should not be empty"); + let device = first_tensor.device(); + + cat_with_slice_assign::( + tensors, + dim, + device, + |shape, device, dtype| B::float_empty(shape, device, dtype.into()), + B::float_slice_assign, + ) + } + + /// Gets the indices of the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the maximum elements of `tensor` along `dim`. + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor; + + /// Gets the indices of the k maximum elements of a tensor along an axis. + /// if two elements are equals, it will be ordered by lowest indices + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `k` - number of maximum elements + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the maximum elements of `tensor` along `dim`. + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor; + + /// Gets the values of the k maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `k` - number of maximum elements + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the values of the maximum elements of `tensor` along `dim`. + fn float_topk(tensor: FloatTensor, dim: usize, k: usize) -> FloatTensor { + let device = tensor.device(); + let dtype = get_device_settings::(&device).int_dtype; + let k_indices = B::int_arange(0..k as i64, &device, dtype); + Self::float_select(Self::float_sort(tensor, dim, true), dim, k_indices) + } + + /// Gets the indices of the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the indices of the minimum elements of `tensor` along `dim`. + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor; + + /// Gets the maximum element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// + /// # Returns + /// + /// A tensor with the maximum element of `tensor`. + fn float_max(tensor: FloatTensor) -> FloatTensor { + let shape = tensor.shape(); + let tensor = B::float_reshape(tensor, Shape::new([shape.num_elements()])); + + B::float_max_dim(tensor, 0) + } + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the maximum elements of `tensor` along `dim`. + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let dtype = get_device_settings::(&tensor.device()).int_dtype; + let index = B::float_argmax(tensor.clone(), dim, dtype); + + B::float_gather(dim, tensor, index) + } + + /// Gets the maximum elements of a tensor along an axis and their indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// * `indices_dtype` - The indices tensor dtype. + /// + /// # Returns + /// + /// A tuple with the maximum elements of `tensor` along `dim` and their indices. + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let index = B::float_argmax(tensor.clone(), dim, indices_dtype); + let values = B::float_gather(dim, tensor, index.clone()); + + (values, index) + } + + /// Gets the minimum element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// + /// # Returns + /// + /// A tensor with the minimum element of `tensor`. + fn float_min(tensor: FloatTensor) -> FloatTensor { + let shape = tensor.shape(); + let tensor = B::float_reshape(tensor, Shape::new([shape.num_elements()])); + + B::float_min_dim(tensor, 0) + } + + /// Gets the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// + /// # Returns + /// + /// A tensor with the minimum elements of `tensor` along `dim`. + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let dtype = get_device_settings::(&tensor.device()).int_dtype; + let index = B::float_argmin(tensor.clone(), dim, dtype); + + B::float_gather(dim, tensor, index) + } + + /// Gets the minimum elements of a tensor along an axis and their indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements of. + /// * `dim` - The dimension along which to get the minimum elements. + /// * `indices_dtype` - The indices tensor dtype. + /// + /// # Returns + /// + /// A tuple with the minimum elements of `tensor` along `dim` and their indices. + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let index = B::float_argmin(tensor.clone(), dim, indices_dtype); + let values = B::float_gather(dim, tensor, index.clone()); + + (values, index) + } + + /// Gets the maximum absolute element of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// + /// # Returns + /// + /// A tensor with the maximum element of `tensor`. + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + let shape = tensor.shape(); + let tensor = B::float_reshape(tensor, Shape::new([shape.num_elements()])); + + B::float_max_abs_dim(tensor, 0) + } + + /// Gets the maximum absolute elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements of. + /// * `dim` - The dimension along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the maximum elements of `tensor` along `dim`. + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + B::float_max_dim(B::float_abs(tensor), dim) + } + + /// Tests if any element in the float `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if any element in the tensor is True, False otherwise. + fn float_any(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + let float_dtype = tensor.dtype(); + let bool_tensor = B::float_equal_elem(tensor, 0f32.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::float_sum(B::bool_into_float(bool_tensor, float_dtype.into())); + B::float_greater_elem(sum, 0f32.into(), out_dtype) + } + + /// Tests if any element in the float `tensor` evaluates to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if any element along this dim in the + /// input evaluates to True, False otherwise. + fn float_any_dim(tensor: FloatTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let float_dtype = tensor.dtype(); + let bool_tensor = B::float_equal_elem(tensor, 0f32.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::float_sum_dim(B::bool_into_float(bool_tensor, float_dtype.into()), dim); + B::float_greater_elem(sum, 0f32.into(), out_dtype) + } + + /// Tests if all elements in the float `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with a single element, True if all elements in the input tensor + /// evaluate to True, False otherwise. + fn float_all(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + let float_dtype = tensor.dtype(); + let num_elems = tensor.shape().num_elements() as f32; + let bool_tensor = B::float_equal_elem(tensor, 0f32.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::float_sum(B::bool_into_float(bool_tensor, float_dtype.into())); + B::float_equal_elem(sum, num_elems.into(), out_dtype) + } + + /// Tests if all elements in the float `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// * `dim` - The axis along which to test. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same size as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if all elements along this dim in the input + /// evaluates to True, False otherwise. + fn float_all_dim(tensor: FloatTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let float_dtype = tensor.dtype(); + let num_elems = tensor.shape()[dim] as f32; + let bool_tensor = B::float_equal_elem(tensor, 0f32.into(), out_dtype); + let bool_tensor = B::bool_not(bool_tensor); + let sum = B::float_sum_dim(B::bool_into_float(bool_tensor, float_dtype.into()), dim); + B::float_equal_elem(sum, num_elems.into(), out_dtype) + } + + /// Returns the signs of the float `tensor`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to extract the signs from. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` containing the signs of the elements of `tensor`. + fn float_sign(tensor: FloatTensor) -> FloatTensor { + let device = tensor.device(); + let bool_dtype = get_device_settings::(&tensor.device()).bool_dtype; + let zeros = B::float_zeros(tensor.shape(), &device, tensor.dtype().into()); + let less_than_zero = B::float_lower_elem(tensor.clone(), 0f32.into(), bool_dtype); + let greater_than_zero = B::float_greater_elem(tensor, 0f32.into(), bool_dtype); + + let mut result = B::float_mask_fill(zeros, less_than_zero, (-1f32).into()); + result = B::float_mask_fill(result, greater_than_zero, 1f32.into()); + result + } + + /// Broadcasts the float `tensor` to the given `shape`. + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor; + + /// Sort the elements of the input `tensor` by value in along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where the elements are sorted by value. + fn float_sort(tensor: FloatTensor, dim: usize, descending: bool) -> FloatTensor { + let device = tensor.device(); + sort::( + tensor, + dim, + descending, + device, + |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::float_into_data(tensor)) + .expect(msg) + .expect(msg) + }, + |data, device, _dtype| B::float_from_data(data, device), + ) + } + + /// Sort the elements of the input `tensor` by value in along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// * `indices_dtype` - The indices tensor dtype. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor and corresponding indices, where + /// the elements are sorted by value and the indices map back to the original input tensor. + fn float_sort_with_indices( + tensor: FloatTensor, + dim: usize, + descending: bool, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let device = tensor.device(); + sort_with_indices::( + tensor, + dim, + descending, + indices_dtype, + device, + |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::float_into_data(tensor)) + .expect(msg) + .expect(msg) + }, + |data, device, _dtype| B::float_from_data(data, device), + ) + } + + /// Returns the indices that sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// * `out_dtype` - The output tensor dtype. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor the indices map back to the original input tensor. + fn float_argsort( + tensor: FloatTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> IntTensor { + let device = tensor.device(); + argsort::(tensor, dim, descending, out_dtype, device, |tensor| { + let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation."; + try_read_sync(B::float_into_data(tensor)) + .expect(msg) + .expect(msg) + }) + } + + /// Samples tensor as a two-dimensional spatial grid of (possibly multi-channel) values, + /// using the given locations in [-1, 1]. + /// + /// # Arguments + /// + /// * `tensor` - The tensor being sampled from, must be contiguous with shape (N, C, H_in, W_in) + /// * `grid` - A tensor of locations, with shape (N, H_out, W_out, 2). Values are [-1, 1]. + /// A [x = -1, y = -1] means top-left, and [x = 1, y = 1] means bottom-right + /// * `options` - Grid sampling options (mode, padding_mode, align_corners) + /// + /// # Returns + /// + /// A tensor with shape (N, C, H_out, W_out) + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, + ) -> FloatTensor { + // TODO: default impl should get int default dtype + float_grid_sample_2d_ref::(tensor, grid, options) + } + + /// Unfold windows along a dimension. + /// + /// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` + /// * `dim` - the selected dim. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with shape ``[pre=..., windows, size, post=...]``. + fn float_unfold(tensor: FloatTensor, dim: usize, size: usize, step: usize) + -> FloatTensor; + + /// Returns a new tensor with boolean elements indicating whether each element of the input is NaN. + /// + /// # Returns + /// + /// A boolean tensor where `true` indicates NaN and `false` indicates a non-NaN value. + fn float_is_nan(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + // Check if the input tensor is NaN by comparing it to itself + // NaN is the only value that is not equal to itself + B::float_not_equal(tensor.clone(), tensor, out_dtype) + } + + /// Returns a new tensor with boolean elements indicating whether each element of the input is infinite (either +INF or -INF). + /// + /// # Returns + /// + /// A boolean tensor where `true` indicates that the value is infinite + fn float_is_inf(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + B::float_equal_elem(B::float_abs(tensor), f64::INFINITY.into(), out_dtype) + } +} diff --git a/crates/burn-backend/src/backend/ops/transaction.rs b/crates/burn-backend/src/backend/ops/transaction.rs new file mode 100644 index 0000000..59440a9 --- /dev/null +++ b/crates/burn-backend/src/backend/ops/transaction.rs @@ -0,0 +1,142 @@ +use alloc::vec::Vec; +use core::future::Future; + +use crate::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}; +use crate::{Backend, ExecutionError, TensorData, TensorPrimitive}; + +enum Order { + Float(usize), + QFloat(usize), + Int(usize), + Bool(usize), +} + +#[derive(Default)] +/// Contains all tensor primitives that are going to be read. +pub struct TransactionPrimitive { + /// Float tensors. + pub read_floats: Vec>, + /// Quantized tensors. + pub read_qfloats: Vec>, + /// Int tensors. + pub read_ints: Vec>, + /// Bool tensors. + pub read_bools: Vec>, + orders: Vec, +} + +#[derive(Default)] +/// Contains all [data](TensorData) related to a [transaction](TransactionPrimitive). +pub struct TransactionPrimitiveData { + /// Float tensor data. + pub read_floats: Vec, + /// Quantized tensor data. + pub read_qfloats: Vec, + /// Int tensor data. + pub read_ints: Vec, + /// Bool tensor data. + pub read_bools: Vec, +} + +/// Operations that are sync by nature and that can be batch together in transactions to improve +/// compute utilization with efficient laziness. +pub trait TransactionOps { + /// Executes a [transaction](TransactionPrimitive) and return its + /// [data](TransactionPrimitiveData). + fn tr_execute( + transaction: TransactionPrimitive, + ) -> impl Future> + Send { + async move { + let mut floats = Vec::new(); + let mut qfloats = Vec::new(); + let mut ints = Vec::new(); + let mut bools = Vec::new(); + + for t in transaction.read_floats { + floats.push(B::float_into_data(t).await?); + } + for t in transaction.read_qfloats { + qfloats.push(B::q_into_data(t).await?); + } + for t in transaction.read_ints { + ints.push(B::int_into_data(t).await?); + } + for t in transaction.read_bools { + bools.push(B::bool_into_data(t).await?); + } + + Ok(TransactionPrimitiveData { + read_floats: floats, + read_qfloats: qfloats, + read_ints: ints, + read_bools: bools, + }) + } + } +} + +impl TransactionPrimitive { + /// Creates a new transaction. + pub fn new( + read_floats: Vec>, + read_qfloats: Vec>, + read_ints: Vec>, + read_bools: Vec>, + ) -> Self { + Self { + read_floats, + read_qfloats, + read_ints, + read_bools, + orders: Vec::default(), + } + } + /// Executes the transaction asynchronously and returns the [data](TensorData) in the same order + /// in which they were registered. + pub async fn execute_async(mut self) -> Result, ExecutionError> { + let mut orders = Vec::new(); + core::mem::swap(&mut orders, &mut self.orders); + let result = B::tr_execute(self).await?; + + let mut floats: Vec<_> = result.read_floats.into_iter().map(Some).collect(); + let mut qfloats: Vec<_> = result.read_qfloats.into_iter().map(Some).collect(); + let mut ints: Vec<_> = result.read_ints.into_iter().map(Some).collect(); + let mut bools: Vec<_> = result.read_bools.into_iter().map(Some).collect(); + + Ok(orders + .into_iter() + .map(|order| match order { + Order::Float(index) => floats.get_mut(index).unwrap().take().unwrap(), + Order::QFloat(index) => qfloats.get_mut(index).unwrap().take().unwrap(), + Order::Int(index) => ints.get_mut(index).unwrap().take().unwrap(), + Order::Bool(index) => bools.get_mut(index).unwrap().take().unwrap(), + }) + .collect::>()) + } + + /// Register a float (or quantized-float) tensor to read in this transaction. + pub fn register_float(&mut self, tensor: TensorPrimitive) { + match tensor { + TensorPrimitive::Float(tensor) => { + self.orders.push(Order::Float(self.read_floats.len())); + self.read_floats.push(tensor); + } + TensorPrimitive::QFloat(tensor) => { + self.orders.push(Order::QFloat(self.read_qfloats.len())); + self.read_qfloats.push(tensor); + } + } + } + + /// Register an int tensor to read in this transaction. + pub fn register_int(&mut self, tensor: IntTensor) { + self.orders.push(Order::Int(self.read_ints.len())); + self.read_ints.push(tensor); + } + + /// Register a bool tensor to read in this transaction. + pub fn register_bool(&mut self, tensor: BoolTensor) { + self.orders.push(Order::Bool(self.read_bools.len())); + self.read_bools.push(tensor); + } +} diff --git a/crates/burn-backend/src/backend/primitive.rs b/crates/burn-backend/src/backend/primitive.rs new file mode 100644 index 0000000..33ae236 --- /dev/null +++ b/crates/burn-backend/src/backend/primitive.rs @@ -0,0 +1,107 @@ +use crate::{Backend, BackendTypes, DeviceOps, get_device_settings}; +use burn_std::{DType, QuantScheme, Shape}; + +#[derive(Debug, Clone)] +/// A primitive tensor representation. +pub enum TensorPrimitive { + /// Float tensor primitive. + Float(B::FloatTensorPrimitive), + /// Quantized float tensor primitive. + QFloat(B::QuantizedTensorPrimitive), +} + +impl TensorPrimitive { + /// Returns the full tensor representation. + pub fn tensor(self) -> B::FloatTensorPrimitive { + match self { + Self::QFloat(tensor) => { + let dtype = get_device_settings::(&tensor.device()).float_dtype; + B::dequantize(tensor, dtype) + } + Self::Float(tensor) => tensor, + } + } + + /// Returns a mutable reference to the full tensor representation. + pub fn get_mut_ref(&mut self) -> &mut B::FloatTensorPrimitive { + match self { + Self::QFloat(_tensor) => todo!(), + Self::Float(tensor) => tensor, + } + } +} + +impl TensorMetadata for TensorPrimitive { + type Device = B::Device; + + fn dtype(&self) -> DType { + match self { + TensorPrimitive::Float(tensor) => tensor.dtype(), + TensorPrimitive::QFloat(tensor) => tensor.dtype(), + } + } + + fn shape(&self) -> Shape { + match self { + TensorPrimitive::Float(tensor) => tensor.shape(), + TensorPrimitive::QFloat(tensor) => tensor.shape(), + } + } + + fn rank(&self) -> usize { + match self { + TensorPrimitive::Float(tensor) => tensor.rank(), + TensorPrimitive::QFloat(tensor) => tensor.rank(), + } + } + fn device(&self) -> Self::Device { + match self { + TensorPrimitive::Float(tensor) => tensor.device(), + TensorPrimitive::QFloat(tensor) => tensor.device(), + } + } + + fn can_mut(&self) -> bool { + match self { + TensorPrimitive::Float(tensor) => tensor.can_mut(), + TensorPrimitive::QFloat(tensor) => tensor.can_mut(), + } + } +} + +/// Tensor metadata trait for tensor primitive. +pub trait TensorMetadata: Clone + Send + Sync + core::fmt::Debug { + /// The device type associated with the tensor. + type Device: DeviceOps; + /// Get the dtype of the tensor. + fn dtype(&self) -> DType; + /// Get the shape of the tensor. + fn shape(&self) -> Shape; + + /// Get the number of dimensions of the tensor. + fn rank(&self) -> usize { + self.shape().num_dims() + } + /// Get the device associated with the tensor. + fn device(&self) -> Self::Device; + + /// Whether the tensor's buffer can be mutated in place — i.e. this handle + /// uniquely owns it, so an in-place op (`slice_assign`, an inplace kernel) + /// writes the existing allocation instead of copying it first. + /// + /// Backends that track buffer ownership (cubecl, fusion, tch) answer + /// precisely; a backend that can't must return a conservative `false` — + /// the buffer may be aliased, so an in-place write can't be assumed safe. + fn can_mut(&self) -> bool; + + /// Get the [quantization scheme](QuantScheme) for a quantized float tensor. + /// + /// # Panics + /// Panics if the tensor is not quantized. + fn scheme(&self) -> QuantScheme { + match self.dtype() { + DType::QFloat(scheme) => scheme, + other => panic!("Quantization scheme is not valid for dtype {other:?}"), + } + } +} diff --git a/crates/burn-backend/src/cubecl.rs b/crates/burn-backend/src/cubecl.rs new file mode 100644 index 0000000..d337410 --- /dev/null +++ b/crates/burn-backend/src/cubecl.rs @@ -0,0 +1,111 @@ +//! Conversion helpers between burn's [`DType`] and cubecl's +//! `ElemType` / `StorageType`. +//! +//! These are exposed as plain named functions (rather than `From`/`Into` +//! trait impls in `burn-std`) so that `burn-std` doesn't need to depend on +//! `cubecl` and the cubecl type tree doesn't leak into the shared standard +//! library. Backend implementations that want these conversions depend on +//! `burn-backend` (with the `cubecl` feature) and call these functions +//! explicitly. + +use burn_std::{BoolStore, DType, QuantScheme, QuantStore, QuantValue}; +use cubecl::ir::{ElemType, FloatKind, IntKind, StorageType, UIntKind}; + +/// Convert a cubecl [`ElemType`] into the corresponding burn [`DType`]. +/// +/// Panics if the cubecl type has no direct burn equivalent (e.g. `TF32`). +pub fn elem_type_to_dtype(value: ElemType) -> DType { + match value { + ElemType::Float(float_kind) => match float_kind { + FloatKind::F16 => DType::F16, + FloatKind::BF16 => DType::BF16, + FloatKind::Flex32 => DType::Flex32, + FloatKind::F32 => DType::F32, + FloatKind::F64 => DType::F64, + FloatKind::TF32 => panic!("Not a valid DType for tensors."), + FloatKind::E2M1 + | FloatKind::E2M3 + | FloatKind::E3M2 + | FloatKind::E4M3 + | FloatKind::E5M2 + | FloatKind::UE8M0 => { + unimplemented!("Not yet supported, will be used for quantization") + } + }, + ElemType::Int(int_kind) => match int_kind { + IntKind::I8 => DType::I8, + IntKind::I16 => DType::I16, + IntKind::I32 => DType::I32, + IntKind::I64 => DType::I64, + }, + ElemType::UInt(uint_kind) => match uint_kind { + UIntKind::U8 => DType::U8, + UIntKind::U16 => DType::U16, + UIntKind::U32 => DType::U32, + UIntKind::U64 => DType::U64, + }, + _ => panic!("Not a valid DType for tensors."), + } +} + +/// Convert a burn [`DType`] into the corresponding cubecl [`ElemType`]. +/// +/// Sub-byte quantization variants whose storage is `PackedNative` may panic +/// (see inline `panic!`s) — those configurations are not representable as a +/// single `ElemType`; use [`dtype_to_storage_type`] instead. +pub fn dtype_to_elem_type(dtype: DType) -> ElemType { + match dtype { + DType::F64 => ElemType::Float(FloatKind::F64), + DType::F32 => ElemType::Float(FloatKind::F32), + DType::Flex32 => ElemType::Float(FloatKind::Flex32), + DType::F16 => ElemType::Float(FloatKind::F16), + DType::BF16 => ElemType::Float(FloatKind::BF16), + DType::I64 => ElemType::Int(IntKind::I64), + DType::I32 => ElemType::Int(IntKind::I32), + DType::I16 => ElemType::Int(IntKind::I16), + DType::I8 => ElemType::Int(IntKind::I8), + DType::U64 => ElemType::UInt(UIntKind::U64), + DType::U32 => ElemType::UInt(UIntKind::U32), + DType::U16 => ElemType::UInt(UIntKind::U16), + DType::U8 => ElemType::UInt(UIntKind::U8), + DType::Bool(store) => match store { + BoolStore::Native => ElemType::Bool, + BoolStore::U8 => ElemType::UInt(UIntKind::U8), + BoolStore::U32 => ElemType::UInt(UIntKind::U32), + }, + DType::QFloat(scheme) => match scheme.store { + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S => ElemType::Int(IntKind::I8), + QuantValue::E4M3 => ElemType::Float(FloatKind::E4M3), + QuantValue::E5M2 => ElemType::Float(FloatKind::E5M2), + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 => { + panic!("Can't store native sub-byte values") + } + }, + QuantStore::PackedU32(_) => ElemType::UInt(UIntKind::U32), + QuantStore::PackedNative(_) => match scheme.value { + QuantValue::E2M1 => panic!("Can't store native sub-byte values"), + other => panic!("{other:?} doesn't support native packing"), + }, + }, + } +} + +/// Convert a burn [`DType`] into the corresponding cubecl [`StorageType`]. +/// +/// Handles sub-byte packed quantization configurations that cannot be expressed +/// as a plain [`ElemType`] by emitting a `StorageType::Packed(...)`. +pub fn dtype_to_storage_type(dtype: DType) -> StorageType { + match dtype { + DType::QFloat(QuantScheme { + store: QuantStore::PackedNative(_), + value: QuantValue::E2M1, + .. + }) => StorageType::Packed(ElemType::Float(FloatKind::E2M1), 2), + _ => dtype_to_elem_type(dtype).into(), + } +} diff --git a/crates/burn-backend/src/lib.rs b/crates/burn-backend/src/lib.rs new file mode 100644 index 0000000..c543d37 --- /dev/null +++ b/crates/burn-backend/src/lib.rs @@ -0,0 +1,176 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! This library provides the core types that define how Burn tensor data is represented, stored, and interpreted. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; + +/// [`Backend`] trait and required types. +pub mod backend; +pub use backend::*; + +// Re-exported types +pub use burn_std::reader::*; // Useful so that backends don't have to add `burn_std` as a dependency. +pub use burn_std::{ + AllocationProperty, BoolDType, BoolStore, Bytes, DType, DataError, DeviceHandle, Distribution, + DistributionSampler, DistributionSamplerKind, Element, ElementConversion, ElementEq, + ElementOrdered, ElementRandom, FloatDType, IntDType, Scalar, SplitPolicy, TensorData, + Tolerance, bf16, distribution, element, f16, stream_id::StreamId, +}; + +/// Shape definition. +pub mod shape { + pub use burn_std::shape::*; +} +pub use shape::*; + +/// Slice utilities. +pub mod slice { + pub use burn_std::{s, slice::*}; +} +pub use slice::*; + +/// Indexing utilities. +pub mod indexing { + pub use burn_std::indexing::*; +} +pub use indexing::*; + +mod alias; +pub use alias::*; + +/// Quantization data representation. +pub mod quantization; + +/// CubeCL inter-operation helpers (gated by the `cubecl` feature). +/// +/// Provides plain conversion functions between burn's [`DType`] and cubecl's +/// `ElemType` / `StorageType`. They are intentionally exposed as named +/// functions rather than `From`/`Into` impls so the cubecl type tree does not +/// leak into `burn-std`'s public surface. +#[cfg(feature = "cubecl")] +pub mod cubecl; + +#[cfg(any( + feature = "cubecl-wgpu", + feature = "cubecl-metal", + feature = "cubecl-vulkan", + feature = "cubecl-webgpu" +))] +mod cube_wgpu { + use crate::backend::DeviceOps; + use burn_std::{BoolStore, DType, DeviceSettings}; + use cubecl::wgpu::WgpuDevice; + + impl DeviceOps for WgpuDevice { + #[cfg(not(any(feature = "cubecl-metal", feature = "cubecl-vulkan")))] + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::U32), + Default::default(), + ) + } + + #[cfg(any(feature = "cubecl-metal", feature = "cubecl-vulkan"))] + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::U8), + Default::default(), + ) + } + } +} + +#[cfg(feature = "cubecl-cuda")] +mod cube_cuda { + use crate::backend::DeviceOps; + use burn_std::{BoolStore, DType, DeviceSettings}; + use cubecl::cuda::CudaDevice; + + impl DeviceOps for CudaDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::U8), + Default::default(), + ) + } + } +} + +#[cfg(feature = "cubecl-cpu")] +mod cube_cpu { + use crate::backend::DeviceOps; + use burn_std::{BoolStore, DType, DeviceSettings}; + use cubecl::cpu::CpuDevice; + + impl DeviceOps for CpuDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::U8), + Default::default(), + ) + } + } +} + +#[cfg(feature = "cubecl-hip")] +mod cube_hip { + use crate::backend::DeviceOps; + use burn_std::{BoolStore, DType, DeviceSettings}; + use cubecl::hip::AmdDevice; + + impl DeviceOps for AmdDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::U8), + Default::default(), + ) + } + } +} + +/// Convenience macro to link to the `burn-tensor` docs for this crate version. +/// +/// Usage: +/// ```rust,ignore +/// # use burn_backend::doc_tensor; +/// doc_tensor!(); // Links to `Tensor` struct +/// doc_tensor!("zeros"); // Links to `Tensor::zeros` method +/// ``` +#[macro_export] +macro_rules! doc_tensor { + () => { + concat!( + "[`Tensor`](https://docs.rs/burn-tensor/", + env!("CARGO_PKG_VERSION"), + "/burn_tensor/struct.Tensor.html)" + ) + }; + + ($method:literal) => { + concat!( + "[`Tensor::", + $method, + "`](", + "https://docs.rs/burn-tensor/", + env!("CARGO_PKG_VERSION"), + "/burn_tensor/struct.Tensor.html#method.", + $method, + ")" + ) + }; +} diff --git a/crates/burn-backend/src/quantization/mod.rs b/crates/burn-backend/src/quantization/mod.rs new file mode 100644 index 0000000..401a88c --- /dev/null +++ b/crates/burn-backend/src/quantization/mod.rs @@ -0,0 +1,10 @@ +mod parameters; +mod scheme; + +pub use parameters::*; +pub use scheme::*; + +pub use burn_std::quantization::{ + BlockSize, Calibration, QuantLevel, QuantMode, QuantParam, QuantPropagation, QuantScheme, + QuantStore, QuantValue, QuantizedBytes, +}; diff --git a/crates/burn-backend/src/quantization/parameters.rs b/crates/burn-backend/src/quantization/parameters.rs new file mode 100644 index 0000000..5b50882 --- /dev/null +++ b/crates/burn-backend/src/quantization/parameters.rs @@ -0,0 +1,15 @@ +use crate::Backend; + +pub use burn_std::quantization::{QParamTensor, QParams}; + +/// The quantization parameters primitive. +/// +/// # Remarks +/// +/// This is a low-level struct used internally by the library to provide the quantization parameters +/// to the backends. It is not designed for direct usage by users, and not recommended to import +/// or use this struct directly. +pub struct QuantizationParametersPrimitive { + /// The scaling factor. + pub scales: B::FloatTensorPrimitive, +} diff --git a/crates/burn-backend/src/quantization/scheme.rs b/crates/burn-backend/src/quantization/scheme.rs new file mode 100644 index 0000000..7e22d20 --- /dev/null +++ b/crates/burn-backend/src/quantization/scheme.rs @@ -0,0 +1,98 @@ +pub use burn_std::{QPARAM_ALIGN, params_shape}; +use burn_std::{QuantLevel, QuantMode, QuantScheme, Shape}; + +use super::{Calibration, QuantizationParametersPrimitive}; +use crate::{Backend, TensorMetadata, get_device_settings}; + +/// Compute the quantization range mapping. +pub fn compute_range( + scheme: &QuantScheme, + tensor: B::FloatTensorPrimitive, + calibration: &Calibration, +) -> (B::FloatTensorPrimitive, B::FloatTensorPrimitive) { + match calibration { + Calibration::MinMax => match scheme.level { + QuantLevel::Tensor => (B::float_min(tensor.clone()), B::float_max(tensor)), + QuantLevel::Block(block_size) => { + let block_elems = block_size.num_elements(); + let shape = tensor.shape(); + let numel = shape.num_elements(); + + assert_eq!( + numel % block_elems, + 0, + "Tensor {shape:?} must be evenly divisible by block size {block_elems}" + ); + + let num_blocks = numel / block_elems; + + let params_shape = params_shape(&shape, scheme.level); + + let blocks = B::float_reshape(tensor, Shape::new([num_blocks, block_elems])); + let blocks_min = + B::float_reshape(B::float_min_dim(blocks.clone(), 1), params_shape.clone()); + let blocks_max = B::float_reshape(B::float_max_dim(blocks, 1), params_shape); + (blocks_min, blocks_max) + } + }, + Calibration::AbsMean => { + // gamma = mean(|W|) per tensor or block — symmetric range [-gamma, +gamma] + let gamma = match scheme.level { + QuantLevel::Tensor => B::float_mean(B::float_abs(tensor)), + QuantLevel::Block(block_size) => { + let block_elems = block_size.num_elements(); + let shape = tensor.shape(); + let numel = shape.num_elements(); + + assert_eq!( + numel % block_elems, + 0, + "Tensor {shape:?} must be evenly divisible by block size {block_elems}" + ); + + let num_blocks = numel / block_elems; + let params_shape = params_shape(&shape, scheme.level); + let blocks = B::float_reshape( + B::float_abs(tensor), + Shape::new([num_blocks, block_elems]), + ); + B::float_reshape(B::float_mean_dim(blocks, 1), params_shape) + } + }; + let neg_gamma = B::float_neg(gamma.clone()); + (neg_gamma, gamma) + } + } +} + +/// Compute the quantization parameters. +pub fn compute_q_params( + scheme: &QuantScheme, + min: B::FloatTensorPrimitive, + max: B::FloatTensorPrimitive, +) -> QuantizationParametersPrimitive { + match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + .. + } => { + let bool_dtype = get_device_settings::(&min.device()).bool_dtype; + // Quantized range `[a, b]` + let (a, b) = scheme.value.range(); + + // Compute scale to convert an input value in range `[-alpha, alpha]` + let min_abs = B::float_abs(min); + let max_abs = B::float_abs(max); + + // `min_abs.max_pair(max_abs)` + let mask = B::float_lower(min_abs.clone(), max_abs.clone(), bool_dtype); + let values_range = + B::float_mul_scalar(B::float_mask_where(min_abs, mask, max_abs), 2f32.into()); + + QuantizationParametersPrimitive { + scales: B::float_div_scalar(values_range, (b - a).into()), + } + } + } +} diff --git a/crates/burn-candle/Cargo.toml b/crates/burn-candle/Cargo.toml new file mode 100644 index 0000000..bedfd3c --- /dev/null +++ b/crates/burn-candle/Cargo.toml @@ -0,0 +1,43 @@ +[package] +authors = ["louisfd "] +categories = ["science"] +description = "[Deprecated] Candle backend for the Burn framework - use burn-cubecl, burn-ndarray, or burn-tch instead" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-candle" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-candle" +documentation = "https://docs.rs/burn-candle" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = [] +doc = ["default"] +tracing = [ + "burn-backend/tracing", + "burn-std/tracing", +] + +cuda = ["candle-core/cuda"] +metal = ["candle-core/metal"] +accelerate = ["candle-core/accelerate"] + +[dependencies] +burn-backend = { workspace = true } +# For rand utils and stub mutex +burn-std = { workspace = true } + +candle-core = { workspace = true } +derive-new = { workspace = true } + +[dev-dependencies] +burn-tch = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-candle/LICENSE-APACHE b/crates/burn-candle/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-candle/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-candle/LICENSE-MIT b/crates/burn-candle/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-candle/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-candle/README.md b/crates/burn-candle/README.md new file mode 100644 index 0000000..d535f6f --- /dev/null +++ b/crates/burn-candle/README.md @@ -0,0 +1,14 @@ +# Burn Candle Backend + +> **Deprecated:** This crate is deprecated as of `0.21.0-pre.2` and will be removed in a future release. +> Please migrate to one of the actively maintained backends: +> - **CubeCL backends** (CUDA, ROCm, Vulkan, Metal, WebGPU) for GPU acceleration +> - **[Flex](../burn-flex)** (`burn-flex`) for portable pure-Rust CPU execution (std, no_std, WASM) +> - **LibTorch** (`burn-tch`) for a mature CPU/GPU backend + +This crate provides a backend for [Burn](https://github.com/tracel-ai/burn) based on the [Candle](https://github.com/huggingface/candle) framework. + +## Feature Flags + +- `cuda` - Cuda GPU device (NVIDIA only) +- `accelerate` - Accelerate framework (macOS only) diff --git a/crates/burn-candle/src/backend.rs b/crates/burn-candle/src/backend.rs new file mode 100644 index 0000000..ce76a60 --- /dev/null +++ b/crates/burn-candle/src/backend.rs @@ -0,0 +1,304 @@ +use burn_backend::{ + BackTrace, Backend, BackendTypes, DType, DTypeUsage, DeviceId, DeviceOps, ExecutionError, + tensor::Device, +}; +use burn_std::{ + BoolStore, DeviceSettings, + rand::{SeedableRng, StdRng}, + stub::Mutex, +}; +use candle_core::{DeviceLocation, backend::BackendDevice}; + +use crate::{ + CandleTensor, IntoDType, + element::{CandleElement, FloatCandleElement, IntCandleElement}, +}; + +/// Tensor backend that uses the [candle](candle_core) crate for executing tensor operations. +/// +/// It is compatible with a wide range of hardware configurations, including CPUs and GPUs +/// that support CUDA or Metal. Additionally, the backend can be compiled to `wasm` when using the CPU. +#[derive(Clone, Default, Debug)] +pub struct Candle {} + +// Seed for CPU device +pub(crate) static SEED: Mutex> = Mutex::new(None); + +pub(crate) fn get_seeded_rng() -> StdRng { + let mut seed = SEED.lock().unwrap(); + seed.take().unwrap_or_else(burn_std::rand::get_seeded_rng) +} + +pub(crate) fn set_seeded_rng(rng_seeded: StdRng) { + let mut seed = SEED.lock().unwrap(); + *seed = Some(rng_seeded); +} + +/// The device type for the candle backend. +#[derive(Clone, Debug, PartialEq, Eq)] +/// The device struct when using the `candle` backend. +/// +/// To create a Cuda or Metal device from the index, use the associated methods to create the variant: +/// ```no_run +/// use burn_candle::CandleDevice; +/// +/// // Create a Cuda device from its index +/// let device = CandleDevice::cuda(0); +/// // Create a Metal device from its index +/// let device = CandleDevice::metal(0); +/// ``` +#[derive(Default)] +pub enum CandleDevice { + /// CPU device. + #[default] + Cpu, + + /// Cuda device with the given index. The index is the index of the Cuda device in the list of + /// all Cuda devices found on the system. + Cuda(CudaDevice), + + /// Metal device with the given index. The index is the index of the Metal device in the list of + /// all Metal devices found on the system. + Metal(MetalDevice), +} + +impl CandleDevice { + /// Create a Cuda device with the given index. + /// The index is the index of the Cuda device in the list of all Cuda devices found on the system. + pub fn cuda(index: usize) -> Self { + CandleDevice::Cuda(CudaDevice { + device: candle_core::CudaDevice::new(index).unwrap(), + index, + }) + } + + /// Create a Metal device with the given index. + /// The index is the index of the Metal device in the list of all Metal devices found on the system. + pub fn metal(index: usize) -> Self { + CandleDevice::Metal(MetalDevice { + device: candle_core::MetalDevice::new(index).unwrap(), + index, + }) + } + + pub(crate) fn set_seed(&self, seed: u64) { + match self { + CandleDevice::Cpu => { + // candle_core::cpu_backend::CpuDevice.set_seed(seed).unwrap(); + // Candle does not support seeding the CPU rng so we use a global seed + let rng = StdRng::seed_from_u64(seed); + set_seeded_rng(rng); + } + CandleDevice::Cuda(cuda_device) => cuda_device.device.set_seed(seed).unwrap(), + CandleDevice::Metal(metal_device) => metal_device.device.set_seed(seed).unwrap(), + } + } +} + +#[derive(Clone, Debug)] +/// A Cuda device for the `candle` backend. +pub struct CudaDevice { + pub(crate) device: candle_core::CudaDevice, + /// The index of the Cuda device in the list of all devices on the system. + pub index: usize, +} + +impl PartialEq for CudaDevice { + fn eq(&self, other: &Self) -> bool { + self.device.same_device(&other.device) && self.index == other.index + } +} + +impl Eq for CudaDevice {} + +#[derive(Clone, Debug)] +/// A Metal device for the `candle` backend. +pub struct MetalDevice { + pub(crate) device: candle_core::MetalDevice, + /// The index of the Metal device in the list of all devices on the system. + pub index: usize, +} + +impl PartialEq for MetalDevice { + fn eq(&self, other: &Self) -> bool { + self.device.same_device(&other.device) && self.index == other.index + } +} + +impl Eq for MetalDevice {} + +impl From for candle_core::Device { + fn from(device: CandleDevice) -> Self { + match device { + CandleDevice::Cpu => candle_core::Device::Cpu, + CandleDevice::Cuda(device) => candle_core::Device::Cuda(device.device), + CandleDevice::Metal(device) => candle_core::Device::Metal(device.device), + } + } +} + +impl From for CandleDevice { + fn from(device: candle_core::Device) -> Self { + match device.location() { + DeviceLocation::Cpu => CandleDevice::Cpu, + DeviceLocation::Cuda { gpu_id } => { + if let candle_core::Device::Cuda(device) = device { + CandleDevice::Cuda(CudaDevice { + device, + index: gpu_id, + }) + } else { + panic!("Expected CUDA device."); + } + } + DeviceLocation::Metal { gpu_id } => { + if let candle_core::Device::Metal(device) = device { + CandleDevice::Metal(MetalDevice { + device, + index: gpu_id, + }) + } else { + panic!("Expected Metal device."); + } + } + } + } +} + +impl burn_backend::Device for CandleDevice { + fn to_id(&self) -> burn_backend::DeviceId { + match self { + CandleDevice::Cuda(device) => DeviceId::new(0, device.index as u16), + CandleDevice::Metal(device) => DeviceId::new(1, device.index as u16), + CandleDevice::Cpu => DeviceId::new(2, 0), + } + } + + fn from_id(device_id: DeviceId) -> Self { + match device_id.type_id { + 0 => CandleDevice::cuda(device_id.index_id as usize), + 1 => CandleDevice::metal(device_id.index_id as usize), + _ => CandleDevice::Cpu, + } + } +} +impl DeviceOps for CandleDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I64, + DType::Bool(BoolStore::U8), + Default::default(), + ) + } +} + +impl BackendTypes for Candle { + type Device = CandleDevice; + + type FloatTensorPrimitive = CandleTensor; + + type IntTensorPrimitive = CandleTensor; + + type BoolTensorPrimitive = CandleTensor; + + type QuantizedTensorPrimitive = CandleTensor; + + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for Candle { + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + fn name(device: &Self::Device) -> String { + match device { + CandleDevice::Cpu => "candle", + CandleDevice::Cuda(..) => "candle", + CandleDevice::Metal(..) => "candle", + } + .to_string() + } + + fn seed(device: &CandleDevice, seed: u64) { + device.set_seed(seed); + } + + fn sync(device: &Device) -> Result<(), ExecutionError> { + let device: candle_core::Device = (device.clone()).into(); + + match device { + candle_core::Device::Cpu => (), + candle_core::Device::Cuda(device) => { + #[cfg(feature = "cuda")] + device + .synchronize() + .map_err(|err| ExecutionError::Generic { + reason: format!("Can't sync the cuda device: {err}"), + backtrace: BackTrace::capture(), + })?; + } + candle_core::Device::Metal(device) => { + // For some reason, device.wait_until_completed() does not seem to work, + // and neither does writing and reading a value with into_data + return Err(ExecutionError::Generic { + reason: + "Device synchronization unavailable with Metal device on Candle backend" + .into(), + backtrace: BackTrace::capture(), + }); + } + } + + Ok(()) + } + + fn dtype_usage(device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet { + if dtype.try_into_dtype().is_ok() { + burn_backend::DTypeUsage::general() + } else { + burn_backend::DTypeUsageSet::empty() + } + } + + fn device_count(_: u16) -> usize { + 1 + } + + fn flush(_device: &Self::Device) {} +} + +#[cfg(test)] +mod tests { + use burn_std::{BoolStore, QuantScheme}; + + use super::*; + + #[test] + fn should_support_dtypes() { + type B = Candle; + let device = Default::default(); + + assert!(B::supports_dtype(&device, DType::F64)); + assert!(B::supports_dtype(&device, DType::F32)); + assert!(B::supports_dtype(&device, DType::Flex32)); + assert!(B::supports_dtype(&device, DType::F16)); + assert!(B::supports_dtype(&device, DType::BF16)); + assert!(B::supports_dtype(&device, DType::I64)); + assert!(B::supports_dtype(&device, DType::U32)); + assert!(B::supports_dtype(&device, DType::U8)); + assert!(B::supports_dtype(&device, DType::I32)); + assert!(B::supports_dtype(&device, DType::I16)); + assert!(B::supports_dtype(&device, DType::Bool(BoolStore::U8))); + + assert!(!B::supports_dtype(&device, DType::U64)); + assert!(!B::supports_dtype(&device, DType::U16)); + assert!(!B::supports_dtype(&device, DType::I8)); + assert!(!B::supports_dtype(&device, DType::Bool(BoolStore::Native))); + assert!(!B::supports_dtype( + &device, + DType::QFloat(QuantScheme::default()) + )); + } +} diff --git a/crates/burn-candle/src/element.rs b/crates/burn-candle/src/element.rs new file mode 100644 index 0000000..bc78c13 --- /dev/null +++ b/crates/burn-candle/src/element.rs @@ -0,0 +1,32 @@ +use std::borrow::Borrow; + +use burn_backend::{Element, bf16, f16}; +use candle_core::{FloatDType, Tensor, WithDType}; + +/// Candle element +pub trait CandleElement: Element + WithDType {} +/// Candle float element +pub trait FloatCandleElement: CandleElement + FloatDType {} +/// Candle int element +pub trait IntCandleElement: CandleElement {} + +impl CandleElement for f64 {} +impl FloatCandleElement for f64 {} + +impl CandleElement for f32 {} +impl FloatCandleElement for f32 {} + +impl CandleElement for f16 {} +impl FloatCandleElement for f16 {} + +impl CandleElement for bf16 {} +impl FloatCandleElement for bf16 {} + +impl CandleElement for u8 {} +impl IntCandleElement for u8 {} + +impl CandleElement for u32 {} +impl IntCandleElement for u32 {} + +impl CandleElement for i64 {} +impl IntCandleElement for i64 {} diff --git a/crates/burn-candle/src/lib.rs b/crates/burn-candle/src/lib.rs new file mode 100644 index 0000000..3138378 --- /dev/null +++ b/crates/burn-candle/src/lib.rs @@ -0,0 +1,27 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![allow(unused)] // TODO remove when backend filled +#![deprecated( + since = "0.21.0", + note = "burn-candle is deprecated and will be removed in a future release. Use burn-cubecl (CUDA/ROCm/Vulkan/Metal/WebGPU), burn-flex, or burn-tch instead." +)] + +//! Burn Candle Backend +//! +//! **Deprecated:** This backend is deprecated and will be removed in a future release. +//! Please migrate to one of the actively maintained backends: +//! - CubeCL backends (CUDA, ROCm, Vulkan, Metal, WebGPU) for GPU acceleration +//! - Flex (`burn-flex`) for portable pure-Rust CPU execution (std, no_std, WASM) +//! - LibTorch (`burn-tch`) for a mature CPU/GPU backend + +#[macro_use] +extern crate derive_new; + +mod backend; +mod element; +mod ops; +mod tensor; + +pub use backend::*; +pub use element::*; +pub use tensor::*; diff --git a/crates/burn-candle/src/ops/activation.rs b/crates/burn-candle/src/ops/activation.rs new file mode 100644 index 0000000..b7ffe33 --- /dev/null +++ b/crates/burn-candle/src/ops/activation.rs @@ -0,0 +1,17 @@ +use burn_backend::{ops::ActivationOps, tensor::FloatTensor}; + +use crate::{ + Candle, CandleTensor, + element::{CandleElement, FloatCandleElement, IntCandleElement}, + tensor, +}; + +impl ActivationOps for Candle { + fn gelu(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.gelu().unwrap()) + } + + fn relu(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.relu().unwrap()) + } +} diff --git a/crates/burn-candle/src/ops/base.rs b/crates/burn-candle/src/ops/base.rs new file mode 100644 index 0000000..0f66548 --- /dev/null +++ b/crates/burn-candle/src/ops/base.rs @@ -0,0 +1,691 @@ +use std::cmp::max; +use std::marker::PhantomData; + +use crate::{ + Candle, CandleDevice, CandleTensor, + element::{CandleElement, FloatCandleElement, IntCandleElement}, +}; +use burn_backend::{ + BackTrace, Backend, Distribution, ExecutionError, Slice, bf16, f16, + ops::unfold::{calculate_unfold_shape, calculate_unfold_windows}, +}; +use burn_backend::{DType, Element, Shape, TensorData, TensorMetadata}; +use candle_core::{Layout, WithDType}; + +use super::tensor; + +pub fn cpu_random(shape: Shape, distribution: Distribution, dtype: DType) -> TensorData { + let mut rng = crate::get_seeded_rng(); + let data = match dtype { + DType::F64 => TensorData::random::(shape, distribution, &mut rng), + DType::F32 => TensorData::random::(shape, distribution, &mut rng), + DType::Flex32 => TensorData::random::(shape, distribution, &mut rng), + DType::F16 => TensorData::random::(shape, distribution, &mut rng), + DType::BF16 => { + TensorData::random::(shape, distribution, &mut rng) + } + DType::I64 => TensorData::random::(shape, distribution, &mut rng), + DType::U32 => TensorData::random::(shape, distribution, &mut rng), + DType::U8 => TensorData::random::(shape, distribution, &mut rng), + other => unimplemented!("DType not supported {dtype:?}"), + }; + crate::set_seeded_rng(rng); + data +} + +pub fn cat(tensors: Vec, dim: usize) -> CandleTensor { + let tensors: Vec = tensors.into_iter().map(|t| t.tensor).collect(); + CandleTensor::new(candle_core::Tensor::cat(&tensors, dim).unwrap()) +} + +pub fn from_data(data: TensorData, device: &CandleDevice) -> CandleTensor { + CandleTensor::from_data::(data, device.clone()) +} +pub fn into_data(tensor: CandleTensor) -> Result { + fn tensor_data_from_dtype( + tensor: &CandleTensor, + ) -> Result { + let data = tensor + .tensor + .flatten_all() + .map_err(|err| ExecutionError::Generic { + reason: format!("{err}"), + backtrace: BackTrace::capture(), + })? + .to_vec1::() + .map_err(|err| ExecutionError::Generic { + reason: format!("{err}"), + backtrace: BackTrace::capture(), + })?; + Ok(TensorData::new(data, tensor.shape())) + } + + match tensor.tensor.dtype() { + candle_core::DType::BF16 => tensor_data_from_dtype::(&tensor), + candle_core::DType::F16 => tensor_data_from_dtype::(&tensor), + candle_core::DType::F32 => tensor_data_from_dtype::(&tensor), + candle_core::DType::F64 => tensor_data_from_dtype::(&tensor), + candle_core::DType::U8 => tensor_data_from_dtype::(&tensor), + candle_core::DType::U32 => tensor_data_from_dtype::(&tensor), + candle_core::DType::I16 => tensor_data_from_dtype::(&tensor), + candle_core::DType::I32 => tensor_data_from_dtype::(&tensor), + candle_core::DType::I64 => tensor_data_from_dtype::(&tensor), + other => todo!("{other:?} not yet supported"), + } +} + +pub fn to_device(tensor: CandleTensor, device: &CandleDevice) -> CandleTensor { + CandleTensor::new(tensor.tensor.to_device(&(device.clone()).into()).unwrap()) +} + +pub fn empty(shape: Shape, device: &CandleDevice, dtype: candle_core::DType) -> CandleTensor { + zeros(shape, device, dtype) +} + +/// Flatten K-dimensional index tuples into 1D linear offsets. +/// +/// `indices` has shape `[num_updates, k]` (i64). +/// Returns a 1D tensor of shape `[num_updates * slice_size]` (u32, for candle index ops). +fn flatten_nd_indices( + indices: &candle_core::Tensor, + data_shape: &[usize], + k: usize, + slice_size: usize, +) -> candle_core::Result { + let device = indices.device(); + let num_updates = indices.dim(0)?; + + // Compute per-dimension strides for the first K dims + let mut strides = vec![0i64; k]; + if k > 0 { + strides[k - 1] = slice_size as i64; + for i in (0..k - 1).rev() { + strides[i] = strides[i + 1] * data_shape[i + 1] as i64; + } + } + + let strides_tensor = candle_core::Tensor::from_slice(&strides, (k,), device)?; + // base_offsets: [num_updates] = indices @ strides + let base_offsets = indices + .to_dtype(candle_core::DType::I64)? + .matmul(&strides_tensor.unsqueeze(1)?)? + .squeeze(1)?; + + // Expand to [num_updates, slice_size] + intra-slice offsets + let slice_offsets = candle_core::Tensor::arange(0i64, slice_size as i64, device)?; + base_offsets + .unsqueeze(1)? + .broadcast_add(&slice_offsets.unsqueeze(0)?)? + .reshape((num_updates * slice_size,))? + .to_dtype(candle_core::DType::U32) +} + +pub fn scatter_nd( + data: CandleTensor, + indices: CandleTensor, + values: CandleTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, +) -> CandleTensor { + try_scatter_nd(data, indices, values, reduction) + .unwrap_or_else(|e| panic!("candle scatter_nd: {e}")) +} + +fn try_scatter_nd( + data: CandleTensor, + indices: CandleTensor, + values: CandleTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, +) -> candle_core::Result { + use burn_backend::tensor::IndexingUpdateOp; + + let data_shape: Vec = data.tensor.dims().to_vec(); + let idx_shape: Vec = indices.tensor.dims().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + let num_updates: usize = idx_shape[..m - 1].iter().product(); + let total_elems: usize = data_shape.iter().product(); + let slice_size: usize = data_shape[k..].iter().product(); + + let flat_indices = indices.tensor.reshape((num_updates, k))?; + let linear_idx = flatten_nd_indices(&flat_indices, &data_shape, k, slice_size)?; + + let flat_data = data.tensor.contiguous()?.reshape(total_elems)?; + let flat_values = values + .tensor + .contiguous()? + .reshape(num_updates * slice_size)?; + + let result = match reduction { + IndexingUpdateOp::Assign => flat_data.scatter(&linear_idx, &flat_values, 0)?, + IndexingUpdateOp::Add => flat_data.scatter_add(&linear_idx, &flat_values, 0)?, + _ => panic!( + "scatter_nd with {:?} reduction is not supported by the candle backend", + reduction + ), + }; + + Ok(CandleTensor::new(result.reshape(data_shape)?)) +} + +pub fn gather_nd(data: CandleTensor, indices: CandleTensor) -> CandleTensor { + try_gather_nd(data, indices).unwrap_or_else(|e| panic!("candle gather_nd: {e}")) +} + +fn try_gather_nd(data: CandleTensor, indices: CandleTensor) -> candle_core::Result { + let data_shape: Vec = data.tensor.dims().to_vec(); + let idx_shape: Vec = indices.tensor.dims().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + let num_indices: usize = idx_shape[..m - 1].iter().product(); + let total_elems: usize = data_shape.iter().product(); + let slice_size: usize = data_shape[k..].iter().product(); + + let flat_indices = indices.tensor.reshape((num_indices, k))?; + let linear_idx = flatten_nd_indices(&flat_indices, &data_shape, k, slice_size)?; + + let flat_data = data.tensor.contiguous()?.reshape(total_elems)?; + let gathered = flat_data.index_select(&linear_idx, 0)?; + + // Output shape: idx_shape[..m-1] ++ data_shape[k..] + let mut out_shape: Vec = idx_shape[..m - 1].to_vec(); + out_shape.extend_from_slice(&data_shape[k..]); + + Ok(CandleTensor::new(gathered.reshape(out_shape)?)) +} + +pub fn zeros(shape: Shape, device: &CandleDevice, dtype: candle_core::DType) -> CandleTensor { + CandleTensor::new( + candle_core::Tensor::zeros(shape.to_vec(), dtype, &(device.clone()).into()).unwrap(), + ) +} + +pub fn ones(shape: Shape, device: &CandleDevice, dtype: candle_core::DType) -> CandleTensor { + CandleTensor::new( + candle_core::Tensor::ones(shape.to_vec(), dtype, &(device.clone()).into()).unwrap(), + ) +} + +pub fn swap_dims(mut tensor: CandleTensor, dim1: usize, dim2: usize) -> CandleTensor { + CandleTensor::new(tensor.tensor.transpose(dim1, dim2).unwrap()) +} + +pub fn permute(tensor: CandleTensor, axes: &[usize]) -> CandleTensor { + CandleTensor::new(tensor.tensor.permute(axes).unwrap()) +} + +pub fn flip(tensor: CandleTensor, axes: &[usize]) -> CandleTensor { + // FIXME: Replace with an appropriate method when Candle provides one. + let mut tensor = tensor.tensor; + for &axis in axes { + // Ensure tensor is contiguous before index_select (required by Candle) + tensor = tensor.contiguous().unwrap(); + + let indexes = candle_core::Tensor::arange_step( + tensor.dim(axis).unwrap() as i64 - 1, + -1, + -1, + tensor.device(), + ) + .unwrap(); + tensor = tensor.index_select(&indexes, axis).unwrap(); + } + + CandleTensor::new(tensor) +} + +pub fn reshape(tensor: CandleTensor, shape: Shape) -> CandleTensor { + CandleTensor::new(tensor.tensor.reshape(shape.to_vec()).unwrap()) +} + +pub fn shape(tensor: &CandleTensor) -> Shape { + tensor.shape() +} + +pub fn slice(tensor: CandleTensor, ranges: &[std::ops::Range]) -> CandleTensor { + let mut narrow_tensor = tensor.tensor; + for (i, range) in ranges.iter().enumerate().take(ranges.len()) { + narrow_tensor = narrow_tensor + .narrow(i, range.start, range.end - range.start) + .unwrap() + } + CandleTensor::new(narrow_tensor) +} + +pub fn slice_with_steps(tensor: CandleTensor, slices: &[Slice]) -> CandleTensor { + let mut result_tensor = tensor.tensor; + + for (dim, slice) in slices.iter().enumerate() { + if slice.step == 1 { + // Use narrow for step=1 (more efficient) + // Convert slice to range using tensor shape + let dim_size = result_tensor.dim(dim).unwrap(); + let range = slice.to_range(dim_size); + let start = range.start; + let length = range.end - range.start; + result_tensor = result_tensor.narrow(dim, start, length).unwrap(); + } else { + // Use index_select for step != 1 + let dim_size = result_tensor.dim(dim).unwrap(); + let range = slice.to_range(dim_size); + let start = range.start; + let end = range.end; + let step = slice.step; + + // Generate indices based on step direction + let indices_vec = if step > 0 { + // Forward stepping + let step_usize = step as usize; + (start..end).step_by(step_usize).collect::>() + } else { + // Backward stepping (negative step) + let step_usize = step.unsigned_abs(); + // Start from end-1 and go backwards + let mut indices = Vec::new(); + let mut idx = end - 1; + while idx >= start && idx < end { + // Check for underflow + indices.push(idx); + if idx >= step_usize { + idx -= step_usize; + } else { + break; + } + } + indices + }; + + // Convert indices to tensor and use index_select + let indices_len = indices_vec.len(); + let device = result_tensor.device(); + let indices = candle_core::Tensor::from_vec( + indices_vec.iter().map(|&x| x as u32).collect::>(), + indices_len, + device, + ) + .unwrap(); + + result_tensor = result_tensor.index_select(&indices, dim).unwrap(); + } + } + + CandleTensor::new(result_tensor) +} + +pub fn slice_assign(tensor: CandleTensor, slices: &[Slice], value: CandleTensor) -> CandleTensor { + // Check if all slices have step=1 (candle's native slice_assign requirement) + let all_unit_steps = slices.iter().all(|s| s.step == 1); + + if all_unit_steps { + // Convert Slice to Range for candle's native slice_assign + let ranges: Vec> = slices + .iter() + .enumerate() + .map(|(dim, slice)| { + let dim_size = tensor.tensor.dim(dim).unwrap_or(usize::MAX); + slice.to_range(dim_size) + }) + .collect(); + + CandleTensor::new(tensor.tensor.slice_assign(&ranges, &value.tensor).unwrap()) + } else { + // Implement slice_assign with steps using scatter operations + slice_assign_with_steps_workaround(tensor, slices, value) + } +} + +/// Implements slice_assign for non-unit steps using index operations +fn slice_assign_with_steps_workaround( + tensor: CandleTensor, + slices: &[Slice], + value: CandleTensor, +) -> CandleTensor { + let shape = tensor.shape(); + let ndims = shape.num_dims(); + let device = tensor.tensor.device(); + + // Generate indices for each dimension based on slice specifications + let indices_per_dim = generate_slice_indices(slices, &shape); + + // Early return if no elements to assign + let total_elements: usize = indices_per_dim.iter().map(|v| v.len()).product(); + if total_elements == 0 { + return tensor; + } + + // Flatten tensors and get metadata + let value_flat = value.tensor.flatten_all().unwrap(); + let strides = tensor.tensor.stride(); + let tensor_shape = tensor.tensor.dims(); + + // Use a macro to handle different dtypes without code duplication + macro_rules! apply_slice_assign { + ($dtype:ty, $to_vec_fn:ident) => {{ + let mut tensor_vec: Vec<$dtype> = + tensor.tensor.flatten_all().unwrap().$to_vec_fn().unwrap(); + let value_vec: Vec<$dtype> = value_flat.$to_vec_fn().unwrap(); + + // Apply assignments using cartesian product of indices + for (value_idx, &value) in value_vec.iter().enumerate() { + let flat_idx = compute_flat_index(value_idx, &indices_per_dim, &strides); + if flat_idx < tensor_vec.len() { + tensor_vec[flat_idx] = value; + } + } + + candle_core::Tensor::from_vec(tensor_vec, tensor_shape, device).unwrap() + }}; + } + + use candle_core::DType; + let result = match tensor.tensor.dtype() { + DType::F32 => apply_slice_assign!(f32, to_vec1), + DType::F64 => apply_slice_assign!(f64, to_vec1), + DType::I64 => apply_slice_assign!(i64, to_vec1), + DType::U32 => apply_slice_assign!(u32, to_vec1), + DType::U8 => apply_slice_assign!(u8, to_vec1), + _ => panic!( + "Unsupported dtype {:?} for slice_assign with steps", + tensor.tensor.dtype() + ), + }; + + CandleTensor::new(result) +} + +/// Generate indices for each dimension based on slice specifications +fn generate_slice_indices(slices: &[Slice], tensor_dims: &[usize]) -> Vec> { + let ndims = tensor_dims.len(); + let mut indices_per_dim = Vec::with_capacity(ndims); + + // Process provided slices + for (dim_idx, slice) in slices.iter().enumerate() { + let dim_size = tensor_dims[dim_idx]; + let range = slice.to_range(dim_size); + let indices = generate_stepped_indices(range.start, range.end, slice.step); + indices_per_dim.push(indices); + } + + // Fill remaining dimensions with full ranges + for &dim_size in tensor_dims.iter().skip(slices.len()) { + indices_per_dim.push((0..dim_size).collect()); + } + + indices_per_dim +} + +/// Generate indices for a single dimension with stepping +fn generate_stepped_indices(start: usize, end: usize, step: isize) -> Vec { + if step > 0 { + // Forward stepping + (start..end).step_by(step as usize).collect() + } else if step < 0 { + // Backward stepping: start from end-1 and go backwards + let step_size = step.unsigned_abs(); + let mut indices = Vec::new(); + let mut idx = end.saturating_sub(1); + + while idx >= start && idx < end { + indices.push(idx); + if idx >= step_size { + idx -= step_size; + } else { + break; + } + } + indices + } else { + // This branch should never be reached since step is validated to be non-zero + panic!("Step cannot be zero") + } +} + +/// Compute flat index from multi-dimensional indices using cartesian product logic +fn compute_flat_index( + value_idx: usize, + indices_per_dim: &[Vec], + strides: &[usize], +) -> usize { + let mut flat_idx = 0; + let mut remainder = value_idx; + + // Convert value_idx to multi-dimensional indices and compute flat tensor index + for dim in (0..indices_per_dim.len()).rev() { + let dim_size = indices_per_dim[dim].len(); + let idx_in_dim = remainder % dim_size; + remainder /= dim_size; + + let actual_idx = indices_per_dim[dim][idx_in_dim]; + flat_idx += actual_idx * strides[dim]; + } + + flat_idx +} + +pub fn narrow(tensor: CandleTensor, dim: usize, start: usize, length: usize) -> CandleTensor { + let tensor = tensor.tensor.narrow(dim, start, length); + match tensor { + Ok(tensor) => CandleTensor::new(tensor), + Err(e) => panic!("error narrow from Candle"), + } +} + +pub fn chunk(tensor: CandleTensor, chunks: usize, dim: usize) -> Vec { + let tensors = tensor.tensor.chunk(chunks, dim); + match tensors { + Ok(tensors) => tensors.into_iter().map(CandleTensor::new).collect(), + Err(e) => panic!("error chunk from Candle"), + } +} + +pub fn expand(tensor: CandleTensor, shape: Shape) -> CandleTensor { + CandleTensor::new(tensor.tensor.broadcast_as(shape.to_vec()).unwrap()) +} + +pub fn unfold(tensor: CandleTensor, dim: usize, size: usize, step: usize) -> CandleTensor { + let result_shape = calculate_unfold_shape(tensor.shape(), dim, size, step); + let windows = result_shape[dim]; + + let mut select_ranges = tensor.shape().into_ranges(); + let new_axis = select_ranges.len(); + + let mut stack = Vec::with_capacity(windows); + for widx in 0..windows { + let start = widx * step; + let end = start + size; + select_ranges[dim] = start..end; + + let mut window_slice = slice(tensor.clone(), &select_ranges); + + window_slice = swap_dims(window_slice, dim, new_axis); + let window_slice = CandleTensor::new(window_slice.tensor.unsqueeze(new_axis).unwrap()); + + stack.push(window_slice); + } + cat(stack, dim) +} + +pub fn sign(tensor: CandleTensor) -> CandleTensor { + CandleTensor::new(tensor.tensor.sign().unwrap()) +} + +pub fn mask_where_broadcasted( + tensor: CandleTensor, + mask: CandleTensor, + value: CandleTensor, +) -> CandleTensor { + let shape = tensor + .tensor + .shape() + .broadcast_shape_binary_op(mask.tensor.shape(), "where_cond") + .unwrap(); + + let mut tensor = tensor.tensor; + let mut mask = mask.tensor; + let mut value = value.tensor; + + if shape != *tensor.shape() { + tensor = tensor.broadcast_as(shape.clone()).unwrap(); + } + if shape != *mask.shape() { + mask = mask.broadcast_as(shape.clone()).unwrap(); + } + if shape != *value.shape() { + value = value.broadcast_as(shape).unwrap(); + } + + CandleTensor::new(mask.where_cond(&value, &tensor).unwrap()) +} + +pub fn cross(lhs: CandleTensor, rhs: CandleTensor, dim: usize) -> CandleTensor { + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + let ndims = shape_lhs.num_dims(); + + // Broadcast the shapes except along dim + let mut broadcast_shape = vec![0; ndims]; + for (i, item) in broadcast_shape.iter_mut().enumerate().take(ndims) { + if i == dim { + *item = shape_lhs[i]; + } else { + let l = shape_lhs[i]; + let r = shape_rhs[i]; + if l == r { + *item = l; + } else if l == 1 { + *item = r; + } else if r == 1 { + *item = l; + } else { + panic!("Tensors are not broadcastable along dimension {}", i); + } + } + } + + // Broadcast lhs and rhs + let lhs_broadcast = if shape_lhs == Shape::from(broadcast_shape.clone()) { + lhs + } else { + expand(lhs, Shape::from(broadcast_shape.clone())) + }; + let rhs_broadcast = if shape_rhs == Shape::from(broadcast_shape.clone()) { + rhs + } else { + expand(rhs, Shape::from(broadcast_shape.clone())) + }; + + // Now, move dim to the last dimension + let mut perm = (0..ndims).collect::>(); + perm.remove(dim); + perm.push(dim); + + let lhs_permuted = permute(lhs_broadcast, &perm); + let rhs_permuted = permute(rhs_broadcast, &perm); + + // Reshape to (*, 3) + let total_elements = lhs_permuted.shape().num_elements(); + let batch_size = total_elements / 3; + let lhs_reshaped = reshape(lhs_permuted, Shape::new([batch_size, 3])); + let rhs_reshaped = reshape(rhs_permuted, Shape::new([batch_size, 3])); + + // Extract components using narrow and squeeze + let lhs_0 = CandleTensor::new( + lhs_reshaped + .tensor + .narrow(1, 0, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + let lhs_1 = CandleTensor::new( + lhs_reshaped + .tensor + .narrow(1, 1, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + let lhs_2 = CandleTensor::new( + lhs_reshaped + .tensor + .narrow(1, 2, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + let rhs_0 = CandleTensor::new( + rhs_reshaped + .tensor + .narrow(1, 0, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + let rhs_1 = CandleTensor::new( + rhs_reshaped + .tensor + .narrow(1, 1, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + let rhs_2 = CandleTensor::new( + rhs_reshaped + .tensor + .narrow(1, 2, 1) + .unwrap() + .squeeze(1) + .unwrap(), + ); + + // Compute cross product components + let result_0 = CandleTensor::new( + lhs_1 + .tensor + .mul(&rhs_2.tensor) + .unwrap() + .sub(&lhs_2.tensor.mul(&rhs_1.tensor).unwrap()) + .unwrap(), + ); + let result_1 = CandleTensor::new( + lhs_2 + .tensor + .mul(&rhs_0.tensor) + .unwrap() + .sub(&lhs_0.tensor.mul(&rhs_2.tensor).unwrap()) + .unwrap(), + ); + let result_2 = CandleTensor::new( + lhs_0 + .tensor + .mul(&rhs_1.tensor) + .unwrap() + .sub(&lhs_1.tensor.mul(&rhs_0.tensor).unwrap()) + .unwrap(), + ); + + // Stack the components + let result_0_unsqueezed = CandleTensor::new(result_0.tensor.unsqueeze(1).unwrap()); + let result_1_unsqueezed = CandleTensor::new(result_1.tensor.unsqueeze(1).unwrap()); + let result_2_unsqueezed = CandleTensor::new(result_2.tensor.unsqueeze(1).unwrap()); + let result = cat( + vec![ + result_0_unsqueezed, + result_1_unsqueezed, + result_2_unsqueezed, + ], + 1, + ); + + // Reshape back to the broadcast shape with dim at the end + let mut result_shape = broadcast_shape; + result_shape.remove(dim); + result_shape.push(3); + let result_reshaped = reshape(result, Shape::from(result_shape)); + + // Permute back + let mut inv_perm = vec![0; ndims]; + for (i, &p) in perm.iter().enumerate() { + inv_perm[p] = i; + } + permute(result_reshaped, &inv_perm) +} diff --git a/crates/burn-candle/src/ops/bool_tensor.rs b/crates/burn-candle/src/ops/bool_tensor.rs new file mode 100644 index 0000000..27c2a3e --- /dev/null +++ b/crates/burn-candle/src/ops/bool_tensor.rs @@ -0,0 +1,209 @@ +use burn_backend::{ + BackTrace, DType, ExecutionError, Scalar, Shape, Slice, TensorData, TensorMetadata, + ops::BoolTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{BoolDType, FloatDType, IntDType}; + +use crate::{ + Candle, CandleTensor, IntoDType, + element::{CandleElement, FloatCandleElement, IntCandleElement}, +}; + +use super::base::{expand, permute, unfold}; + +impl BoolTensorOps for Candle { + fn bool_empty(shape: Shape, device: &Device, _dtype: BoolDType) -> BoolTensor { + super::base::empty(shape, device, candle_core::DType::U8) + } + + fn bool_zeros(shape: Shape, device: &Device, _dtype: BoolDType) -> BoolTensor { + super::base::zeros(shape, device, candle_core::DType::U8) + } + + fn bool_ones(shape: Shape, device: &Device, _dtype: BoolDType) -> BoolTensor { + super::base::ones(shape, device, candle_core::DType::U8) + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + let x: Vec = tensor + .tensor + .flatten_all() + .map_err(|err| ExecutionError::Generic { + reason: format!("{err}"), + backtrace: BackTrace::capture(), + })? + .to_vec1() + .map_err(|err| ExecutionError::Generic { + reason: format!("{err}"), + backtrace: BackTrace::capture(), + })?; + + let y = x.iter().map(|b| !matches!(b, 0)).collect(); + + Ok(TensorData::new(y, tensor.shape())) + } + + fn bool_from_data(data: TensorData, device: &Device) -> BoolTensor { + match data.dtype { + DType::U8 => super::base::from_data::(data, device), + _ => unimplemented!("Unsupported dtype for `bool_from_data`"), + } + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + CandleTensor::new(tensor.tensor.to_dtype(out_dtype.into_dtype()).unwrap()) + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + CandleTensor::new(tensor.tensor.to_dtype(out_dtype.into_dtype()).unwrap()) + } + + fn bool_to_device(tensor: BoolTensor, device: &Device) -> BoolTensor { + super::base::to_device(tensor, device) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + super::base::reshape(tensor, shape) + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + super::base::slice_with_steps(tensor, slices) + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[Slice], + value: BoolTensor, + ) -> BoolTensor { + super::base::slice_assign(tensor, slices, value) + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + super::base::cat(tensors, dim) + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.eq(&rhs_broadcast).unwrap()) + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + let x = (candle_core::Tensor::zeros_like(&tensor.tensor).unwrap()); + CandleTensor::new(tensor.tensor.eq(&x).unwrap()) + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let x = candle_core::Tensor::ones_like(&lhs.tensor).unwrap(); + CandleTensor::new(lhs.tensor.add(&rhs.tensor).unwrap().gt(&x).unwrap()) + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .add(&rhs.tensor) + .unwrap() + .clamp(0u32, 1u32) + .unwrap(), + ) + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + super::base::swap_dims(tensor, dim1, dim2) + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + super::base::permute(tensor, axes) + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + super::base::flip(tensor, axes) + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + CandleTensor::new(tensor.tensor.index_select(&indices.tensor, dim).unwrap()) + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + CandleTensor::new( + tensor + .tensor + .index_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + expand(tensor, shape) + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + unfold(tensor, dim, size, step) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + super::base::mask_where_broadcasted(tensor, mask, value) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + CandleTensor::new( + mask.tensor + .where_cond( + &super::candle_utils::fill_like::(value.elem(), &tensor.tensor), + &tensor.tensor, + ) + .unwrap(), + ) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + let tensor = tensor.tensor.contiguous().unwrap(); + let indices = indices.tensor.contiguous().unwrap(); + CandleTensor::new(tensor.gather(&indices, dim).unwrap()) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + CandleTensor::new( + tensor + .tensor + .scatter_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + CandleTensor::new(lhs.tensor.eq(rhs.elem::()).unwrap()) + } +} diff --git a/crates/burn-candle/src/ops/candle_utils.rs b/crates/burn-candle/src/ops/candle_utils.rs new file mode 100644 index 0000000..957da6e --- /dev/null +++ b/crates/burn-candle/src/ops/candle_utils.rs @@ -0,0 +1,46 @@ +use candle_core::{DType, Device, Shape, Tensor}; + +use crate::element::CandleElement; + +pub(crate) fn fill>( + value: E, + shape: S, + dtype: DType, + device: &Device, +) -> Tensor { + let values = (Tensor::ones((1), dtype, device).unwrap() * value.elem::()).unwrap(); + values.expand(shape).unwrap() +} + +pub(crate) fn fill_like(value: E, reference_tensor: &Tensor) -> Tensor { + fill( + value, + reference_tensor.shape(), + reference_tensor.dtype(), + reference_tensor.device(), + ) +} + +/// Broadcasts two tensors to a common shape for comparison operations +pub(crate) fn broadcast_for_comparison( + lhs: &Tensor, + rhs: &Tensor, +) -> Result<(Tensor, Tensor), candle_core::Error> { + let broadcast_shape = lhs + .shape() + .broadcast_shape_binary_op(rhs.shape(), "comparison")?; + + let lhs = if broadcast_shape != *lhs.shape() { + lhs.broadcast_as(&broadcast_shape)? + } else { + lhs.clone() + }; + + let rhs = if broadcast_shape != *rhs.shape() { + rhs.broadcast_as(&broadcast_shape)? + } else { + rhs.clone() + }; + + Ok((lhs, rhs)) +} diff --git a/crates/burn-candle/src/ops/int_tensor.rs b/crates/burn-candle/src/ops/int_tensor.rs new file mode 100644 index 0000000..fdb0502 --- /dev/null +++ b/crates/burn-candle/src/ops/int_tensor.rs @@ -0,0 +1,570 @@ +use burn_backend::{ + DType, Distribution, ElementConversion, ExecutionError, IntDType, Scalar, Shape, Slice, + TensorData, TensorMetadata, + ops::{FloatTensorOps, IntTensorOps}, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{BoolDType, FloatDType}; + +use crate::{ + Candle, CandleDevice, CandleTensor, IntoDType, + element::{CandleElement, FloatCandleElement, IntCandleElement}, +}; + +use super::base::{cpu_random, expand, permute, sign, unfold}; + +impl IntTensorOps for Candle { + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + super::base::empty(shape, device, dtype.into_dtype()) + } + + async fn int_into_data(tensor: IntTensor) -> Result { + super::base::into_data(tensor) + } + + fn int_from_data(data: TensorData, device: &Device) -> IntTensor { + match data.dtype { + DType::I64 => super::base::from_data::(data, device), + DType::U32 => super::base::from_data::(data, device), + DType::U8 => super::base::from_data::(data, device), + _ => unimplemented!("Unsupported dtype for `int_from_data`"), + } + } + + fn int_to_device(tensor: IntTensor, device: &Device) -> IntTensor { + super::base::to_device(tensor, device) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + super::base::reshape(tensor, shape) + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + super::base::slice_with_steps(tensor, slices) + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[Slice], + value: IntTensor, + ) -> IntTensor { + super::base::slice_assign(tensor, slices, value) + } + + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + CandleTensor::new(tensor.tensor.to_dtype(out_dtype.into_dtype()).unwrap()) + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + source: IntTensor, + ) -> IntTensor { + super::base::mask_where_broadcasted(tensor, mask, source) + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + CandleTensor::new( + mask.tensor + .where_cond( + &super::candle_utils::fill_like::(value.elem(), &tensor.tensor), + &tensor.tensor, + ) + .unwrap(), + ) + } + + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + let tensor = tensor.tensor.contiguous().unwrap(); + let indices = indices.tensor.contiguous().unwrap(); + CandleTensor::new(tensor.gather(&indices, dim).unwrap()) + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + CandleTensor::new( + tensor + .tensor + .scatter_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> IntTensor { + super::base::scatter_nd(data, indices, values, reduction) + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + super::base::gather_nd(data, indices) + } + + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + CandleTensor::new(tensor.tensor.index_select(&indices.tensor, dim).unwrap()) + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + CandleTensor::new( + tensor + .tensor + .index_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + super::base::cat(tensors, dim) + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.eq(&rhs_broadcast).unwrap()) + } + + fn int_equal_elem( + lhs: IntTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new(lhs.tensor.eq(rhs.elem::()).unwrap()) + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.gt(&rhs_broadcast).unwrap()) + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .gt(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.ge(&rhs_broadcast).unwrap()) + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .ge(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.lt(&rhs_broadcast).unwrap()) + } + + fn int_lower_elem( + lhs: IntTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .lt(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.le(&rhs_broadcast).unwrap()) + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .le(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + CandleTensor::new(lhs.tensor.broadcast_add(&rhs.tensor).unwrap()) + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + CandleTensor::new((lhs.tensor + rhs.elem::()).unwrap()) + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + CandleTensor::new(lhs.tensor.broadcast_sub(&rhs.tensor).unwrap()) + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + CandleTensor::new((lhs.tensor - rhs.elem::()).unwrap()) + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + CandleTensor::new(lhs.tensor.broadcast_mul(&rhs.tensor).unwrap()) + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + CandleTensor::new((lhs.tensor * rhs.elem::()).unwrap()) + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + CandleTensor::new(lhs.tensor.broadcast_div(&rhs.tensor).unwrap()) + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + // Candle implements scalar a/b as a * (1/b). With ints 1/b is rounded to 0 so we always obtain 0. + panic!("Not supported by Candle") + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + CandleTensor::new( + (lhs.tensor.clone() + - lhs + .tensor + .broadcast_div(&rhs.tensor) + .unwrap() + .broadcast_mul(&rhs.tensor) + .unwrap()) + .unwrap(), + ) + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + // Same problem as int_div_scalar. + panic!("Not supported by Candle") + } + + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + CandleTensor::new( + candle_core::Tensor::zeros( + shape.to_vec(), + dtype.into_dtype(), + &(device.clone()).into(), + ) + .unwrap(), + ) + } + + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + CandleTensor::new( + candle_core::Tensor::ones(shape.to_vec(), dtype.into_dtype(), &(device.clone()).into()) + .unwrap(), + ) + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + let sum = tensor.tensor.sum_all().unwrap().reshape((1,)).unwrap(); + CandleTensor::new(sum) + } + + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor { + CandleTensor::new(tensor.tensor.sum_keepdim(dim).unwrap()) + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + todo!( + "prod is not implemented for Candle IntTensor (see https://github.com/tracel-ai/burn/issues/1454)" + ) + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + todo!( + "prod_int is not implemented for Candle IntTensor (see https://github.com/tracel-ai/burn/issues/1454)" + ) + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + // Candle implements scalar a/b as a * (1/b). With ints 1/b is rounded to 0 so we always obtain 0. + panic!("Not supported by Candle") + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + // Candle's cumsum doesn't support integer types, so we convert to float, + // compute cumsum, and convert back to int + let dtype = tensor.tensor.dtype(); + let tensor_float = tensor.tensor.to_dtype(candle_core::DType::F32).unwrap(); + let result_float = tensor_float.cumsum(dim).unwrap(); + CandleTensor::new(result_float.to_dtype(dtype).unwrap()) + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + // Convert to float for computation, then convert back + let dtype = tensor.tensor.dtype(); + let tensor_float = tensor.tensor.to_dtype(candle_core::DType::F32).unwrap(); + + let result_float = super::utils::cumulative_with_op(&tensor_float, dim, |prev, curr| { + prev.broadcast_mul(curr) + }); + CandleTensor::new(result_float.to_dtype(dtype).unwrap()) + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + // Convert to float for computation, then convert back + let dtype = tensor.tensor.dtype(); + let tensor_float = tensor.tensor.to_dtype(candle_core::DType::F32).unwrap(); + + let result_float = super::utils::cumulative_with_op(&tensor_float, dim, |prev, curr| { + prev.broadcast_minimum(curr) + }); + CandleTensor::new(result_float.to_dtype(dtype).unwrap()) + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + let result = super::utils::cumulative_with_op(&tensor.tensor, dim, |prev, curr| { + prev.broadcast_maximum(curr) + }); + CandleTensor::new(result) + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + CandleTensor::new(tensor.tensor.argmax_keepdim(dim).unwrap()) + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + panic!("argtopk not implemented for candle backend") + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + CandleTensor::new(tensor.tensor.argmin_keepdim(dim).unwrap()) + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + // Ugly type conversion here as Candle does not support unary ops on ints + match tensor.tensor.dtype() { + candle_core::DType::U8 | candle_core::DType::U32 => tensor, + candle_core::DType::I64 => CandleTensor::new( + tensor + .tensor + .to_dtype(candle_core::DType::F32) + .unwrap() + .abs() + .unwrap() + .to_dtype(candle_core::DType::I64) + .unwrap(), + ), + _ => unreachable!(), + } + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + super::base::swap_dims(tensor, dim1, dim2) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + if let CandleDevice::Cpu = device { + let distribution = if distribution == Distribution::Default { + Distribution::Uniform(0.0, 255.0) + } else { + distribution + }; + // Use our own seed since candle doesn't support it on CPU + return Self::int_from_data(cpu_random(shape, distribution, dtype.into()), device); + } + + let shape = shape.to_vec(); + let device = &(device.clone()).into(); + match distribution { + Distribution::Default => CandleTensor::new( + candle_core::Tensor::rand(0f32, 255f32, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Bernoulli(prob) => CandleTensor::new( + candle_core::Tensor::rand(0f32, 1f32, shape.clone(), device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap() + .lt(&super::candle_utils::fill( + prob, + shape, + dtype.into_dtype(), + device, + )) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Uniform(from, to) => CandleTensor::new( + candle_core::Tensor::rand(from, to, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Normal(mean, std) => CandleTensor::new( + candle_core::Tensor::randn(mean, std, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + } + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + super::base::permute(tensor, axes) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + super::base::flip(tensor, axes) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + expand(tensor, shape) + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + unfold(tensor, dim, size, step) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + sign(tensor) + } + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + unimplemented!("bitwise_and is not implemented for Candle IntTensor"); + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unimplemented!("bitwise_and_scalar is not implemented for Candle IntTensor"); + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + unimplemented!("bitwise_or is not implemented for Candle IntTensor"); + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unimplemented!("bitwise_or_scalar is not implemented for Candle IntTensor"); + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + unimplemented!("bitwise_xor is not implemented for Candle IntTensor"); + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unimplemented!("bitwise_xor_scalar is not implemented for Candle IntTensor"); + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + unimplemented!("bitwise_not is not implemented for Candle IntTensor"); + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + unimplemented!("bitwise_left_shift is not implemented for Candle IntTensor"); + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + unimplemented!("bitwise_right_shift is not implemented for Candle IntTensor"); + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unimplemented!("bitwise_left_shift_scalar is not implemented for Candle IntTensor"); + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unimplemented!("bitwise_right_shift_scalar is not implemented for Candle IntTensor"); + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let int_dtype = lhs.dtype(); + let lhs = Self::int_into_float(lhs, FloatDType::F32); + let rhs = Self::int_into_float(rhs, FloatDType::F32); + + let out = Self::float_matmul(lhs, rhs); + Self::float_into_int(out, int_dtype.into()) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + let dtype = dtype.into_dtype(); + + if tensor.tensor.dtype() == dtype { + tensor + } else { + CandleTensor::new(tensor.tensor.to_dtype(dtype).unwrap()) + } + } +} diff --git a/crates/burn-candle/src/ops/mod.rs b/crates/burn-candle/src/ops/mod.rs new file mode 100644 index 0000000..49de10c --- /dev/null +++ b/crates/burn-candle/src/ops/mod.rs @@ -0,0 +1,10 @@ +mod activation; +mod base; +mod bool_tensor; +mod candle_utils; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; +mod utils; diff --git a/crates/burn-candle/src/ops/module.rs b/crates/burn-candle/src/ops/module.rs new file mode 100644 index 0000000..d397cd4 --- /dev/null +++ b/crates/burn-candle/src/ops/module.rs @@ -0,0 +1,352 @@ +use burn_backend::{ + Shape, + ops::{ + ConvOptions, ConvTransposeOptions, DeformConv2dBackward, DeformConvOptions, + InterpolateMode, InterpolateOptions, MaxPool2dBackward, MaxPool2dWithIndices, ModuleOps, + UnfoldOptions, attention::attention_fallback, + }, + tensor::{FloatTensor, IntTensor}, +}; +use burn_std::IntDType; +use candle_core::ToUsize2; + +use crate::{ + Candle, CandleTensor, + element::{CandleElement, FloatCandleElement, IntCandleElement}, + ops::base::reshape, +}; + +impl ModuleOps for Candle { + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + let conv = x + .tensor + .conv1d( + &weight.tensor, + options.padding[0], + options.stride[0], + options.dilation[0], + options.groups, + ) + .unwrap(); + CandleTensor::new(match bias { + Some(bias) => conv + .broadcast_add(&bias.tensor.unsqueeze(1).unwrap()) + .unwrap(), + None => conv, + }) + } + + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor { + assert!( + options.dilation[0] == options.dilation[1] + && options.padding[0] == options.padding[1] + && options.stride[0] == options.stride[1], + "Candle does not support per dimension options in convolutions" + ); + let conv = x + .tensor + .conv2d( + &weight.tensor, + options.padding[0], + options.stride[0], + options.dilation[0], + options.groups, + ) + .unwrap(); + CandleTensor::new(match bias { + Some(bias) => conv + .broadcast_add( + &bias + .tensor + .unsqueeze(0) + .unwrap() + .unsqueeze(2) + .unwrap() + .unsqueeze(3) + .unwrap(), + ) + .unwrap(), + None => conv, + }) + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + unimplemented!("Candle does not support deformable convolutions") + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + unimplemented!("Candle does not support deformable convolutions") + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + panic!("Candle does not support 3D convolutions"); + } + + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + let conv_transpose = x + .tensor + .conv_transpose1d( + &weight.tensor, + options.padding[0], + options.padding_out[0], + options.stride[0], + options.dilation[0], + options.groups, + ) + .unwrap(); + CandleTensor::new(match bias { + Some(bias) => conv_transpose + .broadcast_add(&bias.tensor.unsqueeze(0).unwrap().unsqueeze(2).unwrap()) + .unwrap(), + None => conv_transpose, + }) + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + assert!( + options.dilation[0] == options.dilation[1] + && options.padding[0] == options.padding[1] + && options.padding_out[0] == options.padding_out[1] + && options.stride[0] == options.stride[1], + "Candle does not support per dimension options in transposed convolutions" + ); + assert!( + options.groups == 1, + "Candle does not support groups in transposed convolutions" + ); + let conv_transpose = x + .tensor + .conv_transpose2d( + &weight.tensor, + options.padding[0], + options.padding_out[0], + options.stride[0], + options.dilation[0], + ) + .unwrap(); + CandleTensor::new(match bias { + Some(bias) => conv_transpose + .broadcast_add( + &bias + .tensor + .unsqueeze(0) + .unwrap() + .unsqueeze(2) + .unwrap() + .unsqueeze(3) + .unwrap(), + ) + .unwrap(), + None => conv_transpose, + }) + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + panic!("Candle does not support 3D transposed convolutions"); + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + assert!( + padding[0] == 0 && padding[1] == 0, + "Candle does not support padding in pooling" + ); + assert!( + count_include_pad, + "Candle does not support excluding pad count in pooling" + ); + assert!(!ceil_mode, "Candle does not support ceil_mode in pooling"); + CandleTensor::new( + x.tensor + .avg_pool2d_with_stride((kernel_size[0], kernel_size[1]), (stride[0], stride[1])) + .unwrap(), + ) + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + _ceil_mode: bool, + ) -> FloatTensor { + panic!("avg_pool2d_backward is not supported by Candle") + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + assert!( + padding[0] == 0 && padding[1] == 0, + "Candle does not support padding in pooling" + ); + assert!( + dilation[0] == 1 && dilation[1] == 1, + "Candle does not support dilation in pooling" + ); + assert!(!ceil_mode, "Candle does not support ceil_mode in pooling"); + CandleTensor::new( + x.tensor + .max_pool2d_with_stride((kernel_size[0], kernel_size[1]), (stride[0], stride[1])) + .unwrap(), + ) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + _ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + panic!("max_pool2d_with_indices is not supported by Candle") + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + _ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + panic!("max_pool2d_with_indices_backward is not supported by Candle") + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + panic!("adaptive_avg_pool2 is not supported by Candle") + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + panic!("adaptive_avg_pool2d_backward is not supported by Candle") + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + let tensor = match options.mode { + InterpolateMode::Nearest => x + .tensor + .upsample_nearest2d(output_size[0], output_size[1]) + .unwrap(), + InterpolateMode::NearestExact => { + panic!("nearest exact interpolation is not implemented in Candle backend") + } + InterpolateMode::Bilinear => { + panic!("bilinear interpolation is not supported by Candle") + } + InterpolateMode::Bicubic => { + panic!("bicubic interpolation is not supported by Candle") + } + InterpolateMode::Lanczos3 => { + panic!("lanczos3 interpolation is not supported by Candle") + } + }; + + CandleTensor::new(tensor) + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + panic!("interpolate_backward is not supported by Candle") + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: burn_backend::ops::AttentionModuleOptions, + ) -> FloatTensor { + attention_fallback::(query, key, value, mask, attn_bias, options) + } + + fn rfft( + signal: FloatTensor, + dim: usize, + _n: Option, + ) -> (FloatTensor, FloatTensor) { + todo!("rfft is unsupported in Candle") + } + + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + _n: Option, + ) -> FloatTensor { + todo!("irfft is unsupported in Candle") + } +} diff --git a/crates/burn-candle/src/ops/qtensor.rs b/crates/burn-candle/src/ops/qtensor.rs new file mode 100644 index 0000000..1ee2e60 --- /dev/null +++ b/crates/burn-candle/src/ops/qtensor.rs @@ -0,0 +1,60 @@ +use burn_backend::{ + Backend, DType, ExecutionError, FloatDType, Shape, TensorData, + ops::QTensorOps, + quantization::{QuantScheme, QuantizationParametersPrimitive}, + tensor::{Device, FloatTensor, QuantizedTensor}, +}; + +use crate::{ + Candle, + element::{FloatCandleElement, IntCandleElement}, +}; + +impl QTensorOps for Candle { + fn q_from_data(data: TensorData, device: &Device) -> QuantizedTensor { + unimplemented!() + } + + fn quantize( + _tensor: FloatTensor, + _scheme: &QuantScheme, + _qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + unimplemented!() + } + + fn dequantize(_tensor: QuantizedTensor, _dtype: FloatDType) -> FloatTensor { + unimplemented!() + } + + fn q_to_device( + _tensor: QuantizedTensor, + _device: &Device, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_reshape(_tensor: QuantizedTensor, _shape: Shape) -> QuantizedTensor { + unimplemented!() + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + unimplemented!() + } + + fn q_swap_dims( + _tensor: QuantizedTensor, + _dim1: usize, + _dim2: usize, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_permute(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_flip(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } +} diff --git a/crates/burn-candle/src/ops/tensor.rs b/crates/burn-candle/src/ops/tensor.rs new file mode 100644 index 0000000..ad622ac --- /dev/null +++ b/crates/burn-candle/src/ops/tensor.rs @@ -0,0 +1,679 @@ +use std::borrow::Borrow; + +use burn_backend::{ + DType, Distribution, ElementConversion, ExecutionError, FloatDType, Scalar, Shape, Slice, + TensorData, bf16, f16, + ops::FloatTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{BoolDType, IntDType}; +use candle_core::{Tensor, backend::BackendStorage, shape}; + +use crate::{ + Candle, CandleDevice, CandleTensor, IntoDType, + element::{CandleElement, FloatCandleElement, IntCandleElement}, +}; + +use super::base::{cpu_random, expand, permute, sign, unfold}; + +impl FloatTensorOps for Candle { + fn float_from_data(data: TensorData, device: &Device) -> CandleTensor { + match data.dtype { + DType::F64 => super::base::from_data::(data, device), + DType::F32 => super::base::from_data::(data, device), + DType::F16 => super::base::from_data::(data, device), + DType::BF16 => super::base::from_data::(data, device), + _ => unimplemented!("Unsupported dtype for `float_from_data`"), + } + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + if let CandleDevice::Cpu = device { + // Use our own seed since candle doesn't support it on CPU + return Self::float_from_data(cpu_random(shape, distribution, dtype.into()), device); + } + + let shape = shape.to_vec(); + let device = &(device.clone()).into(); + match distribution { + Distribution::Default => CandleTensor::new( + candle_core::Tensor::rand(0f32, 1f32, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Bernoulli(prob) => CandleTensor::new( + candle_core::Tensor::rand(0f32, 1f32, shape.clone(), device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap() + .lt(&super::candle_utils::fill( + prob, + shape, + dtype.into_dtype(), + device, + )) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Uniform(from, to) => CandleTensor::new( + candle_core::Tensor::rand(from, to, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + Distribution::Normal(mean, std) => CandleTensor::new( + candle_core::Tensor::randn(mean, std, shape, device) + .unwrap() + .to_dtype(dtype.into_dtype()) + .unwrap(), + ), + } + } + + async fn float_into_data(tensor: CandleTensor) -> Result { + super::base::into_data(tensor) + } + + fn float_to_device(tensor: CandleTensor, device: &Device) -> CandleTensor { + super::base::to_device(tensor, device) + } + + fn float_into_int(tensor: CandleTensor, out_dtype: IntDType) -> IntTensor { + CandleTensor::new(tensor.tensor.to_dtype(out_dtype.into_dtype()).unwrap()) + } + + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + super::base::empty(shape, device, dtype.into_dtype()) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + CandleTensor::new(lhs.tensor.broadcast_add(&rhs.tensor).unwrap()) + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + CandleTensor::new((lhs.tensor + rhs.elem::()).unwrap()) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + CandleTensor::new(lhs.tensor.broadcast_sub(&rhs.tensor).unwrap()) + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + CandleTensor::new((lhs.tensor - rhs.elem::()).unwrap()) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + CandleTensor::new(lhs.tensor.broadcast_mul(&rhs.tensor).unwrap()) + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + CandleTensor::new((lhs.tensor * rhs.elem::()).unwrap()) + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + CandleTensor::new(lhs.tensor.broadcast_div(&rhs.tensor).unwrap()) + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + CandleTensor::new((lhs.tensor / rhs.elem::()).unwrap()) + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + CandleTensor::new( + (lhs.tensor.clone() + - lhs + .tensor + .broadcast_div(&rhs.tensor) + .unwrap() + .floor() + .unwrap() + .broadcast_mul(&rhs.tensor) + .unwrap()) + .unwrap(), + ) + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + // In PyTorch, remainder can also be defined as torch.remainder(a, b) == a - a.div(b, rounding_mode="floor") * b + let rhs_val = rhs.elem::(); + let division_result = (lhs.tensor.clone() / rhs_val).unwrap().floor().unwrap(); + let product = division_result * rhs_val; + + CandleTensor::new((lhs.tensor - product).unwrap()) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let lhs_contiguous = if !lhs.tensor.is_contiguous() { + lhs.tensor.contiguous().unwrap() + } else { + lhs.tensor + }; + let rhs_contiguous = if !rhs.tensor.is_contiguous() { + rhs.tensor.contiguous().unwrap() + } else { + rhs.tensor + }; + CandleTensor::new(lhs_contiguous.broadcast_matmul(&rhs_contiguous).unwrap()) + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + super::base::cross(lhs, rhs, dim) + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + super::base::swap_dims(tensor, dim1, dim2) + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + super::base::reshape(tensor, shape) + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + let tensor = tensor.tensor.contiguous().unwrap(); + let indices = indices.tensor.contiguous().unwrap(); + CandleTensor::new(tensor.gather(&indices, dim).unwrap()) + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + CandleTensor::new( + tensor + .tensor + .scatter_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + super::base::scatter_nd(data, indices, values, reduction) + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + super::base::gather_nd(data, indices) + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + CandleTensor::new(tensor.tensor.index_select(&indices.tensor, dim).unwrap()) + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + CandleTensor::new( + tensor + .tensor + .index_add(&indices.tensor, &value.tensor, dim) + .unwrap(), + ) + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + super::base::slice_with_steps(tensor, slices) + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[Slice], + value: FloatTensor, + ) -> FloatTensor { + super::base::slice_assign(tensor, slices, value) + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + super::base::mask_where_broadcasted(tensor, mask, value) + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + let value = super::candle_utils::fill_like::(value.elem(), &tensor.tensor); + super::base::mask_where_broadcasted(tensor, mask, CandleTensor::new(value)) + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.eq(&rhs_broadcast).unwrap()) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new(lhs.tensor.eq(rhs.elem::()).unwrap()) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.gt(&rhs_broadcast).unwrap()) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .gt(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.ge(&rhs_broadcast).unwrap()) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .ge(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.lt(&rhs_broadcast).unwrap()) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .lt(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> BoolTensor { + let (lhs_broadcast, rhs_broadcast) = + super::candle_utils::broadcast_for_comparison(&lhs.tensor, &rhs.tensor).unwrap(); + CandleTensor::new(lhs_broadcast.le(&rhs_broadcast).unwrap()) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> BoolTensor { + CandleTensor::new( + lhs.tensor + .le(&super::candle_utils::fill_like::( + rhs.elem(), + &lhs.tensor, + )) + .unwrap(), + ) + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + let sum = tensor.tensor.sum_all().unwrap().reshape((1,)).unwrap(); + CandleTensor::new(sum) + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + CandleTensor::new(tensor.tensor.sum_keepdim(dim).unwrap()) + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + CandleTensor::new(tensor.tensor.mean_keepdim(dim).unwrap()) + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + CandleTensor::new(tensor.tensor.cumsum(dim).unwrap()) + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + let result = super::utils::cumulative_with_op(&tensor.tensor, dim, |prev, curr| { + prev.broadcast_mul(curr) + }); + CandleTensor::new(result) + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + let result = super::utils::cumulative_with_op(&tensor.tensor, dim, |prev, curr| { + prev.broadcast_minimum(curr) + }); + CandleTensor::new(result) + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let result = super::utils::cumulative_with_op(&tensor.tensor, dim, |prev, curr| { + prev.broadcast_maximum(curr) + }); + CandleTensor::new(result) + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.exp().unwrap()) + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.log().unwrap()) + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new((tensor.tensor + 1.).unwrap().log().unwrap()) + } + + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor { + CandleTensor::new(tensor.tensor.powf(value.elem()).unwrap()) + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.sqrt().unwrap()) + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.abs().unwrap()) + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.cos().unwrap()) + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + // cosh(x) = (e^x + e^(-x)) / 2 + let exp_x = tensor.tensor.exp().unwrap(); + CandleTensor::new(((exp_x.clone() + exp_x.recip().unwrap()).unwrap() / 2.0).unwrap()) + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.sin().unwrap()) + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + // sinh(x) = (e^x - e^(-x)) / 2 + let exp_x = tensor.tensor.exp().unwrap(); + CandleTensor::new(((exp_x.clone() - exp_x.recip().unwrap()).unwrap() / 2.0).unwrap()) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new((tensor.tensor.sin().unwrap() / tensor.tensor.cos().unwrap()).unwrap()) + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.tanh().unwrap()) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + // acos(x) = PI/2 - asin(x) + let neg_asin_x = Self::float_neg(Self::float_asin(tensor)); + Self::float_add_scalar(neg_asin_x, core::f64::consts::FRAC_PI_2.into()) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + // acosh(x) = ln(x + sqrt(x^2 - 1)) + let x_squared = Self::float_powi_scalar(tensor.clone(), 2.into()); + let x_sq_minus_one = Self::float_sub_scalar(x_squared, 1f64.into()); + let sqrt_term = Self::float_sqrt(x_sq_minus_one); + Self::float_log(Self::float_add(tensor, sqrt_term)) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + // asin(x) = atan(x / sqrt(1 - x^2)) + let x_squared = Self::float_powi_scalar(tensor.clone(), 2.into()); + let one_minus_x_sq = Self::float_add_scalar(Self::float_neg(x_squared), 1f64.into()); + let sqrt_term = Self::float_sqrt(one_minus_x_sq); + Self::float_atan(Self::float_div(tensor, sqrt_term)) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + // asinh(x) = ln(x + sqrt(x^2 + 1)) + let x_squared = Self::float_powi_scalar(tensor.clone(), 2.into()); + let x_sq_plus_one = Self::float_add_scalar(x_squared, 1f64.into()); + let sqrt_term = Self::float_sqrt(x_sq_plus_one); + Self::float_log(Self::float_add(tensor, sqrt_term)) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + // atan(x) = asin(x / sqrt(1 + x^2)) + let x_squared = Self::float_powi_scalar(tensor.clone(), 2.into()); + let one_plus_x_sq = Self::float_add_scalar(x_squared, 1f64.into()); + let sqrt_term = Self::float_sqrt(one_plus_x_sq); + Self::float_asin(Self::float_div(tensor, sqrt_term)) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + // atanh(x) = ln((1 + x) / (1 - x)) / 2 + let num = (1.0 + tensor.tensor.clone()).unwrap(); + let denom = (1.0 - tensor.tensor).unwrap(); + CandleTensor::new(((num / denom).unwrap().log().unwrap() / 2.0).unwrap()) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + // atan2(y, x) = 2 * atan(y / (sqrt(x^2 + y^2) + x)) + let x_squared = Self::float_powi_scalar(rhs.clone(), 2.into()); + let y_squared = Self::float_powi_scalar(lhs.clone(), 2.into()); + let r = Self::float_sqrt(Self::float_add(x_squared, y_squared)); + let ratio = Self::float_div(lhs, Self::float_add(r, rhs)); + Self::float_mul_scalar(Self::float_atan(ratio), 2f64.into()) + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + let inner = |tensor: FloatTensor| -> candle_core::Result> { + // implements round_to_even for consistent behavior vs libtorch + // https://github.com/pytorch/pytorch/blob/main/torch/csrc/jit/runtime/register_ops_utils.h#L65-L67 + + let floor_a = tensor.tensor.floor()?; + let frac_part = tensor.tensor.sub(&floor_a)?; + + let half = (candle_core::Tensor::ones_like(&tensor.tensor)? * 0.5)?; + let mask_half = frac_part.eq(&half)?; + let half_tensor = tensor.tensor.mul(&half)?; + let rounded_half = half_tensor.round()?; + let doubled = + rounded_half.mul(&(candle_core::Tensor::ones_like(&tensor.tensor)? * 2.0)?)?; + let standard_round = tensor.tensor.round()?; + Ok(CandleTensor::new( + mask_half.where_cond(&doubled, &standard_round)?, + )) + }; + inner(tensor).unwrap() + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.floor().unwrap()) + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.ceil().unwrap()) + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + // truncate(x) = ⌊x⌋ if x ≥ 0, and ⌈x⌉ if x < 0 + // This preserves the sign of zero and handles all special cases correctly + let is_negative = tensor.tensor.lt(0.0).unwrap(); + let floored = tensor.tensor.floor().unwrap(); + let ceiled = tensor.tensor.ceil().unwrap(); + CandleTensor::new(is_negative.where_cond(&ceiled, &floored).unwrap()) + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.erf().unwrap()) + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + super::base::cat(tensors, dim) + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + CandleTensor::new( + tensor + .tensor + .argmax_keepdim(dim) + .unwrap() + .to_dtype(out_dtype.into_dtype()) + .unwrap(), + ) + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + panic!("argtopk not implemented for candle backend") + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + CandleTensor::new( + tensor + .tensor + .argmin_keepdim(dim) + .unwrap() + .to_dtype(out_dtype.into_dtype()) + .unwrap(), + ) + } + + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + CandleTensor::new(tensor.tensor.minimum(max.elem::()).unwrap()) + } + + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + CandleTensor::new(tensor.tensor.maximum(min.elem::()).unwrap()) + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + CandleTensor::new( + tensor + .tensor + .clamp(min.elem::(), max.elem::()) + .unwrap(), + ) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + CandleTensor::new(tensor.tensor.recip().unwrap()) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + //broadcast_pow is in main but not yet published + //note: probably replace once pow once 0.3.3 is out + //see: https://github.com/huggingface/candle/pull/1583/files#diff-6319fa1e16dadc4c7b4e25698139703d93b70f30a1f8e2ac0999978e39efaa81R2594 + + CandleTensor::new( + rhs.tensor + .broadcast_mul(&lhs.tensor.log().unwrap()) + .unwrap() + .exp() + .unwrap(), + ) + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + super::base::permute(tensor, axes) + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + super::base::flip(tensor, axes) + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + expand(tensor, shape) + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + unfold(tensor, dim, size, step) + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + sign(tensor) + } + + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor { + let dtype = dtype.into_dtype(); + + if tensor.tensor.dtype() == dtype { + tensor + } else { + CandleTensor::new(tensor.tensor.to_dtype(dtype).unwrap()) + } + } +} diff --git a/crates/burn-candle/src/ops/transaction.rs b/crates/burn-candle/src/ops/transaction.rs new file mode 100644 index 0000000..708d4df --- /dev/null +++ b/crates/burn-candle/src/ops/transaction.rs @@ -0,0 +1,15 @@ +use burn_backend::{ + Backend, + distributed::DistributedOps, + ops::{TransactionOps, TransactionPrimitive}, +}; + +use crate::{ + Candle, + element::{FloatCandleElement, IntCandleElement}, +}; + +impl TransactionOps for Candle {} + +// DistributedOps has default implementations; Candle does not support collective operations. +impl DistributedOps for Candle {} diff --git a/crates/burn-candle/src/ops/utils.rs b/crates/burn-candle/src/ops/utils.rs new file mode 100644 index 0000000..7686018 --- /dev/null +++ b/crates/burn-candle/src/ops/utils.rs @@ -0,0 +1,29 @@ +/// Helper function for cumulative operations in Candle backend +/// +/// This function reduces code duplication for cumulative operations (cumprod, cummin, cummax) +/// which all follow the same pattern of slicing, applying an operation, and concatenating. +/// +/// # Arguments +/// +/// * `tensor` - The input tensor +/// * `dim` - The dimension along which to apply the cumulative operation +/// * `op` - A closure that takes two tensor references and produces a result tensor +pub fn cumulative_with_op(tensor: &candle_core::Tensor, dim: usize, op: F) -> candle_core::Tensor +where + F: Fn(&candle_core::Tensor, &candle_core::Tensor) -> candle_core::Result, +{ + let dim_size = tensor.dims()[dim]; + let mut slices = Vec::with_capacity(dim_size); + + // First slice is the initial value + slices.push(tensor.narrow(dim, 0, 1).unwrap()); + + // Apply cumulative operation + for i in 1..dim_size { + let curr = tensor.narrow(dim, i, 1).unwrap(); + let result = op(&slices[i - 1], &curr).unwrap(); + slices.push(result); + } + + candle_core::Tensor::cat(&slices, dim).unwrap() +} diff --git a/crates/burn-candle/src/tensor.rs b/crates/burn-candle/src/tensor.rs new file mode 100644 index 0000000..4de5b92 --- /dev/null +++ b/crates/burn-candle/src/tensor.rs @@ -0,0 +1,120 @@ +use burn_backend::{DType, FloatDType, IntDType, Shape, quantization::QuantScheme}; +use burn_backend::{Element, TensorData, TensorMetadata}; +use burn_std::BoolStore; + +use crate::{CandleDevice, element::CandleElement}; + +/// A tensor that uses the candle backend. +#[derive(Debug, Clone)] +pub struct CandleTensor { + pub(crate) tensor: candle_core::Tensor, +} + +impl TensorMetadata for CandleTensor { + type Device = CandleDevice; + fn dtype(&self) -> DType { + match self.tensor.dtype() { + candle_core::DType::U8 => DType::U8, + candle_core::DType::U32 => DType::U32, + candle_core::DType::I64 => DType::I64, + candle_core::DType::BF16 => DType::BF16, + candle_core::DType::F16 => DType::F16, + candle_core::DType::F32 => DType::F32, + candle_core::DType::F64 => DType::F64, + candle_core::DType::I16 => DType::I16, + candle_core::DType::I32 => DType::I32, + other => todo!("{other:?} not yet supported"), + } + } + + fn shape(&self) -> Shape { + Shape::from(self.tensor.dims().to_vec()) + } + + fn rank(&self) -> usize { + self.tensor.dims().len() + } + + fn device(&self) -> CandleDevice { + self.tensor.device().clone().into() + } + + fn can_mut(&self) -> bool { + // Candle tensors share storage behind an `Arc` with no public + // uniqueness check, so in-place mutation can never be assumed safe. + false + } +} + +impl CandleTensor { + /// Create a new tensor. + pub fn new(tensor: candle_core::Tensor) -> Self { + Self { tensor } + } + + /// Creates a new tensor from data and a device. + /// + /// # Arguments + /// + /// * `data` - The tensor's data. + /// * `device` - The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// A new tensor. + pub fn from_data(data: TensorData, device: CandleDevice) -> Self { + let candle_shape: candle_core::Shape = data.shape.to_vec().into(); + let tensor = candle_core::Tensor::from_slice( + data.as_slice::().unwrap(), + candle_shape, + &device.into(), + ); + Self::new(tensor.unwrap()) + } +} + +pub(crate) trait IntoDType { + fn try_into_dtype(self) -> Result; + + fn into_dtype(self) -> candle_core::DType + where + Self: Sized, + { + self.try_into_dtype().unwrap() + } +} + +impl IntoDType for IntDType { + fn try_into_dtype(self) -> Result { + let dtype: DType = self.into(); + dtype.try_into_dtype() + } +} + +impl IntoDType for FloatDType { + fn try_into_dtype(self) -> Result { + let dtype: DType = self.into(); + dtype.try_into_dtype() + } +} + +impl IntoDType for DType { + fn try_into_dtype(self) -> Result { + match self { + DType::F64 => Ok(candle_core::DType::F64), + DType::F32 => Ok(candle_core::DType::F32), + DType::Flex32 => Ok(candle_core::DType::F32), + DType::F16 => Ok(candle_core::DType::F16), + DType::BF16 => Ok(candle_core::DType::BF16), + DType::I64 => Ok(candle_core::DType::I64), + DType::U32 => Ok(candle_core::DType::U32), + DType::U8 => Ok(candle_core::DType::U8), + DType::I16 => Ok(candle_core::DType::I16), + DType::I32 => Ok(candle_core::DType::I32), + DType::Bool(BoolStore::U8) => Ok(candle_core::DType::U8), + _ => Err(candle_core::Error::Msg(format!( + "Unsupported dtype {self:?}" + ))), + } + } +} diff --git a/crates/burn-communication/Cargo.toml b/crates/burn-communication/Cargo.toml new file mode 100644 index 0000000..678e075 --- /dev/null +++ b/crates/burn-communication/Cargo.toml @@ -0,0 +1,49 @@ +[package] +authors = ["Guilhem Ané (@Cielbird)", "Nathaniel Simard (@nathanielsimard)"] +description = "Abstractions for network communication for Burn" +edition.workspace = true +license.workspace = true +name = "burn-communication" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-communication" +version.workspace = true + +[lints] +workspace = true + +[features] +tracing = [ + "burn-std/tracing", + "burn-backend?/tracing", +] + +data-service = ["burn-backend"] +websocket = ["axum", "tokio-tungstenite", "futures"] + +[dependencies] +burn-std = { workspace = true, features = ["default"] } +bytes = { workspace = true } +derive-new = { workspace = true } +futures-util = { workspace = true } +log = { workspace = true } +rmp-serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_bytes = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "sync", "signal", "tracing"] } +tokio-util = { workspace = true } +tracing = { workspace = true, features = ["default"] } +tracing-core = { workspace = true, features = ["default"] } +tracing-subscriber = { workspace = true, features = ["default", "fmt", "env-filter"] } + +# Tensor Data Service +burn-backend = { workspace = true, optional = true, features = ["default"] } + +# Websocket +axum = { workspace = true, features = ["ws"], optional = true } +tokio-tungstenite = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + +[dev-dependencies] +# Integration tests for the websocket server (run with `--features websocket`). +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "net"] } +tokio-tungstenite = { workspace = true } diff --git a/crates/burn-communication/README.md b/crates/burn-communication/README.md new file mode 100644 index 0000000..d5f1490 --- /dev/null +++ b/crates/burn-communication/README.md @@ -0,0 +1,15 @@ +# Burn Communication + +Abstractions for network communication + +The Protocol trait defines how to communicate in a server/client style. +The server can set up routes with callbacks upon connection. + +## WebSocket + +Communication with WebSockets is implemented with the `websocket` feature. + +## Tensor Data Service + +The tensor data service provides easy utilities to share tensors peer-to-peer. +One peer can expose a tensor, and another can download it. Each peer is both a client and a server. diff --git a/crates/burn-communication/src/base.rs b/crates/burn-communication/src/base.rs new file mode 100644 index 0000000..123b24d --- /dev/null +++ b/crates/burn-communication/src/base.rs @@ -0,0 +1,221 @@ +use burn_std::future::DynFut; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::hash::Hash; +use std::str::FromStr; + +/// A parsed network endpoint used by nodes to find each other. +/// +/// Construction normalizes the input so that equivalent spellings of the same endpoint +/// compare equal: a missing scheme defaults to `ws`, and a trailing path/slash is dropped. +/// As a result `"localhost:3000"`, `"ws://localhost:3000"`, and `"ws://localhost:3000/"` +/// all parse to the same `Address`. Equality is over `(scheme, host, port)`, so two devices +/// that point at the same server (e.g. different device indices) share one `Address` — that +/// is the signal the remote backend uses to detect a same-host transfer. +/// +/// Note: host names are compared textually, not resolved — `localhost` and `127.0.0.1` are +/// *not* considered equal. Aliases simply fall back to the network transfer path. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Debug)] +pub struct Address { + scheme: String, + host: String, + port: Option, +} + +impl Address { + /// The scheme component (e.g. `ws`), lowercased. Defaults to `ws` when none was given. + pub fn scheme(&self) -> &str { + &self.scheme + } + + /// The host component (host name or IP literal), exactly as written. + pub fn host(&self) -> &str { + &self.host + } + + /// The port component, if one was specified. + pub fn port(&self) -> Option { + self.port + } + + /// Parse an endpoint string into its components, applying canonicalization. + fn parse(s: &str) -> Self { + let s = s.trim(); + let (scheme, rest) = match s.split_once("://") { + Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest), + None => ("ws".to_string(), s), + }; + + // Drop any path/query/fragment — only the authority identifies the endpoint. + let authority = rest + .split(['/', '?', '#']) + .next() + .unwrap_or(rest) + .trim_end_matches('/'); + + // Split host:port from the right so IPv6 literals like `[::1]:3000` keep their + // colons; only the final `:port` (when it parses as a port) is treated as the port. + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) if !host.is_empty() => match port.parse::() { + Ok(port) => (host.to_string(), Some(port)), + Err(_) => (authority.to_string(), None), + }, + _ => (authority.to_string(), None), + }; + + Self { scheme, host, port } + } +} + +impl From<&str> for Address { + fn from(s: &str) -> Self { + Address::parse(s) + } +} + +impl FromStr for Address { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok(Address::parse(s)) + } +} + +impl Display for Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}://{}", self.scheme, self.host)?; + if let Some(port) = self.port { + write!(f, ":{port}")?; + } + Ok(()) + } +} + +/// The protocol used for the communications. +pub trait Protocol: Clone + Send + Sync + 'static { + /// The client implementation for the current protocol. + type Client: ProtocolClient; + /// The server implementation for the current protocol. + type Server: ProtocolServer; +} + +/// Error that happens during a communication. +pub trait CommunicationError: Debug + Send + 'static {} + +/// The client is only used to create a [channel](CommunicationChannel), which should be use to +/// transmit information with the [server](ProtocolServer). +pub trait ProtocolClient: Send + Sync + 'static { + /// Channel used by this protocol. + type Channel: CommunicationChannel; + /// The error type. + type Error: CommunicationError; + + /// Opens a new [channel](CommunicationChannel) with the current protocol at the given + /// [address](Address) and route. + /// + /// * `address` - Address to connect to + /// * `route` - The name of the route (no slashes) + /// + /// Returns `Err(Self::Error)` if the connection couldn't be established (address parse + /// failure, server unreachable, handshake failure, …). The error carries enough context + /// to identify the cause; callers should surface it rather than swallowing it. + fn connect(address: Address, route: &str) -> DynFut>; +} + +/// Data sent and received by the client and server. +#[derive(new)] +pub struct Message { + /// The data is always encoded as bytes. + pub data: bytes::Bytes, +} + +/// Defines how to create a server that respond to a [channel](CommunicationChannel). +pub trait ProtocolServer: Sized + Send + Sync + 'static { + /// Channel used by this protocol. + type Channel: CommunicationChannel; + /// The error type. + type Error: CommunicationError; + + /// Defines an endpoint with the function that responds. + /// TODO Docs: does it need a slash? + fn route(self, path: &str, callback: C) -> Self + where + C: FnOnce(Self::Channel) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static; + + /// Start the server. + fn serve( + self, + shutdown: F, + ) -> impl Future> + Send + 'static + where + F: Future + Send + 'static; +} + +/// Handles communications. +pub trait CommunicationChannel: Send + 'static { + type Error: CommunicationError; + + /// Send a [message](Message) on the channel. + fn send( + &mut self, + message: Message, + ) -> impl std::future::Future> + Send; + + /// Receive a [message](Message) on the channel and returns a new [response message](Message). + fn recv( + &mut self, + ) -> impl std::future::Future, Self::Error>> + Send; + + fn close(&mut self) -> impl std::future::Future> + Send; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_defaults_scheme_and_canonicalizes() { + let bare = Address::from("localhost:3000"); + let with_scheme = Address::from("ws://localhost:3000"); + let trailing = Address::from("ws://localhost:3000/"); + + // Equivalent spellings canonicalize to the same value. + assert_eq!(bare, with_scheme); + assert_eq!(with_scheme, trailing); + assert_eq!(bare.scheme(), "ws"); + assert_eq!(bare.host(), "localhost"); + assert_eq!(bare.port(), Some(3000)); + assert_eq!(with_scheme.to_string(), "ws://localhost:3000"); + } + + #[test] + fn address_distinguishes_port_host_and_scheme() { + assert_ne!( + Address::from("ws://host:3000"), + Address::from("ws://host:3001") + ); + assert_ne!(Address::from("ws://a:3000"), Address::from("ws://b:3000")); + assert_ne!( + Address::from("ws://host:3000"), + Address::from("wss://host:3000") + ); + // Host names are textual, not resolved. + assert_ne!( + Address::from("ws://localhost:3000"), + Address::from("ws://127.0.0.1:3000") + ); + } + + #[test] + fn address_handles_ipv6_and_missing_port() { + let v6 = Address::from("ws://[::1]:3000"); + assert_eq!(v6.host(), "[::1]"); + assert_eq!(v6.port(), Some(3000)); + + let no_port = Address::from("ws://example.com"); + assert_eq!(no_port.host(), "example.com"); + assert_eq!(no_port.port(), None); + assert_eq!(no_port.to_string(), "ws://example.com"); + } +} diff --git a/crates/burn-communication/src/external_comm.rs b/crates/burn-communication/src/external_comm.rs new file mode 100644 index 0000000..0fc187a --- /dev/null +++ b/crates/burn-communication/src/external_comm.rs @@ -0,0 +1,260 @@ +//! This module enables direct data transfer between servers without blocking the client or any server. +//! +//! It eliminates the need for intermediate data transfer through the client, avoiding the process of downloading data from one server and reuploading it to another. +//! +//! The module provides an optimized mechanism for servers to communicate directly, streamlining data movement between them without involving the client. + +use crate::Message; +use crate::base::Protocol; +use crate::base::{Address, CommunicationChannel, ProtocolClient, ProtocolServer}; +use burn_backend::{TensorData, backend::Backend}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, marker::PhantomData, sync::Arc}; +use tokio::sync::Mutex; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TensorTransferId(u64); + +impl From for TensorTransferId { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl TensorTransferId { + pub fn next(&mut self) { + self.0 += 1; + } +} + +#[derive(Debug, Serialize, Deserialize)] +enum ExternalCommMessage { + TensorRequest(TensorTransferId), + Tensor(TensorData), +} + +type ClientChannelRef = Arc::Channel>>; + +pub struct ExternalCommService> { + /// Maps tensor transfer IDs to their exposed state. + pub exposed_tensors: Mutex>, + /// Maps node addresses to their channels. + pub channels: Mutex>>, + /// Notify when a new tensor is exposed. + pub new_tensor_notify: Arc, + + cancel_token: CancellationToken, + + _phantom_data: PhantomData, +} + +pub struct TensorExposeState { + /// The bytes of the tensor data message. Message::Data(...) serialized with rmp_serde + pub bytes: bytes::Bytes, + /// How many times the tensor will be downloaded + pub max_downloads: u32, + /// How man times the tensor has been downloaded + pub cur_download_count: u32, +} + +/// Provides a routing function for a tensor data service for a communications server +pub trait ExternalCommServer { + /// Routes the tensor data service to the "/data" route + fn route_external_comm(self, state: Arc>) -> Self; +} + +impl + 'static> + ExternalCommServer for S +{ + fn route_external_comm(self, state: Arc>) -> Self { + self.route("/data", async move |stream: S::Channel| { + state.handle_data_channel(stream).await; + }) + } +} + +impl ExternalCommService { + pub fn new(cancel_token: CancellationToken) -> Self { + Self { + exposed_tensors: Mutex::new(HashMap::new()), + channels: Mutex::new(HashMap::new()), + new_tensor_notify: Arc::new(Notify::new()), + cancel_token, + _phantom_data: PhantomData::, + } + } + + /// Exposes a tensor to the data server, allowing it to be downloaded by other nodes. + pub async fn expose( + &self, + tensor: B::FloatTensorPrimitive, + max_downloads: u32, + transfer_id: TensorTransferId, + ) { + let data = B::float_into_data(tensor).await.unwrap(); + self.expose_data(data, max_downloads, transfer_id).await + } + + /// Exposes a tensor data to the data server, allowing it to be downloaded by other nodes. + pub async fn expose_data( + &self, + tensor_data: TensorData, + max_downloads: u32, + transfer_id: TensorTransferId, + ) { + let bytes: bytes::Bytes = rmp_serde::to_vec(&ExternalCommMessage::Tensor(tensor_data)) + .unwrap() + .into(); + let mut exposed_tensors = self.exposed_tensors.lock().await; + exposed_tensors.insert( + transfer_id, + TensorExposeState { + bytes, + max_downloads, + cur_download_count: 0, + }, + ); + core::mem::drop(exposed_tensors); + self.new_tensor_notify.notify_waiters(); + } + + pub async fn close(&self) { + // Send a closing message to every open WebSocket stream + + let mut streams = self.channels.lock().await; + for (_, stream) in streams.drain() { + let mut stream = stream.lock().await; + + stream + .close() + .await + .expect("Failed to close WebSocket stream"); + } + } + + /// Downloads a tensor that is exposed on another server. Requires a Tokio 1.x runtime + /// + /// Returns None if the peer closes the connection + pub async fn download_tensor( + &self, + remote: Address, + transfer_id: TensorTransferId, + ) -> Option { + log::info!("Downloading tensor from {remote:?}"); + + let stream = self.get_data_stream(remote).await; + let mut stream = stream.lock().await; + + // Send the download request with the download id + let bytes: bytes::Bytes = + rmp_serde::to_vec(&ExternalCommMessage::TensorRequest(transfer_id)) + .unwrap() + .into(); + stream + .send(Message::new(bytes)) + .await + .expect("Failed to send download id"); + + if let Ok(msg) = stream.recv().await { + let Some(msg) = msg else { + log::warn!("Received None message from the websocket, closing connection."); + return None; + }; + + let ExternalCommMessage::Tensor(data) = rmp_serde::from_slice(&msg.data) + .expect("Can deserialize messages from the websocket.") + else { + panic!("Message should have been TensorData") + }; + return Some(data); + } + log::warn!("Closed connection"); + None + } + + /// Get the WebSocket stream for the given address, or create a new one if it doesn't exist. + async fn get_data_stream( + &self, + address: Address, + ) -> Arc::Channel>> { + let mut streams = self.channels.lock().await; + match streams.get(&address) { + Some(stream) => stream.clone(), + None => { + // Open a new WebSocket connection to the address + let stream = match P::Client::connect(address.clone(), "data").await { + Ok(stream) => stream, + Err(err) => panic!( + "Failed to open remote 'data' channel to {address}: {err:?}. \ + Is a `burn-remote` server running at that address?" + ), + }; + + let stream = Arc::new(Mutex::new(stream)); + streams.insert(address.clone(), stream.clone()); + + stream + } + } + } + + /// Get the requested exposed tensor data, and update download counter + async fn get_exposed_tensor_bytes( + &self, + transfer_id: TensorTransferId, + ) -> Option { + loop { + { + let mut exposed_tensors = self.exposed_tensors.lock().await; + // take the tensor out of the hashmap while we download + if let Some(mut exposed_state) = exposed_tensors.remove(&transfer_id) { + exposed_state.cur_download_count += 1; + let bytes = if exposed_state.cur_download_count == exposed_state.max_downloads { + exposed_state.bytes + } else { + let bytes = exposed_state.bytes.clone(); + exposed_tensors.insert(transfer_id, exposed_state); + bytes + }; + return Some(bytes); + } + } + // No matching tensor, wait for a new one to come in. + self.new_tensor_notify.notified().await; + } + } + + /// Handle incoming connections for downloading tensors. + pub(crate) async fn handle_data_channel( + &self, + mut channel: ::Channel, + ) { + log::info!("[Data Handler] New connection for download."); + + while !self.cancel_token.is_cancelled() { + match channel.recv().await { + Ok(message) => { + if let Some(msg) = message { + let bytes = msg.data; + let msg: ExternalCommMessage = rmp_serde::from_slice(&bytes) + .expect("Can deserialize messages from the websocket."); + let ExternalCommMessage::TensorRequest(transfer_id) = msg else { + panic!("Received a message that wasn't a tensor request! {msg:?}"); + }; + + let bytes = self.get_exposed_tensor_bytes(transfer_id).await.unwrap(); + + channel.send(Message::new(bytes)).await.unwrap(); + } else { + log::info!("Closed connection"); + return; + } + } + Err(err) => panic!("Failed to receive message from websocket: {err:?}"), + }; + } + log::info!("[Data Service] Closing connection for download."); + } +} diff --git a/crates/burn-communication/src/lib.rs b/crates/burn-communication/src/lib.rs new file mode 100644 index 0000000..49acfbb --- /dev/null +++ b/crates/burn-communication/src/lib.rs @@ -0,0 +1,13 @@ +#[macro_use] +extern crate derive_new; + +mod base; +pub use base::*; + +pub mod util; + +#[cfg(feature = "websocket")] +pub mod websocket; + +#[cfg(feature = "data-service")] +pub mod external_comm; diff --git a/crates/burn-communication/src/util.rs b/crates/burn-communication/src/util.rs new file mode 100644 index 0000000..7b2b909 --- /dev/null +++ b/crates/burn-communication/src/util.rs @@ -0,0 +1,46 @@ +use tracing_core::{Level, LevelFilter}; +use tracing_subscriber::{ + Layer, filter::filter_fn, layer::SubscriberExt, registry, util::SubscriberInitExt, +}; + +/// Utilities to help handle communication termination. +pub async fn os_shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + +pub(crate) fn init_logging() { + let layer = tracing_subscriber::fmt::layer() + .with_filter(LevelFilter::INFO) + .with_filter(filter_fn(|m| { + if let Some(path) = m.module_path() { + // The wgpu crate is logging too much, so we skip `info` level. + if path.starts_with("wgpu") && *m.level() >= Level::INFO { + return false; + } + } + true + })); + + // If we start multiple servers in the same process, this will fail, it's ok + let _ = registry().with(layer).try_init(); +} diff --git a/crates/burn-communication/src/websocket/base.rs b/crates/burn-communication/src/websocket/base.rs new file mode 100644 index 0000000..af7981f --- /dev/null +++ b/crates/burn-communication/src/websocket/base.rs @@ -0,0 +1,26 @@ +use crate::{ + base::{Address, Protocol}, + websocket::{client::WsClient, server::WsServer}, +}; + +#[derive(Clone)] +/// A websocket implements a [communication protocol](Protocol) that can be used to communicate +/// over the internet. +pub struct WebSocket {} + +impl Protocol for WebSocket { + type Client = WsClient; + type Server = WsServer; +} + +/// Validate that an [`Address`] uses the websocket scheme. +/// +/// The [`Address`] is already canonicalized at construction (scheme defaults to `ws`, path +/// stripped), so this only has to reject a non-`ws` scheme. The address is returned +/// unchanged on success and its [`Display`](std::fmt::Display) form is the connection url. +pub(crate) fn parse_ws_address(address: Address) -> Result { + match address.scheme() { + "ws" | "wss" => Ok(address), + other => Err(format!("Invalid scheme: {other}")), + } +} diff --git a/crates/burn-communication/src/websocket/client.rs b/crates/burn-communication/src/websocket/client.rs new file mode 100644 index 0000000..436d25d --- /dev/null +++ b/crates/burn-communication/src/websocket/client.rs @@ -0,0 +1,207 @@ +use crate::{ + base::{Address, CommunicationChannel, CommunicationError, Message, ProtocolClient}, + websocket::base::parse_ws_address, +}; +use burn_std::future::DynFut; +use futures::{ + SinkExt, StreamExt, + stream::{SplitSink, SplitStream}, +}; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async_with_config, + tungstenite::{self, protocol::WebSocketConfig}, +}; + +#[derive(Clone)] +pub struct WsClient; + +impl ProtocolClient for WsClient { + type Channel = WsClientChannel; + type Error = WsClientError; + + fn connect(address: Address, route: &str) -> DynFut> { + Box::pin(connect_ws(address, route.to_owned())) + } +} + +/// Open a new WebSocket connection to the address. +async fn connect_ws(address: Address, route: String) -> Result { + let address = parse_ws_address(address).map_err(WsClientError::Address)?; + let url = format!("{address}/{route}"); + const MB: usize = 1024 * 1024; + let (stream, _) = connect_async_with_config( + url, + Some( + WebSocketConfig::default() + .write_buffer_size(0) + .max_message_size(None) + .max_frame_size(Some(MB * 512)) + .accept_unmasked_frames(true) + .read_buffer_size(64 * 1024), // 64 KiB (previous default) + ), + true, + ) + .await?; + + Ok(WsClientChannel { inner: stream }) +} +pub struct WsClientChannel { + inner: WebSocketStream>, +} + +impl CommunicationChannel for WsClientChannel { + type Error = WsClientError; + + async fn send(&mut self, msg: Message) -> Result<(), WsClientError> { + self.inner + .send(tungstenite::Message::Binary(msg.data)) + .await?; + + Ok(()) + } + + async fn recv(&mut self) -> Result, WsClientError> { + // Keep reading until we get a data frame, a close, an error, or the stream ends. + // Ping/Pong (keepalives) and Text frames must be skipped, not turned into errors: a + // single keepalive ping would otherwise look like a fatal error to the consuming loop + // (e.g. the client's response demux) and kill an otherwise-healthy connection. + // tungstenite already answers pings with pongs at the protocol layer, so ignoring them + // here is safe. Mirrors the server channel's `recv`. + loop { + match self.inner.next().await { + Some(Ok(tungstenite::Message::Binary(data))) => return Ok(Some(Message { data })), + // A close frame is an orderly end-of-stream. + Some(Ok(tungstenite::Message::Close(_close_frame))) => return Ok(None), + // Control/text frames carry no protocol payload: skip and keep reading. + Some(Ok( + tungstenite::Message::Ping(_) + | tungstenite::Message::Pong(_) + | tungstenite::Message::Text(_) + | tungstenite::Message::Frame(_), + )) => continue, + Some(Err(err)) => return Err(WsClientError::Tungstenite(err)), + // The stream is exhausted: the peer went away without a close frame (server + // shut the socket, network drop). A common disconnect path, not an error — + // treat it as a clean end-of-stream rather than panicking with `todo!()`. + None => return Ok(None), + } + } + } + + async fn close(&mut self) -> Result<(), WsClientError> { + let reason = "Peer is closing".to_string(); + + self.inner + .send(tungstenite::Message::Close(Some( + tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: reason.clone().into(), + }, + ))) + .await?; + + Ok(()) + } +} + +impl WsClientChannel { + /// Split into independently-owned send and receive halves, so a writer task and a reader loop + /// can run concurrently over one full-duplex socket. + pub fn split(self) -> (WsClientSink, WsClientStream) { + let (sink, stream) = self.inner.split(); + ( + WsClientSink { inner: sink }, + WsClientStream { inner: stream }, + ) + } +} + +/// Send half of a split [`WsClientChannel`]. +pub struct WsClientSink { + inner: SplitSink>, tungstenite::Message>, +} + +impl WsClientSink { + pub async fn send(&mut self, msg: Message) -> Result<(), WsClientError> { + self.inner + .send(tungstenite::Message::Binary(msg.data)) + .await?; + Ok(()) + } + + pub async fn close(&mut self) -> Result<(), WsClientError> { + self.inner + .send(tungstenite::Message::Close(Some( + tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "Peer is closing".to_string().into(), + }, + ))) + .await?; + Ok(()) + } +} + +/// Receive half of a split [`WsClientChannel`]. +pub struct WsClientStream { + inner: SplitStream>>, +} + +impl WsClientStream { + /// Mirrors [`WsClientChannel::recv`]: surface binary frames, skip keepalive/text frames, and + /// treat a close frame or an exhausted stream as a clean end-of-stream. + pub async fn recv(&mut self) -> Result, WsClientError> { + loop { + match self.inner.next().await { + Some(Ok(tungstenite::Message::Binary(data))) => return Ok(Some(Message { data })), + Some(Ok(tungstenite::Message::Close(_close_frame))) => return Ok(None), + Some(Ok( + tungstenite::Message::Ping(_) + | tungstenite::Message::Pong(_) + | tungstenite::Message::Text(_) + | tungstenite::Message::Frame(_), + )) => continue, + Some(Err(err)) => return Err(WsClientError::Tungstenite(err)), + None => return Ok(None), + } + } + } +} + +#[derive(Debug)] +pub enum WsClientError { + /// The address couldn't be parsed (e.g. missing scheme, unsupported scheme). + Address(String), + Io(std::io::Error), + Tungstenite(tungstenite::Error), + UnknownMessage(String), + Other(String), +} +impl CommunicationError for WsClientError {} + +impl core::fmt::Display for WsClientError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Address(msg) => write!(f, "invalid address: {msg}"), + Self::Io(err) => write!(f, "io error: {err}"), + Self::Tungstenite(err) => write!(f, "websocket error: {err}"), + Self::UnknownMessage(msg) => write!(f, "unknown message: {msg}"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for WsClientError {} + +impl From for WsClientError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for WsClientError { + fn from(err: tungstenite::Error) -> Self { + Self::Tungstenite(err) + } +} diff --git a/crates/burn-communication/src/websocket/mod.rs b/crates/burn-communication/src/websocket/mod.rs new file mode 100644 index 0000000..ca64da1 --- /dev/null +++ b/crates/burn-communication/src/websocket/mod.rs @@ -0,0 +1,7 @@ +mod base; +mod client; +mod server; + +pub use base::*; +pub use client::*; +pub use server::*; diff --git a/crates/burn-communication/src/websocket/server.rs b/crates/burn-communication/src/websocket/server.rs new file mode 100644 index 0000000..fe7d3f4 --- /dev/null +++ b/crates/burn-communication/src/websocket/server.rs @@ -0,0 +1,250 @@ +use std::net::SocketAddr; + +use crate::{ + base::{CommunicationChannel, CommunicationError, Message, ProtocolServer}, + util::init_logging, +}; +use axum::{ + Router, + extract::{ + State, WebSocketUpgrade, + ws::{self, WebSocket}, + }, + routing::get, +}; +use futures::{ + SinkExt, StreamExt, + stream::{SplitSink, SplitStream}, +}; + +#[derive(Clone, Debug)] +pub struct WsServer { + port: u16, + router: Router<()>, +} + +pub struct WsServerChannel { + inner: WebSocket, +} + +impl WsServer { + pub fn new(port: u16) -> Self { + Self { + port, + router: Router::new(), + } + } + + /// Serve on an already-bound listener instead of binding `0.0.0.0:port` ourselves. + /// + /// This is what [`serve`](ProtocolServer::serve) delegates to; it is exposed so a caller + /// that needs the actual bound address — e.g. a test binding an ephemeral `:0` port and + /// reading the port back from `listener.local_addr()` — can do so without a fixed port. + pub async fn serve_on( + self, + listener: tokio::net::TcpListener, + shutdown: F, + ) -> Result<(), WsServerError> + where + F: Future + Send + 'static, + { + init_logging(); + + // Report the address the listener actually bound to (resolves an ephemeral `:0` port to + // the real one), so the log confirms the server is accepting connections and tells the + // operator where. + match listener.local_addr() { + Ok(addr) => log::info!("Server started, listening on {addr}"), + Err(err) => log::info!("Server started (could not resolve bound address: {err})"), + } + + axum::serve( + listener, + self.router + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await?; + + Ok(()) + } +} + +impl ProtocolServer for WsServer { + type Channel = WsServerChannel; + type Error = WsServerError; + + async fn serve(self, shutdown: F) -> Result<(), Self::Error> + where + F: Future + Send + 'static, + { + let address = format!("0.0.0.0:{}", self.port); + log::info!("Starting server {address}"); + + let listener = tokio::net::TcpListener::bind(address).await?; + + self.serve_on(listener, shutdown).await + } + + fn route(mut self, path: &str, callback: C) -> Self + where + C: FnOnce(WsServerChannel) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static, + { + // Format path: should start with a / + let path = if path.starts_with("/") { + path.to_owned() + } else { + format!("/{path}") + }; + + let method = get(|ws: WebSocketUpgrade, _: State<()>| async { + ws.on_upgrade(async move |socket| { + callback(WsServerChannel { inner: socket }).await; + }) + }); + + self.router = self.router.route(&path, method); + + self + } +} + +impl CommunicationChannel for WsServerChannel { + type Error = WsServerError; + + async fn send(&mut self, message: Message) -> Result<(), WsServerError> { + self.inner.send(ws::Message::Binary(message.data)).await?; + + Ok(()) + } + + async fn recv(&mut self) -> Result, WsServerError> { + // Keep reading until we get a data frame, a close, an error, or the stream ends. + // Ping/Pong (keepalives) and Text frames must be skipped, not turned into errors: a + // single keepalive ping would otherwise look like a fatal error to the consuming loop + // and kill an otherwise-healthy connection. tungstenite already answers pings with + // pongs at the protocol layer, so ignoring them here is safe. + loop { + match self.inner.next().await { + Some(Ok(ws::Message::Binary(data))) => return Ok(Some(Message { data })), + // A close frame is an orderly end-of-stream. + Some(Ok(ws::Message::Close(_close_frame))) => return Ok(None), + // Control/text frames carry no protocol payload: skip and keep reading. + Some(Ok(ws::Message::Ping(_) | ws::Message::Pong(_) | ws::Message::Text(_))) => { + continue; + } + Some(Err(err)) => return Err(WsServerError::Axum(err)), + // The stream is exhausted: the peer went away without a close frame (closed + // tab, network drop, killed client). This is a common disconnect path, not an + // error — treat it as a clean end-of-stream rather than panicking. + None => return Ok(None), + } + } + } + + async fn close(&mut self) -> Result<(), WsServerError> { + let reason = "Peer is closing".to_string(); + + self.inner + .send(ws::Message::Close(Some(ws::CloseFrame { + code: 1000, // code: Normal + reason: reason.clone().into(), + }))) + .await?; + + Ok(()) + } +} + +impl WsServerChannel { + /// Split into independently-owned send and receive halves, so a writer task and a reader loop + /// can run concurrently over one full-duplex socket. + pub fn split(self) -> (WsServerSink, WsServerStream) { + let (sink, stream) = self.inner.split(); + ( + WsServerSink { inner: sink }, + WsServerStream { inner: stream }, + ) + } +} + +/// Send half of a split [`WsServerChannel`]. +pub struct WsServerSink { + inner: SplitSink, +} + +impl WsServerSink { + pub async fn send(&mut self, message: Message) -> Result<(), WsServerError> { + self.inner.send(ws::Message::Binary(message.data)).await?; + Ok(()) + } + + pub async fn close(&mut self) -> Result<(), WsServerError> { + self.inner + .send(ws::Message::Close(Some(ws::CloseFrame { + code: 1000, // Normal + reason: "Peer is closing".to_string().into(), + }))) + .await?; + Ok(()) + } +} + +/// Receive half of a split [`WsServerChannel`]. +pub struct WsServerStream { + inner: SplitStream, +} + +impl WsServerStream { + /// Mirrors [`WsServerChannel::recv`]: surface binary frames, skip keepalive/text frames, and + /// treat a close frame or an exhausted stream as a clean end-of-stream. + pub async fn recv(&mut self) -> Result, WsServerError> { + loop { + match self.inner.next().await { + Some(Ok(ws::Message::Binary(data))) => return Ok(Some(Message { data })), + Some(Ok(ws::Message::Close(_close_frame))) => return Ok(None), + Some(Ok(ws::Message::Ping(_) | ws::Message::Pong(_) | ws::Message::Text(_))) => { + continue; + } + Some(Err(err)) => return Err(WsServerError::Axum(err)), + None => return Ok(None), + } + } + } +} + +#[derive(Debug)] +pub enum WsServerError { + Io(std::io::Error), + Axum(axum::Error), + UnknownMessage(String), + Other(String), +} + +impl CommunicationError for WsServerError {} + +impl From for WsServerError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for WsServerError { + fn from(err: axum::Error) -> Self { + Self::Axum(err) + } +} + +impl core::fmt::Display for WsServerError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Io(err) => write!(f, "io error: {err}"), + Self::Axum(err) => write!(f, "websocket error: {err}"), + Self::UnknownMessage(msg) => write!(f, "unknown message: {msg}"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for WsServerError {} diff --git a/crates/burn-communication/tests/websocket_server.rs b/crates/burn-communication/tests/websocket_server.rs new file mode 100644 index 0000000..7df5537 --- /dev/null +++ b/crates/burn-communication/tests/websocket_server.rs @@ -0,0 +1,367 @@ +//! Integration tests for the websocket [`WsServer`] channel implementation. +//! +//! These drive a real `WsServer` (bound to an ephemeral `127.0.0.1:0` port read back from the +//! listener) with a real `tokio-tungstenite` client, exercising the concurrency and +//! disconnect behaviour of the channel. Every await that could hang is wrapped in a timeout so +//! a regression fails fast instead of blocking CI. +//! +//! Run with: `cargo test -p burn-communication --features websocket`. +#![cfg(feature = "websocket")] + +use std::time::Duration; + +use burn_communication::websocket::{WsServer, WsServerChannel}; +use burn_communication::{CommunicationChannel, Message, ProtocolServer}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +/// Generous cap on every awaited operation: a passing test finishes in milliseconds; a +/// regression (panic, deadlock, dropped connection) trips the timeout instead of hanging. +const TIMEOUT: Duration = Duration::from_secs(10); + +type Client = WebSocketStream>; + +/// How a single server-side `recv` resolved — used by handlers that report back to the test. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Recv { + Message, + End, + Error, +} + +fn classify(result: Result, E>) -> Recv { + match result { + Ok(Some(_)) => Recv::Message, + Ok(None) => Recv::End, + Err(_) => Recv::Error, + } +} + +/// A running test server bound to an ephemeral port, with a shutdown the test controls. +struct TestServer { + port: u16, + shutdown: Option>, + handle: tokio::task::JoinHandle<()>, +} + +impl TestServer { + /// Bind an ephemeral port, let `build` register the routes, and serve on it. Binding + /// happens before the serve task is spawned, so the port is in the listen backlog by the + /// time this returns and clients can connect without a readiness race. + async fn start(build: impl FnOnce(WsServer) -> WsServer) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().expect("local_addr").port(); + + // The port passed to `new` is unused — we serve on our own pre-bound listener. + let server = build(WsServer::new(0)); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let shutdown = async move { + let _ = shutdown_rx.await; + }; + if let Err(err) = server.serve_on(listener, shutdown).await { + eprintln!("test server error: {err:?}"); + } + }); + + Self { + port, + shutdown: Some(shutdown_tx), + handle, + } + } + + fn url(&self, path: &str) -> String { + format!( + "ws://127.0.0.1:{}/{}", + self.port, + path.trim_start_matches('/') + ) + } + + async fn shutdown(mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + let _ = timeout(TIMEOUT, self.handle).await; + } +} + +/// A route handler that echoes every binary message back, looping until the stream ends. +async fn echo(mut channel: WsServerChannel) { + while let Ok(Some(msg)) = channel.recv().await { + if channel.send(msg).await.is_err() { + break; + } + } +} + +// --- client helpers ----------------------------------------------------------------------- + +async fn connect(url: &str) -> Client { + let (ws, _resp) = timeout(TIMEOUT, connect_async(url)) + .await + .expect("connect timed out") + .expect("connect failed"); + ws +} + +async fn send_binary(ws: &mut Client, data: &[u8]) { + timeout(TIMEOUT, ws.send(WsMessage::Binary(data.to_vec().into()))) + .await + .expect("send timed out") + .expect("send failed"); +} + +/// Read until the next binary frame, skipping pongs/pings/text the server may interleave. +async fn recv_binary(ws: &mut Client) -> Vec { + let fut = async { + loop { + match ws.next().await { + Some(Ok(WsMessage::Binary(data))) => return data.to_vec(), + Some(Ok(_)) => continue, + Some(Err(err)) => panic!("client recv error: {err:?}"), + None => panic!("client stream ended before a binary frame"), + } + } + }; + timeout(TIMEOUT, fut).await.expect("recv_binary timed out") +} + +/// Drive a clean websocket close handshake to completion from the client side. +async fn close_cleanly(mut ws: Client) { + let _ = ws.close(None).await; + // Drain so the server's close echo is read and the handshake completes on both sides. + while let Some(Ok(_)) = ws.next().await {} +} + +// --- tests -------------------------------------------------------------------------------- + +/// Many clients at once each get their own response — proves connections aren't serialized. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_connections_are_not_serialized() { + let server = TestServer::start(|s| s.route("/echo", echo)).await; + let url = server.url("echo"); + + // Spawn all clients eagerly (collect forces the spawns) so they run concurrently, then + // await them — rather than spawn-then-await one at a time, which would serialize them. + let clients: Vec<_> = (0..50) + .map(|i| { + let url = url.clone(); + tokio::spawn(async move { + let mut ws = connect(&url).await; + let payload = format!("hello-{i}").into_bytes(); + send_binary(&mut ws, &payload).await; + assert_eq!( + recv_binary(&mut ws).await, + payload, + "client {i} echo mismatch" + ); + close_cleanly(ws).await; + }) + }) + .collect(); + + for (i, client) in clients.into_iter().enumerate() { + timeout(TIMEOUT, client) + .await + .unwrap_or_else(|_| panic!("client {i} timed out")) + .unwrap_or_else(|_| panic!("client {i} panicked")); + } + + server.shutdown().await; +} + +/// One peer vanishing abruptly (TCP drop, no close frame) must not take down the others. +/// Regression for bug #1: the old `None => todo!()` would panic the handler on disconnect. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn abrupt_disconnect_does_not_affect_other_connections() { + // Echo handler that also reports how its stream ended, so the test can assert the + // disconnected peer's handler terminated rather than panicked or hung. + let (report_tx, mut report_rx) = mpsc::unbounded_channel::(); + let server = TestServer::start(move |s| { + s.route("/echo", move |mut channel: WsServerChannel| { + let report = report_tx.clone(); + async move { + loop { + match channel.recv().await { + Ok(Some(msg)) => { + if channel.send(msg).await.is_err() { + let _ = report.send(Recv::Error); + return; + } + } + Ok(None) => { + let _ = report.send(Recv::End); + return; + } + Err(_) => { + let _ = report.send(Recv::Error); + return; + } + } + } + } + }) + }) + .await; + let url = server.url("echo"); + + let mut a = connect(&url).await; + let mut b = connect(&url).await; + + // Both work to start with. + send_binary(&mut a, b"a1").await; + assert_eq!(recv_binary(&mut a).await, b"a1"); + send_binary(&mut b, b"b1").await; + assert_eq!(recv_binary(&mut b).await, b"b1"); + + // Abruptly drop A's TCP connection — no websocket close handshake. + drop(a); + + // A's handler must terminate (without panicking); with the `todo!()` bug it would panic on + // the bare-None path and never report, tripping this timeout. + let ended = timeout(TIMEOUT, report_rx.recv()) + .await + .expect("A's handler did not terminate — it likely panicked") + .expect("report channel closed"); + assert!( + matches!(ended, Recv::End | Recv::Error), + "A's handler ended in an unexpected state: {ended:?}" + ); + + // B must still be alive and responsive after A vanished. + send_binary(&mut b, b"b2").await; + assert_eq!( + recv_binary(&mut b).await, + b"b2", + "B died after A disconnected" + ); + + close_cleanly(b).await; + server.shutdown().await; +} + +/// Hit the bare end-of-stream arm directly. Regression for bug #1: a second `recv` after the +/// stream has terminated yields `None`, which the old `todo!()` turned into a panic. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn stream_end_after_close_does_not_panic() { + let (report_tx, mut report_rx) = mpsc::unbounded_channel::<(Recv, Recv)>(); + let server = TestServer::start(move |s| { + s.route("/drain", move |mut channel: WsServerChannel| { + let report = report_tx.clone(); + async move { + // First recv sees the client's Close -> Ok(None). The stream is then + // terminated, so the second recv hits the bare-None arm -> Ok(None) with the + // fix, panic with the old `todo!()`. + let first = classify(channel.recv().await); + let second = classify(channel.recv().await); + let _ = report.send((first, second)); + } + }) + }) + .await; + + let ws = connect(&server.url("drain")).await; + close_cleanly(ws).await; + + let (first, second) = timeout(TIMEOUT, report_rx.recv()) + .await + .expect("/drain handler did not finish — it likely panicked on the bare-None path") + .expect("report channel closed"); + assert_eq!( + first, + Recv::End, + "close frame should make the first recv return Ok(None)" + ); + assert_eq!( + second, + Recv::End, + "a terminated stream should make recv return Ok(None), not panic" + ); + + server.shutdown().await; +} + +/// A keepalive ping must be skipped, not treated as a fatal error. Regression for bug #2: +/// the old catch-all turned Ping/Pong/Text into `Err(UnknownMessage)`, killing the connection. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn keepalive_ping_does_not_kill_connection() { + let server = TestServer::start(|s| s.route("/echo", echo)).await; + let mut ws = connect(&server.url("echo")).await; + + // Send a keepalive ping, then a real message; the echo must still come back. + timeout( + TIMEOUT, + ws.send(WsMessage::Ping(b"keepalive".to_vec().into())), + ) + .await + .expect("ping send timed out") + .expect("ping send failed"); + + send_binary(&mut ws, b"after-ping").await; + assert_eq!( + recv_binary(&mut ws).await, + b"after-ping", + "connection died after a keepalive ping" + ); + + close_cleanly(ws).await; + server.shutdown().await; +} + +/// A client-initiated close makes the server's `recv` return `Ok(None)` and the handler end. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn normal_close_ends_recv_cleanly() { + let (report_tx, mut report_rx) = mpsc::unbounded_channel::(); + let server = TestServer::start(move |s| { + s.route("/echo", move |mut channel: WsServerChannel| { + let report = report_tx.clone(); + async move { + loop { + match channel.recv().await { + Ok(Some(msg)) => { + if channel.send(msg).await.is_err() { + let _ = report.send(Recv::Error); + return; + } + } + Ok(None) => { + let _ = report.send(Recv::End); + return; + } + Err(_) => { + let _ = report.send(Recv::Error); + return; + } + } + } + } + }) + }) + .await; + + let mut ws = connect(&server.url("echo")).await; + send_binary(&mut ws, b"ping").await; + assert_eq!(recv_binary(&mut ws).await, b"ping"); + close_cleanly(ws).await; + + let ended = timeout(TIMEOUT, report_rx.recv()) + .await + .expect("handler did not finish after close") + .expect("report channel closed"); + assert_eq!( + ended, + Recv::End, + "a client Close should make recv return Ok(None)" + ); + + server.shutdown().await; +} diff --git a/crates/burn-core/Cargo.toml b/crates/burn-core/Cargo.toml new file mode 100644 index 0000000..62dd13b --- /dev/null +++ b/crates/burn-core/Cargo.toml @@ -0,0 +1,133 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Flexible and Comprehensive Deep Learning Framework in Rust" +documentation = "https://docs.rs/burn-core" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-core" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-core" +version.workspace = true + +[lints] +workspace = true + +[features] +default = [ + "std", + "burn-std/default", + "burn-dataset?/default", + "burn-tensor/default", +] +doc = [ + "std", + "dataset", + "audio", + # Doc features + "burn-std/doc", + "burn-dataset/doc", + "burn-tensor/doc", + "dep:regex" +] +tracing = ["burn-std/tracing", "burn-tensor/tracing", "burn-dataset?/tracing"] + +dataset = ["burn-dataset"] + +network = ["burn-std/network"] +sqlite = ["burn-dataset?/sqlite"] +sqlite-bundled = ["burn-dataset?/sqlite-bundled"] +std = [ + "burn-std/std", + "burn-pack/std", + "burn-tensor/std", + "half/std", + "log", + "rand/std", + "serde/std", + "serde_json/std", + "num-traits/std", +] +vision = ["burn-dataset?/vision"] +audio = ["burn-dataset?/audio"] + +# Backends +remote = ["burn-tensor/remote"] +remote-server = ["burn-tensor/remote-server"] +remote-websocket = ["burn-tensor/remote-websocket"] +cuda = ["burn-tensor/cuda"] +rocm = ["burn-tensor/rocm"] +flex = ["burn-tensor/flex"] +ndarray = ["burn-tensor/ndarray"] +tch = ["burn-tensor/tch"] +vulkan = ["burn-tensor/vulkan"] +webgpu = ["burn-tensor/webgpu"] +wgpu = ["burn-tensor/wgpu"] +metal = ["burn-tensor/metal"] +cpu = ["burn-tensor/cpu"] +autodiff = ["burn-tensor/autodiff"] +ir = ["burn-ir"] + +# Backend features +autotune = ["burn-tensor/autotune"] +autotune-checks = ["burn-tensor/autotune-checks"] +fusion = ["burn-tensor/fusion"] + +# Backend extensions +# burn-dispatch features are enabled from burn-tensor, we just re-export the public types +extension = ["burn-tensor/extension", "burn-backend-extension", "burn-dispatch", "burn-backend"] + +[dependencies] + +# ** Please make sure all dependencies support no_std when std is disabled ** + +burn-std = { workspace = true } +burn-dataset = { workspace = true, optional = true } +burn-derive = { workspace = true } +burn-pack = { workspace = true, default-features = false } +burn-tensor = { workspace = true } + +# Backend extensions +burn-backend = { workspace = true, optional = true } +burn-dispatch = { workspace = true, optional = true } +burn-backend-extension = { workspace = true, optional = true } +burn-ir = { workspace = true, optional = true } + +derive-new = { workspace = true } +log = { workspace = true, optional = true } +rand = { workspace = true } + +# The same implementation of HashMap in std but with no_std support (only alloc crate is needed) +hashbrown = { workspace = true, features = ["serde"] } # no_std compatible + +# Serialize Deserialize +serde = { workspace = true, features = ["derive"] } + +ahash = { workspace = true } +half = { workspace = true } +num-traits = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } #Default enables std +spin = { workspace = true } # Using in place of use std::sync::Mutex when std is disabled +thiserror = { workspace = true, optional = true } +regex = { workspace = true, optional = true } + +[target.'cfg(target_has_atomic = "ptr")'.dependencies] +regex = { workspace = true } + +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +portable-atomic-util = { workspace = true } +portable-atomic = { workspace = true } + +[dev-dependencies] +burn-dataset = { workspace = true, features = ["fake"] } +rstest = { workspace = true } +serial_test = { workspace = true } +tempfile = { workspace = true } + +# Default device for tests +burn-tensor = { workspace = true, features = ["flex"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-core/LICENSE-APACHE b/crates/burn-core/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-core/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-core/LICENSE-MIT b/crates/burn-core/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-core/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-core/README.md b/crates/burn-core/README.md new file mode 100644 index 0000000..5790128 --- /dev/null +++ b/crates/burn-core/README.md @@ -0,0 +1,14 @@ +# Burn Core + +This crate should be used with [burn](https://github.com/tracel-ai/burn). It contains the core +traits and components for building and training deep learning models with Burn. + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-core.svg)](https://crates.io/crates/burn-core) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-core/blob/master/README.md) + +## Feature Flags + +This crate can be used without the standard library (`#![no_std]`) with `alloc` by disabling the +default `std` feature. + +- `std` - enables the standard library. Enabled by default. diff --git a/crates/burn-core/src/backend.rs b/crates/burn-core/src/backend.rs new file mode 100644 index 0000000..1103c38 --- /dev/null +++ b/crates/burn-core/src/backend.rs @@ -0,0 +1,39 @@ +#[cfg(feature = "ir")] +pub use burn_ir as ir; + +pub use burn_backend::*; +pub use burn_backend_extension::*; + +// Dispatch backend extension types +pub use burn_dispatch::{backend::*, device::*, tensor::*}; +// Re-export backends (e.g., Cuda) +pub use burn_dispatch::backends::*; + +/// A trait to allow mapping custom backend-specific structures into their generic dispatch equivalents. +/// +/// This trait is designed to cooperate with the [`#[backend_extension]`](backend_extension) macro. When an +/// extension operation returns a custom struct containing multiple tensor primitives rather than a single +/// output tensor primitive, this trait provides the mechanism to traverse that struct and recursively wrap +/// each internal field into a [`DispatchTensor`]. +/// +/// Implementations of this trait are generated automatically using `#[derive(ExtensionType)]`. +pub trait ExtensionType { + /// The target struct layout where all internal concrete backend tensors are transformed + /// into [`DispatchTensor`]s. + type Target; + + /// Transforms the internal fields of the struct by applying a backend-specific wrapping closure. + /// + /// # Arguments + /// + /// * `map_kind` - A closure provided by the dispatch macro that knows how to map a backend-agnostic + /// [`BackendTensor`] variant into the correct [`DispatchTensorKind`] variant (e.g., `Wgpu`, `Cuda`, `Cpu`). + /// * `checkpointing` - The active tensor recording/checkpointing strategy to attach to the [`DispatchTensor`]. + /// + /// # Returns + /// + /// A new instance of the struct mapped to the [`Dispatch`] backend. + fn map_type(self, map_kind: F, checkpointing: Option) -> Self::Target + where + F: Fn(BackendTensor) -> DispatchTensorKind; +} diff --git a/crates/burn-core/src/config.rs b/crates/burn-core/src/config.rs new file mode 100644 index 0000000..0b9d5c9 --- /dev/null +++ b/crates/burn-core/src/config.rs @@ -0,0 +1,98 @@ +use alloc::{format, string::String, string::ToString}; +pub use burn_derive::Config; +use core::fmt::Debug; + +/// Configuration IO error. +#[derive(Debug)] +pub enum ConfigError { + /// Invalid format. + InvalidFormat(String), + + /// File not found. + FileNotFound(String), +} + +impl core::fmt::Display for ConfigError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut message = "Config error => ".to_string(); + + match self { + Self::InvalidFormat(err) => { + message += format!("Invalid format: {err}").as_str(); + } + Self::FileNotFound(err) => { + message += format!("File not found: {err}").as_str(); + } + }; + + f.write_str(message.as_str()) + } +} + +impl core::error::Error for ConfigError {} + +/// Configuration trait. +pub trait Config: Debug + serde::Serialize + serde::de::DeserializeOwned { + /// Saves the configuration to a file. + /// + /// # Arguments + /// + /// * `file` - File to save the configuration to. + /// + /// # Returns + /// + /// The output of the save operation. + #[cfg(feature = "std")] + fn save>(&self, file: P) -> std::io::Result<()> { + std::fs::write(file, config_to_json(self)) + } + + /// Loads the configuration from a file. + /// + /// # Arguments + /// + /// * `file` - File to load the configuration from. + /// + /// # Returns + /// + /// The loaded configuration. + #[cfg(feature = "std")] + fn load>(file: P) -> Result { + let content = std::fs::read_to_string(file.as_ref()) + .map_err(|_| ConfigError::FileNotFound(file.as_ref().to_string_lossy().to_string()))?; + config_from_str(&content) + } + + /// Loads the configuration from a binary buffer. + /// + /// # Arguments + /// + /// * `data` - Binary buffer to load the configuration from. + /// + /// # Returns + /// + /// The loaded configuration. + fn load_binary(data: &[u8]) -> Result { + let content = core::str::from_utf8(data).map_err(|_| { + ConfigError::InvalidFormat("Could not parse data as utf-8.".to_string()) + })?; + config_from_str(content) + } +} + +/// Converts a configuration to a JSON string. +/// +/// # Arguments +/// +/// * `config` - Configuration to convert. +/// +/// # Returns +/// +/// The JSON string. +pub fn config_to_json(config: &C) -> String { + serde_json::to_string_pretty(config).unwrap() +} + +fn config_from_str(content: &str) -> Result { + serde_json::from_str(content).map_err(|err| ConfigError::InvalidFormat(format!("{err}"))) +} diff --git a/crates/burn-core/src/data/dataloader/base.rs b/crates/burn-core/src/data/dataloader/base.rs new file mode 100644 index 0000000..53b32f9 --- /dev/null +++ b/crates/burn-core/src/data/dataloader/base.rs @@ -0,0 +1,52 @@ +use burn_tensor::Device; + +pub use crate::data::dataset::{Dataset, DatasetIterator}; +use core::iter::Iterator; +use std::sync::Arc; + +/// A progress struct that can be used to track the progress of a data loader. +#[derive(new, Clone, Debug)] +pub struct Progress { + /// The number of items that have been processed. + pub items_processed: usize, + + /// The total number of items that need to be processed. + pub items_total: usize, + + /// The unit of measurement for the progress (e.g., "items", "batches"). + pub unit: Option, +} + +/// A data loader iterator that can be used to iterate over a data loader. +pub trait DataLoaderIterator: Iterator { + /// Returns the progress of the data loader. + fn progress(&self) -> Progress; +} + +/// A data loader that can be used to iterate over a dataset. +pub trait DataLoader: Send + Sync { + /// Returns a boxed [iterator](DataLoaderIterator) to iterate over the data loader. + fn iter<'a>(&'a self) -> Box + 'a>; + + /// The number of items (not the number of batches nor the number of iterations), + /// corresponding to the items_total of the progress returned by the iterator. + fn num_items(&self) -> usize; + + /// Move the data loader to the given device, ensuring the batches are assigned to the correct device. + fn to_device(&self, device: &Device) -> Arc>; + + /// Returns a new data loader containing a subset of the data. + /// + /// The subset includes items from `start` (inclusive) to `end` (exclusive), + /// preserving the batch size and ordering of the original data loader. + /// + /// # Arguments + /// + /// * `start` - The starting index of the subset (inclusive). + /// * `end` - The ending index of the subset (exclusive). + /// + /// # Returns + /// + /// A boxed [`DataLoader`] instance containing only the specified range. + fn slice(&self, start: usize, end: usize) -> Arc>; +} diff --git a/crates/burn-core/src/data/dataloader/batch.rs b/crates/burn-core/src/data/dataloader/batch.rs new file mode 100644 index 0000000..b4b5501 --- /dev/null +++ b/crates/burn-core/src/data/dataloader/batch.rs @@ -0,0 +1,260 @@ +use super::{BatchStrategy, DataLoader, DataLoaderIterator, Progress, batcher::Batcher}; +use burn_dataset::{ + Dataset, + transform::{PartialDataset, ShuffledDataset}, +}; +use burn_tensor::Device; +use rand::SeedableRng; +use std::ops::DerefMut; +use std::sync::Arc; + +/// A data loader that can be used to iterate over a dataset in batches. +pub struct BatchDataLoader { + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + device: Device, + rng: Option>>, +} + +impl Clone for BatchDataLoader { + fn clone(&self) -> Self { + Self { + strategy: self.strategy.clone_dyn(), + dataset: self.dataset.clone(), + batcher: self.batcher.clone(), + device: self.device.clone(), + rng: self.rng.clone(), + } + } +} + +impl BatchDataLoader { + /// Creates a new batch data loader. + /// + /// # Arguments + /// + /// * `strategy` - The batch strategy. + /// * `dataset` - The dataset. + /// * `batcher` - The batcher. + /// * `device` - The device to use when loading a batch. + /// * `rng` - The rng determining if the dataset is shuffled each time a dataloader + /// iterator is created. + /// + /// # Returns + /// + /// The batch data loader. + pub fn new( + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + device: Device, + rng: Option, + ) -> Self { + Self { + strategy, + dataset, + batcher, + device, + rng: rng.map(|rng| Arc::new(spin::Mutex::new(rng))), + } + } +} + +/// A data loader iterator that can be used to iterate over a data loader. +struct BatchDataloaderIterator { + current_index: usize, + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + device: Device, +} + +impl DataLoader for BatchDataLoader +where + I: Send + Sync + Clone + 'static, + O: Send + 'static, +{ + fn iter<'a>(&'a self) -> Box + 'a> { + // When starting a new iteration, we first check if the dataloader was created with an rng, + // implying that we should shuffle the dataset beforehand, while advancing the current + // rng to ensure that each new iteration shuffles the dataset differently. + let dataset = match &self.rng { + Some(rng) => Arc::new(ShuffledDataset::new( + self.dataset.clone(), + rng.lock().deref_mut(), + )), + None => self.dataset.clone(), + }; + Box::new(BatchDataloaderIterator::new( + self.strategy.clone_dyn(), + dataset, + self.batcher.clone(), + self.device.clone(), + )) + } + + fn num_items(&self) -> usize { + self.dataset.len() + } + + fn to_device(&self, device: &Device) -> Arc> { + let rng = self.rng.as_ref().map(|rng| { + let mut rng = rng.lock(); + rng.fork() + }); + Arc::new(Self::new( + self.strategy.clone_dyn(), + self.dataset.clone(), + self.batcher.clone(), + device.clone(), + rng, + )) + } + + fn slice(&self, start: usize, end: usize) -> Arc> { + let rng = self.rng.as_ref().map(|rng| { + let mut rng = rng.lock(); + rng.fork() + }); + let dataloader = Self::new( + self.strategy.clone_dyn(), + Arc::new(PartialDataset::new(self.dataset.clone(), start, end)), + self.batcher.clone(), + self.device.clone(), + rng, + ); + Arc::new(dataloader) + } +} + +impl BatchDataloaderIterator { + /// Creates a new batch data loader iterator. + /// + /// # Arguments + /// + /// * `strategy` - The batch strategy. + /// * `dataset` - The dataset. + /// * `batcher` - The batcher. + /// * `device` - The device to use when loading a batch. + /// + /// # Returns + /// + /// The batch data loader iterator. + pub fn new( + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + device: Device, + ) -> Self { + BatchDataloaderIterator { + current_index: 0, + strategy, + dataset, + batcher, + device, + } + } +} + +impl Iterator for BatchDataloaderIterator { + type Item = O; + + fn next(&mut self) -> Option { + while let Some(item) = self.dataset.get(self.current_index) { + self.current_index += 1; + self.strategy.add(item); + + if let Some(items) = self.strategy.batch(false) { + return Some(self.batcher.batch(items, &self.device)); + } + } + + if let Some(items) = self.strategy.batch(true) { + return Some(self.batcher.batch(items, &self.device)); + } + + None + } +} + +impl DataLoaderIterator for BatchDataloaderIterator { + fn progress(&self) -> Progress { + let unit: Option = Some("items".to_string()); + + Progress::new(self.current_index, self.dataset.len(), unit) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use crate::data::dataloader::FixBatchStrategy; + use crate::data::dataloader::batcher::TestBatcher; + use crate::data::dataset::FakeDataset; + + #[test] + fn test_batch_dataloader() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + let dataloader = BatchDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset.clone(), + batcher, + Default::default(), + None, + ); + + let mut items_dataset = HashSet::new(); + let mut items_dataloader = HashSet::new(); + + for item in dataset.iter() { + items_dataset.insert(item); + } + + for items in dataloader.iter() { + for item in items { + items_dataloader.insert(item); + } + } + + assert_eq!(items_dataset, items_dataloader); + } + + #[test] + fn test_batch_dataloader_slice() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + let dataloader = BatchDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset.clone(), + batcher, + Default::default(), + None, + ); + let dataloader_slice = dataloader.slice(5, 15); + + let mut items_dataloader = HashSet::new(); + let mut items_dataloader_slice = HashSet::new(); + + let mut idx = 0; + for items in dataloader.iter() { + for item in items { + if (5..15).contains(&idx) { + items_dataloader.insert(item); + } + idx += 1; + } + } + + for items in dataloader_slice.iter() { + for item in items { + items_dataloader_slice.insert(item); + } + } + + assert_eq!(items_dataloader, items_dataloader_slice); + } +} diff --git a/crates/burn-core/src/data/dataloader/batcher.rs b/crates/burn-core/src/data/dataloader/batcher.rs new file mode 100644 index 0000000..0dcd0c2 --- /dev/null +++ b/crates/burn-core/src/data/dataloader/batcher.rs @@ -0,0 +1,28 @@ +use burn_tensor::Device; + +/// A trait for batching items of type `I` into items of type `O`. +pub trait Batcher: Send + Sync { + /// Batches the given items on the specified device. + /// + /// # Arguments + /// + /// * `items` - The items to batch. + /// * `device` - The backend device to use. + /// + /// # Returns + /// + /// The batched items. + fn batch(&self, items: Vec, device: &Device) -> O; +} + +/// Test batcher +#[cfg(test)] +#[derive(new, Clone)] +pub struct TestBatcher; + +#[cfg(test)] +impl Batcher> for TestBatcher { + fn batch(&self, items: Vec, _device: &Device) -> Vec { + items + } +} diff --git a/crates/burn-core/src/data/dataloader/builder.rs b/crates/burn-core/src/data/dataloader/builder.rs new file mode 100644 index 0000000..ce74c72 --- /dev/null +++ b/crates/burn-core/src/data/dataloader/builder.rs @@ -0,0 +1,240 @@ +use super::{ + BatchDataLoader, BatchStrategy, DataLoader, FixBatchStrategy, MultiThreadDataLoader, + batcher::Batcher, +}; +use burn_dataset::Dataset; +use burn_tensor::Device; +use rand::{SeedableRng, rngs::StdRng}; +use std::sync::Arc; + +/// A builder for data loaders. +pub struct DataLoaderBuilder { + strategy: Option>>, + batcher: Arc>, + num_threads: Option, + shuffle: Option, + device: Option, +} + +impl DataLoaderBuilder +where + I: Send + Sync + Clone + std::fmt::Debug + 'static, + O: Send + Clone + std::fmt::Debug + 'static, +{ + /// Creates a new data loader builder. + /// + /// # Arguments + /// + /// * `batcher` - The batcher. + /// + /// # Returns + /// + /// The data loader builder. + pub fn new(batcher: Bt) -> Self + where + Bt: Batcher + 'static, + { + Self { + batcher: Arc::new(batcher), + strategy: None, + num_threads: None, + shuffle: None, + device: None, + } + } + + /// Sets the batch size to a fix number. + /// + /// The [fix batch strategy](FixBatchStrategy) will be used. + /// + /// # Arguments + /// + /// * `batch_size` - The batch size. + /// + /// # Returns + /// + /// The data loader builder. + pub fn batch_size(mut self, batch_size: usize) -> Self { + self.strategy = Some(Box::new(FixBatchStrategy::new(batch_size))); + self + } + + /// Sets the seed for shuffling. + /// + /// Each time the dataloader starts a new iteration, the dataset will be shuffled. + /// + /// # Arguments + /// + /// * `seed` - The seed. + /// + /// # Returns + /// + /// The data loader builder. + pub fn shuffle(mut self, seed: u64) -> Self { + self.shuffle = Some(seed); + self + } + + /// Sets the number of workers. + /// + /// - `Some(0)` or `None`: the dataloader will run without work threads. + /// - `Some(n); n > 0`: the dataloader will run with `n` background threads. + /// + /// A 1-worker threaded dataloader will run loads in a background thread, + /// while a 0-worker threaded dataloader will run loads in the main thread. + /// + /// # Arguments + /// + /// * `num_workers` - The number of workers. + /// + /// # Returns + /// + /// The data loader builder. + pub fn num_workers(mut self, num_workers: usize) -> Self { + self.num_threads = Some(num_workers); + self + } + + /// Sets the data loader device. + /// + /// # Arguments + /// + /// * `device` - The device to use when loading a batch. + /// + /// # Returns + /// + /// The data loader builder. + pub fn set_device(mut self, device: Device) -> Self { + self.device = Some(device); + self + } + + /// Builds the data loader. + /// + /// # Arguments + /// + /// * `dataset` - The dataset. + /// + /// # Returns + /// + /// The data loader. + pub fn build(self, dataset: D) -> Arc> + where + D: Dataset + 'static, + { + let dataset = Arc::new(dataset); + + let device = self.device.unwrap_or_default(); + let rng = self.shuffle.map(StdRng::seed_from_u64); + let strategy = match self.strategy { + Some(strategy) => strategy, + None => Box::new(FixBatchStrategy::new(1)), + }; + + if let Some(num_threads) = self.num_threads + && num_threads > 0 + { + return Arc::new(MultiThreadDataLoader::new( + strategy, + dataset, + self.batcher, + num_threads, + device, + rng, + )); + } + + Arc::new(BatchDataLoader::new( + strategy, + dataset, + self.batcher, + device, + rng, + )) + } +} + +#[cfg(test)] +mod tests { + #[cfg(test)] + use burn_tensor::Device; + + use super::*; + use crate::data::dataset::FakeDataset; + + #[derive(new, Clone)] + struct TestBatcherDevice; + + #[cfg(test)] + impl Batcher for TestBatcherDevice { + fn batch(&self, _items: Vec, device: &Device) -> Device { + device.clone() + } + } + + #[test] + fn test_dataloader_no_workers() { + let default_device = Device::default(); + let dataloader = DataLoaderBuilder::new(TestBatcherDevice::new()) + .batch_size(1) + .build(FakeDataset::::new(9)); + + assert_eq!(dataloader.num_items(), 9); + + for device in dataloader.iter() { + assert_eq!(device, default_device) + } + } + + #[test] + fn test_dataloader_default_device() { + let default_device = Device::default(); + let dataloader = DataLoaderBuilder::new(TestBatcherDevice::new()) + .batch_size(1) + .num_workers(1) + .build(FakeDataset::::new(9)); + + assert_eq!(dataloader.num_items(), 9); + + for device in dataloader.iter() { + assert_eq!(device, default_device) + } + } + + #[test] + fn test_dataloader_slice_multi_device() { + let dataloader = DataLoaderBuilder::new(TestBatcherDevice::new()) + .batch_size(1) + .num_workers(1) + .build(FakeDataset::::new(11)); + + #[cfg(all(test, not(feature = "tch"), not(feature = "cuda")))] + // Only one device exists... + let (device1, device2) = (Device::flex(), Device::flex()); + + #[cfg(all(test, feature = "tch"))] + let (device1, device2) = (Device::libtorch_cuda(0), Device::libtorch_cuda(1)); + + #[cfg(all(test, feature = "cuda"))] + let (device1, device2) = (Device::cuda(0), Device::cuda(1)); + + assert_eq!(dataloader.num_items(), 11); + let dataloader_1 = dataloader.slice(0, 5).to_device(&device1); + let dataloader_2 = dataloader.slice(5, 11).to_device(&device2); + + assert_eq!(dataloader_1.num_items(), 5); + assert_eq!(dataloader_2.num_items(), 6); + + let (mut iterator_1, mut iterator_2) = (dataloader_1.iter(), dataloader_2.iter()); + + for _ in 0..5 { + assert_eq!(iterator_1.next().as_ref(), Some(&device1)); + assert_eq!(iterator_2.next().as_ref(), Some(&device2)); + } + + assert_eq!(iterator_1.next(), None); + // For uneven split, the last dataloader (partial dataset) will have the remaining item + assert_eq!(iterator_2.next(), Some(device2)); + assert_eq!(iterator_2.next(), None); + } +} diff --git a/crates/burn-core/src/data/dataloader/mod.rs b/crates/burn-core/src/data/dataloader/mod.rs new file mode 100644 index 0000000..52556af --- /dev/null +++ b/crates/burn-core/src/data/dataloader/mod.rs @@ -0,0 +1,16 @@ +mod base; +mod batch; +mod builder; +mod multithread; +mod strategy; + +/// Module for batching items. +pub mod batcher; +/// Module to split a dataloader. +pub mod split; + +pub use base::*; +pub use batch::*; +pub use builder::*; +pub use multithread::*; +pub use strategy::*; diff --git a/crates/burn-core/src/data/dataloader/multithread.rs b/crates/burn-core/src/data/dataloader/multithread.rs new file mode 100644 index 0000000..70ca69d --- /dev/null +++ b/crates/burn-core/src/data/dataloader/multithread.rs @@ -0,0 +1,553 @@ +use burn_dataset::Dataset; +use burn_dataset::transform::PartialDataset; +use burn_tensor::Device; +use rand::distr::{Distribution, StandardUniform}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +use super::batcher::Batcher; +use super::{BatchDataLoader, BatchStrategy, DataLoader, DataLoaderIterator, Progress}; +use std::sync::{Arc, OnceLock, mpsc, mpsc::SyncSender}; +use std::thread; + +const MAX_QUEUED_ITEMS: usize = 100; + +type RngSeed = ::Seed; + +/// A multi-threaded data loader that can be used to iterate over a dataset. +pub struct MultiThreadDataLoader { + // Configuration parameters needed for initialization + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + device: Device, + seed: Option, + num_threads: usize, + + // The lazily initialized data loaders + dataloaders: OnceLock>>, + + // Spawned once and reused across every `iter()` call so each worker keeps a + // stable CubeCL stream (and its memory pool) instead of leaking one per epoch (#4792). + workers: OnceLock>, +} + +/// A message that can be sent between threads. +#[derive(Debug)] +pub enum Message { + /// A batch of items. + Batch(usize, O, Progress), + + /// The thread is done. + Done, +} + +struct MultiThreadsDataloaderIterator { + num_done: usize, + num_workers: usize, + receiver: mpsc::Receiver>, + progresses: Vec, +} + +/// Per-epoch channel a worker streams its batches into; handed to the worker to start a pass. +type WorkerCommand = SyncSender>; + +struct WorkerPool { + /// One command channel per worker; sending a per-epoch sender starts a pass. + senders: Vec>>, + handles: Vec>, + item_counts: Vec, +} + +impl Drop for WorkerPool { + fn drop(&mut self) { + // Dropping the senders makes each worker's `recv()` return Err, ending its loop. + self.senders.clear(); + for handle in self.handles.drain(..) { + let _ = handle.join(); + } + } +} + +impl MultiThreadDataLoader +where + I: Send + Sync + Clone + 'static, + O: Send + 'static, +{ + /// Creates a new multi-threaded batch data loader. + /// + /// # Arguments + /// + /// * `strategy` - The batch strategy. + /// * `dataset` - The dataset. + /// * `batcher` - The batcher. + /// * `num_threads` - The number of threads. + /// * `device` - The device to use when loading a batch. + /// * `rng` - The rng determining if the dataset is shuffled each time a dataloader + /// iterator is created. + /// + /// # Returns + /// + /// The multi-threaded batch data loader. + pub fn new( + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + num_threads: usize, + device: Device, + rng: Option, + ) -> Self { + let mut seed = None; + if let Some(mut rng) = rng { + // RNG stream splitting (not state cloning): derive a new seed from the RNG's output. + // This is exactly what `rng.fork()` does. + let mut s = RngSeed::default(); + rng.fill_bytes(&mut s); + + seed = Some(s); + } + Self::from_seed(strategy, dataset, batcher, num_threads, device, seed) + } + + fn from_seed( + strategy: Box>, + dataset: Arc>, + batcher: Arc>, + num_threads: usize, + device: Device, + seed: Option, + ) -> Self { + Self { + strategy, + dataset, + batcher, + num_threads, + device, + seed, + dataloaders: OnceLock::new(), + workers: OnceLock::new(), + } + } + + /// Force initialization if needed. + fn initialize(&self) -> &[BatchDataLoader] { + self.dataloaders + .get_or_init(|| { + let mut dataset = self.dataset.clone(); + if let Some(seed) = self.seed.as_ref() { + // Pre-shuffle the dataset before split if shuffle is enabled. + // This ensures that each thread gets a uniform random sample of the dataset. + let mut rng = StdRng::from_seed(*seed); + dataset = Arc::new(burn_dataset::transform::ShuffledDataset::new( + dataset, &mut rng, + )); + } + + let datasets = match self.strategy.batch_size() { + Some(batch_size) => { + PartialDataset::split_chunks(dataset, self.num_threads, batch_size) + } + None => PartialDataset::split(dataset, self.num_threads), + }; + + // Create more rngs from the first one, one for each new dataloader. + let mut rng = self.seed.map(StdRng::from_seed); + let rngs = (0..self.num_threads).map(|_| { + rng.as_mut().map(|rng| { + StdRng::seed_from_u64(Distribution::sample(&StandardUniform, rng)) + }) + }); + + datasets + .into_iter() + .zip(rngs) + .map(|(dataset, rng)| { + let strategy = self.strategy.clone_dyn(); + BatchDataLoader::new( + strategy, + Arc::new(dataset), + self.batcher.clone(), + self.device.clone(), + rng, + ) + }) + .collect() + }) + .as_ref() + } + + /// Lazily spawns the persistent worker pool (once) and returns it. + fn workers(&self) -> &WorkerPool { + self.workers.get_or_init(|| { + let dataloaders = self.initialize(); + let item_counts: Vec = dataloaders.iter().map(|d| d.num_items()).collect(); + + let mut senders = Vec::with_capacity(dataloaders.len()); + let mut handles = Vec::with_capacity(dataloaders.len()); + + for (index, dataloader) in dataloaders.iter().enumerate() { + let dataloader = dataloader.clone(); + let (command_sender, command_receiver) = mpsc::channel::>(); + + let handle = thread::Builder::new() + .name(std::format!("dataloader-{index}")) + .spawn(move || { + while let Ok(sender) = command_receiver.recv() { + let mut iterator = dataloader.iter(); + while let Some(item) = iterator.next() { + let progress = iterator.progress(); + + // Consumer dropped the receiver: abandon this pass, + // don't terminate the worker. + if sender.send(Message::Batch(index, item, progress)).is_err() { + break; + } + } + sender.send(Message::Done).ok(); + } + }) + .unwrap(); + + senders.push(command_sender); + handles.push(handle); + } + + WorkerPool { + senders, + handles, + item_counts, + } + }) + } +} + +impl DataLoader for MultiThreadDataLoader +where + I: Send + Sync + Clone + 'static, + O: Send + 'static + std::fmt::Debug, +{ + fn iter<'a>(&'a self) -> Box + 'a> { + let workers = self.workers(); + + let (sender, receiver) = mpsc::sync_channel::>(MAX_QUEUED_ITEMS); + let unit: Option = Some("items".to_string()); + + let mut progresses = Vec::with_capacity(workers.senders.len()); + for (command_sender, &num_items) in workers.senders.iter().zip(workers.item_counts.iter()) { + progresses.push(Progress::new(0, num_items, unit.clone())); + command_sender + .send(sender.clone()) + .expect("Dataloader worker thread should be alive"); + } + let num_workers = workers.senders.len(); + + // Drop our sender so the channel disconnects once every worker is done. + drop(sender); + + Box::new(MultiThreadsDataloaderIterator::new( + receiver, + num_workers, + progresses, + )) + } + + fn num_items(&self) -> usize { + // For num_items, we can directly use the dataset size without + // necessarily initializing the full loader + self.dataset.len() + } + + fn to_device(&self, device: &Device) -> Arc> { + Arc::new(Self::from_seed( + self.strategy.clone_dyn(), + self.dataset.clone(), + self.batcher.clone(), + self.num_threads, + device.clone(), + self.seed, + )) + } + + fn slice(&self, start: usize, end: usize) -> Arc> { + let dataloader = Self::from_seed( + self.strategy.clone_dyn(), + Arc::new(PartialDataset::new(self.dataset.clone(), start, end)), + self.batcher.clone(), + self.num_threads, + self.device.clone(), + self.seed, + ); + Arc::new(dataloader) + } +} + +impl MultiThreadsDataloaderIterator { + pub fn new( + receiver: mpsc::Receiver>, + num_workers: usize, + progresses: Vec, + ) -> Self { + MultiThreadsDataloaderIterator { + num_done: 0, + num_workers, + receiver, + progresses, + } + } +} +impl DataLoaderIterator for MultiThreadsDataloaderIterator { + fn progress(&self) -> Progress { + let mut items_total = 0; + let mut items_processed = 0; + let unit: Option = Some("items".to_string()); + + for progress in self.progresses.iter() { + items_total += progress.items_total; + items_processed += progress.items_processed; + } + + Progress::new(items_processed, items_total, unit) + } +} + +impl Iterator for MultiThreadsDataloaderIterator { + type Item = O; + + fn next(&mut self) -> Option { + if self.num_workers == 0 { + return None; + } + + loop { + match self.receiver.recv() { + Ok(Message::Batch(index, item, progress)) => { + if let Some(current) = self.progresses.get_mut(index) { + *current = progress; + } + return Some(item); + } + Ok(Message::Done) => { + self.num_done += 1; + if self.num_done == self.num_workers { + // Workers stay alive for the next epoch; nothing to join. + return None; + } + } + // Every worker dropped its sender (the pool is shutting down). + Err(_) => return None, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::dataloader::FixBatchStrategy; + use crate::data::dataloader::batcher::TestBatcher; + use crate::data::dataset::FakeDataset; + use burn_dataset::InMemDataset; + use std::collections::HashSet; + + #[test] + fn test_multi_thread_batch_dataloader() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + let dataloader_single_thread = BatchDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset.clone(), + batcher.clone(), + Default::default(), + None, + ); + let dataloader_multi_thread = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset, + batcher, + 4, + Default::default(), + None, + ); + + let mut items_single_thread = HashSet::new(); + let mut items_multi_thread = HashSet::new(); + + for items in dataloader_single_thread.iter() { + for item in items { + items_single_thread.insert(item); + } + } + + for items in dataloader_multi_thread.iter() { + for item in items { + items_multi_thread.insert(item); + } + } + + assert_eq!(items_single_thread, items_multi_thread); + } + + #[test] + fn test_multi_thread_batch_dataloader_shuffle() { + let num_classes = 2; + let class_size = 100; + let batch_size = 10; + + // Items is a deliberately ordered dataset. + let mut items = Vec::new(); + for class in 0..num_classes { + items.extend(vec![class; class_size]); + } + + { + // Unshuffled multithreaded loader + let dataset = Arc::new(InMemDataset::new(items.clone())); + let batcher = Arc::new(TestBatcher::new()); + + let loader = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(batch_size)), + dataset, + batcher, + num_classes, + Default::default(), + // No rng means no shuffling. + None, + ); + + for batch in loader.iter() { + let mut batch_items = HashSet::new(); + for item in batch { + batch_items.insert(item); + } + + // Since the dataset is not shuffled, we expect each batch to contain the same item. + assert_eq!(batch_items.len(), 1); + } + } + + { + // Shuffled multithreaded loader + let dataset = Arc::new(InMemDataset::new(items.clone())); + let batcher = Arc::new(TestBatcher::new()); + + let loader = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(batch_size)), + dataset.clone(), + batcher.clone(), + num_classes, + Default::default(), + // The rng enables shuffling. + Some(StdRng::seed_from_u64(42)), + ); + + for batch in loader.iter() { + let mut batch_items = HashSet::new(); + for item in batch { + batch_items.insert(item); + } + + // Since the dataset is shuffled, we expect to see all items. + assert_eq!(batch_items.len(), num_classes); + } + } + } + + #[test] + fn test_multi_thread_batch_dataloader_incomplete_batches() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + let dataloader_single_thread = BatchDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset.clone(), + batcher.clone(), + Default::default(), + None, + ); + let dataloader_multi_thread = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset, + batcher, + 4, + Default::default(), + None, + ); + + let mut items_single_thread = HashSet::new(); + let mut items_multi_thread = HashSet::new(); + + let mut single_thread_cnt = 0; + let mut multi_thread_cnt = 0; + for items in dataloader_single_thread.iter() { + items_single_thread.insert(items); + single_thread_cnt += 1; + } + + for items in dataloader_multi_thread.iter() { + items_multi_thread.insert(items); + multi_thread_cnt += 1; + } + + assert_eq!(single_thread_cnt, multi_thread_cnt); + assert_eq!(items_single_thread, items_multi_thread); + } + + // Iterating the same loader over several epochs must keep yielding the full dataset (#4792). + #[test] + fn test_multi_thread_batch_dataloader_multiple_epochs() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + + let expected: HashSet<_> = dataset.iter().collect(); + + let dataloader = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset, + batcher, + 4, + Default::default(), + None, + ); + + for _epoch in 0..3 { + let mut items = HashSet::new(); + for batch in dataloader.iter() { + for item in batch { + items.insert(item); + } + } + assert_eq!(items, expected); + } + } + + // Dropping an iterator early must not kill the workers; the next pass still yields everything. + #[test] + fn test_multi_thread_batch_dataloader_resumes_after_early_drop() { + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(27)); + + let expected: HashSet<_> = dataset.iter().collect(); + + let dataloader = MultiThreadDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset, + batcher, + 4, + Default::default(), + None, + ); + + // Consume a single batch then drop the iterator early. + { + let mut iterator = dataloader.iter(); + let _ = iterator.next(); + } + + let mut items = HashSet::new(); + for batch in dataloader.iter() { + for item in batch { + items.insert(item); + } + } + assert_eq!(items, expected); + } +} diff --git a/crates/burn-core/src/data/dataloader/split.rs b/crates/burn-core/src/data/dataloader/split.rs new file mode 100644 index 0000000..eda994c --- /dev/null +++ b/crates/burn-core/src/data/dataloader/split.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use burn_tensor::Device; + +use super::DataLoader; + +/// Splits a dataloader into multiple partial dataloaders (one per device). +pub fn split_dataloader( + dataloader: Arc>, + devices: &[Device], +) -> Vec>> { + let num_splits = devices.len(); + if num_splits > 1 { + let num_items = dataloader.num_items(); + let mut dataloaders = Vec::with_capacity(num_splits); + + let mut start = 0; + let step = num_items / num_splits; + for (i, device) in devices.iter().enumerate() { + let end = if i == (num_splits - 1) { + num_items + } else { + start + step + }; + let dataloader = dataloader.slice(start, end).to_device(device); + dataloaders.push(dataloader); + start = end; + } + dataloaders + } else { + vec![dataloader] + } +} + +#[cfg(test)] +mod tests { + use burn_tensor::Device; + use std::collections::HashSet; + + use super::*; + use crate::data::dataloader::batcher::Batcher; + use crate::data::dataloader::{BatchDataLoader, FixBatchStrategy}; + use crate::data::dataset::FakeDataset; + + #[test] + fn test_split_batch_dataloader() { + #[derive(new, Clone)] + pub struct TestBatcher; + + #[cfg(test)] + impl Batcher, Device)> for TestBatcher { + fn batch(&self, items: Vec, device: &Device) -> (Vec, Device) { + (items, device.clone()) + } + } + + let batcher = Arc::new(TestBatcher::new()); + let dataset = Arc::new(FakeDataset::::new(11)); + + #[allow(clippy::arc_with_non_send_sync)] + let dataloader = Arc::new(BatchDataLoader::new( + Box::new(FixBatchStrategy::new(5)), + dataset.clone(), + batcher, + Default::default(), + None, + )); + + #[cfg(all(test, not(feature = "tch"), not(feature = "cuda")))] + // Only one device exists... + let (device1, device2) = (Device::flex(), Device::flex()); + + #[cfg(all(test, feature = "tch"))] + let (device1, device2) = (Device::libtorch_cuda(0), Device::libtorch_cuda(1)); + + #[cfg(all(test, feature = "cuda"))] + let (device1, device2) = (Device::cuda(0), Device::cuda(1)); + + let dataloaders = split_dataloader(dataloader.clone(), &[device1.clone(), device2.clone()]); + + assert_eq!(dataloaders.len(), 2); + + let [dataloader_1, dataloader_2] = match dataloaders.try_into() { + Ok(arr) => arr, + Err(_) => unreachable!(), + }; + assert_eq!(dataloader_1.num_items(), 5); + assert_eq!(dataloader_2.num_items(), 6); + + let mut items_dataloader = HashSet::new(); + let mut items_dataloader_split = HashSet::new(); + + for (items, _device) in dataloader.iter() { + for item in items { + items_dataloader.insert(item); + } + } + + for (items, device) in dataloader_1.iter() { + assert_eq!(&device, &device1); + for item in items { + items_dataloader_split.insert(item); + } + } + + for (items, device) in dataloader_2.iter() { + assert_eq!(&device, &device2); + for item in items { + items_dataloader_split.insert(item); + } + } + + assert_eq!(items_dataloader, items_dataloader_split); + } +} diff --git a/crates/burn-core/src/data/dataloader/strategy.rs b/crates/burn-core/src/data/dataloader/strategy.rs new file mode 100644 index 0000000..d6e3413 --- /dev/null +++ b/crates/burn-core/src/data/dataloader/strategy.rs @@ -0,0 +1,87 @@ +/// A strategy to batch items. +pub trait BatchStrategy: Send + Sync { + /// Adds an item to the strategy. + /// + /// # Arguments + /// + /// * `item` - The item to add. + fn add(&mut self, item: I); + + /// Batches the items. + /// + /// # Arguments + /// + /// * `force` - Whether to force batching. + /// + /// # Returns + /// + /// The batched items. + fn batch(&mut self, force: bool) -> Option>; + + /// Creates a new strategy of the same type. + /// + /// # Returns + /// + /// The new strategy. + fn clone_dyn(&self) -> Box>; + + /// Returns the expected batch size for this strategy. + /// + /// # Returns + /// + /// The batch size, or None if the strategy doesn't have a fixed batch size. + fn batch_size(&self) -> Option; +} + +/// A strategy to batch items with a fixed batch size. +pub struct FixBatchStrategy { + items: Vec, + batch_size: usize, +} + +impl FixBatchStrategy { + /// Creates a new strategy to batch items with a fixed batch size. + /// + /// # Arguments + /// + /// * `batch_size` - The batch size. + /// + /// # Returns + /// + /// The strategy. + pub fn new(batch_size: usize) -> Self { + FixBatchStrategy { + items: Vec::with_capacity(batch_size), + batch_size, + } + } +} + +impl BatchStrategy for FixBatchStrategy { + fn add(&mut self, item: I) { + self.items.push(item); + } + + fn batch(&mut self, force: bool) -> Option> { + if self.items.len() < self.batch_size && !force { + return None; + } + + let mut items = Vec::with_capacity(self.batch_size); + std::mem::swap(&mut items, &mut self.items); + + if items.is_empty() { + return None; + } + + Some(items) + } + + fn clone_dyn(&self) -> Box> { + Box::new(Self::new(self.batch_size)) + } + + fn batch_size(&self) -> Option { + Some(self.batch_size) + } +} diff --git a/crates/burn-core/src/data/mod.rs b/crates/burn-core/src/data/mod.rs new file mode 100644 index 0000000..8881032 --- /dev/null +++ b/crates/burn-core/src/data/mod.rs @@ -0,0 +1,15 @@ +/// Dataloader module. +#[cfg(feature = "dataset")] +pub mod dataloader; + +/// Dataset module. +#[cfg(feature = "dataset")] +pub mod dataset { + pub use burn_dataset::*; +} + +/// Network module. +#[cfg(feature = "network")] +pub mod network { + pub use burn_std::network::*; +} diff --git a/crates/burn-core/src/lib.rs b/crates/burn-core/src/lib.rs new file mode 100644 index 0000000..79f2a06 --- /dev/null +++ b/crates/burn-core/src/lib.rs @@ -0,0 +1,93 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "135"] + +//! The core crate of Burn. + +// `derive_new` provides the `#[derive(new)]` macro used across the crate; the lint mistakenly +// reports the `#[macro_use]` as unused. +#[allow(unused_imports)] +#[macro_use] +extern crate derive_new; + +/// Re-export serde for proc macros. +pub use serde; + +/// The configuration module. +pub mod config; + +/// Data module. +#[cfg(feature = "std")] +pub mod data; + +/// Module for the neural network module. +pub mod module; + +/// Module for saving and loading module/optimizer state in the burnpack format. +pub mod store; + +/// Module for the tensor. +pub mod tensor; +// Tensor at root: `burn::Tensor` +pub use tensor::Tensor; + +#[cfg(feature = "extension")] +/// Backend module. +pub mod backend; + +extern crate alloc; + +// TODO: configurable device priority +#[cfg(test)] +#[allow(missing_docs)] +pub fn test_device() -> burn_tensor::Device { + burn_tensor::Device::flex() +} + +#[cfg(test)] +mod test_utils { + use crate as burn; + use crate::module::Module; + use crate::module::Param; + use burn_tensor::Device; + use burn_tensor::Tensor; + + /// Simple linear module. + #[derive(Module, Debug)] + pub struct SimpleLinear { + pub weight: Param>, + pub bias: Option>>, + } + + impl SimpleLinear { + pub fn new(in_features: usize, out_features: usize, device: &Device) -> Self { + let weight = Tensor::random( + [out_features, in_features], + burn_tensor::Distribution::Default, + device, + ); + let bias = Tensor::random([out_features], burn_tensor::Distribution::Default, device); + + Self { + weight: Param::from_tensor(weight), + bias: Some(Param::from_tensor(bias)), + } + } + } +} + +pub mod prelude { + //! Structs and macros used by most projects. Add `use + //! burn::prelude::*` to your code to quickly get started with + //! Burn. + pub use crate::{ + config::Config, + module::Module, + tensor::{ + Bool, Device, DeviceIndex, DeviceKind, ElementConversion, Float, Int, Shape, SliceArg, + Tensor, TensorData, cast::ToElement, s, + }, + }; + pub use burn_std::device::Device as DeviceOps; +} diff --git a/crates/burn-core/src/module/base.rs b/crates/burn-core/src/module/base.rs new file mode 100644 index 0000000..9a441dd --- /dev/null +++ b/crates/burn-core/src/module/base.rs @@ -0,0 +1,459 @@ +use super::{Param, ParamId, Quantizer}; +use alloc::{string::String, vec::Vec}; +pub use burn_derive::Module; +use burn_tensor::{Bool, Device, Int, Tensor}; + +/// Type alias to `Vec` which supports `no_std` environments, but automatically using +/// the `alloc` crate. +pub type Devices = Vec; + +// At the moment, our plan is to continue experimenting with the macro internally and monitor its development. +// We may consider making it public in the future. +macro_rules! module { + (map=$module:ident, ops=$item:expr) => {{ + struct Mapper; + impl ModuleMapper for Mapper { + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + let func = $item; + let tensor = func(tensor); + Param::from_mapped_value(id, tensor, mapper) + } + } + let mut mapper = Mapper; + $module.map(&mut mapper) + }}; + (visit_float=$module:ident, ops=$item:expr, state=$state_ty:ty, init=$init:expr) => {{ + struct Visitor<'a> { + state: &'a mut $state_ty, + } + impl<'a> ModuleVisitor for Visitor<'a> { + fn visit_float(&mut self, param: &Param>) { + let func = $item; + func(¶m.val(), &mut self.state) + } + } + #[allow(clippy::redundant_closure_call)] + let mut state = $init(); + let mut visitor = Visitor { state: &mut state }; + $module.visit(&mut visitor); + state + }}; +} + +/// Trait for all neural network modules. +/// +/// Modules should be created using the [derive](burn_derive::Module) attribute. +/// This will make your module trainable, savable and loadable via +/// `state` and `load`. +/// +/// # Example +/// +/// ```rust, ignore +/// // Not necessary when using the burn crate directly. +/// use burn_core as burn; +/// +/// use burn::{ +/// module::Module, +/// nn::Linear, +/// tensor::Tensor, +/// }; +/// +/// #[derive(Module, Debug)] +/// struct MyModule { +/// my_param: Linear, +/// my_other_field: usize, +/// } +/// ``` +pub trait Module: Clone + Send + core::fmt::Debug { + /// Return all the devices found in the underneath module tree added to the given vector + /// without duplicates. + fn collect_devices(&self, devices: Devices) -> Devices; + + /// Return all the devices found in the underneath module tree without duplicates. + fn devices(&self) -> Devices { + self.collect_devices(Devices::new()) + } + + /// Fork the module and all of its sub-modules to the given device. + /// + /// # Notes + /// + /// This is similar to [to_device](Module::to_device), but it ensures the output module on the + /// new device will have its own autodiff graph. + fn fork(self, device: &Device) -> Self; + + /// Move the module and all of its sub-modules to the given device. + /// + /// # Warnings + /// + /// The operation supports autodiff and it will be registered when activated. However, this may + /// not be what you want. The output model will be an intermediary model, meaning that you + /// can't optimize it with gradient descent. If you want to optimize the output network on the + /// target device, use [fork](Module::fork) instead. + fn to_device(self, device: &Device) -> Self; + + /// Each tensor in the module tree will not require grad. + /// + /// # Warnings + /// + /// This should not be used for inference, use [valid](AutodiffModule::valid) when using + /// AD modules. This is mostly useful when performing partial finetuning, which is updating only + /// a small fraction of the parameters instead of finetuning all of them. + fn no_grad(self) -> Self { + module!( + map = self, + ops = |tensor: Tensor| tensor.set_require_grad(false) + ) + } + + /// Move the module and all of its sub-modules to the autodiff backend. + /// + /// # Notes + /// + /// * Only plain modules (not already on an autodiff backend) can be moved. + /// * Calling `train()` on a module that is already on an autodiff backend + /// will result in a type error, because the module's inner backend does not match. + fn train(self) -> Self + where + Self: AutodiffModule, + { + // ::TrainModule::from_inner(self) + AutodiffModule::from_inner(self) + } + + /// Get the number of parameters the module has, including all of its sub-modules. + fn num_params(&self) -> usize { + module!( + visit_float = self, + ops = |tensor: &Tensor, state: &mut usize| { + *state += tensor.shape().num_elements(); + }, + state = usize, + init = || 0 + ) + } + /// Visit each tensor parameter in the module with a [visitor](ModuleVisitor). + fn visit(&self, visitor: &mut Visitor); + + /// Map each tensor parameter in the module with a [mapper](ModuleMapper). + fn map(self, mapper: &mut Mapper) -> Self; + + /// Quantize the weights of the module. + fn quantize_weights(self, quantizer: &mut Quantizer) -> Self { + self.map(quantizer) + } + + /// Collect this module's parameters into a [`ModuleRecord`](crate::store::ModuleRecord). + /// + /// The record can be saved to a burnpack file or byte buffer and applied back with + /// [`load_record`](Module::load_record). + fn into_record(self) -> crate::store::ModuleRecord + where + Self: Sized, + { + crate::store::ModuleRecord::from_module(self) + } + + /// Apply a [`ModuleRecord`](crate::store::ModuleRecord) to this module, returning the loaded + /// module. + /// + /// Honors the record's [`DTypePolicy`](crate::store::DTypePolicy), `validate`, and + /// `allow_partial` settings. + fn try_load_record( + self, + record: crate::store::ModuleRecord, + ) -> Result + where + Self: Sized, + { + record.apply(self) + } + + /// Apply a [`ModuleRecord`](crate::store::ModuleRecord) to this module, consuming and returning + /// it. + /// + /// Panics if validation fails; use [`try_load_record`](Module::try_load_record) for the + /// fallible variant. + fn load_record(self, record: crate::store::ModuleRecord) -> Self + where + Self: Sized, + { + self.try_load_record(record).expect("Failed to load record") + } + + /// Save this module's parameters to a burnpack file on disk. + /// + /// Convenience for [`into_record`](Module::into_record) followed by + /// [`ModuleRecord::save`](crate::store::ModuleRecord::save). For non-default load behavior + /// (dtype policy, partial loading, validation), go through the record directly. + #[cfg(feature = "std")] + fn save_file>(self, path: P) -> Result<(), crate::store::RecordError> + where + Self: Sized, + { + self.into_record().save(path) + } + + /// Load this module's parameters from a burnpack file on disk, returning the loaded module. + /// + /// Uses the default load behavior. Panics on I/O or validation errors; use + /// [`try_load_file`](Module::try_load_file) for the fallible variant, or go through + /// [`ModuleRecord`](crate::store::ModuleRecord) to configure dtype policy, partial loading or + /// validation. + #[cfg(feature = "std")] + fn load_file>(self, path: P) -> Self + where + Self: Sized, + { + self.try_load_file(path) + .expect("Failed to load module from file") + } + + /// Fallible variant of [`load_file`](Module::load_file). + /// + /// Reads the record from `path` with [`ModuleRecord::load`](crate::store::ModuleRecord::load) + /// and applies it through [`try_load_record`](Module::try_load_record). + #[cfg(feature = "std")] + fn try_load_file>( + self, + path: P, + ) -> Result + where + Self: Sized, + { + let record = crate::store::ModuleRecord::load(path)?; + self.try_load_record(record) + } +} + +/// Module visitor trait for traversing and inspecting module parameters. +pub trait ModuleVisitor { + /// Visit a float parameter in the module. + /// + /// # Parameters + /// - `param`: The float parameter to visit + #[allow(unused_variables)] + fn visit_float(&mut self, param: &Param>) {} + + /// Visit an int parameter in the module. + /// + /// # Parameters + /// - `param`: The integer parameter to visit + #[allow(unused_variables)] + fn visit_int(&mut self, param: &Param>) {} + + /// Visit a bool parameter in the module. + /// + /// # Parameters + /// - `param`: The boolean parameter to visit + #[allow(unused_variables)] + fn visit_bool(&mut self, param: &Param>) {} + + /// Called when entering a submodule. + /// + /// # Parameters + /// - `name`: The name of the submodule being entered + /// - `container_type`: The type of the container with format: + /// - For user-defined structs: "Struct:TypeName" (e.g., "Struct:Linear") + /// - For user-defined enums: "Enum:TypeName" (e.g., "Enum:MyEnum") + /// - For Vec containers: "Vec" (name is the index) + /// - For Tuple containers: "Tuple" (name is the index) + /// - For Array containers: "Array" (name is the index) + /// + /// Note: Option containers do not call enter_module/exit_module to preserve + /// the field name in the path (e.g., "bias" instead of "bias.Some") + #[allow(unused_variables)] + fn enter_module(&mut self, name: &str, container_type: &str) {} + + /// Called when exiting a submodule. + /// + /// # Parameters + /// - `name`: The name of the submodule being exited + /// - `container_type`: The type of the container with format: + /// - For user-defined structs: "Struct:TypeName" (e.g., "Struct:Linear") + /// - For user-defined enums: "Enum:TypeName" (e.g., "Enum:MyEnum") + /// - For Vec containers: "Vec" (name is the index) + /// - For Tuple containers: "Tuple" (name is the index) + /// - For Array containers: "Array" (name is the index) + /// + /// Note: Option containers do not call enter_module/exit_module to preserve + /// the field name in the path (e.g., "bias" instead of "bias.Some") + #[allow(unused_variables)] + fn exit_module(&mut self, name: &str, container_type: &str) {} + + /// Visit a float tensor with its full module path. + /// + /// # Parameters + /// - `path`: The path components to the tensor as a slice (e.g., &["encoder", "layer1", "weight"]). + /// Each element represents a module name in the hierarchy, with the final element + /// being the parameter name. This allows efficient reuse of the path stack. + /// - `id`: The unique identifier of the parameter + /// - `tensor`: The float tensor to visit + #[allow(unused_variables)] + fn visit_float_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + } + + /// Visit an int tensor with its full module path. + /// + /// # Parameters + /// - `path`: The path components to the tensor as a slice (e.g., &["encoder", "layer1", "weight"]). + /// Each element represents a module name in the hierarchy, with the final element + /// being the parameter name. This allows efficient reuse of the path stack. + /// - `id`: The unique identifier of the parameter + /// - `tensor`: The integer tensor to visit + #[allow(unused_variables)] + fn visit_int_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + } + + /// Visit a bool tensor with its full module path. + /// + /// # Parameters + /// - `path`: The path components to the tensor as a slice (e.g., &["encoder", "layer1", "weight"]). + /// Each element represents a module name in the hierarchy, with the final element + /// being the parameter name. This allows efficient reuse of the path stack. + /// - `id`: The unique identifier of the parameter + /// - `tensor`: The boolean tensor to visit + #[allow(unused_variables)] + fn visit_bool_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + } +} + +/// Module mapper trait for transforming module parameters. +pub trait ModuleMapper { + /// Called when entering a submodule. + /// + /// # Parameters + /// - `name`: The name of the submodule being entered + /// - `container_type`: The type of the container with format: + /// - For user-defined structs: "Struct:TypeName" (e.g., "Struct:Linear") + /// - For user-defined enums: "Enum:TypeName" (e.g., "Enum:MyEnum") + /// - For Vec containers: "Vec" (name is the index) + /// - For Tuple containers: "Tuple" (name is the index) + /// - For Array containers: "Array" (name is the index) + /// + /// Note: Option containers do not call enter_module/exit_module to preserve + /// the field name in the path (e.g., "bias" instead of "bias.Some") + #[allow(unused_variables)] + fn enter_module(&mut self, name: &str, container_type: &str) {} + + /// Called when exiting a submodule. + /// + /// # Parameters + /// - `name`: The name of the submodule being exited + /// - `container_type`: The type of the container with format: + /// - For user-defined structs: "Struct:TypeName" (e.g., "Struct:Linear") + /// - For user-defined enums: "Enum:TypeName" (e.g., "Enum:MyEnum") + /// - For Vec containers: "Vec" (name is the index) + /// - For Tuple containers: "Tuple" (name is the index) + /// - For Array containers: "Array" (name is the index) + /// + /// Note: Option containers do not call enter_module/exit_module to preserve + /// the field name in the path (e.g., "bias" instead of "bias.Some") + #[allow(unused_variables)] + fn exit_module(&mut self, name: &str, container_type: &str) {} + + /// Map a float parameter in the module. + /// + /// # Parameters + /// - `param`: The float parameter to transform + /// + /// # Returns + /// The transformed parameter + #[allow(unused_variables)] + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + Param::from_mapped_value(id, tensor, mapper) + } + + /// Map an int parameter in the module. + /// + /// # Parameters + /// - `param`: The integer parameter to transform + /// + /// # Returns + /// The transformed parameter + #[allow(unused_variables)] + fn map_int(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + Param::from_mapped_value(id, tensor, mapper) + } + + /// Map a bool parameter in the module. + /// + /// # Parameters + /// - `param`: The boolean parameter to transform + /// + /// # Returns + /// The transformed parameter + #[allow(unused_variables)] + fn map_bool( + &mut self, + param: Param>, + ) -> Param> { + let (id, tensor, mapper) = param.consume(); + Param::from_mapped_value(id, tensor, mapper) + } +} + +/// Module with auto-differentiation backend. +pub trait AutodiffModule: Module + Send + core::fmt::Debug { + /// Returns the same module, but on the inner backend without auto-differentiation. + fn valid(&self) -> Self; + + /// Wraps an inner module back into an auto-diff module. + fn from_inner(module: Self) -> Self; +} + +#[cfg(all(test, feature = "autodiff"))] +mod tests { + use super::*; + + use crate::{test_device, test_utils::SimpleLinear}; + + #[test] + fn test_module_val_train_stateful() { + let device = test_device().autodiff(); + let module = SimpleLinear::new(4, 4, &device); + + assert!(module.weight.is_require_grad()); + assert!(module.weight.require_grad); + + let module = module.valid(); + assert!(!module.weight.is_require_grad()); + assert!(module.weight.require_grad); // stateful + + // Without `HasAutodiffModule`, we would need to specify the module type as well, which would be annoying + // let module: SimpleLinear = module.train(); + let module = module.train(); + assert!(module.weight.is_require_grad()); + assert!(module.weight.require_grad); // stateful + + let module = module.no_grad(); + assert!(!module.weight.is_require_grad()); + assert!(!module.weight.require_grad); // stateful + + let module = module.valid(); + assert!(!module.weight.is_require_grad()); // always + assert!(!module.weight.require_grad); // stateful + + let module = module.train(); + assert!(!module.weight.is_require_grad()); + assert!(!module.weight.require_grad); // stateful + } +} diff --git a/crates/burn-core/src/module/display.rs b/crates/burn-core/src/module/display.rs new file mode 100644 index 0000000..40d8eb6 --- /dev/null +++ b/crates/burn-core/src/module/display.rs @@ -0,0 +1,578 @@ +use alloc::{ + borrow::ToOwned, + format, + string::{String, ToString}, + vec::Vec, +}; +use core::any; +use core::fmt::{Debug, Display, Write}; + +/// Default display settings for a module. +pub trait ModuleDisplayDefault { + /// Attributes of the module used for display purposes. + /// + /// # Arguments + /// + /// * `_content` - The content object that contains display settings and attributes. + /// + /// # Returns + /// + /// An optional content object containing the display attributes. + fn content(&self, _content: Content) -> Option; + + /// Gets the number of the parameters of the module. + fn num_params(&self) -> usize { + 0 + } +} + +/// Trait to implement custom display settings for a module. +/// +/// In order to implement custom display settings for a module, +/// 1. Add #[module(custom_display)] attribute to the module struct after #[derive(Module)] +/// 2. Implement ModuleDisplay trait for the module +pub trait ModuleDisplay: ModuleDisplayDefault { + /// Formats the module with provided display settings. + /// + /// # Arguments + /// + /// * `passed_settings` - Display settings passed to the module. + /// + /// # Returns + /// + /// A string representation of the formatted module. + fn format(&self, passed_settings: DisplaySettings) -> String { + let settings = if let Some(custom_settings) = self.custom_settings() { + custom_settings.inherit(passed_settings) + } else { + passed_settings + }; + + let indent = " ".repeat(settings.level * settings.indentation_size()); + let indent_close_braces = " ".repeat((settings.level - 1) * settings.indentation_size()); + + let settings = settings.level_up(); + + let self_type = extract_type_name::(); + + // Use custom content if it is implemented and show_all_attributes is false, + // otherwise use default content + let content = if !settings.show_all_attributes() { + self.custom_content(Content::new(settings.clone())) + .unwrap_or_else(|| { + self.content(Content::new(settings.clone())) + .unwrap_or_else(|| { + panic!("Default content should be implemented for {self_type}.") + }) + }) + } else { + self.content(Content::new(settings.clone())) + .unwrap_or_else(|| panic!("Default content should be implemented for {self_type}.")) + }; + + let top_level_type = if let Some(top_level_type) = content.top_level_type { + top_level_type.to_owned() + } else { + self_type.to_owned() + }; + + // If there is only one item in the content, return it or no attributes + if let Some(item) = content.single_item { + return item; + } else if content.attributes.is_empty() { + return top_level_type.to_string(); + } + + let mut result = String::new(); + + // Print the struct name + if settings.new_line_after_attribute() { + writeln!(result, "{top_level_type} {{").unwrap(); + } else { + write!(result, "{top_level_type} {{").unwrap(); + } + + for (i, attribute) in content.attributes.iter().enumerate() { + if settings.new_line_after_attribute() { + writeln!(result, "{indent}{}: {}", attribute.name, attribute.value).unwrap(); + } else if i == 0 { + write!(result, "{}: {}", attribute.name, attribute.value).unwrap(); + } else { + write!(result, ", {}: {}", attribute.name, attribute.value).unwrap(); + } + } + + if settings.show_num_parameters() { + let num_params = self.num_params(); + if num_params > 0 { + if settings.new_line_after_attribute() { + writeln!(result, "{indent}params: {num_params}").unwrap(); + } else { + write!(result, ", params: {num_params}").unwrap(); + } + } + } + + if settings.new_line_after_attribute() { + write!(result, "{indent_close_braces}}}").unwrap(); + } else { + write!(result, "}}").unwrap(); + } + + result + } + + /// Custom display settings for the module. + /// + /// # Returns + /// + /// An optional display settings object. + fn custom_settings(&self) -> Option { + None + } + + /// Custom attributes for the module. + /// + /// # Arguments + /// + /// * `_content` - The content object that contains display settings and attributes. + /// + /// # Returns + /// + /// An optional content object containing the custom attributes. + fn custom_content(&self, _content: Content) -> Option { + None + } +} + +/// Custom module display settings. +#[derive(Debug, Clone)] +pub struct DisplaySettings { + /// Whether to print the module parameter ids. + show_param_id: Option, + + /// Whether to print the module attributes. + show_all_attributes: Option, + + /// Whether to print the module number of parameters. + show_num_parameters: Option, + + /// Print new line after an attribute. + new_line_after_attribute: Option, + + /// Indentation size. + indentation_size: Option, + + /// Level of indentation. + level: usize, +} + +impl Default for DisplaySettings { + fn default() -> Self { + DisplaySettings { + show_param_id: None, + show_all_attributes: None, + show_num_parameters: None, + new_line_after_attribute: None, + indentation_size: None, + level: 1, + } + } +} + +impl DisplaySettings { + /// Create a new format settings. + /// + /// # Returns + /// + /// A new instance of `DisplaySettings`. + pub fn new() -> Self { + Default::default() + } + + /// Sets a flag to show module parameters. + /// + /// # Arguments + /// + /// * `flag` - Boolean flag to show module parameters. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn with_show_param_id(mut self, flag: bool) -> Self { + self.show_param_id = Some(flag); + self + } + + /// Sets a flag to show module attributes. + /// + /// # Arguments + /// + /// * `flag` - Boolean flag to show all module attributes. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn with_show_all_attributes(mut self, flag: bool) -> Self { + self.show_all_attributes = Some(flag); + self + } + + /// Sets a flag to show the number of module parameters. + /// + /// # Arguments + /// + /// * `flag` - Boolean flag to show the number of module parameters. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn with_show_num_parameters(mut self, flag: bool) -> Self { + self.show_num_parameters = Some(flag); + self + } + + /// Sets a flag to print a new line after an attribute. + /// + /// # Arguments + /// + /// * `flag` - Boolean flag to print a new line after an attribute. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn with_new_line_after_attribute(mut self, flag: bool) -> Self { + self.new_line_after_attribute = Some(flag); + self + } + + /// Sets the indentation size. + /// + /// # Arguments + /// + /// * `size` - The size of the indentation. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn with_indentation_size(mut self, size: usize) -> Self { + self.indentation_size = Some(size); + self + } + + /// Inherits settings from the provided settings and return a new settings object. + /// + /// # Arguments + /// + /// * `top` - The top level `DisplaySettings` to inherit from. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance. + pub fn inherit(self, top: Self) -> Self { + let mut updated = self.clone(); + + if let Some(show_param_id) = top.show_param_id { + updated.show_param_id = Some(show_param_id); + }; + + if let Some(show_all_attributes) = top.show_all_attributes { + updated.show_all_attributes = Some(show_all_attributes); + } + + if let Some(show_num_parameters) = top.show_num_parameters { + updated.show_num_parameters = Some(show_num_parameters); + } + + if let Some(new_line_after_attribute) = top.new_line_after_attribute { + updated.new_line_after_attribute = Some(new_line_after_attribute); + } + + if let Some(indentation_size) = top.indentation_size { + updated.indentation_size = Some(indentation_size); + } + + updated.level = top.level; + + updated + } + + /// A convenience method to wrap the DisplaySettings struct in an option. + /// + /// # Returns + /// + /// An optional `DisplaySettings`. + pub fn optional(self) -> Option { + Some(self) + } + + /// Increases the level of indentation. + /// + /// # Returns + /// + /// Updated `DisplaySettings` instance with increased indentation level. + pub fn level_up(mut self) -> Self { + self.level += 1; + self + } + + /// Gets `show_param_id` flag, substitutes false if not set. + /// + /// This flag is used to print the module parameter ids. + /// + /// # Returns + /// + /// A boolean value indicating whether to show parameter ids. + pub fn show_param_id(&self) -> bool { + self.show_param_id.unwrap_or(false) + } + + /// Gets `show_all_attributes`, substitutes false if not set. + /// + /// This flag is used to force to print all module attributes, overriding custom attributes. + /// + /// # Returns + /// + /// A boolean value indicating whether to show all attributes. + pub fn show_all_attributes(&self) -> bool { + self.show_all_attributes.unwrap_or(false) + } + + /// Gets `show_num_parameters`, substitutes true if not set. + /// + /// This flag is used to print the number of module parameters. + /// + /// # Returns + /// + /// A boolean value indicating whether to show the number of parameters. + pub fn show_num_parameters(&self) -> bool { + self.show_num_parameters.unwrap_or(true) + } + + /// Gets `new_line_after_attribute`, substitutes true if not set. + /// + /// This flag is used to print a new line after an attribute. + /// + /// # Returns + /// + /// A boolean value indicating whether to print a new line after an attribute. + pub fn new_line_after_attribute(&self) -> bool { + self.new_line_after_attribute.unwrap_or(true) + } + + /// Gets `indentation_size`, substitutes 2 if not set. + /// + /// This flag is used to set the size of indentation. + /// + /// # Returns + /// + /// An integer value indicating the size of indentation. + pub fn indentation_size(&self) -> usize { + self.indentation_size.unwrap_or(2) + } +} + +/// Struct to store the attributes of a module for formatting. +#[derive(Clone, Debug)] +pub struct Content { + /// List of attributes. + pub attributes: Vec, + + /// Single item content. + pub single_item: Option, + + /// Display settings. + pub display_settings: DisplaySettings, + + /// Top level type name. + pub top_level_type: Option, +} + +impl Content { + /// Creates a new attributes struct. + /// + /// # Arguments + /// + /// * `display_settings` - Display settings for the content. + /// + /// # Returns + /// + /// A new instance of `Content`. + pub fn new(display_settings: DisplaySettings) -> Self { + Content { + attributes: Vec::new(), + single_item: None, + display_settings, + top_level_type: None, + } + } + + /// Adds an attribute to the format settings. The value will be formatted and stored as a string. + /// + /// # Arguments + /// + /// * `name` - Name of the attribute. + /// * `value` - Value of the attribute. + /// + /// # Returns + /// + /// Updated `Content` instance with the new attribute added. + pub fn add(mut self, name: &str, value: &T) -> Self { + if self.single_item.is_some() { + panic!("Cannot add multiple attributes when single item is set."); + } + + let attribute = Attribute { + name: name.to_owned(), + value: value.format(self.display_settings.clone()), // TODO level + 1 + ty: any::type_name::().to_string(), + }; + self.attributes.push(attribute); + self + } + + /// Adds an attribute using its `Debug` representation. + /// + /// This is intended for fields that do not implement [`ModuleDisplay`]. + /// + /// # Arguments + /// + /// * `name` - Name of the attribute. + /// * `value` - Value of the attribute. + /// + /// # Returns + /// + /// Updated `Content` instance with the new attribute added. + pub fn add_debug_attribute(mut self, name: &str, value: &T) -> Self { + if self.single_item.is_some() { + panic!("Cannot add multiple attributes when single item is set."); + } + self.attributes.push(Attribute { + name: name.to_owned(), + value: DisplayAdapter(value).format(self.display_settings.clone()), + ty: any::type_name::().to_string(), + }); + self + } + + /// Adds a single item. + /// + /// # Arguments + /// + /// * `value` - Rendered string of the single item. + /// + /// # Returns + /// + /// Updated `Content` instance with the single item added. + pub fn add_single(mut self, value: &T) -> Self { + if !self.attributes.is_empty() { + panic!("Cannot add single item when attributes are set."); + } + + self.single_item = Some(value.format(self.display_settings.clone())); + + self + } + + /// Adds a single item. + /// + /// # Arguments + /// + /// * `value` - Formatted display value. + /// + /// # Returns + /// + /// Updated `Content` instance with the formatted single item added. + pub fn add_formatted(mut self, value: &T) -> Self { + if !self.attributes.is_empty() { + panic!("Cannot add single item when attributes are set."); + } + + self.single_item = Some(format!("{value}")); + self + } + + /// A convenience method to wrap the Attributes struct in an option + /// because it is often used as an optional field. + /// + /// # Returns + /// + /// An optional `Content`. + pub fn optional(self) -> Option { + if self.attributes.is_empty() && self.single_item.is_none() && self.top_level_type.is_none() + { + None + } else { + Some(self) + } + } + + /// Sets the top level type name. + /// + /// # Arguments + /// + /// * `ty` - The type name to set. + /// + /// # Returns + /// + /// Updated `Content` instance with the top level type name set. + pub fn set_top_level_type(mut self, ty: &str) -> Self { + self.top_level_type = Some(ty.to_owned()); + self + } +} + +/// Minimal display adapter for non-module types. +struct DisplayAdapter<'a, T: Debug>(&'a T); + +impl<'a, T: Debug> ModuleDisplayDefault for DisplayAdapter<'a, T> { + fn content(&self, content: Content) -> Option { + content.add_single(&format!("{:?}", self.0)).optional() + } +} + +impl<'a, T: Debug> ModuleDisplay for DisplayAdapter<'a, T> {} + +/// Attribute to print in the display method. +#[derive(Clone, Debug)] +pub struct Attribute { + /// Name of the attribute. + pub name: String, + + /// Value of the attribute. + pub value: String, + + /// Type of the attribute. + pub ty: String, +} + +/// Extracts the short name of a type T +/// +/// # Returns +/// +/// A string slice representing the short name of the type. +pub fn extract_type_name() -> &'static str { + // Get the full type name of T, including module path and generic parameters + let ty = any::type_name::(); + + // Find the first occurrence of '<' in the full type name + // If not found, use the length of the type name + let end = ty.find('<').unwrap_or(ty.len()); + + // Slice the type name up to the first '<' or the end + let ty = &ty[0..end]; + + // Find the last occurrence of "::" in the sliced type name + // If found, add 2 to skip the "::" itself + // If not found, start from the beginning of the type name + let start = ty.rfind("::").map(|i| i + 2).unwrap_or(0); + + // Find the last occurrence of '<' in the sliced type name + // If not found, use the length of the type name + let end = ty.rfind('<').unwrap_or(ty.len()); + + // If the start index is less than the end index, + // return the slice of the type name from start to end + // Otherwise, return the entire sliced type name + if start < end { &ty[start..end] } else { ty } +} diff --git a/crates/burn-core/src/module/initializer.rs b/crates/burn-core/src/module/initializer.rs new file mode 100644 index 0000000..42a1517 --- /dev/null +++ b/crates/burn-core/src/module/initializer.rs @@ -0,0 +1,617 @@ +use crate::tensor::Shape; + +use crate::config::Config; +use crate::module::{Param, ParamId}; +use crate::tensor::{Distribution, Tensor, s}; + +use crate as burn; + +use burn_tensor::Device; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Enum specifying with what values a tensor should be initialized +#[derive(Config, Debug, PartialEq)] +pub enum Initializer { + /// Fills tensor with specified value everywhere + Constant { + /// The value to fill the tensor with + value: f64, + }, + /// Fills tensor with 1s everywhere + Ones, + /// Fills tensor with 0s everywhere + Zeros, + /// Fills tensor with values drawn uniformly between specified values + Uniform { + /// The minimum value to draw from + min: f64, + + /// The maximum value to draw from + max: f64, + }, + /// Fills tensor with values drawn from normal distribution with specified mean and std + Normal { + /// The mean of the normal distribution + mean: f64, + + /// The standard deviation of the normal distribution + std: f64, + }, + /// Fills tensor with values according to the uniform version of Kaiming initialization + KaimingUniform { + /// The gain to use in initialization formula + gain: f64, + + /// Whether to use fan out only in initialization formula + fan_out_only: bool, + }, + /// Fills tensor with values according to the uniform version of Kaiming initialization + KaimingNormal { + /// The gain to use in initialization formula + gain: f64, + + /// Whether to use fan out only in initialization formula + fan_out_only: bool, + }, + /// Fills tensor with values according to the uniform version of Xavier Glorot initialization + /// described in [Understanding the difficulty of training deep feedforward neural networks + /// ](https://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) + XavierUniform { + /// The gain to use in initialization formula + gain: f64, + }, + /// Fills tensor with values according to the normal version of Xavier Glorot initialization + /// described in [Understanding the difficulty of training deep feedforward neural networks + /// ](https://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) + XavierNormal { + /// The gain to use in initialization formula + gain: f64, + }, + /// Fills tensor with values according to the (semi) orthogonal initialization + /// described in [Exact solutions to the nonlinear dynamics of learning in deep linear neural networks` + /// - [Saxe, A. et al. (2013)](https://arxiv.org/abs/1312.6120) + Orthogonal { + /// The gain to use in initialization formula + gain: f64, + }, +} + +impl Initializer { + /// Inits a tensor parameter of given shape with values depending on initializer kind. + /// + /// # Params + /// + /// - shape: Shape of the initiated tensor. + pub fn init>( + &self, + shape: S, + device: &Device, + ) -> Param> { + self.init_with(shape, None, None, device) + } + + /// Inits a tensor parameter of given shape with values depending on initializer kind. + /// + /// # Params + /// + /// - shape: Shape of the initiated tensor. + pub fn init_with>( + &self, + shape: S, + fan_in: Option, + fan_out: Option, + device: &Device, + ) -> Param> { + let device = device.clone(); + let shape: Shape = shape.into(); + let config = self.clone(); + let shape_for_closure = shape.clone(); + + Param::uninitialized( + ParamId::new(), + move |device, require_grad| { + let config = config.clone(); + let shape = shape.clone(); + device.memory_persistent_allocations((), move |_| { + let mut tensor = config.init_tensor(shape.clone(), fan_in, fan_out, device); + + if require_grad { + tensor = tensor.require_grad(); + } + + tensor + }) + }, + device, + true, + shape_for_closure, + ) + } + + fn init_tensor>( + &self, + shape: S, + fan_in: Option, + fan_out: Option, + device: &Device, + ) -> Tensor { + let shape = shape.into(); + match self { + Initializer::Constant { value } => Tensor::::full(shape, *value, device), + Initializer::Ones => Tensor::::ones(shape, device), + Initializer::Zeros => Tensor::::zeros(shape, device), + Initializer::Uniform { min, max } => uniform_draw(shape, *min, *max, device), + Initializer::Normal { mean, std } => normal_draw(shape, *mean, *std, device), + Initializer::KaimingUniform { gain, fan_out_only } => { + let a = 3.0f64.sqrt() * *gain * self.kaiming_std(*fan_out_only, fan_in, fan_out); + uniform_draw(shape, -a, a, device) + } + Initializer::KaimingNormal { gain, fan_out_only } => { + let std = *gain * self.kaiming_std(*fan_out_only, fan_in, fan_out); + normal_draw(shape, 0.0, std, device) + } + Initializer::XavierUniform { gain } => { + let a = 3.0f64.sqrt() * *gain * self.xavier_std(fan_in, fan_out); + uniform_draw(shape, -a, a, device) + } + Initializer::XavierNormal { gain } => { + let std = *gain * self.xavier_std(fan_in, fan_out); + normal_draw(shape, 0.0, std, device) + } + Initializer::Orthogonal { gain } => { + // following the implementation in pytorch: + // https://github.com/pytorch/pytorch/blob/v2.7.0/torch/nn/init.py#L574 + + assert!( + D >= 2, + "Expected D (in Tensor) to be greater or equal 2; (D >= 2)" + ); + + let rows: usize = shape.dims::()[0]; + let cols: usize = shape.num_elements() / rows; + + let mut t: Tensor<2> = normal_draw([rows, cols], 0.0, 1.0, device); + + if rows < cols { + t = t.transpose(); + } + + let (q, r) = qr_decomposition(t, device); + let [r_rows, r_cols] = r.clone().dims(); + + let diag_r = Tensor::<2>::ones([1, r_rows], device) + .matmul(Tensor::<2>::eye(r_cols, device).mul(r.clone())); + + let ph = diag_r.clone().sign(); + + let mut q = q.mul(ph); + + if rows < cols { + q = q.transpose(); + } + + q.reshape(shape).mul_scalar(*gain) + } + } + } + + fn kaiming_std( + &self, + fan_out_only: bool, + fan_in: Option, + fan_out: Option, + ) -> f64 { + let fan = if fan_out_only { fan_out } else { fan_in }; + let fan = fan.expect( + "Can't use Kaiming initialization without specifying fan. Use init_with method.", + ); + + 1.0 / (fan as f64).sqrt() + } + + fn xavier_std(&self, fan_in: Option, fan_out: Option) -> f64 { + let fan_in = fan_in.expect( + "Can't use Xavier initialization without specifying fan in. Use init_with method and \ + provide fan_in.", + ); + let fan_out = fan_out.expect( + "Can't use Xavier initialization without specifying fan out. Use init_with method and \ + provide fan_out.", + ); + (2.0 / (fan_in + fan_out) as f64).sqrt() + } +} + +fn uniform_draw>( + shape: S, + low: f64, + high: f64, + device: &Device, +) -> Tensor { + let distribution = Distribution::Uniform(low, high); + Tensor::::random(shape, distribution, device) +} + +fn normal_draw>( + shape: S, + mean: f64, + std: f64, + device: &Device, +) -> Tensor { + let distribution = Distribution::Normal(mean, std); + Tensor::::random(shape, distribution, device) +} + +fn qr_decomposition(a: Tensor<2>, device: &Device) -> (Tensor<2>, Tensor<2>) { + // Calculate the QR decomposition using Gram-Schmidt-process: https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process + + let [m, n] = a.clone().dims(); + let mut q = Tensor::<2>::zeros([m, n], device); + let mut r = Tensor::<2>::zeros([n, n], device); + + for j in 0..n { + let mut v: Tensor<1> = a.clone().slice(s![.., j..=j]).squeeze_dim(1); + + for i in 0..j { + let q_i: Tensor<1> = q.clone().slice(s![.., i..=i]).squeeze_dim(1); + let r_ij = q_i.clone().mul(v.clone()).sum(); + + r = r + .clone() + .slice_assign([i..i + 1, j..j + 1], r_ij.clone().unsqueeze()); + + v = v - q_i.mul(r_ij); + } + + // norm of v + let r_jj = v + .clone() + .powf(Tensor::from_floats([2.0], device)) + .sum() + .sqrt(); + + r = r + .clone() + .slice_assign([j..j + 1, j..j + 1], r_jj.clone().unsqueeze()); + + let q_j = v / r_jj; + + q = q + .clone() + .slice_assign([0..m, j..j + 1], q_j.unsqueeze_dim(1)); + } + + (q, r) +} + +#[cfg(test)] +mod tests { + use crate::test_device; + + use super::*; + + use burn_tensor::{ElementConversion, TensorData}; + use num_traits::Pow; + + use burn_tensor::Tolerance; + type FT = f32; + + fn assert_normal_init(expected_mean: f64, expected_var: f64, tensor: &Tensor<2>) { + let (actual_vars, actual_means) = tensor.clone().var_mean(0); + let actual_vars = actual_vars.to_data(); + let actual_vars = actual_vars.as_slice::().unwrap(); + let actual_means = actual_means.to_data(); + let actual_means = actual_means.as_slice::().unwrap(); + + for i in 0..tensor.shape()[0] { + let actual_var = actual_vars[i] as f64; + let actual_mean = actual_means[i] as f64; + + assert!( + (expected_var - actual_var).abs() <= 0.1, + "Expected variance to be between {expected_var} += 0.1, but got {actual_var}" + ); + assert!( + (expected_mean - actual_mean).abs() <= 0.1, + "Expected mean to be between {expected_mean} += 0.1, but got {actual_mean}" + ); + } + } + + #[test] + fn initializer_uniform_init() { + let device = test_device(); + device.seed(0); + + let (min, max) = (0.0, 1.0); + let uniform = Initializer::Uniform { min, max }; + let tensor: Tensor<4> = uniform.init([2, 2, 2, 2], &device).into_value(); + + tensor + .into_data() + .assert_within_range::(min.elem()..max.elem()); + } + + #[test] + fn initializer_normal_init() { + // seed random generator + let device = test_device(); + device.seed(0); + + let (mean, std) = (0.0, 1.0); + let normal: Tensor<1> = Initializer::Normal { mean, std } + .init([10000], &device) + .into_value(); + let (var_act, mean_act) = normal.var_mean(0); + + let var_act: f32 = var_act.into_scalar(); + let mean_act: f32 = mean_act.into_scalar(); + + assert!( + var_act >= 0.9 && var_act <= 1.1, + "Expected variance to be between 1.0 += 0.1, but got {var_act}" + ); + assert!( + mean_act >= -0.1 && mean_act <= 0.1, + "Expected mean to be between 0.0 += 0.1, but got {mean_act}" + ); + } + + #[test] + fn initializer_constant_init() { + let value = 5.0; + let constants: Tensor<4> = Initializer::Constant { value } + .init([2, 2, 2, 2], &test_device()) + .into_value(); + constants.sum().to_data().assert_approx_eq::( + &TensorData::from([value as f32 * 16.0]), + Tolerance::default(), + ); + } + + #[test] + fn initializer_zeros_init() { + let zeros: Tensor<4> = Initializer::Zeros + .init([2, 2, 2, 2], &test_device()) + .into_value(); + zeros + .sum() + .to_data() + .assert_approx_eq::(&TensorData::from([0.0]), Tolerance::default()); + } + + #[test] + fn initializer_ones_init() { + let ones: Tensor<4> = Initializer::Ones + .init([2, 2, 2, 2], &test_device()) + .into_value(); + ones.sum() + .to_data() + .assert_approx_eq::(&TensorData::from([16.0]), Tolerance::default()); + } + + #[test] + fn initializer_kaiming_uniform_init() { + let device = test_device(); + device.seed(0); + + let gain = 2_f64; + let (fan_in, fan_out) = (5, 6); + let k = (gain * (3.0 / fan_in as f64).sqrt()).elem::(); + + let tensor: Tensor<2> = Initializer::KaimingUniform { + gain, + fan_out_only: false, + } + .init_with([fan_out, fan_in], Some(fan_in), None, &device) + .into_value(); + tensor.into_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_kaiming_normal_init() { + let device = test_device(); + device.seed(0); + + let gain = 2.; + let (fan_in, fan_out) = (1000, 10); + let expected_mean = 0_f64; + + let expected_var = (gain * (1. / (fan_in as f64)).sqrt()).pow(2.); + let tensor: Tensor<2> = Initializer::KaimingNormal { + gain, + fan_out_only: false, + } + .init_with([fan_out, fan_in], Some(fan_in), None, &device) + .into_value(); + assert_normal_init(expected_mean, expected_var, &tensor) + } + + #[test] + fn initializer_kaiming_uniform_init_bias() { + let device = test_device(); + device.seed(0); + + let gain = 2_f64; + let shape = [3]; + let fan_in = 5; + let k = (gain * (3.0 / fan_in as f64).sqrt()).elem::(); + + let tensor: Tensor<1> = Initializer::KaimingUniform { + gain, + fan_out_only: false, + } + .init_with(shape, Some(fan_in), None, &device) + .into_value(); + tensor.into_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_kaiming_uniform_init_fan_out() { + let device = test_device(); + device.seed(0); + + let gain = 2_f64; + let (fan_in, fan_out) = (5, 6); + let k = (gain * (3.0 / fan_out as f64).sqrt()).elem::(); + + let tensor: Tensor<2> = Initializer::KaimingUniform { + gain, + fan_out_only: true, + } + .init_with([fan_out, fan_in], None, Some(fan_out), &device) + .into_value(); + tensor.into_data().assert_within_range(-k..k); + } + + #[test] + #[should_panic] + fn initializer_kaiming_uniform_no_fan() { + let device = test_device(); + device.seed(0); + + let gain = 2_f64; + let (fan_in, fan_out) = (5, 6); + + let _: Tensor<2> = Initializer::KaimingUniform { + gain, + fan_out_only: false, + } + .init([fan_out, fan_in], &device) + .into_value(); + } + + #[test] + fn initializer_xavier_uniform_init() { + let device = test_device(); + device.seed(0); + + let gain = 2.; + let (fan_in, fan_out) = (5, 6); + let bound = (gain * (6. / (fan_in + fan_out) as f64).sqrt()).elem::(); + let tensor: Tensor<2> = Initializer::XavierUniform { gain } + .init_with([fan_out, fan_in], Some(fan_in), Some(fan_out), &device) + .into_value(); + + tensor.into_data().assert_within_range(-bound..bound); + } + + #[test] + fn initializer_xavier_normal_init() { + let device = test_device(); + device.seed(0); + + let gain = 2.; + let (fan_in, fan_out) = (1000, 10); + let expected_mean = 0_f64; + + let expected_var = (gain * (2. / (fan_in as f64 + fan_out as f64)).sqrt()).powf(2.); + let tensor: Tensor<2> = Initializer::XavierNormal { gain } + .init_with([fan_out, fan_in], Some(fan_in), Some(fan_out), &device) + .into_value(); + assert_normal_init(expected_mean, expected_var, &tensor) + } + + #[test] + #[should_panic] + fn initializer_xavier_uniform_no_fan() { + let device = test_device(); + device.seed(0); + + let gain = 2.; + let (fan_in, fan_out) = (5, 6); + let _: Tensor<2> = Initializer::XavierUniform { gain } + .init([fan_out, fan_in], &device) + .into_value(); + } + + #[test] + fn test_qr_decomposition() { + let device = test_device(); + device.seed(0); + + // test values follow the example from https://pytorch.org/docs/stable/generated/torch.linalg.qr.html#torch.linalg.qr + let a = Tensor::<2>::from_floats( + [[12., -51., 4.], [6., 167., -68.], [-4., 24., -41.]], + &device, + ); + let qr = qr_decomposition(a.clone(), &device); + + // Q @ R should reconstruct input `a` + let q_matmul_r = qr.0.clone().matmul(qr.1.clone()); + + // assert that the difference between input (`a`) and Q @ R is (almost) zero + q_matmul_r + .into_data() + .assert_approx_eq::(&a.into_data(), Tolerance::rel_abs(0.1, 0.1)); + } + + #[test] + fn initializer_orthogonal_correct() { + let device = test_device(); + device.seed(0); + + let gain = 1.; + + // test 2D tensor + let size = 10; + let q: Tensor<2> = Initializer::Orthogonal { gain } + .init([size, size], &device) + .into_value(); + let eye = Tensor::<2>::eye(size, &device); + + // Q.T @ Q should be close to identity matrix + q.clone() + .transpose() + .matmul(q) + .into_data() + .assert_approx_eq::(&eye.into_data(), Tolerance::rel_abs(0.1, 0.1)); + } + + #[test] + fn initializer_orthogonal_init() { + let device = test_device(); + device.seed(0); + + let gain = 1.; + + // test 2D tensor + let shape = [25, 30]; + let t: Tensor<2> = Initializer::Orthogonal { gain } + .init(shape, &device) + .into_value(); + let dims = t.dims(); + assert_eq!( + shape, dims, + "Expected the shape of the input tensor to match the shape of the output. ({shape:?}, {dims:?})" + ); + + // test 3D tensor + let shape = [24, 6, 85]; + let t: Tensor<3> = Initializer::Orthogonal { gain } + .init(shape, &device) + .into_value(); + let dims = t.dims(); + assert_eq!( + shape, dims, + "Expected the shape of the input tensor to match the shape of the output. ({shape:?}, {dims:?})" + ); + } + + #[test] + #[should_panic] + fn initializer_orthogonal_init_1d() { + let device = test_device(); + device.seed(0); + + let gain = 1.; + + // test 1D tensor + let shape = [3]; + let _: Tensor<1> = Initializer::Orthogonal { gain } + .init(shape, &device) + .into_value(); + } +} diff --git a/crates/burn-core/src/module/mod.rs b/crates/burn-core/src/module/mod.rs new file mode 100644 index 0000000..a7d01ef --- /dev/null +++ b/crates/burn-core/src/module/mod.rs @@ -0,0 +1,11 @@ +mod base; +mod display; +mod initializer; +mod param; +mod quantize; + +pub use base::*; +pub use display::*; +pub use initializer::*; +pub use param::*; +pub use quantize::*; diff --git a/crates/burn-core/src/module/param/base.rs b/crates/burn-core/src/module/param/base.rs new file mode 100644 index 0000000..a00c8e8 --- /dev/null +++ b/crates/burn-core/src/module/param/base.rs @@ -0,0 +1,671 @@ +use super::ParamId; +use super::sync_once_cell::SyncOnceCell; +use alloc::format; + +use alloc::boxed::Box; +use burn_std::stub::RwLock; +use burn_tensor::{Device, Shape}; +use core::ops::Deref; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +#[cfg(target_has_atomic = "ptr")] +type Mapper = Arc T + Send + Sync>; + +#[cfg(not(target_has_atomic = "ptr"))] +type Mapper = Arc T + Send + Sync>>; + +#[cfg(target_has_atomic = "ptr")] +fn new_mapper T + Send + Sync + 'static>(func: F) -> Mapper { + Arc::new(func) +} + +#[cfg(not(target_has_atomic = "ptr"))] +fn new_mapper T + Send + Sync + 'static>(func: F) -> Mapper { + Arc::new(Box::new(func)) +} + +type InitFn

= Box P + Send + Sync>; + +fn new_init_fn P + Send + Sync + 'static>( + func: F, +) -> InitFn

{ + Box::new(func) +} + +/// Coordinates lazy initialization across all clones of a [`Param`]. +/// +/// The sole purpose of this shared state is to ensure the initialization function runs at most +/// once: whichever clone first calls [`val`](Param::val) initializes the value, and all other +/// clones observe the same result. +/// +/// # State Management +/// +/// **Two logical states:** +/// +/// 1. **Initialized**: `value` contains the parameter value and `initialization` is `None`. +/// 2. **Lazily Managed**: `initialization` contains `Some(RwLock<...>)`. +/// - *Before initialization*: `value` is empty, inner option is `Some(Uninitialized)`. +/// - *After initialization*: `value` contains the parameter value, inner option is `None`. +/// +/// The transition from uninitialized to initialized happens exactly once and is synchronized +/// across all clones. +pub(crate) struct LazyInitState { + /// The SyncOnceCell holding the initialized parameter value. + /// Empty for uninitialized parameters, populated after first access or explicit initialization. + pub value: SyncOnceCell, + /// The deferred initialization state for lazy parameters. + /// + /// **State Transitions:** + /// - Initialized params: `None` + /// - Uninitialized params: `Some(RwLock)>)` + /// - After lazy init triggers: `Some(RwLock)` (inner Option is taken) + pub initialization: Option>>>, +} + +impl LazyInitState { + /// Create a new parameter state that is already initialized. + fn initialized(value: T) -> Arc { + Arc::new(Self { + value: SyncOnceCell::initialized(value), + initialization: None, + }) + } + + /// Create a new parameter state that is not already initialized. + fn uninitialized(uninit: Uninitialized) -> Arc { + Arc::new(Self { + value: SyncOnceCell::new(), + initialization: Some(RwLock::new(Some(uninit))), + }) + } + + /// Gets the parameter value, initializing it lazily if needed. + fn val(&self) -> &T { + self.value.get_or_init(|| { + let mut init = self + .initialization + .as_ref() + .expect("Should have an initialization when no state provided.") + .write() + .unwrap(); + let state = init.take().expect("Should exist when not initialized"); + state.initialize() + }) + } +} + +/// Parameters are the fundamental building blocks of [modules](crate::module::Module) where they +/// serve as containers for [tensors](crate::tensor::Tensor) that can be updated during +/// training, and loaded during inference. If you don't want to save the tensors +/// and/or don't want to update it during training, you don't need this type to wrap your tensor. +/// +/// # Cloning +/// +/// Cloning a parameter is always cheap; it never allocates or initializes tensors. +/// Clones share the same lazy initialization state, so initialization happens at most once and +/// all clones resolve to the same value regardless of which one triggered it. +/// +/// This sharing is strictly scoped to lazy initialization. It only guarantees that all clones +/// observe the same initialization result. Subsequent transformations operate on independent +/// parameter values and never propagate across clones. +pub struct Param { + /// The unique ID of this parameter. This is used by eg. optimizers to associate a gradient with a specific parameter. + pub id: ParamId, + /// Shared lazy initialization state across all clones of this parameter. + /// The `Arc` exists solely to coordinate lazy initialization. It is not a general + /// shared-ownership mechanism. Any mutation forks into a new `LazyInitState`. + pub(crate) state: Arc>, + pub(crate) param_mapper: ParamMapper, + // For stateful `module.valid()` <> `module.train()` + pub(crate) require_grad: bool, +} + +#[derive(Clone)] +/// Applies transformations when loading and saving parameters. +/// +/// # Mapper System +/// +/// `ParamMapper` allows applying transformations during serialization and deserialization: +/// - `load: Option>` - transformation during deserialization (applied in `transform_for_load()`) +/// - `save: Option>` - transformation during serialization (applied in `transform_for_save()`) +/// +/// These are commonly used for: +/// - Quantization/dequantization +/// - Precision conversion (e.g., FP32 ↔ FP16) +/// - Custom parameter transformations +pub struct ParamMapper { + load: Option>, + save: Option>, +} + +impl core::fmt::Debug for ParamMapper { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!( + "ParamMapper {{ load: {}, save: {} }}", + self.load.is_some(), + self.save.is_some(), + )) + } +} + +impl ParamMapper { + /// Applies the transformation when loading the given parameter. + pub fn on_load(&self, param: T) -> T { + match &self.load { + Some(mapper) => mapper(param), + None => param, + } + } + /// Applies the transformation when saving the given parameter. + pub fn on_save(&self, param: T) -> T { + match &self.save { + Some(mapper) => mapper(param), + None => param, + } + } +} + +impl Default for ParamMapper { + fn default() -> Self { + Self { + load: None, + save: None, + } + } +} + +impl core::fmt::Display for Param { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(format!("Param: {}", self.id).as_str()) + } +} + +impl core::fmt::Debug for Param { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(format!("Param: {} - {:?}", self.id, self.param_mapper).as_str()) + } +} + +pub(crate) mod sealed { + pub trait Sealed {} +} + +/// Trait that defines what is necessary for a type to be a parameter. +/// +/// # Notes +/// This trait is intentionally sealed to keep the set of parameters closed. +/// +/// Although exposed publicly, parameter types are not meant to be extensible: +/// the parameter loading/saving, module system and optimizers assume a fixed, +/// closed set of parameter types represented exclusively by [`Tensor`](crate::Tensor) instances. +pub trait Parameter: sealed::Sealed + Clone + core::fmt::Debug + Send { + /// Fetch the device. + fn device(&self) -> Device; + + /// Fetch the gradient requirement. + fn is_require_grad(&self) -> bool; + + /// Set the gradient requirement. + fn set_require_grad(self, require_grad: bool) -> Self; + + /// Fetch the shape of the parameter. + fn shape(&self) -> Shape; + + /// Moves the parameter to the target device if it is not already on it, + /// applying any kind-specific preparation required for the loading lifecycle (e.g. detach). + fn load_to_device(self, device: &Device) -> Self; +} + +/// The deferred initialization state for lazy parameters. +#[allow(clippy::type_complexity)] +pub(crate) struct Uninitialized { + /// The initialization function. Called with `(device, is_require_grad) -> Parameter`. + init: InitFn

, + /// The target device on which the parameter should be initialized. + /// Used by `lazy_device()` to provide device information without triggering initialization. + pub(crate) device: Device, + /// The gradient requirement for the parameter. + /// Used by `lazy_is_require_grad()` to provide gradient settings without triggering initialization. + pub(crate) is_require_grad: bool, + /// The shape of the tensor parameter. + /// Used by `lazy_shape()` to provide shape information without triggering initialization. + pub(crate) shape: Shape, +} + +impl Uninitialized

{ + /// Runs the initialization function. + /// + /// This is called by [Param::val] when accessing an uninitialized parameter for the first time. + /// The function is given the stored device and gradient requirement, and returns the initialized parameter. + fn initialize(self) -> P { + (self.init)(&self.device, self.is_require_grad) + } +} + +impl Param { + /// Create a new parameter that is already initialized. + pub fn initialized(id: ParamId, value: T) -> Self { + let require_grad = value.is_require_grad(); + Self { + id, + state: LazyInitState::initialized(value), + param_mapper: Default::default(), + require_grad, + } + } + + /// Create a new parameter that is not already initialized. + pub fn uninitialized( + id: ParamId, + init: F, + device: Device, + is_require_grad: bool, + shape: Shape, + ) -> Self + where + F: FnOnce(&Device, bool) -> T + Send + Sync + 'static, + { + Self { + id, + state: LazyInitState::uninitialized(Uninitialized { + init: new_init_fn(init), + device, + is_require_grad, + shape, + }), + param_mapper: Default::default(), + require_grad: is_require_grad, + } + } + + /// Gets the parameter value, initializing it lazily if needed. + /// + /// For initialized parameters, this returns a clone of the cached value. + /// For uninitialized parameters, this triggers initialization: + pub fn val(&self) -> T { + self.deref().clone() + } + + /// Check if the parameter has been initialized. + /// + /// Returns `true` if the parameter's value has been computed and cached, + /// `false` if it's still lazy and will be initialized on first access. + pub fn is_initialized(&self) -> bool { + self.state.value.get().is_some() + } + + /// Gets the parameter's value while consuming the parameter. + pub fn into_value(self) -> T { + self.consume().1 + } + + /// Gets the parameter id and value while consuming the parameter. + pub fn consume(self) -> (ParamId, T, ParamMapper) { + let tensor = self.val(); + + core::mem::drop(self.state); + + (self.id, tensor, self.param_mapper) + } + + /// Execute the given function on the inner value. + pub fn map T>(self, func: F) -> Self { + let (id, tensor, param_mapper) = self.consume(); + let tensor = func(tensor); + let require_grad = tensor.is_require_grad(); + + Self { + id, + state: LazyInitState::initialized(tensor), + param_mapper, + require_grad, + } + } + + /// Create an initialized parameter with the given id, value, and param mapper. + /// + /// This is a helper method for creating parameters while preserving the param mapper, + /// typically used in ModuleMapper implementations. + pub fn from_mapped_value(id: ParamId, value: T, param_mapper: ParamMapper) -> Self { + let require_grad = value.is_require_grad(); + Self { + id, + state: LazyInitState::initialized(value), + param_mapper, + require_grad, + } + } + + /// Runs a transformation on the parameter when loading. + pub fn load_mapper T + Send + Sync + 'static>(mut self, func: F) -> Self { + self.param_mapper.load = Some(new_mapper(func)); + + self + } + + /// Runs a transformation on the parameter when saving. + pub fn save_mapper T + Send + Sync + 'static>(mut self, func: F) -> Self { + self.param_mapper.save = Some(new_mapper(func)); + + self + } + + /// Returns a new parameter whose initialization value is transformed by the given function. + /// + /// If the parameter is still uninitialized (lazy), the transformation is chained onto the + /// existing initialization without triggering evaluation. If the parameter is already + /// initialized, it immediately applies the transformation to the current value. + pub fn init_mapper T + Send + Sync + 'static>(self, func: F) -> Self + where + T: Sync + 'static, + { + let initialization = match &self.state.initialization { + Some(init) => init, + None => return self.map(func), + }; + + let mut init = initialization.write().unwrap(); + + match init.as_mut() { + Some(value) => { + let device = value.device.clone(); + let is_require_grad = value.is_require_grad; + let shape = value.shape.clone(); + core::mem::drop(init); + + let base = self; + Self { + id: base.id, + param_mapper: base.param_mapper.clone(), + require_grad: base.require_grad, + state: LazyInitState::uninitialized(Uninitialized { + // (device, require_grad) are already encoded in `Uninitialized` state and + // applied when `base.val()` triggers initialization. The transformed tensor + // inherits those settings automatically, but since the mapper function + // `F: Fn(T) -> T` is applied on the tensor, we need to ensure the require + // grad setting is preserved. + init: new_init_fn(move |_a, b| func(base.val()).set_require_grad(b)), + device, + is_require_grad, + shape, + }), + } + } + None => { + core::mem::drop(init); + self.map(func) + } + } + } + + /// The device on which the parameter is or will be initialized, **without triggering initialization**. + /// + /// This is critical for the load optimization: when loading tensors into an uninitialized parameter, + /// we need to know the target device to move the loaded tensor appropriately, but we don't want to + /// trigger the initialization function (which would allocate an unnecessary tensor). + /// + /// Use this instead of [crate::tensor::Tensor::device] when you need the device but want to + /// preserve lazy initialization. + pub fn lazy_device(&self) -> Device { + let initialization = match &self.state.initialization { + Some(init) => init, + None => return self.device(), + }; + + let init = initialization.read().unwrap(); + + match init.as_ref() { + Some(value) => value.device.clone(), + None => self.device(), + } + } + + /// The gradient requirement on which the parameter is or will be initialized, **without triggering initialization**. + /// + /// Similar to [lazy_device](Self::lazy_device), this is critical for the load optimization. + /// When loading tensors into an uninitialized parameter, we need to apply the correct gradient + /// setting to the loaded tensor without triggering the initialization function. + /// + /// # Notes + /// + /// This is a crate-private function, since users are not expected to use `is_require_grad` of an + /// uninitialized module to then override its value. All low-level functions should be provided + /// by `burn` and should handle those details. + pub(crate) fn lazy_is_require_grad(&self) -> bool { + let initialization = match &self.state.initialization { + Some(init) => init, + None => return self.is_require_grad(), + }; + + let init = initialization.read().unwrap(); + + match init.as_ref() { + Some(value) => value.is_require_grad, + None => self.is_require_grad(), + } + } + + /// Override the gradient requirement for the current parameter. + pub fn set_require_grad(self, require_grad: bool) -> Self { + let initialization = match &self.state.initialization { + Some(init) => init, + None => return self.map(|tensor| tensor.set_require_grad(require_grad)), + }; + + let mut init = initialization.write().unwrap(); + let mut is_lazy = false; + + if let Some(value) = init.as_mut() { + is_lazy = true; + value.is_require_grad = require_grad; + }; + + core::mem::drop(init); + + if is_lazy { + return self; + } + + self.map(|tensor| tensor.set_require_grad(require_grad)) + } + + /// The shape of the parameter, **without triggering initialization**. + /// + /// This is critical for shape validation during loading: when applying tensors to an + /// uninitialized parameter, we need to validate the shape without triggering the + /// initialization function (which would allocate an unnecessary tensor). + /// + /// Use this instead of [crate::tensor::Tensor::shape] when you need the shape but want to + /// preserve lazy initialization. + pub fn lazy_shape(&self) -> burn_tensor::Shape { + let initialization = match &self.state.initialization { + Some(init) => init, + None => return self.shape(), + }; + + let init = initialization.read().unwrap(); + + match init.as_ref() { + Some(value) => value.shape.clone(), + None => self.shape(), + } + } + + /// Transform a parameter for loading by applying load transformations. + /// + /// This method is used to restore a parameter from a tensor (typically during deserialization). + /// It ensures the tensor is moved to the expected device, applies the param mapper's + /// `on_load` transformation, and preserves the autodiff settings (require_grad). + pub fn transform_for_load(self, tensor: T, param_id: ParamId) -> Self { + let mut new_tensor = tensor; + + let mapper = self.param_mapper.clone(); + + let expected_device = self.lazy_device(); + let expected_require_grad = self.lazy_is_require_grad(); + + // Make sure we load the tensor into the same module device. + new_tensor = new_tensor.load_to_device(&expected_device); + + new_tensor = mapper.on_load(new_tensor); + + // Make sure we load the tensor with the same autodiff setting. + new_tensor = new_tensor.set_require_grad(expected_require_grad); + + let mut loaded = Self::initialized(param_id, new_tensor); + loaded.param_mapper = mapper; + loaded + } + + /// Transform a parameter for saving by applying save transformations. + /// + /// This method is used to prepare a parameter for saving (typically during serialization). + /// It applies the param mapper's `on_save` transformation, which can be used + /// to modify the tensor before serialization (e.g., quantization, precision conversion). + pub fn transform_for_save(&self) -> Self { + let mut tensor = self.val(); + let mapper = self.param_mapper.clone(); + + tensor = mapper.on_save(tensor); + + Self::initialized(self.id, tensor) + } +} + +impl Clone for Param { + fn clone(&self) -> Self { + Self { + id: self.id, + state: self.state.clone(), + param_mapper: self.param_mapper.clone(), + require_grad: self.require_grad, + } + } +} + +impl Deref for Param { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.state.val() + } +} +#[cfg(test)] +mod tests { + use super::*; + use burn_tensor::Tensor; + + // Param should be Sync so that models can be shared across threads + // (e.g. parallel inference with rayon). + fn _assert_sync() {} + + #[test] + fn param_is_sync() { + fn check() { + _assert_sync::>>(); + } + check(); + } + + /// Concurrent lazy initialization must not panic. + /// + /// Multiple threads call `val()` on an uninitialized `Param` simultaneously. + /// `SyncOnceCell::get_or_init` guarantees only one thread runs the initializer; + /// the others block and receive the same value. + #[cfg(feature = "std")] + #[test] + fn param_concurrent_lazy_init() { + use alloc::vec::Vec; + + let device = Default::default(); + + let param: Param> = Param::uninitialized( + ParamId::new(), + |device, _require_grad| Tensor::random([2, 3], Default::default(), device), + device, + false, + [2, 3].into(), + ); + + // Share across threads via ¶m (requires Sync). + std::thread::scope(|s| { + let handles: Vec<_> = (0..4).map(|_| s.spawn(|| param.val())).collect(); + + let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + + // All threads must get the same value. + let expected = results[0].to_data(); + for result in &results[1..] { + assert_eq!(result.to_data(), expected); + } + }); + } + + #[test] + fn param_clones_share_lazy_initialization() { + let device = Default::default(); + + // We use random values so that if it initializes twice, the data will mismatch. + let param_original: Param> = Param::uninitialized( + ParamId::new(), + |device, _require_grad| Tensor::random([2, 3], Default::default(), device), + device, + false, + [2, 3].into(), + ); + + // Regression: https://github.com/tracel-ai/burn/issues/5040 + // Clone the parameter while it is still uninitialized. + // Previously, this would clone the init function only, leading to different parameter states. + let param_clone = param_original.clone(); + + let tensor_original = param_original.val(); + assert!(param_original.is_initialized()); + assert!(param_clone.is_initialized()); + + let tensor_clone = param_clone.val(); + + tensor_original + .into_data() + .assert_eq(&tensor_clone.into_data(), true); + } + + #[test] + fn param_set_require_grad_forks_from_shared_state() { + let device = Default::default(); + + let param1: Param> = Param::uninitialized( + ParamId::new(), + |device, require_grad| Tensor::ones([2, 3], device).set_require_grad(require_grad), + device, + true, + [2, 3].into(), + ); + + // Clone param; both now point to the exact same Arc + let param2 = param1.clone(); + + // Force initialization via the first clone. + let _tensor1 = param1.val(); + assert!(param1.is_initialized()); + assert!(param2.is_initialized()); + + // set_require_grad intentionally forks: param2 gets a new Arc with the mutated tensor. + let param2 = param2.set_require_grad(false); + + // The fork produced the correct require_grad state. + assert_eq!(param2.require_grad, false); + assert_eq!(param1.require_grad, true); // param1 is unaffected + + // Values are still identical (same tensor data, different grad setting). + param1 + .val() + .into_data() + .assert_eq(¶m2.val().into_data(), true); + } +} diff --git a/crates/burn-core/src/module/param/constant.rs b/crates/burn-core/src/module/param/constant.rs new file mode 100644 index 0000000..9335126 --- /dev/null +++ b/crates/burn-core/src/module/param/constant.rs @@ -0,0 +1,299 @@ +use alloc::format; +use burn_tensor::kind::{Autodiff, Basic}; +use core::fmt::Display; + +use crate as burn; +use crate::module::{ + AutodiffModule, Content, Devices, Module, ModuleDisplay, ModuleDisplayDefault, ModuleMapper, + ModuleVisitor, +}; +use burn_tensor::{Device, Tensor}; + +/// Constant macro. +#[macro_export] +macro_rules! empty { + (module) => { + fn visit(&self, _visitor: &mut V) { + // Nothing to do + } + + fn map(self, _mapper: &mut M) -> Self { + self + } + + fn to_device(self, _: &burn::tensor::Device) -> Self { + self + } + + fn fork(self, _: &burn::tensor::Device) -> Self { + self + } + + fn collect_devices(&self, devices: burn::module::Devices) -> burn::module::Devices { + devices + } + }; + + (ad_module, $type:ty) => { + fn valid(&self) -> Self { + self.clone() + } + + fn from_inner(module: Self) -> Self { + module + } + }; + + ($type:ty) => { + impl burn::module::Module for $type { + empty!(module); + } + + impl burn::module::AutodiffModule for $type { + empty!(ad_module, $type); + } + + impl burn::module::ModuleDisplayDefault for $type { + fn content(&self, content: burn::module::Content) -> Option { + let string = format!("{}", self); + content.add_formatted(&string).optional() + } + } + + impl burn::module::ModuleDisplay for $type {} + }; +} + +// TODO: breaking change for these constant types (currently empty record, non-persistent)? + +// General Types +empty!(alloc::string::String); +empty!(bool); + +// Float Types +empty!(f64); +empty!(f32); +empty!(half::bf16); +empty!(half::f16); + +// Unsigned Integer Types +empty!(usize); +empty!(u64); +empty!(u32); +empty!(u16); +empty!(u8); + +// Signed Integer Types +empty!(isize); +empty!(i64); +empty!(i32); +empty!(i16); +empty!(i8); + +impl burn::module::ModuleDisplay for str {} +impl burn::module::ModuleDisplayDefault for str { + fn content(&self, content: burn::module::Content) -> Option { + content.add_formatted(&self).optional() + } +} + +// TODO: tensor record should persist +impl Module for Tensor { + fn visit(&self, _visitor: &mut V) {} + + fn map(self, _mapper: &mut M) -> Self { + self + } + + fn to_device(self, device: &Device) -> Self { + self.to_device(device) + } + + fn fork(self, device: &Device) -> Self { + self.to_device(device) + } + + fn collect_devices(&self, mut devices: Devices) -> Devices { + let device = self.device(); + + if !devices.contains(&device) { + devices.push(device) + } + + devices + } +} + +impl ModuleDisplayDefault for Tensor { + fn content(&self, content: Content) -> Option { + let string = format!("Tensor {{rank: {D}, shape: {:?}}}", self.shape().as_slice()); + content.add_single(&string).optional() + } +} + +impl ModuleDisplay for Tensor {} + +impl AutodiffModule for Tensor { + fn valid(&self) -> Self { + self.clone().inner() + } + + fn from_inner(tensor: Self) -> Self { + Tensor::from_inner(tensor) + } +} + +// TODO: no longer necessary? +// impl Module for PhantomData { +// type Record = EmptyRecord; + +// fn visit(&self, _visitor: &mut V) { +// // Nothing to do +// } + +// fn map(self, _mapper: &mut M) -> Self { +// self +// } + +// fn load_record(self, _record: Self::Record) -> Self { +// self +// } + +// fn into_record(self) -> Self::Record { +// EmptyRecord::new() +// } + +// fn to_device(self, _: &Device) -> Self { +// self +// } + +// fn fork(self, _: &Device) -> Self { +// self +// } + +// fn collect_devices(&self, devices: Devices) -> Devices { +// devices +// } +// } + +// impl ModuleDisplayDefault for PhantomData { +// fn content(&self, content: Content) -> Option { +// content.add_single(&"PhantomData".to_string()).optional() +// } +// } + +// impl ModuleDisplay for PhantomData {} + +// impl AutodiffModule for PhantomData { +// fn valid(&self) -> Self { +// PhantomData +// } + +// fn from_inner(_module: Self) -> Self { +// Self +// } +// } + +/// Container to satisfy the Module trait for types that are not modules. +#[derive(Clone, Debug)] +#[deprecated( + since = "0.21.0", + note = "Ignored is deprecated. Use #[module(skip)] for non-persistent fields (same behavior)." +)] +pub struct Ignored(pub T); + +#[allow(deprecated)] +impl Module for Ignored +where + T: Sync + Send + core::fmt::Debug + Clone, +{ + fn visit(&self, _visitor: &mut V) { + // Nothing to do + } + + fn map(self, _mapper: &mut M) -> Self { + self + } + + fn to_device(self, _: &Device) -> Self { + self + } + + fn fork(self, _: &Device) -> Self { + self + } + + fn collect_devices(&self, devices: Devices) -> Devices { + devices + } +} + +#[allow(deprecated)] +impl ModuleDisplayDefault for Ignored +where + T: Sync + Send + core::fmt::Debug + Clone, +{ + fn content(&self, content: Content) -> Option { + // For now, just print the debug representation of the ignored value + content.add_single(&format!("{:?}", self.0)).optional() + } +} + +#[allow(deprecated)] +impl ModuleDisplay for Ignored where T: Sync + Send + core::fmt::Debug + Clone {} + +#[allow(deprecated)] +impl Display for Ignored +where + T: Sync + Send + core::fmt::Debug + Clone, +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +#[allow(deprecated)] +impl AutodiffModule for Ignored +where + T: Sync + Send + core::fmt::Debug + Clone, +{ + fn valid(&self) -> Self { + self.clone() + } + + fn from_inner(module: Self) -> Self { + module + } +} + +#[allow(deprecated)] +// Implement deref for Ignored +impl core::ops::Deref for Ignored { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use core::marker::PhantomData; + + use burn::module::Module; + + use crate as burn; + + #[test] + fn empty_module_with_phantom() { + #[derive(Module, Debug, new)] + struct EmptyModule { + #[module(skip)] + _phantom: PhantomData, + } + + let _module = EmptyModule::::new(); + + assert_eq!(core::mem::size_of::>(), 0); + } +} diff --git a/crates/burn-core/src/module/param/group.rs b/crates/burn-core/src/module/param/group.rs new file mode 100644 index 0000000..09b9a74 --- /dev/null +++ b/crates/burn-core/src/module/param/group.rs @@ -0,0 +1,545 @@ +use crate::module::{Module, ModuleVisitor, Param}; + +use alloc::string::String; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +#[cfg(feature = "std")] +use regex::Regex; + +use burn_std::id::ParamId; +use burn_tensor::{Bool, Int, Tensor}; + +/// Errors tied to [ParamGroup]'s. +#[derive(Debug)] +pub enum ParamGroupError { + /// Use of an invalid Regex pattern. + InvalidPatternError(String), +} + +#[derive(Default)] +struct ParamIdCollector { + ids: Vec, +} + +impl ParamIdCollector { + pub fn ids(&self) -> Vec { + self.ids.clone() + } +} + +impl ModuleVisitor for ParamIdCollector { + fn visit_float(&mut self, param: &Param>) { + self.ids.push(param.id); + } + + fn visit_int(&mut self, param: &Param>) { + self.ids.push(param.id); + } + + fn visit_bool(&mut self, param: &Param>) { + self.ids.push(param.id); + } +} + +/// A way to represent a group of parameter for a Burn module. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ParamGroup { + matcher: ParamGroupMatcher, + excludes: Option, +} + +impl ParamGroup { + /// Evaluates whether a given parameter ID and its module path match this group. + pub fn matches(&self, id: &ParamId, path: Option<&str>) -> bool { + let matched = self.matcher.matches(id, path); + + let excluded = if let Some(exclude_matcher) = &self.excludes { + exclude_matcher.matches(id, path) + } else { + false + }; + + matched && !excluded + } + + /// Returns a parameter group with all of the module's parameters. + pub fn ids_from_module(module: M) -> Self { + let mut collector = ParamIdCollector::default(); + module.visit(&mut collector); + Self { + matcher: ParamGroupMatcher::from_ids(collector.ids()), + excludes: None, + } + } + + /// Matches parameters by exact text path (e.g., "model.backbone.linear.weight") + pub fn from_path(path: impl Into) -> Self { + ParamGroup::from_paths(vec![path]) + } + + /// Matches parameters by exact text paths (e.g., "model.backbone.linear.weight", etc.) + pub fn from_paths(paths: Vec>) -> Self { + Self { + matcher: ParamGroupMatcher::Path(Arc::new(PathMatcher::Exact( + paths.into_iter().map(|p| p.into()).collect(), + ))), + excludes: None, + } + } + + /// Matches parameters that include the predicate in their paths (e.g., "backbone") + pub fn from_predicate(path: impl Into) -> Self { + ParamGroup::from_predicates(vec![path]) + } + + /// Matches parameters that include all the predicates in their path (AND logic). + /// (e.g., parameter path contains "backbone" and "linear") + pub fn from_predicates(paths: Vec>) -> Self { + Self { + matcher: ParamGroupMatcher::Path(Arc::new(PathMatcher::Include( + paths.into_iter().map(|p| p.into()).collect(), + ))), + excludes: None, + } + } + + /// Matches parameters that include any of the predicates in their path (OR logic). + /// (e.g., parameter path contains "backbone" or "linear") + pub fn from_any_predicates(paths: Vec>) -> Self { + let mut matchers: Vec = paths + .into_iter() + .map(|p| ParamGroupMatcher::Path(Arc::new(PathMatcher::Include(vec![p.into()])))) + .collect(); + let mut main_matcher = if let Some(value) = matchers.pop() { + value + } else { + return Self { + matcher: ParamGroupMatcher::Path(Arc::new(PathMatcher::Include(vec![]))), + excludes: None, + }; + }; + + matchers + .iter() + .for_each(|m| main_matcher = main_matcher.clone().fuse(m)); + + Self { + matcher: main_matcher, + excludes: None, + } + } + + #[cfg(feature = "std")] + /// Matches parameters by regex pattern (e.g., "^model\.layer\.\d+$") + /// + /// # Errors + /// Returns a [ParamGroupError::InvalidPatternError] if the string cannot be compiled into a valid regex. + pub fn from_regex>(pattern: S) -> Result { + ParamGroup::from_regexes(vec![pattern]) + } + + #[cfg(feature = "std")] + /// Matches parameters for all the regex patterns (AND logic). + /// (e.g., "^encoder\.layer\.\d+", and "bias$" ) + /// + /// # Errors + /// Returns a [ParamGroupError::InvalidPatternError] if the strings cannot be compiled into a valid regex. + pub fn from_regexes>(patterns: Vec) -> Result { + let mut new_patterns = vec![]; + for pattern in patterns { + match Regex::new(pattern.as_ref()) { + Ok(re) => new_patterns.push(re), + Err(e) => { + return Err(ParamGroupError::InvalidPatternError(format!( + "Invalid regex pattern: {e}" + ))); + } + } + } + Ok(Self { + matcher: ParamGroupMatcher::Path(Arc::new(PathMatcher::Regex(new_patterns))), + excludes: None, + }) + } + + #[cfg(feature = "std")] + /// Matches parameters for any the regex patterns (OR logic). + /// (e.g., "^encoder\.layer\.\d+$", or "^decoder\.layer\.\d+$" ) + /// + /// # Errors + /// Returns a [ParamGroupError::InvalidPatternError] if the strings cannot be compiled into a valid regex. + pub fn from_any_regexes>(patterns: Vec) -> Result { + let mut matchers = vec![]; + for pattern in patterns { + match Regex::new(pattern.as_ref()) { + Ok(re) => { + matchers.push(ParamGroupMatcher::Path(Arc::new(PathMatcher::Regex(vec![ + re, + ])))) + } + Err(e) => { + return Err(ParamGroupError::InvalidPatternError(format!( + "Invalid regex pattern: {e}" + ))); + } + } + } + + let mut main_matcher = if let Some(value) = matchers.pop() { + value + } else { + return Ok(Self { + matcher: ParamGroupMatcher::Path(Arc::new(PathMatcher::Include(vec![]))), + excludes: None, + }); + }; + + matchers + .iter() + .for_each(|m| main_matcher = main_matcher.clone().fuse(m)); + + Ok(Self { + matcher: main_matcher, + excludes: None, + }) + } + + /// Matches any parameter. + pub fn all() -> Self { + Self { + matcher: ParamGroupMatcher::All, + excludes: None, + } + } + + /// Matches a specific slice of predefined parameter IDs. + pub fn from_ids(ids: Vec) -> Self { + Self { + matcher: ParamGroupMatcher::Explicit(Arc::new(ids)), + excludes: None, + } + } + + /// Fuse two parameter group. + pub fn fuse(self, other: &Self) -> Self { + Self { + matcher: self.matcher.fuse(&other.matcher), + excludes: None, + } + } + + /// Exclude the given group from the current group + pub fn exclude(mut self, group: Self) -> Self { + self.excludes = match &self.excludes { + Some(excluded) => Some(excluded.clone().fuse(&group.matcher)), + None => Some(group.matcher.clone()), + }; + self + } +} + +mod arc_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::*; + + pub fn serialize(val: &Arc, serializer: S) -> Result + where + S: Serializer, + T: Serialize, + { + val.as_ref().serialize(serializer) + } + + pub fn deserialize<'de, D, T>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + let v = T::deserialize(deserializer)?; + Ok(Arc::new(v)) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +enum ParamGroupMatcher { + All, + #[serde(with = "arc_serde")] + Explicit(Arc>), + #[serde(with = "arc_serde")] + Path(Arc), + #[serde(with = "arc_serde")] + Combined(Arc>), +} + +impl ParamGroupMatcher { + pub fn from_ids(ids: Vec) -> Self { + Self::Explicit(Arc::new(ids)) + } + + pub(crate) fn matches(&self, id: &ParamId, path: Option<&str>) -> bool { + match self { + Self::All => true, + Self::Explicit(ids) => ids.contains(id), + Self::Path(matcher) => path.is_some_and(|p| matcher.matches(p)), + Self::Combined(matchers) => matchers.iter().any(|m| m.matches(id, path)), + } + } + + fn push_combined(self, other: Self) -> Self { + match self { + ParamGroupMatcher::Combined(param_group_matchers) => match other.clone() { + ParamGroupMatcher::All => Self::All, + ParamGroupMatcher::Explicit(_) => { + let mut matchers = (*param_group_matchers).clone(); + matchers.push(other); + Self::Combined(Arc::new(matchers)) + } + ParamGroupMatcher::Path(_) => { + let mut matchers = (*param_group_matchers).clone(); + matchers.push(other); + Self::Combined(Arc::new(matchers)) + } + ParamGroupMatcher::Combined(other_matchers) => { + let mut matchers = (*param_group_matchers).clone(); + matchers.append(&mut (*other_matchers).clone()); + Self::Combined(Arc::new(matchers)) + } + }, + _ => panic!( + "`push_combined` should only be called on a ParamGroupMatcher::Combined variant." + ), + } + } + + pub(crate) fn fuse(self, other: &Self) -> Self { + match (self.clone(), other.clone()) { + (ParamGroupMatcher::All, _) => Self::All, + (_, ParamGroupMatcher::All) => Self::All, + (ParamGroupMatcher::Explicit(_), ParamGroupMatcher::Combined(_)) => { + other.clone().push_combined(self) + } + (ParamGroupMatcher::Path(_), ParamGroupMatcher::Combined(_)) => { + other.clone().push_combined(self) + } + (ParamGroupMatcher::Combined(_), ParamGroupMatcher::Explicit(_)) => { + self.push_combined(other.clone()) + } + (ParamGroupMatcher::Combined(_), ParamGroupMatcher::Path(_)) => { + self.push_combined(other.clone()) + } + (ParamGroupMatcher::Combined(_), ParamGroupMatcher::Combined(_)) => { + self.push_combined(other.clone()) + } + _ => ParamGroupMatcher::Combined(Arc::new(vec![self, other.clone()])), + } + } +} + +#[cfg(feature = "std")] +mod regex_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::*; + + pub fn serialize(regexes: &[Regex], serializer: S) -> Result + where + S: Serializer, + { + let v: Vec<&str> = regexes.iter().map(|r| r.as_str()).collect(); + v.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let strings: Vec = Vec::deserialize(deserializer)?; + strings + .into_iter() + .map(|s| Regex::new(&s).map_err(serde::de::Error::custom)) + .collect() + } +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +enum PathMatcher { + Exact(Vec), + #[cfg(feature = "std")] + #[serde(with = "regex_serde")] + Regex(Vec), + Include(Vec), +} + +impl PathMatcher { + pub(crate) fn matches(&self, path: &str) -> bool { + match self { + PathMatcher::Exact(paths) => paths.iter().any(|p| p == path), + #[cfg(feature = "std")] + PathMatcher::Regex(patterns) => patterns.iter().all(|r| r.is_match(path)), + PathMatcher::Include(includes) => includes.iter().all(|inc| path.contains(inc)), + } + } +} + +impl alloc::fmt::Debug for PathMatcher { + fn fmt(&self, f: &mut alloc::fmt::Formatter<'_>) -> alloc::fmt::Result { + match self { + Self::Exact(arg0) => f.debug_tuple("Exact").field(arg0).finish(), + #[cfg(feature = "std")] + Self::Regex(arg0) => f.debug_tuple("Regex").field(arg0).finish(), + Self::Include(arg0) => f.debug_tuple("Include").field(arg0).finish(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::SimpleLinear; + + #[test] + fn all_matches_any_parameter() { + let group = ParamGroup::all(); + let id = ParamId::new(); + + assert!(group.matches(&id, None)); + } + + #[test] + fn explicit_matches_only_selected_ids() { + let id = ParamId::new(); + let other_id = ParamId::new(); + let group = ParamGroup::from_ids(vec![id.clone()]); + + assert!(group.matches(&id, None)); + assert!(!group.matches(&other_id, None)); + } + + #[test] + fn path_matcher_requires_path_and_matches_exactly() { + let group = ParamGroup::from_path("model.backbone.weight"); + let id = ParamId::new(); + + assert!(group.matches(&id, Some("model.backbone.weight"))); + assert!(!group.matches(&id, Some("model.backbone.bias"))); + assert!(!group.matches(&id, None)); + } + + #[test] + fn predicate_matcher_matches_substrings() { + let group = ParamGroup::from_predicate("backbone"); + let id = ParamId::new(); + + assert!(group.matches(&id, Some("model.backbone.weight"))); + assert!(!group.matches(&id, Some("model.other.weight"))); + } + + #[cfg(feature = "std")] + #[test] + fn regex_matcher_matches_pattern() { + let group = ParamGroup::from_regex(r"^model\.layer\.[0-9]+\.weight$").unwrap(); + let id = ParamId::new(); + + assert!(group.matches(&id, Some("model.layer.3.weight"))); + assert!(!group.matches(&id, Some("model.layer.weight"))); + } + + #[test] + fn ids_from_module_collects_all_param_ids() { + let device = crate::test_device(); + let module = SimpleLinear::new(4, 8, &device); + let weight_id = module.weight.id; + let bias_id = module.bias.as_ref().unwrap().id; + let group = ParamGroup::ids_from_module(module); + + assert!(group.matches(&weight_id, Some("weight"))); + assert!(group.matches(&bias_id, Some("bias"))); + } + + #[test] + fn fuse_combines_multiple_groups() { + let id = ParamId::new(); + let group1 = ParamGroup::from_ids(vec![id.clone()]); + let group2 = ParamGroup::from_path("model.layer.weight"); + let fused = group1.fuse(&group2); + + assert!(fused.matches(&id, Some("model.other.bias"))); + assert!(fused.matches(&ParamId::new(), Some("model.layer.weight"))); + assert!(!fused.matches(&ParamId::new(), Some("model.layer.bias"))); + } + + #[test] + fn exclude_removes_matching_ids_from_a_group() { + let id = ParamId::new(); + let excluded_id = ParamId::new(); + let exclude_group = ParamGroup::from_ids(vec![excluded_id.clone()]); + let group = + ParamGroup::from_ids(vec![id.clone(), excluded_id.clone()]).exclude(exclude_group); + + assert!(group.matches(&id, None)); + assert!(!group.matches(&excluded_id, None)); + } + + #[test] + fn exclude_filters_path_matches() { + let id = ParamId::new(); + let group = ParamGroup::from_predicate("backbone") + .exclude(ParamGroup::from_path("model.backbone.bias")); + + assert!(group.matches(&id, Some("model.backbone.weight"))); + assert!(!group.matches(&id, Some("model.backbone.bias"))); + } + + #[test] + fn exclude_ignores_excludes_on_the_provided_group() { + let id = ParamId::new(); + let excluded = ParamGroup::from_path("model.backbone.bias") + .exclude(ParamGroup::from_path("model.backbone.weight")); + let group = ParamGroup::from_path("model.backbone.weight").exclude(excluded); + + assert!(group.matches(&id, Some("model.backbone.weight"))); + assert!(!group.matches(&id, Some("model.backbone.bias"))); + } + + #[test] + fn from_any_predicates_matches_any_predicate() { + let group = ParamGroup::from_any_predicates(vec!["backbone", "encoder"]); + let id = ParamId::new(); + + assert!(group.matches(&id, Some("model.backbone.weight"))); + assert!(group.matches(&id, Some("model.encoder.weight"))); + assert!(!group.matches(&id, Some("model.decoder.weight"))); + } + + #[cfg(feature = "std")] + #[test] + fn from_any_regexes_matches_any_pattern() { + let group = + ParamGroup::from_any_regexes(vec![r"^model\.layer\.[0-9]+\.weight$", r"^model\.bias$"]) + .unwrap(); + let id = ParamId::new(); + + assert!(group.matches(&id, Some("model.layer.3.weight"))); + assert!(group.matches(&id, Some("model.bias"))); + assert!(!group.matches(&id, Some("model.layer.weight"))); + assert!(!group.matches(&id, Some("model.other.weight"))); + } + + #[cfg(feature = "std")] + #[test] + fn from_regexes_with_invalid_pattern() { + let result = ParamGroup::from_regex(r"[invalid("); + assert!(result.is_err()); + + let result = ParamGroup::from_regexes(vec![r"[invalid("]); + assert!(result.is_err()); + + let result = ParamGroup::from_any_regexes(vec![r"[invalid("]); + assert!(result.is_err()); + } +} diff --git a/crates/burn-core/src/module/param/id.rs b/crates/burn-core/src/module/param/id.rs new file mode 100644 index 0000000..e6223ce --- /dev/null +++ b/crates/burn-core/src/module/param/id.rs @@ -0,0 +1 @@ +pub use burn_std::id::ParamId; diff --git a/crates/burn-core/src/module/param/mod.rs b/crates/burn-core/src/module/param/mod.rs new file mode 100644 index 0000000..52d6abe --- /dev/null +++ b/crates/burn-core/src/module/param/mod.rs @@ -0,0 +1,16 @@ +mod base; +mod constant; +mod group; +mod id; +mod primitive; +mod running; +mod sync_once_cell; +mod tensor; +mod visitor; + +pub use base::*; +pub use constant::*; +pub use group::*; +pub use id::*; +pub use running::*; +pub use visitor::*; diff --git a/crates/burn-core/src/module/param/primitive.rs b/crates/burn-core/src/module/param/primitive.rs new file mode 100644 index 0000000..1dcd23a --- /dev/null +++ b/crates/burn-core/src/module/param/primitive.rs @@ -0,0 +1,319 @@ +use crate::module::{ + AutodiffModule, Content, Module, ModuleDisplay, ModuleDisplayDefault, ModuleMapper, + ModuleVisitor, +}; + +use alloc::{format, string::ToString, vec::Vec}; + +use burn_tensor::Device; +use core::fmt::Debug; + +impl Module for Option +where + T: Module + Debug + Send + Clone, +{ + fn visit(&self, visitor: &mut V) { + if let Some(module) = self { + module.visit(visitor) + } + } + + fn map(self, mapper: &mut M) -> Self { + self.map(|module| module.map(mapper)) + } + + fn to_device(self, device: &Device) -> Self { + self.map(|module| module.to_device(device)) + } + + fn fork(self, device: &Device) -> Self { + self.map(|module| module.fork(device)) + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + if let Some(module) = self.as_ref() { + devices = module.collect_devices(devices); + } + + devices + } +} + +impl ModuleDisplayDefault for Option { + fn content(&self, content: Content) -> Option { + match self { + Some(module) => content.add_single(module).optional(), + None => content.add_single("None").optional(), + } + } +} + +impl ModuleDisplay for Option {} + +impl AutodiffModule for Option +where + T: AutodiffModule + Debug + Send + Clone, +{ + fn valid(&self) -> Self { + self.as_ref().map(|module| module.valid()) + } + + fn from_inner(module: Self) -> Self { + module.map(|module| T::from_inner(module)) + } +} + +impl Module for Vec +where + T: Module + Debug + Send + Clone, +{ + fn num_params(&self) -> usize { + let mut num_params = 0; + for module in self.iter() { + num_params += module.num_params(); + } + + num_params + } + + fn visit(&self, visitor: &mut V) { + for (i, module) in self.iter().enumerate() { + let index_str = alloc::format!("{}", i); + visitor.enter_module(&index_str, "Vec"); + module.visit(visitor); + visitor.exit_module(&index_str, "Vec"); + } + } + + fn map(self, mapper: &mut M) -> Self { + self.into_iter() + .enumerate() + .map(|(i, module)| { + let index_str = alloc::format!("{}", i); + mapper.enter_module(&index_str, "Vec"); + let mapped = module.map(mapper); + mapper.exit_module(&index_str, "Vec"); + mapped + }) + .collect() + } + + fn to_device(self, device: &Device) -> Self { + self.into_iter() + .map(|module| module.to_device(device)) + .collect() + } + + fn fork(self, device: &Device) -> Self { + self.into_iter().map(|module| module.fork(device)).collect() + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + for module in self.iter() { + devices = module.collect_devices(devices); + } + + devices + } +} + +impl ModuleDisplayDefault for Vec { + fn content(&self, content: Content) -> Option { + self.iter() + .enumerate() + .fold(content, |acc, (i, module)| { + let index = format!("{i}"); + acc.add(&index, module) + }) + .set_top_level_type(format!("Vec<0..{}>", self.len()).as_str()) + .optional() + } +} + +impl ModuleDisplay for Vec {} + +impl AutodiffModule for Vec +where + T: AutodiffModule + Debug + Send + Clone, +{ + fn valid(&self) -> Self { + self.iter().map(|module| module.valid()).collect() + } + + fn from_inner(module: Self) -> Self { + module + .into_iter() + .map(|module| T::from_inner(module)) + .collect() + } +} + +impl Module for [T; N] +where + T: Module + Debug + Send + Clone, +{ + fn collect_devices(&self, mut devices: Vec) -> Vec { + for module in self.iter() { + devices = module.collect_devices(devices); + } + + devices + } + + fn num_params(&self) -> usize { + let mut num_params = 0; + for module in self.iter() { + num_params += module.num_params(); + } + + num_params + } + + fn visit(&self, visitor: &mut V) { + for (i, module) in self.iter().enumerate() { + let index_str = alloc::format!("{}", i); + visitor.enter_module(&index_str, "Array"); + module.visit(visitor); + visitor.exit_module(&index_str, "Array"); + } + } + + fn map(self, mapper: &mut M) -> Self { + let mut result = Vec::with_capacity(N); + for (i, module) in IntoIterator::into_iter(self).enumerate() { + let index_str = alloc::format!("{}", i); + mapper.enter_module(&index_str, "Array"); + let mapped = module.map(mapper); + mapper.exit_module(&index_str, "Array"); + result.push(mapped); + } + result + .try_into() + .unwrap_or_else(|v: Vec| panic!("Expected array of length {}, got {}", N, v.len())) + } + + fn to_device(self, device: &Device) -> Self { + self.map(|module| module.to_device(device)) + } + + fn fork(self, device: &Device) -> Self { + self.map(|module| module.fork(device)) + } +} + +impl ModuleDisplayDefault for [T; N] { + fn content(&self, content: Content) -> Option { + self.iter() + .enumerate() + .fold(content, |acc, (i, module)| { + let index = format!("{i}"); + acc.add(&index, module) + }) + .set_top_level_type(format!("[0..{}]", self.len()).as_str()) + .optional() + } +} + +impl ModuleDisplay for [T; N] {} + +impl AutodiffModule for [T; N] +where + T: AutodiffModule + Debug + Send + Clone, +{ + fn valid(&self) -> Self { + self.clone().map(|module| module.valid()) + } + + fn from_inner(module: Self) -> Self { + module.map(|module| T::from_inner(module)) + } +} + +/// A macro for generating implementations for tuple modules of different sizes. +/// For example: `impl_module_tuple!([L0, L1][0, 1])`. +/// Would generate an implementation for a tuple of size 2. +/// For this macro to work properly, please adhere to the convention: +/// `impl_module_tuple!([L0, L1, ..., Ln][0, 1, ..., n])`. +macro_rules! impl_module_tuple { + // `$l` represents the generic modules. + // `$i` represents the indices of the modules in the tuple. + ([$($l:ident),*][$($i:tt),*]) => { + impl<$($l,)*> Module for ($($l,)*) + where + $($l: Module + Debug + Send + Clone,)* + { + fn collect_devices(&self, mut devices: Vec) -> Vec { + $(devices = self.$i.collect_devices(devices);)* + devices + } + + fn fork(self, device: &Device) -> Self { + ($(self.$i.fork(device),)*) + } + + fn to_device(self, device: &Device) -> Self { + ($(self.$i.to_device(device),)*) + } + + fn visit(&self, visitor: &mut V) { + $( + let index_str = $i.to_string(); + visitor.enter_module(&index_str, "Tuple"); + self.$i.visit(visitor); + visitor.exit_module(&index_str, "Tuple"); + )* + } + + fn map(self, mapper: &mut M) -> Self { + ($( + { + let index_str = $i.to_string(); + mapper.enter_module(&index_str, "Tuple"); + let mapped = self.$i.map(mapper); + mapper.exit_module(&index_str, "Tuple"); + mapped + } + ,)*) + } + + } + + impl<$($l,)*> AutodiffModule for ($($l,)*) + where + $($l: AutodiffModule + Debug + Send + Clone,)* + { + fn valid(&self) -> Self { + ($(self.$i.valid(),)*) + } + + fn from_inner(module: Self) -> Self { + ($($l::from_inner(module.$i),)*) + } + } + + impl<$($l,)*> ModuleDisplayDefault for ($($l,)*) + where + $($l: ModuleDisplay,)* + { + fn content(&self, content: Content) -> Option { + let content = content + $(.add(&format!("{}", $i), &self.$i))* + .set_top_level_type(format!("({})", stringify!($($l),*)).as_str()); + content.optional() + } + } + + impl<$($l,)*> ModuleDisplay for ($($l,)*) where $($l: ModuleDisplay,)* {} + + }; +} + +impl_module_tuple!([L0, L1][0, 1]); +impl_module_tuple!([L0, L1, L2][0, 1, 2]); +impl_module_tuple!([L0, L1, L2, L3][0, 1, 2, 3]); +impl_module_tuple!([L0, L1, L2, L3, L4][0, 1, 2, 3, 4]); +impl_module_tuple!([L0, L1, L2, L3, L4, L5][0, 1, 2, 3, 4, 5]); +impl_module_tuple!([L0, L1, L2, L3, L4, L5, L6][0, 1, 2, 3, 4, 5, 6]); +impl_module_tuple!([L0, L1, L2, L3, L4, L5, L6, L7][0, 1, 2, 3, 4, 5, 6, 7]); +impl_module_tuple!([L0, L1, L2, L3, L4, L5, L6, L7, L8][0, 1, 2, 3, 4, 5, 6, 7, 8]); +impl_module_tuple!([L0, L1, L2, L3, L4, L5, L6, L7, L8, L9][0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); diff --git a/crates/burn-core/src/module/param/running.rs b/crates/burn-core/src/module/param/running.rs new file mode 100644 index 0000000..856a0e3 --- /dev/null +++ b/crates/burn-core/src/module/param/running.rs @@ -0,0 +1,233 @@ +use super::ParamId; +use crate::module::{ + AutodiffModule, Content, Module, ModuleDisplay, ModuleDisplayDefault, ModuleMapper, + ModuleVisitor, Param, +}; + +use alloc::string::ToString; +use alloc::vec::Vec; + +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use burn_std::stub::Mutex; +use burn_tensor::{Device, Tensor}; + +#[cfg(feature = "std")] +mod threading { + pub(super) use std::collections::HashMap; + pub(super) use std::thread::ThreadId; + + #[inline(always)] + pub(super) fn get_thread_current_id() -> ThreadId { + std::thread::current().id() + } +} + +#[cfg(not(feature = "std"))] +mod threading { + pub(super) use burn_std::stub::ThreadId; + pub(super) use hashbrown::HashMap; + + #[inline(always)] + pub(super) fn get_thread_current_id() -> ThreadId { + panic!("Current thread id is not available") + } +} + +// Re-export items from the disabled/enabled blocks +use threading::*; + +/// A state that can be updated during the forward pass while being thread safe. +/// +/// # Note +/// +/// The state value is the average of all updates on all threads. +#[derive(Clone, Debug)] +pub struct RunningState { + id: ParamId, + values: Arc>>, + value: Arc>, +} + +// Implement display for the module + +impl core::fmt::Display for RunningState { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "RunningState(id={})", self.id) + } +} + +impl ModuleDisplayDefault for RunningState { + fn content(&self, content: Content) -> Option { + content + .add_formatted(&"RunningState".to_string()) + .optional() + } +} + +impl ModuleDisplay for RunningState {} + +impl Module for RunningState> { + fn visit(&self, visitor: &mut V) { + let tensor = self.value.lock().unwrap(); + let param = Param::initialized(self.id, tensor.clone()); + visitor.visit_float(¶m) + } + + fn map(self, mapper: &mut M) -> Self { + let mut tensor = self.value.lock().unwrap(); + let param = Param::initialized(self.id, tensor.clone()); + let param_out = mapper.map_float(param); + let (_, tensor_out, _) = param_out.consume(); + + *tensor = tensor_out; + core::mem::drop(tensor); + + self + } + + fn to_device(self, device: &Device) -> Self { + let mut tensor = self.value.lock().unwrap(); + let tensor_out = tensor.clone().to_device(device); + + *tensor = tensor_out; + core::mem::drop(tensor); + + self + } + + fn fork(self, device: &Device) -> Self { + self.to_device(device) // Same thing here since no grad. + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + let device = self.value.lock().unwrap().device(); + + if !devices.contains(&device) { + devices.push(device) + } + + devices + } +} + +impl RunningState> { + /// Create a new running state. + pub fn new(value: Tensor) -> Self { + Self { + id: ParamId::new(), + values: Arc::new(Mutex::new(HashMap::new())), + value: Arc::new(Mutex::new(value)), + } + } + + /// Create a new running state. + pub fn with_id(id: ParamId, value: Tensor) -> Self { + Self { + id, + values: Arc::new(Mutex::new(HashMap::new())), + value: Arc::new(Mutex::new(value)), + } + } + + /// Create a new running state from a record. + pub fn from_record(record: Param>) -> Self { + let tensor = record.val(); + Self { + id: record.id, + values: Arc::new(Mutex::new(HashMap::new())), + value: Arc::new(Mutex::new(tensor)), + } + } + + /// Update the value on the current thread. + pub fn update(&self, value: Tensor) { + let thread_id = get_thread_current_id(); + let mut map = self.values.lock().unwrap(); + + if map.contains_key(&thread_id) { + self.update_value(&mut map); + } + + map.insert(thread_id, value); + } + + /// Get the current value, + /// + /// # Note + /// + /// The current value might be outdated by one update. + pub fn value(&self) -> Tensor { + let value = self.value.lock().unwrap(); + value.clone() + } + + /// Get the current value and make sure it is sync. + /// + /// # Note + /// + /// Don't use this function after an update on the same thread where other threads might have to + /// register their update before the actual synchronization needs to happen. + pub fn value_sync(&self) -> Tensor { + let thread_id = get_thread_current_id(); + let mut map = self.values.lock().unwrap(); + + if map.contains_key(&thread_id) { + self.update_value(&mut map); + } + + let value = self.value.lock().unwrap(); + value.clone() + } + + fn sync(&self) { + let mut map = self.values.lock().unwrap(); + + if !map.is_empty() { + self.update_value(&mut map); + } + } + + fn update_value(&self, map: &mut HashMap>) { + let mut value_updated: Option> = None; + let mut counter = 0; + + for (_key, tensor) in map.drain() { + counter += 1; + + value_updated = match value_updated { + Some(current) => { + let device = current.device(); + Some(tensor.to_device(&device).add(current)) + } + None => Some(tensor), + }; + } + + if let Some(value) = value_updated { + let value = value.div_scalar(counter); + let mut value_old = self.value.lock().unwrap(); + *value_old = value; + } + } +} + +impl AutodiffModule for RunningState> { + fn valid(&self) -> Self { + self.sync(); + let value = self.value(); + + RunningState::with_id(self.id, value.inner()) + } + + fn from_inner(module: Self) -> Self { + module.sync(); + let value = module.value(); + + RunningState::with_id(module.id, Tensor::from_inner(value)) + } +} diff --git a/crates/burn-core/src/module/param/sync_once_cell.rs b/crates/burn-core/src/module/param/sync_once_cell.rs new file mode 100644 index 0000000..68cc133 --- /dev/null +++ b/crates/burn-core/src/module/param/sync_once_cell.rs @@ -0,0 +1,60 @@ +//! A `Sync`-compatible single-initialization cell for both `std` and `no_std`. +//! +//! Wraps `std::sync::OnceLock` (with `std`) or `spin::Once` (without `std`) behind +//! a unified API. This makes `Param` `Sync` so models can be shared across threads +//! for parallel inference. +//! +//! We define our own wrapper instead of reusing `burn_std::stub::SyncOnceCell` because +//! that version requires `T: Debug` on all methods and lacks `get()`. + +#[cfg(feature = "std")] +use std::sync::OnceLock as Inner; + +#[cfg(not(feature = "std"))] +use spin::Once as Inner; + +pub(crate) struct SyncOnceCell(Inner); + +impl core::fmt::Debug for SyncOnceCell { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_tuple("SyncOnceCell").field(&self.get()).finish() + } +} + +impl SyncOnceCell { + /// Create a new empty cell. + pub fn new() -> Self { + Self(Inner::new()) + } + + /// Create a cell pre-populated with `value`. + pub fn initialized(value: T) -> Self { + #[cfg(feature = "std")] + { + let cell = Inner::new(); + cell.set(value).ok().expect("cell was just created"); + Self(cell) + } + #[cfg(not(feature = "std"))] + { + Self(Inner::initialized(value)) + } + } + + /// Returns `Some(&T)` if initialized, `None` otherwise. + pub fn get(&self) -> Option<&T> { + self.0.get() + } + + /// Returns `&T`, initializing with `f` on first call. + pub fn get_or_init T>(&self, f: F) -> &T { + #[cfg(feature = "std")] + { + self.0.get_or_init(f) + } + #[cfg(not(feature = "std"))] + { + self.0.call_once(f) + } + } +} diff --git a/crates/burn-core/src/module/param/tensor.rs b/crates/burn-core/src/module/param/tensor.rs new file mode 100644 index 0000000..cab5973 --- /dev/null +++ b/crates/burn-core/src/module/param/tensor.rs @@ -0,0 +1,341 @@ +use super::{Param, ParamId, Parameter}; +use crate::module::{ + AutodiffModule, Content, Module, ModuleDisplay, ModuleDisplayDefault, ModuleMapper, + ModuleVisitor, +}; +use alloc::{format, string::ToString, vec::Vec}; +use burn_tensor::{Bool, Device, Float, Int, Tensor, TensorData}; + +impl super::sealed::Sealed for Tensor {} +impl super::sealed::Sealed for Tensor {} +impl super::sealed::Sealed for Tensor {} + +impl Parameter for Tensor { + fn device(&self) -> Device { + Tensor::device(self) + } + + fn is_require_grad(&self) -> bool { + Tensor::is_require_grad(self) + } + + fn set_require_grad(self, require_grad: bool) -> Self { + Tensor::set_require_grad(self, require_grad) + } + + fn shape(&self) -> burn_std::Shape { + Tensor::shape(self) + } + + fn load_to_device(self, device: &Device) -> Self { + if self.device() != *device { + Tensor::to_device(self, device).detach() + } else { + self + } + } +} + +impl Parameter for Tensor { + fn device(&self) -> Device { + Tensor::device(self) + } + + fn is_require_grad(&self) -> bool { + false + } + + fn set_require_grad(self, _require_grad: bool) -> Self { + self + } + + fn shape(&self) -> burn_std::Shape { + Tensor::shape(self) + } + + fn load_to_device(self, device: &Device) -> Self { + if self.device() != *device { + Tensor::to_device(self, device) + } else { + self + } + } +} + +impl Parameter for Tensor { + fn device(&self) -> Device { + Tensor::device(self) + } + + fn is_require_grad(&self) -> bool { + false + } + + fn set_require_grad(self, _require_grad: bool) -> Self { + self + } + + fn shape(&self) -> burn_std::Shape { + Tensor::shape(self) + } + + fn load_to_device(self, device: &Device) -> Self { + if self.device() != *device { + Tensor::to_device(self, device) + } else { + self + } + } +} + +impl Param> { + /// Create a new parameter from a float tensor. + /// + /// # Warnings + /// + /// We strongly recommend using [Param::uninitialized] if you are using this method to + /// initialize parameters inside a module, since the tensor initialization will be lazy, + /// making the loading of weights more performant. + pub fn from_tensor(value: Tensor) -> Self { + // When creating a parameter from a float tensor, we automatically mark it as requiring + // gradients, so that it can be updated by an optimizer. + Param::initialized(ParamId::new(), value.require_grad()) + } + + /// Create a new parameter from data. + pub fn from_data(data: T, device: &Device) -> Self + where + T: Into, + { + let data: TensorData = data.into(); + // When creating a parameter from a float tensor, we automatically mark it as requiring + // gradients, so that it can be updated by an optimizer. + device.memory_persistent_allocations(data, |data| { + let value = Tensor::from_data(data, device); + Param::initialized(ParamId::new(), value.require_grad()) + }) + } +} + +impl Module for Param> { + fn visit(&self, visitor: &mut V) { + visitor.visit_float(self) + } + + fn map(self, mapper: &mut M) -> Self { + mapper.map_float(self) + } + + fn to_device(self, device: &Device) -> Self { + self.map(|tensor| tensor.to_device(device)) + } + + fn fork(self, device: &Device) -> Self { + self.map(|tensor| { + let is_require_grad = tensor.is_require_grad(); + let mut tensor = tensor.to_device(device).detach(); + + if is_require_grad { + tensor = tensor.require_grad(); + } + + tensor + }) + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + let device = self.val().device(); + + if !devices.contains(&device) { + devices.push(device) + } + + devices + } +} + +impl ModuleDisplayDefault for Param> { + fn content(&self, content: Content) -> Option { + let id = if content.display_settings.show_param_id() { + format!(", id: {}", self.id) + } else { + "".to_string() + }; + let string = format!( + "ParamTensor {{rank: {D}, shape: {:?}, kind: float{id}}}", + self.shape().as_slice() + ); + content.add_formatted(&string).optional() + } +} +impl ModuleDisplay for Param> {} + +impl Module for Param> { + fn visit(&self, visitor: &mut V) { + visitor.visit_int(self) + } + + fn map(self, mapper: &mut M) -> Self { + mapper.map_int(self) + } + + fn to_device(self, device: &Device) -> Self { + self.map(|tensor| tensor.to_device(device)) + } + + fn fork(self, device: &Device) -> Self { + self.to_device(device) // Don't support autodiff. + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + let device = self.val().device(); + + if !devices.contains(&device) { + devices.push(device) + } + + devices + } +} + +impl ModuleDisplayDefault for Param> { + fn content(&self, content: Content) -> Option { + let id = if content.display_settings.show_param_id() { + format!(", id: {}", self.id) + } else { + "".to_string() + }; + let string = format!( + "ParamTensor {{rank: {D}, shape: {:?}, kind: int{id}}}", + self.shape().as_slice() + ); + content.add_formatted(&string).optional() + } +} +impl ModuleDisplay for Param> {} + +impl Module for Param> { + fn visit(&self, visitor: &mut V) { + visitor.visit_bool(self) + } + + fn map(self, mapper: &mut M) -> Self { + mapper.map_bool(self) + } + + fn to_device(self, device: &Device) -> Self { + self.map(|tensor| tensor.to_device(device)) + } + + fn fork(self, device: &Device) -> Self { + self.to_device(device) // Don't support autodiff. + } + + fn collect_devices(&self, mut devices: Vec) -> Vec { + let device = self.val().device(); + + if !devices.contains(&device) { + devices.push(device) + } + + devices + } +} + +impl ModuleDisplayDefault for Param> { + fn content(&self, content: Content) -> Option { + let id = if content.display_settings.show_param_id() { + format!(", id: {}", self.id) + } else { + "".to_string() + }; + + let string = format!( + "ParamTensor {{rank: {D}, shape: {:?}, kind: bool{id}}}", + self.shape().as_slice() + ); + content.add_formatted(&string).optional() + } +} + +impl ModuleDisplay for Param> {} + +impl AutodiffModule for Param> { + fn valid(&self) -> Self { + // Preserve initialized param `require_grad` state, but reset the inner value's + let require_grad = self.require_grad; + let mut param = Param::initialized(self.id, self.val().inner().set_require_grad(false)); + param.require_grad = require_grad; + param + } + + fn from_inner(module: Self) -> Self { + // Reinstate the param's `require_grad` state + let tensor = Tensor::from_inner(module.val()).set_require_grad(module.require_grad); + Param::initialized(module.id, tensor) + } +} + +// impl HasAutodiffModule +// for Param> +// { +// type TrainModule = Param>; +// } + +impl AutodiffModule for Param> { + fn valid(&self) -> Self { + Param::initialized(self.id, self.val().inner()) + } + + fn from_inner(module: Self) -> Self { + Param::initialized(module.id, Tensor::from_inner(module.val())) + } +} + +impl AutodiffModule for Param> { + fn valid(&self) -> Self { + Param::initialized(self.id, self.val().inner()) + } + + fn from_inner(module: Self) -> Self { + Param::initialized(module.id, Tensor::from_inner(module.val())) + } +} + +#[cfg(all(test, feature = "std", feature = "autodiff"))] +mod tests { + use super::*; + use crate::{module::Module, test_device}; + + #[test] + fn test_param_require_grad_stateful() { + let device = test_device().autodiff(); + let tensor = Tensor::<2>::ones([3, 3], &device).require_grad(); + + let param = Param::initialized(ParamId::new(), tensor); + assert!(param.is_require_grad()); + assert!(param.require_grad); + + let param = param.valid(); + assert!(!param.is_require_grad()); + assert!(param.require_grad); // stateful + + // Without `HasAutodiffModule`, we would need to specify the param type as well, which would be annoying: + // let param: Param> = param.train(); + let param = param.train(); + assert!(param.is_require_grad()); + assert!(param.require_grad); // stateful + + let param = param.no_grad(); + assert!(!param.is_require_grad()); + assert!(!param.require_grad); // stateful + + let param = param.valid(); + assert!(!param.is_require_grad()); // always + assert!(!param.require_grad); // stateful + + let param = param.train(); + assert!(!param.is_require_grad()); + assert!(!param.require_grad); // stateful + } +} diff --git a/crates/burn-core/src/module/param/visitor.rs b/crates/burn-core/src/module/param/visitor.rs new file mode 100644 index 0000000..e099993 --- /dev/null +++ b/crates/burn-core/src/module/param/visitor.rs @@ -0,0 +1,37 @@ +use super::{Param, ParamId}; +use crate::module::{Module, ModuleVisitor}; +use alloc::vec::Vec; +use burn_tensor::{Bool, Int, Tensor}; +use core::marker::PhantomData; + +struct ParamIdCollector<'a, M> { + param_ids: &'a mut Vec, + phantom: PhantomData, +} + +impl ModuleVisitor for ParamIdCollector<'_, M> +where + M: Module, +{ + fn visit_float(&mut self, param: &Param>) { + self.param_ids.push(param.id); + } + fn visit_int(&mut self, param: &Param>) { + self.param_ids.push(param.id); + } + fn visit_bool(&mut self, param: &Param>) { + self.param_ids.push(param.id); + } +} + +/// List all the parameter ids in a module. +pub fn list_param_ids(module: &M) -> Vec { + let mut params_ids = Vec::new(); + let mut visitor = ParamIdCollector { + param_ids: &mut params_ids, + phantom: PhantomData::, + }; + module.visit(&mut visitor); + + params_ids +} diff --git a/crates/burn-core/src/module/quantize.rs b/crates/burn-core/src/module/quantize.rs new file mode 100644 index 0000000..283c7a7 --- /dev/null +++ b/crates/burn-core/src/module/quantize.rs @@ -0,0 +1,62 @@ +use burn_tensor::{ + Tensor, + quantization::{Calibration, QuantScheme, compute_q_params, compute_range}, +}; + +use crate::module::{ModuleMapper, Param}; + +/// Describes how to quantize a module. +pub struct Quantizer { + /// The calibration method used in quantization. + pub calibration: Calibration, + /// The quantization scheme. + pub scheme: QuantScheme, +} + +impl ModuleMapper for Quantizer { + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + let range = compute_range(&self.scheme, &tensor, &self.calibration); + let qparams = compute_q_params(&self.scheme, range); + let tensor = tensor.quantize(&self.scheme, qparams); + Param::from_mapped_value(id, tensor, mapper) + } +} + +#[cfg(all(test, not(feature = "tch")))] +mod tests { + use crate::module::{Module, Quantizer}; + use crate::test_device; + use crate::test_utils::SimpleLinear; + use burn_tensor::{ + Tolerance, + quantization::{Calibration, QuantLevel, QuantParam, QuantValue}, + }; + + #[test] + fn should_quantize_module() { + let device = test_device(); + let module = SimpleLinear::new(32, 32, &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Tensor) + .with_param(QuantParam::F32); + + let result = module.weight.val(); + + let calibration = Calibration::MinMax; + let mut quantizer = Quantizer { + calibration, + scheme, + }; + let q_module = module.quantize_weights(&mut quantizer); + let q_result = q_module.weight.val().dequantize(); + + result + .into_data() + .assert_approx_eq::(&q_result.into_data(), Tolerance::permissive()); + } +} diff --git a/crates/burn-core/src/store/mod.rs b/crates/burn-core/src/store/mod.rs new file mode 100644 index 0000000..a494c02 --- /dev/null +++ b/crates/burn-core/src/store/mod.rs @@ -0,0 +1,554 @@ +//! Minimal, non-generic record system for saving and loading module parameters. +//! +//! A [`ModuleRecord`](crate::store::ModuleRecord) holds a module's parameters (path + +//! [`ParamId`](crate::module::ParamId) + [`TensorData`](crate::tensor::TensorData)) and +//! serializes them with the [burnpack](burn_pack) format. It is produced and applied through the +//! [`Module`](crate::module::Module) trait itself ([`Module::into_record`](crate::module::Module::into_record) / +//! [`Module::load_record`](crate::module::Module::load_record)). +//! +//! This module is intentionally tiny: traversal is a straightforward +//! [`ModuleVisitor`](crate::module::ModuleVisitor) / [`ModuleMapper`](crate::module::ModuleMapper) +//! keyed by parameter path, with no filtering, adapters, or lazy snapshots. +//! The richer snapshot/import tooling (filtering, key remapping, PyTorch/SafeTensors adapters, +//! cross-framework stores) lives in the `burn-store` crate. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use hashbrown::HashMap; + +use crate::module::{Module, ModuleMapper, ModuleVisitor, Param, ParamId}; +use crate::tensor::{Bool, DType, Device, Float, Int, Shape, Tensor, TensorData, kind::Basic}; + +use burn_pack::{Reader, Writer}; + +/// Controls how a parameter's dtype is resolved when loading a [`ModuleRecord`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DTypePolicy { + /// The module parameter adopts the record's dtype (data is loaded verbatim). Default. + #[default] + FromRecord, + /// The record's data is cast to the module parameter's current dtype on load. + /// + /// Note this materializes each target parameter to read its dtype. + CastToModule, +} + +/// Error returned by [`ModuleRecord`] save/load and [`Module`] apply operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordError { + /// An I/O or format error occurred while reading or writing the record. + Io(String), + /// Validation failed while applying the record (shape mismatch, or missing tensors + /// when partial loading is not allowed). + Validation(String), +} + +impl core::fmt::Display for RecordError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + RecordError::Io(msg) => write!(f, "Record I/O error: {msg}"), + RecordError::Validation(msg) => write!(f, "Record validation error: {msg}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RecordError {} + +impl From for RecordError { + fn from(err: burn_pack::Error) -> Self { + RecordError::Io(err.to_string()) + } +} + +/// A single recorded tensor: its module path, parameter id, and data. +#[derive(Clone)] +struct RecordTensor { + path: String, + id: ParamId, + data: TensorData, +} + +/// A non-generic record holding a module's parameters. +/// +/// Obtain one from a module with [`Module::into_record`], then either save it +/// ([`save`](ModuleRecord::save) / [`into_bytes`](ModuleRecord::into_bytes)) or apply it back with +/// [`Module::load_record`]. Load-time behavior is +/// configured with the builder methods; they are ignored when saving. +/// +/// The save-side dtype is intentionally not configurable: use `module.cast(dtype)` before +/// taking the record. The record stores whatever dtype the module currently holds. +#[derive(Clone)] +pub struct ModuleRecord { + tensors: Vec, + dtype_policy: DTypePolicy, + allow_partial: bool, + validate: bool, +} + +impl core::fmt::Debug for ModuleRecord { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ModuleRecord") + .field("num_tensors", &self.tensors.len()) + .field("dtype_policy", &self.dtype_policy) + .field("allow_partial", &self.allow_partial) + .field("validate", &self.validate) + .finish() + } +} + +impl ModuleRecord { + fn from_tensors(tensors: Vec) -> Self { + Self { + tensors, + dtype_policy: DTypePolicy::default(), + allow_partial: false, + validate: true, + } + } + + /// The number of tensors in the record. + pub fn len(&self) -> usize { + self.tensors.len() + } + + /// Whether the record holds no tensors. + pub fn is_empty(&self) -> bool { + self.tensors.is_empty() + } + + /// Set the dtype policy used when loading into a module. + pub fn with_dtype_policy(mut self, policy: DTypePolicy) -> Self { + self.dtype_policy = policy; + self + } + + /// Cast the record's data to the module parameter dtypes on load. + /// + /// Sugar for [`with_dtype_policy(DTypePolicy::CastToModule)`](ModuleRecord::with_dtype_policy). + pub fn cast_to_module_dtype(self) -> Self { + self.with_dtype_policy(DTypePolicy::CastToModule) + } + + /// Allow loading even when some module parameters are absent from the record. + pub fn allow_partial(mut self, allow: bool) -> Self { + self.allow_partial = allow; + self + } + + /// Enable or disable validation while loading. + pub fn validate(mut self, validate: bool) -> Self { + self.validate = validate; + self + } + + /// Serialize the record to an in-memory burnpack byte buffer. + pub fn into_bytes(self) -> Result { + Ok(Writer::new(self.pack_tensors()).into_bytes()?) + } + + /// Reconstruct a record from an in-memory burnpack byte buffer. + pub fn from_bytes(bytes: crate::tensor::Bytes) -> Result { + Self::from_reader(Reader::from_bytes(bytes)?) + } + + /// Save the record to a burnpack file on disk. + #[cfg(feature = "std")] + pub fn save>(self, path: P) -> Result<(), RecordError> { + Writer::new(self.pack_tensors()).write_to_file(path)?; + Ok(()) + } + + /// Load a record from a burnpack file on disk. + #[cfg(feature = "std")] + pub fn load>(path: P) -> Result { + Self::from_reader(Reader::from_file(path)?) + } + + fn pack_tensors(self) -> Vec { + self.tensors + .into_iter() + .map(|t| { + burn_pack::Tensor::new( + t.path, + t.data.dtype, + t.data.shape, + Some(t.id.val()), + t.data.bytes, + ) + }) + .collect() + } + + fn from_reader(reader: Reader) -> Result { + let tensors = reader + .into_tensors()? + .into_iter() + .map(|t| { + let id = t.param_id.map(ParamId::from).unwrap_or_else(ParamId::new); + let data = TensorData::from_bytes(t.bytes, t.shape, t.dtype); + RecordTensor { + path: t.name, + id, + data, + } + }) + .collect(); + Ok(Self::from_tensors(tensors)) + } + + /// Collect a module's parameters into a [`ModuleRecord`]. + /// + /// Backs [`Module::into_record`](crate::module::Module::into_record). + pub(crate) fn from_module(module: M) -> Self { + let mut collector = Collector::default(); + module.visit(&mut collector); + ModuleRecord::from_tensors(collector.tensors) + } + + /// Apply this record to a module, returning the loaded module. + /// + /// Honors the record's [`DTypePolicy`], `validate`, and `allow_partial` settings. Backs + /// [`Module::try_load_record`](crate::module::Module::try_load_record). + pub(crate) fn apply(self, module: M) -> Result { + let validate = self.validate; + let allow_partial = self.allow_partial; + + let mut mapper = ModuleRecordMapper::new(self); + let module = module.map(&mut mapper); + + if validate && !mapper.errors.is_empty() { + return Err(RecordError::Validation(format!( + "Apply errors: {:?}", + mapper.errors + ))); + } + if !allow_partial && !mapper.missing.is_empty() { + return Err(RecordError::Validation(format!( + "Missing tensors: {:?}", + mapper.missing + ))); + } + + Ok(module) + } +} + +/// Visitor that collects every parameter as a [`RecordTensor`], keyed by its module path. +#[derive(Default)] +struct Collector { + path: Vec, + tensors: Vec, +} + +impl Collector { + fn record(&mut self, id: ParamId, data: TensorData) { + self.tensors.push(RecordTensor { + path: self.path.join("."), + id, + data, + }); + } +} + +impl ModuleVisitor for Collector { + fn enter_module(&mut self, name: &str, _container_type: &str) { + self.path.push(name.to_string()); + } + + fn exit_module(&mut self, _name: &str, _container_type: &str) { + self.path.pop(); + } + + fn visit_float(&mut self, param: &Param>) { + self.record(param.id, param.val().into_data()); + } + + fn visit_int(&mut self, param: &Param>) { + self.record(param.id, param.val().into_data()); + } + + fn visit_bool(&mut self, param: &Param>) { + self.record(param.id, param.val().into_data()); + } +} + +/// Mapper that loads recorded tensors back onto matching parameters by module path. +struct ModuleRecordMapper { + path: Vec, + tensors: HashMap, + dtype_policy: DTypePolicy, + missing: Vec, + errors: Vec, +} + +impl ModuleRecordMapper { + fn new(record: ModuleRecord) -> Self { + let tensors = record + .tensors + .into_iter() + .map(|t| (t.path, t.data)) + .collect(); + Self { + path: Vec::new(), + tensors, + dtype_policy: record.dtype_policy, + missing: Vec::new(), + errors: Vec::new(), + } + } + + /// Look up the recorded tensor for the current path and build the tensor to load, + /// or `None` (recording it as missing / errored) to leave the parameter unchanged. + /// + /// `module_dtype` is only evaluated on a hit under [`DTypePolicy::CastToModule`], so a + /// missing parameter never materializes its current value just to read a dtype. + fn take( + &mut self, + device: &Device, + target_shape: Shape, + module_dtype: impl FnOnce() -> DType, + ) -> Option> { + let path = self.path.join("."); + let data = match self.tensors.remove_entry(&path) { + Some(data) => data.1, + None => { + self.missing.push(path); + return None; + } + }; + + // Resolve the dtype to load with (CastToModule casts to the module's dtype). + let dtype = match self.dtype_policy { + DTypePolicy::FromRecord => data.dtype, + DTypePolicy::CastToModule => module_dtype(), + }; + + if data.shape != target_shape { + self.errors.push(format!( + "{path}: shape mismatch, expected {:?} but record has {:?}", + target_shape, data.shape + )); + return None; + } + + Some(Tensor::from_data(data, (device, dtype))) + } +} + +/// Generate a `ModuleMapper::map_*` method for one tensor kind. The three kinds differ only +/// in the tensor type, so the body — collect identity, look the tensor up, load on a hit — is +/// shared here. +macro_rules! map_kind { + ($method:ident, $kind:ty) => { + fn $method( + &mut self, + param: Param>, + ) -> Param> { + let id = param.id; + let device = param.lazy_device(); + let shape = param.lazy_shape(); + match self.take(&device, shape, || param.val().dtype()) { + Some(tensor) => param.transform_for_load(tensor, id), + None => param, + } + } + }; +} + +impl ModuleMapper for ModuleRecordMapper { + fn enter_module(&mut self, name: &str, _container_type: &str) { + self.path.push(name.to_string()); + } + + fn exit_module(&mut self, _name: &str, _container_type: &str) { + self.path.pop(); + } + + map_kind!(map_float, Float); + map_kind!(map_int, Int); + map_kind!(map_bool, Bool); +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate as burn; + use crate::module::{Module, Param}; + use crate::tensor::Tensor; + use burn_tensor::Device; + + #[derive(Module, Debug)] + struct Tiny { + weight: Param>, + bias: Param>, + } + + impl Tiny { + fn new(weight: [[f32; 2]; 2], bias: [f32; 2], device: &Device) -> Self { + Self { + weight: Param::from_data(weight, device), + bias: Param::from_data(bias, device), + } + } + } + + #[derive(Module, Debug)] + struct TinyWide { + weight: Param>, + bias: Param>, + gamma: Param>, + } + + impl TinyWide { + fn zeros(device: &Device) -> Self { + Self { + weight: Param::from_data([[0.0, 0.0], [0.0, 0.0]], device), + bias: Param::from_data([0.0, 0.0], device), + gamma: Param::from_data([0.0, 0.0], device), + } + } + } + + fn weights(model: &Tiny) -> (Vec, Vec) { + ( + model.weight.val().to_data().to_vec().unwrap(), + model.bias.val().to_data().to_vec().unwrap(), + ) + } + + #[test] + fn round_trip_in_memory() { + let device = Default::default(); + let model = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device); + + let bytes = model.into_record().into_bytes().unwrap(); + let record = ModuleRecord::from_bytes(bytes).unwrap(); + assert_eq!(record.len(), 2); + + let loaded = Tiny::new([[0.0; 2]; 2], [0.0; 2], &device).load_record(record); + let (w, b) = weights(&loaded); + assert_eq!(w, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(b, vec![5.0, 6.0]); + } + + #[test] + fn round_trip_file() { + let device = Default::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tiny.bpk"); + + Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device) + .into_record() + .save(&path) + .unwrap(); + + let record = ModuleRecord::load(&path).unwrap(); + let loaded = Tiny::new([[0.0; 2]; 2], [0.0; 2], &device).load_record(record); + let (w, b) = weights(&loaded); + assert_eq!(w, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(b, vec![5.0, 6.0]); + } + + #[test] + fn missing_tensor_requires_allow_partial() { + let device = Default::default(); + // Tiny has weight+bias; TinyWide also expects `gamma`, which the record lacks. + let record = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device).into_record(); + + let strict = TinyWide::zeros(&device).try_load_record(record.clone()); + assert!(matches!(strict, Err(RecordError::Validation(_)))); + + let partial = TinyWide::zeros(&device).try_load_record(record.allow_partial(true)); + assert!(partial.is_ok()); + let loaded = partial.unwrap(); + // weight/bias were loaded; gamma kept its (zero) initialization. + assert_eq!( + loaded.weight.val().to_data().to_vec::().unwrap(), + vec![1.0, 2.0, 3.0, 4.0] + ); + assert_eq!( + loaded.gamma.val().to_data().to_vec::().unwrap(), + vec![0.0, 0.0] + ); + } + + /// Build a `Tiny` whose parameters are zero-valued and carry the given dtype. + fn tiny_with_dtype(device: &Device, dtype: DType) -> Tiny { + Tiny { + weight: Param::from_tensor( + Tensor::<2>::from_data([[0.0, 0.0], [0.0, 0.0]], device).cast(dtype), + ), + bias: Param::from_tensor(Tensor::<1>::from_data([0.0, 0.0], device).cast(dtype)), + } + } + + #[test] + fn dtype_policy_from_record_keeps_record_dtype() { + let device = Default::default(); + // Record holds f32 data. + let record = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device).into_record(); + + // Target params are f64, but the default policy (FromRecord) loads the data verbatim (f32). + let loaded = tiny_with_dtype(&device, DType::F64).load_record(record); + assert_eq!(loaded.weight.val().dtype(), DType::F32); + assert_eq!(loaded.bias.val().dtype(), DType::F32); + } + + #[test] + fn dtype_policy_cast_to_module_uses_module_dtype() { + let device = Default::default(); + let record = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device).into_record(); + + // CastToModule casts the record's f32 data to the module's f64 params on load. + let loaded = + tiny_with_dtype(&device, DType::F64).load_record(record.cast_to_module_dtype()); + assert_eq!(loaded.weight.val().dtype(), DType::F64); + assert_eq!(loaded.bias.val().dtype(), DType::F64); + // Values survive the cast. + assert_eq!( + loaded.weight.val().to_data().to_vec::().unwrap(), + vec![1.0, 2.0, 3.0, 4.0] + ); + } + + /// Build a `Tiny` whose `bias` shape (len 3) does not match the record's (len 2). + fn tiny_wrong_bias_shape(device: &Device) -> Tiny { + Tiny { + weight: Param::from_data([[0.0, 0.0], [0.0, 0.0]], device), + bias: Param::from_data([0.0, 0.0, 0.0], device), + } + } + + #[test] + fn shape_mismatch_fails_validation() { + let device = Default::default(); + let record = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device).into_record(); + + // `bias` shape mismatch is reported as a validation error by default. + let result = tiny_wrong_bias_shape(&device).try_load_record(record); + assert!(matches!(result, Err(RecordError::Validation(_)))); + } + + #[test] + fn validate_false_ignores_shape_mismatch() { + let device = Default::default(); + let record = Tiny::new([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device).into_record(); + + // With validation disabled, the shape-mismatched `bias` is skipped (keeps its value) + // while the matching `weight` still loads. + let loaded = tiny_wrong_bias_shape(&device) + .try_load_record(record.validate(false)) + .unwrap(); + assert_eq!( + loaded.weight.val().to_data().to_vec::().unwrap(), + vec![1.0, 2.0, 3.0, 4.0] + ); + assert_eq!( + loaded.bias.val().to_data().to_vec::().unwrap(), + vec![0.0, 0.0, 0.0] + ); + } +} diff --git a/crates/burn-core/src/tensor.rs b/crates/burn-core/src/tensor.rs new file mode 100644 index 0000000..074606b --- /dev/null +++ b/crates/burn-core/src/tensor.rs @@ -0,0 +1 @@ +pub use burn_tensor::*; diff --git a/crates/burn-core/tests/test_derive_config.rs b/crates/burn-core/tests/test_derive_config.rs new file mode 100644 index 0000000..65c4376 --- /dev/null +++ b/crates/burn-core/tests/test_derive_config.rs @@ -0,0 +1,113 @@ +use burn::config::{Config, config_to_json}; +use burn_core as burn; + +#[derive(Config, Debug, PartialEq, Eq)] +pub struct TestEmptyStructConfig {} + +#[derive(Config, Debug, PartialEq)] +pub struct TestStructConfig { + int: i32, + #[config(default = 2)] + int_default: i32, + float: f32, + #[config(default = 2.0)] + float_default: f32, + string: String, + other_config: TestEmptyStructConfig, +} + +#[derive(Config, Debug, PartialEq)] +pub enum TestEnumConfig { + None, + Single(f32), + Multiple(f32, String), + Named { first: f32, second: String }, +} + +#[cfg(feature = "std")] +#[inline(always)] +fn file_path(file_name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(file_name) +} + +#[cfg(feature = "std")] +#[test] +fn struct_config_should_impl_serde() { + let config = TestStructConfig::new(2, 3.0, "Allow".to_string(), TestEmptyStructConfig::new()); + let file_path = file_path("test_struct_config.json"); + + config.save(&file_path).unwrap(); + + let config_loaded = TestStructConfig::load(&file_path).unwrap(); + assert_eq!(config, config_loaded); +} + +#[test] +fn struct_config_should_impl_clone() { + let config = TestStructConfig::new(2, 3.0, "Allow".to_string(), TestEmptyStructConfig::new()); + assert_eq!(config, config.clone()); +} + +#[test] +fn struct_config_should_impl_display() { + let config = TestStructConfig::new(2, 3.0, "Allow".to_string(), TestEmptyStructConfig::new()); + assert_eq!(burn::config::config_to_json(&config), config.to_string()); +} + +#[cfg(feature = "std")] +#[test] +fn enum_config_no_value_should_impl_serde() { + let config = TestEnumConfig::None; + let file_path = file_path("test_enum_no_value_config.json"); + + config.save(&file_path).unwrap(); + + let config_loaded = TestEnumConfig::load(&file_path).unwrap(); + assert_eq!(config, config_loaded); +} + +#[cfg(feature = "std")] +#[test] +fn enum_config_one_value_should_impl_serde() { + let config = TestEnumConfig::Single(42.0); + let file_path = file_path("test_enum_one_value_config.json"); + + config.save(&file_path).unwrap(); + + let config_loaded = TestEnumConfig::load(&file_path).unwrap(); + assert_eq!(config, config_loaded); +} + +#[cfg(feature = "std")] +#[test] +fn enum_config_multiple_values_should_impl_serde() { + let config = TestEnumConfig::Multiple(42.0, "Allow".to_string()); + let file_path = file_path("test_enum_multiple_values_config.json"); + + config.save(&file_path).unwrap(); + + let config_loaded = TestEnumConfig::load(&file_path).unwrap(); + assert_eq!(config, config_loaded); +} + +#[test] +fn enum_config_should_impl_clone() { + let config = TestEnumConfig::Multiple(42.0, "Allow".to_string()); + assert_eq!(config, config.clone()); +} + +#[test] +fn enum_config_should_impl_display() { + let config = TestEnumConfig::Multiple(42.0, "Allow".to_string()); + assert_eq!(burn::config::config_to_json(&config), config.to_string()); +} + +#[test] +fn struct_config_can_load_binary() { + let config = TestStructConfig::new(2, 3.0, "Allow".to_string(), TestEmptyStructConfig::new()); + + let binary = config_to_json(&config).as_bytes().to_vec(); + + let config_loaded = TestStructConfig::load_binary(&binary).unwrap(); + assert_eq!(config, config_loaded); +} diff --git a/crates/burn-core/tests/test_derive_module.rs b/crates/burn-core/tests/test_derive_module.rs new file mode 100644 index 0000000..096bd3e --- /dev/null +++ b/crates/burn-core/tests/test_derive_module.rs @@ -0,0 +1,772 @@ +use burn::module::Initializer; +use burn::module::{Module, Param}; +use burn::tensor::{Int, Tensor}; +use burn_core as burn; +use burn_tensor::Device; + +#[derive(Module, Debug)] +pub struct ModuleBasic { + weight_basic: Param>, +} + +#[derive(Module, Debug)] +#[allow(unused)] +struct ModuleTensorConstInt { + weight_basic: Tensor<2, Int>, +} + +impl ModuleBasic { + fn new(device: &Device) -> Self { + Self { + weight_basic: Initializer::Normal { + std: 1.0, + mean: 0.0, + } + .init([20, 20], device), + } + } +} + +#[derive(Module, Debug)] +struct ModuleWithConstGeneric { + modules: [ModuleBasic; N], +} + +#[derive(Module, Debug)] +struct ModuleWithGenericModule { + module: M, +} + +#[derive(Module, Debug)] +#[allow(clippy::large_enum_variant)] +enum ModuleEnum { + Basic(ModuleBasic), + Composed(ModuleComposed), +} + +#[derive(Module, Debug)] +#[allow(unused)] +enum ModuleEnumNested { + AnotherEnum(ModuleEnum), +} + +#[derive(Module, Debug)] +#[allow(clippy::large_enum_variant)] +enum ModuleEnumWithGenericModule { + Basic(ModuleBasic), + Generic(ModuleWithGenericModule), +} + +#[derive(Module, Debug)] +pub struct ModuleComposed { + weight: Param>, + basic: ModuleBasic, + tuple: (ModuleBasic, ModuleBasic), +} + +impl ModuleComposed { + fn new(device: &Device) -> Self { + let weight = Initializer::Normal { + std: 1.0, + mean: 0.0, + } + .init([20, 20], device); + + Self { + weight, + basic: ModuleBasic::new(device), + tuple: (ModuleBasic::new(device), ModuleBasic::new(device)), + } + } +} + +#[derive(Debug, Clone)] +pub enum PaddingConfig { + Default, + Other, +} + +#[derive(Module, Debug)] +pub struct ModuleWithAttributes { + /// A normal parameter. + weight: Param>, + /// A nested module. + nested: ModuleEnumWithGenericModule, + /// By default, primitives were not persistent (same as `#[module(skip)]`). + other_prob: f64, + /// By default, tensors were not persistent and not visited/mapped (same as `#[module(skip)]`). + tensor: Tensor<1>, + /// A field that is recomputed at runtime. + #[module(skip)] + cached_mask: Option>, + /// A field that contains some debug state. + debug_state: String, + /// Hint required: this generic is NOT a module. + #[module(skip)] + config: N, +} + +impl ModuleWithAttributes { + fn new(device: &Device) -> Self { + let basic = ModuleBasic::new(device); + let weight = basic.weight_basic.clone(); + + Self { + weight, + nested: ModuleEnumWithGenericModule::Basic(basic), + other_prob: 1., + tensor: Tensor::ones([2], device), + cached_mask: Some(Tensor::ones([2, 2], device)), + debug_state: "Hello World".into(), + config: PaddingConfig::Default, + } + } +} + +pub fn test_device() -> Device { + burn_tensor::Device::flex() +} + +mod state { + use super::*; + use burn::store::RecordError; + + #[test] + fn should_load_from_record_basic() { + let device = test_device(); + let module_1 = ModuleBasic::new(&device); + let module_2 = ModuleBasic::new(&device); + + // Access module_1 to trigger initialization before cloning. + // Cloning an uninitialized module preserves lazy state (no memory allocation), + // so we need to initialize first if we want the clone to have the same values. + assert_ne!( + module_1.weight_basic.to_data(), + module_2.weight_basic.to_data() + ); + + let record = module_1.clone().into_record(); + let module_2 = module_2.load_record(record); + + assert_eq!( + module_1.weight_basic.to_data(), + module_2.weight_basic.to_data() + ); + } + + #[test] + fn should_load_from_record_compose() { + let device = test_device(); + let module_1 = ModuleComposed::new(&device); + let module_2 = ModuleComposed::new(&device); + assert_ne!(module_1.weight.to_data(), module_2.weight.to_data()); + assert_ne!( + module_1.basic.weight_basic.to_data(), + module_2.basic.weight_basic.to_data() + ); + + let record = module_1.clone().into_record(); + let module_2 = module_2.load_record(record); + + assert_eq!(module_1.weight.to_data(), module_2.weight.to_data()); + assert_eq!( + module_1.basic.weight_basic.to_data(), + module_2.basic.weight_basic.to_data() + ); + // The tuple of nested modules round-trips too. + assert_eq!( + module_1.tuple.0.weight_basic.to_data(), + module_2.tuple.0.weight_basic.to_data() + ); + assert_eq!( + module_1.tuple.1.weight_basic.to_data(), + module_2.tuple.1.weight_basic.to_data() + ); + } + + #[test] + fn should_load_from_record_enum() { + let device = test_device(); + let module_1 = ModuleEnum::Basic(ModuleBasic::new(&device)); + let module_2 = ModuleEnum::Basic(ModuleBasic::new(&device)); + + // Trigger initialization before cloning so clone has the same values. + let ModuleEnum::Basic(ref module_1_basic) = module_1 else { + panic!("Invalid module type") + }; + let ModuleEnum::Basic(ref module_2_basic) = module_2 else { + panic!("Invalid module type") + }; + assert_ne!( + module_1_basic.weight_basic.to_data(), + module_2_basic.weight_basic.to_data() + ); + + let record = module_1.clone().into_record(); + let module_2 = module_2.load_record(record); + + let ModuleEnum::Basic(module_2_basic) = module_2 else { + panic!("Invalid module type") + }; + assert_eq!( + module_1_basic.weight_basic.to_data(), + module_2_basic.weight_basic.to_data() + ); + } + + #[test] + fn should_load_from_record_const_generic() { + let device = test_device(); + let module_1 = ModuleWithConstGeneric { + modules: [ModuleBasic::new(&device), ModuleBasic::new(&device)], + }; + let module_2 = ModuleWithConstGeneric { + modules: [ModuleBasic::new(&device), ModuleBasic::new(&device)], + }; + + // Trigger initialization before cloning so clone has the same values. + assert_ne!( + module_1.modules[0].weight_basic.to_data(), + module_2.modules[0].weight_basic.to_data(), + ); + assert_ne!( + module_1.modules[1].weight_basic.to_data(), + module_2.modules[1].weight_basic.to_data(), + ); + + let record = module_1.clone().into_record(); + let module_2 = module_2.load_record(record); + + assert_eq!( + module_1.modules[0].weight_basic.to_data(), + module_2.modules[0].weight_basic.to_data(), + ); + assert_eq!( + module_1.modules[1].weight_basic.to_data(), + module_2.modules[1].weight_basic.to_data(), + ); + } + + #[test] + fn should_load_from_record_based_on_attributes() { + let device = test_device(); + let mut module_1 = ModuleWithAttributes::new(&device); + let module_2 = ModuleWithAttributes::new(&device); + + assert_ne!(module_1.weight.to_data(), module_2.weight.to_data()); + + let ModuleEnumWithGenericModule::Basic(ref m1_basic) = module_1.nested else { + panic!("Invalid module type") + }; + let ModuleEnumWithGenericModule::Basic(ref m2_basic) = module_2.nested else { + panic!("Invalid module type") + }; + assert_ne!( + m1_basic.weight_basic.to_data(), + m2_basic.weight_basic.to_data(), + ); + + // Alter the non-persistent fields (skipped fields and plain tensors) to validate that they + // are not captured by the module record. + module_1.cached_mask = Some(module_1.cached_mask.unwrap() * 2); + module_1.tensor = module_1.tensor * 2; + module_1.other_prob = 0.; + module_1.debug_state = "Hello World!".into(); + module_1.config = PaddingConfig::Other; + + let record = module_1.clone().into_record(); + let module_2 = module_2.load_record(record); + + // Modules & params are loaded. + assert_eq!(module_1.weight.to_data(), module_2.weight.to_data()); + let ModuleEnumWithGenericModule::Basic(m2_basic) = module_2.nested else { + panic!("Invalid module type") + }; + assert_eq!( + m1_basic.weight_basic.to_data(), + m2_basic.weight_basic.to_data(), + ); + + // `#[module(skip)]` fields and plain (non-param) tensors are not persisted: module_2 keeps + // its own initialization. + assert_ne!(module_1.other_prob, module_2.other_prob); + assert_ne!(module_1.debug_state, module_2.debug_state); + assert!(matches!(module_1.config, PaddingConfig::Other)); + assert!(matches!(module_2.config, PaddingConfig::Default)); + assert_ne!(module_1.tensor.to_data(), module_2.tensor.to_data()); + assert_ne!( + module_1.cached_mask.as_ref().unwrap().to_data(), + module_2.cached_mask.as_ref().unwrap().to_data() + ); + } + + #[test] + fn incompatible_enum_variant_record_fails_to_load() { + let device = test_device(); + let module_1 = ModuleEnum::Basic(ModuleBasic::new(&device)); + let module_2 = ModuleEnum::Composed(ModuleComposed::new(&device)); + + // The Basic variant's record does not provide the Composed variant's params, so a strict + // (default) load reports the missing tensors as a validation error. + let record = module_1.into_record(); + let result = module_2.try_load_record(record); + assert!(matches!(result, Err(RecordError::Validation(_)))); + } +} + +mod lazy_clone { + use burn_tensor::Device; + + use super::*; + + #[test] + fn clone_uninitialized_param_should_not_trigger_init() { + let device = test_device(); + let module = ModuleBasic::new(&device); + + // Module starts uninitialized (lazy). + assert!(!module.weight_basic.is_initialized()); + + // Cloning should preserve the lazy state, not trigger initialization. + let cloned = module.clone(); + assert!(!module.weight_basic.is_initialized()); + assert!(!cloned.weight_basic.is_initialized()); + } + + #[test] + fn clone_initialized_param_should_share_values() { + let device = test_device(); + let module = ModuleBasic::new(&device); + + // Force initialization by accessing the tensor. + let _ = module.weight_basic.to_data(); + assert!(module.weight_basic.is_initialized()); + + // Clone of an initialized param should have the same values. + let cloned = module.clone(); + assert_eq!(module.weight_basic.to_data(), cloned.weight_basic.to_data()); + } + + #[test] + fn lazy_clone_should_produce_valid_tensor_on_access() { + let device = test_device(); + let module = ModuleBasic::new(&device); + let cloned = module.clone(); + + // Both are uninitialized. + assert!(!module.weight_basic.is_initialized()); + assert!(!cloned.weight_basic.is_initialized()); + + // Accessing the clone should produce a valid tensor with the right shape. + let data = cloned.weight_basic.to_data(); + assert_eq!(data.shape, [20, 20].into()); + + data.assert_eq(&module.weight_basic.val().into_data(), true); + } + + #[test] + fn lazy_clone_and_original_have_same_init() { + let device = test_device(); + let module = ModuleBasic::new(&device); + let cloned = module.clone(); + + let clone_data = cloned.weight_basic.to_data(); + let orig_data = module.weight_basic.to_data(); + + assert_eq!(clone_data.shape, [20, 20].into()); + assert_eq!(orig_data.shape, [20, 20].into()); + assert_eq!(clone_data, orig_data); + } + + #[test] + fn lazy_clone_deref_should_trigger_init() { + let device = test_device(); + let module = ModuleBasic::new(&device); + let cloned = module.clone(); + + // Access via Deref (shape() uses Deref, not val()) on the clone. + let shape = cloned.weight_basic.shape(); + assert_eq!(shape, [20, 20].into()); + assert!(cloned.weight_basic.is_initialized()); + assert!(module.weight_basic.is_initialized()); + } + + #[test] + fn init_mapper_on_lazy_clone_should_not_affect_original() { + use burn::module::ParamId; + use burn::tensor::Shape; + + let device: Device = test_device(); + + // Create two uninitialized params from the same init function. + let param: Param> = Param::uninitialized( + ParamId::new(), + move |d, _| Tensor::random([4, 4], Default::default(), d), + device, + false, + Shape::from([4, 4]), + ); + + let mut cloned = param.clone(); + + // Apply init_mapper on the clone to double all values. + cloned = cloned.init_mapper(|t| t.mul_scalar(2.0)); + + // Random tensor should still have the same initialization point, but * 2.0 + cloned + .val() + .div_scalar(2.0) + .into_data() + .assert_approx_eq::(¶m.val().into_data(), Default::default()); + } + + #[test] + fn load_record_into_uninitialized_module_should_work() { + let device = test_device(); + let module_1 = ModuleBasic::new(&device); + + // Initialize module_1 so we have a record to load. + let _ = module_1.weight_basic.to_data(); + let record = module_1.clone().into_record(); + + // Create a fresh uninitialized module and load weights into it. + let module_2 = ModuleBasic::new(&device); + assert!(!module_2.weight_basic.is_initialized()); + + let module_2 = module_2.load_record(record); + + // After loading, the param should be initialized with the loaded values. + assert_eq!( + module_1.weight_basic.to_data(), + module_2.weight_basic.to_data() + ); + } +} + +mod num_params { + use super::*; + + #[test] + fn should_calculate_num_params_basic() { + let device = test_device(); + let module = ModuleBasic::new(&device); + assert_eq!(20 * 20, module.num_params()); + } + + #[test] + fn should_output_state_composed() { + let device = test_device(); + let module = ModuleComposed::new(&device); + assert_eq!(4 * 20 * 20, module.num_params()); + } + + #[test] + fn should_calculate_num_params_enum() { + let device = test_device(); + let module = ModuleEnum::Basic(ModuleBasic::new(&device)); + assert_eq!(20 * 20, module.num_params()); + + let module = ModuleEnum::Composed(ModuleComposed::new(&device)); + assert_eq!(4 * 20 * 20, module.num_params()); + } + + #[test] + fn should_calculate_num_params_based_on_attributes() { + let device = test_device(); + let module = ModuleWithAttributes::new(&device); + assert_eq!(20 * 20 * 2, module.num_params()); + } +} + +#[cfg(all(feature = "std", feature = "autodiff"))] +mod require_grad { + use burn_tensor::TensorData; + use rand::{ + SeedableRng, + rngs::{StdRng, SysRng}, + }; + + use super::*; + + #[test] + fn should_have_grad_by_default() { + let device = test_device().autodiff(); + let module = ModuleBasic::new(&device); + let grad_x = calculate_grads(&module, |weights, x| weights.matmul(x)); + + assert!(grad_x.is_some()); + } + + #[test] + fn should_have_no_grad_after_no_grad() { + let device = test_device().autodiff(); + let module = ModuleBasic::new(&device).no_grad(); + let grad_x = calculate_grads(&module, |weights, x| weights.matmul(x)); + + assert!(grad_x.is_none()); + } + + #[test] + fn should_have_grad_when_from_record() { + let device = test_device().autodiff(); + let module = ModuleBasic::new(&device); + + // Round-tripping through a record must preserve the params' require_grad state. + let record = module.clone().into_record(); + let module = module.load_record(record); + + let grad_x = calculate_grads(&module, |weights, x| weights.matmul(x)); + + assert!(grad_x.is_some()); + } + + fn calculate_grads( + module: &ModuleBasic, + transformation: fn(Tensor<2>, Tensor<2>) -> Tensor<2>, + ) -> Option> { + let device = module.weight_basic.device(); + let data = TensorData::random::( + module.weight_basic.shape(), + burn_tensor::Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + let x = Tensor::from_data(data, &device).require_grad(); + let t = module.weight_basic.val(); + let y = transformation(t, x); + + let mut grads = y.backward(); + module.weight_basic.grad_remove(&mut grads) + } +} + +#[cfg(feature = "cuda")] +mod grad_distributed { + use burn_tensor::TensorData; + use burn_tensor::distributed::{DistributedContext, ReduceOperation}; + use burn_tensor::{Device, DeviceType, Tolerance}; + use rand::{ + SeedableRng, + rngs::{StdRng, SysRng}, + }; + use serial_test::serial; + use std::sync::mpsc::{Receiver, Sender}; + + use super::*; + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_sum() { + compare_sync_gradient(ReduceOperation::Sum, |weights, x| weights.matmul(x)); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_mean() { + compare_sync_gradient(ReduceOperation::Mean, |weights, x| weights.matmul(x)); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_sum_residual() { + compare_sync_gradient(ReduceOperation::Sum, |weights, x| { + let y = weights.clone().matmul(x); + y.add(weights) + }); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_mean_residual() { + compare_sync_gradient(ReduceOperation::Mean, |weights, x| { + let y = weights.clone().matmul(x); + y.add(weights) + }); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_sum_activation() { + compare_sync_gradient(ReduceOperation::Sum, |weights, x| { + let y = weights.clone().matmul(x); + let y = y.add(weights); + burn_tensor::activation::relu(y) + }); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_mean_activation() { + compare_sync_gradient(ReduceOperation::Mean, |weights, x| { + let y = weights.clone().matmul(x); + let y = y.add(weights); + burn_tensor::activation::relu(y) + }); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_sum_diamond_graph() { + compare_sync_gradient(ReduceOperation::Sum, |weights, x| { + let left = weights.clone().matmul(x.clone().mul_scalar(2)); + let right = weights.clone().matmul(x.clone().exp()); + Tensor::cat(vec![left, right], 0) + }); + } + + #[test] + #[serial] + fn sharded_module_should_sync_gradients_mean_diamond_graph() { + compare_sync_gradient(ReduceOperation::Mean, |weights, x| { + let left = weights.clone().matmul(x.clone().mul_scalar(2)); + let right = weights.clone().matmul(x.clone().exp()); + Tensor::cat(vec![left, right], 0) + }); + } + + fn compare_sync_gradient( + op: ReduceOperation, + transformation: fn(Tensor<2>, Tensor<2>) -> Tensor<2>, + ) { + use burn_tensor::distributed::DistributedConfig; + + const NUM_ITERATIONS: usize = 100; + let devices = Device::enumerate(DeviceType::Cuda).autodiff().into_vec(); + + let module = ModuleBasic::new(&devices[0]); + let (synced_senders, synced_receivers): ( + Vec>, + Vec>, + ) = (0..devices.len()) + .map(|_| std::sync::mpsc::channel()) + .unzip(); + let (original_senders, original_receivers): ( + Vec>, + Vec>, + ) = (0..devices.len()) + .map(|_| std::sync::mpsc::channel()) + .unzip(); + + let config = DistributedConfig { all_reduce_op: op }; + let _context = DistributedContext::init(devices.clone(), config); + + let join_handles = spawn_peer_threads( + &module, + &devices, + synced_senders, + original_senders, + transformation, + NUM_ITERATIONS, + ); + + for _ in 0..NUM_ITERATIONS { + let device = devices.first().unwrap(); + let mut expected: Tensor<2> = + Tensor::from_data(original_receivers.first().unwrap().recv().unwrap(), device); + for r in original_receivers[1..].iter().by_ref() { + let data = r.recv().unwrap(); + expected = expected.add(Tensor::from_data(data, device)); + } + if op == ReduceOperation::Mean { + expected = expected.div_scalar(original_receivers.len() as f32); + } + + for r in synced_receivers.iter().by_ref() { + let data = r.recv().unwrap(); + data.assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + } + + for handle in join_handles { + handle.join().unwrap(); + } + + // DistributedContext goes out of scope -> close_communication_server + } + + fn spawn_peer_threads( + module: &ModuleBasic, + devices: &[Device], + synced_senders: Vec>, + original_senders: Vec>, + transformation: fn(Tensor<2>, Tensor<2>) -> Tensor<2>, + num_iter: usize, + ) -> Vec> { + let mut handles = vec![]; + + for i in 0..devices.len() { + let module_clone = module.clone(); + let device = devices[i].clone(); + let synced_sender = synced_senders[i].clone(); + let original_sender = original_senders[i].clone(); + handles.push(std::thread::spawn(move || { + run_peer_sharded( + &module_clone, + synced_sender, + original_sender, + transformation, + device, + num_iter, + ) + })); + } + + handles + } + + pub fn run_peer_sharded( + module: &ModuleBasic, + synced_sender: Sender, + original_sender: Sender, + transformation: fn(Tensor<2>, Tensor<2>) -> Tensor<2>, + device: Device, + num_iter: usize, + ) { + let mut module = module.clone().fork(&device); + + for _ in 0..num_iter { + module = set_distributed(&module, &device); + let (grads_synced, grads_original) = calculate_grads(&module, transformation); + + let data = grads_original.unwrap().to_data(); + original_sender.clone().send(data).unwrap(); + + let data = grads_synced.unwrap().to_data(); + synced_sender.clone().send(data).unwrap(); + } + } + + fn set_distributed(module: &ModuleBasic, device: &Device) -> ModuleBasic { + let mut module = module.clone().fork(&device); + let (id, tensor, mapper) = module.weight_basic.consume(); + let tensor = tensor.set_distributed(id); + module.weight_basic = Param::from_mapped_value(id, tensor, mapper); + module + } + + fn calculate_grads( + module: &ModuleBasic, + transformation: fn(Tensor<2>, Tensor<2>) -> Tensor<2>, + ) -> (Option>, Option>) { + let device = module.weight_basic.device(); + let data = TensorData::random::( + module.weight_basic.shape(), + burn_tensor::Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + let x = Tensor::from_data(data.clone(), &device).require_grad(); + let t = module.weight_basic.val(); + let y = transformation(t, x); + + let mut grads = y.backward(); + let grads_synced = module.weight_basic.grad_remove(&mut grads); + + // Kind of hacky, but running the backward pass again without marking the tensor as distributed will not sync gradients. + // We can use this to compute the expected sum. + let x = Tensor::from_data(data, &device).require_grad(); + let t = module.weight_basic.val(); + let y = transformation(t, x); + let mut grads = y.backward(); + let grads_original = module.weight_basic.grad_remove(&mut grads); + (grads_synced, grads_original) + } +} diff --git a/crates/burn-core/tests/test_record.rs b/crates/burn-core/tests/test_record.rs new file mode 100644 index 0000000..6d937ac --- /dev/null +++ b/crates/burn-core/tests/test_record.rs @@ -0,0 +1,134 @@ +//! Integration test for the non-generic record system (`burn_core::store`). +//! +//! Saves a (nested) module to a burnpack record, loads it back into a freshly initialized +//! module, and checks the parameters round-trip — exercising the public API exactly as a user +//! would (`into_record` / `save` / `load` / `load_record`). + +use burn::module::{Module, Param}; +use burn::store::ModuleRecord; +use burn::tensor::Tensor; +use burn_core as burn; +use burn_tensor::Device; + +#[derive(Module, Debug)] +struct Layer { + weight: Param>, + bias: Param>, +} + +impl Layer { + fn from_values(weight: [[f32; 2]; 2], bias: [f32; 2], device: &Device) -> Self { + Self { + weight: Param::from_data(weight, device), + bias: Param::from_data(bias, device), + } + } + + fn zeros(device: &Device) -> Self { + Self::from_values([[0.0; 2]; 2], [0.0; 2], device) + } + + fn values(&self) -> (Vec, Vec) { + ( + self.weight.val().to_data().to_vec().unwrap(), + self.bias.val().to_data().to_vec().unwrap(), + ) + } +} + +/// A module with only the `first` layer — its record holds `first.weight` / `first.bias`, +/// a strict subset of [`Mlp`]'s parameters. +#[derive(Module, Debug)] +struct FirstOnly { + first: Layer, +} + +/// Nested module so the record exercises path building (`first.weight`, `second.bias`, ...). +#[derive(Module, Debug)] +struct Mlp { + first: Layer, + second: Layer, +} + +impl Mlp { + fn sample(device: &Device) -> Self { + Self { + first: Layer::from_values([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], device), + second: Layer::from_values([[7.0, 8.0], [9.0, 10.0]], [11.0, 12.0], device), + } + } + + fn zeros(device: &Device) -> Self { + Self { + first: Layer::zeros(device), + second: Layer::zeros(device), + } + } +} + +fn assert_matches_sample(model: &Mlp) { + let (w1, b1) = model.first.values(); + let (w2, b2) = model.second.values(); + assert_eq!(w1, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(b1, vec![5.0, 6.0]); + assert_eq!(w2, vec![7.0, 8.0, 9.0, 10.0]); + assert_eq!(b2, vec![11.0, 12.0]); +} + +#[cfg(feature = "std")] +#[test] +fn save_and_load_module_via_file() { + let device = Default::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mlp.bpk"); + + // Save a trained module to a record on disk. + let record = Mlp::sample(&device).into_record(); + assert_eq!(record.len(), 4); // first.weight, first.bias, second.weight, second.bias + record.save(&path).unwrap(); + + // Load the record and apply it onto a freshly initialized (zeroed) module. + let record = ModuleRecord::load(&path).unwrap(); + let loaded = Mlp::zeros(&device).load_record(record); + + assert_matches_sample(&loaded); +} + +#[test] +fn save_and_load_module_via_bytes() { + let device = Default::default(); + + let bytes = Mlp::sample(&device).into_record().into_bytes().unwrap(); + + let record = ModuleRecord::from_bytes(bytes).unwrap(); + let loaded = Mlp::zeros(&device).load_record(record); + + assert_matches_sample(&loaded); +} + +#[test] +fn missing_parameters_require_allow_partial() { + let device = Default::default(); + + // A record holding only `first.weight` / `first.bias` (a subset of the full Mlp). + let partial = FirstOnly { + first: Layer::from_values([[1.0, 2.0], [3.0, 4.0]], [5.0, 6.0], &device), + } + .into_record(); + let bytes = partial.into_bytes().unwrap(); + + // Strict load into the full Mlp fails: `second.*` is missing from the record. + let strict = ModuleRecord::from_bytes(bytes.clone()).unwrap(); + assert!(Mlp::zeros(&device).try_load_record(strict).is_err()); + + // With `allow_partial`, the present params load and the rest keep their init. + let lenient = ModuleRecord::from_bytes(bytes).unwrap().allow_partial(true); + let loaded = Mlp::zeros(&device).load_record(lenient); + + let (w1, b1) = loaded.first.values(); + assert_eq!(w1, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(b1, vec![5.0, 6.0]); + let (w2, b2) = loaded.second.values(); + assert_eq!(w2, vec![0.0, 0.0, 0.0, 0.0]); + assert_eq!(b2, vec![0.0, 0.0]); +} diff --git a/crates/burn-cpu/Cargo.toml b/crates/burn-cpu/Cargo.toml new file mode 100644 index 0000000..cb22c8e --- /dev/null +++ b/crates/burn-cpu/Cargo.toml @@ -0,0 +1,44 @@ +[package] +authors = ["marcantoinem "] +categories = ["science"] +description = "MLIR based CPU backend for the Burn framework" +documentation = "https://docs.rs/burn-cpu" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "cpu"] +license.workspace = true +name = "burn-cpu" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-cpu" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "fusion", "autotune", "burn-cubecl/default", "cubecl/default"] +doc = ["burn-cubecl/doc"] +std = ["burn-cubecl/std", "cubecl/std"] + +tracing = [ + "burn-backend/tracing", + "burn-cubecl/tracing", + "burn-fusion?/tracing", + "cubecl/tracing", +] + +fusion = ["burn-fusion", "burn-cubecl/fusion"] +autotune = ["burn-cubecl/autotune"] +autotune-checks = ["burn-cubecl/autotune-checks"] + +[dependencies] +burn-fusion = { workspace = true, optional = true, features = ["default"] } +burn-cubecl = { workspace = true } +burn-backend = { workspace = true, features = [ + "default", + "cubecl-cpu", +] } +cubecl = { workspace = true, features = ["cpu"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-cpu/README.md b/crates/burn-cpu/README.md new file mode 100644 index 0000000..e1f1639 --- /dev/null +++ b/crates/burn-cpu/README.md @@ -0,0 +1,12 @@ +# Burn CPU Backend + +[Burn](https://github.com/tracel-ai/burn) CubeCL CPU backend + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-cuda.svg)](https://crates.io/crates/burn-cuda) + +This crate provides a MLIR based CPU backend for [Burn](https://github.com/tracel-ai/burn) using the +[cubecl](https://github.com/tracel-ai/cubecl.git) crates. + +## Usage Example + +Example coming soon diff --git a/crates/burn-cpu/src/lib.rs b/crates/burn-cpu/src/lib.rs new file mode 100644 index 0000000..a6e5d65 --- /dev/null +++ b/crates/burn-cpu/src/lib.rs @@ -0,0 +1,44 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] + +extern crate alloc; + +use burn_cubecl::CubeBackend; +pub use cubecl::cpu::CpuDevice; +use cubecl::cpu::CpuRuntime; + +#[cfg(not(feature = "fusion"))] +pub type Cpu = CubeBackend; + +#[cfg(feature = "fusion")] +pub type Cpu = burn_fusion::Fusion>; + +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::{Backend, BoolStore, DType, DeviceOps}; + + #[test] + fn should_support_dtypes() { + type B = Cpu; + let device = CpuDevice; + let scheme = device.defaults().quantization.scheme; + + assert!(B::supports_dtype(&device, DType::F64)); + assert!(B::supports_dtype(&device, DType::F32)); + assert!(B::supports_dtype(&device, DType::F16)); + assert!(B::supports_dtype(&device, DType::BF16)); + assert!(B::supports_dtype(&device, DType::I64)); + assert!(B::supports_dtype(&device, DType::I32)); + assert!(B::supports_dtype(&device, DType::I16)); + assert!(B::supports_dtype(&device, DType::I8)); + assert!(B::supports_dtype(&device, DType::U64)); + assert!(B::supports_dtype(&device, DType::U32)); + assert!(B::supports_dtype(&device, DType::U16)); + assert!(B::supports_dtype(&device, DType::U8)); + assert!(B::supports_dtype(&device, DType::QFloat(scheme))); + + // Currently not registered in supported types + assert!(!B::supports_dtype(&device, DType::Flex32)); + assert!(!B::supports_dtype(&device, DType::Bool(BoolStore::Native))); + } +} diff --git a/crates/burn-cubecl-fusion/Cargo.toml b/crates/burn-cubecl-fusion/Cargo.toml new file mode 100644 index 0000000..5d499a1 --- /dev/null +++ b/crates/burn-cubecl-fusion/Cargo.toml @@ -0,0 +1,59 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Provide optimizations that can be used with cubecl based backends." +documentation = "https://docs.rs/burn-cubecl-fusion" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "gpu"] +license.workspace = true +name = "burn-cubecl-fusion" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-cubecl-fusion" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["autotune", "std", "cubecl/default", "burn-fusion/default"] + +autotune = [] +autotune-checks = ["cubecl/autotune-checks", "half"] +doc = ["default"] +test-util = [] +std = ["cubecl/std", "burn-backend/std", "burn-fusion/std"] +tracing = [ + "cubecl/tracing", + "burn-std/tracing", + "burn-backend/tracing", + "burn-fusion/tracing", +] + +[dependencies] +burn-fusion = { workspace = true } +burn-ir = { workspace = true } +burn-std = { workspace = true, features = ["default"] } +cubecl = { workspace = true } +cubek = { workspace = true, features = [ + "matmul", + "reduce", + "quantization", + "stdlib", +] } +half = { workspace = true, optional = true } + +# Needed for the cubecl dtype helpers (`burn_backend::cubecl::*`). +burn-backend = { workspace = true, features = ["cubecl"] } + +derive-new = { workspace = true } +hashbrown = { workspace = true } +serde = { workspace = true } +spin = { workspace = true } + +[dev-dependencies] +cubecl = { workspace = true, features = ["test-runtime"] } +half = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-cubecl-fusion/README.md b/crates/burn-cubecl-fusion/README.md new file mode 100644 index 0000000..88c5797 --- /dev/null +++ b/crates/burn-cubecl-fusion/README.md @@ -0,0 +1,3 @@ +# Burn CubeCl Fusion + +Provide optimizations that can be used with [cubecl](../burn-cubecl) based backends. diff --git a/crates/burn-cubecl-fusion/src/base.rs b/crates/burn-cubecl-fusion/src/base.rs new file mode 100644 index 0000000..9c26cea --- /dev/null +++ b/crates/burn-cubecl-fusion/src/base.rs @@ -0,0 +1,124 @@ +use burn_fusion::stream::Context; +use burn_std::{DType, Shape, Strides, quantization::QParamTensor, strides}; +use cubecl::quant::scheme::{QuantParam, QuantScheme}; +use cubecl::{ + Runtime, + client::ComputeClient, + ir::AddressType, + prelude::{TensorArg, TensorBinding}, +}; +use std::marker::PhantomData; + +/// Defines a fallback operation when fusion isn't possible. +pub trait FallbackOperation: Send + Sync { + /// Executes the fallback procedure. + fn run(&self, context: &mut Context>); +} + +/// Runtime parameters for quantization. Can be used to construct a scales handle from the base +/// tensor handle. +pub type QParams = burn_std::quantization::QParams; + +/// Handle to be used when fusing operations. +pub struct CubeFusionHandle { + /// Compute client for jit. + pub client: ComputeClient, + /// The buffer where the data are stored. + pub handle: cubecl::server::Handle, + /// The device of the current tensor. + pub device: R::Device, + /// The element type of the tensor. + pub dtype: DType, + /// The strides of the tensor. + pub strides: Strides, + /// Quantization runtime parameters, if applicable + pub qparams: Option, +} + +impl core::fmt::Debug for CubeFusionHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "CubeFusionHandle {{ device: {:?}, runtime: {}}}", + self.device, + R::name(&self.client), + )) + } +} + +impl Clone for CubeFusionHandle { + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + handle: self.handle.clone(), + device: self.device.clone(), + strides: self.strides.clone(), + dtype: self.dtype, + qparams: self.qparams.clone(), + } + } +} + +unsafe impl Send for CubeFusionHandle {} +unsafe impl Sync for CubeFusionHandle {} + +impl CubeFusionHandle { + /// Return the reference to a tensor handle. + pub fn binding(self, shape: Shape) -> TensorBinding { + TensorBinding { + handle: self.handle.binding(), + strides: self.strides.clone(), + shape, + runtime: PhantomData, + } + } + + pub fn required_address_type(&self) -> AddressType { + match self.dtype { + DType::QFloat(scheme) => { + let len = self.handle.size() as usize * 8 / scheme.size_bits_value(); + AddressType::from_len(len) + } + _ => AddressType::from_len(self.handle.size() as usize / self.dtype.size()), + } + } + + /// Return the reference to a tensor argument. + pub fn into_tensor_arg(self, shape: Shape) -> TensorArg { + let handle = self.binding(shape); + handle.into_tensor_arg() + } + + /// Construct a separate tensor for the quantization scales, if present + pub fn params(&self, scheme: QuantScheme) -> Option { + let qparams = self.qparams.as_ref()?; + let mut handle = self.handle.clone(); + handle.offset_start = Some(qparams.scales.offset_start as u64); + handle.offset_end = Some(qparams.scales.offset_end as u64); + + Some(Self { + client: self.client.clone(), + handle, + device: self.device.clone(), + dtype: match scheme.param { + QuantParam::F32 => DType::F32, + QuantParam::F16 => DType::F16, + QuantParam::BF16 => DType::BF16, + QuantParam::UE8M0 | QuantParam::UE4M3 => unimplemented!("Not yet supported"), + }, + strides: qparams.scales.metadata.strides().clone(), + qparams: None, + }) + } +} + +pub(crate) fn strides_dyn_rank(shape: &[usize]) -> Strides { + let mut strides = strides![0; shape.len()]; + + let mut current = 1; + shape.iter().enumerate().rev().for_each(|(index, val)| { + strides[index] = current; + current *= val; + }); + + strides +} diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/base.rs b/crates/burn-cubecl-fusion/src/engine/codegen/base.rs new file mode 100644 index 0000000..c31d028 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/base.rs @@ -0,0 +1,5 @@ +use cubecl::{define_scalar, define_size, prelude::Vector}; + +define_scalar!(pub DynElem); +define_size!(pub DynSize); +pub type DynVector = Vector; diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/io.rs b/crates/burn-cubecl-fusion/src/engine/codegen/io.rs new file mode 100644 index 0000000..9e108bd --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/io.rs @@ -0,0 +1,875 @@ +//! This module declares input-output primitives to read and write values during kernel expansion. +use crate::engine::codegen::{DynElem, DynSize}; + +use super::{ir::*, tensor::GlobalTensor}; +use burn_std::quantization::QuantScheme; +use cubecl::quant::scheme::QuantLevel; +use cubecl::{ + intrinsic, + ir::Value, + prelude::*, + std::{FastDivmod, tensor::View}, +}; +use cubek::quantization::layout::{BlockScaledLayout, PerTensorLayout, ScalesLayout}; +use serde::{Deserialize, Serialize}; + +/// Define how a tensor might be transformed at runtime. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +pub enum Transform { + /// A reshape operation has been registered on a tensor. + /// + /// This enum entry contains a sequence of [arguments](FuseArg) that points to global scalars representing the + /// new shape for the current tensor. + Reshape(Vec), + /// Two axes have been swapped on a tensor. + /// + /// The enum entry contains those two axes. + SwapDims(usize, usize), +} + +/// Reads the value from the [arg](FuseArg) and cast it to the generic cube primitive. +/// +/// # Notes +/// +/// The [global arguments](GlobalArgs) for both inputs and outputs as well as the +/// [local arguments](LocalArgs) need to be passed to this function. +/// +/// This is because the [argument](FuseArg) might point to a global input, output or local variable +/// created during kernel expansion. +#[cube] +pub fn read( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + ref_pos: usize, + #[comptime] arg: FuseArg, + #[comptime] config: &FuseBlockConfig, +) -> Vector { + set_polyfill_typed::, DynElem, DynSize>(); + match arg { + FuseArg::Input(pos, _precision, layout) => { + let global = inputs.tensors.index(pos); + let vector_size = global.tensor.vector_size(); + + if comptime![!global.broadcasted && vector_size != config.width] { + read_input_aligned(inputs, locals, pos, ref_pos, layout, config, None) + } else { + read_input(inputs, locals, pos, ref_pos, layout, config, None) + } + } + FuseArg::MultiBlockLocal(key, _) | FuseArg::MultiBlockGlobal(key, _) => { + Vector::cast_from(outputs.variables.read(key)) + } + FuseArg::Output(pos, _precision, layout) => { + read_output(inputs, outputs, locals, pos, ref_pos, layout, config) + } + FuseArg::BlockLocal { pos, ty } => match comptime![ty] { + FuseType::F64 => Vector::cast_from(locals.l_f64.find(pos)), + FuseType::F32 | FuseType::Flex32 => Vector::cast_from(locals.l_f32.find(pos)), + FuseType::F16 => Vector::cast_from(locals.l_f16.find(pos)), + FuseType::BF16 => Vector::cast_from(locals.l_bf16.find(pos)), + FuseType::U64 => Vector::cast_from(locals.l_u64.find(pos)), + FuseType::U32 => Vector::cast_from(locals.l_u32.find(pos)), + FuseType::U16 => Vector::cast_from(locals.l_u16.find(pos)), + FuseType::U8 => Vector::cast_from(locals.l_u8.find(pos)), + FuseType::I64 => Vector::cast_from(locals.l_i64.find(pos)), + FuseType::I32 => Vector::cast_from(locals.l_i32.find(pos)), + FuseType::I16 => Vector::cast_from(locals.l_i16.find(pos)), + FuseType::I8 => Vector::cast_from(locals.l_i8.find(pos)), + }, + FuseArg::Scalar(..) => { + let scalar = read_scalar::(inputs, arg); + Vector::new(scalar) + } + FuseArg::ScalarShape(_) => { + let scalar = read_scalar_shape(inputs, arg); + Vector::cast_from(scalar) + } + FuseArg::Literal(val, _precision) => Vector::new(from_const_int::(val)), + FuseArg::InputReshaped { + original, + shape, + broadcasted, + } => match comptime![original.as_ref().clone()] { + FuseArg::Input(pos, _precision, layout) => { + let global = inputs.tensors.index(pos); + let vector_size = global.tensor.vector_size(); + + if comptime![!broadcasted && vector_size != config.width] { + read_input_aligned( + inputs, + locals, + pos, + ref_pos, + layout, + config, + comptime![Some(Transform::Reshape(shape))], + ) + } else { + read_input( + inputs, + locals, + pos, + ref_pos, + layout, + config, + comptime![Some(Transform::Reshape(shape))], + ) + } + } + _ => comptime![panic!("Only input can be reshaped")], + }, + FuseArg::InputSwapDims { + original, + dims, + broadcasted, + } => match comptime![original.as_ref().clone()] { + FuseArg::Input(pos, _precision, layout) => { + let global = inputs.tensors.index(pos); + let vector_size = global.tensor.vector_size(); + + if comptime![!broadcasted && vector_size != config.width] { + read_input_aligned( + inputs, + locals, + pos, + ref_pos, + layout, + config, + comptime![Some(Transform::SwapDims(dims.0, dims.1))], + ) + } else { + read_input( + inputs, + locals, + pos, + ref_pos, + layout, + config, + comptime![Some(Transform::SwapDims(dims.0, dims.1))], + ) + } + } + _ => comptime![panic!("Only input can be swapped dims")], + }, + } +} + +/// Computes the offset for the current global tensor with a quantized layout. +/// +/// The offset can be used to fetch the correct data from the quantized tensor as if it was in a +/// linear contiguous format. +#[cube] +fn index_offset_with_quant_layout( + tensor: &GlobalTensor, + locals: &LocalArgs, + index: usize, + #[comptime] rank: usize, + #[comptime] scheme: QuantScheme, +) -> usize { + let (start, end) = (0, rank - 1); + let num_quants = scheme.num_quants(); + + let offset_ref = index * locals.ref_vector_size; + let mut offset = 0; + + #[unroll] + for i in start..end { + let ogwl = offset_ref / locals.ref_strides[i]; + offset += ogwl % tensor.tensor.shape(i) * tensor.tensor.stride(i); + } + + // Handle packed representation in last dim + let ogwl = offset_ref / locals.ref_strides[end]; + let shape_last = tensor.tensor.shape(end).div_ceil(num_quants); + let stride_last = tensor.tensor.stride(end); + offset += (ogwl.div_ceil(num_quants)) % shape_last * stride_last; + + offset / tensor.tensor.vector_size() +} + +/// Reads a global quantized tensor at the given position. +/// +/// # Notes +/// +/// The values returned in the [Vector] are not dequantized. +#[cube] +pub fn read_quantized( + inputs: &GlobalArgs, + locals: &LocalArgs, + ref_pos: usize, + #[comptime] arg: FuseArg, + #[comptime] config: &FuseBlockConfig, + #[comptime] scheme: QuantScheme, +) -> Vector { + match arg { + FuseArg::Input(pos, _precision, _layout) => { + let global = inputs.tensors.index(pos); + + let offset = + index_offset_with_quant_layout(global, locals, ref_pos, config.rank, scheme); + let val = global.tensor[offset]; + Vector::cast_from(val) + } + _ => panic!("Not supported"), + } +} + +/// Reads a global scalar. +#[cube] +pub fn read_scalar(inputs: &GlobalArgs, #[comptime] arg: FuseArg) -> C { + match arg { + FuseArg::Scalar(pos, _precision) => { + let scalar = inputs.scalars.index(pos); + scalar.get::() + } + _ => comptime![panic!("Not a scalar")], + } +} + +/// Reads a global scalar that is used as a reshape position. +#[cube] +pub fn read_scalar_shape(inputs: &GlobalArgs, #[comptime] arg: FuseArg) -> usize { + match arg { + FuseArg::ScalarShape(pos) => inputs.reshapes[pos], + _ => comptime![panic!("Not a scalar shape")], + } +} + +/// Reads an input tensor. +#[cube] +pub fn read_input( + inputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] pos: usize, + ref_pos: usize, + #[comptime] layout: LayoutInfo, + #[comptime] config: &FuseBlockConfig, + #[comptime] transform: Option, +) -> Vector { + set_polyfill_typed::, DynElem, DynSize>(); + let tensor = inputs.tensors.index(pos); + let offset = match layout { + LayoutInfo::SameAsRef => ref_pos, + LayoutInfo::IsRef => ref_pos, + LayoutInfo::Unknown => get_offset(inputs, locals, tensor, ref_pos, None, config, transform), + }; + Vector::cast_from(tensor.tensor[offset]) +} + +/// Returns a slice of data in the asked precision of the input tensor at the given position. +#[cube] +pub fn read_input_window( + inputs: &GlobalArgs, + #[comptime] pos: usize, + start: usize, + end: usize, +) -> &[C] { + set_polyfill_typed::(); + let tensor = inputs.tensors.index(pos); + let slice = &tensor.tensor[start..end]; + slice.downcast() +} + +/// Returns the input as a slice. +#[cube] +pub fn input_as_slice(inputs: &GlobalArgs, #[comptime] pos: usize) -> &[C] { + set_polyfill_typed::(); + let tensor = inputs.tensors.index(pos); + let slice = tensor.tensor.as_slice(); + slice.downcast() +} + +/// Returns the input tensor as a quantized scale view. +#[cube] +pub fn input_as_scales_view( + inputs: &GlobalArgs, + #[comptime] pos: usize, + #[comptime] tensor_pos: usize, + #[comptime] level: QuantLevel, + #[comptime] config: &FuseBlockConfig, +) -> View<'static, C, usize> { + set_polyfill_typed::, DynElem, DynSize>(); + let tensor = inputs.tensors.index(tensor_pos); + let scales = inputs.tensors.index(pos); + let tensor_len = tensor.tensor.len(); + let rank = config.rank; + let layout = match level { + QuantLevel::Tensor => ScalesLayout::new_PerTensor(PerTensorLayout::new(tensor_len)), + QuantLevel::Block(block_size) => { + let block_size = comptime![block_size.to_dim_vec(rank)]; + let mut tensor_shape = Sequence::new(); + let mut scales_strides = Sequence::new(); + #[unroll] + for i in 0..rank { + tensor_shape.push(FastDivmod::new_Fallback(tensor.tensor.shape(i))); + scales_strides.push(scales.tensor.stride(i)); + } + let vector_size = scales.tensor.vector_size(); + let layout = BlockScaledLayout::new( + tensor_shape, + tensor_len, + scales_strides, + block_size, + vector_size, + ); + ScalesLayout::new_BlockScaled(layout) + } + }; + let slice = scales.tensor.as_slice().downcast(); + View::new::, usize>(unsafe { slice.as_boxed_unchecked() }, layout) +} + +/// Reads the input tensor aligned. +#[cube] +pub fn read_input_aligned( + inputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] pos: usize, + ref_pos: usize, + #[comptime] layout: LayoutInfo, + #[comptime] config: &FuseBlockConfig, + #[comptime] transform: Option, +) -> Vector { + let mut result = Vector::::empty(); + let tensor = inputs.tensors.index(pos); + + match transform.clone() { + Some(Transform::Reshape(shape)) => { + // Very brute force, not really efficient, but not easy to optimize and not a very + // frequent workflow. + let ref_pos = ref_pos * config.width; + #[unroll] + for i in 0..config.width { + let index = reshaped_index( + inputs, + locals, + ref_pos + i, + config.rank, + comptime![shape.clone()], + ); + let index = reshaped_index_to_original_index(&tensor.tensor, index, config.rank); + result.insert(i, C::cast_from(tensor.tensor[index].extract(0))) + } + } + Some(Transform::SwapDims(dim1, dim2)) => { + let offset = + get_offset_aligned(inputs, locals, tensor, ref_pos, layout, config, transform); + let i = comptime![swap_dims_transform(config.rank - 1, (dim1, dim2))]; + let stride = tensor.tensor.stride(i); + + #[unroll] + for i in 0..config.width { + let index = offset + i * stride; + result.insert(i, C::cast_from(tensor.tensor[index].extract(0))) + } + } + None => { + let offset = + get_offset_aligned(inputs, locals, tensor, ref_pos, layout, config, transform); + let stride = tensor.tensor.stride(config.rank - 1); + #[unroll] + for i in 0..config.width { + let index = offset + i * stride; + result.insert(i, C::cast_from(tensor.tensor[index].extract(0))) + } + } + } + + result +} + +/// Computes the offset of the given [GlobalTensor] at on the reference position with a linear +/// layout. +#[cube] +pub fn get_offset_aligned( + inputs: &GlobalArgs, + locals: &LocalArgs, + tensor: &GlobalTensor, + ref_pos: usize, + #[comptime] layout: LayoutInfo, + #[comptime] config: &FuseBlockConfig, + #[comptime] transform: Option, +) -> usize { + match layout { + LayoutInfo::SameAsRef | LayoutInfo::IsRef => { + (ref_pos * locals.ref_vector_size) / tensor.tensor.vector_size() + } + LayoutInfo::Unknown => get_offset( + inputs, + locals, + tensor, + ref_pos, + None, + config, + comptime!(transform.clone()), + ), + } +} + +/// Reads an output tensor. +#[cube] +pub fn read_output( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] pos: usize, + ref_pos: usize, + #[comptime] layout: LayoutInfo, + #[comptime] config: &FuseBlockConfig, +) -> Vector { + let tensor = outputs.tensors.index(pos); + let offset = match layout { + LayoutInfo::SameAsRef => ref_pos, + LayoutInfo::IsRef => ref_pos, + LayoutInfo::Unknown => get_offset(inputs, locals, tensor, ref_pos, None, config, None), + }; + Vector::cast_from(tensor.tensor[offset]) +} + +#[cube] +/// Write the given value at the [arg](Arg) position. +pub fn write( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + ref_pos: usize, + value: Vector, + #[comptime] arg: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + set_polyfill_typed::, DynElem, DynSize>(); + + match arg { + FuseArg::Output(pos, _, layout) => { + let tensor = outputs.tensors.index(pos); + let output_vs = tensor.tensor.vector_size(); + if comptime![output_vs != config.width] { + // Output tensor has a different vector_size than the computation width. + // Write element-by-element to avoid SPIR-V type mismatch. + write_output_aligned( + inputs, outputs, &*locals, pos, ref_pos, value, layout, config, + ); + } else { + // Vector sizes match - set polyfill to output type and write directly. + set_polyfill::(comptime![tensor.ty]); + let offset = match layout { + LayoutInfo::SameAsRef => ref_pos, + LayoutInfo::IsRef => ref_pos, + LayoutInfo::Unknown => { + get_offset(inputs, &*locals, tensor, ref_pos, None, config, None) + } + }; + let tensor = outputs.tensors.index_mut(pos); + + let value = Vector::cast_from(value); + + tensor.tensor[offset] = value; + } + } + FuseArg::BlockLocal { .. } => write_scalar::(locals, value, arg), + FuseArg::MultiBlockLocal(key, _) | FuseArg::MultiBlockGlobal(key, _) => { + outputs.variables.write(key, Vector::cast_from(value)) + } + _ => comptime![panic!("Can't write into inputs and scalars")], + } +} + +/// Writes a [Vector] value element-by-element to an output tensor whose vector_size +/// differs from the computation width. Mirrors [read_input_aligned] for the write path. +#[cube] +fn write_output_aligned( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &LocalArgs, + #[comptime] pos: usize, + ref_pos: usize, + value: Vector, + #[comptime] layout: LayoutInfo, + #[comptime] config: &FuseBlockConfig, +) { + let tensor = outputs.tensors.index(pos); + set_polyfill::(comptime![tensor.ty]); + + match layout { + LayoutInfo::SameAsRef | LayoutInfo::IsRef => { + let offset = (ref_pos * config.width) / tensor.tensor.vector_size(); + let output = outputs.tensors.index_mut(pos); + let stride = output.tensor.stride(config.rank - 1); + + #[unroll] + for i in 0..config.width { + let idx = offset + i * stride; + let val = if comptime![value.vector_size() == config.width] { + Vector::cast_from(value.extract(i)) + } else { + Vector::cast_from(value.extract(i % value.vector_size())) + }; + output.tensor[idx] = val; + } + } + LayoutInfo::Unknown => { + // When layout differs from ref, each of the config.width elements may map + // to a different offset in the output (or to the same offset when the output + // has broadcast dimensions). Compute each element's offset individually to + // avoid cross-contamination of neighboring elements. + let base_flat = ref_pos * config.width; + + #[unroll] + for i in 0..config.width { + let tensor_ro = outputs.tensors.index(pos); + let offset = index_offset_with_layout( + inputs, + tensor_ro, + locals, + base_flat + i, + None, + config.rank, + None, + ); + let output = outputs.tensors.index_mut(pos); + + let val = if comptime![value.vector_size() == config.width] { + Vector::cast_from(value.extract(i)) + } else { + Vector::cast_from(value.extract(i % value.vector_size())) + }; + output.tensor[offset] = val; + } + } + } +} + +#[cube] +/// Write the given value at the [arg](Arg) position. +pub fn write_scalar( + locals: &mut LocalArgs, + value: Vector, + #[comptime] arg: FuseArg, +) { + match arg { + FuseArg::BlockLocal { pos, ty } => match comptime![ty] { + FuseType::F64 => locals.l_f64.insert(pos, Vector::cast_from(value)), + FuseType::F32 | FuseType::Flex32 => locals.l_f32.insert(pos, Vector::cast_from(value)), + FuseType::F16 => locals.l_f16.insert(pos, Vector::cast_from(value)), + FuseType::BF16 => locals.l_bf16.insert(pos, Vector::cast_from(value)), + FuseType::U64 => locals.l_u64.insert(pos, Vector::cast_from(value)), + FuseType::U32 => locals.l_u32.insert(pos, Vector::cast_from(value)), + FuseType::U16 => locals.l_u16.insert(pos, Vector::cast_from(value)), + FuseType::U8 => locals.l_u8.insert(pos, Vector::cast_from(value)), + FuseType::I64 => locals.l_i64.insert(pos, Vector::cast_from(value)), + FuseType::I32 => locals.l_i32.insert(pos, Vector::cast_from(value)), + FuseType::I16 => locals.l_i16.insert(pos, Vector::cast_from(value)), + FuseType::I8 => locals.l_i8.insert(pos, Vector::cast_from(value)), + }, + _ => comptime![panic!("Can't write into something else than scalars")], + } +} + +#[cube] +pub(crate) fn global_offset( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + index: usize, + #[comptime] arg: FuseArg, + #[comptime] range: Option<(usize, usize)>, + #[comptime] config: &FuseBlockConfig, +) -> usize { + match arg { + FuseArg::Input(pos, _precision, _layout) => { + let tensor = inputs.tensors.index(pos); + get_offset(inputs, locals, tensor, index, range, config, None) + } + FuseArg::Output(pos, _precision, _layout) => { + let tensor = outputs.tensors.index(pos); + get_offset(inputs, locals, tensor, index, range, config, None) + } + _ => panic!("Only input and output tensors have global offset."), + } +} + +#[cube] +fn get_offset( + inputs: &GlobalArgs, + locals: &LocalArgs, + tensor: &GlobalTensor, + ref_pos: usize, + #[comptime] range: Option<(usize, usize)>, + #[comptime] config: &FuseBlockConfig, + #[comptime] transform: Option, +) -> usize { + let offset_ref = ref_pos * locals.ref_vector_size; + index_offset_with_layout( + inputs, + tensor, + locals, + offset_ref, + range, + config.rank, + transform, + ) +} + +#[cube] +/// Gets the vector size for a global tensor. +pub fn global_vector_size( + global: &GlobalArgs, + #[comptime] pos: usize, +) -> comptime_type!(VectorSize) { + let tensor = global.tensors.index(pos); + tensor.tensor.vector_size() +} + +#[cube] +/// Gets the rank for a global tensor. +pub fn global_rank(global: &GlobalArgs, #[comptime] pos: usize) -> usize { + let tensor = global.tensors.index(pos); + tensor.tensor.rank() +} + +#[cube] +/// Gets the length for a global tensor. +pub fn global_len(global: &GlobalArgs, #[comptime] pos: usize) -> usize { + let tensor = global.tensors.index(pos); + tensor.tensor.len() +} + +#[cube] +/// Gets the buffer length for a global tensor. +pub fn global_buffer_len(global: &GlobalArgs, #[comptime] pos: usize) -> usize { + let tensor = global.tensors.index(pos); + tensor.tensor.buffer_len() +} + +#[cube] +/// Gets the reference tensor length. +pub fn ref_len( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] config: &FuseBlockConfig, +) -> usize { + match config.ref_layout.clone() { + RefLayout::Concrete(arg) => match comptime![arg] { + FuseArg::Input(index, _, _) => global_len(inputs, index), + FuseArg::Output(index, _, _) => global_len(outputs, index), + _ => panic!("Invalid concrete ref layout."), + }, + RefLayout::Virtual(..) => num_elements(locals, config), + } +} + +#[cube] +/// Gets the reference buffer tensor length. +pub fn ref_buffer_len( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] config: &FuseBlockConfig, +) -> usize { + match config.ref_layout.clone() { + RefLayout::Concrete(arg) => match comptime![arg] { + FuseArg::Input(index, _, _) => global_buffer_len(inputs, index), + FuseArg::Output(index, _, _) => global_buffer_len(outputs, index), + _ => panic!("Invalid concrete ref layout."), + }, + RefLayout::Virtual(VirtualLayout::SwapDims(arg, ..)) => match arg { + FuseArg::Input(index, _, _) => global_buffer_len(inputs, index), + FuseArg::Output(index, _, _) => global_buffer_len(outputs, index), + _ => panic!("Invalid concrete ref layout."), + }, + RefLayout::Virtual(VirtualLayout::Reshaped { .. }) => num_elements(locals, config), + RefLayout::Virtual(VirtualLayout::Shape(..)) => num_elements(locals, config), + RefLayout::Virtual(VirtualLayout::Runtime { .. }) => num_elements(locals, config), + } +} + +#[cube] +/// Gets the reference number of elements. +pub fn num_elements(locals: &LocalArgs, #[comptime] config: &FuseBlockConfig) -> usize { + let mut length = 1; + + for i in 0..config.rank { + length *= locals.ref_shape[i]; + } + + length +} + +#[cube] +/// Gets the reference axis shape. +pub fn ref_shape(locals: &LocalArgs, axis: usize) -> usize { + locals.ref_shape[axis] +} + +#[cube] +/// Gets the reference axis stride. +pub fn ref_stride(locals: &LocalArgs, axis: usize) -> usize { + locals.ref_strides[axis] +} + +#[cube] +/// Gets the reference vector size. +pub fn ref_vector_size(locals: &LocalArgs) -> comptime_type!(VectorSize) { + comptime![locals.ref_vector_size] +} + +#[cube] +/// Gets the given tensor axis shape. +pub fn global_shape(global: &GlobalArgs, axis: usize, #[comptime] pos: usize) -> usize { + let tensor = global.tensors.index(pos); + tensor.tensor.shape(axis) +} + +#[cube] +/// Gets the given tensor axis stride. +pub fn global_stride(global: &GlobalArgs, dim: usize, #[comptime] pos: usize) -> usize { + let tensor = global.tensors.index(pos); + tensor.tensor.stride(dim) +} + +#[cube] +fn index_offset_with_layout( + inputs: &GlobalArgs, + tensor: &GlobalTensor, + locals: &LocalArgs, + offset_ref: usize, + #[comptime] range: Option<(usize, usize)>, + #[comptime] rank: usize, + #[comptime] transform: Option, +) -> usize { + match comptime![transform.clone()] { + Some(Transform::Reshape(shape)) => { + comptime![assert!( + range.is_none(), + "Can't get a range on a reshaped tensor." + )]; + + let index = reshaped_index(inputs, locals, offset_ref, rank, shape); + reshaped_index_to_original_index(&tensor.tensor, index, rank) + } + Some(Transform::SwapDims(dim1, dim2)) => { + let (start, end) = comptime! {match range { + Some(range) => range, + None => (0, rank), + }}; + + let mut offset = 0; + + #[unroll] + for i in start..end { + let index = comptime![swap_dims_transform(i, (dim1, dim2))]; + let ogwl = offset_ref / locals.ref_strides[i]; + offset += ogwl % tensor.tensor.shape(index) * tensor.tensor.stride(index); + } + + offset / tensor.tensor.vector_size() + } + None => { + let (start, end) = comptime! {match range { + Some(range) => range, + None => (0, rank), + }}; + + let mut offset = 0; + + #[unroll] + for i in start..end { + let ogwl = offset_ref / locals.ref_strides[i]; + offset += ogwl % tensor.tensor.shape(i) * tensor.tensor.stride(i); + } + + offset / tensor.tensor.vector_size() + } + } +} + +pub(crate) fn swap_dims_transform(i: usize, dims: (usize, usize)) -> usize { + if i == dims.0 { + dims.1 + } else if i == dims.1 { + dims.0 + } else { + i + } +} + +#[cube] +#[allow(clippy::clone_on_copy)] +/// The index the input tensor would be at if it was contiguous. +fn reshaped_index( + inputs: &GlobalArgs, + locals: &LocalArgs, + index: usize, + #[comptime] rank: usize, + #[comptime] shape: Vec, +) -> usize { + let mut offset = 0; + let mut stride_curr = 1; + + #[unroll] + for r in 0..rank { + let i = reverse_index(rank, r).comptime(); + let arg = shape[i].clone(); + let shape_i = read_scalar_shape(inputs, arg); + let ogwl = index / locals.ref_strides[i]; + + offset += ogwl % shape_i * stride_curr; + + stride_curr *= shape_i; + } + + offset +} + +#[allow(unreachable_code)] +#[cube] +#[allow(clippy::clone_on_copy)] +fn reshaped_index_to_original_index( + original: &Tensor>, + index_reshaped: usize, + #[comptime] rank: usize, +) -> usize { + let mut remaining = index_reshaped; + let mut offset = 0; + + #[unroll] + for r in 0..rank { + let i = reverse_index(rank, r); + let shape = original.shape(i); + let stride = original.stride(i); + + let coordinate = remaining % shape; + + remaining /= shape; + offset += coordinate * stride; + } + + offset / original.vector_size() +} + +#[cube] +#[allow(unused_variables)] +pub(crate) fn reverse_index( + #[comptime] rank: usize, + #[comptime] iter: usize, +) -> comptime_type!(usize) { + rank - iter - 1 +} + +/// Generic way to construct any [`CubePrimitive`] from an int. Used for fusion. +#[allow(unused_variables)] +#[cube] +fn from_const_int(#[comptime] value: usize) -> C { + intrinsic!(|scope| { Value::constant(value.into(), C::__expand_as_type(scope)).into() }) +} + +#[cube] +#[allow(clippy::extra_unused_type_parameters)] +pub(crate) fn set_polyfill_typed() { + intrinsic!(|scope| { + let elem_type = C::__expand_as_type(scope); + set_polyfill::expand::(scope, elem_type); + }) +} diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/ir.rs b/crates/burn-cubecl-fusion/src/engine/codegen/ir.rs new file mode 100644 index 0000000..f114f42 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/ir.rs @@ -0,0 +1,1037 @@ +use super::tensor::GlobalTensor; +use crate::engine::codegen::{DynElem, DynSize, DynVector}; +use burn_std::{ + BoolStore, DType, Shape, Strides, bf16, f16, + quantization::{QuantScheme, QuantStore, QuantValue}, + strides, +}; +use core::fmt::Display; +use cubecl::{ + ir::{ElemType, FloatKind, IntKind, StorageType, UIntKind}, + prelude::*, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +/// Argument to a [fuse operation](FuseOp). +pub enum FuseArg { + /// A readonly input tensor. + Input(usize, FuseType, LayoutInfo), + /// A readwrite output tensor. + Output(usize, FuseType, LayoutInfo), + /// A temporary local variable within a single [block](FuseBlockConfig). + BlockLocal { + /// The position of the current variable relative to all local variables within a single block. + pos: usize, + /// The type of the current variable. + ty: FuseType, + }, + /// A variable shared between multiple [block](FuseBlockConfig) that must have a compatible + /// scope. + MultiBlockLocal(MultiBlockPos, FuseType), + /// A variable shared between multiple [blocks](FuseBlockConfig) within a global accessible + /// scope. + MultiBlockGlobal(MultiBlockPos, FuseType), + /// A global scalar. + Scalar(usize, FuseType), + /// A global scalar used in a reshape operation. + /// + /// This is not a scalar defined by a user for computation, but a scalar defined as part of + /// a reshape operation. + ScalarShape(usize), + /// Only constant that can be encoded into an u32 can be used as literal. + Literal(usize, FuseType), + /// A readonly input tensor that is reshaped. + InputReshaped { + original: Box, + shape: Vec, + broadcasted: bool, + }, + /// A readonly input tensor with swapped dimensions. + InputSwapDims { + original: Box, + dims: (usize, usize), + broadcasted: bool, + }, +} + +/// Metadata of a variable shared between blocks. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +pub struct MultiBlockPos { + /// The block position in all blocks included in a fused trace. + pub block_pos: usize, + /// The [FuseArg::BlockLocal] position in the block where the variable is first initialized. + pub block_local_pos: usize, +} + +#[derive( + CubeType, Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, +)] +/// Layout information. +pub enum LayoutInfo { + /// The layout if the same as the reference. + SameAsRef, + /// The reference layout. + IsRef, + /// The layout if unknown. + Unknown, +} + +impl FuseArg { + pub fn precision(&self) -> FuseType { + *match self { + FuseArg::Input(_, p, _) => p, + FuseArg::BlockLocal { ty, .. } => ty, + FuseArg::MultiBlockLocal(_, p) => p, + FuseArg::MultiBlockGlobal(_, p) => p, + FuseArg::Output(_, p, _) => p, + FuseArg::Scalar(_, p) => p, + FuseArg::Literal(_, p) => p, + FuseArg::ScalarShape(_) => return FuseType::U32, + FuseArg::InputReshaped { original, .. } => return original.precision(), + FuseArg::InputSwapDims { original, .. } => return original.precision(), + } + } +} + +impl CubeType for FuseArg { + type ExpandType = Self; +} + +impl IntoExpand for FuseArg { + type Expand = Self; + + fn into_expand(self, _: &Scope) -> Self::Expand { + self + } +} + +impl ExpandTypeClone for FuseArg { + fn clone_unchecked(&self) -> Self { + self.clone() + } +} + +impl AsRefExpand for FuseArg { + fn __expand_ref_method(&self, _: &Scope) -> &Self { + self + } +} + +impl AsMutExpand for FuseArg { + fn __expand_ref_mut_method(&mut self, _: &Scope) -> &mut Self { + self + } +} + +impl IntoMut for FuseArg { + fn into_mut(self, _context: &Scope) -> Self { + self + } +} + +impl IntoRuntime for FuseArg { + fn __expand_runtime_method(self, _context: &Scope) -> Self::ExpandType { + self + } +} + +impl CubeDebug for FuseArg {} + +#[derive(CubeType, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// Operations that can be executed and fused automatically using a fuse-on-read and/or +/// fuse-on-write strategy. +pub enum FuseOp { + Add(BinaryFuseArgs), + Sub(BinaryFuseArgs), + Mul(BinaryFuseArgs), + Div(BinaryFuseArgs), + Powf(BinaryFuseArgs), + Abs(UnaryFuseArgs), + Exp(UnaryFuseArgs), + Log(UnaryFuseArgs), + Log1p(UnaryFuseArgs), + Cos(UnaryFuseArgs), + Sin(UnaryFuseArgs), + Tanh(UnaryFuseArgs), + Erf(UnaryFuseArgs), + Sqrt(UnaryFuseArgs), + Recip(UnaryFuseArgs), + Assign(UnaryFuseArgs), + Equal(BinaryFuseArgs), + Lower(BinaryFuseArgs), + Greater(BinaryFuseArgs), + LowerEqual(BinaryFuseArgs), + Rem(BinaryFuseArgs), + GreaterEqual(BinaryFuseArgs), + BitwiseAnd(BinaryFuseArgs), + BitwiseOr(BinaryFuseArgs), + BitwiseXor(BinaryFuseArgs), + BitwiseLeftShift(BinaryFuseArgs), + BitwiseRightShift(BinaryFuseArgs), + BitwiseNot(UnaryFuseArgs), + Clamp { + input: FuseArg, + min: FuseArg, + max: FuseArg, + out: FuseArg, + }, + ConditionalAssign { + cond: FuseArg, + lhs: FuseArg, + rhs: FuseArg, + out: FuseArg, + }, + Gather { + input: FuseArg, + indices: FuseArg, + output: FuseArg, + dim: usize, + }, + Select { + input: FuseArg, + indices: FuseArg, + output: FuseArg, + dim: usize, + }, + Cat { + inputs: Vec, + output: FuseArg, + dim: usize, + }, + Dequantize { + values: FuseArg, + params: FuseArg, + output: FuseArg, + scheme: QuantSchemeFuse, + }, +} + +impl Display for FuseOp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FuseOp::Add(args) => write!(f, "{} = {} + {}", args.out, args.lhs, args.rhs), + FuseOp::Sub(args) => write!(f, "{} = {} - {}", args.out, args.lhs, args.rhs), + FuseOp::Mul(args) => write!(f, "{} = {} * {}", args.out, args.lhs, args.rhs), + FuseOp::Div(args) => write!(f, "{} = {} / {}", args.out, args.lhs, args.rhs), + FuseOp::Powf(args) => write!(f, "{} = powf({}, {})", args.out, args.lhs, args.rhs), + FuseOp::Abs(args) => write!(f, "{} = abs({})", args.out, args.input), + FuseOp::Exp(args) => write!(f, "{} = exp({})", args.out, args.input), + FuseOp::Log(args) => write!(f, "{} = log({})", args.out, args.input), + FuseOp::Log1p(args) => write!(f, "{} = log1p({})", args.out, args.input), + FuseOp::Cos(args) => write!(f, "{} = cos({})", args.out, args.input), + FuseOp::Sin(args) => write!(f, "{} = sin({})", args.out, args.input), + FuseOp::Tanh(args) => write!(f, "{} = tanh({})", args.out, args.input), + FuseOp::Erf(args) => write!(f, "{} = erf({})", args.out, args.input), + FuseOp::Sqrt(args) => write!(f, "{} = sqrt({})", args.out, args.input), + FuseOp::Recip(args) => write!(f, "{} = recip({})", args.out, args.input), + FuseOp::Assign(args) => write!(f, "{} = {}", args.out, args.input), + FuseOp::Equal(args) => write!(f, "{} = {} == {}", args.out, args.lhs, args.rhs), + FuseOp::Lower(args) => write!(f, "{} = {} < {}", args.out, args.lhs, args.rhs), + FuseOp::Greater(args) => write!(f, "{} = {} > {}", args.out, args.lhs, args.rhs), + FuseOp::LowerEqual(args) => write!(f, "{} = {} <= {}", args.out, args.lhs, args.rhs), + FuseOp::Rem(args) => write!(f, "{} = {} % {}", args.out, args.lhs, args.rhs), + FuseOp::GreaterEqual(args) => write!(f, "{} = {} >= {}", args.out, args.lhs, args.rhs), + FuseOp::BitwiseAnd(args) => write!(f, "{} = {} & {}", args.out, args.lhs, args.rhs), + FuseOp::BitwiseOr(args) => write!(f, "{} = {} | {}", args.out, args.lhs, args.rhs), + FuseOp::BitwiseXor(args) => write!(f, "{} = {} ^ {}", args.out, args.lhs, args.rhs), + FuseOp::BitwiseLeftShift(args) => { + write!(f, "{} = {} << {}", args.out, args.lhs, args.rhs) + } + FuseOp::BitwiseRightShift(args) => { + write!(f, "{} = {} >> {}", args.out, args.lhs, args.rhs) + } + FuseOp::BitwiseNot(args) => write!(f, "{} = !{}", args.out, args.input), + FuseOp::Clamp { + input, + min, + max, + out, + } => write!(f, "{} = clamp({}, min={}, max={})", out, input, min, max), + FuseOp::ConditionalAssign { + cond, + lhs, + rhs, + out, + } => write!( + f, + "{} = select(cond={}, lhs={}, rhs={})", + out, cond, lhs, rhs + ), + FuseOp::Gather { + input, + indices, + output, + dim, + } => write!( + f, + "{} = gather(input={}, indices={}, dim={})", + output, input, indices, dim + ), + FuseOp::Select { + input, + indices, + output, + dim, + } => write!( + f, + "{} = select(input={}, indices={}, dim={})", + output, input, indices, dim + ), + FuseOp::Cat { + inputs, + output, + dim, + } => { + write!(f, "{output} = cat(inputs=[")?; + for (i, input) in inputs.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{input}")?; + } + write!(f, "], dim={dim})") + } + FuseOp::Dequantize { + values, + params, + output, + scheme: _, + } => write!( + f, + "{} = dequantize(values={}, params={})", + output, values, params + ), + } + } +} + +#[derive( + CubeType, CubeLaunch, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, +)] +pub struct QuantSchemeFuse { + #[cube(comptime)] + pub(crate) scheme: QuantScheme, +} + +impl FuseOp { + /// Element type used for the computation. + pub(crate) fn cmp_elem(&self) -> ElemType { + match self { + FuseOp::Add(op) => op.lhs.precision().into_elem(), + FuseOp::Sub(op) => op.lhs.precision().into_elem(), + FuseOp::Mul(op) => op.lhs.precision().into_elem(), + FuseOp::Div(op) => op.lhs.precision().into_elem(), + FuseOp::Powf(op) => op.lhs.precision().into_elem(), + FuseOp::Abs(op) => op.out.precision().into_elem(), + FuseOp::Exp(op) => op.out.precision().into_elem(), + FuseOp::Log(op) => op.out.precision().into_elem(), + FuseOp::Log1p(op) => op.out.precision().into_elem(), + FuseOp::Cos(op) => op.out.precision().into_elem(), + FuseOp::Sin(op) => op.out.precision().into_elem(), + FuseOp::Tanh(op) => op.out.precision().into_elem(), + FuseOp::Erf(op) => op.out.precision().into_elem(), + FuseOp::Recip(op) => op.out.precision().into_elem(), + FuseOp::Sqrt(op) => op.out.precision().into_elem(), + FuseOp::Assign(op) => op.out.precision().into_elem(), + FuseOp::Equal(op) => op.lhs.precision().into_elem(), + FuseOp::Lower(op) => op.lhs.precision().into_elem(), + FuseOp::Greater(op) => op.lhs.precision().into_elem(), + FuseOp::LowerEqual(op) => op.lhs.precision().into_elem(), + FuseOp::GreaterEqual(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseAnd(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseOr(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseXor(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseLeftShift(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseRightShift(op) => op.lhs.precision().into_elem(), + FuseOp::BitwiseNot(op) => op.out.precision().into_elem(), + FuseOp::ConditionalAssign { out, .. } => out.precision().into_elem(), + FuseOp::Gather { output, .. } => output.precision().into_elem(), + FuseOp::Select { output, .. } => output.precision().into_elem(), + FuseOp::Cat { output, .. } => output.precision().into_elem(), + FuseOp::Dequantize { output, .. } => output.precision().into_elem(), + FuseOp::Rem(op) => op.out.precision().into_elem(), + FuseOp::Clamp { out, .. } => out.precision().into_elem(), + } + } + + pub(crate) fn cmp_storage_ty(&self) -> StorageType { + self.cmp_elem().into() + } +} + +#[derive(CubeType, CubeLaunch, Default, Clone)] +#[expand(derive(Clone))] +/// Global arguments that are used for fusing [element wise operations](ElemTypewiseOp). +pub struct GlobalArgs { + /// Tensors that are stored in global memory. + pub tensors: Sequence, + /// Scalars that are stored in global memory. + pub scalars: Sequence, + /// To be used to perform reshape inside a fused kernel. + pub reshapes: Sequence, + /// When there are no metadata as a reference layout, we provide runtime shape/strides in this + /// sequence instead. + pub runtime_layouts: Sequence, + /// Variables shared between blocks. + pub variables: MultiBlockVariables, +} + +impl GlobalArgsLaunch { + pub fn required_address_type(&self) -> AddressType { + self.tensors + .values + .iter() + .map(|it| it.address_type) + .max() + .unwrap_or_default() + } +} + +/// Variables shared between blocks. +#[derive(CubeType, Default, Clone)] +#[expand(derive(Clone))] +pub struct MultiBlockVariables { + variables: Registry>>, +} + +#[cube] +impl MultiBlockVariables { + /// Initializes the variable with the given key and vector size. + /// + /// # Notes + /// + /// The type of [`NumericExpand`] must be set before calling this function. + pub fn init(&mut self, #[comptime] key: MultiBlockPos) { + let mut registers = + Registry::>>::find_or_default::( + &mut self.variables, + key.block_pos, + ); + let cell = RuntimeCell::new(Vector::empty()); + registers.insert(key.block_local_pos, cell); + } + + /// Read the variable using the provided key. + /// + /// # Notes + /// + /// The variable must be initialized. + pub fn read(&self, #[comptime] key: MultiBlockPos) -> DynVector { + let registers = self.variables.find(key.block_pos); + let cell = registers.find(key.block_local_pos); + cell.read() + } + + /// Write to the variable using the provided key and value. + /// + /// # Notes + /// + /// The variable must be initialized. + pub fn write(&mut self, #[comptime] key: MultiBlockPos, value: DynVector) { + let registers = self.variables.find(key.block_pos); + // Try find for local(visibility) registers. + let cell = registers.find(key.block_local_pos); + cell.store(value); + } +} + +// Because we only create it DURING compilation, not as a real launch arg. +unsafe impl Send for MultiBlockVariables {} +unsafe impl Sync for MultiBlockVariables {} + +impl LaunchArg for MultiBlockVariables { + type RuntimeArg = (); + type CompilationArg = (); + + fn register(_arg: Self::RuntimeArg, _launcher: &mut KernelLauncher) {} + + fn expand( + _arg: &Self::CompilationArg, + _builder: &mut KernelBuilder, + ) -> ::ExpandType { + MultiBlockVariablesExpand { + variables: Default::default(), + } + } +} + +impl Default for GlobalArgsLaunch { + fn default() -> Self { + Self { + tensors: Default::default(), + scalars: Default::default(), + reshapes: Default::default(), + variables: Default::default(), + runtime_layouts: Default::default(), + _phantom_runtime: std::marker::PhantomData, + } + } +} + +impl core::fmt::Debug for GlobalArgsLaunch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({:?})", self.tensors.values) + } +} + +impl GlobalArgsLaunch { + /// Get the shape of the given [argument](Arg). + /// + /// # Panics + /// + /// If the argument doesn't have an handle. + pub fn shape(&self, arg: &FuseArg) -> Shape { + match self.resolve_arg(arg) { + TensorArg::Handle { handle, .. } => handle.shape.clone(), + TensorArg::Alias { shape, .. } => shape.clone(), + } + } + + /// Shape used by the reference tensor. + pub fn shape_ref(&self, ref_layout: &RefLayout, rank: usize) -> Shape { + match ref_layout { + RefLayout::Concrete(arg) => self.shape(arg), + RefLayout::Virtual(layout) => match layout { + VirtualLayout::SwapDims(original, dims) => { + let mut shape = self.shape(original); + shape.swap(dims.0, dims.1); + shape + } + VirtualLayout::Reshaped { reshape_pos, .. } => { + let start = *reshape_pos * rank; + let end = start + rank; + self.reshapes.values[start..end].iter().copied().collect() + } + VirtualLayout::Shape(original, _) => self.shape(original), + VirtualLayout::Runtime { pos } => { + let start = (*pos * 2) * rank; + let end = start + rank; + self.runtime_layouts.values[start..end] + .iter() + .copied() + .collect() + } + }, + } + } + + /// Get the strides of the given [argument](Arg). + /// + /// # Panics + /// + /// If the argument doesn't have an handle. + pub fn strides(&self, arg: &FuseArg) -> Strides { + match self.resolve_arg(arg) { + TensorArg::Handle { handle, .. } => handle.strides.clone(), + TensorArg::Alias { strides, .. } => strides.clone(), + } + } + + pub fn strides_ref(&self, ref_layout: &RefLayout, rank: usize) -> Strides { + match ref_layout { + RefLayout::Concrete(arg) => self.strides(arg), + // When not concrete, we operate on the contiguous layout. + _ => { + let shape = self.shape_ref(ref_layout, rank); + let mut strides = strides![0; shape.len()]; + + let mut current = 1; + shape.iter().enumerate().rev().for_each(|(index, val)| { + strides[index] = current; + current *= val; + }); + + strides + } + } + } + + /// Get the vector size of the given [argument](Arg). + /// + /// # Panics + /// + /// If the argument doesn't have an handle. + pub fn vector_size(&self, arg: &FuseArg) -> VectorSize { + match arg { + FuseArg::Input(pos, _, _) => self.tensors.values[*pos].ty.vector_size(), + FuseArg::Output(pos, _, _) => self.tensors.values[*pos].ty.vector_size(), + other => panic!("Arg not found: {other:?}"), + } + } + + /// Resolve the [argument](Arg) to a [tensor argument](TensorArg). + /// + /// # Panics + /// + /// If the argument isn't a global input or output tensor. + pub fn resolve_arg(&self, arg: &FuseArg) -> &TensorArg { + match arg { + FuseArg::Input(pos, _, _) => &self.tensors.values[*pos].tensor, + FuseArg::Output(pos, _, _) => &self.tensors.values[*pos].tensor, + other => panic!("Arg not found: {other:?}"), + } + } +} + +#[derive(CubeType, Clone)] +#[expand(derive(Clone))] +/// Keep track of all local variables that are used as argument in fused +/// [element wise operations](ElemwiseOp). +pub struct LocalArgs { + pub l_f64: Registry>, + pub l_f32: Registry>, + pub l_f16: Registry>, + pub l_bf16: Registry>, + pub l_i64: Registry>, + pub l_i32: Registry>, + pub l_i16: Registry>, + pub l_i8: Registry>, + pub l_u64: Registry>, + pub l_u32: Registry>, + pub l_u16: Registry>, + pub l_u8: Registry>, + pub ref_shape: Box<[usize]>, + pub ref_strides: Box<[usize]>, + #[cube(comptime)] + pub ref_vector_size: VectorSize, +} + +#[cube] +impl LocalArgs { + /// Creates a new [LocalArgs] container. + pub fn new( + ref_shape: &[usize], + ref_strides: &[usize], + #[comptime] ref_vector_size: VectorSize, + ) -> LocalArgs { + LocalArgs { + l_f64: Registry::>::new(), + l_f32: Registry::>::new(), + l_f16: Registry::>::new(), + l_bf16: Registry::>::new(), + l_i64: Registry::>::new(), + l_i32: Registry::>::new(), + l_i16: Registry::>::new(), + l_i8: Registry::>::new(), + l_u64: Registry::>::new(), + l_u32: Registry::>::new(), + l_u16: Registry::>::new(), + l_u8: Registry::>::new(), + ref_shape: unsafe { ref_shape.as_boxed_unchecked() }, + ref_strides: unsafe { ref_strides.as_boxed_unchecked() }, + ref_vector_size, + } + } +} + +#[derive(CubeType, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// Unary [element wise operation](ElemwiseOp) arguments. +pub struct UnaryFuseArgs { + pub input: FuseArg, + pub out: FuseArg, +} + +#[derive(CubeType, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// Binary [element wise operation](ElemwiseOp) arguments. +pub struct BinaryFuseArgs { + pub lhs: FuseArg, + pub rhs: FuseArg, + pub out: FuseArg, +} + +#[derive( + CubeType, Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +/// Precisions supported by [element wise operations](ElemwiseOp). +/// +/// This is a custom type instead of [ElemType] so it can implement [CubeType] +/// and restricts the supported types for fusion. +pub enum FuseType { + F64, + F32, + Flex32, + F16, + BF16, + I64, + I32, + I16, + I8, + U64, + U32, + U16, + U8, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// Configuration that encapsulates all comptime information necessary for element wise fusion. +pub struct FuseBlockConfig { + pub rank: usize, + pub ref_layout: RefLayout, + pub ops: Vec, + pub width: VectorSize, +} + +impl AsRefExpand for FuseBlockConfig { + fn __expand_ref_method(&self, _: &Scope) -> &Self { + self + } +} + +impl FuseBlockConfig { + pub fn multi_block_variables(&self, registers: &mut Vec<(MultiBlockPos, StorageType)>) { + for op in self.ops.iter() { + op.multi_block_variables(registers); + } + } +} + +impl FuseArg { + pub fn multi_block_variable(&self, registers: &mut Vec<(MultiBlockPos, StorageType)>) { + match self { + FuseArg::MultiBlockGlobal(arg, fuse_type) + // TODO: we need to init the multi-block local, but at some point we could avoid + // that for performance (easier for the underlying compiler). + | FuseArg::MultiBlockLocal(arg, fuse_type) => { + registers.push((arg.clone(), fuse_type.into_storage_type())) + } + _ => {} + }; + } +} + +impl FuseOp { + pub fn multi_block_variables(&self, registers: &mut Vec<(MultiBlockPos, StorageType)>) { + match self { + FuseOp::Add(binary_fuse_args) + | FuseOp::Sub(binary_fuse_args) + | FuseOp::Mul(binary_fuse_args) + | FuseOp::Div(binary_fuse_args) + | FuseOp::Powf(binary_fuse_args) + | FuseOp::Equal(binary_fuse_args) + | FuseOp::Lower(binary_fuse_args) + | FuseOp::Greater(binary_fuse_args) + | FuseOp::LowerEqual(binary_fuse_args) + | FuseOp::Rem(binary_fuse_args) + | FuseOp::GreaterEqual(binary_fuse_args) + | FuseOp::BitwiseAnd(binary_fuse_args) + | FuseOp::BitwiseOr(binary_fuse_args) + | FuseOp::BitwiseXor(binary_fuse_args) + | FuseOp::BitwiseLeftShift(binary_fuse_args) + | FuseOp::BitwiseRightShift(binary_fuse_args) => { + binary_fuse_args.lhs.multi_block_variable(registers); + binary_fuse_args.rhs.multi_block_variable(registers); + binary_fuse_args.out.multi_block_variable(registers); + } + FuseOp::Abs(unary_fuse_args) + | FuseOp::Exp(unary_fuse_args) + | FuseOp::Log(unary_fuse_args) + | FuseOp::Log1p(unary_fuse_args) + | FuseOp::Cos(unary_fuse_args) + | FuseOp::Sin(unary_fuse_args) + | FuseOp::Tanh(unary_fuse_args) + | FuseOp::Erf(unary_fuse_args) + | FuseOp::Sqrt(unary_fuse_args) + | FuseOp::Recip(unary_fuse_args) + | FuseOp::Assign(unary_fuse_args) + | FuseOp::BitwiseNot(unary_fuse_args) => { + unary_fuse_args.input.multi_block_variable(registers); + unary_fuse_args.out.multi_block_variable(registers); + } + FuseOp::Clamp { + input, + min, + max, + out, + } => { + input.multi_block_variable(registers); + min.multi_block_variable(registers); + max.multi_block_variable(registers); + out.multi_block_variable(registers); + } + FuseOp::ConditionalAssign { + cond, + lhs, + rhs, + out, + } => { + cond.multi_block_variable(registers); + lhs.multi_block_variable(registers); + rhs.multi_block_variable(registers); + out.multi_block_variable(registers); + } + FuseOp::Gather { + input, + indices, + output, + dim: _, + } => { + input.multi_block_variable(registers); + indices.multi_block_variable(registers); + output.multi_block_variable(registers); + } + FuseOp::Select { + input, + indices, + output, + dim: _, + } => { + input.multi_block_variable(registers); + indices.multi_block_variable(registers); + output.multi_block_variable(registers); + } + FuseOp::Cat { + inputs, + output, + dim: _, + } => { + for input in inputs { + input.multi_block_variable(registers); + } + output.multi_block_variable(registers); + } + FuseOp::Dequantize { + values, + params, + output, + scheme: _, + } => { + values.multi_block_variable(registers); + params.multi_block_variable(registers); + output.multi_block_variable(registers); + } + } + } +} + +#[cube] +/// Initializes block variables, both globals and locals. +pub fn multi_block_variables_init( + #[comptime] block: &FuseBlockConfig, + variables: &mut MultiBlockVariables, +) { + let output = comptime! { + let mut output = Vec::<(MultiBlockPos, StorageType)>::new(); + block.multi_block_variables(&mut output); + output + }; + + #[unroll] + for i in 0..comptime!(output.len()) { + let (key, dtype) = comptime!(output.get(i).unwrap().clone()); + set_polyfill::(comptime![Type::new(dtype).with_vector_size(block.width)]); + variables.init(key); + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// A reference layout determines how a fuse execution will access elements in tensors. +/// +/// It can either follow the same layout as a concrete tensor, or follow a virtual layout. +pub enum RefLayout { + Concrete(FuseArg), + Virtual(VirtualLayout), +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// A virtual layout is always contiguous and retrieves its shape from either a reshaped tensor or a +/// tensor with swap dimensions. +pub enum VirtualLayout { + /// Virtual tensor with the provided shape id and contiguous strides. + Reshaped { + reshape_pos: usize, + vector_size: VectorSize, + }, + /// Virtual tensor with the same shape as the given input, but with swap dims and contiguous + /// strides. + SwapDims(FuseArg, (usize, usize)), + /// Virtual tensor with the same shape as the given input, but with contiguous strides. + Shape(FuseArg, usize), + /// We don't have access to global metadata, they are passed as runtime values. + Runtime { pos: usize }, +} + +impl FuseArg { + /// Adds layout information. + /// + /// It's going to impact how the input or output is read and written to. + pub fn add_layout_info(&mut self, layout: LayoutInfo) { + match self { + FuseArg::Input(_, _, old) => { + *old = layout; + } + FuseArg::Output(_, _, old) => { + *old = layout; + } + _ => {} + } + } +} + +impl RegistryQuery for FuseArg {} + +impl From for FuseType { + fn from(value: ElemType) -> Self { + match value { + ElemType::Float(kind) => match kind { + FloatKind::F16 => Self::F16, + FloatKind::BF16 => Self::BF16, + FloatKind::F32 => Self::F32, + FloatKind::F64 => Self::F64, + FloatKind::Flex32 => Self::Flex32, + _ => panic!("Unsupported precision for fusion: {value}"), + }, + ElemType::Int(kind) => match kind { + IntKind::I64 => Self::I64, + IntKind::I32 => Self::I32, + IntKind::I16 => Self::I16, + IntKind::I8 => Self::I8, + }, + ElemType::UInt(kind) => match kind { + UIntKind::U64 => Self::U64, + UIntKind::U32 => Self::U32, + UIntKind::U16 => Self::U16, + UIntKind::U8 => Self::U8, + }, + ElemType::Bool => Self::U32, + } + } +} + +impl From for FuseType { + fn from(value: StorageType) -> Self { + value.elem_type().into() + } +} + +impl FuseType { + /// Converts the [fused element type](FuseType) into the [cubecl element type](ElemType). + pub fn into_elem(self) -> ElemType { + match self { + FuseType::F32 => ElemType::Float(FloatKind::F32), + FuseType::Flex32 => ElemType::Float(FloatKind::Flex32), + FuseType::F16 => ElemType::Float(FloatKind::F16), + FuseType::BF16 => ElemType::Float(FloatKind::BF16), + FuseType::I64 => ElemType::Int(IntKind::I64), + FuseType::I32 => ElemType::Int(IntKind::I32), + FuseType::I16 => ElemType::Int(IntKind::I16), + FuseType::I8 => ElemType::Int(IntKind::I8), + FuseType::U64 => ElemType::UInt(UIntKind::U64), + FuseType::U32 => ElemType::UInt(UIntKind::U32), + FuseType::U16 => ElemType::UInt(UIntKind::U16), + FuseType::U8 => ElemType::UInt(UIntKind::U8), + FuseType::F64 => ElemType::Float(FloatKind::F64), + } + } + + /// Convert the [fused element type](FuseType) into the [cubecl storage type](StorageType). + pub fn into_storage_type(self) -> StorageType { + self.into_elem().into() + } + + /// Convert the [fused element type](FuseType) into the [cubecl type](Type) + pub fn into_type(self, vector_size: VectorSize) -> Type { + Type::new(self.into_storage_type()).with_vector_size(vector_size) + } +} + +impl From for FuseType { + fn from(value: DType) -> Self { + match value { + DType::F32 => Self::F32, + DType::Flex32 => Self::Flex32, + DType::F16 => Self::F16, + DType::BF16 => Self::BF16, + DType::I64 => Self::I64, + DType::I32 => Self::I32, + DType::I16 => Self::I16, + DType::I8 => Self::I8, + DType::U64 => Self::U64, + DType::U32 => Self::U32, + DType::U16 => Self::U16, + DType::U8 => Self::U8, + DType::Bool(BoolStore::Native) => Self::U32, + DType::Bool(BoolStore::U8) => Self::U8, + DType::Bool(BoolStore::U32) => Self::U32, + DType::F64 => Self::F64, + DType::QFloat(scheme) => match scheme.store { + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S => Self::I8, + QuantValue::E4M3 | QuantValue::E5M2 => { + unimplemented!("Unsupported precision for fusion") + } + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 => { + panic!("Can't store native sub-byte values") + } + }, + QuantStore::PackedU32(_) => Self::U32, + QuantStore::PackedNative(_) => match scheme.value { + QuantValue::E2M1 => unimplemented!("Unsupported precision for fusion"), + other => panic!("{other:?} doesn't support native packing"), + }, + }, + } + } +} + +impl Display for FuseArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FuseArg::Input(pos, ..) => write!(f, "input({pos})"), + FuseArg::Output(pos, ..) => write!(f, "output({pos})"), + FuseArg::BlockLocal { pos, ty } => write!(f, "local({pos}, {ty:?})"), + FuseArg::MultiBlockLocal(mbp, ..) => write!(f, "{mbp}"), + FuseArg::MultiBlockGlobal(mbp, ..) => write!(f, "global_{mbp}"), + FuseArg::Scalar(pos, ..) => write!(f, "scalar({pos})"), + FuseArg::ScalarShape(pos) => write!(f, "scalar_shape({pos})"), + FuseArg::Literal(val, ..) => write!(f, "literal_{val}"), + FuseArg::InputReshaped { original, .. } => write!(f, "input_reshaped_{original}"), + FuseArg::InputSwapDims { original, .. } => write!(f, "input_swap_dims_{original}"), + } + } +} + +impl Display for MultiBlockPos { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "block_local({}-{})", + self.block_pos, self.block_local_pos + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuse_type_roundtrips_through_elem_type() { + let all = [ + FuseType::F64, + FuseType::F32, + FuseType::Flex32, + FuseType::F16, + FuseType::BF16, + FuseType::I64, + FuseType::I32, + FuseType::I16, + FuseType::I8, + FuseType::U64, + FuseType::U32, + FuseType::U16, + FuseType::U8, + ]; + for ft in all { + assert_eq!( + FuseType::from(ft.into_elem()), + ft, + "round-trip failed for {ft:?}", + ); + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/kernel.rs b/crates/burn-cubecl-fusion/src/engine/codegen/kernel.rs new file mode 100644 index 0000000..b609e64 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/kernel.rs @@ -0,0 +1,1097 @@ +use crate::engine::codegen::{DynElem, DynSize}; + +use super::{io::*, ir::*}; +use burn_std::quantization::{QuantScheme, QuantStore, QuantValue}; +use cubecl::{ + ir::{ElemType, FloatKind, StorageType, UIntKind}, + prelude::*, +}; +use cubek::quantization::{dequantize::dequantize_symmetric_packed_value_at, scheme::QuantMode}; + +#[cube] +/// Fuse element-wise operations at the given write position. +/// +/// # Arguments +/// +/// - `inputs`: Contains all readonly global kernel arguments. +/// - `outputs`: Contains all readwrite global kernel arguments. +/// - `locals`: Contains all local variables defined during kernel expansion. +/// - `write_pos`: The logical position the values are written to. +/// - `write_values`: The explicit values to write at the given position. +/// - `write_args`: The arguments associated to the `writes_values`. +/// - `config`: The current [fuse block configuration](FuseBlockConfig). +/// +/// # Notes +/// +/// The function will start by writing `write_values`. +pub fn fuse_on_write( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + write_values: Registry>, + #[comptime] write_args: Vec, + #[comptime] config: &FuseBlockConfig, +) { + comment!("Fuse on write begin"); + // Write the values given as arguments. + #[unroll] + for i in 0..write_args.len() { + let arg = comptime![write_args.get(i).unwrap().clone()]; + let val = write_values.find(arg.clone()); + + write::(inputs, outputs, locals, write_pos, val, arg, config); + } + + fuse(inputs, outputs, locals, write_pos, config); + comment!("Fuse on write end"); +} + +#[cube] +/// Fuse element-wise operations at the given read position. +/// +/// # Arguments +/// +/// - `inputs`: Contains all readonly global kernel arguments. +/// - `outputs`: Contains all readwrite global kernel arguments. +/// - `locals`: Contains all local variables defined during kernel expansion. +/// - `read_pos`: The logical position the values are read from. +/// - `read_args`: The arguments associated to the `read_pos`. +/// - `config`: The current [fuse block configuration](FuseBlockConfig). +/// +/// # Returns +/// +/// - A sequence of values associated to the given `read_args`. +pub fn fuse_on_read( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + read_pos: usize, + #[comptime] read_args: Sequence, + #[comptime] config: &FuseBlockConfig, +) -> Sequence> { + comment!("Fuse on read begin"); + fuse(inputs, outputs, locals, read_pos, config); + + let mut output = Sequence::new(); + + #[unroll] + for i in 0..read_args.len() { + let arg = comptime![read_args.index(i).clone()]; + let value = read::(inputs, &*outputs, &*locals, read_pos, arg, config); + + output.push(value); + } + + comment!("Fuse on read end"); + output +} + +#[cube] +/// Initializes [LocalArgs] given the input and output [arguments](GlobalArgs) with the [FuseBlockConfig]. +/// +/// # Notes +/// +/// The goal is to resolve and cache the reference shape and strides, as it is used in many +/// different function during kernel expansion. +pub fn init_locals( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + #[comptime] config: &FuseBlockConfig, +) -> LocalArgs { + comment!("Init locals begin"); + let mut ref_shape = Array::new(config.rank); + let mut ref_strides = Array::new(config.rank); + + let locals = match config.ref_layout.clone() { + RefLayout::Concrete(arg) => match comptime![arg] { + FuseArg::Input(index, ..) => { + let layout = inputs.tensors.index(index); + + #[unroll] + for i in 0..config.rank { + ref_shape[i] = layout.tensor.shape(i); + ref_strides[i] = layout.tensor.stride(i); + } + + LocalArgs::new( + ref_shape.as_slice(), + ref_strides.as_slice(), + layout.tensor.vector_size(), + ) + } + FuseArg::Output(index, ..) => { + let layout = outputs.tensors.index(index); + + #[unroll] + for i in 0..config.rank { + ref_shape[i] = layout.tensor.shape(i); + ref_strides[i] = layout.tensor.stride(i); + } + + LocalArgs::new( + ref_shape.as_slice(), + ref_strides.as_slice(), + layout.tensor.vector_size(), + ) + } + _ => comptime![panic!("Invalid concrete ref layout.")], + }, + RefLayout::Virtual(layout) => match layout { + VirtualLayout::SwapDims(original, dims) => { + let layout = match original.clone() { + FuseArg::Input(pos, ..) => inputs.tensors.index(pos), + FuseArg::Output(pos, ..) => outputs.tensors.index(pos), + _ => comptime![panic!("Unsupported")], + }; + + let mut stride_curr = 1; + + #[unroll] + #[allow(clippy::clone_on_copy)] + for i in 0..config.rank { + let reverse = reverse_index(config.rank, i); + let swap = comptime![swap_dims_transform(reverse, dims)]; + let shape = layout.tensor.shape(swap.clone()); + + ref_shape[reverse] = shape; + ref_strides[reverse] = stride_curr; + + stride_curr *= ref_shape[comptime![reverse]]; + } + + LocalArgs::new( + ref_shape.as_slice(), + ref_strides.as_slice(), + layout.tensor.vector_size(), + ) + } + VirtualLayout::Reshaped { + reshape_pos, + vector_size, + } => { + let mut stride_curr = 1; + let start = reshape_pos * config.rank; + + #[unroll] + #[allow(clippy::clone_on_copy)] + for i in 0..config.rank { + let reverse = reverse_index(config.rank, i); + let arg = comptime![FuseArg::ScalarShape(start + reverse)]; + let shape = read_scalar_shape(inputs, arg.clone()); + + ref_shape[comptime![reverse]] = shape; + ref_strides[comptime![reverse]] = stride_curr; + + stride_curr *= ref_shape[comptime![reverse]]; + } + + LocalArgs::new(ref_shape.as_slice(), ref_strides.as_slice(), vector_size) + } + VirtualLayout::Runtime { pos } => { + let start_shape = (pos * 2) * config.rank; + let start_strides = start_shape + config.rank; + + #[unroll] + for i in 0..config.rank { + let shape_index = start_shape + i; + let strides_index = start_strides + i; + + ref_shape[i] = *inputs.runtime_layouts.index(shape_index); + ref_strides[i] = *inputs.runtime_layouts.index(strides_index); + } + + LocalArgs::new(ref_shape.as_slice(), ref_strides.as_slice(), config.width) + } + VirtualLayout::Shape(original, vector_size) => { + let layout = match original.clone() { + FuseArg::Input(pos, ..) => inputs.tensors.index(pos), + FuseArg::Output(pos, ..) => outputs.tensors.index(pos), + _ => comptime![panic!("Unsupported")], + }; + let mut stride_curr = 1; + + #[unroll] + #[allow(clippy::clone_on_copy)] + for i in 0..config.rank { + let reverse = reverse_index(config.rank, i); + let shape = layout.tensor.shape(reverse); + + ref_shape[comptime![reverse]] = shape; + ref_strides[comptime![reverse]] = stride_curr; + + stride_curr *= ref_shape[comptime![reverse]]; + } + + LocalArgs::new(ref_shape.as_slice(), ref_strides.as_slice(), vector_size) + } + }, + }; + comment!("Init locals end"); + locals +} + +#[cube] +/// Expands all [operations](FuseOp) registered in the [block config](FuseBlockConfig]. +fn fuse( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + pos: usize, + #[comptime] config: &FuseBlockConfig, +) { + #[unroll] + for index in 0..config.ops.len() { + let op = config.ops[index].clone(); + let define!(E) = op.cmp_storage_ty(); + let size!(N) = config.width; + + match op { + FuseOp::Add(op) => add::(inputs, outputs, locals, pos, op, config), + FuseOp::Div(op) => div::(inputs, outputs, locals, pos, op, config), + FuseOp::Sub(op) => sub::(inputs, outputs, locals, pos, op, config), + FuseOp::Mul(op) => mul::(inputs, outputs, locals, pos, op, config), + FuseOp::Powf(op) => powf::(inputs, outputs, locals, pos, op, config), + FuseOp::Erf(op) => erf::(inputs, outputs, locals, pos, op, config), + FuseOp::Sqrt(op) => sqrt::(inputs, outputs, locals, pos, op, config), + FuseOp::Abs(op) => abs::(inputs, outputs, locals, pos, op, config), + FuseOp::Log(op) => log::(inputs, outputs, locals, pos, op, config), + FuseOp::Log1p(op) => log1p::(inputs, outputs, locals, pos, op, config), + FuseOp::Recip(op) => recip::(inputs, outputs, locals, pos, op, config), + FuseOp::Assign(op) => assign::(inputs, outputs, locals, pos, op, config), + FuseOp::Exp(op) => exp::(inputs, outputs, locals, pos, op, config), + FuseOp::Cos(op) => cos::(inputs, outputs, locals, pos, op, config), + FuseOp::Sin(op) => sin::(inputs, outputs, locals, pos, op, config), + FuseOp::Tanh(op) => tanh::(inputs, outputs, locals, pos, op, config), + FuseOp::Equal(op) => equal::(inputs, outputs, locals, pos, op, config), + FuseOp::Greater(op) => greater::(inputs, outputs, locals, pos, op, config), + FuseOp::GreaterEqual(op) => { + greater_equal::(inputs, outputs, locals, pos, op, config) + } + FuseOp::Lower(op) => lower::(inputs, outputs, locals, pos, op, config), + FuseOp::LowerEqual(op) => lower_equal::(inputs, outputs, locals, pos, op, config), + FuseOp::BitwiseAnd(op) => bitwise_and::(inputs, outputs, locals, pos, op, config), + FuseOp::BitwiseOr(op) => bitwise_or::(inputs, outputs, locals, pos, op, config), + FuseOp::BitwiseXor(op) => bitwise_xor::(inputs, outputs, locals, pos, op, config), + FuseOp::BitwiseLeftShift(op) => { + bitwise_left_shift::(inputs, outputs, locals, pos, op, config) + } + FuseOp::BitwiseRightShift(op) => { + bitwise_right_shift::(inputs, outputs, locals, pos, op, config) + } + FuseOp::BitwiseNot(op) => bitwise_not::(inputs, outputs, locals, pos, op, config), + FuseOp::ConditionalAssign { + cond, + lhs, + rhs, + out, + } => conditional_assign::( + inputs, outputs, locals, pos, cond, lhs, rhs, out, config, + ), + FuseOp::Gather { + input, + indices, + output, + dim, + } => gather::( + inputs, outputs, locals, pos, dim, input, indices, output, config, + ), + FuseOp::Select { + input, + indices, + output, + dim, + } => select_indices::( + inputs, outputs, locals, pos, dim, input, indices, output, config, + ), + FuseOp::Cat { + inputs: tensors, + output, + dim, + } => concat::(inputs, outputs, locals, pos, dim, tensors, output, config), + FuseOp::Dequantize { + values, + params, + output, + scheme, + } => dequantize::( + inputs, + outputs, + locals, + pos, + values, + params, + output, + scheme.scheme, + config, + ), + FuseOp::Rem(op) => rem::(inputs, outputs, locals, pos, op, config), + FuseOp::Clamp { + input, + min, + max, + out, + } => clamp::(inputs, outputs, locals, pos, input, min, max, out, config), + } + } +} + +macro_rules! binary_op { + ($ident:ident, $op:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: BinaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let lhs = read::(inputs, &*outputs, &*locals, write_pos, op.lhs, config); + let rhs = read::(inputs, &*outputs, &*locals, write_pos, op.rhs, config); + let result = lhs $op rhs; + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +macro_rules! binary_int_op { + ($ident:ident, $op:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: BinaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let lhs = read::(inputs, &*outputs, &*locals, write_pos, op.lhs, config); + let rhs = read::(inputs, &*outputs, &*locals, write_pos, op.rhs, config); + let result = lhs $op rhs; + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +macro_rules! unary_int_op { + ($ident:ident, $op:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: UnaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let input = read::(inputs, &*outputs, &*locals, write_pos, op.input, config); + let result = $op input; + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +macro_rules! binary_func { + ($ident:ident, $func:expr, $c:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: BinaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let lhs = read::(inputs, &*outputs, &*locals, write_pos, op.lhs, config); + let rhs = read::(inputs, &*outputs, &*locals, write_pos, op.rhs, config); + let result = $func(lhs, rhs); + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +macro_rules! comparison_op { + ($ident:ident, $op:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: BinaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let lhs = read::(inputs, &*outputs, &*locals, write_pos, op.lhs, config); + let rhs = read::(inputs, &*outputs, &*locals, write_pos, op.rhs, config); + let result = Vector::new(lhs $op rhs); + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +macro_rules! unary_func { + ($ident:ident, $func:expr, $c:tt) => { + #[cube] + fn $ident( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: UnaryFuseArgs, + #[comptime] config: &FuseBlockConfig, + ) { + let input = read::(inputs, &*outputs, &*locals, write_pos, op.input, config); + let result = $func(input); + + write::(inputs, outputs, locals, write_pos, result, op.out, config); + } + }; +} + +#[cube] +fn assign( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] op: UnaryFuseArgs, + #[comptime] config: &FuseBlockConfig, +) { + let input = read::(inputs, &*outputs, &*locals, write_pos, op.input, config); + + write::(inputs, outputs, locals, write_pos, input, op.out, config); +} + +#[cube] +fn gather( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] dim: usize, + #[comptime] input: FuseArg, + #[comptime] indices: FuseArg, + #[comptime] output: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + let vector_size = locals.ref_vector_size; + + let pos_input = comptime! { + match input { + FuseArg::Input(pos, ..) => pos, + _ => panic!("Input tensor isn't an input"), + } + }; + let pos_indices = comptime! { + match indices { + FuseArg::Input(pos, ..) => pos, + _ => panic!("Indices tensor isn't an input"), + } + }; + + let stride_input_dim = global_stride(inputs, dim, pos_input); + + let mut index = 0; + let mut result = Vector::::empty(); + + if comptime![dim > 0] { + let index_before = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input.clone(), + comptime![Some((0, dim))], + config, + ); + index += index_before; + } + + if comptime![dim + 1 < config.rank] { + let index_after = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input, + comptime![Some((dim + 1, config.rank))], + config, + ); + index += index_after; + } + + let index_offset = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + indices, + comptime![Some((0, config.rank))], + config, + ); + + // TODO: new IR to differentiate between Gather and GatherBroadcasted at comptime? + let stride_indices_vector = global_stride(inputs, config.rank - 1, pos_indices); + + if comptime![dim == config.rank - 1] { + // Per-element indexing (along the dimension) + #[unroll] + for i in 0..vector_size { + let offset = read_input::>( + inputs, + &*locals, + pos_indices, + index_offset + i * stride_indices_vector, + LayoutInfo::IsRef, + config, + None, + ); + + let input = read_input::>( + inputs, + &*locals, + pos_input, + index + (offset.extract(0) as usize * stride_input_dim), + LayoutInfo::IsRef, + config, + None, + ); + result.insert(i, input.extract(0)); + } + } else { + let stride_input_vector = global_stride(inputs, config.rank - 1, pos_input); + + #[unroll] + for i in 0..vector_size { + let offset = read_input::>( + inputs, + &*locals, + pos_indices, + index_offset + i * stride_indices_vector, + LayoutInfo::IsRef, + config, + None, + ); + + let current_index = + index + (offset.extract(0) as usize * stride_input_dim) + (i * stride_input_vector); + + let input = read_input::>( + inputs, + &*locals, + pos_input, + current_index, + LayoutInfo::IsRef, + config, + None, + ); + + result.insert(i, input.extract(0)); + } + } + + write::(inputs, outputs, locals, write_pos, result, output, config); +} + +#[cube] +fn select_indices( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] dim: usize, + #[comptime] input: FuseArg, + #[comptime] indices: FuseArg, + #[comptime] output: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + let (vector_size_ref, stride_dim_ref, shape_dim_ref) = ( + locals.ref_vector_size, + locals.ref_strides[dim], + locals.ref_shape[dim], + ); + + let pos_input = comptime! { + match input { + FuseArg::Input(pos, ..) => pos, + _ => panic!("Input tensor isn't an input"), + } + }; + let pos_indices = match indices { + FuseArg::Input(pos, ..) => pos, + _ => panic!("Indices tensor isn't an input"), + }; + + let stride_input_dim = global_stride(inputs, dim, pos_input); + + let mut index = 0; + let mut result = Vector::empty(); + + if comptime![dim != config.rank - 1] { + // In this scenario the select is actually broadcasted along the axis we're working on. + // + // Therefore the same indices are used to fetch multiple entries in the input tensor. + + if comptime![dim > 0] { + let index_before = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input.clone(), + comptime![Some((0, dim))], + config, + ); + index += index_before; + } + + if comptime![dim + 1 < config.rank] { + let index_after = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input.clone(), + comptime![Some((dim + 1, config.rank))], + config, + ); + index += index_after; + } + + let stride_input_vector = global_stride(inputs, comptime![config.rank - 1], pos_input); + let write_pos_input = write_pos * vector_size_ref; + let coordinate_dim = write_pos_input / stride_dim_ref % shape_dim_ref; + let offset_dim = read_input::>( + inputs, + &*locals, + pos_indices, + coordinate_dim, + LayoutInfo::IsRef, + config, + None, + ); + + index += offset_dim.extract(0) as usize * stride_input_dim; + + #[unroll] + for i in 0..vector_size_ref { + let input = read_input::>( + inputs, + &*locals, + pos_input, + index + i * stride_input_vector, + LayoutInfo::IsRef, + config, + None, + ); + result.insert(i, input.extract(0)); + } + } else { + // In this scenario the select is actually performed on the last dimension we're working on. + // + // Therefore we need to fetch multiple indices that correspond to different entries in the + // input tensor. + + if comptime![dim > 0] { + let index_before = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input.clone(), + comptime![Some((0, dim))], + config, + ); + index += index_before; + } + + if comptime![dim + 1 < config.rank] { + let index_after = global_offset( + inputs, + &*outputs, + &*locals, + write_pos, + input, + comptime![Some((dim + 1, config.rank))], + config, + ); + index += index_after; + } + + let write_pos_indices = write_pos * vector_size_ref; + + #[unroll] + for i in 0..vector_size_ref { + let coordinate_dim = (write_pos_indices + i) / stride_dim_ref % shape_dim_ref; + let offset_dim = read_input::>( + inputs, + &*locals, + pos_indices, + coordinate_dim, + LayoutInfo::IsRef, + config, + None, + ); + + let input = read_input::>( + inputs, + &*locals, + pos_input, + index + (offset_dim.extract(0) as usize * stride_input_dim), + LayoutInfo::IsRef, + config, + None, + ); + result.insert(i, input.extract(0)); + } + } + + write::(inputs, outputs, locals, write_pos, result, output, config); +} + +#[cube] +fn concat( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] dim: usize, + #[comptime] tensors: Vec, + #[comptime] output: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + let (vector_size_ref, stride_dim_ref, shape_dim_ref) = ( + locals.ref_vector_size, + locals.ref_strides[dim], + locals.ref_shape[dim], + ); + + let mut result = Vector::::empty(); + let write_pos_elem = write_pos * vector_size_ref; + + if comptime![dim != config.rank - 1] { + // The concatenation axis isn't the vectorization axis, therefore the whole vector is + // contained in a single input tensor. + let coordinate_dim = write_pos_elem / stride_dim_ref % shape_dim_ref; + + let mut offset_dim_start = 0; + + #[unroll] + for t in 0..tensors.len() { + let tensor = comptime![tensors.get(t).unwrap().clone()]; + let pos_tensor = comptime![concat_input_pos(&tensor)]; + + let shape_tensor_dim = global_shape(inputs, dim, pos_tensor); + let offset_dim_end = offset_dim_start + shape_tensor_dim; + + if coordinate_dim >= offset_dim_start && coordinate_dim < offset_dim_end { + let index = concat_input_offset( + inputs, + &*outputs, + &*locals, + write_pos, + coordinate_dim - offset_dim_start, + tensor, + pos_tensor, + dim, + config, + ); + + let stride_tensor_vector = + global_stride(inputs, comptime![config.rank - 1], pos_tensor); + + #[unroll] + for i in 0..vector_size_ref { + let input = read_input::>( + inputs, + &*locals, + pos_tensor, + index + i * stride_tensor_vector, + LayoutInfo::IsRef, + config, + None, + ); + result.insert(i, input.extract(0)); + } + } + + offset_dim_start = offset_dim_end; + } + } else { + // The concatenation happens along the vectorization axis, therefore each element of the + // vector may come from a different input tensor. + #[unroll] + for i in 0..vector_size_ref { + let coordinate_dim = (write_pos_elem + i) / stride_dim_ref % shape_dim_ref; + let mut offset_dim_start = 0; + + #[unroll] + for t in 0..tensors.len() { + let tensor = comptime![tensors.get(t).unwrap().clone()]; + let pos_tensor = comptime![concat_input_pos(&tensor)]; + + let shape_tensor_dim = global_shape(inputs, dim, pos_tensor); + let offset_dim_end = offset_dim_start + shape_tensor_dim; + + if coordinate_dim >= offset_dim_start && coordinate_dim < offset_dim_end { + let index = concat_input_offset( + inputs, + &*outputs, + &*locals, + write_pos, + coordinate_dim - offset_dim_start, + tensor, + pos_tensor, + dim, + config, + ); + + let input = read_input::>( + inputs, + &*locals, + pos_tensor, + index, + LayoutInfo::IsRef, + config, + None, + ); + result.insert(i, input.extract(0)); + } + + offset_dim_start = offset_dim_end; + } + } + } + + write::(inputs, outputs, locals, write_pos, result, output, config); +} + +fn concat_input_pos(arg: &FuseArg) -> usize { + match arg { + FuseArg::Input(pos, ..) => *pos, + _ => panic!("Cat input isn't a global input"), + } +} + +#[cube] +/// Element index inside a cat input for the vector at `write_pos`, where +/// `coordinate_dim_tensor` is the coordinate along the concat axis relative to the start of +/// this input's segment. +fn concat_input_offset( + inputs: &GlobalArgs, + outputs: &GlobalArgs, + locals: &LocalArgs, + write_pos: usize, + coordinate_dim_tensor: usize, + #[comptime] tensor: FuseArg, + #[comptime] pos_tensor: usize, + #[comptime] dim: usize, + #[comptime] config: &FuseBlockConfig, +) -> usize { + let stride_tensor_dim = global_stride(inputs, dim, pos_tensor); + let mut index = coordinate_dim_tensor * stride_tensor_dim; + + if comptime![dim > 0] { + let index_before = global_offset( + inputs, + outputs, + locals, + write_pos, + tensor.clone(), + comptime![Some((0, dim))], + config, + ); + index += index_before; + } + + if comptime![dim + 1 < config.rank] { + let index_after = global_offset( + inputs, + outputs, + locals, + write_pos, + tensor, + comptime![Some((dim + 1, config.rank))], + config, + ); + index += index_after; + } + + index +} + +#[cube] +fn conditional_assign( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] cond: FuseArg, + #[comptime] lhs: FuseArg, + #[comptime] rhs: FuseArg, + #[comptime] out: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + let cond = read::(inputs, &*outputs, &*locals, write_pos, cond, config); + let lhs = read::(inputs, &*outputs, &*locals, write_pos, lhs, config); + let rhs = read::(inputs, &*outputs, &*locals, write_pos, rhs, config); + let result = select_many(cond, lhs, rhs); + + write::(inputs, outputs, locals, write_pos, result, out, config); +} + +#[cube] +fn clamp( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] input: FuseArg, + #[comptime] min: FuseArg, + #[comptime] max: FuseArg, + #[comptime] out: FuseArg, + #[comptime] config: &FuseBlockConfig, +) { + let input = read::(inputs, &*outputs, &*locals, write_pos, input, config); + let min = read::(inputs, &*outputs, &*locals, write_pos, min, config); + let max = read::(inputs, &*outputs, &*locals, write_pos, max, config); + let result = cubecl::prelude::clamp(input, min, max); + + write::(inputs, outputs, locals, write_pos, result, out, config); +} + +#[cube] +#[allow(clippy::explicit_counter_loop)] +fn dequantize( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + write_pos: usize, + #[comptime] input: FuseArg, + #[comptime] scales: FuseArg, + #[comptime] output: FuseArg, + #[comptime] scheme: QuantScheme, + #[comptime] config: &FuseBlockConfig, +) { + comptime!(assert_eq!( + scheme.mode, + QuantMode::Symmetric, + "Only symmetric quantization mode is supported." + )); + + let quant_ty = comptime![match scheme.store { + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S => StorageType::Scalar(ElemType::UInt(UIntKind::U8)), + QuantValue::E4M3 => StorageType::Scalar(ElemType::Float(FloatKind::E4M3)), + QuantValue::E5M2 => StorageType::Scalar(ElemType::Float(FloatKind::E5M2)), + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 => unreachable!("Can't store native sub-byte values"), + }, + QuantStore::PackedU32(_) => ElemType::UInt(UIntKind::U32).into(), + QuantStore::PackedNative(_) => match scheme.value { + QuantValue::E2M1 => StorageType::Packed(ElemType::Float(FloatKind::E4M3), 2), + other => panic!("{other:?} doesn't support native packing"), + }, + }]; + let param_ty = comptime![match scheme.param { + cubecl::quant::scheme::QuantParam::F32 => + StorageType::Scalar(ElemType::Float(FloatKind::F32)), + cubecl::quant::scheme::QuantParam::F16 => + StorageType::Scalar(ElemType::Float(FloatKind::F16)), + cubecl::quant::scheme::QuantParam::BF16 => + StorageType::Scalar(ElemType::Float(FloatKind::BF16)), + cubecl::quant::scheme::QuantParam::UE8M0 => + StorageType::Scalar(ElemType::Float(FloatKind::UE8M0)), + cubecl::quant::scheme::QuantParam::UE4M3 => + StorageType::Scalar(ElemType::Float(FloatKind::E4M3)), + }]; + let q_vector_size = N::value().comptime() / scheme.num_quants(); + + let define!(QStoreType) = quant_ty; + let size!(QStoreSize) = q_vector_size; + let define!(QParamType) = param_ty; + let size!(NumQuant) = scheme.num_quants(); + + let tensor_pos = comptime!(match input { + FuseArg::Input(pos, _, _) => pos, + _ => panic!("Not supported"), + }); + let pos = comptime!(match scales { + FuseArg::Input(pos, ..) => pos, + _ => unreachable!(""), + }); + + let num_quants = scheme.num_quants(); + + set_polyfill_typed::, DynElem, DynSize>(); + let input = read_quantized::( + inputs, &*locals, write_pos, input, config, scheme, + ); + + let scales = + input_as_scales_view::>(inputs, pos, tensor_pos, scheme.level, config); + + let result = dequantize_symmetric_packed_value_at::< + C, + NumQuant, + QParamType, + QStoreType, + QStoreSize, + >(write_pos * num_quants, input, &scales, scheme); + + let mut vector = Vector::empty(); + + #[unroll] + for i in 0..q_vector_size { + let value = result[i]; + + #[unroll] + for j in 0..num_quants { + let index = i * num_quants + j; + vector.insert(index, value.extract(j)); + } + } + + write::(inputs, outputs, locals, write_pos, vector, output, config); +} + +binary_op!(add, +); +binary_op!(mul, *); +binary_op!(div, /); +binary_op!(sub, -); + +binary_int_op!(bitwise_and, &); +binary_int_op!(bitwise_or, |); +binary_int_op!(bitwise_xor, ^); +binary_int_op!(bitwise_left_shift, <<); +binary_int_op!(bitwise_right_shift, >>); +unary_int_op!(bitwise_not, !); + +comparison_op!(equal, ==); +comparison_op!(greater, >); +comparison_op!(greater_equal, >=); +comparison_op!(lower, <); +comparison_op!(lower_equal, <=); + +binary_func!(powf, Vector::::powf, Float); +binary_func!(rem, Vector::::mod_floor, Float); + +unary_func!(exp, Vector::::exp, Float); +unary_func!(log, Vector::::ln, Float); +unary_func!(log1p, Vector::::log1p, Float); +unary_func!(sqrt, Vector::::sqrt, Float); +unary_func!(cos, Vector::::cos, Float); +unary_func!(sin, Vector::::sin, Float); +unary_func!(tanh, Vector::::tanh, Float); +unary_func!(erf, Vector::::erf, Float); +unary_func!(recip, Vector::::recip, Float); +unary_func!(abs, Vector::::abs, Numeric); diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/mod.rs b/crates/burn-cubecl-fusion/src/engine/codegen/mod.rs new file mode 100644 index 0000000..00951a5 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod io; +pub(crate) mod ir; +pub(crate) mod kernel; +pub(crate) mod tensor; +pub(crate) mod view; + +mod base; +pub(crate) use base::*; diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/tensor.rs b/crates/burn-cubecl-fusion/src/engine/codegen/tensor.rs new file mode 100644 index 0000000..c013fad --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/tensor.rs @@ -0,0 +1,68 @@ +use crate::engine::codegen::{DynElem, DynSize, DynVector}; + +use cubecl::{ir::Type, prelude::*}; +use std::hash::Hash; + +/// Represents a global tensor with the given [element type](ElemType). +/// +/// # Warning +/// +/// The `tensor` field type [Vector>] must be set using polyfill before +/// use. +#[derive(CubeType, Clone)] +#[expand(derive(Clone))] +pub struct GlobalTensor { + /// The global tensor type. + pub tensor: OwnedTensor, + /// The element type of the tensor. + #[cube(comptime)] + pub ty: Type, + /// Whether the current tensor is logically broadcasted. + #[cube(comptime)] + pub broadcasted: bool, +} + +// Everything below is to implement [LaunchArg]. + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct GlobalTensorCompilationArg { + tensor: TensorCompilationArg, + ty: Type, + broadcasted: bool, +} + +#[derive(new, Debug)] +pub struct GlobalTensorArg { + pub tensor: as LaunchArg>::RuntimeArg, + pub ty: Type, + pub broadcasted: bool, + pub address_type: AddressType, +} + +impl LaunchArg for GlobalTensor { + type RuntimeArg = GlobalTensorArg; + type CompilationArg = GlobalTensorCompilationArg; + + fn register( + arg: Self::RuntimeArg, + launcher: &mut KernelLauncher, + ) -> Self::CompilationArg { + launcher.with_scope(|scope| set_polyfill::expand::(scope, arg.ty)); + let tensor = OwnedTensor::::register(arg.tensor, launcher); + GlobalTensorCompilationArg { + tensor, + ty: arg.ty, + broadcasted: arg.broadcasted, + } + } + + fn expand(arg: &Self::CompilationArg, builder: &mut KernelBuilder) -> GlobalTensorExpand { + set_polyfill::expand::(&builder.scope, arg.ty); + let tensor = OwnedTensor::expand(&arg.tensor, builder); + GlobalTensorExpand { + tensor, + ty: arg.ty, + broadcasted: arg.broadcasted, + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/codegen/view.rs b/crates/burn-cubecl-fusion/src/engine/codegen/view.rs new file mode 100644 index 0000000..41b85f0 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/codegen/view.rs @@ -0,0 +1,358 @@ +use crate::engine::codegen::{DynElem, DynSize, io::set_polyfill_typed}; + +use super::{ + io::{ + Transform, global_buffer_len, global_vector_size, input_as_slice, read_input, + read_input_window, ref_buffer_len, ref_len, + }, + ir::{FuseArg, FuseBlockConfig, GlobalArgs, LayoutInfo, LocalArgs}, + kernel::fuse_on_write, +}; +use cubecl::{ + CubeType, + io::read_masked, + ir::StorageType, + prelude::{barrier::BarrierExpand, *}, + std::tensor::{ + ViewOperations, ViewOperationsExpand, ViewOperationsMut, ViewOperationsMutExpand, + layout::Coords1d, + }, +}; + +#[allow(dead_code, reason = "only used in expand")] +#[derive(CubeType)] +pub struct GlobalInput { + inputs: GlobalArgs, + locals: LocalArgs, + #[cube(comptime)] + pos: usize, + #[cube(comptime)] + ty: StorageType, + #[cube(comptime)] + layout: LayoutInfo, + #[cube(comptime)] + config: FuseBlockConfig, + #[cube(comptime)] + transform: Option, +} + +#[cube] +impl GlobalInput { + pub fn new( + inputs: &GlobalArgs, + locals: &LocalArgs, + #[comptime] arg: FuseArg, + #[comptime] config: FuseBlockConfig, + #[comptime] transform: Option, + ) -> GlobalInput { + let (pos, ty, layout) = comptime![match arg { + FuseArg::Input(pos, prec, layout) => (pos, prec.into_storage_type(), layout), + _ => unreachable!("Must be concrete input"), + }]; + + GlobalInput { + inputs: inputs.clone(), + locals: locals.clone(), + pos, + ty, + layout, + config, + transform, + } + } +} + +impl ViewOperations for GlobalInput {} +impl ViewOperationsExpand for GlobalInputExpand { + #[allow(clippy::too_many_arguments)] + fn __expand_read_method( + &self, + scope: &Scope, + pos: NativeExpand, + ) -> ::ExpandType { + ViewOperationsExpand::::__expand_read_unchecked_method(self, scope, pos) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_checked_method( + &self, + scope: &Scope, + pos: NativeExpand, + ) -> ::ExpandType { + let zero = E::__expand_cast_from(scope, 0.into()); + ViewOperationsExpand::::__expand_read_masked_method(self, scope, pos, zero) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_masked_method( + &self, + scope: &Scope, + pos: NativeExpand, + value: ::ExpandType, + ) -> ::ExpandType { + let in_bounds = + ViewOperationsExpand::::__expand_is_in_bounds_method(self, scope, pos); + set_polyfill_typed::expand::(scope); + let slice = input_as_slice::expand(scope, &self.inputs, self.pos); + read_masked::expand::(scope, in_bounds, slice, pos, value) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_unchecked_method( + &self, + scope: &Scope, + pos: NativeExpand, + ) -> ::ExpandType { + set_polyfill_typed::expand::(scope); + let value = read_input::expand::( + scope, + &self.inputs, + &self.locals, + self.pos, + pos, + self.layout, + &self.config, + self.transform.clone(), + ); + E::__expand_cast_from(scope, value) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_as_linear_slice_method( + &self, + scope: &Scope, + pos: NativeExpand, + end: NativeExpand, + ) -> &SliceExpand { + set_polyfill_typed::expand::(scope); + let end = end.__expand_add_method(scope, 1usize.into_expand(scope)); + read_input_window::expand(scope, &self.inputs, self.pos, pos, end) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_tensor_map_load_method( + &self, + _scope: &Scope, + _barrier: &BarrierExpand, + _shared_memory: &mut SliceExpand, + _pos: NativeExpand, + ) { + panic!("Not a tensor map") + } + + #[allow(clippy::too_many_arguments)] + fn __expand_shape_method(&self, scope: &Scope) -> NativeExpand { + global_buffer_len::expand(scope, &self.inputs, self.pos) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_is_in_bounds_method( + &self, + scope: &Scope, + pos: NativeExpand, + ) -> NativeExpand { + let buffer_len = global_buffer_len::expand(scope, &self.inputs, self.pos); + pos.__expand_lt_method(scope, &buffer_len) + } +} + +impl Vectorized for GlobalInput {} +impl VectorizedExpand for GlobalInputExpand { + fn vector_size(&self) -> VectorSize { + let temp_scope = Scope::root(false); + global_vector_size::expand(&temp_scope, &self.inputs, self.pos) + } +} + +#[allow(dead_code, reason = "only used in expand")] +#[derive(CubeType)] +pub struct FusedOutput { + inputs: GlobalArgs, + outputs: GlobalArgs, + locals: LocalArgs, + arg: FuseArg, + #[cube(comptime)] + config: FuseBlockConfig, +} + +#[cube] +impl FusedOutput { + pub fn new( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + arg: FuseArg, + #[comptime] config: FuseBlockConfig, + ) -> Self { + FusedOutput { + inputs: inputs.clone(), + outputs: outputs.clone(), + locals: locals.clone(), + arg, + config, + } + } +} + +impl ViewOperations for FusedOutput {} +impl ViewOperationsExpand for FusedOutputExpand { + #[allow(clippy::too_many_arguments)] + fn __expand_read_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + ) -> ::ExpandType { + todo!() + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_checked_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + ) -> ::ExpandType { + todo!() + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_masked_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + _value: ::ExpandType, + ) -> ::ExpandType { + todo!() + } + + #[allow(clippy::too_many_arguments)] + fn __expand_read_unchecked_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + ) -> ::ExpandType { + todo!() + } + + #[allow(clippy::too_many_arguments)] + fn __expand_as_linear_slice_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + _size: NativeExpand, + ) -> &SliceExpand { + todo!() + } + + #[allow(clippy::too_many_arguments)] + fn __expand_tensor_map_load_method( + &self, + _scope: &Scope, + _barrier: &BarrierExpand, + _shared_memory: &mut SliceExpand, + _pos: NativeExpand, + ) { + panic!("Not a tensor map") + } + + #[allow(clippy::too_many_arguments)] + fn __expand_shape_method(&self, scope: &Scope) -> NativeExpand { + ref_len::expand( + scope, + &self.inputs, + &self.outputs, + &self.locals, + &self.config, + ) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_is_in_bounds_method( + &self, + scope: &Scope, + pos: NativeExpand, + ) -> NativeExpand { + let buffer_len = ref_buffer_len::expand( + scope, + &self.inputs, + &self.outputs, + &self.locals, + &self.config, + ); + pos.__expand_lt_method(scope, &buffer_len) + } +} + +impl ViewOperationsMut for FusedOutput {} +impl ViewOperationsMutExpand for FusedOutputExpand { + #[allow(clippy::too_many_arguments)] + fn __expand_write_method( + &self, + scope: &Scope, + pos: NativeExpand, + value: ::ExpandType, + ) { + let values = Registry::>::__expand_new(scope); + let mut args = comptime![Vec::::new()]; + + let value = Vector::__expand_cast_from(scope, value); + values + .clone() + .__expand_insert_method(scope, comptime![self.arg.clone()], value); + comptime![args.push(self.arg.clone())]; + + let mut outputs = self.outputs.clone(); + let mut locals = self.locals.clone(); + + fuse_on_write::expand( + scope, + &self.inputs, + &mut outputs, + &mut locals, + pos, + values, + args, + &self.config, + ); + } + + #[allow(clippy::too_many_arguments)] + fn __expand_write_checked_method( + &self, + scope: &Scope, + pos: NativeExpand, + value: ::ExpandType, + ) { + let in_bounds = + ViewOperationsExpand::::__expand_is_in_bounds_method(self, scope, pos); + if_expand(scope, in_bounds, |scope| { + ViewOperationsMutExpand::::__expand_write_method(self, scope, pos, value); + }) + } + + #[allow(clippy::too_many_arguments)] + fn __expand_as_linear_slice_mut_method( + &self, + _scope: &Scope, + _pos: NativeExpand, + _size: NativeExpand, + ) -> &mut SliceExpand { + todo!("Not yet supported") + } + + #[allow(clippy::too_many_arguments)] + fn __expand_tensor_map_store_method( + &self, + _scope: &Scope, + _shared_memory: &SliceExpand, + _pos: NativeExpand, + ) { + panic!("Not a tensor map") + } +} + +impl Vectorized for FusedOutput {} +impl VectorizedExpand for FusedOutputExpand { + fn vector_size(&self) -> VectorSize { + self.locals.ref_vector_size + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/fuser.rs b/crates/burn-cubecl-fusion/src/engine/fuser.rs new file mode 100644 index 0000000..9b3dee4 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/fuser.rs @@ -0,0 +1,884 @@ +use super::{ + codegen::ir::{BinaryFuseArgs, FuseArg, FuseOp, UnaryFuseArgs}, + settings::FuseSettings, + trace::{FuseTrace, TraceFuser, block::QuantInput}, +}; +use crate::engine::{codegen::ir::QuantSchemeFuse, scoring::Scoring}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_fusion::{FuserProperties, FuserStatus, OperationFuser}; +use burn_ir::{ + BaseOperationIr, BinaryOpIr, FloatOperationIr, IntOperationIr, NumericOperationIr, OperationIr, + ScalarOpIr, TensorIr, UnaryOpIr, +}; +use burn_std::{ + DType, Shape, + config::{fusion::FusionLogLevel, log_fusion}, +}; +use cubecl::ir::ElemType; + +/// The base operation fuser that can be used to fuse [all supported fuse operations](FuseOp). +/// +/// +/// This fuser doesn't create a ready-to-execute kernel, but rather generates a +/// [trace](FuseTrace) that be used with a [runner](super::trace::TraceRunner). +/// +/// Since this fuser supports fusing multiple blocks, you can fuse any compute-bound operations +/// with the combination of fuse-on-read and fuse-on-write strategy. +/// +/// # Notes +/// +/// It is responsible to translate [OperationIr] into [FuseOp] and it uses the [TraceFuser] +/// to actually fuse the [FuseOp] when possible. +#[derive(Debug, Clone)] +pub(crate) struct TraceOperationFuser { + pub(crate) fuser: TryTraceFuser, + scoring: Scoring, + pub(crate) settings: FuseSettings, + pub(crate) current_output_shape: Shape, + status: FuserStatus, + pub(crate) num_ops: usize, + pub(crate) num_views: usize, + pub(crate) max_bindings: u32, +} + +impl TraceOperationFuser { + /// Emits a `Full`-level fusion log explaining why the fuser transitioned to `Closed`. + /// `prev_num_ops` is the number of ops already fused before the rejected op. + fn log_closed(&self, op: &OperationIr, prev_num_ops: usize, reason: &'static str) { + let max = self.max_bindings; + log_fusion(FusionLogLevel::Full, || { + // Debug-format the op and estimate bindings lazily: both are expensive + // enough to skip when logging is off. + let op_dbg = format!("{op:?}"); + let op_short = op_dbg + .split_once('(') + .map(|(head, _)| head) + .unwrap_or(&op_dbg); + let est = self.fuser.fuser.estimate_bindings(); + format!( + "[fuser] closed on {op_short} ({reason}); had {prev_num_ops} ops, est_bindings={est}/{max}" + ) + }); + } + + /// Checks if the [operation](OperationIr) can be fused with the current fuser. + pub(crate) fn can_fuse(&self, op: &OperationIr) -> bool { + let len_previous = self.len(); + let mut fuser_cloned = self.clone(); + + fuser_cloned.fuse(op); + let len_after = fuser_cloned.len(); + + len_after > len_previous + } +} + +impl OperationFuser for TraceOperationFuser { + fn fuse(&mut self, op: &OperationIr) { + if let FuserStatus::Closed = self.status { + return; + } + + // Capture state before the fuse attempt so we can log a useful reason + // if the fuser closes on this op. + let prev_num_ops = self.num_ops; + + match op { + OperationIr::Drop(tensor) => { + if self.num_ops == 0 { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "drop on empty fuser"); + return; + } + + self.fuser.fuser.fuse_dropped(tensor); + } + OperationIr::BaseFloat(ops) => { + if !self.fuse_base(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "base fuse rejected"); + return; + } + } + OperationIr::BaseInt(ops) => { + if !self.fuse_base(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "base fuse rejected"); + return; + } + } + OperationIr::Float(_dtype, ops) => { + if !self.fuse_float(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "float fuse rejected"); + return; + } + } + OperationIr::Int(ops) => { + if !self.fuse_int(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "int fuse rejected"); + return; + } + } + OperationIr::NumericFloat(_dtype, ops) => { + if !self.fuse_numeric(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "numeric fuse rejected"); + return; + } + } + OperationIr::NumericInt(_dtype, ops) => { + if !self.fuse_numeric(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "numeric fuse rejected"); + return; + } + } + OperationIr::BaseBool(ops) => { + if !self.fuse_base(ops) { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "base fuse rejected"); + return; + } + } + _ => { + self.status = FuserStatus::Closed; + self.log_closed(op, prev_num_ops, "unsupported op variant"); + return; + } + }; + + self.status = FuserStatus::Open; + self.scoring.register(op); + self.num_ops += 1; + } + + fn finish(&mut self) -> FuseTrace { + self.fuser.finish(self.current_output_shape.clone()) + } + + fn len(&self) -> usize { + self.num_ops + } + + fn reset(&mut self) { + self.num_ops = 0; + self.scoring.reset(); + self.num_views = 0; + self.status = FuserStatus::Open; + self.fuser = TryTraceFuser::new(self.max_bindings, self.settings); + self.current_output_shape = Shape::new([]); + } + + fn status(&self) -> FuserStatus { + self.status + } + + fn properties(&self) -> FuserProperties { + let ready = self.num_ops > 0; + let score = self + .scoring + .evaluate(&self.fuser.clone().finish(self.current_output_shape.clone())); + + FuserProperties { ready, score } + } + + fn clone_dyn(&self) -> Box> { + Box::new(self.clone()) + } +} + +impl TraceOperationFuser { + /// Creates a new fuser. + pub fn new(max_bindings: u32, settings: FuseSettings) -> Self { + Self { + fuser: TryTraceFuser::new(max_bindings, settings), + settings, + scoring: Scoring::default(), + num_ops: 0, + num_views: 0, + max_bindings, + current_output_shape: Shape::new([]), + status: FuserStatus::Open, + } + } + + /// Closes the fuser. + pub fn close(&mut self) { + self.status = FuserStatus::Closed; + } + + /// Declares an input tensor argument where the kernel is responsible to load. + /// + /// # Returns + /// + /// - The argument that maps to the tensor to be used during kernel expansion. + pub fn input_unhandled(&mut self, tensor: &TensorIr) -> FuseArg { + self.fuser.fuser.input_unhandled(tensor) + } + + /// Declares an input quantized tensor argument where the kernel is responsible to load. + /// + /// # Returns + /// + /// None if it's not possible to fuse a quantized tensor. Otherwise: + /// + /// - The argument that maps to the tensor values to be used during kernel expansion. + /// - The argument that maps to the tensor params to be used during kernel expansion. + pub fn input_quantized_unhandled(&mut self, tensor: &TensorIr) -> Option<(FuseArg, FuseArg)> { + self.fuser.fuser.input_quantized_unhandled(tensor) + } + + /// Declares an output tensor argument where the kernel is responsible to write values. + /// + /// # Notes + /// + /// Normally you don't have to declare outputs explicitly before they are going to be + /// fused based on the operations [fused](Self::fuse). + /// + /// # Returns + /// + /// - The argument that maps to the tensor to be used during kernel expansion. + pub fn output_unhandled(&mut self, tensor: &TensorIr) -> FuseArg { + if self.current_output_shape.is_empty() { + self.current_output_shape = tensor.shape.clone(); + } else if self.current_output_shape.iter().sum::() < tensor.shape.iter().sum() { + // The larguest shape win. + self.current_output_shape = tensor.shape.clone(); + } + + self.fuser.fuser.output_unhandled(tensor) + } + + /// Closes the previous block and declares a new one. + /// + /// # Arguments + /// + /// - arguments: Tensors that are logical outputs of the current block and inputs of the following blocks. + /// - settings: [FuseSettings] to be used by the next block. + /// + /// # Returns + /// + /// None if it's impossible to create a next block with the given arguments. Otherwise, the + /// corresponding [arguments](Arg) to the given tensors are returned. + pub fn next_block( + &mut self, + arguments: [&TensorIr; N], + settings: FuseSettings, + global: bool, + ) -> [FuseArg; N] { + let block_pos = self.fuser.fuser.num_previous_blocks(); + let current_output_shape = + core::mem::replace(&mut self.current_output_shape, Shape::new([])); + + self.fuser.fuser.next_block(current_output_shape, settings); + + self.settings = settings; + self.status = FuserStatus::Open; + + arguments.map(|arg| self.fuser.fuser.block_local_input(arg, block_pos, global)) + } + + /// Tag the [tensor](TensorIr) as received from a previous block. + /// + /// This will avoid reading the input again and instead use le local version when possible. + pub fn block_local_input(&mut self, tensor: &TensorIr, block_pos: usize, global: bool) { + self.fuser + .fuser + .block_local_input(tensor, block_pos, global); + } + + fn fuse_base(&mut self, ops: &BaseOperationIr) -> bool { + match ops { + BaseOperationIr::Equal(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Equal(BinaryFuseArgs { lhs, rhs, out }) + }), + BaseOperationIr::EqualElem(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Equal(BinaryFuseArgs { lhs, rhs, out }) + }), + BaseOperationIr::Cast(desc) => { + self.fuse_unary_op(&desc.input, &desc.out, |input, out| { + FuseOp::Assign(UnaryFuseArgs { input, out }) + }) + } + BaseOperationIr::SwapDims(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + if self.fuser.fuse(|fuser| { + fuser.input_swap_dims(&desc.input, &desc.out, (desc.dim1, desc.dim2))?; + + Some(()) + }) { + self.num_views += 1; + true + } else { + false + } + } + BaseOperationIr::Reshape(desc) => { + if desc.input.shape == desc.out.shape { + return self.fuse_unary_op(&desc.input, &desc.out, |input, out| { + FuseOp::Assign(UnaryFuseArgs { input, out }) + }); + } + + if desc.input.shape.rank() > desc.out.shape.rank() { + // Not yet supported. + return false; + } + + if !self.output_is_compatible(&desc.out) { + return false; + } + + if self.fuser.fuse(|fuser| { + fuser.input_reshaped(&desc.input, &desc.out)?; + Some(()) + }) { + self.num_views += 1; + true + } else { + false + } + } + BaseOperationIr::Ones(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + let elem: ElemType = dtype_to_elem_type(desc.out.dtype); + let precision = elem.into(); + let input = FuseArg::Literal(1, precision); + + self.fuser.fuse(|fuser| { + let out = fuser.output(&desc.out)?; + + fuser.fuse_operation(FuseOp::Assign(UnaryFuseArgs { input, out })); + + Some(()) + }) + } + BaseOperationIr::Zeros(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + let elem: ElemType = dtype_to_elem_type(desc.out.dtype); + let precision = elem.into(); + let input = FuseArg::Literal(0, precision); + + self.fuser.fuse(|fuser| { + let out = fuser.output(&desc.out)?; + + fuser.fuse_operation(FuseOp::Assign(UnaryFuseArgs { input, out })); + + Some(()) + }) + } + BaseOperationIr::Gather(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let input = build.input_indexed(&desc.tensor)?; + let indices = build.input_indexed(&desc.indices)?; + let output = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::Gather { + input, + indices, + output, + dim: desc.dim, + }); + + Some(()) + }) + } + BaseOperationIr::Select(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let input = build.input_indexed(&desc.tensor)?; + let indices = build.input_indexed(&desc.indices)?; + let output = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::Select { + input, + indices, + output, + dim: desc.dim, + }); + + Some(()) + }) + } + BaseOperationIr::Cat(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let mut tensors = Vec::with_capacity(desc.tensors.len()); + + for tensor in desc.tensors.iter() { + tensors.push(build.input_indexed(tensor)?); + } + + let output = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::Cat { + inputs: tensors, + output, + dim: desc.dim, + }); + + Some(()) + }) + } + BaseOperationIr::MaskWhere(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let cond = build.input(&desc.mask)?; + let rhs = build.input(&desc.tensor)?; + let lhs = build.input(&desc.value)?; + let out = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::ConditionalAssign { + cond, + lhs, + rhs, + out, + }); + + Some(()) + }) + } + BaseOperationIr::MaskFill(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let cond = build.input(&desc.mask)?; + let lhs = build.scalar(&desc.value, desc.out.dtype); + let rhs = build.input(&desc.tensor)?; + let out = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::ConditionalAssign { + cond, + lhs, + rhs, + out, + }); + + Some(()) + }) + } + _ => false, + } + } + + fn fuse_float(&mut self, ops: &FloatOperationIr) -> bool { + match ops { + FloatOperationIr::Exp(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Exp(UnaryFuseArgs { input, out })) + } + FloatOperationIr::Log(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Log(UnaryFuseArgs { input, out })) + } + FloatOperationIr::Powf(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Powf(BinaryFuseArgs { lhs, rhs, out }) + }), + FloatOperationIr::Log1p(desc) => self.fuse_unary_ops(desc, |input, out| { + FuseOp::Log1p(UnaryFuseArgs { input, out }) + }), + FloatOperationIr::Cos(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Cos(UnaryFuseArgs { input, out })) + } + FloatOperationIr::Sin(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Sin(UnaryFuseArgs { input, out })) + } + FloatOperationIr::PowfScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Powf(BinaryFuseArgs { lhs, rhs, out }) + }), + FloatOperationIr::Tanh(desc) => self.fuse_unary_ops(desc, |input, out| { + FuseOp::Tanh(UnaryFuseArgs { input, out }) + }), + FloatOperationIr::Erf(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Erf(UnaryFuseArgs { input, out })) + } + FloatOperationIr::Sqrt(desc) => self.fuse_unary_ops(desc, |input, out| { + FuseOp::Sqrt(UnaryFuseArgs { input, out }) + }), + FloatOperationIr::Recip(desc) => self.fuse_unary_ops(desc, |input, out| { + FuseOp::Recip(UnaryFuseArgs { input, out }) + }), + FloatOperationIr::Dequantize(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let qinput = build.input_quantized(&desc.input)?; + let out = build.output(&desc.out)?; + + match qinput { + QuantInput::AlreadyDequantized { local } => { + build.fuse_operation(FuseOp::Assign(UnaryFuseArgs { + input: local, + out, + })); + } + QuantInput::Quantized { values, params } => { + build.fuse_operation(FuseOp::Dequantize { + values, + params, + output: out, + scheme: match desc.input.dtype { + DType::QFloat(scheme) => QuantSchemeFuse { scheme }, + _ => unreachable!("Should be a quant tensor."), + }, + }); + } + } + + Some(()) + }) + } + _ => false, + } + } + + fn fuse_int(&mut self, ops: &IntOperationIr) -> bool { + match ops { + IntOperationIr::IntoFloat(desc) => { + self.fuse_unary_op(&desc.input, &desc.out, |input, out| { + FuseOp::Assign(UnaryFuseArgs { input, out }) + }) + } + IntOperationIr::BitwiseAnd(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseAnd(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseAndScalar(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseAnd(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseOr(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseOr(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseOrScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseOr(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseXor(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseXor(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseXorScalar(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseXor(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseNot(desc) => self.fuse_unary_ops(desc, |input, out| { + FuseOp::BitwiseNot(UnaryFuseArgs { input, out }) + }), + IntOperationIr::BitwiseLeftShift(desc) => self + .fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseLeftShift(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseLeftShiftScalar(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseLeftShift(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseRightShift(desc) => self + .fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseRightShift(BinaryFuseArgs { lhs, rhs, out }) + }), + IntOperationIr::BitwiseRightShiftScalar(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::BitwiseRightShift(BinaryFuseArgs { lhs, rhs, out }) + }), + _ => false, + } + } + + fn fuse_numeric(&mut self, op: &NumericOperationIr) -> bool { + match op { + NumericOperationIr::Add(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Add(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::AddScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Add(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Sub(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Sub(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::SubScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Sub(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Mul(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Mul(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::MulScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Mul(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Div(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Div(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::DivScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Div(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Abs(desc) => { + self.fuse_unary_ops(desc, |input, out| FuseOp::Abs(UnaryFuseArgs { input, out })) + } + NumericOperationIr::Lower(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Lower(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::LowerElem(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Lower(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Greater(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Greater(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::GreaterElem(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Greater(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::LowerEqual(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::LowerEqual(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::LowerEqualElem(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::LowerEqual(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::GreaterEqual(desc) => self + .fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::GreaterEqual(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::GreaterEqualElem(desc) => self + .fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::GreaterEqual(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Full(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let input = build.scalar(&desc.value, desc.out.dtype); + let out = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::Assign(UnaryFuseArgs { input, out })); + + Some(()) + }) + } + NumericOperationIr::Rem(desc) => self.fuse_binary_ops(desc, |lhs, rhs, out| { + FuseOp::Rem(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::RemScalar(desc) => self.fuse_scalar_ops(desc, |lhs, rhs, out| { + FuseOp::Rem(BinaryFuseArgs { lhs, rhs, out }) + }), + NumericOperationIr::Clamp(desc) => { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let input = build.input(&desc.tensor)?; + let min = build.scalar(&desc.min, desc.out.dtype); + let max = build.scalar(&desc.max, desc.out.dtype); + let out = build.output(&desc.out)?; + + build.fuse_operation(FuseOp::Clamp { + input, + min, + max, + out, + }); + + Some(()) + }) + } + _ => false, + } + } + + fn fuse_binary_ops(&mut self, desc: &BinaryOpIr, func: Func) -> bool + where + Func: Fn(FuseArg, FuseArg, FuseArg) -> FuseOp, + { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let lhs = build.input(&desc.lhs)?; + let rhs = build.input(&desc.rhs)?; + let out = build.output(&desc.out)?; + + build.fuse_operation(func(lhs, rhs, out)); + + Some(()) + }) + } + + fn fuse_unary_ops(&mut self, desc: &UnaryOpIr, func: Func) -> bool + where + Func: Fn(FuseArg, FuseArg) -> FuseOp, + { + self.fuse_unary_op(&desc.input, &desc.out, func) + } + + fn fuse_unary_op(&mut self, input: &TensorIr, out: &TensorIr, func: Func) -> bool + where + Func: Fn(FuseArg, FuseArg) -> FuseOp, + { + if !self.output_is_compatible(out) { + return false; + } + + self.fuser.fuse(|build| { + let input = build.input(input)?; + let out = build.output(out)?; + build.fuse_operation(func(input, out)); + Some(()) + }) + } + + fn fuse_scalar_ops(&mut self, desc: &ScalarOpIr, func: Func) -> bool + where + Func: Fn(FuseArg, FuseArg, FuseArg) -> FuseOp, + { + if !self.output_is_compatible(&desc.out) { + return false; + } + + self.fuser.fuse(|build| { + let elem = desc.lhs.dtype; + let lhs = build.input(&desc.lhs)?; + let rhs = build.scalar(&desc.rhs, elem); + let out = build.output(&desc.out)?; + + build.fuse_operation(func(lhs, rhs, out)); + + Some(()) + }) + } + + fn output_is_compatible(&mut self, out: &TensorIr) -> bool { + if self.current_output_shape.is_empty() { + self.current_output_shape.clone_from(&out.shape); + return true; + } + + let rank = self.current_output_shape.len(); + + // Rank should be equal. + if rank != out.shape.num_dims() { + return false; + } + + let mut updated = self.current_output_shape.clone(); + let mut should_update = false; + + #[allow(clippy::needless_range_loop)] + for i in 0..rank { + let curr = self.current_output_shape[i]; + let new = out.shape[i]; + + if curr == new { + continue; + } + + // Broadcast not enabled. + if !self.settings.broadcast { + return false; + } + + // Broadcasted on new dim. + if new == 0 { + continue; + } + + // Broadcasted on curr dim - update reference output shape. + if curr == 0 && self.settings.output_shape_updates { + should_update = true; + updated[i] = new; + continue; + } + + return false; + } + + if should_update { + // For now forced to have exact shape. + if updated != out.shape { + return false; + } + + self.current_output_shape.clone_from_slice(&out.shape); + } + + true + } +} + +#[derive(Debug, Clone)] +/// Builder wrapper to limit the number of bindings in generated kernels. +pub(crate) struct TryTraceFuser { + pub(crate) fuser: TraceFuser, + max_bindings: u32, + max_ops: u32, + added_ops: bool, +} + +impl TryTraceFuser { + fn new(max_bindings: u32, settings: FuseSettings) -> Self { + Self { + fuser: TraceFuser::new(settings), + max_bindings, + // A good default, avoid errors with for loops over only memory + // bound operations. + max_ops: 64, + added_ops: false, + } + } + + fn fuse(&mut self, add_ops: impl FnOnce(&mut TraceFuser) -> Option<()>) -> bool { + if self.fuser.num_ops_fused() > self.max_ops { + return false; + } + + // Always allow the first operation to be added. + if !self.added_ops { + self.added_ops = true; + + if add_ops(&mut self.fuser).is_none() { + return false; + } + return true; + } + + let mut cloned = self.fuser.clone(); + if add_ops(&mut cloned).is_none() { + return false; + } + + if cloned.estimate_bindings() > self.max_bindings { + return false; + } + + self.fuser = cloned; + true + } + + fn finish(&mut self, shape: Shape) -> FuseTrace { + self.fuser.finish(shape) + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/base.rs b/crates/burn-cubecl-fusion/src/engine/launch/base.rs new file mode 100644 index 0000000..9e5aed8 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/base.rs @@ -0,0 +1,102 @@ +use crate::{ + CubeFusionHandle, + engine::{ + launch::{ + HandleInput, HandleOutput, LaunchPlan, executor::LaunchPlanExecutor, + input::InputPlanner, output::OutputPlanner, runner::TraceRunner, + vectorization::VectorizationPlanner, + }, + trace::{FuseTrace, TraceError, TuneOutput}, + }, +}; +use burn_fusion::stream::Context; +use cubecl::{Runtime, client::ComputeClient}; +use std::marker::PhantomData; + +/// The launcher is responsible to launch a fused kernel using the [TraceRunner] and a [FuseTrace]. +/// +/// TODO: We can reuse the same launcher between runs and avoid a lot of allocation, by simply +/// resetting the state. +pub struct FuseTraceLauncher<'a, R: Runtime, Runner: TraceRunner> { + trace: &'a FuseTrace, + runner: &'a Runner, + _runtime: PhantomData, +} + +impl<'a, R: Runtime, Runner: TraceRunner> FuseTraceLauncher<'a, R, Runner> { + /// Creates a new launcher. + pub fn new(trace: &'a FuseTrace, runner: &'a Runner) -> Self { + Self { + trace, + runner, + _runtime: PhantomData, + } + } + /// Launches the fuse kernel on the given device modifying the context. + pub fn launch( + &self, + client: &ComputeClient, + device: &R::Device, + context: &mut Context>, + ) -> Result, TraceError> { + let mut plan = LaunchPlan::new(&self.trace.blocks); + + InputPlanner::new(&self.trace.resources, &self.trace.blocks).run(context, &mut plan); + + OutputPlanner::new(&self.trace.resources, &self.trace.blocks) + .run(client, device, context, &mut plan); + + VectorizationPlanner::new(&self.trace.resources, &self.trace.blocks).run( + client, + self.runner, + context, + &mut plan, + ); + + match LaunchPlanExecutor::new(&self.trace.resources, &self.trace.blocks).execute::<_>( + client, + self.runner, + context, + plan, + ) { + Err(err) => { + self.rollback(context, err.handles_input, err.handles_output); + Err(err.error) + } + Ok(val) => Ok(val), + } + } + + fn rollback( + &self, + context: &mut Context>, + handle_inputs: Vec>, + handle_outputs: Vec>, + ) { + for input in handle_inputs { + match input { + HandleInput::Normal(input) => { + context + .handles + .register_handle(input.global_ir.id, input.handle_rollback()); + } + HandleInput::QuantValues(input) => { + context + .handles + .register_handle(input.global_ir.id, input.handle); + } + HandleInput::QuantParams(_) => { + // The scales are part of the quant data handle. + } + }; + } + for output in handle_outputs { + if let HandleOutput::Owned { + global_id, handle, .. + } = output + { + context.handles.register_handle(global_id, handle); + } + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/executor.rs b/crates/burn-cubecl-fusion/src/engine/launch/executor.rs new file mode 100644 index 0000000..40ebfe6 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/executor.rs @@ -0,0 +1,316 @@ +use super::{HandleInput, HandleOutput, LaunchPlan, ReferenceSelection}; +use crate::engine::launch::runner::TraceRunner; +use crate::engine::trace::{FuseResources, TensorView, TraceError, TuneOutput, block::FuseBlock}; +use crate::{ + CubeFusionHandle, + engine::{ + codegen::ir::{ + FuseBlockConfig, FuseOp, FuseType, GlobalArgsLaunch, RefLayout, VirtualLayout, + }, + codegen::tensor::GlobalTensorArg, + }, +}; +use burn_fusion::stream::{Context, ScalarId}; +use burn_ir::ScalarIr; +use cubecl::{ + Runtime, + client::ComputeClient, + ir::{AddressType, Type}, + prelude::{InputScalar, TensorArg}, +}; +use std::marker::PhantomData; + +/// Execute a [plan](LaunchPlan) using a [runner](TraceRunner) modifying the [context](Context). +pub struct LaunchPlanExecutor<'a, R: Runtime> { + resources: &'a FuseResources, + blocks: &'a Vec, + _r: PhantomData, +} + +#[derive(new, Debug)] +pub struct ExecutionError> { + pub error: TraceError, + pub handles_input: Vec>, + pub handles_output: Vec>, +} + +impl<'a, R: Runtime> LaunchPlanExecutor<'a, R> { + pub fn new(resources: &'a FuseResources, blocks: &'a Vec) -> Self { + Self { + resources, + blocks, + _r: PhantomData, + } + } + + pub fn execute>( + self, + client: &ComputeClient, + runner: &Runner, + context: &mut Context>, + plan: LaunchPlan<'a, R>, + ) -> Result, ExecutionError> { + let mut num_writes = 0; + for b in plan.blocks.iter() { + for writes in b.writes.values() { + num_writes += writes.len(); + } + } + + #[cfg(feature = "autotune-checks")] + let mut tune_output = TuneOutput::Checked { + handles: std::collections::HashMap::new(), + }; + + #[cfg(not(feature = "autotune-checks"))] + let mut tune_output = TuneOutput::UnChecked(PhantomData); + + if num_writes == 0 { + // Nothing to write, can skip execution. + return Ok(tune_output); + } + + let mut inputs = GlobalArgsLaunch::default(); + let mut outputs = GlobalArgsLaunch::default(); + + // An aliased output reuses the input's buffer variable in the compiled kernel + // (`builder.inplace`), so it must be declared with the exact type the input was + // launched with — including its vector size. + let input_vector_sizes: Vec = plan + .handle_inputs + .iter() + .map(|hi| match hi { + HandleInput::Normal(hi) => hi.vector_size, + HandleInput::QuantValues(hi) => hi.vector_size, + HandleInput::QuantParams(_) => 1, + }) + .collect(); + + register_inputs(plan.handle_inputs.clone(), &mut inputs); + register_scalars( + self.resources.scalars.iter(), + self.resources.views.iter(), + context, + &mut inputs, + ); + register_outputs::( + plan.handle_outputs.clone(), + &input_vector_sizes, + &mut outputs, + &mut tune_output, + ); + + for layout in plan.runtime_layouts { + for s in layout.shape.iter() { + inputs.runtime_layouts.push(*s); + } + for s in layout.strides.iter() { + inputs.runtime_layouts.push(*s); + } + } + + let mut configs = Vec::with_capacity(plan.blocks.len()); + + for (block_plan, block) in plan.blocks.into_iter().zip(self.blocks) { + let reference = match block_plan.reference { + ReferenceSelection::Concrete { layout, .. } => RefLayout::Concrete(layout), + ReferenceSelection::VirtualShape { original, .. } => { + RefLayout::Virtual(VirtualLayout::Shape(original, block_plan.width)) + } + ReferenceSelection::SwapDims { original, dims } => { + RefLayout::Virtual(VirtualLayout::SwapDims(original, dims)) + } + ReferenceSelection::Reshaped { reshape_pos } => { + RefLayout::Virtual(VirtualLayout::Reshaped { + reshape_pos, + vector_size: block_plan.width, + }) + } + ReferenceSelection::Runtime { pos } => { + RefLayout::Virtual(VirtualLayout::Runtime { pos }) + } + ReferenceSelection::Searching => { + return Err(ExecutionError::new( + TraceError::ReferenceNotFound, + plan.handle_inputs, + plan.handle_outputs, + )); + } + }; + + let mut ops = Vec::::new(); + + for read_ops in block_plan.reads.into_values() { + for op in read_ops { + ops.push(op); + } + } + + for op in block.ops.iter() { + ops.push(op.clone()); + } + + for opsw in block_plan.writes.into_values() { + for op in opsw { + ops.push(op); + } + } + + let config = FuseBlockConfig { + rank: plan.rank, + ref_layout: reference, + ops, + width: block_plan.width, + }; + configs.push(config); + } + + Runner::run(runner, client, inputs, outputs, &configs).map_err(|err| { + ExecutionError::new( + TraceError::RunnerError(err), + plan.handle_inputs, + plan.handle_outputs, + ) + })?; + + Ok(tune_output) + } +} + +fn register_inputs( + handle_inputs: Vec>, + inputs: &mut GlobalArgsLaunch, +) { + for hi in handle_inputs { + match hi { + HandleInput::Normal(hi) => { + let at = hi.handle.required_address_type(); + let arg = hi.handle.into_tensor_arg(hi.global_ir.shape.clone()); + inputs.tensors.push(GlobalTensorArg::new( + arg, + hi.precision.into_type(hi.vector_size), + hi.broadcated, + at, + )); + } + HandleInput::QuantValues(hi) => { + let at = hi.handle.required_address_type(); + let arg = hi.handle.into_tensor_arg(hi.global_ir.shape.clone()); + inputs.tensors.push(GlobalTensorArg::new( + arg, + hi.precision.into_type(hi.vector_size), + false, + at, + )); + } + HandleInput::QuantParams(hi) => { + let at = hi.handle.required_address_type(); + let arg = hi.handle.into_tensor_arg(hi.shape.clone()); + inputs.tensors.push(GlobalTensorArg::new( + arg, + hi.precision.into_type(1), + false, + at, + )); + } + } + } +} + +fn register_outputs( + handle_outputs: Vec>, + input_vector_sizes: &[usize], + outputs: &mut GlobalArgsLaunch, + #[allow(unused_variables)] tune_output: &mut TuneOutput, +) { + for item in handle_outputs { + match item { + HandleOutput::Alias { + input_pos, + precision, + global_shape, + strides, + #[cfg(feature = "autotune-checks")] + debug_info, + } => { + outputs.tensors.push(GlobalTensorArg::new( + TensorArg::Alias { + input_pos, + strides, + shape: global_shape, + }, + // Must match the aliased input's launched type: the compiled kernel + // reuses the input's buffer variable, so declaring another vector + // size here would make the comptime type disagree with the actual + // variable and produce invalid casts on the write path. + precision.into_type(input_vector_sizes[input_pos]), + false, + AddressType::default(), + )); + + #[cfg(feature = "autotune-checks")] + if let TuneOutput::Checked { handles, .. } = tune_output { + handles.insert( + debug_info.relative_id, + (debug_info.global_shape.clone(), debug_info.handle.clone()), + ); + } + } + HandleOutput::Owned { + precision, + handle, + global_shape, + vectorization: vector_size, + #[cfg(feature = "autotune-checks")] + relative_id, + .. + } => { + let at = handle.required_address_type(); + + #[cfg(feature = "autotune-checks")] + if let TuneOutput::Checked { handles, .. } = tune_output { + handles.insert(relative_id, (global_shape.clone(), handle.clone())); + } + + let arg = handle.into_tensor_arg(global_shape.clone()); + + let elem = precision.into_elem(); + let ty = Type::new(elem.into()).with_vector_size(vector_size); + + outputs + .tensors + .push(GlobalTensorArg::new(arg, ty, false, at)); + } + } + } +} + +fn register_scalars<'h, R: Runtime>( + scalars: impl Iterator, + views: impl DoubleEndedIterator, + context: &mut Context>, + inputs: &mut GlobalArgsLaunch, +) { + for (precision, id) in scalars { + let dtype = precision.into_storage_type(); + match context.scalars.get(&ScalarId { value: *id }) { + Some(scalar) => match scalar { + ScalarIr::Float(val) => inputs.scalars.push(InputScalar::new(*val, dtype)), + ScalarIr::Int(val) => inputs.scalars.push(InputScalar::new(*val, dtype)), + ScalarIr::UInt(val) => inputs.scalars.push(InputScalar::new(*val, dtype)), + ScalarIr::Bool(val) => inputs.scalars.push(InputScalar::new(*val as u8, dtype)), + }, + None => panic!("Scalar ID not found"), + } + } + + for relative in views { + if let TensorView::Reshape { reshaped, .. } = relative { + let global = context.tensors.get(reshaped).unwrap(); + + for shape in global.shape.iter() { + inputs.reshapes.push(*shape); + } + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/input.rs b/crates/burn-cubecl-fusion/src/engine/launch/input.rs new file mode 100644 index 0000000..1126492 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/input.rs @@ -0,0 +1,252 @@ +use super::{BlockPlan, HandleInput, InputReference}; +use super::{LaunchPlan, NormalHandleInput, PotentialInplace}; +use crate::CubeFusionHandle; +use crate::engine::launch::{QuantParamsHandleInput, QuantValuesHandleInput}; +use crate::engine::trace::block::FuseBlock; +use crate::engine::trace::{FuseResources, RegisterTensor, TensorView}; +use burn_fusion::stream::Context; +use burn_ir::{TensorIr, TensorStatus}; +use burn_std::quantization::params_shape; +use cubecl::Runtime; +use std::marker::PhantomData; + +/// Fetch and register [input handles](HandleInput). Also identifies potential inputs that +/// can be used inplace and/or as the [reference layout](super::super::ir::RefLayout). +pub struct InputPlanner<'a, R: Runtime> { + resources: &'a FuseResources, + blocks: &'a Vec, + _r: PhantomData, +} + +impl<'a, R: Runtime> InputPlanner<'a, R> { + pub fn new(resources: &'a FuseResources, blocks: &'a Vec) -> Self { + Self { + resources, + blocks, + _r: PhantomData, + } + } + + pub fn run(self, context: &mut Context>, plan: &mut LaunchPlan<'a, R>) { + for (pos, input) in self.resources.inputs.iter().enumerate() { + match input { + RegisterTensor::Normal(tensor_relative, precision) => { + let mut tensor_global = + context.tensors.get(&tensor_relative.id).unwrap().clone(); + // Fetching with the real status pops consumed (`ReadWrite`) tensors + // out of the container so that `can_mut()` sees the true reference + // count (memory pool + this handle). Fetching `ReadOnly` and keeping + // a clone in the container during planning made `can_mut()` always + // false, disabling every in-place alias. On execution failure the + // handle is restored by [FuseTraceLauncher::rollback](super::base). + let handle = context + .handles + .get_handle(&tensor_global.id, &tensor_relative.status); + + let mut new_strides = handle.strides.clone(); + + self.analyze(plan, pos, tensor_relative, &handle); + + if tensor_global.shape.rank() < plan.rank { + let num_elem: usize = tensor_global.shape.iter().product(); + for _ in 0..(plan.rank - tensor_global.shape.rank()) { + tensor_global.shape.insert(0, 1); + new_strides.insert(0, num_elem); + } + } + + plan.handle_inputs + .push(HandleInput::Normal(NormalHandleInput::new( + tensor_global, + tensor_relative, + *precision, + handle, + new_strides, + ))); + } + RegisterTensor::QuantValues(tensor_relative) => { + let tensor_global = context.tensors.get(&tensor_relative.id).unwrap().clone(); + let handle = context + .handles + .get_handle(&tensor_global.id, &TensorStatus::ReadOnly); + + let scheme = match tensor_relative.dtype { + burn_std::DType::QFloat(scheme) => scheme, + _ => unreachable!("Can't have quant data without QFloat"), + }; + let params = handle.params(scheme).unwrap(); + let precision = tensor_relative.dtype.into(); + let precision_scales = params.dtype.into(); + + let global_shape = tensor_global.shape.clone(); + let shape_params = params_shape(&global_shape, scheme.level); + plan.handle_inputs + .push(HandleInput::QuantValues(QuantValuesHandleInput { + relative_id: tensor_relative.id, + global_ir: tensor_global, + precision, + handle, + vector_size: 1, + })); + + plan.handle_inputs + .push(HandleInput::QuantParams(QuantParamsHandleInput { + precision: precision_scales, + handle: params, + shape: shape_params, + })); + } + RegisterTensor::QuantParams(_) => { + // It is registered at the same time as quant data. + // The order is important and the index in the vector as well, so that's why we + // have QuantParams. + } + } + } + } + + fn analyze( + &self, + plan: &mut LaunchPlan<'a, R>, + pos: usize, + tensor_relative: &'a TensorIr, + handle: &CubeFusionHandle, + ) { + if !self + .resources + .inputs_unhandled + .contains(&tensor_relative.id) + { + let mut is_a_view = false; + // For each view we try to see if it's not possible to set it as a reference input. + for view in self.resources.views.iter() { + for (block_plan, block) in plan.blocks.iter_mut().zip(self.blocks) { + is_a_view = is_a_view + || Self::analyze_view(pos, tensor_relative, block, block_plan, view); + } + } + + if !is_a_view { + self.analyze_normal(plan, pos, tensor_relative, handle); + } + } + } + + /// Analyzes if the given tensor can be used inplace in one of the block. + fn analyze_normal( + &self, + plan: &mut LaunchPlan<'a, R>, + pos: usize, + tensor_relative: &'a TensorIr, + handle: &CubeFusionHandle, + ) { + enum BlockInplaceSelection { + Notinit, + /// The block reads the input, and therefore can use it for inplace. + Selected(usize), + /// The same input is used in multiple blocks. + Unavailable, + } + + let mut block_inplace_selection = BlockInplaceSelection::Notinit; + + for (idx, block) in plan.blocks.iter().enumerate() { + if block.reads.contains_key(&tensor_relative.id) { + match block_inplace_selection { + BlockInplaceSelection::Notinit => { + block_inplace_selection = BlockInplaceSelection::Selected(idx); + } + BlockInplaceSelection::Selected(_) => { + block_inplace_selection = BlockInplaceSelection::Unavailable; + } + BlockInplaceSelection::Unavailable => {} + } + } + } + + if let BlockInplaceSelection::Selected(idx) = block_inplace_selection { + if self.blocks[idx].shape_ref != tensor_relative.shape { + return; + } + + let block_plan = &mut plan.blocks[idx]; + if tensor_relative.status == TensorStatus::ReadWrite { + // TODO: Autotune trials run on forked contexts whose extra handle clone + // makes `can_mut()` false, so trials never alias: the tuner measures the + // non-aliased kernel while the winner then executes the aliased one. + if self.blocks[idx].settings.inplace && handle.handle.can_mut() { + block_plan.potential_inplaces.push(PotentialInplace { + input_pos: pos, + tensor_relative, + strides: handle.strides.clone(), + }); + } + // Inplace tensors are normally really good as the reference layout, since + // it's normally better to be based on writes rather than on reads. + block_plan.potential_reference_input = + Some(InputReference::Normal { input_pos: pos }); + } else { + block_plan.potential_reference_input = + Some(InputReference::Normal { input_pos: pos }); + } + } + } + + /// Analyzes if the given tensor is also the view provided, and check if it can be used as the reference layout + /// for the given block. + fn analyze_view( + pos: usize, + tensor_relative: &'a TensorIr, + block: &FuseBlock, + block_plan: &mut BlockPlan<'a>, + view: &TensorView, + ) -> bool { + match view { + TensorView::Reshape { + reshaped, + original, + reshape_pos, + shape_relative, + } => { + if original == &tensor_relative.id || reshaped == &tensor_relative.id { + if block_plan.potential_reference_input.is_none() + && shape_relative == &block.shape_ref + { + block_plan.potential_reference_input = Some(InputReference::Reshaped { + reshape_pos: *reshape_pos, + }); + } + return true; + } + } + TensorView::SwapDims { + swapped, + original, + dims, + .. + } => { + if swapped == &tensor_relative.id { + return true; + } + + if original == &tensor_relative.id { + let shape = tensor_relative + .shape + .clone() + .swapped(dims.0, dims.1) + .unwrap(); + + if block_plan.potential_reference_input.is_none() && shape == block.shape_ref { + block_plan.potential_reference_input = Some(InputReference::SwapDims { + original_pos: pos, + dims: *dims, + }); + } + return true; + } + } + }; + + false + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/mod.rs b/crates/burn-cubecl-fusion/src/engine/launch/mod.rs new file mode 100644 index 0000000..94c9405 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod executor; +pub(crate) mod input; +pub(crate) mod output; +pub(crate) mod runner; +pub(crate) mod vectorization; + +pub(crate) mod plan; +pub use plan::*; + +mod base; +pub use base::*; diff --git a/crates/burn-cubecl-fusion/src/engine/launch/output.rs b/crates/burn-cubecl-fusion/src/engine/launch/output.rs new file mode 100644 index 0000000..a81d859 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/output.rs @@ -0,0 +1,720 @@ +use super::{ + super::codegen::ir::FuseType, BlockPlan, HandleOutput, InputReference, LaunchPlan, + NormalHandleInput, ReferenceSelection, +}; +use crate::{ + CubeFusionHandle, + engine::{ + codegen::ir::{FuseArg, FuseOp, LayoutInfo}, + launch::HandleInput, + settings::RefLayoutSetting, + trace::{FuseResources, RegisterTensor, RuntimeLayout, TensorView, block::FuseBlock}, + }, + strides_dyn_rank, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_fusion::stream::Context; +use burn_ir::{TensorId, TensorIr}; +use burn_std::Shape; +use burn_std::{ + Strides, + tensor::{ReshapeAction, contiguous_strides, is_contiguous, reshape_action}, +}; +use cubecl::{Runtime, client::ComputeClient}; + +/// Create or reuse handles for the outputs. +/// +/// It is also responsible to select the reference tensor. +pub struct OutputPlanner<'a, R: Runtime> { + resources: &'a FuseResources, + outputs_sorted: Vec>, + handles: Vec>>, + globals: Vec>, + blocks: &'a Vec, +} + +#[derive(Debug)] +struct OutputSorted<'a> { + pos_original: usize, + precision: FuseType, + tensor_relative: &'a TensorIr, +} + +#[derive(Debug)] +enum OutputKind { + Normal, + Inplace { + /// The position in the potential inplace vector + input_pos: usize, + }, + Transform(TensorView), +} + +impl<'a, R: Runtime> OutputPlanner<'a, R> { + pub fn new(resources: &'a FuseResources, blocks: &'a Vec) -> Self { + let mut outputs_sorted: Vec<_> = resources + .outputs + .iter() + .enumerate() + .filter_map(|(pos, entry)| match entry { + RegisterTensor::Normal(ir, p) => Some((pos, ir, p)), + RegisterTensor::QuantValues(_) => None, + RegisterTensor::QuantParams(_) => None, + }) + .map(|(pos, tensor, precision)| OutputSorted { + pos_original: pos, + precision: *precision, + tensor_relative: tensor, + }) + .collect(); + + outputs_sorted.sort_by(|a, b| { + let a_val: usize = a.tensor_relative.shape.iter().sum(); + let b_val: usize = b.tensor_relative.shape.iter().sum(); + + b_val.cmp(&a_val) + }); + + let mut handles = Vec::with_capacity(resources.outputs.len()); + let mut globals = Vec::with_capacity(resources.outputs.len()); + + for _ in 0..resources.outputs.len() { + handles.push(None); + globals.push(None); + } + + Self { + resources, + outputs_sorted, + handles, + globals, + blocks, + } + } + + pub fn run( + mut self, + client: &ComputeClient, + device: &R::Device, + context: &mut Context>, + plan: &mut LaunchPlan<'a, R>, + ) { + // So that we can borrow self during the iteration. + let mut outputs = Vec::new(); + core::mem::swap(&mut outputs, &mut self.outputs_sorted); + + for output in outputs.into_iter() { + let tensor_global = context + .tensors + .get(&output.tensor_relative.id) + .unwrap() + .clone(); + let strides = strides_dyn_rank(&tensor_global.shape); + let (kind, block_idx) = self.output_kind(plan, &tensor_global, &output, &strides); + + match kind { + OutputKind::Inplace { input_pos } => { + self.inplace_output( + context, + plan, + output, + tensor_global, + strides, + input_pos, + block_idx, + ); + } + OutputKind::Normal => { + self.normal_output( + client, + device, + context, + plan, + output, + tensor_global, + strides, + block_idx, + ); + } + OutputKind::Transform(TensorView::Reshape { original, .. }) => { + self.reshaped_output( + client, + device, + context, + plan, + output, + tensor_global, + strides, + original, + block_idx, + ); + } + OutputKind::Transform(TensorView::SwapDims { original, dims, .. }) => { + self.swapped_dims_output( + client, + device, + context, + plan, + output, + tensor_global, + original, + dims, + block_idx, + ); + } + } + } + + for (handle, global) in self.handles.into_iter().zip(self.globals) { + plan.handle_outputs.push(handle.unwrap()); + plan.global_outputs.push(global.unwrap()); + } + + for i in 0..plan.blocks.len() { + if !plan.blocks[i].reference.is_found() { + match self.blocks[i].settings.ref_layout { + RefLayoutSetting::SameAsBlock { block_pos } => { + plan.blocks[i].reference = + plan.blocks[block_pos as usize].reference.clone(); + } + _ => { + let new_runtime = Self::select_reference_from_inputs( + &self.blocks[i], + &mut plan.blocks[i], + &plan.handle_inputs, + ); + + if let Some(shape) = new_runtime { + let pos = plan.runtime_layouts.len(); + let mut shape_global = shape.clone(); + for (i, s) in shape.iter().enumerate() { + shape_global[i] = *context.shapes_relative2global.get(s).unwrap(); + } + + let strides = strides_dyn_rank(&shape_global); + + plan.blocks[i].reference = ReferenceSelection::Runtime { pos }; + plan.runtime_layouts.push(RuntimeLayout { + shape: shape_global, + strides, + }); + } + } + }; + } else { + Self::add_layout_info_inputs(&mut plan.blocks[i], &plan.handle_inputs); + } + } + + // Make sure dropped are correctly executed. + for id in self.resources.dropped.iter() { + if let Some(tensor_global) = context.tensors.get(id) { + context.handles.remove_handle(tensor_global.id); + } + } + } + + fn select_reference_from_inputs( + block: &FuseBlock, + block_plan: &mut BlockPlan<'_>, + handle_inputs: &[HandleInput], + ) -> Option { + if let Some(input_ref) = block_plan.potential_reference_input.take() { + match input_ref { + InputReference::Normal { input_pos } => { + let reference = handle_inputs + .get(input_pos) + .unwrap() + .as_normal() + .expect("Quant can't be used as inplace"); + + let set_ref_as_concrete = |block: &mut BlockPlan<'_>| { + block.reference = ReferenceSelection::Concrete { + layout: FuseArg::Input( + input_pos, + reference.precision, + LayoutInfo::IsRef, + ), + shape: reference.global_ir.shape.clone(), + strides: reference.handle.strides.clone(), + }; + }; + + let set_ref_as_virtual = |block: &mut BlockPlan<'_>| { + block.reference = ReferenceSelection::VirtualShape { + original: FuseArg::Input( + input_pos, + reference.precision, + LayoutInfo::Unknown, + ), + shape: reference.global_ir.shape.clone(), + strides: contiguous_strides(&reference.global_ir.shape), + }; + }; + + match block.settings.ref_layout { + RefLayoutSetting::Any => set_ref_as_concrete(block_plan), + RefLayoutSetting::SameAsBlock { .. } => { + // Skip set ref. + } + RefLayoutSetting::OnlyContiguous => { + if is_contiguous(&reference.global_ir.shape, &reference.handle.strides) + { + set_ref_as_concrete(block_plan) + } else { + set_ref_as_virtual(block_plan) + } + } + } + + Self::add_layout_info_inputs(block_plan, handle_inputs); + } + InputReference::SwapDims { original_pos, dims } => { + let reference = handle_inputs + .get(original_pos) + .unwrap() + .as_normal() + .expect("Quant can't be used in swap dims operation"); + block_plan.reference = ReferenceSelection::SwapDims { + original: FuseArg::Input( + original_pos, + reference.precision, + LayoutInfo::Unknown, + ), + dims, + }; + } + InputReference::Reshaped { reshape_pos } => { + block_plan.reference = ReferenceSelection::Reshaped { reshape_pos }; + } + }; + None + } else { + Some(block.shape_ref.clone()) + } + } + + fn add_layout_info_inputs(block: &mut BlockPlan<'_>, handle_inputs: &[HandleInput]) { + for hi in handle_inputs.iter().filter_map(|h| match h { + HandleInput::Normal(input) => Some(input), + _ => None, + }) { + let (strides, shape) = match &block.reference { + ReferenceSelection::Concrete { strides, shape, .. } + | ReferenceSelection::VirtualShape { strides, shape, .. } => (strides, shape), + _ => continue, + }; + + if strides == &hi.handle.strides + && shape == &hi.global_ir.shape + && let Some(ops) = block.reads.get_mut(&hi.relative_id) + { + for op in ops.iter_mut() { + if let FuseOp::Assign(op) = op { + op.input.add_layout_info(LayoutInfo::SameAsRef); + } + } + } + } + } + + fn output_kind( + &self, + plan: &mut LaunchPlan<'a, R>, + tensor_global: &TensorIr, + output: &OutputSorted, + strides: &[usize], + ) -> (OutputKind, usize) { + let mut block_idx = None; + for (i, block) in plan.blocks.iter().enumerate() { + if block.writes.contains_key(&output.tensor_relative.id) { + block_idx = Some(i); + break; + } + } + let block_idx = block_idx.unwrap(); + + if let Some(transform) = self.resources.views.iter().find(|v| match v { + TensorView::Reshape { reshaped, .. } => reshaped == &output.tensor_relative.id, + TensorView::SwapDims { swapped, .. } => swapped == &output.tensor_relative.id, + }) { + return (OutputKind::Transform(transform.clone()), block_idx); + } + + let block = &plan.blocks[block_idx]; + let ref_layout_setting = &self.blocks[block_idx].settings.ref_layout; + let kind = block + .potential_inplaces + .iter() + .enumerate() + .find(|(_pos, pi)| { + pi.tensor_relative.dtype == tensor_global.dtype + && pi.tensor_relative.shape == output.tensor_relative.shape + && &*pi.strides == strides + && if block.reference.is_found() { + // An already-selected reference must have compatible strides. + block.reference.compatible_strides_for_inplace(strides) + } else { + // When no reference has been selected yet, this output becomes + // the reference (see [Self::inplace_output]); requiring an + // existing reference here made the first output of every block + // ineligible, since the reference is only selected while + // processing outputs. Blocks that inherit their reference from + // another block are excluded: the inherited layout is unknown + // at this point, so it cannot be validated against the + // candidate's strides. + !matches!(ref_layout_setting, RefLayoutSetting::SameAsBlock { .. }) + } + }) + .map(|(pos, _)| OutputKind::Inplace { input_pos: pos }) + .unwrap_or(OutputKind::Normal); + + (kind, block_idx) + } + + #[allow(clippy::too_many_arguments)] + fn inplace_output( + &mut self, + context: &mut Context>, + plan: &mut LaunchPlan<'a, R>, + output: OutputSorted, + tensor_global: TensorIr, + strides: Strides, + input_index: usize, + block_idx: usize, + ) { + let block = &mut plan.blocks[block_idx]; + #[cfg(feature = "test-util")] + crate::inspect::record_inplace_alias(); + let potential_inplace = block.potential_inplaces.remove(input_index); + let handle_input = match plan.handle_inputs.get(potential_inplace.input_pos).unwrap() { + HandleInput::Normal(handle) => handle, + _ => { + unreachable!("Quant tensor handle can't be used inplace yet.") + } + }; + + // [Self::output_kind] only selects inplace when the reference is already + // validated or this output can become the reference, which blocks inheriting + // their reference from another block (`SameAsBlock`) never can. + debug_assert!( + block.reference.is_found() + || !matches!( + self.blocks[block_idx].settings.ref_layout, + RefLayoutSetting::SameAsBlock { .. } + ), + "inplace alias selected for a `SameAsBlock` block without a validated reference", + ); + + if !block.reference.is_found() { + // The aliased output shares the input's buffer and layout, so the reference + // is expressed with the output argument. Runners assume references are + // either output-concrete or virtual; an input-concrete reference here would + // be resolved against the wrong argument list. + block.reference = ReferenceSelection::Concrete { + layout: FuseArg::Output(output.pos_original, output.precision, LayoutInfo::IsRef), + shape: tensor_global.shape.clone(), + strides: handle_input.handle.strides.clone(), + }; + + if let Some(ops) = block.reads.get_mut(&handle_input.relative_id) { + for op in ops.iter_mut() { + if let FuseOp::Assign(op) = op { + op.input.add_layout_info(LayoutInfo::IsRef); + break; + }; + } + } + + if let Some(ops) = block.writes.get_mut(&output.tensor_relative.id) { + for op in ops { + if let FuseOp::Assign(op) = op { + op.out.add_layout_info(LayoutInfo::IsRef); + break; + } + } + }; + } else { + // Already validated, necessary for correctness. + if let Some(ops) = block.writes.get_mut(&output.tensor_relative.id) { + for op in ops { + if let FuseOp::Assign(op) = op { + op.out.add_layout_info(LayoutInfo::SameAsRef); + break; + } + } + }; + } + + context + .handles + .register_handle(tensor_global.id, handle_input.handle.clone()); + + self.handles[output.pos_original] = Some(HandleOutput::Alias { + input_pos: potential_inplace.input_pos, + precision: output.precision, + global_shape: tensor_global.shape.clone(), + strides, + #[cfg(feature = "autotune-checks")] + debug_info: super::HandleOutputAliasDebugInfo { + relative_id: output.tensor_relative.id, + handle: handle_input.handle.clone(), + global_shape: tensor_global.shape.clone(), + }, + }); + self.globals[output.pos_original] = Some(tensor_global); + } + + #[allow(clippy::too_many_arguments)] + fn normal_output( + &mut self, + client: &ComputeClient, + device: &R::Device, + context: &mut Context>, + plan: &mut LaunchPlan<'a, R>, + output: OutputSorted, + tensor_global: TensorIr, + strides: Strides, + block_idx: usize, + ) { + let block = &mut plan.blocks[block_idx]; + + if !block.reference.is_found() + && self.blocks[block_idx].shape_ref == output.tensor_relative.shape + && !matches!( + self.blocks[block_idx].settings.ref_layout, + RefLayoutSetting::SameAsBlock { .. } + ) + { + block.reference = ReferenceSelection::Concrete { + layout: FuseArg::Output(output.pos_original, output.precision, LayoutInfo::IsRef), + shape: tensor_global.shape.clone(), + strides: strides.clone(), + }; + + // Sometimes outputs that are manually handled don't have any write registered. + if let Some(ops) = block.writes.get_mut(&output.tensor_relative.id) { + for op in ops { + if let FuseOp::Assign(op) = op { + op.out.add_layout_info(LayoutInfo::IsRef); + break; + } + } + }; + } else if let ReferenceSelection::Concrete { + shape: ref_shape, + strides: ref_strides, + .. + } = &block.reference + && ref_strides == &strides + && ref_shape == &tensor_global.shape + && let Some(ops) = block.writes.get_mut(&output.tensor_relative.id) + { + for op in ops { + if let FuseOp::Assign(op) = op { + op.out.add_layout_info(LayoutInfo::SameAsRef); + break; + } + } + }; + + let dtype = tensor_global.dtype; + let size = + tensor_global.shape.iter().product::() * dtype_to_storage_type(dtype).size(); + + let handle = CubeFusionHandle { + client: client.clone(), + handle: client.empty(size), + device: device.clone(), + strides, + dtype, + qparams: None, + }; + + plan.rank = usize::max(tensor_global.shape.rank(), plan.rank); + context + .handles + .register_handle(tensor_global.id, handle.clone()); + + self.handles[output.pos_original] = Some(HandleOutput::Owned { + precision: output.precision, + handle, + global_shape: tensor_global.shape.clone(), + global_id: tensor_global.id, + relative_id: output.tensor_relative.id, + vectorization: 1, + }); + self.globals[output.pos_original] = Some(tensor_global); + } + + #[allow(clippy::too_many_arguments)] + fn reshaped_output( + &mut self, + client: &ComputeClient, + device: &R::Device, + context: &mut Context>, + plan: &mut LaunchPlan<'a, R>, + output: OutputSorted, + tensor_global: TensorIr, + strides: Strides, + original: TensorId, + block_idx: usize, + ) { + let block = &mut plan.blocks[block_idx]; + + let (pos_input, original_handle) = Self::find_child_input(&plan.handle_inputs, original); + + let dtype = tensor_global.dtype; + + let action = reshape_action( + &original_handle.global_ir.shape, + &original_handle.handle.strides, + &tensor_global.shape, + ); + + let update = match action { + ReshapeAction::UpdateStrides { strides } => Some(strides), + ReshapeAction::NoChange => Some(original_handle.handle.strides.clone()), + ReshapeAction::Recompute => None, + }; + + match update { + Some(strides) => { + // We modify the metadata instead. + remove_concrete_write(block, output.tensor_relative.id, output.pos_original); + + let handle = CubeFusionHandle { + client: client.clone(), + handle: original_handle.handle.handle.clone(), + device: device.clone(), + strides, + dtype, + qparams: original_handle.handle.qparams.clone(), + }; + context + .handles + .register_handle(tensor_global.id, handle.clone()); + + // IT will never be access, just a way to keep the original position working. + self.handles[output.pos_original] = Some(HandleOutput::Alias { + input_pos: pos_input, + precision: output.precision, + global_shape: tensor_global.shape.clone(), + strides: handle.strides.clone(), + #[cfg(feature = "autotune-checks")] + debug_info: super::HandleOutputAliasDebugInfo { + relative_id: output.tensor_relative.id, + handle: handle.clone(), + global_shape: tensor_global.shape.clone(), + }, + }); + self.globals[output.pos_original] = Some(tensor_global); + } + None => { + self.normal_output( + client, + device, + context, + plan, + output, + tensor_global, + strides, + block_idx, + ); + } + } + } + + #[allow(clippy::too_many_arguments)] + fn swapped_dims_output( + &mut self, + client: &ComputeClient, + device: &R::Device, + context: &mut Context>, + plan: &mut LaunchPlan<'a, R>, + output: OutputSorted, + tensor_global: TensorIr, + original: TensorId, + dims: (usize, usize), + block_idx: usize, + ) { + let block = &mut plan.blocks[block_idx]; + let (pos_input, original_handle) = Self::find_child_input(&plan.handle_inputs, original); + + let dtype = tensor_global.dtype; + + // TODO: Check if we can also remove the read, if we have a dead partial graph. + // + // We modify the metadata instead. + remove_concrete_write(block, output.tensor_relative.id, output.pos_original); + + let strides = original_handle.handle.strides.clone(); + + let mut handle = CubeFusionHandle { + client: client.clone(), + handle: original_handle.handle.handle.clone(), + device: device.clone(), + strides, + dtype, + qparams: original_handle.handle.qparams.clone(), + }; + handle.strides.swap(dims.0, dims.1); + + context + .handles + .register_handle(tensor_global.id, handle.clone()); + + // IT will never be access, just a way to keep the original position working. + self.handles[output.pos_original] = Some(HandleOutput::Alias { + input_pos: pos_input, + precision: output.precision, + global_shape: tensor_global.shape.clone(), + strides: handle.strides.clone(), + #[cfg(feature = "autotune-checks")] + debug_info: super::HandleOutputAliasDebugInfo { + relative_id: output.tensor_relative.id, + handle: handle.clone(), + global_shape: tensor_global.shape.clone(), + }, + }); + self.globals[output.pos_original] = Some(tensor_global); + } + + fn find_child_input( + handle_inputs: &[HandleInput], + original: TensorId, + ) -> (usize, &NormalHandleInput) { + handle_inputs + .iter() + .enumerate() + .find_map(|(pi, handle)| match handle { + HandleInput::Normal(handle) => match handle.relative_id == original { + true => Some((pi, handle)), + false => None, + }, + _ => None, // Quant tensor can't be reshaped. + }) + .unwrap() + } +} + +fn remove_concrete_write(block: &mut BlockPlan, id: TensorId, output_pos: usize) { + let ops = block.writes.remove(&id); + + if let Some(ops) = ops { + let mut keep = Vec::with_capacity(ops.len()); + + for op in ops { + if let FuseOp::Assign(args) = &op { + if let FuseArg::Output(pos, ..) = args.out { + if pos != output_pos { + keep.push(op); + } + } else { + keep.push(op); + } + } + } + block.writes.insert(id, keep); + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/plan.rs b/crates/burn-cubecl-fusion/src/engine/launch/plan.rs new file mode 100644 index 0000000..4cdd549 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/plan.rs @@ -0,0 +1,272 @@ +use crate::{ + CubeFusionHandle, + engine::{ + codegen::ir::{FuseArg, FuseOp, FuseType}, + launch::vectorization::Vect, + trace::{RuntimeLayout, block::FuseBlock}, + }, +}; +use burn_ir::{TensorId, TensorIr}; +use burn_std::{Shape, Strides}; +use cubecl::{Runtime, ir::VectorSize}; +use std::collections::BTreeMap; + +/// The `LaunchPlan` is responsible for aggregating all runtime information required +/// to dispatch a fused kernel. +/// +/// It maps abstract IR tensors to memory handles, manages vectorization +/// strategies, and tracks layout transformations. +#[derive(Debug)] +pub struct LaunchPlan<'a, R: Runtime> { + /// The IR representation of tensors that are results of the fusion. + pub global_outputs: Vec, + /// Memory handles and metadata for all input tensors. + pub handle_inputs: Vec>, + /// Memory handles and metadata for all output tensors, including aliased inputs. + pub handle_outputs: Vec>, + /// The rank across all tensors in the plan. + /// + /// Smaller tensors are unsqueezed during launch. + pub rank: usize, + /// Detailed planning for each individual computation block within the fusion. + pub blocks: Vec>, + /// Mapping of tensor IDs to their specific vectorization factors. + pub vectorizations: BTreeMap, + /// Metadata for shapes and strides passed from the host when they cannot be + /// inferred from input tensors (e.g., complex deep fusions). + pub runtime_layouts: Vec, +} + +/// Information regarding the execution of a specific block of operations within a fusion. +#[derive(Debug)] +pub struct BlockPlan<'a> { + /// List of inputs that are candidates for in-place memory reuse within this block. + pub potential_inplaces: Vec>, + /// The input tensor chosen to define the iteration space, if any. + pub potential_reference_input: Option, + /// How the master layout is determined for this block. + pub reference: ReferenceSelection, + /// Mapping of tensor IDs to the read operations performed on them. + pub reads: BTreeMap>, + /// Mapping of tensor IDs to the write operations performed on them. + pub writes: BTreeMap>, + /// The width for the operations in this block. + pub width: VectorSize, +} + +/// Metadata for an input tensor being used as a reference for a block's layout. +#[derive(Debug)] +pub enum InputReference { + /// Standard input at the specified position. + Normal { input_pos: usize }, + /// Input that has an axis swapped. + SwapDims { + original_pos: usize, + dims: (usize, usize), + }, + /// Input that has been reshaped. + Reshaped { reshape_pos: usize }, +} + +/// Strategies for selecting the reference layout of a fused block. +/// +/// The reference layout determines how global indices are mapped to tensor coordinates. +#[derive(Clone, Debug)] +pub enum ReferenceSelection { + /// The engine is still calculating the optimal reference. + Searching, + /// Layout from a normal tensor. + Concrete { + layout: FuseArg, + shape: Shape, + strides: Strides, + }, + /// Layout from a swapped dim tensor. + SwapDims { + original: FuseArg, + dims: (usize, usize), + }, + /// Layout from a reshaped tensor. + Reshaped { reshape_pos: usize }, + /// Layout that has the shape of an input, but not its strides. + VirtualShape { + original: FuseArg, + shape: Shape, + strides: Strides, + }, + /// The layout is provided dynamically by the host at runtime. + Runtime { pos: usize }, +} + +impl LaunchPlan<'_, R> { + /// Creates a new `LaunchPlan` from a slice of fusion blocks. + /// + /// Initializes blocks with default "Searching" references and calculates + /// the initial max rank. + pub fn new(fuse_blocks: &[FuseBlock]) -> Self { + let mut rank = 0; + let mut blocks = Vec::with_capacity(fuse_blocks.len()); + + for b in fuse_blocks.iter() { + rank = usize::max(b.shape_ref.len(), rank); + let block = BlockPlan { + reference: ReferenceSelection::Searching, + reads: b.reads.clone(), + writes: b.writes.clone(), + width: 0, + potential_inplaces: Vec::new(), + potential_reference_input: None, + }; + blocks.push(block); + } + + LaunchPlan { + global_outputs: Vec::new(), + handle_inputs: Vec::new(), + handle_outputs: Vec::new(), + rank, + blocks, + vectorizations: Default::default(), + runtime_layouts: Default::default(), + } + } +} + +/// Debugging information for aliased handles when `autotune-checks` is enabled. +#[cfg(feature = "autotune-checks")] +#[derive(Debug, Clone)] +pub struct HandleOutputAliasDebugInfo { + pub handle: CubeFusionHandle, + pub relative_id: TensorId, + pub global_shape: Shape, +} + +/// Represents the output of a fused kernel execution. +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum HandleOutput { + /// An output that reuses the memory of an input tensor (In-place). + Alias { + /// Index of the input handle being aliased. + input_pos: usize, + /// Data type precision. + precision: FuseType, + global_shape: Shape, + strides: Strides, + #[cfg(feature = "autotune-checks")] + debug_info: HandleOutputAliasDebugInfo, + }, + /// An output that requires a newly allocated memory buffer. + Owned { + global_id: TensorId, + relative_id: TensorId, + precision: FuseType, + handle: CubeFusionHandle, + global_shape: Shape, + vectorization: VectorSize, + }, +} + +/// A standard input handle with associated layout and vectorization metadata. +#[derive(Debug, Clone)] +pub struct NormalHandleInput { + pub relative_id: TensorId, + pub global_ir: TensorIr, + pub precision: FuseType, + pub handle: CubeFusionHandle, + pub vector_size: VectorSize, + pub broadcated: bool, + /// Stores the original strides of the handle for restoration during plan rollback. + pub orig_strides: Strides, +} + +/// An input handle containing values for a quantized tensor. +#[derive(Debug, Clone)] +pub struct QuantValuesHandleInput { + pub relative_id: TensorId, + pub global_ir: TensorIr, + pub precision: FuseType, + pub handle: CubeFusionHandle, + pub vector_size: VectorSize, +} + +/// An input handle containing parameters (scales/offsets) for quantization. +#[derive(Debug, Clone)] +pub struct QuantParamsHandleInput { + pub precision: FuseType, + pub handle: CubeFusionHandle, + pub shape: Shape, +} + +/// Different types of inputs that can be passed to a fused kernel. +#[derive(Debug, Clone)] +pub enum HandleInput { + Normal(NormalHandleInput), + QuantValues(QuantValuesHandleInput), + QuantParams(QuantParamsHandleInput), +} + +impl HandleInput { + /// Returns a reference to the inner `NormalHandleInput` if the variant matches. + pub fn as_normal(&self) -> Option<&NormalHandleInput> { + match self { + HandleInput::Normal(normal) => Some(normal), + _ => None, + } + } +} + +impl NormalHandleInput { + /// Creates a new `NormalHandleInput` tracking original strides. + pub fn new( + tensor_global: TensorIr, + tensor_relative: &TensorIr, + precision: FuseType, + mut handle: CubeFusionHandle, + mut strides: Strides, + ) -> Self { + // Swap current handle strides with provided strides to track the original state for rollback. + core::mem::swap(&mut handle.strides, &mut strides); + Self { + precision, + handle, + relative_id: tensor_relative.id, + global_ir: tensor_global, + vector_size: 1, + broadcated: false, + orig_strides: strides, + } + } + + /// Restores the handle's original strides and returns the handle. + /// + /// Used when a plan is invalidated or needs to be rolled back. + pub fn handle_rollback(mut self) -> CubeFusionHandle { + core::mem::swap(&mut self.handle.strides, &mut self.orig_strides); + self.handle + } +} + +/// A candidate for in-place optimization. +#[derive(Debug)] +pub struct PotentialInplace<'a> { + /// Position of the input handle in the `handle_inputs` vector. + pub input_pos: usize, + /// Reference to the IR of the relative tensor. + pub tensor_relative: &'a TensorIr, + /// Current strides of the potential in-place candidate. + pub strides: Strides, +} + +impl ReferenceSelection { + pub fn is_found(&self) -> bool { + !matches!(self, Self::Searching) + } + + pub fn compatible_strides_for_inplace(&self, strides_inplace: &[usize]) -> bool { + match self { + ReferenceSelection::Concrete { strides, .. } => &**strides == strides_inplace, + _ => false, + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/runner.rs b/crates/burn-cubecl-fusion/src/engine/launch/runner.rs new file mode 100644 index 0000000..248019b --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/runner.rs @@ -0,0 +1,96 @@ +use super::super::codegen::ir::{FuseBlockConfig, GlobalArgsLaunch}; +use crate::{ + CubeFusionHandle, + engine::launch::{ + LaunchPlan, + vectorization::{Vect, vectorization_default}, + }, +}; +use burn_fusion::stream::Context; +use burn_ir::{TensorId, TensorIr}; +use cubecl::prelude::*; +use std::collections::{BTreeMap, HashMap}; + +/// A trace runner is responsible for determining the vectorization factor as well as launching +/// a kernel based on global [inputs](GlobalArgsLaunch) and [outputs](GlobalArgsLaunch) +/// with provided [fuse block configs](FuseBlockConfig). +pub trait TraceRunner: Vectorization { + /// The error that might happen while running the trace. + type Error; + + /// Run the trace with the given inputs and outputs. + /// + /// There is one [fuse config](FuseBlockConfig) for each [block](super::block::FuseBlock) registered + /// in the [optimization builder](burn_fusion::OptimizationBuilder). + fn run<'a>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + configs: &'a [FuseBlockConfig], + ) -> Result<(), Self::Error>; +} + +pub enum VectorizationHandle<'a, R: Runtime> { + NormalInput(&'a CubeFusionHandle, &'a TensorIr), + QuantValues(&'a CubeFusionHandle, &'a TensorIr), + QuantParams, +} + +impl<'a, R: Runtime> VectorizationHandle<'a, R> { + /// Returns if the current vectorization handle is from the given tensor id. + pub fn is_from_tensor(&self, id: TensorId) -> bool { + match self { + VectorizationHandle::NormalInput(_, tensor_ir) => tensor_ir.id == id, + VectorizationHandle::QuantValues(_, tensor_ir) => tensor_ir.id == id, + VectorizationHandle::QuantParams => false, + } + } +} + +#[derive(Default)] +pub struct VectorizationAxis { + axis: HashMap, +} + +impl VectorizationAxis { + pub fn get usize>(&self, id: TensorId, default: F) -> usize { + self.axis.get(&id).copied().unwrap_or_else(default) + } + pub fn insert(&mut self, id: TensorId, axis: usize) { + self.axis.insert(id, axis); + } +} + +pub trait Vectorization { + /// Returns the vectorization options. + fn axis(&self, _plan: &LaunchPlan<'_, R>) -> VectorizationAxis { + VectorizationAxis::default() + } + /// The vectorization factor for all inputs and outputs. + #[allow(clippy::too_many_arguments)] + fn vectorization<'a>( + &self, + _context: &Context>, + vectorizations: &mut BTreeMap, + inputs: impl Iterator>, + outputs: impl Iterator, + reshaped: impl Iterator, + swapped: impl Iterator, + vector_sizes: &[VectorSize], + max: VectorSize, + axis: VectorizationAxis, + ) { + vectorization_default( + vectorizations, + inputs, + outputs, + reshaped, + swapped, + vector_sizes, + &Default::default(), + max, + &axis, + ) + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/vectorization/base.rs b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/base.rs new file mode 100644 index 0000000..10810e0 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/base.rs @@ -0,0 +1,474 @@ +use crate::{ + CubeFusionHandle, + engine::launch::runner::{VectorizationAxis, VectorizationHandle}, +}; +use burn_fusion::stream::Context; +use burn_ir::{TensorId, TensorIr}; +use cubecl::{Runtime, ir::VectorSize}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy)] +pub enum Vect { + Broadcasted, + Aligned(VectorSize), +} + +impl Vect { + pub fn vector_size(&self) -> VectorSize { + match self { + Vect::Broadcasted => 1, + Vect::Aligned(val) => *val, + } + } + + pub fn is_broadcast(&self) -> bool { + matches!(self, Vect::Broadcasted) + } +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug)] +pub struct VectorSizeOverrides { + state: Option>>, + default: Option>, +} + +#[allow(unused)] +impl VectorSizeOverrides { + pub fn overrides(&mut self, tensor_id: &TensorId, vector_sizes: Vec) { + let map = match &mut self.state { + Some(val) => val, + None => { + self.state = Some(BTreeMap::new()); + self.state.as_mut().unwrap() + } + }; + + map.insert(*tensor_id, vector_sizes); + } + pub fn overrides_default(&mut self, vector_sizes: Vec) { + self.default = Some(vector_sizes); + } + + pub fn mapping(&self, context: &Context>) -> Self { + match &self.state { + Some(state) => { + let mut state_new = BTreeMap::new(); + + for (k, v) in state.iter() { + let global = context.tensors.get(k).unwrap(); + state_new.insert(global.id, v.clone()); + } + + Self { + state: Some(state_new), + default: self.default.clone(), + } + } + None => Self { + state: None, + default: self.default.clone(), + }, + } + } + + pub fn tensor(&self, tensor_id: &TensorId) -> Option<&Vec> { + let map = match &self.state { + Some(val) => val, + None => return self.default.as_ref(), + }; + + match map.get(tensor_id) { + Some(val) => Some(val), + None => self.default.as_ref(), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn vectorization_default<'a, R: Runtime>( + vectorizations: &mut BTreeMap, + inputs: impl Iterator>, + outputs: impl Iterator, + reshaped: impl Iterator, + swapped: impl Iterator, + vector_sizes: &[VectorSize], + overrides: &VectorSizeOverrides, + max: VectorSize, + axis: &VectorizationAxis, +) { + let swapped: Vec<_> = swapped.collect(); + + for input in inputs { + if let Some((s, o, mr, dims)) = swapped + .iter() + .find(|(_s, o, _mr, _dims)| input.is_from_tensor(o.id)) + { + let (handle, id) = match input { + VectorizationHandle::NormalInput(handle, tensor_ir) => (handle, &tensor_ir.id), + VectorizationHandle::QuantValues(..) => panic!("Can't be swapped"), + VectorizationHandle::QuantParams => panic!("Can't be swapped"), + }; + let val = vectorization_swapped( + handle, + s, + o, + *mr, + dims, + max, + axis, + vector_sizes, + overrides.tensor(id), + ); + multi_reads_vectorization_update(vectorizations, o.id, val); + } else { + match input { + VectorizationHandle::NormalInput(handle, tensor_ir) => { + let val = vectorization_input( + handle, + tensor_ir, + axis, + vector_sizes, + overrides.tensor(&tensor_ir.id), + ); + vectorizations.insert(tensor_ir.id, val); + } + VectorizationHandle::QuantValues(handle, tensor_ir) => { + let val = vectorization_input( + handle, + tensor_ir, + axis, + vector_sizes, + overrides.tensor(&tensor_ir.id), + ); + let num_quants = match tensor_ir.dtype { + burn_std::DType::QFloat(quant_scheme) => quant_scheme.num_quants(), + _ => panic!(""), + }; + let val = match val { + Vect::Broadcasted => Vect::Aligned(1), + Vect::Aligned(val) => Vect::Aligned(val.div_ceil(num_quants)), + }; + vectorizations.insert(tensor_ir.id, val); + } + VectorizationHandle::QuantParams => { + // Doesn't have vectorization for now. + } + }; + } + } + + for (reshaped, original, multi_reads) in reshaped { + let val = vectorization_reshape( + reshaped, + original, + multi_reads, + axis, + vector_sizes, + max, + overrides.tensor(&original.id), + ); + multi_reads_vectorization_update(vectorizations, original.id, val); + } + + for tensor in outputs { + let val = vectorization_output( + tensor, + axis, + vector_sizes, + max, + overrides.tensor(&tensor.id), + ); + vectorizations.insert(tensor.id, val); + } +} + +fn multi_reads_vectorization_update( + vectorizations: &mut BTreeMap, + original: TensorId, + vect: Vect, +) { + if let Some(ori_vect) = vectorizations.get(&original).cloned() { + match ori_vect { + Vect::Broadcasted => { + // keep the original as is. + } + Vect::Aligned(ori) => match vect { + Vect::Broadcasted => { + vectorizations.insert(original, Vect::Aligned(1)); + } + Vect::Aligned(new) => { + let val = if new != ori { 1 } else { new }; + vectorizations.insert(original, Vect::Aligned(val)); + } + }, + }; + } else { + vectorizations.insert(original, vect); + } +} + +// The default version uses the last dimension as vectorization axis and assumes a +// perpendicular contiguous vector. +fn vectorization_input( + handle: &CubeFusionHandle, + desc: &TensorIr, + axis: &VectorizationAxis, + vector_sizes: &[VectorSize], + overrides: Option<&Vec>, +) -> Vect { + let axis = axis.get(desc.id, || handle.strides.len() - 1); + let shape_axis = desc.shape[axis]; + + if shape_axis == 1 { + return Vect::Broadcasted; + } + + // Last dimension strides should be 1, otherwise vecX won't be contiguous. + if handle.strides[axis] != 1 { + return Vect::Aligned(1); + } + + // Reflect changes from https://github.com/tracel-ai/cubecl/pull/1312 + // Calculate the smallest non-zero stride among other dimensions + let mut next_stride = handle + .strides + .iter() + .enumerate() + .filter_map(|(i, &s)| (i != axis && s != 0).then_some(s)) + .min() + .unwrap_or(0); + + // Since when we calculate the vectorization of an input value, we will divide the + // vectorization by the num_quants, we need to adapt the stride check accordingly. + if let burn_std::DType::QFloat(quant_scheme) = handle.dtype { + next_stride *= quant_scheme.num_quants(); + }; + + let inner = |s: VectorSize| { + // The last dimension should be a multiple of the vector size or broadcated. + if shape_axis.is_multiple_of(s) && next_stride % s == 0 { + return Some(Vect::Aligned(s)); + } + None + }; + + match overrides { + Some(vals) => { + for s in vals { + if let Some(val) = inner(*s) { + return val; + } + } + } + None => { + for s in vector_sizes { + if let Some(val) = inner(*s) { + return val; + } + } + } + } + + Vect::Aligned(1) +} + +fn vectorization_output( + desc: &TensorIr, + axis: &VectorizationAxis, + vector_sizes: &[VectorSize], + max: VectorSize, + overrides: Option<&Vec>, +) -> Vect { + let axis = axis.get(desc.id, || desc.shape.rank() - 1); + + let inner = |s: VectorSize| { + // The dimension should be a multiple of the vector size. + if desc.shape[axis].is_multiple_of(s) && s <= max { + return Some(Vect::Aligned(s)); + } + + None + }; + match overrides { + Some(val) => { + for s in val { + if let Some(val) = inner(*s) { + return val; + } + } + } + None => { + for s in vector_sizes { + if let Some(val) = inner(*s) { + return val; + } + } + } + } + + Vect::Aligned(1) +} + +fn vectorization_reshape( + reshaped: &TensorIr, + original: &TensorIr, + multi_reads: bool, + axis: &VectorizationAxis, + vector_sizes: &[VectorSize], + max: VectorSize, + overrides: Option<&Vec>, +) -> Vect { + let axis = axis.get(reshaped.id, || reshaped.shape.rank() - 1); + let reshape_shape_axis = reshaped.shape[axis]; + + if !multi_reads && reshape_shape_axis == 1 { + return Vect::Broadcasted; + } + + // If the axis is not the last dim, didn't think of it, return Aligned(1) to be sure. + if axis != reshaped.shape.rank() - 1 { + return Vect::Aligned(1); + } + + let original_shape_axis = original.shape[original.shape.rank() - 1]; + + if original_shape_axis != reshape_shape_axis { + return Vect::Aligned(1); + } + + let inner = |s: VectorSize| { + if !multi_reads { + // The last dimension should be a multiple of the vector size or broadcated. + if reshape_shape_axis.is_multiple_of(s) && s <= max { + Some(Vect::Aligned(s)) + } else { + None + } + } else { + // Since the original tensor must share the same vectorization factor as the + // reshaped tensor, they must have compatible shapes when both are access + // independently. + if reshape_shape_axis.is_multiple_of(s) + && original_shape_axis.is_multiple_of(s) + && s <= max + { + Some(Vect::Aligned(s)) + } else { + None + } + } + }; + + match overrides { + Some(val) => { + for i in val { + if let Some(vect) = inner(*i) { + return vect; + } + } + } + None => { + for s in vector_sizes { + if let Some(vect) = inner(*s) { + return vect; + } + } + } + } + + Vect::Aligned(1) +} + +#[allow(clippy::too_many_arguments)] +fn vectorization_swapped( + handle: &CubeFusionHandle, + swapped: &TensorIr, + original: &TensorIr, + multi_reads: bool, + dims: &(usize, usize), + max: VectorSize, + axis: &VectorizationAxis, + vector_sizes: &[VectorSize], + overrides: Option<&Vec>, +) -> Vect { + let axis = axis.get(swapped.id, || swapped.shape.rank() - 1); + + let swapped_axis = swapped.shape[axis]; + let shape_axis = original.shape[axis]; + + let axis_index = axis; + let dim_index = if dims.0 == axis_index { + dims.1 + } else if dims.1 == axis_index { + dims.0 + } else { + axis_index + }; + + // Last dimension strides should be 1, otherwise vecX won't be contiguous. + if multi_reads { + if handle.strides[axis_index] != 1 { + return Vect::Aligned(1); + } + if handle.strides[dim_index] != 1 { + return Vect::Aligned(1); + } + } else if handle.strides[dim_index] != 1 { + return Vect::Aligned(1); + } + + if !multi_reads && swapped_axis == 1 { + return Vect::Broadcasted; + } + + // Reflect changes from https://github.com/tracel-ai/cubecl/pull/1312 + let mut next_stride = handle + .strides + .iter() + .enumerate() + .filter_map(|(i, &s)| (i != axis_index && s != 0).then_some(s)) + .min() + .unwrap_or(0); + + // Since when we calculate the vectorization of an input value, we will divide the + // vectorization by the num_quants, we need to adapt the stride check accordingly. + if let burn_std::DType::QFloat(quant_scheme) = handle.dtype { + next_stride *= quant_scheme.num_quants(); + }; + + let inner = |s: VectorSize| { + // The last dimension should be a multiple of the vector size or broadcated. + if multi_reads { + if swapped_axis.is_multiple_of(s) && next_stride % s == 0 && s <= max { + return Some(Vect::Aligned(s)); + } + } else if swapped_axis.is_multiple_of(s) + && shape_axis.is_multiple_of(s) + && next_stride % s == 0 + && s <= max + { + return Some(Vect::Aligned(s)); + } + None + }; + + match overrides { + Some(val) => { + for s in val { + if let Some(val) = inner(*s) { + return val; + } + } + } + None => { + for s in vector_sizes { + if let Some(val) = inner(*s) { + return val; + } + } + } + } + + Vect::Aligned(1) +} diff --git a/crates/burn-cubecl-fusion/src/engine/launch/vectorization/mod.rs b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/mod.rs new file mode 100644 index 0000000..e7de0b2 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod planner; + +pub use base::*; +pub use planner::*; diff --git a/crates/burn-cubecl-fusion/src/engine/launch/vectorization/planner.rs b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/planner.rs new file mode 100644 index 0000000..7d6d2be --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/launch/vectorization/planner.rs @@ -0,0 +1,450 @@ +use super::{ + super::{BlockPlan, HandleOutput, LaunchPlan}, + Vect, +}; +use crate::{ + CubeFusionHandle, + engine::{ + launch::{ + HandleInput, + runner::{Vectorization, VectorizationHandle}, + }, + settings::VectorizationSetting, + trace::{FuseResources, TensorView, block::FuseBlock}, + }, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_fusion::stream::Context; +use burn_ir::TensorId; +use cubecl::{ + Runtime, + client::ComputeClient, + ir::{ElemType, StorageType, UIntKind}, +}; +use cubecl::{ + ir::VectorSize, + quant::scheme::{QuantScheme, QuantStore, QuantValue}, +}; +use std::marker::PhantomData; + +/// Select the best vectorization factor for each tensor handle. +pub struct VectorizationPlanner<'a, R: Runtime> { + resources: &'a FuseResources, + blocks: &'a Vec, + _r: PhantomData, +} + +impl<'a, R: Runtime> VectorizationPlanner<'a, R> { + pub fn new(resources: &'a FuseResources, blocks: &'a Vec) -> Self { + Self { + resources, + blocks, + _r: PhantomData, + } + } + pub fn run>( + self, + client: &ComputeClient, + runner: &Runner, + context: &Context>, + plan: &mut LaunchPlan<'a, R>, + ) { + let has_multiple_read = |tensor: &TensorId| { + let mut read_count = 0; + for block in plan.blocks.iter() { + read_count += block.reads.get(tensor).map(|a| a.len()).unwrap_or(0); + } + read_count > 1 + }; + let tensors_reshaped = self.resources.views.iter().filter_map(|view| match view { + TensorView::Reshape { + reshaped, original, .. + } => Some(( + context.tensors.get(reshaped).unwrap(), + context.tensors.get(original).unwrap(), + has_multiple_read(original), + )), + TensorView::SwapDims { .. } => None, + }); + let tensors_swapped = self.resources.views.iter().filter_map(|view| match view { + TensorView::SwapDims { + swapped, + original, + dims, + .. + } => Some(( + context.tensors.get(swapped).unwrap(), + context.tensors.get(original).unwrap(), + has_multiple_read(original), + dims, + )), + TensorView::Reshape { .. } => None, + }); + + let mut ref_elem = (ElemType::UInt(UIntKind::U64).into(), 8); + let mut quants_vector_sizes: Option> = None; + + for input in plan.handle_inputs.iter() { + let elem: StorageType = match input { + HandleInput::Normal(h) => dtype_to_storage_type(h.global_ir.dtype), + HandleInput::QuantValues(handle) => match handle.global_ir.dtype { + burn_std::DType::QFloat(scheme) => { + vector_sizes_quants(client, &mut quants_vector_sizes, scheme); + continue; + } + _ => panic!("Unable to retrieve the scheme for quantized values."), + }, + HandleInput::QuantParams(..) => continue, + }; + let elem_size = elem.size(); + + if ref_elem.1 >= elem_size { + ref_elem = (elem, elem_size); + } + } + for r in plan.global_outputs.iter() { + let elem: StorageType = dtype_to_storage_type(r.dtype); + let elem_size = elem.size(); + + if ref_elem.1 >= elem_size { + ref_elem = (elem, elem_size); + } + } + + let filtered = plan + .handle_inputs + .iter() + .map(|item| { + item.as_normal() + // Filter out indexed resources. + .map(|item| !self.resources.indexed.contains_key(&item.relative_id)) + .unwrap_or(true) + }) + .collect::>(); + + let vector_sizes = match quants_vector_sizes { + // Quantization normally triggers higher vectorization than anything else, no need to + // compare to ref elem. + Some(vector_sizes) => vector_sizes, + None => client + .io_optimized_vector_sizes(ref_elem.0.size()) + .collect::>(), + }; + let vectorization_axis = runner.axis(plan); + + runner.vectorization( + context, + &mut plan.vectorizations, + plan.handle_inputs + .iter() + .enumerate() + .filter_map(|(i, item)| { + if filtered[i] { + Some(match item { + HandleInput::Normal(h) => { + VectorizationHandle::NormalInput(&h.handle, &h.global_ir) + } + HandleInput::QuantValues(h) => { + VectorizationHandle::QuantValues(&h.handle, &h.global_ir) + } + HandleInput::QuantParams(_) => VectorizationHandle::QuantParams, + }) + } else { + None + } + }), + plan.global_outputs.iter(), + tensors_reshaped, + tensors_swapped, + &vector_sizes, + u8::MAX as usize, + vectorization_axis, + ); + + for tensor in self.resources.indexed.keys() { + let global = context.tensors.get(tensor).unwrap(); + plan.vectorizations.insert(global.id, Vect::Aligned(1)); + } + + let mut block_vectorization = Vec::with_capacity(self.blocks.len()); + for _ in 0..self.blocks.len() { + block_vectorization.push(Vec::new()); + } + + for (input_pos, handle) in plan.handle_inputs.iter_mut().enumerate() { + let (global_ir, relative_id) = match handle { + HandleInput::Normal(h) => (&h.global_ir, &h.relative_id), + HandleInput::QuantValues(h) => (&h.global_ir, &h.relative_id), + HandleInput::QuantParams(_) => continue, + }; + let (vect, br) = match plan.vectorizations.get(&global_ir.id) { + Some(v) => (v.vector_size(), v.is_broadcast()), + None => panic!("No vectorization factor found for {:?}", global_ir.id), + }; + + for (block_pos, block_plan) in plan.blocks.iter().enumerate() { + if block_plan.reads.contains_key(relative_id) { + block_vectorization[block_pos].push(BlockVectorization { + action: VectorizationAction::Input(input_pos), + potential: vect, + broadcasted: br, + }); + } + } + } + + for (output_pos, handle) in plan.handle_outputs.iter().enumerate() { + if let HandleOutput::Owned { + global_id, + relative_id, + .. + } = handle + { + for (block_pos, block_plan) in plan.blocks.iter().enumerate() { + if block_plan.writes.contains_key(relative_id) { + let vectorization = + plan.vectorizations.get(global_id).unwrap().vector_size(); + block_vectorization[block_pos].push(BlockVectorization { + action: VectorizationAction::Output(output_pos), + potential: vectorization, + broadcasted: false, + }); + } + } + } + } + + let mut previous_widths = Vec::with_capacity(block_vectorization.len()); + + // Unhandled inputs might not get included in any fused blocks for now. + // + // So we ensure they are vectorized by setting their vectorization before we set the + // vectorizations in blocks. + // + // Unhandled Outputs are correctly vectorized, so this is only necessary for inputs. + for input in self.resources.inputs_unhandled.iter() { + let pos = self + .resources + .inputs + .get_index(*input) + .unwrap_or_else(|| self.resources.inputs.get_index_quant(*input).unwrap()); + let input_global = context.tensors.get(input).unwrap(); + + match plan.vectorizations.get(&input_global.id).unwrap() { + Vect::Aligned(vect) => { + let handle = &mut plan.handle_inputs[pos]; + match handle { + HandleInput::Normal(handle) => { + handle.vector_size = *vect; + } + HandleInput::QuantValues(handle) => { + handle.vector_size = *vect; + } + HandleInput::QuantParams(_) => {} + } + } + Vect::Broadcasted => {} + } + } + + for ((tmp, block_plan), block) in block_vectorization + .into_iter() + .zip(plan.blocks.iter_mut()) + .zip(self.blocks) + { + match block.settings.vectorization { + VectorizationSetting::Activated => { + apply_vectorization_block( + tmp, + &mut plan.handle_inputs, + &mut plan.handle_outputs, + block_plan, + u8::MAX as usize, + ); + } + VectorizationSetting::SmallerOrEqualThanPreviousBlock { block_pos } => { + apply_vectorization_block( + tmp, + &mut plan.handle_inputs, + &mut plan.handle_outputs, + block_plan, + previous_widths[block_pos], + ); + if block_plan.width == 0 { + block_plan.width = previous_widths[block_pos]; + } + } + VectorizationSetting::EqualThanPreviousBlock { block_pos } => { + apply_vectorization_block( + tmp, + &mut plan.handle_inputs, + &mut plan.handle_outputs, + block_plan, + previous_widths[block_pos], + ); + // Enforces the width. + block_plan.width = previous_widths[block_pos]; + } + VectorizationSetting::Deactivated => { + apply_vectorization_block( + tmp, + &mut plan.handle_inputs, + &mut plan.handle_outputs, + block_plan, + 1, + ); + block_plan.width = 1; + } + } + + // When only virtual inputs/outputs are present for a block, we need to set a width. + if block_plan.width == 0 { + if let Some(w) = previous_widths.last() { + block_plan.width = *w; + } else { + block_plan.width = 1; + } + } + + previous_widths.push(block_plan.width); + } + } +} + +#[derive(Debug)] +enum VectorizationAction { + Input(usize), + Output(usize), +} + +#[derive(Debug)] +struct BlockVectorization { + action: VectorizationAction, + potential: VectorSize, + broadcasted: bool, +} + +fn apply_vectorization_block( + block_vectorization: Vec, + inputs: &mut [HandleInput], + outputs: &mut [HandleOutput], + block_plan: &mut BlockPlan, + max: VectorSize, +) { + for item in block_vectorization { + match item.action { + VectorizationAction::Input(pos) => { + let (vect, br) = if item.potential <= max { + (item.potential, item.broadcasted) + } else { + (1, false) + }; + + match &mut inputs[pos] { + HandleInput::Normal(input) => { + input.vector_size = vect; + input.broadcated = br; + } + HandleInput::QuantValues(input) => { + input.vector_size = vect; + } + HandleInput::QuantParams(_) => { + // Not vectorized + } + } + + if block_plan.width < vect { + block_plan.width = vect; + } + } + VectorizationAction::Output(pos) => { + if let HandleOutput::Owned { vectorization, .. } = &mut outputs[pos] { + let vect = if item.potential <= max { + item.potential + } else { + 1 + }; + *vectorization = vect; + + if block_plan.width < vect { + block_plan.width = vect; + } + } + } + } + } +} + +fn vector_sizes_quants( + client: &ComputeClient, + quants_vector_sizes: &mut Option>, + scheme: QuantScheme, +) { + match scheme.store { + QuantStore::Native => match scheme.value { + // Type sizes are the same so just treat fp8/fp4x2 as i8 + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::E4M3 + | QuantValue::E5M2 + | QuantValue::E2M1 => { + let vector_sizes = client + .io_optimized_vector_sizes(size_of::()) + .collect::>(); + + match &quants_vector_sizes { + Some(sizes) => { + if sizes[0] < vector_sizes[0] { + *quants_vector_sizes = Some(vector_sizes); + } + } + None => { + *quants_vector_sizes = Some(vector_sizes); + } + } + } + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S => { + unreachable!("Can't store native sub-byte values") + } + }, + QuantStore::PackedU32(_) => { + let mut vector_sizes = client + .io_optimized_vector_sizes(size_of::()) + .collect::>(); + + for val in vector_sizes.iter_mut() { + *val *= scheme.num_quants(); + } + + let min = *vector_sizes.last().unwrap(); + + // We need to put back values that are not multiple of num_quants, but may be good + // vectorization factor for other handles in a fused trace. + for val in client.io_optimized_vector_sizes(size_of::()) { + if val < min { + vector_sizes.push(val); + } + } + + match &quants_vector_sizes { + Some(sizes) => { + if sizes[0] < vector_sizes[0] { + let mut min = *vector_sizes.last().unwrap(); + + while min > 1 { + min /= 2; + vector_sizes.push(min); + } + *quants_vector_sizes = Some(vector_sizes); + } + } + None => { + *quants_vector_sizes = Some(vector_sizes); + } + } + } + QuantStore::PackedNative(_) => { + panic!("Not yet supported") + } + }; +} diff --git a/crates/burn-cubecl-fusion/src/engine/mod.rs b/crates/burn-cubecl-fusion/src/engine/mod.rs new file mode 100644 index 0000000..304adef --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod codegen; +pub(crate) mod fuser; +pub(crate) mod launch; +pub(crate) mod scoring; +pub(crate) mod settings; + +pub mod trace; diff --git a/crates/burn-cubecl-fusion/src/engine/scoring.rs b/crates/burn-cubecl-fusion/src/engine/scoring.rs new file mode 100644 index 0000000..8270d27 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/scoring.rs @@ -0,0 +1,162 @@ +use crate::engine::{ + codegen::ir::{FuseArg, FuseOp, UnaryFuseArgs}, + trace::FuseTrace, +}; +use burn_ir::OperationIr; + +#[derive(Debug, Clone, Default)] +/// Tracks and evaluates the efficiency of operation fusion. +pub struct Scoring { + num_writes: usize, + num_reads: usize, + num_ops: usize, +} + +impl Scoring { + /// Resets the internal O counters. + pub fn reset(&mut self) { + self.num_writes = 0; + self.num_reads = 0; + self.num_ops = 0; + } + + /// Registers an unfused operation to the score, counting its total potential I/O. + pub fn register(&mut self, op: &OperationIr) { + self.num_writes += op.outputs().count(); + self.num_reads += op.inputs().count(); + self.num_ops += 1; + } + + /// Evaluates the efficiency of a fused trace by comparing its actual I/O + /// against the registered unfused I/O. Returns the number of saved I/O operations. + pub fn evaluate(&self, trace: &FuseTrace) -> u64 { + let mut num_reads_fused = 0; + let mut num_writes_fused = 0; + let mut num_penalty = 0; + + for b in trace.blocks.iter() { + // Count reads in block + for ops in b.reads.values() { + let result = self.count_fused_io(ops, |args| &args.input); + num_reads_fused += result.0; + num_penalty += result.1; + } + // Count writes in block + for ops in b.writes.values() { + let result = self.count_fused_io(ops, |args| &args.out); + num_writes_fused += result.0; + num_penalty += result.1; + } + } + + self.calculate_score(num_reads_fused, num_writes_fused, num_penalty) + } + + fn calculate_score(&self, reads_fused: usize, writes_fused: usize, num_penalty: usize) -> u64 { + // Those could be tweaked eventually. + + const FACTOR_IO: u64 = 100; + const FACTOR_LAUNCH: u64 = 10; + const FACTOR_PENALTY: u64 = 50; + + let num_fused = reads_fused + writes_fused; + let num_unfused = self.num_reads + self.num_writes; + + let score_io = match num_fused >= num_unfused { + true => 0, + false => (num_unfused - num_fused) as u64 * FACTOR_IO, + }; + + // We minus 1 since at least one kernel launch is necessary. + let score_launch = self.num_ops.saturating_sub(1) as u64 * FACTOR_LAUNCH; + + let score_penalty = num_penalty as u64 * FACTOR_PENALTY; + + (score_io + score_launch).saturating_sub(score_penalty) + } + + fn count_fused_io(&self, ops: &[FuseOp], arg_extractor: F) -> (usize, usize) + where + F: Fn(&UnaryFuseArgs) -> &FuseArg, + { + let mut num_io = 0; + let mut penalty = 0; + + for op in ops.iter() { + let FuseOp::Assign(args) = op else { + unreachable!() + }; + let count_normal = matches!( + arg_extractor(args), + FuseArg::Input(..) | FuseArg::Output(..) + ) as usize; + let count_view = matches!( + arg_extractor(args), + FuseArg::InputReshaped { .. } | FuseArg::InputSwapDims { .. } + ) as usize; + num_io += count_normal + count_view; + penalty += count_view; + } + + (num_io, penalty) + } +} + +#[cfg(test)] +#[allow(clippy::field_reassign_with_default)] +mod tests { + use super::*; + + #[test] + fn test_scoring_io_savings() { + let mut scoring = Scoring::default(); + scoring.num_reads = 2; + scoring.num_writes = 2; + scoring.num_ops = 2; + + let score = scoring.calculate_score(1, 1, 0); + assert_eq!(score, 210); + } + + #[test] + fn test_scoring_with_penalties() { + let mut scoring = Scoring::default(); + scoring.num_reads = 2; + scoring.num_writes = 2; + scoring.num_ops = 2; + + let score = scoring.calculate_score(1, 1, 1); + assert_eq!(score, 160); + } + + #[test] + fn test_penalty_outweighs_benefit() { + let mut scoring = Scoring::default(); + scoring.num_reads = 1; + scoring.num_writes = 1; + scoring.num_ops = 2; + + let score = scoring.calculate_score(1, 1, 1); + assert_eq!(score, 0); + } + + #[test] + fn test_scoring_no_ops() { + let scoring = Scoring::default(); + let score = scoring.calculate_score(0, 0, 0); + assert_eq!(score, 0); + } + + #[test] + fn test_reset() { + let mut scoring = Scoring { + num_writes: 10, + num_reads: 10, + num_ops: 10, + }; + scoring.reset(); + assert_eq!(scoring.num_writes, 0); + assert_eq!(scoring.num_reads, 0); + assert_eq!(scoring.num_ops, 0); + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/settings.rs b/crates/burn-cubecl-fusion/src/engine/settings.rs new file mode 100644 index 0000000..8d0bb25 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/settings.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// Controls which operations can be fused. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct FuseSettings { + /// Enables broadcasting of shapes. + pub broadcast: bool, + /// Enables output shape updates. + /// + /// When broadcast is enabled, the output shape can become bigger after a fusion, + /// therefore an update is needed. + pub output_shape_updates: bool, + /// Enables the reuse of input buffers. + pub inplace: bool, + /// Whether vectorization is enabled. + pub vectorization: VectorizationSetting, + /// How [reference layout](super::ir::RefLayout) selection is done. + pub ref_layout: RefLayoutSetting, +} + +impl Default for FuseSettings { + fn default() -> Self { + Self { + broadcast: true, + output_shape_updates: true, + inplace: true, + vectorization: VectorizationSetting::Activated, + ref_layout: RefLayoutSetting::Any, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +/// How vectorization is handled during fusion. +pub enum VectorizationSetting { + /// The biggest vector_size possible will be used. + Activated, + /// Equivalent to using vector_size of one. + Deactivated, + /// This is a good setting when a block processes values calculated from a previous block. + SmallerOrEqualThanPreviousBlock { block_pos: usize }, + /// This is a good setting when a block processes values calculated from a previous block. + EqualThanPreviousBlock { block_pos: usize }, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +/// Influence how the [reference layout](super::ir::RefLayout) selection is done. +pub enum RefLayoutSetting { + /// Any reference layout is allowed. + Any, + /// Only contiguous reference layout is allowed. + /// + /// Note that forcing a contiguous reference layout might reduce the opportunity of inplace + /// fusion. + OnlyContiguous, + SameAsBlock { + block_pos: u32, + }, +} diff --git a/crates/burn-cubecl-fusion/src/engine/trace/base.rs b/crates/burn-cubecl-fusion/src/engine/trace/base.rs new file mode 100644 index 0000000..d9fced8 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/trace/base.rs @@ -0,0 +1,377 @@ +use crate::engine::{ + codegen::ir::{FuseArg, FuseType}, + trace::block::FuseBlock, +}; +use burn_ir::{TensorId, TensorIr}; +use burn_std::{Shape, Strides}; +use cubecl::prelude::*; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashSet}, + marker::PhantomData, +}; + +#[cfg(feature = "autotune-checks")] +use crate::CubeFusionHandle; +#[cfg(feature = "autotune-checks")] +use burn_backend::TensorData; +#[cfg(feature = "autotune-checks")] +use burn_backend::cubecl::dtype_to_storage_type; +#[cfg(feature = "autotune-checks")] +use std::collections::HashMap; + +#[derive(Clone, Serialize, Deserialize, Debug)] +/// A trace contains all [blocks](FuseBlock) and the [resources](FuseResources) used by the +/// kernel. +pub struct FuseTrace { + pub blocks: Vec, + pub resources: FuseResources, +} + +impl core::fmt::Display for FuseTrace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "FuseTrace")?; + for b in self.blocks.iter() { + writeln!(f, " - Block shape={:?}", b.shape_ref)?; + for (tensor, ops) in b.reads.iter() { + for op in ops.iter() { + writeln!(f, " - {op} <== {tensor}")?; + } + } + for op in b.ops.iter() { + writeln!(f, " - {op}")?; + } + for (tensor, ops) in b.writes.iter() { + for op in ops.iter() { + writeln!(f, " - {op} <== {tensor}")?; + } + } + } + + Ok(()) + } +} + +pub enum TuneOutput { + UnChecked(PhantomData), + #[cfg(feature = "autotune-checks")] + Checked { + handles: HashMap)>, + }, +} + +impl TuneOutput { + #[allow(unused_variables)] + pub fn merge(self, other: Self) -> Self { + let mut result = self; + + match &mut result { + TuneOutput::UnChecked(..) => {} + #[cfg(feature = "autotune-checks")] + TuneOutput::Checked { handles } => match other { + TuneOutput::UnChecked(..) => {} + TuneOutput::Checked { handles: o } => { + for (k, v) in o.into_iter() { + handles.insert(k, v); + } + } + }, + } + + result + } +} + +impl cubecl::tune::AutotuneOutput for TuneOutput { + #[cfg(feature = "autotune-checks")] + fn check_equivalence(&self, other: Self) { + use burn_backend::Tolerance; + use burn_std::DType; + + if let ( + TuneOutput::Checked { + handles: handles_ref, + }, + TuneOutput::Checked { handles }, + ) = (self, &other) + { + let mut num_checked = 0; + let mut num_handles = 0; + for (id, (shape, handle)) in handles_ref.iter() { + num_handles += 1; + if let Some((shape_other, other)) = handles.get(id) { + use burn_std::is_contiguous; + use cubecl::std::tensor::into_contiguous; + + let current_handle = if !is_contiguous(shape, &handle.strides) { + into_contiguous::( + &handle.client, + handle.clone().binding(shape.clone()), + dtype_to_storage_type(handle.dtype), + ) + .handle + } else { + handle.handle.clone() + }; + let other_handle = if !is_contiguous(shape, &other.strides) { + into_contiguous::( + &other.client, + other.clone().binding(shape.clone()), + dtype_to_storage_type(other.dtype), + ) + .handle + } else { + other.handle.clone() + }; + + let data_ref = handle.client.read_one(current_handle).unwrap(); + let data_other = other.client.read_one(other_handle).unwrap(); + let data_ref = TensorData::from_bytes(data_ref, shape.clone(), handle.dtype); + let data_other = + TensorData::from_bytes(data_other, shape_other.clone(), handle.dtype); + + match handle.dtype { + DType::F64 => { + data_ref.assert_approx_eq::(&data_other, Tolerance::permissive()) + } + DType::F32 => { + data_ref.assert_approx_eq::(&data_other, Tolerance::permissive()) + } + DType::F16 => data_ref + .assert_approx_eq::(&data_other, Tolerance::permissive()), + DType::BF16 => data_ref + .assert_approx_eq::(&data_other, Tolerance::permissive()), + _ => data_ref.assert_eq(&data_other, true), + } + num_checked += 1; + } else { + // Debug info for the tests. + println!("No tensor found for {id:?}=>{shape:?}"); + } + } + + // At least one check is needed per output when there is an output. + // + // Some optimizations might write more outputs than needed, so it might be fined if + // the number of handles is different, but at least one is required. + // + // An optimization might not create outputs if its dead code detection is triggered, + // therefore avoiding useless computation. + if num_handles > 0 { + assert!(num_checked >= 1); + } + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +/// Declare all resources used by the kernel, and potentially multiple [blocks](FuseBlock). +/// +/// # Notes +/// +/// Each block can't contain their own resources, since they are shared between blocks. The +/// vectorization factor of one input tensor must be the same for all blocks. +pub struct FuseResources { + pub outputs: RegisteredTensors, + pub inputs: RegisteredTensors, + pub scalars: Vec<(FuseType, u64)>, + // TODO: Making put a map of global registers. + pub views: Vec, + pub indexed: BTreeMap, + pub inputs_unhandled: Vec, + pub outputs_unhandled: Vec, + pub num_reshaped: usize, + /// Necessary to remove some entries from the context. + pub dropped: HashSet, + /// We know during fusion that we have to have those buffers has global. + /// The pos here can be interpreted as GLOBAL pos where the output pos are locals. + pub buffers: RegisteredTensors, + /// Global registers available everywhere. + /// + /// TODO: Not all registers should be globals. + pub registers: BTreeMap, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct RuntimeLayout { + pub shape: Shape, + pub strides: Strides, +} + +impl Default for RuntimeLayout { + fn default() -> Self { + Self { + shape: Shape::new([]), + strides: Strides::new(&[]), + } + } +} + +#[derive(Debug)] +pub enum TraceError { + ReferenceNotFound, + RunnerError(Err), +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum TensorView { + Reshape { + reshaped: TensorId, + original: TensorId, + reshape_pos: usize, + shape_relative: Shape, + }, + SwapDims { + swapped: TensorId, + original: TensorId, + dims: (usize, usize), + }, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug)] +pub struct RegisteredTensors { + tensors: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum RegisterTensor { + Normal(TensorIr, FuseType), + QuantValues(TensorIr), + QuantParams(TensorId), +} + +impl RegisterTensor { + pub fn as_normal_tensor(&self) -> Option<(&TensorIr, &FuseType)> { + match self { + RegisterTensor::Normal(tensor_ir, precision) => Some((tensor_ir, precision)), + RegisterTensor::QuantValues(_) => None, + RegisterTensor::QuantParams(_) => None, + } + } +} + +impl RegisteredTensors { + /// Iterate over all the registered tensors. + pub fn iter(&self) -> impl Iterator { + self.tensors.iter() + } + + /// Consumes and iterate over all the registered tensors. + pub fn into_iter(self) -> impl Iterator { + self.tensors.into_iter() + } + + /// Returns the number of tensors registered. + pub fn len(&self) -> usize { + self.tensors.len() + } + + /// Retrieve the [tensor id](TensorId) at the given index. + pub fn get_id(&self, index: usize) -> Option { + self.tensors.get(index).map(|entry| match entry { + RegisterTensor::Normal(tensor_ir, _) => tensor_ir.id, + RegisterTensor::QuantValues(tensor_ir) => tensor_ir.id, + RegisterTensor::QuantParams(tensor_id) => *tensor_id, + }) + } + + /// Doesn't return quantized tensor. + pub fn get_index(&self, tensor_id: TensorId) -> Option { + self.tensors + .iter() + .enumerate() + .find(|(_pos, entry)| match entry { + RegisterTensor::Normal(tensor_ir, _) => tensor_ir.id == tensor_id, + RegisterTensor::QuantValues(_) => false, + RegisterTensor::QuantParams(_) => false, + }) + .map(|(pos, _)| pos) + } + + /// Get the index of a quantized tensor. + pub fn get_index_quant(&self, tensor_id: TensorId) -> Option { + self.tensors + .iter() + .enumerate() + .find(|(_pos, entry)| match entry { + RegisterTensor::Normal(..) => false, + RegisterTensor::QuantValues(tensor_ir) => tensor_ir.id == tensor_id, + RegisterTensor::QuantParams(_) => false, + }) + .map(|(pos, _)| pos) + } + + /// Doesn't return quantized tensor. + pub fn get(&self, tensor_id: TensorId) -> Option<(&TensorIr, &FuseType)> { + self.tensors + .iter() + .find(|entry| match entry { + RegisterTensor::Normal(tensor_ir, _) => tensor_ir.id == tensor_id, + RegisterTensor::QuantValues(_) => false, + RegisterTensor::QuantParams(_) => false, + }) + .and_then(|entry| match entry { + RegisterTensor::Normal(tensor_ir, fuse_precision) => { + Some((tensor_ir, fuse_precision)) + } + RegisterTensor::QuantValues(_) => None, + RegisterTensor::QuantParams(_) => None, + }) + } + + /// Insert a quantized tensor. + /// + /// It will return the positions for both the value tensor and param tensor. + pub fn insert_quant(&mut self, tensor: TensorIr) -> (usize, usize) { + if let Some(old) = self.tensors.iter().enumerate().find(|(_, val)| match &val { + RegisterTensor::QuantValues(tensor_ir) => tensor_ir == &tensor, + _ => false, + }) { + let values = old.0; + let params = values + 1; + return (values, params); + } + + let params = RegisterTensor::QuantParams(tensor.id); + let values = RegisterTensor::QuantValues(tensor); + let pos_values = self.len(); + self.tensors.push(values); + + let pos_params = self.len(); + self.tensors.push(params); + + (pos_values, pos_params) + } + + /// Insert a normal tensor with the given [precision](FusePrecision) in the current block. + pub fn insert(&mut self, precision: FuseType, tensor: TensorIr) -> usize { + if let Some(old) = self.tensors.iter().enumerate().find(|(_, val)| match &val { + RegisterTensor::Normal(tensor_ir, _) => tensor_ir.id == tensor.id, + _ => false, + }) { + return old.0; + } + + let value = RegisterTensor::Normal(tensor, precision); + let pos = self.len(); + + self.tensors.push(value); + + pos + } + + /// Update the already registered tensor with the given [tensor ir](TensorIr). + /// + /// # Notes + /// + /// This function only works with normal tensors, not quantized tensors. + pub fn update(&mut self, tensor: &TensorIr) { + if let Some(entry) = self.tensors.iter_mut().find(|entry| match entry { + RegisterTensor::Normal(tensor_ir, _) => tensor_ir.id == tensor.id, + _ => false, + }) && let RegisterTensor::Normal(tensor_ir, _) = entry + { + tensor_ir.status = tensor.status + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/trace/block.rs b/crates/burn-cubecl-fusion/src/engine/trace/block.rs new file mode 100644 index 0000000..663aa13 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/trace/block.rs @@ -0,0 +1,541 @@ +use super::{FuseResources, RegisteredTensors, TensorView}; +use crate::engine::{ + codegen::ir::{FuseArg, FuseOp, FuseType, LayoutInfo, MultiBlockPos, UnaryFuseArgs}, + settings::FuseSettings, +}; +use burn_ir::{TensorId, TensorIr, TensorStatus}; +use burn_std::{DType, Shape, quantization::QuantParam}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, btree_map::Entry}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +/// A block containing all [operations](FuseOp) as well as reads and writes for each tensor along +/// with the [fusion settings](FuseSettings). +pub struct FuseBlock { + /// Contains the [fusion settings](FuseSettings) associated to the current block. + pub settings: FuseSettings, + /// Contains all the [operations](FuseOp) registered in the current block. + pub ops: Vec, + /// The reference shape of the current block. + pub shape_ref: Shape, + /// Contains all tensor inputs of the current block except for manually handled tensors. + /// + /// # Notes + /// + /// Some reads might not have read operations registered, such as dequantization, but it's + /// important to be registered here for vectorization. Input tensors that are not + /// registered here must be vectorized manually. + pub reads: BTreeMap>, + /// Contains all tensor outputs of the current block except for manually handled tensors. + /// We can have multiple writes when the same variable is reused after in another block. + pub writes: BTreeMap>, +} + +#[derive(Clone, Debug)] +/// It is responsible to build a [trace](FuseBlock). +pub struct FuseBlockBuilder { + pub settings: FuseSettings, + locals: LocalVariablePool, + pub ops: Vec, + reads: BTreeMap>, + // Only for global registers. + writes: BTreeMap>, + // Output declared in this block alone. + outputs: RegisteredTensors, + pub outputs_unhandled: Vec, + pub local_inputs: BTreeMap, + /// The reference shape used by this block. + pub shape_ref: Shape, +} + +#[derive(Debug)] +/// How a quantized input can be read. +pub enum QuantInput { + /// If already dequantized, we cache the dequantization and returns the local variable + /// corresponding to the float value. + AlreadyDequantized { local: FuseArg }, + /// Otherwise we return the information necessary to dequantize the tensor. + Quantized { values: FuseArg, params: FuseArg }, +} + +impl FuseBlockBuilder { + pub fn new(settings: FuseSettings) -> Self { + Self { + settings, + locals: Default::default(), + ops: Default::default(), + reads: Default::default(), + writes: Default::default(), + outputs: Default::default(), + outputs_unhandled: Default::default(), + local_inputs: Default::default(), + shape_ref: Shape::new([]), + } + } + + /// Register an output tensor. + pub fn output(&mut self, tensor: &TensorIr, resources: &mut FuseResources) -> Option { + if resources.indexed.contains_key(&tensor.id) { + return None; + } + if matches!(tensor.dtype, DType::QFloat(..)) { + return None; + } + let precision = tensor.dtype.into(); + + let out = match self.locals.get(precision, tensor.id) { + Some(local) => local, + None => { + let out = self.locals.create(precision, tensor.id); + + self.outputs.insert(precision, tensor.clone()); + resources.outputs.insert(precision, tensor.clone()); + + out + } + }; + + Some(out) + } + + /// Register an input tensor. + pub fn multi_block_variable( + &mut self, + block_pos: usize, + tensor: &TensorIr, + global: bool, + ) -> Option { + let precision = tensor.dtype.into(); + + if let Some(val) = self.local_inputs.get(&tensor.id) { + return Some(val.clone()); + } + + let val = match self.locals.get(precision, tensor.id) { + Some(val) => val, + None => { + return None; + } + }; + + let arg = if global { + FuseArg::MultiBlockGlobal( + MultiBlockPos { + block_pos, + block_local_pos: self.writes.len(), + }, + val.precision(), + ) + } else { + FuseArg::MultiBlockLocal( + MultiBlockPos { + block_pos, + block_local_pos: self.writes.len(), + }, + val.precision(), + ) + }; + + let ops = match self.writes.get_mut(&tensor.id) { + Some(ops) => ops, + None => { + self.writes.insert(tensor.id, Vec::new()); + self.writes.get_mut(&tensor.id).unwrap() + } + }; + ops.push(FuseOp::Assign(UnaryFuseArgs { + input: val, + out: arg.clone(), + })); + + Some(arg) + } + + /// Register an input tensor. + pub fn input(&mut self, tensor: &TensorIr, resources: &mut FuseResources) -> Option { + if resources.indexed.contains_key(&tensor.id) { + return None; + } + + if matches!(tensor.dtype, DType::QFloat(..)) { + return None; + } + let precision = tensor.dtype.into(); + + if let Some(val) = self.local_inputs.get(&tensor.id) { + return Some(val.clone()); + } + + let arg = match self.locals.get(precision, tensor.id) { + Some(local) => { + resources.inputs.update(tensor); + + local + } + None => { + let input = if resources.outputs.get_index(tensor.id).is_some() { + if let Some(val) = resources.registers.get(&tensor.id) { + return Some(val.clone()); + }; + + let pos = resources.buffers.insert(precision, tensor.clone()); + FuseArg::Output(pos, precision, LayoutInfo::Unknown) + } else { + let pos = resources.inputs.insert(precision, tensor.clone()); + FuseArg::Input(pos, precision, LayoutInfo::Unknown) + }; + + let out = self.locals.create(precision, tensor.id); + + let reads = if let Entry::Vacant(e) = self.reads.entry(tensor.id) { + e.insert(Vec::with_capacity(1)); + self.reads.get_mut(&tensor.id).unwrap() + } else { + self.reads.get_mut(&tensor.id).unwrap() + }; + + reads.push(FuseOp::Assign(UnaryFuseArgs { + input, + out: out.clone(), + })); + + out + } + }; + + Some(arg) + } + + /// Register an input quantized tensor. + pub fn input_quant( + &mut self, + tensor: &TensorIr, + resources: &mut FuseResources, + ) -> Option { + if resources.indexed.contains_key(&tensor.id) { + return None; + } + + let precision = tensor.dtype.into(); + let precision_scales = match tensor.dtype { + DType::QFloat(scheme) => match scheme.param { + QuantParam::F32 => FuseType::F32, + QuantParam::F16 => FuseType::F16, + QuantParam::BF16 => FuseType::BF16, + QuantParam::UE8M0 | QuantParam::UE4M3 => { + unimplemented!("Unsupported fuse precision"); + } + }, + _ => return None, + }; + + let arg = match self.locals.get(precision, tensor.id) { + Some(local) => { + resources.inputs.update(tensor); + QuantInput::AlreadyDequantized { local } + } + None => { + let (new_input, q_index) = resources.inputs.insert_quant(tensor.clone()); + let input = FuseArg::Input(new_input, precision, LayoutInfo::Unknown); + let scales = FuseArg::Input(q_index, precision_scales, LayoutInfo::Unknown); + + // Important to flag that there is a read, even if no operation is registered. + if let Entry::Vacant(e) = self.reads.entry(tensor.id) { + e.insert(Vec::new()); + }; + + QuantInput::Quantized { + values: input, + params: scales, + } + } + }; + + Some(arg) + } + + /// Register an input with swapped dims. + pub fn input_swap_dims( + &mut self, + tensor: &TensorIr, + output: &TensorIr, + dims: (usize, usize), + resources: &mut FuseResources, + ) -> Option { + if matches!(tensor.dtype, DType::QFloat(..)) { + return None; + } + let precision = tensor.dtype.into(); + + let input_index = match self.locals.get(precision, tensor.id) { + Some(_) => { + // Can't fused an already fused input. + if resources.outputs.get(tensor.id).is_some() { + return None; + } + + match resources.inputs.get_index(tensor.id) { + Some(index) => { + resources.inputs.update(tensor); + index + } + None => { + return None; + } + } + } + // First appearance of the original: fusing a single-use view isn't worth it. The + // reshape/slice/swap_dims executes eagerly as a cheap shape/stride update and the result + // is then read with a clean, vectorizable layout. Fusing would instead recompute the view + // index on every read and can break vectorization. We only fuse a view when the original + // is already used elsewhere (the `Some` arm), i.e. when we genuinely need it in multiple + // layouts. + None => return None, + }; + + let out = self.output(output, resources)?; + let original = FuseArg::Input(input_index, precision, LayoutInfo::Unknown); + + let broadcasted = output.shape[output.shape.rank() - 1] == 0; + + resources.views.push(TensorView::SwapDims { + swapped: output.id, + original: tensor.id, + dims, + }); + + let input = FuseArg::InputSwapDims { + original: Box::new(original), + dims, + broadcasted, + }; + + let reads = if let Entry::Vacant(e) = self.reads.entry(tensor.id) { + e.insert(Vec::with_capacity(1)); + self.reads.get_mut(&tensor.id).unwrap() + } else { + self.reads.get_mut(&tensor.id).unwrap() + }; + + reads.push(FuseOp::Assign(UnaryFuseArgs { + input, + out: out.clone(), + })); + + Some(out) + } + + /// Register an input that is reshaped. + pub fn input_reshaped( + &mut self, + tensor: &TensorIr, + output: &TensorIr, + resources: &mut FuseResources, + ) -> Option { + if matches!(tensor.dtype, DType::QFloat(..)) { + return None; + } + let precision = tensor.dtype.into(); + + let input_index = match self.locals.get(precision, tensor.id) { + Some(_) => { + // Can't fused an already fused input. + if resources.outputs.get(tensor.id).is_some() { + return None; + } + + match resources.inputs.get_index(tensor.id) { + Some(index) => { + resources.inputs.update(tensor); + index + } + None => { + return None; + } + } + } + // First appearance of the original: fusing a single-use view isn't worth it. The + // reshape/slice/swap_dims executes eagerly as a cheap shape/stride update and the result + // is then read with a clean, vectorizable layout. Fusing would instead recompute the view + // index on every read and can break vectorization. We only fuse a view when the original + // is already used elsewhere (the `Some` arm), i.e. when we genuinely need it in multiple + // layouts. + None => return None, + }; + + let out = self.output(output, resources)?; + let original = FuseArg::Input(input_index, precision, LayoutInfo::Unknown); + + let mut shape = Vec::new(); + + let index = resources.num_reshaped; + resources.num_reshaped += 1; + + let rank = output.shape.rank(); + + for i in 0..output.shape.rank() { + let id = index * rank + i; + shape.push(FuseArg::ScalarShape(id)); + } + + resources.views.push(TensorView::Reshape { + reshaped: output.id, + original: tensor.id, + reshape_pos: index, + shape_relative: output.shape.clone(), + }); + + let input = FuseArg::InputReshaped { + original: Box::new(original), + shape, + broadcasted: output.shape[rank - 1] == 0, + }; + + let reads = if let Entry::Vacant(e) = self.reads.entry(tensor.id) { + e.insert(Vec::with_capacity(1)); + self.reads.get_mut(&tensor.id).unwrap() + } else { + self.reads.get_mut(&tensor.id).unwrap() + }; + + reads.push(FuseOp::Assign(UnaryFuseArgs { + input, + out: out.clone(), + })); + + Some(out) + } + + /// Build into a fuse block. + pub fn build( + &self, + resources: &FuseResources, + outputs: &mut RegisteredTensors, + buffers: &mut Vec, + ) -> FuseBlock { + let ops = self.ops.clone(); + let reads = self.reads.clone(); + let tensor_writes = self.tensor_writes(resources, buffers); + + let mut writes = self.writes.clone(); + + for (tensor, precision) in tensor_writes + .iter() + .filter_map(|entry| entry.as_normal_tensor()) + { + if let Some(local) = self.locals.get_any_precision(tensor.id) { + let out_index = outputs.insert(*precision, tensor.clone()); + + let ops = match writes.get_mut(&tensor.id) { + Some(ops) => ops, + None => { + writes.insert(tensor.id, Vec::new()); + writes.get_mut(&tensor.id).unwrap() + } + }; + + ops.push(FuseOp::Assign(UnaryFuseArgs { + input: local, + out: FuseArg::Output(out_index, *precision, LayoutInfo::Unknown), + })); + } + } + + FuseBlock { + settings: self.settings, + ops, + shape_ref: self.shape_ref.clone(), + reads, + writes, + } + } + + /// Return the tensor that needs to be written to. + /// + /// # Notes + /// + /// The buffers vector passed as input is only to track the intermediary buffer writes needed + /// during execution. + pub fn tensor_writes( + &self, + resources: &FuseResources, + buffers: &mut Vec, + ) -> RegisteredTensors { + let mut result = RegisteredTensors::default(); + + // All tensors where their latest representation is not read write should be written to since they + // are going to be used after the fused kernel by other operations. + for output in self.outputs.iter() { + if let Some((tensor, _precision)) = output.as_normal_tensor() { + // We get the latest representation from the resources, not just this block. + if let Some((tensor, precision)) = resources.outputs.get(tensor.id) { + if !matches!(tensor.status, TensorStatus::ReadWrite) { + result.insert(*precision, tensor.clone()); + } else if resources.buffers.get(tensor.id).is_some() + && !buffers.contains(&tensor.id) + { + result.insert(*precision, tensor.clone()); + // We make sure we don't write multiple time in the same buffer, only the + // earliest possible. + buffers.push(tensor.id); + } + } + } + } + + result + } +} + +#[derive(Default, Clone, Debug)] +pub struct LocalVariablePool { + values: BTreeMap>, +} + +impl LocalVariablePool { + fn get(&self, precision: FuseType, tensor_id: TensorId) -> Option { + if let Some(indexes) = self.values.get(&precision) + && let Some(index) = indexes.get(&tensor_id) + { + return Some(FuseArg::BlockLocal { + pos: *index, + ty: precision, + }); + } + + None + } + + fn get_any_precision(&self, tensor_id: TensorId) -> Option { + for (precision, indexes) in self.values.iter() { + if let Some(index) = indexes.get(&tensor_id) { + return Some(FuseArg::BlockLocal { + pos: *index, + ty: *precision, + }); + } + } + + None + } + + fn create(&mut self, precision: FuseType, tensor_id: TensorId) -> FuseArg { + if let Some(indexes) = self.values.get_mut(&precision) { + let new_index = indexes.len(); + indexes.insert(tensor_id, new_index); + return FuseArg::BlockLocal { + pos: new_index, + ty: precision, + }; + } + + let new_index = 0; + self.values + .insert(precision, BTreeMap::from_iter([(tensor_id, new_index)])); + + FuseArg::BlockLocal { + pos: new_index, + ty: precision, + } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/trace/fuser.rs b/crates/burn-cubecl-fusion/src/engine/trace/fuser.rs new file mode 100644 index 0000000..fab4c8d --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/trace/fuser.rs @@ -0,0 +1,321 @@ +use super::{ + super::{ + codegen::ir::{FuseArg, FuseOp, FuseType, LayoutInfo}, + settings::FuseSettings, + }, + FuseResources, + block::FuseBlockBuilder, +}; +use super::{FuseTrace, RegisteredTensors}; +use crate::engine::trace::block::QuantInput; +use burn_fusion::stream::ScalarId; +use burn_ir::{ScalarIr, TensorIr}; +use burn_std::{DType, Shape}; +use cubecl::quant::scheme::QuantParam; + +#[derive(Clone, Debug)] +/// It is responsible to create a [trace](FuseTrace) composed of multiple [blocks](super::block::FuseBlock). +/// +/// It mostly handles the [resources](KernelResources) needed by the generated fused kernel, and +/// delegates most of the work to the [block builder](FuseBlockBuilder). +pub struct TraceFuser { + settings: FuseSettings, + // The tensors returned by the block that don't need to be written to global memory. + block_current: FuseBlockBuilder, + blocks_previous: Vec, + resources: FuseResources, +} + +impl TraceFuser { + /// Create a new trace builder with the given bool precision and [fuse settings](FuseSettings). + pub fn new(settings: FuseSettings) -> Self { + Self { + settings, + block_current: FuseBlockBuilder::new(settings), + blocks_previous: Default::default(), + resources: Default::default(), + } + } + + /// Get the number of blocks that are closed. + pub fn num_previous_blocks(&self) -> usize { + self.blocks_previous.len() + } + + /// Tag a tensor as dropped. + pub fn fuse_dropped(&mut self, tensor: &TensorIr) { + self.resources.outputs.update(tensor); + self.resources.inputs.update(tensor); + self.resources.dropped.insert(tensor.id); + } + + /// Register an operation. + pub fn fuse_operation(&mut self, op: FuseOp) { + self.block_current.ops.push(op); + } + + /// The number of operations fused. + pub fn num_ops_fused(&self) -> u32 { + let mut num_ops_fused = 0; + + for block in self.blocks_previous.iter() { + num_ops_fused += block.ops.len(); + } + + num_ops_fused += self.block_current.ops.len(); + num_ops_fused as u32 + } + + /// Close the current block with the given reference shape and creates a new one with new [fusion settings](FuseSettings). + pub fn next_block(&mut self, shape_ref: Shape, settings: FuseSettings) { + let mut block_new = FuseBlockBuilder::new(settings); + core::mem::swap(&mut self.block_current, &mut block_new); + block_new.shape_ref = shape_ref; + self.blocks_previous.push(block_new); + self.settings = settings; + } + + // Estimate how many bindings are in use right now. This can return more than the actual number + // but should never return less. + pub fn estimate_bindings(&self) -> u32 { + let mut buffers = Vec::new(); + let mut estimation = 1; // Metadata takes one. + + // We assume we are not going to write multiple times in the same output buffer. + for b in self.blocks_previous.iter() { + estimation += b.tensor_writes(&self.resources, &mut buffers).len() as u32; + } + + estimation += self + .block_current + .tensor_writes(&self.resources, &mut buffers) + .len() as u32; + estimation += self.resources.inputs.len() as u32; + + estimation + } + + pub(crate) fn num_multi_block_local_inputs(&self) -> usize { + self.block_current + .local_inputs + .iter() + .filter(|(_, x)| matches!(x, FuseArg::MultiBlockLocal(..))) + .count() + } + + /// Tag the [tensor](TensorIr) as received from a previous block. + /// + /// This will avoid reading the input again and instead use le local version when possible. + pub fn block_local_input( + &mut self, + tensor: &TensorIr, + block_pos: usize, + global: bool, + ) -> FuseArg { + let block = &mut self.blocks_previous[block_pos]; + + let src_arg = match block.multi_block_variable(block_pos, tensor, global) { + Some(val) => val, + None => { + // We try to read the input if not present. + block.input(tensor, &mut self.resources); + block + .multi_block_variable(block_pos, tensor, global) + .unwrap() + } + }; + + self.resources.outputs.update(tensor); + + if global { + self.resources.registers.insert(tensor.id, src_arg.clone()); + } + + self.block_current + .local_inputs + .insert(tensor.id, src_arg.clone()); + src_arg + } + + /// Register an output tensor that won't be automatically synced into global memory. + /// + /// It is therefore the responsibility of the operation to write the result to given tensor. + pub fn output_unhandled(&mut self, tensor: &TensorIr) -> FuseArg { + let arg = self + .output(tensor) + .expect("Can't add a new output that is already used in an index operation"); + + self.resources.outputs_unhandled.push(arg.clone()); + self.block_current.outputs_unhandled.push(arg.clone()); + arg + } + + /// Register an input tensor that won't be automatically read into a local variable. + /// + /// It is therefore the responsibility of the operation to read the given tensor. + pub fn input_unhandled(&mut self, tensor: &TensorIr) -> FuseArg { + if self.resources.indexed.contains_key(&tensor.id) { + panic!("Can't add a new input that is already used in an index operation"); + } + + self.resources.outputs.update(tensor); + + let precision = tensor.dtype.into(); + let new_input = self.resources.inputs.insert(precision, tensor.clone()); + let arg = FuseArg::Input(new_input, precision, LayoutInfo::Unknown); + + self.resources.inputs_unhandled.push(tensor.id); + arg + } + + /// Register an input tensor. + pub fn input_quantized_unhandled(&mut self, tensor: &TensorIr) -> Option<(FuseArg, FuseArg)> { + if self.resources.indexed.contains_key(&tensor.id) { + panic!("Can't add a new input that is already used in an index operation"); + } + self.resources.outputs.update(tensor); + + let precision = tensor.dtype.into(); + let precision_scales = match tensor.dtype { + DType::QFloat(scheme) => match scheme.param { + QuantParam::F32 => FuseType::F32, + QuantParam::F16 => FuseType::F16, + QuantParam::BF16 => FuseType::BF16, + QuantParam::UE8M0 | QuantParam::UE4M3 => { + unimplemented!("Unsupported fuse precision"); + } + }, + _ => return None, + }; + + let (new_input, q_index) = self.resources.inputs.insert_quant(tensor.clone()); + let input = FuseArg::Input(new_input, precision, LayoutInfo::Unknown); + let scales = FuseArg::Input(q_index, precision_scales, LayoutInfo::Unknown); + + self.resources.inputs_unhandled.push(tensor.id); + Some((input, scales)) + } + + /// Register an input tensor. + pub fn input(&mut self, tensor: &TensorIr) -> Option { + if matches!(tensor.dtype, DType::QFloat(_)) { + return None; + } + + self.resources.outputs.update(tensor); + + self.block_current.input(tensor, &mut self.resources) + } + + /// Register an input tensor. + pub fn input_quantized(&mut self, tensor: &TensorIr) -> Option { + self.resources.outputs.update(tensor); + self.block_current.input_quant(tensor, &mut self.resources) + } + + /// Register an output tensor. + pub fn output(&mut self, tensor: &TensorIr) -> Option { + if matches!(tensor.dtype, DType::QFloat(_)) { + return None; + } + self.block_current.output(tensor, &mut self.resources) + } + + /// Register an input that will be accessed using custom indexing with no vectorization. + pub fn input_indexed(&mut self, tensor: &TensorIr) -> Option { + if matches!(tensor.dtype, DType::QFloat(_)) { + return None; + } + + if let Some(val) = self.resources.indexed.get(&tensor.id) { + self.resources.outputs.update(tensor); + return Some(val.clone()); + }; + + if self.resources.inputs.get(tensor.id).is_some() { + return None; + } + + if self.resources.outputs.get(tensor.id).is_some() { + return None; + } + + let input = self.input_unhandled(tensor); + self.resources.indexed.insert(tensor.id, input.clone()); + + Some(input) + } + + /// Register an input with swapped dims. + pub fn input_swap_dims( + &mut self, + tensor: &TensorIr, + output: &TensorIr, + dims: (usize, usize), + ) -> Option { + if matches!(tensor.dtype, DType::QFloat(_)) { + return None; + } + + self.resources.outputs.update(tensor); + self.block_current + .input_swap_dims(tensor, output, dims, &mut self.resources) + } + + /// Register an input that is reshaped. + pub fn input_reshaped(&mut self, tensor: &TensorIr, output: &TensorIr) -> Option { + if matches!(tensor.dtype, DType::QFloat(_)) { + return None; + } + + self.resources.outputs.update(tensor); + self.block_current + .input_reshaped(tensor, output, &mut self.resources) + } + + /// Register a scalar value. + pub fn scalar(&mut self, elem: &ScalarIr, dtype: DType) -> FuseArg { + let precision = dtype.into(); + let id = if let ScalarIr::UInt(value) = elem { + ScalarId { value: *value } + } else { + unreachable!() // should always be u64 + }; + + let new_index = self.resources.scalars.len(); + + self.resources.scalars.push((precision, id.value)); + FuseArg::Scalar(new_index, precision) + } + + /// Finish fusing and returns the created trace. + pub fn finish(&mut self, shape_ref: Shape) -> FuseTrace { + let mut resources = self.resources.clone(); + let mut outputs = RegisteredTensors::default(); + let mut buffers = Vec::new(); + + for tensor in resources.buffers.iter() { + let (tensor, ty) = tensor.as_normal_tensor().unwrap(); + outputs.insert(*ty, tensor.clone()); + } + + let mut blocks = Vec::new(); + + let mut register_block = |block: &FuseBlockBuilder| { + let block = block.build(&self.resources, &mut outputs, &mut buffers); + blocks.push(block); + }; + + for block in self.blocks_previous.iter() { + register_block(block); + } + self.block_current.shape_ref = shape_ref; + register_block(&self.block_current); + + // We update the output tensors registered to be the ones that are written to in global + // memory. + resources.outputs = outputs; + + FuseTrace { blocks, resources } + } +} diff --git a/crates/burn-cubecl-fusion/src/engine/trace/mod.rs b/crates/burn-cubecl-fusion/src/engine/trace/mod.rs new file mode 100644 index 0000000..286c232 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/engine/trace/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod block; + +mod base; +mod fuser; + +pub use base::*; +pub use fuser::*; diff --git a/crates/burn-cubecl-fusion/src/inspect.rs b/crates/burn-cubecl-fusion/src/inspect.rs new file mode 100644 index 0000000..f0792df --- /dev/null +++ b/crates/burn-cubecl-fusion/src/inspect.rs @@ -0,0 +1,26 @@ +//! Test-only introspection into launch-level decisions. +//! +//! Some launch decisions — like whether a fused kernel wrote its output in-place into an +//! input buffer — produce identical results either way, so value-based tests cannot catch +//! a silent regression. The counters here make those decisions observable. +//! +//! Counters are process-global: concurrent tests on the same process all increment them. +//! Assert on deltas (`>= n`), or serialize the test (e.g. `#[serial]`) when asserting that +//! a counter did *not* move. + +use core::sync::atomic::{AtomicU64, Ordering}; + +static INPLACE_ALIAS_COUNT: AtomicU64 = AtomicU64::new(0); + +/// The number of fused-kernel outputs planned to alias (reuse) an input buffer instead +/// of allocating their own, since process start. +/// +/// Counted at plan time: a plan whose execution later fails and rolls back still +/// increments the counter, so treat it as an upper bound on executed aliases. +pub fn inplace_alias_count() -> u64 { + INPLACE_ALIAS_COUNT.load(Ordering::Relaxed) +} + +pub(crate) fn record_inplace_alias() { + INPLACE_ALIAS_COUNT.fetch_add(1, Ordering::Relaxed); +} diff --git a/crates/burn-cubecl-fusion/src/lib.rs b/crates/burn-cubecl-fusion/src/lib.rs new file mode 100644 index 0000000..597c23b --- /dev/null +++ b/crates/burn-cubecl-fusion/src/lib.rs @@ -0,0 +1,14 @@ +#[macro_use] +extern crate derive_new; + +pub mod optim; + +#[cfg(feature = "test-util")] +pub mod inspect; + +mod base; + +pub(crate) mod engine; +pub(crate) mod tune; + +pub use base::*; diff --git a/crates/burn-cubecl-fusion/src/optim/base.rs b/crates/burn-cubecl-fusion/src/optim/base.rs new file mode 100644 index 0000000..76abf3e --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/base.rs @@ -0,0 +1,72 @@ +use crate::optim::{ + elemwise::{ElemwiseOptimization, ElemwiseOptimizationState}, + matmul::{MatmulOptimization, MatmulOptimizationState}, + reduce::{ReduceOptimization, ReduceOptimizationState}, + reduce_broadcasted::{ReduceBroadcastedOptimization, ReduceBroadcastedOptimizationState}, +}; +use cubecl::Runtime; +use serde::{Deserialize, Serialize}; + +/// Fusion optimization type for cubecl. +/// +/// More optimization variants should be added here. +#[allow(clippy::large_enum_variant)] +pub enum CubeOptimization { + ElementWise(ElemwiseOptimization), + Matmul(MatmulOptimization), + Reduce(ReduceOptimization), + ReduceBroadcasted(ReduceBroadcastedOptimization), +} + +impl core::fmt::Debug for CubeOptimization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = self.to_opt_state(); + f.write_fmt(format_args!("{value:?}")) + } +} + +impl CubeOptimization { + /// Serializes the current optimization to its state. + pub fn to_opt_state(&self) -> CubeOptimizationState { + match self { + Self::ElementWise(value) => CubeOptimizationState::ElementWise(value.to_state()), + Self::Matmul(value) => CubeOptimizationState::Matmul(value.to_state()), + Self::Reduce(value) => CubeOptimizationState::Reduce(value.to_state()), + Self::ReduceBroadcasted(value) => { + CubeOptimizationState::ReduceBroadcasted(value.to_state()) + } + } + } +} + +impl burn_fusion::NumOperations for CubeOptimization { + fn len(&self) -> usize { + match self { + Self::ElementWise(op) => op.num_ops_fused(), + Self::Matmul(op) => op.num_ops_fused(), + Self::Reduce(op) => op.num_ops_fused(), + Self::ReduceBroadcasted(op) => op.num_ops_fused(), + } + } + + fn name(&self) -> &'static str { + match self { + CubeOptimization::ElementWise(..) => "ElementWise", + CubeOptimization::Matmul(..) => "Matmul", + CubeOptimization::Reduce(..) => "Reduce", + CubeOptimization::ReduceBroadcasted(..) => "ReduceBroadcasted", + } + } +} + +/// Fusion optimization state type for cubecl. +/// +/// More optimization variants should be added here. +#[allow(clippy::large_enum_variant)] +#[derive(Serialize, Deserialize, Debug)] +pub enum CubeOptimizationState { + ElementWise(ElemwiseOptimizationState), + Matmul(MatmulOptimizationState), + Reduce(ReduceOptimizationState), + ReduceBroadcasted(ReduceBroadcastedOptimizationState), +} diff --git a/crates/burn-cubecl-fusion/src/optim/elemwise/fuser.rs b/crates/burn-cubecl-fusion/src/optim/elemwise/fuser.rs new file mode 100644 index 0000000..90ed6a1 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/elemwise/fuser.rs @@ -0,0 +1,85 @@ +use super::optimization::ElemwiseOptimization; +use crate::{ + engine::{ + fuser::TraceOperationFuser, + settings::{FuseSettings, RefLayoutSetting, VectorizationSetting}, + }, + optim::CubeOptimization, +}; +use burn_fusion::OperationFuser; +use burn_std::Shape; +use cubecl::Runtime; + +/// Fuses element wise operations. +pub struct ElementWiseFuser { + fuser: TraceOperationFuser, + device: R::Device, +} + +impl Clone for ElementWiseFuser { + fn clone(&self) -> Self { + Self { + fuser: self.fuser.clone(), + device: self.device.clone(), + } + } +} + +impl ElementWiseFuser { + pub fn shape_id(&self) -> Shape { + self.fuser.current_output_shape.clone() + } + pub fn new(device: R::Device) -> Self { + let client = R::client(&device); + let props = client.properties(); + let max_bindings = props.hardware.max_bindings; + + Self { + fuser: TraceOperationFuser::new( + max_bindings, + FuseSettings { + broadcast: true, + output_shape_updates: true, + inplace: true, + vectorization: VectorizationSetting::Activated, + ref_layout: RefLayoutSetting::Any, + }, + ), + device, + } + } +} + +impl OperationFuser> for ElementWiseFuser { + fn fuse(&mut self, operation: &burn_ir::OperationIr) { + self.fuser.fuse(operation); + } + + fn finish(&mut self) -> CubeOptimization { + let client = R::client(&self.device); + let trace = self.fuser.finish(); + let elementwise = ElemwiseOptimization::new(trace, client, self.device.clone(), self.len()); + + CubeOptimization::ElementWise(elementwise) + } + + fn reset(&mut self) { + self.fuser.reset() + } + + fn status(&self) -> burn_fusion::FuserStatus { + self.fuser.status() + } + + fn properties(&self) -> burn_fusion::FuserProperties { + self.fuser.properties() + } + + fn len(&self) -> usize { + self.fuser.len() + } + + fn clone_dyn(&self) -> Box>> { + Box::new(self.clone()) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/elemwise/mod.rs b/crates/burn-cubecl-fusion/src/optim/elemwise/mod.rs new file mode 100644 index 0000000..7c4308c --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/elemwise/mod.rs @@ -0,0 +1,5 @@ +mod fuser; +mod optimization; + +pub use fuser::*; +pub use optimization::*; diff --git a/crates/burn-cubecl-fusion/src/optim/elemwise/optimization.rs b/crates/burn-cubecl-fusion/src/optim/elemwise/optimization.rs new file mode 100644 index 0000000..93d094e --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/elemwise/optimization.rs @@ -0,0 +1,141 @@ +use crate::{ + CubeFusionHandle, + engine::{ + codegen::{ + DynSize, + io::ref_len, + ir::{ + FuseArg, FuseBlockConfig, GlobalArgs, GlobalArgsLaunch, RefLayout, + multi_block_variables_init, + }, + kernel::{fuse_on_write, init_locals}, + }, + launch::{ + FuseTraceLauncher, + runner::{TraceRunner, Vectorization}, + }, + trace::FuseTrace, + }, +}; +use burn_fusion::stream::Context; +use cubecl::{CubeDim, calculate_cube_count_elemwise, client::ComputeClient, prelude::*}; +use serde::{Deserialize, Serialize}; + +#[derive(new)] +/// Fuse element wise operations into a single kernel. +pub struct ElemwiseOptimization { + pub(crate) trace: FuseTrace, + client: ComputeClient, + device: R::Device, + len: usize, +} + +#[derive(Serialize, Deserialize, Debug)] +/// State for the [elemwise optimization](ElemwiseOptimization). +pub struct ElemwiseOptimizationState { + trace: FuseTrace, + len: usize, +} + +impl ElemwiseOptimization { + /// Execute the optimization. + pub fn execute(&self, context: &mut Context>) { + let launcher = FuseTraceLauncher::new(&self.trace, &ElemwiseRunner); + + match launcher.launch(&self.client, &self.device, context) { + Ok(_) => (), + Err(err) => { + panic!("{err:?} - {:?}", self.trace); + } + } + } + + /// Number of element wise operations fused. + pub fn num_ops_fused(&self) -> usize { + self.len + } + + /// Create an optimization from its [state](ElemwiseOptimizationState). + pub fn from_state(device: &R::Device, state: ElemwiseOptimizationState) -> Self { + Self { + trace: state.trace, + len: state.len, + client: R::client(device), + device: device.clone(), + } + } + + /// Convert the optimization to its [state](ElemwiseOptimizationState). + pub fn to_state(&self) -> ElemwiseOptimizationState { + ElemwiseOptimizationState { + trace: self.trace.clone(), + len: self.len, + } + } +} + +pub struct ElemwiseRunner; + +impl Vectorization for ElemwiseRunner {} +impl TraceRunner for ElemwiseRunner { + type Error = LaunchError; // No error possible + + fn run<'a>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + configs: &[FuseBlockConfig], + ) -> Result<(), Self::Error> { + let config = &configs[0]; + let shape = match &config.ref_layout { + RefLayout::Concrete(arg) => match arg { + FuseArg::Input(..) => inputs.shape_ref(&config.ref_layout, config.rank), + FuseArg::Output(..) => outputs.shape_ref(&config.ref_layout, config.rank), + _ => panic!("Invalid concreate ref layout"), + }, + RefLayout::Virtual(_) => inputs.shape_ref(&config.ref_layout, config.rank), + }; + let working_units = shape.iter().product::() / config.width; + let cube_dim = CubeDim::new(client, working_units); + let cube_count = calculate_cube_count_elemwise(client, working_units, cube_dim); + let address_type = inputs + .required_address_type() + .max(outputs.required_address_type()); + + unsafe { + elemwise_fuse::launch_unchecked( + client, + cube_count, + cube_dim, + address_type, + inputs, + outputs, + config.clone(), + ); + }; + + Ok(()) + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +fn elemwise_fuse( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + #[comptime] config: FuseBlockConfig, +) { + // We write no values for this fusion. + let values = Registry::>::new(); + let args = comptime![Vec::::new()]; + let pos = ABSOLUTE_POS; + + multi_block_variables_init(&config, &mut outputs.variables); + + let mut locals = init_locals(inputs, outputs, &config); + let length = ref_len(inputs, &*outputs, &locals, &config); + + if pos < length { + fuse_on_write::(inputs, outputs, &mut locals, pos, values, args, &config) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/matmul/args.rs b/crates/burn-cubecl-fusion/src/optim/matmul/args.rs new file mode 100644 index 0000000..d8fa80a --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/matmul/args.rs @@ -0,0 +1,571 @@ +use crate::engine::codegen::{ + io::ref_vector_size, + ir::{FuseArg, FuseBlockConfig, FuseType, GlobalArgs, LocalArgs, multi_block_variables_init}, + kernel::init_locals, + view::{FusedOutput, GlobalInput, GlobalInputExpand}, +}; +use cubecl::{ + intrinsic, + prelude::*, + quant::scheme::{QuantLevel, QuantScheme}, + std::{ + FastDivmod, + quant::{ + RunWithQuantType, + view::{QuantizedView, run_with_quant_type}, + }, + tensor::{ + View, ViewExpand, ViewMut, + layout::{Coords1d, Coords2d, VirtualLayout}, + }, + }, +}; +use cubek::{ + matmul::{ + args::{BatchedCoords, MatmulArgs}, + components::global::memory::{ + BatchLayout, BlockScaledLayout, GlobalLayout, GlobalLayoutConfig, GlobalLayoutExpand, + GlobalScaleLayout, GlobalScaleLayoutExpand, NoopLayout, + }, + }, + std::MatrixLayout, +}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; + +#[derive(Clone)] +pub struct FusedMatmulArgs; + +#[derive(CubeLaunch, CubeType)] +pub struct FusedMatmulInput { + global: GlobalArgs, + #[cube(comptime)] + config: FuseBlockConfig, + #[cube(comptime)] + a: MatmulArg, + #[cube(comptime)] + b: MatmulArg, + #[cube(comptime)] + c: Option, + #[cube(comptime)] + out: FuseArg, +} + +#[cube] +impl MatmulArgs for FusedMatmulArgs { + type Output = GlobalArgs; + type Input = FusedMatmulInput; + type State = FusedMatmulState; + type Config = (); + + fn init_state( + inputs: &Self::Input, + outputs: &mut Self::Output, + _config: (), + #[comptime] lhs_layout_config: GlobalLayoutConfig, + #[comptime] rhs_layout_config: GlobalLayoutConfig, + #[comptime] out_layout_config: GlobalLayoutConfig, + ) -> Self::State { + multi_block_variables_init(&inputs.config, &mut outputs.variables); + + let mut locals = init_locals(&inputs.global, outputs, &inputs.config); + let rank = comptime![inputs.config.rank]; + + let mut batch_shape = Sequence::new(); + let mut batch_strides_out = Sequence::new(); + + #[unroll] + for i in 0..rank - 2 { + batch_shape.push(FastDivmod::new_Fallback(locals.ref_shape[i] as u32)); + batch_strides_out.push(locals.ref_strides[i]); + } + + let batch_lhs = input_batch_layout( + &inputs.global, + &batch_shape, + comptime![inputs.a.clone()], + comptime![inputs.config.clone()], + ); + let batch_rhs = input_batch_layout( + &inputs.global, + &batch_shape, + comptime![inputs.b.clone()], + comptime![inputs.config.clone()], + ); + let batch_acc = match comptime![inputs.c.clone()] { + Some(c) => ComptimeOption::Some(input_batch_layout( + &inputs.global, + &batch_shape, + comptime![c], + comptime![inputs.config.clone()], + )), + None => ComptimeOption::new_None(), + }; + let batch_out = BatchLayout::new(batch_strides_out, batch_shape.clone()); + + FusedMatmulState::new( + inputs, + outputs, + &mut locals, + batch_lhs, + batch_rhs, + batch_acc, + VirtualLayout::new::(batch_out), + batch_shape, + &inputs.config, + lhs_layout_config, + rhs_layout_config, + out_layout_config, + ) + } + + fn view_lhs( + state: &Self::State, + ) -> View<'_, Lhs, BatchedCoords> { + global_view( + &state.inputs, + &state.locals, + &state.batch_shape, + comptime![state.a.clone()], + comptime![state.config.clone()], + state.lhs_layout_config, + ) + } + + fn batch_lhs( + state: &Self::State, + batch: usize, + ) -> usize { + state.a_batch.to_source_pos(batch) + } + + fn view_rhs( + state: &Self::State, + ) -> View<'_, Rhs, BatchedCoords> { + global_view( + &state.inputs, + &state.locals, + &state.batch_shape, + comptime![state.b.clone()], + comptime![state.config.clone()], + comptime![state.rhs_layout_config], + ) + } + + fn batch_rhs( + state: &Self::State, + batch: usize, + ) -> usize { + state.b_batch.to_source_pos(batch) + } + + fn view_acc( + state: &Self::State, + ) -> ComptimeOption> { + match comptime![state.c.clone()] { + Some(c) => { + let view = global_view( + &state.inputs, + &state.locals, + &state.batch_shape, + c, + comptime![state.config.clone()], + comptime![state.out_layout_config], + ); + ComptimeOption::Some(view) + } + None => ComptimeOption::new_None(), + } + } + + fn batch_acc( + state: &Self::State, + batch: usize, + ) -> usize { + #[comptime] + match &state.c_batch { + ComptimeOption::Some(c_batch) => c_batch.to_source_pos(batch), + ComptimeOption::None => batch, + } + } + + fn view_out( + state: &Self::State, + ) -> ViewMut<'_, EO, BatchedCoords> { + let rank = comptime![state.config.rank]; + + let shape_row = state.locals.ref_shape[rank - 2] as u32; + let shape_col = state.locals.ref_shape[rank - 1] as u32; + + let stride_row = state.locals.ref_strides[rank - 2]; + let stride_col = state.locals.ref_strides[rank - 1]; + + let mut outputs = state.outputs.clone(); + let mut locals = state.locals.clone(); + + let layout = GlobalLayout::new( + VirtualLayout::new::(NoopLayout::new()), + shape_row, + shape_col, + stride_row, + stride_col, + ref_vector_size(&state.locals), + 1u32, + state.out_layout_config, + ); + let buffer = FusedOutput::new( + &state.inputs, + &mut outputs, + &mut locals, + comptime![state.out.clone()], + comptime![state.config.clone()], + ); + ViewMut::new::(buffer, layout) + } + + fn batch_out( + state: &Self::State, + batch: usize, + ) -> usize { + state.out_batch.to_source_pos(batch) + } + + fn runtime_config( + _state: &Self::State, + ) { + } +} + +#[cube] +#[allow(clippy::missing_transmute_annotations)] +fn global_view( + inputs: &GlobalArgs, + locals: &LocalArgs, + batch_shape: &Sequence>, + #[comptime] arg: MatmulArg, + #[comptime] config: FuseBlockConfig, + #[comptime] layout_config: GlobalLayoutConfig, +) -> View<'static, E, BatchedCoords> { + let rank = comptime![config.rank]; + let data = comptime![arg.data().clone()]; + let data_tensor = match comptime![data.clone()] { + FuseArg::Input(pos, ..) => inputs.tensors.index(pos), + _ => panic!("Input must be concrete"), + }; + + let mut shape_row = data_tensor.tensor.shape(rank - 2) as u32; + let mut shape_col = data_tensor.tensor.shape(rank - 1) as u32; + let mut packing = comptime![1]; + + if arg.scheme().is_some() { + let scheme = arg.scheme().unwrap(); + let num_quants = scheme.num_quants() as u32; + comptime![packing = num_quants]; + match comptime![layout_config.matrix_layout] { + MatrixLayout::RowMajor => shape_col *= num_quants, + MatrixLayout::ColMajor => shape_row *= num_quants, + }; + } + + let shape = (shape_row, shape_col); + + // Noop for normal inputs because batch offset is cached, quantized uses logical batches + let batch_layout = match comptime![arg.clone()] { + MatmulArg::Normal(_) => VirtualLayout::new::(NoopLayout::new()), + MatmulArg::Quantized { data, .. } => { + let data_arg = comptime![MatmulArg::Normal(data)]; + input_batch_layout(inputs, batch_shape, data_arg, comptime![config.clone()]) + } + }; + + let data_layout = global_layout( + inputs, + shape, + batch_layout, + arg.data().clone(), + config.clone(), + data_tensor.tensor.vector_size(), + layout_config, + packing, + ); + let data_buf = GlobalInput::new(inputs, locals, data, comptime![config.clone()], None); + + match comptime![arg.clone()] { + MatmulArg::Normal(_) => View::new::(data_buf, data_layout), + MatmulArg::Quantized { scales, scheme, .. } => { + let scales_layout = match comptime![scheme.level] { + QuantLevel::Tensor => GlobalScaleLayout::new_PerTensor(shape), + QuantLevel::Block(block_size) => { + let block_size = comptime![block_size.as_dim::<2>()]; + + let scales_arg = comptime![MatmulArg::Normal(scales.clone())]; + let batch_layout = input_batch_layout( + inputs, + batch_shape, + scales_arg, + comptime![config.clone()], + ); + + let scales_layout = global_layout( + inputs, + shape, + batch_layout, + comptime![scales.clone()], + comptime![config.clone()], + 1usize, + layout_config, + 1u32, + ); + GlobalScaleLayout::new_BlockScaled(BlockScaledLayout::new( + shape, + scales_layout, + comptime![(block_size[0] as u32, block_size[1] as u32)], + )) + } + }; + let scales_buf = GlobalInput::new(inputs, locals, scales, config, None); + + // Redefine because of `Numeric` bound, kinda hacky but I can't figure out a way to + // assert `Vector::Scalar: Numeric` + let define!(T) = storage_type_of::(); + let view = create_quant_view_dynamic::( + data_buf, + data_layout, + scales_buf, + scales_layout, + scheme, + ); + // Safety: should be fine since `Vector` is guaranteed equal to `E` + comptime![unsafe { core::mem::transmute(view) }] + } + } +} + +#[cube] +fn input_batch_layout( + inputs: &GlobalArgs, + batch_shape: &Sequence>, + #[comptime] arg: MatmulArg, + #[comptime] config: FuseBlockConfig, +) -> VirtualLayout { + let rank = comptime![config.rank]; + match comptime![arg.clone()] { + MatmulArg::Normal(arg) => { + let data_tensor = match comptime![arg.clone()] { + FuseArg::Input(pos, ..) => inputs.tensors.index(pos), + _ => panic!("Input must be concrete"), + }; + + let mut batch_strides = Sequence::new(); + #[unroll] + for i in 0..rank - 2 { + let shape = data_tensor.tensor.shape(i); + let stride = select(shape == 1, 0, data_tensor.tensor.stride(i)); + batch_strides.push(stride); + } + + VirtualLayout::new::(BatchLayout::new(batch_strides, batch_shape.clone())) + } + MatmulArg::Quantized { .. } => VirtualLayout::new::(NoopLayout::new()), + } +} + +#[cube] +fn global_layout( + inputs: &GlobalArgs, + shape: Coords2d, + batch_layout: VirtualLayout, + #[comptime] arg: FuseArg, + #[comptime] config: FuseBlockConfig, + #[comptime] vector_size: VectorSize, + #[comptime] layout_config: GlobalLayoutConfig, + #[comptime] packing: u32, +) -> GlobalLayout { + let rank = comptime![config.rank]; + let data_tensor = match comptime![arg.clone()] { + FuseArg::Input(pos, ..) => inputs.tensors.index(pos), + _ => panic!("Input must be concrete"), + }; + + let (shape_row, shape_col) = shape; + + let stride_row = data_tensor.tensor.stride(rank - 2); + let stride_col = data_tensor.tensor.stride(rank - 1); + + GlobalLayout::new( + batch_layout, + shape_row, + shape_col, + stride_row, + stride_col, + vector_size, + packing, + layout_config, + ) +} + +struct CreateQuantView<'a, E: Numeric, N: Size> { + scope: &'a Scope, + data_buf: GlobalInputExpand, + data_layout: GlobalLayoutExpand, + scales_buf: GlobalInputExpand, + scales_layout: GlobalScaleLayoutExpand, + scheme: QuantScheme, + _ty: PhantomData<(E, N)>, +} + +impl<'a, E: Numeric, N: Size> RunWithQuantType for CreateQuantView<'a, E, N> { + type Output = ViewExpand<'static, Vector, BatchedCoords>; + + fn execute(self) -> Self::Output { + create_quant_view::expand::( + self.scope, + self.data_buf, + self.data_layout, + self.scales_buf, + self.scales_layout, + self.scheme, + ) + } +} + +#[cube] +#[allow(unused)] +fn create_quant_view_dynamic( + data_buf: GlobalInput, + data_layout: GlobalLayout, + scales_buf: GlobalInput, + scales_layout: GlobalScaleLayout, + #[comptime] scheme: QuantScheme, +) -> View<'static, Vector, BatchedCoords> { + intrinsic!(|scope| { + let func = CreateQuantView { + scope, + data_buf, + data_layout, + scales_buf, + scales_layout, + scheme, + _ty: PhantomData, + }; + run_with_quant_type(func, scheme) + }) +} + +#[cube] +fn create_quant_view( + data_buf: GlobalInput, + data_layout: GlobalLayout, + scales_buf: GlobalInput, + scales_layout: GlobalScaleLayout, + #[comptime] scheme: QuantScheme, +) -> View<'static, Vector, BatchedCoords> { + let size!(NQ) = N::value().comptime() / scheme.num_quants(); + + let data_view: View, BatchedCoords> = + View::new::(data_buf, data_layout); + let scales_view: View = + View::new::(scales_buf, scales_layout); + QuantizedView::new(data_view, scales_view, scheme).view() +} + +#[derive(CubeType)] +pub struct FusedMatmulState { + inputs: GlobalArgs, + outputs: GlobalArgs, + locals: LocalArgs, + a_batch: VirtualLayout, + b_batch: VirtualLayout, + c_batch: ComptimeOption>, + out_batch: VirtualLayout, + #[cube(comptime)] + config: FuseBlockConfig, + #[cube(comptime)] + a: MatmulArg, + #[cube(comptime)] + b: MatmulArg, + #[cube(comptime)] + c: Option, + #[cube(comptime)] + out: FuseArg, + #[cube(comptime)] + lhs_layout_config: GlobalLayoutConfig, + #[cube(comptime)] + rhs_layout_config: GlobalLayoutConfig, + #[cube(comptime)] + out_layout_config: GlobalLayoutConfig, + batch_shape: Sequence>, +} + +#[cube] +impl FusedMatmulState { + #[allow(clippy::too_many_arguments)] + pub fn new( + inputs: &FusedMatmulInput, + outputs: &mut GlobalArgs, + locals: &mut LocalArgs, + a_batch: VirtualLayout, + b_batch: VirtualLayout, + c_batch: ComptimeOption>, + out_batch: VirtualLayout, + batch_shape: Sequence>, + #[comptime] config: &FuseBlockConfig, + #[comptime] lhs_layout_config: GlobalLayoutConfig, + #[comptime] rhs_layout_config: GlobalLayoutConfig, + #[comptime] out_layout_config: GlobalLayoutConfig, + ) -> FusedMatmulState { + FusedMatmulState { + inputs: inputs.global.clone(), + outputs: outputs.clone(), + config: comptime![config.clone()], + locals: locals.clone(), + a_batch, + b_batch, + c_batch, + out_batch, + a: comptime![inputs.a.clone()], + b: comptime![inputs.b.clone()], + c: comptime![inputs.c.clone()], + out: comptime![inputs.out.clone()], + lhs_layout_config, + rhs_layout_config, + out_layout_config, + batch_shape, + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +/// Argument to a matmul operation. +pub enum MatmulArg { + Normal(FuseArg), + Quantized { + data: FuseArg, + scales: FuseArg, + precision: FuseType, + scheme: QuantScheme, + }, +} + +impl MatmulArg { + pub fn data(&self) -> &FuseArg { + match self { + MatmulArg::Normal(arg) => arg, + MatmulArg::Quantized { data, .. } => data, + } + } + + pub fn scheme(&self) -> Option<&QuantScheme> { + match self { + MatmulArg::Normal(_) => None, + MatmulArg::Quantized { scheme, .. } => Some(scheme), + } + } + + pub fn precision(&self) -> FuseType { + match self { + MatmulArg::Normal(arg) => arg.precision(), + MatmulArg::Quantized { precision, .. } => *precision, + } + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/matmul/fuser.rs b/crates/burn-cubecl-fusion/src/optim/matmul/fuser.rs new file mode 100644 index 0000000..42e4f3f --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/matmul/fuser.rs @@ -0,0 +1,154 @@ +use super::optimization::{FusedMatmul, MatmulOptimization}; +use crate::{ + engine::{fuser::TraceOperationFuser, settings::FuseSettings}, + optim::CubeOptimization, + optim::matmul::args::MatmulArg, +}; +use burn_fusion::{FuserStatus, OperationFuser}; +use burn_ir::{FloatOperationIr, OperationIr}; +use burn_std::DType; +use cubecl::Runtime; + +/// Fused element wise operations that are normally memory bound. +pub struct MatmulFuser { + fuser: TraceOperationFuser, + fuser_fallback: TraceOperationFuser, + device: R::Device, + matmul: Option, +} + +impl Clone for MatmulFuser { + fn clone(&self) -> Self { + Self { + fuser: self.fuser.clone(), + fuser_fallback: self.fuser_fallback.clone(), + device: self.device.clone(), + matmul: self.matmul.clone(), + } + } +} + +impl MatmulFuser { + pub fn new(device: R::Device) -> Self { + let client = R::client(&device); + let props = client.properties(); + let max_bindings = props.hardware.max_bindings; + let settings_matmul = FuseSettings { + output_shape_updates: false, + ..Default::default() + }; + let settings_fallback = FuseSettings::default(); + + Self { + fuser: TraceOperationFuser::new(max_bindings, settings_matmul), + fuser_fallback: TraceOperationFuser::new(max_bindings, settings_fallback), + device, + matmul: None, + } + } +} + +impl OperationFuser> for MatmulFuser { + fn fuse(&mut self, operation: &OperationIr) { + if let FuserStatus::Closed = self.fuser.status() { + return; + } + + if self.matmul.is_none() { + if let OperationIr::Float(_, FloatOperationIr::Matmul(op)) = operation { + // Precision shouldn't be hardcoded but I don't know how to get float precision of the backend + let lhs = match op.lhs.dtype { + DType::QFloat(scheme) => { + let (data, scales) = self.fuser.input_quantized_unhandled(&op.lhs).unwrap(); + MatmulArg::Quantized { + data, + scales, + precision: op.out.dtype.into(), + scheme, + } + } + _ => MatmulArg::Normal(self.fuser.input_unhandled(&op.lhs)), + }; + let rhs = match op.rhs.dtype { + DType::QFloat(scheme) => { + let (data, scales) = self.fuser.input_quantized_unhandled(&op.rhs).unwrap(); + MatmulArg::Quantized { + data, + scales, + precision: op.out.dtype.into(), + scheme, + } + } + _ => MatmulArg::Normal(self.fuser.input_unhandled(&op.rhs)), + }; + + let out = self.fuser.output_unhandled(&op.out); + + self.matmul = Some(FusedMatmul::new( + lhs, + rhs, + out, + op.clone().into(), + Default::default(), + )); + } else { + self.fuser.close(); + self.fuser_fallback.close(); + } + } else { + let can_register = + self.fuser.can_fuse(operation) && self.fuser_fallback.can_fuse(operation); + + match can_register { + true => { + self.fuser.fuse(operation); + self.fuser_fallback.fuse(operation); + } + false => { + self.fuser.close(); + self.fuser_fallback.close(); + } + }; + } + } + + fn finish(&mut self) -> CubeOptimization { + let client = R::client(&self.device); + let trace = self.fuser.finish(); + let trace_fallback = self.fuser_fallback.finish(); + + let matmul = MatmulOptimization::new( + trace, + trace_fallback, + client, + self.device.clone(), + self.len(), + self.matmul.as_ref().unwrap().clone(), + ); + + CubeOptimization::Matmul(matmul) + } + + fn reset(&mut self) { + self.fuser.reset(); + self.fuser_fallback.reset(); + self.matmul = None; + } + + fn status(&self) -> burn_fusion::FuserStatus { + self.fuser.status() + } + + fn properties(&self) -> burn_fusion::FuserProperties { + self.fuser.properties() + } + + fn len(&self) -> usize { + // Matmul operation isn't registered in the fuser + self.fuser.len() + 1 + } + + fn clone_dyn(&self) -> Box>> { + Box::new(self.clone()) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/matmul/mod.rs b/crates/burn-cubecl-fusion/src/optim/matmul/mod.rs new file mode 100644 index 0000000..75a082a --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/matmul/mod.rs @@ -0,0 +1,8 @@ +mod fuser; +mod optimization; + +pub(crate) mod args; +pub(crate) mod tune; + +pub use fuser::*; +pub use optimization::*; diff --git a/crates/burn-cubecl-fusion/src/optim/matmul/optimization.rs b/crates/burn-cubecl-fusion/src/optim/matmul/optimization.rs new file mode 100644 index 0000000..31dd82a --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/matmul/optimization.rs @@ -0,0 +1,708 @@ +use super::args::FusedMatmulInputLaunch; +#[cfg(feature = "autotune")] +use super::tune::fused_matmul_autotune; +use crate::{ + CubeFusionHandle, FallbackOperation, + engine::{ + codegen::ir::{FuseArg, FuseBlockConfig, FuseType, GlobalArgsLaunch, RefLayout}, + launch::{ + FuseTraceLauncher, HandleInput, LaunchPlan, + runner::{TraceRunner, Vectorization, VectorizationAxis}, + }, + trace::{FuseTrace, TraceError, TuneOutput}, + }, + optim::{ + elemwise::ElemwiseRunner, + matmul::args::{FusedMatmulArgs, MatmulArg}, + }, +}; +use burn_fusion::stream::Context; +use burn_ir::BinaryOpIr; +use cubecl::{ + client::ComputeClient, + prelude::*, + std::tensor::{MatrixBatchLayout, matrix_batch_layout}, +}; +use cubek::{ + matmul::{ + components::tile::TileMatmulKind, + definition::{ + MatmulElems, MatmulGlobalElems, MatmulProblem, MatmulSetupError, MatmulVectorSizes, + }, + routines::{ + BatchMatmulRoutine, BlueprintStrategy, + batch::{ + double_buffering::{CyclicDoubleBufferingAlgorithm, DoubleBufferingArgs}, + double_unit::DoubleUnitAlgorithm, + gemv_innerproduct::{ + DoubleVecMatInnerProductAlgorithm, VecMatInnerProductAlgorithm, + }, + ordered_double_buffering::{OrderedDoubleBufferingAlgorithm, OrderedSelectionArgs}, + simple::{SimpleAlgorithm, SimpleArgs}, + simple_unit::SimpleUnitAlgorithm, + }, + gemm::GemmRoutine, + gemv_unit_perpendicular::GemvUnitPerpendicularRoutine, + }, + strategy::launch_kernel_virtual, + }, + std::MatrixLayout, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Fuse matmul operation followed by elemwise operations into a single kernel. +pub struct MatmulOptimization { + pub(crate) info: Arc>, +} + +pub struct MatmulOptimizationTuneArg { + pub(crate) info: Arc>, + pub(crate) fallback: Box>, +} + +pub(crate) struct MatmulOptimizationInfo { + trace: FuseTrace, + trace_fallback: FuseTrace, + pub(crate) client: ComputeClient, + pub(crate) device: R::Device, + pub(crate) len: usize, + pub(crate) matmul: FusedMatmul, +} + +#[derive(Serialize, Deserialize, Debug)] +/// State for the [matrix optimization](MatmulOptimizationState). +pub struct MatmulOptimizationState { + trace: FuseTrace, + trace_fallback: FuseTrace, + matmul: FusedMatmul, + len: usize, +} + +impl MatmulOptimizationInfo { + /// Returns the number of output buffers added by fusion. + pub fn num_output_buffers(&self) -> usize { + self.trace_fallback.resources.outputs.len() + } + + /// Number of operations fused. + pub fn num_ops_fused(&self) -> usize { + self.len + } +} + +impl MatmulOptimizationTuneArg { + pub(crate) fn execute_fused( + &self, + context: &mut Context>, + selector: FusedMatmulSelector, + ) -> Result, TraceError> { + let launch = FusedMatmulLaunch::new(&self.info.matmul, selector); + let launcher = FuseTraceLauncher::new(&self.info.trace, &launch); + + launcher.launch(&self.info.client, &self.info.device, context) + } + + pub fn execute_fallback(&self, context: &mut Context>) -> TuneOutput { + self.fallback.run(context); + + #[cfg(feature = "autotune-checks")] + let mut output = TuneOutput::Checked { + handles: Default::default(), + }; + #[cfg(not(feature = "autotune-checks"))] + let output = TuneOutput::UnChecked(core::marker::PhantomData); + + #[cfg(feature = "autotune-checks")] + if let TuneOutput::Checked { handles } = &mut output { + let out_desc = context.tensors.get(&self.info.matmul.op.out.id).unwrap(); + let handle_out = context + .handles + .get_handle(&out_desc.id, &burn_ir::TensorStatus::ReadOnly); + + handles.insert( + self.info.matmul.op.out.id, + (out_desc.shape.clone(), handle_out.clone()), + ); + } + + let launcher = FuseTraceLauncher::new(&self.info.trace_fallback, &ElemwiseRunner); + let output_write = launcher + .launch(&self.info.client, &self.info.device, context) + .unwrap(); + + output.merge(output_write) + } +} + +impl MatmulOptimization { + pub fn new( + trace: FuseTrace, + trace_fallback: FuseTrace, + client: ComputeClient, + device: R::Device, + len: usize, + matmul: FusedMatmul, + ) -> Self { + let info = MatmulOptimizationInfo { + trace, + trace_fallback, + client, + device, + len, + matmul, + }; + + Self { + info: Arc::new(info), + } + } + /// Execute the optimization. + pub fn execute( + &mut self, + context: &mut Context>, + fallback: impl FnOnce(usize) -> Box>, + ) { + // The index of the fallback matmul is always 0. + let fallback = fallback(0); + let arg = MatmulOptimizationTuneArg { + info: self.info.clone(), + fallback, + }; + + #[cfg(feature = "autotune")] + fused_matmul_autotune::(arg, context); + + #[cfg(not(feature = "autotune"))] + if arg + .execute_fused(context, FusedMatmulSelector::default()) + .is_err() + { + arg.execute_fallback(context); + } + } + + /// Number of operations fused. + pub fn num_ops_fused(&self) -> usize { + self.info.num_ops_fused() + } + + /// Create an optimization from its [state](MatmulOptimizationState). + pub fn from_state(device: &R::Device, state: MatmulOptimizationState) -> Self { + let info = MatmulOptimizationInfo { + trace: state.trace, + trace_fallback: state.trace_fallback, + len: state.len, + client: R::client(device), + device: device.clone(), + matmul: state.matmul.clone(), + }; + + Self { + info: Arc::new(info), + } + } + + /// Convert the optimization to its [state](MatmulOptimizationState). + pub fn to_state(&self) -> MatmulOptimizationState { + MatmulOptimizationState { + trace: self.info.trace.clone(), + trace_fallback: self.info.trace_fallback.clone(), + matmul: self.info.matmul.clone(), + len: self.info.len, + } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize, Debug)] +pub enum FusedMatmulSelector { + Simple { + multi_rows: bool, + tile_matmul: AcceleratedTileKind, + }, + DoubleBuffering { + specialized: bool, + tile_matmul: AcceleratedTileKind, + }, + OrderedDoubleBuffering { + tile_matmul: AcceleratedTileKind, + }, + SimpleVecMat, + DoubleVecMat, + GemmNoStage, + GemvUnitPerpendicular, + SimpleUnit, + DoubleUnit, +} + +impl FusedMatmulSelector { + /// Not efficient, but only called once when initializing the tunables. + pub fn name(&self) -> String { + let name = match self { + FusedMatmulSelector::Simple { + multi_rows, + tile_matmul, + } => match multi_rows { + false => format!("simple_{tile_matmul:?}"), + true => format!("simple_multirows_{tile_matmul:?}"), + }, + FusedMatmulSelector::DoubleBuffering { + specialized, + tile_matmul, + } => match specialized { + false => format!("double_buffering_{tile_matmul:?}"), + true => format!("double_buffering_specialized_{tile_matmul:?}"), + }, + FusedMatmulSelector::OrderedDoubleBuffering { tile_matmul } => { + format!("double_buffering_ordered_{tile_matmul:?}").to_lowercase() + } + FusedMatmulSelector::SimpleVecMat => "simple_vec_mat".into(), + FusedMatmulSelector::DoubleVecMat => "double_buffering_vec_mat".into(), + FusedMatmulSelector::GemmNoStage => "gemm".into(), + FusedMatmulSelector::GemvUnitPerpendicular => "gemv_unit_perpendicular".into(), + FusedMatmulSelector::SimpleUnit => "simple_unit".into(), + FusedMatmulSelector::DoubleUnit => "double_buffering_unit".into(), + }; + + format!("fused_{name}") + } +} + +impl Default for FusedMatmulSelector { + fn default() -> Self { + FusedMatmulSelector::Simple { + multi_rows: false, + tile_matmul: AcceleratedTileKind::Cmma, + } + } +} + +#[derive(new, Clone, Serialize, Deserialize, Debug)] +pub struct FusedMatmul { + pub(crate) lhs: MatmulArg, + pub(crate) rhs: MatmulArg, + out: FuseArg, + pub(crate) op: BinaryOpIr, + pub(crate) selector: FusedMatmulSelector, +} + +#[derive(new)] +pub struct FusedMatmulLaunch<'a> { + pub(crate) matmul: &'a FusedMatmul, + pub(crate) selector: FusedMatmulSelector, +} + +#[derive(Debug)] +pub enum FusedMatmulError { + LaunchError(MatmulSetupError), + InvalidInput(&'static str), +} + +impl From for FusedMatmulError { + fn from(value: MatmulSetupError) -> Self { + Self::LaunchError(value) + } +} + +impl<'a, R: Runtime> Vectorization for FusedMatmulLaunch<'a> { + fn axis(&self, plan: &LaunchPlan<'_, R>) -> VectorizationAxis { + let lhs_id = self.matmul.op.lhs.id; + let rhs_id = self.matmul.op.rhs.id; + + let mut tensor_lhs = None; + let mut tensor_rhs = None; + + for input in plan.handle_inputs.iter() { + match input { + HandleInput::Normal(input) => { + if input.relative_id == lhs_id { + tensor_lhs = Some((input.global_ir.id, &input.handle.strides)); + } + if input.relative_id == rhs_id { + tensor_rhs = Some((input.global_ir.id, &input.handle.strides)); + } + } + HandleInput::QuantValues(input) => { + if input.relative_id == lhs_id { + tensor_lhs = Some((input.global_ir.id, &input.handle.strides)); + } + if input.relative_id == rhs_id { + tensor_rhs = Some((input.global_ir.id, &input.handle.strides)); + } + } + HandleInput::QuantParams(_) => {} + } + } + + let (lhs_id_global, lhs_strides) = tensor_lhs.unwrap(); + let (rhs_id_global, rhs_strides) = tensor_rhs.unwrap(); + + let mut axis = VectorizationAxis::default(); + + if let MatrixBatchLayout::MildlyPermuted { transposed, .. } = + matrix_batch_layout(lhs_strides, self.matmul.lhs.scheme()) + && transposed + { + axis.insert(lhs_id_global, lhs_strides.len() - 2); + } + + if let MatrixBatchLayout::MildlyPermuted { transposed, .. } = + matrix_batch_layout(rhs_strides, self.matmul.rhs.scheme()) + && transposed + { + axis.insert(rhs_id_global, rhs_strides.len() - 2); + } + + axis + } +} + +impl TraceRunner for FusedMatmulLaunch<'_> { + type Error = FusedMatmulError; + + fn run<'a>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + configs: &'a [FuseBlockConfig], + ) -> Result<(), FusedMatmulError> { + let global_elems = MatmulGlobalElems { + lhs: self.matmul.lhs.precision().into_storage_type(), + rhs: self.matmul.rhs.precision().into_storage_type(), + out: self.matmul.out.precision().into_storage_type(), + }; + let dtypes = MatmulElems::from_globals(&global_elems); + self.matmul_fused(client, inputs, outputs, &configs[0], dtypes) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +/// Which tile matmul to use for accelerated algorithms +pub enum AcceleratedTileKind { + #[default] + Cmma, + Mma, +} + +impl FusedMatmulLaunch<'_> { + fn matmul_fused<'a, R: Runtime>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + config: &'a FuseBlockConfig, + dtypes: MatmulElems, + ) -> Result<(), FusedMatmulError> { + let lhs_shape = inputs.shape(self.matmul.lhs.data()); + let rhs_shape = inputs.shape(self.matmul.rhs.data()); + let out_shape = outputs.shape_ref(&config.ref_layout, config.rank); + + let lhs_strides = inputs.strides(self.matmul.lhs.data()); + let lhs_scheme = self.matmul.lhs.scheme(); + let rhs_strides = inputs.strides(self.matmul.rhs.data()); + let rhs_scheme = self.matmul.rhs.scheme(); + + if matrix_batch_layout(&lhs_strides, lhs_scheme) == MatrixBatchLayout::HighlyPermuted { + return Err(FusedMatmulError::InvalidInput( + "Lhs needs to be contiguous, but can't when fusing.", + )); + } + if matrix_batch_layout(&rhs_strides, rhs_scheme) == MatrixBatchLayout::HighlyPermuted { + return Err(FusedMatmulError::InvalidInput( + "Rhs needs to be contiguous, but can't when fusing.", + )); + } + + let mut vector_sizes = MatmulVectorSizes { + lhs: inputs.vector_size(self.matmul.lhs.data()), + rhs: inputs.vector_size(self.matmul.rhs.data()), + out: match &config.ref_layout { + RefLayout::Concrete(arg) => match arg { + FuseArg::Input(..) => inputs.vector_size(arg), + FuseArg::Output(..) => outputs.vector_size(arg), + _ => panic!("Invalid ref layout"), + }, + RefLayout::Virtual(_) => 1, + }, + }; + + let address_type = inputs + .required_address_type() + .max(outputs.required_address_type()); + + if vector_sizes.out == 1 && (vector_sizes.lhs > 1 || vector_sizes.rhs > 1) { + return Err(FusedMatmulError::InvalidInput( + "Output vector size of 1 removes the gain from fusion", + )); + } + + if let MatmulArg::Quantized { scheme, .. } = self.matmul.lhs { + vector_sizes.lhs *= scheme.num_quants(); + } + if let MatmulArg::Quantized { scheme, .. } = self.matmul.rhs { + vector_sizes.rhs *= scheme.num_quants(); + } + + let out_strides = MatrixLayout::RowMajor.to_strides(&out_shape); + let problem = MatmulProblem::from_shapes_and_strides( + lhs_shape, + rhs_shape, + out_shape, + lhs_strides, + rhs_strides, + out_strides, + dtypes.as_global_elems(), + address_type, + self.matmul.lhs.scheme(), + self.matmul.rhs.scheme(), + )?; + + match self.selector { + FusedMatmulSelector::Simple { + multi_rows, + tile_matmul, + } => { + let args = SimpleArgs { + multi_rows, + tile_matmul: match tile_matmul { + AcceleratedTileKind::Cmma => TileMatmulKind::Cmma, + AcceleratedTileKind::Mma => TileMatmulKind::Mma, + }, + }; + + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &BlueprintStrategy::Inferred(args), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::DoubleBuffering { + specialized, + tile_matmul, + } => { + let args = DoubleBufferingArgs { + specialized, + tile_matmul: match tile_matmul { + AcceleratedTileKind::Cmma => TileMatmulKind::Cmma, + AcceleratedTileKind::Mma => TileMatmulKind::Mma, + }, + }; + + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &BlueprintStrategy::Inferred(args), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::OrderedDoubleBuffering { tile_matmul } => { + let row_count = match self.matmul.lhs.precision() { + FuseType::F16 | FuseType::BF16 => 8, + _ => 4, + }; + + let args = OrderedSelectionArgs { + row_count: Some(row_count), + rows_per_plane: Some(2), + partition_k: Some(2), + tile_matmul: match tile_matmul { + AcceleratedTileKind::Cmma => TileMatmulKind::Cmma, + AcceleratedTileKind::Mma => TileMatmulKind::Mma, + }, + }; + + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &BlueprintStrategy::Inferred(args), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::SimpleUnit => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::DoubleUnit => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::SimpleVecMat => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::DoubleVecMat => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::GemmNoStage => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + + FusedMatmulSelector::GemvUnitPerpendicular => { + match launch_inner_fix_dtype::( + client, + FusedMatmulInputLaunch::new( + inputs, + config.clone(), + self.matmul.lhs.clone(), + self.matmul.rhs.clone(), + None, + self.matmul.out.clone(), + ), + outputs, + problem, + vector_sizes, + &Default::default(), + ) { + Ok(_) => Ok(()), + Err(err) => Err(FusedMatmulError::LaunchError(err)), + } + } + } + } +} + +fn launch_inner_fix_dtype>( + client: &ComputeClient, + input: FusedMatmulInputLaunch, + output: GlobalArgsLaunch, + problem: MatmulProblem, + vector_sizes: MatmulVectorSizes, + blueprint_strategy: &BlueprintStrategy<(), A>, +) -> Result<(), MatmulSetupError> { + launch_kernel_virtual::( + client, + input, + output, + (), + problem, + vector_sizes, + blueprint_strategy, + ) +} diff --git a/crates/burn-cubecl-fusion/src/optim/matmul/tune.rs b/crates/burn-cubecl-fusion/src/optim/matmul/tune.rs new file mode 100644 index 0000000..fb35f99 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/matmul/tune.rs @@ -0,0 +1,299 @@ +use super::optimization::MatmulOptimizationTuneArg; +use crate::{ + CubeFusionHandle, + engine::trace::TuneOutput, + optim::matmul::{AcceleratedTileKind, FusedMatmulSelector}, + tune::{FusionInputGen, TuneInput}, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_fusion::stream::Context; +use cubecl::{ + AutotuneKey, CubeTuneId, Runtime, + std::tensor::MatrixBatchLayout, + tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}, +}; +use cubek::matmul::{ + definition::MatmulKind, + strategy::{MatmulAutotuneKey, MatmulGlobalScale, should_tune_double_buffering}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +pub struct FusedMatmulAutotuneKey { + matmul_key: MatmulAutotuneKey, + #[autotune(anchor)] + num_out_buffers: usize, + #[autotune(anchor)] + num_ops: usize, +} + +/// Executes autotune on matmul operations +pub fn fused_matmul_autotune( + optimization: MatmulOptimizationTuneArg, + context: &mut Context>, +) { + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + const PRIORITY_MAX: i8 = 3; + const PRIORITY_HIGH: i8 = 2; + const PRIORITY_MEDIUM: i8 = 1; + const PRIORITY_MIN: i8 = 0; + const PRIORITY_NEVER: i8 = -1; + + let accelerated = TuneGroup::::new("accelerated", |key| { + if matches!(key.matmul_key.analysis.kind, MatmulKind::General) { + match key.matmul_key.analysis.scale_global { + MatmulGlobalScale::Large => PRIORITY_MAX, + _ => PRIORITY_HIGH, + } + } else if matches!( + key.matmul_key.analysis.kind, + MatmulKind::MatVec | MatmulKind::VecMat + ) { + PRIORITY_MAX + } else { + PRIORITY_MEDIUM + } + }); + + let unit = TuneGroup::::new("unit", |key| { + if !matches!(key.matmul_key.analysis.kind, MatmulKind::General) + || matches!( + key.matmul_key.analysis.scale_global, + MatmulGlobalScale::Small + ) + { + PRIORITY_HIGH + } else { + PRIORITY_MEDIUM + } + }); + + let gemv = TuneGroup::::new("gemv", |key| { + if matches!(key.matmul_key.analysis.kind, MatmulKind::MatVec) { + // LHS is the matrix. + match key.matmul_key.definition.matrix_layout_lhs { + MatrixBatchLayout::Contiguous => PRIORITY_MAX, + MatrixBatchLayout::MildlyPermuted { transposed, .. } => { + if transposed { + PRIORITY_HIGH + } else { + PRIORITY_MAX + } + } + MatrixBatchLayout::HighlyPermuted => PRIORITY_MAX, + } + } else if matches!(key.matmul_key.analysis.kind, MatmulKind::VecMat) { + // RHS is the matrix. + match key.matmul_key.definition.matrix_layout_rhs { + MatrixBatchLayout::Contiguous => PRIORITY_HIGH, + MatrixBatchLayout::MildlyPermuted { transposed, .. } => { + if transposed { + PRIORITY_MAX + } else { + PRIORITY_HIGH + } + } + MatrixBatchLayout::HighlyPermuted => PRIORITY_HIGH, + } + } else { + PRIORITY_NEVER + } + }); + + let odd = TuneGroup::::new("odd", |key| { + if key.matmul_key.definition.lhs_pow2_factor == 0 + || key.matmul_key.definition.rhs_pow2_factor == 0 + { + PRIORITY_MAX + } else { + PRIORITY_MIN + } + }); + + fn double_buffering_priority(key: &FusedMatmulAutotuneKey, max: i8, min: i8) -> i8 { + if should_tune_double_buffering(key.num_out_buffers > 1, &key.matmul_key) { + max + } else { + min + } + } + + // First entry should always work, since it is considered the fallback. + let mut set = TunableSet::new(create_key::, FusionInputGen).with( + Tunable::new("fused_matmul_fallback", tune_fallback::).group(&unit, |key| { + if matches!(key.matmul_key.analysis.kind, MatmulKind::InnerProduct) { + PRIORITY_MAX + } else if matches!( + key.matmul_key.analysis.scale_global, + MatmulGlobalScale::Small + ) { + PRIORITY_HIGH + } else { + PRIORITY_MIN + } + }), + ); + + // Vector-matrix kernels. + for (selector, double_buf) in [ + (FusedMatmulSelector::SimpleVecMat, false), + (FusedMatmulSelector::DoubleVecMat, true), + (FusedMatmulSelector::GemmNoStage, false), + (FusedMatmulSelector::GemvUnitPerpendicular, false), + ] { + set = set.with( + Tunable::new(&selector.name(), move |input| { + tune_fused::(input, selector) + }) + .group(&gemv, move |key| match double_buf { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }), + ); + } + + // Unit matmuls + for (selector, double_buf) in [ + (FusedMatmulSelector::SimpleUnit, false), + (FusedMatmulSelector::DoubleUnit, true), + ] { + set = set.with( + Tunable::new(&selector.name(), move |input| { + tune_fused::(input, selector) + }) + .group(&unit, move |key| match double_buf { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }), + ); + } + + // Accelerated matmuls + for tile_matmul in [AcceleratedTileKind::Cmma, AcceleratedTileKind::Mma] { + for (selector, double_buf, extra_group) in [ + ( + FusedMatmulSelector::Simple { + multi_rows: false, + tile_matmul, + }, + false, + None, + ), + ( + FusedMatmulSelector::Simple { + multi_rows: true, + tile_matmul, + }, + false, + None, + ), + ( + FusedMatmulSelector::OrderedDoubleBuffering { tile_matmul }, + true, + None, + ), + ( + FusedMatmulSelector::DoubleBuffering { + specialized: false, + tile_matmul, + }, + true, + None, + ), + ( + FusedMatmulSelector::DoubleBuffering { + specialized: true, + tile_matmul, + }, + true, + Some(&odd), + ), + ] { + let priority_within_group = + |key: &FusedMatmulAutotuneKey, double_buf: bool| match double_buf { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }; + let mut tunable = Tunable::new(&selector.name(), move |input| { + tune_fused::(input, selector) + }) + .group(&accelerated, move |key| { + priority_within_group(key, double_buf) + }); + + if let Some(group) = extra_group { + tunable = + tunable.group(group, move |key| priority_within_group(key, double_buf)); + } + set = set.with(tunable); + } + } + + set + }); + + TUNER.execute( + &CubeTuneId::new(&optimization.info.client, &optimization.info.device), + &optimization.info.client.clone(), + tunables, + TuneInput::new(context, optimization), + ); +} + +pub(crate) fn create_key( + input: &TuneInput>, +) -> FusedMatmulAutotuneKey { + let opt = input.optimization(); + assert!(input.is_original(), "Not supported when generating key"); + let tensors = input.tensors(); + let handles = input.handles(); + + let lhs = tensors.get(&opt.info.matmul.op.lhs.id).unwrap(); + let rhs = tensors.get(&opt.info.matmul.op.rhs.id).unwrap(); + let out = tensors.get(&opt.info.matmul.op.out.id).unwrap(); + + let lhs_strides = handles + .get_handle_ref(&lhs.id) + .expect("lhs handle") + .strides + .clone(); + let rhs_strides = handles + .get_handle_ref(&rhs.id) + .expect("rhs handle") + .strides + .clone(); + + let key = MatmulAutotuneKey::generate( + &opt.info.client, + &lhs.shape, + &rhs.shape, + &lhs_strides, + &rhs_strides, + dtype_to_storage_type(lhs.dtype), + dtype_to_storage_type(rhs.dtype), + dtype_to_storage_type(out.dtype), + opt.info.matmul.lhs.scheme(), + opt.info.matmul.rhs.scheme(), + ); + FusedMatmulAutotuneKey::new(key, opt.info.num_output_buffers(), opt.info.num_ops_fused()) +} + +fn tune_fused( + input: TuneInput>, + selector: FusedMatmulSelector, +) -> Result, String> { + let is_original = input.is_original(); + input.execute(|ctx, opt| match opt.execute_fused(ctx, selector) { + Ok(out) => Ok(out), + Err(_) if is_original => Ok(opt.execute_fallback(ctx)), + Err(e) => Err(format!("{e:?}")), + }) +} + +fn tune_fallback( + input: TuneInput>, +) -> Result, String> { + Ok(input.execute(|ctx, opt| opt.execute_fallback(ctx))) +} diff --git a/crates/burn-cubecl-fusion/src/optim/mod.rs b/crates/burn-cubecl-fusion/src/optim/mod.rs new file mode 100644 index 0000000..ff3f4d8 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/mod.rs @@ -0,0 +1,8 @@ +pub mod elemwise; +pub mod matmul; +pub mod reduce; +pub mod reduce_broadcasted; + +mod base; + +pub use base::*; diff --git a/crates/burn-cubecl-fusion/src/optim/reduce/args.rs b/crates/burn-cubecl-fusion/src/optim/reduce/args.rs new file mode 100644 index 0000000..0ac5f79 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce/args.rs @@ -0,0 +1,246 @@ +use crate::engine::codegen::{ + io::{ref_buffer_len, ref_len, ref_shape, ref_stride, ref_vector_size}, + ir::{FuseArg, FuseBlockConfig, GlobalArgs, GlobalArgsExpand, LocalArgs, LocalArgsExpand}, + kernel::{fuse_on_read, fuse_on_write, init_locals}, +}; +use cubecl::prelude::*; +use cubek::reduce::components::args::{ReduceArgs, ReduceDType}; + +#[derive(Clone)] +pub struct FusedReduceArgs; + +#[derive(CubeType, CubeLaunch)] +pub struct FusedReduceInput { + pub global: GlobalArgs, + #[cube(comptime)] + pub config: FuseBlockConfig, + #[cube(comptime)] + pub arg: FuseArg, +} + +#[derive(CubeType, CubeLaunch)] +pub struct FusedReduceOutput { + pub global: GlobalArgs, + #[cube(comptime)] + pub config: FuseBlockConfig, + #[cube(comptime)] + pub arg: FuseArg, +} + +#[derive(Clone)] +pub struct FusedReduceState { + inputs: GlobalArgs, + outputs: GlobalArgs, + locals_on_read: LocalArgs, + locals_on_write: LocalArgs, + config_on_read: FuseBlockConfig, + config_on_write: FuseBlockConfig, + // TODO: Should be a list when multiple blocks are there. + input: FuseArg, + out: FuseArg, +} + +#[derive(Clone)] +pub struct FusedReduceStateExpand { + inputs: GlobalArgsExpand, + outputs: GlobalArgsExpand, + locals_on_read: LocalArgsExpand, + locals_on_write: LocalArgsExpand, + config_on_read: FuseBlockConfig, + config_on_write: FuseBlockConfig, + input: FuseArg, + out: FuseArg, +} + +#[cube] +impl ReduceArgs for FusedReduceArgs { + type Input = FusedReduceInput; + type Output = FusedReduceOutput; + type State = FusedReduceState; + + fn init_state( + input: &Self::Input, + output: &mut Self::Output, + ) -> Self::State

{ + let mut locals_read = init_locals(&input.global, &mut output.global, &input.config); + let mut locals_write = init_locals(&input.global, &mut output.global, &output.config); + // TODO Add stuff from previous blocks to the local of each block. + FusedReduceState::new(input, output, &mut locals_read, &mut locals_write) + } + + fn read_input( + state: &Self::State

, + index: usize, + ) -> Vector { + let mut state = state.clone(); + let value = fuse_on_read::( + &state.inputs, + &mut state.outputs, + &mut state.locals_on_read, + index, + comptime! { + let mut sequence = Sequence::new(); + // TODO: Register local arguments from previous blocks. + sequence.push(state.input.clone()); + sequence + }, + &state.config_on_read, + ); + value[0] + } + + fn read_output( + _state: &Self::State

, + _index: usize, + ) -> Vector { + Vector::empty() + } + + fn write_output( + state: &mut Self::State

, + index: usize, + value: Vector, + ) { + let mut values = Registry::>::new(); + let mut args = comptime![Vec::::new()]; + + values.insert(comptime![state.out.clone()], value); + comptime![args.push(state.out.clone())]; + fuse_on_write( + &state.inputs, + &mut state.outputs, + &mut state.locals_on_write, + index, + values, + args, + &state.config_on_write, + ); + } + + fn len_input(state: &Self::State

) -> usize { + ref_len( + &state.inputs, + &state.outputs, + &state.locals_on_read, + &state.config_on_read, + ) + } + + fn len_output(state: &Self::State

) -> usize { + ref_len( + &state.inputs, + &state.outputs, + &state.locals_on_write, + &state.config_on_write, + ) + } + + fn buffer_len_input(state: &Self::State

) -> usize { + ref_buffer_len( + &state.inputs, + &state.outputs, + &state.locals_on_read, + &state.config_on_read, + ) + } + + fn buffer_len_output(state: &Self::State

) -> usize { + ref_buffer_len( + &state.inputs, + &state.outputs, + &state.locals_on_write, + &state.config_on_write, + ) + } + + fn rank_input(state: &Self::State

) -> usize { + state.config_on_read.rank.runtime() + } + + fn rank_output(state: &Self::State

) -> usize { + state.config_on_write.rank.runtime() + } + + fn shape_input(state: &Self::State

, dim: usize) -> usize { + ref_shape(&state.locals_on_read, dim) + } + + fn shape_output(state: &Self::State

, dim: usize) -> usize { + ref_shape(&state.locals_on_write, dim) + } + + fn stride_input(state: &Self::State

, dim: usize) -> usize { + ref_stride(&state.locals_on_read, dim) + } + + fn stride_output(state: &Self::State

, dim: usize) -> usize { + ref_stride(&state.locals_on_write, dim) + } + + fn vector_size_input(state: &Self::State

) -> comptime_type!(VectorSize) { + ref_vector_size(&state.locals_on_read) + } + + fn vector_size_output(state: &Self::State

) -> comptime_type!(VectorSize) { + ref_vector_size(&state.locals_on_write) + } +} + +#[cube] +impl FusedReduceState { + pub fn new( + inputs: &FusedReduceInput, + outputs: &mut FusedReduceOutput, + locals_on_read: &mut LocalArgs, + locals_on_write: &mut LocalArgs, + ) -> FusedReduceState { + FusedReduceState { + inputs: inputs.global.clone(), + outputs: outputs.global.clone(), + locals_on_read: locals_on_read.clone(), + locals_on_write: locals_on_write.clone(), + config_on_read: comptime![inputs.config.clone()], + config_on_write: comptime![outputs.config.clone()], + input: comptime![inputs.arg.clone()], + out: comptime![outputs.arg.clone()], + } + } +} + +impl CubeType for FusedReduceState { + type ExpandType = FusedReduceStateExpand; +} + +impl IntoExpand for FusedReduceStateExpand { + type Expand = Self; + + fn into_expand(self, _: &Scope) -> Self::Expand { + self + } +} + +impl ExpandTypeClone for FusedReduceStateExpand { + fn clone_unchecked(&self) -> Self { + self.clone() + } +} + +impl AsRefExpand for FusedReduceStateExpand { + fn __expand_ref_method(&self, _: &Scope) -> &Self { + self + } +} + +impl AsMutExpand for FusedReduceStateExpand { + fn __expand_ref_mut_method(&mut self, _: &Scope) -> &mut Self { + self + } +} + +impl IntoMut for FusedReduceStateExpand { + fn into_mut(self, _context: &Scope) -> Self { + self + } +} + +impl CubeDebug for FusedReduceStateExpand {} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce/fuser.rs b/crates/burn-cubecl-fusion/src/optim/reduce/fuser.rs new file mode 100644 index 0000000..2aa0c59 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce/fuser.rs @@ -0,0 +1,314 @@ +use super::{ + ReduceSettings, + optimization::{FusedReduce, ReduceInstruction, ReduceOptimization}, +}; +use crate::{ + engine::{ + codegen::ir::FuseType, + fuser::TraceOperationFuser, + settings::{FuseSettings, RefLayoutSetting, VectorizationSetting}, + }, + optim::CubeOptimization, +}; +use burn_fusion::{FuserStatus, OperationFuser}; +use burn_ir::{NumericOperationIr, OperationIr, ReduceDimOpIr}; +use burn_std::Shape; +use cubecl::Runtime; + +/// Fuses element wise operations around a reduce operation. +pub struct ReduceFuser { + pub(crate) fuser: TraceOperationFuser, + pub(crate) fuser_read_fallback: TraceOperationFuser, + fuser_write_fallback: TraceOperationFuser, + settings_write: FuseSettings, + pub(crate) device: R::Device, + pub(crate) reduce: Option, + settings: ReduceSettings, +} + +impl Clone for ReduceFuser { + fn clone(&self) -> Self { + Self { + fuser: self.fuser.clone(), + fuser_read_fallback: self.fuser_read_fallback.clone(), + fuser_write_fallback: self.fuser_write_fallback.clone(), + settings_write: self.settings_write, + device: self.device.clone(), + reduce: self.reduce.clone(), + settings: self.settings, + } + } +} + +#[derive(Debug)] +pub enum ReduceFuserInfo { + FusedReduce { shape_input_id: Shape, axis: usize }, + FusedElemwise { shape_id: Shape }, +} + +impl ReduceFuser { + pub fn new(device: R::Device, settings: ReduceSettings) -> Self { + let client = R::client(&device); + let props = client.properties(); + let max_bindings = props.hardware.max_bindings; + let settings_read = FuseSettings { + // Inplace would work, but not when we have a concrete output to write too. + inplace: true, + ref_layout: RefLayoutSetting::OnlyContiguous, + broadcast: false, + output_shape_updates: true, + vectorization: VectorizationSetting::Activated, + }; + let settings_write = FuseSettings { + inplace: false, + output_shape_updates: false, + vectorization: VectorizationSetting::SmallerOrEqualThanPreviousBlock { block_pos: 0 }, + broadcast: false, + ref_layout: RefLayoutSetting::OnlyContiguous, + }; + let settings_fallback = FuseSettings::default(); + + Self { + fuser: TraceOperationFuser::new(max_bindings, settings_read), + fuser_read_fallback: TraceOperationFuser::new(max_bindings, settings_fallback), + fuser_write_fallback: TraceOperationFuser::new(max_bindings, settings_fallback), + settings_write, + device, + reduce: None, + settings, + } + } + + pub fn reduce_info(&self) -> ReduceFuserInfo { + match &self.reduce { + Some(reduce) => { + let shape_input_id = reduce.op.input.shape.clone(); + let axis = reduce.axis; + + ReduceFuserInfo::FusedReduce { + shape_input_id, + axis, + } + } + None => { + let shape_id = self.fuser_read_fallback.current_output_shape.clone(); + ReduceFuserInfo::FusedElemwise { shape_id } + } + } + } + + fn on_reduce(&mut self, op: &ReduceDimOpIr, inst: ReduceInstruction) { + // TODO: Fix: we need to have fuse-on-read with an identity block. + // + // if self.fuser.num_ops == 0 && false { + // self.fuser.current_output_shape = op.input.shape.dims.clone(); + // } else if self.fuser.current_output_shape != op.input.shape.dims { + + if self.fuser.current_output_shape != op.input.shape { + self.fuser.close(); + self.fuser_read_fallback.close(); + return; + } + + let [input] = self + .fuser + .next_block([&op.input], self.settings_write, false); + + let output = self.fuser.output_unhandled(&op.out); + let axis = op.axis; + + let fuse_on_write_activated = match self.settings { + ReduceSettings::Always => true, + // We only activate fuse-on-write when the reduction isn't on the last dimension, otherwise + // vectorization is impossible. Only [VectorizationMode::Perpendicular] supports vectorization. + // + // We could still fuse some output operations, but it would probably lead to worse performance. + ReduceSettings::OnlyParallel => axis != op.input.shape.rank() - 1, + ReduceSettings::Never => false, + }; + + if !fuse_on_write_activated { + self.fuser.close(); + } + + let acc = match inst { + ReduceInstruction::Mean | ReduceInstruction::Prod | ReduceInstruction::Sum => { + match input.precision() { + FuseType::F16 | FuseType::BF16 => FuseType::F32, + FuseType::I16 | FuseType::I8 => FuseType::I32, + FuseType::U16 | FuseType::U8 => FuseType::U32, + _ => input.precision(), + } + } + _ => input.precision(), + }; + + self.reduce = Some(FusedReduce { + input, + output, + acc, + axis, + op: op.clone(), + use_planes: false, + shared: false, + inst, + }); + + self.fuser_read_fallback.close(); + } + + fn on_elemwise_read(&mut self, operation: &OperationIr) { + let can_register = + self.fuser.can_fuse(operation) && self.fuser_read_fallback.can_fuse(operation); + + match can_register { + true => { + self.fuser.fuse(operation); + self.fuser_read_fallback.fuse(operation); + } + false => { + self.fuser.close(); + self.fuser_read_fallback.close(); + } + }; + } + + fn on_elemwise_write(&mut self, operation: &OperationIr) { + let can_register = + self.fuser.can_fuse(operation) && self.fuser_write_fallback.can_fuse(operation); + + match can_register { + true => { + self.fuser.fuse(operation); + self.fuser_write_fallback.fuse(operation); + } + false => { + self.fuser.close(); + self.fuser_write_fallback.close(); + } + }; + } +} + +impl OperationFuser> for ReduceFuser { + fn fuse(&mut self, operation: &OperationIr) { + if let FuserStatus::Closed = self.fuser.status() { + return; + } + + if self.reduce.is_none() { + if let OperationIr::NumericFloat(_, op) = operation { + match op { + NumericOperationIr::SumDim(op) => { + self.on_reduce(op, ReduceInstruction::Sum); + } + NumericOperationIr::MeanDim(op) => { + self.on_reduce(op, ReduceInstruction::Mean); + } + NumericOperationIr::ProdDim(op) => { + self.on_reduce(op, ReduceInstruction::Prod); + } + NumericOperationIr::ArgMax(op) => { + self.on_reduce(op, ReduceInstruction::ArgMax); + } + NumericOperationIr::ArgMin(op) => { + self.on_reduce(op, ReduceInstruction::ArgMin); + } + NumericOperationIr::MinDim(op) => { + self.on_reduce(op, ReduceInstruction::Min); + } + NumericOperationIr::MaxDim(op) => { + self.on_reduce(op, ReduceInstruction::Max); + } + NumericOperationIr::MaxAbsDim(op) => { + self.on_reduce(op, ReduceInstruction::MaxAbs); + } + _ => { + self.on_elemwise_read(operation); + } + }; + } else if let OperationIr::NumericInt(_, op) = operation { + match op { + NumericOperationIr::SumDim(op) => { + self.on_reduce(op, ReduceInstruction::Sum); + } + NumericOperationIr::MeanDim(op) => { + self.on_reduce(op, ReduceInstruction::Mean); + } + NumericOperationIr::ProdDim(op) => { + self.on_reduce(op, ReduceInstruction::Prod); + } + NumericOperationIr::ArgMax(op) => { + self.on_reduce(op, ReduceInstruction::ArgMax); + } + NumericOperationIr::ArgMin(op) => { + self.on_reduce(op, ReduceInstruction::ArgMin); + } + NumericOperationIr::MinDim(op) => { + self.on_reduce(op, ReduceInstruction::Min); + } + NumericOperationIr::MaxDim(op) => { + self.on_reduce(op, ReduceInstruction::Max); + } + NumericOperationIr::MaxAbsDim(op) => { + self.on_reduce(op, ReduceInstruction::MaxAbs); + } + _ => { + self.on_elemwise_read(operation); + } + }; + } else { + self.on_elemwise_read(operation); + } + } else { + self.on_elemwise_write(operation); + } + } + + fn finish(&mut self) -> CubeOptimization { + let client = R::client(&self.device); + let trace = self.fuser.finish(); + let trace_read_fallback = self.fuser_read_fallback.finish(); + let trace_write_fallback = self.fuser_write_fallback.finish(); + let fuse_reduce = self.reduce.as_ref().unwrap(); + + let reduce = ReduceOptimization::new( + trace, + trace_read_fallback, + trace_write_fallback, + client, + self.device.clone(), + self.len(), + self.fuser_read_fallback.len(), + fuse_reduce.clone(), + self.settings, + ); + + CubeOptimization::Reduce(reduce) + } + + fn reset(&mut self) { + self.fuser.reset(); + self.fuser_read_fallback.reset(); + self.fuser_write_fallback.reset(); + self.reduce = None; + } + + fn status(&self) -> burn_fusion::FuserStatus { + self.fuser.status() + } + + fn properties(&self) -> burn_fusion::FuserProperties { + let mut properties = self.fuser.properties(); + properties.ready = self.reduce.is_some(); + properties + } + + fn len(&self) -> usize { + self.fuser.len() + if self.reduce.is_some() { 1 } else { 0 } + } + + fn clone_dyn(&self) -> Box>> { + Box::new(self.clone()) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce/mod.rs b/crates/burn-cubecl-fusion/src/optim/reduce/mod.rs new file mode 100644 index 0000000..75a082a --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce/mod.rs @@ -0,0 +1,8 @@ +mod fuser; +mod optimization; + +pub(crate) mod args; +pub(crate) mod tune; + +pub use fuser::*; +pub use optimization::*; diff --git a/crates/burn-cubecl-fusion/src/optim/reduce/optimization.rs b/crates/burn-cubecl-fusion/src/optim/reduce/optimization.rs new file mode 100644 index 0000000..3836bd5 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce/optimization.rs @@ -0,0 +1,518 @@ +use super::args::{ + FusedReduceInput, FusedReduceInputLaunch, FusedReduceOutput, FusedReduceOutputLaunch, +}; +#[cfg(feature = "autotune")] +use super::tune::fused_reduce_autotune; +use crate::{ + CubeFusionHandle, FallbackOperation, + engine::{ + codegen::ir::{ + FuseArg, FuseBlockConfig, FuseType, GlobalArgsLaunch, RefLayout, + multi_block_variables_init, + }, + launch::{ + FuseTraceLauncher, + runner::{TraceRunner, Vectorization}, + }, + trace::{FuseTrace, TraceError, TuneOutput}, + }, + optim::{elemwise::ElemwiseRunner, reduce::args::FusedReduceArgs}, +}; +use burn_backend::cubecl::{dtype_to_storage_type, elem_type_to_dtype}; +use burn_fusion::stream::Context; +use burn_ir::ReduceDimOpIr; +use burn_std::DType; +use cubecl::{Runtime, client::ComputeClient, ir::StorageType, prelude::*}; +use cubek::reduce::{ + ReduceDtypes, ReduceError, VectorizationMode, + components::instructions::ReduceOperationConfig, + init_tensors, + launch::{RoutineStrategy, reduce_kernel_virtual}, + output_vectorization_axis, + routines::{ + ReduceBlueprint, ReduceLaunchSettings, ReduceProblem, ReduceVectorSettings, Routine, + cube::CubeRoutine, plane::PlaneRoutine, unit::UnitRoutine, + }, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[cfg(not(feature = "autotune"))] +use cubek::reduce::routines::{BlueprintStrategy, unit::UnitStrategy}; + +pub struct ReduceOptimization { + pub(crate) info: Arc>, +} + +pub(crate) struct ReduceOptimizationInfo { + pub(crate) trace: FuseTrace, + trace_read_fallback: FuseTrace, + trace_write_fallback: FuseTrace, + pub(crate) client: ComputeClient, + pub(crate) device: R::Device, + pub(crate) len: usize, + pub(crate) len_read: usize, + pub(crate) reduce: FusedReduce, + settings: ReduceSettings, +} + +impl ReduceOptimizationInfo { + pub fn from_state(device: &R::Device, state: ReduceOptimizationState) -> Self { + let client = R::client(device); + + Self { + trace: state.trace, + trace_read_fallback: state.trace_read_fallback, + trace_write_fallback: state.trace_write_fallback, + client, + device: device.clone(), + len: state.len, + len_read: state.len_read, + reduce: state.reduce, + settings: state.settings, + } + } + pub fn to_state(&self) -> ReduceOptimizationState { + ReduceOptimizationState { + trace: self.trace.clone(), + trace_read_fallback: self.trace_read_fallback.clone(), + trace_write_fallback: self.trace_write_fallback.clone(), + len: self.len, + len_read: self.len_read, + reduce: self.reduce.clone(), + settings: self.settings, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone)] +pub enum ReduceSettings { + Always, + /// We only activate fuse-on-write when the reduction isn't on the last dimension, otherwise + /// vectorization is impossible. Only [VectorizationMode::Perpendicular] supports vectorization. + /// + /// We could still fuse some output operations, but it would probably lead to worse performance. + OnlyParallel, + Never, +} + +pub(crate) struct ReduceOptimizationTuneArg { + pub(crate) info: Arc>, + pub(crate) fallback: Arc>>, +} + +impl Clone for ReduceOptimizationTuneArg { + fn clone(&self) -> Self { + Self { + info: self.info.clone(), + fallback: self.fallback.clone(), + } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize, Debug)] +pub enum ReduceInstruction { + ArgMax, + ArgMin, + Mean, + Prod, + Sum, + Max, + Min, + MaxAbs, +} + +pub trait ReduceFallbackFn: Send + Sync { + fn run(&self, context: &mut Context>); +} + +#[derive(Serialize, Deserialize)] +pub struct ReduceOptimizationState { + pub(crate) trace: FuseTrace, + pub(crate) trace_read_fallback: FuseTrace, + pub(crate) trace_write_fallback: FuseTrace, + pub(crate) reduce: FusedReduce, + pub(crate) len: usize, + pub(crate) len_read: usize, + pub(crate) settings: ReduceSettings, +} + +impl core::fmt::Debug for ReduceOptimizationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{{ len_read: {}, len_total: {} }}", + self.len_read, self.len + )) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FusedReduce { + pub(crate) input: FuseArg, + pub(crate) output: FuseArg, + pub(crate) acc: FuseType, + pub(crate) axis: usize, + pub(crate) op: ReduceDimOpIr, + pub(crate) use_planes: bool, + pub(crate) shared: bool, + pub(crate) inst: ReduceInstruction, +} + +#[derive(new)] +pub struct FusedReduceLaunch<'a> { + reduce: &'a FusedReduce, + strategy: RoutineStrategy, +} + +#[derive(Debug)] +pub enum FusedReduceError { + Reduce(ReduceError), + InvalidSelection(Box<&'static str>), + InvalidInput, +} + +impl From for FusedReduceError { + fn from(value: ReduceError) -> Self { + Self::Reduce(value) + } +} + +impl ReduceOptimizationTuneArg { + pub fn execute_fused( + &self, + context: &mut Context>, + strategy: RoutineStrategy, + ) -> Result, TraceError> { + let launch = FusedReduceLaunch::new(&self.info.reduce, strategy); + let launcher = FuseTraceLauncher::new(&self.info.trace, &launch); + launcher.launch(&self.info.client, &self.info.device, context) + } + + pub fn execute_fallback(&self, context: &mut Context>) -> TuneOutput { + let launcher = FuseTraceLauncher::new(&self.info.trace_read_fallback, &ElemwiseRunner); + + #[allow(unused_mut)] // It is used when `autotune-checks` is activated. + let mut output_read = launcher + .launch(&self.info.client, &self.info.device, context) + .unwrap(); + + self.fallback.run(context); + + #[cfg(feature = "autotune-checks")] + if let TuneOutput::Checked { handles } = &mut output_read { + let out_desc = context.tensors.get(&self.info.reduce.op.out.id).unwrap(); + let handle_out = context + .handles + .get_handle(&out_desc.id, &burn_ir::TensorStatus::ReadOnly); + + handles.insert( + self.info.reduce.op.out.id, + (out_desc.shape.clone(), handle_out.clone()), + ); + } + + let launcher = FuseTraceLauncher::new(&self.info.trace_write_fallback, &ElemwiseRunner); + + let output_write = launcher + .launch(&self.info.client, &self.info.device, context) + .unwrap(); + + output_read.merge(output_write) + } +} + +#[allow(clippy::too_many_arguments)] +impl ReduceOptimization { + pub fn new( + trace: FuseTrace, + trace_read_fallback: FuseTrace, + trace_write_fallback: FuseTrace, + client: ComputeClient, + device: R::Device, + len: usize, + len_read: usize, + reduce: FusedReduce, + settings: ReduceSettings, + ) -> Self { + let info = ReduceOptimizationInfo { + trace, + trace_read_fallback, + trace_write_fallback, + client, + device, + len, + len_read, + reduce, + settings, + }; + + Self { + info: Arc::new(info), + } + } + /// Execute the optimization. + pub fn execute( + &mut self, + context: &mut Context>, + fallback: impl FnOnce(usize) -> Box>, + ) { + // The index of the fallback reduce is the number of ops fused as read. + let fallback = fallback(self.info.len_read); + let arg = ReduceOptimizationTuneArg { + info: self.info.clone(), + fallback: Arc::new(fallback), + }; + + #[cfg(feature = "autotune")] + fused_reduce_autotune::(arg, context); + + #[cfg(not(feature = "autotune"))] + if arg + .execute_fused( + context, + RoutineStrategy::Unit(BlueprintStrategy::Inferred(UnitStrategy)), + ) + .is_err() + { + arg.execute_fallback(context); + } + } + + pub fn num_output_buffers(&self) -> usize { + self.info.trace_read_fallback.resources.outputs.len() + } + + pub fn to_state(&self) -> ReduceOptimizationState { + ReduceOptimizationState { + trace: self.info.trace.clone(), + trace_read_fallback: self.info.trace_read_fallback.clone(), + trace_write_fallback: self.info.trace_write_fallback.clone(), + reduce: self.info.reduce.clone(), + len: self.info.len, + len_read: self.info.len_read, + settings: self.info.settings, + } + } + + pub fn from_state(device: &R::Device, state: ReduceOptimizationState) -> Self { + let client = R::client(device); + + let info = ReduceOptimizationInfo { + trace: state.trace, + trace_read_fallback: state.trace_read_fallback, + trace_write_fallback: state.trace_write_fallback, + reduce: state.reduce, + len: state.len, + len_read: state.len_read, + client, + device: device.clone(), + settings: state.settings, + }; + + Self { + info: Arc::new(info), + } + } + + /// Returns the number of output buffers added by fusion. + pub fn num_ops_fused(&self) -> usize { + self.info.len + } +} + +// TODO: Implement better vectorization here. +impl Vectorization for FusedReduceLaunch<'_> {} + +impl TraceRunner for FusedReduceLaunch<'_> { + type Error = FusedReduceError; + + fn run<'a>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + configs: &'a [FuseBlockConfig], + ) -> Result<(), FusedReduceError> { + let [config_read, config_write] = [&configs[0], &configs[1]]; + let shape = match &config_read.ref_layout { + RefLayout::Concrete(FuseArg::Output(..)) => { + outputs.shape_ref(&config_read.ref_layout, config_read.rank) + } + _ => inputs.shape_ref(&config_read.ref_layout, config_read.rank), + }; + let reduce_count: usize = shape + .iter() + .enumerate() + .map(|(i, s)| if i == self.reduce.axis { 1 } else { *s }) + .product(); + + let vectorization_mode = match self.reduce.axis == config_read.rank - 1 { + true => VectorizationMode::Parallel, + false => VectorizationMode::Perpendicular, + }; + let address_type = inputs + .required_address_type() + .max(outputs.required_address_type()); + + let settings = ReduceVectorSettings { + vectorization_mode, + vector_size_input: config_read.width, + vector_size_output: config_write.width, + }; + let problem = ReduceProblem { + reduce_len: shape[self.reduce.axis], + reduce_count, + axis: self.reduce.axis, + dtypes: ReduceDtypes { + input: dtype_to_storage_type(self.reduce.op.input.dtype), + output: dtype_to_storage_type(self.reduce.op.out.dtype), + accumulation: self.reduce.acc.into_elem().into(), + }, + address_type, + instruction: reduce_instruction2config(&self.reduce.inst), + }; + + let (blueprint, settings) = match self.strategy.clone() { + RoutineStrategy::Unit(strategy) => { + let routine = UnitRoutine; + routine.prepare(client, problem, settings, strategy)? + } + RoutineStrategy::Plane(strategy) => { + let routine = PlaneRoutine; + routine.prepare(client, problem, settings, strategy)? + } + RoutineStrategy::Cube(strategy) => { + let routine = CubeRoutine; + routine.prepare(client, problem, settings, strategy)? + } + }; + + let out_vec_axis = output_vectorization_axis( + &inputs.strides_ref(&config_read.ref_layout, config_read.rank), + self.reduce.axis, + vectorization_mode, + ); + + let kwargs = ReduceKwArgs { + client, + inputs, + outputs, + reduce_axis: self.reduce.axis, + out_vec_axis, + config_fuse_read: config_read.clone(), + config_fuse_write: config_write.clone(), + input: self.reduce.input.clone(), + output: self.reduce.output.clone(), + blueprint, + settings, + }; + let result = launch_reduce_mixed_precision( + kwargs, + self.reduce.inst, + self.reduce.op.input.dtype, + self.reduce.op.out.dtype, + elem_type_to_dtype(self.reduce.acc.into_elem()), + ); + + match result { + Ok(_) => Ok(()), + Err(err) => Err(FusedReduceError::Reduce(ReduceError::Launch(err))), + } + } +} + +struct ReduceKwArgs<'b, Run: Runtime> { + client: &'b ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + reduce_axis: usize, + out_vec_axis: usize, + blueprint: ReduceBlueprint, + settings: ReduceLaunchSettings, + config_fuse_read: FuseBlockConfig, + config_fuse_write: FuseBlockConfig, + input: FuseArg, + output: FuseArg, +} + +fn launch_reduce_mixed_precision( + kwargs: ReduceKwArgs<'_, Run>, + instruction: ReduceInstruction, + dtype_input: DType, + dtype_output: DType, + dtype_acc: DType, +) -> Result<(), LaunchError> { + let config = reduce_instruction2config(&instruction); + launch_reduce::(kwargs, config, dtype_input, dtype_output, dtype_acc) +} + +pub(crate) fn reduce_instruction2config(instruction: &ReduceInstruction) -> ReduceOperationConfig { + match instruction { + ReduceInstruction::ArgMax => ReduceOperationConfig::ArgMax, + ReduceInstruction::ArgMin => ReduceOperationConfig::ArgMin, + ReduceInstruction::Prod => ReduceOperationConfig::Prod, + ReduceInstruction::Mean => ReduceOperationConfig::Mean, + ReduceInstruction::Sum => ReduceOperationConfig::Sum, + ReduceInstruction::Max => ReduceOperationConfig::Max, + ReduceInstruction::Min => ReduceOperationConfig::Min, + ReduceInstruction::MaxAbs => ReduceOperationConfig::MaxAbs, + } +} + +fn launch_reduce( + kwargs: ReduceKwArgs<'_, Run>, + inst: ReduceOperationConfig, + dtype_input: DType, + dtype_output: DType, + dtype_acc: DType, +) -> Result<(), LaunchError> { + unsafe { + reduce_kernel_fused::launch_unchecked::( + kwargs.client, + kwargs.settings.cube_count, + kwargs.settings.cube_dim, + kwargs.settings.address_type, + kwargs.config_fuse_read.width, + kwargs.config_fuse_write.width, + FusedReduceInputLaunch::new(kwargs.inputs, kwargs.config_fuse_read, kwargs.input), + FusedReduceOutputLaunch::new(kwargs.outputs, kwargs.config_fuse_write, kwargs.output), + kwargs.reduce_axis, + kwargs.out_vec_axis, + kwargs.blueprint, + inst, + dtype_to_storage_type(dtype_input), + dtype_to_storage_type(dtype_output), + dtype_to_storage_type(dtype_acc), + ) + }; + + Ok(()) +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub fn reduce_kernel_fused( + input: &FusedReduceInput, + output: &mut FusedReduceOutput, + reduce_axis: usize, + out_vec_axis: usize, + #[comptime] blueprint: ReduceBlueprint, + #[comptime] config: ReduceOperationConfig, + #[define(In)] _input_dtype: StorageType, + #[define(Out)] _output_dtype: StorageType, + #[define(Acc)] _acc_dtype: StorageType, +) { + multi_block_variables_init(&input.config, &mut output.global.variables); + multi_block_variables_init(&output.config, &mut output.global.variables); + + let (input, mut output) = + init_tensors::(input, output); + + reduce_kernel_virtual::( + &input, + &mut output, + reduce_axis, + out_vec_axis, + blueprint, + config, + ); +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce/tune.rs b/crates/burn-cubecl-fusion/src/optim/reduce/tune.rs new file mode 100644 index 0000000..5500156 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce/tune.rs @@ -0,0 +1,173 @@ +use super::optimization::ReduceOptimizationTuneArg; +use crate::{ + CubeFusionHandle, + engine::trace::TuneOutput, + tune::{FusionInputGen, TuneInput}, +}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_fusion::stream::Context; +use cubecl::{ + AutotuneKey, CubeTuneId, Runtime, + tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}, +}; +use cubek::reduce::{ + launch::{RoutineStrategy, tune_key::ReduceAutotuneKey}, + routines::{BlueprintStrategy, cube::CubeStrategy, plane::PlaneStrategy, unit::UnitStrategy}, +}; +use serde::{Deserialize, Serialize}; + +/// Autotune key for standard fused reduction operations. +/// +/// Records metadata about the fusion graph (IO and ops) alongside +/// the core reduction parameters to ensure stable kernel selection. +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +pub struct FusedReduceAutotuneKey { + reduce_key: ReduceAutotuneKey, + #[autotune(anchor)] + fuse_num_reads: usize, + #[autotune(anchor)] + fuse_num_writes: usize, + #[autotune(anchor)] + fuse_num_ops: usize, +} + +/// Executes autotuning for fused reduction operations. +/// +/// This tuner evaluates different hardware-specific strategies (Plane, Cube, Unit) +/// and assigns priorities based on the `vector_count` of the reduction. +pub fn fused_reduce_autotune( + arg: ReduceOptimizationTuneArg, + context: &mut Context>, +) { + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + const PRIORITY_MAX: i8 = 2; + const PRIORITY_MIN: i8 = 1; + + let mut set = TunableSet::new(create_key::, FusionInputGen); + let group = TuneGroup::::new("fused_reduce", |_key| PRIORITY_MAX); + + // Fallback implementation for robustness. + set = set.with(Tunable::new("fused_reduce_fallback", tune_fallback::)); + + // Define properties to categorize hardware strategies. + enum ReduceProps { + GreatWithLowReduceCount, + GreatWithHighReduceCount, + Balanced, + } + + let strategies = [ + ( + "fused_unit", + RoutineStrategy::Unit(BlueprintStrategy::Inferred(UnitStrategy)), + ReduceProps::GreatWithHighReduceCount, + ), + ( + "fused_plane", + RoutineStrategy::Plane(BlueprintStrategy::Inferred(PlaneStrategy { + independent: true, + })), + ReduceProps::Balanced, + ), + ( + "fused_cube", + RoutineStrategy::Cube(BlueprintStrategy::Inferred(CubeStrategy { + // Two steps reduction doesn't work with fuse-on-write, we can't activate plane + // when using the cube algo. + use_planes: false, + })), + ReduceProps::GreatWithLowReduceCount, + ), + ]; + + for (name, strategy, props) in strategies { + let tunable = Tunable::new(name, move |input| tune_reduce::(input, &strategy)) + .group(&group, move |key| match props { + ReduceProps::GreatWithLowReduceCount => { + if key.reduce_key.vector_count < 128 { + PRIORITY_MAX + } else { + PRIORITY_MIN + } + } + ReduceProps::GreatWithHighReduceCount => { + if key.reduce_key.vector_count > 64 { + PRIORITY_MAX + } else { + PRIORITY_MIN + } + } + ReduceProps::Balanced => PRIORITY_MAX, + }); + + set = set.with(tunable); + } + + set + }); + + TUNER.execute( + &CubeTuneId::new(&arg.info.client, &arg.info.device), + &arg.info.client.clone(), + tunables, + TuneInput::new(context, arg), + ); +} + +/// Creates the autotune key by extracting tensor metadata and fusion block statistics. +pub(crate) fn create_key( + input: &TuneInput>, +) -> FusedReduceAutotuneKey { + let opt = input.optimization(); + assert!( + input.is_original(), + "Forked context not supported for key generation" + ); + let tensors = input.tensors(); + + let input_tensor = tensors.get(&opt.info.reduce.op.input.id).unwrap(); + let out_tensor = tensors.get(&opt.info.reduce.op.out.id).unwrap(); + let acc = opt.info.reduce.acc.into_elem(); + + let key = ReduceAutotuneKey::generate( + dtype_to_elem_type(input_tensor.dtype), + dtype_to_elem_type(out_tensor.dtype), + acc, + &input_tensor.shape, + opt.info.reduce.axis == input_tensor.shape.rank() - 1, + opt.info.reduce.axis, + ); + + // Assume the fusion contains at least a read and a write block. + let read_block = &opt.info.trace.blocks[0]; + let write_block = &opt.info.trace.blocks[1]; + + FusedReduceAutotuneKey::new( + key, + read_block.reads.len() + write_block.reads.len(), + read_block.writes.len() + write_block.writes.len(), + read_block.ops.len() + write_block.ops.len(), + ) +} + +/// Executes a fused reduction optimization. +fn tune_reduce( + input: TuneInput>, + strategy: &RoutineStrategy, +) -> Result, String> { + input + .execute(|ctx, opt| opt.execute_fused(ctx, strategy.clone())) + .map_err(|e| format!("{e:?}")) +} + +/// Executes the fallback path for a reduction optimization. +fn tune_fallback( + input: TuneInput>, +) -> Result, String> { + input.execute(|ctx, opt| { + opt.execute_fallback(ctx); + }); + Ok(TuneOutput::UnChecked(std::marker::PhantomData)) +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/base.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/base.rs new file mode 100644 index 0000000..9fb906b --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/base.rs @@ -0,0 +1,388 @@ +use crate::optim::{ + CubeOptimization, + reduce::{ReduceFuser, ReduceFuserInfo, ReduceSettings}, + reduce_broadcasted::{ + ReduceBlockOptimInfo, ReduceBroadcastedOptimization, ReduceBroadcastedOptimizationInfo, + fuser::{ + block::{ReduceBlockFuser, ReduceBlockFusionAnalysis, ReduceBroadcastedStatus}, + full::ReduceBroadcastedFullFuser, + full_analyzer::FullFuserAnalyzer, + }, + }, +}; +use burn_fusion::{FuserProperties, FuserStatus, OperationFuser}; +use burn_ir::OperationIr; +use cubecl::Runtime; +use std::sync::Arc; + +/// Fuses element wise operations around a reduce operation. +pub struct ReduceBroadcastedFuser { + blocks: Vec>, + fuser_default: ReduceFuser, + num_ops: usize, + state: ReduceBroadcastedStatus, + max_bindings: u32, +} + +impl Clone for ReduceBroadcastedFuser { + fn clone(&self) -> Self { + Self { + blocks: self.blocks.clone(), + fuser_default: self.fuser_default.clone(), + num_ops: self.num_ops, + state: self.state.clone(), + max_bindings: self.max_bindings, + } + } +} + +impl ReduceBroadcastedFuser { + pub fn new(device: R::Device) -> Self { + let fuser = ReduceFuser::new(device, ReduceSettings::Always); + let max_bindings = fuser.fuser.max_bindings; + let block = ReduceBlockFuser::new(fuser.clone()); + + Self { + blocks: vec![block], + fuser_default: fuser, + num_ops: 0, + state: ReduceBroadcastedStatus::Starting, + max_bindings, + } + } + + /// Checks whether the full fuser and all fallback blocks together account for every + /// operation that has been registered across all blocks. + /// + /// This is a dry-run consistency check: it simulates finishing all blocks without + /// mutating any state, then verifies two invariants: + /// + /// 1. **Full fuser coverage** — the number of operations absorbed by the + /// [`ReduceBroadcastedFullFuser`] matches the total operation count. + /// 2. **Fallback coverage** — the sum of operations across all fallback + /// [`ReduceBlockOptimInfo`] entries also matches the total operation count. + /// + /// If either invariant fails, the fuser's state should be marked as + /// [`ReduceBroadcastedStatus::Abort`] because the optimization would produce + /// incorrect results. + fn is_consistent(&self) -> bool { + let analyzer = FullFuserAnalyzer::new(&self.blocks); + let mut full = ReduceBroadcastedFullFuser::new(self.max_bindings, analyzer); + let mut num_ops = 0; + let fallbacks = self + .blocks + .clone() + .iter_mut() + .map(|block| block.finish(&mut num_ops, &mut full)) + .collect::>(); + + let mut num_ops_fallback = 0; + + for f in fallbacks.iter() { + num_ops_fallback += match f { + ReduceBlockOptimInfo::Reduce(info) => info.len, + ReduceBlockOptimInfo::Elemwise(info) => info.num_ops_fused(), + }; + } + + let full_fuser_covers_all = full.num_ops_fused() == num_ops; + let fallbacks_cover_all = num_ops_fallback == num_ops; + + full_fuser_covers_all && fallbacks_cover_all + } + + /// Fuses without checking consistency and the current state. + fn fuse_no_check(&mut self, operation: &OperationIr) { + let block = self.blocks.last_mut().unwrap(); + let analyze = block.analyze(operation, &self.state, &self.fuser_default); + + let info = match analyze { + ReduceBlockFusionAnalysis::Accept => { + block.fuse(operation); + self.num_ops += 1; + block.fuser.reduce_info() + } + ReduceBlockFusionAnalysis::Refuse => { + self.state = ReduceBroadcastedStatus::Closed; + return; + } + ReduceBlockFusionAnalysis::NewBlockRequired => { + let info = block.fuser.reduce_info(); + let mut block = ReduceBlockFuser::new(self.fuser_default.clone()); + block.fuse(operation); + self.num_ops += 1; + self.blocks.push(block); + info + } + }; + + match info { + ReduceFuserInfo::FusedReduce { + shape_input_id, + axis, + } => { + // Only support last axis for now. + if axis != shape_input_id.len() - 1 { + self.state = ReduceBroadcastedStatus::Abort; + } else { + self.state = ReduceBroadcastedStatus::Init { + shape_id: shape_input_id, + axis, + }; + } + } + ReduceFuserInfo::FusedElemwise { .. } => {} + } + } +} + +impl OperationFuser> for ReduceBroadcastedFuser { + fn fuse(&mut self, operation: &OperationIr) { + if matches!( + &self.state, + ReduceBroadcastedStatus::Closed | ReduceBroadcastedStatus::Abort + ) { + return; + } + + // We first need to simulate the fusion to check the consistency, then we perform the + // fusion. + let mut next = self.clone(); + next.fuse_no_check(operation); + + // We can only check for consistency if the optimization is ready. + if next.properties().ready && !next.is_consistent() { + // Fusions that lead to inconsistent trace are closed. + self.state = ReduceBroadcastedStatus::Closed; + } else { + // Normal path. + self.fuse_no_check(operation); + } + } + + fn finish(&mut self) -> CubeOptimization { + let analyzer = FullFuserAnalyzer::new(&self.blocks); + let mut full = ReduceBroadcastedFullFuser::new(self.max_bindings, analyzer); + let mut num_ops = 0; + let fallbacks = self + .blocks + .iter_mut() + .map(|block| block.finish(&mut num_ops, &mut full)) + .collect::>(); + + let broadcasted = Arc::new(full.finish()); + let info = Arc::new(ReduceBroadcastedOptimizationInfo { + fallbacks, + broadcasted, + }); + CubeOptimization::ReduceBroadcasted(ReduceBroadcastedOptimization { info, num_ops }) + } + + fn reset(&mut self) { + let block = ReduceBlockFuser::new(self.fuser_default.clone()); + self.blocks = vec![block]; + self.num_ops = 0; + self.state = ReduceBroadcastedStatus::Starting; + } + + fn status(&self) -> FuserStatus { + match self.state { + ReduceBroadcastedStatus::Closed | ReduceBroadcastedStatus::Abort => { + return FuserStatus::Closed; + } + _ => {} + }; + + let fuser = self.blocks.last().unwrap(); + fuser.fuser.status() + } + + fn properties(&self) -> FuserProperties { + let ready = match self.state { + ReduceBroadcastedStatus::Starting | ReduceBroadcastedStatus::Abort => false, + ReduceBroadcastedStatus::Closed if self.blocks.len() == 1 => { + !self.blocks[0].is_elemwise() + } + _ => true, + }; + let mut props = FuserProperties { score: 0, ready }; + for block in self.blocks.iter() { + let p = block.properties(); + props.score += p.score; + props.ready = p.ready && props.ready; + } + props + } + + fn len(&self) -> usize { + self.num_ops + } + + fn clone_dyn(&self) -> Box>> { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use burn_ir::{ + BaseOperationIr, BinaryOpIr, CreationOpIr, ReduceDimOpIr, TensorId, TensorIr, TensorStatus, + }; + use burn_std::{DType, Shape}; + + use super::*; + + type Run = cubecl::TestRuntime; + + #[test] + fn reduce_broadcast_workflow_1() { + let device: ::Device = Default::default(); + let mut fuser = ReduceBroadcastedFuser::::new(device); + let (tensor1_out, tensor1) = tensor(0, &[1, 2], TensorStatus::ReadWrite); + let (tensor2_out, tensor2) = tensor(1, &[1, 0], TensorStatus::ReadWrite); + + fuser.fuse(&OperationIr::BaseFloat(BaseOperationIr::Ones( + CreationOpIr { out: tensor1_out }, + ))); + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::SumDim(ReduceDimOpIr { + input: tensor1, + out: tensor2_out, + axis: 1, + accumulator_len: 1, + }), + )); + + let status = fuser.status(); + assert_eq!(2, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + // An existing tensor + let (_tensor3_out, tensor3) = tensor(2, &[1, 0], TensorStatus::ReadWrite); + // A new tensor + let (tensor4_out, tensor4) = tensor(3, &[1, 0], TensorStatus::ReadWrite); + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::Add(BinaryOpIr { + lhs: tensor2, + rhs: tensor3, + out: tensor4_out, + }), + )); + + let status = fuser.status(); + assert_eq!(3, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + // An existing tensor + let (_tensor5_out, tensor5) = tensor(4, &[1, 2], TensorStatus::ReadWrite); + // A new tensor + let (tensor6_out, tensor6) = tensor(5, &[1, 2], TensorStatus::ReadWrite); + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::Add(BinaryOpIr { + lhs: tensor4, + rhs: tensor5, + out: tensor6_out, + }), + )); + + let status = fuser.status(); + assert_eq!(4, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + let (tensor7_out, _tensor7) = tensor(6, &[1, 0], TensorStatus::ReadWrite); + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::SumDim(ReduceDimOpIr { + input: tensor6, + out: tensor7_out, + axis: 1, + accumulator_len: 1, + }), + )); + assert_eq!(5, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + let _optimization = fuser.finish(); + } + + #[test] + fn reduce_broadcast_workflow_2() { + let device: ::Device = Default::default(); + let mut fuser = ReduceBroadcastedFuser::::new(device); + let (tensor1_out, tensor1) = tensor(0, &[1, 2], TensorStatus::ReadWrite); + // An existing tensor + let (_tensor2_out, mut tensor2) = tensor(2, &[1, 2], TensorStatus::ReadOnly); + let (tensor3_out, tensor3) = tensor(3, &[1, 2], TensorStatus::ReadWrite); + + // First reduce output + let (tensor4_out, tensor4) = tensor(1, &[1, 0], TensorStatus::ReadWrite); + + fuser.fuse(&OperationIr::BaseFloat(BaseOperationIr::Ones( + CreationOpIr { out: tensor1_out }, + ))); + + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::Add(BinaryOpIr { + lhs: tensor1, + rhs: tensor2.clone(), + out: tensor3_out, + }), + )); + + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::SumDim(ReduceDimOpIr { + input: tensor3, + out: tensor4_out, + axis: 1, + accumulator_len: 1, + }), + )); + + let status = fuser.status(); + assert_eq!(3, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + // A new tensor + let (tensor5_out, _tensor5) = tensor(5, &[1, 2], TensorStatus::ReadWrite); + // Last time we use tensor2. + tensor2.status = TensorStatus::ReadWrite; + fuser.fuse(&OperationIr::NumericFloat( + DType::F32, + burn_ir::NumericOperationIr::Add(BinaryOpIr { + lhs: tensor4, + rhs: tensor2, + out: tensor5_out, + }), + )); + + let status = fuser.status(); + assert_eq!(4, fuser.len()); + assert_eq!(status, FuserStatus::Open); + assert!(fuser.properties().ready,); + + let _optimization = fuser.finish(); + } + + fn tensor(id: u64, shape: &[usize], status: TensorStatus) -> (TensorIr, TensorIr) { + let tensor = TensorIr { + id: TensorId::new(id), + shape: Shape::from(shape), + status: TensorStatus::NotInit, + dtype: DType::F32, + }; + let mut tensor_init = tensor.clone(); + tensor_init.status = status; + + (tensor, tensor_init) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/block.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/block.rs new file mode 100644 index 0000000..6496c88 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/block.rs @@ -0,0 +1,227 @@ +use crate::optim::{ + CubeOptimization, + elemwise::ElemwiseOptimization, + reduce::{FusedReduce, ReduceFuser, ReduceFuserInfo}, + reduce_broadcasted::{ReduceBlockOptimInfo, fuser::full::ReduceBroadcastedFullFuser}, +}; +use burn_fusion::{FuserProperties, OperationFuser}; +use burn_ir::OperationIr; +use burn_std::Shape; +use cubecl::Runtime; +use std::sync::Arc; + +/// Responsible for fusing a single reduce block or elementwise block. +/// +/// When the block kind is reduce, it supports fuse-on-read and fuse-on-write fusion. +/// Broadcasting isn't supported; another block should handle it instead. +pub struct ReduceBlockFuser { + /// We use [ReduceFuser] for both elementwise and reduce blocks, keeping only the + /// fuse-on-read trace if the block is tagged as elementwise. + /// + /// # Notes + /// + /// A single elementwise block can only exist at the end of a full [ReduceBlockFuser], + /// otherwise the optimization will be included in the reduce fusion block. + pub fuser: ReduceFuser, + pub(crate) ops: Vec, + pub(crate) kind: ReduceBlockKind, +} + +/// The current state of the fusion process. +#[derive(Debug, Clone)] +pub enum ReduceBroadcastedStatus { + /// Fusion is starting; no reduction has been fused yet. + Starting, + /// Fusion is initialized with at least one reduce operation. + /// + /// # Notes + /// + /// Subsequent reduce operations must be compatible with the previous reduction to fuse. + Init { shape_id: Shape, axis: usize }, + /// No more operations can be fused. + Closed, + /// Invalid axis. + Abort, +} + +/// The [ReduceBlockFuser] capacity to accept an [OperationIr]. +#[derive(Clone, Copy, Debug)] +pub enum ReduceBlockFusionAnalysis { + /// The operation can be fused; call [ReduceBlockFuser::fuse()]. + Accept, + /// The operation cannot be fused; the optimization should close. + Refuse, + /// The operation can be fused, but requires a new block. + NewBlockRequired, +} + +impl ReduceBlockFuser { + /// Creates a new block. + pub fn new(fuser: ReduceFuser) -> Self { + Self { + fuser: fuser.clone(), + ops: Vec::new(), + kind: ReduceBlockKind::Elemwise, + } + } + + /// Returns true if this is an elementwise fuser. + pub fn is_elemwise(&self) -> bool { + matches!(self.kind, ReduceBlockKind::Elemwise) + } + + /// Analyzes if fusion is possible within this block. + pub fn analyze( + &self, + op: &OperationIr, + status: &ReduceBroadcastedStatus, + default_node: &ReduceFuser, + ) -> ReduceBlockFusionAnalysis { + let mut fuser_try = self.fuser.clone(); + let before = fuser_try.len(); + fuser_try.fuse(op); + let after = fuser_try.len(); + + if after > before { + return ReduceBlockFusionAnalysis::Accept; + } + + // Can't create a new block if the previous one was not a reduction. + if self.fuser.reduce.is_none() { + return ReduceBlockFusionAnalysis::Refuse; + } + + // TODO: `RefLayoutSetting::SameAsBlock` might point to a `SwapDims` layout even though reduce + // requires `OnlyContiguous`. For now we just refuse / close the block. + + // Make sure the reference layout of this block is not a tensor view. + // If a block is only composed of a view, it's not accepted. + let num_views = self.fuser.fuser.num_views; + let input_ref_not_concrete = + self.fuser.fuser.fuser.fuser.num_multi_block_local_inputs() > 0; + + if num_views > 0 && input_ref_not_concrete { + return ReduceBlockFusionAnalysis::Refuse; + } + + let mut fuser_try = default_node.clone(); + let before = fuser_try.len(); + fuser_try.fuse(op); + let after = fuser_try.len(); + + if after > before { + let info = fuser_try.reduce_info(); + + return match (info, status) { + ( + ReduceFuserInfo::FusedReduce { + shape_input_id, + axis, + }, + ReduceBroadcastedStatus::Init { + shape_id, + axis: axis_init, + }, + ) => { + if shape_id == &shape_input_id && axis_init == &axis { + ReduceBlockFusionAnalysis::NewBlockRequired + } else { + ReduceBlockFusionAnalysis::Refuse + } + } + ( + ReduceFuserInfo::FusedElemwise { shape_id }, + ReduceBroadcastedStatus::Init { + shape_id: shape_init, + .. + }, + ) => { + if &shape_id == shape_init { + ReduceBlockFusionAnalysis::NewBlockRequired + } else { + ReduceBlockFusionAnalysis::Refuse + } + } + _ => ReduceBlockFusionAnalysis::Refuse, + }; + } + + ReduceBlockFusionAnalysis::Refuse + } + + /// Fuses an operation within this block. + /// + /// # Warning + /// + /// Ensure [Self::analyze()] is called before this function to confirm the operation is accepted. + pub fn fuse(&mut self, op: &OperationIr) { + self.fuser.fuse(op); + self.ops.push(op.clone()); + + // Update the kind if a reduction is introduced to an elementwise block. + if let (Some(reduce), ReduceBlockKind::Elemwise) = (&self.fuser.reduce, &self.kind) { + self.kind = ReduceBlockKind::Reduce { + ops_index: self.ops.len() - 1, + reduce: Box::new(reduce.clone()), + }; + } + } + + /// Computes the fuser properties. + pub fn properties(&self) -> FuserProperties { + let mut properties = self.fuser.properties(); + if let ReduceBlockKind::Elemwise = &self.kind { + // Elementwise traces are always ready to run. + properties.ready = true; + } + properties + } + + pub fn finish( + &mut self, + num_ops: &mut usize, + full: &mut ReduceBroadcastedFullFuser, + ) -> ReduceBlockOptimInfo { + full.register(self); + + match &self.kind { + ReduceBlockKind::Elemwise => { + let len = self.fuser.fuser_read_fallback.len(); + let device = self.fuser.device.clone(); + *num_ops += len; + let trace = self.fuser.fuser_read_fallback.finish(); + let client = R::client(&device); + let elementwise = ElemwiseOptimization::new(trace, client, device, len); + ReduceBlockOptimInfo::Elemwise(Arc::new(elementwise)) + } + ReduceBlockKind::Reduce { .. } => { + *num_ops += self.fuser.len(); + let optim = self.fuser.finish(); + let info = match optim { + CubeOptimization::Reduce(optim) => optim.info, + _ => unreachable!("Expected Reduce optimization"), + }; + ReduceBlockOptimInfo::Reduce(info) + } + } + } +} + +#[derive(Clone, Debug)] +pub enum ReduceBlockKind { + Elemwise, + Reduce { + ops_index: usize, + reduce: Box, + }, +} + +impl Clone for ReduceBlockFuser { + fn clone(&self) -> Self { + Self { + fuser: self.fuser.clone(), + ops: self.ops.clone(), + kind: self.kind.clone(), + } + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full.rs new file mode 100644 index 0000000..d1d3238 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full.rs @@ -0,0 +1,177 @@ +use crate::{ + engine::{ + fuser::TraceOperationFuser, + settings::{FuseSettings, RefLayoutSetting, VectorizationSetting}, + }, + optim::{ + reduce::{FusedReduce, ReduceInstruction}, + reduce_broadcasted::{ + ReduceBroadcastedInfo, + fuser::{ + block::{ReduceBlockFuser, ReduceBlockKind}, + full_analyzer::FullFuserAnalyzer, + }, + launch::ReduceBroadcastedFuseBlock, + }, + }, +}; +use burn_fusion::OperationFuser; +use cubecl::Runtime; +use cubek::reduce::components::instructions::ReduceOperationConfig; + +/// Responsible for fusing a single trace for all operations involved in this optimization. +pub struct ReduceBroadcastedFullFuser { + pub(crate) fuser: TraceOperationFuser, + analyzer: FullFuserAnalyzer, + blocks: Vec, + settings_read: FuseSettings, + settings_write: FuseSettings, +} + +impl ReduceBroadcastedFullFuser { + /// Creates a new fuser with the given settings. + pub fn new(max_bindings: u32, analyzer: FullFuserAnalyzer) -> Self { + let settings_read = FuseSettings { + output_shape_updates: true, + broadcast: true, + inplace: false, + ref_layout: RefLayoutSetting::OnlyContiguous, + vectorization: VectorizationSetting::Activated, + }; + let settings_write = FuseSettings { + output_shape_updates: false, + inplace: false, + broadcast: false, + ref_layout: RefLayoutSetting::OnlyContiguous, + // Deactivated for now, but would be cool to support vectorization of the output. + vectorization: VectorizationSetting::Deactivated, + }; + let fuser = TraceOperationFuser::new(max_bindings, settings_read); + + Self { + fuser, + blocks: Vec::new(), + settings_write, + settings_read, + analyzer, + } + } + + /// Returns the amount of operations fused. + pub fn num_ops_fused(&self) -> usize { + let mut fused = self.fuser.num_ops; + + for block in self.blocks.iter() { + match block { + ReduceBlockKind::Elemwise => {} + // The base fuser doesn't hold the reduce ops. + ReduceBlockKind::Reduce { .. } => fused += 1, + } + } + + fused + } + + /// Finishes fusing all blocks. + pub fn finish(mut self) -> ReduceBroadcastedInfo { + let mut reduce_axis = 0; + let mut blocks = Vec::new(); + + for block in self.blocks.iter() { + match block { + ReduceBlockKind::Elemwise => {} + ReduceBlockKind::Reduce { reduce, .. } => { + let config = match reduce.inst { + ReduceInstruction::ArgMax => ReduceOperationConfig::ArgMax, + ReduceInstruction::ArgMin => ReduceOperationConfig::ArgMin, + ReduceInstruction::Prod => ReduceOperationConfig::Prod, + ReduceInstruction::Mean => ReduceOperationConfig::Mean, + ReduceInstruction::Sum => ReduceOperationConfig::Sum, + ReduceInstruction::Max => ReduceOperationConfig::Max, + ReduceInstruction::Min => ReduceOperationConfig::Min, + ReduceInstruction::MaxAbs => ReduceOperationConfig::MaxAbs, + }; + + let block = ReduceBroadcastedFuseBlock { + op: config, + input: reduce.input.clone(), + output: reduce.output.clone(), + }; + reduce_axis = reduce.axis; + blocks.push(block); + } + } + } + + let trace = self.fuser.finish(); + + ReduceBroadcastedInfo { + blocks, + trace, + reduce_axis, + } + } + + /// Registers a [ReduceBlockFuser] to build the trace. + pub fn register(&mut self, block: &ReduceBlockFuser) { + // Helper to close previous blocks if necessary + if !self.fuser.is_empty() { + let mut settings = self.settings_read; + settings.vectorization = VectorizationSetting::EqualThanPreviousBlock { block_pos: 0 }; + settings.ref_layout = RefLayoutSetting::SameAsBlock { block_pos: 0 }; + self.fuser.next_block([], settings, false); + + let analysis = self.analyzer.retrieve_next(); + + for (tensor, block_pos) in analysis.inputs { + self.fuser.block_local_input(&tensor, block_pos, false); + } + } + + match &block.kind { + ReduceBlockKind::Elemwise => { + for op in &block.ops { + self.fuser.fuse(op); + } + self.blocks.push(ReduceBlockKind::Elemwise); + } + ReduceBlockKind::Reduce { ops_index, reduce } => { + for op in &block.ops[0..*ops_index] { + self.fuser.fuse(op); + } + + let [input] = self + .fuser + .next_block([&reduce.op.input], self.settings_write, false); + + let output = self.fuser.output_unhandled(&reduce.op.out); + let analysis = self.analyzer.retrieve_next(); + + // Can be broadcasted so the generated buffer can be global. + for (tensor, block_pos) in analysis.inputs { + self.fuser.block_local_input(&tensor, block_pos, false); + } + + let fused_reduce = FusedReduce { + input, + output, + acc: reduce.acc, + axis: reduce.axis, + op: reduce.op.clone(), + use_planes: reduce.use_planes, + shared: reduce.shared, + inst: reduce.inst, + }; + + self.blocks.push(ReduceBlockKind::Reduce { + ops_index: *ops_index, + reduce: Box::new(fused_reduce), + }); + + for op in &block.ops[*ops_index + 1..block.ops.len()] { + self.fuser.fuse(op); + } + } + } + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full_analyzer.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full_analyzer.rs new file mode 100644 index 0000000..b845c2e --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/full_analyzer.rs @@ -0,0 +1,159 @@ +use super::block::ReduceBlockKind; +use crate::optim::reduce_broadcasted::fuser::block::ReduceBlockFuser; +use burn_ir::{TensorId, TensorIr}; +use cubecl::Runtime; +use std::collections::BTreeMap; + +#[derive(Debug)] +pub struct FullFuserAnalyzer { + // We need to know the block id of which we can reuse the read local input. + analyses: Vec>, +} + +impl FullFuserAnalyzer { + pub fn new(blocks: &[ReduceBlockFuser]) -> Self { + let mut state = AnalysisState::default(); + + for block in blocks.iter() { + for (pos, op) in block.ops.iter().enumerate() { + let potential_from_previous_blocks = op.inputs(); + let potential_to_next_blocks = op.outputs(); + + match &block.kind { + ReduceBlockKind::Elemwise => { + state.register( + potential_from_previous_blocks, + potential_to_next_blocks, + BlockKind::Full, + ); + } + ReduceBlockKind::Reduce { ops_index, .. } => { + if pos < *ops_index { + state.register( + potential_from_previous_blocks, + potential_to_next_blocks, + BlockKind::Full, + ); + } else if pos > *ops_index { + state.register( + potential_from_previous_blocks, + potential_to_next_blocks, + BlockKind::Single, + ); + } else { + state.next_block(); + } + } + } + } + state.next_block(); + } + + // First one is never called. + state.analyses.remove(0); + + Self { + analyses: state.analyses, + } + } + + pub fn retrieve_next(&mut self) -> FullFuserAnalysis { + let inputs = self.analyses.remove(0); + FullFuserAnalysis { inputs } + } +} + +#[derive(Debug)] +pub struct FullFuserAnalysis { + /// The tensor received from a previous block. + pub inputs: Vec<(TensorIr, usize)>, +} + +#[derive(Default)] +struct AnalysisState { + /// That pool contains tensors that are available in the fuse-on-write part of a reduce, not + /// broadcasted. + available_from_previous_single: BTreeMap, + /// That pool contains tensors that are available in the fuse-on-read of a reduce and the + /// element-wise broadcasted part + available_from_previous_full: BTreeMap, + block_data: Vec<(TensorIr, usize)>, + analyses: Vec>, + current_full: Vec, + current_single: Vec, +} + +enum BlockKind { + Full, + Single, +} + +impl AnalysisState { + fn next_block(&mut self) { + let block_pos = self.analyses.len(); + let data = core::mem::take(&mut self.block_data); + self.analyses.push(data); + + // Makes the current tensor reads available for the next block. + for p in self.current_single.drain(..) { + // We need to keep the earliest block position. + self.available_from_previous_single + .entry(p.id) + .or_insert(block_pos); + } + for p in self.current_full.drain(..) { + // We need to keep the earliest block position. + self.available_from_previous_full + .entry(p.id) + .or_insert(block_pos); + } + } + + fn register<'a>( + &mut self, + potential_from_previous_blocks: impl Iterator, + potential_to_next_blocks: impl Iterator, + kind: BlockKind, + ) { + match kind { + BlockKind::Full => { + for potential in potential_from_previous_blocks { + // We can't since it's not in the same scope. + // + // TODO: Find a way to merge multiple reduce loops. + // + // if let Some(block_pos) = self.available_from_previous_full.get(&potential.id) { + // self.block_data.push((potential.clone(), *block_pos)); + // } + + // We can since it's a broadcast. + if let Some(block_pos) = self.available_from_previous_single.get(&potential.id) + { + self.block_data.push((potential.clone(), *block_pos)); + } + + // Can reuse the read. + self.current_full.push(potential.clone()); + } + + for p in potential_to_next_blocks { + self.current_full.push(p.clone()); + } + } + BlockKind::Single => { + for potential in potential_from_previous_blocks { + if let Some(block_pos) = self.available_from_previous_single.get(&potential.id) + { + self.block_data.push((potential.clone(), *block_pos)); + } + // Can reuse the read. + self.current_single.push(potential.clone()); + } + + for p in potential_to_next_blocks { + self.current_single.push(p.clone()); + } + } + } + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/mod.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/mod.rs new file mode 100644 index 0000000..22bb521 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/fuser/mod.rs @@ -0,0 +1,6 @@ +mod base; +mod block; +mod full; +mod full_analyzer; + +pub use base::*; diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/launch.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/launch.rs new file mode 100644 index 0000000..334a50e --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/launch.rs @@ -0,0 +1,149 @@ +use crate::{ + engine::{ + codegen::ir::{FuseArg, FuseBlockConfig, GlobalArgsLaunch, RefLayout}, + launch::runner::{TraceRunner, Vectorization}, + }, + optim::reduce_broadcasted::unit::{ + ElemwiseFuseBlockLaunch, ReduceFuseBlockLaunch, reduce_kernel_broadcasted, + }, +}; +use cubecl::{ + Runtime, + ir::{ElemType, FloatKind, StorageType}, + prelude::*, + server::LaunchError, +}; +use cubek::reduce::{ + ReduceDtypes, VectorizationMode, + components::instructions::ReduceOperationConfig, + launch::RoutineStrategy, + output_vectorization_axis, + routines::{ + BlueprintStrategy, GlobalReduceBlueprint, ReduceProblem, ReduceVectorSettings, Routine, + unit::{UnitRoutine, UnitStrategy}, + }, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ReduceBroadcastedFuseBlock { + pub(crate) op: ReduceOperationConfig, + pub(crate) input: FuseArg, + pub(crate) output: FuseArg, +} + +#[derive(new)] +pub struct FusedReduceBroadcastedLaunch<'a> { + blocks: &'a Vec, + reduce_axis: usize, + // TODO: Support multiple strategies. + _strategy: RoutineStrategy, +} + +impl Vectorization for FusedReduceBroadcastedLaunch<'_> {} + +impl TraceRunner for FusedReduceBroadcastedLaunch<'_> { + type Error = LaunchError; + + fn run<'a>( + &'a self, + client: &'a ComputeClient, + inputs: GlobalArgsLaunch, + outputs: GlobalArgsLaunch, + configs: &'a [FuseBlockConfig], + ) -> Result<(), Self::Error> { + let routine = UnitRoutine; + let first_config = &configs[0]; + + let shape = match &first_config.ref_layout { + RefLayout::Concrete(FuseArg::Output(..)) => { + outputs.shape_ref(&first_config.ref_layout, first_config.rank) + } + _ => inputs.shape_ref(&first_config.ref_layout, first_config.rank), + }; + + let reduce_len = shape[self.reduce_axis]; + let reduce_count = shape.iter().product::() / reduce_len; + let address_type = inputs + .required_address_type() + .max(outputs.required_address_type()); + + let (blueprint, settings) = routine + .prepare::( + client, + ReduceProblem { + reduce_len, + reduce_count, + axis: self.reduce_axis, + dtypes: ReduceDtypes { + input: StorageType::Scalar(ElemType::Float(FloatKind::F32)), + output: StorageType::Scalar(ElemType::Float(FloatKind::F32)), + accumulation: StorageType::Scalar(ElemType::Float(FloatKind::F32)), + }, + address_type, + // We assume at least one block. + instruction: self.blocks.first().unwrap().op, + }, + ReduceVectorSettings { + vectorization_mode: VectorizationMode::Parallel, + vector_size_input: first_config.width, + vector_size_output: 1, + }, + BlueprintStrategy::Inferred(UnitStrategy), + ) + .unwrap(); + + assert_eq!(blueprint.vectorization_mode, VectorizationMode::Parallel); + + let mut blocks = SequenceArg::new(); + let mut index = 0; + + for block in self.blocks { + let arg = ReduceFuseBlockLaunch::new( + block.op, + configs[index].clone(), + configs[index + 1].clone(), + block.input.clone(), + block.output.clone(), + match blueprint.global { + GlobalReduceBlueprint::Unit(bpt) => bpt, + _ => panic!(), + }, + ); + index += 2; + blocks.push(arg); + } + + let block_end = match configs.len() > index { + true => ComptimeOptionArgs::Some(ElemwiseFuseBlockLaunch::new( + configs.last().cloned().unwrap(), + )), + false => ComptimeOptionArgs::None, + }; + + let out_vec_axis = output_vectorization_axis( + &inputs.strides_ref(&first_config.ref_layout, first_config.rank), + self.reduce_axis, + VectorizationMode::Parallel, + ); + + // TODO: Ensure parallel is selected. + + unsafe { + reduce_kernel_broadcasted::launch_unchecked::( + client, + settings.cube_count, + settings.cube_dim, + settings.address_type, + inputs, + outputs, + self.reduce_axis, + out_vec_axis, + blocks, + block_end, + ); + } + + Ok(()) + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/mod.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/mod.rs new file mode 100644 index 0000000..b29f224 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/mod.rs @@ -0,0 +1,9 @@ +mod fuser; +mod optimization; + +pub(crate) mod launch; +pub(crate) mod tune; +pub(crate) mod unit; + +pub use fuser::*; +pub use optimization::*; diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/optimization.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/optimization.rs new file mode 100644 index 0000000..ee7cf88 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/optimization.rs @@ -0,0 +1,219 @@ +#[cfg(feature = "autotune")] +use crate::optim::reduce::tune::fused_reduce_autotune; +use crate::{ + CubeFusionHandle, FallbackOperation, + engine::{ + launch::FuseTraceLauncher, + trace::{FuseTrace, TraceError, TuneOutput}, + }, + optim::{ + elemwise::{ElemwiseOptimization, ElemwiseOptimizationState}, + reduce::{ReduceOptimizationInfo, ReduceOptimizationState, ReduceOptimizationTuneArg}, + reduce_broadcasted::{ + launch::{FusedReduceBroadcastedLaunch, ReduceBroadcastedFuseBlock}, + tune::fused_broadcasted_reduce_autotune, + }, + }, +}; +use burn_fusion::stream::Context; +use cubecl::{Runtime, prelude::*}; +use cubek::reduce::launch::RoutineStrategy; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub struct ReduceBroadcastedOptimization { + pub(crate) info: Arc>, + pub(crate) num_ops: usize, +} + +pub(crate) struct ReduceBroadcastedOptimizationInfo { + pub(crate) fallbacks: Vec>, + pub(crate) broadcasted: Arc, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub(crate) struct ReduceBroadcastedInfo { + pub(crate) blocks: Vec, + pub(crate) trace: FuseTrace, + pub(crate) reduce_axis: usize, +} + +pub(crate) enum ReduceBlockOptimInfo { + Reduce(Arc>), + Elemwise(Arc>), +} + +impl ReduceBlockOptimInfo { + pub fn from_state(device: &R::Device, state: ReduceBlockState) -> Self { + match state { + ReduceBlockState::Reduce(state) => { + Self::Reduce(Arc::new(ReduceOptimizationInfo::from_state(device, state))) + } + ReduceBlockState::Elemwise(state) => { + Self::Elemwise(Arc::new(ElemwiseOptimization::from_state(device, state))) + } + } + } + pub fn to_state(&self) -> ReduceBlockState { + match self { + Self::Reduce(info) => ReduceBlockState::Reduce(info.to_state()), + Self::Elemwise(info) => ReduceBlockState::Elemwise(info.to_state()), + } + } +} + +pub(crate) struct ReduceBroadcastedOptimizationTuneArg { + pub(crate) fallbacks: Vec>, + pub(crate) broadcasted: Arc, + pub(crate) client: ComputeClient, + pub(crate) device: R::Device, +} + +pub(crate) enum ReduceBlockOptimArg { + Reduce(ReduceOptimizationTuneArg), + Elemwise(Arc>), +} + +impl ReduceBlockOptimArg { + pub fn execute_fallback( + &self, + context: &mut Context>, + ) -> Option> { + match self { + ReduceBlockOptimArg::Reduce(reduce) => { + #[cfg(feature = "autotune")] + { + fused_reduce_autotune::(reduce.clone(), context); + None + } + #[cfg(not(feature = "autotune"))] + Some(reduce.execute_fallback(context)) + } + ReduceBlockOptimArg::Elemwise(elem) => { + elem.execute(context); + None + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ReduceBroadcastedOptimizationState { + fallbacks: Vec, + broadcasted: ReduceBroadcastedInfo, + num_ops: usize, +} + +#[derive(Serialize, Deserialize, Debug)] +#[allow(clippy::large_enum_variant)] // Only for serialization. +pub enum ReduceBlockState { + Reduce(ReduceOptimizationState), + Elemwise(ElemwiseOptimizationState), +} + +impl ReduceBroadcastedOptimizationTuneArg { + pub fn execute_fused( + &self, + context: &mut Context>, + strategy: RoutineStrategy, + ) -> Result, TraceError> { + let launch = FusedReduceBroadcastedLaunch::new( + &self.broadcasted.blocks, + self.broadcasted.reduce_axis, + strategy, + ); + let launcher = FuseTraceLauncher::new(&self.broadcasted.trace, &launch); + + launcher + .launch(&self.client, &self.device, context) + .map_err(|err| TraceError::RunnerError(format!("{:?}", err))) + } + + pub fn execute_fallback(&self, context: &mut Context>) { + for fallback in self.fallbacks.iter() { + fallback.execute_fallback(context); + } + } +} + +#[allow(clippy::too_many_arguments)] +impl ReduceBroadcastedOptimization { + /// Execute the optimization. + pub fn execute( + &mut self, + context: &mut Context>, + fallback: impl Fn(usize) -> Box>, + ) { + let mut current_index = 0; + let mut client = None; + let mut device = None; + + let fallbacks = self + .info + .fallbacks + .iter() + .map(|info| { + match info { + ReduceBlockOptimInfo::Reduce(info) => { + // The index of the fallback reduce is the number of ops fused as read. + let fallback = fallback(current_index + info.len_read); + client = Some(info.client.clone()); + device = Some(info.device.clone()); + let arg = ReduceOptimizationTuneArg { + info: info.clone(), + fallback: Arc::new(fallback), + }; + current_index += info.len; + ReduceBlockOptimArg::Reduce(arg) + } + ReduceBlockOptimInfo::Elemwise(op) => ReduceBlockOptimArg::Elemwise(op.clone()), + } + }) + .collect(); + + let arg = ReduceBroadcastedOptimizationTuneArg { + fallbacks, + client: client.unwrap(), + device: device.unwrap(), + broadcasted: self.info.broadcasted.clone(), + }; + + #[cfg(feature = "autotune")] + fused_broadcasted_reduce_autotune::(arg, context); + + #[cfg(not(feature = "autotune"))] + arg.execute_fallback(context); + } + + pub fn to_state(&self) -> ReduceBroadcastedOptimizationState { + ReduceBroadcastedOptimizationState { + fallbacks: self + .info + .fallbacks + .iter() + .map(|info| info.to_state()) + .collect(), + broadcasted: self.info.broadcasted.as_ref().clone(), + num_ops: self.num_ops, + } + } + + pub fn from_state(device: &R::Device, state: ReduceBroadcastedOptimizationState) -> Self { + Self { + info: Arc::new(ReduceBroadcastedOptimizationInfo { + fallbacks: state + .fallbacks + .into_iter() + .map(|state| ReduceBlockOptimInfo::from_state(device, state)) + .collect(), + broadcasted: Arc::new(state.broadcasted), + }), + num_ops: state.num_ops, + } + } + + /// Returns the number of output buffers added by fusion. + pub fn num_ops_fused(&self) -> usize { + self.num_ops + } +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/tune.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/tune.rs new file mode 100644 index 0000000..4a61ae7 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/tune.rs @@ -0,0 +1,160 @@ +use super::optimization::ReduceBroadcastedOptimizationTuneArg; +use crate::{ + CubeFusionHandle, + engine::trace::TuneOutput, + optim::{reduce::ReduceOptimizationInfo, reduce_broadcasted::ReduceBlockOptimArg}, + tune::{FusionInputGen, TuneInput}, +}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_fusion::stream::Context; +use cubecl::{ + AutotuneKey, CubeTuneId, Runtime, + tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}, +}; +use cubek::reduce::{ + launch::{RoutineStrategy, tune_key::ReduceAutotuneKey}, + routines::{BlueprintStrategy, unit::UnitStrategy}, +}; +use serde::{Deserialize, Serialize}; + +/// Autotune key for fused broadcasted reduction operations. +/// +/// Captures the characteristics of the fusion (reads, writes, ops) to ensure +/// the best kernel is selected for specific fused graph shapes. +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +pub struct FusedBroadcastedReduceAutotuneKey { + reduce_key: ReduceAutotuneKey, + #[autotune(anchor)] + fuse_num_reads: usize, + #[autotune(anchor)] + fuse_num_writes: usize, + #[autotune(anchor)] + fuse_num_ops: usize, + fuse_num_blocks: usize, +} + +/// Executes the autotuning process for fused reduction operations. +/// +/// This function initializes a local tuner and attempts multiple strategies +/// (fallback vs. unit strategy) to find the most efficient execution path. +pub fn fused_broadcasted_reduce_autotune( + arg: ReduceBroadcastedOptimizationTuneArg, + context: &mut Context>, +) { + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + const PRIORITY_MAX: i8 = 2; + let mut set = TunableSet::new(create_key::, FusionInputGen); + + let group = TuneGroup::::new( + "fused_reduce_broadcasted", + |_key| PRIORITY_MAX, + ); + + // Standard fallback implementation - guaranteed to work. + set = set.with(Tunable::new( + "fused_reduce_broadcasted_fallback", + tune_fallback::, + )); + + // Specialized unit strategy for fused reductions. + set = set.with( + Tunable::new("fused_reduce_broadcasted_unit", move |input| { + tune_reduce::( + input, + &RoutineStrategy::Unit(BlueprintStrategy::Inferred(UnitStrategy)), + ) + }) + .group(&group, |_| PRIORITY_MAX), + ); + + set + }); + + TUNER.execute( + &CubeTuneId::new(&arg.client, &arg.device), + &arg.client.clone(), + tunables, + TuneInput::new(context, arg), + ); +} + +/// Generates the autotune key based on the current optimization context and trace blocks. +pub(crate) fn create_key( + input: &TuneInput>, +) -> FusedBroadcastedReduceAutotuneKey { + let opt = input.optimization(); + assert!( + input.is_original(), + "Forked context not supported for key generation" + ); + let context = input.context(); + + // The fusion must start with a reduction block to be valid here. + let info = match &opt.fallbacks[0] { + ReduceBlockOptimArg::Reduce(reduce) => &reduce.info, + ReduceBlockOptimArg::Elemwise(_) => { + unreachable!("Fusion must start with a reduction block") + } + }; + + let key = generate_reduce_autotune_key(info, context); + + // Sum up complexity metrics across all blocks in the fused trace. + let (mut num_reads, mut num_writes, mut num_ops) = (0, 0, 0); + + for block in opt.broadcasted.trace.blocks.iter() { + num_reads += block.reads.len(); + num_writes += block.writes.len(); + num_ops += block.ops.len(); + } + + FusedBroadcastedReduceAutotuneKey::new( + key, + num_reads, + num_writes, + num_ops, + info.trace.blocks.len(), + ) +} + +/// Helper to generate the base reduction key (shapes, types, axes). +fn generate_reduce_autotune_key( + info: &ReduceOptimizationInfo, + context: &Context>, +) -> ReduceAutotuneKey { + let input = context.tensors.get(&info.reduce.op.input.id).unwrap(); + let out = context.tensors.get(&info.reduce.op.out.id).unwrap(); + let acc = info.reduce.acc.into_elem(); + + ReduceAutotuneKey::generate( + dtype_to_elem_type(input.dtype), + dtype_to_elem_type(out.dtype), + acc, + &input.shape, + info.reduce.axis == input.shape.rank() - 1, // Is it the last dimension? + info.reduce.axis, + ) +} + +/// Executes a fused reduction using a specific routine strategy. +fn tune_reduce( + input: TuneInput>, + strategy: &RoutineStrategy, +) -> Result, String> { + input + .execute(|ctx, opt| opt.execute_fused(ctx, strategy.clone())) + .map_err(|e| format!("{e:?}")) +} + +/// Executes the fallback implementation for the reduction. +fn tune_fallback( + input: TuneInput>, +) -> Result, String> { + input.execute(|ctx, opt| { + opt.execute_fallback(ctx); + }); + // Fallback is often used as a baseline, returning unchecked output. + Ok(TuneOutput::UnChecked(std::marker::PhantomData)) +} diff --git a/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/unit.rs b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/unit.rs new file mode 100644 index 0000000..6fb31e8 --- /dev/null +++ b/crates/burn-cubecl-fusion/src/optim/reduce_broadcasted/unit.rs @@ -0,0 +1,220 @@ +use crate::{ + engine::codegen::{ + ir::{FuseArg, FuseBlockConfig, FuseType, GlobalArgs, multi_block_variables_init}, + kernel::{fuse_on_write, init_locals}, + }, + optim::reduce::args::{FusedReduceArgs, FusedReduceInput, FusedReduceOutput}, +}; +use cubecl::{Runtime, define_size, prelude::*, std::tensor::r#virtual::VirtualTensor}; +use cubek::reduce::{ + ReduceInstruction, ReducePrecision, VectorizationMode, + components::{ + args::NumericVector, + global::unit::GlobalFullUnitReduce, + instructions::{ReduceOperation, ReduceOperationConfig}, + }, + init_tensors, + routines::UnitReduceBlueprint, +}; + +/// A configuration block for a reduction operation within a fused kernel. +/// +/// This struct holds all the compile-time information needed to perform a +/// reduction, including the operation type (Sum, Max, etc.) and the layout +/// configuration for both input and output. +#[derive(CubeType, CubeLaunch, Clone)] +pub struct ReduceFuseBlock { + #[cube(comptime)] + op: ReduceOperationConfig, + #[cube(comptime)] + config_input: FuseBlockConfig, + #[cube(comptime)] + config_output: FuseBlockConfig, + #[cube(comptime)] + input: FuseArg, + #[cube(comptime)] + output: FuseArg, + #[cube(comptime)] + blueprint: UnitReduceBlueprint, +} + +/// A configuration block for an elementwise operation that follows a reduction. +#[derive(CubeType, CubeLaunch, Clone)] +pub struct ElemwiseFuseBlock { + #[cube(comptime)] + config: FuseBlockConfig, +} + +/// The entry point for a broadcasted reduction kernel. +/// +/// This kernel initializes local variables for multiple reduction blocks and then +/// executes the reduction sequence. +/// +/// # Arguments +/// +/// * `inputs` - Global arguments containing input tensor handles. +/// * `outputs` - Global arguments containing output tensor handles. +/// * `reduce_axis` - The dimension along which the reduction is performed. +/// * `blocks` - A sequence of reduction operations to execute. +/// * `block_end` - An optional elementwise block to execute after reductions are complete. +#[cube(launch_unchecked, address_type = "dynamic")] +pub fn reduce_kernel_broadcasted( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + reduce_axis: usize, + out_vec_axis: usize, + blocks: Sequence, + block_end: ComptimeOption, +) { + #[unroll] + for i in 0..blocks.len() { + let block = blocks.index(i); + multi_block_variables_init(&block.config_input, &mut outputs.variables); + multi_block_variables_init(&block.config_output, &mut outputs.variables); + } + + reduce_many( + inputs, + outputs, + reduce_axis, + out_vec_axis, + blocks, + block_end, + ); +} + +define_scalar!(In); +define_scalar!(Acc); +define_scalar!(Out); + +define_size!(InSize); +define_size!(OutSize); + +/// Configures the precision polyfills for the reduction based on the block's `FuseType`. +#[cube] +fn set_polyfill_block(block: &ReduceFuseBlock) { + let input_precision = comptime!(block.input.precision()); + let output_precision = comptime!(block.output.precision()); + let acc_precision = comptime!(match input_precision { + FuseType::F64 => FuseType::F64, + FuseType::F32 => FuseType::F32, + FuseType::Flex32 => FuseType::F32, + FuseType::F16 => FuseType::F32, + FuseType::BF16 => FuseType::F32, + FuseType::I64 => FuseType::I64, + FuseType::I32 => FuseType::I32, + FuseType::I16 => FuseType::I32, + FuseType::I8 => FuseType::I32, + FuseType::U64 => FuseType::U64, + FuseType::U32 => FuseType::U32, + FuseType::U16 => FuseType::U32, + FuseType::U8 => FuseType::U32, + }); + + set_polyfill::(comptime!( + input_precision.into_type(block.config_input.width) + )); + set_polyfill::(comptime!( + output_precision.into_type(block.config_output.width) + )); + set_polyfill::(comptime!(acc_precision.into_type(block.config_input.width))); +} + +/// Internal logic for executing a sequence of reduction blocks followed by an optional +/// trailing elementwise block. +#[cube] +#[allow(clippy::clone_on_copy)] +fn reduce_many( + inputs: &GlobalArgs, + outputs: &mut GlobalArgs, + reduce_axis: usize, + out_vec_axis: usize, + blocks: Sequence, + block_end: ComptimeOption, +) { + let mut axis_size = 0; + + #[unroll] + for i in 0..blocks.len() { + let block = blocks.index(i); + let input = FusedReduceInput { + global: inputs.clone(), + config: comptime!(block.config_input.clone()), + arg: comptime!(block.input.clone()), + }; + let global = outputs.clone(); + let config = comptime!(block.config_output.clone()); + let arg = comptime!(block.output.clone()); + let mut output = FusedReduceOutput { + global, + config, + arg, + }; + + set_polyfill_block(block); + let (input, mut output) = + init_tensors::(&input, &mut output); + + axis_size = reduce_step::<(In, InSize, Acc), (Out, OutSize), ReduceOperation>( + &input, + &mut output, + reduce_axis, + out_vec_axis, + block.op, + comptime!(block.blueprint.clone()), + ); + } + + #[comptime] + if let ComptimeOption::Some(block) = block_end { + let global_index = ABSOLUTE_POS; + let width = block.config.width; + let num_iter = axis_size / width; + let size!(N) = width; + + for i in 0..num_iter { + // Register block local inputs. + let values = Registry::>::new(); + let args = comptime![Vec::::new()]; + let index = global_index * num_iter + i; + let mut locals = init_locals(inputs, outputs, &block.config); + + fuse_on_write::( + inputs, + outputs, + &mut locals, + index, + values, + args, + &block.config.clone(), + ) + } + } +} + +#[cube] +/// Executes a single reduction step using a specified instruction and blueprint. +/// +/// Returns the size of the axis that was reduced. +fn reduce_step>( + input: &VirtualTensor, + output: &mut VirtualTensor, + reduce_axis: usize, + out_vec_axis: usize, + #[comptime] config: I::Config, + #[comptime] blueprint: UnitReduceBlueprint, +) -> usize { + let inst = I::from_config(config); + let axis_size = input.shape(reduce_axis); + + GlobalFullUnitReduce::execute::( + input, + output, + reduce_axis, + out_vec_axis, + &inst, + VectorizationMode::Parallel, + comptime!(blueprint), + ); + axis_size +} diff --git a/crates/burn-cubecl-fusion/src/tune.rs b/crates/burn-cubecl-fusion/src/tune.rs new file mode 100644 index 0000000..8cd4baa --- /dev/null +++ b/crates/burn-cubecl-fusion/src/tune.rs @@ -0,0 +1,215 @@ +use crate::CubeFusionHandle; +use burn_fusion::stream::Context; +use burn_ir::{HandleContainer, TensorId, TensorIr}; +use cubecl::Runtime; +use cubecl::tune::{InputGenerator, TuneInputs}; +use hashbrown::HashMap; +use std::marker::PhantomData; +use std::sync::Arc; + +/// [`TuneInputs`] marker for [`TuneInput`]. This is the indirection that lets a +/// `TunableSet<_, FusionTuneInputs, _>` live `'static` inside `LocalTuner::init`'s +/// cache while still accepting a borrowing `TuneInput<'a, …>` at `execute` time, via HRTB +/// over `'a`. +#[allow(clippy::type_complexity)] +pub(crate) struct FusionTuneInputs(PhantomData<(fn() -> R, fn() -> O)>); + +impl TuneInputs for FusionTuneInputs { + type At<'a> = TuneInput<'a, R, O>; +} + +/// [`InputGenerator`] for [`TuneInput`]: produces a benchmark-only [`TuneState::Fork`] +/// (i.e. with `new_handles = None`). Benchmarks discard their outputs, so the returned +/// input skips the handle-tracking machinery that a wasm-fallback fork carries. +pub(crate) struct FusionInputGen; + +impl InputGenerator> for FusionInputGen +where + K: 'static, + R: Runtime, + O: Send + Sync + 'static, +{ + fn generate<'a>( + &self, + _key: &K, + inputs: & as TuneInputs>::At<'a>, + ) -> as TuneInputs>::At<'a> { + inputs.for_benchmark() + } +} + +/// Shared staging area for handles produced by a wasm try-all fork. +/// +/// [`Fork`](TuneState::Fork)'s `Drop` dumps all of the fork's handles here (replacing any +/// previous contents), and [`Original`](TuneState::Original)'s `Drop` drains this and +/// filters each entry against the real context's current handles — anything the real +/// context doesn't already have is a genuinely new output and gets registered. +/// +/// The pipeline is strictly serial, so +/// the lock is always uncontended; `spin::Mutex` is there purely to give +/// `Arc>` `Send + Sync`. +pub(crate) struct HandleCollector(spin::Mutex>>); + +impl HandleCollector { + fn new() -> Self { + Self(spin::Mutex::new(HashMap::new())) + } + + fn capture(&self, handles: &HandleContainer>) { + let mut bag = self.0.lock(); + bag.clear(); + for id in handles.handle_ids() { + if let Some(h) = handles.get_handle_ref(id) { + bag.insert(*id, h.clone()); + } + } + } + + fn take(&self) -> HashMap> { + core::mem::take(&mut *self.0.lock()) + } +} + +/// Fusion input for autotuning. Thread the caller's `&mut Context` through the tuning +/// pipeline without `unsafe` by riding cubecl's `'a` lifetime parameter. +pub(crate) struct TuneInput<'a, R: Runtime, O> { + optimization: Arc, + state: TuneState<'a, R>, +} + +enum TuneState<'a, R: Runtime> { + Original { + context: &'a mut Context>, + /// Shared with any `Fork` spawned from this `Original`. `Drop` drains from here + /// if the cache-hit path never ran (e.g. wasm fallback succeeded on a fork). + new_handles: Arc>, + /// Set by [`TuneInput::execute`] when the winner ran on this context, which + /// suppresses the drain above. + executed: bool, + }, + /// Owned fork. `new_handles = None` is a benchmark-only sandbox (outputs discarded); + /// `Some(…)` is the wasm try-all path that promotes outputs back into the paired + /// `Original` on drop. + Fork { + context: Box>>, + new_handles: Option>>, + }, +} + +impl<'a, R: Runtime, O> TuneInput<'a, R, O> { + pub(crate) fn new(context: &'a mut Context>, optimization: O) -> Self { + Self { + optimization: Arc::new(optimization), + state: TuneState::Original { + context, + new_handles: Arc::new(HandleCollector::new()), + executed: false, + }, + } + } + + /// Fork the context into a non-tracking `Fork` for a benchmark trial. See + /// [`FusionInputGen`]. + fn for_benchmark(&self) -> Self { + Self { + optimization: self.optimization.clone(), + state: TuneState::Fork { + context: Box::new(self.context().fork()), + new_handles: None, + }, + } + } + + pub(crate) fn is_original(&self) -> bool { + matches!(self.state, TuneState::Original { .. }) + } + + /// Read-only access to the wrapped context. + pub(crate) fn context(&self) -> &Context> { + match &self.state { + TuneState::Original { context, .. } => context, + TuneState::Fork { context, .. } => context, + } + } + + pub(crate) fn tensors(&self) -> &HashMap { + &self.context().tensors + } + + pub(crate) fn handles(&self) -> &HandleContainer> { + &self.context().handles + } + + pub(crate) fn optimization(&self) -> &O { + &self.optimization + } + + /// Consume the input and run `f` with mutable access to the context and + /// optimization. + pub(crate) fn execute(mut self, f: F) -> T + where + F: FnOnce(&mut Context>, &O) -> T, + { + match &mut self.state { + TuneState::Original { + context, executed, .. + } => { + // Suppresses drop-time persistence; the real context was written directly. + *executed = true; + f(context, &self.optimization) + } + TuneState::Fork { context, .. } => f(context, &self.optimization), + } + } +} + +impl<'a, R: Runtime, O> Clone for TuneInput<'a, R, O> { + fn clone(&self) -> Self { + // `Original` clones come from the wasm fallback path (`operations.fastest(i) + // .execute(inputs.clone())`) and must track outputs. `Fork` clones inherit the + // source's tracking (benchmark forks stay non-tracking). + let new_handles = match &self.state { + TuneState::Original { new_handles, .. } => Some(new_handles.clone()), + TuneState::Fork { new_handles, .. } => new_handles.clone(), + }; + Self { + optimization: self.optimization.clone(), + state: TuneState::Fork { + context: Box::new(self.context().fork()), + new_handles, + }, + } + } +} + +impl<'a, R: Runtime, O> Drop for TuneInput<'a, R, O> { + fn drop(&mut self) { + match &mut self.state { + TuneState::Original { + context, + new_handles, + executed, + } => { + if *executed { + return; + } + // Cache-hit path never ran. Drain anything the most recent `Fork` + // deposited and register entries the real context doesn't already have + // (those are genuine new outputs, not inputs inherited via `fork`). + for (id, handle) in new_handles.take() { + if context.handles.get_handle_ref(&id).is_none() { + context.handles.register_handle(id, handle); + } + } + } + TuneState::Fork { + context, + new_handles, + } => { + if let Some(collector) = new_handles { + collector.capture(&context.handles); + } + } + } + } +} diff --git a/crates/burn-cubecl/Cargo.toml b/crates/burn-cubecl/Cargo.toml new file mode 100644 index 0000000..b9938c9 --- /dev/null +++ b/crates/burn-cubecl/Cargo.toml @@ -0,0 +1,92 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Generic backend that can be compiled just-in-time to any shader language target" +documentation = "https://docs.rs/burn-cubecl" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "gpu"] +license.workspace = true +name = "burn-cubecl" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-cubecl" +version.workspace = true + +[lints] +workspace = true + +[features] +default = [ + "autotune", + "std", + "fusion", + "cubecl/default", + "burn-backend/default", + "burn-fusion?/default", + "burn-cubecl-fusion?/default", +] +std = [ + "cubecl/std", + "burn-backend/std", + "burn-fusion?/std", + "burn-cubecl-fusion?/std", +] +doc = ["default"] + +tracing = [ + "dep:tracing", + "cubecl/tracing", + "burn-std/tracing", + "burn-backend/tracing", + "burn-fusion?/tracing", + "burn-cubecl-fusion?/tracing", +] + +autotune = ["burn-cubecl-fusion?/autotune"] +autotune-checks = [ + "autotune", + "cubecl/autotune-checks", + "burn-cubecl-fusion?/autotune-checks", +] + +fusion = ["burn-fusion", "burn-cubecl-fusion"] +fusion-experimental = ["fusion"] + +template = [] + + +[dependencies] +burn-cubecl-fusion = { workspace = true, optional = true } +burn-fusion = { workspace = true, optional = true } +burn-ir = { workspace = true } +burn-std = { workspace = true } +burn-backend = { workspace = true, features = [ + "cubecl", +] } +cubecl = { workspace = true, features = ["stdlib"] } +cubek = { workspace = true, features = [ + "attention", + "matmul", + "convolution", + "interpolate", + "pool", + "reduce", + "random", + "quantization", + "stdlib", + "fft", +] } +tracing = { workspace = true, features = ["attributes"], optional = true } + +derive-new = { workspace = true } +log = { workspace = true } + +# Async +futures-lite = { workspace = true, features = ["std"] } + +# Template +serde = { workspace = true } +text_placeholder = { workspace = true, features = ["struct_context"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-cubecl/LICENSE-APACHE b/crates/burn-cubecl/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-cubecl/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-cubecl/LICENSE-MIT b/crates/burn-cubecl/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-cubecl/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-cubecl/README.md b/crates/burn-cubecl/README.md new file mode 100644 index 0000000..d1811f3 --- /dev/null +++ b/crates/burn-cubecl/README.md @@ -0,0 +1,3 @@ +# Burn CubeCL Backend + +Generic backend that can be compiled just-in-time (JIT) to any shader language target. diff --git a/crates/burn-cubecl/src/backend.rs b/crates/burn-cubecl/src/backend.rs new file mode 100644 index 0000000..238da91 --- /dev/null +++ b/crates/burn-cubecl/src/backend.rs @@ -0,0 +1,255 @@ +use crate::{CubeRuntime, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + Backend, BackendGraph, BackendTypes, DTypeUsage, DTypeUsageSet, DeviceOps, ExecutionError, + TensorData, +}; +use burn_std::{BoolStore, DType}; +use cubecl::{ + features::{MmaConfig, TypeUsage}, + server::ComputeServer, +}; +use std::marker::PhantomData; + +#[cfg(not(feature = "fusion"))] +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}; +#[cfg(not(feature = "fusion"))] +use burn_ir::{BackendIr, TensorHandle}; + +/// Turn a cubecl graph-capture error into a backend [`ExecutionError`]. +fn graph_err(err: impl core::fmt::Display) -> ExecutionError { + ExecutionError::WithContext { + reason: format!("{err}"), + } +} + +/// Generic tensor backend that can be compiled just-in-time to any shader runtime +#[derive(new)] +pub struct CubeBackend { + _runtime: PhantomData, +} + +impl BackendTypes for CubeBackend +where + R: CubeRuntime, + R::Server: ComputeServer, + R::Device: DeviceOps, +{ + type Device = R::Device; + + type FloatTensorPrimitive = CubeTensor; + type IntTensorPrimitive = CubeTensor; + type BoolTensorPrimitive = CubeTensor; + type QuantizedTensorPrimitive = CubeTensor; + + type GraphPrimitive = cubecl::client::Graph; +} + +impl Backend for CubeBackend +where + R: CubeRuntime, + R::Server: ComputeServer, + R::Device: DeviceOps, +{ + fn name(device: &Self::Device) -> String { + let client = R::client(device); + format!("cubecl<{}>", R::name(&client)) + } + + fn seed(_device: &Self::Device, seed: u64) { + cubek::random::seed(seed); + } + + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + fn sync(device: &Self::Device) -> Result<(), ExecutionError> { + let client = R::client(device); + futures_lite::future::block_on(client.sync()).map_err(|err| ExecutionError::WithContext { + reason: format!("{err}"), + }) + } + + fn graph_prepare(device: &Self::Device) -> Result<(), ExecutionError> { + let client = R::client(device); + client.graph_prepare().map_err(graph_err) + } + + fn graph_start_capture(device: &Self::Device) -> Result<(), ExecutionError> { + let client = R::client(device); + client.start_capture().map_err(graph_err) + } + + fn graph_stop_capture(device: &Self::Device) -> Result, ExecutionError> { + let client = R::client(device); + client.stop_capture().map_err(graph_err) + } + + unsafe fn graph_replay( + _device: &Self::Device, + graph: &BackendGraph, + ) -> Result<(), ExecutionError> { + // cubecl's `Graph::replay` is fire-and-forget: it enqueues the dispatch + // and returns immediately, so a replay failure is not reported here — it + // lands in the stream's error queue and surfaces on the next sync/flush. + // + // Safety: the buffer-liveness and stream-ordering obligations are the + // caller's, forwarded verbatim from this method's own contract. + unsafe { graph.replay() }; + Ok(()) + } + + fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + device: &Self::Device, + input: Input, + func: Func, + ) -> Output { + let client = R::client(device); + client.memory_persistent_allocation(input, func).unwrap() + } + + fn memory_cleanup(device: &Self::Device) { + let client = R::client(device); + client.memory_cleanup(); + } + + fn staging<'a, Iter>(data: Iter, device: &Self::Device) + where + Iter: Iterator, + { + let client = R::client(device); + client.staging(data.map(|td| &mut td.bytes), false); + } + + fn supports_dtype(device: &Self::Device, dtype: DType) -> bool { + // Right now no cubecl backend actually works with native bool, even if + // the `TypeUsage` might indicate otherwise. + if let DType::Bool(BoolStore::Native) = dtype { + return false; + } + + let client = R::client(device); + + let type_usage = client.properties().type_usage(dtype_to_storage_type(dtype)); + // Same as `TypeUsage::all_scalar()`, but we make the usage explicit here + type_usage.is_superset( + TypeUsage::Buffer + | TypeUsage::Conversion + | TypeUsage::Arithmetic + | TypeUsage::DotProduct, + ) + } + + fn dtype_usage(device: &Self::Device, dtype: DType) -> DTypeUsageSet { + // Right now no cubecl backend actually works with native bool, even if + // the `TypeUsage` might indicate otherwise. + if let DType::Bool(BoolStore::Native) = dtype { + return DTypeUsageSet::empty(); + } + + let client = R::client(device); + + let props = client.properties(); + let storage = dtype_to_storage_type(dtype); + let usage = props.type_usage(storage); + + let mut out = DTypeUsageSet::new(); + + if usage.is_superset(TypeUsage::Buffer | TypeUsage::Conversion) { + out |= DTypeUsage::Storage; + } + + if usage.contains(TypeUsage::Arithmetic) { + out |= DTypeUsage::Arithmetic; + } + + let has_mma = |cfg: &MmaConfig| { + cfg.a_type == storage || cfg.b_type == storage || cfg.cd_type == storage + }; + if props.features.matmul.cmma.iter().any(has_mma) + || props.features.matmul.mma.iter().any(has_mma) + { + out |= DTypeUsage::Accelerated; + } + + out + } + + fn device_count(type_id: u16) -> usize { + let client = R::client(&Default::default()); + client.device_count(type_id) + } + + fn flush(device: &Self::Device) { + let client = R::client(device); + client.flush().unwrap(); + } +} + +impl core::fmt::Debug for CubeBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CubeCLBackend") + } +} + +impl Clone for CubeBackend { + fn clone(&self) -> Self { + Self::new() + } +} + +impl Default for CubeBackend { + fn default() -> Self { + Self::new() + } +} + +impl CubeRuntime for R +where + R::Device: DeviceOps, +{ + type CubeDevice = R::Device; + type CubeServer = R::Server; +} + +#[cfg(not(feature = "fusion"))] +impl BackendIr for CubeBackend { + type Handle = CubeTensor; + + fn float_tensor(handle: TensorHandle) -> FloatTensor { + handle.handle + } + + fn int_tensor(handle: TensorHandle) -> IntTensor { + handle.handle + } + + fn bool_tensor(handle: TensorHandle) -> BoolTensor { + handle.handle + } + + fn quantized_tensor(handle: TensorHandle) -> QuantizedTensor { + handle.handle + } + + fn float_tensor_handle(tensor: FloatTensor) -> Self::Handle { + tensor + } + + fn int_tensor_handle(tensor: IntTensor) -> Self::Handle { + tensor + } + + fn bool_tensor_handle(tensor: BoolTensor) -> Self::Handle { + tensor + } + + fn quantized_tensor_handle(tensor: QuantizedTensor) -> Self::Handle { + tensor + } +} diff --git a/crates/burn-cubecl/src/element.rs b/crates/burn-cubecl/src/element.rs new file mode 100644 index 0000000..c9a02b7 --- /dev/null +++ b/crates/burn-cubecl/src/element.rs @@ -0,0 +1,94 @@ +use burn_backend::{Element, bf16, f16}; +use cubecl::{ + CubeElement as CubeElem, flex32, + prelude::{Float, Int, Numeric}, +}; +use cubek::{ + matmul::definition::{MatmulPrecision, MatrixPrecision}, + reduce::ReducePrecision, +}; + +/// The base element trait for the jit backend. +pub trait CubeElement: Element + CubeElem + PartialEq + Numeric {} + +/// Element that can be used for matrix multiplication. Includes ints and floats. +pub trait MatmulElement: + CubeElement + MatmulPrecision> +{ +} + +/// The float element type for the jit backend. +pub trait FloatElement: MatmulElement + Float {} + +/// The int element type for the jit backend. +pub trait IntElement: + MatmulElement + Int + ReducePrecision +{ +} + +/// The element type for booleans for the jit backend. +pub trait BoolElement: CubeElement + Int { + /// The true value for the boolean element. + fn true_val() -> Self { + Self::from_int(1) + } + + /// The false value for the boolean element. + fn false_val() -> Self { + Self::from_int(0) + } + + /// New bool element from Rust bool. + fn new_bool(val: bool) -> Self { + match val { + true => Self::true_val(), + false => Self::false_val(), + } + } +} + +impl CubeElement for u64 {} +impl CubeElement for u32 {} +impl CubeElement for u16 {} +impl CubeElement for u8 {} +impl CubeElement for i64 {} +impl CubeElement for i32 {} +impl CubeElement for i16 {} +impl CubeElement for i8 {} +impl CubeElement for f64 {} +impl CubeElement for f32 {} +impl CubeElement for flex32 {} +impl CubeElement for f16 {} +impl CubeElement for bf16 {} + +impl FloatElement for f64 {} +impl FloatElement for f32 {} +impl FloatElement for flex32 {} +impl FloatElement for bf16 {} +impl FloatElement for f16 {} +impl IntElement for i64 {} +impl IntElement for i32 {} +impl IntElement for i16 {} +impl IntElement for i8 {} +impl IntElement for u64 {} +impl IntElement for u32 {} +impl IntElement for u16 {} +impl IntElement for u8 {} + +impl BoolElement for u8 {} +impl BoolElement for u32 {} + +impl MatmulElement for f64 {} +impl MatmulElement for f32 {} +impl MatmulElement for flex32 {} +impl MatmulElement for bf16 {} +impl MatmulElement for f16 {} + +impl MatmulElement for i64 {} +impl MatmulElement for i32 {} +impl MatmulElement for i16 {} +impl MatmulElement for i8 {} +impl MatmulElement for u64 {} +impl MatmulElement for u32 {} +impl MatmulElement for u16 {} +impl MatmulElement for u8 {} diff --git a/crates/burn-cubecl/src/fusion.rs b/crates/burn-cubecl/src/fusion.rs new file mode 100644 index 0000000..99ff13e --- /dev/null +++ b/crates/burn-cubecl/src/fusion.rs @@ -0,0 +1,192 @@ +use crate::{CubeBackend, CubeRuntime, kernel, tensor::CubeTensor}; +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}; +use burn_backend::{DType, Shape}; +use burn_cubecl_fusion::optim::reduce::ReduceSettings; +use burn_cubecl_fusion::optim::reduce_broadcasted::ReduceBroadcastedFuser; +use burn_cubecl_fusion::{ + CubeFusionHandle, FallbackOperation, + optim::{ + CubeOptimization, CubeOptimizationState, + elemwise::{ElementWiseFuser, ElemwiseOptimization}, + matmul::{MatmulFuser, MatmulOptimization}, + reduce::{ReduceFuser, ReduceOptimization}, + reduce_broadcasted::ReduceBroadcastedOptimization, + }, +}; +use burn_fusion::UnfusedOp; +use burn_fusion::{ + FusionBackend, FusionRuntime, + stream::{Operation, OrderedExecution}, +}; +use burn_ir::{BackendIr, TensorHandle}; +use burn_std::Metadata; +use core::marker::PhantomData; +use std::sync::Arc; + +impl burn_fusion::Optimization> for CubeOptimization +where + R: CubeRuntime, +{ + fn execute( + &mut self, + context: &mut burn_fusion::stream::Context< + as FusionRuntime>::FusionHandle, + >, + execution: &OrderedExecution>, + ) { + match self { + Self::ElementWise(op) => op.execute(context), + Self::Matmul(op) => op.execute(context, |index| { + let operation = execution.operation_within_optimization(index); + Box::new(FallbackOperationWrapper::new(operation)) + }), + Self::Reduce(op) => op.execute(context, |index| { + let operation = execution.operation_within_optimization(index); + Box::new(FallbackOperationWrapper::new(operation)) + }), + Self::ReduceBroadcasted(op) => op.execute(context, |index| { + let operation = execution.operation_within_optimization(index); + Box::new(FallbackOperationWrapper::new(operation)) + }), + } + } + + fn to_state(&self) -> CubeOptimizationState { + self.to_opt_state() + } + + fn from_state(device: &R::Device, state: CubeOptimizationState) -> Self { + match state { + CubeOptimizationState::ElementWise(state) => { + Self::ElementWise(ElemwiseOptimization::from_state(device, state)) + } + CubeOptimizationState::Matmul(state) => { + Self::Matmul(MatmulOptimization::from_state(device, state)) + } + CubeOptimizationState::Reduce(state) => { + Self::Reduce(ReduceOptimization::from_state(device, state)) + } + CubeOptimizationState::ReduceBroadcasted(state) => { + Self::ReduceBroadcasted(ReduceBroadcastedOptimization::from_state(device, state)) + } + } + } +} + +struct FallbackOperationWrapper { + operation: O, +} + +impl FallbackOperationWrapper { + fn new(op: O) -> Self { + Self { operation: op } + } +} + +impl FallbackOperation + for FallbackOperationWrapper>>> +{ + fn run(&self, context: &mut burn_fusion::stream::Context>) { + self.operation.as_ref().execute(&mut context.handles); + } +} + +impl FallbackOperation + for FallbackOperationWrapper>> +{ + fn run(&self, context: &mut burn_fusion::stream::Context>) { + self.operation.execute(&mut context.handles); + } +} + +impl BackendIr for CubeBackend { + type Handle = CubeFusionHandle; + + fn float_tensor(handle: TensorHandle) -> FloatTensor { + into_tensor(handle.handle, handle.shape) + } + + fn int_tensor(handle: TensorHandle) -> IntTensor { + into_tensor(handle.handle, handle.shape) + } + + fn bool_tensor(handle: TensorHandle) -> BoolTensor { + into_tensor(handle.handle, handle.shape) + } + + fn quantized_tensor(handle: TensorHandle) -> QuantizedTensor { + into_tensor(handle.handle, handle.shape) + } + + fn float_tensor_handle(tensor: FloatTensor) -> Self::Handle { + tensor.into() + } + + fn int_tensor_handle(tensor: IntTensor) -> Self::Handle { + tensor.into() + } + + fn bool_tensor_handle(tensor: BoolTensor) -> Self::Handle { + tensor.into() + } + + fn quantized_tensor_handle(tensor: QuantizedTensor) -> Self::Handle { + tensor.into() + } +} + +impl FusionRuntime for FusionCubeRuntime { + type OptimizationState = CubeOptimizationState; + type Optimization = CubeOptimization; + type FusionHandle = CubeFusionHandle; + type FusionDevice = R::CubeDevice; + + fn fusers(device: R::Device) -> Vec>> { + vec![ + Box::new(ElementWiseFuser::new(device.clone())), + Box::new(MatmulFuser::new(device.clone())), + Box::new(ReduceFuser::new(device.clone(), ReduceSettings::Always)), + Box::new(ReduceBroadcastedFuser::new(device.clone())), + ] + } +} + +/// Fusion runtime for JIT runtimes. +#[derive(Debug)] +pub struct FusionCubeRuntime { + _b: PhantomData, +} + +impl FusionBackend for CubeBackend { + type FusionRuntime = FusionCubeRuntime; + + type FullPrecisionBackend = CubeBackend; + + fn cast_float(tensor: FloatTensor, dtype: DType) -> Self::Handle { + kernel::cast(tensor, dtype).into() + } +} + +fn into_tensor(handle: CubeFusionHandle, shape: Shape) -> CubeTensor { + CubeTensor { + client: handle.client.clone(), + handle: handle.handle.clone(), + device: handle.device.clone(), + meta: Box::new(Metadata::new(shape, handle.strides.clone())), + dtype: handle.dtype, + qparams: handle.qparams.clone(), + } +} + +impl From> for CubeFusionHandle { + fn from(value: CubeTensor) -> Self { + Self { + client: value.client.clone(), + handle: value.handle.clone(), + device: value.device.clone(), + strides: value.meta.strides.clone(), + dtype: value.dtype, + qparams: value.qparams.clone(), + } + } +} diff --git a/crates/burn-cubecl/src/kernel/attention/base.rs b/crates/burn-cubecl/src/kernel/attention/base.rs new file mode 100644 index 0000000..627f56d --- /dev/null +++ b/crates/burn-cubecl/src/kernel/attention/base.rs @@ -0,0 +1,145 @@ +use crate::{ + CubeBackend, CubeRuntime, kernel::attention::attention_autotune, + ops::numeric::empty_device_dtype, tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + DType, Shape, + ops::{AttentionModuleOptions, attention::attention_fallback}, +}; +use cubek::attention::forward::{ + definition::{ + AccumulatorPrecision, AttentionGlobalTypes, AttentionOptions, AttentionSetupError, + }, + launch, + routines::blackbox_accelerated::BlackboxAcceleratedStrategy, +}; + +#[derive(Debug)] +/// Strategy used to select which attention implementation to run. +pub enum AttentionStrategy { + /// Flash Attention using accelerated inner matmuls. + FlashBlackboxAccelerated(BlackboxAcceleratedStrategy), + + /// Flash Attention using unit inner matmuls. + FlashUnit, + + /// Fallback implementation using multiple separate kernels. + Fallback, + + /// Automatically benchmark and select the best strategy at runtime. + #[cfg(feature = "autotune")] + Autotune, +} + +impl Default for AttentionStrategy { + fn default() -> Self { + // if autotune is enabled, default to autotune + #[cfg(feature = "autotune")] + return AttentionStrategy::Autotune; + + // if autotune is disabled, default to fallback to make sure it runs + #[cfg(not(feature = "autotune"))] + AttentionStrategy::Fallback + } +} + +#[allow(clippy::too_many_arguments)] +/// Launch an attention kernel with given strategy +pub fn attention( + query: CubeTensor, + key: CubeTensor, + value: CubeTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + strategy: AttentionStrategy, +) -> Result, AttentionSetupError> { + match strategy { + AttentionStrategy::FlashBlackboxAccelerated(strategy) => flash_attention( + query, + key, + value, + mask, + attn_bias, + options, + launch::Strategy::BlackboxAccelerated(launch::BlueprintStrategy::Inferred(strategy)), + ), + AttentionStrategy::FlashUnit => flash_attention( + query, + key, + value, + mask, + attn_bias, + options, + launch::Strategy::Unit(launch::BlueprintStrategy::Inferred(())), + ), + AttentionStrategy::Fallback => Ok(attention_fallback::>( + query, key, value, mask, attn_bias, options, + )), + #[cfg(feature = "autotune")] + AttentionStrategy::Autotune => Ok(attention_autotune( + query, key, value, mask, attn_bias, options, + )), + } +} + +#[allow(clippy::too_many_arguments)] +/// Launch a flash attention kernel +pub fn flash_attention( + query: CubeTensor, + key: CubeTensor, + value: CubeTensor, + mask: Option>, + _attn_bias: Option>, + options: AttentionModuleOptions, + strategy: launch::Strategy, +) -> Result, AttentionSetupError> { + let client = query.client.clone(); + let out = init_attention_output(&query, &value); + + let dtypes = AttentionGlobalTypes { + query: dtype_to_storage_type(query.dtype), + key: dtype_to_storage_type(key.dtype), + value: dtype_to_storage_type(value.dtype), + mask: dtype_to_storage_type(mask.as_ref().map(|m| m.dtype).unwrap_or(DType::U8)), + out: dtype_to_storage_type(out.dtype), + }; + + launch::launch_ref::( + strategy, + &client, + query.binding(), + key.binding(), + value.binding(), + mask.map(|mask| mask.binding()), + out.clone().binding(), + &dtypes, + AttentionOptions { + causal: options.is_causal, + accumulator_precision: AccumulatorPrecision::Strict(cubecl::ir::StorageType::Scalar( + cubecl::ir::ElemType::Float(cubecl::ir::FloatKind::F32), + )), + }, + )?; + + Ok(out) +} + +pub(crate) fn init_attention_output( + query: &CubeTensor, + value: &CubeTensor, +) -> CubeTensor { + let num_batches = query.meta.shape[0]; + let num_heads = query.meta.shape[1]; + let seq_q = query.meta.shape[2]; + let val_dim = value.meta.shape[3]; + let out_shape = Shape::new([num_batches, num_heads, seq_q, val_dim]); + + empty_device_dtype::( + query.client.clone(), + query.device.clone(), + out_shape, + query.dtype, + ) +} diff --git a/crates/burn-cubecl/src/kernel/attention/mod.rs b/crates/burn-cubecl/src/kernel/attention/mod.rs new file mode 100644 index 0000000..8ff38a9 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/attention/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod tune; + +pub use base::*; +pub use tune::*; diff --git a/crates/burn-cubecl/src/kernel/attention/tune.rs b/crates/burn-cubecl/src/kernel/attention/tune.rs new file mode 100644 index 0000000..10fbfc8 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/attention/tune.rs @@ -0,0 +1,178 @@ +use crate::{ + CubeRuntime, CubeTuneId, + kernel::attention::{AttentionStrategy, attention}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_backend::ops::AttentionModuleOptions; +use cubecl::tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}; +use cubek::attention::forward::{ + launch::AttentionAutotuneKey, routines::blackbox_accelerated::BlackboxAcceleratedStrategy, +}; + +/// Executes autotune on attention operations +pub fn attention_autotune( + query: CubeTensor, + key: CubeTensor, + value: CubeTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, +) -> CubeTensor { + let client = query.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + const PRIORITY_MAX: i8 = 3; + const PRIORITY_MIN: i8 = 0; + + let flash_attention = + TuneGroup::::new("flash_attention", |_key| PRIORITY_MAX); + + let fallback = TuneGroup::::new("fallback", |key| { + if key.seq_q > 4096 { + PRIORITY_MIN + } else { + PRIORITY_MAX + } + }); + + let mut set = TunableSet::new(create_key::, input_gen::); + + // First entry should always work, since it is considered the fallback. + set = set.with( + Tunable::new( + "fallback", + |(query, key, value, mask, attn_bias, options)| { + attention::( + query, + key, + value, + mask, + attn_bias, + options, + AttentionStrategy::Fallback, + ) + .map_err(|err| std::format!("{err:?}")) + }, + ) + .group(&fallback, |_key| PRIORITY_MAX), + ); + + let seq_q = 1; + let seq_kv = 1; + for num_planes in [2, 4, 8] { + let name = format!("blackbox_accelerated_{num_planes}_planes_p_{seq_q}-{seq_kv}"); + set = set.with( + Tunable::new( + &name, + move |(query, key, value, mask, attn_bias, options)| { + attention::( + query, + key, + value, + mask, + attn_bias, + options, + AttentionStrategy::FlashBlackboxAccelerated( + BlackboxAcceleratedStrategy { + num_planes, + seq_q, + seq_kv, + }, + ), + ) + .map_err(|err| std::format!("{err:?}")) + }, + ) + .group(&flash_attention, |_key| PRIORITY_MAX), + ); + } + + set = set.with( + Tunable::new("unit", |(query, key, value, mask, attn_bias, options)| { + attention::( + query, + key, + value, + mask, + attn_bias, + options, + AttentionStrategy::FlashUnit, + ) + .map_err(|err| std::format!("{err:?}")) + }) + .group(&flash_attention, |_key| PRIORITY_MIN), + ); + + set + }); + + TUNER.execute( + &CubeTuneId::new(&client, &query.device), + &client, + tunables, + (query, key, value, mask, attn_bias, options), + ) +} + +#[allow(clippy::type_complexity)] +fn create_key( + (query, key, value, mask, _attn_bias, _options): &( + CubeTensor, + CubeTensor, + CubeTensor, + Option>, + Option>, + AttentionModuleOptions, + ), +) -> AttentionAutotuneKey { + let total_batches = query.meta.shape[0] * query.meta.shape[1]; + let seq_q = query.meta.shape[2]; + let head_dim = query.meta.shape[3]; + let seq_kv = value.meta.shape[2]; + let val_dim = value.meta.shape[3]; + + AttentionAutotuneKey::generate( + dtype_to_elem_type(query.dtype), + dtype_to_elem_type(key.dtype), + dtype_to_elem_type(value.dtype), + dtype_to_elem_type(query.dtype), + total_batches, + seq_q, + head_dim, + seq_kv, + val_dim, + mask.is_some(), + ) +} + +#[allow(clippy::type_complexity)] +fn input_gen( + _key: &AttentionAutotuneKey, + (query, key, value, mask, attn_bias, options): &( + CubeTensor, + CubeTensor, + CubeTensor, + Option>, + Option>, + AttentionModuleOptions, + ), +) -> ( + CubeTensor, + CubeTensor, + CubeTensor, + Option>, + Option>, + AttentionModuleOptions, +) { + ( + query.clone(), + key.clone(), + value.clone(), + mask.clone(), + attn_bias.clone(), + *options, + ) +} diff --git a/crates/burn-cubecl/src/kernel/binary.rs b/crates/burn-cubecl/src/kernel/binary.rs new file mode 100644 index 0000000..71c746b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/binary.rs @@ -0,0 +1,346 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_shape}, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{TensorMetadata, bf16, f16}; +use cubecl::{ + calculate_cube_count_elemwise, intrinsic, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait BinaryOpFamily: Send + Sync + 'static { + type BinaryOp: BinaryOp; +} + +#[cube] +pub(crate) trait BinaryOp: 'static + Send + Sync { + /// Execute a binary operation. + fn execute(lhs: Vector, rhs: Vector) -> Vector; +} + +pub(crate) struct AddOp; +pub(crate) struct SubOp; +pub(crate) struct MulOp; +pub(crate) struct DivOp; +pub(crate) struct RemainderOp; +pub(crate) struct AndOp; +pub(crate) struct OrOp; +pub(crate) struct PowOp; +pub(crate) struct AssignOp; +pub(crate) struct BinaryMinOp; +pub(crate) struct BinaryMaxOp; + +impl BinaryOpFamily for AddOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for SubOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for MulOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for DivOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for RemainderOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for PowOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for AndOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for OrOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for AssignOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for BinaryMinOp { + type BinaryOp = Self; +} + +impl BinaryOpFamily for BinaryMaxOp { + type BinaryOp = Self; +} + +#[cube] +impl BinaryOp for AddOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs + rhs + } +} + +#[cube] +impl BinaryOp for SubOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs - rhs + } +} + +#[cube] +impl BinaryOp for MulOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs * rhs + } +} + +#[cube] +impl BinaryOp for DivOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs / rhs + } +} + +#[cube] +impl BinaryOp for RemainderOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + Vector::mod_floor(lhs, rhs) + } +} + +#[cube] +impl BinaryOp for PowOp { + #[allow(unused)] + fn execute(lhs: Vector, rhs: Vector) -> Vector { + intrinsic!(|scope| { + let elem = T::__expand_as_type(scope).elem_type(); + + if let cubecl::ir::ElemType::Float(kind) = elem { + match kind { + cubecl::ir::FloatKind::F16 => { + let lhs = as Cast>::__expand_cast_from(scope, lhs); + let rhs = as Cast>::__expand_cast_from(scope, rhs); + let out = Vector::__expand_powf(scope, lhs, rhs); + return as Cast>::__expand_cast_from(scope, out); + } + cubecl::ir::FloatKind::BF16 => { + let lhs = as Cast>::__expand_cast_from(scope, lhs); + let rhs = as Cast>::__expand_cast_from(scope, rhs); + let out = Vector::__expand_powf(scope, lhs, rhs); + return as Cast>::__expand_cast_from(scope, out); + } + cubecl::ir::FloatKind::F64 => { + let lhs = as Cast>::__expand_cast_from(scope, lhs); + let rhs = as Cast>::__expand_cast_from(scope, rhs); + let out = Vector::__expand_powf(scope, lhs, rhs); + return as Cast>::__expand_cast_from(scope, out); + } + _ => {} + } + }; + + let lhs = as Cast>::__expand_cast_from(scope, lhs); + let rhs = as Cast>::__expand_cast_from(scope, rhs); + let out = Vector::__expand_powf(scope, lhs, rhs); + return as Cast>::__expand_cast_from(scope, out); + }) + } +} + +#[cube] +impl BinaryOp for AndOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + Vector::cast_from( + Vector::::cast_from(lhs).vec_and(Vector::::cast_from(rhs)), + ) + } +} + +#[cube] +impl BinaryOp for OrOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + Vector::cast_from(Vector::::cast_from(lhs).or(Vector::::cast_from(rhs))) + } +} + +#[cube] +impl BinaryOp for AssignOp { + fn execute(_lhs: Vector, rhs: Vector) -> Vector { + rhs + } +} + +#[cube] +impl BinaryOp for BinaryMinOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + clamp_max(lhs, rhs) + } +} + +#[cube] +impl BinaryOp for BinaryMaxOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + clamp_min(lhs, rhs) + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_scalar_binop( + input: LinearView<'_, Vector>, + scalar: InputScalar, + mut output: LinearViewMut<'_, Vector>, + #[define(C)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + O::BinaryOp::::execute(input.read(ABSOLUTE_POS), Vector::new(scalar.get::())), + ); +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_binop( + lhs: LinearView<'_, Vector>, + rhs: LinearView<'_, Vector>, + mut out: LinearViewMut<'_, Vector>, + #[define(C)] _dtype: StorageType, +) { + if !out.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + out.write( + ABSOLUTE_POS, + O::BinaryOp::::execute(lhs.read(ABSOLUTE_POS), rhs.read(ABSOLUTE_POS)), + ); +} + +pub(crate) fn launch_binop( + lhs: CubeTensor, + rhs: CubeTensor, +) -> CubeTensor { + let vector_size_lhs = max_vector_size(&lhs); + let vector_size_rhs = max_vector_size(&rhs); + let vector_size = Ord::min(vector_size_lhs, vector_size_rhs); + + let shape_out = broadcast_shape(&[&lhs, &rhs]); + let dtype = lhs.dtype; + + let client = lhs.client.clone(); + let num_elems = shape_out.num_elements(); + let working_units = num_elems / vector_size as usize; + + let cube_dim = CubeDim::new(&lhs.client, working_units); + let cube_count = calculate_cube_count_elemwise(&lhs.client, working_units, cube_dim); + + unsafe { + if lhs.can_mut_broadcast(&rhs) { + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.clone().into_linear_view(), + rhs.into_linear_view_like(&lhs), + lhs.as_linear_view_alias(0), + dtype_to_storage_type(dtype), + ); + + lhs + } else if rhs.can_mut_broadcast(&lhs) { + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.into_linear_view_like(&rhs), + rhs.clone().into_linear_view(), + rhs.as_linear_view_alias(1), + dtype_to_storage_type(dtype), + ); + + rhs + } else { + let output = + empty_device_dtype(lhs.client.clone(), lhs.device.clone(), shape_out, dtype); + + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs, output), + vector_size, + lhs.into_linear_view_like(&output), + rhs.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtype_to_storage_type(dtype), + ); + + output + } + } +} + +pub(crate) fn launch_scalar_binop( + tensor: CubeTensor, + scalar: InputScalar, +) -> CubeTensor { + // Vectorization is only enabled when the last dimension is contiguous. + let vector_size = max_vector_size(&tensor); + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + let dtype = tensor.dtype; + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + unsafe { + if tensor.can_mut() && tensor.is_nonoverlapping() { + kernel_scalar_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + scalar, + tensor.as_linear_view_alias(0), + dtype_to_storage_type(dtype), + ); + + tensor + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + dtype, + ); + + kernel_scalar_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + scalar, + output.clone().into_linear_view(), + dtype_to_storage_type(dtype), + ); + + output + } + } +} diff --git a/crates/burn-cubecl/src/kernel/binary_float.rs b/crates/burn-cubecl/src/kernel/binary_float.rs new file mode 100644 index 0000000..813d118 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/binary_float.rs @@ -0,0 +1,124 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_shape}, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait BinaryOpFloatFamily: Send + Sync + 'static { + type BinaryOp: BinaryOpFloat; +} + +#[cube] +pub(crate) trait BinaryOpFloat: 'static + Send + Sync { + /// Execute a binary operation. + fn execute(lhs: Vector, rhs: Vector) -> Vector; +} + +pub(crate) struct ArcTan2Op; + +impl BinaryOpFloatFamily for ArcTan2Op { + type BinaryOp = Self; +} + +#[cube] +impl BinaryOpFloat for ArcTan2Op { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + Vector::atan2(lhs, rhs) + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_binop( + lhs: LinearView<'_, Vector>, + rhs: LinearView<'_, Vector>, + mut out: LinearViewMut<'_, Vector>, + #[define(C)] _dtype: StorageType, +) { + if !out.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let res = O::BinaryOp::::execute(lhs.read(ABSOLUTE_POS), rhs.read(ABSOLUTE_POS)); + + out.write(ABSOLUTE_POS, res) +} + +pub(crate) fn launch_binop_float( + lhs: CubeTensor, + rhs: CubeTensor, +) -> CubeTensor { + let vector_size_lhs = max_vector_size(&lhs); + let vector_size_rhs = max_vector_size(&rhs); + let vector_size = Ord::min(vector_size_lhs, vector_size_rhs); + + let shape_out = broadcast_shape(&[&lhs, &rhs]); + let dtype = lhs.dtype; + + let client = lhs.client.clone(); + let num_elems = shape_out.num_elements(); + let working_units = num_elems / vector_size as usize; + + let cube_dim = CubeDim::new(&lhs.client, working_units); + let cube_count = calculate_cube_count_elemwise(&lhs.client, working_units, cube_dim); + + unsafe { + if lhs.can_mut_broadcast(&rhs) { + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.clone().into_linear_view(), + rhs.clone().into_linear_view_like(&lhs), + lhs.as_linear_view_alias(0), + dtype_to_storage_type(dtype), + ); + + lhs + } else if rhs.can_mut_broadcast(&lhs) { + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.into_linear_view_like(&rhs), + rhs.clone().into_linear_view(), + rhs.as_linear_view_alias(1), + dtype_to_storage_type(dtype), + ); + + rhs + } else { + let output = + empty_device_dtype(lhs.client.clone(), lhs.device.clone(), shape_out, dtype); + + kernel_binop::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs, output), + vector_size, + lhs.into_linear_view_like(&output), + rhs.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtype_to_storage_type(dtype), + ); + + output + } + } +} + +/// Calculate the four-quadrant inverse tangent of `lhs / rhs`. +pub fn atan2(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop_float::(lhs, rhs) +} diff --git a/crates/burn-cubecl/src/kernel/binary_int.rs b/crates/burn-cubecl/src/kernel/binary_int.rs new file mode 100644 index 0000000..2fed09e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/binary_int.rs @@ -0,0 +1,238 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_shape}, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait BinaryOpIntFamily: Send + Sync + 'static { + type BinaryOp: BinaryOpInt; +} + +#[cube] +pub(crate) trait BinaryOpInt: 'static + Send + Sync { + /// Execute a binary operation. + fn execute(lhs: Vector, rhs: Vector) -> Vector; +} + +pub(crate) struct BitwiseAndOp; +pub(crate) struct BitwiseOrOp; +pub(crate) struct BitwiseXorOp; +pub(crate) struct BitwiseShrOp; +pub(crate) struct BitwiseShlOp; + +impl BinaryOpIntFamily for BitwiseAndOp { + type BinaryOp = Self; +} + +impl BinaryOpIntFamily for BitwiseOrOp { + type BinaryOp = Self; +} + +impl BinaryOpIntFamily for BitwiseXorOp { + type BinaryOp = Self; +} + +impl BinaryOpIntFamily for BitwiseShrOp { + type BinaryOp = Self; +} + +impl BinaryOpIntFamily for BitwiseShlOp { + type BinaryOp = Self; +} + +#[cube] +impl BinaryOpInt for BitwiseAndOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs & rhs + } +} + +#[cube] +impl BinaryOpInt for BitwiseOrOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs | rhs + } +} + +#[cube] +impl BinaryOpInt for BitwiseXorOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs ^ rhs + } +} + +#[cube] +impl BinaryOpInt for BitwiseShrOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs >> rhs + } +} + +#[cube] +impl BinaryOpInt for BitwiseShlOp { + fn execute(lhs: Vector, rhs: Vector) -> Vector { + lhs << rhs + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_scalar_binop_int( + input: LinearView<'_, Vector>, + scalar: InputScalar, + mut output: LinearViewMut<'_, Vector>, + #[define(C)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + O::BinaryOp::::execute(input.read(ABSOLUTE_POS), Vector::new(scalar.get::())), + ); +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_binop_int( + lhs: LinearView<'_, Vector>, + rhs: LinearView<'_, Vector>, + mut out: LinearViewMut<'_, Vector>, + #[define(C)] _dtype: StorageType, +) { + if !out.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + out.write( + ABSOLUTE_POS, + O::BinaryOp::::execute(lhs.read(ABSOLUTE_POS), rhs.read(ABSOLUTE_POS)), + ); +} + +pub(crate) fn launch_binop_int( + lhs: CubeTensor, + rhs: CubeTensor, +) -> CubeTensor { + let vector_size_lhs = max_vector_size(&lhs); + let vector_size_rhs = max_vector_size(&rhs); + let vector_size = Ord::min(vector_size_lhs, vector_size_rhs); + + let shape_out = broadcast_shape(&[&lhs, &rhs]); + + let client = lhs.client.clone(); + let num_elems = shape_out.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&lhs.client, working_units); + let cube_count = calculate_cube_count_elemwise(&lhs.client, working_units, cube_dim); + let dtype = lhs.dtype; + + unsafe { + if lhs.can_mut_broadcast(&rhs) { + kernel_binop_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.clone().into_linear_view(), + rhs.into_linear_view_like(&lhs), + lhs.as_linear_view_alias(0), + dtype_to_storage_type(dtype), + ); + + lhs + } else if rhs.can_mut_broadcast(&lhs) { + kernel_binop_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.into_linear_view_like(&rhs), + rhs.clone().into_linear_view(), + rhs.as_linear_view_alias(1), + dtype_to_storage_type(dtype), + ); + + rhs + } else { + let output = + empty_device_dtype(lhs.client.clone(), lhs.device.clone(), shape_out, lhs.dtype); + + kernel_binop_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs, output), + vector_size, + lhs.into_linear_view_like(&output), + rhs.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtype_to_storage_type(dtype), + ); + + output + } + } +} + +pub(crate) fn launch_scalar_binop_int( + tensor: CubeTensor, + scalar: InputScalar, +) -> CubeTensor { + let vector_size = max_vector_size(&tensor); + let client = tensor.client.clone(); + let num_elems = tensor.meta.shape.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + unsafe { + if tensor.can_mut() && tensor.is_nonoverlapping() { + kernel_scalar_binop_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + scalar, + tensor.as_linear_view_alias(0), + dtype_to_storage_type(tensor.dtype), + ); + + tensor + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + tensor.dtype, + ); + + kernel_scalar_binop_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + scalar, + output.clone().into_linear_view(), + dtype_to_storage_type(output.dtype), + ); + + output + } + } +} diff --git a/crates/burn-cubecl/src/kernel/cast/base.rs b/crates/burn-cubecl/src/kernel/cast/base.rs new file mode 100644 index 0000000..3cb9176 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/cast/base.rs @@ -0,0 +1,74 @@ +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use cubecl::std::tensor::layout::linear::{LinearView, LinearViewMut}; +use cubecl::{calculate_cube_count_elemwise, prelude::*}; + +#[cube(launch, address_type = "dynamic")] +pub(crate) fn cast_element( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + #[define(I, O)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write(ABSOLUTE_POS, Vector::cast_from(input.read(ABSOLUTE_POS))); +} + +/// Cast a tensor to the given element type. +/// +/// Note: When input element is semantically a boolean, prefer bool_cast function. +pub fn cast(input: CubeTensor, dtype: DType) -> CubeTensor { + let dtype_output = match dtype { + DType::Flex32 => DType::F32, + _ => dtype, + }; + let dtype_input = match input.dtype { + DType::Flex32 => DType::F32, + _ => input.dtype, + }; + + if dtype_input == dtype_output { + return input; + } + + let client = input.client.clone(); + + let vector_size = max_vector_size(&input); + + let num_elems: usize = input.meta.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&client, working_units); + let cube_count = calculate_cube_count_elemwise(&client, working_units, cube_dim); + + let output = empty_device_dtype( + client.clone(), + input.device.clone(), + input.shape(), + dtype, // We take the same dtype as passed as input (Flex32 not F32) + ); + + cast_element::launch( + &client, + cube_count, + cube_dim, + address_type!(input, output), + vector_size, + input.into_linear_view(), + output.clone().into_linear_view(), + [ + dtype_to_storage_type(dtype_input), + dtype_to_storage_type(dtype_output), + ], + ); + + output +} diff --git a/crates/burn-cubecl/src/kernel/cast/bool_cast.rs b/crates/burn-cubecl/src/kernel/cast/bool_cast.rs new file mode 100644 index 0000000..6b2e000 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/cast/bool_cast.rs @@ -0,0 +1,72 @@ +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_std::DType; +use cubecl::{ + CubeDim, calculate_cube_count_elemwise, + num_traits::One, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn bool_cast_kernel( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + #[define(B, T)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + Vector::cast_from(input.read(ABSOLUTE_POS) & Vector::one()), + ); +} + +/// Cast a bool tensor to the given element type. +/// +/// This alternative to cast is necessary because bool are represented as u32 or u8 +/// where any non-zero value means true. Depending how it was created +/// it may hold an uncanny bit combination. Naively casting it would not +/// necessarily yield 0 or 1. +pub fn bool_cast(tensor: CubeTensor, out_dtype: DType) -> CubeTensor { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + out_dtype, + ); + + let vector_size = max_vector_size(&tensor); + let num_elems = tensor.meta.num_elements(); + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + let dtype = tensor.dtype; + + unsafe { + bool_cast_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + output.clone().into_linear_view(), + [ + dtype_to_storage_type(dtype), + dtype_to_storage_type(out_dtype), + ], + ) + }; + + output +} diff --git a/crates/burn-cubecl/src/kernel/cast/mod.rs b/crates/burn-cubecl/src/kernel/cast/mod.rs new file mode 100644 index 0000000..52d9d7b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/cast/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod bool_cast; + +pub use base::*; +pub use bool_cast::*; diff --git a/crates/burn-cubecl/src/kernel/clamp.rs b/crates/burn-cubecl/src/kernel/clamp.rs new file mode 100644 index 0000000..9a705e1 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/clamp.rs @@ -0,0 +1,41 @@ +use cubecl::prelude::*; + +use crate::{ + CubeRuntime, + kernel::{NumericUnaryOp, NumericUnaryOpFamily, launch_unary_numeric}, + tensor::CubeTensor, +}; + +#[derive(CubeLaunch, CubeType)] +struct Options { + min_value: InputScalar, + max_value: InputScalar, +} + +pub(crate) fn clamp( + input: CubeTensor, + min_value: InputScalar, + max_value: InputScalar, +) -> CubeTensor { + struct ClampOp; + + #[cube] + impl NumericUnaryOp for ClampOp { + type Options = Options; + + fn execute(input: Vector, options: &Self::Options) -> Vector { + cubecl::prelude::clamp( + input, + Vector::new(options.min_value.get::()), + Vector::new(options.max_value.get::()), + ) + } + } + + impl NumericUnaryOpFamily for ClampOp { + type Options = Options; + type Unary = Self; + } + + launch_unary_numeric::(input, |_| OptionsLaunch::new(min_value, max_value)) +} diff --git a/crates/burn-cubecl/src/kernel/comparison.rs b/crates/burn-cubecl/src/kernel/comparison.rs new file mode 100644 index 0000000..b839939 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/comparison.rs @@ -0,0 +1,465 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_shape}, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +#[cube] +pub(crate) trait ComparisonOpFamily: 'static + Send + Sync { + type Operation: ComparisonOp; +} + +#[cube] +pub(crate) trait ComparisonOp: 'static + Send + Sync { + /// Execute a comparison operation. + fn execute(lhs: Vector, rhs: Vector) -> bool; +} + +struct EqualOp; +struct GreaterEqualOp; +struct LowerEqualOp; +struct GreaterOp; +struct LowerOp; + +impl ComparisonOpFamily for EqualOp { + type Operation = Self; +} + +#[cube] +impl ComparisonOp for EqualOp { + fn execute(lhs: Vector, rhs: Vector) -> bool { + lhs == rhs + } +} + +impl ComparisonOpFamily for GreaterEqualOp { + type Operation = Self; +} + +#[cube] +impl ComparisonOp for GreaterEqualOp { + fn execute(lhs: Vector, rhs: Vector) -> bool { + lhs >= rhs + } +} + +impl ComparisonOpFamily for LowerEqualOp { + type Operation = Self; +} + +#[cube] +impl ComparisonOp for LowerEqualOp { + fn execute(lhs: Vector, rhs: Vector) -> bool { + lhs <= rhs + } +} + +impl ComparisonOpFamily for GreaterOp { + type Operation = Self; +} + +#[cube] +impl ComparisonOp for GreaterOp { + fn execute(lhs: Vector, rhs: Vector) -> bool { + lhs > rhs + } +} + +impl ComparisonOpFamily for LowerOp { + type Operation = Self; +} + +#[cube] +impl ComparisonOp for LowerOp { + fn execute(lhs: Vector, rhs: Vector) -> bool { + lhs < rhs + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_scalar_cmp( + input: LinearView<'_, Vector>, + scalar: InputScalar, + mut output: LinearViewMut<'_, Vector>, + #[define(T, Bool)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + Vector::cast_from(O::Operation::::execute( + input.read(ABSOLUTE_POS), + Vector::new(scalar.get::()), + )), + ); +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_cmp( + lhs: LinearView<'_, Vector>, + rhs: LinearView<'_, Vector>, + mut out: LinearViewMut<'_, Vector>, + #[define(T, Bool)] _dtype: [StorageType; 2], +) { + if !out.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + out.write( + ABSOLUTE_POS, + Vector::cast_from(O::Operation::::execute( + lhs.read(ABSOLUTE_POS), + rhs.read(ABSOLUTE_POS), + )), + ); +} + +pub(crate) fn launch_cmp( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + let vector_size_lhs = max_vector_size(&lhs); + let vector_size_rhs = max_vector_size(&rhs); + + let vector_size = Ord::min(vector_size_lhs, vector_size_rhs); + + let shape_out = broadcast_shape(&[&lhs, &rhs]); + let client = lhs.client.clone(); + let num_elems = shape_out.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&lhs.client, working_units); + let cube_count = calculate_cube_count_elemwise(&lhs.client, working_units, cube_dim); + + let dtypes = [ + dtype_to_storage_type(lhs.dtype), + dtype_to_storage_type(dtype_bool), + ]; + let same_tensor_type = dtypes[0] == dtypes[1]; + if same_tensor_type && lhs.can_mut_broadcast(&rhs) { + unsafe { + kernel_cmp::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.clone().into_linear_view(), + rhs.into_linear_view_like(&lhs), + lhs.as_linear_view_alias(0), + dtypes, + ); + } + + CubeTensor::new( + lhs.client.clone(), + lhs.handle.clone(), + *lhs.meta.clone(), + lhs.device.clone(), + dtype_bool, + ) + } else if same_tensor_type && rhs.can_mut_broadcast(&lhs) { + unsafe { + kernel_cmp::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs), + vector_size, + lhs.into_linear_view_like(&rhs), + rhs.clone().into_linear_view(), + rhs.as_linear_view_alias(1), + dtypes, + ); + }; + + CubeTensor::new( + rhs.client.clone(), + rhs.handle.clone(), + *rhs.meta.clone(), + rhs.device.clone(), + dtype_bool, + ) + } else { + let output = empty_device_dtype( + lhs.client.clone(), + lhs.device.clone(), + shape_out, + dtype_bool, + ); + + unsafe { + kernel_cmp::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(lhs, rhs, output), + vector_size, + lhs.into_linear_view_like(&output), + rhs.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtypes, + ); + }; + + output + } +} + +pub(crate) fn launch_scalar_cmp( + tensor: CubeTensor, + scalar: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + let vector_size = max_vector_size(&tensor); + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + let dtypes = [ + dtype_to_storage_type(tensor.dtype), + dtype_to_storage_type(dtype_bool), + ]; + let same_tensor_type = dtypes[0] == dtypes[1]; + + if same_tensor_type && tensor.can_mut() && tensor.is_nonoverlapping() { + unsafe { + kernel_scalar_cmp::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + scalar, + tensor.as_linear_view_alias(0), + dtypes, + ); + } + + CubeTensor::new( + tensor.client.clone(), + tensor.handle.clone(), + *tensor.meta.clone(), + tensor.device.clone(), + dtype_bool, + ) + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + dtype_bool, + ); + + unsafe { + kernel_scalar_cmp::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + scalar, + output.clone().into_linear_view(), + dtypes, + ); + } + + output + } +} + +pub fn equal( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + launch_cmp::(lhs, rhs, dtype_bool) +} + +pub fn greater( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + launch_cmp::(lhs, rhs, dtype_bool) +} + +pub fn greater_equal( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + launch_cmp::(lhs, rhs, dtype_bool) +} + +pub fn lower( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + launch_cmp::(lhs, rhs, dtype_bool) +} + +pub fn lower_equal( + lhs: CubeTensor, + rhs: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + launch_cmp::(lhs, rhs, dtype_bool) +} + +pub fn equal_elem( + lhs: CubeTensor, + rhs: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + launch_scalar_cmp::(lhs, rhs, dtype_bool) +} + +pub fn greater_elem( + lhs: CubeTensor, + rhs: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + launch_scalar_cmp::(lhs, rhs, dtype_bool) +} + +pub fn lower_elem( + lhs: CubeTensor, + rhs: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + launch_scalar_cmp::(lhs, rhs, dtype_bool) +} + +pub fn greater_equal_elem( + lhs: CubeTensor, + rhs: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + launch_scalar_cmp::(lhs, rhs, dtype_bool) +} + +pub fn lower_equal_elem( + lhs: CubeTensor, + rhs: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + launch_scalar_cmp::(lhs, rhs, dtype_bool) +} + +// Unary comparison / predicate / relational ops + +#[cube] +pub(crate) trait PredicateOp: 'static + Send + Sync { + /// Execute a predicate operation. + fn execute(input: Vector) -> Vector; +} + +pub(crate) trait PredicateOpFamily: 'static + Send + Sync { + type Operation: PredicateOp; +} + +struct IsNanOp; +struct IsInfOp; + +impl PredicateOpFamily for IsNanOp { + type Operation = Self; +} + +#[cube] +impl PredicateOp for IsNanOp { + fn execute(input: Vector) -> Vector { + Vector::is_nan(input) + } +} + +impl PredicateOpFamily for IsInfOp { + type Operation = Self; +} +#[cube] +impl PredicateOp for IsInfOp { + fn execute(input: Vector) -> Vector { + Vector::is_inf(input) + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn kernel_predicate( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + #[define(F, Bool)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + Vector::cast_from(O::Operation::::execute(input.read(ABSOLUTE_POS))), + ); +} + +pub(crate) fn launch_predicate( + tensor: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + let vector_size = max_vector_size(&tensor); + + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + + let dtypes = [ + dtype_to_storage_type(tensor.dtype), + dtype_to_storage_type(dtype_bool), + ]; + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + dtype_bool, + ); + + unsafe { + kernel_predicate::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtypes, + ); + } + + output +} + +pub fn is_nan(tensor: CubeTensor, dtype_bool: DType) -> CubeTensor { + launch_predicate::(tensor, dtype_bool) +} + +pub fn is_inf(tensor: CubeTensor, dtype_bool: DType) -> CubeTensor { + launch_predicate::(tensor, dtype_bool) +} diff --git a/crates/burn-cubecl/src/kernel/contiguous.rs b/crates/burn-cubecl/src/kernel/contiguous.rs new file mode 100644 index 0000000..4b90738 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/contiguous.rs @@ -0,0 +1,125 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use cubecl::quant::scheme::{QuantStore, QuantValue}; +use cubecl::server::MemoryLayoutStrategy; + +use crate::{CubeRuntime, ops::empty_qtensor, tensor::CubeTensor}; + +/// Make a jit tensor contiguous. +pub fn into_contiguous(tensor: CubeTensor) -> CubeTensor { + if tensor.is_contiguous() { + return tensor; + } + + if tensor.qparams.is_some() { + return into_contiguous_quantized(tensor, MemoryLayoutStrategy::Contiguous); + } + + let (client, device, dtype) = (tensor.client.clone(), tensor.device.clone(), tensor.dtype); + + let output = cubecl::std::tensor::into_contiguous( + &client, + tensor.binding(), + dtype_to_storage_type(dtype), + ); + + CubeTensor::new( + client.clone(), + output.handle, + *output.metadata, + device, + dtype, + ) +} + +/// Make a jit tensor contiguous with an aligned last stride. Tensor is considered already contiguous +/// if runtime can read it as is. This is equivalent in practice. +#[cfg_attr( + feature = "tracing", + tracing::instrument(level = "trace", skip(tensor)) +)] +pub fn into_contiguous_aligned(tensor: CubeTensor) -> CubeTensor { + if R::can_read_tensor(tensor.meta.shape(), tensor.meta.strides()) { + return tensor; + } + + if tensor.qparams.is_some() { + return into_contiguous_quantized(tensor, MemoryLayoutStrategy::Optimized); + } + + let (client, device, dtype) = (tensor.client.clone(), tensor.device.clone(), tensor.dtype); + + let output = cubecl::std::tensor::into_contiguous_pitched( + &client, + tensor.binding(), + dtype_to_storage_type(dtype), + ); + + CubeTensor::new( + client.clone(), + output.handle, + *output.metadata, + device, + dtype, + ) +} + +#[cfg_attr( + feature = "tracing", + tracing::instrument(level = "trace", skip(tensor)) +)] +fn into_contiguous_quantized( + tensor: CubeTensor, + strategy: MemoryLayoutStrategy, +) -> CubeTensor { + let scheme = tensor.scheme(); + let output = empty_qtensor(tensor.shape(), tensor.scheme(), &tensor.device, strategy); + let (values, scales) = tensor.quantized_handles().unwrap(); + let (out_values, out_scales) = output.quantized_handles().unwrap(); + + let (client, dtype_scales, dtype_value) = (scales.client.clone(), scales.dtype, values.dtype); + + match scheme.store { + QuantStore::PackedU32(packed_dim) => { + cubecl::std::tensor::into_contiguous_packed_ref( + &client, + values.binding(), + out_values.binding(), + packed_dim, + tensor.meta.shape(), + scheme.num_quants(), + dtype_to_storage_type(DType::U32), + ); + } + // e2m1 is special because it has a native packed representation, `e2m1x2`. + // It's internally stored as `u8` with a packing factor of 2. + QuantStore::PackedNative(packed_dim) if scheme.value == QuantValue::E2M1 => { + cubecl::std::tensor::into_contiguous_packed_ref( + &client, + values.binding(), + out_values.binding(), + packed_dim, + tensor.meta.shape(), + scheme.num_quants(), + dtype_to_storage_type(DType::U8), + ); + } + _ => { + cubecl::std::tensor::copy_into( + &client, + values.binding(), + out_values.binding(), + dtype_to_storage_type(dtype_value), + ); + } + } + + cubecl::std::tensor::copy_into( + &client, + scales.binding(), + out_scales.binding(), + dtype_to_storage_type(dtype_scales), + ); + + output +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_data/fallback.rs b/crates/burn-cubecl/src/kernel/conv/backward_data/fallback.rs new file mode 100644 index 0000000..698bd5f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_data/fallback.rs @@ -0,0 +1,124 @@ +use burn_backend::{ + TensorMetadata, + ops::{ConvOptions, ConvTransposeOptions, conv::calculate_padding_out}, +}; +use burn_std::Shape; +use cubek::convolution::components::ConvSetupError; + +use crate::{ + CubeRuntime, + kernel::conv::{conv_transpose2d, conv_transpose3d}, + ops::{permute_nchw_to_nhwc, permute_nhwc_to_nchw, reshape}, + tensor::CubeTensor, +}; + +pub(crate) fn conv_data_backward_fallback( + out_grad: CubeTensor, + weights: CubeTensor, + in_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + let dim_c = out_grad.rank(); + + let kernel_size = &weights.meta.shape()[1..dim_c]; + let in_shape = &in_shape[1..dim_c]; + let out_shape = &out_grad.meta.shape()[1..dim_c]; + + let mut padding_out = [0; N_DIM]; + + for i in 0..N_DIM { + padding_out[i] = calculate_padding_out( + kernel_size[i], + options.stride[i], + options.padding[i], + options.dilation[i], + in_shape[i], + out_shape[i], + ); + } + + // We don't yet have NHWC kernels for conv_transpose so need to do this. + // Should eventually use NHWC kernels instead + let out_grad = permute_nhwc_to_nchw(out_grad); + let weights = permute_nhwc_to_nchw(weights); + + let in_grad = match N_DIM { + 1 => conv_transpose1d_from_conv_transpose2d( + out_grad, + weights, + ConvTransposeOptions::new( + [options.stride[0]], + [options.padding[0]], + [padding_out[0]], + [options.dilation[0]], + options.groups, + ), + ), + 2 => conv_transpose2d( + out_grad, + weights, + None, + ConvTransposeOptions::new( + [options.stride[0], options.stride[1]], + [options.padding[0], options.padding[1]], + [padding_out[0], padding_out[1]], + [options.dilation[0], options.dilation[1]], + options.groups, + ), + Default::default(), + ), + 3 => Ok(conv_transpose3d( + out_grad, + weights, + None, + ConvTransposeOptions::new( + [options.stride[0], options.stride[1], options.stride[2]], + [options.padding[0], options.padding[1], options.padding[2]], + [padding_out[0], padding_out[1], padding_out[2]], + [ + options.dilation[0], + options.dilation[1], + options.dilation[2], + ], + options.groups, + ), + ) + .unwrap()), + _ => unimplemented!("Invalid dimensionality"), + }?; + Ok(permute_nchw_to_nhwc(in_grad)) +} + +fn conv_transpose1d_from_conv_transpose2d( + x: CubeTensor, + weight: CubeTensor, + options: ConvTransposeOptions<1>, +) -> Result, ConvSetupError> { + let [channels_in, channels_out, kernel_size] = weight.shape().dims(); + let [batch_size, _channels_in, length_in] = x.shape().dims(); + + let weight = reshape( + weight, + Shape::new([channels_in, channels_out, kernel_size, 1]), + ); + let x = reshape(x, Shape::new([batch_size, channels_in, length_in, 1])); + + let tensor = conv_transpose2d( + x, + weight, + None, + ConvTransposeOptions::new( + [options.stride[0], 1], + [options.padding[0], 0], + [options.padding_out[0], 0], + [options.dilation[0], 1], + options.groups, + ), + Default::default(), + )?; + let [batch_size, channels_out, height_out, _weight_out] = tensor.shape().dims(); + Ok(reshape( + tensor, + Shape::from([batch_size, channels_out, height_out]), + )) +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/launch.rs b/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/launch.rs new file mode 100644 index 0000000..a11c5d8 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/launch.rs @@ -0,0 +1,135 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::ConvOptions; +use burn_std::Shape; +use cubek::{ + convolution::{ + AcceleratedTileKind, ConvAlgorithm, ConvolutionArgs, ConvolutionInputs, Strategy, + components::ConvSetupError, launch_ref, + }, + matmul::definition::{MatmulElems, MatmulGlobalElems}, + std::InputBinding, +}; + +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; + +pub fn dgrad_gemm_simple_sync( + out_grad: CubeTensor, + weights: CubeTensor, + input_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleSyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleSyncStrided, + }; + launch_backwards_data::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + out_grad, + weights, + input_shape, + options, + ) +} + +pub fn dgrad_gemm_simple_async( + out_grad: CubeTensor, + weights: CubeTensor, + input_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleAsyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleAsyncStrided, + }; + launch_backwards_data::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + out_grad, + weights, + input_shape, + options, + ) +} + +pub fn dgrad_gemm_simple_tma( + out_grad: CubeTensor, + weights: CubeTensor, + input_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + launch_backwards_data::( + &Strategy::Inferred { + algorithm: ConvAlgorithm::SimpleAsyncTma, + tile_kind, + }, + out_grad, + weights, + input_shape, + options, + ) +} + +/// Perform a convolution backwards data pass using the implicit GEMM (im2col) algorithm, using +/// cubecl tiling matmul components. +/// +/// * `input` - The input feature map +/// * `out_grad` - The output gradients +/// * `weight_shape` - The shape of the weights/weight gradients +/// * `options` - The options to use for the convolution +pub fn launch_backwards_data( + strategy: &Strategy, + out_grad: CubeTensor, + weights: CubeTensor, + input_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + if options.groups != 1 || options.stride.iter().any(|&s| s != 1) { + return Err(ConvSetupError::Groups(options.groups)); + } + + let out_dtype = out_grad.dtype; + + let in_grad = empty_device_dtype( + out_grad.client.clone(), + out_grad.device.clone(), + input_shape, + out_dtype, + ); + + let client = out_grad.client.clone(); + let dtypes = MatmulElems::from_globals(&MatmulGlobalElems { + lhs: dtype_to_storage_type(out_grad.dtype), + rhs: dtype_to_storage_type(weights.dtype), + out: dtype_to_storage_type(out_dtype), + }); + let out_grad_dtype = out_grad.dtype; + let weights_dtype = weights.dtype; + let out_grad = InputBinding::new(out_grad.binding(), dtype_to_storage_type(out_grad_dtype)); + let weights = InputBinding::new(weights.binding(), dtype_to_storage_type(weights_dtype)); + + launch_ref::( + strategy, + &client, + ConvolutionInputs::BackwardData { + out_grad, + weights, + in_grad: in_grad.clone().binding(), + }, + ConvolutionArgs { + stride: options.stride, + padding: options.padding, + dilation: options.dilation, + }, + dtypes, + )?; + + Ok(in_grad) +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/mod.rs b/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/mod.rs new file mode 100644 index 0000000..0df8c51 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_data/implicit_gemm/mod.rs @@ -0,0 +1,2 @@ +pub mod launch; +pub use launch::*; diff --git a/crates/burn-cubecl/src/kernel/conv/backward_data/mod.rs b/crates/burn-cubecl/src/kernel/conv/backward_data/mod.rs new file mode 100644 index 0000000..80e94c4 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_data/mod.rs @@ -0,0 +1,8 @@ +pub mod fallback; +pub mod implicit_gemm; + +#[cfg(feature = "autotune")] +pub mod tune; + +#[cfg(feature = "autotune")] +pub(crate) use tune::*; diff --git a/crates/burn-cubecl/src/kernel/conv/backward_data/tune.rs b/crates/burn-cubecl/src/kernel/conv/backward_data/tune.rs new file mode 100644 index 0000000..d626d8f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_data/tune.rs @@ -0,0 +1,182 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::ConvOptions; +use burn_std::Shape; +use cubecl::{ + ir::StorageType, + tune::{LocalTuner, Tunable, TunableSet, anchor, local_tuner}, +}; +use cubek::convolution::AcceleratedTileKind; + +use crate::{ + CubeAutotuneKey, CubeRuntime, CubeTuneId, + kernel::conv::{ + ConvAutotuneKey, + backward_data::{fallback::conv_data_backward_fallback, implicit_gemm::*}, + }, + tensor::CubeTensor, +}; + +/// Executes autotune on conv2d operations +pub fn dgrad_autotune( + out_grad: CubeTensor, + weights: CubeTensor, + input_shape: Shape, + options: ConvOptions, +) -> CubeTensor { + let client = out_grad.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + // Note: TMA isn't currently implemented properly, and will always error. + // It's kept here so it gets automatically enabled as soon as cubek updates. + // No CMMA for TMA because swizzling will be mandatory for good performance on dgrad. + let tunables = TUNER.init(|| { + TunableSet::new(create_key::, create_wgrad_input::) + .with(Tunable::new( + "wgrad_fallback", + |(out_grad, weights, input_shape, options)| { + conv_data_backward_fallback::(out_grad, weights, input_shape, options) + }, + )) + .with(Tunable::new( + "simple_sync_cmma", + |(input, grad, shape, options)| { + dgrad_gemm_simple_sync(input, grad, shape, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_sync_mma", + |(input, grad, shape, options)| { + dgrad_gemm_simple_sync(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_async_cmma", + |(input, grad, shape, options)| { + dgrad_gemm_simple_async(input, grad, shape, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_async_mma", + |(input, grad, shape, options)| { + dgrad_gemm_simple_async(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_tma_mma", + |(input, grad, shape, options)| { + dgrad_gemm_simple_tma(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + }); + + TUNER.execute( + &CubeTuneId::new(&out_grad.client, &out_grad.device), + &client, + tunables, + (out_grad, weights, input_shape, options), + ) +} + +pub fn create_wgrad_input( + _key: &CubeAutotuneKey, + (out_grad, weights, input_shape, options): &( + CubeTensor, + CubeTensor, + Shape, + ConvOptions, + ), +) -> (CubeTensor, CubeTensor, Shape, ConvOptions) { + ( + out_grad.clone(), + weights.clone(), + input_shape.clone(), + options.clone(), + ) +} + +fn create_key( + (out_grad, weights, input_shape, options): &( + CubeTensor, + CubeTensor, + Shape, + ConvOptions, + ), +) -> CubeAutotuneKey { + let dtype = out_grad.dtype; + let rank = out_grad.meta.num_dims(); + let dim_c = rank - 1; + + let batch_size = out_grad.meta.shape()[0]; + let in_channels = input_shape[dim_c]; + let out_channels = out_grad.meta.shape()[dim_c]; + + let kernel_size = weights.meta.shape()[1..dim_c].to_vec(); + let in_shape = input_shape[1..dim_c] + .iter() + .map(|shape| anchor(*shape, None, None, None)) + .collect(); + + let ConvOptions { + stride, + padding, + dilation, + groups, + } = options.clone(); + + let lhs_stride_align = if out_grad.meta.strides()[dim_c] == 1 { + stride_align( + out_grad.meta.strides(), + dtype_to_storage_type(out_grad.dtype), + ) + } else { + 0 + }; + let lhs_shape_align = pow2_factor(out_channels).min(lhs_stride_align); + let rhs_stride_align = if weights.meta.strides()[dim_c] == 1 { + stride_align(weights.meta.strides(), dtype_to_storage_type(weights.dtype)) + } else { + 0 + }; + let rhs_shape_align = pow2_factor(in_channels).min(rhs_stride_align); + + CubeAutotuneKey::Conv(ConvAutotuneKey::new( + kernel_size, + stride.to_vec(), + padding.to_vec(), + dilation.to_vec(), + groups, + in_channels, + out_channels, + in_shape, + batch_size, + false, + dtype, + lhs_shape_align, + lhs_stride_align, + rhs_shape_align, + rhs_stride_align, + )) +} + +/// Maximum factor relevant for strides. Currently set to 2^10 because that's 128-byte swizzle's +/// repeat number, so it's the largest align that can have performance impacts. +const MAX_STRIDE_FACTOR: u32 = 10; + +/// Defines the non-contiguous stride alignment in terms of powers of two +fn stride_align(strides: &[usize], elem: StorageType) -> u8 { + let max = MAX_STRIDE_FACTOR; + let dim_c = strides.len() - 1; + let factor = strides[..dim_c] + .iter() + .map(|it| (*it * elem.size_bits()) / 8) + .map(|it| it.trailing_zeros()) + .min() + .unwrap_or(max); + factor.min(max) as u8 +} + +/// Defines the potential vectorization. +fn pow2_factor(axis: usize) -> u8 { + axis.trailing_zeros().min(4) as u8 +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_weight/fallback.rs b/crates/burn-cubecl/src/kernel/conv/backward_weight/fallback.rs new file mode 100644 index 0000000..6ddd9f2 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_weight/fallback.rs @@ -0,0 +1,112 @@ +use burn_backend::{TensorMetadata, ops::ConvOptions}; +use burn_std::{Shape, Slice}; +use cubek::convolution::components::ConvSetupError; + +use crate::{ + CubeRuntime, + kernel::{conv::base::conv_forward_nhwc, slice, slice_assign}, + ops::{numeric::empty_device_dtype, swap_dims}, + tensor::CubeTensor, +}; + +/// Calculate the convolution backward pass with regard to the weight gradients. +pub fn conv_weight_backward_fallback( + input: CubeTensor, + output_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + match options.groups == 1 { + true => conv_weight_grad_no_groups::(input, output_grad, weight_shape, options), + false => conv_weight_grad_groups::(input, output_grad, weight_shape, options), + } +} + +fn conv_weight_grad_no_groups( + input: CubeTensor, + output_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + let dim_c = input.rank() - 1; + + let input_swapped = swap_dims(input, 0, dim_c); + let out_grad_swapped = swap_dims(output_grad, 0, dim_c); + let weight_grad_swapped = conv_forward_nhwc( + input_swapped, + out_grad_swapped, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + Default::default(), + )?; + let mut weight_grad = swap_dims(weight_grad_swapped, 0, dim_c); + if weight_grad.shape() != weight_shape { + let ranges = weight_shape.iter().map(|&s| 0..s).collect::>(); + weight_grad = slice(weight_grad, &ranges); + } + + Ok(weight_grad) +} + +#[allow(clippy::single_range_in_vec_init, reason = "False positive")] +fn conv_weight_grad_groups( + input: CubeTensor, + output_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + let mut weight_grad = empty_device_dtype( + input.client.clone(), + input.device.clone(), + weight_shape.clone(), + input.dtype, + ); + + let dim_c = input.rank() - 1; + + let channels_out = weight_shape[0]; + let increment_co = channels_out / options.groups; + + let input_swapped = swap_dims(input, 0, dim_c); + let output_grad_swapped = swap_dims(output_grad, 0, dim_c); + + let kernel_size = &weight_shape[1..dim_c]; + let kernel_size_slice = kernel_size.iter().map(|&s| 0..s).collect::>(); + let increment_ci = weight_grad.meta.shape()[dim_c]; + + for g in 0..options.groups { + let start_idx_ci = g * increment_ci; + let end_idx_ci = (g + 1) * increment_ci; + let start_idx_co = g * increment_co; + let end_idx_co = (g + 1) * increment_co; + + let input = slice(input_swapped.clone(), &[start_idx_ci..end_idx_ci]); + let grad = slice(output_grad_swapped.clone(), &[start_idx_co..end_idx_co]); + + let weight_grad_tmp = conv_forward_nhwc( + input, + grad, + None, + ConvOptions::new(options.dilation, options.padding, options.stride, 1), + Default::default(), + )?; + let mut weight_grad_tmp = swap_dims(weight_grad_tmp, 0, dim_c); + let kernel_size_tmp = &weight_grad_tmp.meta.shape()[1..dim_c]; + + if kernel_size != kernel_size_tmp { + let mut slices = vec![0..increment_co]; + slices.extend(kernel_size_slice.clone()); + slices.push(0..increment_ci); + weight_grad_tmp = slice(weight_grad_tmp, &slices); + } + + let mut slices = vec![start_idx_co..end_idx_co]; + slices.extend(kernel_size_slice.clone()); + slices.push(0..increment_ci); + let slices = slices.into_iter().map(Slice::from).collect::>(); + + weight_grad = slice_assign(weight_grad, &slices, weight_grad_tmp); + } + + Ok(weight_grad) +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/launch.rs b/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/launch.rs new file mode 100644 index 0000000..af9215e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/launch.rs @@ -0,0 +1,135 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::ConvOptions; +use burn_std::Shape; +use cubek::{ + convolution::{ + AcceleratedTileKind, ConvAlgorithm, ConvolutionArgs, ConvolutionInputs, Strategy, + components::ConvSetupError, launch_ref, + }, + matmul::definition::{MatmulElems, MatmulGlobalElems}, + std::InputBinding, +}; + +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; + +pub(crate) fn wgrad_gemm_simple_sync( + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleSyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleSyncStrided, + }; + launch_backwards_weight::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + input, + out_grad, + weight_shape, + options, + ) +} + +pub(crate) fn wgrad_gemm_simple_async( + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleAsyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleAsyncStrided, + }; + launch_backwards_weight::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + input, + out_grad, + weight_shape, + options, + ) +} + +pub(crate) fn wgrad_gemm_simple_tma( + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + launch_backwards_weight::( + &Strategy::Inferred { + algorithm: ConvAlgorithm::SimpleAsyncTma, + tile_kind, + }, + input, + out_grad, + weight_shape, + options, + ) +} + +/// Perform a convolution backwards weight pass using the implicit GEMM (im2col) algorithm, using +/// cubecl tiling matmul components. +/// +/// * `input` - The input feature map +/// * `out_grad` - The output gradients +/// * `weight_shape` - The shape of the weights/weight gradients +/// * `options` - The options to use for the convolution +pub fn launch_backwards_weight( + strategy: &Strategy, + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, +) -> Result, ConvSetupError> { + if options.groups != 1 { + return Err(ConvSetupError::Groups(options.groups)); + } + + let out_dtype = out_grad.dtype; + + let weight_grad = empty_device_dtype( + input.client.clone(), + input.device.clone(), + weight_shape, + out_dtype, + ); + + let client = input.client.clone(); + let dtypes = MatmulElems::from_globals(&MatmulGlobalElems { + lhs: dtype_to_storage_type(input.dtype), + rhs: dtype_to_storage_type(out_grad.dtype), + out: dtype_to_storage_type(out_dtype), + }); + let input_dtype = input.dtype; + let out_grad_dtype = out_grad.dtype; + let input = InputBinding::new(input.binding(), dtype_to_storage_type(input_dtype)); + let out_grad = InputBinding::new(out_grad.binding(), dtype_to_storage_type(out_grad_dtype)); + + launch_ref::( + strategy, + &client, + ConvolutionInputs::BackwardWeight { + input, + out_grad, + weight_grad: weight_grad.clone().binding(), + }, + ConvolutionArgs { + stride: options.stride, + padding: options.padding, + dilation: options.dilation, + }, + dtypes, + )?; + + Ok(weight_grad) +} diff --git a/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/mod.rs b/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/mod.rs new file mode 100644 index 0000000..0df8c51 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_weight/implicit_gemm/mod.rs @@ -0,0 +1,2 @@ +pub mod launch; +pub use launch::*; diff --git a/crates/burn-cubecl/src/kernel/conv/backward_weight/mod.rs b/crates/burn-cubecl/src/kernel/conv/backward_weight/mod.rs new file mode 100644 index 0000000..80e94c4 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_weight/mod.rs @@ -0,0 +1,8 @@ +pub mod fallback; +pub mod implicit_gemm; + +#[cfg(feature = "autotune")] +pub mod tune; + +#[cfg(feature = "autotune")] +pub(crate) use tune::*; diff --git a/crates/burn-cubecl/src/kernel/conv/backward_weight/tune.rs b/crates/burn-cubecl/src/kernel/conv/backward_weight/tune.rs new file mode 100644 index 0000000..f957e1d --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/backward_weight/tune.rs @@ -0,0 +1,185 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::ConvOptions; +use burn_std::Shape; +use cubecl::{ + ir::StorageType, + tune::{LocalTuner, Tunable, TunableSet, anchor, local_tuner}, +}; +use cubek::convolution::AcceleratedTileKind; + +use crate::{ + CubeAutotuneKey, CubeRuntime, CubeTuneId, + kernel::conv::{ + ConvAutotuneKey, + backward_weight::{fallback::conv_weight_backward_fallback, implicit_gemm::*}, + }, + tensor::CubeTensor, +}; + +/// Executes autotune on the weight gradients pass for convolution +pub fn wgrad_autotune( + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, +) -> CubeTensor { + let client = input.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + TunableSet::new(create_key::, create_wgrad_input::) + .with(Tunable::new( + "wgrad_fallback", + |(input, grad, shape, options)| { + conv_weight_backward_fallback::(input, grad, shape, options) + }, + )) + .with(Tunable::new( + "simple_sync_cmma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_sync(input, grad, shape, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_sync_mma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_sync(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_async_cmma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_async(input, grad, shape, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_async_mma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_async(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_tma_cmma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_tma(input, grad, shape, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_tma_mma", + |(input, grad, shape, options)| { + wgrad_gemm_simple_tma(input, grad, shape, options, AcceleratedTileKind::Mma) + }, + )) + }); + + TUNER.execute( + &CubeTuneId::new(&input.client, &input.device), + &client, + tunables, + (input, out_grad, weight_shape, options), + ) +} + +pub fn create_wgrad_input( + _key: &CubeAutotuneKey, + (input, out_grad, weight_shape, options): &( + CubeTensor, + CubeTensor, + Shape, + ConvOptions, + ), +) -> (CubeTensor, CubeTensor, Shape, ConvOptions) { + ( + input.clone(), + out_grad.clone(), + weight_shape.clone(), + options.clone(), + ) +} + +fn create_key( + (input, out_grad, weight_shape, options): &( + CubeTensor, + CubeTensor, + Shape, + ConvOptions, + ), +) -> CubeAutotuneKey { + let dtype = input.dtype; + let rank = input.meta.num_dims(); + let dim_c = rank - 1; + + let batch_size = input.meta.shape()[0]; + let in_channels = input.meta.shape()[dim_c]; + let out_channels = weight_shape[0]; + + let kernel_size = weight_shape[1..dim_c].to_vec(); + let in_shape = input.meta.shape()[1..dim_c] + .iter() + .map(|shape| anchor(*shape, None, None, None)) + .collect(); + + let ConvOptions { + stride, + padding, + dilation, + groups, + } = options.clone(); + + let lhs_stride_align = if out_grad.meta.strides()[dim_c] == 1 { + stride_align( + out_grad.meta.strides(), + dtype_to_storage_type(out_grad.dtype), + ) + } else { + 0 + }; + let lhs_shape_align = pow2_factor(out_channels).min(lhs_stride_align); + let rhs_stride_align = if input.meta.strides()[dim_c] == 1 { + stride_align(input.meta.strides(), dtype_to_storage_type(input.dtype)) + } else { + 0 + }; + let rhs_shape_align = pow2_factor(in_channels).min(rhs_stride_align); + + CubeAutotuneKey::Conv(ConvAutotuneKey::new( + kernel_size, + stride.to_vec(), + padding.to_vec(), + dilation.to_vec(), + groups, + in_channels, + out_channels, + in_shape, + batch_size, + false, + dtype, + lhs_shape_align, + lhs_stride_align, + rhs_shape_align, + rhs_stride_align, + )) +} + +/// Maximum factor relevant for strides. Currently set to 2^10 because that's 128-byte swizzle's +/// repeat number, so it's the largest align that can have performance impacts. +const MAX_STRIDE_FACTOR: u32 = 10; + +/// Defines the non-contiguous stride alignment in terms of powers of two +fn stride_align(strides: &[usize], elem: StorageType) -> u8 { + let max = MAX_STRIDE_FACTOR; + let dim_c = strides.len() - 1; + let factor = strides[..dim_c] + .iter() + .map(|it| (*it * elem.size_bits()) / 8) + .map(|it| it.trailing_zeros()) + .min() + .unwrap_or(max); + factor.min(max) as u8 +} + +/// Defines the potential vectorization. +fn pow2_factor(axis: usize) -> u8 { + axis.trailing_zeros().min(4) as u8 +} diff --git a/crates/burn-cubecl/src/kernel/conv/base.rs b/crates/burn-cubecl/src/kernel/conv/base.rs new file mode 100644 index 0000000..7d81082 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/base.rs @@ -0,0 +1,189 @@ +use burn_backend::ops::ConvOptions; +use burn_std::Shape; +use cubek::convolution::{AcceleratedTileKind, components::ConvSetupError}; + +#[cfg(feature = "autotune")] +use crate::kernel::conv::{backward_weight::wgrad_autotune, dgrad_autotune}; +use crate::{ + CubeRuntime, + kernel::conv::{ + backward_data::{fallback::conv_data_backward_fallback, implicit_gemm::*}, + backward_weight::{fallback::conv_weight_backward_fallback, implicit_gemm::*}, + forward::implicit_gemm::conv_gemm_simple_sync, + }, + ops::{permute_nchw_to_nhwc, permute_nchw_to_nhwc_shape, permute_nhwc_to_nchw}, + tensor::CubeTensor, +}; + +use super::conv_direct; +#[cfg(feature = "autotune")] +use super::forward::conv_autotune; + +/// The strategy to be used when launching a convolution kernel. +pub enum ConvStrategy { + /// A simple direct convolution. + Direct, + #[cfg(feature = "autotune")] + /// Using autotune to choose the best kernel based on runtime information. + Autotune, + /// Implicit GEMM implementation of convolution. Lower memory usage but requires CMMA and + /// has constraints on tensor shape. + ImplicitGemm, +} + +impl Default for ConvStrategy { + fn default() -> Self { + // if autotune is enabled, default to autotune + #[cfg(feature = "autotune")] + return ConvStrategy::Autotune; + + // if autotune is disabled, default to the more memory-conservative algorithm + #[cfg(not(feature = "autotune"))] + ConvStrategy::Direct + } +} + +/// Performs an N-dimensional convolution with the given strategy +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +/// * `strategy` - The convolution algorithm to use. Autotune will pick the fastest available option. +pub fn conv_forward( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, + strategy: ConvStrategy, +) -> Result, ConvSetupError> { + let input = permute_nchw_to_nhwc(input); + let weight = permute_nchw_to_nhwc(weight); + + let out = conv_forward_nhwc(input, weight, bias, options, strategy)?; + + Ok(permute_nhwc_to_nchw(out)) +} + +/// Performs an N-dimensional convolution with the given strategy on NHWC inputs/outputs +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +/// * `strategy` - The convolution algorithm to use. Autotune will pick the fastest available option. +pub fn conv_forward_nhwc( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, + strategy: ConvStrategy, +) -> Result, ConvSetupError> { + match strategy { + ConvStrategy::Direct => conv_direct::(input, weight, bias, options), + #[cfg(feature = "autotune")] + ConvStrategy::Autotune => Ok(conv_autotune::(input, weight, bias, options)), + ConvStrategy::ImplicitGemm => { + if options.groups != 1 { + conv_direct::(input, weight, bias, options) + } else { + conv_gemm_simple_sync::( + input, + weight, + bias, + options, + AcceleratedTileKind::Cmma, + ) + } + } + } +} + +/// Performs an N-dimensional convolution backwards pass with regard to weight, with the given strategy +/// +/// * `input` - The input feature map +/// * `out_grad` - The output gradients +/// * `weight_shape` - The shape of the weights/weight gradients +/// * `options` - The options used for the convolution +/// * `strategy` - The convolution algorithm to use. Autotune will pick the fastest available option. +pub fn conv_weight_backward( + input: CubeTensor, + out_grad: CubeTensor, + weight_shape: Shape, + options: ConvOptions, + strategy: ConvStrategy, +) -> Result, ConvSetupError> { + let input = permute_nchw_to_nhwc(input); + let out_grad = permute_nchw_to_nhwc(out_grad); + let weight_shape = permute_nchw_to_nhwc_shape(weight_shape); + + let weight_grad = match strategy { + ConvStrategy::Direct => { + conv_weight_backward_fallback::(input, out_grad, weight_shape, options) + } + #[cfg(feature = "autotune")] + ConvStrategy::Autotune => Ok(wgrad_autotune::( + input, + out_grad, + weight_shape, + options, + )), + ConvStrategy::ImplicitGemm => { + if options.groups != 1 { + conv_weight_backward_fallback::(input, out_grad, weight_shape, options) + } else { + wgrad_gemm_simple_sync::( + input, + out_grad, + weight_shape, + options, + AcceleratedTileKind::Cmma, + ) + } + } + }?; + + Ok(permute_nhwc_to_nchw(weight_grad)) +} + +/// Performs an N-dimensional convolution backwards data pass with the given strategy +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `in_shape` - The shape of the input to the layer +/// * `options` - The options to use for the convolution +/// * `strategy` - The convolution algorithm to use. Autotune will pick the fastest available option. +pub fn conv_data_backward( + out_grad: CubeTensor, + weights: CubeTensor, + in_shape: Shape, + options: ConvOptions, + strategy: ConvStrategy, +) -> Result, ConvSetupError> { + let out_grad = permute_nchw_to_nhwc(out_grad); + let weights = permute_nchw_to_nhwc(weights); + let in_shape = permute_nchw_to_nhwc_shape(in_shape); + + let weight_grad = match strategy { + ConvStrategy::Direct => { + conv_data_backward_fallback::(out_grad, weights, in_shape, options)? + } + #[cfg(feature = "autotune")] + ConvStrategy::Autotune => dgrad_autotune::(out_grad, weights, in_shape, options), + ConvStrategy::ImplicitGemm => { + if options.groups != 1 || options.stride.iter().any(|&s| s != 1) { + conv_data_backward_fallback::(out_grad, weights, in_shape, options)? + } else { + dgrad_gemm_simple_sync::( + out_grad, + weights, + in_shape, + options, + AcceleratedTileKind::Cmma, + )? + } + } + }; + + Ok(permute_nhwc_to_nchw(weight_grad)) +} diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/base.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/base.rs new file mode 100644 index 0000000..5fa9ed2 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/base.rs @@ -0,0 +1,54 @@ +use crate::{CubeRuntime, tensor::CubeTensor}; +use burn_backend::ops::ConvTransposeOptions; +use cubek::convolution::components::ConvSetupError; + +#[cfg(feature = "autotune")] +use super::conv_transpose2d_autotune; +use super::{conv_transpose2d_col2im, conv_transpose2d_direct}; + +/// The strategy to be used when launching a conv_transpose kernel. +pub enum ConvTranspose2dStrategy { + /// A simple direct convolution. + Direct, + #[cfg(feature = "autotune")] + /// Using autotune to choose the best kernel based on runtime information. + Autotune, + /// GEMM (im2col) based implementation of convolution. Significantly increased memory usage. + Gemm, +} + +impl Default for ConvTranspose2dStrategy { + fn default() -> Self { + // if autotune is enabled, default to autotune + #[cfg(feature = "autotune")] + return ConvTranspose2dStrategy::Autotune; + + // if autotune is disabled, default to the more memory-conservative algorithm + #[cfg(not(feature = "autotune"))] + ConvTranspose2dStrategy::Direct + } +} + +/// Performs a 2D convolution with the given strategy +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +/// * `strategy` - The convolution algorithm to use. Autotune will pick the fastest available option. +pub fn conv_transpose2d( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + strategy: ConvTranspose2dStrategy, +) -> Result, ConvSetupError> { + match strategy { + ConvTranspose2dStrategy::Direct => conv_transpose2d_direct(input, weight, bias, options), + #[cfg(feature = "autotune")] + ConvTranspose2dStrategy::Autotune => { + Ok(conv_transpose2d_autotune(input, weight, bias, options)) + } + ConvTranspose2dStrategy::Gemm => conv_transpose2d_col2im(input, weight, bias, options), + } +} diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/col2im.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/col2im.rs new file mode 100644 index 0000000..cec7846 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/col2im.rs @@ -0,0 +1,307 @@ +use crate::{ + CubeRuntime, + kernel::{ + conv::batches_per_run, + into_contiguous_aligned, + matmul::{MatmulStrategy, matmul}, + slice, + utils::{address_type, decompose_linear, shape_divmod}, + }, + ops::{numeric::empty_device_dtype, reshape, swap_dims}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + Shape, + ops::{ConvTransposeOptions, conv::calculate_conv_transpose_output_size}, +}; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearViewMut}, +}; +use cubek::convolution::components::ConvSetupError; + +/// Perform a 2D convolution transposition using the GEMM (col2im) algorithm. +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +pub fn conv_transpose2d_col2im( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvTransposeOptions<2>, +) -> Result, ConvSetupError> { + let [input_channels, im_ch_per_group, kernel_h, kernel_w] = weight.meta.shape().dims(); + let [batch_size, _, input_h, input_w] = input.meta.shape().dims(); + let groups = options.groups; + let input_ch_per_group = input_channels / groups; + let ConvTransposeOptions { + padding: [padding_h, padding_w], + padding_out: [padding_out_h, padding_out_w], + dilation: [dilation_h, dilation_w], + stride: [stride_h, stride_w], + .. + } = options.clone(); + + let im_h = calculate_conv_transpose_output_size( + kernel_h, + stride_h, + padding_h, + padding_out_h, + dilation_h, + input_h, + ); + let im_w = calculate_conv_transpose_output_size( + kernel_w, + stride_w, + padding_w, + padding_out_w, + dilation_w, + input_w, + ); + let im_channels = im_ch_per_group * groups; + + let batches_per_run = batches_per_run( + batch_size, + input_h * input_w, + input.client.properties().hardware.plane_size_max as usize, + )?; + let col_shape_0 = im_ch_per_group * kernel_h * kernel_w; + + let weight = reshape( + weight.clone(), + Shape::new([groups, input_ch_per_group, col_shape_0]), + ); + let weight = into_contiguous_aligned(swap_dims(weight, 1, 2)); + + if batches_per_run != batch_size { + let runs = batch_size / batches_per_run; + + let im_shape = Shape::new([runs, batches_per_run, im_channels, im_h, im_w]); + let image = empty_device_dtype( + input.client.clone(), + input.device.clone(), + im_shape, + input.dtype, + ); + + let input_shape = Shape::new([runs, batches_per_run, input_channels, input_h, input_w]); + let input = reshape(input, input_shape); + let input_shape_run = Shape::new([batches_per_run, input_channels, input_h, input_w]); + + for run in 0..runs { + let input = index(input.clone(), run); + let input = reshape(input, input_shape_run.clone()); + let im_shape = Shape::new([batches_per_run, im_channels, im_h, im_w]); + let image_slice = index(image.clone(), run); + let image_slice = reshape(image_slice, im_shape); + execute( + input, + weight.clone(), + bias.clone(), + image_slice, + options.clone(), + kernel_h, + kernel_w, + )?; + } + Ok(reshape( + image, + Shape::new([batch_size, im_channels, im_h, im_w]), + )) + } else { + let im_shape = Shape::new([batches_per_run, im_channels, im_h, im_w]); + let image = empty_device_dtype( + input.client.clone(), + input.device.clone(), + im_shape, + input.dtype, + ); + execute( + input, + weight, + bias, + image.clone(), + options, + kernel_h, + kernel_w, + )?; + Ok(image) + } +} + +pub(crate) fn index(tensor: CubeTensor, i: usize) -> CubeTensor { + #[allow(clippy::single_range_in_vec_init)] + let mut indices = vec![i..i + 1]; + for dim in tensor.meta.shape()[1..].iter() { + indices.push(0..*dim); + } + let mut tensor = slice(tensor, &indices); + tensor.meta.remove(0); + tensor +} + +#[allow(clippy::too_many_arguments)] +fn execute( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + image: CubeTensor, + options: ConvTransposeOptions<2>, + kernel_h: usize, + kernel_w: usize, +) -> Result<(), ConvSetupError> { + let [batch_size, _, input_h, input_w] = input.meta.shape().dims(); + let [groups, col_shape_0, input_ch_per_group] = weight.meta.shape().dims(); + + let col_shape_1 = batch_size * input_h * input_w; + + let input = swap_dims(input, 0, 1); + let input_shape = Shape::new([groups, input_ch_per_group, col_shape_1]); + let input = reshape(input, input_shape); + + let dtype = input.dtype; + let columns = matmul(weight, input, None, MatmulStrategy::default(), dtype)?; + let columns = reshape(columns, Shape::new([col_shape_0 * groups, col_shape_1])); + + col2im( + columns, bias, image, kernel_h, kernel_w, input_h, input_w, options, + )?; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn col2im( + columns: CubeTensor, + bias: Option>, + out: CubeTensor, + kernel_h: usize, + kernel_w: usize, + out_h: usize, + out_w: usize, + options: ConvTransposeOptions<2>, +) -> Result<(), LaunchError> { + let dtype = columns.dtype; + + let columns = into_contiguous_aligned(columns); + let bias = bias.map(into_contiguous_aligned); + + let num_elems = out.meta.num_elements(); + + let cube_dim = CubeDim::new(&columns.client, num_elems); + let cube_count = calculate_cube_count_elemwise(&columns.client, num_elems, cube_dim); + + let shape = shape_divmod(&out); + unsafe { + col2im_kernel::launch_unchecked( + &columns.client.clone(), + cube_count, + cube_dim, + address_type!(columns, bias, out), + columns.into_tensor_arg(), + bias.map(|bias| bias.into_buffer_arg()).into(), + out.into_linear_view(), + shape, + Col2ImArgsLaunch::new( + out_h, + out_w, + kernel_h, + kernel_w, + options.padding[0], + options.padding[1], + options.dilation[0], + options.dilation[1], + options.stride[0], + options.stride[1], + ), + dtype_to_storage_type(dtype), + ) + }; + + Ok(()) +} + +#[derive(CubeLaunch, CubeType)] +struct Col2ImArgs { + out_h: usize, + out_w: usize, + + kernel_h: usize, + kernel_w: usize, + + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + stride_h: usize, + stride_w: usize, +} + +#[cube(launch_unchecked, address_type = "dynamic")] +fn col2im_kernel( + columns: &Tensor, + bias: ComptimeOption<&[E]>, + mut image: LinearViewMut<'_, E>, + image_shape: Sequence>, + args: &Col2ImArgs, + #[define(E)] _dtype: StorageType, +) { + if ABSOLUTE_POS >= image.shape() { + terminate!(); + } + + let (_, pos) = decompose_linear(ABSOLUTE_POS, &image_shape); + let [batch, ch_im, im_y, im_x] = *pos else { + unreachable!() + }; + + let im_x = im_x + args.pad_w; + let im_y = im_y + args.pad_h; + + let kernel_extent_w = (args.kernel_w - 1) * args.dilation_w + 1; + let kernel_extent_h = (args.kernel_h - 1) * args.dilation_h + 1; + + let mut val = E::zero(); + + let x_col_start = if im_x >= kernel_extent_w { + (im_x - kernel_extent_w) / args.stride_w + 1 + } else { + 0usize.runtime() + }; + let x_col_end = clamp_max(im_x / args.stride_w + 1, args.out_w); + let y_col_start = if im_y >= kernel_extent_h { + (im_y - kernel_extent_h) / args.stride_h + 1 + } else { + 0usize.runtime() + }; + let y_col_end = clamp_max(im_y / args.stride_h + 1, args.out_h); + + for col_y in y_col_start..y_col_end { + let kernel_y = im_y - col_y * args.stride_h; + for col_x in x_col_start..x_col_end { + let kernel_x = im_x - col_x * args.stride_w; + + if kernel_y.is_multiple_of(args.dilation_h) && kernel_x.is_multiple_of(args.dilation_w) + { + let kernel_y = kernel_y / args.dilation_h; + let kernel_x = kernel_x / args.dilation_w; + + let col_k = + ch_im * args.kernel_h * args.kernel_w + kernel_y * args.kernel_w + kernel_x; + let col_n = batch * args.out_h * args.out_w + col_y * args.out_w + col_x; + let col_pos = col_k * columns.stride(0) + col_n * columns.stride(1); + val += columns[col_pos]; + } + } + } + + #[comptime] + match bias { + ComptimeOption::Some(bias) => image.write(ABSOLUTE_POS, val + bias[ch_im]), + ComptimeOption::None => image.write(ABSOLUTE_POS, val), + } +} diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/mod.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/mod.rs new file mode 100644 index 0000000..214333f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/mod.rs @@ -0,0 +1,15 @@ +mod base; +mod col2im; + +mod transpose_direct; + +#[cfg(feature = "autotune")] +mod tune; + +pub use base::*; +pub use col2im::*; + +pub use transpose_direct::*; + +#[cfg(feature = "autotune")] +pub use tune::*; diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/transpose_direct.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/transpose_direct.rs new file mode 100644 index 0000000..7bd33c2 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/transpose_direct.rs @@ -0,0 +1,187 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, decompose_linear, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{Shape, ops::ConvTransposeOptions}; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearViewMut}, +}; +use cubek::convolution::components::ConvSetupError; + +#[derive(CubeLaunch, CubeType)] +struct ConvArgs { + conv_stride_0: usize, + conv_stride_1: usize, + dilation_0: usize, + dilation_1: usize, + padding_0: usize, + padding_1: usize, + groups: usize, +} + +#[cube(launch, address_type = "dynamic")] +fn conv_transpose2d_direct_kernel( + input: &Tensor, + weight: &Tensor, + bias: ComptimeOption<&[E]>, + mut output: LinearViewMut<'_, E>, + out_shape: Sequence>, + args: ConvArgs, + #[define(E)] _dtype: StorageType, +) { + if ABSOLUTE_POS >= output.shape() { + terminate!(); + } + + let in_c_per_group = weight.shape(0) / args.groups; + let out_c_per_group = weight.shape(1); + let kernel_h = weight.shape(2); + let kernel_w = weight.shape(3); + + let (_, pos) = decompose_linear(ABSOLUTE_POS, &out_shape); + let [batch, oc_out, out_y, out_x] = *pos else { + unreachable!() + }; + + let k = oc_out / out_c_per_group; + let group = k % args.groups; + let out_c = oc_out - out_c_per_group * group; + + let in_c_start = group * in_c_per_group; + let in_c_end = in_c_start + in_c_per_group; + + let stride_0_i = args.conv_stride_0 as i32; + let stride_1_i = args.conv_stride_1 as i32; + + let kms_h = (kernel_h * args.dilation_0) as i32 - stride_0_i; + let kms_w = (kernel_w * args.dilation_1) as i32 - stride_1_i; + + let y_start = ((out_y + args.padding_0) as i32 - kms_h) / stride_0_i; + let x_start = ((out_x + args.padding_1) as i32 - kms_w) / stride_1_i; + + let y_end = clamp(kms_h + y_start + 1, 0, input.shape(2) as i32) as usize; + let x_end = clamp(kms_w + x_start + 1, 0, input.shape(3) as i32) as usize; + let y_start = clamp_min(y_start, 0) as usize; + let x_start = clamp_min(x_start, 0) as usize; + + let idx_input_batch = batch * input.stride(0); + let idx_weight_oc = out_c * weight.stride(1); + + let bias: ComptimeOption = bias.as_ref().map(|bias| bias[oc_out]); + let mut sum = bias.unwrap_or_default(); + + let numerator_h_base = out_y + args.padding_0; + let numerator_w_base = out_x + args.padding_1; + + for in_c in in_c_start..in_c_end { + let idx_input_ic = in_c * input.stride(1); + let idx_weight_ic = in_c * weight.stride(0); + + for in_y in y_start..y_end { + let numerator_tmp = in_y * args.conv_stride_0; + let numerator_h = numerator_h_base - numerator_tmp; + + if numerator_h_base >= numerator_tmp && numerator_h.is_multiple_of(args.dilation_0) { + let kernel_y = numerator_h / args.dilation_0; + let idx_input_y = in_y * input.stride(2); + let idx_weight_ky = kernel_y * weight.stride(2); + + for in_x in x_start..x_end { + let numerator_tmp = in_x * args.conv_stride_1; + let numerator_w = numerator_w_base - numerator_tmp; + + if numerator_w_base >= numerator_tmp + && numerator_w.is_multiple_of(args.dilation_1) + { + let kernel_x = numerator_w / args.dilation_1; + let idx_input_x = in_x * input.stride(3); + let idx_weight_kx = kernel_x * weight.stride(3); + + let index_input = + idx_input_batch + idx_input_ic + idx_input_y + idx_input_x; + let index_weight = + idx_weight_ic + idx_weight_oc + idx_weight_ky + idx_weight_kx; + + let value = input[index_input]; + let weight = weight[index_weight]; + + sum += value * weight; + } + } + } + } + } + + output.write(ABSOLUTE_POS, sum); +} + +/// Perform a 2D convolution transposition using the direct algorithm. +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +/// +pub fn conv_transpose2d_direct( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvTransposeOptions<2>, +) -> Result, ConvSetupError> { + let [batch_size, _, in_height, in_width] = input.meta.shape().dims(); + let [_, out_channels, kernel_0, kernel_1] = weight.meta.shape().dims(); + + let out_0 = (in_height - 1) * options.stride[0] + + options.dilation[0] * (kernel_0 - 1) + + options.padding_out[0] + - 2 * options.padding[0] + + 1; + let out_1 = (in_width - 1) * options.stride[1] + + options.dilation[1] * (kernel_1 - 1) + + options.padding_out[1] + - 2 * options.padding[1] + + 1; + + let shape_out = Shape::new([batch_size, out_channels * options.groups, out_0, out_1]); + + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + shape_out.clone(), + input.dtype, + ); + + let num_elems = output.meta.num_elements(); + let cube_dim = CubeDim::new(&input.client, num_elems); + let cube_count = calculate_cube_count_elemwise(&input.client, num_elems, cube_dim); + let dtype = input.dtype; + + conv_transpose2d_direct_kernel::launch( + &output.client, + cube_count, + cube_dim, + address_type!(input, weight, bias, output), + input.into_tensor_arg(), + weight.into_tensor_arg(), + bias.map(|bias| bias.into_buffer_arg()).into(), + output.clone().into_linear_view(), + shape_divmod(&output), + ConvArgsLaunch::new( + options.stride[0], + options.stride[1], + options.dilation[0], + options.dilation[1], + options.padding[0], + options.padding[1], + options.groups, + ), + dtype_to_storage_type(dtype), + ); + + Ok(output) +} diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/tune.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/tune.rs new file mode 100644 index 0000000..e799c09 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose2d/tune.rs @@ -0,0 +1,99 @@ +use burn_backend::ops::ConvTransposeOptions; +use cubecl::tune::{LocalTuner, Tunable, TunableSet, local_tuner}; + +use crate::{ + CubeAutotuneKey, CubeRuntime, CubeTuneId, + kernel::conv::{ConvTranspose2dAutotuneKey, conv_transpose2d_col2im, conv_transpose2d_direct}, + tensor::CubeTensor, +}; + +/// Executes autotune on conv2d operations +pub fn conv_transpose2d_autotune( + input: CubeTensor, + weights: CubeTensor, + bias: Option>, + options: ConvTransposeOptions<2>, +) -> CubeTensor { + let client = input.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + let tune_set = TUNER.init(|| { + TunableSet::new(create_key::, create_transpose2d_input::) + .with(Tunable::new( + "conv_transpose2d_direct", + |(input, weights, bias, options)| { + conv_transpose2d_direct::(input, weights, bias, options) + }, + )) + .with(Tunable::new( + "conv_transpose2d_col2im", + |(input, weights, bias, options)| { + conv_transpose2d_col2im::(input, weights, bias, options) + }, + )) + }); + + TUNER.execute( + &CubeTuneId::new(&input.client, &input.device), + &client, + tune_set, + (input, weights, bias, options), + ) +} + +pub fn create_transpose2d_input( + _key: &CubeAutotuneKey, + (input, weights, bias, options): &( + CubeTensor, + CubeTensor, + Option>, + ConvTransposeOptions<2>, + ), +) -> ( + CubeTensor, + CubeTensor, + Option>, + ConvTransposeOptions<2>, +) { + ( + input.clone(), + weights.clone(), + bias.clone(), + options.clone(), + ) +} + +fn create_key( + (input, weights, bias, options): &( + CubeTensor, + CubeTensor, + Option>, + ConvTransposeOptions<2>, + ), +) -> CubeAutotuneKey { + let [batch_size, in_channels, height, width] = input.meta.shape().dims(); + let [out_channels, _, kernel_h, kernel_w] = weights.meta.shape().dims(); + let ConvTransposeOptions { + stride, + padding, + dilation, + groups, + padding_out, + } = options.clone(); + CubeAutotuneKey::ConvTranspose(ConvTranspose2dAutotuneKey::new( + [kernel_h, kernel_w], + stride, + padding, + padding_out, + dilation, + groups, + in_channels, + out_channels, + height, + width, + batch_size, + bias.is_some(), + input.dtype, + )) +} diff --git a/crates/burn-cubecl/src/kernel/conv/conv_transpose3d.rs b/crates/burn-cubecl/src/kernel/conv/conv_transpose3d.rs new file mode 100644 index 0000000..79eadd5 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/conv_transpose3d.rs @@ -0,0 +1,228 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearViewMut}, +}; + +use crate::{ + CubeRuntime, + kernel::utils::{address_type, decompose_linear, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::{Shape, ops::ConvTransposeOptions}; + +#[derive(CubeLaunch, CubeType)] +struct ConvArgs { + conv_stride_0: usize, + conv_stride_1: usize, + conv_stride_2: usize, + dilation_0: usize, + dilation_1: usize, + dilation_2: usize, + padding_0: usize, + padding_1: usize, + padding_2: usize, + groups: usize, +} + +#[cube(launch, address_type = "dynamic")] +fn conv_transpose3d_kernel( + input: &Tensor, + weight: &Tensor, + bias: ComptimeOption<&[E]>, + mut output: LinearViewMut<'_, E>, + out_shape: Sequence>, + args: ConvArgs, + #[define(E)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!() + } + + let in_channels = weight.shape(0); + let out_c_per_group = weight.shape(1); + let kernel_size_0 = weight.shape(2); + let kernel_size_1 = weight.shape(3); + let kernel_size_2 = weight.shape(4); + + let stride_0_i = args.conv_stride_0 as i32; + let stride_1_i = args.conv_stride_1 as i32; + let stride_2_i = args.conv_stride_2 as i32; + + let (_, pos) = decompose_linear(ABSOLUTE_POS, &out_shape); + let [batch, out_c_out, out_z, out_y, out_x] = *pos else { + unreachable!() + }; + + let groups = args.groups; + let in_c_per_group = in_channels / groups; + + let k = out_c_out / out_c_per_group; + let group = k % groups; + let out_channel = out_c_out - out_c_per_group * group; + + let in_c_start = group * in_c_per_group; + let in_c_end = in_c_start + in_c_per_group; + + let kernel_d = (kernel_size_0 * args.dilation_0 - args.conv_stride_0) as i32; + let kernel_h = (kernel_size_1 * args.dilation_1 - args.conv_stride_1) as i32; + let kernel_w = (kernel_size_2 * args.dilation_2 - args.conv_stride_2) as i32; + + let z_start = ((out_z + args.padding_0) as i32 - kernel_d) / stride_0_i; + let y_start = ((out_y + args.padding_1) as i32 - kernel_h) / stride_1_i; + let x_start = ((out_x + args.padding_2) as i32 - kernel_w) / stride_2_i; + + let z_end = clamp(kernel_d + z_start + 1, 0, input.shape(2) as i32) as usize; + let y_end = clamp(kernel_h + y_start + 1, 0, input.shape(3) as i32) as usize; + let x_end = clamp(kernel_w + x_start + 1, 0, input.shape(4) as i32) as usize; + + let z_start = clamp_min(z_start, 0) as usize; + let y_start = clamp_min(y_start, 0) as usize; + let x_start = clamp_min(x_start, 0) as usize; + + let index_input_batch = batch * input.stride(0); + let index_weight_out_c = out_channel * weight.stride(1); + + let bias: ComptimeOption = bias.as_ref().map(|bias| bias[out_c_out]); + let mut sum = bias.unwrap_or_default(); + + let numerator_d_base = out_z + args.padding_0; + let numerator_h_base = out_y + args.padding_1; + let numerator_w_base = out_x + args.padding_2; + + for in_c in in_c_start..in_c_end { + let index_input_in_c = in_c * input.stride(1); + let index_weight_in_c = in_c * weight.stride(0); + + for in_z in z_start..z_end { + let numerator_tmp = in_z * args.conv_stride_0; + let numerator_d = numerator_d_base - numerator_tmp; + + if numerator_d_base >= numerator_tmp && numerator_d.is_multiple_of(args.dilation_0) { + let kernel_z = numerator_d / args.dilation_0; + let index_input_z = in_z * input.stride(2); + let index_weight_kz = kernel_z * weight.stride(2); + + for in_y in y_start..y_end { + let numerator_tmp = in_y * args.conv_stride_1; + let numerator_h = numerator_h_base - numerator_tmp; + + if numerator_h_base >= numerator_tmp + && numerator_h.is_multiple_of(args.dilation_1) + { + let kernel_y = numerator_h / args.dilation_1; + let index_input_y = in_y * input.stride(3); + let index_weight_ky = kernel_y * weight.stride(3); + + for in_x in x_start..x_end { + let numerator_tmp = in_x * args.conv_stride_2; + let numerator_w = numerator_w_base - numerator_tmp; + + if numerator_w_base >= numerator_tmp + && numerator_w.is_multiple_of(args.dilation_2) + { + let kernel_x = numerator_w / args.dilation_2; + let index_input_x = in_x * input.stride(4); + let index_weight_kx = kernel_x * weight.stride(4); + + let index_input = index_input_batch + + index_input_in_c + + index_input_z + + index_input_y + + index_input_x; + + let index_weight = index_weight_in_c + + index_weight_out_c + + index_weight_kz + + index_weight_ky + + index_weight_kx; + + let value = input[index_input]; + let weight = weight[index_weight]; + + sum += value * weight; + } + } + } + } + } + } + } + + output.write(ABSOLUTE_POS, sum); +} + +pub(crate) fn conv_transpose3d( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvTransposeOptions<3>, +) -> Result, LaunchError> { + let [batch_size, _, in_depth, in_height, in_width] = input.meta.shape().dims(); + let [_, out_channels, kernel_0, kernel_1, kernel_2] = weight.meta.shape().dims(); + + let out_0 = (in_depth - 1) * options.stride[0] + + options.dilation[0] * (kernel_0 - 1) + + options.padding_out[0] + - 2 * options.padding[0] + + 1; + let out_1 = (in_height - 1) * options.stride[1] + + options.dilation[1] * (kernel_1 - 1) + + options.padding_out[1] + - 2 * options.padding[1] + + 1; + let out_2 = (in_width - 1) * options.stride[2] + + options.dilation[2] * (kernel_2 - 1) + + options.padding_out[2] + - 2 * options.padding[2] + + 1; + + let shape_out = Shape::new([ + batch_size, + out_channels * options.groups, + out_0, + out_1, + out_2, + ]); + + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + shape_out.clone(), + input.dtype, + ); + + let num_elems = output.meta.num_elements(); + let cube_dim = CubeDim::new(&input.client, num_elems); + let cube_count = calculate_cube_count_elemwise(&input.client, num_elems, cube_dim); + + let dtype = input.dtype; + conv_transpose3d_kernel::launch( + &output.client, + cube_count, + cube_dim, + address_type!(input, weight, bias, output), + input.into_tensor_arg(), + weight.into_tensor_arg(), + bias.map(|bias| bias.into_buffer_arg()).into(), + output.clone().into_linear_view(), + shape_divmod(&output), + ConvArgsLaunch::new( + options.stride[0], + options.stride[1], + options.stride[2], + options.dilation[0], + options.dilation[1], + options.dilation[2], + options.padding[0], + options.padding[1], + options.padding[2], + options.groups, + ), + dtype_to_storage_type(dtype), + ); + + Ok(output) +} diff --git a/crates/burn-cubecl/src/kernel/conv/deform_conv2d.rs b/crates/burn-cubecl/src/kernel/conv/deform_conv2d.rs new file mode 100644 index 0000000..79e0ce5 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/deform_conv2d.rs @@ -0,0 +1,311 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{calculate_cube_count_elemwise, prelude::*, std::FastDivmod}; +use cubek::convolution::components::ConvSetupError; + +use burn_backend::{ + Shape, + ops::{DeformConvOptions, conv::calculate_conv_output_size}, +}; + +use crate::{ + CubeRuntime, + kernel::{ + AddOp, into_contiguous_aligned, launch_binop, + matmul::{MatmulStrategy, matmul}, + utils::address_type, + }, + ops::{numeric::zeros_client, reshape, swap_dims}, + tensor::CubeTensor, +}; + +#[derive(CubeLaunch, CubeType)] +struct DeformConv2dArgs { + conv_stride_h: usize, + conv_stride_w: usize, + dilation_h: usize, + dilation_w: usize, + padding_h: InputScalar, + padding_w: InputScalar, + offset_groups: usize, + + kernel_height: usize, + kernel_width: usize, + out_h: usize, + out_w: usize, +} + +#[cube(launch, address_type = "dynamic")] +fn deform_im2col_kernel( + input: &Tensor, + offset: &Tensor, + mask: ComptimeOption<&Tensor>, + columns: &mut Tensor, + pos_shape: Sequence>, + args: &DeformConv2dArgs, + #[comptime] kernel_h_unroll: Option, + #[comptime] kernel_w_unroll: Option, + #[define(F)] _dtype: StorageType, +) { + // position shape: [in_channels, batch_size, out_h, out_w] + // columns shape: [[in_channels, kernel_h, kernel_w], [batch_size, out_h, out_w]] + + let kernel_height = kernel_h_unroll.unwrap_or(args.kernel_height); + let unroll_h = kernel_h_unroll.is_some(); + let kernel_width = kernel_w_unroll.unwrap_or(args.kernel_width); + let unroll_w = kernel_w_unroll.is_some(); + + let out_h = args.out_h; + let out_w = args.out_w; + let in_channels = input.shape(1); + let height = input.shape(2); + let width = input.shape(3); + let col_stride_0 = columns.stride(0); + + let (rem, out_x) = pos_shape[3].div_mod(ABSOLUTE_POS); + let (rem, out_y) = pos_shape[2].div_mod(rem); + let (in_channel, batch) = pos_shape[1].div_mod(rem); + + if in_channel >= in_channels { + terminate!() + } + + let out_k_base = in_channel * kernel_height * kernel_width; + let out_n = batch * out_h * out_w + out_y * out_w + out_x; + + let channels_per_offset_group = in_channels / args.offset_groups; + let group_index = in_channel / channels_per_offset_group; + + let mut col_base_idx = out_k_base * columns.stride(0) + out_n * columns.stride(1); + + let input_base_idx = batch * input.stride(0) + in_channel * input.stride(1); + + let offset_base_idx = batch * offset.stride(0) + + group_index * kernel_height * kernel_width * 2 * offset.stride(1); + + let mask_base_idx = mask.as_ref().map(|mask| { + batch * mask.stride(0) + group_index * kernel_height * kernel_width * mask.stride(1) + }); + + #[unroll(unroll_h)] + for kernel_y in 0..kernel_height { + #[unroll(unroll_w)] + for kernel_x in 0..kernel_width { + let mask_index = kernel_y * kernel_width + kernel_x; + let offset_index = mask_index * 2; + + let offset_y = offset[offset_base_idx + + offset_index * offset.stride(1) + + out_y * offset.stride(2) + + out_x * offset.stride(3)]; + let offset_x = offset[offset_base_idx + + (offset_index + 1) * offset.stride(1) + + out_y * offset.stride(2) + + out_x * offset.stride(3)]; + let y = F::cast_from(out_y * args.conv_stride_h + kernel_y * args.dilation_h) + - args.padding_h.get::() + + offset_y; + let x = F::cast_from(out_x * args.conv_stride_w + kernel_x * args.dilation_w) + - args.padding_w.get::() + + offset_x; + + let interpolated = bilinear_interpolate(input, height, width, y, x, input_base_idx); + #[comptime] + let value = match mask.zip::(mask_base_idx) { + ComptimeOption::Some((mask, base_idx)) => { + let mask_value = mask[base_idx + + mask_index * mask.stride(1) + + out_y * mask.stride(2) + + out_x * mask.stride(3)]; + mask_value * interpolated + } + ComptimeOption::None => interpolated, + }; + + columns[col_base_idx] = value; + col_base_idx += col_stride_0; + } + } +} + +#[cube] +pub(crate) fn bilinear_interpolate( + input: &Tensor, + height: usize, + width: usize, + y: F, + x: F, + offset: usize, +) -> F { + // To simplify code + let y = f32::cast_from(y); + let x = f32::cast_from(x); + let stride_y = input.stride(2); + let stride_x = input.stride(3); + + let mut result = F::new(0.0_f32); + if y > -1.0f32 && height as f32 > y && x > -1.0f32 && width as f32 > x { + let y_low = y.floor(); + let x_low = x.floor(); + let y_high = (y_low + 1.) as usize; + let x_high = (x_low + 1.) as usize; + + let zero = F::new(0.0_f32); + let v1: F = if y_low >= 0. && x_low >= 0. { + input[offset + y_low as usize * stride_y + x_low as usize * stride_x] + } else { + zero + }; + let v2: F = if y_low >= 0. && x_high < width { + input[offset + y_low as usize * stride_y + x_high * stride_x] + } else { + zero + }; + let v3: F = if y_high < height && x_low >= 0. { + input[offset + y_high * stride_y + x_low as usize * stride_x] + } else { + zero + }; + let v4: F = if y_high < height && x_high < width { + input[offset + y_high * stride_y + x_high * stride_x] + } else { + zero + }; + + let l_y = y - y_low; + let l_x = x - x_low; + let h_y = 1.0 - l_y; + let h_x = 1.0 - l_x; + + let w1 = F::cast_from(h_y * h_x); + let w2 = F::cast_from(h_y * l_x); + let w3 = F::cast_from(l_y * h_x); + let w4 = F::cast_from(l_y * l_x); + + result = w1 * v1 + w2 * v2 + w3 * v3 + w4 * v4; + } + result +} + +pub(crate) fn deform_im2col( + input: CubeTensor, + offset: CubeTensor, + mask: Option>, + options: DeformConvOptions<2>, + out_dims: (usize, usize), + kernel_dims: (usize, usize), +) -> Result, LaunchError> { + let client = input.client.clone(); + let device = input.device.clone(); + let dtype = input.dtype; + + let [batch_size, in_channels, _, _] = input.meta.shape().dims(); + let (out_height, out_width) = out_dims; + let (kernel_height, kernel_width) = kernel_dims; + + let shape_out = Shape::new([ + in_channels * kernel_height * kernel_width, + batch_size * out_height * out_width, + ]); + + let pos_shape = [in_channels, batch_size, out_height, out_width] + .into_iter() + .collect(); + + let output = zeros_client(client.clone(), device.clone(), shape_out.clone(), dtype); + + let num_kernels = in_channels * batch_size * out_height * out_width; + let cube_dim = CubeDim::new(&input.client, num_kernels); + let cube_count = calculate_cube_count_elemwise(&input.client, num_kernels, cube_dim); + + deform_im2col_kernel::launch( + &output.client, + cube_count, + cube_dim, + address_type!(input, offset, mask, output), + input.into_tensor_arg(), + offset.into_tensor_arg(), + mask.map(|mask| mask.into_tensor_arg()).into(), + output.clone().binding().into_tensor_arg(), + pos_shape, + DeformConv2dArgsLaunch::new( + options.stride[0], + options.stride[1], + options.dilation[0], + options.dilation[1], + { + let val = options.padding[0] as f32; + InputScalar::new(val, dtype_to_storage_type(dtype)) + }, + { + let val = options.padding[1] as f32; + InputScalar::new(val, dtype_to_storage_type(dtype)) + }, + options.offset_groups, + kernel_height, + kernel_width, + out_height, + out_width, + ), + Some(kernel_height), + Some(kernel_width), + dtype_to_storage_type(dtype), + ); + + Ok(output) +} + +pub(crate) fn deform_conv2d( + input: CubeTensor, + offset: CubeTensor, + weight: CubeTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, +) -> Result, ConvSetupError> { + let input = into_contiguous_aligned(input); + let offset = into_contiguous_aligned(offset); + let weight = into_contiguous_aligned(weight); + let mask = mask.map(|it| into_contiguous_aligned(it)); + let bias = bias.map(|it| into_contiguous_aligned(it)); + + let [batch_size, _, in_height, in_width] = input.meta.shape().dims(); + let [out_channels, _, kernel_h, kernel_w] = weight.meta.shape().dims(); + let groups = options.weight_groups; + + let out_h = calculate_conv_output_size( + kernel_h, + options.stride[0], + options.padding[0], + options.dilation[0], + in_height, + ); + let out_w = calculate_conv_output_size( + kernel_w, + options.stride[1], + options.padding[1], + options.dilation[1], + in_width, + ); + let out_dims = (out_h, out_w); + + let columns = deform_im2col(input, offset, mask, options, out_dims, (kernel_h, kernel_w))?; + + let [col_size_0, col_size_1] = columns.meta.shape().dims(); + let col_size_0 = col_size_0 / groups; + let out_c_per_group = out_channels / groups; + + let dtype = weight.dtype; + let weight = reshape(weight, Shape::new([groups, out_c_per_group, col_size_0])); + let columns = reshape(columns, Shape::new([groups, col_size_0, col_size_1])); + let out = matmul(weight, columns, None, MatmulStrategy::default(), dtype)?; + + let out = reshape(out, Shape::new([out_channels, batch_size, out_h, out_w])); + let out = swap_dims(out, 0, 1); + + if let Some(bias) = bias { + let bias = reshape(bias, Shape::new([1, out_channels, 1, 1])); + Ok(launch_binop::(out, bias)) + } else { + Ok(out) + } +} diff --git a/crates/burn-cubecl/src/kernel/conv/deform_conv_transpose2d.rs b/crates/burn-cubecl/src/kernel/conv/deform_conv_transpose2d.rs new file mode 100644 index 0000000..ad8e180 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/deform_conv_transpose2d.rs @@ -0,0 +1,727 @@ +use super::{bilinear_interpolate, deform_im2col, index}; +use crate::{ + CubeRuntime, + kernel::{ + cast, into_contiguous_aligned, + matmul::{MatmulStrategy, matmul}, + reduce::reduce_dim, + slice_assign, + utils::{address_type, decompose_linear}, + }, + ops::{ + numeric::{empty_device_dtype, zeros_client}, + reshape, swap_dims, + }, + tensor::CubeTensor, +}; +use burn_backend::cubecl::{dtype_to_elem_type, dtype_to_storage_type}; +use burn_backend::{DType, Shape, TensorMetadata, ops::DeformConvOptions}; +use cubecl::{ + CubeDim, CubeLaunch, calculate_cube_count_elemwise, cube, + features::AtomicUsage, + ir::FloatKind, + prelude::*, + std::{ + FastDivmod, + tensor::layout::linear::{LinearView, LinearViewMut}, + }, +}; +use cubek::{ + convolution::components::ConvSetupError, + reduce::components::instructions::ReduceOperationConfig, +}; +use std::marker::PhantomData; + +/// Calculate the [deformable 2D convolution](crate::ops::ModuleOps::deform_conv2d) backward pass using convolutions. +#[allow( + clippy::single_range_in_vec_init, + clippy::type_complexity, + clippy::too_many_arguments +)] +pub(crate) fn deform_conv2d_backward( + input: CubeTensor, + offset: CubeTensor, + weight: CubeTensor, + mask: Option>, + bias: Option>, + out_grad: CubeTensor, + options: DeformConvOptions<2>, +) -> Result< + ( + CubeTensor, + CubeTensor, + CubeTensor, + Option>, + Option>, + ), + ConvSetupError, +> { + let [_, _, out_h, out_w] = out_grad.meta.shape().dims(); + let [_, _, kernel_h, kernel_w] = weight.meta.shape().dims(); + + let gradient_bias = bias.map(|bias| { + let grad = reduce_dim( + out_grad.clone(), + None, + 0, + Default::default(), + ReduceOperationConfig::Sum, + ) + .unwrap(); + let grad = reduce_dim( + grad, + None, + 2, + Default::default(), + ReduceOperationConfig::Sum, + ) + .unwrap(); + let grad = reduce_dim( + grad, + None, + 3, + Default::default(), + ReduceOperationConfig::Sum, + ) + .unwrap(); + + reshape(grad, bias.meta.shape.clone()) + }); + + let input = into_contiguous_aligned(input); + let offset = into_contiguous_aligned(offset); + let weight = into_contiguous_aligned(weight); + let mask = mask.map(|it| into_contiguous_aligned(it)); + + let (input_gradient, offset_gradient, mask_gradient) = backward_gradient_inputs( + input.clone(), + weight.clone(), + offset.clone(), + mask.clone(), + out_grad.clone(), + &options, + (kernel_h, kernel_w), + )?; + + let weight_grad = compute_weight_grad( + input, + offset, + mask, + out_grad, + options, + (kernel_h, kernel_w), + (out_h, out_w), + )?; + + Ok(( + input_gradient, + offset_gradient, + weight_grad, + mask_gradient, + gradient_bias, + )) +} + +fn compute_weight_grad( + input: CubeTensor, + offset: CubeTensor, + mask: Option>, + out_grad: CubeTensor, + options: DeformConvOptions<2>, + kernel_dims: (usize, usize), + out_dims: (usize, usize), +) -> Result, ConvSetupError> { + let [_, in_channels, _, _] = input.meta.shape().dims(); + let [_, out_channels, _, _] = out_grad.meta.shape().dims(); + let (kernel_h, kernel_w) = kernel_dims; + let groups = options.weight_groups; + let dtype = input.dtype; + + let in_c_per_group = in_channels / groups; + let out_c_per_group = out_channels / groups; + + let columns = deform_im2col(input, offset, mask, options, out_dims, kernel_dims)?; + let [col_size_0, col_size_1] = columns.meta.shape().dims(); + let col_size_0 = col_size_0 / groups; + + let out_grad = swap_dims(out_grad, 0, 1); + let out_grad = reshape(out_grad, Shape::new([groups, out_c_per_group, col_size_1])); + + let columns = reshape(columns, Shape::new([groups, col_size_0, col_size_1])); + let columns = swap_dims(columns, 1, 2); + + let grad_weight = matmul(out_grad, columns, None, MatmulStrategy::default(), dtype)?; + + Ok(reshape( + grad_weight, + Shape::new([out_channels, in_c_per_group, kernel_h, kernel_w]), + )) +} + +type InputGradients = (CubeTensor, CubeTensor, Option>); + +fn backward_gradient_inputs( + image: CubeTensor, + weight: CubeTensor, + offset: CubeTensor, + mask: Option>, + out_grad: CubeTensor, + options: &DeformConvOptions<2>, + kernel_dims: (usize, usize), +) -> Result, ConvSetupError> { + let client = out_grad.client.clone(); + let device = out_grad.device.clone(); + + let [out_channels, in_c_per_group, kernel_h, kernel_w] = weight.meta.shape().dims(); + let [batch_size, _, out_h, out_w] = out_grad.meta.shape().dims(); + + let groups = options.weight_groups; + let out_c_per_group = out_channels / groups; + + let col_shape_0 = in_c_per_group * kernel_h * kernel_w; + let col_shape_1 = batch_size * out_h * out_w; + let col_shape = Shape::new([groups, col_shape_0, col_shape_1]); + let mut columns = empty_device_dtype(client, device, col_shape, weight.dtype); + + let weight = reshape(weight, Shape::new([groups, out_c_per_group, col_shape_0])); + + let out_grad = swap_dims(out_grad, 0, 1); + let out_grad_shape = Shape::new([groups, out_c_per_group, col_shape_1]); + let out_grad = reshape(out_grad, out_grad_shape); + + for group in 0..groups { + let dtype = weight.dtype; + let weight = swap_dims(index(weight.clone(), group), 0, 1); + let out_grad = index(out_grad.clone(), group); + let values = matmul(weight, out_grad, None, MatmulStrategy::default(), dtype)?; + let values = reshape(values, Shape::new([1, col_shape_0, col_shape_1])); + columns = slice_assign( + columns, + &[ + burn_backend::Slice::from(group..group + 1), + burn_backend::Slice::from(0..col_shape_0), + burn_backend::Slice::from(0..col_shape_1), + ], + values, + ); + } + + let columns = reshape(columns, Shape::new([col_shape_0 * groups, col_shape_1])); + + let input_shape = image.shape(); + let (offset_gradient, mask_gradient) = compute_offset_and_mask_gradient( + columns.clone(), + image, + offset.clone(), + mask.clone(), + options, + kernel_dims, + )?; + + let input_gradient = + compute_input_grad(columns, offset, mask, options, kernel_dims, input_shape)?; + + Ok((input_gradient, offset_gradient, mask_gradient)) +} + +fn compute_offset_and_mask_gradient( + columns: CubeTensor, + image: CubeTensor, + offset: CubeTensor, + mask: Option>, + options: &DeformConvOptions<2>, + kernel_dims: (usize, usize), +) -> Result<(CubeTensor, Option>), ConvSetupError> { + let client = offset.client.clone(); + let device = offset.device.clone(); + let (kernel_h, kernel_w) = kernel_dims; + + let [batches, _, out_h, out_w] = offset.meta.shape().dims(); + let offset_groups = options.offset_groups; + + let pos_shape = [batches, offset_groups, kernel_h, kernel_w, 2, out_h, out_w]; + let pos_shape = pos_shape.into_iter().collect(); + + let grad_offset = + empty_device_dtype(client.clone(), device.clone(), offset.shape(), offset.dtype); + let grad_mask = mask + .as_ref() + .map(|mask| empty_device_dtype(client.clone(), device.clone(), mask.shape(), mask.dtype)); + + let num_elements_offset = offset.meta.num_elements(); + let cube_dim = CubeDim::new(&image.client, num_elements_offset); + let cube_count = calculate_cube_count_elemwise(&image.client, num_elements_offset, cube_dim); + + let dtype: StorageType = dtype_to_storage_type(image.dtype); + unsafe { + deform_col2img_coord_kernel::launch_unchecked( + &grad_offset.client, + cube_count, + cube_dim, + address_type!(image, offset, mask, grad_offset, grad_mask), + image.into_tensor_arg(), + offset.into_tensor_arg(), + mask.map(|mask| mask.into_tensor_arg()).into(), + columns.into_tensor_arg(), + grad_offset.clone().into_linear_view(), + grad_mask + .clone() + .map(|grad_mask| grad_mask.into_tensor_arg()) + .into(), + pos_shape, + DeformConv2dCol2ImgCoordArgsLaunch::new( + options.stride[0], + options.stride[1], + options.dilation[0], + options.dilation[1], + InputScalar::new(options.padding[0] as f32, dtype.elem_type()), + InputScalar::new(options.padding[1] as f32, dtype.elem_type()), + offset_groups, + kernel_h, + kernel_w, + ), + dtype, + ) + }; + + Ok((grad_offset, grad_mask)) +} + +#[derive(CubeLaunch, CubeType)] +struct DeformConv2dCol2ImgCoordArgs { + stride_h: usize, + stride_w: usize, + dilation_h: usize, + dilation_w: usize, + pad_h: InputScalar, + pad_w: InputScalar, + offset_groups: usize, + kernel_height: usize, + kernel_width: usize, +} + +#[allow(clippy::collapsible_if)] +#[cube(launch_unchecked, address_type = "dynamic")] +fn deform_col2img_coord_kernel( + image: &Tensor, + offset: &Tensor, + mask: ComptimeOption<&Tensor>, + columns: &Tensor, + mut grad_offset: LinearViewMut<'_, F>, + grad_mask: ComptimeOption<&mut Tensor>, + pos_shape: Sequence>, + args: &DeformConv2dCol2ImgCoordArgs, + #[define(F)] _dtype: StorageType, +) { + // Position format: [batch, [offset_groups, kernel_h, kernel_w, 2], out_h, out_w] + // Columns format: [[in_channel, kernel_h, kernel_w], [batch, out_h, out_w]] + // Alternatively : [batch, offset_channels, out_h, out_w] + + if ABSOLUTE_POS >= grad_offset.shape() { + terminate!(); + } + + let out_h = offset.shape(2); + let out_w = offset.shape(3); + let in_channels = image.shape(1); + let height = image.shape(2); + let width = image.shape(3); + let kernel_w = args.kernel_width; + let kernel_h = args.kernel_height; + + let mut grad_offset_val = F::new(0.0_f32); + let mut grad_mask_val = F::new(0.0_f32); + + let (_, pos) = decompose_linear(ABSOLUTE_POS, &pos_shape); + let [batch, offset_group, kernel_y, kernel_x, dir, out_y, out_x] = *pos else { + unreachable!() + }; + + let channels_per_offset_group = in_channels / args.offset_groups; + + let col_n = batch * out_h * out_w + out_y * out_w + out_x; + + let col_base_idx = + offset_group * channels_per_offset_group * kernel_h * kernel_w * columns.stride(0) + + col_n * columns.stride(1); + let mut image_base_idx = + batch * image.stride(0) + offset_group * channels_per_offset_group * image.stride(1); + + let offset_pos_1 = + offset_group * kernel_h * kernel_w * 2 + kernel_y * kernel_w * 2 + kernel_x * 2; + let offset_base_idx = batch * offset.stride(0) + + offset_pos_1 * offset.stride(1) + + out_y * offset.stride(2) + + out_x * offset.stride(3); + + let offset_y_idx = offset_base_idx; + let offset_x_idx = offset_base_idx + offset.stride(1); + + let offset_y = offset[offset_y_idx]; + let offset_x = offset[offset_x_idx]; + + let mask_pos_1 = offset_group * kernel_h * kernel_w + kernel_y * kernel_w + kernel_x; + #[comptime] + let mask_value = match &mask { + ComptimeOption::Some(mask) => { + let mask_idx = batch * mask.stride(0) + + mask_pos_1 * mask.stride(1) + + out_y * mask.stride(2) + + out_x * mask.stride(3); + mask[mask_idx] + } + ComptimeOption::None => F::new(1.0_f32), + }; + + let is_y_direction = dir == 0; + + for col_c in 0..channels_per_offset_group { + let col_pos = col_base_idx + col_c * kernel_h * kernel_w * columns.stride(0); + + let y = F::cast_from(out_y * args.stride_h + kernel_y * args.dilation_h) + - args.pad_h.get::() + + offset_y; + let x = F::cast_from(out_x * args.stride_w + kernel_x * args.dilation_w) + - args.pad_w.get::() + + offset_x; + + let weight = + get_coordinate_weight(image, image_base_idx, height, width, y, x, is_y_direction); + let columns_value = columns[col_pos]; + + grad_offset_val += mask_value * weight * columns_value; + + if grad_mask.is_some() && is_y_direction { + grad_mask_val += + columns_value * bilinear_interpolate(image, height, width, y, x, image_base_idx); + } + + image_base_idx += image.stride(1); + } + + grad_offset.write(ABSOLUTE_POS, grad_offset_val); + + #[comptime] + if let ComptimeOption::Some(grad_mask) = grad_mask { + if is_y_direction { + let idx = batch * grad_mask.stride(0) + + mask_pos_1 * grad_mask.stride(1) + + out_y * grad_mask.stride(2) + + out_x * grad_mask.stride(3); + + grad_mask[idx] = grad_mask_val + } + } +} + +#[cube] +fn get_coordinate_weight( + input: &Tensor, + offset: usize, + height: usize, + width: usize, + y: F, + x: F, + is_y_direction: bool, +) -> F { + let stride_y = input.stride(2); + let stride_x = input.stride(3); + + let y = f32::cast_from(y); + let x = f32::cast_from(x); + + let y_low = f32::floor(y); + let x_low = f32::floor(x); + let y_high = y_low + 1.; + let x_high = x_low + 1.; + + let valid_y_low = y_low >= 0. && y_low < height as f32; + let valid_y_high = y_high >= 0. && y_high < height as f32; + let valid_x_low = x_low >= 0. && x_low < width as f32; + let valid_x_high = x_high >= 0. && x_high < width as f32; + + let bottom_left = if valid_y_low && valid_x_low { + input[offset + y_low as usize * stride_y + x_low as usize * stride_x] + } else { + F::new(0.0_f32) + }; + let bottom_right = if valid_y_low && valid_x_high { + input[offset + y_low as usize * stride_y + x_high as usize * stride_x] + } else { + F::new(0.0_f32) + }; + let top_left = if valid_y_high && valid_x_low { + input[offset + y_high as usize * stride_y + x_low as usize * stride_x] + } else { + F::new(0.0_f32) + }; + let top_right = if valid_y_high && valid_x_high { + input[offset + y_high as usize * stride_y + x_high as usize * stride_x] + } else { + F::new(0.0_f32) + }; + + if is_y_direction { + let delta_x = F::cast_from(x - x_low); + delta_x * (top_right - bottom_right) + + (F::new(1.0_f32) - delta_x) * (top_left - bottom_left) + } else { + let delta_y = F::cast_from(y - y_low); + delta_y * (top_right - top_left) + + (F::new(1.0_f32) - delta_y) * (bottom_right - bottom_left) + } +} + +fn compute_input_grad( + columns: CubeTensor, + offset: CubeTensor, + mask: Option>, + options: &DeformConvOptions<2>, + kernel_dims: (usize, usize), + input_shape: Shape, +) -> Result, LaunchError> { + let client = offset.client.clone(); + let device = offset.device.clone(); + + let supports_fadd = client + .properties() + .atomic_type_usage(Type::atomic(FloatKind::F32)) + .contains(AtomicUsage::Add); + let supports_same_type = client + .properties() + .atomic_type_usage(Type::atomic(dtype_to_elem_type(columns.dtype))) + .contains(AtomicUsage::Add); + + let [batches, in_channels, height, width] = input_shape.dims(); + let [_, _, out_h, out_w] = offset.meta.shape().dims(); + let (kernel_h, kernel_w) = kernel_dims; + + let pos_shape = [in_channels, kernel_h, kernel_w, batches, out_h, out_w]; + let pos_shape = pos_shape.into_iter().collect(); + + let shape = Shape::new([batches, in_channels, height, width]); + let grad_in = match supports_fadd && supports_same_type { + // Use type as is to save a cast + true => zeros_client(client.clone(), device.clone(), shape, columns.dtype), + // Force `f32` to enable bitcasting as `u32`, or use intrinsic when supported + false => zeros_client(client.clone(), device.clone(), shape, DType::F32), + }; + let grad_arg = grad_in.clone().into_tensor_arg(); + + let num_elements = columns.meta.num_elements(); + let cube_dim = CubeDim::new(&offset.client, num_elements); + let cube_count = calculate_cube_count_elemwise(&offset.client, num_elements, cube_dim); + + let launch = match supports_fadd { + true => deform_col2img_kernel::launch_unchecked::, + false => deform_col2img_kernel::launch_unchecked::, + }; + let dtype = offset.dtype; + let dtypes: [StorageType; 2] = match supports_same_type { + true => [dtype_to_storage_type(dtype), dtype_to_storage_type(dtype)], + false => [ + dtype_to_storage_type(dtype), + dtype_to_storage_type(DType::F32), + ], + }; + + unsafe { + launch( + &grad_in.client, + cube_count, + cube_dim, + address_type!(offset, mask, columns, grad_in), + offset.into_tensor_arg(), + mask.map(|mask| mask.into_tensor_arg()).into(), + reshape(columns, Shape::new([num_elements])).into_linear_view(), + grad_arg, + pos_shape, + DeformConv2dCol2ImgArgsLaunch::new( + options.stride[0], + options.stride[1], + options.dilation[0], + options.dilation[1], + InputScalar::new(options.padding[0] as f32, dtypes[0].elem_type()), + InputScalar::new(options.padding[1] as f32, dtypes[0].elem_type()), + options.offset_groups, + kernel_h, + kernel_w, + ), + dtypes, + ) + }; + + Ok(if !supports_same_type || !supports_fadd { + cast(grad_in, dtype) + } else { + grad_in + }) +} + +#[derive(CubeLaunch, CubeType)] +struct DeformConv2dCol2ImgArgs { + stride_h: usize, + stride_w: usize, + dilation_h: usize, + dilation_w: usize, + pad_h: InputScalar, + pad_w: InputScalar, + offset_groups: usize, + kernel_height: usize, + kernel_width: usize, +} + +#[cube(launch_unchecked, address_type = "dynamic")] +fn deform_col2img_kernel( + offset: &Tensor, + mask: ComptimeOption<&Tensor>, + columns: LinearView<'_, F>, + grad_input: &mut Tensor>>, + pos_shape: Sequence>, + args: &DeformConv2dCol2ImgArgs, + #[define(F, FP)] _dtype: [StorageType; 2], +) { + // Position format: [[in_channels, kernel_h, kernel_w], [batch_size, out_h, out_w]] + if ABSOLUTE_POS >= columns.shape() { + terminate!(); + } + + let n_in_channels = grad_input.shape(1); + let height = grad_input.shape(2); + let width = grad_input.shape(3); + let kernel_h = args.kernel_height; + let kernel_w = args.kernel_width; + let n_offset_groups = args.offset_groups; + + let (_, pos) = decompose_linear(ABSOLUTE_POS, &pos_shape); + let [in_channel, kernel_y, kernel_x, batch, out_y, out_x] = *pos else { + unreachable!() + }; + + let channels_per_offset_group = n_in_channels / n_offset_groups; + let offset_group = in_channel / channels_per_offset_group; + + let offset_pos_1 = + offset_group * kernel_h * kernel_w * 2 + kernel_y * kernel_w * 2 + kernel_x * 2; + let offset_base_idx = batch * offset.stride(0) + + offset_pos_1 * offset.stride(1) + + out_y * offset.stride(2) + + out_x * offset.stride(3); + + let offset_y_idx = offset_base_idx; + let offset_x_idx = offset_base_idx + offset.stride(1); + + let offset_y = offset[offset_y_idx]; + let offset_x = offset[offset_x_idx]; + + #[comptime] + let mask_value = match mask { + ComptimeOption::Some(mask) => { + let mask_pos_1 = offset_group * kernel_h * kernel_w + kernel_y * kernel_w + kernel_x; + mask[batch * mask.stride(0) + + mask_pos_1 * mask.stride(1) + + out_y * mask.stride(2) + + out_x * mask.stride(3)] + } + ComptimeOption::None => F::new(1.0_f32), + }; + + let y = F::cast_from(out_y * args.stride_h + kernel_y * args.dilation_h) + - args.pad_h.get::() + + offset_y; + let x = F::cast_from(out_x * args.stride_w + kernel_x * args.dilation_w) + - args.pad_w.get::() + + offset_x; + + for dy in -1..=1i32 { + #[unroll] + for dx in -1..=1i32 { + let yp = y.floor() + F::cast_from(dy); + let xp = x.floor() + F::cast_from(dx); + + if yp >= F::new(0.0_f32) + && yp < F::cast_from(height) + && xp >= F::new(0.0_f32) + && xp < F::cast_from(width) + && F::abs(y - yp) < F::new(1.0_f32) + && F::abs(x - xp) < F::new(1.0_f32) + { + let gradient_pos = batch * grad_input.stride(0) + + in_channel * grad_input.stride(1) + + usize::cast_from(yp) * grad_input.stride(2) + + usize::cast_from(xp) * grad_input.stride(3); + + let weight = + (F::new(1.0_f32) - F::abs(y - yp)) * (F::new(1.0_f32) - F::abs(x - xp)); + + let value = mask_value * F::cast_from(weight) * columns.read(ABSOLUTE_POS); + + FAdd::Op::::float_atomic_add::(&mut grad_input[gradient_pos], value); + } + } + } +} + +type ProxyType = <::Op as FloatAtomicAdd>::ProxyType; + +#[cube] +trait FloatAtomicAddFamily: Send + Sync + 'static { + type Op: FloatAtomicAdd; +} + +#[cube] +trait FloatAtomicAdd: Send + Sync + 'static { + type ProxyType: Numeric; + + fn float_atomic_add(ptr: &mut Atomic, value: F); +} + +#[derive(CubeType)] +struct IntrinsicFloatAtomicAdd { + #[cube(comptime)] + _ty: PhantomData, +} + +#[derive(CubeType)] +struct CASFloatAtomicAdd; + +struct IntrinsicFloatAtomicAddFamily; + +impl FloatAtomicAddFamily for IntrinsicFloatAtomicAddFamily { + type Op = IntrinsicFloatAtomicAdd; +} + +impl FloatAtomicAddFamily for CASFloatAtomicAdd { + type Op = Self; +} + +#[cube] +impl FloatAtomicAdd for IntrinsicFloatAtomicAdd { + type ProxyType = FAdd; + + fn float_atomic_add(ptr: &mut Atomic, value: F) { + let value = FAdd::cast_from(value); + ptr.fetch_add(value); + } +} + +#[cube] +impl FloatAtomicAdd for CASFloatAtomicAdd { + type ProxyType = u32; + + fn float_atomic_add(ptr: &mut Atomic, value: F) { + let value = f32::cast_from(value); + if value != 0.0 { + let mut v = ptr.load(); + loop { + let prev = v; + let v_float = f32::from_bits(v); + let new = (v_float + value).to_bits(); + v = ptr.compare_exchange_weak(v, new); + if prev == v { + break; + } + } + } + } +} diff --git a/crates/burn-cubecl/src/kernel/conv/direct.rs b/crates/burn-cubecl/src/kernel/conv/direct.rs new file mode 100644 index 0000000..a74c98b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/direct.rs @@ -0,0 +1,321 @@ +use crate::{ + CubeRuntime, + kernel::{into_contiguous_aligned, utils::address_type}, + ops::max_vector_size, + tensor::CubeTensor, +}; +use crate::{kernel::utils::decompose_linear, ops::numeric::empty_device_dtype}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + TensorMetadata, + ops::{ConvOptions, conv::calculate_conv_output_sizes}, +}; +use cubecl::{ + calculate_cube_count_elemwise, prelude::*, std::tensor::layout::linear::LinearViewMut, + tensor_vector_size_parallel, +}; +use cubecl::{num_traits::Zero, std::FastDivmod}; +use cubek::convolution::components::ConvSetupError; + +#[derive(CubeLaunch, CubeType, Clone)] +pub(crate) struct ConvParam { + pub stride: u32, + pub dilation: u32, + pub padding: i32, +} + +#[derive(CubeLaunch, CubeType)] +struct Conv2dArgs { + conv_params: Sequence, + channels_per_group: u32, +} + +#[cube(launch_unchecked, address_type = "dynamic")] +#[allow(clippy::redundant_closure)] +fn direct_conv2d_kernel( + input: &Tensor>, + weight: &Tensor>, + bias: ComptimeOption<&[Vector]>, + mut output: LinearViewMut<'_, Vector>, + args: Conv2dArgs, + shape_out: Sequence>, + shape_out_c: FastDivmod, + #[comptime] has_padding: bool, + #[define(E)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let n_spatial = comptime![shape_out.len()]; + + let vector_size_out = output.vector_size(); + let pos = ABSOLUTE_POS * vector_size_out; + + let in_c_per_group = weight.shape(weight.rank() - 1) as u32; + + let (rem, out_c) = shape_out_c.div_mod(pos as u32); + let (b, spatial_pos) = decompose_linear(rem, &shape_out); + + let g = out_c / args.channels_per_group; + let ic_start = in_c_per_group * g; + + let bias: ComptimeOption> = + bias.map(|bias| bias[out_c as usize / vector_size_out]); + let mut sum = bias.unwrap_or_else(|| Vector::zero()); + + let in_offs = b as usize * input.stride(0) + ic_start as usize; + + let stride_oc = weight.stride(0); + + let mut in_shape = Sequence::new(); + let mut in_strides = Sequence::new(); + let mut kernel_shape = Sequence::new(); + let mut kernel_strides = Sequence::new(); + + #[unroll] + for i in 0..n_spatial { + in_shape.push(input.shape(i + 1) as u32); + in_strides.push(input.stride(i + 1)); + kernel_shape.push(weight.shape(i + 1) as u32); + kernel_strides.push(weight.stride(i + 1)); + } + + let weight_offs = out_c as usize * stride_oc; + + let loop_params = LoopParams { + out_pos: spatial_pos, + in_shape, + in_strides, + kernel_shape, + kernel_strides, + conv_params: args.conv_params, + in_c_per_group, + stride_oc, + }; + + kernel_loop( + input, + weight, + &mut sum, + in_offs, + true, + weight_offs, + &loop_params, + 0usize, + has_padding, + ); + + output.write(ABSOLUTE_POS, sum); +} + +#[derive(CubeType, Clone)] +struct LoopParams { + out_pos: Sequence, + in_shape: Sequence, + in_strides: Sequence, + kernel_shape: Sequence, + kernel_strides: Sequence, + conv_params: Sequence, + + in_c_per_group: u32, + stride_oc: usize, +} + +#[cube] +fn kernel_loop( + input: &Tensor>, + weight: &Tensor>, + sum: &mut Vector, + in_offs: usize, + in_bounds: bool, + weight_offs: usize, + params: &LoopParams, + #[comptime] kernel_dim: usize, + #[comptime] has_padding: bool, +) { + if comptime![kernel_dim < params.kernel_shape.len()] { + let out_idx = *params.out_pos.index(kernel_dim); + let conv = params.conv_params.index(kernel_dim); + let shape = *params.in_shape.index(kernel_dim); + let stride = *params.in_strides.index(kernel_dim); + let k_stride = *params.kernel_strides.index(kernel_dim); + + for pos in 0..*params.kernel_shape.index(kernel_dim) { + let in_pos = (out_idx * conv.stride + pos * conv.dilation) as i32 - conv.padding; + let in_offs = in_offs + in_pos as usize * stride; + let weight_offs = weight_offs + pos as usize * k_stride; + let mut in_bounds = in_bounds; + + if has_padding { + in_bounds &= in_pos >= 0 && (in_pos as u32) < shape; + } + + kernel_loop( + input, + weight, + sum, + in_offs, + in_bounds, + weight_offs, + params, + comptime![kernel_dim + 1], + has_padding, + ); + } + } else { + kernel_loop_inner( + input, + weight, + sum, + in_offs, + in_bounds, + weight_offs, + params.in_c_per_group, + params.stride_oc, + ); + } +} + +#[cube] +fn kernel_loop_inner( + input: &Tensor>, + weight: &Tensor>, + sum: &mut Vector, + in_offs: usize, + in_bounds: bool, + weight_offs: usize, + in_c_per_group: u32, + stride_oc: usize, +) { + let vector_size_in = input.vector_size(); + let vector_size_out = sum.size(); + + if in_bounds { + for in_c in range_stepped(0, in_c_per_group, vector_size_in as u32) { + let in_pos = in_offs + in_c as usize; + let mut weight_pos = weight_offs + in_c as usize; + + let val = input[in_pos / vector_size_in]; + + #[unroll] + for v in 0..vector_size_out { + let weight = weight[weight_pos / vector_size_in]; + let val = val * weight; + + #[unroll] + for i in 0..vector_size_in { + sum.insert(v, sum.extract(v) + val.extract(i)); + } + weight_pos += stride_oc; + } + } + } +} + +/// Perform a 2D convolution using the direct convolution algorithm. +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +/// +pub fn conv_direct( + mut input: CubeTensor, + mut weight: CubeTensor, + bias: Option>, + options: ConvOptions, +) -> Result, ConvSetupError> { + let out_dtype = input.dtype; + let rank = input.meta.shape().num_dims(); + let dim_c = rank - 1; + + // We only care about the channels here, everything else can be permuted + if input.meta.strides()[dim_c] != 1 { + input = into_contiguous_aligned(input); + } + if weight.meta.strides()[dim_c] != 1 { + weight = into_contiguous_aligned(weight); + } + + let batch_size = input.meta.shape()[0]; + let in_shape = &input.meta.shape()[1..dim_c]; + let out_channels = weight.meta.shape()[0]; + let kernel_shape = &weight.meta.shape()[1..dim_c]; + + let channels_per_group = out_channels / options.groups; + + let out_size = calculate_conv_output_sizes( + kernel_shape, + &options.stride, + &options.padding, + &options.dilation, + in_shape, + ); + + let mut shape_out = vec![batch_size]; + shape_out.extend(out_size.iter().copied()); + shape_out.push(out_channels); + + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + shape_out.into(), + out_dtype, + ); + + // Need custom vector size calculation here to account for the groups division. Need to vectorize + // over `channels_per_group` instead. + let mut grouped_out_shape = output.shape(); + grouped_out_shape[dim_c] = channels_per_group; + let vector_size_out = tensor_vector_size_parallel( + input.client.io_optimized_vector_sizes(input.dtype.size()), + &grouped_out_shape, + output.meta.strides(), + dim_c, + ); + // Use channels_per_group instead of in_channels to avoid issues here + let vector_size_in = max_vector_size(&weight); + + let shape_out = output.meta.shape()[1..dim_c] + .iter() + .map(|s| *s as u32) + .collect(); + let shape_out_c = out_channels as u32; + + let mut conv_params = SequenceArg::new(); + + for i in 0..kernel_shape.len() { + conv_params.push(ConvParamLaunch::new( + options.stride[i] as u32, + options.dilation[i] as u32, + options.padding[i] as i32, + )); + } + + let working_units = output.meta.num_elements() / vector_size_out; + let cube_dim = CubeDim::new(&input.client, working_units); + let cube_count = calculate_cube_count_elemwise(&input.client, working_units, cube_dim); + + unsafe { + direct_conv2d_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(input, weight, bias, output), + vector_size_in, + vector_size_out, + input.into_tensor_arg(), + weight.into_tensor_arg(), + bias.map(|b| b.into_buffer_arg()).into(), + output.clone().into_linear_view(), + Conv2dArgsLaunch::new(conv_params, channels_per_group as u32), + shape_out, + shape_out_c, + options.padding.iter().any(|it| *it != 0), + dtype_to_storage_type(out_dtype), + ) + }; + + Ok(output) +} diff --git a/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/launch.rs b/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/launch.rs new file mode 100644 index 0000000..ac2c710 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/launch.rs @@ -0,0 +1,171 @@ +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::{ConvOptions, conv::calculate_conv_output_sizes}; +use cubek::{ + convolution::{ + AcceleratedTileKind, ConvAlgorithm, ConvolutionArgs, ConvolutionInputs, Strategy, + components::ConvSetupError, launch_ref, + }, + matmul::definition::{MatmulElems, MatmulGlobalElems}, + std::InputBinding, +}; + +/// Perform a 2D convolution using the implicit GEMM (im2col) algorithm, using cubecl tiling matmul +/// components. Uses [`CmmaLargeMAlgorithm`] for the stage size +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +pub fn conv_gemm_simple_sync( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleSyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleSyncStrided, + }; + launch_convolution_forward::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + input, + weight, + bias, + options, + ) +} + +pub fn conv_gemm_simple_async( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + let algorithm = match tile_kind { + AcceleratedTileKind::Cmma => ConvAlgorithm::SimpleAsyncCyclic, + AcceleratedTileKind::Mma => ConvAlgorithm::SimpleAsyncStrided, + }; + launch_convolution_forward::( + &Strategy::Inferred { + algorithm, + tile_kind, + }, + input, + weight, + bias, + options, + ) +} + +/// Perform a 2D convolution using the implicit GEMM (im2col) algorithm, using cubecl tiling matmul +/// components. Uses [`CmmaLargeMAlgorithm`] for the stage size +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +pub fn conv_gemm_simple_tma( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, + tile_kind: AcceleratedTileKind, +) -> Result, ConvSetupError> { + launch_convolution_forward::( + &Strategy::Inferred { + algorithm: ConvAlgorithm::SimpleAsyncTma, + tile_kind, + }, + input, + weight, + bias, + options, + ) +} + +/// Perform a 2D convolution using the implicit GEMM (im2col) algorithm, using cubecl tiling matmul +/// components, using the specified algorithm. +/// +/// * `input` - The input feature map +/// * `weight` - The weights (filter) applied to each kernel +/// * `bias` - The bias added to each channel +/// * `options` - The options to use for the convolution +pub fn launch_convolution_forward( + strategy: &Strategy, + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, +) -> Result, ConvSetupError> { + if options.groups != 1 { + return Err(ConvSetupError::Groups(options.groups)); + } + + let out_dtype = input.dtype; + let rank = input.meta.shape().num_dims(); + let batch_size = input.meta.shape()[0]; + let dim_c = rank - 1; + let shape = &input.meta.shape()[1..dim_c]; + + let out_channels = weight.meta.shape()[0]; + let weight_shape = &weight.meta.shape()[1..dim_c]; + + let mut out_shape = calculate_conv_output_sizes( + weight_shape, + &options.stride, + &options.padding, + &options.dilation, + shape, + ); + + out_shape.insert(0, batch_size); + out_shape.push(out_channels); + + let out = empty_device_dtype( + input.client.clone(), + input.device.clone(), + out_shape.into(), + out_dtype, + ); + + let bias = bias.map(|bias| { + let dtype = bias.dtype; + InputBinding::Normal(bias.binding(), dtype_to_storage_type(dtype)) + }); + + let client = input.client.clone(); + let dtypes = MatmulElems::from_globals(&MatmulGlobalElems { + lhs: dtype_to_storage_type(input.dtype), + rhs: dtype_to_storage_type(weight.dtype), + out: dtype_to_storage_type(out_dtype), + }); + let input_dtype = input.dtype; + let weight_dtype = weight.dtype; + let input = InputBinding::new(input.binding(), dtype_to_storage_type(input_dtype)); + let weight = InputBinding::new(weight.binding(), dtype_to_storage_type(weight_dtype)); + + launch_ref::( + strategy, + &client, + ConvolutionInputs::Forward { + input, + weight, + bias, + out: out.clone().binding(), + }, + ConvolutionArgs { + stride: options.stride, + padding: options.padding, + dilation: options.dilation, + }, + dtypes, + )?; + + Ok(out) +} diff --git a/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/mod.rs b/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/mod.rs new file mode 100644 index 0000000..0df8c51 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/forward/implicit_gemm/mod.rs @@ -0,0 +1,2 @@ +pub mod launch; +pub use launch::*; diff --git a/crates/burn-cubecl/src/kernel/conv/forward/mod.rs b/crates/burn-cubecl/src/kernel/conv/forward/mod.rs new file mode 100644 index 0000000..d5091ae --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/forward/mod.rs @@ -0,0 +1,7 @@ +pub mod implicit_gemm; + +#[cfg(feature = "autotune")] +pub mod tune; + +#[cfg(feature = "autotune")] +pub(crate) use tune::*; diff --git a/crates/burn-cubecl/src/kernel/conv/forward/tune.rs b/crates/burn-cubecl/src/kernel/conv/forward/tune.rs new file mode 100644 index 0000000..d9d8f76 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/forward/tune.rs @@ -0,0 +1,187 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::ConvOptions; +use cubecl::{ + ir::StorageType, + tune::{LocalTuner, Tunable, TunableSet, anchor, local_tuner}, +}; +use cubek::convolution::AcceleratedTileKind; + +use crate::{ + CubeAutotuneKey, CubeRuntime, CubeTuneId, + kernel::conv::{ConvAutotuneKey, conv_direct, conv_im2col_1x1, forward::implicit_gemm::*}, + tensor::CubeTensor, +}; + +/// Executes autotune on convolution operations +pub fn conv_autotune( + input: CubeTensor, + weight: CubeTensor, + bias: Option>, + options: ConvOptions, +) -> CubeTensor { + let client = input.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + TunableSet::new(create_key::, create_conv_input::) + .with(Tunable::new( + "conv_direct", + |(input, weight, bias, options)| conv_direct::(input, weight, bias, options), + )) + .with(Tunable::new( + "conv_im2col_1x1", + |(input, weight, bias, options)| { + conv_im2col_1x1::(input, weight, bias, options) + }, + )) + .with(Tunable::new( + "simple_sync_cmma", + |(input, weight, bias, options)| { + conv_gemm_simple_sync(input, weight, bias, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_sync_mma", + |(input, weight, bias, options)| { + conv_gemm_simple_sync(input, weight, bias, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_async_cmma", + |(input, weight, bias, options)| { + conv_gemm_simple_async(input, weight, bias, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_async_mma", + |(input, weight, bias, options)| { + conv_gemm_simple_async(input, weight, bias, options, AcceleratedTileKind::Mma) + }, + )) + .with(Tunable::new( + "simple_tma_cmma", + |(input, weight, bias, options)| { + conv_gemm_simple_tma(input, weight, bias, options, AcceleratedTileKind::Cmma) + }, + )) + .with(Tunable::new( + "simple_tma_mma", + |(input, weight, bias, options)| { + conv_gemm_simple_tma(input, weight, bias, options, AcceleratedTileKind::Mma) + }, + )) + }); + + TUNER.execute( + &CubeTuneId::new(&input.client, &input.device), + &client, + tunables, + (input, weight, bias, options), + ) +} + +pub fn create_conv_input( + _key: &CubeAutotuneKey, + (input, weights, bias, options): &( + CubeTensor, + CubeTensor, + Option>, + ConvOptions, + ), +) -> ( + CubeTensor, + CubeTensor, + Option>, + ConvOptions, +) { + ( + input.clone(), + weights.clone(), + bias.clone(), + options.clone(), + ) +} + +fn create_key( + (input, weights, bias, options): &( + CubeTensor, + CubeTensor, + Option>, + ConvOptions, + ), +) -> CubeAutotuneKey { + let dtype = input.dtype; + let rank = input.meta.shape().num_dims(); + let dim_c = rank - 1; + + let batch_size = input.meta.shape()[0]; + let in_channels = input.meta.shape()[dim_c]; + let out_channels = weights.meta.shape()[0]; + + let kernel_size = weights.meta.shape()[1..dim_c].to_vec(); + let in_shape = input.meta.shape()[1..dim_c] + .iter() + .map(|shape| anchor(*shape, None, None, None)) + .collect(); + + let ConvOptions { + stride, + padding, + dilation, + groups, + } = options.clone(); + + let lhs_stride_align = if input.meta.strides()[dim_c] == 1 { + stride_align(input.meta.strides(), dtype_to_storage_type(input.dtype)) + } else { + 0 + }; + let lhs_shape_align = pow2_factor(in_channels).min(lhs_stride_align); + let rhs_stride_align = if weights.meta.strides()[dim_c] == 1 { + stride_align(weights.meta.strides(), dtype_to_storage_type(weights.dtype)) + } else { + 0 + }; + let rhs_shape_align = pow2_factor(in_channels).min(rhs_stride_align); + + CubeAutotuneKey::Conv(ConvAutotuneKey::new( + kernel_size, + stride.to_vec(), + padding.to_vec(), + dilation.to_vec(), + groups, + in_channels, + out_channels, + in_shape, + batch_size, + bias.is_some(), + dtype, + lhs_shape_align, + lhs_stride_align, + rhs_shape_align, + rhs_stride_align, + )) +} + +/// Maximum factor relevant for strides. Currently set to 2^10 because that's 128-byte swizzle's +/// repeat number, so it's the largest align that can have performance impacts. +const MAX_STRIDE_FACTOR: u32 = 10; + +/// Defines the non-contiguous stride alignment in terms of powers of two +fn stride_align(strides: &[usize], elem: StorageType) -> u8 { + let max = MAX_STRIDE_FACTOR; + let dim_c = strides.len() - 1; + let factor = strides[..dim_c] + .iter() + .map(|it| (*it * elem.size_bits()) / 8) + .map(|it| it.trailing_zeros()) + .min() + .unwrap_or(max); + factor.min(max) as u8 +} + +/// Defines the potential vectorization. +fn pow2_factor(axis: usize) -> u8 { + axis.trailing_zeros().min(4) as u8 +} diff --git a/crates/burn-cubecl/src/kernel/conv/im2col.rs b/crates/burn-cubecl/src/kernel/conv/im2col.rs new file mode 100644 index 0000000..6a73e30 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/im2col.rs @@ -0,0 +1,191 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + DType, + ops::{ConvOptions, conv::calculate_conv_output_sizes}, +}; +use burn_std::{Metadata, Shape}; +use core::iter; +use cubecl::{ + prelude::*, + std::tensor::{TensorHandle, into_contiguous_pitched}, +}; +use cubek::convolution::components::ConvSetupError; + +use crate::{ + CubeRuntime, + kernel::{ + AddOp, into_contiguous_aligned, launch_binop, + matmul::{MatmulStrategy, matmul}, + utils::split_dim, + }, + ops::{reshape, swap_dims}, + tensor::CubeTensor, +}; + +#[cfg(not(test))] +pub(crate) fn batches_per_run( + batch_size: usize, + out_shape: usize, + plane_size: usize, +) -> Result { + use cubek::matmul::definition::MatmulAvailabilityError; + + let cube_count_per_batch = out_shape.div_ceil(plane_size); + let max_cube_count = u16::MAX as usize; + let max_simultaneous = Ord::min(max_cube_count / cube_count_per_batch, batch_size); + if max_simultaneous == 0 { + return Err(MatmulAvailabilityError::CubeCountTooBig(CubeCount::Static( + cube_count_per_batch as u32, + 1, + 1, + )) + .into()); + } + Ok((0..=max_simultaneous) + .rev() + .find(|per_run| batch_size.is_multiple_of(*per_run)) + .expect("Logically not possible")) +} + +#[cfg(test)] +#[allow(unused)] +pub(crate) fn batches_per_run( + batch_size: usize, + out_shape: usize, + plane_size: usize, +) -> Result { + Ok(1) +} + +pub fn conv_im2col_1x1( + input: CubeTensor, + mut weight: CubeTensor, + bias: Option>, + options: ConvOptions, +) -> Result, ConvSetupError> { + if options.groups != 1 { + return Err(ConvSetupError::Groups(options.groups)); + } + + let rank = input.meta.num_dims(); + let dim_c = rank - 1; + + let batch_size = input.meta.shape()[0]; + let in_channels = input.meta.shape()[dim_c]; + let in_shape = &input.meta.shape()[1..dim_c]; + let out_channels = weight.meta.shape()[0]; + let kernel_shape = &weight.meta.shape()[1..dim_c]; + + if kernel_shape.iter().any(|s| *s != 1) { + return Err(ConvSetupError::Unknown); + } + + let out_shape = calculate_conv_output_sizes( + kernel_shape, + &options.stride, + &options.padding, + &options.dilation, + in_shape, + ); + + let mut split_m = vec![batch_size]; + split_m.extend(out_shape.iter().copied()); + + if kernel_shape.iter().any(|it| *it != 1) || in_shape != out_shape { + return Err(ConvSetupError::Unknown); + } + + let input = reshape_input(input); // [(NHW), C] : [M, K] + let dtype = input.dtype; + + // Efficient permutation that takes the stride required for TMA into account + let weight = if weight.meta.strides()[dim_c] != 1 { + // Remove kernel dims so padded dim is channels + *weight.meta = Metadata::new( + [out_channels, in_channels], // [N, K] + [weight.meta.strides()[0], weight.meta.strides()[dim_c]], + ); + // Pitched contiguous to skip running another kernel for TMA + into_contiguous_aligned(weight) + } else { + // Already compatible, skip initial reshape + *weight.meta = Metadata::new([out_channels, in_channels], [weight.meta.strides()[0], 1]); + weight + }; + + // Permute to N-major, while keeping memory layout K-major. K-major for both sides is the most + // efficient for matmul, and allows skipping a contiguous kernel + let weight = swap_dims(weight, 0, 1); // [K, N] + + let out = matmul(input, weight, None, MatmulStrategy::default(), dtype)?; // [M, N] + + // Skip reshape to avoid potential `into_contiguous`. We're only splitting dims so it's safe. + let mut out = split_dim(out, 0, &split_m); // [N, H, W, C] + + if let Some(bias) = bias { + let mut bias_shape = iter::repeat_n(1, rank - 1).collect::>(); + bias_shape.push(out_channels); + let bias = reshape(bias, bias_shape.into()); + out = launch_binop::(out, bias); + } + + Ok(out) +} + +/// Reshapes NHWC input to [(N, H, W), C] +fn reshape_input(input: CubeTensor) -> CubeTensor { + let rank = input.meta.num_dims(); + let dim_c = rank - 1; + let dtype = input.dtype; + + let batch_size = input.meta.shape()[0]; + let in_c: usize = input.meta.shape()[dim_c]; + let in_shape: Shape = input.meta.shape()[1..dim_c].into(); + + let mut input = if !is_spatial_contiguous(input.meta.shape(), input.meta.strides()) { + let (client, device) = (input.client.clone(), input.device.clone()); + let contiguous = + into_contiguous_pitched(&client, input.binding(), dtype_to_storage_type(dtype)); + from_handle(client, device, contiguous, dtype) + } else { + input + }; + + *input.meta = Metadata::new( + [batch_size * in_shape.num_elements(), in_c], // [M, K] + [input.meta.strides()[dim_c - 1], input.meta.strides()[dim_c]], + ); + input +} + +fn is_spatial_contiguous(shape: &[usize], strides: &[usize]) -> bool { + let rank = shape.len(); + let dim_c = rank - 1; + + // Channel must be contiguous for the [(N, H, W), C] reshape to be valid + if strides[dim_c] != 1 { + return false; + } + + for i in (1..dim_c).rev() { + if strides[i + 1] * shape[i + 1] != strides[i] { + return false; + } + } + true +} + +fn from_handle( + client: ComputeClient, + device: R::Device, + handle: TensorHandle, + dtype: DType, +) -> CubeTensor { + CubeTensor::new( + client.clone(), + handle.handle, + *handle.metadata, + device.clone(), + dtype, + ) +} diff --git a/crates/burn-cubecl/src/kernel/conv/mod.rs b/crates/burn-cubecl/src/kernel/conv/mod.rs new file mode 100644 index 0000000..03dde01 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/mod.rs @@ -0,0 +1,25 @@ +mod backward_data; +mod backward_weight; +mod base; +mod conv_transpose2d; +mod conv_transpose3d; +mod deform_conv2d; +mod deform_conv_transpose2d; +mod direct; +mod forward; +mod im2col; + +mod tune_key; + +pub(crate) use backward_data::*; +pub(crate) use conv_transpose2d::*; +pub(crate) use conv_transpose3d::*; +pub(crate) use deform_conv_transpose2d::*; +pub(crate) use deform_conv2d::*; +pub(crate) use direct::*; +pub(crate) use im2col::*; + +pub use base::*; +pub use conv_transpose2d::{ConvTranspose2dStrategy, conv_transpose2d}; + +pub(crate) use tune_key::*; diff --git a/crates/burn-cubecl/src/kernel/conv/tune_key.rs b/crates/burn-cubecl/src/kernel/conv/tune_key.rs new file mode 100644 index 0000000..1df268d --- /dev/null +++ b/crates/burn-cubecl/src/kernel/conv/tune_key.rs @@ -0,0 +1,50 @@ +use burn_backend::DType; +use cubecl::AutotuneKey; +use serde::{Deserialize, Serialize}; + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +/// Autotune key representative of matmul versions +pub struct ConvAutotuneKey { + pub kernel_size: Vec, + pub stride: Vec, + pub padding: Vec, + pub dilation: Vec, + pub groups: usize, + #[autotune(anchor)] + pub in_channels: usize, + #[autotune(anchor)] + pub out_channels: usize, + pub shape: Vec, + #[autotune(anchor)] + pub batch_size: usize, + pub has_bias: bool, + pub dtype: DType, + + pub lhs_shape_align: u8, + pub lhs_stride_align: u8, + pub rhs_shape_align: u8, + pub rhs_stride_align: u8, +} + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +/// Autotune key representative of matmul versions +pub struct ConvTranspose2dAutotuneKey { + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub padding_out: [usize; 2], + pub dilation: [usize; 2], + pub groups: usize, + #[autotune(anchor)] + pub in_channels: usize, + #[autotune(anchor)] + pub out_channels: usize, + #[autotune(anchor)] + pub height: usize, + #[autotune(anchor)] + pub width: usize, + #[autotune(anchor)] + pub batch_size: usize, + pub has_bias: bool, + pub dtype: DType, +} diff --git a/crates/burn-cubecl/src/kernel/cross.rs b/crates/burn-cubecl/src/kernel/cross.rs new file mode 100644 index 0000000..2dfc2fd --- /dev/null +++ b/crates/burn-cubecl/src/kernel/cross.rs @@ -0,0 +1,108 @@ +use crate::{ + CubeRuntime, + kernel::{ + into_contiguous, + utils::{address_type, broadcast_shape}, + }, + ops::{numeric::empty_device_dtype, swap_dims}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::std::tensor::layout::linear::{LinearView, LinearViewMut}; +use cubecl::{calculate_cube_count_elemwise, prelude::*}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn cross_kernel( + lhs: LinearView<'_, E>, + rhs: LinearView<'_, E>, + mut output: LinearViewMut<'_, E>, + #[define(E)] _dtype: StorageType, +) { + // Each thread processes one 3-element vector + let vector_idx = ABSOLUTE_POS; + let base_pos = vector_idx * 3; + + if !output.is_in_bounds(base_pos) { + terminate!(); + } + + // Extract vectors + let a0 = lhs.read(base_pos); + let a1 = lhs.read(base_pos + 1); + let a2 = lhs.read(base_pos + 2); + let b0 = rhs.read(base_pos); + let b1 = rhs.read(base_pos + 1); + let b2 = rhs.read(base_pos + 2); + + // Compute cross product: a × b + let x = a1 * b2 - a2 * b1; + let y = a2 * b0 - a0 * b2; + let z = a0 * b1 - a1 * b0; + + // Store result + output.write(base_pos, x); + output.write(base_pos + 1, y); + output.write(base_pos + 2, z); +} + +pub(crate) fn cross( + lhs: CubeTensor, + rhs: CubeTensor, + dim: usize, +) -> CubeTensor { + let ndims = lhs.meta.num_dims(); + + // Validate that the cross dimension has size 3 + if lhs.meta.shape()[dim] != 3 || rhs.meta.shape()[dim] != 3 { + panic!( + "Cross product requires dimension {} to have size 3, but got {} and {}", + dim, + lhs.meta.shape()[dim], + rhs.meta.shape()[dim] + ); + } + + // The kernel reads each 3-vector from contiguous memory, so it expects the + // cross dimension to be the last (innermost) and physically contiguous. + // For non-last dims we permute the cross dim to the last position, run the + // kernel, then permute the result back. swap_dims only updates strides, so + // make the permuted operands contiguous before launch. + if dim != ndims - 1 { + let last = ndims - 1; + let lhs = into_contiguous(swap_dims(lhs, dim, last)); + let rhs = into_contiguous(swap_dims(rhs, dim, last)); + let result = cross(lhs, rhs, last); + return swap_dims(result, dim, last); + } + + let output_shape = broadcast_shape(&[&lhs, &rhs]); + + let output = empty_device_dtype( + lhs.client.clone(), + lhs.device.clone(), + output_shape.clone(), + lhs.dtype, + ); + + // Number of vectors to process + let num_vectors = output_shape.num_elements() / 3; + + let cube_dim = CubeDim::new(&lhs.client, num_vectors); + let cube_count = calculate_cube_count_elemwise(&lhs.client, num_vectors, cube_dim); + let dtype = lhs.dtype; + + unsafe { + cross_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(lhs, rhs, output), + lhs.into_linear_view_like(&output), + rhs.into_linear_view_like(&output), + output.clone().into_linear_view(), + dtype_to_storage_type(dtype), + ); + }; + + output +} diff --git a/crates/burn-cubecl/src/kernel/ctc.rs b/crates/burn-cubecl/src/kernel/ctc.rs new file mode 100644 index 0000000..dc39fd9 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/ctc.rs @@ -0,0 +1,670 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::prelude::*; + +use crate::{ + CubeRuntime, kernel::into_contiguous, ops::numeric::empty_device_dtype, tensor::CubeTensor, +}; +use burn_backend::{Shape, TensorMetadata}; + +/// Maximum `2 * max_target_len + 1` the kernel supports. The alpha/beta state is +/// held in shared memory as two f32 buffers of this size (active row + scratch), +/// so peak shared use at full capacity is `2 * 8192 * 4 = 64 KB`. Apple Metal +/// caps shared memory at 32 KB per block, so the launch site sizes the buffer to +/// the actual per-batch `max_l_prime`; this constant is only the kernel-side +/// upper bound. Inputs exceeding it panic rather than silently degrade. +const SHARED_ALPHA_CAPACITY: u32 = 8192; + +/// Class label at position `s` of the blank-inserted label sequence `l'`. +/// Odd `s` reads the underlying target at index `(s-1)/2`; even `s` is a blank. +#[cube] +fn l_prime_class( + s: usize, + targets: &Tensor, + n: usize, + tgt_n: usize, + tgt_s: usize, + blank: usize, +) -> usize { + if s % 2 == 1 { + u32::cast_from(targets[n * tgt_n + ((s - 1) / 2) * tgt_s]) as usize + } else { + blank + } +} + +/// Numerically stable `log(exp(a) + exp(b))` with a sentinel short-circuit. +/// When `max(a, b) < unreachable_threshold`, returns `max(a, b)` directly so +/// the sentinel value doesn't drift upward each recursion step when both +/// inputs sit at the `-6e4` floor. +/// +/// The threshold's magnitude is forced by f16: the sentinel can't go below +/// `-65504` (f16 max magnitude), so it's `-6e4`, and the threshold has to sit +/// above the sentinel but below any plausible legit alpha value, leaving a +/// narrow band around `-1e4`. On sufficiently long sequences where legit +/// alpha values naturally drop below `-1e4` (roughly `T * log(1/C) < -1e4`), +/// reachable states get misclassified as unreachable. Mitigation is a +/// WGSL-only path with a smaller sentinel; WGSL spec 8.7 lets implementations +/// replace runtime `1/0` with zero, so `-inf` can't be synthesized reliably. +#[cube] +fn log_sum_exp2(a: F, b: F, unreachable_threshold: F, one: F) -> F { + let mut mx = a; + let mut mn = b; + if b > a { + mx = b; + mn = a; + } + if mx < unreachable_threshold { + mx + } else { + mx + (one + (mn - mx).exp()).ln() + } +} + +/// Single alpha (or beta) recurrence step. `near`, `near_m1`, `near_m2` are +/// the three values from the previous time row (alpha: `t-1`; beta: `t+1`). +/// `log_p` is the emission log-prob at the current `(t, l'[s])` and +/// `skip_allowed` toggles the 2-position skip transition. +#[cube] +fn recurrence_step( + near: F, + near_m1: F, + near_m2: F, + log_p: F, + skip_allowed: bool, + unreachable_threshold: F, + one: F, +) -> F { + let lse_01 = log_sum_exp2::(near, near_m1, unreachable_threshold, one); + let combined = if skip_allowed { + log_sum_exp2::(lse_01, near_m2, unreachable_threshold, one) + } else { + lse_01 + }; + log_p + combined +} + +/// Final `-log(alpha_last_blank + alpha_last_label)` reduction. Synthesizes a +/// true `+inf` via `exp()` overflow when both final alphas are at the sentinel +/// (the target is unreachable), so downstream `zero_infinity` logic can detect +/// it via `is_inf`. Builds the overflow arithmetically from a runtime-dependent +/// value (`target_len`, guaranteed >= 1 here) to keep WGSL's comptime-overflow +/// validator quiet. +#[cube] +fn finalize_nll( + last_blank: F, + last_label: F, + target_len: usize, + unreachable_threshold: F, + one: F, +) -> F { + let mut mx = last_blank; + let mut mn = last_label; + if last_label > last_blank { + mx = last_label; + mn = last_blank; + } + if mx < unreachable_threshold { + (F::new(1000.0_f32) * F::cast_from(target_len as u32)).exp() + } else { + F::new(0.0_f32) - (mx + (one + (mn - mx).exp()).ln()) + } +} + +/// Value to emit when `input_len == 0`. `target_len == 0` is the only case +/// with a valid alignment (P(empty | empty) = 1, nll = 0); otherwise the +/// target is unreachable and the output is `+inf` synthesized via overflow. +#[cube] +fn empty_input_nll(target_len: usize) -> F { + if target_len == 0 { + F::new(0.0_f32) + } else { + (F::new(1000.0_f32) * F::cast_from(target_len as u32)).exp() + } +} + +/// CTC alpha-recursion kernel. +/// +/// Each cube handles one batch element. `cube_dim.x` is fixed at launch time +/// (capped to the runtime's hardware limit); each thread strides over the `s` +/// positions of the modified label sequence `l'` (length `2 * target_len + 1`), +/// covering arbitrary target lengths up to `SHARED_ALPHA_CAPACITY`. `alpha` is +/// kept in shared memory and the time loop runs sequentially inside the kernel, +/// using two `sync_cube()` barriers per iteration: one to fence reads of +/// `alpha[t-1]` before any thread writes `alpha[t]`, one to publish the new row +/// before the next iteration. This collapses what would otherwise be roughly +/// `40 * T` host-side dispatches into a single kernel launch. +/// +/// Impossible alignments use a large finite negative sentinel (`-6.0e4`) +/// rather than true `-inf`, because WGSL rejects `f32(-inf)` as an identifier +/// and f16's range caps at ~65504. The recurrence treats values below a +/// threshold (`-1.0e4`) as unreachable. If an entire sequence has no valid +/// alignment (e.g. `target_length > input_length`), the kernel synthesizes +/// `+inf` in the output so downstream `zero_infinity` masking in `burn-nn` +/// can detect it via `is_inf`. +#[cube(launch)] +fn ctc_loss_kernel( + log_probs: &Tensor, // [T, N, C] + targets: &Tensor, // [N, S_max] + input_lengths: &Tensor, // [N] + target_lengths: &Tensor, // [N] + output: &mut Tensor, // [N] + blank: u32, + #[comptime] alpha_capacity: u32, + #[define(F, I)] _dtypes: [StorageType; 2], +) { + let n = CUBE_POS_X as usize; + let cube_dim = CUBE_DIM_X as usize; + let alpha_cap = alpha_capacity as usize; + let blank_u = blank as usize; + + let target_len = u32::cast_from(target_lengths[n]) as usize; + let input_len = u32::cast_from(input_lengths[n]) as usize; + let l_prime_len = 2 * target_len + 1; + + // Empty-input edge case: handled identically in both kernels to keep the + // forward loss and the backward nll agreeing for this sample. + if input_len == 0 { + if UNIT_POS_X == 0 { + output[n] = empty_input_nll::(target_len); + } + terminate!(); + } + + let lp_t = log_probs.stride(0); + let lp_n = log_probs.stride(1); + let lp_c = log_probs.stride(2); + let tgt_n = targets.stride(0); + let tgt_s = targets.stride(1); + + // Two adjacent regions: alpha[0..alpha_cap] is the active row, the second + // half [alpha_cap..2*alpha_cap] is a write scratch buffer that we copy back + // to the active region after a sync. This avoids RAW hazards across stride + // batches in the t-loop (a thread writing alpha[s] races with another + // thread still reading alpha[s-1] or alpha[s-2] for its own s). + let mut alpha = Shared::new_slice(2 * alpha_cap); + // Sentinel for unreachable states. f16 caps at ~65504 magnitude, so we + // can't go lower than `-6e4` without blowing past that range; WGSL also + // rejects `f32(-inf)` as an identifier, so a real -inf literal isn't an + // option anyway. On f32 the sentinel drifts slightly each recursion step + // (log(2) per step when both log_sum_exp inputs sit at the sentinel), + // which is why the recurrence compares against a threshold instead of + // checking `== neg_inf`. See `log_sum_exp2` for the long-sequence caveat. + let neg_inf = F::new(-6.0e4_f32); + let unreachable_threshold = F::new(-1.0e4_f32); + let one = F::new(1.0_f32); + + // Initialize alpha at t = 0 for s < l_prime_len; positions beyond that + // are never read by the recurrence (s < l_prime_len in every read) so + // they don't need to be touched. + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let mut init = neg_inf; + if s == 0 { + init = log_probs[n * lp_n + blank_u * lp_c]; + } else if s == 1 { + let l1 = u32::cast_from(targets[n * tgt_n]) as usize; + init = log_probs[n * lp_n + l1 * lp_c]; + } + alpha[s] = init; + s += cube_dim; + } + sync_cube(); + + // Sequential time loop. Each iteration re-strides over s positions to + // compute alpha[t, s] from alpha[t-1, *] and writes back to the same + // shared memory after a full read fence. + for t in 1..input_len { + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let l_class = l_prime_class::(s, targets, n, tgt_n, tgt_s, blank_u); + let log_p = log_probs[t * lp_t + n * lp_n + l_class * lp_c]; + + let l_class_m2 = if s >= 2 { + l_prime_class::(s - 2, targets, n, tgt_n, tgt_s, blank_u) + } else { + blank_u + }; + let skip_allowed = s >= 2 && l_class != blank_u && l_class != l_class_m2; + + let a_s = alpha[s]; + let mut a_s_m1 = neg_inf; + if s >= 1 { + a_s_m1 = alpha[s - 1]; + } + let mut a_s_m2 = neg_inf; + if s >= 2 { + a_s_m2 = alpha[s - 2]; + } + + alpha[alpha_cap + s] = recurrence_step::( + a_s, + a_s_m1, + a_s_m2, + log_p, + skip_allowed, + unreachable_threshold, + one, + ); + s += cube_dim; + } + sync_cube(); + + // Second pass: copy scratch back into the active alpha slots. + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + alpha[s] = alpha[alpha_cap + s]; + s += cube_dim; + } + sync_cube(); + } + + // Reduce: only thread 0 writes the output for this batch element. + if UNIT_POS_X == 0 { + let last_blank = alpha[2 * target_len]; + // Guard target_len = 0: index 2*0 - 1 underflows. Use -inf so + // log_sum_exp(last_blank, -inf) = last_blank (log_sum_exp(x, x) = x+ln2 + // would be wrong here). + let mut last_label = neg_inf; + if target_len > 0 { + last_label = alpha[2 * target_len - 1]; + } + output[n] = finalize_nll::( + last_blank, + last_label, + target_len, + unreachable_threshold, + one, + ); + } +} + +/// Fused CTC loss for burn-cubecl. Single kernel launch covers the entire +/// alpha recursion across all timesteps. +/// +/// Panics if `2 * max_target_len + 1` exceeds `SHARED_ALPHA_CAPACITY` (8192). +pub fn ctc_loss( + log_probs: CubeTensor, + targets: CubeTensor, + input_lengths: CubeTensor, + target_lengths: CubeTensor, + blank: usize, +) -> CubeTensor { + // Manual stride indexing below requires a contiguous physical layout; + // fusion-produced tensors may arrive with layouts that break that + // assumption. No-op when already contiguous. + let log_probs = into_contiguous(log_probs); + let targets = into_contiguous(targets); + let input_lengths = into_contiguous(input_lengths); + let target_lengths = into_contiguous(target_lengths); + + let log_probs_shape = log_probs.shape(); + let [_t, batch_size, _c] = log_probs_shape.dims::<3>(); + let target_shape = targets.shape(); + let max_target_len = target_shape.dims::<2>()[1]; + let max_l_prime = 2 * max_target_len + 1; + + assert!( + max_l_prime as u32 <= SHARED_ALPHA_CAPACITY, + "ctc_loss: 2 * max_target_len + 1 = {} exceeds the kernel's shared-memory \ + alpha capacity ({}). Reduce target length or raise SHARED_ALPHA_CAPACITY.", + max_l_prime, + SHARED_ALPHA_CAPACITY, + ); + + // Pick a thread count that fits the runtime's per-cube limit. We don't + // need one thread per s position - threads stride over s. + let hw_max = log_probs.client.properties().hardware.max_cube_dim.0; + let cube_dim_x = (max_l_prime as u32).min(hw_max).min(256); + + let client = log_probs.client.clone(); + let device = log_probs.device.clone(); + let f_dtype = log_probs.dtype; + let i_dtype = targets.dtype; + let output = empty_device_dtype::(client.clone(), device, Shape::new([batch_size]), f_dtype); + + let cube_count = CubeCount::Static(batch_size as u32, 1, 1); + let cube_dim = CubeDim::new_1d(cube_dim_x); + + // Pass the actual max_l_prime (not the static capacity) so shared memory + // is sized to what we need. Metal limits threadgroup memory to 32 KB; + // allocating 2 * 8192 * sizeof(f32) = 64 KB would silently corrupt on + // Apple GPUs. Different max_l_prime values trigger separate kernel + // compilations (it's a comptime param), but that's fine: target lengths + // are stable within a dataset. + ctc_loss_kernel::launch::( + &client, + cube_count, + cube_dim, + log_probs.into_tensor_arg(), + targets.into_tensor_arg(), + input_lengths.into_tensor_arg(), + target_lengths.into_tensor_arg(), + output.clone().into_tensor_arg(), + blank as u32, + max_l_prime as u32, + [ + dtype_to_storage_type(f_dtype), + dtype_to_storage_type(i_dtype), + ], + ); + + output +} + +/// Fused CTC alpha + beta recursion kernel. +/// +/// Runs the full forward alpha recursion and reverse beta recursion for one +/// batch element per cube, reusing the same shared-memory layout twice. +/// Writes `alpha_out[T, N, 2S+1]`, `beta_out[T, N, 2S+1]` and the per-sample +/// negative log-likelihood `nll_out[N]`. The three outputs are everything the +/// default CTC gradient-composition helper needs, so the caller can finish the +/// backward pass with a handful of element-wise tensor ops. +/// +/// The alpha phase is identical to `ctc_loss_kernel` except it additionally +/// publishes each row to global memory. The beta phase mirrors it in reverse: +/// initialize at `t = input_len - 1` from `log_probs[t, l'[s]]` at the two +/// boundary `s` positions, then step backward reading `beta[t+1, s]`, +/// `beta[t+1, s+1]`, and (when the skip transition is allowed) `beta[t+1, s+2]`. +#[cube(launch)] +fn ctc_alpha_beta_kernel( + log_probs: &Tensor, // [T, N, C] + targets: &Tensor, // [N, S_max] + input_lengths: &Tensor, // [N] + target_lengths: &Tensor, // [N] + alpha_out: &mut Tensor, // [T, N, 2S+1] + beta_out: &mut Tensor, // [T, N, 2S+1] + nll_out: &mut Tensor, // [N] + blank: u32, + #[comptime] alpha_capacity: u32, + #[define(F, I)] _dtypes: [StorageType; 2], +) { + let n = CUBE_POS_X as usize; + let cube_dim = CUBE_DIM_X as usize; + let alpha_cap = alpha_capacity as usize; + let blank_u = blank as usize; + + let target_len = u32::cast_from(target_lengths[n]) as usize; + let input_len = u32::cast_from(input_lengths[n]) as usize; + let l_prime_len = 2 * target_len + 1; + + // Empty input: alpha_out and beta_out stay at the host-side -inf pre-fill. + // Emit the semantically correct nll (0 for target_len=0, +inf otherwise). + if input_len == 0 { + if UNIT_POS_X == 0 { + nll_out[n] = empty_input_nll::(target_len); + } + terminate!(); + } + + let lp_t = log_probs.stride(0); + let lp_n = log_probs.stride(1); + let lp_c = log_probs.stride(2); + let tgt_n = targets.stride(0); + let tgt_s = targets.stride(1); + let ao_t = alpha_out.stride(0); + let ao_n = alpha_out.stride(1); + let ao_s = alpha_out.stride(2); + let bo_t = beta_out.stride(0); + let bo_n = beta_out.stride(1); + let bo_s = beta_out.stride(2); + + // Shared memory layout: [0..alpha_cap] is the active row; [alpha_cap..2*alpha_cap] + // is scratch for the next row. Same layout is reused for alpha and beta. Beta + // reads are guarded by `s + 1 < l_prime_len` / `s + 2 < l_prime_len`, so the + // residual alpha values sitting in the active row between phases are never + // observed by beta (its boundary init overwrites every slot it reads). + let mut state = Shared::new_slice(2 * alpha_cap); + // Sentinel for unreachable states. See ctc_loss_kernel for the full + // rationale: f16's 65504 magnitude cap forces the -6e4 floor, WGSL + // rejects f32(-inf) literals, and the threshold catches sentinel drift. + let neg_inf = F::new(-6.0e4_f32); + let unreachable_threshold = F::new(-1.0e4_f32); + let one = F::new(1.0_f32); + + // Alpha phase (forward). + // + // Initialize alpha at t = 0 for s < l_prime_len. Positions beyond + // l_prime_len are never read by the recurrence, so they don't need + // to be touched in shared memory; and they stay at the host-side -inf + // pre-fill in alpha_out. + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let mut init = neg_inf; + if s == 0 { + init = log_probs[n * lp_n + blank_u * lp_c]; + } else if s == 1 { + let l1 = u32::cast_from(targets[n * tgt_n]) as usize; + init = log_probs[n * lp_n + l1 * lp_c]; + } + state[s] = init; + alpha_out[n * ao_n + s * ao_s] = init; + s += cube_dim; + } + sync_cube(); + + for t in 1..input_len { + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let l_class = l_prime_class::(s, targets, n, tgt_n, tgt_s, blank_u); + let log_p = log_probs[t * lp_t + n * lp_n + l_class * lp_c]; + + let l_class_m2 = if s >= 2 { + l_prime_class::(s - 2, targets, n, tgt_n, tgt_s, blank_u) + } else { + blank_u + }; + let skip_allowed = s >= 2 && l_class != blank_u && l_class != l_class_m2; + + let a_s = state[s]; + let mut a_s_m1 = neg_inf; + if s >= 1 { + a_s_m1 = state[s - 1]; + } + let mut a_s_m2 = neg_inf; + if s >= 2 { + a_s_m2 = state[s - 2]; + } + + state[alpha_cap + s] = recurrence_step::( + a_s, + a_s_m1, + a_s_m2, + log_p, + skip_allowed, + unreachable_threshold, + one, + ); + s += cube_dim; + } + sync_cube(); + + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + state[s] = state[alpha_cap + s]; + alpha_out[t * ao_t + n * ao_n + s * ao_s] = state[s]; + s += cube_dim; + } + sync_cube(); + } + + if UNIT_POS_X == 0 { + let last_blank = state[2 * target_len]; + // See ctc_loss_kernel: -inf sentinel keeps log_sum_exp correct for target_len = 0. + let mut last_label = neg_inf; + if target_len > 0 { + last_label = state[2 * target_len - 1]; + } + nll_out[n] = finalize_nll::( + last_blank, + last_label, + target_len, + unreachable_threshold, + one, + ); + } + + // Fence thread 0's read of state[2*target_len] / state[2*target_len - 1] + // against the beta boundary init, which writes those same positions. + sync_cube(); + + // Beta phase (reverse). + // + // Boundary initialization at t = input_len - 1: set beta[s] = log_probs[t, l'[s]] + // at s = 2*target_len, and when target_len > 0 also at s = 2*target_len - 1. + // All other s positions in range get -inf. + let t_last = input_len - 1; + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let is_last_blank = s == 2 * target_len; + let is_last_label = target_len > 0 && s == 2 * target_len - 1; + let mut init = neg_inf; + if is_last_blank || is_last_label { + let l_class = l_prime_class::(s, targets, n, tgt_n, tgt_s, blank_u); + init = log_probs[t_last * lp_t + n * lp_n + l_class * lp_c]; + } + state[s] = init; + beta_out[t_last * bo_t + n * bo_n + s * bo_s] = init; + s += cube_dim; + } + sync_cube(); + + // Step back from t = input_len - 2 down to t = 0. + for t_rev in 1..input_len { + let t = input_len - 1 - t_rev; + + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + let l_class = l_prime_class::(s, targets, n, tgt_n, tgt_s, blank_u); + let log_p = log_probs[t * lp_t + n * lp_n + l_class * lp_c]; + + let l_class_p2 = if s + 2 < l_prime_len { + l_prime_class::(s + 2, targets, n, tgt_n, tgt_s, blank_u) + } else { + blank_u + }; + let skip_allowed = s + 2 < l_prime_len && l_class != blank_u && l_class != l_class_p2; + + let b_s = state[s]; + let mut b_s_p1 = neg_inf; + if s + 1 < l_prime_len { + b_s_p1 = state[s + 1]; + } + let mut b_s_p2 = neg_inf; + if s + 2 < l_prime_len { + b_s_p2 = state[s + 2]; + } + + state[alpha_cap + s] = recurrence_step::( + b_s, + b_s_p1, + b_s_p2, + log_p, + skip_allowed, + unreachable_threshold, + one, + ); + s += cube_dim; + } + sync_cube(); + + let mut s = UNIT_POS_X as usize; + while s < l_prime_len { + state[s] = state[alpha_cap + s]; + beta_out[t * bo_t + n * bo_n + s * bo_s] = state[s]; + s += cube_dim; + } + sync_cube(); + } +} + +/// Host entry point for the fused alpha + beta + nll kernel. +/// +/// Returns `(log_alpha_full, log_beta_full, nll)` with shapes +/// `([T, N, 2S+1], [T, N, 2S+1], [N])`. Positions outside the valid +/// `(t < input_length, s < 2*target_length+1)` rectangle hold the +/// pre-fill value `-inf`, matching the default backend's convention. +/// +/// Panics if `2 * max_target_len + 1` exceeds `SHARED_ALPHA_CAPACITY`. +pub fn ctc_alpha_beta( + log_probs: CubeTensor, + targets: CubeTensor, + input_lengths: CubeTensor, + target_lengths: CubeTensor, + blank: usize, +) -> (CubeTensor, CubeTensor, CubeTensor) { + // Manual stride indexing below requires a contiguous physical layout; + // fusion-produced tensors may arrive with layouts that break that + // assumption. No-op when already contiguous. + let log_probs = into_contiguous(log_probs); + let targets = into_contiguous(targets); + let input_lengths = into_contiguous(input_lengths); + let target_lengths = into_contiguous(target_lengths); + + let log_probs_shape = log_probs.shape(); + let [max_input_length, batch_size, _c] = log_probs_shape.dims::<3>(); + let target_shape = targets.shape(); + let max_target_len = target_shape.dims::<2>()[1]; + let max_l_prime = 2 * max_target_len + 1; + + assert!( + max_l_prime as u32 <= SHARED_ALPHA_CAPACITY, + "ctc_loss_backward: 2 * max_target_len + 1 = {} exceeds the kernel's shared-memory \ + alpha capacity ({}). Reduce target length or raise SHARED_ALPHA_CAPACITY.", + max_l_prime, + SHARED_ALPHA_CAPACITY, + ); + + let hw_max = log_probs.client.properties().hardware.max_cube_dim.0; + let cube_dim_x = (max_l_prime as u32).min(hw_max).min(256); + + let client = log_probs.client.clone(); + let device = log_probs.device.clone(); + let f_dtype = log_probs.dtype; + let i_dtype = targets.dtype; + + // Pre-fill alpha/beta with -inf so positions the kernel doesn't touch + // (s >= 2U+1, or t outside the valid range for an individual batch + // element) are not read as stale zeros by the gradient composition. + let shape_abt = Shape::new([max_input_length, batch_size, max_l_prime]); + let neg_inf = InputScalar::new(f32::NEG_INFINITY, dtype_to_storage_type(f_dtype)); + let alpha_out = crate::ops::numeric::full_device_dtype::( + client.clone(), + shape_abt.clone(), + device.clone(), + neg_inf, + f_dtype, + ); + let beta_out = crate::ops::numeric::full_device_dtype::( + client.clone(), + shape_abt, + device.clone(), + neg_inf, + f_dtype, + ); + let nll_out = + empty_device_dtype::(client.clone(), device, Shape::new([batch_size]), f_dtype); + + let cube_count = CubeCount::Static(batch_size as u32, 1, 1); + let cube_dim = CubeDim::new_1d(cube_dim_x); + + ctc_alpha_beta_kernel::launch::( + &client, + cube_count, + cube_dim, + log_probs.into_tensor_arg(), + targets.into_tensor_arg(), + input_lengths.into_tensor_arg(), + target_lengths.into_tensor_arg(), + alpha_out.clone().into_tensor_arg(), + beta_out.clone().into_tensor_arg(), + nll_out.clone().into_tensor_arg(), + blank as u32, + max_l_prime as u32, + [ + dtype_to_storage_type(f_dtype), + dtype_to_storage_type(i_dtype), + ], + ); + + (alpha_out, beta_out, nll_out) +} diff --git a/crates/burn-cubecl/src/kernel/fft/base.rs b/crates/burn-cubecl/src/kernel/fft/base.rs new file mode 100644 index 0000000..3aded0c --- /dev/null +++ b/crates/burn-cubecl/src/kernel/fft/base.rs @@ -0,0 +1,164 @@ +use crate::kernel::index::slice; +use crate::ops::numeric::{empty_device_dtype, zeros}; +use crate::{CubeRuntime, tensor::CubeTensor}; +use burn_backend::{DType, TensorMetadata}; +use burn_std::Slice; +use cubecl::prelude::*; +use cubek::fft::{irfft_launch, rfft_launch}; + +// Materializes a padded tensor (allocate + copy) because rfft_launch/irfft_launch +// in the external cubek crate don't support virtual padding via a length parameter. +// See: https://github.com/tracel-ai/cubek/issues/194 +fn pad_to_length( + tensor: CubeTensor, + dim: usize, + target: usize, +) -> CubeTensor { + let shape = tensor.shape(); + let current = shape[dim]; + if current == target { + return tensor; + } + if current > target { + let ranges: Vec<_> = shape + .iter() + .enumerate() + .map(|(i, &s)| if i == dim { 0..target } else { 0..s }) + .collect(); + return slice(tensor, &ranges); + } + let mut padded_shape = shape.clone(); + padded_shape[dim] = target; + let padded = zeros::(tensor.device.clone(), padded_shape, tensor.dtype); + let slices: Vec = shape.iter().map(|&s| Slice::from(0..s)).collect(); + crate::kernel::index::slice_assign::(padded, &slices, tensor) +} + +/// Launch the rfft kernel with optional padding for non-power-of-two sizes. +/// +/// Signal is first truncated or zero-padded to `n` (when provided), then internally +/// padded to the next power of two so the kernel operates on a pow2 length. +/// Output bin count is `fft_size / 2 + 1` where `fft_size = next_pow2(n)`. +pub fn rfft( + signal: CubeTensor, + dim: usize, + n: Option, +) -> (CubeTensor, CubeTensor) { + let dtype = match signal.dtype { + DType::F64 => f64::as_type_native_unchecked().storage_type(), + DType::F32 => f32::as_type_native_unchecked().storage_type(), + _ => panic!("Unsupported type {:?}", signal.dtype), + }; + + let input_device = signal.device.clone(); + let input_dtype = signal.dtype; + let input_shape = signal.shape(); + let requested_n = n.unwrap_or(input_shape[dim]); + let fft_size = requested_n.next_power_of_two(); + + // Truncate/pad to requested_n, THEN pad to fft_size; otherwise for + // requested_n < input_len < fft_size we would keep bogus samples in [n, fft_size). + let signal = pad_to_length(signal, dim, requested_n); + let signal = pad_to_length(signal, dim, fft_size); + + let signal_shape = signal.shape(); + let mut output_shape = signal_shape.clone(); + output_shape[dim] = fft_size / 2 + 1; + + let output_re = empty_device_dtype( + signal.client.clone(), + signal.device.clone(), + output_shape.clone(), + signal.dtype, + ); + let output_im = empty_device_dtype( + signal.client.clone(), + signal.device.clone(), + output_shape.clone(), + signal.dtype, + ); + + rfft_launch( + &signal.client.clone(), + signal.binding(), + output_re.clone().binding(), + output_im.clone().binding(), + dim, + dtype, + ) + .unwrap_or_else(|e| { + panic!( + "rfft kernel launch failed (device={input_device:?}, dtype={input_dtype:?}, \ + dim={dim}, requested_n={requested_n}, fft_size={fft_size}): {e}" + ) + }); + + (output_re, output_im) +} + +/// Launch the irfft kernel with optional padding for non-power-of-two sizes. +pub fn irfft( + spectrum_re: CubeTensor, + spectrum_im: CubeTensor, + dim: usize, + n: Option, +) -> CubeTensor { + assert!( + spectrum_re.shape() == spectrum_im.shape(), + "irfft: spectrum_re and spectrum_im shapes must match" + ); + assert!( + spectrum_re.shape()[dim] >= 1, + "irfft: spectrum dimension cannot be empty" + ); + assert!( + !matches!(n, Some(0)), + "irfft: n must be >= 1 when specified, got Some(0)" + ); + + let dtype = match spectrum_re.dtype { + DType::F64 => f64::as_type_native_unchecked().storage_type(), + DType::F32 => f32::as_type_native_unchecked().storage_type(), + _ => panic!("Unsupported type {:?}", spectrum_re.dtype), + }; + + let input_device = spectrum_re.device.clone(); + let input_dtype = spectrum_re.dtype; + let requested_n = n.unwrap_or((spectrum_re.shape()[dim] - 1) * 2); + let fft_size = requested_n.next_power_of_two().max(1); + let half_fft = fft_size / 2 + 1; + + let spectrum_re = pad_to_length(spectrum_re, dim, half_fft); + let spectrum_im = pad_to_length(spectrum_im, dim, half_fft); + + let mut signal_shape = spectrum_re.shape().clone(); + signal_shape[dim] = fft_size; + + let signal = empty_device_dtype( + spectrum_re.client.clone(), + spectrum_re.device.clone(), + signal_shape, + spectrum_re.dtype, + ); + + irfft_launch( + &spectrum_re.client.clone(), + spectrum_re.binding(), + spectrum_im.binding(), + signal.clone().binding(), + dim, + dtype, + ) + .unwrap_or_else(|e| { + panic!( + "irfft kernel launch failed (device={input_device:?}, dtype={input_dtype:?}, \ + dim={dim}, requested_n={requested_n}, fft_size={fft_size}): {e}" + ) + }); + + if fft_size > requested_n { + pad_to_length(signal, dim, requested_n) + } else { + signal + } +} diff --git a/crates/burn-cubecl/src/kernel/fft/mod.rs b/crates/burn-cubecl/src/kernel/fft/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-cubecl/src/kernel/fft/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-cubecl/src/kernel/grid_sample/base.rs b/crates/burn-cubecl/src/kernel/grid_sample/base.rs new file mode 100644 index 0000000..ef8d36a --- /dev/null +++ b/crates/burn-cubecl/src/kernel/grid_sample/base.rs @@ -0,0 +1,163 @@ +use cubecl::prelude::*; + +use crate::{CubeRuntime, tensor::CubeTensor}; +use burn_backend::ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}; + +use super::bilinear::grid_sample_bilinear_launch; + +/// Grid sample operation supporting bilinear interpolation +pub fn grid_sample( + input: CubeTensor, + grid: CubeTensor, + options: GridSampleOptions, +) -> CubeTensor { + match options.mode { + InterpolateMode::Bilinear => grid_sample_bilinear_launch(input, grid, options), + _ => panic!( + "Unsupported grid_sample interpolation mode: {:?}", + options.mode + ), + } +} + +/// Compile-time padding mode for kernel specialization +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PaddingMode { + /// Fill with zeros for out-of-bounds coordinates. + Zeros, + /// Clamp coordinates to the border (use nearest edge value). + Border, + /// Reflect coordinates at the boundary. + Reflection, +} + +impl From for PaddingMode { + fn from(mode: GridSamplePaddingMode) -> Self { + match mode { + GridSamplePaddingMode::Zeros => PaddingMode::Zeros, + GridSamplePaddingMode::Border => PaddingMode::Border, + GridSamplePaddingMode::Reflection => PaddingMode::Reflection, + } + } +} + +/// Fetch value based on padding mode (dispatch to appropriate handler) +#[cube] +pub(crate) fn fetch_value( + input: &Tensor, + base: usize, + stride_h: usize, + stride_w: usize, + y: i32, + x: i32, + h: i32, + w: i32, + #[comptime] padding_mode: PaddingMode, +) -> F { + match padding_mode { + PaddingMode::Zeros => fetch_with_zeros(input, base, stride_h, stride_w, y, x, h, w), + PaddingMode::Border => fetch_with_border(input, base, stride_h, stride_w, y, x, h, w), + PaddingMode::Reflection => { + fetch_with_reflection(input, base, stride_h, stride_w, y, x, h, w) + } + } +} + +/// Fetch value with zeros padding (return 0 for out-of-bounds). +#[cube] +pub(crate) fn fetch_with_zeros( + input: &Tensor, + base: usize, + stride_h: usize, + stride_w: usize, + y: i32, + x: i32, + h: i32, + w: i32, +) -> F { + let in_bounds = x >= 0 && x < w && y >= 0 && y < h; + let x_clamped = clamp(x, 0, w - 1) as usize; + let y_clamped = clamp(y, 0, h - 1) as usize; + let idx = base + y_clamped * stride_h + x_clamped * stride_w; + select(in_bounds, input[idx], F::new(0.0_f32)) +} + +/// Fetch value with border padding (clamp to edge). +#[cube] +pub(crate) fn fetch_with_border( + input: &Tensor, + base: usize, + stride_h: usize, + stride_w: usize, + y: i32, + x: i32, + h: i32, + w: i32, +) -> F { + let x_clamped = clamp(x, 0, w - 1) as usize; + let y_clamped = clamp(y, 0, h - 1) as usize; + let idx = base + y_clamped * stride_h + x_clamped * stride_w; + input[idx] +} + +/// Fetch value with reflection padding. +/// Assumes float reflection was applied to center, so indices are at most 2 steps out of bounds. +#[cube] +pub(crate) fn fetch_with_reflection( + input: &Tensor, + base: usize, + stride_h: usize, + stride_w: usize, + y: i32, + x: i32, + h: i32, + w: i32, +) -> F { + let x_reflected = reflect_coord_bounded(x, w); + let y_reflected = reflect_coord_bounded(y, h); + let idx = base + y_reflected * stride_h + x_reflected * stride_w; + input[idx] +} + +/// Reflect an integer index that may be out of bounds. +/// After float reflection, indices can be up to 2 steps out for bicubic (1 step for bilinear). +#[cube] +fn reflect_coord_bounded(idx: i32, size: i32) -> usize { + let max_idx = size - 1; + let neg_reflected = -idx - 1; + let pos_reflected = 2 * max_idx + 1 - idx; + let result = select( + idx < 0, + neg_reflected, + select(idx > max_idx, pos_reflected, idx), + ); + clamp(result, 0, max_idx) as usize +} + +/// Reflect a float coordinate into the valid sampling range. +#[cube] +pub(crate) fn reflect_coord(coord: F, size: u32, #[comptime] align_corners: bool) -> F { + let size_f = F::cast_from(size); + if align_corners { + reflect_float_impl::(coord, F::new(0.0_f32), size_f - F::new(1.0_f32)) + } else { + reflect_float_impl::(coord, F::new(-0.5_f32), size_f - F::new(0.5_f32)) + } +} + +/// Reflect a float coordinate into [min_val, max_val] using a triangle wave pattern. +#[cube] +fn reflect_float_impl(coord: F, min_val: F, max_val: F) -> F { + let span = max_val - min_val; + + let is_valid = span > F::new(0.0_f32); + let safe_span = select(is_valid, span, F::new(1.0_f32)); + + // Triangle wave formula: span - |((x mod 2*span) - span)| + min_val + let period = safe_span * F::new(2.0_f32); + let x = (coord - min_val).abs(); + let x_mod = x - (x / period).floor() * period; + let reflected = safe_span - (x_mod - safe_span).abs() + min_val; + + select(is_valid, reflected, min_val) +} diff --git a/crates/burn-cubecl/src/kernel/grid_sample/bilinear.rs b/crates/burn-cubecl/src/kernel/grid_sample/bilinear.rs new file mode 100644 index 0000000..801f20e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/grid_sample/bilinear.rs @@ -0,0 +1,181 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::std::FastDivmod; +use cubecl::{calculate_cube_count_elemwise, prelude::*}; + +use crate::{ + CubeRuntime, kernel::utils::address_type, ops::numeric::empty_device_dtype, tensor::CubeTensor, +}; +use burn_backend::{Shape, ops::GridSampleOptions}; + +use super::base::{PaddingMode, fetch_value, reflect_coord}; + +/// Grid sample with bilinear interpolation. +/// +/// Each thread processes all channels for one spatial output position: +/// 1. Reading (x, y) coordinates from the grid tensor (once per spatial position) +/// 2. Converting normalized [-1, 1] coords to pixel coordinates (once) +/// 3. For each channel: fetch 4 corner values, interpolate, and write output +#[cube(launch, address_type = "dynamic")] +fn grid_sample_bilinear_kernel( + input: &Tensor, // [N, C, H_in, W_in] + grid: &Tensor, // [N, H_out, W_out, 2] + output: &mut Tensor, // [N, C, H_out, W_out] + shape_spatial: Sequence>, // [N, H_out, W_out] for thread decomposition + #[comptime] align_corners: bool, + #[comptime] pad_mode: PaddingMode, + #[define(F)] _dtype: StorageType, +) { + // Thread index maps to spatial position (n, h_out, w_out) only + let spatial_idx = ABSOLUTE_POS; + let num_spatial = output.shape(0) * output.shape(2) * output.shape(3); + if spatial_idx >= num_spatial { + terminate!(); + } + + // Decompose spatial index into (n, h_out, w_out) + let (rem, w_out) = shape_spatial[2].div_mod(spatial_idx); + let (n, h_out) = shape_spatial[1].div_mod(rem); + + let channels = input.shape(1) as u32; + let h_in = input.shape(2) as u32; + let w_in = input.shape(3) as u32; + + // Read grid coordinates once per spatial position + let grid_offset = n * grid.stride(0) + h_out * grid.stride(1) + w_out * grid.stride(2); + let gx = grid[grid_offset]; // x coordinate in [-1, 1] + let gy = grid[grid_offset + 1]; // y coordinate in [-1, 1] + + // Convert normalized coordinates to pixel coordinates + let (px, py) = if align_corners { + let px = (gx + F::new(1.0_f32)) * F::cast_from((w_in - 1) as f32) / F::new(2.0_f32); + let py = (gy + F::new(1.0_f32)) * F::cast_from((h_in - 1) as f32) / F::new(2.0_f32); + (px, py) + } else { + let px = + (gx + F::new(1.0_f32)) * F::cast_from(w_in as f32) / F::new(2.0_f32) - F::new(0.5_f32); + let py = + (gy + F::new(1.0_f32)) * F::cast_from(h_in as f32) / F::new(2.0_f32) - F::new(0.5_f32); + (px, py) + }; + + // For reflection padding, reflect the coordinate into the valid sampling range. + // This ensures integer indices are at most 1 step out of bounds. + let (px, py) = if comptime!(pad_mode == PaddingMode::Reflection) { + let px = reflect_coord::(px, w_in, align_corners); + let py = reflect_coord::(py, h_in, align_corners); + (px, py) + } else { + (px, py) + }; + + // Compute floor and ceil indices + let x0_f = px.floor(); + let y0_f = py.floor(); + let x1_f = x0_f + F::new(1.0_f32); + let y1_f = y0_f + F::new(1.0_f32); + + // Compute interpolation weights + let wx = px - x0_f; + let wy = py - y0_f; + let wx_ = F::new(1.0_f32) - wx; + let wy_ = F::new(1.0_f32) - wy; + + // Convert to integers for indexing + let x0 = i32::cast_from(x0_f); + let y0 = i32::cast_from(y0_f); + let x1 = i32::cast_from(x1_f); + let y1 = i32::cast_from(y1_f); + + let w_in = w_in as i32; + let h_in = h_in as i32; + + // Pre-compute strides + let stride_n = input.stride(0); + let stride_c = input.stride(1); + let stride_h = input.stride(2); + let stride_w = input.stride(3); + let out_stride_n = output.stride(0); + let out_stride_c = output.stride(1); + let out_stride_h = output.stride(2); + let out_stride_w = output.stride(3); + + // Base offsets for this spatial position + let in_base_n = n * stride_n; + let out_base_spatial = n * out_stride_n + h_out * out_stride_h + w_out * out_stride_w; + + // Loop over all channels - grid coords and weights are reused + for c in 0..channels { + let in_base = in_base_n + c as usize * stride_c; + + let v00 = fetch_value( + input, in_base, stride_h, stride_w, y0, x0, h_in, w_in, pad_mode, + ); + let v01 = fetch_value( + input, in_base, stride_h, stride_w, y1, x0, h_in, w_in, pad_mode, + ); + let v10 = fetch_value( + input, in_base, stride_h, stride_w, y0, x1, h_in, w_in, pad_mode, + ); + let v11 = fetch_value( + input, in_base, stride_h, stride_w, y1, x1, h_in, w_in, pad_mode, + ); + + // Bilinear interpolation + let result = wx_ * wy_ * v00 + wx_ * wy * v01 + wx * wy_ * v10 + wx * wy * v11; + + let out_idx = out_base_spatial + c as usize * out_stride_c; + output[out_idx] = result; + } +} + +/// Launch the grid sample bilinear kernel +pub(crate) fn grid_sample_bilinear_launch( + input: CubeTensor, + grid: CubeTensor, + options: GridSampleOptions, +) -> CubeTensor { + let [batch_size, channels, _h_in, _w_in] = input.meta.shape().dims(); + let [_n, h_out, w_out, two] = grid.meta.shape().dims(); + assert_eq!(two, 2, "Grid last dimension must be 2"); + + // Create output tensor [N, C, H_out, W_out] + let output_shape = Shape::new([batch_size, channels, h_out, w_out]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + output_shape, + input.dtype, + ); + + // Spatial threading: one thread per (n, h_out, w_out) + let spatial_shape = Shape::new([batch_size, h_out, w_out]); + let num_spatial = spatial_shape.num_elements(); + + let mut shape_spatial = SequenceArg::new(); + for dim in spatial_shape.iter() { + shape_spatial.push(*dim); + } + + let cube_dim = CubeDim::new(&input.client, num_spatial); + let cube_count = calculate_cube_count_elemwise(&input.client, num_spatial, cube_dim); + + let padding_mode: PaddingMode = options.padding_mode.into(); + + let dtype = input.dtype; + + grid_sample_bilinear_kernel::launch( + &output.client, + cube_count, + cube_dim, + address_type!(input, grid, output), + input.into_tensor_arg(), + grid.into_tensor_arg(), + output.clone().into_tensor_arg(), + shape_spatial, + options.align_corners, + padding_mode, + dtype_to_storage_type(dtype), + ); + + output +} diff --git a/crates/burn-cubecl/src/kernel/grid_sample/mod.rs b/crates/burn-cubecl/src/kernel/grid_sample/mod.rs new file mode 100644 index 0000000..7bd74df --- /dev/null +++ b/crates/burn-cubecl/src/kernel/grid_sample/mod.rs @@ -0,0 +1,4 @@ +mod base; +mod bilinear; + +pub use base::*; diff --git a/crates/burn-cubecl/src/kernel/index/flip.rs b/crates/burn-cubecl/src/kernel/index/flip.rs new file mode 100644 index 0000000..f8daf6e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/flip.rs @@ -0,0 +1,103 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearViewMut}, +}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn flip_kernel( + input: &Tensor, + mut output: LinearViewMut<'_, E>, + in_shape: Sequence>, + indices: Sequence, + #[define(E, Bool)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let rank = in_shape.len().comptime(); + + let mut offset = ABSOLUTE_POS; + let mut offset_input = 0; + + #[unroll] + for i in 0..rank { + let dim = rank - i - 1; + let shape = input.shape(dim); + + let (rem, offset_local) = in_shape[dim].div_mod(offset); + offset = rem; + + let flip = indices.index(dim).get::() == Bool::from_int(1); + let offset_local = select(flip, shape - offset_local - 1, offset_local); + + offset_input += offset_local * input.stride(dim); + } + + output.write(ABSOLUTE_POS, input[offset_input]); +} + +pub(crate) fn flip( + tensor: CubeTensor, + indices: &[usize], + dtype_bool: DType, +) -> CubeTensor { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + tensor.dtype, + ); + flip_on_output(tensor, output, indices, dtype_bool) +} + +pub(crate) fn flip_on_output( + tensor: CubeTensor, + output: CubeTensor, + indices: &[usize], + dtype_bool: DType, +) -> CubeTensor { + let dtype_input = tensor.dtype; + let ndims = tensor.meta.num_dims(); + let mut indices_sequence = SequenceArg::::new(); + + for i in 0..ndims { + indices_sequence.push({ + let val = indices.contains(&i) as u8; + InputScalar::new(val, dtype_to_storage_type(dtype_bool)) + }); + } + + let num_elements = output.meta.num_elements(); + let cube_dim = CubeDim::new(&tensor.client, num_elements); + let cube_count = calculate_cube_count_elemwise(&tensor.client, num_elements, cube_dim); + + let shape = shape_divmod(&tensor); + unsafe { + flip_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, output), + tensor.into_tensor_arg(), + output.clone().into_linear_view(), + shape, + indices_sequence, + [ + dtype_to_storage_type(dtype_input), + dtype_to_storage_type(dtype_bool), + ], + ) + } + + output +} diff --git a/crates/burn-cubecl/src/kernel/index/gather.rs b/crates/burn-cubecl/src/kernel/index/gather.rs new file mode 100644 index 0000000..f30aaab --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/gather.rs @@ -0,0 +1,84 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_strides, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::std::{FastDivmod, tensor::index_offset_contiguous_fastdivmod}; +use cubecl::{CubeDim, std::tensor::layout::linear::LinearView}; +use cubecl::{calculate_cube_count_elemwise, prelude::*}; +use cubecl::{ + frontend::{ABSOLUTE_POS, Numeric, Tensor}, + std::tensor::layout::linear::LinearViewMut, +}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn gather_kernel( + input: &Tensor, + indices: LinearView<'_, I>, + mut output: LinearViewMut<'_, T>, + in_strides: Sequence, // zeroed out for broadcast dims and `dim` + out_shape: Sequence>, + dim: usize, + #[define(T, I)] _dtypes: [StorageType; 2], +) { + if !indices.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let mut offset = index_offset_contiguous_fastdivmod( + ABSOLUTE_POS, + &out_shape, + &in_strides, + input.vector_size(), + ); + + offset += usize::cast_from(indices.read(ABSOLUTE_POS)) * input.stride(dim); + + output.write(ABSOLUTE_POS, input[offset]); +} + +pub(crate) fn gather( + dim: usize, + tensor: CubeTensor, + indices: CubeTensor, +) -> CubeTensor { + let shape_output = indices.shape(); + let total_elem = shape_output.num_elements(); + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + shape_output, + tensor.dtype, + ); + + let cube_dim = CubeDim::new(&tensor.client, total_elem); + let cube_count = calculate_cube_count_elemwise(&tensor.client, total_elem, cube_dim); + let mut in_strides = broadcast_strides(&output, &tensor); + in_strides.values[dim] = 0; // Zero `dim` to exclude it from the indexing + + let (dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + unsafe { + gather_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, indices, output), + tensor.into_tensor_arg(), + indices.into_linear_view(), + output.clone().into_linear_view(), + in_strides, + shape_divmod(&output), + dim, + [ + dtype_to_storage_type(dtype), + dtype_to_storage_type(indices_dtype), + ], + ) + } + + output +} diff --git a/crates/burn-cubecl/src/kernel/index/gather_nd.rs b/crates/burn-cubecl/src/kernel/index/gather_nd.rs new file mode 100644 index 0000000..7dc5772 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/gather_nd.rs @@ -0,0 +1,124 @@ +use crate::kernel::utils::{shape_divmod, shape_divmod_range}; +use crate::{ + CubeRuntime, kernel::utils::address_type, ops::numeric::empty_device_dtype, tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::prelude::*; +use cubecl::std::FastDivmod; +use cubecl::std::tensor::layout::linear::LinearView; +use cubecl::{CubeDim, calculate_cube_count_elemwise}; + +/// gather_nd GPU kernel. +/// +/// Each thread handles one element of the output. +/// Work items = num_indices * slice_size. +#[cube(launch_unchecked, address_type = "dynamic")] +fn gather_nd_kernel( + data: &Tensor, + indices: LinearView<'_, I>, + output: &mut Tensor, + output_shape: Sequence>, + data_slice_shape: Sequence>, + slice_size: usize, + k: usize, + working_units: usize, + #[define(T, I)] _dtypes: [StorageType; 2], +) { + if ABSOLUTE_POS >= working_units { + terminate!(); + } + + let slice_offset = ABSOLUTE_POS % slice_size; + let index_idx = ABSOLUTE_POS / slice_size; + + // Compute flat offset into data from the K-dimensional index tuple + let idx_base = index_idx * k; + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = usize::cast_from(indices.read(idx_base + j)); + base_offset += idx_val * data.stride(j); + } + + let slice_rank = data_slice_shape.len().comptime(); + let mut data_slice_offset = 0usize; + let mut remainder = slice_offset; + #[unroll] + for i in 0..slice_rank { + let dim = slice_rank - i - 1; + let (rem, coord) = data_slice_shape[dim].div_mod(remainder); + remainder = rem; + data_slice_offset += coord * data.stride(k + dim); + } + + let out_rank = output_shape.len().comptime(); + let mut out_offset = 0usize; + let mut remainder_o = ABSOLUTE_POS; + #[unroll] + for i in 0..out_rank { + let dim = out_rank - i - 1; + let (rem, coord) = output_shape[dim].div_mod(remainder_o); + remainder_o = rem; + out_offset += coord * output.stride(dim); + } + + output[out_offset] = data[base_offset + data_slice_offset]; +} + +pub(crate) fn gather_nd( + tensor: CubeTensor, + indices: CubeTensor, +) -> CubeTensor { + let data_shape = &tensor.meta.shape; + let idx_shape = &indices.meta.shape; + let m = idx_shape.num_dims(); + let k = idx_shape[m - 1]; + + // num_indices = product of first M-1 dims of indices + let num_indices: usize = idx_shape.as_slice()[..m - 1].iter().product(); + // slice_size = product of data.shape[K..] + let slice_size: usize = data_shape.as_slice()[k..].iter().product(); + let total_elem = num_indices * slice_size; + + // Output shape: idx_shape[..m-1] ++ data_shape[k..] + let mut out_dims: Vec = idx_shape.as_slice()[..m - 1].to_vec(); + out_dims.extend_from_slice(&data_shape.as_slice()[k..]); + let out_shape = burn_backend::Shape::from(out_dims); + + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + out_shape, + tensor.dtype, + ); + + let cube_dim = CubeDim::new(&tensor.client, total_elem); + let cube_count = calculate_cube_count_elemwise(&tensor.client, total_elem, cube_dim); + + let (dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + let data_slice_shape = shape_divmod_range(&tensor, k..data_shape.num_dims()); + let output_shape_divmod = shape_divmod(&output); + + unsafe { + gather_nd_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, indices, output), + tensor.into_tensor_arg(), + indices.into_linear_view(), + output.clone().into_tensor_arg(), + output_shape_divmod, + data_slice_shape, + slice_size, + k, + total_elem, + [ + dtype_to_storage_type(dtype), + dtype_to_storage_type(indices_dtype), + ], + ) + } + + output +} diff --git a/crates/burn-cubecl/src/kernel/index/mod.rs b/crates/burn-cubecl/src/kernel/index/mod.rs new file mode 100644 index 0000000..86ee25b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/mod.rs @@ -0,0 +1,22 @@ +mod flip; +mod gather; +mod gather_nd; +mod repeat_dim; +mod scatter; +mod scatter_nd; +mod select; +mod select_assign; +mod slice; +mod slice_assign; + +pub(crate) use flip::*; +pub(crate) use gather_nd::*; +pub(crate) use repeat_dim::*; +pub(crate) use scatter_nd::*; +pub(crate) use select::*; +pub(crate) use select_assign::*; +pub use slice::*; +pub(crate) use slice_assign::*; + +pub(crate) use gather::*; +pub(crate) use scatter::*; diff --git a/crates/burn-cubecl/src/kernel/index/repeat_dim.rs b/crates/burn-cubecl/src/kernel/index/repeat_dim.rs new file mode 100644 index 0000000..ee9956c --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/repeat_dim.rs @@ -0,0 +1,91 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{calculate_cube_count_elemwise, prelude::*, std::FastDivmod}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn repeat_dim_kernel( + input: &Tensor, + output: &mut Tensor, + out_shape: Sequence>, + in_shape: FastDivmod, + #[comptime] dim: usize, + #[define(E)] _dtype: StorageType, +) { + if ABSOLUTE_POS >= output.len() { + terminate!(); + } + + let rank = out_shape.len().comptime(); + + let mut pos = ABSOLUTE_POS; + let mut offset_input = 0; + let mut offset_output = 0; + + #[unroll] + for i in 0..rank { + let i = rank - i - 1; + + let (rem, mut local_pos) = out_shape[i].div_mod(pos); + pos = rem; + + offset_output += local_pos * output.stride(i); + + if i == dim { + local_pos = in_shape.modulo(local_pos); + } + + offset_input += local_pos * input.stride(i); + } + + output[offset_output] = input[offset_input]; +} + +pub(crate) fn repeat_dim( + mut input: CubeTensor, + dim: usize, + times: usize, +) -> CubeTensor { + if input.meta.shape()[dim] == 1 { + input.meta.strides[dim] = 0; + input.meta.shape = input.meta.shape.clone().repeat(dim, times).unwrap(); + return input; + } + + let shape = input.meta.shape.clone().repeat(dim, times).unwrap(); + + // Create output handle + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + shape, + input.dtype, + ); + + let working_units = output.meta.num_elements(); + let cube_dim = CubeDim::new(&input.client, working_units); + let cube_count = calculate_cube_count_elemwise(&input.client, working_units, cube_dim); + + let shape_arg = input.meta.shape()[dim]; + + unsafe { + repeat_dim_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(input, output), + input.into_tensor_arg(), + output.clone().into_tensor_arg(), + shape_divmod(&output), + shape_arg, + dim, + dtype_to_storage_type(output.dtype), + ) + }; + + output +} diff --git a/crates/burn-cubecl/src/kernel/index/scatter.rs b/crates/burn-cubecl/src/kernel/index/scatter.rs new file mode 100644 index 0000000..2e36c60 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/scatter.rs @@ -0,0 +1,116 @@ +use crate::{ + CubeRuntime, + kernel::{ + AddOp, BinaryOp, BinaryOpFamily, OrOp, + utils::{address_type, shape_divmod}, + }, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{CubeDim, calculate_cube_count_elemwise}; +use cubecl::{prelude::*, std::FastDivmod}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn scatter_kernel( + input: &mut Tensor, + indices: &Tensor, + value: &Tensor, + in_shape: Sequence>, + #[comptime] dim: usize, + #[define(T, I)] _dtypes: [StorageType; 2], +) { + let rank = in_shape.len().comptime(); + let stride_input = input.stride(dim); + let stride_value = value.stride(dim); + let stride_indices = indices.stride(dim); + let shape_value = value.shape(dim); + + let mut offset = ABSOLUTE_POS; + let mut offset_input = 0; + let mut offset_indices = 0; + let mut offset_value = 0; + let mut num_elems = 1; + + #[unroll] + for i in 0..rank { + let i = rank - i - 1; + if i != dim { + let shape_input_loop = input.shape(i); + + let (rem, local_pos) = in_shape[i].div_mod(offset); + offset = rem; + + offset_input += local_pos * input.stride(i); + offset_indices += local_pos * indices.stride(i); + offset_value += local_pos * value.stride(i); + + num_elems *= shape_input_loop; + } + } + + let should_stop = ABSOLUTE_POS >= num_elems; + if should_stop { + terminate!(); + } + + for i in 0..shape_value { + let value_idx = (stride_value * i) + offset_value; + let index_idx = (stride_indices * i) + offset_indices; + + let value = value[value_idx]; + let index = usize::cast_from(indices[index_idx]); + + let input_idx = (stride_input * index) + offset_input; + + let value = Op::BinaryOp::>::execute( + Vector::cast_from(input[input_idx]), + Vector::cast_from(value), + ); + input[input_idx] = value.extract(0); + } +} + +pub(crate) fn scatter( + dim: usize, + tensor: CubeTensor, + indices: CubeTensor, + value: CubeTensor, + is_bool: bool, +) -> CubeTensor { + let tensor = match tensor.can_mut() && tensor.is_nonoverlapping() { + true => tensor, + false => tensor.copy(), + }; + + let num_elems = tensor.meta.num_elements() / tensor.meta.shape()[dim]; + + let working_units = num_elems; + let cube_dim = CubeDim::new(&indices.client, working_units); + let cube_count = calculate_cube_count_elemwise(&indices.client, working_units, cube_dim); + + let launch = match is_bool { + true => scatter_kernel::launch_unchecked::, + false => scatter_kernel::launch_unchecked::, + }; + + let (tensor_dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + unsafe { + launch( + &tensor.client.clone(), + cube_count, + cube_dim, + address_type!(tensor, indices, value), + tensor.clone().into_tensor_arg(), + indices.into_tensor_arg(), + value.into_tensor_arg(), + shape_divmod(&tensor), + dim, + [ + dtype_to_storage_type(tensor_dtype), + dtype_to_storage_type(indices_dtype), + ], + ) + } + tensor +} diff --git a/crates/burn-cubecl/src/kernel/index/scatter_nd.rs b/crates/burn-cubecl/src/kernel/index/scatter_nd.rs new file mode 100644 index 0000000..3f3fc7c --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/scatter_nd.rs @@ -0,0 +1,139 @@ +use crate::{ + CubeRuntime, + kernel::{ + AddOp, AssignOp, BinaryMaxOp, BinaryMinOp, BinaryOp, BinaryOpFamily, MulOp, + utils::{address_type, shape_divmod_range}, + }, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::tensor::IndexingUpdateOp; +use cubecl::std::tensor::layout::linear::LinearView; +use cubecl::{CubeDim, calculate_cube_count_elemwise}; +use cubecl::{prelude::*, std::FastDivmod}; + +/// scatter_nd GPU kernel. +/// +/// Each thread handles one element across all update slices. +/// Work items = num_updates * slice_size. +#[cube(launch_unchecked, address_type = "dynamic")] +fn scatter_nd_kernel( + data: &mut Tensor, + indices: LinearView<'_, I>, + values: &Tensor, + data_slice_shape: Sequence>, + values_shape: Sequence>, + slice_size: usize, + k: usize, + working_units: usize, + #[define(T, I)] _dtypes: [StorageType; 2], +) { + if ABSOLUTE_POS >= working_units { + terminate!(); + } + + let slice_offset = ABSOLUTE_POS % slice_size; + let update_idx = ABSOLUTE_POS / slice_size; + + let idx_base = update_idx * k; + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = usize::cast_from(indices.read(idx_base + j)); + base_offset += idx_val * data.stride(j); + } + + // Decompose slice_offset over data's trailing dims (k..n) + let slice_rank = data_slice_shape.len().comptime(); + let mut data_slice_offset = 0usize; + let mut remainder = slice_offset; + #[unroll] + for i in 0..slice_rank { + let dim = slice_rank - i - 1; + let (rem, coord) = data_slice_shape[dim].div_mod(remainder); + remainder = rem; + data_slice_offset += coord * data.stride(k + dim); + } + + // Decompose slice_offset over values' dims (1..n_v), with update_idx for dim 0 + let val_rank = values_shape.len().comptime(); + let mut val_offset = update_idx * values.stride(0); + let mut remainder_v = slice_offset; + #[unroll] + for i in 0..val_rank { + let dim = val_rank - i - 1; + let (rem, coord) = values_shape[dim].div_mod(remainder_v); + remainder_v = rem; + val_offset += coord * values.stride(1 + dim); + } + + let data_idx = base_offset + data_slice_offset; + let result = Op::BinaryOp::>::execute( + Vector::cast_from(data[data_idx]), + Vector::cast_from(values[val_offset]), + ); + data[data_idx] = result.extract(0); +} + +pub(crate) fn scatter_nd( + tensor: CubeTensor, + indices: CubeTensor, + values: CubeTensor, + reduction: IndexingUpdateOp, +) -> CubeTensor { + // Ensure we can write in-place + let tensor = match tensor.can_mut() && tensor.is_nonoverlapping() { + true => tensor, + false => tensor.copy(), + }; + + let data_shape = &tensor.meta.shape; + let idx_shape = &indices.meta.shape; + let m = idx_shape.num_dims(); + let k = idx_shape[m - 1]; + + // num_updates = product of first M-1 dims of indices + let num_updates: usize = idx_shape.as_slice()[..m - 1].iter().product(); + // slice_size = product of data.shape[K..] + let slice_size: usize = data_shape.as_slice()[k..].iter().product(); + let working_units = num_updates * slice_size; + + let cube_dim = CubeDim::new(&indices.client, working_units); + let cube_count = calculate_cube_count_elemwise(&indices.client, working_units, cube_dim); + + let (tensor_dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + let launch = match reduction { + IndexingUpdateOp::Assign => scatter_nd_kernel::launch_unchecked::, + IndexingUpdateOp::Add => scatter_nd_kernel::launch_unchecked::, + IndexingUpdateOp::Mul => scatter_nd_kernel::launch_unchecked::, + IndexingUpdateOp::Min => scatter_nd_kernel::launch_unchecked::, + IndexingUpdateOp::Max => scatter_nd_kernel::launch_unchecked::, + }; + + let data_slice_shape = shape_divmod_range(&tensor, k..data_shape.num_dims()); + // values dims 1.. (skip the num_updates leading dim) + let values_slice_shape = shape_divmod_range(&values, 1..values.meta.shape.num_dims()); + + unsafe { + launch( + &tensor.client.clone(), + cube_count, + cube_dim, + address_type!(tensor, indices, values), + tensor.clone().into_tensor_arg(), + indices.into_linear_view(), + values.into_tensor_arg(), + data_slice_shape, + values_slice_shape, + slice_size, + k, + working_units, + [ + dtype_to_storage_type(tensor_dtype), + dtype_to_storage_type(indices_dtype), + ], + ) + } + + tensor +} diff --git a/crates/burn-cubecl/src/kernel/index/select.rs b/crates/burn-cubecl/src/kernel/index/select.rs new file mode 100644 index 0000000..f5c0bad --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/select.rs @@ -0,0 +1,85 @@ +use crate::{CubeRuntime, kernel::utils::address_type, tensor::CubeTensor}; +use crate::{kernel::utils::shape_divmod, ops::numeric::empty_device_dtype}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + CubeDim, calculate_cube_count_elemwise, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; +use cubecl::{prelude::*, std::FastDivmod}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn select_kernel( + input: &Tensor, + indices: LinearView<'_, I>, + mut output: LinearViewMut<'_, T>, + out_shape: Sequence>, + dim: usize, + #[define(T, I)] _dtypes: [StorageType; 2], +) { + if ABSOLUTE_POS >= output.shape() { + terminate!(); + } + + let rank = out_shape.len().comptime(); + + let mut offset = ABSOLUTE_POS; + let mut offset_input = 0; + + #[unroll] + for i in 0..rank { + let i = rank - i - 1; + let (rem, mut offset_local) = out_shape[i].div_mod(offset); + offset = rem; + + if i == dim { + offset_local = usize::cast_from(indices.read(offset_local)); + } + + offset_input += offset_local * input.stride(i); + } + + output.write(ABSOLUTE_POS, input[offset_input]); +} + +pub(crate) fn select( + tensor: CubeTensor, + dim: usize, + indices: CubeTensor, +) -> CubeTensor { + let mut shape_output = tensor.shape(); + shape_output[dim] = indices.meta.shape()[0]; + let total_elem = shape_output.num_elements(); + + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + shape_output, + tensor.dtype, + ); + + let working_units = total_elem; + let cube_dim = CubeDim::new(&indices.client, working_units); + let cube_count = calculate_cube_count_elemwise(&indices.client, working_units, cube_dim); + + let (tensor_dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + unsafe { + select_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, indices, output), + tensor.into_tensor_arg(), + indices.into_linear_view(), + output.clone().into_linear_view(), + shape_divmod(&output), + dim, + [ + dtype_to_storage_type(tensor_dtype), + dtype_to_storage_type(indices_dtype), + ], + ) + }; + output +} diff --git a/crates/burn-cubecl/src/kernel/index/select_assign.rs b/crates/burn-cubecl/src/kernel/index/select_assign.rs new file mode 100644 index 0000000..0c8ee22 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/select_assign.rs @@ -0,0 +1,104 @@ +use crate::kernel::{ + AddOp, BinaryOp, BinaryOpFamily, OrOp, + utils::{address_type, shape_divmod}, +}; +use crate::{CubeRuntime, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{CubeDim, calculate_cube_count_elemwise, std::tensor::layout::linear::LinearView}; +use cubecl::{prelude::*, std::FastDivmod}; + +/// Uses checked launch mode because user-provided `indices` may contain out-of-bounds values +/// that would cause invalid writes into `tensor`. Checked mode clamps these accesses rather +/// than producing undefined behavior. +#[cube(launch, address_type = "dynamic")] +fn select_assign_kernel( + tensor: &mut Tensor, + indices: LinearView<'_, I>, + value: &Tensor, + value_shape: Sequence>, + working_units: usize, + #[comptime] axis: usize, + #[define(F, I)] _dtypes: [StorageType; 2], +) { + if ABSOLUTE_POS >= working_units { + terminate!(); + } + + let rank = value_shape.len().comptime(); + + let mut offset = ABSOLUTE_POS; + let mut offset_tensor = 0; + let mut offset_value = 0; + + // Calculate offsets and num_elems + #[unroll] + for i in 0..rank { + let i = rank - i - 1; + if i != axis { + let (rem, local_pos) = value_shape[i].div_mod(offset); + offset = rem; + + offset_tensor += local_pos * tensor.stride(i); + offset_value += local_pos * value.stride(i); + } + } + + let strides_tensor_dim = tensor.stride(axis); + let strides_value_dim = value.stride(axis); + + // Main operation + for i in 0..value.shape(axis) { + let index_tensor = usize::cast_from(indices.read(i)) * strides_tensor_dim + offset_tensor; + let index_value = i * strides_value_dim + offset_value; + + let value = Op::BinaryOp::>::execute( + Vector::cast_from(tensor[index_tensor]), + Vector::cast_from(value[index_value]), + ); + write_checked(tensor.as_mut_slice(), index_tensor, F::cast_from(value)); + } +} + +pub(crate) fn select_assign( + tensor: CubeTensor, + dim: usize, + indices: CubeTensor, + value: CubeTensor, + is_bool: bool, +) -> CubeTensor { + let tensor = match tensor.can_mut() && tensor.is_nonoverlapping() { + true => tensor, + false => tensor.copy(), + }; + + let working_units = tensor.meta.num_elements() / tensor.meta.shape()[dim]; + let cube_dim = CubeDim::new(&indices.client, working_units); + let cube_count = calculate_cube_count_elemwise(&indices.client, working_units, cube_dim); + + let launch = match is_bool { + true => select_assign_kernel::launch::, + false => select_assign_kernel::launch::, + }; + + let (tensor_dtype, indices_dtype) = (tensor.dtype, indices.dtype); + + let shape = shape_divmod(&value); + launch( + &tensor.client, + cube_count, + cube_dim, + address_type!(tensor, indices, value), + tensor.clone().into_tensor_arg(), + indices.into_linear_view(), + value.into_tensor_arg(), + shape, + working_units, + dim, + [ + dtype_to_storage_type(tensor_dtype), + dtype_to_storage_type(indices_dtype), + ], + ); + + tensor +} diff --git a/crates/burn-cubecl/src/kernel/index/slice.rs b/crates/burn-cubecl/src/kernel/index/slice.rs new file mode 100644 index 0000000..d4b1ed1 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/slice.rs @@ -0,0 +1,249 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, shape_divmod}, + ops::numeric::empty_device_dtype, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{Slice, TensorMetadata}; +use burn_std::{Metadata, SliceOps}; +use cubecl::{ + calculate_cube_count_elemwise, intrinsic, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearViewMut}, +}; +use std::ops::Range; + +/// Slice a jit tensor with a set of ranges +pub fn slice(tensor: CubeTensor, indices: &[Range]) -> CubeTensor { + let mut dims = tensor.shape(); + let mut offset_start = 0u64; + let mut offset_end = 0u64; + + for i in 0..indices.len() { + offset_start += (tensor.meta.strides()[i] * indices[i].start) as u64; + offset_end += (tensor.meta.strides()[i] * (dims[i] - indices[i].end)) as u64; + dims[i] = indices[i].end - indices[i].start; + } + + let offset_start = offset_start * tensor.dtype.size() as u64; + let offset_end = offset_end * tensor.dtype.size() as u64; + + let memory_offset_alignment = tensor.client.properties().memory.alignment; + + if offset_start.is_multiple_of(memory_offset_alignment) + && offset_end.is_multiple_of(memory_offset_alignment) + { + CubeTensor::new( + tensor.client.clone(), + tensor + .handle + .clone() + .offset_start(offset_start) + .offset_end(offset_end), + Metadata::new(dims, tensor.meta.strides.clone()), + tensor.device.clone(), + tensor.dtype, + ) + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + dims, + tensor.dtype, + ); + slice_on_output(tensor, output, indices) + } +} + +#[cube(launch_unchecked, address_type = "dynamic")] +fn slice_kernel( + input: &Tensor, + mut output: LinearViewMut<'_, E>, + out_shape: Sequence>, + indices: Sequence, + #[define(E)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let rank = comptime![out_shape.len()]; + let mut offset_output = ABSOLUTE_POS; + let mut offset_input = 0; + + #[unroll] + for i in 0..rank { + // Iterate in reverse to use divmod + let dim = rank - i - 1; + + let range_start = indices[dim]; + let (rem, offset_local) = out_shape[dim].div_mod(offset_output); + offset_output = rem; + + let offset_local = offset_local + range_start; + + offset_input += offset_local * input.stride(dim); + } + + output.write(ABSOLUTE_POS, input[offset_input]); +} + +pub(crate) fn slice_on_output( + tensor: CubeTensor, + output: CubeTensor, + indices: &[Range], +) -> CubeTensor { + let ndims = tensor.meta.num_dims(); + let mut indices_sequence = SequenceArg::::new(); + + for i in 0..ndims { + let start = indices.get(i).map(|index| index.start).unwrap_or(0); + indices_sequence.push(start); + } + + let working_units = output.meta.num_elements(); + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + let dtype = tensor.dtype; + + unsafe { + slice_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, output), + tensor.into_tensor_arg(), + output.clone().into_linear_view(), + shape_divmod(&output), + indices_sequence, + dtype_to_storage_type(dtype), + ) + }; + + output +} + +/// Kernel for slicing with steps +#[cube(launch_unchecked, address_type = "dynamic")] +fn slice_with_steps_kernel( + input: &Tensor, + mut output: LinearViewMut<'_, E>, + out_shape: Sequence>, + starts: Sequence, + ends: Sequence, + steps: Sequence, + #[define(E)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let rank = comptime![out_shape.len()]; + let mut output_offset = ABSOLUTE_POS; + let mut input_offset = 0; + + // Calculate the input offset based on output position and slice info + #[unroll] + for i in 0..rank { + // Iterate in reverse to use divmod + let dim = rank - i - 1; + let start = starts[dim]; + let end = ends[dim]; + let step = steps[dim]; + + let (rem, output_idx) = out_shape[dim].div_mod(output_offset); + output_offset = rem; + + let input_idx = if step > 0 { + // Forward stepping + start + output_idx * (step as usize) + } else { + // Backward stepping - start from end-1 + let abs_step = (-step) as usize; + let end_minus_1 = end - 1; + end_minus_1 - output_idx * abs_step + }; + + input_offset += input_idx * input.stride(dim); + } + + output.write(ABSOLUTE_POS, input[input_offset]); +} + +/// Slice a tensor with steps +pub fn slice_with_steps(tensor: CubeTensor, slices: &[Slice]) -> CubeTensor { + // Check if all steps are 1 - if so, use the optimized regular slice + let all_steps_one = slices.iter().all(|info| info.step == 1); + + if all_steps_one { + // Convert Slice to Range for step=1 + let simple_ranges: Vec> = slices + .iter() + .enumerate() + .map(|(i, slice)| slice.to_range(tensor.meta.shape()[i])) + .collect(); + return slice(tensor, &simple_ranges); + } + + // Calculate output shape + let shape_output = tensor.shape().slice(slices).unwrap(); + + // Create output tensor + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + shape_output.clone(), + tensor.dtype, + ); + + // Prepare three separate sequences for kernel + let mut starts = SequenceArg::::new(); + let mut ends = SequenceArg::::new(); + let mut steps = SequenceArg::::new(); + + for (dim, slice) in slices.iter().enumerate() { + let range = slice.to_range(tensor.meta.shape()[dim]); + starts.push(range.start); + ends.push(range.end); + steps.push(slice.step as i32); + } + + // Pad with default values if needed to match tensor dimensions + for dim in slices.len()..tensor.meta.num_dims() { + starts.push(0); + ends.push(tensor.meta.shape[dim]); + steps.push(1); + } + + // Launch kernel + let working_units = shape_output.num_elements(); + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + let dtype = tensor.dtype; + + unsafe { + slice_with_steps_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + address_type!(tensor, output), + tensor.into_tensor_arg(), + output.clone().into_linear_view(), + shape_divmod(&output), + starts, + ends, + steps, + dtype_to_storage_type(dtype), + ); + } + + output +} + +/// This is annoying and we need to find a way to do this automatically at some point +#[allow(unused)] +#[cube] +fn unwrap(value: u32) -> comptime_type!(u32) { + intrinsic!(|_| value.constant().unwrap().as_u32()) +} diff --git a/crates/burn-cubecl/src/kernel/index/slice_assign.rs b/crates/burn-cubecl/src/kernel/index/slice_assign.rs new file mode 100644 index 0000000..49d0fa4 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/index/slice_assign.rs @@ -0,0 +1,259 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, shape_divmod}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, intrinsic, + prelude::*, + std::{FastDivmod, tensor::layout::linear::LinearView}, +}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn slice_assign_kernel( + input: &mut Tensor>, + value: LinearView<'_, Vector>, + slice_shape: Sequence>, + slice_offsets: Sequence, + #[define(E)] _dtype: StorageType, +) { + if !value.is_in_bounds(ABSOLUTE_POS) { + terminate!() + } + + let rank = comptime!(slice_shape.len()); + + let line_size = input.vector_size(); + let mut offset_remainder = ABSOLUTE_POS * line_size; + let mut offset_input = 0; + + #[allow(clippy::explicit_counter_loop)] + #[unroll] + for i in 0..rank { + let dim = rank - i - 1; + let (rem, offset_local) = slice_shape[dim].div_mod(offset_remainder); + + let range_start = slice_offsets[dim]; + let offset_local_input = offset_local + range_start; + + offset_input += offset_local_input * input.stride(dim); + offset_remainder = rem; + } + + // Value tensor is accessed linearly since it's a LinearView + input[offset_input / line_size] = value.read(ABSOLUTE_POS); +} + +/// Kernel for slice assign with steps +#[cube(launch_unchecked, address_type = "dynamic")] +fn slice_assign_with_steps_kernel( + input: &mut Tensor, + value: LinearView<'_, E>, + value_shape: Sequence>, + starts: Sequence, + ends: Sequence, + steps: Sequence, + #[define(E)] _dtype: StorageType, +) { + if !value.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let rank = comptime![value_shape.len()]; + let mut value_offset = ABSOLUTE_POS; + let mut input_offset = 0; + + // Calculate the input offset based on value position and slice info + #[unroll] + for i in 0..rank { + // Iterate in reverse to use divmod + let dim = rank - i - 1; + let start = starts[dim]; + let end = ends[dim]; + let step = steps[dim]; + + let (rem, value_idx) = value_shape[dim].div_mod(value_offset); + value_offset = rem; + + let input_idx = if step > 0 { + // Forward stepping + start + value_idx * (step as usize) + } else if step < 0 { + // Backward stepping - start from end-1 + // For negative steps, we iterate backwards through the selected indices + let abs_step = (-step) as usize; + let end_minus_1 = end - 1; + end_minus_1 - value_idx * abs_step + } else { + // step == 0, shouldn't happen + value_idx + }; + + input_offset += input_idx * input.stride(dim); + } + + input[input_offset] = value.read(ABSOLUTE_POS); +} + +pub(crate) fn slice_assign( + tensor: CubeTensor, + indices: &[burn_backend::Slice], + value: CubeTensor, +) -> CubeTensor { + // Check if any slice has non-unit step + let has_non_unit_step = indices.iter().any(|s| s.step != 1 && s.step != 0); + + if has_non_unit_step { + // Use slice_assign_with_steps + return slice_assign_with_steps(tensor, indices, value); + } + + let client = tensor.client.clone(); + let tensor = match tensor.can_mut() && tensor.is_nonoverlapping() { + true => tensor, + false => tensor.copy(), + }; + let ndims = tensor.meta.num_dims(); + + let vector_size = + if tensor.meta.strides()[ndims - 1] == 1 && value.meta.strides()[ndims - 1] == 1 { + let last = indices + .get(ndims - 1) + .cloned() + .unwrap_or(burn_backend::Slice { + start: 0, + end: Some(tensor.meta.shape()[ndims - 1] as isize), + step: 1, + }); + let end = last.end.unwrap_or(tensor.meta.shape()[ndims - 1] as isize); + let shape = (end - last.start) as usize; + let offset = last.start as usize; + client + .io_optimized_vector_sizes(tensor.dtype.size()) + .filter(|&it| { + shape.is_multiple_of(it) + && strides_compatible(tensor.meta.strides(), it) + && strides_compatible(value.meta.strides(), it) + && offset.is_multiple_of(it) + }) + .max() + .unwrap_or(1) + } else { + 1 + }; + + let mut shape = SequenceArg::>::new(); + let mut offsets = SequenceArg::::new(); + + for i in 0..ndims { + let slice = indices.get(i).cloned().unwrap_or(burn_backend::Slice { + start: 0, + end: Some(tensor.meta.shape()[i] as isize), + step: 1, + }); + let start = slice.start as usize; + let end = slice.end.unwrap_or(tensor.meta.shape()[i] as isize); + let length = (end - slice.start) as usize; + + shape.push(length); + offsets.push(start); + } + + let working_units = value.meta.num_elements() / vector_size; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + unsafe { + slice_assign_kernel::launch_unchecked( + &tensor.client, + cube_count, + cube_dim, + address_type!(tensor, value), + vector_size, + tensor.clone().into_tensor_arg(), + value.into_linear_view(), + shape, + offsets, + dtype_to_storage_type(tensor.dtype), + ) + }; + + tensor +} + +/// Slice assign with steps support +/// +/// This function handles slice assignment with arbitrary step values, including negative steps. +/// It follows NumPy/PyTorch semantics where values[i] is assigned to selected_indices[i]. +/// +/// For example, with s![0..6;-1] which selects indices [5,4,3,2,1,0]: +/// - values[0] goes to index 5 +/// - values[1] goes to index 4 +/// - etc. +pub(crate) fn slice_assign_with_steps( + tensor: CubeTensor, + slices: &[burn_backend::Slice], + value: CubeTensor, +) -> CubeTensor { + let tensor = match tensor.can_mut() && tensor.is_nonoverlapping() { + true => tensor, + false => tensor.copy(), + }; + + // Prepare sequences for kernel + let mut starts = SequenceArg::::new(); + let mut ends = SequenceArg::::new(); + let mut steps = SequenceArg::::new(); + + for (dim, slice) in slices.iter().enumerate() { + let range = slice.to_range(tensor.meta.shape()[dim]); + starts.push(range.start); + ends.push(range.end); + steps.push(slice.step as i32); + } + + // Pad with default values if needed to match tensor dimensions + for dim in slices.len()..tensor.meta.num_dims() { + starts.push(0); + ends.push(tensor.meta.shape[dim]); + steps.push(1); + } + + // Launch kernel + let working_units = value.meta.num_elements(); + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + + let shape = shape_divmod(&value); + unsafe { + slice_assign_with_steps_kernel::launch_unchecked( + &tensor.client, + cube_count, + cube_dim, + address_type!(tensor, value), + tensor.clone().into_tensor_arg(), + value.into_linear_view(), + shape, + starts, + ends, + steps, + dtype_to_storage_type(tensor.dtype), + ); + } + + tensor +} + +fn strides_compatible(strides: &[usize], vec: usize) -> bool { + strides + .iter() + .all(|stride| *stride % vec == 0 || *stride == 1) +} + +/// Helper function for unwrap +#[allow(unused)] +#[cube] +fn unwrap(value: u32) -> comptime_type!(u32) { + intrinsic!(|_| value.constant().unwrap().as_u32()) +} diff --git a/crates/burn-cubecl/src/kernel/interpolate/base.rs b/crates/burn-cubecl/src/kernel/interpolate/base.rs new file mode 100644 index 0000000..97b63c6 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/interpolate/base.rs @@ -0,0 +1,168 @@ +use crate::{ + CubeRuntime, + kernel::{interpolate::interpolate_autotune, into_contiguous}, + ops::{numeric::empty_device_dtype, permute_nchw_to_nhwc, permute_nhwc_to_nchw}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{Shape, TensorMetadata, ops::InterpolateMode, ops::InterpolateOptions}; +#[cfg(not(feature = "autotune"))] +use cubek::interpolate::definition::TileSize; +use cubek::interpolate::{ + definition::{ + InterpolateError, InterpolateMode as CubekInterpolateMode, + InterpolateOptions as CubekInterpolateOptions, NearestMode as CubekNearestMode, + }, + interpolate as cubek_interpolate, interpolate_backward as cubek_interpolate_backward, + launch::InterpolateStrategy as CubekInterpolateStrategy, + routines::{ + BlueprintStrategy, GlobalMemoryRoutine, GlobalMemoryStrategy, SharedMemoryRoutine, + SharedMemoryStrategy, + }, +}; + +#[derive(Debug)] +/// Strategy used to select which interpolate implementation to run. +pub enum InterpolateStrategy { + /// Default interpolate strategy. + GlobalMemory(GlobalMemoryStrategy), + + /// Use shared memory for caching tiles of the input and output. + SharedMemory(SharedMemoryStrategy), + + /// Automatically benchmark and select the best strategy at runtime. + #[cfg(feature = "autotune")] + Autotune, +} + +impl Default for InterpolateStrategy { + fn default() -> Self { + // if autotune is enabled, default to autotune + #[cfg(feature = "autotune")] + return InterpolateStrategy::Autotune; + + // if autotune is disabled, default to global memory with a 16x16 tile size + #[cfg(not(feature = "autotune"))] + InterpolateStrategy::GlobalMemory(GlobalMemoryStrategy { + tile_size: TileSize::new(16, 16), + }) + } +} + +/// Interpolate operation +/// +/// Supports nearest, bilinear, bicubic and lanczos3 modes +pub fn interpolate( + input: CubeTensor, + output_size: [usize; 2], + options: InterpolateOptions, + strategy: InterpolateStrategy, +) -> Result, InterpolateError> { + match strategy { + InterpolateStrategy::GlobalMemory(strategy) => execute_interpolate( + input, + output_size, + options, + CubekInterpolateStrategy::GlobalMemoryStrategy( + BlueprintStrategy::::Inferred(strategy), + ), + ), + InterpolateStrategy::SharedMemory(strategy) => execute_interpolate( + input, + output_size, + options, + CubekInterpolateStrategy::SharedMemoryStrategy( + BlueprintStrategy::::Inferred(strategy), + ), + ), + #[cfg(feature = "autotune")] + InterpolateStrategy::Autotune => Ok(interpolate_autotune(input, output_size, options)), + } +} + +/// Execute the given interpolate strategy without autotuning. This is used by the autotune implementation to run each candidate strategy. +pub fn execute_interpolate( + input: CubeTensor, + output_size: [usize; 2], + options: InterpolateOptions, + strategy: CubekInterpolateStrategy, +) -> Result, InterpolateError> { + let [batch_size, channels, _, _] = input.meta.shape().dims(); + let [out_height, out_width] = output_size; + + let input = into_contiguous(permute_nchw_to_nhwc(input)); + + let shape_out = Shape::new([batch_size, out_height, out_width, channels]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + shape_out, + input.dtype, + ); + + cubek_interpolate( + &input.client.clone(), + input.clone().binding(), + output.clone().binding(), + map_options(options.clone()), + strategy, + dtype_to_storage_type(input.dtype), + )?; + + Ok(permute_nhwc_to_nchw(output)) +} + +/// Backward interpolate operation +/// +/// Note: only nearest mode is supported +pub fn interpolate_backward( + input: CubeTensor, + out_grad: CubeTensor, + _output_size: [usize; 2], + options: InterpolateOptions, +) -> CubeTensor { + let input = permute_nchw_to_nhwc(input); + let out_grad = permute_nchw_to_nhwc(out_grad); + + let output_shape = input.shape(); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + output_shape, + input.dtype, + ); + + cubek_interpolate_backward( + &input.client.clone(), + input.clone().binding(), + out_grad.binding(), + output.clone().binding(), + map_options(options.clone()), + dtype_to_storage_type(input.dtype), + ) + .unwrap_or_else(|e| { + panic!( + "interpolate_backward kernel failed (device={0:?}, dtype={1:?}, options={2:?}): {3}", + input.device, input.dtype, options, e + ) + }); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn map_mode(mode: InterpolateMode) -> CubekInterpolateMode { + match mode { + InterpolateMode::Nearest => CubekInterpolateMode::Nearest(CubekNearestMode::Floor), + InterpolateMode::NearestExact => CubekInterpolateMode::Nearest(CubekNearestMode::Exact), + InterpolateMode::Bilinear => CubekInterpolateMode::Bilinear, + InterpolateMode::Bicubic => CubekInterpolateMode::Bicubic, + InterpolateMode::Lanczos3 => CubekInterpolateMode::Lanczos3, + } +} + +pub(crate) fn map_options(options: InterpolateOptions) -> CubekInterpolateOptions { + CubekInterpolateOptions { + mode: map_mode(options.mode), + align_corners: options.align_corners, + } +} diff --git a/crates/burn-cubecl/src/kernel/interpolate/mod.rs b/crates/burn-cubecl/src/kernel/interpolate/mod.rs new file mode 100644 index 0000000..8ff38a9 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/interpolate/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod tune; + +pub use base::*; +pub use tune::*; diff --git a/crates/burn-cubecl/src/kernel/interpolate/tune.rs b/crates/burn-cubecl/src/kernel/interpolate/tune.rs new file mode 100644 index 0000000..8162a56 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/interpolate/tune.rs @@ -0,0 +1,136 @@ +use crate::{ + CubeRuntime, CubeTuneId, kernel::interpolate::execute_interpolate, + kernel::interpolate::map_mode, tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_backend::ops::InterpolateOptions; +use cubecl::tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}; +use cubek::interpolate::{ + definition::TileSize, + launch::{InterpolateAutotuneKey, InterpolateStrategy}, + routines::{ + BlueprintStrategy, GlobalMemoryRoutine, GlobalMemoryStrategy, SharedMemoryRoutine, + SharedMemoryStrategy, + }, +}; + +/// Interpolate operation with autotuning. This benchmarks multiple strategies and selects the best one at runtime. +pub fn interpolate_autotune( + input: CubeTensor, + output_size: [usize; 2], + options: InterpolateOptions, +) -> CubeTensor { + let client = input.client.clone(); + + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(|| { + const PRIORITY: i8 = 0; + + let global_memory = + TuneGroup::::new("global_memory", |_key| PRIORITY); + let shared_memory = + TuneGroup::::new("shared_memory", |_key| PRIORITY); + + let mut set = TunableSet::new(create_key::, input_gen::); + + let tile_sizes: [TileSize; 16] = [ + // Square shapes + TileSize::new(8, 8), + TileSize::new(16, 16), + TileSize::new(32, 32), + // Rectangular shapes + TileSize::new(8, 16), + TileSize::new(16, 8), + TileSize::new(16, 32), + TileSize::new(32, 16), + // Flat horizontal shapes + TileSize::new(1, 64), + TileSize::new(1, 128), + TileSize::new(1, 256), + TileSize::new(2, 128), + TileSize::new(1, 512), + TileSize::new(1, 1024), + // Flat vertical shapes + TileSize::new(64, 1), + TileSize::new(128, 1), + TileSize::new(256, 1), + ]; + + for tile_size in tile_sizes { + let name = format!( + "global_memory_tile_size_{}_{}", + tile_size.height(), + tile_size.width() + ); + set = set.with( + Tunable::new(&name, move |(input, output_size, options)| { + execute_interpolate::( + input, + output_size, + options, + InterpolateStrategy::GlobalMemoryStrategy(BlueprintStrategy::< + GlobalMemoryRoutine, + >::Inferred( + GlobalMemoryStrategy { tile_size }, + )), + ) + }) + .group(&global_memory, |_key| PRIORITY), + ); + + let name = format!( + "shared_memory_tile_size_{}_{}", + tile_size.height(), + tile_size.width() + ); + set = set.with( + Tunable::new(&name, move |(input, output_size, options)| { + execute_interpolate::( + input, + output_size, + options, + InterpolateStrategy::SharedMemoryStrategy(BlueprintStrategy::< + SharedMemoryRoutine, + >::Inferred( + SharedMemoryStrategy { tile_size }, + )), + ) + }) + .group(&shared_memory, |_key| PRIORITY), + ); + } + + set + }); + + TUNER.execute( + &CubeTuneId::new(&client, &input.device), + &client, + tunables, + (input, output_size, options), + ) +} + +fn create_key( + (input, output_size, options): &(CubeTensor, [usize; 2], InterpolateOptions), +) -> InterpolateAutotuneKey { + let elem_input = dtype_to_elem_type(input.dtype); + let elem_output = dtype_to_elem_type(input.dtype); + let mode = map_mode(options.mode.clone()); + + InterpolateAutotuneKey::generate( + elem_input, + elem_output, + mode, + input.meta.shape(), + output_size, + ) +} + +fn input_gen( + _key: &InterpolateAutotuneKey, + (input, output_size, options): &(CubeTensor, [usize; 2], InterpolateOptions), +) -> (CubeTensor, [usize; 2], InterpolateOptions) { + (input.clone(), *output_size, options.clone()) +} diff --git a/crates/burn-cubecl/src/kernel/mask/base.rs b/crates/burn-cubecl/src/kernel/mask/base.rs new file mode 100644 index 0000000..9c7057e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/mask/base.rs @@ -0,0 +1,39 @@ +use burn_backend::DType; +use cubecl::prelude::InputScalar; + +use super::{MaskFillStrategy, mask_where::MaskWhereStrategy}; +use crate::{CubeRuntime, tensor::CubeTensor}; + +/// Execute the mask fill kernel. +pub(crate) fn mask_fill_auto( + tensor: CubeTensor, + mask: CubeTensor, + value: InputScalar, + dtype_bool: DType, +) -> CubeTensor { + let strategy = if tensor.can_mut() && tensor.is_nonoverlapping() { + MaskFillStrategy::Inplace + } else { + MaskFillStrategy::Readonly + }; + + super::mask_fill(tensor, mask, value, strategy, dtype_bool) +} + +/// Execute the mask where kernel. +pub(crate) fn mask_where_auto( + tensor: CubeTensor, + mask: CubeTensor, + value: CubeTensor, + dtype_bool: DType, +) -> CubeTensor { + let strategy = if tensor.can_mut_broadcast(&value) { + MaskWhereStrategy::InplaceLhs + } else if value.can_mut_broadcast(&tensor) { + MaskWhereStrategy::InplaceRhs + } else { + MaskWhereStrategy::Readonly + }; + + super::mask_where(tensor, mask, value, strategy, dtype_bool) +} diff --git a/crates/burn-cubecl/src/kernel/mask/mask_fill.rs b/crates/burn-cubecl/src/kernel/mask/mask_fill.rs new file mode 100644 index 0000000..901c8b0 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/mask/mask_fill.rs @@ -0,0 +1,99 @@ +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size_many, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; + +#[cube(launch_unchecked, address_type = "dynamic")] +fn mask_fill_kernel( + input: LinearView<'_, Vector>, + mask: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + value: InputScalar, + #[define(T, B)] _dtypes: [StorageType; 2], +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let mask = Vector::cast_from(mask.read(ABSOLUTE_POS)); + let input = input.read(ABSOLUTE_POS); + let value = Vector::new(value.get::()); + + output.write(ABSOLUTE_POS, select_many(mask, value, input)); +} + +#[derive(Clone, Copy, Debug)] +/// Define how to run the mask fill kernel. +/// +/// # Notes +/// +/// All assertions should be done before choosing the strategy. +pub enum MaskFillStrategy { + /// Don't mutate any input. + Readonly, + /// Reuse the input tensor inplace. + Inplace, +} + +/// Execute the mask fill kernel with the given strategy. +pub fn mask_fill( + input: CubeTensor, + mask: CubeTensor, + value: InputScalar, + strategy: MaskFillStrategy, + dtype_bool: DType, +) -> CubeTensor { + let ndims = input.meta.num_dims(); + let output = match strategy { + MaskFillStrategy::Readonly => empty_device_dtype( + input.client.clone(), + input.device.clone(), + input.shape(), + input.dtype, + ), + MaskFillStrategy::Inplace => input.clone(), + }; + + let vector_size = max_vector_size_many(&[&input, &mask], ndims - 1); + let working_units = input.meta.num_elements() / vector_size as usize; + let cube_dim = CubeDim::new(&input.client, working_units); + let cube_count = calculate_cube_count_elemwise(&input.client, working_units, cube_dim); + + let out_arg = match strategy { + MaskFillStrategy::Readonly => output.clone().into_linear_view(), + MaskFillStrategy::Inplace => output.as_linear_view_alias(0), + }; + + let at = address_type!(input, mask, output); + let mask = mask.into_linear_view_like(&input); + + unsafe { + mask_fill_kernel::launch_unchecked( + &output.client, + cube_count, + cube_dim, + at, + vector_size, + input.into_linear_view(), + mask, + out_arg, + value, + [ + dtype_to_storage_type(output.dtype), + dtype_to_storage_type(dtype_bool), + ], + ); + } + + output +} diff --git a/crates/burn-cubecl/src/kernel/mask/mask_where.rs b/crates/burn-cubecl/src/kernel/mask/mask_where.rs new file mode 100644 index 0000000..a9053c1 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/mask/mask_where.rs @@ -0,0 +1,104 @@ +use burn_backend::DType; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +use crate::{ + CubeRuntime, + kernel::utils::{address_type, broadcast_shape}, + ops::{max_vector_size_many, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; + +#[cube(launch, address_type = "dynamic")] +fn mask_where_kernel( + input: LinearView<'_, Vector>, + value: LinearView<'_, Vector>, + mask: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + #[define(T, B)] _dtypes: [StorageType; 2], +) { + let pos = ABSOLUTE_POS; + if !output.is_in_bounds(pos) { + terminate!(); + } + + output.write( + pos, + select_many( + Vector::cast_from(mask.read(pos)), + value.read(pos), + input.read(pos), + ), + ); +} + +#[derive(Clone, Copy, Debug)] +/// Define how to run the mask where kernel. +/// +/// # Notes +/// +/// All assertions should be done before choosing the strategy. +pub enum MaskWhereStrategy { + /// Don't mutate any input. + Readonly, + /// Reuse the lhs tensor inplace. + InplaceLhs, + /// Reuse the rhs tensor inplace. + InplaceRhs, +} + +/// Execute the mask where kernel with the given strategy. +pub fn mask_where( + input: CubeTensor, + mask: CubeTensor, + value: CubeTensor, + strategy: MaskWhereStrategy, + dtype_bool: DType, +) -> CubeTensor { + let vector_size = max_vector_size_many(&[&input, &mask, &value], input.meta.num_dims() - 1); + + let working_units = input.meta.num_elements() / vector_size as usize; + let cube_dim = CubeDim::new(&input.client, working_units); + let cube_count = calculate_cube_count_elemwise(&input.client, working_units, cube_dim); + + let out_shape = broadcast_shape(&[&input, &mask, &value]); + + let output = match strategy { + MaskWhereStrategy::Readonly => empty_device_dtype( + input.client.clone(), + input.device.clone(), + out_shape, + input.dtype, + ), + MaskWhereStrategy::InplaceLhs => input.clone(), + MaskWhereStrategy::InplaceRhs => value.clone(), + }; + + let out = match strategy { + MaskWhereStrategy::Readonly => output.clone().into_linear_view(), + MaskWhereStrategy::InplaceLhs => output.as_linear_view_alias(0), + MaskWhereStrategy::InplaceRhs => output.as_linear_view_alias(1), + }; + + mask_where_kernel::launch( + &output.client, + cube_count, + cube_dim, + address_type!(input, value, mask, output), + vector_size, + input.into_linear_view_like(&output), + value.into_linear_view_like(&output), + mask.into_linear_view_like(&output), + out, + [ + dtype_to_storage_type(output.dtype), + dtype_to_storage_type(dtype_bool), + ], + ); + + output +} diff --git a/crates/burn-cubecl/src/kernel/mask/mod.rs b/crates/burn-cubecl/src/kernel/mask/mod.rs new file mode 100644 index 0000000..0044101 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/mask/mod.rs @@ -0,0 +1,8 @@ +mod base; +mod mask_fill; +mod mask_where; + +pub(crate) use base::*; + +pub use mask_fill::*; +pub use mask_where::*; diff --git a/crates/burn-cubecl/src/kernel/matmul/base.rs b/crates/burn-cubecl/src/kernel/matmul/base.rs new file mode 100644 index 0000000..01e65e1 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/matmul/base.rs @@ -0,0 +1,189 @@ +use super::init_matmul_output; +use crate::{CubeRuntime, kernel::quantization::dequantize, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; +use burn_std::{MatmulTransformAnalysis, MatmulTransformPolicy, QuantLevel}; +use cubek::{ + matmul::{ + definition::{MatmulElems, MatmulGlobalElems, MatmulSetupError}, + strategy::Strategy, + }, + std::InputBinding, +}; + +#[cfg(feature = "autotune")] +use super::matmul_autotune; + +/// The strategy to be used when launching a matmul kernel. +pub enum MatmulStrategy { + #[cfg(feature = "autotune")] + /// Using autotune to choose the best kernel based on runtime information. + Autotune, + /// Cube implementation of matmul. + Cube, +} + +impl Default for MatmulStrategy { + fn default() -> Self { + // if autotune is enabled, default to autotune + #[cfg(feature = "autotune")] + return MatmulStrategy::Autotune; + + #[cfg(not(feature = "autotune"))] + MatmulStrategy::Cube + } +} + +/// Launch a matmul kernel using the given strategy. +pub fn matmul( + mut lhs: CubeTensor, + rhs: CubeTensor, + out: Option>, + strategy: MatmulStrategy, + out_dtype: DType, +) -> Result, MatmulSetupError> { + let out = out.unwrap_or_else(|| init_matmul_output(&lhs, &rhs, out_dtype)); + + // A broadcast-rhs batched matmul that would tile poorly is folded into a + // single matmul: `[.., b, m, k] @ [.., 1, k, n]` runs as `[.., 1, b*m, k]` + // instead of `b` matmuls that each re-read the whole rhs. Pure metadata — + // the launch operands share the handles, the returned tensor keeps the + // broadcast shape. + let mut out_launch = out.clone(); + if lhs.qparams.is_none() { + let analysis = MatmulTransformAnalysis::from_metadata(&lhs.meta, &rhs.meta, &out.meta); + let action = MatmulTransformPolicy::default().action(&analysis); + action.apply(&mut lhs.meta); + action.apply(&mut out_launch.meta); + } + + match strategy { + MatmulStrategy::Cube => { + launch_matmul(&Default::default(), lhs, rhs, out_launch)?; + Ok(out) + } + #[cfg(feature = "autotune")] + MatmulStrategy::Autotune => { + matmul_autotune(lhs, rhs, Some(out_launch), out_dtype); + Ok(out) + } + } +} + +pub(crate) fn launch_matmul_naive( + strategy: &Strategy, + mut lhs: CubeTensor, + mut rhs: CubeTensor, + out: CubeTensor, +) -> Result<(), MatmulSetupError> { + // Naive has very specific layout requirements for block scaled tensors, so we need to manually + // dequantize if it fails to launch normally. This is because naive is assumed to always work. + if lhs.qparams.is_some() || rhs.qparams.is_some() { + match launch_matmul(strategy, lhs.clone(), rhs.clone(), out.clone()) { + Err(_) => { + if lhs.qparams.is_some() { + lhs = dequantize(lhs, out.dtype); + } + if rhs.qparams.is_some() { + rhs = dequantize(rhs, out.dtype); + } + launch_matmul(strategy, lhs, rhs, out) + } + Ok(_) => Ok(()), + } + } else { + launch_matmul(strategy, lhs, rhs, out) + } +} + +pub(crate) fn launch_matmul( + strategy: &Strategy, + lhs: CubeTensor, + mut rhs: CubeTensor, + out: CubeTensor, +) -> Result<(), MatmulSetupError> { + let client = &out.client; + + let lhs_quant_handles = lhs.quantized_handles(); + let out_dtype: DType = out.dtype; + + let (lhs_dtype, lhs_handle) = match lhs_quant_handles { + None => { + let lhs_dtype = lhs.dtype; + ( + lhs_dtype, + InputBinding::new(lhs.binding(), dtype_to_storage_type(lhs_dtype)), + ) + } + Some((data, scale)) => { + let scheme = lhs.scheme(); + let data_dtype = data.dtype; + let scale_dtype = scale.dtype; + ( + out_dtype, + InputBinding::quantized( + data.binding(), + scale.binding(), + lhs.meta.shape().clone(), + scheme, + dtype_to_storage_type(data_dtype), + dtype_to_storage_type(scale_dtype), + ), + ) + } + }; + + let rhs_quant_handles = rhs.quantized_handles(); + + let (rhs_dtype, rhs_handle) = match rhs_quant_handles { + None => ( + lhs_dtype, + InputBinding::new(rhs.binding(), dtype_to_storage_type(lhs_dtype)), + ), + Some((data, scale)) => { + // Extremely hacky fix to ensure naive can run in every case + if matches!(strategy, Strategy::Naive) + && matches!(rhs.scheme().level, QuantLevel::Block(_)) + { + rhs = dequantize(rhs.clone(), lhs_dtype); + let rhs_dtype = rhs.dtype; + ( + lhs_dtype, + InputBinding::new(rhs.binding(), dtype_to_storage_type(rhs_dtype)), + ) + } else { + let scheme = rhs.scheme(); + let data_dtype = data.dtype; + let scale_dtype = scale.dtype; + ( + out_dtype, + InputBinding::quantized( + data.binding(), + scale.binding(), + rhs.meta.shape().clone(), + scheme, + dtype_to_storage_type(data_dtype), + dtype_to_storage_type(scale_dtype), + ), + ) + } + } + }; + + let mut dtypes = MatmulElems::from_globals(&MatmulGlobalElems { + lhs: dtype_to_storage_type(lhs_dtype), + rhs: dtype_to_storage_type(rhs_dtype), + out: dtype_to_storage_type(out_dtype), + }); + + cubek::matmul::launch::launch_ref( + strategy, + client, + lhs_handle, + rhs_handle, + out.clone().binding(), + &mut dtypes, + )?; + + Ok(()) +} diff --git a/crates/burn-cubecl/src/kernel/matmul/mod.rs b/crates/burn-cubecl/src/kernel/matmul/mod.rs new file mode 100644 index 0000000..86df664 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/matmul/mod.rs @@ -0,0 +1,10 @@ +mod base; +mod tune; + +/// Contains utilities for matmul operation +pub mod utils; + +pub use base::*; +#[cfg(feature = "autotune")] +pub use tune::*; +pub use utils::*; diff --git a/crates/burn-cubecl/src/kernel/matmul/tune/base.rs b/crates/burn-cubecl/src/kernel/matmul/tune/base.rs new file mode 100644 index 0000000..1ae9e42 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/matmul/tune/base.rs @@ -0,0 +1,520 @@ +use crate::{ + CubeRuntime, CubeTuneId, + kernel::matmul::{launch_matmul, launch_matmul_naive, utils::init_matmul_output}, + tensor::CubeTensor, +}; +use burn_backend::DType; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + std::tensor::MatrixBatchLayout, + tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}, +}; +use cubek::matmul::{ + components::tile::TileMatmulKind, + definition::MatmulKind, + routines::{ + BlueprintStrategy, TileSizeSelection, + batch::{ + double_buffering::DoubleBufferingArgs, double_unit::DoubleUnitSelectionArgs, + ordered_double_buffering::OrderedSelectionArgs, simple::SimpleArgs, + simple_unit::SimpleUnitSelectionArgs, + }, + cpu_gemm::CpuGemmStrategy, + gemm::GemmStrategy, + }, + strategy::{MatmulAutotuneKey, MatmulGlobalScale, Strategy, should_tune_double_buffering}, +}; + +fn matmul_input_gen( + _key: &MatmulAutotuneKey, + (lhs, rhs, out): &(CubeTensor, CubeTensor, CubeTensor), +) -> (CubeTensor, CubeTensor, CubeTensor) { + (lhs.clone(), rhs.clone(), out.copy()) +} + +/// Executes autotune on matmul operations +pub fn matmul_autotune( + lhs: CubeTensor, + rhs: CubeTensor, + out: Option>, + out_dtype: DType, +) -> CubeTensor { + let output = out.unwrap_or_else(|| init_matmul_output(&lhs, &rhs, out_dtype)); + + // Short-circuit: zero-sized matmul produces a zero-sized output. + if lhs.meta.shape().iter().any(|&d| d == 0) || rhs.meta.shape().iter().any(|&d| d == 0) { + return output; + } + + let client = lhs.client.clone(); + let num_cpu_cores = client.properties().hardware.num_cpu_cores; + + static TUNER: LocalTuner = local_tuner!(); + + let tunables = TUNER.init(move || { + const PRIORITY_MAX: i8 = 3; + const PRIORITY_HIGH: i8 = 2; + const PRIORITY_MEDIUM: i8 = 1; + const PRIORITY_MIN: i8 = 0; + const PRIORITY_NEVER: i8 = -1; + + let accelerated = TuneGroup::::new("accelerated", |key| { + if matches!(key.analysis.kind, MatmulKind::General) { + match key.analysis.scale_global { + MatmulGlobalScale::Large => PRIORITY_MAX, + _ => PRIORITY_HIGH, + } + + // In some case when a relayout can be fused (no call to into_contiguous) it's better + // to use accelerated matmul. + // + // TODO: Actually implement good gemv with fused relayout. + } else if matches!(key.analysis.kind, MatmulKind::MatVec | MatmulKind::VecMat) { + PRIORITY_MAX + } else { + PRIORITY_MEDIUM + } + }); + + let unit = TuneGroup::::new("unit", |key| { + if !matches!(key.analysis.kind, MatmulKind::General) + || matches!(key.analysis.scale_global, MatmulGlobalScale::Small) + { + PRIORITY_HIGH + } else { + PRIORITY_MEDIUM + } + }); + + let tma = TuneGroup::::new("tma", |key| { + // Zero-sized matmuls cannot use TMA kernels. + if key.definition.m == 0 || key.definition.n == 0 || key.definition.k == 0 { + return PRIORITY_NEVER; + } + + // For large matmul, we set the max priority to TMA kernels, higher than any other + // matmuls, since they are the best kernels no matter what. + // + // But only when all axis are large. + let max_axis = usize::max(key.definition.m, key.definition.n); + let max_axis = usize::max(key.definition.k, max_axis); + + let min_axis = usize::min(key.definition.m, key.definition.n); + let min_axis = usize::min(key.definition.k, min_axis); + + let skewed_factor = max_axis / min_axis; + + let priority_max = if matches!(key.analysis.kind, MatmulKind::General) + && matches!(key.analysis.scale_global, MatmulGlobalScale::Large) + && skewed_factor < 4 + { + PRIORITY_MAX + } else { + PRIORITY_HIGH + }; + + if key.definition.lhs_stride_factor >= 4 && key.definition.rhs_stride_factor >= 4 { + priority_max + } else { + PRIORITY_NEVER + } + }); + + let gemv = TuneGroup::::new("gemv", move |key| { + if num_cpu_cores.is_some() { + return PRIORITY_MAX; + } + + if matches!(key.analysis.kind, MatmulKind::MatVec) { + // LHS is the matrix + match key.definition.matrix_layout_lhs { + MatrixBatchLayout::Contiguous => PRIORITY_MAX, + MatrixBatchLayout::MildlyPermuted { transposed, .. } => { + // We don't yet have algo which are good for col major matvec. + if transposed { + PRIORITY_HIGH + } else { + PRIORITY_MAX + } + } + // Every algo will need to relayout, in this case, we should take the optimal + // kernel with a gemv. + MatrixBatchLayout::HighlyPermuted => PRIORITY_MAX, + } + } else if matches!(key.analysis.kind, MatmulKind::VecMat) { + // RHS is the matrix + match key.definition.matrix_layout_rhs { + // We don't have good algos for row major vecmat. + MatrixBatchLayout::Contiguous => PRIORITY_HIGH, + MatrixBatchLayout::MildlyPermuted { transposed, .. } => { + // Best algo is col major vec mat. + if transposed { + PRIORITY_MAX + } else { + PRIORITY_HIGH + } + } + // TODO: Actually do the correct relayout here. + // + // Every algo will need to relayout, in this case, we should take the optimal + // kernel with a gemv. + MatrixBatchLayout::HighlyPermuted => PRIORITY_HIGH, + } + } else { + PRIORITY_NEVER + } + }); + + // CPU-only group + let cpu = + TuneGroup::::new("cpu", move |_key| match num_cpu_cores.is_some() { + true => PRIORITY_MAX, + false => PRIORITY_NEVER, + }); + + fn double_buffering_priority(key: &MatmulAutotuneKey, max: i8, min: i8) -> i8 { + if should_tune_double_buffering(false, key) { + max + } else { + min + } + } + + let mut set = TunableSet::new(create_key::, matmul_input_gen::); + + // First entry should always work, since it is considered the fallback. + set = set.with( + Tunable::new("matmul_naive", |(lhs, rhs, out)| { + launch_matmul_naive::(&Strategy::Naive, lhs, rhs, out) + .map_err(|err| std::format!("{err:?}")) + }) + .group(&unit, |key| { + if matches!(key.analysis.kind, MatmulKind::InnerProduct) { + PRIORITY_MAX + } else if matches!(key.analysis.scale_global, MatmulGlobalScale::Small) { + PRIORITY_HIGH + } else { + PRIORITY_MIN + } + }), + ); + + // Matrix Vector multiplication kernels. + for (strategy, double_buf) in [ + ( + Strategy::DoubleVecMat(BlueprintStrategy::Inferred(().into())), + true, + ), + ( + Strategy::SimpleVecMat(BlueprintStrategy::Inferred(().into())), + false, + ), + ( + Strategy::Gemm(BlueprintStrategy::Inferred(Default::default())), + false, + ), + ( + Strategy::GemvUnitPerpendicular(BlueprintStrategy::Inferred(Default::default())), + false, + ), + ] { + set = set.with( + Tunable::new(&strategy.to_string(), move |(lhs, rhs, out)| { + launch_matmul::(&strategy, lhs, rhs, out) + .map_err(|err| std::format!("{err:?}")) + }) + .group(&gemv, move |key| match double_buf { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }), + ); + } + + // Unit matmuls + for tile_size in [ + TileSizeSelection::MaxTileSize, + TileSizeSelection::MinTileSize, + ] { + for (strategy, double_buf) in [ + ( + Strategy::SimpleUnit(BlueprintStrategy::Inferred(SimpleUnitSelectionArgs { + tile_size, + })), + false, + ), + ( + Strategy::DoubleUnit(BlueprintStrategy::Inferred(DoubleUnitSelectionArgs { + tile_size, + })), + true, + ), + ] { + set = set.with( + Tunable::new(&strategy.to_string(), move |(lhs, rhs, out)| { + launch_matmul::(&strategy, lhs, rhs, out) + .map_err(|err| format!("{err:?}")) + }) + .group(&unit, move |key| match double_buf { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }), + ) + } + } + + // Gemm no stage + // In unit because not accelerated + let gemm_no_stage_strategy = Strategy::Gemm(BlueprintStrategy::Inferred(GemmStrategy { + target_num_planes: None, + })); + set = set.with( + Tunable::new( + &gemm_no_stage_strategy.to_string(), + move |(lhs, rhs, out)| { + launch_matmul::(&gemm_no_stage_strategy, lhs, rhs, out) + .map_err(|err| format!("{err:?}")) + }, + ) + .group(&unit, move |_key| PRIORITY_MAX), + ); + + // CPU GEMM (CPU-only via the `cpu` group; the size limit is specific to this strategy). + let cpu_gemm_strategy = + Strategy::CpuGemm(BlueprintStrategy::Inferred(CpuGemmStrategy::default())); + set = set.with( + Tunable::new(&cpu_gemm_strategy.to_string(), move |(lhs, rhs, out)| { + launch_matmul::(&cpu_gemm_strategy, lhs, rhs, out) + .map_err(|err| format!("{err:?}")) + }) + .group(&cpu, move |_key| PRIORITY_MAX), + ); + + // Accelerated matmuls + for (strategy, double_buf, group_extra, tile_group) in [ + ( + Strategy::SimpleCyclicCmma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: false, + tile_matmul: TileMatmulKind::Cmma, + })), + false, + None, + &accelerated, + ), + ( + Strategy::SimpleCyclicMma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: false, + tile_matmul: TileMatmulKind::Mma, + })), + false, + None, + &accelerated, + ), + ( + Strategy::SimpleCyclicCmma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: true, + tile_matmul: TileMatmulKind::Cmma, + })), + false, + None, + &accelerated, + ), + ( + Strategy::SimpleCyclicMma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: true, + tile_matmul: TileMatmulKind::Mma, + })), + false, + None, + &accelerated, + ), + ( + Strategy::OrderedDoubleCmma(BlueprintStrategy::Inferred(OrderedSelectionArgs { + partition_k: Some(2), + row_count: Some(4), + rows_per_plane: Some(2), + tile_matmul: TileMatmulKind::Cmma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::OrderedDoubleMma(BlueprintStrategy::Inferred(OrderedSelectionArgs { + partition_k: Some(2), + row_count: Some(4), + rows_per_plane: Some(2), + tile_matmul: TileMatmulKind::Mma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::OrderedDoubleCmma(BlueprintStrategy::Inferred(OrderedSelectionArgs { + partition_k: Some(2), + row_count: Some(8), + rows_per_plane: Some(2), + tile_matmul: TileMatmulKind::Cmma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::OrderedDoubleMma(BlueprintStrategy::Inferred(OrderedSelectionArgs { + partition_k: Some(2), + row_count: Some(8), + rows_per_plane: Some(2), + tile_matmul: TileMatmulKind::Mma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::DoubleCyclicCmma(BlueprintStrategy::Inferred(DoubleBufferingArgs { + specialized: false, + tile_matmul: TileMatmulKind::Cmma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::DoubleCyclicMma(BlueprintStrategy::Inferred(DoubleBufferingArgs { + specialized: false, + tile_matmul: TileMatmulKind::Mma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::DoubleCyclicCmma(BlueprintStrategy::Inferred(DoubleBufferingArgs { + specialized: true, + tile_matmul: TileMatmulKind::Cmma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::DoubleCyclicMma(BlueprintStrategy::Inferred(DoubleBufferingArgs { + specialized: true, + tile_matmul: TileMatmulKind::Mma, + })), + true, + None, + &accelerated, + ), + ( + Strategy::SpecializedCyclicCmma(BlueprintStrategy::Inferred(().into())), + true, + None, + &accelerated, + ), + ( + Strategy::SpecializedCyclicMma(BlueprintStrategy::Inferred(().into())), + true, + None, + &accelerated, + ), + ( + Strategy::SimpleTmaCmma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: false, + tile_matmul: TileMatmulKind::Cmma, + })), + false, + Some(&tma), + &accelerated, + ), + ( + Strategy::SimpleTmaMma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: false, + tile_matmul: TileMatmulKind::Mma, + })), + false, + Some(&tma), + &accelerated, + ), + ( + Strategy::SimpleTmaCmma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: true, + tile_matmul: TileMatmulKind::Cmma, + })), + false, + Some(&tma), + &accelerated, + ), + ( + Strategy::SimpleTmaMma(BlueprintStrategy::Inferred(SimpleArgs { + multi_rows: true, + tile_matmul: TileMatmulKind::Mma, + })), + false, + Some(&tma), + &accelerated, + ), + ( + Strategy::SpecializedTmaCmma(BlueprintStrategy::Inferred(().into())), + true, + Some(&tma), + &accelerated, + ), + ( + Strategy::SpecializedTmaMma(BlueprintStrategy::Inferred(().into())), + true, + Some(&tma), + &accelerated, + ), + ] { + let priority_within_group = |key: &MatmulAutotuneKey, double_buf: bool| match double_buf + { + false => PRIORITY_MAX, + true => double_buffering_priority(key, PRIORITY_MAX, PRIORITY_HIGH), + }; + let mut tunable = Tunable::new(&strategy.to_string(), move |(lhs, rhs, out)| { + launch_matmul::(&strategy, lhs, rhs, out).map_err(|err| format!("{err:?}")) + }); + + // tile group + tunable = tunable.group(tile_group, move |key| { + priority_within_group(key, double_buf) + }); + + // extra group + if let Some(group) = group_extra { + tunable = tunable.group(group, move |key| priority_within_group(key, double_buf)); + } + set = set.with(tunable); + } + + set + }); + + TUNER.execute( + &CubeTuneId::new(&lhs.client, &lhs.device), + &client, + tunables, + (lhs, rhs, output.clone()), + ); + + output +} + +fn create_key( + (lhs, rhs, out): &(CubeTensor, CubeTensor, CubeTensor), +) -> MatmulAutotuneKey { + MatmulAutotuneKey::generate( + &lhs.client, + lhs.meta.shape(), + rhs.meta.shape(), + lhs.meta.strides(), + rhs.meta.strides(), + dtype_to_storage_type(lhs.dtype), + dtype_to_storage_type(rhs.dtype), + dtype_to_storage_type(out.dtype), + lhs.try_scheme(), + rhs.try_scheme(), + ) +} diff --git a/crates/burn-cubecl/src/kernel/matmul/tune/mod.rs b/crates/burn-cubecl/src/kernel/matmul/tune/mod.rs new file mode 100644 index 0000000..87ee928 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/matmul/tune/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "autotune")] +mod base; + +#[cfg(feature = "autotune")] +pub use base::matmul_autotune; diff --git a/crates/burn-cubecl/src/kernel/matmul/utils.rs b/crates/burn-cubecl/src/kernel/matmul/utils.rs new file mode 100644 index 0000000..fbbf69f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/matmul/utils.rs @@ -0,0 +1,16 @@ +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::{DType, calculate_matmul_output}; + +/// Creates an empty output tensor with matmul output shape +pub fn init_matmul_output( + lhs: &CubeTensor, + rhs: &CubeTensor, + dtype: DType, +) -> CubeTensor { + empty_device_dtype( + lhs.client.clone(), + lhs.device.clone(), + calculate_matmul_output(lhs.meta.shape(), rhs.meta.shape()).unwrap(), + dtype, + ) +} diff --git a/crates/burn-cubecl/src/kernel/mod.rs b/crates/burn-cubecl/src/kernel/mod.rs new file mode 100644 index 0000000..d59d8a9 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/mod.rs @@ -0,0 +1,55 @@ +mod binary; +mod binary_float; +mod binary_int; +mod cast; +mod clamp; +mod comparison; +mod contiguous; +mod cross; +mod index; +mod mask; +mod unary_float; +mod unary_int; +mod unary_numeric; + +pub(crate) use binary::*; +pub(crate) use binary_float::*; +pub(crate) use binary_int::*; +pub use cast::*; +pub use contiguous::*; +pub(crate) use cross::*; +pub use mask::*; +pub(crate) use unary_float::*; +pub(crate) use unary_int::*; +pub(crate) use unary_numeric::*; + +pub use crate::cubecl::prelude::KernelMetadata; + +/// Attention kernels +pub mod attention; +/// Convolution kernels +pub mod conv; +/// CTC loss kernel +pub mod ctc; +/// FFT algorithms +pub mod fft; +/// Grid sampling kernels +pub mod grid_sample; +/// Interpolation kernels +pub mod interpolate; +/// Matmul kernels +pub mod matmul; +/// Pooling kernels +pub mod pool; +/// Pseudo-random number generator kernels +pub mod prng; +/// Quantization operations +pub mod quantization; +/// Reduction algorithms +pub mod reduce; + +pub(crate) use clamp::*; +pub(crate) use comparison::*; +pub use index::*; + +pub(crate) mod utils; diff --git a/crates/burn-cubecl/src/kernel/pool/base.rs b/crates/burn-cubecl/src/kernel/pool/base.rs new file mode 100644 index 0000000..382c83e --- /dev/null +++ b/crates/burn-cubecl/src/kernel/pool/base.rs @@ -0,0 +1,336 @@ +use crate::{ + CubeRuntime, + kernel::into_contiguous_aligned, + ops::{numeric::empty_device_dtype, permute_nchw_to_nhwc, permute_nhwc_to_nchw}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, Shape, ops::conv::calculate_pool_output_size}; +use cubek::pool::{ + definition::{AdaptiveAvgPoolOptions, AvgPoolOptions, MaxPoolOptions, PoolError, PoolMode}, + pool2d, pool2d_backward, pool2d_with_indices, pool2d_with_indices_backward, +}; + +pub(crate) fn max_pool2d( + x: CubeTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> CubeTensor { + let [batch_size, channels, height, width] = x.meta.shape().dims(); + + let size_0 = calculate_pool_output_size( + kernel_size[0], + stride[0], + padding[0], + dilation[0], + height, + ceil_mode, + ); + let size_1 = calculate_pool_output_size( + kernel_size[1], + stride[1], + padding[1], + dilation[1], + width, + ceil_mode, + ); + + let x = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + + let shape_out = Shape::new([batch_size, size_0, size_1, channels]); + let output = empty_device_dtype(x.client.clone(), x.device.clone(), shape_out, x.dtype); + + let mode = PoolMode::from(MaxPoolOptions::new( + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )); + + pool2d( + &output.client, + x.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("max_pool2d", &x, e)); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn max_pool2d_with_indices( + x: CubeTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + dtype_indices: DType, +) -> (CubeTensor, CubeTensor) { + let [batch_size, channels, size_0, size_1] = x.meta.shape().dims(); + + let size_0 = calculate_pool_output_size( + kernel_size[0], + stride[0], + padding[0], + dilation[0], + size_0, + ceil_mode, + ); + let size_1 = calculate_pool_output_size( + kernel_size[1], + stride[1], + padding[1], + dilation[1], + size_1, + ceil_mode, + ); + + let x = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + + let shape_out = Shape::new([batch_size, size_0, size_1, channels]); + let output = empty_device_dtype( + x.client.clone(), + x.device.clone(), + shape_out.clone(), + x.dtype, + ); + let indices = empty_device_dtype(x.client.clone(), x.device.clone(), shape_out, dtype_indices); + + let mode = PoolMode::from(MaxPoolOptions::new( + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )); + + pool2d_with_indices( + &output.client, + x.clone().binding(), + output.clone().binding(), + indices.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("max_pool2d_with_indices", &x, e)); + + let output = permute_nhwc_to_nchw(output); + let indices = permute_nhwc_to_nchw(indices); + (output, indices) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn max_pool2d_with_indices_backward( + x: CubeTensor, + grad: CubeTensor, + indices: CubeTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> CubeTensor { + let [batches, channels, height, width] = x.meta.shape().dims(); + let input = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + let grad = into_contiguous_aligned(permute_nchw_to_nhwc(grad)); + let indices = into_contiguous_aligned(permute_nchw_to_nhwc(indices)); + + let out_shape = Shape::new([batches, height, width, channels]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + out_shape, + input.dtype, + ); + + let mode = PoolMode::from(MaxPoolOptions::new( + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )); + + pool2d_with_indices_backward( + &output.client, + input.clone().binding(), + grad.clone().binding(), + indices.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + dtype_to_storage_type(indices.dtype), + ) + .unwrap_or_else(|e| pool_panic("max_pool2d_with_indices_backward", &input, e)); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn avg_pool2d( + x: CubeTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> CubeTensor { + let [batch_size, channels, in_h, in_w] = x.meta.shape().dims(); + let dilation = 1; + + let size_0 = calculate_pool_output_size( + kernel_size[0], + stride[0], + padding[0], + dilation, + in_h, + ceil_mode, + ); + let size_1 = calculate_pool_output_size( + kernel_size[1], + stride[1], + padding[1], + dilation, + in_w, + ceil_mode, + ); + + let x = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + + let shape_out = Shape::new([batch_size, size_0, size_1, channels]); + let output = empty_device_dtype(x.client.clone(), x.device.clone(), shape_out, x.dtype); + + let mode = PoolMode::from(AvgPoolOptions::new( + kernel_size, + stride, + padding, + ceil_mode, + count_include_pad, + )); + + pool2d( + &output.client, + x.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("avg_pool2d", &x, e)); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn avg_pool2d_backward( + x: CubeTensor, + grad: CubeTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> CubeTensor { + let [batches, channels, height, width] = x.meta.shape().dims(); + let input = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + let grad = into_contiguous_aligned(permute_nchw_to_nhwc(grad)); + + let out_shape = Shape::new([batches, height, width, channels]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + out_shape, + input.dtype, + ); + + let mode = PoolMode::from(AvgPoolOptions::new( + kernel_size, + stride, + padding, + ceil_mode, + count_include_pad, + )); + + pool2d_backward( + &output.client, + input.clone().binding(), + grad.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("avg_pool2d_backward", &input, e)); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn adaptive_avg_pool2d( + input: CubeTensor, + output_size: [usize; 2], +) -> CubeTensor { + let [batch_size, channels, _, _] = input.meta.shape().dims(); + let input = into_contiguous_aligned(permute_nchw_to_nhwc(input)); + + let output_shape = Shape::new([batch_size, output_size[0], output_size[1], channels]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + output_shape, + input.dtype, + ); + + let mode = PoolMode::from(AdaptiveAvgPoolOptions::new(output_size)); + + pool2d( + &output.client, + input.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("adaptive_avg_pool2d", &input, e)); + + permute_nhwc_to_nchw(output) +} + +pub(crate) fn adaptive_avg_pool2d_backward( + x: CubeTensor, + out_grad: CubeTensor, +) -> CubeTensor { + let [batches, channels, height, width] = x.meta.shape().dims(); + let [_, _, out_h, out_w] = out_grad.meta.shape().dims(); + let input = into_contiguous_aligned(permute_nchw_to_nhwc(x)); + let out_grad = into_contiguous_aligned(permute_nchw_to_nhwc(out_grad)); + + let out_shape = Shape::new([batches, height, width, channels]); + let output = empty_device_dtype( + input.client.clone(), + input.device.clone(), + out_shape, + input.dtype, + ); + + let mode = PoolMode::from(AdaptiveAvgPoolOptions::new([out_h, out_w])); + + pool2d_backward( + &output.client, + input.clone().binding(), + out_grad.clone().binding(), + output.clone().binding(), + mode, + dtype_to_storage_type(output.dtype), + ) + .unwrap_or_else(|e| pool_panic("adaptive_avg_pool2d_backward", &input, e)); + + permute_nhwc_to_nchw(output) +} + +fn pool_panic(label: &str, input: &CubeTensor, error: PoolError) -> ! { + panic!( + "{0} kernel failed (device={1:?}, dtype={2:?}): {3}", + label, input.device, input.dtype, error + ) +} diff --git a/crates/burn-cubecl/src/kernel/pool/mod.rs b/crates/burn-cubecl/src/kernel/pool/mod.rs new file mode 100644 index 0000000..41e113f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/pool/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub(crate) use base::*; diff --git a/crates/burn-cubecl/src/kernel/prng/bernoulli.rs b/crates/burn-cubecl/src/kernel/prng/bernoulli.rs new file mode 100644 index 0000000..395cf15 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/prng/bernoulli.rs @@ -0,0 +1,24 @@ +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, Shape}; + +/// Pseudo-random generator with bernoulli distribution +pub fn random_bernoulli( + shape: Shape, + device: &R::Device, + probability: f32, + dtype: DType, +) -> CubeTensor { + let client = R::client(device); + let output = empty_device_dtype(client.clone(), device.clone(), shape, dtype); + + cubek::random::random_bernoulli( + &client, + probability, + output.clone().binding(), + dtype_to_storage_type(dtype), + ) + .expect("Kernel to never fail"); + + output +} diff --git a/crates/burn-cubecl/src/kernel/prng/mod.rs b/crates/burn-cubecl/src/kernel/prng/mod.rs new file mode 100644 index 0000000..10f2cfe --- /dev/null +++ b/crates/burn-cubecl/src/kernel/prng/mod.rs @@ -0,0 +1,7 @@ +mod bernoulli; +mod normal; +mod uniform; + +pub use bernoulli::*; +pub use normal::*; +pub use uniform::*; diff --git a/crates/burn-cubecl/src/kernel/prng/normal.rs b/crates/burn-cubecl/src/kernel/prng/normal.rs new file mode 100644 index 0000000..462580f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/prng/normal.rs @@ -0,0 +1,26 @@ +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, Shape}; + +/// Pseudo-random generator with uniform distribution +pub fn random_normal( + shape: Shape, + device: &R::Device, + mean: f32, + std: f32, + dtype: DType, +) -> CubeTensor { + let client = R::client(device); + let output = empty_device_dtype(client.clone(), device.clone(), shape, dtype); + + cubek::random::random_normal( + &client, + mean, + std, + output.clone().binding(), + dtype_to_storage_type(dtype), + ) + .expect("Kernel to never fail"); + + output +} diff --git a/crates/burn-cubecl/src/kernel/prng/uniform.rs b/crates/burn-cubecl/src/kernel/prng/uniform.rs new file mode 100644 index 0000000..e29a51f --- /dev/null +++ b/crates/burn-cubecl/src/kernel/prng/uniform.rs @@ -0,0 +1,43 @@ +use crate::{CubeRuntime, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, Shape, TensorMetadata}; + +/// Pseudo-random generator with uniform distribution +pub fn random_uniform( + shape: Shape, + device: &R::Device, + lower_bound: f32, + upper_bound: f32, + dtype: DType, +) -> CubeTensor { + let client = R::client(device); + let output = empty_device_dtype(client.clone(), device.clone(), shape, dtype); + + cubek::random::random_uniform( + &client, + lower_bound, + upper_bound, + output.clone().binding(), + dtype_to_storage_type(dtype), + ) + .expect("Kernel to never fail"); + + output +} + +/// Pseudo-random generator for uniform distribution, based on +/// another tensor. +pub fn random_like_uniform( + tensor: &CubeTensor, + lower_bound: f32, + upper_bound: f32, + dtype: DType, +) -> CubeTensor { + random_uniform( + tensor.shape(), + &tensor.device, + lower_bound, + upper_bound, + dtype, + ) +} diff --git a/crates/burn-cubecl/src/kernel/quantization/dequantize.rs b/crates/burn-cubecl/src/kernel/quantization/dequantize.rs new file mode 100644 index 0000000..667896d --- /dev/null +++ b/crates/burn-cubecl/src/kernel/quantization/dequantize.rs @@ -0,0 +1,35 @@ +use crate::tensor::CubeTensor; +use crate::{CubeRuntime, ops::numeric::empty_device_dtype}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, TensorMetadata}; + +/// Convert the tensor back to a higher precision data type. +pub fn dequantize(tensor: CubeTensor, dtype: DType) -> CubeTensor +where + R: CubeRuntime, +{ + let scheme = match tensor.dtype { + DType::QFloat(scheme) => scheme, + _ => return tensor, + }; + + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + dtype, + ); + let (values, params) = tensor.quantized_handles().unwrap(); + + cubek::quantization::dequantize::launch_ref( + &output.client, + values.binding(), + output.clone().binding(), + params.binding(), + &scheme, + dtype_to_storage_type(dtype), + ) + .expect("Kernel to never fail"); + + output +} diff --git a/crates/burn-cubecl/src/kernel/quantization/mod.rs b/crates/burn-cubecl/src/kernel/quantization/mod.rs new file mode 100644 index 0000000..a0244df --- /dev/null +++ b/crates/burn-cubecl/src/kernel/quantization/mod.rs @@ -0,0 +1,5 @@ +mod dequantize; +mod quantize; + +pub use dequantize::*; +pub use quantize::*; diff --git a/crates/burn-cubecl/src/kernel/quantization/quantize.rs b/crates/burn-cubecl/src/kernel/quantization/quantize.rs new file mode 100644 index 0000000..25ca88b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/quantization/quantize.rs @@ -0,0 +1,31 @@ +use crate::CubeRuntime; +use crate::{ops::empty_qtensor_optimized, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_elem_type; +use burn_backend::{TensorMetadata, quantization::QuantScheme}; + +/// Convert the tensor to a lower precision data type based on the quantization scheme and parameters. +pub fn quantize( + tensor: CubeTensor, + scheme: &QuantScheme, + scale: CubeTensor, +) -> CubeTensor +where + R: CubeRuntime, +{ + let output = empty_qtensor_optimized(tensor.shape(), *scheme, &tensor.device); + let (out_values, out_params) = output.clone().quantized_handles().unwrap(); + let dtype = tensor.dtype; + + cubek::quantization::quantize::launch_ref( + &output.client, + tensor.binding(), + out_values.binding(), + scale.binding(), + out_params.binding(), + scheme, + dtype_to_elem_type(dtype), + ) + .expect("Kernel to never fail"); + + output +} diff --git a/crates/burn-cubecl/src/kernel/reduce/base.rs b/crates/burn-cubecl/src/kernel/reduce/base.rs new file mode 100644 index 0000000..96aa46b --- /dev/null +++ b/crates/burn-cubecl/src/kernel/reduce/base.rs @@ -0,0 +1,294 @@ +#[cfg(feature = "autotune")] +use super::{autotune_reduce, autotune_sum}; +use crate::{ + CubeRuntime, + ops::numeric::{empty_device_contiguous_dtype, zeros_client}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::{dtype_to_elem_type, elem_type_to_dtype}; +use burn_backend::{DType, TensorMetadata}; +use burn_std::{BoolDType, Metadata}; +use cubecl::{AutotuneKey, client::ComputeClient, features::AtomicUsage, ir::Type}; +use cubek::reduce::{ + ReduceDtypes, ReduceError, ReduceStrategy, + components::instructions::ReduceOperationConfig, + launch::{RoutineStrategy, VectorizationStrategy}, + routines::{BlueprintStrategy, unit::UnitStrategy}, + shared_sum, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize, AutotuneKey)] +/// Autotune key representative of sum versions +pub struct SumAutotuneKey { + /// The type of the tensor + dtype: burn_backend::DType, + /// The anchored length of the tensor + #[autotune(anchor)] + length: usize, +} + +/// Check if the client supports atomic add for the given element type. +fn supports_atomic_add(client: &ComputeClient, dtype: DType) -> bool { + client + .properties() + .atomic_type_usage(Type::atomic(dtype_to_elem_type(dtype))) + .contains(AtomicUsage::Add) +} + +/// [Sum](sum) with fallback when `client` doesn't support atomic add for the type `E`. +pub fn sum_fallback( + tensor: CubeTensor, + mut strategy: SumStrategy, +) -> Result, ReduceError> { + // Early check before creating output and fallback + if matches!(strategy, SumStrategy::OneShot(_)) + && !supports_atomic_add(&tensor.client, tensor.dtype) + { + strategy = SumStrategy::Chained(Default::default()); + } + sum(tensor, strategy) +} + +/// Specialize reduce function to compute the sum of all elements of the `input` tensor and return +/// the value into a single-element tensor of shape `1 x 1 x 1 x ...` with the same rank as `input`. +/// +/// This is expected to be faster for larger tensors than calling [reduce] with the `Sum` instruction. +/// +/// Return an error if the `client` doesn't support atomic add for the type `E`. +pub fn sum( + tensor: CubeTensor, + strategy: SumStrategy, +) -> Result, ReduceError> { + let client = tensor.client.clone(); + let device = tensor.device.clone(); + + match strategy { + SumStrategy::OneShot(cube_count) => { + let output = zeros_client(client.clone(), device, [1].into(), tensor.dtype); + let dtype = tensor.dtype; + + shared_sum::( + &client, + tensor.binding(), + output.clone().binding(), + cube_count, + dtype_to_elem_type(dtype), + )?; + + Ok(output) + } + SumStrategy::Chained(strategy) => { + reduce::(tensor, None, strategy, ReduceOperationConfig::Sum) + } + #[cfg(feature = "autotune")] + SumStrategy::Autotune => Ok(autotune_sum::(&client, tensor)), + } +} + +/// Select a strategy to perform a sum. +pub enum SumStrategy { + /// Run a single kernel with many cubes working in parallel to sum all elements. + /// The provided value is the number of elements summed per unit (up-to-rounding ) + OneShot(u32), + /// Use multiple kernels + Chained(KernelReduceStrategy), + /// Use autotune to find the best cube count given the hardware and the input. + #[cfg(feature = "autotune")] + Autotune, +} + +impl Default for SumStrategy { + fn default() -> Self { + #[cfg(feature = "autotune")] + return Self::Autotune; + + #[cfg(not(feature = "autotune"))] + return Self::OneShot(4); + } +} + +/// Reduce all elements of the `input` tensor using the instruction `Rd` and the given [Strategy](ReduceStrategy). +/// +/// Return an error if `strategy` is `Specific(strategy)` and the specified strategy is not supported by the `client`. +/// +/// If there is no error, the output is a tensor with decreasing strides +/// where the shape of reduced dim is set to 1 but all shape are similar to the input. +pub fn reduce( + mut tensor: CubeTensor, + output_dtype: Option, + strategy: KernelReduceStrategy, + config: ReduceOperationConfig, +) -> Result, cubek::reduce::ReduceError> { + // In practice, it looks like starting by the axis with the smallest shape + // and going in increasing order lead to the fastest calculation. + let sorted_axis = argsort(tensor.meta.shape()); + for axis in sorted_axis { + tensor = reduce_dim::(tensor, output_dtype, axis, strategy.clone(), config)?; + } + // reshape to scalar tensor + *tensor.meta = Metadata::new([1], [1]); + Ok(tensor) +} + +/// Reduce with a logical instruction ([`Any`](ReduceOperationConfig::Any) / +/// [`All`](ReduceOperationConfig::All)) and return the result as a boolean tensor. +/// +/// `Any` / `All` require the output dtype (like `Arg*` index outputs): the +/// kernel writes the `0/1` flags directly into the numeric backing of the +/// boolean storage (cubek has no bool elem), so the only step left here is the +/// kernel-free relabel to `Bool`. +/// +/// `dim == None` reduces the whole tensor to a scalar; `Some(dim)` reduces a +/// single axis, keeping it with length 1. +pub fn reduce_logical( + tensor: CubeTensor, + dim: Option, + config: ReduceOperationConfig, + out_dtype: BoolDType, +) -> CubeTensor { + debug_assert!( + matches!( + config, + ReduceOperationConfig::Any | ReduceOperationConfig::All + ), + "reduce_logical only supports Any / All, got {config:?}" + ); + let out_bool = DType::Bool(out_dtype); + let backing = elem_type_to_dtype(dtype_to_elem_type(out_bool)); + + let mut out = match dim { + Some(d) => reduce_dim::(tensor, Some(backing), d, Default::default(), config), + None => reduce::(tensor, Some(backing), Default::default(), config), + } + .expect("Any/All reduce on a valid axis cannot fail"); + + out.dtype = out_bool; // same storage, relabel as Bool (no kernel) + out +} + +fn argsort(shape: &[usize]) -> Vec { + let mut indices = (0..shape.len()).collect::>(); + indices.sort_by_key(|&i| &shape[i]); + indices +} + +/// Reduce the given `axis` of the `input` tensor using the instruction `Rd` and the given [Strategy](ReduceStrategy). +/// +/// Return an error if `strategy` is `Specific(strategy)` and the specified strategy is not supported by the `client`. +/// Also returns an error if the `axis` is larger than the `input` rank or if the shape of `output` is invalid. +/// +/// If there is no error, the output is a tensor with decreasing strides +/// where the shape of reduced dim is set to 1 but all shape are similar to the input. +pub fn reduce_dim( + input: CubeTensor, + output_dtype: Option, + dim: usize, + strategy: KernelReduceStrategy, + config: ReduceOperationConfig, +) -> Result, cubek::reduce::ReduceError> { + debug_assert!( + !matches!( + config, + ReduceOperationConfig::ArgMax + | ReduceOperationConfig::ArgMin + | ReduceOperationConfig::ArgTopK(_) + | ReduceOperationConfig::Any + | ReduceOperationConfig::All + ) || output_dtype.is_some(), + "The `output_dtype` has to be `Some` when the `config` is `ArgMax`, `ArgMin`, `ArgTopK`, `Any` or `All`. + " + ); + + let accumulator_len = match config { + ReduceOperationConfig::ArgTopK(k) => k, + ReduceOperationConfig::TopK(k) => k, + _ => 1, + }; + let dtypes = config.precision( + dtype_to_elem_type(input.dtype), + output_dtype.map(dtype_to_elem_type), + ); + let client = input.client.clone(); + let output = init_reduce_output::(&input, dim, &dtypes, accumulator_len).ok_or( + cubek::reduce::ReduceError::InvalidAxis { + axis: dim, + rank: input.meta.num_dims(), + }, + )?; + + let result = match strategy { + KernelReduceStrategy::Unspecified => cubek::reduce::reduce::( + &client, + input.binding(), + output.clone().binding(), + dim, + ReduceStrategy { + routine: RoutineStrategy::Unit(BlueprintStrategy::Inferred(UnitStrategy)), + vectorization: VectorizationStrategy { + parallel_output_vectorization: false, + }, + }, + config, + dtypes, + ), + KernelReduceStrategy::Specific(strategy) => cubek::reduce::reduce::( + &client, + input.binding(), + output.clone().binding(), + dim, + strategy, + config, + dtypes, + ), + #[cfg(feature = "autotune")] + KernelReduceStrategy::Autotune => { + autotune_reduce::(&client, input, output.clone(), dim, config, dtypes); + Ok(()) + } + }; + result.map(|_| output) +} + +/// Creates an empty output tensor with the proper shape and decreasing strides to reduce the given `axis` of `input` +/// or return `None` if `axis` is out-of-bound. +pub fn init_reduce_output( + input: &CubeTensor, + dim: usize, + dtypes: &ReduceDtypes, + accumulator_len: usize, +) -> Option> { + (dim < input.meta.num_dims()).then(|| { + let mut shape_out = input.shape(); + shape_out[dim] = accumulator_len; + empty_device_contiguous_dtype( + input.client.clone(), + input.device.clone(), + shape_out, + elem_type_to_dtype(dtypes.output.elem_type()), + ) + }) +} + +/// Select a strategy to perform a reduction. +#[derive(Clone, Debug)] +pub enum KernelReduceStrategy { + /// Use a best-effort strategy based on the hardware capacity. + /// This differs from Autotune as it doesn't try and compare many strategies to select the best. + Unspecified, + /// Fix the exact strategy for the reduction. + Specific(cubek::reduce::launch::ReduceStrategy), + /// Use autotune to find the best strategy given the hardware and the inputs. + #[cfg(feature = "autotune")] + Autotune, +} + +impl Default for KernelReduceStrategy { + fn default() -> Self { + #[cfg(feature = "autotune")] + return Self::Autotune; + + #[cfg(not(feature = "autotune"))] + return Self::Unspecified; + } +} diff --git a/crates/burn-cubecl/src/kernel/reduce/mod.rs b/crates/burn-cubecl/src/kernel/reduce/mod.rs new file mode 100644 index 0000000..13a6c23 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/reduce/mod.rs @@ -0,0 +1,7 @@ +mod base; +#[cfg(feature = "autotune")] +mod tune; + +pub use base::*; +#[cfg(feature = "autotune")] +pub use tune::*; diff --git a/crates/burn-cubecl/src/kernel/reduce/tune.rs b/crates/burn-cubecl/src/kernel/reduce/tune.rs new file mode 100644 index 0000000..8a60187 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/reduce/tune.rs @@ -0,0 +1,292 @@ +#![allow(missing_docs)] + +use super::SumAutotuneKey; +use crate::{CubeAutotuneKey, CubeRuntime, CubeTuneId, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_elem_type; +use cubecl::{ + client::ComputeClient, + tune::{LocalTuner, Tunable, TunableSet, TuneGroup, local_tuner}, +}; +use cubek::reduce::{ + ReduceDtypes, ReduceStrategy, + components::instructions::ReduceOperationConfig, + launch::{RoutineStrategy, VectorizationStrategy, tune_key::ReduceAutotuneKey}, + routines::{BlueprintStrategy, cube::CubeStrategy, plane::PlaneStrategy, unit::UnitStrategy}, +}; + +/// Executes autotune on reduce operations. +pub fn autotune_reduce( + client: &ComputeClient, + input: CubeTensor, + output: CubeTensor, + axis: usize, + config: ReduceOperationConfig, + dtypes: ReduceDtypes, +) { + use reduce_ops::*; + + static TUNER: LocalTuner = local_tuner!("reduce-dim"); + + let tunables = TUNER.init(|| { + const PRIORITY_MAX: i8 = 2; + const PRIORITY_MIN: i8 = 1; + const PRIORITY_SKIP: i8 = -1; + + let mut set = TunableSet::new(create_key::, reduce_input_gen::); + + let default_group = + TuneGroup::::new("default_reduce", |_key| PRIORITY_MAX); + let vectorized_parallel_group = + TuneGroup::::new("vectorized_parallel_reduce", |key| { + if key.axis_is_contiguous { + PRIORITY_MAX + } else { + // We disable the tunable with the setting [vector_size.parallel_output_vectorization] + // when the reduce isn't parallel, since it would duplicate tunables. + PRIORITY_SKIP + } + }); + + enum ReduceProps { + GreatWithLowReduceCount, + GreatWithHighReduceCount, + Balanced, + } + + for (vectorization, vector_size_ident) in [ + ( + VectorizationStrategy { + parallel_output_vectorization: true, + }, + "_vectorized_parallel_reduce", + ), + ( + VectorizationStrategy { + parallel_output_vectorization: false, + }, + "", + ), + ] { + for (name, routine, props) in [ + ( + "unit", + RoutineStrategy::Unit(BlueprintStrategy::Inferred(UnitStrategy)), + ReduceProps::GreatWithHighReduceCount, + ), + ( + "plane", + RoutineStrategy::Plane(BlueprintStrategy::Inferred(PlaneStrategy { + independent: true, + })), + ReduceProps::Balanced, + ), + ( + "cube", + RoutineStrategy::Cube(BlueprintStrategy::Inferred(CubeStrategy { + use_planes: true, + })), + ReduceProps::GreatWithLowReduceCount, + ), + ] { + let name = format!("{name}{vector_size_ident}"); + let mut tunable = Tunable::new( + &name, + move |(input, output, axis, config, dtypes): ( + CubeTensor, + CubeTensor, + usize, + ReduceOperationConfig, + ReduceDtypes, + )| { + let strategy = ReduceStrategy { + routine: routine.clone(), + vectorization, + }; + cubek::reduce::reduce::( + &output.client, + input.binding(), + output.clone().binding(), + axis, + strategy, + config, + dtypes, + ) + .map_err(|e| format!("{e}")) + }, + ); + if vectorization.parallel_output_vectorization { + tunable = tunable.group(&vectorized_parallel_group, |_| PRIORITY_MAX); + } + + tunable = tunable.group(&default_group, move |key| match props { + ReduceProps::GreatWithLowReduceCount => { + if key.vector_count < 128 { + PRIORITY_MAX + } else { + // When you have a high level of vector to reduce, it is normally + // better to use another routine. + PRIORITY_MIN + } + } + ReduceProps::GreatWithHighReduceCount => { + if key.vector_count > 64 { + PRIORITY_MAX + } else { + // Bellow 64 it is normally better to use another routine + PRIORITY_MIN + } + } + ReduceProps::Balanced => PRIORITY_MAX, + }); + set = set.with(tunable); + } + } + + set + }); + + TUNER.execute( + &CubeTuneId::new(&input.client, &input.device), + client, + tunables, + (input, output, axis, config, dtypes), + ); +} + +pub(crate) fn create_key( + (input, output, axis, _config, dtypes): &( + CubeTensor, + CubeTensor, + usize, + ReduceOperationConfig, + ReduceDtypes, + ), +) -> ReduceAutotuneKey { + let elem_input = dtype_to_elem_type(input.dtype); + let elem_output = dtype_to_elem_type(output.dtype); + let elem_acc = dtypes.accumulation.elem_type(); + + ReduceAutotuneKey::generate( + elem_input, + elem_output, + elem_acc, + input.meta.shape(), + input.meta.strides()[*axis] == 1, + *axis, + ) +} + +mod reduce_ops { + #![allow(missing_docs)] + + use cubek::reduce::ReduceDtypes; + + use super::*; + + pub(crate) fn reduce_input_gen( + _key: &ReduceAutotuneKey, + (input, output, dim, config, dtypes): &( + CubeTensor, + CubeTensor, + usize, + ReduceOperationConfig, + ReduceDtypes, + ), + ) -> ( + CubeTensor, + CubeTensor, + usize, + ReduceOperationConfig, + ReduceDtypes, + ) { + (input.clone(), output.copy(), *dim, *config, *dtypes) + } +} + +/// Executes autotune on reduce operations. +#[cfg(feature = "autotune")] +pub fn autotune_sum( + client: &ComputeClient, + input: CubeTensor, +) -> CubeTensor { + use sum_ops::*; + + static TUNER: LocalTuner = local_tuner!("autotune-sum"); + + let tunables = TUNER.init(|| { + TunableSet::new(create_key_sum::, sum_input_gen::) + .with(Tunable::new("sum_chained", sum_chained::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + .with(Tunable::new("sum_one_shot", sum_one_shot::)) + }); + + TUNER.execute( + &CubeTuneId::new(&input.client, &input.device), + client, + tunables, + input, + ) +} + +pub(crate) fn create_key_sum(input: &CubeTensor) -> CubeAutotuneKey { + CubeAutotuneKey::Sum(SumAutotuneKey::generate(input)) +} + +impl SumAutotuneKey { + #[allow(unused)] + pub(crate) fn generate(input: &CubeTensor) -> Self { + let dtype = input.dtype; + let length = input.meta.num_elements(); + Self::new(dtype, length) + } +} +mod sum_ops { + #![allow(missing_docs)] + use crate::ops::numeric::zeros_client; + + use super::*; + + pub(crate) fn sum_input_gen( + _key: &CubeAutotuneKey, + input: &CubeTensor, + ) -> CubeTensor { + input.clone() + } + + pub(crate) fn sum_one_shot( + input: CubeTensor, + ) -> Result, String> { + let client = input.client.clone(); + let device = input.device.clone(); + let output = zeros_client(client.clone(), device, [1].into(), input.dtype); + let dtype = input.dtype; + + cubek::reduce::shared_sum::( + &output.client, + input.binding(), + output.clone().binding(), + C, + dtype_to_elem_type(dtype), + ) + .map_err(|e| e.to_string()) + .map(|_| output) + } + + #[cfg(feature = "autotune")] + pub(crate) fn sum_chained( + input: CubeTensor, + ) -> Result, String> { + crate::kernel::reduce::reduce::( + input, + None, + crate::kernel::reduce::KernelReduceStrategy::Autotune, + cubek::reduce::components::instructions::ReduceOperationConfig::Sum, + ) + .map_err(|e| e.to_string()) + } +} diff --git a/crates/burn-cubecl/src/kernel/unary_float.rs b/crates/burn-cubecl/src/kernel/unary_float.rs new file mode 100644 index 0000000..7b9d8e5 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/unary_float.rs @@ -0,0 +1,202 @@ +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait FloatUnaryOpFamily: 'static + Send + Sync { + type Options: LaunchArg; + type Unary: FloatUnaryOp; +} + +#[cube] +pub(crate) trait FloatUnaryOp: 'static + Send + Sync { + type Options: LaunchArg; + + fn execute(input: Vector, options: &Self::Options) -> Vector; +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn unary_float( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + options: &O::Options, + #[define(F)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + O::Unary::::execute(input.read(ABSOLUTE_POS), options), + ); +} + +pub(crate) fn launch_unary_float(tensor: CubeTensor, args: Args) -> CubeTensor +where + // Magic fix for lifetime, the closure is supposed to capture everything required to create the + // argument. + for<'a> Args: FnOnce(&'a ()) -> RuntimeArg, + R: CubeRuntime, + O: FloatUnaryOpFamily, +{ + let vector_size = max_vector_size(&tensor); + + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + let dtype = tensor.dtype; + + unsafe { + if tensor.can_mut() && tensor.is_nonoverlapping() { + unary_float::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + tensor.as_linear_view_alias(0), + args(&()), + dtype_to_storage_type(dtype), + ); + + tensor + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + tensor.dtype, + ); + + unary_float::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + output.clone().into_linear_view(), + args(&()), + dtype_to_storage_type(dtype), + ); + + output + } + } +} + +/// Use comptime enum to implement all unary operations that don't have any input argument in the +/// kernel definition. +pub(crate) mod unary_basic { + use cubecl::num_traits::{One, Zero}; + + use super::*; + + pub(crate) fn launch(tensor: CubeTensor, args: Args) -> CubeTensor + where + R: CubeRuntime, + for<'a> Args: FnOnce(&'a ()) -> BasicFloatUnaryKind, + { + launch_unary_float::(tensor, |input| { + BasicFloatUnaryOptionsLaunch::new(args(input)) + }) + } + + #[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)] + pub enum BasicFloatUnaryKind { + Exp, + Log, + Log1p, + Sqrt, + Abs, + Sign, + ArcCos, + ArcCosh, + ArcSin, + ArcSinh, + ArcTan, + ArcTanh, + Cos, + Cosh, + Sin, + Sinh, + Tan, + Tanh, + Round, + Floor, + Ceil, + Trunc, + Erf, + Recip, + } + + #[derive(CubeLaunch, CubeType)] + struct BasicFloatUnaryOptions { + #[cube(comptime)] + kind: BasicFloatUnaryKind, + } + struct BasicFloatUnary; + + #[cube] + impl FloatUnaryOp for BasicFloatUnary { + type Options = BasicFloatUnaryOptions; + + fn execute(input: Vector, options: &Self::Options) -> Vector { + match comptime![options.kind] { + BasicFloatUnaryKind::Exp => Vector::exp(input), + BasicFloatUnaryKind::Log => Vector::ln(input), + BasicFloatUnaryKind::Log1p => Vector::log1p(input), + BasicFloatUnaryKind::Sqrt => Vector::sqrt(input), + BasicFloatUnaryKind::Abs => Vector::abs(input), + BasicFloatUnaryKind::Sign => { + let zero = Vector::zero(); + let one = Vector::one(); + let minus_one = Vector::new(F::new(-1.0_f32)); + + let is_positive = input.greater_than(&zero); + let is_negative = input.less_than(&zero); + let sign = select_many(is_negative, minus_one, zero); + + select_many(is_positive, one, sign) + } + BasicFloatUnaryKind::Cos => Vector::cos(input), + BasicFloatUnaryKind::Sin => Vector::sin(input), + BasicFloatUnaryKind::Tan => Vector::tan(input), + BasicFloatUnaryKind::Cosh => Vector::cosh(input), + BasicFloatUnaryKind::Sinh => Vector::sinh(input), + BasicFloatUnaryKind::Tanh => Vector::tanh(input), + BasicFloatUnaryKind::Round => Vector::round(input), + BasicFloatUnaryKind::Floor => Vector::floor(input), + BasicFloatUnaryKind::Ceil => Vector::ceil(input), + BasicFloatUnaryKind::Trunc => Vector::trunc(input), + BasicFloatUnaryKind::Erf => Vector::erf(input), + BasicFloatUnaryKind::Recip => Vector::recip(input), + BasicFloatUnaryKind::ArcCos => Vector::acos(input), + BasicFloatUnaryKind::ArcCosh => Vector::acosh(input), + BasicFloatUnaryKind::ArcSin => Vector::asin(input), + BasicFloatUnaryKind::ArcSinh => Vector::asinh(input), + BasicFloatUnaryKind::ArcTan => Vector::atan(input), + BasicFloatUnaryKind::ArcTanh => Vector::atanh(input), + } + } + } + + impl FloatUnaryOpFamily for BasicFloatUnary { + type Options = BasicFloatUnaryOptions; + type Unary = Self; + } +} diff --git a/crates/burn-cubecl/src/kernel/unary_int.rs b/crates/burn-cubecl/src/kernel/unary_int.rs new file mode 100644 index 0000000..d018389 --- /dev/null +++ b/crates/burn-cubecl/src/kernel/unary_int.rs @@ -0,0 +1,154 @@ +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait IntUnaryOpFamily: 'static + Send + Sync { + type Options: LaunchArg; + type Unary: IntUnaryOp; +} + +#[cube] +pub(crate) trait IntUnaryOp: 'static + Send + Sync { + type Options: LaunchArg; + + fn execute(input: Vector, options: &Self::Options) -> Vector; +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn unary_int( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + options: &O::Options, + #[define(I)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + O::Unary::::execute(input.read(ABSOLUTE_POS), options), + ); +} + +pub(crate) fn launch_unary_int(tensor: CubeTensor, args: Args) -> CubeTensor +where + for<'a> Args: FnOnce(&'a ()) -> RuntimeArg, + R: CubeRuntime, + O: IntUnaryOpFamily, +{ + let vector_size = max_vector_size(&tensor); + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + let dtype = tensor.dtype; + + unsafe { + if tensor.can_mut() && tensor.is_nonoverlapping() { + unary_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + tensor.as_linear_view_alias(0), + args(&()), + dtype_to_storage_type(dtype), + ); + + tensor + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + tensor.dtype, + ); + + unary_int::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + output.clone().into_linear_view(), + args(&()), + dtype_to_storage_type(dtype), + ); + + output + } + } +} + +pub(crate) mod unary_basic_int { + + use cubecl::num_traits::{One, Zero}; + + use super::*; + + pub(crate) fn launch(tensor: CubeTensor, args: Args) -> CubeTensor + where + R: CubeRuntime, + for<'a> Args: FnOnce(&'a ()) -> BasicIntUnaryKind, + { + launch_unary_int::(tensor, |input| { + BasicIntUnaryOptionsLaunch::new(args(input)) + }) + } + + #[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)] + pub enum BasicIntUnaryKind { + BitwiseNot, + Sign, + } + + #[derive(CubeLaunch, CubeType)] + struct BasicIntUnaryOptions { + #[cube(comptime)] + kind: BasicIntUnaryKind, + } + struct BasicIntUnary; + + #[cube] + impl IntUnaryOp for BasicIntUnary { + type Options = BasicIntUnaryOptions; + + fn execute(input: Vector, options: &Self::Options) -> Vector { + match comptime![options.kind] { + BasicIntUnaryKind::BitwiseNot => !input, + BasicIntUnaryKind::Sign => { + let zero = Vector::zero(); + let one = Vector::one(); + let minus_one = Vector::new(I::new(-1)); + + let is_positive = input.greater_than(&zero); + let is_negative = input.less_than(&zero); + let sign = select_many(is_negative, minus_one, zero); + + select_many(is_positive, one, sign) + } + } + } + } + + impl IntUnaryOpFamily for BasicIntUnary { + type Options = BasicIntUnaryOptions; + type Unary = Self; + } +} diff --git a/crates/burn-cubecl/src/kernel/unary_numeric.rs b/crates/burn-cubecl/src/kernel/unary_numeric.rs new file mode 100644 index 0000000..61486fd --- /dev/null +++ b/crates/burn-cubecl/src/kernel/unary_numeric.rs @@ -0,0 +1,99 @@ +use crate::{ + CubeRuntime, + kernel::utils::address_type, + ops::{max_vector_size, numeric::empty_device_dtype}, + tensor::CubeTensor, +}; +use burn_backend::TensorMetadata; +use burn_backend::cubecl::dtype_to_storage_type; +use cubecl::{ + calculate_cube_count_elemwise, + prelude::*, + std::tensor::layout::linear::{LinearView, LinearViewMut}, +}; + +pub(crate) trait NumericUnaryOpFamily: 'static + Send + Sync { + type Options: LaunchArg; + type Unary: NumericUnaryOp; +} + +#[cube] +pub(crate) trait NumericUnaryOp: 'static + Send + Sync { + type Options: LaunchArg; + + fn execute(input: Vector, options: &Self::Options) -> Vector; +} + +#[cube(launch_unchecked, address_type = "dynamic")] +pub(crate) fn unary_numeric( + input: LinearView<'_, Vector>, + mut output: LinearViewMut<'_, Vector>, + options: &O::Options, + #[define(T)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + output.write( + ABSOLUTE_POS, + O::Unary::::execute(input.read(ABSOLUTE_POS), options), + ); +} + +pub(crate) fn launch_unary_numeric(tensor: CubeTensor, args: Args) -> CubeTensor +where + // Magic fix for lifetime, the closure is supposed to capture everything required to create the + // argument. + for<'a> Args: FnOnce(&'a ()) -> RuntimeArg, + R: CubeRuntime, + O: NumericUnaryOpFamily, +{ + let vector_size = max_vector_size(&tensor); + let client = tensor.client.clone(); + let num_elems = tensor.meta.num_elements(); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&tensor.client, working_units); + let cube_count = calculate_cube_count_elemwise(&tensor.client, working_units, cube_dim); + let dtype = tensor.dtype; + + unsafe { + if tensor.can_mut() && tensor.is_nonoverlapping() { + unary_numeric::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor), + vector_size, + tensor.clone().into_linear_view(), + tensor.as_linear_view_alias(0), + args(&()), + dtype_to_storage_type(dtype), + ); + + tensor + } else { + let output = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + tensor.shape(), + tensor.dtype, + ); + + unary_numeric::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(tensor, output), + vector_size, + tensor.into_linear_view(), + output.clone().into_linear_view(), + args(&()), + dtype_to_storage_type(dtype), + ); + + output + } + } +} diff --git a/crates/burn-cubecl/src/kernel/utils.rs b/crates/burn-cubecl/src/kernel/utils.rs new file mode 100644 index 0000000..1df2ced --- /dev/null +++ b/crates/burn-cubecl/src/kernel/utils.rs @@ -0,0 +1,136 @@ +use burn_backend::Shape; +use cubecl::prelude::SequenceArg; +use cubecl::{ + prelude::*, + std::{FastDivmod, FastDivmodInt}, +}; + +use crate::{CubeRuntime, tensor::CubeTensor}; + +pub fn shape_divmod(tensor: &CubeTensor) -> SequenceArg> { + let mut arg = SequenceArg::new(); + for dim in tensor.meta.shape().iter() { + arg.push(*dim); + } + arg +} + +pub fn shape_divmod_range( + tensor: &CubeTensor, + range: core::ops::Range, +) -> SequenceArg> { + let mut arg = SequenceArg::new(); + let shape = &tensor.meta.shape; + for i in range { + arg.push(shape[i]); + } + arg +} + +pub fn split_dim( + mut tensor: CubeTensor, + dim: usize, + shape: &[usize], +) -> CubeTensor { + let mut stride = tensor.meta.strides()[dim]; + tensor.meta.remove(dim); + + for size in shape.iter().rev() { + tensor.meta.insert(dim, *size, stride); + stride *= size; + } + + tensor +} + +pub fn broadcast_shape(tensors: &[&CubeTensor]) -> Shape { + let rank = tensors[0].meta.num_dims(); + debug_assert!( + tensors.iter().all(|it| it.meta.num_dims() == rank), + "Broadcast tensors must have the same rank" + ); + + let dims = (0..rank).map(|dim| { + let max = tensors.iter().map(|it| it.meta.shape()[dim]).max(); + let max = max.unwrap_or(1); + debug_assert!( + tensors + .iter() + .all(|it| it.meta.shape()[dim] == max || it.meta.shape()[dim] == 1), + "Broadcast dims must be size 1" + ); + max + }); + + Shape::from(dims) +} + +pub fn broadcast_strides( + reference: &CubeTensor, + tensor: &CubeTensor, +) -> SequenceArg { + if reference.meta.shape() != tensor.meta.shape() { + tensor + .meta + .strides() + .iter() + .zip( + tensor + .meta + .shape() + .iter() + .zip(reference.meta.shape().iter()), + ) + .map(|(stride, (shape, ref_shape))| if *shape == *ref_shape { *stride } else { 0 }) + .collect() + } else { + tensor.meta.strides().iter().copied().collect() + } +} + +#[cube] +pub(crate) fn decompose_linear( + pos: I, + shape: &Sequence>, +) -> (I, Sequence) { + let rank = comptime![shape.len()]; + let mut offs = pos; + let mut out = Sequence::new(); + + #[unroll] + for i in 0..rank { + let dim = comptime![rank - i - 1]; + let (rem, offs_local) = shape.index(dim).div_mod(offs); + out.push(offs_local); + offs = rem; + } + + (offs, out.reversed()) +} + +pub(crate) trait RequiredAddrType { + fn required_address_type(&self) -> AddressType; +} + +impl RequiredAddrType for CubeTensor { + fn required_address_type(&self) -> AddressType { + self.required_address_type() + } +} +impl RequiredAddrType for Option> { + fn required_address_type(&self) -> AddressType { + self.as_ref() + .map(|it| it.required_address_type()) + .unwrap_or_default() + } +} + +macro_rules! address_type { + ($($tensor: tt),*) => { + [$($crate::kernel::utils::RequiredAddrType::required_address_type(&$tensor)),*] + .into_iter() + .max() + .unwrap_or_default() + }; +} +pub(crate) use address_type; diff --git a/crates/burn-cubecl/src/lib.rs b/crates/burn-cubecl/src/lib.rs new file mode 100644 index 0000000..108b02d --- /dev/null +++ b/crates/burn-cubecl/src/lib.rs @@ -0,0 +1,50 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! Burn JIT Backend + +#[macro_use] +extern crate derive_new; +extern crate alloc; + +/// Utilities for implementing JIT kernels +pub mod ops; + +/// Kernel module +pub mod kernel; +/// Tensor module. +pub mod tensor; + +/// Elements for JIT backend +pub mod element; + +use cubecl::{CubeTask, Runtime}; +pub use element::{BoolElement, CubeElement, FloatElement, IntElement}; + +mod backend; + +pub use backend::*; + +// Re-export cubecl. +pub use cubecl; + +mod tune_key; +pub use tune_key::CubeAutotuneKey; + +#[cfg(any(feature = "fusion", test))] +/// Module for interacting with fusion +pub mod fusion; + +#[cfg(feature = "template")] +/// Module for compiling custom non-jit kernels +pub mod template; + +/// Just-in-Time runtime extending the [cube runtime](Runtime). +pub trait CubeRuntime: Runtime { + /// The device that should also implement [burn_backend::backend::DeviceOps]. + type CubeDevice: burn_backend::DeviceOps; + /// The cube server with the [CubeAutotuneKey]. + type CubeServer: cubecl::server::ComputeServer>>; +} + +pub use cubecl::CubeTuneId; diff --git a/crates/burn-cubecl/src/ops/activation.rs b/crates/burn-cubecl/src/ops/activation.rs new file mode 100644 index 0000000..ee0917a --- /dev/null +++ b/crates/burn-cubecl/src/ops/activation.rs @@ -0,0 +1,4 @@ +use crate::{CubeBackend, CubeRuntime}; +use burn_backend::ops::ActivationOps; + +impl ActivationOps for CubeBackend {} diff --git a/crates/burn-cubecl/src/ops/base.rs b/crates/burn-cubecl/src/ops/base.rs new file mode 100644 index 0000000..713b49d --- /dev/null +++ b/crates/burn-cubecl/src/ops/base.rs @@ -0,0 +1,540 @@ +use crate::{CubeRuntime, kernel, ops::numeric::empty_device_dtype, tensor::CubeTensor}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + DType, ExecutionError, Shape, TensorData, + quantization::{QuantLevel, QuantStore, params_shape}, +}; +use burn_backend::{TensorMetadata, ops::unfold::calculate_unfold_shape}; +use burn_std::{ + Metadata, QuantValue, ReshapeAnalysis, reshape_analysis, strides, + tensor::{ReshapeAction, contiguous_strides, reshape_action}, +}; +use cubecl::{ir::VectorSize, server::CopyDescriptor}; +use cubecl::{quant::scheme::BlockSize, tensor_vector_size_parallel}; + +pub(crate) fn from_data(data: TensorData, device: &R::Device) -> CubeTensor { + // `TensorData` may contain lazily materialized device-backed bytes produced + // by `into_data()`. These unnecessary round-trips should be avoided, but + // materializing before re-uploading avoids recursive runtime submission. + if data.bytes.property() == burn_std::AllocationProperty::Device { + let _ = data.bytes.read(burn_std::Reader::new()); + } + + let client = R::client(device); + let alloc = client.create_tensor(data.bytes, data.shape.clone(), data.dtype.size()); + let shape: Shape = (&data.shape).into(); + CubeTensor::new( + client, + alloc.memory, + Metadata::new(shape, alloc.strides), + device.clone(), + data.dtype, + ) +} + +pub(crate) async fn into_data( + tensor: CubeTensor, +) -> Result { + let tensor = kernel::into_contiguous_aligned(tensor); + + let elem_size = tensor.elem_size(); + let shape = tensor.meta.shape().clone(); + let strides = tensor.meta.strides().clone(); + let binding = CopyDescriptor::new(tensor.handle.binding(), shape, strides, elem_size); + // Under an async runtime (e.g. a remote server), a lazy read defers the device→host + // copy to first access, where a blocking read would run on — and starve — an executor + // worker. Read eagerly there so the copy happens inside this awaited future. On a + // sync/threaded runtime the lazy read keeps the streaming optimization. + let read = match burn_std::runtime_kind() { + burn_std::RuntimeKind::Async => tensor.client.read_one_tensor_async(binding).await, + _ => tensor.client.read_lazy_async(binding).await, + }; + let bytes = read.map_err(|err| ExecutionError::WithContext { + reason: format!("{err}"), + })?; + + Ok(TensorData::from_bytes( + bytes, + tensor.meta.shape.clone(), + tensor.dtype, + )) +} + +/// Read data from a `CubeTensor` synchronously +#[allow(unused, reason = "useful for debugging kernels")] +pub fn into_data_sync(tensor: CubeTensor) -> TensorData { + burn_std::future::block_on(into_data(tensor)).unwrap() +} + +#[cfg_attr( + feature = "tracing", + tracing::instrument(level = "trace", skip(tensor, device)) +)] +pub(crate) fn to_device( + tensor: CubeTensor, + device: &R::Device, +) -> CubeTensor { + if &tensor.device == device { + return tensor; + } + + let mut tensor = kernel::into_contiguous_aligned(tensor); + let client = R::client(device); + tensor.to_client(client, device.clone()) +} + +pub(crate) fn empty( + shape: Shape, + device: &R::Device, + dtype: DType, +) -> CubeTensor { + let client = R::client(device); + let alloc = client.empty_tensor(shape.clone(), dtype.size()); + + CubeTensor::new( + client, + alloc.memory, + Metadata::new(shape, alloc.strides), + device.clone(), + dtype, + ) +} + +pub(crate) fn swap_dims( + mut tensor: CubeTensor, + dim1: usize, + dim2: usize, +) -> CubeTensor { + tensor.meta.swap(dim1, dim2); + + if let DType::QFloat(scheme) = tensor.dtype + && let QuantLevel::Block(block_size) = scheme.level + { + let rank = tensor.rank(); + let qparams = tensor.qparams.as_mut().unwrap(); + let mut block_size = block_size.to_dim_vec(rank); + block_size.swap(dim1, dim2); + + // Truncate unit dims from the start + let block_size = BlockSize::new_trim(block_size); + if block_size.len() > BlockSize::MAX_DIMS { + panic!("Swapped block size would exceed max dims"); + } + + qparams.scales.metadata.swap(dim1, dim2); + + tensor.dtype = DType::QFloat(scheme.with_level(QuantLevel::Block(block_size))) + } + + if let DType::QFloat(scheme) = &mut tensor.dtype + && let QuantStore::PackedU32(packed_dim) | QuantStore::PackedNative(packed_dim) = + &mut scheme.store + { + let rank = tensor.meta.num_dims(); + + if *packed_dim == rank - dim1 - 1 { + *packed_dim = rank - dim2 - 1; + } else if *packed_dim == rank - dim2 - 1 { + *packed_dim = rank - dim1 - 1; + } + } + + tensor +} + +/// Permute a tensor's dimensions +pub fn permute(mut tensor: CubeTensor, axes: &[usize]) -> CubeTensor { + tensor.meta.permute(axes).unwrap(); + + if let DType::QFloat(scheme) = tensor.dtype + && let QuantLevel::Block(block_size) = scheme.level + { + let rank = tensor.rank(); + let qparams = tensor.qparams.as_mut().unwrap(); + + let mut block_size = block_size.to_dim_vec(rank); + block_size = axes.iter().map(|i| block_size[*i]).collect(); + + // Truncate unit dims from the start + let block_size = block_size + .into_iter() + .skip_while(|it| *it == 1) + .collect::>(); + if block_size.len() > BlockSize::MAX_DIMS { + panic!("Swapped block size would exceed max dims"); + } + + qparams.scales.metadata.permute(axes).unwrap(); + + tensor.dtype = DType::QFloat(scheme.with_level(QuantLevel::block(&block_size))) + } + + if let DType::QFloat(scheme) = &mut tensor.dtype + && let QuantStore::PackedU32(packed_dim) = &mut scheme.store + { + let rank = tensor.meta.num_dims(); + let new_pos = axes + .iter() + .position(|axis| *axis == rank - *packed_dim - 1) + .unwrap_or(0); + *packed_dim = rank - new_pos - 1; + } + + tensor +} + +/// Permute a tensor's dimensions from NCHW to NHWC, or the N-dimensional equivalent +pub fn permute_nchw_to_nhwc(tensor: CubeTensor) -> CubeTensor { + let rank = tensor.meta.num_dims(); + let c_dim = 1; + + let mut dims = vec![0]; + dims.extend(2..rank); + dims.push(c_dim); + + permute(tensor, &dims) +} + +/// Permute a shape's dimensions from NCHW to NHWC, or the N-dimensional equivalent +pub fn permute_nchw_to_nhwc_shape(shape: Shape) -> Shape { + let rank = shape.num_dims(); + let c_dim = 1; + + let mut dims = vec![0]; + dims.extend(2..rank); + dims.push(c_dim); + + shape.permuted(&dims).expect("Shape permute should succeed") +} + +/// Permute a tensor's dimensions from NHWC to NCHW, or the N-dimensional equivalent +pub fn permute_nhwc_to_nchw(tensor: CubeTensor) -> CubeTensor { + let rank = tensor.meta.num_dims(); + let c_dim = rank - 1; + + let mut dims = vec![0]; + dims.push(c_dim); + dims.extend(1..c_dim); + + permute(tensor, &dims) +} + +/// Permute a shape's dimensions from NHWC to NCHW, or the N-dimensional equivalent +pub fn permute_nhwc_to_nchw_shape(shape: Shape) -> Shape { + let rank = shape.num_dims(); + let c_dim = rank - 1; + + let mut dims = vec![0]; + dims.push(c_dim); + dims.extend(1..c_dim); + + shape.permuted(&dims).expect("Shape permute should succeed") +} + +pub(crate) fn expand(tensor: CubeTensor, target_shape: Shape) -> CubeTensor { + let ndims_in = tensor.meta.shape().num_dims(); + let ndims_out = target_shape.num_dims(); + + // Initialize new strides with zeros + let mut new_strides = strides![0usize; ndims_out]; + + // Calculate the difference in dimensions + let dim_diff = ndims_out.saturating_sub(ndims_in); + + // Compare dimensions from the end, setting strides for matching dimensions or broadcasted ones + let mut tensor_dim_iter = tensor.meta.shape().iter().rev(); + for i in (0..ndims_out).rev() { + if i >= dim_diff { + if let Some(&tensor_dim) = tensor_dim_iter.next() { + if tensor_dim == target_shape[i] || tensor_dim == 1 { + // Copy stride for non-broadcast dimensions or set to 0 for broadcast ones + new_strides[i] = if tensor_dim == target_shape[i] { + tensor.meta.strides()[i - dim_diff] + } else { + 0 + }; + } else { + // Error handling: Dimension mismatch for broadcasting + panic!( + "Dimension mismatch: cannot broadcast dimension {tensor_dim} of tensor to target shape" + ); + } + } else { + // If the input tensor has fewer dimensions, treat missing dimensions as 1 + // and set stride to 0 (broadcasting) + new_strides[i] = 0; + } + } else { + // For extra dimensions in the target shape, set stride to 0 (broadcasting) + new_strides[i] = 0; + } + } + + // Extra check to ensure block scales must be properly handled once they're added + if tensor.qparams.is_some() { + match tensor.scheme().level { + QuantLevel::Tensor => {} + QuantLevel::Block(_) => todo!(), + } + } + + CubeTensor { + client: tensor.client.clone(), + device: tensor.device.clone(), + meta: Box::new(Metadata::new(target_shape, new_strides)), + handle: tensor.handle.clone(), + dtype: tensor.dtype, + qparams: tensor.qparams.clone(), + } +} + +/// Reshape a jit tensor to a new shape +pub fn reshape(mut tensor: CubeTensor, shape: Shape) -> CubeTensor { + let analysis = reshape_action(tensor.meta.shape(), tensor.meta.strides(), &shape); + + match analysis { + ReshapeAction::UpdateStrides { strides } => { + *tensor.meta = Metadata::new(shape, strides); + return tensor; + } + ReshapeAction::NoChange => return tensor, + ReshapeAction::Recompute => (), + } + + let out = empty_device_dtype( + tensor.client.clone(), + tensor.device.clone(), + shape, + tensor.dtype, + ); + + cubecl::std::tensor::copy_into( + &out.client, + tensor.binding(), + out.clone().binding(), + dtype_to_storage_type(out.dtype), + ); + + out +} + +/// Reshape a jit tensor to a new shape +pub fn q_reshape(mut tensor: CubeTensor, shape: Shape) -> CubeTensor { + let scheme = tensor.scheme(); + let curr_shape = tensor.meta.shape(); + + let shape_values = match scheme.store { + QuantStore::Native => shape.clone(), + QuantStore::PackedNative(packed_dim) | QuantStore::PackedU32(packed_dim) => { + let rank = shape.num_dims(); + let mut shape = shape.clone(); + let packed_d = rank - packed_dim - 1; + let num_quants = scheme.num_quants(); + + if !shape[packed_d].is_multiple_of(num_quants) { + unimplemented!( + "Cannot reshape packed tensor: inner dimension {} is not aligned with packing factor {num_quants}", + shape[packed_d] + ); + } + + shape[packed_d] = shape[packed_d].div_ceil(num_quants); + shape + } + }; + + let (values, scales) = tensor.quantized_handles().unwrap(); + let analysis_values = reshape_analysis( + values.meta.shape(), + Some(values.meta.strides()), + &shape_values, + ); + let action_values = + analysis_values.action(values.meta.shape(), values.meta.strides(), &shape_values); + + let n_new_dims = shape.num_dims().saturating_sub(curr_shape.num_dims()); + let is_unsqueeze = n_new_dims > 0 && shape[n_new_dims..] == **curr_shape; + + if !is_unsqueeze + && matches!( + scheme.value, + QuantValue::Q4S | QuantValue::Q4F | QuantValue::Q2S | QuantValue::Q2F + ) + { + // FIXME + todo!("Reshape with sub-byte values is not supported") + } + + // Check valid reshapes + if let ReshapeAction::UpdateStrides { .. } = &action_values { + match analysis_values { + ReshapeAnalysis::IsContiguous => { + if let QuantLevel::Block(block_size) = scheme.level + && block_size.len() > 1 + && !is_unsqueeze + { + // General reshape (e.g. [32, 4] -> [16, 8]): only valid if + // reshaped dimension is aligned with the block boundaries. + unimplemented!("Reshape of ND block-quantized tensor is not yet supported."); + } + } + ReshapeAnalysis::Broadcasted => {} // only preprends unit dims + ReshapeAnalysis::Split => { + if let QuantLevel::Block(block_size) = scheme.level + && block_size.len() > 1 + { + // Split reshape (e.g. [32, 4] -> [32, 2, 2]): only valid if + // reshaped dimension is aligned with the block boundaries. + unimplemented!( + "Split reshape of ND block-quantized tensor is not yet supported." + ); + } + } + other => unreachable!("Reshape analysis {other:?} should not update strides."), + } + } + + let shape_last = *shape.last().unwrap(); + + let shape_scales = match scheme.level { + QuantLevel::Tensor => scales.meta.shape().clone(), // always [1], invariant under reshape + QuantLevel::Block(block_size) + if block_size.len() == 1 && shape_last < (block_size[0] as usize) => + { + // If the new last dimension is smaller than the block size, + // it means a single block now spans across multiple rows. + if scales.meta.shape().num_elements() > 1 { + unimplemented!("Reshape would split a block across multiple rows."); + } + // Exception: allow if there is exactly 1 block total (essentially per-tensor quantization) + scales.meta.shape().clone() + } + QuantLevel::Block(_) => { + // ND blocks: derive scales shape from the new tensor shape + params_shape(&shape, scheme.level) + } + }; + + let action_scales = reshape_action(scales.meta.shape(), scales.meta.strides(), &shape_scales); + + match (action_values, action_scales) { + ( + ReshapeAction::UpdateStrides { strides }, + ReshapeAction::UpdateStrides { + strides: scales_strides, + }, + ) => { + let qparams = tensor.qparams.as_mut().unwrap(); + + *tensor.meta = Metadata::new(shape, strides); + qparams.scales.metadata = Metadata::new(shape_scales, scales_strides); + } + (ReshapeAction::UpdateStrides { strides }, ReshapeAction::NoChange) => { + *tensor.meta = Metadata::new(shape, strides); + } + ( + ReshapeAction::NoChange, + ReshapeAction::UpdateStrides { + strides: scales_strides, + }, + ) => { + let qparams = tensor.qparams.as_mut().unwrap(); + + qparams.scales.metadata = Metadata::new(shape_scales, scales_strides); + } + // Any action to recompute + (ReshapeAction::Recompute, _) | (_, ReshapeAction::Recompute) => { + if let QuantLevel::Block(_) = scheme.level + && shape_scales.num_elements() > 1 + { + // Original block boundaries no longer align with the layout, would have to be recomputed + unimplemented!( + "Cannot reshape a block-quantized tensor when the reshape requires recomputing the buffer." + ); + } + + tensor = kernel::into_contiguous(tensor); + *tensor.meta = Metadata::new(shape, contiguous_strides(&shape_values)); + + let qparams = tensor.qparams.as_mut().unwrap(); + + let strides = contiguous_strides(&shape_scales); + qparams.scales.metadata = Metadata::new(shape_scales, strides); + } + (ReshapeAction::NoChange, ReshapeAction::NoChange) => {} + } + + tensor +} + +pub(crate) fn max_vector_size(tensor: &CubeTensor) -> VectorSize { + tensor_vector_size_parallel( + tensor.client.io_optimized_vector_sizes(tensor.dtype.size()), + tensor.meta.shape(), + tensor.meta.strides(), + tensor.meta.num_dims() - 1, + ) +} + +pub(crate) fn max_vector_size_many( + tensors: &[&CubeTensor], + axis: usize, +) -> VectorSize { + let vec = tensors + .iter() + .map(|tensor| { + tensor_vector_size_parallel( + tensor.client.io_optimized_vector_sizes(tensor.dtype.size()), + tensor.meta.shape(), + tensor.meta.strides(), + axis, + ) + }) + .min(); + + vec.unwrap_or(0) +} + +/// Unfold windows along a dimension. +/// +/// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; +/// where windows are advanced by `step` at each index. +/// +/// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. +/// +/// The new view will have the unfolded dimension replaced by two dimensions; +/// one in the position of the original dimension, with size equal to the number of windows, +/// and one appended to the right-most position, with size equal to `size`. +/// +/// # Arguments +/// +/// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` +/// * `dim` - the dimension to unfold. +/// * `size` - the size of each unfolded window. +/// * `step` - the step between each window. +/// +/// # Returns +/// +/// A tensor view with the shape ``[pre=..., windows, post=..., size]``. +pub fn unfold( + tensor: CubeTensor, + dim: usize, + size: usize, + step: usize, +) -> CubeTensor { + let shape = calculate_unfold_shape(tensor.shape(), dim, size, step); + + let d_stride = tensor.meta.strides()[dim]; + let mut strides = tensor.meta.strides.clone(); + strides[dim] = step * d_stride; + strides.push(d_stride); + + CubeTensor { + meta: Box::new(Metadata::new(shape, strides)), + client: tensor.client.clone(), + handle: tensor.handle.clone(), + device: tensor.device.clone(), + dtype: tensor.dtype, + qparams: tensor.qparams.clone(), + } +} diff --git a/crates/burn-cubecl/src/ops/bool_tensor.rs b/crates/burn-cubecl/src/ops/bool_tensor.rs new file mode 100644 index 0000000..df540a2 --- /dev/null +++ b/crates/burn-cubecl/src/ops/bool_tensor.rs @@ -0,0 +1,241 @@ +use crate::{ + CubeBackend, CubeRuntime, + element::BoolElement, + kernel::{self, AndOp, OrOp}, + tensor::CubeTensor, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{ + ExecutionError, Slice, + ops::BoolTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_backend::{Scalar, Shape, TensorData}; +use burn_std::{BoolDType, BoolStore, DType, FloatDType, IntDType}; +use cubecl::prelude::InputScalar; +use cubek::reduce::components::instructions::ReduceOperationConfig; +use std::ops::Range; + +use super::{expand, numeric, permute, unfold}; + +/// The boolean storage of a cubecl bool tensor. Cubecl backends never use +/// native bool (see `CubeBackend::supports_dtype`), so it is always `U8`/`U32`. +fn bool_store(tensor: &CubeTensor) -> BoolDType { + match tensor.dtype { + DType::Bool(store) => store, + other => unreachable!("cubecl bool tensors are always Bool(_): {other:?}"), + } +} + +impl BoolTensorOps for CubeBackend { + fn bool_empty(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + super::empty(shape, device, dtype.into()) + } + + fn bool_zeros(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + numeric::zeros(device.clone(), shape, dtype.into()) + } + + fn bool_ones(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + numeric::ones(device.clone(), shape, dtype.into()) + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + super::into_data(tensor).await + } + + fn bool_from_data(data: TensorData, device: &Device) -> BoolTensor { + if !matches!( + data.dtype, + DType::Bool(BoolStore::U8) | DType::Bool(BoolStore::U32) + ) { + unimplemented!("Unsupported dtype for `bool_from_data` {:?}", data.dtype); + } + super::from_data(data, device) + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + kernel::bool_cast(tensor, out_dtype.into()) + } + + fn bool_to_device(tensor: BoolTensor, device: &Device) -> BoolTensor { + super::to_device(tensor, device) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + super::reshape(tensor, shape) + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + // Check if all steps are 1 + let all_steps_one = slices.iter().all(|info| info.step == 1); + + if all_steps_one { + // Use optimized slice for step=1 + let simple_ranges: Vec> = slices + .iter() + .enumerate() + .map(|(i, slice)| slice.to_range(tensor.meta.shape()[i])) + .collect(); + + kernel::slice(tensor, &simple_ranges) + } else { + // Use slice with steps kernel + kernel::slice_with_steps(tensor, slices) + } + } + + fn bool_slice_assign( + tensor: BoolTensor, + ranges: &[Slice], + value: BoolTensor, + ) -> BoolTensor { + kernel::slice_assign(tensor, ranges, value) + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let dtype = lhs.dtype; + kernel::equal(lhs, rhs, dtype) + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + let dtype = tensor.dtype; + let storage = dtype_to_storage_type(dtype); + let scalar = match dtype { + DType::Bool(BoolStore::U32) => InputScalar::new(u32::false_val(), storage), + DType::Bool(BoolStore::U8) => InputScalar::new(u8::false_val(), storage), + other => unimplemented!("Unsupported dtype for `bool_from_data` {other:?}"), + }; + kernel::equal_elem(tensor, scalar, dtype) + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + kernel::launch_binop::(lhs, rhs) + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + kernel::launch_binop::(lhs, rhs) + } + + fn bool_any(tensor: BoolTensor) -> BoolTensor { + let store = bool_store(&tensor); + kernel::reduce::reduce_logical(tensor, None, ReduceOperationConfig::Any, store) + } + + fn bool_any_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let store = bool_store(&tensor); + kernel::reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::Any, store) + } + + fn bool_all(tensor: BoolTensor) -> BoolTensor { + let store = bool_store(&tensor); + kernel::reduce::reduce_logical(tensor, None, ReduceOperationConfig::All, store) + } + + fn bool_all_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let store = bool_store(&tensor); + kernel::reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::All, store) + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + kernel::bool_cast(tensor, out_dtype.into()) + } + + fn bool_swap_dims(mut tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + tensor.meta.swap(dim1, dim2); + + tensor + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + kernel::repeat_dim(tensor, dim, times) + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + permute(tensor, axes) + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + expand(tensor, shape) + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + kernel::select(tensor, dim, indices) + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + kernel::select_assign(tensor, dim, indices, value, true) + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + let dtype = tensor.dtype; + kernel::flip(tensor, axes, dtype) + } + + fn bool_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + unfold(tensor, dim, size, step) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + let dtype = tensor.dtype; + kernel::mask_where_auto(tensor, mask, value, dtype) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + let dtype = tensor.dtype; + kernel::mask_fill_auto( + tensor, + mask, + InputScalar::new(value, dtype_to_storage_type(dtype)), + dtype, + ) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + kernel::gather(dim, tensor, indices) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + kernel::scatter(dim, tensor, indices, value, true) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + let dtype = lhs.dtype; + kernel::equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + dtype, + ) + } +} diff --git a/crates/burn-cubecl/src/ops/distributed.rs b/crates/burn-cubecl/src/ops/distributed.rs new file mode 100644 index 0000000..9cd521a --- /dev/null +++ b/crates/burn-cubecl/src/ops/distributed.rs @@ -0,0 +1,56 @@ +use burn_backend::distributed::DistributedOps; + +use crate::{CubeBackend, CubeRuntime}; + +#[cfg(feature = "std")] +use crate::ops::numeric::{self, zeros_client}; +#[cfg(feature = "std")] +use burn_backend::{ + DeviceId, TensorMetadata, + cubecl::dtype_to_elem_type, + distributed::{CollectiveTensor, ReduceOperation}, + tensor::{Device, FloatTensor}, +}; + +impl DistributedOps for CubeBackend { + #[cfg(feature = "std")] + fn all_reduce( + tensor: FloatTensor, + op: ReduceOperation, + device_ids: Vec, + ) -> CollectiveTensor { + let device = &tensor.device.clone(); + let out_tensor = if tensor.handle.can_mut() && tensor.is_contiguous() { + tensor + } else { + let zeros_tensor = zeros_client::( + tensor.client.clone(), + device.clone(), + tensor.shape(), + tensor.dtype(), + ); + numeric::add(zeros_tensor, tensor) + }; + + let op = match op { + ReduceOperation::Sum => cubecl::server::ReduceOperation::Sum, + ReduceOperation::Mean => cubecl::server::ReduceOperation::Mean, + }; + + let mut client = R::client(device); + client.all_reduce( + out_tensor.handle.clone(), + out_tensor.handle.clone(), + dtype_to_elem_type(out_tensor.dtype), + device_ids.clone(), + op, + ); + CollectiveTensor::new(out_tensor) + } + + #[cfg(feature = "std")] + fn sync_collective(device: &Device) { + let client = R::client(device); + client.sync_collective(); + } +} diff --git a/crates/burn-cubecl/src/ops/int_tensor.rs b/crates/burn-cubecl/src/ops/int_tensor.rs new file mode 100644 index 0000000..a1ecfd1 --- /dev/null +++ b/crates/burn-cubecl/src/ops/int_tensor.rs @@ -0,0 +1,663 @@ +use self::unary_basic_int::BasicIntUnaryKind; +use burn_backend::cubecl::dtype_to_storage_type; + +use super::{expand, numeric, permute, unfold}; +use crate::kernel::prng::{random_bernoulli, random_normal, random_uniform}; +use crate::kernel::{ + BitwiseShlOp, BitwiseShrOp, NumericUnaryOp, NumericUnaryOpFamily, launch_binop_int, + launch_scalar_binop_int, launch_unary_numeric, reduce, unary_basic_int, +}; +use crate::{ + CubeBackend, CubeRuntime, + kernel::{ + self, + matmul::{MatmulStrategy, matmul}, + }, +}; +use burn_backend::tensor::{BoolTensor, Device, FloatTensor, IntTensor}; +use burn_backend::{DType, IntDType, Slice, ops::IntTensorOps}; +use burn_backend::{Distribution, ElementConversion, Shape, TensorData, get_device_settings}; +use burn_backend::{ExecutionError, Scalar}; +use burn_std::{BoolDType, FloatDType}; +use cubecl::frontend::Numeric; +use cubecl::prelude::*; +use cubek::reduce::components::instructions::ReduceOperationConfig; +use std::ops::Range; + +impl IntTensorOps for CubeBackend { + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let dtype = dtype.into(); + super::empty(shape, device, dtype) + } + + async fn int_into_data(tensor: IntTensor) -> Result { + super::into_data(tensor).await + } + + fn int_from_data(data: TensorData, device: &Device) -> IntTensor { + match data.dtype { + DType::I64 + | DType::I32 + | DType::I16 + | DType::I8 + | DType::U64 + | DType::U32 + | DType::U16 + | DType::U8 => super::from_data(data, device), + _ => unimplemented!("Unsupported dtype for `int_from_data`"), + } + } + + fn int_to_device(tensor: IntTensor, device: &Device) -> IntTensor { + super::to_device(tensor, device) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + super::reshape(tensor, shape) + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + // Check if all steps are 1 + let all_steps_one = slices.iter().all(|info| info.step == 1); + + if all_steps_one { + // Use optimized slice for step=1 + let simple_ranges: Vec> = slices + .iter() + .enumerate() + .map(|(i, slice)| slice.to_range(tensor.meta.shape()[i])) + .collect(); + + kernel::slice(tensor, &simple_ranges) + } else { + // Use slice with steps kernel + kernel::slice_with_steps(tensor, slices) + } + } + + fn int_slice_assign( + tensor: IntTensor, + ranges: &[Slice], + value: IntTensor, + ) -> IntTensor { + kernel::slice_assign(tensor, ranges, value) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let dtype = lhs.dtype; + matmul(lhs, rhs, None, MatmulStrategy::default(), dtype).unwrap() + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + let bool_dtype = mask.dtype; + kernel::mask_where_auto(tensor, mask, value, bool_dtype) + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + let dtype = tensor.dtype; + let bool_dtype = mask.dtype; + kernel::mask_fill_auto( + tensor, + mask, + InputScalar::new(value, dtype_to_storage_type(dtype)), + bool_dtype, + ) + } + + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + kernel::gather(dim, tensor, indices) + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + kernel::scatter(dim, tensor, indices, value, false) + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> IntTensor { + kernel::scatter_nd(data, indices, values, reduction) + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + kernel::gather_nd(data, indices) + } + + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + kernel::select(tensor, dim, indices) + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + kernel::select_assign(tensor, dim, indices, value, false) + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::equal(lhs, rhs, out_dtype.into()) + } + + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + let dtype = lhs.dtype; + kernel::equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::greater(lhs, rhs, out_dtype.into()) + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::greater_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::greater_equal(lhs, rhs, out_dtype.into()) + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::greater_equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::lower(lhs, rhs, out_dtype.into()) + } + + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + let dtype = lhs.dtype; + kernel::lower_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::lower_equal(lhs, rhs, out_dtype.into()) + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::lower_equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::add(lhs, rhs) + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::add_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::sub(lhs, rhs) + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::sub_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::mul(lhs, rhs) + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::mul_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::div(lhs, rhs) + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::div_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::remainder(lhs, rhs) + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::remainder_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let dtype = dtype.into(); + numeric::zeros(device.clone(), shape, dtype) + } + + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let dtype = dtype.into(); + numeric::ones(device.clone(), shape, dtype) + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + let dtype: DType = dtype.into(); + let client = R::client(device); + numeric::full_device_dtype( + client, + shape, + device.clone(), + InputScalar::new(fill_value, dtype_to_storage_type(dtype)), + dtype, + ) + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + reduce::sum_fallback(tensor, Default::default()).unwrap() + } + + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Sum, + ) + .unwrap() + } + + fn int_any(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, None, ReduceOperationConfig::Any, out_dtype) + } + + fn int_any_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::Any, out_dtype) + } + + fn int_all(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, None, ReduceOperationConfig::All, out_dtype) + } + + fn int_all_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::All, out_dtype) + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + reduce::reduce( + tensor, + None, + Default::default(), + ReduceOperationConfig::Prod, + ) + .unwrap() + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Prod, + ) + .unwrap() + } + + fn int_max(tensor: IntTensor) -> IntTensor { + reduce::reduce(tensor, None, Default::default(), ReduceOperationConfig::Max).unwrap() + } + + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Max, + ) + .unwrap() + } + + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::TopK(k), + ) + .unwrap() + } + + fn int_max_abs(tensor: IntTensor) -> IntTensor { + reduce::reduce( + tensor, + None, + Default::default(), + ReduceOperationConfig::MaxAbs, + ) + .unwrap() + } + + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::MaxAbs, + ) + .unwrap() + } + + fn int_min(tensor: IntTensor) -> IntTensor { + reduce::reduce(tensor, None, Default::default(), ReduceOperationConfig::Min).unwrap() + } + + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Min, + ) + .unwrap() + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Mean, + ) + .unwrap() + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + numeric::cumsum(tensor, dim) + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + numeric::cumprod(tensor, dim) + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + numeric::cummin(tensor, dim) + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + numeric::cummax(tensor, dim) + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + let dtype = tensor.dtype; + reduce::reduce_dim( + tensor, + Some(dtype), + dim, + Default::default(), + ReduceOperationConfig::ArgMax, + ) + .unwrap() + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + let dtype = tensor.dtype; + reduce::reduce_dim( + tensor, + Some(dtype), + dim, + Default::default(), + ReduceOperationConfig::ArgTopK(k), + ) + .unwrap() + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + let dtype = tensor.dtype; + reduce::reduce_dim( + tensor, + Some(dtype), + dim, + Default::default(), + ReduceOperationConfig::ArgMin, + ) + .unwrap() + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + let dtype = tensor.dtype; + kernel::clamp( + tensor, + InputScalar::new(min, dtype_to_storage_type(dtype)), + InputScalar::new(max, dtype_to_storage_type(dtype)), + ) + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + struct Abs; + + #[cube] + impl NumericUnaryOp for Abs { + type Options = (); + + fn execute(input: Vector, _options: &Self::Options) -> Vector { + Vector::abs(input) + } + } + + impl NumericUnaryOpFamily for Abs { + type Options = (); + type Unary = Self; + } + + launch_unary_numeric::(tensor, |_| ()) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + unary_basic_int::launch::(tensor, |_| BasicIntUnaryKind::Sign) + } + + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + kernel::cast(tensor, out_dtype.into()) + } + + fn int_swap_dims(mut tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + tensor.meta.swap(dim1, dim2); + + tensor + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + kernel::repeat_dim(tensor, dim, times) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + let dtype = dtype.into(); + match distribution { + Distribution::Default => random_uniform(shape, device, 0., 255., dtype), + Distribution::Uniform(low, high) => { + random_uniform(shape, device, low.elem(), high.elem(), dtype) + } + Distribution::Bernoulli(prob) => random_bernoulli(shape, device, prob as f32, dtype), + Distribution::Normal(mean, std) => { + random_normal(shape, device, mean.elem(), std.elem(), dtype) + } + } + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + permute(tensor, axes) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + expand(tensor, shape) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + let bool_dtype = get_device_settings::(&tensor.device).bool_dtype; + kernel::flip(tensor, axes, bool_dtype.into()) + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::bitwise_and(lhs, rhs) + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::bitwise_and_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::bitwise_or(lhs, rhs) + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::bitwise_or_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + numeric::bitwise_xor(lhs, rhs) + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + numeric::bitwise_xor_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + unary_basic_int::launch::(tensor, |_| BasicIntUnaryKind::BitwiseNot) + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + launch_binop_int::(lhs, rhs) + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + launch_scalar_binop_int::( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + ) + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + launch_binop_int::(lhs, rhs) + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let dtype = lhs.dtype; + launch_scalar_binop_int::( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + ) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + kernel::cast(tensor, dtype.into()) + } + + fn int_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + unfold(tensor, dim, size, step) + } + + // TODO + // fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + // todo!() + // } + + // fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + // todo!() + // } +} diff --git a/crates/burn-cubecl/src/ops/mod.rs b/crates/burn-cubecl/src/ops/mod.rs new file mode 100644 index 0000000..bf6f213 --- /dev/null +++ b/crates/burn-cubecl/src/ops/mod.rs @@ -0,0 +1,16 @@ +mod activation; +mod bool_tensor; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; + +pub(crate) mod distributed; + +pub(crate) mod base; +pub use base::*; +pub use qtensor::*; + +/// Numeric utility functions for jit backends +pub mod numeric; diff --git a/crates/burn-cubecl/src/ops/module.rs b/crates/burn-cubecl/src/ops/module.rs new file mode 100644 index 0000000..4d333c9 --- /dev/null +++ b/crates/burn-cubecl/src/ops/module.rs @@ -0,0 +1,399 @@ +use crate::{ + CubeBackend, CubeRuntime, + kernel::{self, conv::ConvTranspose2dStrategy}, +}; +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor}; +use burn_backend::{ + TensorMetadata, + ops::{ + AttentionModuleOptions, ConvOptions, ConvTransposeOptions, DeformConv2dBackward, + DeformConvOptions, InterpolateOptions, MaxPool2dBackward, MaxPool2dWithIndices, ModuleOps, + }, +}; +use burn_std::IntDType; + +impl ModuleOps for CubeBackend +where + R: CubeRuntime, +{ + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + kernel::conv::conv_forward::(x, weight, bias, options, Default::default()).unwrap() + } + + fn conv1d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + kernel::conv::conv_data_backward( + output_grad, + weight, + x.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn conv1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + kernel::conv::conv_weight_backward::( + x, + output_grad, + weight.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor { + kernel::conv::conv_forward::(x, weight, bias, options, Default::default()).unwrap() + } + + fn conv2d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + kernel::conv::conv_data_backward( + output_grad, + weight, + x.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn conv2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + kernel::conv::conv_weight_backward::( + x, + output_grad, + weight.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + kernel::conv::deform_conv2d(x, offset, weight, mask, bias, options).unwrap() + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + let (x, o, w, m, b) = kernel::conv::deform_conv2d_backward( + x, + offset, + weight, + mask, + bias, + output_grad, + options, + ) + .unwrap(); + DeformConv2dBackward::new(x, o, w, m, b) + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + kernel::conv::conv_forward::(x, weight, bias, options, Default::default()).unwrap() + } + + fn conv3d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + kernel::conv::conv_data_backward( + output_grad, + weight, + x.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn conv3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + kernel::conv::conv_weight_backward::( + x, + output_grad, + weight.shape(), + options, + Default::default(), + ) + .unwrap() + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + kernel::conv::conv_transpose2d(x, weight, bias, options, ConvTranspose2dStrategy::default()) + .unwrap() + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + kernel::conv::conv_transpose3d(x, weight, bias, options).expect("Kernel to never fail") + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + kernel::pool::avg_pool2d( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ) + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + kernel::pool::avg_pool2d_backward( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ) + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + kernel::pool::max_pool2d(x, kernel_size, stride, padding, dilation, ceil_mode) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + let (output, indices) = kernel::pool::max_pool2d_with_indices( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype.into(), + ); + + MaxPool2dWithIndices::new(output, indices) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + MaxPool2dBackward::new(kernel::pool::max_pool2d_with_indices_backward( + x, + output_grad, + indices, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + )) + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + kernel::pool::adaptive_avg_pool2d(x, output_size) + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + kernel::pool::adaptive_avg_pool2d_backward(x, grad) + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + kernel::interpolate::interpolate(x, output_size, options, Default::default()).unwrap() + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + kernel::interpolate::interpolate_backward(x, grad, output_size, options) + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + ) -> FloatTensor { + // Fall back to naive attention for features the flash kernel doesn't support. + if attn_bias.is_some() || options.softcap.is_some() || options.scale.is_some() { + return burn_backend::ops::attention::attention_fallback::( + query, key, value, mask, attn_bias, options, + ); + } + + kernel::attention::attention( + query, + key, + value, + mask, + attn_bias, + options, + Default::default(), + ) + .expect("Kernel to never fail") + } + + fn has_ctc_loss_backward() -> bool { + true + } + + fn ctc_loss( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, + ) -> FloatTensor { + kernel::ctc::ctc_loss(log_probs, targets, input_lengths, target_lengths, blank) + } + + fn ctc_loss_backward( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + grad_loss: FloatTensor, + blank: usize, + ) -> FloatTensor { + let (log_alpha_full, log_beta_full, nll) = kernel::ctc::ctc_alpha_beta( + log_probs.clone(), + targets.clone(), + input_lengths.clone(), + target_lengths, + blank, + ); + burn_backend::ops::ctc::ctc_grad_from_alpha_beta_default::( + log_probs, + targets, + input_lengths, + grad_loss, + log_alpha_full, + log_beta_full, + nll, + blank, + ) + } + + fn rfft( + signal: FloatTensor, + dim: usize, + n: Option, + ) -> (FloatTensor, FloatTensor) { + kernel::fft::rfft(signal, dim, n) + } + + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + n: Option, + ) -> FloatTensor { + kernel::fft::irfft(spectrum_re, spectrum_im, dim, n) + } +} diff --git a/crates/burn-cubecl/src/ops/numeric.rs b/crates/burn-cubecl/src/ops/numeric.rs new file mode 100644 index 0000000..0052133 --- /dev/null +++ b/crates/burn-cubecl/src/ops/numeric.rs @@ -0,0 +1,472 @@ +use crate::{ + CubeRuntime, + kernel::utils::{address_type, shape_divmod}, +}; +use crate::{element::CubeElement, tensor::CubeTensor}; +use crate::{ + kernel::{ + AddOp, BitwiseAndOp, BitwiseOrOp, BitwiseXorOp, DivOp, MulOp, PowOp, RemainderOp, SubOp, + launch_binop, launch_binop_int, launch_scalar_binop, launch_scalar_binop_int, + }, + ops::max_vector_size, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::{DType, Shape, TensorMetadata}; +use burn_std::Metadata; +use cubecl::{ + calculate_cube_count_elemwise, prelude::*, std::tensor::layout::linear::LinearViewMut, +}; +use cubecl::{client::ComputeClient, server::MemoryLayout}; +use cubecl::{server::MemoryLayoutDescriptor, std::FastDivmod}; + +/// Creates a tensor filled with `value` +pub fn full( + shape: Shape, + device: &R::Device, + value: E, +) -> CubeTensor { + let client = R::client(device); + + full_client::(client, shape, device.clone(), value) +} + +/// Creates a tensor filled with `value` +pub fn full_client( + client: ComputeClient, + shape: Shape, + device: R::Device, + value: E, +) -> CubeTensor { + let dtype = E::dtype(); + full_device_dtype( + client, + shape, + device, + InputScalar::new(value, dtype_to_storage_type(dtype)), + dtype, + ) +} + +/// Creates a tensor filled with `value` +pub fn full_device_dtype( + client: ComputeClient, + shape: Shape, + device: R::Device, + value: InputScalar, + dtype: DType, +) -> CubeTensor { + let empty = empty_device_dtype(client, device, shape, dtype); + + #[cube(launch_unchecked, address_type = "dynamic")] + pub fn full_kernel( + mut tensor: LinearViewMut<'_, Vector>, + value: InputScalar, + #[define(C)] _dtype: StorageType, + ) { + if !tensor.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + tensor.write(ABSOLUTE_POS, Vector::new(value.get::())); + } + + let num_elems = empty.meta.num_elements(); + let vector_size = max_vector_size(&empty); + + let working_units = num_elems / vector_size as usize; + let cube_dim = CubeDim::new(&empty.client, working_units); + let cube_count = calculate_cube_count_elemwise(&empty.client, working_units, cube_dim); + + unsafe { + full_kernel::launch_unchecked( + &empty.client, + cube_count, + cube_dim, + address_type!(empty), + vector_size, + empty.clone().into_linear_view(), + value, + dtype_to_storage_type(empty.dtype), + ); + } + + empty +} + +/// Creates a tensor filled with zeros +pub fn zeros(device: R::Device, shape: Shape, dtype: DType) -> CubeTensor { + let client = R::client(&device); + full_device_dtype( + client, + shape, + device, + InputScalar::new(0u32, dtype_to_storage_type(dtype)), + dtype, + ) +} + +/// Creates a tensor filled with ones +pub fn ones(device: R::Device, shape: Shape, dtype: DType) -> CubeTensor { + let client = R::client(&device); + full_device_dtype( + client, + shape, + device, + InputScalar::new(1u32, dtype_to_storage_type(dtype)), + dtype, + ) +} + +/// Creates a tensor filled with zeros +pub fn zeros_client( + client: ComputeClient, + device: R::Device, + shape: Shape, + dtype: DType, +) -> CubeTensor { + full_device_dtype( + client, + shape, + device, + InputScalar::new(0u32, dtype_to_storage_type(dtype)), + dtype, + ) +} + +/// Creates a tensor filled with ones +pub fn ones_client( + client: ComputeClient, + device: R::Device, + shape: Shape, + dtype: DType, +) -> CubeTensor { + full_device_dtype( + client, + shape, + device, + InputScalar::new(1u32, dtype_to_storage_type(dtype)), + dtype, + ) +} + +/// Create a tensor with uninitialized memory +pub fn empty_device( + client: ComputeClient, + device: R::Device, + shape: Shape, +) -> CubeTensor { + let MemoryLayout { memory, strides } = client.empty_tensor(shape.clone(), size_of::()); + + CubeTensor::new( + client, + memory, + Metadata::new(shape, strides), + device, + E::dtype(), + ) +} + +/// Create a tensor with uninitialized memory +pub fn empty_device_dtype( + client: ComputeClient, + device: R::Device, + shape: Shape, + dtype: DType, +) -> CubeTensor { + let MemoryLayout { memory, strides } = client.empty_tensor(shape.clone(), dtype.size()); + + CubeTensor::new(client, memory, Metadata::new(shape, strides), device, dtype) +} + +/// Create a contiguous tensor with uninitialized memory +pub fn empty_device_contiguous_dtype( + client: ComputeClient, + device: R::Device, + shape: Shape, + dtype: DType, +) -> CubeTensor { + let descriptor = MemoryLayoutDescriptor::contiguous(shape.clone(), dtype.size()); + let MemoryLayout { memory, strides } = client.empty_tensors(vec![descriptor]).remove(0); + + CubeTensor::new(client, memory, Metadata::new(shape, strides), device, dtype) +} + +/// Add two tensors +pub fn add(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Add a tensor and a scalar +pub fn add_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop::(lhs, rhs) +} + +/// Subtract two tensors +pub fn sub(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Subtract a tensor and a scalar +pub fn sub_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop::(lhs, rhs) +} + +/// Multiply two tensors +pub fn mul(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Multiply a tensor and a scalar +pub fn mul_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop::(lhs, rhs) +} + +/// Divide two tensors +pub fn div(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Divide a tensor by a scalar +pub fn div_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop::(lhs, rhs) +} + +/// Calculate remainder of two tensors +pub fn remainder(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Calculate the remainder of a tensor with a scalar +pub fn remainder_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop::(lhs, rhs) +} + +/// Calculate the power of two tensors +pub fn pow(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop::(lhs, rhs) +} + +/// Bitwise and two tensors +pub fn bitwise_and(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop_int::(lhs, rhs) +} + +/// Bitwise and with a scalar +pub fn bitwise_and_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop_int::(lhs, rhs) +} + +/// Bitwise or two tensors +pub fn bitwise_or(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop_int::(lhs, rhs) +} + +/// Bitwise or with a scalar +pub fn bitwise_or_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop_int::(lhs, rhs) +} + +/// Bitwise xor two tensors +pub fn bitwise_xor(lhs: CubeTensor, rhs: CubeTensor) -> CubeTensor { + launch_binop_int::(lhs, rhs) +} + +/// Bitwise xor with a scalar +pub fn bitwise_xor_scalar(lhs: CubeTensor, rhs: InputScalar) -> CubeTensor { + launch_scalar_binop_int::(lhs, rhs) +} + +/// Operation family trait for cumulative operations +pub(crate) trait CumulativeOpFamily: Send + Sync + 'static { + type CumulativeOp: CumulativeOp; +} + +/// Trait for cumulative operations +#[cube] +pub(crate) trait CumulativeOp: 'static + Send + Sync { + /// Execute a cumulative operation + fn execute(lhs: C, rhs: C) -> C; + + /// Get the initial value for the accumulator + fn init_value(first_element: C) -> C; +} + +// Operation types +struct SumOp; +struct ProdOp; +struct MaxOp; +struct MinOp; + +// Implement CumulativeOpFamily for each operation +impl CumulativeOpFamily for SumOp { + type CumulativeOp = Self; +} + +impl CumulativeOpFamily for ProdOp { + type CumulativeOp = Self; +} + +impl CumulativeOpFamily for MaxOp { + type CumulativeOp = Self; +} + +impl CumulativeOpFamily for MinOp { + type CumulativeOp = Self; +} + +// Implement CumulativeOp for each operation type +#[cube] +impl CumulativeOp for SumOp { + fn execute(lhs: N, rhs: N) -> N { + lhs + rhs + } + + fn init_value(_first_element: N) -> N { + N::zero() + } +} + +#[cube] +impl CumulativeOp for ProdOp { + fn execute(lhs: N, rhs: N) -> N { + lhs * rhs + } + + fn init_value(_first_element: N) -> N { + N::from_int(1) + } +} + +#[cube] +impl CumulativeOp for MaxOp { + fn execute(lhs: N, rhs: N) -> N { + max(lhs, rhs) + } + + fn init_value(first_element: N) -> N { + first_element + } +} + +#[cube] +impl CumulativeOp for MinOp { + fn execute(lhs: N, rhs: N) -> N { + min(lhs, rhs) + } + + fn init_value(first_element: N) -> N { + first_element + } +} + +/// Generic cumulative operation kernel +/// +/// # Limitations +/// +/// This is a **naive sequential implementation** along the cumulative dimension: +/// - Each output element sequentially reads all previous elements along the dimension +/// - Computational complexity: O(n^2) memory reads where n is the size of the cumulative dimension +/// - **Performance:** Suitable for small tensors or small dimensions. For large tensors, +/// performance will degrade significantly compared to an optimized parallel scan algorithm. +/// +/// # TODO +/// +/// Implement an efficient GPU-optimized parallel scan algorithm. +#[cube(launch_unchecked, address_type = "dynamic")] +fn cumulative_kernel( + input: &Tensor, + mut output: LinearViewMut<'_, C>, + shape: Sequence>, + #[comptime] dim: usize, + #[define(C)] _dtype: StorageType, +) { + if !output.is_in_bounds(ABSOLUTE_POS) { + terminate!(); + } + + let rank = comptime![shape.len()]; + let dim_stride = input.stride(dim); + + let mut remainder = ABSOLUTE_POS; + let mut offset = 0; + let mut dim_idx = 0; + + #[unroll] + for i in 0..shape.len() { + let i = comptime![rank - i - 1]; + let (rem, local_idx) = shape.index(i).div_mod(remainder); + remainder = rem; + if i == dim { + dim_idx = local_idx; + } else { + offset += local_idx * input.stride(i); + } + } + + // Read first element + let first_read_idx = offset + dim_idx * dim_stride; + let first_elem = input[first_read_idx]; + + // Initialize accumulator + let mut result = O::CumulativeOp::::init_value(first_elem); + + // Accumulate values + for i in 0..=dim_idx { + let read_idx = offset + i * dim_stride; + result = O::CumulativeOp::::execute(result, input[read_idx]); + } + output.write(ABSOLUTE_POS, result); +} + +/// Compute the cumulative sum along a dimension +pub fn cumsum(input: CubeTensor, dim: usize) -> CubeTensor { + cumulative_op::(input, dim) +} + +/// Compute the cumulative product along a dimension +pub fn cumprod(input: CubeTensor, dim: usize) -> CubeTensor { + cumulative_op::(input, dim) +} + +/// Compute the cumulative minimum along a dimension +pub fn cummin(input: CubeTensor, dim: usize) -> CubeTensor { + cumulative_op::(input, dim) +} + +/// Compute the cumulative maximum along a dimension +pub fn cummax(input: CubeTensor, dim: usize) -> CubeTensor { + cumulative_op::(input, dim) +} + +/// Generic cumulative operation function +fn cumulative_op( + input: CubeTensor, + dim: usize, +) -> CubeTensor { + let client = input.client.clone(); + let device = input.device.clone(); + + let output = empty_device_dtype(client.clone(), device, input.shape(), input.dtype); + + let num_elems = output.meta.num_elements(); + let working_units = num_elems; + let cube_dim = CubeDim::new(&client, working_units); + let cube_count = calculate_cube_count_elemwise(&client, working_units, cube_dim); + let shape = shape_divmod(&input); + + unsafe { + cumulative_kernel::launch_unchecked::( + &client, + cube_count, + cube_dim, + address_type!(input, output), + input.into_tensor_arg(), + output.clone().into_linear_view(), + shape, + dim, + dtype_to_storage_type(output.dtype), + ); + } + + output +} diff --git a/crates/burn-cubecl/src/ops/qtensor.rs b/crates/burn-cubecl/src/ops/qtensor.rs new file mode 100644 index 0000000..d0efb50 --- /dev/null +++ b/crates/burn-cubecl/src/ops/qtensor.rs @@ -0,0 +1,283 @@ +use burn_backend::{ + Bytes, DType, ExecutionError, Shape, SplitPolicy, TensorData, TensorMetadata, TensorPrimitive, + get_device_settings, + ops::QTensorOps, + quantization::{ + QParamTensor, QuantLevel, QuantMode, QuantParam, QuantPropagation, QuantScheme, QuantValue, + QuantizationParametersPrimitive, params_shape, + }, + tensor::{Device, FloatTensor, QuantizedTensor}, +}; +use burn_std::{FloatDType, Metadata}; +use cubecl::server::{MemoryLayout, MemoryLayoutDescriptor, MemoryLayoutStrategy}; +use cubecl::{e2m1x2, quant::scheme::QuantStore}; + +use crate::{ + CubeBackend, CubeRuntime, + kernel::{self, matmul::MatmulStrategy}, + tensor::{CubeTensor, QParams}, +}; + +use super::{into_data, permute, swap_dims}; + +/// Create a quantized tensor with packed values (u32). +fn new_qtensor_optimized( + data: Bytes, + shape: impl Into, + scheme: QuantScheme, + device: &R::Device, +) -> CubeTensor { + new_qtensor(data, shape, scheme, device, MemoryLayoutStrategy::Optimized) +} + +/// Create a quantized tensor with packed values (u32). +fn new_qtensor( + data: Bytes, + shape: impl Into, + scheme: QuantScheme, + device: &R::Device, + kind: MemoryLayoutStrategy, +) -> CubeTensor { + new_quantized(shape, scheme, device, Some(data), kind) +} + +/// Create an empty quantized tensor. +pub fn empty_qtensor_optimized( + shape: impl Into, + scheme: QuantScheme, + device: &R::Device, +) -> CubeTensor { + empty_qtensor(shape, scheme, device, MemoryLayoutStrategy::Optimized) +} + +/// Create an empty quantized tensor. +pub fn empty_qtensor( + shape: impl Into, + scheme: QuantScheme, + device: &R::Device, + kind: MemoryLayoutStrategy, +) -> CubeTensor { + new_quantized(shape, scheme, device, None, kind) +} + +fn new_quantized( + shape: impl Into, + scheme: QuantScheme, + device: &R::Device, + data: Option, + alloc_kind: MemoryLayoutStrategy, +) -> CubeTensor { + let client = R::client(device); + let shape: Shape = shape.into(); + let mut shape_value: Shape = shape.clone(); + + let rank = shape.rank(); + let shape_last = shape[rank - 1]; + let num_quants = scheme.num_quants(); + + let data_size = match scheme.store { + QuantStore::PackedU32(_) => { + if !shape_last.is_multiple_of(num_quants) { + panic!("Can't store in u32") + } + shape_value[rank - 1] = shape_last.div_ceil(num_quants); + size_of::() + } + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S | QuantValue::E4M3 | QuantValue::E5M2 => { + size_of::() + } + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 => { + panic!("Can't store native sub-byte values") + } + }, + QuantStore::PackedNative(_) => match scheme.value { + QuantValue::E2M1 => size_of::(), + other => panic!("{other:?} doesn't support native packing"), + }, + }; + + let scales_dtype = match scheme.param { + QuantParam::F32 => DType::F32, + QuantParam::F16 => DType::F16, + QuantParam::BF16 => DType::BF16, + // Represented by U8 and reinterpreted in the kernel + QuantParam::UE8M0 | QuantParam::UE4M3 => DType::U8, + }; + + let scales_shape = params_shape(&shape, scheme.level); + let data_desc = MemoryLayoutDescriptor::new(alloc_kind, shape_value.clone(), data_size); + let scales_desc = + MemoryLayoutDescriptor::new(alloc_kind, scales_shape.clone(), scales_dtype.size()); + + let mut tensors = match data { + Some(data) => { + let num_bytes = shape_value.num_elements() * data_size; + + match data.split(num_bytes, SplitPolicy::Shared) { + Ok((bytes_data, bytes_scales)) => client + .create_tensors(vec![(data_desc, bytes_data), (scales_desc, bytes_scales)]), + Err((data, _)) => client.create_tensors_from_slices(vec![ + (data_desc, &data[..num_bytes]), + (scales_desc, &data[num_bytes..]), + ]), + } + } + None => client.empty_tensors(vec![data_desc, scales_desc]), + }; + let MemoryLayout { + memory: scales_handle, + strides: scales_strides, + } = tensors.remove(1); + let MemoryLayout { memory, strides } = tensors.remove(0); + + let scales = QParamTensor { + offset_start: scales_handle.offset_start.unwrap_or(0) as usize, + offset_end: scales_handle.offset_end.unwrap_or(0) as usize, + metadata: Metadata::new(scales_shape, scales_strides), + dtype: scales_dtype, + }; + let qparams = QParams { scales }; + + CubeTensor::new_quantized( + client, + memory, + shape, + device.clone(), + strides, + DType::QFloat(scheme), + qparams, + ) +} + +impl QTensorOps for CubeBackend { + fn q_from_data(data: TensorData, device: &Device) -> QuantizedTensor { + match data.dtype { + DType::QFloat(scheme) => match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E4M3 + | QuantValue::E5M2 + | QuantValue::E2M1, + .. + } => { + // TensorData quantized representation is the same, with multiple quantized values + // packed into u32 and quantization parameters appended to the bytes + new_qtensor_optimized(data.bytes, data.shape.clone(), scheme, device) + } + }, + _ => panic!( + "Invalid dtype (expected DType::QFloat, got {:?})", + data.dtype + ), + } + } + + // TODO: quantize_dynamic (we can compute min-max on the fly and scale, especially when not per-tensor) + + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + kernel::quantization::quantize(tensor, scheme, qparams.scales) + } + + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor { + kernel::quantization::dequantize(tensor, dtype.into()) + } + + fn q_to_device(tensor: QuantizedTensor, device: &Device) -> QuantizedTensor { + super::to_device(tensor, device) + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + super::q_reshape(tensor, shape) + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + if tensor.qparams.is_none() { + return into_data(tensor).await; + } + + let (shape, dtype) = (tensor.shape(), tensor.dtype); + let (values, params) = tensor.quantized_handles().unwrap(); + + let mut data_values = into_data(values).await?; + let data_params = into_data(params).await?; + + data_values.bytes.extend_from_byte_slice(&data_params.bytes); + + Ok(TensorData { + bytes: data_values.bytes, + shape, + dtype, + }) + } + + fn q_swap_dims( + tensor: QuantizedTensor, + dim1: usize, + dim2: usize, + ) -> QuantizedTensor { + swap_dims(tensor, dim1, dim2) + } + + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + permute(tensor, axes) + } + + fn q_flip(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_matmul(lhs: TensorPrimitive, rhs: TensorPrimitive) -> TensorPrimitive { + let (settings, scheme) = match (&lhs, &rhs) { + (TensorPrimitive::QFloat(lhs), _) => { + (get_device_settings::(&lhs.device), lhs.scheme()) + } + (_, TensorPrimitive::QFloat(rhs)) => { + (get_device_settings::(&rhs.device), rhs.scheme()) + } + _ => unreachable!(), + }; + + // Inherit precision for mixed inputs, default to `FloatElem` for fully quantized. + let out_dtype = match (&lhs, &rhs) { + (TensorPrimitive::Float(lhs), _) => lhs.dtype, + (_, TensorPrimitive::Float(rhs)) => rhs.dtype, + _ => settings.float_dtype.into(), + }; + + let (_lhs_dtype, lhs) = match lhs { + TensorPrimitive::Float(lhs) => (lhs.dtype, lhs), + TensorPrimitive::QFloat(lhs) => (out_dtype, lhs), + }; + let (_rhs_dtype, rhs) = match rhs { + TensorPrimitive::Float(rhs) => (rhs.dtype, rhs), + TensorPrimitive::QFloat(rhs) => (out_dtype, rhs), + }; + + let out = + kernel::matmul::matmul(lhs, rhs, None, MatmulStrategy::default(), out_dtype).unwrap(); + + match settings.quantization.propagation { + QuantPropagation::Propagate => { + TensorPrimitive::QFloat(Self::quantize_dynamic(out, &scheme)) + } + QuantPropagation::Inhibit => TensorPrimitive::Float(out), + } + } +} diff --git a/crates/burn-cubecl/src/ops/tensor.rs b/crates/burn-cubecl/src/ops/tensor.rs new file mode 100644 index 0000000..cf20881 --- /dev/null +++ b/crates/burn-cubecl/src/ops/tensor.rs @@ -0,0 +1,757 @@ +use super::{expand, numeric, permute, unfold}; +use crate::CubeBackend; +use crate::CubeRuntime; +use crate::kernel::matmul::{MatmulStrategy, matmul}; +use crate::kernel::prng::{random_bernoulli, random_normal, random_uniform}; +use crate::kernel::unary_basic::BasicFloatUnaryKind; +use crate::kernel::{ + self, FloatUnaryOp, FloatUnaryOpFamily, launch_unary_float, reduce, unary_basic, +}; +use burn_backend::cubecl::dtype_to_storage_type; +use burn_backend::ops::GridSampleOptions; +use burn_backend::tensor::{BoolTensor, Device, FloatTensor, IntTensor}; +use burn_backend::{DType, ElementConversion, FloatDType, Slice}; +use burn_backend::{Distribution, Shape, TensorData, ops::FloatTensorOps}; +use burn_backend::{ExecutionError, Scalar, get_device_settings}; +use burn_std::{BoolDType, IntDType}; +use cubecl::prelude::*; +use cubek::reduce::components::instructions::ReduceOperationConfig; +use std::ops::Range; + +impl FloatTensorOps for CubeBackend +where + R: CubeRuntime, +{ + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(data), + fields(?data.shape, ?data.dtype) + ))] + fn float_from_data(data: TensorData, device: &Device) -> FloatTensor { + match data.dtype { + DType::F64 | DType::F32 | DType::F16 | DType::BF16 => super::from_data(data, device), + _ => unimplemented!("Unsupported dtype for `float_from_data`"), + } + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + let dtype = dtype.into(); + match distribution { + Distribution::Default => random_uniform(shape, device, 0., 1., dtype), + Distribution::Uniform(low, high) => { + random_uniform(shape, device, low.elem(), high.elem(), dtype) + } + Distribution::Bernoulli(prob) => random_bernoulli(shape, device, prob as f32, dtype), + Distribution::Normal(mean, std) => { + random_normal(shape, device, mean.elem(), std.elem(), dtype) + } + } + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields(from = ?tensor.device, meta = ?tensor.meta, dtype = ?tensor.dtype) + ))] + async fn float_into_data(tensor: FloatTensor) -> Result { + super::into_data(tensor).await + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields(from = ?tensor.device, meta = ?tensor.meta, dtype = ?tensor.dtype) + ))] + fn float_to_device(tensor: FloatTensor, device: &Device) -> FloatTensor { + super::to_device(tensor, device) + } + + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let dtype = dtype.into(); + super::empty(shape, device, dtype) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::add(lhs, rhs) + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let dtype = lhs.dtype; + numeric::add_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn float_zeros(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let dtype = dtype.into(); + numeric::zeros(device.clone(), shape, dtype) + } + + fn float_full( + shape: Shape, + fill_value: Scalar, + device: &R::Device, + dtype: FloatDType, + ) -> FloatTensor { + let dtype: DType = dtype.into(); + let client = R::client(device); + numeric::full_device_dtype( + client, + shape, + device.clone(), + InputScalar::new(fill_value, dtype_to_storage_type(dtype)), + dtype, + ) + } + + fn float_ones(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let dtype = dtype.into(); + numeric::ones(device.clone(), shape, dtype) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::sub(lhs, rhs) + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let dtype = lhs.dtype; + numeric::sub_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::mul(lhs, rhs) + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let dtype = lhs.dtype; + numeric::mul_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::div(lhs, rhs) + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let dtype = lhs.dtype; + numeric::div_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::remainder(lhs, rhs) + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let dtype = lhs.dtype; + numeric::remainder_scalar(lhs, InputScalar::new(rhs, dtype_to_storage_type(dtype))) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let dtype = lhs.dtype; + matmul(lhs, rhs, None, MatmulStrategy::default(), dtype).unwrap() + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + kernel::cross(lhs, rhs, dim) + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + super::swap_dims(tensor, dim1, dim2) + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + super::reshape(tensor, shape) + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + kernel::gather(dim, tensor, indices) + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + kernel::scatter(dim, tensor, indices, value, false) + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + kernel::scatter_nd(data, indices, values, reduction) + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + kernel::gather_nd(data, indices) + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + kernel::select(tensor, dim, indices) + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + kernel::select_assign(tensor, dim, indices, value, false) + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + // Check if all steps are 1 + let all_steps_one = slices.iter().all(|info| info.step == 1); + + if all_steps_one { + // Use optimized slice for step=1 + let simple_ranges: Vec> = slices + .iter() + .enumerate() + .map(|(i, slice)| slice.to_range(tensor.meta.shape()[i])) + .collect(); + + kernel::slice(tensor, &simple_ranges) + } else { + // Use slice with steps kernel + kernel::slice_with_steps(tensor, slices) + } + } + + fn float_slice_assign( + tensor: FloatTensor, + ranges: &[Slice], + value: FloatTensor, + ) -> FloatTensor { + kernel::slice_assign(tensor, ranges, value) + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + let bool_dtype = mask.dtype; + kernel::mask_where_auto(tensor, mask, value, bool_dtype) + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + let dtype = tensor.dtype; + let bool_dtype = mask.dtype; + kernel::mask_fill_auto( + tensor, + mask, + InputScalar::new(value, dtype_to_storage_type(dtype)), + bool_dtype, + ) + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::equal(lhs, rhs, out_dtype.into()) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::greater(lhs, rhs, out_dtype.into()) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::greater_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::greater_equal(lhs, rhs, out_dtype.into()) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::greater_equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::lower(lhs, rhs, out_dtype.into()) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::lower_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + kernel::lower_equal(lhs, rhs, out_dtype.into()) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let dtype = lhs.dtype; + kernel::lower_equal_elem( + lhs, + InputScalar::new(rhs, dtype_to_storage_type(dtype)), + out_dtype.into(), + ) + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + reduce::sum_fallback(tensor, Default::default()).unwrap() + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + reduce::reduce(tensor, None, Default::default(), ReduceOperationConfig::Max).unwrap() + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Max, + ) + .unwrap() + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + reduce::reduce(tensor, None, Default::default(), ReduceOperationConfig::Min).unwrap() + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Min, + ) + .unwrap() + } + + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + reduce::reduce( + tensor, + None, + Default::default(), + ReduceOperationConfig::MaxAbs, + ) + .unwrap() + } + + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::MaxAbs, + ) + .unwrap() + } + + fn float_any(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, None, ReduceOperationConfig::Any, out_dtype) + } + + fn float_any_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: BoolDType, + ) -> BoolTensor { + reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::Any, out_dtype) + } + + fn float_all(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + reduce::reduce_logical(tensor, None, ReduceOperationConfig::All, out_dtype) + } + + fn float_all_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: BoolDType, + ) -> BoolTensor { + reduce::reduce_logical(tensor, Some(dim), ReduceOperationConfig::All, out_dtype) + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Sum, + ) + .unwrap() + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Mean, + ) + .unwrap() + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + reduce::reduce( + tensor, + None, + Default::default(), + ReduceOperationConfig::Mean, + ) + .unwrap() + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + numeric::cumsum(tensor, dim) + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + numeric::cumprod(tensor, dim) + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + numeric::cummin(tensor, dim) + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + numeric::cummax(tensor, dim) + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + reduce::reduce( + tensor, + None, + Default::default(), + ReduceOperationConfig::Prod, + ) + .unwrap() + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::Prod, + ) + .unwrap() + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Exp) + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Log) + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Log1p) + } + + fn float_powf_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + struct Powf; + + #[cube] + impl FloatUnaryOp for Powf { + type Options = InputScalar; + + fn execute(input: Vector, options: &Self::Options) -> Vector { + Vector::powf(input, Vector::new(options.get::())) + } + } + + impl FloatUnaryOpFamily for Powf { + type Options = InputScalar; + type Unary = Self; + } + + let dtype = lhs.dtype; + launch_unary_float::(lhs, |_| { + InputScalar::new(rhs, dtype_to_storage_type(dtype)) + }) + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Sqrt) + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Abs) + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Sign) + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Cos) + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Sin) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Tan) + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Cosh) + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Sinh) + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Tanh) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcCos) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcCosh) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcSin) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcSinh) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcTan) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::ArcTanh) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + crate::kernel::atan2::(lhs, rhs) + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Round) + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Floor) + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Ceil) + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Trunc) + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Erf) + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + reduce::reduce_dim( + tensor, + Some(out_dtype.into()), + dim, + Default::default(), + ReduceOperationConfig::ArgMax, + ) + .unwrap() + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + reduce::reduce_dim( + tensor, + Some(out_dtype.into()), + dim, + Default::default(), + ReduceOperationConfig::ArgTopK(k), + ) + .unwrap() + } + + fn float_topk(tensor: FloatTensor, dim: usize, k: usize) -> FloatTensor { + reduce::reduce_dim( + tensor, + None, + dim, + Default::default(), + ReduceOperationConfig::TopK(k), + ) + .unwrap() + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + reduce::reduce_dim( + tensor, + Some(out_dtype.into()), + dim, + Default::default(), + ReduceOperationConfig::ArgMin, + ) + .unwrap() + } + + fn float_into_int(tensor: FloatTensor, out_dtype: IntDType) -> IntTensor { + kernel::cast(tensor, out_dtype.into()) + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + let dtype = tensor.dtype; + kernel::clamp( + tensor, + InputScalar::new(min, dtype_to_storage_type(dtype)), + InputScalar::new(max, dtype_to_storage_type(dtype)), + ) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + unary_basic::launch::(tensor, |_| BasicFloatUnaryKind::Recip) + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + kernel::repeat_dim(tensor, dim, times) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + numeric::pow(lhs, rhs) + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + permute(tensor, axes) + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + expand(tensor, shape) + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + let bool_dtype = get_device_settings::(&tensor.device).bool_dtype; + kernel::flip(tensor, axes, bool_dtype.into()) + } + + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor { + kernel::cast(tensor, dtype.into()) + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + unfold(tensor, dim, size, step) + } + + fn float_is_nan(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + kernel::is_nan(tensor, out_dtype.into()) + } + + fn float_is_inf(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + kernel::is_inf(tensor, out_dtype.into()) + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, + ) -> FloatTensor { + kernel::grid_sample::grid_sample(tensor, grid, options) + } +} diff --git a/crates/burn-cubecl/src/ops/transaction.rs b/crates/burn-cubecl/src/ops/transaction.rs new file mode 100644 index 0000000..ddc44dd --- /dev/null +++ b/crates/burn-cubecl/src/ops/transaction.rs @@ -0,0 +1,137 @@ +use burn_backend::{ + DType, TensorData, + backend::ExecutionError, + ops::{TransactionOps, TransactionPrimitive, TransactionPrimitiveData}, +}; +use burn_std::{Shape, Strides}; +use cubecl::server::{CopyDescriptor, Handle}; + +use crate::{CubeBackend, CubeRuntime}; + +impl TransactionOps for CubeBackend { + async fn tr_execute( + transaction: TransactionPrimitive, + ) -> Result { + let mut client = None; + + enum Kind { + Float, + Int, + Bool, + } + + #[derive(new)] + struct BindingData { + index: usize, + kind: Kind, + handle: Option, + shape: Shape, + strides: Strides, + dtype: DType, + } + + let mut num_bindings = 0; + + let mut kinds = Vec::new(); + + for t in transaction.read_floats.into_iter() { + if client.is_none() { + client = Some(t.client.clone()); + } + + let t = crate::kernel::into_contiguous_aligned(t); + let binding = BindingData::new( + num_bindings, + Kind::Float, + Some(t.handle.clone()), + t.meta.shape.clone(), + t.meta.strides.clone(), + t.dtype, + ); + + kinds.push(binding); + num_bindings += 1; + } + for t in transaction.read_ints.into_iter() { + if client.is_none() { + client = Some(t.client.clone()); + } + + let t = crate::kernel::into_contiguous_aligned(t); + let binding = BindingData::new( + num_bindings, + Kind::Int, + Some(t.handle.clone()), + t.meta.shape.clone(), + t.meta.strides.clone(), + t.dtype, + ); + + kinds.push(binding); + num_bindings += 1; + } + for t in transaction.read_bools.into_iter() { + if client.is_none() { + client = Some(t.client.clone()); + } + + let t = crate::kernel::into_contiguous_aligned(t); + let binding = BindingData::new( + num_bindings, + Kind::Bool, + Some(t.handle.clone()), + t.meta.shape.clone(), + t.meta.strides.clone(), + t.dtype, + ); + + kinds.push(binding); + num_bindings += 1; + } + + let client = client.unwrap(); + + let bindings = kinds + .iter_mut() + .map(|b| { + CopyDescriptor::new( + b.handle.take().unwrap().binding(), + b.shape.clone(), + b.strides.clone(), + b.dtype.size(), + ) + }) + .collect(); + + let mut data: Vec> = client + .read_tensor_async(bindings) + .await + .map_err(|err| ExecutionError::WithContext { + reason: format!("{err:?}"), + })? + .into_iter() + .map(Some) + .collect::>>(); + + let mut result = TransactionPrimitiveData::default(); + + for binding in kinds { + let bytes = data.get_mut(binding.index).unwrap().take().unwrap(); + let t_data = TensorData::from_bytes(bytes, binding.shape, binding.dtype); + + match binding.kind { + Kind::Float => { + result.read_floats.push(t_data); + } + Kind::Int => { + result.read_ints.push(t_data); + } + Kind::Bool => { + result.read_bools.push(t_data); + } + } + } + + Ok(result) + } +} diff --git a/crates/burn-cubecl/src/template/base.rs b/crates/burn-cubecl/src/template/base.rs new file mode 100644 index 0000000..0ec7c08 --- /dev/null +++ b/crates/burn-cubecl/src/template/base.rs @@ -0,0 +1,103 @@ +use super::SourceTemplate; +use crate::{CubeRuntime, tensor::CubeTensor}; +use cubecl::{CompilationError, Compiler, CubeTask, prelude::*}; + +/// Kernel source to create a [source](SourceTemplate) +pub trait KernelSource: Send + 'static + Sync { + /// Convert to [source](SourceTemplate) + fn source(&self) -> SourceTemplate; + /// Identifier for the kernel, used for caching kernel compilation. + fn id(&self) -> KernelId; +} + +#[derive(new)] +/// Wraps a [kernel source](KernelSource) into a [cube task](CubeTask). +pub struct SourceKernel { + kernel_source: K, + cube_dim: CubeDim, +} + +impl CubeTask for SourceKernel { + fn compile( + &self, + _compiler: &mut C, + _options: &C::CompilationOptions, + _mode: ExecutionMode, + _address_type: StorageType, + ) -> Result, CompilationError> { + let source_template = self.kernel_source.source(); + let source = source_template.complete(); + + Ok(CompiledKernel { + entrypoint_name: "main".to_string(), + debug_name: Some(core::any::type_name::()), + source, + cube_dim: self.cube_dim, + debug_info: None, + repr: None, + }) + } +} + +impl KernelMetadata for SourceKernel { + fn id(&self) -> KernelId { + self.kernel_source.id() + } + + fn address_type(&self) -> StorageType { + u32::as_type_native_unchecked().storage_type() + } +} + +/// Generates kernel source code by replacing some information using templating. +#[macro_export] +macro_rules! kernel_source { + ( + $struct:ident, + $file:expr + ) => { + /// Generated kernel from a source file. + #[derive(new)] + pub struct $struct; + + impl $struct { + fn source(&self) -> $crate::template::SourceTemplate { + $crate::template::SourceTemplate::new(include_str!($file)) + } + } + }; +} + +/// Create a vector containing the dimension, strides and shape of tensors. +/// +/// # Example +/// +/// With two tensors (lhs, rhs) +/// +/// | Indexes | Value | +/// |:------------------------:|:-----------:| +/// | 0..1 | D | +/// | 1..D + 1 | lhs strides | +/// | (D + 1)..(2 * D + 1) | rhs strides | +/// | (2 * D + 1)..(3 * D + 1) | lhs shape | +/// | (3 * D + 1)..(4 * D + 1) | rhs shape | +pub fn build_info(tensors: &[&CubeTensor]) -> Vec { + let ndims = tensors[0].meta.num_dims(); + let mut info: Vec = vec![0; tensors.len() * 2 * ndims + 1]; + info[0] = ndims as u32; + + let mut current = 1; + for tensor in tensors.iter() { + for d in 0..ndims { + info[current] = tensor.meta.strides()[d] as u32; + current += 1; + } + } + for tensor in tensors.iter() { + for d in 0..ndims { + info[current] = tensor.meta.shape()[d] as u32; + current += 1; + } + } + info +} diff --git a/crates/burn-cubecl/src/template/mod.rs b/crates/burn-cubecl/src/template/mod.rs new file mode 100644 index 0000000..8c34090 --- /dev/null +++ b/crates/burn-cubecl/src/template/mod.rs @@ -0,0 +1,5 @@ +mod base; +pub use base::*; + +mod source; +pub use source::*; diff --git a/crates/burn-cubecl/src/template/source.rs b/crates/burn-cubecl/src/template/source.rs new file mode 100644 index 0000000..b13c2f6 --- /dev/null +++ b/crates/burn-cubecl/src/template/source.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; + +/// Kernel source code abstraction allowing for templating. +/// +/// The templates can have text placeholders in the form {{ label }}. +/// They will be updated with their proper value when `generate` is called. +#[derive(Debug)] +pub struct SourceTemplate { + items: HashMap, + templates: Vec, +} + +impl SourceTemplate { + /// Create a new source template. + pub fn new(template: S) -> Self + where + S: Into, + { + Self { + items: HashMap::new(), + templates: vec![template.into()], + } + } + + /// Register the value for a placeholder item. + /// + /// # Notes + /// + /// The value can't have placeholders, since it would require recursive templating with + /// possibly circular dependencies. If you want to add a value that has some + /// placeholders, consider adding a new template to the source using + /// [add_template](SourceTemplate::add_template). The added template can be a function, and you can + /// register the function call instead. + pub fn register(mut self, name: Name, value: Value) -> Self + where + Name: Into, + Value: Into, + { + self.items.insert(name.into(), value.into()); + self + } + + /// Add a new template. + pub fn add_template(mut self, template: S) -> Self + where + S: Into, + { + self.templates.push(template.into()); + self + } + + /// Complete the template and returns the source code. + pub fn complete(mut self) -> String { + let mut source = self.templates.remove(0); + + for s in self.templates.into_iter() { + source.push_str(&s); + } + + let template = text_placeholder::Template::new(&source); + let mut context = HashMap::new(); + + for (key, value) in self.items.iter() { + context.insert(key.as_str(), value.as_str()); + } + + template.fill_with_hashmap(&context) + } +} diff --git a/crates/burn-cubecl/src/tensor/base.rs b/crates/burn-cubecl/src/tensor/base.rs new file mode 100644 index 0000000..7f835ac --- /dev/null +++ b/crates/burn-cubecl/src/tensor/base.rs @@ -0,0 +1,405 @@ +use crate::CubeRuntime; +use crate::kernel::{NumericUnaryOp, NumericUnaryOpFamily, launch_unary_numeric}; +use burn_backend::cubecl::{dtype_to_elem_type, dtype_to_storage_type}; +use burn_backend::quantization::QuantScheme; +use burn_backend::{DType, Shape, TensorMetadata}; +use burn_std::{Metadata, strides, tensor::is_contiguous}; +use cubecl::server::Handle; +use cubecl::std::tensor::TensorHandle; +use cubecl::{client::ComputeClient, std::tensor::layout::linear::LinearViewLaunch}; +use cubecl::{frontend::Numeric, std::tensor::layout::linear::LinearViewLayoutLaunch}; +use cubecl::{ + prelude::{TensorBinding, *}, + std::tensor::layout::linear::LinearViewLayout, +}; +use std::marker::PhantomData; + +use super::QParams; + +/// The basic tensor primitive struct. +pub struct CubeTensor { + /// Compute client for the [runtime](CubeRuntime). + pub client: ComputeClient, + /// The buffer where the data are stored. + pub handle: Handle, + /// The metadata of the tensor. + pub meta: Box, + /// The device of the tensor. + pub device: R::Device, + /// The datatype of the tensor. + pub dtype: DType, + /// Runtime quantization parameters, if applicable + pub qparams: Option, +} + +impl From> for TensorHandle { + fn from(val: CubeTensor) -> Self { + TensorHandle::new( + val.handle.clone(), + val.meta.shape().clone(), + val.meta.strides().clone(), + dtype_to_storage_type(val.dtype), + ) + } +} + +impl cubecl::tune::AutotuneOutput for CubeTensor { + #[cfg(feature = "autotune-checks")] + fn check_equivalence(&self, other: Self) { + use crate::ops::into_data_sync; + use burn_backend::Tolerance; + + let expected = into_data_sync::(self.clone()); + let actual = into_data_sync::(other); + expected.assert_approx_eq::(&actual, Tolerance::permissive()); + } +} + +// TODO: Needed to cleanup leaves tensor. +// +// Maybe not needed when fusion is activated, since we have a detector there. +// We could rely on basic GC strategy when not using fusion. +// +// impl Drop for CubeTensor { +// fn drop(&mut self) { +// todo!() +// } +// } + +impl core::fmt::Debug for CubeTensor +where + R: CubeRuntime, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "CubeTensor {{ shape: {:?}, device: {:?}, strides: {:?}, elem: {}, runtime: {}}}", + self.meta.shape(), + self.device, + self.meta.strides(), + self.dtype.name(), + R::name(&self.client), + )) + } +} + +impl Clone for CubeTensor +where + R: CubeRuntime, +{ + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + handle: self.handle.clone(), + meta: self.meta.clone(), + device: self.device.clone(), + dtype: self.dtype, + qparams: self.qparams.clone(), + } + } +} + +impl TensorMetadata for CubeTensor { + type Device = R::CubeDevice; + fn dtype(&self) -> DType { + self.dtype + } + + fn shape(&self) -> Shape { + self.meta.shape().clone() + } + + fn rank(&self) -> usize { + self.meta.rank() + } + + fn device(&self) -> Self::Device { + self.device.clone() + } + + fn can_mut(&self) -> bool { + self.handle.can_mut() + } +} + +impl CubeTensor +where + R: CubeRuntime, +{ + /// Create a new standard tensor + pub fn new( + client: ComputeClient, + handle: Handle, + metadata: Metadata, + device: R::Device, + dtype: DType, + ) -> Self { + CubeTensor { + client, + handle, + meta: Box::new(metadata), + device, + dtype, + qparams: None, + } + } + + /// Create a new tensor with a contiguous memory layout. + pub fn new_contiguous( + client: ComputeClient, + device: R::Device, + shape: Shape, + handle: Handle, + dtype: DType, + ) -> Self { + let ndims = shape.num_dims(); + let mut strides = strides![0; ndims]; + let mut current = 1; + + shape.iter().enumerate().rev().for_each(|(index, val)| { + strides[index] = current; + current *= val; + }); + + Self { + client, + handle, + meta: Box::new(Metadata::new(shape, strides)), + device, + dtype, + qparams: None, + } + } + + /// Change the context of the current tensor and return the newly transferred tensor. + pub fn to_client(&mut self, client: ComputeClient, device: R::Device) -> Self { + let desc = self.handle.clone().copy_descriptor( + self.meta.shape().clone(), + self.meta.strides().clone(), + self.elem_size(), + ); + let handle = self + .client + .to_client_tensor(desc, &client, dtype_to_elem_type(self.dtype)); + + Self { + client, + handle, + meta: Box::new(Metadata::new(self.shape(), self.meta.strides().clone())), + device, + dtype: self.dtype, + qparams: self.qparams.clone(), + } + } + + /// Return the reference to a tensor handle. + pub fn binding(self) -> TensorBinding { + TensorBinding { + handle: self.handle.binding(), + strides: self.meta.strides, + shape: self.meta.shape, + runtime: PhantomData, + } + } + + /// Returns the element size of this tensor + pub fn elem_size(&self) -> usize { + self.dtype.size() + } + + /// Return the reference to a tensor argument. + pub fn into_tensor_arg(self) -> TensorArg { + self.binding().into_tensor_arg() + } + + /// Return the reference to a buffer argument. + pub fn into_buffer_arg(self) -> BufferArg { + self.into_tensor_arg().into_buffer_arg() + } + + /// Returns a reference to the aliased tensor argument. + pub fn as_tensor_alias(&self, input_pos: usize) -> TensorArg { + TensorArg::Alias { + input_pos, + strides: self.meta.strides().clone(), + shape: self.meta.shape().clone(), + } + } + + /// Return a linear view of this tensor. + pub fn into_linear_view(self) -> LinearViewLaunch { + let layout = LinearViewLayoutLaunch::new(); + let buffer = self.into_tensor_arg(); + LinearViewLaunch::new_tensor::(buffer, layout) + } + + /// Return an aliased linear view of this tensor + pub fn as_linear_view_alias(&self, input_pos: usize) -> LinearViewLaunch { + let layout = LinearViewLayoutLaunch::new(); + let buffer = self.as_tensor_alias(input_pos); + LinearViewLaunch::new_tensor::(buffer, layout) + } + + /// Return a linear view broadcast to the reference tensor's shape + pub fn into_linear_view_like(self, reference: &Self) -> LinearViewLaunch { + let layout = LinearViewLayoutLaunch::from_reference_shape(reference.shape()); + let buffer = self.into_tensor_arg(); + LinearViewLaunch::new_tensor::(buffer, layout) + } + + /// Returns the address type required to index this tensor + pub fn required_address_type(&self) -> AddressType { + match self.try_scheme() { + Some(scheme) => { + let len = self.handle.size() as usize * 8 / scheme.size_bits_value(); + AddressType::from_len(len) + } + None => AddressType::from_len(self.handle.size() as usize / self.dtype.size()), + } + } + + /// Return the `QuantScheme` if present + pub fn try_scheme(&self) -> Option<&QuantScheme> { + match &self.dtype { + DType::QFloat(scheme) => Some(scheme), + _ => None, + } + } + + pub(crate) fn can_mut_broadcast(&self, rhs: &Self) -> bool { + if !self.handle.can_mut() || !self.is_nonoverlapping() { + return false; + } + let ndims = self.meta.num_dims(); + + for i in 0..ndims { + let shape_lhs = self.meta.shape()[i]; + let shape_rhs = rhs.meta.shape()[i]; + + // Output tensor will be different from the mutable tensor. + if shape_lhs < shape_rhs { + return false; + } + } + + true + } + + /// Copy the current tensor. + pub fn copy(&self) -> Self { + struct Copy; + + #[cube] + impl NumericUnaryOp for Copy { + type Options = (); + + fn execute(input: Vector, _options: &Self::Options) -> Vector { + input + } + } + + impl NumericUnaryOpFamily for Copy { + type Options = (); + type Unary = Self; + } + + let tensor = self.clone(); + launch_unary_numeric::(tensor, |_| ()) + } + + /// Check if the tensor is safe to mutate. + pub fn can_mut(&self) -> bool { + self.handle.can_mut() + } + + /// Assert that both tensors are on the same device. + pub fn assert_is_on_same_device(&self, other: &Self) { + if self.device != other.device { + panic!( + "Both tensors should be on the same device {:?} != {:?}", + self.device, other.device + ); + } + } + + /// Check if the current tensor is contiguous. + /// + /// A tensor is contiguous if the elements are stored in memory + /// if the strides in non-increasing order and the + /// strides at position k is equal to the product of the shapes + /// at all positions greater than k. However, all axes with a shape of 1 are ignored. + pub fn is_contiguous(&self) -> bool { + is_contiguous(self.meta.shape(), self.meta.strides()) + } + + /// Check if the current tensor has a contiguous backing buffer (no overlap and no empty memory + /// regions within the shape). + pub fn is_contiguous_buffer(&self) -> bool { + self.meta.shape().num_elements() * self.dtype.size() == self.handle.size() as usize + } + + /// Checks if the tensor is non-overlapping (can be safely written to). + pub fn is_nonoverlapping(&self) -> bool { + let shape = self.meta.shape(); + let strides = self.meta.strides(); + + if strides.contains(&0) { + return false; + } + let rank = self.rank(); + if rank > 1 { + let mut dims = shape.iter().zip(strides.iter()).collect::>(); + dims.sort_by_key(|(_, stride)| **stride); + + let mut max_offset = 0; + for (shape, stride) in dims.into_iter() { + if *stride <= max_offset && *shape != 1 { + return false; + } + + max_offset += (*shape - 1) * *stride; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_contiguous_non_increasing() { + assert!(is_contiguous(&[3, 1], &[1, 1])); + } + + #[test] + fn is_contiguous_basic() { + assert!(is_contiguous(&[32, 32], &[32, 1])); + } + + #[test] + fn is_contiguous_permuted() { + assert!(!is_contiguous(&[32, 32], &[1, 32])); + } + + #[test] + fn is_contiguous_slice() { + assert!(!is_contiguous(&[32, 1, 64], &[32, 64, 1])); + } + + #[test] + fn is_contiguous_4d_positive() { + assert!(is_contiguous(&[8, 256, 32, 32], &[262144, 1024, 32, 1])); + } + + #[test] + fn is_contiguous_4d_negative() { + assert!(!is_contiguous(&[256, 8, 32, 32], &[1024, 262144, 32, 1])); + } + + /// Based on a bug encountered in interpolate_1d + #[test] + fn is_contiguous_4d_unit_shape() { + assert!(!is_contiguous(&[1, 1, 1, 9], &[72, 1, 72, 8])); + } +} diff --git a/crates/burn-cubecl/src/tensor/mod.rs b/crates/burn-cubecl/src/tensor/mod.rs new file mode 100644 index 0000000..013604d --- /dev/null +++ b/crates/burn-cubecl/src/tensor/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod quantization; + +pub use base::*; +pub use quantization::*; diff --git a/crates/burn-cubecl/src/tensor/quantization.rs b/crates/burn-cubecl/src/tensor/quantization.rs new file mode 100644 index 0000000..c77b3ba --- /dev/null +++ b/crates/burn-cubecl/src/tensor/quantization.rs @@ -0,0 +1,122 @@ +use burn_backend::{DType, Shape, TensorMetadata as _, quantization::QParamTensor}; +use burn_std::{Metadata, Strides}; +use cubecl::quant::scheme::{QuantStore, QuantValue}; +use cubecl::{client::ComputeClient, server::Handle}; + +use crate::CubeRuntime; + +use super::CubeTensor; + +/// Runtime parameters for quantization. Can be used to construct a scales handle from the base +/// tensor handle. +pub type QParams = burn_backend::quantization::QParams; + +impl CubeTensor { + /// Create a new quantized tensor + pub fn new_quantized( + client: ComputeClient, + handle: Handle, + shape: Shape, + device: R::Device, + strides: Strides, + dtype: DType, + qparams: QParams, + ) -> Self { + CubeTensor { + client, + handle, + meta: Box::new(Metadata::new(shape, strides)), + device, + dtype, + qparams: Some(qparams), + } + } + + /// Returns the two tensors: (values, params) for a quantized tensor. + /// For the values, native types that aren't supported as a normal `DType` will be returned + /// as an unsigned integer tensor representing the bits. Should be reconstructed using `from_bits` + /// in kernels. + pub fn quantized_handles(&self) -> Option<(CubeTensor, CubeTensor)> { + let params = self.scales()?; + let scheme = match self.dtype { + DType::QFloat(sc) => sc, + _ => return None, + }; + let values = match scheme.store { + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S => CubeTensor { + client: self.client.clone(), + handle: self.handle.clone(), + meta: self.meta.clone(), + device: self.device.clone(), + dtype: DType::I8, + qparams: None, + }, + QuantValue::E4M3 | QuantValue::E5M2 => CubeTensor { + client: self.client.clone(), + handle: self.handle.clone(), + meta: self.meta.clone(), + device: self.device.clone(), + dtype: DType::U8, + qparams: None, + }, + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 => { + panic!("Can't store native sub-byte values") + } + }, + QuantStore::PackedU32(packed_dim) => { + let packed_dim = self.rank() - packed_dim - 1; + let mut shape = self.shape(); + shape[packed_dim] = shape[packed_dim].div_ceil(scheme.num_quants()); + + CubeTensor { + client: self.client.clone(), + handle: self.handle.clone(), + meta: Box::new(Metadata::new(shape, self.meta.strides.clone())), + device: self.device.clone(), + dtype: DType::U32, + qparams: None, + } + } + QuantStore::PackedNative(packed_dim) => match scheme.value { + QuantValue::E2M1 => { + let packed_dim = self.rank() - packed_dim - 1; + let mut shape = self.shape(); + shape[packed_dim] = shape[packed_dim].div_ceil(scheme.num_quants()); + + CubeTensor { + client: self.client.clone(), + handle: self.handle.clone(), + meta: Box::new(Metadata::new(shape, self.meta.strides.clone())), + device: self.device.clone(), + dtype: DType::U8, + qparams: None, + } + } + other => panic!("{other:?} doesn't support native packing"), + }, + }; + + Some((values, params)) + } + + /// Construct a separate tensor for the quantization scales, if present + pub fn scales(&self) -> Option> { + let qparams = self.qparams.as_ref()?; + let mut handle = self.handle.clone(); + handle.offset_start = Some(qparams.scales.offset_start as u64); + handle.offset_end = Some(qparams.scales.offset_end as u64); + + Some(CubeTensor::new( + self.client.clone(), + handle, + qparams.scales.metadata.clone(), + self.device.clone(), + qparams.scales.dtype, + )) + } +} diff --git a/crates/burn-cubecl/src/tune_key.rs b/crates/burn-cubecl/src/tune_key.rs new file mode 100644 index 0000000..d6892c7 --- /dev/null +++ b/crates/burn-cubecl/src/tune_key.rs @@ -0,0 +1,30 @@ +use crate::kernel::{ + conv::{ConvAutotuneKey, ConvTranspose2dAutotuneKey}, + reduce::SumAutotuneKey, +}; +use cubecl::tune::AutotuneKey; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] +/// Key for all autotune-enabled operations +pub enum CubeAutotuneKey { + /// Key for sum operations + Sum(SumAutotuneKey), + /// Key for convolution operations + Conv(ConvAutotuneKey), + /// Key for transpose convolution operations + ConvTranspose(ConvTranspose2dAutotuneKey), +} + +impl Display for CubeAutotuneKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CubeAutotuneKey::Sum(reduce_key) => std::fmt::Debug::fmt(&reduce_key, f), + CubeAutotuneKey::Conv(conv_key) => std::fmt::Debug::fmt(&conv_key, f), + CubeAutotuneKey::ConvTranspose(conv_key) => std::fmt::Debug::fmt(&conv_key, f), + } + } +} + +impl AutotuneKey for CubeAutotuneKey {} diff --git a/crates/burn-cuda/Cargo.toml b/crates/burn-cuda/Cargo.toml new file mode 100644 index 0000000..5591536 --- /dev/null +++ b/crates/burn-cuda/Cargo.toml @@ -0,0 +1,42 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "CUDA backend for the Burn framework" +documentation = "https://docs.rs/burn-cuda" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "gpu", "cuda"] +license.workspace = true +name = "burn-cuda" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-cuda" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "fusion", "autotune", "burn-cubecl/default", "cubecl/default"] +doc = ["burn-cubecl/doc"] +std = ["burn-cubecl/std", "cubecl/std"] +tracing = [ + "burn-backend/tracing", + "burn-cubecl/tracing", + "burn-fusion?/tracing", + "cubecl/tracing", +] + +fusion = ["burn-fusion", "burn-cubecl/fusion"] +autotune = ["burn-cubecl/autotune"] +autotune-checks = ["burn-cubecl/autotune-checks"] + +[dependencies] +burn-fusion = { workspace = true, optional = true, features = ["default"] } +burn-cubecl = { workspace = true } +burn-backend = { workspace = true, features = [ + "cubecl-cuda", +] } +cubecl = { workspace = true, features = ["cuda"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-cuda/README.md b/crates/burn-cuda/README.md new file mode 100644 index 0000000..bf23978 --- /dev/null +++ b/crates/burn-cuda/README.md @@ -0,0 +1,30 @@ +# Burn CUDA Backend + +[Burn](https://github.com/tracel-ai/burn) CUDA backend + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-cuda.svg)](https://crates.io/crates/burn-cuda) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-cuda/blob/master/README.md) + +This crate provides a CUDA backend for [Burn](https://github.com/tracel-ai/burn) using the +[cubecl](https://github.com/tracel-ai/cubecl.git) and [cudarc](https://github.com/coreylowman/cudarc.git) +crates. + +## Usage Example + +```rust +#[cfg(feature = "cuda")] +mod cuda { + use burn_autodiff::Autodiff; + use burn_cuda::{Cuda, CudaDevice}; + use mnist::training; + + pub fn run() { + let device = CudaDevice::default(); + training::run::>>(device); + } +} +``` + +## Dependencies + +Requires CUDA 12.x to be installed and on the `PATH`. \ No newline at end of file diff --git a/crates/burn-cuda/src/lib.rs b/crates/burn-cuda/src/lib.rs new file mode 100644 index 0000000..b2ae199 --- /dev/null +++ b/crates/burn-cuda/src/lib.rs @@ -0,0 +1,44 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] + +extern crate alloc; + +use burn_cubecl::CubeBackend; +pub use cubecl::cuda::CudaDevice; +use cubecl::cuda::CudaRuntime; + +#[cfg(not(feature = "fusion"))] +pub type Cuda = CubeBackend; + +#[cfg(feature = "fusion")] +pub type Cuda = burn_fusion::Fusion>; + +#[cfg(all(test, not(target_os = "macos")))] +mod tests { + use super::*; + use burn_backend::{Backend, BoolStore, DType, DeviceOps}; + + #[test] + fn should_support_dtypes() { + type B = Cuda; + let device = CudaDevice::default(); + let scheme = device.defaults().quantization.scheme; + + assert!(B::supports_dtype(&device, DType::F32)); + assert!(B::supports_dtype(&device, DType::Flex32)); + assert!(B::supports_dtype(&device, DType::F16)); + assert!(B::supports_dtype(&device, DType::BF16)); + assert!(B::supports_dtype(&device, DType::I64)); + assert!(B::supports_dtype(&device, DType::I32)); + assert!(B::supports_dtype(&device, DType::I16)); + assert!(B::supports_dtype(&device, DType::I8)); + assert!(B::supports_dtype(&device, DType::U64)); + assert!(B::supports_dtype(&device, DType::U32)); + assert!(B::supports_dtype(&device, DType::U16)); + assert!(B::supports_dtype(&device, DType::U8)); + assert!(B::supports_dtype(&device, DType::Bool(BoolStore::Native))); + assert!(B::supports_dtype(&device, DType::QFloat(scheme))); + + // Currently not registered in supported types + assert!(!B::supports_dtype(&device, DType::F64)); + } +} diff --git a/crates/burn-dataset/Cargo.toml b/crates/burn-dataset/Cargo.toml new file mode 100644 index 0000000..2cbd2f6 --- /dev/null +++ b/crates/burn-dataset/Cargo.toml @@ -0,0 +1,85 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Library with simple dataset APIs for creating ML data pipelines" +documentation = "https://docs.rs/burn-dataset" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-dataset" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-dataset" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["sqlite-bundled"] +doc = ["default"] +tracing = [ + "burn-std/tracing", +] + +audio = ["hound"] +builtin-sources = ["vision", "dep:tar", "nlp"] +fake = ["dep:fake"] +network = ["dep:burn-std"] +sqlite = ["__sqlite-shared", "dep:rusqlite"] +sqlite-bundled = ["__sqlite-shared", "rusqlite/bundled"] +vision = ["dep:flate2", "dep:globwalk", "dep:image", "network"] +nlp = ["dep:zip", "dep:encoding_rs"] +# internal +__sqlite-shared = [ + "dep:r2d2", + "dep:r2d2_sqlite", + "dep:serde_rusqlite", + "dep:image", + "dep:gix-tempfile", +] +dataframe = ["dep:polars", "dep:planus"] + +[dependencies] +burn-std = { workspace = true, optional = true, features = [ + "default", + "network", +] } +csv = { workspace = true } +derive-new = { workspace = true } +dirs = { workspace = true } +fake = { workspace = true, optional = true } +flate2 = { workspace = true, optional = true } +gix-tempfile = { workspace = true, optional = true } +globwalk = { workspace = true, optional = true } +hound = { workspace = true, optional = true } +image = { workspace = true, optional = true } +planus = { workspace = true, optional = true } +encoding_rs = { workspace = true, optional = true } +polars = { workspace = true, optional = true } +r2d2 = { workspace = true, optional = true } +r2d2_sqlite = { workspace = true, optional = true } +rand = { workspace = true, features = ["std", "sys_rng"] } +zip = { workspace = true, optional = true } +rmp-serde = { workspace = true } +rusqlite = { workspace = true, optional = true } +sanitize-filename = { workspace = true } +serde = { workspace = true, features = ["std", "derive"] } +serde_json = { workspace = true, features = ["std"] } +serde_rusqlite = { workspace = true, optional = true } +strum = { workspace = true } +tar = { workspace = true, optional = true } +tempfile = { workspace = true } +thiserror = { workspace = true } + + +[dev-dependencies] +fake = { workspace = true } +rayon = { workspace = true } +rstest = { workspace = true } + +[package.metadata.cargo-udeps.ignore] +normal = ["strum", "strum_macros"] + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-dataset/LICENSE-APACHE b/crates/burn-dataset/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-dataset/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-dataset/LICENSE-MIT b/crates/burn-dataset/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-dataset/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-dataset/README.md b/crates/burn-dataset/README.md new file mode 100644 index 0000000..742aa23 --- /dev/null +++ b/crates/burn-dataset/README.md @@ -0,0 +1,17 @@ +# Burn Dataset + +> [Burn](https://github.com/tracel-ai/burn) dataset library + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-dataset.svg)](https://crates.io/crates/burn-dataset) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-dataset/blob/master/README.md) + +The Burn Dataset library is designed to streamline your machine learning (ML) data pipeline creation +process. It offers a variety of dataset implementations, transformation functions, and data sources. + +## Feature Flags + +- `audio` - enables audio dataset (SpeechCommandsDataset). Run the following example to try it out: + + ```shell + cargo run --example speech_commands --features audio + ``` diff --git a/crates/burn-dataset/examples/hf_dataset.rs b/crates/burn-dataset/examples/hf_dataset.rs new file mode 100644 index 0000000..07e6bcf --- /dev/null +++ b/crates/burn-dataset/examples/hf_dataset.rs @@ -0,0 +1,22 @@ +use burn_dataset::HuggingfaceDatasetLoader; +use burn_dataset::SqliteDataset; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +struct MnistItemRaw { + pub _image_bytes: Vec, + pub _label: usize, +} +fn main() { + // There are some datasets, such as https://huggingface.co/datasets/ylecun/mnist/tree/main that contains a script, + // In this cases you must enable trusting remote code execution if you want to use it. + let _train_ds: SqliteDataset = HuggingfaceDatasetLoader::new("ylecun/mnist") + .with_trust_remote_code(true) + .dataset("train") + .unwrap(); + + // However not all dataset requires it https://huggingface.co/datasets/Anthropic/hh-rlhf/tree/main + let _train_ds: SqliteDataset = HuggingfaceDatasetLoader::new("Anthropic/hh-rlhf") + .dataset("train") + .unwrap(); +} diff --git a/crates/burn-dataset/examples/speech_commands.rs b/crates/burn-dataset/examples/speech_commands.rs new file mode 100644 index 0000000..7efec10 --- /dev/null +++ b/crates/burn-dataset/examples/speech_commands.rs @@ -0,0 +1,23 @@ +#[cfg(feature = "audio")] +use burn_dataset::{Dataset, audio::SpeechCommandsDataset}; + +#[cfg(feature = "audio")] +fn speech_command() { + let index: usize = 4835; + let test = SpeechCommandsDataset::test(); + let item = test.get(index).unwrap(); + + println!("Item: {:?}", item); + println!("Item Length: {:?}", item.audio_samples.len()); + println!("Label: {}", item.label); + + assert_eq!(test.len(), 4890); + assert_eq!(item.label.to_string(), "Yes"); + assert_eq!(item.sample_rate, 16000); + assert_eq!(item.audio_samples.len(), 16000); +} + +fn main() { + #[cfg(feature = "audio")] + speech_command() +} diff --git a/crates/burn-dataset/src/audio/mod.rs b/crates/burn-dataset/src/audio/mod.rs new file mode 100644 index 0000000..5d357e9 --- /dev/null +++ b/crates/burn-dataset/src/audio/mod.rs @@ -0,0 +1,3 @@ +mod speech_commands; + +pub use speech_commands::*; diff --git a/crates/burn-dataset/src/audio/speech_commands.rs b/crates/burn-dataset/src/audio/speech_commands.rs new file mode 100644 index 0000000..d291a73 --- /dev/null +++ b/crates/burn-dataset/src/audio/speech_commands.rs @@ -0,0 +1,208 @@ +use crate::{ + Dataset, HuggingfaceDatasetLoader, SqliteDataset, + transform::{Mapper, MapperDataset}, +}; + +use hound::WavReader; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumCount, FromRepr}; + +type MappedDataset = MapperDataset, ConvertSamples, SpeechItemRaw>; + +/// Enum representing speech command classes in the Speech Commands dataset. +/// Class names are based on the Speech Commands dataset from Huggingface. +/// See [speech_commands](https://huggingface.co/datasets/google/speech_commands) +/// for more information. +#[allow(missing_docs)] +#[derive(Debug, Display, Clone, Copy, FromRepr, Serialize, Deserialize, EnumCount)] +pub enum SpeechCommandClass { + // Target command words + Yes = 0, + No = 1, + Up = 2, + Down = 3, + Left = 4, + Right = 5, + On = 6, + Off = 7, + Stop = 8, + Go = 9, + Zero = 10, + One = 11, + Two = 12, + Three = 13, + Four = 14, + Five = 15, + Six = 16, + Seven = 17, + Eight = 18, + Nine = 19, + + // Non-target words that can be grouped into "Other" + Bed = 20, + Bird = 21, + Cat = 22, + Dog = 23, + Happy = 24, + House = 25, + Marvin = 26, + Sheila = 27, + Tree = 28, + Wow = 29, + + // Commands from v2 dataset, that can be grouped into "Other" + Backward = 30, + Forward = 31, + Follow = 32, + Learn = 33, + Visual = 34, + + // Background noise + Silence = 35, + + // Other miscellaneous words + Other = 36, +} + +/// Struct containing raw speech data returned from a database. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SpeechItemRaw { + /// Audio file bytes. + pub audio_bytes: Vec, + + /// Label index. + pub label: usize, + + /// Indicates if the label is unknown. + pub is_unknown: bool, +} + +/// Speech item with audio samples and label. +/// +/// The audio samples are floats in the range [-1.0, 1.0]. +/// The sample rate is in Hz. +/// The label is the class index (see [SpeechCommandClass]). +/// To convert to usize simply use `as usize`. To convert label to string use `.to_string()`. +/// +/// The original label is also stored in the `label_original` field for debugging and remapping if needed. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SpeechItem { + /// Audio samples in the range [-1.0, 1.0]. + pub audio_samples: Vec, + + /// The sample rate of the audio. + pub sample_rate: usize, + + /// The label of the audio. + pub label: SpeechCommandClass, +} + +/// Speech Commands dataset from Huggingface v0.02. +/// See [Speech Commands dataset](https://huggingface.co/datasets/google/speech_commands). +/// +/// The data is downloaded from Huggingface and stored in a SQLite database (3.0 GB). +/// The dataset contains 99,720 audio samples of 2,607 people saying 35 different words. +/// +/// NOTE: The most samples are under 1 second long but there are some with pure background noise that +/// need splitting into shorter segmants. +/// +/// The labels are 20 target words, silence and other words. +/// +/// The dataset is split into 3 parts: +/// - train: 84,848 audio files +/// - test: 4,890 audio files +/// - validation: 9,982 audio files +pub struct SpeechCommandsDataset { + dataset: MappedDataset, +} + +impl SpeechCommandsDataset { + /// Create a new dataset with the given split. + pub fn new(split: &str) -> Self { + let dataset: SqliteDataset = + HuggingfaceDatasetLoader::new("google/speech_commands") + .with_subset("v0.02") + .dataset(split) + .unwrap(); + let dataset = MapperDataset::new(dataset, ConvertSamples); + Self { dataset } + } + + /// Create a new dataset with the train split. + pub fn train() -> Self { + Self::new("train") + } + + /// Create a new dataset with the test split. + pub fn test() -> Self { + Self::new("test") + } + + /// Create a new dataset with the validation split. + pub fn validation() -> Self { + Self::new("validation") + } + + /// Returns the number of classes in the dataset + pub fn num_classes() -> usize { + SpeechCommandClass::COUNT + } +} + +impl Dataset for SpeechCommandsDataset { + fn get(&self, index: usize) -> Option { + self.dataset.get(index) + } + + fn len(&self) -> usize { + self.dataset.len() + } +} + +/// Mapper converting audio bytes into audio samples and the label to enum class. +struct ConvertSamples; + +impl ConvertSamples { + /// Convert label to enum class. + fn to_speechcommandclass(label: usize) -> SpeechCommandClass { + SpeechCommandClass::from_repr(label).unwrap() + } + + /// Convert audio bytes into samples of floats [-1.0, 1.0]. + fn to_audiosamples(bytes: &Vec) -> (Vec, usize) { + let reader = WavReader::new(bytes.as_slice()).unwrap(); + let spec = reader.spec(); + + // Maximum value of the audio samples (using bit shift to raise 2 to the power of bits per sample). + let max_value = (1 << (spec.bits_per_sample - 1)) as f32; + + // The sample rate of the audio. + let sample_rate = spec.sample_rate as usize; + + // Convert the audio samples to floats [-1.0, 1.0]. + let audio_samples: Vec = reader + .into_samples::() + .filter_map(Result::ok) + .map(|sample| sample as f32 / max_value) + .collect(); + + (audio_samples, sample_rate) + } +} + +impl Mapper for ConvertSamples { + /// Convert audio bytes into samples of floats [-1.0, 1.0] + /// and the label to enum class with the target word, other and silence classes. + fn map(&self, item: &SpeechItemRaw) -> SpeechItem { + let (audio_samples, sample_rate) = Self::to_audiosamples(&item.audio_bytes); + + // Convert the label to enum class, with the target words, other and silence classes. + let label = Self::to_speechcommandclass(item.label); + + SpeechItem { + audio_samples, + sample_rate, + label, + } + } +} diff --git a/crates/burn-dataset/src/dataset/base.rs b/crates/burn-dataset/src/dataset/base.rs new file mode 100644 index 0000000..eb53980 --- /dev/null +++ b/crates/burn-dataset/src/dataset/base.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; + +use crate::DatasetIterator; + +/// The dataset trait defines a basic collection of items with a predefined size. +pub trait Dataset: Send + Sync { + /// Gets the item at the given index. + fn get(&self, index: usize) -> Option; + + /// Gets the number of items in the dataset. + fn len(&self) -> usize; + + /// Checks if the dataset is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns an iterator over the dataset. + fn iter(&self) -> DatasetIterator<'_, I> + where + Self: Sized, + { + DatasetIterator::new(self) + } +} + +impl Dataset for Arc +where + D: Dataset, +{ + fn get(&self, index: usize) -> Option { + self.as_ref().get(index) + } + + fn len(&self) -> usize { + self.as_ref().len() + } +} + +impl Dataset for Arc> { + fn get(&self, index: usize) -> Option { + self.as_ref().get(index) + } + + fn len(&self) -> usize { + self.as_ref().len() + } +} + +impl Dataset for Box +where + D: Dataset, +{ + fn get(&self, index: usize) -> Option { + self.as_ref().get(index) + } + + fn len(&self) -> usize { + self.as_ref().len() + } +} + +impl Dataset for Box> { + fn get(&self, index: usize) -> Option { + self.as_ref().get(index) + } + + fn len(&self) -> usize { + self.as_ref().len() + } +} diff --git a/crates/burn-dataset/src/dataset/dataframe.rs b/crates/burn-dataset/src/dataset/dataframe.rs new file mode 100644 index 0000000..c1617db --- /dev/null +++ b/crates/burn-dataset/src/dataset/dataframe.rs @@ -0,0 +1,464 @@ +use std::marker::PhantomData; + +use crate::Dataset; + +use polars::frame::row::Row; +use polars::prelude::*; +use serde::de::DeserializeSeed; +use serde::{ + Deserialize, + de::{self, DeserializeOwned, Deserializer, SeqAccess, Visitor}, + forward_to_deserialize_any, +}; + +/// Error type for DataframeDataset +#[derive(thiserror::Error, Debug)] +pub enum DataframeDatasetError { + /// Error occurred during deserialization or other operations + #[error("{0}")] + Other(String), +} + +impl de::Error for DataframeDatasetError { + fn custom(msg: T) -> Self { + DataframeDatasetError::Other(msg.to_string()) + } +} + +/// Dataset implementation for Polars DataFrame +/// +/// This struct provides a way to access data from a Polars DataFrame +/// as if it were a Dataset of type I. +pub struct DataframeDataset { + df: DataFrame, + len: usize, + column_name_mapping: Vec, + phantom: PhantomData, +} + +impl DataframeDataset +where + I: Clone + Send + Sync + DeserializeOwned, +{ + /// Create a new DataframeDataset from a Polars DataFrame + /// + /// # Arguments + /// + /// * `df` - A Polars DataFrame + /// + /// # Returns + /// + /// A Result containing the new DataframeDataset or a DataframeDatasetError + pub fn new(df: DataFrame) -> Result { + let len = df.height(); + let field_names = extract_field_names::(); + + let column_name_mapping = field_names + .iter() + .map(|name| { + df.schema() + .try_get_full(name) + .map(|(index, _, _)| index) + .map_err(|err| DataframeDatasetError::Other(err.to_string())) + }) + .collect::, _>>()?; + + Ok(DataframeDataset { + df, + len, + column_name_mapping, + phantom: PhantomData, + }) + } +} + +impl Dataset for DataframeDataset +where + I: Clone + Send + Sync + DeserializeOwned, +{ + /// Get an item from the dataset at the specified index + /// + /// # Arguments + /// + /// * `index` - The index of the item to retrieve + /// + /// # Returns + /// + /// An Option containing the item if it exists, or None if it doesn't + fn get(&self, index: usize) -> Option { + let row = self.df.get_row(index).ok()?; + + let mut deserializer = RowDeserializer::new(&row, &self.column_name_mapping); + I::deserialize(&mut deserializer).ok() + } + + /// Get the length of the dataset + fn len(&self) -> usize { + self.len + } + + /// Check if the dataset is empty + fn is_empty(&self) -> bool { + self.len == 0 + } +} + +/// A deserializer for Polars DataFrame rows +struct RowDeserializer<'a> { + row: &'a Row<'a>, + column_name_mapping: &'a Vec, + index: usize, +} + +impl<'a> RowDeserializer<'a> { + /// Create a new RowDeserializer + /// + /// # Arguments + /// + /// * `row` - A reference to a Polars DataFrame row + /// * `column_name_mapping` - A reference to a vector mapping field names to column indices + fn new(row: &'a Row, column_name_mapping: &'a Vec) -> RowDeserializer<'a> { + RowDeserializer { + row, + column_name_mapping, + index: 0, + } + } +} + +impl<'de, 'a> Deserializer<'de> for &'a mut RowDeserializer<'a> { + type Error = DataframeDatasetError; + + fn deserialize_any(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + let i = self.column_name_mapping[self.index]; + + let value = &self.row.0[i]; + match value { + AnyValue::Null => visitor.visit_none(), + AnyValue::Boolean(b) => visitor.visit_bool(*b), + AnyValue::Int8(i) => visitor.visit_i8(*i), + AnyValue::Int16(i) => visitor.visit_i16(*i), + AnyValue::Int32(i) => visitor.visit_i32(*i), + AnyValue::Int64(i) => visitor.visit_i64(*i), + AnyValue::UInt8(i) => visitor.visit_u8(*i), + AnyValue::UInt16(i) => visitor.visit_u16(*i), + AnyValue::UInt32(i) => visitor.visit_u32(*i), + AnyValue::UInt64(i) => visitor.visit_u64(*i), + AnyValue::Float32(f) => visitor.visit_f32(*f), + AnyValue::Float64(f) => visitor.visit_f64(*f), + AnyValue::Date(i) => visitor.visit_i32(*i), + AnyValue::String(s) => visitor.visit_string(s.to_string()), + AnyValue::Binary(b) => { + visitor.visit_seq(de::value::SeqDeserializer::new(b.iter().copied())) + } + AnyValue::Time(t) => visitor.visit_i64(*t), + ty => Err(DataframeDatasetError::Other( + format!("Unsupported type: {ty:?}").to_string(), + )), + } + } + + fn deserialize_struct( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + visitor.visit_seq(self) + } + + forward_to_deserialize_any! { + bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map enum identifier ignored_any + } +} + +impl<'de, 'a> SeqAccess<'de> for RowDeserializer<'a> { + type Error = DataframeDatasetError; + + fn next_element_seed(&mut self, seed: T) -> Result, DataframeDatasetError> + where + T: DeserializeSeed<'de>, + { + if self.index >= self.row.0.len() { + return Ok(None); + } + let mut deserializer = RowDeserializer { + row: self.row, + column_name_mapping: self.column_name_mapping, + index: self.index, + }; + self.index += 1; + seed.deserialize(&mut deserializer).map(Some) + } +} + +struct FieldExtractor { + fields: Vec<&'static str>, +} + +impl<'de> Deserializer<'de> for &mut FieldExtractor { + type Error = de::value::Error; + + fn deserialize_any(self, _visitor: V) -> core::result::Result + where + V: Visitor<'de>, + { + Err(de::Error::custom("Field extractor")) + } + + fn deserialize_struct( + self, + _name: &'static str, + fields: &'static [&'static str], + _visitor: V, + ) -> core::result::Result + where + V: Visitor<'de>, + { + self.fields.extend_from_slice(fields); + Err(de::Error::custom("Field extractor")) + } + + forward_to_deserialize_any! { + bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes + byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map enum identifier ignored_any + } +} + +/// Extract field names from a type T that implements Deserialize +/// +/// # Returns +/// +/// A vector of field names as static string slices +fn extract_field_names<'de, T>() -> Vec<&'static str> +where + T: Deserialize<'de>, +{ + let mut extractor = FieldExtractor { fields: Vec::new() }; + let _ = T::deserialize(&mut extractor); + extractor.fields +} + +#[cfg(test)] +mod tests { + use polars::prelude::*; + use serde::Deserialize; + + use super::*; + #[derive(Clone, Debug, Deserialize, PartialEq)] + struct TestData { + int32: i32, + bool: bool, + float64: f64, + string: String, + int16: i16, + uint32: u32, + uint64: u64, + float32: f32, + int64: i64, + int8: i8, + binary: Vec, + } + + fn create_test_dataframe() -> DataFrame { + let s0 = Column::new("int32".into(), &[1i32, 2i32, 3i32]); + let s1 = Column::new("bool".into(), &[true, false, true]); + let s2 = Column::new("float64".into(), &[1.1f64, 2.2f64, 3.3f64]); + let s3 = Column::new("string".into(), &["Boo", "Boo2", "Boo3"]); + let s6 = Column::new("int16".into(), &[1i16, 2i16, 3i16]); + let s8 = Column::new("uint32".into(), &[1u32, 2u32, 3u32]); + let s9 = Column::new("uint64".into(), &[1u64, 2u64, 3u64]); + let s10 = Column::new("float32".into(), &[1.1f32, 2.2f32, 3.3f32]); + let s11 = Column::new("int64".into(), &[1i64, 2i64, 3i64]); + let s12 = Column::new("int8".into(), &[1i8, 2i8, 3i8]); + + let binary_data: Vec<&[u8]> = vec![&[1, 2, 3], &[4, 5, 6], &[7, 8, 9]]; + + let s13 = Column::new("binary".into(), binary_data); + DataFrame::new_infer_height(vec![s0, s1, s2, s3, s6, s8, s9, s10, s11, s12, s13]).unwrap() + } + + #[test] + fn test_dataframe_dataset_creation() { + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df); + assert!(dataset.is_ok()); + } + + #[test] + fn test_dataframe_dataset_length() { + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df).unwrap(); + assert_eq!(dataset.len(), 3); + assert!(!dataset.is_empty()); + } + + #[test] + fn test_dataframe_dataset_get() { + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df).unwrap(); + + let expected_items = vec![ + TestData { + int32: 1, + bool: true, + float64: 1.1, + string: "Boo".to_string(), + int16: 1, + uint32: 1, + uint64: 1, + float32: 1.1, + int64: 1, + int8: 1, + binary: vec![1, 2, 3], + }, + TestData { + int32: 2, + bool: false, + float64: 2.2, + string: "Boo2".to_string(), + int16: 2, + uint32: 2, + uint64: 2, + float32: 2.2, + int64: 2, + int8: 2, + binary: vec![4, 5, 6], + }, + TestData { + int32: 3, + bool: true, + float64: 3.3, + string: "Boo3".to_string(), + int16: 3, + uint32: 3, + uint64: 3, + float32: 3.3, + int64: 3, + int8: 3, + binary: vec![7, 8, 9], + }, + ]; + + for (index, expected_item) in expected_items.iter().enumerate() { + let item = dataset.get(index).unwrap(); + assert_eq!(&item, expected_item); + } + } + + #[test] + fn test_dataframe_dataset_out_of_bounds() { + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df).unwrap(); + assert!(dataset.get(3).is_none()); + } + + #[test] + fn test_dataframe_dataset() { + let df = create_test_dataframe(); + let dataset: DataframeDataset = DataframeDataset::new(df).unwrap(); + + assert_eq!(dataset.len(), 3); + assert!(!dataset.is_empty()); + + let item = dataset.get(1).unwrap(); + assert_eq!( + item, + TestData { + int32: 2, + bool: false, + float64: 2.2, + string: "Boo2".to_string(), + int16: 2, + uint32: 2, + uint64: 2, + float32: 2.2, + int64: 2, + int8: 2, + binary: vec![4, 5, 6], + } + ); + + let item = dataset.get(2).unwrap(); + + assert_eq!( + item, + TestData { + int32: 3, + bool: true, + float64: 3.3, + string: "Boo3".to_string(), + int16: 3, + uint32: 3, + uint64: 3, + float32: 3.3, + int64: 3, + int8: 3, + binary: vec![7, 8, 9], + } + ); + } + + #[test] + fn test_non_existing_struct_fields() { + #[derive(Clone, Debug, Deserialize, PartialEq)] + struct PartialTestData { + int32: i32, + bool: bool, + non_existent: String, + } + + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df); + + assert!(dataset.is_err()); + if let Err(e) = dataset { + assert!(matches!(e, DataframeDatasetError::Other(_))); + } + } + + #[test] + fn test_partial_table() { + #[derive(Clone, Debug, Deserialize, PartialEq)] + struct PartialTestData { + int32: i32, + bool: bool, + string: String, + } + + let df = create_test_dataframe(); + let dataset = DataframeDataset::::new(df).unwrap(); + + assert_eq!(dataset.len(), 3); + assert!(!dataset.is_empty()); + + let item = dataset.get(1).unwrap(); + assert_eq!( + item, + PartialTestData { + int32: 2, + bool: false, + string: "Boo2".to_string(), + } + ); + + let item = dataset.get(2).unwrap(); + assert_eq!( + item, + PartialTestData { + int32: 3, + bool: true, + string: "Boo3".to_string(), + } + ); + } +} diff --git a/crates/burn-dataset/src/dataset/fake.rs b/crates/burn-dataset/src/dataset/fake.rs new file mode 100644 index 0000000..c27f8cf --- /dev/null +++ b/crates/burn-dataset/src/dataset/fake.rs @@ -0,0 +1,38 @@ +use crate::{Dataset, DatasetIterator, InMemDataset}; +use fake::{Dummy, Fake, Faker}; + +/// Dataset filled with fake items generated from the [fake](fake) crate. +pub struct FakeDataset { + dataset: InMemDataset, +} + +impl> FakeDataset { + /// Create a new fake dataset with the given size. + pub fn new(size: usize) -> Self { + let mut items = Vec::with_capacity(size); + for _ in 0..size { + items.push(Faker.fake()); + } + let dataset = InMemDataset::new(items); + + Self { dataset } + } +} + +impl Dataset for FakeDataset { + fn iter(&self) -> DatasetIterator<'_, I> { + DatasetIterator::new(self) + } + + fn get(&self, index: usize) -> Option { + self.dataset.get(index) + } + + fn len(&self) -> usize { + self.dataset.len() + } + + fn is_empty(&self) -> bool { + self.dataset.is_empty() + } +} diff --git a/crates/burn-dataset/src/dataset/in_memory.rs b/crates/burn-dataset/src/dataset/in_memory.rs new file mode 100644 index 0000000..13854cd --- /dev/null +++ b/crates/burn-dataset/src/dataset/in_memory.rs @@ -0,0 +1,192 @@ +use std::{ + fs::File, + io::{BufRead, BufReader}, + path::Path, +}; + +use serde::de::DeserializeOwned; + +use crate::Dataset; + +/// Dataset where all items are stored in ram. +pub struct InMemDataset { + items: Vec, +} + +impl InMemDataset { + /// Creates a new in memory dataset from the given items. + pub fn new(items: Vec) -> Self { + InMemDataset { items } + } +} + +impl Dataset for InMemDataset +where + I: Clone + Send + Sync, +{ + fn get(&self, index: usize) -> Option { + self.items.get(index).cloned() + } + fn len(&self) -> usize { + self.items.len() + } +} + +impl InMemDataset +where + I: Clone + DeserializeOwned, +{ + /// Create from a dataset. All items are loaded in memory. + pub fn from_dataset(dataset: &impl Dataset) -> Self { + let items: Vec = dataset.iter().collect(); + Self::new(items) + } + + /// Create from a json rows file (one json per line). + /// + /// [Supported field types](https://docs.rs/serde_json/latest/serde_json/value/enum.Value.html) + pub fn from_json_rows>(path: P) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut items = Vec::new(); + + for line in reader.lines() { + let item = serde_json::from_str(line.unwrap().as_str()).unwrap(); + items.push(item); + } + + let dataset = Self::new(items); + + Ok(dataset) + } + + /// Create from a csv file. + /// + /// The provided `csv::ReaderBuilder` can be configured to fit your csv format. + /// + /// The supported field types are: String, integer, float, and bool. + /// + /// See: + /// - [Reading with Serde](https://docs.rs/csv/latest/csv/tutorial/index.html#reading-with-serde) + /// - [Delimiters, quotes and variable length records](https://docs.rs/csv/latest/csv/tutorial/index.html#delimiters-quotes-and-variable-length-records) + pub fn from_csv>( + path: P, + builder: &csv::ReaderBuilder, + ) -> Result { + let mut rdr = builder.from_path(path)?; + + let mut items = Vec::new(); + + for result in rdr.deserialize() { + let item: I = result?; + items.push(item); + } + + let dataset = Self::new(items); + + Ok(dataset) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::{SqliteDataset, test_data}; + + use rstest::{fixture, rstest}; + use serde::{Deserialize, Serialize}; + + const DB_FILE: &str = "tests/data/sqlite-dataset.db"; + const JSON_FILE: &str = "tests/data/dataset.json"; + const CSV_FILE: &str = "tests/data/dataset.csv"; + const CSV_FMT_FILE: &str = "tests/data/dataset-fmt.csv"; + + type SqlDs = SqliteDataset; + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct Sample { + column_str: String, + column_bytes: Vec, + column_int: i64, + column_bool: bool, + column_float: f64, + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct SampleCsv { + column_str: String, + column_int: i64, + column_bool: bool, + column_float: f64, + } + + #[fixture] + fn train_dataset() -> SqlDs { + SqliteDataset::from_db_file(DB_FILE, "train").unwrap() + } + + #[rstest] + pub fn from_dataset(train_dataset: SqlDs) { + let dataset = InMemDataset::from_dataset(&train_dataset); + + let non_existing_record_index: usize = 10; + let record_index: usize = 0; + + assert_eq!(train_dataset.get(non_existing_record_index), None); + assert_eq!(dataset.get(record_index).unwrap().column_str, "HI1"); + } + + #[test] + pub fn from_json_rows() { + let dataset = InMemDataset::::from_json_rows(JSON_FILE).unwrap(); + + let non_existing_record_index: usize = 10; + let record_index: usize = 1; + + assert_eq!(dataset.get(non_existing_record_index), None); + assert_eq!(dataset.get(record_index).unwrap().column_str, "HI2"); + assert!(!dataset.get(record_index).unwrap().column_bool); + } + + #[test] + pub fn from_csv_rows() { + let rdr = csv::ReaderBuilder::new(); + let dataset = InMemDataset::::from_csv(CSV_FILE, &rdr).unwrap(); + + let non_existing_record_index: usize = 10; + let record_index: usize = 1; + + assert_eq!(dataset.get(non_existing_record_index), None); + assert_eq!(dataset.get(record_index).unwrap().column_str, "HI2"); + assert_eq!(dataset.get(record_index).unwrap().column_int, 1); + assert!(!dataset.get(record_index).unwrap().column_bool); + assert_eq!(dataset.get(record_index).unwrap().column_float, 1.0); + } + + #[test] + pub fn from_csv_rows_fmt() { + let mut rdr = csv::ReaderBuilder::new(); + let rdr = rdr.delimiter(b' ').has_headers(false); + let dataset = InMemDataset::::from_csv(CSV_FMT_FILE, rdr).unwrap(); + + let non_existing_record_index: usize = 10; + let record_index: usize = 1; + + assert_eq!(dataset.get(non_existing_record_index), None); + assert_eq!(dataset.get(record_index).unwrap().column_str, "HI2"); + assert_eq!(dataset.get(record_index).unwrap().column_int, 1); + assert!(!dataset.get(record_index).unwrap().column_bool); + assert_eq!(dataset.get(record_index).unwrap().column_float, 1.0); + } + + #[test] + pub fn given_in_memory_dataset_when_iterate_should_iterate_though_all_items() { + let items_original = test_data::string_items(); + let dataset = InMemDataset::new(items_original.clone()); + + let items: Vec = dataset.iter().collect(); + + assert_eq!(items_original, items); + } +} diff --git a/crates/burn-dataset/src/dataset/iterator.rs b/crates/burn-dataset/src/dataset/iterator.rs new file mode 100644 index 0000000..cbea476 --- /dev/null +++ b/crates/burn-dataset/src/dataset/iterator.rs @@ -0,0 +1,31 @@ +use crate::dataset::Dataset; +use std::iter::Iterator; + +/// Dataset iterator. +pub struct DatasetIterator<'a, I> { + current: usize, + dataset: &'a dyn Dataset, +} + +impl<'a, I> DatasetIterator<'a, I> { + /// Creates a new dataset iterator. + pub fn new(dataset: &'a D) -> Self + where + D: Dataset, + { + DatasetIterator { + current: 0, + dataset, + } + } +} + +impl Iterator for DatasetIterator<'_, I> { + type Item = I; + + fn next(&mut self) -> Option { + let item = self.dataset.get(self.current); + self.current += 1; + item + } +} diff --git a/crates/burn-dataset/src/dataset/mod.rs b/crates/burn-dataset/src/dataset/mod.rs new file mode 100644 index 0000000..9d9061e --- /dev/null +++ b/crates/burn-dataset/src/dataset/mod.rs @@ -0,0 +1,25 @@ +mod base; +mod in_memory; +mod iterator; + +pub use base::*; +pub use in_memory::*; +pub use iterator::*; + +#[cfg(any(test, feature = "fake"))] +mod fake; + +#[cfg(any(test, feature = "fake"))] +pub use self::fake::*; + +#[cfg(feature = "dataframe")] +mod dataframe; + +#[cfg(feature = "dataframe")] +pub use dataframe::*; + +#[cfg(any(feature = "sqlite", feature = "sqlite-bundled"))] +pub use sqlite::*; + +#[cfg(any(feature = "sqlite", feature = "sqlite-bundled"))] +mod sqlite; diff --git a/crates/burn-dataset/src/dataset/sqlite.rs b/crates/burn-dataset/src/dataset/sqlite.rs new file mode 100644 index 0000000..a3cca83 --- /dev/null +++ b/crates/burn-dataset/src/dataset/sqlite.rs @@ -0,0 +1,851 @@ +use std::{ + collections::HashSet, + fs, io, + marker::PhantomData, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use crate::Dataset; + +use gix_tempfile::{ + AutoRemove, ContainingDirectory, Handle, + handle::{Writable, persist}, +}; +use r2d2::{Pool, PooledConnection}; +use r2d2_sqlite::{ + SqliteConnectionManager, + rusqlite::{OpenFlags, OptionalExtension}, +}; +use sanitize_filename::sanitize; +use serde::{Serialize, de::DeserializeOwned}; +use serde_rusqlite::{columns_from_statement, from_row_with_columns}; + +/// Result type for the sqlite dataset. +pub type Result = core::result::Result; + +/// Sqlite dataset error. +#[derive(thiserror::Error, Debug)] +pub enum SqliteDatasetError { + /// IO related error. + #[error("IO error: {0}")] + Io(#[from] io::Error), + + /// Sql related error. + #[error("Sql error: {0}")] + Sql(#[from] serde_rusqlite::rusqlite::Error), + + /// Serde related error. + #[error("Serde error: {0}")] + Serde(#[from] rmp_serde::encode::Error), + + /// The database file already exists error. + #[error("Overwrite flag is set to false and the database file already exists: {0}")] + FileExists(PathBuf), + + /// Error when creating the connection pool. + #[error("Failed to create connection pool: {0}")] + ConnectionPool(#[from] r2d2::Error), + + /// Error when persisting the temporary database file. + #[error("Could not persist the temporary database file: {0}")] + PersistDbFile(#[from] persist::Error), + + /// Any other error. + #[error("{0}")] + Other(&'static str), +} + +impl From<&'static str> for SqliteDatasetError { + fn from(s: &'static str) -> Self { + SqliteDatasetError::Other(s) + } +} + +/// This struct represents a dataset where all items are stored in an SQLite database. +/// Each instance of this struct corresponds to a specific table within the SQLite database, +/// and allows for interaction with the data stored in the table in a structured and typed manner. +/// +/// The SQLite database must contain a table with the same name as the `split` field. This table should +/// have a primary key column named `row_id`, which is used to index the rows in the table. The `row_id` +/// should start at 1, while the corresponding dataset `index` should start at 0, i.e., `row_id` = `index` + 1. +/// +/// Table columns can be represented in two ways: +/// +/// 1. The table can have a column for each field in the `I` struct. In this case, the column names in the table +/// should match the field names of the `I` struct. The field names can be a subset of column names and +/// can be in any order. +/// +/// For the supported field types, refer to: +/// - [Serialization field types](https://docs.rs/serde_rusqlite/latest/serde_rusqlite) +/// - [SQLite data types](https://www.sqlite.org/datatype3.html) +/// +/// 2. The fields in the `I` struct can be serialized into a single column `item` in the table. In this case, the table +/// should have a single column named `item` of type `BLOB`. This is useful when the `I` struct contains complex fields +/// that cannot be mapped to a SQLite type, such as nested structs, vectors, etc. The serialization is done using +/// [MessagePack](https://msgpack.org/). +/// +/// Note: The code automatically figures out which of the above two cases is applicable, and uses the appropriate +/// method to read the data from the table. +#[derive(Debug)] +pub struct SqliteDataset { + db_file: PathBuf, + split: String, + conn_pool: Pool, + columns: Vec, + len: usize, + select_statement: String, + row_serialized: bool, + phantom: PhantomData, +} + +impl SqliteDataset { + /// Initializes a `SqliteDataset` from a SQLite database file and a split name. + pub fn from_db_file>(db_file: P, split: &str) -> Result { + // Create a connection pool + let conn_pool = create_conn_pool(&db_file, false)?; + + // Determine how the table is stored + let row_serialized = Self::check_if_row_serialized(&conn_pool, split)?; + + // Create a select statement and save it + let select_statement = if row_serialized { + format!("select item from {split} where row_id = ?") + } else { + format!("select * from {split} where row_id = ?") + }; + + // Save the column names and the number of rows + let (columns, len) = fetch_columns_and_len(&conn_pool, &select_statement, split)?; + + Ok(SqliteDataset { + db_file: db_file.as_ref().to_path_buf(), + split: split.to_string(), + conn_pool, + columns, + len, + select_statement, + row_serialized, + phantom: PhantomData, + }) + } + + /// Returns true if table has two columns: row_id (integer) and item (blob). + /// + /// This is used to determine if the table is row serialized or not. + fn check_if_row_serialized( + conn_pool: &Pool, + split: &str, + ) -> Result { + // This struct is used to store the column name and type + struct Column { + name: String, + ty: String, + } + + const COLUMN_NAME: usize = 1; + const COLUMN_TYPE: usize = 2; + + let sql_statement = format!("PRAGMA table_info({split})"); + + let conn = conn_pool.get()?; + + let mut stmt = conn.prepare(sql_statement.as_str())?; + let column_iter = stmt.query_map([], |row| { + Ok(Column { + name: row + .get::(COLUMN_NAME) + .unwrap() + .to_lowercase(), + ty: row + .get::(COLUMN_TYPE) + .unwrap() + .to_lowercase(), + }) + })?; + + let mut columns: Vec = vec![]; + + for column in column_iter { + columns.push(column?); + } + + if columns.len() != 2 { + Ok(false) + } else { + // Check if the column names and types match the expected values + Ok(columns[0].name == "row_id" + && columns[0].ty == "integer" + && columns[1].name == "item" + && columns[1].ty == "blob") + } + } + + /// Get the database file name. + pub fn db_file(&self) -> PathBuf { + self.db_file.clone() + } + + /// Get the split name. + pub fn split(&self) -> &str { + self.split.as_str() + } +} + +impl Dataset for SqliteDataset +where + I: Clone + Send + Sync + DeserializeOwned, +{ + /// Get an item from the dataset. + fn get(&self, index: usize) -> Option { + // Row ids start with 1 (one) and index starts with 0 (zero) + let row_id = index + 1; + + // Get a connection from the pool + let connection = self.conn_pool.get().unwrap(); + let mut statement = connection.prepare(self.select_statement.as_str()).unwrap(); + + if self.row_serialized { + // Fetch with a single column `item` and deserialize it with MessagePack + statement + .query_row([row_id], |row| { + // Deserialize item (blob) with MessagePack (rmp-serde) + Ok( + rmp_serde::from_slice::(row.get_ref(0).unwrap().as_blob().unwrap()) + .unwrap(), + ) + }) + .optional() //Converts Error (not found) to None + .unwrap() + } else { + // Fetch a row with multiple columns and deserialize it serde_rusqlite + statement + .query_row([row_id], |row| { + // Deserialize the row with serde_rusqlite + Ok(from_row_with_columns::(row, &self.columns).unwrap()) + }) + .optional() //Converts Error (not found) to None + .unwrap() + } + } + + /// Return the number of rows in the dataset. + fn len(&self) -> usize { + self.len + } +} + +/// Fetch the column names and the number of rows from the database. +fn fetch_columns_and_len( + conn_pool: &Pool, + select_statement: &str, + split: &str, +) -> Result<(Vec, usize)> { + // Save the column names + let connection = conn_pool.get()?; + let statement = connection.prepare(select_statement)?; + let columns = columns_from_statement(&statement); + + // Count the number of rows and save it as len + // + // NOTE: Using coalesce(max(row_id), 0) instead of count(*) because count(*) is super slow for large tables. + // The coalesce(max(row_id), 0) returns 0 if the table is empty, otherwise it returns the max row_id, + // which corresponds to the number of rows in the table. + // The main assumption, which always holds true, is that the row_id is always increasing and there are no gaps. + // This is true for all the datasets that we are using, otherwise row_id will not correspond to the index. + let mut statement = + connection.prepare(format!("select coalesce(max(row_id), 0) from {split}").as_str())?; + + let len = statement.query_row([], |row| { + let len: usize = row.get(0)?; + Ok(len) + })?; + Ok((columns, len)) +} + +/// Helper function to create a connection pool +fn create_conn_pool>( + db_file: P, + write: bool, +) -> Result> { + let sqlite_flags = if write { + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE + } else { + OpenFlags::SQLITE_OPEN_READ_ONLY + }; + + let manager = SqliteConnectionManager::file(db_file).with_flags(sqlite_flags); + Pool::new(manager).map_err(SqliteDatasetError::ConnectionPool) +} + +/// The `SqliteDatasetStorage` struct represents a SQLite database for storing datasets. +/// It consists of an optional name, a database file path, and a base directory for storage. +#[derive(Clone, Debug)] +pub struct SqliteDatasetStorage { + name: Option, + db_file: Option, + base_dir: Option, +} + +impl SqliteDatasetStorage { + /// Creates a new instance of `SqliteDatasetStorage` using a dataset name. + /// + /// # Arguments + /// + /// * `name` - A string slice that holds the name of the dataset. + pub fn from_name(name: &str) -> Self { + SqliteDatasetStorage { + name: Some(name.to_string()), + db_file: None, + base_dir: None, + } + } + + /// Creates a new instance of `SqliteDatasetStorage` using a database file path. + /// + /// # Arguments + /// + /// * `db_file` - A reference to the Path that represents the database file path. + pub fn from_file>(db_file: P) -> Self { + SqliteDatasetStorage { + name: None, + db_file: Some(db_file.as_ref().to_path_buf()), + base_dir: None, + } + } + + /// Sets the base directory for storing the dataset. + /// + /// # Arguments + /// + /// * `base_dir` - A string slice that represents the base directory. + pub fn with_base_dir>(mut self, base_dir: P) -> Self { + self.base_dir = Some(base_dir.as_ref().to_path_buf()); + self + } + + /// Checks if the database file exists in the given path. + /// + /// # Returns + /// + /// * A boolean value indicating whether the file exists or not. + pub fn exists(&self) -> bool { + self.db_file().exists() + } + + /// Fetches the database file path. + /// + /// # Returns + /// + /// * A `PathBuf` instance representing the file path. + pub fn db_file(&self) -> PathBuf { + match &self.db_file { + Some(db_file) => db_file.clone(), + None => { + let name = sanitize(self.name.as_ref().expect("Name is not set")); + Self::base_dir(self.base_dir.to_owned()).join(format!("{name}.db")) + } + } + } + + /// Determines the base directory for storing the dataset. + /// + /// # Arguments + /// + /// * `base_dir` - An `Option` that may contain a `PathBuf` instance representing the base directory. + /// + /// # Returns + /// + /// * A `PathBuf` instance representing the base directory. + pub fn base_dir(base_dir: Option) -> PathBuf { + match base_dir { + Some(base_dir) => base_dir, + None => dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset"), + } + } + + /// Provides a writer instance for the SQLite dataset. + /// + /// # Arguments + /// + /// * `overwrite` - A boolean indicating if the existing database file should be overwritten. + /// + /// # Returns + /// + /// * A `Result` which is `Ok` if the writer could be created, `Err` otherwise. + pub fn writer(&self, overwrite: bool) -> Result> + where + I: Clone + Send + Sync + Serialize + DeserializeOwned, + { + SqliteDatasetWriter::new(self.db_file(), overwrite) + } + + /// Provides a reader instance for the SQLite dataset. + /// + /// # Arguments + /// + /// * `split` - A string slice that defines the data split for reading (e.g., "train", "test"). + /// + /// # Returns + /// + /// * A `Result` which is `Ok` if the reader could be created, `Err` otherwise. + pub fn reader(&self, split: &str) -> Result> + where + I: Clone + Send + Sync + Serialize + DeserializeOwned, + { + if !self.exists() { + panic!("The database file does not exist"); + } + + SqliteDataset::from_db_file(self.db_file(), split) + } +} + +/// This `SqliteDatasetWriter` struct is a SQLite database writer dedicated to storing datasets. +/// It retains the current writer's state and its database connection. +/// +/// Being thread-safe, this writer can be concurrently used across multiple threads. +/// +/// Typical applications include: +/// +/// - Generation of a new dataset +/// - Storage of preprocessed data or metadata +/// - Enlargement of a dataset's item count post preprocessing +#[derive(Debug)] +pub struct SqliteDatasetWriter { + db_file: PathBuf, + db_file_tmp: Option>, + splits: Arc>>, + overwrite: bool, + conn_pool: Option>, + is_completed: Arc>, + phantom: PhantomData, +} + +impl SqliteDatasetWriter +where + I: Clone + Send + Sync + Serialize + DeserializeOwned, +{ + /// Creates a new instance of `SqliteDatasetWriter`. + /// + /// # Arguments + /// + /// * `db_file` - A reference to the Path that represents the database file path. + /// * `overwrite` - A boolean indicating if the existing database file should be overwritten. + /// + /// # Returns + /// + /// * A `Result` which is `Ok` if the writer could be created, `Err` otherwise. + pub fn new>(db_file: P, overwrite: bool) -> Result { + let writer = Self { + db_file: db_file.as_ref().to_path_buf(), + db_file_tmp: None, + splits: Arc::new(RwLock::new(HashSet::new())), + overwrite, + conn_pool: None, + is_completed: Arc::new(RwLock::new(false)), + phantom: PhantomData, + }; + + writer.init() + } + + /// Initializes the dataset writer by creating the database file, tables, and connection pool. + /// + /// # Returns + /// + /// * A `Result` which is `Ok` if the writer could be initialized, `Err` otherwise. + fn init(mut self) -> Result { + // Remove the db file if it already exists + if self.db_file.exists() { + if self.overwrite { + fs::remove_file(&self.db_file)?; + } else { + return Err(SqliteDatasetError::FileExists(self.db_file)); + } + } + + // Create the database file directory if it does not exist + let db_file_dir = self + .db_file + .parent() + .ok_or("Unable to get parent directory")?; + + if !db_file_dir.exists() { + fs::create_dir_all(db_file_dir)?; + } + + // Create a temp database file name as {base_dir}/{name}.db.tmp + let mut db_file_tmp = self.db_file.clone(); + db_file_tmp.set_extension("db.tmp"); + if db_file_tmp.exists() { + fs::remove_file(&db_file_tmp)?; + } + + // Create the temp database file and wrap it with a gix_tempfile::Handle + // This will ensure that the temp file is deleted when the writer is dropped + // or when process exits with SIGINT or SIGTERM (tempfile crate does not do this) + gix_tempfile::signal::setup(Default::default()); + self.db_file_tmp = Some(gix_tempfile::writable_at( + &db_file_tmp, + ContainingDirectory::Exists, + AutoRemove::Tempfile, + )?); + + let conn_pool = create_conn_pool(db_file_tmp, true)?; + self.conn_pool = Some(conn_pool); + + Ok(self) + } + + /// Serializes and writes an item to the database. The item is written to the table for the + /// specified split. If the table does not exist, it is created. If the table exists, the item + /// is appended to the table. The serialization is done using the [MessagePack](https://msgpack.org/) + /// + /// # Arguments + /// + /// * `split` - A string slice that defines the data split for writing (e.g., "train", "test"). + /// * `item` - A reference to the item to be written to the database. + /// + /// # Returns + /// + /// * A `Result` containing the index of the inserted row if successful, an error otherwise. + pub fn write(&self, split: &str, item: &I) -> Result { + // Acquire the read lock (wont't block other reads) + let is_completed = self.is_completed.read().unwrap(); + + // If the writer is completed, return an error + if *is_completed { + return Err(SqliteDatasetError::Other( + "Cannot save to a completed dataset writer", + )); + } + + // create the table for the split if it does not exist + if !self.splits.read().unwrap().contains(split) { + self.create_table(split)?; + } + + // Get a connection from the pool + let conn_pool = self.conn_pool.as_ref().unwrap(); + let conn = conn_pool.get()?; + + // Serialize the item using MessagePack + let serialized_item = rmp_serde::to_vec(item)?; + + // Turn off the synchronous and journal mode for speed up + // We are sacrificing durability for speed but it's okay because + // we always recreate the dataset if it is not completed. + pragma_update_with_error_handling(&conn, "synchronous", "OFF")?; + pragma_update_with_error_handling(&conn, "journal_mode", "OFF")?; + + // Insert the serialized item into the database + let insert_statement = format!("insert into {split} (item) values (?)"); + conn.execute(insert_statement.as_str(), [serialized_item])?; + + // Get the primary key of the last inserted row and convert to index (row_id-1) + let index = (conn.last_insert_rowid() - 1) as usize; + + Ok(index) + } + + /// Marks the dataset as completed and persists the temporary database file. + pub fn set_completed(&mut self) -> Result<()> { + let mut is_completed = self.is_completed.write().unwrap(); + + // Force close the connection pool + // This is required on Windows platform where the connection pool prevents + // from persisting the db by renaming the temp file. + if let Some(pool) = self.conn_pool.take() { + std::mem::drop(pool); + } + + // Rename the database file from tmp to db + let _file_result = self + .db_file_tmp + .take() // take ownership of the temporary file and set to None + .unwrap() // unwrap the temporary file + .persist(&self.db_file)? + .ok_or("Unable to persist the database file")?; + + *is_completed = true; + Ok(()) + } + + /// Creates table for the data split. + /// + /// Note: call is idempotent and thread-safe. + /// + /// # Arguments + /// + /// * `split` - A string slice that defines the data split for the table (e.g., "train", "test"). + /// + /// # Returns + /// + /// * A `Result` which is `Ok` if the table could be created, `Err` otherwise. + /// + /// TODO (@antimora): add support creating a table with columns corresponding to the item fields + fn create_table(&self, split: &str) -> Result<()> { + // Check if the split already exists + if self.splits.read().unwrap().contains(split) { + return Ok(()); + } + + let conn_pool = self.conn_pool.as_ref().unwrap(); + let connection = conn_pool.get()?; + let create_table_statement = format!( + "create table if not exists {split} (row_id integer primary key autoincrement not \ + null, item blob not null)" + ); + + connection.execute(create_table_statement.as_str(), [])?; + + // Add the split to the splits + self.splits.write().unwrap().insert(split.to_string()); + + Ok(()) + } +} + +/// Runs a pragma update and ignores the `ExecuteReturnedResults` error. +/// +/// Sometimes ExecuteReturnedResults is returned when running a pragma update. This is not an error +/// and can be ignored. This function runs the pragma update and ignores the error if it is +/// `ExecuteReturnedResults`. +fn pragma_update_with_error_handling( + conn: &PooledConnection, + setting: &str, + value: &str, +) -> Result<()> { + let result = conn.pragma_update(None, setting, value); + if let Err(error) = result + && error != rusqlite::Error::ExecuteReturnedResults + { + return Err(SqliteDatasetError::Sql(error)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use rayon::prelude::*; + use rstest::{fixture, rstest}; + use serde::{Deserialize, Serialize}; + use tempfile::{NamedTempFile, TempDir, tempdir}; + + use super::*; + + type SqlDs = SqliteDataset; + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct Sample { + column_str: String, + column_bytes: Vec, + column_int: i64, + column_bool: bool, + column_float: f64, + } + + #[fixture] + fn train_dataset() -> SqlDs { + SqliteDataset::::from_db_file("tests/data/sqlite-dataset.db", "train").unwrap() + } + + #[rstest] + pub fn len(train_dataset: SqlDs) { + assert_eq!(train_dataset.len(), 2); + } + + #[rstest] + pub fn get_some(train_dataset: SqlDs) { + let item = train_dataset.get(0).unwrap(); + assert_eq!(item.column_str, "HI1"); + assert_eq!(item.column_bytes, vec![55, 231, 159]); + assert_eq!(item.column_int, 1); + assert!(item.column_bool); + assert_eq!(item.column_float, 1.0); + } + + #[rstest] + pub fn get_none(train_dataset: SqlDs) { + assert_eq!(train_dataset.get(10), None); + } + + #[rstest] + pub fn multi_thread(train_dataset: SqlDs) { + let indices: Vec = vec![0, 1, 1, 3, 4, 5, 6, 0, 8, 1]; + let results: Vec> = + indices.par_iter().map(|&i| train_dataset.get(i)).collect(); + + let mut match_count = 0; + for (_index, result) in indices.iter().zip(results.iter()) { + if let Some(_val) = result { + match_count += 1 + } + } + + assert_eq!(match_count, 5); + } + + #[test] + fn sqlite_dataset_storage() { + // Test with non-existing file + let storage = SqliteDatasetStorage::from_file("non-existing.db"); + assert!(!storage.exists()); + + // Test with non-existing name + let storage = SqliteDatasetStorage::from_name("non-existing.db"); + assert!(!storage.exists()); + + // Test with existing file + let storage = SqliteDatasetStorage::from_file("tests/data/sqlite-dataset.db"); + assert!(storage.exists()); + let result = storage.reader::("train"); + assert!(result.is_ok()); + let train = result.unwrap(); + assert_eq!(train.len(), 2); + + // Test get writer + let temp_file = NamedTempFile::new().unwrap(); + let storage = SqliteDatasetStorage::from_file(temp_file.path()); + assert!(storage.exists()); + let result = storage.writer::(true); + assert!(result.is_ok()); + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct Complex { + column_str: String, + column_bytes: Vec, + column_int: i64, + column_bool: bool, + column_float: f64, + column_complex: Vec>>, + } + + /// Create a temporary directory. + #[fixture] + fn tmp_dir() -> TempDir { + // Create a TempDir. This object will be automatically + // deleted when it goes out of scope. + tempdir().unwrap() + } + type Writer = SqliteDatasetWriter; + + /// Create a SqliteDatasetWriter with a temporary directory. + /// Make sure to return the temporary directory so that it is not deleted. + #[fixture] + fn writer_fixture(tmp_dir: TempDir) -> (Writer, TempDir) { + let temp_dir_str = tmp_dir.path(); + let storage = SqliteDatasetStorage::from_name("preprocessed").with_base_dir(temp_dir_str); + let overwrite = true; + let result = storage.writer::(overwrite); + assert!(result.is_ok()); + let writer = result.unwrap(); + (writer, tmp_dir) + } + + #[test] + fn test_new() { + // Test that the constructor works with overwrite = true + let test_path = NamedTempFile::new().unwrap(); + let _writer = SqliteDatasetWriter::::new(&test_path, true).unwrap(); + assert!(!test_path.path().exists()); + + // Test that the constructor works with overwrite = false + let test_path = NamedTempFile::new().unwrap(); + let result = SqliteDatasetWriter::::new(&test_path, false); + assert!(result.is_err()); + + // Test that the constructor works with no existing file + let temp = NamedTempFile::new().unwrap(); + let test_path = temp.path().to_path_buf(); + assert!(temp.close().is_ok()); + assert!(!test_path.exists()); + let _writer = SqliteDatasetWriter::::new(&test_path, true).unwrap(); + assert!(!test_path.exists()); + } + + #[rstest] + pub fn sqlite_writer_write(writer_fixture: (Writer, TempDir)) { + // Get the dataset_saver from the fixture and tmp_dir (will be deleted after scope) + let (writer, _tmp_dir) = writer_fixture; + + assert!(writer.overwrite); + assert!(!writer.db_file.exists()); + + let new_item = Complex { + column_str: "HI1".to_string(), + column_bytes: vec![1_u8, 2, 3], + column_int: 0, + column_bool: true, + column_float: 1.0, + column_complex: vec![vec![vec![[1, 23_u8, 3]]]], + }; + + let index = writer.write("train", &new_item).unwrap(); + assert_eq!(index, 0); + + let mut writer = writer; + + writer.set_completed().expect("Failed to set completed"); + + assert!(writer.db_file.exists()); + assert!(writer.db_file_tmp.is_none()); + + let result = writer.write("train", &new_item); + + // Should fail because the writer is completed + assert!(result.is_err()); + + let dataset = SqliteDataset::::from_db_file(writer.db_file, "train").unwrap(); + + let fetched_item = dataset.get(0).unwrap(); + assert_eq!(fetched_item, new_item); + assert_eq!(dataset.len(), 1); + } + + #[rstest] + pub fn sqlite_writer_write_multi_thread(writer_fixture: (Writer, TempDir)) { + // Get the dataset_saver from the fixture and tmp_dir (will be deleted after scope) + let (writer, _tmp_dir) = writer_fixture; + + let writer = Arc::new(writer); + let record_count = 20; + + let splits = ["train", "test"]; + + (0..record_count).into_par_iter().for_each(|index: i64| { + let thread_id: std::thread::ThreadId = std::thread::current().id(); + let sample = Complex { + column_str: format!("test_{thread_id:?}_{index}"), + column_bytes: vec![index as u8, 2, 3], + column_int: index, + column_bool: true, + column_float: 1.0, + column_complex: vec![vec![vec![[1, index as u8, 3]]]], + }; + + // half for train and half for test + let split = splits[index as usize % 2]; + + let _index = writer.write(split, &sample).unwrap(); + }); + + let mut writer = Arc::try_unwrap(writer).unwrap(); + + writer + .set_completed() + .expect("Should set completed successfully"); + + let train = + SqliteDataset::::from_db_file(writer.db_file.clone(), "train").unwrap(); + let test = SqliteDataset::::from_db_file(writer.db_file, "test").unwrap(); + + assert_eq!(train.len(), record_count as usize / 2); + assert_eq!(test.len(), record_count as usize / 2); + } +} diff --git a/crates/burn-dataset/src/lib.rs b/crates/burn-dataset/src/lib.rs new file mode 100644 index 0000000..822a19d --- /dev/null +++ b/crates/burn-dataset/src/lib.rs @@ -0,0 +1,52 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! # Burn Dataset +//! +//! Burn Dataset is a library for creating and loading datasets. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; +extern crate dirs; + +/// Sources for datasets. +pub mod source; + +pub mod transform; + +/// Audio datasets. +#[cfg(feature = "audio")] +pub mod audio; + +/// Vision datasets. +#[cfg(feature = "vision")] +pub mod vision; + +/// Natural language processing datasets. +#[cfg(feature = "nlp")] +pub mod nlp; + +/// Network dataset utilities. +#[cfg(feature = "network")] +pub mod network { + pub use burn_std::network::*; +} + +mod dataset; +pub use dataset::*; +#[cfg(any(feature = "sqlite", feature = "sqlite-bundled"))] +pub use source::huggingface::downloader::*; + +#[cfg(test)] +mod test_data { + pub fn string_items() -> Vec { + vec![ + "1 Item".to_string(), + "2 Items".to_string(), + "3 Items".to_string(), + "4 Items".to_string(), + ] + } +} diff --git a/crates/burn-dataset/src/nlp/ag_news.rs b/crates/burn-dataset/src/nlp/ag_news.rs new file mode 100644 index 0000000..dc151f1 --- /dev/null +++ b/crates/burn-dataset/src/nlp/ag_news.rs @@ -0,0 +1,211 @@ +//! AG NEWS Dataset Module +//! +//! This module provides functionality for loading the AG NEWS text classification dataset. +//! AG NEWS is a collection of news articles categorized into different topics. +//! The dataset is split into training (120,000 articles) and test (7,600 articles) sets. +//! +//! ## Dataset Details +//! - **Classes**: 4 categories (World, Sports, Business, Sci/Tech) +//! - **AG NEWS mirror**: [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L83) +//! - **License**: [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE) +//! +//! ## Usage Example +//! ```rust +//! use burn_dataset::nlp::AgNewsDataset; +//! +//! // Create an AG NEWS dataset accessor +//! let dataset = AgNewsDataset::new(); +//! +//! // Access training and test sets +//! let train_dataset = dataset.train(); +//! let test_dataset = dataset.test(); +//! ``` + +use std::{path::PathBuf, sync::Mutex}; + +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use tar::Archive; + +use crate::InMemDataset; +use crate::network::downloader; + +/// AG NEWS mirror from [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L83). +/// Licensed under the [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE). +const AG_NEWS_URL: &str = "https://s3.amazonaws.com/fast-ai-nlp/ag_news_csv.tgz"; + +/// Represents an item in the AG NEWS dataset. +/// +/// Each item contains a label, title, and content of a news article. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct AgNewsItem { + /// The category label of the news article. + pub label: String, + /// The title of the news article. + pub title: String, + /// The content/body of the news article. + pub content: String, +} + +/// AG NEWS dataset accessor. +/// +/// This struct provides convenient access to the AG NEWS text classification dataset. +/// It automatically downloads (if not already downloaded), extracts, and loads the datasets. +/// +/// The dataset is split into training (120,000 articles) and test (7,600 articles) sets. +pub struct AgNewsDataset { + agnews_dir: PathBuf, +} + +/// AG NEWS dataset download lock. +/// +/// This lock ensures that only one thread downloads the AG NEWS dataset at a time. +static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(()); + +impl AgNewsDataset { + /// Creates a new AG NEWS dataset accessor. + /// + /// This will download and extract the dataset if it's not already present. + pub fn new() -> Self { + Self { + agnews_dir: Self::download(), + } + } + + /// Downloads and extracts the AG NEWS dataset. + /// + /// # Returns + /// Path to the directory containing the extracted dataset. + fn download() -> PathBuf { + // Acquire the lock. This will block if another thread already holds the lock. + let _lock = DOWNLOAD_LOCK.lock().unwrap(); + + // Dataset files are stored in the burn-dataset cache directory + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset"); + + // AG NEWS dataset directory + let agnews_dir = cache_dir.join("ag_news_csv"); + + // AG NEWS dataset url + let url = AG_NEWS_URL; + + // AG NEWS dataset archive filename + let filename = "ag_news_csv.tgz"; + + // Check for already downloaded content + if !agnews_dir.exists() { + // Download gzip file + let bytes = downloader::download_file_as_bytes(url, filename); + + // Decode gzip file content and unpack archive + let gz_buffer = GzDecoder::new(&bytes[..]); + let mut archive = Archive::new(gz_buffer); + archive.unpack(cache_dir).unwrap(); + } + + agnews_dir + } + + /// Parses a CSV file into an in-memory dataset. + /// + /// # Arguments + /// * `file_path` - Path to the CSV file to parse. + /// + /// # Returns + /// An `InMemDataset` containing the parsed data. + fn parse_csv(file_path: &str) -> InMemDataset { + let mut rdr = csv::ReaderBuilder::new(); + let rdr = rdr.has_headers(false); + + InMemDataset::from_csv(file_path, &rdr).expect("Failed to parse CSV file") + } + + /// Gets the training dataset. + /// + /// # Returns + /// An `InMemDataset` instance containing 120,000 training articles. + pub fn train(&self) -> InMemDataset { + let file_path = self.agnews_dir.join("train.csv"); + Self::parse_csv(file_path.to_str().unwrap()) + } + + /// Gets the test dataset. + /// + /// # Returns + /// An `InMemDataset` instance containing 7,600 test articles. + pub fn test(&self) -> InMemDataset { + let file_path = self.agnews_dir.join("test.csv"); + Self::parse_csv(file_path.to_str().unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dataset; + + // AG NEWS dataset train and test dataset lengths + const TRAIN_DATASET_LEN: usize = 120000; + const TEST_DATASET_LEN: usize = 7600; + + #[test] + fn test_agnews_download() { + let agnews_dir = AgNewsDataset::download(); + assert!(agnews_dir.exists()); + } + + #[test] + fn test_agnews_len() { + let agnews = AgNewsDataset::new(); + let train_dataset = agnews.train(); + let test_dataset = agnews.test(); + assert_eq!(train_dataset.len(), TRAIN_DATASET_LEN); + assert_eq!(test_dataset.len(), TEST_DATASET_LEN); + } + + #[test] + fn test_agnews_first_and_last_item() { + let agnews = AgNewsDataset::new(); + + // Test the first and the last item in training dataset + let train_dataset = agnews.train(); + let first_item = train_dataset.get(0).unwrap(); + let last_item = train_dataset.get(train_dataset.len() - 1).unwrap(); + assert!(compare_item(&first_item, &("3".to_string(), "Wall St. Bears Claw Back Into the Black (Reuters)".to_string(), "Reuters - Short-sellers, Wall Street's dwindling\\band of ultra-cynics, are seeing green again.".to_string()))); + assert!(compare_item( + &last_item, + &( + "2".to_string(), + "Nets get Carter from Raptors".to_string(), + "INDIANAPOLIS -- All-Star Vince Carter was traded by the Toronto Raptors to the New Jersey Nets for Alonzo Mourning, Eric Williams, Aaron Williams, and a pair of first-round draft picks yesterday.".to_string() + ) + )); + + // Test the first and the last item in test dataset + let test_dataset = agnews.test(); + let first_item = test_dataset.get(0).unwrap(); + let last_item = test_dataset.get(test_dataset.len() - 1).unwrap(); + assert!(compare_item( + &first_item, + &( + "3".to_string(), + "Fears for T N pension after talks".to_string(), + "Unions representing workers at Turner Newall say they are 'disappointed' after talks with stricken parent firm Federal Mogul.".to_string() + ) + )); + assert!(compare_item( + &last_item, + &( + "3".to_string(), + "EBay gets into rentals".to_string(), + "EBay plans to buy the apartment and home rental service Rent.com for \\$415 million, adding to its already exhaustive breadth of offerings.".to_string() + ) + )); + } + + fn compare_item(item: &AgNewsItem, target: &(String, String, String)) -> bool { + item.label == target.0 && item.title == target.1 && item.content == target.2 + } +} diff --git a/crates/burn-dataset/src/nlp/mod.rs b/crates/burn-dataset/src/nlp/mod.rs new file mode 100644 index 0000000..52574b3 --- /dev/null +++ b/crates/burn-dataset/src/nlp/mod.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "builtin-sources")] +mod ag_news; +mod text_folder; + +#[cfg(feature = "builtin-sources")] +pub use ag_news::*; +pub use text_folder::*; diff --git a/crates/burn-dataset/src/nlp/text_folder.rs b/crates/burn-dataset/src/nlp/text_folder.rs new file mode 100644 index 0000000..9df8850 --- /dev/null +++ b/crates/burn-dataset/src/nlp/text_folder.rs @@ -0,0 +1,421 @@ +use crate::transform::{Mapper, MapperDataset}; +use crate::{Dataset, InMemDataset}; + +use encoding_rs::{GB18030, GBK, UTF_8, UTF_16BE, UTF_16LE}; +use globwalk::{self, DirEntry}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +const SUPPORTED_FILES: [&str; 1] = ["txt"]; + +/// Text data type. +#[derive(Debug, Clone, PartialEq)] +pub struct TextData { + /// The text content. + pub text: String, + + /// Original text source. + pub text_path: String, +} + +/// Text dataset item. +#[derive(Debug, Clone, PartialEq)] +pub struct TextDatasetItem { + /// Text content. + pub text: TextData, + + /// Label for the text. + pub label: usize, +} + +/// Raw text dataset item. +#[derive(Debug, Clone)] +struct TextDatasetItemRaw { + /// Text path. + text_path: PathBuf, + + /// Text label. + label: String, +} + +impl TextDatasetItemRaw { + fn new>(text_path: P, label: String) -> TextDatasetItemRaw { + TextDatasetItemRaw { + text_path: text_path.as_ref().to_path_buf(), + label, + } + } +} + +struct PathToTextDatasetItem { + classes: HashMap, +} + +/// Parse the text content from file with auto-detection of encoding. +fn parse_text_content(text_path: &PathBuf) -> String { + // Read raw bytes from disk + let mut file = fs::File::open(text_path).unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + + // Try to detect encoding and decode text + // First try UTF-8 with BOM + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) && bytes.len() >= 3 { + let (result, _, had_errors) = UTF_8.decode(&bytes[3..]); + if !had_errors { + return result.into_owned(); + } + } + + // Try UTF-8 without BOM + let (result, _, had_errors) = UTF_8.decode(&bytes); + if !had_errors { + return result.into_owned(); + } + + // Try UTF-16LE with BOM + if bytes.starts_with(&[0xFF, 0xFE]) && bytes.len() >= 2 { + let (result, had_errors) = UTF_16LE.decode_with_bom_removal(&bytes[2..]); + if !had_errors { + return result.into_owned(); + } + } + + // Try UTF-16BE with BOM + if bytes.starts_with(&[0xFE, 0xFF]) && bytes.len() >= 2 { + let (result, had_errors) = UTF_16BE.decode_with_bom_removal(&bytes[2..]); + if !had_errors { + return result.into_owned(); + } + } + + // Try GB18030 encoding + let (result, _, had_errors) = GB18030.decode(&bytes); + if !had_errors { + return result.into_owned(); + } + + // Try GBK encoding + let (result, _, had_errors) = GBK.decode(&bytes); + if !had_errors { + return result.into_owned(); + } + + // Default fallback - use from_utf8_lossy for any remaining cases + String::from_utf8_lossy(&bytes).to_string() +} + +impl Mapper for PathToTextDatasetItem { + /// Convert a raw text dataset item (path-like) to text content with a target label. + fn map(&self, item: &TextDatasetItemRaw) -> TextDatasetItem { + let label = *self.classes.get(&item.label).unwrap(); + + // Load text from disk + let text_content = parse_text_content(&item.text_path); + + let text_data = TextData { + text: text_content, + text_path: item.text_path.display().to_string(), + }; + + TextDatasetItem { + text: text_data, + label, + } + } +} + +/// Error type for [TextFolderDataset](TextFolderDataset). +#[derive(Error, Debug)] +pub enum TextLoaderError { + /// Unknown error. + #[error("unknown: `{0}`")] + Unknown(String), + + /// I/O operation error. + #[error("I/O error: `{0}`")] + IOError(String), + + /// Invalid file error. + #[error("Invalid file extension: `{0}`")] + InvalidFileExtensionError(String), + + /// Encoding error. + #[error("Encoding error: `{0}`")] + EncodingError(String), +} + +type TextDatasetMapper = + MapperDataset, PathToTextDatasetItem, TextDatasetItemRaw>; + +/// A generic dataset to load texts from disk. +pub struct TextFolderDataset { + dataset: TextDatasetMapper, +} + +impl Dataset for TextFolderDataset { + fn get(&self, index: usize) -> Option { + self.dataset.get(index) + } + + fn len(&self) -> usize { + self.dataset.len() + } +} + +impl TextFolderDataset { + /// Create a text classification dataset from the root folder. + /// + /// # Arguments + /// + /// * `root` - Dataset root folder. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification>(root: P) -> Result { + // New dataset containing any of the supported file types + TextFolderDataset::new_classification_with(root, &SUPPORTED_FILES) + } + + /// Create a text classification dataset from the root folder. + /// The included texts are filtered based on the provided extensions. + /// + /// # Arguments + /// + /// * `root` - Dataset root folder. + /// * `extensions` - List of allowed extensions. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification_with(root: P, extensions: &[S]) -> Result + where + P: AsRef, + S: AsRef, + { + // Glob all texts with extensions + let walker = globwalk::GlobWalkerBuilder::from_patterns( + root.as_ref(), + &[format!( + "*.{{{}}}", // "*.{ext1,ext2,ext3} + extensions + .iter() + .map(Self::check_extension) + .collect::, _>>()? + .join(",") + )], + ) + .follow_links(true) + .sort_by(|p1: &DirEntry, p2: &DirEntry| p1.path().cmp(p2.path())) // order by path + .build() + .map_err(|err| TextLoaderError::Unknown(format!("{err:?}")))? + .filter_map(Result::ok); + + // Get all dataset items + let mut items = Vec::new(); + let mut classes = HashSet::new(); + for text in walker { + let text_path = text.path(); + + // Label name is represented by the parent folder name + let label = text_path + .parent() + .ok_or_else(|| { + TextLoaderError::IOError("Could not resolve text parent folder".to_string()) + })? + .file_name() + .ok_or_else(|| { + TextLoaderError::IOError( + "Could not resolve text parent folder name".to_string(), + ) + })? + .to_string_lossy() + .into_owned(); + + classes.insert(label.clone()); + + items.push(TextDatasetItemRaw::new(text_path, label)) + } + + // Sort class names + let mut classes = classes.into_iter().collect::>(); + classes.sort(); + + Self::with_items(items, &classes) + } + + /// Create a text classification dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - List of dataset items, each item represented by a tuple `(text path, label)`. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification_with_items, S: AsRef>( + items: Vec<(P, String)>, + classes: &[S], + ) -> Result { + // Parse items and check valid text extension types + let items = items + .into_iter() + .map(|(path, label)| { + // Map text path and label + let path = path.as_ref(); + let label = label; + + Self::check_extension(&path.extension().unwrap().to_str().unwrap())?; + + Ok(TextDatasetItemRaw::new(path, label)) + }) + .collect::, _>>()?; + + Self::with_items(items, classes) + } + + /// Create a text dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - Raw dataset items. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + fn with_items>( + items: Vec, + classes: &[S], + ) -> Result { + // NOTE: right now we don't need to validate the supported text files since + // the method is private. We assume it's already validated. + let dataset = InMemDataset::new(items); + + // Class names to index map + let classes = classes.iter().map(|c| c.as_ref()).collect::>(); + let classes_map: HashMap<_, _> = classes + .into_iter() + .enumerate() + .map(|(idx, cls)| (cls.to_string(), idx)) + .collect(); + + let mapper = PathToTextDatasetItem { + classes: classes_map, + }; + let dataset = MapperDataset::new(dataset, mapper); + + Ok(Self { dataset }) + } + + /// Check if extension is supported. + fn check_extension>(extension: &S) -> Result { + let extension = extension.as_ref(); + if !SUPPORTED_FILES.contains(&extension) { + Err(TextLoaderError::InvalidFileExtensionError( + extension.to_string(), + )) + } else { + Ok(extension.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + const TEXT_ROOT: &str = "tests/data/text_folder"; + + #[test] + fn test_text_folder_dataset() { + let dataset = TextFolderDataset::new_classification(TEXT_ROOT).unwrap(); + + // Dataset should have 4 elements (2 positive + 2 negative) + assert_eq!(dataset.len(), 4); + assert_eq!(dataset.get(4), None); + + // Check that we have items from both classes + let mut found_positive = false; + let mut found_negative = false; + + for i in 0..dataset.len() { + let item = dataset.get(i).unwrap(); + if item.label == 0 { + found_negative = true; + // Check that the text content is loaded correctly + assert!(!item.text.text.is_empty()); + assert!(item.text.text_path.contains("negative")); + } else if item.label == 1 { + found_positive = true; + // Check that the text content is loaded correctly + assert!(!item.text.text.is_empty()); + assert!(item.text.text_path.contains("positive")); + } + } + + // Verify we found items from both classes + assert!(found_positive); + assert!(found_negative); + } + + #[test] + fn test_text_folder_dataset_with_invalid_extension() { + // Try to create a dataset with an unsupported extension + let result = TextFolderDataset::new_classification_with(TEXT_ROOT, &["invalid"]); + assert!(result.is_err()); + } + + #[test] + fn test_text_folder_dataset_with_items() { + // Create the dataset + let root = Path::new(TEXT_ROOT); + let items = vec![ + ( + root.join("positive").join("sample1.txt"), + "positive".to_string(), + ), + ( + root.join("negative").join("sample2.txt"), + "negative".to_string(), + ), + ]; + let classes = vec!["positive", "negative"]; + let dataset = TextFolderDataset::new_classification_with_items(items, &classes).unwrap(); + + // Dataset should have 2 elements + assert_eq!(dataset.len(), 2); + assert_eq!(dataset.get(2), None); + + // Get items + let item0 = dataset.get(0).unwrap(); + let item1 = dataset.get(1).unwrap(); + + // Check item0 + assert!(compare_item( + &item0, + &( + "This is a positive text sample for testing the text folder dataset functionality." + .to_string(), + 0 + ) + )); + + // Check item1 + assert_eq!(item1.label, 1); + assert!(item1.text.text_path.contains("negative")); + assert!(compare_item( + &item1, + &( + "另一个负面文本样本,用以确保数据集能够处理同一类别中的多个文件。".to_string(), + 1 + ) + )); + } + + fn compare_item(item: &TextDatasetItem, target: &(String, usize)) -> bool { + item.text.text == target.0 && item.label == target.1 + } +} diff --git a/crates/burn-dataset/src/source/huggingface/downloader.rs b/crates/burn-dataset/src/source/huggingface/downloader.rs new file mode 100644 index 0000000..a97bd46 --- /dev/null +++ b/crates/burn-dataset/src/source/huggingface/downloader.rs @@ -0,0 +1,367 @@ +use std::fs::{self, create_dir_all}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::{SqliteDataset, SqliteDatasetError, SqliteDatasetStorage}; + +use sanitize_filename::sanitize; +use serde::de::DeserializeOwned; +use thiserror::Error; + +const PYTHON_SOURCE: &str = include_str!("importer.py"); +#[cfg(not(target_os = "windows"))] +const VENV_BIN_PYTHON: &str = "bin/python3"; +#[cfg(target_os = "windows")] +const VENV_BIN_PYTHON: &str = "Scripts\\python"; + +/// Error type for [HuggingfaceDatasetLoader](HuggingfaceDatasetLoader). +#[derive(Error, Debug)] +pub enum ImporterError { + /// Unknown error. + #[error("unknown: `{0}`")] + Unknown(String), + + /// Fail to download python dependencies. + #[error("fail to download python dependencies: `{0}`")] + FailToDownloadPythonDependencies(String), + + /// Fail to create sqlite dataset. + #[error("sqlite dataset: `{0}`")] + SqliteDataset(#[from] SqliteDatasetError), + + /// python3 is not installed. + #[error("python3 is not installed")] + PythonNotInstalled, + + /// venv environment is not initialized. + #[error("venv environment is not initialized")] + VenvNotInitialized, +} + +/// Load a dataset from [huggingface datasets](https://huggingface.co/datasets). +/// +/// The dataset with all splits is stored in a single sqlite database (see [SqliteDataset](SqliteDataset)). +/// +/// # Example +/// ```no_run +/// use burn_dataset::HuggingfaceDatasetLoader; +/// use burn_dataset::SqliteDataset; +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Deserialize, Debug, Clone)] +/// struct MnistItemRaw { +/// pub image_bytes: Vec, +/// pub label: usize, +/// } +/// +/// let train_ds:SqliteDataset = HuggingfaceDatasetLoader::new("mnist") +/// .dataset("train") +/// .unwrap(); +/// ``` +/// +/// # Note +/// This loader relies on the [`datasets` library by HuggingFace](https://huggingface.co/docs/datasets/index) +/// to download datasets. This is a Python library, so you must have an existing Python installation. +pub struct HuggingfaceDatasetLoader { + name: String, + subset: Option, + base_dir: Option, + huggingface_token: Option, + huggingface_cache_dir: Option, + huggingface_data_dir: Option, + trust_remote_code: bool, + use_python_venv: bool, +} + +impl HuggingfaceDatasetLoader { + /// Create a huggingface dataset loader. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + subset: None, + base_dir: None, + huggingface_token: None, + huggingface_cache_dir: None, + huggingface_data_dir: None, + trust_remote_code: false, + use_python_venv: true, + } + } + + /// Create a huggingface dataset loader for a subset of the dataset. + /// + /// The subset name must be one of the subsets listed in the dataset page. + /// + /// If no subset names are listed, then do not use this method. + pub fn with_subset(mut self, subset: &str) -> Self { + self.subset = Some(subset.to_string()); + self + } + + /// Specify a base directory to store the dataset. + /// + /// If not specified, the dataset will be stored in the system cache directory under `burn-dataset`. + pub fn with_base_dir(mut self, base_dir: &str) -> Self { + self.base_dir = Some(base_dir.into()); + self + } + + /// Specify a huggingface token to download datasets behind authentication. + /// + /// You can get a token from [tokens settings](https://huggingface.co/settings/tokens) + pub fn with_huggingface_token(mut self, huggingface_token: &str) -> Self { + self.huggingface_token = Some(huggingface_token.to_string()); + self + } + + /// Specify a huggingface cache directory to store the downloaded datasets. + /// + /// If not specified, the dataset will be stored in the system cache directory under `huggingface/datasets`. + pub fn with_huggingface_cache_dir(mut self, huggingface_cache_dir: &str) -> Self { + self.huggingface_cache_dir = Some(huggingface_cache_dir.to_string()); + self + } + + /// Specify a relative path to a subset of a dataset. This is used in some datasets for the + /// manual steps of dataset download process. + /// + /// Unless you've encountered a ManualDownloadError + /// when loading your dataset you probably don't have to worry about this setting. + pub fn with_huggingface_data_dir(mut self, huggingface_data_dir: &str) -> Self { + self.huggingface_data_dir = Some(huggingface_data_dir.to_string()); + self + } + + /// Specify whether or not to trust remote code. + /// + /// If not specified, trust remote code is set to true. + pub fn with_trust_remote_code(mut self, trust_remote_code: bool) -> Self { + self.trust_remote_code = trust_remote_code; + self + } + + /// Specify whether or not to use the burn-dataset Python + /// virtualenv for running the importer script. If false, local + /// `python3`'s environment is used. + /// + /// If not specified, the virtualenv is used. + pub fn with_use_python_venv(mut self, use_python_venv: bool) -> Self { + self.use_python_venv = use_python_venv; + self + } + + /// Load the dataset. + pub fn dataset( + self, + split: &str, + ) -> Result, ImporterError> { + let db_file = self.db_file()?; + let dataset = SqliteDataset::from_db_file(db_file, split)?; + Ok(dataset) + } + + /// Get the path to the sqlite database file. + /// + /// If the database file does not exist, it will be downloaded and imported. + pub fn db_file(self) -> Result { + // determine (and create if needed) the base directory + let base_dir = SqliteDatasetStorage::base_dir(self.base_dir); + + if !base_dir.exists() { + create_dir_all(&base_dir).expect("Failed to create base directory"); + } + + //sanitize the name and subset + let name = sanitize(self.name.as_str()); + + // create the db file path + let db_file_name = if let Some(subset) = self.subset.clone() { + format!("{name}-{}.db", sanitize(subset.as_str())) + } else { + format!("{name}.db") + }; + + let db_file = base_dir.join(db_file_name); + + // import the dataset if needed + if !Path::new(&db_file).exists() { + import( + self.name, + self.subset, + db_file.clone(), + base_dir, + self.huggingface_token, + self.huggingface_cache_dir, + self.huggingface_data_dir, + self.trust_remote_code, + self.use_python_venv, + )?; + } + + Ok(db_file) + } +} + +/// Import a dataset from huggingface. The transformed dataset is stored as sqlite database. +#[allow(clippy::too_many_arguments)] +fn import( + name: String, + subset: Option, + base_file: PathBuf, + base_dir: PathBuf, + huggingface_token: Option, + huggingface_cache_dir: Option, + huggingface_data_dir: Option, + trust_remote_code: bool, + use_python_venv: bool, +) -> Result<(), ImporterError> { + let python_path = if use_python_venv { + install_python_deps(&base_dir)? + } else { + get_python_name()?.into() + }; + + let mut command = Command::new(python_path); + + command.arg(importer_script_path(&base_dir)); + + command.arg("--name"); + command.arg(name); + + command.arg("--file"); + command.arg(base_file); + + if let Some(subset) = subset { + command.arg("--subset"); + command.arg(subset); + } + + if let Some(huggingface_token) = huggingface_token { + command.arg("--token"); + command.arg(huggingface_token); + } + + if let Some(huggingface_cache_dir) = huggingface_cache_dir { + command.arg("--cache_dir"); + command.arg(huggingface_cache_dir); + } + if let Some(huggingface_data_dir) = huggingface_data_dir { + command.arg("--data_dir"); + command.arg(huggingface_data_dir); + } + if trust_remote_code { + command.arg("--trust_remote_code"); + command.arg("True"); + } + let mut handle = command.spawn().unwrap(); + + let exit_status = handle + .wait() + .map_err(|err| ImporterError::Unknown(format!("{err:?}")))?; + + if !exit_status.success() { + return Err(ImporterError::Unknown(format!("{exit_status}"))); + } + + Ok(()) +} + +/// check python --version output is `Python 3.x.x` +fn check_python_version_is_3(python: &str) -> bool { + let output = Command::new(python).arg("--version").output(); + match output { + Ok(output) => { + if output.status.success() { + let version_string = String::from_utf8_lossy(&output.stdout); + if let Some(index) = version_string.find(' ') { + let version = &version_string[index + 1..]; + version.starts_with("3.") + } else { + false + } + } else { + false + } + } + Err(_error) => false, + } +} + +/// get python3 name `python` `python3` or `py` +fn get_python_name() -> Result<&'static str, ImporterError> { + let python_name_list = ["python3", "python", "py"]; + for python_name in python_name_list.iter() { + if check_python_version_is_3(python_name) { + return Ok(python_name); + } + } + Err(ImporterError::PythonNotInstalled) +} + +fn importer_script_path(base_dir: &Path) -> PathBuf { + let path_file = base_dir.join("importer.py"); + + fs::write(&path_file, PYTHON_SOURCE).expect("Write python dataset downloader"); + path_file +} + +fn install_python_deps(base_dir: &Path) -> Result { + let venv_dir = base_dir.join("venv"); + let venv_python_path = venv_dir.join(VENV_BIN_PYTHON); + // If the venv environment is already initialized, skip the initialization. + if !check_python_version_is_3(venv_python_path.to_str().unwrap()) { + let python_name = get_python_name()?; + let mut command = Command::new(python_name); + command.args([ + "-m", + "venv", + venv_dir + .as_os_str() + .to_str() + .expect("Path utf8 conversion should not fail"), + ]); + + // Spawn the venv creation process and wait for it to complete. + let mut handle = command.spawn().unwrap(); + + handle.wait().map_err(|err| { + ImporterError::FailToDownloadPythonDependencies(format!(" error: {err}")) + })?; + // Check if the venv environment can be used successfully." + if !check_python_version_is_3(venv_python_path.to_str().unwrap()) { + return Err(ImporterError::VenvNotInitialized); + } + } + + let mut ensurepip_cmd = Command::new(&venv_python_path); + ensurepip_cmd.args(["-m", "ensurepip", "--upgrade"]); + let status = ensurepip_cmd.status().map_err(|err| { + ImporterError::FailToDownloadPythonDependencies(format!("failed to run ensurepip: {err}")) + })?; + if !status.success() { + return Err(ImporterError::FailToDownloadPythonDependencies( + "ensurepip failed to initialize pip".to_string(), + )); + } + + let mut command = Command::new(&venv_python_path); + command.args([ + "-m", + "pip", + "--quiet", + "install", + "pyarrow", + "sqlalchemy", + "Pillow", + "soundfile", + "datasets", + ]); + + // Spawn the pip install process and wait for it to complete. + let mut handle = command.spawn().unwrap(); + handle + .wait() + .map_err(|err| ImporterError::FailToDownloadPythonDependencies(format!(" error: {err}")))?; + + Ok(venv_python_path) +} diff --git a/crates/burn-dataset/src/source/huggingface/importer.py b/crates/burn-dataset/src/source/huggingface/importer.py new file mode 100644 index 0000000..c3ce923 --- /dev/null +++ b/crates/burn-dataset/src/source/huggingface/importer.py @@ -0,0 +1,207 @@ +import argparse + +import pyarrow as pa +from datasets import Audio, Image, load_dataset +from sqlalchemy import Column, Integer, Table, create_engine, event, inspect +from sqlalchemy.types import LargeBinary + + +def download_and_export( + name: str, + subset: str, + db_file: str, + token: str, + cache_dir: str, + data_dir: str | None, + trust_remote_code: bool, +): + """ + Download a dataset from using HuggingFace dataset and export it to a sqlite database. + """ + + # TODO For media columns (Image and Audio) sometimes when decode=False, + # bytes can be none {'bytes': None, 'path': 'healthy_train.265.jpg'} + # We should handle this case, but unfortunately we did not come across this case yet to test it. + + print("*" * 80) + print("Starting huggingface dataset download and export") + print(f"Dataset Name: {name}") + print(f"Subset Name: {subset}") + print(f"Sqlite database file: {db_file}") + print(f"Trust remote code: {trust_remote_code}") + if cache_dir is None: + print(f"Custom cache dir: {cache_dir}") + print("*" * 80) + + # Load the dataset + dataset_all = load_dataset( + name, + subset, + cache_dir=cache_dir, + data_dir=data_dir, + use_auth_token=token, + trust_remote_code=trust_remote_code, + ) + + print(f"Dataset: {dataset_all}") + + # Create the database connection descriptor (sqlite) + engine = create_engine(f"sqlite:///{db_file}") + + # Set some sqlite pragmas to speed up the database + event.listen(engine, "connect", set_sqlite_pragma) + + # Add an row_id column to each table as primary key (datasets does not have API for this) + event.listen(Table, "before_create", add_pk_column) + + # Export each split in the dataset + for key in dataset_all.keys(): + dataset = dataset_all[key] + + # Disable decoding for audio and image fields + dataset = disable_decoding(dataset) + + # Flatten the dataset + dataset = dataset.flatten() + + # Rename columns to remove dots from the names + dataset = rename_columns(dataset) + + print(f"Saving dataset: {name} - {key}") + print(f"Dataset features: {dataset.features}") + + # Save the dataset to a sqlite database + dataset.to_sql( + key, # table name + engine, + # don't save the index, use row_id instead (index is not unique) + index=False, + dtype=blob_columns(dataset), # save binary columns as blob + ) + + # Print the schema of the database so we can reference the columns in the rust code + print_table_info(engine) + + +def disable_decoding(dataset): + """ + Disable decoding for audio and image fields. The fields will be saved as raw file bytes. + """ + for k, v in dataset.features.items(): + if isinstance(v, Audio): + dataset = dataset.cast_column(k, Audio(decode=False)) + elif isinstance(v, Image): + dataset = dataset.cast_column(k, Image(decode=False)) + + return dataset + + +def rename_columns(dataset): + """ + Rename columns to remove dots from the names. Dots appear in the column names because of the flattening. + Dots are not allowed in column names in rust and sql (unless quoted). So we replace them with underscores. + This way there is an easy name mapping between the rust and sql columns. + """ + + for name in dataset.features.keys(): + if "." in name: + dataset = dataset.rename_column(name, name.replace(".", "_")) + + return dataset + + +def blob_columns(dataset): + """ + Make sure all binary columns are blob columns in the database because + `to_sql` exports binary values as TEXT instead of BLOB. + """ + type_mapping = {} + for name, value in dataset.features.items(): + if value.pa_type is not None and pa.types.is_binary(value.pa_type): + type_mapping[name] = LargeBinary + return type_mapping + + +def set_sqlite_pragma(dbapi_connection, connection_record): + """ + Set some sqlite pragmas to speed up the database + """ + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA synchronous = OFF") + cursor.execute("PRAGMA journal_mode = OFF") + cursor.close() + + +def add_pk_column(target, connection, **kw): + """ + Add an id column to each table. + """ + target.append_column(Column("row_id", Integer, primary_key=True)) + + +def print_table_info(engine): + """ + Print the schema of the database so we can reference the columns in the rust code + """ + print(f"Printing table schema for sqlite3 db ({engine})") + inspector = inspect(engine) + for table_name in inspector.get_table_names(): + print(f"Table: {table_name}") + for column in inspector.get_columns(table_name): + print(f"Column: {column['name']} - {column['type']}") + print("") + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Huggingface datasets downloader to use with burn-dataset" + ) + parser.add_argument( + "--name", type=str, help="Name of the dataset to download", required=True + ) + parser.add_argument( + "--file", type=str, help="Base file name where the data is saved", required=True + ) + parser.add_argument( + "--subset", type=str, help="Subset name", required=False, default=None + ) + parser.add_argument( + "--token", + type=str, + help="HuggingFace authentication token", + required=False, + default=None, + ) + parser.add_argument( + "--cache_dir", type=str, help="Cache directory", required=False, default=None + ) + parser.add_argument( + "--data_dir", type=str, help="Relative path to a specific subset of your dataset", required=False, default=None + ) + parser.add_argument( + "--trust_remote_code", + type=bool, + help="Trust remote code", + required=False, + default=None, + ) + + return parser.parse_args() + + +def run(): + args = parse_args() + + download_and_export( + args.name, + args.subset, + args.file, + args.token, + args.data_dir, + args.cache_dir, + args.trust_remote_code, + ) + + +if __name__ == "__main__": + run() diff --git a/crates/burn-dataset/src/source/huggingface/mod.rs b/crates/burn-dataset/src/source/huggingface/mod.rs new file mode 100644 index 0000000..a6ccec9 --- /dev/null +++ b/crates/burn-dataset/src/source/huggingface/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod downloader; + +pub use downloader::*; diff --git a/crates/burn-dataset/src/source/mod.rs b/crates/burn-dataset/src/source/mod.rs new file mode 100644 index 0000000..b9ed762 --- /dev/null +++ b/crates/burn-dataset/src/source/mod.rs @@ -0,0 +1,3 @@ +/// Huggingface source +#[cfg(any(feature = "sqlite", feature = "sqlite-bundled"))] +pub mod huggingface; diff --git a/crates/burn-dataset/src/transform/composed.rs b/crates/burn-dataset/src/transform/composed.rs new file mode 100644 index 0000000..dfe0a9e --- /dev/null +++ b/crates/burn-dataset/src/transform/composed.rs @@ -0,0 +1,56 @@ +use crate::Dataset; + +/// Compose multiple datasets together to create a bigger one. +#[derive(new)] +pub struct ComposedDataset { + datasets: Vec, +} + +impl Dataset for ComposedDataset +where + D: Dataset, + I: Clone, +{ + fn get(&self, index: usize) -> Option { + let mut current_index = 0; + for dataset in self.datasets.iter() { + if index < dataset.len() + current_index { + return dataset.get(index - current_index); + } + current_index += dataset.len(); + } + None + } + fn len(&self) -> usize { + let mut total = 0; + for dataset in self.datasets.iter() { + total += dataset.len(); + } + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FakeDataset; + + #[test] + fn test_composed_dataset() { + let dataset1 = FakeDataset::::new(10); + let dataset2 = FakeDataset::::new(5); + + let items1 = dataset1.iter().collect::>(); + let items2 = dataset2.iter().collect::>(); + + let composed = ComposedDataset::new(vec![dataset1, dataset2]); + + assert_eq!(composed.len(), 15); + + let expected_items: Vec = items1.iter().chain(items2.iter()).cloned().collect(); + + let items = composed.iter().collect::>(); + + assert_eq!(items, expected_items); + } +} diff --git a/crates/burn-dataset/src/transform/mapper.rs b/crates/burn-dataset/src/transform/mapper.rs new file mode 100644 index 0000000..f73eec8 --- /dev/null +++ b/crates/burn-dataset/src/transform/mapper.rs @@ -0,0 +1,60 @@ +use crate::Dataset; +use std::marker::PhantomData; + +/// Basic mapper trait to be used with the [mapper dataset](MapperDataset). +pub trait Mapper: Send + Sync { + /// Maps an item of type I to an item of type O. + fn map(&self, item: &I) -> O; +} + +/// Dataset mapping each element in an inner dataset to another element type lazily. +#[derive(new)] +pub struct MapperDataset { + dataset: D, + mapper: M, + input: PhantomData, +} + +impl Dataset for MapperDataset +where + D: Dataset, + M: Mapper + Send + Sync, + I: Send + Sync, + O: Send + Sync, +{ + fn get(&self, index: usize) -> Option { + let item = self.dataset.get(index); + item.map(|item| self.mapper.map(&item)) + } + + fn len(&self) -> usize { + self.dataset.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{InMemDataset, test_data}; + + #[test] + pub fn given_mapper_dataset_when_iterate_should_iterate_though_all_map_items() { + struct StringToFirstChar; + + impl Mapper for StringToFirstChar { + fn map(&self, item: &String) -> String { + let mut item = item.clone(); + item.truncate(1); + item + } + } + + let items_original = test_data::string_items(); + let dataset = InMemDataset::new(items_original); + let dataset = MapperDataset::new(dataset, StringToFirstChar); + + let items: Vec = dataset.iter().collect(); + + assert_eq!(vec!["1", "2", "3", "4"], items); + } +} diff --git a/crates/burn-dataset/src/transform/mod.rs b/crates/burn-dataset/src/transform/mod.rs new file mode 100644 index 0000000..a35f57d --- /dev/null +++ b/crates/burn-dataset/src/transform/mod.rs @@ -0,0 +1,30 @@ +//! # Dataset Transformations +//! +//! This module provides a collection of [`crate::Dataset`] composition wrappers; +//! providing composition, subset selection, sampling, random shuffling, and windowing. +//! +//! * [`ComposedDataset`] - composes a list of datasets. +//! * [`PartialDataset`] - selects a contiguous index range subset of a dataset. +//! * [`ShuffledDataset`] - a randomly shuffled / mutably shuffle-able dataset; +//! a thin wrapper around [`SelectionDataset`]. +//! * [`SamplerDataset`] - samples a dataset; support for with/without replacement, +//! and under/oversampling. +//! * [`SelectionDataset`] - selects a subset of a dataset via indices; support for shuffling. +//! * [`WindowsDataset`] - creates a sliding window over a dataset. +mod composed; +mod mapper; +mod options; +mod partial; +mod sampler; +mod selection; +mod shuffle; +mod window; + +pub use composed::*; +pub use mapper::*; +pub use options::*; +pub use partial::*; +pub use sampler::*; +pub use selection::*; +pub use shuffle::*; +pub use window::*; diff --git a/crates/burn-dataset/src/transform/options.rs b/crates/burn-dataset/src/transform/options.rs new file mode 100644 index 0000000..eee5dd3 --- /dev/null +++ b/crates/burn-dataset/src/transform/options.rs @@ -0,0 +1,199 @@ +use rand::SeedableRng; +use rand::prelude::StdRng; +use rand::rngs::SysRng; + +/// Defines a source for a `StdRng`. +/// +/// # Examples +/// +/// ```rust,no_run +/// use rand::rngs::StdRng; +/// use rand::SeedableRng; +/// use burn_dataset::transform::RngSource; +/// +/// // Default via `StdRng::from_os_rng()` (`RngSource::Default`) +/// let system: RngSource = RngSource::default(); +/// +/// // From a fixed seed (`RngSource::Seed`) +/// let seeded: RngSource = 42.into(); +/// +/// // From an existing rng (`RngSource::Rng`) +/// let rng = StdRng::seed_from_u64(123); +/// let with_rng: RngSource = rng.into(); +/// +/// // Forks the parent RNG to derive an independent, deterministic child RNG. +/// // The original `rng` is modified, and the resulting `RngSource` contains +/// // a new RNG starting from a unique state. +/// let mut rng = StdRng::seed_from_u64(123); +/// let forked: RngSource = (&mut rng).into(); +/// ``` +#[derive(Debug, Default, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] +pub enum RngSource { + /// Build a new rng from the system. + #[default] + Default, + + /// The rng is passed as a seed. + Seed(u64), + + /// The rng is passed as an option. + Rng(StdRng), +} + +impl From for StdRng { + fn from(source: RngSource) -> Self { + match source { + RngSource::Default => StdRng::try_from_rng(&mut SysRng).unwrap(), + RngSource::Rng(rng) => rng, + RngSource::Seed(seed) => StdRng::seed_from_u64(seed), + } + } +} + +impl From for RngSource { + fn from(seed: u64) -> Self { + Self::Seed(seed) + } +} + +impl From for RngSource { + fn from(rng: StdRng) -> Self { + Self::Rng(rng) + } +} + +/// Derive an independent RNG from a mutable parent RNG. +/// +/// This advances the parent RNG and creates a new RNG seeded from its output. +/// The derived RNG is *not* a clone of the parent's state, but an independent +/// stream (equivalent to `SeedableRng::fork`). +impl From<&mut StdRng> for RngSource { + fn from(rng: &mut StdRng) -> Self { + Self::Rng(rng.fork()) + } +} + +/// Helper option to describe the size of a wrapper, relative to a wrapped object. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum SizeConfig { + /// Use the size of the source dataset. + #[default] + Default, + + /// Use the size as a ratio of the source dataset size. + /// + /// Must be >= 0. + Ratio(f64), + + /// Use a fixed size. + Fixed(usize), +} + +impl SizeConfig { + /// Construct a source which will have the same size as the source dataset. + pub fn source() -> Self { + Self::Default + } + + /// Resolve the effective size. + /// + /// ## Arguments + /// + /// - `source_size`: the size of the source dataset. + /// + /// ## Returns + /// + /// The resolved size of the wrapper dataset. + pub fn resolve(self, source_size: usize) -> usize { + match self { + SizeConfig::Default => source_size, + SizeConfig::Ratio(ratio) => { + assert!(ratio >= 0.0, "Ratio must be non-negative: {ratio}"); + ((source_size as f64) * ratio) as usize + } + SizeConfig::Fixed(size) => size, + } + } +} + +impl From for SizeConfig { + fn from(size: usize) -> Self { + Self::Fixed(size) + } +} + +impl From for SizeConfig { + fn from(ratio: f64) -> Self { + Self::Ratio(ratio) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn test_rng_source_default() { + let rng_source: RngSource = Default::default(); + assert_eq!(&rng_source, &RngSource::Default); + assert_eq!(&rng_source, &RngSource::default()); + + // Exercise the from_os_rng() call; but we don't know its seed; + let _rng: StdRng = rng_source.into(); + } + + #[test] + fn test_rng_source_seed() { + let rng_source = RngSource::from(42); + assert_eq!(&rng_source, &RngSource::Seed(42)); + + let rng: StdRng = rng_source.into(); + let expected = StdRng::seed_from_u64(42); + + assert_eq!(rng, expected); + } + + #[test] + fn test_rng_source_rng() { + // From StdRng (owned). + { + let original = StdRng::seed_from_u64(42); + + let rng_source = RngSource::from(original); + let rng: StdRng = rng_source.into(); + // No longer clone, but from <> into should not have advanced the state + let original = StdRng::seed_from_u64(42); + assert_eq!(rng, original); + } + + // From &mut StdRng (forks parent) + { + let mut original = StdRng::seed_from_u64(42); + let mut rng = StdRng::seed_from_u64(42); + let rng_forked = rng.fork(); + + let rng_source = RngSource::from(&mut original); + + // Ensure the original was advanced + assert_eq!(original, rng); + + // Ensure the sourced RNG matches the fork + let rng: StdRng = rng_source.into(); + assert_eq!(rng, rng_forked); + } + } + + #[test] + fn test_size_config() { + assert_eq!(SizeConfig::default(), SizeConfig::Default); + + assert_eq!(SizeConfig::from(42), SizeConfig::Fixed(42)); + + assert_eq!(SizeConfig::from(1.5), SizeConfig::Ratio(1.5)); + + assert_eq!(SizeConfig::source(), SizeConfig::Default); + assert_eq!(SizeConfig::source().resolve(50), 50); + } +} diff --git a/crates/burn-dataset/src/transform/partial.rs b/crates/burn-dataset/src/transform/partial.rs new file mode 100644 index 0000000..520aa8d --- /dev/null +++ b/crates/burn-dataset/src/transform/partial.rs @@ -0,0 +1,206 @@ +use crate::Dataset; +use std::{marker::PhantomData, sync::Arc}; + +/// Only use a fraction of an existing dataset lazily. +#[derive(new, Clone)] +pub struct PartialDataset { + dataset: D, + start_index: usize, + end_index: usize, + input: PhantomData, +} + +impl PartialDataset +where + D: Dataset, +{ + /// Splits a dataset into multiple partial datasets. + pub fn split(dataset: D, num: usize) -> Vec, I>> { + let dataset = Arc::new(dataset); // cheap cloning. + + let mut current = 0; + let mut datasets = Vec::with_capacity(num); + + let batch_size = dataset.len() / num; + + for i in 0..num { + let start = current; + let mut end = current + batch_size; + + if i == (num - 1) { + end = dataset.len(); + } + + let dataset = PartialDataset::new(dataset.clone(), start, end); + + current += batch_size; + datasets.push(dataset); + } + + datasets + } + + /// Splits a dataset by distributing complete chunks/batches across multiple partial datasets. + pub fn split_chunks( + dataset: D, + num: usize, + batch_size: usize, + ) -> Vec, I>> { + let dataset = Arc::new(dataset); // cheap cloning. + let total_items = dataset.len(); + + // Total number of complete batches + let total_batches = total_items.div_ceil(batch_size); + let batches_per_split = total_batches / num; + let extra_batches = total_batches % num; + + let mut datasets = Vec::with_capacity(num); + let mut current_batch = 0; + + for i in 0..num { + // Extra batches distributed across first splits + let split_batches = if i < extra_batches { + batches_per_split + 1 + } else { + batches_per_split + }; + + let start_batch = current_batch; + let end_batch = start_batch + split_batches; + + let start_index = start_batch * batch_size; + let end_index = core::cmp::min(end_batch * batch_size, total_items); + + if start_index < total_items { + datasets.push(PartialDataset::new(dataset.clone(), start_index, end_index)); + } + + current_batch = end_batch; + } + + datasets + } +} + +impl Dataset for PartialDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + fn get(&self, index: usize) -> Option { + let index = index + self.start_index; + if index < self.start_index || index >= self.end_index { + return None; + } + self.dataset.get(index) + } + + fn len(&self) -> usize { + usize::min(self.end_index - self.start_index, self.dataset.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FakeDataset; + use std::collections::HashSet; + + #[test] + fn test_start_from_beginning() { + let dataset_original = FakeDataset::::new(27); + let mut items_original_1 = HashSet::new(); + let mut items_original_2 = HashSet::new(); + let mut items_partial = HashSet::new(); + dataset_original.iter().enumerate().for_each(|(i, item)| { + match i >= 10 { + true => items_original_2.insert(item), + false => items_original_1.insert(item), + }; + }); + + let dataset_partial = PartialDataset::new(dataset_original, 0, 10); + + for item in dataset_partial.iter() { + items_partial.insert(item); + } + + assert_eq!(dataset_partial.len(), 10); + assert_eq!(items_original_1, items_partial); + for item in items_original_2 { + assert!(!items_partial.contains(&item)); + } + } + + #[test] + fn test_start_inside() { + let dataset_original = FakeDataset::::new(27); + let mut items_original_1 = HashSet::new(); + let mut items_original_2 = HashSet::new(); + let mut items_partial = HashSet::new(); + + dataset_original.iter().enumerate().for_each(|(i, item)| { + match !(10..20).contains(&i) { + true => items_original_2.insert(item), + false => items_original_1.insert(item), + }; + }); + + let dataset_partial = PartialDataset::new(dataset_original, 10, 20); + for item in dataset_partial.iter() { + items_partial.insert(item); + } + + assert_eq!(dataset_partial.len(), 10); + assert_eq!(items_original_1, items_partial); + for item in items_original_2 { + assert!(!items_partial.contains(&item)); + } + } + + #[test] + fn test_split_contains_all_items_without_duplicates() { + let dataset_original = FakeDataset::::new(27); + let mut items_original = Vec::new(); + let mut items_partial = Vec::new(); + for item in dataset_original.iter() { + items_original.push(item); + } + + let dataset_partials = PartialDataset::split(dataset_original, 4); + let expected_len = [6, 6, 6, 9]; + + for (i, dataset) in dataset_partials.iter().enumerate() { + assert_eq!(dataset.len(), expected_len[i]); + for item in dataset.iter() { + items_partial.push(item); + } + } + + assert_eq!(items_original, items_partial); + } + + #[test] + fn test_split_chunks_contains_all_items_without_duplicates() { + let dataset_original = FakeDataset::::new(27); + let mut items_original = Vec::new(); + let mut items_partial = Vec::new(); + for item in dataset_original.iter() { + items_original.push(item); + } + + let dataset_partials = PartialDataset::split_chunks(dataset_original, 4, 5); + // [(2 * 5), (2 * 5), 5, 2] -> 5 complete chunks + 1 incomplete with 2 remaining items + // OTOH, `split(dataset, 4)` would yield [6, 6, 6, 9] -> 4 incomplete chunks + 4 incomplete with [1, 1, 1, 4] + let expected_len = [10, 10, 5, 2]; + + for (i, dataset) in dataset_partials.iter().enumerate() { + assert_eq!(dataset.len(), expected_len[i]); + for item in dataset.iter() { + items_partial.push(item); + } + } + + assert_eq!(items_original, items_partial); + } +} diff --git a/crates/burn-dataset/src/transform/sampler.rs b/crates/burn-dataset/src/transform/sampler.rs new file mode 100644 index 0000000..a4f0268 --- /dev/null +++ b/crates/burn-dataset/src/transform/sampler.rs @@ -0,0 +1,438 @@ +use crate::Dataset; +use crate::transform::{RngSource, SizeConfig}; +use rand::prelude::SliceRandom; +use rand::{RngExt, distr::Uniform, rngs::StdRng, seq::IteratorRandom}; +use std::{marker::PhantomData, ops::DerefMut, sync::Mutex}; + +/// Options to configure a [SamplerDataset]. +#[derive(Debug, PartialEq)] +pub struct SamplerDatasetOptions { + /// The sampling mode. + pub replace_samples: bool, + + /// The size source of the wrapper relative to the dataset. + pub size_config: SizeConfig, + + /// The source of the random number generator. + pub rng_source: RngSource, +} + +impl Default for SamplerDatasetOptions { + fn default() -> Self { + Self { + replace_samples: true, + size_config: SizeConfig::Default, + rng_source: RngSource::Default, + } + } +} + +impl From> for SamplerDatasetOptions +where + T: Into, +{ + fn from(option: Option) -> Self { + match option { + Some(option) => option.into(), + None => Self::default(), + } + } +} + +impl From for SamplerDatasetOptions { + fn from(size: usize) -> Self { + Self::default().with_replacement().with_fixed_size(size) + } +} + +impl SamplerDatasetOptions { + /// Set the replacement mode. + pub fn with_replace_samples(self, replace_samples: bool) -> Self { + Self { + replace_samples, + ..self + } + } + + /// Set the replacement mode to WithReplacement. + pub fn with_replacement(self) -> Self { + self.with_replace_samples(true) + } + + /// Set the replacement mode to WithoutReplacement. + pub fn without_replacement(self) -> Self { + self.with_replace_samples(false) + } + + /// Set the size source. + pub fn with_size(self, source: S) -> Self + where + S: Into, + { + Self { + size_config: source.into(), + ..self + } + } + + /// Set the size to the size of the source. + pub fn with_source_size(self) -> Self { + self.with_size(SizeConfig::Default) + } + + /// Set the size to a fixed size. + pub fn with_fixed_size(self, size: usize) -> Self { + self.with_size(size) + } + + /// Set the size to be a multiple of the ration and the source size. + pub fn with_size_ratio(self, size_ratio: f64) -> Self { + self.with_size(size_ratio) + } + + /// Set the `RngSource`. + pub fn with_rng(self, rng: R) -> Self + where + R: Into, + { + Self { + rng_source: rng.into(), + ..self + } + } + + /// Use the system rng. + pub fn with_system_rng(self) -> Self { + self.with_rng(RngSource::Default) + } + + /// Use a rng, built from a seed. + pub fn with_seed(self, seed: u64) -> Self { + self.with_rng(seed) + } +} + +/// Sample items from a dataset. +/// +/// This is a convenient way of modeling a dataset as a probability distribution of a fixed size. +/// You have multiple options to instantiate the dataset sampler. +/// +/// * With replacement (Default): This is the most efficient way of using the sampler because no state is +/// required to keep indices that have been selected. +/// +/// * Without replacement: This has a similar effect to using a +/// [shuffled dataset](crate::transform::ShuffledDataset), but with more flexibility since you can +/// set the dataset to an arbitrary size. Once every item has been used, a new cycle is +/// created with a new random suffle. +pub struct SamplerDataset { + dataset: D, + size: usize, + state: Mutex, + input: PhantomData, +} +enum SamplerState { + WithReplacement(StdRng), + WithoutReplacement(StdRng, Vec), +} + +impl SamplerDataset +where + D: Dataset, + I: Send + Sync, +{ + /// Creates a new sampler dataset with replacement. + /// + /// When the sample size is less than or equal to the source dataset size, + /// data will be sampled without replacement from the source dataset in + /// a uniformly shuffled order. + /// + /// When the sample size is greater than the source dataset size, + /// the entire source dataset will be sampled once for every multiple + /// of the size ratios; with the remaining samples taken without replacement + /// uniformly from the source. All samples will be returned uniformly shuffled. + /// + /// ## Arguments + /// + /// * `dataset`: the dataset to wrap. + /// * `options`: the options to configure the sampler dataset. + /// + /// ## Examples + /// ```rust,ignore + /// use burn_dataset::transform::{ + /// SamplerDataset, + /// SamplerDatasetOptions, + /// }; + /// + /// // Examples below assuming `dataset.len()` = `10`. + /// + /// // sample size: 5 + /// // WithReplacement + /// // rng: StdRng::from_os_rng() + /// SamplerDataset::new(dataset, 5); + /// + /// // sample size: 10 (source) + /// // WithReplacement + /// // rng: StdRng::from_os_rng() + /// SamplerDataset::new(dataset, SamplerDatasetOptions::default()); + /// + /// // sample size: 15 + /// // WithoutReplacement + /// // rng: StdRng::seed_from_u64(42) + /// SamplerDataset::new( + /// dataset, + /// SamplerDatasetOptions::default() + /// .with_size(1.5) + /// .without_replacement() + /// .with_rng(42), + /// ); + /// ``` + pub fn new(dataset: D, options: O) -> Self + where + O: Into, + { + let options = options.into(); + let size = options.size_config.resolve(dataset.len()); + let rng = options.rng_source.into(); + Self { + dataset, + size, + state: Mutex::new(match options.replace_samples { + true => SamplerState::WithReplacement(rng), + false => SamplerState::WithoutReplacement(rng, Vec::with_capacity(size)), + }), + input: PhantomData, + } + } + + /// Creates a new sampler dataset with replacement. + /// + /// # Arguments + /// + /// - `dataset`: the dataset to wrap. + /// - `size`: the effective size of the sampled dataset. + pub fn with_replacement(dataset: D, size: usize) -> Self { + Self::new( + dataset, + SamplerDatasetOptions::default() + .with_replacement() + .with_fixed_size(size), + ) + } + + /// Creates a new sampler dataset without replacement. + /// + /// When the sample size is less than or equal to the source dataset size, + /// data will be sampled without replacement from the source dataset in + /// a uniformly shuffled order. + /// + /// When the sample size is greater than the source dataset size, + /// the entire source dataset will be sampled once for every multiple + /// of the size ratios; with the remaining samples taken without replacement + /// uniformly from the source. All samples will be returned uniformly shuffled. + /// + /// # Arguments + /// - `dataset`: the dataset to wrap. + /// - `size`: the effective size of the sampled dataset. + pub fn without_replacement(dataset: D, size: usize) -> Self { + Self::new( + dataset, + SamplerDatasetOptions::default() + .without_replacement() + .with_fixed_size(size), + ) + } + + /// Determines if the sampler is using the "with replacement" strategy. + /// + /// # Returns + /// - `true`: If the sampler is configured to sample with replacement. + /// - `false`: If the sampler is configured to sample without replacement. + pub fn is_with_replacement(&self) -> bool { + match self.state.lock().unwrap().deref_mut() { + SamplerState::WithReplacement(_) => true, + SamplerState::WithoutReplacement(_, _) => false, + } + } + + fn index(&self) -> usize { + match self.state.lock().unwrap().deref_mut() { + SamplerState::WithReplacement(rng) => { + rng.sample(Uniform::new(0, self.dataset.len()).unwrap()) + } + SamplerState::WithoutReplacement(rng, indices) => { + if indices.is_empty() { + // Refill the state. + let idx_range = 0..self.dataset.len(); + for _ in 0..(self.size / self.dataset.len()) { + // No need to `.choose_multiple` here because we're using + // the entire source range; and `.choose_multiple` will + // not return a random sample anyway. + indices.extend(idx_range.clone()) + } + + // From `choose_multiple` documentation: + // > Although the elements are selected randomly, the order of elements in + // > the buffer is neither stable nor fully random. If random ordering is + // > desired, shuffle the result. + indices.extend(idx_range.sample(rng, self.size - indices.len())); + + // The real shuffling is done here. + indices.shuffle(rng); + } + + indices.pop().expect("Indices are refilled when empty.") + } + } + } +} + +impl Dataset for SamplerDataset +where + D: Dataset, + I: Send + Sync, +{ + fn get(&self, index: usize) -> Option { + if index >= self.size { + return None; + } + + self.dataset.get(self.index()) + } + + fn len(&self) -> usize { + self.size + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::bool_assert_comparison)] + + use super::*; + use crate::FakeDataset; + use rand::SeedableRng; + use std::collections::HashMap; + + #[test] + fn test_samplerdataset_options() { + let options = SamplerDatasetOptions::default(); + assert_eq!(options.replace_samples, true); + assert_eq!(options.size_config, SizeConfig::Default); + assert_eq!(options.rng_source, RngSource::Default); + + // ReplacementMode + let options = options.with_replace_samples(false); + assert_eq!(options.replace_samples, false); + let options = options.with_replacement(); + assert_eq!(options.replace_samples, true); + let options = options.without_replacement(); + assert_eq!(options.replace_samples, false); + + // SourceSize + let options = options.with_size(SizeConfig::Default); + assert_eq!(options.size_config, SizeConfig::Default); + let options = options.with_source_size(); + assert_eq!(options.size_config, SizeConfig::Default); + let options = options.with_fixed_size(10); + assert_eq!(options.size_config, SizeConfig::Fixed(10)); + let options = options.with_size_ratio(1.5); + assert_eq!(options.size_config, SizeConfig::Ratio(1.5)); + + // RngSource + let options = options.with_system_rng(); + assert_eq!(options.rng_source, RngSource::Default); + let options = options.with_seed(42); + assert_eq!(options.rng_source, RngSource::Seed(42)); + let rng = StdRng::seed_from_u64(9); + let options = options.with_rng(rng); + assert!(matches!(options.rng_source, RngSource::Rng(_))); + } + + #[test] + fn sampler_dataset_constructors_test() { + let ds = SamplerDataset::new(FakeDataset::::new(10), 15); + assert_eq!(ds.len(), 15); + assert_eq!(ds.dataset.len(), 10); + assert!(ds.is_with_replacement()); + + let ds = SamplerDataset::with_replacement(FakeDataset::::new(10), 15); + assert_eq!(ds.len(), 15); + assert_eq!(ds.dataset.len(), 10); + assert!(ds.is_with_replacement()); + + let ds = SamplerDataset::without_replacement(FakeDataset::::new(10), 15); + assert_eq!(ds.len(), 15); + assert_eq!(ds.dataset.len(), 10); + assert!(!ds.is_with_replacement()); + } + + #[test] + fn sampler_dataset_with_replacement_iter() { + let factor = 3; + let len_original = 10; + let dataset_sampler = SamplerDataset::with_replacement( + FakeDataset::::new(len_original), + len_original * factor, + ); + let mut total = 0; + + for _item in dataset_sampler.iter() { + total += 1; + } + + assert_eq!(total, factor * len_original); + } + + #[test] + fn sampler_dataset_without_replacement_bucket_test() { + let factor = 3; + let len_original = 10; + + let dataset_sampler = SamplerDataset::new( + FakeDataset::::new(len_original), + SamplerDatasetOptions::default() + .without_replacement() + .with_size_ratio(factor as f64), + ); + + let mut buckets = HashMap::new(); + + for item in dataset_sampler.iter() { + let count = match buckets.get(&item) { + Some(count) => count + 1, + None => 1, + }; + + buckets.insert(item, count); + } + + let mut total = 0; + for count in buckets.into_values() { + assert_eq!(count, factor); + total += count; + } + assert_eq!(total, factor * len_original); + } + + #[test] + fn sampler_dataset_without_replacement_uniform_order_test() { + // This is a reversion test on the indices.shuffle(rng) call in SamplerDataset::index(). + let size = 1000; + let dataset_sampler = + SamplerDataset::without_replacement(FakeDataset::::new(size), size); + + let indices: Vec<_> = (0..size).map(|_| dataset_sampler.index()).collect(); + let mean_delta = indices + .windows(2) + .map(|pair| pair[1].abs_diff(pair[0])) + .sum::() as f64 + / (size - 1) as f64; + + let expected = (size + 2) as f64 / 3.0; + + assert!( + (mean_delta - expected).abs() <= 0.25 * expected, + "Sampled indices are not uniformly distributed: mean_delta: {mean_delta}, expected: {expected}" + ); + } +} diff --git a/crates/burn-dataset/src/transform/selection.rs b/crates/burn-dataset/src/transform/selection.rs new file mode 100644 index 0000000..283be57 --- /dev/null +++ b/crates/burn-dataset/src/transform/selection.rs @@ -0,0 +1,374 @@ +use crate::Dataset; +use crate::transform::RngSource; +use rand::prelude::SliceRandom; +use rand::rngs::StdRng; +use std::marker::PhantomData; +use std::sync::Arc; + +/// Generates a vector of indices from 0 to size - 1. +/// +/// # Arguments +/// +/// * `size` - The size of the dataset. +/// +/// # Returns +/// +/// A vector containing indices from 0 to size - 1. +#[inline(always)] +pub fn iota(size: usize) -> Vec { + (0..size).collect() +} + +/// Generates a shuffled vector of indices up to a size. +/// +/// # Arguments +/// +/// * `size` - The size of the dataset to shuffle. +/// +/// # Returns +/// +/// A vector of shuffled indices. +#[inline(always)] +pub fn shuffled_indices(size: usize, rng: &mut StdRng) -> Vec { + let mut indices = iota(size); + indices.shuffle(rng); + indices +} + +/// A dataset that selects a subset of indices from an existing dataset. +/// +/// Indices may appear multiple times, but they must be within the bounds of the original dataset. +#[derive(Clone)] +pub struct SelectionDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + /// The wrapped dataset from which to select indices. + pub wrapped: Arc, + + /// The indices to select from the wrapped dataset. + pub indices: Vec, + + input: PhantomData, +} + +impl SelectionDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + /// Creates a new selection dataset with the given dataset and indices. + /// + /// Checks that all indices are within the bounds of the dataset. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// * `indices` - A slice of indices to select from the dataset. + /// These indices must be within the bounds of the dataset. + /// + /// # Panics + /// + /// Panics if any index is out of bounds for the dataset. + pub fn from_indices_checked(dataset: S, indices: Vec) -> Self + where + S: Into>, + { + let dataset = dataset.into(); + + let size = dataset.len(); + if let Some(idx) = indices.iter().find(|&i| *i >= size) { + panic!("Index out of bounds for wrapped dataset size: {idx} >= {size}"); + } + + Self::from_indices_unchecked(dataset, indices) + } + + /// Creates a new selection dataset with the given dataset and indices without checking bounds. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// * `indices` - A vector of indices to select from the dataset. + /// + /// # Safety + /// + /// This function does not check if the indices are within the bounds of the dataset. + pub fn from_indices_unchecked(dataset: S, indices: Vec) -> Self + where + S: Into>, + { + Self { + wrapped: dataset.into(), + indices, + input: PhantomData, + } + } + + /// Creates a new selection dataset that selects all indices from the dataset. + /// + /// This allocates a 1-to-1 mapping of indices to the dataset size, + /// essentially functioning as a no-op selection. This is only useful + /// when the dataset will later be shuffled or transformed in place. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// + /// # Returns + /// + /// A new `SelectionDataset` that selects all indices from the dataset. + pub fn new_select_all(dataset: S) -> Self + where + S: Into>, + { + let dataset = dataset.into(); + let size = dataset.len(); + Self::from_indices_unchecked(dataset, iota(size)) + } + + /// Creates a new selection dataset with shuffled indices. + /// + /// Selects every index of the dataset and shuffles them + /// with randomness from the provided random number generator. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// * `rng` - A mutable reference to a random number generator. + /// + /// # Returns + /// + /// A new `SelectionDataset` with shuffled indices. + pub fn new_shuffled(dataset: S, rng_source: R) -> Self + where + S: Into>, + R: Into, + { + let mut this = Self::new_select_all(dataset); + this.shuffle(rng_source); + this + } + + /// Shuffles the indices of the dataset using a mutable random number generator. + /// + /// This method modifies the dataset in place, shuffling the indices. + /// + /// # Arguments + /// + /// * `rng` - A mutable reference to a random number generator. + pub fn shuffle(&mut self, rng_source: R) + where + R: Into, + { + let mut rng: StdRng = rng_source.into().into(); + self.indices.shuffle(&mut rng) + } + + /// Creates a new dataset that is a slice of the current selection dataset. + /// + /// Slices the *selection indices* from ``[start..end]``. + /// + /// Independent of future shuffles on the parent, but shares the same wrapped dataset. + /// + /// + /// # Arguments + /// + /// * `start` - The start of the range. + /// * `end` - The end of the range (exclusive). + // TODO: SliceArg in burn-tensor should be lifted to burn-std; this should use SliceArg. + pub fn slice(&self, start: usize, end: usize) -> Self { + Self::from_indices_unchecked(self.wrapped.clone(), self.indices[start..end].to_vec()) + } + + /// Split into `num` datasets by slicing the selection indices evenly. + /// + /// Split is done via `slice`, so the datasets share the same wrapped dataset. + /// + /// Independent of future shuffles on the parent, but shares the same wrapped dataset. + /// + /// # Arguments + /// + /// * `num` - The number of datasets to split into. + /// + /// # Returns + /// + /// A vector of `SelectionDataset` instances, each containing a subset of the indices. + pub fn split(&self, num: usize) -> Vec { + let n = self.indices.len(); + + let mut current = 0; + let mut datasets = Vec::with_capacity(num); + + let batch_size = n / num; + for i in 0..num { + let start = current; + let mut end = current + batch_size; + + if i == (num - 1) { + end = n; + } + + let dataset = self.slice(start, end); + + current += batch_size; + datasets.push(dataset); + } + + datasets + } +} + +impl Dataset for SelectionDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + fn get(&self, index: usize) -> Option { + let index = self.indices.get(index)?; + self.wrapped.get(*index) + } + + fn len(&self) -> usize { + self.indices.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FakeDataset; + use rand::SeedableRng; + + #[test] + fn test_iota() { + let size = 10; + let indices = iota(size); + assert_eq!(indices.len(), size); + assert_eq!(indices, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + } + + #[test] + fn test_shuffled_indices_same_seed_is_deterministic() { + let size = 10; + + let mut rng1 = StdRng::seed_from_u64(10); + // `StdRng` is no longer `Clone`, so its internal state cannot be duplicated. + // To test determinism, we must explicitly create a second RNG from the same seed. + let mut rng2 = StdRng::seed_from_u64(10); + + let mut expected = iota(size); + expected.shuffle(&mut rng1); + + let indices = shuffled_indices(size, &mut rng2); + + assert_eq!(indices, expected); + } + + #[test] + fn test_shuffled_indices_forked_rngs_differ() { + let size = 10; + + let mut rng1 = StdRng::seed_from_u64(10); + let mut rng2 = rng1.fork(); + + let mut a = iota(size); + let mut b = iota(size); + + a.shuffle(&mut rng1); + b.shuffle(&mut rng2); + + assert_ne!(a, b); + } + + #[should_panic(expected = "Index out of bounds for wrapped dataset size: 300 >= 27")] + #[test] + fn test_from_indices_checked_panics() { + let source_dataset = FakeDataset::::new(27); + let indices: Vec = vec![15, 1, 12, 300]; + SelectionDataset::from_indices_checked(source_dataset, indices); + } + + #[test] + fn test_checked_selection_dataset() { + let source_dataset = FakeDataset::::new(27); + + let indices: Vec = vec![15, 1, 12, 12]; + let expected: Vec = indices + .iter() + .map(|i| source_dataset.get(*i).unwrap()) + .collect(); + + let selection = SelectionDataset::from_indices_checked(source_dataset, indices.clone()); + + assert_eq!(&selection.indices, &indices); + + let items = selection.iter().collect::>(); + + assert_eq!(items, expected); + } + + #[test] + fn test_shuffled_dataset() { + let dataset = FakeDataset::::new(27); + let source_items = dataset.iter().collect::>(); + + let selection = SelectionDataset::new_shuffled(dataset, 42); + + let indices = shuffled_indices(source_items.len(), &mut StdRng::seed_from_u64(42)); + + assert_eq!(&selection.indices, &indices); + assert_eq!(selection.len(), source_items.len()); + + let expected_items: Vec<_> = indices + .iter() + .map(|&i| source_items[i].to_string()) + .collect(); + assert_eq!(&selection.iter().collect::>(), &expected_items); + } + + #[test] + fn test_slice() { + let dataset = FakeDataset::::new(27); + let source_items = dataset.iter().collect::>(); + + let selection = SelectionDataset::new_select_all(dataset); + + let start = 5; + let end = 15; + let sliced_selection = selection.slice(start, end); + + assert_eq!(sliced_selection.len(), end - start); + + #[allow(clippy::needless_range_loop)] + for i in start..end { + assert_eq!( + sliced_selection.get(i - start), + Some(source_items[i].to_string()) + ); + } + } + + #[test] + fn test_split() { + let dataset = FakeDataset::::new(28); + let source_items = dataset.iter().collect::>(); + + let selection = SelectionDataset::new_select_all(dataset); + + let split_contents: Vec> = selection + .split(3) + .iter() + .map(|d| d.iter().collect::>()) + .collect(); + assert_eq!( + split_contents, + vec![ + source_items[0..9].to_vec(), + source_items[9..18].to_vec(), + source_items[18..28].to_vec(), + ] + ); + } +} diff --git a/crates/burn-dataset/src/transform/shuffle.rs b/crates/burn-dataset/src/transform/shuffle.rs new file mode 100644 index 0000000..8e93e49 --- /dev/null +++ b/crates/burn-dataset/src/transform/shuffle.rs @@ -0,0 +1,109 @@ +use crate::Dataset; +use crate::transform::{RngSource, SelectionDataset}; + +/// A Shuffled a dataset. +/// +/// This is a thin wrapper around a [SelectionDataset] which selects and shuffles +/// the full indices of the original dataset. +/// +/// Consider using [SelectionDataset] if you are only interested in +/// shuffling mechanisms. +/// +/// Consider using [sampler dataset](crate::transform::SamplerDataset) if you +/// want a probability distribution which is computed lazily. +pub struct ShuffledDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + wrapped: SelectionDataset, +} + +impl ShuffledDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + /// Creates a new selection dataset with shuffled indices. + /// + /// This is a thin wrapper around `SelectionDataset::new_shuffled`. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// * `rng_source` - The source of the random number generator. + /// + /// # Returns + /// + /// A new `ShuffledDataset`. + pub fn new(dataset: D, rng_source: R) -> Self + where + R: Into, + { + Self { + wrapped: SelectionDataset::new_shuffled(dataset, rng_source), + } + } + + /// Creates a new selection dataset with shuffled indices using a fixed seed. + /// + /// This is a thin wrapper around `SelectionDataset::new_shuffled_with_seed`. + /// + /// # Arguments + /// + /// * `dataset` - The original dataset to select from. + /// * `seed` - A fixed seed for the random number generator. + /// + /// # Returns + /// + /// A new `ShuffledDataset`. + #[deprecated(since = "0.19.0", note = "Use `new(dataset, seed)` instead`")] + pub fn with_seed(dataset: D, seed: u64) -> Self { + Self::new(dataset, seed) + } +} + +impl Dataset for ShuffledDataset +where + D: Dataset, + I: Clone + Send + Sync, +{ + fn get(&self, index: usize) -> Option { + self.wrapped.get(index) + } + + fn len(&self) -> usize { + self.wrapped.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FakeDataset; + use crate::transform::selection::shuffled_indices; + use rand::SeedableRng; + use rand::prelude::StdRng; + + #[test] + fn test_shuffled_dataset() { + let dataset = FakeDataset::::new(27); + let source_items = dataset.iter().collect::>(); + + let seed = 42; + + #[allow(deprecated)] + let shuffled = ShuffledDataset::with_seed(dataset, seed); + + let mut rng = StdRng::seed_from_u64(seed); + let indices = shuffled_indices(source_items.len(), &mut rng); + + assert_eq!(shuffled.len(), source_items.len()); + + let expected_items: Vec<_> = indices + .iter() + .map(|&i| source_items[i].to_string()) + .collect(); + assert_eq!(&shuffled.iter().collect::>(), &expected_items); + } +} diff --git a/crates/burn-dataset/src/transform/window.rs b/crates/burn-dataset/src/transform/window.rs new file mode 100644 index 0000000..e6bb8b9 --- /dev/null +++ b/crates/burn-dataset/src/transform/window.rs @@ -0,0 +1,290 @@ +use std::{cmp::max, marker::PhantomData, num::NonZeroUsize}; + +use crate::Dataset; + +/// Functionality to create a window. +pub trait Window { + /// Creates a window of a collection. + /// + /// # Returns + /// + /// A `Vec` representing the window. + fn window(&self, current: usize, size: NonZeroUsize) -> Option>; +} + +impl + ?Sized> Window for T { + fn window(&self, current: usize, size: NonZeroUsize) -> Option> { + (current..current + size.get()) + .map(|x| self.get(x)) + .collect() + } +} + +/// Functionality to create a `WindowsIterator`. +pub trait Windows { + /// Creates and returns an iterator over all the windows of length `size`. + fn windows(&self, size: usize) -> WindowsIterator<'_, I>; +} + +impl> Windows for T { + /// Is empty if the `Dataset` is shorter than `size`. + /// + /// # Panics + /// + /// Panics if `size` is 0. + /// + /// # Examples + /// + /// ``` + /// use crate::burn_dataset::{ + /// transform::{Windows, WindowsDataset}, + /// Dataset, InMemDataset, + /// }; + /// + /// let items = [1, 2, 3, 4].to_vec(); + /// let dataset = InMemDataset::new(items.clone()); + /// + /// for window in dataset.windows(2) { + /// // do sth with window + /// } + /// ``` + fn windows(&self, size: usize) -> WindowsIterator<'_, I> { + let size = NonZeroUsize::new(size).expect("window size must be non-zero"); + WindowsIterator::new(self, size) + } +} + +/// Overlapping windows iterator. +pub struct WindowsIterator<'a, I> { + /// The size of the windows. + pub size: NonZeroUsize, + current: usize, + dataset: &'a dyn Dataset, +} + +impl<'a, I> WindowsIterator<'a, I> { + /// Creates a new `WindowsIterator` instance. The windows overlap. + /// Is empty if the input `Dataset` is shorter than `size`. + /// + /// # Parameters + /// + /// - `dataset`: The dataset over which windows will be created. + /// - `size`: The size of the windows. + pub fn new(dataset: &'a dyn Dataset, size: NonZeroUsize) -> Self { + WindowsIterator { + current: 0, + dataset, + size, + } + } +} + +impl Iterator for WindowsIterator<'_, I> { + type Item = Vec; + + fn next(&mut self) -> Option> { + self.current += 1; + self.dataset.window(self.current - 1, self.size) + } +} + +impl Clone for WindowsIterator<'_, I> { + fn clone(&self) -> Self { + WindowsIterator { + size: self.size, + dataset: self.dataset, + current: self.current, + } + } +} + +/// Dataset designed to work with overlapping windows of data. +pub struct WindowsDataset { + /// The size of the windows. + pub size: NonZeroUsize, + dataset: D, + input: PhantomData, +} + +impl WindowsDataset +where + D: Dataset, +{ + /// Creates a new `WindowsDataset` instance. The windows overlap. + /// Is empty if the input `Dataset` is shorter than `size`. + /// + /// # Parameters + /// + /// - `dataset`: The dataset over which windows will be created. + /// - `size`: The size of the windows. + pub fn new(dataset: D, size: usize) -> Self + where + D:, + { + let size = NonZeroUsize::new(size).expect("window size must be non-zero"); + WindowsDataset:: { + size, + dataset, + input: PhantomData, + } + } +} + +impl Dataset> for WindowsDataset +where + D: Dataset, + I: Send + Sync, +{ + /// Retrieves a window of items from the dataset. + /// + /// # Parameters + /// + /// - `index`: The index of the window. + /// + /// # Returns + /// + /// A vector representing the window. + fn get(&self, index: usize) -> Option> { + self.dataset.window(index, self.size) + } + + /// Retrieves the number of windows in the dataset. + /// + /// # Returns + /// + /// A size representing the number of windows. + fn len(&self) -> usize { + let len = self.dataset.len() as isize - self.size.get() as isize + 1; + max(len, 0) as usize + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use crate::{ + Dataset, InMemDataset, + transform::{Windows, WindowsDataset}, + }; + + #[rstest] + pub fn windows_should_be_equal_to_vec_windows() { + let items = [1, 2, 3, 4, 5].to_vec(); + let dataset = InMemDataset::new(items.clone()); + let expected = items + .windows(3) + .map(|x| x.to_vec()) + .collect::>>(); + + let result = dataset.windows(3).collect::>>(); + + assert_eq!(result, expected); + } + + #[rstest] + pub fn windows_dataset_should_be_equal_to_vec_windows() { + let items = [1, 2, 3, 4, 5].to_vec(); + let dataset = InMemDataset::new(items.clone()); + let expected = items + .windows(3) + .map(|x| x.to_vec()) + .collect::>>(); + + let result = WindowsDataset::new(dataset, 3) + .iter() + .collect::>>(); + + assert_eq!(result, expected); + } + + #[rstest] + pub fn cloned_iterator_should_be_equal() { + let items = [1, 2, 3, 4, 5].to_vec(); + let dataset = InMemDataset::new(items.clone()); + let original = dataset.windows(4); + + let cloned = original.clone(); + + assert!(std::ptr::eq(cloned.dataset, original.dataset)); + assert_eq!(cloned.size, original.size); + assert_eq!(cloned.current, original.current); + } + + #[rstest] + pub fn cloned_iterator_should_be_unaffected() { + let items = [1, 2, 3, 4, 5].to_vec(); + let dataset = InMemDataset::new(items.clone()); + let mut original = dataset.windows(4); + + let cloned = original.clone(); + original.current = 2; + + assert_ne!(cloned.current, original.current); + } + + #[rstest] + #[should_panic(expected = "window size must be non-zero")] + pub fn windows_should_panic() { + let items = [1, 2].to_vec(); + let dataset = InMemDataset::new(items.clone()); + + dataset.windows(0); + } + + #[rstest] + #[should_panic(expected = "window size must be non-zero")] + pub fn new_window_dataset_should_panic() { + let items = [1, 2].to_vec(); + let dataset = InMemDataset::new(items.clone()); + + WindowsDataset::new(dataset, 0); + } + + #[rstest] + pub fn window_dataset_len_should_be_equal() { + let dataset = InMemDataset::new([1, 2, 3, 4].to_vec()); + + let result = WindowsDataset::new(dataset, 2).len(); + + assert_eq!(result, 3); + } + + #[rstest] + pub fn window_iterator_should_be_empty() { + let dataset = InMemDataset::new([1, 2].to_vec()); + let mut peekable = dataset.windows(4).peekable(); + + let result = peekable.peek(); + + assert_eq!(result, None); + } + + #[rstest] + pub fn window_dataset_len_should_be_zero() { + let dataset = InMemDataset::new([1, 2].to_vec()); + + let result = WindowsDataset::new(dataset, 4).len(); + + assert_eq!(result, 0); + } + + #[rstest] + pub fn window_dataset_get_should_be_equal() { + let dataset = InMemDataset::new([1, 2, 3, 4].to_vec()); + let expected = Some([1, 2, 3].to_vec()); + + let result = WindowsDataset::new(dataset, 3).get(0); + + assert_eq!(result, expected); + } + + #[rstest] + pub fn window_dataset_get_should_be_none() { + let dataset = InMemDataset::new([1, 2].to_vec()); + + let result = WindowsDataset::new(dataset, 4).get(0); + + assert_eq!(result, None); + } +} diff --git a/crates/burn-dataset/src/vision/cifar.rs b/crates/burn-dataset/src/vision/cifar.rs new file mode 100644 index 0000000..1598db2 --- /dev/null +++ b/crates/burn-dataset/src/vision/cifar.rs @@ -0,0 +1,241 @@ +//! CIFAR Dataset Module +//! +//! This module provides functionality for loading the CIFAR-10 and CIFAR-100 image classification datasets. +//! CIFAR (Canadian Institute For Advanced Research) datasets are widely used benchmarks in computer vision, +//! consisting of 32×32 pixel color images split into training (50,000 images) and test (10,000 images) sets. +//! +//! ## Dataset Variants +//! - **CIFAR-10**: Contains 10 distinct classes (e.g., airplane, automobile, bird, cat) +//! - CIFAR-10 mirror from [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L44). +//! - Licensed under the [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE). +//! - **CIFAR-100**: Contains 100 fine-grained classes (e.g., beaver, dolphin, oak tree) +//! - CIFAR-100 mirror from [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L75). +//! - Licensed under the [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE). +//! +//! ## Usage Example +//! ```rust +//! use burn_dataset::vision::CifarDataset; +//! use burn_dataset::vision::CifarType; +//! +//! // Create a CIFAR-10 dataset accessor +//! let dataset = CifarDataset::new(CifarType::Cifar10); +//! +//! // Access training and test sets +//! let train_dataset = dataset.train(); +//! let test_dataset = dataset.test(); +//! ``` +//! ```rust +//! use burn_dataset::vision::CifarDataset; +//! use burn_dataset::vision::CifarType; +//! +//! // Create a CIFAR-100 dataset accessor +//! let dataset = CifarDataset::new(CifarType::Cifar100); +//! +//! // Access training and test sets +//! let train_dataset = dataset.train(); +//! let test_dataset = dataset.test(); +//! ``` + +use std::{path::PathBuf, sync::Mutex}; + +use flate2::read::GzDecoder; +use tar::Archive; + +use crate::network::downloader; +use crate::vision::ImageFolderDataset; + +/// CIFAR-10 mirror from [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L44). +/// Licensed under the [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE). +const CIFAR10_URL: &str = "https://s3.amazonaws.com/fast-ai-sample/cifar10.tgz"; + +/// CIFAR-100 mirror from [fastai](https://github.com/fastai/fastai/blob/master/fastai/data/external.py#L75). +/// Licensed under the [Apache License](https://github.com/fastai/fastai/blob/master/LICENSE). +const CIFAR100_URL: &str = "https://s3.amazonaws.com/fast-ai-imageclas/cifar100.tgz"; + +/// Enum representing the types of CIFAR datasets available. +/// +/// CIFAR (Canadian Institute For Advanced Research) datasets are widely used benchmarks for image classification. +/// This enum provides support for the two main CIFAR datasets. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum CifarType { + /// CIFAR-10 dataset containing 10 classes with 60,000 images in total. + Cifar10, + /// CIFAR-100 dataset containing 100 classes with 60,000 images in total. + Cifar100, +} + +/// CIFAR dataset accessor. +/// +/// This struct provides convenient access to the CIFAR-10 and CIFAR-100 image classification datasets. +/// It automatically downloads (if not already downloaded), extracts, and loads the datasets. +/// +/// All images in CIFAR datasets are 32×32 pixel color images, with 50,000 images in the training set +/// and 10,000 images in the test set. +/// +/// ## Differences between datasets +/// - **CIFAR-10**: Contains 10 mutually exclusive classes such as airplane, automobile, bird, cat, etc. +/// - **CIFAR-100**: Contains 100 fine-grained classes such as beaver, dolphin, etc. +pub struct CifarDataset { + cifar_dir: PathBuf, +} + +impl CifarDataset { + /// Creates a new CIFAR dataset accessor. + /// + /// # Arguments + /// * `cifar_type` - Specifies whether to use CIFAR-10 or CIFAR-100 dataset + pub fn new(cifar_type: CifarType) -> Self { + Self { + cifar_dir: download(&cifar_type), + } + } + + /// Gets the training dataset. + /// + /// # Returns + /// An `ImageFolderDataset` instance containing 50,000 training images + pub fn train(&self) -> ImageFolderDataset { + ImageFolderDataset::new_classification(self.cifar_dir.join("train")).unwrap() + } + + /// Gets the test dataset. + /// + /// # Returns + /// An `ImageFolderDataset` instance containing 10,000 test images + pub fn test(&self) -> ImageFolderDataset { + ImageFolderDataset::new_classification(self.cifar_dir.join("test")).unwrap() + } +} + +/// CIFAR dataset download lock. +/// +/// This lock ensures that only one thread downloads the CIFAR dataset at a time. +static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(()); + +fn download(cifar_type: &CifarType) -> PathBuf { + // Acquire the lock. This will block if another thread already holds the lock. + let _lock = DOWNLOAD_LOCK.lock().unwrap(); + + // Dataset files are stored in the burn-dataset cache directory + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset"); + + // Cifar store directory + let cifar_dir = match cifar_type { + CifarType::Cifar10 => cache_dir.join("cifar10"), + CifarType::Cifar100 => cache_dir.join("cifar100"), + }; + + // Cifar dataset url + let url = match cifar_type { + CifarType::Cifar10 => CIFAR10_URL, + CifarType::Cifar100 => CIFAR100_URL, + }; + + // Cifar dataset archive filename + let filename = match cifar_type { + CifarType::Cifar10 => "cifar10.tgz", + CifarType::Cifar100 => "cifar100.tgz", + }; + + // Check for already downloaded content + if !cifar_dir.exists() { + // Download gzip file + let bytes = downloader::download_file_as_bytes(url, filename); + + // Decode gzip file content and unpack archive + let gz_buffer = GzDecoder::new(&bytes[..]); + let mut archive = Archive::new(gz_buffer); + archive.unpack(cache_dir).unwrap(); + } + + cifar_dir +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Dataset, vision::Annotation}; + + /// CIFAR dataset length + const TRAINDATASET_LEN: usize = 50000; + const TESTDATASET_LEN: usize = 10000; + + /// CIFAR-10 label range + const CIFAR10_LABEL_MIN: usize = 0; + const CIFAR10_LABEL_MAX: usize = 9; + + /// CIFAR-100 label range + const CIFAR100_LABEL_MIN: usize = 0; + const CIFAR100_LABEL_MAX: usize = 99; + + #[test] + fn test_cifar10_download() { + let cifar_dir = download(&CifarType::Cifar10); + assert!(cifar_dir.exists()); + } + + #[test] + fn test_cifar100_download() { + let cifar_dir = download(&CifarType::Cifar100); + assert!(cifar_dir.exists()); + } + + #[test] + fn test_cifar10_len() { + let dataset = CifarDataset::new(CifarType::Cifar10); + let train_dataset = dataset.train(); + let test_dataset = dataset.test(); + assert_eq!(train_dataset.len(), TRAINDATASET_LEN); + assert_eq!(test_dataset.len(), TESTDATASET_LEN); + } + + #[test] + fn test_cifar100_len() { + let dataset = CifarDataset::new(CifarType::Cifar100); + let train_dataset = dataset.train(); + let test_dataset = dataset.test(); + assert_eq!(train_dataset.len(), TRAINDATASET_LEN); + assert_eq!(test_dataset.len(), TESTDATASET_LEN); + } + + #[test] + fn test_cifar10_label_range() { + let dataset = CifarDataset::new(CifarType::Cifar10); + let test_dataset = dataset.test(); + let (min, max) = get_label_range(&test_dataset); + assert_eq!(min, CIFAR10_LABEL_MIN); + assert_eq!(max, CIFAR10_LABEL_MAX); + } + + #[test] + fn test_cifar100_label_range() { + let dataset = CifarDataset::new(CifarType::Cifar100); + let test_dataset = dataset.test(); + let (min, max) = get_label_range(&test_dataset); + assert_eq!(min, CIFAR100_LABEL_MIN); + assert_eq!(max, CIFAR100_LABEL_MAX); + } + + fn get_label_range(dataset: &ImageFolderDataset) -> (usize, usize) { + let labels: Vec<_> = dataset.iter().map(|item| item.annotation).collect(); + let mut min = 128; + let mut max = 0; + for label in labels { + let index = match label { + Annotation::Label(index) => index, + _ => 0, + }; + if index < min { + min = index; + } + if index > max { + max = index; + } + } + + (min, max) + } +} diff --git a/crates/burn-dataset/src/vision/image_folder.rs b/crates/burn-dataset/src/vision/image_folder.rs new file mode 100644 index 0000000..63d0ad5 --- /dev/null +++ b/crates/burn-dataset/src/vision/image_folder.rs @@ -0,0 +1,1124 @@ +use crate::transform::{Mapper, MapperDataset}; +use crate::{Dataset, InMemDataset}; + +use globwalk::{self, DirEntry}; +use image::{self, ColorType}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +const SUPPORTED_FILES: [&str; 4] = ["bmp", "jpg", "jpeg", "png"]; +const BBOX_MIN_NUM_VALUES: usize = 4; + +/// Image data type. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum PixelDepth { + /// 8-bit unsigned. + U8(u8), + /// 16-bit unsigned. + U16(u16), + /// 32-bit floating point. + F32(f32), +} + +impl TryFrom for u8 { + type Error = &'static str; + + fn try_from(value: PixelDepth) -> Result { + if let PixelDepth::U8(v) = value { + Ok(v) + } else { + Err("Value is not u8") + } + } +} + +impl TryFrom for u16 { + type Error = &'static str; + + fn try_from(value: PixelDepth) -> Result { + if let PixelDepth::U16(v) = value { + Ok(v) + } else { + Err("Value is not u16") + } + } +} + +impl TryFrom for f32 { + type Error = &'static str; + + fn try_from(value: PixelDepth) -> Result { + if let PixelDepth::F32(v) = value { + Ok(v) + } else { + Err("Value is not f32") + } + } +} + +/// Annotation type for different tasks. +#[derive(Debug, Clone, PartialEq)] +pub enum Annotation { + /// Image-level label. + Label(usize), + /// Multiple image-level labels. + MultiLabel(Vec), + /// Object bounding boxes. + BoundingBoxes(Vec), + /// Segmentation mask. + SegmentationMask(SegmentationMask), +} + +/// Segmentation mask annotation. +/// For semantic segmentation, a mask has a single channel (C = 1). +/// For instance segmentation, there may be multiple masks per image (C >= 1). +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentationMask { + /// Segmentation mask. + pub mask: Vec, +} + +/// Object detection bounding box annotation. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct BoundingBox { + /// Coordinates in [x_min, y_min, width, height] format. + pub coords: [f32; 4], + + /// Box class label. + pub label: usize, +} + +/// Image dataset item. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageDatasetItem { + /// Image as a vector with a valid image type. + pub image: Vec, + + /// Original source image width. + pub image_width: usize, + + /// Original source image height. + pub image_height: usize, + + /// Annotation for the image. + pub annotation: Annotation, + + /// Original image source. + pub image_path: String, +} + +/// Raw annotation types. +#[derive(Deserialize, Serialize, Debug, Clone)] +enum AnnotationRaw { + Label(String), + MultiLabel(Vec), + BoundingBoxes(Vec), + SegmentationMask(PathBuf), +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct ImageDatasetItemRaw { + /// Image path. + image_path: PathBuf, + + /// Image annotation. + annotation: AnnotationRaw, +} + +impl ImageDatasetItemRaw { + fn new>(image_path: P, annotation: AnnotationRaw) -> ImageDatasetItemRaw { + ImageDatasetItemRaw { + image_path: image_path.as_ref().to_path_buf(), + annotation, + } + } +} + +struct PathToImageDatasetItem { + classes: HashMap, +} + +fn segmentation_mask_to_vec_usize(mask_path: &PathBuf) -> Vec { + // Load image from disk + let image = image::open(mask_path).unwrap(); + + // Image as Vec + // if rgb8 or rgb16, keep only the first channel assuming all channels are the same + + match image.color() { + ColorType::L8 => image.into_luma8().iter().map(|&x| x as usize).collect(), + ColorType::L16 => image.into_luma16().iter().map(|&x| x as usize).collect(), + ColorType::Rgb8 => image + .into_rgb8() + .iter() + .step_by(3) + .map(|&x| x as usize) + .collect(), + ColorType::Rgb16 => image + .into_rgb16() + .iter() + .step_by(3) + .map(|&x| x as usize) + .collect(), + _ => panic!("Unrecognized image color type"), + } +} + +/// Parse the image annotation to the corresponding type. +fn parse_image_annotation( + annotation: &AnnotationRaw, + classes: &HashMap, +) -> Annotation { + // TODO: add support for other annotations + // - [ ] Object bounding boxes + // - [x] Segmentation mask + // For now, only image classification labels and segmentation are supported. + + // Map class string to label id + match annotation { + AnnotationRaw::Label(name) => Annotation::Label(*classes.get(name).unwrap()), + AnnotationRaw::MultiLabel(names) => Annotation::MultiLabel( + names + .iter() + .map(|name| *classes.get(name).unwrap()) + .collect(), + ), + AnnotationRaw::SegmentationMask(mask_path) => { + Annotation::SegmentationMask(SegmentationMask { + mask: segmentation_mask_to_vec_usize(mask_path), + }) + } + AnnotationRaw::BoundingBoxes(v) => Annotation::BoundingBoxes(v.clone()), + } +} + +/// Retrieve all available classes from the COCO JSON +fn parse_coco_classes( + json: &serde_json::Value, +) -> Result, ImageLoaderError> { + let mut classes = HashMap::new(); + + if let Some(json_classes) = json["categories"].as_array() { + for class in json_classes { + let id = class["id"] + .as_u64() + .ok_or_else(|| ImageLoaderError::ParsingError("Invalid class ID".to_string())) + .and_then(|v| { + usize::try_from(v).map_err(|_| { + ImageLoaderError::ParsingError("Class ID out of usize range".to_string()) + }) + })?; + + let name = class["name"] + .as_str() + .filter(|&s| !s.is_empty()) + .ok_or_else(|| ImageLoaderError::ParsingError("Invalid class name".to_string()))? + .to_string(); + + classes.insert(name, id); + } + } + + if classes.is_empty() { + return Err(ImageLoaderError::ParsingError( + "No classes found in annotations".to_string(), + )); + } + + Ok(classes) +} + +/// Retrieve annotations from COCO JSON +fn parse_coco_bbox_annotations( + json: &serde_json::Value, +) -> Result, ImageLoaderError> { + let mut annotations = HashMap::new(); + + if let Some(json_annotations) = json["annotations"].as_array() { + for annotation in json_annotations { + let image_id = annotation["image_id"].as_u64().ok_or_else(|| { + ImageLoaderError::ParsingError("Invalid image ID in annotation".into()) + })?; + + let class_id = annotation["category_id"] + .as_u64() + .ok_or_else(|| { + ImageLoaderError::ParsingError("Invalid class ID in annotations".to_string()) + }) + .and_then(|v| { + usize::try_from(v).map_err(|_| { + ImageLoaderError::ParsingError( + "Class ID in annotations out of usize range".to_string(), + ) + }) + })?; + + let bbox_coords = annotation["bbox"] + .as_array() + .ok_or_else(|| ImageLoaderError::ParsingError("missing bbox array".to_string()))? + .iter() + .map(|v| { + v.as_f64() + .ok_or_else(|| { + ImageLoaderError::ParsingError("invalid bbox value".to_string()) + }) + .map(|val| val as f32) + }) + .collect::, _>>()?; + + if bbox_coords.len() < BBOX_MIN_NUM_VALUES { + return Err(ImageLoaderError::ParsingError(format!( + "not enough bounding box coordinates in annotation for image {image_id}", + ))); + } + + let bbox = BoundingBox { + coords: [ + bbox_coords[0], + bbox_coords[1], + bbox_coords[2], + bbox_coords[3], + ], + label: class_id, + }; + + annotations + .entry(image_id) + .and_modify(|entry| { + if let AnnotationRaw::BoundingBoxes(bboxes) = entry { + bboxes.push(bbox.clone()); + } + }) + .or_insert_with(|| AnnotationRaw::BoundingBoxes(vec![bbox])); + } + } + + if annotations.is_empty() { + return Err(ImageLoaderError::ParsingError( + "no annotations found".to_string(), + )); + } + + Ok(annotations) +} + +/// Retrieve all available images from the COCO JSON +fn parse_coco_images>( + images_path: &P, + mut annotations: HashMap, + json: &serde_json::Value, +) -> Result, ImageLoaderError> { + let mut images = Vec::new(); + if let Some(json_images) = json["images"].as_array() { + for image in json_images { + let image_id = image["id"].as_u64().ok_or_else(|| { + ImageLoaderError::ParsingError("Invalid image ID in image list".to_string()) + })?; + + let file_name = image["file_name"] + .as_str() + .ok_or_else(|| ImageLoaderError::ParsingError("Invalid image ID".to_string()))? + .to_string(); + + let mut image_path = images_path.as_ref().to_path_buf(); + image_path.push(file_name); + + if !image_path.exists() { + return Err(ImageLoaderError::IOError(format!( + "Image {} not found", + image_path.display() + ))); + } + + let annotation = annotations + .remove(&image_id) + .unwrap_or_else(|| AnnotationRaw::BoundingBoxes(Vec::new())); + + images.push(ImageDatasetItemRaw { + annotation, + image_path, + }); + } + } + + if images.is_empty() { + return Err(ImageLoaderError::ParsingError( + "No images found in annotations".to_string(), + )); + } + + Ok(images) +} + +impl Mapper for PathToImageDatasetItem { + /// Convert a raw image dataset item (path-like) to a 3D image array with a target label. + fn map(&self, item: &ImageDatasetItemRaw) -> ImageDatasetItem { + let annotation = parse_image_annotation(&item.annotation, &self.classes); + + // Load image from disk + let image = image::open(&item.image_path).unwrap(); + + // Save image dimensions for manipulation + let img_width = image.width() as usize; + let img_height = image.height() as usize; + + // Image as Vec + let img_vec = match image.color() { + ColorType::L8 => image + .into_luma8() + .iter() + .map(|&x| PixelDepth::U8(x)) + .collect(), + ColorType::La8 => image + .into_luma_alpha8() + .iter() + .map(|&x| PixelDepth::U8(x)) + .collect(), + ColorType::L16 => image + .into_luma16() + .iter() + .map(|&x| PixelDepth::U16(x)) + .collect(), + ColorType::La16 => image + .into_luma_alpha16() + .iter() + .map(|&x| PixelDepth::U16(x)) + .collect(), + ColorType::Rgb8 => image + .into_rgb8() + .iter() + .map(|&x| PixelDepth::U8(x)) + .collect(), + ColorType::Rgba8 => image + .into_rgba8() + .iter() + .map(|&x| PixelDepth::U8(x)) + .collect(), + ColorType::Rgb16 => image + .into_rgb16() + .iter() + .map(|&x| PixelDepth::U16(x)) + .collect(), + ColorType::Rgba16 => image + .into_rgba16() + .iter() + .map(|&x| PixelDepth::U16(x)) + .collect(), + ColorType::Rgb32F => image + .into_rgb32f() + .iter() + .map(|&x| PixelDepth::F32(x)) + .collect(), + ColorType::Rgba32F => image + .into_rgba32f() + .iter() + .map(|&x| PixelDepth::F32(x)) + .collect(), + _ => panic!("Unrecognized image color type"), + }; + + ImageDatasetItem { + image: img_vec, + image_width: img_width, + image_height: img_height, + annotation, + image_path: item.image_path.display().to_string(), + } + } +} + +/// Error type for [ImageFolderDataset](ImageFolderDataset). +#[derive(Error, Debug)] +pub enum ImageLoaderError { + /// Unknown error. + #[error("unknown: `{0}`")] + Unknown(String), + + /// I/O operation error. + #[error("I/O error: `{0}`")] + IOError(String), + + /// Invalid file error. + #[error("Invalid file extension: `{0}`")] + InvalidFileExtensionError(String), + + /// Parsing error. + #[error("Parsing error: `{0}`")] + ParsingError(String), +} + +type ImageDatasetMapper = + MapperDataset, PathToImageDatasetItem, ImageDatasetItemRaw>; + +/// A generic dataset to load images from disk. +pub struct ImageFolderDataset { + dataset: ImageDatasetMapper, +} + +impl Dataset for ImageFolderDataset { + fn get(&self, index: usize) -> Option { + self.dataset.get(index) + } + + fn len(&self) -> usize { + self.dataset.len() + } +} + +impl ImageFolderDataset { + /// Create an image classification dataset from the root folder. + /// + /// # Arguments + /// + /// * `root` - Dataset root folder. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification>(root: P) -> Result { + // New dataset containing any of the supported file types + ImageFolderDataset::new_classification_with(root, &SUPPORTED_FILES) + } + + /// Create an image classification dataset from the root folder. + /// The included images are filtered based on the provided extensions. + /// + /// # Arguments + /// + /// * `root` - Dataset root folder. + /// * `extensions` - List of allowed extensions. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification_with( + root: P, + extensions: &[S], + ) -> Result + where + P: AsRef, + S: AsRef, + { + // Glob all images with extensions + let walker = globwalk::GlobWalkerBuilder::from_patterns( + root.as_ref(), + &[format!( + "*.{{{}}}", // "*.{ext1,ext2,ext3} + extensions + .iter() + .map(Self::check_extension) + .collect::, _>>()? + .join(",") + )], + ) + .follow_links(true) + .sort_by(|p1: &DirEntry, p2: &DirEntry| p1.path().cmp(p2.path())) // order by path + .build() + .map_err(|err| ImageLoaderError::Unknown(format!("{err:?}")))? + .filter_map(Result::ok); + + // Get all dataset items + let mut items = Vec::new(); + let mut classes = HashSet::new(); + for img in walker { + let image_path = img.path(); + + // Label name is represented by the parent folder name + let label = image_path + .parent() + .ok_or_else(|| { + ImageLoaderError::IOError("Could not resolve image parent folder".to_string()) + })? + .file_name() + .ok_or_else(|| { + ImageLoaderError::IOError( + "Could not resolve image parent folder name".to_string(), + ) + })? + .to_string_lossy() + .into_owned(); + + classes.insert(label.clone()); + + items.push(ImageDatasetItemRaw::new( + image_path, + AnnotationRaw::Label(label), + )) + } + + // Sort class names + let mut classes = classes.into_iter().collect::>(); + classes.sort(); + + Self::with_items(items, &classes) + } + + /// Create an image classification dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - List of dataset items, each item represented by a tuple `(image path, label)`. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + pub fn new_classification_with_items, S: AsRef>( + items: Vec<(P, String)>, + classes: &[S], + ) -> Result { + // Parse items and check valid image extension types + let items = items + .into_iter() + .map(|(path, label)| { + // Map image path and label + let path = path.as_ref(); + let label = AnnotationRaw::Label(label); + + Self::check_extension(&path.extension().unwrap().to_str().unwrap())?; + + Ok(ImageDatasetItemRaw::new(path, label)) + }) + .collect::, _>>()?; + + Self::with_items(items, classes) + } + + /// Create a multi-label image classification dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - List of dataset items, each item represented by a tuple `(image path, labels)`. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + pub fn new_multilabel_classification_with_items, S: AsRef>( + items: Vec<(P, Vec)>, + classes: &[S], + ) -> Result { + // Parse items and check valid image extension types + let items = items + .into_iter() + .map(|(path, labels)| { + // Map image path and multi-label + let path = path.as_ref(); + let labels = AnnotationRaw::MultiLabel(labels); + + Self::check_extension(&path.extension().unwrap().to_str().unwrap())?; + + Ok(ImageDatasetItemRaw::new(path, labels)) + }) + .collect::, _>>()?; + + Self::with_items(items, classes) + } + + /// Create an image segmentation dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - List of dataset items, each item represented by a tuple `(image path, annotation path)`. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + pub fn new_segmentation_with_items, S: AsRef>( + items: Vec<(P, P)>, + classes: &[S], + ) -> Result { + // Parse items and check valid image extension types + let items = items + .into_iter() + .map(|(image_path, mask_path)| { + // Map image path and segmentation mask path + let image_path = image_path.as_ref(); + let annotation = AnnotationRaw::SegmentationMask(mask_path.as_ref().to_path_buf()); + + Self::check_extension(&image_path.extension().unwrap().to_str().unwrap())?; + + Ok(ImageDatasetItemRaw::new(image_path, annotation)) + }) + .collect::, _>>()?; + + Self::with_items(items, classes) + } + + /// Create a COCO detection dataset based on the annotations JSON and image directory. + /// + /// # Arguments + /// + /// * `annotations_json` - Path to the JSON file containing annotations in COCO format (for + /// example instances_train2017.json). + /// + /// * `images_path` - Path containing the images matching the annotations JSON. + /// + /// # Returns + /// A new dataset instance. + pub fn new_coco_detection, I: AsRef>( + annotations_json: A, + images_path: I, + ) -> Result { + let file = fs::File::open(annotations_json) + .map_err(|e| ImageLoaderError::IOError(format!("Failed to open annotations: {e}")))?; + let json: Value = serde_json::from_reader(file).map_err(|e| { + ImageLoaderError::ParsingError(format!("Failed to parse annotations: {e}")) + })?; + + let classes = parse_coco_classes(&json)?; + let annotations = parse_coco_bbox_annotations(&json)?; + let items = parse_coco_images(&images_path, annotations, &json)?; + let dataset = InMemDataset::new(items); + let mapper = PathToImageDatasetItem { classes }; + let dataset = MapperDataset::new(dataset, mapper); + + Ok(Self { dataset }) + } + + /// Create an image dataset with the specified items. + /// + /// # Arguments + /// + /// * `items` - Raw dataset items. + /// * `classes` - Dataset class names. + /// + /// # Returns + /// A new dataset instance. + fn with_items>( + items: Vec, + classes: &[S], + ) -> Result { + // NOTE: right now we don't need to validate the supported image files since + // the method is private. We assume it's already validated. + let dataset = InMemDataset::new(items); + + // Class names to index map + let classes = classes.iter().map(|c| c.as_ref()).collect::>(); + let classes_map: HashMap<_, _> = classes + .into_iter() + .enumerate() + .map(|(idx, cls)| (cls.to_string(), idx)) + .collect(); + + let mapper = PathToImageDatasetItem { + classes: classes_map, + }; + let dataset = MapperDataset::new(dataset, mapper); + + Ok(Self { dataset }) + } + + /// Check if extension is supported. + fn check_extension>(extension: &S) -> Result { + let extension = extension.as_ref(); + if !SUPPORTED_FILES.contains(&extension) { + Err(ImageLoaderError::InvalidFileExtensionError( + extension.to_string(), + )) + } else { + Ok(extension.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + const DATASET_ROOT: &str = "tests/data/image_folder"; + const SEGMASK_ROOT: &str = "tests/data/segmask_folder"; + const COCO_JSON: &str = "tests/data/dataset_coco.json"; + const COCO_IMAGES: &str = "tests/data/image_folder_coco"; + + #[test] + pub fn image_folder_dataset() { + let dataset = ImageFolderDataset::new_classification(DATASET_ROOT).unwrap(); + + // Dataset has 3 elements + assert_eq!(dataset.len(), 3); + assert_eq!(dataset.get(3), None); + + // Dataset elements should be: orange (0), red (1), red (1) + assert_eq!(dataset.get(0).unwrap().annotation, Annotation::Label(0)); + assert_eq!(dataset.get(1).unwrap().annotation, Annotation::Label(1)); + assert_eq!(dataset.get(2).unwrap().annotation, Annotation::Label(1)); + } + + #[test] + pub fn image_folder_dataset_filtered() { + let dataset = ImageFolderDataset::new_classification_with(DATASET_ROOT, &["jpg"]).unwrap(); + + // Filtered dataset has 2 elements + assert_eq!(dataset.len(), 2); + assert_eq!(dataset.get(2), None); + + // Dataset elements should be: orange (0), red (1) + assert_eq!(dataset.get(0).unwrap().annotation, Annotation::Label(0)); + assert_eq!(dataset.get(1).unwrap().annotation, Annotation::Label(1)); + } + + #[test] + pub fn image_folder_dataset_with_items_sizes() { + let root = Path::new(DATASET_ROOT); + let items = vec![ + (root.join("orange").join("dot.jpg"), "orange".to_string()), + (root.join("red").join("dot.jpg"), "red".to_string()), + (root.join("red").join("dot.png"), "red".to_string()), + ]; + let dataset = + ImageFolderDataset::new_classification_with_items(items, &["orange", "red"]).unwrap(); + + // Dataset has 3 elements + assert_eq!(dataset.len(), 3); + assert_eq!(dataset.get(3), None); + + // Test item sizes + + assert_eq!( + ( + dataset.get(0).unwrap().image_width, + dataset.get(0).unwrap().image_height + ), + (1, 1) + ); + assert_eq!( + ( + dataset.get(1).unwrap().image_width, + dataset.get(1).unwrap().image_height + ), + (1, 1) + ); + assert_eq!( + ( + dataset.get(2).unwrap().image_width, + dataset.get(2).unwrap().image_height + ), + (1, 1) + ); + } + + #[test] + pub fn image_folder_dataset_with_items() { + let root = Path::new(DATASET_ROOT); + let items = vec![ + (root.join("orange").join("dot.jpg"), "orange".to_string()), + (root.join("red").join("dot.jpg"), "red".to_string()), + (root.join("red").join("dot.png"), "red".to_string()), + ]; + let dataset = + ImageFolderDataset::new_classification_with_items(items, &["orange", "red"]).unwrap(); + + // Dataset has 3 elements + assert_eq!(dataset.len(), 3); + assert_eq!(dataset.get(3), None); + + // Dataset elements should be: orange (0), red (1), red (1) + assert_eq!(dataset.get(0).unwrap().annotation, Annotation::Label(0)); + assert_eq!(dataset.get(1).unwrap().annotation, Annotation::Label(1)); + assert_eq!(dataset.get(2).unwrap().annotation, Annotation::Label(1)); + } + + #[test] + pub fn image_folder_dataset_multilabel() { + let root = Path::new(DATASET_ROOT); + let items = vec![ + ( + root.join("orange").join("dot.jpg"), + vec!["dot".to_string(), "orange".to_string()], + ), + ( + root.join("red").join("dot.jpg"), + vec!["dot".to_string(), "red".to_string()], + ), + ( + root.join("red").join("dot.png"), + vec!["dot".to_string(), "red".to_string()], + ), + ]; + let dataset = ImageFolderDataset::new_multilabel_classification_with_items( + items, + &["dot", "orange", "red"], + ) + .unwrap(); + + // Dataset has 3 elements + assert_eq!(dataset.len(), 3); + assert_eq!(dataset.get(3), None); + + // Dataset elements should be: [dot, orange] (0, 1), [dot, red] (0, 2), [dot, red] (0, 2) + assert_eq!( + dataset.get(0).unwrap().annotation, + Annotation::MultiLabel(vec![0, 1]) + ); + assert_eq!( + dataset.get(1).unwrap().annotation, + Annotation::MultiLabel(vec![0, 2]) + ); + assert_eq!( + dataset.get(2).unwrap().annotation, + Annotation::MultiLabel(vec![0, 2]) + ); + } + + #[test] + #[should_panic] + pub fn image_folder_dataset_invalid_extension() { + // Some invalid file extension + let _ = ImageFolderDataset::new_classification_with(DATASET_ROOT, &["ico"]).unwrap(); + } + + #[test] + pub fn pixel_depth_try_into_u8() { + let val = u8::MAX; + let pix: u8 = PixelDepth::U8(val).try_into().unwrap(); + assert_eq!(pix, val); + } + + #[test] + #[should_panic] + pub fn pixel_depth_try_into_u8_invalid() { + let _: u8 = PixelDepth::U16(u8::MAX as u16 + 1).try_into().unwrap(); + } + + #[test] + pub fn pixel_depth_try_into_u16() { + let val = u16::MAX; + let pix: u16 = PixelDepth::U16(val).try_into().unwrap(); + assert_eq!(pix, val); + } + + #[test] + #[should_panic] + pub fn pixel_depth_try_into_u16_invalid() { + let _: u16 = PixelDepth::F32(u16::MAX as f32).try_into().unwrap(); + } + + #[test] + pub fn pixel_depth_try_into_f32() { + let val = f32::MAX; + let pix: f32 = PixelDepth::F32(val).try_into().unwrap(); + assert_eq!(pix, val); + } + + #[test] + #[should_panic] + pub fn pixel_depth_try_into_f32_invalid() { + let _: f32 = PixelDepth::U16(u16::MAX).try_into().unwrap(); + } + + #[test] + pub fn parse_image_annotation_label_string() { + let classes = HashMap::from([("0".to_string(), 0_usize), ("1".to_string(), 1_usize)]); + let anno = AnnotationRaw::Label("0".to_string()); + assert_eq!( + parse_image_annotation(&anno, &classes), + Annotation::Label(0) + ); + } + + #[test] + pub fn parse_image_annotation_multilabel_string() { + let classes = HashMap::from([ + ("0".to_string(), 0_usize), + ("1".to_string(), 1_usize), + ("2".to_string(), 2_usize), + ]); + let anno = AnnotationRaw::MultiLabel(vec!["0".to_string(), "2".to_string()]); + assert_eq!( + parse_image_annotation(&anno, &classes), + Annotation::MultiLabel(vec![0, 2]) + ); + } + + #[test] + pub fn segmask_image_path_to_vec_usize() { + let root = Path::new(SEGMASK_ROOT); + + // checkerboard mask + const TEST_CHECKERBOARD_MASK_PATTERN: [u8; 64] = [ + 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, + 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, + 2, 1, 2, 1, 2, 1, + ]; + assert_eq!( + TEST_CHECKERBOARD_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect::>(), + segmentation_mask_to_vec_usize(&root.join("annotations").join("mask_checkerboard.png")), + ); + + // random 2 colors mask + const TEST_RANDOM2COLORS_MASK_PATTERN: [u8; 64] = [ + 1, 2, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 2, 2, 2, 1, 2, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 1, 1, 2, 2, 2, 1, 2, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 2, 1, + 1, 1, 1, 1, 1, 1, + ]; + assert_eq!( + TEST_RANDOM2COLORS_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect::>(), + segmentation_mask_to_vec_usize( + &root.join("annotations").join("mask_random_2colors.png") + ), + ); + // random 3 colors mask + const TEST_RANDOM3COLORS_MASK_PATTERN: [u8; 64] = [ + 3, 1, 3, 3, 1, 1, 3, 2, 3, 3, 3, 3, 1, 3, 2, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1, 1, 3, 3, + 3, 2, 3, 2, 2, 3, 2, 3, 3, 1, 3, 1, 3, 3, 1, 1, 3, 2, 1, 2, 2, 2, 1, 2, 1, 2, 3, 3, 1, + 3, 3, 2, 1, 2, 2, + ]; + assert_eq!( + TEST_RANDOM3COLORS_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect::>(), + segmentation_mask_to_vec_usize( + &root.join("annotations").join("mask_random_3colors.png") + ), + ); + } + + #[test] + pub fn segmask_folder_dataset() { + let root = Path::new(SEGMASK_ROOT); + + let items = vec![ + ( + root.join("images").join("image_checkerboard.png"), + root.join("annotations").join("mask_checkerboard.png"), + ), + ( + root.join("images").join("image_random_2colors.png"), + root.join("annotations").join("mask_random_2colors.png"), + ), + ( + root.join("images").join("image_random_3colors.png"), + root.join("annotations").join("mask_random_3colors.png"), + ), + ]; + let dataset = ImageFolderDataset::new_segmentation_with_items( + items, + &[ + "foo", // 0 + "bar", // 1 + "baz", // 2 + "qux", // 3 + ], + ) + .unwrap(); + + // Dataset has 3 elements; each (image, annotation) is a single item + assert_eq!(dataset.len(), 3); + assert_eq!(dataset.get(3), None); + + // checkerboard mask + const TEST_CHECKERBOARD_MASK_PATTERN: [u8; 64] = [ + 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, + 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, + 2, 1, 2, 1, 2, 1, + ]; + assert_eq!( + dataset.get(0).unwrap().annotation, + Annotation::SegmentationMask(SegmentationMask { + mask: TEST_CHECKERBOARD_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect() + }) + ); + // random 2 colors mask + const TEST_RANDOM2COLORS_MASK_PATTERN: [u8; 64] = [ + 1, 2, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 2, 2, 2, 1, 2, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 1, 1, 2, 2, 2, 1, 2, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2, 2, 1, + 1, 1, 1, 1, 1, 1, + ]; + assert_eq!( + dataset.get(1).unwrap().annotation, + Annotation::SegmentationMask(SegmentationMask { + mask: TEST_RANDOM2COLORS_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect() + }) + ); + // random 3 colors mask + const TEST_RANDOM3COLORS_MASK_PATTERN: [u8; 64] = [ + 3, 1, 3, 3, 1, 1, 3, 2, 3, 3, 3, 3, 1, 3, 2, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1, 1, 3, 3, + 3, 2, 3, 2, 2, 3, 2, 3, 3, 1, 3, 1, 3, 3, 1, 1, 3, 2, 1, 2, 2, 2, 1, 2, 1, 2, 3, 3, 1, + 3, 3, 2, 1, 2, 2, + ]; + assert_eq!( + dataset.get(2).unwrap().annotation, + Annotation::SegmentationMask(SegmentationMask { + mask: TEST_RANDOM3COLORS_MASK_PATTERN + .iter() + .map(|&x| x as usize) + .collect() + }) + ); + } + + #[test] + pub fn coco_detection_dataset() { + let dataset = ImageFolderDataset::new_coco_detection(COCO_JSON, COCO_IMAGES).unwrap(); + assert_eq!(dataset.len(), 3); // we have only three images defined + assert_eq!(dataset.get(3), None); + + const TWO_DOTS_AND_TRIANGLE_B1: BoundingBox = BoundingBox { + coords: [3.125_172, 18.090_784, 10.960_11, 10.740_027], + label: 0, + }; + + const TWO_DOTS_AND_TRIANGLE_B2: BoundingBox = BoundingBox { + coords: [3.257_221_5, 3.037_139, 10.563_961, 10.828_06], + label: 0, + }; + + const TWO_DOTS_AND_TRIANGLE_B3: BoundingBox = BoundingBox { + coords: [15.097_662, 3.389_271, 12.632_737, 11.180_193], + label: 1, + }; + + const DOTS_TRIANGLE_B1: BoundingBox = BoundingBox { + coords: [3.125_172, 17.914_719, 10.828_06, 11.004_127], + label: 0, + }; + + const DOTS_TRIANGLE_B2: BoundingBox = BoundingBox { + coords: [15.273_727, 3.301_238, 12.192_573, 11.708_39], + label: 1, + }; + + const ONE_DOT_B1: BoundingBox = BoundingBox { + coords: [10.079_78, 9.595_598, 10.960_11, 11.356_258], + label: 0, + }; + + for item in dataset.iter() { + let file_name = Path::new(&item.image_path).file_name().unwrap(); + match item.annotation { + // check if the number of bounding boxes is correct + Annotation::BoundingBoxes(v) => { + if file_name == "two_dots_and_triangle.jpg" { + assert_eq!(v.len(), 3); + assert!(v.contains(&TWO_DOTS_AND_TRIANGLE_B1)); + assert!(v.contains(&TWO_DOTS_AND_TRIANGLE_B2)); + assert!(v.contains(&TWO_DOTS_AND_TRIANGLE_B3)); + } else if file_name == "dot_triangle.jpg" { + assert_eq!(v.len(), 2); + assert!(v.contains(&DOTS_TRIANGLE_B1)); + assert!(v.contains(&DOTS_TRIANGLE_B2)); + } else if file_name == "one_dot.jpg" { + assert_eq!(v.len(), 1); + assert!(v.contains(&ONE_DOT_B1)); + } else { + panic!("{}", format!("unexpected image name: {}", item.image_path)); + } + } + _ => panic!("unexpected annotation"), + } + } + } +} diff --git a/crates/burn-dataset/src/vision/mnist.rs b/crates/burn-dataset/src/vision/mnist.rs new file mode 100644 index 0000000..909c779 --- /dev/null +++ b/crates/burn-dataset/src/vision/mnist.rs @@ -0,0 +1,221 @@ +use std::fs::{File, create_dir_all}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; + +use crate::{ + Dataset, InMemDataset, + transform::{Mapper, MapperDataset}, +}; + +use crate::network::downloader::download_file_as_bytes; + +// CVDF mirror of http://yann.lecun.com/exdb/mnist/ +const URL: &str = "https://storage.googleapis.com/cvdf-datasets/mnist/"; +const TRAIN_IMAGES: &str = "train-images-idx3-ubyte"; +const TRAIN_LABELS: &str = "train-labels-idx1-ubyte"; +const TEST_IMAGES: &str = "t10k-images-idx3-ubyte"; +const TEST_LABELS: &str = "t10k-labels-idx1-ubyte"; + +const WIDTH: usize = 28; +const HEIGHT: usize = 28; + +/// MNIST item. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct MnistItem { + /// Image as a 2D array of floats. + pub image: [[f32; WIDTH]; HEIGHT], + + /// Label of the image. + pub label: u8, +} + +#[derive(Deserialize, Debug, Clone)] +struct MnistItemRaw { + pub image_bytes: Vec, + pub label: u8, +} + +struct BytesToImage; + +impl Mapper for BytesToImage { + /// Convert a raw MNIST item (image bytes) to a MNIST item (2D array image). + fn map(&self, item: &MnistItemRaw) -> MnistItem { + // Ensure the image dimensions are correct. + debug_assert_eq!(item.image_bytes.len(), WIDTH * HEIGHT); + + // Convert the image to a 2D array of floats. + let mut image_array = [[0f32; WIDTH]; HEIGHT]; + for (i, pixel) in item.image_bytes.iter().enumerate() { + let x = i % WIDTH; + let y = i / HEIGHT; + image_array[y][x] = *pixel as f32; + } + + MnistItem { + image: image_array, + label: item.label, + } + } +} + +type MappedDataset = MapperDataset, BytesToImage, MnistItemRaw>; + +/// The MNIST dataset consists of 70,000 28x28 black-and-white images in 10 classes (one for each digits), with 7,000 +/// images per class. There are 60,000 training images and 10,000 test images. +/// +/// The data is downloaded from the web from the [CVDF mirror](https://github.com/cvdfoundation/mnist). +pub struct MnistDataset { + dataset: MappedDataset, +} + +impl Dataset for MnistDataset { + fn get(&self, index: usize) -> Option { + self.dataset.get(index) + } + + fn len(&self) -> usize { + self.dataset.len() + } +} + +impl MnistDataset { + /// Creates a new train dataset. + pub fn train() -> Self { + Self::new("train") + } + + /// Creates a new test dataset. + pub fn test() -> Self { + Self::new("test") + } + + fn new(split: &str) -> Self { + // Download dataset + let root = MnistDataset::download(split); + + // MNIST is tiny so we can load it in-memory + // Train images (u8): 28 * 28 * 60000 = 47.04Mb + // Test images (u8): 28 * 28 * 10000 = 7.84Mb + let images = MnistDataset::read_images(&root, split); + let labels = MnistDataset::read_labels(&root, split); + + // Collect as vector of MnistItemRaw + let items: Vec<_> = images + .into_iter() + .zip(labels) + .map(|(image_bytes, label)| MnistItemRaw { image_bytes, label }) + .collect(); + + let dataset = InMemDataset::new(items); + let dataset = MapperDataset::new(dataset, BytesToImage); + + Self { dataset } + } + + /// Download the MNIST dataset files from the web. + /// Panics if the download cannot be completed or the content of the file cannot be written to disk. + fn download(split: &str) -> PathBuf { + // Dataset files are stored in the burn-dataset cache directory + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset"); + let split_dir = cache_dir.join("mnist").join(split); + + if !split_dir.exists() { + create_dir_all(&split_dir).expect("Failed to create base directory"); + } + + // Download split files + match split { + "train" => { + MnistDataset::download_file(TRAIN_IMAGES, &split_dir); + MnistDataset::download_file(TRAIN_LABELS, &split_dir); + } + "test" => { + MnistDataset::download_file(TEST_IMAGES, &split_dir); + MnistDataset::download_file(TEST_LABELS, &split_dir); + } + _ => panic!("Invalid split specified {split}"), + }; + + split_dir + } + + /// Download a file from the MNIST dataset URL to the destination directory. + /// File download progress is reported with the help of a [progress bar](indicatif). + fn download_file>(name: &str, dest_dir: &P) -> PathBuf { + // Output file name + let file_name = dest_dir.as_ref().join(name); + + if !file_name.exists() { + // Download gzip file + let bytes = download_file_as_bytes(&format!("{URL}{name}.gz"), name); + + // Create file to write the downloaded content to + let mut output_file = File::create(&file_name).unwrap(); + + // Decode gzip file content and write to disk + let mut gz_buffer = GzDecoder::new(&bytes[..]); + std::io::copy(&mut gz_buffer, &mut output_file).unwrap(); + } + + file_name + } + + /// Read images at the provided path for the specified split. + /// Each image is a vector of bytes. + fn read_images>(root: &P, split: &str) -> Vec> { + let file_name = if split == "train" { + TRAIN_IMAGES + } else { + TEST_IMAGES + }; + let file_name = root.as_ref().join(file_name); + + // Read number of images from 16-byte header metadata + let mut f = File::open(file_name).unwrap(); + let mut buf = [0u8; 4]; + let _ = f.seek(SeekFrom::Start(4)).unwrap(); + f.read_exact(&mut buf) + .expect("Should be able to read image file header"); + let size = u32::from_be_bytes(buf); + + let mut buf_images: Vec = vec![0u8; WIDTH * HEIGHT * (size as usize)]; + let _ = f.seek(SeekFrom::Start(16)).unwrap(); + f.read_exact(&mut buf_images) + .expect("Should be able to read image file header"); + + buf_images + .chunks(WIDTH * HEIGHT) + .map(|chunk| chunk.to_vec()) + .collect() + } + + /// Read labels at the provided path for the specified split. + fn read_labels>(root: &P, split: &str) -> Vec { + let file_name = if split == "train" { + TRAIN_LABELS + } else { + TEST_LABELS + }; + let file_name = root.as_ref().join(file_name); + + // Read number of labels from 8-byte header metadata + let mut f = File::open(file_name).unwrap(); + let mut buf = [0u8; 4]; + let _ = f.seek(SeekFrom::Start(4)).unwrap(); + f.read_exact(&mut buf) + .expect("Should be able to read label file header"); + let size = u32::from_be_bytes(buf); + + let mut buf_labels: Vec = vec![0u8; size as usize]; + let _ = f.seek(SeekFrom::Start(8)).unwrap(); + f.read_exact(&mut buf_labels) + .expect("Should be able to read labels from file"); + + buf_labels + } +} diff --git a/crates/burn-dataset/src/vision/mod.rs b/crates/burn-dataset/src/vision/mod.rs new file mode 100644 index 0000000..948eb0b --- /dev/null +++ b/crates/burn-dataset/src/vision/mod.rs @@ -0,0 +1,9 @@ +#[cfg(feature = "builtin-sources")] +mod cifar; +mod image_folder; +mod mnist; + +#[cfg(feature = "builtin-sources")] +pub use cifar::*; +pub use image_folder::*; +pub use mnist::*; diff --git a/crates/burn-dataset/tests/data/dataset-fmt.csv b/crates/burn-dataset/tests/data/dataset-fmt.csv new file mode 100644 index 0000000..74a155b --- /dev/null +++ b/crates/burn-dataset/tests/data/dataset-fmt.csv @@ -0,0 +1,2 @@ +HI1 1 true 1.0 +HI2 1 false 1.0 diff --git a/crates/burn-dataset/tests/data/dataset.csv b/crates/burn-dataset/tests/data/dataset.csv new file mode 100644 index 0000000..2ec5fe8 --- /dev/null +++ b/crates/burn-dataset/tests/data/dataset.csv @@ -0,0 +1,3 @@ +column_str,column_int,column_bool,column_float +HI1,1,true,1.0 +HI2,1,false,1.0 diff --git a/crates/burn-dataset/tests/data/dataset.json b/crates/burn-dataset/tests/data/dataset.json new file mode 100644 index 0000000..a04eaa2 --- /dev/null +++ b/crates/burn-dataset/tests/data/dataset.json @@ -0,0 +1,2 @@ +{"column_str":"HI1","column_bytes":[1,2,3,3],"column_int":1,"column_bool":true,"column_float":1.0} +{"column_str":"HI2","column_bytes":[1,2,3,3],"column_int":1,"column_bool":false,"column_float":1.0} \ No newline at end of file diff --git a/crates/burn-dataset/tests/data/dataset_coco.json b/crates/burn-dataset/tests/data/dataset_coco.json new file mode 100644 index 0000000..6a75bf9 --- /dev/null +++ b/crates/burn-dataset/tests/data/dataset_coco.json @@ -0,0 +1,132 @@ +{ + "images": [ + { + "width": 32, + "height": 32, + "id": 0, + "file_name": "two_dots_and_triangle.jpg" + }, + { + "width": 32, + "height": 32, + "id": 1, + "file_name": "dot_triangle.jpg" + }, + { + "width": 32, + "height": 32, + "id": 2, + "file_name": "one_dot.jpg" + } + ], + "categories": [ + { + "id": 0, + "name": "dot" + }, + { + "id": 1, + "name": "triangle" + } + ], + "annotations": [ + { + "id": 0, + "image_id": 0, + "category_id": 0, + "segmentation": [], + "bbox": [ + 3.1251719394773056, + 18.0907840440165, + 10.96011004126548, + 10.740027510316379 + ], + "ignore": 0, + "iscrowd": 0, + "area": 117.71188335928603 + }, + { + "id": 1, + "image_id": 0, + "category_id": 0, + "segmentation": [], + "bbox": [ + 3.2572214580467658, + 3.0371389270976605, + 10.563961485557085, + 10.828060522696012 + ], + "ignore": 0, + "iscrowd": 0, + "area": 114.38721432504178 + }, + { + "id": 2, + "image_id": 0, + "category_id": 1, + "segmentation": [], + "bbox": [ + 15.097661623108666, + 3.3892709766162312, + 12.632737276478679, + 11.18019257221458 + ], + "ignore": 0, + "iscrowd": 0, + "area": 141.23643546522516 + }, + { + "id": 3, + "image_id": 1, + "category_id": 0, + "segmentation": [], + "bbox": [ + 3.125171939477304, + 17.914718019257222, + 10.82806052269601, + 11.004126547455297 + ], + "ignore": 0, + "iscrowd": 0, + "area": 119.15334825525184 + }, + { + "id": 4, + "image_id": 1, + "category_id": 1, + "segmentation": [], + "bbox": [ + 15.27372764786794, + 3.301237964236589, + 12.192572214580478, + 11.708390646492433 + ], + "ignore": 0, + "iscrowd": 0, + "area": 142.7553984738776 + }, + { + "id": 5, + "image_id": 2, + "category_id": 0, + "segmentation": [], + "bbox": [ + 10.07977991746905, + 9.59559834938102, + 10.960110041265464, + 11.356258596973863 + ], + "ignore": 0, + "iscrowd": 0, + "area": 124.46584387990049 + } + ], + "info": { + "year": 2024, + "version": "1.0", + "description": "", + "contributor": "", + "url": "", + "date_created": "2024-12-11 22:16:31.823494" + } +} diff --git a/crates/burn-dataset/tests/data/image_folder/orange/dot.jpg b/crates/burn-dataset/tests/data/image_folder/orange/dot.jpg new file mode 100755 index 0000000000000000000000000000000000000000..3629f7fd4b40fdc5451390dc885715c4cae1f855 GIT binary patch literal 727 zcmex=}T+5Ri6|E+FFJVCMj-APxLK zz#zy0bSxt?qY?v?AS1INnAuRebI{N?Mn?>~P20{M%Pff?d0xX;l1B?$Bv6EF@~*g^hcWGV+@WL##!?#l!i}s%{{wrhu|0V$3_|6dk literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/image_folder/red/dot.jpg b/crates/burn-dataset/tests/data/image_folder/red/dot.jpg new file mode 100755 index 0000000000000000000000000000000000000000..be19ad3337777765edd5d303537de35c0342fe09 GIT binary patch literal 634 zcmex= zRY=j$kxe)-kzJ`!#HexNLJno8jR!@8E`CrkPAY2R|V^&07y2J$~}^+4C1KUw!=a z`ODXD-+%o41@ado12e>1aG#<1OAzQUCSV+}u!H=?$W#u*%z`YeiiT`Lj)Clng~Cck zjT|CQ6Blkg$f;}`^g%SK=pvVxipfLOk07sseMX$en#l4Q++zrT-D2QjW&}navmk># R!_ReH8tV_7|JLyTCIFvl#0&rc literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/image_folder/red/dot.png b/crates/burn-dataset/tests/data/image_folder/red/dot.png new file mode 100755 index 0000000000000000000000000000000000000000..93d9dd10f4adb5ad36411ce240abbde6766b1d54 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}K#X;^)4C~IxyaaMs(j9#r85lP9 zbN@+X1@buyJR*x382Ao@Fyrz36)8YLUQZXt5Q*^Q3hlL@iZ9ae4Pzd+~kx+GMu~Jj}lp;*P3=@*TBj4i-2aBA}N-S#Eegb6ze$3W#ElT_M zfTg}6>;o8u!iBzIC_?xd!ueg1poX^-mVM2hdcyk&+d@LCB%C z)pqJlCag3qz}^Me*BtT_CyzLp_5J|mXKQA0y@sFDu)nQ?YST^Y(A*GMw}zgX^h-x6 z99RV>6nN2r7PKLPY3S!ryrgP@j}$Ah9)5UqJ+vJVhirbf;l>AZ zbr)kU2Kb*<^^fI%@G3CftEzuRRdxCufV%(;2IlqacLNVkk-vA|m$?gA(hVFrG4J!f z4h(DtQqBiGA&>q)9NpQ*M&RnZfTb2l7zX0%X6HsREsi~BfweTN%G1D(H-NjFD7R~o zzKtZ9o7;bKZp=>|gB={pY8RP-F5bxV1_K`_2r));oY`!OGnp*$R?!kK#hXlGk|-s} ziHV8kCCMpCa*9<>l(i%bM;Zek%k#0a#blBHx2eY=8EH)l$4Fo$hLafe1X5@U+FP{1 zVvM{%5&0i(VyL4qa|~FK6Y2c+<5{WDWZNci&ErZz+R*(RsnSJIrwgTgj6q~LAu);6 zMhY-I8!b<$*mUseSeMxOK*921>&(|(#lIaLiTtp!`?c%ey>ivn?$!#BZmo;nEKIEds5=_!fBV8%UPJ5U$q%=bk5A>*_VgVI)LrhEOSblxu5fN^{bt7_DyT6RzH4v zX^fOn1Tm+|)2Fm=x;dwUjM2K^?>aoOt9xehz-M2c={WU%=Uay+He5*)D^G3hy!X{)HI38{KNT_(odgoc;aR0QQzgAavtm5R@FE6KBY|+hM nweK7-Kb9R`_UVvgyIXpXJ!&p9UdaF0R<-U6`h>bV)boD>$RbUE literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/image_folder_coco/one_dot.jpg b/crates/burn-dataset/tests/data/image_folder_coco/one_dot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b719f53061cd3b2cfc5b71a7a2244a91df02aefd GIT binary patch literal 1434 zcmb7DeQXnT7=G?|y;qoL6NO)ZLN9OQd9k&^n5rmR# z3i)X_g|OPT2uBv-XnQhFIh!b_G!{=#ebzjOV>*6W$MMc?n$0kMLt9H?=MH+8(p`dj z1ki{eR7B8?4s;@o3VJ8VlR}8%zGe8u20uuw2(da4CC1a(k2t~xANm&3n1UCOp{(-Eo^aPOK4~&jweUbNo zvu^-J6J6nC*yzL4leM-2zYPQSU4Zi&!1DZ@ZWPnw82S!qBCQ%Sz_H`Nnl`GvXPLdt zL|D*m#hiKlv>Wh)XH37yOmqomK`@yFizw!pZ5Er&YOz}Fc@D{*C+AtMQh_8p6_?9p z%P%Y{P>LLiOVNvPJaJ4yt{~(pcB@_a-=MdWIX~37%v&^|tl7 zdd^&F9=rN@z*OO#dhxG6XOlEci9{mMstc{a$+d7x>W1wPR;_X0Q6p)26e<%iRhZ0KoxhtiRN>9s)V3cKoJNdF~OU$2bDZOVU6Q330|@fmL8x&}D-{v2b5JW?+G3iL{(Z zErSw;RMcV=)Paa*=mAdFq@s;A6AcInBsEUdSVYiZDQS2+cM-vvpy|wgd~?oszW4Vx3SsT+|E zK%Oi#7nOAUKwr3^wIJ=^ z0}6|^%nU%NFdSztu{#iNM;u+@C=u{c#GEv#KAAOxEdqW~z(s}SSew&?4&_!`=9Bmi#(OCw zffmw04=ON0ITS!4I3OI~HuRK%0pke^oLoOSp{Imj15o>MPPL1gRlq4L7SUx zS?-!hr;Cs$0MOs_{H+iG@tXkC2YLQpBhODa0Z_vL7i@F+#U}w`{zU)5IbZMz0QbEB zotNi)rh0(0`v81~N{n`+a~%qwWNt3NWHW$b3xG!tfNS>5+%P7@;nV;?2F@zA8{pV+ zfMt28y={TMC7AGVZi{jr%#Xhg8lXs0xX?s+(Gr>#iD*}`*hM0DmCI$WGMU0n$tc`d zH<^s_VptE()6-M#?(O5n`6xM0P7om|%n{L2nwD}3nS%S@#&-cL!EI7e1PdffP%Ob; z1|OV)@D`myP(_DDq}j7P@o^y^CgtK0ue^OYQ-D)y0Lv8XtwD6Xd_#ETHfS;BG#NP;576g}HBL6IU> z2{hvousSk5zO*(lD8XQSP^*iYG#S2VOz}2bRvoC>s?&`;oznMOeob`!?k93Ol(FBe z8nKm4zc;paeMd-t2e?QoWR9R8f8joAIbPr$8?bWx(7By2jGoAQrzRpJY_n)OseiO7 zd!^wp_qASG6T?H)?*rV`zRE1)aB+|Op3|9z&unKbQxhoQf zp1*mzr77xA>pEI$-LWI|C2ZB-IqCVMCfERoXI7^hYfE3AowYh3g02ba88S}&bYahW z4`a{7CfzR0J+<^k`kFxPVDrU^6~3*3fpri*5qc-B{JXpFHdNOaT=%C2S}uydstPq7 z2yJWMbtWQ=47~O+N(Bt|hTIjT*tn~|7OQa?=b3PROiX2*N|_wI$TkT3#QfJjISES{ zJSp>=1MCi)GeYCT)|k|;sr|6A=dle2Ye((&>b6mlrpZdamCLk#;;rB!H;IeuqQ;oI zx`wJ_)=Kt{Rr<`bB;8u2-+=hT(fggd$Fq~q>n4AjOnl?jBl=6HO5Z*!j}31O>bN33 an!~n`=j6$f;ph*-(lbBB-}$yue&`==a*EXe literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.png b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.png new file mode 100644 index 0000000000000000000000000000000000000000..3c87252ebe032f7f28544c96150a30651e7f55f4 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYi^8&4O<5Dr<@gN>XF1_I2AdcWW2 z$giqh5wLJGuc%hY3+Gm@xw$}|lRS`<1LTSR0&>!&wL(^WV7O!(qN_G@GcV8_22WQ% Jmvv4FO#oZUApZaW literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.txt b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.txt new file mode 100644 index 0000000..2e01635 --- /dev/null +++ b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_checkerboard.txt @@ -0,0 +1,8 @@ +1 2 1 2 1 2 1 2 +2 1 2 1 2 1 2 1 +1 2 1 2 1 2 1 2 +2 1 2 1 2 1 2 1 +1 2 1 2 1 2 1 2 +2 1 2 1 2 1 2 1 +1 2 1 2 1 2 1 2 +2 1 2 1 2 1 2 1 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.png b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a129ab263b2438c63a5a860f1752d12e999e67 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYi^Cr=m05DwYYgN>Xm4h%;Qr2Kww zsjuGj$@rcY-&UqQp*B}e<;qNrTG?>>$0~;Hg&|o-AF>8iuDrt*lC?A}Zd>S|pN!kG WwbVCupA!L^#^CAd=d#Wzp$PzXXeti? literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.txt b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.txt new file mode 100644 index 0000000..4fa2b7c --- /dev/null +++ b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_2colors.txt @@ -0,0 +1,8 @@ +1 2 1 1 1 2 1 1 +1 2 1 1 1 1 2 1 +2 2 2 1 2 1 2 2 +2 2 2 2 2 2 1 1 +2 2 2 1 2 1 1 1 +1 1 2 2 2 2 2 1 +2 2 1 2 1 2 1 2 +2 1 1 1 1 1 1 1 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.png b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.png new file mode 100644 index 0000000000000000000000000000000000000000..38984e71cad9b1508bf8659605a18091146c8fa9 GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYka08bak5Dr<>gN?Z?jsk}bA}`mU zF5{E7nYlZAHB&BQY4p3k`P*L2Tk$7{c@o3V4=ZeLe+q0=xjl&?_i9bZ0^J4gI!|rZ lJ-O}A?%mt8HmGxcV7G}fv#~WQQU;pM;OXk;vd$@?2>>74Fth*w literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.txt b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.txt new file mode 100644 index 0000000..08f222b --- /dev/null +++ b/crates/burn-dataset/tests/data/segmask_folder/annotations/mask_random_3colors.txt @@ -0,0 +1,8 @@ +3 1 3 3 1 1 3 2 +3 3 3 3 1 3 2 1 +2 2 2 2 1 1 2 2 +1 1 1 3 3 3 2 3 +2 2 3 2 3 3 1 3 +1 3 3 1 1 3 2 1 +2 2 2 1 2 1 2 3 +3 1 3 3 2 1 2 2 diff --git a/crates/burn-dataset/tests/data/segmask_folder/images/image_checkerboard.png b/crates/burn-dataset/tests/data/segmask_folder/images/image_checkerboard.png new file mode 100644 index 0000000000000000000000000000000000000000..2087501e41b6f2663152e5a335d1161f2e4c1aab GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYka98VX=5Dr<^gN%#~1`JG&*Bzw< zoG%4GmKI35CwuR_c2~H_dX2dA#rhCNAm97vqDR4^&v%ML7#i*0KX*L>iC+JFryI-& ppK|zjrRztK=Dv2?zfoWPX-X^jhPHh)a3G-5mQxc}yY137zc hR&4&*bneq*W}ZgFzjxQ>_W;dh@O1TaS?83{1ORC6F(&{3 literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/segmask_folder/images/image_random_3colors.png b/crates/burn-dataset/tests/data/segmask_folder/images/image_random_3colors.png new file mode 100644 index 0000000000000000000000000000000000000000..880bac466f179eccc75613d429a4993c69510ae6 GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYka$(}BbAsn)%2N&`lRS#qG&nfqQ`0$<%JF6?cA*VDeVjGtWP)}Oz`AgCQbJ@M}gPkWO z=I#8UBKdQJr<>iIO`)G($j#N?%Lo*o$j|+UVX}|gf*@<5oj~U?c)I$ztaD0e0ssYW BOJV>3 literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/sqlite-dataset.db b/crates/burn-dataset/tests/data/sqlite-dataset.db new file mode 100644 index 0000000000000000000000000000000000000000..a53bf20693a9875ce0ba4b2a6add2a63eac2ade4 GIT binary patch literal 12288 zcmeI#ze~eF6bJD95JH17MJd5Scw-A9s6$s%IkgxQn?|rxX*I<{+CUm9x|RMHF8)QG z`UeOu{t2A~FRhi>>MBLP2bbKvmrL$GWO>z!)sB5?g;A#w)1iC)tii_LXI&mS4K%3)8L9 zIjd2A_IN=p3?4t@U0~m>HFD}(7!u;`bV4A=K5IHU-fZ1jxhuv009U< z00Izz00bZa0SG_<0xK<`Ny^q{CX*4)$_K^ClO}#m_ls}O3tOMn|07v{(MJm@Od$XP q2tWV=5P$##AOHafKmY;|SVe)Xq-;+s|2sbj!ld}mis{G?_5TOlBBfaX literal 0 HcmV?d00001 diff --git a/crates/burn-dataset/tests/data/text_folder/negative/sample1.txt b/crates/burn-dataset/tests/data/text_folder/negative/sample1.txt new file mode 100644 index 0000000..7e3d1f2 --- /dev/null +++ b/crates/burn-dataset/tests/data/text_folder/negative/sample1.txt @@ -0,0 +1 @@ +This is a negative text sample for testing the text folder dataset functionality. \ No newline at end of file diff --git a/crates/burn-dataset/tests/data/text_folder/negative/sample2.txt b/crates/burn-dataset/tests/data/text_folder/negative/sample2.txt new file mode 100644 index 0000000..a4abb75 --- /dev/null +++ b/crates/burn-dataset/tests/data/text_folder/negative/sample2.txt @@ -0,0 +1 @@ +另一个负面文本样本,用以确保数据集能够处理同一类别中的多个文件。 \ No newline at end of file diff --git a/crates/burn-dataset/tests/data/text_folder/positive/sample1.txt b/crates/burn-dataset/tests/data/text_folder/positive/sample1.txt new file mode 100644 index 0000000..6e18f0b --- /dev/null +++ b/crates/burn-dataset/tests/data/text_folder/positive/sample1.txt @@ -0,0 +1 @@ +This is a positive text sample for testing the text folder dataset functionality. \ No newline at end of file diff --git a/crates/burn-dataset/tests/data/text_folder/positive/sample2.txt b/crates/burn-dataset/tests/data/text_folder/positive/sample2.txt new file mode 100644 index 0000000..d025616 --- /dev/null +++ b/crates/burn-dataset/tests/data/text_folder/positive/sample2.txt @@ -0,0 +1 @@ +另一个正面文本样本,以确保数据集能够处理同一类别中的多个文件。 \ No newline at end of file diff --git a/crates/burn-derive/Cargo.toml b/crates/burn-derive/Cargo.toml new file mode 100644 index 0000000..f20d207 --- /dev/null +++ b/crates/burn-derive/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Derive crate for the Burn framework" +edition.workspace = true +keywords = [] +license.workspace = true +name = "burn-derive" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-derive" +version.workspace = true + +[lints] +workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true, features = ["visit"] } +derive-new = { workspace = true } diff --git a/crates/burn-derive/LICENSE-APACHE b/crates/burn-derive/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-derive/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-derive/LICENSE-MIT b/crates/burn-derive/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-derive/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-derive/README.md b/crates/burn-derive/README.md new file mode 100644 index 0000000..bc34709 --- /dev/null +++ b/crates/burn-derive/README.md @@ -0,0 +1,6 @@ +# Burn Derive + +This crate should only be used with [burn](https://github.com/tracel-ai/burn). + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-derive.svg)](https://crates.io/crates/burn-derive) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-derive/blob/master/README.md) diff --git a/crates/burn-derive/src/config/analyzer.rs b/crates/burn-derive/src/config/analyzer.rs new file mode 100644 index 0000000..e5e6285 --- /dev/null +++ b/crates/burn-derive/src/config/analyzer.rs @@ -0,0 +1,87 @@ +use super::ConfigEnumAnalyzer; +use crate::config::ConfigStructAnalyzer; +use crate::shared::{attribute::AttributeItem, field::FieldTypeAnalyzer}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Field, Ident}; + +pub struct ConfigAnalyzerFactory {} + +pub trait ConfigAnalyzer { + fn gen_new_fn(&self) -> TokenStream { + quote! {} + } + fn gen_builder_fns(&self) -> TokenStream { + quote! {} + } + fn gen_serde_impl(&self) -> TokenStream; + fn gen_clone_impl(&self) -> TokenStream; + fn gen_display_impl(&self) -> TokenStream; + fn gen_config_impl(&self) -> TokenStream; +} + +impl ConfigAnalyzerFactory { + pub fn new() -> Self { + Self {} + } + + pub fn create_analyzer(&self, item: &syn::DeriveInput) -> Box { + let name = item.ident.clone(); + let config_type = parse_asm(item); + + match config_type { + ConfigType::Struct(data) => Box::new(self.create_struct_analyzer(name, data)), + ConfigType::Enum(data) => Box::new(self.create_enum_analyzer(name, data)), + } + } + + fn create_struct_analyzer(&self, name: Ident, fields: Vec) -> ConfigStructAnalyzer { + let fields = fields.into_iter().map(FieldTypeAnalyzer::new); + + let mut fields_required = Vec::new(); + let mut fields_option = Vec::new(); + let mut fields_default = Vec::new(); + + for field in fields { + let attributes: Vec = field + .attributes() + .filter(|attr| attr.has_name("config")) + .map(|attr| attr.item()) + .collect(); + + if !attributes.is_empty() { + let item = attributes.first().unwrap().clone(); + fields_default.push((field.clone(), item)); + continue; + } + + if field.is_of_type(&["Option"]) { + fields_option.push(field.clone()); + continue; + } + + fields_required.push(field.clone()); + } + + ConfigStructAnalyzer::new(name, fields_required, fields_option, fields_default) + } + + fn create_enum_analyzer(&self, name: Ident, data: syn::DataEnum) -> ConfigEnumAnalyzer { + ConfigEnumAnalyzer::new(name, data) + } +} + +enum ConfigType { + Struct(Vec), + Enum(syn::DataEnum), +} + +fn parse_asm(ast: &syn::DeriveInput) -> ConfigType { + match &ast.data { + syn::Data::Struct(struct_data) => { + ConfigType::Struct(struct_data.fields.clone().into_iter().collect()) + } + syn::Data::Enum(enum_data) => ConfigType::Enum(enum_data.clone()), + syn::Data::Union(_) => panic!("Only struct and enum can be derived"), + } +} diff --git a/crates/burn-derive/src/config/analyzer_enum.rs b/crates/burn-derive/src/config/analyzer_enum.rs new file mode 100644 index 0000000..84ddd9b --- /dev/null +++ b/crates/burn-derive/src/config/analyzer_enum.rs @@ -0,0 +1,141 @@ +use crate::shared::enum_variant::map_enum_variant; + +use super::ConfigAnalyzer; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub struct ConfigEnumAnalyzer { + name: Ident, + data: syn::DataEnum, +} + +impl ConfigEnumAnalyzer { + pub fn new(name: Ident, data: syn::DataEnum) -> Self { + Self { name, data } + } + + fn serde_enum_ident(&self) -> Ident { + Ident::new(&format!("{}Serde", self.name), self.name.span()) + } + + fn gen_serde_enum(&self) -> TokenStream { + let enum_name = self.serde_enum_ident(); + let data = &self.data.variants; + + quote! { + #[derive(burn::serde::Serialize, burn::serde::Deserialize)] + #[serde(crate = "burn::serde")] + enum #enum_name { + #data + } + + } + } + + fn gen_serialize_fn(&self) -> TokenStream { + let enum_name = self.serde_enum_ident(); + let variants = self.data.variants.iter().map(|variant| { + let variant_name = &variant.ident; + let (inputs, outputs) = map_enum_variant(variant, |ident| quote! { #ident.clone() }); + + quote! { Self::#variant_name #inputs => #enum_name::#variant_name #outputs } + }); + + let name = &self.name; + + quote! { + impl burn::serde::Serialize for #name { + fn serialize(&self, serializer: S) -> Result + where + S: burn::serde::Serializer { + let serde_state = match self { + #(#variants),* + }; + serde_state.serialize(serializer) + } + } + + } + } + + fn gen_deserialize_fn(&self) -> TokenStream { + let enum_name = self.serde_enum_ident(); + let variants = self.data.variants.iter().map(|variant| { + let variant_name = &variant.ident; + let (inputs, outputs) = map_enum_variant(variant, |ident| quote! { #ident.clone() }); + + quote! { #enum_name::#variant_name #inputs => Self::#variant_name #outputs } + }); + let name = &self.name; + + quote! { + impl<'de> burn::serde::Deserialize<'de> for #name { + fn deserialize(deserializer: D) -> Result + where + D: burn::serde::Deserializer<'de> { + let serde_state = #enum_name::deserialize(deserializer)?; + Ok(match serde_state { + #(#variants),* + }) + } + } + + } + } +} + +impl ConfigAnalyzer for ConfigEnumAnalyzer { + fn gen_serde_impl(&self) -> TokenStream { + let struct_gen = self.gen_serde_enum(); + let serialize_gen = self.gen_serialize_fn(); + let deserialize_gen = self.gen_deserialize_fn(); + + quote! { + #struct_gen + #serialize_gen + #deserialize_gen + } + } + + fn gen_clone_impl(&self) -> TokenStream { + let variants = self.data.variants.iter().map(|variant| { + let variant_name = &variant.ident; + let (inputs, outputs) = map_enum_variant(variant, |ident| quote! { #ident.clone() }); + + quote! { Self::#variant_name #inputs => Self::#variant_name #outputs } + }); + let name = &self.name; + + quote! { + impl Clone for #name { + fn clone(&self) -> Self { + match self { + #(#variants),* + } + } + } + + } + } + + fn gen_display_impl(&self) -> TokenStream { + let name = &self.name; + + quote! { + impl core::fmt::Display for #name { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&burn::config::config_to_json(self)) + } + } + } + } + + fn gen_config_impl(&self) -> TokenStream { + let name = &self.name; + + quote! { + impl burn::config::Config for #name { + } + } + } +} diff --git a/crates/burn-derive/src/config/analyzer_struct.rs b/crates/burn-derive/src/config/analyzer_struct.rs new file mode 100644 index 0000000..d347f71 --- /dev/null +++ b/crates/burn-derive/src/config/analyzer_struct.rs @@ -0,0 +1,380 @@ +use super::ConfigAnalyzer; +use crate::shared::{attribute::AttributeItem, field::FieldTypeAnalyzer}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub struct ConfigStructAnalyzer { + name: Ident, + fields_required: Vec, + fields_option: Vec, + fields_default: Vec<(FieldTypeAnalyzer, AttributeItem)>, +} + +impl ConfigStructAnalyzer { + pub fn new( + name: Ident, + fields_required: Vec, + fields_option: Vec, + fields_default: Vec<(FieldTypeAnalyzer, AttributeItem)>, + ) -> Self { + Self { + name, + fields_required, + fields_option, + fields_default, + } + } + + fn wrap_impl_block(&self, tokens: TokenStream) -> TokenStream { + let name = &self.name; + + quote! { + impl #name { + #tokens + } + } + } + + fn names(&self) -> Vec { + let mut names = Vec::new(); + + for field in self.fields_required.iter() { + names.push(field.clone()); + } + + for field in self.fields_option.iter() { + names.push(field.clone()); + } + + for (field, _) in self.fields_default.iter() { + names.push(field.clone()); + } + + names + } + + fn name_types(&self, names: &[FieldTypeAnalyzer]) -> Vec { + let mut name_types = Vec::new(); + + for field in names.iter() { + let name = field.ident(); + let ty = &field.field.ty; + + name_types.push(quote! { + #name: #ty + }); + } + + name_types + } + + fn serde_struct_ident(&self) -> Ident { + Ident::new(&format!("{}Serde", self.name), self.name.span()) + } + + fn gen_serialize_fn( + &self, + struct_name: &Ident, + struct_gen: &TokenStream, + names: &[FieldTypeAnalyzer], + ) -> TokenStream { + let name = &self.name; + let names = names.iter().map(|name| { + let name = name.ident(); + quote! { #name: self.#name.clone() } + }); + + quote! { + impl burn::serde::Serialize for #name { + + fn serialize(&self, serializer: S) -> Result + where + S: burn::serde::Serializer { + #[derive(burn::serde::Serialize)] + #[serde(crate = "burn::serde")] + #struct_gen + + let serde_state = #struct_name { + #(#names),* + }; + serde_state.serialize(serializer) + } + } + + } + } + + fn gen_deserialize_fn( + &self, + struct_name: &Ident, + struct_gen: &TokenStream, + names: &[FieldTypeAnalyzer], + ) -> TokenStream { + let name = &self.name; + let names = names.iter().map(|name| { + let name = name.ident(); + quote! { #name: serde_state.#name } + }); + + quote! { + impl<'de> burn::serde::Deserialize<'de> for #name { + fn deserialize(deserializer: D) -> Result + where + D: burn::serde::Deserializer<'de> { + #[derive(burn::serde::Deserialize)] + #[serde(crate = "burn::serde")] + #struct_gen + + let serde_state = #struct_name::deserialize(deserializer)?; + Ok(#name { + #(#names),* + }) + } + } + + } + } + + fn gen_serde_struct(&self, names: &[TokenStream]) -> TokenStream { + let struct_name = self.serde_struct_ident(); + + quote! { + struct #struct_name { + #(#names),* + } + + } + } +} + +impl ConfigAnalyzer for ConfigStructAnalyzer { + fn gen_new_fn(&self) -> TokenStream { + let mut body = quote! {}; + let mut args = Vec::new(); + + let mut fn_docs = quote! {}; + let mut has_field_docs = false; + let mut has_required_docs = false; + let mut has_option_docs = false; + let mut has_default_docs = false; + let mut docs_header = |fn_docs: &mut TokenStream, + required_docs: bool, + option_docs: bool, + default_docs: bool| { + if !has_field_docs { + has_field_docs = true; + fn_docs.extend(quote! { + #[doc = "# Arguments"] + }); + } + if !has_required_docs && required_docs { + fn_docs.extend(quote! { + #[doc = "###### Required Arguments"] + }); + has_required_docs = true; + } + if !has_option_docs && option_docs { + fn_docs.extend(quote! { + #[doc = "###### Optional Arguments"] + }); + has_option_docs = true; + } + if !has_default_docs && default_docs { + fn_docs.extend(quote! { + #[doc = "###### Default Arguments"] + }); + has_default_docs = true; + } + }; + + for field in self.fields_required.iter() { + let name = field.ident(); + let ty = &field.field.ty; + let docs = field.docs(); + + body.extend(quote! { + #name: #name, + }); + args.push(quote! { + #name: #ty + }); + docs_header(&mut fn_docs, true, false, false); + let doc_str = format!("###### `{}`\n\n", quote!(#name)); + fn_docs.extend(quote! { + #[doc = #doc_str] + #(#docs)* + }); + } + + for field in self.fields_option.iter() { + let name = field.ident(); + let docs = field.docs(); + + body.extend(quote! { + #name: None, + }); + docs_header(&mut fn_docs, false, true, false); + let default_doc = "- Defaults to `None`"; + let doc_str = format!("###### `{}`\n", quote!(#name)); + fn_docs.extend(quote! { + #[doc = #doc_str] + #(#docs)* + #[doc = #default_doc] + }); + } + + for (field, attribute) in self.fields_default.iter() { + let name = field.ident(); + let value = &attribute.value; + let docs = field.docs(); + + match value { + syn::Lit::Str(value) => { + let stream: proc_macro2::TokenStream = value.value().parse().unwrap(); + + body.extend(quote! { + #name: #stream, + }); + } + _ => { + body.extend(quote! { + #name: #value, + }); + } + }; + docs_header(&mut fn_docs, false, false, true); + let default_doc = format!("- Defaults to `{}`", quote!(#value)); + let doc_str = format!("###### `{}`\n", quote!(#name)); + fn_docs.extend(quote! { + #[doc = #doc_str] + #(#docs)* + #[doc = #default_doc] + }); + } + + let body = quote! { + #[doc = "Create a new instance of the config."] + #fn_docs + #[allow(clippy::too_many_arguments)] + pub fn new( + #(#args),* + ) -> Self { + Self { #body } + } + }; + self.wrap_impl_block(body) + } + + fn gen_builder_fns(&self) -> TokenStream { + let mut body = quote! {}; + + for (field, attribute) in self.fields_default.iter() { + let name = field.ident(); + let ty = &field.field.ty; + let value = &attribute.value; + let docs = field.docs(); + let default_doc = format!("- Defaults to `{}`", quote!(#value)); + let doc_str = format!( + "Sets the value for the field [`{}`](Self::{0}).\n\n", + quote!(#name) + ); + let fn_docs = quote! { + #[doc = #doc_str] + #(#docs)* + #[doc = #default_doc] + }; + let fn_name = Ident::new(&format!("with_{name}"), name.span()); + + body.extend(quote! { + #fn_docs + pub fn #fn_name(mut self, #name: #ty) -> Self { + self.#name = #name; + self + } + }); + } + + for field in self.fields_option.iter() { + let name = field.ident(); + let ty = &field.field.ty; + let docs = field.docs(); + let default_doc = "- Defaults to `None`"; + let doc_str = format!( + "Sets the value for the field [`{}`](Self::{0}).\n\n", + quote!(#name) + ); + let fn_docs = quote! { + #[doc = #doc_str] + #(#docs)* + #[doc = #default_doc] + }; + let fn_name = Ident::new(&format!("with_{name}"), name.span()); + + body.extend(quote! { + #fn_docs + pub fn #fn_name(mut self, #name: #ty) -> Self { + self.#name = #name; + self + } + }); + } + + self.wrap_impl_block(body) + } + + fn gen_serde_impl(&self) -> TokenStream { + let names = self.names(); + + let struct_name = self.serde_struct_ident(); + let name_types = self.name_types(&names); + let struct_gen = self.gen_serde_struct(&name_types); + + let serialize_gen = self.gen_serialize_fn(&struct_name, &struct_gen, &names); + let deserialize_gen = self.gen_deserialize_fn(&struct_name, &struct_gen, &names); + + quote! { + #serialize_gen + #deserialize_gen + } + } + + fn gen_clone_impl(&self) -> TokenStream { + let name = &self.name; + let names = self.names().into_iter().map(|name| { + let name = name.ident(); + quote! { #name: self.#name.clone() } + }); + + quote! { + impl Clone for #name { + fn clone(&self) -> Self { + Self { + #(#names),* + } + } + } + + } + } + + fn gen_display_impl(&self) -> TokenStream { + let name = &self.name; + + quote! { + impl core::fmt::Display for #name { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&burn::config::config_to_json(self)) + } + } + } + } + + fn gen_config_impl(&self) -> TokenStream { + let name = &self.name; + + quote! { + impl burn::config::Config for #name { + } + } + } +} diff --git a/crates/burn-derive/src/config/base.rs b/crates/burn-derive/src/config/base.rs new file mode 100644 index 0000000..cca3f04 --- /dev/null +++ b/crates/burn-derive/src/config/base.rs @@ -0,0 +1,24 @@ +use super::ConfigAnalyzerFactory; +use quote::quote; + +pub(crate) fn derive_impl(item: &syn::DeriveInput) -> proc_macro::TokenStream { + let factory = ConfigAnalyzerFactory::new(); + let analyzer = factory.create_analyzer(item); + + let constructor = analyzer.gen_new_fn(); + let builders = analyzer.gen_builder_fns(); + let serde = analyzer.gen_serde_impl(); + let clone = analyzer.gen_clone_impl(); + let display = analyzer.gen_display_impl(); + let config_impl = analyzer.gen_config_impl(); + + quote! { + #config_impl + #constructor + #builders + #serde + #clone + #display + } + .into() +} diff --git a/crates/burn-derive/src/config/mod.rs b/crates/burn-derive/src/config/mod.rs new file mode 100644 index 0000000..36a5c0e --- /dev/null +++ b/crates/burn-derive/src/config/mod.rs @@ -0,0 +1,9 @@ +mod analyzer; +mod analyzer_enum; +mod analyzer_struct; +mod base; + +pub(crate) use analyzer::*; +pub(crate) use analyzer_enum::*; +pub(crate) use analyzer_struct::*; +pub(crate) use base::*; diff --git a/crates/burn-derive/src/lib.rs b/crates/burn-derive/src/lib.rs new file mode 100644 index 0000000..8581732 --- /dev/null +++ b/crates/burn-derive/src/lib.rs @@ -0,0 +1,88 @@ +#![warn(missing_docs)] + +//! The derive crate of Burn. + +#[macro_use] +extern crate derive_new; + +use proc_macro::TokenStream; + +pub(crate) mod config; +pub(crate) mod module; +pub(crate) mod record_state; +pub(crate) mod shared; + +/// Derive macro for the `Module` trait. +/// +/// # Sub-modules +/// +/// By default, the macro automatically detects sub-modules and parameters as module types. +/// +/// Any field not recognized as a module type is assumed to be a non-module +/// and is skipped by the module system (not persistent, not visited). +/// +/// ## Generics +/// +/// Generic type parameters (e.g., `field: M`) are assumed to be sub-modules by default. +/// If a generic field represents some other runtime state or configuration, you can use +/// the `#[module(skip)]` attribute to provide a hint. +/// +/// # Field Attributes +/// +/// ## `#[module(skip)]` +/// +/// Explicitly marks a field to be ignored by the module derive. +/// +/// Skipped fields are not parameters, not modules, and are not persistent. +/// This is equivalent to the deprecated `Ignored` wrapper. +/// +/// ### Requirements +/// +/// The field must implement: `Debug + Clone + Send`. +/// +/// # Example +/// +/// ```ignore +/// #[derive(Module, Debug)] +/// pub struct MyModule { +/// /// A normal parameter. +/// weights: Param>, +/// /// A field configured at runtime. +/// dropout_prob: f64, +/// /// A field that is recomputed at runtime. +/// cached_mask: Option>, +/// /// A field that contains some debug state. +/// debug_state: String, +/// /// Treated as a module (default for generics). +/// inner: M, +/// /// Hint required: this generic is NOT a module. +/// #[module(skip)] +/// other: N, +/// } +/// ``` +#[proc_macro_derive(Module, attributes(module))] +pub fn module_derive(input: TokenStream) -> TokenStream { + let input = syn::parse(input).unwrap(); + module::derive_impl(&input) +} + +/// Derive macro for the config. +#[proc_macro_derive(Config, attributes(config))] +pub fn config_derive(input: TokenStream) -> TokenStream { + let item = syn::parse(input).unwrap(); + config::derive_impl(&item) +} + +/// Derive macro for a recordable state (optimizer or learning-rate scheduler), decomposing it +/// into named tensors and scalars for the burnpack format. +/// +/// Supported field shapes: `Tensor`, `Option>`, `Vec>`, scalars +/// (`usize`/`isize`/`u8`..`u64`/`i8`..`i64`/`f32`/`f64`/`bool`), `Option`, a nested +/// `RecordState`, and `Option`. Scalar fields must use a concrete primitive type, not a +/// type alias (e.g. `f64`, not `LearningRate`): classification is syntactic, so an alias is treated +/// as a nested state. +#[proc_macro_derive(RecordState)] +pub fn record_state_derive(input: TokenStream) -> TokenStream { + let input = syn::parse(input).unwrap(); + record_state::derive_impl(&input) +} diff --git a/crates/burn-derive/src/module/base.rs b/crates/burn-derive/src/module/base.rs new file mode 100644 index 0000000..e76daa8 --- /dev/null +++ b/crates/burn-derive/src/module/base.rs @@ -0,0 +1,35 @@ +use super::{ + codegen::{generate_module_standard, generate_module_stateless}, + codegen_enum::EnumModuleCodegen, + codegen_struct::StructModuleCodegen, +}; +use proc_macro::TokenStream; + +pub(crate) fn derive_impl(ast: &syn::DeriveInput) -> TokenStream { + match &ast.data { + syn::Data::Struct(_) => match StructModuleCodegen::from_ast(ast) { + Ok(struct_codegen) => { + if struct_codegen.has_module_fields() { + generate_module_standard(ast, struct_codegen) + } else { + generate_module_stateless(ast, struct_codegen) + } + } + Err(err) => err.to_compile_error(), + }, + syn::Data::Enum(_data) => match EnumModuleCodegen::from_ast(ast) { + Ok(enum_codegen) => { + if enum_codegen.has_module_fields() { + generate_module_standard(ast, enum_codegen) + } else { + generate_module_stateless(ast, enum_codegen) + } + } + Err(err) => err.to_compile_error(), + }, + syn::Data::Union(_) => { + syn::Error::new_spanned(ast, "Union modules aren't supported").to_compile_error() + } + } + .into() +} diff --git a/crates/burn-derive/src/module/codegen.rs b/crates/burn-derive/src/module/codegen.rs new file mode 100644 index 0000000..d0a9a30 --- /dev/null +++ b/crates/burn-derive/src/module/codegen.rs @@ -0,0 +1,217 @@ +use super::display; +use crate::{ + module::generics::{GenericKind, ModuleGenerics}, + shared::generics::GenericsHelper, +}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Attribute, Generics, parse_quote}; + +/// Basic trait to be implemented for Module generation. +pub(crate) trait ModuleCodegen { + fn gen_num_params(&self) -> TokenStream; + fn gen_visit(&self) -> TokenStream; + fn gen_collect_devices(&self) -> TokenStream; + fn gen_to_device(&self) -> TokenStream; + fn gen_fork(&self) -> TokenStream; + fn gen_map(&self) -> TokenStream; + fn gen_valid(&self) -> TokenStream; + fn gen_from_inner(&self) -> TokenStream; + fn gen_clone(&self) -> TokenStream; + + fn gen_display(&self) -> TokenStream; + + fn module_generics(&self) -> &ModuleGenerics; +} + +pub(crate) fn generate_module_standard( + ast: &syn::DeriveInput, + codegen: Codegen, +) -> TokenStream { + let name = &ast.ident; + + let generics = GenericsParser::from_ast(&ast.generics, codegen.module_generics()); + + let display_fn = display::display_fn(ast); + let attributes_fn = codegen.gen_display(); + let num_params_fn = codegen.gen_num_params(); + let visit = codegen.gen_visit(); + let map_mut = codegen.gen_map(); + let collect_devices = codegen.gen_collect_devices(); + let to_device = codegen.gen_to_device(); + let fork = codegen.gen_fork(); + let valid_fn = codegen.gen_valid(); + let from_inner_fn = codegen.gen_from_inner(); + let clone_fn = codegen.gen_clone(); + + let (generics_module, generics_ty_module, generics_where_module) = + generics.module.split_for_impl(); + let (generics_module_autodiff, generics_ty_module_autodiff, generics_where_module_autodiff) = + generics.module_autodiff.split_for_impl(); + + let mut codegen = quote! { + + impl #generics_module burn::module::Module for #name #generics_ty_module #generics_where_module { + #num_params_fn + + #visit + #map_mut + + #collect_devices + #to_device + #fork + + } + + impl #generics_module_autodiff burn::module::AutodiffModule for #name #generics_ty_module_autodiff #generics_where_module_autodiff + { + #valid_fn + + #from_inner_fn + } + + impl #generics_module core::fmt::Display for #name #generics_ty_module #generics_where_module { + #display_fn + } + + impl #generics_module burn::module::ModuleDisplayDefault for #name #generics_ty_module #generics_where_module { + #attributes_fn + + fn num_params(&self) -> usize { + burn::module::Module::num_params(self) + } + } + + impl #generics_module Clone for #name #generics_ty_module #generics_where_module { + #clone_fn + } + }; + + if !has_custom_display(&ast.attrs) { + codegen.extend(quote! { + impl #generics_module burn::module::ModuleDisplay for #name #generics_ty_module #generics_where_module { + + } + }); + } + + codegen +} + +// When there is inner param or module, the type is considered stateless. +pub(crate) fn generate_module_stateless( + ast: &syn::DeriveInput, + codegen: Codegen, // Pass the codegen here +) -> TokenStream { + let name = &ast.ident; + let (generics, generics_ty, generics_where) = ast.generics.split_for_impl(); + + let display_fn = display::display_fn(ast); + let attributes_fn = codegen.gen_display(); // Use codegen for attributes too + let clone_fn = codegen.gen_clone(); // The automatic clone logic + + let mut codegen = quote! { + impl #generics burn::module::Module for #name #generics_ty #generics_where { + burn::empty!(module); + } + + impl #generics burn::module::AutodiffModule for #name #generics_ty #generics_where { + burn::empty!(ad_module, #name #generics_ty); + } + + impl #generics core::fmt::Display for #name #generics_ty #generics_where { + #display_fn + } + + impl #generics burn::module::ModuleDisplayDefault for #name #generics_ty #generics_where { + #attributes_fn + } + + impl #generics Clone for #name #generics_ty #generics_where { + #clone_fn + } + }; + + if !has_custom_display(&ast.attrs) { + codegen.extend(quote! { + impl #generics burn::module::ModuleDisplay for #name #generics_ty #generics_where { + + } + }); + } + + codegen +} + +struct GenericsParser { + module: Generics, + module_autodiff: Generics, +} + +impl GenericsParser { + fn from_ast(generics: &Generics, module_generics: &ModuleGenerics) -> Self { + let mut module = GenericsHelper::new(generics.clone()); + let mut module_autodiff = GenericsHelper::new(generics.clone()); + + module.types().into_iter().for_each(|ident| { + // By default, require module bound + let mut requires_module_bound = true; + let mut generic_kind = None; + if !module_generics.is_empty() { + generic_kind = module_generics.get_generic_kind(&ident); + let has_module_bound = matches!(generic_kind, Some(GenericKind::Module)); + let is_unbounded = matches!(generic_kind, Some(GenericKind::Plain)); + + requires_module_bound = has_module_bound || is_unbounded; + } + + if requires_module_bound { + module.add_predicate(parse_quote! { + #ident: burn::module::Module + }); + + module.add_predicate(parse_quote! { + #ident: burn::module::ModuleDisplay + }); + + module_autodiff.add_predicate(parse_quote! { + #ident: burn::module::AutodiffModule + }); + + module_autodiff.add_predicate(parse_quote! { + #ident: burn::module::ModuleDisplay + }); + } else { + // Add required bounds to impl + if let Some(GenericKind::Skip) = generic_kind { + module.add_predicate(parse_quote! { + #ident: Clone + core::fmt::Debug + Send + }); + module_autodiff.add_predicate(parse_quote! { + #ident: Clone + core::fmt::Debug + Send + }); + } + } + }); + + Self { + module: module.generics, + module_autodiff: module_autodiff.generics, + } + } +} + +fn has_custom_display(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + attr.path().is_ident("module") + && attr + .parse_nested_meta(|meta| { + if meta.path.is_ident("custom_display") { + Ok(()) + } else { + Err(meta.error("unsupported attribute")) + } + }) + .is_ok() + }) +} diff --git a/crates/burn-derive/src/module/codegen_enum.rs b/crates/burn-derive/src/module/codegen_enum.rs new file mode 100644 index 0000000..bcd8dcd --- /dev/null +++ b/crates/burn-derive/src/module/codegen_enum.rs @@ -0,0 +1,303 @@ +use super::codegen::ModuleCodegen; +use crate::module::{ + codegen_struct::{ModuleFieldType, parse_module_field_type}, + generics::{ModuleGenerics, parse_module_generics}, +}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; + +pub(crate) struct EnumModuleCodegen { + pub name: Ident, + pub variants: Vec, + pub generics: ModuleGenerics, +} + +impl ModuleCodegen for EnumModuleCodegen { + fn gen_num_params(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|_| { + quote! { + burn::module::Module::num_params(module) + } + }); + + quote! { + fn num_params(&self) -> usize { + #match_body + } + } + } + + fn gen_visit(&self) -> TokenStream { + let enum_name = self.name.to_string(); + let container_type = format!("Enum:{}", enum_name); + let match_body = self.gen_variants_match_fn(|variant_name| { + let variant_str = variant_name.to_string(); + quote! { + { + visitor.enter_module(#variant_str, #container_type); + burn::module::Module::visit(module, visitor); + visitor.exit_module(#variant_str, #container_type); + } + } + }); + + quote! { + fn visit(&self, visitor: &mut Visitor) { + #match_body + } + } + } + + fn gen_collect_devices(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|_| { + quote! { + burn::module::Module::collect_devices(module, devices) + } + }); + + quote! { + fn collect_devices( + &self, + devices: burn::module::Devices + ) -> burn::module::Devices { + #match_body + } + } + } + + fn gen_to_device(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|variant| { + quote! { + Self::#variant(burn::module::Module::to_device(module, device)) + } + }); + + quote! { + fn to_device(self, device: &burn::tensor::Device) -> Self { + #match_body + } + } + } + + fn gen_fork(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|variant| { + quote! { + Self::#variant(burn::module::Module::fork(module, device)) + } + }); + + quote! { + fn fork(self, device: &burn::tensor::Device) -> Self { + #match_body + } + } + } + + fn gen_map(&self) -> TokenStream { + let enum_name = self.name.to_string(); + let container_type = format!("Enum:{}", enum_name); + let match_body = self.gen_variants_match_fn(|variant| { + let variant_str = variant.to_string(); + quote! { + { + mapper.enter_module(#variant_str, #container_type); + let result = burn::module::Module::map(module, mapper); + mapper.exit_module(#variant_str, #container_type); + Self::#variant(result) + } + } + }); + + quote! { + fn map(self, mapper: &mut Mapper) -> Self { + #match_body + } + } + } + + fn gen_valid(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|variant| { + quote! { + Self::#variant(burn::module::AutodiffModule::valid(module)) + } + }); + + quote! { + fn valid(&self) -> Self { + #match_body + } + } + } + + fn gen_from_inner(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn_param("module", "Self::", |variant| { + quote! { + Self::#variant(burn::module::AutodiffModule::from_inner(module)) + } + }); + + quote! { + fn from_inner(module: Self) -> Self { + #match_body + } + } + } + + fn gen_clone(&self) -> TokenStream { + let match_body = self.gen_variants_match_fn(|variant| { + quote! { + Self::#variant(module.clone()) + } + }); + + quote! { + fn clone(&self) -> Self { + #match_body + } + } + } + + fn module_generics(&self) -> &ModuleGenerics { + &self.generics + } + + fn gen_display(&self) -> TokenStream { + // Only tuple enum variants with exactly one field are supported + let variant_prints = self.variants.iter().map(|variant| { + let variant_name = &variant.ident; + let field_names = + (0..1).map(|i| syn::Ident::new(&format!("_{i}"), proc_macro2::Span::call_site())); + + let field_prints = field_names.clone().map(|field_name| { + quote! { .add(stringify!(#field_name), #field_name) } + }); + quote! { + Self::#variant_name(#(#field_names),*) => { + content.set_top_level_type(&stringify!(#variant_name)) + #(#field_prints)* + .optional() + } + } + }); + quote! { + fn content(&self, mut content: burn::module::Content) -> Option { + match self { + #(#variant_prints)* + } + } + } + } +} + +impl EnumModuleCodegen { + pub fn from_ast(ast: &syn::DeriveInput) -> syn::Result { + let mut generics = parse_module_generics(&ast.generics); + Ok(Self { + name: ast.ident.clone(), + variants: parse_variants(ast, &mut generics)?, + generics, + }) + } + + /// Generate the enum variants' match arms with the provided function + fn gen_variants_match_fn(&self, func: F) -> TokenStream + where + F: Fn(Ident) -> TokenStream, + { + self.gen_variants_match_fn_param("self", "Self::", func) + } + + /// Generate a match expression over the given argument (e.g., `self`) + /// and using the provided prefix for variants (e.g., `Self::`) + fn gen_variants_match_fn_param(&self, arg: &str, prefix: &str, func: F) -> TokenStream + where + F: Fn(Ident) -> TokenStream, + { + let match_arms = self.variants.iter().map(|variant| { + let name = &variant.ident; + let full_variant = syn::parse_str::(&format!("{prefix}{name}")).unwrap(); + let arm_pattern = quote! { #full_variant(module) }; + let arm_code = func(name.clone()); + quote! { #arm_pattern => #arm_code, } + }); + + let arg = Ident::new(arg, Span::call_site()); + + quote! { + match #arg { + #(#match_arms)* + } + } + } + + /// Returns true if any field in any variant is considered a module. + pub fn has_module_fields(&self) -> bool { + self.variants + .iter() + .any(|variant| variant.field_type.is_module) + } +} + +/// Module enum variant +pub(crate) struct EnumVariant { + pub ident: syn::Ident, + pub field_type: ModuleFieldType, +} + +pub(crate) fn parse_variants( + ast: &syn::DeriveInput, + generics: &mut ModuleGenerics, +) -> syn::Result> { + let enum_data = match &ast.data { + syn::Data::Enum(data) => data, + _ => return Err(syn::Error::new_spanned(ast, "Only enums are supported")), + }; + + let mut variants = Vec::new(); + + for variant in enum_data.variants.iter() { + for attr in &variant.attrs { + if attr.path().is_ident("module") { + Err(syn::Error::new_spanned( + variant, + "Module attributes are not supported for enum variants.", + ))?; + } + } + + match &variant.fields { + syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let field = &fields.unnamed[0]; + + // USE THE SAME PARSER AS STRUCTS + // This gives us the is_module, is_param, etc. logic + let field_type = parse_module_field_type(field, generics)?; + + variants.push(EnumVariant { + ident: variant.ident.clone(), + field_type, + }); + } + syn::Fields::Unnamed(_) => { + return Err(syn::Error::new_spanned( + variant, + "Module derive only supports tuple enum variants with exactly one field.", + )); + } + syn::Fields::Named(_) => { + return Err(syn::Error::new_spanned( + variant, + "Module derive does not support struct enum variants.", + )); + } + syn::Fields::Unit => { + return Err(syn::Error::new_spanned( + variant, + "Module derive does not support unit enum variants.", + )); + } + } + } + + Ok(variants) +} diff --git a/crates/burn-derive/src/module/codegen_struct.rs b/crates/burn-derive/src/module/codegen_struct.rs new file mode 100644 index 0000000..fae8adf --- /dev/null +++ b/crates/burn-derive/src/module/codegen_struct.rs @@ -0,0 +1,498 @@ +use std::collections::HashSet; + +use crate::module::generics::{ + GenericKind, ModuleGenerics, parse_module_generics, parse_ty_generics, +}; + +use super::codegen::ModuleCodegen; +use proc_macro2::{Ident, TokenStream}; +use quote::{ToTokens, quote}; +use syn::Field; + +pub(crate) struct StructModuleCodegen { + pub name: Ident, + pub fields: Vec, + pub generics: ModuleGenerics, +} + +impl ModuleCodegen for StructModuleCodegen { + fn gen_num_params(&self) -> TokenStream { + let body = self.gen_fields_fn(|name, field_type| { + if field_type.is_parameter_module() || field_type.maybe_generic_module() { + quote! { + num_params += burn::module::Module::num_params(&self.#name); + } + } else { + quote! {} // other fields have 0 params + } + }); + + quote! { + fn num_params(&self) -> usize { + let mut num_params = 0; + #body + num_params + } + } + } + + fn gen_visit(&self) -> TokenStream { + let struct_name = self.name.to_string(); + let container_type = format!("Struct:{}", struct_name); + let body = self.gen_fields_fn(|name, field_type| { + if field_type.is_parameter_module() || field_type.maybe_generic_module() { + let name_str = name.to_string(); + quote! { + visitor.enter_module(#name_str, #container_type); + burn::module::Module::visit(&self.#name, visitor); + visitor.exit_module(#name_str, #container_type); + } + } else { + quote! {} + } + }); + + quote! { + fn visit(&self, visitor: &mut Visitor) { + #body + } + } + } + + fn gen_collect_devices(&self) -> TokenStream { + let body = self.gen_fields_fn(|name, field_type| { + if field_type.is_module || field_type.maybe_generic_module() { + quote! { + let devices = burn::module::Module::collect_devices(&self.#name, devices); + } + } else { + quote! {} + } + }); + + quote! { + fn collect_devices( + &self, + devices: burn::module::Devices + ) -> burn::module::Devices { + #body + devices + } + } + } + + fn gen_to_device(&self) -> TokenStream { + let (names, body) = self.gen_fields_fn_names(|name, field_type| { + if field_type.is_module || field_type.maybe_generic_module() { + quote! { + let #name = burn::module::Module::to_device(self.#name, device); + } + } else { + quote! { let #name = self.#name; } + } + }); + + quote! { + fn to_device(self, device: &burn::tensor::Device) -> Self { + #body + Self { #(#names),* } + } + } + } + + fn gen_fork(&self) -> TokenStream { + let (names, body) = self.gen_fields_fn_names(|name, field_type| { + if field_type.is_module || field_type.maybe_generic_module() { + quote! { + let #name = burn::module::Module::fork(self.#name, device); + } + } else { + quote! { let #name = self.#name; } + } + }); + + quote! { + fn fork(self, device: &burn::tensor::Device) -> Self { + #body + Self { #(#names),* } + } + } + } + + fn gen_map(&self) -> TokenStream { + let struct_name = self.name.to_string(); + let container_type = format!("Struct:{}", struct_name); + let (names, body) = self.gen_fields_fn_names(|name, field_type| { + if field_type.is_parameter_module() || field_type.maybe_generic_module() { + let name_str = name.to_string(); + quote! { + mapper.enter_module(#name_str, #container_type); + let #name = burn::module::Module::map(self.#name, mapper); + mapper.exit_module(#name_str, #container_type); + } + } else { + quote! { let #name = self.#name; } + } + }); + + quote! { + fn map(self, mapper: &mut Mapper) -> Self { + #body + Self { #(#names),* } + } + } + } + + fn gen_valid(&self) -> TokenStream { + let (names, body) = self.gen_fields_fn_names(|name, field_type| { + if field_type.is_module || field_type.maybe_generic_module() { + quote! { + let #name = burn::module::AutodiffModule::valid(&self.#name); + } + } else { + quote! { let #name = self.#name.clone(); } + } + }); + + quote! { + fn valid(&self) -> Self { + #body + Self { #(#names),* } + } + } + } + + fn gen_from_inner(&self) -> TokenStream { + let (names, body) = self.gen_fields_fn_names(|name, field_type| { + if field_type.is_module || field_type.maybe_generic_module() { + quote! { + let #name = burn::module::AutodiffModule::from_inner(#name); + } + } else { + quote! { let #name = #name; } + } + }); + + let destructure = quote! { + let Self { #(#names),* } = module; + }; + + quote! { + fn from_inner(module: Self) -> Self { + #destructure + #body + Self { #(#names),* } + } + } + } + + fn gen_clone(&self) -> TokenStream { + let (names, body) = self.gen_fields_fn_names(|name, _field_type| { + quote! { + let #name = self.#name.clone(); + } + }); + + quote! { + fn clone(&self) -> Self { + #body + Self { #(#names),* } + } + } + } + + fn module_generics(&self) -> &ModuleGenerics { + &self.generics + } + + fn gen_display(&self) -> TokenStream { + // let struct_name = self.name.to_string(); + let struct_ident = &self.name; + let field_prints = self.fields.iter().map(|field| { + let field_name = field.ident(); + if field.field_type.is_module || field.field_type.maybe_generic_module() { + // Standard module type, use underlying `ModuleDisplay` impl + quote! { .add(stringify!(#field_name), &self.#field_name) } + } else { + // Not a module, use the debug implementation + quote! { + .add_debug_attribute(stringify!(#field_name), &self.#field_name) + } + } + }); + quote! { + fn content(&self, mut content: burn::module::Content) -> Option { + content + .set_top_level_type(&stringify!(#struct_ident)) + #(#field_prints)* + .optional() + } + } + } +} + +impl StructModuleCodegen { + pub fn from_ast(ast: &syn::DeriveInput) -> syn::Result { + let mut generics = parse_module_generics(&ast.generics); + Ok(Self { + name: ast.ident.clone(), + fields: parse_module_fields(ast, &mut generics)?, + generics, + }) + } + + fn gen_fields_fn_names(&self, func: F) -> (Vec, TokenStream) + where + F: Fn(Ident, &ModuleFieldType) -> TokenStream, + { + let mut body = quote! {}; + let mut names = Vec::new(); + + for field in self.fields.iter() { + let name = field.ident(); + + names.push(name.clone()); + body.extend(func(name, &field.field_type)); + } + + (names, body) + } + + fn gen_fields_fn(&self, func: F) -> TokenStream + where + F: Fn(Ident, &ModuleFieldType) -> TokenStream, + { + let mut body = quote! {}; + + for field in self.fields.iter() { + body.extend(func(field.ident(), &field.field_type)); + } + + body + } + + pub fn has_module_fields(&self) -> bool { + // let has_module_fields = self.fields.iter().any(|f| f.field_type.is_module); + // eprintln!("[HAS MODULE FIELDS] {}: {has_module_fields:?}", self.name); + self.fields.iter().any(|f| f.field_type.is_module) + } +} + +#[derive(new, Debug)] +pub struct ModuleField { + pub field: Field, + pub field_type: ModuleFieldType, +} + +impl ModuleField { + pub fn ident(&self) -> Ident { + self.field.ident.clone().unwrap() + } +} + +#[derive(Debug)] +pub enum ModuleFieldAttribute { + Skip, +} + +#[derive(Default, Debug)] +pub struct ModuleFieldType { + pub is_module: bool, + pub attr: Option, + pub generic_idents: HashSet, +} + +impl ModuleFieldType { + /// Returns true if the field is a module with parameters + /// (i.e., a real module that is neither skipped nor constant). + pub fn is_parameter_module(&self) -> bool { + self.is_module && self.attr.is_none() + } + + /// Returns true for generic fields that are assumed to be modules. + pub fn maybe_generic_module(&self) -> bool { + // We assumed it might be a module generic if the field is not marked + // by any attributes (skip or constant) + !self.generic_idents.is_empty() && self.attr.is_none() + } +} + +pub(crate) fn parse_module_fields( + ast: &syn::DeriveInput, + generics: &mut ModuleGenerics, +) -> syn::Result> { + let mut fields = Vec::new(); + let mut module_generics = HashSet::new(); + let mut skip_generics = HashSet::new(); + + match &ast.data { + syn::Data::Struct(struct_data) => { + for field in struct_data.fields.iter() { + let field_type = parse_module_field_type(field, generics)?; + if field_type.is_module { + module_generics.extend(field_type.generic_idents.iter().cloned()); + } else if matches!(field_type.attr, Some(ModuleFieldAttribute::Skip)) { + skip_generics.extend(field_type.generic_idents.iter().cloned()); + } + fields.push(ModuleField::new(field.clone(), field_type)); + } + } + syn::Data::Enum(_) => panic!("Only struct can be derived"), + syn::Data::Union(_) => panic!("Only struct can be derived"), + }; + + for ident in module_generics.intersection(&skip_generics) { + if let Some(param) = ast.generics.params.iter().find_map(|p| match p { + syn::GenericParam::Type(tp) if tp.ident == *ident => Some(tp), + _ => None, + }) { + return Err(syn::Error::new( + param.ident.span(), + format!( + "Generic type `{ident}` should not be used on both a module field and a skipped field. \ + Consider removing `#[module(skip)]` or using a different type for one of the fields.", + ), + )); + } + } + + Ok(fields) +} + +pub(crate) fn parse_module_field_type( + field: &Field, + generics: &mut ModuleGenerics, +) -> syn::Result { + let mut field_type = ModuleFieldType::default(); + + // Check for generics + let mut has_module_bound = false; + let field_generics = parse_ty_generics(&field.ty, generics) + .into_iter() + .inspect(|ident| { + has_module_bound |= generics.is_bounded_module(ident); + }) + .collect::>(); + + // Infer if a field is a module + let is_primitive = is_primitive_type(&field.ty); + let is_param = is_param_type(&field.ty); + + for attr in &field.attrs { + if attr.path().is_ident("module") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + // Mark field attribute and generic + field_type.attr = Some(ModuleFieldAttribute::Skip); + for ty in &field_generics { + generics.update(ty, GenericKind::Skip); + } + Ok(()) + } else { + let path = meta.path.to_token_stream().to_string(); + Err(meta.error(format!("Unsupported module attribute: {}", path))) + }?; + + if is_param && field_type.attr.is_some() { + Err(meta.error("Fields of type 'Param' should not be marked as 'skip'. Use a 'Tensor' instead.")) + } else if has_module_bound && field_type.attr.is_some() { + Err(meta.error("Fields with module generics should not be marked as 'skip'.")) + } + else { + Ok(()) + } + })?; + } + } + + field_type.is_module = + !(is_primitive || matches!(field_type.attr, Some(ModuleFieldAttribute::Skip))); + field_type.generic_idents = field_generics; + + Ok(field_type) +} + +fn type_matches_ident(ty: &syn::Type, idents: &[&str]) -> bool { + if let syn::Type::Path(type_path) = ty { + // Look at the last segment of the path (e.g., 'Param' in 'burn::module::Param') + if let Some(segment) = type_path.path.segments.last() { + return idents.contains(&segment.ident.to_string().as_str()); + } + } + false +} + +fn is_primitive_ident(ident: &str) -> bool { + matches!( + ident, + "bool" + | "u8" + | "u16" + | "u32" + | "u64" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "isize" + | "f32" + | "f64" + | "String" + ) +} + +fn is_primitive_type(ty: &syn::Type) -> bool { + match ty { + // e.g. usize, String, or Option + syn::Type::Path(type_path) => { + let segment = match type_path.path.segments.last() { + Some(seg) => seg, + None => return false, + }; + + let ident = segment.ident.to_string(); + + // Direct primitive + if is_primitive_ident(&ident) { + return true; + } + + // Generic types like Option, Vec, etc. + match &segment.arguments { + syn::PathArguments::AngleBracketed(args) => args.args.iter().all(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + is_primitive_type(inner_ty) + } else { + false + } + }), + _ => false, + } + } + + // e.g. (T, U, ...) + syn::Type::Tuple(tuple) => tuple.elems.iter().all(is_primitive_type), + + // e.g. [T; N] + syn::Type::Array(array) => is_primitive_type(&array.elem), + + // e.g. &T or &mut T + syn::Type::Reference(reference) => is_primitive_type(&reference.elem), + + // e.g. *const T / *mut T + syn::Type::Ptr(ptr) => is_primitive_type(&ptr.elem), + + // e.g. (T) + syn::Type::Paren(paren) => is_primitive_type(&paren.elem), + + // slices `[T]` + syn::Type::Slice(slice) => is_primitive_type(&slice.elem), + + _ => false, + } +} + +fn is_param_type(ty: &syn::Type) -> bool { + type_matches_ident(ty, &["Param"]) +} diff --git a/crates/burn-derive/src/module/display.rs b/crates/burn-derive/src/module/display.rs new file mode 100644 index 0000000..9b32dce --- /dev/null +++ b/crates/burn-derive/src/module/display.rs @@ -0,0 +1,10 @@ +use quote::quote; + +pub fn display_fn(_ast: &syn::DeriveInput) -> proc_macro2::TokenStream { + quote! { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let formatted = burn::module::ModuleDisplay::format(self, Default::default()); + write!(f, "{}", formatted) + } + } +} diff --git a/crates/burn-derive/src/module/generics.rs b/crates/burn-derive/src/module/generics.rs new file mode 100644 index 0000000..5281e51 --- /dev/null +++ b/crates/burn-derive/src/module/generics.rs @@ -0,0 +1,129 @@ +use std::collections::{HashMap, HashSet}; + +use proc_macro2::Ident; +use syn::{GenericParam, Generics, Type, TypeParamBound, WherePredicate, visit::Visit}; + +#[derive(Debug)] +pub enum GenericKind { + /// A generic with `Module` bound. + Module, + /// A generic used in a field marked by `#[module(skip)]`. + Skip, + /// A plain generic that does not fit any of the above conditions. + Plain, +} + +#[derive(Debug)] +pub struct ModuleGenerics { + kinds: HashMap, +} + +impl ModuleGenerics { + pub fn is_empty(&self) -> bool { + self.kinds.is_empty() + } + + pub fn get_generic_kind(&self, ident: &Ident) -> Option<&GenericKind> { + self.kinds.get(ident) + } + + pub fn is_bounded_module(&self, ident: &Ident) -> bool { + self.kinds + .get(ident) + .map(|kind| matches!(kind, GenericKind::Module)) + .unwrap_or(false) + } + + pub fn update(&mut self, ident: &Ident, kind: GenericKind) { + self.kinds.insert(ident.clone(), kind); + } + + pub fn contains(&self, ident: &Ident) -> bool { + self.kinds.contains_key(ident) + } +} + +pub fn parse_module_generics(generics: &Generics) -> ModuleGenerics { + let mut kinds = HashMap::new(); + + // Check inline bounds e.g. `M: Module` + for param in &generics.params { + if let GenericParam::Type(type_param) = param { + let ident = &type_param.ident; + if has_module_bound(&type_param.bounds) { + kinds.insert(ident.clone(), GenericKind::Module); + } else { + kinds.insert(ident.clone(), GenericKind::Plain); + } + } + } + + // Check `where` clauses + if let Some(where_clause) = &generics.where_clause { + for predicate in &where_clause.predicates { + if let WherePredicate::Type(pt) = predicate { + // We only care if the bounded type is a simple identifier (like 'M') + if let Type::Path(p) = &pt.bounded_ty + && let Some(ident) = p.path.get_ident() + { + if has_module_bound(&pt.bounds) { + kinds.insert(ident.clone(), GenericKind::Module); + } else { + kinds.insert(ident.clone(), GenericKind::Plain); + } + } + } + } + } + + ModuleGenerics { kinds } +} + +/// Helper to check if a list of bounds contains "Module". +fn has_module_bound( + bounds: &syn::punctuated::Punctuated, +) -> bool { + has_bound(bounds, "Module") +} + +/// Helper to check if a list of bounds contains the specified bound. +fn has_bound( + bounds: &syn::punctuated::Punctuated, + ident: &str, +) -> bool { + bounds.iter().any(|bound| { + if let TypeParamBound::Trait(trait_bound) = bound + && let Some(segment) = trait_bound.path.segments.last() + { + return segment.ident == ident; + } + false + }) +} + +pub fn parse_ty_generics(ty: &Type, declared: &ModuleGenerics) -> HashSet { + struct Collector<'a> { + generics: HashSet, + declared: &'a ModuleGenerics, + } + + impl<'ast, 'a> Visit<'ast> for Collector<'a> { + fn visit_type_path(&mut self, type_path: &'ast syn::TypePath) { + if type_path.qself.is_none() + && let Some(ident) = type_path.path.get_ident() + && (self.declared.contains(ident)) + { + self.generics.insert(ident.clone()); + } + + syn::visit::visit_type_path(self, type_path); + } + } + + let mut collector = Collector { + generics: HashSet::new(), + declared, + }; + collector.visit_type(ty); + collector.generics +} diff --git a/crates/burn-derive/src/module/mod.rs b/crates/burn-derive/src/module/mod.rs new file mode 100644 index 0000000..66b68d3 --- /dev/null +++ b/crates/burn-derive/src/module/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod codegen; +pub(crate) mod codegen_enum; +pub(crate) mod codegen_struct; +pub(crate) mod display; +pub(crate) mod generics; + +mod base; + +pub(crate) use base::*; diff --git a/crates/burn-derive/src/record_state/mod.rs b/crates/burn-derive/src/record_state/mod.rs new file mode 100644 index 0000000..8bb164b --- /dev/null +++ b/crates/burn-derive/src/record_state/mod.rs @@ -0,0 +1,257 @@ +use quote::quote; +use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type}; + +/// How a state field is serialized. +enum FieldKind { + /// A `Tensor` leaf. + Tensor, + /// An `Option>` leaf (carrying the inner `Tensor` type). + OptionTensor(Type), + /// A `Vec>` field (carrying the inner `Tensor` type). + VecTensor(Type), + /// A scalar leaf serialized via `burn_pack::Scalar`'s `From`/`TryFrom` conversions. + Scalar, + /// An `Option` leaf (carrying the inner scalar type). + OptionScalar(Type), + /// A nested [`RecordState`] field. + Nested, + /// An `Option` field (carrying the inner nested type). + OptionNested(Type), +} + +/// Returns the identifier of the last path segment of a type, if any (`Tensor` -> `Tensor`). +fn head_ident(ty: &Type) -> Option { + match ty { + Type::Path(path) => path.path.segments.last().map(|s| s.ident.to_string()), + _ => None, + } +} + +/// Returns the single generic type argument of a type (the `T` in `Option` / `Vec`). +fn single_generic_arg(ty: &Type) -> Option { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) +} + +fn is_scalar(ident: &str) -> bool { + // Only the widths `burn_pack::Scalar` has `From`/`TryFrom` conversions for. `i128`/`u128` are + // intentionally excluded — `Scalar` tops out at 64-bit, so a 128-bit field is treated as a + // nested `RecordState` and fails with a clear trait-bound error rather than a silent truncation. + matches!( + ident, + "usize" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "i8" + | "i16" + | "i32" + | "i64" + | "f32" + | "f64" + | "bool" + ) +} + +fn classify(ty: &Type) -> Result { + Ok(match head_ident(ty).as_deref() { + Some("Tensor") => FieldKind::Tensor, + Some("Vec") => match single_generic_arg(ty) { + Some(inner) if head_ident(&inner).as_deref() == Some("Tensor") => { + FieldKind::VecTensor(inner) + } + _ => { + return Err(syn::Error::new_spanned( + ty, + "RecordState only supports `Vec>` for sequence fields.", + )); + } + }, + Some("Option") => match single_generic_arg(ty) { + Some(inner) => match head_ident(&inner).as_deref() { + Some("Tensor") => FieldKind::OptionTensor(inner), + Some(ident) if is_scalar(ident) => FieldKind::OptionScalar(inner), + _ => FieldKind::OptionNested(inner), + }, + None => FieldKind::Nested, + }, + Some(ident) if is_scalar(ident) => FieldKind::Scalar, + _ => FieldKind::Nested, + }) +} + +pub(crate) fn derive_impl(ast: &DeriveInput) -> proc_macro::TokenStream { + match try_derive_impl(ast) { + Ok(tokens) => tokens, + Err(err) => err.to_compile_error().into(), + } +} + +fn try_derive_impl(ast: &DeriveInput) -> Result { + let name = &ast.ident; + let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl(); + + let fields = match &ast.data { + Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + other => { + return Err(syn::Error::new_spanned( + other, + "RecordState can only be derived for structs with named fields.", + )); + } + }, + _ => { + return Err(syn::Error::new_spanned( + name, + "RecordState can only be derived for structs.", + )); + } + }; + + let mut flatten = Vec::new(); + let mut unflatten = Vec::new(); + let mut ctor = Vec::new(); + + for field in fields { + let ident = field.ident.as_ref().unwrap(); + let leaf = ident.to_string(); + let ty = &field.ty; + ctor.push(quote! { #ident }); + + match classify(ty)? { + FieldKind::Tensor => { + flatten.push(quote! { + out.push_tensor(prefix, #leaf, self.#ident.clone().into_data()); + }); + unflatten.push(quote! { + let #ident = <#ty>::from_data(src.take_tensor(prefix, #leaf)?, device); + }); + } + FieldKind::OptionTensor(inner) => { + flatten.push(quote! { + if let Some(value) = &self.#ident { + out.push_tensor(prefix, #leaf, value.clone().into_data()); + } + }); + unflatten.push(quote! { + let #ident = src + .take_tensor(prefix, #leaf) + .map(|data| <#inner>::from_data(data, device)); + }); + } + FieldKind::VecTensor(inner) => { + flatten.push(quote! { + out.push_scalar( + prefix, + concat!(#leaf, ".len"), + burn_pack::Scalar::from(self.#ident.len()), + ); + for (__index, __value) in self.#ident.iter().enumerate() { + let __leaf = crate::join_index(#leaf, __index); + out.push_tensor(prefix, &__leaf, __value.clone().into_data()); + } + }); + unflatten.push(quote! { + let #ident = { + let __len = >::try_from( + src.take_scalar(prefix, concat!(#leaf, ".len"))?, + ) + .ok()?; + let mut __items = ::alloc::vec::Vec::with_capacity(__len); + for __index in 0..__len { + let __leaf = crate::join_index(#leaf, __index); + __items.push(<#inner>::from_data( + src.take_tensor(prefix, &__leaf)?, + device, + )); + } + __items + }; + }); + } + FieldKind::Scalar => { + flatten.push(quote! { + out.push_scalar(prefix, #leaf, burn_pack::Scalar::from(self.#ident)); + }); + unflatten.push(quote! { + let #ident = <#ty as ::core::convert::TryFrom>::try_from( + src.take_scalar(prefix, #leaf)?, + ) + .ok()?; + }); + } + FieldKind::OptionScalar(inner) => { + flatten.push(quote! { + if let Some(value) = self.#ident { + out.push_scalar(prefix, #leaf, burn_pack::Scalar::from(value)); + } + }); + unflatten.push(quote! { + let #ident: Option<#inner> = src.take_scalar(prefix, #leaf).and_then(|__s| { + <#inner as ::core::convert::TryFrom>::try_from(__s).ok() + }); + }); + } + FieldKind::Nested => { + flatten.push(quote! { + { + let __path = crate::join_path(prefix, #leaf); + crate::RecordState::state_flatten(&self.#ident, &__path, out); + } + }); + unflatten.push(quote! { + let #ident = { + let __path = crate::join_path(prefix, #leaf); + <#ty as crate::RecordState>::state_unflatten(&__path, src, device)? + }; + }); + } + FieldKind::OptionNested(inner) => { + flatten.push(quote! { + if let Some(value) = &self.#ident { + let __path = crate::join_path(prefix, #leaf); + crate::RecordState::state_flatten(value, &__path, out); + } + }); + unflatten.push(quote! { + let #ident = { + let __path = crate::join_path(prefix, #leaf); + if src.has_under(&__path) { + <#inner as crate::RecordState>::state_unflatten(&__path, src, device) + } else { + None + } + }; + }); + } + } + } + + Ok(quote! { + impl #impl_generics crate::RecordState for #name #ty_generics #where_clause { + fn state_flatten(&self, prefix: &str, out: &mut crate::StateSink) { + #(#flatten)* + } + + fn state_unflatten( + prefix: &str, + src: &mut crate::StateSource, + device: &burn::tensor::Device, + ) -> Option { + #(#unflatten)* + Some(Self { #(#ctor),* }) + } + } + } + .into()) +} diff --git a/crates/burn-derive/src/shared/attribute.rs b/crates/burn-derive/src/shared/attribute.rs new file mode 100644 index 0000000..bc1f037 --- /dev/null +++ b/crates/burn-derive/src/shared/attribute.rs @@ -0,0 +1,49 @@ +use syn::{Attribute, Meta}; + +pub struct AttributeAnalyzer { + attr: Attribute, +} + +#[derive(Clone)] +pub struct AttributeItem { + pub value: syn::Lit, +} + +impl AttributeAnalyzer { + pub fn new(attr: Attribute) -> Self { + Self { attr } + } + + pub fn item(&self) -> AttributeItem { + let value = match &self.attr.meta { + Meta::List(val) => val.parse_args::().unwrap(), + Meta::NameValue(meta) => meta.clone(), + Meta::Path(_) => panic!("Path meta unsupported"), + }; + + let lit = match value.value { + syn::Expr::Lit(lit) => lit.lit, + _ => panic!("Only literal is supported"), + }; + + AttributeItem { value: lit } + } + + pub fn has_name(&self, name: &str) -> bool { + Self::path_syn_name(self.attr.path()) == name + } + + fn path_syn_name(path: &syn::Path) -> String { + let length = path.segments.len(); + let mut name = String::new(); + for (i, segment) in path.segments.iter().enumerate() { + if i == length - 1 { + name += segment.ident.to_string().as_str(); + } else { + let tmp = segment.ident.to_string() + "::"; + name += tmp.as_str(); + } + } + name + } +} diff --git a/crates/burn-derive/src/shared/enum_variant.rs b/crates/burn-derive/src/shared/enum_variant.rs new file mode 100644 index 0000000..ceed9fe --- /dev/null +++ b/crates/burn-derive/src/shared/enum_variant.rs @@ -0,0 +1,51 @@ +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; +use syn::{FieldsNamed, Variant}; + +/// Process a variant of an enum where the output is the result of the given mapper. +pub(crate) fn map_enum_variant( + variant: &Variant, + mapper: Mapper, +) -> (TokenStream, TokenStream) +where + Mapper: Fn(&Ident) -> TokenStream, +{ + let gen_fields_unnamed = |num: usize| { + let mut inputs = Vec::new(); + let mut outputs = Vec::new(); + + for i in 0..num { + let arg_name = Ident::new(&format!("arg_{i}"), Span::call_site()); + let input = quote! { #arg_name }; + let output = mapper(&arg_name); + + inputs.push(input); + outputs.push(output); + } + + (quote! (( #(#inputs),* )), quote! (( #(#outputs),* ))) + }; + let gen_fields_named = |fields: &FieldsNamed| { + let mut inputs = Vec::new(); + let mut outputs = Vec::new(); + + fields.named.iter().for_each(|field| { + let ident = field.ident.as_ref().expect("Named field to have a name."); + let input = quote! { #ident }; + let output = mapper(ident); + + inputs.push(input); + outputs.push(quote! { + #ident: #output + }); + }); + + (quote! {{ #(#inputs),* }}, quote! {{ #(#outputs),* }}) + }; + + match &variant.fields { + syn::Fields::Named(fields) => gen_fields_named(fields), + syn::Fields::Unnamed(_) => gen_fields_unnamed(variant.fields.len()), + syn::Fields::Unit => (quote! {}, quote! {}), + } +} diff --git a/crates/burn-derive/src/shared/field.rs b/crates/burn-derive/src/shared/field.rs new file mode 100644 index 0000000..7dd5534 --- /dev/null +++ b/crates/burn-derive/src/shared/field.rs @@ -0,0 +1,84 @@ +use super::attribute::AttributeAnalyzer; +use proc_macro2::Ident; +use syn::{Field, Type, TypePath}; + +#[derive(Clone)] +pub struct FieldTypeAnalyzer { + pub field: Field, +} + +impl FieldTypeAnalyzer { + pub fn new(field: Field) -> Self { + FieldTypeAnalyzer { field } + } + + pub fn ident(&self) -> Ident { + self.field.ident.clone().unwrap() + } + + pub fn is_of_type(&self, paths: &[&str]) -> bool { + match &self.field.ty { + syn::Type::Path(path) => { + let name = Self::path_name(path); + paths.contains(&name.as_str()) + } + _ => false, + } + } + + #[allow(dead_code)] + pub fn first_generic_field(&self) -> TypePath { + let err = || panic!("Field {} as no generic", self.field.ident.clone().unwrap()); + match &self.field.ty { + syn::Type::Path(path) => Self::path_generic_argument(path), + _ => err(), + } + } + pub fn path_generic_argument(path: &TypePath) -> TypePath { + let segment = path.path.segments.last().unwrap(); + let err = || panic!("Path segment {} has no generic", segment.ident.clone(),); + match &segment.arguments { + syn::PathArguments::None => err(), + syn::PathArguments::AngleBracketed(param) => { + let first_param = param.args.first().unwrap(); + + if let syn::GenericArgument::Type(Type::Path(path)) = first_param { + path.clone() + } else { + err() + } + } + syn::PathArguments::Parenthesized(_) => err(), + } + } + + fn path_name(path: &TypePath) -> String { + let length = path.path.segments.len(); + let mut name = String::new(); + for (i, segment) in path.path.segments.iter().enumerate() { + if i == length - 1 { + name += segment.ident.to_string().as_str(); + } else { + let tmp = segment.ident.to_string() + "::"; + name += tmp.as_str(); + } + } + name + } + + /// Returns the docs of the field. + pub fn docs(&self) -> impl Iterator { + self.field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + } + + pub fn attributes(&self) -> impl Iterator { + self.field + .attrs + .clone() + .into_iter() + .map(AttributeAnalyzer::new) + } +} diff --git a/crates/burn-derive/src/shared/generics.rs b/crates/burn-derive/src/shared/generics.rs new file mode 100644 index 0000000..edbb019 --- /dev/null +++ b/crates/burn-derive/src/shared/generics.rs @@ -0,0 +1,30 @@ +use proc_macro2::Ident; +use syn::{Generics, WhereClause, WherePredicate, parse_quote}; + +#[derive(new)] +pub struct GenericsHelper { + pub(crate) generics: Generics, +} + +impl GenericsHelper { + pub fn add_predicate(&mut self, predicate: WherePredicate) { + let where_clause: WhereClause = match &self.generics.where_clause { + Some(val) => parse_quote! { + #val + #predicate, + }, + None => parse_quote! { + where + #predicate, + }, + }; + self.generics.where_clause = Some(where_clause); + } + + pub fn types(&self) -> Vec { + self.generics + .type_params() + .map(|tp| tp.ident.clone()) + .collect() + } +} diff --git a/crates/burn-derive/src/shared/mod.rs b/crates/burn-derive/src/shared/mod.rs new file mode 100644 index 0000000..009851f --- /dev/null +++ b/crates/burn-derive/src/shared/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod attribute; +pub(crate) mod enum_variant; +pub(crate) mod field; +pub(crate) mod generics; diff --git a/crates/burn-dispatch/Cargo.toml b/crates/burn-dispatch/Cargo.toml new file mode 100644 index 0000000..1ea02a4 --- /dev/null +++ b/crates/burn-dispatch/Cargo.toml @@ -0,0 +1,118 @@ +[package] +authors = [ + "laggui ", + "nathanielsimard ", +] +categories = ["science"] +description = "Backend dispatch for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-dispatch" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-dispatch" +documentation = "https://docs.rs/burn-dispatch" +version.workspace = true + +[lints] +workspace = true + +[features] +default = [ + "std", + "burn-autodiff?/default", + "burn-cpu?/default", + "burn-cuda?/default", + "burn-flex/default", + "burn-ndarray?/default", + "burn-rocm?/default", + "burn-tch?/default", + "burn-wgpu?/default", +] +doc = ["default"] +std = [ + "burn-backend/std", + "burn-autodiff?/std", + "burn-cpu?/std", + "burn-cuda?/std", + "burn-flex/std", + "burn-ndarray?/std", + "burn-rocm?/std", + "burn-tch?/std", + "burn-wgpu?/std", +] +tracing = [ + "burn-autodiff?/tracing", + "burn-cpu?/tracing", + "burn-cuda?/tracing", + "burn-flex/tracing", + "burn-ndarray?/tracing", + "burn-rocm?/tracing", + "burn-tch?/tracing", + "burn-wgpu?/tracing", +] + +# Backends +cuda = ["burn-cuda"] +flex = [] # default +rocm = ["burn-rocm"] +ndarray = ["burn-ndarray"] +tch = ["burn-tch"] +vulkan = ["wgpu", "burn-wgpu/vulkan"] +webgpu = ["wgpu", "burn-wgpu/webgpu"] +metal = ["wgpu", "burn-wgpu/metal"] +wgpu = ["burn-wgpu"] +cpu = ["burn-cpu"] +autodiff = ["burn-autodiff"] +# Remote compute client over Iroh. +remote = ["std", "burn-remote", "burn-remote/iroh"] +# Host a remote compute server over Iroh. +remote-server = ["remote", "burn-remote/server"] +# Add the legacy WebSocket transport. +remote-websocket = ["remote", "burn-remote/websocket"] + +# Backend features +autotune = [ + "burn-wgpu?/autotune", + "burn-cuda?/autotune", + "burn-rocm?/autotune", + "burn-cpu?/autotune", +] +autotune-checks = [ + "burn-wgpu?/autotune-checks", + "burn-cuda?/autotune-checks", + "burn-rocm?/autotune-checks", + "burn-cpu?/autotune-checks", +] +fusion = [ + "burn-wgpu?/fusion", + "burn-cuda?/fusion", + "burn-rocm?/fusion", + "burn-cpu?/fusion", + "burn-remote?/fusion", +] + +[dependencies] +burn-backend = { workspace = true } + +# Backends +burn-autodiff = { workspace = true, optional = true } +burn-cpu = { workspace = true, optional = true } +burn-cuda = { workspace = true, optional = true } +burn-flex = { workspace = true } +burn-ndarray = { workspace = true, optional = true } +burn-tch = { workspace = true, optional = true } +burn-rocm = { workspace = true, optional = true } +burn-wgpu = { workspace = true, optional = true } + +burn-remote = { workspace = true, optional = true, features = ["client"] } + +# Op macros with `.as_$inner_kind()` +paste = { workspace = true } + +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +burn-flex = { workspace = true, features = ["critical-section"]} + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-dispatch/README.md b/crates/burn-dispatch/README.md new file mode 100644 index 0000000..7b81db5 --- /dev/null +++ b/crates/burn-dispatch/README.md @@ -0,0 +1,3 @@ +# Burn Backend Dispatch + +A multi-backend dispatch that forwards the tensor operations to the appropriate backend. diff --git a/crates/burn-dispatch/build.rs b/crates/burn-dispatch/build.rs new file mode 100644 index 0000000..8eca0d7 --- /dev/null +++ b/crates/burn-dispatch/build.rs @@ -0,0 +1,22 @@ +fn main() { + println!("cargo::rustc-check-cfg=cfg(default_backend)"); + + // If you try to build with `--no-default-features`, we enable a cpu backend by default + let cuda = cfg!(feature = "cuda"); + let flex = cfg!(feature = "flex"); + let rocm = cfg!(feature = "rocm"); + let ndarray = cfg!(feature = "ndarray"); + let tch = cfg!(feature = "tch"); + let cpu = cfg!(feature = "cpu"); + let metal = cfg!(feature = "metal"); + let vulkan = cfg!(feature = "vulkan"); + let webgpu = cfg!(feature = "webgpu"); + let wgpu = cfg!(feature = "wgpu"); + + let no_backend_enabled = + !(cuda || flex || rocm || ndarray || tch || cpu || metal || vulkan || webgpu || wgpu); + + if no_backend_enabled { + println!("cargo:rustc-cfg=default_backend"); + } +} diff --git a/crates/burn-dispatch/src/backend.rs b/crates/burn-dispatch/src/backend.rs new file mode 100644 index 0000000..4c5f314 --- /dev/null +++ b/crates/burn-dispatch/src/backend.rs @@ -0,0 +1,940 @@ +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +#[cfg(any( + feature = "cpu", + feature = "ndarray", + feature = "flex", + default_backend +))] +use alloc::vec; + +#[cfg(feature = "autodiff")] +use burn_backend::distributed::{DistributedParamId, DistributedParams}; +use burn_backend::{AutodiffBackend, Backend, BackendGraph, BackendTypes, DType, ExecutionError}; + +/// A captured graph from one of the dispatched backends (see +/// [`BackendTypes::GraphPrimitive`]). +/// +/// Like [`DispatchTensorKind`], one variant per enabled backend: the graph is +/// captured by, and can only replay on, the backend it was recorded on. +#[derive(Debug, Clone)] +pub enum DispatchGraph { + /// A graph captured on the [CPU backend](Cpu). + #[cfg(feature = "cpu")] + Cpu(BackendGraph), + + /// A graph captured on the [CUDA backend](Cuda). + #[cfg(feature = "cuda")] + Cuda(BackendGraph), + + /// A graph captured on the [Metal backend](Metal). + #[cfg(feature = "metal")] + Metal(BackendGraph), + + /// A graph captured on the [ROCm backend](Rocm). + #[cfg(feature = "rocm")] + Rocm(BackendGraph), + + /// A graph captured on the [Vulkan backend](Vulkan). + #[cfg(feature = "vulkan")] + Vulkan(BackendGraph), + + /// A graph captured on the [Wgpu backend](Wgpu). + #[cfg(feature = "wgpu")] + Wgpu(BackendGraph), + + /// A graph captured on the [WebGPU backend](WebGpu). + #[cfg(feature = "webgpu")] + WebGpu(BackendGraph), + + /// A graph captured on the [Flex backend](Flex). + #[cfg(any(feature = "flex", default_backend))] + Flex(BackendGraph), + + /// A graph captured on the [NdArray backend](NdArray). + #[cfg(feature = "ndarray")] + NdArray(BackendGraph), + + /// A graph captured on the [LibTorch backend](LibTorch). + #[cfg(feature = "tch")] + LibTorch(BackendGraph), + + /// A graph captured on the [Remote backend](Remote). + #[cfg(feature = "remote")] + Remote(BackendGraph), +} + +/// The error returned when a graph operation cannot be dispatched. +fn graph_dispatch_err(reason: alloc::string::String) -> ExecutionError { + ExecutionError::WithContext { reason } +} + +/// Match arm generator for [`Backend::graph_stop_capture`] on [`Dispatch`]: +/// each backend's captured graph is wrapped in its [`DispatchGraph`] variant. +macro_rules! graph_stop_capture_arms { + ($device:expr; $([$Backend:ident, $cfg:meta]),*) => { + match $device { + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend(device) => { + <$crate::backends::$Backend as Backend>::graph_stop_capture(device) + .map(DispatchGraph::$Backend) + } + )* + #[allow(unreachable_patterns)] + other => Err(graph_dispatch_err(format!( + "Graph capture is not supported for device {other:?}" + ))), + } + }; +} + +/// Match arm generator for [`Backend::graph_replay`] on [`Dispatch`]: the graph +/// variant must match the device's backend, since a graph only replays on the +/// backend that captured it. +macro_rules! graph_replay_arms { + ($device:expr, $graph:expr; $([$Backend:ident, $cfg:meta]),*) => { + match ($device, $graph) { + $( + #[cfg($cfg)] + ($crate::DispatchDevice::$Backend(device), DispatchGraph::$Backend(graph)) => { + // Safety: forwarded verbatim from `Dispatch::graph_replay`'s + // own contract. + unsafe { + <$crate::backends::$Backend as Backend>::graph_replay(device, graph) + } + } + )* + #[allow(unreachable_patterns)] + (device, _) => Err(graph_dispatch_err(format!( + "The graph was not captured on the backend of device {device:?}" + ))), + } + }; +} + +#[cfg(feature = "autodiff")] +use alloc::boxed::Box; +#[cfg(feature = "autodiff")] +use burn_autodiff::grads::Gradients; + +#[allow(unused)] +use crate::DispatchDeviceId; +#[allow(unused)] +use crate::DispatchTensorKind; +use crate::backends::*; +use crate::{DispatchDevice, DispatchTensor}; + +/// The main execution backend in Burn. +/// +/// [`Dispatch`] acts as a global backend that can manage multiple underlying +/// backends (e.g., `Cpu`, `Cuda`, `Wgpu`, `Metal`, etc.). +/// It is responsible for: +/// - Dispatching tensor operations to the appropriate backend. +/// - Managing cross-backend tensor transfers. +/// +/// Essentially, [`Dispatch`] is the single entry point for executing tensor operations +/// in a backend-agnostic way. It allows Burn to provide a unified, global backend +/// for users while still leveraging multiple specialized backends under the hood. +/// +/// # Example +/// +/// ```ignore +/// use burn::Dispatch; +/// use burn::DispatchDevice; +/// +/// // Select the device to execute operations on +/// let device = DispatchDevice::Cuda(Default::default()); +/// +/// // Create a tensor using the global backend +/// let t = Tensor::::zeros([128, 128], &device); +/// ``` +#[derive(Debug, Default, Clone)] +pub struct Dispatch; + +impl BackendTypes for Dispatch { + type Device = DispatchDevice; + + type FloatTensorPrimitive = DispatchTensor; + type IntTensorPrimitive = DispatchTensor; + type BoolTensorPrimitive = DispatchTensor; + type QuantizedTensorPrimitive = DispatchTensor; + + type GraphPrimitive = DispatchGraph; +} + +impl Backend for Dispatch { + fn name(device: &Self::Device) -> String { + let inner = dispatch_device!(device, |device| B::name(device)); + format!("dispatch<{inner}>") + } + + fn seed(device: &Self::Device, seed: u64) { + dispatch_device!(device, |device| B::seed(device, seed)) + } + + fn sync(device: &Self::Device) -> Result<(), ExecutionError> { + dispatch_device!(device, |device| B::sync(device)) + } + + fn graph_prepare(device: &Self::Device) -> Result<(), ExecutionError> { + dispatch_device!(device, |device| B::graph_prepare(device)) + } + + fn graph_start_capture(device: &Self::Device) -> Result<(), ExecutionError> { + dispatch_device!(device, |device| B::graph_start_capture(device)) + } + + fn graph_stop_capture(device: &Self::Device) -> Result { + backend_list!(graph_stop_capture_arms, device) + } + + unsafe fn graph_replay( + device: &Self::Device, + graph: &DispatchGraph, + ) -> Result<(), ExecutionError> { + backend_list!(graph_replay_arms, device, graph) + } + + fn dtype_usage(device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet { + dispatch_device!(device, |device| B::dtype_usage(device, dtype)) + } + + fn ad_enabled(device: &Self::Device) -> bool { + match device { + #[cfg(feature = "autodiff")] + DispatchDevice::Autodiff(_) => true, + _ => false, + } + } + + fn device_count(type_id: u16) -> usize { + let (dispatch_id, backend_type_id) = DispatchDevice::decode_type_id(type_id); + match dispatch_id { + #[cfg(feature = "cpu")] + DispatchDeviceId::Cpu => Cpu::device_count(backend_type_id), + #[cfg(feature = "cuda")] + DispatchDeviceId::Cuda => Cuda::device_count(backend_type_id), + #[cfg(feature = "metal")] + DispatchDeviceId::Metal => Metal::device_count(backend_type_id), + #[cfg(feature = "rocm")] + DispatchDeviceId::Rocm => Rocm::device_count(backend_type_id), + #[cfg(feature = "vulkan")] + DispatchDeviceId::Vulkan => Vulkan::device_count(backend_type_id), + #[cfg(feature = "wgpu")] + DispatchDeviceId::Wgpu => Wgpu::device_count(backend_type_id), + #[cfg(feature = "webgpu")] + DispatchDeviceId::WebGpu => WebGpu::device_count(backend_type_id), + #[cfg(any(feature = "flex", default_backend))] + DispatchDeviceId::Flex => Flex::device_count(backend_type_id), + #[cfg(feature = "ndarray")] + DispatchDeviceId::NdArray => NdArray::device_count(backend_type_id), + #[cfg(feature = "tch")] + DispatchDeviceId::LibTorch => LibTorch::device_count(backend_type_id), + #[cfg(feature = "remote")] + DispatchDeviceId::Remote => Remote::device_count(backend_type_id), + _ => unreachable!("No backend feature enabled."), + } + } + + fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + device: &Self::Device, + input: Input, + func: Func, + ) -> Output { + dispatch_device!(device, |device| B::memory_persistent_allocations( + device, input, func + )) + } + + fn memory_cleanup(device: &Self::Device) { + dispatch_device!(device, |device| B::memory_cleanup(device)) + } + + fn staging<'a, Iter>(data: Iter, device: &Self::Device) + where + Iter: Iterator, + { + dispatch_device!(device, |device| B::staging(data, device)) + } + + fn supports_dtype(device: &Self::Device, dtype: DType) -> bool { + dispatch_device!(device, |device| B::supports_dtype(device, dtype)) + } + + fn flush(device: &Self::Device) { + dispatch_device!(device, |device| B::flush(device)) + } +} + +#[cfg(feature = "autodiff")] +impl AutodiffBackend for Dispatch { + type InnerBackend = Dispatch; + + type Gradients = Gradients; + + fn backward(tensor: DispatchTensor) -> Self::Gradients { + let DispatchTensor { kind, .. } = tensor; + + match kind { + DispatchTensorKind::Autodiff(tensor) => match *tensor { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => tensor.autodiff().backward(), + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => tensor.autodiff().backward(), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => tensor.autodiff().backward(), + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + }, + _ => panic!("Requires autodiff tensor."), + } + } + + fn grad(tensor: &DispatchTensor, grads: &Self::Gradients) -> Option { + let DispatchTensor { + kind, + checkpointing, + } = tensor; + let grad: Option = match &kind { + DispatchTensorKind::Autodiff(inner_kind) => match &**inner_kind { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Cpu(crate::BackendTensor::Float(t))), + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Cuda(crate::BackendTensor::Float(t))), + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Metal(crate::BackendTensor::Float(t))), + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Rocm(crate::BackendTensor::Float(t))), + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Vulkan(crate::BackendTensor::Float(t))), + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Wgpu(crate::BackendTensor::Float(t))), + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::WebGpu(crate::BackendTensor::Float(t))), + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Flex(crate::BackendTensor::Float(t))), + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::NdArray(crate::BackendTensor::Float(t))), + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::LibTorch(crate::BackendTensor::Float(t))), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => tensor + .as_autodiff() + .grad(grads) + .map(|t| DispatchTensorKind::Remote(crate::BackendTensor::Float(t))), + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + }, + _ => panic!("Requires autodiff tensor."), + }; + grad.map(|kind| DispatchTensor { + kind, + checkpointing: *checkpointing, + }) + } + + fn grad_remove(tensor: &DispatchTensor, grads: &mut Self::Gradients) -> Option { + let DispatchTensor { + kind, + checkpointing, + } = tensor; + let grad: Option = match &kind { + DispatchTensorKind::Autodiff(inner_kind) => match &**inner_kind { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Cpu(crate::BackendTensor::Float(t))), + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Cuda(crate::BackendTensor::Float(t))), + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Metal(crate::BackendTensor::Float(t))), + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Rocm(crate::BackendTensor::Float(t))), + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Vulkan(crate::BackendTensor::Float(t))), + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Wgpu(crate::BackendTensor::Float(t))), + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::WebGpu(crate::BackendTensor::Float(t))), + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Flex(crate::BackendTensor::Float(t))), + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::NdArray(crate::BackendTensor::Float(t))), + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::LibTorch(crate::BackendTensor::Float(t))), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => tensor + .as_autodiff() + .grad_remove(grads) + .map(|t| DispatchTensorKind::Remote(crate::BackendTensor::Float(t))), + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + }, + _ => panic!("Requires autodiff tensor."), + }; + grad.map(|kind| DispatchTensor { + kind, + checkpointing: *checkpointing, + }) + } + + fn grad_replace(tensor: &DispatchTensor, grads: &mut Self::Gradients, grad: DispatchTensor) { + let DispatchTensor { + kind, + checkpointing, + } = tensor; + let DispatchTensor { + kind: grad, + checkpointing: grad_ckp, + } = grad; + debug_assert_eq!(checkpointing, &grad_ckp); + + match &kind { + DispatchTensorKind::Autodiff(inner_kind) => match (&**inner_kind, grad) { + #[cfg(feature = "cpu")] + (DispatchTensorKind::Cpu(tensor), DispatchTensorKind::Cpu(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "cuda")] + (DispatchTensorKind::Cuda(tensor), DispatchTensorKind::Cuda(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "metal")] + (DispatchTensorKind::Metal(tensor), DispatchTensorKind::Metal(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "rocm")] + (DispatchTensorKind::Rocm(tensor), DispatchTensorKind::Rocm(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "vulkan")] + (DispatchTensorKind::Vulkan(tensor), DispatchTensorKind::Vulkan(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "wgpu")] + (DispatchTensorKind::Wgpu(tensor), DispatchTensorKind::Wgpu(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "webgpu")] + (DispatchTensorKind::WebGpu(tensor), DispatchTensorKind::WebGpu(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(any(feature = "flex", default_backend))] + (DispatchTensorKind::Flex(tensor), DispatchTensorKind::Flex(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "ndarray")] + (DispatchTensorKind::NdArray(tensor), DispatchTensorKind::NdArray(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + #[cfg(feature = "remote")] + (DispatchTensorKind::Remote(tensor), DispatchTensorKind::Remote(grad)) => { + tensor.as_autodiff().grad_replace(grads, grad.float()) + } + (DispatchTensorKind::Autodiff(_), _) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + // TODO: distributed message? + (t, g) => panic!( + "The provided tensors are not on the same backend. Got backends {t:?} and {g:?}." + ), + }, + _ => panic!("Requires autodiff tensor."), + } + } + + fn inner(tensor: DispatchTensor) -> DispatchTensor { + let DispatchTensor { + kind, + checkpointing: _, + } = tensor; + + let kind = match kind { + DispatchTensorKind::Autodiff(inner_kind) => match *inner_kind { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => DispatchTensorKind::Cpu( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => DispatchTensorKind::Cuda( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => DispatchTensorKind::Metal( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => DispatchTensorKind::Rocm( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => DispatchTensorKind::Vulkan( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => DispatchTensorKind::Wgpu( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => DispatchTensorKind::WebGpu( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => DispatchTensorKind::Flex( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => DispatchTensorKind::NdArray( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => DispatchTensorKind::LibTorch( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => DispatchTensorKind::Remote( + crate::BackendTensor::Float(tensor.autodiff().primitive), + ), + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + }, + _ => panic!("Requires autodiff tensor."), + }; + DispatchTensor { + kind, + checkpointing: None, + } + } + + fn int_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + fn bool_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + fn q_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + fn from_inner(tensor: DispatchTensor) -> DispatchTensor { + let DispatchTensor { + kind, + checkpointing, + } = tensor; + + let kind = match kind { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Cpu( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Cuda( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Metal( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Rocm( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Vulkan( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Wgpu( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::WebGpu( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Flex( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::NdArray( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => DispatchTensorKind::Autodiff(Box::new( + DispatchTensorKind::LibTorch(crate::BackendTensor::Autodiff( + Autodiff::::from_inner(tensor.float()), + )), + )), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Remote( + crate::BackendTensor::Autodiff(Autodiff::::from_inner(tensor.float())), + ))) + } + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + }; + + // TODO: should use C::STRATEGY + let checkpointing = if let Some(strategy) = checkpointing { + Some(strategy) + } else { + Some(crate::CheckpointingStrategy::None) + }; + DispatchTensor { + kind, + checkpointing, + } + } + + fn int_from_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + fn bool_from_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + fn q_from_inner(tensor: DispatchTensor) -> DispatchTensor { + tensor + } + + // Only the collective-capable backends (Cuda/Remote) carry distributed params; in builds + // without them the match arms cfg out, leaving the bindings unused and the tail unreachable. + #[allow(unused_variables, unreachable_code)] + fn set_distributed_params( + tensor: DispatchTensor, + param_id: DistributedParamId, + ) -> DispatchTensor { + let DispatchTensor { + kind, + checkpointing, + } = tensor; + + let kind = match kind { + DispatchTensorKind::Autodiff(inner_kind) => match *inner_kind { + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Cuda( + crate::BackendTensor::Autodiff(Autodiff::::set_distributed_params( + tensor.as_autodiff().clone(), + param_id, + )), + ))) + } + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => { + DispatchTensorKind::Autodiff(Box::new(DispatchTensorKind::Remote( + crate::BackendTensor::Autodiff(Autodiff::::set_distributed_params( + tensor.as_autodiff().clone(), + param_id, + )), + ))) + } + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + other => { + panic!("Distributed operations are not supported for tensor kind {other:?}") + } + }, + _ => panic!("Requires autodiff tensor."), + }; + + let checkpointing = if let Some(strategy) = checkpointing { + Some(strategy) + } else { + Some(crate::CheckpointingStrategy::None) + }; + DispatchTensor { + kind, + checkpointing, + } + } + + #[allow(unused_variables)] + fn distributed_params(tensor: &DispatchTensor) -> Option { + let DispatchTensor { + kind, + checkpointing: _, + } = tensor; + + match &kind { + DispatchTensorKind::Autodiff(inner_kind) => match &**inner_kind { + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => { + tensor.as_autodiff().node.distributed_params.clone() + } + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => { + tensor.as_autodiff().node.distributed_params.clone() + } + + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + // Backends without distributed support never carry distributed params. + _ => None, + }, + _ => panic!("Requires autodiff tensor."), + } + } + + #[allow(unused_variables)] + fn is_distributed(tensor: &DispatchTensor) -> bool { + let DispatchTensor { + kind, + checkpointing: _, + } = tensor; + + match &kind { + DispatchTensorKind::Autodiff(inner_kind) => match &**inner_kind { + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => { + tensor.as_autodiff().node.distributed_params.is_some() + } + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => { + tensor.as_autodiff().node.distributed_params.is_some() + } + + DispatchTensorKind::Autodiff(_) => { + panic!("Autodiff should not wrap an autodiff tensor.") + } + // Backends without distributed support are never distributed. + _ => false, + }, + _ => panic!("Requires autodiff tensor."), + } + } +} + +// NOTE: placeholder for autodiff module requirements +#[cfg(not(feature = "autodiff"))] +impl AutodiffBackend for Dispatch { + type InnerBackend = Dispatch; + + type Gradients = bool; + + fn backward(_tensor: DispatchTensor) -> Self::Gradients { + unimplemented!("Requires `autodiff` feature") + } + + fn grad(_tensor: &DispatchTensor, _grads: &Self::Gradients) -> Option { + unimplemented!("Requires `autodiff` feature") + } + + fn grad_remove( + _tensor: &DispatchTensor, + _grads: &mut Self::Gradients, + ) -> Option { + unimplemented!("Requires `autodiff` feature") + } + + fn grad_replace(_tensor: &DispatchTensor, _grads: &mut Self::Gradients, _grad: DispatchTensor) { + unimplemented!("Requires `autodiff` feature") + } + + fn inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn int_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn bool_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn q_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn from_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn int_from_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn bool_from_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } + + fn q_from_inner(_tensor: DispatchTensor) -> DispatchTensor { + unimplemented!("Requires `autodiff` feature") + } +} + +impl Dispatch { + /// List all available devices of the specified [type id](DispatchDeviceId). + pub fn enumerate(type_id: DispatchDeviceId) -> Vec { + // TODO: right now this assumes `type_id = 0`, but WgpuDevice and LibTorchDevice have other types. + match type_id { + #[cfg(feature = "cpu")] + DispatchDeviceId::Cpu => vec![CpuDevice.into()], + #[cfg(feature = "cuda")] + DispatchDeviceId::Cuda => (0..Cuda::device_count(0)) + .map(|i| CudaDevice::new(i).into()) + .collect(), + #[cfg(feature = "metal")] + DispatchDeviceId::Metal => (0..Metal::device_count(0)) + .map(|i| DispatchDevice::Metal(WgpuDevice::DiscreteGpu(i))) + .collect(), + #[cfg(feature = "rocm")] + DispatchDeviceId::Rocm => (0..Rocm::device_count(0)) + .map(|i| RocmDevice::new(i).into()) + .collect(), + #[cfg(feature = "vulkan")] + DispatchDeviceId::Vulkan => (0..Vulkan::device_count(0)) + .map(|i| DispatchDevice::Vulkan(WgpuDevice::DiscreteGpu(i))) + .collect(), + #[cfg(feature = "wgpu")] + DispatchDeviceId::Wgpu => (0..Wgpu::device_count(0)) + .map(|i| DispatchDevice::Wgpu(WgpuDevice::DiscreteGpu(i))) + .collect(), + #[cfg(feature = "webgpu")] + DispatchDeviceId::WebGpu => (0..WebGpu::device_count(0)) + .map(|i| DispatchDevice::WebGpu(WgpuDevice::DiscreteGpu(i))) + .collect(), + #[cfg(any(feature = "flex", default_backend))] + DispatchDeviceId::Flex => vec![FlexDevice.into()], + #[cfg(feature = "ndarray")] + DispatchDeviceId::NdArray => vec![NdArrayDevice::Cpu.into()], + #[cfg(feature = "tch")] + DispatchDeviceId::LibTorch => (0..LibTorch::device_count(0)) + .map(|i| LibTorchDevice::Cuda(i).into()) + .collect(), + #[cfg(feature = "remote")] + // Remote devices are keyed by a network address, which the type-id-only + // `enumerate` can't carry. Use [`Dispatch::enumerate_remote_websocket`] to list the devices + // behind a given address. + DispatchDeviceId::Remote => Vec::new(), + _ => unreachable!("No backend feature enabled."), + } + } + + /// List every device hosted by the remote server at `address`. + /// + /// Unlike [`enumerate`](Self::enumerate), remote devices are identified by a network + /// address rather than enumerable local hardware, so they need a dedicated entry point. + /// Connecting to the server (required to learn its device count) happens here; see + /// [`RemoteDevice::enumerate_websocket`]. + /// + /// Websocket-only: Iroh peers are addressed by endpoint identity, not a URL string. + #[cfg(feature = "remote-websocket")] + pub fn enumerate_remote_websocket(address: &str) -> Vec { + RemoteDevice::enumerate_websocket(address) + .into_iter() + .map(DispatchDevice::Remote) + .collect() + } +} diff --git a/crates/burn-dispatch/src/device.rs b/crates/burn-dispatch/src/device.rs new file mode 100644 index 0000000..ca0722d --- /dev/null +++ b/crates/burn-dispatch/src/device.rs @@ -0,0 +1,654 @@ +use burn_backend::{DeviceId, DeviceOps, DeviceSettings}; + +use crate::backends::*; + +#[cfg(feature = "autodiff")] +use alloc::boxed::Box; + +/// Represents a device for the [`Dispatch`](crate::Dispatch). +/// +/// Each variant corresponds to a backend that the [`Dispatch`](crate::Dispatch) can dispatch operations to. +/// +/// # Example +/// +/// ```ignore +/// use burn::DispatchDevice; +/// +/// #[cfg(feature = "cpu")] +/// let cpu_device = DispatchDevice::Cpu(Default::default()); +/// +/// #[cfg(feature = "cuda")] +/// let cuda_device = DispatchDevice::Cuda(Default::default()); +/// ``` +#[derive(Clone, Eq)] +pub enum DispatchDevice { + /// The [CPU backend](Cpu) device. + #[cfg(feature = "cpu")] + Cpu(CpuDevice), + + /// The [CUDA backend](Cuda) device. + #[cfg(feature = "cuda")] + Cuda(CudaDevice), + + /// The [Metal backend](Metal) device (via WGPU runtime). + #[cfg(feature = "metal")] + Metal(WgpuDevice), + + /// The [ROCm backend](Rocm) device. + #[cfg(feature = "rocm")] + Rocm(RocmDevice), + + /// The [Vulkan backend](Vulkan) device. + #[cfg(feature = "vulkan")] + Vulkan(WgpuDevice), + + /// The [Wgpu backend](Wgpu) device (via WGPU runtime with auto-selected compiler). + #[cfg(feature = "wgpu")] + Wgpu(WgpuDevice), + + /// The [WebGPU backend](WebGpu) device (via WGPU runtime). + #[cfg(feature = "webgpu")] + WebGpu(WgpuDevice), + + /// The [Flex backend](Flex) device (CPU-only). + #[cfg(any(feature = "flex", default_backend))] + Flex(FlexDevice), + + /// The [NdArray backend](NdArray) device (CPU-only). + #[cfg(feature = "ndarray")] + NdArray(NdArrayDevice), + + /// The [LibTorch backend](LibTorch) device. + #[cfg(feature = "tch")] + LibTorch(LibTorchDevice), + + /// The [remote backend](Remote) device, identified by a network address. + #[cfg(feature = "remote")] + Remote(RemoteDevice), + + /// The [autodiff enabled backend](Autodiff) device. + #[cfg(feature = "autodiff")] + Autodiff(AutodiffDevice), +} + +#[cfg(feature = "autodiff")] +// This tuple struct mainly restricts users from creating Autodiff(Autodiff) devices. +/// A wrapper that enables automatic differentiation for a [`DispatchDevice`]. +/// +/// Use [`DispatchDevice::autodiff`] to construct this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutodiffDevice { + pub(crate) inner: Box, + pub(crate) checkpointing: CheckpointingStrategy, +} + +#[cfg(feature = "autodiff")] +impl AutodiffDevice { + pub(crate) fn new(device: DispatchDevice, checkpointing: CheckpointingStrategy) -> Self { + Self { + inner: Box::new(device), + checkpointing, + } + } + + /// Returns the underlying device, removing the autodiff capability. + pub fn inner(self) -> DispatchDevice { + *self.inner + } +} + +#[cfg(feature = "autodiff")] +// Useful for match in dispatch macros +impl core::ops::Deref for AutodiffDevice { + type Target = DispatchDevice; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// Checkpointing strategy for autodiff. +#[repr(u8)] +pub enum CheckpointingStrategy { + Balanced, + #[default] + None, +} + +#[cfg(feature = "autodiff")] +pub(crate) fn validate_checkpointing( + lhs: Option, + rhs: Option, +) -> Option { + match (lhs, rhs) { + (Some(lhs), Some(rhs)) => { + assert_eq!( + lhs, rhs, + "Autodiff strategy mismatch: {lhs:?} vs {rhs:?}. Tensors in the same operation must share a strategy." + ); + Some(lhs) + } + (None, None) => None, + // When tensors are created on non-autodiff device there is no checkpointing, but + // tensor created with autodiff which moved out (`tensor.inner()`) will still carry the state. + // In such cases, we can "promote" the checkpointing. + (None, rhs) => rhs, + (lhs, None) => lhs, + } +} + +impl core::fmt::Debug for DispatchDevice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(device) => f.debug_tuple("Cpu").field(device).finish(), + #[cfg(feature = "cuda")] + Self::Cuda(device) => f.debug_tuple("Cuda").field(device).finish(), + #[cfg(feature = "metal")] + Self::Metal(device) => f.debug_tuple("Metal").field(device).finish(), + #[cfg(feature = "rocm")] + Self::Rocm(device) => f.debug_tuple("Rocm").field(device).finish(), + #[cfg(feature = "vulkan")] + Self::Vulkan(device) => f.debug_tuple("Vulkan").field(device).finish(), + #[cfg(feature = "wgpu")] + Self::Wgpu(device) => f.debug_tuple("Wgpu").field(device).finish(), + #[cfg(feature = "webgpu")] + Self::WebGpu(device) => f.debug_tuple("WebGpu").field(device).finish(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(device) => f.debug_tuple("Flex").field(device).finish(), + #[cfg(feature = "ndarray")] + Self::NdArray(device) => f.debug_tuple("NdArray").field(device).finish(), + #[cfg(feature = "tch")] + Self::LibTorch(device) => f.debug_tuple("LibTorch").field(device).finish(), + #[cfg(feature = "remote")] + Self::Remote(device) => f.debug_tuple("Remote").field(device).finish(), + #[cfg(feature = "autodiff")] + // Format without `AutodiffDevice` wrapper + Self::Autodiff(device) => f.debug_tuple("Autodiff").field(&device.inner).finish(), + } + } +} + +impl Default for DispatchDevice { + #[allow(unreachable_code)] + fn default() -> Self { + // TODO: which priority? + // Single override e.g. `BURN_DEVICE=vulkan` forces Vulkan or panics if not available. + // Priority list e.g. `BURN_DEVICE_PRIORITY=cuda,vulkan,cpu` sets the order. + // Both could be tied into `burn.toml` config + // For now we just use `BURN_DEVICE` on CI to force a single device + + #[cfg(feature = "std")] + { + if let Ok(device_str) = std::env::var("BURN_DEVICE") { + match device_str.to_lowercase().as_str() { + "cuda" => { + #[cfg(feature = "cuda")] + return Self::Cuda(CudaDevice::default()); + panic!( + "BURN_DEVICE=cuda requested, but the 'cuda' feature is not enabled." + ); + } + "metal" => { + #[cfg(feature = "metal")] + return Self::Metal(burn_wgpu::WgpuDevice::default()); + panic!( + "BURN_DEVICE=metal requested, but the 'metal' feature is not enabled." + ); + } + "rocm" => { + #[cfg(feature = "rocm")] + return Self::Rocm(RocmDevice::default()); + panic!( + "BURN_DEVICE=rocm requested, but the 'rocm' feature is not enabled." + ); + } + "vulkan" => { + #[cfg(feature = "vulkan")] + return Self::Vulkan(burn_wgpu::WgpuDevice::default()); + panic!( + "BURN_DEVICE=vulkan requested, but the 'vulkan' feature is not enabled." + ); + } + "webgpu" => { + #[cfg(feature = "webgpu")] + return Self::WebGpu(burn_wgpu::WgpuDevice::default()); + panic!( + "BURN_DEVICE=webgpu requested, but the 'webgpu' feature is not enabled." + ); + } + "wgpu" => { + #[cfg(feature = "wgpu")] + return Self::Wgpu(burn_wgpu::WgpuDevice::default()); + panic!( + "BURN_DEVICE=wgpu requested, but the 'wgpu' feature is not enabled." + ); + } + "cpu" => { + #[cfg(feature = "cpu")] + return Self::Cpu(CpuDevice); + panic!("BURN_DEVICE=cpu requested, but the 'cpu' feature is not enabled."); + } + "tch" => { + #[cfg(feature = "tch")] + return Self::LibTorch(LibTorchDevice::default()); + panic!("BURN_DEVICE=tch requested, but the 'tch' feature is not enabled."); + } + "remote" => { + #[cfg(feature = "remote")] + return Self::Remote(RemoteDevice::default()); + panic!( + "BURN_DEVICE=remote requested, but the 'remote' feature is not enabled." + ); + } + "flex" => { + #[cfg(any(feature = "flex", default_backend))] + return Self::Flex(FlexDevice); + panic!( + "BURN_DEVICE=flex requested, but the 'flex' feature is not enabled." + ); + } + "ndarray" => { + #[cfg(feature = "ndarray")] + return Self::NdArray(NdArrayDevice::default()); + panic!( + "BURN_DEVICE=ndarray requested, but the 'ndarray' feature is not enabled." + ); + } + _ => panic!("Unknown BURN_DEVICE override: '{}'.", device_str), + } + } + } + + #[cfg(feature = "cuda")] + return Self::Cuda(CudaDevice::default()); + + #[cfg(feature = "metal")] + return Self::Metal(burn_wgpu::WgpuDevice::default()); + + #[cfg(feature = "rocm")] + return Self::Rocm(RocmDevice::default()); + + #[cfg(feature = "vulkan")] + return Self::Vulkan(burn_wgpu::WgpuDevice::default()); + + #[cfg(feature = "webgpu")] + return Self::WebGpu(burn_wgpu::WgpuDevice::default()); + + #[cfg(feature = "wgpu")] + return Self::Wgpu(burn_wgpu::WgpuDevice::default()); + + #[cfg(feature = "cpu")] + return Self::Cpu(CpuDevice); + + #[cfg(feature = "tch")] + return Self::LibTorch(LibTorchDevice::default()); + + // Prefer Flex over NdArray when both are enabled: Flex is the long-term + // CPU backend replacement and should win the default tie. + #[cfg(any(feature = "flex", default_backend))] + return Self::Flex(FlexDevice); + + #[cfg(feature = "remote")] + return Self::Remote(RemoteDevice::default()); + + #[cfg(feature = "ndarray")] + return Self::NdArray(NdArrayDevice::default()); + } +} + +impl PartialEq for DispatchDevice { + /// Compares devices based on hardware identity. + /// + /// Returns `true` if both devices represent the same compute resource. + /// Note that this comparison ignores autodiff and checkpointing settings. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + // If both are Autodiff, compare the inner devices + #[cfg(feature = "autodiff")] + (DispatchDevice::Autodiff(a), DispatchDevice::Autodiff(b)) => a == b, + // If one is Autodiff, compare it to the raw device + #[cfg(feature = "autodiff")] + (DispatchDevice::Autodiff(a), b) => a.inner.as_ref() == b, + #[cfg(feature = "autodiff")] + (a, DispatchDevice::Autodiff(b)) => a == b.inner.as_ref(), + #[cfg(feature = "cpu")] + (Self::Cpu(a), Self::Cpu(b)) => a == b, + #[cfg(feature = "cuda")] + (Self::Cuda(a), Self::Cuda(b)) => a == b, + #[cfg(feature = "metal")] + (Self::Metal(a), Self::Metal(b)) => a == b, + #[cfg(feature = "rocm")] + (Self::Rocm(a), Self::Rocm(b)) => a == b, + #[cfg(feature = "vulkan")] + (Self::Vulkan(a), Self::Vulkan(b)) => a == b, + #[cfg(feature = "wgpu")] + (Self::Wgpu(a), Self::Wgpu(b)) => a == b, + #[cfg(feature = "webgpu")] + (Self::WebGpu(a), Self::WebGpu(b)) => a == b, + #[cfg(any(feature = "flex", default_backend))] + (Self::Flex(a), Self::Flex(b)) => a == b, + #[cfg(feature = "ndarray")] + (Self::NdArray(a), Self::NdArray(b)) => a == b, + #[cfg(feature = "tch")] + (Self::LibTorch(a), Self::LibTorch(b)) => a == b, + #[cfg(feature = "remote")] + (Self::Remote(a), Self::Remote(b)) => a == b, + #[allow(unreachable_patterns)] + (_, _) => false, + } + } +} + +const INTERNAL_ID_MASK: u16 = 0x00FF; +const BACKEND_SHIFT: u32 = 8; + +impl DispatchDevice { + #[cfg(feature = "autodiff")] + /// Creates a new [`DispatchDevice`] with [automatic differentiation](Autodiff) enabled. + pub fn autodiff(device: impl Into) -> DispatchDevice { + Self::autodiff_checkpointed(device, CheckpointingStrategy::None) + } + #[cfg(feature = "autodiff")] + /// Creates a new [`DispatchDevice`] with [automatic differentiation](Autodiff) enabled. + pub fn autodiff_checkpointed( + device: impl Into, + checkpointing: CheckpointingStrategy, + ) -> DispatchDevice { + let device = device.into(); + DispatchDevice::Autodiff(AutodiffDevice::new(device, checkpointing)) + } + + /// Returns the inner device, without autodiff (when enabled). + pub fn inner(self) -> Self { + #[cfg(feature = "autodiff")] + if let DispatchDevice::Autodiff(device) = self { + return *device.inner; + } + + self + } + + /// Returns a unique number per variant to encode into type_id. + fn backend_id(&self) -> DispatchDeviceId { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(_) => DispatchDeviceId::Cpu, + #[cfg(feature = "cuda")] + Self::Cuda(_) => DispatchDeviceId::Cuda, + #[cfg(feature = "metal")] + Self::Metal(_) => DispatchDeviceId::Metal, + #[cfg(feature = "rocm")] + Self::Rocm(_) => DispatchDeviceId::Rocm, + #[cfg(feature = "vulkan")] + Self::Vulkan(_) => DispatchDeviceId::Vulkan, + #[cfg(feature = "wgpu")] + Self::Wgpu(_) => DispatchDeviceId::Wgpu, + #[cfg(feature = "webgpu")] + Self::WebGpu(_) => DispatchDeviceId::WebGpu, + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(_) => DispatchDeviceId::Flex, + #[cfg(feature = "ndarray")] + Self::NdArray(_) => DispatchDeviceId::NdArray, + #[cfg(feature = "tch")] + Self::LibTorch(_) => DispatchDeviceId::LibTorch, + #[cfg(feature = "remote")] + Self::Remote(_) => DispatchDeviceId::Remote, + #[cfg(feature = "autodiff")] + Self::Autodiff(device) => device.inner.backend_id(), + } + } + + /// Encode variant ID and backend type ID into a unique `type_id`. + fn encode_type_id(&self, backend_type_id: u16) -> u16 { + // Use the lower 8 bits for the backend's internal type ID + let internal_type_id = backend_type_id & INTERNAL_ID_MASK; + // Use the upper 8 bits for the DispatchDevice/DispatchDeviceId + let backend = u16::from(self.backend_id()) << BACKEND_SHIFT; + backend | internal_type_id + } + + /// Decode an encoded `type_id` into variant ID and backend type ID. + pub(crate) fn decode_type_id(type_id: u16) -> (DispatchDeviceId, u16) { + let backend_raw = type_id >> BACKEND_SHIFT; + let internal_type_id = type_id & INTERNAL_ID_MASK; + + let backend = DispatchDeviceId::try_from(backend_raw).expect("Unknown DispatchDevice ID"); + + (backend, internal_type_id) + } +} + +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum DispatchDeviceId { + Cpu = 0, + Cuda = 1, + Wgpu = 2, + Rocm = 3, + Flex = 4, + LibTorch = 5, + NdArray = 6, + Metal = 7, + Vulkan = 8, + WebGpu = 9, + Remote = 10, +} + +impl From for u16 { + fn from(variant: DispatchDeviceId) -> Self { + variant as u16 + } +} + +impl TryFrom for DispatchDeviceId { + type Error = (); + + fn try_from(value: u16) -> Result { + match value { + #[cfg(feature = "cpu")] + 0 => Ok(Self::Cpu), + #[cfg(feature = "cuda")] + 1 => Ok(Self::Cuda), + #[cfg(feature = "wgpu")] + 2 => Ok(Self::Wgpu), + #[cfg(feature = "rocm")] + 3 => Ok(Self::Rocm), + #[cfg(any(feature = "flex", default_backend))] + 4 => Ok(Self::Flex), + #[cfg(feature = "tch")] + 5 => Ok(Self::LibTorch), + #[cfg(feature = "ndarray")] + 6 => Ok(Self::NdArray), + #[cfg(feature = "metal")] + 7 => Ok(Self::Metal), + #[cfg(feature = "vulkan")] + 8 => Ok(Self::Vulkan), + #[cfg(feature = "webgpu")] + 9 => Ok(Self::WebGpu), + #[cfg(feature = "remote")] + 10 => Ok(Self::Remote), + _ => Err(()), + } + } +} + +impl DeviceOps for DispatchDevice { + fn defaults(&self) -> DeviceSettings { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(device) => device.defaults(), + #[cfg(feature = "cuda")] + Self::Cuda(device) => device.defaults(), + #[cfg(feature = "metal")] + Self::Metal(device) => device.defaults(), + #[cfg(feature = "rocm")] + Self::Rocm(device) => device.defaults(), + #[cfg(feature = "vulkan")] + Self::Vulkan(device) => device.defaults(), + #[cfg(feature = "wgpu")] + Self::Wgpu(device) => device.defaults(), + #[cfg(feature = "webgpu")] + Self::WebGpu(device) => device.defaults(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(device) => device.defaults(), + #[cfg(feature = "ndarray")] + Self::NdArray(device) => device.defaults(), + #[cfg(feature = "tch")] + Self::LibTorch(device) => device.defaults(), + #[cfg(feature = "remote")] + Self::Remote(device) => device.defaults(), + #[cfg(feature = "autodiff")] + Self::Autodiff(device) => device.inner.defaults(), + } + } +} + +impl burn_backend::Device for DispatchDevice { + fn from_id(mut device_id: DeviceId) -> Self { + let (dispatch_id, backend_type_id) = Self::decode_type_id(device_id.type_id); + device_id.type_id = backend_type_id; + + match dispatch_id { + #[cfg(feature = "cpu")] + DispatchDeviceId::Cpu => Self::Cpu(CpuDevice::from_id(device_id)), + #[cfg(feature = "cuda")] + DispatchDeviceId::Cuda => Self::Cuda(CudaDevice::from_id(device_id)), + #[cfg(feature = "metal")] + DispatchDeviceId::Metal => Self::Metal(WgpuDevice::from_id(device_id)), + #[cfg(feature = "rocm")] + DispatchDeviceId::Rocm => Self::Rocm(RocmDevice::from_id(device_id)), + #[cfg(feature = "vulkan")] + DispatchDeviceId::Vulkan => Self::Vulkan(WgpuDevice::from_id(device_id)), + #[cfg(feature = "wgpu")] + DispatchDeviceId::Wgpu => Self::Wgpu(WgpuDevice::from_id(device_id)), + #[cfg(feature = "webgpu")] + DispatchDeviceId::WebGpu => Self::WebGpu(WgpuDevice::from_id(device_id)), + #[cfg(any(feature = "flex", default_backend))] + DispatchDeviceId::Flex => Self::Flex(FlexDevice::from_id(device_id)), + #[cfg(feature = "ndarray")] + DispatchDeviceId::NdArray => Self::NdArray(NdArrayDevice::from_id(device_id)), + #[cfg(feature = "tch")] + DispatchDeviceId::LibTorch => Self::LibTorch(LibTorchDevice::from_id(device_id)), + #[cfg(feature = "remote")] + DispatchDeviceId::Remote => Self::Remote(RemoteDevice::from_id(device_id)), + _ => unreachable!("No backend feature enabled."), + } + } + + fn to_id(&self) -> DeviceId { + let mut device_id = match self { + #[cfg(feature = "cpu")] + Self::Cpu(device) => device.to_id(), + #[cfg(feature = "cuda")] + Self::Cuda(device) => device.to_id(), + #[cfg(feature = "metal")] + Self::Metal(device) => device.to_id(), + #[cfg(feature = "rocm")] + Self::Rocm(device) => device.to_id(), + #[cfg(feature = "vulkan")] + Self::Vulkan(device) => device.to_id(), + #[cfg(feature = "wgpu")] + Self::Wgpu(device) => device.to_id(), + #[cfg(feature = "webgpu")] + Self::WebGpu(device) => device.to_id(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(device) => device.to_id(), + #[cfg(feature = "ndarray")] + Self::NdArray(device) => device.to_id(), + #[cfg(feature = "tch")] + Self::LibTorch(device) => device.to_id(), + #[cfg(feature = "remote")] + Self::Remote(device) => device.to_id(), + #[cfg(feature = "autodiff")] + Self::Autodiff(device) => device.inner.to_id(), + }; + device_id.type_id = self.encode_type_id(device_id.type_id); + device_id + } +} + +#[cfg(feature = "cpu")] +impl From for DispatchDevice { + fn from(device: CpuDevice) -> Self { + DispatchDevice::Cpu(device) + } +} + +#[cfg(feature = "cuda")] +impl From for DispatchDevice { + fn from(device: CudaDevice) -> Self { + DispatchDevice::Cuda(device) + } +} + +#[cfg(feature = "rocm")] +impl From for DispatchDevice { + fn from(device: RocmDevice) -> Self { + DispatchDevice::Rocm(device) + } +} + +// A bare `WgpuDevice` maps to the auto-compiler [`DispatchDevice::Wgpu`] variant. To target a +// specific wgpu specialization (Metal, Vulkan, WebGpu) construct the variant explicitly. +#[cfg(all( + feature = "wgpu", + not(any(feature = "metal", feature = "vulkan", feature = "webgpu")) +))] +impl From for DispatchDevice { + fn from(device: WgpuDevice) -> Self { + DispatchDevice::Wgpu(device) + } +} + +#[cfg(all(feature = "metal", not(any(feature = "vulkan", feature = "webgpu"))))] +impl From for DispatchDevice { + fn from(device: WgpuDevice) -> Self { + DispatchDevice::Metal(device) + } +} + +#[cfg(all(feature = "vulkan", not(any(feature = "metal", feature = "webgpu"))))] +impl From for DispatchDevice { + fn from(device: WgpuDevice) -> Self { + DispatchDevice::Vulkan(device) + } +} + +#[cfg(all(feature = "webgpu", not(any(feature = "metal", feature = "vulkan"))))] +impl From for DispatchDevice { + fn from(device: WgpuDevice) -> Self { + DispatchDevice::WebGpu(device) + } +} + +#[cfg(any(feature = "flex", default_backend))] +impl From for DispatchDevice { + fn from(device: FlexDevice) -> Self { + DispatchDevice::Flex(device) + } +} + +#[cfg(feature = "ndarray")] +impl From for DispatchDevice { + fn from(device: NdArrayDevice) -> Self { + DispatchDevice::NdArray(device) + } +} + +#[cfg(feature = "tch")] +impl From for DispatchDevice { + fn from(device: LibTorchDevice) -> Self { + DispatchDevice::LibTorch(device) + } +} + +#[cfg(feature = "remote")] +impl From for DispatchDevice { + fn from(device: RemoteDevice) -> Self { + DispatchDevice::Remote(device) + } +} diff --git a/crates/burn-dispatch/src/lib.rs b/crates/burn-dispatch/src/lib.rs new file mode 100644 index 0000000..5c50a58 --- /dev/null +++ b/crates/burn-dispatch/src/lib.rs @@ -0,0 +1,126 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "138"] + +//! Burn multi-backend dispatch. +//! +//! # Available Backends +//! +//! The dispatch backend supports the following variants, each enabled via cargo features: +//! +//! | Backend | Feature | Description | +//! |------------|------------|-------------| +//! | `Cpu` | `cpu` | Rust CPU backend (MLIR + LLVM) | +//! | `Cuda` | `cuda` | NVIDIA CUDA backend | +//! | `Metal` | `metal` | Apple Metal backend via `wgpu` (MSL) | +//! | `Rocm` | `rocm` | AMD ROCm backend | +//! | `Vulkan` | `vulkan` | Vulkan backend via `wgpu` (SPIR-V) | +//! | `Wgpu` | `webgpu` | WebGPU backend via `wgpu` (WGSL) | +//! | `Flex` | `flex` | Pure Rust CPU backend using `burn-flex` | +//! | `NdArray` | `ndarray` | Pure Rust CPU backend using `ndarray` (legacy - prefer `flex`) | +//! | `LibTorch` | `tch` | Libtorch backend via `tch` | +//! | `Autodiff` | `autodiff` | Autodiff-enabled backend (used in combination with any of the backends above) | +//! +//! **Note:** All backends, including the WGPU-based ones (`wgpu`, `metal`, `vulkan`, `webgpu`), +//! can be combined freely. Each enabled wgpu backend appears as its own +//! [`DispatchDevice`] variant. + +#[macro_use] +mod macros; + +/// Dispatch backend module. +pub mod backend; +/// Dispatch device module. +pub mod device; +mod ops; +/// Dispatch tensor module. +pub mod tensor; + +/// Entry points for hosting a remote-execution server. +#[cfg(feature = "remote-server")] +pub mod remote_server; + +pub use backend::*; +pub use device::*; +pub use tensor::*; + +extern crate alloc; + +/// Backends and devices used. +pub mod backends { + #[cfg(feature = "autodiff")] + pub use burn_autodiff as autodiff; + #[cfg(feature = "autodiff")] + pub use burn_autodiff::Autodiff; // re-export for extensions + + #[cfg(feature = "cpu")] + pub use burn_cpu as cpu; + #[cfg(feature = "cpu")] + pub use burn_cpu::Cpu; + #[cfg(feature = "cuda")] + pub use burn_cuda as cuda; + #[cfg(feature = "cuda")] + pub use burn_cuda::Cuda; + #[cfg(feature = "rocm")] + pub use burn_rocm as rocm; + #[cfg(feature = "rocm")] + pub use burn_rocm::Rocm; + #[cfg(feature = "wgpu")] + pub use burn_wgpu as wgpu; + #[cfg(feature = "metal")] + pub use burn_wgpu::Metal; + #[cfg(feature = "vulkan")] + pub use burn_wgpu::Vulkan; + #[cfg(feature = "webgpu")] + pub use burn_wgpu::WebGpu; + #[cfg(feature = "wgpu")] + pub use burn_wgpu::Wgpu; + + #[cfg(any(feature = "flex", default_backend))] + pub use burn_flex as flex; + #[cfg(any(feature = "flex", default_backend))] + pub use burn_flex::Flex; + #[cfg(feature = "ndarray")] + pub use burn_ndarray as ndarray; + #[cfg(feature = "ndarray")] + pub use burn_ndarray::NdArray; + #[cfg(feature = "tch")] + pub use burn_tch as libtorch; + #[cfg(feature = "tch")] + pub use burn_tch::LibTorch; + + #[cfg(feature = "remote")] + pub use burn_remote as remote; + #[cfg(feature = "remote")] + pub use burn_remote::RemoteBackend as Remote; + + pub use super::devices::*; +} + +// Re-export devices + +/// Backend devices. +pub mod devices { + #[cfg(feature = "cpu")] + pub use burn_cpu::CpuDevice; + #[cfg(feature = "cuda")] + pub use burn_cuda::CudaDevice; + #[cfg(feature = "rocm")] + pub use burn_rocm::RocmDevice; + #[cfg(feature = "wgpu")] + pub use burn_wgpu::WgpuDevice; + + #[cfg(any(feature = "flex", default_backend))] + pub use burn_flex::FlexDevice; + #[cfg(feature = "ndarray")] + pub use burn_ndarray::NdArrayDevice; + #[cfg(feature = "tch")] + pub use burn_tch::LibTorchDevice; + + #[cfg(feature = "remote")] + pub use burn_remote::RemoteDevice; + + #[cfg(feature = "remote")] + pub use burn_remote::BURN_REMOTE_ALPN; +} diff --git a/crates/burn-dispatch/src/macros.rs b/crates/burn-dispatch/src/macros.rs new file mode 100644 index 0000000..9bbece3 --- /dev/null +++ b/crates/burn-dispatch/src/macros.rs @@ -0,0 +1,1466 @@ +/// Supplies a list of all supported backends and their corresponding feature flags +/// to a callback macro. This centralizes the backend registry. +macro_rules! backend_list { + ($callback:ident, $($extra:tt)*) => { + $callback! { + $($extra)*; + [Cpu, feature = "cpu"], + [Cuda, feature = "cuda"], + [Metal, feature = "metal"], + [Rocm, feature = "rocm"], + [Vulkan, feature = "vulkan"], + [Wgpu, feature = "wgpu"], + [WebGpu, feature = "webgpu"], + [Flex, any(feature = "flex", default_backend)], + [NdArray, feature = "ndarray"], + [LibTorch, feature = "tch"], + [Remote, feature = "remote"] + } + }; +} + +/// Supplies a list of all supported distributed backends and their corresponding feature flags +/// to a callback macro. This centralizes the backend registry. +macro_rules! distributed_backend_list { + ($callback:ident, $($extra:tt)*) => { + $callback! { + $($extra)*; + // The cubecl CudaServer implements the distributed communication directly; the remote + // backend forwards the collective operations to its server (which runs a real + // distributed backend). + [Cuda, feature = "cuda"], + [Remote, feature = "remote"] + } + }; +} + +/// Supplies a matrix of cross-backend combinations. Used for operations where the source and destination backends may differ. +macro_rules! backend_matrix { + ($callback:ident, $($extra:tt)*) => { + $callback! { + $($extra)*; + [Cpu, feature = "cpu"] => [[Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Cuda, feature = "cuda"] => [[Cpu, feature = "cpu"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Metal, feature = "metal"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Rocm, feature = "rocm"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Vulkan, feature = "vulkan"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Wgpu, feature = "wgpu"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [WebGpu, feature = "webgpu"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [Flex, any(feature = "flex", default_backend)] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [NdArray, feature = "ndarray"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [LibTorch, feature = "tch"], [Remote, feature = "remote"]]; + [LibTorch, feature = "tch"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [Remote, feature = "remote"]]; + [Remote, feature = "remote"] => [[Cpu, feature = "cpu"], [Cuda, feature = "cuda"], [Metal, feature = "metal"], [Rocm, feature = "rocm"], [Vulkan, feature = "vulkan"], [Wgpu, feature = "wgpu"], [WebGpu, feature = "webgpu"], [Flex, any(feature = "flex", default_backend)], [NdArray, feature = "ndarray"], [LibTorch, feature = "tch"]] + } + }; +} + +#[cfg(feature = "autodiff")] +/// Helper to map the runtime strategy to the compile-time Autodiff generic. +macro_rules! with_autodiff_backend { + ($Backend:ident, $checkpointing:expr, |$B:ident| $body:expr) => { + match $checkpointing { + Some($crate::CheckpointingStrategy::Balanced) => { + type $B = $crate::backends::Autodiff< + $crate::backends::$Backend, + burn_autodiff::checkpoint::strategy::BalancedCheckpointing, + >; + $body + } + Some($crate::CheckpointingStrategy::None) => { + type $B = $crate::backends::Autodiff< + $crate::backends::$Backend, + burn_autodiff::checkpoint::strategy::NoCheckpointing, + >; + $body + } + None => unreachable!("Should only be called with autodiff."), + } + }; +} + +/// Match arm generator for `dispatch_device`. +/// Maps each backend variant to a block where the specific backend type is bound to `B`. +macro_rules! dispatch_device_arms { + ( + $device:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => { + match $device { + // Autodiff arm first + #[cfg(feature = "autodiff")] + $crate::DispatchDevice::Autodiff(inner) => { + // Recursively dispatch on inner + dispatch_device_arms!( + @autodiff + &**inner, + |$inner| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend($inner) => { + type B = $crate::backends::$Backend; + $body + } + )* + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for device {other:?}"), + } + }; + ( + @autodiff + $device:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => { + match $device { + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend($inner) => { + type B = $crate::backends::Autodiff<$crate::backends::$Backend>; + $body + } + )* + $crate::DispatchDevice::Autodiff(_) => unreachable!("Autodiff should not wrap an autodiff device."), + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for device {other:?}"), + } + }; +} + +/// Dispatches an operation body based on the provided device. +macro_rules! dispatch_device { + ($device:expr, |$inner:ident| $body:expr) => { + dispatch_device!(@internal backend_list, $device, |$inner| $body) + }; + (@distributed $device:expr, |$inner:ident| $body:expr) => { + dispatch_device!(@internal distributed_backend_list, $device, |$inner| $body) + }; + (@internal $list_macro:ident, $device:expr, |$inner:ident| $body:expr) => { + $list_macro!(dispatch_device_arms, $device, |$inner| $body) + }; +} + +/// Match arm generator for `to_device`. +/// Handles the logic for same-backend transfers (fast path) and cross-backend +/// transfers by generating a grid of all device combinations provided via `backend_matrix`. +macro_rules! to_device_arms { + ( + $kind:ident, $inner_fn:ident, $tensor:expr, $device:expr, $to_device:ident, |$inner:ident, $device_ident:ident| $body:expr; + $( [$B1:ident, $src_cfg:meta] => [ $( [$B2:ident, $dst_cfg:meta] ),+ ] );* + ) => { + match ($tensor.kind, $device) { + // --- Same backend to_device --- + $( + #[cfg($src_cfg)] + ($crate::DispatchTensorKind::$B1(t), $crate::DispatchDevice::$B1(d)) => { + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B1($crate::BackendTensor::$kind( + $crate::backends::$B1::$to_device(t.$inner_fn(), d) + )), + checkpointing: $tensor.checkpointing, + } + } + )* + + // --- Cross backend arms --- + // This loop generates the grid of combinations + $( + $( + #[cfg(all($src_cfg, $dst_cfg))] + ($crate::DispatchTensorKind::$B1(t), $crate::DispatchDevice::$B2($device_ident)) => { + type B1 = $crate::backends::$B1; + type B2 = $crate::backends::$B2; + let $inner = t.$inner_fn(); + + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B2( + $crate::BackendTensor::$kind($body) + ), + checkpointing: $tensor.checkpointing, + } + } + )+ + )* + // --- To autodiff --- + // This can happen when moving a bool or int tensor to the device of a float autodiff tensor. + // We move it to the inner device and preserve the checkpointing strategy. + + // --- Same backend to_device --- + $( + #[cfg(all($src_cfg, feature = "autodiff"))] + ($crate::DispatchTensorKind::$B1(t), $crate::DispatchDevice::Autodiff(device_ad)) + if matches!(&*device_ad.inner, $crate::DispatchDevice::$B1(_)) => { + let $crate::DispatchDevice::$B1(d) = &*device_ad.inner else { unreachable!() }; + + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B1($crate::BackendTensor::$kind( + <$crate::backends::$B1>::$to_device(t.$inner_fn(), d) + )), + checkpointing: Some(device_ad.checkpointing), + } + } + )* + + // --- Same backend to_device --- + $( + $( + #[cfg(all($src_cfg, $dst_cfg, feature = "autodiff"))] + ($crate::DispatchTensorKind::$B1(tensor), $crate::DispatchDevice::Autodiff(device_ad)) + if matches!(&*device_ad.inner, $crate::DispatchDevice::$B2(_)) => { + let $crate::DispatchDevice::$B2($device_ident) = &*device_ad.inner else { unreachable!() }; + type B1 = $crate::backends::$B1; + type B2 = $crate::backends::$B2; + let $inner = tensor.$inner_fn(); + + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B2( + $crate::BackendTensor::$kind($body) + ), + checkpointing: Some(device_ad.checkpointing), + } + + }, + )+ + )* + #[cfg(feature = "autodiff")] + (_, $crate::DispatchDevice::Autodiff(_)) => unreachable!("Autodiff should not wrap an autodiff device."), + #[cfg(feature = "autodiff")] + ($crate::DispatchTensorKind::Autodiff(..), _) => panic!("Operation not marked for autodiff.") + } + }; +} + +/// Handles tensor movement between devices, supporting both same-backend transfers +/// and cross-backend dispatches. +macro_rules! to_device { + ($kind:ident, $inner_fn:ident, $tensor:expr, $device:expr, $to_device:ident, |$inner:ident, $device_ident:ident| $body:expr) => { + backend_matrix!( + to_device_arms, + $kind, + $inner_fn, + $tensor, + $device, + $to_device, + |$inner, $device_ident| $body + ) + }; +} + +/// Match arm generator for `float_to_device`. +/// +/// Similar to `to_device_arms`, but float tensors are checked for autodiff support. +macro_rules! float_to_device_arms { + ( + $tensor:expr, $device:expr, $to_device:ident, |$inner:ident, $device_ident:ident| $body:expr; + $( [$B1:ident, $src_cfg:meta] => [ $( [$B2:ident, $dst_cfg:meta] ),+ ] );* + ) => { + match ($tensor.kind, $device) { + #[cfg(feature = "autodiff")] + ($crate::DispatchTensorKind::Autodiff(kind), $crate::DispatchDevice::Autodiff(device)) => { + let ckp = $tensor.checkpointing; + float_to_device_arms!( + @autodiff + *kind, &**device, ckp, $to_device; + $([$B1, $src_cfg]);* + ) + + } + // --- Same backend to_device --- + $( + #[cfg($src_cfg)] + ($crate::DispatchTensorKind::$B1(kind), $crate::DispatchDevice::$B1(d)) => { + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B1($crate::BackendTensor::Float( + $crate::backends::$B1::$to_device(kind.float(), d) + )), + checkpointing: $tensor.checkpointing, + } + } + )* + + // --- Cross backend arms --- + // This loop generates the grid of combinations + $( + $( + #[cfg(all($src_cfg, $dst_cfg))] + ($crate::DispatchTensorKind::$B1(kind), $crate::DispatchDevice::$B2($device_ident)) => { + type B1 = $crate::backends::$B1; + type B2 = $crate::backends::$B2; + let $inner = kind.float(); + + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$B2($crate::BackendTensor::Float($body)), + checkpointing: $tensor.checkpointing, + } + } + )+ + )* + #[cfg(feature = "autodiff")] + ($crate::DispatchTensorKind::Autodiff(..), _) | (_, $crate::DispatchDevice::Autodiff(_)) => panic!("Cannot move between autodiff and non-autodiff instances.") + } + }; + + // Autodiff(DispatchTensor) + ( + @autodiff + $tensor:expr, $device:expr, $ckp:expr, $to_device:ident; + $( [$B1:ident, $src_cfg:meta] );* + ) => {{ + match ($tensor, $device) { + // --- Same backend to_device --- + $( + #[cfg($src_cfg)] + ($crate::DispatchTensorKind::$B1(tensor), $crate::DispatchDevice::$B1(d)) => { + let kind = $crate::DispatchTensorKind::Autodiff(alloc::boxed::Box::new($crate::DispatchTensorKind::$B1($crate::BackendTensor::Autodiff( + with_autodiff_backend!($B1, $ckp, |B| { + B::$to_device(tensor.autodiff(), d) + }) + )))); + $crate::DispatchTensor {kind, checkpointing: $ckp} + } + )* + // TODO: should be possible + (_, _) => unimplemented!("Autodiff tensor cannot be moved between backends.") + } + }}; +} + +/// Handles float tensor movement between devices (that might support autodiff). +macro_rules! float_to_device { + ($kind:ident, $inner_fn:ident, $tensor:expr, $device:expr, $to_device:ident, |$inner:ident, $device_ident:ident| $body:expr) => { + backend_matrix!( + float_to_device_arms, + $tensor, + $device, + $to_device, + |$inner, $device_ident| $body + ) + }; +} + +/// Dispatches a tensor creation operation (e.g., zeros, ones) to the correct backend +/// based on the provided device. +macro_rules! creation_op { + ($kind:ident, $device:expr, |$inner:ident| $body:expr) => { + backend_list!(creation_op_arms, $kind, $device, |$inner| $body) + }; +} + +/// Match arm generator for `creation_float`. +/// +/// Similar to `creation_op_arms`, but float tensors are checked for autodiff support. +macro_rules! creation_op_arms { + ( + $kind:ident, + $device:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match $device { + // Autodiff arm first + #[cfg(feature = "autodiff")] + $crate::DispatchDevice::Autodiff(inner) => { + // Recursively dispatch on inner + creation_op_arms!( + @autodiff + $kind, + &**inner, + Some(inner.checkpointing), + |$inner| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend($inner) => { + type B = $crate::backends::$Backend; + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend( + $crate::BackendTensor::$kind($body) + ), + checkpointing: None, + } + } + )* + } + }}; + + ( + @autodiff + $kind:ident, + $device:expr, + $ckp:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match $device { + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend($inner) => { + with_autodiff_backend!($Backend, $ckp, |B| { + wrap_float!(@wrap_autodiff $kind, $Backend, $ckp, { $body }) + }) + } + )* + $crate::DispatchDevice::Autodiff(_) => unreachable!("Autodiff should not wrap an autodiff device.") + } + }}; +} + +/// Wrap the result in the backend tensor kind, handling float -> autodiff. +#[cfg(feature = "autodiff")] +macro_rules! wrap_float { + ( + @wrap_autodiff Float, + $Backend:ident, + $ckp:expr, + $expr:expr + ) => { + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::Autodiff(alloc::boxed::Box::new( + $crate::DispatchTensorKind::$Backend($crate::BackendTensor::Autodiff($expr)), + )), + checkpointing: $ckp, + } + }; + + ( + @wrap_autodiff $other:ident, + $Backend:ident, + $ckp:expr, + $expr:expr + ) => { + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$other($expr)), + checkpointing: $ckp, + } + }; +} + +/// Match arm generator for `unary_op`. +/// Unwraps the inner tensor primitive (e.g., `inner.float()`) and provides the backend type `B` +/// for the operation. +/// +/// When the return kind is provided, the result is wrapped in the corresponding `DispatchTensor` variant. +macro_rules! unary_op_arms { + ( + Float, // handle autodiff wrapped tensors for float return kind (e.g. int_into_float) + $inner_kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + let checkpointing = $tensor.checkpointing; + + match $tensor.kind { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + type B = $crate::backends::$Backend; + let $inner = $inner.$inner_kind(); + + #[cfg(feature = "autodiff")] + if checkpointing.is_some() { + with_autodiff_backend!($Backend, checkpointing, |B| { + wrap_float!(@wrap_autodiff Float, $Backend, checkpointing, { $body }) + }) + } else { + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::Float($body)), + checkpointing, + } + } + + #[cfg(not(feature = "autodiff"))] + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::Float($body)), + checkpointing, + } + } + )* + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(..) => panic!("Operation not marked for autodiff.") + } + }}; + + ( + $kind:ident, + $inner_kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + let checkpointing = $tensor.checkpointing; + + match $tensor.kind { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + type B = $crate::backends::$Backend; + let $inner = $inner.$inner_kind(); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)), + checkpointing, + } + } + )* + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(..) => panic!("Operation not marked for autodiff.") + } + }}; + + // Operations that do not return a tensor kind + ( + $inner_kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match $tensor.kind { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + type B = $crate::backends::$Backend; + let $inner = $inner.$inner_kind(); + $body + } + )* + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(..) => panic!("Operation not marked for autodiff.") + } + }}; +} + +/// Backend dispatch for unary operations. +/// +/// When the return `=> Kind` is not provided, the operation output is not wrapped in a dispatch tensor (e.g., `into_data(..)`) +macro_rules! unary_op { + ($tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr => $kind:ident) => { + backend_list!(unary_op_arms, $kind, $inner_kind, $tensor, |$inner| { + $body + }) + }; + ($tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr) => { + backend_list!(unary_op_arms, $inner_kind, $tensor, |$inner| { $body }) + }; +} + +/// Match arm generator for `unary_float`. +/// +/// Similar to `unary_op_arms`, but float tensors are checked for autodiff support. +macro_rules! unary_float_arms { + ( + $mode:ident, // `owned` or `ref` + $kind:ident, + $inner_kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + let checkpointing = $tensor.checkpointing; + + match $tensor.kind { + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(inner) => { + unary_float_arms!( + @autodiff $mode, + checkpointing, + $kind, + { if_mode!($mode, &**inner, *inner) }, + |$inner| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + type B = $crate::backends::$Backend; + let $inner = unary_float_arms!(@unwrap $mode, $inner, $inner_kind); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend( + $crate::BackendTensor::$kind($body) + ), + checkpointing, + } + } + )* + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for tensor kind {other:?}"), + } + }}; + + // --- Autodiff recursive arm --- + ( + @autodiff $mode:ident, + $ckp:expr, + $kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match $tensor { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + with_autodiff_backend!($Backend, $ckp, |B| { + let $inner = unary_float_arms!(@unwrap_ad $mode, $inner); + wrap_float!(@wrap_autodiff $kind, $Backend, $ckp, { $body }) + }) + } + )* + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor."), + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for tensor kind {other:?}"), + } + }}; + + // --- Non-wrapping arms (operations not returning a tensor) --- + ( + $mode:ident, + $inner_kind:ident, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + #[cfg(feature = "autodiff")] + let checkpointing = &$tensor.checkpointing; + + match { if_mode!($mode, &$tensor.kind, $tensor.kind) } { + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(inner) => { + unary_float_arms!( + @autodiff $mode, + checkpointing, + { if_mode!($mode, &**inner, *inner) }, + |$inner| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + type B = $crate::backends::$Backend; + let $inner = unary_float_arms!(@unwrap $mode, $inner, $inner_kind); + $body + } + )* + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for tensor kind {other:?}"), + } + }}; + ( + @autodiff $mode:ident, + $ckp:expr, + $tensor:expr, + |$inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match $tensor { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend($inner) => { + with_autodiff_backend!($Backend, $ckp, |B| { + let $inner = unary_float_arms!(@unwrap_ad $mode, $inner); + $body + }) + } + )* + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor."), + #[allow(unreachable_patterns)] + other => panic!("Distributed operations are not supported for tensor kind {other:?}"), + } + }}; + + // --- Helpers to unwarp the tensor based on owned/ref --- + (@unwrap owned, $inner:ident, $inner_kind:ident) => { $inner.$inner_kind() }; + (@unwrap ref, $inner:ident, $inner_kind:ident) => { + paste::paste! { $inner.[< as_ $inner_kind >]() } + }; + + (@unwrap_ad owned, $inner:ident) => { $inner.autodiff() }; + (@unwrap_ad ref, $inner:ident) => { $inner.as_autodiff() }; + +} + +/// Utility to pick a token based on mode +macro_rules! if_mode { + (ref, $if_ref:expr, $if_owned:expr) => { + $if_ref + }; + (owned, $if_ref:expr, $if_owned:expr) => { + $if_owned + }; +} + +/// Backend dispatch for float unary operations (that might support autodiff). +/// +/// When the return `=> Kind` is not provided, the operation output is not wrapped in a dispatch tensor (e.g., `into_data(..)`) +macro_rules! unary_float { + // --- Entrypoints that default to standard backend_list --- + ($tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr => $kind:ident) => { + unary_float!(@internal backend_list, $tensor, $inner_kind, |$inner| $body => $kind) + }; + ($tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr) => { + unary_float!(@internal backend_list, $tensor, $inner_kind, |$inner| $body) + }; + (ref $tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr) => { + unary_float!(@internal backend_list, ref $tensor, $inner_kind, |$inner| $body) + }; + + // --- Distributed entrypoint --- + (@distributed $tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr => $kind:ident) => { + unary_float!(@internal distributed_backend_list, $tensor, $inner_kind, |$inner| $body => $kind) + }; + + // --- Internal implementation workers that accept the list macro dynamically --- + (@internal $list_macro:ident, $tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr => $kind:ident) => { + $list_macro!( + unary_float_arms, + owned, + $kind, + $inner_kind, + $tensor, + |$inner| { $body } + ) + }; + (@internal $list_macro:ident, $tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr) => { + $list_macro!(unary_float_arms, owned, $inner_kind, $tensor, |$inner| { + $body + }) + }; + (@internal $list_macro:ident, ref $tensor:expr, $inner_kind:ident, |$inner:ident| $body:expr) => { + $list_macro!(unary_float_arms, ref, $inner_kind, $tensor, |$inner| { + $body + }) + }; +} + +/// Match arm generator for `binary_op`. +/// Matches two tensors to ensure they share the same backend before unwrapping them for the operation. +macro_rules! binary_op_arms { + ( + $kind:ident, + ($lhs:expr, $lhs_kind:ident), + ($rhs:expr, $rhs_kind:ident), + |$lhs_inner:ident, $rhs_inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + #[cfg(feature = "autodiff")] + let checkpointing = $crate::validate_checkpointing($lhs.checkpointing, $rhs.checkpointing); + #[cfg(not(feature = "autodiff"))] + let checkpointing = $lhs.checkpointing; + + match ($lhs.kind, $rhs.kind) { + $( + #[cfg($cfg)] + ($crate::DispatchTensorKind::$Backend($lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + type B = $crate::backends::$Backend; + let $lhs_inner = $lhs_inner.$lhs_kind(); + let $rhs_inner = $rhs_inner.$rhs_kind(); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)), + + checkpointing, + } + } + )* + #[allow(unreachable_patterns)] + (lhs, rhs) => { + panic!( + "The provided tensors are not on the same backend. Got backends {:?} and {:?}.", lhs, rhs + ); + } + } + }}; +} + +/// Backend dispatch for binary operations. +/// Automatically verifies that both tensors reside on the same backend. +macro_rules! binary_op { + (($lhs:expr, $lhs_kind:ident), ($rhs:expr, $rhs_kind:ident), |$lhs_inner:ident, $rhs_inner:ident| $body:expr => $kind:ident) => { + backend_list!( + binary_op_arms, + $kind, + ($lhs, $lhs_kind), + ($rhs, $rhs_kind), + |$lhs_inner, $rhs_inner| { $body } + ) + }; +} + +/// Match arm generator for `binary_float`. +/// Matches two tensors to ensure they share the same backend before unwrapping them for the operation. +macro_rules! binary_float_arms { + // (float, float) binary op + ( + $kind:ident, + ($lhs:expr, float), + ($rhs:expr, float), + |$lhs_inner:ident, $rhs_inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + #[cfg(feature = "autodiff")] + let checkpointing = $crate::validate_checkpointing($lhs.checkpointing, $rhs.checkpointing); + #[cfg(not(feature = "autodiff"))] + let checkpointing = $lhs.checkpointing; + + match ($lhs.kind, $rhs.kind) { + // Autodiff arms first + #[cfg(feature = "autodiff")] + ($crate::DispatchTensorKind::Autodiff(lhs_inner), $crate::DispatchTensorKind::Autodiff(rhs_inner)) => { + // Recursively dispatch on inner + binary_float_arms!( + @autodiff + $kind, + (*lhs_inner, autodiff, checkpointing), + (*rhs_inner, autodiff, checkpointing), + |$lhs_inner, $rhs_inner| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + ($crate::DispatchTensorKind::$Backend($lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + type B = $crate::backends::$Backend; + let $lhs_inner = $lhs_inner.float(); + let $rhs_inner = $rhs_inner.float(); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)), + checkpointing, + } + } + )* + #[allow(unreachable_patterns)] + (lhs, rhs) => { + panic!( + "The provided tensors are not on the same backend. Got backends {:?} and {:?}.", lhs, rhs + ); + } + } + }}; + // (float, any) binary op + ( + $kind:ident, + ($lhs:expr, float), + ($rhs:expr, $rhs_kind:ident), + |$lhs_inner:ident, $rhs_inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + #[cfg(feature = "autodiff")] + let checkpointing = $crate::validate_checkpointing($lhs.checkpointing, $rhs.checkpointing); + #[cfg(not(feature = "autodiff"))] + let checkpointing = $lhs.checkpointing; + + match ($lhs.kind, $rhs.kind) { + $( + // Autodiff arms first + #[cfg(all(feature = "autodiff", $cfg))] + ($crate::DispatchTensorKind::Autodiff(lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + // Match on inner + match *lhs_inner { + $crate::DispatchTensorKind::$Backend($lhs_inner) => { + with_autodiff_backend!($Backend, checkpointing, |B| { + let $lhs_inner = $lhs_inner.autodiff(); + let $rhs_inner = $rhs_inner.$rhs_kind(); + wrap_float!( + @wrap_autodiff + $kind, + $Backend, + checkpointing, + { $body } + ) + }) + } + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor."), + #[allow(unreachable_patterns)] + _ => panic!("The provided tensors are not on the same backend.") + } + }, + + #[cfg($cfg)] + ($crate::DispatchTensorKind::$Backend($lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + type B = $crate::backends::$Backend; + let $lhs_inner = $lhs_inner.float(); + let $rhs_inner = $rhs_inner.$rhs_kind(); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)), + checkpointing, + } + } + )* + #[allow(unreachable_patterns)] + (lhs, rhs) => { + panic!( + "The provided tensors are not on the same backend. Got backends {:?} and {:?}.", lhs, rhs + ); + } + } + }}; + ( + $kind:ident, + ($lhs:expr, $lhs_kind:ident), + ($rhs:expr, $rhs_kind:ident), + |$lhs_inner:ident, $rhs_inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match ($lhs, $rhs) { + $( + #[cfg($cfg)] + ($crate::DispatchTensorKind::$Backend($lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + type B = $crate::backends::$Backend; + let $lhs_inner = $lhs_inner.$lhs_kind(); + let $rhs_inner = $rhs_inner.$rhs_kind(); + $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)) + } + )* + (lhs, rhs) => { + panic!( + "The provided tensors are not on the same backend. Got backends {:?} and {:?}.", lhs, rhs + ); + } + } + }}; + // Autodiff (lhs, rhs) tensors + ( + @autodiff + $kind:ident, + ($lhs:expr, $lhs_kind:ident, $ckp_lhs:expr), + ($rhs:expr, $rhs_kind:ident, $ckp_rhs:expr), + |$lhs_inner:ident, $rhs_inner:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => {{ + match ($lhs, $rhs) { + $( + #[cfg($cfg)] + ($crate::DispatchTensorKind::$Backend($lhs_inner), $crate::DispatchTensorKind::$Backend($rhs_inner)) => { + with_autodiff_backend!($Backend, $ckp_lhs, |B| { + let $lhs_inner = $lhs_inner.$lhs_kind(); + let $rhs_inner = $rhs_inner.$rhs_kind(); + wrap_float!( + @wrap_autodiff + $kind, + $Backend, + $ckp_lhs, + { $body } + ) + }) + } + )* + #[cfg(feature = "autodiff")] + ($crate::DispatchTensorKind::Autodiff(..), _) | (_, $crate::DispatchTensorKind::Autodiff(..)) => unreachable!("Autodiff should not wrap an autodiff tensor."), + #[allow(unreachable_patterns)] + (lhs, rhs) => { + panic!( + "The provided tensors are not on the same backend. Got backends {:?} and {:?}.", lhs, rhs + ); + } + } + }}; + +} + +/// Backend dispatch for binary operations. +/// Automatically verifies that both tensors reside on the same backend. +macro_rules! binary_float { + (($lhs:expr, $lhs_kind:ident), ($rhs:expr, $rhs_kind:ident), |$lhs_inner:ident, $rhs_inner:ident| $body:expr => $kind:ident) => { + backend_list!( + binary_float_arms, + $kind, + ($lhs, $lhs_kind), + ($rhs, $rhs_kind), + |$lhs_inner, $rhs_inner| { $body } + ) + }; +} + +/// The core logic for a single backend in a `multi_op`. +/// Handles the manual unwrapping of required/optional inputs and the +/// re-wrapping of multiple required/optional output tensors. +macro_rules! multi_op_arm { + ( + $Backend:ident, + $ckp:ident, + [ $( ($x:ident, $x_kind:ident) ),+ ], + [ $( ($opt_in:ident, $opt_kind:ident) ),* ], + [ $( ($out:ident, $out_kind:ident) ),+ ], + [ $( $opt_out:ident ),* ], + $body:expr + ) => {{ + type B = $crate::backends::$Backend; + + // Required inputs + $( + let $x = match $x.kind { + $crate::DispatchTensorKind::$Backend(inner) => inner.$x_kind(), + #[allow(unreachable_patterns)] + _ => panic!("Input tensor {} is on the wrong device", stringify!($x)), + }; + )+ + + // Optional inputs + $( + let $opt_in = $opt_in.map(|o| match o.kind { + $crate::DispatchTensorKind::$Backend(inner) => inner.$opt_kind(), + #[allow(unreachable_patterns)] + _ => panic!("Optional tensor {} is on the wrong device", stringify!($opt_in)), + }); + )* + + let ($($out),+, $($opt_out),*) = $body; + + // Outputs and optional outputs + ( + $( + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$out_kind($out)), + checkpointing: $ckp, + } + ),+, + $( + $opt_out.map(|t| + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::Float(t)), + checkpointing: $ckp, + } + ) + ),* + ) + }}; +} + +#[cfg(feature = "autodiff")] +macro_rules! wrap_input_autodiff { + ($Backend:ident, $inner:expr, int) => { + $inner.int() + }; + ($Backend:ident, $inner:expr, bool) => { + $inner.bool() + }; + // Float tensors: wrap with autodiff + ($Backend:ident, $inner:expr, float) => { + $inner.autodiff() + }; +} + +#[cfg(feature = "autodiff")] +// DispatchTensorKind::Autodiff(DispatchTensorKind::$Backend(BackendTensor::Autodiff())) +macro_rules! multi_op_arm_autodiff { + ( + $Backend:ident, + $ckp:ident, + [ $( ($x:ident, $x_kind:ident) ),+ ], + [ $( ($opt_in:ident, $opt_kind:ident) ),* ], + [ $( ($out:ident, $out_kind:ident) ),+ ], + [ $( $opt_out:ident ),* ], + $body:expr + ) => {{ + // type B = Autodiff<$Backend>; + with_autodiff_backend!($Backend, $ckp, |B| { + // Required inputs + $( + let $x = match $x.kind { + $crate::DispatchTensorKind::Autodiff(inner) => { + match *inner { + $crate::DispatchTensorKind::$Backend(inner) => wrap_input_autodiff!($Backend, inner, $x_kind), + _ => panic!("Input tensor {} is on the wrong device", stringify!($x)), + } + }, + // Unreachable, except when input is int + $crate::DispatchTensorKind::$Backend(inner) => wrap_input_autodiff!($Backend, inner, $x_kind), + #[allow(unreachable_patterns)] + _ => panic!("Input tensor {} is on the wrong device", stringify!($x)), + }; + )+ + + // Optional inputs (always assumed to be float / autodiff) + $( + let $opt_in = $opt_in.map(|o| match o.kind { + $crate::DispatchTensorKind::Autodiff(inner) => { + match *inner { + $crate::DispatchTensorKind::$Backend(inner) => wrap_input_autodiff!($Backend, inner, $opt_kind), + _ => panic!("Input tensor {} is on the wrong device", stringify!($opt_in)), + } + }, + _ => panic!("Optional tensor {} is on the wrong device", stringify!($opt_in)), + }); + )* + + let ($($out),+, $($opt_out),*) = $body; + + // Outputs and optional outputs + ( + $( wrap_float!(@wrap_autodiff $out_kind, $Backend, $ckp, $out) ),+, + $( $opt_out.map(|t| wrap_float!(@wrap_autodiff Float, $Backend, $ckp, t)) ),* + ) + }) + }}; +} + +/// Helper to extract the first identifier from an input list. +/// Used to determine the device/backend for dispatching multi-tensor operations. +macro_rules! first_input { + ([ ($x:ident, $kind:ident) $(, $rest:tt)* ]) => { + $x + }; +} + +/// Match arm generator for `multi_op`. +/// Determines the backend based on the first input and delegates to `multi_op_arm` +/// to handle the repetition-heavy unwrapping and wrapping logic. +macro_rules! multi_op_arms_autodiff { + ( + $inputs:tt, + $opt_inputs:tt, + $outputs:tt, + $opt_outputs:tt, + $body:expr; + $( [$Backend:ident, $cfg:meta] ),* + ) => {{ + let first_input = &first_input!($inputs); + let checkpointing = first_input.checkpointing; + match &first_input.kind { + // Autodiff first + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(inner) => { + match **inner { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + multi_op_arm_autodiff!( + $Backend, + checkpointing, + $inputs, + $opt_inputs, + $outputs, + $opt_outputs, + $body + ) + } + )* + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor.") + } + }, + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + multi_op_arm!( + $Backend, + checkpointing, + $inputs, + $opt_inputs, + $outputs, + $opt_outputs, + $body + ) + } + )* + } + }}; +} + +/// Match arm generator for `multi_op`. +/// +/// Similar to `multi_op_arms`, but skips autodiff checks. +macro_rules! multi_op_arms { + ( + $inputs:tt, + $opt_inputs:tt, + $outputs:tt, + $opt_outputs:tt, + $body:expr; + $( [$Backend:ident, $cfg:meta] ),* + ) => {{ + let first_input = &first_input!($inputs); + let checkpointing = first_input.checkpointing; + + match first_input.kind { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + multi_op_arm!( + $Backend, + checkpointing, + $inputs, + $opt_inputs, + $outputs, + $opt_outputs, + $body + ) + } + )* + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(..) => panic!("Operation not marked for autodiff.") + } + }}; +} + +/// High-level macro for complex module operations (e.g., conv2d) and multi-tensor operations. +/// Handles variable numbers of required/optional inputs and wraps multiple outputs. +/// +/// Usage: +/// ```ignore +/// multi_op!( +/// inputs[(x, float), (weight, float)], +/// opt_inputs[(bias, float)], +/// => Float, +/// B::conv2d(x, weight, bias, options) +/// ) +/// ``` +macro_rules! multi_op { + // --- Single output shorthands --- + // Automatically wraps body in tuple and extracts .0 + ( + inputs[$( ($x:ident, $kind:ident) ),+], + => Float, + $body:expr + ) => { + multi_op!( + inputs[$( ($x, $kind) ),+], + opt_inputs[], + outputs[(out, Float)], + opt_outputs[], + { ($body,) } + ) + .0 + }; + ( + inputs[$( ($x:ident, $kind:ident) ),+], + opt_inputs[ $(($opt_in:ident, $opt_kind:ident)),* ], + => $out_kind:ident, + $body:expr + ) => { + multi_op!( + inputs[$( ($x, $kind) ),+], + opt_inputs[ $(($opt_in, $opt_kind)),* ], + outputs[(out, $out_kind)], + opt_outputs[], + { ($body,) } + ) + .0 + }; + // Int/Bool op specialization (not marked for autodiff) + ( + inputs[$( ($x:ident, $kind:ident) ),+], + => $out_kind:ident, + $body:expr + ) => { + backend_list!( + multi_op_arms, + [ $(($x, $kind)),+ ], + [], + [ (out, $out_kind) ], + [], + { ($body,) } + ).0 + }; + + // --- Required + optional for both inputs and outputs --- + ( + inputs[ $(($x:ident, $kind:ident)),+ ], + opt_inputs[ $(($opt_in:ident, $opt_kind:ident)),* ], + outputs[ $( ($out:ident, $out_kind:ident) ),+ ], + opt_outputs[ $($opt_out:ident),* ], + $body:expr + ) => { + backend_list!( + multi_op_arms_autodiff, + [ $(($x, $kind)),+ ], + [ $(($opt_in, $opt_kind)),* ], + [ $(($out, $out_kind)),+ ], + [ $($opt_out),* ], + $body + ) + }; + + ( + inputs[ $(($x:ident, $kind:ident)),+ ], + opt_inputs[ $(($opt_in:ident, $opt_kind:ident)),* ], + outputs[ $($out:ident),+ ], + $body:expr + ) => { + multi_op!( + inputs[ $(($x, $kind)),+ ], + opt_inputs[ $(($opt_in, $opt_kind)),* ], + outputs[ $(($out, Float)),+ ], + opt_outputs[], + $body + ) + }; + + ( + inputs[ $(($x:ident, $kind:ident)),+ ], + outputs[ $( ($out:ident, $out_kind:ident) ),+ ], + $body:expr + ) => { + multi_op!( + inputs[ $(($x, $kind)),+ ], + opt_inputs[], + outputs[ $(($out, $out_kind)),+ ], + opt_outputs[], + $body + ) + }; +} + +/// Unwraps a `Vec` for a known backend. +macro_rules! unwrap_vec { + ($Backend:ident, $vec:expr, $kind:ident) => { + $vec.into_iter() + .map(|t| match t.kind { + $crate::DispatchTensorKind::$Backend(inner) => inner.$kind(), + #[allow(unreachable_patterns)] + _ => panic!( + "Tensor is on the wrong backend (expected {}).", + stringify!($Backend) + ), + }) + .collect::>() + }; + + // Autodiff-wrapped backend + (@autodiff $Backend:ident, $vec:expr, $kind:ident) => { + $vec.into_iter() + .map(|t| match t.kind { + $crate::DispatchTensorKind::Autodiff(inner) => match *inner { + $crate::DispatchTensorKind::$Backend(inner) => inner.$kind(), + _ => panic!( + "Autodiff float tensor is on the wrong backend (expected {}).", + stringify!($Backend) + ), + }, + _ => panic!( + "Expected autodiff-wrapped float tensor for backend {}.", + stringify!($Backend) + ), + }) + .collect::>() + }; +} + +/// Match arm generator for `vec_op`. +macro_rules! vec_op_arms { + (Float, $inner_kind:ident, $tensors:expr, |$inner:ident| $body:expr; $([$Backend:ident, $cfg:meta]),*) => {{ + let first = &$tensors[0]; + let checkpointing = first.checkpointing; + + match &first.kind { + // Autodiff arm first + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(inner) => { + // Recursively dispatch on inner + match **inner { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + with_autodiff_backend!($Backend, checkpointing, |B| { + let $inner = unwrap_vec!(@autodiff $Backend, $tensors, autodiff); + wrap_float!( @wrap_autodiff Float, $Backend, checkpointing, { $body } ) + }) + } + )* + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor.") + } + }, + + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + type B = $crate::backends::$Backend; + + let $inner = unwrap_vec!($Backend, $tensors, $inner_kind); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::Float($body)), + checkpointing, + } + } + )* + } + }}; + ($kind:ident, $inner_kind:ident, $tensors:expr, |$inner:ident| $body:expr; $([$Backend:ident, $cfg:meta]),*) => {{ + let first = &$tensors[0]; + let checkpointing = first.checkpointing; + match first.kind { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + type B = $crate::backends::$Backend; + + let $inner = unwrap_vec!($Backend, $tensors, $inner_kind); + $crate::DispatchTensor { + kind: $crate::DispatchTensorKind::$Backend($crate::BackendTensor::$kind($body)), + checkpointing, + } + } + )* + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(..) => panic!("Operation not marked for autodiff.") + } + }}; +} + +/// Backend dispatch for operations on multiple inputs (vec). +/// Automatically verifies that tensors reside on the first backend. +macro_rules! vec_op { + ($tensors:expr, $inner_kind:ident, |$inner:ident| $body:expr => $kind:ident) => { + backend_list!(vec_op_arms, $kind, $inner_kind, $tensors, |$inner| { + $body + }) + }; +} + +/// Match arm generator for `transaction_op`. +macro_rules! transaction_op_arms { + ($tx:ident, $first:expr; $([$Backend:ident, $cfg:meta]),*) => {{ + match &$first.kind { + // Autodiff arm first + #[cfg(feature = "autodiff")] + $crate::DispatchTensorKind::Autodiff(inner) => { + // Recursively dispatch on inner + match **inner { + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + type B = $crate::backends::$Backend; + + // Unwrap vec + let floats = unwrap_vec!(@autodiff $Backend, $tx.read_floats, autodiff_inner); + let ints = unwrap_vec!($Backend, $tx.read_ints, int); + let bools = unwrap_vec!($Backend, $tx.read_bools, bool); + // Not supported + let qfloats = $tx.read_qfloats.into_iter().map(|_t| todo!("Quantization not supported yet")).collect(); + + B::tr_execute(TransactionPrimitive::new(floats, qfloats, ints, bools)).await + } + )* + $crate::DispatchTensorKind::Autodiff(..) => unreachable!("Autodiff should not wrap an autodiff tensor.") + } + }, + + $( + #[cfg($cfg)] + $crate::DispatchTensorKind::$Backend(_) => { + type B = $crate::backends::$Backend; + + // Unwrap vec + let floats = unwrap_vec!($Backend, $tx.read_floats, float); + let ints = unwrap_vec!($Backend, $tx.read_ints, int); + let bools = unwrap_vec!($Backend, $tx.read_bools, bool); + // Not supported + let qfloats = $tx.read_qfloats.into_iter().map(|_t| todo!("Quantization not supported yet")).collect(); + + B::tr_execute(TransactionPrimitive::new(floats, qfloats, ints, bools)).await + } + )* + } + }}; +} + +/// Helper to dispatch a transaction based on the first available tensor. +macro_rules! transaction_op { + ($tx:ident, $first:expr) => { + backend_list!(transaction_op_arms, $tx, $first) + }; +} diff --git a/crates/burn-dispatch/src/ops/activation.rs b/crates/burn-dispatch/src/ops/activation.rs new file mode 100644 index 0000000..2346697 --- /dev/null +++ b/crates/burn-dispatch/src/ops/activation.rs @@ -0,0 +1,61 @@ +use burn_backend::{Scalar, ops::ActivationOps, tensor::FloatTensor}; + +use crate::Dispatch; + +impl ActivationOps for Dispatch { + fn leaky_relu(tensor: FloatTensor, negative_slope: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::leaky_relu(tensor, negative_slope) => Float) + } + + fn relu(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::relu(tensor) => Float) + } + + fn relu_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + binary_float!((output, float), (grad, float), |output, grad| B::relu_backward(output, grad) => Float) + } + + fn gelu(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::gelu(tensor) => Float) + } + + fn prelu(tensor: FloatTensor, alpha: FloatTensor) -> FloatTensor { + binary_float!((tensor, float), (alpha, float), |tensor, alpha| B::prelu(tensor, alpha) => Float) + } + + fn gelu_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + binary_float!((x, float), (grad, float), |x, grad| B::gelu_backward(x, grad) => Float) + } + + fn sigmoid(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::sigmoid(tensor) => Float) + } + + fn sigmoid_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + binary_float!((output, float), (grad, float), |output, grad| B::sigmoid_backward(output, grad) => Float) + } + + fn hard_sigmoid(tensor: FloatTensor, alpha: Scalar, beta: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::hard_sigmoid(tensor, alpha, beta) => Float) + } + + fn softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::softmax(tensor, dim) => Float) + } + + fn log_softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::log_softmax(tensor, dim) => Float) + } + + fn softmin(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::softmin(tensor, dim) => Float) + } + + fn log_sigmoid(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::log_sigmoid(tensor) => Float) + } + + fn log_sigmoid_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + binary_float!((x, float), (grad, float), |x, grad| B::log_sigmoid_backward(x, grad) => Float) + } +} diff --git a/crates/burn-dispatch/src/ops/bool_tensor.rs b/crates/burn-dispatch/src/ops/bool_tensor.rs new file mode 100644 index 0000000..ef8f0b4 --- /dev/null +++ b/crates/burn-dispatch/src/ops/bool_tensor.rs @@ -0,0 +1,217 @@ +use alloc::vec::Vec; +use burn_backend::{ + BoolDType, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, TensorData, + ops::BoolTensorOps, + tensor::{BoolTensor, FloatTensor, IntTensor}, +}; + +use crate::{Dispatch, DispatchDevice}; + +impl BoolTensorOps for Dispatch { + fn bool_empty(shape: Shape, device: &DispatchDevice, dtype: BoolDType) -> BoolTensor { + creation_op!(Bool, device, |device| B::bool_empty(shape, device, dtype)) + } + + fn bool_zeros(shape: Shape, device: &DispatchDevice, dtype: BoolDType) -> BoolTensor { + creation_op!(Bool, device, |device| B::bool_zeros(shape, device, dtype)) + } + + fn bool_ones(shape: Shape, device: &DispatchDevice, dtype: BoolDType) -> BoolTensor { + creation_op!(Bool, device, |device| B::bool_ones(shape, device, dtype)) + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + unary_op!(tensor, bool, |tensor| B::bool_into_data(tensor).await) + } + + fn bool_from_data(data: TensorData, device: &DispatchDevice) -> BoolTensor { + creation_op!(Bool, device, |device| B::bool_from_data(data, device)) + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + unary_op!(tensor, bool, |tensor| B::bool_into_int(tensor, out_dtype) => Int) + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + unary_op!(tensor, bool, |tensor| B::bool_into_float(tensor, out_dtype) => Float) + } + + fn bool_to_device(tensor: BoolTensor, device: &DispatchDevice) -> BoolTensor { + to_device!( + Bool, + bool, + tensor, + device, + bool_to_device, + |inner, device| { + let data = + burn_backend::read_sync(B1::bool_into_data(inner)).expect("Should read data"); + B2::bool_from_data(data, device) + } + ) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_reshape(tensor, shape) => Bool) + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_slice(tensor, slices) => Bool) + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[Slice], + value: BoolTensor, + ) -> BoolTensor { + binary_op!((tensor, bool), (value, bool), |tensor, value| B::bool_slice_assign(tensor, slices, value) => Bool) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + multi_op!( + inputs[(tensor, bool), (mask, bool), (value, bool)], => Bool, + B::bool_mask_where(tensor, mask, value) + ) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + binary_op!((tensor, bool), (mask, bool), |tensor, mask| B::bool_mask_fill(tensor, mask, value) => Bool) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + binary_op!((tensor, bool), (indices, int), |tensor, indices| B::bool_gather(dim, tensor, indices) => Bool) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + multi_op!( + inputs[(tensor, bool), (indices, int), (value, bool)], => Bool, + B::bool_scatter_or(dim, tensor, indices, value) + ) + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + binary_op!((lhs, bool), (rhs, bool), |lhs, rhs| B::bool_equal(lhs, rhs) => Bool) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + unary_op!(lhs, bool, |lhs| B::bool_equal_elem(lhs, rhs) => Bool) + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_not(tensor) => Bool) + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + binary_op!((lhs, bool), (rhs, bool), |lhs, rhs| B::bool_and(lhs, rhs) => Bool) + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + binary_op!((lhs, bool), (rhs, bool), |lhs, rhs| B::bool_or(lhs, rhs) => Bool) + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_swap_dims(tensor, dim1, dim2) => Bool) + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_permute(tensor, axes) => Bool) + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_flip(tensor, axes) => Bool) + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_expand(tensor, shape) => Bool) + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_unfold(tensor, dim, size, step) => Bool) + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + binary_op!((tensor, bool), (indices, int), |tensor, indices| B::bool_select(tensor, dim, indices) => Bool) + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + multi_op!( + inputs[(tensor, bool), (indices, int), (value, bool)], => Bool, + B::bool_select_or(tensor, dim, indices, value) + ) + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_repeat_dim(tensor, dim, times) => Bool) + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + vec_op!(tensors, bool, |tensors| B::bool_cat(tensors, dim) => Bool) + } + + fn bool_not_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + binary_op!((lhs, bool), (rhs, bool), |lhs, rhs| B::bool_not_equal(lhs, rhs) => Bool) + } + + fn bool_not_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + unary_op!(lhs, bool, |lhs| B::bool_not_equal_elem(lhs, rhs) => Bool) + } + + fn bool_xor(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + binary_op!((lhs, bool), (rhs, bool), |lhs, rhs| B::bool_xor(lhs, rhs) => Bool) + } + + fn bool_transpose(tensor: BoolTensor) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_transpose(tensor) => Bool) + } + + fn bool_any(tensor: BoolTensor) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_any(tensor) => Bool) + } + + fn bool_any_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_any_dim(tensor, dim) => Bool) + } + + fn bool_all(tensor: BoolTensor) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_all(tensor) => Bool) + } + + fn bool_all_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + unary_op!(tensor, bool, |tensor| B::bool_all_dim(tensor, dim) => Bool) + } + + async fn bool_argwhere(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + unary_op!(tensor, bool, |tensor| B::bool_argwhere(tensor, out_dtype).await => Int) + } +} diff --git a/crates/burn-dispatch/src/ops/distributed.rs b/crates/burn-dispatch/src/ops/distributed.rs new file mode 100644 index 0000000..7ce9c6d --- /dev/null +++ b/crates/burn-dispatch/src/ops/distributed.rs @@ -0,0 +1,171 @@ +use alloc::vec::Vec; + +use burn_backend::{ + DeviceId, + distributed::{ + CollectiveTensor, DistributedConfig, DistributedOps, DistributedParams, ReduceOperation, + TensorRef, + }, + tensor::FloatTensor, +}; + +use crate::{Dispatch, DispatchDevice}; + +macro_rules! dispatch_distributed_devices_arms { + ( + $device:expr, + $devices:expr, + |$inner_devices:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => { + match $device { + // Autodiff arm first + #[cfg(feature = "autodiff")] + $crate::DispatchDevice::Autodiff(inner) => { + let inner_devices = $devices + .iter() + .map(|d| { + match &d { + #[cfg(feature = "autodiff")] + $crate::DispatchDevice::Autodiff(d_inner) => *d_inner.inner.clone(), + _ => unreachable!("All devices are expected to be of the same variant."), + } + }) + .collect::>(); + let inner_devices = inner_devices.as_slice(); + // Recursively dispatch on inner + dispatch_distributed_devices_arms!( + @autodiff + &**inner, + &*inner_devices, + |$inner_devices| $body; + $([$Backend, $cfg]),* + ) + }, + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend(_) => { + type B = $crate::backends::$Backend; + let $inner_devices = $devices + .iter() + .map(|d| { + let DispatchDevice::$Backend(dev) = d else { + unreachable!("All devices are expected to be of the same variant.") + }; + dev.clone() + }) + .collect::>(); + $body + } + )* + other => panic!("Distributed operations are not supported for device {other:?}"), + } + }; + ( + @autodiff + $device:expr, + $devices:expr, + |$inner_devices:ident| $body:expr; + $([$Backend:ident, $cfg:meta]),* + ) => { + match $device { + $( + #[cfg($cfg)] + $crate::DispatchDevice::$Backend(_) => { + type B = $crate::backends::Autodiff<$crate::backends::$Backend>; + let $inner_devices = $devices + .iter() + .map(|d| { + let DispatchDevice::$Backend(dev) = d else { + unreachable!("All devices are expected to be of the same variant.") + }; + dev.clone() + }) + .collect::>(); + $body + } + )* + $crate::DispatchDevice::Autodiff(_) => panic!("Autodiff should not wrap an autodiff device."), + other => panic!("Distributed operations are not supported for device {other:?}"), + } + }; +} + +/// Dispatches an operation body based on the provided devices. +macro_rules! dispatch_distributed_devices { + ($device:expr, $devices:expr, |$inner_devices:ident| $body:expr) => { + distributed_backend_list!( + dispatch_distributed_devices_arms, + $device, + $devices, + |$inner_devices| $body + ) + }; +} + +// In builds without a collective-capable backend (Cuda/Remote), the distributed dispatch arms +// are all cfg'd out, leaving only a diverging fallback — so the captured arguments and trailing +// expressions are intentionally unused/unreachable. +#[allow(unused_variables, unreachable_code)] +impl DistributedOps for Dispatch { + fn start_communication_server(devices: &[DispatchDevice], config: DistributedConfig) { + if !devices.is_empty() { + let first = &devices[0]; + dispatch_distributed_devices!(first, devices, |inner_devices| { + B::start_communication_server(&inner_devices, config) + }); + } + } + + fn close_communication_server(device: &DispatchDevice) { + dispatch_device!(@distributed device, |device| { + B::close_communication_server(device) + }) + } + + fn register_sync_parameters( + device: &DispatchDevice, + sharded_param_ids: Vec, + ) { + dispatch_device!(@distributed device, |device| B::register_sync_parameters( + device, + sharded_param_ids, + )) + } + + fn submit_sync_collective(device: &DispatchDevice) { + dispatch_device!(@distributed device, |device| B::submit_sync_collective(device)) + } + + fn submit_gradient_sync(_tensor: TensorRef, _distributed_params: DistributedParams) { + unimplemented!() + } + + fn all_reduce( + tensor: FloatTensor, + op: ReduceOperation, + device_ids: Vec, + ) -> CollectiveTensor { + // Safety: we call `assume_resolved` only to wrap it in a new `CollectiveTensor`. + // Explicit type: the distributed dispatch only emits arms for collective-capable + // backends (Cuda, Remote), so a build with none of them leaves only the diverging + // fallback and the match would otherwise infer `!`. + let tensor: FloatTensor = unary_float!(@distributed tensor, float, |tensor| { + let collective_tensor = B::all_reduce(tensor, op, device_ids); + unsafe { collective_tensor.assume_resolved() } + } => Float); + CollectiveTensor::new(tensor) + } + + fn sync_collective(device: &DispatchDevice) { + dispatch_device!(@distributed device, |device| B::sync_collective(device)) + } + + unsafe fn comm_device(_tensor: &TensorRef) -> DispatchDevice { + unimplemented!() + } + + unsafe fn float_from_ref(_tensor: &TensorRef) -> FloatTensor { + unimplemented!() + } +} diff --git a/crates/burn-dispatch/src/ops/int_tensor.rs b/crates/burn-dispatch/src/ops/int_tensor.rs new file mode 100644 index 0000000..ddf65c2 --- /dev/null +++ b/crates/burn-dispatch/src/ops/int_tensor.rs @@ -0,0 +1,560 @@ +use alloc::vec::Vec; +use burn_backend::{ + BoolDType, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, TensorData, + ops::IntTensorOps, + tensor::{BoolTensor, FloatTensor, IntTensor}, +}; + +use crate::{Dispatch, DispatchDevice}; + +impl IntTensorOps for Dispatch { + fn int_empty(shape: Shape, device: &DispatchDevice, dtype: IntDType) -> IntTensor { + creation_op!(Int, device, |device| B::int_empty(shape, device, dtype)) + } + + async fn int_into_data(tensor: IntTensor) -> Result { + unary_op!(tensor, int, |tensor| B::int_into_data(tensor).await) + } + + fn int_from_data(data: TensorData, device: &DispatchDevice) -> IntTensor { + creation_op!(Int, device, |device| B::int_from_data(data, device)) + } + + fn int_to_device(tensor: IntTensor, device: &DispatchDevice) -> IntTensor { + to_device!(Int, int, tensor, device, int_to_device, |inner, device| { + let data = burn_backend::read_sync(B1::int_into_data(inner)).expect("Should read data"); + B2::int_from_data(data, device) + }) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_reshape(tensor, shape) => Int) + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_slice(tensor, slices) => Int) + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[Slice], + value: IntTensor, + ) -> IntTensor { + binary_op!((tensor, int), (value, int), |tensor, value| B::int_slice_assign(tensor, slices, value) => Int) + } + + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + unary_op!(tensor, int, |tensor| B::int_into_float(tensor, out_dtype) => Float) + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + multi_op!( + inputs[(tensor, int), (mask, bool), (value, int)], => Int, + B::int_mask_where(tensor, mask, value) + ) + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + binary_op!((tensor, int), (mask, bool), |tensor, mask| B::int_mask_fill(tensor, mask, value) => Int) + } + + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + binary_op!((tensor, int), (indices, int), |tensor, indices| B::int_gather(dim, tensor, indices) => Int) + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + multi_op!( + inputs[(tensor, int), (indices, int), (value, int)], => Int, + B::int_scatter_add(dim, tensor, indices, value) + ) + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> IntTensor { + multi_op!( + inputs[(data, int), (indices, int), (values, int)], => Int, + B::int_scatter_nd(data, indices, values, reduction) + ) + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + binary_op!((data, int), (indices, int), |data, indices| B::int_gather_nd(data, indices) => Int) + } + + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + binary_op!((tensor, int), (indices, int), |tensor, indices| B::int_select(tensor, dim, indices) => Int) + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + multi_op!( + inputs[(tensor, int), (indices, int), (value, int)], => Int, + B::int_select_add(tensor, dim, indices, value) + ) + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_equal(lhs, rhs, out_dtype) => Bool) + } + + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_greater(lhs, rhs, out_dtype) => Bool) + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_greater_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_greater_equal(lhs, rhs, out_dtype) => Bool) + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_greater_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_lower(lhs, rhs, out_dtype) => Bool) + } + + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_lower_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_lower_equal(lhs, rhs, out_dtype) => Bool) + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_lower_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_add(lhs, rhs) => Int) + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_add_scalar(lhs, rhs) => Int) + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_sub(lhs, rhs) => Int) + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_sub_scalar(lhs, rhs) => Int) + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_mul(lhs, rhs) => Int) + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_mul_scalar(lhs, rhs) => Int) + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_div(lhs, rhs) => Int) + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_div_scalar(lhs, rhs) => Int) + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_remainder(lhs, rhs) => Int) + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_remainder_scalar(lhs, rhs) => Int) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_matmul(lhs, rhs) => Int) + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_sum(tensor) => Int) + } + + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_sum_dim(tensor, dim) => Int) + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_prod(tensor) => Int) + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_prod_dim(tensor, dim) => Int) + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_mean_dim(tensor, dim) => Int) + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_cumsum(tensor, dim) => Int) + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_cumprod(tensor, dim) => Int) + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_cummin(tensor, dim) => Int) + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_cummax(tensor, dim) => Int) + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_argmax(tensor, dim) => Int) + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_argtopk(tensor, dim, k) => Int) + } + + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_topk(tensor, dim, k) => Int) + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_argmin(tensor, dim) => Int) + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_abs(tensor) => Int) + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_swap_dims(tensor, dim1, dim2) => Int) + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_permute(tensor, axes) => Int) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_flip(tensor, axes) => Int) + } + + fn int_random( + shape: Shape, + distribution: burn_backend::Distribution, + device: &DispatchDevice, + dtype: IntDType, + ) -> IntTensor { + creation_op!(Int, device, |device| { + B::int_random(shape, distribution, device, dtype) + }) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_expand(tensor, shape) => Int) + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::bitwise_and(lhs, rhs) => Int) + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::bitwise_and_scalar(lhs, rhs) => Int) + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::bitwise_or(lhs, rhs) => Int) + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::bitwise_or_scalar(lhs, rhs) => Int) + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::bitwise_xor(lhs, rhs) => Int) + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::bitwise_xor_scalar(lhs, rhs) => Int) + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::bitwise_not(tensor) => Int) + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::bitwise_left_shift(lhs, rhs) => Int) + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::bitwise_left_shift_scalar(lhs, rhs) => Int) + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::bitwise_right_shift(lhs, rhs) => Int) + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::bitwise_right_shift_scalar(lhs, rhs) => Int) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_cast(tensor, dtype) => Int) + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_unfold(tensor, dim, size, step) => Int) + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_repeat_dim(tensor, dim, times) => Int) + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + vec_op!(tensors, int, |tensors| B::int_cat(tensors, dim) => Int) + } + + fn int_not_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_not_equal(lhs, rhs, out_dtype) => Bool) + } + + fn int_not_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_op!(lhs, int, |lhs| B::int_not_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_op!((lhs, int), (rhs, int), |lhs, rhs| B::int_powi(lhs, rhs) => Int) + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + unary_op!(lhs, int, |lhs| B::int_powi_scalar_impl(lhs, rhs) => Int) + } + + fn int_clamp_min(tensor: IntTensor, min: Scalar) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_clamp_min(tensor, min) => Int) + } + + fn int_clamp_max(tensor: IntTensor, max: Scalar) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_clamp_max(tensor, max) => Int) + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_clamp(tensor, min, max) => Int) + } + + fn int_neg(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_neg(tensor) => Int) + } + + fn int_zeros(shape: Shape, device: &DispatchDevice, dtype: IntDType) -> IntTensor { + creation_op!(Int, device, |device| B::int_zeros(shape, device, dtype)) + } + + fn int_ones(shape: Shape, device: &DispatchDevice, dtype: IntDType) -> IntTensor { + creation_op!(Int, device, |device| B::int_ones(shape, device, dtype)) + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &DispatchDevice, + dtype: IntDType, + ) -> IntTensor { + creation_op!(Int, device, |device| B::int_full( + shape, fill_value, device, dtype + )) + } + + fn int_mean(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_mean(tensor) => Int) + } + + fn int_max(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_max(tensor) => Int) + } + + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_max_dim(tensor, dim) => Int) + } + + fn int_max_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + multi_op!( + inputs[(tensor, int)], + outputs[(out, Int), (indices, Int)], + B::int_max_dim_with_indices(tensor, dim) + ) + } + + fn int_max_abs(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_max_abs(tensor) => Int) + } + + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_max_abs_dim(tensor, dim) => Int) + } + + fn int_min(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_min(tensor) => Int) + } + + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_min_dim(tensor, dim) => Int) + } + + fn int_min_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + multi_op!( + inputs[(tensor, int)], + outputs[(out, Int), (indices, Int)], + B::int_min_dim_with_indices(tensor, dim) + ) + } + + fn int_transpose(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_transpose(tensor) => Int) + } + + fn int_arange_step( + range: core::ops::Range, + step: usize, + device: &DispatchDevice, + dtype: IntDType, + ) -> IntTensor { + creation_op!(Int, device, |device| B::int_arange_step( + range, step, device, dtype + )) + } + + fn int_arange( + range: core::ops::Range, + device: &DispatchDevice, + dtype: IntDType, + ) -> IntTensor { + creation_op!(Int, device, |device| B::int_arange(range, device, dtype)) + } + + fn int_any(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + unary_op!(tensor, int, |tensor| B::int_any(tensor, out_dtype) => Bool) + } + + fn int_any_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + unary_op!(tensor, int, |tensor| B::int_any_dim(tensor, dim, out_dtype) => Bool) + } + + fn int_all(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + unary_op!(tensor, int, |tensor| B::int_all(tensor, out_dtype) => Bool) + } + + fn int_all_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + unary_op!(tensor, int, |tensor| B::int_all_dim(tensor, dim, out_dtype) => Bool) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_sign(tensor) => Int) + } + + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_sort(tensor, dim, descending) => Int) + } + + fn int_sort_with_indices( + tensor: IntTensor, + dim: usize, + descending: bool, + ) -> (IntTensor, IntTensor) { + multi_op!( + inputs[(tensor, int)], + outputs[(out, Int), (indices, Int)], + B::int_sort_with_indices(tensor, dim, descending) + ) + } + + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + unary_op!(tensor, int, |tensor| B::int_argsort(tensor, dim, descending) => Int) + } +} diff --git a/crates/burn-dispatch/src/ops/mod.rs b/crates/burn-dispatch/src/ops/mod.rs new file mode 100644 index 0000000..5ce9d98 --- /dev/null +++ b/crates/burn-dispatch/src/ops/mod.rs @@ -0,0 +1,8 @@ +mod activation; +mod bool_tensor; +mod distributed; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; diff --git a/crates/burn-dispatch/src/ops/module.rs b/crates/burn-dispatch/src/ops/module.rs new file mode 100644 index 0000000..2d2044c --- /dev/null +++ b/crates/burn-dispatch/src/ops/module.rs @@ -0,0 +1,722 @@ +use burn_backend::{ + IntDType, + ops::{ + DeformConv2dBackward, MaxPool1dBackward, MaxPool1dWithIndices, MaxPool2dBackward, + MaxPool2dWithIndices, ModuleOps, + }, + tensor::{FloatTensor, IntTensor}, +}; + +use crate::Dispatch; + +impl ModuleOps for Dispatch { + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv2d(x, weight, bias, options) + ) + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: burn_backend::ops::DeformConvOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (offset, float), (weight, float)], + opt_inputs[(mask, float), (bias, float)], + => Float, + B::deform_conv2d(x, offset, weight, mask, bias, options) + ) + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: burn_backend::ops::DeformConvOptions<2>, + ) -> DeformConv2dBackward { + let (x_grad, offset_grad, weight_grad, mask_grad, bias_grad) = multi_op!( + inputs[(x, float), (offset, float), (weight, float), (output_grad, float)], + opt_inputs[(mask, float), (bias, float)], + outputs[(x_grad, Float), (offset_grad, Float), (weight_grad, Float)], + opt_outputs[mask_grad, bias_grad], + { + let res = B::deform_conv2d_backward(x, offset, weight, mask, bias, output_grad, options); + (res.x_grad, res.offset_grad, res.weight_grad, res.mask_grad, res.bias_grad) + } + ); + DeformConv2dBackward::new(x_grad, offset_grad, weight_grad, mask_grad, bias_grad) + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv3d(x, weight, bias, options) + ) + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvTransposeOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv_transpose2d(x, weight, bias, options) + ) + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvTransposeOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv_transpose3d(x, weight, bias, options) + ) + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + multi_op!(inputs[(x, float)], + => Float, + B::avg_pool2d(x, kernel_size, stride, padding, count_include_pad, ceil_mode) + ) + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (grad, float)], + => Float, + B::avg_pool2d_backward(x, grad, kernel_size, stride, padding, count_include_pad, ceil_mode) + ) + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + multi_op!( + inputs[(x, float)], + => Float, + B::adaptive_avg_pool2d(x, output_size) + ) + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (grad, float)], + => Float, + B::adaptive_avg_pool2d_backward(x, grad) + ) + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + multi_op!( + inputs[(x, float)], + => Float, + B::max_pool2d(x, kernel_size, stride, padding, dilation, ceil_mode) + ) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + let (out, indices) = multi_op!( + inputs[(x, float)], + outputs[(out, Float), (indices, Int)], + { + let res = B::max_pool2d_with_indices(x, kernel_size, stride, padding, dilation, ceil_mode, indices_dtype); + (res.output, res.indices) + } + ); + MaxPool2dWithIndices::new(out, indices) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + let x_grad = multi_op!( + inputs[(x, float), (output_grad, float), (indices, int)], + => Float, + { + let res = B::max_pool2d_with_indices_backward(x, kernel_size, stride, padding, dilation, ceil_mode, output_grad, indices); + res.x_grad + } + ); + MaxPool2dBackward::new(x_grad) + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: burn_backend::ops::InterpolateOptions, + ) -> FloatTensor { + multi_op!( + inputs[(x, float)], + => Float, + B::interpolate(x, output_size, options) + ) + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: burn_backend::ops::InterpolateOptions, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (grad, float)], + => Float, + B::interpolate_backward(x, grad, output_size, options) + ) + } + + fn embedding(weights: FloatTensor, indices: IntTensor) -> FloatTensor { + multi_op!( + inputs[(weights, float), (indices, int)], + => Float, + B::embedding(weights, indices) + ) + } + + fn embedding_backward( + weights: FloatTensor, + output_grad: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + multi_op!( + inputs[(weights, float), (output_grad, float), (indices, int)], + => Float, + B::embedding_backward(weights, output_grad, indices) + ) + } + + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv1d(x, weight, bias, options) + ) + } + + fn conv1d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv1d_x_backward(x, weight, output_grad, options) + ) + } + + fn conv1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv1d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv1d_bias_backward(x, bias, output_grad) + ) + } + + fn conv2d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv2d_x_backward(x, weight, output_grad, options) + ) + } + + fn conv2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv2d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv2d_bias_backward(x, bias, output_grad) + ) + } + + fn conv3d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv3d_x_backward(x, weight, output_grad, options) + ) + } + + fn conv3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv3d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv3d_bias_backward(x, bias, output_grad) + ) + } + + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: burn_backend::ops::ConvTransposeOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float)], + opt_inputs[(bias, float)], + => Float, + B::conv_transpose1d(x, weight, bias, options) + ) + } + + fn conv_transpose1d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(weight, float), (output_grad, float)], + => Float, + B::conv_transpose1d_x_backward(weight, output_grad, options) + ) + } + + fn conv_transpose1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<1>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv_transpose1d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv_transpose1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv_transpose1d_bias_backward(x, bias, output_grad) + ) + } + + fn conv_transpose2d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(weight, float), (output_grad, float)], + => Float, + B::conv_transpose2d_x_backward(weight, output_grad, options) + ) + } + + fn conv_transpose2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<2>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv_transpose2d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv_transpose2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv_transpose2d_bias_backward(x, bias, output_grad) + ) + } + + fn conv_transpose3d_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(weight, float), (output_grad, float)], + => Float, + B::conv_transpose3d_x_backward(weight, output_grad, options) + ) + } + + fn conv_transpose3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: burn_backend::ops::ConvTransposeOptions<3>, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (weight, float), (output_grad, float)], + => Float, + B::conv_transpose3d_weight_backward(x, weight, output_grad, options) + ) + } + + fn conv_transpose3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (bias, float), (output_grad, float)], + => Float, + B::conv_transpose3d_bias_backward(x, bias, output_grad) + ) + } + + fn unfold4d( + x: FloatTensor, + kernel_size: [usize; 2], + options: burn_backend::ops::UnfoldOptions, + ) -> FloatTensor { + multi_op!(inputs[(x, float)], => Float, B::unfold4d(x, kernel_size, options)) + } + + fn avg_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + multi_op!(inputs[(x, float)], => Float, + B::avg_pool1d(x, kernel_size, stride, padding, count_include_pad, ceil_mode) + ) + } + + fn avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (grad, float)], + => Float, + B::avg_pool1d_backward(x, grad, kernel_size, stride, padding, count_include_pad, ceil_mode) + ) + } + + fn adaptive_avg_pool1d(x: FloatTensor, output_size: usize) -> FloatTensor { + multi_op!(inputs[(x, float)], => Float, B::adaptive_avg_pool1d(x, output_size)) + } + + fn adaptive_avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(x, float), (grad, float)], + => Float, + B::adaptive_avg_pool1d_backward(x, grad) + ) + } + + fn max_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> FloatTensor { + multi_op!(inputs[(x, float)], => Float, + B::max_pool1d(x, kernel_size, stride, padding, dilation, ceil_mode)) + } + + fn max_pool1d_with_indices( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool1dWithIndices { + let (out, indices) = multi_op!( + inputs[(x, float)], + outputs[(out, Float), (indices, Int)], + { + let res = B::max_pool1d_with_indices(x, kernel_size, stride, padding, dilation, ceil_mode, indices_dtype); + (res.output, res.indices) + } + ); + MaxPool1dWithIndices::new(out, indices) + } + + fn max_pool1d_with_indices_backward( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool1dBackward { + let x_grad = multi_op!( + inputs[(x, float), (output_grad, float), (indices, int)], + => Float, + { + let res = B::max_pool1d_with_indices_backward(x, kernel_size, stride, padding, dilation, ceil_mode, output_grad, indices); + res.x_grad + } + ); + MaxPool1dBackward::new(x_grad) + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: burn_backend::ops::AttentionModuleOptions, + ) -> FloatTensor { + multi_op!( + inputs[(query, float), (key, float), (value, float)], + opt_inputs[(mask, bool), (attn_bias, float)], + => Float, + B::attention(query, key, value, mask, attn_bias, options) + ) + } + + fn layer_norm( + tensor: FloatTensor, + gamma: FloatTensor, + beta: Option>, + epsilon: f64, + ) -> FloatTensor { + multi_op!( + inputs[(tensor, float), (gamma, float)], + opt_inputs[(beta, float)], + => Float, + B::layer_norm(tensor, gamma, beta, epsilon) + ) + } + + fn rfft( + signal: FloatTensor, + dim: usize, + n: Option, + ) -> (FloatTensor, FloatTensor) { + let (real, imag) = multi_op!( + inputs[(signal, float)], + outputs[(real, Float), (imag, Float)], + { + let res = B::rfft(signal, dim, n); + (res.0, res.1) + } + ); + + (real, imag) + } + + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + n: Option, + ) -> FloatTensor { + multi_op!( + inputs[(spectrum_re, float), (spectrum_im, float)], + => Float, + { + B::irfft(spectrum_re, spectrum_im, dim, n) + } + ) + } + + fn has_ctc_loss_backward() -> bool { + // Dispatch routes per-tensor at runtime, but autodiff queries this flag + // statically. Returning `false` makes autodiff differentiate through + // the default decomposed forward, which is safe for every inner + // backend regardless of whether it has its own ctc_loss_backward. + false + } + + fn ctc_loss( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, + ) -> FloatTensor { + multi_op!( + inputs[(log_probs, float), (targets, int), (input_lengths, int), (target_lengths, int)], + => Float, + B::ctc_loss(log_probs, targets, input_lengths, target_lengths, blank) + ) + } + + fn ctc_loss_backward( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + grad_loss: FloatTensor, + blank: usize, + ) -> FloatTensor { + multi_op!( + inputs[(log_probs, float), (targets, int), (input_lengths, int), (target_lengths, int), (grad_loss, float)], + => Float, + B::ctc_loss_backward(log_probs, targets, input_lengths, target_lengths, grad_loss, blank) + ) + } + + // TODO: linear ops + // fn linear( + // x: FloatTensor, + // weight: FloatTensor, + // bias: Option>, + // ) -> FloatTensor { + + // } +} diff --git a/crates/burn-dispatch/src/ops/qtensor.rs b/crates/burn-dispatch/src/ops/qtensor.rs new file mode 100644 index 0000000..b59dd88 --- /dev/null +++ b/crates/burn-dispatch/src/ops/qtensor.rs @@ -0,0 +1,210 @@ +use burn_backend::{ + DeviceOps, ExecutionError, FloatDType, Shape, Slice, TensorData, TensorMetadata, + TensorPrimitive, + ops::QTensorOps, + quantization::{QuantPropagation, QuantScheme, QuantizationParametersPrimitive}, + tensor::{FloatTensor, IntTensor, QuantizedTensor}, +}; + +use crate::{Dispatch, DispatchDevice}; + +impl QTensorOps for Dispatch { + fn q_from_data(data: TensorData, device: &DispatchDevice) -> QuantizedTensor { + creation_op!(Quantized, device, |device| B::q_from_data(data, device)) + } + + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + binary_op!( + (tensor, float), + (qparams.scales, float), + |tensor, scales| { + B::quantize(tensor, scheme, QuantizationParametersPrimitive { scales }) + } => Quantized + ) + } + + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor { + unary_op!(tensor, quantized, |tensor| B::dequantize(tensor, dtype) => Float) + } + + fn q_to_device( + tensor: QuantizedTensor, + device: &DispatchDevice, + ) -> QuantizedTensor { + to_device!( + Quantized, + quantized, + tensor, + device, + q_to_device, + |inner, device| { + let data = + burn_backend::read_sync(B1::q_into_data(inner)).expect("Should read data"); + B2::q_from_data(data, device) + } + ) + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_reshape(tensor, shape) => Quantized) + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + unary_op!(tensor, quantized, |tensor| B::q_into_data(tensor).await) + } + + fn q_expand(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_expand(tensor, shape) => Quantized) + } + + fn q_swap_dims( + tensor: QuantizedTensor, + dim1: usize, + dim2: usize, + ) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_swap_dims(tensor, dim1, dim2) => Quantized) + } + + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_permute(tensor, axes) => Quantized) + } + + fn q_flip(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_flip(tensor, axes) => Quantized) + } + + fn q_select( + tensor: QuantizedTensor, + dim: usize, + indices: IntTensor, + ) -> QuantizedTensor { + binary_op!( + (tensor, quantized), + (indices, int), + |tensor, indices| B::q_select(tensor, dim, indices) => Quantized + ) + } + + fn q_slice(tensor: QuantizedTensor, slices: &[Slice]) -> QuantizedTensor { + unary_op!(tensor, quantized, |tensor| B::q_slice(tensor, slices) => Quantized) + } + + fn q_matmul(lhs: TensorPrimitive, rhs: TensorPrimitive) -> TensorPrimitive { + // TODO: this would be much cleaner if we consolidated tensor primitive types + match (lhs, rhs) { + (TensorPrimitive::QFloat(lhs), TensorPrimitive::QFloat(rhs)) => { + let propagation = lhs.device().defaults().quantization.propagation; + if matches!(propagation, QuantPropagation::Propagate) { + let out = binary_op!( + (lhs, quantized), + (rhs, quantized), + |lhs, rhs| { + if let TensorPrimitive::QFloat(out) = B::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::QFloat(rhs), + ) { + out + } else { + unreachable!() + } + } => Quantized + ); + TensorPrimitive::QFloat(out) + } else { + let out = binary_op!( + (lhs, quantized), + (rhs, quantized), + |lhs, rhs| { + if let TensorPrimitive::Float(out) = B::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::QFloat(rhs), + ) { + out + } else { + unreachable!() + } + } => Float + ); + TensorPrimitive::Float(out) + } + } + (TensorPrimitive::Float(lhs), TensorPrimitive::QFloat(rhs)) => { + let propagation = rhs.device().defaults().quantization.propagation; + if matches!(propagation, QuantPropagation::Propagate) { + let out = binary_op!( + (lhs, float), + (rhs, quantized), + |lhs, rhs| { + if let TensorPrimitive::QFloat(out) = B::q_matmul( + TensorPrimitive::Float(lhs), + TensorPrimitive::QFloat(rhs), + ) { + out + } else { + unreachable!() + } + } => Quantized + ); + TensorPrimitive::QFloat(out) + } else { + let out = binary_op!( + (lhs, float), + (rhs, quantized), + |lhs, rhs| { + if let TensorPrimitive::Float(out) = B::q_matmul( + TensorPrimitive::Float(lhs), + TensorPrimitive::QFloat(rhs), + ) { + out + } else { + unreachable!() + } + } => Float + ); + TensorPrimitive::Float(out) + } + } + (TensorPrimitive::QFloat(lhs), TensorPrimitive::Float(rhs)) => { + let propagation = lhs.device().defaults().quantization.propagation; + if matches!(propagation, QuantPropagation::Propagate) { + let out = binary_op!( + (lhs, quantized), + (rhs, float), + |lhs, rhs| { + if let TensorPrimitive::QFloat(out) = B::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::Float(rhs), + ) { + out + } else { + unreachable!() + } + } => Quantized + ); + TensorPrimitive::QFloat(out) + } else { + let out = binary_op!( + (lhs, quantized), + (rhs, float), + |lhs, rhs| { + if let TensorPrimitive::Float(out) = B::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::Float(rhs), + ) { + out + } else { + unreachable!() + } + } => Float + ); + TensorPrimitive::Float(out) + } + } + _ => unreachable!(), + } + } +} diff --git a/crates/burn-dispatch/src/ops/tensor.rs b/crates/burn-dispatch/src/ops/tensor.rs new file mode 100644 index 0000000..a6d58d3 --- /dev/null +++ b/crates/burn-dispatch/src/ops/tensor.rs @@ -0,0 +1,696 @@ +use alloc::vec::Vec; +use burn_backend::{ + BoolDType, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, TensorData, + ops::FloatTensorOps, + tensor::{BoolTensor, FloatTensor, IntTensor}, +}; + +use crate::{Dispatch, DispatchDevice}; + +impl FloatTensorOps for Dispatch { + fn float_from_data( + data: burn_backend::TensorData, + device: &DispatchDevice, + ) -> FloatTensor { + creation_op!(Float, device, |device| B::float_from_data(data, device)) + } + + fn float_random( + shape: Shape, + distribution: burn_backend::Distribution, + device: &DispatchDevice, + dtype: FloatDType, + ) -> FloatTensor { + creation_op!(Float, device, |device| { + B::float_random(shape, distribution, device, dtype) + }) + } + + async fn float_into_data(tensor: FloatTensor) -> Result { + unary_float!(tensor, float, |tensor| B::float_into_data(tensor).await) + } + + fn float_to_device(tensor: FloatTensor, device: &DispatchDevice) -> FloatTensor { + // Relocating a non-tracked float tensor onto an autodiff device is a plain data move: + // place it on the underlying hardware device and leave the tensor non-tracked. The + // int/bool `to_device` paths already handle this case; only the float path used to + // panic. This is what lets gradient tensors — which are never autodiff-tracked — be + // moved onto the autodiff `device_main` during multi-device training. + #[cfg(feature = "autodiff")] + if let DispatchDevice::Autodiff(device_ad) = device + && !matches!(&tensor.kind, crate::DispatchTensorKind::Autodiff(_)) + { + return Self::float_to_device(tensor, &device_ad.inner); + } + + float_to_device!( + Float, + float, + tensor, + device, + float_to_device, + |inner, device| { + let data = + burn_backend::read_sync(B1::float_into_data(inner)).expect("Should read data"); + B2::float_from_data(data, device) + } + ) + } + + fn float_into_int(tensor: FloatTensor, dtype: burn_backend::IntDType) -> IntTensor { + unary_float!(tensor, float, |tensor| B::float_into_int(tensor, dtype) => Int) + } + + fn float_empty(shape: Shape, device: &DispatchDevice, dtype: FloatDType) -> FloatTensor { + creation_op!(Float, device, |device| B::float_empty(shape, device, dtype)) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_add(lhs, rhs) => Float) + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_add_scalar(lhs, rhs) => Float) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_sub(lhs, rhs) => Float) + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_sub_scalar(lhs, rhs) => Float) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_mul(lhs, rhs) => Float) + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_mul_scalar(lhs, rhs) => Float) + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_div(lhs, rhs) => Float) + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_div_scalar(lhs, rhs) => Float) + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_remainder(lhs, rhs) => Float) + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_remainder_scalar(lhs, rhs) => Float) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_matmul(lhs, rhs) => Float) + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_cross(lhs, rhs, dim) => Float) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_recip(tensor) => Float) + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_swap_dims(tensor, dim1, dim2) => Float) + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_permute(tensor, axes) => Float) + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_flip(tensor, axes) => Float) + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_reshape(tensor, shape) => Float) + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + binary_float!((tensor, float), (indices, int), |tensor, indices| B::float_gather(dim, tensor, indices) => Float) + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(tensor, float), (indices, int), (value, float)], => Float, + B::float_scatter_add(dim, tensor, indices, value) + ) + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + multi_op!( + inputs[(data, float), (indices, int), (values, float)], => Float, + B::float_scatter_nd(data, indices, values, reduction) + ) + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + binary_float!((data, float), (indices, int), |data, indices| B::float_gather_nd(data, indices) => Float) + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + binary_float!((tensor, float), (indices, int), |tensor, indices| B::float_select(tensor, dim, indices) => Float) + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(tensor, float), (indices, int), (value, float)], => Float, + B::float_select_add(tensor, dim, indices, value) + ) + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_slice(tensor, slices) => Float) + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[Slice], + value: FloatTensor, + ) -> FloatTensor { + binary_float!((tensor, float), (value, float), |tensor, value| B::float_slice_assign(tensor, slices, value) => Float) + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + multi_op!( + inputs[(tensor, float), (mask, bool), (value, float)], => Float, + B::float_mask_where(tensor, mask, value) + ) + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + binary_float!((tensor, float), (mask, bool), |tensor, mask| B::float_mask_fill(tensor, mask, value) => Float) + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_equal(lhs, rhs, out_dtype) => Bool) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_greater(lhs, rhs, out_dtype) => Bool) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_greater_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_greater_equal(lhs, rhs, out_dtype) => Bool) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_greater_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_lower(lhs, rhs, out_dtype) => Bool) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_lower_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_lower_equal(lhs, rhs, out_dtype) => Bool) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_lower_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sum(tensor) => Float) + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sum_dim(tensor, dim) => Float) + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_mean_dim(tensor, dim) => Float) + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cumsum(tensor, dim) => Float) + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cumprod(tensor, dim) => Float) + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cummin(tensor, dim) => Float) + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cummax(tensor, dim) => Float) + } + + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cast(tensor, dtype) => Float) + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_exp(tensor) => Float) + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_log(tensor) => Float) + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_log1p(tensor) => Float) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_powf(lhs, rhs) => Float) + } + + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_powf_scalar_impl(tensor, value) => Float) + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sqrt(tensor) => Float) + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_abs(tensor) => Float) + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cos(tensor) => Float) + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sin(tensor) => Float) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_tan(tensor) => Float) + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_cosh(tensor) => Float) + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sinh(tensor) => Float) + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_tanh(tensor) => Float) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_acos(tensor) => Float) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_acosh(tensor) => Float) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_asin(tensor) => Float) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_asinh(tensor) => Float) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_atan(tensor) => Float) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_atanh(tensor) => Float) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_atan2(lhs, rhs) => Float) + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_round(tensor) => Float) + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_floor(tensor) => Float) + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_ceil(tensor) => Float) + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_trunc(tensor) => Float) + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_erf(tensor) => Float) + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + unary_float!(tensor, float, |tensor| B::float_argmax(tensor, dim, out_dtype) => Int) + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + unary_float!(tensor, float, |tensor| B::float_argtopk(tensor, dim, k, out_dtype) => Int) + } + + fn float_topk(tensor: FloatTensor, dim: usize, k: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_topk(tensor, dim, k) => Float) + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + unary_float!(tensor, float, |tensor| B::float_argmin(tensor, dim, out_dtype) => Int) + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_expand(tensor, shape) => Float) + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + unary_float!(tensor, float, |tensor| { + B::float_unfold(tensor, dim, size, step) + } => Float) + } + + fn float_detach(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_detach(tensor) => Float) + } + + fn float_set_require_grad(tensor: FloatTensor, require_grad: bool) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_set_require_grad(tensor, require_grad) => Float) + } + + fn float_is_require_grad(tensor: &FloatTensor) -> bool { + unary_float!(ref tensor, float, |tensor| B::float_is_require_grad(tensor)) + } + + // Default implementation + fn float_zeros(shape: Shape, device: &DispatchDevice, dtype: FloatDType) -> FloatTensor { + creation_op!(Float, device, |device| B::float_zeros(shape, device, dtype)) + } + + fn float_ones(shape: Shape, device: &DispatchDevice, dtype: FloatDType) -> FloatTensor { + creation_op!(Float, device, |device| B::float_ones(shape, device, dtype)) + } + + fn float_full( + shape: Shape, + fill_value: Scalar, + device: &DispatchDevice, + dtype: FloatDType, + ) -> FloatTensor { + creation_op!(Float, device, |device| B::float_full( + shape, fill_value, device, dtype + )) + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_repeat_dim(tensor, dim, times) => Float) + } + + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_clamp_min(tensor, min) => Float) + } + + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_clamp_max(tensor, max) => Float) + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_clamp(tensor, min, max) => Float) + } + + fn float_neg(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_neg(tensor) => Float) + } + + fn float_transpose(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_transpose(tensor) => Float) + } + + fn float_not_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_not_equal(lhs, rhs, out_dtype) => Bool) + } + + fn float_not_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(lhs, float, |lhs| B::float_not_equal_elem(lhs, rhs, out_dtype) => Bool) + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_prod(tensor) => Float) + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_prod_dim(tensor, dim) => Float) + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_mean(tensor) => Float) + } + + fn float_powi(lhs: FloatTensor, rhs: IntTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, int), |lhs, rhs| B::float_powi(lhs, rhs) => Float) + } + + fn float_powi_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + unary_float!(lhs, float, |lhs| B::float_powi_scalar_impl(lhs, rhs) => Float) + } + + fn float_powf_scalar(tensor: FloatTensor, value: Scalar) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_powf_scalar(tensor, value) => Float) + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + vec_op!(tensors, float, |tensors| B::float_cat(tensors, dim) => Float) + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_max(tensor) => Float) + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_max_dim(tensor, dim) => Float) + } + + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + multi_op!( + inputs[(tensor, float)], + outputs[(out, Float), (indices, Int)], + B::float_max_dim_with_indices(tensor, dim, indices_dtype) + ) + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_min(tensor) => Float) + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_min_dim(tensor, dim) => Float) + } + + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + multi_op!( + inputs[(tensor, float)], + outputs[(out, Float), (indices, Int)], + B::float_min_dim_with_indices(tensor, dim, indices_dtype) + ) + } + + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_max_abs(tensor) => Float) + } + + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_max_abs_dim(tensor, dim) => Float) + } + + fn float_any(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_any(tensor, out_dtype) => Bool) + } + + fn float_any_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_any_dim(tensor, dim, out_dtype) => Bool) + } + + fn float_all(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_all(tensor, out_dtype) => Bool) + } + + fn float_all_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: BoolDType, + ) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_all_dim(tensor, dim, out_dtype) => Bool) + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sign(tensor) => Float) + } + + fn float_sort(tensor: FloatTensor, dim: usize, descending: bool) -> FloatTensor { + unary_float!(tensor, float, |tensor| B::float_sort(tensor, dim, descending) => Float) + } + + fn float_sort_with_indices( + tensor: FloatTensor, + dim: usize, + descending: bool, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + multi_op!( + inputs[(tensor, float)], + outputs[(out, Float), (indices, Int)], + B::float_sort_with_indices(tensor, dim, descending, indices_dtype) + ) + } + + fn float_argsort( + tensor: FloatTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> IntTensor { + unary_float!(tensor, float, |tensor| B::float_argsort(tensor, dim, descending, out_dtype) => Int) + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: burn_backend::ops::GridSampleOptions, + ) -> FloatTensor { + binary_float!((tensor, float), (grid, float), |tensor, grid| B::float_grid_sample_2d(tensor, grid, options) => Float) + } + + fn float_is_nan(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_is_nan(tensor, out_dtype) => Bool) + } + + fn float_is_inf(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + unary_float!(tensor, float, |tensor| B::float_is_inf(tensor, out_dtype) => Bool) + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float!((lhs, float), (rhs, float), |lhs, rhs| B::float_hypot(lhs, rhs) => Float) + } +} diff --git a/crates/burn-dispatch/src/ops/transaction.rs b/crates/burn-dispatch/src/ops/transaction.rs new file mode 100644 index 0000000..8da3a18 --- /dev/null +++ b/crates/burn-dispatch/src/ops/transaction.rs @@ -0,0 +1,26 @@ +use alloc::vec::Vec; +use burn_backend::{ + ExecutionError, + ops::{TransactionOps, TransactionPrimitive, TransactionPrimitiveData}, +}; + +use crate::Dispatch; + +impl TransactionOps for Dispatch { + async fn tr_execute( + transaction: TransactionPrimitive, + ) -> Result { + let first_tensor = transaction + .read_floats + .first() + .or(transaction.read_ints.first()) + .or(transaction.read_bools.first()); + + match first_tensor { + Some(tensor) => { + transaction_op!(transaction, tensor) + } + None => Ok(TransactionPrimitiveData::default()), + } + } +} diff --git a/crates/burn-dispatch/src/remote_server.rs b/crates/burn-dispatch/src/remote_server.rs new file mode 100644 index 0000000..b6e51bc --- /dev/null +++ b/crates/burn-dispatch/src/remote_server.rs @@ -0,0 +1,172 @@ +//! Concrete-backend dispatch for `burn-remote`'s server entry points. +//! +//! Lives in `burn-dispatch` because matching on [`DispatchDevice`] requires the +//! local `wgpu_metal`/`wgpu_vulkan`/`wgpu_webgpu` cfgs set by this crate's +//! `build.rs`, plus visibility of every in-tree `BackendIr` type. The user +//! surface (`Channel` enum, opaque `Device` argument) lives in `burn-tensor`. + +use std::sync::Arc; + +use burn_remote::Endpoint; +use burn_remote::server::{CustomOpRegistry, IrohRemoteProtocol, PeerAuthorizer, RemoteProtocol}; +use burn_remote::telemetry::TelemetryProbe; + +use crate::backends::*; +use crate::{Dispatch, DispatchDevice, DispatchDeviceId}; + +/// Transport used to serve remote clients. Re-exported from `burn-remote` so the whole stack +/// shares one definition. +pub use burn_remote::server::Channel; + +/// Collect every [`Device`] the host exposes for the backend that owns `$variant`, by +/// enumerating the backend (see [`Dispatch::enumerate`]) and unwrapping the matching variant. +/// `$id` is the [`DispatchDeviceId`] to enumerate; the result is `Vec>`, indexed by +/// hardware device index, which is exactly the index a client selects with `Device::remote`. +/// +/// Enumeration is only trustworthy when it finds **more than one** device: several backends (the +/// wgpu family — Vulkan/WebGpu/Wgpu — and the CPU-only ones like Flex) can't enumerate hardware +/// and report either nothing or a single placeholder index that isn't the device you'd actually +/// run on. In that case fall back to hosting a single backend-specific default device +/// (`Device::::default()`, e.g. `WgpuDevice::DefaultDevice`) rather than a possibly-empty or +/// bogus enumerated list. This generalizes what used to be a hardcoded Vulkan special-case. +macro_rules! host_devices { + ($id:expr, $variant:ident) => {{ + let devices = Dispatch::enumerate($id) + .into_iter() + .filter_map(|device| match device { + DispatchDevice::$variant(device) => Some(device), + _ => None, + }) + .collect::>(); + if devices.len() > 1 { + devices + } else { + vec![Default::default()] + } + }}; +} + +/// Run `$body` with the concrete backend that owns `$device`'s variant bound to the type alias +/// `$b` and that backend's host device list bound to `$devices`. +/// +/// This is the single source of truth for the `DispatchDevice` → concrete-`BackendIr` mapping. +/// The sync and async server entry points differ only in whether `$body` awaits the call, so they +/// share this one match instead of duplicating eleven `#[cfg]`-gated arms each. Backends without a +/// `BackendIr` impl (`LibTorch`, `Remote`) panic; `Autodiff` is already stripped by `.inner()`. +macro_rules! with_backend { + ($device:expr, |$b:ident, $devices:ident| $body:expr) => { + match $device.inner() { + #[cfg(feature = "cpu")] + DispatchDevice::Cpu(_) => { + type $b = Cpu; + let $devices = host_devices!(DispatchDeviceId::Cpu, Cpu); + $body + } + #[cfg(feature = "cuda")] + DispatchDevice::Cuda(_) => { + type $b = Cuda; + let $devices = host_devices!(DispatchDeviceId::Cuda, Cuda); + $body + } + #[cfg(feature = "metal")] + DispatchDevice::Metal(_) => { + type $b = Metal; + let $devices = host_devices!(DispatchDeviceId::Metal, Metal); + $body + } + #[cfg(feature = "rocm")] + DispatchDevice::Rocm(_) => { + type $b = Rocm; + let $devices = host_devices!(DispatchDeviceId::Rocm, Rocm); + $body + } + #[cfg(feature = "vulkan")] + DispatchDevice::Vulkan(_) => { + type $b = Vulkan; + let $devices = host_devices!(DispatchDeviceId::Vulkan, Vulkan); + $body + } + #[cfg(feature = "wgpu")] + DispatchDevice::Wgpu(_) => { + type $b = Wgpu; + let $devices = host_devices!(DispatchDeviceId::Wgpu, Wgpu); + $body + } + #[cfg(feature = "webgpu")] + DispatchDevice::WebGpu(_) => { + type $b = WebGpu; + let $devices = host_devices!(DispatchDeviceId::WebGpu, WebGpu); + $body + } + #[cfg(any(feature = "flex", default_backend))] + DispatchDevice::Flex(_) => { + type $b = Flex; + let $devices = host_devices!(DispatchDeviceId::Flex, Flex); + $body + } + #[cfg(feature = "ndarray")] + DispatchDevice::NdArray(_) => { + type $b = NdArray; + let $devices = host_devices!(DispatchDeviceId::NdArray, NdArray); + $body + } + #[cfg(feature = "tch")] + DispatchDevice::LibTorch(_) => { + panic!("LibTorch is not supported as a remote-server backend (no BackendIr impl)") + } + #[cfg(feature = "remote")] + DispatchDevice::Remote(_) => { + panic!("Cannot host a remote server on a remote device") + } + #[cfg(feature = "autodiff")] + DispatchDevice::Autodiff(_) => { + unreachable!("Autodiff stripped by .inner() above") + } + } + }; +} + +/// Start a remote-execution server for `device`'s backend, blocking the current thread. +/// +/// `device` selects the backend; `channel` selects the transport. The server hosts that backend's +/// devices, indexed by hardware device index. Use [`start_async`] for the async counterpart. +#[cfg(not(target_family = "wasm"))] +pub fn start(device: DispatchDevice, channel: Channel) { + with_backend!(device, |B, devices| { + burn_remote::server::RemoteServerBuilder::::new(devices) + .channel(channel) + .start() + }) +} + +/// Async counterpart of [`start`]; runs on the caller's tokio runtime instead of blocking. +#[cfg(not(target_family = "wasm"))] +pub async fn start_async(device: DispatchDevice, channel: Channel) { + with_backend!(device, |B, devices| { + burn_remote::server::RemoteServerBuilder::::new(devices) + .channel(channel) + .start_async() + .await + }) +} + +/// Build a backend-erased protocol handler for `device`'s backend. +/// +/// Resolves the dispatch device to its concrete backend type and returns a [`RemoteProtocol`] +/// the caller registers on its own Iroh router under [`BURN_REMOTE_ALPN`]. +pub fn remote_protocol( + device: DispatchDevice, + endpoint: &Endpoint, + probe: TelemetryProbe, + authorizer: Arc, +) -> RemoteProtocol { + with_backend!(device, |B, devices| { + RemoteProtocol::new(IrohRemoteProtocol::::new( + endpoint.clone(), + devices, + authorizer, + probe, + CustomOpRegistry::default(), + )) + }) +} diff --git a/crates/burn-dispatch/src/tensor.rs b/crates/burn-dispatch/src/tensor.rs new file mode 100644 index 0000000..ed02727 --- /dev/null +++ b/crates/burn-dispatch/src/tensor.rs @@ -0,0 +1,570 @@ +use crate::{DispatchDevice, backends::*}; + +#[cfg(feature = "autodiff")] +use burn_autodiff::checkpoint::strategy::{ + BalancedCheckpointing, CheckpointStrategy, NoCheckpointing, +}; +use burn_backend::{Backend, BackendTypes, DType, Shape, TensorMetadata}; + +use crate::CheckpointingStrategy; +#[cfg(feature = "autodiff")] +use alloc::boxed::Box; +#[cfg(feature = "autodiff")] +use burn_backend::tensor::FloatTensor; + +use alloc::{format, string::String}; + +// TODO: if we reduce the different associated types for float/int/bool/quantized tensor primitives down to a single +// `B::TensorPrimitive` we can simplify this. + +/// Tensor which points to a backend tensor primitive kind. +#[derive(Clone, Debug)] +pub enum BackendTensor { + /// Float tensor handle. + Float(B::FloatTensorPrimitive), + /// Int tensor handle. + Int(B::IntTensorPrimitive), + /// Bool tensor handle. + Bool(B::BoolTensorPrimitive), + /// Quantized tensor handle. + Quantized(B::QuantizedTensorPrimitive), + #[cfg(feature = "autodiff")] + /// Autodiff float tensor handle. + Autodiff(FloatTensor>), +} + +impl BackendTensor { + /// Returns the inner float tensor primitive. + pub fn float(self) -> B::FloatTensorPrimitive { + match self { + BackendTensor::Float(tensor) => tensor, + BackendTensor::Int(_) => panic!("Should be float, got int"), + BackendTensor::Bool(_) => panic!("Should be float, got bool"), + BackendTensor::Quantized(_) => panic!("Should be float, got quantized"), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(_) => panic!("Should be float, got autodiff"), + } + } + /// Returns the inner float tensor primitive. + pub fn as_float(&self) -> &B::FloatTensorPrimitive { + match self { + BackendTensor::Float(tensor) => tensor, + BackendTensor::Int(_) => panic!("Should be float, got int"), + BackendTensor::Bool(_) => panic!("Should be float, got bool"), + BackendTensor::Quantized(_) => panic!("Should be float, got quantized"), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(_) => panic!("Should be float, got autodiff"), + } + } + + /// Returns the inner int tensor primitive. + pub fn int(self) -> B::IntTensorPrimitive { + match self { + BackendTensor::Int(tensor) => tensor, + BackendTensor::Float(_) => panic!("Should be int, got float"), + BackendTensor::Bool(_) => panic!("Should be int, got bool"), + BackendTensor::Quantized(_) => panic!("Should be int, got quantized"), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(_) => panic!("Should be int, got autodiff"), + } + } + + /// Returns the inner bool tensor primitive. + pub fn bool(self) -> B::BoolTensorPrimitive { + match self { + BackendTensor::Bool(tensor) => tensor, + BackendTensor::Float(_) => panic!("Should be bool, got float"), + BackendTensor::Int(_) => panic!("Should be bool, got int"), + BackendTensor::Quantized(_) => panic!("Should be bool, got quantized"), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(_) => panic!("Should be bool, got autodiff"), + } + } + + /// Returns the inner quantized tensor primitive. + pub fn quantized(self) -> B::QuantizedTensorPrimitive { + match self { + BackendTensor::Quantized(tensor) => tensor, + _ => unreachable!(), + } + } + + #[cfg(feature = "autodiff")] + /// Returns the inner autodiff tensor primitive. + pub fn autodiff(self) -> FloatTensor> { + match self { + BackendTensor::Autodiff(tensor) => tensor, + // NOTE: this is the panicking code reached in tensor.rs:74:18: + _ => unreachable!(), + } + } + + #[cfg(feature = "autodiff")] + /// Returns the inner autodiff tensor primitive. + pub fn as_autodiff(&self) -> &FloatTensor> { + match self { + BackendTensor::Autodiff(tensor) => tensor, + _ => unreachable!(), + } + } + + #[cfg(feature = "autodiff")] + /// Returns the inner autodiff tensor primitive. + pub fn autodiff_inner(self) -> B::FloatTensorPrimitive { + match self { + BackendTensor::Autodiff(tensor) => tensor.primitive, + _ => unreachable!(), + } + } + + /// Returns the tensor primitive kind name. + pub fn name(&self) -> &'static str { + match self { + BackendTensor::Float(_) => "Float", + BackendTensor::Int(_) => "Int", + BackendTensor::Bool(_) => "Bool", + BackendTensor::Quantized(_) => "Quantized", + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(_) => "Autodiff", + } + } +} + +impl TensorMetadata for BackendTensor { + type Device = B::Device; + fn device(&self) -> Self::Device { + match self { + BackendTensor::Float(tensor) => tensor.device(), + BackendTensor::Int(tensor) => tensor.device(), + BackendTensor::Bool(tensor) => tensor.device(), + BackendTensor::Quantized(tensor) => tensor.device(), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(tensor) => tensor.device(), + } + } + fn dtype(&self) -> DType { + match self { + BackendTensor::Float(tensor) => tensor.dtype(), + BackendTensor::Int(tensor) => tensor.dtype(), + BackendTensor::Bool(tensor) => tensor.dtype(), + BackendTensor::Quantized(tensor) => tensor.dtype(), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(tensor) => tensor.dtype(), + } + } + + fn shape(&self) -> Shape { + match self { + BackendTensor::Float(tensor) => tensor.shape(), + BackendTensor::Int(tensor) => tensor.shape(), + BackendTensor::Bool(tensor) => tensor.shape(), + BackendTensor::Quantized(tensor) => tensor.shape(), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(tensor) => tensor.shape(), + } + } + + fn can_mut(&self) -> bool { + match self { + BackendTensor::Float(tensor) => tensor.can_mut(), + BackendTensor::Int(tensor) => tensor.can_mut(), + BackendTensor::Bool(tensor) => tensor.can_mut(), + BackendTensor::Quantized(tensor) => tensor.can_mut(), + #[cfg(feature = "autodiff")] + BackendTensor::Autodiff(tensor) => tensor.can_mut(), + } + } +} + +/// A tensor that can dispatch operations to any enabled backend at runtime. +/// +/// When the `autodiff` feature is enabled, tensors may carry a checkpointing +/// strategy used to control gradient computation. This is derived from the +/// device used to create the tensor. +#[derive(Clone, Debug)] +pub struct DispatchTensor { + /// Tensor kind primitive. + pub kind: DispatchTensorKind, + // Technically more of a device property, but device is not a dispatch tensor field. + // Right now this is the easiest way to preserve the checkpointing strategy because primitives are not consolidated. + // Once float/int/bool primitives are consolidated into a single associative type, we could hold that + // property for all autodiff tensors. + /// Holds the autodiff checkpointing strategy. + /// - `None`: tensor is not tracked by autodiff + /// - `Some(strategy)`: tensor is tracked by autodiff, and uses the checkpointing `strategy` + pub checkpointing: Option, +} + +/// Internal representation of a [`DispatchTensor`]. +/// +/// This enum contains the concrete backend tensor for each enabled backend. +/// It is not intended to be used directly; instead, it is manipulated by +/// the dispatch system to route operations to the correct backend. +/// +/// Each variant corresponds to a specific backend implementation. +#[derive(Clone, Debug)] +pub enum DispatchTensorKind { + /// The [CPU backend](Cpu) tensor. + #[cfg(feature = "cpu")] + Cpu(BackendTensor), + + /// The [CUDA backend](Cuda) tensor. + #[cfg(feature = "cuda")] + Cuda(BackendTensor), + + /// The [Metal backend](Metal) tensor. + #[cfg(feature = "metal")] + Metal(BackendTensor), + + /// The [ROCm backend](Rocm) tensor. + #[cfg(feature = "rocm")] + Rocm(BackendTensor), + + /// The [Vulkan backend](Vulkan) tensor. + #[cfg(feature = "vulkan")] + Vulkan(BackendTensor), + + /// The [Wgpu backend](Wgpu) tensor. + #[cfg(feature = "wgpu")] + Wgpu(BackendTensor), + + /// The [WebGPU backend](Wgpu) tensor. + #[cfg(feature = "webgpu")] + WebGpu(BackendTensor), + + /// The [Flex backend](Flex) tensor. + #[cfg(any(feature = "flex", default_backend))] + Flex(BackendTensor), + + /// The [NdArray backend](NdArray) tensor. + #[cfg(feature = "ndarray")] + NdArray(BackendTensor), + + /// The [LibTorch backend](LibTorch) tensor. + #[cfg(feature = "tch")] + LibTorch(BackendTensor), + + /// The [Remote backend](Remote) tensor (lives on a remote server). + #[cfg(feature = "remote")] + Remote(BackendTensor), + + /// The [autodiff enabled backend](Autodiff) tensor. + #[cfg(feature = "autodiff")] + Autodiff(Box), +} + +impl TensorMetadata for DispatchTensorKind { + type Device = DispatchDevice; + + fn dtype(&self) -> DType { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(tensor) => tensor.dtype(), + #[cfg(feature = "cuda")] + Self::Cuda(tensor) => tensor.dtype(), + #[cfg(feature = "metal")] + Self::Metal(tensor) => tensor.dtype(), + #[cfg(feature = "rocm")] + Self::Rocm(tensor) => tensor.dtype(), + #[cfg(feature = "vulkan")] + Self::Vulkan(tensor) => tensor.dtype(), + #[cfg(feature = "wgpu")] + Self::Wgpu(tensor) => tensor.dtype(), + #[cfg(feature = "webgpu")] + Self::WebGpu(tensor) => tensor.dtype(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(tensor) => tensor.dtype(), + #[cfg(feature = "ndarray")] + Self::NdArray(tensor) => tensor.dtype(), + #[cfg(feature = "tch")] + Self::LibTorch(tensor) => tensor.dtype(), + #[cfg(feature = "remote")] + Self::Remote(tensor) => tensor.dtype(), + #[cfg(feature = "autodiff")] + Self::Autodiff(tensor) => tensor.dtype(), + } + } + + fn shape(&self) -> Shape { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(tensor) => tensor.shape(), + #[cfg(feature = "cuda")] + Self::Cuda(tensor) => tensor.shape(), + #[cfg(feature = "metal")] + Self::Metal(tensor) => tensor.shape(), + #[cfg(feature = "rocm")] + Self::Rocm(tensor) => tensor.shape(), + #[cfg(feature = "vulkan")] + Self::Vulkan(tensor) => tensor.shape(), + #[cfg(feature = "wgpu")] + Self::Wgpu(tensor) => tensor.shape(), + #[cfg(feature = "webgpu")] + Self::WebGpu(tensor) => tensor.shape(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(tensor) => tensor.shape(), + #[cfg(feature = "ndarray")] + Self::NdArray(tensor) => tensor.shape(), + #[cfg(feature = "tch")] + Self::LibTorch(tensor) => tensor.shape(), + #[cfg(feature = "remote")] + Self::Remote(tensor) => tensor.shape(), + #[cfg(feature = "autodiff")] + Self::Autodiff(tensor) => tensor.shape(), + } + } + + fn device(&self) -> DispatchDevice { + match self { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(tensor) => DispatchDevice::Cpu(tensor.device()), + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(tensor) => DispatchDevice::Cuda(tensor.device()), + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(tensor) => DispatchDevice::Metal(tensor.device()), + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(tensor) => DispatchDevice::Rocm(tensor.device()), + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(tensor) => DispatchDevice::Vulkan(tensor.device()), + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(tensor) => DispatchDevice::Wgpu(tensor.device()), + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(tensor) => DispatchDevice::WebGpu(tensor.device()), + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(tensor) => DispatchDevice::Flex(tensor.device()), + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(tensor) => DispatchDevice::NdArray(tensor.device()), + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(tensor) => DispatchDevice::LibTorch(tensor.device()), + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(tensor) => DispatchDevice::Remote(tensor.device()), + #[cfg(feature = "autodiff")] + DispatchTensorKind::Autodiff(tensor) => DispatchDevice::autodiff(tensor.device()), + } + } + + fn can_mut(&self) -> bool { + match self { + #[cfg(feature = "cpu")] + Self::Cpu(tensor) => tensor.can_mut(), + #[cfg(feature = "cuda")] + Self::Cuda(tensor) => tensor.can_mut(), + #[cfg(feature = "metal")] + Self::Metal(tensor) => tensor.can_mut(), + #[cfg(feature = "rocm")] + Self::Rocm(tensor) => tensor.can_mut(), + #[cfg(feature = "vulkan")] + Self::Vulkan(tensor) => tensor.can_mut(), + #[cfg(feature = "wgpu")] + Self::Wgpu(tensor) => tensor.can_mut(), + #[cfg(feature = "webgpu")] + Self::WebGpu(tensor) => tensor.can_mut(), + #[cfg(any(feature = "flex", default_backend))] + Self::Flex(tensor) => tensor.can_mut(), + #[cfg(feature = "ndarray")] + Self::NdArray(tensor) => tensor.can_mut(), + #[cfg(feature = "tch")] + Self::LibTorch(tensor) => tensor.can_mut(), + #[cfg(feature = "remote")] + Self::Remote(tensor) => tensor.can_mut(), + #[cfg(feature = "autodiff")] + Self::Autodiff(tensor) => tensor.can_mut(), + } + } +} + +impl TensorMetadata for DispatchTensor { + fn dtype(&self) -> DType { + self.kind.dtype() + } + + fn shape(&self) -> Shape { + self.kind.shape() + } + + fn can_mut(&self) -> bool { + self.kind.can_mut() + } + + type Device = DispatchDevice; + + fn device(&self) -> Self::Device { + #[allow(unused_mut)] + let mut device = self.kind.device(); + + // TODO: should int and bool kinds return an autodiff device? + // It would be much easier once there is a single underlying primitive type, which + // we can wrap with Autodiff in all cases. + + #[cfg(feature = "autodiff")] + if let DispatchDevice::Autodiff(device) = &mut device + && let Some(checkpointing) = &self.checkpointing + { + device.checkpointing = *checkpointing; + } + + device + } +} + +impl DispatchTensorKind { + /// Returns the backend tensor kind name. + pub(crate) fn name(&self) -> &'static str { + match self { + #[cfg(feature = "cpu")] + DispatchTensorKind::Cpu(_) => "Cpu", + #[cfg(feature = "cuda")] + DispatchTensorKind::Cuda(_) => "Cuda", + #[cfg(feature = "metal")] + DispatchTensorKind::Metal(_) => "Metal", + #[cfg(feature = "rocm")] + DispatchTensorKind::Rocm(_) => "Rocm", + #[cfg(feature = "vulkan")] + DispatchTensorKind::Vulkan(_) => "Vulkan", + #[cfg(feature = "wgpu")] + DispatchTensorKind::Wgpu(_) => "Wgpu", + #[cfg(feature = "webgpu")] + DispatchTensorKind::WebGpu(_) => "WebGpu", + #[cfg(any(feature = "flex", default_backend))] + DispatchTensorKind::Flex(_) => "Flex", + #[cfg(feature = "ndarray")] + DispatchTensorKind::NdArray(_) => "NdArray", + #[cfg(feature = "tch")] + DispatchTensorKind::LibTorch(_) => "LibTorch", + #[cfg(feature = "remote")] + DispatchTensorKind::Remote(_) => "Remote", + #[cfg(feature = "autodiff")] + DispatchTensorKind::Autodiff(_) => "Autodiff", + } + } +} + +#[cfg(feature = "autodiff")] +trait IntoCheckpointingStrategy { + const STRATEGY: CheckpointingStrategy; +} + +#[cfg(feature = "autodiff")] +impl IntoCheckpointingStrategy for NoCheckpointing { + const STRATEGY: CheckpointingStrategy = CheckpointingStrategy::None; +} + +#[cfg(feature = "autodiff")] +impl IntoCheckpointingStrategy for BalancedCheckpointing { + const STRATEGY: CheckpointingStrategy = CheckpointingStrategy::Balanced; +} + +/// Trait to execute runtime routing conversions between the dynamic dispatch layer and specific backends. +pub trait DispatchKindConversion { + /// Attempts to extract a backend-specific [`BackendTensor`] wrapper from a generic, dynamically-routed [`DispatchTensor`]. + /// + /// # Errors + /// + /// Returns an error if the dynamic routing state does not match the requested backend `B`. + fn try_into_backend(tensor: DispatchTensor) -> Result, String>; + + /// Encapsulates a backend-specific tensor variant back into a globally routing [`DispatchTensor`]. + fn from_backend(tensor: BackendTensor) -> DispatchTensor; +} + +macro_rules! impl_dispatch_conversion { + ($backend:ident, $cfg:meta) => { + #[cfg($cfg)] + impl DispatchKindConversion<$backend> for DispatchTensor { + fn try_into_backend(tensor: DispatchTensor) -> Result, String> { + // The catch-all is unreachable in single-backend builds (the enum then has one + // variant), but required when several backend features are enabled. + #[allow(unreachable_patterns)] + match tensor.kind { + DispatchTensorKind::$backend(t) => Ok(t), + other => Err(format!( + "Expected {} tensor, got variant: {}", + stringify!($backend), + other.name() + )), + } + } + + fn from_backend(tensor: BackendTensor<$backend>) -> DispatchTensor { + DispatchTensor { + kind: DispatchTensorKind::$backend(tensor), + checkpointing: None, + } + } + } + + #[cfg(all($cfg, feature = "autodiff"))] + impl + DispatchKindConversion> for DispatchTensor + { + fn try_into_backend( + tensor: DispatchTensor, + ) -> Result>, String> { + match tensor.kind { + DispatchTensorKind::Autodiff(t) => match *t { + DispatchTensorKind::$backend(t) => match t { + // Encode as `BackendTensor::Float` for `Autodiff` + BackendTensor::Autodiff(t) => Ok(BackendTensor::Float(t)), + other => Err(format!( + "Expected Autodiff {} float tensor, got Autodiff variant: {}", + stringify!($backend), + other.name() + )), + }, + other => Err(format!( + "Expected Autodiff {} tensor, got Autodiff variant: {}", + stringify!($backend), + other.name() + )), + }, + other => Err(format!( + "Expected Autodiff tensor, got backend: {}", + other.name() + )), + } + } + + fn from_backend(tensor: BackendTensor>) -> DispatchTensor { + // Unwrap the Autodiff backend representation back into the inner hardware representation + let kind = match tensor { + // Inverse: Wrap the `Float` variant back into the backend's `Autodiff` primitive variant + BackendTensor::Float(t) => { + let ad_tensor = BackendTensor::Autodiff(t); + // Wrap in the concrete backend's dispatch container + let inner_dispatch = DispatchTensorKind::$backend(ad_tensor); + // Re-apply the outer Autodiff dispatch wrapper + DispatchTensorKind::Autodiff(Box::new(inner_dispatch)) + } + + // Pass-throughs for non-differentiable types + BackendTensor::Int(t) => DispatchTensorKind::$backend(BackendTensor::Int(t)), + BackendTensor::Bool(t) => DispatchTensorKind::$backend(BackendTensor::Bool(t)), + BackendTensor::Quantized(t) => { + DispatchTensorKind::$backend(BackendTensor::Quantized(t)) + } + + BackendTensor::Autodiff(_) => { + panic!("Unexpected Autodiff variant provided to `from_backend`",) + } + }; + + DispatchTensor { + kind, + checkpointing: Some(C::STRATEGY), + } + } + } + }; +} + +impl_dispatch_conversion!(Flex, any(feature = "flex", default_backend)); +impl_dispatch_conversion!(Cpu, feature = "cpu"); +impl_dispatch_conversion!(Cuda, feature = "cuda"); +impl_dispatch_conversion!(Rocm, feature = "rocm"); +impl_dispatch_conversion!(Remote, feature = "remote"); +impl_dispatch_conversion!(Metal, feature = "metal"); +impl_dispatch_conversion!(Vulkan, feature = "vulkan"); +impl_dispatch_conversion!(Wgpu, feature = "wgpu"); +impl_dispatch_conversion!(WebGpu, feature = "webgpu"); +impl_dispatch_conversion!(NdArray, feature = "ndarray"); +impl_dispatch_conversion!(LibTorch, feature = "tch"); diff --git a/crates/burn-flex/ACKNOWLEDGMENTS.md b/crates/burn-flex/ACKNOWLEDGMENTS.md new file mode 100644 index 0000000..1d0915d --- /dev/null +++ b/crates/burn-flex/ACKNOWLEDGMENTS.md @@ -0,0 +1,30 @@ +# Acknowledgments + +burn-flex draws on ideas and techniques from several open-source projects. + +## ndarray + +[ndarray](https://github.com/rust-ndarray/ndarray) - N-dimensional array library for Rust. + +- **8-fold unrolled reduction loop**: Our `sum_f32` implementation in `simd/kernels.rs` uses + ndarray's `unrolled_fold` pattern, where eight independent accumulators allow LLVM to emit optimal + SIMD code without explicit intrinsic dispatch. + +## Candle + +[Candle](https://github.com/huggingface/candle) - Minimalist ML framework by Hugging Face. + +- **Tiled im2col for convolutions**: Our convolution implementation uses a tiled im2col approach + (TILE_SIZE=512) inspired by Candle, processing output in fixed-size tiles for better L2 cache + utilization and enabling tile-level parallelism. + +## gemm / macerator + +[gemm](https://github.com/sarah-ek/gemm) and [macerator](https://github.com/wingertge/macerator), +part of the [faer](https://github.com/sarah-ek/faer-rs) ecosystem. + +- **gemm**: Powers all matrix multiplication and convolution GEMM calls (matmul, conv im2col, + deformable conv). Provides strided memory access so we can multiply transposed tensors without + copying, and native f16 support. +- **macerator**: Provides portable SIMD dispatch for scatter-add reductions across NEON, AVX2, and + WASM SIMD128 with a scalar fallback. diff --git a/crates/burn-flex/ARCHITECTURE.md b/crates/burn-flex/ARCHITECTURE.md new file mode 100644 index 0000000..85434d3 --- /dev/null +++ b/crates/burn-flex/ARCHITECTURE.md @@ -0,0 +1,788 @@ +# burn-flex Architecture + +A pure-Rust CPU backend for [Burn](https://github.com/tracel-ai/burn). + +## Goals + +From README: + +- Fast, memory-efficient CPU backend +- Multi-threading, SIMD, optimized matrix multiplication +- Runs on std, no_std, and WebAssembly +- Supports f16/bf16 +- Zero-copy data loading +- Thread-safe by design (Arc-based COW) + +## Robustness + +burn-flex is tested for edge-case robustness to ensure safe behavior on embedded devices and in +production. This includes: + +- **Integer overflow safety**: `wrapping_abs`, `wrapping_neg`, `wrapping_shl/shr` for signed + integers at type boundaries (e.g. `i64::MIN`), matching PyTorch two's complement semantics +- **Rounding correctness**: Uses `num_traits::Float::round` with a ties-to-even correction, + correct for the full float range (values beyond integer precision have no fractional bits) +- **Input validation**: Hard assertions for invalid pooling parameters (zero kernel/stride) and + zero-sized reduce dimensions, preventing undefined behavior on malformed inputs +- **Negative index detection**: Debug assertions on gather/scatter index conversions +- **Index dtype correctness**: Index-producing ops (argmax, argmin, argsort, argwhere, + sort_with_indices) must respect `out_dtype`/`indices_dtype` parameters. Internally use + `isize` + `INDEX_DTYPE` for platform portability, then cast to the requested dtype via + `int_cast` if needed. Never hardcode `i64` for index outputs as it breaks on 32-bit targets. + +## Target Platform + +**Primary: Apple Silicon M3 (ARM64 + NEON)** + +- 128-bit SIMD registers (4x f32, 8x f16) +- Unified memory architecture +- Native f16 support in hardware + +**Secondary: x86_64 with AVX2/AVX-512** (via conditional compilation) + +--- + +## Design Principles + +1. **Leverage Burn** - Use `burn-backend` types and `burn-std` utilities wherever possible +2. **Portability first** - No platform-specific dependencies; std, no_std, WASM +3. **Zero C dependencies** - Pure Rust only (gemm crate for matrix multiplication) +4. **Simple and direct** - Eager execution, no lazy graphs, no fusion (use `burn-fusion` if needed) +5. **Memory reuse** - Minimize allocations through in-place ops and buffer reuse + +--- + +## Feature Flags + +```toml +default = ["std", "simd", "rayon"] +``` + +| Feature | Default | Description | +| ----------- | ------- | --------------------------------------------------------------------------- | +| `std` | Yes | Standard library support | +| `simd` | Yes | Portable SIMD via macerator (enables `macerator`, `aligned-vec`) | +| `rayon` | Yes | Parallel execution for large tensors (forwards `gemm/rayon`) | +| `x86-v4` | No | AVX-512 kernels in gemm for x86_64 (Sapphire Rapids, Zen 4/5, etc.) | +| `apple-amx` | No | Apple Silicon AMX matrix coprocessor in gemm (experimental upstream) | + +The `simd` feature also forwards `gemm/wasm-simd128-enable`, a no-op outside WASM. + +`gemm` is an always-on required dependency (not behind a feature flag). + +### Performance impact on Apple M3 Max (median speedup vs serial baseline) + +Measured via `cargo bench -p burn-flex --bench {matmul,attention,conv_ops}` with features +`std,simd` (serial), `std,simd,rayon` (default), and `std,simd,rayon,apple-amx`. + +| Workload | rayon vs serial | +apple-amx vs rayon | combined | +| --------------------------------------- | --------------- | ------------------- | -------- | +| matmul 1024×1024 f32 | 7.0x | 1.7x | **12.2x** | +| matmul 512×512 f32 | 3.8x | 1.5x | 5.8x | +| attention self b1·h32·s256·d128 | 1.0x | 2.0x | 2.0x | +| attention self b1·h12·s512·d64 | 1.0x | 1.6x | 1.6x | +| conv2d first_layer 4×3×224×224 k7×7 s2 | 9.8x | 1.2x | **11.6x** | +| conv2d large 16×128×64×64 k3×3 | 7.7x | 1.5x | 11.1x | +| conv2d k7×7 | 6.5x | 1.4x | 9.2x | + +Notes: +- Attention ops currently see no rayon uplift; the per-head matmul pipeline does not + propagate `Parallelism::Rayon` to gemm. AMX still delivers a standalone speedup. +- Small shapes (e.g. `batch8_64x64` matmul, `depthwise_k3_8x32x512` conv1d) can regress + under rayon due to thread-spawn overhead; a size-based gating in the matmul/conv + paths would recover those without losing the large-shape wins. +- AMX regresses on transposed operands (`both/rhs_transposed_256x256` matmul drop to + ~0.55x vs rayon). Avoid `apple-amx` for workloads dominated by transposed GEMM. + +--- + +## Memory Strategy + +Minimize allocations wherever possible: + +### In-Place Operations + +When tensor is contiguous at offset 0, mutate in place: + +```rust +fn neg_inplace(mut tensor: FlexTensor) -> FlexTensor { + if let Some((0, end)) = tensor.layout().contiguous_offsets() { + let slice: &mut [f32] = tensor.storage_mut(); + for x in slice[..end].iter_mut() { + *x = -*x; + } + tensor + } else { + // Allocate new buffer for non-contiguous + neg_copy(&tensor) + } +} +``` + +### Output Buffer Reuse + +For binary ops, reuse lhs buffer when contiguous at offset 0: + +```rust +fn add(mut lhs: FlexTensor, rhs: &FlexTensor) -> FlexTensor { + if let Some((0, l_end)) = lhs.layout().contiguous_offsets() { + if let Some((r_start, r_end)) = rhs.layout().contiguous_offsets() { + let lhs_storage: &mut [f32] = lhs.storage_mut(); + let rhs_storage: &[f32] = rhs.storage(); + for (l, &r) in lhs_storage[..l_end].iter_mut().zip(&rhs_storage[r_start..r_end]) { + *l = *l + r; + } + return lhs; + } + } + add_alloc(&lhs, rhs) +} +``` + +### When to Allocate + +Only allocate when necessary: + +- Shape changes (broadcast, concat, reshape of non-contiguous) +- Non-contiguous input that must become contiguous +- Views/slices with non-zero offset + +### Arc-based Copy-on-Write + +Tensor storage is wrapped in `Arc` for O(1) cloning and thread-safe COW: + +```rust +pub struct FlexTensor { + data: Arc, // O(1) clone via refcount increment + layout: Layout, + dtype: DType, +} + +impl FlexTensor { + /// Check if this tensor uniquely owns its data + pub fn is_unique(&self) -> bool { + Arc::strong_count(&self.data) == 1 + } + + /// Get mutable access, cloning data if shared (COW) + pub fn make_data_mut(&mut self) -> &mut Bytes { + Arc::make_mut(&mut self.data) + } +} +``` + +Benefits: + +- **O(1) cloning**: `Arc::clone` is just a refcount increment +- **Thread-safe sharing**: `Arc` is `Send + Sync` +- **COW semantics**: `Arc::make_mut` clones only when shared +- **Smart in-place ops**: `is_unique()` enables mutation without allocation + +This enables the optimization pattern used throughout: + +```rust +fn add_inplace(mut lhs: FlexTensor, rhs: &FlexTensor) -> FlexTensor { + if lhs.is_unique() && lhs.is_contiguous_at_offset_zero() { + // Mutate in place - no allocation needed + let storage = lhs.make_data_mut(); + // ... perform addition ... + lhs + } else { + // Allocate new buffer + add_alloc(&lhs, rhs) + } +} +``` + +Performance impact (vs previous non-Arc implementation): + +- Binary ops: **2.6-4.2x faster** than NdArray (was 1.4-1.8x) +- Scalar ops: **2.6x faster** (was 1.8x) +- Memory: 3x less allocation for binary ops (4.2 MB vs 12.6 MB for 1M elements) + +--- + +## Burn Infrastructure We Use + +From `burn-backend`: + +- `Shape` - tensor dimensions +- `TensorData` - serialized tensor format +- `DType` - runtime dtype enum +- `Element` trait - compile-time element types +- `Backend` trait - the interface we implement +- `*TensorOps` traits - operation interfaces + +From `burn-std`: + +- `Bytes` - aligned byte storage with COW semantics (our tensor backing store) +- `is_contiguous()` - stride validation +- Platform abstractions for no_std + +--- + +## Core Types + +### Layout + +Metadata for interpreting storage as an N-dimensional tensor: + +```rust +use burn_backend::Shape; + +pub struct Layout { + shape: Shape, + strides: Vec, // Signed strides for zero-copy flip + start_offset: usize, +} +``` + +**Signed Strides** + +Strides are `isize` (signed) to enable zero-copy flip operations. A negative stride means we iterate +backward through that dimension: + +```rust +// Original tensor [1, 2, 3, 4] with shape [4], stride [1], offset 0 +// Flipped tensor uses: +// - offset: 3 (point to last element) +// - stride: -1 (move backward) +// Iteration: indices 3, 2, 1, 0 -> values 4, 3, 2, 1 +``` + +Many operations are zero-copy (metadata changes only): + +- `transpose()` - swap strides +- `narrow()` - adjust offset +- `reshape()` - recompute strides if contiguous +- `broadcast()` - set stride to 0 +- `flip()` - negate stride, adjust offset +- `permute()` - reorder strides + +**Zero-Copy Flip** + +With signed strides, `flip(tensor, axes)` is O(1): + +```rust +pub fn flip(&self, axes: &[usize]) -> Self { + let mut new_strides = self.strides.clone(); + let mut offset_adjustment: isize = 0; + + for &axis in axes { + let dim_size = self.shape.dims[axis]; + if dim_size > 1 { + // Move start to the last element in this dimension + offset_adjustment += (dim_size as isize - 1) * self.strides[axis]; + // Negate stride to iterate backward + new_strides[axis] = -new_strides[axis]; + } + } + + let new_start = (self.start_offset as isize + offset_adjustment) as usize; + Self { shape: self.shape.clone(), strides: new_strides, start_offset: new_start } +} +``` + +This avoids the O(n) element-by-element copy that would be required with unsigned strides. + +### Tensor + +Uses `Arc` for O(1) cloning with COW semantics: + +```rust +use std::sync::Arc; +use burn_std::Bytes; +use burn_backend::DType; + +pub struct FlexTensor { + data: Arc, // O(1) clone, COW via Arc::make_mut + layout: Layout, + dtype: DType, +} + +impl FlexTensor { + /// Zero-copy typed view of full storage (for use with StridedIter) + pub fn storage(&self) -> &[E] { + bytemuck::cast_slice(&self.data) + } + + /// Mutable typed view for in-place operations + pub fn storage_mut(&mut self) -> &mut [E] { + bytemuck::cast_slice_mut(&mut self.data) + } +} +``` + +Operations dispatch on `dtype` and cast once at the boundary: + +```rust +fn add(a: &FlexTensor, b: &FlexTensor) -> FlexTensor { + match a.dtype { + DType::F32 => add_impl(a.as_slice::(), b.as_slice::()), + DType::F16 => add_impl(a.as_slice::(), b.as_slice::()), + // ... + } +} +``` + +--- + +## Backend Implementation + +```rust +use burn_backend::{Backend, DType}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Flex; + +impl Backend for Flex { + type Device = FlexDevice; + type FloatTensorPrimitive = FlexTensor; + type IntTensorPrimitive = FlexTensor; + type BoolTensorPrimitive = FlexTensor; + type QuantizedTensorPrimitive = FlexQTensor; + + fn name() -> String { "flex".into() } + + fn float_supported_dtypes() -> Vec { + vec![DType::F64, DType::F32, DType::F16, DType::BF16] + } + + fn int_supported_dtypes() -> Vec { + vec![DType::I64, DType::I32, DType::I16, DType::I8, + DType::U64, DType::U32, DType::U16, DType::U8] + } +} +``` + +--- + +## FusionBackend + +burn-flex does not implement `FusionBackend`. Without JIT compilation, fusion adds tracking overhead +with no performance benefit. Deferred operations would still execute one-by-one with intermediate +allocations. For CPU with fusion, use `burn-cpu` (which has cubecl's MLIR-based JIT runtime). + +--- + +## Execution Strategy + +### Contiguous Fast Path + +Most tensors are contiguous. Detect and use direct slice operations: + +```rust +fn unary_op(storage: &[T], layout: &Layout, f: F) -> Vec +where + T: Copy, + F: Fn(T) -> T, +{ + if let Some((start, end)) = layout.contiguous_offsets() { + storage[start..end].iter().map(|&x| f(x)).collect() + } else { + StridedIter::new(layout).map(|i| f(storage[i])).collect() + } +} +``` + +### SIMD Kernels + +Portable SIMD via macerator, with automatic dispatch per architecture (NEON, AVX2, SSE, WASM +SIMD128) and a scalar fallback module for unsupported platforms: + +```rust +use macerator::{Simd, with_simd, vload_unaligned, vstore_unaligned}; + +#[with_simd] +fn my_kernel(src: &[f32], dst: &mut [f32]) { + let lanes = f32::lanes::(); + // load/store vectors, use operator overloading for arithmetic +} + +// Dispatch: detects CPU features at runtime +my_kernel(src, dst); +``` + +The `simd/` module is organized as: + +- `portable.rs`: macerator-based binary, comparison, and boolean ops (auto-dispatches to + NEON/AVX2/SSE/SIMD128/scalar) +- `kernels.rs`: macerator-based reduction kernels (sum, scatter-add) +- `scalar.rs`: fallback for builds without the `simd` feature (bool ops only) +- `aligned.rs`: SIMD-aligned memory allocation + +### Parallel Execution + +Via rayon for large tensors: + +```rust +use rayon::prelude::*; + +fn parallel_unary(src: &[T], f: F) -> Vec +where + T: Copy + Send + Sync, + F: Fn(T) -> T + Send + Sync, +{ + src.par_iter().map(|&x| f(x)).collect() +} +``` + +### Linear Algebra + +gemm crate for matrix multiplication with rayon parallelism: + +```rust +use gemm::{gemm, Parallelism}; + +pub fn matmul_f32(lhs: &[f32], rhs: &[f32], out: &mut [f32], m: usize, n: usize, k: usize) { + let parallelism = if m * n * k >= 192 * 192 * 192 { + Parallelism::Rayon(0) // Use all available threads + } else { + Parallelism::None + }; + + unsafe { + gemm( + m, n, k, + out.as_mut_ptr(), n as isize, 1, + 1.0, // alpha + lhs.as_ptr(), k as isize, 1, + rhs.as_ptr(), n as isize, 1, + 0.0, // beta + parallelism, + ); + } +} +``` + +Performance: 1.3-3.4x faster than NdArray (which uses matrixmultiply crate). + +### Convolutions (im2col + gemm) + +All convolutions use the im2col transformation followed by matrix multiplication. This approach: + +- Converts convolution to a well-optimized GEMM operation +- Leverages the same gemm crate used for matmul +- Supports arbitrary strides, padding, dilation, and groups + +**Unified 3D Implementation** + +Rather than three separate implementations, conv1d and conv2d delegate to conv3d: + +``` +conv1d([B, C, W], kernel=[K_out, C_in, W_k]) + → expand dims → conv3d([B, C, 1, 1, W], kernel=[K_out, C_in, 1, 1, W_k]) + → squeeze → [B, K_out, W_out] + +conv2d([B, C, H, W], kernel=[K_out, C_in, H_k, W_k]) + → expand dims → conv3d([B, C, 1, H, W], kernel=[K_out, C_in, 1, H_k, W_k]) + → squeeze → [B, K_out, H_out, W_out] +``` + +Size-1 dimensions have negligible overhead since the gemm operation dominates runtime. + +**im2col Transformation** + +Rearranges input patches into columns for matrix multiplication: + +``` +Input: [B, C_in, D, H, W] +Kernel: [C_out, C_in/groups, K_d, K_h, K_w] + +im2col produces: [spatial_out, C_in/groups * K_d * K_h * K_w] + where spatial_out = D_out * H_out * W_out + +GEMM: W[C_out/groups, col_len] × col[col_len, spatial_out] + → output[C_out/groups, spatial_out] +``` + +**Dtype Support** + +| Dtype | Implementation | +| ----- | ------------------------------------- | +| f32 | Native gemm | +| f64 | Native gemm | +| f16 | Native gemm (since gemm v0.15) | +| bf16 | Convert to f32, compute, convert back | + +bf16 requires conversion because gemm doesn't have native bf16 support. + +**Current Optimizations** + +- **Rayon parallelism**: Batches and groups are parallelized via rayon +- **Tiled im2col**: Column buffer is tiled for better cache locality + +**Remaining Optimization Opportunities** + +1. **Direct convolution**: For small kernels (3x3), direct convolution without im2col can be faster + due to less memory movement + +### Pooling (Unified 3D) + +All pooling operations use the same unified 3D pattern as convolutions: + +``` +pool1d([B, C, W]) + → expand dims → pool3d([B, C, 1, 1, W]) + → squeeze → [B, C, W_out] + +pool2d([B, C, H, W]) + → expand dims → pool3d([B, C, 1, H, W]) + → squeeze → [B, C, H_out, W_out] +``` + +**Supported Operations** + +| Operation | Forward | Backward | +| ----------------- | ------- | ----------------- | +| max_pool | Yes | Yes (via indices) | +| avg_pool | Yes | Yes | +| adaptive_avg_pool | Yes | Yes | + +**Dtype Support** + +| Dtype | Implementation | +| ----- | ------------------------------------- | +| f32 | Native | +| f64 | Native | +| f16 | Native | +| bf16 | Convert to f32, compute, convert back | + +**Parallelization** + +Pooling uses rayon to parallelize over (batch, channel) pairs: + +```rust +(0..batch_size).into_par_iter().for_each(|b| { + (0..channels).into_par_iter().for_each(|c| { + // Process spatial dimensions for this (b, c) slice + }); +}); +``` + +Each (b, c) slice is independent with good cache locality. + +**Max Pool Indices** + +Max pool stores flat indices into input spatial dimensions (as i64): + +- Used by backward pass to route gradients to correct input positions +- Matches Burn's IntElem type for compatibility + +### Conv Transpose (Unified 3D) + +Transposed convolutions (deconvolutions) for upsampling. Uses the same unified 3D pattern: + +``` +conv_transpose1d([B, C_in, W]) + → expand dims → conv_transpose3d([B, C_in, 1, 1, W]) + → squeeze → [B, C_out, W_out] + +conv_transpose2d([B, C_in, H, W]) + → expand dims → conv_transpose3d([B, C_in, 1, H, W]) + → squeeze → [B, C_out, H_out, W_out] +``` + +**Algorithm** + +Unlike regular convolution (which gathers input into output), transposed convolution scatters: + +```rust +for each input position (id, ih, iw): + for each kernel position (kd, kh, kw): + od = id * stride_d + kd * dilation_d - padding_d + oh = ih * stride_h + kh * dilation_h - padding_h + ow = iw * stride_w + kw * dilation_w - padding_w + if (od, oh, ow) in bounds: + output[od, oh, ow] += input[id, ih, iw] * weight[kd, kh, kw] +``` + +**Weight Shape** + +Conv transpose weight shape is opposite of regular conv: + +- Regular conv: `[out_channels, in_channels_per_group, kd, kh, kw]` +- Transpose conv: `[in_channels, out_channels_per_group, kd, kh, kw]` + +**Output Size Formula** + +``` +output_size = (input - 1) * stride + dilation * (kernel - 1) + 1 + padding_out - 2 * padding +``` + +**Parallelization** + +Uses rayon over (batch, output_channel) pairs. For f32, uses atomic adds for thread-safe +accumulation: + +```rust +(0..batch_size * out_channels).into_par_iter().for_each(|k| { + // Scatter input values to output using atomic f32 adds +}); +``` + +**Dtype Support** + +| Dtype | Implementation | +| ----- | -------------------------------------- | +| f32 | Native with atomic adds | +| f64 | Native (sequential per output channel) | +| f16 | Native (sequential) | +| bf16 | Convert to f32, compute, convert back | + +### Attention (Scaled Dot-Product) + +Computes `softmax(Q @ K^T * scale + bias) @ V` with fused scale, softcap, masking (bool + causal), +and additive bias. Auto-selects between two strategies: + +**Naive attention** (seq_q * seq_kv <= 256K): Materializes the full [seq_q, seq_kv] score matrix. Per (batch, +head), issues two gemm calls: one for `Q @ K^T` and one for `softmax(scores) @ V`. The softmax loop +applies scale/softcap/mask/bias and normalizes in two passes (find-max, then exp-and-sum). NaN-safe: +fully-masked rows produce zero output, not NaN. + +**Flash attention** (seq_q * seq_kv > 256K): Tiles over the KV dimension in chunks of TILE_KV (64 on +native, 32 on WASM). Each tile does a small score gemm, online softmax update (running max/sum with +correction factor to rescale previous tiles), and a value accumulation gemm. Memory is +`O(seq_q * TILE_KV)` per head instead of `O(seq_q * seq_kv)`. + +**Why two strategies**: Benchmarks show naive is 5-10% faster for typical transformer shapes +(seq <= 512) because two large gemm calls amortize kernel dispatch overhead better than many small +tiled ones. Flash wins when the score matrix exceeds L2 cache. The threshold is `NAIVE_SCORE_BUDGET` +(256K elements = 1 MB for f32). + +Both paths share: gemm via `gemm::gemm`, dtype dispatch with f16/bf16 upcast to f32, scratch buffer +reuse across (batch, head) pairs. + +### Unfold (Zero-Copy Strided View) + +Unfold extracts sliding windows from a tensor along a dimension. Unlike most backends that copy +data, Flex implements unfold as a **zero-copy strided view**. + +**Output Shape** + +Given input with shape `[pre..., dim_size, post...]`, unfold along dimension `dim` produces: + +- Output shape: `[pre..., windows, post..., window_size]` +- Windows count: `(dim_size - window_size + step) / step` + +**Algorithm** + +Instead of copying window data, Flex manipulates strides: + +```rust +// Build output strides: +// - Dimension `dim` (now windows): input_stride[dim] * step +// - New window_size dimension (appended): input_stride[dim] +// - All other dimensions: same as input + +output_strides[dim] = input_strides[dim] * step; // Windows stride +output_strides.push(input_strides[dim]); // Within-window stride +``` + +This makes unfold O(1) regardless of tensor size, simply returning a view with new shape/strides. + +**Example** + +``` +Input: [1, 2, 3, 4, 5] shape [5], stride [1] +Unfold dim=0, size=3, step=1 + +Output shape: [3, 3] (3 windows of size 3) +Output strides: [1, 1] (window stride = 1*1, within-window stride = 1) + +Logical view: + Window 0: [1, 2, 3] (offsets 0, 1, 2) + Window 1: [2, 3, 4] (offsets 1, 2, 3) + Window 2: [3, 4, 5] (offsets 2, 3, 4) +``` + +**Performance** + +| Metric | Flex | NdArray | +| --------------- | ---------------------------- | ------------------------------ | +| Time complexity | O(1) | O(output_elements) | +| Memory | 56-136 bytes (metadata only) | Megabytes (copies all windows) | +| Speedup | **1,300-156,000x faster** | - | + +**Non-Contiguous Output** + +The returned tensor is non-contiguous (overlapping windows share storage). Operations that require +contiguous data call `to_contiguous()` internally. Many operations (reduce, matmul, conv) work +directly on strided tensors via `StridedIter`. + +### FFT (Real Forward and Inverse) + +**Location**: `ops/fft.rs` + +Forward (rfft) and inverse (irfft) real FFT via Cooley-Tukey with mixed radix-4/radix-2 DIT. + +**Key optimizations:** + +- **Complex packing**: For rfft, pack N real values as N/2 complex, run a half-size complex FFT, + then unpack using Hermitian symmetry. For irfft, reverse the process: repack spectrum, half-size + inverse FFT, de-interleave. This halves the work compared to a full N-point FFT. +- **Compile-time twiddle tables**: `const fn` Taylor-series sin/cos generates static twiddle factor + tables for N=2 through 65536. Zero runtime allocation for common sizes. Stored as split f32 + arrays for direct SIMD loads. +- **Unrolled small kernels**: Hardcoded butterfly networks for N=2, 4, 8 with compile-time twiddle + values (W_4=-i, W_8=sqrt2/2). Eliminates loop overhead for the small inner FFTs produced by + complex packing. +- **Mixed radix-4/radix-2**: Pairs of radix-2 stages are fused into radix-4 passes, halving the + number of data passes for better cache behavior. Odd-stage-count FFTs do one radix-2 pass first. +- **SIMD butterflies**: `#[macerator::with_simd]` vectorizes radix-4 butterfly passes across + consecutive elements within each stage. +- **Inverse via conjugation**: irfft computes IFFT as `(1/N)*conj(FFT(conj(X)))`, reusing the + forward FFT (with its SIMD path) rather than maintaining a separate inverse kernel. +- **Rayon parallelism**: Batched transforms (multiple independent fibers along the FFT dimension) + are distributed across threads. + +**Dtype support**: f32 (native with SIMD radix-4), f64 (rfft computes in f64 with widened f32 +twiddles; irfft truncates to f32 for computation), f16/bf16 (via f32 upcast/downcast). + +--- + +## Optimization Decisions + +### Implemented + +| Optimization | Benefit | Notes | +| ----------------------------- | ----------------------------------- | -------------------------------------------- | +| **Arc-based COW** | O(1) clone, 2.6-4.2x faster ops | `is_unique()` enables true in-place mutation | +| **Portable SIMD (macerator)** | ~1.5-1.7x for contiguous ops | Auto-dispatches to NEON/AVX2/SSE/SIMD128 | +| **Rayon parallelism** | Scales with cores for large tensors | Threshold: 4M elements (memory-bound ops) | +| **Row-based 2D iteration** | 5.9x faster for transposed tensors | Replaces per-element StridedIter | +| **In-place mutation** | Eliminates allocation | When tensor is unique and contiguous | + +### Considered but Skipped + +| Optimization | Why Skipped | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cache blocking / loop tiling** | Requires architecture-specific tile sizes. M3 has 128KB L1, but optimal tile size varies by operation, data type, and cache hierarchy. Adds complexity without portable benefit. | +| **Software prefetching** | ARM64 `_prefetch` intrinsic is unstable (requires nightly Rust). Apple Silicon has excellent hardware prefetchers that detect strided access patterns automatically. Benefit likely marginal. | +| **Kernel fusion** | Outside burn-flex scope. Fusion is handled at the Burn framework level via `burn-fusion`. This backend focuses on single-operation efficiency. | +| **Hand-tuned intrinsics** | Portable SIMD via macerator covers NEON/AVX2/SSE/SIMD128 with a single implementation. Hand-tuned per-arch intrinsics add maintenance burden with marginal benefit for memory-bound ops. | + +### Why Element-wise Ops are Memory-Bound + +Element-wise operations (add, mul, etc.) perform ~1 FLOP per 4-8 bytes loaded. Modern CPUs can +execute 100+ FLOPs in the time it takes to load one cache line from RAM. This means: + +1. **SIMD helps marginally** - Reduces instruction count but doesn't change memory bandwidth +2. **Avoiding allocation matters more** - In-place mutation eliminates write-allocate traffic +3. **Simple loops auto-vectorize** - Compiler generates good SIMD code for predictable patterns +4. **Hardware prefetchers are effective** - M3 detects sequential and strided patterns automatically + +--- + +## Zero-Copy Loading + +`Bytes` from burn-std supports zero-copy scenarios (mmap, external buffers). `FlexTensor` wraps this +in `Arc` for cheap cloning while preserving zero-copy capabilities. + +## Thread Safety + +`Arc` provides thread-safe sharing with automatic COW: + +- `Arc` is `Send + Sync` for safe cross-thread sharing +- `Arc::make_mut` triggers copy only when data is shared +- `Arc::strong_count` enables `is_unique()` checks for in-place optimization diff --git a/crates/burn-flex/BENCHMARKS.md b/crates/burn-flex/BENCHMARKS.md new file mode 100644 index 0000000..3dec2b7 --- /dev/null +++ b/crates/burn-flex/BENCHMARKS.md @@ -0,0 +1,976 @@ +# Benchmarks: Flex vs NdArray + +All benchmarks run on Apple M3 Max, comparing burn-flex against burn-ndarray. Default features +enabled (`std`, `simd`, `rayon`); `gemm` is a required dependency. + +**Date**: 2026-04-06 + +## How to Read + +- **Median** time reported (lower is better) +- **Speedup** = NdArray median / Flex median +- **Mem** = peak allocation (`max alloc` from divan) +- Bold speedup means Flex wins; plain means tie or NdArray wins + +--- + +## Binary Operations (f32) + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ----------- | ---- | ------- | ------- | -------- | -------- | ----------- | +| add | 4K | 389 ns | 436 ns | **1.1x** | 16.4 KB | 16.4 KB | +| add | 64K | 7.36 us | 7.45 us | ~1x | 262 KB | 262 KB | +| add | 1M | 83.9 us | 115 us | **1.4x** | 4.19 MB | 4.19 MB | +| mul | 4K | 382 ns | 430 ns | **1.1x** | 16.4 KB | 16.4 KB | +| mul | 64K | 7.40 us | 7.40 us | ~1x | 262 KB | 262 KB | +| mul | 1M | 115 us | 115 us | ~1x | 4.19 MB | 4.19 MB | +| div | 1M | 115 us | 115 us | ~1x | 4.19 MB | 4.19 MB | +| add_scalar | 1M | 78.7 us | 87.8 us | **1.1x** | 4.19 MB | 4.19 MB | +| mul_scalar | 1M | 75.8 us | 87.5 us | **1.2x** | 4.19 MB | 4.19 MB | +| powf | 64K | 197 us | 199 us | ~1x | 262 KB | 262 KB | +| powf | 1M | 3.17 ms | 3.21 ms | ~1x | 4.19 MB | 4.19 MB | +| powf_scalar | 1M | 3.23 ms | 3.18 ms | ~1x | 4.19 MB | 4.19 MB | +| atan2 | 64K | 143 us | 142 us | ~1x | 262 KB | 262 KB | +| atan2 | 1M | 2.33 ms | 2.32 ms | ~1x | 4.19 MB | 4.19 MB | + +### Transposed + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --------- | ------- | ------- | ------- | -------- | ----------- | +| add | 256x256 | 48.5 us | 46.0 us | 0.95x | 262 KB | 262 KB | +| add | 1024x1024 | 1.00 ms | 990 us | ~1x | 4.19 MB | 4.19 MB | + +--- + +## Binary Operations (i64) + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| -------------- | ---- | ------- | ------- | -------- | -------- | ----------- | +| int_add | 4K | 361 ns | 655 ns | **1.8x** | 16.5 KB | 32.8 KB | +| int_add | 64K | 7.40 us | 14.6 us | **2.0x** | 262 KB | 524 KB | +| int_add | 1M | 115 us | 230 us | **2.0x** | 4.19 MB | 8.39 MB | +| int_mul | 4K | 366 ns | 1.95 us | **5.3x** | 16.4 KB | 32.8 KB | +| int_mul | 64K | 7.40 us | 26.7 us | **3.6x** | 262 KB | 524 KB | +| int_mul | 1M | 115 us | 230 us | **2.0x** | 4.19 MB | 8.39 MB | +| int_div | 1M | 604 us | 698 us | **1.2x** | 4.19 MB | 8.39 MB | +| int_add_scalar | 1M | 75.8 us | 174 us | **2.3x** | 4.19 MB | 8.39 MB | +| int_mul_scalar | 1M | 75.7 us | 258 us | **3.4x** | 4.19 MB | 8.39 MB | + +### Int Power + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | -------- | ------- | ------- | -------- | -------- | ----------- | +| int_powi | 256x256 | 95.6 us | 83.1 us | 0.87x | 262 KB | 524 KB | +| int_powi | 1024x256 | 336 us | 382 us | **1.1x** | 1.05 MB | 2.10 MB | + +### Transposed (i64) + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | -------- | +| int_add | 256x256 | 55.6 us | 50.5 us | 0.91x | +| int_add | 1024x1024 | 996 us | 1.10 ms | **1.1x** | + +--- + +## Int Cast + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---------- | --------- | ------- | ------- | ----------- | -------- | ----------- | +| i64 to i8 | 256x256 | 3.19 us | 20.2 us | **6.3x** | 65.6 KB | 65.6 KB | +| i64 to i32 | 64x64 | 16.8 ns | 1.37 us | **82x** | 16.0 B | 16.4 KB | +| i64 to i32 | 256x256 | 13.6 ns | 20.0 us | **~1475x** | 16.0 B | 262 KB | +| i64 to i32 | 1024x1024 | 13.6 ns | 352 us | **~25963x** | 16.0 B | 4.19 MB | + +--- + +## Int Random + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | ---------- | ------- | ------- | -------- | +| uniform | 64x64 | 20.9 us | 31.6 us | **1.5x** | +| uniform | 256x256 | 334 us | 510 us | **1.5x** | +| uniform | 1024x1024 | 5.36 ms | 8.16 ms | **1.5x** | +| uniform | 16x128x128 | 1.35 ms | 2.04 ms | **1.5x** | + +--- + +## Matrix Multiplication + +### Square (f32) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | -------- | -------- | ----------- | +| 64x64 | 6.06 us | 18.9 us | **3.1x** | 33.6 KB | 49.3 KB | +| 128x128 | 43.8 us | 41.8 us | ~1x | 328 KB | 197 KB | +| 256x256 | 166 us | 138 us | 0.83x | 524 KB | 786 KB | +| 512x512 | 579 us | 840 us | **1.4x** | 2.10 MB | 3.15 MB | +| 1024x1024 | 2.69 ms | 5.83 ms | **2.2x** | 8.39 MB | 12.6 MB | + +### Rectangular (f32) + +| Shape | Flex | NdArray | Speedup | +| ------------------- | ------ | ------- | ------- | +| 512x64 x 64x512 | 167 us | 144 us | 0.87x | +| 256x512 x 512x256 | 265 us | 266 us | ~1x | +| 128x1024 x 1024x128 | 190 us | 199 us | ~1x | + +### Transposed (256x256) + +| Config | Flex | NdArray | Speedup | +| --------------- | ------ | ------- | -------- | +| LHS transposed | 140 us | 174 us | **1.2x** | +| RHS transposed | 158 us | 173 us | **1.1x** | +| Both transposed | 165 us | 210 us | **1.3x** | + +### Batched (f32) + +| Shape | Flex | NdArray | Speedup | +| ------------------ | ------- | ------- | -------- | +| 8x 64x64 | 57.6 us | 76.7 us | **1.3x** | +| 32x 64x64 | 67.0 us | 111 us | **1.7x** | +| 16x 128x128 | 267 us | 540 us | **2.0x** | +| 12x 512x64 (heads) | 777 us | 1.71 ms | **2.2x** | + +### Broadcast (f32) + +| Shape | Flex | NdArray | Speedup | +| ------------------------- | ------- | ------- | -------- | +| [1,64,64] x [8,64,64] | 47.9 us | 79.8 us | **1.7x** | +| [8,64,64] x [1,64,64] | 54.0 us | 78.5 us | **1.5x** | +| [2,1,32,32] x [1,4,32,32] | 7.00 us | 40.0 us | **5.7x** | +| [4,1,64,64] x [1,4,64,64] | 50.0 us | 66.4 us | **1.3x** | + +### Integer (i32) + +| Size | Flex | NdArray | Speedup | +| ------- | ------- | ------- | -------- | +| 64x64 | 30.2 us | 110 us | **3.7x** | +| 128x128 | 196 us | 971 us | **4.9x** | +| 256x256 | 1.90 ms | 10.1 ms | **5.3x** | +| 512x512 | 18.3 ms | 119 ms | **6.5x** | + +--- + +## Slice Operations + +### Basic Slicing + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --------- | ------ | ------- | --------- | -------- | ----------- | +| slice 1D | 1K | 112 ns | 235 ns | **2.1x** | 56.0 B | 2.15 KB | +| slice 1D | 1M | 105 ns | 26.4 us | **~252x** | 8.75 B | 2.10 MB | +| slice 2D | 256x256 | 120 ns | 3.61 us | **30x** | 19.0 B | 65.7 KB | +| slice 2D | 1024x1024 | 115 ns | 31.9 us | **~278x** | 17.5 B | 1.05 MB | +| slice 3D | 64x64x64 | 148 ns | 16.0 us | **~108x** | 28.5 B | 131 KB | + +### Narrow + +| Operation | Size | Flex | NdArray | Speedup | +| ----------- | --------- | ------ | ------- | --------- | +| narrow dim0 | 256x256 | 141 ns | 1.70 us | **12x** | +| narrow dim0 | 1024x1024 | 142 ns | 26.0 us | **~183x** | +| narrow dim1 | 256x256 | 128 ns | 6.84 us | **53x** | + +### Slice Assignment + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | -------- | +| assign 1D | 1K | 304 ns | 395 ns | **1.3x** | +| assign 2D | 256x256 | 5.39 us | 5.69 us | **1.1x** | +| assign 2D | 1024x1024 | 74.9 us | 74.8 us | ~1x | + +### Transposed Slicing + +| Size | Flex | NdArray | Speedup | +| --------- | ------- | ------- | ---------- | +| 256x256 | 98.2 ns | 7.98 us | **81x** | +| 1024x1024 | 98.2 ns | 232 us | **~2363x** | + +### Slice with Step + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | ---------- | +| step2 1D | 1K | 87.1 ns | 360 ns | **4.1x** | +| step2 1D | 1M | 76.7 ns | 142 us | **~1849x** | +| step2 2D | 1024x1024 | 103 ns | 86.8 us | **~845x** | +| step4 2D | 256x256 | 101 ns | 2.65 us | **26x** | + +--- + +## Concatenation + +### Cat (dim 0, contiguous memcpy fast path) + +| Tensors | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ------- | -------- | ------- | ------- | -------- | -------- | ----------- | +| 4x | 256x256 | 16.0 us | 33.1 us | **2.1x** | 1.05 MB | 2.10 MB | +| 4x | 1024x256 | 57.9 us | 132 us | **2.3x** | 4.20 MB | 8.39 MB | +| 16x | 64x64 | 4.11 us | 11.5 us | **2.8x** | 265 KB | 528 KB | +| 4x | 16K (1D) | 3.47 us | 10.1 us | **2.9x** | 263 KB | 525 KB | + +### Cat (dim 1, general path) + +| Tensors | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ------- | ------- | ------- | ------- | -------- | -------- | ----------- | +| 4x | 256x64 | 6.92 us | 59.2 us | **8.6x** | 263 KB | 525 KB | +| 4x | 1024x64 | 25.5 us | 366 us | **14x** | 1.05 MB | 2.10 MB | + +Dim-1 cat is much faster because NdArray's default uses N `slice_assign` calls while Flex copies +contiguous chunks directly. + +--- + +## Reduce Operations + +### Full Tensor Sum + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | -------- | -------- | ----------- | +| 1K | 118 ns | 156 ns | **1.3x** | 76.2 B | 44.0 B | +| 64K | 3.23 us | 6.20 us | **1.9x** | 80.0 B | 44.0 B | +| 1M | 43.3 us | 97.0 us | **2.2x** | 84.0 B | 44.0 B | + +### Full Tensor Max + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | -------- | -------- | ----------- | +| 1K | 207 ns | 662 ns | **3.2x** | 76.2 B | 44.0 B | +| 64K | 8.78 us | 32.5 us | **3.7x** | 84.0 B | 44.0 B | +| 1M | 139 us | 558 us | **4.0x** | 84.0 B | 44.0 B | + +### Full Tensor Min + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | -------- | -------- | ----------- | +| 1K | 278 ns | 579 ns | **2.1x** | 84.0 B | 44.0 B | +| 64K | 9.15 us | 34.8 us | **3.8x** | 84.0 B | 44.0 B | +| 1M | 142 us | 540 us | **3.8x** | 84.0 B | 44.0 B | + +### Int Max + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 2.84 us | 9.12 us | **3.2x** | 84.0 B | 48.0 B | +| 1024x1024 | 42.2 us | 145 us | **3.4x** | 92.0 B | 48.0 B | + +### Sum Along Dimension + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 256x256 | 0 | 5.06 us | 11.4 us | **2.3x** | +| 256x256 | 1 | 2.77 us | 4.61 us | **1.7x** | +| 1024x1024 | 0 | 80.0 us | 100 us | **1.3x** | +| 1024x1024 | 1 | 42.2 us | 82.0 us | **1.9x** | + +### 3D Sum (Batched) + +| Shape | Dim | Flex | NdArray | Speedup | +| ---------- | --- | ------- | ------- | -------- | +| 32x256x256 | 1 | 156 us | 212 us | **1.4x** | +| 32x256x256 | 2 | 86.2 us | 134 us | **1.6x** | + +### Sum Transposed + +| Size | Flex | NdArray | Speedup | +| --------- | ------- | ------- | -------- | +| 256x256 | 3.27 us | 6.20 us | **1.9x** | +| 1024x1024 | 41.4 us | 96.7 us | **2.3x** | + +### Sum Dim on Transposed + +| Size | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 256x256 | 0 | 2.79 us | 4.40 us | **1.6x** | +| 1024x1024 | 0 | 41.5 us | 81.9 us | **2.0x** | + +### Mean Along Dimension + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 256x256 | 1 | 2.90 us | 4.53 us | **1.6x** | +| 1024x1024 | 1 | 42.5 us | 82.4 us | **1.9x** | + +### Argmax + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 1K | - | 752 ns | 4.09 us | **5.4x** | +| 256x256 | 1 | 66.7 us | 242 us | **3.6x** | +| 1024x1024 | 1 | 120 us | 3.98 ms | **33x** | + +--- + +## Cumulative Operations + +### Cumsum + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 1K | 0 | 838 ns | 65.7 us | **78x** | +| 64K | 0 | 45.2 us | 4.25 ms | **94x** | +| 1M | 0 | 719 us | 68.1 ms | **95x** | +| 256x256 | 0 | 11.3 us | 34.2 us | **3.0x** | +| 256x256 | 1 | 42.6 us | 215 us | **5.0x** | +| 1024x1024 | 1 | 709 us | 5.51 ms | **7.8x** | + +### Cumprod + +| Shape | Dim | Flex | NdArray | Speedup | +| ------- | --- | ------- | ------- | -------- | +| 1K | 0 | 1.27 us | 66.0 us | **52x** | +| 256x256 | 1 | 66.3 us | 216 us | **3.3x** | + +### Cummin + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 1K | 0 | 1.79 us | 66.3 us | **37x** | +| 256x256 | 1 | 102 us | 204 us | **2.0x** | +| 1024x1024 | 1 | 1.71 ms | 5.53 ms | **3.2x** | + +### Cummax + +| Shape | Dim | Flex | NdArray | Speedup | +| --------- | --- | ------- | ------- | -------- | +| 1K | 0 | 1.82 us | 65.9 us | **36x** | +| 256x256 | 1 | 102 us | 123 us | **1.2x** | +| 1024x1024 | 1 | 1.70 ms | 3.60 ms | **2.1x** | + +### 3D Cumsum (Batched) + +| Shape | Dim | Flex | NdArray | Speedup | +| -------- | --- | ------- | ------- | -------- | +| 32x64x64 | 1 | 24.1 us | 83.7 us | **3.5x** | +| 32x64x64 | 2 | 68.0 us | 237 us | **3.5x** | + +--- + +## Gather/Scatter Operations + +### Gather + +| Shape | Dim | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 0 | 32.9 us | 140 us | **4.3x** | 393 KB | 786 KB | +| 256x256 | 1 | 33.8 us | 87.1 us | **2.6x** | 393 KB | 786 KB | +| 1024x1024 | 1 | 273 us | 1.31 ms | **4.8x** | 6.29 MB | 12.6 MB | + +### Scatter Add + +| Shape | Dim | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 1 | 35.7 us | 189 us | **5.3x** | 524 KB | 918 KB | +| 1024x1024 | 1 | 563 us | 2.83 ms | **5.0x** | 8.39 MB | 14.7 MB | + +### Select + +| Shape | Dim | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 0 | 2.02 us | 12.9 us | **6.4x** | 132 KB | 143 KB | +| 256x256 | 1 | 26.3 us | 31.1 us | **1.2x** | 132 KB | 143 KB | +| 1024x1024 | 0 | 26.8 us | 88.1 us | **3.3x** | 2.10 MB | 2.15 MB | + +### Bool Select + +| Shape | Indices | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| -------- | ------- | ------- | ------- | ------- | -------- | ----------- | +| 256x256 | 128 | 935 ns | 12.0 us | **13x** | 33.9 KB | 45.0 KB | +| 1024x256 | 512 | 2.89 us | 47.4 us | **16x** | 135 KB | 180 KB | + +### Select Add + +| Shape | Dim | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | --- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 0 | 7.35 us | 13.5 us | **1.8x** | 263 KB | 263 KB | +| 1024x1024 | 0 | 103 us | 126 us | **1.2x** | 4.20 MB | 4.20 MB | + +--- + +## Unary Operations + +### Basic Math + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | ---- | ------- | ------- | -------- | +| exp | 4K | 5.07 us | 5.20 us | ~1x | +| exp | 64K | 80.5 us | 85.0 us | **1.1x** | +| exp | 1M | 1.31 ms | 1.35 ms | ~1x | +| log | 4K | 6.74 us | 6.87 us | ~1x | +| log | 64K | 106 us | 111 us | ~1x | +| log | 1M | 1.72 ms | 1.77 ms | ~1x | +| sqrt | 4K | 612 ns | 860 ns | **1.4x** | +| sqrt | 64K | 9.03 us | 12.8 us | **1.4x** | +| sqrt | 1M | 142 us | 195 us | **1.4x** | +| abs | 1M | 75.8 us | 75.8 us | ~1x | +| recip | 1M | 75.5 us | 75.6 us | ~1x | + +### Trigonometric + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | ---- | ------- | ------- | -------- | +| sin | 4K | 5.65 us | 8.04 us | **1.4x** | +| sin | 64K | 89.2 us | 130 us | **1.5x** | +| sin | 1M | 1.45 ms | 2.10 ms | **1.4x** | +| cos | 4K | 6.57 us | 8.45 us | **1.3x** | +| cos | 1M | 1.68 ms | 2.21 ms | **1.3x** | +| tanh | 4K | 7.07 us | 13.7 us | **1.9x** | +| tanh | 64K | 112 us | 222 us | **2.0x** | +| tanh | 1M | 1.80 ms | 3.57 ms | **2.0x** | + +### Transposed (Non-contiguous) + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | -------- | +| exp | 256x256 | 80.1 us | 84.8 us | **1.1x** | +| exp | 1024x1024 | 1.31 ms | 1.35 ms | ~1x | + +--- + +## Comparison & Boolean Operations + +### Tensor-Tensor Comparisons + +| Operation | Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ---- | ------- | ------- | ------- | -------- | ----------- | +| greater | 4K | 431 ns | 398 ns | ~1x | 4.17 KB | 4.14 KB | +| greater | 64K | 6.48 us | 5.73 us | ~1x | 65.6 KB | 65.6 KB | +| greater | 1M | 93 us | 88 us | ~1x | 1.05 MB | 1.05 MB | +| equal | 4K | 433 ns | 403 ns | ~1x | 4.17 KB | 4.14 KB | +| equal | 1M | 86 us | 89 us | ~1x | 1.05 MB | 1.05 MB | +| lower | 1M | 92 us | 87 us | ~1x | 1.05 MB | 1.05 MB | + +### Scalar Comparisons + +| Operation | Size | Flex | NdArray | Speedup | +| ------------ | ---- | ----- | ------- | --------- | +| greater_elem | 1M | 56 us | 76 us | **1.36x** | + +### Transposed Comparisons + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | ------- | +| greater | 256x256 | 53.6 us | 43.7 us | 0.82x | +| greater | 1024x1024 | 985 us | 990 us | ~1x | + +### Broadcast Comparisons + +| Operation | Shape | Flex | NdArray | Speedup | +| --------- | --------- | ------- | ------- | -------- | +| greater | 256x256 | 7.98 us | 25.6 us | **3.2x** | +| greater | 1024x1024 | 120 us | 317 us | **2.6x** | + +### Expand (Broadcasting) + +| Operation | Flex | NdArray | Speedup | +| ------------------- | ------ | ------- | ---------- | +| 1x1 to 1000x1000 | 126 ns | 291 us | **~2307x** | +| 1024x1 to 1024x1024 | 110 ns | 310 us | **~2803x** | +| 1x1024 to 1024x1024 | 126 ns | 78.6 us | **~623x** | + +### Boolean Operations + +| Operation | Size | Flex | NdArray | Speedup | +| --------- | ---- | ------- | ------- | ------- | +| bool_not | 1M | 24.1 us | 19.0 us | 0.79x | +| bool_and | 1M | 34.6 us | 28.8 us | 0.83x | + +--- + +## Convolutions + +### Kernel Size Comparison (4x64x56x56, 64 to 128 channels) + +| Kernel | Flex | NdArray | Speedup | +| ------ | ------- | ------- | -------- | +| 1x1 | 577 us | 803 us | **1.4x** | +| 3x3 | 3.65 ms | 9.46 ms | **2.6x** | +| 5x5 | 8.05 ms | 24.7 ms | **3.1x** | +| 7x7 | 15.7 ms | 49.8 ms | **3.2x** | + +### ResNet Layers (batch=1, 3x3) + +| Layer | Input | Channels | Flex | NdArray | Speedup | +| ------ | ----------- | -------------- | ------- | ------- | -------- | +| conv1 | 1x3x224x224 | 3 to 64 (k7s2) | 954 us | 1.26 ms | **1.3x** | +| layer1 | 1x64x56x56 | 64 to 64 | 986 us | 1.82 ms | **1.8x** | +| layer2 | 1x128x28x28 | 128 to 128 | 1.08 ms | 1.60 ms | **1.5x** | +| layer3 | 1x256x14x14 | 256 to 256 | 1.65 ms | 3.08 ms | **1.9x** | +| layer4 | 1x512x7x7 | 512 to 512 | 2.71 ms | 10.3 ms | **3.8x** | + +### Small (batch=1, 3x3) + +| Input | Channels | Flex | NdArray | Speedup | +| ---------- | -------- | ------- | ------- | -------- | +| 1x3x32x32 | 3 to 16 | 71.3 us | 79.4 us | **1.1x** | +| 1x16x32x32 | 16 to 32 | 219 us | 252 us | **1.2x** | +| 1x32x16x16 | 32 to 64 | 164 us | 338 us | **2.1x** | + +### Large Batched (batch=16, 3x3) + +| Input | Channels | Flex | NdArray | Speedup | +| ------------- | ---------- | ------- | ------- | -------- | +| 16x64x128x128 | 64 to 128 | 79.8 ms | 180 ms | **2.3x** | +| 16x128x64x64 | 128 to 256 | 59.8 ms | 218 ms | **3.7x** | + +### Medium Batched (batch=8, 3x3) + +| Input | Channels | Flex | NdArray | Speedup | +| ---------- | --------- | ------- | ------- | -------- | +| 8x3x64x64 | 3 to 64 | 925 us | 491 us | 0.53x | +| 8x32x64x64 | 32 to 64 | 4.67 ms | 6.44 ms | **1.4x** | +| 8x64x32x32 | 64 to 128 | 3.07 ms | 9.27 ms | **3.0x** | + +### Conv1d + +| Input | Kernel | Flex | NdArray | Speedup | +| ---------- | ------ | ------- | ------- | -------- | +| 1x16x256 | 3 | 31.4 us | 163 us | **5.2x** | +| 8x32x512 | 5 | 536 us | 2.32 ms | **4.3x** | +| 16x64x1024 | 7 | 5.17 ms | 50.7 ms | **9.8x** | + +--- + +## Pooling + +### Max Pool 2D + +| Input | Kernel | Flex | NdArray | Speedup | +| ------------ | ------ | ------- | ------- | -------- | +| 1x64x56x56 | 3x3 s2 | 135 us | 165 us | **1.2x** | +| 8x64x56x56 | 3x3 s2 | 683 us | 914 us | **1.3x** | +| 16x128x28x28 | 2x2 s2 | 406 us | 640 us | **1.6x** | +| 1x512x14x14 | 2x2 s2 | 90.5 us | 106 us | **1.2x** | + +### Max Pool 2D (ResNet) + +| Input | Kernel | Flex | NdArray | Speedup | +| ------------- | ------ | ------- | ------- | -------- | +| 1x64x112x112 | 3x3 s2 | 446 us | 520 us | **1.2x** | +| 8x64x112x112 | 3x3 s2 | 2.63 ms | 3.00 ms | **1.1x** | +| 16x64x112x112 | 3x3 s2 | 5.03 ms | 5.91 ms | **1.2x** | + +### Avg Pool 2D + +| Input | Kernel | Flex | NdArray | Speedup | +| ------------ | ------ | ------ | ------- | -------- | +| 1x64x56x56 | 3x3 s2 | 155 us | 149 us | ~1x | +| 8x64x56x56 | 3x3 s2 | 782 us | 889 us | **1.1x** | +| 16x128x28x28 | 2x2 s2 | 484 us | 480 us | ~1x | + +### Adaptive Avg Pool 2D + +| Input | Output | Flex | NdArray | Speedup | +| ----------- | ------ | ------- | ------- | -------- | +| 1x256x56x56 | 7x7 | 151 us | 142 us | 0.94x | +| 1x512x7x7 | 1x1 | 63.7 us | 68.2 us | **1.1x** | +| 8x512x7x7 | 1x1 | 112 us | 110 us | ~1x | +| 16x2048x7x7 | 1x1 | 286 us | 289 us | ~1x | + +### Max Pool 1D + +| Input | Kernel | Flex | NdArray | Speedup | +| ----------- | ------ | ------- | ------- | -------- | +| 1x64x256 | 3 s2 | 57.8 us | 79.4 us | **1.4x** | +| 8x128x512 | 3 s2 | 316 us | 828 us | **2.6x** | +| 16x256x1024 | 3 s2 | 1.73 ms | 5.41 ms | **3.1x** | + +### Kernel Size Comparison (4x64x56x56) + +| Kernel | Flex | NdArray | Speedup | +| ------ | ------- | ------- | -------- | +| 2x2 | 221 us | 317 us | **1.4x** | +| 3x3 | 375 us | 515 us | **1.4x** | +| 5x5 | 1.03 ms | 799 us | 0.78x | + +--- + +## Transposed Convolutions + +### Conv Transpose 2D + +| Input | Output | Flex | NdArray | Speedup | +| -------------- | ------ | ------- | ------- | ------- | +| 1x64x7x7 | 14x14 | 138 us | 1.67 ms | **12x** | +| 1x128x14x14 | 28x28 | 533 us | 12.9 ms | **24x** | +| 1x256x28x28 | 56x56 | 2.49 ms | 209 ms | **84x** | +| 1x512x7x7 k3s1 | 7x7 | 1.01 ms | 52.6 ms | **52x** | +| 8x64x14x14 | 28x28 | 3.41 ms | 49.7 ms | **15x** | + +### DCGAN Generator + +| Layer | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| -------------- | ------- | ------- | -------- | -------- | ----------- | +| 1x1 to 4x4 | 156 us | 1.43 ms | **9.2x** | 33.1 KB | 16.4 KB | +| 4x4 to 8x8 | 234 us | 3.83 ms | **16x** | 164 KB | 32.8 KB | +| 8x8 to 16x16 | 305 us | 4.24 ms | **14x** | 852 KB | 65.6 KB | +| 16x16 to 32x32 | 26.4 us | 1.47 ms | **56x** | 193 KB | 12.3 KB | + +### Conv Transpose 1D + +| Input | Flex | NdArray | Speedup | +| --------- | ------- | ------- | ------- | +| 1x64x32 | 17.5 us | 383 us | **22x** | +| 8x128x64 | 433 us | 9.02 ms | **21x** | +| 1x256x128 | 337 us | 8.95 ms | **27x** | + +### Conv Transpose 3D + +| Input | Output | Flex | NdArray | Speedup | +| ---------- | -------- | ------- | ------- | ------- | +| 1x32x4x4x4 | 8x8x8 | 245 us | 2.58 ms | **11x** | +| 1x64x8x8x8 | 16x16x16 | 1.41 ms | 47.4 ms | **34x** | + +--- + +## Interpolation + +### Nearest + +| Input | Output | Flex | NdArray | Speedup | +| ----------- | ------- | ------- | ------- | -------- | +| 1x3x64x64 | 128x128 | 22.7 us | 138 us | **6.1x** | +| 1x3x32x32 | 128x128 | 22.8 us | 143 us | **6.3x** | +| 1x3x256x256 | 128x128 | 22.5 us | 143 us | **6.3x** | +| 8x3x64x64 | 128x128 | 58.5 us | 307 us | **5.2x** | +| 1x64x32x32 | 64x64 | 57.9 us | 254 us | **4.4x** | + +### Bilinear + +| Input | Output | Flex | NdArray | Speedup | +| ----------- | ------- | ------- | ------- | -------- | +| 1x3x64x64 | 128x128 | 81.2 us | 161 us | **2.0x** | +| 1x3x32x32 | 128x128 | 86.5 us | 147 us | **1.7x** | +| 1x3x256x256 | 128x128 | 83.9 us | 157 us | **1.9x** | +| 8x3x64x64 | 128x128 | 181 us | 382 us | **2.1x** | +| 1x64x32x32 | 64x64 | 108 us | 309 us | **2.8x** | + +### Bicubic + +| Input | Output | Flex | NdArray | Speedup | +| ----------- | ------- | ------ | ------- | -------- | +| 1x3x64x64 | 128x128 | 159 us | 239 us | **1.5x** | +| 1x3x32x32 | 128x128 | 160 us | 232 us | **1.4x** | +| 1x3x256x256 | 128x128 | 159 us | 238 us | **1.5x** | +| 8x3x64x64 | 128x128 | 894 us | 994 us | **1.1x** | +| 1x64x32x32 | 64x64 | 616 us | 707 us | **1.1x** | + +--- + +## Grid Sample 2D + +| Input | Grid | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---------- | ----- | ------- | ------- | -------- | -------- | ----------- | +| 1x3x32x32 | 32x32 | 16.7 us | 87.6 us | **5.2x** | 12.5 KB | 12.3 KB | +| 1x3x64x64 | 64x64 | 66.9 us | 127 us | **1.9x** | 49.4 KB | 49.2 KB | +| 4x3x32x32 | 32x32 | 60.7 us | 152 us | **2.5x** | 49.4 KB | 49.2 KB | +| 1x16x64x64 | 64x64 | 291 us | 223 us | 0.77x | 262 KB | 262 KB | + +--- + +## Cross Product & Unfold + +### Cross Product + +| Shape | Flex | NdArray | Speedup | +| ------- | ------- | ------- | -------- | +| 1Kx3 | 31.1 us | 43.5 us | **1.4x** | +| 64Kx3 | 1.88 ms | 2.78 ms | **1.5x** | +| 256Kx3 | 7.58 ms | 11.1 ms | **1.5x** | +| 64x3x64 | 141 us | 290 us | **2.1x** | + +### Unfold (1D) + +| Input | Window | Step | Flex | NdArray | Speedup | +| ----- | ------ | ---- | ------- | ------- | ------------ | +| 1K | 8 | 1 | 62.2 ns | 120 us | **~1927x** | +| 64K | 8 | 1 | 64.1 ns | 7.55 ms | **~117686x** | +| 64K | 64 | 1 | 62.2 ns | 8.04 ms | **~129295x** | +| 64K | 64 | 32 | 62.2 ns | 254 us | **~4094x** | + +### Unfold (2D/3D) + +| Shape | Dim | Window | Step | Flex | NdArray | Speedup | +| -------- | --- | ------ | ---- | ------- | ------- | ----------- | +| 256x256 | 1 | 8 | 1 | 68.7 ns | 871 us | **~12685x** | +| 256x256 | 1 | 32 | 16 | 62.5 ns | 57.7 us | **~924x** | +| 1024x256 | 1 | 8 | 1 | 62.8 ns | 3.28 ms | **~52182x** | +| 32x64x64 | 2 | 8 | 4 | 77.1 ns | 424 us | **~5497x** | + +--- + +## Deformable Convolutions + +### Small/Tiny Inputs + +| Input | Config | Flex | NdArray | Speedup | +| --------- | ----------- | ------- | ------- | -------- | +| 1x3x8x8 | 3 to 8, k3 | 8.72 us | 92.6 us | **11x** | +| 1x3x8x8 | no mask | 7.98 us | 78.9 us | **9.9x** | +| 1x3x16x16 | 3 to 16, k3 | 36.4 us | 122 us | **3.4x** | +| 1x3x16x16 | stride 2 | 10.2 us | 80.8 us | **7.9x** | +| 2x8x16x16 | 8 to 16, k3 | 116 us | 246 us | **2.1x** | + +### Medium Inputs + +| Input | Config | Flex | NdArray | Speedup | +| ---------- | ------------ | ------ | ------- | ------- | +| 1x16x32x32 | 16 to 32, k3 | 826 us | 581 us | 0.70x | +| 1x16x32x32 | wg=4 | 840 us | 528 us | 0.63x | +| 1x16x32x32 | og=4 | 942 us | 606 us | 0.64x | + +--- + +## Attention (Scaled Dot-Product) + +Flex auto-selects between two gemm-backed strategies: + +- **Naive** (score matrix <= 256K elements): Materializes full [seq_q, seq_kv] score matrix. Two + large gemm calls per (batch, head) amortize dispatch overhead better than many small tiled calls. +- **Flash** (score matrix > 256K elements): Tiles over KV dimension with online softmax. + `O(seq_q * TILE_KV)` memory per head instead of `O(seq_q * seq_kv)`. + +Both fuse scale + softcap + masking + bias + softmax into a single pass, reducing intermediate +allocations from ~12 (NdArray fallback) to 3. + +### Self-Attention + +| Config | Flex | NdArray | Speedup | +| --------------- | ------- | ------- | -------- | +| h8, s64, d64 | 180 us | 534 us | **3.0x** | +| h12, s128, d64 | 989 us | 1.61 ms | **1.6x** | +| h12, s256, d64 | 3.79 ms | 6.03 ms | **1.6x** | +| h12, s512, d64 | 14.8 ms | 22.7 ms | **1.5x** | +| h32, s256, d128 | 15.1 ms | 17.5 ms | **1.2x** | +| b4, h12, s128 | 3.96 ms | 5.47 ms | **1.4x** | + +### Causal Attention + +| Config | Flex | NdArray | Speedup | +| -------------- | ------- | ------- | -------- | +| h12, s128, d64 | 1.01 ms | 1.72 ms | **1.7x** | +| h12, s256, d64 | 3.82 ms | 6.47 ms | **1.7x** | +| h12, s512, d64 | 14.8 ms | 23.8 ms | **1.6x** | + +### With Additive Bias (ALiBi-style) + +| Config | Flex | NdArray | Speedup | +| -------------- | ------- | ------- | -------- | +| h12, s128, d64 | 1.04 ms | 1.60 ms | **1.5x** | +| h12, s256, d64 | 4.00 ms | 6.15 ms | **1.5x** | + +### Cross-Attention (seq_q != seq_k) + +| Config | Flex | NdArray | Speedup | +| ----------------- | ------- | ------- | -------- | +| sq128, sk512, d64 | 3.82 ms | 6.14 ms | **1.6x** | +| sq32, sk1024, d64 | 2.05 ms | 3.72 ms | **1.8x** | + +--- + +## Quantized Tensor Operations + +All quantized ops (except layout ops) go through a dequantize-op-quantize cycle. Flex stores scales +separately and applies `scale * x_q` directly; NdArray reparses `QuantizedBytes` on every dequantize +call, which dominates the cost. + +### Quantize (float to i8) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | -------- | -------- | ----------- | +| 4K | 6.93 us | 10.4 us | **1.5x** | 20.6 KB | 24.7 KB | +| 64K | 109 us | 145 us | **1.3x** | 328 KB | 393 KB | +| 1M | 1.75 ms | 2.31 ms | **1.3x** | 5.24 MB | 6.29 MB | + +### Dequantize (i8 to float) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | --------- | -------- | ----------- | +| 4K | 399 ns | 48.8 us | **~122x** | 16.5 KB | 24.6 KB | +| 64K | 3.73 us | 801 us | **~215x** | 262 KB | 393 KB | +| 1M | 54.6 us | 13.0 ms | **~238x** | 4.19 MB | 6.29 MB | + +### q_add (dequant + add + requant) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | --------- | -------- | ----------- | +| 4K | 1.15 us | 101 us | **88x** | 20.6 KB | 41.0 KB | +| 64K | 14.3 us | 1.61 ms | **~113x** | 524 KB | 655 KB | +| 1M | 208 us | 25.9 ms | **~125x** | 8.39 MB | 10.5 MB | + +### q_matmul (dequant + matmul + requant) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ------- | ------- | ------- | ------- | -------- | ----------- | +| 64x64 | 7.10 us | 137 us | **19x** | 66.5 KB | 49.3 KB | +| 256x256 | 147 us | 1.93 ms | **13x** | 1.05 MB | 788 KB | +| 512x512 | 641 us | 8.07 ms | **13x** | 4.19 MB | 3.15 MB | + +### q_sum (dequant + sum) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| ---- | ------- | ------- | --------- | -------- | ----------- | +| 4K | 605 ns | 50.7 us | **84x** | 2.13 KB | 24.6 KB | +| 64K | 7.71 us | 834 us | **~108x** | 262 KB | 393 KB | +| 1M | 194 us | 13.3 ms | **68x** | 4.19 MB | 6.29 MB | + +### q_permute (zero-copy layout op) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | ------- | -------- | ----------- | +| 256x256 | 75.5 ns | 66.1 ns | 0.88x | 20.5 B | 4.00 B | +| 1024x1024 | 77.5 ns | 66.1 ns | 0.85x | 20.5 B | 4.00 B | + +### q_argmax (operates on i8 directly) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 67.9 us | 106 us | **1.6x** | 3.25 KB | 4.16 KB | +| 1024x1024 | 137 us | 1.71 ms | **12x** | 12.5 KB | 16.4 KB | + +### q_argmin (operates on i8 directly) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 69.4 us | 106 us | **1.5x** | 3.25 KB | 4.16 KB | +| 1024x1024 | 139 us | 1.72 ms | **12x** | 12.5 KB | 16.4 KB | + +### q_gather (operates on i8 directly for tensor-level quant) + +| Size | Flex | NdArray | Speedup | Flex Mem | NdArray Mem | +| --------- | ------- | ------- | -------- | -------- | ----------- | +| 256x256 | 65.7 us | 155 us | **2.4x** | 590 KB | 721 KB | +| 1024x1024 | 393 us | 2.40 ms | **6.1x** | 9.44 MB | 11.5 MB | + +--- + +## Default Ops (sort, repeat, creation, embedding, predicates) + +These ops override burn's default trait implementations with direct storage operations. + +### Sort (f32, 1D) + +| Size | Flex | NdArray | Speedup | +| ---- | ------- | ------- | -------- | +| 4K | 53.6 us | 123 us | **2.3x** | +| 64K | 593 us | 1.59 ms | **2.7x** | +| 1M | 8.47 ms | 23.9 ms | **2.8x** | + +### Sort (f32, 2D along last dim) + +| Size | Flex | NdArray | Speedup | +| --------- | ------- | ------- | -------- | +| 64x64 | 5.72 us | 70.3 us | **12x** | +| 256x256 | 200 us | 1.26 ms | **6.3x** | +| 1024x1024 | 1.17 ms | 34.5 ms | **29x** | + +### Argsort (f32, 1D) + +| Size | Flex | NdArray | Speedup | +| ---- | ------- | ------- | -------- | +| 4K | 70.2 us | 123 us | **1.7x** | +| 1M | 12.8 ms | 26.2 ms | **2.1x** | + +### Repeat Dim (f32, 256x256) + +| Config | Flex | NdArray | Speedup | +| ----------------- | ------- | ------- | -------- | +| dim0 4x | 12.7 us | 134 us | **11x** | +| dim1 4x | 11.5 us | 138 us | **12x** | +| dim0 8x (512x512) | 98.4 us | 880 us | **8.9x** | + +### Tensor Creation (f32, 1M elements) + +| Operation | Flex | NdArray | Speedup | +| --------- | ------- | ------- | ------- | +| zeros | 17.7 us | 579 us | **33x** | +| ones | 35.7 us | 579 us | **16x** | +| full | 35.9 us | 579 us | **16x** | + +### Arange (i64) + +| Size | Flex | NdArray | Speedup | +| ---- | ------- | ------- | ------- | +| 4K | 1.21 us | 1.21 us | ~1x | +| 1M | 299 us | 280 us | 0.94x | + +### Embedding (f32) + +| Config | Flex | NdArray | Speedup | +| ----------------------- | ------- | ------- | -------- | +| 30k vocab, d=512, 8x128 | 26.1 us | 150 us | **5.8x** | +| 50k vocab, d=768, 4x256 | 38.9 us | 188 us | **4.8x** | + +### Predicates (f32, 1M elements) + +| Operation | Flex | NdArray | Speedup | +| --------- | ------- | ------- | -------- | +| is_nan | 46.4 us | 73.6 us | **1.6x** | +| is_inf | 52.6 us | 147 us | **2.8x** | + +--- + +## FFT (Real FFT) + +Compared against `realfft` v3 (backed by `rustfft` v6), the gold-standard pure-Rust FFT library. +NdArray does not implement rfft. `realfft` requires `std`; Flex works in `no_std`. + +### 1D rfft + +| Size | Flex (median) | realfft (median) | Ratio | +| ------- | ------------- | ---------------- | ----- | +| n=256 | 841 ns | 252 ns | 3.3x | +| n=1024 | 2.64 us | 991 ns | 2.7x | +| n=4096 | 10.6 us | 4.37 us | 2.4x | +| n=16384 | 45.4 us | 20.7 us | 2.2x | +| n=65536 | 231 us | 91.2 us | 2.5x | + +### Batched 2D rfft (along last dim) + +| Size | Flex (median) | realfft (median) | Ratio | +| --------- | ------------- | ---------------- | ----- | +| 16 x 1024 | 59.9 us | 15.9 us | 3.8x | +| 64 x 1024 | 114 us | 64.0 us | 1.8x | +| 256 x 256 | 191 us | 64.9 us | 2.9x | + +### 1D irfft (inverse) + +| Size | Flex (median) | realfft (median) | Ratio | +| ------- | ------------- | ---------------- | ----- | +| n=256 | 1.32 us | 207 ns | 6.4x | +| n=1024 | 4.01 us | 908 ns | 4.4x | +| n=4096 | 15.6 us | 4.24 us | 3.7x | +| n=16384 | 63.2 us | 21.1 us | 3.0x | +| n=65536 | 278 us | 93.0 us | 3.0x | + +Flex implementation: Cooley-Tukey with mixed radix-4/radix-2, complex packing (forward) / inverse +packing (inverse), compile-time twiddle tables via const fn, SIMD vectorization via macerator, +unrolled small kernels (N=2,4,8), and rayon parallelism across fibers. The remaining gap to rustfft +is due to their hand-tuned per-arch SIMD rewrites, split-radix algorithms, and strength-reduced +modular arithmetic. + +--- + +## Running Benchmarks + +```bash +cargo bench --bench attention +cargo bench --bench binary_ops +cargo bench --bench matmul +cargo bench --bench int_ops +cargo bench --bench slice_ops +cargo bench --bench reduce_ops +cargo bench --bench cumulative_ops +cargo bench --bench gather_scatter_ops +cargo bench --bench unary_ops +cargo bench --bench comparison_ops +cargo bench --bench conv_ops +cargo bench --bench pool_ops +cargo bench --bench conv_transpose_ops +cargo bench --bench interpolate_ops +cargo bench --bench cross_unfold_ops +cargo bench --bench deform_conv_ops +cargo bench --bench quantization_ops +cargo bench --bench cat_max_min_ops +cargo bench --bench default_ops +cargo bench --bench fft_ops +``` diff --git a/crates/burn-flex/COMPARISON.md b/crates/burn-flex/COMPARISON.md new file mode 100644 index 0000000..9163fe6 --- /dev/null +++ b/crates/burn-flex/COMPARISON.md @@ -0,0 +1,624 @@ +# burn-flex vs burn-ndarray: Comprehensive Comparison + +This document compares burn-flex (proposed replacement) against burn-ndarray (current CPU backend) +to demonstrate full coverage and the architectural differences between the two. + +## Executive Summary + +burn-flex is a from-scratch CPU backend built to replace burn-ndarray. The +[ndarray](https://crates.io/crates/ndarray) crate has been slow to evolve: it lacks f16/bf16 +support, is limited to 6 dimensions, uses unsigned-only strides (preventing zero-copy flip), and +simulates quantization rather than executing natively. burn-flex addresses all of these while +passing the full `burn-backend-tests` suite, all ONNX model checks, and real model inference +(ALBERT, MiniLM). + +Performance improvements fall into two categories: + +- **Compute gains** (1.1-9.7x): Better algorithms and libraries (gemm over matrixmultiply, Arc COW + for buffer reuse, SIMD reductions). +- **Structural improvements** (up to 166,000x): Operations that burn-ndarray eagerly materializes + (unfold, expand, slice, dequantize) are represented as zero-copy views or direct lookups in + burn-flex, avoiding the work entirely. + +burn-flex uses significantly less memory, supports f16/bf16 natively, runs on no_std/WASM/embedded, +and has no dimension limit. + +--- + +## 1. Architecture + +### Tensor Representation + +| Aspect | burn-flex | burn-ndarray | +| ----------- | ----------------------------------------------------- | ------------------------------------------------------------------------- | +| Storage | `Arc` (type-erased bytes) | `enum NdArrayTensor { F64(NdArrayStorage), F32(...), ... }` | +| Dtype | Runtime `DType` field on `FlexTensor` | Compile-time via enum variant | +| Dispatch | `match dtype` at op entry, cast once | `execute_with_dtype!` macro expands match for every op | +| Clone cost | O(1) Arc refcount increment | O(1) ArcArray refcount increment | +| COW | `Arc::make_mut` / `is_unique()` | `ArcArray::is_unique()` + `NdArrayStorage::Borrowed` always returns false | +| Metadata | `Layout { shape, strides: Vec, start_offset }` | ndarray's internal strides (`usize` only) | +| Stride sign | **Signed** (`isize`) for zero-copy flip | **Unsigned** (`usize`), flip requires data copy | + +**FlexTensor** (44 bytes without shape vec): + +```rust +struct FlexTensor { + data: Arc, // 8 bytes (pointer) + layout: Layout, // shape + strides + offset + dtype: DType, // 1 byte enum +} +``` + +**NdArrayTensor** (enum with 11 typed variants): + +```rust +enum NdArrayTensor { + F64(NdArrayStorage), + F32(NdArrayStorage), + // ... 9 more variants +} +``` + +**Key insight**: Flex uses one struct for all dtypes with runtime dispatch. NdArray uses a typed +enum with macro-based dispatch. Flex's approach is simpler (no macros, no generics plumbing) and +enables operations to handle all dtypes uniformly. + +### Backend Type + +| Aspect | burn-flex | burn-ndarray | +| ------------- | ---------------------------- | ------------------------------------------------------- | +| Type | `struct Flex;` (unit struct) | `struct NdArray` (3 generic params) | +| Float element | Runtime (f32/f64/f16/bf16) | Compile-time `E: FloatNdArrayElement` (f32 or f64 only) | +| Int element | Runtime (i8-i64, u8-u64) | Compile-time `I: IntNdArrayElement` | +| Quant element | Runtime | Compile-time `Q: QuantElement` | + +Flex eliminates generic parameters entirely. Users write `Flex` instead of `NdArray`. +Dtype selection happens at runtime via `DType`. + +--- + +## 2. Feature Coverage + +### Float Dtypes + +| Dtype | burn-flex | burn-ndarray | +| -------- | ----------------------------------------------------------- | --------------------- | +| f32 | Full support (native) | Full support (native) | +| f64 | Full support (native) | Full support (native) | +| **f16** | **Full support (native)** | **Not supported** | +| **bf16** | **Full support (via f32 conversion for compute-heavy ops)** | **Not supported** | +| Flex32 | Not applicable | Maps to f32 | + +burn-flex's f16 support is native for all operations. For matmul and convolution, the gemm crate has +native f16 kernels (since v0.15). bf16 converts to f32 for compute-heavy ops (matmul, conv) because +gemm lacks native bf16 support. + +### Integer Dtypes + +| Dtype | burn-flex | burn-ndarray | +| ----- | ------------ | ------------ | +| i64 | Full support | Full support | +| i32 | Full support | Full support | +| i16 | Full support | Full support | +| i8 | Full support | Full support | +| u64 | Full support | Full support | +| u32 | Full support | Full support | +| u16 | Full support | Full support | +| u8 | Full support | Full support | + +Both backends support the same integer dtypes. + +### Bool + +| Feature | burn-flex | burn-ndarray | +| ---------- | ------------------------- | --------------------------------------- | +| Storage | `u8` (1 byte per element) | `bool` (1 byte per element via ndarray) | +| Operations | All BoolTensorOps | All BoolTensorOps | + +### Quantization + +| Feature | burn-flex | burn-ndarray | +| -------------- | --------------------------------------------------------------- | --------------------------------------- | +| Quantize | Per-tensor and per-block symmetric | Per-tensor and per-block symmetric | +| Dequantize | `scale * x_q` (direct multiply, **135-232x faster**) | Reparses `QuantizedBytes` on every call | +| Scale storage | `Vec` stored separately | `QParams` in `NdArrayQTensor` | +| Q layout ops | **Zero-copy** (permute, flip, expand, slice, select) | Copies entire tensor | +| Q ordering ops | **Skip dequantization** (argmax, argmin, gather on i8 directly) | Dequantize to f32, then operate | +| QuantStore | Native | Native | +| QuantValue | Q8F, Q8S | Q8F, Q8S (+ Q4/Q2 for export_tests) | + +The fundamental difference is scale storage. Flex stores scales separately so dequantization is a +simple `scale * x_q` multiply. NdArray stores everything in `QuantizedBytes` which must be parsed on +every access, making it the bottleneck for all quantized operations. + +--- + +## 3. Operation Coverage + +### Tensor Operations (FloatTensorOps) + +All operations listed below are implemented by both backends unless marked otherwise. + +| Operation | burn-flex | burn-ndarray | Notes | +| ----------------------------------------------------------------- | --------- | ------------ | ----------------------------------------------------- | +| from_data | Yes | Yes | | +| into_data | Yes | Yes | | +| random | Yes | Yes | | +| empty/zeros/ones | Yes | Yes | | +| full | Yes | Yes | | +| add / sub / mul / div | Yes | Yes | | +| add_scalar / sub_scalar / mul_scalar / div_scalar | Yes | Yes | | +| remainder | Yes | Yes | | +| remainder_scalar | Yes | Yes | | +| matmul | Yes | Yes | Flex uses gemm, NdArray uses matrixmultiply | +| neg | Yes | Yes | | +| recip | Yes | Yes | | +| swap_dims / permute | Yes | Yes | Both zero-copy | +| reshape | Yes | Yes | Both zero-copy when contiguous | +| gather / scatter_add | Yes | Yes | | +| select / select_add | Yes | Yes | | +| slice / slice_assign | Yes | Yes | Flex: zero-copy view; NdArray: may copy | +| mask_fill / mask_where | Yes | Yes | | +| equal / not_equal / greater / lower / greater_equal / lower_equal | Yes | Yes | | +| equal_elem / not_equal_elem / greater_elem / lower_elem | Yes | Yes | | +| sum / sum_dim / mean / mean_dim / prod / prod_dim | Yes | Yes | | +| max / max_dim / max_dim_with_indices | Yes | Yes | | +| min / min_dim / min_dim_with_indices | Yes | Yes | | +| argmax / argmin | Yes | Yes | | +| any / any_dim / all / all_dim | Yes | Yes | | +| exp / log / log1p | Yes | Yes | | +| powf / powf_scalar / powi / powi_scalar | Yes | Yes | | +| sqrt / abs / sign | Yes | Yes | | +| cos / sin / tanh | Yes | Yes | | +| erf | Yes | Yes | | +| cat | Yes | Yes | | +| into_int / into_bool | Yes | Yes | | +| clamp / clamp_min / clamp_max | Yes | Yes | | +| expand | Yes | Yes | Flex: zero-copy; NdArray: copies | +| flip | Yes | Yes | Flex: zero-copy (signed strides); NdArray: copies | +| repeat_dim | Yes | Yes | | +| sort / sort_with_indices / argsort | Yes | Yes | | +| cumsum / cumprod / cummin / cummax | Yes | Yes | | +| narrow | Yes | Yes | Flex: zero-copy; NdArray: may copy | +| chunk | Yes | Yes | | +| cross | Yes | Yes | | +| unfold | Yes | Yes | Flex: zero-copy (strided view); NdArray: materializes | +| round / floor / ceil | Yes | Yes | | +| cast | Yes | Yes | | +| grid_sample_2d | Yes | Yes | | +| bool_select | Yes | Yes | | +| int_powi | Yes | Yes | | + +### Module Operations (ModuleOps) + +| Operation | burn-flex | burn-ndarray | Notes | +| -------------------------------- | --------- | ------------ | -------------------------------------------------------------------------------------- | +| conv1d | Yes | Yes | Flex: delegates to conv3d | +| conv2d | Yes | Yes | Flex: delegates to conv3d | +| conv3d | Yes | Yes | Flex: unified implementation | +| conv_transpose1d | Yes | Yes | Flex: delegates to conv_transpose3d | +| conv_transpose2d | Yes | Yes | Flex: delegates to conv_transpose3d | +| conv_transpose3d | Yes | Yes | Flex: unified implementation | +| deform_conv2d | Yes | Yes | | +| deform_conv2d_backward | Yes | Yes | | +| avg_pool2d | Yes | Yes | Flex: delegates to pool3d | +| avg_pool2d_backward | Yes | Yes | | +| max_pool2d | Yes | Yes | Flex: delegates to pool3d | +| max_pool2d_with_indices | Yes | Yes | | +| max_pool2d_with_indices_backward | Yes | Yes | | +| adaptive_avg_pool2d | Yes | Yes | | +| adaptive_avg_pool2d_backward | Yes | Yes | | +| interpolate | Yes | Yes | Nearest, bilinear, bicubic | +| attention (SDPA) | Yes | Yes | Flex: auto-selects naive or flash by score matrix size; NdArray: matmul + softmax | +| rfft | Yes | No | Flex: Cooley-Tukey with complex packing, radix-4, SIMD, compile-time twiddles. no_std. | +| irfft | Yes | No | Flex: Inverse packing trick, SIMD via conjugate-forward-conjugate. no_std. | + +### Int and Bool Operations + +Both backends implement all IntTensorOps and BoolTensorOps. The operations mirror float ops where +applicable (arithmetic, comparison, reduction, gather/scatter, slice, etc.) plus type-specific +operations (int_random uniform, bool_not, bool_and, bool_or, bool_xor). + +### Quantized Operations (QTensorOps) + +Both backends implement all QTensorOps. The ops follow a dequantize-op-requantize pattern for most +operations. Flex optimizes by: + +- Storing scales separately for O(1) dequantization access +- Zero-copy layout ops on quantized tensors (permute, flip, expand, slice, select) +- Skipping dequantization for ordering ops (argmax, argmin, gather with tensor-level quant) + +### Activation Operations (ActivationOps) + +Both backends implement all ActivationOps via the default trait implementations (relu, gelu, etc.). + +### Transaction Operations + +Both backends implement TransactionOps for batched tensor operations. + +--- + +## 4. Dimension Limits + +| Aspect | burn-flex | burn-ndarray | +| -------------- | -------------------------------- | ---------------------------------------------- | +| Max dimensions | **Unlimited** (arbitrary rank) | **6** (hardcoded in reshape macro) | +| Enforcement | Dynamic `Vec` for strides | Static `Dim<[usize; N]>` requires match on 1-6 | + +burn-ndarray's dimension limit comes from its `reshape!` macro which matches on dimensions 1-6: + +```rust +match $D { + 1 => reshape!(ty $ty, n 1, ...), + // ... + 6 => reshape!(ty $ty, n 6, ...), + _ => panic!("NdArray supports arrays up to 6 dimensions"), +} +``` + +burn-flex uses `IxDyn`-equivalent dynamic shapes with no upper bound. + +--- + +## 5. Zero-Copy Operations + +| Operation | burn-flex | burn-ndarray | +| -------------- | --------------------------------- | ------------------------------ | +| transpose | Zero-copy (swap strides) | Zero-copy (ndarray view) | +| permute | Zero-copy (reorder strides) | Zero-copy (ndarray view) | +| reshape | Zero-copy if contiguous | Zero-copy if standard layout | +| slice / narrow | Zero-copy (offset + strides) | May allocate depending on path | +| **flip** | **Zero-copy (negate stride)** | **Copies data** | +| **unfold** | **Zero-copy (O(1) strided view)** | **O(n) full materialization** | +| **expand** | **Zero-copy (set stride to 0)** | **Copies data** | + +Flex's signed strides (`isize`) enable zero-copy flip, which is impossible with ndarray's unsigned +strides. The unfold operation is especially dramatic: Flex returns a strided view in ~50ns +regardless of size, while NdArray copies all window data (milliseconds for large tensors). + +--- + +## 6. Memory Strategy + +### In-Place Mutation + +| Strategy | burn-flex | burn-ndarray | +| ------------------ | ----------------------------------------------------- | ---------------------------------- | +| Unique check | `Arc::strong_count(&data) == 1` | `ArcArray::is_unique()` | +| In-place threshold | Contiguous at offset 0 AND unique | Unique (via SIMD ops, not all ops) | +| Binary op reuse | Reuses lhs buffer when contiguous | Allocates new for most ops | +| Allocation savings | 3x less for binary ops (4.2 MB vs 12.6 MB for 1M f32) | Standard ndarray allocation | + +### Zero-Copy Loading + +Both backends support zero-copy loading from external sources (burnpack files, mmap'd data): + +| Feature | burn-flex | burn-ndarray | +| ----------- | ----------------------------------------- | ------------------------------------------------ | +| Mechanism | `Arc` wraps borrowed data directly | `NdArrayStorage::Borrowed` holds `Bytes` + shape | +| COW trigger | `Arc::make_mut` clones on shared mutation | `into_owned()` copies borrowed to ArcArray | +| View access | `storage::()` via bytemuck cast | `view()` via unsafe ArrayView from raw pointer | + +--- + +## 7. SIMD + +| Aspect | burn-flex | burn-ndarray | +| ------------ | ----------------------------------------------------------- | ---------------------------------------------- | +| Library | macerator (required with `simd` feature) | macerator (optional with `simd` feature) | +| Dispatch | `Arch::new().dispatch(kernel)` | Same macerator dispatch | +| ISAs | NEON, AVX2, AVX512, SSE, SIMD128, scalar fallback | NEON, AVX2, SSE, SIMD128, scalar fallback | +| Coverage | Binary ops, comparisons, boolean ops, reductions, unary ops | Binary ops, comparisons, unary ops, conv, pool | +| Without SIMD | Scalar fallback module (`simd/scalar.rs`) | Falls back to ndarray operations | + +Both use macerator for portable SIMD. NdArray additionally has SIMD-optimized conv and pool kernels. +Flex relies on the gemm crate's built-in SIMD for matmul/conv performance. + +--- + +## 8. Matrix Multiplication + +| Aspect | burn-flex | burn-ndarray | +| ----------- | -------------------------------------- | ---------------------------------------------------- | +| Library | `gemm` crate (v0.18) | `matrixmultiply` crate (via ndarray) | +| f32 | Native gemm kernel | matrixmultiply | +| f64 | Native gemm kernel | matrixmultiply | +| f16 | **Native gemm kernel (since v0.15)** | **Not supported** | +| bf16 | Convert to f32, gemm, convert back | **Not supported** | +| i32 matmul | Manual nested loop | Manual nested loop | +| Parallelism | Rayon via gemm (threshold: 192^3) | Rayon via iter_range_par macro | +| Batched | Parallel over batches + per-batch gemm | Parallel over batches + ndarray general_mat_mul | +| Broadcast | Handles batch broadcast natively | Handles batch broadcast via stride mapping | +| BLAS option | No (pure Rust only) | Yes (Accelerate, OpenBLAS, Netlib via feature flags) | + +burn-ndarray offers optional BLAS acceleration (Accelerate on macOS, OpenBLAS, Netlib) through +feature flags. burn-flex uses only the gemm crate, which is pure Rust but highly optimized with its +own SIMD kernels. The gemm crate consistently outperforms matrixmultiply by 1.3-3.4x on Apple M3 +Max. + +--- + +## 9. Convolutions + +| Aspect | burn-flex | burn-ndarray | +| ------------ | ----------------------------- | -------------------------------------------------- | +| Algorithm | im2col + gemm (unified 3D) | Direct computation (per-dimension implementations) | +| conv1d | Delegates to conv3d | Separate implementation | +| conv2d | Delegates to conv3d | Separate implementation | +| conv3d | Single unified implementation | Separate implementation | +| f16 support | **Native gemm** | **Not supported** | +| bf16 support | **Via f32 conversion** | **Not supported** | +| Parallelism | Rayon over batches and groups | iter_range_par over batches | +| SIMD conv | Via gemm SIMD kernels | macerator-based SIMD conv kernel | + +Flex's unified 3D approach means one implementation covers all dimensionalities. The tradeoff is +that 1D/2D convolutions expand dimensions (negligible overhead since gemm dominates). + +NdArray has dedicated SIMD conv/pool kernels via macerator, which can be faster for specific +patterns. Flex relies on the gemm crate's SIMD for all compute-heavy paths. + +--- + +## 10. Parallelism + +| Aspect | burn-flex | burn-ndarray | +| ---------------- | ------------------------------------- | ----------------------------------------- | +| Library | rayon (optional) | rayon (optional, called "multi-threads") | +| Feature flag | `rayon` | `multi-threads` | +| Threshold | 4M elements for memory-bound ops | Via `run_par!` / `iter_range_par!` macros | +| Scope | Large tensors, batch dims, pool, conv | Matmul batches, ops via macros | +| gemm parallelism | Rayon via `Parallelism::Rayon(0)` | matrixmultiply threading | +| Without feature | Single-threaded (all ops work) | Single-threaded (all ops work) | + +--- + +## 11. Platform Support + +| Target | burn-flex | burn-ndarray | +| ------------------------------- | ---------------------------------- | ------------------------- | +| x86_64 (std) | Yes | Yes | +| aarch64 (std) | Yes (primary target) | Yes | +| wasm32-unknown-unknown | **Yes (verified)** | Yes (claimed, categories) | +| thumbv6m-none-eabi (Cortex-M0+) | **Yes (verified, no atomic ptrs)** | Not verified | +| thumbv7m-none-eabi (Cortex-M3) | **Yes (verified)** | Not verified | +| no_std | **Yes (tested, MNIST inference)** | Yes (supported) | + +burn-flex has been explicitly tested on embedded targets with Burn's `burn-no-std-tests` integration +suite (MNIST model inference). + +--- + +## 12. Dependencies + +### burn-flex + +| Dependency | Purpose | Required | +| ------------ | ----------------------------------- | ------------------ | +| burn-backend | Backend traits, types | Always | +| burn-ir | BackendIr trait | Always | +| burn-std | Bytes, Shape, platform abstractions | Always | +| half | f16/bf16 types | Always | +| bytemuck | Zero-copy type casting | Always | +| num-traits | Numeric traits (libm for no_std) | Always | +| gemm | Matrix multiplication | Always | +| macerator | Portable SIMD | Optional (`simd`) | +| aligned-vec | SIMD-aligned allocation | Optional (`simd`) | +| rayon | Parallelism | Optional (`rayon`) | + +**Total: 7 required + 3 optional** + +### burn-ndarray + +| Dependency | Purpose | Required | +| -------------------- | -------------------------------- | -------------------------- | +| burn-backend | Backend traits, types | Always | +| burn-std | Platform abstractions | Always | +| burn-autodiff | Autodiff support | Optional (`std`) | +| burn-ir | IR types | Always | +| ndarray | N-dimensional array library | Always | +| matrixmultiply | Matrix multiplication | Always | +| atomic_float | Atomic f32/f64 | Always | +| const-random | Compile-time random | Always | +| libm | Math functions for no_std | Always | +| num-traits | Numeric traits | Always | +| paste | Macro utilities | Always | +| rand | Random number generation | Always | +| macerator | Portable SIMD | Optional (`simd`) | +| bytemuck | Type casting | Optional (`simd`) | +| itertools | Iterator utilities | Optional (`simd`) | +| seq-macro | Sequence macros | Optional (`simd`) | +| rayon | Parallelism | Optional (`multi-threads`) | +| blas-src | BLAS bindings | Optional (`blas-*`) | +| openblas-src | OpenBLAS | Optional (`blas-openblas`) | +| portable-atomic | Atomic for no-atomic-ptr targets | Conditional | +| portable-atomic-util | Atomic utilities | Conditional | + +**Total: 12 required + 9 optional + 2 conditional** + +burn-flex has significantly fewer dependencies, with no dependency on ndarray itself, no macro +utility crates, and no BLAS bindings. + +--- + +## 13. Codebase Size + +| Metric | burn-flex | burn-ndarray | +| -------------- | ------------- | ------------ | +| Source files | 38 | 37 | +| Total lines | ~23,500 | ~11,400 | +| ops/ directory | ~19,700 lines | ~8,200 lines | +| SIMD module | ~1,200 lines | ~2,100 lines | + +burn-flex has roughly 2x the code. This is because: + +1. Flex implements all ops from scratch (ndarray delegates to the ndarray crate's built-in ops) +2. Flex has dedicated optimized implementations (pool, conv, reduce, cumulative, gather/scatter) +3. Flex has more comprehensive dtype handling (f16/bf16 paths for every op) +4. Flex has explicit contiguous/non-contiguous fast paths throughout + +--- + +## 14. Testing + +| Aspect | burn-flex | burn-ndarray | +| --------------------- | ---------------------------------------------------------- | ----------------------- | +| burn-backend-tests | **All pass** (6 feature flag combos) | All pass | +| burn-no-std-tests | **Pass** (MNIST inference) | Not explicitly verified | +| ONNX model checks | **All pass** | All pass | +| Real model inference | **ALBERT, MiniLM** | Not documented | +| Feature combos tested | no-default, simd, std, std+simd, std+rayon, std+simd+rayon | Default | +| Edge-case robustness | Integer overflow, rounding, zero-size, invalid params | Standard | +| Embedded builds | thumbv6m, thumbv7m, wasm32 | wasm32 | + +--- + +## 15. Performance Summary + +All benchmarks on Apple M3 Max, default features enabled. + +### Compute Performance + +Genuine algorithmic and library improvements: + +| Category | Flex vs NdArray | Why | +| ---------------- | -------------------- | ----------------------------------------- | +| Binary ops (f32) | **2.4-3.9x faster** | Arc COW avoids allocation; 3x less memory | +| Binary ops (i64) | **1.5-6.4x faster** | Same COW benefits | +| Matmul (square) | **1.1-3.4x faster** | gemm > matrixmultiply | +| Matmul (batched) | **1.8-3.2x faster** | Better batch parallelism | +| Attention | **1.2-2.4x faster** | Flash attention, 2-8.5x lower peak memory | +| Conv2d | **1.2-4.0x faster** | im2col+gemm vs direct | +| Conv1d | **4.3-9.6x faster** | Unified 3D avoids overhead | +| Pooling | **1.2-3.1x faster** | Unified 3D, better parallelism | +| Interpolation | **1.2-3.6x faster** | Direct computation vs intermediates | +| Reductions | **1.6-5.1x faster** | Zero-alloc SIMD single-pass | +| Cumulative | **3.1-97x faster** | Blocked scan, scalar accumulator | +| Gather/scatter | **1.6-9.8x faster** | Direct indexing | +| Unary | **1.1-2.7x faster** | In-place mutation when possible | +| Comparisons | **2.1-3.9x faster** | SIMD + compact u8 output | +| Int cast | **5.0-7.6x faster** | Direct byte reinterpretation | +| Quantize | **1.6x faster** | Fused 2-pass implementation | +| Concatenation | **3.6-16.3x faster** | Direct memcpy vs slice_assign | + +### Structural Improvements + +These reflect changes in how operations are _represented and executed_, not pure compute speedups. +burn-ndarray eagerly materializes data where burn-flex uses zero-copy views or separated storage. + +| Category | Improvement | What changed | +| ------------- | ------------------ | ------------------------------------------------------------ | +| Dequantize | **135-232x** | Direct `scale * x_q` vs reparsing `QuantizedBytes` each call | +| Quantized ops | **2.9-117x** | Dominated by fast dequantize path | +| Slice/narrow | **2.1-2,100x** | Zero-copy strided view vs potential data copy | +| Unfold | **1,200-166,000x** | O(1) strided view vs O(n) full materialization | +| Expand | **550-2,600x** | Zero-copy broadcast (stride=0) vs data copy | + +> **Note on quantization**: burn-ndarray simulates quantization by dequantizing to f32 for most +> operations. The quantized speedups reflect the difference between simulated and native execution, +> not equivalent algorithms running at different speeds. + +### Where NdArray Wins + +| Category | NdArray advantage | Reason | +| -------------------------- | ----------------- | ------------------------------------------- | +| bool_not/bool_and | ~20% faster | ndarray's vectorized mapv is well-optimized | +| int_powf_scalar | ~10% faster | ndarray's vectorized internals | +| Transposed i64 add (large) | ~7% faster | ndarray handles non-contiguous well | +| Deform conv (medium) | ~30% faster | NdArray has optimized deform conv path | +| Max pool 5x5 | ~17% faster | Specific kernel size advantage | + +These are specific edge cases where NdArray's ndarray-based internals have an advantage. + +--- + +## 16. Why Replace burn-ndarray? + +The [ndarray](https://crates.io/crates/ndarray) crate has been slow to accept contributions and +evolve. Burn's CPU backend inherits these constraints: + +- **No f16/bf16**: Models using half-precision weights must convert to f32. An f16 PR has been open + for a long time with no clear timeline. +- **6-dimension limit**: Hard-coded in reshape macros, cannot be fixed without upstream changes. +- **Unsigned strides**: `usize`-only strides make zero-copy flip impossible. +- **Simulated quantization**: No native quantized storage; dequantize/requantize on every op. +- **COW limitations**: `NdArrayStorage::Borrowed` always returns false for `is_unique()`, preventing + in-place mutation of externally loaded data. + +burn-flex was built to address these gaps without waiting on upstream. It is not intended to compete +with CubeCL CPU, which targets high-performance computation through operator fusion and just-in-time +compilation via LLVM. The goal is to provide a lightweight, portable replacement for burn-ndarray +that works today on platforms CubeCL CPU cannot target (no_std, WASM, embedded). + +## 17. What burn-flex Adds + +1. **f16/bf16 support**: Native arithmetic on half-precision types. Enables running models that use + f16 weights without conversion. + +2. **No dimension limit**: Arbitrary tensor rank (ndarray is limited to 6). + +3. **Zero-copy flip/unfold/expand**: Signed strides enable O(1) flip. Unfold returns a strided view + instead of materializing all windows. + +4. **Unified 3D conv/pool**: Single implementation covers 1D/2D/3D, reducing code paths and + potential for inconsistencies. + +5. **Native quantization**: Stores scales separately for direct `scale * x_q` dequantization instead + of reparsing packed bytes on every access. Zero-copy layout ops on quantized tensors. + +6. **Fewer dependencies**: 7 required deps vs 12. No ndarray, no matrixmultiply, no paste, no + const-random, no BLAS bindings. + +7. **Simpler type system**: `Flex` vs `NdArray`. No generic parameters, no element trait + hierarchy (`FloatNdArrayElement`, `IntNdArrayElement`, `NdArrayElement`, `ExpElement`). + +8. **Real FFT**: Forward (rfft) and inverse (irfft) real FFT with complex packing, SIMD + butterflies, and compile-time twiddle tables. Works in `no_std` (rustfft/realfft require std). + NdArray implements neither. + +--- + +## 18. What burn-ndarray Has That burn-flex Does Not + +1. **BLAS acceleration**: Feature flags for Accelerate (macOS), OpenBLAS, and Netlib BLAS. These can + outperform gemm for very large matmuls on specific hardware. burn-flex relies solely on the gemm + crate. + +2. **SIMD conv/pool kernels**: burn-ndarray has dedicated macerator-based SIMD kernels for + convolution and pooling. burn-flex delegates to gemm's SIMD. + +3. **export_tests feature**: burn-ndarray serves as a reference implementation for some burn-cubecl + kernels via `export_tests`. + +--- + +## 19. Migration Path + +For Burn users switching from burn-ndarray to burn-flex: + +| Change | Details | +| -------------- | -------------------------------------------------- | +| Type parameter | `NdArray` becomes `Flex` | +| Device | `NdArrayDevice::Cpu` becomes `FlexDevice` | +| Feature flags | `multi-threads` becomes `rayon` | +| BLAS features | No equivalent (gemm handles matmul) | +| Autodiff | Use `burn_autodiff::Autodiff` (same pattern) | +| f16/bf16 | Works out of the box (new capability) | +| Quantization | Same API, faster execution | +| Tests | Same burn-backend-tests suite passes | + +--- + +## 20. Conclusion + +burn-flex is a from-scratch replacement for burn-ndarray, motivated by ndarray's lack of f16/bf16 +support, 6-dimension limit, simulated quantization, and slow pace of upstream development. It +implements all required Backend traits (FloatTensorOps, IntTensorOps, BoolTensorOps, QTensorOps, +ModuleOps, ActivationOps, TransactionOps) and passes the same test suite. + +Performance gains come in two forms: compute improvements (1.1-9.7x) from better libraries and +algorithms, and structural improvements (up to 166,000x) from representing operations as zero-copy +views instead of eagerly materializing data. Memory usage is significantly reduced through Arc-based +COW and in-place mutation. + +The only capabilities lost are optional BLAS acceleration (replaced by the gemm crate, which is +faster in most benchmarks) and the `export_tests` reference implementation feature. diff --git a/crates/burn-flex/Cargo.toml b/crates/burn-flex/Cargo.toml new file mode 100644 index 0000000..117dbfa --- /dev/null +++ b/crates/burn-flex/Cargo.toml @@ -0,0 +1,81 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +categories = ["science", "mathematics", "no-std"] +description = "A fast, portable CPU backend for the Burn framework" +documentation = "https://docs.rs/burn-flex" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "simd"] +license.workspace = true +name = "burn-flex" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-flex" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "simd", "rayon"] +doc = ["default"] +std = [ + "gemm/std", + "burn-backend/std", + "burn-std/std", + "burn-ir/std", + "half/std", + "num-traits/std", +] +simd = ["dep:macerator", "dep:aligned-vec", "gemm/wasm-simd128-enable"] +rayon = ["dep:rayon", "gemm/rayon"] +# Opt-in gemm codegen paths. Not enabled by default. +# AVX-512 kernels for x86_64 (Sapphire Rapids, Zen 4/5, etc.). +x86-v4 = ["gemm/x86-v4"] +# Apple AMX matrix coprocessor (M-series). Experimental upstream; requires std. +apple-amx = ["std", "gemm/experimental-apple-amx"] +tracing = ["burn-std/tracing", "burn-backend/tracing", "burn-ir/tracing"] +critical-section = [ + "dep:once_cell", + "once_cell/critical-section", + "dep:portable-atomic", + "portable-atomic/critical-section", + "dep:portable-atomic-util", +] + +[dependencies] + +# ** Please make sure all dependencies support no_std when std is disabled ** + +burn-backend = { workspace = true } +burn-ir = { workspace = true } +burn-std = { workspace = true } + +bytemuck = { workspace = true } +half = { workspace = true } +# libm for `erf` (f32/f64). num-traits does not expose erf, and Rust's +# f64/f32 have no builtin erf method even with `std`. libm is already +# in the dep graph transitively via num-traits, so listing it here +# doesn't add a new dependency. +libm = { workspace = true } +num-traits = { workspace = true } + +# Matmul +gemm = { workspace = true, features = ["f16"] } + +# SIMD +aligned-vec = { workspace = true, optional = true } +macerator = { workspace = true, optional = true } + +# Parallel +rayon = { workspace = true, optional = true } + +# no_std targets without atomic CAS (enabled via `critical-section` feature) +once_cell = { workspace = true, optional = true } +portable-atomic = { workspace = true, optional = true } +portable-atomic-util = { workspace = true, optional = true } + +[dev-dependencies] +realfft = { workspace = true } + +[package.metadata.docs.rs] +features = ["default"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-flex/README.md b/crates/burn-flex/README.md new file mode 100644 index 0000000..4c03d9a --- /dev/null +++ b/crates/burn-flex/README.md @@ -0,0 +1,187 @@ +# burn-flex + +A fast, memory-efficient CPU backend for Burn with multi-threading, SIMD, and optimized matrix +multiplication. Runs on std, no_std, and WebAssembly. Supports f16/bf16, zero-copy data loading, and +is thread-safe by design. + +> **[Detailed comparison with burn-ndarray](./COMPARISON.md)**: Full architecture, feature coverage, +> operation-by-operation analysis, and migration path. + +### Features + +- **Zero-Copy Operations**: Many operations return strided views without copying data: + - `transpose`, `permute`, `flip`, `narrow`, `slice` + - `unfold` (sliding windows as strided view instead of materialization) + - `expand` (broadcast via zero strides) +- **Arc-based Copy-on-Write**: O(1) tensor cloning with automatic COW semantics. In-place mutation + when uniquely owned. +- **Convolutions**: Unified 3D implementation with im2col + gemm. Conv1d/2d delegate to conv3d. + Direct conv path for small spatial 1D convs (decomposes into kw gemm calls on NCHW data, skipping + NHWC conversion and im2col). 1x1 pointwise fast path. Supports groups, dilation, padding. +- **Attention**: Auto-selecting between two gemm-backed strategies based on sequence length. Short + sequences (score matrix seq_q \* seq_kv <= 256K elements) use naive attention with two large gemm + calls for lower overhead. Larger shapes use tiled flash attention with online softmax for + O(TILE_KV) memory per row. Both support causal masking, additive bias (ALiBi), softcap, custom + scale, and cross-attention. +- **Pooling**: Max pool, avg pool, adaptive avg pool. All via unified 3D with backward pass support. +- **Conv Transpose**: Scatter-based transposed convolutions for upsampling. +- **Portable SIMD**: Uses [macerator](https://crates.io/crates/macerator) for automatic dispatch: + - aarch64: NEON + - x86_64: AVX2, AVX512, SSE + - wasm32: SIMD128 + - Embedded/other: Scalar fallback +- **Matrix Multiplication**: Optimized via [gemm](https://crates.io/crates/gemm) with native f16 + support. Strided batched matmul passes layout strides directly to gemm, so transposed views (e.g. + `q.matmul(k.swap_dims(-2,-1))`) run at contiguous speed with no copy. +- **FFT**: Forward (rfft) and inverse (irfft) real FFT via Cooley-Tukey with complex packing, + compile-time twiddle tables, SIMD butterflies, and unrolled small kernels. Works in `no_std`. +- **Parallel Execution**: Optional rayon for large tensors +- **Quantization**: Full quantize/dequantize support with per-tensor and per-block symmetric + schemes. All ~40 quantized ops (arithmetic, trig, reductions, sorting, etc.) work out of the box. + Layout ops on quantized tensors (permute, flip, expand, slice, select) are zero-copy. Stores + scales separately for direct `scale * x_q` dequantization instead of reparsing packed bytes. +- **Dtype Support**: f32, f64, f16 (native), bf16 (via f32 conversion), i8-i64, u8-u64 +- **Built on Burn**: Leverages Burn's native infrastructure (`Bytes`, `Shape`, `TensorData`, + `Element` trait) from burn-backend and burn-std + +### Feature Flags + +Default: `std`, `simd`, `rayon`. + +| Flag | Default | Description | +| ------------------- | ------- | ------------------------------------------------------------------- | +| `std` | Yes | Standard library support | +| `simd` | Yes | Portable SIMD via macerator; also enables `gemm/wasm-simd128-enable`| +| `rayon` | Yes | Parallel execution for large tensors (forwards `gemm/rayon`) | +| `x86-v4` | No | AVX-512 kernels in gemm for x86_64 (Sapphire Rapids, Zen 4/5) | +| `apple-amx` | No | Apple Silicon AMX matrix coprocessor in gemm (experimental) | +| `tracing` | No | Propagate `tracing` instrumentation | +| `critical-section` | No | Support for no_std targets without atomic CAS | + +Enable opt-in paths by passing them to Cargo, either directly on `burn-flex` or through the +top-level `burn` crate: + +```toml +# Direct +burn-flex = { version = "0.21", features = ["apple-amx"] } + +# Via burn +burn = { version = "0.21", features = ["flex", "apple-amx"] } +``` + +See [ARCHITECTURE.md#feature-flags](./ARCHITECTURE.md#feature-flags) for per-case benchmark impact. + +### Why replace burn-ndarray? + +burn-ndarray depends on the [ndarray](https://crates.io/crates/ndarray) crate, which has been slow +to accept contributions and evolve. + +burn-flex was built as a from-scratch replacement that addresses the gaps while maintaining full +compatibility with Burn's backend test suite. + +### Performance vs burn-ndarray (Apple M3 Max) + +#### Compute Performance + +burn-ndarray now uses macerator SIMD for f32 elementwise ops +([tracel-ai/burn#2851](https://github.com/tracel-ai/burn/pull/2851)), so contiguous f32 binary/unary +ops are at parity. Flex advantages come from gemm, integer ops (i32 vs i64), structural zero-copy, +and fused kernels. + +| Category | Speedup | Highlights | +| ----------------- | ------------ | --------------------------------------- | +| Binary ops (f32) | **~1x** | Both use macerator SIMD for f32 | +| Binary ops (i32) | **1.8-5.3x** | Flex uses i32, NdArray uses i64 | +| Matmul (square) | **1.4-3.1x** | gemm at small/large; tied at mid-sizes | +| Matmul (batched) | **1.3-2.2x** | Multi-head attention shapes | +| Matmul (int) | **3.7-6.5x** | gemm vs matrixmultiply for integers | +| Conv2d (3x3) | **1.1-3.7x** | Larger kernels and batches benefit most | +| Conv1d | **4.3-9.8x** | | +| Conv transpose | **9.2-84x** | Direct scatter vs im2col | +| Attention | **1.2-3.0x** | Fused softmax, 2-8x lower peak memory | +| Pooling | **1.1-3.1x** | | +| Interpolation | **1.1-6.3x** | Nearest 4-6x, bilinear 1.7-2.8x | +| Reductions | **1.3-5.4x** | Near-zero allocation for scalar results | +| Cumulative ops | **2.1-95x** | 1D cumsum: 95x faster | +| Gather/scatter | **1.2-6.4x** | | +| Unary (tanh, sin) | **1.3-2.0x** | tanh 2x, sin/cos 1.3-1.5x | +| Sort | **2.3-29x** | 2D sort up to 29x | +| Repeat dim | **8.9-12x** | Single alloc + memcpy vs N slice_assign | +| Tensor creation | **16-33x** | zeros/ones/full | +| Embedding | **4.8-5.8x** | | +| Quantize | **1.3-1.5x** | Fused 2-pass implementation | +| FFT (rfft/irfft) | **yes** | Native implementation, works in no_std | + +#### Structural Improvements + +These reflect better _operation representation_, not faster computation. burn-ndarray eagerly +materializes data for these operations; burn-flex avoids the work entirely through zero-copy views +and separated storage layouts. + +| Category | Improvement | What changed | +| ------------- | ---------------- | ------------------------------------------------------------ | +| Dequantize | **122-238x** | Direct `scale * x_q` vs reparsing `QuantizedBytes` each call | +| Quantized ops | **6.1-125x** | Dominated by fast dequantize path above | +| Slice/narrow | **2.1-2,400x** | Zero-copy strided view vs data copy | +| Unfold | **920-130,000x** | O(1) strided view vs O(n) full materialization | +| Expand | **620-2,800x** | Zero-copy broadcast (stride=0) vs data copy | +| Int cast | **6.3-26,000x** | Zero-copy reinterpret vs element-wise conversion | + +> **Note on quantization**: burn-ndarray simulates quantization by dequantizing to f32 for most +> operations. The quantized speedups reflect the difference between simulated and native execution, +> not equivalent algorithms running at different speeds. + +See [BENCHMARKS.md](./BENCHMARKS.md) for the full breakdown. + +### Performance vs candle-core (Apple M3 Max, pure-Rust, no BLAS) + +Per-op comparison against [candle-core](https://github.com/huggingface/candle), pure-Rust on both +sides. Across 11 bench files covering every flex op that intersects with candle's CPU API, flex is +as fast or faster on every operation category. + +| Category | Representative ratio | Notes | +| ----------------------------------- | -------------------- | -------------------------------- | +| Batched matmul | **8-11x** | Strided gemm, no copy | +| Conv1d (wav2vec2) | **1.4-7.6x** | Direct conv path | +| Conv2d (ResNet) | **1.3-4.0x** | 1x1 pointwise 4x | +| Conv transpose | **1.5-1.9x** | | +| Max/min reductions | **3.8-5.1x** | SIMD + zero-alloc | +| Pooling (k=3 s=2) | **1.8-2.5x** | | +| Layer norm (fused) | **1.6-3.4x** | Two-pass Welford kernel | +| Softmax (fused) | **1.4-1.7x** | Three-pass row kernel | +| Cat, gather, select | **1.3-2.5x** | | +| Nearest2d interpolation | **1.3-1.4x** | | +| Elementwise, matmul, gelu, view ops | tied | Both at memory bandwidth ceiling | + +### Status + +- All `burn-backend-tests` pass across all feature flag combinations: + - `no-default-features` (no_std, no SIMD, no rayon) + - `no-default-features + simd` (no_std with SIMD) + - `std` + - `std + simd` + - `std + rayon` + - `std + simd + rayon` (default) +- Burn's `burn-no-std-tests` integration suite passes (MNIST model inference in `#![no_std]`) +- Builds for embedded and WebAssembly targets: + - `thumbv6m-none-eabi` (ARM Cortex-M0+, no atomic pointers) + - `thumbv7m-none-eabi` (ARM Cortex-M3) + - `wasm32-unknown-unknown` +- Passes [Miri](https://github.com/rust-lang/miri) (undefined behavior detector) on all burn-flex + code (quantization tests skipped due to upstream UB in burn-std). Validates memory safety of + unsafe pointer arithmetic, bytemuck casts, and Send/Sync implementations. +- Tested for edge-case robustness: integer overflow at type boundaries, large-float rounding, + invalid pooling parameters, zero-sized dimensions. Safe for embedded devices. +- All ONNX model checks in `burn-onnx` pass +- Real model inference verified: + - [ALBERT](https://huggingface.co/albert/albert-base-v2) (masked language model, all v2 variants) + - [MiniLM](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) (sentence embeddings, L6 + and L12) + +### Documentation + +- [COMPARISON.md](./COMPARISON.md) - Comprehensive comparison with burn-ndarray +- [ARCHITECTURE.md](./ARCHITECTURE.md) - Design decisions, memory strategy, and implementation + patterns +- [BENCHMARKS.md](./BENCHMARKS.md) - Full benchmark results (Flex vs NdArray) +- [ACKNOWLEDGMENTS.md](./ACKNOWLEDGMENTS.md) - Projects that influenced burn-flex diff --git a/crates/burn-flex/src/backend.rs b/crates/burn-flex/src/backend.rs new file mode 100644 index 0000000..16ebc9f --- /dev/null +++ b/crates/burn-flex/src/backend.rs @@ -0,0 +1,311 @@ +use alloc::string::String; +use burn_std::{BoolStore, DeviceSettings, QuantConfig, QuantScheme, QuantStore}; + +use burn_backend::{Backend, BackendTypes, DType, DTypeUsage, DTypeUsageSet, DeviceId, DeviceOps}; +use burn_ir::{BackendIr, HandleKind, TensorHandle}; +use burn_std::device::Device; +use burn_std::rand::{SeedableRng, StdRng}; +use burn_std::stub::Mutex; + +use crate::qtensor::FlexQTensor; +use crate::tensor::FlexTensor; + +/// Type alias for the RNG used by Flex. +pub type FlexRng = StdRng; + +/// Global seed storage for reproducible random number generation. +/// Uses Mutex for thread-safe RNG state management. +pub(crate) static SEED: Mutex> = Mutex::new(None); + +/// Fallback RNG when `SEED` is empty (consumed or never set). +/// +/// The seeding flow is: `Backend::seed()` stores a `FlexRng` in `SEED`. Random +/// ops (`float_random`, `int_random`) call `SEED.lock().take()`, consuming it for +/// that op and falling back to this function for subsequent calls. This function +/// delegates to burn_std's own entropy source. +pub(crate) fn get_seeded_rng() -> FlexRng { + burn_std::rand::get_seeded_rng() +} + +/// CPU device for the Flex backend. +/// +/// Unit struct since there's only one CPU device. +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct FlexDevice; + +impl Device for FlexDevice { + fn to_id(&self) -> DeviceId { + DeviceId::new(0, 0) + } + + fn from_id(_id: DeviceId) -> Self { + Self + } +} + +impl DeviceOps for FlexDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I32, + DType::Bool(BoolStore::Native), + QuantConfig::new( + QuantScheme::default().with_store(QuantStore::Native), + Default::default(), + ), + ) + } +} + +impl core::fmt::Display for FlexDevice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Cpu") + } +} + +impl core::fmt::Debug for FlexDevice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + core::fmt::Display::fmt(self, f) + } +} + +/// The Flex backend, a fast, portable CPU backend for Burn. +/// +/// The `E` and `I` type parameters exist purely to match the shape of other Burn +/// backends (e.g. `NdArray`) so `Flex` slots into `burn-dispatch`'s +/// generic dispatch macros. The body of `Flex` uses runtime `DType` dispatch, so +/// both parameters are phantom and unused at runtime. +/// +/// # Limitations of the phantom generics +/// +/// The `Backend` impl is provided only for the default instantiation +/// `Flex`. Writing `Flex` (with no arguments) resolves to the default +/// and works exactly as before. Writing `Flex` or any other non-default +/// combination is a valid Rust type but will not satisfy trait bounds requiring +/// `Backend`, producing errors like: +/// +/// ```text +/// the trait bound `Flex: Backend` is not satisfied +/// ``` +/// +/// This is a deliberate compromise for the initial migration: making `Flex` +/// generic over element types at the trait-impl level is a follow-up that would +/// require rewriting all `impl FooOps for Flex` blocks plus internal +/// `Flex::method()` calls (tracked in +/// [#4762](https://github.com/tracel-ai/burn/issues/4762)). Until then, treat +/// the generic parameters as opaque shape placeholders; real element-type +/// selection happens at runtime via `DType`. +/// +/// The bound is locked in by a compile-fail doctest so that if someone later +/// makes the `Backend` impl generic over `E`/`I`, this documentation gets +/// flagged as out of date: +/// +/// ```compile_fail +/// use burn_backend::Backend; +/// use burn_flex::Flex; +/// fn requires_backend() {} +/// requires_backend::>(); +/// ``` +#[derive(Clone, Copy, Debug, Default)] +pub struct Flex {} + +impl BackendTypes for Flex { + type Device = FlexDevice; + + type FloatTensorPrimitive = FlexTensor; + type IntTensorPrimitive = FlexTensor; + type BoolTensorPrimitive = FlexTensor; + type QuantizedTensorPrimitive = FlexQTensor; + + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for Flex { + fn name(_device: &Self::Device) -> String { + "flex".into() + } + + fn seed(_device: &Self::Device, seed: u64) { + let rng = FlexRng::seed_from_u64(seed); + let mut seed_lock = SEED.lock().unwrap(); + *seed_lock = Some(rng); + } + + fn device_count(_type_id: u16) -> usize { + 1 + } + + fn dtype_usage(_device: &Self::Device, dtype: DType) -> DTypeUsageSet { + match dtype { + // Full support for standard types + DType::F64 | DType::F32 | DType::F16 | DType::BF16 => { + DTypeUsage::Storage | DTypeUsage::Arithmetic + } + DType::I64 | DType::I32 | DType::I16 | DType::I8 => { + DTypeUsage::Storage | DTypeUsage::Arithmetic + } + DType::U64 | DType::U32 | DType::U16 | DType::U8 => { + DTypeUsage::Storage | DTypeUsage::Arithmetic + } + // Bool storage: flex stores bools as 1 byte per element, so Native and + // U8 are both supported (they share the same layout, only the tag + // differs). Bool(U32) would require 4-byte-per-element storage + // throughout the backend and is not yet implemented. + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) => { + DTypeUsage::Storage | DTypeUsage::Arithmetic + } + DType::Bool(burn_std::BoolStore::U32) => DTypeUsageSet::empty(), + // Quantized types: storage only for now + DType::QFloat(_) => DTypeUsage::Storage.into(), + _ => DTypeUsageSet::empty(), + } + } + + fn flush(_device: &Self::Device) {} +} + +impl BackendIr for Flex { + type Handle = HandleKind; + + fn float_tensor(handle: TensorHandle) -> FlexTensor { + match handle.handle { + HandleKind::Float(t) => t, + _ => panic!("Expected float handle, got {}", handle.handle.name()), + } + } + + fn int_tensor(handle: TensorHandle) -> FlexTensor { + match handle.handle { + HandleKind::Int(t) => t, + _ => panic!("Expected int handle, got {}", handle.handle.name()), + } + } + + fn bool_tensor(handle: TensorHandle) -> FlexTensor { + match handle.handle { + HandleKind::Bool(t) => t, + _ => panic!("Expected bool handle, got {}", handle.handle.name()), + } + } + + fn quantized_tensor(handle: TensorHandle) -> FlexQTensor { + match handle.handle { + HandleKind::Quantized(t) => t, + _ => panic!("Expected quantized handle, got {}", handle.handle.name()), + } + } + + fn float_tensor_handle(tensor: FlexTensor) -> Self::Handle { + HandleKind::Float(tensor) + } + + fn int_tensor_handle(tensor: FlexTensor) -> Self::Handle { + HandleKind::Int(tensor) + } + + fn bool_tensor_handle(tensor: FlexTensor) -> Self::Handle { + HandleKind::Bool(tensor) + } + + fn quantized_tensor_handle(tensor: FlexQTensor) -> Self::Handle { + HandleKind::Quantized(tensor) + } +} + +// Ops traits are implemented in the ops module + +#[cfg(test)] +mod tests { + use burn_backend::{Backend, DType}; + use burn_std::BoolStore; + + use super::*; + + #[test] + fn supports_bool_native() { + let device = FlexDevice; + assert!(Flex::supports_dtype( + &device, + DType::Bool(BoolStore::Native) + )); + } + + #[test] + fn supports_bool_u8() { + let device = FlexDevice; + assert!(Flex::supports_dtype(&device, DType::Bool(BoolStore::U8))); + } + + #[test] + fn does_not_support_bool_u32() { + let device = FlexDevice; + assert!( + !Flex::supports_dtype(&device, DType::Bool(BoolStore::U32)), + "Bool(U32) should not be supported: flex stores bools as 1 byte per element" + ); + } + + #[test] + fn bool_empty_preserves_native_dtype() { + use burn_backend::ops::BoolTensorOps; + let shape = burn_std::Shape::from(alloc::vec![3]); + let t = Flex::bool_empty(shape, &FlexDevice, burn_std::BoolDType::Native); + assert_eq!(t.dtype(), DType::Bool(BoolStore::Native)); + } + + #[test] + fn bool_empty_preserves_u8_dtype() { + use burn_backend::ops::BoolTensorOps; + let shape = burn_std::Shape::from(alloc::vec![3]); + let t = Flex::bool_empty(shape, &FlexDevice, burn_std::BoolDType::U8); + assert_eq!(t.dtype(), DType::Bool(BoolStore::U8)); + } + + #[test] + fn device_prints_as_cpu() { + use alloc::format; + assert_eq!(format!("{:?}", FlexDevice), "Cpu"); + assert_eq!(format!("{}", FlexDevice), "Cpu"); + } + + #[test] + fn comparison_preserves_out_dtype_native() { + let lhs = FlexTensor::from_data(burn_backend::TensorData::from([1.0f32, 2.0, 3.0])); + let rhs = FlexTensor::from_data(burn_backend::TensorData::from([2.0f32, 2.0, 1.0])); + let result = crate::ops::comparison::greater(lhs, rhs, burn_std::BoolDType::Native); + assert_eq!(result.dtype(), DType::Bool(BoolStore::Native)); + } + + #[test] + fn comparison_preserves_out_dtype_u8() { + let lhs = FlexTensor::from_data(burn_backend::TensorData::from([1.0f32, 2.0, 3.0])); + let rhs = FlexTensor::from_data(burn_backend::TensorData::from([2.0f32, 2.0, 1.0])); + let result = crate::ops::comparison::greater(lhs, rhs, burn_std::BoolDType::U8); + assert_eq!(result.dtype(), DType::Bool(BoolStore::U8)); + } + + #[test] + #[should_panic(expected = "Bool(U32)")] + fn comparison_u32_panics() { + let lhs = FlexTensor::from_data(burn_backend::TensorData::from([1.0f32, 2.0])); + let rhs = FlexTensor::from_data(burn_backend::TensorData::from([2.0f32, 1.0])); + let _ = crate::ops::comparison::greater(lhs, rhs, burn_std::BoolDType::U32); + } + + #[test] + fn bool_not_preserves_u8_dtype() { + use burn_backend::ops::BoolTensorOps; + // Construct a Bool(U8) tensor directly to verify bool_not preserves + // the dtype tag across the op. from_data would produce Bool(Native), + // so we use make_bool_tensor to get the U8 tag. + let t_u8 = crate::ops::comparison::make_bool_tensor( + alloc::vec![1, 0, 1], + burn_std::Shape::from(alloc::vec![3]), + burn_std::BoolDType::U8, + ); + let result = Flex::bool_not(t_u8); + assert_eq!(result.dtype(), DType::Bool(BoolStore::U8)); + let data: &[u8] = result.bytes(); + assert_eq!(&data[..3], &[0, 1, 0]); + } +} diff --git a/crates/burn-flex/src/layout.rs b/crates/burn-flex/src/layout.rs new file mode 100644 index 0000000..cb3a0aa --- /dev/null +++ b/crates/burn-flex/src/layout.rs @@ -0,0 +1,660 @@ +use alloc::vec; +use alloc::vec::Vec; +use burn_std::{Shape, Slice}; + +/// Layout describes how to interpret a linear buffer as an N-dimensional tensor. +/// +/// Stores shape, strides (in elements, can be negative for flipped dimensions), +/// and a start offset for views/slices. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + shape: Shape, + /// Strides in elements. Negative strides enable zero-copy flip. + strides: Vec, + start_offset: usize, +} + +/// Compute row-major contiguous strides for a shape (as `usize`). +pub(crate) fn contiguous_strides_usize(shape: &Shape) -> Vec { + let ndims = shape.num_dims(); + let mut strides = vec![1usize; ndims]; + for i in (0..ndims.saturating_sub(1)).rev() { + strides[i] = strides[i + 1] * shape[i + 1]; + } + strides +} + +/// Compute the flat offset for the `slice_idx`-th 1D fiber along `dim`. +/// +/// Enumerates all index combinations for dimensions other than `dim`, +/// mapping the flat `slice_idx` (0..product of non-dim sizes) to the +/// corresponding starting offset in a contiguous buffer. +pub(crate) fn slice_base_offset( + slice_idx: usize, + shape: &Shape, + strides: &[usize], + dim: usize, +) -> usize { + let ndims = shape.num_dims(); + let mut offset = 0; + let mut remaining = slice_idx; + for d in (0..ndims).rev() { + if d == dim { + continue; + } + let s = shape[d]; + offset += (remaining % s) * strides[d]; + remaining /= s; + } + offset +} + +impl Layout { + /// Create a new contiguous layout (row-major/C-order). + pub fn contiguous(shape: Shape) -> Self { + let strides: Vec = contiguous_strides_usize(&shape) + .into_iter() + .map(|s| s as isize) + .collect(); + + Self { + shape, + strides, + start_offset: 0, + } + } + + /// Create a layout with explicit strides. + pub fn new(shape: Shape, strides: Vec, start_offset: usize) -> Self { + debug_assert_eq!(shape.num_dims(), strides.len()); + Self { + shape, + strides, + start_offset, + } + } + + /// The shape of the tensor. + pub fn shape(&self) -> &Shape { + &self.shape + } + + /// The strides in elements (can be negative for flipped dimensions). + pub fn strides(&self) -> &[isize] { + &self.strides + } + + /// The start offset for views/slices. + pub fn start_offset(&self) -> usize { + self.start_offset + } + + /// Number of dimensions. + pub fn num_dims(&self) -> usize { + self.shape.num_dims() + } + + /// Total number of elements. + pub fn num_elements(&self) -> usize { + self.shape.num_elements() + } + + /// Check if this layout is contiguous (row-major, positive strides). + pub fn is_contiguous(&self) -> bool { + if self.shape.num_dims() == 0 { + return true; + } + + let mut expected_stride = 1isize; + for i in (0..self.shape.num_dims()).rev() { + if self.strides[i] != expected_stride { + return false; + } + expected_stride *= self.shape[i] as isize; + } + true + } + + /// If contiguous, return (start, end) offsets for direct slice access. + pub fn contiguous_offsets(&self) -> Option<(usize, usize)> { + if self.is_contiguous() { + Some((self.start_offset, self.start_offset + self.num_elements())) + } else { + None + } + } + + /// Transpose: swap two dimensions (zero-copy, metadata only). + pub fn transpose(&self, dim1: usize, dim2: usize) -> Self { + let mut dims = self.shape.to_vec(); + let mut strides = self.strides.clone(); + dims.swap(dim1, dim2); + strides.swap(dim1, dim2); + Self { + shape: Shape::from(dims), + strides, + start_offset: self.start_offset, + } + } + + /// Permute: reorder dimensions according to axes (zero-copy, metadata only). + /// + /// `axes` must be a permutation of 0..ndim. + pub fn permute(&self, axes: &[usize]) -> Self { + debug_assert_eq!( + axes.len(), + self.num_dims(), + "permute: axes length must match number of dimensions" + ); + + let new_dims: Vec = axes.iter().map(|&i| self.shape[i]).collect(); + let new_strides: Vec = axes.iter().map(|&i| self.strides[i]).collect(); + + Self { + shape: Shape::from(new_dims), + strides: new_strides, + start_offset: self.start_offset, + } + } + + /// Flip: reverse elements along specified axes (zero-copy, metadata only). + /// + /// For each flipped axis, negates the stride and adjusts start_offset + /// to point to the last element along that dimension. + pub fn flip(&self, axes: &[usize]) -> Self { + let mut new_strides = self.strides.clone(); + let mut offset_adjustment: isize = 0; + + for &axis in axes { + debug_assert!( + axis < self.num_dims(), + "flip: axis {} out of bounds for {} dimensions", + axis, + self.num_dims() + ); + + let dim_size = self.shape[axis]; + if dim_size > 1 { + // Move start to last element along this axis + offset_adjustment += (dim_size as isize - 1) * self.strides[axis]; + // Negate stride to iterate backwards + new_strides[axis] = -new_strides[axis]; + } + } + + let new_start_isize = self.start_offset as isize + offset_adjustment; + debug_assert!(new_start_isize >= 0, "flip: negative offset"); + let new_start = new_start_isize as usize; + + Self { + shape: self.shape.clone(), + strides: new_strides, + start_offset: new_start, + } + } + + /// Narrow/slice along a dimension (zero-copy, metadata only). + pub fn narrow(&self, dim: usize, start: usize, len: usize) -> Self { + debug_assert!( + start + len <= self.shape[dim], + "narrow: start ({}) + len ({}) exceeds dimension size ({})", + start, + len, + self.shape[dim] + ); + let mut dims = self.shape.to_vec(); + dims[dim] = len; + + let new_offset_isize = self.start_offset as isize + self.strides[dim] * start as isize; + debug_assert!(new_offset_isize >= 0, "narrow: negative offset"); + let new_offset = new_offset_isize as usize; + + Self { + shape: Shape::from(dims), + strides: self.strides.clone(), + start_offset: new_offset, + } + } + + /// Apply slices to create a new layout. + /// + /// Returns `(new_layout, needs_copy)`: + /// - `needs_copy = false`: Can use zero-copy view with new layout + /// - `needs_copy = true`: Has negative steps requiring data reordering + pub fn slice(&self, slices: &[Slice]) -> (Self, bool) { + let ndims = self.num_dims(); + let mut new_dims = self.shape.to_vec(); + let mut new_strides = self.strides.clone(); + let mut new_offset = self.start_offset as isize; + let mut needs_copy = false; + + for (dim, slice) in slices.iter().enumerate() { + if dim >= ndims { + break; + } + + let dim_size = self.shape[dim] as isize; + let stride = self.strides[dim]; + + // Normalize start index (handle negative) + let start = if slice.start < 0 { + (dim_size + slice.start).max(0) as usize + } else { + (slice.start as usize).min(dim_size as usize) + }; + + // Normalize end index (handle negative and None) + // Note: Range [start, end) determines WHICH elements to select, + // step determines iteration ORDER + let end = match slice.end { + Some(e) if e < 0 => (dim_size + e).max(0) as usize, + Some(e) => (e as usize).min(dim_size as usize), + None => dim_size as usize, // Always full range when end is None + }; + + let step = slice.step; + let abs_step = step.unsigned_abs(); + + if step > 0 { + // Positive step: forward iteration + let len = if end > start { + (end - start).div_ceil(abs_step) + } else { + 0 + }; + new_dims[dim] = len; + new_strides[dim] = stride * step; + new_offset += stride * start as isize; + } else { + // Negative step: select range then iterate in reverse + // Requires copy to reorder elements + needs_copy = true; + let len = if end > start { + (end - start).div_ceil(abs_step) + } else { + 0 + }; + new_dims[dim] = len; + new_strides[dim] = stride; // Will be handled during copy + } + } + + debug_assert!(new_offset >= 0, "slice: negative offset"); + + ( + Self { + shape: Shape::from(new_dims), + strides: new_strides, + start_offset: new_offset as usize, + }, + needs_copy, + ) + } + + /// Reshape to a new shape. Only works if contiguous with zero offset. + /// + /// Returns None if not contiguous or has non-zero offset (would require data copy). + pub fn reshape(&self, new_shape: Shape) -> Option { + if !self.is_contiguous() || self.start_offset != 0 { + return None; + } + debug_assert_eq!( + self.num_elements(), + new_shape.num_elements(), + "reshape must preserve total elements" + ); + Some(Self::contiguous(new_shape)) + } + + /// Compute linear index from multi-dimensional indices. + pub fn index(&self, indices: &[usize]) -> usize { + debug_assert_eq!(indices.len(), self.num_dims()); + let mut offset = self.start_offset as isize; + for (i, &idx) in indices.iter().enumerate() { + offset += idx as isize * self.strides[i]; + } + debug_assert!(offset >= 0, "index: negative offset"); + offset as usize + } + + /// Get stride of the innermost (last) dimension. + /// Returns 1 for contiguous tensors, larger values for transposed. + /// Returns absolute value (ignores flip). + pub fn inner_stride(&self) -> usize { + self.strides.last().map(|s| s.unsigned_abs()).unwrap_or(1) + } + + /// Check if innermost dimension is contiguous (|stride| == 1). + /// This enables efficient vectorized inner loops. + pub fn has_contiguous_inner(&self) -> bool { + self.inner_stride() == 1 + } + + /// For 2D layouts, get (outer_size, inner_size, outer_stride, inner_stride). + /// Returns None if not 2D. + pub fn as_2d_strides(&self) -> Option<(usize, usize, isize, isize)> { + if self.num_dims() != 2 { + return None; + } + Some(( + self.shape[0], + self.shape[1], + self.strides[0], + self.strides[1], + )) + } + + /// Check if all strides are non-negative. + pub fn has_positive_strides(&self) -> bool { + self.strides.iter().all(|&s| s >= 0) + } + + /// Compute strided blocks for efficient iteration. + /// + /// Returns (block_len, num_blocks, block_stride) where: + /// - block_len: number of contiguous elements in each block + /// - num_blocks: total number of blocks + /// - block_stride: stride between consecutive blocks (0 if single block) + /// + /// For contiguous tensors: single block covering all elements. + /// For transposed/strided: multiple blocks of contiguous data. + pub fn strided_blocks(&self) -> StridedBlocks<'_> { + let n = self.num_elements(); + if n == 0 { + return StridedBlocks::Single { start: 0, len: 0 }; + } + + // Fast path: fully contiguous + if self.is_contiguous() { + return StridedBlocks::Single { + start: self.start_offset, + len: n, + }; + } + + // Find contiguous inner dimensions (only positive strides) + // Start from innermost and work outward while strides match contiguous pattern + let ndims = self.num_dims(); + let mut block_len = 1usize; + let mut expected_stride = 1isize; + + for i in (0..ndims).rev() { + if self.strides[i] == expected_stride { + block_len *= self.shape[i]; + expected_stride *= self.shape[i] as isize; + } else { + break; + } + } + + if block_len == n { + // All dimensions contiguous (just offset) + return StridedBlocks::Single { + start: self.start_offset, + len: n, + }; + } + + let num_blocks = n / block_len; + StridedBlocks::Multiple { + layout: self, + block_len, + num_blocks, + } + } +} + +/// Result of strided block analysis. +#[derive(Debug, Clone)] +pub enum StridedBlocks<'a> { + /// Single contiguous block - direct slice access. + Single { start: usize, len: usize }, + /// Multiple blocks requiring iteration. + Multiple { + layout: &'a Layout, + block_len: usize, + num_blocks: usize, + }, +} + +impl<'a> StridedBlocks<'a> { + /// Get the block length (elements per block). + pub fn block_len(&self) -> usize { + match self { + Self::Single { len, .. } => *len, + Self::Multiple { block_len, .. } => *block_len, + } + } + + /// Iterator over block start indices. + pub fn block_starts(&self) -> BlockStartIter<'_> { + match self { + Self::Single { start, .. } => BlockStartIter::Single { + start: *start, + done: false, + }, + Self::Multiple { + layout, + block_len, + num_blocks, + } => { + // Calculate dimensions for outer iteration (non-contiguous part) + let ndims = layout.num_dims(); + let mut outer_dims = 0; + let mut expected_stride = 1isize; + + for i in (0..ndims).rev() { + if layout.strides[i] == expected_stride { + expected_stride *= layout.shape[i] as isize; + } else { + outer_dims = i + 1; + break; + } + } + + BlockStartIter::Multiple { + layout, + multi_index: vec![0; outer_dims], + remaining: *num_blocks, + block_len: *block_len, + } + } + } + } +} + +/// Iterator over block start indices. +pub enum BlockStartIter<'a> { + Single { + start: usize, + done: bool, + }, + Multiple { + layout: &'a Layout, + multi_index: Vec, + remaining: usize, + block_len: usize, + }, +} + +impl Iterator for BlockStartIter<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + match self { + Self::Single { start, done } => { + if *done { + None + } else { + *done = true; + Some(*start) + } + } + Self::Multiple { + layout, + multi_index, + remaining, + block_len: _, + } => { + if *remaining == 0 { + return None; + } + + // Compute current block start + let outer_dims = multi_index.len(); + let mut offset = layout.start_offset as isize; + for (i, &idx) in multi_index.iter().enumerate() { + offset += idx as isize * layout.strides[i]; + } + + *remaining -= 1; + + // Advance multi-index for next iteration + let shape = &layout.shape; + for d in (0..outer_dims).rev() { + multi_index[d] += 1; + if multi_index[d] < shape[d] { + break; + } + multi_index[d] = 0; + } + + Some(offset as usize) + } + } + } + + fn size_hint(&self) -> (usize, Option) { + let len = match self { + Self::Single { done, .. } => { + if *done { + 0 + } else { + 1 + } + } + Self::Multiple { remaining, .. } => *remaining, + }; + (len, Some(len)) + } +} + +impl ExactSizeIterator for BlockStartIter<'_> {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contiguous_layout() { + let layout = Layout::contiguous(Shape::from(vec![2, 3, 4])); + assert_eq!(layout.strides(), &[12, 4, 1]); + assert!(layout.is_contiguous()); + } + + #[test] + fn test_transpose() { + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + let transposed = layout.transpose(0, 1); + assert_eq!(transposed.shape().to_vec(), vec![3, 2]); + assert_eq!(transposed.strides(), &[1, 3]); + assert!(!transposed.is_contiguous()); + } + + #[test] + fn test_narrow() { + let layout = Layout::contiguous(Shape::from(vec![4, 4])); + let narrowed = layout.narrow(0, 1, 2); + assert_eq!(narrowed.shape().to_vec(), vec![2, 4]); + assert_eq!(narrowed.start_offset(), 4); + } + + #[test] + fn test_contiguous_offsets() { + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + assert_eq!(layout.contiguous_offsets(), Some((0, 6))); + } + + #[test] + fn test_index() { + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + assert_eq!(layout.index(&[0, 0]), 0); + assert_eq!(layout.index(&[0, 2]), 2); + assert_eq!(layout.index(&[1, 0]), 3); + assert_eq!(layout.index(&[1, 2]), 5); + } + + #[test] + fn test_flip_1d() { + // Original: [0, 1, 2, 3] with strides [1] + // Flipped: strides [-1], start_offset = 3 + let layout = Layout::contiguous(Shape::from(vec![4])); + let flipped = layout.flip(&[0]); + + assert_eq!(flipped.shape().to_vec(), vec![4]); + assert_eq!(flipped.strides(), &[-1]); + assert_eq!(flipped.start_offset(), 3); + + // Verify indices: logical [0] -> physical [3], logical [1] -> physical [2], etc. + assert_eq!(flipped.index(&[0]), 3); + assert_eq!(flipped.index(&[1]), 2); + assert_eq!(flipped.index(&[2]), 1); + assert_eq!(flipped.index(&[3]), 0); + } + + #[test] + fn test_flip_2d_axis0() { + // [[0, 1, 2], [3, 4, 5]] with strides [3, 1] + // Flip axis 0: strides [-3, 1], start_offset = 3 + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + let flipped = layout.flip(&[0]); + + assert_eq!(flipped.strides(), &[-3, 1]); + assert_eq!(flipped.start_offset(), 3); + + // Row 0 of flipped = Row 1 of original + assert_eq!(flipped.index(&[0, 0]), 3); + assert_eq!(flipped.index(&[0, 1]), 4); + assert_eq!(flipped.index(&[0, 2]), 5); + // Row 1 of flipped = Row 0 of original + assert_eq!(flipped.index(&[1, 0]), 0); + assert_eq!(flipped.index(&[1, 1]), 1); + assert_eq!(flipped.index(&[1, 2]), 2); + } + + #[test] + fn test_flip_2d_axis1() { + // [[0, 1, 2], [3, 4, 5]] with strides [3, 1] + // Flip axis 1: strides [3, -1], start_offset = 2 + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + let flipped = layout.flip(&[1]); + + assert_eq!(flipped.strides(), &[3, -1]); + assert_eq!(flipped.start_offset(), 2); + + // Col 0 of flipped = Col 2 of original + assert_eq!(flipped.index(&[0, 0]), 2); + assert_eq!(flipped.index(&[0, 1]), 1); + assert_eq!(flipped.index(&[0, 2]), 0); + assert_eq!(flipped.index(&[1, 0]), 5); + assert_eq!(flipped.index(&[1, 1]), 4); + assert_eq!(flipped.index(&[1, 2]), 3); + } + + #[test] + fn test_flip_both_axes() { + // [[0, 1, 2], [3, 4, 5]] -> [[5, 4, 3], [2, 1, 0]] + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + let flipped = layout.flip(&[0, 1]); + + assert_eq!(flipped.strides(), &[-3, -1]); + assert_eq!(flipped.start_offset(), 5); // 3 + 2 = 5 + + assert_eq!(flipped.index(&[0, 0]), 5); + assert_eq!(flipped.index(&[0, 1]), 4); + assert_eq!(flipped.index(&[0, 2]), 3); + assert_eq!(flipped.index(&[1, 0]), 2); + assert_eq!(flipped.index(&[1, 1]), 1); + assert_eq!(flipped.index(&[1, 2]), 0); + } +} diff --git a/crates/burn-flex/src/lib.rs b/crates/burn-flex/src/lib.rs new file mode 100644 index 0000000..a909bd8 --- /dev/null +++ b/crates/burn-flex/src/lib.rs @@ -0,0 +1,47 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # burn-flex +//! +//! A fast, portable CPU backend for [Burn](https://github.com/tracel-ai/burn). +//! +//! ## Features +//! +//! - Pure Rust (no C dependencies) +//! - f16/bf16 support +//! - SIMD acceleration via macerator (NEON, AVX2/AVX-512/SSE, SIMD128, scalar fallback) +//! - Zero-copy tensor views +//! - Thread-safe by design +//! +//! ## Usage +//! +//! ```ignore +//! use burn_flex::Flex; +//! use burn::tensor::Tensor; +//! +//! let tensor: Tensor = Tensor::from_data([[1.0, 2.0], [3.0, 4.0]], &Default::default()); +//! ``` + +extern crate alloc; + +#[cfg(all(not(target_has_atomic = "ptr"), not(feature = "critical-section")))] +compile_error!( + "This target lacks atomic CAS support. Enable the `critical-section` feature: \ + burn-flex = { ..., features = [\"critical-section\"] }" +); + +mod backend; +mod layout; +mod qtensor; +mod strided_index; +mod tensor; + +#[doc(hidden)] +pub mod ops; + +#[doc(hidden)] +pub mod simd; + +pub use backend::{Flex, FlexDevice}; +pub use layout::Layout; +pub use qtensor::FlexQTensor; +pub use tensor::FlexTensor; diff --git a/crates/burn-flex/src/ops/activation.rs b/crates/burn-flex/src/ops/activation.rs new file mode 100644 index 0000000..2dd4e6f --- /dev/null +++ b/crates/burn-flex/src/ops/activation.rs @@ -0,0 +1,1464 @@ +//! Activation function operations for the Flex backend. +//! +//! Each activation is implemented as a single-pass unary operation, +//! replacing the default multi-op compositions from Burn's trait defaults. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::Scalar; +use burn_backend::ops::{ActivationOps, FloatTensorOps}; +use burn_backend::tensor::FloatTensor; +use burn_backend::{DType, TensorMetadata}; +use burn_std::{Bytes, bf16, f16}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; +use num_traits::ToPrimitive; + +use crate::ops::binary::binary_op; +use crate::ops::unary::unary_op; +use crate::{Flex, FlexTensor, Layout}; + +impl ActivationOps for Flex { + fn relu(tensor: FloatTensor) -> FloatTensor { + unary_op(tensor, |x: f32| x.max(0.0), |x: f64| x.max(0.0)) + } + + fn relu_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + // grad * (output > 0): zero the gradient where output was zero + binary_op( + output, + grad, + |out: f32, g| if out > 0.0 { g } else { 0.0 }, + |out: f64, g| if out > 0.0 { g } else { 0.0 }, + None, + ) + } + + fn leaky_relu(tensor: FloatTensor, negative_slope: Scalar) -> FloatTensor { + let ns32 = negative_slope.to_f32().unwrap(); + let ns64 = negative_slope.to_f64().unwrap(); + unary_op( + tensor, + move |x: f32| if x >= 0.0 { x } else { ns32 * x }, + move |x: f64| if x >= 0.0 { x } else { ns64 * x }, + ) + } + + fn prelu(tensor: FloatTensor, alpha: FloatTensor) -> FloatTensor { + // x if x >= 0, alpha * x otherwise + binary_op( + tensor, + alpha, + |x: f32, a| if x >= 0.0 { x } else { a * x }, + |x: f64, a| if x >= 0.0 { x } else { a * x }, + None, + ) + } + + fn gelu(tensor: FloatTensor) -> FloatTensor { + // 0.5 * x * (1 + erf(x / sqrt(2))) + use crate::ops::unary::{erf_f32, erf_f64}; + let sqrt2_f32: f32 = core::f32::consts::SQRT_2; + let sqrt2_f64: f64 = core::f64::consts::SQRT_2; + unary_op( + tensor, + move |x: f32| 0.5 * x * (1.0 + erf_f32(x / sqrt2_f32)), + move |x: f64| 0.5 * x * (1.0 + erf_f64(x / sqrt2_f64)), + ) + } + + fn gelu_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + // d/dx[gelu(x)] = 0.5 * (1 + erf(x/sqrt(2))) + x * (1/sqrt(2*pi)) * exp(-x^2/2) + use crate::ops::unary::{erf_f32, erf_f64}; + let sqrt2_f32: f32 = core::f32::consts::SQRT_2; + let sqrt2_f64: f64 = core::f64::consts::SQRT_2; + let inv_sqrt_2pi_f32: f32 = 1.0 / (2.0 * core::f32::consts::PI).sqrt(); + let inv_sqrt_2pi_f64: f64 = 1.0 / (2.0 * core::f64::consts::PI).sqrt(); + binary_op( + x, + grad, + move |x: f32, g| { + let cdf = 0.5 * (1.0 + erf_f32(x / sqrt2_f32)); + let pdf = inv_sqrt_2pi_f32 * (-0.5 * x * x).exp(); + g * (cdf + x * pdf) + }, + move |x: f64, g| { + let cdf = 0.5 * (1.0 + erf_f64(x / sqrt2_f64)); + let pdf = inv_sqrt_2pi_f64 * (-0.5 * x * x).exp(); + g * (cdf + x * pdf) + }, + None, + ) + } + + fn sigmoid(tensor: FloatTensor) -> FloatTensor { + unary_op(tensor, sigmoid_f32, sigmoid_f64) + } + + fn sigmoid_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + // grad * output * (1 - output) + binary_op( + output, + grad, + |s: f32, g| g * s * (1.0 - s), + |s: f64, g| g * s * (1.0 - s), + None, + ) + } + + fn hard_sigmoid(tensor: FloatTensor, alpha: Scalar, beta: Scalar) -> FloatTensor { + let alpha32 = alpha.to_f32().unwrap(); + let beta32 = beta.to_f32().unwrap(); + let alpha64 = alpha.to_f64().unwrap(); + let beta64 = beta.to_f64().unwrap(); + unary_op( + tensor, + move |x: f32| (alpha32 * x + beta32).clamp(0.0, 1.0), + move |x: f64| (alpha64 * x + beta64).clamp(0.0, 1.0), + ) + } + + fn log_sigmoid(tensor: FloatTensor) -> FloatTensor { + // Numerically stable: -softplus(-x) = -log(1 + exp(-x)) + // For x >= 0: -log(1 + exp(-x)) (standard form, exp(-x) is small) + // For x < 0: x - log(1 + exp(x)) (avoids exp of large positive) + unary_op( + tensor, + |x: f32| { + if x >= 0.0 { + -((-x).exp().ln_1p()) + } else { + x - x.exp().ln_1p() + } + }, + |x: f64| { + if x >= 0.0 { + -((-x).exp().ln_1p()) + } else { + x - x.exp().ln_1p() + } + }, + ) + } + + fn log_sigmoid_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + // d/dx[log_sigmoid(x)] = sigmoid(-x) * (-1) * (-1) = 1 - sigmoid(x) = sigmoid(-x) + // So: grad * sigmoid(-x) + binary_op( + x, + grad, + |x: f32, g| g * sigmoid_f32(-x), + |x: f64, g| g * sigmoid_f64(-x), + None, + ) + } + + fn softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + softmax(tensor, dim) + } +} + +#[inline] +fn sigmoid_f32(x: f32) -> f32 { + if x >= 0.0 { + 1.0 / (1.0 + (-x).exp()) + } else { + let e = x.exp(); + e / (1.0 + e) + } +} + +#[inline] +fn sigmoid_f64(x: f64) -> f64 { + if x >= 0.0 { + 1.0 / (1.0 + (-x).exp()) + } else { + let e = x.exp(); + e / (1.0 + e) + } +} + +// ============================================================================ +// Fused softmax +// ============================================================================ +// +// `ActivationOps` does not currently expose a `softmax` hook, so +// `burn_tensor::activation::softmax` falls back to a 5-op decomposition +// (`max_dim`/`sub`/`exp`/`sum_dim`/`div`). This module provides a fused +// alternative users can opt into directly. + +/// Fused softmax along `dim`. +/// +/// Three-pass row-wise algorithm (max, exp+sum, normalize) keeping each row +/// cache-hot. Rows are processed in parallel via rayon. For axes other than +/// the last, the tensor is permuted to put `dim` last, the fused kernel runs, +/// and the result is permuted back (both permutes are metadata-only; the +/// fused kernel's internal `to_contiguous` materializes the permuted layout +/// once). +/// +/// # Panics +/// +/// * If `dim` is out of range for `input`. +/// * If `input`'s dtype is not one of `f32`/`f64`/`f16`/`bf16`. +pub fn softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let rank = tensor.shape().num_dims(); + assert!( + dim < rank, + "softmax dim {} out of range for rank {}", + dim, + rank + ); + + if dim != rank - 1 { + let swapped = Flex::float_swap_dims(tensor, dim, rank - 1); + let normed = softmax_last(swapped); + return Flex::float_swap_dims(normed, dim, rank - 1); + } + + softmax_last(tensor) +} + +fn softmax_last(tensor: FloatTensor) -> FloatTensor { + let tensor = tensor.to_contiguous(); + match tensor.dtype() { + DType::F32 => softmax_last_f32(tensor), + DType::F64 => softmax_last_f64(tensor), + DType::F16 => softmax_last_f16(tensor), + DType::BF16 => softmax_last_bf16(tensor), + dtype => panic!("softmax: unsupported dtype {:?}", dtype), + } +} + +fn softmax_last_f32(tensor: FlexTensor) -> FlexTensor { + let shape = tensor.layout().shape().clone(); + let last = *shape.last().expect("softmax: empty shape"); + if last == 0 { + return tensor; + } + let input: &[f32] = tensor.storage(); + let n = input.len(); + + // Zero-initialize the output. The previous implementation used + // `Vec::with_capacity` + `spare_capacity_mut` + a raw-pointer cast to + // `&mut [f32]` to skip the memset, but forming a `&mut [f32]` over + // uninitialized memory violates Rust's validity invariant (references + // must point to initialized values of the correct type) even if every + // element is written before it is read. The sound zero-memset + // alternative would require threading `&mut [MaybeUninit]` through + // the row kernel, which does not compose with macerator's `#[with_simd]` + // signature. The memset is a streaming write on a bandwidth-bound + // kernel, so the overhead is small (~10% at the largest bench shape) + // and the fused path remains well ahead of decomposed and candle. + let mut output: Vec = vec![0.0; n]; + let out_slice = output.as_mut_slice(); + + // Row-parallel via rayon: one macerator dispatch per chunk of rows, + // amortized over all rows in the chunk. + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + const ROWS_PER_TASK: usize = 64; + let chunk_elems = ROWS_PER_TASK * last; + out_slice + .par_chunks_mut(chunk_elems) + .zip(input.par_chunks(chunk_elems)) + .for_each(|(o, i)| softmax_rows_f32(i, o, last)); + } + #[cfg(not(feature = "rayon"))] + { + softmax_rows_f32(input, out_slice, last); + } + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(shape), + DType::F32, + ) +} + +/// Row sweep for f32 softmax. With the `simd` feature, delegates to the +/// `#[macerator::with_simd]` SIMD kernel (one dispatch per chunk of rows, +/// amortized over all rows in the chunk). Without `simd`, uses a scalar +/// row kernel. +#[inline] +fn softmax_rows_f32(input: &[f32], output: &mut [f32], row_len: usize) { + // Release-mode invariant checks. These run once per chunk of rows + // (dozens of times per call, not per-element), so the overhead is + // unmeasurable against the kernel work. A debug-only check would + // silently pass a short final chunk to the row kernel on release + // builds if a future refactor broke the row alignment at the call + // site, yielding wrong softmax output with no panic. + assert_eq!(input.len(), output.len()); + assert_eq!(input.len() % row_len, 0); + #[cfg(feature = "simd")] + softmax_rows_f32_simd(input, output, row_len); + #[cfg(not(feature = "simd"))] + { + for (in_row, out_row) in input.chunks(row_len).zip(output.chunks_mut(row_len)) { + softmax_row_f32_scalar(in_row, out_row); + } + } +} + +#[cfg(feature = "simd")] +#[macerator::with_simd] +fn softmax_rows_f32_simd(input: &[f32], output: &mut [f32], row_len: usize) { + debug_assert_eq!(input.len(), output.len()); + debug_assert_eq!(input.len() % row_len, 0); + for (in_row, out_row) in input.chunks(row_len).zip(output.chunks_mut(row_len)) { + softmax_row_f32_simd::(in_row, out_row); + } +} + +/// Scalar fallback row kernel for f32 softmax when the `simd` feature is +/// disabled. Uses the same 3-pass algorithm as the SIMD path; LLVM +/// autovectorizes the max-reduce and normalize loops on most targets. +#[cfg(not(feature = "simd"))] +#[inline] +fn softmax_row_f32_scalar(input: &[f32], output: &mut [f32]) { + let mut max_val = f32::NEG_INFINITY; + for &x in input { + if x > max_val { + max_val = x; + } + } + let mut sum = 0.0f32; + for (i, &x) in input.iter().enumerate() { + let e = (x - max_val).exp(); + output[i] = e; + sum += e; + } + let inv = 1.0f32 / sum; + for x in output.iter_mut() { + *x *= inv; + } +} + +/// Inner row kernel for a single softmax row. `#[inline(always)]` so it +/// inlines into `softmax_rows_f32_simd`'s loop body for each monomorphized S, +/// avoiding a per-row call boundary. +#[cfg(feature = "simd")] +#[inline(always)] +fn softmax_row_f32_simd(input: &[f32], output: &mut [f32]) { + use macerator::{Scalar, vload_unaligned, vstore_unaligned}; + let lanes = ::lanes::(); + let len = input.len(); + let simd_len = len / lanes * lanes; + + // Pass 1: row max for numerical stability. + // SIMD max-reduction across the row, scalar tail. + let (mut max_val, tail_start) = if simd_len >= lanes { + let mut max_vec = unsafe { vload_unaligned::(input.as_ptr()) }; + let mut j = lanes; + while j < simd_len { + let v = unsafe { vload_unaligned::(input.as_ptr().add(j)) }; + max_vec = max_vec.max(v); + j += lanes; + } + (max_vec.reduce_max(), simd_len) + } else { + (f32::NEG_INFINITY, 0) + }; + for &x in &input[tail_start..] { + if x > max_val { + max_val = x; + } + } + + // Pass 2: compute exp(x - max), store in output, accumulate sum. + // Scalar exp (no SIMD exp in macerator). This pass is the one that + // actually does memory reads + writes on the whole row, so scalar + // here still lands us at memory bandwidth. + let mut sum = 0.0f32; + for idx in 0..len { + let e = (input[idx] - max_val).exp(); + output[idx] = e; + sum += e; + } + + // Pass 3: normalize. + // SIMD splat + multiply, scalar tail. + let inv = 1.0f32 / sum; + let inv_vec = inv.splat::(); + let mut i = 0; + while i < simd_len { + unsafe { + let v = vload_unaligned::(output.as_ptr().add(i)); + vstore_unaligned::(output.as_mut_ptr().add(i), v * inv_vec); + } + i += lanes; + } + for x in &mut output[i..] { + *x *= inv; + } +} + +// f64, f16, bf16 softmax share the same row-parallel dispatcher shell and +// differ only in their row kernel (native f64 vs via-f32 for half +// precision). Generated via macros to keep the three variants in lockstep. +// Only f32 has a dedicated SIMD fast path above. + +macro_rules! softmax_last_dtype { + ($fn_name:ident, $T:ty, $zero:expr, $dtype:expr, $row_fn:ident) => { + fn $fn_name(tensor: FlexTensor) -> FlexTensor { + let shape = tensor.layout().shape().clone(); + let last = *shape.last().expect("softmax: empty shape"); + if last == 0 { + return tensor; + } + let input: &[$T] = tensor.storage(); + let mut output: Vec<$T> = vec![$zero; input.len()]; + + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + output + .par_chunks_mut(last) + .zip(input.par_chunks(last)) + .for_each(|(o, i)| $row_fn(i, o)); + } + #[cfg(not(feature = "rayon"))] + { + for (i, o) in input.chunks(last).zip(output.chunks_mut(last)) { + $row_fn(i, o); + } + } + + FlexTensor::new(Bytes::from_elems(output), Layout::contiguous(shape), $dtype) + } + }; +} + +/// Half-precision softmax row kernel. Accumulates in f32 for numerical +/// stability and converts back to the target type at each write. This +/// double-rounds across passes 2 and 3; acceptable for half precision. An +/// f32 scratch buffer would remove the double rounding at the cost of a +/// per-row allocation. +macro_rules! softmax_row_half { + ($fn_name:ident, $T:ty) => { + #[inline] + fn $fn_name(input: &[$T], output: &mut [$T]) { + let mut max_val = f32::NEG_INFINITY; + for &x in input { + let xf = x.to_f32(); + if xf > max_val { + max_val = xf; + } + } + let mut sum = 0.0f32; + for (i, &x) in input.iter().enumerate() { + let e = (x.to_f32() - max_val).exp(); + output[i] = <$T>::from_f32(e); + sum += e; + } + let inv = 1.0f32 / sum; + for x in output.iter_mut() { + *x = <$T>::from_f32(x.to_f32() * inv); + } + } + }; +} + +#[inline] +fn softmax_row_f64(input: &[f64], output: &mut [f64]) { + let mut max_val = f64::NEG_INFINITY; + for &x in input { + if x > max_val { + max_val = x; + } + } + let mut sum = 0.0f64; + for (i, &x) in input.iter().enumerate() { + let e = (x - max_val).exp(); + output[i] = e; + sum += e; + } + let inv = 1.0f64 / sum; + for x in output.iter_mut() { + *x *= inv; + } +} + +softmax_row_half!(softmax_row_f16, f16); +softmax_row_half!(softmax_row_bf16, bf16); + +softmax_last_dtype!(softmax_last_f64, f64, 0.0f64, DType::F64, softmax_row_f64); +softmax_last_dtype!( + softmax_last_f16, + f16, + f16::from_f32(0.0), + DType::F16, + softmax_row_f16 +); +softmax_last_dtype!( + softmax_last_bf16, + bf16, + bf16::from_f32(0.0), + DType::BF16, + softmax_row_bf16 +); + +// ============================================================================ +// Fused layer_norm +// ============================================================================ +// +// `burn::nn::LayerNorm::forward` decomposes into ~6 primitive tensor ops +// with intermediate allocations, and there is no backend trait hook for +// layer_norm. This module provides a fused alternative users can opt into +// directly. Two-pass row kernel (sum+sumsq sweep, then normalize+affine +// sweep), both vectorized via macerator. + +/// Fused layer normalization along the last axis. +/// +/// Applies `y = ((x - mean) / sqrt(var + eps)) * gamma + beta`, where +/// `mean` and `var` are computed per row along the last axis of `input`. +/// `gamma` and `beta` are 1-D tensors of length `input.shape()[-1]`; +/// `beta` is optional (set to `None` for a bias-free layer norm). +/// +/// Two-pass row kernel (mean/variance via a single sum+sum-of-squares +/// sweep, then one normalize+affine sweep). Both passes are SIMD via +/// macerator; each row stays cache-hot across both passes. +/// +/// Supports `f32` (SIMD-vectorized), `f64` (scalar + LLVM autovec), and +/// `f16`/`bf16` (via an f32 cast-fuse-cast shell; the f32 row kernel +/// already accumulates in f32, so this matches the precision a +/// half-precision-native kernel would produce). +/// +/// # Panics +/// +/// * If `input`'s dtype is not one of `f32`/`f64`/`f16`/`bf16`. +/// * If `input` has rank 0. +/// * If `gamma` (or `beta`, when present) is not a 1-D tensor of length +/// equal to the last dim of `input`. +pub fn layer_norm( + input: FloatTensor, + gamma: FloatTensor, + beta: Option>, + epsilon: f64, +) -> FloatTensor { + let rank = input.shape().num_dims(); + assert!(rank >= 1, "layer_norm: input must have at least one dim"); + // Keep gamma/beta dtypes aligned with the input. The half-precision path + // (see `layer_norm_via_f32`) ultimately accesses storage using the input's + // element type, and a mismatch would panic there; reject it up front with + // a clearer layer_norm-specific error message. + assert_eq!( + gamma.dtype(), + input.dtype(), + "layer_norm: gamma dtype {:?} does not match input dtype {:?}", + gamma.dtype(), + input.dtype(), + ); + if let Some(ref b) = beta { + assert_eq!( + b.dtype(), + input.dtype(), + "layer_norm: beta dtype {:?} does not match input dtype {:?}", + b.dtype(), + input.dtype(), + ); + } + let input = input.to_contiguous(); + let gamma = gamma.to_contiguous(); + let beta = beta.map(|b| b.to_contiguous()); + + let d_model = *input + .layout() + .shape() + .last() + .expect("layer_norm: empty shape"); + // Validate rank + length explicitly rather than just last-dim == d_model. + // A gamma shaped like `[2, d_model]` would pass a last-dim check but + // has 2*d_model elements, which would index the wrong data in the row + // kernel (caught by an inner assert, but with a confusing message). + let gamma_shape = gamma.layout().shape(); + assert!( + gamma_shape.len() == 1 && gamma_shape[0] == d_model, + "layer_norm: gamma must be a 1-D tensor of length equal to last dim of input \ + (got shape {:?}, expected [{}])", + gamma_shape, + d_model, + ); + if let Some(ref b) = beta { + let beta_shape = b.layout().shape(); + assert!( + beta_shape.len() == 1 && beta_shape[0] == d_model, + "layer_norm: beta must be a 1-D tensor of length equal to last dim of input \ + (got shape {:?}, expected [{}])", + beta_shape, + d_model, + ); + } + + match input.dtype() { + DType::F32 => layer_norm_f32(input, gamma, beta, epsilon as f32), + DType::F64 => layer_norm_f64(input, gamma, beta, epsilon), + DType::F16 => { + layer_norm_via_f32::(input, gamma, beta, epsilon, f16::to_f32, f16::from_f32) + } + DType::BF16 => { + layer_norm_via_f32::(input, gamma, beta, epsilon, bf16::to_f32, bf16::from_f32) + } + dtype => panic!("burn_flex::layer_norm: unsupported dtype {:?}", dtype), + } +} + +fn layer_norm_via_f32( + input: FlexTensor, + gamma: FlexTensor, + beta: Option, + epsilon: f64, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor { + let input_f32 = crate::ops::module::cast_to_f32::(input, to_f32); + let gamma_f32 = crate::ops::module::cast_to_f32::(gamma, to_f32); + let beta_f32 = beta.map(|b| crate::ops::module::cast_to_f32::(b, to_f32)); + let out = layer_norm_f32(input_f32, gamma_f32, beta_f32, epsilon as f32); + crate::ops::module::cast_from_f32::(out, from_f32) +} + +/// Fused f64 layer_norm. The Welford mean/variance pass is serial (the +/// mean update on iteration `k` depends on iteration `k-1`); the +/// normalize+affine pass autovectorizes on targets with f64 SIMD. A +/// macerator f64 path can be added if profiling shows it matters. +fn layer_norm_f64( + input: FlexTensor, + gamma: FlexTensor, + beta: Option, + epsilon: f64, +) -> FlexTensor { + let shape = input.layout().shape().clone(); + let d_model = *shape.last().expect("layer_norm: empty shape"); + if d_model == 0 { + return input; + } + let input_data: &[f64] = input.storage(); + let gamma_data: &[f64] = gamma.storage(); + let beta_data: Option<&[f64]> = beta.as_ref().map(|b| b.storage()); + let mut output: Vec = vec![0.0; input_data.len()]; + + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + const ROWS_PER_TASK: usize = 64; + let chunk_elems = ROWS_PER_TASK * d_model; + match beta_data { + Some(beta_slice) => { + output + .par_chunks_mut(chunk_elems) + .zip(input_data.par_chunks(chunk_elems)) + .for_each(|(o, i)| { + layer_norm_rows_f64_with_beta( + i, o, gamma_data, beta_slice, d_model, epsilon, + ); + }); + } + None => { + output + .par_chunks_mut(chunk_elems) + .zip(input_data.par_chunks(chunk_elems)) + .for_each(|(o, i)| { + layer_norm_rows_f64_no_beta(i, o, gamma_data, d_model, epsilon); + }); + } + } + } + #[cfg(not(feature = "rayon"))] + { + match beta_data { + Some(beta_slice) => layer_norm_rows_f64_with_beta( + input_data, + output.as_mut_slice(), + gamma_data, + beta_slice, + d_model, + epsilon, + ), + None => layer_norm_rows_f64_no_beta( + input_data, + output.as_mut_slice(), + gamma_data, + d_model, + epsilon, + ), + } + } + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(shape), + DType::F64, + ) +} + +#[inline] +fn layer_norm_rows_f64_with_beta( + input: &[f64], + output: &mut [f64], + gamma: &[f64], + beta: &[f64], + d_model: usize, + epsilon: f64, +) { + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + let (mean, inv_std) = welford_f64(in_row, epsilon); + for (i, &x) in in_row.iter().enumerate() { + out_row[i] = (x - mean) * (inv_std * gamma[i]) + beta[i]; + } + } +} + +#[inline] +fn layer_norm_rows_f64_no_beta( + input: &[f64], + output: &mut [f64], + gamma: &[f64], + d_model: usize, + epsilon: f64, +) { + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + let (mean, inv_std) = welford_f64(in_row, epsilon); + for (i, &x) in in_row.iter().enumerate() { + out_row[i] = (x - mean) * (inv_std * gamma[i]); + } + } +} + +#[inline] +fn welford_f64(row: &[f64], epsilon: f64) -> (f64, f64) { + let mut mean = 0.0f64; + let mut m2 = 0.0f64; + for (k, &x) in row.iter().enumerate() { + let n_k = (k + 1) as f64; + let delta = x - mean; + mean += delta / n_k; + m2 += delta * (x - mean); + } + let var = m2 / row.len() as f64; + (mean, 1.0f64 / (var + epsilon).sqrt()) +} + +fn layer_norm_f32( + input: FlexTensor, + gamma: FlexTensor, + beta: Option, + epsilon: f32, +) -> FlexTensor { + let shape = input.layout().shape().clone(); + let d_model = *shape.last().expect("layer_norm: empty shape"); + if d_model == 0 { + return input; + } + + let input_data: &[f32] = input.storage(); + let gamma_data: &[f32] = gamma.storage(); + let beta_data: Option<&[f32]> = beta.as_ref().map(|b| b.storage()); + + let n = input_data.len(); + // See softmax_last_f32 for the rationale on zero-init instead of + // `spare_capacity_mut` + `&mut [f32]` cast: the latter creates a + // reference to uninitialized f32 values, which is UB under Rust's + // aliasing model even with no intervening read. + let mut output: Vec = vec![0.0; n]; + let out_slice = output.as_mut_slice(); + + // `#[macerator::with_simd]` can't auto-lifetime through + // `Option<&[T]>`, so we dispatch two separate monomorphized + // versions, one with beta and one without. Both call into the + // same shared row kernel. + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + const ROWS_PER_TASK: usize = 64; + let chunk_elems = ROWS_PER_TASK * d_model; + match beta_data { + Some(beta_slice) => { + out_slice + .par_chunks_mut(chunk_elems) + .zip(input_data.par_chunks(chunk_elems)) + .for_each(|(o, i)| { + layer_norm_rows_f32_with_beta( + i, o, gamma_data, beta_slice, d_model, epsilon, + ); + }); + } + None => { + out_slice + .par_chunks_mut(chunk_elems) + .zip(input_data.par_chunks(chunk_elems)) + .for_each(|(o, i)| { + layer_norm_rows_f32_no_beta(i, o, gamma_data, d_model, epsilon); + }); + } + } + } + #[cfg(not(feature = "rayon"))] + { + match beta_data { + Some(beta_slice) => layer_norm_rows_f32_with_beta( + input_data, out_slice, gamma_data, beta_slice, d_model, epsilon, + ), + None => { + layer_norm_rows_f32_no_beta(input_data, out_slice, gamma_data, d_model, epsilon) + } + } + } + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(shape), + DType::F32, + ) +} + +/// Row sweep for f32 layer_norm with bias. Delegates to the SIMD kernel +/// when the `simd` feature is enabled; otherwise uses a scalar row loop. +#[inline] +fn layer_norm_rows_f32_with_beta( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + beta: &[f32], + d_model: usize, + epsilon: f32, +) { + // Release-mode invariant checks; see softmax_rows_f32 for rationale. + assert_eq!(input.len(), output.len()); + assert_eq!(input.len() % d_model, 0); + assert_eq!(gamma.len(), d_model); + assert_eq!(beta.len(), d_model); + #[cfg(feature = "simd")] + layer_norm_rows_f32_with_beta_simd(input, output, gamma, beta, d_model, epsilon); + #[cfg(not(feature = "simd"))] + { + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + layer_norm_row_f32_scalar(in_row, out_row, gamma, Some(beta), epsilon); + } + } +} + +/// Row sweep for f32 layer_norm without bias. +#[inline] +fn layer_norm_rows_f32_no_beta( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + d_model: usize, + epsilon: f32, +) { + // Release-mode invariant checks; see softmax_rows_f32 for rationale. + assert_eq!(input.len(), output.len()); + assert_eq!(input.len() % d_model, 0); + assert_eq!(gamma.len(), d_model); + #[cfg(feature = "simd")] + layer_norm_rows_f32_no_beta_simd(input, output, gamma, d_model, epsilon); + #[cfg(not(feature = "simd"))] + { + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + layer_norm_row_f32_scalar(in_row, out_row, gamma, None, epsilon); + } + } +} + +/// Scalar fallback row kernel for layer_norm when the `simd` feature is +/// disabled. Two-pass algorithm matching the SIMD version (sum+sumsq, +/// then normalize+affine). +#[cfg(not(feature = "simd"))] +#[inline] +fn layer_norm_row_f32_scalar( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + beta: Option<&[f32]>, + epsilon: f32, +) { + // Welford's online algorithm for mean and variance, rather than the + // `sumsq / n - mean * mean` identity the SIMD path uses. The identity + // is vulnerable to catastrophic cancellation when the two terms are + // close in magnitude (large mean relative to variance). Welford's + // single-pass formulation avoids that by tracking the running mean + // and accumulating squared deviations from it. The scalar path is + // the contract used when `simd` is disabled, so we prefer numerical + // stability over bit-for-bit match with the SIMD tree reduction. + let len = input.len(); + let mut mean = 0.0f32; + let mut m2 = 0.0f32; + for (k, &x) in input.iter().enumerate() { + let n_k = (k + 1) as f32; + let delta = x - mean; + mean += delta / n_k; + let delta2 = x - mean; + m2 += delta * delta2; + } + let var = m2 / len as f32; + let inv_std = 1.0f32 / (var + epsilon).sqrt(); + for (i, &x) in input.iter().enumerate() { + let scale = inv_std * gamma[i]; + let normed = (x - mean) * scale; + output[i] = match beta { + Some(b) => normed + b[i], + None => normed, + }; + } +} + +/// SIMD-dispatched row sweep for f32 layer_norm with bias (beta). One +/// macerator dispatch per chunk of rows, amortized over the whole chunk. +#[cfg(feature = "simd")] +#[macerator::with_simd] +fn layer_norm_rows_f32_with_beta_simd( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + beta: &[f32], + d_model: usize, + epsilon: f32, +) { + debug_assert_eq!(input.len(), output.len()); + debug_assert_eq!(input.len() % d_model, 0); + debug_assert_eq!(gamma.len(), d_model); + debug_assert_eq!(beta.len(), d_model); + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + layer_norm_row_f32_simd::(in_row, out_row, gamma, Some(beta), epsilon); + } +} + +/// SIMD-dispatched row sweep for f32 layer_norm without bias. +#[cfg(feature = "simd")] +#[macerator::with_simd] +fn layer_norm_rows_f32_no_beta_simd( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + d_model: usize, + epsilon: f32, +) { + debug_assert_eq!(input.len(), output.len()); + debug_assert_eq!(input.len() % d_model, 0); + debug_assert_eq!(gamma.len(), d_model); + for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) { + layer_norm_row_f32_simd::(in_row, out_row, gamma, None, epsilon); + } +} + +/// Single-row layer_norm kernel. Two vectorized passes. +#[cfg(feature = "simd")] +#[inline(always)] +fn layer_norm_row_f32_simd( + input: &[f32], + output: &mut [f32], + gamma: &[f32], + beta: Option<&[f32]>, + epsilon: f32, +) { + use macerator::{Scalar, vload_unaligned, vstore_unaligned}; + let lanes = ::lanes::(); + let len = input.len(); + let simd_len = len / lanes * lanes; + + // Pass 1: compute sum and sum-of-squares in one sweep, then derive + // mean and variance. Two independent SIMD accumulators (sum, sumsq) + // expose ILP to the two FMA ports. + let (sum, sumsq) = if simd_len >= lanes { + let mut acc_sum = 0.0f32.splat::(); + let mut acc_sumsq = 0.0f32.splat::(); + let mut i = 0; + while i < simd_len { + unsafe { + let v = vload_unaligned::(input.as_ptr().add(i)); + acc_sum += v; + // acc_sumsq += v * v; Vector::mul_add(self, a, b) = self*a + b, + // so v.mul_add(v, acc_sumsq) = v*v + acc_sumsq. + acc_sumsq = v.mul_add(v, acc_sumsq); + } + i += lanes; + } + let mut s = acc_sum.reduce_add(); + let mut sq = acc_sumsq.reduce_add(); + for &x in &input[simd_len..] { + s += x; + sq += x * x; + } + (s, sq) + } else { + let mut s = 0.0f32; + let mut sq = 0.0f32; + for &x in input { + s += x; + sq += x * x; + } + (s, sq) + }; + + let n = len as f32; + let mean = sum / n; + // Biased variance: E[x^2] - E[x]^2. Matches burn::nn::LayerNorm which + // uses var_mean_bias (the biased estimator) rather than Bessel's + // correction. + let var = (sumsq / n) - mean * mean; + let inv_std = 1.0f32 / (var + epsilon).sqrt(); + + // Pass 2: normalize and affine transform. + // out[i] = (x[i] - mean) * inv_std * gamma[i] + beta[i] + // mean_vec and inv_std_vec are hoisted outside the loop (one splat + // each per row). gamma and beta are read once per element; both + // fit in L1 and are shared across all rows within a rayon chunk. + let mean_vec = mean.splat::(); + let inv_std_vec = inv_std.splat::(); + let mut i = 0; + while i < simd_len { + unsafe { + let x = vload_unaligned::(input.as_ptr().add(i)); + let g = vload_unaligned::(gamma.as_ptr().add(i)); + // scale = inv_std * g + let scale = inv_std_vec * g; + // centered = x - mean + let centered = x - mean_vec; + // out = centered * scale (+ beta if present) + let normed = centered * scale; + let out = if let Some(b) = beta { + let b_vec = vload_unaligned::(b.as_ptr().add(i)); + normed + b_vec + } else { + normed + }; + vstore_unaligned::(output.as_mut_ptr().add(i), out); + } + i += lanes; + } + // Scalar tail + while i < len { + let centered = input[i] - mean; + let normed = centered * inv_std * gamma[i]; + output[i] = match beta { + Some(b) => normed + b[i], + None => normed, + }; + i += 1; + } +} + +// Tests kept here exercise flex-specific behavior: SIMD boundaries, rayon +// chunk boundaries, non-contiguous input handling, the flex-internal +// layer_norm op (no public API yet), and dtype-specific fused softmax +// paths (f16/bf16/f64). Plain activation/softmax smoke tests have been +// migrated to burn-backend-tests so they cover every backend. When adding +// new tests, keep them here only if they probe flex internals; otherwise +// add them to crates/burn-backend-tests/tests/tensor/float/activation/. +#[cfg(test)] +mod tests { + use alloc::vec; + use burn_backend::{DType, TensorData, TensorMetadata, Tolerance}; + use burn_std::{bf16, f16}; + use num_traits::Float; + + use crate::FlexTensor; + + // ============================================================================ + // Reference implementations (per-row, last-axis). + // + // These mirror the contract the fused kernel commits to: stable softmax via + // (x - max), layer_norm via (x - mean) * inv(sqrt(var + eps)) with optional + // affine. Written in plain Rust over f32/f64 slices so the tests avoid any + // tensor-library dependency. + // ============================================================================ + + fn softmax_row(row_in: &[T], row_out: &mut [T]) { + let max = row_in + .iter() + .copied() + .fold(T::neg_infinity(), |a, b| if a > b { a } else { b }); + let mut sum = T::zero(); + for (i, &x) in row_in.iter().enumerate() { + let e = (x - max).exp(); + row_out[i] = e; + sum = sum + e; + } + for v in row_out.iter_mut() { + *v = *v / sum; + } + } + + fn softmax_last_ref(data: &[T], row_len: usize) -> Vec { + let mut out = vec![T::zero(); data.len()]; + for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) { + softmax_row(i, o); + } + out + } + + fn layer_norm_row( + row_in: &[T], + gamma: &[T], + beta: Option<&[T]>, + eps: T, + row_out: &mut [T], + ) { + let n = T::from(row_in.len()).unwrap(); + let mean = row_in.iter().copied().fold(T::zero(), |a, b| a + b) / n; + let var = row_in + .iter() + .map(|&x| (x - mean) * (x - mean)) + .fold(T::zero(), |a, b| a + b) + / n; + let inv_std = T::one() / (var + eps).sqrt(); + for (i, &x) in row_in.iter().enumerate() { + let normed = (x - mean) * inv_std; + let scaled = normed * gamma[i]; + row_out[i] = match beta { + Some(b) => scaled + b[i], + None => scaled, + }; + } + } + + fn layer_norm_last_ref( + data: &[T], + gamma: &[T], + beta: Option<&[T]>, + eps: T, + row_len: usize, + ) -> Vec { + let mut out = vec![T::zero(); data.len()]; + for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) { + layer_norm_row(i, gamma, beta, eps, o); + } + out + } + + // ============================================================================ + // Helpers: FlexTensor constructors for typed inputs. + // ============================================================================ + + fn flex_f32(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, shape.to_vec())) + } + + fn flex_f64(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, shape.to_vec())) + } + + fn flex_half(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, shape.to_vec())) + } + + // ============================================================================ + // layer_norm tests + // ============================================================================ + + #[test] + fn test_layer_norm_2d_with_beta() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], &[2, 4]); + let gamma = flex_f32(vec![1.0; 4], &[4]); + let beta = flex_f32(vec![0.0; 4], &[4]); + let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5); + + let expected: Vec = vec![ + -1.3416408, -0.4472136, 0.4472136, 1.3416408, -1.3416408, -0.4472136, 0.4472136, + 1.3416408, + ]; + out.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 4]), + Tolerance::absolute(1e-4), + ); + } + + #[test] + fn test_layer_norm_with_affine() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f32(vec![2.0, 0.5, 1.0, 3.0], &[4]); + let beta = flex_f32(vec![1.0, -1.0, 0.0, 2.0], &[4]); + let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5); + + // normalized = [-1.3416, -0.4472, 0.4472, 1.3416] + // affine: [-1.6833, -1.2236, 0.4472, 6.0249] + out.into_data().assert_approx_eq::( + &TensorData::new(vec![-1.6833, -1.2236, 0.4472, 6.0249], vec![1, 4]), + Tolerance::absolute(1e-3), + ); + } + + #[test] + fn test_layer_norm_no_beta() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f32(vec![1.0; 4], &[4]); + let out = crate::ops::activation::layer_norm(t, gamma, None, 1e-5); + + out.into_data().assert_approx_eq::( + &TensorData::new( + vec![-1.3416408, -0.4472136, 0.4472136, 1.3416408], + vec![1, 4], + ), + Tolerance::absolute(1e-4), + ); + } + + // ============================================================================ + // softmax SIMD / rayon boundary tests + // ============================================================================ + + #[test] + fn test_softmax_simd_body_row() { + // Row length 32 ensures the SIMD body runs on every supported target: + // NEON (lanes=4), AVX2 (lanes=8), AVX-512 (lanes=16), SIMD128 (lanes=4). + let data: Vec = (0..32).map(|i| i as f32 * 0.1).collect(); + let expected = softmax_last_ref(&data, 32); + let fused = crate::ops::activation::softmax(flex_f32(data, &[1, 32]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![1, 32]), + Tolerance::absolute(1e-5), + ); + } + + #[test] + fn test_softmax_multi_chunk_rayon() { + // 100 rows > ROWS_PER_TASK (64) triggers the rayon par_chunks path. + let data: Vec = (0..100 * 16).map(|i| ((i % 17) as f32) * 0.05).collect(); + let expected = softmax_last_ref(&data, 16); + let fused = crate::ops::activation::softmax(flex_f32(data, &[100, 16]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![100, 16]), + Tolerance::absolute(1e-5), + ); + } + + #[test] + fn test_softmax_f64() { + // Exercises softmax_last_dtype! + softmax_row_native f64 path. + let data: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let expected = softmax_last_ref(&data, 4); + let fused = crate::ops::activation::softmax(flex_f64(data, &[2, 4]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 4]), + Tolerance::absolute(1e-10), + ); + } + + #[test] + fn test_softmax_f16() { + // Exercises softmax_last_dtype! + softmax_row_half f16 path. + let source: Vec = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5]; + let data: Vec = source.iter().map(|&x| f16::from_f32(x)).collect(); + let expected = softmax_last_ref(&data, 4); + let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 4]), + Tolerance::absolute(1e-2), + ); + } + + #[test] + fn test_softmax_bf16() { + // Exercises softmax_last_dtype! + softmax_row_half bf16 path. + let source: Vec = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5]; + let data: Vec = source.iter().map(|&x| bf16::from_f32(x)).collect(); + let expected = softmax_last_ref(&data, 4); + let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 4]), + Tolerance::absolute(5e-2), + ); + } + + #[test] + fn test_layer_norm_multi_chunk_rayon() { + // 128 rows > ROWS_PER_TASK (64) triggers the rayon path. + let data: Vec = (0..128 * 16).map(|i| ((i % 19) as f32) * 0.03).collect(); + let gamma_data: Vec = vec![1.0; 16]; + let beta_data: Vec = vec![0.0; 16]; + let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 16); + let fused = crate::ops::activation::layer_norm( + flex_f32(data, &[128, 16]), + flex_f32(gamma_data, &[16]), + Some(flex_f32(beta_data, &[16])), + 1e-5, + ); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![128, 16]), + Tolerance::absolute(1e-4), + ); + } + + #[test] + fn test_softmax_empty_last_dim_returns_input() { + // shape [2, 0]: empty last dim should round-trip unchanged instead + // of producing NaN via 0/0. + let t = flex_f32(Vec::::new(), &[2, 0]); + let result = crate::ops::activation::softmax(t, 1); + assert_eq!(result.shape().as_slice(), &[2, 0]); + } + + #[test] + fn test_layer_norm_empty_last_dim_returns_input() { + let t = flex_f32(Vec::::new(), &[3, 0]); + let gamma = flex_f32(Vec::::new(), &[0]); + let beta = flex_f32(Vec::::new(), &[0]); + let result = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5); + assert_eq!(result.shape().as_slice(), &[3, 0]); + } + + #[test] + #[should_panic(expected = "gamma must be a 1-D tensor")] + fn test_layer_norm_gamma_length_mismatch_panics() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f32(vec![1.0, 1.0, 1.0], &[3]); + let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5); + } + + #[test] + #[should_panic(expected = "beta must be a 1-D tensor")] + fn test_layer_norm_beta_length_mismatch_panics() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f32(vec![1.0, 1.0, 1.0, 1.0], &[4]); + let beta = flex_f32(vec![0.0, 0.0, 0.0], &[3]); + let _ = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5); + } + + #[test] + #[should_panic(expected = "gamma must be a 1-D tensor")] + fn test_layer_norm_gamma_rank_mismatch_panics() { + // gamma [2, 4] has matching last-dim but rank 2, so the old last-dim + // check alone would have accepted it and then indexed wrong storage. + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f32(vec![1.0; 8], &[2, 4]); + let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5); + } + + // Row length 17 leaves exactly one scalar-tail element after every common + // SIMD width (NEON/SSE f32x4: body=16, tail=1; AVX2 f32x8: body=16, tail=1; + // AVX-512 f32x16: body=16, tail=1). Row lengths that divide evenly by the + // SIMD width skip the tail branch entirely, so a bug in the scalar tail + // kernel would sail past CI without a test like this. + #[test] + fn test_softmax_simd_body_plus_scalar_tail() { + let data: Vec = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect(); + let expected = softmax_last_ref(&data, 17); + let fused = crate::ops::activation::softmax(flex_f32(data, &[2, 17]), 1); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 17]), + Tolerance::absolute(1e-5), + ); + } + + #[test] + fn test_layer_norm_simd_body_plus_scalar_tail() { + let data: Vec = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect(); + let gamma_data: Vec = (0..17).map(|i| 1.0 + i as f32 * 0.05).collect(); + let beta_data: Vec = (0..17).map(|i| i as f32 * 0.01).collect(); + let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 17); + let fused = crate::ops::activation::layer_norm( + flex_f32(data, &[2, 17]), + flex_f32(gamma_data, &[17]), + Some(flex_f32(beta_data, &[17])), + 1e-5, + ); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 17]), + Tolerance::absolute(1e-5), + ); + } + + #[test] + fn test_layer_norm_f64_with_beta_multi_chunk() { + // 80 rows > ROWS_PER_TASK (64) exercises the rayon multi-chunk f64 path. + let d_model = 16; + let n_rows = 80; + let data: Vec = (0..n_rows * d_model) + .map(|i| ((i % 13) as f64) * 0.07 - 0.3) + .collect(); + let gamma_data: Vec = vec![0.9; d_model]; + let beta_data: Vec = vec![0.05; d_model]; + let eps = 1e-5f64; + let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), eps, d_model); + let fused = crate::ops::activation::layer_norm( + flex_f64(data, &[n_rows, d_model]), + flex_f64(gamma_data, &[d_model]), + Some(flex_f64(beta_data, &[d_model])), + eps, + ); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![n_rows, d_model]), + Tolerance::absolute(1e-10), + ); + } + + #[test] + fn test_layer_norm_f64_no_beta() { + let data: Vec = vec![1.0, 2.0, 3.0, 4.0, -1.0, 0.5, 1.5, -0.5]; + let gamma_data: Vec = vec![1.0; 4]; + let eps = 1e-5f64; + let expected = layer_norm_last_ref(&data, &gamma_data, None, eps, 4); + let fused = crate::ops::activation::layer_norm( + flex_f64(data, &[2, 4]), + flex_f64(gamma_data, &[4]), + None, + eps, + ); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 4]), + Tolerance::absolute(1e-10), + ); + } + + // Shared body for f16/bf16 layer_norm tests. The fused half-precision + // kernel casts to f32 internally, so the reference is computed in f32 + // and compared back against the half output with an f32 tolerance. + fn check_layer_norm_half_precision(from_f32: fn(f32) -> E, dtype: DType) + where + E: burn_backend::Element + Float, + { + let rows_f32: [f32; 12] = [ + 1.0, 2.0, 3.0, 4.0, -1.0, 0.0, 1.0, 2.0, 0.5, -0.5, 1.5, -1.5, + ]; + let gamma_f32: [f32; 4] = [1.0, 0.5, 1.5, 1.0]; + let beta_f32: [f32; 4] = [0.1, -0.1, 0.0, 0.2]; + let eps = 1e-5f32; + + let expected_f32 = layer_norm_last_ref(&rows_f32, &gamma_f32, Some(&beta_f32), eps, 4); + + let data: Vec = rows_f32.iter().map(|&x| from_f32(x)).collect(); + let gamma_data: Vec = gamma_f32.iter().map(|&x| from_f32(x)).collect(); + let beta_data: Vec = beta_f32.iter().map(|&x| from_f32(x)).collect(); + assert_eq!(E::dtype(), dtype); + + let fused = crate::ops::activation::layer_norm( + flex_half(data, &[3, 4]), + flex_half(gamma_data, &[4]), + Some(flex_half(beta_data, &[4])), + eps as f64, + ); + fused.into_data().assert_approx_eq::( + &TensorData::new(expected_f32, vec![3, 4]), + Tolerance::absolute(3e-2), + ); + } + + #[test] + fn test_layer_norm_f16_via_f32_cast() { + check_layer_norm_half_precision::(f16::from_f32, DType::F16); + } + + #[test] + fn test_layer_norm_bf16_via_f32_cast() { + check_layer_norm_half_precision::(bf16::from_f32, DType::BF16); + } + + #[test] + #[should_panic(expected = "softmax dim")] + fn test_softmax_dim_out_of_range_panics() { + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[2, 2]); + let _ = crate::ops::activation::softmax(t, 2); + } + + #[test] + #[should_panic(expected = "gamma dtype")] + fn test_layer_norm_gamma_dtype_mismatch_panics() { + // Input f32, gamma f64: layer_norm rejects the mismatch up front + // rather than panicking later inside the storage-typed access. + let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]); + let gamma = flex_f64(vec![1.0; 4], &[4]); + let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5); + } +} diff --git a/crates/burn-flex/src/ops/attention.rs b/crates/burn-flex/src/ops/attention.rs new file mode 100644 index 0000000..48a9c00 --- /dev/null +++ b/crates/burn-flex/src/ops/attention.rs @@ -0,0 +1,1488 @@ +//! Attention (scaled dot-product) for CPU. +//! +//! Computes: softmax(Q @ K^T * scale + bias) @ V +//! +//! Two strategies, auto-selected by `attention()` based on sequence length: +//! +//! - **Naive** (seq_kv <= 8*TILE_KV): materializes full score matrix, two +//! large gemm calls per (batch, head). Faster for short sequences. +//! - **Flash** (seq_kv > 8*TILE_KV): tiles over KV with online softmax, +//! O(TILE_KV) scratch per row. Better cache behavior for long sequences. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::DType; +use burn_backend::ops::AttentionModuleOptions; +use burn_std::Bytes; +use bytemuck::Pod; +use num_traits::Float; + +use crate::{FlexTensor, Layout}; + +/// KV tile size for flash attention. +/// +/// Chosen so the score row (TILE_KV * 4 bytes for f32) and the V tile +/// [TILE_KV, val_dim] fit comfortably in L1. With val_dim=128 the V tile +/// is 32KB; this fits well on Apple Silicon (64-128KB L1) and modern x86 +/// (48KB+ L1d since Golden Cove / Zen 4). On older x86 with 32KB L1d the +/// tile saturates L1 but still benefits from L2 residency. +/// WASM targets use a smaller tile to stay within tighter cache budgets. +#[cfg(target_family = "wasm")] +const TILE_KV: usize = 32; +#[cfg(not(target_family = "wasm"))] +const TILE_KV: usize = 64; + +/// Max score matrix size (in elements) for the naive path. +/// +/// Naive attention materializes a [seq_q, seq_kv] score matrix per head. +/// When this exceeds the budget, flash attention is used instead. +/// 256K elements = 1 MB for f32, fits comfortably in L2. +const NAIVE_SCORE_BUDGET: usize = 256 * 1024; + +/// Auto-selecting attention: picks the fastest strategy based on sequence length. +/// +/// Uses naive attention when the score matrix (seq_q * seq_kv) fits within +/// `NAIVE_SCORE_BUDGET`. Falls back to flash attention for larger shapes. +pub fn attention( + query: FlexTensor, + key: FlexTensor, + value: FlexTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, +) -> FlexTensor { + debug_assert!( + query.layout().shape().num_dims() == 4, + "attention: query must be 4D, got {}D", + query.layout().shape().num_dims() + ); + debug_assert!( + key.layout().shape().num_dims() == 4, + "attention: key must be 4D, got {}D", + key.layout().shape().num_dims() + ); + let seq_q = query.layout().shape()[2]; + let seq_kv = key.layout().shape()[2]; + if seq_q * seq_kv <= NAIVE_SCORE_BUDGET { + return attention_naive(query, key, value, mask, attn_bias, options); + } + attention_flash(query, key, value, mask, attn_bias, options) +} + +/// Dispatch attention by dtype, casting f16/bf16 to f32 for computation. +macro_rules! dispatch_attention_dtype { + ($query:expr, $key:expr, $value:expr, $mask:expr, $attn_bias:expr, $options:expr, $impl_fn:ident) => {{ + let query = $query; + let key = $key; + let value = $value; + let mask = $mask; + let attn_bias = $attn_bias; + let options = $options; + let dtype = query.dtype(); + debug_assert_eq!(key.dtype(), dtype, "attention: key dtype mismatch"); + debug_assert_eq!(value.dtype(), dtype, "attention: value dtype mismatch"); + if let Some(ref b) = attn_bias { + debug_assert_eq!(b.dtype(), dtype, "attention: attn_bias dtype mismatch"); + } + match dtype { + DType::F32 => $impl_fn::(query, key, value, mask, attn_bias, options), + DType::F64 => $impl_fn::(query, key, value, mask, attn_bias, options), + DType::F16 => { + use burn_std::f16; + let r = $impl_fn::( + cast_to_f32(query, f16::to_f32), + cast_to_f32(key, f16::to_f32), + cast_to_f32(value, f16::to_f32), + mask, + attn_bias.map(|b| cast_to_f32(b, f16::to_f32)), + options, + ); + cast_from_f32(r, f16::from_f32) + } + DType::BF16 => { + use burn_std::bf16; + let r = $impl_fn::( + cast_to_f32(query, bf16::to_f32), + cast_to_f32(key, bf16::to_f32), + cast_to_f32(value, bf16::to_f32), + mask, + attn_bias.map(|b| cast_to_f32(b, bf16::to_f32)), + options, + ); + cast_from_f32(r, bf16::from_f32) + } + dtype => panic!("attention: unsupported dtype {:?}", dtype), + } + }}; +} + +/// Contiguous mask/bias tensor plus the per-batch and per-head element offsets the +/// inner loop should use to locate the `[seq_q, seq_kv]` tile for each `(batch, head)` +/// pair. When a leading dim (batch or heads) is `1` in the source, its step is `0`, so +/// the inner loop re-reads the same tile for every pair without allocating an expanded +/// copy. The tile length itself is always `seq_q * seq_kv` and is computed at the call +/// site, so it is not stored here. +struct BroadcastMaskBias { + tensor: FlexTensor, + batch_step: usize, + head_step: usize, +} + +/// Prepare an attention mask or bias for the inner loop, accepting ONNX Attention-23 +/// broadcast shapes. +/// +/// Stride-0 along the leading `[batch, heads]` dims is handled without materializing, +/// so the common ONNX patterns (`[1, 1, seq_q, seq_kv]`, `[batch, 1, seq_q, seq_kv]`, +/// `[1, heads, seq_q, seq_kv]`) stay zero-copy. That matters especially for the flash +/// path, where materializing an expanded mask/bias would allocate a full +/// `[batch, heads, seq_q, seq_kv]` buffer and negate flash attention's memory +/// efficiency. If the trailing `[seq_q, seq_kv]` dims are themselves broadcast (rare in +/// practice), we fall back to `expand` + `to_contiguous` so the tile stays contiguous +/// in memory for the inner loop's slice-based access. +fn broadcast_attn_mask_bias( + tensor: FlexTensor, + target: [usize; 4], + name: &'static str, +) -> BroadcastMaskBias { + let ndim = tensor.layout().shape().num_dims(); + assert!(ndim == 4, "attention: {name} must be 4D, got {ndim}D"); + let shape = tensor.layout().shape(); + let src = [shape[0], shape[1], shape[2], shape[3]]; + for i in 0..4 { + assert!( + src[i] == target[i] || src[i] == 1, + "attention: {name} dim {i} must be {} or 1, got {}", + target[i], + src[i] + ); + } + + let tile_len = target[2] * target[3]; + + // Broadcast on seq_q or seq_kv: the source's trailing tile has fewer elements + // than `tile_len`, so per-pair slice access would under-read. Materialize via + // expand + to_contiguous in that case. + if src[2] != target[2] || src[3] != target[3] { + let expanded = crate::ops::expand::expand(tensor, burn_std::Shape::new(target)); + return BroadcastMaskBias { + tensor: expanded.to_contiguous(), + batch_step: target[1] * tile_len, + head_step: tile_len, + }; + } + + // Trailing dims match the target. Keep the source at its own shape (size + // `src[0] * src[1] * tile_len`) and zero-out the step for any leading dim of 1. + BroadcastMaskBias { + tensor: tensor.to_contiguous(), + batch_step: if src[0] == 1 { 0 } else { src[1] * tile_len }, + head_step: if src[1] == 1 { 0 } else { tile_len }, + } +} + +/// Flash attention: tiled computation with online softmax. Use directly to bypass auto-selection. +pub fn attention_flash( + query: FlexTensor, + key: FlexTensor, + value: FlexTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, +) -> FlexTensor { + dispatch_attention_dtype!(query, key, value, mask, attn_bias, options, attention_impl) +} + +fn cast_to_f32( + tensor: FlexTensor, + to_f32: fn(E) -> f32, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let data: &[E] = tensor.storage(); + let f32_data: Vec = data.iter().map(|&v| to_f32(v)).collect(); + FlexTensor::new( + Bytes::from_elems(f32_data), + Layout::contiguous(shape), + DType::F32, + ) +} + +fn cast_from_f32( + tensor: FlexTensor, + from_f32: fn(f32) -> E, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let data: &[f32] = tensor.storage(); + let half_data: Vec = data.iter().map(|&v| from_f32(v)).collect(); + FlexTensor::new( + Bytes::from_elems(half_data), + Layout::contiguous(shape), + E::dtype(), + ) +} + +/// Flash attention: tiled computation that avoids materializing the full scores matrix. +/// +/// Input shapes (all 4D): +/// query: \[batch, heads, seq_q, head_dim\] +/// key: \[batch, heads, seq_kv, head_dim\] +/// value: \[batch, heads, seq_kv, val_dim\] +/// mask: \[batch, heads, seq_q, seq_kv\] (optional, u8 where nonzero = masked out) +/// attn_bias: \[batch, heads, seq_q, seq_kv\] (optional) +/// +/// Output: \[batch, heads, seq_q, val_dim\] +/// +/// Algorithm per (batch, head): +/// For each KV tile of size TILE_KV: +/// 1. Score matmul: scores\[seq_q, tile_kv\] = Q @ K_tile^T (gemm) +/// 2. Per query row: apply scale, softcap, mask, bias +/// 3. Per query row: online softmax update (running max/sum, rescale accumulator) +/// 4. Value matmul: output += P @ V_tile (gemm) +/// Final: output\[qi\] /= row_sum\[qi\] for each query row +fn attention_impl( + query: FlexTensor, + key: FlexTensor, + value: FlexTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, +) -> FlexTensor +where + T: FlashGemm + burn_backend::Element, +{ + if let Some(softcap) = options.softcap { + assert!(softcap > 0.0, "softcap must be positive, got {softcap}"); + } + + let query = query.to_contiguous(); + let key = key.to_contiguous(); + let value = value.to_contiguous(); + + let q_shape = query.layout().shape(); + let k_shape = key.layout().shape(); + let v_shape = value.layout().shape(); + assert!(q_shape.num_dims() == 4, "attention: query must be 4D"); + assert!(k_shape.num_dims() == 4, "attention: key must be 4D"); + assert!(v_shape.num_dims() == 4, "attention: value must be 4D"); + + let batch = q_shape[0]; + let heads = q_shape[1]; + let seq_q = q_shape[2]; + let head_dim = q_shape[3]; + assert!(head_dim > 0, "attention: head_dim must be non-zero"); + + let seq_kv = k_shape[2]; + let val_dim = v_shape[3]; + + assert_eq!(k_shape[0], batch, "attention: key batch mismatch"); + assert_eq!(k_shape[1], heads, "attention: key heads mismatch"); + assert_eq!(k_shape[3], head_dim, "attention: key head_dim mismatch"); + assert_eq!(v_shape[0], batch, "attention: value batch mismatch"); + assert_eq!(v_shape[1], heads, "attention: value heads mismatch"); + assert_eq!(v_shape[2], seq_kv, "attention: value seq_kv mismatch"); + + let target = [batch, heads, seq_q, seq_kv]; + let mask_bcast = mask.map(|m| broadcast_attn_mask_bias(m, target, "mask")); + let bias_bcast = attn_bias.map(|b| broadcast_attn_mask_bias(b, target, "bias")); + + let scale = T::from( + options + .scale + .unwrap_or_else(|| 1.0 / (head_dim as f64).sqrt()), + ) + .unwrap(); + let softcap: Option = options.softcap.map(|s| T::from(s).unwrap()); + let causal_offset = if options.is_causal { + Some(seq_kv as isize - seq_q as isize) + } else { + None + }; + + let q_data: &[T] = query.storage(); + let k_data: &[T] = key.storage(); + let v_data: &[T] = value.storage(); + let mask_data: Option<&[u8]> = mask_bcast.as_ref().map(|b| b.tensor.bytes()); + let bias_data: Option<&[T]> = bias_bcast.as_ref().map(|b| b.tensor.storage()); + let (mask_batch_step, mask_head_step) = mask_bcast + .as_ref() + .map(|b| (b.batch_step, b.head_step)) + .unwrap_or((0, 0)); + let (bias_batch_step, bias_head_step) = bias_bcast + .as_ref() + .map(|b| (b.batch_step, b.head_step)) + .unwrap_or((0, 0)); + + let mut output = vec![T::zero(); batch * heads * seq_q * val_dim]; + + // 4D strides for contiguous layout + let q_head_stride = seq_q * head_dim; + let q_batch_stride = heads * q_head_stride; + let k_head_stride = seq_kv * head_dim; + let k_batch_stride = heads * k_head_stride; + let v_head_stride = seq_kv * val_dim; + let v_batch_stride = heads * v_head_stride; + let o_head_stride = seq_q * val_dim; + let o_batch_stride = heads * o_head_stride; + let mask_tile_len = seq_q * seq_kv; + + let params = AttentionParams { + scale, + softcap, + causal_offset, + seq_q, + seq_kv, + head_dim, + val_dim, + }; + + // Allocate scratch buffers once and reuse across all (batch, head) pairs + let mut scratch = ScratchBuffers { + row_max: vec![T::neg_infinity(); seq_q], + row_sum: vec![T::zero(); seq_q], + scores: vec![T::zero(); seq_q * TILE_KV], + }; + + for b in 0..batch { + for h in 0..heads { + let q_off = b * q_batch_stride + h * q_head_stride; + let k_off = b * k_batch_stride + h * k_head_stride; + let v_off = b * v_batch_stride + h * v_head_stride; + let o_off = b * o_batch_stride + h * o_head_stride; + let mask_off = b * mask_batch_step + h * mask_head_step; + let bias_off = b * bias_batch_step + h * bias_head_step; + + flash_attention_head( + &q_data[q_off..q_off + q_head_stride], + &k_data[k_off..k_off + k_head_stride], + &v_data[v_off..v_off + v_head_stride], + &mut output[o_off..o_off + o_head_stride], + mask_data.map(|m| &m[mask_off..mask_off + mask_tile_len]), + bias_data.map(|b| &b[bias_off..bias_off + mask_tile_len]), + ¶ms, + &mut scratch, + ); + } + } + + let shape = burn_std::Shape::from(vec![batch, heads, seq_q, val_dim]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(shape), + T::dtype(), + ) +} + +/// Gemm dispatch for flash attention block matmuls. +/// +/// Wraps `gemm::gemm` for f32 and f64 so `flash_attention_head` stays generic. +/// Convention: dst = alpha * dst + beta * (lhs @ rhs) +trait FlashGemm: Float + Pod + Copy + core::ops::AddAssign { + /// Block matrix multiply used for score and value matmuls. + /// + /// # Safety + /// All pointers must be valid for the given dimensions and strides. + unsafe fn block_gemm(args: BlockGemmArgs); +} + +/// Arguments for a block matrix multiply: dst = alpha * dst + beta * (lhs @ rhs). +struct BlockGemmArgs { + m: usize, + n: usize, + k: usize, + dst: *mut T, + dst_cs: isize, + dst_rs: isize, + read_dst: bool, + lhs: *const T, + lhs_cs: isize, + lhs_rs: isize, + rhs: *const T, + rhs_cs: isize, + rhs_rs: isize, + alpha: T, + beta: T, +} + +macro_rules! impl_flash_gemm { + ($ty:ty) => { + impl FlashGemm for $ty { + unsafe fn block_gemm(a: BlockGemmArgs) { + unsafe { + gemm::gemm( + a.m, + a.n, + a.k, + a.dst, + a.dst_cs, + a.dst_rs, + a.read_dst, + a.lhs, + a.lhs_cs, + a.lhs_rs, + a.rhs, + a.rhs_cs, + a.rhs_rs, + a.alpha, + a.beta, + false, + false, + false, + gemm::Parallelism::None, + ); + } + } + } + }; +} + +impl_flash_gemm!(f32); +impl_flash_gemm!(f64); + +/// Scratch buffers reused across (batch, head) pairs to avoid per-head allocation. +struct ScratchBuffers { + row_max: Vec, + row_sum: Vec, + scores: Vec, +} + +/// Parameters for a single (batch, head) flash attention computation. +struct AttentionParams { + scale: T, + softcap: Option, + causal_offset: Option, + seq_q: usize, + seq_kv: usize, + head_dim: usize, + val_dim: usize, +} + +#[allow(clippy::too_many_arguments)] +/// Process a single (batch, head) pair with flash attention. +/// +/// Uses gemm for the two block matmuls per tile: +/// 1. Score matmul: scores\[seq_q, tile_kv\] = Q @ K_tile^T +/// 2. Value matmul: output += P @ V_tile (where P = exp(scores - max)) +/// +/// The online softmax (scale, mask, bias, exp, correction) is applied +/// row-by-row between the two gemm calls. +fn flash_attention_head( + q: &[T], + k: &[T], + v: &[T], + output: &mut [T], + mask: Option<&[u8]>, + bias: Option<&[T]>, + p: &AttentionParams, + scratch: &mut ScratchBuffers, +) { + debug_assert_eq!(q.len(), p.seq_q * p.head_dim); + debug_assert_eq!(k.len(), p.seq_kv * p.head_dim); + debug_assert_eq!(v.len(), p.seq_kv * p.val_dim); + debug_assert_eq!(output.len(), p.seq_q * p.val_dim); + + let neg_inf = T::neg_infinity(); + let AttentionParams { + scale, + softcap, + causal_offset, + seq_q, + seq_kv, + head_dim, + val_dim, + } = *p; + + // Reset scratch buffers for this (batch, head) pair + let row_max = &mut scratch.row_max; + row_max.fill(neg_inf); + let row_sum = &mut scratch.row_sum; + row_sum.fill(T::zero()); + let scores = &mut scratch.scores; + + let num_kv_tiles = seq_kv.div_ceil(TILE_KV); + + for tile_idx in 0..num_kv_tiles { + let kv_start = tile_idx * TILE_KV; + let kv_end = (kv_start + TILE_KV).min(seq_kv); + let tile_kv = kv_end - kv_start; + + // Step 1: Score matmul via gemm + // scores[seq_q, tile_kv] = Q[seq_q, head_dim] @ K_tile[tile_kv, head_dim]^T + // + // Q is row-major [seq_q, head_dim]: rs=head_dim, cs=1 + // K_tile^T: treat K[tile_kv, head_dim] row-major with swapped strides + // K row-major: rs=head_dim, cs=1 -> K^T: rs=1, cs=head_dim + // scores row-major [seq_q, tile_kv]: rs=tile_kv, cs=1 + unsafe { + T::block_gemm(BlockGemmArgs { + m: seq_q, + n: tile_kv, + k: head_dim, + dst: scores.as_mut_ptr(), + dst_cs: 1, + dst_rs: tile_kv as isize, + read_dst: false, + lhs: q.as_ptr(), + lhs_cs: 1, + lhs_rs: head_dim as isize, + rhs: k.as_ptr().add(kv_start * head_dim), + rhs_cs: head_dim as isize, + rhs_rs: 1, + alpha: T::zero(), + beta: T::one(), + }); + } + + // Step 2: Apply scale/softcap/mask/bias, online softmax, rescale output + for qi in 0..seq_q { + let score_row = &mut scores[qi * tile_kv..(qi + 1) * tile_kv]; + + let mut tile_max = neg_inf; + + for (ki, score) in score_row.iter_mut().enumerate() { + let kv_idx = kv_start + ki; + let mut val = *score * scale; + + if let Some(cap) = softcap { + val = cap * (val / cap).tanh(); + } + + if let Some(m) = mask + && m[qi * seq_kv + kv_idx] != 0 + { + val = neg_inf; + } + + if let Some(offset) = causal_offset + && (kv_idx as isize) > (qi as isize) + offset + { + val = neg_inf; + } + + if let Some(b) = bias { + val += b[qi * seq_kv + kv_idx]; + } + + *score = val; + if val > tile_max { + tile_max = val; + } + } + + if tile_max == neg_inf { + // All masked: zero out scores so gemm contributes nothing + for score in score_row.iter_mut() { + *score = T::zero(); + } + continue; + } + + let new_max = if row_max[qi] > tile_max { + row_max[qi] + } else { + tile_max + }; + + let mut tile_sum = T::zero(); + for score in score_row.iter_mut() { + let e = (*score - new_max).exp(); + *score = e; + tile_sum += e; + } + + let correction = if row_max[qi] == neg_inf { + T::zero() + } else { + (row_max[qi] - new_max).exp() + }; + + // Rescale existing output accumulator + let out_row = &mut output[qi * val_dim..(qi + 1) * val_dim]; + for o in out_row.iter_mut() { + *o = *o * correction; + } + + row_sum[qi] = row_sum[qi] * correction + tile_sum; + row_max[qi] = new_max; + } + + // Step 3: Value matmul via gemm + // output[seq_q, val_dim] += P[seq_q, tile_kv] @ V_tile[tile_kv, val_dim] + // + // P (scores buffer) row-major: rs=tile_kv, cs=1 + // V_tile row-major [tile_kv, val_dim]: rs=val_dim, cs=1 + // output row-major [seq_q, val_dim]: rs=val_dim, cs=1 + // + // dst = 1.0 * dst + 1.0 * P @ V (accumulate) + unsafe { + T::block_gemm(BlockGemmArgs { + m: seq_q, + n: val_dim, + k: tile_kv, + dst: output.as_mut_ptr(), + dst_cs: 1, + dst_rs: val_dim as isize, + read_dst: true, + lhs: scores.as_ptr(), + lhs_cs: 1, + lhs_rs: tile_kv as isize, + rhs: v.as_ptr().add(kv_start * val_dim), + rhs_cs: 1, + rhs_rs: val_dim as isize, + alpha: T::one(), + beta: T::one(), + }); + } + } + + // Final normalization: output /= row_sum + for qi in 0..seq_q { + let sum = row_sum[qi]; + if sum > T::zero() { + let inv_sum = T::one() / sum; + let out_row = &mut output[qi * val_dim..(qi + 1) * val_dim]; + for o in out_row.iter_mut() { + *o = *o * inv_sum; + } + } + // sum == 0 means all positions masked; output stays zero + } +} + +/// Naive attention: mathematically equivalent to flash but without KV tiling. +/// +/// Materializes the full [seq_q, seq_kv] score matrix, applies scale/softcap/mask/causal/bias, +/// row-wise softmax, then a single output matmul. Two large gemm calls per (batch, head). +/// +/// Faster than flash for short sequences. Also useful as a benchmarking baseline. +pub fn attention_naive( + query: FlexTensor, + key: FlexTensor, + value: FlexTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, +) -> FlexTensor { + dispatch_attention_dtype!( + query, + key, + value, + mask, + attn_bias, + options, + attention_naive_impl + ) +} + +fn attention_naive_impl( + query: FlexTensor, + key: FlexTensor, + value: FlexTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, +) -> FlexTensor +where + T: FlashGemm + burn_backend::Element, +{ + if let Some(softcap) = options.softcap { + assert!(softcap > 0.0, "softcap must be positive, got {softcap}"); + } + + let query = query.to_contiguous(); + let key = key.to_contiguous(); + let value = value.to_contiguous(); + + let q_shape = query.layout().shape(); + let k_shape = key.layout().shape(); + let v_shape = value.layout().shape(); + assert!(q_shape.num_dims() == 4, "attention_naive: query must be 4D"); + assert!(k_shape.num_dims() == 4, "attention_naive: key must be 4D"); + assert!(v_shape.num_dims() == 4, "attention_naive: value must be 4D"); + + let batch = q_shape[0]; + let heads = q_shape[1]; + let seq_q = q_shape[2]; + let head_dim = q_shape[3]; + assert!(head_dim > 0, "attention_naive: head_dim must be non-zero"); + + let seq_kv = k_shape[2]; + let val_dim = v_shape[3]; + + assert_eq!(k_shape[0], batch, "attention_naive: key batch mismatch"); + assert_eq!(k_shape[1], heads, "attention_naive: key heads mismatch"); + assert_eq!( + k_shape[3], head_dim, + "attention_naive: key head_dim mismatch" + ); + assert_eq!(v_shape[0], batch, "attention_naive: value batch mismatch"); + assert_eq!(v_shape[1], heads, "attention_naive: value heads mismatch"); + assert_eq!(v_shape[2], seq_kv, "attention_naive: value seq_kv mismatch"); + + let target = [batch, heads, seq_q, seq_kv]; + let mask_bcast = mask.map(|m| broadcast_attn_mask_bias(m, target, "mask")); + let bias_bcast = attn_bias.map(|b| broadcast_attn_mask_bias(b, target, "bias")); + + let scale = T::from( + options + .scale + .unwrap_or_else(|| 1.0 / (head_dim as f64).sqrt()), + ) + .unwrap(); + let softcap: Option = options.softcap.map(|s| T::from(s).unwrap()); + let causal_offset = if options.is_causal { + Some(seq_kv as isize - seq_q as isize) + } else { + None + }; + + let q_data: &[T] = query.storage(); + let k_data: &[T] = key.storage(); + let v_data: &[T] = value.storage(); + let mask_data: Option<&[u8]> = mask_bcast.as_ref().map(|b| b.tensor.bytes()); + let bias_data: Option<&[T]> = bias_bcast.as_ref().map(|b| b.tensor.storage()); + let (mask_batch_step, mask_head_step) = mask_bcast + .as_ref() + .map(|b| (b.batch_step, b.head_step)) + .unwrap_or((0, 0)); + let (bias_batch_step, bias_head_step) = bias_bcast + .as_ref() + .map(|b| (b.batch_step, b.head_step)) + .unwrap_or((0, 0)); + + let mut output = vec![T::zero(); batch * heads * seq_q * val_dim]; + let mut scores = vec![T::zero(); seq_q * seq_kv]; + + let q_head_stride = seq_q * head_dim; + let q_batch_stride = heads * q_head_stride; + let k_head_stride = seq_kv * head_dim; + let k_batch_stride = heads * k_head_stride; + let v_head_stride = seq_kv * val_dim; + let v_batch_stride = heads * v_head_stride; + let o_head_stride = seq_q * val_dim; + let o_batch_stride = heads * o_head_stride; + let mask_tile_len = seq_q * seq_kv; + + let params = AttentionParams { + scale, + softcap, + causal_offset, + seq_q, + seq_kv, + head_dim, + val_dim, + }; + + for b in 0..batch { + for h in 0..heads { + let q_off = b * q_batch_stride + h * q_head_stride; + let k_off = b * k_batch_stride + h * k_head_stride; + let v_off = b * v_batch_stride + h * v_head_stride; + let o_off = b * o_batch_stride + h * o_head_stride; + let mask_off = b * mask_batch_step + h * mask_head_step; + let bias_off = b * bias_batch_step + h * bias_head_step; + + naive_attention_head( + &q_data[q_off..q_off + q_head_stride], + &k_data[k_off..k_off + k_head_stride], + &v_data[v_off..v_off + v_head_stride], + &mut output[o_off..o_off + o_head_stride], + &mut scores, + ¶ms, + ( + mask_data.map(|m| &m[mask_off..mask_off + mask_tile_len]), + bias_data.map(|b| &b[bias_off..bias_off + mask_tile_len]), + ), + ); + } + } + + let shape = burn_std::Shape::from(vec![batch, heads, seq_q, val_dim]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(shape), + T::dtype(), + ) +} + +/// Process a single (batch, head) pair with naive (non-tiled) attention. +/// +/// 1. scores[seq_q, seq_kv] = Q @ K^T (one gemm) +/// 2. Apply scale/softcap/mask/causal/bias + softmax (per-row) +/// 3. output[seq_q, val_dim] = scores @ V (one gemm) +fn naive_attention_head( + q: &[T], + k: &[T], + v: &[T], + output: &mut [T], + scores: &mut [T], + p: &AttentionParams, + mask_bias: (Option<&[u8]>, Option<&[T]>), +) { + let (mask, bias) = mask_bias; + let neg_inf = T::neg_infinity(); + let AttentionParams { + scale, + softcap, + causal_offset, + seq_q, + seq_kv, + head_dim, + val_dim, + } = *p; + + // scores = Q @ K^T + unsafe { + T::block_gemm(BlockGemmArgs { + m: seq_q, + n: seq_kv, + k: head_dim, + dst: scores.as_mut_ptr(), + dst_cs: 1, + dst_rs: seq_kv as isize, + read_dst: false, + lhs: q.as_ptr(), + lhs_cs: 1, + lhs_rs: head_dim as isize, + rhs: k.as_ptr(), + rhs_cs: head_dim as isize, + rhs_rs: 1, + alpha: T::zero(), + beta: T::one(), + }); + } + + for qi in 0..seq_q { + let row = &mut scores[qi * seq_kv..(qi + 1) * seq_kv]; + + let mut row_max = neg_inf; + for (ki, s) in row.iter_mut().enumerate() { + let mut val = *s * scale; + + if let Some(cap) = softcap { + val = cap * (val / cap).tanh(); + } + + if let Some(m) = mask + && m[qi * seq_kv + ki] != 0 + { + val = neg_inf; + } + + if let Some(offset) = causal_offset + && (ki as isize) > (qi as isize) + offset + { + val = neg_inf; + } + + if let Some(b) = bias { + val += b[qi * seq_kv + ki]; + } + + *s = val; + if val > row_max { + row_max = val; + } + } + + if row_max == neg_inf { + row.fill(T::zero()); + continue; + } + + let mut sum = T::zero(); + for s in row.iter_mut() { + let e = (*s - row_max).exp(); + *s = e; + sum += e; + } + + let inv_sum = T::one() / sum; + for s in row.iter_mut() { + *s = *s * inv_sum; + } + } + + // output = softmax(scores) @ V + unsafe { + T::block_gemm(BlockGemmArgs { + m: seq_q, + n: val_dim, + k: seq_kv, + dst: output.as_mut_ptr(), + dst_cs: 1, + dst_rs: val_dim as isize, + read_dst: false, + lhs: scores.as_ptr(), + lhs_cs: 1, + lhs_rs: seq_kv as isize, + rhs: v.as_ptr(), + rhs_cs: 1, + rhs_rs: val_dim as isize, + alpha: T::zero(), + beta: T::one(), + }); + } +} + +// Tests kept here exercise flex-specific internals: direct calls into +// `attention_flash` / `attention_naive` (the public `attention()` dispatcher +// routes small shapes to naive so flash-path coverage requires a direct +// call), the `broadcast_attn_mask_bias` helper's rank/dim validation, +// flex-internal tiling and online-softmax correction paths, and +// dtype-specific kernels (f16/f64). Generic attention semantics (causal, +// custom scale, softcap, cross-attention, bool mask, additive bias, +// multi-batch/multi-head, single-element) live in +// crates/burn-backend-tests/tests/tensor/float/module/attention.rs, which +// exercises every backend. When adding new tests, keep them here only if +// they probe flex internals; otherwise add them to that suite. +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + use burn_backend::DType; + use burn_backend::ops::AttentionModuleOptions; + use burn_std::{BoolStore, Bytes, Shape, f16}; + + use crate::{FlexTensor, Layout}; + + fn flex_f32(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(shape.to_vec())), + DType::F32, + ) + } + + fn flex_f64(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(shape.to_vec())), + DType::F64, + ) + } + + fn flex_f16(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(shape.to_vec())), + DType::F16, + ) + } + + fn flex_bool(data: Vec, shape: &[usize]) -> FlexTensor { + FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(shape.to_vec())), + DType::Bool(BoolStore::Native), + ) + } + + /// Assert two attention-output slices are elementwise close. Used by the + /// flash broadcast tests below. + fn assert_attention_outputs_close(bcast: &[f32], full: &[f32], label: &str) { + assert_eq!(bcast.len(), full.len(), "{label}: length mismatch"); + for (i, (&a, &b)) in bcast.iter().zip(full).enumerate() { + assert!((a - b).abs() < 1e-5, "{label} mismatch at {i}: {a} vs {b}"); + } + } + + #[test] + fn test_flash_bias_broadcast_across_batch_and_heads() { + // Exercises the flash path for a `[1, 1, seq_q, seq_kv]` broadcast + // bias. The dispatcher in `attention()` routes to naive for + // `seq_q * seq_kv <= NAIVE_SCORE_BUDGET` (and the backend-tests + // attention suite uses small shapes), so the flash entry needs a + // direct call to stay covered. General broadcast semantics for the + // main `attention()` path live in + // crates/burn-backend-tests/tests/tensor/float/module/attention.rs. + let batch = 2; + let heads = 2; + let seq_q = 3; + let seq_kv = 5; + let head_dim = 4; + + let mk = |shape: &[usize], g: &dyn Fn(usize) -> f32| -> FlexTensor { + let len: usize = shape.iter().product(); + flex_f32((0..len).map(g).collect(), shape) + }; + + let q = mk(&[batch, heads, seq_q, head_dim], &|i| { + (i as f32 * 0.1).sin() + }); + let k = mk(&[batch, heads, seq_kv, head_dim], &|i| { + (i as f32 * 0.1 + 1.0).sin() + }); + let v = mk(&[batch, heads, seq_kv, head_dim], &|i| { + (i as f32 * 0.1 + 2.0).sin() + }); + + let bias_tile: Vec = (0..seq_q * seq_kv) + .map(|i| (i as f32 * 0.4).sin()) + .collect(); + let bias_bcast = flex_f32(bias_tile.clone(), &[1, 1, seq_q, seq_kv]); + let bias_full_vec: Vec = bias_tile + .iter() + .cloned() + .cycle() + .take(batch * heads * seq_q * seq_kv) + .collect(); + let bias_full = flex_f32(bias_full_vec, &[batch, heads, seq_q, seq_kv]); + + let out_bcast = super::attention_flash( + q.clone(), + k.clone(), + v.clone(), + None, + Some(bias_bcast), + Default::default(), + ); + let out_full = super::attention_flash(q, k, v, None, Some(bias_full), Default::default()); + + let bcast: &[f32] = out_bcast.storage(); + let full: &[f32] = out_full.storage(); + assert_attention_outputs_close(bcast, full, "flash bias[1,1,sq,skv]"); + } + + #[test] + fn test_flash_bool_mask_broadcast_across_batch_and_heads() { + // Flash-path counterpart to the bias broadcast test, but for a + // `[1, 1, seq_q, seq_kv]` bool mask. The mask (u8) slicing path with + // stride-0 batch/head steps is a different branch from the bias + // (f32) path, so both need direct flash coverage. + let batch = 2; + let heads = 2; + let seq_q = 3; + let seq_kv = 5; + let head_dim = 4; + + let mk = |shape: &[usize], g: &dyn Fn(usize) -> f32| -> FlexTensor { + let len: usize = shape.iter().product(); + flex_f32((0..len).map(g).collect(), shape) + }; + + let q = mk(&[batch, heads, seq_q, head_dim], &|i| { + (i as f32 * 0.1).sin() + }); + let k = mk(&[batch, heads, seq_kv, head_dim], &|i| { + (i as f32 * 0.1 + 1.0).sin() + }); + let v = mk(&[batch, heads, seq_kv, head_dim], &|i| { + (i as f32 * 0.1 + 2.0).sin() + }); + + // Mask out columns 3 and 4 (1 == masked out). + let mask_tile: Vec = (0..seq_q * seq_kv) + .map(|i| if (i % seq_kv) >= 3 { 1u8 } else { 0u8 }) + .collect(); + let mask_bcast = flex_bool(mask_tile.clone(), &[1, 1, seq_q, seq_kv]); + let mask_full_vec: Vec = mask_tile + .iter() + .copied() + .cycle() + .take(batch * heads * seq_q * seq_kv) + .collect(); + let mask_full = flex_bool(mask_full_vec, &[batch, heads, seq_q, seq_kv]); + + let out_bcast = super::attention_flash( + q.clone(), + k.clone(), + v.clone(), + Some(mask_bcast), + None, + Default::default(), + ); + let out_full = super::attention_flash(q, k, v, Some(mask_full), None, Default::default()); + + let bcast: &[f32] = out_bcast.storage(); + let full: &[f32] = out_full.storage(); + assert_attention_outputs_close(bcast, full, "flash bool mask[1,1,sq,skv]"); + } + + #[test] + #[should_panic(expected = "must be 4D")] + fn test_mask_wrong_rank_panics() { + // Contract: the broadcast helper rejects non-4D mask/bias up-front + // with a clearer message than `expand`'s rank prepending would produce. + let mask = flex_bool(vec![0u8; 6], &[2, 3]); + super::broadcast_attn_mask_bias(mask, [1, 1, 2, 3], "mask"); + } + + #[test] + #[should_panic(expected = "bias dim 1 must be 3 or 1, got 2")] + fn test_bias_incompatible_dim_panics() { + // Contract: a dim that is neither equal to target nor `1` is a hard + // error. Here heads=2 in the bias but target heads=3. + let bias = flex_f32(vec![0.0f32; 2 * 2 * 4 * 5], &[2, 2, 4, 5]); + super::broadcast_attn_mask_bias(bias, [2, 3, 4, 5], "bias"); + } + + #[test] + fn test_multi_tile_seq_kv() { + // seq_kv > TILE_KV (64) exercises tiling with online softmax + // correction. 128 keys = exactly 2 tiles. + let seq_q = 2; + let seq_kv = 128; + let head_dim = 4; + let val_dim = 2; + + // Q: first query selects dim 0, second selects dim 1. + let mut q_data = vec![0.0f32; seq_q * head_dim]; + q_data[0] = 1.0; + q_data[head_dim + 1] = 1.0; + + // K: all keys identical so all scores are equal. + let k_data = vec![0.1f32; seq_kv * head_dim]; + + // V: linearly increasing values. + let mut v_data = vec![0.0f32; seq_kv * val_dim]; + for i in 0..seq_kv { + v_data[i * val_dim] = i as f32; + v_data[i * val_dim + 1] = (seq_kv - 1 - i) as f32; + } + + let q = flex_f32(q_data, &[1, 1, seq_q, head_dim]); + let k = flex_f32(k_data, &[1, 1, seq_kv, head_dim]); + let v = flex_f32(v_data, &[1, 1, seq_kv, val_dim]); + + let result = super::attention(q, k, v, None, None, Default::default()); + let data: &[f32] = result.storage(); + + // All scores equal -> output = mean of 0..127 = 63.5 for both cols. + assert_eq!(data.len(), seq_q * val_dim); + assert!( + (data[0] - 63.5).abs() < 0.1, + "expected ~63.5, got {}", + data[0] + ); + assert!( + (data[1] - 63.5).abs() < 0.1, + "expected ~63.5, got {}", + data[1] + ); + } + + #[test] + fn test_multi_tile_causal() { + // seq_kv=128 with causal mask: each query sees only up to + // causal_offset + its own index worth of keys. + let seq_q = 4; + let seq_kv = 128; + let head_dim = 2; + let val_dim = 1; + + // All queries/keys [1, 0] so all visible scores equal. + let mut q_data = vec![0.0f32; seq_q * head_dim]; + for i in 0..seq_q { + q_data[i * head_dim] = 1.0; + } + let mut k_data = vec![0.0f32; seq_kv * head_dim]; + for i in 0..seq_kv { + k_data[i * head_dim] = 1.0; + } + let v_data: Vec = (0..seq_kv).map(|i| i as f32).collect(); + + let q = flex_f32(q_data, &[1, 1, seq_q, head_dim]); + let k = flex_f32(k_data, &[1, 1, seq_kv, head_dim]); + let v = flex_f32(v_data, &[1, 1, seq_kv, val_dim]); + + let opts = AttentionModuleOptions { + is_causal: true, + ..Default::default() + }; + let result = super::attention(q, k, v, None, None, opts); + let data: &[f32] = result.storage(); + + // causal_offset = seq_kv - seq_q = 124 + // q[0] sees 0..=124 -> mean 62.0; q[1] 62.5; q[2] 63.0; q[3] 63.5. + assert_eq!(data.len(), seq_q); + assert!((data[0] - 62.0).abs() < 0.1, "q0: got {}", data[0]); + assert!((data[1] - 62.5).abs() < 0.1, "q1: got {}", data[1]); + assert!((data[2] - 63.0).abs() < 0.1, "q2: got {}", data[2]); + assert!((data[3] - 63.5).abs() < 0.1, "q3: got {}", data[3]); + } + + #[test] + fn test_tile_boundary_mask() { + // Mask falls exactly on a tile boundary: first 64 keys masked, + // next 64 visible. + let seq_q = 1; + let seq_kv = 128; + let head_dim = 2; + let val_dim = 1; + + let q_data = vec![1.0f32, 0.0]; + let k_data = [1.0f32, 0.0].repeat(seq_kv); + let v_data: Vec = (0..seq_kv).map(|i| i as f32).collect(); + // true == masked out. + let mask_data: Vec = (0..seq_kv).map(|i| (i < 64) as u8).collect(); + + let q = flex_f32(q_data, &[1, 1, seq_q, head_dim]); + let k = flex_f32(k_data, &[1, 1, seq_kv, head_dim]); + let v = flex_f32(v_data, &[1, 1, seq_kv, val_dim]); + let mask = flex_bool(mask_data, &[1, 1, seq_q, seq_kv]); + + let result = super::attention(q, k, v, Some(mask), None, Default::default()); + let data: &[f32] = result.storage(); + + // Visible = 64..128 -> mean of 64..=127 = 95.5. + assert!( + (data[0] - 95.5).abs() < 0.1, + "expected ~95.5, got {}", + data[0] + ); + } + + #[test] + fn test_non_uniform_scores_across_tiles() { + // Forces the online softmax correction path: tile 1 has much + // larger scores than tile 0, so the correction factor + // exp(old_max - new_max) < 1. + let seq_q = 1; + let seq_kv = 128; + let head_dim = 1; + let val_dim = 1; + + let q_data = vec![1.0f32]; + let mut k_data = vec![0.0f32; seq_kv]; + for k in k_data.iter_mut().take(64) { + *k = 0.1; + } + for k in k_data.iter_mut().skip(64) { + *k = 5.0; + } + let mut v_data = vec![0.0f32; seq_kv]; + for v in v_data.iter_mut().skip(64) { + *v = 1.0; + } + + let q = flex_f32(q_data, &[1, 1, seq_q, head_dim]); + let k = flex_f32(k_data, &[1, 1, seq_kv, head_dim]); + let v = flex_f32(v_data, &[1, 1, seq_kv, val_dim]); + + let result = super::attention(q, k, v, None, None, Default::default()); + let data: &[f32] = result.storage(); + + // Large-score keys (tile 2) dominate softmax -> output ~ 1.0. + assert!(data[0] > 0.99, "expected ~1.0, got {}", data[0]); + } + + #[test] + fn test_partial_last_tile() { + // seq_kv=100 is not a multiple of TILE_KV=64, so the last tile has + // 36 elements -> exercises partial-tile gemm and score buffer + // indexing. + let seq_q = 2; + let seq_kv = 100; + let head_dim = 2; + let val_dim = 1; + + let q_data = [0.1f32, 0.1].repeat(seq_q); + let k_data = [0.1f32, 0.1].repeat(seq_kv); + let v_data: Vec = (0..seq_kv).map(|i| i as f32).collect(); + + let q = flex_f32(q_data, &[1, 1, seq_q, head_dim]); + let k = flex_f32(k_data, &[1, 1, seq_kv, head_dim]); + let v = flex_f32(v_data, &[1, 1, seq_kv, val_dim]); + + let result = super::attention(q, k, v, None, None, Default::default()); + let data: &[f32] = result.storage(); + + // Uniform attention -> mean of 0..99 = 49.5. + assert_eq!(data.len(), seq_q); + assert!( + (data[0] - 49.5).abs() < 0.1, + "expected ~49.5, got {}", + data[0] + ); + assert!( + (data[1] - 49.5).abs() < 0.1, + "expected ~49.5, got {}", + data[1] + ); + } + + /// Verify naive attention produces the same results as flash attention + /// across various configurations. + #[test] + fn test_naive_matches_flash() { + /// Deterministic tensor for cross-validation tests. + /// + /// Uses `i * 997 % N` as a cheap hash: 997 is prime and coprime to + /// any power-of-two length, so the sequence visits all residues + /// before repeating. For f32 this gives values in [-0.5, 0.5]. For + /// bool masks it gives ~30% density with an irregular pattern that + /// varies across rows (unlike a regular `i % 3` stride). + fn make_tensor(shape: &[usize], dtype: DType) -> FlexTensor { + let len: usize = shape.iter().product(); + match dtype { + DType::F32 => { + let data: Vec = + (0..len).map(|i| ((i % 997) as f32 / 997.0) - 0.5).collect(); + flex_f32(data, shape) + } + DType::Bool(_) => { + let data: Vec = (0..len) + .map(|i| (i.wrapping_mul(997) % 100 < 30) as u8) + .collect(); + flex_bool(data, shape) + } + _ => unreachable!(), + } + } + + #[allow(clippy::too_many_arguments)] + fn run_both( + batch: usize, + heads: usize, + seq_q: usize, + seq_kv: usize, + head_dim: usize, + val_dim: usize, + with_mask: bool, + with_bias: bool, + options: AttentionModuleOptions, + label: &str, + ) { + let f32_dt = DType::F32; + let bool_dt = DType::Bool(BoolStore::Native); + let q = make_tensor(&[batch, heads, seq_q, head_dim], f32_dt); + let k = make_tensor(&[batch, heads, seq_kv, head_dim], f32_dt); + let v = make_tensor(&[batch, heads, seq_kv, val_dim], f32_dt); + let score_shape = [batch, heads, seq_q, seq_kv]; + let mask = with_mask.then(|| make_tensor(&score_shape, bool_dt)); + let bias = with_bias.then(|| make_tensor(&score_shape, f32_dt)); + + let flash = super::attention_flash( + q.clone(), + k.clone(), + v.clone(), + mask.clone(), + bias.clone(), + options, + ); + let naive = super::attention_naive(q, k, v, mask, bias, options); + + let flash_data: &[f32] = flash.storage(); + let naive_data: &[f32] = naive.storage(); + assert_eq!( + flash_data.len(), + naive_data.len(), + "{label}: length mismatch" + ); + + for (i, (&f, &n)) in flash_data.iter().zip(naive_data.iter()).enumerate() { + let diff = (f - n).abs(); + let tol = 1e-4 * f.abs().max(n.abs()).max(1.0); + assert!( + diff < tol, + "{label}: position {i}: flash={f} vs naive={n}, diff={diff}" + ); + } + } + + let default = AttentionModuleOptions::default(); + let causal = AttentionModuleOptions { + is_causal: true, + ..Default::default() + }; + let all_opts = AttentionModuleOptions { + scale: Some(0.05), + softcap: Some(30.0), + is_causal: true, + }; + + run_both(1, 1, 4, 4, 8, 8, false, false, default, "basic_4x4"); + run_both( + 2, + 4, + 8, + 8, + 16, + 16, + false, + false, + default, + "multi_head_batch", + ); + run_both(1, 2, 4, 32, 16, 16, false, false, default, "cross_attn"); + run_both(1, 1, 4, 128, 16, 16, false, false, default, "multi_tile"); + run_both(1, 2, 16, 16, 32, 32, false, false, causal, "causal"); + run_both(2, 2, 16, 16, 32, 32, false, false, all_opts, "all_options"); + run_both(1, 1, 32, 256, 64, 64, false, false, causal, "large_causal"); + run_both(1, 2, 8, 8, 16, 16, true, false, default, "with_mask"); + run_both(1, 2, 8, 8, 16, 16, false, true, default, "with_bias"); + run_both( + 2, + 2, + 16, + 128, + 32, + 32, + true, + true, + causal, + "mask_bias_causal", + ); + run_both(1, 1, 4, 100, 16, 16, false, false, default, "partial_tile"); + run_both( + 1, + 2, + 8, + 100, + 16, + 16, + false, + false, + causal, + "partial_tile_causal", + ); + } + + #[test] + fn test_f64_flash_attention() { + let q = flex_f64(vec![1.0f64, 0.0, 0.0, 1.0], &[1, 1, 2, 2]); + let k = q.clone(); + let v = flex_f64(vec![10.0f64, 20.0], &[1, 1, 2, 1]); + + let result = super::attention(q, k, v, None, None, Default::default()); + let data: &[f64] = result.storage(); + + assert!((data[0] - 13.30).abs() < 0.1, "got {}", data[0]); + assert!((data[1] - 16.70).abs() < 0.1, "got {}", data[1]); + } + + /// Verify f16 cast-to-f32 round-trip produces correct results for both + /// paths. + #[test] + fn test_f16_attention() { + let q_vec: Vec = [1.0f32, 0.0, 0.0, 1.0] + .iter() + .map(|&v| f16::from_f32(v)) + .collect(); + let v_vec: Vec = [10.0f32, 20.0].iter().map(|&v| f16::from_f32(v)).collect(); + let q = flex_f16(q_vec, &[1, 1, 2, 2]); + let k = q.clone(); + let v = flex_f16(v_vec, &[1, 1, 2, 1]); + + let flash = super::attention_flash( + q.clone(), + k.clone(), + v.clone(), + None, + None, + Default::default(), + ); + let naive = super::attention_naive(q, k, v, None, None, Default::default()); + + let flash_data: &[f16] = flash.storage(); + let naive_data: &[f16] = naive.storage(); + + // softmax([1/sqrt(2), 0]) = [0.670, 0.330] -> row0: ~13.3, row1: ~16.7 + for (label, data) in [("flash", flash_data), ("naive", naive_data)] { + let r0 = data[0].to_f32(); + let r1 = data[1].to_f32(); + assert!((r0 - 13.30).abs() < 0.2, "{label} row0: got {r0}"); + assert!((r1 - 16.70).abs() < 0.2, "{label} row1: got {r1}"); + } + } +} diff --git a/crates/burn-flex/src/ops/binary.rs b/crates/burn-flex/src/ops/binary.rs new file mode 100644 index 0000000..aaca5b5 --- /dev/null +++ b/crates/burn-flex/src/ops/binary.rs @@ -0,0 +1,1124 @@ +//! Binary tensor operations (add, sub, mul, div). + +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::FlexTensor; +use crate::layout::Layout; +use crate::strided_index::StridedIter; + +#[cfg(feature = "simd")] +use crate::simd; + +/// Operation type for SIMD dispatch. +#[derive(Clone, Copy)] +pub enum BinaryOp { + Add, + Sub, + Mul, + Div, +} + +/// Apply a binary operation element-wise to two tensors. +/// +/// Requires tensors to have the same shape. Uses SIMD acceleration for f32 +/// when available and both tensors are contiguous. +/// +/// Pass `simd_hint` to enable direct SIMD dispatch for standard ops (add/sub/mul/div). +/// Pass `None` for custom operations that have no SIMD fast path. +pub fn binary_op( + lhs: FlexTensor, + rhs: FlexTensor, + f32_op: F32Op, + f64_op: F64Op, + simd_hint: Option, +) -> FlexTensor +where + F32Op: Fn(f32, f32) -> f32 + Copy, + F64Op: Fn(f64, f64) -> f64 + Copy, +{ + debug_assert_eq!(lhs.dtype(), rhs.dtype(), "binary_op: dtype mismatch"); + + // Broadcast tensors to the same shape if needed + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + let dtype = lhs.dtype(); + + match dtype { + DType::F32 => binary_op_f32(lhs, &rhs, f32_op, simd_hint), + DType::F64 => binary_op_typed(lhs, &rhs, f64_op), + DType::F16 => binary_op_typed(lhs, &rhs, |a: f16, b: f16| { + f16::from_f32(f32_op(a.to_f32(), b.to_f32())) + }), + DType::BF16 => binary_op_typed(lhs, &rhs, |a: bf16, b: bf16| { + bf16::from_f32(f32_op(a.to_f32(), b.to_f32())) + }), + _ => panic!("binary_op: unsupported dtype {:?}", dtype), + } +} + +/// Specialized binary operation for f32 with SIMD fast path. +#[cfg(feature = "simd")] +fn binary_op_f32( + mut lhs: FlexTensor, + rhs: &FlexTensor, + op: Op, + simd_hint: Option, +) -> FlexTensor +where + Op: Fn(f32, f32) -> f32, +{ + // Permuted lhs + broadcast rhs (e.g. `x.permute(...) - mean`): + // the generic path would walk the permuted lhs with a scalar + // `StridedIter`, an order of magnitude slower than the SIMD fast + // path. Pay one memcpy to materialize lhs contiguous so the fast + // paths below can take over. + // + // Gate on `simd_hint.is_some()` so custom ops like `atan2` or + // `powf` (which have no SIMD fast path and go straight to + // `binary_op_typed`) don't pay for a memcpy they can't benefit + // from. Their strided fallback handles non-contig lhs directly. + if simd_hint.is_some() && !lhs.layout().is_contiguous() && rhs.layout().strides().contains(&0) { + lhs = lhs.to_contiguous(); + } + + // In-place SIMD fast path: lhs unique contiguous at offset 0, rhs + // contiguous (no broadcast). + if let Some(simd_op) = simd_hint + && lhs.is_unique() + && let (Some((0, l_end)), Some((r_start, r_end))) = ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) + { + let r_slice: &[f32] = &rhs.storage()[r_start..r_end]; + let lhs_storage: &mut [f32] = lhs.storage_mut(); + let l_slice = &mut lhs_storage[..l_end]; + + match simd_op { + BinaryOp::Add => simd::add_inplace_f32(l_slice, r_slice), + BinaryOp::Sub => simd::sub_inplace_f32(l_slice, r_slice), + BinaryOp::Mul => simd::mul_inplace_f32(l_slice, r_slice), + BinaryOp::Div => simd::div_inplace_f32(l_slice, r_slice), + } + return lhs; + } + + // Broadcast SIMD fast path: rhs is broadcast via stride-0 dims in + // one of two hot shapes that dominate layer_norm decomposition -- + // shared-row (`gamma.unsqueeze() * x`) or per-row scalar + // (`x - x.mean_dim(-1)`). + if let Some(simd_op) = simd_hint + && let Some(pattern) = detect_broadcast_pattern(lhs.layout(), rhs) + { + return apply_broadcast_pattern_f32(lhs, rhs, simd_op, pattern); + } + + binary_op_typed(lhs, rhs, op) +} + +/// Categorization of the two broadcast patterns we can accelerate. +/// +/// Consumers assume `lhs` and `rhs` already share the same logical +/// shape and that `lhs` is row-contiguous at offset 0. +#[cfg(feature = "simd")] +#[derive(Debug, Clone, Copy)] +enum BroadcastView { + /// rhs's inner `row_len` elements form a contiguous row that is + /// shared across `outer_count` outer positions. Starts at + /// `rhs_row_offset` in rhs's storage. + SharedRow { + outer_count: usize, + row_len: usize, + rhs_row_offset: usize, + }, + /// rhs's inner `row_len` elements are all the same scalar, + /// stepping through `outer_count` scalars along the outer dims + /// starting at `rhs_scalar_base` in rhs's storage. + PerRowScalar { + outer_count: usize, + row_len: usize, + rhs_scalar_base: usize, + }, +} + +/// Detect whether rhs can be handled as one of the accelerated +/// broadcast patterns, returning `None` if the stride pattern doesn't +/// fit either bucket or the resulting offsets would leave rhs's +/// storage. +#[cfg(feature = "simd")] +fn detect_broadcast_pattern(lhs: &Layout, rhs: &FlexTensor) -> Option { + let rhs_layout = rhs.layout(); + let rhs_storage_elems = rhs.storage::().len(); + // Require lhs to be row-contiguous at offset 0. The broadcast kernel + // below uses linear offsets into lhs's storage; relaxing this would + // complicate the indexing without helping the hot layer_norm path. + let (l_start, _) = lhs.contiguous_offsets()?; + if l_start != 0 { + return None; + } + let ndims = lhs.num_dims(); + if ndims == 0 || rhs_layout.num_dims() != ndims { + return None; + } + let lhs_shape = lhs.shape(); + let rhs_strides = rhs_layout.strides(); + + let last_stride = rhs_strides[ndims - 1]; + + // Case A: shared row. Innermost rhs stride is 1, and every outer + // dim either has stride 0 (a broadcast dim) or size 1 (stride + // doesn't matter since the dim never advances past index 0). + if last_stride == 1 { + let outer_ok = (0..ndims - 1).all(|d| rhs_strides[d] == 0 || lhs_shape[d] == 1); + if outer_ok { + let outer_count: usize = (0..ndims - 1).map(|d| lhs_shape[d]).product(); + let row_len = lhs_shape[ndims - 1]; + if outer_count == 0 || row_len == 0 { + return None; + } + let rhs_row_offset = rhs_layout.start_offset(); + // Bounds: kernel reads `rhs_storage[off..off+row_len]`. + if rhs_row_offset.checked_add(row_len)? > rhs_storage_elems { + return None; + } + return Some(BroadcastView::SharedRow { + outer_count, + row_len, + rhs_row_offset, + }); + } + } + + // Case B: per-row scalar. Innermost dims all have stride 0 in + // rhs and outer dims walk rhs contiguously in row-major order. + if last_stride == 0 { + // Count the trailing stride-0 dims to find the inner scalar span. + let mut inner_dims = 0usize; + let mut row_len: usize = 1; + for d in (0..ndims).rev() { + if rhs_strides[d] == 0 { + inner_dims += 1; + row_len *= lhs_shape[d]; + } else { + break; + } + } + if inner_dims == 0 { + return None; + } + // The outer dims must walk rhs's storage contiguously in + // row-major order. + let outer_ndims = ndims - inner_dims; + let mut expected: isize = 1; + for d in (0..outer_ndims).rev() { + if rhs_strides[d] != expected { + return None; + } + expected *= lhs_shape[d] as isize; + } + let outer_count: usize = (0..outer_ndims).map(|d| lhs_shape[d]).product(); + if outer_count == 0 || row_len == 0 { + return None; + } + let rhs_scalar_base = rhs_layout.start_offset(); + // Bounds: kernel reads `rhs_storage[base..base+outer_count]`. + if rhs_scalar_base.checked_add(outer_count)? > rhs_storage_elems { + return None; + } + return Some(BroadcastView::PerRowScalar { + outer_count, + row_len, + rhs_scalar_base, + }); + } + + None +} + +/// Execute a detected broadcast pattern for f32. Writes in-place into +/// lhs when unique; otherwise allocates a fresh contiguous output. +#[cfg(feature = "simd")] +fn apply_broadcast_pattern_f32( + mut lhs: FlexTensor, + rhs: &FlexTensor, + simd_op: BinaryOp, + pattern: BroadcastView, +) -> FlexTensor { + let numel = lhs.layout().num_elements(); + let rhs_storage = rhs.storage::(); + + if lhs.is_unique() { + let dst = &mut lhs.storage_mut::()[..numel]; + run_broadcast_pattern_f32(dst, rhs_storage, simd_op, pattern); + lhs + } else { + // Copy lhs once, then apply the broadcast in place. The + // memcpy is cheaper than the StridedIter fallback it replaces. + let mut out: Vec = lhs.storage::()[..numel].to_vec(); + run_broadcast_pattern_f32(&mut out, rhs_storage, simd_op, pattern); + make_tensor(out, lhs.layout().shape().clone(), lhs.dtype()) + } +} + +/// Shared kernel: run the chosen broadcast pattern against a mutable +/// destination buffer (which already holds lhs's values) and rhs's +/// storage slice. +#[cfg(feature = "simd")] +fn run_broadcast_pattern_f32( + dst: &mut [f32], + rhs_storage: &[f32], + simd_op: BinaryOp, + pattern: BroadcastView, +) { + match pattern { + BroadcastView::SharedRow { + outer_count, + row_len, + rhs_row_offset, + } => { + let rhs_row: &[f32] = &rhs_storage[rhs_row_offset..rhs_row_offset + row_len]; + let total = outer_count * row_len; + // One SIMD dispatch covers the whole outer walk. The kernel + // keeps `rhs_row` in registers across rows for small row + // lengths, and pays the macerator feature-detection cost + // exactly once. + let dst_full = &mut dst[..total]; + match simd_op { + BinaryOp::Add => simd::add_shared_row_inplace_f32(dst_full, rhs_row), + BinaryOp::Sub => simd::sub_shared_row_inplace_f32(dst_full, rhs_row), + BinaryOp::Mul => simd::mul_shared_row_inplace_f32(dst_full, rhs_row), + BinaryOp::Div => simd::div_shared_row_inplace_f32(dst_full, rhs_row), + } + } + BroadcastView::PerRowScalar { + outer_count, + row_len, + rhs_scalar_base, + } => { + let scalars = &rhs_storage[rhs_scalar_base..rhs_scalar_base + outer_count]; + // One monomorphized helper per op. The closure is statically + // known at each call site so LLVM still autovectorizes the + // inner scalar loop, and the outer op dispatch happens once. + match simd_op { + BinaryOp::Add => per_row_scalar_apply(dst, scalars, row_len, |a, b| a + b), + BinaryOp::Sub => per_row_scalar_apply(dst, scalars, row_len, |a, b| a - b), + BinaryOp::Mul => per_row_scalar_apply(dst, scalars, row_len, |a, b| a * b), + BinaryOp::Div => per_row_scalar_apply(dst, scalars, row_len, |a, b| a / b), + } + } + } +} + +/// Apply `dst[r * row_len + j] = op(dst[r * row_len + j], scalars[r])` +/// for `r in 0..scalars.len(), j in 0..row_len`. Generic over `Op` so +/// each call site gets a monomorphized, autovectorizable inner loop. +#[cfg(feature = "simd")] +#[inline] +fn per_row_scalar_apply(dst: &mut [f32], scalars: &[f32], row_len: usize, op: Op) +where + Op: Fn(f32, f32) -> f32, +{ + for (i, &scalar) in scalars.iter().enumerate() { + let start = i * row_len; + for x in dst[start..start + row_len].iter_mut() { + *x = op(*x, scalar); + } + } +} + +/// Fallback when SIMD is disabled. +#[cfg(not(feature = "simd"))] +fn binary_op_f32( + lhs: FlexTensor, + rhs: &FlexTensor, + op: Op, + _simd_hint: Option, +) -> FlexTensor +where + Op: Fn(f32, f32) -> f32, +{ + binary_op_typed(lhs, rhs, op) +} + +/// Binary operation with in-place optimization for Pod types. +pub(crate) fn binary_op_typed(mut lhs: FlexTensor, rhs: &FlexTensor, op: Op) -> FlexTensor +where + E: Element + bytemuck::Pod, + Op: Fn(E, E) -> E, +{ + let rhs_storage: &[E] = rhs.storage(); + + // In-place fast path: lhs unique, contiguous at offset 0, rhs contiguous + if lhs.is_unique() + && let (Some((0, l_end)), Some((r_start, r_end))) = ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) + { + let lhs_storage: &mut [E] = lhs.storage_mut(); + let r_slice = &rhs_storage[r_start..r_end]; + for (l, &r) in lhs_storage[..l_end].iter_mut().zip(r_slice) { + *l = op(*l, r); + } + return lhs; + } + + // Allocating path + let shape = lhs.layout().shape().clone(); + let dtype = lhs.dtype(); + let lhs_storage: &[E] = lhs.storage(); + + let result: Vec = match ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) { + // Both contiguous (but lhs not at offset 0) + (Some((l_start, l_end)), Some((r_start, r_end))) => { + let l_slice = &lhs_storage[l_start..l_end]; + let r_slice = &rhs_storage[r_start..r_end]; + l_slice + .iter() + .zip(r_slice) + .map(|(&a, &b)| op(a, b)) + .collect() + } + // Fast path for 2D non-contiguous (common for transpose) + _ if lhs.layout().num_dims() == 2 => { + apply_2d_strided(lhs_storage, rhs_storage, lhs.layout(), rhs.layout(), op) + } + // General fallback + _ => { + let lhs_iter = StridedIter::new(lhs.layout()); + let rhs_iter = StridedIter::new(rhs.layout()); + lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| op(lhs_storage[li], rhs_storage[ri])) + .collect() + } + }; + + make_tensor(result, shape, dtype) +} + +/// Fast 2D strided binary operation using row-based iteration. +#[inline] +pub(crate) fn apply_2d_strided( + lhs: &[E], + rhs: &[E], + lhs_layout: &Layout, + rhs_layout: &Layout, + op: Op, +) -> Vec +where + E: Copy, + Op: Fn(E, E) -> R, +{ + let (rows, cols, l_row_stride, l_col_stride) = lhs_layout.as_2d_strides().unwrap(); + let (_, _, r_row_stride, r_col_stride) = rhs_layout.as_2d_strides().unwrap(); + let l_offset = lhs_layout.start_offset() as isize; + let r_offset = rhs_layout.start_offset() as isize; + + let mut result = Vec::with_capacity(rows * cols); + + for row in 0..rows { + let l_row_start = l_offset + row as isize * l_row_stride; + let r_row_start = r_offset + row as isize * r_row_stride; + for col in 0..cols { + let l_idx = (l_row_start + col as isize * l_col_stride) as usize; + let r_idx = (r_row_start + col as isize * r_col_stride) as usize; + result.push(op(lhs[l_idx], rhs[r_idx])); + } + } + + result +} + +/// Apply a scalar operation to each element of a tensor. +/// +/// Attempts in-place mutation when tensor is contiguous at offset 0. +pub fn scalar_op( + tensor: FlexTensor, + scalar: f64, + f32_op: F32Op, + f64_op: F64Op, +) -> FlexTensor +where + F32Op: Fn(f32, f32) -> f32 + Copy, + F64Op: Fn(f64, f64) -> f64 + Copy, +{ + let dtype = tensor.dtype(); + + match dtype { + DType::F32 => scalar_op_typed(tensor, scalar as f32, f32_op), + DType::F64 => scalar_op_typed(tensor, scalar, f64_op), + DType::F16 => { + let scalar_f16 = f16::from_f32(scalar as f32); + let s = scalar_f16.to_f32(); + scalar_op_typed(tensor, scalar_f16, |a: f16, _| { + f16::from_f32(f32_op(a.to_f32(), s)) + }) + } + DType::BF16 => { + let scalar_bf16 = bf16::from_f32(scalar as f32); + let s = scalar_bf16.to_f32(); + scalar_op_typed(tensor, scalar_bf16, |a: bf16, _| { + bf16::from_f32(f32_op(a.to_f32(), s)) + }) + } + _ => panic!("scalar_op: unsupported dtype {:?}", dtype), + } +} + +pub(crate) fn scalar_op_typed(mut tensor: FlexTensor, scalar: E, op: Op) -> FlexTensor +where + E: Element + bytemuck::Pod, + Op: Fn(E, E) -> E, +{ + // In-place fast path: unique, contiguous at offset 0 + if tensor.is_unique() + && let Some((0, end)) = tensor.layout().contiguous_offsets() + { + let storage: &mut [E] = tensor.storage_mut(); + for x in storage[..end].iter_mut() { + *x = op(*x, scalar); + } + return tensor; + } + + // Allocating path + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + let storage: &[E] = tensor.storage(); + + let result: Vec = match tensor.layout().contiguous_offsets() { + Some((start, end)) => storage[start..end].iter().map(|&x| op(x, scalar)).collect(), + None => StridedIter::new(tensor.layout()) + .map(|i| op(storage[i], scalar)) + .collect(), + }; + + make_tensor(result, shape, dtype) +} + +/// Helper to construct a tensor from result data. +fn make_tensor( + data: Vec, + shape: Shape, + dtype: DType, +) -> FlexTensor { + let bytes = Bytes::from_elems(data); + let layout = Layout::contiguous(shape); + FlexTensor::new(bytes, layout, dtype) +} + +/// Apply a binary operation element-wise to two integer tensors. +/// +/// Supports all integer dtypes: I64, I32, I16, I8, U64, U32, U16, U8. +pub fn int_binary_op(lhs: FlexTensor, rhs: FlexTensor, op: Op) -> FlexTensor +where + Op: Fn(i64, i64) -> i64 + Copy, +{ + debug_assert_eq!(lhs.dtype(), rhs.dtype(), "int_binary_op: dtype mismatch"); + + // Broadcast tensors to the same shape if needed + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + let dtype = lhs.dtype(); + + match dtype { + DType::I64 => binary_op_typed(lhs, &rhs, op), + DType::I32 => binary_op_typed(lhs, &rhs, |a: i32, b: i32| op(a as i64, b as i64) as i32), + DType::I16 => binary_op_typed(lhs, &rhs, |a: i16, b: i16| op(a as i64, b as i64) as i16), + DType::I8 => binary_op_typed(lhs, &rhs, |a: i8, b: i8| op(a as i64, b as i64) as i8), + // u64 values > i64::MAX wrap to negative i64. This is correct for + // add/sub/mul/bitwise (two's complement). Div/rem are handled at the call site. + DType::U64 => binary_op_typed(lhs, &rhs, |a: u64, b: u64| op(a as i64, b as i64) as u64), + DType::U32 => binary_op_typed(lhs, &rhs, |a: u32, b: u32| op(a as i64, b as i64) as u32), + DType::U16 => binary_op_typed(lhs, &rhs, |a: u16, b: u16| op(a as i64, b as i64) as u16), + DType::U8 => binary_op_typed(lhs, &rhs, |a: u8, b: u8| op(a as i64, b as i64) as u8), + _ => panic!("int_binary_op: unsupported dtype {:?}", dtype), + } +} + +/// Apply a scalar operation to each element of an integer tensor. +/// Note: scalar is truncated to target dtype (matches PyTorch). +pub fn int_scalar_op(tensor: FlexTensor, scalar: i64, op: Op) -> FlexTensor +where + Op: Fn(i64, i64) -> i64 + Copy, +{ + let dtype = tensor.dtype(); + + match dtype { + DType::I64 => scalar_op_typed(tensor, scalar, op), + DType::I32 => scalar_op_typed(tensor, scalar as i32, |a: i32, b: i32| { + op(a as i64, b as i64) as i32 + }), + DType::I16 => scalar_op_typed(tensor, scalar as i16, |a: i16, b: i16| { + op(a as i64, b as i64) as i16 + }), + DType::I8 => scalar_op_typed(tensor, scalar as i8, |a: i8, b: i8| { + op(a as i64, b as i64) as i8 + }), + DType::U64 => scalar_op_typed(tensor, scalar as u64, |a: u64, b: u64| { + op(a as i64, b as i64) as u64 + }), + DType::U32 => scalar_op_typed(tensor, scalar as u32, |a: u32, b: u32| { + op(a as i64, b as i64) as u32 + }), + DType::U16 => scalar_op_typed(tensor, scalar as u16, |a: u16, b: u16| { + op(a as i64, b as i64) as u16 + }), + DType::U8 => scalar_op_typed(tensor, scalar as u8, |a: u8, b: u8| { + op(a as i64, b as i64) as u8 + }), + _ => panic!("int_scalar_op: unsupported dtype {:?}", dtype), + } +} + +// Tests kept here exercise flex-specific behavior of `binary_op` / +// `scalar_op`: non-contiguous (transposed/narrowed/permuted) strides, +// flex f16/bf16 half-precision storage paths, and broadcast patterns +// that probe the flex layout system. Plain contiguous add/sub/mul/div +// and scalar-op smoke tests have been dropped in favor of the +// equivalent coverage in burn-backend-tests, which exercises every +// backend. When adding new tests, keep them here only if they probe +// flex-internal dispatch; otherwise add them to +// crates/burn-backend-tests/tests/tensor/float/ops/. +#[cfg(test)] +#[allow(clippy::needless_range_loop)] +mod tests { + use super::*; + use alloc::vec; + use burn_backend::{TensorData, Tolerance}; + + // =================== + // F16 tests + // =================== + + #[test] + fn test_binary_add_f16() { + let a_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(f16::from_f32) + .collect(); + let b_vals: Vec = vec![5.0, 6.0, 7.0, 8.0] + .into_iter() + .map(f16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])); + + let result = binary_op(a, b, |x, y| x + y, |x, y| x + y, None); + let expected: Vec = vec![6.0, 8.0, 10.0, 12.0] + .into_iter() + .map(f16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(f16::from_f32(0.01)), + ); + } + + #[test] + fn test_binary_mul_f16() { + let a_vals: Vec = vec![2.0, 3.0, 4.0, 5.0] + .into_iter() + .map(f16::from_f32) + .collect(); + let b_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(f16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])); + + let result = binary_op(a, b, |x, y| x * y, |x, y| x * y, None); + let expected: Vec = vec![2.0, 6.0, 12.0, 20.0] + .into_iter() + .map(f16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(f16::from_f32(0.01)), + ); + } + + #[test] + fn test_binary_f16_transposed() { + let a_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(f16::from_f32) + .collect(); + let b_vals: Vec = vec![10.0, 20.0, 30.0, 40.0] + .into_iter() + .map(f16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])).transpose(0, 1); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])).transpose(0, 1); + + // a_t = [[1,3], [2,4]], b_t = [[10,30], [20,40]] + // result = [[11,33], [22,44]] + let result = binary_op(a, b, |x, y| x + y, |x, y| x + y, None); + let expected: Vec = vec![11.0, 33.0, 22.0, 44.0] + .into_iter() + .map(f16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(f16::from_f32(0.1)), + ); + } + + #[test] + fn test_scalar_f16() { + let a_vals: Vec = vec![1.0, 2.0, 3.0].into_iter().map(f16::from_f32).collect(); + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![3])); + + let result = scalar_op(a, 10.0, |x, y| x + y, |x, y| x + y); + let expected: Vec = vec![11.0, 12.0, 13.0] + .into_iter() + .map(f16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![3]), + Tolerance::absolute(f16::from_f32(0.01)), + ); + } + + // =================== + // BF16 tests + // =================== + + #[test] + fn test_binary_add_bf16() { + let a_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + let b_vals: Vec = vec![5.0, 6.0, 7.0, 8.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])); + + let result = binary_op(a, b, |x, y| x + y, |x, y| x + y, None); + let expected: Vec = vec![6.0, 8.0, 10.0, 12.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(bf16::from_f32(0.1)), + ); + } + + #[test] + fn test_binary_mul_bf16() { + let a_vals: Vec = vec![2.0, 3.0, 4.0, 5.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + let b_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])); + + let result = binary_op(a, b, |x, y| x * y, |x, y| x * y, None); + let expected: Vec = vec![2.0, 6.0, 12.0, 20.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(bf16::from_f32(0.1)), + ); + } + + #[test] + fn test_binary_bf16_transposed() { + let a_vals: Vec = vec![1.0, 2.0, 3.0, 4.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + let b_vals: Vec = vec![10.0, 20.0, 30.0, 40.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![2, 2])).transpose(0, 1); + let b = FlexTensor::from_data(TensorData::new(b_vals, vec![2, 2])).transpose(0, 1); + + // a_t = [[1,3], [2,4]], b_t = [[10,30], [20,40]] + // result = [[11,33], [22,44]] + let result = binary_op(a, b, |x, y| x + y, |x, y| x + y, None); + let expected: Vec = vec![11.0, 33.0, 22.0, 44.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![2, 2]), + Tolerance::absolute(bf16::from_f32(0.5)), + ); + } + + #[test] + fn test_scalar_bf16() { + let a_vals: Vec = vec![1.0, 2.0, 3.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![3])); + + let result = scalar_op(a, 10.0, |x, y| x + y, |x, y| x + y); + let expected: Vec = vec![11.0, 12.0, 13.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![3]), + Tolerance::absolute(bf16::from_f32(0.1)), + ); + } + + #[test] + fn test_scalar_f16_non_representable() { + // 0.1 is not exactly representable in f16; verify the scalar is rounded + // to f16 precision before the op (matching dtype semantics). + let a_vals: Vec = vec![1.0, 2.0, 3.0].into_iter().map(f16::from_f32).collect(); + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![3])); + + let s_f16 = f16::from_f32(0.1); + let result = scalar_op(a, 0.1, |x, y| x * y, |x, y| x * y); + let expected: Vec = vec![1.0, 2.0, 3.0] + .into_iter() + .map(|v| f16::from_f32(f16::from_f32(v).to_f32() * s_f16.to_f32())) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![3]), + Tolerance::absolute(f16::from_f32(0.001)), + ); + } + + #[test] + fn test_scalar_bf16_non_representable() { + // 1.1 is not exactly representable in bf16; verify dtype rounding. + let a_vals: Vec = vec![1.0, 2.0, 3.0] + .into_iter() + .map(bf16::from_f32) + .collect(); + let a = FlexTensor::from_data(TensorData::new(a_vals, vec![3])); + + let s_bf16 = bf16::from_f32(1.1); + let result = scalar_op(a, 1.1, |x, y| x * y, |x, y| x * y); + let expected: Vec = vec![1.0, 2.0, 3.0] + .into_iter() + .map(|v| bf16::from_f32(bf16::from_f32(v).to_f32() * s_bf16.to_f32())) + .collect(); + result.into_data().assert_approx_eq::( + &TensorData::new(expected, vec![3]), + Tolerance::absolute(bf16::from_f32(0.01)), + ); + } + + // ============================================================================ + // Broadcast binary-op fast paths + // ============================================================================ + + /// Shared-row broadcast: 1-D gamma reshaped + expanded, with the + /// size-1 outer dim exemption in play. + #[test] + fn test_binary_shared_row_broadcast_f32() { + let a = FlexTensor::from_data(TensorData::new( + vec![ + 1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, + ], + vec![1, 3, 4], + )); + // 1D gamma broadcast over rows. + let gamma = + FlexTensor::from_data(TensorData::new(vec![10.0f32, 20.0, 30.0, 40.0], vec![4])); + let gamma_unsqueezed = gamma.reshape(Shape::from(vec![1, 1, 4])); + let result = binary_op( + a, + gamma_unsqueezed, + |a, b| a * b, + |a, b| a * b, + Some(BinaryOp::Mul), + ); + let data = result.into_data(); + let expected = vec![ + 10.0f32, 40.0, 90.0, 160.0, 50.0, 120.0, 210.0, 320.0, 90.0, 200.0, 330.0, 480.0, + ]; + assert_eq!(data.as_slice::().unwrap(), expected.as_slice()); + } + + /// Per-row scalar broadcast: `[1, 3, 4] - mean_dim(-1)` shape, + /// rhs expands to strides `[3, 1, 0]`. + #[test] + fn test_binary_per_row_scalar_broadcast_f32() { + let a = FlexTensor::from_data(TensorData::new( + vec![ + 1.0f32, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0, 100.0, 200.0, 300.0, 400.0, + ], + vec![1, 3, 4], + )); + // Scalars shaped like `mean_dim(-1)` output. + let mean = FlexTensor::from_data(TensorData::new(vec![2.5f32, 25.0, 250.0], vec![1, 3, 1])); + let result = binary_op(a, mean, |a, b| a - b, |a, b| a - b, Some(BinaryOp::Sub)); + let data = result.into_data(); + let expected = vec![ + -1.5f32, -0.5, 0.5, 1.5, -15.0, -5.0, 5.0, 15.0, -150.0, -50.0, 50.0, 150.0, + ]; + assert_eq!(data.as_slice::().unwrap(), expected.as_slice()); + } + + /// Non-contig lhs + shared-row broadcast: must materialize lhs + /// contiguous then dispatch to the shared-row kernel. + #[test] + fn test_binary_permuted_lhs_broadcast_rhs() { + let a = FlexTensor::from_data(TensorData::new( + (0..24).map(|i| i as f32).collect::>(), + vec![2, 3, 4], + )); + let a_permuted = a.transpose(1, 2); // shape [2, 4, 3] + let gamma = FlexTensor::from_data(TensorData::new(vec![1.0f32, 10.0, 100.0], vec![3])); + let gamma_expanded = gamma.reshape(Shape::from(vec![1, 1, 3])); + + let result = binary_op( + a_permuted, + gamma_expanded, + |a, b| a * b, + |a, b| a * b, + Some(BinaryOp::Mul), + ); + + // Compare against a naive reference computed on the permuted + // values. + let reference: Vec = { + // original[b, r, c] = b*12 + r*4 + c + // permuted[b, c, r] = original[b, r, c] + // result[b, c, r] = permuted[b, c, r] * gamma_row[r] + let gamma_vals = [1.0f32, 10.0, 100.0]; + let mut out = Vec::with_capacity(24); + for b in 0..2 { + for c in 0..4 { + for r in 0..3 { + let orig_val = (b * 12 + r * 4 + c) as f32; + out.push(orig_val * gamma_vals[r]); + } + } + } + out + }; + + let data = result.into_data(); + assert_eq!(data.as_slice::().unwrap(), reference.as_slice()); + } + + /// Non-contig lhs + per-row-scalar broadcast-sub. + #[test] + fn test_binary_permuted_lhs_per_row_scalar_sub() { + let a = FlexTensor::from_data(TensorData::new( + (1..=24).map(|i| i as f32).collect::>(), + vec![2, 3, 4], + )); + let a_permuted = a.transpose(1, 2); // shape [2, 4, 3] + + // Per-row scalar in the permuted layout: shape [2, 4, 1]. + let mean = FlexTensor::from_data(TensorData::new( + (0..8).map(|i| i as f32).collect::>(), + vec![2, 4, 1], + )); + + let result = binary_op( + a_permuted, + mean, + |a, b| a - b, + |a, b| a - b, + Some(BinaryOp::Sub), + ); + + // Reference computation. + let reference: Vec = { + let mut out = Vec::with_capacity(24); + for b in 0..2 { + for c in 0..4 { + let mean_val = (b * 4 + c) as f32; + for r in 0..3 { + let orig_val = (b * 12 + r * 4 + c + 1) as f32; + out.push(orig_val - mean_val); + } + } + } + out + }; + + let data = result.into_data(); + assert_eq!(data.as_slice::().unwrap(), reference.as_slice()); + } + + /// Exercise every `(op, pattern)` combination of the broadcast fast path: + /// Add/Sub/Mul/Div crossed with SharedRow and PerRowScalar. The existing + /// targeted tests only cover a subset, so a sign error in + /// `div_shared_row_inplace_f32` or `add_per_row_scalar` would ship green. + #[test] + fn test_binary_broadcast_all_ops_and_patterns_f32() { + fn build_shared() -> (FlexTensor, FlexTensor) { + let a = FlexTensor::from_data(TensorData::new( + vec![4.0f32, 8.0, 12.0, 20.0, 30.0, 60.0], + vec![2, 3], + )); + let b = FlexTensor::from_data(TensorData::new(vec![2.0f32, 4.0, 3.0], vec![3])) + .reshape(Shape::from(vec![1, 3])); + (a, b) + } + fn build_perrow() -> (FlexTensor, FlexTensor) { + let a = FlexTensor::from_data(TensorData::new( + vec![4.0f32, 8.0, 12.0, 20.0, 30.0, 60.0], + vec![2, 3], + )); + let b = FlexTensor::from_data(TensorData::new(vec![2.0f32, 5.0], vec![2, 1])); + (a, b) + } + + let run = |name: &str, + build: fn() -> (FlexTensor, FlexTensor), + simd_op: BinaryOp, + op_fn: fn(f32, f32) -> f32, + expected: &[f32]| { + let (a, b) = build(); + let result = binary_op( + a, + b, + op_fn, + |x: f64, y: f64| op_fn(x as f32, y as f32) as f64, + Some(simd_op), + ); + let data = result.into_data(); + assert_eq!( + data.as_slice::().unwrap(), + expected, + "case {name} produced wrong values" + ); + }; + + // SharedRow expected: lhs[i][j] OP rhs[j] + run( + "shared_add", + build_shared, + BinaryOp::Add, + |a, b| a + b, + &[6.0, 12.0, 15.0, 22.0, 34.0, 63.0], + ); + run( + "shared_sub", + build_shared, + BinaryOp::Sub, + |a, b| a - b, + &[2.0, 4.0, 9.0, 18.0, 26.0, 57.0], + ); + run( + "shared_mul", + build_shared, + BinaryOp::Mul, + |a, b| a * b, + &[8.0, 32.0, 36.0, 40.0, 120.0, 180.0], + ); + run( + "shared_div", + build_shared, + BinaryOp::Div, + |a, b| a / b, + &[2.0, 2.0, 4.0, 10.0, 7.5, 20.0], + ); + // PerRowScalar expected: lhs[i][j] OP rhs[i] + run( + "perrow_add", + build_perrow, + BinaryOp::Add, + |a, b| a + b, + &[6.0, 10.0, 14.0, 25.0, 35.0, 65.0], + ); + run( + "perrow_sub", + build_perrow, + BinaryOp::Sub, + |a, b| a - b, + &[2.0, 6.0, 10.0, 15.0, 25.0, 55.0], + ); + run( + "perrow_mul", + build_perrow, + BinaryOp::Mul, + |a, b| a * b, + &[8.0, 16.0, 24.0, 100.0, 150.0, 300.0], + ); + run( + "perrow_div", + build_perrow, + BinaryOp::Div, + |a, b| a / b, + &[2.0, 4.0, 6.0, 4.0, 6.0, 12.0], + ); + } + + /// Non-unique lhs: `apply_broadcast_pattern_f32` takes the allocating + /// branch instead of writing in place. Clone the lhs so its Arc refcount + /// is > 1, then run a broadcast op and verify the result matches the + /// unique path. Without this test, a regression in the allocating branch + /// would only fire on shared-lhs call sites which are rare in bench code. + #[test] + fn test_binary_broadcast_non_unique_lhs_f32() { + let a = FlexTensor::from_data(TensorData::new( + vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], + vec![2, 3], + )); + let _keep_alive = a.clone(); // bump Arc refcount so lhs is shared + let b = FlexTensor::from_data(TensorData::new(vec![10.0f32, 20.0, 30.0], vec![3])) + .reshape(Shape::from(vec![1, 3])); + let result = binary_op(a, b, |a, b| a + b, |a, b| a + b, Some(BinaryOp::Add)); + let data = result.into_data(); + assert_eq!( + data.as_slice::().unwrap(), + &[11.0f32, 22.0, 33.0, 14.0, 25.0, 36.0] + ); + } + + /// Fully-broadcast scalar: rhs strides all 0, PerRowScalar with + /// empty outer walk, applies one scalar across the whole dst. + #[test] + fn test_binary_fully_broadcast_scalar_f32() { + let a = FlexTensor::from_data(TensorData::new( + (0..12).map(|i| i as f32).collect::>(), + vec![2, 2, 3], + )); + // 1-element tensor expanded to lhs's full shape. All strides + // become 0. + let scalar_tensor = FlexTensor::from_data(TensorData::new(vec![100.0f32], [1])); + let scalar_expanded = crate::ops::expand::expand(scalar_tensor, Shape::from(vec![2, 2, 3])); + // Sanity check: every stride is 0. + assert!(scalar_expanded.layout().strides().iter().all(|&s| s == 0)); + + let result = binary_op( + a, + scalar_expanded, + |a, b| a + b, + |a, b| a + b, + Some(BinaryOp::Add), + ); + + let expected: Vec = (0..12).map(|i| i as f32 + 100.0).collect(); + let data = result.into_data(); + assert_eq!(data.as_slice::().unwrap(), expected.as_slice()); + } +} diff --git a/crates/burn-flex/src/ops/bool.rs b/crates/burn-flex/src/ops/bool.rs new file mode 100644 index 0000000..753f5ec --- /dev/null +++ b/crates/burn-flex/src/ops/bool.rs @@ -0,0 +1,559 @@ +//! Bool tensor operations for the Flex backend. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{ + DType, ExecutionError, TensorData, + ops::{BoolTensorOps, IntTensorOps}, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{Bytes, FloatDType, IntDType, Shape, Slice, bf16, f16}; + +use crate::{Flex, FlexTensor, Layout}; + +impl BoolTensorOps for Flex { + fn bool_from_data(data: TensorData, _device: &Device) -> BoolTensor { + FlexTensor::from_data(data) + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + Ok(tensor.into_data()) + } + + fn bool_to_device(tensor: BoolTensor, _device: &Device) -> BoolTensor { + tensor + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + crate::ops::cat::cat(tensors, dim) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + tensor.reshape(shape) + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + crate::ops::slice::slice(tensor, slices) + } + + fn bool_empty( + shape: Shape, + _device: &Device, + dtype: burn_std::BoolDType, + ) -> BoolTensor { + FlexTensor::empty(shape, DType::from(dtype)) + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[Slice], + value: BoolTensor, + ) -> BoolTensor { + crate::ops::slice::slice_assign(tensor, slices, value) + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: burn_std::IntDType) -> IntTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let out_dt = DType::from(out_dtype); + let bools = tensor.bytes(); + + macro_rules! convert { + ($int_ty:ty) => {{ + let data: Vec<$int_ty> = + bools.iter().map(|&x| if x != 0 { 1 } else { 0 }).collect(); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + }}; + } + + match out_dtype { + IntDType::I64 => convert!(i64), + IntDType::I32 => convert!(i32), + IntDType::I16 => convert!(i16), + IntDType::I8 => convert!(i8), + IntDType::U64 => convert!(u64), + IntDType::U32 => convert!(u32), + IntDType::U16 => convert!(u16), + IntDType::U8 => convert!(u8), + } + } + + fn bool_into_float( + tensor: BoolTensor, + out_dtype: burn_std::FloatDType, + ) -> FloatTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let out_dt = DType::from(out_dtype); + let bools = tensor.bytes(); + + match out_dtype { + FloatDType::F64 => { + let data: Vec = bools + .iter() + .map(|&x| if x != 0 { 1.0 } else { 0.0 }) + .collect(); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::F32 | FloatDType::Flex32 => { + let data: Vec = bools + .iter() + .map(|&x| if x != 0 { 1.0 } else { 0.0 }) + .collect(); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::F16 => { + let one = f16::from_f32(1.0); + let zero = f16::from_f32(0.0); + let data: Vec = bools + .iter() + .map(|&x| if x != 0 { one } else { zero }) + .collect(); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::BF16 => { + let one = bf16::from_f32(1.0); + let zero = bf16::from_f32(0.0); + let data: Vec = bools + .iter() + .map(|&x| if x != 0 { one } else { zero }) + .collect(); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + } + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + tensor.transpose(dim1, dim2) + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + tensor.permute(axes) + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + crate::ops::flip::flip(tensor, axes) + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + use crate::strided_index::StridedIter; + + // Broadcast to a common shape before comparing. The contiguous fast + // path below uses `zip`, which silently truncates to the shorter + // operand; and the output shape is taken from lhs, so mismatched + // operands would otherwise produce a result vec shorter than the + // output layout claims. + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + let out_dtype = burn_std::BoolDType::from(lhs.dtype()); + let shape = lhs.layout().shape().clone(); + let lhs_storage: &[u8] = lhs.bytes(); + let rhs_storage: &[u8] = rhs.bytes(); + + let result: Vec = match ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) { + (Some((l_start, l_end)), Some((r_start, r_end))) => { + let l_slice = &lhs_storage[l_start..l_end]; + let r_slice = &rhs_storage[r_start..r_end]; + l_slice + .iter() + .zip(r_slice) + .map(|(&a, &b)| (a == b) as u8) + .collect() + } + _ => { + let lhs_iter = StridedIter::new(lhs.layout()); + let rhs_iter = StridedIter::new(rhs.layout()); + lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| (lhs_storage[li] == rhs_storage[ri]) as u8) + .collect() + } + }; + + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) + } + + fn bool_not(mut tensor: BoolTensor) -> BoolTensor { + use crate::strided_index::StridedIter; + + debug_assert!( + matches!( + tensor.dtype(), + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) + ), + "bool_not: only Bool(Native) and Bool(U8) are supported, got {:?}", + tensor.dtype() + ); + + // Fast path: in-place for unique, contiguous tensors at offset 0. This + // preserves the input tensor's dtype tag implicitly (the in-place SIMD + // ops flip bytes without touching the dtype tag). + if tensor.is_unique() + && tensor.layout().is_contiguous() + && tensor.layout().start_offset() == 0 + { + let storage = tensor.storage_mut::(); + crate::simd::bool_not_inplace_u8(storage); + return tensor; + } + + // Allocating path for shared, non-contiguous, or offset tensors: + // preserve the input's bool dtype for the new tensor. + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + let shape = tensor.layout().shape().clone(); + let storage: &[u8] = tensor.bytes(); + + let result: Vec = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let slice = &storage[start..end]; + let mut out = vec![0u8; slice.len()]; + crate::simd::bool_not_u8(slice, &mut out); + out + } + None => StridedIter::new(tensor.layout()) + .map(|idx| (storage[idx] == 0) as u8) + .collect(), + }; + + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + bool_binary_op_simd(lhs, rhs, BoolBinaryOp::And) + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + bool_binary_op_simd(lhs, rhs, BoolBinaryOp::Or) + } + + fn bool_xor(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + bool_binary_op_simd(lhs, rhs, BoolBinaryOp::Xor) + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + crate::ops::expand::expand(tensor, shape) + } + + // Missing methods + fn bool_zeros( + shape: Shape, + device: &Device, + dtype: burn_std::BoolDType, + ) -> BoolTensor { + Self::bool_empty(shape, device, dtype) + } + + fn bool_ones( + shape: Shape, + _device: &Device, + dtype: burn_std::BoolDType, + ) -> BoolTensor { + let num_elements = shape.num_elements(); + let data = vec![1u8; num_elements]; + crate::ops::comparison::make_bool_tensor(data, shape, dtype) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + crate::ops::mask::mask_where_bool(tensor, mask, value) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: burn_backend::Scalar, + ) -> BoolTensor { + let value: bool = value.elem(); + crate::ops::mask::mask_fill_bool(tensor, mask, value) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + crate::ops::gather_scatter::gather_bool(tensor, dim, indices) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + crate::ops::gather_scatter::scatter_or(tensor, dim, indices, value) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: burn_backend::Scalar) -> BoolTensor { + use crate::strided_index::StridedIter; + + let out_dtype = burn_std::BoolDType::from(lhs.dtype()); + let shape = lhs.layout().shape().clone(); + let storage: &[u8] = lhs.bytes(); + let rhs_bool: bool = rhs.elem(); + let rhs_val = rhs_bool as u8; + + let result: Vec = match lhs.layout().contiguous_offsets() { + Some((start, end)) => storage[start..end] + .iter() + .map(|&v| (v == rhs_val) as u8) + .collect(), + None => StridedIter::new(lhs.layout()) + .map(|idx| (storage[idx] == rhs_val) as u8) + .collect(), + }; + + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + crate::ops::unfold::unfold_bool(tensor, dim, size, step) + } + + fn bool_not_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(lhs.dtype()); + crate::ops::comparison::bool_not_equal(lhs, rhs, out_dtype) + } + + fn bool_not_equal_elem(lhs: BoolTensor, rhs: burn_backend::Scalar) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(lhs.dtype()); + let rhs: bool = rhs.elem(); + crate::ops::comparison::bool_not_equal_elem(lhs, rhs, out_dtype) + } + + fn bool_any(tensor: BoolTensor) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + crate::ops::comparison::any_bool(tensor, out_dtype) + } + + fn bool_any_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + crate::ops::comparison::any_bool_dim(tensor, dim, out_dtype) + } + + fn bool_all(tensor: BoolTensor) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + crate::ops::comparison::all_bool(tensor, out_dtype) + } + + fn bool_all_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + crate::ops::comparison::all_bool_dim(tensor, dim, out_dtype) + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + crate::ops::gather_scatter::select::(tensor, dim, indices) + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + let mut result = crate::ops::gather_scatter::select_add::(tensor, dim, indices, value); + // Clamp to 0/1: select_add sums u8 values, but bool OR saturates at 1 + let storage: &mut [u8] = result.storage_mut(); + for v in storage.iter_mut() { + if *v > 1 { + *v = 1; + } + } + result + } + + fn bool_transpose(tensor: BoolTensor) -> BoolTensor { + let ndims = tensor.layout().num_dims(); + if ndims < 2 { + return tensor; + } + tensor.transpose(ndims - 2, ndims - 1) + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + crate::ops::repeat_dim::repeat_dim(tensor, dim, times) + } + + async fn bool_argwhere(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let ndims = shape.num_dims(); + let data: &[u8] = tensor.storage(); + let n = shape.num_elements(); + + let count = data[..n].iter().filter(|&&v| v != 0).count(); + let mut coords: Vec = Vec::with_capacity(count * ndims); + let strides = crate::layout::contiguous_strides_usize(&shape); + + for (flat_idx, &val) in data[..n].iter().enumerate() { + if val != 0 { + let mut remaining = flat_idx; + for &s in &strides { + coords.push((remaining / s) as isize); + remaining %= s; + } + } + } + + let out_shape = Shape::from(vec![count, ndims]); + let result = FlexTensor::new( + Bytes::from_elems(coords), + Layout::contiguous(out_shape), + crate::ops::INDEX_DTYPE, + ); + if result.dtype() != DType::from(out_dtype) { + Flex::int_cast(result, out_dtype) + } else { + result + } + } +} + +/// Boolean binary operation type. +#[derive(Clone, Copy)] +enum BoolBinaryOp { + And, + Or, + Xor, +} + +fn bool_binary_op_simd(lhs: FlexTensor, rhs: FlexTensor, op: BoolBinaryOp) -> FlexTensor { + use crate::strided_index::StridedIter; + + debug_assert_eq!(lhs.dtype(), rhs.dtype(), "bool_binary_op: dtype mismatch"); + + // Broadcast to a common shape before dispatching. The scalar/SIMD helpers + // below assume equal-length operands; without this, mismatched shapes + // either silently keep the lhs shape or OOB-panic inside the helpers. + let (mut lhs, mut rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + // Preserve the input bool dtype (taken from lhs; rhs is assumed to match + // in dtype, checked above). + let out_dtype = burn_std::BoolDType::from(lhs.dtype()); + let shape = lhs.layout().shape().clone(); + let l_offsets = lhs.layout().contiguous_offsets(); + let r_offsets = rhs.layout().contiguous_offsets(); + + // Fast path 1: lhs is unique and contiguous at offset 0 -> in-place on lhs + if lhs.is_unique() + && let (Some((0, l_end)), Some((r_start, r_end))) = (l_offsets, r_offsets) + { + let rhs_storage: &[u8] = rhs.bytes(); + let r_slice = &rhs_storage[r_start..r_end]; + let lhs_storage: &mut [u8] = lhs.storage_mut(); + let l_slice = &mut lhs_storage[..l_end]; + + match op { + BoolBinaryOp::And => crate::simd::bool_and_inplace_u8(l_slice, r_slice), + BoolBinaryOp::Or => crate::simd::bool_or_inplace_u8(l_slice, r_slice), + BoolBinaryOp::Xor => crate::simd::bool_xor_inplace_u8(l_slice, r_slice), + } + return lhs; + } + + // Fast path 2: rhs is unique and contiguous at offset 0 -> in-place on rhs + // (And/Or/Xor are commutative, so we can swap operands) + if rhs.is_unique() + && let (Some((l_start, l_end)), Some((0, r_end))) = (l_offsets, r_offsets) + { + let lhs_storage: &[u8] = lhs.bytes(); + let l_slice = &lhs_storage[l_start..l_end]; + let rhs_storage: &mut [u8] = rhs.storage_mut(); + let r_slice = &mut rhs_storage[..r_end]; + + match op { + BoolBinaryOp::And => crate::simd::bool_and_inplace_u8(r_slice, l_slice), + BoolBinaryOp::Or => crate::simd::bool_or_inplace_u8(r_slice, l_slice), + BoolBinaryOp::Xor => crate::simd::bool_xor_inplace_u8(r_slice, l_slice), + } + return rhs; + } + + // Allocating path: neither tensor is suitable for in-place + let lhs_storage: &[u8] = lhs.bytes(); + let rhs_storage: &[u8] = rhs.bytes(); + + let result: Vec = match (l_offsets, r_offsets) { + (Some((l_start, l_end)), Some((r_start, r_end))) => { + let l_slice = &lhs_storage[l_start..l_end]; + let r_slice = &rhs_storage[r_start..r_end]; + let mut out = vec![0u8; l_slice.len()]; + match op { + BoolBinaryOp::And => crate::simd::bool_and_u8(l_slice, r_slice, &mut out), + BoolBinaryOp::Or => crate::simd::bool_or_u8(l_slice, r_slice, &mut out), + BoolBinaryOp::Xor => crate::simd::bool_xor_u8(l_slice, r_slice, &mut out), + } + out + } + _ => { + let lhs_iter = StridedIter::new(lhs.layout()); + let rhs_iter = StridedIter::new(rhs.layout()); + match op { + BoolBinaryOp::And => lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| lhs_storage[li] & rhs_storage[ri]) + .collect(), + BoolBinaryOp::Or => lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| lhs_storage[li] | rhs_storage[ri]) + .collect(), + BoolBinaryOp::Xor => lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| lhs_storage[li] ^ rhs_storage[ri]) + .collect(), + } + } + }; + + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) +} + +// Tests kept here exercise flex-specific dtype storage selection via +// explicit IntDType/FloatDType. Plain bool ops, bool-to-int/float +// casts, and negative-stride (flipped) bool coverage have been migrated +// to crates/burn-backend-tests/tests/tensor/bool/ops/{logical,cast}.rs +// so they run against every backend. When adding new tests, keep them +// here only if they probe flex dtype dispatch; otherwise add them +// there. +#[cfg(test)] +mod tests { + use alloc::vec; + use burn_backend::TensorData; + use burn_backend::ops::BoolTensorOps; + use burn_std::{FloatDType, IntDType}; + + use crate::{Flex, FlexTensor}; + + #[test] + fn test_bool_into_int_u8() { + let t = FlexTensor::from_data(TensorData::from([true, false, true])); + let result = Flex::bool_into_int(t, IntDType::U8); + assert_eq!(result.dtype(), burn_backend::DType::U8); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1u8, 0, 1]); + } + + #[test] + fn test_bool_into_float_f64() { + let t = FlexTensor::from_data(TensorData::from([true, false, true])); + let result = Flex::bool_into_float(t, FloatDType::F64); + assert_eq!(result.dtype(), burn_backend::DType::F64); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1.0f64, 0.0, 1.0]); + } +} diff --git a/crates/burn-flex/src/ops/cat.rs b/crates/burn-flex/src/ops/cat.rs new file mode 100644 index 0000000..38177fc --- /dev/null +++ b/crates/burn-flex/src/ops/cat.rs @@ -0,0 +1,119 @@ +//! Concatenation operations for FlexTensor. + +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::{FlexTensor, Layout}; + +/// Concatenate tensors along the given dimension. +pub fn cat(tensors: Vec, dim: usize) -> FlexTensor { + assert!(!tensors.is_empty(), "cat: cannot concatenate empty list"); + if tensors.len() == 1 { + return tensors.into_iter().next().unwrap(); + } + + let dtype = tensors[0].dtype(); + match dtype { + DType::F32 => cat_impl::(tensors, dim), + DType::F64 => cat_impl::(tensors, dim), + DType::F16 => cat_impl::(tensors, dim), + DType::BF16 => cat_impl::(tensors, dim), + DType::I64 => cat_impl::(tensors, dim), + DType::I32 => cat_impl::(tensors, dim), + DType::I16 => cat_impl::(tensors, dim), + DType::I8 => cat_impl::(tensors, dim), + DType::U64 => cat_impl::(tensors, dim), + DType::U32 => cat_impl::(tensors, dim), + DType::U16 => cat_impl::(tensors, dim), + DType::U8 | DType::Bool(_) => cat_impl::(tensors, dim), + _ => panic!("cat: unsupported dtype {:?}", dtype), + } +} + +fn cat_impl(tensors: Vec, dim: usize) -> FlexTensor { + let dtype = tensors[0].dtype(); + let first_shape = tensors[0].layout().shape(); + let ndims = first_shape.num_dims(); + + assert!( + dim < ndims, + "cat: dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + // Compute output shape: sum along cat dim, others must match + let mut out_dims = first_shape.to_vec(); + out_dims[dim] = 0; + for t in &tensors { + assert_eq!( + t.dtype(), + dtype, + "cat: dtype mismatch: expected {:?}, got {:?}", + dtype, + t.dtype() + ); + let s = t.layout().shape(); + assert_eq!(s.num_dims(), ndims, "cat: dimension count mismatch"); + for (d, out_d) in out_dims.iter_mut().enumerate() { + if d == dim { + *out_d += s[d]; + } else { + assert_eq!( + s[d], first_shape[d], + "cat: shape mismatch at dim {d}: expected {}, got {}", + first_shape[d], s[d] + ); + } + } + } + + let out_shape = Shape::from(out_dims.clone()); + let total_elements: usize = out_shape.num_elements(); + + if total_elements == 0 { + let bytes = Bytes::from_elems::(Vec::new()); + return FlexTensor::new(bytes, Layout::contiguous(out_shape), dtype); + } + + let mut output: Vec = Vec::with_capacity(total_elements); + + // Fast path: dim 0, all contiguous + if dim == 0 + && tensors + .iter() + .all(|t| t.layout().contiguous_offsets().is_some()) + { + for t in &tensors { + let (start, end) = t.layout().contiguous_offsets().unwrap(); + let data: &[E] = t.storage(); + output.extend_from_slice(&data[start..end]); + } + } else { + // General path: iterate over outer/inner chunks + // outer_size = product of dims before `dim` + // inner_size = product of dims after `dim` + let outer_size: usize = out_dims[..dim].iter().product(); + let inner_size: usize = out_dims[dim + 1..].iter().product(); + + // Make all tensors contiguous for simple indexing + let contiguous: Vec = tensors.into_iter().map(|t| t.to_contiguous()).collect(); + + for outer in 0..outer_size { + for t in &contiguous { + let data: &[E] = t.storage(); + let t_dim_size = t.layout().shape()[dim]; + let t_start = t.layout().start_offset(); + // Each tensor's chunk for this outer index: + // offset = t_start + outer * t_dim_size * inner_size + let chunk_start = t_start + outer * t_dim_size * inner_size; + let chunk_len = t_dim_size * inner_size; + output.extend_from_slice(&data[chunk_start..chunk_start + chunk_len]); + } + } + } + + let bytes = Bytes::from_elems(output); + FlexTensor::new(bytes, Layout::contiguous(out_shape), dtype) +} diff --git a/crates/burn-flex/src/ops/comparison.rs b/crates/burn-flex/src/ops/comparison.rs new file mode 100644 index 0000000..4658480 --- /dev/null +++ b/crates/burn-flex/src/ops/comparison.rs @@ -0,0 +1,1041 @@ +//! Comparison operations returning boolean tensors. + +use alloc::boxed::Box; +#[cfg(feature = "simd")] +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{BoolDType, BoolStore, Bytes, Shape, bf16, f16}; +use bytemuck::Pod; + +use crate::strided_index::StridedIter; +use crate::{FlexTensor, Layout}; + +use crate::simd; + +/// Comparison operation type for SIMD dispatch. +pub use simd::CmpOp as CompareOp; + +/// Compare two tensors element-wise, returning a boolean tensor with the +/// requested output dtype. +pub fn compare( + lhs: FlexTensor, + rhs: FlexTensor, + out_dtype: BoolDType, + f32_cmp: F32Cmp, + f64_cmp: F64Cmp, + simd_hint: Option, +) -> FlexTensor +where + F32Cmp: Fn(f32, f32) -> bool + Copy, + F64Cmp: Fn(f64, f64) -> bool + Copy, +{ + debug_assert_eq!(lhs.dtype(), rhs.dtype(), "compare: dtype mismatch"); + + // Broadcast to same shape if needed + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + let dtype = lhs.dtype(); + + match dtype { + DType::F32 => compare_f32(lhs, &rhs, out_dtype, f32_cmp, simd_hint), + DType::F64 => compare_typed(lhs, &rhs, out_dtype, f64_cmp), + DType::F16 => compare_typed(lhs, &rhs, out_dtype, |a: f16, b: f16| { + f32_cmp(a.to_f32(), b.to_f32()) + }), + DType::BF16 => compare_typed(lhs, &rhs, out_dtype, |a: bf16, b: bf16| { + f32_cmp(a.to_f32(), b.to_f32()) + }), + _ => panic!("compare: unsupported dtype {:?}", dtype), + } +} + +/// Specialized comparison for f32 with SIMD fast path. +#[cfg(feature = "simd")] +fn compare_f32( + lhs: FlexTensor, + rhs: &FlexTensor, + out_dtype: BoolDType, + cmp: Cmp, + simd_hint: Option, +) -> FlexTensor +where + Cmp: Fn(f32, f32) -> bool, +{ + // SIMD fast path: both tensors contiguous + if let (Some((l_start, l_end)), Some((r_start, r_end))) = ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) && let Some(simd_op) = simd_hint + { + let shape = lhs.layout().shape().clone(); + let lhs_storage: &[f32] = lhs.storage(); + let rhs_storage: &[f32] = rhs.storage(); + + let l_slice = &lhs_storage[l_start..l_end]; + let r_slice = &rhs_storage[r_start..r_end]; + + let mut result = vec![0u8; l_slice.len()]; + simd::cmp_f32(l_slice, r_slice, &mut result, simd_op); + + return make_bool_tensor(result, shape, out_dtype); + } + + // Optimized broadcast path for outer-product style broadcasting + // Pattern: [N, 1] vs [1, M] -> [N, M] where one has stride 0 in inner dim + if lhs.layout().num_dims() == 2 + && let Some(simd_op) = simd_hint + && let Some((result, shape)) = try_broadcast_cmp_f32(&lhs, rhs, simd_op) + { + return make_bool_tensor(result, shape, out_dtype); + } + + // Fallback to generic path + compare_typed(lhs, rhs, out_dtype, cmp) +} + +/// Try optimized outer-product style broadcast comparison. +/// Returns Some((result, shape)) if the pattern matches. +#[cfg(feature = "simd")] +fn try_broadcast_cmp_f32( + lhs: &FlexTensor, + rhs: &FlexTensor, + op: simd::CmpOp, +) -> Option<(Vec, Shape)> { + let lhs_strides = lhs.layout().strides(); + let rhs_strides = rhs.layout().strides(); + let shape = lhs.layout().shape().clone(); + let [rows, cols] = shape[..] else { + return None; + }; + + // Pattern 1: lhs has stride 0 in dim 1 (column broadcast), rhs contiguous + // lhs[i,j] = lhs_data[i*stride], rhs[i,j] = rhs_data[i*cols + j] + if lhs_strides[1] == 0 && rhs_strides == [cols as isize, 1] { + let lhs_storage: &[f32] = lhs.storage(); + let rhs_storage: &[f32] = rhs.storage(); + let l_offset = lhs.layout().start_offset() as isize; + let l_stride = lhs_strides[0]; + let r_offset = rhs.layout().start_offset(); + + let mut result = vec![0u8; rows * cols]; + for row in 0..rows { + let a_val = lhs_storage[(l_offset + row as isize * l_stride) as usize]; + let r_row_start = r_offset + row * cols; + let r_slice = &rhs_storage[r_row_start..r_row_start + cols]; + let out_start = row * cols; + simd::cmp_scalar_f32( + r_slice, + a_val, + &mut result[out_start..out_start + cols], + swap_cmp_op(op), + ); + } + return Some((result, shape)); + } + + // Pattern 2: rhs has stride 0 in dim 0 (row broadcast), lhs contiguous + // lhs[i,j] = lhs_data[i*cols + j], rhs[i,j] = rhs_data[j*stride] + if rhs_strides[0] == 0 && lhs_strides == [cols as isize, 1] { + let lhs_storage: &[f32] = lhs.storage(); + let rhs_storage: &[f32] = rhs.storage(); + let l_offset = lhs.layout().start_offset(); + let r_offset = rhs.layout().start_offset() as isize; + let r_stride = rhs_strides[1]; + + // Build the broadcast rhs values once + let rhs_row: Vec = (0..cols) + .map(|j| rhs_storage[(r_offset + j as isize * r_stride) as usize]) + .collect(); + + let mut result = vec![0u8; rows * cols]; + for row in 0..rows { + let l_row_start = l_offset + row * cols; + let l_slice = &lhs_storage[l_row_start..l_row_start + cols]; + let out_start = row * cols; + // Compare row with broadcast values + for (j, (&lv, &rv)) in l_slice.iter().zip(rhs_row.iter()).enumerate() { + result[out_start + j] = match op { + simd::CmpOp::Gt => (lv > rv) as u8, + simd::CmpOp::Ge => (lv >= rv) as u8, + simd::CmpOp::Lt => (lv < rv) as u8, + simd::CmpOp::Le => (lv <= rv) as u8, + simd::CmpOp::Eq => (lv == rv) as u8, + simd::CmpOp::Ne => (lv != rv) as u8, + }; + } + } + return Some((result, shape)); + } + + // Pattern 3: Outer product - lhs stride 0 in dim 1, rhs stride 0 in dim 0 + // This is the [N,1] vs [1,M] case + if lhs_strides[1] == 0 && rhs_strides[0] == 0 { + let lhs_storage: &[f32] = lhs.storage(); + let rhs_storage: &[f32] = rhs.storage(); + let l_offset = lhs.layout().start_offset() as isize; + let l_stride = lhs_strides[0]; + let r_offset = rhs.layout().start_offset() as isize; + let r_stride = rhs_strides[1]; + + // Build the broadcast rhs row once + let rhs_row: Vec = (0..cols) + .map(|j| rhs_storage[(r_offset + j as isize * r_stride) as usize]) + .collect(); + + let mut result = vec![0u8; rows * cols]; + for row in 0..rows { + let a_val = lhs_storage[(l_offset + row as isize * l_stride) as usize]; + let out_start = row * cols; + simd::cmp_scalar_f32( + &rhs_row, + a_val, + &mut result[out_start..out_start + cols], + swap_cmp_op(op), + ); + } + return Some((result, shape)); + } + + None +} + +/// Swap comparison operation for reversed operand order. +#[cfg(feature = "simd")] +fn swap_cmp_op(op: simd::CmpOp) -> simd::CmpOp { + match op { + simd::CmpOp::Gt => simd::CmpOp::Lt, // a > b becomes b < a + simd::CmpOp::Ge => simd::CmpOp::Le, + simd::CmpOp::Lt => simd::CmpOp::Gt, + simd::CmpOp::Le => simd::CmpOp::Ge, + simd::CmpOp::Eq => simd::CmpOp::Eq, // symmetric + simd::CmpOp::Ne => simd::CmpOp::Ne, + } +} + +/// Fallback when SIMD is disabled. +#[cfg(not(feature = "simd"))] +fn compare_f32( + lhs: FlexTensor, + rhs: &FlexTensor, + out_dtype: BoolDType, + cmp: Cmp, + _simd_hint: Option, +) -> FlexTensor +where + Cmp: Fn(f32, f32) -> bool, +{ + compare_typed(lhs, rhs, out_dtype, cmp) +} + +/// Compare tensor with scalar, returning a boolean tensor with the requested +/// output dtype. +pub fn compare_elem( + lhs: FlexTensor, + rhs: f64, + out_dtype: BoolDType, + f32_cmp: F32Cmp, + f64_cmp: F64Cmp, + simd_hint: Option, +) -> FlexTensor +where + F32Cmp: Fn(f32, f32) -> bool + Copy, + F64Cmp: Fn(f64, f64) -> bool + Copy, +{ + let dtype = lhs.dtype(); + + match dtype { + DType::F32 => compare_elem_f32(lhs, rhs as f32, out_dtype, f32_cmp, simd_hint), + DType::F64 => compare_elem_typed(lhs, rhs, out_dtype, f64_cmp), + DType::F16 => { + let scalar = f16::from_f64(rhs); + compare_elem_typed(lhs, scalar, out_dtype, |a: f16, b: f16| { + f32_cmp(a.to_f32(), b.to_f32()) + }) + } + DType::BF16 => { + let scalar = bf16::from_f64(rhs); + compare_elem_typed(lhs, scalar, out_dtype, |a: bf16, b: bf16| { + f32_cmp(a.to_f32(), b.to_f32()) + }) + } + _ => panic!("compare_elem: unsupported dtype {:?}", dtype), + } +} + +/// Specialized scalar comparison for f32 with SIMD fast path. +#[cfg(feature = "simd")] +fn compare_elem_f32( + lhs: FlexTensor, + rhs: f32, + out_dtype: BoolDType, + cmp: Cmp, + simd_hint: Option, +) -> FlexTensor +where + Cmp: Fn(f32, f32) -> bool, +{ + // SIMD fast path: tensor is contiguous + if let Some((start, end)) = lhs.layout().contiguous_offsets() + && let Some(simd_op) = simd_hint + { + let shape = lhs.layout().shape().clone(); + let lhs_storage: &[f32] = lhs.storage(); + let l_slice = &lhs_storage[start..end]; + + let mut result = vec![0u8; l_slice.len()]; + simd::cmp_scalar_f32(l_slice, rhs, &mut result, simd_op); + + return make_bool_tensor(result, shape, out_dtype); + } + + // Fallback to generic path + compare_elem_typed(lhs, rhs, out_dtype, cmp) +} + +/// Fallback when SIMD is disabled. +#[cfg(not(feature = "simd"))] +fn compare_elem_f32( + lhs: FlexTensor, + rhs: f32, + out_dtype: BoolDType, + cmp: Cmp, + _simd_hint: Option, +) -> FlexTensor +where + Cmp: Fn(f32, f32) -> bool, +{ + compare_elem_typed(lhs, rhs, out_dtype, cmp) +} + +fn compare_typed( + lhs: FlexTensor, + rhs: &FlexTensor, + out_dtype: BoolDType, + cmp: Cmp, +) -> FlexTensor +where + E: Element + Pod, + Cmp: Fn(E, E) -> bool, +{ + let shape = lhs.layout().shape().clone(); + let lhs_storage: &[E] = lhs.storage(); + let rhs_storage: &[E] = rhs.storage(); + + let result: Vec = match ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) { + (Some((l_start, l_end)), Some((r_start, r_end))) => { + let l_slice = &lhs_storage[l_start..l_end]; + let r_slice = &rhs_storage[r_start..r_end]; + l_slice + .iter() + .zip(r_slice) + .map(|(&a, &b)| cmp(a, b) as u8) + .collect() + } + // Fast path for 2D non-contiguous (common for transpose) + _ if lhs.layout().num_dims() == 2 => crate::ops::binary::apply_2d_strided( + lhs_storage, + rhs_storage, + lhs.layout(), + rhs.layout(), + |a, b| cmp(a, b) as u8, + ), + _ => { + let lhs_iter = StridedIter::new(lhs.layout()); + let rhs_iter = StridedIter::new(rhs.layout()); + lhs_iter + .zip(rhs_iter) + .map(|(li, ri)| cmp(lhs_storage[li], rhs_storage[ri]) as u8) + .collect() + } + }; + + make_bool_tensor(result, shape, out_dtype) +} + +fn compare_elem_typed(lhs: FlexTensor, rhs: E, out_dtype: BoolDType, cmp: Cmp) -> FlexTensor +where + E: Element + Pod + Copy, + Cmp: Fn(E, E) -> bool, +{ + let shape = lhs.layout().shape().clone(); + let lhs_storage: &[E] = lhs.storage(); + + let result: Vec = match lhs.layout().contiguous_offsets() { + Some((start, end)) => lhs_storage[start..end] + .iter() + .map(|&a| cmp(a, rhs) as u8) + .collect(), + None => StridedIter::new(lhs.layout()) + .map(|idx| cmp(lhs_storage[idx], rhs) as u8) + .collect(), + }; + + make_bool_tensor(result, shape, out_dtype) +} + +/// Build a bool `FlexTensor` from a `Vec` of 0/1 bytes, tagged with the +/// requested output dtype. +/// +/// burn-flex stores bools as 1 byte per element, so only Native and U8 are +/// supported. `Bool(U32)` would require 4-byte-per-element storage throughout +/// the backend; `dtype_usage` declares it unsupported and this function panics +/// if it's requested. +pub(crate) fn make_bool_tensor(data: Vec, shape: Shape, out_dtype: BoolDType) -> FlexTensor { + let store = match out_dtype { + BoolDType::Native => BoolStore::Native, + BoolDType::U8 => BoolStore::U8, + BoolDType::U32 => panic!( + "burn-flex does not support Bool(U32) storage (only Native and U8). \ + Use a backend that declares Bool(U32) support, or work with Bool(Native)/Bool(U8)." + ), + }; + let bytes = Bytes::from_elems(data); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::Bool(store)) +} + +// Specific comparison functions + +pub fn greater(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a > b, + |a, b| a > b, + Some(CompareOp::Gt), + ) +} + +pub fn greater_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a > b, + |a, b| a > b, + Some(CompareOp::Gt), + ) +} + +pub fn greater_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a >= b, + |a, b| a >= b, + Some(CompareOp::Ge), + ) +} + +pub fn greater_equal_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a >= b, + |a, b| a >= b, + Some(CompareOp::Ge), + ) +} + +pub fn lower(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a < b, + |a, b| a < b, + Some(CompareOp::Lt), + ) +} + +pub fn lower_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a < b, + |a, b| a < b, + Some(CompareOp::Lt), + ) +} + +pub fn lower_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a <= b, + |a, b| a <= b, + Some(CompareOp::Le), + ) +} + +pub fn lower_equal_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a <= b, + |a, b| a <= b, + Some(CompareOp::Le), + ) +} + +pub fn equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a == b, + |a, b| a == b, + Some(CompareOp::Eq), + ) +} + +pub fn equal_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a == b, + |a, b| a == b, + Some(CompareOp::Eq), + ) +} + +pub fn not_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare( + lhs, + rhs, + out_dtype, + |a, b| a != b, + |a, b| a != b, + Some(CompareOp::Ne), + ) +} + +pub fn not_equal_elem(lhs: FlexTensor, rhs: f64, out_dtype: BoolDType) -> FlexTensor { + compare_elem( + lhs, + rhs, + out_dtype, + |a, b| a != b, + |a, b| a != b, + Some(CompareOp::Ne), + ) +} + +// Integer comparison functions + +fn compare_int( + lhs: FlexTensor, + rhs: FlexTensor, + out_dtype: BoolDType, + i64_cmp: I64Cmp, + u64_cmp: U64Cmp, +) -> FlexTensor +where + I64Cmp: Fn(i64, i64) -> bool, + U64Cmp: Fn(u64, u64) -> bool, +{ + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + + match lhs.dtype() { + DType::I64 => compare_typed(lhs, &rhs, out_dtype, i64_cmp), + DType::U64 => compare_typed(lhs, &rhs, out_dtype, u64_cmp), + DType::I32 => compare_typed(lhs, &rhs, out_dtype, |a: i32, b: i32| { + i64_cmp(a as i64, b as i64) + }), + DType::I16 => compare_typed(lhs, &rhs, out_dtype, |a: i16, b: i16| { + i64_cmp(a as i64, b as i64) + }), + DType::I8 => compare_typed(lhs, &rhs, out_dtype, |a: i8, b: i8| { + i64_cmp(a as i64, b as i64) + }), + DType::U32 => compare_typed(lhs, &rhs, out_dtype, |a: u32, b: u32| { + i64_cmp(a as i64, b as i64) + }), + DType::U16 => compare_typed(lhs, &rhs, out_dtype, |a: u16, b: u16| { + i64_cmp(a as i64, b as i64) + }), + DType::U8 => compare_typed(lhs, &rhs, out_dtype, |a: u8, b: u8| { + i64_cmp(a as i64, b as i64) + }), + other => panic!("compare_int: unsupported dtype {:?}", other), + } +} + +fn compare_int_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, + i64_cmp: I64Cmp, + u64_cmp: U64Cmp, +) -> FlexTensor +where + I64Cmp: Fn(i64, i64) -> bool, + U64Cmp: Fn(u64, u64) -> bool, +{ + match lhs.dtype() { + DType::I64 => compare_elem_typed(lhs, i64_rhs, out_dtype, i64_cmp), + DType::U64 => compare_elem_typed(lhs, u64_rhs, out_dtype, u64_cmp), + DType::I32 => compare_elem_typed(lhs, i64_rhs as i32, out_dtype, |a: i32, b: i32| { + i64_cmp(a as i64, b as i64) + }), + DType::I16 => compare_elem_typed(lhs, i64_rhs as i16, out_dtype, |a: i16, b: i16| { + i64_cmp(a as i64, b as i64) + }), + DType::I8 => compare_elem_typed(lhs, i64_rhs as i8, out_dtype, |a: i8, b: i8| { + i64_cmp(a as i64, b as i64) + }), + DType::U32 => compare_elem_typed(lhs, i64_rhs as u32, out_dtype, |a: u32, b: u32| { + i64_cmp(a as i64, b as i64) + }), + DType::U16 => compare_elem_typed(lhs, i64_rhs as u16, out_dtype, |a: u16, b: u16| { + i64_cmp(a as i64, b as i64) + }), + DType::U8 => compare_elem_typed(lhs, i64_rhs as u8, out_dtype, |a: u8, b: u8| { + i64_cmp(a as i64, b as i64) + }), + other => panic!("compare_int_elem: unsupported dtype {:?}", other), + } +} + +pub fn int_greater(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a > b, |a, b| a > b) +} + +pub fn int_greater_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem(lhs, i64_rhs, u64_rhs, out_dtype, |a, b| a > b, |a, b| a > b) +} + +pub fn int_greater_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a >= b, |a, b| a >= b) +} + +pub fn int_greater_equal_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem( + lhs, + i64_rhs, + u64_rhs, + out_dtype, + |a, b| a >= b, + |a, b| a >= b, + ) +} + +pub fn int_lower(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a < b, |a, b| a < b) +} + +pub fn int_lower_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem(lhs, i64_rhs, u64_rhs, out_dtype, |a, b| a < b, |a, b| a < b) +} + +pub fn int_lower_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a <= b, |a, b| a <= b) +} + +pub fn int_lower_equal_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem( + lhs, + i64_rhs, + u64_rhs, + out_dtype, + |a, b| a <= b, + |a, b| a <= b, + ) +} + +pub fn int_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a == b, |a, b| a == b) +} + +pub fn int_equal_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem( + lhs, + i64_rhs, + u64_rhs, + out_dtype, + |a, b| a == b, + |a, b| a == b, + ) +} + +pub fn int_not_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + compare_int(lhs, rhs, out_dtype, |a, b| a != b, |a, b| a != b) +} + +pub fn int_not_equal_elem( + lhs: FlexTensor, + i64_rhs: i64, + u64_rhs: u64, + out_dtype: BoolDType, +) -> FlexTensor { + compare_int_elem( + lhs, + i64_rhs, + u64_rhs, + out_dtype, + |a, b| a != b, + |a, b| a != b, + ) +} + +pub fn bool_not_equal(lhs: FlexTensor, rhs: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + let shape = lhs.layout().shape().clone(); + let lhs_data: &[u8] = lhs.bytes(); + let rhs_data: &[u8] = rhs.bytes(); + let result: Vec = match ( + lhs.layout().contiguous_offsets(), + rhs.layout().contiguous_offsets(), + ) { + (Some((ls, le)), Some((rs, re))) => lhs_data[ls..le] + .iter() + .zip(&rhs_data[rs..re]) + .map(|(&a, &b)| if a != b { 1 } else { 0 }) + .collect(), + _ => { + let lhs = lhs.to_contiguous(); + let rhs = rhs.to_contiguous(); + lhs.bytes() + .iter() + .zip(rhs.bytes()) + .map(|(&a, &b)| if a != b { 1 } else { 0 }) + .collect() + } + }; + make_bool_tensor(result, shape, out_dtype) +} + +pub fn bool_not_equal_elem(lhs: FlexTensor, rhs: bool, out_dtype: BoolDType) -> FlexTensor { + let rhs_val: u8 = if rhs { 1 } else { 0 }; + let shape = lhs.layout().shape().clone(); + let lhs = lhs.to_contiguous(); + let data: &[u8] = lhs.bytes(); + let result: Vec = data + .iter() + .map(|&a| if a != rhs_val { 1 } else { 0 }) + .collect(); + make_bool_tensor(result, shape, out_dtype) +} + +// ============================================================================ +// any / all operations +// ============================================================================ + +/// Check if any element is non-zero (float tensors). +pub fn any_float(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let has_any = match tensor.dtype() { + DType::F32 => iter_elements::(&tensor).any(|x| x != 0.0), + DType::F64 => iter_elements::(&tensor).any(|x| x != 0.0), + DType::F16 => iter_elements::(&tensor).any(|x: f16| x.to_f32() != 0.0), + DType::BF16 => iter_elements::(&tensor).any(|x: bf16| x.to_f32() != 0.0), + _ => panic!("any_float: unsupported dtype {:?}", tensor.dtype()), + }; + bool_scalar(has_any, out_dtype) +} + +/// Check if any element along a dimension is non-zero (float tensors). +pub fn any_float_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim(&tensor, dim, false, |a, b| a || b, out_dtype) +} + +/// Check if all elements are non-zero (float tensors). +pub fn all_float(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let all = match tensor.dtype() { + DType::F32 => iter_elements::(&tensor).all(|x| x != 0.0), + DType::F64 => iter_elements::(&tensor).all(|x| x != 0.0), + DType::F16 => iter_elements::(&tensor).all(|x: f16| x.to_f32() != 0.0), + DType::BF16 => iter_elements::(&tensor).all(|x: bf16| x.to_f32() != 0.0), + _ => panic!("all_float: unsupported dtype {:?}", tensor.dtype()), + }; + bool_scalar(all, out_dtype) +} + +/// Check if all elements along a dimension are non-zero (float tensors). +pub fn all_float_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim(&tensor, dim, true, |a, b| a && b, out_dtype) +} + +/// Check if any element is non-zero (int tensors). +pub fn any_int(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let has_any = match tensor.dtype() { + DType::I64 => iter_elements::(&tensor).any(|x| x != 0), + DType::I32 => iter_elements::(&tensor).any(|x| x != 0), + DType::I16 => iter_elements::(&tensor).any(|x| x != 0), + DType::I8 => iter_elements::(&tensor).any(|x| x != 0), + DType::U64 => iter_elements::(&tensor).any(|x| x != 0), + DType::U32 => iter_elements::(&tensor).any(|x| x != 0), + DType::U16 => iter_elements::(&tensor).any(|x| x != 0), + DType::U8 => iter_elements::(&tensor).any(|x| x != 0), + _ => panic!("any_int: unsupported dtype {:?}", tensor.dtype()), + }; + bool_scalar(has_any, out_dtype) +} + +/// Check if any element along a dimension is non-zero (int tensors). +pub fn any_int_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim_int(&tensor, dim, false, |a, b| a || b, out_dtype) +} + +/// Check if all elements are non-zero (int tensors). +pub fn all_int(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let all = match tensor.dtype() { + DType::I64 => iter_elements::(&tensor).all(|x| x != 0), + DType::I32 => iter_elements::(&tensor).all(|x| x != 0), + DType::I16 => iter_elements::(&tensor).all(|x| x != 0), + DType::I8 => iter_elements::(&tensor).all(|x| x != 0), + DType::U64 => iter_elements::(&tensor).all(|x| x != 0), + DType::U32 => iter_elements::(&tensor).all(|x| x != 0), + DType::U16 => iter_elements::(&tensor).all(|x| x != 0), + DType::U8 => iter_elements::(&tensor).all(|x| x != 0), + _ => panic!("all_int: unsupported dtype {:?}", tensor.dtype()), + }; + bool_scalar(all, out_dtype) +} + +/// Check if all elements along a dimension are non-zero (int tensors). +pub fn all_int_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim_int(&tensor, dim, true, |a, b| a && b, out_dtype) +} + +/// Check if any bool element is true. +pub fn any_bool(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let data: &[u8] = tensor.bytes(); + bool_scalar(data.iter().any(|&x| x != 0), out_dtype) +} + +/// Check if any bool element along a dimension is true. +pub fn any_bool_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim_raw(&tensor, dim, false, |a, b| a || b, out_dtype) +} + +/// Check if all bool elements are true. +pub fn all_bool(tensor: FlexTensor, out_dtype: BoolDType) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let data: &[u8] = tensor.bytes(); + bool_scalar(data.iter().all(|&x| x != 0), out_dtype) +} + +/// Check if all bool elements along a dimension are true. +pub fn all_bool_dim(tensor: FlexTensor, dim: usize, out_dtype: BoolDType) -> FlexTensor { + reduce_bool_dim_raw(&tensor, dim, true, |a, b| a && b, out_dtype) +} + +// ============================================================================ +// Helpers for any/all +// ============================================================================ + +fn bool_scalar(val: bool, out_dtype: BoolDType) -> FlexTensor { + let byte: u8 = if val { 1 } else { 0 }; + make_bool_tensor(alloc::vec![byte], Shape::from(alloc::vec![1]), out_dtype) +} + +fn iter_elements<'a, E: Element + Pod + 'a>( + tensor: &'a FlexTensor, +) -> Box + 'a> { + let data: &[E] = tensor.storage(); + match tensor.layout().contiguous_offsets() { + Some((start, end)) => Box::new(data[start..end].iter().copied()), + None => Box::new(StridedIter::new(tensor.layout()).map(move |idx| data[idx])), + } +} + +/// Reduce along a dimension producing a bool tensor. +/// +/// The `is_nonzero` closure reads the data slice at a given index and returns +/// whether the element is nonzero. +fn reduce_bool_dim_with( + tensor: &FlexTensor, + dim: usize, + init: bool, + combine: fn(bool, bool) -> bool, + out_dtype: BoolDType, + is_nonzero: impl Fn(usize) -> bool, +) -> FlexTensor { + debug_assert!(tensor.is_contiguous() && tensor.layout().start_offset() == 0); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + assert!(dim < ndims); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let out_size = outer_size.max(1) * inner_size.max(1); + let mut result: Vec = Vec::with_capacity(out_size); + + for outer in 0..outer_size.max(1) { + for inner in 0..inner_size.max(1) { + let mut acc = init; + for d in 0..dim_size { + let idx = outer * dim_size * inner_size + d * inner_size + inner; + acc = combine(acc, is_nonzero(idx)); + } + result.push(if acc { 1 } else { 0 }); + } + } + + make_bool_tensor(result, Shape::from(out_shape), out_dtype) +} + +/// Reduce along a dimension producing a bool tensor (for float any/all_dim). +fn reduce_bool_dim( + tensor: &FlexTensor, + dim: usize, + init: bool, + combine: fn(bool, bool) -> bool, + out_dtype: BoolDType, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + match tensor.dtype() { + DType::F32 => { + let data: &[f32] = tensor.storage(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| { + data[idx] != 0.0 + }) + } + DType::F64 => { + let data: &[f64] = tensor.storage(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| { + data[idx] != 0.0 + }) + } + DType::F16 => { + let data: &[f16] = tensor.storage(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| { + data[idx].to_f32() != 0.0 + }) + } + DType::BF16 => { + let data: &[bf16] = tensor.storage(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| { + data[idx].to_f32() != 0.0 + }) + } + _ => panic!("reduce_bool_dim: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Reduce along a dimension producing a bool tensor (for int any/all_dim). +fn reduce_bool_dim_int( + tensor: &FlexTensor, + dim: usize, + init: bool, + combine: fn(bool, bool) -> bool, + out_dtype: BoolDType, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + macro_rules! dispatch { + ($ty:ty) => {{ + let data: &[$ty] = tensor.storage(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| data[idx] != 0) + }}; + } + match tensor.dtype() { + DType::I64 => dispatch!(i64), + DType::I32 => dispatch!(i32), + DType::I16 => dispatch!(i16), + DType::I8 => dispatch!(i8), + DType::U64 => dispatch!(u64), + DType::U32 => dispatch!(u32), + DType::U16 => dispatch!(u16), + DType::U8 => dispatch!(u8), + other => panic!("reduce_bool_dim_int: unsupported dtype {:?}", other), + } +} + +/// Reduce along a dimension producing a bool tensor (for bool any/all_dim). +fn reduce_bool_dim_raw( + tensor: &FlexTensor, + dim: usize, + init: bool, + combine: fn(bool, bool) -> bool, + out_dtype: BoolDType, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let data: &[u8] = tensor.bytes(); + reduce_bool_dim_with(&tensor, dim, init, combine, out_dtype, |idx| data[idx] != 0) +} + +// Tests kept here probe flex-internal `reduce_bool_dim_with` dispatch on +// non-contiguous inputs (stale-pointer-read regression, see prior incident +// in `any_float_dim`). Plain comparison ops and stride variants (flipped +// / transposed / narrowed) have been migrated to burn-backend-tests at +// tensor/{float,int}/ops/comparison.rs so every backend is exercised. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + fn tensor_2d(data: Vec, rows: usize, cols: usize) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, vec![rows, cols])) + } + + #[test] + fn test_any_float_dim_transposed() { + // [[0, 1], [0, 0]] transposed -> [[0, 0], [1, 0]] + // any_dim along dim 1: [false, true] -> [0, 1] + // Before the C2 fix, to_contiguous was called inside reduce_bool_dim_with + // AFTER taking a reference to the original non-contiguous storage, + // causing stale pointer reads. + let tensor = tensor_2d(vec![0.0, 1.0, 0.0, 0.0], 2, 2); + let transposed = tensor.transpose(0, 1); + assert!(!transposed.is_contiguous()); + + let result = any_float_dim(transposed, 1, BoolDType::Native); + let data: &[u8] = result.bytes(); + assert_eq!(data, &[0, 1]); // row 0: all zeros; row 1: has a 1 + } + + #[test] + fn test_any_float_dim_narrowed() { + // [0, 5, 0, 3, 0, 0] narrowed to [[5, 0], [3, 0]] (shape [2,2]) + // any_dim along dim 1: [true, true] -> [1, 1] + let tensor = FlexTensor::from_data(TensorData::new( + vec![0.0f32, 5.0, 0.0, 3.0, 0.0, 0.0], + [3, 2], + )); + let narrowed = tensor.narrow(0, 0, 2); // first 2 rows: [[0, 5], [0, 3]] + let result = any_float_dim(narrowed, 1, BoolDType::Native); + let data: &[u8] = result.bytes(); + assert_eq!(data, &[1, 1]); + } +} diff --git a/crates/burn-flex/src/ops/conv.rs b/crates/burn-flex/src/ops/conv.rs new file mode 100644 index 0000000..69fae22 --- /dev/null +++ b/crates/burn-flex/src/ops/conv.rs @@ -0,0 +1,2969 @@ +//! Forward convolution operations using tiled im2col + gemm approach. +//! +//! All convolutions (1D, 2D, 3D) use a unified 3D implementation: +//! - conv1d: adds two size-1 dimensions, calls conv3d, squeezes output +//! - conv2d: adds one size-1 dimension, calls conv3d, squeezes output +//! - conv3d: native implementation +//! +//! Optimizations: +//! - Tiled im2col: Process output in tiles for better cache usage and parallelism +//! - NHWC layout: Convert to channels-last for cache-friendly access +//! - Nested parallelism: Batch and tile dimensions run in parallel via rayon +//! - 1x1 fast path: Skip im2col for pointwise convolutions +//! - Depthwise fast path: For canonical depthwise (groups == c_in == c_out, +//! channels_per_group == 1), skip NHWC conversion, im2col, and gemm entirely. +//! Uses a direct per-(b, c) accumulate with analytic bounds so the inner +//! spatial loop has no padding checks and autovectorizes. +//! - Small-channel fast path: For groups=1 convs with very few input channels +//! (e.g. 3-channel Sobel-style edge filters), reuse the depthwise kernel by +//! accumulating over input channels. Skips the same NHWC/im2col/gemm overhead +//! as the depthwise path. Wins when `channels_in` is small enough that +//! gemm's per-dispatch setup cost dominates over the tiny inner compute. +//! - Direct conv path: For small-spatial 1D-like convolutions, decompose into +//! per-kernel-position gemm calls on NCHW data, skipping NHWC conversion and im2col +//! +//! Supported dtypes: f32, f64, f16 (native gemm), bf16 (via f32 conversion) + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::DType; +use burn_backend::ops::ConvOptions; +use burn_backend::ops::conv::calculate_conv_output_size; +use burn_std::{Bytes, Shape, f16}; + +use crate::{FlexTensor, Layout}; + +use super::conv_common::{add_bias, squeeze_3d_to_1d, squeeze_3d_to_2d}; + +// ============================================================================ +// Macros for forward conv +// ============================================================================ + +/// Generates a conv3d_1x1 function that uses the optimized gemm fast path. +macro_rules! conv3d_1x1_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $one:expr, $add_fn:expr) => { + fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + ) -> FlexTensor { + conv3d_1x1_impl::<$T>(x, weight, bias, options, $dtype, $zero, $one, $add_fn) + } + }; +} + +/// Generates a conv3d typed function with 1x1, depthwise, small-channel, and +/// direct fast-path checks. +macro_rules! conv3d_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $gemm_fn:ident, $add_fn:expr, $fn_1x1:ident, $fn_depthwise:ident, $fn_small_channel:ident $(, $fn_direct:ident)?) => { + pub fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + ) -> FlexTensor { + let w_shape = weight.layout().shape(); + if is_1x1_conv(w_shape[2], w_shape[3], w_shape[4], options) { + return $fn_1x1(x, weight, bias, options); + } + let x_shape = x.layout().shape(); + if should_use_depthwise_conv(x_shape, w_shape, options) { + return $fn_depthwise(x, weight, bias, options); + } + if should_use_small_channel_conv(x_shape, w_shape, options) { + return $fn_small_channel(x, weight, bias, options); + } + $( + if should_use_direct_conv(x_shape, w_shape, options) { + return $fn_direct(x, weight, bias, options); + } + )? + conv3d_impl::<$T>(x, weight, bias, options, $dtype, $zero, $gemm_fn, $add_fn) + } + }; +} + +// ============================================================================ +// Conv1d - delegates to conv3d +// ============================================================================ + +conv_nd_via_3d!( + conv1d_f32, + conv3d_f32, + expand_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvOptions +); +conv_nd_via_3d!( + conv1d_f64, + conv3d_f64, + expand_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvOptions +); +conv_nd_via_3d!( + conv1d_f16, + conv3d_f16, + expand_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvOptions +); +bf16_via_f32!(conv1d_bf16, conv1d_f32, 1, ConvOptions); + +fn expand_1d_to_3d( + x: &FlexTensor, + weight: &FlexTensor, + options: &ConvOptions<1>, +) -> (FlexTensor, FlexTensor, ConvOptions<3>) { + let x_shape = x.layout().shape(); + let x_3d = x.reshape(Shape::from(vec![x_shape[0], x_shape[1], 1, 1, x_shape[2]])); + + let w_shape = weight.layout().shape(); + let weight_3d = weight.reshape(Shape::from(vec![w_shape[0], w_shape[1], 1, 1, w_shape[2]])); + + let options_3d = ConvOptions::new( + [1, 1, options.stride[0]], + [0, 0, options.padding[0]], + [1, 1, options.dilation[0]], + options.groups, + ); + + (x_3d, weight_3d, options_3d) +} + +// ============================================================================ +// Conv2d - delegates to conv3d +// ============================================================================ + +conv_nd_via_3d!( + conv2d_f32, + conv3d_f32, + expand_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvOptions +); +conv_nd_via_3d!( + conv2d_f64, + conv3d_f64, + expand_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvOptions +); +conv_nd_via_3d!( + conv2d_f16, + conv3d_f16, + expand_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvOptions +); +bf16_via_f32!(conv2d_bf16, conv2d_f32, 2, ConvOptions); + +fn expand_2d_to_3d( + x: &FlexTensor, + weight: &FlexTensor, + options: &ConvOptions<2>, +) -> (FlexTensor, FlexTensor, ConvOptions<3>) { + let x_shape = x.layout().shape(); + let x_3d = x.reshape(Shape::from(vec![ + x_shape[0], x_shape[1], 1, x_shape[2], x_shape[3], + ])); + + let w_shape = weight.layout().shape(); + let weight_3d = weight.reshape(Shape::from(vec![ + w_shape[0], w_shape[1], 1, w_shape[2], w_shape[3], + ])); + + let options_3d = ConvOptions::new( + [1, options.stride[0], options.stride[1]], + [0, options.padding[0], options.padding[1]], + [1, options.dilation[0], options.dilation[1]], + options.groups, + ); + + (x_3d, weight_3d, options_3d) +} + +// ============================================================================ +// Conv3d - native implementations +// ============================================================================ + +conv3d_typed!( + conv3d_f32, + f32, + DType::F32, + 0.0f32, + gemm_f32, + |a, b| a + b, + conv3d_1x1_f32, + conv3d_depthwise_f32, + conv3d_small_channel_f32, + conv3d_direct_f32 +); +conv3d_typed!( + conv3d_f64, + f64, + DType::F64, + 0.0f64, + gemm_f64, + |a, b| a + b, + conv3d_1x1_f64, + conv3d_depthwise_f64, + conv3d_small_channel_f64, + conv3d_direct_f64 +); +conv3d_typed!( + conv3d_f16, + f16, + DType::F16, + f16::from_f32(0.0), + gemm_f16, + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()), + conv3d_1x1_f16, + conv3d_depthwise_f16, + conv3d_small_channel_f16 +); +bf16_via_f32!(conv3d_bf16, conv3d_f32, 3, ConvOptions); + +/// Generic 3D convolution implementation using tiled im2col. +/// +/// Tiled approach processes output in TILE_SIZE chunks: +/// - Reduces memory usage (smaller im2col buffer per tile) +/// - Enables tile-level parallelism +/// - Improves cache utilization +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +fn conv3d_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + dtype: DType, + zero: T, + gemm_fn: fn(&[T], &[T], usize, usize, usize) -> Vec, + add_fn: fn(T, T) -> T, +) -> FlexTensor { + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let channels_in = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let channels_out = w_shape[0]; + let channels_per_group = w_shape[1]; + let kernel_d = w_shape[2]; + let kernel_h = w_shape[3]; + let kernel_w = w_shape[4]; + + let [stride_d, stride_h, stride_w] = options.stride; + let [pad_d, pad_h, pad_w] = options.padding; + let groups = options.groups; + let out_channels_per_group = channels_out / groups; + + let out_d = calculate_conv_output_size(kernel_d, stride_d, pad_d, options.dilation[0], in_d); + let out_h = calculate_conv_output_size(kernel_h, stride_h, pad_h, options.dilation[1], in_h); + let out_w = calculate_conv_output_size(kernel_w, stride_w, pad_w, options.dilation[2], in_w); + + // Validate sizes won't overflow index calculations + let _total = [batch_size, channels_out, out_d, out_h, out_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv: output tensor dimensions would overflow index calculations"); + let _col_total = [channels_per_group, kernel_d, kernel_h, kernel_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv: kernel dimensions would overflow index calculations"); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + let col_len = channels_per_group * kernel_d * kernel_h * kernel_w; + let spatial_out = out_d * out_h * out_w; + + let [dilation_d, dilation_h, dilation_w] = options.dilation; + + // Tile size for processing output pixels. Larger = better GEMM utilization, + // smaller = more parallelism and better cache usage. 512 is a good balance. + const TILE_SIZE: usize = 512; + let num_tiles = spatial_out.div_ceil(TILE_SIZE); + + // Flatten kernel [c_out, c_in, kd, kh, kw] -> [c_out, kd, kh, kw, c_in] + // for GEMM. Expressed as a 2D transpose of (c_in, k_spatial) per c_out + // so the compiler can unroll the k_spatial inner loop; the older 5- + // nested formulation had a 10-term index expression LLVM wouldn't + // autovectorize. + let k_spatial = kernel_d * kernel_h * kernel_w; + let mut w_flat = vec![zero; channels_out * col_len]; + for c_out in 0..channels_out { + let src_base = c_out * channels_per_group * k_spatial; + let dst_base = c_out * col_len; + for c_in in 0..channels_per_group { + let src_row = src_base + c_in * k_spatial; + for k in 0..k_spatial { + w_flat[dst_base + k * channels_per_group + c_in] = w_data[src_row + k]; + } + } + } + + // Convert input to NHWC layout for cache-friendly access in im2col. + // Loop order (c innermost, spatial outer) is intentional: on aarch64 + // M3 Max, strided loads + contiguous stores beat the inverse by + // 10-35% per layer. Load prefetchers outrun the store write buffer. + let nhwc_stride = ( + in_d * in_h * in_w * channels_in, + in_h * in_w * channels_in, + in_w * channels_in, + channels_in, + 1, + ); + let mut x_nhwc = vec![zero; batch_size * in_d * in_h * in_w * channels_in]; + for b in 0..batch_size { + for d in 0..in_d { + for h in 0..in_h { + for w in 0..in_w { + for c in 0..channels_in { + let src_idx = b * channels_in * in_d * in_h * in_w + + c * in_d * in_h * in_w + + d * in_h * in_w + + h * in_w + + w; + let dst_idx = b * nhwc_stride.0 + + d * nhwc_stride.1 + + h * nhwc_stride.2 + + w * nhwc_stride.3 + + c; + x_nhwc[dst_idx] = x_data[src_idx]; + } + } + } + } + } + + // Use tiled parallel execution + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut dst = vec![zero; batch_size * channels_out * spatial_out]; + let dst_ptr = crate::ops::SendMutPtr::new(dst.as_mut_ptr()); + + // Process batches and tiles in parallel (nested parallelism) + (0..batch_size).into_par_iter().for_each(|b| { + (0..num_tiles).into_par_iter().for_each(|tile_idx| { + let tile_start = tile_idx * TILE_SIZE; + let tile_end = (tile_start + TILE_SIZE).min(spatial_out); + let tile_size = tile_end - tile_start; + + // Process each group separately + for g in 0..groups { + let in_c_start = g * channels_per_group; + let out_c_start = g * out_channels_per_group; + + // Build im2col for this tile and group + let mut col_tile = vec![zero; col_len * tile_size]; + + im2col_3d_tile( + &mut col_tile, + &x_nhwc, + tile_start, + tile_end, + out_h, + out_w, + kernel_d, + kernel_h, + kernel_w, + stride_d, + stride_h, + stride_w, + dilation_d, + dilation_h, + dilation_w, + pad_d, + pad_h, + pad_w, + in_d, + in_h, + in_w, + channels_per_group, + col_len, + b, + in_c_start, + nhwc_stride, + ); + + // Get weight slice for this group + let w_start = out_c_start * col_len; + let w_end = w_start + out_channels_per_group * col_len; + let w_group = &w_flat[w_start..w_end]; + + // GEMM: w_group[out_c_per_group, col_len] @ col_tile[tile_size, col_len]^T + let result = gemm_fn( + w_group, + &col_tile, + out_channels_per_group, + col_len, + tile_size, + ); + + // Write results to output for this group's output channels + for (local_idx, global_idx) in (tile_start..tile_end).enumerate() { + for c_out in 0..out_channels_per_group { + let dst_idx = b * channels_out * spatial_out + + (out_c_start + c_out) * spatial_out + + global_idx; + let res_idx = c_out * tile_size + local_idx; + unsafe { + debug_assert!( + dst_idx < batch_size * channels_out * spatial_out + ); + dst_ptr.write(dst_idx, result[res_idx]); + } + } + } + } + }); + }); + dst + } + #[cfg(not(feature = "rayon"))] + { + // Sequential path with tiling + let mut output = vec![zero; batch_size * channels_out * spatial_out]; + + for b in 0..batch_size { + for tile_idx in 0..num_tiles { + let tile_start = tile_idx * TILE_SIZE; + let tile_end = (tile_start + TILE_SIZE).min(spatial_out); + let tile_size = tile_end - tile_start; + + // Process each group separately + for g in 0..groups { + let in_c_start = g * channels_per_group; + let out_c_start = g * out_channels_per_group; + + let mut col_tile = vec![zero; col_len * tile_size]; + + im2col_3d_tile( + &mut col_tile, + &x_nhwc, + tile_start, + tile_end, + out_h, + out_w, + kernel_d, + kernel_h, + kernel_w, + stride_d, + stride_h, + stride_w, + dilation_d, + dilation_h, + dilation_w, + pad_d, + pad_h, + pad_w, + in_d, + in_h, + in_w, + channels_per_group, + col_len, + b, + in_c_start, + nhwc_stride, + ); + + // Get weight slice for this group + let w_start = out_c_start * col_len; + let w_end = w_start + out_channels_per_group * col_len; + let w_group = &w_flat[w_start..w_end]; + + // GEMM for this group + let result = gemm_fn( + w_group, + &col_tile, + out_channels_per_group, + col_len, + tile_size, + ); + + // Write results to output for this group's output channels + for (local_idx, global_idx) in (tile_start..tile_end).enumerate() { + for c_out in 0..out_channels_per_group { + let dst_idx = b * channels_out * spatial_out + + (out_c_start + c_out) * spatial_out + + global_idx; + let res_idx = c_out * tile_size + local_idx; + output[dst_idx] = result[res_idx]; + } + } + } + } + } + output + } + }; + + let mut output = output; + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + add_bias( + &mut output, + bias_data, + batch_size, + channels_out, + spatial_out, + add_fn, + ); + } + + let out_shape = Shape::from(vec![batch_size, channels_out, out_d, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +/// Build im2col tile for a range of output positions. +/// +/// Fills `col_tile` with shape [tile_size, col_len] where each row is a flattened +/// patch from the NHWC input for one output position. +#[allow(clippy::too_many_arguments)] +fn im2col_3d_tile( + col_tile: &mut [T], + x_nhwc: &[T], + tile_start: usize, + tile_end: usize, + out_h: usize, + out_w: usize, + kernel_d: usize, + kernel_h: usize, + kernel_w: usize, + stride_d: usize, + stride_h: usize, + stride_w: usize, + dilation_d: usize, + dilation_h: usize, + dilation_w: usize, + pad_d: usize, + pad_h: usize, + pad_w: usize, + in_d: usize, + in_h: usize, + in_w: usize, + channels_per_group: usize, + col_len: usize, + b: usize, + in_c_start: usize, + nhwc_stride: (usize, usize, usize, usize, usize), +) { + for (local_idx, global_idx) in (tile_start..tile_end).enumerate() { + let out_d_idx = global_idx / (out_h * out_w); + let rem = global_idx % (out_h * out_w); + let out_h_idx = rem / out_w; + let out_w_idx = rem % out_w; + + let mut col_offset = 0; + for kd in 0..kernel_d { + let id = (out_d_idx * stride_d + kd * dilation_d) as isize - pad_d as isize; + for kh in 0..kernel_h { + let ih = (out_h_idx * stride_h + kh * dilation_h) as isize - pad_h as isize; + for kw in 0..kernel_w { + let iw = (out_w_idx * stride_w + kw * dilation_w) as isize - pad_w as isize; + + if id >= 0 + && id < in_d as isize + && ih >= 0 + && ih < in_h as isize + && iw >= 0 + && iw < in_w as isize + { + let id = id as usize; + let ih = ih as usize; + let iw = iw as usize; + let inp_base = b * nhwc_stride.0 + + id * nhwc_stride.1 + + ih * nhwc_stride.2 + + iw * nhwc_stride.3 + + in_c_start; + for c in 0..channels_per_group { + col_tile[local_idx * col_len + col_offset] = x_nhwc[inp_base + c]; + col_offset += 1; + } + } else { + // Padding: skip channels_per_group positions (already zero) + col_offset += channels_per_group; + } + } + } + } + } +} + +/// Check if this is a 1x1 convolution that can use the fast path. +fn is_1x1_conv( + kernel_d: usize, + kernel_h: usize, + kernel_w: usize, + options: &ConvOptions<3>, +) -> bool { + kernel_d == 1 + && kernel_h == 1 + && kernel_w == 1 + && options.stride == [1, 1, 1] + && options.padding == [0, 0, 0] +} + +/// Optimized 1x1 convolution: skip im2col, call gemm directly on NCHW data. +/// +/// For 1x1 conv with stride=1 and padding=0, the input for each (batch, group) is +/// already [channels_per_group, spatial] row-major in NCHW layout. We pass it directly +/// to gemm as the RHS with appropriate strides, avoiding the transpose allocation +/// and the intermediate result buffer. +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +fn conv3d_1x1_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + dtype: DType, + zero: T, + one: T, + add_fn: fn(T, T) -> T, +) -> FlexTensor { + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let channels_in = x_shape[1]; + let spatial = x_shape[2] * x_shape[3] * x_shape[4]; + + let channels_out = w_shape[0]; + let channels_per_group = w_shape[1]; + let groups = options.groups; + let out_channels_per_group = channels_out / groups; + + let m = out_channels_per_group; + let k = channels_per_group; + let n = spatial; + + // Validate output size won't overflow + let total_output = [batch_size, channels_out, spatial] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv 1x1: output tensor dimensions would overflow index calculations"); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + // Gemm: C[M,N] = W[M,K] * X[K,N] + // W is [out_channels_per_group, channels_per_group] row-major + // X is [channels_per_group, spatial] row-major (directly from NCHW layout) + // C is [out_channels_per_group, spatial] row-major + #[cfg(feature = "rayon")] + let parallelism = if m.saturating_mul(n).saturating_mul(k) >= 192 * 192 * 192 { + gemm::Parallelism::Rayon(0) + } else { + gemm::Parallelism::None + }; + #[cfg(not(feature = "rayon"))] + let parallelism = gemm::Parallelism::None; + + let mut output = vec![zero; total_output]; + + { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + let dst_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + (0..batch_size).into_par_iter().for_each(|b| { + for g in 0..groups { + let x_offset = b * channels_in * spatial + g * k * spatial; + let w_offset = g * out_channels_per_group * k; + let out_offset = + b * channels_out * spatial + g * out_channels_per_group * spatial; + + unsafe { + gemm::gemm( + m, + n, + k, + dst_ptr.ptr_add(out_offset), + 1, // dst_cs + n as isize, // dst_rs + false, // read_dst + w_data.as_ptr().add(w_offset), + 1, // lhs_cs: W row-major + k as isize, // lhs_rs + x_data.as_ptr().add(x_offset), + 1, // rhs_cs: X row-major + n as isize, // rhs_rs + zero, + one, + false, + false, + false, + parallelism, + ); + } + } + }); + } + #[cfg(not(feature = "rayon"))] + { + for b in 0..batch_size { + for g in 0..groups { + let x_offset = b * channels_in * spatial + g * k * spatial; + let w_offset = g * out_channels_per_group * k; + let out_offset = + b * channels_out * spatial + g * out_channels_per_group * spatial; + + unsafe { + gemm::gemm( + m, + n, + k, + output.as_mut_ptr().add(out_offset), + 1, + n as isize, + false, + w_data.as_ptr().add(w_offset), + 1, + k as isize, + x_data.as_ptr().add(x_offset), + 1, + n as isize, + zero, + one, + false, + false, + false, + parallelism, + ); + } + } + } + } + } + + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + add_bias( + &mut output, + bias_data, + batch_size, + channels_out, + spatial, + add_fn, + ); + } + + let out_shape = Shape::from(vec![ + batch_size, + channels_out, + x_shape[2], + x_shape[3], + x_shape[4], + ]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +conv3d_1x1_typed!(conv3d_1x1_f32, f32, DType::F32, 0.0f32, 1.0f32, |a, b| a + + b); +conv3d_1x1_typed!(conv3d_1x1_f64, f64, DType::F64, 0.0f64, 1.0f64, |a, b| a + + b); +conv3d_1x1_typed!( + conv3d_1x1_f16, + f16, + DType::F16, + f16::from_f32(0.0), + f16::from_f32(1.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()) +); + +// ============================================================================ +// Depthwise conv fast path (no NHWC, no im2col, no gemm) +// ============================================================================ +// +// For canonical depthwise convolutions where every input channel maps to +// exactly one output channel through its own filter (groups == c_in == c_out, +// channels_per_group == 1), the generic im2col + per-group gemm path pays +// enormous overhead: +// +// - Per (tile, group) heap allocation of a col_tile buffer. +// - Per (tile, group) gemm dispatch with M=1, K=kernel_spatial, N=tile_size. +// The gemm kernel's setup cost dominates the tiny inner compute. +// - Full NHWC conversion of the input even though no channel mixing occurs. +// +// The depthwise path drops all three. It walks NCHW data directly and, for +// each (batch, channel), accumulates the convolution by looping over kernel +// positions on the outside and spatial positions on the inside. The valid +// output range for each kernel position is computed analytically so the +// inner spatial loop has no padding checks and LLVM autovectorizes it in +// the common stride=1, dilation=1 case. + +/// Decide whether to use the depthwise fast path. +/// +/// Triggers on canonical depthwise 1D or 2D convolutions (restricted to +/// `kd == 1 && in_d == 1` after the 3D expansion used by conv1d/conv2d). +fn should_use_depthwise_conv( + x_shape: &[usize], + w_shape: &[usize], + options: &ConvOptions<3>, +) -> bool { + let channels_in = x_shape[1]; + let channels_out = w_shape[0]; + let channels_per_group = w_shape[1]; + let groups = options.groups; + + // Canonical depthwise: one input channel per group, one output channel per group. + if channels_per_group != 1 || groups != channels_in || channels_out != channels_in { + return false; + } + + // Only the 1D/2D-in-3D shapes (kd=1, in_d=1) produced by the conv1d/conv2d + // expansion. Pure 3D depthwise would need additional loop nesting. + if w_shape[2] != 1 || x_shape[2] != 1 { + return false; + } + if options.stride[0] != 1 || options.padding[0] != 0 || options.dilation[0] != 1 { + return false; + } + + true +} + +/// Compute the half-open range `[out_start, out_end)` of output positions `o` +/// for which the corresponding input index `o * stride + k * dilation - pad` +/// lies inside `[0, in_size)`. +#[inline] +fn valid_out_range( + k: usize, + dilation: usize, + pad: usize, + stride: usize, + in_size: usize, + out_size: usize, +) -> (usize, usize) { + debug_assert!(stride >= 1, "stride must be >= 1"); + let offset = k * dilation; + + // Lower: smallest o with o*stride + offset >= pad. + let out_start = if offset >= pad { + 0 + } else { + (pad - offset).div_ceil(stride) + }; + + // Upper (exclusive): smallest o with o*stride + offset - pad >= in_size, + // i.e. smallest o with o*stride >= in_size + pad - offset. + let threshold = in_size + pad; + let out_end = if offset >= threshold { + 0 + } else { + (threshold - offset).div_ceil(stride) + }; + + let out_end = out_end.min(out_size); + let out_start = out_start.min(out_end); + (out_start, out_end) +} + +/// Output-plane element count above which `conv_plane_accumulate` switches +/// from the `kh, kw, oh, ow` "kh-outer" loop order to `oh, kh, kw, ow` +/// "oh-outer". +/// +/// Below the threshold the whole plane fits comfortably in L1 (~32 KB on +/// most modern CPUs), so the hardware prefetcher tracks the regular +/// `oh`-stride access and the kh-outer order amortizes per-kernel-position +/// setup over long inner runs. Above the threshold the plane no longer +/// fits, and the kh-outer order refetches the output from L2/DRAM once +/// per `(kh, kw)` pair: a `kh * kw`-fold amplification of output memory +/// traffic. Flipping to oh-outer pins one output row in L1 across every +/// kernel position and traverses the plane exactly once. +/// +/// 8192 f32 elements = 32 KB, i.e. L1 data-cache size on M3/M4 Max and +/// most modern x86 server parts. The gap in the conv benchmarks is clean: +/// every depthwise shape that regressed under pure oh-outer was <= 3136 +/// elements, while the Sobel / preproc / mask shapes that win under +/// oh-outer are all > 65000 elements. +const CONV_PLANE_OH_OUTER_THRESHOLD: usize = 8192; + +/// Accumulate one 2D conv plane: `out_plane += conv2d(in_plane, w_plane)` using +/// the precomputed analytic `oh_ranges`/`ow_ranges` to skip padding checks in +/// the inner loop. +/// +/// **Precondition**: `out_plane` must already hold the running accumulator +/// (zero on the first call, or whatever partial sum has been built up across +/// previous `ci` iterations). The function reads each output element before +/// writing, so an uninitialized buffer produces silent garbage. +/// +/// Dispatches to one of two loop orders based on `out_plane.len()`; see +/// `CONV_PLANE_OH_OUTER_THRESHOLD` for the tradeoff. Shared by +/// `conv3d_depthwise_impl` and `conv3d_small_channel_impl`. The dispatcher +/// is `#[inline]` (not `inline(always)`) so the runtime length branch lives +/// once at each call site instead of inlining both variants; the variants +/// themselves stay `inline(always)` so LLVM sees the concrete inner loop +/// pattern and emits SIMD fmuladd. Using `num_traits::Float` bounds (rather +/// than fn-pointer arithmetic) is load-bearing for vectorization. +#[inline] +#[allow(clippy::too_many_arguments)] +fn conv_plane_accumulate( + out_plane: &mut [T], + in_plane: &[T], + w_plane: &[T], + kernel_h: usize, + kernel_w: usize, + in_w: usize, + out_w: usize, + stride_h: usize, + stride_w: usize, + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + oh_ranges: &[(usize, usize)], + ow_ranges: &[(usize, usize)], +) { + if out_plane.len() > CONV_PLANE_OH_OUTER_THRESHOLD { + conv_plane_accumulate_oh_outer( + out_plane, in_plane, w_plane, kernel_h, kernel_w, in_w, out_w, stride_h, stride_w, + pad_h, pad_w, dilation_h, dilation_w, oh_ranges, ow_ranges, + ); + } else { + conv_plane_accumulate_kh_outer( + out_plane, in_plane, w_plane, kernel_h, kernel_w, in_w, out_w, stride_h, stride_w, + pad_h, pad_w, dilation_h, dilation_w, oh_ranges, ow_ranges, + ); + } +} + +/// `oh`-outermost variant. Pins one output row in L1 across every kernel +/// position that accumulates into it, so each row is touched exactly once +/// regardless of how large the full output plane is. Selected when the +/// plane exceeds L1 (see `CONV_PLANE_OH_OUTER_THRESHOLD`). +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn conv_plane_accumulate_oh_outer( + out_plane: &mut [T], + in_plane: &[T], + w_plane: &[T], + kernel_h: usize, + kernel_w: usize, + in_w: usize, + out_w: usize, + stride_h: usize, + stride_w: usize, + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + oh_ranges: &[(usize, usize)], + ow_ranges: &[(usize, usize)], +) { + // An empty plane or zero-width output is a trivial no-op. This also + // guards the divide below against `out_w == 0`, which is not reachable + // from the in-tree callers (burn's `calculate_conv_output_size` is + // always >= 1 for valid inputs) but would otherwise panic if some + // future caller handed us a degenerate slice. + if out_plane.is_empty() || out_w == 0 { + return; + } + + // out_plane is a per-(batch, channel) slice of shape [out_h, out_w] + // stored contiguously; both call sites produce it by disjoint splitting + // of a `vec![zero; batch * channels * out_h * out_w]` allocation, so + // the length is always an exact multiple of `out_w`. The `debug_assert` + // turns that documentary invariant into an enforceable one. + debug_assert_eq!( + out_plane.len() % out_w, + 0, + "out_plane length must be a whole number of rows" + ); + let out_h = out_plane.len() / out_w; + + for oh in 0..out_h { + let out_row = &mut out_plane[oh * out_w..(oh + 1) * out_w]; + + for kh in 0..kernel_h { + let (oh_start, oh_end) = oh_ranges[kh]; + // Skip kernel rows that fall outside the padded image at this oh. + if oh < oh_start || oh >= oh_end { + continue; + } + let ih = oh * stride_h + kh * dilation_h - pad_h; + let in_row = &in_plane[ih * in_w..(ih + 1) * in_w]; + + for kw in 0..kernel_w { + let (ow_start, ow_end) = ow_ranges[kw]; + if ow_start >= ow_end { + continue; + } + let w_val = w_plane[kh * kernel_w + kw]; + // All terms are non-negative because ow_start was chosen so + // that the corresponding `iw` is in bounds. + let iw_start = ow_start * stride_w + kw * dilation_w - pad_w; + + if stride_w == 1 { + let run_len = ow_end - ow_start; + let in_slice = &in_row[iw_start..iw_start + run_len]; + let out_slice = &mut out_row[ow_start..ow_end]; + for (o, &xv) in out_slice.iter_mut().zip(in_slice.iter()) { + *o = *o + w_val * xv; + } + } else { + let mut iw = iw_start; + for o in &mut out_row[ow_start..ow_end] { + *o = *o + w_val * in_row[iw]; + iw += stride_w; + } + } + } + } + } +} + +/// `kh, kw`-outermost variant. Amortizes per-kernel-position setup over +/// long regular `oh` runs that the hardware prefetcher can track. +/// Selected when the whole output plane already fits in L1 (see +/// `CONV_PLANE_OH_OUTER_THRESHOLD`): the oh-outer trick buys nothing +/// there and the extra per-iteration bookkeeping slightly hurts. +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn conv_plane_accumulate_kh_outer( + out_plane: &mut [T], + in_plane: &[T], + w_plane: &[T], + kernel_h: usize, + kernel_w: usize, + in_w: usize, + out_w: usize, + stride_h: usize, + stride_w: usize, + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + oh_ranges: &[(usize, usize)], + ow_ranges: &[(usize, usize)], +) { + for kh in 0..kernel_h { + let (oh_start, oh_end) = oh_ranges[kh]; + if oh_start >= oh_end { + continue; + } + for kw in 0..kernel_w { + let (ow_start, ow_end) = ow_ranges[kw]; + if ow_start >= ow_end { + continue; + } + let w_val = w_plane[kh * kernel_w + kw]; + let iw_start = ow_start * stride_w + kw * dilation_w - pad_w; + let run_len = ow_end - ow_start; + for oh in oh_start..oh_end { + let ih = oh * stride_h + kh * dilation_h - pad_h; + let in_row = &in_plane[ih * in_w..(ih + 1) * in_w]; + let out_row = &mut out_plane[oh * out_w..(oh + 1) * out_w]; + if stride_w == 1 { + let in_slice = &in_row[iw_start..iw_start + run_len]; + let out_slice = &mut out_row[ow_start..ow_end]; + for (o, &xv) in out_slice.iter_mut().zip(in_slice.iter()) { + *o = *o + w_val * xv; + } + } else { + let mut iw = iw_start; + for o in &mut out_row[ow_start..ow_end] { + *o = *o + w_val * in_row[iw]; + iw += stride_w; + } + } + } + } + } +} + +macro_rules! conv3d_depthwise_typed { + ($fn_name:ident, $T:ty, $dtype:expr) => { + fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + ) -> FlexTensor { + conv3d_depthwise_impl::<$T>(x, weight, bias, options, $dtype) + } + }; +} + +conv3d_depthwise_typed!(conv3d_depthwise_f32, f32, DType::F32); +conv3d_depthwise_typed!(conv3d_depthwise_f64, f64, DType::F64); +conv3d_depthwise_typed!(conv3d_depthwise_f16, f16, DType::F16); + +/// Depthwise conv3d: one filter per channel, no channel mixing. +/// +/// Preconditions (checked by `should_use_depthwise_conv`): +/// - `channels_per_group == 1` +/// - `groups == channels_in == channels_out` +/// - `kernel_d == 1`, `in_d == 1`, trivial d-axis options. +/// +/// Uses `num_traits::Float` bounds so the inner multiply-accumulate compiles +/// down to direct `fmul`/`fadd` instructions that LLVM can autovectorize. +/// Function-pointer arithmetic (`fn(T, T) -> T`) would prevent vectorization +/// because each call is an indirect branch that blocks the loop pattern match. +fn conv3d_depthwise_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + dtype: DType, +) -> FlexTensor +where + T: num_traits::Float + bytemuck::Pod + Clone + Copy + burn_backend::Element + Send + Sync, +{ + let zero = ::zero(); + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let kernel_h = w_shape[3]; + let kernel_w = w_shape[4]; + + let [_, stride_h, stride_w] = options.stride; + let [_, pad_h, pad_w] = options.padding; + let [_, dilation_h, dilation_w] = options.dilation; + + let out_h = calculate_conv_output_size(kernel_h, stride_h, pad_h, dilation_h, in_h); + let out_w = calculate_conv_output_size(kernel_w, stride_w, pad_w, dilation_w, in_w); + + let total = [batch_size, channels, out_h, out_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv depthwise: output dimensions would overflow"); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + let in_spatial = in_h * in_w; + let out_spatial = out_h * out_w; + let k_spatial = kernel_h * kernel_w; + + // Valid oh/ow ranges are identical for every (b, c), so precompute once. + let oh_ranges: Vec<(usize, usize)> = (0..kernel_h) + .map(|kh| valid_out_range(kh, dilation_h, pad_h, stride_h, in_h, out_h)) + .collect(); + let ow_ranges: Vec<(usize, usize)> = (0..kernel_w) + .map(|kw| valid_out_range(kw, dilation_w, pad_w, stride_w, in_w, out_w)) + .collect(); + + let mut output = vec![zero; total]; + + // Per-plane work for one `(batch, channel)` pair. Output slice is passed + // in so the rayon and sequential dispatch paths can source it differently + // (from a raw pointer or a direct borrow) without duplicating this body. + let plane_work = |bc: usize, out_plane: &mut [T]| { + let c = bc % channels; + let in_base = bc * in_spatial; + let w_base = c * k_spatial; + conv_plane_accumulate( + out_plane, + &x_data[in_base..in_base + in_spatial], + &w_data[w_base..w_base + k_spatial], + kernel_h, + kernel_w, + in_w, + out_w, + stride_h, + stride_w, + pad_h, + pad_w, + dilation_h, + dilation_w, + &oh_ranges, + &ow_ranges, + ); + }; + + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let dst_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + (0..batch_size * channels).into_par_iter().for_each(|bc| { + // SAFETY: disjoint `[bc * out_spatial, (bc+1) * out_spatial)` + // ranges tile the output buffer. + let out_plane: &mut [T] = unsafe { + core::slice::from_raw_parts_mut(dst_ptr.ptr_add(bc * out_spatial), out_spatial) + }; + plane_work(bc, out_plane); + }); + } + #[cfg(not(feature = "rayon"))] + { + for bc in 0..batch_size * channels { + let out_base = bc * out_spatial; + plane_work(bc, &mut output[out_base..out_base + out_spatial]); + } + } + + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + assert_eq!( + bias_data.len(), + channels, + "conv depthwise: bias length ({}) must equal channels ({channels})", + bias_data.len() + ); + add_bias( + &mut output, + bias_data, + batch_size, + channels, + out_spatial, + |a, b| a + b, + ); + } + + let out_shape = Shape::from(vec![batch_size, channels, 1, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// Small-channel conv fast path (no NHWC, no im2col, no gemm) +// ============================================================================ +// +// For `groups=1` convs with very few input channels (e.g. 3-channel Sobel +// filters, single-channel mask networks, early-stage image preprocessors), +// the generic `conv3d_impl` pays the same kind of overhead as the depthwise +// case: gemm is dispatched with `M = channels_out, K = channels_in * k_spatial, +// N = tile_size`, and at small `K` the gemm kernel's pack + select + dispatch +// cost dominates the actual FMAs. On top of that, the full NHWC conversion +// of the input is pure memory traffic that buys nothing when the channel +// dimension is already tiny. +// +// The small-channel path walks NCHW data directly. For each `(batch, out_ch)` +// pair it accumulates contributions from every input channel by calling the +// same `conv_plane_accumulate` helper used by the depthwise path. Compared +// to the depthwise case this adds an outer `for ci in 0..channels_in` loop +// around the accumulate; everything else (analytic output ranges, parallel +// fan-out, bias add) is identical. + +/// Threshold on `channels_in` for the small-channel fast path. +/// +/// Picked empirically: at `channels_in <= 4`, gemm dispatch overhead exceeds +/// the inner compute by a clear margin. Tuning higher risks regressing shapes +/// where gemm's data reuse across output channels wins. +const SMALL_CHANNEL_IN_THRESHOLD: usize = 4; + +/// Threshold on `channels_out` for the small-channel fast path. +/// +/// The small-channel path loops `for co: for ci:` and re-reads each input +/// channel once per output channel. At large `channels_out`, that redundant +/// memory traffic dominates over gemm's register-tiled data reuse. Picked +/// empirically: classic ImageNet first-layer (`3 -> 64`) runs ~5x slower on +/// the direct path than on gemm, so we cap at 16 to stay safely on the right +/// side of that cliff. Sobel-style filters (`3 -> 3..8`) are well under this +/// and keep their 1.1-1.4x win over burn-ndarray. +const SMALL_CHANNEL_OUT_THRESHOLD: usize = 16; + +/// Decide whether to use the small-channel fast path. +/// +/// Triggers on `groups=1` 2D-in-3D convs (or 1D via the conv1d expansion) +/// with both small `channels_in` and small `channels_out`. Non-depthwise +/// grouped convs and the many-channel case both go through the generic +/// `conv3d_impl`. +fn should_use_small_channel_conv( + x_shape: &[usize], + w_shape: &[usize], + options: &ConvOptions<3>, +) -> bool { + if options.groups != 1 { + return false; + } + + // Only the 1D/2D-in-3D shapes (kd=1, in_d=1). Pure 3D convs do not benefit + // and would require adding a d-axis loop. + if w_shape[2] != 1 || x_shape[2] != 1 { + return false; + } + if options.stride[0] != 1 || options.padding[0] != 0 || options.dilation[0] != 1 { + return false; + } + + let channels_in = x_shape[1]; + let channels_out = w_shape[0]; + channels_in > 0 + && channels_in <= SMALL_CHANNEL_IN_THRESHOLD + && channels_out > 0 + && channels_out <= SMALL_CHANNEL_OUT_THRESHOLD +} + +macro_rules! conv3d_small_channel_typed { + ($fn_name:ident, $T:ty, $dtype:expr) => { + fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + ) -> FlexTensor { + conv3d_small_channel_impl::<$T>(x, weight, bias, options, $dtype) + } + }; +} + +conv3d_small_channel_typed!(conv3d_small_channel_f32, f32, DType::F32); +conv3d_small_channel_typed!(conv3d_small_channel_f64, f64, DType::F64); +conv3d_small_channel_typed!(conv3d_small_channel_f16, f16, DType::F16); + +/// Small-channel conv3d: `groups=1` with small `channels_in`. +/// +/// Preconditions (checked by `should_use_small_channel_conv`): +/// - `options.groups == 1` +/// - `channels_in <= SMALL_CHANNEL_IN_THRESHOLD` +/// - `kernel_d == 1`, `in_d == 1`, trivial d-axis options. +fn conv3d_small_channel_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + dtype: DType, +) -> FlexTensor +where + T: num_traits::Float + bytemuck::Pod + Clone + Copy + burn_backend::Element + Send + Sync, +{ + let zero = ::zero(); + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let channels_in = x_shape[1]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let channels_out = w_shape[0]; + let kernel_h = w_shape[3]; + let kernel_w = w_shape[4]; + + let [_, stride_h, stride_w] = options.stride; + let [_, pad_h, pad_w] = options.padding; + let [_, dilation_h, dilation_w] = options.dilation; + + let out_h = calculate_conv_output_size(kernel_h, stride_h, pad_h, dilation_h, in_h); + let out_w = calculate_conv_output_size(kernel_w, stride_w, pad_w, dilation_w, in_w); + + let total = [batch_size, channels_out, out_h, out_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv small-channel: output dimensions would overflow"); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + let in_spatial = in_h * in_w; + let out_spatial = out_h * out_w; + let k_spatial = kernel_h * kernel_w; + let w_co_stride = channels_in * k_spatial; + let x_batch_stride = channels_in * in_spatial; + + let oh_ranges: Vec<(usize, usize)> = (0..kernel_h) + .map(|kh| valid_out_range(kh, dilation_h, pad_h, stride_h, in_h, out_h)) + .collect(); + let ow_ranges: Vec<(usize, usize)> = (0..kernel_w) + .map(|kw| valid_out_range(kw, dilation_w, pad_w, stride_w, in_w, out_w)) + .collect(); + + let mut output = vec![zero; total]; + + // Per-plane work for one `(batch, out_channel)` pair. Accumulates + // `sum over ci of conv2d(in[b, ci], w[co, ci])` into `out_plane` by + // calling the shared helper once per input channel. + let plane_work = |b_co: usize, out_plane: &mut [T]| { + let b = b_co / channels_out; + let co = b_co % channels_out; + for ci in 0..channels_in { + let in_base = b * x_batch_stride + ci * in_spatial; + let w_base = co * w_co_stride + ci * k_spatial; + conv_plane_accumulate( + out_plane, + &x_data[in_base..in_base + in_spatial], + &w_data[w_base..w_base + k_spatial], + kernel_h, + kernel_w, + in_w, + out_w, + stride_h, + stride_w, + pad_h, + pad_w, + dilation_h, + dilation_w, + &oh_ranges, + &ow_ranges, + ); + } + }; + + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let dst_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + (0..batch_size * channels_out) + .into_par_iter() + .for_each(|b_co| { + // SAFETY: disjoint `[b_co * out_spatial, (b_co+1) * out_spatial)` + // ranges tile the output buffer. + let out_plane: &mut [T] = unsafe { + core::slice::from_raw_parts_mut( + dst_ptr.ptr_add(b_co * out_spatial), + out_spatial, + ) + }; + plane_work(b_co, out_plane); + }); + } + #[cfg(not(feature = "rayon"))] + { + for b_co in 0..batch_size * channels_out { + let out_base = b_co * out_spatial; + plane_work(b_co, &mut output[out_base..out_base + out_spatial]); + } + } + + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + assert_eq!( + bias_data.len(), + channels_out, + "conv small-channel: bias length ({}) must equal channels_out ({channels_out})", + bias_data.len() + ); + add_bias( + &mut output, + bias_data, + batch_size, + channels_out, + out_spatial, + |a, b| a + b, + ); + } + + let out_shape = Shape::from(vec![batch_size, channels_out, 1, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// Direct conv path for small spatial sizes (no im2col) +// ============================================================================ + +/// Decide whether to use the direct conv path instead of tiled im2col + GEMM. +/// +/// The direct path decomposes the conv into kw gemm calls with strided pointers +/// into NCHW data, eliminating both the NHWC conversion and im2col buffer. +/// This wins when spatial_out is small enough that im2col allocation and fill +/// overhead is significant relative to compute. +fn should_use_direct_conv(x_shape: &[usize], w_shape: &[usize], options: &ConvOptions<3>) -> bool { + // Only for groups=1, no padding, dilation=1 (the wav2vec2 case). + if options.groups != 1 || options.padding != [0, 0, 0] || options.dilation != [1, 1, 1] { + return false; + } + + let kernel_d = w_shape[2]; + let kernel_h = w_shape[3]; + let kernel_w = w_shape[4]; + + // Only for "1D-like" convolutions expanded to 3D + if kernel_d != 1 || kernel_h != 1 { + return false; + } + if x_shape[2] != 1 || x_shape[3] != 1 { + return false; + } + + let channels_in = x_shape[1]; + let in_w = x_shape[4]; + let out_w = calculate_conv_output_size(kernel_w, options.stride[2], 0, 1, in_w); + + // The direct path eliminates im2col buffer allocation (kw * c_in * tile_size floats) + // and the copy into it. This matters when spatial_out is small relative to c_in * kw. + channels_in >= 32 && kernel_w <= 8 && out_w <= 800 +} + +macro_rules! conv3d_direct_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $one:expr, $add_fn:expr) => { + fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + ) -> FlexTensor { + conv3d_direct_impl::<$T>(x, weight, bias, options, $dtype, $zero, $one, $add_fn) + } + }; +} + +conv3d_direct_typed!( + conv3d_direct_f32, + f32, + DType::F32, + 0.0f32, + 1.0f32, + |a, b| a + b +); +conv3d_direct_typed!( + conv3d_direct_f64, + f64, + DType::F64, + 0.0f64, + 1.0f64, + |a, b| a + b +); + +/// Direct conv3d: decompose into kw gemm calls on NCHW data directly. +/// +/// The conv operation is: out[co, o] = sum_k sum_c w[co, c, k] * x[c, o*stride+k] +/// +/// This decomposes into kw matrix multiplies, one per kernel position: +/// out += W_k @ X_k where W_k = w[:, :, k] and X_k = x[:, o*stride+k] +/// +/// Each gemm reads directly from NCHW input with strided pointers, eliminating +/// both the NHWC conversion and im2col buffer entirely. +/// +/// Constraints: groups=1, padding=0, dilation=1, 1D-like (d=1, h=1). +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +fn conv3d_direct_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvOptions<3>, + dtype: DType, + zero: T, + one: T, + add_fn: fn(T, T) -> T, +) -> FlexTensor { + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let channels_in = x_shape[1]; + let in_w = x_shape[4]; + + let channels_out = w_shape[0]; + let kernel_w = w_shape[4]; + let stride_w = options.stride[2]; + + let out_w = calculate_conv_output_size(kernel_w, stride_w, 0, 1, in_w); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + // Weight layout: [c_out, c_in, kw] contiguous (kd=kh=1). + // For kernel position k: W_k[co, c] = w_data[co * c_in * kw + c * kw + k] + let lhs_rs = (channels_in * kernel_w) as isize; + let lhs_cs = kernel_w as isize; + + // For kernel position k: X_k[c_in, o] = x_data[c_in * in_w + o * stride + k] + let rhs_rs = in_w as isize; + let rhs_cs = stride_w as isize; + + let m = channels_out; + let gemm_k = channels_in; + let n = out_w; + + #[cfg(feature = "rayon")] + let parallelism = if m.saturating_mul(n).saturating_mul(gemm_k) >= 192 * 192 * 192 { + gemm::Parallelism::Rayon(0) + } else { + gemm::Parallelism::None + }; + #[cfg(not(feature = "rayon"))] + let parallelism = gemm::Parallelism::None; + + let total_output = batch_size + .checked_mul(channels_out) + .and_then(|x| x.checked_mul(out_w)) + .expect("conv direct: output dimensions overflow"); + let mut output = vec![zero; total_output]; + + let batch_x_len = channels_in * in_w; + + { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + let dst_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + (0..batch_size).into_par_iter().for_each(|b| { + let out_offset = b * channels_out * out_w; + + for k in 0..kernel_w { + unsafe { + let x_base = x_data.as_ptr().add(b * batch_x_len); + gemm::gemm( + m, + n, + gemm_k, + dst_ptr.ptr_add(out_offset), + 1, + n as isize, + k > 0, + w_data.as_ptr().add(k), + lhs_cs, + lhs_rs, + x_base.add(k), + rhs_cs, + rhs_rs, + one, + one, + false, + false, + false, + parallelism, + ); + } + } + }); + } + #[cfg(not(feature = "rayon"))] + { + for b in 0..batch_size { + let out_offset = b * channels_out * out_w; + + for k in 0..kernel_w { + unsafe { + let x_base = x_data.as_ptr().add(b * batch_x_len); + gemm::gemm( + m, + n, + gemm_k, + output.as_mut_ptr().add(out_offset), + 1, + n as isize, + k > 0, + w_data.as_ptr().add(k), + lhs_cs, + lhs_rs, + x_base.add(k), + rhs_cs, + rhs_rs, + one, + one, + false, + false, + false, + parallelism, + ); + } + } + } + } + } + + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + add_bias( + &mut output, + bias_data, + batch_size, + channels_out, + out_w, + add_fn, + ); + } + + let out_shape = Shape::from(vec![batch_size, channels_out, 1, 1, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// gemm implementations +// ============================================================================ +// +// One thin wrapper per element type around `gemm::gemm` with the strides and +// parallelism heuristic fixed to what conv3d_impl needs. The wrappers share +// every line of logic aside from the numeric type, zero, and one literals, +// so they're generated via a macro. + +macro_rules! gemm_typed { + ($fn_name:ident, $T:ty, $zero:expr, $one:expr) => { + fn $fn_name(a: &[$T], b: &[$T], m: usize, k: usize, n: usize) -> Vec<$T> { + let mut c = vec![$zero; m * n]; + #[cfg(feature = "rayon")] + let parallelism = if m * n * k >= 192 * 192 * 192 { + gemm::Parallelism::Rayon(0) + } else { + gemm::Parallelism::None + }; + #[cfg(not(feature = "rayon"))] + let parallelism = gemm::Parallelism::None; + unsafe { + gemm::gemm( + m, + n, + k, + c.as_mut_ptr(), + 1, + n as isize, + false, + a.as_ptr(), + 1, + k as isize, + b.as_ptr(), + k as isize, + 1, + $zero, + $one, + false, + false, + false, + parallelism, + ); + } + c + } + }; +} + +gemm_typed!(gemm_f32, f32, 0.0f32, 1.0f32); +gemm_typed!(gemm_f64, f64, 0.0f64, 1.0f64); +gemm_typed!(gemm_f16, f16, f16::from_f32(0.0), f16::from_f32(1.0)); + +// ============================================================================ +// Tests +// ============================================================================ + +// Tests kept here exercise flex-specific dtype storage paths (f64/f16/bf16) +// and flex-internal fast-path dispatch (1x1, depthwise, small-channel, +// direct, oh-outer) plus validation/panic checks on flex helpers. Plain +// conv shape/stride/padding/groups correctness is covered at the public +// API level by crates/burn-backend-tests/tests/tensor/float/module/ +// conv{1,2,3}d.rs, so those tests were removed from here. When adding +// new tests, keep them here only if they probe flex dtype dispatch or a +// flex-internal fast path; otherwise add them there. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + use burn_std::bf16; + + #[test] + fn test_conv1d_direct_path() { + // c_in=64 >= 32, kernel=3, stride=2, out_w=49 <= 800 -> hits direct path + let c_in = 64; + let c_out = 32; + let in_w = 100; + let kw = 3; + let stride = 2; + let out_w = (in_w - kw) / stride + 1; // 49 + + // Deterministic input and weights + let x_data: Vec = (0..c_in * in_w) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + let w_data: Vec = (0..c_out * c_in * kw) + .map(|i| ((i % 50) as f32 / 50.0) - 0.5) + .collect(); + + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, c_in, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![c_out, c_in, kw])); + let options = ConvOptions::new([stride], [0], [1], 1); + let result = conv1d_f32(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + assert_eq!(out.len(), c_out * out_w); + + // Verify against naive reference implementation + for co in 0..c_out { + for o in 0..out_w { + let mut expected = 0.0f32; + for ci in 0..c_in { + for k in 0..kw { + expected += w_data[co * c_in * kw + ci * kw + k] + * x_data[ci * in_w + o * stride + k]; + } + } + let actual = out[co * out_w + o]; + assert!( + (actual - expected).abs() < 1e-3, + "mismatch at co={co}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + + #[test] + fn test_conv1d_direct_path_kw2() { + // Test with kw=2 (L5/L6 shapes use kw=2) + let c_in = 64; + let c_out = 32; + let in_w = 50; + let kw = 2; + let stride = 2; + let out_w = (in_w - kw) / stride + 1; // 24 + + let x_data: Vec = (0..c_in * in_w) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + let w_data: Vec = (0..c_out * c_in * kw) + .map(|i| ((i % 50) as f32 / 50.0) - 0.5) + .collect(); + + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, c_in, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![c_out, c_in, kw])); + let options = ConvOptions::new([stride], [0], [1], 1); + let result = conv1d_f32(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + for co in 0..c_out { + for o in 0..out_w { + let mut expected = 0.0f32; + for ci in 0..c_in { + for k in 0..kw { + expected += w_data[co * c_in * kw + ci * kw + k] + * x_data[ci * in_w + o * stride + k]; + } + } + let actual = out[co * out_w + o]; + assert!( + (actual - expected).abs() < 1e-3, + "mismatch at co={co}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + + #[test] + fn test_conv1d_direct_path_f64() { + let c_in = 64; + let c_out = 16; + let in_w = 50; + let kw = 3; + let stride = 2; + let out_w = (in_w - kw) / stride + 1; + + let x_data: Vec = (0..c_in * in_w) + .map(|i| ((i % 100) as f64 / 100.0) - 0.5) + .collect(); + let w_data: Vec = (0..c_out * c_in * kw) + .map(|i| ((i % 50) as f64 / 50.0) - 0.5) + .collect(); + + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, c_in, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![c_out, c_in, kw])); + let options = ConvOptions::new([stride], [0], [1], 1); + let result = conv1d_f64(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + for co in 0..c_out { + for o in 0..out_w { + let mut expected = 0.0f64; + for ci in 0..c_in { + for k in 0..kw { + expected += w_data[co * c_in * kw + ci * kw + k] + * x_data[ci * in_w + o * stride + k]; + } + } + let actual = out[co * out_w + o]; + assert!( + (actual - expected).abs() < 1e-10, + "f64 mismatch at co={co}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + + #[test] + fn test_conv1d_direct_path_with_bias() { + let c_in = 64; + let c_out = 32; + let in_w = 50; + let kw = 2; + let stride = 2; + let out_w = (in_w - kw) / stride + 1; + + let x_data: Vec = (0..c_in * in_w) + .map(|i| ((i % 100) as f32 / 100.0) - 0.5) + .collect(); + let w_data: Vec = (0..c_out * c_in * kw) + .map(|i| ((i % 50) as f32 / 50.0) - 0.5) + .collect(); + let bias_data: Vec = (0..c_out).map(|i| i as f32 * 0.1).collect(); + + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, c_in, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![c_out, c_in, kw])); + let bias = FlexTensor::from_data(TensorData::new(bias_data.clone(), vec![c_out])); + let options = ConvOptions::new([stride], [0], [1], 1); + let result = conv1d_f32(x, weight, Some(bias), &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + for co in 0..c_out { + for o in 0..out_w { + let mut expected = bias_data[co]; + for ci in 0..c_in { + for k in 0..kw { + expected += w_data[co * c_in * kw + ci * kw + k] + * x_data[ci * in_w + o * stride + k]; + } + } + let actual = out[co * out_w + o]; + assert!( + (actual - expected).abs() < 1e-3, + "bias mismatch at co={co}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + + #[test] + fn test_conv2d_f64() { + let x_data: Vec = (1..=16).map(|x| x as f64).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + let w_data = vec![1.0f64; 4]; + let weight = FlexTensor::from_data(TensorData::new(w_data, vec![1, 1, 2, 2])); + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let result = conv2d_f64(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + assert_eq!( + out, + vec![14.0, 18.0, 22.0, 30.0, 34.0, 38.0, 46.0, 50.0, 54.0] + ); + } + + #[test] + fn test_conv2d_f16() { + let x_data: Vec = (1..=16).map(|x| f16::from_f32(x as f32)).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + let w_data: Vec = vec![f16::from_f32(1.0); 4]; + let weight = FlexTensor::from_data(TensorData::new(w_data, vec![1, 1, 2, 2])); + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let result = conv2d_f16(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + let expected = vec![14.0, 18.0, 22.0, 30.0, 34.0, 38.0, 46.0, 50.0, 54.0]; + for (a, e) in out.iter().zip(expected.iter()) { + assert!((a.to_f32() - e).abs() < 0.5); + } + } + + #[test] + fn test_conv2d_bf16() { + let x_data: Vec = (1..=16).map(|x| bf16::from_f32(x as f32)).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + let w_data: Vec = vec![bf16::from_f32(1.0); 4]; + let weight = FlexTensor::from_data(TensorData::new(w_data, vec![1, 1, 2, 2])); + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 1); + let result = conv2d_bf16(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + let expected = vec![14.0, 18.0, 22.0, 30.0, 34.0, 38.0, 46.0, 50.0, 54.0]; + for (a, e) in out.iter().zip(expected.iter()) { + assert!((a.to_f32() - e).abs() < 0.5); + } + } + + // ======================================================================== + // Depthwise conv fast-path tests + // ======================================================================== + // + // These tests exercise `conv3d_depthwise_impl` and cross-check against a + // naive reference to catch any indexing, bounds, or accumulation mistakes. + + /// Naive NCHW depthwise conv2d reference implementation. + /// Returns output with shape `[batch, channels, out_h, out_w]`. + #[allow(clippy::too_many_arguments)] + fn naive_depthwise_conv2d_f32( + x: &[f32], + w: &[f32], + bias: Option<&[f32]>, + batch: usize, + channels: usize, + in_h: usize, + in_w: usize, + kernel_h: usize, + kernel_w: usize, + stride_h: usize, + stride_w: usize, + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + ) -> (Vec, usize, usize) { + let out_h = (in_h + 2 * pad_h - dilation_h * (kernel_h - 1) - 1) / stride_h + 1; + let out_w = (in_w + 2 * pad_w - dilation_w * (kernel_w - 1) - 1) / stride_w + 1; + let mut out = vec![0.0f32; batch * channels * out_h * out_w]; + for b in 0..batch { + for c in 0..channels { + for oh in 0..out_h { + for ow in 0..out_w { + let mut acc = 0.0f32; + for kh in 0..kernel_h { + let ih = oh as isize * stride_h as isize + + kh as isize * dilation_h as isize + - pad_h as isize; + if ih < 0 || ih >= in_h as isize { + continue; + } + for kw in 0..kernel_w { + let iw = ow as isize * stride_w as isize + + kw as isize * dilation_w as isize + - pad_w as isize; + if iw < 0 || iw >= in_w as isize { + continue; + } + let x_idx = + ((b * channels + c) * in_h + ih as usize) * in_w + iw as usize; + let w_idx = (c * kernel_h + kh) * kernel_w + kw; + acc += x[x_idx] * w[w_idx]; + } + } + if let Some(bias) = bias { + acc += bias[c]; + } + let o_idx = ((b * channels + c) * out_h + oh) * out_w + ow; + out[o_idx] = acc; + } + } + } + } + (out, out_h, out_w) + } + + /// Build deterministic pseudo-random input for a depthwise test. + fn seeded_vec_f32(n: usize, seed: u32) -> Vec { + (0..n) + .map(|i| { + let v = ((i as u32).wrapping_mul(2654435761).wrapping_add(seed)) & 0xffff; + (v as f32 / 32768.0) - 1.0 + }) + .collect() + } + + /// Shared helper: run conv2d via the public dispatch (which must pick the + /// depthwise path for these shapes) and compare to the naive reference. + #[allow(clippy::too_many_arguments)] + fn check_depthwise_conv2d_f32( + batch: usize, + channels: usize, + in_h: usize, + in_w: usize, + kernel_h: usize, + kernel_w: usize, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + with_bias: bool, + ) { + let x_vec = seeded_vec_f32(batch * channels * in_h * in_w, 1); + let w_vec = seeded_vec_f32(channels * kernel_h * kernel_w, 2); + let bias_vec = if with_bias { + Some(seeded_vec_f32(channels, 3)) + } else { + None + }; + + let (expected, out_h, out_w) = naive_depthwise_conv2d_f32( + &x_vec, + &w_vec, + bias_vec.as_deref(), + batch, + channels, + in_h, + in_w, + kernel_h, + kernel_w, + stride[0], + stride[1], + padding[0], + padding[1], + dilation[0], + dilation[1], + ); + + let x = FlexTensor::from_data(TensorData::new(x_vec, vec![batch, channels, in_h, in_w])); + let weight = FlexTensor::from_data(TensorData::new( + w_vec, + vec![channels, 1, kernel_h, kernel_w], + )); + let bias = bias_vec.map(|v| FlexTensor::from_data(TensorData::new(v, vec![channels]))); + let options = ConvOptions::new(stride, padding, dilation, channels); + let result = conv2d_f32(x, weight, bias, &options); + + assert_eq!( + result.layout().shape().to_vec(), + vec![batch, channels, out_h, out_w], + "output shape mismatch" + ); + + let out: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(out.len(), expected.len()); + for (i, (a, e)) in out.iter().zip(expected.iter()).enumerate() { + assert!( + (a - e).abs() < 1e-4, + "mismatch at {i}: got {a}, expected {e}" + ); + } + } + + #[test] + fn test_conv2d_depthwise_3x3_no_pad() { + // Canonical depthwise: groups == channels_in == channels_out = 8, 3x3. + check_depthwise_conv2d_f32(2, 8, 16, 16, 3, 3, [1, 1], [0, 0], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_3x3_pad1() { + // Same input and output size via padding=1. + check_depthwise_conv2d_f32(2, 8, 16, 16, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_3x3_stride2_pad1() { + // Halve spatial via stride 2, with padding. + check_depthwise_conv2d_f32(1, 16, 32, 32, 3, 3, [2, 2], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_5x5_pad2() { + check_depthwise_conv2d_f32(2, 4, 10, 10, 5, 5, [1, 1], [2, 2], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_7x7_pad3() { + // Large kernel like ConvNeXt's 7x7 depthwise. + check_depthwise_conv2d_f32(2, 24, 14, 14, 7, 7, [1, 1], [3, 3], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_dilated() { + // Dilation 2 means the effective receptive field is 5x5 but with gaps. + check_depthwise_conv2d_f32(1, 8, 12, 12, 3, 3, [1, 1], [2, 2], [2, 2], false); + } + + #[test] + fn test_conv2d_depthwise_with_bias() { + check_depthwise_conv2d_f32(2, 8, 8, 8, 3, 3, [1, 1], [1, 1], [1, 1], true); + } + + #[test] + fn test_conv2d_depthwise_single_channel() { + // groups = channels_in = 1 is a degenerate depthwise which should + // still go through this path (it also looks identical to an + // ungrouped 1-channel conv, but validates the range math). + check_depthwise_conv2d_f32(1, 1, 5, 5, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_asymmetric_kernel() { + // Non-square kernel. + check_depthwise_conv2d_f32(1, 4, 8, 12, 3, 5, [1, 1], [1, 2], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_f64() { + // Smoke test f64 dispatch through depthwise path. + let x_data: Vec = (0..2 * 4 * 5 * 5).map(|i| (i as f64) * 0.1).collect(); + let w_data: Vec = (0..4 * 3 * 3).map(|i| (i as f64) * 0.01).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![2, 4, 5, 5])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![4, 1, 3, 3])); + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 4); + let result = conv2d_f64(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + // Verify against a naive f64 reference for one element (center of channel 2). + let b = 0usize; + let c = 2usize; + let oh = 2usize; + let ow = 2usize; + let mut expected = 0.0f64; + for kh in 0..3 { + for kw in 0..3 { + let ih = oh as isize + kh as isize - 1; + let iw = ow as isize + kw as isize - 1; + if (0..5).contains(&ih) && (0..5).contains(&iw) { + let x_idx = ((b * 4 + c) * 5 + ih as usize) * 5 + iw as usize; + let w_idx = (c * 3 + kh) * 3 + kw; + expected += x_data[x_idx] * w_data[w_idx]; + } + } + } + let out_idx = ((b * 4 + c) * 5 + oh) * 5 + ow; + assert!((out[out_idx] - expected).abs() < 1e-10); + } + + #[test] + fn test_conv2d_depthwise_f16() { + // Validate f16 depthwise dispatch against a naive f32 reference. + // Shape check alone would miss indexing/accumulation bugs that still + // happen to produce the right shape. + use burn_std::f16; + let x_data_f32: Vec = (0..16).map(|i| i as f32 * 0.1).collect(); + let w_data_f32: Vec = (0..16).map(|i| i as f32 * 0.01).collect(); + let x_data: Vec = x_data_f32.iter().copied().map(f16::from_f32).collect(); + let w_data: Vec = w_data_f32.iter().copied().map(f16::from_f32).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 4, 2, 2])); + let weight = FlexTensor::from_data(TensorData::new(w_data, vec![4, 1, 2, 2])); + let options = ConvOptions::new([1, 1], [0, 0], [1, 1], 4); + let result = conv2d_f16(x, weight, None, &options); + assert_eq!(result.layout().shape().to_vec(), vec![1, 4, 1, 1]); + let out: Vec = result.into_data().to_vec().unwrap(); + + // Depthwise: out[c] = sum over (kh, kw) of x[c, kh, kw] * w[c, 0, kh, kw]. + // The input per-channel is 4 elements (2x2) and the kernel is 2x2, so + // there's exactly one output pixel per channel. + for c in 0..4 { + let mut expected = 0.0f32; + for k in 0..4 { + // c * 4 indexes into channel c's 2x2 plane; both x and w share + // the same [c, ...] offset because the depthwise weight layout + // is [c_out, 1, kh, kw]. + expected += x_data_f32[c * 4 + k] * w_data_f32[c * 4 + k]; + } + let actual = out[c].to_f32(); + assert!( + (actual - expected).abs() < 1e-2, + "f16 depthwise mismatch at c={c}: expected {expected}, got {actual}" + ); + } + } + + #[test] + fn test_conv1d_depthwise() { + // conv1d -> conv3d expansion (kd=kh=1), depthwise on the last axis. + let channels = 4; + let in_w = 16; + let kw = 3; + let x_data = seeded_vec_f32(channels * in_w, 10); + let w_data = seeded_vec_f32(channels * kw, 20); + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, channels, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![channels, 1, kw])); + let options = ConvOptions::new([1], [1], [1], channels); + let result = conv1d_f32(x, weight, None, &options); + let out_w = in_w; + assert_eq!(result.layout().shape().to_vec(), vec![1, channels, out_w]); + let out: Vec = result.into_data().to_vec().unwrap(); + + // Naive reference. + for c in 0..channels { + for o in 0..out_w { + let mut expected = 0.0f32; + for k in 0..kw { + let i = o as isize + k as isize - 1; + if i >= 0 && i < in_w as isize { + expected += x_data[c * in_w + i as usize] * w_data[c * kw + k]; + } + } + let actual = out[c * out_w + o]; + assert!( + (actual - expected).abs() < 1e-5, + "conv1d depthwise mismatch at c={c}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + + #[test] + fn test_conv1d_depthwise_stride_batch_bias() { + // Depthwise conv1d with batch > 1, stride > 1, padding, and bias. + // conv1d flows through the same conv3d_depthwise_impl as conv2d + // because conv1d expands to a 3D shape with kd=kh=1. This test + // validates the full dispatch path with a non-trivial stride. + let batch = 3; + let channels = 6; + let in_w = 32; + let kw = 5; + let stride = 2; + let pad = 2; + let out_w = (in_w + 2 * pad - kw) / stride + 1; + + let x_data = seeded_vec_f32(batch * channels * in_w, 30); + let w_data = seeded_vec_f32(channels * kw, 40); + let bias_data = seeded_vec_f32(channels, 50); + + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![batch, channels, in_w])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![channels, 1, kw])); + let bias = FlexTensor::from_data(TensorData::new(bias_data.clone(), vec![channels])); + let options = ConvOptions::new([stride], [pad], [1], channels); + let result = conv1d_f32(x, weight, Some(bias), &options); + assert_eq!( + result.layout().shape().to_vec(), + vec![batch, channels, out_w] + ); + let out: Vec = result.into_data().to_vec().unwrap(); + + // Naive reference. + for b in 0..batch { + for c in 0..channels { + for o in 0..out_w { + let mut expected = bias_data[c]; + for k in 0..kw { + let i = (o as isize * stride as isize) + k as isize - pad as isize; + if i >= 0 && i < in_w as isize { + let x_idx = (b * channels + c) * in_w + i as usize; + let w_idx = c * kw + k; + expected += x_data[x_idx] * w_data[w_idx]; + } + } + let actual = out[(b * channels + c) * out_w + o]; + assert!( + (actual - expected).abs() < 1e-4, + "conv1d depthwise mismatch at b={b}, c={c}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + } + + // ======================================================================== + // Small-channel conv fast-path tests + // ======================================================================== + // + // These tests exercise `conv3d_small_channel_impl` for groups=1 convs + // with `channels_in <= SMALL_CHANNEL_IN_THRESHOLD`. They cross-check the + // output against a naive NCHW reference that sums over both input + // channels and kernel positions. + + #[allow(clippy::too_many_arguments)] + fn naive_conv2d_f32( + x: &[f32], + w: &[f32], + bias: Option<&[f32]>, + batch: usize, + channels_in: usize, + channels_out: usize, + in_h: usize, + in_w: usize, + kernel_h: usize, + kernel_w: usize, + stride_h: usize, + stride_w: usize, + pad_h: usize, + pad_w: usize, + dilation_h: usize, + dilation_w: usize, + ) -> (Vec, usize, usize) { + let out_h = (in_h + 2 * pad_h - dilation_h * (kernel_h - 1) - 1) / stride_h + 1; + let out_w = (in_w + 2 * pad_w - dilation_w * (kernel_w - 1) - 1) / stride_w + 1; + let mut out = vec![0.0f32; batch * channels_out * out_h * out_w]; + for b in 0..batch { + for co in 0..channels_out { + for oh in 0..out_h { + for ow in 0..out_w { + let mut acc = 0.0f32; + for ci in 0..channels_in { + for kh in 0..kernel_h { + let ih = oh as isize * stride_h as isize + + kh as isize * dilation_h as isize + - pad_h as isize; + if ih < 0 || ih >= in_h as isize { + continue; + } + for kw in 0..kernel_w { + let iw = ow as isize * stride_w as isize + + kw as isize * dilation_w as isize + - pad_w as isize; + if iw < 0 || iw >= in_w as isize { + continue; + } + let x_idx = ((b * channels_in + ci) * in_h + ih as usize) + * in_w + + iw as usize; + let w_idx = + ((co * channels_in + ci) * kernel_h + kh) * kernel_w + kw; + acc += x[x_idx] * w[w_idx]; + } + } + } + if let Some(bias) = bias { + acc += bias[co]; + } + let o_idx = ((b * channels_out + co) * out_h + oh) * out_w + ow; + out[o_idx] = acc; + } + } + } + } + (out, out_h, out_w) + } + + #[allow(clippy::too_many_arguments)] + fn check_small_channel_conv2d_f32( + batch: usize, + channels_in: usize, + channels_out: usize, + in_h: usize, + in_w: usize, + kernel_h: usize, + kernel_w: usize, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + with_bias: bool, + ) { + let x_vec = seeded_vec_f32(batch * channels_in * in_h * in_w, 100); + let w_vec = seeded_vec_f32(channels_out * channels_in * kernel_h * kernel_w, 200); + let bias_vec = if with_bias { + Some(seeded_vec_f32(channels_out, 300)) + } else { + None + }; + + let (expected, out_h, out_w) = naive_conv2d_f32( + &x_vec, + &w_vec, + bias_vec.as_deref(), + batch, + channels_in, + channels_out, + in_h, + in_w, + kernel_h, + kernel_w, + stride[0], + stride[1], + padding[0], + padding[1], + dilation[0], + dilation[1], + ); + + let x = FlexTensor::from_data(TensorData::new(x_vec, vec![batch, channels_in, in_h, in_w])); + let weight = FlexTensor::from_data(TensorData::new( + w_vec, + vec![channels_out, channels_in, kernel_h, kernel_w], + )); + let bias = bias_vec.map(|v| FlexTensor::from_data(TensorData::new(v, vec![channels_out]))); + let options = ConvOptions::new(stride, padding, dilation, 1); + let result = conv2d_f32(x, weight, bias, &options); + + assert_eq!( + result.layout().shape().to_vec(), + vec![batch, channels_out, out_h, out_w], + "output shape mismatch" + ); + + let out: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(out.len(), expected.len()); + for (i, (a, e)) in out.iter().zip(expected.iter()).enumerate() { + assert!( + (a - e).abs() < 1e-3, + "mismatch at {i}: got {a}, expected {e}" + ); + } + } + + #[test] + fn test_conv2d_small_channel_3in_8out_k3x3_pad1() { + // Sobel-like: 3 input channels, several output channels, 3x3 kernel. + check_small_channel_conv2d_f32(2, 3, 8, 16, 16, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_3in_3out_k3x3_no_pad() { + // Channels_in == channels_out == 3, same count but groups=1 so each + // output channel combines all input channels (not depthwise). + check_small_channel_conv2d_f32(1, 3, 3, 10, 10, 3, 3, [1, 1], [0, 0], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_3in_16out_k5x5_pad2() { + check_small_channel_conv2d_f32(2, 3, 16, 12, 12, 5, 5, [1, 1], [2, 2], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_4in_8out_k3x3_stride2() { + // Threshold channels_in == 4. + check_small_channel_conv2d_f32(1, 4, 8, 16, 16, 3, 3, [2, 2], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_2in_4out_dilated() { + check_small_channel_conv2d_f32(1, 2, 4, 12, 12, 3, 3, [1, 1], [2, 2], [2, 2], false); + } + + #[test] + fn test_conv2d_small_channel_with_bias() { + check_small_channel_conv2d_f32(2, 3, 8, 8, 8, 3, 3, [1, 1], [1, 1], [1, 1], true); + } + + #[test] + fn test_conv2d_small_channel_asymmetric_kernel() { + // 1x3 and 3x1 kernels are common for separable edge filters. + check_small_channel_conv2d_f32(1, 3, 6, 16, 16, 1, 3, [1, 1], [0, 1], [1, 1], false); + check_small_channel_conv2d_f32(1, 3, 6, 16, 16, 3, 1, [1, 1], [1, 0], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_f64() { + // Smoke test f64 dispatch. + let x_data: Vec = (0..2 * 3 * 5 * 5).map(|i| (i as f64) * 0.1).collect(); + let w_data: Vec = (0..4 * 3 * 3 * 3).map(|i| (i as f64) * 0.01).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![2, 3, 5, 5])); + let weight = FlexTensor::from_data(TensorData::new(w_data.clone(), vec![4, 3, 3, 3])); + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + let result = conv2d_f64(x, weight, None, &options); + let out: Vec = result.into_data().to_vec().unwrap(); + + // Verify against a naive reference for one element (center of channel 2). + let b = 0usize; + let co = 2usize; + let oh = 2usize; + let ow = 2usize; + let mut expected = 0.0f64; + for ci in 0..3 { + for kh in 0..3 { + for kw in 0..3 { + let ih = oh as isize + kh as isize - 1; + let iw = ow as isize + kw as isize - 1; + if (0..5).contains(&ih) && (0..5).contains(&iw) { + let x_idx = ((b * 3 + ci) * 5 + ih as usize) * 5 + iw as usize; + let w_idx = ((co * 3 + ci) * 3 + kh) * 3 + kw; + expected += x_data[x_idx] * w_data[w_idx]; + } + } + } + } + let out_idx = ((b * 4 + co) * 5 + oh) * 5 + ow; + assert!( + (out[out_idx] - expected).abs() < 1e-10, + "got {}, expected {expected}", + out[out_idx] + ); + } + + #[test] + fn test_conv2d_small_channel_f16() { + // Validate f16 dispatch through the small-channel path by running + // the same shape through the f32 path and comparing element-wise. + // This catches indexing/accumulation bugs in addition to regressions + // in the `num_traits::Float` monomorphization for f16. + use burn_std::f16; + let x_data_f32: Vec = (0..3 * 4 * 4).map(|i| i as f32 * 0.1).collect(); + let w_data_f32: Vec = (0..4 * 3 * 3 * 3).map(|i| i as f32 * 0.01).collect(); + + let x_data_f16: Vec = x_data_f32.iter().copied().map(f16::from_f32).collect(); + let w_data_f16: Vec = w_data_f32.iter().copied().map(f16::from_f32).collect(); + + let x_f16 = FlexTensor::from_data(TensorData::new(x_data_f16, vec![1, 3, 4, 4])); + let weight_f16 = FlexTensor::from_data(TensorData::new(w_data_f16, vec![4, 3, 3, 3])); + let x_f32 = FlexTensor::from_data(TensorData::new(x_data_f32, vec![1, 3, 4, 4])); + let weight_f32 = FlexTensor::from_data(TensorData::new(w_data_f32, vec![4, 3, 3, 3])); + + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + let result_f16 = conv2d_f16(x_f16, weight_f16, None, &options); + let result_f32 = conv2d_f32(x_f32, weight_f32, None, &options); + assert_eq!(result_f16.layout().shape().to_vec(), vec![1, 4, 4, 4]); + assert_eq!(result_f32.layout().shape().to_vec(), vec![1, 4, 4, 4]); + + let out_f16: Vec = result_f16.into_data().to_vec().unwrap(); + let out_f32: Vec = result_f32.into_data().to_vec().unwrap(); + assert_eq!(out_f16.len(), out_f32.len()); + + // f16 has ~11 bits of mantissa (~0.1% relative precision). With + // accumulation across `c_in * k_spatial = 27` FMAs the relative + // error can grow to a few times that. Use a relative tolerance + // scaled by the expected magnitude with a small absolute floor for + // values near zero. + let rel_tol = 3e-3f32; + let abs_tol = 1e-2f32; + for (i, (actual, expected)) in out_f16.iter().zip(out_f32.iter()).enumerate() { + let actual_f32 = actual.to_f32(); + let bound = (expected.abs() * rel_tol).max(abs_tol); + assert!( + !actual_f32.is_nan() && (actual_f32 - expected).abs() <= bound, + "f16 small-channel mismatch at {i}: got {actual_f32}, expected {expected}, bound {bound}" + ); + } + } + + #[test] + fn test_conv2d_small_channel_bias_length_mismatch_panics() { + // The small-channel impl must panic loudly when bias length does not + // match channels_out. A silent truncation here would make models with + // misconfigured bias silently produce wrong output. + let x = FlexTensor::from_data(TensorData::new(vec![0.0f32; 48], vec![1, 3, 4, 4])); + let weight = FlexTensor::from_data(TensorData::new(vec![0.0f32; 108], vec![4, 3, 3, 3])); + // Bias has only 2 elements but there are 4 output channels. + let bias = FlexTensor::from_data(TensorData::new(vec![0.0f32, 0.0], vec![2])); + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 1); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + conv2d_f32(x, weight, Some(bias), &options) + })); + assert!(result.is_err(), "expected panic on bias length mismatch"); + } + + #[test] + fn test_conv2d_depthwise_bias_length_mismatch_panics() { + // Mirror of the small-channel bias check for the depthwise path. + // Depthwise dispatch requires groups == channels_in == channels_out, so + // we construct a 3->3 depthwise conv and deliberately underspecify the + // bias length to exercise the assert_eq in conv3d_depthwise_impl. + let x = FlexTensor::from_data(TensorData::new(vec![0.0f32; 48], vec![1, 3, 4, 4])); + let weight = FlexTensor::from_data(TensorData::new(vec![0.0f32; 27], vec![3, 1, 3, 3])); + // Bias has only 2 elements but there are 3 depthwise channels. + let bias = FlexTensor::from_data(TensorData::new(vec![0.0f32, 0.0], vec![2])); + let options = ConvOptions::new([1, 1], [1, 1], [1, 1], 3); // groups=3 => depthwise + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + conv2d_f32(x, weight, Some(bias), &options) + })); + assert!(result.is_err(), "expected panic on bias length mismatch"); + } + + #[test] + fn test_conv_plane_accumulate_accumulates_into_prefilled() { + // Pin the documented precondition that `conv_plane_accumulate` + // accumulates into `out_plane` rather than overwriting it. Both + // in-tree callers pre-zero before the first call, then call it + // repeatedly across input channels with the result acting as the + // running accumulator. A regression that overwrites instead of + // accumulating would silently drop every input-channel contribution + // except the last. + // + // Test shape: single 2x2 input -> single 2x2 output, 1x1 kernel with + // weight 1.0. Pre-fill out_plane with [10, 20, 30, 40] so the + // expected post-call value is x + prefill. + let x = vec![1.0f32, 2.0, 3.0, 4.0]; + let w = vec![1.0f32]; // 1x1 kernel, weight = 1 + let mut out_plane = vec![10.0f32, 20.0, 30.0, 40.0]; + + // 1x1 kernel, no padding: the only kernel position contributes to + // every output row and column (indices [0, out_h) and [0, out_w)). + let oh_ranges = [(0usize, 2usize)]; // per kh: (out_start, out_end) + let ow_ranges = [(0usize, 2usize)]; // per kw: (out_start, out_end) + + super::conv_plane_accumulate::( + &mut out_plane, + &x, + &w, + /* kernel_h */ 1, + /* kernel_w */ 1, + /* in_w */ 2, + /* out_w */ 2, + /* stride_h */ 1, + /* stride_w */ 1, + /* pad_h */ 0, + /* pad_w */ 0, + /* dilation_h */ 1, + /* dilation_w */ 1, + &oh_ranges, + &ow_ranges, + ); + + // Expected: prefill + (x * 1.0) element-wise. + assert_eq!(out_plane, vec![11.0f32, 22.0, 33.0, 44.0]); + } + + #[test] + fn test_conv1d_small_channel_3in() { + // conv1d -> conv3d expansion with groups=1 and 3 input channels. + let batch = 2; + let channels_in = 3; + let channels_out = 5; + let in_w = 24; + let kw = 5; + let stride = 1; + let pad = 2; + let out_w = in_w; // same padding + + let x_data = seeded_vec_f32(batch * channels_in * in_w, 400); + let w_data = seeded_vec_f32(channels_out * channels_in * kw, 500); + let x = FlexTensor::from_data(TensorData::new( + x_data.clone(), + vec![batch, channels_in, in_w], + )); + let weight = FlexTensor::from_data(TensorData::new( + w_data.clone(), + vec![channels_out, channels_in, kw], + )); + let options = ConvOptions::new([stride], [pad], [1], 1); + let result = conv1d_f32(x, weight, None, &options); + assert_eq!( + result.layout().shape().to_vec(), + vec![batch, channels_out, out_w] + ); + let out: Vec = result.into_data().to_vec().unwrap(); + + for b in 0..batch { + for co in 0..channels_out { + for o in 0..out_w { + let mut expected = 0.0f32; + for ci in 0..channels_in { + for k in 0..kw { + let i = o as isize + k as isize - pad as isize; + if i >= 0 && i < in_w as isize { + let x_idx = (b * channels_in + ci) * in_w + i as usize; + let w_idx = (co * channels_in + ci) * kw + k; + expected += x_data[x_idx] * w_data[w_idx]; + } + } + } + let actual = out[(b * channels_out + co) * out_w + o]; + assert!( + (actual - expected).abs() < 1e-4, + "mismatch at b={b}, co={co}, o={o}: expected {expected}, got {actual}" + ); + } + } + } + } + + #[test] + fn test_conv2d_small_channel_single_input() { + // 1 input channel is allowed (<= threshold) but this also matches + // many existing conv tests that passed through conv3d_impl before + // this path existed. Cross-check against the naive reference. + check_small_channel_conv2d_f32(1, 1, 4, 8, 8, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_threshold_exact() { + // Verify the thresholds are honored: + // - channels_in in [1, 4] AND channels_out in [1, 16] triggers the + // small-channel path + // - channels_in == 5 does not + // - channels_out == 17 does not + // - groups != 1 does not + // + // conv2d options are expanded to 3D as `[0, pad_h, pad_w]` and + // `[1, stride_h, stride_w]` etc. - the d-axis is always trivial. + + // c_in == 4, c_out == 16: exactly at the thresholds, should trigger. + assert!(should_use_small_channel_conv( + &[1, 4, 1, 8, 8], + &[16, 4, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 1), + )); + // c_in == 5: excluded. + assert!(!should_use_small_channel_conv( + &[1, 5, 1, 8, 8], + &[2, 5, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 1), + )); + // c_in == 3, c_out == 17: excluded (large output channel count). + assert!(!should_use_small_channel_conv( + &[1, 3, 1, 8, 8], + &[17, 3, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 1), + )); + // c_in == 3, c_out == 64 (ImageNet first layer): excluded. + assert!(!should_use_small_channel_conv( + &[1, 3, 1, 224, 224], + &[64, 3, 1, 7, 7], + &ConvOptions::new([1, 2, 2], [0, 3, 3], [1, 1, 1], 1), + )); + // Non-groups=1 is excluded. + assert!(!should_use_small_channel_conv( + &[1, 4, 1, 8, 8], + &[4, 1, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 4), + )); + + // Cross-check c_in == 5 against naive reference (this goes through + // the generic conv3d_impl path, not the small-channel path). + check_small_channel_conv2d_f32(1, 5, 4, 8, 8, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + // The tests below exercise `conv_plane_accumulate_oh_outer`, the variant + // selected when the output plane exceeds `CONV_PLANE_OH_OUTER_THRESHOLD` + // (8192 elements). Every test above this point uses shapes where the plane + // is <= 256 elements and stays on the kh-outer variant; without these, + // the oh-outer code path ships uncovered. + // + // 96x96 = 9216 > 8192, so one element past the threshold. 97x97 = 9409 + // leaves a little more slack in case someone nudges the threshold. + + #[test] + fn test_conv2d_depthwise_oh_outer_k3x3() { + // Depthwise path, oh-outer dispatch, 3x3 kernel, stride 1. + // Plane = 96 * 96 = 9216 elements. + check_depthwise_conv2d_f32(1, 2, 96, 96, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_oh_outer_k3x3_stride2() { + // Depthwise, oh-outer, stride 2: exercises the `stride_w != 1` + // inner branch inside the oh-outer variant. Plane = 96*96 = 9216. + check_depthwise_conv2d_f32(1, 2, 192, 192, 3, 3, [2, 2], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_oh_outer_k5x1() { + // Depthwise, oh-outer, 5x1 asymmetric kernel: the exact shape + // pattern that motivated the loop reorder (Sobel-style separable + // filter on a large plane). Plane = 97*97 = 9409. + check_depthwise_conv2d_f32(1, 2, 97, 97, 5, 1, [1, 1], [2, 0], [1, 1], false); + } + + #[test] + fn test_conv2d_depthwise_oh_outer_k1x5() { + // Depthwise, oh-outer, 1x5 asymmetric kernel. Plane = 97*97 = 9409. + check_depthwise_conv2d_f32(1, 2, 97, 97, 1, 5, [1, 1], [0, 2], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_oh_outer_k3x3() { + // Small-channel path, oh-outer dispatch, stride 1. + // Plane = 96 * 96 = 9216. + check_small_channel_conv2d_f32(1, 3, 4, 96, 96, 3, 3, [1, 1], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_oh_outer_k3x3_stride2() { + // Small-channel, oh-outer, stride 2: exercises the stride != 1 + // inner branch through small-channel dispatch. Plane = 96*96. + check_small_channel_conv2d_f32(1, 3, 4, 192, 192, 3, 3, [2, 2], [1, 1], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_oh_outer_k5x1_sobel() { + // Small-channel, oh-outer, 5x1 asymmetric kernel: the shape from + // the user-reported Sobel regression on RGB, on a plane large + // enough to cross the threshold. Plane = 97*97 = 9409. + check_small_channel_conv2d_f32(1, 3, 3, 97, 97, 5, 1, [1, 1], [2, 0], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_oh_outer_k1x5_sobel() { + // Small-channel, oh-outer, 1x5 asymmetric kernel. Plane = 97*97. + check_small_channel_conv2d_f32(1, 3, 3, 97, 97, 1, 5, [1, 1], [0, 2], [1, 1], false); + } + + #[test] + fn test_conv2d_small_channel_oh_outer_with_bias_and_dilation() { + // Small-channel, oh-outer, with bias and dilation > 1. + // Plane = 96*96 = 9216. + check_small_channel_conv2d_f32(1, 3, 8, 100, 100, 3, 3, [1, 1], [2, 2], [2, 2], true); + } + + #[test] + fn test_conv2d_depthwise_predicate_triggers() { + // Directly assert `should_use_depthwise_conv` returns true for + // representative canonical depthwise shapes, and false for shapes + // that look depthwise-ish but are not. Without this, a future change + // that tightens the predicate could silently revert depthwise shapes + // to the generic path while the correctness tests still pass. + // + // conv2d options expand to 3D as `[1, stride_h, stride_w]` etc., with + // the d-axis always trivial. conv1d expands with `kd == kh == 1` and + // `in_d == in_h == 1`. + + // Canonical depthwise 3x3, groups == c_in == c_out == 32. + assert!(should_use_depthwise_conv( + &[4, 32, 1, 56, 56], + &[32, 1, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 32), + )); + // Canonical depthwise 7x7, the ConvNeXt shape Thomas reported. + assert!(should_use_depthwise_conv( + &[4, 48, 1, 56, 56], + &[48, 1, 1, 7, 7], + &ConvOptions::new([1, 1, 1], [0, 3, 3], [1, 1, 1], 48), + )); + // Conv1d depthwise (kh == 1 via the conv1d -> conv3d expansion). + assert!(should_use_depthwise_conv( + &[8, 64, 1, 1, 1024], + &[64, 1, 1, 1, 3], + &ConvOptions::new([1, 1, 1], [0, 0, 1], [1, 1, 1], 64), + )); + // Strided + dilated depthwise. + assert!(should_use_depthwise_conv( + &[1, 16, 1, 32, 32], + &[16, 1, 1, 3, 3], + &ConvOptions::new([1, 2, 2], [0, 1, 1], [1, 2, 2], 16), + )); + + // Not depthwise: groups == 1. + assert!(!should_use_depthwise_conv( + &[1, 8, 1, 16, 16], + &[16, 8, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 1), + )); + // Not depthwise: channels_per_group > 1 (grouped but not depthwise). + assert!(!should_use_depthwise_conv( + &[1, 8, 1, 16, 16], + &[8, 4, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 2), + )); + // Not depthwise: groups == c_in but c_out != c_in (depth multiplier + // > 1; the canonical depthwise path is restricted to multiplier 1). + assert!(!should_use_depthwise_conv( + &[1, 8, 1, 16, 16], + &[16, 1, 1, 3, 3], + &ConvOptions::new([1, 1, 1], [0, 1, 1], [1, 1, 1], 8), + )); + // Not depthwise: pure 3D with kd > 1 (the `d`-axis restriction). + assert!(!should_use_depthwise_conv( + &[1, 8, 4, 16, 16], + &[8, 1, 3, 3, 3], + &ConvOptions::new([1, 1, 1], [1, 1, 1], [1, 1, 1], 8), + )); + } + + #[test] + fn test_valid_out_range_basics() { + // Sanity checks for the analytic range helper. + // 3x3, no pad, stride 1: every k position produces the full range. + let (s, e) = valid_out_range(0, 1, 0, 1, 5, 3); + assert_eq!((s, e), (0, 3)); + let (s, e) = valid_out_range(2, 1, 0, 1, 5, 3); + assert_eq!((s, e), (0, 3)); + // 3x3, pad 1, stride 1: kernel position 0 needs o*1 + 0 >= 1 -> o >= 1. + let (s, e) = valid_out_range(0, 1, 1, 1, 5, 5); + assert_eq!((s, e), (1, 5)); + // kernel position 2 needs o*1 + 2 - 1 < 5 -> o < 4. + let (s, e) = valid_out_range(2, 1, 1, 1, 5, 5); + assert_eq!((s, e), (0, 4)); + // stride 2: o*2 + 0 in [0, in). With in=5, out=3: o in [0, 3). + let (s, e) = valid_out_range(0, 1, 0, 2, 5, 3); + assert_eq!((s, e), (0, 3)); + // dilation 2, pad 2, stride 1: kernel position 0 iw = o*1 + 0 - 2 -> o >= 2. + let (s, e) = valid_out_range(0, 2, 2, 1, 5, 5); + assert_eq!((s, e), (2, 5)); + } +} diff --git a/crates/burn-flex/src/ops/conv_common.rs b/crates/burn-flex/src/ops/conv_common.rs new file mode 100644 index 0000000..d8f7dc1 --- /dev/null +++ b/crates/burn-flex/src/ops/conv_common.rs @@ -0,0 +1,110 @@ +//! Shared macros and helpers for forward and transposed convolution. + +use alloc::vec::Vec; +use burn_backend::DType; +use burn_std::{Bytes, Shape, bf16}; + +use crate::{FlexTensor, Layout}; + +// ============================================================================ +// Macros for dtype wrappers +// ============================================================================ + +/// Generates a convNd function that delegates to a conv3d function via expand/squeeze. +macro_rules! conv_nd_via_3d { + ($fn_name:ident, $conv3d_fn:ident, $expand_fn:ident, $squeeze_fn:ident, $dim:literal, $Options:ident) => { + pub fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &$Options<$dim>, + ) -> FlexTensor { + let (x_3d, weight_3d, options_3d) = $expand_fn(&x, &weight, options); + let result_3d = $conv3d_fn(x_3d, weight_3d, bias, &options_3d); + $squeeze_fn(result_3d) + } + }; +} + +/// Generates a bf16 function that converts to f32, calls the f32 variant, converts back. +macro_rules! bf16_via_f32 { + ($bf16_fn:ident, $f32_fn:ident, $dim:literal, $Options:ident) => { + pub fn $bf16_fn( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &$Options<$dim>, + ) -> FlexTensor { + let x_f32 = $crate::ops::conv_common::convert_bf16_to_f32(&x); + let weight_f32 = $crate::ops::conv_common::convert_bf16_to_f32(&weight); + let bias_f32 = bias.map(|b| $crate::ops::conv_common::convert_bf16_to_f32(&b)); + let result_f32 = $f32_fn(x_f32, weight_f32, bias_f32, options); + $crate::ops::conv_common::convert_f32_to_bf16(&result_f32) + } + }; +} + +// ============================================================================ +// Squeeze helpers (used by both conv and conv_transpose 1d/2d paths) +// ============================================================================ + +pub(crate) fn squeeze_3d_to_1d(tensor: FlexTensor) -> FlexTensor { + let shape = tensor.layout().shape(); + tensor.reshape(Shape::from(alloc::vec![shape[0], shape[1], shape[4]])) +} + +pub(crate) fn squeeze_3d_to_2d(tensor: FlexTensor) -> FlexTensor { + let shape = tensor.layout().shape(); + tensor.reshape(Shape::from(alloc::vec![ + shape[0], shape[1], shape[3], shape[4] + ])) +} + +// ============================================================================ +// bf16 conversion helpers +// ============================================================================ + +pub(crate) fn convert_bf16_to_f32(tensor: &FlexTensor) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let data: &[bf16] = tensor.storage(); + let f32_data: Vec = data.iter().map(|x| x.to_f32()).collect(); + FlexTensor::new( + Bytes::from_elems(f32_data), + Layout::contiguous(tensor.layout().shape().clone()), + DType::F32, + ) +} + +pub(crate) fn convert_f32_to_bf16(tensor: &FlexTensor) -> FlexTensor { + let data: &[f32] = tensor.storage(); + let bf16_data: Vec = data.iter().map(|x| bf16::from_f32(*x)).collect(); + FlexTensor::new( + Bytes::from_elems(bf16_data), + Layout::contiguous(tensor.layout().shape().clone()), + DType::BF16, + ) +} + +// ============================================================================ +// Bias addition +// ============================================================================ + +#[allow(clippy::needless_range_loop)] +pub(crate) fn add_bias( + output: &mut [T], + bias: &[T], + batch: usize, + channels: usize, + spatial: usize, + add_fn: fn(T, T) -> T, +) { + for b in 0..batch { + for c in 0..channels { + let offset = b * channels * spatial + c * spatial; + let bias_val = bias[c]; + for i in 0..spatial { + output[offset + i] = add_fn(output[offset + i], bias_val); + } + } + } +} diff --git a/crates/burn-flex/src/ops/conv_transpose.rs b/crates/burn-flex/src/ops/conv_transpose.rs new file mode 100644 index 0000000..48f2f3d --- /dev/null +++ b/crates/burn-flex/src/ops/conv_transpose.rs @@ -0,0 +1,474 @@ +//! Transposed convolution operations using GEMM + col2im approach. +//! +//! All transposed convolutions (1D, 2D, 3D) use a unified 3D implementation: +//! - conv_transpose1d: adds two size-1 dimensions, calls conv_transpose3d, squeezes output +//! - conv_transpose2d: adds one size-1 dimension, calls conv_transpose3d, squeezes output +//! - conv_transpose3d: native implementation +//! +//! For each (batch, group), computes: columns = W_g^T @ X_g, then scatters +//! the columns matrix into the output via col2im. The GEMM handles the heavy +//! channel-reduction multiply-adds; col2im is a lightweight spatial scatter. +//! +//! Supported dtypes: f32, f64, f16 (native gemm), bf16 (via f32 conversion) + +use alloc::vec; +use burn_backend::DType; +use burn_backend::ops::ConvTransposeOptions; +use burn_backend::ops::conv::calculate_conv_transpose_output_size; +use burn_std::{Bytes, Shape, f16}; + +use crate::{FlexTensor, Layout}; + +use super::conv_common::{add_bias, squeeze_3d_to_1d, squeeze_3d_to_2d}; + +/// Generates a conv_transpose3d typed function. +macro_rules! conv_transpose3d_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $gemm_fn:ident, $add_fn:expr) => { + pub fn $fn_name( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvTransposeOptions<3>, + ) -> FlexTensor { + conv_transpose3d_impl::<$T>(x, weight, bias, options, $dtype, $zero, $gemm_fn, $add_fn) + } + }; +} + +// ============================================================================ +// Conv Transpose 1d - delegates to conv_transpose3d +// ============================================================================ + +conv_nd_via_3d!( + conv_transpose1d_f32, + conv_transpose3d_f32, + expand_transpose_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvTransposeOptions +); +conv_nd_via_3d!( + conv_transpose1d_f64, + conv_transpose3d_f64, + expand_transpose_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvTransposeOptions +); +conv_nd_via_3d!( + conv_transpose1d_f16, + conv_transpose3d_f16, + expand_transpose_1d_to_3d, + squeeze_3d_to_1d, + 1, + ConvTransposeOptions +); +bf16_via_f32!( + conv_transpose1d_bf16, + conv_transpose1d_f32, + 1, + ConvTransposeOptions +); + +fn expand_transpose_1d_to_3d( + x: &FlexTensor, + weight: &FlexTensor, + options: &ConvTransposeOptions<1>, +) -> (FlexTensor, FlexTensor, ConvTransposeOptions<3>) { + // x: [N, C_in, L] -> [N, C_in, 1, 1, L] + let x_shape = x.layout().shape(); + let x_3d = x.reshape(Shape::from(vec![x_shape[0], x_shape[1], 1, 1, x_shape[2]])); + + // weight: [C_in, C_out, K] -> [C_in, C_out, 1, 1, K] + let w_shape = weight.layout().shape(); + let weight_3d = weight.reshape(Shape::from(vec![w_shape[0], w_shape[1], 1, 1, w_shape[2]])); + + let options_3d = ConvTransposeOptions::new( + [1, 1, options.stride[0]], + [0, 0, options.padding[0]], + [0, 0, options.padding_out[0]], + [1, 1, options.dilation[0]], + options.groups, + ); + + (x_3d, weight_3d, options_3d) +} + +// ============================================================================ +// Conv Transpose 2d - delegates to conv_transpose3d +// ============================================================================ + +conv_nd_via_3d!( + conv_transpose2d_f32, + conv_transpose3d_f32, + expand_transpose_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvTransposeOptions +); +conv_nd_via_3d!( + conv_transpose2d_f64, + conv_transpose3d_f64, + expand_transpose_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvTransposeOptions +); +conv_nd_via_3d!( + conv_transpose2d_f16, + conv_transpose3d_f16, + expand_transpose_2d_to_3d, + squeeze_3d_to_2d, + 2, + ConvTransposeOptions +); +bf16_via_f32!( + conv_transpose2d_bf16, + conv_transpose2d_f32, + 2, + ConvTransposeOptions +); + +fn expand_transpose_2d_to_3d( + x: &FlexTensor, + weight: &FlexTensor, + options: &ConvTransposeOptions<2>, +) -> (FlexTensor, FlexTensor, ConvTransposeOptions<3>) { + // x: [N, C_in, H, W] -> [N, C_in, 1, H, W] + let x_shape = x.layout().shape(); + let x_3d = x.reshape(Shape::from(vec![ + x_shape[0], x_shape[1], 1, x_shape[2], x_shape[3], + ])); + + // weight: [C_in, C_out, Kh, Kw] -> [C_in, C_out, 1, Kh, Kw] + let w_shape = weight.layout().shape(); + let weight_3d = weight.reshape(Shape::from(vec![ + w_shape[0], w_shape[1], 1, w_shape[2], w_shape[3], + ])); + + let options_3d = ConvTransposeOptions::new( + [1, options.stride[0], options.stride[1]], + [0, options.padding[0], options.padding[1]], + [0, options.padding_out[0], options.padding_out[1]], + [1, options.dilation[0], options.dilation[1]], + options.groups, + ); + + (x_3d, weight_3d, options_3d) +} + +// ============================================================================ +// Conv Transpose 3d - core implementation +// ============================================================================ + +conv_transpose3d_typed!( + conv_transpose3d_f32, + f32, + DType::F32, + 0.0f32, + conv_transpose_gemm_f32, + |a, b| a + b +); +conv_transpose3d_typed!( + conv_transpose3d_f64, + f64, + DType::F64, + 0.0f64, + conv_transpose_gemm_f64, + |a, b| a + b +); +conv_transpose3d_typed!( + conv_transpose3d_f16, + f16, + DType::F16, + f16::from_f32(0.0), + conv_transpose_gemm_f16, + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()) +); +bf16_via_f32!( + conv_transpose3d_bf16, + conv_transpose3d_f32, + 3, + ConvTransposeOptions +); + +/// GEMM for conv_transpose: writes `c = a^T @ b` where a is [k,m] and b is [k,n]. +type ConvTransposeGemmFn = fn(&mut [T], &[T], &[T], usize, usize, usize); + +/// 3D transposed convolution via GEMM + col2im. +#[allow(clippy::too_many_arguments)] +fn conv_transpose3d_impl( + x: FlexTensor, + weight: FlexTensor, + bias: Option, + options: &ConvTransposeOptions<3>, + dtype: DType, + zero: T, + gemm_fn: ConvTransposeGemmFn, + add_fn: fn(T, T) -> T, +) -> FlexTensor { + let x = x.to_contiguous(); + let weight = weight.to_contiguous(); + + let x_shape = x.layout().shape(); + let w_shape = weight.layout().shape(); + + let batch_size = x_shape[0]; + let in_channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + // Weight shape for transpose: [in_channels, out_channels_per_group, kd, kh, kw] + let out_channels_per_group = w_shape[1]; + let kernel_d = w_shape[2]; + let kernel_h = w_shape[3]; + let kernel_w = w_shape[4]; + + let [stride_d, stride_h, stride_w] = options.stride; + let [pad_d, pad_h, pad_w] = options.padding; + let [pad_out_d, pad_out_h, pad_out_w] = options.padding_out; + let [dilation_d, dilation_h, dilation_w] = options.dilation; + let groups = options.groups; + + let out_channels = out_channels_per_group * groups; + let in_channels_per_group = in_channels / groups; + + let out_d = calculate_conv_transpose_output_size( + kernel_d, stride_d, pad_d, pad_out_d, dilation_d, in_d, + ); + let out_h = calculate_conv_transpose_output_size( + kernel_h, stride_h, pad_h, pad_out_h, dilation_h, in_h, + ); + let out_w = calculate_conv_transpose_output_size( + kernel_w, stride_w, pad_w, pad_out_w, dilation_w, in_w, + ); + + let x_data: &[T] = x.storage(); + let w_data: &[T] = weight.storage(); + + let k_spatial = [kernel_d, kernel_h, kernel_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv_transpose: kernel dimensions would overflow"); + let in_spatial = [in_d, in_h, in_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv_transpose: input spatial dimensions would overflow"); + let out_spatial = out_d * out_h * out_w; + let col_ch = out_channels_per_group + .checked_mul(k_spatial) + .expect("conv_transpose: columns dimensions would overflow"); + let columns_len = col_ch + .checked_mul(in_spatial) + .expect("conv_transpose: columns buffer size would overflow"); + + let output_size = [batch_size, out_channels, out_d, out_h, out_w] + .iter() + .try_fold(1usize, |acc, &x| acc.checked_mul(x)) + .expect("conv_transpose: output dimensions would overflow"); + let mut output = vec![zero; output_size]; + + // Reuse columns buffer across (batch, group) iterations; GEMM overwrites it fully. + let mut columns = vec![zero; columns_len]; + + for b in 0..batch_size { + for g in 0..groups { + let ic_start = g * in_channels_per_group; + let oc_start = g * out_channels_per_group; + + let x_offset = b * in_channels * in_spatial + ic_start * in_spatial; + let w_offset = ic_start * out_channels_per_group * k_spatial; + + let x_group = &x_data[x_offset..x_offset + in_channels_per_group * in_spatial]; + let w_group = &w_data[w_offset..w_offset + in_channels_per_group * col_ch]; + + gemm_fn( + &mut columns, + w_group, + x_group, + col_ch, + in_channels_per_group, + in_spatial, + ); + + // col2im: scatter columns into output for this (batch, group) + let out_base = b * out_channels * out_spatial; + for oc in 0..out_channels_per_group { + let out_ch_base = out_base + (oc_start + oc) * out_spatial; + let oc_col_base = oc * k_spatial; + + for kd in 0..kernel_d { + for kh in 0..kernel_h { + for kw in 0..kernel_w { + let k_idx = kd * kernel_h * kernel_w + kh * kernel_w + kw; + let col_base = (oc_col_base + k_idx) * in_spatial; + + for id in 0..in_d { + let od_raw = id * stride_d + kd * dilation_d; + if od_raw < pad_d { + continue; + } + let od = od_raw - pad_d; + if od >= out_d { + continue; + } + + for ih in 0..in_h { + let oh_raw = ih * stride_h + kh * dilation_h; + if oh_raw < pad_h { + continue; + } + let oh = oh_raw - pad_h; + if oh >= out_h { + continue; + } + + for iw in 0..in_w { + let ow_raw = iw * stride_w + kw * dilation_w; + if ow_raw < pad_w { + continue; + } + let ow = ow_raw - pad_w; + if ow >= out_w { + continue; + } + + let s = id * in_h * in_w + ih * in_w + iw; + let val = columns[col_base + s]; + let out_idx = + out_ch_base + od * out_h * out_w + oh * out_w + ow; + output[out_idx] = add_fn(output[out_idx], val); + } + } + } + } + } + } + } + } + } + + // Add bias if present + if let Some(bias) = bias { + let bias = bias.to_contiguous(); + let bias_data: &[T] = bias.storage(); + add_bias( + &mut output, + bias_data, + batch_size, + out_channels, + out_spatial, + add_fn, + ); + } + + let out_shape = Shape::from(vec![batch_size, out_channels, out_d, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// gemm for conv_transpose: C[m,n] = A[k,m]^T @ B[k,n] +// ============================================================================ + +macro_rules! conv_transpose_gemm_typed { + ($fn_name:ident, $T:ty, $zero:expr, $one:expr) => { + fn $fn_name(c: &mut [$T], a: &[$T], b: &[$T], m: usize, k: usize, n: usize) { + debug_assert_eq!(c.len(), m * n); + debug_assert_eq!(a.len(), k * m); + debug_assert_eq!(b.len(), k * n); + #[cfg(feature = "rayon")] + let parallelism = if m * n * k >= 192 * 192 * 192 { + gemm::Parallelism::Rayon(0) + } else { + gemm::Parallelism::None + }; + #[cfg(not(feature = "rayon"))] + let parallelism = gemm::Parallelism::None; + unsafe { + gemm::gemm( + m, + n, + k, + c.as_mut_ptr(), + 1, // dst_cs + n as isize, // dst_rs + false, + a.as_ptr(), + m as isize, // lhs_cs: A^T column stride = row length of A + 1, // lhs_rs: A^T row stride = 1 + b.as_ptr(), + 1, // rhs_cs + n as isize, // rhs_rs + $zero, + $one, + false, + false, + false, + parallelism, + ); + } + } + }; +} + +conv_transpose_gemm_typed!(conv_transpose_gemm_f32, f32, 0.0f32, 1.0f32); +conv_transpose_gemm_typed!(conv_transpose_gemm_f64, f64, 0.0f64, 1.0f64); +conv_transpose_gemm_typed!( + conv_transpose_gemm_f16, + f16, + f16::from_f32(0.0), + f16::from_f32(1.0) +); + +// ============================================================================ +// Tests +// ============================================================================ + +// Tests kept here exercise flex-specific dtype storage paths (f64/f16) +// through the conv_transpose dispatch. Plain conv_transpose shape/stride/ +// padding/groups/dilation/multichannel/batch correctness is covered at +// the public API level by crates/burn-backend-tests/tests/tensor/float/ +// module/conv_transpose{1,2,3}d.rs, so those tests were removed from here. +// When adding new tests, keep them here only if they probe flex dtype +// dispatch or a flex-internal fast path; otherwise add them there. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_conv_transpose2d_f64() { + let x = FlexTensor::from_data(TensorData::new( + vec![1.0f64, 2.0, 3.0, 4.0], + vec![1, 1, 2, 2], + )); + let w = FlexTensor::from_data(TensorData::new(vec![1.0f64; 4], vec![1, 1, 2, 2])); + let opts = ConvTransposeOptions::new([1, 1], [0, 0], [0, 0], [1, 1], 1); + let result = conv_transpose2d_f64(x, w, None, &opts); + let out: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(out, vec![1.0, 3.0, 2.0, 4.0, 10.0, 6.0, 3.0, 7.0, 4.0]); + } + + #[test] + fn test_conv_transpose2d_f16() { + let x_data: Vec = [1.0f32, 2.0, 3.0, 4.0] + .iter() + .map(|&v| f16::from_f32(v)) + .collect(); + let w_data: Vec = [1.0f32; 4].iter().map(|&v| f16::from_f32(v)).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 2, 2])); + let w = FlexTensor::from_data(TensorData::new(w_data, vec![1, 1, 2, 2])); + let opts = ConvTransposeOptions::new([1, 1], [0, 0], [0, 0], [1, 1], 1); + let result = conv_transpose2d_f16(x, w, None, &opts); + let out: Vec = result.into_data().to_vec().unwrap(); + let expected = [1.0f32, 3.0, 2.0, 4.0, 10.0, 6.0, 3.0, 7.0, 4.0]; + for (a, e) in out.iter().zip(expected.iter()) { + assert!((a.to_f32() - e).abs() < 0.1); + } + } +} diff --git a/crates/burn-flex/src/ops/cumulative.rs b/crates/burn-flex/src/ops/cumulative.rs new file mode 100644 index 0000000..ebd3780 --- /dev/null +++ b/crates/burn-flex/src/ops/cumulative.rs @@ -0,0 +1,264 @@ +//! Cumulative operations along a dimension. + +use alloc::vec; +use burn_backend::Element; +use burn_std::Bytes; +use bytemuck::Pod; +use num_traits::{Bounded, Num}; + +use crate::{FlexTensor, Layout}; + +/// Cumulative sum along a dimension. +/// +/// For each position along `dim`, output contains the sum of all elements +/// from index 0 up to and including that position. +pub fn cumsum( + tensor: FlexTensor, + dim: usize, +) -> FlexTensor { + cumulative_op(tensor, dim, E::zero(), |acc, val| acc + val) +} + +/// Cumulative product along a dimension. +pub fn cumprod( + tensor: FlexTensor, + dim: usize, +) -> FlexTensor { + cumulative_op(tensor, dim, E::one(), |acc, val| acc * val) +} + +/// Generic cumulative operation along a dimension. +/// +/// Uses blocked iteration for cache-friendly access: processes contiguous +/// inner blocks together rather than striding across memory one element at +/// a time. +fn cumulative_op( + tensor: FlexTensor, + dim: usize, + init: E, + op: F, +) -> FlexTensor +where + F: Fn(E, E) -> E, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let ndims = shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + let data: &[E] = tensor.storage(); + let total_size = shape.num_elements(); + let mut result = vec![E::default(); total_size]; + + let dim_size = shape[dim]; + // Contiguous block size after the cumulative dimension + let inner_size: usize = shape[dim + 1..].iter().product(); + // Number of outer blocks (dimensions before the cumulative dimension) + let outer_size: usize = shape[..dim].iter().product(); + let block_size = dim_size * inner_size; + + if inner_size == 1 { + // Scalar accumulator path: accumulator stays in a register. + for outer in 0..outer_size { + let base = outer * dim_size; + let mut acc = init; + for i in 0..dim_size { + acc = op(acc, data[base + i]); + result[base + i] = acc; + } + } + } else { + // Blocked path: process contiguous inner blocks together for + // cache-friendly access when the cumulative dim is not last. + let mut acc = vec![init; inner_size]; + for outer in 0..outer_size { + let base = outer * block_size; + acc.fill(init); + for i in 0..dim_size { + let offset = base + i * inner_size; + for j in 0..inner_size { + acc[j] = op(acc[j], data[offset + j]); + result[offset + j] = acc[j]; + } + } + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), E::dtype()) +} + +/// Cumulative operation for half-precision types, accumulating in f32. +fn cumulative_op_half( + tensor: FlexTensor, + dim: usize, + init: f32, + op: F, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor +where + F: Fn(f32, f32) -> f32, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let ndims = shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + let data: &[E] = tensor.storage(); + let total_size = shape.num_elements(); + let mut result = vec![E::default(); total_size]; + + let dim_size = shape[dim]; + let inner_size: usize = shape[dim + 1..].iter().product(); + let outer_size: usize = shape[..dim].iter().product(); + let block_size = dim_size * inner_size; + + // Accumulator buffer for f32 intermediate values + let mut acc = vec![init; inner_size]; + + for outer in 0..outer_size { + let base = outer * block_size; + + // Reset accumulators + acc.fill(init); + + for i in 0..dim_size { + let offset = base + i * inner_size; + for j in 0..inner_size { + acc[j] = op(acc[j], to_f32(data[offset + j])); + result[offset + j] = from_f32(acc[j]); + } + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), E::dtype()) +} + +// Type-specific wrappers + +pub fn cumsum_f32(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumsum::(tensor, dim) +} + +pub fn cumsum_f64(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumsum::(tensor, dim) +} + +pub fn cumprod_f32(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumprod::(tensor, dim) +} + +pub fn cumprod_f64(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumprod::(tensor, dim) +} + +/// Cumulative min along a dimension. +pub fn cummin( + tensor: FlexTensor, + dim: usize, +) -> FlexTensor { + cumulative_op(tensor, dim, E::max_value(), Ord::min) +} + +pub fn cummin_f32(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumulative_op(tensor, dim, f32::INFINITY, |acc, val| { + if val.is_nan() || val < acc { val } else { acc } + }) +} + +pub fn cummin_f64(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumulative_op(tensor, dim, f64::INFINITY, |acc, val| { + if val.is_nan() || val < acc { val } else { acc } + }) +} + +/// Cumulative max along a dimension. +pub fn cummax( + tensor: FlexTensor, + dim: usize, +) -> FlexTensor { + cumulative_op(tensor, dim, E::min_value(), Ord::max) +} + +pub fn cummax_f32(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumulative_op(tensor, dim, f32::NEG_INFINITY, |acc, val| { + if val.is_nan() || val > acc { val } else { acc } + }) +} + +pub fn cummax_f64(tensor: FlexTensor, dim: usize) -> FlexTensor { + cumulative_op(tensor, dim, f64::NEG_INFINITY, |acc, val| { + if val.is_nan() || val > acc { val } else { acc } + }) +} + +pub fn cumsum_half( + tensor: FlexTensor, + dim: usize, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor { + cumulative_op_half(tensor, dim, 0.0, |acc, val| acc + val, to_f32, from_f32) +} + +pub fn cumprod_half( + tensor: FlexTensor, + dim: usize, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor { + cumulative_op_half(tensor, dim, 1.0, |acc, val| acc * val, to_f32, from_f32) +} + +pub fn cummin_half( + tensor: FlexTensor, + dim: usize, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor { + cumulative_op_half( + tensor, + dim, + f32::INFINITY, + |acc, val| if val.is_nan() || val < acc { val } else { acc }, + to_f32, + from_f32, + ) +} + +pub fn cummax_half( + tensor: FlexTensor, + dim: usize, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor { + cumulative_op_half( + tensor, + dim, + f32::NEG_INFINITY, + |acc, val| if val.is_nan() || val > acc { val } else { acc }, + to_f32, + from_f32, + ) +} + +// Cumsum / cumprod / cummin / cummax coverage (basic, stride variants, +// NaN propagation, int dtype) lives in +// crates/burn-backend-tests/tests/tensor/{float,int}/ops/cumulative.rs so +// every backend is exercised. When adding new tests, keep them here only +// if they probe flex-specific kernel internals; otherwise add them +// there. diff --git a/crates/burn-flex/src/ops/deform_conv.rs b/crates/burn-flex/src/ops/deform_conv.rs new file mode 100644 index 0000000..31e07de --- /dev/null +++ b/crates/burn-flex/src/ops/deform_conv.rs @@ -0,0 +1,1021 @@ +//! Deformable convolution implementation using im2col + GEMM. +//! +//! Deformable convolution applies learned offsets to the sampling grid, +//! allowing the network to adaptively adjust its receptive field. +//! +//! Optimization approach: +//! - Build deformable im2col matrix with bilinear-interpolated samples +//! - Use optimized GEMM for the actual convolution +//! - Rayon parallelism over batch dimension + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::DType; +use burn_std::{Bytes, Shape}; + +use crate::{FlexTensor, Layout}; + +/// Build deformable im2col matrix for one batch sample and weight group. +/// +/// Fills `col` with shape [col_len, spatial_out] where each column holds +/// bilinear-interpolated and optionally masked samples for one output position. +#[allow(clippy::too_many_arguments)] +fn deform_im2col_f32( + col: &mut [f32], + x_data: &[f32], + offset_data: &[f32], + mask_data: Option<&[f32]>, + b: usize, + ic_start: usize, + channels_per_weight_group: usize, + channels_per_offset_group: usize, + channels_in: usize, + offset_groups: usize, + offset_channels: usize, + kernel_h: usize, + kernel_w: usize, + out_h: usize, + out_w: usize, + in_h: usize, + in_w: usize, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + spatial_out: usize, +) { + for oh in 0..out_h { + for ow in 0..out_w { + let spatial_idx = oh * out_w + ow; + + for kh in 0..kernel_h { + for kw in 0..kernel_w { + let base_h = (oh * stride[0] + kh * dilation[0]) as f32 - padding[0] as f32; + let base_w = (ow * stride[1] + kw * dilation[1]) as f32 - padding[1] as f32; + + for ic in 0..channels_per_weight_group { + let global_ic = ic_start + ic; + let offset_group = global_ic / channels_per_offset_group; + + let kernel_idx = kh * kernel_w + kw; + let offset_idx_h = offset_group * kernel_h * kernel_w * 2 + kernel_idx * 2; + let offset_idx_w = offset_idx_h + 1; + + let offset_h_flat = b * offset_channels * spatial_out + + offset_idx_h * spatial_out + + spatial_idx; + let offset_w_flat = b * offset_channels * spatial_out + + offset_idx_w * spatial_out + + spatial_idx; + + let offset_h = offset_data[offset_h_flat]; + let offset_w = offset_data[offset_w_flat]; + + let sample_h = base_h + offset_h; + let sample_w = base_w + offset_w; + + let mut val = bilinear_interpolate( + x_data, + b, + global_ic, + in_h, + in_w, + channels_in, + sample_h, + sample_w, + ); + + if let Some(md) = mask_data { + let mask_idx_base = offset_group * kernel_h * kernel_w + kernel_idx; + let mask_idx = b * (offset_groups * kernel_h * kernel_w) * spatial_out + + mask_idx_base * spatial_out + + spatial_idx; + val *= md[mask_idx]; + } + + let col_row = kh * kernel_w * channels_per_weight_group + + kw * channels_per_weight_group + + ic; + col[col_row * spatial_out + spatial_idx] = val; + } + } + } + } + } +} + +/// Deformable 2D convolution using im2col + GEMM. +/// +/// # Arguments +/// * `x` - Input tensor [batch, channels_in, height, width] +/// * `offset` - Offset tensor \[batch, offset_groups * kernel_h * kernel_w * 2, out_h, out_w\] +/// * `weight` - Weight tensor \[channels_out, channels_in/weight_groups, kernel_h, kernel_w\] +/// * `mask` - Optional mask tensor \[batch, offset_groups * kernel_h * kernel_w, out_h, out_w\] +/// * `bias` - Optional bias tensor \[channels_out\] +/// * `stride` - Stride \[stride_h, stride_w\] +/// * `padding` - Padding \[pad_h, pad_w\] +/// * `dilation` - Dilation \[dil_h, dil_w\] +/// * `weight_groups` - Number of weight groups +/// * `offset_groups` - Number of offset groups +#[allow(clippy::too_many_arguments)] +pub fn deform_conv2d_f32( + x: FlexTensor, + offset: FlexTensor, + weight: FlexTensor, + mask: Option, + bias: Option, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + weight_groups: usize, + offset_groups: usize, +) -> FlexTensor { + let x = x.to_contiguous(); + let offset = offset.to_contiguous(); + let weight = weight.to_contiguous(); + let mask = mask.map(|m| m.to_contiguous()); + let bias = bias.map(|b| b.to_contiguous()); + + let x_shape = x.layout().shape(); + let weight_shape = weight.layout().shape(); + let offset_shape = offset.layout().shape(); + + let batch = x_shape[0]; + let channels_in = x_shape[1]; + let in_h = x_shape[2]; + let in_w = x_shape[3]; + + let channels_out = weight_shape[0]; + let channels_per_weight_group = weight_shape[1]; // channels_in / weight_groups + let kernel_h = weight_shape[2]; + let kernel_w = weight_shape[3]; + + let out_h = offset_shape[2]; + let out_w = offset_shape[3]; + + let x_data: &[f32] = x.storage(); + let offset_data: &[f32] = offset.storage(); + let weight_data: &[f32] = weight.storage(); + let mask_data: Option<&[f32]> = mask.as_ref().map(|m| m.storage()); + let bias_data: Option<&[f32]> = bias.as_ref().map(|b| b.storage()); + + let channels_per_offset_group = channels_in / offset_groups; + let out_channels_per_weight_group = channels_out / weight_groups; + let spatial_out = out_h * out_w; + let col_len = channels_per_weight_group * kernel_h * kernel_w; + let offset_channels = offset_shape[1]; + + // Flatten weights to [channels_out, col_len] for GEMM + // Layout: [oc, ic, kh, kw] -> [oc, kh * kw * ic] + let mut w_flat = vec![0.0f32; channels_out * col_len]; + for oc in 0..channels_out { + for kh in 0..kernel_h { + for kw in 0..kernel_w { + for ic in 0..channels_per_weight_group { + let w_idx = oc * channels_per_weight_group * kernel_h * kernel_w + + ic * kernel_h * kernel_w + + kh * kernel_w + + kw; + let flat_idx = oc * col_len + + kh * kernel_w * channels_per_weight_group + + kw * channels_per_weight_group + + ic; + w_flat[flat_idx] = weight_data[w_idx]; + } + } + } + } + + #[cfg(feature = "rayon")] + let output = { + use rayon::prelude::*; + + let results: Vec> = (0..batch) + .into_par_iter() + .map(|b| { + let mut batch_output = vec![0.0f32; channels_out * spatial_out]; + + // Process each weight group + for g in 0..weight_groups { + let ic_start = g * channels_per_weight_group; + let oc_start = g * out_channels_per_weight_group; + + // Build deformable im2col for this batch and group + let mut col = vec![0.0f32; col_len * spatial_out]; + + deform_im2col_f32( + &mut col, + x_data, + offset_data, + mask_data, + b, + ic_start, + channels_per_weight_group, + channels_per_offset_group, + channels_in, + offset_groups, + offset_channels, + kernel_h, + kernel_w, + out_h, + out_w, + in_h, + in_w, + stride, + padding, + dilation, + spatial_out, + ); + + // GEMM: w_group[out_c_per_wg, col_len] @ col[col_len, spatial_out] + // Result: [out_c_per_wg, spatial_out] + let w_start = oc_start * col_len; + let w_end = w_start + out_channels_per_weight_group * col_len; + let w_group = &w_flat[w_start..w_end]; + + let result = gemm_f32( + w_group, + &col, + out_channels_per_weight_group, + col_len, + spatial_out, + ); + + // Copy to output + for oc_local in 0..out_channels_per_weight_group { + let oc = oc_start + oc_local; + for s in 0..spatial_out { + batch_output[oc * spatial_out + s] = result[oc_local * spatial_out + s]; + } + } + } + + // Add bias + if let Some(bd) = bias_data { + for oc in 0..channels_out { + for s in 0..spatial_out { + batch_output[oc * spatial_out + s] += bd[oc]; + } + } + } + + batch_output + }) + .collect(); + + // Flatten results + let mut output = vec![0.0f32; batch * channels_out * spatial_out]; + for (b, batch_out) in results.into_iter().enumerate() { + let start = b * channels_out * spatial_out; + output[start..start + channels_out * spatial_out].copy_from_slice(&batch_out); + } + output + }; + + #[cfg(not(feature = "rayon"))] + let output = { + let mut output = vec![0.0f32; batch * channels_out * spatial_out]; + + for b in 0..batch { + // Process each weight group + for g in 0..weight_groups { + let ic_start = g * channels_per_weight_group; + let oc_start = g * out_channels_per_weight_group; + + // Build deformable im2col for this batch and group + let mut col = vec![0.0f32; col_len * spatial_out]; + + deform_im2col_f32( + &mut col, + x_data, + offset_data, + mask_data, + b, + ic_start, + channels_per_weight_group, + channels_per_offset_group, + channels_in, + offset_groups, + offset_channels, + kernel_h, + kernel_w, + out_h, + out_w, + in_h, + in_w, + stride, + padding, + dilation, + spatial_out, + ); + + // GEMM + let w_start = oc_start * col_len; + let w_end = w_start + out_channels_per_weight_group * col_len; + let w_group = &w_flat[w_start..w_end]; + + let result = gemm_f32( + w_group, + &col, + out_channels_per_weight_group, + col_len, + spatial_out, + ); + + // Copy to output + for oc_local in 0..out_channels_per_weight_group { + let oc = oc_start + oc_local; + for s in 0..spatial_out { + let out_idx = b * channels_out * spatial_out + oc * spatial_out + s; + output[out_idx] = result[oc_local * spatial_out + s]; + } + } + } + + // Add bias + if let Some(bd) = bias_data { + #[allow(clippy::needless_range_loop)] + for oc in 0..channels_out { + for s in 0..spatial_out { + let idx = b * channels_out * spatial_out + oc * spatial_out + s; + output[idx] += bd[oc]; + } + } + } + } + + output + }; + + let out_shape = Shape::from(vec![batch, channels_out, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::F32, + ) +} + +/// GEMM: C = A @ B where A is [m, k], B is [k, n], result C is [m, n] +#[inline] +fn gemm_f32(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec { + let mut c = vec![0.0f32; m * n]; + unsafe { + gemm::gemm( + m, + n, + k, + c.as_mut_ptr(), + 1, // dst_cs: column stride = 1 (row-major) + n as isize, // dst_rs: row stride = n (row-major) + false, + a.as_ptr(), + 1, // lhs_cs: column stride = 1 (row-major) + k as isize, // lhs_rs: row stride = k (row-major) + b.as_ptr(), + 1, // rhs_cs: column stride = 1 (row-major) + n as isize, // rhs_rs: row stride = n (row-major) + 0.0, // alpha: dst = alpha*dst + beta*lhs*rhs + 1.0, // beta + false, + false, + false, + gemm::Parallelism::None, + ); + } + c +} + +/// Bilinear interpolation for sampling at fractional coordinates. +#[inline] +#[allow(clippy::too_many_arguments)] +fn bilinear_interpolate( + data: &[f32], + batch: usize, + channel: usize, + height: usize, + width: usize, + channels: usize, + h: f32, + w: f32, +) -> f32 { + // Out of bounds check + if h <= -1.0 || h >= height as f32 || w <= -1.0 || w >= width as f32 { + return 0.0; + } + + let h_low = h.floor(); + let w_low = w.floor(); + let h_high = (h_low + 1.0) as usize; + let w_high = (w_low + 1.0) as usize; + + let base = batch * channels * height * width + channel * height * width; + + let v1 = if h_low >= 0.0 && w_low >= 0.0 { + data[base + (h_low as usize) * width + (w_low as usize)] + } else { + 0.0 + }; + let v2 = if h_low >= 0.0 && w_high < width { + data[base + (h_low as usize) * width + w_high] + } else { + 0.0 + }; + let v3 = if h_high < height && w_low >= 0.0 { + data[base + h_high * width + (w_low as usize)] + } else { + 0.0 + }; + let v4 = if h_high < height && w_high < width { + data[base + h_high * width + w_high] + } else { + 0.0 + }; + + let lh = h - h_low; + let lw = w - w_low; + let hh = 1.0 - lh; + let hw = 1.0 - lw; + + hh * hw * v1 + hh * lw * v2 + lh * hw * v3 + lh * lw * v4 +} + +/// Backward pass for deformable 2D convolution (f32). +/// +/// Returns (x_grad, offset_grad, weight_grad, mask_grad, bias_grad). +#[allow(clippy::too_many_arguments)] +pub fn deform_conv2d_backward_f32( + x: FlexTensor, + offset: FlexTensor, + weight: FlexTensor, + mask: Option, + bias: Option, + output_grad: FlexTensor, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + weight_groups: usize, + offset_groups: usize, +) -> ( + FlexTensor, + FlexTensor, + FlexTensor, + Option, + Option, +) { + let x = x.to_contiguous(); + let offset = offset.to_contiguous(); + let weight = weight.to_contiguous(); + let mask = mask.map(|m| m.to_contiguous()); + let output_grad = output_grad.to_contiguous(); + + let x_shape = x.layout().shape(); + let weight_shape = weight.layout().shape(); + let offset_shape = offset.layout().shape(); + let out_grad_shape = output_grad.layout().shape(); + + let batch = x_shape[0]; + let channels_in = x_shape[1]; + let in_h = x_shape[2]; + let in_w = x_shape[3]; + + let channels_out = weight_shape[0]; + let kernel_h = weight_shape[2]; + let kernel_w = weight_shape[3]; + + let out_h = out_grad_shape[2]; + let out_w = out_grad_shape[3]; + + let x_data: &[f32] = x.storage(); + let offset_data: &[f32] = offset.storage(); + let weight_data: &[f32] = weight.storage(); + let mask_data: Option<&[f32]> = mask.as_ref().map(|m| m.storage()); + let out_grad_data: &[f32] = output_grad.storage(); + + let channels_per_offset_group = channels_in / offset_groups; + let channels_per_weight_group = channels_in / weight_groups; + let out_channels_per_weight_group = channels_out / weight_groups; + + // Initialize gradients + let mut x_grad = vec![0.0f32; batch * channels_in * in_h * in_w]; + let mut offset_grad = vec![0.0f32; batch * offset_shape[1] * out_h * out_w]; + let mut weight_grad = vec![0.0f32; weight_shape.num_elements()]; + let mut mask_grad = mask + .as_ref() + .map(|m| vec![0.0f32; m.layout().shape().num_elements()]); + let mut bias_grad = bias.as_ref().map(|_| vec![0.0f32; channels_out]); + + // Compute bias gradient (sum over batch and spatial dimensions) + if let Some(ref mut bg) = bias_grad { + for b in 0..batch { + for (oc, bg_oc) in bg.iter_mut().enumerate() { + for oh in 0..out_h { + for ow in 0..out_w { + let idx = + b * channels_out * out_h * out_w + oc * out_h * out_w + oh * out_w + ow; + *bg_oc += out_grad_data[idx]; + } + } + } + } + } + + // Main backward loop + for b in 0..batch { + for oc in 0..channels_out { + let weight_group = oc / out_channels_per_weight_group; + + for oh in 0..out_h { + for ow in 0..out_w { + let out_idx = + b * channels_out * out_h * out_w + oc * out_h * out_w + oh * out_w + ow; + let grad_out = out_grad_data[out_idx]; + + let ic_start = weight_group * channels_per_weight_group; + let ic_end = ic_start + channels_per_weight_group; + + for ic in ic_start..ic_end { + let offset_group = ic / channels_per_offset_group; + + for kh in 0..kernel_h { + for kw in 0..kernel_w { + let base_h = (oh * stride[0]) as f32 + (kh * dilation[0]) as f32 + - padding[0] as f32; + let base_w = (ow * stride[1]) as f32 + (kw * dilation[1]) as f32 + - padding[1] as f32; + + let kernel_idx = kh * kernel_w + kw; + let offset_idx_h = + offset_group * kernel_h * kernel_w * 2 + kernel_idx * 2; + let offset_idx_w = offset_idx_h + 1; + + let offset_h_flat = b * offset_shape[1] * out_h * out_w + + offset_idx_h * out_h * out_w + + oh * out_w + + ow; + let offset_w_flat = b * offset_shape[1] * out_h * out_w + + offset_idx_w * out_h * out_w + + oh * out_w + + ow; + + let off_h = offset_data[offset_h_flat]; + let off_w = offset_data[offset_w_flat]; + + let sample_h = base_h + off_h; + let sample_w = base_w + off_w; + + // Get mask value + let (mask_val, mask_flat_idx) = if let Some(md) = mask_data { + let mask_idx_base = + offset_group * kernel_h * kernel_w + kernel_idx; + let mask_idx = + b * (offset_groups * kernel_h * kernel_w) * out_h * out_w + + mask_idx_base * out_h * out_w + + oh * out_w + + ow; + (md[mask_idx], Some(mask_idx)) + } else { + (1.0, None) + }; + + // Weight index + let weight_ic = ic - ic_start; + let weight_idx = oc + * (channels_per_weight_group * kernel_h * kernel_w) + + weight_ic * kernel_h * kernel_w + + kh * kernel_w + + kw; + + let w = weight_data[weight_idx]; + + // Get interpolated value for weight gradient + let interp_val = bilinear_interpolate( + x_data, + b, + ic, + in_h, + in_w, + channels_in, + sample_h, + sample_w, + ); + + // Weight gradient + weight_grad[weight_idx] += grad_out * mask_val * interp_val; + + // Mask gradient + if let (Some(mg), Some(midx)) = (&mut mask_grad, mask_flat_idx) { + mg[midx] += grad_out * w * interp_val; + } + + // Input and offset gradients via bilinear interpolation backward + let grad_val = grad_out * mask_val * w; + bilinear_interpolate_backward_f32( + &mut x_grad, + &mut offset_grad, + x_data, + b, + ic, + in_h, + in_w, + channels_in, + sample_h, + sample_w, + grad_val, + offset_h_flat, + offset_w_flat, + ); + } + } + } + } + } + } + } + + // Build output tensors + let x_grad_tensor = FlexTensor::new( + Bytes::from_elems(x_grad), + Layout::contiguous(x_shape.clone()), + DType::F32, + ); + let offset_grad_tensor = FlexTensor::new( + Bytes::from_elems(offset_grad), + Layout::contiguous(offset_shape.clone()), + DType::F32, + ); + let weight_grad_tensor = FlexTensor::new( + Bytes::from_elems(weight_grad), + Layout::contiguous(weight_shape.clone()), + DType::F32, + ); + let mask_grad_tensor = mask_grad.map(|mg| { + FlexTensor::new( + Bytes::from_elems(mg), + Layout::contiguous(mask.as_ref().unwrap().layout().shape().clone()), + DType::F32, + ) + }); + let bias_grad_tensor = bias_grad.map(|bg| { + FlexTensor::new( + Bytes::from_elems(bg), + Layout::contiguous(Shape::from(vec![channels_out])), + DType::F32, + ) + }); + + ( + x_grad_tensor, + offset_grad_tensor, + weight_grad_tensor, + mask_grad_tensor, + bias_grad_tensor, + ) +} + +/// Backward pass for bilinear interpolation - computes gradients for input and offsets. +#[allow(clippy::too_many_arguments)] +#[inline] +fn bilinear_interpolate_backward_f32( + x_grad: &mut [f32], + offset_grad: &mut [f32], + x_data: &[f32], + batch: usize, + channel: usize, + height: usize, + width: usize, + channels: usize, + h: f32, + w: f32, + grad_val: f32, + offset_h_flat: usize, + offset_w_flat: usize, +) { + // Out of bounds check + if h <= -1.0 || h >= height as f32 || w <= -1.0 || w >= width as f32 { + return; + } + + let h_low = h.floor(); + let w_low = w.floor(); + let h_high = h_low + 1.0; + let w_high = w_low + 1.0; + + let lh = h - h_low; + let lw = w - w_low; + let hh = 1.0 - lh; + let hw = 1.0 - lw; + + let base = batch * channels * height * width + channel * height * width; + + // Get input values for offset gradient computation + let v1 = if h_low >= 0.0 && w_low >= 0.0 { + x_data[base + (h_low as usize) * width + (w_low as usize)] + } else { + 0.0 + }; + let v2 = if h_low >= 0.0 && (w_high as usize) < width { + x_data[base + (h_low as usize) * width + (w_high as usize)] + } else { + 0.0 + }; + let v3 = if (h_high as usize) < height && w_low >= 0.0 { + x_data[base + (h_high as usize) * width + (w_low as usize)] + } else { + 0.0 + }; + let v4 = if (h_high as usize) < height && (w_high as usize) < width { + x_data[base + (h_high as usize) * width + (w_high as usize)] + } else { + 0.0 + }; + + // Input gradient (distribute grad_val to the 4 corners) + if h_low >= 0.0 && w_low >= 0.0 { + let idx = base + (h_low as usize) * width + (w_low as usize); + x_grad[idx] += hh * hw * grad_val; + } + if h_low >= 0.0 && (w_high as usize) < width { + let idx = base + (h_low as usize) * width + (w_high as usize); + x_grad[idx] += hh * lw * grad_val; + } + if (h_high as usize) < height && w_low >= 0.0 { + let idx = base + (h_high as usize) * width + (w_low as usize); + x_grad[idx] += lh * hw * grad_val; + } + if (h_high as usize) < height && (w_high as usize) < width { + let idx = base + (h_high as usize) * width + (w_high as usize); + x_grad[idx] += lh * lw * grad_val; + } + + // Offset gradient (derivative of bilinear interpolation w.r.t. coordinates) + let grad_h = hw * (v3 - v1) + lw * (v4 - v2); + let grad_w = hh * (v2 - v1) + lh * (v4 - v3); + + offset_grad[offset_h_flat] += grad_val * grad_h; + offset_grad[offset_w_flat] += grad_val * grad_w; +} + +/// f64 version of deform_conv2d using im2col + GEMM. +#[allow(clippy::too_many_arguments)] +pub fn deform_conv2d_f64( + x: FlexTensor, + offset: FlexTensor, + weight: FlexTensor, + mask: Option, + bias: Option, + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + weight_groups: usize, + offset_groups: usize, +) -> FlexTensor { + let x = x.to_contiguous(); + let offset = offset.to_contiguous(); + let weight = weight.to_contiguous(); + let mask = mask.map(|m| m.to_contiguous()); + let bias = bias.map(|b| b.to_contiguous()); + + let x_shape = x.layout().shape(); + let weight_shape = weight.layout().shape(); + let offset_shape = offset.layout().shape(); + + let batch = x_shape[0]; + let channels_in = x_shape[1]; + let in_h = x_shape[2]; + let in_w = x_shape[3]; + + let channels_out = weight_shape[0]; + let channels_per_weight_group = weight_shape[1]; + let kernel_h = weight_shape[2]; + let kernel_w = weight_shape[3]; + + let out_h = offset_shape[2]; + let out_w = offset_shape[3]; + + let x_data: &[f64] = x.storage(); + let offset_data: &[f64] = offset.storage(); + let weight_data: &[f64] = weight.storage(); + let mask_data: Option<&[f64]> = mask.as_ref().map(|m| m.storage()); + let bias_data: Option<&[f64]> = bias.as_ref().map(|b| b.storage()); + + let channels_per_offset_group = channels_in / offset_groups; + let out_channels_per_weight_group = channels_out / weight_groups; + let spatial_out = out_h * out_w; + let col_len = channels_per_weight_group * kernel_h * kernel_w; + + // Flatten weights + let mut w_flat = vec![0.0f64; channels_out * col_len]; + for oc in 0..channels_out { + for kh in 0..kernel_h { + for kw in 0..kernel_w { + for ic in 0..channels_per_weight_group { + let w_idx = oc * channels_per_weight_group * kernel_h * kernel_w + + ic * kernel_h * kernel_w + + kh * kernel_w + + kw; + let flat_idx = oc * col_len + + kh * kernel_w * channels_per_weight_group + + kw * channels_per_weight_group + + ic; + w_flat[flat_idx] = weight_data[w_idx]; + } + } + } + } + + let mut output = vec![0.0f64; batch * channels_out * spatial_out]; + + for b in 0..batch { + for g in 0..weight_groups { + let ic_start = g * channels_per_weight_group; + let oc_start = g * out_channels_per_weight_group; + + let mut col = vec![0.0f64; col_len * spatial_out]; + + for oh in 0..out_h { + for ow in 0..out_w { + let spatial_idx = oh * out_w + ow; + + for kh in 0..kernel_h { + for kw in 0..kernel_w { + let base_h = + (oh * stride[0] + kh * dilation[0]) as f64 - padding[0] as f64; + let base_w = + (ow * stride[1] + kw * dilation[1]) as f64 - padding[1] as f64; + + for ic in 0..channels_per_weight_group { + let global_ic = ic_start + ic; + let offset_group = global_ic / channels_per_offset_group; + + let kernel_idx = kh * kernel_w + kw; + let offset_idx_h = + offset_group * kernel_h * kernel_w * 2 + kernel_idx * 2; + let offset_idx_w = offset_idx_h + 1; + + let offset_h_flat = b * offset_shape[1] * spatial_out + + offset_idx_h * spatial_out + + spatial_idx; + let offset_w_flat = b * offset_shape[1] * spatial_out + + offset_idx_w * spatial_out + + spatial_idx; + + let offset_h = offset_data[offset_h_flat]; + let offset_w = offset_data[offset_w_flat]; + + let sample_h = base_h + offset_h; + let sample_w = base_w + offset_w; + + let mut val = bilinear_interpolate_f64( + x_data, + b, + global_ic, + in_h, + in_w, + channels_in, + sample_h, + sample_w, + ); + + if let Some(md) = mask_data { + let mask_idx_base = + offset_group * kernel_h * kernel_w + kernel_idx; + let mask_idx = + b * (offset_groups * kernel_h * kernel_w) * spatial_out + + mask_idx_base * spatial_out + + spatial_idx; + val *= md[mask_idx]; + } + + let col_row = kh * kernel_w * channels_per_weight_group + + kw * channels_per_weight_group + + ic; + col[col_row * spatial_out + spatial_idx] = val; + } + } + } + } + } + + // GEMM + let w_start = oc_start * col_len; + let w_end = w_start + out_channels_per_weight_group * col_len; + let w_group = &w_flat[w_start..w_end]; + + let result = gemm_f64( + w_group, + &col, + out_channels_per_weight_group, + col_len, + spatial_out, + ); + + for oc_local in 0..out_channels_per_weight_group { + let oc = oc_start + oc_local; + for s in 0..spatial_out { + let out_idx = b * channels_out * spatial_out + oc * spatial_out + s; + output[out_idx] = result[oc_local * spatial_out + s]; + } + } + } + + if let Some(bd) = bias_data { + for (oc, &bias_val) in bd.iter().enumerate() { + for s in 0..spatial_out { + let idx = b * channels_out * spatial_out + oc * spatial_out + s; + output[idx] += bias_val; + } + } + } + } + + let out_shape = Shape::from(vec![batch, channels_out, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::F64, + ) +} + +#[inline] +fn gemm_f64(a: &[f64], b: &[f64], m: usize, k: usize, n: usize) -> Vec { + let mut c = vec![0.0f64; m * n]; + unsafe { + gemm::gemm( + m, + n, + k, + c.as_mut_ptr(), + 1, + n as isize, + false, + a.as_ptr(), + 1, + k as isize, + b.as_ptr(), + 1, + n as isize, + 0.0, + 1.0, + false, + false, + false, + gemm::Parallelism::None, + ); + } + c +} + +#[allow(clippy::too_many_arguments)] +#[inline] +fn bilinear_interpolate_f64( + data: &[f64], + batch: usize, + channel: usize, + height: usize, + width: usize, + channels: usize, + h: f64, + w: f64, +) -> f64 { + if h <= -1.0 || h >= height as f64 || w <= -1.0 || w >= width as f64 { + return 0.0; + } + + let h_low = h.floor(); + let w_low = w.floor(); + let h_high = (h_low + 1.0) as usize; + let w_high = (w_low + 1.0) as usize; + + let base = batch * channels * height * width + channel * height * width; + + let v1 = if h_low >= 0.0 && w_low >= 0.0 { + data[base + (h_low as usize) * width + (w_low as usize)] + } else { + 0.0 + }; + let v2 = if h_low >= 0.0 && w_high < width { + data[base + (h_low as usize) * width + w_high] + } else { + 0.0 + }; + let v3 = if h_high < height && w_low >= 0.0 { + data[base + h_high * width + (w_low as usize)] + } else { + 0.0 + }; + let v4 = if h_high < height && w_high < width { + data[base + h_high * width + w_high] + } else { + 0.0 + }; + + let lh = h - h_low; + let lw = w - w_low; + let hh = 1.0 - lh; + let hw = 1.0 - lw; + + hh * hw * v1 + hh * lw * v2 + lh * hw * v3 + lh * lw * v4 +} diff --git a/crates/burn-flex/src/ops/expand.rs b/crates/burn-flex/src/ops/expand.rs new file mode 100644 index 0000000..5caa3d8 --- /dev/null +++ b/crates/burn-flex/src/ops/expand.rs @@ -0,0 +1,312 @@ +//! Expand operation for broadcasting tensors to larger shapes. + +use alloc::vec; +use alloc::vec::Vec; +use burn_std::Shape; + +use crate::{FlexTensor, Layout}; + +/// Compute the broadcast shape of two tensors. +/// +/// Returns the shape that both tensors can be expanded to for element-wise operations. +pub fn broadcast_shape(lhs: &Shape, rhs: &Shape) -> Shape { + let max_dims = lhs.num_dims().max(rhs.num_dims()); + let mut result = vec![0; max_dims]; + + for (i, out) in result.iter_mut().enumerate() { + let lhs_idx = i as isize + lhs.num_dims() as isize - max_dims as isize; + let rhs_idx = i as isize + rhs.num_dims() as isize - max_dims as isize; + + let lhs_dim = if lhs_idx >= 0 { + lhs[lhs_idx as usize] + } else { + 1 + }; + let rhs_dim = if rhs_idx >= 0 { + rhs[rhs_idx as usize] + } else { + 1 + }; + + if lhs_dim == rhs_dim { + *out = lhs_dim; + } else if lhs_dim == 1 { + *out = rhs_dim; + } else if rhs_dim == 1 { + *out = lhs_dim; + } else { + panic!( + "broadcast_shape: incompatible dimensions {} and {} at position {}", + lhs_dim, rhs_dim, i + ); + } + } + + Shape::from(result) +} + +/// Broadcast two tensors to the same shape for binary operations. +pub fn broadcast_binary(lhs: FlexTensor, rhs: FlexTensor) -> (FlexTensor, FlexTensor) { + let lhs_shape = lhs.layout().shape().clone(); + let rhs_shape = rhs.layout().shape().clone(); + + if lhs_shape == rhs_shape { + return (lhs, rhs); + } + + let target = broadcast_shape(&lhs_shape, &rhs_shape); + + let lhs_expanded = if lhs_shape == target { + lhs + } else { + expand(lhs, target.clone()) + }; + let rhs_expanded = if rhs_shape == target { + rhs + } else { + expand(rhs, target) + }; + + (lhs_expanded, rhs_expanded) +} + +/// Expand a tensor to a larger shape by broadcasting. +/// +/// Dimensions of size 1 can be expanded to any size. The result is a view +/// that doesn't copy data - it uses stride 0 for expanded dimensions. +pub fn expand(tensor: FlexTensor, target_shape: Shape) -> FlexTensor { + // Capture values we need before consuming tensor + let src_dims = tensor.layout().shape().to_vec(); + let src_strides = tensor.layout().strides().to_vec(); + let start_offset = tensor.layout().start_offset(); + let dtype = tensor.dtype(); + + let src_ndims = src_dims.len(); + let target_ndims = target_shape.num_dims(); + + // Broadcasting only prepends new dims; it never drops existing ones. + // A target with fewer dims than the source would silently discard the + // trailing source strides and produce an invalid layout. + assert!( + target_ndims >= src_ndims, + "expand: target rank ({}) must be >= source rank ({}); \ + broadcasting cannot drop dimensions", + target_ndims, + src_ndims + ); + + // Prepend 1s to source shape if needed (for broadcasting like [3] -> [2, 3]) + let dim_diff = target_ndims - src_ndims; + + let mut new_strides = Vec::with_capacity(target_ndims); + + for i in 0..target_ndims { + let target_dim = target_shape[i]; + + if i < dim_diff { + // New dimension prepended - must be broadcastable from size 1 + new_strides.push(0); + } else { + let src_idx = i - dim_diff; + let src_dim = src_dims[src_idx]; + let src_stride = src_strides[src_idx]; + + if src_dim == target_dim { + // Same size - keep stride + new_strides.push(src_stride); + } else if src_dim == 1 { + // Broadcast dimension - stride becomes 0 + new_strides.push(0); + } else { + panic!( + "expand: cannot expand dimension {} from {} to {}", + i, src_dim, target_dim + ); + } + } + } + + let new_layout = Layout::new(target_shape, new_strides, start_offset); + FlexTensor::from_arc(tensor.data_arc(), new_layout, dtype) +} + +// Tests kept here probe flex-internal expand behavior: stride metadata +// (stride 0 on broadcast dims, preservation of negative strides on +// flipped inputs, preserved start-offset on narrowed inputs) and the +// flex-only `broadcast_binary` helper. Public-API expand coverage for +// transpose/flip/narrow variants lives in +// crates/burn-backend-tests/tests/tensor/{float,int,bool}/ops/expand.rs +// so it runs on every backend. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_expand_1d_to_2d() { + // [3] -> [2, 3] + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0], [3])); + let expanded = expand(tensor, Shape::new([2, 3])); + + assert_eq!(expanded.layout().shape().to_vec(), vec![2, 3]); + assert_eq!(expanded.layout().strides(), &[0, 1]); + } + + #[test] + fn test_expand_broadcast_dim() { + // [3, 1] -> [3, 4] + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0], [3, 1])); + let expanded = expand(tensor, Shape::new([3, 4])); + + assert_eq!(expanded.layout().shape().to_vec(), vec![3, 4]); + // Original strides for [3, 1] would be [1, 1] + // After expand to [3, 4], stride for dim 1 becomes 0 + assert_eq!(expanded.layout().strides()[1], 0); + } + + #[test] + fn test_expand_same_shape() { + // [2, 3] -> [2, 3] (no change) + let tensor = FlexTensor::from_data(TensorData::new( + vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], + [2, 3], + )); + let original_strides = tensor.layout().strides().to_vec(); + let expanded = expand(tensor, Shape::new([2, 3])); + + assert_eq!(expanded.layout().shape().to_vec(), vec![2, 3]); + assert_eq!(expanded.layout().strides(), &original_strides); + } + + // === Non-contiguous tensor tests === + + #[test] + fn test_expand_transposed() { + // [[1, 2], [3, 4]] transposed -> [[1, 3], [2, 4]] with strides [1, 2] + // Expand [2, 2] -> [3, 2, 2] by prepending dimension + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0], [2, 2])); + let transposed = tensor.transpose(0, 1); + assert!(!transposed.is_contiguous()); + assert_eq!(transposed.layout().strides(), &[1, 2]); + + let expanded = expand(transposed, Shape::new([3, 2, 2])); + assert_eq!(expanded.layout().shape().to_vec(), vec![3, 2, 2]); + // New dim with stride 0, original strides preserved + assert_eq!(expanded.layout().strides(), &[0, 1, 2]); + + // Verify content: should see same transposed values repeated 3 times + let data: Vec = expanded.into_data().to_vec().unwrap(); + // [[1, 3], [2, 4]] repeated 3 times + assert_eq!( + data, + vec![1.0, 3.0, 2.0, 4.0, 1.0, 3.0, 2.0, 4.0, 1.0, 3.0, 2.0, 4.0] + ); + } + + #[test] + fn test_expand_flipped_1d() { + // [1, 2, 3] flipped -> [3, 2, 1] with negative stride + // Expand [3] -> [2, 3] + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0], [3])); + let flipped = crate::ops::flip::flip(tensor, &[0]); + assert!(flipped.layout().strides()[0] < 0); + + let expanded = expand(flipped, Shape::new([2, 3])); + assert_eq!(expanded.layout().shape().to_vec(), vec![2, 3]); + // Stride 0 for new broadcast dim, negative stride preserved + assert_eq!(expanded.layout().strides()[0], 0); + assert!(expanded.layout().strides()[1] < 0); + + // Verify content: [3, 2, 1] repeated twice + let data: Vec = expanded.into_data().to_vec().unwrap(); + assert_eq!(data, vec![3.0, 2.0, 1.0, 3.0, 2.0, 1.0]); + } + + #[test] + fn test_expand_flipped_2d_preserves_negative_stride() { + // [[1, 2], [3, 4]] with axis 0 flipped -> [[3, 4], [1, 2]] + // Shape [2, 2] with strides [-2, 1] (negative on axis 0) + // Expand [2, 2] -> [3, 2, 2] + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0], [2, 2])); + let flipped = crate::ops::flip::flip(tensor, &[0]); + assert!(flipped.layout().strides()[0] < 0); + assert_eq!(flipped.layout().strides()[1], 1); + + let expanded = expand(flipped, Shape::new([3, 2, 2])); + assert_eq!(expanded.layout().shape().to_vec(), vec![3, 2, 2]); + // Stride 0 for broadcast, negative stride preserved for axis 1, positive for axis 2 + assert_eq!(expanded.layout().strides()[0], 0); + assert!(expanded.layout().strides()[1] < 0); + assert_eq!(expanded.layout().strides()[2], 1); + + // Verify content + let data: Vec = expanded.into_data().to_vec().unwrap(); + // [[3, 4], [1, 2]] repeated 3 times + assert_eq!( + data, + vec![3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0, 2.0] + ); + } + + #[test] + fn test_expand_narrowed_preserves_offset() { + // [0, 1, 2, 3, 4] narrowed to [1, 2, 3] with offset 1 + // Expand [3] -> [2, 3] + let tensor = FlexTensor::from_data(TensorData::new(vec![0.0f32, 1.0, 2.0, 3.0, 4.0], [5])); + let narrowed = tensor.narrow(0, 1, 3); + assert_eq!(narrowed.layout().start_offset(), 1); + + let expanded = expand(narrowed, Shape::new([2, 3])); + assert_eq!(expanded.layout().shape().to_vec(), vec![2, 3]); + // Start offset preserved + assert_eq!(expanded.layout().start_offset(), 1); + + // Verify content: [1, 2, 3] repeated twice + let data: Vec = expanded.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1.0, 2.0, 3.0, 1.0, 2.0, 3.0]); + } + + #[test] + #[should_panic(expected = "broadcasting cannot drop dimensions")] + fn test_expand_to_fewer_dims_panics() { + // Shape [2, 3, 4] expanded to target [2, 3] would silently drop + // the trailing dim and produce an invalid layout; must panic. + let tensor = FlexTensor::from_data(TensorData::new(alloc::vec![0.0f32; 24], [2, 3, 4])); + let _ = expand(tensor, Shape::new([2, 3])); + } + + #[test] + #[should_panic(expected = "broadcasting cannot drop dimensions")] + fn test_expand_1d_to_scalar_panics() { + // 1D -> 0D exercises the saturating_sub edge we removed: dim_diff + // used to come out as 0 and the loop would iterate zero times, + // silently producing a 0-dim layout over 1D data. + let tensor = FlexTensor::from_data(TensorData::new(alloc::vec![1.0f32, 2.0, 3.0], [3])); + let _ = expand(tensor, Shape::from(alloc::vec::Vec::::new())); + } + + #[test] + fn test_broadcast_binary_with_flipped() { + // One tensor flipped, broadcast to same shape + // lhs: [1, 2, 3, 4] flipped -> [4, 3, 2, 1], shape [4] + // rhs: [[1], [2]], shape [2, 1] -> broadcast to [2, 4] + // After broadcast: lhs [2, 4], rhs [2, 4] + let lhs = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0], [4])); + let lhs = crate::ops::flip::flip(lhs, &[0]); + assert!(lhs.layout().strides()[0] < 0); + + let rhs = FlexTensor::from_data(TensorData::new(vec![10.0f32, 20.0], [2, 1])); + + let (lhs_bc, rhs_bc) = broadcast_binary(lhs, rhs); + assert_eq!(lhs_bc.layout().shape().to_vec(), vec![2, 4]); + assert_eq!(rhs_bc.layout().shape().to_vec(), vec![2, 4]); + + // lhs should have stride 0 in dim 0 (broadcast), negative stride in dim 1 (from flip) + assert_eq!(lhs_bc.layout().strides()[0], 0); + assert!(lhs_bc.layout().strides()[1] < 0); + + // Verify lhs content: [4, 3, 2, 1] repeated twice + let lhs_data: Vec = lhs_bc.into_data().to_vec().unwrap(); + assert_eq!(lhs_data, vec![4.0, 3.0, 2.0, 1.0, 4.0, 3.0, 2.0, 1.0]); + } +} diff --git a/crates/burn-flex/src/ops/fft.rs b/crates/burn-flex/src/ops/fft.rs new file mode 100644 index 0000000..5f61da7 --- /dev/null +++ b/crates/burn-flex/src/ops/fft.rs @@ -0,0 +1,2168 @@ +//! Real FFT (rfft) via Cooley-Tukey with aggressive optimization. +//! +//! Key optimizations: +//! - Real FFT via complex packing: pack N real values as N/2 complex, +//! do a half-size complex FFT, then unpack using Hermitian symmetry (~2x) +//! - Compile-time twiddle tables via const fn Taylor-series sin/cos +//! - Unrolled small complex FFT kernels for N=2, 4, 8 +//! - Mixed radix-4/radix-2 butterfly stages (halves passes over data) +//! - SIMD-vectorized butterflies via macerator +//! - Rayon parallelism across independent fibers + +use alloc::vec; +use alloc::vec::Vec; +use burn_std::{Bytes, Shape}; + +use crate::layout::{contiguous_strides_usize, slice_base_offset}; +use crate::{FlexTensor, Layout}; + +// ============================================================================ +// Const-evaluable sin/cos via Taylor series (13 terms, ~13 digit accuracy) +// ============================================================================ + +const PI: f64 = core::f64::consts::PI; + +const fn const_sin(x: f64) -> f64 { + let mut x = x; + x = x - ((x / (2.0 * PI)) as i64 as f64) * 2.0 * PI; + if x > PI { + x -= 2.0 * PI; + } else if x < -PI { + x += 2.0 * PI; + } + let x2 = x * x; + let mut term = x; + let mut sum = x; + let mut i = 1u32; + while i <= 12 { + term *= -x2 / ((2 * i) as f64 * (2 * i + 1) as f64); + sum += term; + i += 1; + } + sum +} + +const fn const_cos(x: f64) -> f64 { + const_sin(x + PI / 2.0) +} + +// ============================================================================ +// Compile-time twiddle table +// ============================================================================ + +struct TwiddleTable { + re: [f32; M], + im: [f32; M], + offsets: [usize; 18], + num_stages: usize, +} + +const fn make_twiddle_table() -> TwiddleTable { + let mut re = [0.0f32; M]; + let mut im = [0.0f32; M]; + let mut offsets = [0usize; 18]; + let num_stages = N.trailing_zeros() as usize; + let mut pos = 0usize; + let mut len = 2usize; + let mut stage = 0usize; + while stage < num_stages { + offsets[stage] = pos; + let half = len / 2; + let angle_step = -2.0 * PI / len as f64; + let mut k = 0usize; + while k < half { + let angle = angle_step * k as f64; + re[pos] = const_cos(angle) as f32; + im[pos] = const_sin(angle) as f32; + pos += 1; + k += 1; + } + len <<= 1; + stage += 1; + } + offsets[num_stages] = pos; + TwiddleTable { + re, + im, + offsets, + num_stages, + } +} + +macro_rules! def_twiddle { + ($name:ident, $n:expr) => { + static $name: TwiddleTable<{ $n - 1 }> = make_twiddle_table::<$n, { $n - 1 }>(); + }; +} + +def_twiddle!(TW_2, 2); +def_twiddle!(TW_4, 4); +def_twiddle!(TW_8, 8); +def_twiddle!(TW_16, 16); +def_twiddle!(TW_32, 32); +def_twiddle!(TW_64, 64); +def_twiddle!(TW_128, 128); +def_twiddle!(TW_256, 256); +def_twiddle!(TW_512, 512); +def_twiddle!(TW_1024, 1024); +def_twiddle!(TW_2048, 2048); +def_twiddle!(TW_4096, 4096); +def_twiddle!(TW_8192, 8192); +def_twiddle!(TW_16384, 16384); +def_twiddle!(TW_32768, 32768); +def_twiddle!(TW_65536, 65536); + +enum TwiddleRef { + Static { + re: &'static [f32], + im: &'static [f32], + offsets: &'static [usize], + }, + Owned { + re: Vec, + im: Vec, + offsets: Vec, + }, +} + +impl TwiddleRef { + fn re(&self) -> &[f32] { + match self { + Self::Static { re, .. } => re, + Self::Owned { re, .. } => re, + } + } + fn im(&self) -> &[f32] { + match self { + Self::Static { im, .. } => im, + Self::Owned { im, .. } => im, + } + } + fn offsets(&self) -> &[usize] { + match self { + Self::Static { offsets, .. } => offsets, + Self::Owned { offsets, .. } => offsets, + } + } +} + +fn get_twiddles(n: usize) -> TwiddleRef { + macro_rules! match_static { + ($($size:expr => $table:ident),+ $(,)?) => { + match n { + 0 | 1 => TwiddleRef::Static { re: &[], im: &[], offsets: &[0] }, + $($size => TwiddleRef::Static { + re: &$table.re, im: &$table.im, + offsets: &$table.offsets[..$table.num_stages + 1], + },)+ + _ => { + let (re, im, offsets) = precompute_twiddles_runtime(n); + TwiddleRef::Owned { re, im, offsets } + } + } + }; + } + match_static!( + 2 => TW_2, 4 => TW_4, 8 => TW_8, 16 => TW_16, + 32 => TW_32, 64 => TW_64, 128 => TW_128, 256 => TW_256, + 512 => TW_512, 1024 => TW_1024, 2048 => TW_2048, 4096 => TW_4096, + 8192 => TW_8192, 16384 => TW_16384, 32768 => TW_32768, 65536 => TW_65536, + ) +} + +fn precompute_twiddles_runtime(n: usize) -> (Vec, Vec, Vec) { + let num_stages = n.trailing_zeros() as usize; + let total = n - 1; + let mut re = Vec::with_capacity(total); + let mut im = Vec::with_capacity(total); + let mut offsets = Vec::with_capacity(num_stages + 1); + let mut len = 2; + for _ in 0..num_stages { + offsets.push(re.len()); + let half = len / 2; + let angle_step = -2.0 * core::f64::consts::PI / len as f64; + for k in 0..half { + let angle = angle_step * k as f64; + re.push(const_cos(angle) as f32); + im.push(const_sin(angle) as f32); + } + len <<= 1; + } + offsets.push(re.len()); + (re, im, offsets) +} + +// ============================================================================ +// Bit-reversal permutation +// ============================================================================ + +#[inline] +fn bit_reverse_permute(re: &mut [f32], im: &mut [f32], n: usize) { + let mut j = 0usize; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j ^= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } +} + +// ============================================================================ +// Unrolled small complex FFT kernels +// ============================================================================ + +/// Complex FFT of size 2: single butterfly, no twiddles. +#[inline(always)] +fn complex_fft_2(re: &mut [f32], im: &mut [f32]) { + let (r0, r1) = (re[0], re[1]); + let (i0, i1) = (im[0], im[1]); + re[0] = r0 + r1; + re[1] = r0 - r1; + im[0] = i0 + i1; + im[1] = i0 - i1; +} + +/// Complex FFT of size 4: 2 stages, fully unrolled. +/// Twiddle for stage 1, k=1 is W_4^1 = -i. +#[inline(always)] +fn complex_fft_4(re: &mut [f32], im: &mut [f32]) { + // Bit-reversal: swap indices 1 and 2 + re.swap(1, 2); + im.swap(1, 2); + + // Stage 0: two size-2 butterflies + let (r0, r1) = (re[0] + re[1], re[0] - re[1]); + let (i0, i1) = (im[0] + im[1], im[0] - im[1]); + let (r2, r3) = (re[2] + re[3], re[2] - re[3]); + let (i2, i3) = (im[2] + im[3], im[2] - im[3]); + + // Stage 1: size-4 butterfly + // k=0: W=1, butterfly (0,2) + re[0] = r0 + r2; + im[0] = i0 + i2; + re[2] = r0 - r2; + im[2] = i0 - i2; + // k=1: W=-i, butterfly (1,3): -i*(r3+i*i3) = (i3, -r3) + re[1] = r1 + i3; + im[1] = i1 - r3; + re[3] = r1 - i3; + im[3] = i1 + r3; +} + +/// Complex FFT of size 8: 3 stages, fully unrolled. +#[inline(always)] +fn complex_fft_8(re: &mut [f32], im: &mut [f32]) { + // Bit-reversal for n=8: [0,4,2,6,1,5,3,7] + re.swap(1, 4); + im.swap(1, 4); + re.swap(3, 6); + im.swap(3, 6); + + // Stage 0: four size-2 butterflies + macro_rules! butterfly2 { + ($a:expr, $b:expr) => { + let (ra, rb) = (re[$a] + re[$b], re[$a] - re[$b]); + let (ia, ib) = (im[$a] + im[$b], im[$a] - im[$b]); + re[$a] = ra; + re[$b] = rb; + im[$a] = ia; + im[$b] = ib; + }; + } + butterfly2!(0, 1); + butterfly2!(2, 3); + butterfly2!(4, 5); + butterfly2!(6, 7); + + // Stage 1: two size-4 butterflies + // Group [0,1,2,3]: k=0 W=1, k=1 W=-i + { + let (r0, r2) = (re[0] + re[2], re[0] - re[2]); + let (i0, i2) = (im[0] + im[2], im[0] - im[2]); + re[0] = r0; + im[0] = i0; + re[2] = r2; + im[2] = i2; + // k=1: W=-i → (im[3], -re[3]) + let (t_re, t_im) = (im[3], -re[3]); + let (r1a, r1b) = (re[1] + t_re, re[1] - t_re); + let (i1a, i1b) = (im[1] + t_im, im[1] - t_im); + re[1] = r1a; + re[3] = r1b; + im[1] = i1a; + im[3] = i1b; + } + // Group [4,5,6,7]: same pattern + { + let (r4, r6) = (re[4] + re[6], re[4] - re[6]); + let (i4, i6) = (im[4] + im[6], im[4] - im[6]); + re[4] = r4; + im[4] = i4; + re[6] = r6; + im[6] = i6; + let (t_re, t_im) = (im[7], -re[7]); + let (r5a, r5b) = (re[5] + t_re, re[5] - t_re); + let (i5a, i5b) = (im[5] + t_im, im[5] - t_im); + re[5] = r5a; + re[7] = r5b; + im[5] = i5a; + im[7] = i5b; + } + + // Stage 2: one size-8 butterfly + // k=0: W=1 + { + let (a, b) = (re[0] + re[4], re[0] - re[4]); + let (c, d) = (im[0] + im[4], im[0] - im[4]); + re[0] = a; + re[4] = b; + im[0] = c; + im[4] = d; + } + // k=1: W_8^1 = (sqrt2/2, -sqrt2/2) + { + const W: f32 = core::f32::consts::FRAC_1_SQRT_2; // 0.7071... + let t_re = W * re[5] - (-W) * im[5]; // W*re + W*im + let t_im = W * im[5] + (-W) * re[5]; // W*im - W*re + re[5] = re[1] - t_re; + im[5] = im[1] - t_im; + re[1] += t_re; + im[1] += t_im; + } + // k=2: W_8^2 = -i + { + let (t_re, t_im) = (im[6], -re[6]); + re[6] = re[2] - t_re; + im[6] = im[2] - t_im; + re[2] += t_re; + im[2] += t_im; + } + // k=3: W_8^3 = (-sqrt2/2, -sqrt2/2) + { + const W: f32 = core::f32::consts::FRAC_1_SQRT_2; + let t_re = -W * re[7] - (-W) * im[7]; // -W*re + W*im + let t_im = -W * im[7] + (-W) * re[7]; // -W*im - W*re + re[7] = re[3] - t_re; + im[7] = im[3] - t_im; + re[3] += t_re; + im[3] += t_im; + } +} + +// ============================================================================ +// General complex FFT: mixed radix-4/radix-2 with SIMD +// ============================================================================ + +/// Complex FFT of size n (power of 2) using precomputed twiddles. +#[inline] +fn complex_fft(re: &mut [f32], im: &mut [f32], n: usize, tw: &TwiddleRef) { + match n { + 0 | 1 => return, + 2 => { + complex_fft_2(re, im); + return; + } + 4 => { + complex_fft_4(re, im); + return; + } + 8 => { + complex_fft_8(re, im); + return; + } + _ => {} + } + + bit_reverse_permute(re, im, n); + + let tw_re = tw.re(); + let tw_im = tw.im(); + let offsets = tw.offsets(); + let num_stages = offsets.len() - 1; + + // For odd number of stages, do one radix-2 pass first so the + // remaining stages can be processed in radix-4 pairs. + // Stage 0 twiddle is always W_2^0 = 1, so just add/sub. + let start_stage = if num_stages % 2 == 1 { + let mut start = 0; + while start < n { + let (a, b) = (re[start] + re[start + 1], re[start] - re[start + 1]); + let (c, d) = (im[start] + im[start + 1], im[start] - im[start + 1]); + re[start] = a; + re[start + 1] = b; + im[start] = c; + im[start + 1] = d; + start += 2; + } + 1 + } else { + 0 + }; + + #[cfg(feature = "simd")] + { + simd_fft::radix4_simd(re, im, n, tw_re, tw_im, offsets, start_stage, num_stages); + } + #[cfg(not(feature = "simd"))] + { + radix4_scalar(re, im, n, tw_re, tw_im, offsets, start_stage, num_stages); + } +} + +/// Correct DIT radix-4: fuse two radix-2 stages into one pass. +/// +/// For each pair of stages (s, s+1) with quarter = 2^s: +/// 1. Apply W_{2q}^k to x[p1] and x[p3] (inner stage twiddle, same for both) +/// 2. Inner butterflies: a=p0+tw1, b=p0-tw1, c=p2+tw3, d=p2-tw3 +/// 3. Apply W_{4q}^k to c and d; d also gets -i rotation +/// 4. Outer butterflies: p0=a+tc, p2=a-tc, p1=b+(-i*td), p3=b-(-i*td) +#[cfg(not(feature = "simd"))] +#[allow(clippy::too_many_arguments)] +fn radix4_scalar( + re: &mut [f32], + im: &mut [f32], + n: usize, + tw_re: &[f32], + tw_im: &[f32], + offsets: &[usize], + start_stage: usize, + num_stages: usize, +) { + let mut stage = start_stage; + while stage + 1 < num_stages { + let quarter = 1 << stage; + let group_size = quarter << 2; + let tw_off_inner = offsets[stage]; // W_{2q}^k + let tw_off_outer = offsets[stage + 1]; // W_{4q}^k + + let mut group_start = 0; + while group_start < n { + for k in 0..quarter { + let p0 = group_start + k; + let p1 = p0 + quarter; + let p2 = p1 + quarter; + let p3 = p2 + quarter; + + // Inner twiddle: W_{2q}^k applied to p1 and p3 + let wi_re = tw_re[tw_off_inner + k]; + let wi_im = tw_im[tw_off_inner + k]; + let tw1_re = wi_re * re[p1] - wi_im * im[p1]; + let tw1_im = wi_re * im[p1] + wi_im * re[p1]; + let tw3_re = wi_re * re[p3] - wi_im * im[p3]; + let tw3_im = wi_re * im[p3] + wi_im * re[p3]; + + // Inner butterflies + let a_re = re[p0] + tw1_re; + let a_im = im[p0] + tw1_im; + let b_re = re[p0] - tw1_re; + let b_im = im[p0] - tw1_im; + let c_re = re[p2] + tw3_re; + let c_im = im[p2] + tw3_im; + let d_re = re[p2] - tw3_re; + let d_im = im[p2] - tw3_im; + + // Outer twiddle: W_{4q}^k applied to c and d + let wo_re = tw_re[tw_off_outer + k]; + let wo_im = tw_im[tw_off_outer + k]; + let tc_re = wo_re * c_re - wo_im * c_im; + let tc_im = wo_re * c_im + wo_im * c_re; + let td_re = wo_re * d_re - wo_im * d_im; + let td_im = wo_re * d_im + wo_im * d_re; + + // Outer butterflies (-i*(td_re+i*td_im) = (td_im, -td_re)) + re[p0] = a_re + tc_re; + im[p0] = a_im + tc_im; + re[p2] = a_re - tc_re; + im[p2] = a_im - tc_im; + re[p1] = b_re + td_im; + im[p1] = b_im - td_re; + re[p3] = b_re - td_im; + im[p3] = b_im + td_re; + } + group_start += group_size; + } + stage += 2; + } +} + +#[cfg(feature = "simd")] +mod simd_fft { + use macerator::{Simd, vload_unaligned, vstore_unaligned}; + + /// Scalar radix-4 butterfly for the SIMD tail path. + #[allow(clippy::too_many_arguments)] + #[inline(always)] + fn scalar_radix4( + re: &mut [f32], + im: &mut [f32], + p0: usize, + quarter: usize, + tw_re: &[f32], + tw_im: &[f32], + tw_off_inner: usize, + tw_off_outer: usize, + k: usize, + ) { + let p1 = p0 + quarter; + let p2 = p1 + quarter; + let p3 = p2 + quarter; + + let wi_r = tw_re[tw_off_inner + k]; + let wi_i = tw_im[tw_off_inner + k]; + let tw1_re = wi_r * re[p1] - wi_i * im[p1]; + let tw1_im = wi_r * im[p1] + wi_i * re[p1]; + let tw3_re = wi_r * re[p3] - wi_i * im[p3]; + let tw3_im = wi_r * im[p3] + wi_i * re[p3]; + + let a_re = re[p0] + tw1_re; + let a_im = im[p0] + tw1_im; + let b_re = re[p0] - tw1_re; + let b_im = im[p0] - tw1_im; + let c_re = re[p2] + tw3_re; + let c_im = im[p2] + tw3_im; + let d_re = re[p2] - tw3_re; + let d_im = im[p2] - tw3_im; + + let wo_r = tw_re[tw_off_outer + k]; + let wo_i = tw_im[tw_off_outer + k]; + let tc_re = wo_r * c_re - wo_i * c_im; + let tc_im = wo_r * c_im + wo_i * c_re; + let td_re = wo_r * d_re - wo_i * d_im; + let td_im = wo_r * d_im + wo_i * d_re; + + re[p0] = a_re + tc_re; + im[p0] = a_im + tc_im; + re[p2] = a_re - tc_re; + im[p2] = a_im - tc_im; + re[p1] = b_re + td_im; + im[p1] = b_im - td_re; + re[p3] = b_re - td_im; + im[p3] = b_im + td_re; + } + + /// SIMD radix-4 butterfly passes (pairs of radix-2 stages). + #[macerator::with_simd] + #[allow(clippy::too_many_arguments)] + pub fn radix4_simd( + re: &mut [f32], + im: &mut [f32], + n: usize, + tw_re: &[f32], + tw_im: &[f32], + offsets: &[usize], + start_stage: usize, + num_stages: usize, + ) { + let lanes = S::lanes32(); + let mut stage = start_stage; + + while stage + 1 < num_stages { + let quarter = 1 << stage; + let group_size = quarter << 2; + let tw_off_inner = offsets[stage]; + let tw_off_outer = offsets[stage + 1]; + + if quarter >= lanes { + let mut group_start = 0; + while group_start < n { + let mut k = 0; + while k + lanes <= quarter { + unsafe { + // Inner twiddle: W_{2q}^k + let wi_r = + vload_unaligned::(tw_re.as_ptr().add(tw_off_inner + k)); + let wi_i = + vload_unaligned::(tw_im.as_ptr().add(tw_off_inner + k)); + + let p0 = group_start + k; + let p1 = p0 + quarter; + let p2 = p1 + quarter; + let p3 = p2 + quarter; + + let r0 = vload_unaligned::(re.as_ptr().add(p0)); + let i0 = vload_unaligned::(im.as_ptr().add(p0)); + let r1 = vload_unaligned::(re.as_ptr().add(p1)); + let i1 = vload_unaligned::(im.as_ptr().add(p1)); + let r2 = vload_unaligned::(re.as_ptr().add(p2)); + let i2 = vload_unaligned::(im.as_ptr().add(p2)); + let r3 = vload_unaligned::(re.as_ptr().add(p3)); + let i3 = vload_unaligned::(im.as_ptr().add(p3)); + + // Apply inner twiddle to p1 and p3 + let tw1_re = wi_r * r1 - wi_i * i1; + let tw1_im = wi_r * i1 + wi_i * r1; + let tw3_re = wi_r * r3 - wi_i * i3; + let tw3_im = wi_r * i3 + wi_i * r3; + + // Inner butterflies + let a_re = r0 + tw1_re; + let a_im = i0 + tw1_im; + let b_re = r0 - tw1_re; + let b_im = i0 - tw1_im; + let c_re = r2 + tw3_re; + let c_im = i2 + tw3_im; + let d_re = r2 - tw3_re; + let d_im = i2 - tw3_im; + + // Outer twiddle: W_{4q}^k + let wo_r = + vload_unaligned::(tw_re.as_ptr().add(tw_off_outer + k)); + let wo_i = + vload_unaligned::(tw_im.as_ptr().add(tw_off_outer + k)); + let tc_re = wo_r * c_re - wo_i * c_im; + let tc_im = wo_r * c_im + wo_i * c_re; + let td_re = wo_r * d_re - wo_i * d_im; + let td_im = wo_r * d_im + wo_i * d_re; + + // Outer butterflies: -i*(td_re+i*td_im) = (td_im, -td_re) + vstore_unaligned::(re.as_mut_ptr().add(p0), a_re + tc_re); + vstore_unaligned::(im.as_mut_ptr().add(p0), a_im + tc_im); + vstore_unaligned::(re.as_mut_ptr().add(p2), a_re - tc_re); + vstore_unaligned::(im.as_mut_ptr().add(p2), a_im - tc_im); + vstore_unaligned::(re.as_mut_ptr().add(p1), b_re + td_im); + vstore_unaligned::(im.as_mut_ptr().add(p1), b_im - td_re); + vstore_unaligned::(re.as_mut_ptr().add(p3), b_re - td_im); + vstore_unaligned::(im.as_mut_ptr().add(p3), b_im + td_re); + } + k += lanes; + } + while k < quarter { + scalar_radix4( + re, + im, + group_start + k, + quarter, + tw_re, + tw_im, + tw_off_inner, + tw_off_outer, + k, + ); + k += 1; + } + group_start += group_size; + } + } else { + let mut group_start = 0; + while group_start < n { + for k in 0..quarter { + scalar_radix4( + re, + im, + group_start + k, + quarter, + tw_re, + tw_im, + tw_off_inner, + tw_off_outer, + k, + ); + } + group_start += group_size; + } + } + stage += 2; + } + } +} + +// ============================================================================ +// Real FFT unpacking +// ============================================================================ + +/// Unpack N/2-point complex FFT result into N/2+1 real FFT bins. +/// +/// Given Z = FFT(pack(x)), recovers X = FFT(x) using: +/// Xe[k] = (Z[k] + conj(Z[N/2-k])) / 2 +/// Xo[k] = -i * (Z[k] - conj(Z[N/2-k])) / 2 +/// X[k] = Xe[k] + W_N^k * Xo[k] +fn unpack_rfft( + z_re: &[f32], + z_im: &[f32], + half: usize, + unpack_tw_re: &[f32], + unpack_tw_im: &[f32], + out_re: &mut [f32], + out_im: &mut [f32], +) { + // k=0: X[0] = Z_re[0] + Z_im[0] (real) + out_re[0] = z_re[0] + z_im[0]; + out_im[0] = 0.0; + + // k=N/2: X[N/2] = Z_re[0] - Z_im[0] (real) + out_re[half] = z_re[0] - z_im[0]; + out_im[half] = 0.0; + + // k=1..half-1 + for k in 1..half { + let j = half - k; + let (zk_re, zk_im) = (z_re[k], z_im[k]); + let (zj_re, zj_im) = (z_re[j], z_im[j]); + + // Xe = (Z[k] + conj(Z[j])) / 2 + let xe_re = (zk_re + zj_re) * 0.5; + let xe_im = (zk_im - zj_im) * 0.5; + + // Xo = -i * (Z[k] - conj(Z[j])) / 2 + // diff = Z[k] - conj(Z[j]) = (zk_re - zj_re, zk_im + zj_im) + // -i * diff = (diff_im, -diff_re) + let xo_re = (zk_im + zj_im) * 0.5; + let xo_im = (zj_re - zk_re) * 0.5; + + // X[k] = Xe + W * Xo + let wr = unpack_tw_re[k]; + let wi = unpack_tw_im[k]; + + out_re[k] = xe_re + wr * xo_re - wi * xo_im; + out_im[k] = xe_im + wr * xo_im + wi * xo_re; + } +} + +// ============================================================================ +// Tensor helpers +// ============================================================================ + +fn make_tensors_typed( + re: Vec, + im: Vec, + shape: Shape, +) -> (FlexTensor, FlexTensor) { + let dtype = E::dtype(); + let re_t = FlexTensor::new( + Bytes::from_elems(re), + Layout::contiguous(shape.clone()), + dtype, + ); + let im_t = FlexTensor::new(Bytes::from_elems(im), Layout::contiguous(shape), dtype); + (re_t, im_t) +} + +// ============================================================================ +// Top-level rfft: real FFT via complex packing +// ============================================================================ + +/// Process a single fiber: pack real signal as complex, FFT, unpack. +#[allow(clippy::too_many_arguments)] +#[inline] +fn rfft_fiber( + signal: &[f32], + in_stride: usize, + n: usize, + sig_len: usize, + out_re: &mut [f32], + out_im: &mut [f32], + tw_half: &TwiddleRef, + unpack_tw_re: &[f32], + unpack_tw_im: &[f32], + z_re: &mut [f32], + z_im: &mut [f32], +) { + let half = n / 2; + + if n == 1 { + out_re[0] = if sig_len >= 1 { signal[0] } else { 0.0 }; + out_im[0] = 0.0; + return; + } + + if sig_len >= n { + if in_stride == 1 { + for k in 0..half { + z_re[k] = signal[2 * k]; + z_im[k] = signal[2 * k + 1]; + } + } else { + for k in 0..half { + z_re[k] = signal[(2 * k) * in_stride]; + z_im[k] = signal[(2 * k + 1) * in_stride]; + } + } + } else { + for k in 0..half { + let even = 2 * k; + let odd = 2 * k + 1; + z_re[k] = if even < sig_len { + signal[even * in_stride] + } else { + 0.0 + }; + z_im[k] = if odd < sig_len { + signal[odd * in_stride] + } else { + 0.0 + }; + } + } + + complex_fft(z_re, z_im, half, tw_half); + unpack_rfft(z_re, z_im, half, unpack_tw_re, unpack_tw_im, out_re, out_im); +} + +pub fn rfft_f32(tensor: FlexTensor, dim: usize, n: Option) -> (FlexTensor, FlexTensor) { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + assert!( + dim < shape.num_dims(), + "rfft: dim {dim} out of bounds for {}-D tensor", + shape.num_dims() + ); + + let requested_n = n.unwrap_or_else(|| { + let sig_len = shape[dim]; + assert!( + sig_len > 0 && sig_len.is_power_of_two(), + "rfft: dimension size must be a power of 2, got {sig_len}" + ); + sig_len + }); + let fft_size = requested_n.next_power_of_two(); + let sig_len = shape[dim].min(requested_n); + + let n = fft_size; + let out_len = n / 2 + 1; + + let mut out_dims: Vec = shape.as_slice().to_vec(); + out_dims[dim] = out_len; + let out_shape = Shape::from(out_dims); + let total_out = out_shape.num_elements(); + let num_fibers = shape.num_elements() / shape[dim]; + + let data: &[f32] = tensor.storage(); + let in_strides = contiguous_strides_usize(&shape); + let out_strides = contiguous_strides_usize(&out_shape); + + // N=1: each element is its own DFT, no twiddles needed + if n == 1 { + let mut re_out = vec![0.0f32; total_out]; + let im_out = vec![0.0f32; total_out]; + if sig_len >= 1 { + for fiber_idx in 0..num_fibers { + let base = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + re_out[out_base] = data[base]; + } + } + return make_tensors_typed(re_out, im_out, out_shape); + } + + let half = n / 2; + let tw_half = get_twiddles(half); + + let tw_full = get_twiddles(n); + let full_offsets = tw_full.offsets(); + let last_stage_off = if full_offsets.len() >= 2 { + full_offsets[full_offsets.len() - 2] + } else { + 0 + }; + let unpack_tw_re = &tw_full.re()[last_stage_off..]; + let unpack_tw_im = &tw_full.im()[last_stage_off..]; + + let mut re_out = vec![0.0f32; total_out]; + let mut im_out = vec![0.0f32; total_out]; + + let in_stride = in_strides[dim]; + let out_stride = out_strides[dim]; + + #[cfg(feature = "rayon")] + if num_fibers >= 4 && n >= 64 { + use rayon::prelude::*; + + let fiber_results: Vec<(usize, Vec, Vec)> = (0..num_fibers) + .into_par_iter() + .map(|fiber_idx| { + let base_offset = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let mut z_re = vec![0.0f32; half.max(1)]; + let mut z_im = vec![0.0f32; half.max(1)]; + let mut fiber_re = vec![0.0f32; out_len]; + let mut fiber_im = vec![0.0f32; out_len]; + + rfft_fiber( + &data[base_offset..], + in_stride, + n, + sig_len, + &mut fiber_re, + &mut fiber_im, + &tw_half, + unpack_tw_re, + unpack_tw_im, + &mut z_re, + &mut z_im, + ); + (fiber_idx, fiber_re, fiber_im) + }) + .collect(); + + for (fiber_idx, fiber_re, fiber_im) in fiber_results { + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + for k in 0..out_len { + re_out[out_base + k * out_stride] = fiber_re[k]; + im_out[out_base + k * out_stride] = fiber_im[k]; + } + } + + let (re, im) = make_tensors_typed(re_out, im_out, out_shape); + return (re, im); + } + + let mut z_re_buf = vec![0.0f32; half.max(1)]; + let mut z_im_buf = vec![0.0f32; half.max(1)]; + let mut fiber_re = vec![0.0f32; out_len]; + let mut fiber_im = vec![0.0f32; out_len]; + + for fiber_idx in 0..num_fibers { + let base_offset = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + + rfft_fiber( + &data[base_offset..], + in_stride, + n, + sig_len, + &mut fiber_re, + &mut fiber_im, + &tw_half, + unpack_tw_re, + unpack_tw_im, + &mut z_re_buf, + &mut z_im_buf, + ); + + for k in 0..out_len { + re_out[out_base + k * out_stride] = fiber_re[k]; + im_out[out_base + k * out_stride] = fiber_im[k]; + } + } + + let (re, im) = make_tensors_typed(re_out, im_out, out_shape); + (re, im) +} + +#[allow(clippy::too_many_arguments)] +fn rfft_fiber_f64( + signal: &[f64], + in_stride: usize, + n: usize, + sig_len: usize, + half: usize, + out_re: &mut [f64], + out_im: &mut [f64], + tw_re: &[f32], + tw_im: &[f32], + tw_offsets: &[usize], + unpack_re: &[f32], + unpack_im: &[f32], + z_re: &mut [f64], + z_im: &mut [f64], +) { + if n == 1 { + out_re[0] = if sig_len >= 1 { signal[0] } else { 0.0 }; + out_im[0] = 0.0; + return; + } + + if sig_len >= n { + for k in 0..half { + z_re[k] = signal[(2 * k) * in_stride]; + z_im[k] = signal[(2 * k + 1) * in_stride]; + } + } else { + for k in 0..half { + let even = 2 * k; + let odd = 2 * k + 1; + z_re[k] = if even < sig_len { + signal[even * in_stride] + } else { + 0.0 + }; + z_im[k] = if odd < sig_len { + signal[odd * in_stride] + } else { + 0.0 + }; + } + } + + fft_f64_inplace(z_re, z_im, half, tw_re, tw_im, tw_offsets); + + out_re[0] = z_re[0] + z_im[0]; + out_im[0] = 0.0; + out_re[half] = z_re[0] - z_im[0]; + out_im[half] = 0.0; + + for k in 1..half { + let j = half - k; + let (zk_re, zk_im) = (z_re[k], z_im[k]); + let (zj_re, zj_im) = (z_re[j], z_im[j]); + + let xe_re = (zk_re + zj_re) * 0.5; + let xe_im = (zk_im - zj_im) * 0.5; + let xo_re = (zk_im + zj_im) * 0.5; + let xo_im = (zj_re - zk_re) * 0.5; + + let wr = unpack_re[k] as f64; + let wi = unpack_im[k] as f64; + + out_re[k] = xe_re + wr * xo_re - wi * xo_im; + out_im[k] = xe_im + wr * xo_im + wi * xo_re; + } +} + +pub fn rfft_f64(tensor: FlexTensor, dim: usize, n: Option) -> (FlexTensor, FlexTensor) { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + assert!( + dim < shape.num_dims(), + "rfft: dim {dim} out of bounds for {}-D tensor", + shape.num_dims() + ); + + let requested_n = n.unwrap_or_else(|| { + let sig_len = shape[dim]; + assert!( + sig_len > 0 && sig_len.is_power_of_two(), + "rfft: dimension size must be a power of 2, got {sig_len}" + ); + sig_len + }); + let fft_size = requested_n.next_power_of_two(); + let sig_len = shape[dim].min(requested_n); + + let n = fft_size; + let out_len = n / 2 + 1; + + let mut out_dims: Vec = shape.as_slice().to_vec(); + out_dims[dim] = out_len; + let out_shape = Shape::from(out_dims); + let total_out = out_shape.num_elements(); + let num_fibers = shape.num_elements() / shape[dim]; + + let data: &[f64] = tensor.storage(); + let in_strides = contiguous_strides_usize(&shape); + let out_strides = contiguous_strides_usize(&out_shape); + let half = n / 2; + + let tw_half = get_twiddles(half); + let tw_full = get_twiddles(n); + let full_offsets = tw_full.offsets(); + let last_stage_off = if full_offsets.len() >= 2 { + full_offsets[full_offsets.len() - 2] + } else { + 0 + }; + let unpack_re = &tw_full.re()[last_stage_off..]; + let unpack_im = &tw_full.im()[last_stage_off..]; + + let mut re_out = vec![0.0f64; total_out]; + let mut im_out = vec![0.0f64; total_out]; + let in_stride = in_strides[dim]; + let out_stride = out_strides[dim]; + + let tw_half_re = tw_half.re(); + let tw_half_im = tw_half.im(); + let tw_half_offsets = tw_half.offsets(); + + #[cfg(feature = "rayon")] + if num_fibers >= 4 && n >= 64 { + use rayon::prelude::*; + + let fiber_results: Vec<(usize, Vec, Vec)> = (0..num_fibers) + .into_par_iter() + .map(|fiber_idx| { + let base_offset = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let mut z_re = vec![0.0f64; half.max(1)]; + let mut z_im = vec![0.0f64; half.max(1)]; + let mut fiber_re = vec![0.0f64; out_len]; + let mut fiber_im = vec![0.0f64; out_len]; + + rfft_fiber_f64( + &data[base_offset..], + in_stride, + n, + sig_len, + half, + &mut fiber_re, + &mut fiber_im, + tw_half_re, + tw_half_im, + tw_half_offsets, + unpack_re, + unpack_im, + &mut z_re, + &mut z_im, + ); + (fiber_idx, fiber_re, fiber_im) + }) + .collect(); + + for (fiber_idx, fiber_re, fiber_im) in fiber_results { + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + for k in 0..out_len { + re_out[out_base + k * out_stride] = fiber_re[k]; + im_out[out_base + k * out_stride] = fiber_im[k]; + } + } + + let (re, im) = make_tensors_typed(re_out, im_out, out_shape); + return (re, im); + } + + let mut z_re = vec![0.0f64; half.max(1)]; + let mut z_im = vec![0.0f64; half.max(1)]; + let mut fiber_re = vec![0.0f64; out_len]; + let mut fiber_im = vec![0.0f64; out_len]; + + for fiber_idx in 0..num_fibers { + let base_offset = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + + rfft_fiber_f64( + &data[base_offset..], + in_stride, + n, + sig_len, + half, + &mut fiber_re, + &mut fiber_im, + tw_half_re, + tw_half_im, + tw_half_offsets, + unpack_re, + unpack_im, + &mut z_re, + &mut z_im, + ); + + for k in 0..out_len { + re_out[out_base + k * out_stride] = fiber_re[k]; + im_out[out_base + k * out_stride] = fiber_im[k]; + } + } + + let (re, im) = make_tensors_typed(re_out, im_out, out_shape); + (re, im) +} + +/// f64 complex FFT using f32 twiddle table (widened in inner loop). +/// Twiddle precision is limited to ~7 digits (f32), so output accuracy +/// is below full f64 precision for large N. +fn fft_f64_inplace( + re: &mut [f64], + im: &mut [f64], + n: usize, + tw_re: &[f32], + tw_im: &[f32], + offsets: &[usize], +) { + if n <= 1 { + return; + } + + // Bit-reversal + let mut j = 0usize; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j ^= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + + // Scalar radix-2 passes + let num_stages = offsets.len() - 1; + let mut len = 2; + for &tw_off in &offsets[..num_stages] { + let half = len / 2; + let mut start = 0; + while start < n { + for k in 0..half { + let wr = tw_re[tw_off + k] as f64; + let wi = tw_im[tw_off + k] as f64; + let even = start + k; + let odd = even + half; + let t_re = wr * re[odd] - wi * im[odd]; + let t_im = wr * im[odd] + wi * re[odd]; + re[odd] = re[even] - t_re; + im[odd] = im[even] - t_im; + re[even] += t_re; + im[even] += t_im; + } + start += len; + } + len <<= 1; + } +} + +pub fn rfft_f16(tensor: FlexTensor, dim: usize, n: Option) -> (FlexTensor, FlexTensor) { + use burn_std::f16; + let tensor = super::module::cast_to_f32(tensor, f16::to_f32); + let (re, im) = rfft_f32(tensor, dim, n); + ( + super::module::cast_from_f32(re, f16::from_f32), + super::module::cast_from_f32(im, f16::from_f32), + ) +} + +pub fn rfft_bf16(tensor: FlexTensor, dim: usize, n: Option) -> (FlexTensor, FlexTensor) { + use burn_std::bf16; + let tensor = super::module::cast_to_f32(tensor, bf16::to_f32); + let (re, im) = rfft_f32(tensor, dim, n); + ( + super::module::cast_from_f32(re, bf16::from_f32), + super::module::cast_from_f32(im, bf16::from_f32), + ) +} + +// ============================================================================ +// Inverse real FFT (irfft) +// ============================================================================ + +/// Inverse complex FFT: IFFT(X) = (1/N) * conj(FFT(conj(X))). +/// +/// Conjugates input, runs the forward FFT (with SIMD), conjugates +/// output, and scales by 1/N. +#[inline] +fn inverse_complex_fft(re: &mut [f32], im: &mut [f32], n: usize, tw: &TwiddleRef) { + if n <= 1 { + return; + } + + // Conjugate input + for v in im.iter_mut() { + *v = -*v; + } + + // Forward FFT (mixed radix-4/radix-2, SIMD when available) + complex_fft(re, im, n, tw); + + // Conjugate output and scale by 1/N + let scale = 1.0 / n as f32; + for v in re.iter_mut() { + *v *= scale; + } + for v in im.iter_mut() { + *v = -*v * scale; + } +} + +/// Repack N/2+1 spectrum bins into N/2 complex values Z[k], +/// reversing rfft's unpack step. +/// +/// Z[k] = Xe[k] + i*conj(W)*D[k] where: +/// Xe = (X[k] + conj(X[half-k])) / 2 +/// D = (X[k] - conj(X[half-k])) / 2 +/// W = W_N^k (same twiddle used in rfft unpack) +fn repack_irfft( + x_re: &[f32], + x_im: &[f32], + half: usize, + tw_re: &[f32], + tw_im: &[f32], + z_re: &mut [f32], + z_im: &mut [f32], +) { + // k=0: Z[0] = (X[0] + X[half])/2 + i*(X[0] - X[half])/2 + z_re[0] = (x_re[0] + x_re[half]) * 0.5; + z_im[0] = (x_re[0] - x_re[half]) * 0.5; + + for k in 1..half { + let j = half - k; + let (xk_re, xk_im) = (x_re[k], x_im[k]); + let (xj_re, xj_im) = (x_re[j], x_im[j]); + + // Xe = (X[k] + conj(X[j])) / 2 + let a_re = (xk_re + xj_re) * 0.5; + let a_im = (xk_im - xj_im) * 0.5; + + // D = (X[k] - conj(X[j])) / 2 + let d_re = (xk_re - xj_re) * 0.5; + let d_im = (xk_im + xj_im) * 0.5; + + // i*conj(W)*D where W = (wr, wi), conj(W) = (wr, -wi) + // conj(W)*D = (wr*d_re + wi*d_im, wr*d_im - wi*d_re) + // i*(...) = (-(wr*d_im - wi*d_re), wr*d_re + wi*d_im) + let wr = tw_re[k]; + let wi = tw_im[k]; + + z_re[k] = a_re - wr * d_im + wi * d_re; + z_im[k] = a_im + wr * d_re + wi * d_im; + } +} + +/// Process a single irfft fiber via inverse packing trick. +/// +/// Repacks N/2+1 spectrum bins into N/2 complex values, runs N/2-point +/// inverse complex FFT, then de-interleaves to N real output values. +#[allow(clippy::too_many_arguments)] +#[inline] +fn irfft_fiber( + re_in: &[f32], + im_in: &[f32], + in_stride: usize, + half: usize, + spec_bins: usize, + signal_out: &mut [f32], + out_stride: usize, + tw_half: &TwiddleRef, + unpack_tw_re: &[f32], + unpack_tw_im: &[f32], + z_re: &mut [f32], + z_im: &mut [f32], + spec_re: &mut [f32], + spec_im: &mut [f32], +) { + if spec_bins > half { + for k in 0..=half { + spec_re[k] = re_in[k * in_stride]; + spec_im[k] = im_in[k * in_stride]; + } + } else { + for k in 0..=half { + spec_re[k] = if k < spec_bins { + re_in[k * in_stride] + } else { + 0.0 + }; + spec_im[k] = if k < spec_bins { + im_in[k * in_stride] + } else { + 0.0 + }; + } + } + + repack_irfft( + spec_re, + spec_im, + half, + unpack_tw_re, + unpack_tw_im, + z_re, + z_im, + ); + + // Inverse complex FFT of size N/2 + inverse_complex_fft(z_re, z_im, half, tw_half); + + // De-interleave: signal[2k] = z_re[k], signal[2k+1] = z_im[k] + if out_stride == 1 { + for k in 0..half { + signal_out[2 * k] = z_re[k]; + signal_out[2 * k + 1] = z_im[k]; + } + } else { + for k in 0..half { + signal_out[(2 * k) * out_stride] = z_re[k]; + signal_out[(2 * k + 1) * out_stride] = z_im[k]; + } + } +} + +pub fn irfft_f32( + spectrum_re: FlexTensor, + spectrum_im: FlexTensor, + dim: usize, + n: Option, +) -> FlexTensor { + let spectrum_re = spectrum_re.to_contiguous(); + let spectrum_im = spectrum_im.to_contiguous(); + let shape = spectrum_re.layout().shape().clone(); + assert!( + *spectrum_im.layout().shape() == shape, + "irfft: spectrum_re and spectrum_im shapes must match" + ); + assert!( + dim < shape.num_dims(), + "irfft: dim {dim} out of bounds for {}-D tensor", + shape.num_dims() + ); + let half_plus_1 = shape[dim]; + assert!( + half_plus_1 >= 1, + "irfft: spectrum dimension cannot be empty" + ); + + let spec_bins = half_plus_1; + + let requested_n = n.unwrap_or_else(|| { + let sig_len = (half_plus_1 - 1) * 2; + assert!( + sig_len.is_power_of_two(), + "irfft: reconstructed signal length must be a power of 2, got {sig_len}" + ); + sig_len + }); + let fft_size = requested_n.next_power_of_two(); + + // N=1: single DC bin, output is just the real value along `dim`. + // If caller's spectrum has more bins, take the DC (bin 0) only. + if fft_size <= 1 { + let out = if spectrum_re.layout().shape()[dim] != 1 { + spectrum_re.narrow(dim, 0, 1) + } else { + spectrum_re + }; + return out; + } + + let half = fft_size / 2; + let n = fft_size; + + let mut out_dims: Vec = shape.as_slice().to_vec(); + out_dims[dim] = n; + let out_shape = Shape::from(out_dims); + let total_out = out_shape.num_elements(); + let num_fibers = shape.num_elements() / half_plus_1; + + let re_data: &[f32] = spectrum_re.storage(); + let im_data: &[f32] = spectrum_im.storage(); + let in_strides = contiguous_strides_usize(&shape); + let out_strides = contiguous_strides_usize(&out_shape); + + // Twiddles for N/2-point inverse complex FFT + let tw_half = get_twiddles(half); + + // Unpack twiddles: last stage of size-N table (same as rfft) + let tw_full = get_twiddles(n); + let full_offsets = tw_full.offsets(); + let last_stage_off = if full_offsets.len() >= 2 { + full_offsets[full_offsets.len() - 2] + } else { + 0 + }; + let unpack_tw_re = &tw_full.re()[last_stage_off..]; + let unpack_tw_im = &tw_full.im()[last_stage_off..]; + + let mut signal_out = vec![0.0f32; total_out]; + let in_stride = in_strides[dim]; + let out_stride = out_strides[dim]; + + #[cfg(feature = "rayon")] + if num_fibers >= 4 && n >= 64 { + use rayon::prelude::*; + + let fiber_results: Vec<(usize, Vec)> = (0..num_fibers) + .into_par_iter() + .map(|fiber_idx| { + let re_base = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let mut z_re = vec![0.0f32; half.max(1)]; + let mut z_im = vec![0.0f32; half.max(1)]; + let mut spec_re = vec![0.0f32; half + 1]; + let mut spec_im = vec![0.0f32; half + 1]; + let mut fiber_out = vec![0.0f32; n]; + + irfft_fiber( + &re_data[re_base..], + &im_data[re_base..], + in_stride, + half, + spec_bins, + &mut fiber_out, + 1, + &tw_half, + unpack_tw_re, + unpack_tw_im, + &mut z_re, + &mut z_im, + &mut spec_re, + &mut spec_im, + ); + (fiber_idx, fiber_out) + }) + .collect(); + + for (fiber_idx, fiber_out) in fiber_results { + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + for k in 0..n { + signal_out[out_base + k * out_stride] = fiber_out[k]; + } + } + + let result = FlexTensor::new( + Bytes::from_elems(signal_out), + Layout::contiguous(out_shape), + burn_backend::DType::F32, + ); + return if fft_size > requested_n { + result.narrow(dim, 0, requested_n) + } else { + result + }; + } + + let mut z_re = vec![0.0f32; half.max(1)]; + let mut z_im = vec![0.0f32; half.max(1)]; + let mut spec_re = vec![0.0f32; half + 1]; + let mut spec_im = vec![0.0f32; half + 1]; + let mut fiber_out = vec![0.0f32; n]; + + for fiber_idx in 0..num_fibers { + let re_base = slice_base_offset(fiber_idx, &shape, &in_strides, dim); + let out_base = slice_base_offset(fiber_idx, &out_shape, &out_strides, dim); + + irfft_fiber( + &re_data[re_base..], + &im_data[re_base..], + in_stride, + half, + spec_bins, + &mut fiber_out, + 1, + &tw_half, + unpack_tw_re, + unpack_tw_im, + &mut z_re, + &mut z_im, + &mut spec_re, + &mut spec_im, + ); + + for k in 0..n { + signal_out[out_base + k * out_stride] = fiber_out[k]; + } + } + + let result = FlexTensor::new( + Bytes::from_elems(signal_out), + Layout::contiguous(out_shape), + burn_backend::DType::F32, + ); + if fft_size > requested_n { + result.narrow(dim, 0, requested_n) + } else { + result + } +} + +pub fn irfft_f64( + spectrum_re: FlexTensor, + spectrum_im: FlexTensor, + dim: usize, + n: Option, +) -> FlexTensor { + use burn_backend::DType; + match spectrum_re.dtype() { + DType::F64 => { + let re_f32 = super::module::cast_to_f32::(spectrum_re, |v| v as f32); + let im_f32 = super::module::cast_to_f32::(spectrum_im, |v| v as f32); + let result = irfft_f32(re_f32, im_f32, dim, n); + super::module::cast_from_f32::(result, |v| v as f64) + } + _ => irfft_f32(spectrum_re, spectrum_im, dim, n), + } +} + +pub fn irfft_f16( + spectrum_re: FlexTensor, + spectrum_im: FlexTensor, + dim: usize, + n: Option, +) -> FlexTensor { + use burn_std::f16; + let re = super::module::cast_to_f32(spectrum_re, f16::to_f32); + let im = super::module::cast_to_f32(spectrum_im, f16::to_f32); + let result = irfft_f32(re, im, dim, n); + super::module::cast_from_f32(result, f16::from_f32) +} + +pub fn irfft_bf16( + spectrum_re: FlexTensor, + spectrum_im: FlexTensor, + dim: usize, + n: Option, +) -> FlexTensor { + use burn_std::bf16; + let re = super::module::cast_to_f32(spectrum_re, bf16::to_f32); + let im = super::module::cast_to_f32(spectrum_im, bf16::to_f32); + let result = irfft_f32(re, im, dim, n); + super::module::cast_from_f32(result, bf16::from_f32) +} + +// Tests kept here exercise flex-specific internals: the FFT kernels +// (`rfft_f32`/`_f64`/`_f16`, `irfft_*`, `complex_fft`, `inverse_complex_fft`) +// across sizes that span the radix-4 and complex packing paths (N=1, 2, 4, 8, +// 256, 1024, 4096), f16/f64 dtype handling, twiddle accuracy, Parseval's +// theorem on synthetic inputs, and a reference cross-check against realfft. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::{DType, TensorData, Tolerance}; + + fn make_f32(data: Vec, shape: Vec) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, shape)) + } + + fn make_f64(data: Vec, shape: Vec) -> FlexTensor { + FlexTensor::from_data(TensorData::new(data, shape)) + } + + fn assert_approx(tensor: FlexTensor, expected: &[f32], tol: f32) { + let shape = tensor.layout().shape().as_slice().to_vec(); + tensor.into_data().assert_approx_eq::( + &TensorData::new(expected.to_vec(), shape), + Tolerance::absolute(tol), + ); + } + + fn assert_approx_f64(tensor: FlexTensor, expected: &[f64], tol: f64) { + tensor + .into_data() + .assert_approx_eq::(&TensorData::from(expected), Tolerance::absolute(tol)); + } + + // ---- N=1 ---- + + #[test] + fn rfft_n1() { + let signal = make_f32(vec![5.0], vec![1]); + let (re, im) = rfft_f32(signal, 0, None); + assert_approx(re, &[5.0], 1e-6); + assert_approx(im, &[0.0], 1e-6); + } + + // ---- N=2 ---- + + #[test] + fn rfft_n2() { + let signal = make_f32(vec![1.0, -1.0], vec![2]); + let (re, im) = rfft_f32(signal, 0, None); + assert_approx(re, &[0.0, 2.0], 1e-6); + assert_approx(im, &[0.0, 0.0], 1e-6); + } + + // ---- N=4: known DFT of [1,0,0,0] = [1,1,1] (all real) ---- + + #[test] + fn rfft_n4_impulse() { + let signal = make_f32(vec![1.0, 0.0, 0.0, 0.0], vec![4]); + let (re, im) = rfft_f32(signal, 0, None); + assert_approx(re, &[1.0, 1.0, 1.0], 1e-6); + assert_approx(im, &[0.0, 0.0, 0.0], 1e-6); + } + + // ---- N=4: constant signal [1,1,1,1] -> DC only ---- + + #[test] + fn rfft_n4_constant() { + let signal = make_f32(vec![1.0, 1.0, 1.0, 1.0], vec![4]); + let (re, im) = rfft_f32(signal, 0, None); + assert_approx(re, &[4.0, 0.0, 0.0], 1e-6); + assert_approx(im, &[0.0, 0.0, 0.0], 1e-6); + } + + // ---- N=4: zeros ---- + + #[test] + fn rfft_n4_zeros() { + let signal = make_f32(vec![0.0; 4], vec![4]); + let (re, im) = rfft_f32(signal, 0, None); + assert_approx(re, &[0.0, 0.0, 0.0], 1e-6); + assert_approx(im, &[0.0, 0.0, 0.0], 1e-6); + } + + // ---- N=8 ---- + + #[test] + fn rfft_n8_impulse() { + let mut signal = vec![0.0f32; 8]; + signal[0] = 1.0; + let (re, im) = rfft_f32(make_f32(signal, vec![8]), 0, None); + // DFT of impulse is all 1s + assert_approx(re, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-6); + assert_approx(im, &[0.0, 0.0, 0.0, 0.0, 0.0], 1e-6); + } + + #[test] + fn rfft_n8_cosine() { + // cos(2*pi*k/8) for k=0..7 -> energy at bin 1 + let signal: Vec = (0..8) + .map(|k| (2.0 * std::f32::consts::PI * k as f32 / 8.0).cos()) + .collect(); + let (re, im) = rfft_f32(make_f32(signal, vec![8]), 0, None); + // Bin 1 should have amplitude 4 (real), rest ~0 + assert_approx(re, &[0.0, 4.0, 0.0, 0.0, 0.0], 1e-4); + assert_approx(im, &[0.0, 0.0, 0.0, 0.0, 0.0], 1e-4); + } + + // ---- Larger size: N=256 ---- + + #[test] + fn rfft_n256_impulse() { + let mut signal = vec![0.0f32; 256]; + signal[0] = 1.0; + let (re, im) = rfft_f32(make_f32(signal, vec![256]), 0, None); + let re_data = re.into_data(); + let im_data = im.into_data(); + let re_vals = re_data.as_slice::().unwrap(); + let im_vals = im_data.as_slice::().unwrap(); + assert_eq!(re_vals.len(), 129); + for &v in re_vals { + assert!((v - 1.0).abs() < 1e-5, "re bin should be 1.0, got {v}"); + } + for &v in im_vals { + assert!(v.abs() < 1e-5, "im bin should be 0.0, got {v}"); + } + } + + // ---- Multi-dimensional: FFT along dim 1 ---- + + #[test] + fn rfft_2d_dim1() { + // 2 rows, each of length 4: impulse and constant + let data = vec![ + 1.0, 0.0, 0.0, 0.0, // row 0: impulse + 1.0, 1.0, 1.0, 1.0, // row 1: constant + ]; + let signal = make_f32(data, vec![2, 4]); + let (re, im) = rfft_f32(signal, 1, None); + // Shape should be [2, 3] + let re_data = re.into_data(); + let im_data = im.into_data(); + let re_vals = re_data.as_slice::().unwrap(); + let im_vals = im_data.as_slice::().unwrap(); + assert_eq!(re_vals.len(), 6); // 2 * 3 + // Row 0 (impulse): [1, 1, 1] + assert!((re_vals[0] - 1.0).abs() < 1e-5); + assert!((re_vals[1] - 1.0).abs() < 1e-5); + assert!((re_vals[2] - 1.0).abs() < 1e-5); + // Row 1 (constant): [4, 0, 0] + assert!((re_vals[3] - 4.0).abs() < 1e-5); + assert!((re_vals[4]).abs() < 1e-5); + assert!((re_vals[5]).abs() < 1e-5); + // All imaginary should be ~0 + for &v in im_vals { + assert!(v.abs() < 1e-5); + } + } + + // ---- FFT along dim 0 ---- + + #[test] + fn rfft_2d_dim0() { + // 4 rows, 2 cols: impulse in each column + let data = vec![1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let signal = make_f32(data, vec![4, 2]); + let (re, _im) = rfft_f32(signal, 0, None); + // Shape should be [3, 2] + let re_data = re.into_data(); + let re_vals = re_data.as_slice::().unwrap(); + assert_eq!(re_vals.len(), 6); + // Each column is an impulse -> all bins = 1 + for &v in re_vals { + assert!((v - 1.0).abs() < 1e-5, "expected 1.0, got {v}"); + } + } + + // ---- f64 dtype ---- + + #[test] + fn rfft_f64_n4_impulse() { + let signal = make_f64(vec![1.0, 0.0, 0.0, 0.0], vec![4]); + let (re, im) = rfft_f64(signal, 0, None); + assert_approx_f64(re, &[1.0, 1.0, 1.0], 1e-10); + assert_approx_f64(im, &[0.0, 0.0, 0.0], 1e-10); + } + + #[test] + fn rfft_f64_n8_cosine() { + let signal: Vec = (0..8) + .map(|k| (2.0 * std::f64::consts::PI * k as f64 / 8.0).cos()) + .collect(); + let (re, im) = rfft_f64(make_f64(signal, vec![8]), 0, None); + assert_approx_f64(re, &[0.0, 4.0, 0.0, 0.0, 0.0], 1e-6); + assert_approx_f64(im, &[0.0, 0.0, 0.0, 0.0, 0.0], 1e-6); + } + + // ---- f16 dtype ---- + + #[test] + fn rfft_f16_n4_impulse() { + use burn_std::f16; + let f16_data = vec![ + f16::from_f32(1.0), + f16::from_f32(0.0), + f16::from_f32(0.0), + f16::from_f32(0.0), + ]; + let signal = FlexTensor::new( + Bytes::from_elems(f16_data), + Layout::contiguous(Shape::from(vec![4])), + DType::F16, + ); + let (re, _im) = rfft_f16(signal, 0, None); + // Verify via round-trip to f32 + let re_f32 = super::super::module::cast_to_f32(re, f16::to_f32); + let re_data = re_f32.into_data(); + let re_vals = re_data.as_slice::().unwrap(); + assert_eq!(re_vals.len(), 3); + for &v in re_vals { + assert!((v - 1.0).abs() < 0.01, "expected ~1.0, got {v}"); + } + } + + // ---- Const twiddle accuracy ---- + + #[test] + fn const_sin_cos_accuracy() { + let test_angles = [0.0, 0.1, 0.5, 1.0, 2.0, 3.0, -1.0, -3.0, 6.0]; + for &angle in &test_angles { + let cs = const_sin(angle); + let cc = const_cos(angle); + let rs = angle.sin(); + let rc = angle.cos(); + assert!( + (cs - rs).abs() < 1e-12, + "const_sin({angle}) = {cs}, expected {rs}" + ); + assert!( + (cc - rc).abs() < 1e-12, + "const_cos({angle}) = {cc}, expected {rc}" + ); + } + } + + // ---- N=1024 round-trip with known property: Parseval's theorem ---- + // Sum of |x|^2 = (1/N) * Sum of |X|^2 + + #[test] + fn rfft_n1024_parseval() { + let n = 1024; + let signal: Vec = (0..n).map(|i| (i as f32 * 0.37).sin()).collect(); + let time_energy: f64 = signal.iter().map(|&x| (x as f64) * (x as f64)).sum(); + + let (re, im) = rfft_f32(make_f32(signal, vec![n]), 0, None); + let re_data = re.into_data(); + let im_data = im.into_data(); + let re_vals = re_data.as_slice::().unwrap(); + let im_vals = im_data.as_slice::().unwrap(); + + // Frequency energy: DC and Nyquist count once, others count double + let out_len = n / 2 + 1; + let mut freq_energy = 0.0f64; + for k in 0..out_len { + let mag2 = (re_vals[k] as f64).powi(2) + (im_vals[k] as f64).powi(2); + if k == 0 || k == n / 2 { + freq_energy += mag2; + } else { + freq_energy += 2.0 * mag2; + } + } + freq_energy /= n as f64; + + let rel_err = (freq_energy - time_energy).abs() / time_energy; + assert!( + rel_err < 1e-4, + "Parseval's theorem violated: time={time_energy}, freq={freq_energy}, rel_err={rel_err}" + ); + } + + // ---- irfft tests ---- + + #[test] + fn irfft_roundtrip_n4() { + let signal = make_f32(vec![1.0, 2.0, 3.0, 4.0], vec![4]); + let (re, im) = rfft_f32(signal.clone(), 0, None); + let reconstructed = irfft_f32(re, im, 0, None); + assert_approx(reconstructed, &[1.0, 2.0, 3.0, 4.0], 1e-5); + } + + #[test] + fn irfft_roundtrip_n8() { + let data: Vec = (0..8).map(|i| (i as f32 * 0.3).sin()).collect(); + let signal = make_f32(data.clone(), vec![8]); + let (re, im) = rfft_f32(signal, 0, None); + let reconstructed = irfft_f32(re, im, 0, None); + assert_approx(reconstructed, &data, 1e-5); + } + + #[test] + fn rfft_vs_realfft() { + // Verify our rfft matches realfft (rustfft-backed) for non-trivial input + // at sizes that exercise radix-4 (n>=16) and the complex packing trick. + use realfft::RealFftPlanner; + + let mut planner = RealFftPlanner::::new(); + + for &n in &[4, 8, 16, 32, 64, 256, 1024, 4096] { + let data: Vec = (0..n).map(|i| (i as f32 * 0.37).sin() + 0.5).collect(); + + // Our rfft + let signal = make_f32(data.clone(), vec![n]); + let (re_out, im_out) = rfft_f32(signal, 0, None); + let re_data = re_out.into_data(); + let im_data = im_out.into_data(); + let our_re = re_data.as_slice::().unwrap(); + let our_im = im_data.as_slice::().unwrap(); + + // Reference: realfft + let r2c = planner.plan_fft_forward(n); + let mut input = data.clone(); + let mut spectrum = r2c.make_output_vec(); + r2c.process(&mut input, &mut spectrum).unwrap(); + + let out_len = n / 2 + 1; + assert_eq!(our_re.len(), out_len); + assert_eq!(spectrum.len(), out_len); + + let max_re_err = our_re + .iter() + .zip(spectrum.iter()) + .map(|(&a, b)| (a - b.re).abs()) + .fold(0.0f32, f32::max); + let max_im_err = our_im + .iter() + .zip(spectrum.iter()) + .map(|(&a, b)| (a - b.im).abs()) + .fold(0.0f32, f32::max); + assert!( + max_re_err < 1e-3 && max_im_err < 1e-3, + "rfft vs realfft mismatch at n={n}: max_re_err={max_re_err}, max_im_err={max_im_err}" + ); + } + } + + #[test] + fn irfft_vs_realfft() { + // Verify our irfft matches realfft's inverse for non-trivial spectra. + use realfft::RealFftPlanner; + + let mut planner = RealFftPlanner::::new(); + + for &n in &[4, 8, 16, 32, 64, 256, 1024, 4096] { + // Generate a spectrum via realfft forward + let r2c = planner.plan_fft_forward(n); + let c2r = planner.plan_fft_inverse(n); + let data: Vec = (0..n).map(|i| (i as f32 * 0.37).sin() + 0.5).collect(); + let mut input = data.clone(); + let mut spectrum = r2c.make_output_vec(); + r2c.process(&mut input, &mut spectrum).unwrap(); + + // Our irfft + let out_len = n / 2 + 1; + let spec_re: Vec = spectrum.iter().map(|c| c.re).collect(); + let spec_im: Vec = spectrum.iter().map(|c| c.im).collect(); + let re_tensor = make_f32(spec_re, vec![out_len]); + let im_tensor = make_f32(spec_im, vec![out_len]); + let our_result = irfft_f32(re_tensor, im_tensor, 0, None); + let our_data = our_result.into_data(); + let our_vals = our_data.as_slice::().unwrap(); + + // Reference: realfft inverse (note: realfft doesn't normalize, so scale) + let mut spec_copy = spectrum.clone(); + let mut ref_output = c2r.make_output_vec(); + c2r.process(&mut spec_copy, &mut ref_output).unwrap(); + let scale = 1.0 / n as f32; + let ref_scaled: Vec = ref_output.iter().map(|&v| v * scale).collect(); + + let max_err = our_vals + .iter() + .zip(ref_scaled.iter()) + .map(|(&a, &b)| (a - b).abs()) + .fold(0.0f32, f32::max); + assert!( + max_err < 1e-3, + "irfft vs realfft mismatch at n={n}: max_err={max_err}" + ); + } + } + + #[test] + fn forward_complex_fft_impulse() { + // DFT of impulse [1,0,0,...,0] should be all 1s + for &n in &[4, 8, 16, 32, 64] { + let tw = get_twiddles(n); + let mut re = vec![0.0f32; n]; + let mut im = vec![0.0f32; n]; + re[0] = 1.0; + complex_fft(&mut re, &mut im, n, &tw); + let max_re_err = re.iter().map(|&v| (v - 1.0).abs()).fold(0.0f32, f32::max); + let max_im_err = im.iter().map(|&v| v.abs()).fold(0.0f32, f32::max); + assert!( + max_re_err < 1e-5 && max_im_err < 1e-5, + "forward FFT impulse n={n}: max_re_err={max_re_err}, max_im_err={max_im_err}" + ); + } + } + + #[test] + fn inverse_complex_fft_roundtrip() { + for &n in &[4, 8, 16, 32, 64, 256] { + let tw = get_twiddles(n); + let mut re: Vec = (0..n).map(|i| (i as f32 * 0.3).sin()).collect(); + let mut im = vec![0.0f32; n]; + let orig_re = re.clone(); + + complex_fft(&mut re, &mut im, n, &tw); + inverse_complex_fft(&mut re, &mut im, n, &tw); + + let max_err = re + .iter() + .zip(orig_re.iter()) + .map(|(&got, &expected)| (got - expected).abs()) + .fold(0.0f32, f32::max); + assert!( + max_err < 1e-4, + "inverse_complex_fft roundtrip n={n}: max error {max_err}" + ); + } + } + + #[test] + fn irfft_roundtrip_n256() { + let data: Vec = (0..256).map(|i| (i as f32 * 0.1).cos()).collect(); + let signal = make_f32(data.clone(), vec![256]); + let (re, im) = rfft_f32(signal, 0, None); + let reconstructed = irfft_f32(re, im, 0, None); + let result = reconstructed.into_data(); + let vals = result.as_slice::().unwrap(); + let max_err = vals + .iter() + .zip(data.iter()) + .map(|(&got, &expected)| (got - expected).abs()) + .fold(0.0f32, f32::max); + assert!( + max_err < 5e-3, + "irfft_roundtrip_n256: max error {max_err} exceeds tolerance" + ); + } + + #[test] + fn irfft_roundtrip_2d_dim1() { + let data = vec![ + 1.0, 2.0, 3.0, 4.0, // row 0 + 5.0, 6.0, 7.0, 8.0, // row 1 + ]; + let signal = make_f32(data.clone(), vec![2, 4]); + let (re, im) = rfft_f32(signal, 1, None); + let reconstructed = irfft_f32(re, im, 1, None); + assert_approx(reconstructed, &data, 1e-5); + } + + #[test] + fn irfft_roundtrip_2d_dim0() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let signal = make_f32(data.clone(), vec![4, 2]); + let (re, im) = rfft_f32(signal, 0, None); + let reconstructed = irfft_f32(re, im, 0, None); + assert_approx(reconstructed, &data, 1e-5); + } + + #[test] + fn irfft_known_spectrum() { + // DC=4, all others zero -> constant signal [1,1,1,1] + let re = make_f32(vec![4.0, 0.0, 0.0], vec![3]); + let im = make_f32(vec![0.0, 0.0, 0.0], vec![3]); + let signal = irfft_f32(re, im, 0, None); + assert_approx(signal, &[1.0, 1.0, 1.0, 1.0], 1e-5); + } + + #[test] + fn irfft_f64_roundtrip() { + // irfft_f64 truncates to f32 internally, so tolerance is f32-level + let data: Vec = (0..8).map(|i| (i as f64 * 0.3).sin()).collect(); + let signal = make_f64(data.clone(), vec![8]); + let (re, im) = rfft_f64(signal, 0, None); + let reconstructed = irfft_f64(re, im, 0, None); + assert_approx_f64(reconstructed, &data, 1e-5); + } + + // Coverage for the n=Some(pow2) path on flex. + + #[test] + fn rfft_n_larger_than_signal_zero_pads() { + // n=8 zero-pads the signal; output has 8/2+1 = 5 bins. + let signal = make_f32(vec![1.0, 0.0, 0.0, 0.0], vec![4]); + let (re, im) = rfft_f32(signal, 0, Some(8)); + assert_eq!(re.layout().shape()[0], 5); + assert_eq!(im.layout().shape()[0], 5); + let re_vals = re.into_data().as_slice::().unwrap().to_vec(); + for (k, v) in re_vals.iter().enumerate() { + assert!( + (v - 1.0).abs() < 1e-5, + "impulse DFT re[{k}] should be 1.0, got {v}" + ); + } + } + + #[test] + fn rfft_n_smaller_than_signal_truncates_first() { + // Signal length 8, n=4 -> truncate to 4, compute 4-point DFT of [1,0,0,0]. + let signal = make_f32(vec![1.0, 0.0, 0.0, 0.0, 99.0, 99.0, 99.0, 99.0], vec![8]); + let (re, _im) = rfft_f32(signal, 0, Some(4)); + assert_eq!(re.layout().shape()[0], 3); + let re_vals = re.into_data().as_slice::().unwrap().to_vec(); + for v in &re_vals { + assert!((v - 1.0).abs() < 1e-5, "expected 1.0, got {v}"); + } + } + + #[test] + fn rfft_irfft_roundtrip_with_pow2_n() { + let data = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let signal = make_f32(data.clone(), vec![8]); + let (re, im) = rfft_f32(signal, 0, Some(8)); + let reconstructed = irfft_f32(re, im, 0, Some(8)); + assert_approx(reconstructed, &data, 1e-4); + } + + #[test] + fn rfft_f64_with_pow2_n_and_truncation() { + // Signal length 8, n=4. Output has 4/2+1 = 3 bins. + let data: Vec = (0..8).map(|i| (i as f64 * 0.3).sin()).collect(); + let signal = make_f64(data, vec![8]); + let (re, im) = rfft_f64(signal, 0, Some(4)); + assert_eq!(re.layout().shape()[0], 3); + assert_eq!(im.layout().shape()[0], 3); + } + + #[test] + fn rfft_vs_realfft_with_pow2_n_and_padding() { + use realfft::RealFftPlanner; + + let mut planner = RealFftPlanner::::new(); + // Signal shorter than n (pow2); backend zero-pads before the FFT. + for &(sig_len, n) in &[(3usize, 4usize), (5, 8), (6, 8), (9, 16)] { + let data: Vec = (0..sig_len) + .map(|i| (i as f32 * 0.41).cos() - 0.2) + .collect(); + let mut padded = data.clone(); + padded.resize(n, 0.0); + + let r2c = planner.plan_fft_forward(n); + let mut input = padded; + let mut ref_spec = r2c.make_output_vec(); + r2c.process(&mut input, &mut ref_spec).unwrap(); + + let signal = make_f32(data, vec![sig_len]); + let (re, im) = rfft_f32(signal, 0, Some(n)); + let re_v = re.into_data().as_slice::().unwrap().to_vec(); + let im_v = im.into_data().as_slice::().unwrap().to_vec(); + + assert_eq!(re_v.len(), n / 2 + 1); + for (k, refc) in ref_spec.iter().enumerate() { + let err_re = (re_v[k] - refc.re).abs(); + let err_im = (im_v[k] - refc.im).abs(); + assert!( + err_re < 1e-3 && err_im < 1e-3, + "sig_len={sig_len} n={n} bin={k}: got ({}, {}), ref ({}, {})", + re_v[k], + im_v[k], + refc.re, + refc.im + ); + } + } + } +} diff --git a/crates/burn-flex/src/ops/flip.rs b/crates/burn-flex/src/ops/flip.rs new file mode 100644 index 0000000..a423b9a --- /dev/null +++ b/crates/burn-flex/src/ops/flip.rs @@ -0,0 +1,43 @@ +//! Flip operation for reversing tensor elements along axes. +//! +//! With signed strides, flip is a zero-copy operation that simply negates +//! the stride and adjusts the start offset for each flipped axis. + +use crate::FlexTensor; + +/// Flip tensor elements along specified axes. +/// +/// This is a zero-copy operation using negative strides. +pub fn flip(tensor: FlexTensor, axes: &[usize]) -> FlexTensor { + if axes.is_empty() { + return tensor; + } + + let new_layout = tensor.layout().flip(axes); + tensor.with_layout(new_layout) +} + +// Tests kept here exercise flex-specific behavior: flip is a zero-copy +// stride-only operation in the flex backend, and the test below verifies +// the underlying buffer pointer is shared across the flip. Correctness +// tests for flip along various axes live in +// crates/burn-backend-tests/tests/tensor/{float,int,bool}/ops/flip.rs and +// run against every backend. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_flip_is_zero_copy() { + // Verify flip doesn't copy data by checking it shares the same underlying storage + let tensor = FlexTensor::from_data(TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0], [4])); + let tensor_ptr = tensor.bytes().as_ptr(); + let flipped = flip(tensor, &[0]); + let flipped_ptr = flipped.bytes().as_ptr(); + assert_eq!( + tensor_ptr, flipped_ptr, + "flip should share underlying storage" + ); + } +} diff --git a/crates/burn-flex/src/ops/float.rs b/crates/burn-flex/src/ops/float.rs new file mode 100644 index 0000000..36e6339 --- /dev/null +++ b/crates/burn-flex/src/ops/float.rs @@ -0,0 +1,1220 @@ +//! Float tensor operations for the Flex backend. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{ + DType, Distribution, ExecutionError, FloatDType, Scalar, TensorData, TensorMetadata, + ops::{FloatTensorOps, GridSampleOptions, IntTensorOps}, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{Bytes, IntDType, Shape, Slice, bf16, f16}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use crate::Layout; +use num_traits::ToPrimitive; + +use crate::ops::binary::{BinaryOp, binary_op, scalar_op}; +use crate::ops::matmul; +use crate::ops::unary; +use crate::{Flex, FlexTensor}; + +impl FloatTensorOps for Flex { + fn float_from_data(data: TensorData, _device: &Device) -> FloatTensor { + FlexTensor::from_data(data) + } + + fn float_random( + shape: Shape, + distribution: Distribution, + _device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + let mut seed = crate::backend::SEED.lock().unwrap(); + let mut rng = seed.take().unwrap_or_else(crate::backend::get_seeded_rng); + let data = match dtype { + FloatDType::F64 => TensorData::random::(shape, distribution, &mut rng), + FloatDType::F32 | FloatDType::Flex32 => { + TensorData::random::(shape, distribution, &mut rng) + } + FloatDType::F16 => TensorData::random::(shape, distribution, &mut rng), + FloatDType::BF16 => TensorData::random::(shape, distribution, &mut rng), + }; + *seed = Some(rng); + FlexTensor::from_data(data) + } + + async fn float_into_data(tensor: FloatTensor) -> Result { + Ok(tensor.into_data()) + } + + fn float_to_device(tensor: FloatTensor, _device: &Device) -> FloatTensor { + // CPU backend: no-op, tensors are always on CPU + tensor + } + + fn float_detach(tensor: FloatTensor) -> FloatTensor { + tensor + } + + fn float_into_int(tensor: FloatTensor, out_dtype: burn_std::IntDType) -> IntTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let src = tensor.dtype(); + let out_dt = DType::from(out_dtype); + + // Read source floats as f64 (lossless for f32/f16/bf16). + macro_rules! read_floats { + (|$x:ident| $conv:expr) => { + match src { + DType::F32 => tensor + .storage::() + .iter() + .map(|v| { + let $x = *v as f64; + $conv + }) + .collect(), + DType::F64 => tensor + .storage::() + .iter() + .map(|v| { + let $x = *v; + $conv + }) + .collect(), + DType::F16 => tensor + .storage::() + .iter() + .map(|v| { + let $x = f32::from(*v) as f64; + $conv + }) + .collect(), + DType::BF16 => tensor + .storage::() + .iter() + .map(|v| { + let $x = f32::from(*v) as f64; + $conv + }) + .collect(), + _ => panic!("float_into_int: unsupported source dtype {:?}", src), + } + }; + } + + macro_rules! convert { + ($int_ty:ty) => {{ + let data: Vec<$int_ty> = read_floats!(|x| x as $int_ty); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + }}; + } + + match out_dtype { + IntDType::I64 => convert!(i64), + IntDType::I32 => convert!(i32), + IntDType::I16 => convert!(i16), + IntDType::I8 => convert!(i8), + IntDType::U64 => convert!(u64), + IntDType::U32 => convert!(u32), + IntDType::U16 => convert!(u16), + IntDType::U8 => convert!(u8), + } + } + + fn float_empty(shape: Shape, _device: &Device, dtype: FloatDType) -> FloatTensor { + FlexTensor::empty(shape, dtype.into()) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op(lhs, rhs, |a, b| a + b, |a, b| a + b, Some(BinaryOp::Add)) + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let rhs_val = rhs.to_f64().unwrap(); + scalar_op(lhs, rhs_val, |a, b| a + b, |a, b| a + b) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op(lhs, rhs, |a, b| a - b, |a, b| a - b, Some(BinaryOp::Sub)) + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let rhs_val = rhs.to_f64().unwrap(); + scalar_op(lhs, rhs_val, |a, b| a - b, |a, b| a - b) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op(lhs, rhs, |a, b| a * b, |a, b| a * b, Some(BinaryOp::Mul)) + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let rhs_val = rhs.to_f64().unwrap(); + scalar_op(lhs, rhs_val, |a, b| a * b, |a, b| a * b) + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op(lhs, rhs, |a, b| a / b, |a, b| a / b, Some(BinaryOp::Div)) + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let rhs_val = rhs.to_f64().unwrap(); + scalar_op(lhs, rhs_val, |a, b| a / b, |a, b| a / b) + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + // Python/PyTorch-style remainder: result has same sign as divisor + binary_op( + lhs, + rhs, + |a, b| ((a % b) + b) % b, + |a, b| ((a % b) + b) % b, + None, + ) + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let rhs_val = rhs.to_f64().unwrap(); + // Python/PyTorch-style remainder: result has same sign as divisor + scalar_op( + lhs, + rhs_val, + |a, b| ((a % b) + b) % b, + |a, b| ((a % b) + b) % b, + ) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + matmul::matmul(lhs, rhs) + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + let shape = lhs.layout().shape(); + let ndims = shape.num_dims(); + assert_eq!( + shape[dim], 3, + "cross product requires dimension {} to have size 3, got {}", + dim, shape[dim] + ); + + // Helper to create slices that select index `idx` along `dim` + let make_slices = |idx: usize| -> alloc::vec::Vec { + (0..ndims) + .map(|d| { + if d == dim { + Slice::new(idx as isize, Some((idx + 1) as isize), 1) + } else { + Slice::new(0, None, 1) + } + }) + .collect() + }; + + // Extract components along the dimension + // a = [a0, a1, a2], b = [b0, b1, b2] + let a0 = Self::float_slice(lhs.clone(), &make_slices(0)); + let a1 = Self::float_slice(lhs.clone(), &make_slices(1)); + let a2 = Self::float_slice(lhs, &make_slices(2)); + + let b0 = Self::float_slice(rhs.clone(), &make_slices(0)); + let b1 = Self::float_slice(rhs.clone(), &make_slices(1)); + let b2 = Self::float_slice(rhs, &make_slices(2)); + + // Cross product: c = a × b + // c0 = a1*b2 - a2*b1 + // c1 = a2*b0 - a0*b2 + // c2 = a0*b1 - a1*b0 + let c0 = Self::float_sub( + Self::float_mul(a1.clone(), b2.clone()), + Self::float_mul(a2.clone(), b1.clone()), + ); + let c1 = Self::float_sub( + Self::float_mul(a2, b0.clone()), + Self::float_mul(a0.clone(), b2), + ); + let c2 = Self::float_sub(Self::float_mul(a0, b1), Self::float_mul(a1, b0)); + + // Concatenate along the dimension + Self::float_cat(vec![c0, c1, c2], dim) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + unary::recip(tensor) + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + tensor.transpose(dim1, dim2) + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + tensor.permute(axes) + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + crate::ops::flip::flip(tensor, axes) + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + crate::ops::cat::cat(tensors, dim) + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + tensor.reshape(shape) + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::F64 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::F16 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::BF16 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + _ => panic!("float_gather: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::F64 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::F16 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::BF16 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + _ => panic!("float_scatter_add: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + match data.dtype() { + DType::F32 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::F64 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::F16 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::BF16 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + _ => panic!("float_scatter_nd: unsupported dtype {:?}", data.dtype()), + } + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + match data.dtype() { + DType::F32 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::F64 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::F16 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::BF16 => crate::ops::gather_scatter::gather_nd::(data, indices), + _ => panic!("float_gather_nd: unsupported dtype {:?}", data.dtype()), + } + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::F64 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::F16 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::BF16 => crate::ops::gather_scatter::select::(tensor, dim, indices), + _ => panic!("float_select: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::F64 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::F16 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::BF16 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + _ => panic!("float_select_add: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + crate::ops::slice::slice(tensor, slices) + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[Slice], + value: FloatTensor, + ) -> FloatTensor { + crate::ops::slice::slice_assign(tensor, slices, value) + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::mask::mask_where_f32(tensor, mask, value), + DType::F64 => crate::ops::mask::mask_where_f64(tensor, mask, value), + DType::F16 => crate::ops::mask::mask_where_f16(tensor, mask, value), + DType::BF16 => crate::ops::mask::mask_where_bf16(tensor, mask, value), + dtype => panic!("float_mask_where: unsupported dtype {:?}", dtype), + } + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::mask::mask_fill_f32(tensor, mask, value.to_f32().unwrap()), + DType::F64 => crate::ops::mask::mask_fill_f64(tensor, mask, value.to_f64().unwrap()), + DType::F16 => crate::ops::mask::mask_fill_f16( + tensor, + mask, + f16::from_f64(value.to_f64().unwrap()), + ), + DType::BF16 => crate::ops::mask::mask_fill_bf16( + tensor, + mask, + bf16::from_f64(value.to_f64().unwrap()), + ), + dtype => panic!("float_mask_fill: unsupported dtype {:?}", dtype), + } + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::equal(lhs, rhs, out_dtype) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::equal_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::greater(lhs, rhs, out_dtype) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::greater_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::greater_equal(lhs, rhs, out_dtype) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::greater_equal_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::lower(lhs, rhs, out_dtype) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::lower_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::lower_equal(lhs, rhs, out_dtype) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::lower_equal_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_not_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::not_equal(lhs, rhs, out_dtype) + } + + fn float_not_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::not_equal_elem(lhs, rhs.to_f64().unwrap(), out_dtype) + } + + fn float_neg(tensor: FloatTensor) -> FloatTensor { + unary::unary_op(tensor, |x: f32| -x, |x: f64| -x) + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + let min32 = min.to_f32().unwrap(); + let max32 = max.to_f32().unwrap(); + let min64 = min.to_f64().unwrap(); + let max64 = max.to_f64().unwrap(); + unary::unary_op( + tensor, + move |x: f32| x.clamp(min32, max32), + move |x: f64| x.clamp(min64, max64), + ) + } + + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + let min32 = min.to_f32().unwrap(); + let min64 = min.to_f64().unwrap(); + unary::unary_op( + tensor, + move |x: f32| x.max(min32), + move |x: f64| x.max(min64), + ) + } + + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + let max32 = max.to_f32().unwrap(); + let max64 = max.to_f64().unwrap(); + unary::unary_op( + tensor, + move |x: f32| x.min(max32), + move |x: f64| x.min(max64), + ) + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + unary::unary_op( + tensor, + |x: f32| { + if x.is_nan() { + x + } else if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + } + }, + |x: f64| { + if x.is_nan() { + x + } else if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + } + }, + ) + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + crate::ops::reduce::mean(tensor) + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + crate::ops::reduce::max(tensor) + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + crate::ops::reduce::max_dim(tensor, dim) + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + crate::ops::reduce::min(tensor) + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + crate::ops::reduce::min_dim(tensor, dim) + } + + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: burn_std::IntDType, + ) -> (FloatTensor, IntTensor) { + let (values, indices) = crate::ops::reduce::max_dim_with_indices(tensor, dim); + if indices.dtype() != DType::from(indices_dtype) { + (values, Flex::int_cast(indices, indices_dtype)) + } else { + (values, indices) + } + } + + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: burn_std::IntDType, + ) -> (FloatTensor, IntTensor) { + let (values, indices) = crate::ops::reduce::min_dim_with_indices(tensor, dim); + if indices.dtype() != DType::from(indices_dtype) { + (values, Flex::int_cast(indices, indices_dtype)) + } else { + (values, indices) + } + } + + fn float_any(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + crate::ops::comparison::any_float(tensor, out_dtype) + } + + fn float_any_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::any_float_dim(tensor, dim, out_dtype) + } + + fn float_all(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + crate::ops::comparison::all_float(tensor, out_dtype) + } + + fn float_all_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::all_float_dim(tensor, dim, out_dtype) + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + crate::ops::reduce::sum(tensor) + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + crate::ops::reduce::sum_dim(tensor, dim) + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + crate::ops::reduce::mean_dim(tensor, dim) + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + crate::ops::reduce::prod(tensor) + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + crate::ops::reduce::prod_dim(tensor, dim) + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::cumulative::cumsum_f32(tensor, dim), + DType::F64 => crate::ops::cumulative::cumsum_f64(tensor, dim), + DType::F16 => { + crate::ops::cumulative::cumsum_half(tensor, dim, f16::to_f32, f16::from_f32) + } + DType::BF16 => { + crate::ops::cumulative::cumsum_half(tensor, dim, bf16::to_f32, bf16::from_f32) + } + _ => panic!("float_cumsum: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::cumulative::cumprod_f32(tensor, dim), + DType::F64 => crate::ops::cumulative::cumprod_f64(tensor, dim), + DType::F16 => { + crate::ops::cumulative::cumprod_half(tensor, dim, f16::to_f32, f16::from_f32) + } + DType::BF16 => { + crate::ops::cumulative::cumprod_half(tensor, dim, bf16::to_f32, bf16::from_f32) + } + _ => panic!("float_cumprod: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::cumulative::cummin_f32(tensor, dim), + DType::F64 => crate::ops::cumulative::cummin_f64(tensor, dim), + DType::F16 => { + crate::ops::cumulative::cummin_half(tensor, dim, f16::to_f32, f16::from_f32) + } + DType::BF16 => { + crate::ops::cumulative::cummin_half(tensor, dim, bf16::to_f32, bf16::from_f32) + } + _ => panic!("float_cummin: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + match tensor.dtype() { + DType::F32 => crate::ops::cumulative::cummax_f32(tensor, dim), + DType::F64 => crate::ops::cumulative::cummax_f64(tensor, dim), + DType::F16 => { + crate::ops::cumulative::cummax_half(tensor, dim, f16::to_f32, f16::from_f32) + } + DType::BF16 => { + crate::ops::cumulative::cummax_half(tensor, dim, bf16::to_f32, bf16::from_f32) + } + _ => panic!("float_cummax: unsupported dtype {:?}", tensor.dtype()), + } + } + + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor { + use crate::Layout; + use burn_std::{Bytes, bf16, f16}; + + let src_dtype = tensor.dtype(); + let target_dtype = DType::from(dtype); + + // No-op if already the same dtype + if src_dtype == target_dtype { + return tensor; + } + + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + + // Convert to f64 intermediate, then to target + let f64_values: Vec = match src_dtype { + DType::F32 => { + let src: &[f32] = tensor.storage(); + src.iter().map(|&v| v as f64).collect() + } + DType::F64 => { + let src: &[f64] = tensor.storage(); + src.to_vec() + } + DType::F16 => { + let src: &[f16] = tensor.storage(); + src.iter().map(|&v| v.to_f32() as f64).collect() + } + DType::BF16 => { + let src: &[bf16] = tensor.storage(); + src.iter().map(|&v| v.to_f32() as f64).collect() + } + _ => panic!("float_cast: unsupported source dtype {:?}", src_dtype), + }; + + // Convert from f64 to target dtype + match target_dtype { + DType::F32 => { + let result: Vec = f64_values.iter().map(|&v| v as f32).collect(); + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::F32) + } + DType::F64 => { + let bytes = Bytes::from_elems(f64_values); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::F64) + } + DType::F16 => { + let result: Vec = f64_values.iter().map(|&v| f16::from_f64(v)).collect(); + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::F16) + } + DType::BF16 => { + let result: Vec = f64_values.iter().map(|&v| bf16::from_f64(v)).collect(); + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::BF16) + } + _ => panic!("float_cast: unsupported target dtype {:?}", target_dtype), + } + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + unary::exp(tensor) + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + unary::log(tensor) + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + unary::log1p(tensor) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op(lhs, rhs, |a: f32, b| a.powf(b), |a: f64, b| a.powf(b), None) + } + + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor { + let exp = value.to_f64().unwrap(); + scalar_op(tensor, exp, |a: f32, b| a.powf(b), |a: f64, b| a.powf(b)) + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + unary::sqrt(tensor) + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + unary::abs(tensor) + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + unary::cos(tensor) + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + unary::sin(tensor) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + unary::tan(tensor) + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + unary::cosh(tensor) + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + unary::sinh(tensor) + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + unary::tanh(tensor) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + unary::acos(tensor) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + unary::acosh(tensor) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + unary::asin(tensor) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + unary::asinh(tensor) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + unary::atan(tensor) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + unary::atanh(tensor) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op( + lhs, + rhs, + |a: f32, b| a.atan2(b), + |a: f64, b| a.atan2(b), + None, + ) + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + unary::round(tensor) + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + unary::floor(tensor) + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + unary::ceil(tensor) + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + unary::trunc(tensor) + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + unary::erf(tensor) + } + + fn float_argmax( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::IntDType, + ) -> IntTensor { + let result = crate::ops::reduce::argmax(tensor, dim); + if result.dtype() != DType::from(out_dtype) { + Flex::int_cast(result, out_dtype) + } else { + result + } + } + + fn float_argtopk( + _tensor: FloatTensor, + _dim: usize, + _k: usize, + _out_dtype: burn_std::IntDType, + ) -> IntTensor { + unimplemented!("float_argtopk not implemented for flex") + } + + fn float_argmin( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::IntDType, + ) -> IntTensor { + let result = crate::ops::reduce::argmin(tensor, dim); + if result.dtype() != DType::from(out_dtype) { + Flex::int_cast(result, out_dtype) + } else { + result + } + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + crate::ops::expand::expand(tensor, shape) + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + // unfold is now type-agnostic (zero-copy strided view) + crate::ops::unfold::unfold(tensor, dim, size, step) + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, + ) -> FloatTensor { + crate::ops::grid_sample::grid_sample_2d(tensor, grid, options) + } + + fn float_zeros(shape: Shape, _device: &Device, dtype: FloatDType) -> FloatTensor { + FlexTensor::zeros(shape, dtype.into()) + } + + fn float_ones(shape: Shape, _device: &Device, dtype: FloatDType) -> FloatTensor { + let dt: burn_backend::DType = dtype.into(); + match dt { + DType::F32 => FlexTensor::filled_typed(shape, dt, 1.0f32), + DType::F64 => FlexTensor::filled_typed(shape, dt, 1.0f64), + DType::F16 => FlexTensor::filled_typed(shape, dt, f16::ONE), + DType::BF16 => FlexTensor::filled_typed(shape, dt, bf16::ONE), + _ => unreachable!(), + } + } + + fn float_full( + shape: Shape, + fill_value: Scalar, + _device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + let dt: burn_backend::DType = dtype.into(); + match dt { + DType::F32 => FlexTensor::filled_typed(shape, dt, fill_value.to_f32().unwrap()), + DType::F64 => FlexTensor::filled_typed(shape, dt, fill_value.to_f64().unwrap()), + DType::F16 => { + FlexTensor::filled_typed(shape, dt, f16::from_f32(fill_value.to_f32().unwrap())) + } + DType::BF16 => { + FlexTensor::filled_typed(shape, dt, bf16::from_f32(fill_value.to_f32().unwrap())) + } + _ => unreachable!(), + } + } + + fn float_transpose(tensor: FloatTensor) -> FloatTensor { + let ndims = tensor.layout().num_dims(); + if ndims < 2 { + return tensor; + } + tensor.transpose(ndims - 2, ndims - 1) + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + crate::ops::repeat_dim::repeat_dim(tensor, dim, times) + } + + fn float_sort(tensor: FloatTensor, dim: usize, descending: bool) -> FloatTensor { + crate::ops::sort::sort(tensor, dim, descending) + } + + fn float_sort_with_indices( + tensor: FloatTensor, + dim: usize, + descending: bool, + indices_dtype: burn_std::IntDType, + ) -> (FloatTensor, IntTensor) { + let (values, indices) = crate::ops::sort::sort_with_indices(tensor, dim, descending); + let indices = if indices.dtype() != DType::from(indices_dtype) { + Flex::int_cast(indices, indices_dtype) + } else { + indices + }; + (values, indices) + } + + fn float_argsort( + tensor: FloatTensor, + dim: usize, + descending: bool, + out_dtype: burn_std::IntDType, + ) -> IntTensor { + let indices = crate::ops::sort::argsort(tensor, dim, descending); + if indices.dtype() != DType::from(out_dtype) { + Flex::int_cast(indices, out_dtype) + } else { + indices + } + } + + fn float_powi(lhs: FloatTensor, rhs: IntTensor) -> FloatTensor { + let dtype = lhs.dtype(); + Self::float_powf(lhs, Flex::int_into_float(rhs, dtype.into())) + } + + fn float_powi_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + match rhs.to_i64().unwrap() { + 0 => Self::float_ones(lhs.shape(), &Default::default(), lhs.dtype().into()), + 1 => lhs, + 2 => Self::float_mul(lhs.clone(), lhs), + -1 => Self::float_recip(lhs), + -2 => Self::float_recip(Self::float_mul(lhs.clone(), lhs)), + _ => Self::float_powf_scalar_impl(lhs, rhs), + } + } + + fn float_powf_scalar(tensor: FloatTensor, value: Scalar) -> FloatTensor { + if let Some(exp) = value.try_as_integer() { + Self::float_powi_scalar(tensor, exp) + } else { + Self::float_powf_scalar_impl(tensor, value) + } + } + + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + let abs = unary::abs(tensor); + crate::ops::reduce::max(abs) + } + + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let abs = unary::abs(tensor); + crate::ops::reduce::max_dim(abs, dim) + } + + fn float_is_nan(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + unary::float_predicate(tensor, out_dtype, |x: f32| x.is_nan(), |x: f64| x.is_nan()) + } + + fn float_is_inf(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + unary::float_predicate( + tensor, + out_dtype, + |x: f32| x.is_infinite(), + |x: f64| x.is_infinite(), + ) + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_op( + lhs, + rhs, + |a: f32, b| a.hypot(b), + |a: f64, b| a.hypot(b), + None, + ) + } +} + +// Tests kept here exercise flex-specific behavior: direct `Flex::` +// backend-op calls with explicit IntDType/FloatDType to pin dtype storage +// selection (U8/I32/I64, F16/F64). Plain arithmetic, math, cast, cross, +// unfold, and random smoke tests have been dropped in favor of the +// equivalent coverage in burn-backend-tests, which exercises every backend. +// When adding new tests, keep them here only if they probe flex dtype +// storage or flex internals; otherwise add them to +// crates/burn-backend-tests/tests/tensor/float/ops/. +#[cfg(test)] +mod tests { + use burn_backend::TensorData; + + use crate::Flex; + + #[test] + fn test_float_into_int_i32() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([1.5f32, -2.7, 0.0, 255.9])); + let result = Flex::float_into_int(t, IntDType::I32); + assert_eq!(result.dtype(), burn_backend::DType::I32); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1, -2, 0, 255]); + } + + #[test] + fn test_float_into_int_u8() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([0.0f32, 1.9, 127.5, 255.0])); + let result = Flex::float_into_int(t, IntDType::U8); + assert_eq!(result.dtype(), burn_backend::DType::U8); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![0, 1, 127, 255]); + } + + #[test] + fn test_float_argmax_i32_out_dtype() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([[1.0f32, 3.0, 2.0]])); + let result = Flex::float_argmax(t, 1, IntDType::I32); + assert_eq!(result.dtype(), burn_backend::DType::I32); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1]); + } + + #[test] + fn test_float_argmin_i32_out_dtype() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([[3.0f32, 1.0, 2.0]])); + let result = Flex::float_argmin(t, 1, IntDType::I32); + assert_eq!(result.dtype(), burn_backend::DType::I32); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1]); + } + + #[test] + fn test_float_argmax_i64_out_dtype() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([[1.0f32, 3.0, 2.0]])); + let result = Flex::float_argmax(t, 1, IntDType::I64); + assert_eq!(result.dtype(), burn_backend::DType::I64); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1]); + } + + #[test] + fn test_float_max_dim_with_indices_i32() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([[1.0f32, 5.0], [3.0, 2.0]])); + let (values, indices) = Flex::float_max_dim_with_indices(t, 1, IntDType::I32); + assert_eq!(indices.dtype(), burn_backend::DType::I32); + let idx: Vec = indices.into_data().to_vec().unwrap(); + assert_eq!(idx, vec![1, 0]); + let vals: Vec = values.into_data().to_vec().unwrap(); + assert_eq!(vals, vec![5.0, 3.0]); + } + + #[test] + fn test_float_min_dim_with_indices_i32() { + use burn_backend::ops::FloatTensorOps; + use burn_std::IntDType; + + let t = crate::FlexTensor::from_data(TensorData::from([[1.0f32, 5.0], [3.0, 2.0]])); + let (values, indices) = Flex::float_min_dim_with_indices(t, 1, IntDType::I32); + assert_eq!(indices.dtype(), burn_backend::DType::I32); + let idx: Vec = indices.into_data().to_vec().unwrap(); + assert_eq!(idx, vec![0, 1]); + let vals: Vec = values.into_data().to_vec().unwrap(); + assert_eq!(vals, vec![1.0, 2.0]); + } + + #[test] + fn test_float_random_f64() { + use burn_backend::{DType, FloatDType, ops::FloatTensorOps}; + + let shape = burn_std::Shape::from(vec![100]); + let dist = burn_backend::Distribution::Uniform(0.0, 1.0); + let device = crate::FlexDevice; + let t = Flex::float_random(shape, dist, &device, FloatDType::F64); + assert_eq!(t.dtype(), DType::F64); + let data: Vec = t.into_data().to_vec().unwrap(); + assert!(data.iter().all(|&v| (0.0..=1.0).contains(&v))); + } + + #[test] + fn test_float_random_f16() { + use burn_backend::{DType, FloatDType, ops::FloatTensorOps}; + + let shape = burn_std::Shape::from(vec![100]); + let dist = burn_backend::Distribution::Uniform(0.0, 1.0); + let device = crate::FlexDevice; + let t = Flex::float_random(shape, dist, &device, FloatDType::F16); + assert_eq!(t.dtype(), DType::F16); + } +} diff --git a/crates/burn-flex/src/ops/gather_scatter.rs b/crates/burn-flex/src/ops/gather_scatter.rs new file mode 100644 index 0000000..8fef585 --- /dev/null +++ b/crates/burn-flex/src/ops/gather_scatter.rs @@ -0,0 +1,1251 @@ +//! Gather and scatter operations for indexed tensor access. + +use alloc::borrow::Cow; +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape}; +use bytemuck::Pod; + +#[cfg(feature = "rayon")] +use rayon::prelude::*; + +use crate::{FlexTensor, Layout}; + +/// Read indices from a tensor as `isize`, the native offset type used by the +/// gather/scatter/select kernels in this module. +/// +/// This is the internal index layer for burn-flex: every indexed op +/// ([`gather`], [`scatter_add`], [`select`], [`select_add`], and the +/// [`scatter_min`]/[`scatter_max`] variants) routes its index tensor through +/// this helper before touching the element buffer. Normalising to `isize` +/// lets the kernels use a single inner-loop signature regardless of how the +/// caller's index tensor was dtyped. +/// +/// # Accepted widths +/// +/// Any of the integer DTypes `I8`, `I16`, `I32`, `I64`, `U8`, `U16`, `U32`, +/// `U64` is accepted. This is intentional: burn-flex's default `IntElem` is +/// I32 rather than the I64 convention used by other backends, and users can +/// also pin index tensors to any width they want via +/// `Tensor::from_data(.., (&device, DType::Ix))`. Whichever width lands here +/// is converted to `isize` on the fly. +/// +/// # Zero-copy vs. owned +/// +/// The return type is `Cow<'_, [isize]>` because only one width is zero-copy: +/// the one matching the host pointer width. On 64-bit targets, I64 indices +/// can be borrowed directly via `bytemuck::cast_slice` (both are 8 bytes). +/// Every other width requires an owned `Vec` with an element-wise +/// cast. U64 indices additionally go through a `try_from` to surface values +/// that would wrap when cast to `isize`. +/// +/// # History +/// +/// Earlier versions of `int_gather`, `int_scatter_add`, `int_select`, and +/// `int_select_add` carried a `debug_assert_eq!(indices.dtype(), DType::I64, +/// ..)` that contradicted this helper's contract. The asserts were dropped +/// in tracel-ai/burn#4776 once it was confirmed that `read_indices` had +/// always handled every supported width correctly at runtime. If you're +/// tempted to re-add a dtype check here, don't - the float siblings +/// ([`gather_f32`], [`select_f32`], ...) already share this helper without a +/// check, and asymmetry between the int and float paths was what surfaced +/// the bug. +fn read_indices(tensor: &FlexTensor) -> Cow<'_, [isize]> { + match tensor.dtype() { + #[cfg(target_pointer_width = "64")] + DType::I64 => { + const { assert!(size_of::() == size_of::()) }; + let data = tensor.storage::(); + Cow::Borrowed(bytemuck::cast_slice(data)) + } + #[cfg(target_pointer_width = "32")] + DType::I64 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| { + isize::try_from(v).unwrap_or_else(|_| { + panic!("read_indices: i64 index {v} out of isize range") + }) + }) + .collect(), + ), + #[cfg(target_pointer_width = "64")] + DType::I32 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| v as isize) + .collect(), + ), + #[cfg(target_pointer_width = "32")] + DType::I32 => { + const { assert!(size_of::() == size_of::()) }; + let data = tensor.storage::(); + Cow::Borrowed(bytemuck::cast_slice(data)) + } + DType::I16 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| v as isize) + .collect(), + ), + DType::I8 => Cow::Owned(tensor.storage::().iter().map(|&v| v as isize).collect()), + DType::U64 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| { + isize::try_from(v).unwrap_or_else(|_| { + panic!("read_indices: u64 index {v} out of isize range") + }) + }) + .collect(), + ), + #[cfg(target_pointer_width = "64")] + DType::U32 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| v as isize) + .collect(), + ), + #[cfg(target_pointer_width = "32")] + DType::U32 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| { + isize::try_from(v).unwrap_or_else(|_| { + panic!("read_indices: u32 index {v} out of isize range") + }) + }) + .collect(), + ), + DType::U16 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|&v| v as isize) + .collect(), + ), + DType::U8 => Cow::Owned(tensor.storage::().iter().map(|&v| v as isize).collect()), + other => panic!("read_indices: unsupported index dtype {:?}", other), + } +} + +#[cold] +#[inline(never)] +fn index_oob(raw: isize, dim_size: usize) -> ! { + panic!("index {raw} out of bounds for dimension of size {dim_size}"); +} + +/// Validate an index is non-negative and within bounds, panicking with a clear message otherwise. +#[inline(always)] +fn checked_index(raw: isize, dim_size: usize) -> usize { + if raw < 0 || raw as usize >= dim_size { + index_oob(raw, dim_size); + } + raw as usize +} + +/// Gather values from tensor along a dimension using indices. +/// +/// For a 2D tensor with dim=1: +/// output[i, j] = tensor[i, indices[i, j]] +/// +/// The output has the same shape as indices. +pub fn gather( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let indices = indices.to_contiguous(); + + let tensor_shape = tensor.layout().shape(); + let indices_shape = indices.layout().shape(); + let ndims = tensor_shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + // Validate shapes: all dims except `dim` must match between tensor and indices + for i in 0..ndims { + if i != dim { + assert_eq!( + tensor_shape[i], indices_shape[i], + "gather: shape mismatch at dim {}: tensor {} vs indices {}", + i, tensor_shape[i], indices_shape[i] + ); + } + } + + let tensor_data: &[E] = tensor.storage(); + let indices_data = read_indices(&indices); + + // Calculate strides for tensor (row-major) + let tensor_strides: Vec = compute_strides(tensor_shape); + let indices_strides: Vec = compute_strides(indices_shape); + + let output_size = indices_shape.num_elements(); + + // Use specialized 2D implementation for common case + if ndims == 2 { + let result = gather_2d::( + tensor_data, + &indices_data, + tensor_shape[0], + tensor_shape[1], + indices_shape[0], + indices_shape[1], + dim, + ); + let bytes = Bytes::from_elems(result); + return FlexTensor::new(bytes, Layout::contiguous(indices_shape.clone()), E::dtype()); + } + + // General N-D case with pre-allocated coordinates + let dim_stride = tensor_strides[dim]; + + let gather_dim_size = tensor_shape[dim]; + + #[cfg(feature = "rayon")] + let result: Vec = (0..output_size) + .into_par_iter() + .map(|out_idx| { + let index_val = checked_index(indices_data[out_idx], gather_dim_size); + let src_idx = compute_gather_index( + out_idx, + index_val, + dim, + dim_stride, + &indices_strides, + &tensor_strides, + ndims, + ); + tensor_data[src_idx] + }) + .collect(); + + #[cfg(not(feature = "rayon"))] + let result: Vec = (0..output_size) + .map(|out_idx| { + let index_val = checked_index(indices_data[out_idx], gather_dim_size); + let src_idx = compute_gather_index( + out_idx, + index_val, + dim, + dim_stride, + &indices_strides, + &tensor_strides, + ndims, + ); + tensor_data[src_idx] + }) + .collect(); + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(indices_shape.clone()), E::dtype()) +} + +/// Optimized 2D gather implementation. +#[inline] +fn gather_2d( + tensor_data: &[E], + indices_data: &[isize], + tensor_rows: usize, + tensor_cols: usize, + indices_rows: usize, + indices_cols: usize, + dim: usize, +) -> Vec { + let output_size = indices_rows * indices_cols; + let dim_size = if dim == 0 { tensor_rows } else { tensor_cols }; + + let mut result = vec![E::default(); output_size]; + + #[cfg(feature = "rayon")] + const PARALLEL_THRESHOLD: usize = 256 * 1024; + + #[cfg(feature = "rayon")] + if output_size >= PARALLEL_THRESHOLD { + if dim == 0 { + result + .par_chunks_mut(indices_cols) + .enumerate() + .for_each(|(i, row)| { + for j in 0..indices_cols { + let src_row = checked_index(indices_data[i * indices_cols + j], dim_size); + row[j] = tensor_data[src_row * tensor_cols + j]; + } + }); + } else { + result + .par_chunks_mut(indices_cols) + .enumerate() + .for_each(|(i, row)| { + for j in 0..indices_cols { + let src_col = checked_index(indices_data[i * indices_cols + j], dim_size); + row[j] = tensor_data[i * tensor_cols + src_col]; + } + }); + } + } else if dim == 0 { + for i in 0..indices_rows { + for j in 0..indices_cols { + let src_row = checked_index(indices_data[i * indices_cols + j], dim_size); + result[i * indices_cols + j] = tensor_data[src_row * tensor_cols + j]; + } + } + } else { + for i in 0..indices_rows { + for j in 0..indices_cols { + let src_col = checked_index(indices_data[i * indices_cols + j], dim_size); + result[i * indices_cols + j] = tensor_data[i * tensor_cols + src_col]; + } + } + } + + #[cfg(not(feature = "rayon"))] + { + if dim == 0 { + for i in 0..indices_rows { + for j in 0..indices_cols { + let src_row = checked_index(indices_data[i * indices_cols + j], dim_size); + result[i * indices_cols + j] = tensor_data[src_row * tensor_cols + j]; + } + } + } else { + for i in 0..indices_rows { + for j in 0..indices_cols { + let src_col = checked_index(indices_data[i * indices_cols + j], dim_size); + result[i * indices_cols + j] = tensor_data[i * tensor_cols + src_col]; + } + } + } + } + + result +} + +/// Compute source index for gather operation (N-D case). +#[inline] +fn compute_gather_index( + out_idx: usize, + index_val: usize, + dim: usize, + dim_stride: usize, + indices_strides: &[usize], + tensor_strides: &[usize], + ndims: usize, +) -> usize { + let mut src_idx = index_val * dim_stride; + let mut remaining = out_idx; + + for d in 0..ndims { + if d != dim { + let coord = remaining / indices_strides[d]; + remaining %= indices_strides[d]; + src_idx += coord * tensor_strides[d]; + } else { + remaining %= indices_strides[d]; + } + } + src_idx +} + +/// Scatter add: adds values to tensor at positions specified by indices. +pub fn scatter_add( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let indices = indices.to_contiguous(); + let value = value.to_contiguous(); + + let tensor_shape = tensor.layout().shape().clone(); + let indices_shape = indices.layout().shape(); + let value_shape = value.layout().shape(); + let ndims = tensor_shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + assert_eq!( + indices_shape, + value_shape, + "scatter_add: indices shape {:?} must match value shape {:?}", + indices_shape.to_vec(), + value_shape.to_vec() + ); + + for i in 0..ndims { + if i != dim { + assert_eq!( + tensor_shape[i], indices_shape[i], + "scatter_add: shape mismatch at dim {}: tensor {} vs indices {}", + i, tensor_shape[i], indices_shape[i] + ); + } + } + + let tensor_data: &[E] = tensor.storage(); + let indices_data = read_indices(&indices); + let value_data: &[E] = value.storage(); + + let mut result: Vec = tensor_data.to_vec(); + + let tensor_strides: Vec = compute_strides(&tensor_shape); + let indices_strides: Vec = compute_strides(indices_shape); + + let num_elements = indices_shape.num_elements(); + + // Use specialized 2D implementation + if ndims == 2 { + scatter_add_2d( + &mut result, + &indices_data, + value_data, + tensor_shape[0], + tensor_shape[1], + indices_shape[0], + indices_shape[1], + dim, + ); + } else { + // General N-D case (sequential due to potential index conflicts) + let dim_stride = tensor_strides[dim]; + let scatter_dim_size = tensor_shape[dim]; + for idx in 0..num_elements { + let index_val = checked_index(indices_data[idx], scatter_dim_size); + let dst_idx = compute_gather_index( + idx, + index_val, + dim, + dim_stride, + &indices_strides, + &tensor_strides, + ndims, + ); + result[dst_idx] += value_data[idx]; + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(tensor_shape), E::dtype()) +} + +/// Optimized 2D scatter_add implementation. +#[inline] +#[allow(clippy::too_many_arguments)] +fn scatter_add_2d( + result: &mut [E], + indices_data: &[isize], + value_data: &[E], + tensor_rows: usize, + tensor_cols: usize, + indices_rows: usize, + indices_cols: usize, + dim: usize, +) { + let dim_size = if dim == 0 { tensor_rows } else { tensor_cols }; + if dim == 0 { + for i in 0..indices_rows { + for j in 0..indices_cols { + let idx = i * indices_cols + j; + let dst_row = checked_index(indices_data[idx], dim_size); + result[dst_row * tensor_cols + j] += value_data[idx]; + } + } + } else { + for i in 0..indices_rows { + for j in 0..indices_cols { + let idx = i * indices_cols + j; + let dst_col = checked_index(indices_data[idx], dim_size); + result[i * tensor_cols + dst_col] += value_data[idx]; + } + } + } +} + +/// Select slices from tensor along a dimension using 1D indices. +/// +/// Unlike gather, indices is 1D and selects entire slices. +/// For a 2D tensor with dim=0 and indices=[2, 0]: +/// output[0, :] = tensor[2, :] +/// output[1, :] = tensor[0, :] +pub fn select( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let indices = indices.to_contiguous(); + + let tensor_shape = tensor.layout().shape(); + let ndims = tensor_shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + assert_eq!( + indices.layout().num_dims(), + 1, + "select: indices must be 1D, got {} dims", + indices.layout().num_dims() + ); + + let tensor_data: &[E] = tensor.storage(); + let indices_data = read_indices(&indices); + let num_indices = indices_data.len(); + + // Build output shape: replace dim with num_indices + let mut output_dims = tensor_shape.to_vec(); + output_dims[dim] = num_indices; + let output_shape = Shape::from(output_dims); + + // Use optimized 2D implementation with bulk copies + if ndims == 2 { + let result = select_2d::( + tensor_data, + &indices_data, + tensor_shape[0], + tensor_shape[1], + num_indices, + dim, + ); + let bytes = Bytes::from_elems(result); + return FlexTensor::new(bytes, Layout::contiguous(output_shape), E::dtype()); + } + + // General N-D case + let tensor_strides: Vec = compute_strides(tensor_shape); + let output_strides: Vec = compute_strides(&output_shape); + let output_size = output_shape.num_elements(); + + // Calculate slice size (elements after dim) + let slice_size: usize = tensor_strides[dim]; + + let select_dim_size = tensor_shape[dim]; + + // If dim is the last dimension or we can use bulk copies + if dim == ndims - 1 || slice_size == 1 { + // Element-wise with parallelism + #[cfg(feature = "rayon")] + let result: Vec = (0..output_size) + .into_par_iter() + .map(|out_idx| { + let mut remaining = out_idx; + let mut src_idx = 0; + for d in 0..ndims { + let coord = remaining / output_strides[d]; + remaining %= output_strides[d]; + if d == dim { + let index_val = checked_index(indices_data[coord], select_dim_size); + src_idx += index_val * tensor_strides[d]; + } else { + src_idx += coord * tensor_strides[d]; + } + } + tensor_data[src_idx] + }) + .collect(); + + #[cfg(not(feature = "rayon"))] + #[allow(clippy::needless_range_loop)] + let result: Vec = { + let mut result = vec![E::default(); output_size]; + for out_idx in 0..output_size { + let mut remaining = out_idx; + let mut src_idx = 0; + for d in 0..ndims { + let coord = remaining / output_strides[d]; + remaining %= output_strides[d]; + if d == dim { + let index_val = checked_index(indices_data[coord], select_dim_size); + src_idx += index_val * tensor_strides[d]; + } else { + src_idx += coord * tensor_strides[d]; + } + } + result[out_idx] = tensor_data[src_idx]; + } + result + }; + + let bytes = Bytes::from_elems(result); + return FlexTensor::new(bytes, Layout::contiguous(output_shape), E::dtype()); + } + + // Use bulk copies for contiguous slices + let mut result = vec![E::default(); output_size]; + + // For each position in dimensions before `dim` + let outer_count = if dim == 0 { + 1 + } else { + tensor_shape[..dim].iter().product() + }; + + for outer in 0..outer_count { + let outer_offset_tensor = outer * tensor_strides[if dim == 0 { 0 } else { dim - 1 }]; + let outer_offset_output = outer * output_strides[if dim == 0 { 0 } else { dim - 1 }]; + + for (i, &idx) in indices_data.iter().enumerate() { + let index_val = checked_index(idx, select_dim_size); + let src_start = outer_offset_tensor + index_val * tensor_strides[dim]; + let dst_start = outer_offset_output + i * output_strides[dim]; + result[dst_start..dst_start + slice_size] + .copy_from_slice(&tensor_data[src_start..src_start + slice_size]); + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(output_shape), E::dtype()) +} + +/// Optimized 2D select with bulk row copies when dim=0. +#[inline] +fn select_2d( + tensor_data: &[E], + indices_data: &[isize], + tensor_rows: usize, + tensor_cols: usize, + num_indices: usize, + dim: usize, +) -> Vec { + let dim_size = if dim == 0 { tensor_rows } else { tensor_cols }; + let (output_rows, output_cols) = if dim == 0 { + (num_indices, tensor_cols) + } else { + (tensor_rows, num_indices) + }; + let output_size = output_rows * output_cols; + + // Minimum bytes of output before we consider rayon. Below this, a + // single-threaded loop is faster because there is not enough work to + // amortize the work-stealing dispatch overhead. + #[cfg(feature = "rayon")] + const PARALLEL_THRESHOLD_BYTES: usize = 4 * 1024 * 1024; + + // Minimum elements per rayon task. Without batching, par_chunks_mut + // creates one task per row (e.g. 512 single-row tasks of 4 KB each) + // whose dispatch overhead dominates the actual copy. + #[cfg(feature = "rayon")] + const MIN_ELEMS_PER_TASK: usize = 64 * 1024; + + if dim == 0 { + // SAFETY: the output has exactly num_indices * tensor_cols elements. + // Both the parallel and serial paths below write every element exactly + // once via non-overlapping row copies, so no element is left uninitialized. + let mut result = Vec::with_capacity(output_size); + #[allow(clippy::uninit_vec)] + unsafe { + result.set_len(output_size) + }; + + #[cfg(feature = "rayon")] + if output_size * size_of::() >= PARALLEL_THRESHOLD_BYTES { + // Batch multiple rows per rayon task so each task copies at + // least MIN_ELEMS_PER_TASK elements. + let rows_per_chunk = (MIN_ELEMS_PER_TASK / tensor_cols).max(1); + let elems_per_chunk = rows_per_chunk * tensor_cols; + result.par_chunks_mut(elems_per_chunk).enumerate().for_each( + |(chunk_idx, dst_chunk)| { + let start_row = chunk_idx * rows_per_chunk; + let chunk_rows = dst_chunk.len() / tensor_cols; + for i in 0..chunk_rows { + let src_row_idx = checked_index(indices_data[start_row + i], dim_size); + let src_start = src_row_idx * tensor_cols; + let dst_start = i * tensor_cols; + dst_chunk[dst_start..dst_start + tensor_cols] + .copy_from_slice(&tensor_data[src_start..src_start + tensor_cols]); + } + }, + ); + } else { + for (i, &idx) in indices_data.iter().enumerate() { + let src_row_idx = checked_index(idx, dim_size); + let src_start = src_row_idx * tensor_cols; + let dst_start = i * tensor_cols; + result[dst_start..dst_start + tensor_cols] + .copy_from_slice(&tensor_data[src_start..src_start + tensor_cols]); + } + } + + #[cfg(not(feature = "rayon"))] + { + for (i, &idx) in indices_data.iter().enumerate() { + let src_row_idx = checked_index(idx, dim_size); + let src_start = src_row_idx * tensor_cols; + let dst_start = i * tensor_cols; + result[dst_start..dst_start + tensor_cols] + .copy_from_slice(&tensor_data[src_start..src_start + tensor_cols]); + } + } + + result + } else { + // dim == 1: gather individual elements per row (not contiguous). + // Zero-init is fine here since the inner loop is per-element anyway. + let mut result = vec![E::default(); output_size]; + + #[cfg(feature = "rayon")] + if output_size * size_of::() >= PARALLEL_THRESHOLD_BYTES { + let rows_per_chunk = (MIN_ELEMS_PER_TASK / output_cols).max(1); + let elems_per_chunk = rows_per_chunk * output_cols; + result.par_chunks_mut(elems_per_chunk).enumerate().for_each( + |(chunk_idx, dst_chunk)| { + let start_row = chunk_idx * rows_per_chunk; + let chunk_rows = dst_chunk.len() / output_cols; + for r in 0..chunk_rows { + let row = start_row + r; + let dst_base = r * output_cols; + for (j, &idx) in indices_data.iter().enumerate() { + let src_col = checked_index(idx, dim_size); + dst_chunk[dst_base + j] = tensor_data[row * tensor_cols + src_col]; + } + } + }, + ); + } else { + for row in 0..output_rows { + for (j, &idx) in indices_data.iter().enumerate() { + let src_col = checked_index(idx, dim_size); + result[row * output_cols + j] = tensor_data[row * tensor_cols + src_col]; + } + } + } + + #[cfg(not(feature = "rayon"))] + { + for row in 0..output_rows { + for (j, &idx) in indices_data.iter().enumerate() { + let src_col = checked_index(idx, dim_size); + result[row * output_cols + j] = tensor_data[row * tensor_cols + src_col]; + } + } + } + + result + } +} + +/// Select add: adds values back to tensor at positions specified by 1D indices. +pub fn select_add( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let indices = indices.to_contiguous(); + let value = value.to_contiguous(); + + let tensor_shape = tensor.layout().shape().clone(); + let value_shape = value.layout().shape(); + let ndims = tensor_shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + assert_eq!( + indices.layout().num_dims(), + 1, + "select_add: indices must be 1D" + ); + + let tensor_data: &[E] = tensor.storage(); + let indices_data = read_indices(&indices); + let value_data: &[E] = value.storage(); + let num_indices = indices_data.len(); + + // Validate value shape + for d in 0..ndims { + if d == dim { + assert_eq!( + value_shape[d], num_indices, + "select_add: value dim {} should be {} (num indices), got {}", + d, num_indices, value_shape[d] + ); + } else { + assert_eq!( + value_shape[d], tensor_shape[d], + "select_add: value dim {} should match tensor dim {}, got {}", + d, tensor_shape[d], value_shape[d] + ); + } + } + + let mut result: Vec = tensor_data.to_vec(); + + // Use optimized 2D implementation + if ndims == 2 { + select_add_2d( + &mut result, + &indices_data, + value_data, + tensor_shape[0], + tensor_shape[1], + num_indices, + dim, + ); + let bytes = Bytes::from_elems(result); + return FlexTensor::new(bytes, Layout::contiguous(tensor_shape), E::dtype()); + } + + // General N-D case + let tensor_strides: Vec = compute_strides(&tensor_shape); + let value_strides: Vec = compute_strides(value_shape); + let select_add_dim_size = tensor_shape[dim]; + + for (val_idx, &val) in value_data.iter().enumerate() { + let mut remaining = val_idx; + let mut dst_idx = 0; + for d in 0..ndims { + let coord = remaining / value_strides[d]; + remaining %= value_strides[d]; + if d == dim { + let index_val = checked_index(indices_data[coord], select_add_dim_size); + dst_idx += index_val * tensor_strides[d]; + } else { + dst_idx += coord * tensor_strides[d]; + } + } + result[dst_idx] += val; + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(tensor_shape), E::dtype()) +} + +/// Optimized 2D select_add. +#[inline] +fn select_add_2d( + result: &mut [E], + indices_data: &[isize], + value_data: &[E], + tensor_rows: usize, + tensor_cols: usize, + num_indices: usize, + dim: usize, +) { + let dim_size = if dim == 0 { tensor_rows } else { tensor_cols }; + if dim == 0 { + for (i, &idx) in indices_data.iter().enumerate() { + let dst_row = checked_index(idx, dim_size); + let dst_start = dst_row * tensor_cols; + let src_start = i * tensor_cols; + for j in 0..tensor_cols { + result[dst_start + j] += value_data[src_start + j]; + } + } + } else { + for row in 0..tensor_rows { + for (j, &idx) in indices_data.iter().enumerate() { + let dst_col = checked_index(idx, dim_size); + result[row * tensor_cols + dst_col] += value_data[row * num_indices + j]; + } + } + } +} + +/// Compute row-major strides for a shape. +#[inline] +fn compute_strides(dims: &[usize]) -> Vec { + let ndims = dims.len(); + let mut strides = vec![1usize; ndims]; + for i in (0..ndims.saturating_sub(1)).rev() { + strides[i] = strides[i + 1] * dims[i + 1]; + } + strides +} + +/// Multi-dimensional scatter: update `data` at locations specified by N-dimensional index tuples. +pub fn scatter_nd< + E: Element + Pod + Default + Copy + core::ops::AddAssign + core::ops::Mul + PartialOrd, +>( + data: FlexTensor, + indices: FlexTensor, + values: FlexTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, +) -> FlexTensor { + use burn_backend::tensor::IndexingUpdateOp; + + let data = data.to_contiguous(); + let indices = indices.to_contiguous(); + let values = values.to_contiguous(); + + let data_shape: Vec = data.layout().shape().to_vec(); + let idx_shape: Vec = indices.layout().shape().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + + let num_indices: usize = idx_shape[..m - 1].iter().product(); + let slice_size: usize = data_shape[k..].iter().product(); + + let data_data: &[E] = data.storage(); + let idx_data = read_indices(&indices); + let val_data: &[E] = values.storage(); + + let mut result: Vec = data_data.to_vec(); + + let strides = compute_strides(&data_shape); + + for n in 0..num_indices { + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = idx_data[n * k + j] as usize; + base_offset += idx_val * strides[j]; + } + + let val_offset = n * slice_size; + match reduction { + IndexingUpdateOp::Assign => { + result[base_offset..(base_offset + slice_size)] + .copy_from_slice(&val_data[val_offset..(val_offset + slice_size)]); + } + IndexingUpdateOp::Add => { + for s in 0..slice_size { + result[base_offset + s] += val_data[val_offset + s]; + } + } + IndexingUpdateOp::Mul => { + for s in 0..slice_size { + result[base_offset + s] = result[base_offset + s] * val_data[val_offset + s]; + } + } + IndexingUpdateOp::Min => { + for s in 0..slice_size { + let b = val_data[val_offset + s]; + if b < result[base_offset + s] { + result[base_offset + s] = b; + } + } + } + IndexingUpdateOp::Max => { + for s in 0..slice_size { + let b = val_data[val_offset + s]; + if b > result[base_offset + s] { + result[base_offset + s] = b; + } + } + } + } + } + + let shape = Shape::from(data_shape); + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), E::dtype()) +} + +/// Multi-dimensional gather: collect slices from `data` at locations specified by N-dimensional +/// index tuples. +pub fn gather_nd( + data: FlexTensor, + indices: FlexTensor, +) -> FlexTensor { + let data = data.to_contiguous(); + let indices = indices.to_contiguous(); + + let data_shape: Vec = data.layout().shape().to_vec(); + let idx_shape: Vec = indices.layout().shape().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + + let num_indices: usize = idx_shape[..m - 1].iter().product(); + let slice_size: usize = data_shape[k..].iter().product(); + + let data_data: &[E] = data.storage(); + let idx_data = read_indices(&indices); + + let mut out_shape_vec: Vec = idx_shape[..m - 1].to_vec(); + out_shape_vec.extend_from_slice(&data_shape[k..]); + + let strides = compute_strides(&data_shape); + + let total = num_indices * slice_size; + let mut result = vec![E::default(); total]; + + for n in 0..num_indices { + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = idx_data[n * k + j] as usize; + base_offset += idx_val * strides[j]; + } + let out_offset = n * slice_size; + result[out_offset..(out_offset + slice_size)] + .copy_from_slice(&data_data[base_offset..(base_offset + slice_size)]); + } + + let shape = Shape::from(out_shape_vec); + let bytes = Bytes::from_elems(result); + FlexTensor::new(bytes, Layout::contiguous(shape), E::dtype()) +} + +// Type-specific wrappers + +pub fn gather_f32(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + gather::(tensor, dim, indices) +} + +pub fn gather_f64(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + gather::(tensor, dim, indices) +} + +pub fn gather_i64(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + gather::(tensor, dim, indices) +} + +pub fn scatter_add_f32( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + scatter_add::(tensor, dim, indices, value) +} + +pub fn scatter_add_f64( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + scatter_add::(tensor, dim, indices, value) +} + +pub fn scatter_add_i64( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + scatter_add::(tensor, dim, indices, value) +} + +pub fn select_f32(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + select::(tensor, dim, indices) +} + +pub fn select_f64(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + select::(tensor, dim, indices) +} + +pub fn select_i64(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + select::(tensor, dim, indices) +} + +pub fn select_add_f32( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + select_add::(tensor, dim, indices, value) +} + +pub fn select_add_f64( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + select_add::(tensor, dim, indices, value) +} + +pub fn select_add_i64( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + select_add::(tensor, dim, indices, value) +} + +// Bool-specific operations + +pub fn gather_bool(tensor: FlexTensor, dim: usize, indices: FlexTensor) -> FlexTensor { + gather::(tensor, dim, indices) +} + +/// Scatter OR for bool tensors: ORs values into tensor at indexed positions. +pub fn scatter_or( + tensor: FlexTensor, + dim: usize, + indices: FlexTensor, + value: FlexTensor, +) -> FlexTensor { + // Preserve the input tensor's bool dtype for the output. + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + let tensor = tensor.to_contiguous(); + let indices = indices.to_contiguous(); + let value = value.to_contiguous(); + + let tensor_shape = tensor.layout().shape().clone(); + let indices_shape = indices.layout().shape(); + let value_shape = value.layout().shape(); + let ndims = tensor_shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + assert_eq!( + indices_shape, + value_shape, + "scatter_or: indices shape {:?} must match value shape {:?}", + indices_shape.to_vec(), + value_shape.to_vec() + ); + + for i in 0..ndims { + if i != dim { + assert_eq!( + tensor_shape[i], indices_shape[i], + "scatter_or: shape mismatch at dim {}: tensor {} vs indices {}", + i, tensor_shape[i], indices_shape[i] + ); + } + } + + let tensor_data: &[u8] = tensor.storage(); + let indices_data = read_indices(&indices); + let value_data: &[u8] = value.storage(); + + let mut result: Vec = tensor_data.to_vec(); + + let tensor_strides = compute_strides(&tensor_shape); + let indices_strides = compute_strides(indices_shape); + + let num_elements = indices_shape.num_elements(); + + let scatter_or_dim_size = tensor_shape[dim]; + + // Use 2D specialized path + if ndims == 2 { + let tensor_cols = tensor_shape[1]; + let indices_rows = indices_shape[0]; + let indices_cols = indices_shape[1]; + + if dim == 0 { + for i in 0..indices_rows { + for j in 0..indices_cols { + let idx = i * indices_cols + j; + let dst_row = checked_index(indices_data[idx], scatter_or_dim_size); + result[dst_row * tensor_cols + j] |= value_data[idx]; + } + } + } else { + for i in 0..indices_rows { + for j in 0..indices_cols { + let idx = i * indices_cols + j; + let dst_col = checked_index(indices_data[idx], scatter_or_dim_size); + result[i * tensor_cols + dst_col] |= value_data[idx]; + } + } + } + } else { + let dim_stride = tensor_strides[dim]; + for idx in 0..num_elements { + let index_val = checked_index(indices_data[idx], scatter_or_dim_size); + let dst_idx = compute_gather_index( + idx, + index_val, + dim, + dim_stride, + &indices_strides, + &tensor_strides, + ndims, + ); + result[dst_idx] |= value_data[idx]; + } + } + + crate::ops::comparison::make_bool_tensor(result, tensor_shape, out_dtype) +} + +// Tests kept here probe flex-specific behavior: non-I64 index dtype +// acceptance through the internal `read_indices` path and the uninit- +// buffer + rayon-chunked `select` kernel. Plain gather/scatter/select +// tests (including an empty-indices edge case) live in +// crates/burn-backend-tests/tests/tensor/float/ops/{gather_scatter,select}.rs +// so every backend is exercised. When adding new tests, keep them here +// only if they probe flex internals; otherwise add them there. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_gather_with_i32_indices() { + let tensor = FlexTensor::from_data(TensorData::new(vec![10.0f32, 20.0, 30.0, 40.0], [4])); + let indices = FlexTensor::from_data(TensorData::new(vec![3i32, 0, 2], [3])); + + let result = gather::(tensor, 0, indices); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![40.0, 10.0, 30.0]); + } + + #[test] + fn test_select_with_i32_indices() { + let tensor = FlexTensor::from_data(TensorData::new( + vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], + [3, 2], + )); + let indices = FlexTensor::from_data(TensorData::new(vec![2i32, 0], [2])); + + let result = select::(tensor, 0, indices); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![5.0, 6.0, 1.0, 2.0]); + } + + /// Exercises the uninit buffer path (dim=0) and, when rayon is enabled, + /// the chunked parallel path (output > 4 MB). + #[test] + fn test_select_2d_dim0_large() { + let rows = 2048; + let cols = 1024; + let data: Vec = (0..rows * cols).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data.clone(), [rows, cols])); + + // Select every other row in reverse order. + let idx: Vec = (0..rows as i64).rev().step_by(2).collect(); + let num_idx = idx.len(); + let indices = FlexTensor::from_data(TensorData::new(idx.clone(), [num_idx])); + + let result = select::(tensor, 0, indices); + assert_eq!(result.layout().shape().to_vec(), vec![num_idx, cols]); + let out: Vec = result.into_data().to_vec().unwrap(); + + for (i, &row_idx) in idx.iter().enumerate() { + let expected_start = row_idx as usize * cols; + let actual = &out[i * cols..(i + 1) * cols]; + let expected = &data[expected_start..expected_start + cols]; + assert_eq!( + actual, expected, + "mismatch at output row {i} (src row {row_idx})" + ); + } + } +} diff --git a/crates/burn-flex/src/ops/grid_sample.rs b/crates/burn-flex/src/ops/grid_sample.rs new file mode 100644 index 0000000..3f233af --- /dev/null +++ b/crates/burn-flex/src/ops/grid_sample.rs @@ -0,0 +1,244 @@ +//! Grid sampling operations for FlexTensor. +//! +//! Supported dtypes: f32, f64, f16, bf16. All dtypes share a single f64 +//! compute path. This is required for f16/bf16 correctness (so coordinate +//! math, bilinear weights, and accumulated samples keep full precision) and +//! incidentally gives f32 a small accuracy bump at the cost of extra casts. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::element::cast::ToElement; +use burn_backend::ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use num_traits::{Float, NumCast}; + +use crate::{FlexTensor, Layout}; + +/// Grid sample 2D (bilinear and nearest-neighbor interpolation). +/// +/// Input tensor shape: [N, C, H_in, W_in] +/// Grid shape: [N, H_out, W_out, 2] (x, y normalized to [-1, 1]) +/// Output shape: [N, C, H_out, W_out] +pub fn grid_sample_2d( + tensor: FlexTensor, + grid: FlexTensor, + options: GridSampleOptions, +) -> FlexTensor { + match options.mode { + InterpolateMode::Bilinear | InterpolateMode::Nearest => {} + other => panic!("grid_sample_2d: {:?} mode is not supported", other), + } + + let tensor = tensor.to_contiguous(); + let grid = grid.to_contiguous(); + + match tensor.dtype() { + DType::F32 => grid_sample_2d_impl::(tensor, grid, options), + DType::F64 => grid_sample_2d_impl::(tensor, grid, options), + DType::F16 => grid_sample_2d_impl::(tensor, grid, options), + DType::BF16 => grid_sample_2d_impl::(tensor, grid, options), + _ => panic!("grid_sample_2d: unsupported dtype {:?}", tensor.dtype()), + } +} + +fn grid_sample_2d_impl( + tensor: FlexTensor, + grid: FlexTensor, + options: GridSampleOptions, +) -> FlexTensor +where + T: Float + Element + bytemuck::Pod, +{ + let t_shape = tensor.layout().shape(); + let g_shape = grid.layout().shape(); + + assert_eq!(t_shape.num_dims(), 4, "grid_sample_2d: input must be 4D"); + assert_eq!(g_shape.num_dims(), 4, "grid_sample_2d: grid must be 4D"); + assert_eq!(g_shape[3], 2, "grid_sample_2d: grid last dim must be 2"); + assert_eq!( + t_shape[0], g_shape[0], + "grid_sample_2d: batch size mismatch" + ); + + let batch_size = t_shape[0]; + let channels = t_shape[1]; + let h_in = t_shape[2]; + let w_in = t_shape[3]; + let h_out = g_shape[1]; + let w_out = g_shape[2]; + + let tensor_data: &[T] = tensor.storage(); + let grid_data: &[T] = grid.storage(); + + let out_shape = Shape::from(vec![batch_size, channels, h_out, w_out]); + let out_len = batch_size * channels * h_out * w_out; + let mut output: Vec = vec![T::zero(); out_len]; + + let align = options.align_corners; + let pad_mode = options.padding_mode; + + let t_stride_n = channels * h_in * w_in; + let t_stride_c = h_in * w_in; + let t_stride_h = w_in; + + let g_stride_n = h_out * w_out * 2; + let g_stride_h = w_out * 2; + + let o_stride_n = channels * h_out * w_out; + let o_stride_c = h_out * w_out; + let o_stride_h = w_out; + + // Low-precision types (f16/bf16) are widened to f64 for all arithmetic so + // that coordinate math, weights, and accumulated samples keep full precision. + // + // The from_f64 unwrap is unreachable for any well-formed input: bilinear + // is a convex combination of finite samples so the result stays bounded by + // the sample envelope. The `half` crate's `NumCast` impl for f16/bf16 + // forwards through `to_f32` and maps non-finite inputs to `Some(inf/nan)`; + // `num_traits`'s f32/f64 impls do the same. The message-bearing panic is a + // diagnostic hatch for future dtypes where this invariant does not hold. + let to_f64 = |x: T| -> f64 { ToElement::to_f64(&x) }; + let from_f64 = |x: f64| -> T { + ::from(x).unwrap_or_else(|| { + panic!( + "grid_sample_2d: NumCast::from({x:?}) to {:?} returned None", + T::dtype() + ) + }) + }; + + for b in 0..batch_size { + for y in 0..h_out { + for x in 0..w_out { + let g_idx = b * g_stride_n + y * g_stride_h + x * 2; + let sample_x = to_f64(grid_data[g_idx]); + let sample_y = to_f64(grid_data[g_idx + 1]); + + let (px, py) = if align { + let px = (sample_x + 1.0) * ((w_in - 1) as f64) / 2.0; + let py = (sample_y + 1.0) * ((h_in - 1) as f64) / 2.0; + (px, py) + } else { + let px = (sample_x + 1.0) * (w_in as f64) / 2.0 - 0.5; + let py = (sample_y + 1.0) * (h_in as f64) / 2.0 - 0.5; + (px, py) + }; + + let (px, py) = apply_padding(px, py, w_in, h_in, pad_mode, align); + + let read = |t_base: usize, xi: i64, yi: i64| -> f64 { + match pad_mode { + GridSamplePaddingMode::Zeros => { + if xi >= 0 && xi < w_in as i64 && yi >= 0 && yi < h_in as i64 { + to_f64(tensor_data[t_base + yi as usize * t_stride_h + xi as usize]) + } else { + 0.0 + } + } + GridSamplePaddingMode::Border | GridSamplePaddingMode::Reflection => { + let xi = xi.clamp(0, (w_in - 1) as i64) as usize; + let yi = yi.clamp(0, (h_in - 1) as i64) as usize; + to_f64(tensor_data[t_base + yi * t_stride_h + xi]) + } + } + }; + + for c in 0..channels { + let t_base = b * t_stride_n + c * t_stride_c; + let o_idx = b * o_stride_n + c * o_stride_c + y * o_stride_h + x; + + let val = if matches!(options.mode, InterpolateMode::Nearest) { + let xi = px.round() as i64; + let yi = py.round() as i64; + read(t_base, xi, yi) + } else { + // Bilinear + let x0 = px.floor() as i64; + let y0 = py.floor() as i64; + let x1 = x0 + 1; + let y1 = y0 + 1; + + let x_frac = px - px.floor(); + let y_frac = py - py.floor(); + + let w00 = (1.0 - x_frac) * (1.0 - y_frac); + let w01 = (1.0 - x_frac) * y_frac; + let w10 = x_frac * (1.0 - y_frac); + let w11 = x_frac * y_frac; + + read(t_base, x0, y0) * w00 + + read(t_base, x0, y1) * w01 + + read(t_base, x1, y0) * w10 + + read(t_base, x1, y1) * w11 + }; + + output[o_idx] = from_f64(val); + } + } + } + } + + let bytes = Bytes::from_elems(output); + FlexTensor::new(bytes, Layout::contiguous(out_shape), T::dtype()) +} + +fn apply_padding( + px: f64, + py: f64, + w: usize, + h: usize, + mode: GridSamplePaddingMode, + align_corners: bool, +) -> (f64, f64) { + if !px.is_finite() || !py.is_finite() { + return match mode { + GridSamplePaddingMode::Border => { + let cx = ((w - 1) as f64 / 2.0).clamp(0.0, (w - 1) as f64); + let cy = ((h - 1) as f64 / 2.0).clamp(0.0, (h - 1) as f64); + (cx, cy) + } + _ => (px, py), + }; + } + + match mode { + GridSamplePaddingMode::Zeros => (px, py), + GridSamplePaddingMode::Border => { + let px = px.clamp(0.0, (w - 1) as f64); + let py = py.clamp(0.0, (h - 1) as f64); + (px, py) + } + GridSamplePaddingMode::Reflection => { + let px = reflect_coordinate(px, w, align_corners); + let py = reflect_coordinate(py, h, align_corners); + (px, py) + } + } +} + +fn reflect_coordinate(coord: f64, size: usize, align_corners: bool) -> f64 { + let size_f = size as f64; + let (min_val, max_val) = if align_corners { + (0.0, size_f - 1.0) + } else { + (-0.5, size_f - 0.5) + }; + + let span = max_val - min_val; + if span <= 0.0 { + return min_val; + } + + let period = 2.0 * span; + let x = (coord - min_val).abs(); + let x_mod = x - (x / period).floor() * period; + span - (x_mod - span).abs() + min_val +} + +// Correctness of grid_sample_2d (nearest/bilinear, zeros/border/reflection +// padding, align_corners on/off) is covered by the cross-backend suite in +// crates/burn-backend-tests/tests/tensor/float/ops/grid_sample.rs, which +// reaches `grid_sample_2d` via the `FloatTensorOps` trait for flex. No +// flex-specific tests remain here. diff --git a/crates/burn-flex/src/ops/int.rs b/crates/burn-flex/src/ops/int.rs new file mode 100644 index 0000000..9804190 --- /dev/null +++ b/crates/burn-flex/src/ops/int.rs @@ -0,0 +1,1336 @@ +//! Int tensor operations for the Flex backend. + +use alloc::vec::Vec; +use burn_backend::{ + DType, Distribution, ExecutionError, FloatDType, Scalar, TensorData, TensorMetadata, + ops::IntTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IntTensor}, +}; +use burn_std::{Bytes, IntDType, Shape, Slice, bf16, f16}; +use num_traits::ToPrimitive; + +use crate::Layout; +use crate::ops::binary::{binary_op_typed, int_binary_op, int_scalar_op, scalar_op_typed}; +use crate::{Flex, FlexTensor, ops::matmul}; + +/// Convert a Scalar to (i64, u64) pair for the given dtype. +/// Only the matching type's conversion is validated; the other gets a dummy 0. +fn scalar_to_int_pair(dtype: DType, rhs: &Scalar) -> (i64, u64) { + if dtype == DType::U64 { + (0, rhs.to_u64().unwrap()) + } else { + (rhs.to_i64().unwrap(), 0) + } +} + +impl IntTensorOps for Flex { + fn int_from_data(data: TensorData, _device: &Device) -> IntTensor { + FlexTensor::from_data(data) + } + + async fn int_into_data(tensor: IntTensor) -> Result { + Ok(tensor.into_data()) + } + + fn int_to_device(tensor: IntTensor, _device: &Device) -> IntTensor { + tensor + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + crate::ops::cat::cat(tensors, dim) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + tensor.reshape(shape) + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + crate::ops::slice::slice(tensor, slices) + } + + fn int_empty(shape: Shape, _device: &Device, dtype: IntDType) -> IntTensor { + FlexTensor::empty(shape, dtype.into()) + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + debug_assert_eq!( + tensor.dtype(), + value.dtype(), + "int_mask_where: dtype mismatch" + ); + match tensor.dtype() { + DType::I64 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::I32 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::I16 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::I8 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::U64 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::U32 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::U16 => crate::ops::mask::mask_where::(tensor, mask, value), + DType::U8 => crate::ops::mask::mask_where::(tensor, mask, value), + dt => panic!("int_mask_where: unsupported dtype {:?}", dt), + } + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::mask::mask_fill(tensor, mask, value.to_i64().unwrap()), + DType::I32 => crate::ops::mask::mask_fill(tensor, mask, value.to_i64().unwrap() as i32), + DType::I16 => crate::ops::mask::mask_fill(tensor, mask, value.to_i64().unwrap() as i16), + DType::I8 => crate::ops::mask::mask_fill(tensor, mask, value.to_i64().unwrap() as i8), + DType::U64 => crate::ops::mask::mask_fill(tensor, mask, value.to_u64().unwrap()), + DType::U32 => crate::ops::mask::mask_fill(tensor, mask, value.to_u64().unwrap() as u32), + DType::U16 => crate::ops::mask::mask_fill(tensor, mask, value.to_u64().unwrap() as u16), + DType::U8 => crate::ops::mask::mask_fill(tensor, mask, value.to_u64().unwrap() as u8), + dt => panic!("int_mask_fill: unsupported dtype {:?}", dt), + } + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[Slice], + value: IntTensor, + ) -> IntTensor { + crate::ops::slice::slice_assign(tensor, slices, value) + } + + /// Gather ints along `dim` at the given indices. + /// + /// The `tensor` dispatches on its own int dtype (I8/I16/I32/I64 signed or + /// U8/U16/U32/U64 unsigned). The `indices` tensor may be any of those + /// widths too - it's normalised to `isize` by the shared `read_indices` + /// helper in `ops::gather_scatter` before the kernel runs, so callers are + /// not required to pre-convert to I64. + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::I32 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::I16 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::I8 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::U64 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::U32 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::U16 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + DType::U8 => crate::ops::gather_scatter::gather::(tensor, dim, indices), + dt => panic!("int_gather: unsupported dtype {:?}", dt), + } + } + + /// Scatter-add int values at the given indices along `dim`. + /// + /// `tensor` and `value` must share the same int dtype; `indices` may be + /// any supported int width. See [`int_gather`](Self::int_gather) for the + /// full index-width policy. + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + debug_assert_eq!( + tensor.dtype(), + value.dtype(), + "int_scatter_add: dtype mismatch" + ); + match tensor.dtype() { + DType::I64 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::I32 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::I16 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::I8 => crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value), + DType::U64 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::U32 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::U16 => { + crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value) + } + DType::U8 => crate::ops::gather_scatter::scatter_add::(tensor, dim, indices, value), + dt => panic!("int_scatter_add: unsupported dtype {:?}", dt), + } + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> IntTensor { + match data.dtype() { + DType::I64 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::I32 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::I16 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::I8 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::U64 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::U32 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::U16 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + DType::U8 => { + crate::ops::gather_scatter::scatter_nd::(data, indices, values, reduction) + } + dt => panic!("int_scatter_nd: unsupported dtype {:?}", dt), + } + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + match data.dtype() { + DType::I64 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::I32 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::I16 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::I8 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::U64 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::U32 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::U16 => crate::ops::gather_scatter::gather_nd::(data, indices), + DType::U8 => crate::ops::gather_scatter::gather_nd::(data, indices), + dt => panic!("int_gather_nd: unsupported dtype {:?}", dt), + } + } + + /// Select ints along `dim` by a 1D index tensor. + /// + /// The `indices` tensor may be any supported int width. See + /// [`int_gather`](Self::int_gather) for the full index-width policy. + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::I32 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::I16 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::I8 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::U64 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::U32 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::U16 => crate::ops::gather_scatter::select::(tensor, dim, indices), + DType::U8 => crate::ops::gather_scatter::select::(tensor, dim, indices), + dt => panic!("int_select: unsupported dtype {:?}", dt), + } + } + + /// Select-add int values at a 1D index tensor along `dim`. + /// + /// `tensor` and `value` must share the same int dtype; `indices` may be + /// any supported int width. See [`int_gather`](Self::int_gather) for the + /// full index-width policy. + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + debug_assert_eq!( + tensor.dtype(), + value.dtype(), + "int_select_add: dtype mismatch" + ); + match tensor.dtype() { + DType::I64 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::I32 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::I16 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::I8 => crate::ops::gather_scatter::select_add::(tensor, dim, indices, value), + DType::U64 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::U32 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::U16 => { + crate::ops::gather_scatter::select_add::(tensor, dim, indices, value) + } + DType::U8 => crate::ops::gather_scatter::select_add::(tensor, dim, indices, value), + dt => panic!("int_select_add: unsupported dtype {:?}", dt), + } + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_equal(lhs, rhs, out_dtype) + } + + fn int_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_equal_elem(lhs, i, u, out_dtype) + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_greater(lhs, rhs, out_dtype) + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_greater_elem(lhs, i, u, out_dtype) + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_greater_equal(lhs, rhs, out_dtype) + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_greater_equal_elem(lhs, i, u, out_dtype) + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_lower(lhs, rhs, out_dtype) + } + + fn int_lower_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_lower_elem(lhs, i, u, out_dtype) + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_lower_equal(lhs, rhs, out_dtype) + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_lower_equal_elem(lhs, i, u, out_dtype) + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a + b) + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| { + a.wrapping_add(b) + }); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a + b) + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a - b) + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| { + a.wrapping_sub(b) + }); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a - b) + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a * b) + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| { + a.wrapping_mul(b) + }); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a * b) + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + // U64 values > i64::MAX produce wrong results through i64 cast + if lhs.dtype() == DType::U64 { + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + return binary_op_typed(lhs, &rhs, |a: u64, b: u64| a / b); + } + int_binary_op(lhs, rhs, |a, b| a / b) + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| a / b); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a / b) + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + // U64 values > i64::MAX produce wrong results through i64 cast + if lhs.dtype() == DType::U64 { + let (lhs, rhs) = crate::ops::expand::broadcast_binary(lhs, rhs); + return binary_op_typed(lhs, &rhs, |a: u64, b: u64| a % b); + } + // Python/PyTorch-style remainder: result has same sign as divisor + int_binary_op(lhs, rhs, |a, b| ((a % b) + b) % b) + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| a % b); + } + // Python/PyTorch-style remainder: result has same sign as divisor + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| ((a % b) + b) % b) + } + + // Precision limits: i64/u64 > 2^24 for f32/f16/bf16, > 2^53 for f64. + fn int_into_float( + tensor: IntTensor, + out_dtype: burn_std::FloatDType, + ) -> FloatTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let src = tensor.dtype(); + let out_dt = DType::from(out_dtype); + + // Read source ints, applying conversion per-element. + // Each arm binds `$x` to the native int value; `$conv` must work for all int types. + macro_rules! read_ints { + (|$x:ident| $conv:expr) => { + match src { + DType::I64 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::I32 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::I16 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::I8 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::U64 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::U32 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::U16 => tensor.storage::().iter().map(|&$x| $conv).collect(), + DType::U8 => tensor.storage::().iter().map(|&$x| $conv).collect(), + _ => panic!("int_into_float: unsupported source dtype {:?}", src), + } + }; + } + + match out_dtype { + FloatDType::F64 => { + let data: Vec = read_ints!(|x| x as f64); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::F32 | FloatDType::Flex32 => { + let data: Vec = read_ints!(|x| x as f32); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::F16 => { + let data: Vec = read_ints!(|x| f16::from_f32(x as f32)); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + FloatDType::BF16 => { + let data: Vec = read_ints!(|x| bf16::from_f32(x as f32)); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), out_dt) + } + } + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + tensor.transpose(dim1, dim2) + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + tensor.permute(axes) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + crate::ops::flip::flip(tensor, axes) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + _device: &Device, + dtype: IntDType, + ) -> IntTensor { + let mut seed = crate::backend::SEED.lock().unwrap(); + let mut rng = seed.take().unwrap_or_else(crate::backend::get_seeded_rng); + let data = match dtype { + IntDType::I64 => TensorData::random::(shape, distribution, &mut rng), + IntDType::I32 => TensorData::random::(shape, distribution, &mut rng), + IntDType::I16 => TensorData::random::(shape, distribution, &mut rng), + IntDType::I8 => TensorData::random::(shape, distribution, &mut rng), + IntDType::U64 => TensorData::random::(shape, distribution, &mut rng), + IntDType::U32 => TensorData::random::(shape, distribution, &mut rng), + IntDType::U16 => TensorData::random::(shape, distribution, &mut rng), + IntDType::U8 => TensorData::random::(shape, distribution, &mut rng), + }; + *seed = Some(rng); + FlexTensor::from_data(data) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + crate::ops::expand::expand(tensor, shape) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + matmul::int_matmul(lhs, rhs) + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + crate::ops::reduce::sum(tensor) + } + + fn int_sum_dim(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::sum_dim(tensor, dim) + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + crate::ops::reduce::prod(tensor) + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::prod_dim(tensor, dim) + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::mean_dim(tensor, dim) + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::I32 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::I16 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::I8 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::U64 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::U32 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::U16 => crate::ops::cumulative::cumsum::(tensor, dim), + DType::U8 => crate::ops::cumulative::cumsum::(tensor, dim), + dt => panic!("int_cumsum: unsupported dtype {:?}", dt), + } + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::I32 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::I16 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::I8 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::U64 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::U32 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::U16 => crate::ops::cumulative::cumprod::(tensor, dim), + DType::U8 => crate::ops::cumulative::cumprod::(tensor, dim), + dt => panic!("int_cumprod: unsupported dtype {:?}", dt), + } + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::cumulative::cummin::(tensor, dim), + DType::I32 => crate::ops::cumulative::cummin::(tensor, dim), + DType::I16 => crate::ops::cumulative::cummin::(tensor, dim), + DType::I8 => crate::ops::cumulative::cummin::(tensor, dim), + DType::U64 => crate::ops::cumulative::cummin::(tensor, dim), + DType::U32 => crate::ops::cumulative::cummin::(tensor, dim), + DType::U16 => crate::ops::cumulative::cummin::(tensor, dim), + DType::U8 => crate::ops::cumulative::cummin::(tensor, dim), + dt => panic!("int_cummin: unsupported dtype {:?}", dt), + } + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + match tensor.dtype() { + DType::I64 => crate::ops::cumulative::cummax::(tensor, dim), + DType::I32 => crate::ops::cumulative::cummax::(tensor, dim), + DType::I16 => crate::ops::cumulative::cummax::(tensor, dim), + DType::I8 => crate::ops::cumulative::cummax::(tensor, dim), + DType::U64 => crate::ops::cumulative::cummax::(tensor, dim), + DType::U32 => crate::ops::cumulative::cummax::(tensor, dim), + DType::U16 => crate::ops::cumulative::cummax::(tensor, dim), + DType::U8 => crate::ops::cumulative::cummax::(tensor, dim), + dt => panic!("int_cummax: unsupported dtype {:?}", dt), + } + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::argmax(tensor, dim) + } + + fn int_argtopk(_tensor: IntTensor, _dim: usize, _k: usize) -> IntTensor { + panic!("argtopk not implemented for flex") + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::argmin(tensor, dim) + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + crate::ops::unary::int_abs(tensor) + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a & b) + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| a & b); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a & b) + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a | b) + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| a | b); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a | b) + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a ^ b) + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, rhs.to_u64().unwrap(), |a: u64, b: u64| a ^ b); + } + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a ^ b) + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + // Use scalar op with dummy value, only applying NOT to lhs + int_scalar_op(tensor, 0, |a, _| !a) + } + + // Shift amounts masked to type width via wrapping_shl/wrapping_shr. + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a.wrapping_shl(b as u32)) + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a.wrapping_shl(b as u32)) + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a.wrapping_shr(b as u32)) + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + int_scalar_op(lhs, rhs.to_i64().unwrap(), |a, b| a.wrapping_shr(b as u32)) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + let target_dtype: DType = dtype.into(); + + // If already the target dtype, return as-is + if tensor.dtype() == target_dtype { + return tensor; + } + + // Make contiguous for easier iteration + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + + // Helper macro to convert between types + macro_rules! cast_impl { + ($src_type:ty, $dst_type:ty, $dst_dtype:expr) => {{ + let src: &[$src_type] = tensor.storage(); + let dst: Vec<$dst_type> = src.iter().map(|&x| x as $dst_type).collect(); + FlexTensor::new( + Bytes::from_elems(dst), + Layout::contiguous(shape), + $dst_dtype, + ) + }}; + } + + // Match source dtype to target dtype + match (tensor.dtype(), target_dtype) { + // From I64 + (DType::I64, DType::I32) => cast_impl!(i64, i32, DType::I32), + (DType::I64, DType::I16) => cast_impl!(i64, i16, DType::I16), + (DType::I64, DType::I8) => cast_impl!(i64, i8, DType::I8), + (DType::I64, DType::U64) => cast_impl!(i64, u64, DType::U64), + (DType::I64, DType::U32) => cast_impl!(i64, u32, DType::U32), + (DType::I64, DType::U16) => cast_impl!(i64, u16, DType::U16), + (DType::I64, DType::U8) => cast_impl!(i64, u8, DType::U8), + + // From I32 + (DType::I32, DType::I64) => cast_impl!(i32, i64, DType::I64), + (DType::I32, DType::I16) => cast_impl!(i32, i16, DType::I16), + (DType::I32, DType::I8) => cast_impl!(i32, i8, DType::I8), + (DType::I32, DType::U64) => cast_impl!(i32, u64, DType::U64), + (DType::I32, DType::U32) => cast_impl!(i32, u32, DType::U32), + (DType::I32, DType::U16) => cast_impl!(i32, u16, DType::U16), + (DType::I32, DType::U8) => cast_impl!(i32, u8, DType::U8), + + // From I16 + (DType::I16, DType::I64) => cast_impl!(i16, i64, DType::I64), + (DType::I16, DType::I32) => cast_impl!(i16, i32, DType::I32), + (DType::I16, DType::I8) => cast_impl!(i16, i8, DType::I8), + (DType::I16, DType::U64) => cast_impl!(i16, u64, DType::U64), + (DType::I16, DType::U32) => cast_impl!(i16, u32, DType::U32), + (DType::I16, DType::U16) => cast_impl!(i16, u16, DType::U16), + (DType::I16, DType::U8) => cast_impl!(i16, u8, DType::U8), + + // From I8 + (DType::I8, DType::I64) => cast_impl!(i8, i64, DType::I64), + (DType::I8, DType::I32) => cast_impl!(i8, i32, DType::I32), + (DType::I8, DType::I16) => cast_impl!(i8, i16, DType::I16), + (DType::I8, DType::U64) => cast_impl!(i8, u64, DType::U64), + (DType::I8, DType::U32) => cast_impl!(i8, u32, DType::U32), + (DType::I8, DType::U16) => cast_impl!(i8, u16, DType::U16), + (DType::I8, DType::U8) => cast_impl!(i8, u8, DType::U8), + + // From U64 + (DType::U64, DType::I64) => cast_impl!(u64, i64, DType::I64), + (DType::U64, DType::I32) => cast_impl!(u64, i32, DType::I32), + (DType::U64, DType::I16) => cast_impl!(u64, i16, DType::I16), + (DType::U64, DType::I8) => cast_impl!(u64, i8, DType::I8), + (DType::U64, DType::U32) => cast_impl!(u64, u32, DType::U32), + (DType::U64, DType::U16) => cast_impl!(u64, u16, DType::U16), + (DType::U64, DType::U8) => cast_impl!(u64, u8, DType::U8), + + // From U32 + (DType::U32, DType::I64) => cast_impl!(u32, i64, DType::I64), + (DType::U32, DType::I32) => cast_impl!(u32, i32, DType::I32), + (DType::U32, DType::I16) => cast_impl!(u32, i16, DType::I16), + (DType::U32, DType::I8) => cast_impl!(u32, i8, DType::I8), + (DType::U32, DType::U64) => cast_impl!(u32, u64, DType::U64), + (DType::U32, DType::U16) => cast_impl!(u32, u16, DType::U16), + (DType::U32, DType::U8) => cast_impl!(u32, u8, DType::U8), + + // From U16 + (DType::U16, DType::I64) => cast_impl!(u16, i64, DType::I64), + (DType::U16, DType::I32) => cast_impl!(u16, i32, DType::I32), + (DType::U16, DType::I16) => cast_impl!(u16, i16, DType::I16), + (DType::U16, DType::I8) => cast_impl!(u16, i8, DType::I8), + (DType::U16, DType::U64) => cast_impl!(u16, u64, DType::U64), + (DType::U16, DType::U32) => cast_impl!(u16, u32, DType::U32), + (DType::U16, DType::U8) => cast_impl!(u16, u8, DType::U8), + + // From U8 + (DType::U8, DType::I64) => cast_impl!(u8, i64, DType::I64), + (DType::U8, DType::I32) => cast_impl!(u8, i32, DType::I32), + (DType::U8, DType::I16) => cast_impl!(u8, i16, DType::I16), + (DType::U8, DType::I8) => cast_impl!(u8, i8, DType::I8), + (DType::U8, DType::U64) => cast_impl!(u8, u64, DType::U64), + (DType::U8, DType::U32) => cast_impl!(u8, u32, DType::U32), + (DType::U8, DType::U16) => cast_impl!(u8, u16, DType::U16), + + _ => panic!( + "int_cast: unsupported conversion from {:?} to {:?}", + tensor.dtype(), + target_dtype + ), + } + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + crate::ops::unfold::unfold_int(tensor, dim, size, step) + } + + fn int_neg(tensor: IntTensor) -> IntTensor { + int_scalar_op(tensor, 0i64, |a, _| a.wrapping_neg()) + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + if tensor.dtype() == DType::U64 { + let min_val = min.to_u64().unwrap(); + let max_val = max.to_u64().unwrap(); + return scalar_op_typed(tensor, 0u64, move |x: u64, _| x.clamp(min_val, max_val)); + } + let min_val = min.to_i64().unwrap(); + let max_val = max.to_i64().unwrap(); + int_scalar_op(tensor, 0i64, move |x, _| x.clamp(min_val, max_val)) + } + + fn int_clamp_min(tensor: IntTensor, min: Scalar) -> IntTensor { + if tensor.dtype() == DType::U64 { + let min_val = min.to_u64().unwrap(); + return scalar_op_typed(tensor, 0u64, move |x: u64, _| x.max(min_val)); + } + let min_val = min.to_i64().unwrap(); + int_scalar_op(tensor, 0i64, move |x, _| x.max(min_val)) + } + + fn int_clamp_max(tensor: IntTensor, max: Scalar) -> IntTensor { + if tensor.dtype() == DType::U64 { + let max_val = max.to_u64().unwrap(); + return scalar_op_typed(tensor, 0u64, move |x: u64, _| x.min(max_val)); + } + let max_val = max.to_i64().unwrap(); + int_scalar_op(tensor, 0i64, move |x, _| x.min(max_val)) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + if tensor.dtype() == DType::U64 { + return scalar_op_typed(tensor, 0u64, |x: u64, _| if x > 0 { 1 } else { 0 }); + } + int_scalar_op(tensor, 0i64, |x, _| { + if x > 0 { + 1 + } else if x < 0 { + -1 + } else { + 0 + } + }) + } + + fn int_mean(tensor: IntTensor) -> IntTensor { + let n = tensor.layout().num_elements(); + assert!(n > 0, "int_mean: cannot take mean of empty tensor"); + let dtype = tensor.dtype(); + let sum_result = crate::ops::reduce::sum(tensor); + // Compute in i64 to avoid truncation of n for small int types + macro_rules! compute_mean { + ($ty:ty) => {{ + let data: &[$ty] = sum_result.storage(); + let mean_val = (data[0] as i64 / n as i64) as $ty; + FlexTensor::new( + Bytes::from_elems(alloc::vec![mean_val]), + Layout::contiguous(Shape::from(alloc::vec![1])), + dtype, + ) + }}; + } + match dtype { + DType::I64 => compute_mean!(i64), + DType::I32 => compute_mean!(i32), + DType::I16 => compute_mean!(i16), + DType::I8 => compute_mean!(i8), + other => panic!("int_mean: unsupported dtype {:?}", other), + } + } + + fn int_max(tensor: IntTensor) -> IntTensor { + crate::ops::reduce::max(tensor) + } + + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::max_dim(tensor, dim) + } + + fn int_min(tensor: IntTensor) -> IntTensor { + crate::ops::reduce::min(tensor) + } + + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + crate::ops::reduce::min_dim(tensor, dim) + } + + fn int_max_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + crate::ops::reduce::max_dim_with_indices(tensor, dim) + } + + fn int_min_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + crate::ops::reduce::min_dim_with_indices(tensor, dim) + } + + fn int_any(tensor: IntTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + crate::ops::comparison::any_int(tensor, out_dtype) + } + + fn int_any_dim( + tensor: IntTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::any_int_dim(tensor, dim, out_dtype) + } + + fn int_all(tensor: IntTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + crate::ops::comparison::all_int(tensor, out_dtype) + } + + fn int_all_dim( + tensor: IntTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::all_int_dim(tensor, dim, out_dtype) + } + + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + int_binary_op(lhs, rhs, |a, b| a.wrapping_pow(b as u32)) + } + + fn int_zeros(shape: Shape, _device: &Device, dtype: IntDType) -> IntTensor { + FlexTensor::zeros(shape, dtype.into()) + } + + fn int_ones(shape: Shape, _device: &Device, dtype: IntDType) -> IntTensor { + let dt: DType = dtype.into(); + match dt { + DType::I64 => FlexTensor::filled_typed(shape, dt, 1i64), + DType::I32 => FlexTensor::filled_typed(shape, dt, 1i32), + DType::I16 => FlexTensor::filled_typed(shape, dt, 1i16), + DType::I8 => FlexTensor::filled_typed(shape, dt, 1i8), + DType::U64 => FlexTensor::filled_typed(shape, dt, 1u64), + DType::U32 => FlexTensor::filled_typed(shape, dt, 1u32), + DType::U16 => FlexTensor::filled_typed(shape, dt, 1u16), + DType::U8 => FlexTensor::filled_typed(shape, dt, 1u8), + _ => unreachable!(), + } + } + + fn int_full( + shape: Shape, + fill_value: burn_backend::Scalar, + _device: &Device, + dtype: IntDType, + ) -> IntTensor { + let dt: DType = dtype.into(); + let v = fill_value.to_i64().unwrap(); + match dt { + DType::I64 => FlexTensor::filled_typed(shape, dt, v), + DType::I32 => FlexTensor::filled_typed(shape, dt, v as i32), + DType::I16 => FlexTensor::filled_typed(shape, dt, v as i16), + DType::I8 => FlexTensor::filled_typed(shape, dt, v as i8), + DType::U64 => FlexTensor::filled_typed(shape, dt, v as u64), + DType::U32 => FlexTensor::filled_typed(shape, dt, v as u32), + DType::U16 => FlexTensor::filled_typed(shape, dt, v as u16), + DType::U8 => FlexTensor::filled_typed(shape, dt, v as u8), + _ => unreachable!(), + } + } + + fn int_transpose(tensor: IntTensor) -> IntTensor { + let ndims = tensor.layout().num_dims(); + if ndims < 2 { + return tensor; + } + tensor.transpose(ndims - 2, ndims - 1) + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + crate::ops::repeat_dim::repeat_dim(tensor, dim, times) + } + + fn int_not_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + crate::ops::comparison::int_not_equal(lhs, rhs, out_dtype) + } + + fn int_not_equal_elem( + lhs: IntTensor, + rhs: burn_backend::Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let (i, u) = scalar_to_int_pair(lhs.dtype(), &rhs); + crate::ops::comparison::int_not_equal_elem(lhs, i, u, out_dtype) + } + + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + crate::ops::sort::sort(tensor, dim, descending) + } + + fn int_sort_with_indices( + tensor: IntTensor, + dim: usize, + descending: bool, + ) -> (IntTensor, IntTensor) { + crate::ops::sort::sort_with_indices(tensor, dim, descending) + } + + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + crate::ops::sort::argsort(tensor, dim, descending) + } + + fn int_powi_scalar(lhs: IntTensor, rhs: burn_backend::Scalar) -> IntTensor { + use num_traits::ToPrimitive; + match rhs.to_i64().unwrap() { + 0 => Self::int_ones(lhs.shape(), &Default::default(), lhs.dtype().into()), + 1 => lhs, + 2 => Self::int_mul(lhs.clone(), lhs), + _ => Self::int_powi_scalar_impl(lhs, rhs), + } + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: burn_backend::Scalar) -> IntTensor { + use num_traits::ToPrimitive; + let exp = rhs.to_i64().unwrap() as u32; + if lhs.dtype() == DType::U64 { + return scalar_op_typed(lhs, exp as u64, move |x: u64, _| x.wrapping_pow(exp)); + } + int_scalar_op(lhs, exp as i64, move |x, _| x.wrapping_pow(exp)) + } + + fn int_max_abs(tensor: IntTensor) -> IntTensor { + let abs = Self::int_abs(tensor); + crate::ops::reduce::max(abs) + } + + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let abs = Self::int_abs(tensor); + crate::ops::reduce::max_dim(abs, dim) + } + + fn int_arange( + range: core::ops::Range, + _device: &Device, + dtype: IntDType, + ) -> IntTensor { + Self::int_arange_step(range, 1, &Default::default(), dtype) + } + + fn int_arange_step( + range: core::ops::Range, + step: usize, + _device: &Device, + dtype: IntDType, + ) -> IntTensor { + let dt: DType = dtype.into(); + + macro_rules! arange_typed { + ($ty:ty) => {{ + let data: Vec<$ty> = range.step_by(step).map(|v| v as $ty).collect(); + let shape = Shape::from(alloc::vec![data.len()]); + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), dt) + }}; + } + + match dt { + DType::I64 => arange_typed!(i64), + DType::I32 => arange_typed!(i32), + DType::I16 => arange_typed!(i16), + DType::I8 => arange_typed!(i8), + DType::U64 => arange_typed!(u64), + DType::U32 => arange_typed!(u32), + DType::U16 => arange_typed!(u16), + DType::U8 => arange_typed!(u8), + _ => unreachable!(), + } + } +} + +// Tests kept here exercise flex-specific behavior: dtype storage +// selection for every int width (I16/I32/U8/U16/U32/I64/U64), and edge +// cases of the dtype-specific kernels (u64 wrap, i64::MIN abs/neg, bit +// shift at width). Plain int arithmetic, scalar ops, bool->int cast +// smokes, and negative-stride (flipped/transposed) variants have been +// migrated to burn-backend-tests so they run against every backend. +// When adding new tests, keep them here only if they probe flex dtype +// storage; otherwise add them to +// crates/burn-backend-tests/tests/tensor/int/ops/. +#[cfg(test)] +mod tests { + use alloc::vec; + use burn_backend::TensorData; + use burn_backend::ops::IntTensorOps; + + use crate::Flex; + use crate::FlexTensor; + + #[test] + fn test_u64_div_large_values() { + let a = FlexTensor::from_data(TensorData::new(vec![u64::MAX], [1])); + let b = FlexTensor::from_data(TensorData::new(vec![2u64], [1])); + let result = Flex::int_div(a, b); + let values: Vec = bytemuck::cast_slice(&result.into_data().bytes).to_vec(); + assert_eq!(values[0], u64::MAX / 2); + } + + #[test] + fn test_u64_remainder_large_values() { + let a = FlexTensor::from_data(TensorData::new(vec![u64::MAX], [1])); + let b = FlexTensor::from_data(TensorData::new(vec![2u64], [1])); + let result = Flex::int_remainder(a, b); + let values: Vec = bytemuck::cast_slice(&result.into_data().bytes).to_vec(); + assert_eq!(values[0], u64::MAX % 2); + } + + #[test] + fn test_int_abs_min_value() { + // i64::MIN.abs() panics in debug; wrapping_abs returns MIN (matches PyTorch) + let a = FlexTensor::from_data(TensorData::new(vec![i64::MIN], [1])); + let result = Flex::int_abs(a); + let values: Vec = bytemuck::cast_slice(&result.into_data().bytes).to_vec(); + assert_eq!(values[0], i64::MIN.wrapping_abs()); + } + + #[test] + fn test_int_neg_min_value() { + // i64::MIN negation panics in debug; wrapping_neg returns MIN (matches PyTorch) + let a = FlexTensor::from_data(TensorData::new(vec![i64::MIN], [1])); + let result = Flex::int_neg(a); + let values: Vec = bytemuck::cast_slice(&result.into_data().bytes).to_vec(); + assert_eq!(values[0], i64::MIN.wrapping_neg()); + } + + #[test] + fn test_int_shift_large_amount() { + // Shift by >= bit width panics without wrapping; should not crash + let a = FlexTensor::from_data(TensorData::new(vec![1i64], [1])); + let b = FlexTensor::from_data(TensorData::new(vec![64i64], [1])); + let _left = Flex::bitwise_left_shift(a.clone(), b.clone()); + let _right = Flex::bitwise_right_shift(a, b); + } + + #[test] + fn test_int_into_float_f64() { + use burn_backend::ops::IntTensorOps; + use burn_std::FloatDType; + + let t = FlexTensor::from_data(TensorData::new(vec![1i64, 2, -3], [3])); + let result = Flex::int_into_float(t, FloatDType::F64); + assert_eq!(result.dtype(), burn_backend::DType::F64); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1.0f64, 2.0, -3.0]); + } + + #[test] + fn test_u64_add_scalar_large() { + let t = FlexTensor::from_data(TensorData::new(vec![1u64, 2, 3], [3])); + let big: u64 = (i64::MAX as u64) + 100; + let result = Flex::int_add_scalar(t, burn_backend::Scalar::from(big)); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![big + 1, big + 2, big + 3]); + } + + #[test] + fn test_u64_greater_elem_large() { + let big: u64 = (i64::MAX as u64) + 100; + let t = FlexTensor::from_data(TensorData::new(vec![big, big + 1, big - 1], [3])); + let result = Flex::int_greater_elem( + t, + burn_backend::Scalar::from(big), + burn_std::BoolStore::Native, + ); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![false, true, false]); + } + + #[test] + fn test_int_mask_fill_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![1i32, 2, 3, 4], [4])); + let mask = FlexTensor::from_data(TensorData::new(vec![true, false, true, false], [4])); + let result = Flex::int_mask_fill(t, mask, burn_backend::Scalar::from(0i64)); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![0, 2, 0, 4]); + } + + #[test] + fn test_int_mask_fill_i16() { + let t = FlexTensor::from_data(TensorData::new(vec![10i16, 20, 30, 40], [4])); + let mask = FlexTensor::from_data(TensorData::new(vec![false, true, false, true], [4])); + let result = Flex::int_mask_fill(t, mask, burn_backend::Scalar::from(-1i64)); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![10, -1, 30, -1]); + } + + #[test] + fn test_int_mask_fill_u8() { + let t = FlexTensor::from_data(TensorData::new(vec![1u8, 2, 3, 4], [4])); + let mask = FlexTensor::from_data(TensorData::new(vec![true, true, false, false], [4])); + let result = Flex::int_mask_fill(t, mask, burn_backend::Scalar::from(255i64)); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![255, 255, 3, 4]); + } + + #[test] + fn test_int_mask_fill_u32() { + let t = FlexTensor::from_data(TensorData::new(vec![100u32, 200, 300], [3])); + let mask = FlexTensor::from_data(TensorData::new(vec![true, false, true], [3])); + let result = Flex::int_mask_fill(t, mask, burn_backend::Scalar::from(0i64)); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![0, 200, 0]); + } + + #[test] + fn test_int_mask_where_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![1i32, 2, 3, 4], [4])); + let mask = FlexTensor::from_data(TensorData::new(vec![true, false, true, false], [4])); + let v = FlexTensor::from_data(TensorData::new(vec![10i32, 20, 30, 40], [4])); + let result = Flex::int_mask_where(t, mask, v); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![10, 2, 30, 4]); + } + + #[test] + fn test_int_mask_where_u8() { + let t = FlexTensor::from_data(TensorData::new(vec![1u8, 2, 3, 4], [4])); + let mask = FlexTensor::from_data(TensorData::new(vec![false, true, false, true], [4])); + let v = FlexTensor::from_data(TensorData::new(vec![10u8, 20, 30, 40], [4])); + let result = Flex::int_mask_where(t, mask, v); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1, 20, 3, 40]); + } + + #[test] + fn test_int_gather_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![10i32, 20, 30, 40, 50, 60], [2, 3])); + let indices = FlexTensor::from_data(TensorData::new(vec![2i64, 0, 1, 2], [2, 2])); + let result = Flex::int_gather(1, t, indices); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![30, 10, 50, 60]); + } + + #[test] + fn test_int_select_u16() { + let t = FlexTensor::from_data(TensorData::new(vec![10u16, 20, 30, 40, 50, 60], [2, 3])); + let indices = FlexTensor::from_data(TensorData::new(vec![0i64, 1], [2])); + let result = Flex::int_select(t, 1, indices); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![10, 20, 40, 50]); + } + + #[test] + fn test_int_cumsum_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![1i32, 2, 3, 4], [4])); + let result = Flex::int_cumsum(t, 0); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1, 3, 6, 10]); + } + + #[test] + fn test_int_cumprod_u8() { + let t = FlexTensor::from_data(TensorData::new(vec![1u8, 2, 3, 4], [4])); + let result = Flex::int_cumprod(t, 0); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1, 2, 6, 24]); + } + + #[test] + fn test_int_cummin_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![3i32, 1, 4, 1, 5], [5])); + let result = Flex::int_cummin(t, 0); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![3, 1, 1, 1, 1]); + } + + #[test] + fn test_int_cummax_u16() { + let t = FlexTensor::from_data(TensorData::new(vec![3u16, 1, 4, 1, 5], [5])); + let result = Flex::int_cummax(t, 0); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![3, 3, 4, 4, 5]); + } + + #[test] + fn test_int_scatter_add_i32() { + let t = FlexTensor::from_data(TensorData::new(vec![0i32, 0, 0], [1, 3])); + let indices = FlexTensor::from_data(TensorData::new(vec![0i64, 2, 1], [1, 3])); + let values = FlexTensor::from_data(TensorData::new(vec![10i32, 20, 30], [1, 3])); + let result = Flex::int_scatter_add(1, t, indices, values); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![10, 30, 20]); + } + + #[test] + fn test_int_select_add_u8() { + let t = FlexTensor::from_data(TensorData::new(vec![1u8, 2, 3], [3])); + let indices = FlexTensor::from_data(TensorData::new(vec![0i64, 2], [2])); + let values = FlexTensor::from_data(TensorData::new(vec![10u8, 20], [2])); + let result = Flex::int_select_add(t, 0, indices, values); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![11, 2, 23]); + } + + #[test] + fn test_int_random_i32() { + use burn_backend::{DType, Distribution, ops::IntTensorOps}; + use burn_std::{IntDType, Shape}; + + let shape = Shape::from(vec![100]); + let dist = Distribution::Uniform(0.0, 10.0); + let device = crate::FlexDevice; + let t = Flex::int_random(shape, dist, &device, IntDType::I32); + assert_eq!(t.dtype(), DType::I32); + let data: Vec = t.into_data().to_vec().unwrap(); + assert!(data.iter().all(|&v| (0..=10).contains(&v))); + } + + #[test] + fn test_int_random_u8() { + use burn_backend::{DType, Distribution, ops::IntTensorOps}; + use burn_std::{IntDType, Shape}; + + let shape = Shape::from(vec![50]); + let dist = Distribution::Uniform(0.0, 100.0); + let device = crate::FlexDevice; + let t = Flex::int_random(shape, dist, &device, IntDType::U8); + assert_eq!(t.dtype(), DType::U8); + } + + #[test] + fn test_int_mean_i32() { + use burn_backend::{DType, ops::IntTensorOps}; + + let t = FlexTensor::from_data(TensorData::new(vec![10i32, 20, 30], [3])); + let result = Flex::int_mean(t); + assert_eq!(result.dtype(), DType::I32); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![20]); // (10 + 20 + 30) / 3 = 20 + } +} diff --git a/crates/burn-flex/src/ops/interpolate.rs b/crates/burn-flex/src/ops/interpolate.rs new file mode 100644 index 0000000..d42a54f --- /dev/null +++ b/crates/burn-flex/src/ops/interpolate.rs @@ -0,0 +1,1187 @@ +//! Interpolation operations for image resizing. +//! +//! Supported modes: +//! - Nearest: Floor-based coordinate mapping (fastest) +//! - Bilinear: 4-point weighted average (good quality/speed tradeoff) +//! - Bicubic: 16-point cubic convolution (highest quality) +//! +//! Optimizations: +//! - Rayon parallelism over (batch, channel) pairs +//! - Precomputed coordinate mappings where beneficial +//! +//! Supported dtypes: f32, f64, f16 (native), bf16 (via f32 conversion) + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::DType; +use burn_std::{Bytes, Shape, bf16, f16}; +use num_traits::Float; + +use crate::{FlexTensor, Layout}; + +// ============================================================================ +// Macros for dtype wrappers +// ============================================================================ + +/// Generates an interpolation forward typed dispatcher. +macro_rules! interpolate_typed { + ($fn_name:ident, $impl_fn:ident, $T:ty) => { + pub fn $fn_name(x: FlexTensor, output_size: [usize; 2], align_corners: bool) -> FlexTensor { + $impl_fn::<$T>(x, output_size, align_corners) + } + }; +} + +/// Generates an interpolation bf16 forward wrapper via f32 conversion. +macro_rules! interpolate_bf16 { + ($bf16_fn:ident, $f32_fn:ident) => { + pub fn $bf16_fn(x: FlexTensor, output_size: [usize; 2], align_corners: bool) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let result_f32 = $f32_fn(x_f32, output_size, align_corners); + convert_f32_to_bf16(&result_f32) + } + }; +} + +/// Generates an interpolation backward typed dispatcher. +macro_rules! interpolate_backward_typed { + ($fn_name:ident, $impl_fn:ident, $T:ty) => { + pub fn $fn_name( + x: FlexTensor, + grad: FlexTensor, + output_size: [usize; 2], + align_corners: bool, + ) -> FlexTensor { + $impl_fn::<$T>(x, grad, output_size, align_corners) + } + }; +} + +/// Generates an interpolation bf16 backward wrapper via f32 conversion. +macro_rules! interpolate_backward_bf16 { + ($bf16_fn:ident, $f32_fn:ident) => { + pub fn $bf16_fn( + x: FlexTensor, + grad: FlexTensor, + output_size: [usize; 2], + align_corners: bool, + ) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let grad_f32 = convert_bf16_to_f32(&grad); + let result_f32 = $f32_fn(x_f32, grad_f32, output_size, align_corners); + convert_f32_to_bf16(&result_f32) + } + }; +} + +// ============================================================================ +// Public API - dtype dispatch +// ============================================================================ + +interpolate_typed!(interpolate_nearest_f32, interpolate_nearest_impl, f32); +interpolate_typed!(interpolate_nearest_f64, interpolate_nearest_impl, f64); +interpolate_typed!(interpolate_nearest_f16, interpolate_nearest_impl, f16); +interpolate_bf16!(interpolate_nearest_bf16, interpolate_nearest_f32); + +interpolate_typed!(interpolate_bilinear_f32, interpolate_bilinear_impl, f32); +interpolate_typed!(interpolate_bilinear_f64, interpolate_bilinear_impl, f64); +interpolate_typed!(interpolate_bilinear_f16, interpolate_bilinear_impl, f16); +interpolate_bf16!(interpolate_bilinear_bf16, interpolate_bilinear_f32); + +interpolate_typed!(interpolate_bicubic_f32, interpolate_bicubic_impl, f32); +interpolate_typed!(interpolate_bicubic_f64, interpolate_bicubic_impl, f64); +interpolate_typed!(interpolate_bicubic_f16, interpolate_bicubic_impl, f16); +interpolate_bf16!(interpolate_bicubic_bf16, interpolate_bicubic_f32); + +interpolate_typed!(interpolate_lanczos3_f32, interpolate_lanczos3_impl, f32); +interpolate_typed!(interpolate_lanczos3_f64, interpolate_lanczos3_impl, f64); +interpolate_typed!(interpolate_lanczos3_f16, interpolate_lanczos3_impl, f16); +interpolate_bf16!(interpolate_lanczos3_bf16, interpolate_lanczos3_f32); + +// ============================================================================ +// Backward pass - dtype dispatch +// ============================================================================ + +interpolate_backward_typed!( + interpolate_nearest_backward_f32, + interpolate_nearest_backward_impl, + f32 +); +interpolate_backward_typed!( + interpolate_nearest_backward_f64, + interpolate_nearest_backward_impl, + f64 +); +interpolate_backward_typed!( + interpolate_nearest_backward_f16, + interpolate_nearest_backward_impl, + f16 +); +interpolate_backward_bf16!( + interpolate_nearest_backward_bf16, + interpolate_nearest_backward_f32 +); + +interpolate_backward_typed!( + interpolate_bilinear_backward_f32, + interpolate_bilinear_backward_impl, + f32 +); +interpolate_backward_typed!( + interpolate_bilinear_backward_f64, + interpolate_bilinear_backward_impl, + f64 +); +interpolate_backward_typed!( + interpolate_bilinear_backward_f16, + interpolate_bilinear_backward_impl, + f16 +); +interpolate_backward_bf16!( + interpolate_bilinear_backward_bf16, + interpolate_bilinear_backward_f32 +); + +interpolate_backward_typed!( + interpolate_bicubic_backward_f32, + interpolate_bicubic_backward_impl, + f32 +); +interpolate_backward_typed!( + interpolate_bicubic_backward_f64, + interpolate_bicubic_backward_impl, + f64 +); +interpolate_backward_typed!( + interpolate_bicubic_backward_f16, + interpolate_bicubic_backward_impl, + f16 +); +interpolate_backward_bf16!( + interpolate_bicubic_backward_bf16, + interpolate_bicubic_backward_f32 +); + +// ============================================================================ +// Generic implementations with rayon parallelism +// ============================================================================ + +/// Compute coordinate mapping parameters. +/// +/// align_corners=true: ratio = (in_size - 1) / (out_size - 1), coord = out * ratio +/// align_corners=false: ratio = in_size / out_size, coord = (out + 0.5) * ratio - 0.5 +fn coord_ratio(in_size: usize, out_size: usize, align_corners: bool) -> f64 { + if align_corners { + (in_size as f64 - 1.0) / (out_size.max(1) - 1).max(1) as f64 + } else { + in_size as f64 / out_size as f64 + } +} + +/// Map an output coordinate to input coordinate. +#[inline] +fn map_coord(out_coord: usize, ratio: f64, align_corners: bool) -> f64 { + if align_corners { + out_coord as f64 * ratio + } else { + (out_coord as f64 + 0.5) * ratio - 0.5 + } +} + +/// Precompute nearest-neighbor source index lookup table. +/// Returns a Vec where `map[out_coord]` is the corresponding input coordinate. +fn nearest_index_map(in_size: usize, out_size: usize) -> Vec { + let ratio = in_size as f64 / out_size as f64; + let max = in_size - 1; + (0..out_size) + .map(|o| ((o as f64 * ratio).floor() as usize).min(max)) + .collect() +} + +/// Nearest neighbor interpolation. +/// Maps output coordinates to input using floor(ratio * out_coord) via precomputed lookup tables. +fn interpolate_nearest_impl( + x: FlexTensor, + output_size: [usize; 2], + _align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod + Send + Sync, +{ + let x = x.to_contiguous(); + let input = x.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_map = nearest_index_map(in_height, out_height); + let x_map = nearest_index_map(in_width, out_width); + + let out_numel = batch * channels * out_height * out_width; + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + + // Per-plane nearest gather using precomputed index maps. + #[inline] + fn gather_plane( + input: &[T], + in_base: usize, + output: &mut [T], + in_width: usize, + out_width: usize, + y_map: &[usize], + x_map: &[usize], + ) { + for (oh, &ih) in y_map.iter().enumerate() { + let in_row = in_base + ih * in_width; + let out_row_start = oh * out_width; + for (ow, &iw) in x_map.iter().enumerate() { + output[out_row_start + ow] = input[in_row + iw]; + } + } + } + + let output = { + let mut output: Vec = Vec::with_capacity(out_numel); + #[allow(clippy::uninit_vec)] + unsafe { + output.set_len(out_numel); + } + + let bc = batch * channels; + + // Each (batch, channel) plane is independent, so parallelize across planes. + #[cfg(feature = "rayon")] + if out_numel >= super::PARALLEL_THRESHOLD { + use rayon::prelude::*; + + output + .par_chunks_mut(out_hw) + .enumerate() + .for_each(|(bc_idx, out_plane)| { + let in_base = bc_idx * in_hw; + gather_plane( + input, in_base, out_plane, in_width, out_width, &y_map, &x_map, + ); + }); + } else { + for bc_idx in 0..bc { + let in_base = bc_idx * in_hw; + let out_start = bc_idx * out_hw; + gather_plane( + input, + in_base, + &mut output[out_start..out_start + out_hw], + in_width, + out_width, + &y_map, + &x_map, + ); + } + } + #[cfg(not(feature = "rayon"))] + for bc_idx in 0..bc { + let in_base = bc_idx * in_hw; + let out_start = bc_idx * out_hw; + gather_plane( + input, + in_base, + &mut output[out_start..out_start + out_hw], + in_width, + out_width, + &y_map, + &x_map, + ); + } + output + }; + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(Shape::from(vec![batch, channels, out_height, out_width])), + x.dtype(), + ) +} + +/// Bilinear interpolation. +/// Uses 4-point weighted average based on distance to neighbors. +fn interpolate_bilinear_impl( + x: FlexTensor, + output_size: [usize; 2], + align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod + Send + Sync, +{ + let x = x.to_contiguous(); + let input = x.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_ratio = coord_ratio(in_height, out_height, align_corners); + let x_ratio = coord_ratio(in_width, out_width, align_corners); + + let out_numel = batch * channels * out_height * out_width; + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![T::zero(); out_numel]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + (0..batch).into_par_iter().for_each(|b| { + (0..channels).into_par_iter().for_each(|c| { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y_low = (y_in.floor().max(0.0)) as usize; + let y_high = (y_low + 1).min(in_height - 1); + let y_weight = T::from((y_in - y_low as f64).max(0.0)).unwrap(); + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x_low = (x_in.floor().max(0.0)) as usize; + let x_high = (x_low + 1).min(in_width - 1); + let x_weight = T::from((x_in - x_low as f64).max(0.0)).unwrap(); + + let p_a = input[in_base + y_low * in_width + x_low]; + let p_b = input[in_base + y_low * in_width + x_high]; + let p_c = input[in_base + y_high * in_width + x_low]; + let p_d = input[in_base + y_high * in_width + x_high]; + + let one = T::one(); + let result = p_a * (one - x_weight) * (one - y_weight) + + p_b * x_weight * (one - y_weight) + + p_c * (one - x_weight) * y_weight + + p_d * x_weight * y_weight; + + let out_idx = out_base + oh * out_width + ow; + unsafe { + out_ptr.write(out_idx, result); + } + } + } + }); + }); + output + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![T::zero(); out_numel]; + + for b in 0..batch { + for c in 0..channels { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y_low = (y_in.floor().max(0.0)) as usize; + let y_high = (y_low + 1).min(in_height - 1); + let y_weight = T::from((y_in - y_low as f64).max(0.0)).unwrap(); + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x_low = (x_in.floor().max(0.0)) as usize; + let x_high = (x_low + 1).min(in_width - 1); + let x_weight = T::from((x_in - x_low as f64).max(0.0)).unwrap(); + + let p_a = input[in_base + y_low * in_width + x_low]; + let p_b = input[in_base + y_low * in_width + x_high]; + let p_c = input[in_base + y_high * in_width + x_low]; + let p_d = input[in_base + y_high * in_width + x_high]; + + let one = T::one(); + let result = p_a * (one - x_weight) * (one - y_weight) + + p_b * x_weight * (one - y_weight) + + p_c * (one - x_weight) * y_weight + + p_d * x_weight * y_weight; + + output[out_base + oh * out_width + ow] = result; + } + } + } + } + output + } + }; + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(Shape::from(vec![batch, channels, out_height, out_width])), + x.dtype(), + ) +} + +/// Bicubic interpolation using cubic convolution. +fn interpolate_bicubic_impl( + x: FlexTensor, + output_size: [usize; 2], + align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod + Send + Sync, +{ + let x = x.to_contiguous(); + let input = x.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_ratio = coord_ratio(in_height, out_height, align_corners); + let x_ratio = coord_ratio(in_width, out_width, align_corners); + + let out_numel = batch * channels * out_height * out_width; + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + let a = -0.75_f64; + + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![T::zero(); out_numel]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + let num_bc_pairs = batch * channels; + + // Adaptive parallelization: if few (batch, channel) pairs, parallelize rows too + if num_bc_pairs < 8 { + // Fine-grained: parallelize over (batch, channel, row) for better CPU utilization + let total_rows = batch * channels * out_height; + (0..total_rows).into_par_iter().for_each(|id| { + let b = id / (channels * out_height); + let remainder = id % (channels * out_height); + let c = remainder / out_height; + let oh = remainder % out_height; + + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor() as isize; + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x0 = x_in.floor() as isize; + + let mut sum = 0.0_f64; + + for dy in -1..=2_isize { + let y = y0 + dy; + let y_clamped = y.clamp(0, in_height as isize - 1) as usize; + let ty = (y_in - y0 as f64) - dy as f64; + let wy = cubic_weight(ty, a); + + for dx in -1..=2_isize { + let x = x0 + dx; + let x_clamped = x.clamp(0, in_width as isize - 1) as usize; + let tx = (x_in - x0 as f64) - dx as f64; + let wx = cubic_weight(tx, a); + + let val = input[in_base + y_clamped * in_width + x_clamped]; + let val_f64 = + ::to_f64(&val).unwrap_or(0.0); + sum += val_f64 * wx * wy; + } + } + + let out_idx = out_base + oh * out_width + ow; + unsafe { + out_ptr.write(out_idx, T::from(sum).unwrap()); + } + } + }); + } else { + // Coarse-grained: parallelize over (batch, channel) for cache efficiency + (0..batch).into_par_iter().for_each(|b| { + (0..channels).into_par_iter().for_each(|c| { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor() as isize; + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x0 = x_in.floor() as isize; + + let mut sum = 0.0_f64; + + for dy in -1..=2_isize { + let y = y0 + dy; + let y_clamped = y.clamp(0, in_height as isize - 1) as usize; + let ty = (y_in - y0 as f64) - dy as f64; + let wy = cubic_weight(ty, a); + + for dx in -1..=2_isize { + let x = x0 + dx; + let x_clamped = x.clamp(0, in_width as isize - 1) as usize; + let tx = (x_in - x0 as f64) - dx as f64; + let wx = cubic_weight(tx, a); + + let val = input[in_base + y_clamped * in_width + x_clamped]; + let val_f64 = ::to_f64(&val) + .unwrap_or(0.0); + sum += val_f64 * wx * wy; + } + } + + let out_idx = out_base + oh * out_width + ow; + unsafe { + out_ptr.write(out_idx, T::from(sum).unwrap()); + } + } + } + }); + }); + } + output + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![T::zero(); out_numel]; + + for b in 0..batch { + for c in 0..channels { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor() as isize; + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x0 = x_in.floor() as isize; + + let mut sum = 0.0_f64; + + for dy in -1..=2_isize { + let y = y0 + dy; + let y_clamped = y.clamp(0, in_height as isize - 1) as usize; + let ty = (y_in - y0 as f64) - dy as f64; + let wy = cubic_weight(ty, a); + + for dx in -1..=2_isize { + let x = x0 + dx; + let x_clamped = x.clamp(0, in_width as isize - 1) as usize; + let tx = (x_in - x0 as f64) - dx as f64; + let wx = cubic_weight(tx, a); + + let val = input[in_base + y_clamped * in_width + x_clamped]; + let val_f64 = + ::to_f64(&val).unwrap_or(0.0); + sum += val_f64 * wx * wy; + } + } + + output[out_base + oh * out_width + ow] = T::from(sum).unwrap(); + } + } + } + } + output + } + }; + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(Shape::from(vec![batch, channels, out_height, out_width])), + x.dtype(), + ) +} + +/// Cubic convolution weight function. +/// Uses the Keys cubic interpolation kernel. +#[inline] +fn cubic_weight(t: f64, a: f64) -> f64 { + let t_abs = t.abs(); + if t_abs < 1.0 { + ((a + 2.0) * t_abs - (a + 3.0)) * t_abs * t_abs + 1.0 + } else if t_abs < 2.0 { + ((a * t_abs - 5.0 * a) * t_abs + 8.0 * a) * t_abs - 4.0 * a + } else { + 0.0 + } +} + +/// Lanczos3 interpolation weight function. +/// Uses a sinc-windowed sinc kernel with a=3. +#[inline] +fn lanczos3_weight(x: f64) -> f64 { + if x == 0.0 { + return 1.0; + } + let abs_x = x.abs(); + if abs_x >= 3.0 { + return 0.0; + } + let pi = core::f64::consts::PI; + let pi_x = pi * x; + let pi_x_over_3 = pi_x / 3.0; + (pi_x.sin() * pi_x_over_3.sin()) / (pi_x * pi_x_over_3) +} + +/// Lanczos3 interpolation (6x6 sinc-windowed kernel). +/// +/// Uses skip-and-renormalize boundary handling: out-of-bounds samples are +/// excluded and weights are renormalized. This avoids edge ringing artifacts +/// from replicated boundary pixels (unlike bicubic which clamps to edge). +/// Matches the ndarray reference implementation. +fn interpolate_lanczos3_impl( + x: FlexTensor, + output_size: [usize; 2], + align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod + Send + Sync, +{ + let x = x.to_contiguous(); + let input = x.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_ratio = coord_ratio(in_height, out_height, align_corners); + let x_ratio = coord_ratio(in_width, out_width, align_corners); + + let out_numel = batch * channels * out_height * out_width; + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + let max_h = in_height as isize - 1; + let max_w = in_width as isize - 1; + + /// Compute one Lanczos3 output pixel given pre-computed y coordinates. + #[inline] + #[allow(clippy::too_many_arguments)] + fn lanczos3_sample( + input: &[T], + in_base: usize, + in_width: usize, + y_in: f64, + y0: f64, + x_in: f64, + max_h: isize, + max_w: isize, + ) -> T { + let x0 = x_in.floor(); + let mut result = 0.0_f64; + let mut weight_sum = 0.0_f64; + + for ky in -2..=3_isize { + let yi = y0 as isize + ky; + if yi < 0 || yi > max_h { + continue; + } + let wy = lanczos3_weight(y_in - (y0 + ky as f64)); + for kx in -2..=3_isize { + let xi = x0 as isize + kx; + if xi < 0 || xi > max_w { + continue; + } + let wx = lanczos3_weight(x_in - (x0 + kx as f64)); + let w = wy * wx; + let val = input[in_base + yi as usize * in_width + xi as usize]; + let val_f64 = ::to_f64(&val).unwrap_or(0.0); + result += val_f64 * w; + weight_sum += w; + } + } + + if weight_sum != 0.0 { + result /= weight_sum; + } + T::from(result).unwrap() + } + + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![T::zero(); out_numel]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + let total_rows = batch * channels * out_height; + (0..total_rows).into_par_iter().for_each(|id| { + let b = id / (channels * out_height); + let remainder = id % (channels * out_height); + let c = remainder / out_height; + let oh = remainder % out_height; + + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor(); + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let out_idx = out_base + oh * out_width + ow; + unsafe { + out_ptr.write( + out_idx, + lanczos3_sample(input, in_base, in_width, y_in, y0, x_in, max_h, max_w), + ); + } + } + }); + output + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![T::zero(); out_numel]; + + for b in 0..batch { + for c in 0..channels { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor(); + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + output[out_base + oh * out_width + ow] = lanczos3_sample( + input, in_base, in_width, y_in, y0, x_in, max_h, max_w, + ); + } + } + } + } + output + } + }; + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(Shape::from(vec![batch, channels, out_height, out_width])), + x.dtype(), + ) +} + +// ============================================================================ +// Backward implementations +// ============================================================================ + +/// Nearest neighbor backward: accumulates gradients at source positions. +fn interpolate_nearest_backward_impl( + x: FlexTensor, + grad: FlexTensor, + output_size: [usize; 2], + _align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod + Send + Sync, +{ + let grad = grad.to_contiguous(); + let grad_data = grad.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_map = nearest_index_map(in_height, out_height); + let x_map = nearest_index_map(in_width, out_width); + + let in_numel = batch * channels * in_height * in_width; + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + + // Scatter-add gradients from one output plane into one input gradient plane. + #[inline] + fn scatter_plane( + grad_data: &[T], + grad_base: usize, + input_grad: &mut [T], + in_width: usize, + out_width: usize, + y_map: &[usize], + x_map: &[usize], + ) { + for (oh, &ih) in y_map.iter().enumerate() { + let grad_row = grad_base + oh * out_width; + for (ow, &iw) in x_map.iter().enumerate() { + input_grad[ih * in_width + iw] = + input_grad[ih * in_width + iw] + grad_data[grad_row + ow]; + } + } + } + + let mut input_grad = vec![T::zero(); in_numel]; + let bc = batch * channels; + + // Each (batch, channel) plane is independent, so parallelize across planes. + // Gate on output size since the work is proportional to iterating output pixels. + #[cfg(feature = "rayon")] + if bc * out_hw >= super::PARALLEL_THRESHOLD { + use rayon::prelude::*; + + input_grad + .par_chunks_mut(in_hw) + .enumerate() + .for_each(|(bc_idx, grad_plane)| { + let grad_base = bc_idx * out_hw; + scatter_plane( + grad_data, grad_base, grad_plane, in_width, out_width, &y_map, &x_map, + ); + }); + } else { + for bc_idx in 0..bc { + let grad_base = bc_idx * out_hw; + let in_start = bc_idx * in_hw; + scatter_plane( + grad_data, + grad_base, + &mut input_grad[in_start..in_start + in_hw], + in_width, + out_width, + &y_map, + &x_map, + ); + } + } + #[cfg(not(feature = "rayon"))] + for bc_idx in 0..bc { + let grad_base = bc_idx * out_hw; + let in_start = bc_idx * in_hw; + scatter_plane( + grad_data, + grad_base, + &mut input_grad[in_start..in_start + in_hw], + in_width, + out_width, + &y_map, + &x_map, + ); + } + + FlexTensor::new( + Bytes::from_elems(input_grad), + Layout::contiguous(Shape::from(vec![batch, channels, in_height, in_width])), + x.dtype(), + ) +} + +/// Bilinear backward: distributes gradients to 4 source positions weighted by bilinear coefficients. +fn interpolate_bilinear_backward_impl( + x: FlexTensor, + grad: FlexTensor, + output_size: [usize; 2], + align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod, +{ + let grad = grad.to_contiguous(); + let grad_data = grad.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_ratio = coord_ratio(in_height, out_height, align_corners); + let x_ratio = coord_ratio(in_width, out_width, align_corners); + + let in_numel = batch * channels * in_height * in_width; + let mut input_grad = vec![T::zero(); in_numel]; + + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + + for b in 0..batch { + for c in 0..channels { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y_low = (y_in.floor().max(0.0)) as usize; + let y_high = (y_low + 1).min(in_height - 1); + let y_weight = T::from((y_in - y_low as f64).max(0.0)).unwrap(); + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x_low = (x_in.floor().max(0.0)) as usize; + let x_high = (x_low + 1).min(in_width - 1); + let x_weight = T::from((x_in - x_low as f64).max(0.0)).unwrap(); + + let grad_val = grad_data[out_base + oh * out_width + ow]; + let one = T::one(); + + input_grad[in_base + y_low * in_width + x_low] = input_grad + [in_base + y_low * in_width + x_low] + + grad_val * (one - x_weight) * (one - y_weight); + input_grad[in_base + y_low * in_width + x_high] = input_grad + [in_base + y_low * in_width + x_high] + + grad_val * x_weight * (one - y_weight); + input_grad[in_base + y_high * in_width + x_low] = input_grad + [in_base + y_high * in_width + x_low] + + grad_val * (one - x_weight) * y_weight; + input_grad[in_base + y_high * in_width + x_high] = input_grad + [in_base + y_high * in_width + x_high] + + grad_val * x_weight * y_weight; + } + } + } + } + + FlexTensor::new( + Bytes::from_elems(input_grad), + Layout::contiguous(Shape::from(vec![batch, channels, in_height, in_width])), + x.dtype(), + ) +} + +/// Bicubic backward: distributes gradients to 16 source positions weighted by cubic coefficients. +fn interpolate_bicubic_backward_impl( + x: FlexTensor, + grad: FlexTensor, + output_size: [usize; 2], + align_corners: bool, +) -> FlexTensor +where + T: Float + burn_backend::Element + bytemuck::Pod, +{ + let grad = grad.to_contiguous(); + let grad_data = grad.storage::(); + let shape = x.layout().shape(); + + let batch = shape[0]; + let channels = shape[1]; + let in_height = shape[2]; + let in_width = shape[3]; + assert!( + in_height > 0 && in_width > 0, + "interpolate: input spatial dimensions must be > 0" + ); + let [out_height, out_width] = output_size; + + let y_ratio = coord_ratio(in_height, out_height, align_corners); + let x_ratio = coord_ratio(in_width, out_width, align_corners); + + let in_numel = batch * channels * in_height * in_width; + let mut input_grad = vec![T::zero(); in_numel]; + + let in_hw = in_height * in_width; + let out_hw = out_height * out_width; + let a = -0.75_f64; + + for b in 0..batch { + for c in 0..channels { + let in_base = b * channels * in_hw + c * in_hw; + let out_base = b * channels * out_hw + c * out_hw; + + for oh in 0..out_height { + let y_in = map_coord(oh, y_ratio, align_corners); + let y0 = y_in.floor() as isize; + + for ow in 0..out_width { + let x_in = map_coord(ow, x_ratio, align_corners); + let x0 = x_in.floor() as isize; + + let grad_val = ::to_f64( + &grad_data[out_base + oh * out_width + ow], + ) + .unwrap_or(0.0); + + for dy in -1..=2_isize { + let y = y0 + dy; + let y_idx = y.clamp(0, in_height as isize - 1) as usize; + let ty = (y_in - y0 as f64) - dy as f64; + let wy = cubic_weight(ty, a); + + for dx in -1..=2_isize { + let x = x0 + dx; + let x_idx = x.clamp(0, in_width as isize - 1) as usize; + let tx = (x_in - x0 as f64) - dx as f64; + let wx = cubic_weight(tx, a); + + let weight = wx * wy * grad_val; + input_grad[in_base + y_idx * in_width + x_idx] = input_grad + [in_base + y_idx * in_width + x_idx] + + T::from(weight).unwrap(); + } + } + } + } + } + } + + FlexTensor::new( + Bytes::from_elems(input_grad), + Layout::contiguous(Shape::from(vec![batch, channels, in_height, in_width])), + x.dtype(), + ) +} + +// ============================================================================ +// Dtype conversion helpers +// ============================================================================ + +fn convert_bf16_to_f32(x: &FlexTensor) -> FlexTensor { + let x = x.clone().to_contiguous(); + let input = x.storage::(); + let output: Vec = input.iter().map(|v| v.to_f32()).collect(); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(x.layout().shape().clone()), + DType::F32, + ) +} + +fn convert_f32_to_bf16(x: &FlexTensor) -> FlexTensor { + let x = x.clone().to_contiguous(); + let input = x.storage::(); + let output: Vec = input.iter().map(|v| bf16::from_f32(*v)).collect(); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(x.layout().shape().clone()), + DType::BF16, + ) +} + +// Tests kept here exercise flex-specific behavior: the f32-typed +// internal helpers (`interpolate_nearest_f32`, `interpolate_bilinear_f32`, +// `interpolate_bicubic_f32`, `interpolate_nearest_backward_f32`) that +// back the dtype-dispatching public ops. End-to-end interpolate +// correctness across backends lives in the shared module tests under +// crates/burn-backend-tests/tests/tensor/float/module/{bicubic,bilinear, +// lanczos3,nearest}_interpolate.rs. +#[cfg(test)] +mod tests { + use super::*; + + fn make_input_f32(batch: usize, channels: usize, height: usize, width: usize) -> FlexTensor { + let numel = batch * channels * height * width; + let data: Vec = (0..numel).map(|i| i as f32).collect(); + FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(vec![batch, channels, height, width])), + DType::F32, + ) + } + + #[test] + fn test_nearest_upsample_2x() { + let data = vec![1.0f32, 2.0, 3.0, 4.0]; + let x = FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(vec![1, 1, 2, 2])), + DType::F32, + ); + + let result = interpolate_nearest_f32(x, [4, 4], true); + let output = result.storage::(); + + assert_eq!(output.len(), 16); + assert_eq!(output[0], 1.0); + assert_eq!(output[1], 1.0); + assert_eq!(output[2], 2.0); + assert_eq!(output[3], 2.0); + } + + #[test] + fn test_bilinear_upsample_2x() { + let data = vec![0.0f32, 1.0, 1.0, 0.0]; + let x = FlexTensor::new( + Bytes::from_elems(data), + Layout::contiguous(Shape::from(vec![1, 1, 2, 2])), + DType::F32, + ); + + let result = interpolate_bilinear_f32(x, [4, 4], true); + let output = result.storage::(); + + assert!((output[0] - 0.0).abs() < 1e-5); + assert!((output[3] - 1.0).abs() < 1e-5); + assert!((output[12] - 1.0).abs() < 1e-5); + assert!((output[15] - 0.0).abs() < 1e-5); + } + + #[test] + fn test_bicubic_basic() { + let x = make_input_f32(1, 1, 4, 4); + let result = interpolate_bicubic_f32(x, [8, 8], true); + assert_eq!(result.layout().shape().to_vec(), vec![1, 1, 8, 8]); + } + + #[test] + fn test_downsample() { + let x = make_input_f32(1, 1, 4, 4); + let result = interpolate_nearest_f32(x, [2, 2], true); + assert_eq!(result.layout().shape().to_vec(), vec![1, 1, 2, 2]); + } + + #[test] + fn test_nearest_backward() { + let x = make_input_f32(1, 1, 2, 2); + let grad = FlexTensor::new( + Bytes::from_elems(vec![1.0f32; 16]), + Layout::contiguous(Shape::from(vec![1, 1, 4, 4])), + DType::F32, + ); + + let result = interpolate_nearest_backward_f32(x, grad, [4, 4], true); + let output = result.storage::(); + + assert_eq!(output.len(), 4); + assert!((output[0] - 4.0).abs() < 1e-5); + } +} diff --git a/crates/burn-flex/src/ops/mask.rs b/crates/burn-flex/src/ops/mask.rs new file mode 100644 index 0000000..6e7cfad --- /dev/null +++ b/crates/burn-flex/src/ops/mask.rs @@ -0,0 +1,318 @@ +//! Mask operations for conditional element replacement. + +use alloc::vec::Vec; +use burn_backend::Element; +use burn_std::{Bytes, bf16, f16}; + +use crate::{FlexTensor, Layout}; + +/// Allocate a Vec of given length without zeroing. +/// The caller must write every element before reading. +#[cfg(feature = "simd")] +#[inline] +fn uninit_vec(len: usize) -> Vec { + let mut v = Vec::with_capacity(len); + #[allow(clippy::uninit_vec)] + unsafe { + v.set_len(len); + } + v +} + +/// Fill tensor elements with a value where mask is true. +/// +/// mask_fill(tensor, mask, value) -> tensor with elements replaced where mask is true +pub fn mask_fill(tensor: FlexTensor, mask: FlexTensor, value: T) -> FlexTensor +where + T: Element + bytemuck::Pod + Copy, +{ + let dtype = tensor.dtype(); + + // Broadcast mask to tensor shape if needed + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + + let shape = tensor.layout().shape().clone(); + let tensor_data: &[T] = tensor.storage(); + let mask_data: &[u8] = mask.bytes(); + + let result: Vec = tensor_data + .iter() + .zip(mask_data.iter()) + .map(|(&elem, &m)| if m != 0 { value } else { elem }) + .collect(); + + FlexTensor::new(Bytes::from_elems(result), Layout::contiguous(shape), dtype) +} + +/// Mask fill for f32 (SIMD-accelerated). +pub fn mask_fill_f32(tensor: FlexTensor, mask: FlexTensor, value: f32) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_fill_f32(tensor.storage(), mask.bytes(), value, &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_fill(tensor, mask, value) + } +} + +/// Mask fill for f64 (SIMD-accelerated). +pub fn mask_fill_f64(tensor: FlexTensor, mask: FlexTensor, value: f64) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_fill_f64(tensor.storage(), mask.bytes(), value, &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_fill(tensor, mask, value) + } +} + +/// Mask fill for f16. +pub fn mask_fill_f16(tensor: FlexTensor, mask: FlexTensor, value: f16) -> FlexTensor { + mask_fill(tensor, mask, value) +} + +/// Mask fill for bf16. +pub fn mask_fill_bf16(tensor: FlexTensor, mask: FlexTensor, value: bf16) -> FlexTensor { + mask_fill(tensor, mask, value) +} + +/// Mask fill for i64 (SIMD-accelerated). +pub fn mask_fill_i64(tensor: FlexTensor, mask: FlexTensor, value: i64) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_fill_i64(tensor.storage(), mask.bytes(), value, &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_fill(tensor, mask, value) + } +} + +/// Mask fill for u64. +pub fn mask_fill_u64(tensor: FlexTensor, mask: FlexTensor, value: u64) -> FlexTensor { + mask_fill(tensor, mask, value) +} + +/// Mask fill for bool tensors (SIMD-accelerated). +pub fn mask_fill_bool(tensor: FlexTensor, mask: FlexTensor, value: bool) -> FlexTensor { + // Preserve the input tensor's bool dtype for the output. + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + #[cfg(feature = "simd")] + { + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let len = tensor.bytes().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_fill_u8(tensor.bytes(), mask.bytes(), value as u8, &mut out); + crate::ops::comparison::make_bool_tensor(out, shape, out_dtype) + } + #[cfg(not(feature = "simd"))] + { + let (tensor, mask) = crate::ops::expand::broadcast_binary(tensor, mask); + let tensor = tensor.to_contiguous(); + let mask = mask.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let tensor_data: &[u8] = tensor.bytes(); + let mask_data: &[u8] = mask.bytes(); + let value_u8 = value as u8; + let result: Vec = tensor_data + .iter() + .zip(mask_data.iter()) + .map(|(&elem, &m)| if m != 0 { value_u8 } else { elem }) + .collect(); + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) + } +} + +/// Replace elements from value tensor where mask is true. +/// +/// mask_where(tensor, mask, value) -> tensor with elements from value where mask is true +pub fn mask_where(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor +where + T: Element + bytemuck::Pod + Copy, +{ + let dtype = tensor.dtype(); + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + + let shape = tensor.layout().shape().clone(); + let tensor_data: &[T] = tensor.storage(); + let mask_data: &[u8] = mask.bytes(); + let value_data: &[T] = value.storage(); + + let result: Vec = tensor_data + .iter() + .zip(mask_data.iter()) + .zip(value_data.iter()) + .map(|((&t, &m), &v)| if m != 0 { v } else { t }) + .collect(); + + FlexTensor::new(Bytes::from_elems(result), Layout::contiguous(shape), dtype) +} + +/// Helper to broadcast three tensors to the same shape. +fn broadcast_three( + tensor: FlexTensor, + mask: FlexTensor, + value: FlexTensor, +) -> (FlexTensor, FlexTensor, FlexTensor) { + let target_shape = + crate::ops::expand::broadcast_shape(tensor.layout().shape(), mask.layout().shape()); + let target_shape = crate::ops::expand::broadcast_shape(&target_shape, value.layout().shape()); + + let tensor = if tensor.layout().shape() == &target_shape { + tensor + } else { + crate::ops::expand::expand(tensor, target_shape.clone()) + }; + let mask = if mask.layout().shape() == &target_shape { + mask + } else { + crate::ops::expand::expand(mask, target_shape.clone()) + }; + let value = if value.layout().shape() == &target_shape { + value + } else { + crate::ops::expand::expand(value, target_shape) + }; + + ( + tensor.to_contiguous(), + mask.to_contiguous(), + value.to_contiguous(), + ) +} + +/// Mask where for f32 (SIMD-accelerated). +pub fn mask_where_f32(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_where_f32(tensor.storage(), mask.bytes(), value.storage(), &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_where::(tensor, mask, value) + } +} + +/// Mask where for f64 (SIMD-accelerated). +pub fn mask_where_f64(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_where_f64(tensor.storage(), mask.bytes(), value.storage(), &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_where::(tensor, mask, value) + } +} + +/// Mask where for f16. +pub fn mask_where_f16(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + mask_where::(tensor, mask, value) +} + +/// Mask where for bf16. +pub fn mask_where_bf16(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + mask_where::(tensor, mask, value) +} + +/// Mask where for i64 (SIMD-accelerated). +pub fn mask_where_i64(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + #[cfg(feature = "simd")] + { + let dtype = tensor.dtype(); + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + let shape = tensor.layout().shape().clone(); + let len = tensor.storage::().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_where_i64(tensor.storage(), mask.bytes(), value.storage(), &mut out); + FlexTensor::new(Bytes::from_elems(out), Layout::contiguous(shape), dtype) + } + #[cfg(not(feature = "simd"))] + { + mask_where::(tensor, mask, value) + } +} + +/// Mask where for bool tensors (SIMD-accelerated). +pub fn mask_where_bool(tensor: FlexTensor, mask: FlexTensor, value: FlexTensor) -> FlexTensor { + // Preserve the input tensor's bool dtype for the output. + let out_dtype = burn_std::BoolDType::from(tensor.dtype()); + #[cfg(feature = "simd")] + { + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + let shape = tensor.layout().shape().clone(); + let len = tensor.bytes().len(); + let mut out = uninit_vec::(len); + crate::simd::mask_where_u8(tensor.bytes(), mask.bytes(), value.bytes(), &mut out); + crate::ops::comparison::make_bool_tensor(out, shape, out_dtype) + } + #[cfg(not(feature = "simd"))] + { + let (tensor, mask, value) = broadcast_three(tensor, mask, value); + let shape = tensor.layout().shape().clone(); + let tensor_data: &[u8] = tensor.bytes(); + let mask_data: &[u8] = mask.bytes(); + let value_data: &[u8] = value.bytes(); + let result: Vec = tensor_data + .iter() + .zip(mask_data.iter()) + .zip(value_data.iter()) + .map(|((&t, &m), &v)| if m != 0 { v } else { t }) + .collect(); + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) + } +} + +// All mask_fill / mask_where tests, including negative-stride (flipped / +// transposed / narrowed) variants, have been migrated to +// burn-backend-tests (float/ops/mask.rs, int/ops/mask.rs) so they cover +// every backend. The flex-internal dispatchers `mask_fill_f32` / +// `mask_where_f32` / `mask_fill_i64` exercised there indirectly via +// `Flex::float_mask_fill`, `Flex::int_mask_where`, etc. When adding new +// tests, keep them here only if they probe flex-specific behavior that +// cannot be expressed through the public backend API; otherwise add +// them to burn-backend-tests. diff --git a/crates/burn-flex/src/ops/matmul.rs b/crates/burn-flex/src/ops/matmul.rs new file mode 100644 index 0000000..db70258 --- /dev/null +++ b/crates/burn-flex/src/ops/matmul.rs @@ -0,0 +1,976 @@ +//! Matrix multiplication via gemm crate. +//! +//! Optimizations: +//! - Strided gemm for f32/f64/f16 avoids copying non-contiguous tensors +//! - Enables parallelism for large matrices (with rayon feature) +//! - Batched matmul parallelized across batch dimension + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::{FlexTensor, Layout}; + +/// Types that can be used with gemm-based matmul. +/// Only implement for types that `gemm::gemm` dispatches on via TypeId (f32, f64, f16). +trait GemmScalar: Element + bytemuck::Pod { + fn zero() -> Self; + fn one() -> Self; +} + +impl GemmScalar for f32 { + fn zero() -> Self { + 0.0 + } + fn one() -> Self { + 1.0 + } +} + +impl GemmScalar for f64 { + fn zero() -> Self { + 0.0 + } + fn one() -> Self { + 1.0 + } +} + +impl GemmScalar for f16 { + fn zero() -> Self { + f16::from_f32(0.0) + } + fn one() -> Self { + f16::from_f32(1.0) + } +} + +/// Checked multiplication for matrix sizes, panics on overflow. +#[inline] +fn checked_size(a: usize, b: usize) -> usize { + a.checked_mul(b) + .unwrap_or_else(|| panic!("matmul: matrix size overflow: {a} * {b}")) +} + +/// Threshold for enabling parallelism (M*N*K operations). +/// 192^3 = ~7M ops - balance between 128x128 (no parallel) and 256x256 (parallel) +const PARALLEL_THRESHOLD: usize = 192 * 192 * 192; + +/// Threshold for batch-level parallelism (total ops across all batches). +/// Use batch parallelism when individual matrices are small but total work is large. +#[cfg(feature = "rayon")] +const BATCH_PARALLEL_THRESHOLD: usize = 128 * 128 * 128; // ~2M ops total + +/// Get parallelism setting based on matrix size. +fn get_parallelism(m: usize, n: usize, k: usize) -> gemm::Parallelism { + let ops = m.saturating_mul(n).saturating_mul(k); + if ops >= PARALLEL_THRESHOLD { + #[cfg(feature = "rayon")] + { + gemm::Parallelism::Rayon(0) // 0 = use all available threads + } + #[cfg(not(feature = "rayon"))] + { + gemm::Parallelism::None + } + } else { + gemm::Parallelism::None + } +} + +/// Dispatch matrix multiplication based on dtype. +pub fn matmul(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + assert_eq!(lhs.dtype(), rhs.dtype(), "matmul: dtype mismatch"); + + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + assert!(lhs_rank >= 2, "matmul requires at least 2D tensors"); + assert!(rhs_rank >= 2, "matmul requires at least 2D tensors"); + + // Check inner dimensions match: lhs[..., M, K] x rhs[..., K, N] + let k_lhs = lhs_shape[lhs_rank - 1]; + let k_rhs = rhs_shape[rhs_rank - 2]; + assert_eq!(k_lhs, k_rhs, "matmul: inner dimensions must match"); + + match lhs.dtype() { + DType::F32 => matmul_gemm::(lhs, rhs), + DType::F64 => matmul_gemm::(lhs, rhs), + DType::F16 => matmul_gemm::(lhs, rhs), + DType::BF16 => matmul_bf16(lhs, rhs), + _ => panic!("matmul: unsupported dtype {:?}", lhs.dtype()), + } +} + +/// Extract 2D matrix strides from a tensor layout. +/// Returns (row_stride, col_stride) for the last two dimensions. +fn get_2d_strides(layout: &Layout) -> (isize, isize) { + let strides = layout.strides(); + let ndim = strides.len(); + let row_stride = strides[ndim - 2]; + let col_stride = strides[ndim - 1]; + (row_stride, col_stride) +} + +/// Compute broadcast batch dimensions for batched matmul. +/// Returns (broadcast_shape, lhs_strides, rhs_strides) where strides map +/// output batch index to input batch offset (in matrices). +fn broadcast_batch_dims( + lhs_batch: &[usize], + rhs_batch: &[usize], +) -> (Vec, Vec, Vec) { + // Pad shorter batch dims with 1s on the left + let max_len = lhs_batch.len().max(rhs_batch.len()); + let lhs_padded: Vec = (0..max_len) + .map(|i| { + if i < max_len - lhs_batch.len() { + 1 + } else { + lhs_batch[i - (max_len - lhs_batch.len())] + } + }) + .collect(); + let rhs_padded: Vec = (0..max_len) + .map(|i| { + if i < max_len - rhs_batch.len() { + 1 + } else { + rhs_batch[i - (max_len - rhs_batch.len())] + } + }) + .collect(); + + // Compute broadcast shape and strides + let mut broadcast_shape = Vec::with_capacity(max_len); + let mut lhs_strides = Vec::with_capacity(max_len); + let mut rhs_strides = Vec::with_capacity(max_len); + + // Compute strides from right to left + let mut lhs_stride = 1usize; + let mut rhs_stride = 1usize; + for i in (0..max_len).rev() { + let ld = lhs_padded[i]; + let rd = rhs_padded[i]; + debug_assert!( + ld == rd || ld == 1 || rd == 1, + "matmul: batch dimensions not broadcastable: {:?} vs {:?}", + lhs_batch, + rhs_batch + ); + broadcast_shape.push(ld.max(rd)); + // Stride is 0 if dimension is 1 (broadcast), otherwise actual stride + lhs_strides.push(if ld == 1 { 0 } else { lhs_stride }); + rhs_strides.push(if rd == 1 { 0 } else { rhs_stride }); + lhs_stride *= ld; + rhs_stride *= rd; + } + + // Reverse to get correct order + broadcast_shape.reverse(); + lhs_strides.reverse(); + rhs_strides.reverse(); + + (broadcast_shape, lhs_strides, rhs_strides) +} + +/// Convert a flat batch index to input batch offset using broadcast strides. +#[inline] +fn batch_index_to_offset(b: usize, broadcast_shape: &[usize], strides: &[usize]) -> usize { + let mut offset = 0; + let mut remaining = b; + for i in (0..broadcast_shape.len()).rev() { + let idx = remaining % broadcast_shape[i]; + offset += idx * strides[i]; + remaining /= broadcast_shape[i]; + } + offset +} + +/// Compute element-level batch strides for a tensor in a broadcast context. +/// Uses the actual layout strides so non-contiguous (transposed/sliced) tensors +/// work without a copy. Dimensions that are broadcast (size 1) get stride 0. +#[allow(clippy::needless_range_loop)] +fn broadcast_batch_elem_strides( + batch_shape: &[usize], + layout_strides: &[isize], + broadcast_len: usize, +) -> Vec { + let batch_ndim = batch_shape.len(); + debug_assert!(broadcast_len >= batch_ndim); + let mut result = vec![0isize; broadcast_len]; + + for i in 0..broadcast_len { + let batch_idx = i as isize - (broadcast_len as isize - batch_ndim as isize); + if batch_idx >= 0 { + let bi = batch_idx as usize; + if batch_shape[bi] > 1 { + result[i] = layout_strides[bi]; + } + } + } + + result +} + +/// Convert a flat batch index to an element offset using element-level strides. +#[inline] +fn batch_elem_offset(b: usize, broadcast_shape: &[usize], elem_strides: &[isize]) -> isize { + let mut offset: isize = 0; + let mut remaining = b; + for i in (0..broadcast_shape.len()).rev() { + let idx = remaining % broadcast_shape[i]; + offset += idx as isize * elem_strides[i]; + remaining /= broadcast_shape[i]; + } + offset +} + +// ============================================================================ +// Generic gemm-based matmul (f32, f64, f16) +// ============================================================================ + +fn matmul_gemm(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs_rank = lhs.layout().shape().num_dims(); + let rhs_rank = rhs.layout().shape().num_dims(); + + if lhs_rank == 2 && rhs_rank == 2 { + matmul_2d_strided::(lhs, rhs) + } else { + matmul_batched_gemm::(lhs, rhs) + } +} + +/// 2D matmul with strided support: [M, K] x [K, N] -> [M, N] +fn matmul_2d_strided(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + + let m = lhs_shape[0]; + let k = lhs_shape[1]; + let n = rhs_shape[1]; + + let (lhs_row_stride, lhs_col_stride) = get_2d_strides(lhs.layout()); + let (rhs_row_stride, rhs_col_stride) = get_2d_strides(rhs.layout()); + + let lhs_data: &[T] = lhs.storage(); + let rhs_data: &[T] = rhs.storage(); + let lhs_ptr = unsafe { lhs_data.as_ptr().add(lhs.layout().start_offset()) }; + let rhs_ptr = unsafe { rhs_data.as_ptr().add(rhs.layout().start_offset()) }; + + let out_shape = Shape::from(vec![m, n]); + let mut output = FlexTensor::empty(out_shape, T::dtype()); + let out_data: &mut [T] = output.storage_mut(); + + let parallelism = get_parallelism(m, n, k); + + unsafe { + gemm_call( + m, + n, + k, + out_data.as_mut_ptr(), + 1, + n as isize, + lhs_ptr, + lhs_col_stride, + lhs_row_stride, + rhs_ptr, + rhs_col_stride, + rhs_row_stride, + parallelism, + ); + } + + output +} + +/// Strided gemm call for one matrix. Wraps `gemm::gemm` with GemmScalar zero/one. +#[inline] +#[allow(clippy::too_many_arguments)] +unsafe fn gemm_call( + m: usize, + n: usize, + k: usize, + out: *mut T, + out_cs: isize, + out_rs: isize, + lhs: *const T, + lhs_cs: isize, + lhs_rs: isize, + rhs: *const T, + rhs_cs: isize, + rhs_rs: isize, + parallelism: gemm::Parallelism, +) { + unsafe { + gemm::gemm( + m, + n, + k, + out, + out_cs, + out_rs, + false, + lhs, + lhs_cs, + lhs_rs, + rhs, + rhs_cs, + rhs_rs, + T::zero(), + T::one(), + false, + false, + false, + parallelism, + ); + } +} + +/// Batched matmul: [B..., M, K] x [B..., K, N] -> [B..., M, N] +/// Supports broadcasting on batch dimensions and strided (non-contiguous) inputs. +fn matmul_batched_gemm(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + let m = lhs_shape[lhs_rank - 2]; + let k = lhs_shape[lhs_rank - 1]; + let n = rhs_shape[rhs_rank - 1]; + + let lhs_batch: Vec = lhs_shape[..lhs_rank - 2].to_vec(); + let rhs_batch: Vec = rhs_shape[..rhs_rank - 2].to_vec(); + + let (broadcast_shape, _, _) = broadcast_batch_dims(&lhs_batch, &rhs_batch); + let batch_size: usize = broadcast_shape.iter().product(); + let broadcast_len = broadcast_shape.len(); + + let lhs_batch_strides = + broadcast_batch_elem_strides(&lhs_batch, lhs.layout().strides(), broadcast_len); + let rhs_batch_strides = + broadcast_batch_elem_strides(&rhs_batch, rhs.layout().strides(), broadcast_len); + + let (lhs_row_stride, lhs_col_stride) = get_2d_strides(lhs.layout()); + let (rhs_row_stride, rhs_col_stride) = get_2d_strides(rhs.layout()); + + let out_matrix_size = checked_size(m, n); + + let mut out_dims = broadcast_shape.clone(); + out_dims.push(m); + out_dims.push(n); + let out_shape = Shape::from(out_dims); + + let mut output = FlexTensor::empty(out_shape, T::dtype()); + + let lhs_data: &[T] = lhs.storage(); + let rhs_data: &[T] = rhs.storage(); + let lhs_start = lhs.layout().start_offset() as isize; + let rhs_start = rhs.layout().start_offset() as isize; + let out_data: &mut [T] = output.storage_mut(); + + let per_matrix_ops = m.saturating_mul(n).saturating_mul(k); + + // Closure: run gemm for one batch slice at the given pointers + let run_one = |out_ptr: *mut T, b: usize, parallelism: gemm::Parallelism| { + let lhs_off = lhs_start + batch_elem_offset(b, &broadcast_shape, &lhs_batch_strides); + let rhs_off = rhs_start + batch_elem_offset(b, &broadcast_shape, &rhs_batch_strides); + unsafe { + gemm_call::( + m, + n, + k, + out_ptr, + 1, + n as isize, + lhs_data.as_ptr().offset(lhs_off), + lhs_col_stride, + lhs_row_stride, + rhs_data.as_ptr().offset(rhs_off), + rhs_col_stride, + rhs_row_stride, + parallelism, + ); + } + }; + + // Strategy: + // 1. Large matrices: let gemm parallelize internally + // 2. Small matrices, large batch: parallelize batch loop + // 3. Small total work: single-threaded + #[cfg(feature = "rayon")] + { + let total_ops = batch_size.saturating_mul(per_matrix_ops); + let prefer_batch_parallel = batch_size >= 4 && total_ops >= BATCH_PARALLEL_THRESHOLD; + + if per_matrix_ops >= PARALLEL_THRESHOLD && !prefer_batch_parallel { + let parallelism = gemm::Parallelism::Rayon(0); + for b in 0..batch_size { + run_one(out_data[b * out_matrix_size..].as_mut_ptr(), b, parallelism); + } + } else if total_ops >= BATCH_PARALLEL_THRESHOLD && batch_size > 1 { + use rayon::prelude::*; + + out_data + .par_chunks_mut(out_matrix_size) + .enumerate() + .for_each(|(b, out_chunk)| { + run_one(out_chunk.as_mut_ptr(), b, gemm::Parallelism::None); + }); + } else { + for b in 0..batch_size { + run_one( + out_data[b * out_matrix_size..].as_mut_ptr(), + b, + gemm::Parallelism::None, + ); + } + } + } + + #[cfg(not(feature = "rayon"))] + { + let _ = per_matrix_ops; + for b in 0..batch_size { + run_one( + out_data[b * out_matrix_size..].as_mut_ptr(), + b, + gemm::Parallelism::None, + ); + } + } + + output +} + +// ============================================================================ +// bf16 matmul (via f32 conversion) +// ============================================================================ + +fn matmul_bf16(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs = lhs.to_contiguous(); + let rhs = rhs.to_contiguous(); + + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + + // Convert bf16 -> f32 + let lhs_f32: Vec = lhs.storage::().iter().map(|x| x.to_f32()).collect(); + let rhs_f32: Vec = rhs.storage::().iter().map(|x| x.to_f32()).collect(); + + // Create f32 tensors + let lhs_f32_tensor = FlexTensor::new( + Bytes::from_elems(lhs_f32), + Layout::contiguous(lhs_shape.clone()), + DType::F32, + ); + let rhs_f32_tensor = FlexTensor::new( + Bytes::from_elems(rhs_f32), + Layout::contiguous(rhs_shape.clone()), + DType::F32, + ); + + // Compute matmul in f32 + let result_f32 = matmul_gemm::(lhs_f32_tensor, rhs_f32_tensor); + + // Convert f32 -> bf16 + let result_bf16: Vec = result_f32 + .storage::() + .iter() + .map(|x| bf16::from_f32(*x)) + .collect(); + + FlexTensor::new( + Bytes::from_elems(result_bf16), + result_f32.layout().clone(), + DType::BF16, + ) +} + +// ============================================================================ +// Integer matmul (naive, with optional SIMD for i32) +// ============================================================================ + +/// Integer matrix multiplication dispatch. +pub fn int_matmul(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + assert_eq!(lhs.dtype(), rhs.dtype(), "int_matmul: dtype mismatch"); + + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + assert!(lhs_rank >= 2, "int_matmul requires at least 2D tensors"); + assert!(rhs_rank >= 2, "int_matmul requires at least 2D tensors"); + + let k_lhs = lhs_shape[lhs_rank - 1]; + let k_rhs = rhs_shape[rhs_rank - 2]; + assert_eq!(k_lhs, k_rhs, "int_matmul: inner dimensions must match"); + + match lhs.dtype() { + DType::I32 => matmul_i32(lhs, rhs), + DType::I64 => matmul_i64(lhs, rhs), + _ => panic!("int_matmul: unsupported dtype {:?}", lhs.dtype()), + } +} + +/// i32 matmul using naive triple loop with SIMD dot product. +fn matmul_i32(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs = lhs.to_contiguous(); + let rhs = rhs.to_contiguous(); + + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + if lhs_rank == 2 && rhs_rank == 2 { + matmul_2d_i32(&lhs, &rhs) + } else { + matmul_batched_i32(lhs, rhs) + } +} + +/// 2D i32 matmul: [M, K] x [K, N] -> [M, N] +/// Transposes rhs to enable contiguous access for dot product. +fn matmul_2d_i32(lhs: &FlexTensor, rhs: &FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + + let m = lhs_shape[0]; + let k = lhs_shape[1]; + let n = rhs_shape[1]; + + let lhs_data: &[i32] = lhs.storage(); + let rhs_data: &[i32] = rhs.storage(); + + // Transpose rhs [K, N] -> [N, K] for contiguous column access + let mut rhs_t = vec![0i32; k * n]; + for i in 0..k { + for j in 0..n { + rhs_t[j * k + i] = rhs_data[i * n + j]; + } + } + + let mut output = vec![0i32; m * n]; + + // Now both lhs rows and rhs columns (transposed rows) are contiguous + for i in 0..m { + let lhs_row = &lhs_data[i * k..(i + 1) * k]; + for j in 0..n { + let rhs_col = &rhs_t[j * k..(j + 1) * k]; + output[i * n + j] = dot_i32(lhs_row, rhs_col); + } + } + + let out_shape = Shape::from(vec![m, n]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::I32, + ) +} + +/// Dot product for i32 slices. Uses macerator SIMD when the `simd` feature is enabled. +#[inline] +fn dot_i32(a: &[i32], b: &[i32]) -> i32 { + debug_assert_eq!(a.len(), b.len()); + + #[cfg(feature = "simd")] + { + dot_i32_simd(a, b) + } + + #[cfg(not(feature = "simd"))] + { + dot_i32_scalar(a, b) + } +} + +#[cfg(not(feature = "simd"))] +#[inline] +fn dot_i32_scalar(a: &[i32], b: &[i32]) -> i32 { + let mut sum = 0i32; + for i in 0..a.len() { + sum = sum.wrapping_add(a[i].wrapping_mul(b[i])); + } + sum +} + +#[cfg(feature = "simd")] +#[macerator::with_simd] +fn dot_i32_simd(a: &[i32], b: &[i32]) -> i32 { + use macerator::{Scalar, VMulAdd, vload_unaligned}; + + let lanes = i32::lanes::(); + let len = a.len(); + let simd_len = len / lanes * lanes; + let mut acc = 0i32.splat::(); + + let mut i = 0; + while i < simd_len { + let va = unsafe { vload_unaligned(a.as_ptr().add(i)) }; + let vb = unsafe { vload_unaligned(b.as_ptr().add(i)) }; + acc = i32::vmul_add(va, vb, acc); + i += lanes; + } + + let mut sum = acc.reduce_add(); + while i < len { + sum = sum.wrapping_add(a[i].wrapping_mul(b[i])); + i += 1; + } + sum +} + +/// Batched i32 matmul: [B..., M, K] x [B..., K, N] -> [B..., M, N] +/// +/// Uses naive triple-loop with SIMD dot product and batch-level parallelism. +fn matmul_batched_i32(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + let m = lhs_shape[lhs_rank - 2]; + let k = lhs_shape[lhs_rank - 1]; + let n = rhs_shape[rhs_rank - 1]; + + let lhs_batch: Vec = lhs_shape[..lhs_rank - 2].to_vec(); + let rhs_batch: Vec = rhs_shape[..rhs_rank - 2].to_vec(); + + let (broadcast_shape, lhs_strides, rhs_strides) = broadcast_batch_dims(&lhs_batch, &rhs_batch); + + let batch_size: usize = broadcast_shape.iter().product(); + let rhs_batch_size: usize = rhs_batch.iter().product(); + let lhs_matrix_size = checked_size(m, k); + let rhs_matrix_size = checked_size(k, n); + let out_matrix_size = checked_size(m, n); + + let mut out_dims = broadcast_shape.clone(); + out_dims.push(m); + out_dims.push(n); + let out_shape = Shape::from(out_dims); + + let lhs_data: &[i32] = lhs.storage(); + let rhs_data: &[i32] = rhs.storage(); + + // Transpose rhs per actual rhs batch: [B_rhs, K, N] -> [B_rhs, N, K] + let mut rhs_transposed = vec![0i32; rhs_batch_size * n * k]; + for b in 0..rhs_batch_size { + let src_offset = b * rhs_matrix_size; + let dst_offset = b * n * k; + for i in 0..k { + for j in 0..n { + rhs_transposed[dst_offset + j * k + i] = rhs_data[src_offset + i * n + j]; + } + } + } + + let mut output = vec![0i32; batch_size * out_matrix_size]; + + let run_one = |b: usize, out_slice: &mut [i32]| { + let lhs_batch_idx = batch_index_to_offset(b, &broadcast_shape, &lhs_strides); + let rhs_batch_idx = batch_index_to_offset(b, &broadcast_shape, &rhs_strides); + let lhs_offset = lhs_batch_idx * lhs_matrix_size; + let rhs_t_offset = rhs_batch_idx * n * k; + + let lhs_slice = &lhs_data[lhs_offset..lhs_offset + lhs_matrix_size]; + let rhs_t_slice = &rhs_transposed[rhs_t_offset..rhs_t_offset + n * k]; + + for i in 0..m { + let lhs_row = &lhs_slice[i * k..(i + 1) * k]; + for j in 0..n { + let rhs_col = &rhs_t_slice[j * k..(j + 1) * k]; + out_slice[i * n + j] = dot_i32(lhs_row, rhs_col); + } + } + }; + + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + output + .par_chunks_mut(out_matrix_size) + .enumerate() + .for_each(|(b, out_slice)| run_one(b, out_slice)); + } + + #[cfg(not(feature = "rayon"))] + { + for b in 0..batch_size { + let offset = b * out_matrix_size; + run_one(b, &mut output[offset..offset + out_matrix_size]); + } + } + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::I32, + ) +} + +/// i64 matmul using naive triple loop. +fn matmul_i64(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs = lhs.to_contiguous(); + let rhs = rhs.to_contiguous(); + + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + if lhs_rank == 2 && rhs_rank == 2 { + matmul_2d_i64(&lhs, &rhs) + } else { + matmul_batched_i64(lhs, rhs) + } +} + +/// 2D i64 matmul: [M, K] x [K, N] -> [M, N] +fn matmul_2d_i64(lhs: &FlexTensor, rhs: &FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + + let m = lhs_shape[0]; + let k = lhs_shape[1]; + let n = rhs_shape[1]; + + let lhs_data: &[i64] = lhs.storage(); + let rhs_data: &[i64] = rhs.storage(); + + let mut output = vec![0i64; m * n]; + + for i in 0..m { + for j in 0..n { + let mut sum = 0i64; + for l in 0..k { + sum = sum.wrapping_add(lhs_data[i * k + l].wrapping_mul(rhs_data[l * n + j])); + } + output[i * n + j] = sum; + } + } + + let out_shape = Shape::from(vec![m, n]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::I64, + ) +} + +/// Batched i64 matmul with broadcast support +fn matmul_batched_i64(lhs: FlexTensor, rhs: FlexTensor) -> FlexTensor { + let lhs_shape = lhs.layout().shape(); + let rhs_shape = rhs.layout().shape(); + let lhs_rank = lhs_shape.num_dims(); + let rhs_rank = rhs_shape.num_dims(); + + let m = lhs_shape[lhs_rank - 2]; + let k = lhs_shape[lhs_rank - 1]; + let n = rhs_shape[rhs_rank - 1]; + + let lhs_batch: Vec = lhs_shape[..lhs_rank - 2].to_vec(); + let rhs_batch: Vec = rhs_shape[..rhs_rank - 2].to_vec(); + + // Compute broadcast batch dimensions + let (broadcast_shape, lhs_strides, rhs_strides) = broadcast_batch_dims(&lhs_batch, &rhs_batch); + + let batch_size: usize = broadcast_shape.iter().product(); + let lhs_matrix_size = checked_size(m, k); + let rhs_matrix_size = checked_size(k, n); + let out_matrix_size = checked_size(m, n); + + let mut out_dims = broadcast_shape.clone(); + out_dims.push(m); + out_dims.push(n); + let out_shape = Shape::from(out_dims); + + let lhs_data: &[i64] = lhs.storage(); + let rhs_data: &[i64] = rhs.storage(); + + let mut output = vec![0i64; batch_size * out_matrix_size]; + + for b in 0..batch_size { + let lhs_batch_idx = batch_index_to_offset(b, &broadcast_shape, &lhs_strides); + let rhs_batch_idx = batch_index_to_offset(b, &broadcast_shape, &rhs_strides); + let lhs_offset = lhs_batch_idx * lhs_matrix_size; + let rhs_offset = rhs_batch_idx * rhs_matrix_size; + let out_offset = b * out_matrix_size; + + for i in 0..m { + for j in 0..n { + let mut sum = 0i64; + for l in 0..k { + let lhs_idx = lhs_offset + i * k + l; + let rhs_idx = rhs_offset + l * n + j; + sum = sum.wrapping_add(lhs_data[lhs_idx].wrapping_mul(rhs_data[rhs_idx])); + } + output[out_offset + i * n + j] = sum; + } + } + } + + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + DType::I64, + ) +} + +// ============================================================================ +// Tests +// ============================================================================ + +// Tests kept here exercise flex-specific behavior of the matmul kernel: +// dtype-specific storage paths (F64, F16, BF16) that the generic +// FloatElem-parameterized backend-tests cannot reach. Plain contiguous +// F32/I32/I64 matmul, stride-through-matmul variants (transposed / +// swap_dims / broadcast-transposed), and other generic coverage have +// been migrated to burn-backend-tests so they run against every backend. +// When adding new tests, keep them here only if they probe a flex +// dtype-storage path; otherwise add them to +// crates/burn-backend-tests/tests/tensor/float/ops/matmul.rs. +#[cfg(test)] +mod tests { + use alloc::vec; + use burn_backend::TensorData; + use burn_backend::ops::FloatTensorOps; + use burn_std::{bf16, f16}; + + use crate::{Flex, FlexTensor}; + + #[test] + fn test_matmul_f64() { + let lhs = FlexTensor::from_data(TensorData::new(vec![1.0f64, 2.0, 3.0, 4.0], [2, 2])); + let rhs = FlexTensor::from_data(TensorData::new(vec![5.0f64, 6.0, 7.0, 8.0], [2, 2])); + + let result = Flex::float_matmul(lhs, rhs); + let values: Vec = result.into_data().to_vec().unwrap(); + + assert_eq!(values, vec![19.0, 22.0, 43.0, 50.0]); + } + + #[test] + fn test_matmul_f16() { + let lhs_vals: Vec = [1.0f32, 2.0, 3.0, 4.0] + .iter() + .copied() + .map(f16::from_f32) + .collect(); + let rhs_vals: Vec = [5.0f32, 6.0, 7.0, 8.0] + .iter() + .copied() + .map(f16::from_f32) + .collect(); + + let lhs = FlexTensor::from_data(TensorData::new(lhs_vals, [2, 2])); + let rhs = FlexTensor::from_data(TensorData::new(rhs_vals, [2, 2])); + + let result = Flex::float_matmul(lhs, rhs); + let values: Vec = result.into_data().to_vec().unwrap(); + + let expected = [19.0f32, 22.0, 43.0, 50.0]; + for (a, e) in values.iter().zip(expected.iter()) { + assert!((a.to_f32() - e).abs() < 0.1, "f16 matmul mismatch"); + } + } + + #[test] + fn test_matmul_bf16() { + let lhs_vals: Vec = [1.0f32, 2.0, 3.0, 4.0] + .iter() + .copied() + .map(bf16::from_f32) + .collect(); + let rhs_vals: Vec = [5.0f32, 6.0, 7.0, 8.0] + .iter() + .copied() + .map(bf16::from_f32) + .collect(); + + let lhs = FlexTensor::from_data(TensorData::new(lhs_vals, [2, 2])); + let rhs = FlexTensor::from_data(TensorData::new(rhs_vals, [2, 2])); + + let result = Flex::float_matmul(lhs, rhs); + let values: Vec = result.into_data().to_vec().unwrap(); + + let expected = [19.0f32, 22.0, 43.0, 50.0]; + for (a, e) in values.iter().zip(expected.iter()) { + assert!((a.to_f32() - e).abs() < 0.5, "bf16 matmul mismatch"); + } + } + + #[test] + fn test_matmul_batched_transposed_f64() { + // Non-contiguous (swap_dims) batched matmul on the F64 dtype path. + let q_data = TensorData::new(vec![1.0f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [2, 2, 2]); + let k_data = TensorData::new(vec![1.0f64, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 2.0], [2, 2, 2]); + + let q = FlexTensor::from_data(q_data.clone()); + let k = FlexTensor::from_data(k_data.clone()); + let k_t = k.transpose(1, 2); + let result = Flex::float_matmul(q, k_t); + + let q2 = FlexTensor::from_data(q_data); + let k2 = FlexTensor::from_data(k_data) + .transpose(1, 2) + .to_contiguous(); + let expected = Flex::float_matmul(q2, k2); + + let values: Vec = result.into_data().to_vec().unwrap(); + let expected: Vec = expected.into_data().to_vec().unwrap(); + assert_eq!(values, expected); + } + + #[test] + fn test_matmul_batched_transposed_f16() { + // Non-contiguous (swap_dims) batched matmul on the F16 dtype path. + let f = f16::from_f32; + let q_data = TensorData::new( + vec![ + f(1.0), + f(2.0), + f(3.0), + f(4.0), + f(5.0), + f(6.0), + f(7.0), + f(8.0), + ], + [2, 2, 2], + ); + let k_data = TensorData::new( + vec![ + f(1.0), + f(0.0), + f(0.0), + f(1.0), + f(2.0), + f(0.0), + f(0.0), + f(2.0), + ], + [2, 2, 2], + ); + + let q = FlexTensor::from_data(q_data.clone()); + let k = FlexTensor::from_data(k_data.clone()); + let k_t = k.transpose(1, 2); + let result = Flex::float_matmul(q, k_t); + + let q2 = FlexTensor::from_data(q_data); + let k2 = FlexTensor::from_data(k_data) + .transpose(1, 2) + .to_contiguous(); + let expected = Flex::float_matmul(q2, k2); + + let values: Vec = result.into_data().to_vec().unwrap(); + let expected: Vec = expected.into_data().to_vec().unwrap(); + assert_eq!(values, expected); + } +} diff --git a/crates/burn-flex/src/ops/mod.rs b/crates/burn-flex/src/ops/mod.rs new file mode 100644 index 0000000..35f754a --- /dev/null +++ b/crates/burn-flex/src/ops/mod.rs @@ -0,0 +1,122 @@ +//! Backend operations implementations. + +use alloc::borrow::Cow; +use burn_backend::DType; +use burn_std::{bf16, f16}; + +use crate::FlexTensor; + +/// The `DType` that matches `isize` on the current platform. +#[cfg(target_pointer_width = "64")] +pub(crate) const INDEX_DTYPE: DType = DType::I64; +/// The `DType` that matches `isize` on the current platform. +#[cfg(target_pointer_width = "32")] +pub(crate) const INDEX_DTYPE: DType = DType::I32; + +/// Minimum total element count for rayon fan-out on per-row or per-chunk loops. +/// Below this, task-dispatch overhead dominates the per-unit work. +#[cfg(feature = "rayon")] +pub(crate) const PARALLEL_THRESHOLD: usize = 256 * 1024; + +/// Wrapper for raw mutable pointers that can be sent across rayon threads. +/// +/// # Safety +/// +/// The caller must ensure: +/// - The pointer remains valid for the lifetime of all uses +/// - No two threads write to the same offset +/// - No references to the underlying data exist during writes +#[cfg(feature = "rayon")] +pub(crate) struct SendMutPtr(*mut T); + +#[cfg(feature = "rayon")] +unsafe impl Send for SendMutPtr {} +#[cfg(feature = "rayon")] +unsafe impl Sync for SendMutPtr {} + +#[cfg(feature = "rayon")] +impl SendMutPtr { + pub(crate) fn new(ptr: *mut T) -> Self { + Self(ptr) + } + + /// Write `val` at the given element offset. + /// + /// # Safety + /// Offset must be in bounds and no other thread may write to the same offset. + pub(crate) unsafe fn write(&self, offset: usize, val: T) { + unsafe { self.0.add(offset).write(val) } + } + + /// Returns the raw pointer offset by `offset` elements. + /// + /// # Safety + /// Offset must be in bounds. + pub(crate) unsafe fn ptr_add(&self, offset: usize) -> *mut T { + unsafe { self.0.add(offset) } + } +} + +/// Read a float tensor's storage as f32 values, regardless of source dtype. +/// Returns a borrowed slice for F32 (zero-copy) and an owned Vec for other +/// float dtypes (F64, F16, BF16). +/// +/// Returns elements in underlying buffer order. Callers needing logical +/// iteration order must call `to_contiguous()` first. +/// +/// # Panics +/// Panics if the tensor's dtype is not one of F32, F64, F16, or BF16. +pub(crate) fn float_storage_as_f32(tensor: &FlexTensor) -> Cow<'_, [f32]> { + match tensor.dtype() { + DType::F32 => Cow::Borrowed(tensor.storage::()), + DType::F64 => Cow::Owned(tensor.storage::().iter().map(|&x| x as f32).collect()), + DType::F16 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|x| f32::from(*x)) + .collect(), + ), + DType::BF16 => Cow::Owned( + tensor + .storage::() + .iter() + .map(|x| f32::from(*x)) + .collect(), + ), + other => panic!("float_storage_as_f32: unsupported dtype {:?}", other), + } +} + +pub mod activation; +pub mod attention; +pub mod binary; +mod bool; +pub mod cat; +pub mod comparison; +#[macro_use] +mod conv_common; +pub mod conv; +pub mod conv_transpose; +pub mod cumulative; +pub mod deform_conv; +pub mod expand; +pub mod fft; +pub mod flip; +mod float; +pub mod gather_scatter; +pub mod grid_sample; +mod int; +pub mod interpolate; +pub mod mask; +pub mod matmul; +mod module; +pub mod pool; +mod qtensor; +pub mod reduce; +pub mod repeat_dim; +pub mod slice; +pub mod sort; +mod transaction; +pub mod unary; +pub mod unfold; diff --git a/crates/burn-flex/src/ops/module.rs b/crates/burn-flex/src/ops/module.rs new file mode 100644 index 0000000..4d80409 --- /dev/null +++ b/crates/burn-flex/src/ops/module.rs @@ -0,0 +1,776 @@ +//! Module operations for the Flex backend. +//! +//! These operations power neural network modules like convolutions and pooling. + +use crate::ops::{conv, conv_transpose, deform_conv, interpolate, pool}; +use crate::{Flex, FlexTensor, Layout}; +use burn_backend::{ + DType, Element, TensorMetadata, + ops::{ + AttentionModuleOptions, ConvOptions, ConvTransposeOptions, DeformConv2dBackward, + DeformConvOptions, FloatTensorOps, IntTensorOps, InterpolateMode, InterpolateOptions, + MaxPool2dBackward, MaxPool2dWithIndices, ModuleOps, + }, + tensor::{BoolTensor, FloatTensor, IntTensor}, +}; +use burn_std::{Bytes, IntDType, Shape}; +use bytemuck::Pod; + +/// Cast a tensor from half-precision type E to f32. +pub(crate) fn cast_to_f32( + tensor: FlexTensor, + to_f32: fn(E) -> f32, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let data: &[E] = tensor.storage(); + let f32_data: alloc::vec::Vec = data.iter().map(|&v| to_f32(v)).collect(); + let bytes = Bytes::from_elems(f32_data); + FlexTensor::new(bytes, Layout::contiguous(shape), DType::F32) +} + +/// Cast a tensor from f32 back to half-precision type E. +pub(crate) fn cast_from_f32( + tensor: FlexTensor, + from_f32: fn(f32) -> E, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let data: &[f32] = tensor.storage(); + let half_data: alloc::vec::Vec = data.iter().map(|&v| from_f32(v)).collect(); + let bytes = Bytes::from_elems(half_data); + FlexTensor::new(bytes, Layout::contiguous(shape), E::dtype()) +} + +impl ModuleOps for Flex { + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv::conv1d_f32(x, weight, bias, &options), + DType::F64 => conv::conv1d_f64(x, weight, bias, &options), + DType::F16 => conv::conv1d_f16(x, weight, bias, &options), + DType::BF16 => conv::conv1d_bf16(x, weight, bias, &options), + dtype => panic!("conv1d: unsupported dtype {:?}", dtype), + } + } + + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv::conv2d_f32(x, weight, bias, &options), + DType::F64 => conv::conv2d_f64(x, weight, bias, &options), + DType::F16 => conv::conv2d_f16(x, weight, bias, &options), + DType::BF16 => conv::conv2d_bf16(x, weight, bias, &options), + dtype => panic!("conv2d: unsupported dtype {:?}", dtype), + } + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => deform_conv::deform_conv2d_f32( + x, + offset, + weight, + mask, + bias, + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ), + DType::F64 => deform_conv::deform_conv2d_f64( + x, + offset, + weight, + mask, + bias, + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ), + DType::F16 => { + use burn_std::f16; + let result = deform_conv::deform_conv2d_f32( + cast_to_f32(x, f16::to_f32), + cast_to_f32(offset, f16::to_f32), + cast_to_f32(weight, f16::to_f32), + mask.map(|m| cast_to_f32(m, f16::to_f32)), + bias.map(|b| cast_to_f32(b, f16::to_f32)), + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ); + cast_from_f32(result, f16::from_f32) + } + DType::BF16 => { + use burn_std::bf16; + let result = deform_conv::deform_conv2d_f32( + cast_to_f32(x, bf16::to_f32), + cast_to_f32(offset, bf16::to_f32), + cast_to_f32(weight, bf16::to_f32), + mask.map(|m| cast_to_f32(m, bf16::to_f32)), + bias.map(|b| cast_to_f32(b, bf16::to_f32)), + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ); + cast_from_f32(result, bf16::from_f32) + } + dtype => panic!("deform_conv2d: unsupported dtype {:?}", dtype), + } + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + let (x_grad, offset_grad, weight_grad, mask_grad, bias_grad) = match x.dtype() { + DType::F32 => deform_conv::deform_conv2d_backward_f32( + x, + offset, + weight, + mask, + bias, + output_grad, + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ), + DType::F16 => { + use burn_std::f16; + let (xg, og, wg, mg, bg) = deform_conv::deform_conv2d_backward_f32( + cast_to_f32(x, f16::to_f32), + cast_to_f32(offset, f16::to_f32), + cast_to_f32(weight, f16::to_f32), + mask.map(|m| cast_to_f32(m, f16::to_f32)), + bias.map(|b| cast_to_f32(b, f16::to_f32)), + cast_to_f32(output_grad, f16::to_f32), + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ); + ( + cast_from_f32(xg, f16::from_f32), + cast_from_f32(og, f16::from_f32), + cast_from_f32(wg, f16::from_f32), + mg.map(|m| cast_from_f32(m, f16::from_f32)), + bg.map(|b| cast_from_f32(b, f16::from_f32)), + ) + } + DType::BF16 => { + use burn_std::bf16; + let (xg, og, wg, mg, bg) = deform_conv::deform_conv2d_backward_f32( + cast_to_f32(x, bf16::to_f32), + cast_to_f32(offset, bf16::to_f32), + cast_to_f32(weight, bf16::to_f32), + mask.map(|m| cast_to_f32(m, bf16::to_f32)), + bias.map(|b| cast_to_f32(b, bf16::to_f32)), + cast_to_f32(output_grad, bf16::to_f32), + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ); + ( + cast_from_f32(xg, bf16::from_f32), + cast_from_f32(og, bf16::from_f32), + cast_from_f32(wg, bf16::from_f32), + mg.map(|m| cast_from_f32(m, bf16::from_f32)), + bg.map(|b| cast_from_f32(b, bf16::from_f32)), + ) + } + // f64 backward computed via f32: precision loss for large/small values. + // A native f64 implementation would require duplicating ~400 lines of + // deform_conv2d_backward. f64 deform_conv is rare in practice. + DType::F64 => { + let to = |v: f64| v as f32; + let from = |v: f32| v as f64; + let (xg, og, wg, mg, bg) = deform_conv::deform_conv2d_backward_f32( + cast_to_f32(x, to), + cast_to_f32(offset, to), + cast_to_f32(weight, to), + mask.map(|m| cast_to_f32(m, to)), + bias.map(|b| cast_to_f32(b, to)), + cast_to_f32(output_grad, to), + options.stride, + options.padding, + options.dilation, + options.weight_groups, + options.offset_groups, + ); + ( + cast_from_f32(xg, from), + cast_from_f32(og, from), + cast_from_f32(wg, from), + mg.map(|m| cast_from_f32(m, from)), + bg.map(|b| cast_from_f32(b, from)), + ) + } + dtype => panic!("deform_conv2d_backward: unsupported dtype {:?}", dtype), + }; + DeformConv2dBackward::new(x_grad, offset_grad, weight_grad, mask_grad, bias_grad) + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv::conv3d_f32(x, weight, bias, &options), + DType::F64 => conv::conv3d_f64(x, weight, bias, &options), + DType::F16 => conv::conv3d_f16(x, weight, bias, &options), + DType::BF16 => conv::conv3d_bf16(x, weight, bias, &options), + dtype => panic!("conv3d: unsupported dtype {:?}", dtype), + } + } + + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv_transpose::conv_transpose1d_f32(x, weight, bias, &options), + DType::F64 => conv_transpose::conv_transpose1d_f64(x, weight, bias, &options), + DType::F16 => conv_transpose::conv_transpose1d_f16(x, weight, bias, &options), + DType::BF16 => conv_transpose::conv_transpose1d_bf16(x, weight, bias, &options), + dtype => panic!("conv_transpose1d: unsupported dtype {:?}", dtype), + } + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv_transpose::conv_transpose2d_f32(x, weight, bias, &options), + DType::F64 => conv_transpose::conv_transpose2d_f64(x, weight, bias, &options), + DType::F16 => conv_transpose::conv_transpose2d_f16(x, weight, bias, &options), + DType::BF16 => conv_transpose::conv_transpose2d_bf16(x, weight, bias, &options), + dtype => panic!("conv_transpose2d: unsupported dtype {:?}", dtype), + } + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => conv_transpose::conv_transpose3d_f32(x, weight, bias, &options), + DType::F64 => conv_transpose::conv_transpose3d_f64(x, weight, bias, &options), + DType::F16 => conv_transpose::conv_transpose3d_f16(x, weight, bias, &options), + DType::BF16 => conv_transpose::conv_transpose3d_bf16(x, weight, bias, &options), + dtype => panic!("conv_transpose3d: unsupported dtype {:?}", dtype), + } + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => pool::avg_pool2d_f32( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + DType::F64 => pool::avg_pool2d_f64( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + DType::F16 => pool::avg_pool2d_f16( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + DType::BF16 => pool::avg_pool2d_bf16( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ), + dtype => panic!("avg_pool2d: unsupported dtype {:?}", dtype), + } + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + _divisor_override: bool, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => pool::avg_pool2d_backward_f32( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ), + DType::F64 => pool::avg_pool2d_backward_f64( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ), + DType::F16 => pool::avg_pool2d_backward_f16( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ), + DType::BF16 => pool::avg_pool2d_backward_bf16( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ), + dtype => panic!("avg_pool2d_backward: unsupported dtype {:?}", dtype), + } + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + match x.dtype() { + DType::F32 => pool::adaptive_avg_pool2d_f32(x, output_size), + DType::F64 => pool::adaptive_avg_pool2d_f64(x, output_size), + DType::F16 => pool::adaptive_avg_pool2d_f16(x, output_size), + DType::BF16 => pool::adaptive_avg_pool2d_bf16(x, output_size), + dtype => panic!("adaptive_avg_pool2d: unsupported dtype {:?}", dtype), + } + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => pool::adaptive_avg_pool2d_backward_f32(x, grad), + DType::F64 => pool::adaptive_avg_pool2d_backward_f64(x, grad), + DType::F16 => pool::adaptive_avg_pool2d_backward_f16(x, grad), + DType::BF16 => pool::adaptive_avg_pool2d_backward_bf16(x, grad), + dtype => panic!( + "adaptive_avg_pool2d_backward: unsupported dtype {:?}", + dtype + ), + } + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + match x.dtype() { + DType::F32 => { + pool::max_pool2d_f32(x, kernel_size, stride, padding, dilation, ceil_mode) + } + DType::F64 => { + pool::max_pool2d_f64(x, kernel_size, stride, padding, dilation, ceil_mode) + } + DType::F16 => { + pool::max_pool2d_f16(x, kernel_size, stride, padding, dilation, ceil_mode) + } + DType::BF16 => { + pool::max_pool2d_bf16(x, kernel_size, stride, padding, dilation, ceil_mode) + } + dtype => panic!("max_pool2d: unsupported dtype {:?}", dtype), + } + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + let (output, mut indices) = match x.dtype() { + DType::F32 => pool::max_pool2d_with_indices_f32( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + DType::F64 => pool::max_pool2d_with_indices_f64( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + DType::F16 => pool::max_pool2d_with_indices_f16( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + DType::BF16 => pool::max_pool2d_with_indices_bf16( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ), + dtype => panic!("max_pool2d_with_indices: unsupported dtype {:?}", dtype), + }; + if indices.dtype() != DType::from(indices_dtype) { + indices = Flex::int_cast(indices, indices_dtype); + } + MaxPool2dWithIndices::new(output, indices) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + _kernel_size: [usize; 2], + _stride: [usize; 2], + _padding: [usize; 2], + _dilation: [usize; 2], + _ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + let x_grad = match x.dtype() { + DType::F32 => pool::max_pool2d_backward_f32(x, output_grad, indices), + DType::F64 => pool::max_pool2d_backward_f64(x, output_grad, indices), + DType::F16 => pool::max_pool2d_backward_f16(x, output_grad, indices), + DType::BF16 => pool::max_pool2d_backward_bf16(x, output_grad, indices), + dtype => panic!( + "max_pool2d_with_indices_backward: unsupported dtype {:?}", + dtype + ), + }; + MaxPool2dBackward::new(x_grad) + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + match (options.mode, x.dtype()) { + (InterpolateMode::Nearest, DType::F32) => { + interpolate::interpolate_nearest_f32(x, output_size, options.align_corners) + } + (InterpolateMode::Nearest, DType::F64) => { + interpolate::interpolate_nearest_f64(x, output_size, options.align_corners) + } + (InterpolateMode::Nearest, DType::F16) => { + interpolate::interpolate_nearest_f16(x, output_size, options.align_corners) + } + (InterpolateMode::Nearest, DType::BF16) => { + interpolate::interpolate_nearest_bf16(x, output_size, options.align_corners) + } + (InterpolateMode::Bilinear, DType::F32) => { + interpolate::interpolate_bilinear_f32(x, output_size, options.align_corners) + } + (InterpolateMode::Bilinear, DType::F64) => { + interpolate::interpolate_bilinear_f64(x, output_size, options.align_corners) + } + (InterpolateMode::Bilinear, DType::F16) => { + interpolate::interpolate_bilinear_f16(x, output_size, options.align_corners) + } + (InterpolateMode::Bilinear, DType::BF16) => { + interpolate::interpolate_bilinear_bf16(x, output_size, options.align_corners) + } + (InterpolateMode::Bicubic, DType::F32) => { + interpolate::interpolate_bicubic_f32(x, output_size, options.align_corners) + } + (InterpolateMode::Bicubic, DType::F64) => { + interpolate::interpolate_bicubic_f64(x, output_size, options.align_corners) + } + (InterpolateMode::Bicubic, DType::F16) => { + interpolate::interpolate_bicubic_f16(x, output_size, options.align_corners) + } + (InterpolateMode::Bicubic, DType::BF16) => { + interpolate::interpolate_bicubic_bf16(x, output_size, options.align_corners) + } + (InterpolateMode::Lanczos3, DType::F32) => { + interpolate::interpolate_lanczos3_f32(x, output_size, options.align_corners) + } + (InterpolateMode::Lanczos3, DType::F64) => { + interpolate::interpolate_lanczos3_f64(x, output_size, options.align_corners) + } + (InterpolateMode::Lanczos3, DType::F16) => { + interpolate::interpolate_lanczos3_f16(x, output_size, options.align_corners) + } + (InterpolateMode::Lanczos3, DType::BF16) => { + interpolate::interpolate_lanczos3_bf16(x, output_size, options.align_corners) + } + (mode, dtype) => panic!( + "interpolate: unsupported mode {:?} / dtype {:?}", + mode, dtype + ), + } + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + match (options.mode, x.dtype()) { + (InterpolateMode::Nearest, DType::F32) => { + interpolate::interpolate_nearest_backward_f32( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Nearest, DType::F64) => { + interpolate::interpolate_nearest_backward_f64( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Nearest, DType::F16) => { + interpolate::interpolate_nearest_backward_f16( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Nearest, DType::BF16) => { + interpolate::interpolate_nearest_backward_bf16( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bilinear, DType::F32) => { + interpolate::interpolate_bilinear_backward_f32( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bilinear, DType::F64) => { + interpolate::interpolate_bilinear_backward_f64( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bilinear, DType::F16) => { + interpolate::interpolate_bilinear_backward_f16( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bilinear, DType::BF16) => { + interpolate::interpolate_bilinear_backward_bf16( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bicubic, DType::F32) => { + interpolate::interpolate_bicubic_backward_f32( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bicubic, DType::F64) => { + interpolate::interpolate_bicubic_backward_f64( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bicubic, DType::F16) => { + interpolate::interpolate_bicubic_backward_f16( + x, + grad, + output_size, + options.align_corners, + ) + } + (InterpolateMode::Bicubic, DType::BF16) => { + interpolate::interpolate_bicubic_backward_bf16( + x, + grad, + output_size, + options.align_corners, + ) + } + (mode, dtype) => { + panic!( + "interpolate_backward: unsupported mode {:?} / dtype {:?}", + mode, dtype + ) + } + } + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + ) -> FloatTensor { + crate::ops::attention::attention(query, key, value, mask, attn_bias, options) + } + + fn rfft( + signal: FloatTensor, + dim: usize, + n: Option, + ) -> (FloatTensor, FloatTensor) { + match signal.dtype() { + DType::F32 => crate::ops::fft::rfft_f32(signal, dim, n), + DType::F64 => crate::ops::fft::rfft_f64(signal, dim, n), + DType::F16 => crate::ops::fft::rfft_f16(signal, dim, n), + DType::BF16 => crate::ops::fft::rfft_bf16(signal, dim, n), + dtype => panic!("rfft: unsupported dtype {:?}", dtype), + } + } + + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + n: Option, + ) -> FloatTensor { + match spectrum_re.dtype() { + DType::F32 => crate::ops::fft::irfft_f32(spectrum_re, spectrum_im, dim, n), + DType::F64 => crate::ops::fft::irfft_f64(spectrum_re, spectrum_im, dim, n), + DType::F16 => crate::ops::fft::irfft_f16(spectrum_re, spectrum_im, dim, n), + DType::BF16 => crate::ops::fft::irfft_bf16(spectrum_re, spectrum_im, dim, n), + dtype => panic!("irfft: unsupported dtype {:?}", dtype), + } + } + + fn embedding(weights: FloatTensor, indices: IntTensor) -> FloatTensor { + let [batch_size, seq_length] = indices.shape().dims(); + let [_, d_model] = weights.shape().dims(); + + let indices = Flex::int_reshape(indices, Shape::from(alloc::vec![batch_size * seq_length])); + let output = Flex::float_select(weights, 0, indices); + Flex::float_reshape( + output, + Shape::from(alloc::vec![batch_size, seq_length, d_model]), + ) + } + + fn layer_norm( + tensor: FloatTensor, + gamma: FloatTensor, + beta: Option>, + epsilon: f64, + ) -> FloatTensor { + crate::ops::activation::layer_norm(tensor, gamma, beta, epsilon) + } + + fn embedding_backward( + weights: FloatTensor, + output_grad: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + let [batch_size, seq_length] = indices.shape().dims(); + let [n_embeddings, d_model] = weights.shape().dims(); + let dtype = output_grad.dtype(); + + let indices = Flex::int_reshape(indices, Shape::from(alloc::vec![batch_size * seq_length])); + let output_grad = Flex::float_reshape( + output_grad, + Shape::from(alloc::vec![batch_size * seq_length, d_model]), + ); + let grad = Flex::float_zeros( + Shape::from(alloc::vec![n_embeddings, d_model]), + &Default::default(), + dtype.into(), + ); + Flex::float_select_add(grad, 0, indices, output_grad) + } +} diff --git a/crates/burn-flex/src/ops/pool.rs b/crates/burn-flex/src/ops/pool.rs new file mode 100644 index 0000000..f0c0538 --- /dev/null +++ b/crates/burn-flex/src/ops/pool.rs @@ -0,0 +1,1891 @@ +//! Pooling operations using a unified 3D implementation. +//! +//! All pooling (1D, 2D, 3D) uses a unified 3D implementation: +//! - pool1d: adds two size-1 dimensions, calls pool3d, squeezes output +//! - pool2d: adds one size-1 dimension, calls pool3d, squeezes output +//! - pool3d: native implementation +//! +//! Supported dtypes: f32, f64, f16 (native), bf16 (via f32 conversion) + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::{FlexTensor, Layout}; + +// ============================================================================ +// Macros for dtype wrappers +// ============================================================================ + +/// Generates max_pool3d_with_indices typed dispatchers. +macro_rules! max_pool3d_with_indices_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $neg_inf:expr) => { + pub fn $fn_name( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, + ) -> (FlexTensor, FlexTensor) { + max_pool3d_with_indices_impl::<$T>( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + $dtype, + $neg_inf, + ) + } + }; +} + +/// Generates avg_pool3d typed dispatchers. +macro_rules! avg_pool3d_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $add_fn:expr, $div_fn:expr) => { + pub fn $fn_name( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + count_include_pad: bool, + ceil_mode: bool, + ) -> FlexTensor { + avg_pool3d_impl::<$T>( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + $dtype, + $zero, + $add_fn, + $div_fn, + ) + } + }; +} + +/// Generates adaptive_avg_pool3d typed dispatchers. +macro_rules! adaptive_avg_pool3d_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $add_fn:expr, $div_fn:expr) => { + pub fn $fn_name(x: FlexTensor, output_size: [usize; 3]) -> FlexTensor { + adaptive_avg_pool3d_impl::<$T>(x, output_size, $dtype, $zero, $add_fn, $div_fn) + } + }; +} + +/// Generates max_pool3d_backward typed dispatchers. +macro_rules! max_pool3d_backward_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $add_fn:expr) => { + pub fn $fn_name(x: FlexTensor, grad: FlexTensor, indices: FlexTensor) -> FlexTensor { + match indices.dtype() { + DType::I64 => { + max_pool3d_backward_impl::<$T, i64>(x, grad, indices, $dtype, $zero, $add_fn) + } + DType::I32 => { + max_pool3d_backward_impl::<$T, i32>(x, grad, indices, $dtype, $zero, $add_fn) + } + DType::I16 => { + max_pool3d_backward_impl::<$T, i16>(x, grad, indices, $dtype, $zero, $add_fn) + } + DType::I8 => { + max_pool3d_backward_impl::<$T, i8>(x, grad, indices, $dtype, $zero, $add_fn) + } + other => panic!("max_pool3d_backward: unsupported index dtype {other:?}",), + } + } + }; +} + +/// Generates avg_pool3d_backward typed dispatchers. +macro_rules! avg_pool3d_backward_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $add_fn:expr, $div_fn:expr) => { + pub fn $fn_name( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + count_include_pad: bool, + ) -> FlexTensor { + avg_pool3d_backward_impl::<$T>( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + $dtype, + $zero, + $add_fn, + $div_fn, + ) + } + }; +} + +/// Generates adaptive_avg_pool3d_backward typed dispatchers. +macro_rules! adaptive_avg_pool3d_backward_typed { + ($fn_name:ident, $T:ty, $dtype:expr, $zero:expr, $add_fn:expr, $div_fn:expr) => { + pub fn $fn_name(x: FlexTensor, grad: FlexTensor) -> FlexTensor { + adaptive_avg_pool3d_backward_impl::<$T>(x, grad, $dtype, $zero, $add_fn, $div_fn) + } + }; +} + +// ============================================================================ +// Output size calculation +// ============================================================================ + +/// Calculate pooling output size for a single dimension. +fn pool_output_size( + input: usize, + kernel: usize, + padding: usize, + stride: usize, + dilation: usize, + ceil_mode: bool, +) -> usize { + assert!(kernel > 0, "pool: kernel size must be > 0"); + assert!(stride > 0, "pool: stride must be > 0"); + let effective_kernel = dilation * (kernel - 1) + 1; + let padded = input + 2 * padding; + if padded < effective_kernel { + return if ceil_mode { 1 } else { 0 }; + } + let numerator = padded - effective_kernel; + if ceil_mode { + numerator.div_ceil(stride) + 1 + } else { + numerator / stride + 1 + } +} + +// ============================================================================ +// Max Pool 3D - core implementation +// ============================================================================ + +max_pool3d_with_indices_typed!( + max_pool3d_with_indices_f32, + f32, + DType::F32, + f32::NEG_INFINITY +); +max_pool3d_with_indices_typed!( + max_pool3d_with_indices_f64, + f64, + DType::F64, + f64::NEG_INFINITY +); +max_pool3d_with_indices_typed!( + max_pool3d_with_indices_f16, + f16, + DType::F16, + f16::NEG_INFINITY +); + +pub fn max_pool3d_with_indices_bf16( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_f32 = convert_bf16_to_f32(&x); + let (output_f32, indices) = + max_pool3d_with_indices_f32(x_f32, kernel_size, stride, padding, dilation, ceil_mode); + (convert_f32_to_bf16(&output_f32), indices) +} + +/// Generic 3D max pooling with indices implementation. +#[allow(clippy::too_many_arguments)] +fn max_pool3d_with_indices_impl( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, + dtype: DType, + neg_inf: T, +) -> (FlexTensor, FlexTensor) +where + T: bytemuck::Pod + Copy + PartialOrd + Send + Sync + Element, +{ + let x = x.to_contiguous(); + let x_shape = x.layout().shape(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let [kernel_d, kernel_h, kernel_w] = kernel_size; + let [stride_d, stride_h, stride_w] = stride; + let [pad_d, pad_h, pad_w] = padding; + let [dilation_d, dilation_h, dilation_w] = dilation; + + let out_d = pool_output_size(in_d, kernel_d, pad_d, stride_d, dilation_d, ceil_mode); + let out_h = pool_output_size(in_h, kernel_h, pad_h, stride_h, dilation_h, ceil_mode); + let out_w = pool_output_size(in_w, kernel_w, pad_w, stride_w, dilation_w, ceil_mode); + + let spatial_out = out_d * out_h * out_w; + let x_data: &[T] = x.storage(); + + let (output, indices) = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![neg_inf; batch_size * channels * spatial_out]; + let mut indices = vec![-1i64; batch_size * channels * spatial_out]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + let idx_ptr = crate::ops::SendMutPtr::new(indices.as_mut_ptr()); + + // Flatten batch*channels into a single par_iter so rayon chooses + // the right granularity instead of creating one task per channel. + let bc_total = batch_size * channels; + (0..bc_total).into_par_iter().for_each(|bc| { + let b = bc / channels; + let c = bc % channels; + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = bc * spatial_out; + + for od in 0..out_d { + for oh in 0..out_h { + for ow in 0..out_w { + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut max_val = neg_inf; + let mut max_idx: i64 = -1; + + for kd in 0..kernel_d { + let id = + (od * stride_d + kd * dilation_d) as isize - pad_d as isize; + if id < 0 || id >= in_d as isize { + continue; + } + let id = id as usize; + + for kh in 0..kernel_h { + let ih = + (oh * stride_h + kh * dilation_h) as isize - pad_h as isize; + if ih < 0 || ih >= in_h as isize { + continue; + } + let ih = ih as usize; + + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw * dilation_w) as isize + - pad_w as isize; + if iw < 0 || iw >= in_w as isize { + continue; + } + let iw = iw as usize; + + let x_idx = x_offset + id * in_h * in_w + ih * in_w + iw; + let val = x_data[x_idx]; + + if max_idx < 0 || val > max_val { + max_val = val; + max_idx = (id * in_h * in_w + ih * in_w + iw) as i64; + } + } + } + } + + unsafe { + out_ptr.write(out_idx, max_val); + idx_ptr.write(out_idx, max_idx); + } + } + } + } + }); + (output, indices) + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![neg_inf; batch_size * channels * spatial_out]; + let mut indices = vec![-1i64; batch_size * channels * spatial_out]; + + for b in 0..batch_size { + for c in 0..channels { + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = b * channels * spatial_out + c * spatial_out; + + for od in 0..out_d { + for oh in 0..out_h { + for ow in 0..out_w { + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut max_val = neg_inf; + let mut max_idx: i64 = -1; + + for kd in 0..kernel_d { + let id = + (od * stride_d + kd * dilation_d) as isize - pad_d as isize; + if id < 0 || id >= in_d as isize { + continue; + } + let id = id as usize; + + for kh in 0..kernel_h { + let ih = (oh * stride_h + kh * dilation_h) as isize + - pad_h as isize; + if ih < 0 || ih >= in_h as isize { + continue; + } + let ih = ih as usize; + + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw * dilation_w) as isize + - pad_w as isize; + if iw < 0 || iw >= in_w as isize { + continue; + } + let iw = iw as usize; + + let x_idx = + x_offset + id * in_h * in_w + ih * in_w + iw; + let val = x_data[x_idx]; + + if max_idx < 0 || val > max_val { + max_val = val; + max_idx = + (id * in_h * in_w + ih * in_w + iw) as i64; + } + } + } + } + + output[out_idx] = max_val; + indices[out_idx] = max_idx; + } + } + } + } + } + (output, indices) + } + }; + + let out_shape = Shape::from(vec![batch_size, channels, out_d, out_h, out_w]); + let output_tensor = FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape.clone()), + dtype, + ); + let indices_tensor = FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(out_shape), + DType::I64, + ); + + (output_tensor, indices_tensor) +} + +/// 3D max pooling (without returning indices) for f32. +pub fn max_pool3d_f32( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, +) -> FlexTensor { + max_pool3d_with_indices_f32(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 3D max pooling (without returning indices) for f64. +pub fn max_pool3d_f64( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, +) -> FlexTensor { + max_pool3d_with_indices_f64(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 3D max pooling (without returning indices) for f16. +pub fn max_pool3d_f16( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, +) -> FlexTensor { + max_pool3d_with_indices_f16(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 3D max pooling (without returning indices) for bf16. +pub fn max_pool3d_bf16( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + dilation: [usize; 3], + ceil_mode: bool, +) -> FlexTensor { + max_pool3d_with_indices_bf16(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +// ============================================================================ +// Max Pool 2D - delegates to 3D +// ============================================================================ + +/// 2D max pooling with indices for f32. +pub fn max_pool2d_with_indices_f32( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_3d = expand_2d_to_3d(&x); + let (output, indices) = max_pool3d_with_indices_f32( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + [1, dilation[0], dilation[1]], + ceil_mode, + ); + (squeeze_3d_to_2d(output), squeeze_3d_to_2d(indices)) +} + +/// 2D max pooling with indices for f64. +pub fn max_pool2d_with_indices_f64( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_3d = expand_2d_to_3d(&x); + let (output, indices) = max_pool3d_with_indices_f64( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + [1, dilation[0], dilation[1]], + ceil_mode, + ); + (squeeze_3d_to_2d(output), squeeze_3d_to_2d(indices)) +} + +/// 2D max pooling with indices for f16. +pub fn max_pool2d_with_indices_f16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_3d = expand_2d_to_3d(&x); + let (output, indices) = max_pool3d_with_indices_f16( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + [1, dilation[0], dilation[1]], + ceil_mode, + ); + (squeeze_3d_to_2d(output), squeeze_3d_to_2d(indices)) +} + +/// 2D max pooling with indices for bf16. +pub fn max_pool2d_with_indices_bf16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_3d = expand_2d_to_3d(&x); + let (output, indices) = max_pool3d_with_indices_bf16( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + [1, dilation[0], dilation[1]], + ceil_mode, + ); + (squeeze_3d_to_2d(output), squeeze_3d_to_2d(indices)) +} + +/// 2D max pooling (without indices) for f32. +pub fn max_pool2d_f32( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> FlexTensor { + max_pool2d_with_indices_f32(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 2D max pooling (without indices) for f64. +pub fn max_pool2d_f64( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> FlexTensor { + max_pool2d_with_indices_f64(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 2D max pooling (without indices) for f16. +pub fn max_pool2d_f16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> FlexTensor { + max_pool2d_with_indices_f16(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +/// 2D max pooling (without indices) for bf16. +pub fn max_pool2d_bf16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> FlexTensor { + max_pool2d_with_indices_bf16(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +// ============================================================================ +// Max Pool 1D - delegates to 3D +// ============================================================================ + +/// 1D max pooling with indices for f32. +pub fn max_pool1d_with_indices_f32( + x: FlexTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, +) -> (FlexTensor, FlexTensor) { + let x_3d = expand_1d_to_3d(&x); + let (output, indices) = max_pool3d_with_indices_f32( + x_3d, + [1, 1, kernel_size], + [1, 1, stride], + [0, 0, padding], + [1, 1, dilation], + ceil_mode, + ); + (squeeze_3d_to_1d(output), squeeze_3d_to_1d(indices)) +} + +/// 1D max pooling (without indices) for f32. +pub fn max_pool1d_f32( + x: FlexTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, +) -> FlexTensor { + max_pool1d_with_indices_f32(x, kernel_size, stride, padding, dilation, ceil_mode).0 +} + +// ============================================================================ +// Avg Pool 3D - core implementation +// ============================================================================ + +avg_pool3d_typed!( + avg_pool3d_f32, + f32, + DType::F32, + 0.0f32, + |a, b| a + b, + |sum, count| sum / count as f32 +); +avg_pool3d_typed!( + avg_pool3d_f64, + f64, + DType::F64, + 0.0f64, + |a, b| a + b, + |sum, count| sum / count as f64 +); +avg_pool3d_typed!( + avg_pool3d_f16, + f16, + DType::F16, + f16::from_f32(0.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()), + |sum: f16, count| f16::from_f32(sum.to_f32() / count as f32) +); + +pub fn avg_pool3d_bf16( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let result_f32 = avg_pool3d_f32( + x_f32, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ); + convert_f32_to_bf16(&result_f32) +} + +/// Generic 3D average pooling implementation. +#[allow(clippy::too_many_arguments)] +fn avg_pool3d_impl( + x: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + count_include_pad: bool, + ceil_mode: bool, + dtype: DType, + zero: T, + add_fn: fn(T, T) -> T, + div_fn: fn(T, usize) -> T, +) -> FlexTensor +where + T: bytemuck::Pod + Copy + Send + Sync + Element, +{ + let x = x.to_contiguous(); + let x_shape = x.layout().shape(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let [kernel_d, kernel_h, kernel_w] = kernel_size; + let [stride_d, stride_h, stride_w] = stride; + let [pad_d, pad_h, pad_w] = padding; + + // Avg pool doesn't use dilation in typical implementations + let dilation_d = 1; + let dilation_h = 1; + let dilation_w = 1; + + let out_d = pool_output_size(in_d, kernel_d, pad_d, stride_d, dilation_d, ceil_mode); + let out_h = pool_output_size(in_h, kernel_h, pad_h, stride_h, dilation_h, ceil_mode); + let out_w = pool_output_size(in_w, kernel_w, pad_w, stride_w, dilation_w, ceil_mode); + + let spatial_out = out_d * out_h * out_w; + let x_data: &[T] = x.storage(); + let _kernel_volume = kernel_d * kernel_h * kernel_w; + + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![zero; batch_size * channels * spatial_out]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + let bc_total = batch_size * channels; + (0..bc_total).into_par_iter().for_each(|bc| { + let b = bc / channels; + let c = bc % channels; + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = bc * spatial_out; + + for od in 0..out_d { + for oh in 0..out_h { + for ow in 0..out_w { + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut sum = zero; + let mut count = 0usize; + let mut pad_count = 0usize; + + for kd in 0..kernel_d { + let id = (od * stride_d + kd) as isize - pad_d as isize; + let id_in_bounds = + id >= -(pad_d as isize) && id < (in_d + pad_d) as isize; + if !id_in_bounds { + continue; + } + let id_valid = id >= 0 && id < in_d as isize; + + for kh in 0..kernel_h { + let ih = (oh * stride_h + kh) as isize - pad_h as isize; + let ih_in_bounds = + ih >= -(pad_h as isize) && ih < (in_h + pad_h) as isize; + if !ih_in_bounds { + continue; + } + let ih_valid = ih >= 0 && ih < in_h as isize; + + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw) as isize - pad_w as isize; + let iw_in_bounds = + iw >= -(pad_w as isize) && iw < (in_w + pad_w) as isize; + if !iw_in_bounds { + continue; + } + + pad_count += 1; + + let iw_valid = iw >= 0 && iw < in_w as isize; + if !id_valid || !ih_valid || !iw_valid { + continue; + } + + let id = id as usize; + let ih = ih as usize; + let iw = iw as usize; + let x_idx = x_offset + id * in_h * in_w + ih * in_w + iw; + sum = add_fn(sum, x_data[x_idx]); + count += 1; + } + } + } + + let divisor = if count_include_pad { + pad_count.max(1) + } else { + count.max(1) + }; + + unsafe { + out_ptr.write(out_idx, div_fn(sum, divisor)); + } + } + } + } + }); + output + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![zero; batch_size * channels * spatial_out]; + + for b in 0..batch_size { + for c in 0..channels { + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = b * channels * spatial_out + c * spatial_out; + + for od in 0..out_d { + for oh in 0..out_h { + for ow in 0..out_w { + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut sum = zero; + let mut count = 0usize; + + // Track count for count_include_pad (positions within padded bounds) + let mut pad_count = 0usize; + + for kd in 0..kernel_d { + let id = (od * stride_d + kd) as isize - pad_d as isize; + // Check if within padded bounds (not ceil_mode extension) + let id_in_bounds = + id >= -(pad_d as isize) && id < (in_d + pad_d) as isize; + if !id_in_bounds { + continue; // ceil_mode extension - skip entirely + } + let id_valid = id >= 0 && id < in_d as isize; + + for kh in 0..kernel_h { + let ih = (oh * stride_h + kh) as isize - pad_h as isize; + let ih_in_bounds = + ih >= -(pad_h as isize) && ih < (in_h + pad_h) as isize; + if !ih_in_bounds { + continue; + } + let ih_valid = ih >= 0 && ih < in_h as isize; + + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw) as isize - pad_w as isize; + let iw_in_bounds = iw >= -(pad_w as isize) + && iw < (in_w + pad_w) as isize; + if !iw_in_bounds { + continue; + } + + // Position is within padded bounds + pad_count += 1; + + let iw_valid = iw >= 0 && iw < in_w as isize; + if !id_valid || !ih_valid || !iw_valid { + continue; // In padding zone - count but don't add + } + + let id = id as usize; + let ih = ih as usize; + let iw = iw as usize; + let x_idx = + x_offset + id * in_h * in_w + ih * in_w + iw; + sum = add_fn(sum, x_data[x_idx]); + count += 1; + } + } + } + + let divisor = if count_include_pad { + pad_count.max(1) // Positions within padded bounds + } else { + count.max(1) // Only actual valid positions + }; + + output[out_idx] = div_fn(sum, divisor); + } + } + } + } + } + output + } + }; + + let out_shape = Shape::from(vec![batch_size, channels, out_d, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// Avg Pool 2D - delegates to 3D +// ============================================================================ + +/// 2D average pooling for f32. +pub fn avg_pool2d_f32( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = avg_pool3d_f32( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ceil_mode, + ); + squeeze_3d_to_2d(result) +} + +/// 2D average pooling for f64. +pub fn avg_pool2d_f64( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = avg_pool3d_f64( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ceil_mode, + ); + squeeze_3d_to_2d(result) +} + +/// 2D average pooling for f16. +pub fn avg_pool2d_f16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = avg_pool3d_f16( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ceil_mode, + ); + squeeze_3d_to_2d(result) +} + +/// 2D average pooling for bf16. +pub fn avg_pool2d_bf16( + x: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = avg_pool3d_bf16( + x_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ceil_mode, + ); + squeeze_3d_to_2d(result) +} + +// ============================================================================ +// Avg Pool 1D - delegates to 3D +// ============================================================================ + +/// 1D average pooling for f32. +pub fn avg_pool1d_f32( + x: FlexTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, +) -> FlexTensor { + let x_3d = expand_1d_to_3d(&x); + let result = avg_pool3d_f32( + x_3d, + [1, 1, kernel_size], + [1, 1, stride], + [0, 0, padding], + count_include_pad, + ceil_mode, + ); + squeeze_3d_to_1d(result) +} + +// ============================================================================ +// Adaptive Avg Pool 3D - core implementation +// ============================================================================ + +adaptive_avg_pool3d_typed!( + adaptive_avg_pool3d_f32, + f32, + DType::F32, + 0.0f32, + |a, b| a + b, + |sum, count| sum / count as f32 +); +adaptive_avg_pool3d_typed!( + adaptive_avg_pool3d_f64, + f64, + DType::F64, + 0.0f64, + |a, b| a + b, + |sum, count| sum / count as f64 +); +adaptive_avg_pool3d_typed!( + adaptive_avg_pool3d_f16, + f16, + DType::F16, + f16::from_f32(0.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()), + |sum: f16, count| f16::from_f32(sum.to_f32() / count as f32) +); + +pub fn adaptive_avg_pool3d_bf16(x: FlexTensor, output_size: [usize; 3]) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let result_f32 = adaptive_avg_pool3d_f32(x_f32, output_size); + convert_f32_to_bf16(&result_f32) +} + +/// Generic 3D adaptive average pooling implementation. +fn adaptive_avg_pool3d_impl( + x: FlexTensor, + output_size: [usize; 3], + dtype: DType, + zero: T, + add_fn: fn(T, T) -> T, + div_fn: fn(T, usize) -> T, +) -> FlexTensor +where + T: bytemuck::Pod + Copy + Send + Sync + Element, +{ + let x = x.to_contiguous(); + let x_shape = x.layout().shape(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + + let [out_d, out_h, out_w] = output_size; + let spatial_out = out_d * out_h * out_w; + let x_data: &[T] = x.storage(); + + let output = { + #[cfg(feature = "rayon")] + { + use rayon::prelude::*; + + let mut output = vec![zero; batch_size * channels * spatial_out]; + let out_ptr = crate::ops::SendMutPtr::new(output.as_mut_ptr()); + + (0..batch_size).into_par_iter().for_each(|b| { + (0..channels).into_par_iter().for_each(|c| { + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = b * channels * spatial_out + c * spatial_out; + + for od in 0..out_d { + // Compute input range for this output position + // start = floor(out * in / out_size), end = ceil((out+1) * in / out_size) + let d_start = (od * in_d) / out_d; + let d_end = ((od + 1) * in_d).div_ceil(out_d); + + for oh in 0..out_h { + let h_start = (oh * in_h) / out_h; + let h_end = ((oh + 1) * in_h).div_ceil(out_h); + + for ow in 0..out_w { + let w_start = (ow * in_w) / out_w; + let w_end = ((ow + 1) * in_w).div_ceil(out_w); + + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut sum = zero; + let mut count = 0usize; + + for id in d_start..d_end { + for ih in h_start..h_end { + for iw in w_start..w_end { + let x_idx = + x_offset + id * in_h * in_w + ih * in_w + iw; + sum = add_fn(sum, x_data[x_idx]); + count += 1; + } + } + } + + unsafe { + out_ptr.write(out_idx, div_fn(sum, count.max(1))); + } + } + } + } + }); + }); + output + } + #[cfg(not(feature = "rayon"))] + { + let mut output = vec![zero; batch_size * channels * spatial_out]; + + for b in 0..batch_size { + for c in 0..channels { + let x_offset = b * channels * in_d * in_h * in_w + c * in_d * in_h * in_w; + let out_offset = b * channels * spatial_out + c * spatial_out; + + for od in 0..out_d { + let d_start = (od * in_d) / out_d; + let d_end = ((od + 1) * in_d).div_ceil(out_d); + + for oh in 0..out_h { + let h_start = (oh * in_h) / out_h; + let h_end = ((oh + 1) * in_h).div_ceil(out_h); + + for ow in 0..out_w { + let w_start = (ow * in_w) / out_w; + let w_end = ((ow + 1) * in_w).div_ceil(out_w); + + let out_idx = out_offset + od * out_h * out_w + oh * out_w + ow; + let mut sum = zero; + let mut count = 0usize; + + for id in d_start..d_end { + for ih in h_start..h_end { + for iw in w_start..w_end { + let x_idx = + x_offset + id * in_h * in_w + ih * in_w + iw; + sum = add_fn(sum, x_data[x_idx]); + count += 1; + } + } + } + + output[out_idx] = div_fn(sum, count.max(1)); + } + } + } + } + } + output + } + }; + + let out_shape = Shape::from(vec![batch_size, channels, out_d, out_h, out_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// Adaptive Avg Pool 2D - delegates to 3D +// ============================================================================ + +/// 2D adaptive average pooling for f32. +pub fn adaptive_avg_pool2d_f32(x: FlexTensor, output_size: [usize; 2]) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = adaptive_avg_pool3d_f32(x_3d, [1, output_size[0], output_size[1]]); + squeeze_3d_to_2d(result) +} + +/// 2D adaptive average pooling for f64. +pub fn adaptive_avg_pool2d_f64(x: FlexTensor, output_size: [usize; 2]) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = adaptive_avg_pool3d_f64(x_3d, [1, output_size[0], output_size[1]]); + squeeze_3d_to_2d(result) +} + +/// 2D adaptive average pooling for f16. +pub fn adaptive_avg_pool2d_f16(x: FlexTensor, output_size: [usize; 2]) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = adaptive_avg_pool3d_f16(x_3d, [1, output_size[0], output_size[1]]); + squeeze_3d_to_2d(result) +} + +/// 2D adaptive average pooling for bf16. +pub fn adaptive_avg_pool2d_bf16(x: FlexTensor, output_size: [usize; 2]) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let result = adaptive_avg_pool3d_bf16(x_3d, [1, output_size[0], output_size[1]]); + squeeze_3d_to_2d(result) +} + +// ============================================================================ +// Adaptive Avg Pool 1D - delegates to 3D +// ============================================================================ + +/// 1D adaptive average pooling for f32. +pub fn adaptive_avg_pool1d_f32(x: FlexTensor, output_size: usize) -> FlexTensor { + let x_3d = expand_1d_to_3d(&x); + let result = adaptive_avg_pool3d_f32(x_3d, [1, 1, output_size]); + squeeze_3d_to_1d(result) +} + +// ============================================================================ +// Backward passes +// ============================================================================ + +/// Max pool 2D backward using stored indices. +pub fn max_pool2d_backward_f32(x: FlexTensor, grad: FlexTensor, indices: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let indices_3d = expand_2d_to_3d(&indices); + let result = max_pool3d_backward_f32(x_3d, grad_3d, indices_3d); + squeeze_3d_to_2d(result) +} + +/// Max pool 2D backward for f64. +pub fn max_pool2d_backward_f64(x: FlexTensor, grad: FlexTensor, indices: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let indices_3d = expand_2d_to_3d(&indices); + let result = max_pool3d_backward_f64(x_3d, grad_3d, indices_3d); + squeeze_3d_to_2d(result) +} + +/// Max pool 2D backward for f16. +pub fn max_pool2d_backward_f16(x: FlexTensor, grad: FlexTensor, indices: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let indices_3d = expand_2d_to_3d(&indices); + let result = max_pool3d_backward_f16(x_3d, grad_3d, indices_3d); + squeeze_3d_to_2d(result) +} + +/// Max pool 2D backward for bf16. +pub fn max_pool2d_backward_bf16( + x: FlexTensor, + grad: FlexTensor, + indices: FlexTensor, +) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let grad_f32 = convert_bf16_to_f32(&grad); + let result_f32 = max_pool2d_backward_f32(x_f32, grad_f32, indices); + convert_f32_to_bf16(&result_f32) +} + +max_pool3d_backward_typed!(max_pool3d_backward_f32, f32, DType::F32, 0.0f32, |a, b| a + + b); +max_pool3d_backward_typed!(max_pool3d_backward_f64, f64, DType::F64, 0.0f64, |a, b| a + + b); +max_pool3d_backward_typed!( + max_pool3d_backward_f16, + f16, + DType::F16, + f16::from_f32(0.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()) +); + +/// Generic max pool 3D backward implementation. +fn max_pool3d_backward_impl( + x: FlexTensor, + grad: FlexTensor, + indices: FlexTensor, + dtype: DType, + zero: T, + add_fn: fn(T, T) -> T, +) -> FlexTensor +where + T: bytemuck::Pod + Copy + Send + Sync + Element, + I: bytemuck::Pod + Copy + Send + Sync + Element, +{ + let x_shape = x.layout().shape(); + let grad = grad.to_contiguous(); + let indices = indices.to_contiguous(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + let spatial_in = in_d * in_h * in_w; + + let grad_shape = grad.layout().shape(); + let out_d = grad_shape[2]; + let out_h = grad_shape[3]; + let out_w = grad_shape[4]; + let spatial_out = out_d * out_h * out_w; + + let grad_data: &[T] = grad.storage(); + let indices_data: &[I] = indices.storage(); + + // Accumulate gradients back to input positions + let mut output = vec![zero; batch_size * channels * spatial_in]; + + for b in 0..batch_size { + for c in 0..channels { + let grad_offset = b * channels * spatial_out + c * spatial_out; + let out_offset = b * channels * spatial_in + c * spatial_in; + + for i in 0..spatial_out { + let idx = indices_data[grad_offset + i].elem::(); + if idx >= 0 { + let input_idx = out_offset + idx as usize; + output[input_idx] = add_fn(output[input_idx], grad_data[grad_offset + i]); + } + } + } + } + + let out_shape = Shape::from(vec![batch_size, channels, in_d, in_h, in_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +/// Avg pool 2D backward. +pub fn avg_pool2d_backward_f32( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = avg_pool3d_backward_f32( + x_3d, + grad_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ); + squeeze_3d_to_2d(result) +} + +/// Avg pool 2D backward for f64. +pub fn avg_pool2d_backward_f64( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = avg_pool3d_backward_f64( + x_3d, + grad_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ); + squeeze_3d_to_2d(result) +} + +/// Avg pool 2D backward for f16. +pub fn avg_pool2d_backward_f16( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, +) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = avg_pool3d_backward_f16( + x_3d, + grad_3d, + [1, kernel_size[0], kernel_size[1]], + [1, stride[0], stride[1]], + [0, padding[0], padding[1]], + count_include_pad, + ); + squeeze_3d_to_2d(result) +} + +/// Avg pool 2D backward for bf16. +pub fn avg_pool2d_backward_bf16( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, +) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let grad_f32 = convert_bf16_to_f32(&grad); + let result_f32 = avg_pool2d_backward_f32( + x_f32, + grad_f32, + kernel_size, + stride, + padding, + count_include_pad, + ); + convert_f32_to_bf16(&result_f32) +} + +avg_pool3d_backward_typed!( + avg_pool3d_backward_f32, + f32, + DType::F32, + 0.0f32, + |a, b| a + b, + |val, count| val / count as f32 +); +avg_pool3d_backward_typed!( + avg_pool3d_backward_f64, + f64, + DType::F64, + 0.0f64, + |a, b| a + b, + |val, count| val / count as f64 +); +avg_pool3d_backward_typed!( + avg_pool3d_backward_f16, + f16, + DType::F16, + f16::from_f32(0.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()), + |val: f16, count| f16::from_f32(val.to_f32() / count as f32) +); + +/// Generic avg pool 3D backward implementation. +#[allow(clippy::too_many_arguments)] +fn avg_pool3d_backward_impl( + x: FlexTensor, + grad: FlexTensor, + kernel_size: [usize; 3], + stride: [usize; 3], + padding: [usize; 3], + count_include_pad: bool, + dtype: DType, + zero: T, + add_fn: fn(T, T) -> T, + div_fn: fn(T, usize) -> T, +) -> FlexTensor +where + T: bytemuck::Pod + Copy + Send + Sync + Element, +{ + let x_shape = x.layout().shape(); + let grad = grad.to_contiguous(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + let spatial_in = in_d * in_h * in_w; + + let [kernel_d, kernel_h, kernel_w] = kernel_size; + let [stride_d, stride_h, stride_w] = stride; + let [pad_d, pad_h, pad_w] = padding; + let kernel_volume = kernel_d * kernel_h * kernel_w; + + let grad_shape = grad.layout().shape(); + let out_d = grad_shape[2]; + let out_h = grad_shape[3]; + let out_w = grad_shape[4]; + let spatial_out = out_d * out_h * out_w; + + let grad_data: &[T] = grad.storage(); + + // Distribute gradient equally across window + let mut output = vec![zero; batch_size * channels * spatial_in]; + + for b in 0..batch_size { + for c in 0..channels { + let grad_offset = b * channels * spatial_out + c * spatial_out; + let out_offset = b * channels * spatial_in + c * spatial_in; + + for od in 0..out_d { + for oh in 0..out_h { + for ow in 0..out_w { + let grad_idx = grad_offset + od * out_h * out_w + oh * out_w + ow; + let grad_val = grad_data[grad_idx]; + + // Count valid positions for this output + let mut count = 0usize; + for kd in 0..kernel_d { + let id = (od * stride_d + kd) as isize - pad_d as isize; + if id >= 0 && id < in_d as isize { + for kh in 0..kernel_h { + let ih = (oh * stride_h + kh) as isize - pad_h as isize; + if ih >= 0 && ih < in_h as isize { + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw) as isize - pad_w as isize; + if iw >= 0 && iw < in_w as isize { + count += 1; + } + } + } + } + } + } + + let divisor = if count_include_pad { + kernel_volume + } else { + count.max(1) + }; + + // Distribute gradient + let distributed = div_fn(grad_val, divisor); + for kd in 0..kernel_d { + let id = (od * stride_d + kd) as isize - pad_d as isize; + if id < 0 || id >= in_d as isize { + continue; + } + let id = id as usize; + + for kh in 0..kernel_h { + let ih = (oh * stride_h + kh) as isize - pad_h as isize; + if ih < 0 || ih >= in_h as isize { + continue; + } + let ih = ih as usize; + + for kw in 0..kernel_w { + let iw = (ow * stride_w + kw) as isize - pad_w as isize; + if iw < 0 || iw >= in_w as isize { + continue; + } + let iw = iw as usize; + + let input_idx = out_offset + id * in_h * in_w + ih * in_w + iw; + output[input_idx] = add_fn(output[input_idx], distributed); + } + } + } + } + } + } + } + } + + let out_shape = Shape::from(vec![batch_size, channels, in_d, in_h, in_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +/// Adaptive avg pool 2D backward. +pub fn adaptive_avg_pool2d_backward_f32(x: FlexTensor, grad: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = adaptive_avg_pool3d_backward_f32(x_3d, grad_3d); + squeeze_3d_to_2d(result) +} + +/// Adaptive avg pool 2D backward for f64. +pub fn adaptive_avg_pool2d_backward_f64(x: FlexTensor, grad: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = adaptive_avg_pool3d_backward_f64(x_3d, grad_3d); + squeeze_3d_to_2d(result) +} + +/// Adaptive avg pool 2D backward for f16. +pub fn adaptive_avg_pool2d_backward_f16(x: FlexTensor, grad: FlexTensor) -> FlexTensor { + let x_3d = expand_2d_to_3d(&x); + let grad_3d = expand_2d_to_3d(&grad); + let result = adaptive_avg_pool3d_backward_f16(x_3d, grad_3d); + squeeze_3d_to_2d(result) +} + +/// Adaptive avg pool 2D backward for bf16. +pub fn adaptive_avg_pool2d_backward_bf16(x: FlexTensor, grad: FlexTensor) -> FlexTensor { + let x_f32 = convert_bf16_to_f32(&x); + let grad_f32 = convert_bf16_to_f32(&grad); + let result_f32 = adaptive_avg_pool2d_backward_f32(x_f32, grad_f32); + convert_f32_to_bf16(&result_f32) +} + +adaptive_avg_pool3d_backward_typed!( + adaptive_avg_pool3d_backward_f32, + f32, + DType::F32, + 0.0f32, + |a, b| a + b, + |val, count| val / count as f32 +); +adaptive_avg_pool3d_backward_typed!( + adaptive_avg_pool3d_backward_f64, + f64, + DType::F64, + 0.0f64, + |a, b| a + b, + |val, count| val / count as f64 +); +adaptive_avg_pool3d_backward_typed!( + adaptive_avg_pool3d_backward_f16, + f16, + DType::F16, + f16::from_f32(0.0), + |a: f16, b: f16| f16::from_f32(a.to_f32() + b.to_f32()), + |val: f16, count| f16::from_f32(val.to_f32() / count as f32) +); + +/// Generic adaptive avg pool 3D backward implementation. +fn adaptive_avg_pool3d_backward_impl( + x: FlexTensor, + grad: FlexTensor, + dtype: DType, + zero: T, + add_fn: fn(T, T) -> T, + div_fn: fn(T, usize) -> T, +) -> FlexTensor +where + T: bytemuck::Pod + Copy + Send + Sync + Element, +{ + let x_shape = x.layout().shape(); + let grad = grad.to_contiguous(); + + let batch_size = x_shape[0]; + let channels = x_shape[1]; + let in_d = x_shape[2]; + let in_h = x_shape[3]; + let in_w = x_shape[4]; + let spatial_in = in_d * in_h * in_w; + + let grad_shape = grad.layout().shape(); + let out_d = grad_shape[2]; + let out_h = grad_shape[3]; + let out_w = grad_shape[4]; + let spatial_out = out_d * out_h * out_w; + + let grad_data: &[T] = grad.storage(); + + let mut output = vec![zero; batch_size * channels * spatial_in]; + + for b in 0..batch_size { + for c in 0..channels { + let grad_offset = b * channels * spatial_out + c * spatial_out; + let out_offset = b * channels * spatial_in + c * spatial_in; + + for od in 0..out_d { + let d_start = (od * in_d) / out_d; + let d_end = ((od + 1) * in_d).div_ceil(out_d); + + for oh in 0..out_h { + let h_start = (oh * in_h) / out_h; + let h_end = ((oh + 1) * in_h).div_ceil(out_h); + + for ow in 0..out_w { + let w_start = (ow * in_w) / out_w; + let w_end = ((ow + 1) * in_w).div_ceil(out_w); + + let grad_idx = grad_offset + od * out_h * out_w + oh * out_w + ow; + let grad_val = grad_data[grad_idx]; + + let count = (d_end - d_start) * (h_end - h_start) * (w_end - w_start); + let distributed = div_fn(grad_val, count.max(1)); + + for id in d_start..d_end { + for ih in h_start..h_end { + for iw in w_start..w_end { + let input_idx = out_offset + id * in_h * in_w + ih * in_w + iw; + output[input_idx] = add_fn(output[input_idx], distributed); + } + } + } + } + } + } + } + } + + let out_shape = Shape::from(vec![batch_size, channels, in_d, in_h, in_w]); + FlexTensor::new( + Bytes::from_elems(output), + Layout::contiguous(out_shape), + dtype, + ) +} + +// ============================================================================ +// Dimension expansion/squeeze helpers +// ============================================================================ + +/// Expand 2D tensor [N, C, H, W] to 3D [N, C, 1, H, W]. +fn expand_2d_to_3d(x: &FlexTensor) -> FlexTensor { + let shape = x.layout().shape(); + x.reshape(Shape::from(vec![shape[0], shape[1], 1, shape[2], shape[3]])) +} + +/// Squeeze 3D tensor [N, C, 1, H, W] to 2D [N, C, H, W]. +fn squeeze_3d_to_2d(x: FlexTensor) -> FlexTensor { + let shape = x.layout().shape(); + x.reshape(Shape::from(vec![shape[0], shape[1], shape[3], shape[4]])) +} + +/// Expand 1D tensor [N, C, L] to 3D [N, C, 1, 1, L]. +fn expand_1d_to_3d(x: &FlexTensor) -> FlexTensor { + let shape = x.layout().shape(); + x.reshape(Shape::from(vec![shape[0], shape[1], 1, 1, shape[2]])) +} + +/// Squeeze 3D tensor [N, C, 1, 1, L] to 1D [N, C, L]. +fn squeeze_3d_to_1d(x: FlexTensor) -> FlexTensor { + let shape = x.layout().shape(); + x.reshape(Shape::from(vec![shape[0], shape[1], shape[4]])) +} + +// ============================================================================ +// bf16 conversion helpers +// ============================================================================ + +fn convert_bf16_to_f32(tensor: &FlexTensor) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let data: &[bf16] = tensor.storage(); + let f32_data: Vec = data.iter().map(|x| x.to_f32()).collect(); + FlexTensor::new( + Bytes::from_elems(f32_data), + Layout::contiguous(tensor.layout().shape().clone()), + DType::F32, + ) +} + +fn convert_f32_to_bf16(tensor: &FlexTensor) -> FlexTensor { + let data: &[f32] = tensor.storage(); + let bf16_data: Vec = data.iter().map(|x| bf16::from_f32(*x)).collect(); + FlexTensor::new( + Bytes::from_elems(bf16_data), + Layout::contiguous(tensor.layout().shape().clone()), + DType::BF16, + ) +} + +// ============================================================================ +// Tests +// ============================================================================ + +// Tests kept here probe flex internals: the `pool_output_size` helper +// (including zero-kernel/stride panics), dtype storage paths for max_pool2d +// (f16/bf16/f64), the backward kernels (max/avg/adaptive), and flex's +// count_include_pad avg_pool2d semantics. Plain forward-pass pool tests +// (max/avg/adaptive 2d, pool1d/3d delegation) live in +// crates/burn-backend-tests/tests/tensor/float/module/{maxpool,avgpool, +// adaptive_avgpool}*.rs and run on every backend. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_pool_output_size() { + // Basic: input=4, kernel=2, padding=0, stride=2, dilation=1 + // output = (4 + 0 - 2) / 2 + 1 = 2 + assert_eq!(pool_output_size(4, 2, 0, 2, 1, false), 2); + + // With padding: input=4, kernel=2, padding=1, stride=2 + // output = (4 + 2 - 2) / 2 + 1 = 3 + assert_eq!(pool_output_size(4, 2, 1, 2, 1, false), 3); + + // With ceil mode: input=5, kernel=2, padding=0, stride=2 + // floor: (5 - 2) / 2 + 1 = 2 + // ceil: ceil(3/2) + 1 = 3 + assert_eq!(pool_output_size(5, 2, 0, 2, 1, false), 2); + assert_eq!(pool_output_size(5, 2, 0, 2, 1, true), 3); + + // With dilation: input=7, kernel=2, dilation=2 + // effective_kernel = 2*(2-1)+1 = 3 + // output = (7 - 3) / 1 + 1 = 5 + assert_eq!(pool_output_size(7, 2, 0, 1, 2, false), 5); + } + + #[test] + fn test_avg_pool2d_count_include_pad() { + // 3x3 input with padding 1, kernel 2x2, stride 2 + let x_data = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 3, 3])); + + // count_include_pad = true: divide by kernel size (4) + let result_include = avg_pool2d_f32(x.clone(), [2, 2], [2, 2], [1, 1], true, false); + let out_include: Vec = result_include.into_data().to_vec().unwrap(); + + // count_include_pad = false: divide by actual count + let result_exclude = avg_pool2d_f32(x, [2, 2], [2, 2], [1, 1], false, false); + let out_exclude: Vec = result_exclude.into_data().to_vec().unwrap(); + + // Corner position with padding: only 1 valid element + // With count_include_pad: 1.0 / 4 = 0.25 + // Without: 1.0 / 1 = 1.0 + assert!((out_include[0] - 0.25).abs() < 1e-5); + assert!((out_exclude[0] - 1.0).abs() < 1e-5); + } + + #[test] + fn test_max_pool2d_f64() { + let x_data: Vec = (1..=16).map(|x| x as f64).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + + let result = max_pool2d_f64(x, [2, 2], [2, 2], [0, 0], [1, 1], false); + let out: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(out, vec![6.0, 8.0, 14.0, 16.0]); + } + + #[test] + fn test_max_pool2d_f16() { + let x_data: Vec = (1..=16).map(|x| f16::from_f32(x as f32)).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + + let result = max_pool2d_f16(x, [2, 2], [2, 2], [0, 0], [1, 1], false); + let out: Vec = result.into_data().to_vec().unwrap(); + + assert!((out[0].to_f32() - 6.0).abs() < 0.1); + assert!((out[1].to_f32() - 8.0).abs() < 0.1); + assert!((out[2].to_f32() - 14.0).abs() < 0.1); + assert!((out[3].to_f32() - 16.0).abs() < 0.1); + } + + #[test] + fn test_max_pool2d_bf16() { + let x_data: Vec = (1..=16).map(|x| bf16::from_f32(x as f32)).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + + let result = max_pool2d_bf16(x, [2, 2], [2, 2], [0, 0], [1, 1], false); + let out: Vec = result.into_data().to_vec().unwrap(); + + assert!((out[0].to_f32() - 6.0).abs() < 0.5); + assert!((out[1].to_f32() - 8.0).abs() < 0.5); + } + + #[test] + fn test_max_pool_backward() { + // Forward pass + let x_data: Vec = (1..=16).map(|x| x as f32).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data.clone(), vec![1, 1, 4, 4])); + let (_output, indices) = + max_pool2d_with_indices_f32(x.clone(), [2, 2], [2, 2], [0, 0], [1, 1], false); + + // Backward pass: gradient of 1.0 for each output + let grad = FlexTensor::from_data(TensorData::new(vec![1.0f32; 4], vec![1, 1, 2, 2])); + + let x_grad = max_pool2d_backward_f32(x, grad, indices); + let grad_data: Vec = x_grad.into_data().to_vec().unwrap(); + + // Gradient should be 1.0 at max positions, 0.0 elsewhere + // Max positions were: 5, 7, 13, 15 (0-indexed) + assert_eq!(grad_data[5], 1.0); + assert_eq!(grad_data[7], 1.0); + assert_eq!(grad_data[13], 1.0); + assert_eq!(grad_data[15], 1.0); + assert_eq!(grad_data[0], 0.0); + } + + #[test] + fn test_avg_pool_backward() { + let x_data: Vec = (1..=16).map(|x| x as f32).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + + // Backward with gradient of 4.0 for each output (will distribute as 1.0 each) + let grad = FlexTensor::from_data(TensorData::new(vec![4.0f32; 4], vec![1, 1, 2, 2])); + + let x_grad = avg_pool2d_backward_f32(x, grad, [2, 2], [2, 2], [0, 0], false); + let grad_data: Vec = x_grad.into_data().to_vec().unwrap(); + + // Each position in input should receive grad/4 = 1.0 + assert!(grad_data.iter().all(|&v| (v - 1.0).abs() < 1e-5)); + } + + #[test] + fn test_adaptive_avg_pool_backward() { + let x_data: Vec = (1..=16).map(|x| x as f32).collect(); + let x = FlexTensor::from_data(TensorData::new(x_data, vec![1, 1, 4, 4])); + + // Backward with gradient of 4.0 for each output element + let grad = FlexTensor::from_data(TensorData::new(vec![4.0f32; 4], vec![1, 1, 2, 2])); + let x_grad = adaptive_avg_pool2d_backward_f32(x, grad); + let grad_data: Vec = x_grad.into_data().to_vec().unwrap(); + + // Each input position receives gradient from its output region + assert!(grad_data.iter().all(|&v| (v - 1.0).abs() < 1e-5)); + } + + #[test] + #[should_panic(expected = "kernel size must be > 0")] + fn test_pool_output_size_zero_kernel_panics() { + pool_output_size(4, 0, 0, 1, 1, false); + } + + #[test] + #[should_panic(expected = "stride must be > 0")] + fn test_pool_output_size_zero_stride_panics() { + pool_output_size(4, 2, 0, 0, 1, false); + } +} diff --git a/crates/burn-flex/src/ops/qtensor.rs b/crates/burn-flex/src/ops/qtensor.rs new file mode 100644 index 0000000..bdb41e5 --- /dev/null +++ b/crates/burn-flex/src/ops/qtensor.rs @@ -0,0 +1,763 @@ +//! Quantized tensor operations for the Flex backend. + +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use burn_backend::{ + DType, ExecutionError, FloatDType, TensorData, TensorMetadata, + ops::{IntTensorOps, QTensorOps}, + quantization::{ + QuantLevel, QuantScheme, QuantStore, QuantizationParametersPrimitive, QuantizedBytes, + }, + tensor::{Device, FloatTensor, IntTensor, QuantizedTensor}, +}; +use burn_std::{Bytes, Shape, Slice, bf16, f16}; + +use super::float_storage_as_f32; +use crate::{Flex, FlexQTensor, FlexTensor, Layout}; + +impl QTensorOps for Flex { + fn q_from_data(data: TensorData, _device: &Device) -> QuantizedTensor { + let scheme = match data.dtype { + DType::QFloat(scheme) => scheme, + _ => panic!("Expected quantized dtype, got {:?}", data.dtype), + }; + + let shape = data.shape.clone(); + let num_elements = data.num_elements(); + + let q_bytes = QuantizedBytes { + bytes: data.into_bytes(), + scheme, + num_elements, + }; + + let (values, qparams) = q_bytes.into_vec_i8(); + let tensor_data = TensorData::new(values, shape); + let tensor = FlexTensor::from_data(tensor_data); + + // Use native storage since we've unpacked to i8 + let scheme = scheme.with_store(QuantStore::Native); + + FlexQTensor::new(tensor, scheme, qparams.scales) + } + + fn quantize_dynamic(tensor: FloatTensor, scheme: &QuantScheme) -> QuantizedTensor { + let shape = tensor.shape(); + let tensor = tensor.to_contiguous(); + let float_data = float_storage_as_f32(&tensor); + let (a, b) = scheme.value.range(); + let range = b - a; + + let (quantized, scales) = match scheme.level { + QuantLevel::Tensor => { + // Pass 1: find alpha = max(|min|, |max|) + let mut alpha: f32 = 0.0; + for &x in &*float_data { + let abs = x.abs(); + if abs > alpha { + alpha = abs; + } + } + let scale = validated_scale(2.0 * alpha / range); + let inv_scale = 1.0 / scale; + + // Pass 2: quantize + let quantized = float_data + .iter() + .map(|&x| (x * inv_scale).round().clamp(a, b) as i8) + .collect::>(); + + (quantized, alloc::vec![scale]) + } + QuantLevel::Block(block_size) => { + let block_elems = block_size.num_elements(); + debug_assert!( + float_data.len().is_multiple_of(block_elems), + "tensor length {} not divisible by block size {}", + float_data.len(), + block_elems + ); + let num_blocks = float_data.len() / block_elems; + let mut scales = Vec::with_capacity(num_blocks); + let mut quantized = Vec::with_capacity(float_data.len()); + + for block in float_data.chunks(block_elems) { + // Find alpha for this block + let mut alpha: f32 = 0.0; + for &x in block { + let abs = x.abs(); + if abs > alpha { + alpha = abs; + } + } + let scale = validated_scale(2.0 * alpha / range); + let inv_scale = 1.0 / scale; + scales.push(scale); + + // Quantize this block + for &x in block { + quantized.push((x * inv_scale).round().clamp(a, b) as i8); + } + } + + (quantized, scales) + } + }; + + let bytes = Bytes::from_elems(quantized); + let layout = Layout::contiguous(shape); + let qt = FlexTensor::new(bytes, layout, DType::I8); + + FlexQTensor::new(qt, scheme.with_store(QuantStore::Native), scales) + } + + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + let shape = tensor.shape(); + let tensor = tensor.to_contiguous(); + let float_data = float_storage_as_f32(&tensor); + + // Extract and validate scales from the qparams tensor. The scales tensor + // shares its dtype with the float element type, which can be any of + // f32/f64/f16/bf16, so we normalise via float_storage_as_f32 instead of + // assuming f32 storage. + let scales_tensor = qparams.scales.to_contiguous(); + let scales_data = float_storage_as_f32(&scales_tensor); + let scales: Vec = scales_data.iter().copied().map(validated_scale).collect(); + + let (a, b) = scheme.value.range(); + + let quantized = match scheme.level { + QuantLevel::Tensor => { + let inv_scale = 1.0 / scales[0]; + float_data + .iter() + .map(|&x| (x * inv_scale).round().clamp(a, b) as i8) + .collect::>() + } + QuantLevel::Block(block_size) => { + let block_elems = block_size.num_elements(); + debug_assert!( + float_data.len().is_multiple_of(block_elems), + "tensor length {} not divisible by block size {}", + float_data.len(), + block_elems + ); + let mut quantized = Vec::with_capacity(float_data.len()); + for (block, &scale) in float_data.chunks(block_elems).zip(scales.iter()) { + let inv_scale = 1.0 / scale; + for &x in block { + quantized.push((x * inv_scale).round().clamp(a, b) as i8); + } + } + quantized + } + }; + + let bytes = Bytes::from_elems(quantized); + let layout = Layout::contiguous(shape); + let qt = FlexTensor::new(bytes, layout, DType::I8); + + FlexQTensor::new(qt, scheme.with_store(QuantStore::Native), scales) + } + + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor { + let shape = tensor.tensor.shape(); + let qt = tensor.tensor.to_contiguous(); + let q_data: &[i8] = qt.storage(); + + let dequantized = match tensor.scheme.level { + QuantLevel::Tensor => { + let scale = tensor.scales[0]; + q_data + .iter() + .map(|&x_q| scale * x_q as f32) + .collect::>() + } + QuantLevel::Block(block_size) => { + let block_elems = block_size.num_elements(); + q_data + .chunks(block_elems) + .zip(tensor.scales.iter()) + .flat_map(|(block, &scale)| block.iter().map(move |&x_q| scale * x_q as f32)) + .collect::>() + } + }; + + let layout = Layout::contiguous(shape); + match dtype { + FloatDType::F32 | FloatDType::Flex32 => { + FlexTensor::new(Bytes::from_elems(dequantized), layout, DType::F32) + } + FloatDType::F64 => { + let data: Vec = dequantized.iter().map(|&v| v as f64).collect(); + FlexTensor::new(Bytes::from_elems(data), layout, DType::F64) + } + FloatDType::F16 => { + let data: Vec = dequantized.iter().map(|&v| f16::from_f32(v)).collect(); + FlexTensor::new(Bytes::from_elems(data), layout, DType::F16) + } + FloatDType::BF16 => { + let data: Vec = dequantized.iter().map(|&v| bf16::from_f32(v)).collect(); + FlexTensor::new(Bytes::from_elems(data), layout, DType::BF16) + } + } + } + + fn q_to_device(tensor: QuantizedTensor, _device: &Device) -> QuantizedTensor { + tensor + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| t.reshape(shape)) + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + let shape = tensor.tensor.shape(); + let scheme = tensor.scheme; + let qt = tensor.tensor.to_contiguous(); + let values: Vec = qt.storage::().to_vec(); + + Ok(TensorData::quantized( + values, + shape.to_vec(), + scheme, + &tensor.scales, + )) + } + + fn q_swap_dims( + tensor: QuantizedTensor, + dim1: usize, + dim2: usize, + ) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| t.transpose(dim1, dim2)) + } + + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| t.permute(axes)) + } + + fn q_flip(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| crate::ops::flip::flip(t, axes)) + } + + fn q_expand(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| crate::ops::expand::expand(t, shape)) + } + + fn q_select( + tensor: QuantizedTensor, + dim: usize, + indices: IntTensor, + ) -> QuantizedTensor { + match tensor.scheme.level { + QuantLevel::Tensor => FlexQTensor::new( + crate::ops::gather_scatter::select::(tensor.tensor, dim, indices), + tensor.scheme, + tensor.scales, + ), + QuantLevel::Block(_) => { + let scheme = tensor.scheme; + let float_tensor = Flex::dequantize(tensor, FloatDType::F32); + let result = crate::ops::gather_scatter::select::(float_tensor, dim, indices); + Flex::quantize_dynamic(result, &scheme) + } + } + } + + fn q_slice(tensor: QuantizedTensor, slices: &[Slice]) -> QuantizedTensor { + block_safe_layout_op(tensor, |t| crate::ops::slice::slice(t, slices)) + } + + fn q_argmax( + tensor: QuantizedTensor, + dim: usize, + out_dtype: burn_std::IntDType, + ) -> IntTensor { + let result = crate::ops::reduce::argmax(tensor.tensor, dim); + if result.dtype() != DType::from(out_dtype) { + Flex::int_cast(result, out_dtype) + } else { + result + } + } + + fn q_argmin( + tensor: QuantizedTensor, + dim: usize, + out_dtype: burn_std::IntDType, + ) -> IntTensor { + let result = crate::ops::reduce::argmin(tensor.tensor, dim); + if result.dtype() != DType::from(out_dtype) { + Flex::int_cast(result, out_dtype) + } else { + result + } + } + + fn q_gather( + dim: usize, + tensor: QuantizedTensor, + indices: IntTensor, + ) -> QuantizedTensor { + match tensor.scheme.level { + QuantLevel::Tensor => FlexQTensor::new( + crate::ops::gather_scatter::gather::(tensor.tensor, dim, indices), + tensor.scheme, + tensor.scales, + ), + QuantLevel::Block(_) => { + let scheme = tensor.scheme; + let float_tensor = Flex::dequantize(tensor, FloatDType::F32); + let result = crate::ops::gather_scatter::gather::(float_tensor, dim, indices); + Flex::quantize_dynamic(result, &scheme) + } + } + } +} + +/// Apply a layout operation to a quantized tensor. +/// For block-quantized tensors, dequantizes and requantizes to preserve +/// correct scale-to-block mapping. +fn block_safe_layout_op( + qtensor: FlexQTensor, + op: impl FnOnce(FlexTensor) -> FlexTensor, +) -> FlexQTensor { + match qtensor.scheme.level { + QuantLevel::Tensor => FlexQTensor::new(op(qtensor.tensor), qtensor.scheme, qtensor.scales), + QuantLevel::Block(_) => { + let scheme = qtensor.scheme; + let float_tensor = Flex::dequantize(qtensor, FloatDType::F32); + let result = op(float_tensor); + Flex::quantize_dynamic(result, &scheme) + } + } +} + +/// Ensure scale is finite and nonzero to avoid division by zero or NaN propagation. +fn validated_scale(scale: f32) -> f32 { + if scale.is_normal() { + scale + } else { + f32::MIN_POSITIVE + } +} + +// Tests kept here exercise flex-specific behavior: quantization scheme +// roundtrips, per-block / dynamic quantization, block-quantized layout +// ops (transpose / select / flip dequantize), and f16/f64 dequantize +// dtype paths. Plain layout-preservation / select / slice / argmax / +// argmin / gather tests are covered generically in +// crates/burn-backend-tests/tests/tensor/float/quantization/ops/extended/ +// so they run on every backend. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::{TensorMetadata, quantization::QuantValue}; + + #[test] + fn test_quantize_dequantize_roundtrip() { + // Create a float tensor + let values = vec![0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [2, 3])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + // Compute scale: symmetric, so scale = 2 * max(|min|, |max|) / (b - a) + // max_abs = 5.0, range = 127 - (-127) = 254 + // scale = 2 * 5.0 / 254 = 0.03937008 + let scale: f32 = 2.0 * 5.0 / 254.0; + let scales_tensor = FlexTensor::from_data(TensorData::new(vec![scale], [1])); + + let qparams = QuantizationParametersPrimitive { + scales: scales_tensor, + }; + + // Quantize + let qtensor = Flex::quantize(tensor, &scheme, qparams); + assert_eq!(qtensor.tensor.shape().to_vec(), vec![2, 3]); + assert_eq!(qtensor.tensor.dtype(), DType::I8); + + // Check quantized values + let q_vals: &[i8] = qtensor.tensor.storage(); + // 0 / 0.03937 = 0, 1 / 0.03937 = 25.4 -> 25, etc. + assert_eq!(q_vals[0], 0); + assert_eq!(q_vals[1], 25); + assert_eq!(q_vals[5], 127); + + // Dequantize + let result = Flex::dequantize(qtensor, FloatDType::F32); + assert_eq!(result.shape().to_vec(), vec![2, 3]); + assert_eq!(result.dtype(), DType::F32); + + let result_vals: &[f32] = result.storage(); + // Values should be approximately equal (quantization introduces small errors) + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.05, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_quantize_dequantize_negative_values() { + let values = vec![-3.0f32, -1.5, 0.0, 1.5, 3.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [5])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let scale: f32 = 2.0 * 3.0 / 254.0; + let scales_tensor = FlexTensor::from_data(TensorData::new(vec![scale], [1])); + + let qparams = QuantizationParametersPrimitive { + scales: scales_tensor, + }; + + let qtensor = Flex::quantize(tensor, &scheme, qparams); + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.05, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_q_from_data_into_data_roundtrip() { + // Create quantized TensorData the standard way + let values = vec![0i8, 25, 51, 76, 102, 127]; + let scale = 0.03937008f32; + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let data = TensorData::quantized(values.clone(), [2, 3], scheme, &[scale]); + + // Load into FlexQTensor + let qtensor = Flex::q_from_data(data, &Default::default()); + assert_eq!(qtensor.tensor.shape().to_vec(), vec![2, 3]); + assert_eq!(qtensor.scales, vec![scale]); + + // Dequantize and check values + let float_tensor = Flex::dequantize(qtensor, FloatDType::F32); + let result: &[f32] = float_tensor.storage(); + assert!((result[0]).abs() < 0.01); // 0 * scale ~ 0 + assert!((result[5] - 5.0).abs() < 0.05); // 127 * scale ~ 5.0 + } + + #[test] + fn test_quantize_zero_tensor() { + let values = vec![0.0f32; 4]; + let tensor = FlexTensor::from_data(TensorData::new(values, [4])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + // Scale of 0 should be handled gracefully + let scales_tensor = FlexTensor::from_data(TensorData::new(vec![0.0f32], [1])); + let qparams = QuantizationParametersPrimitive { + scales: scales_tensor, + }; + + let qtensor = Flex::quantize(tensor, &scheme, qparams); + let q_vals: &[i8] = qtensor.tensor.storage(); + assert_eq!(q_vals, &[0, 0, 0, 0]); + } + + #[test] + fn test_quantize_dynamic_roundtrip() { + let values = vec![-3.0f32, -1.5, 0.0, 1.5, 3.0, 4.5]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [2, 3])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + assert_eq!(qtensor.tensor.shape().to_vec(), vec![2, 3]); + assert_eq!(qtensor.scales.len(), 1); + + // Scale should be 2 * 4.5 / 254 + let expected_scale: f32 = 2.0 * 4.5 / 254.0; + assert!( + (qtensor.scales[0] - expected_scale).abs() < 1e-6, + "scale={}, expected={}", + qtensor.scales[0], + expected_scale + ); + + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.1, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_per_block_quantize_dequantize() { + use burn_std::quantization::BlockSize; + + let values = vec![0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [8])); + + let block_size = BlockSize::new([4]); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Block(block_size)) + .with_store(QuantStore::Native); + + // Block 1: [0, 1, 2, 3] -> max_abs=3, scale = 6/254 + // Block 2: [4, 5, 6, 7] -> max_abs=7, scale = 14/254 + let scale_1: f32 = 2.0 * 3.0 / 254.0; + let scale_2: f32 = 2.0 * 7.0 / 254.0; + let scales_tensor = FlexTensor::from_data(TensorData::new(vec![scale_1, scale_2], [2])); + + let qparams = QuantizationParametersPrimitive { + scales: scales_tensor, + }; + + let qtensor = Flex::quantize(tensor, &scheme, qparams); + assert_eq!(qtensor.scales.len(), 2); + + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.1, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_quantize_dynamic_block() { + use burn_std::quantization::BlockSize; + + let values = vec![-2.0f32, -1.0, 0.0, 1.0, 4.0, 5.0, 6.0, 7.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [8])); + + let block_size = BlockSize::new([4]); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Block(block_size)) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + assert_eq!(qtensor.scales.len(), 2); + + // Block 1: [-2, -1, 0, 1] -> alpha=2, scale = 4/254 + // Block 2: [4, 5, 6, 7] -> alpha=7, scale = 14/254 + let expected_scale_1: f32 = 2.0 * 2.0 / 254.0; + let expected_scale_2: f32 = 2.0 * 7.0 / 254.0; + assert!((qtensor.scales[0] - expected_scale_1).abs() < 1e-6); + assert!((qtensor.scales[1] - expected_scale_2).abs() < 1e-6); + + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.1, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_quantize_dynamic_q8f() { + // Q8F uses asymmetric range [-128, 127] + let values = vec![-5.0f32, -2.5, 0.0, 2.5, 5.0, 7.5]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [6])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8F) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + + // Q8F range: [-128, 127], so range = 255 + // alpha = 7.5, scale = 2 * 7.5 / 255 + let expected_scale: f32 = 2.0 * 7.5 / 255.0; + assert!( + (qtensor.scales[0] - expected_scale).abs() < 1e-6, + "scale={}, expected={}", + qtensor.scales[0], + expected_scale + ); + + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!((orig - deq).abs() < 0.1, "orig={orig}, dequantized={deq}"); + } + } + + #[test] + fn test_block_quantized_transpose_dequantize() { + use burn_std::quantization::BlockSize; + + // 2x4 tensor, 2 blocks of 4 + let values = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let tensor = FlexTensor::from_data(TensorData::new(values, [2, 4])); + + let block_size = BlockSize::new([4]); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Block(block_size)) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + + // Transpose to [4, 2], then dequantize + let transposed = Flex::q_swap_dims(qtensor, 0, 1); + assert_eq!(transposed.tensor.shape().to_vec(), vec![4, 2]); + + let result = Flex::dequantize(transposed, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + + // Original [[1,2,3,4],[5,6,7,8]] transposed to [[1,5],[2,6],[3,7],[4,8]] + let expected = [1.0f32, 5.0, 2.0, 6.0, 3.0, 7.0, 4.0, 8.0]; + for (exp, deq) in expected.iter().zip(result_vals.iter()) { + assert!( + (exp - deq).abs() < 0.15, + "expected={exp}, dequantized={deq}" + ); + } + } + + #[test] + fn test_block_quantized_select() { + use burn_std::quantization::BlockSize; + + // 2x4 tensor, 2 blocks of 4 + let values = vec![1.0f32, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0]; + let tensor = FlexTensor::from_data(TensorData::new(values, [2, 4])); + + let block_size = BlockSize::new([4]); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Block(block_size)) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + + // Select row 1 -> [10, 20, 30, 40] + let indices = FlexTensor::from_data(TensorData::new(vec![1i64], [1])); + let selected = Flex::q_select(qtensor, 0, indices); + assert_eq!(selected.tensor.shape().to_vec(), vec![1, 4]); + + let result = Flex::dequantize(selected, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + let expected = [10.0f32, 20.0, 30.0, 40.0]; + for (exp, deq) in expected.iter().zip(result_vals.iter()) { + assert!((exp - deq).abs() < 0.5, "expected={exp}, dequantized={deq}"); + } + } + + #[test] + fn test_block_quantized_flip_dequantize() { + use burn_std::quantization::BlockSize; + + let values = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let tensor = FlexTensor::from_data(TensorData::new(values, [2, 4])); + + let block_size = BlockSize::new([4]); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Block(block_size)) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + + // Flip along axis 0: [[5,6,7,8],[1,2,3,4]] + let flipped = Flex::q_flip(qtensor, &[0]); + assert_eq!(flipped.tensor.shape().to_vec(), vec![2, 4]); + + let result = Flex::dequantize(flipped, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + let expected = [5.0f32, 6.0, 7.0, 8.0, 1.0, 2.0, 3.0, 4.0]; + for (exp, deq) in expected.iter().zip(result_vals.iter()) { + assert!( + (exp - deq).abs() < 0.15, + "expected={exp}, dequantized={deq}" + ); + } + } + + #[test] + fn test_quantize_dynamic_f64_tensor() { + use burn_backend::quantization::QuantValue; + + let values = vec![0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0]; + let tensor = FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous([6].into()), + DType::F64, + ); + assert_eq!(tensor.dtype(), DType::F64); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + assert_eq!(qtensor.tensor.dtype(), DType::I8); + + // Dequantize and verify round-trip accuracy + let result = Flex::dequantize(qtensor, FloatDType::F32); + let result_vals: &[f32] = result.storage(); + let expected = [0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0]; + for (exp, deq) in expected.iter().zip(result_vals.iter()) { + assert!( + (exp - deq).abs() < 0.15, + "expected={exp}, dequantized={deq}" + ); + } + } + + #[test] + fn test_dequantize_f64() { + let values = vec![0.0f32, 1.0, 2.0, 3.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [4])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + let result = Flex::dequantize(qtensor, FloatDType::F64); + assert_eq!(result.dtype(), DType::F64); + let result_vals: &[f64] = result.storage(); + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!( + (*orig as f64 - deq).abs() < 0.05, + "orig={orig}, dequantized={deq}" + ); + } + } + + #[test] + fn test_dequantize_f16() { + let values = vec![0.0f32, 1.0, 2.0, 3.0]; + let tensor = FlexTensor::from_data(TensorData::new(values.clone(), [4])); + + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + + let qtensor = Flex::quantize_dynamic(tensor, &scheme); + let result = Flex::dequantize(qtensor, FloatDType::F16); + assert_eq!(result.dtype(), DType::F16); + let result_vals: &[f16] = result.storage(); + for (orig, deq) in values.iter().zip(result_vals.iter()) { + assert!( + (*orig - f32::from(*deq)).abs() < 0.05, + "orig={orig}, dequantized={deq}" + ); + } + } +} diff --git a/crates/burn-flex/src/ops/reduce.rs b/crates/burn-flex/src/ops/reduce.rs new file mode 100644 index 0000000..93b682f --- /dev/null +++ b/crates/burn-flex/src/ops/reduce.rs @@ -0,0 +1,2559 @@ +//! Reduction operations for FlexTensor. +//! +//! Optimized with: +//! - Strided iteration (no copy for non-contiguous tensors) +//! - Portable SIMD via macerator (NEON, AVX2, SIMD128, scalar fallback) +//! - Rayon parallelism for large tensors + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::strided_index::StridedIter; +use crate::{FlexTensor, Layout}; + +use super::{INDEX_DTYPE, float_storage_as_f32}; + +/// Assert that a dimension size fits in `isize`, which is required for index-producing +/// operations (argmax, argmin, *_with_indices) that store dimension indices as `isize`. +#[inline(always)] +fn assert_dim_fits_isize(dim_size: usize, dim: usize) { + assert!( + dim_size <= isize::MAX as usize, + "dimension {dim} has size {dim_size} which exceeds isize::MAX" + ); +} + +#[cfg(feature = "simd")] +use crate::simd::kernels; + +#[cfg(feature = "simd")] +use crate::simd::aligned; + +#[cfg(feature = "rayon")] +use rayon::prelude::*; + +/// Truncate an i64 to a smaller Pod type, keeping the low-order bytes. +/// Endian-safe: works correctly on both little-endian and big-endian targets. +fn truncate_i64_to_pod(value: i64) -> E { + let bytes = value.to_ne_bytes(); + let size = core::mem::size_of::(); + debug_assert!(size <= core::mem::size_of::()); + let offset = if cfg!(target_endian = "big") { + core::mem::size_of::() - size + } else { + 0 + }; + bytemuck::pod_read_unaligned(&bytes[offset..offset + size]) +} + +// ============================================================================ +// Sum (all elements) +// ============================================================================ + +/// Sum all elements in a tensor, returning a scalar tensor. +pub fn sum(tensor: FlexTensor) -> FlexTensor { + match tensor.dtype() { + DType::F32 => sum_f32(&tensor), + DType::F64 => sum_impl::(&tensor), + DType::F16 => reduce_scalar_half(&tensor, |a, b| a + b, 0.0, f16::to_f32, f16::from_f32), + DType::BF16 => reduce_scalar_half(&tensor, |a, b| a + b, 0.0, bf16::to_f32, bf16::from_f32), + DType::I8 => sum_impl_widening::(&tensor), + DType::I16 => sum_impl_widening::(&tensor), + DType::I32 => sum_impl_widening::(&tensor), + DType::I64 => sum_impl::(&tensor), + DType::U8 => sum_impl_widening::(&tensor), + DType::U16 => sum_impl_widening::(&tensor), + DType::U32 => sum_impl_widening::(&tensor), + DType::U64 => sum_impl::(&tensor), + _ => panic!("sum: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Optimized f32 sum with SIMD and parallelism. +fn sum_f32(tensor: &FlexTensor) -> FlexTensor { + let result = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[f32] = tensor.storage(); + let slice = &data[start..end]; + sum_f32_contiguous(slice) + } + None => { + // Non-contiguous: check if we can sum the buffer directly. + // For transposed tensors that use all elements (no slicing), + // the sum is the same regardless of element order. + let data: &[f32] = tensor.storage(); + let elem_count = tensor.layout().num_elements(); + + if data.len() == elem_count { + // Tensor uses entire buffer - sum directly (order doesn't matter for sum) + sum_f32_contiguous(data) + } else { + // Sliced or partial view - must use strided iteration + StridedIter::new(tensor.layout()).map(|idx| data[idx]).sum() + } + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new(bytes, Layout::contiguous(Shape::from(vec![1])), DType::F32) +} + +/// SIMD + parallel sum for contiguous f32 slice. +/// +/// The parallel threshold is higher than the general `PARALLEL_THRESHOLD` because +/// sum is memory-bound and L2-resident data (< ~16 MiB / 4M f32 elements on +/// Apple M-series) doesn't benefit from rayon's task dispatch overhead. +#[inline] +fn sum_f32_contiguous(data: &[f32]) -> f32 { + #[cfg(feature = "rayon")] + if data.len() >= 4 * 1024 * 1024 { + return sum_f32_parallel(data); + } + + #[cfg(feature = "simd")] + { + kernels::sum_f32(data) + } + + #[cfg(not(feature = "simd"))] + { + data.iter().copied().sum() + } +} + +/// Parallel sum using rayon with SIMD per chunk. +#[cfg(feature = "rayon")] +#[inline] +fn sum_f32_parallel(data: &[f32]) -> f32 { + const CHUNK_SIZE: usize = 64 * 1024; // 64K elements per chunk + + data.par_chunks(CHUNK_SIZE) + .map(|chunk| { + #[cfg(feature = "simd")] + { + kernels::sum_f32(chunk) + } + #[cfg(not(feature = "simd"))] + { + chunk.iter().copied().sum::() + } + }) + .sum() +} + +fn sum_impl( + tensor: &FlexTensor, +) -> FlexTensor { + let result: E = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end].iter().copied().sum() + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()).map(|idx| data[idx]).sum() + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(vec![1])), + tensor.dtype(), + ) +} + +/// Widening scalar reduction for small integer types: accumulate in i64 to avoid overflow. +macro_rules! widening_scalar_reduce { + ($name:ident, $fold:expr, $init:expr) => { + fn $name(tensor: &FlexTensor) -> FlexTensor + where + E: Element + bytemuck::Pod + Default, + i64: From, + { + let total: i64 = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end] + .iter() + .fold($init, |acc, x| ($fold)(acc, i64::from(*x))) + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()) + .fold($init, |acc, idx| ($fold)(acc, i64::from(data[idx]))) + } + }; + // Truncate back to target type (wrapping, matches PyTorch) + let result: E = truncate_i64_to_pod(total); + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(vec![1])), + tensor.dtype(), + ) + } + }; +} + +widening_scalar_reduce!( + sum_impl_widening, + |acc: i64, x: i64| acc.wrapping_add(x), + 0i64 +); +widening_scalar_reduce!( + prod_impl_widening, + |acc: i64, x: i64| acc.wrapping_mul(x), + 1i64 +); + +/// Scalar reduction for half-precision types, accumulating in f32. +fn reduce_scalar_half( + tensor: &FlexTensor, + fold: fn(f32, f32) -> f32, + init: f32, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor +where + E: Element + bytemuck::Pod, +{ + let result: f32 = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end] + .iter() + .fold(init, |acc, x| fold(acc, to_f32(*x))) + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()).fold(init, |acc, idx| fold(acc, to_f32(data[idx]))) + } + }; + + let bytes = Bytes::from_elems(vec![from_f32(result)]); + FlexTensor::new(bytes, Layout::contiguous(Shape::from(vec![1])), E::dtype()) +} + +// ============================================================================ +// Sum along dimension +// ============================================================================ + +/// Sum along a dimension, keeping the dimension with size 1. +pub fn sum_dim(tensor: FlexTensor, dim: usize) -> FlexTensor { + match tensor.dtype() { + DType::F32 => reduce_dim_f32(&tensor, dim, ReduceOp::Sum), + DType::F64 => reduce_dim_impl::(&tensor, dim, 0.0, |acc, x| acc + x), + DType::F16 => reduce_dim_half( + &tensor, + dim, + 0.0, + |acc, x| acc + x, + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => reduce_dim_half( + &tensor, + dim, + 0.0, + |acc, x| acc + x, + bf16::to_f32, + bf16::from_f32, + ), + DType::I8 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::I16 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::I32 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::I64 => reduce_dim_impl::(&tensor, dim, 0, |acc, x| acc + x), + DType::U8 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::U16 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::U32 => reduce_dim_widening::(&tensor, dim, 0, |acc, x| acc.wrapping_add(x)), + DType::U64 => reduce_dim_impl::(&tensor, dim, 0, |acc, x| acc + x), + _ => panic!("sum_dim: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Mean along a dimension, keeping the dimension with size 1. +pub fn mean_dim(tensor: FlexTensor, dim: usize) -> FlexTensor { + let dim_size = tensor.layout().shape()[dim]; + assert!( + dim_size > 0, + "mean_dim: cannot take mean of empty dimension" + ); + let dtype = tensor.dtype(); + + // Half-precision types fuse sum+divide in f32 to avoid overflow when the + // intermediate sum exceeds f16::MAX, so they don't go through sum_dim. + match dtype { + DType::F16 => return mean_dim_half::(&tensor, dim), + DType::BF16 => return mean_dim_half::(&tensor, dim), + _ => {} + } + + let sum_result = sum_dim(tensor, dim); + + // Divide by dimension size + match dtype { + DType::F32 => scalar_div::(sum_result, dim_size as f32), + DType::F64 => scalar_div::(sum_result, dim_size as f64), + DType::I8 => { + let divisor = dim_size as i32; + let mut tensor = sum_result; + let data: &mut [i8] = tensor.storage_mut(); + for x in data.iter_mut() { + *x = ((*x as i32) / divisor) as i8; + } + tensor + } + DType::I16 => { + let divisor = dim_size as i32; + let mut tensor = sum_result; + let data: &mut [i16] = tensor.storage_mut(); + for x in data.iter_mut() { + *x = ((*x as i32) / divisor) as i16; + } + tensor + } + DType::I32 => scalar_div::(sum_result, dim_size as i32), + DType::I64 => scalar_div::(sum_result, dim_size as i64), + DType::U8 => { + let divisor = dim_size as u32; + let mut tensor = sum_result; + let data: &mut [u8] = tensor.storage_mut(); + for x in data.iter_mut() { + *x = ((*x as u32) / divisor) as u8; + } + tensor + } + DType::U16 => { + let divisor = dim_size as u32; + let mut tensor = sum_result; + let data: &mut [u16] = tensor.storage_mut(); + for x in data.iter_mut() { + *x = ((*x as u32) / divisor) as u16; + } + tensor + } + DType::U32 => scalar_div::(sum_result, dim_size as u32), + DType::U64 => scalar_div::(sum_result, dim_size as u64), + _ => panic!("mean_dim: unsupported dtype {:?}", dtype), + } +} + +/// Product of all elements in a tensor, returning a scalar tensor. +pub fn prod(tensor: FlexTensor) -> FlexTensor { + match tensor.dtype() { + DType::F32 => prod_impl::(&tensor), + DType::F64 => prod_impl::(&tensor), + DType::F16 => reduce_scalar_half(&tensor, |a, b| a * b, 1.0, f16::to_f32, f16::from_f32), + DType::BF16 => reduce_scalar_half(&tensor, |a, b| a * b, 1.0, bf16::to_f32, bf16::from_f32), + DType::I8 => prod_impl_widening::(&tensor), + DType::I16 => prod_impl_widening::(&tensor), + DType::I32 => prod_impl_widening::(&tensor), + DType::I64 => prod_impl::(&tensor), + DType::U8 => prod_impl_widening::(&tensor), + DType::U16 => prod_impl_widening::(&tensor), + DType::U32 => prod_impl_widening::(&tensor), + DType::U64 => prod_impl::(&tensor), + _ => panic!("prod: unsupported dtype {:?}", tensor.dtype()), + } +} + +fn prod_impl( + tensor: &FlexTensor, +) -> FlexTensor { + let result: E = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end].iter().copied().product() + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()) + .map(|idx| data[idx]) + .product() + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(vec![1])), + tensor.dtype(), + ) +} + +/// Product along a dimension, keeping the dimension with size 1. +pub fn prod_dim(tensor: FlexTensor, dim: usize) -> FlexTensor { + match tensor.dtype() { + DType::F32 => reduce_dim_f32(&tensor, dim, ReduceOp::Prod), + DType::F64 => reduce_dim_impl::(&tensor, dim, 1.0, |acc, x| acc * x), + DType::F16 => reduce_dim_half( + &tensor, + dim, + 1.0, + |acc, x| acc * x, + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => reduce_dim_half( + &tensor, + dim, + 1.0, + |acc, x| acc * x, + bf16::to_f32, + bf16::from_f32, + ), + DType::I8 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::I16 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::I32 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::I64 => reduce_dim_impl::(&tensor, dim, 1, |acc, x| acc * x), + DType::U8 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::U16 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::U32 => reduce_dim_widening::(&tensor, dim, 1, |acc, x| acc.wrapping_mul(x)), + DType::U64 => reduce_dim_impl::(&tensor, dim, 1, |acc, x| acc * x), + _ => panic!("prod_dim: unsupported dtype {:?}", tensor.dtype()), + } +} + +// ============================================================================ +// Max / Min (all elements) +// ============================================================================ + +/// Max of all elements, returning a scalar tensor of shape \[1\]. +pub fn max(tensor: FlexTensor) -> FlexTensor { + match tensor.dtype() { + DType::F32 => max_f32_reduce(&tensor), + DType::F64 => max_impl::(&tensor), + DType::F16 => reduce_scalar_half( + &tensor, + f32::max, + f32::NEG_INFINITY, + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => reduce_scalar_half( + &tensor, + f32::max, + f32::NEG_INFINITY, + bf16::to_f32, + bf16::from_f32, + ), + DType::I8 => max_impl::(&tensor), + DType::I16 => max_impl::(&tensor), + DType::I32 => max_impl::(&tensor), + DType::I64 => max_impl::(&tensor), + DType::U8 => max_impl::(&tensor), + DType::U16 => max_impl::(&tensor), + DType::U32 => max_impl::(&tensor), + DType::U64 => max_impl::(&tensor), + _ => panic!("max: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Min of all elements, returning a scalar tensor of shape \[1\]. +pub fn min(tensor: FlexTensor) -> FlexTensor { + match tensor.dtype() { + DType::F32 => min_f32_reduce(&tensor), + DType::F64 => min_impl::(&tensor), + DType::F16 => { + reduce_scalar_half(&tensor, f32::min, f32::INFINITY, f16::to_f32, f16::from_f32) + } + DType::BF16 => reduce_scalar_half( + &tensor, + f32::min, + f32::INFINITY, + bf16::to_f32, + bf16::from_f32, + ), + DType::I8 => min_impl::(&tensor), + DType::I16 => min_impl::(&tensor), + DType::I32 => min_impl::(&tensor), + DType::I64 => min_impl::(&tensor), + DType::U8 => min_impl::(&tensor), + DType::U16 => min_impl::(&tensor), + DType::U32 => min_impl::(&tensor), + DType::U64 => min_impl::(&tensor), + _ => panic!("min: unsupported dtype {:?}", tensor.dtype()), + } +} + +fn max_f32_reduce(tensor: &FlexTensor) -> FlexTensor { + let result = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[f32] = tensor.storage(); + max_f32_contiguous(&data[start..end]) + } + None => { + let data: &[f32] = tensor.storage(); + let elem_count = tensor.layout().num_elements(); + if data.len() == elem_count { + // Non-contiguous but uses all elements (e.g., transposed) + max_f32_contiguous(data) + } else { + StridedIter::new(tensor.layout()) + .map(|idx| data[idx]) + .reduce(|a, b| if a >= b { a } else { b }) + .expect("max: tensor must not be empty") + } + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new(bytes, Layout::contiguous(Shape::from(vec![1])), DType::F32) +} + +#[inline] +fn max_f32_contiguous(data: &[f32]) -> f32 { + #[cfg(feature = "simd")] + { + kernels::max_f32(data) + } + + #[cfg(not(feature = "simd"))] + { + data.iter() + .copied() + .reduce(|a, b| if a >= b { a } else { b }) + .expect("max: tensor must not be empty") + } +} + +fn min_f32_reduce(tensor: &FlexTensor) -> FlexTensor { + let result = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[f32] = tensor.storage(); + min_f32_contiguous(&data[start..end]) + } + None => { + let data: &[f32] = tensor.storage(); + let elem_count = tensor.layout().num_elements(); + if data.len() == elem_count { + min_f32_contiguous(data) + } else { + StridedIter::new(tensor.layout()) + .map(|idx| data[idx]) + .reduce(|a, b| if a <= b { a } else { b }) + .expect("min: tensor must not be empty") + } + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new(bytes, Layout::contiguous(Shape::from(vec![1])), DType::F32) +} + +#[inline] +fn min_f32_contiguous(data: &[f32]) -> f32 { + #[cfg(feature = "simd")] + { + kernels::min_f32(data) + } + + #[cfg(not(feature = "simd"))] + { + data.iter() + .copied() + .reduce(|a, b| if a <= b { a } else { b }) + .expect("min: tensor must not be empty") + } +} + +fn max_impl(tensor: &FlexTensor) -> FlexTensor { + let result: E = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end] + .iter() + .copied() + .reduce(|a, b| if a >= b { a } else { b }) + .expect("max: tensor must not be empty") + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()) + .map(|idx| data[idx]) + .reduce(|a, b| if a >= b { a } else { b }) + .expect("max: tensor must not be empty") + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(vec![1])), + tensor.dtype(), + ) +} + +fn min_impl(tensor: &FlexTensor) -> FlexTensor { + let result: E = match tensor.layout().contiguous_offsets() { + Some((start, end)) => { + let data: &[E] = tensor.storage(); + data[start..end] + .iter() + .copied() + .reduce(|a, b| if a <= b { a } else { b }) + .expect("min: tensor must not be empty") + } + None => { + let data: &[E] = tensor.storage(); + StridedIter::new(tensor.layout()) + .map(|idx| data[idx]) + .reduce(|a, b| if a <= b { a } else { b }) + .expect("min: tensor must not be empty") + } + }; + + let bytes = Bytes::from_elems(vec![result]); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(vec![1])), + tensor.dtype(), + ) +} + +// ============================================================================ +// Argmax / Argmin +// ============================================================================ + +/// Argmax along a dimension, returning indices as isize (INDEX_DTYPE). +pub fn argmax(tensor: FlexTensor, dim: usize) -> FlexTensor { + assert_dim_fits_isize(tensor.layout().shape()[dim], dim); + // f32 last-dim fast path: 2-pass SIMD for large rows, 1-pass scalar for small rows + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_indices_f32_last_simd(&tensor, dim, kernels::max_f32); + } + return extremum_indices_f32_last_scalar(&tensor, dim, |a, b| a > b); + } + match tensor.dtype() { + DType::F32 => { + extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a > b) + }) + .1 + } + DType::F64 => { + extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a > b) + }) + .1 + } + DType::F16 => { + extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + f16::to_f32, + f16::from_f32, + ) + .1 + } + DType::BF16 => { + extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + bf16::to_f32, + bf16::from_f32, + ) + .1 + } + DType::I8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b).1, + DType::I16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b).1, + DType::I32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b).1, + DType::I64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b).1, + _ => panic!("argmax: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Argmin along a dimension, returning indices as isize (INDEX_DTYPE). +pub fn argmin(tensor: FlexTensor, dim: usize) -> FlexTensor { + assert_dim_fits_isize(tensor.layout().shape()[dim], dim); + // f32 last-dim fast path: 2-pass SIMD for large rows, 1-pass scalar for small rows + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_indices_f32_last_simd(&tensor, dim, kernels::min_f32); + } + return extremum_indices_f32_last_scalar(&tensor, dim, |a, b| a < b); + } + match tensor.dtype() { + DType::F32 => { + extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a < b) + }) + .1 + } + DType::F64 => { + extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a < b) + }) + .1 + } + DType::F16 => { + extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + f16::to_f32, + f16::from_f32, + ) + .1 + } + DType::BF16 => { + extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + bf16::to_f32, + bf16::from_f32, + ) + .1 + } + DType::I8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b).1, + DType::I16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b).1, + DType::I32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b).1, + DType::I64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b).1, + _ => panic!("argmin: unsupported dtype {:?}", tensor.dtype()), + } +} + +// ============================================================================ +// Dimension reduction helpers +// ============================================================================ + +#[derive(Clone, Copy)] +enum ReduceOp { + Sum, + Prod, +} + +/// Optimized f32 dimension reduction with SIMD. +fn reduce_dim_f32(tensor: &FlexTensor, dim: usize, op: ReduceOp) -> FlexTensor { + let ndims = tensor.layout().shape().num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + // Copy to contiguous only when the flattened stride assumption breaks: + // non-contiguous tensor with 2+ outer dims or 2+ inner dims. + let outer_dims = dim; + let inner_dims = ndims - dim - 1; + let needs_copy = !tensor.is_contiguous() && (outer_dims > 1 || inner_dims > 1); + let tensor = if needs_copy { + tensor.to_contiguous() + } else { + tensor.clone() + }; + let shape = tensor.layout().shape(); + let strides = tensor.layout().strides(); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let out_size: usize = out_shape.iter().product(); + + // Empty output: any zero-sized non-reduced dim means the result has no + // elements. Return early so the SIMD kernels and fallback loops never + // see `outer_size == 0` or `inner_size == 0`. + if out_size == 0 { + return FlexTensor::new( + Bytes::from_elems(Vec::::new()), + Layout::contiguous(Shape::from(out_shape)), + DType::F32, + ); + } + + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let data: &[f32] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + let dim_stride = strides[dim]; + + let (init, reduce_fn): (f32, fn(f32, f32) -> f32) = match op { + ReduceOp::Sum => (0.0, |a, b| a + b), + ReduceOp::Prod => (1.0, |a, b| a * b), + }; + + // Check for negative strides (from flip operations) - fall back to general case + let has_negative_strides = strides.iter().any(|&s| s < 0); + + // Check if inner dimension is contiguous (stride = 1) and no negative strides + let inner_contiguous = !has_negative_strides && (dim + 1 >= ndims || strides[ndims - 1] == 1); + + let result: Vec = if inner_contiguous && dim == ndims - 1 && dim_stride == 1 { + // Reducing last dimension with contiguous data: use SIMD. + // `reduce_last_dim_f32` reads each row as `&data[start..start + dim_size]`, + // which only matches the logical row when the reduce dim itself has + // stride 1. Transposed views (e.g. shape [3,2] strides [1,3]) would + // otherwise read contiguous storage and return wrong sums. + reduce_last_dim_f32(data, start_offset, outer_size, dim_size, strides, dim, op) + } else if dim == 0 && inner_contiguous && matches!(op, ReduceOp::Sum) { + // First-dim reduction with contiguous inner: use cache-friendly accumulation + reduce_first_dim_f32(data, start_offset, dim_size, inner_size, dim_stride) + } else if dim > 0 && dim < ndims - 1 && inner_contiguous && matches!(op, ReduceOp::Sum) { + // Middle-dim reduction (e.g., [B, M, K] reducing dim=1): cache-friendly accumulation + let outer_stride = strides[dim - 1]; + reduce_middle_dim_f32( + data, + start_offset, + outer_size, + dim_size, + inner_size, + outer_stride, + dim_stride, + ) + } else if dim_stride == 1 && matches!(op, ReduceOp::Sum) && outer_size == 1 { + // Reduction dimension is contiguous, no outer batch (e.g., transposed 2D reducing dim=0) + // Storage is [inner_size rows of dim_size elements each] - use sum_rows_f32 + #[cfg(feature = "simd")] + { + let mut result = vec![0.0f32; inner_size]; + kernels::sum_rows_f32( + &data[start_offset..], + &mut result, + inner_size, // number of rows (output positions) + dim_size, // elements per row (to sum) + ); + result + } + #[cfg(not(feature = "simd"))] + { + let inner_stride: isize = if dim + 1 < ndims { strides[dim + 1] } else { 1 }; + let mut result = Vec::with_capacity(out_size); + for inner in 0..inner_size { + let base = (start_offset as isize + inner as isize * inner_stride) as usize; + let slice = &data[base..base + dim_size]; + result.push(slice.iter().copied().sum()); + } + result + } + } else if dim_stride == 1 && matches!(op, ReduceOp::Sum) { + // Reduction dimension is contiguous but with outer batches + let outer_stride: isize = if dim > 0 { strides[dim - 1] } else { 0 }; + let inner_stride: isize = if dim + 1 < ndims { strides[dim + 1] } else { 1 }; + + let mut result = Vec::with_capacity(out_size); + for outer in 0..outer_size { + for inner in 0..inner_size { + let base = (start_offset as isize + + outer as isize * outer_stride + + inner as isize * inner_stride) as usize; + let slice = &data[base..base + dim_size]; + #[cfg(feature = "simd")] + let acc = kernels::sum_f32(slice); + #[cfg(not(feature = "simd"))] + let acc = slice.iter().copied().sum(); + result.push(acc); + } + } + result + } else if tensor.is_contiguous() { + // Contiguous: use flat index arithmetic (safe for any ndims). + // outer_size and inner_size are guaranteed positive by the out_size == 0 + // early return above. + let mut result = Vec::with_capacity(out_size); + for outer in 0..outer_size { + for inner in 0..inner_size { + let mut acc = init; + for d in 0..dim_size { + let idx = start_offset + outer * dim_size * inner_size + d * inner_size + inner; + acc = reduce_fn(acc, data[idx]); + } + result.push(acc); + } + } + result + } else { + // Non-contiguous with at most 1 outer + 1 inner dim (e.g., flipped 2D) + let outer_stride: isize = if dim > 0 { strides[dim - 1] } else { 0 }; + let inner_stride: isize = if dim + 1 < ndims { strides[dim + 1] } else { 1 }; + + let mut result = Vec::with_capacity(out_size); + for outer in 0..outer_size { + for inner in 0..inner_size { + let base = start_offset as isize + + outer as isize * outer_stride + + inner as isize * inner_stride; + let mut acc = init; + for d in 0..dim_size { + let idx = (base + d as isize * dim_stride) as usize; + acc = reduce_fn(acc, data[idx]); + } + result.push(acc); + } + } + result + }; + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(out_shape)), + DType::F32, + ) +} + +/// Reduce middle dimension (e.g., [B, M, K] reducing dim=1) with cache-friendly iteration. +/// For each batch, iterate over rows (dim to reduce) sequentially and accumulate into columns. +#[inline] +fn reduce_middle_dim_f32( + data: &[f32], + start_offset: usize, + outer_size: usize, // batch size + dim_size: usize, // rows to sum + inner_size: usize, // columns (output per batch) + outer_stride: isize, + dim_stride: isize, +) -> Vec { + let out_size = outer_size * inner_size; + + #[cfg(feature = "simd")] + { + // Use aligned allocation for optimal SIMD scatter-add + let mut result = aligned::alloc_aligned_zeroed::(out_size); + kernels::scatter_add_batched( + &data[start_offset..], + &mut result, + outer_size, + dim_size, + inner_size, + outer_stride as usize, + dim_stride as usize, + ); + aligned::to_vec(result) + } + + #[cfg(not(feature = "simd"))] + { + let mut result = vec![0.0f32; out_size]; + let start = start_offset as isize; + for batch in 0..outer_size { + let batch_start = (start + batch as isize * outer_stride) as usize; + let out_batch_start = batch * inner_size; + + for row in 0..dim_size { + let row_start = (batch_start as isize + row as isize * dim_stride) as usize; + for c in 0..inner_size { + result[out_batch_start + c] += data[row_start + c]; + } + } + } + result + } +} + +/// Reduce first dimension with cache-friendly row iteration. +/// Instead of iterating per-output (col) and gathering from rows (cache-unfriendly), +/// iterate over rows (sequential access) and scatter-accumulate into outputs. +#[inline] +fn reduce_first_dim_f32( + data: &[f32], + start_offset: usize, + dim_size: usize, // number of rows to sum + inner_size: usize, // number of columns (output positions) + dim_stride: isize, // stride between rows +) -> Vec { + #[cfg(feature = "simd")] + { + // Use aligned allocation for optimal SIMD scatter-add + let mut result = aligned::alloc_aligned_zeroed::(inner_size); + kernels::scatter_add_f32( + &data[start_offset..], + &mut result, + dim_size, + inner_size, + dim_stride as usize, + ); + aligned::to_vec(result) + } + + #[cfg(not(feature = "simd"))] + { + let mut result = vec![0.0f32; inner_size]; + let start = start_offset as isize; + for row in 0..dim_size { + let row_start = (start + row as isize * dim_stride) as usize; + for c in 0..inner_size { + result[c] += data[row_start + c]; + } + } + result + } +} + +/// Reduce last dimension with SIMD. +/// +/// For contiguous Sum: batches all rows in a single kernel call using +/// 4-accumulator SIMD to hide add latency. +#[inline] +fn reduce_last_dim_f32( + data: &[f32], + start_offset: usize, + outer_size: usize, + dim_size: usize, + strides: &[isize], + dim: usize, + op: ReduceOp, +) -> Vec { + let outer_stride: isize = if dim > 0 { + strides[dim - 1] + } else { + dim_size as isize + }; + + // `outer_size > 0` is guaranteed by the out_size == 0 early return in + // `reduce_dim_f32`. + let rows = outer_size; + + // Contiguous Sum: batch all rows in one kernel call to avoid per-row overhead. + #[cfg(feature = "simd")] + if matches!(op, ReduceOp::Sum) && outer_stride == dim_size as isize { + let mut result = vec![0.0f32; rows]; + kernels::sum_rows_f32(&data[start_offset..], &mut result, rows, dim_size); + return result; + } + + // Fallback: non-contiguous strides or Prod. + let mut result = Vec::with_capacity(rows); + for outer in 0..rows { + let row_start = (start_offset as isize + outer as isize * outer_stride) as usize; + let row = &data[row_start..row_start + dim_size]; + + let val = match op { + ReduceOp::Sum => { + #[cfg(feature = "simd")] + { + kernels::sum_f32(row) + } + #[cfg(not(feature = "simd"))] + { + row.iter().copied().sum() + } + } + ReduceOp::Prod => row.iter().copied().product(), + }; + result.push(val); + } + result +} + +/// Generic dimension reduction implementation. +fn reduce_dim_impl(tensor: &FlexTensor, dim: usize, init: E, reduce_fn: F) -> FlexTensor +where + E: Element + bytemuck::Pod + Copy, + F: Fn(E, E) -> E, +{ + let ndims = tensor.layout().shape().num_dims(); + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + // Copy to contiguous only when the flattened stride assumption breaks: + // non-contiguous tensor with 2+ outer dims or 2+ inner dims. + let outer_dims = dim; + let inner_dims = ndims - dim - 1; + let needs_copy = !tensor.is_contiguous() && (outer_dims > 1 || inner_dims > 1); + let tensor = if needs_copy { + tensor.to_contiguous() + } else { + tensor.clone() + }; + let shape = tensor.layout().shape(); + let strides = tensor.layout().strides(); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let out_size: usize = out_shape.iter().product(); + + // Empty output: any zero-sized non-reduced dim means the result has no + // elements. Returning early keeps the loops below from producing phantom + // outputs when `outer_size == 0` or `inner_size == 0`. + if out_size == 0 { + return FlexTensor::new( + Bytes::from_elems(Vec::::new()), + Layout::contiguous(Shape::from(out_shape)), + tensor.dtype(), + ); + } + + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let mut result: Vec = Vec::with_capacity(out_size); + + if tensor.is_contiguous() { + // Contiguous: use flat index arithmetic (safe for any ndims). + // outer_size and inner_size are positive by the early return above. + for outer in 0..outer_size { + for inner in 0..inner_size { + let mut acc = init; + for d in 0..dim_size { + let idx = start_offset + outer * dim_size * inner_size + d * inner_size + inner; + acc = reduce_fn(acc, data[idx]); + } + result.push(acc); + } + } + } else { + // Non-contiguous with at most 1 outer + 1 inner dim + let dim_stride = strides[dim]; + let outer_stride: isize = if dim > 0 { strides[dim - 1] } else { 0 }; + let inner_stride: isize = if dim + 1 < ndims { strides[dim + 1] } else { 1 }; + + for outer in 0..outer_size { + for inner in 0..inner_size { + let base = start_offset as isize + + outer as isize * outer_stride + + inner as isize * inner_stride; + let mut acc = init; + for d in 0..dim_size { + let idx = (base + d as isize * dim_stride) as usize; + acc = reduce_fn(acc, data[idx]); + } + result.push(acc); + } + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(out_shape)), + tensor.dtype(), + ) +} + +/// Widening dimension reduction for small integer types: accumulate in i64 to avoid overflow. +fn reduce_dim_widening(tensor: &FlexTensor, dim: usize, init: i64, reduce_fn: F) -> FlexTensor +where + E: Element + bytemuck::Pod, + i64: From, + F: Fn(i64, i64) -> i64, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let out_size: usize = out_shape.iter().product(); + + // Empty output: skip the loop entirely for zero-sized non-reduced dims. + if out_size == 0 { + return FlexTensor::new( + Bytes::from_elems(Vec::::new()), + Layout::contiguous(Shape::from(out_shape)), + tensor.dtype(), + ); + } + + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let mut result: Vec = Vec::with_capacity(out_size); + + for outer in 0..outer_size { + for inner in 0..inner_size { + let mut acc = init; + for d in 0..dim_size { + let idx = start_offset + outer * dim_size * inner_size + d * inner_size + inner; + acc = reduce_fn(acc, i64::from(data[idx])); + } + // Truncate back to target type (wrapping, matches PyTorch) + let val: E = truncate_i64_to_pod(acc); + result.push(val); + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(out_shape)), + tensor.dtype(), + ) +} + +/// Half-precision dimension reduction with f32 accumulation. +/// +/// Works for both f16 and bf16 via the `to_f32`/`from_f32` closures. +fn reduce_dim_half( + tensor: &FlexTensor, + dim: usize, + init: f32, + reduce_fn: F, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor +where + E: Element + bytemuck::Pod, + F: Fn(f32, f32) -> f32, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let out_size: usize = out_shape.iter().product(); + + // Empty output: skip the loop entirely for zero-sized non-reduced dims. + if out_size == 0 { + return FlexTensor::new( + Bytes::from_elems(Vec::::new()), + Layout::contiguous(Shape::from(out_shape)), + E::dtype(), + ); + } + + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let mut result: Vec = Vec::with_capacity(out_size); + + for outer in 0..outer_size { + for inner in 0..inner_size { + let mut acc = init; + for d in 0..dim_size { + let idx = start_offset + outer * dim_size * inner_size + d * inner_size + inner; + acc = reduce_fn(acc, to_f32(data[idx])); + } + result.push(from_f32(acc)); + } + } + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(out_shape)), + E::dtype(), + ) +} + +/// Sum along `dim` for an already-contiguous row-major f32 slice, producing +/// one output per (outer, inner) position. +/// +/// The caller owns the contiguity guarantee: `data.len()` must equal +/// `outer_size * dim_size * inner_size` in logical row-major order. Dispatches +/// to the same SIMD kernels `reduce_dim_f32` uses, but without the stride +/// bookkeeping. +fn sum_dim_contiguous_f32( + data: &[f32], + outer_size: usize, + dim_size: usize, + inner_size: usize, +) -> Vec { + // Empty output: if any non-reduced dim has size 0, the result is empty. + // Returning early avoids indexing past an empty `data` slice in the SIMD + // kernels and keeps the output length in sync with the caller's out_shape. + if outer_size == 0 || inner_size == 0 { + return Vec::new(); + } + + // Last-dim: each output is the sum of a contiguous run of dim_size elements. + if inner_size == 1 { + let rows = outer_size; + #[cfg(feature = "simd")] + { + let mut result = vec![0.0f32; rows]; + kernels::sum_rows_f32(data, &mut result, rows, dim_size); + return result; + } + #[cfg(not(feature = "simd"))] + { + return (0..rows) + .map(|i| data[i * dim_size..(i + 1) * dim_size].iter().sum()) + .collect(); + } + } + + // First-dim (or equivalent: any collapsed-outer case): scatter-add dim_size + // rows of inner_size cols into a single inner_size accumulator. + if outer_size == 1 { + #[cfg(feature = "simd")] + { + let mut result = aligned::alloc_aligned_zeroed::(inner_size); + kernels::scatter_add_f32(data, &mut result, dim_size, inner_size, inner_size); + return aligned::to_vec(result); + } + #[cfg(not(feature = "simd"))] + { + let mut result = vec![0.0f32; inner_size]; + for row in 0..dim_size { + let row_start = row * inner_size; + for c in 0..inner_size { + result[c] += data[row_start + c]; + } + } + return result; + } + } + + // Middle-dim: batched scatter-add. For each outer batch, sum dim_size rows + // of inner_size cols into a per-batch accumulator. + let out_size = outer_size * inner_size; + #[cfg(feature = "simd")] + { + let mut result = aligned::alloc_aligned_zeroed::(out_size); + kernels::scatter_add_batched( + data, + &mut result, + outer_size, + dim_size, + inner_size, + dim_size * inner_size, + inner_size, + ); + aligned::to_vec(result) + } + #[cfg(not(feature = "simd"))] + { + let mut result = vec![0.0f32; out_size]; + for outer in 0..outer_size { + let out_base = outer * inner_size; + for d in 0..dim_size { + let in_base = outer * dim_size * inner_size + d * inner_size; + for c in 0..inner_size { + result[out_base + c] += data[in_base + c]; + } + } + } + result + } +} + +/// Mean along a dimension for half-precision types, fusing sum and divide in f32. +/// +/// A naive `sum_dim` + `scalar_div` implementation can overflow to +inf when the +/// intermediate sum exceeds `f16::MAX` (65504), even if the final mean fits. This +/// function keeps the entire reduction and division in f32 and only narrows to +/// f16/bf16 on store via `E::from_elem`. +fn mean_dim_half(tensor: &FlexTensor, dim: usize) -> FlexTensor +where + E: Element + bytemuck::Pod, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + + let dim_size = shape[dim]; + assert!( + dim_size > 0, + "mean_dim: cannot take mean of empty dimension" + ); + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let outer_size: usize = shape[..dim].iter().product(); + let inner_size: usize = shape[dim + 1..].iter().product(); + + let data = float_storage_as_f32(&tensor); + let divisor = dim_size as f32; + + let sums = sum_dim_contiguous_f32(&data, outer_size, dim_size, inner_size); + let result: Vec = sums + .into_iter() + .map(|s| E::from_elem(s / divisor)) + .collect(); + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(Shape::from(out_shape)), + E::dtype(), + ) +} + +/// Scalar mean for half-precision types, fusing sum and divide in f32. +/// Avoids f16 overflow when the total sum exceeds `f16::MAX`. Empty input +/// produces NaN to match the f32/f64 path in `mean()`. +fn mean_scalar_half(tensor: &FlexTensor) -> FlexTensor +where + E: Element + bytemuck::Pod, +{ + let tensor = tensor.to_contiguous(); + let n = tensor.layout().num_elements(); + let data = float_storage_as_f32(&tensor); + // Route through `sum_f32_contiguous` to pick up SIMD + rayon for the + // f32 reduction. The half-precision narrowing happens after the divide. + let acc = sum_f32_contiguous(&data); + + let mean = acc / (n as f32); + let bytes = Bytes::from_elems(vec![E::from_elem(mean)]); + FlexTensor::new(bytes, Layout::contiguous(Shape::from(vec![1])), E::dtype()) +} + +// ============================================================================ +// Mean (all elements) +// ============================================================================ + +/// Mean of all elements, returning a scalar tensor. +pub fn mean(tensor: FlexTensor) -> FlexTensor { + let dtype = tensor.dtype(); + + // Half-precision types fuse sum+divide in f32 to avoid overflow when the + // total sum exceeds f16::MAX. + match dtype { + DType::F16 => return mean_scalar_half::(&tensor), + DType::BF16 => return mean_scalar_half::(&tensor), + _ => {} + } + + let n = tensor.layout().num_elements(); + let sum_result = sum(tensor); + match dtype { + DType::F32 => scalar_div::(sum_result, n as f32), + DType::F64 => scalar_div::(sum_result, n as f64), + _ => panic!("mean: unsupported dtype {:?}", dtype), + } +} + +// ============================================================================ +// Max/Min along dimension (value + optional indices in a single pass) +// ============================================================================ + +/// Max along a dimension, returning only values. +pub fn max_dim(tensor: FlexTensor, dim: usize) -> FlexTensor { + assert!( + tensor.layout().shape()[dim] > 0, + "max_dim: dimension {dim} has size 0" + ); + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_dim_f32_last_simd(&tensor, dim, kernels::max_f32); + } + return extremum_f32_last_scalar(&tensor, dim, |a, b| a > b); + } + match tensor.dtype() { + DType::F32 => { + extremum_dim::(&tensor, dim, |a, b| !b.is_nan() && (a.is_nan() || a > b)) + } + DType::F64 => { + extremum_dim::(&tensor, dim, |a, b| !b.is_nan() && (a.is_nan() || a > b)) + } + DType::F16 => extremum_dim_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => extremum_dim_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + bf16::to_f32, + bf16::from_f32, + ), + DType::I64 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::I32 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::I16 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::I8 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::U64 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::U32 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::U16 => extremum_dim::(&tensor, dim, |a, b| a > b), + DType::U8 => extremum_dim::(&tensor, dim, |a, b| a > b), + _ => panic!("max_dim: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Min along a dimension, returning only values. +pub fn min_dim(tensor: FlexTensor, dim: usize) -> FlexTensor { + assert!( + tensor.layout().shape()[dim] > 0, + "min_dim: dimension {dim} has size 0" + ); + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_dim_f32_last_simd(&tensor, dim, kernels::min_f32); + } + return extremum_f32_last_scalar(&tensor, dim, |a, b| a < b); + } + match tensor.dtype() { + DType::F32 => { + extremum_dim::(&tensor, dim, |a, b| !b.is_nan() && (a.is_nan() || a < b)) + } + DType::F64 => { + extremum_dim::(&tensor, dim, |a, b| !b.is_nan() && (a.is_nan() || a < b)) + } + DType::F16 => extremum_dim_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => extremum_dim_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + bf16::to_f32, + bf16::from_f32, + ), + DType::I64 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::I32 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::I16 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::I8 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::U64 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::U32 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::U16 => extremum_dim::(&tensor, dim, |a, b| a < b), + DType::U8 => extremum_dim::(&tensor, dim, |a, b| a < b), + _ => panic!("min_dim: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Max along a dimension with indices, returning (values, indices) in a single pass. +pub fn max_dim_with_indices(tensor: FlexTensor, dim: usize) -> (FlexTensor, FlexTensor) { + let dim_len = tensor.layout().shape()[dim]; + assert!( + dim_len > 0, + "max_dim_with_indices: dimension {dim} has size 0" + ); + assert_dim_fits_isize(dim_len, dim); + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_dim_with_indices_f32_last_simd(&tensor, dim, kernels::max_f32); + } + return extremum_with_indices_f32_last_scalar(&tensor, dim, |a, b| a > b); + } + match tensor.dtype() { + DType::F32 => extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a > b) + }), + DType::F64 => extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a > b) + }), + DType::F16 => extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a > b), + bf16::to_f32, + bf16::from_f32, + ), + DType::I64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::I32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::I16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::I8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::U64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::U32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::U16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + DType::U8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a > b), + _ => panic!( + "max_dim_with_indices: unsupported dtype {:?}", + tensor.dtype() + ), + } +} + +/// Min along a dimension with indices, returning (values, indices) in a single pass. +pub fn min_dim_with_indices(tensor: FlexTensor, dim: usize) -> (FlexTensor, FlexTensor) { + let dim_len = tensor.layout().shape()[dim]; + assert!( + dim_len > 0, + "min_dim_with_indices: dimension {dim} has size 0" + ); + assert_dim_fits_isize(dim_len, dim); + if tensor.dtype() == DType::F32 && dim == tensor.layout().shape().num_dims() - 1 { + #[cfg(feature = "simd")] + if tensor.layout().shape()[dim] >= EXTREMUM_SIMD_ROW_THRESHOLD { + return extremum_dim_with_indices_f32_last_simd(&tensor, dim, kernels::min_f32); + } + return extremum_with_indices_f32_last_scalar(&tensor, dim, |a, b| a < b); + } + match tensor.dtype() { + DType::F32 => extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a < b) + }), + DType::F64 => extremum_dim_with_indices::(&tensor, dim, |a, b| { + !b.is_nan() && (a.is_nan() || a < b) + }), + DType::F16 => extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + f16::to_f32, + f16::from_f32, + ), + DType::BF16 => extremum_dim_with_indices_half::( + &tensor, + dim, + |a, b| !b.is_nan() && (a.is_nan() || a < b), + bf16::to_f32, + bf16::from_f32, + ), + DType::I64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::I32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::I16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::I8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::U64 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::U32 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::U16 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + DType::U8 => extremum_dim_with_indices::(&tensor, dim, |a, b| a < b), + _ => panic!( + "min_dim_with_indices: unsupported dtype {:?}", + tensor.dtype() + ), + } +} + +// ============================================================================ +// Extremum helpers (SIMD fast paths + generic scalar, parallelized with rayon) +// ============================================================================ + +// Lower threshold than the global PARALLEL_THRESHOLD (256K) because the per-element +// work (a single comparison + conditional store) is cheap enough that rayon overhead +// is amortized at smaller sizes. 32K elements * ~1.5ns/elem = ~48µs of serial work, +// enough to justify thread-pool dispatch. +#[cfg(feature = "rayon")] +const EXTREMUM_PARALLEL_THRESHOLD: usize = 32 * 1024; + +/// Minimum row length for the 2-pass SIMD extremum path (reduce + scan). +/// Below this, single-pass scalar is faster since it reads each element once. +#[cfg(feature = "simd")] +const EXTREMUM_SIMD_ROW_THRESHOLD: usize = 512; + +/// Scalar single-pass f32 last-dim extremum (values only). +/// Avoids the generic closure path by operating directly on contiguous f32 rows. +fn extremum_f32_last_scalar(tensor: &FlexTensor, dim: usize, is_better: F) -> FlexTensor +where + F: Fn(f32, f32) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let reduce_row = |outer: usize| -> f32 { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + // Single left-to-right scan: first NaN wins, otherwise track the + // extremum. Starting `best = row[0]` means the i=0 `is_better` + // branch is a no-op for strict comparisons. + let mut best = row[0]; + for &v in row { + if v.is_nan() { + return f32::NAN; + } + if is_better(v, best) { + best = v; + } + } + best + }; + + #[cfg(feature = "rayon")] + let values: Vec = if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(&reduce_row).collect() + } else { + (0..outer_size).map(reduce_row).collect() + }; + + #[cfg(not(feature = "rayon"))] + let values: Vec = (0..outer_size).map(reduce_row).collect(); + + FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape)), + DType::F32, + ) +} + +/// Scalar single-pass f32 last-dim argmax/argmin (indices only). +/// Uses direct pointer access and simple comparisons instead of the generic closure path. +fn extremum_indices_f32_last_scalar(tensor: &FlexTensor, dim: usize, is_better: F) -> FlexTensor +where + F: Fn(f32, f32) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let find_row = |outer: usize| -> isize { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + // Single left-to-right scan: first NaN wins, otherwise track the + // extremum. Starting `best = row[0]` means the i=0 `is_better` + // branch is a no-op (strict comparisons are false on equal + // operands), so we don't need a separate row[0] check. + let mut best = row[0]; + let mut best_idx: isize = 0; + for (i, &v) in row.iter().enumerate() { + if v.is_nan() { + return i as isize; + } + if is_better(v, best) { + best = v; + best_idx = i as isize; + } + } + best_idx + }; + + #[cfg(feature = "rayon")] + let indices: Vec = if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(find_row).collect() + } else { + (0..outer_size).map(find_row).collect() + }; + + #[cfg(not(feature = "rayon"))] + let indices: Vec = (0..outer_size).map(find_row).collect(); + + FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ) +} + +/// Scalar single-pass f32 last-dim extremum with indices (values + indices). +fn extremum_with_indices_f32_last_scalar( + tensor: &FlexTensor, + dim: usize, + is_better: F, +) -> (FlexTensor, FlexTensor) +where + F: Fn(f32, f32) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let find_row = |outer: usize| -> (f32, isize) { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + let mut best = row[0]; + let mut best_idx: isize = 0; + for (i, &v) in row.iter().enumerate() { + if v.is_nan() { + return (f32::NAN, i as isize); + } + if is_better(v, best) { + best = v; + best_idx = i as isize; + } + } + (best, best_idx) + }; + + #[cfg(feature = "rayon")] + let (values, indices): (Vec, Vec) = + if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(find_row).unzip() + } else { + (0..outer_size).map(find_row).unzip() + }; + + #[cfg(not(feature = "rayon"))] + let (values, indices): (Vec, Vec) = (0..outer_size).map(find_row).unzip(); + + let val_tensor = FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape.clone())), + DType::F32, + ); + let idx_tensor = FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ); + (val_tensor, idx_tensor) +} + +/// Uses macerator SIMD reduction per contiguous row, with NaN propagation. +#[cfg(feature = "simd")] +fn extremum_dim_f32_last_simd( + tensor: &FlexTensor, + dim: usize, + simd_reduce: fn(&[f32]) -> f32, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let reduce_row = |outer: usize| -> f32 { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + let ext = simd_reduce(row); + // SIMD max/min may silently drop NaN (architecture-dependent). + // If the result is already NaN, we're done. Otherwise, scan to + // check for any NaN the SIMD op missed. + if ext.is_nan() { + return f32::NAN; + } + for &v in row { + if v.is_nan() { + return f32::NAN; + } + } + ext + }; + + #[cfg(feature = "rayon")] + let values: Vec = if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(reduce_row).collect() + } else { + (0..outer_size).map(reduce_row).collect() + }; + + #[cfg(not(feature = "rayon"))] + let values: Vec = (0..outer_size).map(reduce_row).collect(); + + FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape)), + DType::F32, + ) +} + +/// SIMD fast path for f32 last-dim extremum with indices. +/// Per row: SIMD reduction finds the extremum value, then a linear scan +/// locates the first NaN or first matching index. +#[cfg(feature = "simd")] +fn extremum_dim_with_indices_f32_last_simd( + tensor: &FlexTensor, + dim: usize, + simd_reduce: fn(&[f32]) -> f32, +) -> (FlexTensor, FlexTensor) { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let find_row = |outer: usize| -> (f32, isize) { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + let ext = simd_reduce(row); + // Single scan: return first NaN (with NaN value) or first match of ext. + for (i, &v) in row.iter().enumerate() { + if v.is_nan() { + return (f32::NAN, i as isize); + } + if v == ext { + return (ext, i as isize); + } + } + (ext, 0) + }; + + #[cfg(feature = "rayon")] + let (values, indices): (Vec, Vec) = + if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(find_row).unzip() + } else { + (0..outer_size).map(find_row).unzip() + }; + + #[cfg(not(feature = "rayon"))] + let (values, indices): (Vec, Vec) = (0..outer_size).map(find_row).unzip(); + + let val_tensor = FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape.clone())), + DType::F32, + ); + let idx_tensor = FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ); + (val_tensor, idx_tensor) +} + +/// SIMD fast path for f32 last-dim argmax/argmin (indices only, no values allocation). +#[cfg(feature = "simd")] +fn extremum_indices_f32_last_simd( + tensor: &FlexTensor, + dim: usize, + simd_reduce: fn(&[f32]) -> f32, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let dim_size = shape[dim]; + let outer_size: usize = shape[..dim].iter().product(); + let data: &[f32] = tensor.storage(); + let start = tensor.layout().start_offset(); + + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + + let find_row = |outer: usize| -> isize { + let row_start = start + outer * dim_size; + let row = &data[row_start..row_start + dim_size]; + let ext = simd_reduce(row); + for (i, &v) in row.iter().enumerate() { + if v.is_nan() || v == ext { + return i as isize; + } + } + 0 + }; + + #[cfg(feature = "rayon")] + let indices: Vec = if outer_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..outer_size).into_par_iter().map(find_row).collect() + } else { + (0..outer_size).map(find_row).collect() + }; + + #[cfg(not(feature = "rayon"))] + let indices: Vec = (0..outer_size).map(find_row).collect(); + + FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ) +} + +/// Find extremum value along a dimension. `is_better(new, current) -> bool`. +fn extremum_dim(tensor: &FlexTensor, dim: usize, is_better: F) -> FlexTensor +where + E: Element + bytemuck::Pod + Send + Sync, + F: Fn(E, E) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + assert!(dim < ndims); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let outer_size: usize = shape[..dim].iter().product::(); + let inner_size: usize = shape[dim + 1..].iter().product::(); + let out_size = outer_size * inner_size; + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let find = |flat_idx: usize| -> E { + let outer = flat_idx / inner_size; + let inner = flat_idx % inner_size; + let base = start_offset + outer * dim_size * inner_size + inner; + let mut best = data[base]; + for d in 1..dim_size { + let val = data[base + d * inner_size]; + if is_better(val, best) { + best = val; + } + } + best + }; + + #[cfg(feature = "rayon")] + let values: Vec = if out_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..out_size).into_par_iter().map(&find).collect() + } else { + (0..out_size).map(find).collect() + }; + + #[cfg(not(feature = "rayon"))] + let values: Vec = (0..out_size).map(find).collect(); + + FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape)), + E::dtype(), + ) +} + +/// Find extremum value and its index along a dimension. `is_better(new, current) -> bool`. +fn extremum_dim_with_indices( + tensor: &FlexTensor, + dim: usize, + is_better: F, +) -> (FlexTensor, FlexTensor) +where + E: Element + bytemuck::Pod + Send + Sync, + F: Fn(E, E) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + assert!(dim < ndims); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let outer_size: usize = shape[..dim].iter().product::(); + let inner_size: usize = shape[dim + 1..].iter().product::(); + let out_size = outer_size * inner_size; + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let find = |flat_idx: usize| -> (E, isize) { + let outer = flat_idx / inner_size; + let inner = flat_idx % inner_size; + let base = start_offset + outer * dim_size * inner_size + inner; + let mut best = data[base]; + let mut best_idx: isize = 0; + for d in 1..dim_size { + let val = data[base + d * inner_size]; + if is_better(val, best) { + best = val; + best_idx = d as isize; + } + } + (best, best_idx) + }; + + #[cfg(feature = "rayon")] + let (values, indices): (Vec, Vec) = + if out_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..out_size).into_par_iter().map(&find).unzip() + } else { + (0..out_size).map(find).unzip() + }; + + #[cfg(not(feature = "rayon"))] + let (values, indices): (Vec, Vec) = (0..out_size).map(find).unzip(); + + let val_tensor = FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape.clone())), + E::dtype(), + ); + let idx_tensor = FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ); + (val_tensor, idx_tensor) +} + +/// Find extremum value along a dimension for half-precision types (compared via f32). +fn extremum_dim_half( + tensor: &FlexTensor, + dim: usize, + is_better: F, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> FlexTensor +where + E: Element + bytemuck::Pod + Send + Sync, + F: Fn(f32, f32) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + assert!(dim < ndims); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let outer_size: usize = shape[..dim].iter().product::(); + let inner_size: usize = shape[dim + 1..].iter().product::(); + let out_size = outer_size * inner_size; + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let find = |flat_idx: usize| -> E { + let outer = flat_idx / inner_size; + let inner = flat_idx % inner_size; + let base = start_offset + outer * dim_size * inner_size + inner; + let mut best = to_f32(data[base]); + for d in 1..dim_size { + let val = to_f32(data[base + d * inner_size]); + if is_better(val, best) { + best = val; + } + } + from_f32(best) + }; + + #[cfg(feature = "rayon")] + let values: Vec = if out_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..out_size).into_par_iter().map(&find).collect() + } else { + (0..out_size).map(find).collect() + }; + + #[cfg(not(feature = "rayon"))] + let values: Vec = (0..out_size).map(find).collect(); + + FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape)), + E::dtype(), + ) +} + +/// Find extremum value and index along a dimension for half-precision types (compared via f32). +fn extremum_dim_with_indices_half( + tensor: &FlexTensor, + dim: usize, + is_better: F, + to_f32: fn(E) -> f32, + from_f32: fn(f32) -> E, +) -> (FlexTensor, FlexTensor) +where + E: Element + bytemuck::Pod + Send + Sync, + F: Fn(f32, f32) -> bool + Send + Sync, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape(); + let ndims = shape.num_dims(); + assert!(dim < ndims); + + let dim_size = shape[dim]; + let mut out_shape: Vec = shape.to_vec(); + out_shape[dim] = 1; + let outer_size: usize = shape[..dim].iter().product::(); + let inner_size: usize = shape[dim + 1..].iter().product::(); + let out_size = outer_size * inner_size; + let data: &[E] = tensor.storage(); + let start_offset = tensor.layout().start_offset(); + + let find = |flat_idx: usize| -> (E, isize) { + let outer = flat_idx / inner_size; + let inner = flat_idx % inner_size; + let base = start_offset + outer * dim_size * inner_size + inner; + let mut best = to_f32(data[base]); + let mut best_idx: isize = 0; + for d in 1..dim_size { + let val = to_f32(data[base + d * inner_size]); + if is_better(val, best) { + best = val; + best_idx = d as isize; + } + } + (from_f32(best), best_idx) + }; + + #[cfg(feature = "rayon")] + let (values, indices): (Vec, Vec) = + if out_size * dim_size >= EXTREMUM_PARALLEL_THRESHOLD { + (0..out_size).into_par_iter().map(&find).unzip() + } else { + (0..out_size).map(find).unzip() + }; + + #[cfg(not(feature = "rayon"))] + let (values, indices): (Vec, Vec) = (0..out_size).map(find).unzip(); + + let val_tensor = FlexTensor::new( + Bytes::from_elems(values), + Layout::contiguous(Shape::from(out_shape.clone())), + E::dtype(), + ); + let idx_tensor = FlexTensor::new( + Bytes::from_elems(indices), + Layout::contiguous(Shape::from(out_shape)), + INDEX_DTYPE, + ); + (val_tensor, idx_tensor) +} + +// ============================================================================ +// Scalar division helpers +// ============================================================================ + +fn scalar_div + Copy>( + mut tensor: FlexTensor, + divisor: E, +) -> FlexTensor { + let data: &mut [E] = tensor.storage_mut(); + for x in data.iter_mut() { + *x = *x / divisor; + } + tensor +} + +// ============================================================================ +// Tests +// ============================================================================ + +// Tests kept here exercise flex-specific behavior: dtype storage selection +// for every numeric width (i8/i16/i32/i64/u8/u16/u32/u64, bf16/f16/f32/f64) +// and edge cases of the dtype-specific reduction kernels (zero-sized +// non-reduced dims across contiguous/half/widening paths, dim sizes that +// exceed the element's max, f16 mean overflow fusion, zero-size panics on +// max_dim/min_dim). Plain 1d/2d smokes, NaN propagation, negative-stride +// (flipped/transposed/narrowed) variants, 4D middle-dim reductions, and +// permuted-argmax regressions have been migrated to burn-backend-tests so +// they run against every backend. When adding new tests, keep them here +// only if they probe flex dtype storage or panic on flex internals; +// otherwise add them to crates/burn-backend-tests/tests/tensor/{float,int}/ops/. +#[cfg(test)] +mod tests { + use alloc::vec; + use burn_backend::TensorData; + use burn_backend::ops::{FloatTensorOps, IntTensorOps}; + use burn_std::{bf16, f16}; + + use crate::{Flex, FlexTensor}; + + #[test] + fn test_mean_f16_overflow_intermediate_sum() { + // Scalar `mean()` for f16 must fuse sum+divide on the f32 accumulator. + // Sum of 0..1024 is 523776, well above f16::MAX (65504), so a naive + // sum-then-divide that materialises the intermediate in f16 would clip + // to inf. The final mean (511.5) fits f16 comfortably. + let data: Vec = (0..1024).map(|i| f16::from_f32(i as f32)).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [1024])); + + let result = Flex::float_mean(tensor); + let result_data = result.into_data(); + let values: &[f16] = bytemuck::cast_slice(&result_data.bytes); + + assert_eq!(values.len(), 1); + let mean = values[0].to_f32(); + assert!(mean.is_finite(), "mean overflowed to {mean}"); + assert!((mean - 511.5).abs() < 0.5, "expected ~511.5, got {mean}"); + } + + #[test] + fn test_mean_dim_f16_zero_outer_dim() { + // Regression test for mean_dim_half / sum_dim_contiguous_f32 clamping + // `outer_size.max(1)` in the last-dim branch: shape [0, 4] reducing + // dim=1 has outer_size=0, and the old clamp produced rows=1 which + // ran sum_rows_f32 past the end of an empty buffer. Result should be + // an empty tensor with shape [0, 1]. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [0, 4])); + let result = Flex::float_mean_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![0, 1]); + let result_data = result.into_data(); + let values: &[f16] = bytemuck::cast_slice(&result_data.bytes); + assert!(values.is_empty()); + } + + #[test] + fn test_sum_dim_f32_zero_outer_dim() { + // Regression: the f32 last-dim SIMD path (`reduce_last_dim_f32`) used + // `rows = outer_size.max(1)` which would index past the end of an + // empty data buffer for shape [0, K]. Guarded by the `out_size == 0` + // early return in `reduce_dim_f32`. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [0, 4])); + let result = Flex::float_sum_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![0, 1]); + assert!(result.into_data().bytes.is_empty()); + } + + #[test] + fn test_sum_dim_f32_zero_inner_dim() { + // Mirror of the outer-zero case: shape [3, 0] reducing dim=0 has + // inner_size=0. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [3, 0])); + let result = Flex::float_sum_dim(tensor, 0); + + assert_eq!(result.layout().shape().to_vec(), vec![1, 0]); + assert!(result.into_data().bytes.is_empty()); + } + + #[test] + fn test_sum_dim_f64_zero_outer_dim() { + // Covers `reduce_dim_impl` (generic contiguous/non-contiguous path) + // for the zero-sized non-reduced dim case. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [0, 4])); + let result = Flex::float_sum_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![0, 1]); + assert!(result.into_data().bytes.is_empty()); + } + + #[test] + fn test_sum_dim_i8_zero_outer_dim() { + // Covers `reduce_dim_widening` (i8/i16/u8/u16 path that accumulates + // in i64) for the zero-sized non-reduced dim case. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [0, 4])); + let result = Flex::int_sum_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![0, 1]); + assert!(result.into_data().bytes.is_empty()); + } + + #[test] + fn test_sum_dim_bf16_zero_outer_dim() { + // Covers `reduce_dim_half` (bf16/f16 sum_dim/prod_dim path) for the + // zero-sized non-reduced dim case. + let data: Vec = Vec::new(); + let tensor = FlexTensor::from_data(TensorData::new(data, [0, 4])); + let result = Flex::float_sum_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![0, 1]); + assert!(result.into_data().bytes.is_empty()); + } + + #[test] + fn test_mean_dim_i8_large_dimension() { + // dim_size=200 exceeds i8::MAX (127). Before the fix, 200 as i8 = -56, + // causing wrong results (or 256 as i8 = 0 causing div-by-zero). + let mut data: Vec = vec![0i8; 200]; + data[0] = 100; + let tensor = FlexTensor::from_data(TensorData::new(data, [1, 200])); + let result = Flex::int_mean_dim(tensor, 1); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + // integer division: 100 / 200 = 0 + assert_eq!(values, vec![0]); + } + + #[test] + fn test_mean_dim_i16_large_dimension() { + // dim_size=40000 exceeds i16::MAX (32767). + let mut data: Vec = vec![0i16; 40000]; + data[0] = 32000; + let tensor = FlexTensor::from_data(TensorData::new(data, [1, 40000])); + let result = Flex::int_mean_dim(tensor, 1); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![0]); + } + + #[test] + fn test_sum_i32() { + let data: Vec = vec![1, 2, 3, 4, 5]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + let result = Flex::int_sum(tensor); + + assert_eq!(result.layout().shape().to_vec(), vec![1]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![15]); + } + + #[test] + fn test_sum_dim_i32() { + let data: Vec = vec![1, 2, 3, 4, 5, 6]; + let tensor = FlexTensor::from_data(TensorData::new(data, [2, 3])); + let result = Flex::int_sum_dim(tensor, 1); + + assert_eq!(result.layout().shape().to_vec(), vec![2, 1]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![6, 15]); + } + + #[test] + fn test_argmax_i32() { + let data: Vec = vec![1, 5, 3, 2, 4]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + let result = Flex::int_argmax(tensor, 0); + + assert_eq!(result.layout().shape().to_vec(), vec![1]); + let result_data = result.into_data(); + #[cfg(target_pointer_width = "64")] + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + #[cfg(target_pointer_width = "32")] + let values: Vec = bytemuck::cast_slice::(&result_data.bytes) + .iter() + .map(|&v| v as i64) + .collect(); + assert_eq!(values, vec![1]); + } + + #[test] + #[should_panic(expected = "dimension 0 has size 0")] + fn test_max_dim_zero_size_panics() { + let tensor = FlexTensor::from_data(TensorData::new(Vec::::new(), [0, 3])); + Flex::float_max_dim(tensor, 0); + } + + #[test] + #[should_panic(expected = "dimension 1 has size 0")] + fn test_min_dim_zero_size_panics() { + let tensor = FlexTensor::from_data(TensorData::new(Vec::::new(), [3, 0])); + Flex::float_min_dim(tensor, 1); + } + + // === Unsigned integer dtype tests === + + #[test] + fn test_sum_u32() { + let tensor = FlexTensor::from_data(TensorData::new(vec![10u32, 20, 30], [3])); + let result = Flex::int_sum(tensor); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![60]); + } + + #[test] + fn test_sum_u64() { + let tensor = FlexTensor::from_data(TensorData::new(vec![100u64, 200, 300], [3])); + let result = Flex::int_sum(tensor); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![600]); + } + + #[test] + fn test_sum_dim_u8() { + let tensor = FlexTensor::from_data(TensorData::new(vec![1u8, 2, 3, 4], [2, 2])); + let result = Flex::int_sum_dim(tensor, 1); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![3, 7]); + } + + #[test] + fn test_prod_u16() { + let tensor = FlexTensor::from_data(TensorData::new(vec![2u16, 3, 5], [3])); + let result = Flex::int_prod(tensor); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![30]); + } + + #[test] + fn test_max_u32() { + let tensor = FlexTensor::from_data(TensorData::new(vec![5u32, 100, 42], [3])); + let result = Flex::int_max(tensor); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![100]); + } + + #[test] + fn test_min_u8() { + let tensor = FlexTensor::from_data(TensorData::new(vec![5u8, 1, 42], [3])); + let result = Flex::int_min(tensor); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![1]); + } + + #[test] + fn test_max_dim_u64() { + let tensor = FlexTensor::from_data(TensorData::new(vec![10u64, 20, 30, 5], [2, 2])); + let result = Flex::int_max_dim(tensor, 1); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![20, 30]); + } + + #[test] + fn test_min_dim_u16() { + let tensor = FlexTensor::from_data(TensorData::new(vec![10u16, 2, 30, 5], [2, 2])); + let result = Flex::int_min_dim(tensor, 1); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![2, 5]); + } + + #[test] + fn test_mean_dim_u8() { + let tensor = FlexTensor::from_data(TensorData::new(vec![10u8, 20, 30, 40], [2, 2])); + let result = Flex::int_mean_dim(tensor, 1); + let data: Vec = result.into_data().to_vec().unwrap(); + assert_eq!(data, vec![15, 35]); + } + + #[test] + fn test_max_dim_with_indices_u32() { + let tensor = FlexTensor::from_data(TensorData::new(vec![5u32, 10, 3, 8], [2, 2])); + let (values, indices) = Flex::int_max_dim_with_indices(tensor, 1); + let vals: Vec = values.into_data().to_vec().unwrap(); + let idxs: Vec = bytemuck::cast_slice(&indices.into_data().bytes).to_vec(); + assert_eq!(vals, vec![10, 8]); + assert_eq!(idxs, vec![1, 1]); + } + + // Cross-path consistency: `argmax`/`argmin` route short rows to the + // scalar kernel and rows of length >= EXTREMUM_SIMD_ROW_THRESHOLD to + // the SIMD kernel. Both kernels must agree on "first NaN wins". + // This is a flex-internal dispatch concern; the behavioral NaN + // propagation contract itself is exercised in burn-backend-tests + // under the `flex` feature gate (see issue #4814). + #[test] + fn test_argmax_scalar_and_simd_paths_agree_on_leading_nan() { + let short = + FlexTensor::from_data(TensorData::new(vec![f32::NAN, f32::NAN, f32::NAN], [1, 3])); + let short_idxs: Vec = + bytemuck::cast_slice(&super::argmax(short, 1).into_data().bytes).to_vec(); + + let mut long_data = alloc::vec![1.0f32; 600]; + long_data[0] = f32::NAN; + long_data[1] = f32::NAN; + long_data[300] = 5.0; + let long = FlexTensor::from_data(TensorData::new(long_data, [1, 600])); + let long_idxs: Vec = + bytemuck::cast_slice(&super::argmax(long, 1).into_data().bytes).to_vec(); + + assert_eq!(short_idxs, vec![0], "scalar path"); + assert_eq!(long_idxs, vec![0], "SIMD path"); + } +} diff --git a/crates/burn-flex/src/ops/repeat_dim.rs b/crates/burn-flex/src/ops/repeat_dim.rs new file mode 100644 index 0000000..706bf98 --- /dev/null +++ b/crates/burn-flex/src/ops/repeat_dim.rs @@ -0,0 +1,54 @@ +//! Repeat a tensor along a dimension. + +use alloc::vec::Vec; +use burn_std::{Bytes, Shape}; + +use crate::{FlexTensor, Layout}; + +/// Repeat `tensor` along `dim` by `times`. +pub fn repeat_dim(tensor: FlexTensor, dim: usize, times: usize) -> FlexTensor { + if times == 1 { + return tensor; + } + + let ndims = tensor.layout().num_dims(); + assert!( + dim < ndims, + "repeat_dim: dim {} out of bounds for tensor with {} dimensions", + dim, + ndims + ); + + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + let elem_size = crate::tensor::dtype_size(dtype); + + let mut new_dims: Vec = shape.iter().cloned().collect(); + new_dims[dim] *= times; + let new_shape = Shape::from(new_dims); + + let src: &[u8] = tensor.bytes(); + let n = new_shape.num_elements() * elem_size; + let mut dst: Vec = Vec::with_capacity(n); + + let inner: usize = shape.iter().skip(dim + 1).product(); + let dim_size = shape[dim]; + let chunk_bytes = dim_size * inner * elem_size; + let outer: usize = shape.iter().take(dim).product(); + + for o in 0..outer { + let start = o * chunk_bytes; + let end = start + chunk_bytes; + for _t in 0..times { + dst.extend_from_slice(&src[start..end]); + } + } + + debug_assert_eq!(dst.len(), n); + FlexTensor::new( + Bytes::from_bytes_vec(dst), + Layout::contiguous(new_shape), + dtype, + ) +} diff --git a/crates/burn-flex/src/ops/slice.rs b/crates/burn-flex/src/ops/slice.rs new file mode 100644 index 0000000..4844b89 --- /dev/null +++ b/crates/burn-flex/src/ops/slice.rs @@ -0,0 +1,690 @@ +//! Slice operations for FlexTensor. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, Slice, bf16, f16}; + +use crate::{FlexTensor, Layout}; + +/// Slice a tensor according to the given slice parameters. +/// +/// For positive steps, this is zero-copy (metadata only). +/// For negative steps, data is copied to handle the reversal. +pub fn slice(tensor: FlexTensor, slices: &[Slice]) -> FlexTensor { + let (new_layout, needs_copy) = tensor.layout().slice(slices); + + if !needs_copy { + // Zero-copy: share data with new layout + FlexTensor::from_arc(tensor.data_arc(), new_layout, tensor.dtype()) + } else { + // Needs copy due to negative steps + slice_with_copy(&tensor, slices) + } +} + +/// Slice with data copy (handles negative steps). +fn slice_with_copy(tensor: &FlexTensor, slices: &[Slice]) -> FlexTensor { + match tensor.dtype() { + DType::F32 => slice_copy_impl::(tensor, slices), + DType::F64 => slice_copy_impl::(tensor, slices), + DType::F16 => slice_copy_impl::(tensor, slices), + DType::BF16 => slice_copy_impl::(tensor, slices), + DType::I32 => slice_copy_impl::(tensor, slices), + DType::I64 => slice_copy_impl::(tensor, slices), + DType::I16 => slice_copy_impl::(tensor, slices), + DType::I8 => slice_copy_impl::(tensor, slices), + DType::U32 => slice_copy_impl::(tensor, slices), + DType::U64 => slice_copy_impl::(tensor, slices), + DType::U16 => slice_copy_impl::(tensor, slices), + DType::U8 => slice_copy_impl::(tensor, slices), + DType::Bool(_) => slice_copy_impl::(tensor, slices), + _ => panic!("slice: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Generic slice implementation with copy. +fn slice_copy_impl( + tensor: &FlexTensor, + slices: &[Slice], +) -> FlexTensor { + let src = tensor.storage::(); + let src_layout = tensor.layout(); + let ndims = src_layout.num_dims(); + + // Calculate output shape and collect normalized slice info + let mut out_shape = Vec::with_capacity(ndims); + let mut slice_info: Vec<(usize, usize, isize)> = Vec::with_capacity(ndims); // (start, len, step) + + for dim in 0..ndims { + let dim_size = src_layout.shape()[dim] as isize; + + let slice = if dim < slices.len() { + &slices[dim] + } else { + // Default: full range + &Slice::new(0, None, 1) + }; + + let (start, len, step) = compute_slice_info(slice, dim_size); + out_shape.push(len); + slice_info.push((start, len, step)); + } + + let out_layout = Layout::contiguous(Shape::from(out_shape.clone())); + let num_elements = out_layout.num_elements(); + + if num_elements == 0 { + let bytes = Bytes::from_elems::(Vec::new()); + return FlexTensor::new(bytes, out_layout, tensor.dtype()); + } + + // Allocate output + let mut out_data: Vec = Vec::with_capacity(num_elements); + + // Use recursive iteration for arbitrary dimensions + let mut indices = vec![0usize; ndims]; + copy_slice_recursive(src, src_layout, &slice_info, &mut out_data, &mut indices, 0); + + let bytes = Bytes::from_elems(out_data); + FlexTensor::new(bytes, out_layout, tensor.dtype()) +} + +/// Recursively copy sliced elements. +fn copy_slice_recursive( + src: &[E], + src_layout: &Layout, + slice_info: &[(usize, usize, isize)], + out: &mut Vec, + indices: &mut [usize], + dim: usize, +) { + let ndims = src_layout.num_dims(); + + if dim == ndims { + // Base case: copy single element + let src_idx = compute_src_index(src_layout, slice_info, indices); + out.push(src[src_idx]); + return; + } + + let (_, len, _) = slice_info[dim]; + + for i in 0..len { + indices[dim] = i; + copy_slice_recursive(src, src_layout, slice_info, out, indices, dim + 1); + } +} + +/// Compute source index from output indices and slice info. +fn compute_src_index( + layout: &Layout, + slice_info: &[(usize, usize, isize)], + out_indices: &[usize], +) -> usize { + let mut idx = layout.start_offset() as isize; + for (dim, &out_i) in out_indices.iter().enumerate() { + let (start, _, step) = slice_info[dim]; + let src_i = if step > 0 { + start + out_i * step as usize + } else { + // Negative step: start from high index, go down + let result = start as isize - (out_i as isize) * (-step); + debug_assert!(result >= 0, "slice: negative source index at dim {dim}"); + result as usize + }; + idx += src_i as isize * layout.strides()[dim]; + } + debug_assert!(idx >= 0, "slice: negative final index"); + idx as usize +} + +/// Normalize a potentially negative index to a positive one. +fn normalize_index(idx: isize, dim_size: isize) -> usize { + if idx < 0 { + (dim_size + idx).max(0) as usize + } else { + idx as usize + } +} + +/// Assign values to a slice of a tensor. +pub fn slice_assign(tensor: FlexTensor, slices: &[Slice], value: FlexTensor) -> FlexTensor { + match tensor.dtype() { + DType::F32 => slice_assign_impl::(tensor, slices, value), + DType::F64 => slice_assign_impl::(tensor, slices, value), + DType::F16 => slice_assign_impl::(tensor, slices, value), + DType::BF16 => slice_assign_impl::(tensor, slices, value), + DType::I32 => slice_assign_impl::(tensor, slices, value), + DType::I64 => slice_assign_impl::(tensor, slices, value), + DType::I16 => slice_assign_impl::(tensor, slices, value), + DType::I8 => slice_assign_impl::(tensor, slices, value), + DType::U32 => slice_assign_impl::(tensor, slices, value), + DType::U64 => slice_assign_impl::(tensor, slices, value), + DType::U16 => slice_assign_impl::(tensor, slices, value), + DType::U8 => slice_assign_impl::(tensor, slices, value), + DType::Bool(_) => slice_assign_impl::(tensor, slices, value), + _ => panic!("slice_assign: unsupported dtype {:?}", tensor.dtype()), + } +} + +/// Generic slice assign implementation. +fn slice_assign_impl( + tensor: FlexTensor, + slices: &[Slice], + value: FlexTensor, +) -> FlexTensor { + // Broadcast-scalar fast path: if `value` is a fully-broadcast + // scalar (all strides zero), read the scalar once instead of + // materializing the expansion via `to_contiguous`. The + // `num_elements > 0` gate also guards the storage read against + // zero-sized sources where `iter().all(...)` would be vacuously + // true. + if value.layout().num_elements() > 0 && value.layout().strides().iter().all(|&s| s == 0) { + let scalar = value.storage::()[value.layout().start_offset()]; + return slice_write_impl::(tensor, slices, WriteSource::Scalar(scalar)); + } + + let value = value.to_contiguous(); + let val_src: &[E] = value.storage::(); + slice_write_impl::(tensor, slices, WriteSource::Slice(val_src)) +} + +/// Source to splat into a sliced region of a destination tensor. The +/// two variants drive the same dispatch tree; `Scalar` hits the +/// broadcast-scalar fast path (no value buffer), `Slice` hits the +/// memcpy-style assign path (advances through `val_src`). +#[derive(Copy, Clone)] +enum WriteSource<'a, E: Copy> { + Scalar(E), + Slice(&'a [E]), +} + +impl<'a, E: Copy> WriteSource<'a, E> { + /// Write a contiguous span of `dst` starting at `dst_offset` for + /// `len` elements. For `Slice`, `src_offset` is the current + /// position in the value buffer. + #[inline] + fn write_span(self, dst: &mut [E], dst_offset: usize, len: usize, src_offset: usize) { + match self { + WriteSource::Scalar(s) => dst[dst_offset..dst_offset + len].fill(s), + WriteSource::Slice(src) => dst[dst_offset..dst_offset + len] + .copy_from_slice(&src[src_offset..src_offset + len]), + } + } + + /// Write a single element. `src_idx` is only read in the `Slice` + /// variant. + #[inline] + fn write_element(self, dst: &mut [E], dst_idx: usize, src_idx: usize) { + match self { + WriteSource::Scalar(s) => dst[dst_idx] = s, + WriteSource::Slice(src) => dst[dst_idx] = src[src_idx], + } + } +} + +/// Unified slice writer used by both `slice_assign_impl` and the +/// scalar-broadcast fast path. Walks the destination's sliced region +/// (1D / 2D inner-contig / ND inner-contig / strided fallback) and +/// pulls values from the given [`WriteSource`]. +fn slice_write_impl( + tensor: FlexTensor, + slices: &[Slice], + source: WriteSource<'_, E>, +) -> FlexTensor { + let mut tensor = tensor.to_contiguous(); + let dst_layout = tensor.layout().clone(); + let ndims = dst_layout.num_dims(); + + let slice_info: Vec<(usize, usize, isize)> = (0..ndims) + .map(|dim| { + let dim_size = dst_layout.shape()[dim] as isize; + let slice = if dim < slices.len() { + &slices[dim] + } else { + &Slice::new(0, None, 1) + }; + compute_slice_info(slice, dim_size) + }) + .collect(); + + let dst = tensor.storage_mut::(); + + let inner_contiguous = slice_info + .last() + .map(|(_, _, step)| *step == 1) + .unwrap_or(false); + + if ndims == 0 { + // Rank 0: single scalar destination. Only reachable from the + // scalar fast path; `slice_assign` on a rank-0 tensor with a + // rank-0 source also ends up here. + if !dst.is_empty() { + source.write_element(dst, 0, 0); + } + } else if ndims == 1 { + let (start, len, step) = slice_info[0]; + if step == 1 { + source.write_span(dst, start, len, 0); + } else { + for i in 0..len { + let dst_i = if step > 0 { + start + i * step as usize + } else { + (start as isize - (i as isize) * (-step)) as usize + }; + source.write_element(dst, dst_i, i); + } + } + } else if ndims == 2 && inner_contiguous { + let (row_start, row_len, row_step) = slice_info[0]; + let (col_start, col_len, _) = slice_info[1]; + let dst_cols = dst_layout.shape()[1]; + + let mut val_offset = 0; + for r in 0..row_len { + let row_idx = if row_step > 0 { + row_start + r * row_step as usize + } else { + (row_start as isize - (r as isize) * (-row_step)) as usize + }; + let dst_row_start = row_idx * dst_cols + col_start; + source.write_span(dst, dst_row_start, col_len, val_offset); + val_offset += col_len; + } + } else if inner_contiguous { + let inner_len = slice_info[ndims - 1].1; + let outer_dims = ndims - 1; + let dst_strides = dst_layout.strides(); + + let outer_count: usize = slice_info.iter().take(outer_dims).map(|i| i.1).product(); + + let mut outer_indices = vec![0usize; outer_dims]; + let mut val_offset = 0; + + for _ in 0..outer_count { + let mut dst_offset = dst_layout.start_offset() as isize; + for (dim, &idx) in outer_indices.iter().enumerate() { + let (start, _, step) = slice_info[dim]; + let src_i = if step > 0 { + start + idx * step as usize + } else { + (start as isize - (idx as isize) * (-step)) as usize + }; + dst_offset += src_i as isize * dst_strides[dim]; + } + dst_offset += slice_info[ndims - 1].0 as isize * dst_strides[ndims - 1]; + let dst_offset = dst_offset as usize; + + source.write_span(dst, dst_offset, inner_len, val_offset); + val_offset += inner_len; + + // Odometer increment over outer dims. + for dim in (0..outer_dims).rev() { + outer_indices[dim] += 1; + if outer_indices[dim] < slice_info[dim].1 { + break; + } + outer_indices[dim] = 0; + } + } + } else { + let total_elements: usize = slice_info.iter().map(|(_, len, _)| len).product(); + let dst_strides = dst_layout.strides(); + let mut indices = vec![0usize; ndims]; + + for i in 0..total_elements { + let mut dst_offset = dst_layout.start_offset() as isize; + for (dim, &idx) in indices.iter().enumerate() { + let (start, _, step) = slice_info[dim]; + let src_i = if step > 0 { + start + idx * step as usize + } else { + (start as isize - (idx as isize) * (-step)) as usize + }; + dst_offset += src_i as isize * dst_strides[dim]; + } + + source.write_element(dst, dst_offset as usize, i); + + for dim in (0..ndims).rev() { + indices[dim] += 1; + if indices[dim] < slice_info[dim].1 { + break; + } + indices[dim] = 0; + } + } + } + + tensor +} + +/// Compute slice info (start, len, step) for a dimension. +/// For negative step: start is the LAST index in the range (end-1), iterating down. +fn compute_slice_info(slice: &Slice, dim_size: isize) -> (usize, usize, isize) { + let step = slice.step; + let abs_step = step.unsigned_abs(); + + // Normalize start and end to [0, dim_size] + let range_start = normalize_index(slice.start, dim_size); + let range_end = match slice.end { + Some(e) => normalize_index(e, dim_size).min(dim_size as usize), + None => dim_size as usize, + }; + + let len = if range_end > range_start { + (range_end - range_start).div_ceil(abs_step) + } else { + 0 + }; + + if step > 0 { + // Forward: start at low index, go up + (range_start, len, step) + } else { + // Reverse: start at end-1 (highest index in range), go down + // For s![2..8;-2]: start from index 7, go to 5, then 3 + let reverse_start = if range_end > range_start { + range_end - 1 + } else { + range_start + }; + (reverse_start, len, step) + } +} + +// Tests kept here exercise flex-specific behavior: the internal +// `slice` / `slice_assign` helpers, the broadcast-scalar fast paths for +// `slice_fill` (1D contiguous, 2D inner-contig, 3D inner-contig, ND +// strided fallback, stepped-row 2D inner-contig), and non-f32 dtype +// coverage. General slice correctness across backends is covered by +// crates/burn-backend-tests/tests/tensor/float/ops/{slice,slice_assign}.rs. +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::TensorData; + + #[test] + fn test_slice_basic() { + // Create a 2x3 tensor: [[0, 1, 2], [3, 4, 5]] + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [2, 3])); + + // Slice [0:1, 1:3] -> [[1, 2]] + let slices = vec![Slice::new(0, Some(1), 1), Slice::new(1, Some(3), 1)]; + let result = slice(tensor, &slices); + + assert_eq!(result.layout().shape().to_vec(), vec![1, 2]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![1.0, 2.0]); + } + + #[test] + fn test_slice_with_step() { + // Create a 1D tensor: [0, 1, 2, 3, 4, 5] + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [6])); + + // Slice [0:6:2] -> [0, 2, 4] + let slices = vec![Slice::new(0, Some(6), 2)]; + let result = slice(tensor, &slices); + + assert_eq!(result.layout().shape().to_vec(), vec![3]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![0.0, 2.0, 4.0]); + } + + #[test] + fn test_slice_negative_index() { + // Create a 1D tensor: [0, 1, 2, 3, 4] + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + + // Slice [-3:] -> [2, 3, 4] + let slices = vec![Slice::new(-3, None, 1)]; + let result = slice(tensor, &slices); + + assert_eq!(result.layout().shape().to_vec(), vec![3]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![2.0, 3.0, 4.0]); + } + + #[test] + fn test_slice_negative_step() { + // Create a 1D tensor: [0, 1, 2, 3, 4] + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + + // Slice [0..;-1] -> [4, 3, 2, 1, 0] (reverse full range) + // In Burn's semantics: range selects elements, step determines order + let slices = vec![Slice::new(0, None, -1)]; + let result = slice(tensor, &slices); + + assert_eq!(result.layout().shape().to_vec(), vec![5]); + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![4.0, 3.0, 2.0, 1.0, 0.0]); + } + + #[test] + fn test_slice_assign_1d() { + // Create a 1D tensor: [0, 1, 2, 3, 4] + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + + // Assign [10, 11, 12] to positions [1:4] + let value_data: Vec = vec![10.0, 11.0, 12.0]; + let value = FlexTensor::from_data(TensorData::new(value_data, [3])); + let slices = vec![Slice::new(1, Some(4), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![0.0, 10.0, 11.0, 12.0, 4.0]); + } + + #[test] + fn test_slice_assign_2d() { + // Create a 3x3 tensor + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [3, 3])); + + // Assign [[10, 11], [12, 13]] to [1:3, 1:3] + let value_data: Vec = vec![10.0, 11.0, 12.0, 13.0]; + let value = FlexTensor::from_data(TensorData::new(value_data, [2, 2])); + let slices = vec![Slice::new(1, Some(3), 1), Slice::new(1, Some(3), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!( + values, + vec![0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 6.0, 12.0, 13.0,] + ); + } + + #[test] + fn test_slice_assign_2d_full_row() { + // Create a 3x4 tensor + let data: Vec = (0..12).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [3, 4])); + + // Assign [100, 101, 102, 103] to row 1 + let value_data: Vec = vec![100.0, 101.0, 102.0, 103.0]; + let value = FlexTensor::from_data(TensorData::new(value_data, [1, 4])); + let slices = vec![Slice::new(1, Some(2), 1), Slice::new(0, None, 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!( + values, + vec![ + 0.0, 1.0, 2.0, 3.0, 100.0, 101.0, 102.0, 103.0, 8.0, 9.0, 10.0, 11.0, + ] + ); + } + + // Broadcast-scalar fast path tests: mimic what + // `Tensor::slice_fill` produces (a 1-element source expanded to + // the slice shape with all strides zero). + + fn broadcast_scalar_f32(value: f32, target_shape: &[usize]) -> FlexTensor { + let scalar_tensor = FlexTensor::from_data(TensorData::new(vec![value], [1])); + crate::ops::expand::expand(scalar_tensor, Shape::from(target_shape.to_vec())) + } + + #[test] + fn test_slice_assign_broadcast_scalar_1d_contiguous() { + let data: Vec = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let tensor = FlexTensor::from_data(TensorData::new(data, [5])); + let value = broadcast_scalar_f32(7.0, &[3]); + let slices = vec![Slice::new(1, Some(4), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!(values, vec![0.0, 7.0, 7.0, 7.0, 4.0]); + } + + #[test] + fn test_slice_assign_broadcast_scalar_2d_inner_contiguous() { + let data: Vec = (0..16).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [4, 4])); + let value = broadcast_scalar_f32(-1.0, &[2, 2]); + let slices = vec![Slice::new(1, Some(3), 1), Slice::new(1, Some(3), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!( + values, + vec![ + 0.0, 1.0, 2.0, 3.0, 4.0, -1.0, -1.0, 7.0, 8.0, -1.0, -1.0, 11.0, 12.0, 13.0, 14.0, + 15.0, + ] + ); + } + + #[test] + fn test_slice_assign_broadcast_scalar_3d_inner_contiguous() { + // 3D case that matched the user-reported regression shape. + let data: Vec = (0..24).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [2, 3, 4])); + let value = broadcast_scalar_f32(9.0, &[1, 2, 2]); + let slices = vec![ + Slice::new(0, Some(1), 1), + Slice::new(0, Some(2), 1), + Slice::new(1, Some(3), 1), + ]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + // Region [0..1, 0..2, 1..3] fills positions (0, 0, 1), (0, 0, 2), + // (0, 1, 1), (0, 1, 2) with 9.0. Linear indices: 1, 2, 5, 6. + let mut expected: Vec = (0..24).map(|i| i as f32).collect(); + for &i in &[1usize, 2, 5, 6] { + expected[i] = 9.0; + } + assert_eq!(values, expected); + } + + #[test] + fn test_slice_assign_broadcast_scalar_strided_fallback() { + // Stepped slice: hits the strided fallback (not inner-contiguous). + let data: Vec = (0..10).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [10])); + // Source needs to match slice_info length: s![0..10;2] selects + // 5 positions (0, 2, 4, 6, 8), so the expand target is [5]. + let value = broadcast_scalar_f32(0.0, &[5]); + let slices = vec![Slice::new(0, Some(10), 2)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + assert_eq!( + values, + vec![0.0, 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 7.0, 0.0, 9.0] + ); + } + + /// Broadcast-scalar fast path on a non-f32 dtype. + #[test] + fn test_slice_assign_broadcast_scalar_i64() { + fn broadcast_scalar_i64(value: i64, target_shape: &[usize]) -> FlexTensor { + let scalar_tensor = FlexTensor::from_data(TensorData::new(vec![value], [1])); + crate::ops::expand::expand(scalar_tensor, Shape::from(target_shape.to_vec())) + } + + let data: Vec = (0..12).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [3, 4])); + let value = broadcast_scalar_i64(-7, &[2, 2]); + let slices = vec![Slice::new(0, Some(2), 1), Slice::new(1, Some(3), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + // Positions (0, 1), (0, 2), (1, 1), (1, 2): linear indices 1, + // 2, 5, 6 get replaced by -7. + assert_eq!(values, vec![0, -7, -7, 3, 4, -7, -7, 7, 8, 9, 10, 11]); + } + + /// ND strided fallback path: 3D slice with a stepped inner dim. + #[test] + fn test_slice_assign_broadcast_scalar_nd_strided_fallback() { + let data: Vec = (0..24).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [2, 3, 4])); + // Step 2 on the innermost dim means slice_info's last step != 1, + // so inner_contiguous is false and we take the ND fallback. + let value = broadcast_scalar_f32(9.0, &[2, 3, 2]); + let slices = vec![ + Slice::new(0, Some(2), 1), + Slice::new(0, Some(3), 1), + Slice::new(0, Some(4), 2), + ]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + // Step-2 on dim 2 picks indices 0 and 2, so within each + // `[b, r, :]` row the 0 and 2 positions get 9.0. + let mut expected: Vec = (0..24).map(|i| i as f32).collect(); + for b in 0..2 { + for r in 0..3 { + for c in [0, 2] { + expected[b * 12 + r * 4 + c] = 9.0; + } + } + } + assert_eq!(values, expected); + } + + /// 2D inner-contig branch with `row_step > 1`. + #[test] + fn test_slice_assign_broadcast_scalar_2d_stepped_rows() { + let data: Vec = (0..25).map(|i| i as f32).collect(); + let tensor = FlexTensor::from_data(TensorData::new(data, [5, 5])); + // Step-2 rows pick out rows 0, 2, 4 (3 rows). Slice the inner + // dim as a contiguous range so the 2D-inner-contig branch with + // row_step != 1 fires. + let value = broadcast_scalar_f32(-1.0, &[3, 3]); + let slices = vec![Slice::new(0, Some(5), 2), Slice::new(1, Some(4), 1)]; + let result = slice_assign(tensor, &slices, value); + + let result_data = result.into_data(); + let values: Vec = bytemuck::cast_slice(&result_data.bytes).to_vec(); + let mut expected: Vec = (0..25).map(|i| i as f32).collect(); + for r in [0, 2, 4] { + for c in 1..4 { + expected[r * 5 + c] = -1.0; + } + } + assert_eq!(values, expected); + } +} diff --git a/crates/burn-flex/src/ops/sort.rs b/crates/burn-flex/src/ops/sort.rs new file mode 100644 index 0000000..0604e55 --- /dev/null +++ b/crates/burn-flex/src/ops/sort.rs @@ -0,0 +1,715 @@ +//! Sort and argsort operations for FlexTensor. +//! +//! Operates directly on storage without TensorData round-trips. + +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::{DType, Element}; +use burn_std::{Bytes, Shape, bf16, f16}; +use bytemuck::Pod; + +#[cfg(feature = "rayon")] +use rayon::prelude::*; + +use crate::{FlexTensor, Layout}; + +use super::INDEX_DTYPE; +#[cfg(feature = "rayon")] +use super::PARALLEL_THRESHOLD; + +/// Validate sort dimension and check for empty tensors. +/// Returns `true` if the tensor is empty (caller should return early). +fn validate_sort_args(shape: &Shape, dim: usize) -> bool { + assert!( + dim < shape.num_dims(), + "sort: dim {} out of bounds for tensor with {} dimensions", + dim, + shape.num_dims() + ); + let dim_size = shape[dim]; + assert!( + dim_size <= isize::MAX as usize, + "sort: dimension {} has size {} which exceeds isize::MAX", + dim, + dim_size + ); + shape.num_elements() == 0 +} + +/// Sort elements along a dimension, returning the sorted tensor. +pub fn sort(tensor: FlexTensor, dim: usize, descending: bool) -> FlexTensor { + match tensor.dtype() { + DType::F32 => sort_typed::(tensor, dim, descending, f32::total_cmp), + DType::F64 => sort_typed::(tensor, dim, descending, f64::total_cmp), + DType::F16 => sort_half(tensor, dim, descending, f16::to_f32, f16::from_f32), + DType::BF16 => sort_half(tensor, dim, descending, bf16::to_f32, bf16::from_f32), + DType::I64 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::I32 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::I16 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::I8 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::U64 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::U32 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::U16 => sort_typed::(tensor, dim, descending, Ord::cmp), + DType::U8 => sort_typed::(tensor, dim, descending, Ord::cmp), + dt => panic!("sort: unsupported dtype {:?}", dt), + } +} + +/// Sort elements along a dimension, returning (sorted_values, indices). +pub fn sort_with_indices( + tensor: FlexTensor, + dim: usize, + descending: bool, +) -> (FlexTensor, FlexTensor) { + match tensor.dtype() { + DType::F32 => sort_with_indices_typed::(tensor, dim, descending, f32::total_cmp), + DType::F64 => sort_with_indices_typed::(tensor, dim, descending, f64::total_cmp), + DType::F16 => sort_with_indices_half(tensor, dim, descending, f16::to_f32, f16::from_f32), + DType::BF16 => { + sort_with_indices_half(tensor, dim, descending, bf16::to_f32, bf16::from_f32) + } + DType::I64 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::I32 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::I16 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::I8 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::U64 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::U32 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::U16 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + DType::U8 => sort_with_indices_typed::(tensor, dim, descending, Ord::cmp), + dt => panic!("sort_with_indices: unsupported dtype {:?}", dt), + } +} + +/// Argsort along a dimension, returning indices that would sort the tensor. +pub fn argsort(tensor: FlexTensor, dim: usize, descending: bool) -> FlexTensor { + match tensor.dtype() { + DType::F32 => argsort_typed::(tensor, dim, descending, f32::total_cmp), + DType::F64 => argsort_typed::(tensor, dim, descending, f64::total_cmp), + DType::F16 => argsort_half(tensor, dim, descending, f16::to_f32), + DType::BF16 => argsort_half(tensor, dim, descending, bf16::to_f32), + DType::I64 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::I32 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::I16 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::I8 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::U64 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::U32 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::U16 => argsort_typed::(tensor, dim, descending, Ord::cmp), + DType::U8 => argsort_typed::(tensor, dim, descending, Ord::cmp), + dt => panic!("argsort: unsupported dtype {:?}", dt), + } +} + +// --------------------------------------------------------------------------- +// Typed sort (operates directly on storage) +// --------------------------------------------------------------------------- + +fn sort_typed( + tensor: FlexTensor, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + if validate_sort_args(&shape, dim) { + return tensor; + } + + let mut data: Vec = tensor.storage::().to_vec(); + + if shape.num_dims() == 1 { + if descending { + data.sort_unstable_by(|a, b| cmp(b, a)); + } else { + data.sort_unstable_by(cmp); + } + } else { + sort_along_dim(&mut data, &shape, dim, descending, cmp); + } + + FlexTensor::new(Bytes::from_elems(data), Layout::contiguous(shape), dtype) +} + +fn sort_with_indices_typed( + tensor: FlexTensor, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) -> (FlexTensor, FlexTensor) { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + let n = shape.num_elements(); + if validate_sort_args(&shape, dim) { + let idx = make_index_tensor(Vec::new(), shape.clone()); + return (tensor, idx); + } + + let src: &[E] = tensor.storage(); + let mut values: Vec = src.to_vec(); + let mut indices: Vec = vec![0; n]; + + if shape.num_dims() == 1 { + sort_1d_with_indices(&mut values, &mut indices, descending, cmp); + } else { + sort_along_dim_with_indices(&mut values, &mut indices, &shape, dim, descending, cmp); + } + + let idx_tensor = make_index_tensor(indices, shape.clone()); + let val_tensor = FlexTensor::new(Bytes::from_elems(values), Layout::contiguous(shape), dtype); + (val_tensor, idx_tensor) +} + +/// Argsort without materializing sorted values. +fn argsort_typed( + tensor: FlexTensor, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let n = shape.num_elements(); + if validate_sort_args(&shape, dim) { + return make_index_tensor(Vec::new(), shape); + } + + let src: &[E] = tensor.storage(); + let mut indices: Vec = vec![0; n]; + + if shape.num_dims() == 1 { + let mut idx_vec: Vec = (0..n).collect(); + if descending { + idx_vec.sort_unstable_by(|&a, &b| cmp(&src[b], &src[a])); + } else { + idx_vec.sort_unstable_by(|&a, &b| cmp(&src[a], &src[b])); + } + for (out_i, &orig_i) in idx_vec.iter().enumerate() { + indices[out_i] = orig_i as isize; + } + } else { + argsort_along_dim(src, &mut indices, &shape, dim, descending, cmp); + } + + make_index_tensor(indices, shape) +} + +/// 1D sort with index tracking, shared by typed and half-precision paths. +fn sort_1d_with_indices( + values: &mut [E], + indices: &mut [isize], + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) { + let n = values.len(); + let mut idx_vec: Vec = (0..n).collect(); + if descending { + idx_vec.sort_unstable_by(|&a, &b| cmp(&values[b], &values[a])); + } else { + idx_vec.sort_unstable_by(|&a, &b| cmp(&values[a], &values[b])); + } + // Apply permutation in one pass using the sorted index order + let old_values = values.to_vec(); + for (out_i, &orig_i) in idx_vec.iter().enumerate() { + values[out_i] = old_values[orig_i]; + indices[out_i] = orig_i as isize; + } +} + +/// Sort along a given dimension for N-D tensors. +fn sort_along_dim( + data: &mut [E], + shape: &Shape, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) { + let strides = contiguous_strides(shape); + let dim_size = shape[dim]; + let dim_stride = strides[dim]; + let num_slices = data.len() / dim_size; + + // Fast path: last dimension (stride==1). Rows are contiguous at + // offsets `slice_idx * dim_size`, so `chunks_exact_mut(dim_size)` + // walks them directly. Parallelized with rayon above the threshold. + if dim_stride == 1 { + // chunks_exact silently drops any remainder, so lock the + // "length is a multiple of dim_size" invariant that holds for + // contiguous tensors by construction. + debug_assert_eq!(data.len() % dim_size, 0); + let sort_row = |row: &mut [E]| { + if descending { + row.sort_unstable_by(|a, b| cmp(b, a)); + } else { + row.sort_unstable_by(cmp); + } + }; + + #[cfg(feature = "rayon")] + if data.len() >= PARALLEL_THRESHOLD { + data.par_chunks_exact_mut(dim_size).for_each(sort_row); + return; + } + + data.chunks_exact_mut(dim_size).for_each(sort_row); + return; + } + + let mut slice_buf: Vec = vec![data[0]; dim_size]; + + for slice_idx in 0..num_slices { + let base = slice_base_offset(slice_idx, shape, &strides, dim); + + for i in 0..dim_size { + slice_buf[i] = data[base + i * dim_stride]; + } + + if descending { + slice_buf.sort_unstable_by(|a, b| cmp(b, a)); + } else { + slice_buf.sort_unstable_by(cmp); + } + + for i in 0..dim_size { + data[base + i * dim_stride] = slice_buf[i]; + } + } +} + +/// Sort along a dimension, tracking original indices. +fn sort_along_dim_with_indices( + data: &mut [E], + indices: &mut [isize], + shape: &Shape, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) { + let strides = contiguous_strides(shape); + let dim_size = shape[dim]; + let dim_stride = strides[dim]; + let num_slices = data.len() / dim_size; + + // Fast path: last dimension (stride==1). Values and indices rows + // are both contiguous at `slice_idx * dim_size`, so we can zip + // matching chunks and avoid the per-row stride arithmetic. + if dim_stride == 1 { + // zip silently truncates to the shorter iterator and chunks_exact + // silently drops remainders, so lock both invariants. + debug_assert_eq!(data.len(), indices.len()); + debug_assert_eq!(data.len() % dim_size, 0); + // Buffer is reused across rows (one per thread under rayon via + // `for_each_init`) to avoid a heap alloc per row. + let sort_row = |pairs: &mut Vec<(usize, E)>, (row, idx_row): (&mut [E], &mut [isize])| { + pairs.clear(); + pairs.extend((0..dim_size).map(|i| (i, row[i]))); + if descending { + pairs.sort_unstable_by(|a, b| cmp(&b.1, &a.1)); + } else { + pairs.sort_unstable_by(|a, b| cmp(&a.1, &b.1)); + } + for (i, &(orig_idx, val)) in pairs.iter().enumerate() { + row[i] = val; + idx_row[i] = orig_idx as isize; + } + }; + + #[cfg(feature = "rayon")] + if data.len() >= PARALLEL_THRESHOLD { + data.par_chunks_exact_mut(dim_size) + .zip(indices.par_chunks_exact_mut(dim_size)) + .for_each_init(|| Vec::with_capacity(dim_size), sort_row); + return; + } + + let mut pairs: Vec<(usize, E)> = Vec::with_capacity(dim_size); + data.chunks_exact_mut(dim_size) + .zip(indices.chunks_exact_mut(dim_size)) + .for_each(|row_and_idx| sort_row(&mut pairs, row_and_idx)); + return; + } + + let mut pairs: Vec<(usize, E)> = Vec::with_capacity(dim_size); + + for slice_idx in 0..num_slices { + let base = slice_base_offset(slice_idx, shape, &strides, dim); + + pairs.clear(); + for i in 0..dim_size { + pairs.push((i, data[base + i * dim_stride])); + } + + if descending { + pairs.sort_unstable_by(|a, b| cmp(&b.1, &a.1)); + } else { + pairs.sort_unstable_by(|a, b| cmp(&a.1, &b.1)); + } + + for (i, &(orig_idx, val)) in pairs.iter().enumerate() { + let offset = base + i * dim_stride; + data[offset] = val; + indices[offset] = orig_idx as isize; + } + } +} + +/// Argsort along a dimension without writing sorted values. +fn argsort_along_dim( + data: &[E], + indices: &mut [isize], + shape: &Shape, + dim: usize, + descending: bool, + cmp: fn(&E, &E) -> core::cmp::Ordering, +) { + let strides = contiguous_strides(shape); + let dim_size = shape[dim]; + let dim_stride = strides[dim]; + let num_slices = data.len() / dim_size; + + // Fast path: last dimension (stride==1). Both input rows and + // output index rows are contiguous at `slice_idx * dim_size`. + if dim_stride == 1 { + // zip silently truncates to the shorter iterator and chunks_exact + // silently drops remainders, so lock both invariants. + debug_assert_eq!(data.len(), indices.len()); + debug_assert_eq!(data.len() % dim_size, 0); + // Buffer is reused across rows (one per thread under rayon via + // `for_each_init`) to avoid a heap alloc per row. + let sort_row = |idx_buf: &mut Vec, (row, idx_row): (&[E], &mut [isize])| { + idx_buf.clear(); + idx_buf.extend(0..dim_size); + if descending { + idx_buf.sort_unstable_by(|&a, &b| cmp(&row[b], &row[a])); + } else { + idx_buf.sort_unstable_by(|&a, &b| cmp(&row[a], &row[b])); + } + for (i, &orig_idx) in idx_buf.iter().enumerate() { + idx_row[i] = orig_idx as isize; + } + }; + + #[cfg(feature = "rayon")] + if data.len() >= PARALLEL_THRESHOLD { + data.par_chunks_exact(dim_size) + .zip(indices.par_chunks_exact_mut(dim_size)) + .for_each_init(|| Vec::with_capacity(dim_size), sort_row); + return; + } + + let mut idx_buf: Vec = Vec::with_capacity(dim_size); + data.chunks_exact(dim_size) + .zip(indices.chunks_exact_mut(dim_size)) + .for_each(|row_and_idx| sort_row(&mut idx_buf, row_and_idx)); + return; + } + + let mut idx_buf: Vec = (0..dim_size).collect(); + + for slice_idx in 0..num_slices { + let base = slice_base_offset(slice_idx, shape, &strides, dim); + + idx_buf.clear(); + idx_buf.extend(0..dim_size); + + if descending { + idx_buf.sort_unstable_by(|&a, &b| { + cmp(&data[base + b * dim_stride], &data[base + a * dim_stride]) + }); + } else { + idx_buf.sort_unstable_by(|&a, &b| { + cmp(&data[base + a * dim_stride], &data[base + b * dim_stride]) + }); + } + + for (i, &orig_idx) in idx_buf.iter().enumerate() { + indices[base + i * dim_stride] = orig_idx as isize; + } + } +} + +// --------------------------------------------------------------------------- +// Half-precision sort (convert to f32, sort, convert back) +// --------------------------------------------------------------------------- + +fn sort_half( + tensor: FlexTensor, + dim: usize, + descending: bool, + to_f32: fn(H) -> f32, + from_f32: fn(f32) -> H, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + if validate_sort_args(&shape, dim) { + return tensor; + } + let src: &[H] = tensor.storage(); + let mut f32_data: Vec = src.iter().map(|&v| to_f32(v)).collect(); + + if shape.num_dims() == 1 { + if descending { + f32_data.sort_unstable_by(|a, b| f32::total_cmp(b, a)); + } else { + f32_data.sort_unstable_by(f32::total_cmp); + } + } else { + sort_along_dim(&mut f32_data, &shape, dim, descending, f32::total_cmp); + } + + let result: Vec = f32_data.iter().map(|&v| from_f32(v)).collect(); + FlexTensor::new(Bytes::from_elems(result), Layout::contiguous(shape), dtype) +} + +fn sort_with_indices_half( + tensor: FlexTensor, + dim: usize, + descending: bool, + to_f32: fn(H) -> f32, + from_f32: fn(f32) -> H, +) -> (FlexTensor, FlexTensor) { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let dtype = tensor.dtype(); + let n = shape.num_elements(); + if validate_sort_args(&shape, dim) { + let idx = make_index_tensor(Vec::new(), shape.clone()); + return (tensor, idx); + } + let src: &[H] = tensor.storage(); + let mut f32_data: Vec = src.iter().map(|&v| to_f32(v)).collect(); + let mut indices: Vec = vec![0; n]; + + if shape.num_dims() == 1 { + sort_1d_with_indices(&mut f32_data, &mut indices, descending, f32::total_cmp); + } else { + sort_along_dim_with_indices( + &mut f32_data, + &mut indices, + &shape, + dim, + descending, + f32::total_cmp, + ); + } + + let result: Vec = f32_data.iter().map(|&v| from_f32(v)).collect(); + let val_tensor = FlexTensor::new( + Bytes::from_elems(result), + Layout::contiguous(shape.clone()), + dtype, + ); + let idx_tensor = make_index_tensor(indices, shape); + (val_tensor, idx_tensor) +} + +fn argsort_half( + tensor: FlexTensor, + dim: usize, + descending: bool, + to_f32: fn(H) -> f32, +) -> FlexTensor { + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let n = shape.num_elements(); + if validate_sort_args(&shape, dim) { + return make_index_tensor(Vec::new(), shape); + } + let src: &[H] = tensor.storage(); + let f32_data: Vec = src.iter().map(|&v| to_f32(v)).collect(); + let mut indices: Vec = vec![0; n]; + + if shape.num_dims() == 1 { + let mut idx_vec: Vec = (0..n).collect(); + if descending { + idx_vec.sort_unstable_by(|&a, &b| f32::total_cmp(&f32_data[b], &f32_data[a])); + } else { + idx_vec.sort_unstable_by(|&a, &b| f32::total_cmp(&f32_data[a], &f32_data[b])); + } + for (out_i, &orig_i) in idx_vec.iter().enumerate() { + indices[out_i] = orig_i as isize; + } + } else { + argsort_along_dim( + &f32_data, + &mut indices, + &shape, + dim, + descending, + f32::total_cmp, + ); + } + + make_index_tensor(indices, shape) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn contiguous_strides(shape: &Shape) -> Vec { + crate::layout::contiguous_strides_usize(shape) +} + +fn slice_base_offset(slice_idx: usize, shape: &Shape, strides: &[usize], dim: usize) -> usize { + crate::layout::slice_base_offset(slice_idx, shape, strides, dim) +} + +fn make_index_tensor(indices: Vec, shape: Shape) -> FlexTensor { + let bytes = Bytes::from_elems(indices); + FlexTensor::new(bytes, Layout::contiguous(shape), INDEX_DTYPE) +} + +// Tests kept here exercise flex-specific behavior: the internal +// `sort_along_dim` / `sort_along_dim_with_indices` / `argsort_along_dim` +// helpers at sizes that straddle `PARALLEL_THRESHOLD`, so both the serial +// and rayon-parallel branches are covered. End-to-end sort/argsort +// correctness across backends lives in +// crates/burn-backend-tests/tests/tensor/float/ops/sort_argsort.rs. +#[cfg(test)] +mod tests { + use super::*; + + // Exercise both below and above the parallel threshold so serial and + // rayon fast paths in sort_along_dim agree on row-wise sort results. + fn check_sort_last_dim(rows: usize, cols: usize) { + let n = rows * cols; + // Deterministic non-monotonic input with repeats (mirrors bench fill % 1000). + let src: Vec = (0..n) + .map(|i| ((i * 1664525 + 1013904223) % 1000) as f32) + .collect(); + + let mut data = src.clone(); + let shape = Shape::new([rows, cols]); + sort_along_dim(&mut data, &shape, 1, false, f32::total_cmp); + + for r in 0..rows { + let row = &data[r * cols..(r + 1) * cols]; + for w in row.windows(2) { + assert!(w[0] <= w[1], "row {r} not sorted: {:?}", row); + } + let mut expected: Vec = src[r * cols..(r + 1) * cols].to_vec(); + expected.sort_unstable_by(f32::total_cmp); + assert_eq!(row, expected.as_slice()); + } + } + + #[test] + fn sort_along_last_dim_small_serial() { + // 64*64 = 4K elements, well under PARALLEL_THRESHOLD. + check_sort_last_dim(64, 64); + } + + #[cfg(feature = "rayon")] + #[test] + fn sort_along_last_dim_large_parallel() { + // Just above PARALLEL_THRESHOLD so the rayon path is exercised + // without paying for a much larger input under debug test builds. + let cols = 1024; + let rows = (PARALLEL_THRESHOLD / cols) + 1; + check_sort_last_dim(rows, cols); + } + + #[test] + fn sort_along_last_dim_descending() { + let mut data: Vec = (0..4096).map(|i| (i % 17) as f32).collect(); + let shape = Shape::new([128, 32]); + sort_along_dim(&mut data, &shape, 1, true, f32::total_cmp); + for r in 0..128 { + let row = &data[r * 32..(r + 1) * 32]; + for w in row.windows(2) { + assert!(w[0] >= w[1]); + } + } + } + + fn check_sort_with_indices_last_dim(rows: usize, cols: usize, descending: bool) { + let src: Vec = (0..rows * cols).map(|i| (i as f32 * 0.37).sin()).collect(); + let mut values = src.clone(); + let mut indices = vec![0isize; rows * cols]; + let shape = Shape::new([rows, cols]); + sort_along_dim_with_indices( + &mut values, + &mut indices, + &shape, + 1, + descending, + f32::total_cmp, + ); + for r in 0..rows { + let vs = &values[r * cols..(r + 1) * cols]; + let idx_row = &indices[r * cols..(r + 1) * cols]; + let orig = &src[r * cols..(r + 1) * cols]; + let want_order = if descending { + core::cmp::Ordering::Less + } else { + core::cmp::Ordering::Greater + }; + for w in vs.windows(2) { + assert_ne!(f32::total_cmp(&w[0], &w[1]), want_order); + } + // Indices must reconstruct the sorted values from the original row + // and must be a valid permutation of 0..cols (each index appears once). + let mut seen = vec![false; cols]; + for (i, &orig_idx) in idx_row.iter().enumerate() { + let j = orig_idx as usize; + assert_eq!(vs[i], orig[j]); + assert!(!seen[j], "row {r}: index {j} repeated"); + seen[j] = true; + } + } + } + + #[test] + fn sort_with_indices_last_dim_ascending() { + // 512*512 = 256K, exactly PARALLEL_THRESHOLD (hits parallel branch). + check_sort_with_indices_last_dim(512, 512, false); + } + + #[test] + fn sort_with_indices_last_dim_descending() { + check_sort_with_indices_last_dim(512, 512, true); + } + + fn check_argsort_last_dim(rows: usize, cols: usize, descending: bool) { + let src: Vec = (0..rows * cols) + .map(|i| ((i * 7919) % 997) as f32) + .collect(); + let mut indices = vec![0isize; rows * cols]; + let shape = Shape::new([rows, cols]); + argsort_along_dim(&src, &mut indices, &shape, 1, descending, f32::total_cmp); + for r in 0..rows { + let idx_row = &indices[r * cols..(r + 1) * cols]; + let orig = &src[r * cols..(r + 1) * cols]; + let sorted: Vec = idx_row.iter().map(|&i| orig[i as usize]).collect(); + for w in sorted.windows(2) { + if descending { + assert!(w[0] >= w[1]); + } else { + assert!(w[0] <= w[1]); + } + } + // idx_row must be a permutation of 0..cols; with heavy duplicates + // in src this catches parallel bugs that emit the same index twice. + let mut seen = vec![false; cols]; + for &i in idx_row { + let j = i as usize; + assert!(!seen[j], "row {r}: index {j} repeated"); + seen[j] = true; + } + } + } + + #[test] + fn argsort_last_dim_ascending() { + // 200*1500 = 300K, above PARALLEL_THRESHOLD. + check_argsort_last_dim(200, 1500, false); + } + + #[test] + fn argsort_last_dim_descending() { + check_argsort_last_dim(200, 1500, true); + } +} diff --git a/crates/burn-flex/src/ops/transaction.rs b/crates/burn-flex/src/ops/transaction.rs new file mode 100644 index 0000000..ec2227c --- /dev/null +++ b/crates/burn-flex/src/ops/transaction.rs @@ -0,0 +1,11 @@ +//! Transaction operations for the Flex backend. + +use crate::Flex; +use burn_backend::distributed::DistributedOps; +use burn_backend::ops::TransactionOps; + +// TransactionOps has default implementations. +impl TransactionOps for Flex {} + +// DistributedOps has default implementations; Flex does not support collective operations. +impl DistributedOps for Flex {} diff --git a/crates/burn-flex/src/ops/unary.rs b/crates/burn-flex/src/ops/unary.rs new file mode 100644 index 0000000..09f7b50 --- /dev/null +++ b/crates/burn-flex/src/ops/unary.rs @@ -0,0 +1,463 @@ +//! Unary tensor operations (exp, log, sqrt, sin, cos, etc.). + +use alloc::vec::Vec; +use burn_backend::DType; +use burn_std::{BoolDType, Bytes, bf16, f16}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use crate::layout::StridedBlocks; +use crate::{FlexTensor, Layout}; + +/// Apply a float predicate element-wise, producing a boolean tensor. +/// +/// Delegates to make_bool_tensor for output +/// construction, so it shares the same `BoolDType` support (Native/U8 only, +/// panics on U32). +pub fn float_predicate( + tensor: FlexTensor, + out_dtype: BoolDType, + f32_pred: F32P, + f64_pred: F64P, +) -> FlexTensor +where + F32P: Fn(f32) -> bool + Copy, + F64P: Fn(f64) -> bool + Copy, +{ + let tensor = tensor.to_contiguous(); + let shape = tensor.layout().shape().clone(); + let n = shape.num_elements(); + + let result: Vec = match tensor.dtype() { + DType::F32 => { + let s: &[f32] = tensor.storage(); + s[..n].iter().map(|&x| f32_pred(x) as u8).collect() + } + DType::F64 => { + let s: &[f64] = tensor.storage(); + s[..n].iter().map(|&x| f64_pred(x) as u8).collect() + } + DType::F16 => { + let s: &[f16] = tensor.storage(); + s[..n].iter().map(|&x| f32_pred(x.to_f32()) as u8).collect() + } + DType::BF16 => { + let s: &[bf16] = tensor.storage(); + s[..n].iter().map(|&x| f32_pred(x.to_f32()) as u8).collect() + } + dt => panic!("float_predicate: expected float dtype, got {:?}", dt), + }; + + crate::ops::comparison::make_bool_tensor(result, shape, out_dtype) +} + +/// Apply a unary operation element-wise to a tensor. +pub fn unary_op(tensor: FlexTensor, f32_op: F32Op, f64_op: F64Op) -> FlexTensor +where + F32Op: Fn(f32) -> f32 + Copy, + F64Op: Fn(f64) -> f64 + Copy, +{ + let dtype = tensor.dtype(); + + match dtype { + DType::F32 => unary_op_typed(tensor, f32_op), + DType::F64 => unary_op_typed(tensor, f64_op), + DType::F16 => unary_op_typed(tensor, |x: f16| f16::from_f32(f32_op(x.to_f32()))), + DType::BF16 => unary_op_typed(tensor, |x: bf16| bf16::from_f32(f32_op(x.to_f32()))), + _ => panic!("unary_op: unsupported dtype {:?}", dtype), + } +} + +/// Generic unary operation for any element type. +fn unary_op_typed(mut tensor: FlexTensor, op: Op) -> FlexTensor +where + E: burn_backend::Element + bytemuck::Pod, + Op: Fn(E) -> E, +{ + let n = tensor.layout().num_elements(); + + // In-place fast path: unique, contiguous tensor at offset 0 + if tensor.is_unique() && tensor.layout().is_contiguous() && tensor.layout().start_offset() == 0 + { + let storage: &mut [E] = tensor.storage_mut(); + for x in storage[..n].iter_mut() { + *x = op(*x); + } + return tensor; + } + + // Allocating path for non-contiguous or offset tensors + let layout = tensor.layout().clone(); + let src: &[E] = tensor.storage(); + + // Check for negative strides (from flip operations) + let has_negative_strides = layout.strides().iter().any(|&s| s < 0); + + // Fast path: storage exactly matches tensor view (covers transposed tensors) + // Iterate in storage order (contiguous) and preserve original layout. + // Only valid when all strides are positive. + if !has_negative_strides && layout.start_offset() == 0 && src.len() == n { + let result: Vec = src.iter().map(|&x| op(x)).collect(); + let bytes = Bytes::from_elems(result); + return FlexTensor::new(bytes, layout, E::dtype()); + } + + // Fallback for negative strides: use StridedIter for correct element order + if has_negative_strides { + let result: Vec = crate::strided_index::StridedIter::new(&layout) + .map(|idx| op(src[idx])) + .collect(); + let bytes = Bytes::from_elems(result); + return FlexTensor::new( + bytes, + Layout::contiguous(layout.shape().clone()), + E::dtype(), + ); + } + + // General path for views/slices with offset or extra storage + let blocks = layout.strided_blocks(); + let result = match &blocks { + // Single contiguous block (with offset) + StridedBlocks::Single { start, len } => { + src[*start..*start + *len].iter().map(|&x| op(x)).collect() + } + // Strided: iterate over contiguous blocks + StridedBlocks::Multiple { + block_len, + num_blocks, + .. + } => { + let block_len = *block_len; + let num_blocks = *num_blocks; + let mut result = Vec::with_capacity(n); + + if block_len == 1 { + for block_start in blocks.block_starts() { + result.push(op(src[block_start])); + } + } else { + for block_start in blocks.block_starts() { + for i in 0..block_len { + result.push(op(src[block_start + i])); + } + } + } + debug_assert_eq!(result.len(), num_blocks * block_len); + result + } + }; + + let bytes = Bytes::from_elems(result); + FlexTensor::new( + bytes, + Layout::contiguous(layout.shape().clone()), + E::dtype(), + ) +} + +// ============================================================================ +// Specific unary operations +// ============================================================================ + +/// Exponential: e^x +pub fn exp(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.exp(), |x| x.exp()) +} + +/// Natural logarithm: ln(x) +pub fn log(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.ln(), |x| x.ln()) +} + +/// Natural logarithm of (1 + x): ln(1 + x) +pub fn log1p(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.ln_1p(), |x| x.ln_1p()) +} + +/// Square root +pub fn sqrt(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.sqrt(), |x| x.sqrt()) +} + +/// Absolute value (float) +pub fn abs(tensor: FlexTensor) -> FlexTensor { + #[cfg(feature = "simd")] + if tensor.dtype() == DType::F32 + && tensor.is_unique() + && tensor.layout().is_contiguous() + && tensor.layout().start_offset() == 0 + { + let n = tensor.layout().num_elements(); + let mut tensor = tensor; + let storage: &mut [f32] = tensor.storage_mut(); + crate::simd::abs_inplace_f32(&mut storage[..n]); + return tensor; + } + unary_op(tensor, |x| x.abs(), |x| x.abs()) +} + +/// Absolute value (integer) +pub fn int_abs(tensor: FlexTensor) -> FlexTensor { + let dtype = tensor.dtype(); + match dtype { + DType::I64 => unary_op_typed::(tensor, |x| x.wrapping_abs()), + DType::I32 => unary_op_typed::(tensor, |x| x.wrapping_abs()), + DType::I16 => unary_op_typed::(tensor, |x| x.wrapping_abs()), + DType::I8 => unary_op_typed::(tensor, |x| x.wrapping_abs()), + // Unsigned integers: abs is identity + DType::U64 | DType::U32 | DType::U16 | DType::U8 => tensor, + _ => panic!("int_abs: unsupported dtype {:?}", dtype), + } +} + +/// Reciprocal: 1/x +pub fn recip(tensor: FlexTensor) -> FlexTensor { + #[cfg(feature = "simd")] + if tensor.dtype() == DType::F32 + && tensor.is_unique() + && tensor.layout().is_contiguous() + && tensor.layout().start_offset() == 0 + { + let n = tensor.layout().num_elements(); + let mut tensor = tensor; + let storage: &mut [f32] = tensor.storage_mut(); + crate::simd::recip_inplace_f32(&mut storage[..n]); + return tensor; + } + unary_op(tensor, |x| 1.0 / x, |x| 1.0 / x) +} + +/// Cosine +pub fn cos(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.cos(), |x| x.cos()) +} + +/// Sine +pub fn sin(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.sin(), |x| x.sin()) +} + +/// Tangent +pub fn tan(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.tan(), |x| x.tan()) +} + +/// Hyperbolic cosine +pub fn cosh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.cosh(), |x| x.cosh()) +} + +/// Hyperbolic sine +pub fn sinh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.sinh(), |x| x.sinh()) +} + +/// Hyperbolic tangent +pub fn tanh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.tanh(), |x| x.tanh()) +} + +/// Inverse cosine (arccos) +pub fn acos(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.acos(), |x| x.acos()) +} + +/// Inverse hyperbolic cosine +pub fn acosh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.acosh(), |x| x.acosh()) +} + +/// Inverse sine (arcsin) +pub fn asin(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.asin(), |x| x.asin()) +} + +/// Inverse hyperbolic sine +pub fn asinh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.asinh(), |x| x.asinh()) +} + +/// Inverse tangent (arctan) +pub fn atan(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.atan(), |x| x.atan()) +} + +/// Inverse hyperbolic tangent +pub fn atanh(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.atanh(), |x| x.atanh()) +} + +/// Round to nearest integer (ties to even / banker's rounding) +pub fn round(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, round_ties_even_f32, round_ties_even_f64) +} + +fn round_ties_even_f32(x: f32) -> f32 { + round_ties_even(x) +} + +fn round_ties_even_f64(x: f64) -> f64 { + round_ties_even(x) +} + +/// Round to nearest integer, ties to even (banker's rounding). +/// +/// `num_traits::Float::round` rounds ties away from zero. This corrects +/// the halfway case to round to the nearest even integer instead. +/// +/// Safety of the `to_i64` path: for f32, values with magnitude >= 2^23 +/// have no fractional bits, so `(x - r).abs()` is always 0.0 (never 0.5). +/// For f64, the threshold is 2^52. The halfway check therefore only +/// triggers for values well within i64 range. +fn round_ties_even(x: F) -> F { + let r = x.round(); + if (x - r).abs() == F::from(0.5).unwrap() { + // Ties: round to even by checking if r is odd + match r.to_i64() { + Some(ri) if ri % 2 != 0 => r - x.signum(), + _ => r, + } + } else { + r + } +} + +/// Floor (round down) +pub fn floor(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.floor(), |x| x.floor()) +} + +/// Ceiling (round up) +pub fn ceil(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.ceil(), |x| x.ceil()) +} + +/// Truncate (round towards zero) +pub fn trunc(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, |x| x.trunc(), |x| x.trunc()) +} + +/// Error function +pub fn erf(tensor: FlexTensor) -> FlexTensor { + unary_op(tensor, erf_f32, erf_f64) +} + +// ============================================================================ +// Error function implementation +// ============================================================================ +// +// Delegates to libm for full f32 / f64 precision. The previous Abramowitz +// and Stegun 7.1.26 formula was capped at ~1.5e-7 max absolute error, +// adequate for f32 but well short of f64's ~2.2e-16 precision. libm's erf +// is derived from fdlibm and lands within a few ulps across the full +// range in both precisions. + +/// Error function for f32. +pub fn erf_f32(x: f32) -> f32 { + libm::erff(x) +} + +/// Error function for f64. +pub fn erf_f64(x: f64) -> f64 { + libm::erf(x) +} + +// Tests kept here probe flex-internal unary helpers that don't flow through +// the generic FloatTensorOps API. Specifically, the manual round_ties_even +// implementation has a subtle to_i64 conversion path that must not trip on +// values outside i64 range. Plain elementwise unary smokes (exp, log, sqrt, +// abs, sin/cos, tanh, round/floor/ceil, erf) and their stride-through-op +// variants (transposed/flipped/narrowed/sliced) have been migrated to +// burn-backend-tests so they run against every backend. When adding new +// tests, keep them here only if they probe flex-internal helpers +// (erf_f32/f64, round_ties_even); otherwise add them to +// crates/burn-backend-tests/tests/tensor/float/ops/. +#[cfg(test)] +#[allow(clippy::excessive_precision)] +mod tests { + use alloc::vec; + use burn_backend::TensorData; + + use crate::FlexTensor; + + #[test] + fn test_round_ties_even_large_float() { + // Values outside i32 range used to break the manual round_ties_even impl. + let data = vec![2e18_f32, -2e18_f32, f32::MAX, f32::MIN]; + let tensor = FlexTensor::from_data(TensorData::new(data.clone(), [data.len()])); + let result = super::round(tensor); + let out: Vec = result.into_data().to_vec().unwrap(); + for (a, b) in out.iter().zip(data.iter()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } + + // The previous A&S 7.1.26 erf implementation had ~1.5e-7 max absolute + // error regardless of arithmetic precision. That was acceptable for + // f32 but orders of magnitude worse than f64's ~2.2e-16 precision. + // These tests guard near-ulp accuracy across the domain, including + // libm's piecewise-rational switch points near |x|=0.84375 and the + // saturation region around |x|>=6 where erf(x) rounds to ±1. + // Reference values from Wolfram Alpha / DLMF. + #[test] + fn test_erf_f64_is_full_precision() { + // Reference values computed with mpmath at 25-digit precision. + let cases = [ + (0.0f64, 0.0f64), + (1e-10, 1.128379167095512574e-10), // small-x linear regime + (0.5, 0.5204998778130465377), + (0.84, 0.7651427114549945347), // just below libm's 0.84375 boundary + (0.85, 0.7706680576083525324), // just above + (1.0, 0.8427007929497148693), + (1.5, 0.9661051464753107271), + (2.0, 0.9953222650189527342), + (3.0, 0.9999779095030014145), + (6.0, 0.9999999999999999785), // near saturation + (30.0, 1.0), // fully saturated + (-0.5, -0.5204998778130465377), + (-1.0, -0.8427007929497148693), + (-3.0, -0.9999779095030014145), + ]; + for (x, expected) in cases { + let got = super::erf_f64(x); + let err = (got - expected).abs(); + assert!( + err < 1e-14, + "erf_f64({}) = {} expected {} (err {:e})", + x, + got, + expected, + err + ); + } + } + + // f32 erf must stay accurate to within f32 precision (~6e-8). Covers + // the same piecewise boundaries as the f64 test. + #[test] + fn test_erf_f32_full_f32_precision() { + let cases = [ + (0.0f32, 0.0f32), + (0.5, 0.520_499_9), + (0.84, 0.765_142_7), + (0.85, 0.770_668_06), + (1.0, 0.842_700_8), + (2.0, 0.995_322_24), + (6.0, 1.0), + (-0.5, -0.520_499_9), + (-1.0, -0.842_700_8), + ]; + for (x, expected) in cases { + let got = super::erf_f32(x); + assert!( + (got - expected).abs() < 1e-6, + "erf_f32({}) = {} expected {}", + x, + got, + expected + ); + } + } +} diff --git a/crates/burn-flex/src/ops/unfold.rs b/crates/burn-flex/src/ops/unfold.rs new file mode 100644 index 0000000..c90b0c0 --- /dev/null +++ b/crates/burn-flex/src/ops/unfold.rs @@ -0,0 +1,118 @@ +//! Unfold operation for sliding window extraction. +//! +//! Implemented as a zero-copy strided view. The output tensor shares storage +//! with the input, using strides to represent overlapping windows. + +use alloc::vec::Vec; + +use burn_std::Shape; + +use crate::{FlexTensor, Layout}; + +/// Calculate the number of windows that can be extracted from a dimension. +#[inline] +fn calculate_windows(dim_size: usize, window_size: usize, step: usize) -> usize { + assert!(step > 0, "step must be positive"); + if dim_size + step < window_size { + 0 + } else { + (dim_size + step - window_size) / step + } +} + +/// Unfold: extract sliding windows from a tensor along a dimension. +/// +/// Given a tensor with shape `[pre..., dim_size, post...]`, extracts windows of +/// `size` elements along dimension `dim`, stepping by `step`. +/// +/// Returns a tensor with shape `[pre..., windows, post..., size]` where: +/// - `windows = (dim_size - size + step) / step` +/// - The `size` dimension is appended at the end +/// +/// This is a zero-copy operation that returns a strided view of the input. +/// The same storage elements may appear in multiple windows (overlapping). +pub fn unfold(tensor: FlexTensor, dim: usize, size: usize, step: usize) -> FlexTensor { + let input_layout = tensor.layout(); + let shape = input_layout.shape(); + let input_strides = input_layout.strides(); + let start_offset = input_layout.start_offset(); + let ndims = shape.num_dims(); + let dtype = tensor.dtype(); + + assert!( + dim < ndims, + "dim {} out of bounds for {} dimensions", + dim, + ndims + ); + assert!(size > 0, "window size must be positive"); + assert!(step > 0, "step must be positive"); + assert!( + shape[dim] >= size, + "dimension {} has size {} which is smaller than window size {}", + dim, + shape[dim], + size + ); + + let dim_size = shape[dim]; + let windows = calculate_windows(dim_size, size, step); + + // Build output shape: [pre..., windows, post..., size] + let mut output_dims: Vec = Vec::with_capacity(ndims + 1); + for (d, &s) in shape.iter().enumerate() { + if d == dim { + output_dims.push(windows); + } else { + output_dims.push(s); + } + } + output_dims.push(size); // Append size at the end + + // Build output strides: + // - Dimensions before `dim`: same stride as input + // - Dimension `dim` (now windows): input_stride[dim] * step + // - Dimensions after `dim`: same stride as input + // - New size dimension (appended): input_stride[dim] + let mut output_strides: Vec = Vec::with_capacity(ndims + 1); + for (d, &s) in input_strides.iter().enumerate() { + if d == dim { + // Windows dimension: stride = original_stride * step + output_strides.push(s * step as isize); + } else { + output_strides.push(s); + } + } + // Append stride for the size dimension (position within window) + output_strides.push(input_strides[dim]); + + let output_shape = Shape::from(output_dims); + let output_layout = Layout::new(output_shape, output_strides, start_offset); + + // Zero-copy: reuse the same storage with new layout + FlexTensor::from_arc(tensor.data_arc(), output_layout, dtype) +} + +// Type-specific wrappers (all delegate to the generic unfold which is now type-agnostic) + +pub fn unfold_f32(tensor: FlexTensor, dim: usize, size: usize, step: usize) -> FlexTensor { + unfold(tensor, dim, size, step) +} + +pub fn unfold_f64(tensor: FlexTensor, dim: usize, size: usize, step: usize) -> FlexTensor { + unfold(tensor, dim, size, step) +} + +pub fn unfold_bool(tensor: FlexTensor, dim: usize, size: usize, step: usize) -> FlexTensor { + unfold(tensor, dim, size, step) +} + +pub fn unfold_int(tensor: FlexTensor, dim: usize, size: usize, step: usize) -> FlexTensor { + unfold(tensor, dim, size, step) +} + +// Correctness of unfold across dtypes and shapes is covered by the +// cross-backend suite in +// crates/burn-backend-tests/tests/tensor/{float,int,bool}/ops/unfold.rs, +// which exercises the flex backend through the public `unfold` op. No +// flex-specific tests remain here. diff --git a/crates/burn-flex/src/qtensor.rs b/crates/burn-flex/src/qtensor.rs new file mode 100644 index 0000000..aa95db5 --- /dev/null +++ b/crates/burn-flex/src/qtensor.rs @@ -0,0 +1,77 @@ +use alloc::vec::Vec; + +use burn_backend::{DType, TensorMetadata}; +use burn_std::{QuantScheme, Shape}; + +use crate::{FlexDevice, tensor::FlexTensor}; + +/// Quantized tensor for the Flex backend. +/// +/// Stores quantized i8 values in the tensor and keeps scales separately +/// for efficient dequantization without reparsing bytes. +#[derive(Clone, Debug)] +pub struct FlexQTensor { + /// The underlying quantized data (stored as i8). + pub(crate) tensor: FlexTensor, + /// Quantization scheme. + pub(crate) scheme: QuantScheme, + /// Per-tensor or per-block scale factors. + pub(crate) scales: Vec, +} + +impl FlexQTensor { + /// Create a new quantized tensor. + /// + /// The tensor must store i8 data and scales must be non-empty. + pub fn new(tensor: FlexTensor, scheme: QuantScheme, scales: Vec) -> Self { + assert_eq!( + tensor.dtype(), + DType::I8, + "quantized tensor must store i8 data, got {:?}", + tensor.dtype() + ); + assert!( + !scales.is_empty(), + "quantized tensor must have at least one scale factor" + ); + Self { + tensor, + scheme, + scales, + } + } + + /// Get the underlying tensor. + pub fn tensor(&self) -> &FlexTensor { + &self.tensor + } + + /// Get the quantization scales. + pub fn scales(&self) -> &[f32] { + &self.scales + } +} + +impl TensorMetadata for FlexQTensor { + type Device = FlexDevice; + + fn dtype(&self) -> DType { + DType::QFloat(self.scheme) + } + + fn shape(&self) -> Shape { + self.tensor.shape() + } + + fn rank(&self) -> usize { + self.tensor.rank() + } + + fn device(&self) -> Self::Device { + FlexDevice + } + + fn can_mut(&self) -> bool { + self.tensor.is_unique() + } +} diff --git a/crates/burn-flex/src/simd/aligned.rs b/crates/burn-flex/src/simd/aligned.rs new file mode 100644 index 0000000..fd6b3c0 --- /dev/null +++ b/crates/burn-flex/src/simd/aligned.rs @@ -0,0 +1,70 @@ +//! SIMD-aligned memory allocation utilities. +//! +//! Provides aligned vectors for optimal SIMD performance. Uses 64-byte alignment +//! to support all SIMD instruction sets (NEON: 16B, AVX2: 32B, AVX-512: 64B). + +use aligned_vec::{AVec, ConstAlign}; +use alloc::vec::Vec; + +/// Alignment for SIMD operations (64 bytes = AVX-512 width). +/// Also works for NEON (16B) and AVX2 (32B) since 64 is a multiple of both. +pub const SIMD_ALIGN: usize = 64; + +/// Type alias for 64-byte aligned vector. +pub type AlignedVec = AVec>; + +/// Allocate a SIMD-aligned vector with the given capacity. +#[inline] +pub fn alloc_aligned(capacity: usize) -> AlignedVec { + AVec::with_capacity(SIMD_ALIGN, capacity) +} + +/// Allocate a SIMD-aligned vector filled with zeros. +#[inline] +pub fn alloc_aligned_zeroed(len: usize) -> AlignedVec { + let mut vec = AVec::with_capacity(SIMD_ALIGN, len); + vec.resize(len, T::zeroed()); + vec +} + +/// Convert an aligned vector to a regular Vec. +/// +/// This may copy the data if the alignment requirements differ. +#[inline] +pub fn to_vec(aligned: AlignedVec) -> Vec { + aligned.to_vec() +} + +/// Convert a slice to an aligned vector (copies data). +#[inline] +pub fn from_slice(slice: &[T]) -> AlignedVec { + let mut vec = AVec::with_capacity(SIMD_ALIGN, slice.len()); + vec.extend_from_slice(slice); + vec +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alignment() { + let vec: AlignedVec = alloc_aligned(1000); + assert_eq!(vec.as_ptr() as usize % SIMD_ALIGN, 0); + } + + #[test] + fn test_zeroed() { + let vec: AlignedVec = alloc_aligned_zeroed(100); + assert_eq!(vec.len(), 100); + assert!(vec.iter().all(|&x| x == 0.0)); + } + + #[test] + fn test_from_slice() { + let data = [1.0f32, 2.0, 3.0, 4.0]; + let aligned = from_slice(&data); + assert_eq!(aligned.as_ptr() as usize % SIMD_ALIGN, 0); + assert_eq!(&aligned[..], &data[..]); + } +} diff --git a/crates/burn-flex/src/simd/kernels.rs b/crates/burn-flex/src/simd/kernels.rs new file mode 100644 index 0000000..43429d6 --- /dev/null +++ b/crates/burn-flex/src/simd/kernels.rs @@ -0,0 +1,317 @@ +//! Portable SIMD kernels using macerator. +//! +//! These kernels work across all architectures supported by macerator: +//! - aarch64 (NEON) +//! - x86_64 (AVX2, AVX512, SSE) +//! - wasm32 (SIMD128) +//! - Scalar fallback for embedded/other platforms + +use core::iter::Sum; +use core::ops::AddAssign; + +use macerator::{ + ReduceAdd, ReduceMax, ReduceMin, Simd, VAdd, VOrd, vload_unaligned, vstore_unaligned, +}; + +// ============================================================================ +// Sum reduction +// ============================================================================ + +/// Sum all elements in a f32 slice using SIMD with 4 accumulators. +#[inline] +pub fn sum_f32(data: &[f32]) -> f32 { + macerator_sum(data) +} + +/// 8-accumulator SIMD sum. Independent accumulator chains let the CPU +/// pipeline floating-point adds and hide L2 cache latency. +#[macerator::with_simd] +fn macerator_sum(mut xs: &[F]) -> F { + let lanes = F::lanes::(); + let stride = lanes * 8; + let zero = F::default().splat::(); + let (mut s0, mut s1, mut s2, mut s3) = (zero, zero, zero, zero); + let (mut s4, mut s5, mut s6, mut s7) = (zero, zero, zero, zero); + + while xs.len() >= stride { + unsafe { + let p = xs.as_ptr(); + s0 += vload_unaligned(p); + s1 += vload_unaligned(p.add(lanes)); + s2 += vload_unaligned(p.add(lanes * 2)); + s3 += vload_unaligned(p.add(lanes * 3)); + s4 += vload_unaligned(p.add(lanes * 4)); + s5 += vload_unaligned(p.add(lanes * 5)); + s6 += vload_unaligned(p.add(lanes * 6)); + s7 += vload_unaligned(p.add(lanes * 7)); + } + xs = &xs[stride..]; + } + + // Combine 8 accumulators into one, then drain remaining full vectors + let mut sum = ((s0 + s1) + (s2 + s3)) + ((s4 + s5) + (s6 + s7)); + while xs.len() >= lanes { + sum += unsafe { vload_unaligned(xs.as_ptr()) }; + xs = &xs[lanes..]; + } + + sum.reduce_add() + xs.iter().copied().sum() +} + +// ============================================================================ +// Scatter-add for dimension reductions +// ============================================================================ + +/// Scatter-add: for each row, add to corresponding output positions. +/// Used for cache-friendly first-dim and middle-dim reductions. +/// +/// # Arguments +/// * `src` - Source data pointer +/// * `dst` - Destination accumulator (must be pre-zeroed) +/// * `num_rows` - Number of rows to sum +/// * `row_len` - Length of each row (columns) +/// * `src_row_stride` - Stride between source rows +#[macerator::with_simd] +pub fn scatter_add_f32( + src: &[F], + dst: &mut [F], + num_rows: usize, + row_len: usize, + src_row_stride: usize, +) { + let lanes = F::lanes::(); + + for row in 0..num_rows { + let row_start = row * src_row_stride; + let row_data = &src[row_start..row_start + row_len]; + + let simd_len = row_len / lanes * lanes; + + // SIMD accumulate + let mut i = 0; + while i < simd_len { + unsafe { + let s = vload_unaligned(row_data.as_ptr().add(i)); + let d = vload_unaligned(dst.as_ptr().add(i)); + vstore_unaligned::(dst.as_mut_ptr().add(i), d + s); + } + i += lanes; + } + + // Scalar tail + for j in simd_len..row_len { + dst[j] += row_data[j]; + } + } +} + +/// Batched scatter-add for middle-dim reductions. +/// For tensors like [B, M, K] reducing dim=1. +#[macerator::with_simd] +pub fn scatter_add_batched( + src: &[F], + dst: &mut [F], + num_batches: usize, + num_rows: usize, + row_len: usize, + batch_stride: usize, + row_stride: usize, +) { + let lanes = F::lanes::(); + + for batch in 0..num_batches { + let batch_src_start = batch * batch_stride; + let batch_dst_start = batch * row_len; + let batch_dst = &mut dst[batch_dst_start..batch_dst_start + row_len]; + + for row in 0..num_rows { + let row_start = batch_src_start + row * row_stride; + let row_data = &src[row_start..row_start + row_len]; + + let simd_len = row_len / lanes * lanes; + + let mut i = 0; + while i < simd_len { + unsafe { + let s = vload_unaligned(row_data.as_ptr().add(i)); + let d = vload_unaligned(batch_dst.as_ptr().add(i)); + vstore_unaligned::(batch_dst.as_mut_ptr().add(i), d + s); + } + i += lanes; + } + + for j in simd_len..row_len { + batch_dst[j] += row_data[j]; + } + } + } +} + +// ============================================================================ +// Row-wise sum (last-dim reduction) +// ============================================================================ + +/// Sum each row, storing results in output slice. +/// Used for last-dim reductions. +#[inline] +pub fn sum_rows_f32(src: &[f32], dst: &mut [f32], num_rows: usize, row_len: usize) { + debug_assert_eq!(dst.len(), num_rows, "dst length must equal num_rows"); + debug_assert!( + src.len() >= num_rows * row_len, + "src too short: need {} elements, got {}", + num_rows * row_len, + src.len() + ); + for (row, dst_val) in dst.iter_mut().enumerate() { + let row_start = row * row_len; + let row_data = &src[row_start..row_start + row_len]; + *dst_val = macerator_sum(row_data); + } +} + +// ============================================================================ +// Max/Min reduction +// ============================================================================ + +/// Find the maximum element in a f32 slice using SIMD. +#[inline] +pub fn max_f32(data: &[f32]) -> f32 { + macerator_max(data, f32::NEG_INFINITY) +} + +/// Find the minimum element in a f32 slice using SIMD. +#[inline] +pub fn min_f32(data: &[f32]) -> f32 { + macerator_min(data, f32::INFINITY) +} + +#[macerator::with_simd] +fn macerator_max(mut xs: &[F], init: F) -> F { + let lanes = F::lanes::(); + let mut acc = init.splat::(); + + while xs.len() >= lanes { + let v = unsafe { vload_unaligned(xs.as_ptr()) }; + acc = acc.max(v); + xs = &xs[lanes..]; + } + + let mut result = acc.reduce_max(); + for &x in xs { + if x > result { + result = x; + } + } + result +} + +#[macerator::with_simd] +fn macerator_min(mut xs: &[F], init: F) -> F { + let lanes = F::lanes::(); + let mut acc = init.splat::(); + + while xs.len() >= lanes { + let v = unsafe { vload_unaligned(xs.as_ptr()) }; + acc = acc.min(v); + xs = &xs[lanes..]; + } + + let mut result = acc.reduce_min(); + for &x in xs { + if x < result { + result = x; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sum_f32() { + let data: Vec = (0..1000).map(|i| i as f32).collect(); + let expected: f32 = data.iter().sum(); + let result = sum_f32(&data); + assert!((result - expected).abs() < 0.01); + } + + #[test] + fn test_sum_f32_empty() { + let data: Vec = vec![]; + assert_eq!(sum_f32(&data), 0.0); + } + + #[test] + fn test_sum_f32_small() { + let data = vec![1.0, 2.0, 3.0]; + assert_eq!(sum_f32(&data), 6.0); + } + + #[test] + fn test_scatter_add_f32() { + // Simulate reducing [3, 4] along dim=0 -> [1, 4] + let src = vec![ + 1.0, 2.0, 3.0, 4.0, // row 0 + 5.0, 6.0, 7.0, 8.0, // row 1 + 9.0, 10.0, 11.0, 12.0, // row 2 + ]; + let mut dst = vec![0.0; 4]; + + scatter_add_f32(&src, &mut dst, 3, 4, 4); + + assert_eq!(dst, vec![15.0, 18.0, 21.0, 24.0]); + } + + #[test] + fn test_sum_rows_f32() { + // Simulate reducing [3, 4] along dim=1 -> [3, 1] + let src = vec![ + 1.0, 2.0, 3.0, 4.0, // row 0 -> 10 + 5.0, 6.0, 7.0, 8.0, // row 1 -> 26 + 9.0, 10.0, 11.0, 12.0, // row 2 -> 42 + ]; + let mut dst = vec![0.0; 3]; + + sum_rows_f32(&src, &mut dst, 3, 4); + + assert_eq!(dst, vec![10.0, 26.0, 42.0]); + } + + #[test] + fn test_max_f32() { + let data: Vec = (0..1000).map(|i| i as f32).collect(); + assert_eq!(max_f32(&data), 999.0); + } + + #[test] + fn test_max_f32_small() { + let data = vec![3.0, 1.0, 4.0, 1.0, 5.0]; + assert_eq!(max_f32(&data), 5.0); + } + + #[test] + fn test_max_f32_negative() { + let data = vec![-3.0, -1.0, -4.0, -1.0, -5.0]; + assert_eq!(max_f32(&data), -1.0); + } + + #[test] + fn test_min_f32() { + let data: Vec = (0..1000).map(|i| i as f32).collect(); + assert_eq!(min_f32(&data), 0.0); + } + + #[test] + fn test_min_f32_small() { + let data = vec![3.0, 1.0, 4.0, 1.0, 5.0]; + assert_eq!(min_f32(&data), 1.0); + } + + #[test] + fn test_min_f32_negative() { + let data = vec![-3.0, -1.0, -4.0, -1.0, -5.0]; + assert_eq!(min_f32(&data), -5.0); + } +} diff --git a/crates/burn-flex/src/simd/mod.rs b/crates/burn-flex/src/simd/mod.rs new file mode 100644 index 0000000..9b5440c --- /dev/null +++ b/crates/burn-flex/src/simd/mod.rs @@ -0,0 +1,42 @@ +//! SIMD-optimized kernels for tensor operations. +//! +//! Provides portable SIMD implementations via `macerator` with automatic +//! dispatch to the best available instruction set: +//! - aarch64: NEON +//! - x86_64: AVX2, AVX512, SSE +//! - wasm32: SIMD128 +//! - Other: Scalar fallback +//! +//! Enable with the `simd` feature flag (enabled by default). + +// Portable SIMD kernels using macerator (reductions, scatter-add) +#[cfg(feature = "simd")] +pub mod kernels; + +// SIMD-aligned memory allocation +#[cfg(feature = "simd")] +pub mod aligned; + +// When simd feature enabled: use portable macerator for binary/comparison/bool ops +#[cfg(feature = "simd")] +mod portable; + +#[cfg(feature = "simd")] +pub use portable::{ + CmpOp, abs_inplace_f32, add_inplace_f32, add_shared_row_inplace_f32, bool_and_inplace_u8, + bool_and_u8, bool_not_inplace_u8, bool_not_u8, bool_or_inplace_u8, bool_or_u8, + bool_xor_inplace_u8, bool_xor_u8, cmp_f32, cmp_scalar_f32, div_inplace_f32, + div_shared_row_inplace_f32, mask_fill_f32, mask_fill_f64, mask_fill_i64, mask_fill_u8, + mask_where_f32, mask_where_f64, mask_where_i64, mask_where_u8, mul_inplace_f32, + mul_shared_row_inplace_f32, recip_inplace_f32, sub_inplace_f32, sub_shared_row_inplace_f32, +}; + +// When simd feature disabled: use scalar fallback (bool ops + CmpOp only) +#[cfg(not(feature = "simd"))] +mod scalar; + +#[cfg(not(feature = "simd"))] +pub use scalar::{ + CmpOp, bool_and_inplace_u8, bool_and_u8, bool_not_inplace_u8, bool_not_u8, bool_or_inplace_u8, + bool_or_u8, bool_xor_inplace_u8, bool_xor_u8, +}; diff --git a/crates/burn-flex/src/simd/portable.rs b/crates/burn-flex/src/simd/portable.rs new file mode 100644 index 0000000..4596777 --- /dev/null +++ b/crates/burn-flex/src/simd/portable.rs @@ -0,0 +1,1387 @@ +//! Portable SIMD kernels using macerator. +//! +//! Replaces platform-specific implementations (neon.rs) with a single +//! portable implementation that auto-dispatches to NEON/AVX2/SSE/SIMD128/scalar. + +use macerator::{Scalar, Simd, VBitAnd, VBitOr, VBitXor, vload_unaligned, vstore_unaligned}; + +#[cfg(feature = "rayon")] +use rayon::prelude::*; + +/// Threshold for parallel execution (elements). +/// For memory-bound operations, parallelism helps when data exceeds L3 cache. +#[cfg(feature = "rayon")] +const PARALLEL_THRESHOLD: usize = 4 * 1024 * 1024; + +#[cfg(feature = "rayon")] +const CHUNK_SIZE: usize = 4096; + +// ============================================================================ +// f32 in-place binary ops +// ============================================================================ + +macro_rules! define_inplace_f32_op { + ($pub_fn:ident, $seq_fn:ident, $par_fn:ident, $op:tt) => { + #[inline] + pub fn $pub_fn(a: &mut [f32], b: &[f32]) { + debug_assert_eq!(a.len(), b.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + $par_fn(a, b); + return; + } + + $seq_fn(a, b); + } + + #[macerator::with_simd] + #[allow(clippy::assign_op_pattern)] + fn $seq_fn(a: &mut [f32], b: &[f32]) { + let lanes = S::lanes32(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned(a.as_ptr().add(i)); + let vb = vload_unaligned(b.as_ptr().add(i)); + vstore_unaligned::(a.as_mut_ptr().add(i), va $op vb); + } + i += lanes; + } + + for j in simd_len..len { + a[j] = a[j] $op b[j]; + } + } + + #[cfg(feature = "rayon")] + fn $par_fn(a: &mut [f32], b: &[f32]) { + a.par_chunks_mut(CHUNK_SIZE) + .zip(b.par_chunks(CHUNK_SIZE)) + .for_each(|(a_chunk, b_chunk)| { + $seq_fn(a_chunk, b_chunk); + }); + } + }; +} + +define_inplace_f32_op!(add_inplace_f32, add_inplace_f32_seq, add_inplace_f32_par, +); +define_inplace_f32_op!(sub_inplace_f32, sub_inplace_f32_seq, sub_inplace_f32_par, -); +define_inplace_f32_op!(mul_inplace_f32, mul_inplace_f32_seq, mul_inplace_f32_par, *); +define_inplace_f32_op!(div_inplace_f32, div_inplace_f32_seq, div_inplace_f32_par, /); + +// ============================================================================ +// f32 shared-row broadcast binary ops +// ============================================================================ +// +// Pattern: `dst[r * row_len + j] = dst[r * row_len + j] op row[j]` for +// all `r in 0..num_rows, j in 0..row_len`. That is, a single contiguous +// `row` is broadcast and combined with each of `num_rows` consecutive +// rows in `dst`. This is what `gamma.unsqueeze() * x` and friends +// produce after broadcast expansion. +// +// We call the SIMD dispatch once for the entire walk so that +// `#[macerator::with_simd]`'s feature detection pays once, not once per +// row. Calling the normal `add_inplace_f32` in a `0..num_rows` loop +// gives the right answer but costs ~55k dispatches on the typical +// layer_norm shape; see issue #64 item 2 benchmarks. + +macro_rules! define_shared_row_f32_op { + ($pub_fn:ident, $seq_fn:ident, $par_fn:ident, $op:tt) => { + #[inline] + pub fn $pub_fn(dst: &mut [f32], row: &[f32]) { + let row_len = row.len(); + if row_len == 0 || dst.is_empty() { + return; + } + debug_assert_eq!( + dst.len() % row_len, + 0, + "shared-row broadcast: dst length must be a multiple of row length" + ); + + #[cfg(feature = "rayon")] + if dst.len() >= PARALLEL_THRESHOLD { + $par_fn(dst, row); + return; + } + + $seq_fn(dst, row); + } + + #[macerator::with_simd] + #[allow(clippy::assign_op_pattern)] + fn $seq_fn(dst: &mut [f32], row: &[f32]) { + let lanes = S::lanes32(); + let row_len = row.len(); + let simd_len = row_len / lanes * lanes; + let num_rows = dst.len() / row_len; + + for r in 0..num_rows { + let base = r * row_len; + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned::(dst.as_ptr().add(base + i)); + let vb = vload_unaligned::(row.as_ptr().add(i)); + vstore_unaligned::( + dst.as_mut_ptr().add(base + i), + va $op vb, + ); + } + i += lanes; + } + for j in simd_len..row_len { + dst[base + j] = dst[base + j] $op row[j]; + } + } + } + + #[cfg(feature = "rayon")] + fn $par_fn(dst: &mut [f32], row: &[f32]) { + let row_len = row.len(); + // Chunk on row boundaries so each worker sees whole rows. + let rows_per_chunk = (CHUNK_SIZE / row_len).max(1); + let chunk_elems = rows_per_chunk * row_len; + dst.par_chunks_mut(chunk_elems).for_each(|chunk| { + $seq_fn(chunk, row); + }); + } + }; +} + +define_shared_row_f32_op!( + add_shared_row_inplace_f32, + add_shared_row_inplace_f32_seq, + add_shared_row_inplace_f32_par, + + +); +define_shared_row_f32_op!( + sub_shared_row_inplace_f32, + sub_shared_row_inplace_f32_seq, + sub_shared_row_inplace_f32_par, + - +); +define_shared_row_f32_op!( + mul_shared_row_inplace_f32, + mul_shared_row_inplace_f32_seq, + mul_shared_row_inplace_f32_par, + * +); +define_shared_row_f32_op!( + div_shared_row_inplace_f32, + div_shared_row_inplace_f32_seq, + div_shared_row_inplace_f32_par, + / +); + +// ============================================================================ +// f32 in-place unary ops (SIMD-accelerated) +// ============================================================================ + +#[inline] +pub fn abs_inplace_f32(a: &mut [f32]) { + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + abs_inplace_f32_par(a); + return; + } + + abs_inplace_f32_seq(a); +} + +#[macerator::with_simd] +fn abs_inplace_f32_seq(a: &mut [f32]) { + let lanes = S::lanes32(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let mut i = 0; + while i < simd_len { + unsafe { + let v = vload_unaligned::(a.as_ptr().add(i)); + vstore_unaligned::(a.as_mut_ptr().add(i), v.abs()); + } + i += lanes; + } + + for v in &mut a[simd_len..len] { + *v = v.abs(); + } +} + +#[cfg(feature = "rayon")] +fn abs_inplace_f32_par(a: &mut [f32]) { + a.par_chunks_mut(CHUNK_SIZE).for_each(|chunk| { + abs_inplace_f32_seq(chunk); + }); +} + +#[inline] +pub fn recip_inplace_f32(a: &mut [f32]) { + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + recip_inplace_f32_par(a); + return; + } + + recip_inplace_f32_seq(a); +} + +#[macerator::with_simd] +fn recip_inplace_f32_seq(a: &mut [f32]) { + let lanes = S::lanes32(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + // Use exact SIMD division (not VRecip which is approximate on NEON/SSE) + let ones = 1.0f32.splat::(); + + let mut i = 0; + while i < simd_len { + unsafe { + let v = vload_unaligned::(a.as_ptr().add(i)); + vstore_unaligned::(a.as_mut_ptr().add(i), ones / v); + } + i += lanes; + } + + for v in &mut a[simd_len..len] { + *v = 1.0 / *v; + } +} + +#[cfg(feature = "rayon")] +fn recip_inplace_f32_par(a: &mut [f32]) { + a.par_chunks_mut(CHUNK_SIZE).for_each(|chunk| { + recip_inplace_f32_seq(chunk); + }); +} + +// ============================================================================ +// f32 comparison ops +// ============================================================================ + +#[derive(Clone, Copy)] +pub enum CmpOp { + Gt, + Ge, + Lt, + Le, + Eq, + Ne, +} + +#[inline] +pub fn cmp_f32(a: &[f32], b: &[f32], out: &mut [u8], op: CmpOp) { + debug_assert_eq!(a.len(), b.len()); + debug_assert_eq!(a.len(), out.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + cmp_f32_par(a, b, out, op); + return; + } + + cmp_f32_seq(a, b, out, op); +} + +/// Comparison kernel using simple loops that LLVM autovectorizes. +/// +/// Autovectorization outperforms explicit SIMD here because comparisons +/// produce u8 output from f32 input (4:1 size ratio). LLVM batches 16+ +/// comparisons and packs results into a single wide vector store, while +/// explicit SIMD (store_as_bool) writes only `lanes` bytes per iteration. +#[inline] +fn cmp_f32_seq(a: &[f32], b: &[f32], out: &mut [u8], op: CmpOp) { + match op { + CmpOp::Gt => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a > *b) as u8; + } + } + CmpOp::Ge => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a >= *b) as u8; + } + } + CmpOp::Lt => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a < *b) as u8; + } + } + CmpOp::Le => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a <= *b) as u8; + } + } + CmpOp::Eq => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a == *b) as u8; + } + } + CmpOp::Ne => { + for ((a, b), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = (*a != *b) as u8; + } + } + } +} + +#[cfg(feature = "rayon")] +fn cmp_f32_par(a: &[f32], b: &[f32], out: &mut [u8], op: CmpOp) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(a.len()); + cmp_f32_seq(&a[start..end], &b[start..end], out_chunk, op); + }); +} + +#[inline] +pub fn cmp_scalar_f32(a: &[f32], scalar: f32, out: &mut [u8], op: CmpOp) { + debug_assert_eq!(a.len(), out.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + cmp_scalar_f32_par(a, scalar, out, op); + return; + } + + cmp_scalar_f32_seq(a, scalar, out, op); +} + +/// Scalar comparison kernel using simple loops that LLVM autovectorizes. +/// See `cmp_f32_seq` for rationale. +#[inline] +fn cmp_scalar_f32_seq(a: &[f32], scalar: f32, out: &mut [u8], op: CmpOp) { + match op { + CmpOp::Gt => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a > scalar) as u8; + } + } + CmpOp::Ge => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a >= scalar) as u8; + } + } + CmpOp::Lt => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a < scalar) as u8; + } + } + CmpOp::Le => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a <= scalar) as u8; + } + } + CmpOp::Eq => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a == scalar) as u8; + } + } + CmpOp::Ne => { + for (a, o) in a.iter().zip(out.iter_mut()) { + *o = (*a != scalar) as u8; + } + } + } +} + +#[cfg(feature = "rayon")] +fn cmp_scalar_f32_par(a: &[f32], scalar: f32, out: &mut [u8], op: CmpOp) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(a.len()); + cmp_scalar_f32_seq(&a[start..end], scalar, out_chunk, op); + }); +} + +// ============================================================================ +// u8 boolean ops +// ============================================================================ + +macro_rules! define_bool_binary_u8_op { + ($pub_fn:ident, $seq_fn:ident, $par_fn:ident, + $inplace_pub:ident, $inplace_seq:ident, $inplace_par:ident, + $trait:ident, $method:ident, $op:tt) => { + #[inline] + pub fn $pub_fn(a: &[u8], b: &[u8], out: &mut [u8]) { + debug_assert_eq!(a.len(), b.len()); + debug_assert_eq!(a.len(), out.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + $par_fn(a, b, out); + return; + } + + $seq_fn(a, b, out); + } + + #[macerator::with_simd] + fn $seq_fn(a: &[u8], b: &[u8], out: &mut [u8]) { + let lanes = S::lanes8(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned::(a.as_ptr().add(i)); + let vb = vload_unaligned::(b.as_ptr().add(i)); + vstore_unaligned::( + out.as_mut_ptr().add(i), + ::$method::(va, vb), + ); + } + i += lanes; + } + + for j in simd_len..len { + out[j] = a[j] $op b[j]; + } + } + + #[cfg(feature = "rayon")] + fn $par_fn(a: &[u8], b: &[u8], out: &mut [u8]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(a.len()); + $seq_fn(&a[start..end], &b[start..end], out_chunk); + }); + } + + #[inline] + pub fn $inplace_pub(a: &mut [u8], b: &[u8]) { + debug_assert_eq!(a.len(), b.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + $inplace_par(a, b); + return; + } + + $inplace_seq(a, b); + } + + #[allow(clippy::assign_op_pattern)] + #[macerator::with_simd] + fn $inplace_seq(a: &mut [u8], b: &[u8]) { + let lanes = S::lanes8(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned::(a.as_ptr().add(i)); + let vb = vload_unaligned::(b.as_ptr().add(i)); + vstore_unaligned::( + a.as_mut_ptr().add(i), + ::$method::(va, vb), + ); + } + i += lanes; + } + + for j in simd_len..len { + a[j] = a[j] $op b[j]; + } + } + + #[cfg(feature = "rayon")] + fn $inplace_par(a: &mut [u8], b: &[u8]) { + a.par_chunks_mut(CHUNK_SIZE) + .zip(b.par_chunks(CHUNK_SIZE)) + .for_each(|(a_chunk, b_chunk)| { + $inplace_seq(a_chunk, b_chunk); + }); + } + }; +} + +define_bool_binary_u8_op!( + bool_and_u8, bool_and_u8_seq, bool_and_u8_par, + bool_and_inplace_u8, bool_and_inplace_u8_seq, bool_and_inplace_u8_par, + VBitAnd, vbitand, &); +define_bool_binary_u8_op!( + bool_or_u8, bool_or_u8_seq, bool_or_u8_par, + bool_or_inplace_u8, bool_or_inplace_u8_seq, bool_or_inplace_u8_par, + VBitOr, vbitor, |); +define_bool_binary_u8_op!( + bool_xor_u8, bool_xor_u8_seq, bool_xor_u8_par, + bool_xor_inplace_u8, bool_xor_inplace_u8_seq, bool_xor_inplace_u8_par, + VBitXor, vbitxor, ^); + +// Boolean NOT is special (unary), implemented separately. + +#[inline] +pub fn bool_not_u8(a: &[u8], out: &mut [u8]) { + debug_assert_eq!(a.len(), out.len()); + + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + bool_not_u8_par(a, out); + return; + } + + bool_not_u8_seq(a, out); +} + +#[macerator::with_simd] +fn bool_not_u8_seq(a: &[u8], out: &mut [u8]) { + let lanes = S::lanes8(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let zeros = 0u8.splat::(); + + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned::(a.as_ptr().add(i)); + let mask = va.eq(zeros); + mask.store_as_bool(out.as_mut_ptr().add(i) as *mut bool); + } + i += lanes; + } + + for j in simd_len..len { + out[j] = (a[j] == 0) as u8; + } +} + +#[cfg(feature = "rayon")] +fn bool_not_u8_par(a: &[u8], out: &mut [u8]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(a.len()); + bool_not_u8_seq(&a[start..end], out_chunk); + }); +} + +#[inline] +pub fn bool_not_inplace_u8(a: &mut [u8]) { + #[cfg(feature = "rayon")] + if a.len() >= PARALLEL_THRESHOLD { + bool_not_inplace_u8_par(a); + return; + } + + bool_not_inplace_u8_seq(a); +} + +#[macerator::with_simd] +fn bool_not_inplace_u8_seq(a: &mut [u8]) { + let lanes = S::lanes8(); + let len = a.len(); + let simd_len = len / lanes * lanes; + + let zeros = 0u8.splat::(); + + let mut i = 0; + while i < simd_len { + unsafe { + let va = vload_unaligned::(a.as_ptr().add(i)); + let mask = va.eq(zeros); + mask.store_as_bool(a.as_mut_ptr().add(i) as *mut bool); + } + i += lanes; + } + + for v in &mut a[simd_len..len] { + *v = (*v == 0) as u8; + } +} + +#[cfg(feature = "rayon")] +fn bool_not_inplace_u8_par(a: &mut [u8]) { + a.par_chunks_mut(CHUNK_SIZE).for_each(|chunk| { + bool_not_inplace_u8_seq(chunk); + }); +} + +// ============================================================================ +// Mask select (mask_where / mask_fill) via bitwise blend +// ============================================================================ + +/// Conditional select: `out[i] = if mask[i] != 0 { value[i] } else { tensor[i] }` +/// +/// Operates on f32 data reinterpreted as u32 for bitwise blend. +/// Uses SIMD bitwise ops: `(mask & value) | (!mask & tensor)`. +#[inline] +pub fn mask_where_f32(tensor: &[f32], mask: &[u8], value: &[f32], out: &mut [f32]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), value.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let v = bytemuck::cast_slice::(value); + let o = bytemuck::cast_slice_mut::(out); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_where_u32_par(t, mask, v, o); + return; + } + + mask_blend_u32(t, mask, v, o); +} + +/// Conditional select for f64. +#[inline] +pub fn mask_where_f64(tensor: &[f64], mask: &[u8], value: &[f64], out: &mut [f64]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), value.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let v = bytemuck::cast_slice::(value); + let o = bytemuck::cast_slice_mut::(out); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_where_u64_par(t, mask, v, o); + return; + } + + mask_blend_u64(t, mask, v, o); +} + +/// Conditional select for i64. +#[inline] +pub fn mask_where_i64(tensor: &[i64], mask: &[u8], value: &[i64], out: &mut [i64]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), value.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let v = bytemuck::cast_slice::(value); + let o = bytemuck::cast_slice_mut::(out); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_where_u64_par(t, mask, v, o); + return; + } + + mask_blend_u64(t, mask, v, o); +} + +/// Conditional select for u8 (bool tensors). +#[inline] +pub fn mask_where_u8(tensor: &[u8], mask: &[u8], value: &[u8], out: &mut [u8]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), value.len()); + debug_assert_eq!(tensor.len(), out.len()); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_where_u8_par(tensor, mask, value, out); + return; + } + + mask_where_u8_seq(tensor, mask, value, out); +} + +/// Conditional fill: `out[i] = if mask[i] != 0 { fill_value } else { tensor[i] }` +#[inline] +pub fn mask_fill_f32(tensor: &[f32], mask: &[u8], fill_value: f32, out: &mut [f32]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let o = bytemuck::cast_slice_mut::(out); + let fill_bits = fill_value.to_bits(); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_fill_u32_par(t, mask, fill_bits, o); + return; + } + + mask_blend_fill_u32(t, mask, fill_bits, o); +} + +/// Conditional fill for f64. +#[inline] +pub fn mask_fill_f64(tensor: &[f64], mask: &[u8], fill_value: f64, out: &mut [f64]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let o = bytemuck::cast_slice_mut::(out); + let fill_bits = fill_value.to_bits(); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_fill_u64_par(t, mask, fill_bits, o); + return; + } + + mask_blend_fill_u64(t, mask, fill_bits, o); +} + +/// Conditional fill for i64. +#[inline] +pub fn mask_fill_i64(tensor: &[i64], mask: &[u8], fill_value: i64, out: &mut [i64]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), out.len()); + + let t = bytemuck::cast_slice::(tensor); + let o = bytemuck::cast_slice_mut::(out); + let fill_bits = fill_value as u64; + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_fill_u64_par(t, mask, fill_bits, o); + return; + } + + mask_blend_fill_u64(t, mask, fill_bits, o); +} + +/// Conditional fill for u8 (bool tensors). +#[inline] +pub fn mask_fill_u8(tensor: &[u8], mask: &[u8], fill_value: u8, out: &mut [u8]) { + debug_assert_eq!(tensor.len(), mask.len()); + debug_assert_eq!(tensor.len(), out.len()); + + #[cfg(feature = "rayon")] + if tensor.len() >= PARALLEL_THRESHOLD { + mask_fill_u8_par(tensor, mask, fill_value, out); + return; + } + + mask_fill_u8_seq(tensor, mask, fill_value, out); +} + +// -- Branchless bitwise blend kernels -- +// +// These use a tight branchless loop that LLVM autovectorizes with native +// u8->u32/u64 widening instructions (NEON ushll, AVX2 vpmovzxbd). +// +// Mask values must be exactly 0 or 1 (Burn bool tensor invariant). +// wrapping_sub(0, 0)=0x00, wrapping_sub(0, 1)=0xFF..FF. Other values +// produce partial masks and corrupt output. + +#[inline] +fn mask_blend_u32(tensor: &[u32], mask: &[u8], value: &[u32], out: &mut [u32]) { + for i in 0..tensor.len() { + let m = 0u32.wrapping_sub(mask[i] as u32); + out[i] = (value[i] & m) | (tensor[i] & !m); + } +} + +#[inline] +fn mask_blend_fill_u32(tensor: &[u32], mask: &[u8], fill_bits: u32, out: &mut [u32]) { + for i in 0..tensor.len() { + let m = 0u32.wrapping_sub(mask[i] as u32); + out[i] = (fill_bits & m) | (tensor[i] & !m); + } +} + +#[inline] +fn mask_blend_u64(tensor: &[u64], mask: &[u8], value: &[u64], out: &mut [u64]) { + for i in 0..tensor.len() { + let m = 0u64.wrapping_sub(mask[i] as u64); + out[i] = (value[i] & m) | (tensor[i] & !m); + } +} + +#[inline] +fn mask_blend_fill_u64(tensor: &[u64], mask: &[u8], fill_bits: u64, out: &mut [u64]) { + for i in 0..tensor.len() { + let m = 0u64.wrapping_sub(mask[i] as u64); + out[i] = (fill_bits & m) | (tensor[i] & !m); + } +} + +#[cfg(feature = "rayon")] +fn mask_where_u32_par(tensor: &[u32], mask: &[u8], value: &[u32], out: &mut [u32]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_blend_u32( + &tensor[start..end], + &mask[start..end], + &value[start..end], + out_chunk, + ); + }); +} + +#[cfg(feature = "rayon")] +fn mask_fill_u32_par(tensor: &[u32], mask: &[u8], fill_bits: u32, out: &mut [u32]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_blend_fill_u32(&tensor[start..end], &mask[start..end], fill_bits, out_chunk); + }); +} + +#[cfg(feature = "rayon")] +fn mask_where_u64_par(tensor: &[u64], mask: &[u8], value: &[u64], out: &mut [u64]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_blend_u64( + &tensor[start..end], + &mask[start..end], + &value[start..end], + out_chunk, + ); + }); +} + +#[cfg(feature = "rayon")] +fn mask_fill_u64_par(tensor: &[u64], mask: &[u8], fill_bits: u64, out: &mut [u64]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_blend_fill_u64(&tensor[start..end], &mask[start..end], fill_bits, out_chunk); + }); +} + +// -- u8 SIMD kernels (for bool tensors) -- + +#[macerator::with_simd] +fn mask_where_u8_seq(tensor: &[u8], mask: &[u8], value: &[u8], out: &mut [u8]) { + let lanes = S::lanes8(); + let len = tensor.len(); + let simd_len = len / lanes * lanes; + + // SIMD subtract wraps: 0-0=0x00, 0-1=0xFF + let zeros = 0u8.splat::(); + + let mut i = 0; + while i < simd_len { + unsafe { + let vm_raw = vload_unaligned::(mask.as_ptr().add(i)); + let vm = zeros - vm_raw; // 0->0x00, 1->0xFF + let vt = vload_unaligned::(tensor.as_ptr().add(i)); + let vv = vload_unaligned::(value.as_ptr().add(i)); + let selected = (vm & vv) | (!vm & vt); + vstore_unaligned::(out.as_mut_ptr().add(i), selected); + } + i += lanes; + } + + for j in simd_len..len { + let m = 0u8.wrapping_sub(mask[j]); + out[j] = (m & value[j]) | (!m & tensor[j]); + } +} + +#[cfg(feature = "rayon")] +fn mask_where_u8_par(tensor: &[u8], mask: &[u8], value: &[u8], out: &mut [u8]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_where_u8_seq( + &tensor[start..end], + &mask[start..end], + &value[start..end], + out_chunk, + ); + }); +} + +#[macerator::with_simd] +fn mask_fill_u8_seq(tensor: &[u8], mask: &[u8], fill_value: u8, out: &mut [u8]) { + let lanes = S::lanes8(); + let len = tensor.len(); + let simd_len = len / lanes * lanes; + let vfill = fill_value.splat::(); + let zeros = 0u8.splat::(); + + let mut i = 0; + while i < simd_len { + unsafe { + let vm_raw = vload_unaligned::(mask.as_ptr().add(i)); + let vm = zeros - vm_raw; // 0->0x00, 1->0xFF + let vt = vload_unaligned::(tensor.as_ptr().add(i)); + let selected = (vm & vfill) | (!vm & vt); + vstore_unaligned::(out.as_mut_ptr().add(i), selected); + } + i += lanes; + } + + for j in simd_len..len { + let m = 0u8.wrapping_sub(mask[j]); + out[j] = (m & fill_value) | (!m & tensor[j]); + } +} + +#[cfg(feature = "rayon")] +fn mask_fill_u8_par(tensor: &[u8], mask: &[u8], fill_value: u8, out: &mut [u8]) { + out.par_chunks_mut(CHUNK_SIZE) + .enumerate() + .for_each(|(chunk_idx, out_chunk)| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(tensor.len()); + mask_fill_u8_seq( + &tensor[start..end], + &mask[start..end], + fill_value, + out_chunk, + ); + }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_inplace_f32() { + let mut a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; + let b = [10.0f32, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0]; + add_inplace_f32(&mut a, &b); + assert_eq!(a, [11.0, 22.0, 33.0, 44.0, 55.0, 66.0, 77.0]); + } + + #[test] + fn test_sub_inplace_f32() { + let mut a = [10.0f32, 20.0, 30.0, 40.0, 50.0]; + let b = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + sub_inplace_f32(&mut a, &b); + assert_eq!(a, [9.0, 18.0, 27.0, 36.0, 45.0]); + } + + #[test] + fn test_mul_inplace_f32() { + let mut a = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + let b = [2.0f32, 2.0, 2.0, 2.0, 2.0]; + mul_inplace_f32(&mut a, &b); + assert_eq!(a, [2.0, 4.0, 6.0, 8.0, 10.0]); + } + + #[test] + fn test_div_inplace_f32() { + let mut a = [10.0f32, 20.0, 30.0, 40.0]; + let b = [2.0f32, 4.0, 5.0, 8.0]; + div_inplace_f32(&mut a, &b); + assert_eq!(a, [5.0, 5.0, 6.0, 5.0]); + } + + #[test] + fn test_cmp_gt_f32() { + let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; + let b = [2.0f32, 2.0, 2.0, 4.0, 4.0, 4.0, 4.0]; + let mut out = [0u8; 7]; + cmp_f32(&a, &b, &mut out, CmpOp::Gt); + assert_eq!(out, [0, 0, 1, 0, 1, 1, 1]); + } + + #[test] + fn test_cmp_ge_f32() { + let a = [1.0f32, 2.0, 3.0, 4.0]; + let b = [2.0f32, 2.0, 2.0, 5.0]; + let mut out = [0u8; 4]; + cmp_f32(&a, &b, &mut out, CmpOp::Ge); + assert_eq!(out, [0, 1, 1, 0]); + } + + #[test] + fn test_cmp_eq_f32() { + let a = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + let b = [1.0f32, 3.0, 3.0, 5.0, 5.0]; + let mut out = [0u8; 5]; + cmp_f32(&a, &b, &mut out, CmpOp::Eq); + assert_eq!(out, [1, 0, 1, 0, 1]); + } + + #[test] + fn test_cmp_ne_f32() { + let a = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + let b = [1.0f32, 3.0, 3.0, 5.0, 5.0]; + let mut out = [0u8; 5]; + cmp_f32(&a, &b, &mut out, CmpOp::Ne); + assert_eq!(out, [0, 1, 0, 1, 0]); + } + + #[test] + fn test_cmp_scalar_gt_f32() { + let a = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + let mut out = [0u8; 5]; + cmp_scalar_f32(&a, 3.0, &mut out, CmpOp::Gt); + assert_eq!(out, [0, 0, 0, 1, 1]); + } + + #[test] + fn test_bool_not_u8() { + let a = [1u8, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0]; + let mut out = [0u8; 18]; + bool_not_u8(&a, &mut out); + let expected = [0u8, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1]; + assert_eq!(out, expected); + } + + #[test] + fn test_bool_not_inplace_u8() { + let mut a = [1u8, 0, 1, 0]; + bool_not_inplace_u8(&mut a); + assert_eq!(a, [0, 1, 0, 1]); + } + + #[test] + fn test_bool_and_u8() { + let a = [1u8, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0]; + let b = [1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1]; + let mut out = [0u8; 18]; + bool_and_u8(&a, &b, &mut out); + let expected = [1u8, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0]; + assert_eq!(out, expected); + } + + #[test] + fn test_bool_or_u8() { + let a = [1u8, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0]; + let b = [1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1]; + let mut out = [0u8; 18]; + bool_or_u8(&a, &b, &mut out); + let expected = [1u8, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1]; + assert_eq!(out, expected); + } + + #[test] + fn test_bool_xor_u8() { + let a = [1u8, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0]; + let b = [1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1]; + let mut out = [0u8; 18]; + bool_xor_u8(&a, &b, &mut out); + let expected = [0u8, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]; + assert_eq!(out, expected); + } + + #[test] + fn test_bool_and_inplace_u8() { + let mut a = [1u8, 1, 0, 0]; + let b = [1u8, 0, 1, 0]; + bool_and_inplace_u8(&mut a, &b); + assert_eq!(a, [1, 0, 0, 0]); + } + + #[test] + fn test_bool_or_inplace_u8() { + let mut a = [1u8, 1, 0, 0]; + let b = [1u8, 0, 1, 0]; + bool_or_inplace_u8(&mut a, &b); + assert_eq!(a, [1, 1, 1, 0]); + } + + #[test] + fn test_bool_xor_inplace_u8() { + let mut a = [1u8, 1, 0, 0]; + let b = [1u8, 0, 1, 0]; + bool_xor_inplace_u8(&mut a, &b); + assert_eq!(a, [0, 1, 1, 0]); + } + + #[test] + fn test_abs_inplace_f32() { + let mut a = [-3.0f32, -1.0, 0.0, 1.0, 3.0, -5.0, 7.0]; + abs_inplace_f32(&mut a); + assert_eq!(a, [3.0, 1.0, 0.0, 1.0, 3.0, 5.0, 7.0]); + } + + #[test] + fn test_recip_inplace_f32() { + let mut a = [1.0f32, 2.0, 4.0, 0.5, 10.0]; + recip_inplace_f32(&mut a); + assert_eq!(a, [1.0, 0.5, 0.25, 2.0, 0.1]); + } + + // ================================================================ + // mask_where / mask_fill tests + // ================================================================ + + #[test] + fn test_mask_where_f32_basic() { + let tensor = [1.0f32, 2.0, 3.0, 4.0]; + let mask = [1u8, 0, 1, 0]; + let value = [10.0f32, 20.0, 30.0, 40.0]; + let mut out = [0.0f32; 4]; + mask_where_f32(&tensor, &mask, &value, &mut out); + assert_eq!(out, [10.0, 2.0, 30.0, 4.0]); + } + + #[test] + fn test_mask_where_f32_all_true() { + let tensor = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let mask = [1u8; 9]; + let value = [10.0f32, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0]; + let mut out = [0.0f32; 9]; + mask_where_f32(&tensor, &mask, &value, &mut out); + assert_eq!(out, value); + } + + #[test] + fn test_mask_where_f32_all_false() { + let tensor = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let mask = [0u8; 9]; + let value = [10.0f32, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0]; + let mut out = [0.0f32; 9]; + mask_where_f32(&tensor, &mask, &value, &mut out); + assert_eq!(out, tensor); + } + + #[test] + fn test_mask_where_f64_basic() { + let tensor = [1.0f64, 2.0, 3.0]; + let mask = [0u8, 1, 0]; + let value = [10.0f64, 20.0, 30.0]; + let mut out = [0.0f64; 3]; + mask_where_f64(&tensor, &mask, &value, &mut out); + assert_eq!(out, [1.0, 20.0, 3.0]); + } + + #[test] + fn test_mask_where_i64_basic() { + let tensor = [10i64, 20, 30, 40, 50]; + let mask = [1u8, 0, 1, 0, 1]; + let value = [-1i64, -2, -3, -4, -5]; + let mut out = [0i64; 5]; + mask_where_i64(&tensor, &mask, &value, &mut out); + assert_eq!(out, [-1, 20, -3, 40, -5]); + } + + #[test] + fn test_mask_where_u8_basic() { + let tensor = [0u8, 1, 0, 1]; + let mask = [1u8, 1, 0, 0]; + let value = [1u8, 0, 1, 0]; + let mut out = [0u8; 4]; + mask_where_u8(&tensor, &mask, &value, &mut out); + assert_eq!(out, [1, 0, 0, 1]); + } + + #[test] + fn test_mask_fill_f32_basic() { + let tensor = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; + let mask = [1u8, 0, 1, 0, 1, 0, 1]; + let mut out = [0.0f32; 7]; + mask_fill_f32(&tensor, &mask, -1.0, &mut out); + assert_eq!(out, [-1.0, 2.0, -1.0, 4.0, -1.0, 6.0, -1.0]); + } + + #[test] + fn test_mask_fill_f64_basic() { + let tensor = [1.0f64, 2.0, 3.0]; + let mask = [0u8, 1, 0]; + let mut out = [0.0f64; 3]; + mask_fill_f64(&tensor, &mask, 99.0, &mut out); + assert_eq!(out, [1.0, 99.0, 3.0]); + } + + #[test] + fn test_mask_fill_i64_basic() { + let tensor = [10i64, 20, 30, 40]; + let mask = [1u8, 0, 0, 1]; + let mut out = [0i64; 4]; + mask_fill_i64(&tensor, &mask, -1, &mut out); + assert_eq!(out, [-1, 20, 30, -1]); + } + + #[test] + fn test_mask_fill_u8_basic() { + let tensor = [0u8, 1, 0, 1, 0]; + let mask = [1u8, 1, 0, 0, 1]; + let mut out = [0u8; 5]; + mask_fill_u8(&tensor, &mask, 1, &mut out); + assert_eq!(out, [1, 1, 0, 1, 1]); + } + + #[test] + fn test_mask_where_f32_nan() { + let tensor = [f32::NAN, 2.0, 3.0, f32::NAN]; + let mask = [1u8, 0, 1, 0]; + let value = [10.0f32, 20.0, 30.0, 40.0]; + let mut out = [0.0f32; 4]; + mask_where_f32(&tensor, &mask, &value, &mut out); + // mask=1 picks value, mask=0 picks tensor (including NaN) + assert_eq!(out[0], 10.0); + assert_eq!(out[1], 2.0); + assert_eq!(out[2], 30.0); + assert!(out[3].is_nan()); + } + + // Lane-boundary tests: sizes that exercise SIMD + scalar tail on all ISAs. + // 17 elements for f32 = 4 NEON iters + 1 tail, or 2 AVX2 iters + 1 tail. + + #[test] + fn test_mask_where_f32_lane_boundary() { + let n = 17; + let tensor: Vec = (0..n).map(|i| i as f32).collect(); + let value: Vec = (0..n).map(|i| (i as f32) * 10.0).collect(); + let mask: Vec = (0..n).map(|i| (i % 2) as u8).collect(); + let mut out = vec![0.0f32; n]; + mask_where_f32(&tensor, &mask, &value, &mut out); + for i in 0..n { + let expected = if i % 2 != 0 { value[i] } else { tensor[i] }; + assert_eq!(out[i], expected, "mismatch at index {i}"); + } + } + + #[test] + fn test_mask_fill_f32_lane_boundary() { + let n = 17; + let tensor: Vec = (0..n).map(|i| i as f32).collect(); + let mask: Vec = (0..n).map(|i| (i % 3 == 0) as u8).collect(); + let mut out = vec![0.0f32; n]; + mask_fill_f32(&tensor, &mask, -1.0, &mut out); + for i in 0..n { + let expected = if i % 3 == 0 { -1.0 } else { tensor[i] }; + assert_eq!(out[i], expected, "mismatch at index {i}"); + } + } + + #[test] + fn test_mask_where_u8_lane_boundary() { + // 33 elements for u8: exercises 2 NEON iters + 1 tail, or 1 AVX2 iter + 1 tail + let n = 33; + let tensor: Vec = (0..n).map(|i| (i % 2) as u8).collect(); + let value: Vec = (0..n).map(|i| ((i + 1) % 2) as u8).collect(); + let mask: Vec = (0..n).map(|i| (i % 3 == 0) as u8).collect(); + let mut out = vec![0u8; n]; + mask_where_u8(&tensor, &mask, &value, &mut out); + for i in 0..n { + let expected = if i % 3 == 0 { value[i] } else { tensor[i] }; + assert_eq!(out[i], expected, "mismatch at index {i}"); + } + } + + #[test] + fn test_mask_where_f64_lane_boundary() { + // 9 elements for f64: 4 NEON iters + 1 tail (2 lanes), or 2 AVX2 iters + 1 tail (4 lanes) + let n = 9; + let tensor: Vec = (0..n).map(|i| i as f64).collect(); + let value: Vec = (0..n).map(|i| -(i as f64)).collect(); + let mask: Vec = (0..n).map(|i| (i % 2) as u8).collect(); + let mut out = vec![0.0f64; n]; + mask_where_f64(&tensor, &mask, &value, &mut out); + for i in 0..n { + let expected = if i % 2 != 0 { value[i] } else { tensor[i] }; + assert_eq!(out[i], expected, "mismatch at index {i}"); + } + } + + #[test] + fn test_mask_where_empty() { + let mut out = vec![0.0f32; 0]; + mask_where_f32(&[], &[], &[], &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_mask_fill_empty() { + let mut out = vec![0.0f32; 0]; + mask_fill_f32(&[], &[], 1.0, &mut out); + assert!(out.is_empty()); + } + + // bool_not_u8 writes into a `*mut bool` via macerator's store_as_bool. + // Rust's bool is only valid as 0x00 or 0x01 (any other byte is UB when + // read back as bool). A previous audit flagged that SIMD mask stores + // might emit 0xFF. Verify every output byte is normalized to 0/1. + #[test] + fn bool_not_u8_output_is_normalized_0_or_1() { + // Spans SIMD body + scalar tail on any realistic lane width: + // 17 elements -> NEON 16-byte SIMD + 1 tail; AVX2 32 spills to tail. + // 127 elements -> SIMD body + 15 tail for 16-byte lanes. + for &len in &[1usize, 8, 15, 16, 17, 31, 32, 63, 127, 256] { + let a: Vec = (0..len).map(|i| (i % 2) as u8).collect(); + let mut out = vec![0xAAu8; len]; + super::bool_not_u8(&a, &mut out); + for (i, &b) in out.iter().enumerate() { + assert!( + b == 0 || b == 1, + "len={}: out[{}] = 0x{:02x}, expected 0x00 or 0x01", + len, + i, + b + ); + let expected = if a[i] == 0 { 1 } else { 0 }; + assert_eq!( + b, expected, + "len={}: out[{}] = {}, expected {}", + len, i, b, expected + ); + } + } + } + + #[test] + fn bool_not_inplace_u8_output_is_normalized_0_or_1() { + for &len in &[1usize, 8, 15, 16, 17, 31, 32, 63, 127, 256] { + let mut a: Vec = (0..len).map(|i| (i % 2) as u8).collect(); + let original = a.clone(); + super::bool_not_inplace_u8(&mut a); + for (i, &b) in a.iter().enumerate() { + assert!( + b == 0 || b == 1, + "len={}: a[{}] = 0x{:02x}, expected 0x00 or 0x01", + len, + i, + b + ); + let expected = if original[i] == 0 { 1 } else { 0 }; + assert_eq!( + b, expected, + "len={}: a[{}] = {}, expected {}", + len, i, b, expected + ); + } + } + } + + // Edge cases: empty input, homogeneous all-zero, homogeneous all-one. + // Homogeneous inputs exercise the SIMD mask-to-byte conversion for + // all-true and all-false cases, which alternating inputs do not. + #[test] + fn bool_not_u8_edge_cases() { + // Empty input. + let mut out: Vec = Vec::new(); + super::bool_not_u8(&[], &mut out); + assert!(out.is_empty()); + + // All zeros -> all ones. + let a = alloc::vec![0u8; 32]; + let mut out = alloc::vec![0xAAu8; 32]; + super::bool_not_u8(&a, &mut out); + assert!(out.iter().all(|&b| b == 1)); + + // All ones -> all zeros. + let a = alloc::vec![1u8; 32]; + let mut out = alloc::vec![0xAAu8; 32]; + super::bool_not_u8(&a, &mut out); + assert!(out.iter().all(|&b| b == 0)); + } +} diff --git a/crates/burn-flex/src/simd/scalar.rs b/crates/burn-flex/src/simd/scalar.rs new file mode 100644 index 0000000..6299ef6 --- /dev/null +++ b/crates/burn-flex/src/simd/scalar.rs @@ -0,0 +1,74 @@ +/// Comparison operation type. +#[derive(Clone, Copy)] +pub enum CmpOp { + Gt, + Ge, + Lt, + Le, + Eq, + Ne, +} + +/// Scalar boolean NOT: out[i] = !a[i] (0 becomes 1, non-zero becomes 0) +#[inline] +pub fn bool_not_u8(a: &[u8], out: &mut [u8]) { + for i in 0..a.len() { + out[i] = (a[i] == 0) as u8; + } +} + +/// Scalar boolean NOT in-place: a[i] = !a[i] +#[inline] +pub fn bool_not_inplace_u8(a: &mut [u8]) { + for x in a.iter_mut() { + *x = (*x == 0) as u8; + } +} + +/// Scalar boolean AND: out[i] = a[i] & b[i] +#[inline] +pub fn bool_and_u8(a: &[u8], b: &[u8], out: &mut [u8]) { + for i in 0..a.len() { + out[i] = a[i] & b[i]; + } +} + +/// Scalar boolean OR: out[i] = a[i] | b[i] +#[inline] +pub fn bool_or_u8(a: &[u8], b: &[u8], out: &mut [u8]) { + for i in 0..a.len() { + out[i] = a[i] | b[i]; + } +} + +/// Scalar boolean XOR: out[i] = a[i] ^ b[i] +#[inline] +pub fn bool_xor_u8(a: &[u8], b: &[u8], out: &mut [u8]) { + for i in 0..a.len() { + out[i] = a[i] ^ b[i]; + } +} + +/// Scalar boolean AND in-place: a[i] &= b[i] +#[inline] +pub fn bool_and_inplace_u8(a: &mut [u8], b: &[u8]) { + for i in 0..a.len() { + a[i] &= b[i]; + } +} + +/// Scalar boolean OR in-place: a[i] |= b[i] +#[inline] +pub fn bool_or_inplace_u8(a: &mut [u8], b: &[u8]) { + for i in 0..a.len() { + a[i] |= b[i]; + } +} + +/// Scalar boolean XOR in-place: a[i] ^= b[i] +#[inline] +pub fn bool_xor_inplace_u8(a: &mut [u8], b: &[u8]) { + for i in 0..a.len() { + a[i] ^= b[i]; + } +} diff --git a/crates/burn-flex/src/strided_index.rs b/crates/burn-flex/src/strided_index.rs new file mode 100644 index 0000000..49e3125 --- /dev/null +++ b/crates/burn-flex/src/strided_index.rs @@ -0,0 +1,99 @@ +use crate::layout::Layout; +use alloc::vec; +use alloc::vec::Vec; + +/// Iterator that yields linear indices for strided tensor access. +/// +/// Handles non-contiguous tensors (including those with negative strides from flip) +/// by computing the correct storage index for each logical element position. +pub struct StridedIter<'a> { + /// Current storage index (signed to handle negative strides) + storage_index: isize, + multi_index: Vec, + layout: &'a Layout, + remaining: usize, +} + +impl<'a> StridedIter<'a> { + /// Create a new strided iterator for the given layout. + pub fn new(layout: &'a Layout) -> Self { + let ndims = layout.num_dims(); + Self { + storage_index: layout.start_offset() as isize, + multi_index: vec![0; ndims], + layout, + remaining: layout.num_elements(), + } + } +} + +impl Iterator for StridedIter<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + + debug_assert!( + self.storage_index >= 0, + "StridedIter: negative storage index" + ); + let idx = self.storage_index as usize; + self.remaining -= 1; + + // Advance multi-index (last dimension first, like odometer) + let shape = self.layout.shape(); + let strides = self.layout.strides(); + + for d in (0..shape.num_dims()).rev() { + self.multi_index[d] += 1; + if self.multi_index[d] < shape[d] { + self.storage_index += strides[d]; + break; + } + // Wrap around this dimension + self.multi_index[d] = 0; + self.storage_index -= (shape[d] as isize - 1) * strides[d]; + } + + Some(idx) + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl ExactSizeIterator for StridedIter<'_> {} + +#[cfg(test)] +mod tests { + use super::*; + use burn_std::Shape; + + #[test] + fn test_contiguous_iteration() { + let layout = Layout::contiguous(Shape::from(vec![2, 3])); + let indices: Vec<_> = StridedIter::new(&layout).collect(); + assert_eq!(indices, vec![0, 1, 2, 3, 4, 5]); + } + + #[test] + fn test_transposed_iteration() { + // Original: [[0, 1, 2], [3, 4, 5]] (shape [2, 3], strides [3, 1]) + // Transposed: [[0, 3], [1, 4], [2, 5]] (shape [3, 2], strides [1, 3]) + let layout = Layout::contiguous(Shape::from(vec![2, 3])).transpose(0, 1); + let indices: Vec<_> = StridedIter::new(&layout).collect(); + assert_eq!(indices, vec![0, 3, 1, 4, 2, 5]); + } + + #[test] + fn test_narrowed_iteration() { + // Original: [[0, 1, 2, 3], [4, 5, 6, 7]] (shape [2, 4]) + // Narrowed dim=1, start=1, len=2: [[1, 2], [5, 6]] + let layout = Layout::contiguous(Shape::from(vec![2, 4])).narrow(1, 1, 2); + let indices: Vec<_> = StridedIter::new(&layout).collect(); + assert_eq!(indices, vec![1, 2, 5, 6]); + } +} diff --git a/crates/burn-flex/src/tensor.rs b/crates/burn-flex/src/tensor.rs new file mode 100644 index 0000000..82ab187 --- /dev/null +++ b/crates/burn-flex/src/tensor.rs @@ -0,0 +1,1016 @@ +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::fmt; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; + +use burn_backend::{DType, Element, TensorData, TensorMetadata}; +use burn_std::{Bytes, Shape, bf16, f16}; + +use crate::{FlexDevice, layout::Layout}; + +/// CPU tensor primitive for the Flex backend. +/// +/// Uses type-erased byte storage with runtime dtype and Arc-based sharing. +/// Clone is O(1) (refcount increment). Copy-on-write for mutations. +#[derive(Clone)] +pub struct FlexTensor { + /// Shared byte storage. Clone increments refcount. + data: Arc, + /// Layout describing shape, strides, and offset. + layout: Layout, + /// Runtime data type. + dtype: DType, +} + +impl fmt::Debug for FlexTensor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FlexTensor") + .field("shape", self.layout.shape()) + .field("dtype", &self.dtype) + .field("contiguous", &self.layout.is_contiguous()) + .field("unique", &self.is_unique()) + .finish() + } +} + +impl FlexTensor { + /// Create a new tensor from bytes, layout, and dtype. + pub fn new(data: Bytes, layout: Layout, dtype: DType) -> Self { + Self { + data: Arc::new(data), + layout, + dtype, + } + } + + /// Create a tensor from TensorData. + pub fn from_data(data: TensorData) -> Self { + let shape = data.shape.clone(); + let layout = Layout::contiguous(shape); + let dtype = data.dtype; + Self { + data: Arc::new(data.bytes), + layout, + dtype, + } + } + + /// Convert tensor to TensorData. + /// + /// If non-contiguous or shared, this will copy data. + pub fn into_data(self) -> TensorData { + if self.layout.is_contiguous() && self.layout.start_offset() == 0 { + let expected_bytes = self.layout.num_elements() * dtype_size(self.dtype); + assert!( + expected_bytes <= self.data.len(), + "into_data: buffer ({} bytes) too small for {} elements of {:?}", + self.data.len(), + self.layout.num_elements(), + self.dtype + ); + if self.data.len() == expected_bytes { + // Buffer exactly matches logical size; try zero-copy unwrap + match Arc::try_unwrap(self.data) { + Ok(bytes) => TensorData { + bytes, + shape: self.layout.shape().clone(), + dtype: self.dtype, + }, + Err(arc) => { + let bytes = Bytes::from_bytes_vec((*arc)[..expected_bytes].to_vec()); + TensorData { + bytes, + shape: self.layout.shape().clone(), + dtype: self.dtype, + } + } + } + } else { + // Contiguous at offset 0 but buffer is oversized (e.g., narrowed view). + // Truncate to exact logical size. + let bytes = Bytes::from_bytes_vec(self.data[..expected_bytes].to_vec()); + TensorData { + bytes, + shape: self.layout.shape().clone(), + dtype: self.dtype, + } + } + } else { + // Non-contiguous or non-zero offset: copy to contiguous layout + self.to_contiguous().into_data() + } + } + + /// Check if this tensor has exclusive ownership of its data. + /// + /// When true, in-place mutations are safe without copying. + #[inline] + pub fn is_unique(&self) -> bool { + Arc::strong_count(&self.data) == 1 + } + + /// Get the layout. + pub fn layout(&self) -> &Layout { + &self.layout + } + + /// Create a new tensor with a different layout but sharing the same data. + /// + /// This is a zero-copy operation used for operations like flip, transpose, etc. + pub fn with_layout(self, layout: Layout) -> Self { + Self { + data: self.data, + layout, + dtype: self.dtype, + } + } + + /// Get the dtype. + pub fn dtype(&self) -> DType { + self.dtype + } + + /// Check if tensor is contiguous. + pub fn is_contiguous(&self) -> bool { + self.layout.is_contiguous() + } + + /// Get the raw bytes (read-only). + pub fn bytes(&self) -> &[u8] { + &self.data + } + + /// Get a clone of the Arc for sharing data with a new layout. + /// + /// Use this for zero-copy view operations (reshape, transpose, slice). + pub fn data_arc(&self) -> Arc { + Arc::clone(&self.data) + } + + /// Create a tensor from shared data, layout, and dtype. + /// + /// Use this for zero-copy view operations. + pub fn from_arc(data: Arc, layout: Layout, dtype: DType) -> Self { + Self { + data, + layout, + dtype, + } + } + + /// Zero-copy typed view of the full storage buffer. + /// + /// Use with `StridedIter` for non-contiguous access, or with + /// `layout().contiguous_offsets()` for the contiguous fast path. + /// + /// # Panics + /// Panics if `E::dtype()` doesn't match the tensor's dtype. + /// Note: Bool tensors are stored as u8, so both Bool(Native) and Bool(U8) + /// dtypes accept u8 access. + pub fn storage(&self) -> &[E] { + assert!( + E::dtype() == self.dtype + || (matches!( + self.dtype, + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) + ) && E::dtype() == DType::U8), + "storage: dtype mismatch (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ); + bytemuck::cast_slice(&self.data) + } + + /// Mutable typed view with copy-on-write semantics. + /// + /// If the tensor is shared (refcount > 1), this will copy the data first. + /// For in-place operations, prefer `try_storage_mut()` which returns None + /// if shared, allowing you to choose an alternative strategy. + /// + /// # Panics + /// Panics if `E::dtype()` doesn't match the tensor's dtype. + /// Note: Bool tensors are stored as u8, so both Bool(Native) and Bool(U8) + /// dtypes accept u8 access. + pub fn storage_mut(&mut self) -> &mut [E] { + assert!( + E::dtype() == self.dtype + || (matches!( + self.dtype, + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) + ) && E::dtype() == DType::U8), + "storage_mut: dtype mismatch (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ); + // COW: clone data if shared + let bytes = Arc::make_mut(&mut self.data); + bytemuck::cast_slice_mut(bytes) + } + + /// Try to get mutable storage without copying. + /// + /// Returns `Some` if tensor is uniquely owned, `None` if shared. + /// Use this when you want to avoid the implicit copy in `storage_mut()`. + /// Note: Bool tensors are stored as u8, so both Bool(Native) and Bool(U8) + /// dtypes accept u8 access. + pub fn try_storage_mut(&mut self) -> Option<&mut [E]> { + assert!( + E::dtype() == self.dtype + || (matches!( + self.dtype, + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) + ) && E::dtype() == DType::U8), + "try_storage_mut: dtype mismatch (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ); + if self.is_unique() { + // Safe: we're the only owner + let bytes = Arc::get_mut(&mut self.data)?; + Some(bytemuck::cast_slice_mut(bytes)) + } else { + None + } + } + + /// Get typed slice view (zero-cost if contiguous and offset is 0). + /// + /// Returns None if dtype doesn't match E or tensor is non-contiguous. + pub fn as_slice(&self) -> Option<&[E]> { + if E::dtype() != self.dtype { + return None; + } + let storage: &[E] = self.storage(); + self.layout + .contiguous_offsets() + .map(|(start, end)| &storage[start..end]) + } + + /// Create an empty tensor with given shape and dtype. + pub fn empty(shape: Shape, dtype: DType) -> Self { + let num_elements = shape.num_elements(); + let elem_size = dtype_size(dtype); + let bytes = Bytes::from_bytes_vec(alloc::vec![0u8; num_elements * elem_size]); + let layout = Layout::contiguous(shape); + Self { + data: Arc::new(bytes), + layout, + dtype, + } + } + + /// Create a tensor filled with zeros. + pub fn zeros(shape: Shape, dtype: DType) -> Self { + Self::empty(shape, dtype) + } + + /// Create a tensor filled with `n` copies of a typed value. + pub fn filled_typed( + shape: Shape, + dtype: DType, + value: E, + ) -> Self { + assert_eq!( + dtype_size(dtype), + core::mem::size_of::(), + "filled_typed: dtype size mismatch" + ); + let n = shape.num_elements(); + let data = alloc::vec![value; n]; + let bytes = Bytes::from_elems(data); + Self { + data: Arc::new(bytes), + layout: Layout::contiguous(shape), + dtype, + } + } + + /// Copy to contiguous layout if needed. + pub fn to_contiguous(&self) -> Self { + // Fast path requires the logical tensor to cover the whole buffer. + // A contiguous prefix view (e.g. [8, 5] sliced to [5, 5]) has + // canonical strides and offset 0 but an oversized buffer, and would + // otherwise mislead callers that read `storage()` / `bytes()` by + // length (e.g. the SIMD `mask_fill_*` kernels). + if self.is_contiguous() + && self.layout.start_offset() == 0 + && self.data.len() == self.layout.num_elements() * dtype_size(self.dtype) + { + return self.clone(); + } + + // Copy data to new contiguous buffer + match self.dtype { + DType::F64 => self.copy_contiguous::(), + DType::F32 => self.copy_contiguous::(), + DType::F16 => self.copy_contiguous::(), + DType::BF16 => self.copy_contiguous::(), + DType::I64 => self.copy_contiguous::(), + DType::I32 => self.copy_contiguous::(), + DType::I16 => self.copy_contiguous::(), + DType::I8 => self.copy_contiguous::(), + DType::U64 => self.copy_contiguous::(), + DType::U32 => self.copy_contiguous::(), + DType::U16 => self.copy_contiguous::(), + DType::U8 => self.copy_contiguous::(), + DType::Bool(burn_std::BoolStore::Native | burn_std::BoolStore::U8) => { + self.copy_contiguous::() + } + DType::Bool(burn_std::BoolStore::U32) => { + panic!("burn-flex: Bool(U32) storage is not yet supported") + } + _ => panic!("Unsupported dtype for contiguous copy: {:?}", self.dtype), + } + } + + fn copy_contiguous(&self) -> Self { + let src: &[E] = bytemuck::cast_slice(&self.data); + let n = self.layout.num_elements(); + let mut dst = Vec::with_capacity(n); + + // Squeeze size-1 dims and merge adjacent stride-contiguous + // runs so e.g. a permuted `[N, H, W, C]` ConvNeXt layer-norm + // input becomes a plain 2D `[H*W, C]` transpose that the + // tiled copy below handles at near-memcpy speed. Without the + // collapse, the 4D ND fallback scalar-walks the tensor. + let collapsed = collapse_for_copy(self.layout.shape(), self.layout.strides()); + let (shape, strides) = collapsed.as_slices(); + let offset = self.layout.start_offset() as isize; + let all_positive = strides.iter().all(|&s| s >= 0); + + if shape.len() <= 1 && all_positive { + // 0-D scalar or 1-D run with a uniform stride. Empty + // collapsed shape means rank 0 (numel 1); otherwise + // numel is the single dim's size (which may be 0 for + // zero-sized 1D tensors, so don't clamp via `.max(1)`). + let collapsed_numel = if shape.is_empty() { 1 } else { shape[0] }; + debug_assert_eq!(n, collapsed_numel); + // SAFETY: capacity is n; we fill every position below. + unsafe { dst.set_len(n) }; + if shape.is_empty() { + if n > 0 { + dst[0] = src[offset as usize]; + } + } else { + let len = shape[0]; + let stride = strides[0]; + if stride == 1 { + dst[..len].copy_from_slice(&src[offset as usize..offset as usize + len]); + } else { + for (i, slot) in dst.iter_mut().take(len).enumerate() { + let idx = (offset + i as isize * stride) as usize; + *slot = src[idx]; + } + } + } + } else if shape.len() == 2 && all_positive { + // 2D positive-stride (transpose-like): tile both dims so + // reads stay in cache. The loop-nesting chooser inside + // `copy_2d_tiled` picks whichever ordering puts the + // smaller source stride on the innermost loop. + debug_assert_eq!(shape[0] * shape[1], n, "2D strides must cover all elements"); + // SAFETY: capacity is n; `copy_2d_tiled` writes every + // `(row, col)` position exactly once. + unsafe { dst.set_len(n) }; + copy_2d_tiled( + &mut dst, src, offset, shape[0], shape[1], strides[0], strides[1], + ); + } else if all_positive && strides.last().copied() == Some(1) { + // Since the inner stride is 1, we can bulk-copy + // the contiguous inner run for each outer index. + unsafe { dst.set_len(n) }; + copy_inner_contiguous_run(&mut dst, src, offset, shape, strides); + } else { + // General fallback: covers negative strides (flipped + // tensors) and ND layouts that can't collapse to ≤2D. + for idx in crate::strided_index::StridedIter::new(&self.layout) { + dst.push(src[idx]); + } + } + + let bytes = Bytes::from_elems(dst); + let layout = Layout::contiguous(self.layout.shape().clone()); + Self { + data: Arc::new(bytes), + layout, + dtype: self.dtype, + } + } + + /// Reshape tensor. Zero-copy if contiguous. + pub fn reshape(&self, new_shape: Shape) -> Self { + assert_eq!( + self.layout.num_elements(), + new_shape.num_elements(), + "reshape must preserve total elements" + ); + + if let Some(new_layout) = self.layout.reshape(new_shape.clone()) { + Self { + data: Arc::clone(&self.data), + layout: new_layout, + dtype: self.dtype, + } + } else { + // Non-contiguous: copy first + self.to_contiguous().reshape(new_shape) + } + } + + /// Transpose two dimensions. Zero-copy (metadata only). + pub fn transpose(&self, dim1: usize, dim2: usize) -> Self { + Self { + data: Arc::clone(&self.data), + layout: self.layout.transpose(dim1, dim2), + dtype: self.dtype, + } + } + + /// Narrow/slice along a dimension. Zero-copy (metadata only). + pub fn narrow(&self, dim: usize, start: usize, len: usize) -> Self { + Self { + data: Arc::clone(&self.data), + layout: self.layout.narrow(dim, start, len), + dtype: self.dtype, + } + } + + /// Permute dimensions according to axes. Zero-copy (metadata only). + pub fn permute(&self, axes: &[usize]) -> Self { + Self { + data: Arc::clone(&self.data), + layout: self.layout.permute(axes), + dtype: self.dtype, + } + } +} + +impl TensorMetadata for FlexTensor { + type Device = FlexDevice; + + fn dtype(&self) -> DType { + self.dtype + } + + fn shape(&self) -> Shape { + self.layout.shape().clone() + } + + fn rank(&self) -> usize { + self.layout.num_dims() + } + + fn device(&self) -> Self::Device { + FlexDevice + } + + fn can_mut(&self) -> bool { + self.is_unique() + } +} + +/// Max rank we're willing to handle without falling back to the +/// strided iterator. Burn tensors are capped at 8 dims in practice. +const COLLAPSE_MAX_RANK: usize = 8; + +/// Collapsed layout result of [`collapse_for_copy`], stored in stack +/// arrays so `to_contiguous()` doesn't have to hit the allocator on +/// its hot path. +#[derive(Debug, Clone, Copy)] +struct CollapsedLayout { + ndim: usize, + shape: [usize; COLLAPSE_MAX_RANK], + strides: [isize; COLLAPSE_MAX_RANK], +} + +impl CollapsedLayout { + #[inline] + fn as_slices(&self) -> (&[usize], &[isize]) { + (&self.shape[..self.ndim], &self.strides[..self.ndim]) + } +} + +/// Collapse a shape/stride pair into the minimum-rank equivalent +/// layout for a contiguous copy: +/// +/// 1. Squeeze size-1 dims (their stride never gets stepped past 0). +/// 2. Merge adjacent dims `(i, i+1)` when +/// `stride[i] == stride[i+1] * shape[i+1]`, which means the two +/// dims form a single logical run through memory. +/// +/// Canonical example: a 4D ConvNeXt input `[1, 244, 224, 48]` with +/// strides `[2_623_488, 224, 1, 54656]` (from +/// `[N, C, H, W].permute([0, 2, 3, 1])`) collapses to 2D +/// `[54656, 48]` with strides `[1, 54656]`. +/// +/// If the input rank exceeds [`COLLAPSE_MAX_RANK`] the result is +/// left at rank > 2 so the caller falls through to its generic +/// strided path. If the input is rank > `COLLAPSE_MAX_RANK`, we +/// return the original (un-collapsed) layout truncated, which the +/// caller will reject via its `shape.len() == 2` gate. +/// +/// PRECONDITION: the caller must gate on all-positive strides before +/// using the collapsed layout. The merge rule assumes positive +/// strides and will produce iteration-order-incorrect results for +/// flipped tensors. +fn collapse_for_copy(shape: &[usize], strides: &[isize]) -> CollapsedLayout { + let mut out = CollapsedLayout { + ndim: 0, + shape: [0; COLLAPSE_MAX_RANK], + strides: [0; COLLAPSE_MAX_RANK], + }; + + // Bail out to the caller's fallback if the rank is too large to + // fit our stack buffer. In practice this never triggers (burn + // tensors are ≤8 dims), but leaving the `ndim` high signals the + // caller to take the generic strided path. + if shape.len() > COLLAPSE_MAX_RANK { + out.ndim = shape.len().min(COLLAPSE_MAX_RANK); + return out; + } + + // Single forward sweep: squeeze size-1 dims and merge whenever + // the current dim's `stride * size` equals the previous output + // dim's stride (i.e. the two form a contiguous run). + // + // Use `checked_mul` so a pathological layout whose stride math + // would overflow `isize` simply fails to merge rather than + // wrapping into an incorrect merge decision. Real tensors can't + // hit this (total numel is bounded by `isize::MAX`), but + // hand-built layouts passed through the test paths could. + for (&s, &st) in shape.iter().zip(strides.iter()) { + if s == 1 { + continue; + } + let merge = out.ndim > 0 + && (s as isize) + .checked_mul(st) + .is_some_and(|run| out.strides[out.ndim - 1] == run); + if merge { + out.shape[out.ndim - 1] *= s; + out.strides[out.ndim - 1] = st; + } else { + out.shape[out.ndim] = s; + out.strides[out.ndim] = st; + out.ndim += 1; + } + } + + out +} + +/// Copy a strided source into a contiguous destination by treating the +/// layout as a set of strided outer dims wrapping a contiguous inner run. +/// +/// Precondition: strides are all positive and the innermost stride is 1 +#[inline] +fn copy_inner_contiguous_run( + dst: &mut [E], + src: &[E], + offset: isize, + shape: &[usize], + strides: &[isize], +) { + debug_assert!(!shape.is_empty()); + debug_assert_eq!(*strides.last().unwrap(), 1, "innermost stride must be 1"); + + // We want the maximal trailing run that is contiguous in src. + // Walk inward-out, a dim dim_idx extends the contiguous run if AND ONLY IF ("iff") + // strides[dim_idx] == product of inner shapes seen so far. + let mut expected_stride: isize = 1; + let mut split = shape.len(); // first OUTER index (dims [split..] are inner) + while split > 0 { + let dim_idx = split - 1; + if strides[dim_idx] == expected_stride { + expected_stride = expected_stride + .checked_mul(shape[dim_idx] as isize) + .expect("contiguous run length overflows isize"); + split -= 1; + } else { + break; + } + } + + let outer_shape = &shape[..split]; + let outer_strides = &strides[..split]; + let inner_run_length: usize = shape[split..].iter().product(); + + // Whole tensor is one contiguous run! We can bulk-copy the whole thing. + if outer_shape.is_empty() { + let src_index = offset as usize; + dst[..inner_run_length].copy_from_slice(&src[src_index..src_index + inner_run_length]); + return; + } + + if inner_run_length == 0 || outer_shape.contains(&0) { + return; + } + + // Walk the outer index space in row-major order via an odometer: + // each iteration ticks the rightmost counter, and so on overflow we + // reset it and carry +1 into the dim to its left. + // Row-major is what we want because dst is contiguous (consecutive outer-index + // tuples land in consecutive inner_run_length slots, so we just bump + // dst_position each step instead of recomputing it) + // We use this instead of N nested for loops because the rank isn't + // known at compile time. + let mut counter = [0usize; COLLAPSE_MAX_RANK]; + let outer_dim_count = outer_shape.len(); + let mut dst_position = 0usize; + + loop { + let mut src_start = offset; + for dim_idx in 0..outer_dim_count { + src_start += counter[dim_idx] as isize * outer_strides[dim_idx]; + } + let src_index = src_start as usize; + dst[dst_position..dst_position + inner_run_length] + .copy_from_slice(&src[src_index..src_index + inner_run_length]); + dst_position += inner_run_length; + + let mut dim_idx = outer_dim_count; + loop { + if dim_idx == 0 { + return; + } + dim_idx -= 1; + counter[dim_idx] += 1; + if counter[dim_idx] < outer_shape[dim_idx] { + break; + } + counter[dim_idx] = 0; + } + } +} + +/// Tiled 2D copy from a strided source into a contiguous destination. +/// The loop nesting is chosen so the innermost read walks whichever +/// source stride is smaller, which keeps the hot loop in cache even +/// for transpose-like layouts. +#[inline] +fn copy_2d_tiled( + dst: &mut [E], + src: &[E], + offset: isize, + rows: usize, + cols: usize, + row_stride: isize, + col_stride: isize, +) { + const TILE: usize = 16; + + if row_stride <= col_stride { + // row-inside-col: the inner loop walks `row_stride` (smaller). + for col_tile in (0..cols).step_by(TILE) { + let col_end = (col_tile + TILE).min(cols); + for row_tile in (0..rows).step_by(TILE) { + let row_end = (row_tile + TILE).min(rows); + for col in col_tile..col_end { + let col_base = offset + col as isize * col_stride; + for row in row_tile..row_end { + let idx = (col_base + row as isize * row_stride) as usize; + // SAFETY: caller set `dst.len() == rows * cols` + // and each `(row, col)` is visited once. + unsafe { + *dst.get_unchecked_mut(row * cols + col) = src[idx]; + } + } + } + } + } + } else { + // col-inside-row: the inner loop walks `col_stride` (smaller). + for row_tile in (0..rows).step_by(TILE) { + let row_end = (row_tile + TILE).min(rows); + for col_tile in (0..cols).step_by(TILE) { + let col_end = (col_tile + TILE).min(cols); + for row in row_tile..row_end { + let row_base = + offset + row as isize * row_stride + col_tile as isize * col_stride; + let dst_base = row * cols + col_tile; + for c in 0..(col_end - col_tile) { + let idx = (row_base + c as isize * col_stride) as usize; + // SAFETY: same as above. + unsafe { + *dst.get_unchecked_mut(dst_base + c) = src[idx]; + } + } + } + } + } + } +} + +/// Get the size in bytes for a dtype element. +/// +/// Matches `burn_std::DType::size()` semantics: Bool(Native) and Bool(U8) are +/// 1 byte, Bool(U32) is 4 bytes. This makes buffer-size validation correct +/// regardless of which BoolStore variant the dtype carries. +/// +/// # Panics +/// +/// Panics if the dtype has a zero-byte element size. `burn_std::DType::size()` +/// returns 0 for sub-byte quantized dtypes (Q4F, Q4S, Q2F, Q2S, and most +/// `QuantStore::PackedNative` variants). burn-flex does not yet support these +/// packed quantization formats; passing them here would silently produce +/// empty allocations in `FlexTensor::empty`, truncated buffers in `into_data`, +/// and zero-byte memcpys in `repeat_dim`. The panic turns all three into a +/// loud, actionable failure at the dispatch boundary. +pub(crate) fn dtype_size(dtype: DType) -> usize { + // Delegate to burn-std's canonical size to stay in sync. + let size = dtype.size(); + assert!( + size > 0, + "burn-flex: dtype {:?} has zero-byte element size (sub-byte packed \ + quantization is not yet supported)", + dtype + ); + size +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn test_from_data_roundtrip() { + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]); + let tensor = FlexTensor::from_data(data.clone()); + let result = tensor.into_data(); + assert_eq!(data.shape, result.shape); + assert_eq!(data.dtype, result.dtype); + } + + #[test] + fn test_collapse_for_copy_squeezes_size1_and_merges_contig() { + // Permuted ConvNeXt input: [1, 48, 244, 224].permute([0,2,3,1]). + let shape = vec![1, 244, 224, 48]; + let strides = vec![2_623_488_isize, 224, 1, 54656]; + let collapsed = collapse_for_copy(&shape, &strides); + let (s, st) = collapsed.as_slices(); + assert_eq!(s, &[54656, 48]); + assert_eq!(st, &[1, 54656]); + } + + #[test] + fn test_collapse_for_copy_already_contiguous_3d() { + let collapsed = collapse_for_copy(&[2, 3, 4], &[12, 4, 1]); + let (s, st) = collapsed.as_slices(); + assert_eq!(s, &[24]); + assert_eq!(st, &[1]); + } + + #[test] + fn test_collapse_for_copy_transpose_2d() { + let collapsed = collapse_for_copy(&[5, 3], &[1, 5]); + let (s, st) = collapsed.as_slices(); + assert_eq!(s, &[5, 3]); + assert_eq!(st, &[1, 5]); + } + + #[test] + fn test_collapse_for_copy_all_size1() { + let collapsed = collapse_for_copy(&[1, 1, 1], &[0, 0, 0]); + let (s, st) = collapsed.as_slices(); + assert!(s.is_empty()); + assert!(st.is_empty()); + } + + /// Regression: an empty 1D view produced by `narrow` at a + /// non-zero offset forces `copy_contiguous` to run (it can't + /// early-return via the contiguous-at-offset-0 shortcut). The + /// old `debug_assert_eq!(n, shape.product().max(1))` tripped + /// for this shape because `.max(1)` produced 1 while the true + /// numel is 0. + #[test] + fn test_to_contiguous_zero_sized_narrowed() { + let t = FlexTensor::from_data(TensorData::new( + (0..6).map(|i| i as f32).collect::>(), + vec![6], + )); + // narrow(dim, start=3, len=0): shape [0], start_offset 3. + let empty_view = t.narrow(0, 3, 0); + assert_eq!(empty_view.shape().to_vec(), vec![0]); + assert_ne!(empty_view.layout().start_offset(), 0); + + let contig = empty_view.to_contiguous(); + assert_eq!(contig.shape().to_vec(), vec![0]); + assert_eq!(contig.layout().start_offset(), 0); + assert_eq!(contig.into_data().bytes.len(), 0); + } + + /// Regression for #4855: a prefix view (e.g. `narrow(dim, 0, n)`) has + /// canonical contiguous strides and start_offset 0, but its underlying + /// buffer is still the larger original. `to_contiguous` must materialize + /// a right-sized copy so callers keying off `storage().len()` (like the + /// SIMD `mask_fill_*` kernels reached from `triu`/`tril` in LU on tall + /// matrices) don't walk past the logical shape. + #[test] + fn test_to_contiguous_prefix_view_shrinks_buffer() { + let data: Vec = (0..40).map(|i| i as f32).collect(); + let t = FlexTensor::from_data(TensorData::new(data, vec![8, 5])); + + let prefix = t.narrow(0, 0, 5); + assert_eq!(prefix.shape().to_vec(), vec![5, 5]); + assert_eq!(prefix.layout().strides(), &[5, 1]); + assert_eq!(prefix.layout().start_offset(), 0); + assert!(prefix.is_contiguous()); + assert_eq!(prefix.storage::().len(), 40); + + let contig = prefix.to_contiguous(); + assert_eq!(contig.storage::().len(), 25); + assert_eq!(contig.layout().num_elements(), 25); + assert_eq!( + contig.storage::(), + &(0..5) + .flat_map(|r| (0..5).map(move |c| (r * 5 + c) as f32)) + .collect::>()[..] + ); + } + + /// 4D permuted layout round-trips through the collapse + tiled + /// copy path. Mirrors the ConvNeXt channels-last permute. + #[test] + fn test_to_contiguous_4d_permuted_matches_naive() { + let dims = [1, 48, 4, 5]; + let n: usize = dims.iter().product(); + let data: Vec = (0..n).map(|i| i as f32).collect(); + let t = FlexTensor::from_data(TensorData::new(data.clone(), dims.to_vec())); + let permuted = t.permute(&[0, 2, 3, 1]); + assert!(!permuted.is_contiguous()); + + let contig = permuted.to_contiguous(); + assert!(contig.is_contiguous()); + assert_eq!(contig.shape().to_vec(), vec![1, 4, 5, 48]); + + // Expected via manual strided walk of the source. + let mut expected = Vec::with_capacity(n); + for h in 0..4 { + for w in 0..5 { + for c in 0..48 { + let idx = c * 20 + h * 5 + w; + expected.push(data[idx]); + } + } + } + + let result_data = contig.into_data(); + let values = result_data.as_slice::().unwrap(); + assert_eq!(values, expected.as_slice()); + } + + /// Regression: the ShuffleNet channel shuffle (reshape -> permute([0,2,1,3,4]) -> reshape) + /// collapses to a 3D layout with stride-1 innermost. + /// Before this fix that went into the StridedIter scalar walk in the final else in copy_contiguous + /// Now, we use a new inner-contiguous branch that bulk-copies the trailing run + #[test] + fn test_to_contiguous_3d_inner_stride1_matches_naive() { + let dims = [1, 2, 4, 3, 3]; // [N, G, C, H, W] + let n: usize = dims.iter().product(); + let data: Vec = (0..n).map(|i| i as f32).collect(); + let t = FlexTensor::from_data(TensorData::new(data.clone(), dims.to_vec())); + let permuted = t.permute(&[0, 2, 1, 3, 4]); // swap G and C from the original dims in the permute + assert!(!permuted.is_contiguous()); + + let contiguous_data = permuted.to_contiguous(); + assert!(contiguous_data.is_contiguous()); + assert_eq!(contiguous_data.shape().to_vec(), vec![1, 4, 2, 3, 3]); + + // Now we manually do a strided walk to be able to check that against a naive solution. + // output[0, c, g, h, w] = input[0, g, c, h, w]. + let mut expected = Vec::with_capacity(n); + for c in 0..4 { + for g in 0..2 { + for h in 0..3 { + for w in 0..3 { + let idx = g * 36 + c * 9 + h * 3 + w; + expected.push(data[idx]); + } + } + } + } + + // Verify the naive solution matches (the manual strided walk). + let result_data = contiguous_data.into_data(); + let values = result_data.as_slice::().unwrap(); + assert_eq!(values, expected.as_slice()); + } + + /// Exercise the `row_stride > col_stride` branch of the 2D tiled + /// copy (the ConvNeXt case hits the other branch). + #[test] + fn test_to_contiguous_2d_row_stride_gt_col_stride() { + // `slice(s![..;2, ..])` on a [6, 3] contiguous tensor gives a + // [3, 3] view with strides [6, 1] that doesn't collapse, so + // the 2D branch runs with row_stride > col_stride. + let data: Vec = (0..18).map(|i| i as f32).collect(); + let t = FlexTensor::from_data(TensorData::new(data, vec![6, 3])); + let stepped = crate::ops::slice::slice( + t, + &[ + burn_std::Slice::new(0, Some(6), 2), + burn_std::Slice::new(0, None, 1), + ], + ); + // Verify the layout matches what the branch requires. + assert_eq!(stepped.layout().shape().to_vec(), vec![3, 3]); + assert_eq!(stepped.layout().strides(), &[6, 1]); + assert!(!stepped.layout().is_contiguous()); + + let contig = stepped.to_contiguous(); + assert!(contig.is_contiguous()); + assert_eq!(contig.shape().to_vec(), vec![3, 3]); + + let result_data = contig.into_data(); + let values = result_data.as_slice::().unwrap(); + // Expected: rows 0, 2, 4 of the original 6x3 tensor. + let expected = vec![ + 0.0f32, 1.0, 2.0, // row 0 + 6.0, 7.0, 8.0, // row 2 + 12.0, 13.0, 14.0, // row 4 + ]; + assert_eq!(values, expected.as_slice()); + } + + #[test] + fn test_reshape() { + let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]); + let tensor = FlexTensor::from_data(data); + let reshaped = tensor.reshape(Shape::from(vec![3, 2])); + assert_eq!(reshaped.shape().to_vec(), vec![3, 2]); + } + + #[test] + fn test_transpose() { + let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]); + let tensor = FlexTensor::from_data(data); + let transposed = tensor.transpose(0, 1); + assert_eq!(transposed.shape().to_vec(), vec![3, 2]); + assert!(!transposed.is_contiguous()); + } + + #[test] + fn test_clone_is_cheap() { + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0]); + let tensor = FlexTensor::from_data(data); + + // Before clone, tensor is unique + assert!(tensor.is_unique()); + + // Clone shares data + let cloned = tensor.clone(); + assert!(!tensor.is_unique()); + assert!(!cloned.is_unique()); + + // Both point to same data + assert!(core::ptr::eq( + tensor.bytes().as_ptr(), + cloned.bytes().as_ptr(), + )); + } + + #[test] + fn test_cow_on_mutation() { + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0]); + let tensor = FlexTensor::from_data(data); + let mut cloned = tensor.clone(); + + // Both share data + assert!(!tensor.is_unique()); + assert!(!cloned.is_unique()); + + // Mutate cloned - triggers COW + let storage: &mut [f32] = cloned.storage_mut(); + storage[0] = 99.0; + + // Now cloned has its own copy, tensor is unique again + assert!(tensor.is_unique()); + assert!(cloned.is_unique()); + + // Data is different + assert_ne!(tensor.bytes().as_ptr(), cloned.bytes().as_ptr()); + assert_eq!(tensor.storage::()[0], 1.0); + assert_eq!(cloned.storage::()[0], 99.0); + } + + #[test] + fn test_into_data_narrowed_at_offset_zero() { + // [1, 2, 3, 4, 5, 6] shape [2, 3] + let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]); + let tensor = FlexTensor::from_data(data); + // narrow to first row: shape [1, 3], offset 0, contiguous + let narrowed = tensor.narrow(0, 0, 1); + assert!(narrowed.is_contiguous()); + assert_eq!(narrowed.layout().start_offset(), 0); + + let result = narrowed.into_data(); + assert_eq!(result.shape.to_vec(), vec![1, 3]); + // Must have exactly 3 f32s = 12 bytes, not 24 + assert_eq!(result.bytes.len(), 3 * core::mem::size_of::()); + let values: Vec = result.to_vec().unwrap(); + assert_eq!(values, vec![1.0, 2.0, 3.0]); + } +} diff --git a/crates/burn-fusion/Cargo.toml b/crates/burn-fusion/Cargo.toml new file mode 100644 index 0000000..50bc328 --- /dev/null +++ b/crates/burn-fusion/Cargo.toml @@ -0,0 +1,48 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Kernel fusion backend decorator for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-fusion" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-fusion" +documentation = "https://docs.rs/burn-fusion" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "tracing"] +std = ["serde/std", "tracing?/std"] +doc = ["default"] +memory-checks = ["std"] + +tracing = [ + "dep:tracing", + "burn-backend/tracing", + "burn-ir/tracing", +] + +# Exposes the fusion spy API for tests that need to assert on fusion decisions. +# Not intended for production use. +test-util = ["std"] + +[dependencies] +burn-backend = { workspace = true, features = ["default"] } +burn-std = { workspace = true, features = ["default"] } +burn-ir = { workspace = true, features = ["default"] } +tracing = { workspace = true, optional = true, features = ["attributes"] } +smallvec = { workspace = true } + +hashbrown = { workspace = true } +derive-new = { workspace = true } +spin = { workspace = true } +log = { workspace = true } +serde = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-fusion/LICENSE-APACHE b/crates/burn-fusion/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-fusion/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-fusion/LICENSE-MIT b/crates/burn-fusion/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-fusion/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-fusion/README.md b/crates/burn-fusion/README.md new file mode 100644 index 0000000..8c508f5 --- /dev/null +++ b/crates/burn-fusion/README.md @@ -0,0 +1,3 @@ +# Burn Fusion + +A kernel fusion backend decorator for Burn. diff --git a/crates/burn-fusion/src/backend.rs b/crates/burn-fusion/src/backend.rs new file mode 100644 index 0000000..4909883 --- /dev/null +++ b/crates/burn-fusion/src/backend.rs @@ -0,0 +1,319 @@ +use crate::{ + FusionTensor, + client::GlobalFusionClient, + stream::{Context, OrderedExecution}, +}; +use burn_backend::{ + Backend, BackendGraph, BackendTypes, DType, DeviceOps, ExecutionError, + tensor::{BoolTensor, Device, FloatTensor, IntTensor, QuantizedTensor}, +}; +use burn_ir::{BackendIr, HandleContainer, OperationIr, TensorHandle, TensorIr}; +use serde::{Serialize, de::DeserializeOwned}; +use std::marker::PhantomData; + +/// Get the client for the given device. +pub fn get_client(device: &Device) -> Client { + GlobalFusionClient::load(device) +} + +/// Enable dynamic operation fusion on a backend that implements [fusion backend](crate::FusionBackend). +#[derive(Clone, Debug, Default)] +pub struct Fusion { + _backend: PhantomData, +} + +impl BackendTypes for Fusion { + type Device = B::Device; + + type FloatTensorPrimitive = FusionTensor; + type IntTensorPrimitive = FusionTensor; + type BoolTensorPrimitive = FusionTensor; + type QuantizedTensorPrimitive = FusionTensor; + + // Fusion adds nothing to a captured graph: the fused kernels are recorded + // on the inner backend's stream, so the graph handle is the inner one. + type GraphPrimitive = B::GraphPrimitive; +} + +impl Backend for Fusion { + fn name(device: &Self::Device) -> String { + format!("fusion<{}>", B::name(device)) + } + + fn seed(device: &B::Device, seed: u64) { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + client.sync(move || B::seed(&device, seed)); + } + + fn sync(device: &Self::Device) -> Result<(), ExecutionError> { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + client.sync(move || B::sync(&device)) + } + + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + device: &Self::Device, + input: Input, + func: Func, + ) -> Output { + B::memory_persistent_allocations(device, input, func) + } + + fn memory_cleanup(device: &Self::Device) { + B::memory_cleanup(device) + } + + fn staging<'a, Iter>(data: Iter, device: &Self::Device) + where + Iter: Iterator, + { + B::staging(data, device); + } + + fn supports_dtype(device: &Self::Device, dtype: DType) -> bool { + B::supports_dtype(device, dtype) + } + + fn dtype_usage(device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet { + B::dtype_usage(device, dtype) + } + + fn device_count(type_id: u16) -> usize { + B::device_count(type_id) + } + + fn flush(device: &Self::Device) { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + client.sync(move || B::flush(&device)) + } + + fn graph_prepare(device: &Self::Device) -> Result<(), ExecutionError> { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + // `client.sync` drains the lazy queue and runs the closure without a + // device sync — the inner backend then arms persistent allocation. + client.sync(move || B::graph_prepare(&device)) + } + + fn graph_start_capture(device: &Self::Device) -> Result<(), ExecutionError> { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + // Drain any pending fused operations so nothing leaks into the capture + // window before it opens, then begin capture on the same stream. + client.sync(move || B::graph_start_capture(&device)) + } + + fn graph_stop_capture(device: &Self::Device) -> Result, ExecutionError> { + let client = GlobalFusionClient::::load(device); + let device = device.clone(); + // Drain the capture run's fused kernels onto the captured stream (this + // is where they actually launch), then close the capture. + client.sync(move || B::graph_stop_capture(&device)) + } + + unsafe fn graph_replay( + device: &Self::Device, + graph: &BackendGraph, + ) -> Result<(), ExecutionError> { + let client = GlobalFusionClient::::load(device); + // Drain any queued fused operations onto the stream first (no device + // sync), then launch the captured graph after them. + client.sync(|| ()); + // Safety: forwarded verbatim from this method's own contract. + unsafe { B::graph_replay(device, graph) } + } +} + +/// The status of a [fuser](OperationFuser). +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub enum FuserStatus { + /// No more operations can be fused. + Closed, + /// More operations can be fused. + Open, +} + +/// The properties of a [fuser](OperationFuser). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FuserProperties { + /// The score of the optimization, higher is better. + pub score: u64, + /// If the operation is ready to be executed. + pub ready: bool, +} + +/// The fusion operation abstraction allows implementations to fuse many +/// [tensor operations](OperationIr) into one, improving the performance of the backend. +/// +/// +/// # Notes +/// +/// The implementations are free to execute the registered operations the way they want to improve +/// the speed and efficiency of the computational graph. It doesn't mean that all registered +/// operations should be fused, but that another way of executing them is more efficient. +/// +/// Also, it is important to return (FuserStatus::Closed) when no more registered operation can +/// improve the performance. +pub trait OperationFuser: Send { + /// Register a new [tensor operation](OperationIr). + fn fuse(&mut self, operation: &OperationIr); + /// Finish the optimization and create a fusion operation. + fn finish(&mut self) -> O; + /// Reset the state. + fn reset(&mut self); + /// Return the builder [status](FuserStatus). + fn status(&self) -> FuserStatus; + /// Return the builder [properties](FuserProperties). + fn properties(&self) -> FuserProperties; + /// The number of operation fused. + fn len(&self) -> usize; + /// If no operations are fused. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Clone the optimization builder. + fn clone_dyn(&self) -> Box>; +} + +/// The number of operations contained in the data structure. +pub trait NumOperations: core::fmt::Debug { + /// The number of registered operations. + fn len(&self) -> usize; + /// If the current optimization is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// The name of the optimization. + fn name(&self) -> &'static str; +} + +/// The optimization created from a [fuser](OperationFuser). +pub trait Optimization: Send + NumOperations { + /// Execute the optimization. + fn execute(&mut self, context: &mut Context, execution: &OrderedExecution); + + /// Returns the state that can be serialized. + fn to_state(&self) -> R::OptimizationState; + /// Create the optimization from the state. + fn from_state(device: &R::FusionDevice, state: R::OptimizationState) -> Self; +} + +/// Type alias for `::FusionDevice`. +pub type FusionDevice = ::FusionDevice; +/// Type alias for `::FusionHandle`. +pub type FusionHandle = ::FusionHandle; +/// Client alias. +pub type Client = GlobalFusionClient; + +/// Trait that defines a runtime that will benefits from fused operations. +pub trait FusionRuntime: Send + Sync + Sized + core::fmt::Debug + 'static { + /// The state that can be serialized for an optimization. + type OptimizationState: Serialize + DeserializeOwned; + /// Optimization type for the backend. + type Optimization: Optimization; + /// Handle used to store tensor dynamically. + type FusionHandle: Clone + Send; + /// Device used by the runtime. + type FusionDevice: DeviceOps; + + /// The list of fusers that will be used to optimize the computational graph. + fn fusers(device: Self::FusionDevice) -> Vec>>; + + /// Create a cross-stream alias of `handle` to register under a fresh tensor id. + /// + /// Called by [`MultiStream::tag_shared_view`](crate::stream::MultiStream::tag_shared_view) when + /// a tensor is shared from one stream to another. The new handle must be an *independent* + /// container entry over the *same* backing buffer, so that consuming one alias (a `ReadWrite` + /// last-use that frees its handle) never frees the buffer out from under the other stream. + /// + /// The default just clones the handle, which is exactly right for local backends whose handle + /// is an `Arc`-style refcount over a device buffer — the clone is a new map entry sharing the + /// allocation. Backends whose handle is a *remote* resource (the router/remote backend, where + /// the handle is a thin id pointing at a server-side tensor) must override this: a bare clone + /// keeps the same server id, so all aliases collapse to one server handle and the first consume + /// frees it for everyone. Such backends allocate a fresh server id aliasing the same buffer. + fn alias_handle(handle: &Self::FusionHandle) -> Self::FusionHandle { + handle.clone() + } + + /// Reclaim a `ReadWrite` (last-use) handle as the fusion engine drains a block. + /// + /// The default removes the container entry; the handle's own `Drop` releases the backend + /// resource. This is correct for local backends, whose handle is an `Arc`-style buffer refcount. + /// + /// A runtime whose handle is a *remote* resource must override this. The router/remote backend + /// frees a tensor by registering an `OperationIr::Drop`, but the drained block **already** freed + /// it server-side — every consumed op (including any `Drop`) is replayed on the server, popping + /// its `ReadWrite` inputs. So letting the handle's `Drop` run here registers a *second*, + /// redundant `Drop` for the same id (the "unfused drop" traffic). The override removes the + /// container entry while suppressing that re-registration. + fn free_handle(handles: &mut HandleContainer, tensor: &TensorIr) { + handles.free(tensor); + } +} + +/// Trait that allows an existing [backend](Backend) to specify graph optimizations using +/// [operation fuser](crate::OperationFuser). +/// +/// Collective operations on the remote/router interpreter path are driven through the +/// [`DistributedOps`](burn_backend::distributed::DistributedOps) supertrait of [`Backend`], which +/// every fusion backend satisfies unconditionally. +pub trait FusionBackend: + BackendIr, Device = FusionDevice> +{ + /// The runtime used for this backend. + type FusionRuntime: FusionRuntime; + + /// Cast a float tensor and returns the resulting handle. + fn cast_float(tensor: FloatTensor, dtype: DType) -> Self::Handle; + + /// Pointer to the full precision fusion backend. + type FullPrecisionBackend: FusionBackend; +} + +// Fusion implements `BackendIr` to enable router backend usage. +impl BackendIr for Fusion { + type Handle = FusionTensor; + + fn float_tensor(handle: TensorHandle) -> FloatTensor { + handle.handle + } + + fn int_tensor(handle: TensorHandle) -> IntTensor { + handle.handle + } + + fn bool_tensor(handle: TensorHandle) -> BoolTensor { + handle.handle + } + + fn quantized_tensor(handle: TensorHandle) -> QuantizedTensor { + handle.handle + } + + fn float_tensor_handle(tensor: FloatTensor) -> Self::Handle { + tensor + } + + fn int_tensor_handle(tensor: IntTensor) -> Self::Handle { + tensor + } + + fn bool_tensor_handle(tensor: BoolTensor) -> Self::Handle { + tensor + } + + fn quantized_tensor_handle(tensor: QuantizedTensor) -> Self::Handle { + tensor + } +} diff --git a/crates/burn-fusion/src/client.rs b/crates/burn-fusion/src/client.rs new file mode 100644 index 0000000..cd4998b --- /dev/null +++ b/crates/burn-fusion/src/client.rs @@ -0,0 +1,434 @@ +use crate::{ + FusionBackend, FusionDevice, FusionHandle, FusionRuntime, FusionServer, FusionTensor, + FusionUtilities, UnfusedOp, + stream::{StreamId, execution::Operation}, +}; +use burn_backend::{Device, DeviceHandle, DeviceId, DeviceService, DeviceServiceStage}; +use burn_std::CommunicationId; + +use burn_backend::{TensorData, backend::ExecutionError}; +use burn_ir::{OperationIr, TensorId, TensorIr}; +use burn_std::stub::RwLock; +use hashbrown::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Use a mutex to communicate with the fusion server. +pub struct GlobalFusionClient { + server: DeviceHandle>, + device: FusionDevice, +} + +impl DeviceService for FusionServer { + fn init(device_id: DeviceId) -> Self { + let device = FusionDevice::::from_id(device_id); + let utilities = FusionUtilities { + initialized_comms: RwLock::new(HashSet::default()), + }; + FusionServer::new(device, utilities) + } + + fn utilities(&self) -> burn_backend::ServerUtilitiesHandle { + self.utilities.clone() + } + fn stage() -> DeviceServiceStage { + DeviceServiceStage::Upstream + } +} + +impl Clone for GlobalFusionClient +where + R: FusionRuntime, +{ + fn clone(&self) -> Self { + Self { + server: self.server.clone(), + device: self.device.clone(), + } + } +} +impl GlobalFusionClient +where + R: FusionRuntime + 'static, +{ + /// Loads the client from the given device. + pub fn load(device: &FusionDevice) -> Self { + Self { + device: device.clone(), + server: DeviceHandle::new(device.to_id()), + } + } +} + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +impl GlobalFusionClient +where + R: FusionRuntime + 'static, +{ + /// Create a new client for the given [device](FusionRuntime::FusionDevice). + pub fn new(device: FusionDevice) -> Self { + Self { + device: device.clone(), + server: DeviceHandle::new(device.to_id()), + } + } + + pub(crate) fn tag_shared_view(&self, src_stream: StreamId, src: TensorId, dst: TensorId) { + let _ = self + .server + .submit_blocking(move |server| { + server.tag_shared_view(src_stream, src, dst); + }) + .ok(); + } + + /// Register a new [tensor operation intermediate representation](OperationIr) on `stream`. + /// + /// Returns the new (uninitialized) output tensor(s) generated by the registered operation. + /// + /// Cross-stream tensor sharing is resolved out-of-band by `tag_shared_view` before + /// this call (see [`MultiStream`](crate::stream::MultiStream) docs for the full + /// strategy), so every input tensor in `repr` is assumed to live on `stream`. Pass + /// `StreamId::current()` for normal ops and the tensor's own stream for `Drop`. + pub fn register( + &self, + stream: StreamId, + repr: OperationIr, + operation: O, + ) -> Vec> + where + O: Operation + 'static, + { + // Create output tensors returned by this operation + let outputs = repr + .outputs() + .map(|output| { + FusionTensor::new( + output.id, + output.shape.clone(), + output.dtype, + self.clone(), + stream, + ) + }) + .collect(); + + // By doing this comparison, we reduce the number of bytes transferred in the device handle + // queue. + if size_of::() < size_of::>() { + // Here the [`O`] type is smaller than the [`UnfusedOp`] type, so it's better to + // transfer it directly. + self.server.submit(move |server| { + let operation = UnfusedOp::new(operation, stream); + server.register(stream, repr, operation); + }); + } else { + // Here the [`O`] type is larger than the [`UnfusedOp`] type, so it's better to + // first create the [`UnfusedOp`] before transferring it to the server. + let operation = UnfusedOp::new(operation, stream); + + self.server.submit(move |server| { + server.register(stream, repr, operation); + }); + } + + outputs + } + + /// Register all lazy computation. + pub fn sync(&self, sync_fn: impl FnOnce() -> Re + Send + 'static) -> Re { + let id = StreamId::current(); + self.server + .submit_blocking(move |server| { + server.drain_stream(id); + sync_fn() + }) + .unwrap() + } + + /// Flush the operations queue. + pub fn flush_queue(&self) { + self.server.flush_queue(); + } + + /// Create a new (uninitialized) empty tensor handle and returns its corresponding [tensor id](TensorId). + pub fn create_empty_handle(&self) -> TensorId { + let value = COUNTER.fetch_add(1, Ordering::Relaxed); + TensorId::new(value) + } + + /// Get the current device used by all operations handled by this client. + pub fn device(&self) -> &FusionDevice { + &self.device + } + + /// Create a tensor with the given handle and returns its corresponding [tensor id](TensorId). + pub fn register_tensor_handle(&self, handle: FusionHandle) -> TensorId { + let id = self.create_empty_handle(); + + self.server + .submit(move |server| server.handles.register_handle(id, handle)); + + id + } + + /// Read the values contained by a float tensor. + pub fn read_tensor_float( + self, + tensor: TensorIr, + stream: StreamId, + ) -> impl Future> + Send + where + B: FusionBackend, + { + self.server + .submit_blocking(move |server| server.float_data::(tensor, stream)) + .unwrap() + } + + /// Read the values contained by an int tensor. + pub fn read_tensor_int( + self, + tensor: TensorIr, + stream: StreamId, + ) -> impl Future> + Send + where + B: FusionBackend, + { + self.server + .submit_blocking(move |server| server.int_data::(tensor, stream)) + .unwrap() + } + + /// Read the values contained by a bool tensor. + pub fn read_tensor_bool( + self, + tensor: TensorIr, + stream: StreamId, + ) -> impl Future> + Send + where + B: FusionBackend, + { + self.server + .submit_blocking(move |server| server.bool_data::(tensor, stream)) + .unwrap() + } + + /// Read the values contained by a quantized tensor. + pub fn read_tensor_quantized( + self, + tensor: TensorIr, + stream: StreamId, + ) -> impl Future> + Send + where + B: FusionBackend, + { + self.server + .submit_blocking(move |server| server.quantized_data::(tensor, stream)) + .unwrap() + } + + /// Change the client of the given float tensor. + pub fn change_client_float( + tensor: TensorIr, + client_src: Self, + client_dst: Self, + stream: StreamId, + ) -> FusionTensor + where + B: FusionBackend, + { + let dtype = tensor.dtype; + let client_dst_cloned = client_dst.clone(); + let shape = tensor.shape.clone(); + let id = client_src.create_empty_handle(); + + let float_tensor = client_src + .server + .submit_blocking(move |server_src| server_src.read_float::(tensor, stream)) + .unwrap(); + let float_tensor = B::float_to_device(float_tensor, client_dst.device()); + client_dst.server.submit(move |server_dst| { + server_dst + .handles + .register_float_tensor::(&id, float_tensor.clone()); + }); + + FusionTensor::new(id, shape, dtype, client_dst_cloned, StreamId::current()) + } + + /// Change the client of the given int tensor. + pub fn change_client_int( + tensor: TensorIr, + client_src: Self, + client_dst: Self, + stream: StreamId, + ) -> FusionTensor + where + B: FusionBackend, + { + let dtype = tensor.dtype; + let client_dst_cloned = client_dst.clone(); + let shape = tensor.shape.clone(); + let id = client_src.create_empty_handle(); + + let int_tensor = client_src + .server + .submit_blocking(move |server_src| server_src.read_int::(tensor, stream)) + .unwrap(); + let int_tensor = B::int_to_device(int_tensor, client_dst.device()); + client_dst.server.submit(move |server_dst| { + server_dst + .handles + .register_int_tensor::(&id, int_tensor.clone()); + }); + + FusionTensor::new(id, shape, dtype, client_dst_cloned, StreamId::current()) + } + + /// Change the client of the given bool tensor. + pub fn change_client_bool( + tensor: TensorIr, + client_src: Self, + client_dst: Self, + stream: StreamId, + ) -> FusionTensor + where + B: FusionBackend, + { + let dtype = tensor.dtype; + let client_dst_cloned = client_dst.clone(); + let shape = tensor.shape.clone(); + let id = client_src.create_empty_handle(); + + let bool_tensor = client_src + .server + .submit_blocking(move |server_src| server_src.read_bool::(tensor, stream)) + .unwrap(); + let bool_tensor = B::bool_to_device(bool_tensor, client_dst.device()); + client_dst.server.submit(move |server_dst| { + server_dst + .handles + .register_bool_tensor::(&id, bool_tensor.clone()); + }); + + FusionTensor::new(id, shape, dtype, client_dst_cloned, StreamId::current()) + } + + /// Change the client of the given quantized tensor. + pub fn change_client_quantized( + tensor: TensorIr, + client_src: Self, + client_dst: Self, + stream: StreamId, + ) -> FusionTensor + where + B: FusionBackend, + { + let dtype = tensor.dtype; + let client_dst_cloned = client_dst.clone(); + let shape = tensor.shape.clone(); + let id = client_src.create_empty_handle(); + + let q_tensor = client_src + .server + .submit_blocking(move |server_src| server_src.read_quantized::(tensor, stream)) + .unwrap(); + let q_tensor = B::q_to_device(q_tensor, client_dst.device()); + client_dst.server.submit(move |server_dst| { + server_dst + .handles + .register_quantized_tensor::(&id, q_tensor.clone()); + }); + + FusionTensor::new(id, shape, dtype, client_dst_cloned, StreamId::current()) + } + + /// Resolve the given float tensor to a primitive tensor. + pub fn resolve_tensor_float(&self, tensor: FusionTensor) -> B::FloatTensorPrimitive + where + B: FusionBackend, + { + // Must evaluate `into_ir()` on the frontend thread. If evaluated inside + // the closure (server thread), a cross-stream tensor will trigger `shared_view` -> + // `submit_blocking`, causing a fatal channel re-entrancy panic (`BorrowMutError`). + let stream = tensor.stream; + let tensor = tensor.into_ir(); + self.server + .submit_blocking(move |server| { + server.drain_stream(stream); + server.resolve_server_float::(&tensor) + }) + .unwrap() + } + + /// Resolve the given int tensor to a primitive tensor. + pub fn resolve_tensor_int(&self, tensor: FusionTensor) -> B::IntTensorPrimitive + where + B: FusionBackend, + { + // Must evaluate `into_ir()` on the frontend thread. If evaluated inside + // the closure (server thread), a cross-stream tensor will trigger `shared_view` -> + // `submit_blocking`, causing a fatal channel re-entrancy panic (`BorrowMutError`). + let stream = tensor.stream; + let tensor = tensor.into_ir(); + self.server + .submit_blocking(move |server| { + server.drain_stream(stream); + server.resolve_server_int::(&tensor) + }) + .unwrap() + } + + /// Resolve the given bool tensor to a primitive tensor. + pub fn resolve_tensor_bool(&self, tensor: FusionTensor) -> B::BoolTensorPrimitive + where + B: FusionBackend, + { + // Must evaluate `into_ir()` on the frontend thread. If evaluated inside + // the closure (server thread), a cross-stream tensor will trigger `shared_view` -> + // `submit_blocking`, causing a fatal channel re-entrancy panic (`BorrowMutError`). + let stream = tensor.stream; + let tensor = tensor.into_ir(); + self.server + .submit_blocking(move |server| { + server.drain_stream(stream); + server.resolve_server_bool::(&tensor) + }) + .unwrap() + } + + /// Synchronize the collective operations. + pub fn sync_collective(&self, device: &B::Device) + where + B: FusionBackend, + { + // Ensure that all operations are resolved before calling sync_collective. + self.sync(|| ()); + B::sync_collective(device) + } + + /// Ensure that communication between the given devices is initialized. + /// Initializing communication is generally blocking, so we make sure to flush those operations. + pub fn ensure_collective_init(&self, device_ids: Vec) + where + B: FusionBackend, + { + let utilities = self + .server + .utilities() + .downcast::() + .expect("Can downcast to `FusionUtilities`"); + let id = CommunicationId::from(device_ids); + + if !utilities.initialized_comms.read().unwrap().contains(&id) { + // Communication initialization is blocking for the server, so we need to flush right away to make sure other devices + // aren't waiting indefinitely on this initialization call. + // This is already handled by cubecl, but since fusion adds another layer of streams and asynchronous submits, + // we also needed to add some logic here to flush the fusion server. + self.flush_queue(); + let mut initialized_comms = utilities.initialized_comms.write().unwrap(); + initialized_comms.insert(id); + } + } +} diff --git a/crates/burn-fusion/src/inspect.rs b/crates/burn-fusion/src/inspect.rs new file mode 100644 index 0000000..732a2d9 --- /dev/null +++ b/crates/burn-fusion/src/inspect.rs @@ -0,0 +1,521 @@ +//! Test-only introspection into fusion runtime behavior. +//! +//! When a [`FusionInspector`] is installed for a given [`StreamId`], every execution +//! plan that runs on that stream is captured as a [`FusionReport`] describing which +//! [`OperationIr`]s ended up fused into a single kernel and which ran unfused. +//! Handle snapshots from the backing [`HandleContainer`](burn_ir::HandleContainer) +//! are also recorded at quiescent points, enabling leak detection. +//! +//! [`FusionBlock`] and [`BlockKind`] are the same types the logger renders into its +//! Full-level execution table — see [`crate::stream::execution::trace`]. +//! +//! # Usage +//! +//! ```ignore +//! # use burn_fusion::inspect::FusionInspector; +//! # use burn_tensor::StreamId; +//! let stream = StreamId::current(); +//! let inspector = FusionInspector::install(stream); +//! // ... run tensor ops with a fusion-wrapped backend, then sync ... +//! let reports = inspector.drain(); +//! let block = reports[0].assert_single_fused_block(); +//! assert_eq!(block.fuser_name(), Some("ElementWise")); +//! ``` +//! +//! # Threading +//! +//! The fusion server runs on a background thread per device. Reports are captured on +//! that thread and deposited into a process-global registry keyed by [`StreamId`]. +//! Multiple inspectors for *different* streams may coexist freely. Each only sees +//! reports from its own stream, so concurrent tests on different streams are naturally +//! isolated without any serialization. +//! +//! Installing two inspectors for the *same* stream will panic. Use `test_stream()` +//! (which returns a fresh unique [`StreamId`] per call) to avoid accidental sharing. +//! +//! ## Handle leak detection +//! +//! [`HandleContainer`](burn_ir::HandleContainer) is shared per-device across the +//! whole process, so handle snapshots are not stream-scoped. Every live handle on +//! the device appears in the snapshot regardless of which stream created it. +//! Tests that assert on handle counts via [`FusionInspector::new_handles_since_baseline`] +//! must therefore be serialized against each other with `#[serial]` +//! to prevent handles from concurrent tests from appearing as false leaks. + +use burn_ir::{OperationIr, TensorId}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::stream::StreamId; +use crate::stream::execution::trace::format_table; +pub use crate::stream::execution::trace::{BlockKind, FusionBlock}; + +/// The list of fusion decisions captured for one execution plan. +#[derive(Debug, Clone)] +pub struct FusionReport { + /// One entry per leaf of the executed strategy, in execution order. + pub blocks: Vec, +} + +impl FusionReport { + /// Render the report as the same human-readable table that fusion logging emits at + /// [`FusionLogLevel::Full`](burn_std::config::fusion::FusionLogLevel). Useful for + /// rich panic messages from inspector-based tests. + pub fn format_table(&self) -> String { + format_table(&self.blocks) + } + + /// Iterate over the fused blocks only. + pub fn fused_blocks(&self) -> impl Iterator { + self.blocks + .iter() + .filter(|b| matches!(b.kind, BlockKind::Fused { .. })) + } + + /// Iterate over the unfused blocks only. + pub fn unfused_blocks(&self) -> impl Iterator { + self.blocks + .iter() + .filter(|b| matches!(b.kind, BlockKind::Unfused)) + } + + /// The total number of operations across all blocks. + pub fn total_operations(&self) -> usize { + self.blocks.iter().map(|b| b.operations.len()).sum() + } + + /// Assert that exactly one block exists and that it is fused. Returns it. + /// + /// # Panics + /// Panics if the report does not contain exactly one fused block. + pub fn assert_single_fused_block(&self) -> &FusionBlock { + assert_eq!( + self.blocks.len(), + 1, + "expected exactly one block, got {}: {:#?}", + self.blocks.len(), + self.blocks, + ); + let block = &self.blocks[0]; + assert!( + matches!(block.kind, BlockKind::Fused { .. }), + "expected block to be fused, got {:?}", + block.kind, + ); + block + } +} + +/// Test-facing helpers on [`FusionBlock`]. Defined here so they aren't part of the +/// always-compiled logging data model. +impl FusionBlock { + /// The fuser name if this block was fused, `None` otherwise. + pub fn fuser_name(&self) -> Option<&'static str> { + match self.kind { + BlockKind::Fused { name, .. } => Some(name), + BlockKind::Unfused => None, + } + } + + /// Returns `true` if the block's operations match the given matchers one-for-one, + /// in order. + pub fn ops_match(&self, matchers: &[OpMatcher]) -> bool { + if self.operations.len() != matchers.len() { + return false; + } + self.operations + .iter() + .zip(matchers.iter()) + .all(|(op, m)| m(op)) + } +} + +/// A boxed predicate over an [`OperationIr`]. The matchers returned from +/// [`matchers`] have this type. +pub type OpMatcher = Box bool + Send + Sync>; + +/// A guard installed via [`FusionInspector::install`]. Holds the shared buffers and +/// clears the global sink on drop. +#[must_use = "the inspector is uninstalled as soon as this guard is dropped"] +pub struct FusionInspector { + buffer: Arc>>, + /// The full set of [`TensorId`]s alive in the + /// [`HandleContainer`](burn_ir::HandleContainer) at the most recent quiescent + /// point. Used to assert stream-isolated leak detection: a TensorId that appears + /// here but wasn't in the baseline (see [`FusionInspector::set_baseline`]) was + /// born during this test and still hasn't been freed. + live_handles: Arc>>>, + /// Snapshot of `live_handles` captured by [`FusionInspector::set_baseline`]. Any + /// handle here is assumed to belong to unrelated work (other running tests, + /// pre-existing state) and excluded from the leak set. + baseline_handles: Arc>>>, + /// Track which stream this inspector belongs to. + stream_id: StreamId, +} + +struct Sink { + buffer: Arc>>, + live_handles: Arc>>>, +} + +/// Maps each observed [`StreamId`] to its active [`Sink`]. +fn registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +impl FusionInspector { + /// Install an inspector for `stream_id`. + /// + /// # Panics + /// Panics if an inspector for the same stream is already active. Two inspectors + /// for *different* streams may coexist freely. + pub fn install(stream_id: StreamId) -> Self { + let buffer = Arc::new(Mutex::new(Vec::new())); + let live_handles = Arc::new(Mutex::new(None)); + let baseline_handles = Arc::new(Mutex::new(None)); + + { + let mut reg = registry().lock().unwrap_or_else(|p| p.into_inner()); + assert!( + !reg.contains_key(&stream_id), + "FusionInspector: a sink for stream {stream_id:?} is already installed. Drop the previous inspector before installing a new one.", + ); + reg.insert( + stream_id, + Sink { + buffer: buffer.clone(), + live_handles: live_handles.clone(), + }, + ); + } + + Self { + stream_id, + buffer, + live_handles, + baseline_handles, + } + } + + /// Take all captured reports, clearing the buffer. + pub fn drain(&self) -> Vec { + let mut buf = self.buffer.lock().unwrap_or_else(|p| p.into_inner()); + core::mem::take(&mut *buf) + } + + /// Peek at currently captured reports without clearing. + pub fn reports(&self) -> Vec { + self.buffer + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + + /// The most recent [`HandleContainer`](burn_ir::HandleContainer) size observed by + /// the inspector, or `None` if no snapshot has been taken yet. + /// + /// Snapshots are emitted by the fusion runtime at the same quiescent points as + /// `memory-checks` (after every registered op and after every drain), so calling + /// this immediately after a `Backend::sync` reflects the post-drain state. + pub fn last_handle_count(&self) -> Option { + self.live_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + .map(|s| s.len()) + } + + /// Snapshot of the [`TensorId`]s currently in the + /// [`HandleContainer`](burn_ir::HandleContainer), or `None` if nothing has been + /// observed yet. Mirrors [`Self::last_handle_count`] but returns the full ID set + /// so callers can compute stream-isolated deltas. + pub fn last_live_handles(&self) -> Option> { + self.live_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + + /// Capture the most recent live-handle snapshot as a baseline. Handles present + /// here are excluded from [`Self::new_handles_since_baseline`]. + /// + /// Call this *after* `Backend::sync` so the fusion server has had a chance to + /// emit a snapshot reflecting the pre-test quiescent state. Tests that use this + /// method should be marked `#[serial]` to prevent handles from concurrent tests + /// from polluting the baseline window. + pub fn set_baseline(&self) { + let current = self + .live_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + .unwrap_or_default(); + *self + .baseline_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) = Some(current); + } + + /// The set of [`TensorId`]s present in the most recent snapshot that were *not* + /// in the baseline captured by [`Self::set_baseline`] — i.e., handles born + /// during this test's window that are still alive. + /// + /// If no baseline was set, every live handle is considered "new". An empty set + /// after the test syncs means no handles leaked. + pub fn new_handles_since_baseline(&self) -> HashSet { + let live = self + .live_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + .unwrap_or_default(); + let baseline = self + .baseline_handles + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + .unwrap_or_default(); + live.difference(&baseline).copied().collect() + } +} + +impl Drop for FusionInspector { + fn drop(&mut self) { + if let Ok(mut reg) = registry().lock() { + reg.remove(&self.stream_id); + } + } +} + +/// Whether an inspector is currently installed. Called from the fusion server thread. +pub(crate) fn is_installed() -> bool { + registry().lock().map(|r| !r.is_empty()).unwrap_or(false) +} + +/// Push a report into the sink registered for `stream_id`, if any. +/// Called from the fusion server thread. +pub(crate) fn emit(stream_id: StreamId, blocks: &[FusionBlock]) { + let reg = match registry().lock() { + Ok(r) => r, + Err(_) => return, + }; + let Some(sink) = reg.get(&stream_id) else { + return; + }; + let report = FusionReport { + blocks: blocks.to_vec(), + }; + if let Ok(mut buf) = sink.buffer.lock() { + buf.push(report); + } +} + +/// Record the live [`TensorId`]s for `stream_id` at a quiescent point. +pub(crate) fn emit_handle_snapshot(stream_id: StreamId, ids: impl IntoIterator) { + let reg = match registry().lock() { + Ok(r) => r, + Err(_) => return, + }; + let Some(sink) = reg.get(&stream_id) else { + return; + }; + let set: HashSet = ids.into_iter().collect(); + if let Ok(mut slot) = sink.live_handles.lock() { + *slot = Some(set); + } +} + +/// Small library of matchers for common operations, to keep tests readable. +pub mod matchers { + use super::OpMatcher; + use burn_backend::DType; + use burn_ir::{ + BaseOperationIr, FloatOperationIr, IntOperationIr, NumericOperationIr, OperationIr, + }; + + /// Matches a float add (`a + b`) on the given dtype. + pub fn is_add_float(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::NumericFloat(d, NumericOperationIr::Add(_)) if *d == dtype + ) + }) + } + + /// Matches a float multiply (`a * b`) on the given dtype. + pub fn is_mul_float(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::NumericFloat(d, NumericOperationIr::Mul(_)) if *d == dtype + ) + }) + } + + /// Matches a float multiply by scalar on the given dtype. + pub fn is_mul_scalar_float(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::NumericFloat(d, NumericOperationIr::MulScalar(_)) if *d == dtype + ) + }) + } + + /// Matches an int bitwise and. + pub fn is_bitwise_and_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseAnd(_)))) + } + + /// Matches an int bitwise and by scalar. + pub fn is_bitwise_and_scalar_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseAndScalar(_)))) + } + + /// Matches an int bitwise or. + pub fn is_bitwise_or_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseOr(_)))) + } + + /// Matches an int bitwise or by scalar. + pub fn is_bitwise_or_scalar_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseOrScalar(_)))) + } + + /// Matches an int bitwise xor. + pub fn is_bitwise_xor_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseXor(_)))) + } + + /// Matches an int bitwise xor by scalar. + pub fn is_bitwise_xor_scalar_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseXorScalar(_)))) + } + + /// Matches an int bitwise not. + pub fn is_bitwise_not_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseNot(_)))) + } + + /// Matches an int bitwise left shift. + pub fn is_bitwise_left_shift_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseLeftShift(_)))) + } + + /// Matches an int bitwise left shift by scalar. + pub fn is_bitwise_left_shift_scalar_int() -> OpMatcher { + Box::new(|op| { + matches!( + op, + OperationIr::Int(IntOperationIr::BitwiseLeftShiftScalar(_)) + ) + }) + } + + /// Matches an int bitwise right shift. + pub fn is_bitwise_right_shift_int() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::BitwiseRightShift(_)))) + } + + /// Matches an int bitwise right shift by scalar. + pub fn is_bitwise_right_shift_scalar_int() -> OpMatcher { + Box::new(|op| { + matches!( + op, + OperationIr::Int(IntOperationIr::BitwiseRightShiftScalar(_)) + ) + }) + } + + /// Matches an int-to-float cast. + pub fn is_int_into_float() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Int(IntOperationIr::IntoFloat(_)))) + } + + /// Matches a float subtraction (`a - b`) on the given dtype. + pub fn is_sub_float(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::NumericFloat(d, NumericOperationIr::Sub(_)) if *d == dtype + ) + }) + } + + /// Matches a `sum_dim` reduction on float tensors of the given dtype. + pub fn is_sum_dim_float(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::NumericFloat(d, NumericOperationIr::SumDim(_)) if *d == dtype + ) + }) + } + + /// Matches `Exp` on the given float dtype. + pub fn is_exp(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::Float(d, FloatOperationIr::Exp(_)) if *d == dtype + ) + }) + } + + /// Matches `Log` on the given float dtype. + pub fn is_log(dtype: DType) -> OpMatcher { + Box::new(move |op| { + matches!( + op, + OperationIr::Float(d, FloatOperationIr::Log(_)) if *d == dtype + ) + }) + } + + /// Matches a `reshape` on any element type (`BaseFloat`/`BaseInt`/`BaseBool`). + pub fn is_reshape() -> OpMatcher { + use burn_ir::BaseOperationIr; + Box::new(|op| { + matches!( + op, + OperationIr::BaseFloat(BaseOperationIr::Reshape(_)) + | OperationIr::BaseInt(BaseOperationIr::Reshape(_)) + | OperationIr::BaseBool(BaseOperationIr::Reshape(_)) + ) + }) + } + + /// Matches a `swap_dims` on any element type (`BaseFloat`/`BaseInt`/`BaseBool`). + pub fn is_swap_dims() -> OpMatcher { + use burn_ir::BaseOperationIr; + Box::new(|op| { + matches!( + op, + OperationIr::BaseFloat(BaseOperationIr::SwapDims(_)) + | OperationIr::BaseInt(BaseOperationIr::SwapDims(_)) + | OperationIr::BaseBool(BaseOperationIr::SwapDims(_)) + ) + }) + } + + /// Matches a `Cat` (concatenation) on any tensor kind. + pub fn is_cat() -> OpMatcher { + Box::new(|op| { + matches!( + op, + OperationIr::BaseFloat(BaseOperationIr::Cat(_)) + | OperationIr::BaseInt(BaseOperationIr::Cat(_)) + | OperationIr::BaseBool(BaseOperationIr::Cat(_)) + ) + }) + } + + /// Matches the memory-bookkeeping `Drop` op emitted when a tensor is deallocated. + /// These aren't really operations — useful to filter out when counting ops in + /// fusion-shape tests. + pub fn is_drop() -> OpMatcher { + Box::new(|op| matches!(op, OperationIr::Drop(_))) + } +} diff --git a/crates/burn-fusion/src/lib.rs b/crates/burn-fusion/src/lib.rs new file mode 100644 index 0000000..404c9cb --- /dev/null +++ b/crates/burn-fusion/src/lib.rs @@ -0,0 +1,36 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! # Burn Fusion +//! +//! This library is a part of the Burn project. It is a standalone crate that +//! can be used to perform automatic operation fusion on backends that support it. + +#[macro_use] +extern crate derive_new; + +/// Client module exposing types to communicate with the fusion server. +pub mod client; +/// Stream module exposing all tensor operations that can be optimized. +pub mod stream; + +/// Search module for stream optimizations. +pub(crate) mod search; + +mod backend; +mod op; +mod ops; +mod server; +mod tensor; + +/// Test-only introspection into fusion runtime behavior — see +/// [`inspect::FusionInspector`]. +#[cfg(feature = "test-util")] +pub mod inspect; + +pub use op::UnfusedOp; +pub(crate) use server::*; + +pub use backend::*; +pub use ops::NoOp; +pub use tensor::*; diff --git a/crates/burn-fusion/src/op.rs b/crates/burn-fusion/src/op.rs new file mode 100644 index 0000000..828cfa7 --- /dev/null +++ b/crates/burn-fusion/src/op.rs @@ -0,0 +1,116 @@ +use burn_backend::StreamId; +use burn_ir::HandleContainer; +use burn_std::arena::ReservedMemory; +use std::{cell::RefCell, sync::Arc}; + +use crate::{FusionRuntime, stream::Operation}; + +const MAX_ITEM_COUNT: usize = 256; +const MAX_ITEM_SIZE: usize = 512; + +type Data = burn_std::arena::Bytes; +type Arena = burn_std::arena::Arena; + +std::thread_local! { + static ARENA: RefCell = const {RefCell::new(Arena::new())}; +} + +/// An [operation](Operation) that isn't fused. +/// +/// This can be executed with [Self::execute]. +pub struct UnfusedOp { + kind: UnfusedOpKind, + stream_id: StreamId, +} + +impl UnfusedOp { + /// Creates a new unfused [operation](Operation) that will execute on the given [StreamId]. + pub fn new + 'static>(op: O, stream_id: StreamId) -> Self { + let arena_item = match Arena::accept::() { + true => ARENA.with_borrow_mut(|arena| arena.reserve()), + false => None, + }; + + let reserved = match arena_item { + Some(val) => val, + None => { + return UnfusedOp { + kind: UnfusedOpKind::Alloc(Arc::new(op)), + stream_id, + }; + } + }; + let reserved = reserved.init(op); + let ptr_execute = shim_execute::; + + UnfusedOp { + kind: UnfusedOpKind::Arena(UnfusedOpInArena { + reserved, + ptr_execute, + }), + stream_id, + } + } + + /// Executes the [operation](Operation) and modifies the given handles. + pub fn execute(&self, handles: &mut HandleContainer) { + self.stream_id.executes(|| match &self.kind { + UnfusedOpKind::Arena(o) => o.execute(handles), + UnfusedOpKind::Alloc(o) => o.execute(handles), + }) + } +} + +#[derive(Debug)] +struct UnfusedOpInArena { + /// The data pointer. + reserved: ReservedMemory, + /// The execute function pointer. + ptr_execute: fn(*const Data, handles: &mut HandleContainer), +} + +impl Clone for UnfusedOpInArena { + fn clone(&self) -> Self { + Self { + reserved: self.reserved.clone(), + ptr_execute: self.ptr_execute, + } + } +} + +impl UnfusedOpInArena { + fn execute(&self, handles: &mut HandleContainer) { + (self.ptr_execute)(self.reserved.as_ref(), handles); + } +} + +fn shim_execute>( + ptr_data: *const Data, + handles: &mut HandleContainer, +) { + let operation: &O = unsafe { (ptr_data as *const O).as_ref().unwrap() }; + operation.execute(handles); +} + +impl Clone for UnfusedOp { + fn clone(&self) -> Self { + Self { + kind: self.kind.clone(), + stream_id: self.stream_id, + } + } +} + +enum UnfusedOpKind { + Arena(UnfusedOpInArena), + Alloc(Arc>), +} + +impl Clone for UnfusedOpKind { + fn clone(&self) -> Self { + match self { + Self::Arena(o) => Self::Arena(o.clone()), + Self::Alloc(o) => Self::Alloc(o.clone()), + } + } +} diff --git a/crates/burn-fusion/src/ops/activation.rs b/crates/burn-fusion/src/ops/activation.rs new file mode 100644 index 0000000..ec95a9a --- /dev/null +++ b/crates/burn-fusion/src/ops/activation.rs @@ -0,0 +1,4 @@ +use crate::{Fusion, FusionBackend}; +use burn_backend::ops::ActivationOps; + +impl ActivationOps for Fusion {} diff --git a/crates/burn-fusion/src/ops/base.rs b/crates/burn-fusion/src/ops/base.rs new file mode 100644 index 0000000..90ccc78 --- /dev/null +++ b/crates/burn-fusion/src/ops/base.rs @@ -0,0 +1,15 @@ +use crate::{FusionBackend, stream::Operation}; +use burn_ir::HandleContainer; +use std::marker::PhantomData; + +/// A no-operation placeholder for the fusion backend. +/// +/// `NoOp` is an implementation of [`Operation`] that doesn't execute anything. +#[derive(new, Clone, Debug)] +pub struct NoOp { + _b: PhantomData, +} + +impl Operation for NoOp { + fn execute(&self, _handles: &mut HandleContainer) {} +} diff --git a/crates/burn-fusion/src/ops/binary.rs b/crates/burn-fusion/src/ops/binary.rs new file mode 100644 index 0000000..16594cc --- /dev/null +++ b/crates/burn-fusion/src/ops/binary.rs @@ -0,0 +1,117 @@ +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_float_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(Debug)] + struct $name { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl $name { + fn new(desc: BinaryOpIr) -> Self { + Self { + desc, + _b: PhantomData, + } + } + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let rhs = handles.get_float_tensor::(&self.desc.rhs); + let output = $ops(lhs, rhs); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_float_cmp_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let rhs = handles.get_float_tensor::(&self.desc.rhs); + let output = $ops(lhs, rhs, self.desc.out.dtype.into()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_int_cmp_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(Debug)] + struct $name { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl $name { + fn new(desc: BinaryOpIr) -> Self { + Self { + desc, + _b: PhantomData, + } + } + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_int_tensor::(&self.desc.lhs); + let rhs = handles.get_int_tensor::(&self.desc.rhs); + let output = $ops(lhs, rhs, self.desc.out.dtype.into()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_int_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_int_tensor::(&self.desc.lhs); + let rhs = handles.get_int_tensor::(&self.desc.rhs); + let output = $ops(lhs, rhs); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} diff --git a/crates/burn-fusion/src/ops/bool_tensor.rs b/crates/burn-fusion/src/ops/bool_tensor.rs new file mode 100644 index 0000000..80f25af --- /dev/null +++ b/crates/burn-fusion/src/ops/bool_tensor.rs @@ -0,0 +1,945 @@ +use crate::{ + Fusion, FusionBackend, + client::GlobalFusionClient, + get_client, + stream::{StreamId, execution::Operation}, +}; +use burn_backend::{ + BoolDType, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, TensorData, + ops::BoolTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}, +}; +use burn_ir::{ + BaseOperationIr, BinaryOpIr, BoolOperationIr, CastOpIr, CatOpIr, CreationOpIr, FlipOpIr, + GatherOpIr, HandleContainer, InitOperationIr, MaskFillOpIr, MaskWhereOpIr, OperationIr, + OperationOutput, PermuteOpIr, RepeatDimOpIr, ScalarOpIr, ScatterOpIr, SelectAssignOpIr, + SelectOpIr, ShapeOpIr, SliceAssignOpIr, SliceOpIr, SwapDimsOpIr, TensorIr, UnaryOpIr, + UnfoldOpIr, +}; +use std::marker::PhantomData; + +use super::NoOp; + +impl BoolTensorOps for Fusion { + fn bool_empty(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + #[derive(new, Debug)] + struct EmptyOps { + desc: TensorIr, + device: Device, + } + + impl Operation for EmptyOps { + fn execute(&self, handles: &mut HandleContainer) { + let output = B::bool_empty( + self.desc.shape.clone(), + &self.device, + self.desc.dtype.into(), + ); + handles.register_bool_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseBool(BaseOperationIr::Empty(desc.clone())), + EmptyOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn bool_zeros(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + #[derive(new, Debug)] + struct ZerosOps { + desc: TensorIr, + device: Device, + } + + impl Operation for ZerosOps { + fn execute(&self, handles: &mut HandleContainer) { + let output = B::bool_zeros( + self.desc.shape.clone(), + &self.device, + self.desc.dtype.into(), + ); + handles.register_bool_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseBool(BaseOperationIr::Zeros(desc.clone())), + ZerosOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn bool_ones(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + #[derive(new, Debug)] + struct OnesOps { + desc: TensorIr, + device: Device, + } + + impl Operation for OnesOps { + fn execute(&self, handles: &mut HandleContainer) { + let output = B::bool_ones( + self.desc.shape.clone(), + &self.device, + self.desc.dtype.into(), + ); + handles.register_bool_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseBool(BaseOperationIr::Ones(desc.clone())), + OnesOps::::new(desc.out, device.clone()), + ) + .output() + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + tensor.bool_into_data::().await + } + + fn bool_from_data(data: burn_backend::TensorData, device: &Device) -> BoolTensor { + let client = get_client::(device); + let dtype = data.dtype; + let tensor = B::bool_from_data(data, device); + let shape = burn_backend::TensorMetadata::shape(&tensor); + + let handle = B::bool_tensor_handle(tensor); + let desc = InitOperationIr::create(shape, dtype, || client.register_tensor_handle(handle)); + + client + .register( + StreamId::current(), + OperationIr::Init(desc), + NoOp::::new(), + ) + .output() + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + #[derive(new, Debug)] + struct IntoIntOps { + desc: CastOpIr, + _b: PhantomData, + } + + impl Operation for IntoIntOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_into_int(input, self.desc.out.dtype.into()); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Bool(BoolOperationIr::IntoInt(desc.clone())), + IntoIntOps::::new(desc), + ) + .output() + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct IntoFloatOps { + desc: CastOpIr, + _b: PhantomData, + } + + impl Operation for IntoFloatOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_into_float(input, self.desc.out.dtype.into()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Bool(BoolOperationIr::IntoFloat(desc.clone())), + IntoFloatOps::::new(desc), + ) + .output() + } + + fn bool_to_device(tensor: BoolTensor, device_dst: &Device) -> BoolTensor { + let device_src: &B::Device = tensor.client.device(); + + if device_src == device_dst { + return tensor; + } + + let id = tensor.stream; + let client_dst = get_client::(device_dst); + let client_src = tensor.client.clone(); + + GlobalFusionClient::change_client_bool::(tensor.into_ir(), client_src, client_dst, id) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + if tensor.shape == shape { + return tensor; + } + + #[derive(new, Debug)] + struct ReshapeDimsOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ReshapeDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_reshape(input, self.desc.out.shape.clone()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Reshape(desc.clone())), + ReshapeDimsOps::::new(desc), + ) + .output() + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + #[derive(new, Debug)] + struct SliceOps { + desc: SliceOpIr, + _b: PhantomData, + } + + impl Operation for SliceOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + + let output = B::bool_slice(tensor, self.desc.ranges.as_slice()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Slice(desc.clone())), + SliceOps::::new(desc), + ) + .output() + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[Slice], + value: BoolTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct SliceAssignOps { + desc: SliceAssignOpIr, + _b: PhantomData, + } + + impl Operation for SliceAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let value = handles.get_bool_tensor::(&self.desc.value); + + let output = B::bool_slice_assign(tensor, self.desc.ranges.as_slice(), value); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::SliceAssign(desc.clone())), + SliceAssignOps::::new(desc), + ) + .output() + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + #[derive(new, Debug)] + struct CatOps { + desc: CatOpIr, + _b: PhantomData, + } + + impl Operation for CatOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensors = self + .desc + .tensors + .iter() + .map(|tensor| handles.get_bool_tensor::(tensor)) + .collect(); + + let output = B::bool_cat(tensors, self.desc.dim); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Cat(desc.clone())), + CatOps::::new(desc), + ) + .output() + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + #[derive(new, Debug)] + struct EqualOps { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl Operation for EqualOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_bool_tensor::(&self.desc.lhs); + let rhs = handles.get_bool_tensor::(&self.desc.rhs); + let output = B::bool_equal(lhs, rhs); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Equal(desc.clone())), + EqualOps::::new(desc), + ) + .output() + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + #[derive(new, Debug)] + struct NotOps { + desc: UnaryOpIr, + _b: PhantomData, + } + + impl Operation for NotOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_not(input); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Bool(BoolOperationIr::Not(desc.clone())), + NotOps::::new(desc), + ) + .output() + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + #[derive(new, Debug)] + struct AndOps { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl Operation for AndOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_bool_tensor::(&self.desc.lhs); + let rhs = handles.get_bool_tensor::(&self.desc.rhs); + let output = B::bool_and(lhs, rhs); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Bool(BoolOperationIr::And(desc.clone())), + AndOps::::new(desc), + ) + .output() + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + #[derive(new, Debug)] + struct OrOps { + desc: BinaryOpIr, + _b: PhantomData, + } + + impl Operation for OrOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_bool_tensor::(&self.desc.lhs); + let rhs = handles.get_bool_tensor::(&self.desc.rhs); + let output = B::bool_or(lhs, rhs); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + client + .register( + streams, + OperationIr::Bool(BoolOperationIr::Or(desc.clone())), + OrOps::::new(desc), + ) + .output() + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + #[derive(new, Debug)] + struct SwapDimsOps { + desc: SwapDimsOpIr, + _b: PhantomData, + } + + impl Operation for SwapDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_swap_dims(input, self.desc.dim1, self.desc.dim2); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::SwapDims(desc.clone())), + SwapDimsOps::::new(desc), + ) + .output() + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + #[derive(new, Debug)] + struct PermuteDimsOps { + desc: PermuteOpIr, + _b: PhantomData, + } + + impl Operation for PermuteDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_permute(input, self.desc.axes.as_slice()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Permute(desc.clone())), + PermuteDimsOps::::new(desc), + ) + .output() + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + #[derive(new, Debug)] + struct ExpandOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ExpandOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_expand(input, self.desc.out.shape.clone()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Expand(desc.clone())), + ExpandOps::::new(desc), + ) + .output() + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + #[derive(new, Debug)] + struct FlipOps { + desc: FlipOpIr, + _b: PhantomData, + } + + impl Operation for FlipOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_flip(input, self.desc.axes.as_slice()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Flip(desc.clone())), + FlipOps::::new(desc), + ) + .output() + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + #[derive(new, Debug)] + struct RepeatDimOps { + desc: RepeatDimOpIr, + _b: PhantomData, + } + + impl Operation for RepeatDimOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + + let output = B::bool_repeat_dim(tensor, self.desc.dim, self.desc.times); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::RepeatDim(desc.clone())), + RepeatDimOps::::new(desc), + ) + .output() + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + #[derive(new, Debug)] + struct UnfoldOps { + desc: UnfoldOpIr, + _b: PhantomData, + } + + impl Operation for UnfoldOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_bool_tensor::(&self.desc.input); + let output = B::bool_unfold(input, self.desc.dim, self.desc.size, self.desc.step); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Unfold(desc.clone())), + UnfoldOps::::new(desc), + ) + .output() + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct MaskWhereOps { + desc: MaskWhereOpIr, + _b: PhantomData, + } + + impl Operation for MaskWhereOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let value = handles.get_bool_tensor::(&self.desc.value); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::bool_mask_where(tensor, mask, value); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::MaskWhere(desc.clone())), + MaskWhereOps::::new(desc), + ) + .output() + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + #[derive(new, Debug)] + struct MaskFillOps { + desc: MaskFillOpIr, + _b: PhantomData, + } + + impl Operation for MaskFillOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::bool_mask_fill(tensor, mask, self.desc.value.into()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::MaskFill(desc.clone())), + MaskFillOps::::new(desc), + ) + .output() + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct GatherOps { + desc: GatherOpIr, + _b: PhantomData, + } + + impl Operation for GatherOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::bool_gather(self.desc.dim, tensor, indices); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Gather(desc.clone())), + GatherOps::::new(desc), + ) + .output() + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct ScatterOps { + desc: ScatterOpIr, + _b: PhantomData, + } + + impl Operation for ScatterOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_bool_tensor::(&self.desc.value); + + let output = B::bool_scatter_or(self.desc.dim, tensor, indices, value); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Scatter(desc.clone())), + ScatterOps::::new(desc), + ) + .output() + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + #[derive(new, Debug)] + struct EqualElemOps { + desc: ScalarOpIr, + _b: PhantomData, + } + impl Operation for EqualElemOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_bool_tensor::(&self.desc.lhs); + let output = B::bool_equal_elem(lhs, self.desc.rhs.into()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let dtype = lhs.dtype; + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, dtype, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::EqualElem(desc.clone())), + EqualElemOps::::new(desc), + ) + .output() + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct SelectOps { + desc: SelectOpIr, + _b: PhantomData, + } + + impl Operation for SelectOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::bool_select(tensor, self.desc.dim, indices); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::Select(desc.clone())), + SelectOps::::new(desc), + ) + .output() + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + #[derive(new, Debug)] + struct SelectAssignOps { + desc: SelectAssignOpIr, + _b: PhantomData, + } + + impl Operation for SelectAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_bool_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_bool_tensor::(&self.desc.value); + + let output = B::bool_select_or(tensor, self.desc.dim, indices, value); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseBool(BaseOperationIr::SelectAssign(desc.clone())), + SelectAssignOps::::new(desc), + ) + .output() + } +} diff --git a/crates/burn-fusion/src/ops/distributed.rs b/crates/burn-fusion/src/ops/distributed.rs new file mode 100644 index 0000000..344f05f --- /dev/null +++ b/crates/burn-fusion/src/ops/distributed.rs @@ -0,0 +1,67 @@ +use std::marker::PhantomData; + +use burn_backend::{ + DeviceId, + distributed::{CollectiveTensor, DistributedOps, ReduceOperation}, + tensor::{Device, FloatTensor}, +}; +use burn_ir::{AllReduceOpIr, DistributedOperationIr, HandleContainer, OperationIr}; + +use crate::{ + Fusion, FusionBackend, get_client, + stream::{Operation, StreamId}, +}; +use burn_ir::OperationOutput; + +impl DistributedOps for Fusion { + fn all_reduce( + tensor: FloatTensor, + op: ReduceOperation, + device_ids: Vec, + ) -> CollectiveTensor { + #[derive(new, Debug)] + struct AllReduceOps { + desc: AllReduceOpIr, + op: ReduceOperation, + device_ids: Vec, + _b: PhantomData, + } + + impl Operation for AllReduceOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let output = B::all_reduce(tensor, self.op, self.device_ids.clone()); + handles.register_float_tensor::(&self.desc.out.id, unsafe { + output.assume_resolved() + }); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = AllReduceOpIr::create( + tensor.into_ir(), + op, + device_ids.iter().map(|id| (*id).into()).collect(), + || client.create_empty_handle(), + ); + + let output = client + .register( + streams, + OperationIr::Distributed(DistributedOperationIr::AllReduce(desc.clone())), + AllReduceOps::::new(desc, op, device_ids.clone()), + ) + .output(); + + client.ensure_collective_init::(device_ids); + + CollectiveTensor::new(output) + } + + fn sync_collective(device: &Device) { + let client = get_client::(device); + client.sync_collective::(device); + } +} diff --git a/crates/burn-fusion/src/ops/int_tensor.rs b/crates/burn-fusion/src/ops/int_tensor.rs new file mode 100644 index 0000000..2c74e22 --- /dev/null +++ b/crates/burn-fusion/src/ops/int_tensor.rs @@ -0,0 +1,2185 @@ +use super::NoOp; +use crate::{ + Fusion, FusionBackend, binary_int_cmp_ops, binary_int_ops, + client::GlobalFusionClient, + get_client, reduce_int_ops, scalar_int_cmp_ops, scalar_int_ops, + stream::{StreamId, execution::Operation}, + unary_int_ops, +}; +use burn_backend::{ + BoolDType, Distribution, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, + TensorData, + ops::IntTensorOps, + tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}, +}; +use burn_ir::*; +use std::marker::PhantomData; + +impl IntTensorOps for Fusion { + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + #[derive(new, Debug)] + struct EmptyOps { + desc: TensorIr, + device: Device, + } + + impl Operation for EmptyOps { + fn execute(&self, handles: &mut HandleContainer) { + let output = B::int_empty( + self.desc.shape.clone(), + &self.device, + self.desc.dtype.into(), + ); + handles.register_int_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseInt(BaseOperationIr::Empty(desc.clone())), + EmptyOps::::new(desc.out, device.clone()), + ) + .output() + } + + async fn int_into_data(tensor: IntTensor) -> Result { + tensor.int_into_data::().await + } + + fn int_from_data(data: TensorData, device: &Device) -> IntTensor { + let client = get_client::(device); + let dtype = data.dtype; + let tensor = B::int_from_data(data, device); + let shape = burn_backend::TensorMetadata::shape(&tensor); + + let handle = B::int_tensor_handle(tensor); + let desc = InitOperationIr::create(shape, dtype, || client.register_tensor_handle(handle)); + + client + .register( + StreamId::current(), + OperationIr::Init(desc), + NoOp::::new(), + ) + .output() + } + + fn int_to_device(tensor: IntTensor, device_dst: &Device) -> IntTensor { + let device_src: &B::Device = tensor.client.device(); + + if device_src == device_dst { + return tensor; + } + + let id = tensor.stream; + let client_dst = get_client::(device_dst); + let client_src = tensor.client.clone(); + + GlobalFusionClient::change_client_int::(tensor.into_ir(), client_src, client_dst, id) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + if tensor.shape == shape { + return tensor; + } + + #[derive(new, Debug)] + struct ReshapeDimsOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ReshapeDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_reshape(input, self.desc.out.shape.clone()); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Reshape(desc.clone())), + ReshapeDimsOps::::new(desc), + ) + .output() + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + #[derive(new, Debug)] + struct SliceOps { + desc: SliceOpIr, + _b: PhantomData, + } + + impl Operation for SliceOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + + let output = B::int_slice(tensor, self.desc.ranges.as_slice()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Slice(desc.clone())), + SliceOps::::new(desc), + ) + .output() + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[burn_backend::Slice], + value: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct SliceAssignOps { + desc: SliceAssignOpIr, + _b: PhantomData, + } + + impl Operation for SliceAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let value = handles.get_int_tensor::(&self.desc.value); + + let output = B::int_slice_assign(tensor, self.desc.ranges.as_slice(), value); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::SliceAssign(desc.clone())), + SliceAssignOps::::new(desc), + ) + .output() + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(MatmulOps, B::int_matmul); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = MatmulOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Matmul(desc.clone())), + MatmulOps::::new(desc.into()), + ) + .output() + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct MaskWhereOps { + desc: MaskWhereOpIr, + _b: PhantomData, + } + + impl Operation for MaskWhereOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let value = handles.get_int_tensor::(&self.desc.value); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::int_mask_where(tensor, mask, value); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::MaskWhere(desc.clone())), + MaskWhereOps::::new(desc), + ) + .output() + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + #[derive(new, Debug)] + struct MaskFillOps { + desc: MaskFillOpIr, + _b: PhantomData, + } + + impl Operation for MaskFillOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::int_mask_fill(tensor, mask, self.desc.value.into()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::MaskFill(desc.clone())), + MaskFillOps::::new(desc), + ) + .output() + } + + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct GatherOps { + desc: GatherOpIr, + _b: PhantomData, + } + + impl Operation for GatherOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::int_gather(self.desc.dim, tensor, indices); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Gather(desc.clone())), + GatherOps::::new(desc), + ) + .output() + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct ScatterOps { + desc: ScatterOpIr, + _b: PhantomData, + } + + impl Operation for ScatterOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_int_tensor::(&self.desc.value); + + let output = B::int_scatter_add(self.desc.dim, tensor, indices, value); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Scatter(desc.clone())), + ScatterOps::::new(desc), + ) + .output() + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: IndexingUpdateOp, + ) -> IntTensor { + #[derive(new, Debug)] + struct ScatterNdOps { + desc: ScatterNdOpIr, + _b: PhantomData, + } + + impl Operation for ScatterNdOps { + fn execute(&self, handles: &mut HandleContainer) { + let data = handles.get_int_tensor::(&self.desc.data); + let indices = handles.get_int_tensor::(&self.desc.indices); + let values = handles.get_int_tensor::(&self.desc.values); + + let output = B::int_scatter_nd(data, indices, values, self.desc.reduction); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = data.client.clone(); + let desc = ScatterNdOpIr::create( + data.into_ir(), + indices.into_ir(), + values.into_ir(), + reduction, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::ScatterNd(desc.clone())), + ScatterNdOps::::new(desc), + ) + .output() + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + #[derive(new, Debug)] + struct GatherNdOps { + desc: GatherNdOpIr, + _b: PhantomData, + } + + impl Operation for GatherNdOps { + fn execute(&self, handles: &mut HandleContainer) { + let data = handles.get_int_tensor::(&self.desc.data); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::int_gather_nd(data, indices); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = data.client.clone(); + let desc = GatherNdOpIr::create(data.into_ir(), indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::GatherNd(desc.clone())), + GatherNdOps::::new(desc), + ) + .output() + } + + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct SelectOps { + desc: SelectOpIr, + _b: PhantomData, + } + + impl Operation for SelectOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::int_select(tensor, self.desc.dim, indices); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Select(desc.clone())), + SelectOps::::new(desc), + ) + .output() + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + #[derive(new, Debug)] + struct SelectAssignOps { + desc: SelectAssignOpIr, + _b: PhantomData, + } + + impl Operation for SelectAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_int_tensor::(&self.desc.value); + + let output = B::int_select_add(tensor, self.desc.dim, indices, value); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::SelectAssign(desc.clone())), + SelectAssignOps::::new(desc), + ) + .output() + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + #[derive(new, Debug)] + struct CatOps { + desc: CatOpIr, + _b: PhantomData, + } + + impl Operation for CatOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensors = self + .desc + .tensors + .iter() + .map(|tensor| handles.get_int_tensor::(tensor)) + .collect(); + + let output = B::int_cat(tensors, self.desc.dim); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Cat(desc.clone())), + CatOps::::new(desc), + ) + .output() + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_int_cmp_ops!(EqualOps, B::int_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Equal(desc.clone())), + EqualOps::::new(desc), + ) + .output() + } + + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + scalar_int_cmp_ops!(EqualElemOps, B::int_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::EqualElem(desc.clone())), + EqualElemOps::::new(desc), + ) + .output() + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_int_cmp_ops!(GreaterOps, B::int_greater); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.lhs.dtype, NumericOperationIr::Greater(desc.clone())), + GreaterOps::::new(desc), + ) + .output() + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_int_cmp_ops!(GreaterElemOps, B::int_greater_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterElem(desc.clone()), + ), + GreaterElemOps::::new(desc), + ) + .output() + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_int_cmp_ops!(GreaterEqualOps, B::int_greater_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterEqual(desc.clone()), + ), + GreaterEqualOps::::new(desc), + ) + .output() + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_int_cmp_ops!(GreaterEqualElemOps, B::int_greater_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterEqualElem(desc.clone()), + ), + GreaterEqualElemOps::::new(desc), + ) + .output() + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_int_cmp_ops!(LowerOps, B::int_lower); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.lhs.dtype, NumericOperationIr::Lower(desc.clone())), + LowerOps::::new(desc), + ) + .output() + } + + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + scalar_int_cmp_ops!(LowerElemOps, B::int_lower_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerElem(desc.clone()), + ), + LowerElemOps::::new(desc), + ) + .output() + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_int_cmp_ops!(LowerEqualOps, B::int_lower_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerEqual(desc.clone()), + ), + LowerEqualOps::::new(desc), + ) + .output() + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_int_cmp_ops!(LowerEqualElemOps, B::int_lower_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerEqualElem(desc.clone()), + ), + LowerEqualElemOps::::new(desc), + ) + .output() + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(AddOps, B::int_add); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Add(desc.clone())), + AddOps::::new(desc), + ) + .output() + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(AddOps, B::int_add_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::AddScalar(desc.clone()), + ), + AddOps::::new(desc), + ) + .output() + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(SubOps, B::int_sub); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Sub(desc.clone())), + SubOps::::new(desc), + ) + .output() + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(SubOps, B::int_sub_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::SubScalar(desc.clone()), + ), + SubOps::::new(desc), + ) + .output() + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(MulOps, B::int_mul); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Mul(desc.clone())), + MulOps::::new(desc), + ) + .output() + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(MulOps, B::int_mul_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MulScalar(desc.clone()), + ), + MulOps::::new(desc), + ) + .output() + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(DivOps, B::int_div); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Div(desc.clone())), + DivOps::::new(desc), + ) + .output() + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(DivOps, B::int_div_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::DivScalar(desc.clone()), + ), + DivOps::::new(desc), + ) + .output() + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(ModOps, B::int_remainder); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Rem(desc.clone())), + ModOps::::new(desc), + ) + .output() + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(ModOps, B::int_remainder_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::RemScalar(desc.clone()), + ), + ModOps::::new(desc), + ) + .output() + } + + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + #[derive(new, Debug)] + struct ZerosOps { + desc: TensorIr, + device: Device, + } + + impl Operation for ZerosOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.desc.shape.clone(); + let output = B::int_zeros(shape, &self.device, self.desc.dtype.into()); + handles.register_int_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseInt(BaseOperationIr::Zeros(desc.clone())), + ZerosOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + #[derive(new, Debug)] + struct OnesOps { + desc: TensorIr, + device: Device, + } + + impl Operation for OnesOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.desc.shape.clone(); + let output = B::int_ones(shape, &self.device, self.desc.dtype.into()); + handles.register_int_tensor::(&self.desc.id, output); + } + } + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseInt(BaseOperationIr::Ones(desc.clone())), + OnesOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + #[derive(new, Debug)] + struct FullOps { + out: TensorIr, + elem: ScalarIr, + device: Device, + } + + impl Operation for FullOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.out.shape.clone(); + let output = + B::int_full(shape, self.elem.into(), &self.device, self.out.dtype.into()); + handles.register_int_tensor::(&self.out.id, output); + } + } + + let client = get_client::(device); + let dtype = dtype.into(); + let value = fill_value.into(); + let desc = FullOpIr::create(shape, dtype, value, || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Full(desc.clone())), + FullOps::::new(desc.out, desc.value, device.clone()), + ) + .output() + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + unary_int_ops!(SumOps, B::int_sum, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Sum(desc.clone())), + SumOps::::new(desc.into()), + ) + .output() + } + + fn int_sum_dim(tensor: IntTensor, axis: usize) -> IntTensor { + reduce_int_ops!(SumDimOps, |tensor, axis, _| B::int_sum_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + ReduceDimOpIr::create(tensor.into_ir(), axis, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::SumDim(desc.clone())), + SumDimOps::::new(desc), + ) + .output() + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + unary_int_ops!(ProdOps, B::int_prod, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Prod(desc.clone())), + ProdOps::::new(desc.into()), + ) + .output() + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(ProdDimOps, |tensor, axis, _| B::int_prod_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::ProdDim(desc.clone())), + ProdDimOps::::new(desc), + ) + .output() + } + + fn int_mean(tensor: IntTensor) -> IntTensor { + unary_int_ops!(MeanOps, B::int_mean, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Mean(desc.clone())), + MeanOps::::new(desc.into()), + ) + .output() + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(MeanDimOps, |tensor, axis, _| B::int_mean_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::MeanDim(desc.clone())), + MeanDimOps::::new(desc), + ) + .output() + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + #[derive(new, Debug)] + struct CumsumOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumsumOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_cumsum(input, self.desc.axis); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::CumSum(desc.clone())), + CumsumOps::::new(desc), + ) + .output() + } + + fn int_arange_step( + range: std::ops::Range, + step: usize, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + // Build `arange` from fusable ops (`int_full` of ones → `cumsum` → affine) instead of the + // host-materialized `from_data` default. This keeps it *inside* the fusion graph: for the + // remote backend it means no per-call `Init` op shipping the index data over the network — + // the indices are computed on the device and ride the cached graph like any other op. + // (`int_arange` delegates here, so this covers both.) + // + // Match the default impl's contract: `range.step_by(0)` panics, so a zero step is a misuse + // we surface loudly rather than silently returning an empty tensor. + assert!(step != 0, "arange step must be non-zero"); + if range.end <= range.start { + return Self::int_full(Shape::new([0]), 0i64.into(), device, dtype); + } + + let count = ((range.end - range.start) as usize).div_ceil(step); + // `ones.cumsum` is `[1, 2, ..., count]`; map the 1-based index `k` to `start + (k - 1) * step`. + let ones = Self::int_full(Shape::new([count]), 1i64.into(), device, dtype); + let counting = Self::int_cumsum(ones, 0); + let scaled = Self::int_mul_scalar(counting, (step as i64).into()); + Self::int_add_scalar(scaled, (range.start - step as i64).into()) + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + #[derive(new, Debug)] + struct CumprodOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumprodOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_cumprod(input, self.desc.axis); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::CumProd(desc.clone())), + CumprodOps::::new(desc), + ) + .output() + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + #[derive(new, Debug)] + struct CumminOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumminOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_cummin(input, self.desc.axis); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::CumMin(desc.clone())), + CumminOps::::new(desc), + ) + .output() + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + #[derive(new, Debug)] + struct CummaxOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CummaxOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_cummax(input, self.desc.axis); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::CumMax(desc.clone())), + CummaxOps::::new(desc), + ) + .output() + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(ArgMaxOps, |tensor, axis, _| B::int_argmax(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::ArgMax(desc.clone())), + ArgMaxOps::::new(desc), + ) + .output() + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + reduce_int_ops!(ArgTopKOps, B::int_argtopk); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::ArgTopK(desc.clone())), + ArgTopKOps::::new(desc), + ) + .output() + } + + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + reduce_int_ops!(TopKOps, B::int_topk); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::TopK(desc.clone())), + TopKOps::::new(desc), + ) + .output() + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(ArgMinOps, |tensor, axis, _| B::int_argmin(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::ArgMin(desc.clone())), + ArgMinOps::::new(desc), + ) + .output() + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + #[derive(new, Debug)] + struct ClampOps { + desc: ClampOpIr, + _b: PhantomData, + } + + impl Operation for ClampOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.tensor); + let output = B::int_clamp(input, self.desc.min.into(), self.desc.max.into()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let min = min.into(); + let max = max.into(); + let desc = ClampOpIr::create(tensor.into_ir(), min, max, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Clamp(desc.clone())), + ClampOps::::new(desc), + ) + .output() + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + unary_int_ops!(AbsOps, B::int_abs); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Abs(desc.clone())), + AbsOps::::new(desc), + ) + .output() + } + + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct IntoFloatOps { + desc: CastOpIr, + _b: PhantomData, + } + + impl Operation for IntoFloatOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_into_float(input, self.desc.out.dtype.into()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::IntoFloat(desc.clone())), + IntoFloatOps::::new(desc), + ) + .output() + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + #[derive(new, Debug)] + struct SwapDimsOps { + desc: SwapDimsOpIr, + _b: PhantomData, + } + + impl Operation for SwapDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_swap_dims(input, self.desc.dim1, self.desc.dim2); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::SwapDims(desc.clone())), + SwapDimsOps::::new(desc), + ) + .output() + } + + fn int_max(tensor: IntTensor) -> IntTensor { + unary_int_ops!(MaxOps, B::int_max, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Max(desc.clone())), + MaxOps::::new(desc.into()), + ) + .output() + } + + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(MaxDimOps, |tensor, axis, _| B::int_max_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::MaxDim(desc.clone())), + MaxDimOps::::new(desc), + ) + .output() + } + + fn int_max_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + #[derive(new, Debug)] + struct MaxDimWithIndicesOps { + desc: ReduceDimWithIndicesOpIr, + _b: PhantomData, + } + + impl Operation for MaxDimWithIndicesOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let (output, indices) = B::int_max_dim_with_indices(tensor, self.desc.dim); + + handles.register_int_tensor::(&self.desc.out.id, output); + handles.register_int_tensor::(&self.desc.out_indices.id, indices); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, dtype, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(dtype, NumericOperationIr::MaxDimWithIndices(desc.clone())), + MaxDimWithIndicesOps::::new(desc), + ) + .outputs() + .into() + } + + fn int_min(tensor: IntTensor) -> IntTensor { + unary_int_ops!(MinOps, B::int_min, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Min(desc.clone())), + MinOps::::new(desc.into()), + ) + .output() + } + + fn int_max_abs(tensor: IntTensor) -> IntTensor { + unary_int_ops!(MaxAbsOps, B::int_max_abs, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::MaxAbs(desc.clone())), + MaxAbsOps::::new(desc.into()), + ) + .output() + } + + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(MaxAbsDimOps, |tensor, axis, _| B::int_max_abs_dim( + tensor, axis + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MaxAbsDim(desc.clone()), + ), + MaxAbsDimOps::::new(desc), + ) + .output() + } + + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + reduce_int_ops!(MinDimOps, |tensor, axis, _| B::int_min_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::MinDim(desc.clone())), + MinDimOps::::new(desc), + ) + .output() + } + + fn int_min_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + #[derive(new, Debug)] + struct MinDimWithIndicesOps { + desc: ReduceDimWithIndicesOpIr, + _b: PhantomData, + } + + impl Operation for MinDimWithIndicesOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + let (output, indices) = B::int_min_dim_with_indices(tensor, self.desc.dim); + + handles.register_int_tensor::(&self.desc.out.id, output); + handles.register_int_tensor::(&self.desc.out_indices.id, indices); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, dtype, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(dtype, NumericOperationIr::MinDimWithIndices(desc.clone())), + MinDimWithIndicesOps::::new(desc), + ) + .outputs() + .into() + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + #[derive(new, Debug)] + struct IntRandomOps { + desc: RandomOpIr, + device: Device, + } + + impl Operation for IntRandomOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.desc.out.shape.clone(); + let output = B::int_random( + shape, + self.desc.distribution, + &self.device, + self.desc.out.dtype.into(), + ); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let dtype = dtype.into(); + let client = get_client::(device); + let desc = RandomOpIr::create(shape, dtype, distribution, || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::NumericInt(dtype, NumericOperationIr::IntRandom(desc.clone())), + IntRandomOps::::new(desc, device.clone()), + ) + .output() + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + #[derive(new, Debug)] + struct PermuteDimsOps { + desc: PermuteOpIr, + _b: PhantomData, + } + + impl Operation for PermuteDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_permute(input, self.desc.axes.as_slice()); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Permute(desc.clone())), + PermuteDimsOps::::new(desc), + ) + .output() + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + #[derive(new, Debug)] + struct ExpandOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ExpandOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_expand(input, self.desc.out.shape.clone()); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Expand(desc.clone())), + ExpandOps::::new(desc), + ) + .output() + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + #[derive(new, Debug)] + struct FlipDimsOps { + desc: FlipOpIr, + _b: PhantomData, + } + + impl Operation for FlipDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let axes = &self.desc.axes; + let output = B::int_flip(input, axes); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Flip(desc.clone())), + FlipDimsOps::::new(desc), + ) + .output() + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + #[derive(new, Debug)] + struct RepeatDimOps { + desc: RepeatDimOpIr, + _b: PhantomData, + } + + impl Operation for RepeatDimOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_int_tensor::(&self.desc.tensor); + + let output = B::int_repeat_dim(tensor, self.desc.dim, self.desc.times); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::RepeatDim(desc.clone())), + RepeatDimOps::::new(desc), + ) + .output() + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(BitwiseAndOps, B::bitwise_and); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseAnd(desc.clone())), + BitwiseAndOps::::new(desc), + ) + .output() + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(BitwiseAndOps, B::bitwise_and_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseAndScalar(desc.clone())), + BitwiseAndOps::::new(desc), + ) + .output() + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(BitwiseOrOps, B::bitwise_or); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseOr(desc.clone())), + BitwiseOrOps::::new(desc), + ) + .output() + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(BitwiseOrOps, B::bitwise_or_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseOrScalar(desc.clone())), + BitwiseOrOps::::new(desc), + ) + .output() + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(BitwiseXorOps, B::bitwise_xor); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseXor(desc.clone())), + BitwiseXorOps::::new(desc), + ) + .output() + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(BitwiseXorOps, B::bitwise_xor_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseXorScalar(desc.clone())), + BitwiseXorOps::::new(desc), + ) + .output() + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + unary_int_ops!(BitwiseNotOps, B::bitwise_not); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseNot(desc.clone())), + BitwiseNotOps::::new(desc), + ) + .output() + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(BitwiseLeftShiftOps, B::bitwise_left_shift); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseLeftShift(desc.clone())), + BitwiseLeftShiftOps::::new(desc), + ) + .output() + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(BitwiseLeftShiftOps, B::bitwise_left_shift_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseLeftShiftScalar(desc.clone())), + BitwiseLeftShiftOps::::new(desc), + ) + .output() + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(BitwiseRightShiftOps, B::bitwise_right_shift); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseRightShift(desc.clone())), + BitwiseRightShiftOps::::new(desc), + ) + .output() + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(BitwiseRightShiftOps, B::bitwise_right_shift_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Int(IntOperationIr::BitwiseRightShiftScalar(desc.clone())), + BitwiseRightShiftOps::::new(desc), + ) + .output() + } + + fn int_cast(tensor: IntTensor, dtype: burn_backend::IntDType) -> IntTensor { + #[derive(new, Debug)] + struct CastOps { + desc: CastOpIr, + dtype: burn_backend::IntDType, + _b: PhantomData, + } + + impl Operation for CastOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output: B::IntTensorPrimitive = B::int_cast(input, self.dtype); + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Cast(desc.clone())), + CastOps::::new(desc, dtype), + ) + .output() + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + #[derive(new, Debug)] + struct UnfoldOps { + desc: UnfoldOpIr, + _b: PhantomData, + } + + impl Operation for UnfoldOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = B::int_unfold(input, self.desc.dim, self.desc.size, self.desc.step); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Unfold(desc.clone())), + UnfoldOps::::new(desc), + ) + .output() + } + + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + binary_int_ops!(PowOps, B::int_powi); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericInt(desc.out.dtype, NumericOperationIr::Powi(desc.clone())), + PowOps::::new(desc), + ) + .output() + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + scalar_int_ops!(PowiOps, B::int_powi_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::PowiScalar(desc.clone()), + ), + PowiOps::::new(desc), + ) + .output() + } +} diff --git a/crates/burn-fusion/src/ops/mod.rs b/crates/burn-fusion/src/ops/mod.rs new file mode 100644 index 0000000..327815d --- /dev/null +++ b/crates/burn-fusion/src/ops/mod.rs @@ -0,0 +1,13 @@ +mod activation; +mod binary; +mod bool_tensor; +mod distributed; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; +mod unary; + +mod base; +pub use base::NoOp; diff --git a/crates/burn-fusion/src/ops/module.rs b/crates/burn-fusion/src/ops/module.rs new file mode 100644 index 0000000..feef822 --- /dev/null +++ b/crates/burn-fusion/src/ops/module.rs @@ -0,0 +1,1709 @@ +use crate::{ + Fusion, FusionBackend, + stream::{StreamId, execution::Operation}, +}; +use burn_backend::{ + ops::{ + ConvOptions, ConvTransposeOptions, DeformConv2dBackward, DeformConvOptions, + InterpolateOptions, MaxPool1dBackward, MaxPool1dWithIndices, MaxPool2dBackward, + MaxPool2dWithIndices, ModuleOps, + }, + tensor::{FloatTensor, IntTensor}, +}; +use burn_ir::*; +use burn_std::IntDType; +use std::marker::PhantomData; + +macro_rules! make_ops { + ($name:ident, $desc:ty, $fn:expr) => { + #[derive(new, Debug)] + struct $name { + desc: $desc, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + #[allow(clippy::redundant_closure_call)] + $fn(&self.desc, handles) + } + } + }; +} + +impl ModuleOps> for Fusion { + // linear and its backward ops fall back to the default ModuleOps impl, + // which decomposes into matmul + add / matmul + sum. This preserves + // downstream fusion in burn-cubecl-fusion, which matches on those + // primitive IR nodes. + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + make_ops!(Conv1dOps, Conv1dOpIr, |desc: &Conv1dOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + let output = B::conv1d(x, weight, bias, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + }); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv1dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv1d(desc.clone())), + Conv1dOps::::new(desc), + ) + .output() + } + + fn conv1d_x_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<1>, + ) -> FloatTensor> { + make_ops!( + Conv1dXBackwardOps, + Conv1dXBackwardOpIr, + |desc: &Conv1dXBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv1d_x_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv1dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv1dXBackward(desc.clone())), + Conv1dXBackwardOps::::new(desc), + ) + .output() + } + + fn conv1d_weight_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<1>, + ) -> FloatTensor> { + make_ops!( + Conv1dWeightBackwardOps, + Conv1dWeightBackwardOpIr, + |desc: &Conv1dWeightBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv1d_weight_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv1dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv1dWeightBackward(desc.clone())), + Conv1dWeightBackwardOps::::new(desc), + ) + .output() + } + + fn conv1d_bias_backward( + x: FloatTensor>, + bias: FloatTensor>, + output_grad: FloatTensor>, + ) -> FloatTensor> { + make_ops!( + Conv1dBiasBackwardOps, + Conv1dBiasBackwardOpIr, + |desc: &Conv1dBiasBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv1d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv1dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv1dBiasBackward(desc.clone())), + Conv1dBiasBackwardOps::::new(desc), + ) + .output() + } + + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor { + make_ops!(Conv2dOps, Conv2dOpIr, |args: &Conv2dOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let x = handles.get_float_tensor::(&args.x); + let weight = handles.get_float_tensor::(&args.weight); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv2d(x, weight, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + }); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv2dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv2d(desc.clone())), + Conv2dOps::::new(desc), + ) + .output() + } + + fn conv2d_x_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<2>, + ) -> FloatTensor> { + make_ops!( + Conv2dXBackwardOps, + Conv2dXBackwardOpIr, + |desc: &Conv2dXBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv2d_x_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv2dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv2dXBackward(desc.clone())), + Conv2dXBackwardOps::::new(desc), + ) + .output() + } + + fn conv2d_weight_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<2>, + ) -> FloatTensor> { + make_ops!( + Conv2dWeightBackwardOps, + Conv2dWeightBackwardOpIr, + |desc: &Conv2dWeightBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv2d_weight_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv2dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv2dWeightBackward(desc.clone())), + Conv2dWeightBackwardOps::::new(desc), + ) + .output() + } + + fn conv2d_bias_backward( + x: FloatTensor>, + bias: FloatTensor>, + output_grad: FloatTensor>, + ) -> FloatTensor> { + make_ops!( + Conv2dBiasBackwardOps, + Conv2dBiasBackwardOpIr, + |desc: &Conv2dBiasBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv2d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv2dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv2dBiasBackward(desc.clone())), + Conv2dBiasBackwardOps::::new(desc), + ) + .output() + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + make_ops!( + DeformConv2dOps, + DeformConv2dOpIr, + |args: &DeformConv2dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let offset = handles.get_float_tensor::(&args.offset); + let weight = handles.get_float_tensor::(&args.weight); + let mask = args + .mask + .as_ref() + .map(|mask| handles.get_float_tensor::(mask)); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = + B::deform_conv2d(x, offset, weight, mask, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = DeformConv2dOpIr::create( + x.into_ir(), + offset.into_ir(), + weight.into_ir(), + mask.map(|mask| mask.into_ir()), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::DeformableConv2d(Box::new(desc.clone()))), + DeformConv2dOps::::new(desc), + ) + .output() + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + make_ops!( + DeformConv2dBackwardOps, + DeformConv2dBackwardOpIr, + |args: &DeformConv2dBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let offset = handles.get_float_tensor::(&args.offset); + let weight = handles.get_float_tensor::(&args.weight); + let mask = args + .mask + .as_ref() + .map(|mask| handles.get_float_tensor::(mask)); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + let output_grad = handles.get_float_tensor::(&args.out_grad); + + let output = B::deform_conv2d_backward( + x, + offset, + weight, + mask, + bias, + output_grad, + args.options.clone().into(), + ); + + handles.register_float_tensor::(&args.input_grad.id, output.x_grad); + handles.register_float_tensor::(&args.offset_grad.id, output.offset_grad); + handles.register_float_tensor::(&args.weight_grad.id, output.weight_grad); + if let Some((mask_grad, field)) = output.mask_grad.zip(args.mask_grad.as_ref()) { + handles.register_float_tensor::(&field.id, mask_grad); + } + if let Some((bias_grad, field)) = output.bias_grad.zip(args.bias_grad.as_ref()) { + handles.register_float_tensor::(&field.id, bias_grad); + } + } + ); + + let has_bias = bias.is_some(); + let has_mask = mask.is_some(); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = DeformConv2dBackwardOpIr::create( + x.into_ir(), + offset.into_ir(), + weight.into_ir(), + mask.map(|mask| mask.into_ir()), + bias.map(|bias| bias.into_ir()), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + let mut outputs = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::DeformableConv2dBackward(Box::new( + desc.clone(), + ))), + DeformConv2dBackwardOps::::new(desc), + ) + .into_iter(); + + // When the number of outputs is variable, the order is important + let input_grad = outputs.next().unwrap(); + let offset_grad = outputs.next().unwrap(); + let weight_grad = outputs.next().unwrap(); + let mask_grad = has_mask.then(|| outputs.next().unwrap()); + let bias_grad = has_bias.then(|| outputs.next().unwrap()); + + DeformConv2dBackward::new(input_grad, offset_grad, weight_grad, mask_grad, bias_grad) + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + make_ops!(Conv3dOps, Conv3dOpIr, |args: &Conv3dOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let x = handles.get_float_tensor::(&args.x); + let weight = handles.get_float_tensor::(&args.weight); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv3d(x, weight, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + }); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv3dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv3d(desc.clone())), + Conv3dOps::::new(desc), + ) + .output() + } + + fn conv3d_x_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<3>, + ) -> FloatTensor> { + make_ops!( + Conv3dXBackwardOps, + Conv3dXBackwardOpIr, + |desc: &Conv3dXBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv3d_x_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv3dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv3dXBackward(desc.clone())), + Conv3dXBackwardOps::::new(desc), + ) + .output() + } + + fn conv3d_weight_backward( + x: FloatTensor>, + weight: FloatTensor>, + output_grad: FloatTensor>, + options: ConvOptions<3>, + ) -> FloatTensor> { + make_ops!( + Conv3dWeightBackwardOps, + Conv3dWeightBackwardOpIr, + |desc: &Conv3dWeightBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = + B::conv3d_weight_backward(x, weight, output_grad, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv3dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv3dWeightBackward(desc.clone())), + Conv3dWeightBackwardOps::::new(desc), + ) + .output() + } + + fn conv3d_bias_backward( + x: FloatTensor>, + bias: FloatTensor>, + output_grad: FloatTensor>, + ) -> FloatTensor> { + make_ops!( + Conv3dBiasBackwardOps, + Conv3dBiasBackwardOpIr, + |desc: &Conv3dBiasBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv3d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = Conv3dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Conv3dBiasBackward(desc.clone())), + Conv3dBiasBackwardOps::::new(desc), + ) + .output() + } + + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + make_ops!( + ConvTranspose1dOps, + ConvTranspose1dOpIr, + |args: &ConvTranspose1dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let weight = handles.get_float_tensor::(&args.weight); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose1d(x, weight, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = ConvTranspose1dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::ConvTranspose1d(desc.clone())), + ConvTranspose1dOps::::new(desc), + ) + .output() + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + make_ops!( + ConvTranspose2dOps, + ConvTranspose2dOpIr, + |args: &ConvTranspose2dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let weight = handles.get_float_tensor::(&args.weight); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose2d(x, weight, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = ConvTranspose2dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::ConvTranspose2d(desc.clone())), + ConvTranspose2dOps::::new(desc), + ) + .output() + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + make_ops!( + ConvTranspose3dOps, + ConvTranspose3dOpIr, + |args: &ConvTranspose3dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let weight = handles.get_float_tensor::(&args.weight); + let bias = args + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose3d(x, weight, bias, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = ConvTranspose3dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::ConvTranspose3d(desc.clone())), + ConvTranspose3dOps::::new(desc), + ) + .output() + } + + fn avg_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + AvgPool1dOps, + AvgPool1dOpIr, + |args: &AvgPool1dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::avg_pool1d( + x, + args.kernel_size, + args.stride, + args.padding, + args.count_include_pad, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AvgPool1dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AvgPool1d(desc.clone())), + AvgPool1dOps::::new(desc), + ) + .output() + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + AvgPool2dOps, + AvgPool2dOpIr, + |args: &AvgPool2dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::avg_pool2d( + x, + args.kernel_size, + args.stride, + args.padding, + args.count_include_pad, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AvgPool2dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AvgPool2d(desc.clone())), + AvgPool2dOps::::new(desc), + ) + .output() + } + + fn avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + AvgPool1dBackwardOps, + AvgPool1dBackwardOpIr, + |args: &AvgPool1dBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let output = B::avg_pool1d_backward( + x, + grad, + args.kernel_size, + args.stride, + args.padding, + args.count_include_pad, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AvgPool1dBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AvgPool1dBackward(desc.clone())), + AvgPool1dBackwardOps::::new(desc), + ) + .output() + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + AvgPool2dBackwardOps, + AvgPool2dBackwardOpIr, + |args: &AvgPool2dBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let output = B::avg_pool2d_backward( + x, + grad, + args.kernel_size, + args.stride, + args.padding, + args.count_include_pad, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AvgPool2dBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AvgPool2dBackward(desc.clone())), + AvgPool2dBackwardOps::::new(desc), + ) + .output() + } + + fn max_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + MaxPool1dOps, + MaxPool1dOpIr, + |args: &MaxPool1dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::max_pool1d( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool1dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool1d(desc.clone())), + MaxPool1dOps::::new(desc), + ) + .output() + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + make_ops!( + MaxPool2dOps, + MaxPool2dOpIr, + |args: &MaxPool2dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::max_pool2d( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool2dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool2d(desc.clone())), + MaxPool2dOps::::new(desc), + ) + .output() + } + + fn max_pool1d_with_indices( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool1dWithIndices { + make_ops!( + MaxPool1dWithIndicesOps, + MaxPool1dWithIndicesOpIr, + |args: &MaxPool1dWithIndicesOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::max_pool1d_with_indices( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + args.out_indices.dtype.into(), + ); + + handles.register_float_tensor::(&args.out.id, output.output); + handles.register_int_tensor::(&args.out_indices.id, output.indices); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool1dWithIndicesOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype.into(), + || client.create_empty_handle(), + ); + + let [out, out_indices] = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool1dWithIndices(desc.clone())), + MaxPool1dWithIndicesOps::::new(desc), + ) + .outputs(); + + MaxPool1dWithIndices::new(out, out_indices) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + make_ops!( + MaxPool2dWithIndicesOps, + MaxPool2dWithIndicesOpIr, + |args: &MaxPool2dWithIndicesOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::max_pool2d_with_indices( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + args.out_indices.dtype.into(), + ); + + handles.register_float_tensor::(&args.out.id, output.output); + handles.register_int_tensor::(&args.out_indices.id, output.indices); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool2dWithIndicesOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype.into(), + || client.create_empty_handle(), + ); + + let [out, out_indices] = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool2dWithIndices(desc.clone())), + MaxPool2dWithIndicesOps::::new(desc), + ) + .outputs(); + + MaxPool2dWithIndices::new(out, out_indices) + } + + fn max_pool1d_with_indices_backward( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool1dBackward { + make_ops!( + MaxPool1dWithIndicesBackwardOps, + MaxPool1dWithIndicesBackwardOpIr, + |args: &MaxPool1dWithIndicesBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let indices = handles.get_int_tensor::(&args.indices); + let output = B::max_pool1d_with_indices_backward( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + grad, + indices, + ); + + handles.register_float_tensor::(&args.out.id, output.x_grad); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool1dWithIndicesBackwardOpIr::create( + x.into_ir(), + output_grad.into_ir(), + indices.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + let out = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool1dWithIndicesBackward( + desc.clone(), + )), + MaxPool1dWithIndicesBackwardOps::::new(desc), + ) + .output(); + + MaxPool1dBackward::new(out) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + make_ops!( + MaxPool2dWithIndicesBackwardOps, + MaxPool2dWithIndicesBackwardOpIr, + |args: &MaxPool2dWithIndicesBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let indices = handles.get_int_tensor::(&args.indices); + let output = B::max_pool2d_with_indices_backward( + x, + args.kernel_size, + args.stride, + args.padding, + args.dilation, + args.ceil_mode, + grad, + indices, + ); + + handles.register_float_tensor::(&args.out.id, output.x_grad); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = MaxPool2dWithIndicesBackwardOpIr::create( + x.into_ir(), + output_grad.into_ir(), + indices.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + let out = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::MaxPool2dWithIndicesBackward( + desc.clone(), + )), + MaxPool2dWithIndicesBackwardOps::::new(desc), + ) + .output(); + + MaxPool2dBackward::new(out) + } + + fn adaptive_avg_pool1d(x: FloatTensor, output_size: usize) -> FloatTensor { + make_ops!( + AdaptiveAvgPool1dOps, + AdaptiveAvgPool1dOpIr, + |args: &AdaptiveAvgPool1dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::adaptive_avg_pool1d(x, args.output_size); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AdaptiveAvgPool1dOpIr::create(x.into_ir(), output_size, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool1d(desc.clone())), + AdaptiveAvgPool1dOps::::new(desc), + ) + .output() + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + make_ops!( + AdaptiveAvgPool2dOps, + AdaptiveAvgPool2dOpIr, + |args: &AdaptiveAvgPool2dOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::adaptive_avg_pool2d(x, args.output_size); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AdaptiveAvgPool2dOpIr::create(x.into_ir(), output_size, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool2d(desc.clone())), + AdaptiveAvgPool2dOps::::new(desc), + ) + .output() + } + + fn adaptive_avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + make_ops!( + AdaptiveAvgPool1dBackwardOps, + AdaptiveAvgPool1dBackwardOpIr, + |args: &AdaptiveAvgPool1dBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let output = B::adaptive_avg_pool1d_backward(x, grad); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AdaptiveAvgPool1dBackwardOpIr::create(x.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool1dBackward(desc.clone())), + AdaptiveAvgPool1dBackwardOps::::new(desc), + ) + .output() + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + make_ops!( + AdaptiveAvgPool2dBackwardOps, + AdaptiveAvgPool2dBackwardOpIr, + |args: &AdaptiveAvgPool2dBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let output = B::adaptive_avg_pool2d_backward(x, grad); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = AdaptiveAvgPool2dBackwardOpIr::create(x.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool2dBackward(desc.clone())), + AdaptiveAvgPool2dBackwardOps::::new(desc), + ) + .output() + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + make_ops!( + InterpolateOps, + InterpolateOpIr, + |args: &InterpolateOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let output = B::interpolate(x, args.output_size, args.options.clone().into()); + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = InterpolateOpIr::create(x.into_ir(), output_size, options.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Interpolate(desc.clone())), + InterpolateOps::::new(desc), + ) + .output() + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + make_ops!( + InterpolateBackwardOps, + InterpolateBackwardOpIr, + |args: &InterpolateBackwardOpIr, handles: &mut HandleContainer| { + let x = handles.get_float_tensor::(&args.x); + let grad = handles.get_float_tensor::(&args.grad); + let output = + B::interpolate_backward(x, grad, args.output_size, args.options.clone().into()); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = x.client.clone(); + let desc = InterpolateBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + output_size, + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::InterpolateBackward(desc.clone())), + InterpolateBackwardOps::::new(desc), + ) + .output() + } + + fn attention( + query: FloatTensor>, + key: FloatTensor>, + value: FloatTensor>, + mask: Option>>, + attn_bias: Option>>, + options: burn_backend::ops::AttentionModuleOptions, + ) -> FloatTensor> { + make_ops!( + AttentionOps, + AttentionOpIr, + |args: &AttentionOpIr, handles: &mut HandleContainer| { + let query = handles.get_float_tensor::(&args.query); + let key = handles.get_float_tensor::(&args.key); + let value = handles.get_float_tensor::(&args.value); + let mask = args.mask.as_ref().map(|m| handles.get_bool_tensor::(m)); + let attn_bias = args + .attn_bias + .as_ref() + .map(|ab| handles.get_float_tensor::(ab)); + + let output = B::attention( + query, + key, + value, + mask, + attn_bias, + args.options.clone().into(), + ); + + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + + let client = query.client.clone(); + let desc = AttentionOpIr::create( + query.into_ir(), + key.into_ir(), + value.into_ir(), + mask.map(|m| m.into_ir()), + attn_bias.map(|ab| ab.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Attention(desc.clone())), + AttentionOps::::new(desc), + ) + .output() + } + + fn rfft( + signal: FloatTensor>, + dim: usize, + n: Option, + ) -> (FloatTensor>, FloatTensor>) { + make_ops!(RfftOps, RfftOpIr, |desc: &RfftOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let signal = handles.get_float_tensor::(&desc.signal); + let (re, im) = B::rfft(signal, desc.dim, desc.n); + + handles.register_float_tensor::(&desc.out_re.id, re); + handles.register_float_tensor::(&desc.out_im.id, im); + }); + + let streams = StreamId::current(); + let client = signal.client.clone(); + + let desc = RfftOpIr::create(signal.into_ir(), dim, n, || client.create_empty_handle()); + + let mut outputs = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::Rfft(desc.clone())), + RfftOps::::new(desc), + ) + .into_iter(); + + (outputs.next().unwrap(), outputs.next().unwrap()) + } + + fn irfft( + spectrum_re: FloatTensor>, + spectrum_im: FloatTensor>, + dim: usize, + n: Option, + ) -> FloatTensor> { + make_ops!(IRfftOps, IRfftOpIr, |desc: &IRfftOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let input_re = handles.get_float_tensor::(&desc.input_re); + let input_im = handles.get_float_tensor::(&desc.input_im); + + let signal = B::irfft(input_re, input_im, desc.dim, desc.n); + handles.register_float_tensor::(&desc.out_signal.id, signal); + }); + + let streams = StreamId::current(); + let client = spectrum_re.client.clone(); + + let desc = IRfftOpIr::create(spectrum_re.into_ir(), spectrum_im.into_ir(), dim, n, || { + client.create_empty_handle() + }); + + let mut outputs = client + .register( + streams, + OperationIr::Module(ModuleOperationIr::IRfft(desc.clone())), + IRfftOps::::new(desc), + ) + .into_iter(); + + outputs.next().unwrap() + } + + fn has_ctc_loss_backward() -> bool { + B::has_ctc_loss_backward() + } + + fn ctc_loss( + log_probs: FloatTensor>, + targets: IntTensor>, + input_lengths: IntTensor>, + target_lengths: IntTensor>, + blank: usize, + ) -> FloatTensor> { + // CTC is treated as its own non-fuseable IR node, the same way + // `attention` and `conv2d` are. The execute callback drains the input + // handles and dispatches to `B::ctc_loss` on the inner backend, which + // either runs a native kernel (cubecl, libtorch) or the decomposed + // default - either way it executes on raw inner-backend tensors, + // never re-entering the fusion stream. + make_ops!(CtcLossOps, CtcLossOpIr, |args: &CtcLossOpIr, + handles: &mut HandleContainer< + B::Handle, + >| { + let log_probs = handles.get_float_tensor::(&args.log_probs); + let targets = handles.get_int_tensor::(&args.targets); + let input_lengths = handles.get_int_tensor::(&args.input_lengths); + let target_lengths = handles.get_int_tensor::(&args.target_lengths); + let output = B::ctc_loss( + log_probs, + targets, + input_lengths, + target_lengths, + args.blank, + ); + handles.register_float_tensor::(&args.out.id, output); + }); + + let streams = StreamId::current(); + let client = log_probs.client.clone(); + let desc = CtcLossOpIr::create( + log_probs.into_ir(), + targets.into_ir(), + input_lengths.into_ir(), + target_lengths.into_ir(), + blank, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::CtcLoss(desc.clone())), + CtcLossOps::::new(desc), + ) + .output() + } + + fn ctc_loss_backward( + log_probs: FloatTensor>, + targets: IntTensor>, + input_lengths: IntTensor>, + target_lengths: IntTensor>, + grad_loss: FloatTensor>, + blank: usize, + ) -> FloatTensor> { + // Mirrors `ctc_loss`: a typed IR node that dispatches to the inner + // backend's native backward kernel. + make_ops!( + CtcLossBackwardOps, + CtcLossBackwardOpIr, + |args: &CtcLossBackwardOpIr, handles: &mut HandleContainer| { + let log_probs = handles.get_float_tensor::(&args.log_probs); + let targets = handles.get_int_tensor::(&args.targets); + let input_lengths = handles.get_int_tensor::(&args.input_lengths); + let target_lengths = handles.get_int_tensor::(&args.target_lengths); + let grad_loss = handles.get_float_tensor::(&args.grad_loss); + let output = B::ctc_loss_backward( + log_probs, + targets, + input_lengths, + target_lengths, + grad_loss, + args.blank, + ); + handles.register_float_tensor::(&args.out.id, output); + } + ); + + let streams = StreamId::current(); + let client = log_probs.client.clone(); + let desc = CtcLossBackwardOpIr::create( + log_probs.into_ir(), + targets.into_ir(), + input_lengths.into_ir(), + target_lengths.into_ir(), + grad_loss.into_ir(), + blank, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::Module(ModuleOperationIr::CtcLossBackward(desc.clone())), + CtcLossBackwardOps::::new(desc), + ) + .output() + } +} diff --git a/crates/burn-fusion/src/ops/qtensor.rs b/crates/burn-fusion/src/ops/qtensor.rs new file mode 100644 index 0000000..c0ffece --- /dev/null +++ b/crates/burn-fusion/src/ops/qtensor.rs @@ -0,0 +1,502 @@ +use std::marker::PhantomData; + +use burn_backend::{ + DType, ExecutionError, FloatDType, Shape, Slice, TensorData, TensorMetadata, TensorPrimitive, + get_device_settings, + ops::QTensorOps, + quantization::{QuantPropagation, QuantScheme, QuantizationParametersPrimitive}, + tensor::{Device, FloatTensor, IntTensor, QuantizedTensor}, +}; +use burn_ir::{ + BaseOperationIr, DequantizeOpIr, FlipOpIr, FloatOperationIr, GatherOpIr, HandleContainer, + InitOperationIr, MatmulOpIr, OperationIr, OperationOutput, PermuteOpIr, + QuantizationParametersIr, QuantizeOpIr, SelectOpIr, ShapeOpIr, SliceOpIr, SwapDimsOpIr, +}; + +use crate::{ + Fusion, FusionBackend, + client::GlobalFusionClient, + get_client, + stream::{StreamId, execution::Operation}, +}; + +use super::NoOp; + +impl QTensorOps for Fusion { + fn q_from_data(data: TensorData, device: &Device) -> QuantizedTensor { + let client = get_client::(device); + let dtype = data.dtype; + let tensor = B::q_from_data(data, device); + let shape = burn_backend::TensorMetadata::shape(&tensor); + + let handle = B::quantized_tensor_handle(tensor); + let desc = InitOperationIr::create(shape, dtype, || client.register_tensor_handle(handle)); + + client + .register( + StreamId::current(), + OperationIr::Init(desc), + NoOp::::new(), + ) + .output() + } + + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + #[derive(new, Debug)] + struct QuantizeOp { + desc: QuantizeOpIr, + _b: PhantomData, + } + + impl Operation for QuantizeOp { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let scales = handles.get_float_tensor::(&self.desc.qparams.scales); + + let qparams = QuantizationParametersPrimitive { scales }; + let output = B::quantize(tensor, &self.desc.scheme, qparams); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let qparams = QuantizationParametersIr { + scales: qparams.scales.into_ir(), + }; + let desc = QuantizeOpIr::create(tensor.into_ir(), qparams, *scheme, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.tensor.dtype, FloatOperationIr::Quantize(desc.clone())), + QuantizeOp::::new(desc), + ) + .output() + } + + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct DequantizeOp { + desc: DequantizeOpIr, + _b: PhantomData, + } + + impl Operation for DequantizeOp { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_quantized_tensor::(&self.desc.input); + + let output = B::dequantize(tensor, self.desc.out.dtype.into()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let dtype = dtype.into(); + let desc = DequantizeOpIr::create(tensor.into_ir(), dtype, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(dtype, FloatOperationIr::Dequantize(desc.clone())), + DequantizeOp::::new(desc), + ) + .output() + } + + fn q_to_device( + tensor: QuantizedTensor, + device_dst: &Device, + ) -> QuantizedTensor { + let device_src: &B::Device = tensor.client.device(); + let device_dst: B::Device = device_dst.clone(); + + if device_src == &device_dst { + return tensor; + } + + let id = tensor.stream; + let client_dst = get_client::(&device_dst); + let client_src = tensor.client.clone(); + + GlobalFusionClient::change_client_quantized::( + tensor.into_ir(), + client_src, + client_dst, + id, + ) + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + if tensor.shape == shape { + return tensor; + } + + #[derive(new, Debug)] + struct ReshapeDimsOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ReshapeDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_quantized_tensor::(&self.desc.input); + let output = B::q_reshape(input, self.desc.out.shape.clone()); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Reshape(desc.clone())), + ReshapeDimsOps::::new(desc), + ) + .output() + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + tensor.q_into_data::().await + } + + fn q_swap_dims( + tensor: QuantizedTensor, + dim1: usize, + dim2: usize, + ) -> QuantizedTensor { + #[derive(new, Debug)] + struct SwapDimsOps { + desc: SwapDimsOpIr, + _b: PhantomData, + } + + impl Operation for SwapDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_quantized_tensor::(&self.desc.input); + let output = B::q_swap_dims(input, self.desc.dim1, self.desc.dim2); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::SwapDims(desc.clone())), + SwapDimsOps::::new(desc), + ) + .output() + } + + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + #[derive(new, Debug)] + struct PermuteDimsOps { + desc: PermuteOpIr, + _b: PhantomData, + } + + impl Operation for PermuteDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_quantized_tensor::(&self.desc.input); + let output = B::q_permute(input, self.desc.axes.as_slice()); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Permute(desc.clone())), + PermuteDimsOps::::new(desc), + ) + .output() + } + + fn q_flip(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + #[derive(new, Debug)] + struct FlipOps { + desc: FlipOpIr, + _b: PhantomData, + } + + impl Operation for FlipOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_quantized_tensor::(&self.desc.input); + let output = B::q_flip(input, &self.desc.axes); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Flip(desc.clone())), + FlipOps::::new(desc), + ) + .output() + } + + fn q_gather( + dim: usize, + tensor: QuantizedTensor, + indices: IntTensor, + ) -> QuantizedTensor { + #[derive(new, Debug)] + struct GatherOps { + desc: GatherOpIr, + _b: PhantomData, + } + + impl Operation for GatherOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_quantized_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::q_gather(self.desc.dim, tensor, indices); + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Gather(desc.clone())), + GatherOps::::new(desc), + ) + .output() + } + + fn q_select( + tensor: QuantizedTensor, + dim: usize, + indices: IntTensor, + ) -> QuantizedTensor { + #[derive(new, Debug)] + struct SelectOps { + desc: SelectOpIr, + _b: PhantomData, + } + + impl Operation for SelectOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_quantized_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::q_select(tensor, self.desc.dim, indices); + + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Select(desc.clone())), + SelectOps::::new(desc), + ) + .output() + } + + fn q_slice(tensor: QuantizedTensor, slices: &[Slice]) -> QuantizedTensor { + #[derive(new, Debug)] + struct SliceOps { + desc: SliceOpIr, + _b: PhantomData, + } + + impl Operation for SliceOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_quantized_tensor::(&self.desc.tensor); + + let output = B::q_slice(tensor, self.desc.ranges.as_slice()); + + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Slice(desc.clone())), + SliceOps::::new(desc), + ) + .output() + } + + fn q_expand(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + #[derive(new, Debug)] + struct ExpandOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ExpandOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_quantized_tensor::(&self.desc.input); + let output = B::q_expand(input, self.desc.out.shape.clone()); + + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Expand(desc.clone())), + ExpandOps::::new(desc), + ) + .output() + } + + fn q_matmul(lhs: TensorPrimitive, rhs: TensorPrimitive) -> TensorPrimitive { + #[derive(new, Debug)] + struct MatmulOps { + desc: MatmulOpIr, + lhs_quantized: bool, + rhs_quantized: bool, + _b: PhantomData, + } + + impl Operation for MatmulOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = match self.lhs_quantized { + true => { + TensorPrimitive::QFloat(handles.get_quantized_tensor::(&self.desc.lhs)) + } + false => TensorPrimitive::Float(handles.get_float_tensor::(&self.desc.lhs)), + }; + let rhs = match self.rhs_quantized { + true => { + TensorPrimitive::QFloat(handles.get_quantized_tensor::(&self.desc.rhs)) + } + false => TensorPrimitive::Float(handles.get_float_tensor::(&self.desc.rhs)), + }; + let output = B::q_matmul(lhs, rhs); + match output { + TensorPrimitive::Float(output) => { + handles.register_float_tensor::(&self.desc.out.id, output); + } + TensorPrimitive::QFloat(output) => { + handles.register_quantized_tensor::(&self.desc.out.id, output); + } + } + } + } + + let mut propagation = QuantPropagation::Inhibit; + let mut scheme = QuantScheme::default(); + let streams = StreamId::current(); + let mut lhs_quantized = false; + let mut rhs_quantized = false; + + let client = match &lhs { + TensorPrimitive::Float(lhs) => lhs.client.clone(), + TensorPrimitive::QFloat(lhs) => lhs.client.clone(), + }; + + let settings = get_device_settings::(client.device()); + + if let TensorPrimitive::QFloat(lhs) = &lhs { + propagation = settings.quantization.propagation; + scheme = lhs.scheme(); + lhs_quantized = true; + } + if let TensorPrimitive::QFloat(rhs) = &rhs { + propagation = settings.quantization.propagation; + scheme = rhs.scheme(); + rhs_quantized = true; + } + + let dtype = match propagation { + QuantPropagation::Propagate => DType::QFloat(scheme), + QuantPropagation::Inhibit => match (&lhs, &rhs) { + (TensorPrimitive::Float(t), _) | (_, TensorPrimitive::Float(t)) => t.dtype, + _ => settings.float_dtype.into(), + }, + }; + + let lhs = match lhs { + TensorPrimitive::Float(lhs) => lhs.into_ir(), + TensorPrimitive::QFloat(lhs) => lhs.into_ir(), + }; + let rhs = match rhs { + TensorPrimitive::Float(rhs) => rhs.into_ir(), + TensorPrimitive::QFloat(rhs) => rhs.into_ir(), + }; + + let desc = MatmulOpIr::create_mixed(lhs, rhs, dtype, || client.create_empty_handle()); + + let out = client + .register( + streams, + OperationIr::Float(dtype, FloatOperationIr::Matmul(desc.clone())), + MatmulOps::::new(desc, lhs_quantized, rhs_quantized), + ) + .output(); + + match propagation { + QuantPropagation::Propagate => TensorPrimitive::QFloat(out), + QuantPropagation::Inhibit => TensorPrimitive::Float(out), + } + } +} diff --git a/crates/burn-fusion/src/ops/tensor.rs b/crates/burn-fusion/src/ops/tensor.rs new file mode 100644 index 0000000..c47ac05 --- /dev/null +++ b/crates/burn-fusion/src/ops/tensor.rs @@ -0,0 +1,2592 @@ +use super::NoOp; +use crate::{ + Fusion, FusionBackend, binary_float_cmp_ops, binary_float_ops, + client::GlobalFusionClient, + get_client, reduce_float_ops, reduce_float2int_ops, scalar_float_cmp_ops, scalar_float_ops, + stream::{StreamId, execution::Operation}, + unary_float_ops, +}; +use burn_backend::{ + BoolDType, Distribution, ExecutionError, FloatDType, IntDType, Scalar, Shape, Slice, + TensorData, + ops::{FloatTensorOps, GridSampleOptions}, + tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}, +}; +use burn_ir::*; +use std::marker::PhantomData; + +impl FloatTensorOps for Fusion { + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(data), + fields(?data.shape, ?data.dtype) + ))] + fn float_from_data(data: TensorData, device: &Device) -> FloatTensor { + let client = get_client::(device); + let dtype = data.dtype; + let tensor = B::float_from_data(data, device); + let shape = burn_backend::TensorMetadata::shape(&tensor); + + let handle = B::float_tensor_handle(tensor); + let desc = InitOperationIr::create(shape, dtype, || client.register_tensor_handle(handle)); + + client + .register( + StreamId::current(), + OperationIr::Init(desc), + NoOp::::new(), + ) + .output() + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + #[derive(new, Debug)] + struct RandomOps { + desc: RandomOpIr, + device: Device, + } + + impl Operation for RandomOps { + fn execute(&self, handles: &mut HandleContainer) { + let output: B::FloatTensorPrimitive = B::float_random( + self.desc.out.shape.clone(), + self.desc.distribution, + &self.device, + self.desc.out.dtype.into(), + ); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let dtype = dtype.into(); + let client = get_client::(device); + let desc = RandomOpIr::create(shape, dtype, distribution, || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::Float(dtype, FloatOperationIr::Random(desc.clone())), + RandomOps::::new(desc, device.clone()), + ) + .output() + } + + fn float_zeros(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct ZerosOps { + out: TensorIr, + device: Device, + } + + impl Operation for ZerosOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.out.shape.clone(); + let output = B::float_zeros(shape, &self.device, self.out.dtype.into()); + handles.register_float_tensor::(&self.out.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseFloat(BaseOperationIr::Zeros(desc.clone())), + ZerosOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn float_ones(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct OnesOps { + out: TensorIr, + device: Device, + } + + impl Operation for OnesOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.out.shape.clone(); + let output = B::float_ones(shape, &self.device, self.out.dtype.into()); + handles.register_float_tensor::(&self.out.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseFloat(BaseOperationIr::Ones(desc.clone())), + OnesOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn float_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + #[derive(new, Debug)] + struct FullOps { + out: TensorIr, + elem: ScalarIr, + device: Device, + } + + impl Operation for FullOps { + fn execute(&self, handles: &mut HandleContainer) { + let shape = self.out.shape.clone(); + let dtype = self.out.dtype.into(); + let output: B::FloatTensorPrimitive = + B::float_full(shape, self.elem.into(), &self.device, dtype); + handles.register_float_tensor::(&self.out.id, output); + } + } + + let dtype = dtype.into(); + let client = get_client::(device); + let value = fill_value.into(); + let desc = FullOpIr::create(shape, dtype, value, || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::NumericFloat(dtype, NumericOperationIr::Full(desc.clone())), + FullOps::::new(desc.out, desc.value, device.clone()), + ) + .output() + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields( + from = ?tensor.client.device(), + shape = ?tensor.shape, + dtype = ?tensor.dtype + ) + ))] + async fn float_into_data(tensor: FloatTensor) -> Result { + tensor.into_data::().await + } + + #[cfg_attr(feature = "tracing", tracing::instrument( + level="trace", + skip(tensor), + fields( + from = ?tensor.client.device(), + shape = ?tensor.shape, + dtype = ?tensor.dtype, + ) + ))] + fn float_to_device(tensor: FloatTensor, device_dst: &Device) -> FloatTensor { + let device_src: &B::Device = tensor.client.device(); + + if device_src == device_dst { + return tensor; + } + + let id = tensor.stream; + let client_dst = get_client::(device_dst); + let client_src = tensor.client.clone(); + + GlobalFusionClient::change_client_float::(tensor.into_ir(), client_src, client_dst, id) + } + + fn float_into_int(tensor: FloatTensor, dtype: IntDType) -> IntTensor { + #[derive(new, Debug)] + struct IntoIntOps { + desc: CastOpIr, + _b: PhantomData, + } + + impl Operation for IntoIntOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_into_int(input, self.desc.out.dtype.into()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.input.dtype, FloatOperationIr::IntoInt(desc.clone())), + IntoIntOps::::new(desc), + ) + .output() + } + + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct EmptyOps { + desc: TensorIr, + device: Device, + } + + impl Operation for EmptyOps { + fn execute(&self, handles: &mut HandleContainer) { + let output = B::float_empty( + self.desc.shape.clone(), + &self.device, + self.desc.dtype.into(), + ); + handles.register_float_tensor::(&self.desc.id, output); + } + } + + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register( + StreamId::current(), + OperationIr::BaseFloat(BaseOperationIr::Empty(desc.clone())), + EmptyOps::::new(desc.out, device.clone()), + ) + .output() + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(AddOps, B::float_add); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Add(desc.clone())), + AddOps::::new(desc), + ) + .output() + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(AddOps, B::float_add_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::AddScalar(desc.clone()), + ), + AddOps::::new(desc), + ) + .output() + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + #[derive(new, Debug)] + struct ClampOps { + desc: ClampOpIr, + _b: PhantomData, + } + + impl Operation for ClampOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.tensor); + let output = B::float_clamp(input, self.desc.min.into(), self.desc.max.into()); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let min = min.into(); + let max = max.into(); + let desc = ClampOpIr::create(tensor.into_ir(), min, max, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.tensor.dtype, + NumericOperationIr::Clamp(desc.clone()), + ), + ClampOps::::new(desc), + ) + .output() + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(SubOps, B::float_sub); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Sub(desc.clone())), + SubOps::::new(desc), + ) + .output() + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(SubOps, B::float_sub_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::SubScalar(desc.clone()), + ), + SubOps::::new(desc), + ) + .output() + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(MulOps, B::float_mul); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Mul(desc.clone())), + MulOps::::new(desc), + ) + .output() + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(MulOps, B::float_mul_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MulScalar(desc.clone()), + ), + MulOps::::new(desc), + ) + .output() + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(DivOps, B::float_div); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Div(desc.clone())), + DivOps::::new(desc), + ) + .output() + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(DivOps, B::float_div_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::DivScalar(desc.clone()), + ), + DivOps::::new(desc), + ) + .output() + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(ModOps, B::float_remainder); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Rem(desc.clone())), + ModOps::::new(desc), + ) + .output() + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(ModOps, B::float_remainder_scalar); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::RemScalar(desc.clone()), + ), + ModOps::::new(desc), + ) + .output() + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(MatmulOps, B::float_matmul); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = MatmulOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Matmul(desc.clone())), + MatmulOps::::new(desc.into()), + ) + .output() + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + #[derive(new, Debug)] + struct CrossOps { + desc: CrossOpIr, + _b: PhantomData, + } + + impl Operation for CrossOps { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let rhs = handles.get_float_tensor::(&self.desc.rhs); + let output = B::float_cross(lhs, rhs, self.desc.dim); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = CrossOpIr::create(lhs.into_ir(), rhs.into_ir(), dim, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Cross(desc.clone())), + CrossOps::::new(desc), + ) + .output() + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + #[derive(new, Debug)] + struct SwapDimsOps { + desc: SwapDimsOpIr, + _b: PhantomData, + } + + impl Operation for SwapDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_swap_dims(input, self.desc.dim1, self.desc.dim2); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::SwapDims(desc.clone())), + SwapDimsOps::::new(desc), + ) + .output() + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + if tensor.shape == shape { + return tensor; + } + + #[derive(new, Debug)] + struct ReshapeDimsOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ReshapeDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_reshape(input, self.desc.out.shape.clone()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Reshape(desc.clone())), + ReshapeDimsOps::::new(desc), + ) + .output() + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct GatherOps { + desc: GatherOpIr, + _b: PhantomData, + } + + impl Operation for GatherOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::float_gather(self.desc.dim, tensor, indices); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Gather(desc.clone())), + GatherOps::::new(desc), + ) + .output() + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct ScatterOps { + desc: ScatterOpIr, + _b: PhantomData, + } + + impl Operation for ScatterOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_float_tensor::(&self.desc.value); + + let output = B::float_scatter_add(self.desc.dim, tensor, indices, value); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Scatter(desc.clone())), + ScatterOps::::new(desc), + ) + .output() + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: IndexingUpdateOp, + ) -> FloatTensor { + #[derive(new, Debug)] + struct ScatterNdOps { + desc: ScatterNdOpIr, + _b: PhantomData, + } + + impl Operation for ScatterNdOps { + fn execute(&self, handles: &mut HandleContainer) { + let data = handles.get_float_tensor::(&self.desc.data); + let indices = handles.get_int_tensor::(&self.desc.indices); + let values = handles.get_float_tensor::(&self.desc.values); + + let output = B::float_scatter_nd(data, indices, values, self.desc.reduction); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = data.client.clone(); + let desc = ScatterNdOpIr::create( + data.into_ir(), + indices.into_ir(), + values.into_ir(), + reduction, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::ScatterNd(desc.clone())), + ScatterNdOps::::new(desc), + ) + .output() + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + #[derive(new, Debug)] + struct GatherNdOps { + desc: GatherNdOpIr, + _b: PhantomData, + } + + impl Operation for GatherNdOps { + fn execute(&self, handles: &mut HandleContainer) { + let data = handles.get_float_tensor::(&self.desc.data); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::float_gather_nd(data, indices); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = data.client.clone(); + let desc = GatherNdOpIr::create(data.into_ir(), indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::GatherNd(desc.clone())), + GatherNdOps::::new(desc), + ) + .output() + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct SelectOps { + desc: SelectOpIr, + _b: PhantomData, + } + + impl Operation for SelectOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + + let output = B::float_select(tensor, self.desc.dim, indices); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Select(desc.clone())), + SelectOps::::new(desc), + ) + .output() + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct SelectAssignOps { + desc: SelectAssignOpIr, + _b: PhantomData, + } + + impl Operation for SelectAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let indices = handles.get_int_tensor::(&self.desc.indices); + let value = handles.get_float_tensor::(&self.desc.value); + + let output = B::float_select_add(tensor, self.desc.dim, indices, value); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::SelectAssign(desc.clone())), + SelectAssignOps::::new(desc), + ) + .output() + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + #[derive(new, Debug)] + struct SliceOps { + desc: SliceOpIr, + _b: PhantomData, + } + + impl Operation for SliceOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + + let output = B::float_slice(tensor, self.desc.ranges.as_slice()); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Slice(desc.clone())), + SliceOps::::new(desc), + ) + .output() + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[burn_backend::Slice], + value: FloatTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct SliceAssignOps { + desc: SliceAssignOpIr, + _b: PhantomData, + } + + impl Operation for SliceAssignOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let value = handles.get_float_tensor::(&self.desc.value); + + let output = B::float_slice_assign(tensor, self.desc.ranges.as_slice(), value); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::SliceAssign(desc.clone())), + SliceAssignOps::::new(desc), + ) + .output() + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + #[derive(new, Debug)] + struct MaskWhereOps { + desc: MaskWhereOpIr, + _b: PhantomData, + } + + impl Operation for MaskWhereOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let value = handles.get_float_tensor::(&self.desc.value); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::float_mask_where(tensor, mask, value); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::MaskWhere(desc.clone())), + MaskWhereOps::::new(desc), + ) + .output() + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + #[derive(new, Debug)] + struct MaskFillOps { + desc: MaskFillOpIr, + _b: PhantomData, + } + + impl Operation for MaskFillOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let mask = handles.get_bool_tensor::(&self.desc.mask); + + let output = B::float_mask_fill(tensor, mask, self.desc.value.into()); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::MaskFill(desc.clone())), + MaskFillOps::::new(desc), + ) + .output() + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float_cmp_ops!(EqualOps, B::float_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Equal(desc.clone())), + EqualOps::::new(desc), + ) + .output() + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_float_cmp_ops!(EqualElemOps, B::float_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::EqualElem(desc.clone())), + EqualElemOps::::new(desc), + ) + .output() + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float_cmp_ops!(GreaterOps, B::float_greater); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::Greater(desc.clone()), + ), + GreaterOps::::new(desc), + ) + .output() + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_float_cmp_ops!(GreaterElemOps, B::float_greater_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterElem(desc.clone()), + ), + GreaterElemOps::::new(desc), + ) + .output() + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float_cmp_ops!(GreaterEqualOps, B::float_greater_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterEqual(desc.clone()), + ), + GreaterEqualOps::::new(desc), + ) + .output() + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_float_cmp_ops!(GreaterEqualElemOps, B::float_greater_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterEqualElem(desc.clone()), + ), + GreaterEqualElemOps::::new(desc), + ) + .output() + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float_cmp_ops!(LowerOps, B::float_lower); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat(desc.lhs.dtype, NumericOperationIr::Lower(desc.clone())), + LowerOps::::new(desc), + ) + .output() + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_float_cmp_ops!(LowerElemOps, B::float_lower_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerElem(desc.clone()), + ), + LowerElemOps::::new(desc), + ) + .output() + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + binary_float_cmp_ops!(LowerEqualOps, B::float_lower_equal); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerEqual(desc.clone()), + ), + LowerEqualOps::::new(desc), + ) + .output() + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + scalar_float_cmp_ops!(LowerEqualElemOps, B::float_lower_equal_elem); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerEqualElem(desc.clone()), + ), + LowerEqualElemOps::::new(desc), + ) + .output() + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(SumOps, B::float_sum, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Sum(desc.clone())), + SumOps::::new(desc.into()), + ) + .output() + } + + fn float_sum_dim(tensor: FloatTensor, axis: usize) -> FloatTensor { + reduce_float_ops!(SumDimOps, |tensor, axis, _| B::float_sum_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + ReduceDimOpIr::create(tensor.into_ir(), axis, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::SumDim(desc.clone())), + SumDimOps::::new(desc), + ) + .output() + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ProdOps, B::float_prod, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Prod(desc.clone())), + ProdOps::::new(desc.into()), + ) + .output() + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce_float_ops!(ProdDimOps, |tensor, axis, _| B::float_prod_dim( + tensor, axis + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::ProdDim(desc.clone()), + ), + ProdDimOps::::new(desc), + ) + .output() + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(MeanOps, B::float_mean, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Mean(desc.clone())), + MeanOps::::new(desc.into()), + ) + .output() + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce_float_ops!(MeanDimOps, |tensor, axis, _| B::float_mean_dim( + tensor, axis + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MeanDim(desc.clone()), + ), + MeanDimOps::::new(desc), + ) + .output() + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CumsumOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumsumOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_cumsum(input, self.desc.axis); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::CumSum(desc.clone())), + CumsumOps::::new(desc), + ) + .output() + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CumprodOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumprodOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_cumprod(input, self.desc.axis); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::CumProd(desc.clone()), + ), + CumprodOps::::new(desc), + ) + .output() + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CumminOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CumminOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_cummin(input, self.desc.axis); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::CumMin(desc.clone())), + CumminOps::::new(desc), + ) + .output() + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CummaxOps { + desc: DimOpIr, + _b: PhantomData, + } + + impl Operation for CummaxOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_cummax(input, self.desc.axis); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::CumMax(desc.clone())), + CummaxOps::::new(desc), + ) + .output() + } + + fn float_exp(lhs: FloatTensor) -> FloatTensor { + unary_float_ops!(ExpOps, B::float_exp); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = UnaryOpIr::create(lhs.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Exp(desc.clone())), + ExpOps::::new(desc), + ) + .output() + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(LogOps, B::float_log); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Log(desc.clone())), + LogOps::::new(desc), + ) + .output() + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(Log1pOps, B::float_log1p); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Log1p(desc.clone())), + Log1pOps::::new(desc), + ) + .output() + } + + fn float_powf_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + scalar_float_ops!(PowfOps, B::float_powf_scalar_impl); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::PowfScalar(desc.clone())), + PowfOps::::new(desc), + ) + .output() + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(SqrtOps, B::float_sqrt); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Sqrt(desc.clone())), + SqrtOps::::new(desc), + ) + .output() + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(AbsOps, B::float_abs); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Abs(desc.clone())), + AbsOps::::new(desc), + ) + .output() + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(CosOps, B::float_cos); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Cos(desc.clone())), + CosOps::::new(desc), + ) + .output() + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(SinOps, B::float_sin); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Sin(desc.clone())), + SinOps::::new(desc), + ) + .output() + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(TanOps, B::float_tan); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Tan(desc.clone())), + TanOps::::new(desc), + ) + .output() + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(CoshOps, B::float_cosh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Cosh(desc.clone())), + CoshOps::::new(desc), + ) + .output() + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(SinhOps, B::float_sinh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Sinh(desc.clone())), + SinhOps::::new(desc), + ) + .output() + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(TanhOps, B::float_tanh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Tanh(desc.clone())), + TanhOps::::new(desc), + ) + .output() + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcCosOps, B::float_acos); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcCos(desc.clone())), + ArcCosOps::::new(desc), + ) + .output() + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcCoshOps, B::float_acosh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcCosh(desc.clone())), + ArcCoshOps::::new(desc), + ) + .output() + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcSinOps, B::float_asin); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcSin(desc.clone())), + ArcSinOps::::new(desc), + ) + .output() + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcSinhOps, B::float_asinh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcSinh(desc.clone())), + ArcSinhOps::::new(desc), + ) + .output() + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcTanOps, B::float_atan); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcTan(desc.clone())), + ArcTanOps::::new(desc), + ) + .output() + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(ArcTanhOps, B::float_atanh); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcTanh(desc.clone())), + ArcTanhOps::::new(desc), + ) + .output() + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(ArcTan2Ops, B::float_atan2); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::ArcTan2(desc.clone())), + ArcTan2Ops::::new(desc), + ) + .output() + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(Recip, B::float_recip); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Recip(desc.clone())), + Recip::::new(desc), + ) + .output() + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(TanhOps, B::float_erf); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Erf(desc.clone())), + TanhOps::::new(desc), + ) + .output() + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + #[derive(new, Debug)] + struct CatOps { + desc: CatOpIr, + _b: PhantomData, + } + + impl Operation for CatOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensors = self + .desc + .tensors + .iter() + .map(|tensor| handles.get_float_tensor::(tensor)) + .collect(); + + let output = B::float_cat(tensors, self.desc.dim); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Cat(desc.clone())), + CatOps::::new(desc), + ) + .output() + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + reduce_float2int_ops!(ArgMaxOps, |input, axis, _, dtype| B::float_argmax( + input, axis, dtype + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgMax(desc.clone()), + ), + ArgMaxOps::::new(desc), + ) + .output() + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + reduce_float2int_ops!(ArgTopKOps, B::float_argtopk); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, k, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgTopK(desc.clone()), + ), + ArgTopKOps::::new(desc), + ) + .output() + } + + fn float_topk(tensor: FloatTensor, dim: usize, k: usize) -> FloatTensor { + reduce_float_ops!(TopKOps, B::float_topk); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::TopK(desc.clone())), + TopKOps::::new(desc), + ) + .output() + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + #[derive(new, Debug)] + struct RepeatDimOps { + desc: RepeatDimOpIr, + _b: PhantomData, + } + + impl Operation for RepeatDimOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + + let output = B::float_repeat_dim(tensor, self.desc.dim, self.desc.times); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::RepeatDim(desc.clone())), + RepeatDimOps::::new(desc), + ) + .output() + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + reduce_float2int_ops!(ArgMinOps, |input, axis, _, dtype| B::float_argmin( + input, axis, dtype + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgMin(desc.clone()), + ), + ArgMinOps::::new(desc), + ) + .output() + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(MaxOps, B::float_max, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Max(desc.clone())), + MaxOps::::new(desc.into()), + ) + .output() + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce_float_ops!(MaxDimOps, |tensor, axis, _| B::float_max_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::MaxDim(desc.clone())), + MaxDimOps::::new(desc), + ) + .output() + } + + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + #[derive(new, Debug)] + struct MaxDimWithIndicesOps { + desc: ReduceDimWithIndicesOpIr, + _b: PhantomData, + } + + impl Operation for MaxDimWithIndicesOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let (output, indices) = B::float_max_dim_with_indices( + tensor, + self.desc.dim, + self.desc.out_indices.dtype.into(), + ); + + handles.register_float_tensor::(&self.desc.out.id, output); + handles.register_int_tensor::(&self.desc.out_indices.id, indices); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, indices_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.tensor.dtype, + NumericOperationIr::MaxDimWithIndices(desc.clone()), + ), + MaxDimWithIndicesOps::::new(desc), + ) + .outputs() + .into() + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(MinOps, B::float_min, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::Min(desc.clone())), + MinOps::::new(desc.into()), + ) + .output() + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce_float_ops!(MinDimOps, |tensor, axis, _| B::float_min_dim(tensor, axis)); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::MinDim(desc.clone())), + MinDimOps::::new(desc), + ) + .output() + } + + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + #[derive(new, Debug)] + struct MinDimWithIndicesOps { + desc: ReduceDimWithIndicesOpIr, + _b: PhantomData, + } + + impl Operation for MinDimWithIndicesOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let (output, indices) = B::float_min_dim_with_indices( + tensor, + self.desc.dim, + self.desc.out_indices.dtype.into(), + ); + + handles.register_float_tensor::(&self.desc.out.id, output); + handles.register_int_tensor::(&self.desc.out_indices.id, indices); + } + } + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, indices_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.tensor.dtype, + NumericOperationIr::MinDimWithIndices(desc.clone()), + ), + MinDimWithIndicesOps::::new(desc), + ) + .outputs() + .into() + } + + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(MaxAbsOps, B::float_max_abs, reduce); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat(desc.out.dtype, NumericOperationIr::MaxAbs(desc.clone())), + MaxAbsOps::::new(desc.into()), + ) + .output() + } + + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + reduce_float_ops!(MaxAbsDimOps, |tensor, axis, _| B::float_max_abs_dim( + tensor, axis + )); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MaxAbsDim(desc.clone()), + ), + MaxAbsDimOps::::new(desc), + ) + .output() + } + + // TODO: float_powi w/ burn-cubecl-fusion impl + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(PowOps, B::float_powf); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Powf(desc.clone())), + PowOps::::new(desc), + ) + .output() + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + #[derive(new, Debug)] + struct PermuteDimsOps { + desc: PermuteOpIr, + _b: PhantomData, + } + + impl Operation for PermuteDimsOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_permute(input, self.desc.axes.as_slice()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Permute(desc.clone())), + PermuteDimsOps::::new(desc), + ) + .output() + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + #[derive(new, Debug)] + struct ExpandOps { + desc: ShapeOpIr, + _b: PhantomData, + } + + impl Operation for ExpandOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_expand(input, self.desc.out.shape.clone()); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Expand(desc.clone())), + ExpandOps::::new(desc), + ) + .output() + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + #[derive(new, Debug)] + struct FlipOps { + desc: FlipOpIr, + _b: PhantomData, + } + + impl Operation for FlipOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_flip(input, &self.desc.axes); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseInt(BaseOperationIr::Flip(desc.clone())), + FlipOps::::new(desc), + ) + .output() + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(RoundOps, B::float_round); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Round(desc.clone())), + RoundOps::::new(desc), + ) + .output() + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(FloorOps, B::float_floor); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Floor(desc.clone())), + FloorOps::::new(desc), + ) + .output() + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(CeilOps, B::float_ceil); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Ceil(desc.clone())), + CeilOps::::new(desc), + ) + .output() + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + unary_float_ops!(TruncOps, B::float_trunc); + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Trunc(desc.clone())), + TruncOps::::new(desc), + ) + .output() + } + + fn float_cast(tensor: FloatTensor, dtype: burn_backend::FloatDType) -> FloatTensor { + #[derive(new, Debug)] + struct CastOps { + desc: CastOpIr, + dtype: burn_backend::FloatDType, + _b: PhantomData, + } + + impl Operation for CastOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output: B::FloatTensorPrimitive = B::float_cast(input, self.dtype); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Cast(desc.clone())), + CastOps::::new(desc, dtype), + ) + .output() + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + #[derive(new, Debug)] + struct UnfoldOps { + desc: UnfoldOpIr, + _b: PhantomData, + } + + impl Operation for UnfoldOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_unfold(input, self.desc.dim, self.desc.size, self.desc.step); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::BaseFloat(BaseOperationIr::Unfold(desc.clone())), + UnfoldOps::::new(desc), + ) + .output() + } + + fn float_is_nan(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + #[derive(new, Debug)] + struct IsNanOps { + desc: UnaryOpIr, + _b: PhantomData, + } + impl Operation for IsNanOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_is_nan(input, self.desc.out.dtype.into()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create_comparison(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.input.dtype, FloatOperationIr::IsNan(desc.clone())), + IsNanOps::::new(desc), + ) + .output() + } + + fn float_is_inf(tensor: FloatTensor, out_dtype: BoolDType) -> BoolTensor { + #[derive(new, Debug)] + struct IsInfOps { + desc: UnaryOpIr, + _b: PhantomData, + } + impl Operation for IsInfOps { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = B::float_is_inf(input, self.desc.out.dtype.into()); + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = UnaryOpIr::create_comparison(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.input.dtype, FloatOperationIr::IsInf(desc.clone())), + IsInfOps::::new(desc), + ) + .output() + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, + ) -> FloatTensor { + #[derive(new, Debug)] + struct GridSample2dOps { + desc: GridSample2dOpIr, + _b: PhantomData, + } + + impl Operation for GridSample2dOps { + fn execute(&self, handles: &mut HandleContainer) { + let tensor = handles.get_float_tensor::(&self.desc.tensor); + let grid = handles.get_float_tensor::(&self.desc.grid); + let output = + B::float_grid_sample_2d(tensor, grid, self.desc.options.clone().into()); + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + + let streams = StreamId::current(); + + let client = tensor.client.clone(); + let desc = + GridSample2dOpIr::create(tensor.into_ir(), grid.into_ir(), options.into(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::GridSample2d(desc.clone())), + GridSample2dOps::::new(desc), + ) + .output() + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + binary_float_ops!(HypotOps, B::float_hypot); + + let streams = StreamId::current(); + + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register( + streams, + OperationIr::Float(desc.out.dtype, FloatOperationIr::Hypot(desc.clone())), + HypotOps::::new(desc), + ) + .output() + } +} diff --git a/crates/burn-fusion/src/ops/transaction.rs b/crates/burn-fusion/src/ops/transaction.rs new file mode 100644 index 0000000..186a1d8 --- /dev/null +++ b/crates/burn-fusion/src/ops/transaction.rs @@ -0,0 +1,36 @@ +use burn_backend::{ + backend::ExecutionError, + ops::{TransactionOps, TransactionPrimitive}, +}; + +use crate::{Fusion, FusionBackend}; + +impl TransactionOps> for Fusion { + async fn tr_execute( + transaction: TransactionPrimitive, + ) -> Result { + B::tr_execute(TransactionPrimitive::new( + transaction + .read_floats + .into_iter() + .map(|t| t.client.clone().resolve_tensor_float::(t)) + .collect(), + transaction + .read_qfloats + .into_iter() + .map(|_t| todo!("Quantization not supported yet")) + .collect(), + transaction + .read_ints + .into_iter() + .map(|t| t.client.clone().resolve_tensor_int::(t)) + .collect(), + transaction + .read_bools + .into_iter() + .map(|t| t.client.clone().resolve_tensor_bool::(t)) + .collect(), + )) + .await + } +} diff --git a/crates/burn-fusion/src/ops/unary.rs b/crates/burn-fusion/src/ops/unary.rs new file mode 100644 index 0000000..da3bd19 --- /dev/null +++ b/crates/burn-fusion/src/ops/unary.rs @@ -0,0 +1,324 @@ +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs.into()); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; + ( + $name:ident, + $ops:expr, + noconvert + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_float_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ReduceDimOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = $ops(input, self.desc.axis, self.desc.accumulator_len); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_float2int_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ReduceDimOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = $ops( + input, + self.desc.axis, + self.desc.accumulator_len, + self.desc.out.dtype.into(), + ); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_int_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ReduceDimOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = $ops(input, self.desc.axis, self.desc.accumulator_len); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float2int_ops { + ( + $name:ident, + $ops:expr, + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs.clone()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! unary_float_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: UnaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = $ops(input); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; + ( + $name:ident, + $ops:expr, + reduce + ) => { + #[derive(new, Debug)] + struct $name { + desc: UnaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_float_tensor::(&self.desc.input); + let output = $ops(input); + + handles.register_float_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! unary_int_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: UnaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = $ops(input); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; + ( + $name:ident, + $ops:expr, + reduce + ) => { + #[derive(new, Debug)] + struct $name { + desc: UnaryOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let input = handles.get_int_tensor::(&self.desc.input); + let output = $ops(input); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float_cmp_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_float_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs.into(), self.desc.out.dtype.into()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_int_cmp_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_int_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs.into(), self.desc.out.dtype.into()); + + handles.register_bool_tensor::(&self.desc.out.id, output); + } + } + }; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_int_ops { + ( + $name:ident, + $ops:expr + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_int_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs.into()); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; + ( + $name:ident, + $ops:expr, + noconvert + ) => { + #[derive(new, Debug)] + struct $name { + desc: ScalarOpIr, + _b: PhantomData, + } + + impl Operation for $name { + fn execute(&self, handles: &mut HandleContainer) { + let lhs = handles.get_int_tensor::(&self.desc.lhs); + let output = $ops(lhs, self.desc.rhs); + + handles.register_int_tensor::(&self.desc.out.id, output); + } + } + }; +} diff --git a/crates/burn-fusion/src/search/block.rs b/crates/burn-fusion/src/search/block.rs new file mode 100644 index 0000000..a09cfbd --- /dev/null +++ b/crates/burn-fusion/src/search/block.rs @@ -0,0 +1,639 @@ +use crate::{ + FuserStatus, NumOperations, OperationFuser, + search::graph::{GraphNode, SubGraph, is_valid_execution_order}, + stream::store::ExecutionStrategy, +}; +use burn_ir::{OperationIr, TensorId, TensorIr, TensorStatus}; +use std::{collections::HashSet, sync::Arc}; + +/// A block represents a list of operations, not necessarily in the same order as the execution +/// stream. +/// +/// The start and end position of the relative execution stream are tracked in the block alongside +/// the ordering. +pub struct Block { + builders: Vec>>, + operations: Vec, + ids: HashSet, + /// Tensor ids produced (as an output) by an operation of this block. + produced: HashSet, + /// Tensor ids consumed by this block but produced elsewhere (external inputs). + read: HashSet, + /// Tensor ids this block reads with [ReadWrite](TensorStatus::ReadWrite) status — i.e. it is + /// the last use and frees/reuses the buffer in place. + freed: HashSet, + /// The original blocks this block subsumes. + /// + /// Seeded to a single node by the optimizer before a merge pass and unioned on + /// [merge](Self::merge), so the [Reachability](crate::search::graph::Reachability) guard can + /// reason about the original dependency graph through the recursive merging. + constituents: SubGraph, + ordering: Vec, + /// The start position in the relative execution stream. + pub start_pos: usize, + /// The end position in the relative execution stream. + pub end_pos: usize, +} + +/// The result of [registering](Block::register) an [operation](OperationIr). +pub enum RegistrationResult { + /// If the [operation](OperationIr) is correctly registered. + Accepted, + /// If the [operation](OperationIr) isn't part of the graph. + /// + /// In this case the operation isn't registered. + NotPartOfTheGraph, +} + +/// The optimization found for a [block](Block). +#[derive(Debug, new)] +pub struct BlockOptimization { + /// The [execution strategy](ExecutionStrategy) to be used to execute the [block](Block). + pub strategy: ExecutionStrategy, + /// The ordering of each operation in the relative execution stream. + pub ordering: Vec, +} + +impl Block { + /// The original blocks this block subsumes. + pub fn constituents(&self) -> &SubGraph { + &self.constituents + } + + /// Seed this block's [constituents](Self::constituents) to a single original block index. + pub fn seed_constituent(&mut self, index: usize) { + self.constituents = SubGraph::single(index); + } +} + +/// A single operation at its stream position, viewed as a [GraphNode]. +pub struct OperationNode<'a> { + /// The operation. + pub operation: &'a OperationIr, + /// The stream position of the operation. + pub position: usize, +} + +impl GraphNode for OperationNode<'_> { + type Resource = TensorId; + + fn produced(&self) -> impl Iterator { + self.operation.outputs().map(|tensor| tensor.id) + } + + fn read(&self) -> impl Iterator { + self.operation.inputs().map(|tensor| tensor.id) + } + + fn freed(&self) -> impl Iterator { + self.operation + .inputs() + .filter(|tensor| matches!(tensor.status, TensorStatus::ReadWrite)) + .map(|tensor| tensor.id) + } + + fn position(&self) -> usize { + self.position + } +} + +/// The dependency edges between blocks are derived from what each block reads, produces, and +/// frees — see [GraphNode]. +impl GraphNode for Block { + type Resource = TensorId; + + fn produced(&self) -> impl Iterator { + self.produced.iter().copied() + } + + fn read(&self) -> impl Iterator { + self.read.iter().copied() + } + + fn freed(&self) -> impl Iterator { + self.freed.iter().copied() + } + + fn produces(&self, resource: TensorId) -> bool { + self.produced.contains(&resource) + } + + fn reads(&self, resource: TensorId) -> bool { + self.read.contains(&resource) + } + + fn position(&self) -> usize { + self.start_pos + } +} + +impl Block { + /// Create a new block that will be optimized with the provided [optimization builders](OptimizationBuilder). + pub fn new(builders: &[Box>]) -> Self { + Self { + builders: builders.iter().map(|o| o.clone_dyn()).collect(), + operations: Vec::new(), + ids: HashSet::new(), + produced: HashSet::new(), + read: HashSet::new(), + freed: HashSet::new(), + constituents: SubGraph::empty(), + ordering: Vec::new(), + start_pos: usize::MAX, + end_pos: usize::MIN, + } + } + + /// Sort the [blocks](Block) based on the start position. + pub fn sort(blocks: &mut [Self]) { + blocks.sort_by_key(|a| a.start_pos); + } + + /// Optimize the block. + pub fn optimize(mut self) -> BlockOptimization { + match find_best_optimization_index(&mut self.builders) { + BestOptimization::Found { index, score } => { + let opt = self.builders[index].finish(); + let opt_len = opt.len(); + if opt_len < self.operations.len() { + self.ordering.drain(opt_len..); + } + + let strategy = ExecutionStrategy::Optimization { + ordering: Arc::new(self.ordering.clone()), + opt, + score, + }; + BlockOptimization::new(strategy, self.ordering) + } + BestOptimization::NotFound => { + let strategy = ExecutionStrategy::Operations { + ordering: Arc::new(self.ordering.clone()), + }; + BlockOptimization::new(strategy, self.ordering) + } + } + } + + /// Returns if the block contains any of the provided [tensors](TensorIr). + pub fn contains_tensors(&self, tensors: &[&TensorIr]) -> bool { + for node in tensors { + if self.ids.contains(&node.id) { + return true; + } + } + + false + } + + /// Merge the current block with the other one and returns if the operation is successful. + /// + /// # Warning + /// + /// This will modify the current block even if the other block isn't correctly merged. + pub fn merge(&mut self, other: &Block) -> bool { + // A block executes as one contiguous unit in registration order (fused kernels replay + // the fusion order). Appending the other block's operations can place a consumer before + // its producer — or a free before a read — when the blocks depend on each other. Reject + // such merges before mutating anything; the caller can retry in the other direction. + if !self.can_append(other) { + return false; + } + + let self_ready = self.has_ready_optimization(); + let other_ready = other.has_ready_optimization(); + + // Absorb the other block's provenance so the merge guard sees the combined set on any + // subsequent `can_contract` check. + self.constituents.union_with(&other.constituents); + + for (op, pos) in other.operations.iter().zip(&other.ordering) { + self.register(op, *pos, true); + } + + // If the merged block can still be improved, keep it — that's the + // usual lazy-optimization signal. + if self.still_optimizing() { + return true; + } + + // Otherwise, only accept the merge when it *creates* a ready fusion + // that didn't exist separately. If either side already had a ready + // fusion before the merge, merging would collapse them and hide one + // of the fusions — keep the blocks separate instead. + !self_ready && !other_ready && self.has_ready_optimization() + } + + fn has_ready_optimization(&self) -> bool { + self.builders.iter().any(|b| b.properties().ready) + } + + /// Whether appending the other block's operations after this block's — the registration + /// order a [merge](Self::merge) in this direction produces — respects every tensor lifetime + /// (no read before the producing operation, no read after the freeing operation). + /// + /// This is the validity check `merge` performs, exposed so callers can test a merge + /// direction *before* paying for a deep clone of the base block. + pub fn can_append<'a>(&'a self, other: &'a Block) -> bool { + is_valid_execution_order(self.operation_nodes().chain(other.operation_nodes())) + } + + /// The block's operations in registration order, viewed as [graph nodes](OperationNode). + fn operation_nodes(&self) -> impl Iterator> + Clone { + self.operations + .iter() + .zip(&self.ordering) + .map(|(operation, &position)| OperationNode { + operation, + position, + }) + } + + /// Register an [operation](OperationIr) in the current block. + /// + /// You need to provide the order of the operation as well as a force flag. + /// + /// When the force flag is true, the builder will always accept the operation, otherwise it + /// might refuse it if the operation [isn't part of the graph](RegistrationResult::NotPartOfTheGraph). + /// + /// Forcing is useful to fuse operations that are part of different graphs, but included + /// in the same optimization. + pub fn register( + &mut self, + operation: &OperationIr, + order: usize, + force: bool, + ) -> RegistrationResult { + if self.ids.is_empty() { + self.register_op(operation, order); + return RegistrationResult::Accepted; + } + let mut contains = false; + for node in operation.nodes() { + contains = self.ids.contains(&node.id); + + if contains { + break; + } + } + + if !contains && !force { + return RegistrationResult::NotPartOfTheGraph; + } + + self.register_op(operation, order); + RegistrationResult::Accepted + } + + /// If the block can still be optimized further. + pub fn still_optimizing(&self) -> bool { + let mut num_stopped = 0; + + for optimization in self.builders.iter() { + if let FuserStatus::Closed = optimization.status() { + num_stopped += 1 + } + } + + num_stopped < self.builders.len() + } + + fn register_op(&mut self, operation: &OperationIr, pos: usize) { + self.operations.push(operation.clone()); + self.ordering.push(pos); + + if pos < self.start_pos { + self.start_pos = pos; + } + if pos + 1 > self.end_pos { + self.end_pos = pos + 1; + } + + for builder in self.builders.iter_mut() { + builder.fuse(operation); + } + + for node in operation.nodes() { + self.ids.insert(node.id); + } + + // Maintain the produced / external-read sets incrementally. A consumed id counts as an + // external read only while nothing in the block has produced it; producing an id makes + // it internal (and clears any earlier external record). This is order-independent, so it + // stays correct when `merge` folds ops in a non-causal order. + for node in operation.inputs() { + if !self.produced.contains(&node.id) { + self.read.insert(node.id); + } + if let TensorStatus::ReadWrite = node.status { + self.freed.insert(node.id); + } + } + for node in operation.outputs() { + self.produced.insert(node.id); + self.read.remove(&node.id); + } + } +} + +impl BlockOptimization { + /// Maps the ordering of the current block optimization using the given mapping. + pub fn map_ordering(&mut self, mapping: &[usize]) { + for i in self.ordering.iter_mut() { + *i = mapping[*i]; + } + self.strategy.map_ordering(mapping); + } + + /// Extend the optimization to cover all `num_operations` of its segment: the + /// positions the search left uncovered (the drained tail of a block whose best + /// fusion stopped early) are appended as un-fused + /// [operations](ExecutionStrategy::Operations) in a + /// [composed](ExecutionStrategy::Composed) strategy. + /// + /// Leaving the tail out is right in lazy mode — it seeds the next exploration + /// round, where more incoming operations may fuse with it. At a sync the segment + /// is final: no later round can pick the tail up, and a plan covering only part + /// of the segment can never be matched again (plan lookup at a sync compares the + /// whole remaining segment). Covering the full segment makes the cached plan + /// reusable on every subsequent identical sync. + /// + /// The uncovered positions are always a trailing run of the segment (interior + /// holes are re-optimized by the stream optimizer before this is called — enforced + /// by a debug assertion), so appending them last preserves the hazard-respecting + /// execution order. + pub fn include_trailing(&mut self, num_operations: usize) { + if self.ordering.len() >= num_operations { + return; + } + + #[cfg(debug_assertions)] + { + let mut resolved = vec![false; num_operations]; + for &position in &self.ordering { + resolved[position] = true; + } + debug_assert!( + resolved[..self.ordering.len()].iter().all(|&r| r), + "uncovered positions must be a trailing run of the segment" + ); + } + let trailing: Vec = (self.ordering.len()..num_operations).collect(); + + let tail = ExecutionStrategy::Operations { + ordering: Arc::new(trailing.clone()), + }; + let strategy = core::mem::replace(&mut self.strategy, ExecutionStrategy::Composed(vec![])); + self.strategy = match strategy { + ExecutionStrategy::Composed(mut items) => { + items.push(Box::new(tail)); + ExecutionStrategy::Composed(items) + } + single => ExecutionStrategy::Composed(vec![Box::new(single), Box::new(tail)]), + }; + self.ordering.extend(trailing); + } +} + +impl ExecutionStrategy { + /// Maps the ordering of the current execution strategy using the given mapping. + pub fn map_ordering(&mut self, mapping: &[usize]) { + match self { + ExecutionStrategy::Optimization { ordering, .. } => { + let mut ordering_mapped = ordering.to_vec(); + + for o in ordering_mapped.iter_mut() { + *o = mapping[*o]; + } + *ordering = Arc::new(ordering_mapped); + } + ExecutionStrategy::Operations { ordering } => { + let mut ordering_mapped = ordering.to_vec(); + + for o in ordering_mapped.iter_mut() { + *o = mapping[*o]; + } + + *ordering = Arc::new(ordering_mapped); + } + ExecutionStrategy::Composed(items) => { + for item in items.iter_mut() { + item.map_ordering(mapping); + } + } + } + } +} + +enum BestOptimization { + NotFound, + Found { index: usize, score: u64 }, +} + +fn find_best_optimization_index( + optimizations: &mut [Box>], +) -> BestOptimization { + let mut best_index = BestOptimization::NotFound; + let mut best_score = 0; + + for (i, optimization) in optimizations.iter().enumerate() { + let properties = optimization.properties(); + + // A score of zero is worse than fusing. + if properties.ready && properties.score > best_score { + best_index = BestOptimization::Found { + index: i, + score: properties.score, + }; + best_score = properties.score; + } + } + + best_index +} + +impl PartialEq for Block { + fn eq(&self, other: &Self) -> bool { + // Since the ordering can be seen as operation ids, we can use it to compare + // blocks. + let mut sorted_a = self.ordering.clone(); + let mut sorted_b = other.ordering.clone(); + sorted_a.sort(); + sorted_b.sort(); + + sorted_a == sorted_b + } +} + +impl core::fmt::Debug for Block { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "Block {{ pos: [{:?}, {:?}; {:?}] }}", + self.start_pos, + self.end_pos, + self.ordering.len(), + )) + } +} + +impl Clone for Block { + fn clone(&self) -> Self { + Self { + builders: self.builders.iter().map(|b| b.clone_dyn()).collect(), + operations: self.operations.clone(), + ids: self.ids.clone(), + produced: self.produced.clone(), + read: self.read.clone(), + freed: self.freed.clone(), + constituents: self.constituents.clone(), + ordering: self.ordering.clone(), + start_pos: self.start_pos, + end_pos: self.end_pos, + } + } +} + +/// Integration of [Block] with the [graph](crate::search::graph) algorithms: dependency edges +/// between blocks are derived from the tensors they read, produce, and free. +#[cfg(test)] +mod tests { + use super::*; + use crate::search::graph::Dag; + use crate::search::testing::{add, add_rw}; + use crate::stream::execution::tests::TestOptimization; + + /// A block owning the given `(operation, position)` pairs. No builders needed — the + /// dependency analysis only reads the data-flow sets and `start_pos`. + fn block(ops: &[(OperationIr, usize)]) -> Block { + let builders: Vec>> = Vec::new(); + let mut block = Block::new(&builders); + for (op, pos) in ops { + block.register(op, *pos, true); + } + block + } + + #[test] + fn dependency_beats_start_pos_in_topological_order() { + // Block 0 (start_pos 0) consumes tensor 50, which block 1 (start_pos 5) produces. + let blocks = vec![ + block(&[(add(50, 1, 2), 0)]), // Consumes 50 -> depends on block 1. + block(&[(add(9, 8, 50), 5)]), // Produces 50. + ]; + + assert_eq!(Dag::new(&blocks).topological_order(), Some(vec![1, 0])); + } + + #[test] + fn mutual_dependency_between_blocks_is_a_cycle() { + // Block 0 produces 100 (consumed by block 1) and consumes 200 (produced by block 1). + let blocks = vec![ + block(&[(add(1, 2, 100), 0), (add(200, 3, 101), 1)]), + block(&[(add(100, 4, 200), 2)]), + ]; + + assert!(!Dag::new(&blocks).is_acyclic()); + } + + #[test] + fn freeing_block_runs_after_reading_block() { + // Both blocks read tensor 100 (produced elsewhere): block 0 read-only, block 1 frees it + // (ReadWrite). The freer must run after the reader. + let blocks = vec![ + block(&[(add(100, 1, 2), 0)]), // Reads 100 read-only. + block(&[(add_rw(100, 3, 4), 1)]), // Frees 100. + ]; + + assert_eq!(Dag::new(&blocks).topological_order(), Some(vec![0, 1])); + } + + #[test] + fn merge_guard_blocks_contraction_around_intermediate_block() { + // A(0) -> C(1) -> B(2): C sits between A and B, so merging A and B would create a cycle. + let mut blocks = vec![ + block(&[(add(1, 2, 100), 0)]), // A: produces 100. + block(&[(add(100, 3, 200), 1)]), // C: consumes 100, produces 200. + block(&[(add(200, 4, 300), 2)]), // B: consumes 200. + ]; + for (i, block) in blocks.iter_mut().enumerate() { + block.seed_constituent(i); + } + let guard = Dag::new(&blocks).reachability(); + + // A and B cannot merge (C is between them)... + assert!(!guard.can_contract(blocks[0].constituents(), blocks[2].constituents())); + // ...but each direct edge is fine. + assert!(guard.can_contract(blocks[0].constituents(), blocks[1].constituents())); + assert!(guard.can_contract(blocks[1].constituents(), blocks[2].constituents())); + } + + #[test] + fn merge_guard_refuses_unseeded_blocks() { + let blocks = vec![block(&[(add(1, 2, 3), 0)]), block(&[(add(4, 5, 6), 1)])]; + let guard = Dag::new(&blocks).reachability(); + + assert!(!guard.can_contract(blocks[0].constituents(), blocks[1].constituents())); + } + + /// A block with a builder open to any prefix of `pattern`, owning the given ops. + /// + /// The builder keeps the block `still_optimizing` as long as the registered ops follow the + /// pattern, so merge acceptance is driven by the pattern and the tests below can isolate the + /// internal-order validation. + fn block_with_pattern( + pattern: &[OperationIr], + ops: &[(OperationIr, usize)], + ) -> Block { + use crate::stream::execution::tests::TestOptimizationBuilder; + + let builders: Vec>> = + vec![Box::new(TestOptimizationBuilder::new(0, pattern.to_vec()))]; + let mut block = Block::new(&builders); + for (op, pos) in ops { + block.register(op, *pos, true); + } + block + } + + #[test] + fn merge_rejects_consumer_fused_before_producer() { + let x = add(1, 2, 10); + let y = add(50, 3, 11); // Reads 50... + let z = add(4, 5, 50); // ...which this op produces. + + // The pattern accepts the forward merge order [x, y, z], so only the internal-order + // validation can reject it: y would be fused before the op producing its input. + let pattern = [x.clone(), y.clone(), z.clone(), x.clone()]; + let mut base = block_with_pattern(&pattern, &[(x, 0), (y, 1)]); + let producer = block_with_pattern(&pattern, &[(z, 2)]); + + assert!(!base.merge(&producer)); + } + + #[test] + fn merge_accepts_producer_fused_before_consumer() { + let x = add(1, 2, 10); + let y = add(50, 3, 11); + let z = add(4, 5, 50); + + // Same blocks as above, merged in the other direction: [z, x, y] is causal. + let pattern = [z.clone(), x.clone(), y.clone(), x.clone()]; + let consumers = block_with_pattern(&pattern, &[(x, 0), (y, 1)]); + let mut base = block_with_pattern(&pattern, &[(z, 2)]); + + assert!(base.merge(&consumers)); + } + + #[test] + fn merge_rejects_free_fused_before_read() { + let reader = add(100, 1, 2); // Reads 100. + let freer = add_rw(100, 3, 4); // Frees 100. + + // Fusing the free before the read would release the tensor under the reader. + let pattern = [freer.clone(), reader.clone(), reader.clone()]; + let mut base = block_with_pattern(&pattern, &[(freer, 1)]); + let other = block_with_pattern(&pattern, &[(reader, 0)]); + + assert!(!base.merge(&other)); + } +} diff --git a/crates/burn-fusion/src/search/graph/dag.rs b/crates/burn-fusion/src/search/graph/dag.rs new file mode 100644 index 0000000..feccfd5 --- /dev/null +++ b/crates/burn-fusion/src/search/graph/dag.rs @@ -0,0 +1,190 @@ +use super::{GraphNode, SubGraph}; +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +/// A dependency graph over a fixed list of nodes, with edges derived from the nodes' data-flow +/// sets (see [GraphNode]). +/// +/// The graph is not guaranteed to be acyclic — a cycle means the nodes cannot be linearized and +/// is reported by [topological_order](Self::topological_order) returning `None`. +pub struct Dag { + /// `dependencies[i]` = nodes that must execute before node `i` (direct edges only). + dependencies: Vec, + /// `dependents[j]` = nodes that directly depend on node `j` (transpose of `dependencies`). + dependents: Vec, + /// Tie-break position of each node (see [GraphNode::position]). + positions: Vec, +} + +impl Dag { + /// Build the dependency graph of the given nodes. + pub fn new(nodes: &[N]) -> Self { + let n = nodes.len(); + let mut dependencies = vec![SubGraph::empty(); n]; + let mut dependents = vec![SubGraph::empty(); n]; + + for (i, node) in nodes.iter().enumerate() { + for (j, other) in nodes.iter().enumerate() { + if i != j && depends_on(node, other) { + dependencies[i].insert(j); + dependents[j].insert(i); + } + } + } + + Self { + dependencies, + dependents, + positions: nodes.iter().map(|n| n.position()).collect(), + } + } + + /// The number of nodes in the graph. + pub fn len(&self) -> usize { + self.dependencies.len() + } + + /// Whether node `i` directly depends on node `j` (`j` must execute before `i`). + #[cfg(test)] + pub fn depends_on(&self, i: usize, j: usize) -> bool { + self.dependencies[i].contains(j) + } + + /// A valid execution order of the nodes (dependencies first), or `None` if the graph has a + /// cycle and cannot be linearized. + /// + /// Among the nodes ready at each step, the one with the smallest `(position, index)` is + /// emitted first, so an edge-free graph reproduces the original program order. + pub fn topological_order(&self) -> Option> { + let n = self.len(); + let mut pending: Vec = self.dependencies.iter().map(SubGraph::len).collect(); + + // Min-heap of the nodes ready to execute, keyed by `(position, index)`. + let mut ready: BinaryHeap> = (0..n) + .filter(|&i| pending[i] == 0) + .map(|i| Reverse((self.positions[i], i))) + .collect(); + + let mut order = Vec::with_capacity(n); + while let Some(Reverse((_, i))) = ready.pop() { + order.push(i); + for dependent in self.dependents[i].iter() { + pending[dependent] -= 1; + if pending[dependent] == 0 { + ready.push(Reverse((self.positions[dependent], dependent))); + } + } + } + + // A node still pending at the end sits on a cycle. + (order.len() == n).then_some(order) + } + + /// Whether the graph can be linearized. + pub fn is_acyclic(&self) -> bool { + self.topological_order().is_some() + } + + /// Compute the transitive [Reachability] of the graph. + pub fn reachability(&self) -> Reachability { + let n = self.len(); + let mut ancestors = self.dependencies.clone(); + + // A node absorbs the (final) ancestor sets of its direct dependencies. In execution + // order every dependency is processed before its dependents, so a single pass is + // complete. Taking the set out and putting it back sidesteps the aliasing between + // `ancestors[i]` and `ancestors[j]` without any clone (`j != i`: no self-edges). + match self.topological_order() { + Some(order) => { + for i in order { + let mut acc = core::mem::take(&mut ancestors[i]); + for j in self.dependencies[i].iter() { + acc.union_with(&ancestors[j]); + } + ancestors[i] = acc; + } + } + // On a cycle no linear pass exists (defensive — the merge guards keep the block set + // acyclic): relax the direct edges to a fixpoint instead. Unions only grow, so a + // length change is an exact change signal. + None => loop { + let mut changed = false; + for i in 0..n { + let mut acc = core::mem::take(&mut ancestors[i]); + let before = acc.len(); + for j in self.dependencies[i].iter() { + acc.union_with(&ancestors[j]); + } + changed |= acc.len() != before; + ancestors[i] = acc; + } + if !changed { + break; + } + }, + } + + // Descendants are the transpose of ancestors. + let mut descendants = vec![SubGraph::empty(); n]; + for (i, ancestors) in ancestors.iter().enumerate() { + for j in ancestors.iter() { + descendants[j].insert(i); + } + } + + Reachability { + ancestors, + descendants, + } + } +} + +/// Transitive reachability over a [Dag]. +/// +/// Answers whether contracting a set of nodes into a single node keeps the graph acyclic, which +/// is the legality condition for merging fusion blocks that may depend on each other. +pub struct Reachability { + /// `ancestors[i]` = nodes that must execute before node `i` (transitive). + ancestors: Vec, + /// `descendants[i]` = nodes that must execute after node `i` (transitive). + descendants: Vec, +} + +impl Reachability { + /// Whether contracting the two subgraphs into a single node keeps the graph acyclic. + /// + /// Contraction is illegal iff some node outside `a ∪ b` is both an ancestor and a descendant + /// of the union — it would have to execute both before and after the contracted node. + /// + /// Empty subgraphs are refused conservatively: they identify nodes the reachability was not + /// built from. + pub fn can_contract(&self, a: &SubGraph, b: &SubGraph) -> bool { + if a.is_empty() || b.is_empty() { + return false; + } + + let mut union = a.clone(); + union.union_with(b); + + let mut ancestors = SubGraph::empty(); + let mut descendants = SubGraph::empty(); + for i in union.iter() { + ancestors.union_with(&self.ancestors[i]); + descendants.union_with(&self.descendants[i]); + } + + let mut between = ancestors; + between.intersect_with(&descendants); + between.subtract(&union); + between.is_empty() + } +} + +/// Whether `node` must execute after `other`: read-after-write (`node` reads a resource `other` +/// produces) or write-after-read (`node` frees a resource `other` reads). +/// +/// Works directly on the nodes' own data-flow sets through the [GraphNode] membership queries — +/// no intermediate collection. +fn depends_on(node: &N, other: &N) -> bool { + node.read().any(|r| other.produces(r)) || node.freed().any(|r| other.reads(r)) +} diff --git a/crates/burn-fusion/src/search/graph/lifetime.rs b/crates/burn-fusion/src/search/graph/lifetime.rs new file mode 100644 index 0000000..fa734e4 --- /dev/null +++ b/crates/burn-fusion/src/search/graph/lifetime.rs @@ -0,0 +1,46 @@ +use super::GraphNode; +use std::collections::HashSet; + +/// Whether executing the nodes in the given order keeps every read resource live. +/// +/// The nodes are taken already in execution order — pass a (cloneable, cheap) iterator over the +/// candidate order rather than materializing it; the function walks it twice. +/// +/// Simulates resource lifetimes: every resource a node reads must be live when the node +/// executes, a freed resource dies after the freeing node, and produced resources become live. +/// A resource read before any ordered node produced it is external (from an earlier segment) +/// and assumed live — unless some ordered node *does* produce it, in which case the order reads +/// it before its producer and is invalid. +pub fn is_valid_execution_order(order: impl Iterator + Clone) -> bool { + let mut produced = HashSet::new(); + for node in order.clone() { + produced.extend(node.produced()); + } + + let mut alive = HashSet::new(); + let mut dead = HashSet::new(); + for node in order { + for resource in node.read() { + if dead.contains(&resource) { + return false; // Read after the resource was freed: bad order. + } + if !alive.contains(&resource) { + if produced.contains(&resource) { + return false; // Produced in this segment but not live yet: bad order. + } + alive.insert(resource); // External resource from a prior segment. + } + } + for resource in node.freed() { + alive.remove(&resource); + dead.insert(resource); + } + for resource in node.produced() { + // Producing redefines the resource, even if an id were ever reused after a free. + dead.remove(&resource); + alive.insert(resource); + } + } + + true +} diff --git a/crates/burn-fusion/src/search/graph/mod.rs b/crates/burn-fusion/src/search/graph/mod.rs new file mode 100644 index 0000000..475d196 --- /dev/null +++ b/crates/burn-fusion/src/search/graph/mod.rs @@ -0,0 +1,18 @@ +//! A small, self-contained dependency-graph toolkit used by the fusion search. +//! +//! Everything here is generic over the [GraphNode] trait — nothing depends on the tensor IR — so +//! the algorithms can be unit-tested in isolation and reused at different granularities: fusion +//! blocks, execution-strategy chunks, or single operations. + +mod dag; +mod lifetime; +mod node; +mod subgraph; + +pub use dag::*; +pub use lifetime::*; +pub use node::*; +pub use subgraph::*; + +#[cfg(test)] +mod tests; diff --git a/crates/burn-fusion/src/search/graph/node.rs b/crates/burn-fusion/src/search/graph/node.rs new file mode 100644 index 0000000..edb620e --- /dev/null +++ b/crates/burn-fusion/src/search/graph/node.rs @@ -0,0 +1,69 @@ +use core::hash::Hash; + +/// Data-flow description of a node in a dependency graph. +/// +/// A node reads and produces resources (tensors, buffers, …) identified by +/// [Resource](Self::Resource). Dependencies between nodes are never declared explicitly: they are +/// derived from the resource sets by [Dag::new](super::Dag::new) using two hazard rules. +/// +/// - **Read-after-write**: a node reading a resource depends on the node producing it. +/// - **Write-after-read**: a node freeing a resource depends on every other node reading it — +/// executing the freeing node first would release the resource out from under the readers. +pub trait GraphNode { + /// Identifies a resource read, produced, or freed by a node. + type Resource: Copy + Eq + Hash; + + /// The resources produced by this node. + fn produced(&self) -> impl Iterator; + + /// The resources this node reads that are produced by other nodes. + fn read(&self) -> impl Iterator; + + /// The resources this node reads for the last time, releasing the underlying storage + /// (in-place reuse or deallocation). + fn freed(&self) -> impl Iterator; + + /// Whether this node produces the resource — the membership form of + /// [produced](Self::produced). Nodes that already hold their resources in a set should + /// override this with a direct lookup. + fn produces(&self, resource: Self::Resource) -> bool { + self.produced().any(|r| r == resource) + } + + /// Whether this node reads the resource — the membership form of [read](Self::read). + fn reads(&self, resource: Self::Resource) -> bool { + self.read().any(|r| r == resource) + } + + /// The position of the node in the original program order, used to break ties between + /// independent nodes when ordering them. + fn position(&self) -> usize; +} + +impl GraphNode for &N { + type Resource = N::Resource; + + fn produced(&self) -> impl Iterator { + (*self).produced() + } + + fn read(&self) -> impl Iterator { + (*self).read() + } + + fn freed(&self) -> impl Iterator { + (*self).freed() + } + + fn produces(&self, resource: Self::Resource) -> bool { + (*self).produces(resource) + } + + fn reads(&self, resource: Self::Resource) -> bool { + (*self).reads(resource) + } + + fn position(&self) -> usize { + (*self).position() + } +} diff --git a/crates/burn-fusion/src/search/graph/subgraph.rs b/crates/burn-fusion/src/search/graph/subgraph.rs new file mode 100644 index 0000000..365be84 --- /dev/null +++ b/crates/burn-fusion/src/search/graph/subgraph.rs @@ -0,0 +1,125 @@ +/// A set of nodes of a [Dag](super::Dag), identified by their node index. +/// +/// The first 64 node indices live in an inline word, so subgraphs over small graphs — the common +/// case, given the block cap — never touch the heap. Larger graphs spill into a word vector kept +/// canonical (no trailing zero words), which makes the derived equality structural. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SubGraph { + /// Nodes `0..64`. + word0: u64, + /// Nodes `64..`: word `i` holds nodes `(i + 1) * 64..(i + 2) * 64`. + spill: Vec, +} + +const BITS: usize = u64::BITS as usize; + +impl SubGraph { + /// The empty subgraph. + pub fn empty() -> Self { + Self::default() + } + + /// The subgraph containing a single node. + pub fn single(node: usize) -> Self { + let mut set = Self::empty(); + set.insert(node); + set + } + + /// Whether the subgraph contains no node. + pub fn is_empty(&self) -> bool { + self.word0 == 0 && self.spill.is_empty() + } + + /// The number of nodes in the subgraph. + pub fn len(&self) -> usize { + let spill: u32 = self.spill.iter().map(|word| word.count_ones()).sum(); + (self.word0.count_ones() + spill) as usize + } + + /// Add a node to the subgraph. + pub fn insert(&mut self, node: usize) { + if node < BITS { + self.word0 |= 1 << node; + return; + } + let word = node / BITS - 1; + if word >= self.spill.len() { + self.spill.resize(word + 1, 0); + } + self.spill[word] |= 1 << (node % BITS); + } + + /// Whether the subgraph contains the node. + #[cfg(test)] + pub fn contains(&self, node: usize) -> bool { + if node < BITS { + return self.word0 & (1 << node) != 0; + } + self.spill + .get(node / BITS - 1) + .is_some_and(|word| word & (1 << (node % BITS)) != 0) + } + + /// Add every node of the other subgraph. + pub fn union_with(&mut self, other: &Self) { + self.word0 |= other.word0; + if other.spill.len() > self.spill.len() { + self.spill.resize(other.spill.len(), 0); + } + for (word, other) in self.spill.iter_mut().zip(&other.spill) { + *word |= other; + } + } + + /// Keep only the nodes also contained in the other subgraph. + pub fn intersect_with(&mut self, other: &Self) { + self.word0 &= other.word0; + self.spill.truncate(other.spill.len()); + for (word, other) in self.spill.iter_mut().zip(&other.spill) { + *word &= other; + } + self.trim(); + } + + /// Remove every node of the other subgraph. + pub fn subtract(&mut self, other: &Self) { + self.word0 &= !other.word0; + for (word, other) in self.spill.iter_mut().zip(&other.spill) { + *word &= !other; + } + self.trim(); + } + + /// Whether the two subgraphs share at least one node. + #[cfg(test)] + pub fn intersects(&self, other: &Self) -> bool { + self.word0 & other.word0 != 0 + || self.spill.iter().zip(&other.spill).any(|(a, b)| a & b != 0) + } + + /// The nodes of the subgraph, in ascending index order. + pub fn iter(&self) -> impl Iterator + '_ { + core::iter::once(self.word0) + .chain(self.spill.iter().copied()) + .enumerate() + .flat_map(|(i, word)| { + let mut bits = word; + core::iter::from_fn(move || { + if bits == 0 { + return None; + } + let bit = bits.trailing_zeros() as usize; + bits &= bits - 1; + Some(i * BITS + bit) + }) + }) + } + + /// Restore the canonical form (no trailing zero spill words). + fn trim(&mut self) { + while self.spill.last() == Some(&0) { + self.spill.pop(); + } + } +} diff --git a/crates/burn-fusion/src/search/graph/tests.rs b/crates/burn-fusion/src/search/graph/tests.rs new file mode 100644 index 0000000..cfdd079 --- /dev/null +++ b/crates/burn-fusion/src/search/graph/tests.rs @@ -0,0 +1,331 @@ +use super::*; + +/// A plain node for exercising the graph algorithms without any tensor machinery. +#[derive(Default)] +struct TestNode { + position: usize, + produced: Vec, + read: Vec, + freed: Vec, +} + +impl TestNode { + fn new(position: usize) -> Self { + Self { + position, + ..Default::default() + } + } + + fn produces(mut self, resources: impl IntoIterator) -> Self { + self.produced.extend(resources); + self + } + + fn reads(mut self, resources: impl IntoIterator) -> Self { + self.read.extend(resources); + self + } + + /// Reads the resources for the last time (both read and freed). + fn frees(mut self, resources: impl IntoIterator) -> Self { + for resource in resources { + self.read.push(resource); + self.freed.push(resource); + } + self + } +} + +impl GraphNode for TestNode { + type Resource = u32; + + fn produced(&self) -> impl Iterator { + self.produced.iter().copied() + } + + fn read(&self) -> impl Iterator { + self.read.iter().copied() + } + + fn freed(&self) -> impl Iterator { + self.freed.iter().copied() + } + + fn position(&self) -> usize { + self.position + } +} + +mod subgraph { + use super::*; + + #[test] + fn insert_contains_iter() { + let mut set = SubGraph::empty(); + assert!(set.is_empty()); + + set.insert(3); + set.insert(70); // Spills past the inline word. + assert!(set.contains(3)); + assert!(set.contains(70)); + assert!(!set.contains(4)); + assert_eq!(set.len(), 2); + assert_eq!(set.iter().collect::>(), vec![3, 70]); + } + + #[test] + fn set_operations_keep_canonical_form() { + let mut a = SubGraph::single(2); + a.insert(100); + + // Subtracting the high bit must trim the trailing word so equality stays structural. + a.subtract(&SubGraph::single(100)); + assert_eq!(a, SubGraph::single(2)); + + let mut b = SubGraph::single(2); + b.union_with(&SubGraph::single(65)); + assert!(b.intersects(&SubGraph::single(65))); + assert!(!b.intersects(&SubGraph::single(64))); + + b.intersect_with(&SubGraph::single(2)); + assert_eq!(b, SubGraph::single(2)); + } +} + +mod dag { + use super::*; + + #[test] + fn independent_nodes_ordered_by_position() { + let nodes = vec![ + TestNode::new(2).reads([200, 201]).produces([202]), + TestNode::new(0).reads([100, 101]).produces([102]), + ]; + let dag = Dag::new(&nodes); + + assert!(!dag.depends_on(0, 1)); + assert!(!dag.depends_on(1, 0)); + assert_eq!(dag.topological_order(), Some(vec![1, 0])); + } + + #[test] + fn read_after_write_beats_position() { + // Node 0 (position 0) reads resource 50, which node 1 (position 5) produces. + let nodes = vec![ + TestNode::new(0).reads([50]).produces([2]), + TestNode::new(5).reads([9]).produces([50]), + ]; + let dag = Dag::new(&nodes); + + assert!(dag.depends_on(0, 1)); + assert_eq!(dag.topological_order(), Some(vec![1, 0])); + } + + #[test] + fn mutual_dependency_is_a_cycle() { + // Node 0 produces 100 (read by node 1) and reads 200 (produced by node 1). + let nodes = vec![ + TestNode::new(0).reads([200]).produces([100]), + TestNode::new(1).reads([100]).produces([200]), + ]; + let dag = Dag::new(&nodes); + + assert!(!dag.is_acyclic()); + assert_eq!(dag.topological_order(), None); + } + + #[test] + fn write_after_read_orders_freer_after_reader() { + // Both nodes read resource 100 (produced elsewhere); node 1 frees it. The freer must + // run after the reader, even though nothing flows between them. + let nodes = vec![ + TestNode::new(0).reads([100]).produces([2]), + TestNode::new(1).frees([100]).produces([4]), + ]; + let dag = Dag::new(&nodes); + + assert!(dag.depends_on(1, 0)); + assert!(!dag.depends_on(0, 1)); + assert_eq!(dag.topological_order(), Some(vec![0, 1])); + } + + #[test] + fn diamond_topological_order() { + // 0 + // / \ + // 1 2 + // \ / + // 3 + let nodes = vec![ + TestNode::new(0).produces([10]), + TestNode::new(1).reads([10]).produces([11]), + TestNode::new(2).reads([10]).produces([12]), + TestNode::new(3).reads([11, 12]).produces([13]), + ]; + let dag = Dag::new(&nodes); + + assert_eq!(dag.topological_order(), Some(vec![0, 1, 2, 3])); + } +} + +mod reachability { + use super::*; + + /// A → C → B chain: contracting A and B would trap C between them. + fn chain() -> Vec { + vec![ + TestNode::new(0).produces([100]), // A + TestNode::new(1).reads([100]).produces([200]), // C + TestNode::new(2).reads([200]).produces([300]), // B + ] + } + + #[test] + fn direct_edge_can_contract() { + let reachability = Dag::new(&chain()).reachability(); + + assert!(reachability.can_contract(&SubGraph::single(0), &SubGraph::single(1))); + assert!(reachability.can_contract(&SubGraph::single(1), &SubGraph::single(2))); + } + + #[test] + fn intermediate_node_blocks_contraction() { + let reachability = Dag::new(&chain()).reachability(); + + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::single(2))); + } + + #[test] + fn contraction_including_the_intermediate_is_legal() { + let reachability = Dag::new(&chain()).reachability(); + + // Contracting {A, C} with {B} is fine: nothing remains between them. + let mut a_and_c = SubGraph::single(0); + a_and_c.union_with(&SubGraph::single(1)); + assert!(reachability.can_contract(&a_and_c, &SubGraph::single(2))); + } + + #[test] + fn empty_subgraphs_are_refused() { + let reachability = Dag::new(&chain()).reachability(); + + assert!(!reachability.can_contract(&SubGraph::empty(), &SubGraph::empty())); + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::empty())); + } + + #[test] + fn transitive_ancestors_block_contraction() { + // 0 → 1 → 2 → 3: node 0 and node 3 have no direct edge, but both 1 and 2 sit between. + let nodes = vec![ + TestNode::new(0).produces([10]), + TestNode::new(1).reads([10]).produces([11]), + TestNode::new(2).reads([11]).produces([12]), + TestNode::new(3).reads([12]).produces([13]), + ]; + let reachability = Dag::new(&nodes).reachability(); + + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::single(3))); + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::single(2))); + } + + #[test] + fn cyclic_graph_falls_back_to_fixpoint() { + // 0 ↔ 1 feed each other (a cycle); 2 reads from 1. + let nodes = vec![ + TestNode::new(0).reads([200]).produces([100]), + TestNode::new(1).reads([100]).produces([200]), + TestNode::new(2).reads([200]).produces([300]), + ]; + let dag = Dag::new(&nodes); + assert!(!dag.is_acyclic()); + + let reachability = dag.reachability(); + // Contracting the cycle itself is legal — it disappears inside the contracted node... + assert!(reachability.can_contract(&SubGraph::single(0), &SubGraph::single(1))); + // ...but node 1 still sits between node 0 and node 2. + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::single(2))); + } + + #[test] + fn supports_more_than_64_nodes() { + // A chain of 70 nodes: node i reads what node i-1 produces. + let nodes = (0..70) + .map(|i| { + let node = TestNode::new(i).produces([i as u32 + 1]); + match i { + 0 => node, + _ => node.reads([i as u32]), + } + }) + .collect::>(); + let dag = Dag::new(&nodes); + let reachability = dag.reachability(); + + assert_eq!(dag.topological_order(), Some((0..70).collect::>())); + assert!(reachability.can_contract(&SubGraph::single(68), &SubGraph::single(69))); + assert!(!reachability.can_contract(&SubGraph::single(0), &SubGraph::single(69))); + } +} + +mod lifetime { + use super::*; + + /// Run the lifetime simulation on `nodes` executed in the given order. + fn valid(nodes: &[TestNode], order: &[usize]) -> bool { + is_valid_execution_order(order.iter().map(|&i| &nodes[i])) + } + + #[test] + fn program_order_is_valid() { + let nodes = vec![ + TestNode::new(0).reads([1]).produces([10]), + TestNode::new(1).frees([10]).produces([11]), + ]; + + assert!(valid(&nodes, &[0, 1])); + } + + #[test] + fn read_before_produce_is_invalid() { + let nodes = vec![ + TestNode::new(0).reads([1]).produces([10]), + TestNode::new(1).reads([10]).produces([11]), + ]; + + assert!(!valid(&nodes, &[1, 0])); + } + + #[test] + fn read_after_free_is_invalid() { + let nodes = vec![ + TestNode::new(0).frees([1]).produces([10]), + TestNode::new(1).reads([1]).produces([11]), + ]; + + assert!(!valid(&nodes, &[0, 1])); + assert!(valid(&nodes, &[1, 0])); + } + + #[test] + fn external_reads_are_assumed_live() { + // Resource 1 is produced by no ordered node: it comes from an earlier segment. + let nodes = vec![ + TestNode::new(0).reads([1]).produces([10]), + TestNode::new(1).reads([1, 10]).produces([11]), + ]; + + assert!(valid(&nodes, &[0, 1])); + } + + #[test] + fn order_may_cover_a_subset_of_nodes() { + // Only node 1 is ordered; the resource node 0 produces counts as external. + let nodes = vec![ + TestNode::new(0).produces([10]), + TestNode::new(1).reads([10]).produces([11]), + ]; + + assert!(valid(&nodes, &[1])); + } +} diff --git a/crates/burn-fusion/src/search/merging.rs b/crates/burn-fusion/src/search/merging.rs new file mode 100644 index 0000000..ffceffc --- /dev/null +++ b/crates/burn-fusion/src/search/merging.rs @@ -0,0 +1,517 @@ +use super::Block; +use crate::{NumOperations, search::graph::Reachability}; + +#[derive(Debug, PartialEq)] +/// The result of [merging](merge_blocks) [blocks](Block). +// `Full`/`Partial` carry owned blocks by design; the size gap between variants is expected. +#[allow(clippy::large_enum_variant)] +pub enum MergeBlocksResult { + /// All [blocks](Block) merged into one. + Full(Block), + /// Some [blocks](Block) merged and some failed. + Partial { + merged: Vec>, + failed: Vec>, + }, + /// All [blocks](Block) failed to merge. + Fail, +} + +/// Merge multiple [block](Block) together. +/// +/// The resulting [blocks](Block) might be sorted if the flag is true, otherwise the order isn't +/// guarantee. This is mostly useful for testing. +/// +/// # Strategy +/// +/// The merging strategy is in two steps: +/// +/// 1. The first step is to recursively try to merge adjacent blocks. This has the advantage of +/// trying multiple blocks ordering, therefore trying multiple permutation of the blocks. +/// However, it has the downside of not trying to merge blocks that are further away in the list +/// of blocks. Since trying all combinations possible is exponential, therefore not possible, we +/// fallback on the second strategy. +/// 2. The second step is to reduce blocks by setting an accumulator block, then sequentially +/// trying to merge the remaining blocks. We try some permutations based on the result from +/// step1. +pub fn merge_blocks( + blocks: &[&Block], + sorted: bool, + guard: &Reachability, +) -> MergeBlocksResult { + if blocks.is_empty() { + return MergeBlocksResult::Fail; + } + + if blocks.len() == 1 { + return MergeBlocksResult::Full(blocks[0].clone()); + } + + if blocks.len() == 2 { + let block0 = blocks[0]; + let block1 = blocks[1]; + + return match merge_two(block0, block1, guard) { + Some(result) => MergeBlocksResult::Full(result), + None => MergeBlocksResult::Fail, + }; + } + + let mut step1 = merge_blocks_step1(blocks, guard); + + if step1.full.len() == 1 && step1.failed.is_empty() && step1.partial.is_empty() { + MergeBlocksResult::Full(step1.full.remove(0)) + } else if step1.partial.len() == 1 && step1.failed.is_empty() && step1.full.is_empty() { + MergeBlocksResult::Full(step1.partial.remove(0)) + } else { + let result = merge_blocks_step2(step1, guard); + + if !sorted { + return result; + } + + match result { + MergeBlocksResult::Full(block) => MergeBlocksResult::Full(block), + MergeBlocksResult::Partial { + mut merged, + mut failed, + } => { + Block::sort(&mut merged); + Block::sort(&mut failed); + + MergeBlocksResult::Partial { merged, failed } + } + MergeBlocksResult::Fail => MergeBlocksResult::Fail, + } + } +} + +struct MergeBlockStep1 { + full: Vec>, + partial: Vec>, + failed: Vec>, +} + +impl Default for MergeBlockStep1 { + fn default() -> Self { + Self { + full: Default::default(), + partial: Default::default(), + failed: Default::default(), + } + } +} + +fn merge_blocks_step1( + blocks: &[&Block], + guard: &Reachability, +) -> MergeBlockStep1 { + let step_size = blocks.len() / 2; + let num_steps = f32::ceil(blocks.len() as f32 / step_size as f32) as usize; + + let mut result = MergeBlockStep1::default(); + + for i in 0..num_steps { + let start = i * step_size; + let end = usize::min(start + step_size, blocks.len()); + + match merge_blocks(&blocks[start..end], false, guard) { + MergeBlocksResult::Full(block) => { + result.full.push(block); + } + MergeBlocksResult::Partial { + mut merged, + mut failed, + } => { + result.partial.append(&mut merged); + result.failed.append(&mut failed); + } + MergeBlocksResult::Fail => { + for b in &blocks[start..end] { + result.failed.push((*b).clone()); + } + } + } + } + + result +} + +fn merge_blocks_step2( + mut step1: MergeBlockStep1, + guard: &Reachability, +) -> MergeBlocksResult { + // First let's try to merge partial graphs. + if step1.partial.len() > 1 { + match merge_accumulator(&step1.partial[0], &step1.partial[1..], guard) { + MergeBlocksResult::Full(block) => { + step1.partial = vec![block]; + } + MergeBlocksResult::Partial { merged, mut failed } => { + step1.partial = merged; + step1.failed.append(&mut failed); + } + MergeBlocksResult::Fail => {} + } + } + + // Then let's try to merge partial graphs with failed merges. + if !step1.failed.is_empty() { + step1.partial.append(&mut step1.failed); + match merge_accumulator(&step1.partial[0], &step1.partial[1..], guard) { + MergeBlocksResult::Full(block) => { + step1.partial = vec![block]; + } + MergeBlocksResult::Partial { merged, mut failed } => { + step1.partial = merged; + step1.failed.append(&mut failed); + } + MergeBlocksResult::Fail => {} + } + } + + // Then let's try to merge full graphs. + if step1.full.len() > 1 { + match merge_accumulator(&step1.full[0], &step1.full[1..], guard) { + MergeBlocksResult::Full(block) => { + step1.full = vec![block]; + } + MergeBlocksResult::Partial { merged, mut failed } => { + step1.full = merged; + step1.failed.append(&mut failed); + } + MergeBlocksResult::Fail => {} + } + } + + // Then let's try to merge full graphs with failed graphs. + if !step1.full.is_empty() { + step1.full.append(&mut step1.failed); + match merge_accumulator(&step1.full[0], &step1.full[1..], guard) { + MergeBlocksResult::Full(block) => { + step1.full = vec![block]; + } + MergeBlocksResult::Partial { merged, mut failed } => { + step1.full = merged; + step1.failed.append(&mut failed); + } + MergeBlocksResult::Fail => {} + } + } + + // Then let's try to merge full graphs with partial graphs. + if !step1.full.is_empty() || !step1.partial.is_empty() { + step1.full.append(&mut step1.partial); + match merge_accumulator(&step1.full[0], &step1.full[1..], guard) { + MergeBlocksResult::Full(block) => { + step1.full = vec![block]; + } + MergeBlocksResult::Partial { merged, mut failed } => { + step1.full = merged; + step1.failed.append(&mut failed); + } + MergeBlocksResult::Fail => { + // We do nothing. + } + } + } + + if step1.full.is_empty() { + MergeBlocksResult::Fail + } else if step1.failed.is_empty() { + if step1.full.len() == 1 { + MergeBlocksResult::Full(step1.full.remove(0)) + } else { + MergeBlocksResult::Partial { + merged: step1.full, + failed: vec![], + } + } + } else { + MergeBlocksResult::Partial { + merged: step1.full, + failed: step1.failed, + } + } +} + +fn merge_accumulator( + base: &Block, + blocks: &[Block], + guard: &Reachability, +) -> MergeBlocksResult { + let mut base = base.clone(); + let mut merged_failed = Vec::>::new(); + let mut merged_success = false; + + for block in blocks { + // `merge_two` checks the cycle guard and tries both merge directions — the accumulator + // may depend on the block, in which case only folding the accumulator *into* the block + // yields a valid operation order. + match merge_two(&base, block, guard) { + Some(merged) => { + merged_success = true; + base = merged; + } + None => { + merged_failed.push(block.clone()); + } + } + } + + if merged_success { + if merged_failed.is_empty() { + MergeBlocksResult::Full(base) + } else { + MergeBlocksResult::Partial { + merged: vec![base], + failed: merged_failed, + } + } + } else { + MergeBlocksResult::Fail + } +} + +fn merge_two( + a: &Block, + b: &Block, + guard: &Reachability, +) -> Option> { + if !guard.can_contract(a.constituents(), b.constituents()) { + return None; + } + + // Test each direction's operation order before paying for a deep clone of the base block + // (operations, builders, and data-flow sets all clone). + if a.can_append(b) { + let mut base = a.clone(); + if base.merge(b) { + return Some(base); + } + } + + if b.can_append(a) { + let mut base = b.clone(); + if base.merge(a) { + return Some(base); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + pub use crate::stream::execution::tests::{TestOptimization, TestOptimizationBuilder}; + use crate::{ + OperationFuser, + search::graph::Dag, + stream::tests::{operation_1, operation_2, operation_3}, + }; + + /// Seed constituents by index, build the [Reachability] guard from the blocks, and merge + /// (sorted). + fn merge(mut blocks: Vec>) -> MergeBlocksResult { + for (i, block) in blocks.iter_mut().enumerate() { + block.seed_constituent(i); + } + let refs = blocks.iter().collect::>(); + let guard = Dag::new(&refs).reachability(); + merge_blocks(&refs, true, &guard) + } + + #[test] + fn test_merge_blocks_no_block() { + let actual = merge(Vec::>::new()); + + assert_eq!(actual, MergeBlocksResult::Fail); + } + + #[test] + fn test_merge_blocks_single() { + let builders = builders(); + let block = Block::new(&builders); + let actual = merge(vec![block.clone()]); + + assert_eq!(actual, MergeBlocksResult::Full(block)); + } + + #[test] + fn test_merge_blocks_two_blocks() { + let builders = builders(); + let mut block1 = Block::new(&builders); + let mut block2 = Block::new(&builders); + block1.register(&operation_1(), 0, false); + block1.register(&operation_1(), 1, false); + block2.register(&operation_1(), 2, false); + block2.register(&operation_1(), 3, false); + + let actual = merge(vec![block1, block2]); + + let mut expected = Block::new(&builders); + expected.register(&operation_1(), 0, false); + expected.register(&operation_1(), 1, false); + expected.register(&operation_1(), 2, false); + expected.register(&operation_1(), 3, false); + + assert_eq!(actual, MergeBlocksResult::Full(expected)); + } + + #[test] + fn test_merge_blocks_three_blocks() { + let builders = builders(); + let mut block1 = Block::new(&builders); + let mut block2 = Block::new(&builders); + let mut block3 = Block::new(&builders); + block1.register(&operation_1(), 0, false); + block2.register(&operation_1(), 1, false); + block3.register(&operation_1(), 2, false); + + let actual = merge(vec![block1, block2, block3]); + + let mut expected = Block::new(&builders); + expected.register(&operation_1(), 0, false); + expected.register(&operation_1(), 1, false); + expected.register(&operation_1(), 2, false); + + assert_eq!(actual, MergeBlocksResult::Full(expected)); + } + + #[test] + fn test_merge_blocks_three_blocks_partial() { + let builders = builders(); + let mut block1 = Block::new(&builders); + let mut block2 = Block::new(&builders); + let mut block3 = Block::new(&builders); + block1.register(&operation_1(), 0, false); + block2.register(&operation_2(), 1, false); + block3.register(&operation_1(), 2, false); + + let actual = merge(vec![block1, block2, block3]); + + let mut expected1 = Block::new(&builders); + let mut expected2 = Block::new(&builders); + expected1.register(&operation_1(), 0, false); + expected1.register(&operation_1(), 2, false); + expected2.register(&operation_2(), 1, false); + + assert_eq!( + actual, + MergeBlocksResult::Partial { + merged: vec![expected1, expected2], + failed: vec![] + } + ); + } + + #[test] + fn test_merge_blocks_four_blocks_partial_with_failure() { + let builders = builders(); + let mut block1 = Block::new(&builders); + let mut block2 = Block::new(&builders); + let mut block3 = Block::new(&builders); + let mut block4 = Block::new(&builders); + block1.register(&operation_1(), 0, false); + block2.register(&operation_2(), 1, false); + block3.register(&operation_1(), 2, false); + block4.register(&operation_3(), 3, false); + + let actual = merge(vec![block1, block2, block3, block4]); + + let mut expected1 = Block::new(&builders); + let mut expected2 = Block::new(&builders); + let mut failed = Block::new(&builders); + expected1.register(&operation_1(), 0, false); + expected1.register(&operation_1(), 2, false); + expected2.register(&operation_2(), 1, false); + failed.register(&operation_3(), 3, false); + + assert_eq!( + actual, + MergeBlocksResult::Partial { + merged: vec![expected1], + failed: vec![expected2, failed] + } + ); + } + + #[test] + fn test_merge_blocks_five_blocks_partial_with_failure() { + let builders = builders(); + let mut block1 = Block::new(&builders); + let mut block2 = Block::new(&builders); + let mut block3 = Block::new(&builders); + let mut block4 = Block::new(&builders); + let mut block5 = Block::new(&builders); + block1.register(&operation_1(), 0, false); + block2.register(&operation_2(), 1, false); + block3.register(&operation_1(), 2, false); + block4.register(&operation_3(), 3, false); + block5.register(&operation_2(), 4, false); + + let actual = merge(vec![block1, block2, block3, block4, block5]); + + let mut expected1 = Block::new(&builders); + let mut expected2 = Block::new(&builders); + let mut failed = Block::new(&builders); + expected1.register(&operation_1(), 0, false); + expected1.register(&operation_1(), 2, false); + expected2.register(&operation_2(), 1, false); + expected2.register(&operation_2(), 4, false); + failed.register(&operation_3(), 3, false); + + assert_eq!( + actual, + MergeBlocksResult::Partial { + merged: vec![expected1, expected2], + failed: vec![failed] + } + ); + } + + /// The accumulator may depend on a block it tries to absorb: folding that block's ops at the + /// end would fuse a consumer before its producer. The merge must then happen in the other + /// direction (the accumulator folded into the block), preserving a full merge instead of + /// degrading to a partial one. + #[test] + fn test_merge_blocks_accumulator_reverses_direction() { + use crate::search::testing::add; + + let x = add(1, 2, 10); + let y = add(50, 3, 11); // Reads 50... + let z = add(4, 5, 50); // ...which this op produces. + let w = add(6, 7, 12); // Independent. + + // The builder accepts exactly the reversed merge order [z, x, y, w]. + let pattern = vec![z.clone(), x.clone(), y.clone(), w.clone(), x.clone()]; + let builders: Vec>> = + vec![Box::new(TestOptimizationBuilder::new(0, pattern))]; + + let mut block1 = Block::new(&builders); + block1.register(&x, 0, true); + block1.register(&y, 5, true); + let mut block2 = Block::new(&builders); + block2.register(&z, 3, true); + let mut block3 = Block::new(&builders); + block3.register(&w, 6, true); + + let actual = merge(vec![block1, block2, block3]); + + let mut expected = Block::new(&builders); + expected.register(&z, 3, true); + expected.register(&x, 0, true); + expected.register(&y, 5, true); + expected.register(&w, 6, true); + + assert_eq!(actual, MergeBlocksResult::Full(expected)); + } + + fn builders() -> Vec>> { + let builder_1 = TestOptimizationBuilder::new(0, vec![operation_1(); 10]); + let builder_2 = TestOptimizationBuilder::new(1, vec![operation_2(); 10]); + + vec![Box::new(builder_1), Box::new(builder_2)] + } +} diff --git a/crates/burn-fusion/src/search/mod.rs b/crates/burn-fusion/src/search/mod.rs new file mode 100644 index 0000000..3ff1ada --- /dev/null +++ b/crates/burn-fusion/src/search/mod.rs @@ -0,0 +1,10 @@ +mod block; +mod optimization; + +pub(crate) mod graph; +pub(super) mod merging; +#[cfg(test)] +pub(crate) mod testing; +pub(super) use block::*; + +pub use optimization::*; diff --git a/crates/burn-fusion/src/search/optimization/blocks.rs b/crates/burn-fusion/src/search/optimization/blocks.rs new file mode 100644 index 0000000..17809f9 --- /dev/null +++ b/crates/burn-fusion/src/search/optimization/blocks.rs @@ -0,0 +1,162 @@ +use burn_std::config::{fusion::FusionLogLevel, log_fusion}; + +use crate::{ + NumOperations, + search::{ + Block, BlockOptimization, + graph::Dag, + merging::{MergeBlocksResult, merge_blocks}, + }, + stream::store::ExecutionStrategy, +}; + +/// Try to optimize a list of [blocks](Block) into a [block optimization](BlockOptimization). +/// +/// # Notes +/// +/// The blocks form a dependency DAG: a block may depend on another when it consumes a tensor the +/// other produces. Blocks with no dependency between them can still be merged/reordered freely; +/// dependent blocks must be emitted in a topological order (dependencies first). +/// +/// The contract is that the length of operations executed must include all operations. If we don't +/// find an optimization that can be executed with that constraint, we return a +/// [BlocksOptimizerResult::WithHoles]. +pub struct BlocksOptimizer { + blocks: Vec>, + num_ops: usize, +} + +/// When we can't find a proper optimization for the provided list of [blocks](Block). +pub enum BlocksOptimizerResult { + /// When an optimization fill the hole stream. + Full(BlockOptimization), + /// The optimization found with the holes indices. + WithHoles { + strategies: Vec>>, + ordering: Vec, + holes: Vec, + }, +} + +impl BlocksOptimizer { + /// Create a new optimizer with the given blocks. + pub fn new(blocks: Vec>) -> Self { + let num_ops: usize = blocks.iter().map(|g| g.end_pos).max().unwrap(); + + Self { blocks, num_ops } + } + + /// Optimizes the blocks. + /// + /// Strategy: + /// 1. Try to merge blocks together — the merge guard rejects any contraction that would + /// create a dependency cycle, so a surviving merge is always order-safe. + /// 2. Emit the blocks in a topological order (dependencies first) and ask every block for its + /// best optimization (or the fallback [Operations](ExecutionStrategy::Operations) if no + /// builder matched), concatenating the strategies. + /// 3. An *interior* unresolved position — an op that no block's final + /// ordering covered, but which sits before some other resolved + /// position — is a hole that must be filled by a second optimization + /// pass. Trailing unresolved positions, on the other hand, are the + /// natural tail of a drained block and are left in the queue for the + /// processor to handle in the next round. + pub fn optimize(mut self) -> BlocksOptimizerResult { + self = self.merging_pass(); + + let num_ops = self.num_ops; + let blocks = core::mem::take(&mut self.blocks); + + // Emit blocks in a valid execution order: a dependency must run before its dependents. + // The set is acyclic (register keeps it so, and `merging_pass` only accepts cycle-free + // merges), so `topological_order` always succeeds; fall back to the current order if not. + let order = Dag::new(&blocks) + .topological_order() + .unwrap_or_else(|| (0..blocks.len()).collect::>()); + let mut slots: Vec>> = blocks.into_iter().map(Some).collect(); + let blocks: Vec> = order + .into_iter() + .map(|i| slots[i].take().expect("each block taken once")) + .collect(); + + let mut strategies: Vec>> = Vec::with_capacity(blocks.len()); + let mut ordering = Vec::new(); + let mut resolved = vec![false; num_ops]; + + for block in blocks { + let mut block_opt = block.optimize(); + for pos in block_opt.ordering.iter() { + resolved[*pos] = true; + } + ordering.append(&mut block_opt.ordering); + strategies.push(Box::new(block_opt.strategy)); + } + + // An unresolved position is a hole only if some position *after* it + // is resolved (it's interleaved, not trailing). A trailing run of + // unresolved positions is a drained tail — left for the processor. + let last_resolved_end = resolved + .iter() + .rposition(|&r| r) + .map(|i| i + 1) + .unwrap_or(0); + let holes: Vec = (0..last_resolved_end).filter(|i| !resolved[*i]).collect(); + + let num_strategies = strategies.len(); + log_fusion(FusionLogLevel::Basic, move || { + if num_strategies > 1 { + format!("selected composed strategy ({num_strategies} sub-strategies)") + } else { + "selected single strategy".to_string() + } + }); + + if holes.is_empty() { + let strategy = if strategies.len() > 1 { + ExecutionStrategy::Composed(strategies) + } else { + *strategies.remove(0) + }; + BlocksOptimizerResult::Full(BlockOptimization::new(strategy, ordering)) + } else { + BlocksOptimizerResult::WithHoles { + strategies, + ordering, + holes, + } + } + } + + /// Try to merge blocks together. + fn merging_pass(mut self) -> Self { + if self.blocks.len() == 1 { + return self; + } + + Block::sort(&mut self.blocks); + + // Seed constituents so the guard can reason about the original dependency DAG through the + // recursive merging, then reject any contraction that would create a cycle. + for (i, block) in self.blocks.iter_mut().enumerate() { + block.seed_constituent(i); + } + let blocks = self.blocks.iter().collect::>(); + let guard = Dag::new(&blocks).reachability(); + + match merge_blocks(&blocks, false, &guard) { + MergeBlocksResult::Full(block) => { + self.blocks = vec![block]; + } + MergeBlocksResult::Partial { + mut merged, + mut failed, + } => { + merged.append(&mut failed); + self.blocks = merged; + Block::sort(&mut self.blocks); + } + MergeBlocksResult::Fail => {} + } + + self + } +} diff --git a/crates/burn-fusion/src/search/optimization/mod.rs b/crates/burn-fusion/src/search/optimization/mod.rs new file mode 100644 index 0000000..9731cd0 --- /dev/null +++ b/crates/burn-fusion/src/search/optimization/mod.rs @@ -0,0 +1,7 @@ +mod blocks; +mod stream; + +#[cfg(test)] +mod tests; + +pub use stream::*; diff --git a/crates/burn-fusion/src/search/optimization/stream.rs b/crates/burn-fusion/src/search/optimization/stream.rs new file mode 100644 index 0000000..7e324b5 --- /dev/null +++ b/crates/burn-fusion/src/search/optimization/stream.rs @@ -0,0 +1,827 @@ +use super::blocks::BlocksOptimizer; +use crate::{ + NumOperations, OperationFuser, + search::{ + Block, BlockOptimization, OperationNode, RegistrationResult, + graph::{Dag, GraphNode, is_valid_execution_order}, + merging::{MergeBlocksResult, merge_blocks}, + optimization::blocks::BlocksOptimizerResult, + }, + stream::{execution::op_kind, store::ExecutionStrategy}, +}; +use burn_ir::{OperationIr, TensorId, TensorStatus}; +use burn_std::config::{config, fusion::FusionLogLevel, log_fusion}; +use std::collections::HashSet; +use std::sync::Arc; + +/// Optimize a stream of [operations](OperationIr) using a list of [builders](OptimizationBuilder). +pub struct StreamOptimizer { + builders: Vec>>, + blocks: Vec>, + length: usize, + stopped: bool, + max_blocks: Option, +} + +impl StreamOptimizer { + /// Create a new stream optimizer. + pub fn new(builders: Vec>>) -> Self { + // Too high and it may break the fusion cache always retriggering explorations. It also + // bounds the per-op cost of the dependency analysis: every registration that touches + // several blocks rebuilds the block DAG and its transitive reachability, which scale + // quadratically (and worse) with the number of blocks. + let max_blocks = Some(config().fusion().beam_search.max_blocks); + Self { + builders, + blocks: Vec::new(), + length: 0, + stopped: false, + max_blocks, + } + } + + /// Register a new [operation](OperationIr) in the optimizer. + /// + /// You can use the function [Self::still_optimizing] to know if the operations are actually + /// being registered. + pub fn register(&mut self, operation: &OperationIr) { + if self.stopped { + let length = self.length; + log_fusion(FusionLogLevel::Full, || { + format!( + "[stream] {} dropped (optimizer stopped at op {length})", + op_kind(operation) + ) + }); + return; + } + + if self.blocks.is_empty() { + self.on_new_block(operation); + self.length += 1; + return; + } + + match self.merge_blocks(operation, false) { + MergeBlockStep::Full | MergeBlockStep::NoNeed => {} + MergeBlockStep::Fail | MergeBlockStep::Partial => { + // The operation ties together blocks that couldn't be merged into one. Instead of + // giving up on the segment, give the operation its own block that *depends* on the + // blocks it reads from — the block set becomes a dependency DAG. + self.on_dependent_op(operation); + if self.stopped { + return; + } + self.length += 1; + return; + } + } + + if let Some(max_blocks) = self.max_blocks { + if self.register_max_block(operation, max_blocks) { + self.length += 1; + } else { + let length = self.length; + log_fusion(FusionLogLevel::Medium, || { + format!( + "[stream] stopped (max_blocks={max_blocks} reached) at op {length} ({})", + op_kind(operation) + ) + }); + self.stopped = true; + } + return; + } + + let added_count = self.register_inner(operation, false); + if added_count == 0 { + self.on_new_block(operation); + } else { + self.log_accepted(operation, added_count); + } + + self.length += 1; + } + + /// Optimize the current stream on the given [operations](OperationIr). + /// + /// # Notes + /// + /// The operations provided are the same as the ones used in the [register](Self::register) + /// method, this simply remove the need for the current type to also keep track of the list of + /// operations. + pub fn optimize(&self, operations: &[OperationIr]) -> BlockOptimization { + let result = BlocksOptimizer::new(self.blocks.clone()).optimize(); + + let out = match result { + BlocksOptimizerResult::Full(block_optimization) => block_optimization, + BlocksOptimizerResult::WithHoles { + mut strategies, + mut ordering, + mut holes, + } => { + loop { + let mut search = self.new_empty_search(); + + let mut operations_holes = Vec::with_capacity(holes.len()); + + for index in holes.iter() { + let op = &operations[*index]; + operations_holes.push(op.clone()); + search.register(op); + } + + let mut optimization_of_holes = search.optimize(&operations_holes); + + optimization_of_holes.map_ordering(&holes); + + // Append the re-optimized holes as their own chunk; `repair_order` (below) puts + // every chunk into a hazard-respecting execution order, so the placement here + // doesn't need to be correct — only complete. + let consumed = optimization_of_holes.ordering.len(); + strategies.push(Box::new(optimization_of_holes.strategy)); + ordering.append(&mut optimization_of_holes.ordering); + holes.drain(0..consumed); + + if holes.is_empty() { + break; + } + } + + BlockOptimization::new(ExecutionStrategy::Composed(strategies), ordering) + } + }; + + repair_order(out, operations) + } + + /// Reset the state of the optimizer. + pub fn reset(&mut self) { + self.builders.iter_mut().for_each(|b| b.reset()); + self.length = 0; + self.blocks.clear(); + self.stopped = false; + } + + /// Returns if some optimizations are still possible within the stream. + pub fn still_optimizing(&self) -> bool { + if self.stopped { + return false; + } + if self.blocks.is_empty() { + return true; + } + + let mut num_stopped = 0; + + for block in self.blocks.iter() { + if !block.still_optimizing() { + num_stopped += 1 + } + } + + num_stopped < self.blocks.len() + } + + fn register_max_block(&mut self, operation: &OperationIr, max_blocks: usize) -> bool { + if max_blocks == 1 { + // Register in the single block with a force. + self.register_inner(operation, true); + return true; + } + let added_count = self.register_inner(operation, false); + + if added_count > 0 { + self.log_accepted(operation, added_count); + return true; + } + + if added_count == 0 && self.blocks.len() < max_blocks { + self.on_new_block(operation); + return true; + } + + self.merge_blocks(operation, true); + + if self.blocks.len() >= max_blocks { + self.stopped = true; + return false; + } + + let added_count = self.register_inner(operation, false); + + if added_count == 0 { + self.on_new_block(operation); + } else { + self.log_accepted(operation, added_count); + } + + true + } + + fn log_accepted(&self, operation: &OperationIr, added_count: usize) { + let length = self.length; + let num_blocks = self.blocks.len(); + log_fusion(FusionLogLevel::Full, || { + format!( + "[stream] op {length} {} → accepted in {added_count}/{num_blocks} block(s)", + op_kind(operation) + ) + }); + } + + fn register_inner(&mut self, operation: &OperationIr, force: bool) -> usize { + let mut added_count = 0; + for block in self.blocks.iter_mut() { + match block.register(operation, self.length, force) { + RegistrationResult::Accepted => { + added_count += 1; + } + RegistrationResult::NotPartOfTheGraph => {} + } + } + added_count + } + + fn new_empty_search(&self) -> Self { + Self::new( + self.builders + .iter() + .map(|b| { + let mut b = b.clone_dyn(); + b.reset(); + b + }) + .collect(), + ) + } + + fn merge_blocks(&mut self, operation: &OperationIr, all: bool) -> MergeBlockStep { + let nodes = operation.nodes(); + let mut block_merges = Vec::new(); + + for (i, block) in self.blocks.iter().enumerate() { + if all || block.contains_tensors(&nodes) { + block_merges.push(i); + } + } + + if block_merges.len() <= 1 { + return MergeBlockStep::NoNeed; + } + + // Seed each block with its index, then build the cycle guard over ALL blocks — a block + // that "sits between" two merge candidates need not itself be a candidate, so the guard + // must see the full dependency graph, not just the subset being merged. + for (i, block) in self.blocks.iter_mut().enumerate() { + block.seed_constituent(i); + } + let all_blocks = self.blocks.iter().collect::>(); + let guard = Dag::new(&all_blocks).reachability(); + + let blocks_to_merge = self + .blocks + .iter() + .enumerate() + .filter_map(|(i, g)| match block_merges.contains(&i) { + true => Some(g), + false => None, + }) + .collect::>(); + + let merged = merge_blocks(&blocks_to_merge, false, &guard); + + let mut clear_blocks = || { + let mut indices = block_merges.to_vec(); + indices.sort(); + + for g in indices.into_iter().rev() { + self.blocks.remove(g); + } + }; + + match merged { + MergeBlocksResult::Full(block) => { + clear_blocks(); + self.blocks.push(block); + Block::sort(&mut self.blocks); + MergeBlockStep::Full + } + MergeBlocksResult::Partial { + mut merged, + mut failed, + } => { + clear_blocks(); + self.blocks.append(&mut merged); + self.blocks.append(&mut failed); + Block::sort(&mut self.blocks); + MergeBlockStep::Partial + } + MergeBlocksResult::Fail => MergeBlockStep::Fail, + } + } + + fn on_new_block(&mut self, operation: &OperationIr) { + let mut block = Block::new(&self.builders); + block.register(operation, self.length, true); + self.blocks.push(block); + + let length = self.length; + let num_blocks = self.blocks.len(); + log_fusion(FusionLogLevel::Full, || { + format!( + "[stream] op {length} {} → new block (total: {num_blocks})", + op_kind(operation) + ) + }); + } + + /// Give the operation its own block that depends on the blocks it reads from. + /// + /// The new block owns the operation's outputs and records its upstream inputs as external — + /// those external inputs form the dependency edges. A freshly created block is a pure sink, so + /// it cannot create a cycle, except through an in-place output that re-produces an upstream + /// tensor; that case is caught by the defensive acyclicity check. + fn on_dependent_op(&mut self, operation: &OperationIr) { + // Dependent blocks count toward the cap. With no room left, try to free a slot by + // force-merging mergeable blocks; if that fails, stop the segment (the op is deferred). + if let Some(max_blocks) = self.max_blocks + && self.blocks.len() >= max_blocks + { + self.merge_blocks(operation, true); + + if self.blocks.len() >= max_blocks { + let length = self.length; + log_fusion(FusionLogLevel::Medium, || { + format!( + "[stream] stopped (max_blocks={max_blocks} reached on dependency) at op {length} ({})", + op_kind(operation) + ) + }); + self.stopped = true; + return; + } + } + + let mut block = Block::new(&self.builders); + block.register(operation, self.length, true); + self.blocks.push(block); + + if !Dag::new(&self.blocks).is_acyclic() { + self.blocks.pop(); + let length = self.length; + log_fusion(FusionLogLevel::Medium, || { + format!( + "[stream] stopped (unresolvable dependency cycle) at op {length} ({})", + op_kind(operation) + ) + }); + self.stopped = true; + return; + } + + let length = self.length; + let num_blocks = self.blocks.len(); + log_fusion(FusionLogLevel::Full, || { + format!( + "[stream] op {length} {} → new dependent block (total: {num_blocks})", + op_kind(operation) + ) + }); + } +} + +enum MergeBlockStep { + Full, + Partial, + Fail, + NoNeed, +} + +/// Reorder the top-level strategy chunks so the execution order respects tensor handle lifetimes. +/// +/// Blocks and re-optimized holes are placed by separate, incremental heuristics that can't see the +/// whole picture (a hole may depend on another hole spliced later). This final pass treats each +/// top-level strategy as an atomic [Chunk] node and orders the chunks topologically (see +/// [GraphNode] for the hazard rules deriving the edges). Ties keep the earliest stream position +/// first, reproducing the historical order when there are no hazards. +/// +/// If the chunk graph has a cycle — the chunking can't be linearized as atomic units — unfused +/// chunks are [split](repair_order_split) to break the cycle before giving up on fusion. +fn repair_order(opt: BlockOptimization, operations: &[OperationIr]) -> BlockOptimization { + let (strategies, ordering) = match opt.strategy { + ExecutionStrategy::Composed(items) => (items, opt.ordering), + // A single strategy has nothing to reorder across, but a merge can still leave its ops + // internally out of stream order — validate, and unfuse in stream order if it's broken. + single => { + if ordering_is_valid(&opt.ordering, operations) { + return BlockOptimization::new(single, opt.ordering); + } + return unfused_stream_order(opt.ordering); + } + }; + + // Split the concatenated ordering back into one chunk per top-level strategy. + let mut chunks = Vec::with_capacity(strategies.len()); + let mut offset = 0; + for strategy in &strategies { + let len = strategy_len(strategy); + chunks.push(Chunk::new( + ordering[offset..offset + len].to_vec(), + operations, + )); + offset += len; + } + + let order = match Dag::new(&chunks).topological_order() { + Some(order) => order, + // The chunks can't be linearized as atomic units. Unfused chunks are free to split + // (their ops execute individually), which may break the cycle while every fused + // optimization survives intact. + None => return repair_order_split(strategies, chunks, operations), + }; + + assemble(strategies, &chunks, &order, operations) +} + +/// Retry [repair_order] with every [Operations](ExecutionStrategy::Operations) chunk split into +/// single-operation chunks, keeping fused optimizations atomic. Splitting only removes ordering +/// constraints, so this resolves any cycle that isn't between fused chunks themselves. +fn repair_order_split( + strategies: Vec>>, + chunks: Vec, + operations: &[OperationIr], +) -> BlockOptimization { + let mut split_strategies = Vec::new(); + let mut split_chunks = Vec::new(); + for (strategy, chunk) in strategies.into_iter().zip(chunks) { + match *strategy { + ExecutionStrategy::Operations { .. } => { + for position in chunk.positions { + split_strategies.push(Box::new(ExecutionStrategy::Operations { + ordering: Arc::new(vec![position]), + })); + split_chunks.push(Chunk::new(vec![position], operations)); + } + } + strategy => { + split_strategies.push(Box::new(strategy)); + split_chunks.push(chunk); + } + } + } + + match Dag::new(&split_chunks).topological_order() { + Some(order) => assemble(split_strategies, &split_chunks, &order, operations), + // Fused chunks are mutually dependent: the segment isn't executable as built — run + // everything unfused in stream order, which is always valid. + None => unfused_stream_order( + split_chunks + .into_iter() + .flat_map(|chunk| chunk.positions) + .collect(), + ), + } +} + +/// Emit the strategies in the given chunk order, validating the final operation-level ordering. +fn assemble( + strategies: Vec>>, + chunks: &[Chunk], + order: &[usize], + operations: &[OperationIr], +) -> BlockOptimization { + let mut slots: Vec>>> = + strategies.into_iter().map(Some).collect(); + let mut new_strategies = Vec::with_capacity(order.len()); + let mut new_ordering = Vec::new(); + for &k in order { + new_strategies.push(slots[k].take().expect("each chunk taken once")); + new_ordering.extend_from_slice(&chunks[k].positions); + } + + // A chunk that is internally miss-ordered can still violate handle lifetimes even after the + // chunk-level sort. If the result isn't executable, fall back to running every operation + // unfused in stream order. + if !ordering_is_valid(&new_ordering, operations) { + return unfused_stream_order(new_ordering); + } + + BlockOptimization::new(ExecutionStrategy::Composed(new_strategies), new_ordering) +} + +/// Run every operation unfused, in ascending stream order — always a valid execution order. +/// +/// This is the last-resort fallback: every fusion in the segment is dropped. Logged so a segment +/// that silently degrades shows up when investigating fusion regressions. +fn unfused_stream_order(mut positions: Vec) -> BlockOptimization { + let num_ops = positions.len(); + log_fusion(FusionLogLevel::Medium, || { + format!("[repair] falling back to unfused stream order ({num_ops} ops)") + }); + + positions.sort_unstable(); + let ordering = Arc::new(positions.clone()); + BlockOptimization::new(ExecutionStrategy::Operations { ordering }, positions) +} + +/// Whether the execution order respects tensor handle lifetimes: every operation's inputs must be +/// live when it runs (see [is_valid_execution_order]). +fn ordering_is_valid(ordering: &[usize], operations: &[OperationIr]) -> bool { + is_valid_execution_order(ordering.iter().map(|&position| OperationNode { + operation: &operations[position], + position, + })) +} + +/// Number of stream positions a strategy covers (its share of the concatenated ordering). +fn strategy_len(strategy: &ExecutionStrategy) -> usize { + match strategy { + ExecutionStrategy::Optimization { ordering, .. } => ordering.len(), + ExecutionStrategy::Operations { ordering } => ordering.len(), + ExecutionStrategy::Composed(items) => items.iter().map(|s| strategy_len(s)).sum(), + } +} + +/// One top-level strategy of a composed optimization, viewed as a single atomic [GraphNode] +/// covering the stream positions of its operations. +struct Chunk { + positions: Vec, + produced: HashSet, + read: HashSet, + freed: HashSet, +} + +impl Chunk { + fn new(positions: Vec, operations: &[OperationIr]) -> Self { + let mut produced = HashSet::new(); + for &position in &positions { + for tensor in operations[position].outputs() { + produced.insert(tensor.id); + } + } + + let mut read = HashSet::new(); + let mut freed = HashSet::new(); + for &position in &positions { + for tensor in operations[position].inputs() { + // A ReadWrite read frees the tensor even when this chunk also produced it — the + // write-after-read hazard against readers in *other* chunks still applies + // (mirrors [Block::register_op]). + if let TensorStatus::ReadWrite = tensor.status { + freed.insert(tensor.id); + } + if produced.contains(&tensor.id) { + continue; + } + read.insert(tensor.id); + } + } + + Self { + positions, + produced, + read, + freed, + } + } +} + +impl GraphNode for Chunk { + type Resource = TensorId; + + fn produced(&self) -> impl Iterator { + self.produced.iter().copied() + } + + fn read(&self) -> impl Iterator { + self.read.iter().copied() + } + + fn freed(&self) -> impl Iterator { + self.freed.iter().copied() + } + + fn produces(&self, resource: TensorId) -> bool { + self.produced.contains(&resource) + } + + fn reads(&self, resource: TensorId) -> bool { + self.read.contains(&resource) + } + + fn position(&self) -> usize { + self.positions.iter().copied().min().unwrap_or(0) + } +} + +/// Tests for the [repair_order] pass (driven directly with hand-built chunkings) and the +/// [on_dependent_op](StreamOptimizer::on_dependent_op) stop paths. +#[cfg(test)] +mod tests { + use super::*; + use crate::search::testing::{add, add_rw}; + use crate::stream::execution::tests::{TestOptimization, TestOptimizationBuilder}; + + fn fused_with( + builder_id: usize, + ordering: Vec, + score: u64, + ) -> ExecutionStrategy { + ExecutionStrategy::Optimization { + opt: TestOptimization::new(builder_id, ordering.len()), + ordering: Arc::new(ordering), + score, + } + } + + fn fused(ordering: Vec) -> ExecutionStrategy { + fused_with(0, ordering, 0) + } + + fn unfused(ordering: Vec) -> ExecutionStrategy { + ExecutionStrategy::Operations { + ordering: Arc::new(ordering), + } + } + + fn composed( + parts: Vec>, + ) -> ExecutionStrategy { + ExecutionStrategy::Composed(parts.into_iter().map(Box::new).collect()) + } + + /// A chunk producing AND freeing the same tensor still constrains readers in other chunks: + /// the free must be a write-after-read edge. With the edge, the chunk-level cycle is + /// detected and the unfused chunk is split around the fused one, salvaging the fusion; + /// without it, the invalid order is only caught by the final validation, which unfuses the + /// whole segment. + #[test] + fn splits_unfused_produce_free_chunk_instead_of_unfusing_everything() { + let ops = vec![ + add(1, 2, 10), // 0: produces 10 (unfused chunk) + add(10, 3, 100), // 1: fused, reads 10 + add(100, 4, 101), // 2: fused + add_rw(10, 5, 11), // 3: frees 10 (same unfused chunk as op 0) + ]; + let opt = BlockOptimization::new( + composed(vec![fused(vec![1, 2]), unfused(vec![0, 3])]), + vec![1, 2, 0, 3], + ); + + let repaired = repair_order(opt, &ops); + + // The fused pair survives; the produce/free chunk is split around it. + assert_eq!(repaired.ordering, vec![0, 1, 2, 3]); + assert_eq!( + repaired.strategy, + composed(vec![unfused(vec![0]), fused(vec![1, 2]), unfused(vec![3])]), + ); + } + + /// A chunk appended after a chunk it feeds (a hole depending on a later-spliced hole) is + /// moved before its consumer. + #[test] + fn reorders_chunks_by_dependency() { + let ops = vec![ + add(1, 2, 5), // 0: fused + add(5, 3, 10), // 1: fused, produces 10 + add(10, 4, 20), // 2: unfused, reads 10 + ]; + // The consumer chunk was placed first. + let opt = BlockOptimization::new( + composed(vec![unfused(vec![2]), fused(vec![0, 1])]), + vec![2, 0, 1], + ); + + let repaired = repair_order(opt, &ops); + + assert_eq!(repaired.ordering, vec![0, 1, 2]); + assert_eq!( + repaired.strategy, + composed(vec![fused(vec![0, 1]), unfused(vec![2])]), + ); + } + + /// Two fused chunks that feed each other cannot be linearized as atomic units and cannot be + /// split — the whole segment falls back to unfused stream order. + #[test] + fn falls_back_to_unfused_when_fused_chunks_cycle() { + let ops = vec![ + add(1, 2, 10), // 0: chunk A, produces 10 + add(10, 6, 20), // 1: chunk B, reads 10, produces 20 + add(20, 7, 21), // 2: chunk B + add(20, 5, 11), // 3: chunk A, reads 20 + ]; + let opt = BlockOptimization::new( + composed(vec![fused(vec![0, 3]), fused(vec![1, 2])]), + vec![0, 3, 1, 2], + ); + + let repaired = repair_order(opt, &ops); + + assert_eq!(repaired.ordering, vec![0, 1, 2, 3]); + assert_eq!(repaired.strategy, unfused(vec![0, 1, 2, 3])); + } + + /// A single (non-composed) strategy whose internal order breaks a tensor lifetime is + /// replaced by unfused stream order. + #[test] + fn single_strategy_with_invalid_order_unfuses() { + let ops = vec![ + add(1, 2, 10), // 0: produces 10 + add(10, 3, 11), // 1: reads 10 + ]; + // The consumer is fused before its producer. + let opt = BlockOptimization::new(fused(vec![1, 0]), vec![1, 0]); + + let repaired = repair_order(opt, &ops); + + assert_eq!(repaired.ordering, vec![0, 1]); + assert_eq!(repaired.strategy, unfused(vec![0, 1])); + } + + /// A single strategy with a valid order passes through untouched. + #[test] + fn single_strategy_with_valid_order_passes_through() { + let ops = vec![add(1, 2, 10), add(10, 3, 11)]; + let opt = BlockOptimization::new(fused(vec![0, 1]), vec![0, 1]); + + let repaired = repair_order(opt, &ops); + + assert_eq!(repaired.ordering, vec![0, 1]); + assert_eq!(repaired.strategy, fused(vec![0, 1])); + } + + /// Build a [StreamOptimizer] with one [TestOptimizationBuilder] per pattern. + fn optimizer(patterns: Vec>) -> StreamOptimizer { + let builders = patterns + .into_iter() + .enumerate() + .map(|(i, p)| { + Box::new(TestOptimizationBuilder::new(i, p)) + as Box> + }) + .collect(); + StreamOptimizer::new(builders) + } + + /// A dependent op arrives while the block cap is full of un-mergeable (ready) blocks: the + /// segment stops, the op is deferred, and the blocks registered so far still optimize. + #[test] + fn dependent_op_at_max_blocks_stops_when_no_merge_possible() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + let c = add(104, 204, 105); // Reads A's 104 and B's 204. + + let mut opt = optimizer(vec![ + vec![a1.clone(), a2.clone()], + vec![b1.clone(), b2.clone()], + ]); + opt.max_blocks = Some(2); + + for op in [&a1, &a2, &b1, &b2] { + opt.register(op); + } + assert!(!opt.stopped); + + // Both blocks hold their own ready fusion, so force-merging cannot free a slot. + opt.register(&c); + assert!(opt.stopped); + + let ops = vec![a1, a2, b1, b2]; + let res = opt.optimize(&ops); + assert_eq!(res.ordering, vec![0, 1, 2, 3]); + assert_eq!( + res.strategy, + composed(vec![ + fused_with(0, vec![0, 1], 2), + fused_with(1, vec![2, 3], 2), + ]), + ); + } + + /// The defensive cycle check in [on_dependent_op](StreamOptimizer::on_dependent_op): an op + /// that reads a tensor a block frees AND a tensor the same block produces would have to run + /// both before and after that block. Unreachable from a valid stream (it reads a freed + /// tensor), so the path is driven directly. + #[test] + fn dependent_op_creating_a_cycle_stops_segment() { + let a = add_rw(1, 2, 3); // Block A: frees 1, produces 3. + let b = add(10, 11, 12); // Block B: unrelated. + let n = add(1, 3, 20); // Reads 1 (freed by A) and 3 (produced by A). + + let mut opt = optimizer(vec![]); + opt.max_blocks = None; + opt.register(&a); + opt.register(&b); + assert_eq!(opt.blocks.len(), 2); + + opt.on_dependent_op(&n); + + assert!(opt.stopped); + assert_eq!(opt.blocks.len(), 2, "the cyclic block was rejected"); + } +} diff --git a/crates/burn-fusion/src/search/optimization/tests.rs b/crates/burn-fusion/src/search/optimization/tests.rs new file mode 100644 index 0000000..2b46cb5 --- /dev/null +++ b/crates/burn-fusion/src/search/optimization/tests.rs @@ -0,0 +1,387 @@ +//! Tests for the [StreamOptimizer] and the underlying [BlocksOptimizer]. +//! +//! These tests focus on whether the optimizer reorders operations across +//! independent blocks to find better fusion opportunities. Two operations end +//! up in distinct blocks when their tensor ids are disjoint — meaning they +//! have no data dependencies and can safely be reordered. + +use std::sync::Arc; + +use super::StreamOptimizer; +use crate::search::testing::add; +use crate::stream::execution::tests::{TestOptimization, TestOptimizationBuilder}; +use crate::{OperationFuser, search::BlockOptimization, stream::store::ExecutionStrategy}; +use burn_ir::OperationIr; + +/// Build a [StreamOptimizer] with one [TestOptimizationBuilder] per pattern. +fn optimizer(patterns: Vec>) -> StreamOptimizer { + let builders = patterns + .into_iter() + .enumerate() + .map(|(i, p)| { + Box::new(TestOptimizationBuilder::new(i, p)) + as Box> + }) + .collect(); + StreamOptimizer::new(builders) +} + +/// Register every op in the stream and return the resulting [BlockOptimization]. +fn run( + ops: &[OperationIr], + patterns: Vec>, +) -> BlockOptimization { + let mut opt = optimizer(patterns); + for op in ops { + opt.register(op); + } + opt.optimize(ops) +} + +// --- Strategy constructors (make expected values short and readable) ------- + +fn optimization( + builder_id: usize, + size: usize, + ordering: Vec, + score: u64, +) -> ExecutionStrategy { + ExecutionStrategy::Optimization { + opt: TestOptimization::new(builder_id, size), + ordering: Arc::new(ordering), + score, + } +} + +fn operations(ordering: Vec) -> ExecutionStrategy { + ExecutionStrategy::Operations { + ordering: Arc::new(ordering), + } +} + +fn composed( + parts: Vec>, +) -> ExecutionStrategy { + ExecutionStrategy::Composed(parts.into_iter().map(Box::new).collect()) +} + +// --------------------------------------------------------------------------- +// Baseline: a single block with a builder whose pattern matches the stream. +// --------------------------------------------------------------------------- + +#[test] +fn single_block_fuses_full_stream() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let ops = vec![a1.clone(), a2.clone()]; + + let res = run(&ops, vec![vec![a1, a2]]); + + assert_eq!(res.ordering, vec![0, 1]); + assert_eq!(res.strategy, optimization(0, 2, vec![0, 1], 2)); +} + +// --------------------------------------------------------------------------- +// Two independent blocks, registered contiguously (all A then all B). +// Both should fuse — no reordering required. +// --------------------------------------------------------------------------- + +#[test] +fn contiguous_independent_blocks_fuse_both() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + let ops = vec![a1.clone(), a2.clone(), b1.clone(), b2.clone()]; + + let res = run( + &ops, + vec![vec![a1.clone(), a2.clone()], vec![b1.clone(), b2.clone()]], + ); + + assert_eq!(res.ordering, vec![0, 1, 2, 3]); + assert_eq!( + res.strategy, + composed(vec![ + optimization(0, 2, vec![0, 1], 2), + optimization(1, 2, vec![2, 3], 2), + ]), + ); +} + +// --------------------------------------------------------------------------- +// Two independent blocks *interleaved* in the stream. +// +// Block A has no shared tensors with block B, so the two blocks have no data +// dependency — reordering them is safe. Both blocks fuse, each block's ops +// grouped contiguously. +// --------------------------------------------------------------------------- + +#[test] +fn interleaved_independent_blocks_fuse_both() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + // Stream is A1, B1, A2, B2 — each pair belongs to its own independent block. + let ops = vec![a1.clone(), b1.clone(), a2.clone(), b2.clone()]; + + let res = run( + &ops, + vec![vec![a1.clone(), a2.clone()], vec![b1.clone(), b2.clone()]], + ); + + assert_eq!(res.ordering, vec![0, 2, 1, 3]); + assert_eq!( + res.strategy, + composed(vec![ + optimization(0, 2, vec![0, 2], 2), + optimization(1, 2, vec![1, 3], 2), + ]), + ); +} + +// --------------------------------------------------------------------------- +// Interleaved independent blocks with only ONE builder that matches the +// *combined*, reordered stream [A1, A2, B1, B2]. The merging pass combines +// the two blocks so the builder sees its full pattern and fuses everything +// into one optimization. +// --------------------------------------------------------------------------- + +#[test] +fn interleaved_blocks_with_merged_pattern_fuses_full() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + let ops = vec![a1.clone(), b1.clone(), a2.clone(), b2.clone()]; + + // Single builder whose pattern is the combined, reordered stream. + let res = run( + &ops, + vec![vec![a1.clone(), a2.clone(), b1.clone(), b2.clone()]], + ); + + assert_eq!(res.ordering, vec![0, 2, 1, 3]); + assert_eq!(res.strategy, optimization(0, 4, vec![0, 2, 1, 3], 4)); +} + +// --------------------------------------------------------------------------- +// Interleaved independent blocks, only one has a matching builder. Block A +// fuses; block B's ops survive as un-fused Operations in the composed +// strategy. +// --------------------------------------------------------------------------- + +#[test] +fn interleaved_blocks_single_builder_preserves_other_ops() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + let ops = vec![a1.clone(), b1.clone(), a2.clone(), b2.clone()]; + + // Only a builder for block A — B's ops survive as Operations. + let res = run(&ops, vec![vec![a1.clone(), a2.clone()]]); + + assert_eq!(res.ordering, vec![0, 2, 1, 3]); + assert_eq!( + res.strategy, + composed(vec![ + optimization(0, 2, vec![0, 2], 2), + operations(vec![1, 3]), + ]), + ); +} + +// --------------------------------------------------------------------------- +// Within a single block, operations are registered in stream order. A builder +// whose pattern would only match a *reordered* version of the stream cannot +// fuse. This documents that reordering does NOT happen inside a block (unlike +// across blocks). +// --------------------------------------------------------------------------- + +#[test] +fn within_block_order_is_not_reordered() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let ops = vec![a1.clone(), a2.clone()]; + + // Builder wants [a2, a1] — only reachable if we reorder within the block. + let res = run(&ops, vec![vec![a2.clone(), a1.clone()]]); + + assert_eq!(res.ordering, vec![0, 1]); + assert_eq!(res.strategy, operations(vec![0, 1])); +} + +// --------------------------------------------------------------------------- +// Lazy-optimization semantics. +// +// `still_optimizing()` is a "keep feeding me" signal for the caller: it +// returns true if at least one builder might still match a longer pattern. +// `optimize()` is the commit point — it picks the best *ready* fusion, even +// if other builders are still open (this is what happens on a forced sync). +// --------------------------------------------------------------------------- + +/// A builder whose pattern is longer than the stream so far. The builder's +/// actual ops match the pattern prefix, so the builder stays Open and +/// `still_optimizing` keeps signaling "feed me more". A forced `optimize` +/// (simulating a sync) falls back to unfused operations because no builder +/// has reached `ready`. +#[test] +fn pattern_longer_than_stream_stays_optimizing() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let a3 = add(104, 105, 106); + + let mut opt = optimizer(vec![vec![a1.clone(), a2.clone(), a3.clone()]]); + opt.register(&a1); + opt.register(&a2); + + assert!(opt.still_optimizing(), "builder still waiting for a3"); + + let ops = vec![a1, a2]; + let res = opt.optimize(&ops); + assert_eq!(res.ordering, vec![0, 1]); + assert_eq!(res.strategy, operations(vec![0, 1])); +} + +/// One builder is ready at two ops, another is still open waiting for a +/// third. `still_optimizing` stays true so the caller has the option to keep +/// feeding and pick up the longer fusion. A forced commit takes the ready +/// one. +#[test] +fn ready_and_open_coexist_keeps_optimizing() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let a3 = add(104, 105, 106); + + let mut opt = optimizer(vec![ + vec![a1.clone(), a2.clone()], // ready after 2 ops + vec![a1.clone(), a2.clone(), a3.clone()], // still open after 2 ops + ]); + opt.register(&a1); + opt.register(&a2); + + assert!(opt.still_optimizing(), "builder 2 still waiting for a3"); + + let ops = vec![a1, a2]; + let res = opt.optimize(&ops); + assert_eq!(res.ordering, vec![0, 1]); + assert_eq!(res.strategy, optimization(0, 2, vec![0, 1], 2)); +} + +/// Multiple builders are ready at commit time — the highest-scoring fusion +/// wins. +#[test] +fn commit_picks_highest_score_ready_builder() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let a3 = add(104, 105, 106); + + let mut opt = optimizer(vec![ + vec![a1.clone(), a2.clone()], // score 2 + vec![a1.clone(), a2.clone(), a3.clone()], // score 3 + ]); + opt.register(&a1); + opt.register(&a2); + opt.register(&a3); + + // Every builder is closed (everyone has seen at least as many ops as its + // pattern) — there's nothing more to gain by feeding. + assert!(!opt.still_optimizing()); + + let ops = vec![a1, a2, a3]; + let res = opt.optimize(&ops); + assert_eq!(res.ordering, vec![0, 1, 2]); + assert_eq!(res.strategy, optimization(1, 3, vec![0, 1, 2], 3)); +} + +/// Every builder is closed with `ready=false` (the stream diverged from +/// every pattern). `still_optimizing` flips to false — caller should +/// commit. Forced commit returns unfused operations. +#[test] +fn all_closed_without_ready_bails_out() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + // Builder wants [a1, b1]: stream diverges at the second op. + let b1 = add(200, 201, 202); + + let mut opt = optimizer(vec![vec![a1.clone(), b1.clone()]]); + opt.register(&a1); + opt.register(&a2); + + assert!(!opt.still_optimizing(), "mismatch closes the builder"); + + let ops = vec![a1, a2]; + let res = opt.optimize(&ops); + assert_eq!(res.ordering, vec![0, 1]); + assert_eq!(res.strategy, operations(vec![0, 1])); +} + +/// A single pattern that spans two independent blocks, but the last op of +/// the pattern hasn't arrived yet. The merge succeeds (builder stays Open in +/// the merged block → `merge` returns true), so `still_optimizing` stays +/// true. A forced commit falls back to unfused operations in the merged +/// block's registration order. +#[test] +fn pattern_straddles_incomplete_merged_blocks() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); + + // Stream is [a1, b1, a2] — builder needs b2 too. + let mut opt = optimizer(vec![vec![a1.clone(), a2.clone(), b1.clone(), b2.clone()]]); + opt.register(&a1); + opt.register(&b1); + opt.register(&a2); + + assert!(opt.still_optimizing(), "builder waiting for b2"); + + let ops = vec![a1, b1, a2]; + let res = opt.optimize(&ops); + // After merging, the merged block's registration order is + // [a1 (pos 0), a2 (pos 2), b1 (pos 1)]. + assert_eq!(res.ordering, vec![0, 2, 1]); + assert_eq!(res.strategy, operations(vec![0, 2, 1])); +} + +// --------------------------------------------------------------------------- +// Dependent blocks. +// +// Block A and block B each hold their own *ready* fusion. Merging them would +// hide one of the two fusions, so `Block::merge` refuses. Op C then reads an +// output of A and an output of B, so it references two un-mergeable blocks. +// +// Previously this stopped the search entirely. Now C gets its own block that +// *depends on* A and B; the search continues and every op is covered. C's +// (unfused) block is emitted after both blocks it depends on. +// --------------------------------------------------------------------------- + +#[test] +fn dependent_block_created_instead_of_stopping() { + let a1 = add(100, 101, 102); + let a2 = add(102, 103, 104); // block A produces 104 + let b1 = add(200, 201, 202); + let b2 = add(202, 203, 204); // block B produces 204 + let c = add(104, 204, 105); // reads A's 104 and B's 204 + + let ops = vec![a1.clone(), a2.clone(), b1.clone(), b2.clone(), c.clone()]; + + let res = run( + &ops, + vec![vec![a1.clone(), a2.clone()], vec![b1.clone(), b2.clone()]], + ); + + // All five ops are covered (the search did not stop at C) and C runs after + // both A and B — a valid topological order. + assert_eq!(res.ordering, vec![0, 1, 2, 3, 4]); + assert_eq!( + res.strategy, + composed(vec![ + optimization(0, 2, vec![0, 1], 2), + optimization(1, 2, vec![2, 3], 2), + operations(vec![4]), + ]), + ); +} diff --git a/crates/burn-fusion/src/search/testing.rs b/crates/burn-fusion/src/search/testing.rs new file mode 100644 index 0000000..c964764 --- /dev/null +++ b/crates/burn-fusion/src/search/testing.rs @@ -0,0 +1,41 @@ +//! Shared IR constructors for the search tests. + +use burn_backend::{DType, Shape}; +use burn_ir::{BinaryOpIr, NumericOperationIr, OperationIr, TensorId, TensorIr, TensorStatus}; + +/// A `[32, 32]` float tensor with the given id, read with `ReadOnly` status. +pub(crate) fn tensor(id: u64) -> TensorIr { + TensorIr { + id: TensorId::new(id), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + } +} + +/// An add operation reading `lhs` and `rhs` and producing `out`. +pub(crate) fn add(lhs: u64, rhs: u64, out: u64) -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::Add(BinaryOpIr { + lhs: tensor(lhs), + rhs: tensor(rhs), + out: tensor(out), + }), + ) +} + +/// Like [add] but reads `lhs` with [ReadWrite](TensorStatus::ReadWrite) status — it is the last +/// use and frees the tensor. +pub(crate) fn add_rw(lhs: u64, rhs: u64, out: u64) -> OperationIr { + let mut lhs = tensor(lhs); + lhs.status = TensorStatus::ReadWrite; + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::Add(BinaryOpIr { + lhs, + rhs: tensor(rhs), + out: tensor(out), + }), + ) +} diff --git a/crates/burn-fusion/src/server.rs b/crates/burn-fusion/src/server.rs new file mode 100644 index 0000000..790ea82 --- /dev/null +++ b/crates/burn-fusion/src/server.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use crate::{ + FusionBackend, FusionRuntime, UnfusedOp, + stream::{MultiStream, StreamId}, +}; +use burn_backend::{TensorData, backend::ExecutionError}; +use burn_ir::{HandleContainer, OperationIr, TensorId, TensorIr}; +use burn_std::{CommunicationId, stub::RwLock}; +use hashbrown::HashSet; + +pub(crate) struct FusionUtilities { + // Used in client using a downcast. + #[allow(dead_code)] + pub(crate) initialized_comms: RwLock>, +} + +pub struct FusionServer { + streams: MultiStream, + pub(crate) handles: HandleContainer, + pub(crate) utilities: Arc, +} + +impl FusionServer +where + R: FusionRuntime, +{ + pub fn new(device: R::FusionDevice, utilities: FusionUtilities) -> Self { + Self { + streams: MultiStream::new(device.clone()), + handles: HandleContainer::new(), + utilities: Arc::new(utilities), + } + } + + pub fn register(&mut self, stream: StreamId, repr: OperationIr, operation: UnfusedOp) { + self.streams + .register(stream, repr, operation, &mut self.handles) + } + + pub fn tag_shared_view(&mut self, src_stream: StreamId, src: TensorId, dst: TensorId) { + self.streams + .tag_shared_view(src_stream, src, dst, &mut self.handles) + } + + pub fn drain_stream(&mut self, id: StreamId) { + self.streams.drain(&mut self.handles, id) + } + + pub fn read_float(&mut self, tensor: TensorIr, id: StreamId) -> B::FloatTensorPrimitive + where + B: FusionBackend, + { + // Make sure all registered operations are executed. + // The underlying backend can still be async. + self.drain_stream(id); + let tensor_float = self.handles.get_float_tensor::(&tensor); + self.streams.mark_read(id, &tensor, &self.handles); + tensor_float + } + + pub fn read_int(&mut self, tensor: TensorIr, id: StreamId) -> B::IntTensorPrimitive + where + B: FusionBackend, + { + // Make sure all registered operations are executed. + // The underlying backend can still be async. + self.drain_stream(id); + let tensor_int = self.handles.get_int_tensor::(&tensor); + self.streams.mark_read(id, &tensor, &self.handles); + tensor_int + } + + pub fn read_bool(&mut self, tensor: TensorIr, id: StreamId) -> B::BoolTensorPrimitive + where + B: FusionBackend, + { + // Make sure all registered operations are executed. + // The underlying backend can still be async. + self.drain_stream(id); + let tensor_bool = self.handles.get_bool_tensor::(&tensor); + self.streams.mark_read(id, &tensor, &self.handles); + tensor_bool + } + + pub fn read_quantized( + &mut self, + tensor: TensorIr, + id: StreamId, + ) -> B::QuantizedTensorPrimitive + where + B: FusionBackend, + { + // Make sure all registered operations are executed. + // The underlying backend can still be async. + self.drain_stream(id); + let tensor_q = self.handles.get_quantized_tensor::(&tensor); + self.streams.mark_read(id, &tensor, &self.handles); + tensor_q + } + + pub fn float_data( + &mut self, + tensor: TensorIr, + id: StreamId, + ) -> impl Future> + Send + use + where + B: FusionBackend, + { + B::float_into_data(self.read_float::(tensor, id)) + } + + pub fn int_data( + &mut self, + tensor: TensorIr, + id: StreamId, + ) -> impl Future> + Send + use + where + B: FusionBackend, + { + B::int_into_data(self.read_int::(tensor, id)) + } + + pub fn bool_data( + &mut self, + tensor: TensorIr, + id: StreamId, + ) -> impl Future> + Send + use + where + B: FusionBackend, + { + B::bool_into_data(self.read_bool::(tensor, id)) + } + + pub fn quantized_data( + &mut self, + tensor: TensorIr, + id: StreamId, + ) -> impl Future> + Send + use + where + B: FusionBackend, + { + B::q_into_data(self.read_quantized::(tensor, id)) + } + + pub fn resolve_server_float(&mut self, tensor: &TensorIr) -> B::FloatTensorPrimitive + where + B: FusionBackend, + { + self.handles.get_float_tensor::(tensor) + } + + pub fn resolve_server_int(&mut self, tensor: &TensorIr) -> B::IntTensorPrimitive + where + B: FusionBackend, + { + self.handles.get_int_tensor::(tensor) + } + + pub fn resolve_server_bool(&mut self, tensor: &TensorIr) -> B::BoolTensorPrimitive + where + B: FusionBackend, + { + self.handles.get_bool_tensor::(tensor) + } +} diff --git a/crates/burn-fusion/src/stream/base.rs b/crates/burn-fusion/src/stream/base.rs new file mode 100644 index 0000000..39336fd --- /dev/null +++ b/crates/burn-fusion/src/stream/base.rs @@ -0,0 +1 @@ +pub use burn_backend::StreamId; diff --git a/crates/burn-fusion/src/stream/context.rs b/crates/burn-fusion/src/stream/context.rs new file mode 100644 index 0000000..a8c38e6 --- /dev/null +++ b/crates/burn-fusion/src/stream/context.rs @@ -0,0 +1,1792 @@ +use burn_backend::{Shape, Slice}; +use burn_ir::*; +use hashbrown::HashMap; + +/// The context contains the relative graph tensor mapping so that a relative tensor id can be +/// mapped to an existing tensor that can be fetched and updated with the +/// [handle container](HandleContainer). +/// +/// It also contains all scalar values, which can change even for the same graph. They are sorted +/// in the order in which they appear in the graph. +pub struct Context { + /// The tensor mapping where local tensor id points to the updated tensor representation. + pub tensors: HashMap, + /// Handle container to retrieve tensors based on their representation. + pub handles: HandleContainer, + /// Scalars found in the graph in the order they appeared. + pub scalars: HashMap, + /// Shape mapping from relative shape ids to global (real) shape ids. + pub shapes_relative2global: HashMap, + /// Concrete slice ranges found in the graph, indexed by the placeholder id assigned during + /// relativization (the value carried in a relativized range's `start` field). Ranges can change + /// for the same relative graph — like scalars — so they are kept out of the relative form and + /// rebound per invocation. + pub ranges: Vec, +} + +impl Context { + /// Fork the context into an independent owned copy. Used by autotuning to give each + /// benchmark run a sandbox — mutations stay local until the caller merges new handles + /// back. `HandleContainer::fork` clones the id→handle map; actual GPU buffers are + /// refcounted so only the map itself is duplicated. + pub fn fork(&self) -> Self { + Self { + tensors: self.tensors.clone(), + handles: self.handles.fork(), + scalars: self.scalars.clone(), + shapes_relative2global: self.shapes_relative2global.clone(), + ranges: self.ranges.clone(), + } + } +} + +#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)] +/// Scalar unique identifier. +pub struct ScalarId { + /// The value. + pub value: u64, +} + +pub(crate) struct OperationConverter { + tensors_relative2global: HashMap, + tensors_global2relative: HashMap, + shapes_global2relative: HashMap, + shapes_relative2global: HashMap, + scalars: HashMap, + ranges: Vec, +} + +impl Default for OperationConverter { + fn default() -> Self { + let mut val = Self { + tensors_relative2global: Default::default(), + tensors_global2relative: Default::default(), + shapes_global2relative: Default::default(), + shapes_relative2global: Default::default(), + scalars: Default::default(), + ranges: Default::default(), + }; + + // global 1 is always shape id 0. + val.shapes_global2relative.insert(1, 0); + val.shapes_relative2global.insert(0, 1); + + val + } +} + +/// RAII guard that temporarily lends three [`OperationConverter`] fields plus a +/// [`HandleContainer`] into a single owned [`Context`]. On drop, the guard moves every +/// field back into its original location. +pub(crate) struct ContextGuard<'a, H> { + context: Option>, + converter: &'a mut OperationConverter, + handles: &'a mut HandleContainer, +} + +impl<'a, H> ContextGuard<'a, H> { + /// Move the converter's per-block exposed state and the server's handle container into a + /// fresh [`Context`]. The originals are left holding `Default` placeholders; the guard + /// will swap them back on drop. + pub(crate) fn new( + converter: &'a mut OperationConverter, + handles: &'a mut HandleContainer, + ) -> Self { + let context = Context { + tensors: core::mem::take(&mut converter.tensors_relative2global), + scalars: core::mem::take(&mut converter.scalars), + shapes_relative2global: core::mem::take(&mut converter.shapes_relative2global), + ranges: core::mem::take(&mut converter.ranges), + handles: core::mem::take(handles), + }; + + Self { + context: Some(context), + converter, + handles, + } + } +} + +impl core::ops::Deref for ContextGuard<'_, H> { + type Target = Context; + + fn deref(&self) -> &Self::Target { + self.context.as_ref().expect("context guard is alive") + } +} + +impl core::ops::DerefMut for ContextGuard<'_, H> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.context.as_mut().expect("context guard is alive") + } +} + +impl Drop for ContextGuard<'_, H> { + fn drop(&mut self) { + if let Some(ctx) = self.context.take() { + self.converter.tensors_relative2global = ctx.tensors; + self.converter.scalars = ctx.scalars; + self.converter.shapes_relative2global = ctx.shapes_relative2global; + self.converter.ranges = ctx.ranges; + *self.handles = ctx.handles; + } + } +} + +pub(crate) trait RelativeOps { + /// Convert (usually an [`OperationIr`]) to a relative form. + /// + /// The id and the shape of tensors will be computed relative to existing + /// operations in the queue. We do this because we want to fuse operations + /// that have similar shapes, but we do not care about the exact values. + /// + /// Similar we do not care about the exact ids of the tensor, but about their + /// relative ids (how close they are in the operation queue) + fn to_relative(&self, converter: &mut OperationConverter) -> Self; +} + +impl OperationConverter { + pub(crate) fn clear(&mut self) { + self.tensors_relative2global.clear(); + self.tensors_global2relative.clear(); + + self.shapes_global2relative.clear(); + self.shapes_relative2global.clear(); + + // global 1 is always shape id 0. + self.shapes_global2relative.insert(1, 0); + self.shapes_relative2global.insert(0, 1); + + self.scalars.clear(); + self.ranges.clear(); + } + + /// Relativize a slice range: stash the concrete bounds (they vary per invocation) and return a + /// placeholder whose `start` carries the binding id, mirroring how scalars become + /// `ScalarIr::UInt(placeholder)`. The relative form is thus invariant to the actual bounds, so + /// graphs that differ only in slice ranges still match — and graph replay restores the concrete + /// range from [`Context::ranges`] / `GraphBindings::ranges`. + fn relative_range(&mut self, range: &Slice) -> Slice { + let id = self.ranges.len(); + self.ranges.push(*range); + Slice { + start: id as isize, + end: None, + step: 1, + } + } +} + +impl RelativeOps for OperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + OperationIr::BaseFloat(ops) => OperationIr::BaseFloat(ops.to_relative(converter)), + OperationIr::BaseInt(ops) => OperationIr::BaseInt(ops.to_relative(converter)), + OperationIr::BaseBool(ops) => OperationIr::BaseBool(ops.to_relative(converter)), + OperationIr::NumericFloat(dtype, ops) => { + OperationIr::NumericFloat(*dtype, ops.to_relative(converter)) + } + OperationIr::NumericInt(dtype, ops) => { + OperationIr::NumericInt(*dtype, ops.to_relative(converter)) + } + OperationIr::Bool(ops) => OperationIr::Bool(ops.to_relative(converter)), + OperationIr::Int(ops) => OperationIr::Int(ops.to_relative(converter)), + OperationIr::Float(dtype, ops) => { + OperationIr::Float(*dtype, ops.to_relative(converter)) + } + OperationIr::Module(ops) => OperationIr::Module(ops.to_relative(converter)), + OperationIr::Custom(ops) => OperationIr::Custom(ops.to_relative(converter)), + OperationIr::Init(ops) => OperationIr::Init(ops.to_relative(converter)), + OperationIr::Drop(tensor) => OperationIr::Drop(tensor.to_relative(converter)), + OperationIr::Distributed(ops) => OperationIr::Distributed(ops.to_relative(converter)), + OperationIr::Activation(ops) => OperationIr::Activation(ops.to_relative(converter)), + } + } +} + +impl RelativeOps for ModuleOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + ModuleOperationIr::Embedding(desc) => ModuleOperationIr::Embedding(EmbeddingOpIr { + weights: desc.weights.to_relative(converter), + indices: desc.indices.to_relative(converter), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::EmbeddingBackward(desc) => { + ModuleOperationIr::EmbeddingBackward(EmbeddingBackwardOpIr { + weights: desc.weights.to_relative(converter), + out_grad: desc.out_grad.to_relative(converter), + indices: desc.indices.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Linear(desc) => ModuleOperationIr::Linear(LinearOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::LinearXBackward(desc) => { + ModuleOperationIr::LinearXBackward(LinearXBackwardOpIr { + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::LinearWeightBackward(desc) => { + ModuleOperationIr::LinearWeightBackward(LinearWeightBackwardOpIr { + x: desc.x.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::LinearBiasBackward(desc) => { + ModuleOperationIr::LinearBiasBackward(LinearBiasBackwardOpIr { + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv1d(desc) => ModuleOperationIr::Conv1d(Conv1dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::Conv1dXBackward(desc) => { + ModuleOperationIr::Conv1dXBackward(Conv1dXBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv1dWeightBackward(desc) => { + ModuleOperationIr::Conv1dWeightBackward(Conv1dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv1dBiasBackward(desc) => { + ModuleOperationIr::Conv1dBiasBackward(Conv1dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv2d(desc) => ModuleOperationIr::Conv2d(Conv2dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::Conv2dXBackward(desc) => { + ModuleOperationIr::Conv2dXBackward(Conv2dXBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv2dWeightBackward(desc) => { + ModuleOperationIr::Conv2dWeightBackward(Conv2dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv2dBiasBackward(desc) => { + ModuleOperationIr::Conv2dBiasBackward(Conv2dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv3d(desc) => ModuleOperationIr::Conv3d(Conv3dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::Conv3dXBackward(desc) => { + ModuleOperationIr::Conv3dXBackward(Conv3dXBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv3dWeightBackward(desc) => { + ModuleOperationIr::Conv3dWeightBackward(Conv3dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Conv3dBiasBackward(desc) => { + ModuleOperationIr::Conv3dBiasBackward(Conv3dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::DeformableConv2d(desc) => { + ModuleOperationIr::DeformableConv2d(Box::new(DeformConv2dOpIr { + x: desc.x.to_relative(converter), + offset: desc.offset.to_relative(converter), + weight: desc.weight.to_relative(converter), + mask: desc.mask.as_ref().map(|t| t.to_relative(converter)), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + })) + } + ModuleOperationIr::DeformableConv2dBackward(desc) => { + ModuleOperationIr::DeformableConv2dBackward(Box::new(DeformConv2dBackwardOpIr { + x: desc.x.to_relative(converter), + offset: desc.offset.to_relative(converter), + weight: desc.weight.to_relative(converter), + mask: desc.mask.as_ref().map(|t| t.to_relative(converter)), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + out_grad: desc.out_grad.to_relative(converter), + options: desc.options.clone(), + input_grad: desc.input_grad.to_relative(converter), + offset_grad: desc.offset_grad.to_relative(converter), + weight_grad: desc.weight_grad.to_relative(converter), + mask_grad: desc.mask_grad.as_ref().map(|t| t.to_relative(converter)), + bias_grad: desc.bias_grad.as_ref().map(|t| t.to_relative(converter)), + })) + } + ModuleOperationIr::ConvTranspose1d(desc) => { + ModuleOperationIr::ConvTranspose1d(ConvTranspose1dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::ConvTranspose2d(desc) => { + ModuleOperationIr::ConvTranspose2d(ConvTranspose2dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::ConvTranspose3d(desc) => { + ModuleOperationIr::ConvTranspose3d(ConvTranspose3dOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + bias: desc.bias.as_ref().map(|t| t.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AvgPool1d(desc) => ModuleOperationIr::AvgPool1d(AvgPool1dOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + count_include_pad: desc.count_include_pad, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::AvgPool2d(desc) => ModuleOperationIr::AvgPool2d(AvgPool2dOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + count_include_pad: desc.count_include_pad, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::AvgPool1dBackward(desc) => { + ModuleOperationIr::AvgPool1dBackward(AvgPool1dBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + count_include_pad: desc.count_include_pad, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AvgPool2dBackward(desc) => { + ModuleOperationIr::AvgPool2dBackward(AvgPool2dBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + count_include_pad: desc.count_include_pad, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AdaptiveAvgPool1d(desc) => { + ModuleOperationIr::AdaptiveAvgPool1d(AdaptiveAvgPool1dOpIr { + x: desc.x.to_relative(converter), + output_size: desc.output_size, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AdaptiveAvgPool2d(desc) => { + ModuleOperationIr::AdaptiveAvgPool2d(AdaptiveAvgPool2dOpIr { + x: desc.x.to_relative(converter), + output_size: desc.output_size, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AdaptiveAvgPool1dBackward(desc) => { + ModuleOperationIr::AdaptiveAvgPool1dBackward(AdaptiveAvgPool1dBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::AdaptiveAvgPool2dBackward(desc) => { + ModuleOperationIr::AdaptiveAvgPool2dBackward(AdaptiveAvgPool2dBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::MaxPool1d(desc) => ModuleOperationIr::MaxPool1d(MaxPool1dOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::MaxPool1dWithIndices(desc) => { + ModuleOperationIr::MaxPool1dWithIndices(MaxPool1dWithIndicesOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + out_indices: desc.out_indices.to_relative(converter), + }) + } + ModuleOperationIr::MaxPool1dWithIndicesBackward(desc) => { + ModuleOperationIr::MaxPool1dWithIndicesBackward(MaxPool1dWithIndicesBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + indices: desc.indices.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::MaxPool2d(desc) => ModuleOperationIr::MaxPool2d(MaxPool2dOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::MaxPool2dWithIndices(desc) => { + ModuleOperationIr::MaxPool2dWithIndices(MaxPool2dWithIndicesOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + out_indices: desc.out_indices.to_relative(converter), + }) + } + ModuleOperationIr::MaxPool2dWithIndicesBackward(desc) => { + ModuleOperationIr::MaxPool2dWithIndicesBackward(MaxPool2dWithIndicesBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + indices: desc.indices.to_relative(converter), + kernel_size: desc.kernel_size, + stride: desc.stride, + padding: desc.padding, + dilation: desc.dilation, + ceil_mode: desc.ceil_mode, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Interpolate(desc) => { + ModuleOperationIr::Interpolate(InterpolateOpIr { + x: desc.x.to_relative(converter), + output_size: desc.output_size, + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::InterpolateBackward(desc) => { + ModuleOperationIr::InterpolateBackward(InterpolateBackwardOpIr { + x: desc.x.to_relative(converter), + grad: desc.grad.to_relative(converter), + output_size: desc.output_size, + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::Rfft(desc) => ModuleOperationIr::Rfft(RfftOpIr { + signal: desc.signal.to_relative(converter), + dim: desc.dim, + n: desc.n, + out_re: desc.out_re.to_relative(converter), + out_im: desc.out_im.to_relative(converter), + }), + ModuleOperationIr::IRfft(desc) => ModuleOperationIr::IRfft(IRfftOpIr { + input_re: desc.input_re.to_relative(converter), + input_im: desc.input_im.to_relative(converter), + dim: desc.dim, + n: desc.n, + out_signal: desc.out_signal.to_relative(converter), + }), + ModuleOperationIr::Attention(desc) => ModuleOperationIr::Attention(AttentionOpIr { + query: desc.query.to_relative(converter), + key: desc.key.to_relative(converter), + value: desc.value.to_relative(converter), + mask: desc.mask.as_ref().map(|m| m.to_relative(converter)), + attn_bias: desc.attn_bias.as_ref().map(|ab| ab.to_relative(converter)), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::CtcLoss(desc) => ModuleOperationIr::CtcLoss(CtcLossOpIr { + log_probs: desc.log_probs.to_relative(converter), + targets: desc.targets.to_relative(converter), + input_lengths: desc.input_lengths.to_relative(converter), + target_lengths: desc.target_lengths.to_relative(converter), + blank: desc.blank, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::CtcLossBackward(desc) => { + ModuleOperationIr::CtcLossBackward(CtcLossBackwardOpIr { + log_probs: desc.log_probs.to_relative(converter), + targets: desc.targets.to_relative(converter), + input_lengths: desc.input_lengths.to_relative(converter), + target_lengths: desc.target_lengths.to_relative(converter), + grad_loss: desc.grad_loss.to_relative(converter), + blank: desc.blank, + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::LayerNorm(desc) => ModuleOperationIr::LayerNorm(LayerNormOpIr { + input: desc.input.to_relative(converter), + gamma: desc.gamma.to_relative(converter), + beta: desc.beta.as_ref().map(|t| t.to_relative(converter)), + epsilon: desc.epsilon, + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::Unfold4d(desc) => ModuleOperationIr::Unfold4d(Unfold4dOpIr { + x: desc.x.to_relative(converter), + kernel_size: desc.kernel_size, + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }), + ModuleOperationIr::ConvTranspose1dWeightBackward(desc) => { + ModuleOperationIr::ConvTranspose1dWeightBackward( + ConvTranspose1dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }, + ) + } + ModuleOperationIr::ConvTranspose1dBiasBackward(desc) => { + ModuleOperationIr::ConvTranspose1dBiasBackward(ConvTranspose1dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::ConvTranspose2dWeightBackward(desc) => { + ModuleOperationIr::ConvTranspose2dWeightBackward( + ConvTranspose2dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }, + ) + } + ModuleOperationIr::ConvTranspose2dBiasBackward(desc) => { + ModuleOperationIr::ConvTranspose2dBiasBackward(ConvTranspose2dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ModuleOperationIr::ConvTranspose3dWeightBackward(desc) => { + ModuleOperationIr::ConvTranspose3dWeightBackward( + ConvTranspose3dWeightBackwardOpIr { + x: desc.x.to_relative(converter), + weight: desc.weight.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }, + ) + } + ModuleOperationIr::ConvTranspose3dBiasBackward(desc) => { + ModuleOperationIr::ConvTranspose3dBiasBackward(ConvTranspose3dBiasBackwardOpIr { + x: desc.x.to_relative(converter), + bias: desc.bias.to_relative(converter), + output_grad: desc.output_grad.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + } + } +} + +impl RelativeOps for FloatOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + FloatOperationIr::Exp(desc) => FloatOperationIr::Exp(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Log(desc) => FloatOperationIr::Log(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Log1p(desc) => FloatOperationIr::Log1p(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Erf(desc) => FloatOperationIr::Erf(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Powf(desc) => FloatOperationIr::Powf(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Hypot(desc) => FloatOperationIr::Hypot(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::PowfScalar(desc) => FloatOperationIr::PowfScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Sqrt(desc) => FloatOperationIr::Sqrt(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Cos(desc) => FloatOperationIr::Cos(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Sin(desc) => FloatOperationIr::Sin(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Tanh(desc) => FloatOperationIr::Tanh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Tan(desc) => FloatOperationIr::Tan(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Cosh(desc) => FloatOperationIr::Cosh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Sinh(desc) => FloatOperationIr::Sinh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcCos(desc) => FloatOperationIr::ArcCos(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcCosh(desc) => FloatOperationIr::ArcCosh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcSin(desc) => FloatOperationIr::ArcSin(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcSinh(desc) => FloatOperationIr::ArcSinh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcTan(desc) => FloatOperationIr::ArcTan(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcTanh(desc) => FloatOperationIr::ArcTanh(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::ArcTan2(desc) => FloatOperationIr::ArcTan2(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::IntoInt(desc) => FloatOperationIr::IntoInt(CastOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Matmul(desc) => FloatOperationIr::Matmul(MatmulOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Cross(desc) => FloatOperationIr::Cross(CrossOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + dim: desc.dim, + }), + FloatOperationIr::Random(desc) => FloatOperationIr::Random(RandomOpIr { + out: desc.out.to_relative(converter), + distribution: desc.distribution, + }), + FloatOperationIr::Recip(desc) => FloatOperationIr::Recip(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Quantize(desc) => FloatOperationIr::Quantize(QuantizeOpIr { + tensor: desc.tensor.to_relative(converter), + qparams: QuantizationParametersIr { + scales: desc.qparams.scales.to_relative(converter), + }, + scheme: desc.scheme, + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Dequantize(desc) => FloatOperationIr::Dequantize(DequantizeOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Round(desc) => FloatOperationIr::Round(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Floor(desc) => FloatOperationIr::Floor(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Ceil(desc) => FloatOperationIr::Ceil(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::Trunc(desc) => FloatOperationIr::Trunc(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::IsNan(desc) => FloatOperationIr::IsNan(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::IsInf(desc) => FloatOperationIr::IsInf(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + FloatOperationIr::GridSample2d(desc) => { + FloatOperationIr::GridSample2d(GridSample2dOpIr { + tensor: desc.tensor.to_relative(converter), + grid: desc.grid.to_relative(converter), + options: desc.options.clone(), + out: desc.out.to_relative(converter), + }) + } + } + } +} + +impl RelativeOps for ActivationOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + ActivationOperationIr::Relu(desc) => ActivationOperationIr::Relu(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::ReluBackward(desc) => { + ActivationOperationIr::ReluBackward(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::LeakyRelu(desc) => { + ActivationOperationIr::LeakyRelu(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::PRelu(desc) => ActivationOperationIr::PRelu(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::Gelu(desc) => ActivationOperationIr::Gelu(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::GeluBackward(desc) => { + ActivationOperationIr::GeluBackward(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::Sigmoid(desc) => ActivationOperationIr::Sigmoid(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::SigmoidBackward(desc) => { + ActivationOperationIr::SigmoidBackward(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::HardSigmoid(desc) => { + ActivationOperationIr::HardSigmoid(HardSigmoidOpIr { + tensor: desc.tensor.to_relative(converter), + alpha: desc.alpha.to_relative(converter), + beta: desc.beta.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::LogSigmoid(desc) => { + ActivationOperationIr::LogSigmoid(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::LogSigmoidBackward(desc) => { + ActivationOperationIr::LogSigmoidBackward(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + ActivationOperationIr::Softmax(desc) => ActivationOperationIr::Softmax(DimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::LogSoftmax(desc) => ActivationOperationIr::LogSoftmax(DimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + out: desc.out.to_relative(converter), + }), + ActivationOperationIr::Softmin(desc) => ActivationOperationIr::Softmin(DimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + out: desc.out.to_relative(converter), + }), + } + } +} + +impl RelativeOps for BoolOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + BoolOperationIr::IntoFloat(desc) => BoolOperationIr::IntoFloat(CastOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BoolOperationIr::IntoInt(desc) => BoolOperationIr::IntoInt(CastOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BoolOperationIr::Not(desc) => BoolOperationIr::Not(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BoolOperationIr::And(desc) => BoolOperationIr::And(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BoolOperationIr::Or(desc) => BoolOperationIr::Or(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BoolOperationIr::Xor(desc) => BoolOperationIr::Xor(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + } + } +} + +impl RelativeOps for IntOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + IntOperationIr::IntoFloat(desc) => IntOperationIr::IntoFloat(CastOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::Matmul(desc) => IntOperationIr::Matmul(MatmulOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseAnd(desc) => IntOperationIr::BitwiseAnd(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseAndScalar(desc) => { + IntOperationIr::BitwiseAndScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + IntOperationIr::BitwiseOr(desc) => IntOperationIr::BitwiseOr(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseOrScalar(desc) => IntOperationIr::BitwiseOrScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseXor(desc) => IntOperationIr::BitwiseXor(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseXorScalar(desc) => { + IntOperationIr::BitwiseXorScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + IntOperationIr::BitwiseNot(desc) => IntOperationIr::BitwiseNot(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + IntOperationIr::BitwiseLeftShift(desc) => { + IntOperationIr::BitwiseLeftShift(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + IntOperationIr::BitwiseLeftShiftScalar(desc) => { + IntOperationIr::BitwiseLeftShiftScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + IntOperationIr::BitwiseRightShift(desc) => { + IntOperationIr::BitwiseRightShift(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + IntOperationIr::BitwiseRightShiftScalar(desc) => { + IntOperationIr::BitwiseRightShiftScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + } + } +} + +impl RelativeOps for CustomOpIr { + fn to_relative(&self, converter: &mut OperationConverter) -> CustomOpIr { + let id = self.id.clone(); + + CustomOpIr { + id, + inputs: self + .inputs + .iter() + .map(|x| x.to_relative(converter)) + .collect(), + outputs: self + .outputs + .iter() + .map(|x| x.to_relative(converter)) + .collect(), + scalars: self + .scalars + .iter() + .map(|x| x.to_relative(converter)) + .collect(), + } + } +} + +impl RelativeOps for NumericOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + NumericOperationIr::Add(desc) => NumericOperationIr::Add(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::AddScalar(desc) => NumericOperationIr::AddScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Sub(desc) => NumericOperationIr::Sub(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::SubScalar(desc) => NumericOperationIr::SubScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Div(desc) => NumericOperationIr::Div(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::DivScalar(desc) => NumericOperationIr::DivScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Rem(desc) => NumericOperationIr::Rem(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::RemScalar(desc) => NumericOperationIr::RemScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Mul(desc) => NumericOperationIr::Mul(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MulScalar(desc) => NumericOperationIr::MulScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Abs(desc) => NumericOperationIr::Abs(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Full(desc) => NumericOperationIr::Full(FullOpIr { + out: desc.out.to_relative(converter), + value: desc.value.to_relative(converter), + }), + NumericOperationIr::MeanDim(desc) => NumericOperationIr::MeanDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Mean(desc) => NumericOperationIr::Mean(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Sum(desc) => NumericOperationIr::Sum(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::SumDim(desc) => { + NumericOperationIr::SumDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, // Axis should stay the same. + accumulator_len: desc.accumulator_len, + }) + } + NumericOperationIr::Prod(desc) => NumericOperationIr::Prod(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::ProdDim(desc) => NumericOperationIr::ProdDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Greater(desc) => NumericOperationIr::Greater(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::GreaterElem(desc) => NumericOperationIr::GreaterElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::GreaterEqual(desc) => { + NumericOperationIr::GreaterEqual(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + NumericOperationIr::GreaterEqualElem(desc) => { + NumericOperationIr::GreaterEqualElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + NumericOperationIr::Lower(desc) => NumericOperationIr::Lower(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::LowerElem(desc) => NumericOperationIr::LowerElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::LowerEqual(desc) => NumericOperationIr::LowerEqual(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::LowerEqualElem(desc) => { + NumericOperationIr::LowerEqualElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }) + } + NumericOperationIr::ArgMax(desc) => NumericOperationIr::ArgMax(ReduceDimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, // Axis should stay the same. + accumulator_len: desc.accumulator_len, + }), + NumericOperationIr::ArgTopK(desc) => NumericOperationIr::ArgTopK(ReduceDimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, // Axis should stay the same. + accumulator_len: desc.accumulator_len, + }), + NumericOperationIr::TopK(desc) => NumericOperationIr::TopK(ReduceDimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, // Axis should stay the same. + accumulator_len: desc.accumulator_len, + }), + NumericOperationIr::ArgMin(desc) => NumericOperationIr::ArgMin(ReduceDimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, // Axis should stay the same. + accumulator_len: desc.accumulator_len, + }), + NumericOperationIr::Max(desc) => NumericOperationIr::Max(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MaxDimWithIndices(desc) => { + NumericOperationIr::MaxDimWithIndices(ReduceDimWithIndicesOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + out: desc.out.to_relative(converter), + out_indices: desc.out_indices.to_relative(converter), + }) + } + NumericOperationIr::MinDimWithIndices(desc) => { + NumericOperationIr::MinDimWithIndices(ReduceDimWithIndicesOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + out: desc.out.to_relative(converter), + out_indices: desc.out_indices.to_relative(converter), + }) + } + NumericOperationIr::Min(desc) => NumericOperationIr::Min(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MaxDim(desc) => NumericOperationIr::MaxDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MinDim(desc) => NumericOperationIr::MinDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MaxAbs(desc) => NumericOperationIr::MaxAbs(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::MaxAbsDim(desc) => NumericOperationIr::MaxAbsDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Clamp(desc) => NumericOperationIr::Clamp(ClampOpIr { + tensor: desc.tensor.to_relative(converter), + min: desc.min.to_relative(converter), + max: desc.max.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::IntRandom(desc) => NumericOperationIr::IntRandom(RandomOpIr { + out: desc.out.to_relative(converter), + distribution: desc.distribution, + }), + NumericOperationIr::Powi(desc) => NumericOperationIr::Powi(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::PowiScalar(desc) => NumericOperationIr::PowiScalar(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::CumSum(desc) => NumericOperationIr::CumSum(DimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, + }), + NumericOperationIr::CumProd(desc) => NumericOperationIr::CumProd(DimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, + }), + NumericOperationIr::CumMin(desc) => NumericOperationIr::CumMin(DimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, + }), + NumericOperationIr::CumMax(desc) => NumericOperationIr::CumMax(DimOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axis: desc.axis, + }), + NumericOperationIr::Neg(desc) => NumericOperationIr::Neg(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Sign(desc) => NumericOperationIr::Sign(UnaryOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::ClampMin(desc) => NumericOperationIr::ClampMin(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::ClampMax(desc) => NumericOperationIr::ClampMax(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + NumericOperationIr::Sort(desc) => NumericOperationIr::Sort(SortOpIr { + input: desc.input.to_relative(converter), + dim: desc.dim, + descending: desc.descending, + out: desc.out.to_relative(converter), + }), + NumericOperationIr::SortWithIndices(desc) => { + NumericOperationIr::SortWithIndices(SortWithIndicesOpIr { + input: desc.input.to_relative(converter), + dim: desc.dim, + descending: desc.descending, + out: desc.out.to_relative(converter), + out_indices: desc.out_indices.to_relative(converter), + }) + } + NumericOperationIr::ArgSort(desc) => NumericOperationIr::ArgSort(SortOpIr { + input: desc.input.to_relative(converter), + dim: desc.dim, + descending: desc.descending, + out: desc.out.to_relative(converter), + }), + } + } +} + +impl RelativeOps for BaseOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + BaseOperationIr::Reshape(desc) => BaseOperationIr::Reshape(ShapeOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::SwapDims(desc) => BaseOperationIr::SwapDims(SwapDimsOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + dim1: desc.dim1, + dim2: desc.dim2, + }), + BaseOperationIr::Permute(desc) => BaseOperationIr::Permute(PermuteOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axes: desc.axes.clone(), + }), + BaseOperationIr::Expand(desc) => BaseOperationIr::Expand(ShapeOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Unfold(desc) => BaseOperationIr::Unfold(UnfoldOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + dim: desc.dim, + size: desc.size, + step: desc.step, + }), + BaseOperationIr::Flip(desc) => BaseOperationIr::Flip(FlipOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + axes: desc.axes.clone(), + }), + BaseOperationIr::Slice(desc) => BaseOperationIr::Slice(SliceOpIr { + tensor: desc.tensor.to_relative(converter), + ranges: desc + .ranges + .iter() + .map(|r| converter.relative_range(r)) + .collect(), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::SliceAssign(desc) => BaseOperationIr::SliceAssign(SliceAssignOpIr { + tensor: desc.tensor.to_relative(converter), + ranges: desc + .ranges + .iter() + .map(|r| converter.relative_range(r)) + .collect(), + value: desc.value.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Gather(desc) => BaseOperationIr::Gather(GatherOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + indices: desc.indices.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Scatter(desc) => BaseOperationIr::Scatter(ScatterOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + indices: desc.indices.to_relative(converter), + value: desc.value.to_relative(converter), + update: desc.update, + out: desc.out.to_relative(converter), + }), + BaseOperationIr::ScatterNd(desc) => BaseOperationIr::ScatterNd(ScatterNdOpIr { + data: desc.data.to_relative(converter), + indices: desc.indices.to_relative(converter), + values: desc.values.to_relative(converter), + reduction: desc.reduction, + out: desc.out.to_relative(converter), + }), + BaseOperationIr::GatherNd(desc) => BaseOperationIr::GatherNd(GatherNdOpIr { + data: desc.data.to_relative(converter), + indices: desc.indices.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Select(desc) => BaseOperationIr::Select(SelectOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + indices: desc.indices.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::SelectAssign(desc) => { + BaseOperationIr::SelectAssign(SelectAssignOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + indices: desc.indices.to_relative(converter), + value: desc.value.to_relative(converter), + update: desc.update, + out: desc.out.to_relative(converter), + }) + } + BaseOperationIr::MaskWhere(desc) => BaseOperationIr::MaskWhere(MaskWhereOpIr { + tensor: desc.tensor.to_relative(converter), + mask: desc.mask.to_relative(converter), + value: desc.value.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::MaskFill(desc) => BaseOperationIr::MaskFill(MaskFillOpIr { + tensor: desc.tensor.to_relative(converter), + mask: desc.mask.to_relative(converter), + value: desc.value.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Equal(desc) => BaseOperationIr::Equal(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::EqualElem(desc) => BaseOperationIr::EqualElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::RepeatDim(desc) => BaseOperationIr::RepeatDim(RepeatDimOpIr { + tensor: desc.tensor.to_relative(converter), + dim: desc.dim, + times: desc.times, + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Cat(desc) => BaseOperationIr::Cat(CatOpIr { + tensors: desc + .tensors + .iter() + .map(|tensor| tensor.to_relative(converter)) + .collect(), + dim: desc.dim, + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Cast(desc) => BaseOperationIr::Cast(CastOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Empty(desc) => BaseOperationIr::Empty(desc.to_relative(converter)), + BaseOperationIr::Ones(desc) => BaseOperationIr::Ones(desc.to_relative(converter)), + BaseOperationIr::Zeros(desc) => BaseOperationIr::Zeros(desc.to_relative(converter)), + BaseOperationIr::NotEqual(desc) => BaseOperationIr::NotEqual(BinaryOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::NotEqualElem(desc) => BaseOperationIr::NotEqualElem(ScalarOpIr { + lhs: desc.lhs.to_relative(converter), + rhs: desc.rhs.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::All(desc) => BaseOperationIr::All(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::Any(desc) => BaseOperationIr::Any(ReduceOpIr { + input: desc.input.to_relative(converter), + out: desc.out.to_relative(converter), + }), + BaseOperationIr::AllDim(desc) => BaseOperationIr::AllDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + BaseOperationIr::AnyDim(desc) => BaseOperationIr::AnyDim(ReduceDimOpIr { + input: desc.input.to_relative(converter), + axis: desc.axis, + accumulator_len: desc.accumulator_len, + out: desc.out.to_relative(converter), + }), + } + } +} + +impl RelativeOps for InitOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + Self { + out: self.out.to_relative(converter), + } + } +} + +impl RelativeOps for CreationOpIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + Self { + out: self.out.to_relative(converter), + } + } +} + +impl RelativeOps for TensorIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + let relative_id = self.id.to_relative(converter); + + // We can create relative shapes by mapping each shape found to an ID, which is a `usize`. + let mut relative_shape = Vec::with_capacity(self.shape.rank()); + for dim in self.shape.iter() { + if let Some(dim_id) = converter.shapes_global2relative.get(dim) { + // We already saw that dim value before, so we retrieve its ID. + relative_shape.push(*dim_id); + } else { + // We never saw this dim value before, therefore we create a new ID. + let dim_id = converter.shapes_global2relative.len(); + relative_shape.push(dim_id); + + converter.shapes_global2relative.insert(*dim, dim_id); + converter.shapes_relative2global.insert(dim_id, *dim); + } + } + + // We create the relative tensor. + let relative_tensor = TensorIr { + id: relative_id, + shape: Shape::from(relative_shape), + status: self.status, + dtype: self.dtype, + }; + + // We update both mappings. + converter + .tensors_relative2global + .insert(relative_id, self.clone()); + converter + .tensors_global2relative + .insert(self.id, relative_tensor.clone()); + + relative_tensor + } +} + +impl RelativeOps for DistributedOperationIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + match self { + DistributedOperationIr::AllReduce(desc) => { + DistributedOperationIr::AllReduce(AllReduceOpIr { + tensor: desc.tensor.to_relative(converter), + out: desc.out.to_relative(converter), + op: desc.op, + device_ids: desc.device_ids.clone(), + }) + } + DistributedOperationIr::SyncCollective => DistributedOperationIr::SyncCollective, + } + } +} + +impl RelativeOps for TensorId { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + if let Some(value) = converter.tensors_global2relative.get(self) { + // If we already have the same tensor registered, we have to update its value, but not + // its id. + value.id + } else { + // We create a new relative id since we never seen this tensor in the graph before. + TensorId::new(converter.tensors_relative2global.len() as u64) + } + } +} + +impl RelativeOps for ScalarIr { + fn to_relative(&self, converter: &mut OperationConverter) -> Self { + // Every scalar variant (including `Bool`) is stashed verbatim and replaced by a positional + // `UInt` placeholder; replay rebinds it by index from `GraphBindings::scalars`, so the + // concrete type is irrelevant to relativization. + let id = ScalarId { + value: converter.scalars.len() as u64, + }; + + converter.scalars.insert(id, *self); + ScalarIr::UInt(id.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::DType; + use burn_ir::{TensorId, TensorIr, TensorStatus}; + + /// Helper to build a minimal [`Context`] with string handles for testing fork behavior. + fn make_test_context() -> Context { + let mut ctx = Context { + tensors: HashMap::new(), + handles: HandleContainer::new(), + scalars: HashMap::new(), + shapes_relative2global: HashMap::new(), + ranges: Vec::new(), + }; + + let id_input = TensorId::new(1); + ctx.tensors.insert( + id_input, + TensorIr { + id: id_input, + shape: Shape::new([4, 4]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + ); + ctx.handles + .register_handle(id_input, "input_handle".to_string()); + + ctx + } + + #[test] + fn context_fork_output_handles_are_isolated() { + // Output handles registered in a forked context are NOT visible in the original — + // forks are independent sandboxes. `burn-cubecl-fusion`'s `TuneInput` layer is + // responsible for merging new handles back into the real context when appropriate. + let original = make_test_context(); + let output_id = TensorId::new(100); + + { + let mut fork = original.fork(); + fork.handles + .register_handle(output_id, "output_handle".to_string()); + + // The fork has the output handle. + assert!(fork.handles.get_handle_ref(&output_id).is_some()); + } + + // But the original does NOT — isolation is the point of `fork()`. + assert!(original.handles.get_handle_ref(&output_id).is_none()); + } + + #[test] + fn context_fork_preserves_input_handles() { + let original = make_test_context(); + let input_id = TensorId::new(1); + + let fork = original.fork(); + + // Fork should have a copy of the input handle. + assert_eq!( + fork.handles.get_handle_ref(&input_id), + Some(&"input_handle".to_string()) + ); + // Original is unchanged. + assert_eq!( + original.handles.get_handle_ref(&input_id), + Some(&"input_handle".to_string()) + ); + } + + #[test] + fn context_double_fork_fully_isolated() { + // Forking a fork creates a second level of isolation — a mutation in `fork2` is + // invisible to both `fork1` and the original. + let original = make_test_context(); + + let fork1 = original.fork(); + let mut fork2 = fork1.fork(); + + let deep_output_id = TensorId::new(200); + fork2 + .handles + .register_handle(deep_output_id, "deep_output".to_string()); + + // Neither the first fork nor the original see the deeply-nested output. + assert!(fork1.handles.get_handle_ref(&deep_output_id).is_none()); + assert!(original.handles.get_handle_ref(&deep_output_id).is_none()); + } +} + +#[cfg(test)] +mod tests_ir { + use super::*; + use burn_backend::DType; + use burn_ir::{TensorId, TensorIr, TensorStatus}; + + #[test] + fn tensor_description_to_relative() { + let tensor1 = TensorIr { + id: TensorId::new(500), + shape: Shape::new([512, 32, 2048]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }; + let tensor2 = TensorIr { + id: TensorId::new(501), + shape: Shape::new([512, 128, 2048]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }; + let mut converter = OperationConverter::default(); + let tensor1_local = tensor1.to_relative(&mut converter); + let tensor2_local = tensor2.to_relative(&mut converter); + + assert_eq!( + tensor1_local, + TensorIr { + id: TensorId::new(0), + shape: Shape::new([1, 2, 3]), + status: TensorStatus::ReadOnly, + dtype: DType::F32 + } + ); + assert_eq!( + tensor2_local, + TensorIr { + id: TensorId::new(1), + shape: Shape::new([1, 4, 3]), + status: TensorStatus::ReadOnly, + dtype: DType::F32 + } + ); + } + + #[test] + fn scalar_ir_to_relative() { + let scalar1 = ScalarIr::Float(1.0); + let scalar2 = ScalarIr::UInt(1); + let mut converter = OperationConverter::default(); + let scalar1_local = scalar1.to_relative(&mut converter); + let scalar2_local = scalar2.to_relative(&mut converter); + + assert_eq!(scalar1_local, ScalarIr::UInt(0)); + assert_eq!(scalar2_local, ScalarIr::UInt(1)); + } + + #[test] + fn scalar_ir_bool_to_relative() { + // A `Bool` scalar used to `todo!()`-panic here, which crashed any custom op carrying a bool + // argument the moment it was fused into a cached graph. It must relativize like every other + // scalar: replaced by a positional `UInt` placeholder, with the concrete value stashed so + // replay can rebind it from `GraphBindings::scalars`. + let mut converter = OperationConverter::default(); + let relative = ScalarIr::Bool(true).to_relative(&mut converter); + + assert_eq!(relative, ScalarIr::UInt(0)); + assert_eq!( + converter.scalars.get(&ScalarId { value: 0 }), + Some(&ScalarIr::Bool(true)) + ); + } +} diff --git a/crates/burn-fusion/src/stream/execution/base.rs b/crates/burn-fusion/src/stream/execution/base.rs new file mode 100644 index 0000000..d76231e --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/base.rs @@ -0,0 +1,16 @@ +use burn_ir::HandleContainer; + +use crate::FusionRuntime; + +/// The mode in which the execution is done. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ExecutionMode { + Lazy, + Sync, +} + +/// General trait to abstract how a single operation is executed. +pub trait Operation: Send + Sync + core::fmt::Debug { + /// Execute the operation. + fn execute(&self, handles: &mut HandleContainer); +} diff --git a/crates/burn-fusion/src/stream/execution/explorer.rs b/crates/burn-fusion/src/stream/execution/explorer.rs new file mode 100644 index 0000000..1ffe32f --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/explorer.rs @@ -0,0 +1,163 @@ +use std::sync::Arc; + +use burn_ir::OperationIr; +use burn_std::config::{config, fusion::FusionLogLevel, log_fusion}; + +use super::{ExecutionMode, op_kind}; +use crate::{ + NumOperations, OperationFuser, + search::{BlockOptimization, StreamOptimizer}, + stream::store::ExecutionStrategy, +}; + +/// Explore and create new optimization. +pub struct Explorer { + optimizer: StreamOptimizer, + num_deferred: usize, + num_explored: usize, + is_still_optimizing: bool, + /// Number of optimizations actually built so far (one per cache miss that ran the optimizer). + num_explorations: usize, + /// Stop exploring once `num_explorations` reaches this; see [`BeamSearchConfig::max_explorations`]. + max_explorations: Option, +} + +/// The result of an exploration done by the [explorer](Explorer). +pub enum ExplorationAction { + /// Found a new optimization. + Completed(BlockOptimization), + /// We should continue exploring before arriving at a conclusion. + Continue, + /// Exploration is disabled (the cap was reached): execute the segment unfused, without caching. + Unfused(BlockOptimization), +} + +impl Explorer { + /// Create a new explorer. + pub(crate) fn new(optimizations: Vec>>) -> Self { + Self { + optimizer: StreamOptimizer::new(optimizations), + num_deferred: 0, + num_explored: 0, + is_still_optimizing: true, + num_explorations: 0, + max_explorations: config().fusion().beam_search.max_explorations, + } + } + + /// Indicate that a new operation is added. + pub(crate) fn on_new_operation(&mut self) { + self.num_deferred += 1; + } + + /// If the explorer is up to date. + pub(crate) fn is_up_to_date(&self) -> bool { + self.num_deferred == 0 + } + + /// Explore the provided operations. + pub(crate) fn explore( + &mut self, + operations: &[OperationIr], + mode: ExecutionMode, + ) -> ExplorationAction { + let total_ops = operations.len(); + let deferred = self.num_deferred; + let mode_dbg = match mode { + ExecutionMode::Lazy => "lazy", + ExecutionMode::Sync => "sync", + }; + + log_fusion(FusionLogLevel::Full, move || { + format!("[explorer] explore ({mode_dbg}): {deferred} deferred of {total_ops} queued") + }); + + // Exploration cap reached: skip the optimizer (both the incremental block-register in + // `update` and the search in `optimize`) and run the segment unfused. We mark the deferred + // ops as consumed so the processor sees the explorer as up-to-date and stops looping. + if let Some(max) = self.max_explorations + && self.num_explorations >= max + { + self.num_deferred = 0; + self.is_still_optimizing = false; + let ordering: Vec = (0..operations.len()).collect(); + let strategy = ExecutionStrategy::Operations { + ordering: Arc::new(ordering.clone()), + }; + return ExplorationAction::Unfused(BlockOptimization::new(strategy, ordering)); + } + + self.update(operations); + + // Can only continue exploration when not sync. + if let ExecutionMode::Lazy = mode + && self.is_still_optimizing + { + return ExplorationAction::Continue; + } + + let mut optimization = self.optimizer.optimize(operations); + self.num_explorations += 1; + + // At a sync the segment is final: fold the drained tail the search left for + // "the next round" into the plan as un-fused operations, so the cached plan + // covers the whole segment and can be matched by the policy on the next + // identical sync instead of re-exploring the same graph every time. + if let ExecutionMode::Sync = mode { + optimization.include_trailing(operations.len()); + } + + ExplorationAction::Completed(optimization) + } + + /// Number of optimizations built so far; a cached plan that is reused does not + /// count. Used by tests to assert that recurring graphs stop exploring. + #[cfg(test)] + pub(crate) fn num_explorations(&self) -> usize { + self.num_explorations + } + + /// Reset the state of the explorer to the provided list of operations. + pub(crate) fn reset(&mut self, operations: &[OperationIr]) { + self.optimizer.reset(); + self.num_explored = 0; + self.num_deferred = operations.len(); + self.is_still_optimizing = true; + } + + /// Register any operations that we had deferred + fn update(&mut self, operations: &[OperationIr]) { + let start_explored = self.num_explored; + for i in (0..self.num_deferred).rev() { + if !self.is_still_optimizing { + let remaining = i + 1; + let seen = self.num_explored - start_explored; + log_fusion(FusionLogLevel::Full, move || { + format!( + "[explorer] stopped optimizing after {seen} new op(s); {remaining} deferred op(s) left unprocessed" + ) + }); + break; + } + let index = operations.len() - 1 - i; + let relative = &operations[index]; + + self.optimizer.register(relative); + self.num_explored += 1; + + let before = self.is_still_optimizing; + self.is_still_optimizing = self.optimizer.still_optimizing(); + if before && !self.is_still_optimizing { + let explored = self.num_explored; + log_fusion(FusionLogLevel::Full, || { + format!( + "[explorer] still_optimizing → false after op {} (explored {explored} ops)", + op_kind(relative) + ) + }); + } + } + + self.num_deferred = 0; + } +} diff --git a/crates/burn-fusion/src/stream/execution/mod.rs b/crates/burn-fusion/src/stream/execution/mod.rs new file mode 100644 index 0000000..fbe22de --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/mod.rs @@ -0,0 +1,20 @@ +pub(crate) mod validator; + +mod base; +mod explorer; +mod ordering; +mod policy; +mod processor; +pub(crate) mod trace; + +pub(crate) use trace::{log_execution_table, op_kind}; + +pub use base::*; +pub use ordering::*; + +pub(crate) use explorer::*; +pub(crate) use policy::*; +pub(crate) use processor::*; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/burn-fusion/src/stream/execution/ordering.rs b/crates/burn-fusion/src/stream/execution/ordering.rs new file mode 100644 index 0000000..9ce8538 --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/ordering.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use burn_ir::HandleContainer; + +use crate::{FusionRuntime, NumOperations, Optimization, UnfusedOp, stream::Context}; + +/// Manage the execution of potentially multiple optimizations and operations out of order. +pub struct OrderedExecution { + operations: Vec>, + num_executed: usize, + ordering: Option>>, +} + +impl OrderedExecution { + /// Returns the operation that can be executed without impacting the state of the execution. + /// + /// This is useful to implement fallback for optimizations. + #[allow(clippy::borrowed_box)] + pub fn operation_within_optimization(&self, index: usize) -> UnfusedOp { + match &self.ordering { + Some(val) => { + let index = val[index]; + self.operations[index].clone() + } + None => panic!("No ordering provided"), + } + } + + pub(crate) fn new(operations: Vec>) -> Self { + Self { + operations, + num_executed: 0, + ordering: None, + } + } + + pub(crate) fn finish(mut self) -> (Vec>, usize) { + self.operations.drain(0..self.num_executed); + (self.operations, self.num_executed) + } + + pub(crate) fn execute_optimization( + &mut self, + optimization: &mut R::Optimization, + context: &mut Context, + ordering: Arc>, + ) { + if ordering.len() > self.operations.len() { + panic!("Ordering is bigger than operations"); + } + self.ordering = Some(ordering); + let num_drained = optimization.len(); + optimization.execute(context, self); + self.num_executed += num_drained; + } + + pub(crate) fn execute_operations( + &mut self, + handles: &mut HandleContainer, + ordering: &[usize], + ) { + self.num_executed += ordering.len(); + + for id in ordering { + let op = &self.operations[*id]; + op.execute(handles); + } + } +} diff --git a/crates/burn-fusion/src/stream/execution/policy.rs b/crates/burn-fusion/src/stream/execution/policy.rs new file mode 100644 index 0000000..14c21b8 --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/policy.rs @@ -0,0 +1,572 @@ +use burn_ir::OperationIr; + +use super::ExecutionMode; +use super::validator::{ + ExecutionPlanOperationsStore, TriggerOperationsStore, TriggerProgress, TriggerValidator, + ValidatorState, +}; +use crate::stream::execution::validator::OperationsValidator; +use crate::stream::store::{ExecutionPlanId, ExecutionPlanStore, ExecutionTrigger, SearchQuery}; +use std::marker::PhantomData; + +/// The policy keeps track of all possible execution plans for the current operations. +/// +/// # Details +/// +/// We keep track of each new operation added and invalidate potential execution plans +/// when we see a different operation is added. +/// +/// Therefore, the overhead is very minimal, since the time-complexity of checking for existing +/// execution plans scales with the number of concurrent potential plans for the current operations, +/// which isn't supposed to be big at any time. +pub(crate) struct Policy { + /// List of potential execution plans that are compatible with current stream segment + candidates: Vec>, + /// List of candidate execution plans that have been found; we can still keep searching + /// to potentially find a better one. + availables: Vec, + /// The found execution plan that should be executed, along with the number of operations + /// in the plan. + found: Option<(ExecutionPlanId, usize)>, + /// The number of operations that have been analyzed + num_operations: usize, + _item_type: PhantomData, +} + +#[derive(new)] +struct AvailableItem { + id: ExecutionPlanId, + size: usize, + triggers: Vec, +} + +/// Action to be made depending on the stream. +#[derive(PartialEq, Eq, Debug)] +pub enum Action { + /// Continue exploring using the [builder](crate::OptimizationBuilder). + Explore, + /// The current policy indicates that an exploration may be possible in the future, so the + /// best action is to defer any execution. + /// + /// Sometimes, it can be a false positive and a new exploration should be built from scratch. + /// Therefore it's important to keep the previous operations to rebuild the state if it + /// happens. + Defer, + /// An exploration has been found, and the best action is to execute it! + Execute(ExecutionPlanId), +} + +impl Policy { + /// Create a new policy. + pub(crate) fn new() -> Self { + Self { + candidates: Vec::new(), + availables: Vec::new(), + found: None, + num_operations: 0, + _item_type: PhantomData, + } + } + + /// Returns the [action](Action) that should be taken given the state of the policy. + pub fn action( + &self, + store: &ExecutionPlanStore, + operations: &[OperationIr], + mode: ExecutionMode, + ) -> Action { + if self.num_operations < operations.len() { + panic!( + "Internal Error: Can't retrieve the policy action on a list of operations bigger than what is analyzed." + ); + } + + if let Some((id, _length)) = self.found { + return Action::Execute(id); + } + + match mode { + ExecutionMode::Lazy => self.action_lazy(operations), + ExecutionMode::Sync => self.action_sync(operations, store), + } + } + + /// Update the policy state. + pub fn update(&mut self, store: &ExecutionPlanStore, operation: &OperationIr) { + // reset the candidates to contain all execution plans starting with the operation. + if self.num_operations == 0 { + self.candidates = store + .find(SearchQuery::PlansStartingWith(operation)) + .into_iter() + .map(OperationsValidator::new) + .collect(); + } + + self.update_candidates(store, operation); + self.check_candidates(store); + + self.update_availables(store, operation); + self.check_availables(); + self.num_operations += 1; + } + + // Reset the state of the policy. + pub fn reset(&mut self) { + self.candidates.clear(); + self.availables.clear(); + + self.num_operations = 0; + self.found = None; + } + + /// Check which candidates can be removed, and which one can go from + /// 'candidate' to 'available' + fn check_candidates(&mut self, store: &ExecutionPlanStore) { + let mut candidates_to_remove = Vec::new(); + + for candidate in self.candidates.iter() { + match candidate.state { + ValidatorState::Found { size } => { + let item = store.get_unchecked(candidate.id); + let mut triggers = Vec::with_capacity(item.triggers.len()); + + for (index, trigger) in item.triggers.iter().enumerate() { + triggers.push(match trigger { + ExecutionTrigger::OnOperations(_) => TriggerValidator::OnOperations { + matching: OperationsValidator::new(index), + progress: TriggerProgress::NotInit, + }, + ExecutionTrigger::OnSync => TriggerValidator::OnSync, + ExecutionTrigger::Always => TriggerValidator::Always, + }); + } + + self.availables + .push(AvailableItem::new(candidate.id, size, triggers)); + candidates_to_remove.push(candidate.id); + } + ValidatorState::Invalidated => { + candidates_to_remove.push(candidate.id); + } + ValidatorState::Validating => {} + }; + } + + let mut updated_candidates = Vec::new(); + core::mem::swap(&mut updated_candidates, &mut self.candidates); + + self.candidates = updated_candidates + .into_iter() + .filter(|candidate| !candidates_to_remove.iter().any(|id| id == &candidate.id)) + .collect(); + } + + fn check_availables(&mut self) { + for available in self.availables.iter() { + for trigger in available.triggers.iter() { + match trigger { + TriggerValidator::OnOperations { + matching, + progress: _, + } => { + if let ValidatorState::Found { + size: _size_of_trigger, + } = matching.state + { + self.found = Some((available.id, available.size)); + return; + } + } + TriggerValidator::Always => { + self.found = Some((available.id, available.size)); + return; + } + TriggerValidator::OnSync => { + // Does nothing during an update. + } + } + } + } + } + + fn update_candidates(&mut self, store: &ExecutionPlanStore, operation: &OperationIr) { + let main_store = ExecutionPlanOperationsStore::new(store); + + self.candidates + .iter_mut() + .for_each(|candidate| candidate.update(operation, self.num_operations, &main_store)); + } + + fn update_availables(&mut self, store: &ExecutionPlanStore, operation: &OperationIr) { + self.availables.iter_mut().for_each(|available| { + let store_trigger = TriggerOperationsStore::new(available.id, store); + + available.triggers.iter_mut().for_each(|trigger| { + if let TriggerValidator::OnOperations { matching, progress } = trigger { + match progress { + TriggerProgress::NotInit => { + *progress = TriggerProgress::NumChecked(0); + } + TriggerProgress::NumChecked(num_check) => { + matching.update(operation, *num_check, &store_trigger); + *num_check += 1; + } + } + } + }); + }); + } + + fn action_lazy(&self, operations: &[OperationIr]) -> Action { + if !self.candidates.is_empty() { + return Action::Defer; + } + + for available in self.availables.iter() { + if available.size == operations.len() { + return Action::Defer; + } + + for trigger in available.triggers.iter() { + if let TriggerValidator::OnOperations { + matching, + progress: _, + } = trigger + && let ValidatorState::Validating = matching.state + { + return Action::Defer; + } + } + } + + Action::Explore + } + + fn action_sync(&self, operations: &[OperationIr], store: &ExecutionPlanStore) -> Action { + for available in self.availables.iter() { + if available.size == operations.len() { + return Action::Execute(available.id); + } + } + + for candidate in self.candidates.iter() { + let item = store.get_unchecked(candidate.id); + + if item.operations.len() == operations.len() { + return Action::Execute(candidate.id); + } + } + + Action::Explore + } +} + +#[cfg(test)] +mod tests { + use burn_backend::{DType, Shape}; + use burn_ir::{FloatOperationIr, TensorId, TensorIr, TensorStatus, UnaryOpIr}; + + use super::*; + use crate::{ + search::BlockOptimization, + stream::store::{ExecutionPlan, ExecutionStrategy, ExecutionTrigger}, + }; + use std::ops::Range; + + #[test] + fn given_no_optimization_should_explore() { + let store = ExecutionPlanStore::default(); + let mut policy = Policy::new(); + let stream = TestStream::new(3); + + stream.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(0..3), + Action::Explore, + ); + } + + #[test] + fn given_existing_optimizations_when_sync_should_execute_one_when_available() { + let mut store = ExecutionPlanStore::default(); + let mut policy = Policy::new(); + let stream = TestStream::new(3); + + let id_1 = store.add(ExecutionPlan { + operations: stream.operations[0..2].to_vec(), + triggers: Vec::new(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(2), Vec::new()), + }); + let _id_2 = store.add(ExecutionPlan { + operations: stream.operations[0..3].to_vec(), + triggers: Vec::new(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(3), Vec::new()), + }); + + stream.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(0..2), + Action::Defer, + ); + + let action = policy.action(&store, &stream.operations[0..2], ExecutionMode::Sync); + assert_eq!(action, Action::Execute(id_1)); + } + + #[test] + fn given_existing_plan_when_found_trigger_should_execute_plan() { + let mut store = ExecutionPlanStore::default(); + let mut policy = Policy::new(); + + let stream = TestStream::new(3); + let id = store.add(ExecutionPlan { + operations: stream.operations[0..2].to_vec(), + triggers: stream.operations[2..3] + .iter() + .map(|desc| ExecutionTrigger::OnOperations(vec![desc.clone()])) + .collect(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(2), Vec::new()), + }); + + stream.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(0..2), + Action::Defer, + ); + stream.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(2..3), + Action::Execute(id), + ); + } + + #[test] + fn should_support_multiple_triggers() { + let mut store = ExecutionPlanStore::default(); + let mut policy_1 = Policy::new(); + let mut policy_2 = Policy::new(); + + let mut stream_1 = TestStream::new(2); + let mut stream_2 = TestStream::new(2); + + // Create different end operation for each stream. + let trigger_id_1 = 5; + let trigger_id_2 = 6; + stream_1.new_ops(trigger_id_1); + stream_2.new_ops(trigger_id_2); + + let id = store.add(ExecutionPlan { + operations: stream_1.operations[0..2].to_vec(), + triggers: vec![ + ExecutionTrigger::OnOperations(vec![stream_1.operations[2].clone()]), + ExecutionTrigger::OnOperations(vec![stream_2.operations[2].clone()]), + ], + optimization: BlockOptimization::new(ExecutionStrategy::operations(2), Vec::new()), + }); + + stream_1.assert_updates( + &store, + &mut policy_1, + AssertUpdatesOptions::OperationsIndex(0..2), + Action::Defer, + ); + stream_2.assert_updates( + &store, + &mut policy_2, + AssertUpdatesOptions::OperationsIndex(0..2), + Action::Defer, + ); + + stream_1.assert_updates( + &store, + &mut policy_1, + AssertUpdatesOptions::OperationsIndex(2..3), // First trigger. + Action::Execute(id), + ); + stream_2.assert_updates( + &store, + &mut policy_2, + AssertUpdatesOptions::OperationsIndex(2..3), // Second trigger. + Action::Execute(id), + ); + } + + #[test] + fn should_select_right_optimization() { + let mut store = ExecutionPlanStore::default(); + let mut policy_1 = Policy::new(); + let mut policy_2 = Policy::new(); + + let mut stream_1 = TestStream::new(2); + let mut stream_2 = TestStream::new(2); + + // Create different streams after op 2. + stream_1.new_ops(4); + stream_1.new_ops(5); + + stream_2.new_ops(5); + stream_2.new_ops(6); + + let optimization_stream_1 = store.add(ExecutionPlan { + operations: stream_1.operations[0..3].to_vec(), + triggers: stream_1.operations[3..4] + .iter() + .map(|desc| ExecutionTrigger::OnOperations(vec![desc.clone()])) + .collect(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(3), Vec::new()), + }); + let optimization_stream_2 = store.add(ExecutionPlan { + operations: stream_2.operations[0..3].to_vec(), + triggers: stream_2.operations[3..4] + .iter() + .map(|desc| ExecutionTrigger::OnOperations(vec![desc.clone()])) + .collect(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(3), Vec::new()), + }); + assert_ne!(optimization_stream_1, optimization_stream_2); + + stream_1.assert_updates( + &store, + &mut policy_1, + AssertUpdatesOptions::OperationsIndex(0..3), + Action::Defer, + ); + stream_2.assert_updates( + &store, + &mut policy_2, + AssertUpdatesOptions::OperationsIndex(0..3), + Action::Defer, + ); + + stream_1.assert_updates( + &store, + &mut policy_1, + AssertUpdatesOptions::OperationsIndex(3..4), + Action::Execute(optimization_stream_1), + ); + stream_2.assert_updates( + &store, + &mut policy_2, + AssertUpdatesOptions::OperationsIndex(3..4), + Action::Execute(optimization_stream_2), + ); + } + + #[test] + fn should_invalidate_wrong_optimizations() { + let mut store = ExecutionPlanStore::default(); + let stream_1 = TestStream::new(4); + let mut stream_2 = TestStream::new(2); + stream_2.new_ops(6); + stream_2.new_ops(7); + + store.add(ExecutionPlan { + operations: stream_1.operations[0..3].to_vec(), + triggers: stream_1.operations[3..4] + .iter() + .map(|desc| ExecutionTrigger::OnOperations(vec![desc.clone()])) + .collect(), + optimization: BlockOptimization::new(ExecutionStrategy::operations(3), Vec::new()), + }); + + let mut policy = Policy::new(); + // Same path as stream 1 + stream_2.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(0..2), + Action::Defer, + ); + + // But is different. + stream_2.assert_updates( + &store, + &mut policy, + AssertUpdatesOptions::OperationsIndex(2..4), + Action::Explore, + ); + } + + #[derive(Default, Debug)] + struct TestStream { + tensors: Vec, + operations: Vec, + } + + #[derive(Debug)] + enum AssertUpdatesOptions { + OperationsIndex(Range), + } + + impl TestStream { + /// Create a new test stream with `num_ops` operations registered. + pub fn new(num_ops: usize) -> Self { + let mut stream = Self::default(); + for id in 0..num_ops { + stream.new_ops(id as u64 + 1); + } + + stream + } + + /// The first follow should only be cache miss. + pub fn assert_updates( + &self, + optimizations: &ExecutionPlanStore<()>, + policy: &mut Policy<()>, + options: AssertUpdatesOptions, + action: Action, + ) { + match options { + AssertUpdatesOptions::OperationsIndex(range) => { + for i in range { + let stream = &self.operations[0..i]; + let next_ops = &self.operations[i]; + policy.update(optimizations, next_ops); + let result = policy.action(optimizations, stream, ExecutionMode::Lazy); + + assert_eq!(result, action); + } + } + } + } + + /// Add a simple operation to the stream. + pub fn new_ops(&mut self, out_id: u64) { + if self.tensors.is_empty() { + // Root node. + self.new_empty_node(0); + } + + // Out node. + self.new_empty_node(out_id); + + self.operations.push(OperationIr::Float( + DType::F32, + FloatOperationIr::Log(self.unary_description()), + )); + } + + fn new_empty_node(&mut self, id: u64) { + self.tensors.push(TensorIr { + id: TensorId::new(id), + shape: Shape::new([32, 32, 1]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }); + } + + fn unary_description(&self) -> UnaryOpIr { + let size = self.tensors.len(); + + UnaryOpIr { + input: self.tensors[size - 2].clone(), + out: self.tensors[size - 1].clone(), + } + } + } +} diff --git a/crates/burn-fusion/src/stream/execution/processor.rs b/crates/burn-fusion/src/stream/execution/processor.rs new file mode 100644 index 0000000..ef36f3f --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/processor.rs @@ -0,0 +1,224 @@ +use burn_ir::OperationIr; +use burn_std::config::{fusion::FusionLogLevel, log_fusion}; + +use super::{ExecutionMode, ExplorationAction, Explorer}; +use crate::search::BlockOptimization; +use crate::stream::execution::{Action, Policy}; +use crate::stream::store::{ExecutionPlan, ExecutionPlanId, ExecutionPlanStore, ExecutionTrigger}; +use crate::{NumOperations, OperationFuser}; + +/// Process a [stream segment](StreamSegment) following a [policy](Policy). +pub(crate) struct Processor { + policy: Policy, + explorer: Explorer, +} + +/// A part of a stream that can be executed partially using [execution plan](ExecutionPlan). +pub(crate) trait StreamSegment { + /// The operations in the segment. + fn operations(&self) -> &[OperationIr]; + /// Execute part of the segment using the given plan id. + fn execute(&mut self, id: ExecutionPlanId, store: &mut ExecutionPlanStore); + /// Execute the segment with a one-off optimization, without caching it in the store. + fn execute_unfused(&mut self, optimization: BlockOptimization); +} + +impl Processor { + /// Create a new stream processor. + pub fn new(optimizations: Vec>>) -> Self { + Self { + policy: Policy::new(), + explorer: Explorer::new(optimizations), + } + } + + /// Process the [stream segment](StreamSegment) with the provided [mode](ExecutionMode). + pub fn process( + &mut self, + mut segment: Segment, + store: &mut ExecutionPlanStore, + mode: ExecutionMode, + ) where + Segment: StreamSegment, + { + // We assume that we always register a new operation in lazy mode. + if let ExecutionMode::Lazy = mode { + self.on_new_operation(&segment, store); + } + + loop { + if segment.operations().is_empty() { + break; + } + + let action = self.policy.action(store, segment.operations(), mode); + + match action { + Action::Explore => { + self.explore(&mut segment, store, mode); + + if self.explorer.is_up_to_date() { + break; + } + } + Action::Defer => { + match mode { + ExecutionMode::Lazy => break, + ExecutionMode::Sync => panic!("Can't defer while sync"), + }; + } + Action::Execute(id) => { + let mode_dbg = match mode { + ExecutionMode::Lazy => "lazy", + ExecutionMode::Sync => "sync", + }; + let num_ops = segment.operations().len(); + log_fusion(FusionLogLevel::Full, move || { + format!( + "[plan] cache hit: execute plan #{id} ({mode_dbg}, segment has {num_ops} ops)" + ) + }); + + if let ExecutionMode::Sync = mode { + store.add_trigger(id, ExecutionTrigger::OnSync); + } + + segment.execute(id, store); + self.reset(store, segment.operations()); + } + }; + } + } + + fn on_new_operation(&mut self, segment: &Segment, store: &mut ExecutionPlanStore) + where + Segment: StreamSegment, + { + self.policy.update( + store, + segment + .operations() + .last() + .expect("At least one operation in the operation list."), + ); + self.explorer.on_new_operation(); + } + + fn explore>( + &mut self, + item: &mut Item, + store: &mut ExecutionPlanStore, + mode: ExecutionMode, + ) { + match self.explorer.explore(item.operations(), mode) { + ExplorationAction::Completed(optim) => { + let id = Self::on_exploration_completed( + &self.policy, + item.operations(), + store, + optim, + mode, + ); + item.execute(id, store); + self.reset(store, item.operations()); + } + ExplorationAction::Continue => { + if let ExecutionMode::Sync = mode { + panic!("Can't continue exploring when sync.") + } + } + ExplorationAction::Unfused(optim) => { + // Exploration is capped: run the ops unfused and don't touch the store, so a + // never-cacheable dynamic graph stops both re-exploring and growing the cache. + item.execute_unfused(optim); + self.reset(store, item.operations()); + } + } + } + + /// Number of optimizations built so far; a cached plan that is reused does not + /// count. Used by tests to assert that recurring graphs stop exploring. + #[cfg(test)] + pub(crate) fn num_explorations(&self) -> usize { + self.explorer.num_explorations() + } + + fn reset(&mut self, store: &mut ExecutionPlanStore, operations: &[OperationIr]) { + self.explorer.reset(operations); + self.policy.reset(); + + // Reset the policy state with the remaining operations + for operation in operations.iter() { + self.policy.update(store, operation); + } + } + + /// We found an optimization (i.e. a new execution plan). + /// Cache it in the store. + fn on_exploration_completed( + policy: &Policy, + operations: &[OperationIr], + store: &mut ExecutionPlanStore, + optimization: BlockOptimization, + mode: ExecutionMode, + ) -> ExecutionPlanId { + let num_optimized = optimization.ordering.len(); + let relative = &operations[0..num_optimized]; + + { + let total_ops = operations.len(); + let mode_dbg = match mode { + ExecutionMode::Lazy => "lazy", + ExecutionMode::Sync => "sync", + }; + log_fusion(FusionLogLevel::Full, move || { + format!( + "[plan] exploration completed: {mode_dbg}, {num_optimized}/{total_ops} ops optimized" + ) + }); + } + + match mode { + ExecutionMode::Lazy => { + let next_ops = &operations[num_optimized..operations.len()]; + + let trigger = if next_ops.is_empty() { + // Happens if the next ops is included in the fused operation, and there is no + // way the builder can still continue fusing. + ExecutionTrigger::Always + } else { + ExecutionTrigger::OnOperations(next_ops.to_vec()) + }; + + match policy.action(store, relative, ExecutionMode::Sync) { + Action::Execute(id) => { + store.add_trigger(id, trigger); + id + } + _ => { + let plan = ExecutionPlan { + operations: relative.to_vec(), + triggers: vec![trigger], + optimization, + }; + store.add(plan) + } + } + } + ExecutionMode::Sync => match policy.action(store, relative, ExecutionMode::Sync) { + Action::Execute(id) => { + store.add_trigger(id, ExecutionTrigger::OnSync); + id + } + _ => { + let plan = ExecutionPlan { + operations: relative.to_vec(), + triggers: vec![ExecutionTrigger::OnSync], + optimization, + }; + store.add(plan) + } + }, + } + } +} diff --git a/crates/burn-fusion/src/stream/execution/tests.rs b/crates/burn-fusion/src/stream/execution/tests.rs new file mode 100644 index 0000000..8740b50 --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/tests.rs @@ -0,0 +1,765 @@ +//! A testing module that ensures the correctness of the explorer, policy, and processor. +//! +//! The primary focus is on validating the seamless interaction between these three components to +//! execute and optimize a stream of operations accurately. +//! +//! To test these components effectively, we create mock types for the stream, optimization, +//! optimization builder, and stream segment. These mock types aid in comprehensively +//! understanding the process of optimizing streams. +use std::sync::Arc; + +use burn_backend::{DType, Shape}; +use burn_ir::{ + BinaryOpIr, FloatOperationIr, NumericOperationIr, OperationIr, ScalarIr, ScalarOpIr, TensorId, + TensorIr, TensorStatus, UnaryOpIr, +}; + +use crate::{ + FuserProperties, FuserStatus, NumOperations, OperationFuser, + search::BlockOptimization, + stream::store::{ + ExecutionPlan, ExecutionPlanId, ExecutionPlanStore, ExecutionStrategy, ExecutionTrigger, + }, +}; + +use super::*; + +/// A fake stream of operations for testing purpose. +pub struct TestStream { + processor: Processor, + store: ExecutionPlanStore, + executed: Vec, + operations: Vec, +} + +/// A fake [optimization builder](OptimizationBuilder) for testing purpose. +/// +/// The optimizer tries to fuse only the `expected_operations` if they appear +/// in the operations queue +#[derive(Clone)] +pub struct TestOptimizationBuilder { + builder_id: usize, + expected_operations: Vec, + actual: Vec, +} + +/// A fake optimization for testing purpose. +#[derive(new, Debug, PartialEq)] +pub struct TestOptimization { + builder_id: usize, + size: usize, +} + +impl NumOperations for TestOptimization { + fn len(&self) -> usize { + self.size + } + fn name(&self) -> &'static str { + "TestOptimization" + } +} + +/// A fake [stream segment](StreamSegment) for testing purpose. +#[derive(new)] +pub struct TestSegment<'i> { + operations: &'i mut Vec, + executed: &'i mut Vec, +} + +impl ExecutionStrategy { + /// Create an ordered execution strategy with the given size. + pub fn operations(size: usize) -> Self { + Self::Operations { + ordering: Arc::new((0..size).collect()), + } + } +} + +impl ExecutionStrategy { + /// Only use it for testing, to easily create ordered strategies. + pub fn optimization(opt: TestOptimization) -> Self { + let ordering = Arc::new((0..opt.size).collect()); + Self::Optimization { + opt, + ordering, + score: 1, + } + } +} + +/// This is a substantial test case that examines a lengthy scenario with a diverse set of conditions. +/// +/// While it's usually preferable to split tests into multiple independent scenarios, in this case, it is +/// crucial to verify that the stream's state is correctly updated when various cases occur consecutively. +#[test] +fn should_support_complex_stream() { + // We have 2 different optimization builders in this test case. + let builder_id_1 = 0; + let builder_id_2 = 1; + + // We will have a total of 3 execution plans to execute. + let plan_id_1 = 0; + let plan_id_2 = 1; + let plan_id_3 = 2; + + let builder_1 = TestOptimizationBuilder::new(builder_id_1, vec![operation_1(), operation_2()]); + let builder_2 = TestOptimizationBuilder::new(builder_id_2, vec![operation_2(), operation_2()]); + let mut stream = TestStream::new(vec![Box::new(builder_1), Box::new(builder_2)]); + + // builder_1 is still waiting to see next op is operation_2 + // builder_2 is closed because it's not the right operation + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(0); + + // No optimization found for the first two operations. + stream.add(operation_1()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(1); + stream.assert_last_executed(plan_id_1); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_1(), operation_1()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization::new(ExecutionStrategy::operations(2), Vec::new()), + }, + ); + + // Nothing to execute. + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(1); + + // Now we should trigger the first optimization builder. + stream.add(operation_2()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(2); + stream.assert_last_executed(plan_id_2); + stream.assert_plan( + plan_id_2, + ExecutionPlan { + operations: vec![operation_1(), operation_2()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization::new( + ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 2)), + vec![0, 1], + ), + }, + ); + + // Nothing to execute. + stream.add(operation_2()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(2); + + // Now we should trigger the second optimization builder. + stream.add(operation_2()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(3); + stream.assert_last_executed(plan_id_3); + stream.assert_plan( + plan_id_3, + ExecutionPlan { + operations: vec![operation_2(), operation_2()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_2, 2)), + ordering: vec![0, 1], + }, + }, + ); + + // Nothing to execute. + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(3); + + // Now we should trigger the first optimization builder (second plan). + stream.add(operation_2()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(4); + stream.assert_last_executed(plan_id_2); + stream.assert_plan( + plan_id_2, + ExecutionPlan { + operations: vec![operation_1(), operation_2()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 2)), + ordering: vec![0, 1], + }, + }, + ); + + // Nothing to execute. + stream.add(operation_2()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(4); + + // Now we should trigger the first optimization builder (third plan). + stream.add(operation_2()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(5); + stream.assert_last_executed(plan_id_3); +} + +/// In this scenario we will never use an optimization, but we check that we reuse the execution plan stored. +#[test] +fn should_reuse_basic_operations() { + let builder_id_1 = 0; + let plan_id_1 = 0; + let plan_id_2 = 1; + + let builder_1 = TestOptimizationBuilder::new(builder_id_1, vec![operation_1(), operation_2()]); + let mut stream = TestStream::new(vec![Box::new(builder_1)]); + + stream.add(operation_3()); + stream.assert_last_executed(plan_id_1); + stream.assert_number_of_operations(0); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_3()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::operations(1), + ordering: vec![0], + }, + }, + ); + + stream.add(operation_3()); + stream.assert_last_executed(plan_id_1); + stream.assert_number_of_operations(0); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_3()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::operations(1), + ordering: vec![0], + }, + }, + ); + + // Lazy try to build optimization 1. + stream.add(operation_1()); + // But not possible. + stream.add(operation_3()); + + // Creates a new plan with both operations. + stream.assert_plan( + plan_id_2, + ExecutionPlan { + operations: vec![operation_1(), operation_3()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::operations(2), + ordering: vec![0], + }, + }, + ); + stream.assert_number_of_operations(0); + stream.assert_last_executed(plan_id_2); +} + +// In this scenario we validate that we support multiple optimization builders with overlapping +// operations. +// +// This is a very long scenario that validates a lot of things. +#[test] +fn should_support_overlapping_optimizations() { + // We have 2 different optimization builders in this test case. + let builder_id_1 = 0; + let builder_id_2 = 0; + + // We will have a total of 5 execution plans to execute. + let plan_id_1 = 0; + let plan_id_2 = 1; + let plan_id_3 = 2; + let plan_id_4 = 3; + let plan_id_5 = 4; + + let builder_1 = TestOptimizationBuilder::new(builder_id_1, vec![operation_1(), operation_2()]); + let builder_2 = TestOptimizationBuilder::new( + builder_id_2, + vec![operation_1(), operation_2(), operation_1(), operation_1()], + ); + let mut stream = TestStream::new(vec![Box::new(builder_1), Box::new(builder_2)]); + + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(0); + + stream.add(operation_2()); + stream.assert_number_of_operations(2); + stream.assert_number_of_executions(0); + + stream.add(operation_1()); + stream.assert_number_of_operations(3); + stream.assert_number_of_executions(0); + + stream.add(operation_2()); + stream.assert_number_of_operations(2); + stream.assert_number_of_executions(1); + stream.assert_last_executed(plan_id_1); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_1(), operation_2()], + triggers: vec![ExecutionTrigger::OnOperations(vec![ + operation_1(), + operation_2(), + ])], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 2)), + ordering: vec![0, 1], + }, + }, + ); + + stream.add(operation_2()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(3); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_1(), operation_2()], + triggers: vec![ + ExecutionTrigger::OnOperations(vec![operation_1(), operation_2()]), + ExecutionTrigger::OnOperations(vec![operation_2()]), + ], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 2)), + ordering: vec![0, 1], + }, + }, + ); + stream.assert_plan( + plan_id_2, + ExecutionPlan { + operations: vec![operation_2()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::operations(1), + ordering: vec![0], + }, + }, + ); + + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(3); + + stream.add(operation_2()); + stream.assert_number_of_operations(2); + stream.assert_number_of_executions(3); + + stream.add(operation_1()); + stream.assert_number_of_operations(3); + stream.assert_number_of_executions(3); + + stream.add(operation_1()); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(4); + + stream.assert_plan( + plan_id_3, + ExecutionPlan { + operations: vec![operation_1(), operation_2(), operation_1(), operation_1()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 4)), + ordering: vec![0], + }, + }, + ); + + stream.add(operation_1()); + stream.assert_number_of_operations(1); + stream.assert_number_of_executions(4); + + stream.add(operation_2()); + stream.assert_number_of_operations(2); + stream.assert_number_of_executions(4); + + stream.add(operation_1()); + stream.assert_number_of_operations(3); + stream.assert_number_of_executions(4); + + stream.sync(); + stream.assert_number_of_operations(0); + // The sync executes a single plan covering the whole remaining segment: the + // fused pair composed with the un-fused trailing operation. + stream.assert_number_of_executions(5); + stream.assert_plan( + plan_id_1, + ExecutionPlan { + operations: vec![operation_1(), operation_2()], + triggers: vec![ + ExecutionTrigger::OnOperations(vec![operation_1(), operation_2()]), + ExecutionTrigger::OnOperations(vec![operation_2()]), + ], + optimization: BlockOptimization { + strategy: ExecutionStrategy::optimization(TestOptimization::new(builder_id_1, 2)), + ordering: vec![0, 1], + }, + }, + ); + stream.assert_plan( + plan_id_4, + ExecutionPlan { + operations: vec![operation_1(), operation_2(), operation_1()], + triggers: vec![ExecutionTrigger::OnSync], + optimization: BlockOptimization { + strategy: ExecutionStrategy::Composed(vec![ + Box::new(ExecutionStrategy::optimization(TestOptimization::new( + builder_id_1, + 2, + ))), + Box::new(ExecutionStrategy::Operations { + ordering: Arc::new(vec![2]), + }), + ]), + ordering: vec![0, 1, 2], + }, + }, + ); + + stream.add(operation_3()); + stream.assert_last_executed(plan_id_5); + stream.assert_plan( + plan_id_5, + ExecutionPlan { + operations: vec![operation_3()], + triggers: vec![ExecutionTrigger::Always], + optimization: BlockOptimization { + strategy: ExecutionStrategy::operations(1), + ordering: vec![0], + }, + }, + ); + + stream.add(operation_3()); + stream.assert_last_executed(plan_id_5); +} + +/// A plan discovered at a sync point must cover the whole segment and be reusable on +/// the next identical pass without re-exploring. +/// +/// This is the shape of an autoregressive inference step: the graph defers past the +/// fusable prefix because some fuser is still open, and the step ends on a sync. The +/// search leaves the still-open tail out of its optimization (in lazy mode the tail +/// seeds the next round), but at a sync there is no next round: the tail is folded +/// into the plan as un-fused operations in a composed strategy. The plan then matches +/// the whole segment, so every subsequent identical step executes it directly instead +/// of re-running the whole exploration. +#[test] +fn should_reuse_sync_plan_on_next_pass() { + let plan_id = 0; + + let pair_builder = TestOptimizationBuilder::new(0, vec![operation_1(), operation_2()]); + // A builder that wants a longer sequence: it stays open past the tail op, which is + // what defers the whole segment until the sync. + let hungry_builder = TestOptimizationBuilder::new( + 1, + vec![operation_1(), operation_2(), operation_3(), operation_1()], + ); + let mut stream = TestStream::new(vec![Box::new(pair_builder), Box::new(hungry_builder)]); + + // First pass: everything defers, the sync explores once and executes one plan + // covering the whole segment. + stream.add(operation_1()); + stream.add(operation_2()); + stream.add(operation_3()); + stream.assert_number_of_executions(0); + stream.sync(); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(1); + stream.assert_last_executed(plan_id); + stream.assert_number_of_explorations(1); + stream.assert_plan( + plan_id, + ExecutionPlan { + operations: vec![operation_1(), operation_2(), operation_3()], + triggers: vec![ExecutionTrigger::OnSync], + optimization: BlockOptimization { + strategy: ExecutionStrategy::Composed(vec![ + Box::new(ExecutionStrategy::optimization(TestOptimization::new(0, 2))), + Box::new(ExecutionStrategy::Operations { + ordering: Arc::new(vec![2]), + }), + ]), + ordering: vec![0, 1, 2], + }, + }, + ); + + // Second pass: the identical stream ends at the same sync; the cached plan + // matches the whole segment and executes without any re-exploration. + stream.add(operation_1()); + stream.add(operation_2()); + stream.add(operation_3()); + stream.assert_number_of_executions(1); + stream.sync(); + stream.assert_number_of_operations(0); + stream.assert_number_of_executions(2); + stream.assert_last_executed(plan_id); + stream.assert_number_of_explorations(1); +} + +impl TestStream { + /// Create a new stream with the given optimization builders. + fn new(optimizations: Vec>>) -> Self { + Self { + processor: Processor::::new(optimizations), + store: ExecutionPlanStore::::new(), + executed: Vec::new(), + operations: Vec::new(), + } + } + + /// Add an operation to the stream. + fn add(&mut self, operation: OperationIr) { + self.operations.push(operation); + self.processor.process( + TestSegment::new(&mut self.operations, &mut self.executed), + &mut self.store, + ExecutionMode::Lazy, + ); + } + + /// Sync the stream. + fn sync(&mut self) { + self.processor.process( + TestSegment::new(&mut self.operations, &mut self.executed), + &mut self.store, + ExecutionMode::Sync, + ); + } + + /// Assert that the plan has been executed as provided. + fn assert_plan(&self, id: ExecutionPlanId, expected: ExecutionPlan) { + let actual = self.store.get_unchecked(id); + assert_eq!(actual.operations, expected.operations, "Same operations"); + assert_eq!(actual.triggers, expected.triggers, "Same triggers"); + } + + /// Assert that the given plan id has been the last executed. + fn assert_last_executed(&self, id: ExecutionPlanId) { + match self.executed.last() { + Some(last_id) => assert_eq!(*last_id, id), + None => panic!("No plan has been executed"), + } + } + + /// Assert the number of executions since the start of the stream. + fn assert_number_of_executions(&self, number: usize) { + assert_eq!(self.executed.len(), number, "Number of execution match"); + } + + /// Assert the number of operations queued. + fn assert_number_of_operations(&self, number: usize) { + assert_eq!(self.operations.len(), number); + } + + /// Assert the number of optimizations built since the start of the stream; + /// reusing a cached plan does not count. + fn assert_number_of_explorations(&self, number: usize) { + assert_eq!( + self.processor.num_explorations(), + number, + "Number of explorations match" + ); + } +} + +impl TestOptimizationBuilder { + /// Create a new optimization builder that follows a pattern with a trigger. + pub fn new(builder_id: usize, operations: Vec) -> Self { + Self { + builder_id, + expected_operations: operations, + actual: Vec::new(), + } + } +} + +impl OperationFuser for TestOptimizationBuilder { + /// Register a new operation. + fn fuse(&mut self, operation: &OperationIr) { + self.actual.push(operation.clone()); + } + + /// Build the optimization. + fn finish(&mut self) -> TestOptimization { + TestOptimization::new(self.builder_id, self.len()) + } + + /// Reset the state. + fn reset(&mut self) { + self.actual.clear(); + } + + /// Return the optimization status. + fn status(&self) -> FuserStatus { + if self.actual.len() < self.expected_operations.len() { + let operations = &self.expected_operations[0..self.actual.len()]; + + return match self.actual == operations { + // Still optimizing. + true => FuserStatus::Open, + // Never gonna be possible on that stream. + false => FuserStatus::Closed, + }; + } + + FuserStatus::Closed + } + + /// Return the properties of this optimization. + fn properties(&self) -> FuserProperties { + if self.actual.len() < self.expected_operations.len() { + // Optimization not possible. + return FuserProperties { + score: 0, + ready: false, + }; + } + + let stream_is_ok = + self.actual[0..self.expected_operations.len()] == self.expected_operations; + + if !stream_is_ok { + // Optimization not possible. + return FuserProperties { + score: 0, + ready: false, + }; + } + + // Optimization possible. + FuserProperties { + score: self.expected_operations.len() as u64, + ready: true, + } + } + + // The number of operations that should be handle by the optimization. + fn len(&self) -> usize { + self.expected_operations.len() + } + fn clone_dyn(&self) -> Box> { + Box::new(self.clone()) + } +} + +impl StreamSegment for TestSegment<'_> { + // The operations in the process. + fn operations(&self) -> &[OperationIr] { + self.operations + } + + // Execute the process. + fn execute(&mut self, id: ExecutionPlanId, store: &mut ExecutionPlanStore) { + let execution_plan = store.get_unchecked(id); + + self.execute_strategy(&execution_plan.optimization.strategy); + + self.executed.push(id); + } + + fn execute_unfused(&mut self, optimization: BlockOptimization) { + self.execute_strategy(&optimization.strategy); + } +} + +impl TestSegment<'_> { + fn execute_strategy(&mut self, strategy: &ExecutionStrategy) { + match strategy { + ExecutionStrategy::Optimization { opt, .. } => { + self.operations.drain(0..opt.size); + } + ExecutionStrategy::Operations { ordering } => { + self.operations.drain(0..ordering.len()); + } + ExecutionStrategy::Composed(strategies) => { + for strategy in strategies { + self.execute_strategy(strategy); + } + } + } + } +} + +/// Just a simple operation. +pub fn operation_1() -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::Add(BinaryOpIr { + lhs: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + rhs: TensorIr { + id: TensorId::new(1), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + out: TensorIr { + id: TensorId::new(2), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) +} + +/// Just a simple operation. +pub fn operation_2() -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::AddScalar(ScalarOpIr { + lhs: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + rhs: ScalarIr::Float(5.0), + out: TensorIr { + id: TensorId::new(2), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) +} + +/// Just a simple operation. +pub fn operation_3() -> OperationIr { + OperationIr::Float( + DType::F32, + FloatOperationIr::Log(UnaryOpIr { + input: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + out: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) +} diff --git a/crates/burn-fusion/src/stream/execution/trace.rs b/crates/burn-fusion/src/stream/execution/trace.rs new file mode 100644 index 0000000..c91fea0 --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/trace.rs @@ -0,0 +1,383 @@ +//! Full-level fusion logging and the shared data model used by both the logger and +//! the test-only [`FusionInspector`](crate::inspect::FusionInspector). +//! +//! A single execution of a [`ExecutionStrategy`] is flattened into a sequence of +//! [`FusionBlock`]s — one per strategy leaf — each tagged with a [`BlockKind`]. The +//! logger renders these as a human-readable table; the inspector stores them in +//! `FusionReport`s so tests can assert on what fused together. + +use burn_ir::{OperationIr, TensorIr}; +use burn_std::config::{fusion::FusionLogLevel, log_fusion}; +use core::fmt::Write; + +use crate::NumOperations; +use crate::stream::{StreamId, store::ExecutionStrategy}; + +/// One contiguous run of operations — the output of a single [`ExecutionStrategy`] +/// leaf. A `Composed` strategy flattens into a sequence of these. +#[derive(Debug, Clone)] +pub struct FusionBlock { + /// Whether this block ran fused or unfused. + pub kind: BlockKind, + /// The operations contained in the block, in execution order. + pub operations: Vec, +} + +/// Whether a [`FusionBlock`] was fused into a single kernel or ran op-by-op. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BlockKind { + /// The operations were fused into a single kernel produced by the named fuser. + Fused { + /// The name of the fuser / optimization (e.g. `"ElementWise"`). + name: &'static str, + /// The score the fuser reported for this kernel. + score: u64, + }, + /// The operations ran individually; no fusion happened for this block. + Unfused, +} + +/// Emit an execution table for the given `strategy` at [`FusionLogLevel::Full`]. +/// +/// `global` is the `OperationQueue::global` slice at the time of execution — it holds the +/// real tensor IDs and shapes that the indices inside the strategy's `ordering` fields +/// point into. The table is built lazily: no allocation happens when the log level is +/// below `Full` (except when the inspector is installed, which eagerly captures the +/// structured blocks for test assertions). +pub(crate) fn log_execution_table( + _id: StreamId, + strategy: &ExecutionStrategy, + global: &[OperationIr], +) { + // When the inspector is active, build the blocks eagerly so we can emit a + // structured report even when fusion logging is disabled. Otherwise stay lazy. + #[cfg(feature = "test-util")] + let blocks_for_inspector: Option> = if crate::inspect::is_installed() { + let mut blocks = Vec::new(); + collect_blocks(strategy, global, &mut blocks); + crate::inspect::emit(_id, &blocks); + Some(blocks) + } else { + None + }; + + log_fusion(FusionLogLevel::Full, || { + #[cfg(feature = "test-util")] + if let Some(blocks) = &blocks_for_inspector { + return format_table(blocks); + } + let mut blocks = Vec::new(); + collect_blocks(strategy, global, &mut blocks); + format_table(&blocks) + }); +} + +pub(crate) fn collect_blocks( + strategy: &ExecutionStrategy, + global: &[OperationIr], + blocks: &mut Vec, +) { + match strategy { + ExecutionStrategy::Optimization { + opt, + ordering, + score, + } => { + blocks.push(FusionBlock { + kind: BlockKind::Fused { + name: opt.name(), + score: *score, + }, + operations: ordering.iter().map(|&i| global[i].clone()).collect(), + }); + } + ExecutionStrategy::Operations { ordering } => { + blocks.push(FusionBlock { + kind: BlockKind::Unfused, + operations: ordering.iter().map(|&i| global[i].clone()).collect(), + }); + } + ExecutionStrategy::Composed(items) => { + for item in items { + collect_blocks(item, global, blocks); + } + } + } +} + +pub(crate) fn format_table(blocks: &[FusionBlock]) -> String { + let total: usize = blocks.iter().map(|b| b.operations.len()).sum(); + if total == 0 { + return String::from("fusion block: "); + } + + // One row per op across all blocks. The `block` column carries the block header + // on the first row of each block and is left blank for the rest, so each fused + // block is visually grouped but the name/score appear exactly once. + struct Row { + idx: String, + block: String, + op: String, + inputs: String, + outputs: String, + } + + let headers = ["idx", "block", "op", "inputs", "outputs"]; + let mut widths = headers.map(str::len); + + let mut rows: Vec = Vec::with_capacity(total); + let mut global_idx: usize = 0; + for block in blocks { + let header = block_header(&block.kind, block.operations.len()); + for (i, op) in block.operations.iter().enumerate() { + let row = Row { + idx: global_idx.to_string(), + block: if i == 0 { + header.clone() + } else { + String::new() + }, + op: op_kind(op), + inputs: format_tensors(op.inputs()), + outputs: format_tensors(op.outputs()), + }; + widths[0] = widths[0].max(row.idx.len()); + widths[1] = widths[1].max(row.block.len()); + widths[2] = widths[2].max(row.op.len()); + widths[3] = widths[3].max(row.inputs.len()); + widths[4] = widths[4].max(row.outputs.len()); + rows.push(row); + global_idx += 1; + } + } + + // Two-space separators between the 5 columns. + let table_width = widths.iter().sum::() + 2 * (widths.len() - 1); + + let mut out = String::new(); + writeln!(out, "{}", "═".repeat(table_width)).unwrap(); + writeln!( + out, + "fusion trace ({total} op{}, {} block{})", + if total == 1 { "" } else { "s" }, + blocks.len(), + if blocks.len() == 1 { "" } else { "s" }, + ) + .unwrap(); + + let write_row = |out: &mut String, cols: [&str; 5]| { + for (i, c) in cols.iter().enumerate() { + if i > 0 { + out.push_str(" "); + } + write!(out, "{: String { + let ops_suffix = if size == 1 { "op" } else { "ops" }; + match kind { + BlockKind::Fused { name, score } => { + format!("▸ fused {name} (score={score}, {size} {ops_suffix})") + } + BlockKind::Unfused => format!("▸ un-fused ({size} {ops_suffix})"), + } +} + +/// Take the top-level variant name from a Debug representation (`"Foo(..)"` → `"Foo"`). +fn debug_head(s: &str) -> &str { + let end = s.find(['(', '{', ' ']).unwrap_or(s.len()); + &s[..end] +} + +/// Produce a "Outer::Inner" kind string for every variant that has an inner enum, +/// so the table shows the concrete operation (e.g. `BaseFloat::Reshape`) rather than +/// just the category. +pub(crate) fn op_kind(op: &OperationIr) -> String { + fn inner(s: String) -> String { + debug_head(&s).to_string() + } + match op { + OperationIr::BaseFloat(x) => format!("BaseFloat::{}", inner(format!("{x:?}"))), + OperationIr::BaseInt(x) => format!("BaseInt::{}", inner(format!("{x:?}"))), + OperationIr::BaseBool(x) => format!("BaseBool::{}", inner(format!("{x:?}"))), + OperationIr::NumericFloat(_, x) => format!("NumericFloat::{}", inner(format!("{x:?}"))), + OperationIr::NumericInt(_, x) => format!("NumericInt::{}", inner(format!("{x:?}"))), + OperationIr::Bool(x) => format!("Bool::{}", inner(format!("{x:?}"))), + OperationIr::Int(x) => format!("Int::{}", inner(format!("{x:?}"))), + OperationIr::Float(_, x) => format!("Float::{}", inner(format!("{x:?}"))), + OperationIr::Module(x) => format!("Module::{}", inner(format!("{x:?}"))), + OperationIr::Init(x) => format!("Init::{}", inner(format!("{x:?}"))), + OperationIr::Custom(_) => "Custom".to_string(), + OperationIr::Drop(_) => "Drop".to_string(), + OperationIr::Distributed(x) => format!("Distributed::{}", inner(format!("{x:?}"))), + OperationIr::Activation(x) => format!("Activation::{}", inner(format!("{x:?}"))), + } +} + +fn format_tensors<'a, I: Iterator>(tensors: I) -> String { + let mut out = String::new(); + let mut first = true; + for t in tensors { + if !first { + out.push_str(", "); + } + first = false; + write!(out, "t{}:", t.id.value()).unwrap(); + write_dtype(&mut out, &t.dtype); + write_shape(&mut out, &t.shape); + } + out +} + +fn write_dtype(out: &mut String, dtype: &burn_backend::DType) { + // Debug gives "F32", "Bool(Native)", etc. Lowercase the initial identifier so the + // output reads like a Rust type annotation; keep anything after the first paren/brace + // (e.g. Bool variants or QFloat scheme details) verbatim. + let dbg = format!("{dtype:?}"); + let split = dbg.find(['(', '{', ' ']).unwrap_or(dbg.len()); + let (head, tail) = dbg.split_at(split); + for c in head.chars() { + out.push(c.to_ascii_lowercase()); + } + out.push_str(tail); +} + +fn write_shape(out: &mut String, shape: &burn_backend::Shape) { + out.push('['); + let mut first = true; + for d in shape.iter() { + if !first { + out.push(','); + } + first = false; + write!(out, "{d}").unwrap(); + } + out.push(']'); +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::{DType, Shape}; + use burn_ir::{BaseOperationIr, TensorId, TensorIr, TensorStatus, UnaryOpIr}; + + fn tensor(id: u64, dims: &[usize]) -> TensorIr { + TensorIr { + id: TensorId::new(id), + shape: Shape::new_raw(dims.iter().copied().collect()), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + } + } + + #[test] + fn tensor_format_is_compact() { + let t = tensor(42, &[2, 3, 4]); + let out = format_tensors(core::iter::once(&t)); + assert_eq!(out, "t42:f32[2,3,4]"); + } + + #[test] + fn tensor_format_multiple_tensors_comma_separated() { + let a = tensor(1, &[8]); + let b = tensor(2, &[]); + let out = format_tensors([&a, &b].into_iter()); + assert_eq!(out, "t1:f32[8], t2:f32[]"); + } + + #[test] + fn op_kind_includes_inner_variant() { + let input = tensor(1, &[4, 2]); + let out = tensor(2, &[2, 4]); + let op = OperationIr::BaseFloat(BaseOperationIr::Permute(burn_ir::PermuteOpIr { + input, + out, + axes: vec![1, 0], + })); + assert_eq!(op_kind(&op), "BaseFloat::Permute"); + } + + #[test] + fn op_kind_handles_unary_inside_float() { + let input = tensor(1, &[4]); + let out = tensor(2, &[4]); + let op = OperationIr::Float( + DType::F32, + burn_ir::FloatOperationIr::Exp(UnaryOpIr { input, out }), + ); + assert_eq!(op_kind(&op), "Float::Exp"); + } + + #[test] + fn format_table_puts_name_and_score_in_block_header() { + let op = OperationIr::Float( + DType::F32, + burn_ir::FloatOperationIr::Exp(UnaryOpIr { + input: tensor(1, &[4]), + out: tensor(2, &[4]), + }), + ); + let blocks = vec![ + FusionBlock { + kind: BlockKind::Fused { + name: "FusedKernel", + score: 42, + }, + operations: vec![op.clone(), op.clone()], + }, + FusionBlock { + kind: BlockKind::Unfused, + operations: vec![op], + }, + ]; + + let table = format_table(&blocks); + + // Top separator precedes the title line. + let first_line = table.lines().next().unwrap(); + assert!( + first_line.chars().all(|c| c == '═'), + "expected top line of ═, got {first_line:?}" + ); + assert!(table.contains("\nfusion trace (3 ops, 2 blocks)\n")); + // Fused header names the optimization and its score exactly once. + assert!(table.contains("▸ fused FusedKernel (score=42, 2 ops)")); + // Un-fused header is tagged and sized. + assert!(table.contains("▸ un-fused (1 op)")); + // No per-row repetition of "FusedKernel" or "42": they appear exactly once. + assert_eq!(table.matches("FusedKernel").count(), 1); + assert_eq!(table.matches("score=42").count(), 1); + // Indices are global (0, 1, 2 across blocks). + assert!(table.contains("\n0 ")); + assert!(table.contains("\n1 ")); + assert!(table.contains("\n2 ")); + } +} diff --git a/crates/burn-fusion/src/stream/execution/validator.rs b/crates/burn-fusion/src/stream/execution/validator.rs new file mode 100644 index 0000000..2095a9d --- /dev/null +++ b/crates/burn-fusion/src/stream/execution/validator.rs @@ -0,0 +1,136 @@ +use burn_ir::OperationIr; + +use crate::stream::store::{ExecutionPlanId, ExecutionPlanStore, ExecutionTrigger}; + +/// Compare each operation in the list of operations provided by the [store](OperationsStore) +/// to verify if the newly added operations match the original list. +/// +/// It is used by the [policy](crate::stream::execution::Policy) to check each candidate as well +/// as to verify if a list of operations is optimal to execute based on their triggers. +#[derive(Debug)] +pub(crate) struct OperationsValidator { + /// The ID used to retrieve the operation list. + pub(crate) id: ID, + /// The current [state](MatchingState). + pub(crate) state: ValidatorState, +} + +/// The state of the validator. +#[derive(Debug)] +pub(crate) enum ValidatorState { + /// A matching operation list has been found. + Found { size: usize }, + /// No matching operation list has been found. + Invalidated, + /// Potentially going to find a matching operation list when more operations are added. + Validating, +} + +/// Provides a list of operations based on an Id. +pub(crate) trait OperationsStore { + /// The type used for the identifier. + type Id: Copy; + + /// retrieve the list of operations corresponding on the provided id. + fn get(&self, id: Self::Id) -> &[OperationIr]; +} + +impl OperationsValidator { + /// Create a new validator. + pub(crate) fn new(id: ID) -> Self { + Self { + id, + state: ValidatorState::Validating, + } + } + + /// Update the state of the validator based on the newly added operation. + pub(crate) fn update(&mut self, added: &OperationIr, added_position: usize, store: &S) + where + S: OperationsStore, + ID: PartialEq + Copy, + { + match &self.state { + ValidatorState::Found { size: _ } => return, + ValidatorState::Invalidated => return, + ValidatorState::Validating => {} + }; + + let item = store.get(self.id); + let operation_candidate = match item.get(added_position) { + Some(val) => val, + None => { + self.state = ValidatorState::Invalidated; + return; + } + }; + + if operation_candidate != added { + self.state = ValidatorState::Invalidated; + return; + } + + // Finished + if item.len() == added_position + 1 { + self.state = ValidatorState::Found { size: item.len() }; + } + } +} + +/// [Operations store](OperationsStore) used to retrieve the list of operations for a trigger. +#[derive(new)] +pub(crate) struct TriggerOperationsStore<'a, O> { + id: ExecutionPlanId, + store: &'a ExecutionPlanStore, +} + +/// Validates when operations match a trigger. +#[derive(Debug)] +pub(crate) enum TriggerValidator { + OnOperations { + matching: OperationsValidator, + progress: TriggerProgress, + }, + Always, + OnSync, +} + +/// The progress made into the trigger validation process. +#[derive(Debug)] +pub(crate) enum TriggerProgress { + /// When the validation hasn't started. + NotInit, + /// The number of operations that have been checked. + NumChecked(usize), +} + +/// An execution plan can have many triggers, so we use the position in the list to identify a +/// trigger. +pub(crate) type TriggerId = usize; + +impl OperationsStore for TriggerOperationsStore<'_, O> { + type Id = TriggerId; + + fn get(&self, id: Self::Id) -> &[OperationIr] { + match &self.store.get_unchecked(self.id).triggers[id] { + ExecutionTrigger::OnOperations(operations) => operations, + ExecutionTrigger::OnSync => &[], + ExecutionTrigger::Always => &[], + } + } +} + +/// [Operations store](OperationsStore) used to retrieve the list of operations for an +/// [execution plan](crate::stream::store::ExecutionPlan). +#[derive(new)] +pub(crate) struct ExecutionPlanOperationsStore<'a, O> { + store: &'a ExecutionPlanStore, +} + +impl OperationsStore for ExecutionPlanOperationsStore<'_, O> { + type Id = ExecutionPlanId; + + fn get(&self, id: Self::Id) -> &[OperationIr] { + &self.store.get_unchecked(id).operations + } +} diff --git a/crates/burn-fusion/src/stream/memory_checks.rs b/crates/burn-fusion/src/stream/memory_checks.rs new file mode 100644 index 0000000..069d7e8 --- /dev/null +++ b/crates/burn-fusion/src/stream/memory_checks.rs @@ -0,0 +1,247 @@ +use hashbrown::HashMap; +use std::{ + fmt::Display, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + mpsc::SyncSender, + }, + thread::JoinHandle, + time::Duration, +}; + +use burn_ir::{HandleContainer, TensorId, TensorStatus}; +use burn_std::id::StreamId; + +use crate::FusionRuntime; + +use super::Stream; + +/// Memory checks struct to validate there is no memory leak with the fusion runtime. +#[derive(Clone)] +pub(crate) struct MemoryChecks { + sender: SyncSender, + num_queued: Arc, + // Keeps track of its thread. + _handle: Arc>, +} + +enum Message { + Register(StreamAnalyses), + Check(SyncSender), +} + +enum MemoryReport { + Success, + NotReady, + NotStarted, + Fail(String), +} + +#[derive(Default)] +struct StreamAnalyses { + streams: HashMap, + num_handles: usize, +} + +impl Display for StreamAnalyses { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("\n==== Fusion Memory Report ====\n")?; + f.write_fmt(format_args!(" - Handles: {}\n", self.num_handles))?; + f.write_fmt(format_args!(" - Streams: {}\n", self.streams.len()))?; + + for (id, analysis) in self.streams.iter() { + f.write_fmt(format_args!( + " - {} => operations: {} cursor: {}\n", + id, analysis.num_operations, analysis.cursor + ))?; + for (tid, status) in analysis.variables.iter() { + f.write_fmt(format_args!(" - {tid} => status: {status:?}\n"))?; + } + } + + f.write_str("==============================\n") + } +} + +#[derive(Default, Debug)] +struct Analysis { + variables: HashMap, + num_operations: usize, + cursor: u64, +} + +#[macro_export] +/// Export memory checks tests. +macro_rules! memory_checks { + () => { + #[cfg(test)] + mod memory_checks { + #[test] + fn test_memory_leaks() { + burn_fusion::stream::memory_checks::check_memory_leaks(); + } + } + }; +} + +static INSTANCE: spin::Mutex> = spin::Mutex::new(None); + +/// Performs memory checks and panics if a leak is discovered. +pub fn check_memory_leaks() { + let mut num_try_uninit = 0; + let max_try = 25; + + loop { + let report = fetch_memory_report(); + match report { + MemoryReport::Success => return, + MemoryReport::NotReady => { + num_try_uninit = 0; + std::thread::sleep(Duration::from_millis(100)) + } + MemoryReport::NotStarted => { + if num_try_uninit >= max_try { + // Nothing is running on the fusion runtime. + return; + } + num_try_uninit += 1; + std::thread::sleep(Duration::from_millis(100)) + } + MemoryReport::Fail(msg) => panic!("{msg}"), + } + } +} + +fn fetch_memory_report() -> MemoryReport { + let report = INSTANCE.lock(); + + let report = match report.as_ref() { + Some(client) => client, + None => return MemoryReport::NotStarted, + }; + + let (sender, rec) = std::sync::mpsc::sync_channel(1); + match report.sender.send(Message::Check(sender)) { + Ok(_) => {} + Err(err) => { + panic!("Channel closed can't send the check call: {err:?}") + } + }; + + match rec.recv() { + Ok(report) => report, + Err(err) => panic!("Received an error from fetching check results: {err}"), + } +} + +impl Default for MemoryChecks { + fn default() -> Self { + let mut instance = INSTANCE.lock(); + let result = match instance.as_mut() { + Some(client) => client.clone(), + None => { + let this = Self::spawn_new(); + *instance = Some(this.clone()); + this + } + }; + core::mem::drop(instance); + result + } +} + +impl MemoryChecks { + pub(crate) fn check( + &mut self, + streams: &HashMap>, + handles: &HandleContainer, + ) { + let mut analyses = StreamAnalyses { + num_handles: handles.num_handles(), + streams: Default::default(), + }; + + for (id, s) in streams.iter() { + let analysis = Analysis { + variables: s.queue.variables.clone(), + num_operations: s.queue.global.len(), + cursor: s.cursor, + }; + analyses.streams.insert(*id, analysis); + } + + self.num_queued.fetch_add(1, Ordering::Relaxed); + match self.sender.send(Message::Register(analyses)) { + Ok(..) => {} + Err(err) => { + panic!("Can't register memory checks analysis: {err:?}") + } + } + } + + fn spawn_new() -> Self { + let (sender, rec) = std::sync::mpsc::sync_channel(100); + let num_queued = Arc::new(AtomicU64::new(0)); + let num_queued_moved = num_queued.clone(); + + let handle = std::thread::spawn(move || { + let mut last_analyses = None; + + loop { + let payload = match rec.recv() { + Err(_err) => { + // A client has panic, safe to skip as it may be normal. + continue; + } + Ok(payload) => payload, + }; + match payload { + Message::Register(payload) => { + last_analyses = Some(payload); + num_queued_moved.fetch_sub(1, Ordering::Relaxed); + } + Message::Check(callback) => { + if num_queued_moved.load(Ordering::Relaxed) > 1 { + callback.send(MemoryReport::NotReady).unwrap(); + continue; + } + + // We assume that if nothing has been registered in the last second + // while being at a count of 1, it's the end. + std::thread::sleep(Duration::from_secs(5)); + + if num_queued_moved.load(Ordering::Relaxed) <= 1 { + match last_analyses.take() { + Some(val) => { + callback.send(Self::final_check(val)).unwrap(); + } + None => { + callback + .send(MemoryReport::Fail("No analyses".into())) + .unwrap(); + } + } + } else { + callback.send(MemoryReport::NotReady).unwrap(); + } + } + } + } + }); + + Self { + sender, + num_queued, + _handle: Arc::new(handle), + } + } + + fn final_check(analyses: StreamAnalyses) -> MemoryReport { + if !analyses.streams.is_empty() || analyses.num_handles > 0 { + return MemoryReport::Fail(format!("{analyses}")); + } + + MemoryReport::Success + } +} diff --git a/crates/burn-fusion/src/stream/mod.rs b/crates/burn-fusion/src/stream/mod.rs new file mode 100644 index 0000000..8562e56 --- /dev/null +++ b/crates/burn-fusion/src/stream/mod.rs @@ -0,0 +1,32 @@ +pub(crate) mod execution; +pub(crate) mod queue; +pub(crate) mod store; + +#[cfg(feature = "memory-checks")] +/// Memory checks module. +pub mod memory_checks; + +#[cfg(not(feature = "memory-checks"))] +#[macro_export] +/// Export memory checks tests. +macro_rules! memory_checks { + () => { + #[cfg(test)] + mod memory_checks { + #[ignore = "'memory-checks' disabled"] + #[test] + fn test_memory_leaks() { + // + } + } + }; +} + +mod base; +mod context; +mod multi; + +pub use base::*; +pub use context::*; +pub use execution::*; +pub use multi::*; diff --git a/crates/burn-fusion/src/stream/multi.rs b/crates/burn-fusion/src/stream/multi.rs new file mode 100644 index 0000000..e299648 --- /dev/null +++ b/crates/burn-fusion/src/stream/multi.rs @@ -0,0 +1,301 @@ +use super::{ + StreamId, + execution::{ExecutionMode, Processor, StreamSegment}, + queue::OperationQueue, + store::{ExecutionPlanId, ExecutionPlanStore}, +}; +use crate::{FusionRuntime, UnfusedOp, search::BlockOptimization}; +use burn_ir::{HandleContainer, OperationIr, TensorId}; +use hashbrown::{HashMap, HashSet}; + +/// Keep track of multiple concurrent lazy streams of operations. +/// +/// # Why this exists +/// +/// Each `Stream` holds a lazy queue of [`OperationIr`]s whose inputs are assumed +/// to live on that stream. That makes single-stream execution simple — every +/// `TensorId` in a queue is resolvable from the same handle map and the same +/// pending op chain. But a [`FusionTensor`](crate::FusionTensor) is `Send + Clone`, +/// so user code can move or clone a tensor from one thread (= one [`StreamId`]) to +/// another. The receiving thread will then submit ops whose inputs reference a +/// tensor whose home is a *different* stream's queue. This struct is what makes +/// that case behave correctly without giving up the stream-locality invariant. +/// +/// # Strategy: shared views +/// +/// We never let a foreign-stream tensor id appear in another stream's queue. +/// Instead, when [`FusionTensor::clone`](crate::FusionTensor::clone) or +/// [`FusionTensor::into_ir`](crate::FusionTensor::into_ir) detects that +/// `self.stream != StreamId::current()`, it allocates a fresh id (`dst`) and calls +/// `tag_shared_view` with `(src_stream, src, dst)`. That call does two +/// things, in order: +/// +/// 1. **Materialise `src`.** The id `src` might be the output of an op still +/// pending on `src_stream`. We need its backing handle to actually exist before +/// we can alias it. If [`HandleContainer::get_handle_ref`] returns `None` — +/// meaning no op has produced a handle for `src` yet — we drain `src_stream` +/// synchronously, forcing every pending op to run (and thus the handle to be +/// registered). We also record `src` in `shared_sources` so that any +/// *next* share of the same `src` can skip the drain: once registered, a +/// handle stays put (see invariants below). +/// +/// 2. **Alias the handle under `dst`.** [`HandleContainer::register_handle`] is +/// called with `dst` and a `clone()` of `src`'s backend handle. Cubecl handles +/// are `Arc`-style reference counters over a backing buffer, so `clone()` is +/// cheap and the buffer survives until the last alias drops. After this call, +/// `handles[src]` and `handles[dst]` are two distinct map entries that both +/// point at the same allocation. +/// +/// `shared_view` then returns a new `FusionTensor` carrying `(id = dst, stream = +/// current)`. Every subsequent op on that tensor enqueues on `current` like any +/// other local tensor — the rest of the fusion engine sees no special case. +/// +/// # Freeing +/// +/// Each `FusionTensor::drop` enqueues an `OperationIr::Drop(ir)` on **its own** +/// `stream` field (the home stream of that particular alias), not the calling +/// thread's stream. So: +/// +/// - The original tensor's drop targets `src_stream` and removes `handles[src]`. +/// - The alias tensor's drop targets the stream that minted it and removes +/// `handles[dst]`. +/// +/// Each removal decrements the backend handle's `Arc` refcount; the underlying +/// buffer is freed only after the last side drops. No cross-stream coordination +/// is needed. +/// +/// # Bounding `shared_sources` +/// +/// Naively the set would grow forever, since `tag_shared_view` only ever inserts. +/// Cleanup happens in `register`: as soon as we see an +/// `OperationIr::Drop(ir)` come through, we remove `ir.id` from +/// `shared_sources` immediately — without waiting for the queued `Drop` +/// to actually execute. This is safe because a `Drop` op is registered only +/// after the last live `FusionTensor` with that id has been dropped, so no +/// future `tag_shared_view` can possibly receive that id as a `src`. Removing +/// the entry therefore cannot trigger a redundant drain on any subsequent call. +/// +/// # The SSA-like invariant +/// +/// Skipping the drain on subsequent shares of the same `src` relies on a +/// property of the fusion IR: **every op output uses a fresh `TensorId` +/// allocated by [`crate::Client::create_empty_handle`], never the id of an +/// input.** Once `handles[src]` is set, no later op overwrites it; the data +/// behind `src` is effectively immutable from the IR's point of view. +/// +/// The cubecl-fusion engine *does* sometimes reuse the backing buffer of an +/// input for an output (in-place fusion), but that path is gated by +/// `handle.can_mut()`, which returns false the moment another reference exists. +/// Calling `handle.clone()` in step 2 above is precisely that extra reference, +/// so aliased sources are never eligible for in-place reuse — the engine +/// allocates a fresh output buffer instead. +/// +/// # The chained-share fast path +/// +/// When a share is itself re-shared (owner → peer → grandpeer), the second +/// `tag_shared_view` call has `src = peer's id`. That id was set up directly by +/// the previous call (via `register_handle`), not by an op enqueued on the peer +/// stream, so `handles.get_handle_ref(&src)` is already `Some` the moment we +/// look. The drain check therefore short-circuits — even though `peer's id` was +/// never added to `shared_sources` (only sources that *required* a +/// drain are tracked there), the handle-existence test alone is sufficient. +pub struct MultiStream { + /// Tensor ids that have been the source of a cross-stream share *and* + /// required a drain when first shared. Used by `tag_shared_view` to + /// skip the drain on subsequent shares of the same source. Bounded by + /// pruning in `register` when a `Drop` op for the id is enqueued — + /// see the struct-level docs for the full strategy. + shared_sources: HashSet, + streams: HashMap>, + optimizations: ExecutionPlanStore, + device: R::FusionDevice, + #[cfg(feature = "memory-checks")] + memory_checks: super::memory_checks::MemoryChecks, +} + +impl MultiStream { + pub(crate) fn new(device: R::FusionDevice) -> Self { + Self { + shared_sources: HashSet::new(), + streams: HashMap::new(), + optimizations: ExecutionPlanStore::new(), + device, + #[cfg(feature = "memory-checks")] + memory_checks: super::memory_checks::MemoryChecks::default(), + } + } + + /// Register a new tensor operation on the given `stream`. + pub(crate) fn register( + &mut self, + stream: StreamId, + repr: OperationIr, + operation: UnfusedOp, + handles: &mut HandleContainer, + ) { + // Bound `shared_sources` (see struct-level docs). When the last `FusionTensor` + // for an id is dropped, a `Drop` op is registered here. At that point no live + // `FusionTensor` holds this id, so no future `tag_shared_view` can use it as + // a source — it is safe to drop the entry immediately, without waiting for + // the queued `Drop` op to actually execute. + if let OperationIr::Drop(ir) = &repr { + self.shared_sources.remove(&ir.id); + } + + self.enqueue_operation(stream, repr, operation, handles); + + #[cfg(feature = "memory-checks")] + self.memory_checks.check(&self.streams, handles); + #[cfg(feature = "test-util")] + crate::inspect::emit_handle_snapshot(stream, handles.handle_ids().copied()); + } + + /// Set up a cross-stream alias `dst` for the foreign tensor `src` that lives on + /// `src_stream`. Called when [`FusionTensor::clone`](crate::FusionTensor::clone) + /// or [`FusionTensor::into_ir`](crate::FusionTensor::into_ir) detects that the + /// tensor's home stream is not the current stream. + /// + /// See the [`MultiStream`] struct-level docs for the full strategy. In short: + /// + /// - If `src`'s handle isn't materialised yet, drain `src_stream` so the + /// producing op runs and registers it. Skip the drain on subsequent shares + /// of the same source by remembering it in `shared_sources` *or* by + /// observing that the handle is already in the container (which covers the + /// chained-share case where `src` is itself a previously-aliased view). + /// - Then alias the backing handle under `dst`. `register_handle` clones the + /// cubecl handle (`Arc`-style), so both ids share refcount on the buffer + /// until each side's own `Drop` op runs. + pub fn tag_shared_view( + &mut self, + src_stream: StreamId, + src: TensorId, + dst: TensorId, + handles: &mut HandleContainer, + ) { + // Drain only when neither short-circuit applies: `shared_sources` records ids + // we already drained for, and a `Some` handle means `src` is materialised + // (e.g., it was itself set up by an earlier `tag_shared_view` call). We + // record `src` only when we actually drain — the handle-existence path is + // naturally idempotent on later calls. + if !self.shared_sources.contains(&src) && handles.get_handle_ref(&src).is_none() { + self.shared_sources.insert(src); + self.drain(handles, src_stream); + } + + if let Some(handle) = handles.get_handle_ref(&src) { + // Not a bare `clone()`: remote backends need a fresh server-side handle over the same + // buffer so consuming one alias doesn't free it for the other stream. Local backends' + // `alias_handle` default *is* `clone()`. See `FusionRuntime::alias_handle`. + let alias = R::alias_handle(handle); + handles.register_handle(dst, alias); + } + } + + /// Enqueue an operation on the queue for `stream` and run the lazy processor. + fn enqueue_operation( + &mut self, + stream: StreamId, + repr: OperationIr, + operation: UnfusedOp, + handles: &mut HandleContainer, + ) { + let s = self + .streams + .entry(stream) + .or_insert_with(|| Stream::new(self.device.clone())); + s.queue.add(repr, operation); + + let len_before = s.queue.global.len(); + s.processor.process( + Segment::new(&mut s.queue, handles, stream), + &mut self.optimizations, + ExecutionMode::Lazy, + ); + let len_after = s.queue.global.len(); + s.cursor += (len_before - len_after) as u64; + } + + /// Mark a tensor as read. + #[allow(unused_variables)] + pub fn mark_read( + &mut self, + id: StreamId, + ir: &burn_ir::TensorIr, + handles: &HandleContainer, + ) { + if !matches!(ir.status, burn_ir::TensorStatus::ReadWrite) { + return; + }; + + let stream = match self.streams.get_mut(&id) { + Some(val) => val, + None => return, + }; + + stream.queue.variables.remove(&ir.id); + + if stream.queue.variables.is_empty() { + self.streams.remove(&id); + } + + #[cfg(feature = "memory-checks")] + self.memory_checks.check(&self.streams, handles); + #[cfg(feature = "test-util")] + crate::inspect::emit_handle_snapshot(id, handles.handle_ids().copied()); + } + + /// Drain a stream. + pub fn drain(&mut self, handles: &mut HandleContainer, id: StreamId) { + id.executes(|| { + if let Some(stream) = self.streams.get_mut(&id) { + let num_executed = stream.queue.global.len(); + stream.processor.process( + Segment::new(&mut stream.queue, handles, id), + &mut self.optimizations, + ExecutionMode::Sync, + ); + stream.cursor += num_executed as u64; + } + }); + #[cfg(feature = "test-util")] + crate::inspect::emit_handle_snapshot(id, handles.handle_ids().copied()); + } +} + +pub(crate) struct Stream { + pub(crate) queue: OperationQueue, + processor: Processor, + pub(crate) cursor: u64, +} + +#[derive(new)] +struct Segment<'a, R: FusionRuntime> { + queue: &'a mut OperationQueue, + handles: &'a mut HandleContainer, + id: StreamId, +} + +impl StreamSegment for Segment<'_, R> { + fn operations(&self) -> &[OperationIr] { + &self.queue.relative + } + + fn execute(&mut self, id: ExecutionPlanId, store: &mut ExecutionPlanStore) { + self.queue.execute(id, self.handles, store, self.id) + } + + fn execute_unfused(&mut self, optimization: BlockOptimization) { + self.queue + .execute_unfused(optimization, self.handles, self.id) + } +} + +impl Stream { + fn new(device: R::FusionDevice) -> Self { + Self { + processor: Processor::new(R::fusers(device)), + queue: OperationQueue::new(), + cursor: 0, + } + } +} diff --git a/crates/burn-fusion/src/stream/queue/base.rs b/crates/burn-fusion/src/stream/queue/base.rs new file mode 100644 index 0000000..b6c573c --- /dev/null +++ b/crates/burn-fusion/src/stream/queue/base.rs @@ -0,0 +1,87 @@ +use crate::stream::{OperationConverter, RelativeOps}; +use crate::{FusionRuntime, UnfusedOp}; +use burn_ir::{OperationIr, TensorId, TensorStatus}; + +use hashbrown::HashMap; + +/// A growing list of [tensor operation descriptions](OperationIr). +/// +/// Every queue is associated with a single [`StreamId`](super::super::StreamId) — every tensor it +/// references is local to that stream (cross-stream sharing is handled out-of-band by +/// [`MultiStream::tag_shared_view`](super::super::MultiStream::tag_shared_view), which aliases +/// the source handle under a fresh local tensor id before the op is enqueued). +pub struct OperationQueue { + /// List of operation descriptions. These contain the exact tensor IDs + /// and shapes so that kernels can be run correctly. + /// + /// The length of this list is the same as the length of the `operations` list. + pub(crate) global: Vec, + /// List of operation descriptions. The tensor IDs and shapes are relative + /// because we don't need to know the exact values, but they are sufficient to + /// determine which operations can be fused. + pub(crate) relative: Vec, + pub(crate) converter: OperationConverter, + pub(crate) operations: Vec>, + pub(crate) variables: HashMap, +} + +impl Default for OperationQueue { + fn default() -> Self { + Self::new() + } +} + +impl OperationQueue { + /// Create a new empty queue. + pub fn new() -> Self { + Self { + global: Vec::new(), + relative: Vec::new(), + converter: OperationConverter::default(), + operations: Vec::new(), + variables: HashMap::new(), + } + } + + /// Add a new tensor operation to the queue. + /// + /// The new [operation intermediate representation](OperationIr) will be converted to a local + /// representation that can be reused when the same pattern emerge in different but similar + /// scenario, so that the same optimization can be used. + pub fn add(&mut self, global: OperationIr, operation: UnfusedOp) { + for node in global.nodes() { + self.variables.insert(node.id, node.status); + } + let relative = global.to_relative(&mut self.converter); + self.relative.push(relative); + self.global.push(global); + self.operations.push(operation); + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use burn_backend::StreamId; + + #[test] + fn stream_id_from_different_threads() { + let current = StreamId::current(); + + let thread1 = std::thread::spawn(|| (StreamId::current(), StreamId::current())); + let thread2 = std::thread::spawn(StreamId::current); + + let (stream_1, stream_11) = thread1.join().unwrap(); + let stream_2 = thread2.join().unwrap(); + + assert_ne!(current, stream_1, "Should be different from thread 1"); + assert_ne!(current, stream_2, "Should be different from thread 2"); + assert_ne!( + stream_1, stream_2, + "Should be different from different threads" + ); + assert_eq!( + stream_1, stream_11, + "Should be the same, since same thread." + ); + } +} diff --git a/crates/burn-fusion/src/stream/queue/execution.rs b/crates/burn-fusion/src/stream/queue/execution.rs new file mode 100644 index 0000000..b7d46c9 --- /dev/null +++ b/crates/burn-fusion/src/stream/queue/execution.rs @@ -0,0 +1,125 @@ +use burn_ir::{HandleContainer, TensorStatus}; + +use crate::{ + FusionRuntime, UnfusedOp, + search::BlockOptimization, + stream::{ + Context, ContextGuard, OperationConverter, OrderedExecution, RelativeOps, StreamId, + execution::log_execution_table, + store::{ExecutionPlanId, ExecutionPlanStore, ExecutionStrategy}, + }, +}; + +use super::OperationQueue; + +impl OperationQueue { + /// Execute the queue partially following the execution strategy from the plan. + pub(crate) fn execute( + &mut self, + id: ExecutionPlanId, + handles: &mut HandleContainer, + store: &mut ExecutionPlanStore, + stream_id: StreamId, + ) { + let plan = store.get_mut_unchecked(id); + self.execute_block_optimization(&mut plan.optimization, handles, stream_id); + } + + /// Execute the queue with a one-off [`BlockOptimization`] that isn't stored in the cache. + /// + /// Used when fusion exploration is capped (see [`Explorer`](crate::stream::execution)): a + /// cache-missing segment runs unfused without paying the optimizer cost or growing the store. + pub(crate) fn execute_unfused( + &mut self, + mut optimization: BlockOptimization, + handles: &mut HandleContainer, + stream_id: StreamId, + ) { + self.execute_block_optimization(&mut optimization, handles, stream_id); + } + + fn execute_block_optimization( + &mut self, + step: &mut BlockOptimization, + handles: &mut HandleContainer, + stream_id: StreamId, + ) { + log_execution_table(stream_id, &step.strategy, &self.global); + + let mut operations = Vec::new(); + core::mem::swap(&mut operations, &mut self.operations); + + let (operations, num_drained) = + run_strategy(step, &mut self.converter, handles, operations); + + self.operations = operations; + self.drain_queue(num_drained, handles); + } + + /// Bookkeeping after executing `num_drained` operations from the queue. + fn drain_queue(&mut self, num_drained: usize, handles: &mut HandleContainer) { + self.global[0..num_drained] + .iter() + .flat_map(|desc| desc.nodes()) + .for_each(|tensor| { + if tensor.status == TensorStatus::ReadWrite { + self.variables.remove(&tensor.id); + }; + R::free_handle(handles, tensor) + }); + + self.global.drain(0..num_drained); + + self.reset_relative(); + } + + fn reset_relative(&mut self) { + self.relative.clear(); + self.converter.clear(); + + for node in self.global.iter() { + let relative = node.to_relative(&mut self.converter); + self.relative.push(relative); + } + } +} + +/// Drive one block's execution strategy. +/// +/// Wraps the converter's per-block fields and the handle container into a single owned +/// [`Context`] via [`ContextGuard`] for the duration of this call, then threads `&mut Context` +/// through the recursive strategy walk. Operations-only strategies just grab +/// `&mut ctx.handles`; optimization strategies hand `&mut ctx` to the fused op. +fn run_strategy( + optimization: &mut BlockOptimization, + converter: &mut OperationConverter, + handles: &mut HandleContainer, + operations: Vec>, +) -> (Vec>, usize) { + let mut execution = OrderedExecution::new(operations); + { + let mut guard = ContextGuard::new(converter, handles); + execute_strategy::(&mut optimization.strategy, &mut guard, &mut execution); + } + execution.finish() +} + +fn execute_strategy( + strategy: &mut ExecutionStrategy, + context: &mut Context, + execution: &mut OrderedExecution, +) { + match strategy { + ExecutionStrategy::Optimization { ordering, opt, .. } => { + execution.execute_optimization(opt, context, ordering.clone()); + } + ExecutionStrategy::Operations { ordering } => { + execution.execute_operations(&mut context.handles, ordering); + } + ExecutionStrategy::Composed(items) => { + for item in items.iter_mut() { + execute_strategy::(item, context, execution); + } + } + } +} diff --git a/crates/burn-fusion/src/stream/queue/mod.rs b/crates/burn-fusion/src/stream/queue/mod.rs new file mode 100644 index 0000000..450cba0 --- /dev/null +++ b/crates/burn-fusion/src/stream/queue/mod.rs @@ -0,0 +1,4 @@ +mod base; +mod execution; + +pub use base::*; diff --git a/crates/burn-fusion/src/stream/store/base.rs b/crates/burn-fusion/src/stream/store/base.rs new file mode 100644 index 0000000..7de0308 --- /dev/null +++ b/crates/burn-fusion/src/stream/store/base.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use crate::search::BlockOptimization; + +use super::{ExecutionPlanIndex, InsertQuery, SearchQuery}; +use burn_ir::OperationIr; +use serde::{Deserialize, Serialize}; + +/// The store that contains all explorations done on a device. +#[derive(Default)] +pub(crate) struct ExecutionPlanStore { + plans: Vec>, + index: ExecutionPlanIndex, +} + +/// How a list of operations should be executed. +#[derive(PartialEq, Debug, Clone)] +pub(crate) enum ExecutionStrategy { + /// An optimization was found, and therefore should be executed. + Optimization { + opt: O, + ordering: Arc>, + score: u64, + }, + /// No optimization was found, each operation should be executed individually. + Operations { ordering: Arc> }, + /// A composition of multiple execution strategies. + Composed(Vec>), +} + +/// The trigger that indicates when to stop exploring. +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub(crate) enum ExecutionTrigger { + OnOperations(Vec), + OnSync, + Always, +} + +/// The unique identifier for an exploration that was executed. +pub(crate) type ExecutionPlanId = usize; + +/// The outcome of an exploration that can be stored. +#[derive(Debug)] +pub(crate) struct ExecutionPlan { + /// The operations on which the exploration is related to. + pub(crate) operations: Vec, + /// The criteria that signal when this plan should be executed. Only one trigger is necessary. + pub(crate) triggers: Vec, + /// The optimization that should be used when executing this plan. + pub(crate) optimization: BlockOptimization, +} + +impl ExecutionPlanStore { + pub fn new() -> Self { + Self { + plans: Vec::new(), + index: ExecutionPlanIndex::default(), + } + } + + pub fn find(&self, query: SearchQuery<'_>) -> Vec { + self.index.find(query) + } + + pub fn add(&mut self, exploration: ExecutionPlan) -> ExecutionPlanId { + if exploration.operations.is_empty() { + panic!("Can't add an empty optimization."); + } + + let id = self.plans.len(); + + self.index.insert(InsertQuery::NewPlan { + operations: &exploration.operations, + id, + }); + + self.plans.push(exploration); + + id + } + + pub fn get_mut_unchecked(&mut self, id: ExecutionPlanId) -> &mut ExecutionPlan { + &mut self.plans[id] + } + + pub fn get_unchecked(&self, id: ExecutionPlanId) -> &ExecutionPlan { + &self.plans[id] + } + + /// Add a new end condition for an optimization. + pub fn add_trigger(&mut self, id: ExecutionPlanId, trigger: ExecutionTrigger) { + let criteria = &mut self.plans[id].triggers; + + if !criteria.contains(&trigger) { + criteria.push(trigger); + } + } +} diff --git a/crates/burn-fusion/src/stream/store/index.rs b/crates/burn-fusion/src/stream/store/index.rs new file mode 100644 index 0000000..014639c --- /dev/null +++ b/crates/burn-fusion/src/stream/store/index.rs @@ -0,0 +1,296 @@ +use crate::stream::store::ExecutionPlanId; +use burn_ir::OperationIr; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, +}; + +/// Index used to search optimizations. +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct ExecutionPlanIndex { + /// We can't use `HashMap>` since `OperationIr` + /// doesn't implement [`Eq`](core::cmp::Eq). + /// + /// `OperationIr` can't implement `Eq` since float types don't implement it. + /// + /// We rely instead on [`PartialEq`](core::cmp::PartialEq) to manually handle hash collisions. + /// This is OK because we use `relative` operations where any scalar values are set to zeros, + /// see [`RelativeStreamConverter`](crate::stream::RelativeStreamConverter). + /// + /// Map from the hash of the `OperationIr` to a list of `(OperationIr, index)` pairs, + /// where `index` is the index of all the execution plans that start with the `OperationIr` + /// in the `starters` list. + mapping: HashMap>, + starters: Vec>, +} + +pub enum SearchQuery<'a> { + PlansStartingWith(&'a OperationIr), +} + +pub enum InsertQuery<'a> { + NewPlan { + operations: &'a [OperationIr], + id: ExecutionPlanId, + }, +} + +impl ExecutionPlanIndex { + /// Search optimizations with the given [query](SearchQuery). + pub fn find(&self, query: SearchQuery<'_>) -> Vec { + match query { + SearchQuery::PlansStartingWith(ops) => self.find_starting_with(ops), + } + } + + /// Register a new optimization with the given [query](InsertQuery). + pub fn insert(&mut self, query: InsertQuery<'_>) { + match query { + InsertQuery::NewPlan { operations, id } => { + if let Some(operation) = operations.first() { + self.insert_new_operation(operation, id) + } + } + } + } + + /// Find execution plans starting with the `OperationIr` + fn find_starting_with(&self, operation: &OperationIr) -> Vec { + let key = self.operation_key(operation); + let values = match self.mapping.get(&key) { + Some(val) => val, + None => return Vec::new(), + }; + + if values.is_empty() { + return Vec::new(); + } + + let (_, index) = match values.iter().find(|value| &value.0 == operation) { + Some(val) => val, + None => return Vec::new(), + }; + + match self.starters.get(*index) { + Some(value) => value.clone(), + None => Vec::new(), + } + } + + /// Update the index for an execution plan starting with operation `ops` + fn insert_new_operation(&mut self, ops: &OperationIr, new_id: ExecutionPlanId) { + let key = self.operation_key(ops); + let values = match self.mapping.get_mut(&key) { + Some(val) => val, + None => { + // New starter ops. + let index = self.starters.len(); + self.starters.push(vec![new_id]); + self.mapping.insert(key, vec![(ops.clone(), index)]); + + return; + } + }; + let (_, index) = match values.iter_mut().find(|value| &value.0 == ops) { + Some(val) => val, + None => { + // New with hash collision. + let index = self.starters.len(); + self.starters.push(vec![new_id]); + values.push((ops.clone(), index)); + return; + } + }; + + // New optimization for an existing starter. + self.starters + .get_mut(*index) + .expect("Should exist") + .push(new_id); + } + + // Hash the value of the first operation in a list. + fn operation_key(&self, ops: &OperationIr) -> u64 { + let mut hasher = DefaultHasher::new(); + ops.hash(&mut hasher); + hasher.finish() + } +} + +#[cfg(test)] +mod tests { + use burn_backend::{DType, Shape}; + use burn_ir::{ + BinaryOpIr, NumericOperationIr, ScalarIr, ScalarOpIr, TensorId, TensorIr, TensorStatus, + }; + + use super::*; + + #[test] + fn should_find_optimization_id_based_on_tensor_ops() { + let mut index = ExecutionPlanIndex::default(); + let stream_1 = [ops_1()]; + let optimization_id_1 = 0; + + index.insert(InsertQuery::NewPlan { + operations: &stream_1, + id: optimization_id_1, + }); + + let found = index.find(SearchQuery::PlansStartingWith(&stream_1[0])); + + assert_eq!(found, vec![optimization_id_1]); + } + + #[test] + fn should_support_multiple_optimization_ids_with_same_starting_ops() { + let mut index = ExecutionPlanIndex::default(); + let stream_1 = [ops_1(), ops_2(), ops_1()]; + let stream_2 = [ops_1(), ops_1(), ops_2()]; + let optimization_id_1 = 0; + let optimization_id_2 = 1; + + index.insert(InsertQuery::NewPlan { + operations: &stream_1, + id: optimization_id_1, + }); + index.insert(InsertQuery::NewPlan { + operations: &stream_2, + id: optimization_id_2, + }); + + let found = index.find(SearchQuery::PlansStartingWith(&stream_1[0])); + + assert_eq!(found, vec![optimization_id_1, optimization_id_2]); + } + + #[test] + fn should_only_find_optimization_with_correct_starting_ops() { + let mut index = ExecutionPlanIndex::default(); + let stream_1 = [ops_1(), ops_1()]; + let stream_2 = [ops_2(), ops_1()]; + let optimization_id_1 = 0; + let optimization_id_2 = 1; + + index.insert(InsertQuery::NewPlan { + operations: &stream_1, + id: optimization_id_1, + }); + index.insert(InsertQuery::NewPlan { + operations: &stream_2, + id: optimization_id_2, + }); + + let found = index.find(SearchQuery::PlansStartingWith(&stream_1[0])); + + assert_eq!(found, vec![optimization_id_1]); + } + + #[test] + fn should_handle_hash_collisions() { + let mut index = ExecutionPlanIndex::default(); + let stream_1 = [ops_1(), ops_1()]; + let stream_2 = [ops_3(), ops_1()]; + let optimization_id_1 = 0; + let optimization_id_2 = 1; + + let stream_1_key = index.operation_key(&stream_1[0]); + let stream_2_key = index.operation_key(&stream_2[0]); + + assert_ne!( + stream_1_key, stream_2_key, + "Ops 1 and Ops 3 should not have the same hash" + ); // ops 1 and 3 have different variants, so the hash differs + assert_ne!( + stream_1[0], stream_2[0], + "Ops 1 and Ops 3 should be different." + ); + + index.insert(InsertQuery::NewPlan { + operations: &stream_1, + id: optimization_id_1, + }); + index.insert(InsertQuery::NewPlan { + operations: &stream_2, + id: optimization_id_2, + }); + + let found = index.find(SearchQuery::PlansStartingWith(&stream_1[0])); + + assert_eq!(found, vec![optimization_id_1]); + } + + fn ops_1() -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::Add(BinaryOpIr { + lhs: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + rhs: TensorIr { + id: TensorId::new(1), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + out: TensorIr { + id: TensorId::new(2), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) + } + + fn ops_2() -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::AddScalar(ScalarOpIr { + lhs: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + rhs: ScalarIr::Float(5.0), + out: TensorIr { + id: TensorId::new(2), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) + } + + fn ops_3() -> OperationIr { + OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::Sub(BinaryOpIr { + lhs: TensorIr { + id: TensorId::new(0), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + rhs: TensorIr { + id: TensorId::new(1), + shape: Shape::new([32, 32]), + status: TensorStatus::ReadOnly, + dtype: DType::F32, + }, + out: TensorIr { + id: TensorId::new(2), + shape: Shape::new([32, 32]), + status: TensorStatus::NotInit, + dtype: DType::F32, + }, + }), + ) + } +} diff --git a/crates/burn-fusion/src/stream/store/mod.rs b/crates/burn-fusion/src/stream/store/mod.rs new file mode 100644 index 0000000..f14aff0 --- /dev/null +++ b/crates/burn-fusion/src/stream/store/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod index; + +pub(crate) use base::*; +pub(super) use index::*; diff --git a/crates/burn-fusion/src/tensor.rs b/crates/burn-fusion/src/tensor.rs new file mode 100644 index 0000000..cc41e0d --- /dev/null +++ b/crates/burn-fusion/src/tensor.rs @@ -0,0 +1,266 @@ +use crate::{ + Client, FusionBackend, FusionRuntime, + stream::{Operation, StreamId}, +}; +use burn_backend::{DType, ExecutionError, Shape, TensorData, TensorMetadata}; +use burn_ir::{OperationIr, TensorId, TensorIr, TensorStatus}; +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +/// Tensor primitive for the [fusion backend](crate::FusionBackend) for all kind. +pub struct FusionTensor { + /// Tensor id. + pub id: TensorId, + /// The shape of the tensor. + pub shape: Shape, + /// The fusion client. + pub client: Client, + /// The datatype of the tensor. + pub dtype: DType, + /// The current stream id this tensor is on. + pub stream: StreamId, + pub(crate) count: Arc, +} + +impl Clone for FusionTensor { + fn clone(&self) -> Self { + let current = StreamId::current(); + if self.stream != current { + return self.shared_view(current); + } + + self.count.fetch_add(1, Ordering::Acquire); + + Self { + id: self.id, + shape: self.shape.clone(), + client: self.client.clone(), + dtype: self.dtype, + stream: self.stream, + count: self.count.clone(), + } + } +} + +impl core::fmt::Debug for FusionTensor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + format!( + "{{ id: {:?}, shape: {:?}, device: {:?} }}", + self.id, + self.shape, + self.client.device(), + ) + .as_str(), + ) + } +} + +impl TensorMetadata for FusionTensor { + type Device = R::FusionDevice; + fn dtype(&self) -> DType { + self.dtype + } + + fn shape(&self) -> Shape { + self.shape.clone() + } + + fn rank(&self) -> usize { + self.shape.num_dims() + } + + fn device(&self) -> Self::Device { + self.client.device().clone() + } + + fn can_mut(&self) -> bool { + // Same rule as `status` at drain time: a handle shared on its stream + // (count > 1) is read-only, a unique one is read-write and the fused + // kernel may write its buffer in place. + matches!( + self.status(self.count.load(Ordering::Acquire)), + TensorStatus::ReadWrite + ) + } +} + +impl FusionTensor { + pub(crate) fn new( + id: TensorId, + shape: Shape, + dtype: DType, + client: Client, + stream: StreamId, + ) -> Self { + Self { + id, + shape, + client, + dtype, + stream, + count: Arc::new(AtomicU32::new(1)), + } + } + + fn status(&self, count: u32) -> TensorStatus { + if count <= 1 { + TensorStatus::ReadWrite + } else { + TensorStatus::ReadOnly + } + } + + /// Intermediate representation to be used when using an uninitialized tensor as output. + pub fn to_ir_out(&self) -> TensorIr { + TensorIr { + status: TensorStatus::NotInit, + shape: self.shape.clone(), + id: self.id, + dtype: self.dtype, + } + } + + /// Intermediate representation to be used when using an initialized tensor used as input. + pub fn into_ir(mut self) -> TensorIr { + let current = StreamId::current(); + if self.stream != current { + self = self.shared_view(current); + } + + let count = self.count.load(Ordering::Acquire); + let status = self.status(count); + + let mut shape_out = Shape::from(Vec::::new()); + core::mem::swap(&mut self.shape, &mut shape_out); + + if let TensorStatus::ReadWrite = status { + // Avoids an unwanted drop on the same thread. + // + // Since `drop` is called after `into_ir`, we must not register a drop if the tensor + // was consumed with a `ReadWrite` status. + self.count.fetch_add(1, Ordering::Acquire); + } + + TensorIr { + status, + shape: shape_out, + id: self.id, + dtype: self.dtype, + } + } + + /// Create a fresh `FusionTensor` on `current` that aliases the same backing + /// handle as `self`. Used by [`Clone`] and [`Self::into_ir`] when the tensor is + /// crossing stream boundaries — the rest of the pipeline only ever sees ids + /// whose home stream is the calling stream. + /// + /// The cross-stream coordination (draining the source stream so the handle + /// exists, then aliasing it under a fresh id) is done by + /// [`MultiStream::tag_shared_view`](crate::stream::MultiStream::tag_shared_view). + /// See that type's docs for the full strategy — how shares are tagged, how the + /// buffer's lifetime is managed across the two sides, and why a single drain + /// per source is enough. + fn shared_view(&self, current: StreamId) -> Self { + let new_id = self.client.create_empty_handle(); + + self.client.tag_shared_view(self.stream, self.id, new_id); + + Self::new( + new_id, + self.shape.clone(), + self.dtype, + self.client.clone(), + current, + ) + } + + pub(crate) async fn into_data(self) -> Result + where + B: FusionBackend, + { + let id = self.stream; + let client = self.client.clone(); + let desc = self.into_ir(); + client.read_tensor_float::(desc, id).await + } + + pub(crate) async fn q_into_data(self) -> Result + where + B: FusionBackend, + { + if let DType::QFloat(_scheme) = self.dtype { + let id = self.stream; + let client = self.client.clone(); + let desc = self.into_ir(); + client.read_tensor_quantized::(desc, id).await + } else { + panic!("Expected quantized float dtype, got {:?}", self.dtype) + } + } + + pub(crate) async fn int_into_data(self) -> Result + where + B: FusionBackend, + { + let id = self.stream; + let client = self.client.clone(); + let desc = self.into_ir(); + client.read_tensor_int::(desc, id).await + } + + pub(crate) async fn bool_into_data(self) -> Result + where + B: FusionBackend, + { + let id = self.stream; + let client = self.client.clone(); + let desc = self.into_ir(); + client.read_tensor_bool::(desc, id).await + } +} + +#[derive(new, Debug)] +pub(crate) struct DropOp { + pub(crate) id: TensorId, +} + +impl Operation for DropOp { + fn execute(&self, handles: &mut burn_ir::HandleContainer) { + handles.remove_handle(self.id); + } +} + +impl Drop for FusionTensor { + fn drop(&mut self) { + let count = self.count.fetch_sub(1, Ordering::Acquire); + + // Workaround to prevent segfaults when an operation panics + if std::thread::panicking() { + return; + } + + match self.status(count) { + TensorStatus::ReadWrite => { + let mut shape = Shape::from(Vec::::new()); + core::mem::swap(&mut shape, &mut self.shape); + + let ir = TensorIr { + id: self.id, + shape, + status: TensorStatus::ReadWrite, + dtype: self.dtype, + }; + + // Drop is targeted at the tensor's home stream so it runs after any pending ops + // on this id, regardless of the thread we happen to be dropping from. + self.client + .register(self.stream, OperationIr::Drop(ir), DropOp { id: self.id }); + } + TensorStatus::ReadOnly => {} + TensorStatus::NotInit => {} + } + } +} diff --git a/crates/burn-ir/Cargo.toml b/crates/burn-ir/Cargo.toml new file mode 100644 index 0000000..0248a63 --- /dev/null +++ b/crates/burn-ir/Cargo.toml @@ -0,0 +1,33 @@ +[package] +authors = ["laggui ", "nathanielsimard "] +categories = ["science"] +description = "Intermediate representation for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor"] +license.workspace = true +name = "burn-ir" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-ir" +documentation = "https://docs.rs/burn-ir" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["burn-backend/std"] +doc = ["default"] +tracing = [ + "burn-backend/tracing", +] + +[dependencies] +serde = { workspace = true } +hashbrown = { workspace = true } # no_std compatible + +burn-backend = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-ir/README.md b/crates/burn-ir/README.md new file mode 100644 index 0000000..3e26709 --- /dev/null +++ b/crates/burn-ir/README.md @@ -0,0 +1,7 @@ +# Burn Intermediate Representation + +Defines an Intermediate Representation (IR) used to represent tensors and operations. + +The abstraction over computation allows execution across different targets (e.g., remote backend). +It also enables optimization and transformation of tensor computations before execution (e.g., +operator fusion). diff --git a/crates/burn-ir/src/backend.rs b/crates/burn-ir/src/backend.rs new file mode 100644 index 0000000..b655600 --- /dev/null +++ b/crates/burn-ir/src/backend.rs @@ -0,0 +1,63 @@ +use burn_backend::{ + Backend, Shape, + tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}, +}; + +/// A tensor representation containing a reference to a tensor resource with a given shape. +#[derive(Clone)] +pub struct TensorHandle { + /// The type that can be used to point to a tensor of any kind. + pub handle: H, + /// The shape associated to the tensor. + pub shape: Shape, +} + +/// Backend extension trait that allows an existing [backend](Backend) to use the Burn tensor +/// intermediate representation for compilation purpose or other... +pub trait BackendIr: Backend { + /// The type that can be used to point to a tensor of any kind. + type Handle: Sync + Send + Clone; + + /// Convert a [handle](BackendIr::Handle) to a [float tensor](burn_backend::BackendTypes::FloatTensorPrimitive). + fn float_tensor(handle: TensorHandle) -> FloatTensor; + /// Convert a [handle](BackendIr::Handle) to an [int tensor](burn_backend::BackendTypes::IntTensorPrimitive). + fn int_tensor(handle: TensorHandle) -> IntTensor; + /// Convert a [handle](BackendIr::Handle) to a [bool tensor](burn_backend::BackendTypes::BoolTensorPrimitive). + fn bool_tensor(handle: TensorHandle) -> BoolTensor; + /// Convert a [handle](BackendIr::Handle) to a [quantized tensor](burn_backend::BackendTypes::QuantizedTensorPrimitive). + fn quantized_tensor(handle: TensorHandle) -> QuantizedTensor; + + /// Convert a [float tensor](burn_backend::BackendTypes::FloatTensorPrimitive) to a [handle](BackendIr::Handle). + fn float_tensor_handle(tensor: FloatTensor) -> Self::Handle; + /// Convert an [int tensor](burn_backend::BackendTypes::IntTensorPrimitive) to a [handle](BackendIr::Handle). + fn int_tensor_handle(tensor: IntTensor) -> Self::Handle; + /// Convert a [bool tensor](burn_backend::BackendTypes::BoolTensorPrimitive) to a [handle](BackendIr::Handle). + fn bool_tensor_handle(tensor: BoolTensor) -> Self::Handle; + /// Convert a [quantized tensor](burn_backend::BackendTypes::QuantizedTensorPrimitive) to a [handle](BackendIr::Handle). + fn quantized_tensor_handle(tensor: QuantizedTensor) -> Self::Handle; +} + +/// Handle which points to a backend tensor primitive kind. +#[derive(Clone, Debug)] +pub enum HandleKind { + /// Float tensor handle. + Float(B::FloatTensorPrimitive), + /// Int tensor handle. + Int(B::IntTensorPrimitive), + /// Bool tensor handle. + Bool(B::BoolTensorPrimitive), + /// Quantized tensor handle. + Quantized(B::QuantizedTensorPrimitive), +} + +impl HandleKind { + /// Returns the handle kind name. + pub fn name(&self) -> &str { + match self { + HandleKind::Float(_) => "float", + HandleKind::Int(_) => "int", + HandleKind::Bool(_) => "bool", + HandleKind::Quantized(_) => "quantized", + } + } +} diff --git a/crates/burn-ir/src/builder.rs b/crates/burn-ir/src/builder.rs new file mode 100644 index 0000000..6ef0f19 --- /dev/null +++ b/crates/burn-ir/src/builder.rs @@ -0,0 +1,1479 @@ +#![allow(missing_docs)] + +use alloc::vec::Vec; +use burn_backend::{ + DType, Distribution, Shape, Slice, SliceOps, calculate_matmul_output, + ops::{ + conv::{ + calculate_conv_output_shape, calculate_conv_transpose_output_shape, + calculate_pool_output_shape, + }, + unfold::calculate_unfold_shape, + }, + quantization::QuantScheme, + tensor::IndexingUpdateOp, +}; + +use crate::{ScalarIr, TensorId, TensorIr}; + +use super::operation::*; + +impl CreationOpIr { + pub fn create(shape: Shape, dtype: DType, new_id: impl FnOnce() -> TensorId) -> Self { + let out = TensorIr::uninit(new_id(), shape, dtype); + + CreationOpIr { out } + } +} + +impl InitOperationIr { + pub fn create(shape: Shape, dtype: DType, new_id: impl FnOnce() -> TensorId) -> Self { + let out = TensorIr::uninit(new_id(), shape, dtype); + + InitOperationIr { out } + } +} + +impl RandomOpIr { + pub fn create( + shape: Shape, + dtype: DType, + distribution: Distribution, + new_id: impl FnOnce() -> TensorId, + ) -> Self { + let out = TensorIr::uninit(new_id(), shape, dtype); + + RandomOpIr { out, distribution } + } +} + +impl FullOpIr { + pub fn create( + shape: Shape, + dtype: DType, + value: ScalarIr, + new_id: impl FnOnce() -> TensorId, + ) -> Self { + // TODO: check that ScalarIr dtype matches dtype? + let out = TensorIr::uninit(new_id(), shape, dtype); + + FullOpIr { out, value } + } +} + +impl CastOpIr { + pub fn create(input: TensorIr, dtype: DType, new_id: impl FnOnce() -> TensorId) -> Self { + let out = TensorIr::uninit(new_id(), input.shape.clone(), dtype); + CastOpIr { input, out } + } +} + +impl ShapeOpIr { + pub fn expand(input: TensorIr, shape: Shape, new_id: impl FnOnce() -> TensorId) -> Self { + let shape = input.shape.expand(shape).unwrap(); + Self::create(input, shape, new_id) + } + + pub fn reshape(input: TensorIr, shape: Shape, new_id: impl FnOnce() -> TensorId) -> Self { + let shape = input.shape.reshape(shape).unwrap(); + Self::create(input, shape, new_id) + } + + fn create(input: TensorIr, shape: Shape, new_id: impl FnOnce() -> TensorId) -> Self { + let out = TensorIr::uninit(new_id(), shape, input.dtype); + ShapeOpIr { input, out } + } +} + +// "Lower" specific operations into a binary or unary op representation. +// Useful when collecting inputs and outputs and don't care about the other semantics. +impl From for BinaryOpIr { + fn from(value: MatmulOpIr) -> Self { + Self { + lhs: value.lhs, + rhs: value.rhs, + out: value.out, + } + } +} + +impl From for UnaryOpIr { + fn from(value: ReduceOpIr) -> Self { + Self { + input: value.input, + out: value.out, + } + } +} + +#[derive(Debug)] +#[allow(missing_docs)] +pub enum IrError { + DTypeMismatch, +} + +fn dtype_compat(lhs: &DType, rhs: &DType) -> bool { + let lhs_qfloat = matches!(lhs, DType::QFloat(_)); + let rhs_qfloat = matches!(rhs, DType::QFloat(_)); + if lhs_qfloat && (rhs_qfloat || rhs.is_float()) + || lhs.is_float() && (rhs_qfloat || rhs.is_float()) + { + true + } else { + lhs == rhs + } +} + +fn output_check<'a, I>(inputs: I, compat: impl Fn(&DType, &DType) -> bool) -> Result +where + I: IntoIterator, +{ + let mut iter = inputs.into_iter(); + let first = iter.next().unwrap(); + for d in iter { + if !compat(first, d) { + return Err(IrError::DTypeMismatch); + } + } + Ok(*first) +} + +fn output_dtype<'a, I: IntoIterator>(inputs: I) -> Result { + output_check(inputs, |a, b| a == b) +} + +fn output_dtype_mixed<'a, I: IntoIterator>(inputs: I) -> Result { + output_check(inputs, dtype_compat) +} + +/// Macro to implement `create` constructors for operations with a single output. +/// +/// Supports shape and dtype validation. +macro_rules! impl_ir_create { + // Leaf: the default `create`, inferring the output dtype from the inputs. + (@create_fn $op:ident { $( $field:ident : $ty:ty ),* $(,)? } , $shape:expr, $dtype:expr) => { + #[doc = "Create a new operation IR from the given inputs."] + #[doc = "`new_id` should generate a unique `TensorId` for the uninitialized output tensor."] + #[allow(clippy::too_many_arguments)] + pub fn create($( $field : $ty ),*, new_id: impl FnOnce() -> crate::TensorId) -> $op { + let shape = $shape; + let dtype = $dtype; + let out = TensorIr::uninit(new_id(), shape, dtype); + $op { $( $field ),*, out } + } + }; + + // Leaf: a single additional constructor that takes an explicit output dtype. + (@extra_fn $op:ident { $( $field:ident : $ty:ty ),* $(,)? } , $shape:expr, $dtype:expr, $fn_name:ident ( $extra:ident : $extra_ty:ty )) => { + #[doc = "Create a new operation IR from the given inputs and the given output dtype."] + #[allow(clippy::too_many_arguments)] + pub fn $fn_name($( $field : $ty ),*, $extra: $extra_ty, new_id: impl FnOnce() -> crate::TensorId) -> $op { + let shape = $shape; + let _ = $dtype; // still validates dtype if needed + let out = TensorIr::uninit(new_id(), shape, $extra); + $op { $( $field ),*, out } + } + }; + + // Recursively emit each additional constructor in its own `impl` block. The field list is + // forwarded as an opaque token tree (`$fields`) so it is not iterated alongside the + // constructor list (the two repeat independently). + (@extras $op:ident $fields:tt, $shape:expr, $dtype:expr, $fn_name:ident ( $extra:ident : $extra_ty:ty ) $(, $rest_fn:ident ( $rest_extra:ident : $rest_extra_ty:ty ) )* $(,)?) => { + impl $op { + impl_ir_create!(@extra_fn $op $fields, $shape, $dtype, $fn_name ( $extra : $extra_ty )); + } + impl_ir_create!(@extras $op $fields, $shape, $dtype, $( $rest_fn ( $rest_extra : $rest_extra_ty ) ),*); + }; + (@extras $op:ident $fields:tt, $shape:expr, $dtype:expr, $(,)?) => {}; + + // Case: simple op, single `create` + ( + $op:ident $fields:tt, + shape = $shape:expr, + dtype = $dtype:expr $(,)? + ) => { + impl $op { + impl_ir_create!(@create_fn $op $fields, $shape, $dtype); + } + }; + + // Case: op with one or more additional constructors that accept an explicit output dtype + ( + $op:ident $fields:tt, + shape = $shape:expr, + dtype = $dtype:expr, + $fn_name:ident ( $extra:ident : $extra_ty:ty ) + $(, $rest_fn:ident ( $rest_extra:ident : $rest_extra_ty:ty ) )* $(,)? + ) => { + impl $op { + impl_ir_create!(@create_fn $op $fields, $shape, $dtype); + } + impl_ir_create!(@extras $op $fields, $shape, $dtype, $fn_name ( $extra : $extra_ty ) $(, $rest_fn ( $rest_extra : $rest_extra_ty ) )*); + }; +} + +impl_ir_create!( + UnaryOpIr { input: TensorIr }, + shape = input.shape.clone(), + dtype = input.dtype, + // Additional constructor for unary comparisons + create_comparison(bool_dtype: DType) +); + +impl_ir_create!( + BinaryOpIr { + lhs: TensorIr, + rhs: TensorIr + }, + shape = lhs.shape.broadcast(&rhs.shape).unwrap(), + dtype = output_dtype([&lhs.dtype, &rhs.dtype]).unwrap(), + // Additional constructor for binary comparisons + create_comparison(bool_dtype: DType) +); + +impl_ir_create!( + ScalarOpIr { + lhs: TensorIr, + rhs: ScalarIr + }, + shape = lhs.shape.clone(), + dtype = lhs.dtype, + // Additional constructor for scalar comparisons + create_comparison(bool_dtype: DType) +); + +impl_ir_create!( + MatmulOpIr { + lhs: TensorIr, + rhs: TensorIr + }, + shape = calculate_matmul_output(&lhs.shape, &rhs.shape).unwrap(), + dtype = output_dtype_mixed([&lhs.dtype, &rhs.dtype]).unwrap(), + // Additional constructor for mixed dtypes + create_mixed(out_dtype: DType) +); + +impl_ir_create!( + SwapDimsOpIr { + input: TensorIr, + dim1: usize, + dim2: usize + }, + shape = input.shape.clone().swapped(dim1, dim2).unwrap(), + dtype = input.dtype +); + +impl_ir_create!( + PermuteOpIr { input: TensorIr, axes: Vec }, + shape = input.shape.clone().permuted(&axes).unwrap(), + dtype = input.dtype +); + +impl_ir_create!( + RepeatDimOpIr { + tensor: TensorIr, + dim: usize, + times: usize + }, + shape = tensor.shape.clone().repeat(dim, times).unwrap(), + dtype = tensor.dtype +); + +impl_ir_create!( + FlipOpIr { input: TensorIr, axes: Vec }, + shape = input.shape.clone(), // TODO: check if axes are within the tensor dimensions + dtype = input.dtype +); + +impl_ir_create!( + CatOpIr { tensors: Vec, dim: usize }, + shape = Shape::cat(tensors.iter().map(|t| &t.shape), dim).unwrap(), + dtype = output_dtype(tensors.iter().map(|t| &t.dtype)).unwrap() +); + +impl_ir_create!( + AllReduceOpIr { + tensor: TensorIr, + op: burn_backend::distributed::ReduceOperation, + device_ids: Vec + }, + shape = tensor.shape.clone(), + dtype = tensor.dtype +); + +impl_ir_create!( + GatherOpIr { + tensor: TensorIr, + dim: usize, + indices: TensorIr + }, + shape = indices.shape.clone(), // TODO: check dims compat between tensor and indices + dtype = tensor.dtype +); + +impl_ir_create!( + ScatterOpIr { + tensor: TensorIr, + dim: usize, + indices: TensorIr, + value: TensorIr, + update: IndexingUpdateOp + }, + shape = tensor.shape.clone(), // TODO: check dims compat between tensor and indices + dtype = output_dtype([&tensor.dtype, &value.dtype]).unwrap() +); + +impl_ir_create!( + ScatterNdOpIr { + data: TensorIr, + indices: TensorIr, + values: TensorIr, + reduction: IndexingUpdateOp + }, + shape = data.shape.clone(), + dtype = output_dtype([&data.dtype, &values.dtype]).unwrap() +); + +impl GatherNdOpIr { + /// Create a new GatherNd IR operation. + pub fn create( + data: TensorIr, + indices: TensorIr, + new_id: impl FnOnce() -> crate::TensorId, + ) -> Self { + let m = indices.shape.num_dims(); + let k = indices.shape[m - 1]; + let mut dims = indices.shape.as_slice()[..m - 1].to_vec(); + dims.extend_from_slice(&data.shape.as_slice()[k..]); + let shape = Shape::from(dims); + let dtype = data.dtype; + let out = TensorIr::uninit(new_id(), shape, dtype); + GatherNdOpIr { data, indices, out } + } +} + +impl_ir_create!( + ReduceOpIr { input: TensorIr }, + shape = [1].into(), + dtype = input.dtype, + // Additional constructor for reduce-all/reduce-any (bool output) + create_bool(bool_dtype: DType) +); + +fn reduce_output_shape(mut output_shape: Shape, axis: usize, accumulator_len: usize) -> Shape { + assert!(output_shape.rank() > axis); + output_shape[axis] = accumulator_len; + output_shape +} + +impl_ir_create!( + ReduceDimOpIr { + input: TensorIr, + axis: usize, + accumulator_len: usize, + }, + shape = reduce_output_shape(input.shape.clone(), axis, accumulator_len), + dtype = input.dtype, + // Additional constructor for argument reduction + create_arg(ind_dtype: DType), + // Additional constructor for reduce-all/reduce-any along a dim (bool output) + create_bool(bool_dtype: DType) +); + +impl_ir_create!( + DimOpIr { + input: TensorIr, + axis: usize + }, + shape = input.shape.clone(), // TODO: check dims within rank + dtype = input.dtype +); + +impl_ir_create!( + SelectOpIr { + tensor: TensorIr, + dim: usize, + indices: TensorIr + }, + // TODO: shape.select? + shape = { + let mut s = tensor.shape.clone(); + s[dim] = indices.shape[0]; + s + }, + dtype = tensor.dtype +); + +impl_ir_create!( + SelectAssignOpIr { + tensor: TensorIr, + dim: usize, + indices: TensorIr, + value: TensorIr, + update: IndexingUpdateOp + }, + // TODO: check value and indices shape match for dim + shape = tensor.shape.clone(), + dtype = output_dtype([&tensor.dtype, &value.dtype]).unwrap() +); + +impl_ir_create!( + SliceOpIr { + tensor: TensorIr, + ranges: Vec, + }, + shape = tensor.shape.clone().slice(&ranges).unwrap(), + dtype = tensor.dtype +); + +impl_ir_create!( + SliceAssignOpIr { + tensor: TensorIr, + ranges: Vec, + value: TensorIr + }, + // TODO: check slice and value number of elements match + shape = tensor.shape.clone(), + dtype = output_dtype([&tensor.dtype, &value.dtype]).unwrap() +); + +impl_ir_create!( + MaskWhereOpIr { + tensor: TensorIr, + mask: TensorIr, + value: TensorIr + }, + shape = Shape::broadcast_many([&tensor.shape, &mask.shape, &value.shape]).unwrap(), + dtype = output_dtype([&tensor.dtype, &value.dtype]).unwrap() +); + +impl_ir_create!( + MaskFillOpIr { + tensor: TensorIr, + mask: TensorIr, + value: ScalarIr + }, + shape = tensor.shape.broadcast(&mask.shape).unwrap(), + dtype = tensor.dtype +); + +impl_ir_create!( + ClampOpIr { + tensor: TensorIr, + min: ScalarIr, + max: ScalarIr + }, + shape = tensor.shape.clone(), + dtype = tensor.dtype +); + +impl_ir_create!( + HardSigmoidOpIr { + tensor: TensorIr, + alpha: ScalarIr, + beta: ScalarIr + }, + shape = tensor.shape.clone(), + dtype = tensor.dtype +); + +impl_ir_create!( + SortOpIr { + input: TensorIr, + dim: usize, + descending: bool + }, + shape = input.shape.clone(), + dtype = input.dtype, + // Additional constructor for argsort (output is the indices tensor) + create_arg(ind_dtype: DType) +); + +impl SortWithIndicesOpIr { + /// Create a sort-with-indices IR. + pub fn create( + input: TensorIr, + dim: usize, + descending: bool, + indices_dtype: DType, + mut new_id: impl FnMut() -> TensorId, + ) -> Self { + let shape = input.shape.clone(); + let dtype = input.dtype; + let out = TensorIr::uninit(new_id(), shape.clone(), dtype); + let out_indices = TensorIr::uninit(new_id(), shape, indices_dtype); + SortWithIndicesOpIr { + input, + dim, + descending, + out, + out_indices, + } + } +} + +impl LayerNormOpIr { + /// Create a layer-norm IR. + pub fn create( + input: TensorIr, + gamma: TensorIr, + beta: Option, + epsilon: f64, + new_id: impl FnOnce() -> TensorId, + ) -> Self { + let dtype = output_dtype( + [ + Some(&input.dtype), + Some(&gamma.dtype), + beta.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap(); + let out = TensorIr::uninit(new_id(), input.shape.clone(), dtype); + LayerNormOpIr { + input, + gamma, + beta, + epsilon: ScalarIr::Float(epsilon), + out, + } + } +} + +impl Unfold4dOpIr { + /// Create an unfold4d IR. + pub fn create( + x: TensorIr, + kernel_size: [usize; 2], + options: Unfold4dOptionsIr, + new_id: impl FnOnce() -> TensorId, + ) -> Self { + // Output shape mirrors the implementation in `unfold4d_using_unfold`: + // [N, C * kH * kW, num_blocks_h * num_blocks_w] + let dilation = options.dilation; + let padding = options.padding; + let stride = options.stride; + let weight_shape = Shape::from([ + x.shape[1] * kernel_size[0] * kernel_size[1], + x.shape[1], + kernel_size[0], + kernel_size[1], + ]); + let conv_options = burn_backend::ops::ConvOptions::new(stride, padding, dilation, 1); + let out_shape = burn_backend::ops::conv::calculate_conv_output_shape( + &x.shape, + &weight_shape, + &conv_options.stride, + &conv_options.padding, + &conv_options.dilation, + ) + .unwrap(); + let shape = Shape::from([ + x.shape[0], + x.shape[1] * kernel_size[0] * kernel_size[1], + out_shape[2] * out_shape[3], + ]); + let out = TensorIr::uninit(new_id(), shape, x.dtype); + Unfold4dOpIr { + x, + kernel_size, + options, + out, + } + } +} + +impl_ir_create!( + ConvTranspose1dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: ConvTranspose1dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + ConvTranspose1dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + ConvTranspose2dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: ConvTranspose2dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + ConvTranspose2dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + ConvTranspose3dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: ConvTranspose3dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + ConvTranspose3dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + AvgPool1dOpIr { + x: TensorIr, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool + }, + shape = calculate_pool_output_shape( + &x.shape, + &[kernel_size], + &[stride], + &[padding], + &[1], + ceil_mode + ) + .unwrap(), + dtype = x.dtype +); + +impl_ir_create!( + AvgPool1dBackwardOpIr { + x: TensorIr, + grad: TensorIr, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + AvgPool2dOpIr { + x: TensorIr, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool + }, + shape = calculate_pool_output_shape( + &x.shape, + &kernel_size, + &stride, + &padding, + &[1, 1], + ceil_mode + ) + .unwrap(), + dtype = x.dtype +); + +impl_ir_create!( + AvgPool2dBackwardOpIr { + x: TensorIr, + grad: TensorIr, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + MaxPool1dOpIr { + x: TensorIr, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool + }, + shape = calculate_pool_output_shape( + &x.shape, + &[kernel_size], + &[stride], + &[padding], + &[dilation], + ceil_mode + ) + .unwrap(), + dtype = x.dtype +); + +impl_ir_create!( + MaxPool2dOpIr { + x: TensorIr, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool + }, + shape = calculate_pool_output_shape( + &x.shape, + &kernel_size, + &stride, + &padding, + &dilation, + ceil_mode + ) + .unwrap(), + dtype = x.dtype +); + +impl_ir_create!( + MaxPool1dWithIndicesBackwardOpIr { + x: TensorIr, + grad: TensorIr, + indices: TensorIr, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + MaxPool2dWithIndicesBackwardOpIr { + x: TensorIr, + grad: TensorIr, + indices: TensorIr, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + AdaptiveAvgPool1dOpIr { + x: TensorIr, + output_size: usize + }, + shape = Shape::new([x.shape[0], x.shape[1], output_size]), + dtype = x.dtype +); + +impl_ir_create!( + AdaptiveAvgPool2dOpIr { + x: TensorIr, + output_size: [usize; 2] + }, + shape = Shape::new([x.shape[0], x.shape[1], output_size[0], output_size[1]]), + dtype = x.dtype +); + +impl_ir_create!( + AdaptiveAvgPool1dBackwardOpIr { + x: TensorIr, + grad: TensorIr, + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + AdaptiveAvgPool2dBackwardOpIr { + x: TensorIr, + grad: TensorIr, + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + InterpolateOpIr { + x: TensorIr, + output_size: [usize; 2], + options: InterpolateOptionsIr + }, + shape = Shape::new([x.shape[0], x.shape[1], output_size[0], output_size[1]]), + dtype = x.dtype +); + +impl_ir_create!( + InterpolateBackwardOpIr { + x: TensorIr, + grad: TensorIr, + output_size: [usize; 2], + options: InterpolateOptionsIr + }, + shape = x.shape.clone(), + dtype = x.dtype +); + +impl_ir_create!( + GridSample2dOpIr { + tensor: TensorIr, + grid: TensorIr, + options: GridSampleOptionsIr + }, + // Input tensor: [N, C, H_in, W_in] + // Grid: [N, H_out, W_out, 2] + // Output: [N, C, H_out, W_out] + shape = Shape::new([ + tensor.shape[0], + tensor.shape[1], + grid.shape[1], + grid.shape[2] + ]), + dtype = tensor.dtype +); + +impl_ir_create!( + EmbeddingOpIr { + weights: TensorIr, + indices: TensorIr, + }, + shape = { + // weights: [n_embeddings, d_model] + // indices: [batch_size, seq_length] + // output: [batch_size, seq_length, d_model] + let d_model = weights.shape[1]; + Shape::from(alloc::vec![indices.shape[0], indices.shape[1], d_model]) + }, + dtype = weights.dtype +); + +impl_ir_create!( + EmbeddingBackwardOpIr { + weights: TensorIr, + out_grad: TensorIr, + indices: TensorIr, + }, + shape = weights.shape.clone(), + dtype = output_dtype([&weights.dtype, &out_grad.dtype]).unwrap() +); + +impl_ir_create!( + LinearOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option + }, + shape = { + // output: [..., d_output] where x is [..., d_input] and weight is [d_input, d_output] + let n = x.shape.num_dims(); + let mut dims: Vec = (0..n).map(|i| x.shape[i]).collect(); + dims[n - 1] = weight.shape[1]; + Shape::from(dims) + }, + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + LinearXBackwardOpIr { + weight: TensorIr, + output_grad: TensorIr, + }, + shape = { + // dx = output_grad @ weight^T + // output_grad: [..., d_output], weight: [d_input, d_output] + // result: [..., d_input] + let n = output_grad.shape.num_dims(); + let mut dims: Vec = (0..n).map(|i| output_grad.shape[i]).collect(); + dims[n - 1] = weight.shape[0]; + Shape::from(dims) + }, + dtype = output_grad.dtype +); + +impl_ir_create!( + LinearWeightBackwardOpIr { + x: TensorIr, + output_grad: TensorIr, + }, + shape = { + // dW: [d_input, d_output] + let d_input = x.shape[x.shape.num_dims() - 1]; + let d_output = output_grad.shape[output_grad.shape.num_dims() - 1]; + Shape::from(alloc::vec![d_input, d_output]) + }, + dtype = output_grad.dtype +); + +impl_ir_create!( + LinearBiasBackwardOpIr { + output_grad: TensorIr, + }, + shape = { + // db: [d_output] + let d_output = output_grad.shape[output_grad.shape.num_dims() - 1]; + Shape::from(alloc::vec![d_output]) + }, + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv1dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: Conv1dOptionsIr + }, + shape = calculate_conv_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.dilation, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + Conv1dXBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv1dOptionsIr + }, + shape = x.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv1dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv1dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv1dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv2dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: Conv2dOptionsIr + }, + shape = calculate_conv_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.dilation, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + Conv2dXBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv2dOptionsIr + }, + shape = x.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv2dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv2dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv2dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv3dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: Conv3dOptionsIr + }, + shape = calculate_conv_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.dilation, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + Conv3dXBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv3dOptionsIr + }, + shape = x.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv3dWeightBackwardOpIr { + x: TensorIr, + weight: TensorIr, + output_grad: TensorIr, + options: Conv3dOptionsIr + }, + shape = weight.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + Conv3dBiasBackwardOpIr { + x: TensorIr, + bias: TensorIr, + output_grad: TensorIr, + }, + shape = bias.shape.clone(), + dtype = output_grad.dtype +); + +impl_ir_create!( + DeformConv2dOpIr { + x: TensorIr, + offset: TensorIr, + weight: TensorIr, + mask: Option, + bias: Option, + options: DeformableConv2dOptionsIr + }, + shape = calculate_conv_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.dilation, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&offset.dtype), + Some(&weight.dtype), + mask.as_ref().map(|m| &m.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + ConvTranspose1dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: ConvTranspose1dOptionsIr + }, + shape = calculate_conv_transpose_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.padding_out, + &options.dilation, + options.groups, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + ConvTranspose2dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: ConvTranspose2dOptionsIr + }, + shape = calculate_conv_transpose_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.padding_out, + &options.dilation, + options.groups, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + ConvTranspose3dOpIr { + x: TensorIr, + weight: TensorIr, + bias: Option, + options: ConvTranspose3dOptionsIr + }, + shape = calculate_conv_transpose_output_shape( + &x.shape, + &weight.shape, + &options.stride, + &options.padding, + &options.padding_out, + &options.dilation, + options.groups, + ) + .unwrap(), + dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap() +); + +impl_ir_create!( + UnfoldOpIr { + input: TensorIr, + dim: usize, + size: usize, + step: usize + }, + shape = calculate_unfold_shape(input.shape.clone(), dim, size, step), + dtype = input.dtype +); + +impl_ir_create!( + CrossOpIr { + lhs: TensorIr, + rhs: TensorIr, + dim: usize + }, + shape = lhs.shape.broadcast(&rhs.shape).unwrap(), + dtype = output_dtype([&lhs.dtype, &rhs.dtype]).unwrap() +); + +impl_ir_create!( + QuantizeOpIr { + tensor: TensorIr, + qparams: QuantizationParametersIr, + scheme: QuantScheme + }, + shape = tensor.shape.clone(), + dtype = DType::QFloat(scheme) +); + +impl_ir_create!( + AttentionOpIr { + query: TensorIr, + key: TensorIr, + value: TensorIr, + mask: Option, + attn_bias: Option, + options: AttentionOptionsIr, + }, + shape = Shape::new([query.shape[0], query.shape[1], query.shape[2], value.shape[3]]), + dtype = query.dtype +); + +impl_ir_create!( + CtcLossOpIr { + log_probs: TensorIr, + targets: TensorIr, + input_lengths: TensorIr, + target_lengths: TensorIr, + blank: usize, + }, + shape = Shape::new([log_probs.shape[1]]), + dtype = log_probs.dtype +); + +impl_ir_create!( + CtcLossBackwardOpIr { + log_probs: TensorIr, + targets: TensorIr, + input_lengths: TensorIr, + target_lengths: TensorIr, + grad_loss: TensorIr, + blank: usize, + }, + shape = log_probs.shape.clone(), + dtype = log_probs.dtype +); + +impl DequantizeOpIr { + pub fn create(input: TensorIr, dtype: DType, new_id: impl FnOnce() -> TensorId) -> Self { + let out = TensorIr::uninit(new_id(), input.shape.clone(), dtype); + + DequantizeOpIr { input, out } + } +} + +// Operations with multiple outputs + +impl ReduceDimWithIndicesOpIr { + pub fn create( + tensor: TensorIr, + dim: usize, + dtype_indices: DType, + mut new_id: impl FnMut() -> TensorId, + ) -> Self { + let mut shape = tensor.shape.clone(); + shape[dim] = 1; + let out = TensorIr::uninit(new_id(), shape.clone(), tensor.dtype); + let out_indices = TensorIr::uninit(new_id(), shape.clone(), dtype_indices); + + ReduceDimWithIndicesOpIr { + tensor, + dim, + out, + out_indices, + } + } +} + +impl DeformConv2dBackwardOpIr { + #[allow(clippy::too_many_arguments)] + pub fn create( + x: TensorIr, + offset: TensorIr, + weight: TensorIr, + mask: Option, + bias: Option, + out_grad: TensorIr, + options: DeformableConv2dOptionsIr, + mut new_id: impl FnMut() -> TensorId, + ) -> Self { + let dtype = output_dtype( + [ + Some(&x.dtype), + Some(&weight.dtype), + mask.as_ref().map(|m| &m.dtype), + bias.as_ref().map(|b| &b.dtype), + ] + .iter() + .filter_map(|&d| d), + ) + .unwrap(); + + let input_grad = TensorIr::uninit(new_id(), x.shape.clone(), dtype); + let offset_grad = TensorIr::uninit(new_id(), offset.shape.clone(), dtype); + let weight_grad = TensorIr::uninit(new_id(), weight.shape.clone(), dtype); + let mask_grad = mask + .as_ref() + .map(|t| TensorIr::uninit(new_id(), t.shape.clone(), dtype)); + let bias_grad = bias + .as_ref() + .map(|t| TensorIr::uninit(new_id(), t.shape.clone(), dtype)); + + DeformConv2dBackwardOpIr { + x, + offset, + weight, + mask, + bias, + out_grad, + options, + input_grad, + offset_grad, + weight_grad, + mask_grad, + bias_grad, + } + } +} + +impl MaxPool1dWithIndicesOpIr { + #[allow(clippy::too_many_arguments)] + pub fn create( + x: TensorIr, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + dtype_indices: DType, + mut new_id: impl FnMut() -> TensorId, + ) -> Self { + let shape = calculate_pool_output_shape( + &x.shape, + &[kernel_size], + &[stride], + &[padding], + &[dilation], + ceil_mode, + ) + .unwrap(); + let out = TensorIr::uninit(new_id(), shape.clone(), x.dtype); + let out_indices = TensorIr::uninit(new_id(), shape, dtype_indices); + + MaxPool1dWithIndicesOpIr { + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + out, + out_indices, + } + } +} + +impl MaxPool2dWithIndicesOpIr { + #[allow(clippy::too_many_arguments)] + pub fn create( + x: TensorIr, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + dtype_indices: DType, + mut new_id: impl FnMut() -> TensorId, + ) -> Self { + let shape = calculate_pool_output_shape( + &x.shape, + &kernel_size, + &stride, + &padding, + &dilation, + ceil_mode, + ) + .unwrap(); + let out = TensorIr::uninit(new_id(), shape.clone(), x.dtype); + let out_indices = TensorIr::uninit(new_id(), shape, dtype_indices); + + MaxPool2dWithIndicesOpIr { + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + out, + out_indices, + } + } +} diff --git a/crates/burn-ir/src/graph.rs b/crates/burn-ir/src/graph.rs new file mode 100644 index 0000000..c5c58b9 --- /dev/null +++ b/crates/burn-ir/src/graph.rs @@ -0,0 +1,39 @@ +use crate::{ScalarIr, TensorId}; +use alloc::vec::Vec; +use burn_backend::Slice; +use serde::{Deserialize, Serialize}; + +/// Identifier for a cached, reusable group of operations (a graph). +/// +/// A router backend that supports graph caching (e.g. the remote backend) registers a relative +/// op-graph once under this id, then replays it by id with only the changing [bindings](GraphBindings). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GraphId(pub u64); + +/// Per-invocation bindings used to specialize a cached graph to concrete tensors. +/// +/// The cached graph is in *relative* form (positional tensor ids, relative shape-dim ids, scalar +/// placeholders). The bindings carry only the graph's *boundary* — its inputs and surviving +/// outputs — plus the dense shape-dim table and the scalar values. The replay reconstructs the +/// concrete shape of **every** tensor (including intermediates) from [`shapes`](Self::shapes), and +/// allocates fresh ids for intermediate tensors itself, so the payload stays small regardless of +/// how many ops the graph contains. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphBindings { + /// Boundary tensors only: `(relative id, concrete id)` for each graph input and surviving + /// output. Intermediate tensors are *not* listed — the replaying backend allocates their ids. + pub tensors: Vec<(TensorId, TensorId)>, + /// Concrete dim value for each relative shape-dim id, indexed directly by the id. Relative dim + /// ids are dense (`0..N`), so this is a plain table rather than a map, and each distinct dim + /// value appears once however many tensors share it. Lets the replay rebuild every tensor's + /// concrete shape — inputs, outputs and intermediates alike. + pub shapes: Vec, + /// Concrete scalar values indexed by their placeholder id (the value carried in a relativized + /// `ScalarIr::UInt(placeholder)`). + pub scalars: Vec, + /// Concrete slice ranges indexed by their placeholder id (the value carried in a relativized + /// range's `start` field). The relative graph keeps every `Slice` range as a positional + /// placeholder — its actual bounds are discarded by relativization (they vary per invocation, + /// like scalars) — so the replay restores each from here. + pub ranges: Vec, +} diff --git a/crates/burn-ir/src/handle.rs b/crates/burn-ir/src/handle.rs new file mode 100644 index 0000000..8c12b6f --- /dev/null +++ b/crates/burn-ir/src/handle.rs @@ -0,0 +1,312 @@ +use hashbrown::HashMap; + +use crate::{BackendIr, TensorHandle, TensorId, TensorIr, TensorStatus}; + +/// Keep all [tensor handles](BackendIr::Handle) in one place and ensure that all resources +/// are used optimally. +pub struct HandleContainer { + handles: HashMap>, + counter: u64, +} + +// Hand-written perfect derive as we don't require `H: Default`. +impl Default for HandleContainer { + fn default() -> Self { + Self { + handles: HashMap::new(), + counter: 0, + } + } +} + +impl HandleContainer { + /// Fork the container, useful for autotune. + pub fn fork(&self) -> Self { + let mut handles = HashMap::with_capacity(self.handles.len()); + + for (id, handle) in self.handles.iter() { + handles.insert(*id, handle.clone()); + } + + Self { + handles, + counter: self.counter, + } + } +} + +impl core::fmt::Debug for HandleContainer { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("HandleContainer") + .field("handles", &self.handles.keys()) // only care about the IDs when debugging + .field("counter", &self.counter) + .finish() + } +} + +/// Backend [tensor handle](BackendIr::Handle) wrapper tracking their creation state +#[derive(Clone)] +pub enum Handle { + /// No [tensor handle](BackendIr::Handle) has been created yet + NotInit, + /// A [tensor handle](BackendIr::Handle) has been created + Existing(H), +} + +impl HandleContainer { + /// Create a new HandleContainer + pub fn new() -> Self { + Self { + handles: HashMap::new(), + counter: 0, + } + } + + /// Register a handle for the given [tensor id](TensorId). + pub fn register_handle(&mut self, id: TensorId, handle: H) { + self.handles.insert(id, Handle::Existing(handle)); + } + + /// Whether a handle exists. + pub fn has_handle(&self, id: &TensorId) -> bool { + self.handles.contains_key(id) + } + + /// Get the reference to a handle. + pub fn get_handle_ref(&self, id: &TensorId) -> Option<&H> { + self.handles + .get(id) + .filter(|h| !matches!(h, Handle::NotInit)) + .map(|h| match h { + Handle::Existing(handle) => handle, + Handle::NotInit => unreachable!(), + }) + } + + /// Get the handle for the given [tensor id](TensorId). The status is used to determine if the + /// tensor should be popped out of the current tensor map, necessary for inplace operations. + /// + /// # Warnings + /// + /// Make sure the status corresponds to the operation you want to execute the handle on, + /// otherwise you might remove a tensor handle that will be required in the future. + pub fn get_handle(&mut self, id: &TensorId, status: &TensorStatus) -> H { + let (id, handle) = self + .handles + .remove_entry(id) + .unwrap_or_else(|| panic!("Should have handle for tensor {id:?}")); + + match handle { + Handle::Existing(handle) => match status { + TensorStatus::ReadOnly => { + self.handles.insert(id, Handle::Existing(handle.clone())); + handle + } + TensorStatus::ReadWrite => handle, + TensorStatus::NotInit => panic!( + "Cannot get uninitialized tensor {id:?}. Tensor exist but with wrong status" + ), + }, + Handle::NotInit => panic!("Cannot get uninitialized handle {id:?}."), + } + } + + /// Get the tensor handle for the given [tensor intermediate representation](TensorIr). + pub fn get_tensor_handle(&mut self, tensor: &TensorIr) -> TensorHandle { + TensorHandle { + handle: self.get_handle(&tensor.id, &tensor.status), + shape: tensor.shape.clone(), + } + } + + /// Get the [float tensor](burn_backend::backend::BackendTypes::FloatTensorPrimitive) corresponding to the + /// given [tensor intermediate representation](TensorIr). + pub fn get_float_tensor(&mut self, tensor: &TensorIr) -> B::FloatTensorPrimitive + where + B: BackendIr, + { + B::float_tensor(self.get_tensor_handle(tensor)) + } + + /// Get the [int tensor](burn_backend::backend::BackendTypes::IntTensorPrimitive) corresponding to the + /// given [tensor intermediate representation](TensorIr). + pub fn get_int_tensor(&mut self, tensor: &TensorIr) -> B::IntTensorPrimitive + where + B: BackendIr, + { + B::int_tensor(self.get_tensor_handle(tensor)) + } + + /// Get the [bool tensor](burn_backend::backend::BackendTypes::BoolTensorPrimitive) corresponding to the + /// given [tensor intermediate representation](TensorIr). + pub fn get_bool_tensor(&mut self, tensor: &TensorIr) -> B::BoolTensorPrimitive + where + B: BackendIr, + { + B::bool_tensor(self.get_tensor_handle(tensor)) + } + + /// Get the [quantized tensor](burn_backend::backend::BackendTypes::QuantizedTensorPrimitive) corresponding to the + /// given [tensor intermediate representation](TensorIr). + pub fn get_quantized_tensor(&mut self, tensor: &TensorIr) -> B::QuantizedTensorPrimitive + where + B: BackendIr, + { + B::quantized_tensor(self.get_tensor_handle(tensor)) + } + + /// Register a new [float tensor](burn_backend::backend::BackendTypes::FloatTensorPrimitive) with the corresponding [tensor id](TensorId). + pub fn register_float_tensor(&mut self, id: &TensorId, tensor: B::FloatTensorPrimitive) + where + B: BackendIr, + { + let handle = B::float_tensor_handle(tensor); + self.handles.insert(*id, Handle::Existing(handle)); + } + + /// Register a new [quantized tensor](burn_backend::backend::BackendTypes::QuantizedTensorPrimitive) with the corresponding [tensor ids](TensorId). + pub fn register_quantized_tensor( + &mut self, + id: &TensorId, + tensor: B::QuantizedTensorPrimitive, + ) where + B: BackendIr, + { + let handle = B::quantized_tensor_handle(tensor); + self.handles.insert(*id, Handle::Existing(handle)); + } + + /// Register a new [int tensor](burn_backend::backend::BackendTypes::IntTensorPrimitive) with the corresponding [tensor id](TensorId). + pub fn register_int_tensor(&mut self, id: &TensorId, tensor: B::IntTensorPrimitive) + where + B: BackendIr, + { + let handle = B::int_tensor_handle(tensor); + self.handles.insert(*id, Handle::Existing(handle)); + } + + /// Register a new [bool tensor](burn_backend::backend::BackendTypes::BoolTensorPrimitive) with the corresponding [tensor id](TensorId). + pub fn register_bool_tensor(&mut self, id: &TensorId, tensor: B::BoolTensorPrimitive) + where + B: BackendIr, + { + let handle = B::bool_tensor_handle(tensor); + self.handles.insert(*id, Handle::Existing(handle)); + } + + /// Remove tensor handle from container. + pub fn remove_handle(&mut self, id: TensorId) -> Option> { + self.handles.remove(&id) + } + + /// Remove tensor handle from container if writable + pub fn free(&mut self, tensor: &TensorIr) { + match tensor.status { + TensorStatus::ReadOnly => (), + TensorStatus::NotInit => (), + TensorStatus::ReadWrite => { + self.handles.remove(&tensor.id); + } + }; + } + + /// Returns the number of handles. + pub fn num_handles(&self) -> usize { + self.handles.len() + } + + /// Returns the IDs of all currently registered handles. + /// + /// Useful for snapshotting which handles exist at a point in time (e.g., before + /// executing on a forked context) so that newly registered output handles can + /// be detected afterwards. + pub fn handle_ids(&self) -> impl Iterator { + self.handles.keys() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TensorId; + + /// Helper to create a TensorId for tests. + fn tid(value: u64) -> TensorId { + TensorId::new(value) + } + + #[test] + fn fork_clones_existing_handles() { + let mut container = HandleContainer::::new(); + container.register_handle(tid(1), "input_a".to_string()); + container.register_handle(tid(2), "input_b".to_string()); + + let fork = container.fork(); + + assert_eq!(fork.num_handles(), 2); + assert!(fork.get_handle_ref(&tid(1)).is_some()); + assert!(fork.get_handle_ref(&tid(2)).is_some()); + } + + #[test] + fn fork_is_isolated_from_original() { + // This test documents the core of the autotune clone bug: + // output handles registered in a fork do NOT appear in the original. + let mut container = HandleContainer::::new(); + container.register_handle(tid(1), "input_a".to_string()); + + let mut fork = container.fork(); + + // Simulate an optimization registering output handles in the fork. + fork.register_handle(tid(100), "output_x".to_string()); + fork.register_handle(tid(101), "output_y".to_string()); + + // The fork has the output handles. + assert_eq!(fork.num_handles(), 3); + assert!(fork.get_handle_ref(&tid(100)).is_some()); + assert!(fork.get_handle_ref(&tid(101)).is_some()); + + // But the original does NOT — these output handles are lost. + assert_eq!(container.num_handles(), 1); + assert!(container.get_handle_ref(&tid(100)).is_none()); + assert!(container.get_handle_ref(&tid(101)).is_none()); + } + + #[test] + fn fork_mutations_do_not_affect_original() { + let mut container = HandleContainer::::new(); + container.register_handle(tid(1), "original_value".to_string()); + + let mut fork = container.fork(); + + // Overwrite a handle in the fork (e.g., inplace output reuse). + fork.register_handle(tid(1), "modified_in_fork".to_string()); + + // Original is unchanged. + assert_eq!( + container.get_handle_ref(&tid(1)), + Some(&"original_value".to_string()) + ); + assert_eq!( + fork.get_handle_ref(&tid(1)), + Some(&"modified_in_fork".to_string()) + ); + } + + #[test] + fn double_fork_is_fully_isolated() { + // Simulates what happens when UnsafeTuneContext::get() is called on a Fork: + // it forks again, creating a second level of isolation. + let mut container = HandleContainer::::new(); + container.register_handle(tid(1), "input".to_string()); + + let fork1 = container.fork(); + let mut fork2 = fork1.fork(); + + fork2.register_handle(tid(200), "deep_output".to_string()); + + assert!(fork1.get_handle_ref(&tid(200)).is_none()); + assert!(container.get_handle_ref(&tid(200)).is_none()); + assert!(fork2.get_handle_ref(&tid(200)).is_some()); + } +} diff --git a/crates/burn-ir/src/lib.rs b/crates/burn-ir/src/lib.rs new file mode 100644 index 0000000..709b381 --- /dev/null +++ b/crates/burn-ir/src/lib.rs @@ -0,0 +1,23 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! Burn intermediate representation. + +extern crate alloc; + +mod backend; +mod builder; +mod graph; +mod handle; +mod operation; +mod scalar; +mod tensor; + +pub use backend::*; +pub use builder::*; +pub use graph::*; +pub use handle::*; +pub use operation::*; +pub use scalar::*; +pub use tensor::*; diff --git a/crates/burn-ir/src/operation.rs b/crates/burn-ir/src/operation.rs new file mode 100644 index 0000000..0acf555 --- /dev/null +++ b/crates/burn-ir/src/operation.rs @@ -0,0 +1,5132 @@ +use burn_backend::ops::AttentionModuleOptions; +use burn_backend::tensor::IndexingUpdateOp; +use core::hash::Hash; +use serde::{Deserialize, Serialize}; + +use alloc::borrow::ToOwned; +use alloc::boxed::Box; +use alloc::{string::String, vec::Vec}; + +use burn_backend::{ + DType, Distribution, Slice, + ops::{ + ConvOptions, ConvTransposeOptions, DeformConvOptions, GridSampleOptions, + GridSamplePaddingMode, InterpolateMode, InterpolateOptions, + }, + quantization::QuantScheme, +}; + +use crate::{ScalarIr, TensorId, TensorIr, TensorStatus}; + +/// Visitor for mutating the components of an [`OperationIr`] in place. +pub trait IrVisitorMut { + /// Visit a [`TensorIr`] mutably. + fn visit_tensor_mut(&mut self, _tensor: &mut TensorIr) {} + /// Visit a [`ScalarIr`] mutably. + fn visit_scalar_mut(&mut self, _scalar: &mut ScalarIr) {} + /// Visit a slice range mutably. + fn visit_range_mut(&mut self, _range: &mut Slice) {} +} + +/// Custom operation in fusion stream, declaring its inputs, outputs and scalar arguments. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct CustomOpIr { + /// Unique identifier of the operation. + pub id: String, + /// Input tensors used in the custom operation. + pub inputs: Vec, + /// Output tensors used in the custom operation. + pub outputs: Vec, + /// Non-tensor scalar arguments, in declaration order. + /// + /// Carried through fusion's relativization like any other scalar (see the + /// `RelativeOps for CustomOpIr` impl), so a cached graph replays with fresh scalar values. The + /// remote backend relies on these to ship a custom op's scalar arguments to the server, where + /// the registered handler reads them back with [`ScalarIr::elem`]. + pub scalars: Vec, +} + +impl CustomOpIr { + /// Create a new custom operation intermediate representation (without scalar arguments). + pub fn new(id: &'static str, inputs: &[TensorIr], outputs: &[TensorIr]) -> Self { + Self { + id: id.to_owned(), + inputs: inputs.to_vec(), + outputs: outputs.to_vec(), + scalars: Vec::new(), + } + } + + /// Create a new custom operation intermediate representation with scalar arguments. + pub fn with_scalars( + id: &'static str, + inputs: &[TensorIr], + outputs: &[TensorIr], + scalars: Vec, + ) -> Self { + Self { + id: id.to_owned(), + inputs: inputs.to_vec(), + outputs: outputs.to_vec(), + scalars, + } + } + + /// Cast the intermediate representation, and get the in and output tensors. + pub fn as_fixed( + &self, + ) -> (&[TensorIr; N_IN], &[TensorIr; N_OUT]) { + ( + self.inputs.as_slice().try_into().expect( + "Wrong number of inputs expected (expected {D}, is {}), check your implementation", + ), + self.outputs.as_slice().try_into().expect( + "Wrong number of outputs expected (expected {D}, is {}), check your implementation", + ), + ) + } + + fn inputs(&self) -> Box + '_> { + Box::new(self.inputs.iter()) + } + + fn outputs(&self) -> Box + '_> { + Box::new(self.outputs.iter()) + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + for t in self.inputs.iter_mut() { + v.visit_tensor_mut(t); + } + for t in self.outputs.iter_mut() { + v.visit_tensor_mut(t); + } + for s in self.scalars.iter_mut() { + v.visit_scalar_mut(s); + } + } +} + +/// Describe all tensor operations possible. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +pub enum OperationIr { + /// Basic operation on a float tensor. + BaseFloat(BaseOperationIr), + /// Basic operation on an int tensor. + BaseInt(BaseOperationIr), + /// Basic operation on a bool tensor. + BaseBool(BaseOperationIr), + /// Numeric operation on a float tensor. + NumericFloat(DType, NumericOperationIr), + /// Numeric operation on an int tensor. + NumericInt(DType, NumericOperationIr), + /// Operation specific to a bool tensor. + Bool(BoolOperationIr), + /// Operation specific to an int tensor. + Int(IntOperationIr), + /// Operation specific to a float tensor. + Float(DType, FloatOperationIr), + /// Module operation. + Module(ModuleOperationIr), + /// Initialize operation. + Init(InitOperationIr), + /// A custom operation. + Custom(CustomOpIr), + /// A tensor is dropped. + Drop(TensorIr), + /// Operation specific to a distributed tensor. + Distributed(DistributedOperationIr), + /// Activation function operation (relu, gelu, sigmoid, softmax, …). + Activation(ActivationOperationIr), +} + +/// Operation intermediate representation specific to a float tensor. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum FloatOperationIr { + /// Operation corresponding to [exp](burn_backend::ops::FloatTensorOps::float_exp). + Exp(UnaryOpIr), + /// Operation corresponding to [log](burn_backend::ops::FloatTensorOps::float_log). + Log(UnaryOpIr), + /// Operation corresponding to [log1p](burn_backend::ops::FloatTensorOps::float_log1p). + Log1p(UnaryOpIr), + /// Operation corresponding to [erf](burn_backend::ops::FloatTensorOps::float_erf). + Erf(UnaryOpIr), + /// Operation corresponding to [powf_scalar](burn_backend::ops::FloatTensorOps::float_powf_scalar). + PowfScalar(ScalarOpIr), + /// Operation corresponding to [sqrt](burn_backend::ops::FloatTensorOps::float_sqrt). + Sqrt(UnaryOpIr), + /// Operation corresponding to [cos](burn_backend::ops::FloatTensorOps::float_cos). + Cos(UnaryOpIr), + /// Operation corresponding to [cosh](burn_backend::ops::FloatTensorOps::float_cosh). + Cosh(UnaryOpIr), + /// Operation corresponding to [sin](burn_backend::ops::FloatTensorOps::float_sin). + Sin(UnaryOpIr), + /// Operation corresponding to [sin](burn_backend::ops::FloatTensorOps::float_sinh). + Sinh(UnaryOpIr), + /// Operation corresponding to [tan](burn_backend::ops::FloatTensorOps::float_tan). + Tan(UnaryOpIr), + /// Operation corresponding to [tanh](burn_backend::ops::FloatTensorOps::float_tanh). + Tanh(UnaryOpIr), + /// Operation corresponding to [acos](burn_backend::ops::FloatTensorOps::float_acos). + ArcCos(UnaryOpIr), + /// Operation corresponding to [acosh](burn_backend::ops::FloatTensorOps::float_acosh). + ArcCosh(UnaryOpIr), + /// Operation corresponding to [asin](burn_backend::ops::FloatTensorOps::float_asin). + ArcSin(UnaryOpIr), + /// Operation corresponding to [asinh](burn_backend::ops::FloatTensorOps::float_asinh). + ArcSinh(UnaryOpIr), + /// Operation corresponding to [atan](burn_backend::ops::FloatTensorOps::float_atan). + ArcTan(UnaryOpIr), + /// Operation corresponding to [atanh](burn_backend::ops::FloatTensorOps::float_atanh). + ArcTanh(UnaryOpIr), + /// Operation corresponding to [atan2](burn_backend::ops::FloatTensorOps::float_atan2). + ArcTan2(BinaryOpIr), + /// Operation corresponding to [round](burn_backend::ops::FloatTensorOps::float_round). + Round(UnaryOpIr), + /// Operation corresponding to [floor](burn_backend::ops::FloatTensorOps::float_floor). + Floor(UnaryOpIr), + /// Operation corresponding to [ceil](burn_backend::ops::FloatTensorOps::float_ceil). + Ceil(UnaryOpIr), + /// Operation corresponding to [trunc](burn_backend::ops::FloatTensorOps::float_trunc). + Trunc(UnaryOpIr), + /// Operation corresponding to [into_int](burn_backend::ops::FloatTensorOps::float_into_int). + IntoInt(CastOpIr), + /// Operation corresponding to [matmul](burn_backend::ops::FloatTensorOps::float_matmul). + Matmul(MatmulOpIr), + /// Operation corresponding to [cross](burn_backend::ops::FloatTensorOps::float_cross). + Cross(CrossOpIr), + /// Operation corresponding to [random](burn_backend::ops::FloatTensorOps::float_random). + Random(RandomOpIr), + /// Operation corresponding to [recip](burn_backend::ops::FloatTensorOps::float_recip). + Recip(UnaryOpIr), + /// Operation corresponding to [is_nan](burn_backend::ops::FloatTensorOps::float_is_nan). + IsNan(UnaryOpIr), + /// Operation corresponding to [is_nan](burn_backend::ops::FloatTensorOps::float_is_inf). + IsInf(UnaryOpIr), + /// Operation corresponding to [quantize](burn_backend::ops::QTensorOps::quantize). + Quantize(QuantizeOpIr), + /// Operation corresponding to [dequantize](burn_backend::ops::QTensorOps::dequantize). + Dequantize(DequantizeOpIr), + /// Operation corresponding to [grid_sample_2d](burn_backend::ops::FloatTensorOps::float_grid_sample_2d). + GridSample2d(GridSample2dOpIr), + /// Operation corresponding to [powf](burn_backend::ops::FloatTensorOps::float_powi). + Powf(BinaryOpIr), + /// Operation corresponding to [hypot](burn_backend::ops::FloatTensorOps::float_hypot). + Hypot(BinaryOpIr), +} + +/// Operation intermediate representation specific to module. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum ModuleOperationIr { + /// Operation corresponding to [embedding](burn_backend::ops::ModuleOps::embedding). + Embedding(EmbeddingOpIr), + /// Operation corresponding to [embedding_backward](burn_backend::ops::ModuleOps::embedding_backward). + EmbeddingBackward(EmbeddingBackwardOpIr), + /// Operation corresponding to [linear](burn_backend::ops::ModuleOps::linear). + Linear(LinearOpIr), + /// Operation corresponding to [linear_x_backward](burn_backend::ops::ModuleOps::linear_x_backward). + LinearXBackward(LinearXBackwardOpIr), + /// Operation corresponding to [linear_weight_backward](burn_backend::ops::ModuleOps::linear_weight_backward). + LinearWeightBackward(LinearWeightBackwardOpIr), + /// Operation corresponding to [linear_bias_backward](burn_backend::ops::ModuleOps::linear_bias_backward). + LinearBiasBackward(LinearBiasBackwardOpIr), + /// Operation corresponding to [conv1d](burn_backend::ops::ModuleOps::conv1d). + Conv1d(Conv1dOpIr), + /// Operation corresponding to [conv1d_x_backward](burn_backend::ops::ModuleOps::conv1d_x_backward). + Conv1dXBackward(Conv1dXBackwardOpIr), + /// Operation corresponding to [conv1d_weight_backward](burn_backend::ops::ModuleOps::conv1d_weight_backward). + Conv1dWeightBackward(Conv1dWeightBackwardOpIr), + /// Operation corresponding to [conv1d_bias_backward](burn_backend::ops::ModuleOps::conv1d_bias_backward). + Conv1dBiasBackward(Conv1dBiasBackwardOpIr), + /// Operation corresponding to [conv2d](burn_backend::ops::ModuleOps::conv2d). + Conv2d(Conv2dOpIr), + /// Operation corresponding to [conv2d_x_backward](burn_backend::ops::ModuleOps::conv2d_x_backward). + Conv2dXBackward(Conv2dXBackwardOpIr), + /// Operation corresponding to [conv2d_weight_backward](burn_backend::ops::ModuleOps::conv2d_weight_backward). + Conv2dWeightBackward(Conv2dWeightBackwardOpIr), + /// Operation corresponding to [conv2d_bias_backward](burn_backend::ops::ModuleOps::conv2d_bias_backward). + Conv2dBiasBackward(Conv2dBiasBackwardOpIr), + /// Operation corresponding to [conv3d](burn_backend::ops::ModuleOps::conv3d). + Conv3d(Conv3dOpIr), + /// Operation corresponding to [conv3d_x_backward](burn_backend::ops::ModuleOps::conv3d_x_backward). + Conv3dXBackward(Conv3dXBackwardOpIr), + /// Operation corresponding to [conv3d_weight_backward](burn_backend::ops::ModuleOps::conv3d_weight_backward). + Conv3dWeightBackward(Conv3dWeightBackwardOpIr), + /// Operation corresponding to [conv3d_bias_backward](burn_backend::ops::ModuleOps::conv3d_bias_backward). + Conv3dBiasBackward(Conv3dBiasBackwardOpIr), + /// Operation corresponding to [deform_conv2d](burn_backend::ops::ModuleOps::deform_conv2d) + DeformableConv2d(Box), + /// Operation corresponding to [deform_conv2d_backward](burn_backend::ops::ModuleOps::deform_conv2d_backward) + DeformableConv2dBackward(Box), + /// Operation corresponding to [conv transpose 1d](burn_backend::ops::ModuleOps::conv_transpose1d). + ConvTranspose1d(ConvTranspose1dOpIr), + /// Operation corresponding to [conv transpose 2d](burn_backend::ops::ModuleOps::conv_transpose2d). + ConvTranspose2d(ConvTranspose2dOpIr), + /// Operation corresponding to [conv transpose 3d](burn_backend::ops::ModuleOps::conv_transpose3d). + ConvTranspose3d(ConvTranspose3dOpIr), + /// Operation corresponding to [avg pool 1d](burn_backend::ops::ModuleOps::avg_pool1d). + AvgPool1d(AvgPool1dOpIr), + /// Operation corresponding to [avg pool 2d](burn_backend::ops::ModuleOps::avg_pool2d). + AvgPool2d(AvgPool2dOpIr), + /// Operation corresponding to + /// [avg pool 1d backward](burn_backend::ops::ModuleOps::avg_pool1d_backward). + AvgPool1dBackward(AvgPool1dBackwardOpIr), + /// Operation corresponding to + /// [avg pool 2d backward](burn_backend::ops::ModuleOps::avg_pool2d_backward). + AvgPool2dBackward(AvgPool2dBackwardOpIr), + /// Operation corresponding to + /// [adaptive avg pool 1d](burn_backend::ops::ModuleOps::adaptive_avg_pool1d). + AdaptiveAvgPool1d(AdaptiveAvgPool1dOpIr), + /// Operation corresponding to + /// [adaptive avg pool 2d](burn_backend::ops::ModuleOps::adaptive_avg_pool2d). + AdaptiveAvgPool2d(AdaptiveAvgPool2dOpIr), + /// Operation corresponding to + /// [adaptive avg pool 1d backward](burn_backend::ops::ModuleOps::adaptive_avg_pool1d_backward). + AdaptiveAvgPool1dBackward(AdaptiveAvgPool1dBackwardOpIr), + /// Operation corresponding to + /// [adaptive avg pool 2d backward](burn_backend::ops::ModuleOps::adaptive_avg_pool2d_backward). + AdaptiveAvgPool2dBackward(AdaptiveAvgPool2dBackwardOpIr), + /// Operation corresponding to + /// [max pool 1d](burn_backend::ops::ModuleOps::max_pool1d). + MaxPool1d(MaxPool1dOpIr), + /// Operation corresponding to + /// [max pool 1d with indices](burn_backend::ops::ModuleOps::max_pool1d_with_indices). + MaxPool1dWithIndices(MaxPool1dWithIndicesOpIr), + /// Operation corresponding to + /// [max pool 1d with indices backward](burn_backend::ops::ModuleOps::max_pool1d_with_indices_backward). + MaxPool1dWithIndicesBackward(MaxPool1dWithIndicesBackwardOpIr), + /// Operation corresponding to + /// [max pool 2d](burn_backend::ops::ModuleOps::max_pool1d). + MaxPool2d(MaxPool2dOpIr), + /// Operation corresponding to + /// [max pool 2d with indices](burn_backend::ops::ModuleOps::max_pool2d_with_indices). + MaxPool2dWithIndices(MaxPool2dWithIndicesOpIr), + /// Operation corresponding to + /// [max pool 2d with indices backward](burn_backend::ops::ModuleOps::max_pool2d_with_indices_backward). + MaxPool2dWithIndicesBackward(MaxPool2dWithIndicesBackwardOpIr), + /// Operation corresponding to [interpolate](burn_backend::ops::ModuleOps::interpolate). + Interpolate(InterpolateOpIr), + /// Operation corresponding to [interpolate backward](burn_backend::ops::ModuleOps::interpolate_backward). + InterpolateBackward(InterpolateBackwardOpIr), + /// Operation corresponding to [rfft](burn_backend::ops::ModuleOps::rfft) + Rfft(RfftOpIr), + /// Operation corresponding to [irfft](burn_backend::ops::ModuleOps::irfft) + IRfft(IRfftOpIr), + /// Operation corresponding to [attention](burn_backend::ops::ModuleOps::attention). + Attention(AttentionOpIr), + /// Operation corresponding to [ctc_loss](burn_backend::ops::ModuleOps::ctc_loss). + CtcLoss(CtcLossOpIr), + /// Operation corresponding to + /// [ctc_loss_backward](burn_backend::ops::ModuleOps::ctc_loss_backward). + CtcLossBackward(CtcLossBackwardOpIr), + /// Operation corresponding to [layer_norm](burn_backend::ops::ModuleOps::layer_norm). + LayerNorm(LayerNormOpIr), + /// Operation corresponding to [unfold4d](burn_backend::ops::ModuleOps::unfold4d). + Unfold4d(Unfold4dOpIr), + /// Operation corresponding to + /// [conv_transpose1d_weight_backward](burn_backend::ops::ModuleOps::conv_transpose1d_weight_backward). + ConvTranspose1dWeightBackward(ConvTranspose1dWeightBackwardOpIr), + /// Operation corresponding to + /// [conv_transpose1d_bias_backward](burn_backend::ops::ModuleOps::conv_transpose1d_bias_backward). + ConvTranspose1dBiasBackward(ConvTranspose1dBiasBackwardOpIr), + /// Operation corresponding to + /// [conv_transpose2d_weight_backward](burn_backend::ops::ModuleOps::conv_transpose2d_weight_backward). + ConvTranspose2dWeightBackward(ConvTranspose2dWeightBackwardOpIr), + /// Operation corresponding to + /// [conv_transpose2d_bias_backward](burn_backend::ops::ModuleOps::conv_transpose2d_bias_backward). + ConvTranspose2dBiasBackward(ConvTranspose2dBiasBackwardOpIr), + /// Operation corresponding to + /// [conv_transpose3d_weight_backward](burn_backend::ops::ModuleOps::conv_transpose3d_weight_backward). + ConvTranspose3dWeightBackward(ConvTranspose3dWeightBackwardOpIr), + /// Operation corresponding to + /// [conv_transpose3d_bias_backward](burn_backend::ops::ModuleOps::conv_transpose3d_bias_backward). + ConvTranspose3dBiasBackward(ConvTranspose3dBiasBackwardOpIr), +} + +/// Basic operations that can be done on any tensor type. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum BaseOperationIr { + /// Operation corresponding to: + /// + /// Float => [reshape](burn_backend::ops::FloatTensorOps::float_reshape). + /// Int => [reshape](burn_backend::ops::IntTensorOps::int_reshape). + /// Bool => [reshape](burn_backend::ops::BoolTensorOps::bool_reshape). + Reshape(ShapeOpIr), + + /// Operation corresponding to: + /// + /// Float => [swap_dims](burn_backend::ops::FloatTensorOps::float_swap_dims). + /// Int => [swap_dims](burn_backend::ops::IntTensorOps::int_swap_dims). + /// Bool => [swap_dims](burn_backend::ops::BoolTensorOps::bool_swap_dims). + SwapDims(SwapDimsOpIr), + + /// Operation corresponding to: + /// + /// Float => [permute](burn_backend::ops::FloatTensorOps::float_permute). + /// Int => [permute](burn_backend::ops::IntTensorOps::int_permute). + /// Bool => [permute](burn_backend::ops::BoolTensorOps::bool_permute). + Permute(PermuteOpIr), + + /// Operation corresponding to: + /// Float => [flip](burn_backend::ops::FloatTensorOps::float_flip). + /// Int => [flip](burn_backend::ops::IntTensorOps::int_flip). + /// Bool => [flip](burn_backend::ops::BoolTensorOps::bool_flip). + Flip(FlipOpIr), + + /// Operation corresponding to: + /// + /// Float => [expand](burn_backend::ops::FloatTensorOps::float_expand). + /// Int => [expand](burn_backend::ops::IntTensorOps::int_expand). + /// Bool => [expand](burn_backend::ops::BoolTensorOps::bool_expand). + Expand(ShapeOpIr), + + /// Unfold windows along an axis. + /// + Unfold(UnfoldOpIr), + + /// Operation corresponding to: + /// + /// Float => [slice](burn_backend::ops::FloatTensorOps::float_slice). + /// Int => [slice](burn_backend::ops::IntTensorOps::int_slice). + /// Bool => [slice](burn_backend::ops::BoolTensorOps::bool_slice). + Slice(SliceOpIr), + /// Operation corresponding to: + /// + /// Float => [slice assign](burn_backend::ops::FloatTensorOps::float_slice_assign). + /// Int => [slice assign](burn_backend::ops::IntTensorOps::int_slice_assign). + /// Bool => [slice assign](burn_backend::ops::BoolTensorOps::bool_slice_assign). + SliceAssign(SliceAssignOpIr), + /// Operation corresponding to: + /// + /// Float => [select](burn_backend::ops::FloatTensorOps::float_select). + /// Int => [select](burn_backend::ops::IntTensorOps::int_select). + /// Bool => [select](burn_backend::ops::BoolTensorOps::bool_select). + Select(SelectOpIr), + /// Operation corresponding to: + /// + /// Float => [select assign](burn_backend::ops::FloatTensorOps::float_select_add). + /// Int => [select assign](burn_backend::ops::IntTensorOps::int_select_add). + /// Bool => [select assign](burn_backend::ops::BoolTensorOps::bool_select_or). + SelectAssign(SelectAssignOpIr), + /// Operation corresponding to: + /// + /// Float => [mask where](burn_backend::ops::FloatTensorOps::float_mask_where). + /// Int => [mask where](burn_backend::ops::IntTensorOps::int_mask_where). + /// Bool => [mask where](burn_backend::ops::BoolTensorOps::bool_mask_where). + MaskWhere(MaskWhereOpIr), + /// Operation corresponding to: + /// + /// Float => [mask fill](burn_backend::ops::FloatTensorOps::float_mask_fill). + /// Int => [mask fill](burn_backend::ops::IntTensorOps::int_mask_fill). + /// Bool => [mask fill](burn_backend::ops::BoolTensorOps::bool_mask_fill). + MaskFill(MaskFillOpIr), + /// Operation corresponding to: + /// + /// Float => [gather](burn_backend::ops::FloatTensorOps::float_gather). + /// Int => [gather](burn_backend::ops::IntTensorOps::int_gather). + /// Bool => [gather](burn_backend::ops::BoolTensorOps::bool_gather). + Gather(GatherOpIr), + /// Operation corresponding to: + /// + /// Float => [scatter](burn_backend::ops::FloatTensorOps::float_scatter_add). + /// Int => [scatter](burn_backend::ops::IntTensorOps::int_scatter_add). + /// Bool => [scatter](burn_backend::ops::BoolTensorOps::bool_scatter_or). + Scatter(ScatterOpIr), + /// Multi-dimensional scatter operation. + ScatterNd(ScatterNdOpIr), + /// Multi-dimensional gather operation. + GatherNd(GatherNdOpIr), + /// Operation corresponding to: + /// + /// Float => [equal](burn_backend::ops::FloatTensorOps::float_equal). + /// Int => [equal](burn_backend::ops::IntTensorOps::int_equal). + /// Bool => [equal](burn_backend::ops::BoolTensorOps::bool_equal). + Equal(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [equal elem](burn_backend::ops::FloatTensorOps::float_equal_elem). + /// Int => [equal elem](burn_backend::ops::IntTensorOps::int_equal_elem). + /// Bool => [equal elem](burn_backend::ops::BoolTensorOps::bool_equal_elem). + EqualElem(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [repeat dim](burn_backend::ops::FloatTensorOps::float_repeat_dim). + /// Int => [repeat dim](burn_backend::ops::IntTensorOps::int_repeat_dim). + /// Bool => [repeat dim](burn_backend::ops::BoolTensorOps::bool_repeat_dim). + RepeatDim(RepeatDimOpIr), + /// Operation corresponding to: + /// + /// Float => [cat](burn_backend::ops::FloatTensorOps::float_cat). + /// Int => [cat](burn_backend::ops::IntTensorOps::int_cat). + /// Bool => [cat](burn_backend::ops::BoolTensorOps::bool_cat). + Cat(CatOpIr), + /// Cast operation, no direct operation and should be supported by fusion backend. + Cast(CastOpIr), + /// Operation corresponding to: + /// + /// Float => [empty](burn_backend::ops::FloatTensorOps::float_empty). + /// Int => [empty](burn_backend::ops::IntTensorOps::int_empty). + /// Bool => [empty](burn_backend::ops::BoolTensorOps::bool_empty). + Empty(CreationOpIr), + /// Operation corresponding to: + /// + /// Float => [ones](burn_backend::ops::FloatTensorOps::float_ones). + /// Int => [ones](burn_backend::ops::IntTensorOps::int_ones). + /// Bool => [ones](burn_backend::ops::BoolTensorOps::bool_ones). + Ones(CreationOpIr), + /// Operation corresponding to: + /// + /// Float => [zeros](burn_backend::ops::FloatTensorOps::float_zeros). + /// Int => [zeros](burn_backend::ops::IntTensorOps::int_zeros). + /// Bool => [zeros](burn_backend::ops::BoolTensorOps::bool_zeros). + Zeros(CreationOpIr), + /// Operation corresponding to: + /// + /// Float => [not_equal](burn_backend::ops::FloatTensorOps::float_not_equal). + /// Int => [not_equal](burn_backend::ops::IntTensorOps::int_not_equal). + /// Bool => [not_equal](burn_backend::ops::BoolTensorOps::bool_not_equal). + NotEqual(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [not_equal_elem](burn_backend::ops::FloatTensorOps::float_not_equal_elem). + /// Int => [not_equal_elem](burn_backend::ops::IntTensorOps::int_not_equal_elem). + /// Bool => [not_equal_elem](burn_backend::ops::BoolTensorOps::bool_not_equal_elem). + NotEqualElem(ScalarOpIr), + /// Reduce-`all` over the input tensor. + /// + /// Float/Int input is treated as a non-zero check; bool input is the value itself. + /// Output is a single-element bool tensor. + All(ReduceOpIr), + /// Reduce-`any` over the input tensor. + Any(ReduceOpIr), + /// Reduce-`all` along a dim. + AllDim(ReduceDimOpIr), + /// Reduce-`any` along a dim. + AnyDim(ReduceDimOpIr), +} + +/// Numeric operations on int and float tensors. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum NumericOperationIr { + /// Operation corresponding to: + /// + /// Float => [add](burn_backend::ops::FloatTensorOps::float_add). + /// Int => [add](burn_backend::ops::IntTensorOps::int_add). + Add(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [add scalar](burn_backend::ops::FloatTensorOps::float_add_scalar). + /// Int => [add scalar](burn_backend::ops::IntTensorOps::int_add_scalar). + AddScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [sub](burn_backend::ops::FloatTensorOps::float_sub). + /// Int => [sub](burn_backend::ops::IntTensorOps::int_sub). + Sub(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [sub scalar](burn_backend::ops::FloatTensorOps::float_sub_scalar). + /// Int => [sub scalar](burn_backend::ops::IntTensorOps::int_sub_scalar). + SubScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [div](burn_backend::ops::FloatTensorOps::float_div). + /// Int => [div](burn_backend::ops::IntTensorOps::int_div). + Div(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [div scalar](burn_backend::ops::FloatTensorOps::float_div_scalar). + /// Int => [div scalar](burn_backend::ops::IntTensorOps::int_div_scalar). + DivScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [rem](burn_backend::ops::FloatTensorOps::float_remainder). + /// Int => [rem](burn_backend::ops::IntTensorOps::int_remainder). + Rem(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [rem scalar](burn_backend::ops::FloatTensorOps::float_remainder_scalar). + /// Int => [rem scalar](burn_backend::ops::IntTensorOps::int_remainder_scalar). + RemScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [mul](burn_backend::ops::FloatTensorOps::float_mul). + /// Int => [mul](burn_backend::ops::IntTensorOps::int_mul). + Mul(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [mul scalar](burn_backend::ops::FloatTensorOps::float_mul_scalar). + /// Int => [mul scalar](burn_backend::ops::IntTensorOps::int_mul_scalar). + MulScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [abs](burn_backend::ops::FloatTensorOps::float_abs). + /// Int => [abs](burn_backend::ops::IntTensorOps::int_abs). + Abs(UnaryOpIr), + /// Operation corresponding to: + /// + /// Float => [full](burn_backend::ops::FloatTensorOps::float_full). + /// Int => [full](burn_backend::ops::IntTensorOps::int_full). + Full(FullOpIr), + /// Operation corresponding to: + /// + /// Float => [mean dim](burn_backend::ops::FloatTensorOps::float_mean_dim). + /// Int => [mean dim](burn_backend::ops::IntTensorOps::int_mean_dim). + MeanDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [mean](burn_backend::ops::FloatTensorOps::float_mean). + /// Int => [mean](burn_backend::ops::IntTensorOps::int_mean). + Mean(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [sum](burn_backend::ops::FloatTensorOps::float_sum). + /// Int => [sum](burn_backend::ops::IntTensorOps::int_sum). + Sum(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [sum dim](burn_backend::ops::FloatTensorOps::float_sum_dim). + /// Int => [sum dim](burn_backend::ops::IntTensorOps::int_sum_dim). + SumDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [prod](burn_backend::ops::FloatTensorOps::float_prod). + /// Int => [prod](burn_backend::ops::IntTensorOps::int_prod). + Prod(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [prod dim](burn_backend::ops::FloatTensorOps::float_prod_dim). + /// Int => [prod dim](burn_backend::ops::IntTensorOps::int_prod_dim). + ProdDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [greater](burn_backend::ops::FloatTensorOps::float_greater). + /// Int => [greater](burn_backend::ops::IntTensorOps::int_greater). + Greater(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [greater elem](burn_backend::ops::FloatTensorOps::float_greater_elem). + /// Int => [greater elem](burn_backend::ops::IntTensorOps::int_greater_elem). + GreaterElem(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [greater equal](burn_backend::ops::FloatTensorOps::float_greater_elem). + /// Int => [greater elem](burn_backend::ops::IntTensorOps::int_greater_elem). + GreaterEqual(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [greater equal elem](burn_backend::ops::FloatTensorOps::float_greater_equal_elem). + /// Int => [greater equal elem](burn_backend::ops::IntTensorOps::int_greater_equal_elem). + GreaterEqualElem(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [lower](burn_backend::ops::FloatTensorOps::float_lower). + /// Int => [lower](burn_backend::ops::IntTensorOps::int_lower). + Lower(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [lower elem](burn_backend::ops::FloatTensorOps::float_lower_elem). + /// Int => [lower elem](burn_backend::ops::IntTensorOps::int_lower_elem). + LowerElem(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [lower equal](burn_backend::ops::FloatTensorOps::float_lower_equal). + /// Int => [lower equal](burn_backend::ops::IntTensorOps::int_lower_equal). + LowerEqual(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [lower equal elem](burn_backend::ops::FloatTensorOps::float_lower_equal_elem). + /// Int => [lower equal elem](burn_backend::ops::IntTensorOps::int_lower_equal_elem). + LowerEqualElem(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [argmax](burn_backend::ops::FloatTensorOps::float_argmax). + /// Int => [argmax](burn_backend::ops::IntTensorOps::int_argmax). + ArgMax(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [argtopk](burn_backend::ops::FloatTensorOps::float_argtopk). + /// Int => [argtopk](burn_backend::ops::IntTensorOps::int_argtopk). + ArgTopK(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [topk](burn_backend::ops::FloatTensorOps::float_topk). + /// Int => [topk](burn_backend::ops::IntTensorOps::int_topk). + TopK(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [argmin](burn_backend::ops::FloatTensorOps::float_argmin). + /// Int => [argmin](burn_backend::ops::IntTensorOps::int_argmin). + ArgMin(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [max](burn_backend::ops::FloatTensorOps::float_max). + /// Int => [max](burn_backend::ops::IntTensorOps::int_max). + Max(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [max dim with indices](burn_backend::ops::FloatTensorOps::float_max_dim_with_indices). + /// Int => [max dim with indices](burn_backend::ops::IntTensorOps::int_max_dim_with_indices). + MaxDimWithIndices(ReduceDimWithIndicesOpIr), + /// Operation corresponding to: + /// + /// Float => [min dim with indices](burn_backend::ops::FloatTensorOps::float_min_dim_with_indices). + /// Int => [min dim with indices](burn_backend::ops::IntTensorOps::int_min_dim_with_indices). + MinDimWithIndices(ReduceDimWithIndicesOpIr), + /// Operation corresponding to: + /// + /// Float => [min](burn_backend::ops::FloatTensorOps::float_min). + /// Int => [min](burn_backend::ops::IntTensorOps::int_min). + Min(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [max dim](burn_backend::ops::FloatTensorOps::float_max_dim). + /// Int => [max dim](burn_backend::ops::IntTensorOps::int_max_dim). + MaxDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [min dim](burn_backend::ops::FloatTensorOps::float_min_dim). + /// Int => [min dim](burn_backend::ops::IntTensorOps::int_min_dim). + MinDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [max_abs](burn_backend::ops::FloatTensorOps::float_max_abs). + /// Int => [max_abs](burn_backend::ops::IntTensorOps::int_max_abs). + MaxAbs(ReduceOpIr), + /// Operation corresponding to: + /// + /// Float => [max_abs dim](burn_backend::ops::FloatTensorOps::float_max_abs_dim). + /// Int => [max_abs dim](burn_backend::ops::IntTensorOps::int_max_abs_dim). + MaxAbsDim(ReduceDimOpIr), + /// Operation corresponding to: + /// + /// Float => [clamp](burn_backend::ops::FloatTensorOps::float_clamp). + /// Int => [clamp](burn_backend::ops::IntTensorOps::int_clamp). + Clamp(ClampOpIr), + /// Operation corresponding to: + /// + /// Int => [random](burn_backend::ops::IntTensorOps::int_random). + IntRandom(RandomOpIr), + /// Operation corresponding to: + /// + /// Float => [powf](burn_backend::ops::FloatTensorOps::float_powi). + /// Int => [powf](burn_backend::ops::IntTensorOps::int_powi). + Powi(BinaryOpIr), + /// Operation corresponding to: + /// + /// Float => [powi_scalar](burn_backend::ops::FloatTensorOps::float_powi_scalar). + /// Int => [powi_scalar](burn_backend::ops::IntTensorOps::int_powi_scalar). + PowiScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [cumsum](burn_backend::ops::FloatTensorOps::float_cumsum). + /// Int => [cumsum](burn_backend::ops::IntTensorOps::int_cumsum). + CumSum(DimOpIr), + /// Operation corresponding to: + /// + /// Float => [cumprod](burn_backend::ops::FloatTensorOps::float_cumprod). + /// Int => [cumprod](burn_backend::ops::IntTensorOps::int_cumprod). + CumProd(DimOpIr), + /// Operation corresponding to: + /// + /// Float => [cummin](burn_backend::ops::FloatTensorOps::float_cummin). + /// Int => [cummin](burn_backend::ops::IntTensorOps::int_cummin). + CumMin(DimOpIr), + /// Operation corresponding to: + /// + /// Float => [cummax](burn_backend::ops::FloatTensorOps::float_cummax). + /// Int => [cummax](burn_backend::ops::IntTensorOps::int_cummax). + CumMax(DimOpIr), + /// Operation corresponding to: + /// + /// Float => [neg](burn_backend::ops::FloatTensorOps::float_neg). + /// Int => [neg](burn_backend::ops::IntTensorOps::int_neg). + Neg(UnaryOpIr), + /// Operation corresponding to: + /// + /// Float => [sign](burn_backend::ops::FloatTensorOps::float_sign). + /// Int => [sign](burn_backend::ops::IntTensorOps::int_sign). + Sign(UnaryOpIr), + /// Operation corresponding to: + /// + /// Float => [clamp_min](burn_backend::ops::FloatTensorOps::float_clamp_min). + /// Int => [clamp_min](burn_backend::ops::IntTensorOps::int_clamp_min). + ClampMin(ScalarOpIr), + /// Operation corresponding to: + /// + /// Float => [clamp_max](burn_backend::ops::FloatTensorOps::float_clamp_max). + /// Int => [clamp_max](burn_backend::ops::IntTensorOps::int_clamp_max). + ClampMax(ScalarOpIr), + /// Sort along a dim. Output shares the input's shape and dtype. + Sort(SortOpIr), + /// Sort along a dim, also returning the source indices. + SortWithIndices(SortWithIndicesOpIr), + /// Sort along a dim and return only the indices (i.e., the argsort). + /// + /// Shares [`SortOpIr`] with [`Sort`](Self::Sort); only the output dtype differs. + ArgSort(SortOpIr), +} + +/// Operation intermediate representation specific to an int tensor. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum IntOperationIr { + /// Operation corresponding to [into float](burn_backend::ops::IntTensorOps::int_into_float). + IntoFloat(CastOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise and](burn_backend::ops::IntTensorOps::bitwise_and). + BitwiseAnd(BinaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise and scalar](burn_backend::ops::IntTensorOps::bitwise_and_scalar). + BitwiseAndScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise or](burn_backend::ops::IntTensorOps::bitwise_or). + BitwiseOr(BinaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise or scalar](burn_backend::ops::IntTensorOps::bitwise_or_scalar). + BitwiseOrScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise xor](burn_backend::ops::IntTensorOps::bitwise_xor). + BitwiseXor(BinaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise xor scalar](burn_backend::ops::IntTensorOps::bitwise_xor_scalar). + BitwiseXorScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise not](burn_backend::ops::IntTensorOps::bitwise_not). + BitwiseNot(UnaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise left shift](burn_backend::ops::IntTensorOps::bitwise_left_shift). + BitwiseLeftShift(BinaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise left shift scalar](burn_backend::ops::IntTensorOps::bitwise_left_shift_scalar). + BitwiseLeftShiftScalar(ScalarOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise right shift](burn_backend::ops::IntTensorOps::bitwise_right_shift). + BitwiseRightShift(BinaryOpIr), + /// Operation corresponding to: + /// + /// Int => [bitwise right shift scalar](burn_backend::ops::IntTensorOps::bitwise_right_shift_scalar). + BitwiseRightShiftScalar(ScalarOpIr), + /// Operation corresponding to [matmul](burn_backend::ops::IntTensorOps::int_matmul). + Matmul(MatmulOpIr), +} + +/// Operation intermediate representation specific to a bool tensor. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum BoolOperationIr { + /// Operation corresponding to [into float](burn_backend::ops::BoolTensorOps::bool_into_float). + IntoFloat(CastOpIr), + /// Operation corresponding to [into int](burn_backend::ops::BoolTensorOps::bool_into_int). + IntoInt(CastOpIr), + /// Operation corresponding to [not](burn_backend::ops::BoolTensorOps::bool_not). + Not(UnaryOpIr), + /// Operation corresponding to [and](burn_backend::ops::BoolTensorOps::bool_and). + And(BinaryOpIr), + /// Operation corresponding to [or](burn_backend::ops::BoolTensorOps::bool_or). + Or(BinaryOpIr), + /// Operation corresponding to [xor](burn_backend::ops::BoolTensorOps::bool_xor). + Xor(BinaryOpIr), +} + +/// Operations that can be done on distributed tensors. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +pub enum DistributedOperationIr { + /// Operation corresponding to: + /// [all_reduce](burn_backend::distributed::DistributedOps::all_reduce). + AllReduce(AllReduceOpIr), + /// Resolve the pending collective operations on the executing device. Corresponds to + /// [sync_collective](burn_backend::distributed::DistributedOps::sync_collective). + /// + /// Fire-and-forget and payload-free: it syncs whichever device the interpreter is bound to. + /// Modeled as an operation (not a side-channel call) so it travels the normal op stream + /// alongside [`AllReduce`](DistributedOperationIr::AllReduce) and is ordered against it. + SyncCollective, +} + +/// Swap dim operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct SwapDimsOpIr { + /// Input tensor intermediate representation. + pub input: TensorIr, + /// Output tensor intermediate representation. + pub out: TensorIr, + /// The first dim to swap. + pub dim1: usize, + /// The second dim to swap. + pub dim2: usize, +} + +/// Permute operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct PermuteOpIr { + /// Input tensor intermediate representation. + pub input: TensorIr, + /// Output tensor intermediate representation. + pub out: TensorIr, + /// The new order of the dimensions. + pub axes: Vec, +} + +/// Shape operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ShapeOpIr { + /// Input tensor intermediate representation. + pub input: TensorIr, + /// Output tensor intermediate representation with the new shape. + pub out: TensorIr, +} + +/// Unfold operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct UnfoldOpIr { + /// Input tensor intermediate representation. + pub input: TensorIr, + /// Output tensor intermediate representation. + pub out: TensorIr, + + /// The selected dim. + pub dim: usize, + /// The window size. + pub size: usize, + /// The window step along dim. + pub step: usize, +} + +/// Flip operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct FlipOpIr { + /// Input tensor intermediate representation. + pub input: TensorIr, + /// Output tensor intermediate representation. + pub out: TensorIr, + /// The dimensions to flip. + pub axes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct RandomOpIr { + pub out: TensorIr, + pub distribution: Distribution, +} + +/// Creation operation intermediate representation. +/// As opposed to [InitOperationIr], creation operations are lazy initialized. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct CreationOpIr { + /// Output tensor intermediate representation. + pub out: TensorIr, +} + +/// Full operation intermediate representation. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct FullOpIr { + /// Output tensor intermediate representation. + pub out: TensorIr, + /// Fill value. + pub value: ScalarIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +/// Declares a tensor has been initialized. +/// +/// It is necessary to register for proper orphan detection and avoid memory leak. +pub struct InitOperationIr { + /// The initialized tensor. + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct BinaryOpIr { + pub lhs: TensorIr, + pub rhs: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MatmulOpIr { + pub lhs: TensorIr, + pub rhs: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct CrossOpIr { + pub lhs: TensorIr, + pub rhs: TensorIr, + pub out: TensorIr, + pub dim: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct UnaryOpIr { + pub input: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ScalarOpIr { + pub lhs: TensorIr, + // TODO: Make that an enum with `Value` and `Id` variants for relative/global + // conversion. + pub rhs: ScalarIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash)] +#[allow(missing_docs)] +pub struct ReduceOpIr { + pub input: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash)] +#[allow(missing_docs)] +pub struct ReduceDimOpIr { + pub input: TensorIr, + pub out: TensorIr, + pub axis: usize, + pub accumulator_len: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct CastOpIr { + pub input: TensorIr, + pub out: TensorIr, +} + +/// IR for operations that operate along a dimension without reducing it. +/// Unlike `ReduceDimOpIr`, the output shape is the same as the input shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash)] +#[allow(missing_docs)] +pub struct DimOpIr { + pub input: TensorIr, + pub out: TensorIr, + pub axis: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct GatherOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub indices: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ScatterOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub indices: TensorIr, + pub value: TensorIr, + pub update: IndexingUpdateOp, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ScatterNdOpIr { + pub data: TensorIr, + pub indices: TensorIr, + pub values: TensorIr, + pub reduction: IndexingUpdateOp, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct GatherNdOpIr { + pub data: TensorIr, + pub indices: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct SelectOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub indices: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct SelectAssignOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub indices: TensorIr, + pub value: TensorIr, + pub update: IndexingUpdateOp, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct SliceOpIr { + pub tensor: TensorIr, + pub ranges: Vec, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct SliceAssignOpIr { + pub tensor: TensorIr, + pub ranges: Vec, + pub value: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaskWhereOpIr { + pub tensor: TensorIr, + pub mask: TensorIr, + pub value: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaskFillOpIr { + pub tensor: TensorIr, + pub mask: TensorIr, + pub value: ScalarIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ClampOpIr { + pub tensor: TensorIr, + pub min: ScalarIr, + pub max: ScalarIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct RepeatDimOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub times: usize, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct CatOpIr { + pub tensors: Vec, + pub dim: usize, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AllReduceOpIr { + pub tensor: TensorIr, + pub out: TensorIr, + /// How to reduce the values across the participating devices. + pub op: burn_backend::distributed::ReduceOperation, + /// The devices participating in the collective operation. + pub device_ids: Vec, +} + +/// Serializable representation of a [device id](burn_backend::DeviceId). +/// +/// The intermediate representation is part of the wire protocol (e.g. the remote backend), so it +/// cannot store `burn_backend::DeviceId` directly since that type is not serializable. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceIdIr { + /// Identifies the type of the device. + pub type_id: u16, + /// Identifies the device number. + pub index_id: u16, +} + +impl From for DeviceIdIr { + fn from(value: burn_backend::DeviceId) -> Self { + Self { + type_id: value.type_id, + index_id: value.index_id, + } + } +} + +impl From for burn_backend::DeviceId { + fn from(value: DeviceIdIr) -> Self { + burn_backend::DeviceId::new(value.type_id, value.index_id) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ReduceDimWithIndicesOpIr { + pub tensor: TensorIr, + pub dim: usize, + pub out: TensorIr, + pub out_indices: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct EmbeddingOpIr { + pub weights: TensorIr, + pub indices: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct EmbeddingBackwardOpIr { + pub weights: TensorIr, + pub out_grad: TensorIr, + pub indices: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct LinearOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct LinearXBackwardOpIr { + pub weight: TensorIr, + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct LinearWeightBackwardOpIr { + pub x: TensorIr, + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct LinearBiasBackwardOpIr { + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv1dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: Conv1dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv1dXBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv1dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv1dWeightBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv1dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv1dBiasBackwardOpIr { + pub x: TensorIr, + pub bias: TensorIr, + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv2dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: Conv2dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv2dXBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv2dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv2dWeightBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv2dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv2dBiasBackwardOpIr { + pub x: TensorIr, + pub bias: TensorIr, + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct DeformConv2dOpIr { + pub x: TensorIr, + pub offset: TensorIr, + pub weight: TensorIr, + pub mask: Option, + pub bias: Option, + pub options: DeformableConv2dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct DeformConv2dBackwardOpIr { + pub x: TensorIr, + pub offset: TensorIr, + pub weight: TensorIr, + pub mask: Option, + pub bias: Option, + pub out_grad: TensorIr, + pub options: DeformableConv2dOptionsIr, + pub input_grad: TensorIr, + pub offset_grad: TensorIr, + pub weight_grad: TensorIr, + pub mask_grad: Option, + pub bias_grad: Option, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv3dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: Conv3dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv3dXBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv3dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv3dWeightBackwardOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub output_grad: TensorIr, + pub options: Conv3dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv3dBiasBackwardOpIr { + pub x: TensorIr, + pub bias: TensorIr, + pub output_grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose1dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: ConvTranspose1dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose2dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: ConvTranspose2dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose3dOpIr { + pub x: TensorIr, + pub weight: TensorIr, + pub bias: Option, + pub options: ConvTranspose3dOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv1dOptionsIr { + pub stride: [usize; 1], + pub padding: [usize; 1], + pub dilation: [usize; 1], + pub groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv2dOptionsIr { + pub stride: [usize; 2], + pub padding: [usize; 2], + pub dilation: [usize; 2], + pub groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct DeformableConv2dOptionsIr { + pub stride: [usize; 2], + pub padding: [usize; 2], + pub dilation: [usize; 2], + pub weight_groups: usize, + pub offset_groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Conv3dOptionsIr { + pub stride: [usize; 3], + pub padding: [usize; 3], + pub dilation: [usize; 3], + pub groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose1dOptionsIr { + pub stride: [usize; 1], + pub padding: [usize; 1], + pub padding_out: [usize; 1], + pub dilation: [usize; 1], + pub groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose2dOptionsIr { + pub stride: [usize; 2], + pub padding: [usize; 2], + pub padding_out: [usize; 2], + pub dilation: [usize; 2], + pub groups: usize, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConvTranspose3dOptionsIr { + pub stride: [usize; 3], + pub padding: [usize; 3], + pub padding_out: [usize; 3], + pub dilation: [usize; 3], + pub groups: usize, +} + +/// Quantization parameters intermediate representation. +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuantizationParametersIr { + /// The scaling factor. + pub scales: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct QuantizeOpIr { + pub tensor: TensorIr, + pub qparams: QuantizationParametersIr, + pub scheme: QuantScheme, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct DequantizeOpIr { + pub input: TensorIr, + pub out: TensorIr, +} + +impl From> for Conv1dOptionsIr { + fn from(value: ConvOptions<1>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From> for Conv2dOptionsIr { + fn from(value: ConvOptions<2>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From> for Conv3dOptionsIr { + fn from(value: ConvOptions<3>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From> for DeformableConv2dOptionsIr { + fn from(value: DeformConvOptions<2>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + dilation: value.dilation, + weight_groups: value.weight_groups, + offset_groups: value.offset_groups, + } + } +} + +impl From> for ConvTranspose1dOptionsIr { + fn from(value: ConvTransposeOptions<1>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + padding_out: value.padding_out, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From> for ConvTranspose2dOptionsIr { + fn from(value: ConvTransposeOptions<2>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + padding_out: value.padding_out, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From> for ConvTranspose3dOptionsIr { + fn from(value: ConvTransposeOptions<3>) -> Self { + Self { + stride: value.stride, + padding: value.padding, + padding_out: value.padding_out, + dilation: value.dilation, + groups: value.groups, + } + } +} + +impl From for ConvOptions<1> { + fn from(val: Conv1dOptionsIr) -> Self { + ConvOptions { + stride: val.stride, + padding: val.padding, + dilation: val.dilation, + groups: val.groups, + } + } +} + +impl From for ConvOptions<2> { + fn from(val: Conv2dOptionsIr) -> Self { + ConvOptions { + stride: val.stride, + padding: val.padding, + dilation: val.dilation, + groups: val.groups, + } + } +} + +impl From for ConvOptions<3> { + fn from(val: Conv3dOptionsIr) -> Self { + ConvOptions { + stride: val.stride, + padding: val.padding, + dilation: val.dilation, + groups: val.groups, + } + } +} + +impl From for DeformConvOptions<2> { + fn from(value: DeformableConv2dOptionsIr) -> Self { + DeformConvOptions { + stride: value.stride, + padding: value.padding, + dilation: value.dilation, + weight_groups: value.weight_groups, + offset_groups: value.offset_groups, + } + } +} + +impl From for ConvTransposeOptions<1> { + fn from(val: ConvTranspose1dOptionsIr) -> Self { + ConvTransposeOptions { + stride: val.stride, + padding: val.padding, + padding_out: val.padding_out, + dilation: val.dilation, + groups: val.groups, + } + } +} + +impl From for ConvTransposeOptions<2> { + fn from(val: ConvTranspose2dOptionsIr) -> Self { + ConvTransposeOptions { + stride: val.stride, + padding: val.padding, + padding_out: val.padding_out, + dilation: val.dilation, + groups: val.groups, + } + } +} + +impl From for ConvTransposeOptions<3> { + fn from(val: ConvTranspose3dOptionsIr) -> Self { + ConvTransposeOptions { + stride: val.stride, + padding: val.padding, + padding_out: val.padding_out, + dilation: val.dilation, + groups: val.groups, + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AvgPool1dOpIr { + pub x: TensorIr, + pub kernel_size: usize, + pub stride: usize, + pub padding: usize, + pub count_include_pad: bool, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AvgPool2dOpIr { + pub x: TensorIr, + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub count_include_pad: bool, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AvgPool1dBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub kernel_size: usize, + pub stride: usize, + pub padding: usize, + pub count_include_pad: bool, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AvgPool2dBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub count_include_pad: bool, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AdaptiveAvgPool1dOpIr { + pub x: TensorIr, + pub output_size: usize, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AdaptiveAvgPool2dOpIr { + pub x: TensorIr, + pub output_size: [usize; 2], + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AdaptiveAvgPool1dBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AdaptiveAvgPool2dBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaxPool1dOpIr { + pub x: TensorIr, + pub kernel_size: usize, + pub stride: usize, + pub padding: usize, + pub dilation: usize, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaxPool1dWithIndicesOpIr { + pub x: TensorIr, + pub kernel_size: usize, + pub stride: usize, + pub padding: usize, + pub dilation: usize, + pub ceil_mode: bool, + pub out: TensorIr, + pub out_indices: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaxPool1dWithIndicesBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub indices: TensorIr, + pub kernel_size: usize, + pub stride: usize, + pub padding: usize, + pub dilation: usize, + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaxPool2dOpIr { + pub x: TensorIr, + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub dilation: [usize; 2], + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[allow(missing_docs)] +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct MaxPool2dWithIndicesOpIr { + pub x: TensorIr, + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub dilation: [usize; 2], + pub ceil_mode: bool, + pub out: TensorIr, + pub out_indices: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct MaxPool2dWithIndicesBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub indices: TensorIr, + pub kernel_size: [usize; 2], + pub stride: [usize; 2], + pub padding: [usize; 2], + pub dilation: [usize; 2], + pub ceil_mode: bool, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub enum InterpolateModeIr { + Nearest, + NearestExact, + Bilinear, + Bicubic, + Lanczos3, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct InterpolateOptionsIr { + pub mode: InterpolateModeIr, + pub align_corners: bool, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct InterpolateOpIr { + pub x: TensorIr, + pub output_size: [usize; 2], + pub options: InterpolateOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct RfftOpIr { + pub signal: TensorIr, + pub dim: usize, + pub n: Option, + pub out_re: TensorIr, + pub out_im: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct IRfftOpIr { + pub input_re: TensorIr, + pub input_im: TensorIr, + pub dim: usize, + pub n: Option, + pub out_signal: TensorIr, +} + +#[allow(missing_docs)] +impl RfftOpIr { + pub fn create(signal: TensorIr, dim: usize, n: Option, mut new_id: F) -> Self + where + F: FnMut() -> crate::TensorId, + { + // `n` is required to be a power of two at the public API boundary, so + // the output has `n / 2 + 1` bins (matching scipy/torch for pow2 n). + let mut shape = signal.shape.clone(); + let fft_len = n.unwrap_or(shape[dim]); + shape[dim] = fft_len / 2 + 1; + let dtype = signal.dtype; + + Self { + signal, + dim, + n, + out_re: TensorIr::uninit(new_id(), shape.clone(), dtype), + out_im: TensorIr::uninit(new_id(), shape, dtype), + } + } +} + +#[allow(missing_docs)] +impl IRfftOpIr { + pub fn create( + input_re: TensorIr, + input_im: TensorIr, + dim: usize, + n: Option, + mut new_id: F, + ) -> Self + where + F: FnMut() -> crate::TensorId, + { + debug_assert!( + input_re.shape[dim] >= 1, + "IRfftOpIr: input spectrum dimension must be >= 1" + ); + debug_assert!( + !matches!(n, Some(0)), + "IRfftOpIr: n must be >= 1 when specified" + ); + let mut shape = input_re.shape.clone(); + shape[dim] = n.unwrap_or((shape[dim] - 1) * 2); + let dtype = input_re.dtype; + + Self { + input_re, + input_im, + dim, + n, + out_signal: TensorIr::uninit(new_id(), shape, dtype), + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AttentionOptionsIr { + pub scale: Option, + pub softcap: Option, + pub is_causal: bool, +} + +impl From for AttentionModuleOptions { + fn from(ir: AttentionOptionsIr) -> Self { + AttentionModuleOptions { + scale: ir.scale.map(|s| s.elem()), + softcap: ir.softcap.map(|s| s.elem()), + is_causal: ir.is_causal, + } + } +} + +impl From for AttentionOptionsIr { + fn from(ir: AttentionModuleOptions) -> Self { + AttentionOptionsIr { + scale: ir.scale.map(ScalarIr::Float), + softcap: ir.softcap.map(ScalarIr::Float), + is_causal: ir.is_causal, + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct AttentionOpIr { + pub query: TensorIr, + pub key: TensorIr, + pub value: TensorIr, + pub mask: Option, + pub attn_bias: Option, + pub options: AttentionOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct CtcLossOpIr { + pub log_probs: TensorIr, + pub targets: TensorIr, + pub input_lengths: TensorIr, + pub target_lengths: TensorIr, + pub blank: usize, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct CtcLossBackwardOpIr { + pub log_probs: TensorIr, + pub targets: TensorIr, + pub input_lengths: TensorIr, + pub target_lengths: TensorIr, + pub grad_loss: TensorIr, + pub blank: usize, + pub out: TensorIr, +} + +impl From for InterpolateMode { + fn from(val: InterpolateModeIr) -> Self { + match val { + InterpolateModeIr::Nearest => Self::Nearest, + InterpolateModeIr::NearestExact => Self::NearestExact, + InterpolateModeIr::Bilinear => Self::Bilinear, + InterpolateModeIr::Bicubic => Self::Bicubic, + InterpolateModeIr::Lanczos3 => Self::Lanczos3, + } + } +} + +impl From for InterpolateOptions { + fn from(val: InterpolateOptionsIr) -> Self { + Self::new(val.mode.into()).with_align_corners(val.align_corners) + } +} + +impl From for InterpolateModeIr { + fn from(val: InterpolateMode) -> Self { + match val { + InterpolateMode::Nearest => Self::Nearest, + InterpolateMode::NearestExact => Self::Nearest, + InterpolateMode::Bilinear => Self::Bilinear, + InterpolateMode::Bicubic => Self::Bicubic, + InterpolateMode::Lanczos3 => Self::Lanczos3, + } + } +} + +impl From for InterpolateOptionsIr { + fn from(val: InterpolateOptions) -> Self { + Self { + mode: val.mode.into(), + align_corners: val.align_corners, + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct InterpolateBackwardOpIr { + pub x: TensorIr, + pub grad: TensorIr, + pub output_size: [usize; 2], + pub options: InterpolateOptionsIr, + pub out: TensorIr, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub enum GridSamplePaddingModeIr { + Zeros, + Border, + Reflection, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct GridSampleOptionsIr { + pub mode: InterpolateModeIr, + pub padding_mode: GridSamplePaddingModeIr, + pub align_corners: bool, +} + +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct GridSample2dOpIr { + pub tensor: TensorIr, + pub grid: TensorIr, + pub options: GridSampleOptionsIr, + pub out: TensorIr, +} + +impl From for GridSamplePaddingMode { + fn from(val: GridSamplePaddingModeIr) -> Self { + match val { + GridSamplePaddingModeIr::Zeros => Self::Zeros, + GridSamplePaddingModeIr::Border => Self::Border, + GridSamplePaddingModeIr::Reflection => Self::Reflection, + } + } +} + +impl From for GridSamplePaddingModeIr { + fn from(val: GridSamplePaddingMode) -> Self { + match val { + GridSamplePaddingMode::Zeros => Self::Zeros, + GridSamplePaddingMode::Border => Self::Border, + GridSamplePaddingMode::Reflection => Self::Reflection, + } + } +} + +impl From for GridSampleOptions { + fn from(val: GridSampleOptionsIr) -> Self { + Self { + mode: val.mode.into(), + padding_mode: val.padding_mode.into(), + align_corners: val.align_corners, + } + } +} + +impl From for GridSampleOptionsIr { + fn from(val: GridSampleOptions) -> Self { + Self { + mode: val.mode.into(), + padding_mode: val.padding_mode.into(), + align_corners: val.align_corners, + } + } +} + +impl OperationIr { + /// Get all input [tensors](TensorIr) involved with the current operation. + pub fn inputs(&self) -> impl Iterator { + match self { + OperationIr::BaseFloat(repr) => repr.inputs(), + OperationIr::BaseInt(repr) => repr.inputs(), + OperationIr::BaseBool(repr) => repr.inputs(), + OperationIr::NumericFloat(_dtype, repr) => repr.inputs(), + OperationIr::NumericInt(_dtype, repr) => repr.inputs(), + OperationIr::Bool(repr) => repr.inputs(), + OperationIr::Int(repr) => repr.inputs(), + OperationIr::Float(_dtype, repr) => repr.inputs(), + OperationIr::Module(repr) => repr.inputs(), + OperationIr::Init(repr) => repr.inputs(), + OperationIr::Custom(repr) => repr.inputs(), + OperationIr::Drop(repr) => Box::new([repr].into_iter()), + OperationIr::Distributed(repr) => repr.inputs(), + OperationIr::Activation(repr) => repr.inputs(), + } + } + + /// Get all output [tensors](TensorIr) involved with the current operation. + pub fn outputs(&self) -> impl Iterator { + match self { + OperationIr::BaseFloat(repr) => repr.outputs(), + OperationIr::BaseInt(repr) => repr.outputs(), + OperationIr::BaseBool(repr) => repr.outputs(), + OperationIr::NumericFloat(_dtype, repr) => repr.outputs(), + OperationIr::NumericInt(_dtype, repr) => repr.outputs(), + OperationIr::Bool(repr) => repr.outputs(), + OperationIr::Int(repr) => repr.outputs(), + OperationIr::Float(_dtype, repr) => repr.outputs(), + OperationIr::Module(repr) => repr.outputs(), + OperationIr::Init(repr) => repr.outputs(), + OperationIr::Custom(repr) => repr.outputs(), + OperationIr::Drop(_repr) => Box::new([].into_iter()), + OperationIr::Distributed(repr) => repr.outputs(), + OperationIr::Activation(repr) => repr.outputs(), + } + } + + /// Get all [tensor](TensorIr) involved with the current operation. + pub fn nodes(&self) -> Vec<&TensorIr> { + self.inputs().chain(self.outputs()).collect() + } + + /// Set the given nodes that are [read write](super::TensorStatus::ReadWrite) to + /// [read only](super::TensorStatus::ReadOnly) in the current operation. + /// + /// Returns the tensor that were updated with their original representation. + pub fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + match self { + OperationIr::BaseFloat(repr) => repr.mark_read_only(nodes), + OperationIr::BaseInt(repr) => repr.mark_read_only(nodes), + OperationIr::BaseBool(repr) => repr.mark_read_only(nodes), + OperationIr::NumericFloat(_dtype, repr) => repr.mark_read_only(nodes), + OperationIr::NumericInt(_dtype, repr) => repr.mark_read_only(nodes), + OperationIr::Bool(repr) => repr.mark_read_only(nodes), + OperationIr::Int(repr) => repr.mark_read_only(nodes), + OperationIr::Float(_dtype, repr) => repr.mark_read_only(nodes), + OperationIr::Module(repr) => repr.mark_read_only(nodes), + OperationIr::Init(_) => Vec::new(), + OperationIr::Drop(repr) => { + let mut output = Vec::new(); + repr.mark_read_only(nodes, &mut output); + output + } + OperationIr::Custom(repr) => { + let mut output = Vec::new(); + + for input in repr.inputs.iter_mut() { + input.mark_read_only(nodes, &mut output); + } + + output + } + OperationIr::Distributed(repr) => repr.mark_read_only(nodes), + OperationIr::Activation(repr) => repr.mark_read_only(nodes), + } + } + + /// Visit every mutable component of this operation in place. + /// + /// For each variant, visits its [tensors](TensorIr) (in the same order as + /// [`inputs`](Self::inputs) then [`outputs`](Self::outputs) combined), then its + /// [scalars](ScalarIr), then its slice ranges. Only `Slice`/`SliceAssign` (under the `Base*` + /// variants) carry ranges; every other category visits no ranges. + pub fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + OperationIr::BaseFloat(repr) => repr.visit_mut(v), + OperationIr::BaseInt(repr) => repr.visit_mut(v), + OperationIr::BaseBool(repr) => repr.visit_mut(v), + OperationIr::NumericFloat(_dtype, repr) => repr.visit_mut(v), + OperationIr::NumericInt(_dtype, repr) => repr.visit_mut(v), + OperationIr::Bool(repr) => repr.visit_mut(v), + OperationIr::Int(repr) => repr.visit_mut(v), + OperationIr::Float(_dtype, repr) => repr.visit_mut(v), + OperationIr::Module(repr) => repr.visit_mut(v), + OperationIr::Init(repr) => repr.visit_mut(v), + OperationIr::Custom(repr) => repr.visit_mut(v), + OperationIr::Drop(repr) => v.visit_tensor_mut(repr), + OperationIr::Distributed(repr) => repr.visit_mut(v), + OperationIr::Activation(repr) => repr.visit_mut(v), + } + } +} + +impl BaseOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + BaseOperationIr::Reshape(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::SwapDims(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Permute(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Expand(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Flip(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Slice(repr) => Box::new([&repr.tensor].into_iter()), + BaseOperationIr::SliceAssign(repr) => Box::new([&repr.tensor, &repr.value].into_iter()), + BaseOperationIr::Gather(repr) => Box::new([&repr.tensor, &repr.indices].into_iter()), + BaseOperationIr::Scatter(repr) => { + Box::new([&repr.tensor, &repr.indices, &repr.value].into_iter()) + } + BaseOperationIr::ScatterNd(repr) => { + Box::new([&repr.data, &repr.indices, &repr.values].into_iter()) + } + BaseOperationIr::GatherNd(repr) => Box::new([&repr.data, &repr.indices].into_iter()), + BaseOperationIr::Select(repr) => Box::new([&repr.tensor, &repr.indices].into_iter()), + BaseOperationIr::SelectAssign(repr) => { + Box::new([&repr.tensor, &repr.indices, &repr.value].into_iter()) + } + BaseOperationIr::MaskWhere(repr) => { + Box::new([&repr.tensor, &repr.mask, &repr.value].into_iter()) + } + BaseOperationIr::MaskFill(repr) => Box::new([&repr.tensor, &repr.mask].into_iter()), + BaseOperationIr::Equal(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + BaseOperationIr::EqualElem(repr) => Box::new([&repr.lhs].into_iter()), + BaseOperationIr::RepeatDim(repr) => Box::new([&repr.tensor].into_iter()), + BaseOperationIr::Cat(repr) => Box::new(repr.tensors.iter()), + BaseOperationIr::Cast(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Unfold(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Empty(_repr) => Box::new([].into_iter()), + BaseOperationIr::Ones(_repr) => Box::new([].into_iter()), + BaseOperationIr::Zeros(_repr) => Box::new([].into_iter()), + BaseOperationIr::NotEqual(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + BaseOperationIr::NotEqualElem(repr) => Box::new([&repr.lhs].into_iter()), + BaseOperationIr::All(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::Any(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::AllDim(repr) => Box::new([&repr.input].into_iter()), + BaseOperationIr::AnyDim(repr) => Box::new([&repr.input].into_iter()), + } + } + + fn outputs(&self) -> Box + '_> { + match self { + BaseOperationIr::Reshape(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::SwapDims(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Permute(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Expand(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Flip(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Slice(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::SliceAssign(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Gather(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Scatter(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::ScatterNd(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::GatherNd(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Select(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::SelectAssign(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::MaskWhere(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::MaskFill(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Equal(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::EqualElem(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::RepeatDim(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Cat(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Cast(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Unfold(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Empty(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Ones(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Zeros(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::NotEqual(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::NotEqualElem(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::All(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::Any(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::AllDim(repr) => Box::new([&repr.out].into_iter()), + BaseOperationIr::AnyDim(repr) => Box::new([&repr.out].into_iter()), + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + BaseOperationIr::Reshape(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::SwapDims(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Permute(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + + BaseOperationIr::Expand(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + + BaseOperationIr::Flip(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Slice(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + BaseOperationIr::SliceAssign(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.value.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Gather(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Scatter(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + repr.value.mark_read_only(nodes, &mut output); + } + BaseOperationIr::ScatterNd(repr) => { + repr.data.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + repr.values.mark_read_only(nodes, &mut output); + } + BaseOperationIr::GatherNd(repr) => { + repr.data.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Select(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + } + BaseOperationIr::SelectAssign(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + repr.value.mark_read_only(nodes, &mut output); + } + BaseOperationIr::MaskWhere(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.mask.mark_read_only(nodes, &mut output); + repr.value.mark_read_only(nodes, &mut output); + } + BaseOperationIr::MaskFill(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.mask.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Equal(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + BaseOperationIr::EqualElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + BaseOperationIr::RepeatDim(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Cat(repr) => { + for t in repr.tensors.iter_mut() { + t.mark_read_only(nodes, &mut output); + } + } + BaseOperationIr::Cast(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Unfold(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Empty(_) => {} + BaseOperationIr::Zeros(_) => {} + BaseOperationIr::Ones(_) => {} + BaseOperationIr::NotEqual(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + BaseOperationIr::NotEqualElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + BaseOperationIr::All(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::Any(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::AllDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BaseOperationIr::AnyDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + BaseOperationIr::Reshape(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::SwapDims(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Permute(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Expand(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Flip(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Slice(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + repr.ranges.iter_mut().for_each(|r| v.visit_range_mut(r)); + } + BaseOperationIr::SliceAssign(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.value); + v.visit_tensor_mut(&mut repr.out); + repr.ranges.iter_mut().for_each(|r| v.visit_range_mut(r)); + } + BaseOperationIr::Gather(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Scatter(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.value); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::ScatterNd(repr) => { + v.visit_tensor_mut(&mut repr.data); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.values); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::GatherNd(repr) => { + v.visit_tensor_mut(&mut repr.data); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Select(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::SelectAssign(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.value); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::MaskWhere(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.mask); + v.visit_tensor_mut(&mut repr.value); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::MaskFill(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.mask); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.value); + } + BaseOperationIr::Equal(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::EqualElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + BaseOperationIr::RepeatDim(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Cat(repr) => { + for t in repr.tensors.iter_mut() { + v.visit_tensor_mut(t); + } + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Cast(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Unfold(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Empty(repr) => { + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Ones(repr) => { + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Zeros(repr) => { + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::NotEqual(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::NotEqualElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + BaseOperationIr::All(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::Any(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::AllDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BaseOperationIr::AnyDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +impl NumericOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + NumericOperationIr::Add(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::AddScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Sub(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::SubScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Mul(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::MulScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Div(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::DivScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Rem(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::RemScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::GreaterElem(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::GreaterEqualElem(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::LowerElem(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::LowerEqualElem(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Greater(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::GreaterEqual(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::Lower(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::LowerEqual(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::ArgMax(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::ArgTopK(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::TopK(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::ArgMin(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Clamp(repr) => Box::new([&repr.tensor].into_iter()), + NumericOperationIr::Abs(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Full(_repr) => Box::new([].into_iter()), + NumericOperationIr::MeanDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Mean(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Sum(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::SumDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Prod(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::ProdDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Max(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::MaxDimWithIndices(repr) => Box::new([&repr.tensor].into_iter()), + NumericOperationIr::MinDimWithIndices(repr) => Box::new([&repr.tensor].into_iter()), + NumericOperationIr::Min(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::MaxDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::MinDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::MaxAbs(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::MaxAbsDim(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::IntRandom(_repr) => Box::new([].into_iter()), + NumericOperationIr::Powi(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + NumericOperationIr::PowiScalar(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::CumMin(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::CumMax(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::CumProd(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::CumSum(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Neg(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::Sign(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::ClampMin(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::ClampMax(repr) => Box::new([&repr.lhs].into_iter()), + NumericOperationIr::Sort(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::SortWithIndices(repr) => Box::new([&repr.input].into_iter()), + NumericOperationIr::ArgSort(repr) => Box::new([&repr.input].into_iter()), + } + } + + fn outputs(&self) -> Box + '_> { + match self { + NumericOperationIr::Add(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::AddScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Sub(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::SubScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Mul(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MulScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Div(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::DivScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Rem(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::RemScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::GreaterElem(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::GreaterEqualElem(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::LowerElem(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::LowerEqualElem(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Greater(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::GreaterEqual(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Lower(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::LowerEqual(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ArgMax(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ArgTopK(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::TopK(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ArgMin(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Clamp(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Abs(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Full(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MeanDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Mean(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Sum(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::SumDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Prod(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ProdDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Max(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MaxDimWithIndices(repr) => { + Box::new([&repr.out, &repr.out_indices].into_iter()) + } + NumericOperationIr::MinDimWithIndices(repr) => { + Box::new([&repr.out, &repr.out_indices].into_iter()) + } + NumericOperationIr::Min(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MaxDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MinDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MaxAbs(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::MaxAbsDim(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::IntRandom(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Powi(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::PowiScalar(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::CumMin(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::CumMax(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::CumProd(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::CumSum(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Neg(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Sign(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ClampMin(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::ClampMax(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::Sort(repr) => Box::new([&repr.out].into_iter()), + NumericOperationIr::SortWithIndices(repr) => { + Box::new([&repr.out, &repr.out_indices].into_iter()) + } + NumericOperationIr::ArgSort(repr) => Box::new([&repr.out].into_iter()), + } + } + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + NumericOperationIr::Add(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::AddScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Sub(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::SubScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Mul(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MulScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Div(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::DivScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Rem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::RemScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::GreaterElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::GreaterEqualElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::LowerElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::LowerEqualElem(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Greater(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::GreaterEqual(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Lower(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::LowerEqual(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ArgMax(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ArgTopK(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::TopK(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ArgMin(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Clamp(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Abs(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Full(_) => {} + NumericOperationIr::MeanDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Mean(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Sum(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::SumDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Prod(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ProdDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Max(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MaxDimWithIndices(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MinDimWithIndices(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Min(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MaxDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MinDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MaxAbs(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::MaxAbsDim(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::IntRandom(_) => {} + NumericOperationIr::Powi(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::PowiScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::CumSum(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::CumProd(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::CumMin(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::CumMax(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Neg(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Sign(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ClampMin(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ClampMax(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + NumericOperationIr::Sort(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::SortWithIndices(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + NumericOperationIr::ArgSort(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + NumericOperationIr::Add(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::AddScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Sub(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::SubScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Mul(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MulScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Div(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::DivScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Rem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::RemScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::GreaterElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::GreaterEqualElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::LowerElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::LowerEqualElem(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Greater(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::GreaterEqual(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Lower(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::LowerEqual(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::ArgMax(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::ArgTopK(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::TopK(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::ArgMin(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Clamp(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.min); + v.visit_scalar_mut(&mut repr.max); + } + NumericOperationIr::Abs(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Full(repr) => { + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.value); + } + NumericOperationIr::MeanDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Mean(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Sum(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::SumDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Prod(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::ProdDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Max(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MaxDimWithIndices(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + v.visit_tensor_mut(&mut repr.out_indices); + } + NumericOperationIr::MinDimWithIndices(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + v.visit_tensor_mut(&mut repr.out_indices); + } + NumericOperationIr::Min(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MaxDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MinDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MaxAbs(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::MaxAbsDim(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::IntRandom(repr) => { + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Powi(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::PowiScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::CumMin(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::CumMax(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::CumProd(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::CumSum(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Neg(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::Sign(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::ClampMin(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::ClampMax(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + NumericOperationIr::Sort(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + NumericOperationIr::SortWithIndices(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + v.visit_tensor_mut(&mut repr.out_indices); + } + NumericOperationIr::ArgSort(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +impl FloatOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + FloatOperationIr::Matmul(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + FloatOperationIr::Cross(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + FloatOperationIr::Random(_repr) => Box::new([].into_iter()), + FloatOperationIr::Exp(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Log(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Log1p(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Erf(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Recip(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::PowfScalar(repr) => Box::new([&repr.lhs].into_iter()), + FloatOperationIr::Sqrt(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Cos(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Sin(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Tanh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Round(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Floor(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Ceil(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Trunc(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::IntoInt(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Quantize(repr) => { + Box::new([&repr.tensor, &repr.qparams.scales].into_iter()) + } + FloatOperationIr::Dequantize(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::IsNan(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::IsInf(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::GridSample2d(repr) => { + Box::new([&repr.tensor, &repr.grid].into_iter()) + } + FloatOperationIr::Tan(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Cosh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::Sinh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcCos(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcCosh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcSin(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcSinh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcTan(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcTanh(repr) => Box::new([&repr.input].into_iter()), + FloatOperationIr::ArcTan2(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + FloatOperationIr::Powf(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + FloatOperationIr::Hypot(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + } + } + fn outputs(&self) -> Box + '_> { + match self { + FloatOperationIr::Matmul(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Cross(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Random(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Exp(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Log(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Log1p(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Erf(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Recip(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::PowfScalar(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Sqrt(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Cos(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Sin(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Tanh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Round(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Floor(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Ceil(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Trunc(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::IntoInt(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Quantize(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Dequantize(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::IsNan(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::IsInf(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::GridSample2d(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Tan(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Cosh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Sinh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcCos(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcCosh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcSin(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcSinh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcTan(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcTanh(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::ArcTan2(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Powf(repr) => Box::new([&repr.out].into_iter()), + FloatOperationIr::Hypot(repr) => Box::new([&repr.out].into_iter()), + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + FloatOperationIr::Matmul(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Cross(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Random(_) => {} + FloatOperationIr::Exp(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Log(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Log1p(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Erf(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Recip(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::PowfScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Sqrt(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Cos(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Sin(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Tanh(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Round(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Floor(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Ceil(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Trunc(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Quantize(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.qparams.scales.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Dequantize(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::IntoInt(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::IsNan(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::IsInf(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + FloatOperationIr::GridSample2d(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + repr.grid.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Tan(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::Cosh(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::Sinh(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcCos(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcCosh(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcSin(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcSinh(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcTan(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcTanh(repr) => repr.input.mark_read_only(nodes, &mut output), + FloatOperationIr::ArcTan2(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Powf(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + FloatOperationIr::Hypot(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + FloatOperationIr::Matmul(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Cross(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Random(repr) => { + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Exp(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Log(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Log1p(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Erf(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Recip(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::PowfScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + FloatOperationIr::Sqrt(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Cos(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Sin(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Tanh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Round(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Floor(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Ceil(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Trunc(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::IntoInt(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Quantize(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.qparams.scales); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Dequantize(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::IsNan(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::IsInf(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::GridSample2d(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.grid); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Tan(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Cosh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Sinh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcCos(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcCosh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcSin(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcSinh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcTan(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcTanh(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::ArcTan2(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Powf(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + FloatOperationIr::Hypot(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +impl IntOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + IntOperationIr::Matmul(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::IntoFloat(repr) => Box::new([&repr.input].into_iter()), + IntOperationIr::BitwiseAnd(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::BitwiseAndScalar(repr) => Box::new([&repr.lhs].into_iter()), + IntOperationIr::BitwiseOr(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::BitwiseOrScalar(repr) => Box::new([&repr.lhs].into_iter()), + IntOperationIr::BitwiseXor(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::BitwiseXorScalar(repr) => Box::new([&repr.lhs].into_iter()), + IntOperationIr::BitwiseNot(repr) => Box::new([&repr.input].into_iter()), + IntOperationIr::BitwiseLeftShift(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::BitwiseLeftShiftScalar(repr) => Box::new([&repr.lhs].into_iter()), + IntOperationIr::BitwiseRightShift(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + IntOperationIr::BitwiseRightShiftScalar(repr) => Box::new([&repr.lhs].into_iter()), + } + } + + fn outputs(&self) -> Box + '_> { + match self { + IntOperationIr::Matmul(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::IntoFloat(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseAnd(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseAndScalar(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseOr(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseOrScalar(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseXor(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseXorScalar(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseNot(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseLeftShift(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseLeftShiftScalar(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseRightShift(repr) => Box::new([&repr.out].into_iter()), + IntOperationIr::BitwiseRightShiftScalar(repr) => Box::new([&repr.out].into_iter()), + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + IntOperationIr::Matmul(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::IntoFloat(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseAnd(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseAndScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseOr(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseOrScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseXor(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseXorScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseNot(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseLeftShift(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseLeftShiftScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseRightShift(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + IntOperationIr::BitwiseRightShiftScalar(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + IntOperationIr::Matmul(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::IntoFloat(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseAnd(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseAndScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + IntOperationIr::BitwiseOr(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseOrScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + IntOperationIr::BitwiseXor(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseXorScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + IntOperationIr::BitwiseNot(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseLeftShift(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseLeftShiftScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + IntOperationIr::BitwiseRightShift(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + IntOperationIr::BitwiseRightShiftScalar(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + } + } +} + +impl BoolOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + BoolOperationIr::IntoFloat(repr) => Box::new([&repr.input].into_iter()), + BoolOperationIr::IntoInt(repr) => Box::new([&repr.input].into_iter()), + BoolOperationIr::Not(repr) => Box::new([&repr.input].into_iter()), + BoolOperationIr::And(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + BoolOperationIr::Or(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + BoolOperationIr::Xor(repr) => Box::new([&repr.lhs, &repr.rhs].into_iter()), + } + } + fn outputs(&self) -> Box + '_> { + match self { + BoolOperationIr::IntoFloat(repr) => Box::new([&repr.out].into_iter()), + BoolOperationIr::IntoInt(repr) => Box::new([&repr.out].into_iter()), + BoolOperationIr::Not(repr) => Box::new([&repr.out].into_iter()), + BoolOperationIr::And(repr) => Box::new([&repr.out].into_iter()), + BoolOperationIr::Or(repr) => Box::new([&repr.out].into_iter()), + BoolOperationIr::Xor(repr) => Box::new([&repr.out].into_iter()), + } + } + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + BoolOperationIr::IntoFloat(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BoolOperationIr::IntoInt(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BoolOperationIr::Not(repr) => { + repr.input.mark_read_only(nodes, &mut output); + } + BoolOperationIr::And(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + BoolOperationIr::Or(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + BoolOperationIr::Xor(repr) => { + repr.lhs.mark_read_only(nodes, &mut output); + repr.rhs.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + BoolOperationIr::IntoFloat(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BoolOperationIr::IntoInt(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BoolOperationIr::Not(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + BoolOperationIr::And(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + BoolOperationIr::Or(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + BoolOperationIr::Xor(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +impl ModuleOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + ModuleOperationIr::Embedding(repr) => { + Box::new([&repr.weights, &repr.indices].into_iter()) + } + ModuleOperationIr::EmbeddingBackward(repr) => { + Box::new([&repr.weights, &repr.out_grad, &repr.indices].into_iter()) + } + ModuleOperationIr::Linear(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::LinearXBackward(repr) => { + Box::new([&repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::LinearWeightBackward(repr) => { + Box::new([&repr.x, &repr.output_grad].into_iter()) + } + ModuleOperationIr::LinearBiasBackward(repr) => { + Box::new([&repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv1d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::Conv1dXBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv1dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv1dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv2d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::Conv2dXBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv2dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv2dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv3d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::Conv3dXBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv3dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::Conv3dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + ModuleOperationIr::DeformableConv2d(repr) => match (&repr.mask, &repr.bias) { + (Some(mask), Some(bias)) => { + Box::new([&repr.x, &repr.offset, &repr.weight, mask, bias].into_iter()) + } + (Some(mask), None) => { + Box::new([&repr.x, &repr.offset, &repr.weight, mask].into_iter()) + } + (None, Some(bias)) => { + Box::new([&repr.x, &repr.offset, &repr.weight, bias].into_iter()) + } + (None, None) => Box::new([&repr.x, &repr.offset, &repr.weight].into_iter()), + }, + ModuleOperationIr::DeformableConv2dBackward(repr) => match (&repr.mask, &repr.bias) { + (Some(mask), Some(bias)) => Box::new( + [ + &repr.x, + &repr.offset, + &repr.weight, + &repr.out_grad, + mask, + bias, + ] + .into_iter(), + ), + (Some(mask), None) => Box::new( + [&repr.x, &repr.offset, &repr.weight, &repr.out_grad, mask].into_iter(), + ), + (None, Some(bias)) => Box::new( + [&repr.x, &repr.offset, &repr.weight, &repr.out_grad, bias].into_iter(), + ), + (None, None) => { + Box::new([&repr.x, &repr.offset, &repr.weight, &repr.out_grad].into_iter()) + } + }, + ModuleOperationIr::ConvTranspose1d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::ConvTranspose2d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::ConvTranspose3d(repr) => { + if let Some(bias) = &repr.bias { + Box::new([&repr.x, &repr.weight, bias].into_iter()) + } else { + Box::new([&repr.x, &repr.weight].into_iter()) + } + } + ModuleOperationIr::AvgPool1d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::AvgPool2d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::AvgPool1dBackward(repr) => { + Box::new([&repr.x, &repr.grad].into_iter()) + } + ModuleOperationIr::AvgPool2dBackward(repr) => { + Box::new([&repr.x, &repr.grad].into_iter()) + } + ModuleOperationIr::AdaptiveAvgPool1d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::AdaptiveAvgPool2d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::AdaptiveAvgPool1dBackward(repr) => { + Box::new([&repr.x, &repr.grad].into_iter()) + } + ModuleOperationIr::AdaptiveAvgPool2dBackward(repr) => { + Box::new([&repr.x, &repr.grad].into_iter()) + } + ModuleOperationIr::MaxPool1d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::MaxPool1dWithIndices(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::MaxPool1dWithIndicesBackward(repr) => { + Box::new([&repr.x, &repr.indices, &repr.grad].into_iter()) + } + ModuleOperationIr::MaxPool2d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::MaxPool2dWithIndices(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::MaxPool2dWithIndicesBackward(repr) => { + Box::new([&repr.x, &repr.indices, &repr.grad].into_iter()) + } + ModuleOperationIr::Interpolate(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::InterpolateBackward(repr) => { + Box::new([&repr.x, &repr.grad].into_iter()) + } + ModuleOperationIr::Rfft(repr) => Box::new([&repr.signal].into_iter()), + ModuleOperationIr::IRfft(repr) => { + Box::new([&repr.input_re, &repr.input_im].into_iter()) + } + ModuleOperationIr::Attention(repr) => { + if let Some(mask) = &repr.mask { + if let Some(attn_bias) = &repr.attn_bias { + Box::new([&repr.query, &repr.key, &repr.value, mask, attn_bias].into_iter()) + } else { + Box::new([&repr.query, &repr.key, &repr.value, mask].into_iter()) + } + } else if let Some(attn_bias) = &repr.attn_bias { + Box::new([&repr.query, &repr.key, &repr.value, attn_bias].into_iter()) + } else { + Box::new([&repr.query, &repr.key, &repr.value].into_iter()) + } + } + ModuleOperationIr::CtcLoss(repr) => Box::new( + [ + &repr.log_probs, + &repr.targets, + &repr.input_lengths, + &repr.target_lengths, + ] + .into_iter(), + ), + ModuleOperationIr::CtcLossBackward(repr) => Box::new( + [ + &repr.log_probs, + &repr.targets, + &repr.input_lengths, + &repr.target_lengths, + &repr.grad_loss, + ] + .into_iter(), + ), + ModuleOperationIr::LayerNorm(repr) => match &repr.beta { + Some(beta) => Box::new([&repr.input, &repr.gamma, beta].into_iter()), + None => Box::new([&repr.input, &repr.gamma].into_iter()), + }, + ModuleOperationIr::Unfold4d(repr) => Box::new([&repr.x].into_iter()), + ModuleOperationIr::ConvTranspose1dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::ConvTranspose1dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + ModuleOperationIr::ConvTranspose2dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::ConvTranspose2dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + ModuleOperationIr::ConvTranspose3dWeightBackward(repr) => { + Box::new([&repr.x, &repr.weight, &repr.output_grad].into_iter()) + } + ModuleOperationIr::ConvTranspose3dBiasBackward(repr) => { + Box::new([&repr.x, &repr.bias, &repr.output_grad].into_iter()) + } + } + } + fn outputs(&self) -> Box + '_> { + match self { + ModuleOperationIr::Embedding(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::EmbeddingBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Linear(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::LinearXBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::LinearWeightBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::LinearBiasBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv1d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv1dXBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv1dWeightBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv1dBiasBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv2dXBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv2dWeightBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv2dBiasBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv3d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv3dXBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv3dWeightBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Conv3dBiasBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::DeformableConv2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::DeformableConv2dBackward(repr) => { + match (&repr.mask_grad, &repr.bias_grad) { + (Some(mask_grad), Some(bias_grad)) => Box::new( + [ + &repr.input_grad, + &repr.offset_grad, + &repr.weight_grad, + mask_grad, + bias_grad, + ] + .into_iter(), + ), + (Some(mask_grad), None) => Box::new( + [ + &repr.input_grad, + &repr.offset_grad, + &repr.weight_grad, + mask_grad, + ] + .into_iter(), + ), + (None, Some(bias_grad)) => Box::new( + [ + &repr.input_grad, + &repr.offset_grad, + &repr.weight_grad, + bias_grad, + ] + .into_iter(), + ), + (None, None) => Box::new( + [&repr.input_grad, &repr.offset_grad, &repr.weight_grad].into_iter(), + ), + } + } + ModuleOperationIr::ConvTranspose1d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::ConvTranspose2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::ConvTranspose3d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AvgPool1d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AvgPool2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AvgPool1dBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AvgPool2dBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AdaptiveAvgPool1d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AdaptiveAvgPool2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AdaptiveAvgPool1dBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::AdaptiveAvgPool2dBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::MaxPool1d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::MaxPool1dWithIndices(repr) => { + Box::new([&repr.out, &repr.out_indices].into_iter()) + } + ModuleOperationIr::MaxPool1dWithIndicesBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::MaxPool2d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::MaxPool2dWithIndices(repr) => { + Box::new([&repr.out, &repr.out_indices].into_iter()) + } + ModuleOperationIr::MaxPool2dWithIndicesBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::Interpolate(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::InterpolateBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Rfft(repr) => Box::new([&repr.out_re, &repr.out_im].into_iter()), + ModuleOperationIr::IRfft(repr) => Box::new([&repr.out_signal].into_iter()), + ModuleOperationIr::Attention(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::CtcLoss(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::CtcLossBackward(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::LayerNorm(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::Unfold4d(repr) => Box::new([&repr.out].into_iter()), + ModuleOperationIr::ConvTranspose1dWeightBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::ConvTranspose1dBiasBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::ConvTranspose2dWeightBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::ConvTranspose2dBiasBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::ConvTranspose3dWeightBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + ModuleOperationIr::ConvTranspose3dBiasBackward(repr) => { + Box::new([&repr.out].into_iter()) + } + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + ModuleOperationIr::Embedding(repr) => { + repr.weights.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::EmbeddingBackward(repr) => { + repr.weights.mark_read_only(nodes, &mut output); + repr.out_grad.mark_read_only(nodes, &mut output); + repr.indices.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Linear(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::LinearXBackward(repr) => { + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::LinearWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::LinearBiasBackward(repr) => { + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv1d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::Conv1dXBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv1dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv1dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::Conv2dXBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv2dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv2dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv3d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::Conv3dXBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv3dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Conv3dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::DeformableConv2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.offset.mark_read_only(nodes, &mut output); + + match (&mut repr.mask, &mut repr.bias) { + (Some(mask), Some(bias)) => { + mask.mark_read_only(nodes, &mut output); + bias.mark_read_only(nodes, &mut output); + } + (Some(mask), None) => { + mask.mark_read_only(nodes, &mut output); + } + (None, Some(bias)) => { + bias.mark_read_only(nodes, &mut output); + } + (None, None) => {} + }; + } + ModuleOperationIr::DeformableConv2dBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.offset.mark_read_only(nodes, &mut output); + repr.out_grad.mark_read_only(nodes, &mut output); + + if let Some(mask) = repr.mask.as_mut() { + mask.mark_read_only(nodes, &mut output); + } + if let Some(bias) = repr.bias.as_mut() { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::ConvTranspose1d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::ConvTranspose2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::ConvTranspose3d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + + if let Some(bias) = &mut repr.bias { + bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::AvgPool1d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AvgPool2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AvgPool1dBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AvgPool2dBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AdaptiveAvgPool1d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AdaptiveAvgPool2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AdaptiveAvgPool1dBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::AdaptiveAvgPool2dBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool1d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool1dWithIndices(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool1dWithIndicesBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool2d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool2dWithIndices(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::MaxPool2dWithIndicesBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Interpolate(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::InterpolateBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Rfft(repr) => { + repr.signal.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::IRfft(repr) => { + repr.input_re.mark_read_only(nodes, &mut output); + repr.input_im.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::Attention(repr) => { + repr.query.mark_read_only(nodes, &mut output); + repr.key.mark_read_only(nodes, &mut output); + repr.value.mark_read_only(nodes, &mut output); + if let Some(mask) = &mut repr.mask { + mask.mark_read_only(nodes, &mut output); + } + if let Some(attn_bias) = &mut repr.attn_bias { + attn_bias.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::CtcLoss(repr) => { + repr.log_probs.mark_read_only(nodes, &mut output); + repr.targets.mark_read_only(nodes, &mut output); + repr.input_lengths.mark_read_only(nodes, &mut output); + repr.target_lengths.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::CtcLossBackward(repr) => { + repr.log_probs.mark_read_only(nodes, &mut output); + repr.targets.mark_read_only(nodes, &mut output); + repr.input_lengths.mark_read_only(nodes, &mut output); + repr.target_lengths.mark_read_only(nodes, &mut output); + repr.grad_loss.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::LayerNorm(repr) => { + repr.input.mark_read_only(nodes, &mut output); + repr.gamma.mark_read_only(nodes, &mut output); + if let Some(beta) = &mut repr.beta { + beta.mark_read_only(nodes, &mut output); + } + } + ModuleOperationIr::Unfold4d(repr) => { + repr.x.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose1dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose1dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose2dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose2dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose3dWeightBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.weight.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + ModuleOperationIr::ConvTranspose3dBiasBackward(repr) => { + repr.x.mark_read_only(nodes, &mut output); + repr.bias.mark_read_only(nodes, &mut output); + repr.output_grad.mark_read_only(nodes, &mut output); + } + }; + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + ModuleOperationIr::Embedding(repr) => { + v.visit_tensor_mut(&mut repr.weights); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::EmbeddingBackward(repr) => { + v.visit_tensor_mut(&mut repr.weights); + v.visit_tensor_mut(&mut repr.out_grad); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Linear(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::LinearXBackward(repr) => { + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::LinearWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::LinearBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv1d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv1dXBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv1dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv1dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv2dXBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv2dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv2dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv3d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv3dXBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv3dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Conv3dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::DeformableConv2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.offset); + v.visit_tensor_mut(&mut repr.weight); + if let Some(mask) = &mut repr.mask { + v.visit_tensor_mut(mask); + } + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::DeformableConv2dBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.offset); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.out_grad); + if let Some(mask) = &mut repr.mask { + v.visit_tensor_mut(mask); + } + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.input_grad); + v.visit_tensor_mut(&mut repr.offset_grad); + v.visit_tensor_mut(&mut repr.weight_grad); + if let Some(mask_grad) = &mut repr.mask_grad { + v.visit_tensor_mut(mask_grad); + } + if let Some(bias_grad) = &mut repr.bias_grad { + v.visit_tensor_mut(bias_grad); + } + } + ModuleOperationIr::ConvTranspose1d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose3d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + if let Some(bias) = &mut repr.bias { + v.visit_tensor_mut(bias); + } + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AvgPool1d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AvgPool2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AvgPool1dBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AvgPool2dBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AdaptiveAvgPool1d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AdaptiveAvgPool2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AdaptiveAvgPool1dBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::AdaptiveAvgPool2dBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::MaxPool1d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::MaxPool1dWithIndices(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + v.visit_tensor_mut(&mut repr.out_indices); + } + ModuleOperationIr::MaxPool1dWithIndicesBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::MaxPool2d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::MaxPool2dWithIndices(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + v.visit_tensor_mut(&mut repr.out_indices); + } + ModuleOperationIr::MaxPool2dWithIndicesBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.indices); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Interpolate(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::InterpolateBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::Rfft(repr) => { + v.visit_tensor_mut(&mut repr.signal); + v.visit_tensor_mut(&mut repr.out_re); + v.visit_tensor_mut(&mut repr.out_im); + } + ModuleOperationIr::IRfft(repr) => { + v.visit_tensor_mut(&mut repr.input_re); + v.visit_tensor_mut(&mut repr.input_im); + v.visit_tensor_mut(&mut repr.out_signal); + } + ModuleOperationIr::Attention(repr) => { + v.visit_tensor_mut(&mut repr.query); + v.visit_tensor_mut(&mut repr.key); + v.visit_tensor_mut(&mut repr.value); + if let Some(mask) = &mut repr.mask { + v.visit_tensor_mut(mask); + } + if let Some(attn_bias) = &mut repr.attn_bias { + v.visit_tensor_mut(attn_bias); + } + v.visit_tensor_mut(&mut repr.out); + if let Some(scale) = &mut repr.options.scale { + v.visit_scalar_mut(scale); + } + if let Some(softcap) = &mut repr.options.softcap { + v.visit_scalar_mut(softcap); + } + } + ModuleOperationIr::CtcLoss(repr) => { + v.visit_tensor_mut(&mut repr.log_probs); + v.visit_tensor_mut(&mut repr.targets); + v.visit_tensor_mut(&mut repr.input_lengths); + v.visit_tensor_mut(&mut repr.target_lengths); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::CtcLossBackward(repr) => { + v.visit_tensor_mut(&mut repr.log_probs); + v.visit_tensor_mut(&mut repr.targets); + v.visit_tensor_mut(&mut repr.input_lengths); + v.visit_tensor_mut(&mut repr.target_lengths); + v.visit_tensor_mut(&mut repr.grad_loss); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::LayerNorm(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.gamma); + if let Some(beta) = &mut repr.beta { + v.visit_tensor_mut(beta); + } + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.epsilon); + } + ModuleOperationIr::Unfold4d(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose1dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose1dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose2dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose2dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose3dWeightBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.weight); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + ModuleOperationIr::ConvTranspose3dBiasBackward(repr) => { + v.visit_tensor_mut(&mut repr.x); + v.visit_tensor_mut(&mut repr.bias); + v.visit_tensor_mut(&mut repr.output_grad); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +impl DistributedOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + DistributedOperationIr::AllReduce(repr) => Box::new([&repr.tensor].into_iter()), + DistributedOperationIr::SyncCollective => Box::new([].into_iter()), + } + } + + fn outputs(&self) -> Box + '_> { + match self { + DistributedOperationIr::AllReduce(repr) => Box::new([&repr.out].into_iter()), + DistributedOperationIr::SyncCollective => Box::new([].into_iter()), + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + + match self { + DistributedOperationIr::AllReduce(repr) => { + repr.tensor.mark_read_only(nodes, &mut output); + } + DistributedOperationIr::SyncCollective => {} + } + + output + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + DistributedOperationIr::AllReduce(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + } + DistributedOperationIr::SyncCollective => {} + } + } +} + +impl InitOperationIr { + fn inputs(&self) -> Box + '_> { + Box::new([].into_iter()) + } + fn outputs(&self) -> Box + '_> { + Box::new([&self.out].into_iter()) + } + + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + v.visit_tensor_mut(&mut self.out); + } +} + +impl TensorIr { + fn mark_read_only(&mut self, nodes: &[TensorId], output: &mut Vec) { + if self.status == TensorStatus::ReadWrite && nodes.contains(&self.id) { + output.push(self.clone()); + self.status = TensorStatus::ReadOnly; + } + } +} + +impl core::hash::Hash for RandomOpIr { + fn hash(&self, state: &mut H) { + self.out.hash(state); + + match self.distribution { + Distribution::Default => 1u8.hash(state), + Distribution::Bernoulli(_) => 2u8.hash(state), + Distribution::Uniform(_, _) => 3u8.hash(state), + Distribution::Normal(_, _) => 4u8.hash(state), + } + } +} + +/// Extension trait to extract outputs when registering an operation. +pub trait OperationOutput { + /// Extract a single output. + fn output(self) -> O; + + /// Extract a fixed number of outputs. + fn outputs(self) -> [O; N]; +} + +impl OperationOutput for Vec { + fn output(self) -> O { + let [tensor] = self.outputs(); + tensor + } + + fn outputs(self) -> [O; N] { + self.try_into().unwrap() + } +} + +/// Operation IR for sort along a dim. The output preserves the input shape/dtype. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct SortOpIr { + /// Input tensor. + pub input: TensorIr, + /// Dim along which to sort. + pub dim: usize, + /// Sort descending. + pub descending: bool, + /// Output tensor (same shape/dtype as input). + pub out: TensorIr, +} + +/// Operation IR for sort-with-indices: returns sorted values + source indices. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct SortWithIndicesOpIr { + /// Input tensor. + pub input: TensorIr, + /// Dim along which to sort. + pub dim: usize, + /// Sort descending. + pub descending: bool, + /// Output tensor with sorted values. + pub out: TensorIr, + /// Output tensor with the indices into the original input. + pub out_indices: TensorIr, +} + +/// Operation IR for layer normalization with optional bias. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct LayerNormOpIr { + /// Input tensor. + pub input: TensorIr, + /// Scale (gamma) parameter. + pub gamma: TensorIr, + /// Optional shift (beta) parameter. + pub beta: Option, + /// Numerical-stability epsilon. + pub epsilon: ScalarIr, + /// Output tensor. + pub out: TensorIr, +} + +/// Operation IR for unfold4d (a 4d sliding-window kernel-as-explicit-columns reshape). +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct Unfold4dOpIr { + /// Input tensor of shape `[N, C, H, W]`. + pub x: TensorIr, + /// Kernel size `[kH, kW]`. + pub kernel_size: [usize; 2], + /// Conv-like options (stride, padding, dilation). + pub options: Unfold4dOptionsIr, + /// Output tensor. + pub out: TensorIr, +} + +/// Options for [`Unfold4dOpIr`] — mirrors the backend's `UnfoldOptions`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct Unfold4dOptionsIr { + /// Stride `[sH, sW]`. + pub stride: [usize; 2], + /// Padding `[pH, pW]`. + pub padding: [usize; 2], + /// Dilation `[dH, dW]`. + pub dilation: [usize; 2], +} + +/// Operation IR for the weight-gradient of `conv_transpose1d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose1dWeightBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Convolution weights. + pub weight: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Convolution options. + pub options: ConvTranspose1dOptionsIr, + /// Output: gradient w.r.t. `weight`. + pub out: TensorIr, +} + +/// Operation IR for the bias-gradient of `conv_transpose1d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose1dBiasBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Bias tensor (sets the output shape). + pub bias: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Output: gradient w.r.t. `bias`. + pub out: TensorIr, +} + +/// Operation IR for the weight-gradient of `conv_transpose2d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose2dWeightBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Convolution weights. + pub weight: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Convolution options. + pub options: ConvTranspose2dOptionsIr, + /// Output: gradient w.r.t. `weight`. + pub out: TensorIr, +} + +/// Operation IR for the bias-gradient of `conv_transpose2d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose2dBiasBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Bias tensor. + pub bias: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Output: gradient w.r.t. `bias`. + pub out: TensorIr, +} + +/// Operation IR for the weight-gradient of `conv_transpose3d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose3dWeightBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Convolution weights. + pub weight: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Convolution options. + pub options: ConvTranspose3dOptionsIr, + /// Output: gradient w.r.t. `weight`. + pub out: TensorIr, +} + +/// Operation IR for the bias-gradient of `conv_transpose3d`. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct ConvTranspose3dBiasBackwardOpIr { + /// Forward input. + pub x: TensorIr, + /// Bias tensor. + pub bias: TensorIr, + /// Upstream gradient. + pub output_grad: TensorIr, + /// Output: gradient w.r.t. `bias`. + pub out: TensorIr, +} + +/// Operation IR for the hard-sigmoid activation function, which takes two scalars +/// (`alpha` and `beta`) in addition to the input tensor. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub struct HardSigmoidOpIr { + /// Input tensor. + pub tensor: TensorIr, + /// Alpha — multiplied with the tensor before adding `beta`. + pub alpha: ScalarIr, + /// Beta — added after multiplying by `alpha`. + pub beta: ScalarIr, + /// Output tensor. + pub out: TensorIr, +} + +/// Operation intermediate representation for activation functions. +/// +/// Activations are kept in their own enum (rather than smeared across `FloatOperationIr`) +/// so backends can match a single arm to dispatch the fused implementation, instead of +/// receiving the function decomposed into 5–20 primitive ops. +#[derive(Clone, Debug, Hash, PartialEq, Serialize, Deserialize)] +pub enum ActivationOperationIr { + /// [relu](burn_backend::ops::ActivationOps::relu). + Relu(UnaryOpIr), + /// [relu_backward](burn_backend::ops::ActivationOps::relu_backward). + /// `lhs` = output of the forward pass, `rhs` = upstream gradient. + ReluBackward(BinaryOpIr), + /// [leaky_relu](burn_backend::ops::ActivationOps::leaky_relu). + /// `lhs` = input, `rhs` = `negative_slope` scalar. + LeakyRelu(ScalarOpIr), + /// [prelu](burn_backend::ops::ActivationOps::prelu). + /// `lhs` = input, `rhs` = alpha tensor. + PRelu(BinaryOpIr), + /// [gelu](burn_backend::ops::ActivationOps::gelu). + Gelu(UnaryOpIr), + /// [gelu_backward](burn_backend::ops::ActivationOps::gelu_backward). + /// `lhs` = forward input, `rhs` = upstream gradient. + GeluBackward(BinaryOpIr), + /// [sigmoid](burn_backend::ops::ActivationOps::sigmoid). + Sigmoid(UnaryOpIr), + /// [sigmoid_backward](burn_backend::ops::ActivationOps::sigmoid_backward). + /// `lhs` = output of the forward pass, `rhs` = upstream gradient. + SigmoidBackward(BinaryOpIr), + /// [hard_sigmoid](burn_backend::ops::ActivationOps::hard_sigmoid). + HardSigmoid(HardSigmoidOpIr), + /// [log_sigmoid](burn_backend::ops::ActivationOps::log_sigmoid). + LogSigmoid(UnaryOpIr), + /// [log_sigmoid_backward](burn_backend::ops::ActivationOps::log_sigmoid_backward). + /// `lhs` = forward input, `rhs` = upstream gradient. + LogSigmoidBackward(BinaryOpIr), + /// [softmax](burn_backend::ops::ActivationOps::softmax). + Softmax(DimOpIr), + /// [log_softmax](burn_backend::ops::ActivationOps::log_softmax). + LogSoftmax(DimOpIr), + /// [softmin](burn_backend::ops::ActivationOps::softmin). + Softmin(DimOpIr), +} + +/// Generate [`ActivationOperationIr`]'s `inputs`/`outputs`/`mark_read_only` from a single +/// variant → input-field table, so the three methods can't drift out of sync. The `out` +/// field is implicit (every variant's op IR has one). Each entry lists the variant and the +/// names of its input tensor fields (`input`, `lhs`/`rhs`, `tensor`, …). +macro_rules! activation_ir_tensor_access { + ($( $variant:ident => [ $($field:ident),+ ] ),+ $(,)?) => { + impl ActivationOperationIr { + fn inputs(&self) -> Box + '_> { + match self { + $( Self::$variant(repr) => Box::new([$(&repr.$field),+].into_iter()), )+ + } + } + + fn outputs(&self) -> Box + '_> { + match self { + $( Self::$variant(repr) => Box::new([&repr.out].into_iter()), )+ + } + } + + fn mark_read_only(&mut self, nodes: &[TensorId]) -> Vec { + let mut output = Vec::new(); + match self { + $( Self::$variant(repr) => { + $( repr.$field.mark_read_only(nodes, &mut output); )+ + } )+ + } + output + } + } + }; +} + +impl ActivationOperationIr { + fn visit_mut(&mut self, v: &mut impl IrVisitorMut) { + match self { + ActivationOperationIr::Relu(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::ReluBackward(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::LeakyRelu(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.rhs); + } + ActivationOperationIr::PRelu(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::Gelu(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::GeluBackward(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::Sigmoid(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::SigmoidBackward(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::HardSigmoid(repr) => { + v.visit_tensor_mut(&mut repr.tensor); + v.visit_tensor_mut(&mut repr.out); + v.visit_scalar_mut(&mut repr.alpha); + v.visit_scalar_mut(&mut repr.beta); + } + ActivationOperationIr::LogSigmoid(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::LogSigmoidBackward(repr) => { + v.visit_tensor_mut(&mut repr.lhs); + v.visit_tensor_mut(&mut repr.rhs); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::Softmax(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::LogSoftmax(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + ActivationOperationIr::Softmin(repr) => { + v.visit_tensor_mut(&mut repr.input); + v.visit_tensor_mut(&mut repr.out); + } + } + } +} + +activation_ir_tensor_access! { + Relu => [input], + ReluBackward => [lhs, rhs], + LeakyRelu => [lhs], + PRelu => [lhs, rhs], + Gelu => [input], + GeluBackward => [lhs, rhs], + Sigmoid => [input], + SigmoidBackward => [lhs, rhs], + HardSigmoid => [tensor], + LogSigmoid => [input], + LogSigmoidBackward => [lhs, rhs], + Softmax => [input], + LogSoftmax => [input], + Softmin => [input], +} + +#[cfg(test)] +mod visit_mut_tests { + use super::*; + use burn_backend::{DType, Shape}; + + fn tensor(id: u64) -> TensorIr { + TensorIr::uninit(TensorId::new(id), Shape::from([2, 2]), DType::F32) + } + + /// Bumps every visited tensor id by 100 and collects (and rewrites) every visited scalar. + #[derive(Default)] + struct CollectVisitor { + scalars: Vec, + rewrite_scalar: Option, + } + + impl IrVisitorMut for CollectVisitor { + fn visit_tensor_mut(&mut self, tensor: &mut TensorIr) { + tensor.id = TensorId::new(tensor.id.value() + 100); + } + + fn visit_scalar_mut(&mut self, scalar: &mut ScalarIr) { + self.scalars.push(*scalar); + if let Some(value) = self.rewrite_scalar { + *scalar = value; + } + } + } + + #[test] + fn visit_mut_visits_all_tensors_and_scalars() { + // NumericFloat MulScalar (a ScalarOpIr with a scalar `rhs`). + let mut mul = OperationIr::NumericFloat( + DType::F32, + NumericOperationIr::MulScalar(ScalarOpIr { + lhs: tensor(1), + rhs: ScalarIr::Float(2.0), + out: tensor(2), + }), + ); + + // Bump every tensor id by 100 and rewrite the scalar to 9.0. + let mut visitor = CollectVisitor { + rewrite_scalar: Some(ScalarIr::Float(9.0)), + ..Default::default() + }; + mul.visit_mut(&mut visitor); + + let ids: Vec = mul + .inputs() + .chain(mul.outputs()) + .map(|t| t.id.value()) + .collect(); + assert_eq!(ids, vec![101, 102]); + // The scalar must have been visited (and was rewritable). + assert_eq!(visitor.scalars, vec![ScalarIr::Float(2.0)]); + + // Visiting again observes the rewritten scalar. + let mut after = CollectVisitor::default(); + mul.visit_mut(&mut after); + assert_eq!(after.scalars, vec![ScalarIr::Float(9.0)]); + + // BaseFloat Reshape (a ShapeOpIr, input + out, no scalars). + let mut reshape = OperationIr::BaseFloat(BaseOperationIr::Reshape(ShapeOpIr { + input: tensor(10), + out: tensor(11), + })); + + let mut visitor = CollectVisitor::default(); + reshape.visit_mut(&mut visitor); + let ids: Vec = reshape + .inputs() + .chain(reshape.outputs()) + .map(|t| t.id.value()) + .collect(); + assert_eq!(ids, vec![110, 111]); + assert_eq!(visitor.scalars.len(), 0); + } +} diff --git a/crates/burn-ir/src/scalar.rs b/crates/burn-ir/src/scalar.rs new file mode 100644 index 0000000..3434776 --- /dev/null +++ b/crates/burn-ir/src/scalar.rs @@ -0,0 +1,77 @@ +use burn_backend::{DType, Scalar}; +use burn_backend::{Element, ElementConversion}; +use core::hash::Hash; +use serde::{Deserialize, Serialize}; + +/// A scalar representation. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub enum ScalarIr { + Float(f64), + Int(i64), + UInt(u64), + Bool(bool), +} + +impl Hash for ScalarIr { + fn hash(&self, state: &mut H) { + match self { + ScalarIr::Float(x) => x.to_bits().hash(state), + ScalarIr::Int(x) => x.hash(state), + ScalarIr::UInt(x) => x.hash(state), + ScalarIr::Bool(x) => x.hash(state), + } + } +} + +impl ScalarIr { + /// Creates a scalar with the specified data type. + pub fn new(value: E, dtype: &DType) -> Self { + if dtype.is_float() { + Self::Float(value.elem()) + } else if dtype.is_int() { + Self::Int(value.elem()) + } else if dtype.is_uint() { + Self::UInt(value.elem()) + } else if dtype.is_bool() { + Self::Bool(value.elem()) + } else { + unimplemented!("Scalar not supported for {dtype:?}") + } + } + + /// Converts and returns the converted element. + pub fn elem(self) -> E { + match self { + ScalarIr::Float(x) => x.elem(), + ScalarIr::Int(x) => x.elem(), + ScalarIr::UInt(x) => x.elem(), + ScalarIr::Bool(x) => x.elem(), + } + } +} + +// The enums are similar, but both types have different roles: +// - `Scalar`: runtime literal value +// - `ScalarIr`: serializable literal representation (used for IR) +impl From for ScalarIr { + fn from(value: Scalar) -> Self { + match value { + Scalar::Float(x) => Self::Float(x), + Scalar::Int(x) => Self::Int(x), + Scalar::UInt(x) => Self::UInt(x), + Scalar::Bool(x) => Self::Bool(x), + } + } +} + +impl From for Scalar { + fn from(value: ScalarIr) -> Self { + match value { + ScalarIr::Float(x) => Self::Float(x), + ScalarIr::Int(x) => Self::Int(x), + ScalarIr::UInt(x) => Self::UInt(x), + ScalarIr::Bool(x) => Self::Bool(x), + } + } +} diff --git a/crates/burn-ir/src/tensor.rs b/crates/burn-ir/src/tensor.rs new file mode 100644 index 0000000..bd5d89f --- /dev/null +++ b/crates/burn-ir/src/tensor.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +use burn_backend::{DType, Shape}; + +/// The tensor unique identifier. +#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)] +pub struct TensorId { + value: u64, +} + +impl core::fmt::Display for TensorId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("TensorId({:?})", self.value)) + } +} + +/// The status of the current tensor. +#[derive(Hash, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TensorStatus { + /// The tensor can be read, but not written. + ReadOnly, + /// The tensor can be mutated inplace. + ReadWrite, + /// No handle exists for that tensor. + NotInit, +} + +/// A tensor definition represents a snapshot of a tensor when it was used. +/// +/// # Example +/// +/// A tensor that is used multiple times has its status updated for each operation. +/// +/// 1. Status::NotInit +/// 2. Status::ReadOnly +/// 3. Status::ReadOnly +/// 4. Status::ReadWrite +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct TensorIr { + /// The [tensor id](TensorId). + pub id: TensorId, + /// The shape of the tensor. + pub shape: Shape, + /// The [status](TensorStatus) of the tensor when it was used. + pub status: TensorStatus, + /// The [type](DType) of the tensor. + pub dtype: DType, +} + +impl TensorId { + /// Create a new tensor id. + pub fn new(value: u64) -> Self { + Self { value } + } + + /// Return the underlying numeric value of this id. + pub fn value(&self) -> u64 { + self.value + } +} + +impl TensorIr { + /// Create a new tensor that is not already initialized. + pub fn uninit(id: TensorId, shape: Shape, dtype: DType) -> Self { + Self { + id, + status: TensorStatus::NotInit, + shape, + dtype, + } + } +} diff --git a/crates/burn-ndarray/Cargo.toml b/crates/burn-ndarray/Cargo.toml new file mode 100644 index 0000000..b227128 --- /dev/null +++ b/crates/burn-ndarray/Cargo.toml @@ -0,0 +1,97 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Ndarray backend for the Burn framework" +documentation = "https://docs.rs/burn-ndarray" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-ndarray" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-ndarray" +version.workspace = true + +[lints] +workspace = true + +[features] +blas-accelerate = [ + "blas-src/accelerate", # Accelerate framework (macOS only) + "ndarray/blas", +] +blas-netlib = ["blas-src/netlib", "ndarray/blas"] +blas-openblas = ["blas-src/openblas", "ndarray/blas", "openblas-src"] +blas-openblas-system = [ + "blas-src/openblas", + "ndarray/blas", + "openblas-src/system", +] +default = ["std", "simd", "multi-threads"] +doc = ["default"] +multi-threads = [ + "rayon", + "ndarray/rayon", + "matrixmultiply/threading", +] +simd = ["macerator", "bytemuck", "seq-macro", "itertools"] +std = [ + "burn-autodiff", + "burn-std/std", + "burn-backend/std", + "burn-ir/std", + "ndarray/std", + "matrixmultiply/std", + "rand/std", + "rand/std_rng", + "num-traits/std", + "macerator/std", +] +tracing = [ + "burn-autodiff?/tracing", + "burn-std/tracing", + "burn-backend/tracing", + "burn-ir/tracing", +] + +# Serves as a ref impl for some burn-cubecl kernels +export_tests = [] + +[dependencies] + +# ** Please make sure all dependencies support no_std when std is disabled ** + +burn-autodiff = { workspace = true, optional = true } +burn-std = { workspace = true } +burn-ir = { workspace = true } +burn-backend = { workspace = true } + +atomic_float = { workspace = true } +blas-src = { workspace = true, default-features = false, optional = true } # no-std compatible +const-random = { workspace = true } +libm = { workspace = true } +matrixmultiply = { workspace = true, default-features = false } +ndarray = { workspace = true } +num-traits = { workspace = true } +openblas-src = { workspace = true, optional = true } +paste = { workspace = true } +rand = { workspace = true, default-features = false } + +# SIMD +bytemuck = { workspace = true, optional = true } +itertools = { version = "0.14", optional = true } +macerator = { workspace = true, optional = true } +seq-macro = { version = "0.3", optional = true } + +# Parallel +rayon = { workspace = true, optional = true } + +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +portable-atomic = { workspace = true } +portable-atomic-util = { workspace = true } + +[dev-dependencies] +bytes = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-ndarray/LICENSE-APACHE b/crates/burn-ndarray/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-ndarray/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-ndarray/LICENSE-MIT b/crates/burn-ndarray/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-ndarray/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-ndarray/README.md b/crates/burn-ndarray/README.md new file mode 100644 index 0000000..788a6c0 --- /dev/null +++ b/crates/burn-ndarray/README.md @@ -0,0 +1,36 @@ +# Burn NdArray + +> [Burn](https://github.com/tracel-ai/burn) ndarray backend + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-ndarray.svg)](https://crates.io/crates/burn-ndarray) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-ndarray/blob/master/README.md) + +> **New projects should use [`burn-flex`](https://crates.io/crates/burn-flex).** It is a +> from-scratch pure-Rust CPU backend that replaces `burn-ndarray` with faster gemm, zero-copy view +> operations, native quantization, and full support for `std`, `no_std`, and WebAssembly. See +> [`burn-flex/COMPARISON.md`](../burn-flex/COMPARISON.md) for a migration path and +> operation-by-operation benchmarks. + +## Feature Flags + +This crate can be used without the standard library (`#![no_std]`) with `alloc` by disabling the +default `std` feature. + +The following flags support various BLAS options: + +- `blas-accelerate` - Accelerate framework (macOS only) +- `blas-netlib` - Netlib +- `blas-openblas` - OpenBLAS static linked +- `blas-openblas-system` - OpenBLAS from the system + +Note: under the `no_std` mode, the seed is fixed if the seed is not +initialized by `Backend::seed` method. + +### Platform Support + +| Option | CPU | GPU | Linux | MacOS | Windows | Android | iOS | WASM | +| :--------- | :-: | :-: | :---: | :---: | :-----: | :-----: | :-: | :--: | +| Pure Rust | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | +| Accelerate | Yes | No | No | Yes | No | No | Yes | No | +| Netlib | Yes | No | Yes | Yes | Yes | No | No | No | +| Openblas | Yes | No | Yes | Yes | Yes | Yes | Yes | No | diff --git a/crates/burn-ndarray/build.rs b/crates/burn-ndarray/build.rs new file mode 100644 index 0000000..dcb4354 --- /dev/null +++ b/crates/burn-ndarray/build.rs @@ -0,0 +1,6 @@ +fn main() { + // https://github.com/rust-ndarray/ndarray/issues/1197 + if cfg!(feature = "blas-accelerate") { + println!("cargo:rustc-link-lib=framework=Accelerate"); + } +} diff --git a/crates/burn-ndarray/src/backend.rs b/crates/burn-ndarray/src/backend.rs new file mode 100644 index 0000000..07c7664 --- /dev/null +++ b/crates/burn-ndarray/src/backend.rs @@ -0,0 +1,211 @@ +use crate::rand::NdArrayRng; +use crate::{NdArrayQTensor, NdArrayTensor}; +use alloc::string::String; +use burn_backend::quantization::{QuantLevel, QuantMode, QuantScheme, QuantStore, QuantValue}; +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor}; +use burn_backend::{Backend, BackendTypes, DType, DeviceId, DeviceOps}; +use burn_ir::{BackendIr, HandleKind, TensorHandle}; +use burn_std::stub::Mutex; +use burn_std::{BoolStore, DeviceSettings, QuantConfig}; +use rand::SeedableRng; + +pub(crate) static SEED: Mutex> = Mutex::new(None); + +/// The device type for the ndarray backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NdArrayDevice { + /// The CPU device. + #[default] + Cpu, +} + +impl DeviceOps for NdArrayDevice { + fn defaults(&self) -> DeviceSettings { + // E = f32, I = i64 + DeviceSettings::new( + DType::F32, + DType::I64, + DType::Bool(BoolStore::Native), + QuantConfig::new( + QuantScheme::default().with_store(QuantStore::Native), + Default::default(), + ), + ) + } +} + +impl burn_backend::Device for NdArrayDevice { + fn from_id(_device_id: DeviceId) -> Self { + Self::Cpu + } + + fn to_id(&self) -> DeviceId { + DeviceId { + type_id: 0, + index_id: 0, + } + } +} + +/// Tensor backend that uses the [ndarray](ndarray) crate for executing tensor operations. +/// +/// This backend is compatible with CPUs and can be compiled for almost any platform, including +/// `wasm`, `arm`, and `x86`. +#[derive(Clone, Copy, Default, Debug)] +pub struct NdArray; + +impl BackendTypes for NdArray { + type Device = NdArrayDevice; + + type FloatTensorPrimitive = NdArrayTensor; + type IntTensorPrimitive = NdArrayTensor; + type BoolTensorPrimitive = NdArrayTensor; + type QuantizedTensorPrimitive = NdArrayQTensor; + + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for NdArray { + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + fn name(_device: &Self::Device) -> String { + String::from("ndarray") + } + + fn seed(_device: &Self::Device, seed: u64) { + let rng = NdArrayRng::seed_from_u64(seed); + let mut seed = SEED.lock().unwrap(); + *seed = Some(rng); + } + + fn dtype_usage(_device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet { + match dtype { + DType::F64 + | DType::F32 + | DType::Flex32 + | DType::I64 + | DType::I32 + | DType::I16 + | DType::I8 + | DType::U64 + | DType::U32 + | DType::U16 + | DType::U8 + | DType::Bool(BoolStore::Native) => burn_backend::DTypeUsage::general(), + DType::F16 | DType::BF16 | DType::Bool(_) => burn_backend::DTypeUsageSet::empty(), + DType::QFloat(scheme) => { + match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + #[cfg(not(feature = "export_tests"))] + value: QuantValue::Q8F | QuantValue::Q8S, + // For tests, "native" sub-byte quant serves as a reference for value equality. + // Values are stored as i8 regardless. + #[cfg(feature = "export_tests")] + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S, + store: QuantStore::Native, + .. + } => burn_backend::DTypeUsage::general(), + _scheme => burn_backend::DTypeUsageSet::empty(), + } + } + } + } + + fn device_count(_: u16) -> usize { + 1 + } + + fn flush(_device: &Self::Device) {} +} + +impl BackendIr for NdArray { + type Handle = HandleKind; + + fn float_tensor(handle: TensorHandle) -> FloatTensor { + match handle.handle { + HandleKind::Float(handle) => handle, + _ => panic!("Expected float handle, got {}", handle.handle.name()), + } + } + + fn int_tensor(handle: TensorHandle) -> IntTensor { + match handle.handle { + HandleKind::Int(handle) => handle, + _ => panic!("Expected int handle, got {}", handle.handle.name()), + } + } + + fn bool_tensor(handle: TensorHandle) -> BoolTensor { + match handle.handle { + HandleKind::Bool(handle) => handle, + _ => panic!("Expected bool handle, got {}", handle.handle.name()), + } + } + + fn quantized_tensor(handle: TensorHandle) -> QuantizedTensor { + match handle.handle { + HandleKind::Quantized(handle) => handle, + _ => panic!("Expected quantized handle, got {}", handle.handle.name()), + } + } + + fn float_tensor_handle(tensor: FloatTensor) -> Self::Handle { + HandleKind::Float(tensor) + } + + fn int_tensor_handle(tensor: IntTensor) -> Self::Handle { + HandleKind::Int(tensor) + } + + fn bool_tensor_handle(tensor: BoolTensor) -> Self::Handle { + HandleKind::Bool(tensor) + } + + fn quantized_tensor_handle(tensor: QuantizedTensor) -> Self::Handle { + HandleKind::Quantized(tensor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_support_dtypes() { + type B = NdArray; + let device = NdArrayDevice::Cpu; + let scheme = device.defaults().quantization.scheme; + + assert!(B::supports_dtype(&device, DType::F64)); + assert!(B::supports_dtype(&device, DType::F32)); + assert!(B::supports_dtype(&device, DType::Flex32)); + assert!(B::supports_dtype(&device, DType::I64)); + assert!(B::supports_dtype(&device, DType::I32)); + assert!(B::supports_dtype(&device, DType::I16)); + assert!(B::supports_dtype(&device, DType::I8)); + assert!(B::supports_dtype(&device, DType::U64)); + assert!(B::supports_dtype(&device, DType::U32)); + assert!(B::supports_dtype(&device, DType::U16)); + assert!(B::supports_dtype(&device, DType::U8)); + assert!(B::supports_dtype(&device, DType::Bool(BoolStore::Native))); + assert!(B::supports_dtype(&device, DType::QFloat(scheme))); + + assert!(!B::supports_dtype(&device, DType::F16)); + assert!(!B::supports_dtype(&device, DType::BF16)); + // QuantStore::U32 not supported + assert!(!B::supports_dtype( + &device, + DType::QFloat(QuantScheme::default()) + )); + } +} diff --git a/crates/burn-ndarray/src/element.rs b/crates/burn-ndarray/src/element.rs new file mode 100644 index 0000000..8485352 --- /dev/null +++ b/crates/burn-ndarray/src/element.rs @@ -0,0 +1,207 @@ +use burn_backend::Element; +use num_traits::Signed; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use num_traits::Pow; + +use libm::{log1p, log1pf}; + +/// A float element for ndarray backend. +pub trait FloatNdArrayElement: NdArrayElement + Signed + core::cmp::PartialOrd +where + Self: Sized, +{ +} + +/// An int element for ndarray backend. +pub trait IntNdArrayElement: NdArrayElement + core::cmp::PartialOrd {} + +/// A general element for ndarray backend. +pub trait NdArrayElement: + Element + + ndarray::LinalgScalar + + ndarray::ScalarOperand + + ExpElement + + AddAssignElement + + num_traits::FromPrimitive + + core::ops::AddAssign + + core::cmp::PartialEq + + core::ops::Rem +{ +} + +/// A element for ndarray backend that supports exp ops. +pub trait ExpElement { + /// Exponent + fn exp_elem(self) -> Self; + /// Log + fn log_elem(self) -> Self; + /// Log1p + fn log1p_elem(self) -> Self; + /// Powf + fn powf_elem(self, value: f32) -> Self; + /// Powi + fn powi_elem(self, value: i32) -> Self; + /// Sqrt + fn sqrt_elem(self) -> Self; + /// Abs + fn abs_elem(self) -> Self; +} + +/// The addition assignment operator implemented for ndarray elements. +pub trait AddAssignElement { + /// Performs the addition assignment operation. + /// + /// For `bool`, this corresponds to logical OR assignment. + fn add_assign(&mut self, rhs: Rhs); +} + +impl AddAssignElement for E { + fn add_assign(&mut self, rhs: Self) { + *self += rhs; + } +} + +impl AddAssignElement for bool { + fn add_assign(&mut self, rhs: Self) { + *self = *self || rhs; // logical OR for bool + } +} + +/// A quantized element for the ndarray backend. +pub trait QuantElement: NdArrayElement {} + +impl QuantElement for i8 {} + +impl FloatNdArrayElement for f64 {} +impl FloatNdArrayElement for f32 {} + +impl IntNdArrayElement for i64 {} +impl IntNdArrayElement for i32 {} +impl IntNdArrayElement for i16 {} +impl IntNdArrayElement for i8 {} + +impl IntNdArrayElement for u64 {} +impl IntNdArrayElement for u32 {} +impl IntNdArrayElement for u16 {} +impl IntNdArrayElement for u8 {} + +macro_rules! make_float { + ( + $ty:ty, + $log1p:expr + ) => { + impl NdArrayElement for $ty {} + + #[allow(clippy::cast_abs_to_unsigned)] + impl ExpElement for $ty { + #[inline(always)] + fn exp_elem(self) -> Self { + self.exp() + } + + #[inline(always)] + fn log_elem(self) -> Self { + self.ln() + } + + #[inline(always)] + fn log1p_elem(self) -> Self { + $log1p(self) + } + + #[inline(always)] + fn powf_elem(self, value: f32) -> Self { + self.pow(value) + } + + #[inline(always)] + fn powi_elem(self, value: i32) -> Self { + #[cfg(feature = "std")] + let val = self.powi(value); + + #[cfg(not(feature = "std"))] + let val = Self::powf_elem(self, value as f32); + + val + } + + #[inline(always)] + fn sqrt_elem(self) -> Self { + self.sqrt() + } + + #[inline(always)] + fn abs_elem(self) -> Self { + self.abs() + } + } + }; +} +macro_rules! make_int { + ( + $ty:ty, + $abs:expr + ) => { + impl NdArrayElement for $ty {} + + #[allow(clippy::cast_abs_to_unsigned)] + impl ExpElement for $ty { + #[inline(always)] + fn exp_elem(self) -> Self { + (self as f32).exp() as $ty + } + + #[inline(always)] + fn log_elem(self) -> Self { + (self as f32).ln() as $ty + } + + #[inline(always)] + fn log1p_elem(self) -> Self { + log1pf(self as f32) as $ty + } + + #[inline(always)] + fn powf_elem(self, value: f32) -> Self { + (self as f32).pow(value) as $ty + } + + #[inline(always)] + fn powi_elem(self, value: i32) -> Self { + #[cfg(feature = "std")] + let val = f32::powi(self as f32, value) as $ty; + + #[cfg(not(feature = "std"))] + let val = Self::powf_elem(self, value as f32); + + val + } + + #[inline(always)] + fn sqrt_elem(self) -> Self { + (self as f32).sqrt() as $ty + } + + #[inline(always)] + fn abs_elem(self) -> Self { + $abs(self) + } + } + }; +} + +make_float!(f64, log1p); +make_float!(f32, log1pf); + +make_int!(i64, i64::wrapping_abs); +make_int!(i32, i32::wrapping_abs); +make_int!(i16, i16::wrapping_abs); +make_int!(i8, i8::wrapping_abs); +make_int!(u64, |x| x); +make_int!(u32, |x| x); +make_int!(u16, |x| x); +make_int!(u8, |x| x); diff --git a/crates/burn-ndarray/src/lib.rs b/crates/burn-ndarray/src/lib.rs new file mode 100644 index 0000000..34a4625 --- /dev/null +++ b/crates/burn-ndarray/src/lib.rs @@ -0,0 +1,29 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! Burn ndarray backend. + +#[cfg(any( + feature = "blas-netlib", + feature = "blas-openblas", + feature = "blas-openblas-system", +))] +extern crate blas_src; + +mod backend; +mod element; +mod ops; +mod parallel; +mod rand; +mod sharing; +mod storage; +mod tensor; + +pub use backend::*; +pub use element::*; +pub(crate) use sharing::*; +pub(crate) use storage::*; +pub use tensor::*; + +extern crate alloc; diff --git a/crates/burn-ndarray/src/ops/activation.rs b/crates/burn-ndarray/src/ops/activation.rs new file mode 100644 index 0000000..d5c856e --- /dev/null +++ b/crates/burn-ndarray/src/ops/activation.rs @@ -0,0 +1,8 @@ +use crate::{NdArray, execute_with_numeric_dtype, ops::NdArrayMathOps}; +use burn_backend::{ElementConversion, TensorMetadata, ops::ActivationOps, tensor::FloatTensor}; + +impl ActivationOps for NdArray { + fn relu(tensor: FloatTensor) -> FloatTensor { + execute_with_numeric_dtype!(tensor, |array| NdArrayMathOps::clamp_min(array, 0.elem())) + } +} diff --git a/crates/burn-ndarray/src/ops/adaptive_avgpool.rs b/crates/burn-ndarray/src/ops/adaptive_avgpool.rs new file mode 100644 index 0000000..baaee09 --- /dev/null +++ b/crates/burn-ndarray/src/ops/adaptive_avgpool.rs @@ -0,0 +1,103 @@ +use crate::{ + SharedArray, element::FloatNdArrayElement, iter_range_par, run_par, sharing::UnsafeSharedRef, +}; +use burn_backend::ElementConversion; +use ndarray::Array4; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +pub(crate) fn adaptive_avg_pool2d( + x: SharedArray, + output_size: [usize; 2], +) -> SharedArray { + let [batch_size, channels, input_height, input_width] = x.shape().try_into().unwrap(); + + let mut output = Array4::from_elem( + (batch_size, channels, output_size[0], output_size[1]), + 0.elem(), + ); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output = unsafe_shared_out.get(); + for h in 0..output_size[0] { + for w in 0..output_size[1] { + let ih_start = start_index(h, output_size[0], input_height); + let ih_end = end_index(h, output_size[0], input_height); + let iw_start = start_index(w, output_size[1], input_width); + let iw_end = end_index(w, output_size[1], input_width); + + let mut sum_val: E = 0.elem(); + + for ih in ih_start..ih_end { + for iw in iw_start..iw_end { + sum_val += x[[b, c, ih, iw]]; + } + } + + let count: E = (((ih_end - ih_start) * (iw_end - iw_start)) as i32).elem(); + output[[b, c, h, w]] = sum_val / count.elem(); + } + } + }) + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn adaptive_avg_pool2d_backward( + x: SharedArray, + grad: SharedArray, +) -> SharedArray { + let [_, _, input_height, input_width] = x.shape().try_into().unwrap(); + let [batch_size, channels, output_height, output_width] = grad.shape().try_into().unwrap(); + + let mut output_grad = + Array4::from_elem((batch_size, channels, input_height, input_width), 0.elem()); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output_grad); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output_grad = unsafe_shared_out.get(); + for oh in 0..output_height { + for ow in 0..output_width { + let ih_start = start_index(oh, output_height, input_height); + let ih_end = end_index(oh, output_height, input_height); + + let iw_start = start_index(ow, output_width, input_width); + let iw_end = end_index(ow, output_width, input_width); + + let count: E = (((ih_end - ih_start) * (iw_end - iw_start)) as i32).elem(); + + for ih in ih_start..ih_end { + for iw in iw_start..iw_end { + output_grad[[b, c, ih, iw]] += grad[[b, c, oh, ow]] / count.elem(); + } + } + } + } + }) + }); + + output_grad.into_dyn().into_shared() +} + +fn start_index(output_size_index: usize, output_size: usize, input_size: usize) -> usize { + ((output_size_index as f32 * input_size as f32) / output_size as f32).floor() as usize +} + +fn end_index(output_size_index: usize, output_size: usize, input_size: usize) -> usize { + let index = + (((output_size_index + 1) as f32 * input_size as f32) / output_size as f32).ceil() as usize; + + usize::min(index, input_size) +} diff --git a/crates/burn-ndarray/src/ops/avgpool.rs b/crates/burn-ndarray/src/ops/avgpool.rs new file mode 100644 index 0000000..4d015dd --- /dev/null +++ b/crates/burn-ndarray/src/ops/avgpool.rs @@ -0,0 +1,172 @@ +use crate::{ + SharedArray, element::FloatNdArrayElement, iter_range_par, run_par, sharing::UnsafeSharedRef, +}; + +use burn_backend::ElementConversion; +use burn_backend::ops::conv::calculate_pool_output_size; +use ndarray::Array4; + +pub(crate) fn avg_pool2d( + x: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> SharedArray { + let [kernel_height, kernel_width] = kernel_size; + let [padding_height, padding_width] = padding; + let [stride_height, stride_width] = stride; + let [batch_size, channels, x_height, x_width] = x.shape().try_into().unwrap(); + + let out_height = calculate_pool_output_size( + kernel_height, + stride_height, + padding_height, + 1, + x_height, + ceil_mode, + ); + let out_width = calculate_pool_output_size( + kernel_width, + stride_width, + padding_width, + 1, + x_width, + ceil_mode, + ); + + // Padded input bounds (for count_include_pad calculation) + let padded_height = x_height + 2 * padding_height; + let padded_width = x_width + 2 * padding_width; + + let mut output = Array4::from_elem((batch_size, channels, out_height, out_width), 0.elem()); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output = unsafe_shared_out.get(); + + for oh in 0..out_height { + for ow in 0..out_width { + let mut sum_val: E = 0.elem(); + let mut valid_count = 0usize; + let mut padded_count = 0usize; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw; + + // Check if within padded bounds (excludes ceil_mode extensions) + if ih < padded_height && iw < padded_width { + padded_count += 1; + + // Check if within valid (non-padding) input bounds + if ih >= padding_height + && ih < x_height + padding_height + && iw >= padding_width + && iw < x_width + padding_width + { + let ih_valid = ih - padding_height; + let iw_valid = iw - padding_width; + sum_val += x[[b, c, ih_valid, iw_valid]]; + valid_count += 1; + } + } + } + } + + // count_include_pad: count positions within padded bounds (not ceil_mode extensions) + // !count_include_pad: count only valid (non-padding) positions + let count: E = if count_include_pad { + (padded_count as i32).elem() + } else { + (valid_count as i32).elem() + }; + + output[[b, c, oh, ow]] = sum_val / count; + } + } + }) + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn avg_pool2d_backward( + x: SharedArray, + grad: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + _ceil_mode: bool, +) -> SharedArray { + let [kernel_height, kernel_width] = kernel_size; + let [stride_height, stride_width] = stride; + let [padding_height, padding_width] = padding; + let [batch_size, channels, x_height, x_width] = x.shape().try_into().unwrap(); + let [_batch_size, _channels, out_height, out_width] = grad.shape().try_into().unwrap(); + + // Padded input bounds (for count_include_pad calculation) + let padded_height = x_height + 2 * padding_height; + let padded_width = x_width + 2 * padding_width; + + let mut output_grad = Array4::from_elem((batch_size, channels, x_height, x_width), 0.elem()); + let unsafe_shared_grad = UnsafeSharedRef::new(&mut output_grad); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output_grad = unsafe_shared_grad.get(); + + for oh in 0..out_height { + for ow in 0..out_width { + let ih_start_kernel = oh * stride_height; + let iw_start_kernel = ow * stride_width; + + let ih_end_kernel = ih_start_kernel + kernel_height; + let iw_end_kernel = iw_start_kernel + kernel_width; + + // Clip to valid input bounds (for gradient distribution) + let ih_start = usize::max(ih_start_kernel, padding_height); + let iw_start = usize::max(iw_start_kernel, padding_width); + let ih_end = usize::min(ih_end_kernel, x_height + padding_height); + let iw_end = usize::min(iw_end_kernel, x_width + padding_width); + + // Calculate count based on count_include_pad + let count = if count_include_pad { + // Count positions within padded bounds (not ceil_mode extensions) + let ih_start_padded = ih_start_kernel; + let iw_start_padded = iw_start_kernel; + let ih_end_padded = usize::min(ih_end_kernel, padded_height); + let iw_end_padded = usize::min(iw_end_kernel, padded_width); + (ih_end_padded - ih_start_padded) * (iw_end_padded - iw_start_padded) + } else { + // Count only valid (non-padding) positions + (ih_end - ih_start) * (iw_end - iw_start) + }; + + for ih in ih_start..ih_end { + for iw in iw_start..iw_end { + let ih = ih - padding_height; + let iw = iw - padding_width; + + output_grad[[b, c, ih, iw]] += + grad[[b, c, oh, ow]] / (count as i32).elem(); + } + } + } + } + }) + }); + + output_grad.into_dyn().into_shared() +} diff --git a/crates/burn-ndarray/src/ops/base.rs b/crates/burn-ndarray/src/ops/base.rs new file mode 100644 index 0000000..d34fbcd --- /dev/null +++ b/crates/burn-ndarray/src/ops/base.rs @@ -0,0 +1,1665 @@ +use alloc::{vec, vec::Vec}; +use burn_backend::element::{Element, ElementConversion}; +#[cfg(feature = "simd")] +use burn_backend::{DType, quantization::QuantValue}; +use core::fmt::Debug; +use core::marker::PhantomData; +use ndarray::Dimension; +use ndarray::IntoDimension; +use ndarray::SliceInfo; +use ndarray::Zip; +use ndarray::s; +use ndarray::{Array2, ArrayD}; +use num_traits::Signed; +#[cfg(feature = "simd")] +use paste::paste; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +#[cfg(feature = "simd")] +use crate::ops::simd::{ + binary::try_binary_simd, + binary_elemwise::{ + VecAdd, VecBitAnd, VecBitOr, VecBitXor, VecClamp, VecDiv, VecMax, VecMin, VecMul, VecSub, + try_binary_scalar_simd, + }, + cmp::{ + VecEquals, VecGreater, VecGreaterEq, VecLower, VecLowerEq, try_cmp_scalar_simd, + try_cmp_simd, + }, + unary::{RecipVec, VecAbs, VecBitNot, try_unary_simd}, +}; +use crate::reshape; +use crate::{ + IntNdArrayElement, ShapeOps, + ops::macros::{ + cummax_dim, cummin_dim, cumprod_dim, cumsum_dim, keepdim, mean_dim, prod_dim, sum_dim, + }, +}; +use crate::{SharedArray, element::NdArrayElement}; +use burn_backend::ops::unfold::calculate_unfold_shape; +use burn_backend::{Shape, Slice}; +use ndarray::ArrayView; +use ndarray::Axis; +use ndarray::Dim; +use ndarray::IxDyn; +use ndarray::SliceInfoElem; + +pub struct NdArrayOps { + e: PhantomData, +} + +pub(crate) struct NdArrayMathOps { + e: PhantomData, +} + +impl NdArrayOps +where + E: Copy + Debug + Element + crate::AddAssignElement, +{ + pub fn slice(tensor: ArrayView, slices: &[Slice]) -> SharedArray { + let slices = Self::to_slice_args_with_steps(slices, tensor.shape().num_dims()); + tensor.slice_move(slices.as_slice()).to_shared() + } + + pub fn slice_assign( + tensor: SharedArray, + slices: &[Slice], + value: SharedArray, + ) -> SharedArray { + let slices = Self::to_slice_args_with_steps(slices, tensor.shape().num_dims()); + let mut array = tensor.into_owned(); + array.slice_mut(slices.as_slice()).assign(&value); + array.into_shared() + } + + pub fn mask_where( + tensor: SharedArray, + mask: SharedArray, + source: SharedArray, + ) -> SharedArray { + let tensor = tensor.broadcast(mask.dim()).unwrap(); + let source = source.broadcast(mask.dim()).unwrap(); + Zip::from(&tensor) + .and(&mask) + .and(&source) + .map_collect(|&x, &mask_val, &y| if mask_val { y } else { x }) + .into_shared() + } + + pub fn mask_fill(tensor: SharedArray, mask: SharedArray, value: E) -> SharedArray { + // Use into_owned() instead of clone() - only copies if shared, avoids copy if unique + let mut output = tensor.into_owned(); + let broadcast_mask = mask.broadcast(output.dim()).unwrap(); + Zip::from(&mut output) + .and(&broadcast_mask) + .for_each(|out, &mask_val| { + if mask_val { + *out = value; + } + }); + output.into_shared() + } + + pub fn gather( + dim: usize, + mut tensor: SharedArray, + mut indices: SharedArray, + ) -> SharedArray { + let ndims = tensor.shape().num_dims(); + if dim != ndims - 1 { + tensor.swap_axes(ndims - 1, dim); + indices.swap_axes(ndims - 1, dim); + } + let (shape_tensor, shape_indices) = (tensor.shape(), indices.shape().into_shape()); + let (size_tensor, size_index) = (shape_tensor[ndims - 1], shape_indices[ndims - 1]); + let batch_size = Self::gather_batch_size(shape_tensor, &shape_indices); + + let indices = NdArrayOps::reshape(indices, Shape::new([batch_size, size_index])); + let tensor = NdArrayOps::reshape(tensor, Shape::new([batch_size, size_tensor])); + let mut output = Array2::from_elem((batch_size, size_index), 0.elem::()); + + for b in 0..batch_size { + let indices = indices.slice(s!(b, ..)); + for (i, index) in indices.iter().enumerate() { + output[[b, i]] = tensor[[b, index.elem::() as usize]]; + } + } + + let mut output = NdArrayOps::reshape(output.into_shared().into_dyn(), shape_indices); + + if dim != ndims - 1 { + output.swap_axes(ndims - 1, dim); + } + + output + } + + pub fn scatter( + dim: usize, + mut tensor: SharedArray, + mut indices: SharedArray, + mut value: SharedArray, + ) -> SharedArray { + let ndims = tensor.shape().num_dims(); + if dim != ndims - 1 { + tensor.swap_axes(ndims - 1, dim); + indices.swap_axes(ndims - 1, dim); + value.swap_axes(ndims - 1, dim); + } + + let (shape_tensor, shape_indices, shape_value) = + (tensor.shape().into_shape(), indices.shape(), value.shape()); + let (size_tensor, size_index, size_value) = ( + shape_tensor[ndims - 1], + shape_indices[ndims - 1], + shape_value[ndims - 1], + ); + let batch_size = Self::gather_batch_size(&shape_tensor, shape_indices); + + if shape_value != shape_indices { + panic!( + "Invalid dimension: the shape of the index tensor should be the same as the value \ + tensor: Index {:?} value {:?}", + shape_indices, shape_value + ); + } + + let indices = NdArrayOps::reshape(indices, Shape::new([batch_size, size_index])); + let value = NdArrayOps::reshape(value, Shape::new([batch_size, size_value])); + let mut tensor = NdArrayOps::reshape(tensor, Shape::new([batch_size, size_tensor])); + + for b in 0..batch_size { + let indices = indices.slice(s!(b, ..)); + + for (i, index) in indices.iter().enumerate() { + let index = index.elem::() as usize; + tensor[[b, index]].add_assign(value[[b, i]]); + } + } + + let mut output = NdArrayOps::reshape(tensor.into_shared().into_dyn(), shape_tensor); + if dim != ndims - 1 { + output.swap_axes(ndims - 1, dim); + } + output + } + + pub fn scatter_nd( + data: SharedArray, + indices: SharedArray, + values: SharedArray, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> SharedArray + where + E: core::ops::Mul + PartialOrd, + { + use burn_backend::tensor::IndexingUpdateOp; + + let data_shape: Vec = data.shape().to_vec(); + let idx_shape: Vec = indices.shape().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + + // Number of index tuples = product of batch dims (first M-1 dims of indices) + let num_indices: usize = idx_shape[..m - 1].iter().product(); + // Size of each slice to scatter = product of data.shape[K..] + let slice_size: usize = data_shape[k..].iter().product(); + + let mut output = data.into_owned(); + let output_flat = output + .as_slice_mut() + .expect("ndarray scatter_nd requires contiguous data"); + + // Flatten indices to [num_indices, K] + let idx_flat = indices + .as_slice() + .expect("ndarray scatter_nd requires contiguous indices"); + + // Flatten values to [num_indices, slice_size] + let val_flat = values + .as_slice() + .expect("ndarray scatter_nd requires contiguous values"); + + let strides: Vec = { + let mut s = vec![0usize; k]; + if k > 0 { + s[k - 1] = slice_size; + for i in (0..k - 1).rev() { + s[i] = s[i + 1] * data_shape[i + 1]; + } + } + s + }; + + for n in 0..num_indices { + // Compute flat base offset from the K-dimensional index + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = idx_flat[n * k + j].elem::() as usize; + base_offset += idx_val * strides[j]; + } + + // Apply reduction over the slice + let val_offset = n * slice_size; + match reduction { + IndexingUpdateOp::Assign => { + output_flat[base_offset..(base_offset + slice_size)] + .copy_from_slice(&val_flat[val_offset..(val_offset + slice_size)]); + } + IndexingUpdateOp::Add => { + for s in 0..slice_size { + output_flat[base_offset + s].add_assign(val_flat[val_offset + s]); + } + } + IndexingUpdateOp::Mul => { + for s in 0..slice_size { + output_flat[base_offset + s] = + output_flat[base_offset + s] * val_flat[val_offset + s]; + } + } + IndexingUpdateOp::Min => { + for s in 0..slice_size { + let b = val_flat[val_offset + s]; + if b < output_flat[base_offset + s] { + output_flat[base_offset + s] = b; + } + } + } + IndexingUpdateOp::Max => { + for s in 0..slice_size { + let b = val_flat[val_offset + s]; + if b > output_flat[base_offset + s] { + output_flat[base_offset + s] = b; + } + } + } + } + } + + output.into_shared() + } + + pub fn gather_nd( + data: SharedArray, + indices: SharedArray, + ) -> SharedArray { + let data_shape: Vec = data.shape().to_vec(); + let idx_shape: Vec = indices.shape().to_vec(); + let m = idx_shape.len(); + let k = idx_shape[m - 1]; + + // Number of index tuples + let num_indices: usize = idx_shape[..m - 1].iter().product(); + // Size of each output slice + let slice_size: usize = data_shape[k..].iter().product(); + + // Output shape: idx_shape[..m-1] ++ data_shape[k..] + let mut out_shape_vec: Vec = idx_shape[..m - 1].to_vec(); + out_shape_vec.extend_from_slice(&data_shape[k..]); + let out_total = num_indices * slice_size; + + let data_flat = data + .as_slice() + .expect("ndarray gather_nd requires contiguous data"); + + let idx_flat = indices + .as_slice() + .expect("ndarray gather_nd requires contiguous indices"); + + let strides: Vec = { + let mut s = vec![0usize; k]; + if k > 0 { + s[k - 1] = slice_size; + for i in (0..k - 1).rev() { + s[i] = s[i + 1] * data_shape[i + 1]; + } + } + s + }; + + let mut output_vec: Vec = vec![0.elem::(); out_total]; + + for n in 0..num_indices { + let mut base_offset = 0usize; + for j in 0..k { + let idx_val = idx_flat[n * k + j].elem::() as usize; + base_offset += idx_val * strides[j]; + } + + let out_offset = n * slice_size; + output_vec[out_offset..(out_offset + slice_size)] + .copy_from_slice(&data_flat[base_offset..(base_offset + slice_size)]); + } + + let out_shape = Shape::from(out_shape_vec); + let output = ArrayD::from_shape_vec(out_shape.as_slice(), output_vec) + .expect("gather_nd: shape mismatch"); + + output.into_shared() + } + + fn gather_batch_size(shape_tensor: &[usize], shape_indices: &[usize]) -> usize { + let ndims = shape_tensor.num_dims(); + let mut batch_size = 1; + + for i in 0..ndims - 1 { + if shape_tensor[i] != shape_indices[i] { + panic!( + "Unsupported dimension, only the last dimension can differ: Tensor {:?} Index \ + {:?}", + shape_tensor, shape_indices + ); + } + batch_size *= shape_indices[i]; + } + + batch_size + } + + pub fn reshape(tensor: SharedArray, shape: Shape) -> SharedArray { + reshape!( + ty E, + shape shape, + array tensor, + d shape.num_dims() + ) + } + + pub(crate) fn concatenate( + arrays: &[ndarray::ArrayView], + dim: usize, + ) -> SharedArray { + let array = ndarray::concatenate(Axis(dim), arrays) + .unwrap() + .into_shared(); + + // Transform column-major layout into row-major (standard) layout. (fix #1053) + // Get shape first (via reference), then pass ownership to avoid clone + let shape = array.shape().into_shape(); + Self::reshape(array, shape) + } + + pub fn cat(tensors: Vec>, dim: usize) -> SharedArray { + let arrays: Vec<_> = tensors.iter().map(|t| t.view()).collect(); + Self::concatenate(&arrays, dim) + } + + #[allow(clippy::wrong_self_convention)] + fn to_slice_args_with_steps( + burn_slices: &[burn_backend::Slice], + ndims: usize, + ) -> Vec { + let mut slices = vec![SliceInfoElem::NewAxis; ndims]; + + for i in 0..ndims { + slices[i] = if i < burn_slices.len() { + let slice = &burn_slices[i]; + + // Check for empty range (would result in no elements) + if let Some(end) = slice.end + && slice.start == end + { + SliceInfoElem::Slice { + start: 0, + end: Some(0), + step: 1, + } + } else { + // Pass slice parameters directly to ndarray + // ndarray handles both positive and negative steps correctly: + // - Positive step: iterates forward from start + // - Negative step: iterates backward from the last element in range + SliceInfoElem::Slice { + start: slice.start, + end: slice.end, + step: slice.step, + } + } + } else { + // Dimension not specified in slices - use full range + SliceInfoElem::Slice { + start: 0, + end: None, + step: 1, + } + } + } + + slices + } + + pub fn swap_dims(mut tensor: SharedArray, dim1: usize, dim2: usize) -> SharedArray { + tensor.swap_axes(dim1, dim2); + + tensor + } + + pub fn permute(tensor: SharedArray, axes: &[usize]) -> SharedArray { + tensor.permuted_axes(axes.into_dimension()) + } + + /// Broadcasts the tensor to the given shape + pub(crate) fn expand(tensor: SharedArray, shape: Shape) -> SharedArray { + tensor + .broadcast(shape.into_dimension()) + .expect("The shapes should be broadcastable") + // need to convert view to owned array because NdArrayTensor expects owned array + // and try_into_owned_nocopy() panics for broadcasted arrays (zero strides) + .into_owned() + .into_shared() + } + + pub fn flip(tensor: SharedArray, axes: &[usize]) -> SharedArray { + let slice_items: Vec<_> = (0..tensor.shape().num_dims()) + .map(|i| { + if axes.contains(&i) { + SliceInfoElem::Slice { + start: 0, + end: None, + step: -1, + } + } else { + SliceInfoElem::Slice { + start: 0, + end: None, + step: 1, + } + } + }) + .collect(); + let slice_info = + SliceInfo::, IxDyn, IxDyn>::try_from(slice_items).unwrap(); + tensor.slice(slice_info).into_owned().into_shared() + } + + /// Unfold windows along a dimension. + /// + /// # Warning + /// + /// This is a copy impl; `ndarray` doesn't expose the layout machinery + /// necessary to build the stride view. + /// + /// Returns a copy of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` + /// * `dim` - the dimension to unfold. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with shape ``[pre=..., windows, post=..., size]``. + #[allow(unused)] + pub(crate) fn unfold( + tensor: SharedArray, + dim: usize, + size: usize, + step: usize, + ) -> SharedArray { + let result_shape = calculate_unfold_shape(tensor.shape(), dim, size, step); + let windows = result_shape[dim]; + + let mut slices = vec![Slice::new(0, None, 1); tensor.shape().len()]; + let new_axis = slices.len(); + + let mut stack = Vec::with_capacity(windows); + for widx in 0..windows { + let start = widx * step; + let end = start + size; + slices[dim] = Slice::new(start as isize, Some(end as isize), 1); + + let mut window_slice = + tensor.slice(Self::to_slice_args_with_steps(&slices, slices.len()).as_slice()); + window_slice.insert_axis_inplace(Axis(new_axis)); + window_slice.swap_axes(dim, new_axis); + + stack.push(window_slice); + } + Self::concatenate(&stack, dim) + } +} + +#[cfg(feature = "simd")] +macro_rules! dispatch_binary_simd { + (noq, $elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_binary_simd::<$elem, $elem, $ty, $ty, $op>($lhs, $rhs),)* + _ => Err(($lhs, $rhs)), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_binary_simd::<$elem, $elem, $ty, $ty, $op>($lhs, $rhs),)* + DType::QFloat(strategy) => match strategy.value { + QuantValue::Q8F | QuantValue::Q8S => try_binary_simd::<$elem, $elem, i8, i8, $op>($lhs, $rhs), + _ => Err(($lhs, $rhs)), + }, + _ => Err(($lhs, $rhs)), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; +} + +#[cfg(not(feature = "simd"))] +macro_rules! dispatch_binary_simd { + (noq, $elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ ($lhs, $rhs) }}; + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ ($lhs, $rhs) }}; +} + +#[cfg(feature = "simd")] +macro_rules! dispatch_binary_scalar_simd { + (noq, $elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_binary_scalar_simd::<$elem, $elem, $ty, $ty, $op>($lhs, $rhs),)* + _ => Err($lhs), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_binary_scalar_simd::<$elem, $elem, $ty, $ty, $op>($lhs, $rhs),)* + DType::QFloat(strategy) => match strategy.value { + QuantValue::Q8F | QuantValue::Q8S => try_binary_scalar_simd::<$elem, $elem, i8, i8, $op>($lhs, $rhs), + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S | QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => Err($lhs) + }, + _ => Err($lhs), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; +} + +#[cfg(not(feature = "simd"))] +macro_rules! dispatch_binary_scalar_simd { + (noq, $elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ $lhs }}; + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ $lhs }}; +} + +#[cfg(feature = "simd")] +macro_rules! dispatch_cmp_simd { + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_cmp_simd::<$elem, $ty, $op>($lhs, $rhs),)* + DType::QFloat(strategy) => match strategy.value { + QuantValue::Q8F | QuantValue::Q8S => try_cmp_simd::<$elem, i8, $op>($lhs, $rhs), + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S | QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => Err(($lhs, $rhs)) + }, + _ => Err(($lhs, $rhs)), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; +} + +#[cfg(not(feature = "simd"))] +macro_rules! dispatch_cmp_simd { + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ ($lhs, $rhs) }}; +} + +#[cfg(feature = "simd")] +macro_rules! dispatch_cmp_scalar_simd { + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_cmp_scalar_simd::<$elem, $ty, $op>($lhs, $rhs),)* + DType::QFloat(strategy) => match strategy.value { + QuantValue::Q8F | QuantValue::Q8S => try_cmp_scalar_simd::<$elem, i8, $op>($lhs, $rhs), + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S | QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => Err($lhs) + }, + _ => Err($lhs), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; +} + +#[cfg(not(feature = "simd"))] +macro_rules! dispatch_cmp_scalar_simd { + ($elem: ty, $op: ty, $lhs: expr, $rhs: expr, $($ty: ty),*) => {{ $lhs }}; +} + +#[cfg(feature = "simd")] +macro_rules! dispatch_unary_simd { + ($elem: ty, $op: ty, $lhs: expr, $($ty: ty),*) => {{ + paste! { + let simd = match $elem::dtype() { + $(DType::[<$ty:upper>] => try_unary_simd::<$elem, $elem, $ty, $ty, $op>($lhs),)* + _ => Err($lhs), + }; + match simd { + Ok(out) => return out, + Err(args) => args, + } + } + }}; +} + +#[cfg(not(feature = "simd"))] +macro_rules! dispatch_unary_simd { + ($elem: ty, $op: ty, $lhs: expr, $($ty: ty),*) => {{ $lhs }}; +} + +// Helper function to broadcast two tensors to a common shape for binary operations +// Returns broadcasted views that can be safely zipped +fn broadcast_for_binary_ops<'a, E: Copy, S1, S2>( + lhs: &'a ndarray::ArrayBase, + rhs: &'a ndarray::ArrayBase, +) -> ( + ndarray::ArrayView<'a, E, ndarray::IxDyn>, + ndarray::ArrayView<'a, E, ndarray::IxDyn>, +) +where + S1: ndarray::Data, + S2: ndarray::Data, +{ + // Get shapes + let lhs_shape = lhs.shape(); + let rhs_shape = rhs.shape(); + + // Compute broadcast shape using ndarray's broadcast compatibility rules + let ndims = lhs_shape.len().max(rhs_shape.len()); + let mut broadcast_shape = vec![1; ndims]; + + for i in 0..ndims { + let lhs_dim = if i < lhs_shape.len() { + lhs_shape[lhs_shape.len() - 1 - i] + } else { + 1 + }; + let rhs_dim = if i < rhs_shape.len() { + rhs_shape[rhs_shape.len() - 1 - i] + } else { + 1 + }; + + if lhs_dim == rhs_dim { + broadcast_shape[ndims - 1 - i] = lhs_dim; + } else if lhs_dim == 1 { + broadcast_shape[ndims - 1 - i] = rhs_dim; + } else if rhs_dim == 1 { + broadcast_shape[ndims - 1 - i] = lhs_dim; + } else { + panic!( + "Incompatible shapes for broadcasting: {:?} and {:?}", + lhs_shape, rhs_shape + ); + } + } + + // Create IxDyn from broadcast shape + let broadcast_dim = ndarray::IxDyn(&broadcast_shape); + + // Broadcast both arrays + let lhs_broadcast = lhs + .broadcast(broadcast_dim.clone()) + .expect("Failed to broadcast lhs"); + let rhs_broadcast = rhs + .broadcast(broadcast_dim) + .expect("Failed to broadcast rhs"); + + (lhs_broadcast, rhs_broadcast) +} + +impl NdArrayMathOps +where + E: Copy + NdArrayElement, +{ + pub fn add(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_binary_simd!( + E, VecAdd, lhs, rhs, u8, i8, u16, i16, u32, i32, f32, u64, i64, f64 + ); + + let array = &lhs + &rhs; + array.into_shared() + } + + pub fn add_scalar(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + E, + VecAdd, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + i32, + f32, + u64, + i64, + f64 + ); + + let array = lhs + rhs; + array.into_shared() + } + + pub fn sub(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_binary_simd!( + E, VecSub, lhs, rhs, u8, i8, u16, i16, u32, i32, f32, u64, i64, f64 + ); + + let array = lhs - rhs; + array.into_shared() + } + + pub fn sub_scalar(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + E, + VecSub, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + i32, + f32, + u64, + i64, + f64 + ); + + let array = lhs - rhs; + array.into_shared() + } + + pub fn mul(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = + dispatch_binary_simd!(noq, E, VecMul, lhs, rhs, u16, i16, u32, i32, f32, f64); + + let array = lhs * rhs; + array.into_shared() + } + + pub fn mul_scalar(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + noq, + E, + VecMul, + lhs, + rhs.elem(), + u16, + i16, + u32, + i32, + f32, + f64 + ); + + let array = lhs * rhs; + array.into_shared() + } + + pub fn div(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_binary_simd!(noq, E, VecDiv, lhs, rhs, f32, f64); + + let array = lhs / rhs; + array.into_shared() + } + + pub fn div_scalar(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!(noq, E, VecDiv, lhs, rhs.elem(), f32, f64); + + let array = lhs / rhs; + array.into_shared() + } + + pub fn remainder(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = broadcast_for_binary_ops(&lhs, &rhs); + + Zip::from(&lhs) + .and(&rhs) + .map_collect(|&a, &b| { + let a_f = a.to_f64(); + let b_f = b.to_f64(); + let r = a_f - b_f * (a_f / b_f).floor(); + r.elem() + }) + .into_shared() + } + + pub fn remainder_scalar(lhs: SharedArray, rhs: E) -> SharedArray + where + E: core::ops::Rem, + { + let array = lhs.mapv(|x| ((x % rhs) + rhs) % rhs); + array.into_shared() + } + + pub fn recip(tensor: SharedArray) -> SharedArray { + let tensor = dispatch_unary_simd!(E, RecipVec, tensor, f32); + + let array = tensor.map(|x| 1.elem::() / *x); + array.into_shared() + } + + /// Sum all elements - zero-copy for borrowed storage. + pub fn sum_view(view: ArrayView<'_, E, IxDyn>) -> SharedArray { + let sum = view.sum(); + ArrayD::from_elem(IxDyn(&[1]), sum).into_shared() + } + + /// Mean of all elements - zero-copy for borrowed storage. + pub fn mean_view(view: ArrayView<'_, E, IxDyn>) -> SharedArray { + let mean = view.mean().unwrap(); + ArrayD::from_elem(IxDyn(&[1]), mean).into_shared() + } + + /// Product of all elements - zero-copy for borrowed storage. + pub fn prod_view(view: ArrayView<'_, E, IxDyn>) -> SharedArray { + let prod = view.iter().fold(E::one(), |acc, &x| acc * x); + ArrayD::from_elem(IxDyn(&[1]), prod).into_shared() + } + + pub fn mean_dim(tensor: SharedArray, dim: usize) -> SharedArray { + let ndims = tensor.shape().num_dims(); + match ndims { + d if (1..=6).contains(&d) => keepdim!(dim, tensor, mean), + _ => panic!("Dim not supported {ndims}"), + } + } + + pub fn sum_dim(tensor: SharedArray, dim: usize) -> SharedArray { + let ndims = tensor.shape().num_dims(); + match ndims { + d if (1..=6).contains(&d) => keepdim!(dim, tensor, sum), + _ => panic!("Dim not supported {ndims}"), + } + } + + pub fn prod_dim(tensor: SharedArray, dim: usize) -> SharedArray { + let ndims = tensor.shape().num_dims(); + match ndims { + d if (1..=6).contains(&d) => keepdim!(dim, tensor, prod), + _ => panic!("Dim not supported {ndims}"), + } + } + + pub fn cumsum(tensor: SharedArray, dim: usize) -> SharedArray { + cumsum_dim(tensor, dim) + } + + pub fn cumprod(tensor: SharedArray, dim: usize) -> SharedArray { + cumprod_dim(tensor, dim) + } + + pub fn select( + tensor: SharedArray, + dim: usize, + indices: SharedArray, + ) -> SharedArray { + // Read the indices from a contiguous slice rather than through the + // array's own iterator: the slice walks a pointer, while the + // iterator advances a dynamic-rank index on every element and costs + // more than the selection itself. The standardization is a view, and + // hence free, for contiguous indices; otherwise, it pays that + // iteration once, which the direct consumption would pay anyway. + let indices = indices.as_standard_layout(); + let array = tensor.select( + Axis(dim), + &indices + .as_slice() + .unwrap() + .iter() + .map(|i| i.elem::() as usize) + .collect::>(), + ); + + array.into_shared() + } + + pub fn select_assign( + tensor: SharedArray, + dim: usize, + indices: SharedArray, + value: SharedArray, + ) -> SharedArray { + let mut output_array = tensor.into_owned(); + + for (index_value, index) in indices.into_iter().enumerate() { + let mut view = output_array.index_axis_mut(Axis(dim), index.elem::() as usize); + let value = value.index_axis(Axis(dim), index_value); + + view.zip_mut_with(&value, |a, b| *a += *b); + } + + output_array.into_shared() + } + + fn broadcast_dims(lhs: D::Pattern, rhs: D::Pattern) -> Option + where + D: Dimension + IntoDimension, + { + if lhs == rhs { + Some(lhs) + } else { + let lhs = lhs.into_dimension(); + let rhs = rhs.into_dimension(); + let lhs = lhs.slice(); + let rhs = rhs.slice(); + + let total_dims = core::cmp::max(lhs.len(), rhs.len()); + let mut target_shape = vec![0; total_dims]; + // Iterate backwards (from the trailing dimensions inward) + for i in 0..total_dims { + let lhs_dim = lhs + .len() + .checked_sub(1 + i) + .map(|idx| lhs[idx]) + .unwrap_or(1); + let rhs_dim = rhs + .len() + .checked_sub(1 + i) + .map(|idx| rhs[idx]) + .unwrap_or(1); + + if lhs_dim == rhs_dim { + target_shape[total_dims - 1 - i] = lhs_dim; + } else if lhs_dim == 1 { + target_shape[total_dims - 1 - i] = rhs_dim; + } else if rhs_dim == 1 { + target_shape[total_dims - 1 - i] = lhs_dim; + } else { + // Dimensions are completely incompatible (e.g., trying to match 3 and 5) + return None; + } + } + + let dyn_dim = IxDyn(&target_shape); + D::Dim::from_dimension(&dyn_dim).map(|d| d.into_pattern()) + } + } + + pub(crate) fn elementwise_op( + lhs: SharedArray, + rhs: SharedArray, + var_name: impl FnMut(&E, &OtherE) -> E, + ) -> SharedArray { + if let Some(target) = Self::broadcast_dims::(lhs.dim(), rhs.dim()) { + let lhs = lhs.broadcast(target.clone()).unwrap(); + let rhs = rhs.broadcast(target).unwrap(); + + Zip::from(lhs).and(rhs).map_collect(var_name).into_shared() + } else { + panic!( + "Incompatible shapes for broadcasting: {:?} and {:?}", + lhs.shape(), + rhs.shape() + ); + } + } + + pub(crate) fn elementwise_op_scalar( + lhs: SharedArray, + var_name: impl FnMut(E) -> E, + ) -> SharedArray { + lhs.mapv(var_name).into_shared() + } + + pub(crate) fn abs(tensor: SharedArray) -> SharedArray { + let tensor = dispatch_unary_simd!(E, VecAbs, tensor, i8, i16, i32, f32, f64); + + tensor.mapv_into(|a| a.abs_elem()).into_shared() + } + + pub(crate) fn equal(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_cmp_simd!( + E, VecEquals, lhs, rhs, u8, i8, u16, i16, u32, f32, i32, u64, i64, f64 + ); + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs == rhs) + .into_shared() + } + + pub(crate) fn equal_elem(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_cmp_scalar_simd!( + E, + VecEquals, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + lhs.mapv(|a| a == rhs).into_shared() + } + + pub(crate) fn sign_op(tensor: SharedArray) -> SharedArray + where + E: Signed, + { + let zero = 0.elem(); + let one = 1.elem::(); + + tensor + .mapv(|x| { + if x == zero { + zero + } else { + match x.is_positive() { + true => one, + false => -one, + } + } + }) + .into_shared() + } +} + +impl NdArrayMathOps +where + E: Copy + NdArrayElement + PartialOrd, +{ + /// Max of all elements - zero-copy for borrowed storage. + pub fn max_view(view: ArrayView<'_, E, IxDyn>) -> SharedArray { + let max = view + .iter() + .copied() + .reduce(|a, b| if a > b { a } else { b }) + .expect("Cannot compute max of empty tensor"); + ArrayD::from_elem(IxDyn(&[1]), max).into_shared() + } + + /// Min of all elements - zero-copy for borrowed storage. + pub fn min_view(view: ArrayView<'_, E, IxDyn>) -> SharedArray { + let min = view + .iter() + .copied() + .reduce(|a, b| if a < b { a } else { b }) + .expect("Cannot compute min of empty tensor"); + ArrayD::from_elem(IxDyn(&[1]), min).into_shared() + } + + /// Argmax along dimension - zero-copy for borrowed storage. + pub fn argmax_view( + view: ArrayView<'_, E, IxDyn>, + dim: usize, + ) -> SharedArray { + arg_view(view, dim, CmpType::Max) + } + + /// Argmin along dimension - zero-copy for borrowed storage. + pub fn argmin_view( + view: ArrayView<'_, E, IxDyn>, + dim: usize, + ) -> SharedArray { + arg_view(view, dim, CmpType::Min) + } + + pub fn cummin(tensor: SharedArray, dim: usize) -> SharedArray { + cummin_dim(tensor, dim) + } + + pub fn cummax(tensor: SharedArray, dim: usize) -> SharedArray { + cummax_dim(tensor, dim) + } + + pub fn argmax( + tensor: SharedArray, + dim: usize, + ) -> SharedArray { + arg(tensor, dim, CmpType::Max) + } + + pub fn argmin( + tensor: SharedArray, + dim: usize, + ) -> SharedArray { + arg(tensor, dim, CmpType::Min) + } + + pub fn clamp_min(tensor: SharedArray, min: E) -> SharedArray { + let mut tensor = dispatch_binary_scalar_simd!( + E, + VecMax, + tensor, + min.elem(), + u8, + i8, + u16, + i16, + u32, + i32, + f32, + u64, + i64, + f64 + ); + + tensor.mapv_inplace(|x| match x < min { + true => min, + false => x, + }); + + tensor + } + + pub fn clamp_max(tensor: SharedArray, max: E) -> SharedArray { + let mut tensor = dispatch_binary_scalar_simd!( + E, + VecMin, + tensor, + max.elem(), + u8, + i8, + u16, + i16, + u32, + i32, + f32, + u64, + i64, + f64 + ); + + tensor.mapv_inplace(|x| match x > max { + true => max, + false => x, + }); + + tensor + } + + pub fn clamp(tensor: SharedArray, min: E, max: E) -> SharedArray { + let mut tensor = dispatch_binary_scalar_simd!( + E, + VecClamp, + tensor, + (min.elem(), max.elem()), + u8, + i8, + u16, + i16, + u32, + i32, + f32, + u64, + i64, + f64 + ); + + tensor.mapv_inplace(|x| match x < min { + true => min, + false => match x > max { + true => max, + false => x, + }, + }); + + tensor + } + + pub(crate) fn greater(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_cmp_simd!( + E, VecGreater, lhs, rhs, u8, i8, u16, i16, u32, f32, i32, u64, i64, f64 + ); + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs > rhs) + .into_shared() + } + + pub(crate) fn greater_elem(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_cmp_scalar_simd!( + E, + VecGreater, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + lhs.mapv(|a| a > rhs).into_shared() + } + + pub(crate) fn greater_equal(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_cmp_simd!( + E, + VecGreaterEq, + lhs, + rhs, + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs >= rhs) + .into_shared() + } + + pub(crate) fn greater_equal_elem(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_cmp_scalar_simd!( + E, + VecGreaterEq, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + lhs.mapv(|a| a >= rhs).into_shared() + } + + pub(crate) fn lower_equal(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_cmp_simd!( + E, VecLowerEq, lhs, rhs, u8, i8, u16, i16, u32, f32, i32, u64, i64, f64 + ); + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs <= rhs) + .into_shared() + } + + pub(crate) fn lower_equal_elem(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_cmp_scalar_simd!( + E, + VecLowerEq, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + lhs.mapv(|a| a <= rhs).into_shared() + } + + pub(crate) fn lower(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = dispatch_cmp_simd!( + E, VecLower, lhs, rhs, u8, i8, u16, i16, u32, f32, i32, u64, i64, f64 + ); + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs < rhs) + .into_shared() + } + + pub(crate) fn lower_elem(lhs: SharedArray, rhs: E) -> SharedArray { + let lhs = dispatch_cmp_scalar_simd!( + E, + VecLower, + lhs, + rhs.elem(), + u8, + i8, + u16, + i16, + u32, + f32, + i32, + u64, + i64, + f64 + ); + + lhs.mapv(|a| a < rhs).into_shared() + } +} + +pub struct NdArrayBitOps(PhantomData); + +impl NdArrayBitOps { + pub(crate) fn bitand(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = + dispatch_binary_simd!(I, VecBitAnd, lhs, rhs, i8, u8, i16, u16, i32, u32, i64, u64); + + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &I, b: &I| { + (a.elem::() & (b.elem::())).elem() + }) + } + + pub(crate) fn bitand_scalar(lhs: SharedArray, rhs: I) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + I, + VecBitAnd, + lhs, + rhs.elem(), + i8, + u8, + i16, + u16, + i32, + u32, + i64, + u64 + ); + + NdArrayMathOps::elementwise_op_scalar(lhs, |a: I| { + (a.elem::() & rhs.elem::()).elem() + }) + } + + pub(crate) fn bitor(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = + dispatch_binary_simd!(I, VecBitOr, lhs, rhs, i8, u8, i16, u16, i32, u32, i64, u64); + + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &I, b: &I| { + (a.elem::() | (b.elem::())).elem() + }) + } + + pub(crate) fn bitor_scalar(lhs: SharedArray, rhs: I) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + I, + VecBitOr, + lhs, + rhs.elem(), + i8, + u8, + i16, + u16, + i32, + u32, + i64, + u64 + ); + + NdArrayMathOps::elementwise_op_scalar(lhs, |a: I| { + (a.elem::() | rhs.elem::()).elem() + }) + } + + pub(crate) fn bitxor(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + let (lhs, rhs) = + dispatch_binary_simd!(I, VecBitXor, lhs, rhs, i8, u8, i16, u16, i32, u32, i64, u64); + + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &I, b: &I| { + (a.elem::() ^ (b.elem::())).elem() + }) + } + + pub(crate) fn bitxor_scalar(lhs: SharedArray, rhs: I) -> SharedArray { + let lhs = dispatch_binary_scalar_simd!( + I, + VecBitXor, + lhs, + rhs.elem(), + i8, + u8, + i16, + u16, + i32, + u32, + i64, + u64 + ); + + NdArrayMathOps::elementwise_op_scalar(lhs, |a: I| { + (a.elem::() ^ rhs.elem::()).elem() + }) + } + + pub(crate) fn bitnot(tensor: SharedArray) -> SharedArray { + let tensor = + dispatch_unary_simd!(I, VecBitNot, tensor, i8, u8, i16, u16, i32, u32, i64, u64); + + NdArrayMathOps::elementwise_op_scalar(tensor, |a: I| (!a.elem::()).elem()) + } +} + +pub struct NdArrayBoolOps; + +// Rust booleans are either `00000000` or `00000001`, so bitwise and/or is fine, but bitwise not would +// produce invalid values. +impl NdArrayBoolOps { + pub(crate) fn equal(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + #[cfg(feature = "simd")] + let (lhs, rhs) = match try_cmp_simd::(lhs, rhs) { + Ok(out) => return out, + Err(args) => args, + }; + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs == rhs) + .into_shared() + } + + pub(crate) fn equal_elem(lhs: SharedArray, rhs: bool) -> SharedArray { + #[cfg(feature = "simd")] + let lhs = match try_cmp_scalar_simd::(lhs, rhs.elem()) { + Ok(out) => return out, + Err(args) => args, + }; + + lhs.mapv(|a| a == rhs).into_shared() + } + + pub(crate) fn and(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + #[cfg(feature = "simd")] + let (lhs, rhs) = match try_binary_simd::(lhs, rhs) { + Ok(out) => return out, + Err(args) => args, + }; + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs && rhs) + .into_shared() + } + + pub(crate) fn or(lhs: SharedArray, rhs: SharedArray) -> SharedArray { + #[cfg(feature = "simd")] + let (lhs, rhs) = match try_binary_simd::(lhs, rhs) { + Ok(out) => return out, + Err(args) => args, + }; + + // Use the helper to broadcast both arrays to a common shape + let (lhs_broadcast, rhs_broadcast) = broadcast_for_binary_ops(&lhs, &rhs); + // Now we can safely zip and compare + Zip::from(&lhs_broadcast) + .and(&rhs_broadcast) + .map_collect(|&lhs, &rhs| lhs || rhs) + .into_shared() + } + + /// Any element is true - zero-copy for borrowed storage. + pub fn any_view(view: ArrayView<'_, bool, IxDyn>) -> bool { + view.iter().any(|&x| x) + } + + /// All elements are true - zero-copy for borrowed storage. + pub fn all_view(view: ArrayView<'_, bool, IxDyn>) -> bool { + view.iter().all(|&x| x) + } +} + +enum CmpType { + Min, + Max, +} + +fn arg( + tensor: SharedArray, + dim: usize, + cmp: CmpType, +) -> SharedArray { + arg_view(tensor.view(), dim, cmp) +} + +/// View-based argmax/argmin - zero-copy for borrowed storage. +fn arg_view( + view: ArrayView<'_, E, IxDyn>, + dim: usize, + cmp: CmpType, +) -> SharedArray { + let mut reshape = view.shape().to_vec(); + reshape[dim] = 1; + + let output = view.map_axis(Axis(dim), |arr| { + // Find the min/max value in the array, and return its index. + let (_e, idx) = arr.indexed_iter().fold((arr[0], 0usize), |acc, (idx, e)| { + let cmp = match cmp { + CmpType::Min => e < &acc.0, + CmpType::Max => e > &acc.0, + }; + + if cmp { (*e, idx) } else { acc } + }); + + (idx as i64).elem() + }); + + let output = output.to_shape(Dim(reshape.as_slice())).unwrap(); + + output.into_shared() +} + +#[cfg(test)] +mod tests { + use burn_backend::TensorData; + + use crate::NdArrayTensor; + + use super::*; + + #[test] + fn should_generate_row_major_layout_for_cat() { + let expected_shape: &[usize] = &[4, 6, 2]; + let expected_strides: &[isize] = &[12, 2, 1]; + let NdArrayTensor::I32(expected_storage) = NdArrayTensor::from_data(TensorData::from([ + [[1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0]], + [[7, 0], [8, 0], [9, 0], [10, 0], [11, 0], [12, 0]], + [[13, 0], [14, 0], [15, 0], [16, 0], [17, 0], [18, 0]], + [[19, 0], [20, 0], [21, 0], [22, 0], [23, 0], [24, 0]], + ])) else { + panic!() + }; + let expected_array = expected_storage.into_shared(); + + let NdArrayTensor::I32(tensor_storage) = NdArrayTensor::from_data(TensorData::from([ + [1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18], + [19, 20, 21, 22, 23, 24], + ])) else { + panic!() + }; + let tensor = tensor_storage.into_shared(); + + // unsqueeze dim on the outermost axis + let array = NdArrayOps::reshape(tensor, Shape::from([4, 6, 1])); + let NdArrayTensor::I32(zeros_storage) = + NdArrayTensor::from_data(TensorData::zeros::([4, 6, 1])) + else { + panic!() + }; + let zeros = zeros_storage.into_shared(); + // make `ndarray` concatenates array on the outermost axis + let array = NdArrayOps::cat([array, zeros].to_vec(), 2); + + assert!(array.is_standard_layout()); + assert_eq!(array.shape(), expected_shape); + assert_eq!(array.strides(), expected_strides); + assert_eq!( + array.into_iter().collect::>(), + expected_array.into_iter().collect::>(), + ); + } +} diff --git a/crates/burn-ndarray/src/ops/bool_tensor.rs b/crates/burn-ndarray/src/ops/bool_tensor.rs new file mode 100644 index 0000000..67913a4 --- /dev/null +++ b/crates/burn-ndarray/src/ops/bool_tensor.rs @@ -0,0 +1,219 @@ +// Language +use alloc::vec; +use alloc::vec::Vec; +use burn_backend::Scalar; +use burn_backend::{ElementConversion, TensorMetadata, tensor::FloatTensor}; +use burn_backend::{ + backend::ExecutionError, + ops::BoolTensorOps, + tensor::{BoolTensor, IntTensor}, +}; +use burn_std::{BoolDType, FloatDType, IntDType}; +use ndarray::IntoDimension; + +// Current crate +use crate::{NdArray, execute_with_int_dtype, tensor::NdArrayTensor}; +use crate::{ + NdArrayDevice, SharedArray, execute_with_float_out_dtype, execute_with_int_out_dtype, slice, +}; + +// Workspace crates +use burn_backend::{Shape, TensorData}; + +use super::{NdArrayBoolOps, NdArrayOps}; + +impl BoolTensorOps for NdArray { + fn bool_from_data(data: TensorData, _device: &NdArrayDevice) -> NdArrayTensor { + if !data.dtype.is_bool() { + unimplemented!("Unsupported dtype for `bool_from_data`") + } + NdArrayTensor::from_data(data) + } + + async fn bool_into_data(tensor: NdArrayTensor) -> Result { + Ok(tensor.into_data()) + } + + fn bool_to_device(tensor: NdArrayTensor, _device: &NdArrayDevice) -> NdArrayTensor { + tensor + } + + fn bool_reshape(tensor: NdArrayTensor, shape: Shape) -> NdArrayTensor { + NdArrayOps::reshape(tensor.bool(), shape).into() + } + + fn bool_slice(tensor: NdArrayTensor, slices: &[burn_backend::Slice]) -> NdArrayTensor { + slice!(tensor, slices) + } + + fn bool_into_int(tensor: NdArrayTensor, out_dtype: IntDType) -> NdArrayTensor { + // Use mapv directly instead of collecting to Vec and going through TensorData + execute_with_int_out_dtype!( + out_dtype, + I, + tensor.bool().mapv(|b| b.elem::()).into_shared().into() + ) + } + + fn bool_empty(shape: Shape, _device: &NdArrayDevice, dtype: BoolDType) -> NdArrayTensor { + Self::bool_zeros(shape, _device, dtype) + } + + fn bool_zeros(shape: Shape, _device: &NdArrayDevice, _dtype: BoolDType) -> NdArrayTensor { + let values = vec![false; shape.num_elements()]; + NdArrayTensor::from_data(TensorData::new(values, shape)) + } + + fn bool_ones(shape: Shape, _device: &NdArrayDevice, _dtype: BoolDType) -> NdArrayTensor { + let values = vec![true; shape.num_elements()]; + NdArrayTensor::from_data(TensorData::new(values, shape)) + } + + fn bool_slice_assign( + tensor: NdArrayTensor, + slices: &[burn_backend::Slice], + value: NdArrayTensor, + ) -> NdArrayTensor { + NdArrayOps::slice_assign(tensor.bool(), slices, value.bool()).into() + } + + fn bool_cat(tensors: Vec, dim: usize) -> NdArrayTensor { + NdArrayOps::cat(tensors.into_iter().map(|it| it.bool()).collect(), dim).into() + } + + fn bool_equal(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + NdArrayBoolOps::equal(lhs.bool(), rhs.bool()).into() + } + + fn bool_not(tensor: NdArrayTensor) -> NdArrayTensor { + tensor.bool().mapv(|a| !a).into_shared().into() + } + + fn bool_and(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + NdArrayBoolOps::and(lhs.bool(), rhs.bool()).into() + } + + fn bool_or(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + NdArrayBoolOps::or(lhs.bool(), rhs.bool()).into() + } + + fn bool_into_float(tensor: NdArrayTensor, out_dtype: FloatDType) -> FloatTensor { + execute_with_float_out_dtype!( + out_dtype, + E, + tensor.bool().mapv(|b| b.elem::()).into_shared().into() + ) + } + + fn bool_swap_dims(tensor: NdArrayTensor, dim1: usize, dim2: usize) -> NdArrayTensor { + NdArrayOps::swap_dims(tensor.bool(), dim1, dim2).into() + } + + fn bool_permute(tensor: NdArrayTensor, axes: &[usize]) -> NdArrayTensor { + tensor.bool().permuted_axes(axes.into_dimension()).into() + } + + fn bool_expand(tensor: NdArrayTensor, shape: Shape) -> NdArrayTensor { + NdArrayOps::expand(tensor.bool(), shape).into() + } + + fn bool_select(tensor: NdArrayTensor, dim: usize, indices: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!(indices, I, |indices: SharedArray| -> NdArrayTensor { + let tensor_bool = tensor.bool(); + let indices_vec: Vec = indices + .into_iter() + .map(|i| i.elem::() as usize) + .collect(); + + let selected = tensor_bool.select(ndarray::Axis(dim), &indices_vec); + selected.into_shared().into() + }) + } + + fn bool_select_or( + tensor: NdArrayTensor, + dim: usize, + indices: NdArrayTensor, + value: NdArrayTensor, + ) -> NdArrayTensor { + execute_with_int_dtype!(indices, I, |indices: SharedArray| -> NdArrayTensor { + let mut output_array = tensor.bool().into_owned(); + let value_bool = value.bool(); + + for (index_value, index) in indices.into_iter().enumerate() { + let index_usize = index.elem::() as usize; + let mut view = output_array.index_axis_mut(ndarray::Axis(dim), index_usize); + let value_slice = value_bool.index_axis(ndarray::Axis(dim), index_value); + // For boolean tensors, select_assign should use logical OR operation + view.zip_mut_with(&value_slice, |a, b| *a = *a || *b); + } + output_array.into_shared().into() + }) + } + + fn bool_flip(tensor: NdArrayTensor, axes: &[usize]) -> NdArrayTensor { + NdArrayOps::flip(tensor.bool(), axes).into() + } + + fn bool_unfold(tensor: NdArrayTensor, dim: usize, size: usize, step: usize) -> NdArrayTensor { + NdArrayOps::unfold(tensor.bool(), dim, size, step).into() + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + NdArrayOps::mask_where(tensor.bool(), mask.bool(), value.bool()).into() + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + NdArrayOps::mask_fill(tensor.bool(), mask.bool(), value.elem()).into() + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + execute_with_int_dtype!(indices, |indices| NdArrayOps::gather( + dim, + tensor.bool(), + indices + )) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + execute_with_int_dtype!(indices, |indices| NdArrayOps::scatter( + dim, + tensor.bool(), + indices, + value.bool() + )) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + NdArrayBoolOps::equal_elem(lhs.bool(), rhs.elem()).into() + } + + fn bool_any(tensor: BoolTensor) -> BoolTensor { + // Use view() for zero-copy on borrowed storage with short-circuit evaluation + let result = NdArrayBoolOps::any_view(tensor.bool().view()); + NdArrayTensor::from_data(TensorData::new(vec![result], Shape::new([1]))) + } + + fn bool_all(tensor: BoolTensor) -> BoolTensor { + // Use view() for zero-copy on borrowed storage with short-circuit evaluation + let result = NdArrayBoolOps::all_view(tensor.bool().view()); + NdArrayTensor::from_data(TensorData::new(vec![result], Shape::new([1]))) + } +} diff --git a/crates/burn-ndarray/src/ops/conv.rs b/crates/burn-ndarray/src/ops/conv.rs new file mode 100644 index 0000000..5fb2cad --- /dev/null +++ b/crates/burn-ndarray/src/ops/conv.rs @@ -0,0 +1,574 @@ +use burn_backend::{ + ElementConversion, + ops::{ + ConvOptions, ConvTransposeOptions, + conv::{calculate_conv_output_size, calculate_conv_transpose_output_size}, + }, +}; +use ndarray::{ + Array3, Array4, Array5, ArrayView2, ArrayView3, ArrayViewMut2, ArrayViewMut3, Axis, Dim, s, +}; + +use crate::{ + NdArrayElement, SharedArray, iter_par, iter_range_par, + ops::padding::{apply_padding_4d, apply_padding_5d}, + run_par, + sharing::UnsafeSharedRef, + tensor::NdArrayTensor, +}; + +#[inline(always)] +fn conv2d_mad_inner( + mut output: ArrayViewMut2, + x: ArrayView2, + k: E, + k_xy: (usize, usize), + out_xy: (usize, usize), + stride: (usize, usize), + dilation: (usize, usize), +) { + let (kh, kw) = k_xy; + let (out_width, out_height) = out_xy; + let (stride_width, stride_height) = stride; + let (dilation_width, dilation_height) = dilation; + + for oh in 0..out_height { + // Construct a sub-slice view of the input row. + // This is done upfront so that rustc does not have to emit bounds checks + // in the hot loop below. + let ir = x + .row(oh * stride_height + kh * dilation_height) + .to_slice() + .unwrap(); + + // Ditto. Construct a sub-slice view of the output row, and explicitly specify + // the bounds upfront as 0..out_width so that rustc can make the assumption + // that all accesses are in-bounds in the below loop. + let mut or = output.row_mut(oh); + let or = &mut or.as_slice_mut().unwrap()[0..out_width]; + + #[allow(clippy::needless_range_loop)] + for ow in 0..out_width { + let iw = ow * stride_width + kw * dilation_width; + or[ow] += ir[iw] * k; + } + } +} + +#[inline(always)] +fn conv3d_mad_inner( + mut output: ArrayViewMut3, + x: ArrayView3, + k: E, + k_xyz: (usize, usize, usize), + out_xyz: (usize, usize, usize), + stride: (usize, usize, usize), + dilation: (usize, usize, usize), +) { + let (kd, kh, kw) = k_xyz; + let (out_width, out_height, out_depth) = out_xyz; + let (stride_width, stride_height, stride_depth) = stride; + let (dilation_width, dilation_height, dilation_depth) = dilation; + + for od in 0..out_depth { + let id = od * stride_depth + kd * dilation_depth; + + for oh in 0..out_height { + let ih = oh * stride_height + kh * dilation_height; + + // Construct a sub-slice view of the input row. + // This is done upfront so that rustc does not have to emit bounds checks + // in the hot loop below. + let ir = x.slice(s![id, ih, ..]).to_slice().unwrap(); + + // Ditto. Construct a sub-slice view of the output row, and explicitly specify + // the bounds upfront as 0..out_width so that rustc can make the assumption + // that all accesses are in-bounds in the below loop. + let or = &mut output + .slice_mut(s![od, oh, 0..out_width]) + .into_slice() + .unwrap()[0..out_width]; + + #[allow(clippy::needless_range_loop)] + for ow in 0..out_width { + let iw = ow * stride_width + kw * dilation_width; + or[ow] += ir[iw] * k; + } + } + } +} + +pub(crate) fn conv2d( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvOptions<2>, +) -> SharedArray +where + NdArrayTensor: From>, +{ + let [dilation_height, dilation_width] = options.dilation; + let [padding_height, padding_width] = options.padding; + let [stride_height, stride_width] = options.stride; + let [batch_size, _in_channels, in_height, in_width] = x.shape().try_into().unwrap(); + let [out_channels, in_channels, kernel_height, kernel_width] = + weight.shape().try_into().unwrap(); + let channels_per_group = out_channels / options.groups; + + let out_height = calculate_conv_output_size( + kernel_height, + stride_height, + padding_height, + dilation_height, + in_height, + ); + let out_width = calculate_conv_output_size( + kernel_width, + stride_width, + padding_width, + dilation_width, + in_width, + ); + + let x = apply_padding_4d::(x, options.padding, 0i32.elem()); + + // Convert inputs from dynamic indexes to static to improve perf. + let x = x.into_dimensionality::().unwrap(); + let weights = weight.into_dimensionality::().unwrap(); + + let mut output = Array3::zeros(Dim([batch_size * out_channels, out_height, out_width])); + + run_par!(|| { + iter_par!(output.axis_iter_mut(Axis(0))) + .enumerate() + .for_each( + #[inline(never)] + |(k, mut output)| { + let b = k / out_channels; + let oc = k % out_channels; + let g = oc / channels_per_group; + + for ic in (in_channels * g)..(in_channels * (g + 1)) { + let weight_ic = ic - (g * in_channels); + + let x = x.slice(s![b, ic, .., ..]); + let k = weights.slice(s![oc, weight_ic, .., ..]); + + for kh in 0..kernel_height { + for kw in 0..kernel_width { + let k = k[[kh, kw]]; + + // NOTE: This function call is duplicated twice so that the compiler can perform auto-vectorization + // in the case that the stride/dilation is 1. + #[allow(clippy::if_same_then_else)] + if (1, 1, 1, 1) + == ( + stride_width, + stride_height, + dilation_width, + dilation_height, + ) + { + conv2d_mad_inner( + output.view_mut(), + x.view(), + k, + (kh, kw), + (out_width, out_height), + (stride_width, stride_height), + (dilation_width, dilation_height), + ); + } else { + conv2d_mad_inner( + output.view_mut(), + x.view(), + k, + (kh, kw), + (out_width, out_height), + (stride_width, stride_height), + (dilation_width, dilation_height), + ); + } + } + } + } + + if let Some(bias) = &bias { + let bias = bias[oc]; + + for oh in 0..out_height { + // Get a mutable slice reference to the row we're looping over. + // We explicitly define the bounds to 0..out_width so that rustc can make + // the assumption that all accesses are in-bounds. + let mut or = output.row_mut(oh); + let or = &mut or.as_slice_mut().unwrap()[0..out_width]; + + #[allow(clippy::needless_range_loop)] + for ow in 0..out_width { + or[ow] += bias; + } + } + } + }, + ); + }); + + output + .to_shape([batch_size, out_channels, out_height, out_width]) + .unwrap() + .into_dyn() + .into_shared() +} + +pub(crate) fn conv_transpose2d( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvTransposeOptions<2>, +) -> SharedArray { + let [dilation_height, dilation_width] = options.dilation; + let [padding_height, padding_width] = options.padding; + let [stride_height, stride_width] = options.stride; + let [out_padding_height, out_padding_width] = options.padding_out; + let [batch_size, _in_channels, in_height, in_width] = x.shape().try_into().unwrap(); + let [in_channels, out_channels, kernel_height, kernel_width] = + weight.shape().try_into().unwrap(); + + let out_height = calculate_conv_transpose_output_size( + kernel_height, + stride_height, + padding_height, + out_padding_height, + dilation_height, + in_height, + ); + let out_width = calculate_conv_transpose_output_size( + kernel_width, + stride_width, + padding_width, + out_padding_width, + dilation_width, + in_width, + ); + + let x = x; + let mut output = Array4::zeros(Dim([ + batch_size, + out_channels * options.groups, + out_height, + out_width, + ])); + + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * out_channels * options.groups).for_each(|k| unsafe { + let b = k / (out_channels * options.groups); + let oc = k % out_channels; + let g = (k / out_channels) % options.groups; + + let output = unsafe_shared_out.get(); + + let oc_out = oc + (out_channels * g); + let ic_start = g * (in_channels / options.groups); + let ic_end = ic_start + in_channels / options.groups; + + for ic in ic_start..ic_end { + for ih in 0..in_height { + for iw in 0..in_width { + for kh in 0..kernel_height { + for kw in 0..kernel_width { + let oh = ih * stride_height + kh * dilation_height; + let ow = iw * stride_width + kw * dilation_width; + + if oh >= out_height + padding_height + || ow >= out_width + padding_width + || oh < padding_height + || ow < padding_width + { + continue; + } + + let oh = oh - padding_height; + let ow = ow - padding_width; + + output[[b, oc_out, oh, ow]] += + x[[b, ic, ih, iw]] * weight[[ic, oc, kh, kw]]; + } + } + } + } + } + + if let Some(bias) = &bias { + for oh in 0..out_height { + for ow in 0..out_width { + output[[b, oc_out, oh, ow]] += bias[oc_out]; + } + } + } + }); + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn conv3d( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvOptions<3>, +) -> SharedArray +where + NdArrayTensor: From>, +{ + let [dilation_depth, dilation_height, dilation_width] = options.dilation; + let [padding_depth, padding_height, padding_width] = options.padding; + let [stride_depth, stride_height, stride_width] = options.stride; + let [batch_size, _in_channels, in_depth, in_height, in_width] = x.shape().try_into().unwrap(); + let [ + out_channels, + in_channels, + kernel_depth, + kernel_height, + kernel_width, + ] = weight.shape().try_into().unwrap(); + let out_c_per_group = out_channels / options.groups; + + let out_depth = calculate_conv_output_size( + kernel_depth, + stride_depth, + padding_depth, + dilation_depth, + in_depth, + ); + let out_height = calculate_conv_output_size( + kernel_height, + stride_height, + padding_height, + dilation_height, + in_height, + ); + let out_width = calculate_conv_output_size( + kernel_width, + stride_width, + padding_width, + dilation_width, + in_width, + ); + + let x = apply_padding_5d::(x, options.padding, 0i32.elem()); + + // Convert inputs from dynamic indexes to static to improve perf. + let x = x.into_dimensionality::().unwrap(); + let weights = weight.into_dimensionality::().unwrap(); + + let mut output = Array4::zeros(Dim([ + batch_size * out_channels, + out_depth, + out_height, + out_width, + ])); + + run_par!(|| { + iter_par!(output.axis_iter_mut(Axis(0))) + .enumerate() + .for_each( + #[inline(never)] + |(k, mut output)| { + let b = k / out_channels; + let oc = k % out_channels; + let g = oc / out_c_per_group; + + for ic in (in_channels * g)..(in_channels * (g + 1)) { + let weight_ic = ic - (g * in_channels); + + let x = x.slice(s![b, ic, .., .., ..]); + let k = weights.slice(s![oc, weight_ic, .., .., ..]); + + for kd in 0..kernel_depth { + for kh in 0..kernel_height { + for kw in 0..kernel_width { + let k = k[[kd, kh, kw]]; + + // NOTE: This function call is duplicated twice so that the compiler can perform auto-vectorization + // in the case that the stride/dilation is 1. + #[allow(clippy::if_same_then_else)] + if (1, 1, 1, 1, 1, 1) + == ( + stride_width, + stride_height, + stride_depth, + dilation_width, + dilation_height, + dilation_depth, + ) + { + conv3d_mad_inner( + output.view_mut(), + x.view(), + k, + (kd, kh, kw), + (out_width, out_height, out_depth), + (stride_width, stride_height, stride_depth), + (dilation_width, dilation_height, dilation_depth), + ); + } else { + conv3d_mad_inner( + output.view_mut(), + x.view(), + k, + (kd, kh, kw), + (out_width, out_height, out_depth), + (stride_width, stride_height, stride_depth), + (dilation_width, dilation_height, dilation_depth), + ); + } + } + } + } + } + + if let Some(bias) = &bias { + let bias = bias[oc]; + + // Get a mutable iterator to the row we're looping over. + let orows = output.rows_mut(); + for mut or in orows { + // We explicitly define the bounds to 0..out_width so that rustc can make + // the assumption that all accesses are in-bounds. + let or = &mut or.as_slice_mut().unwrap()[0..out_width]; + + #[allow(clippy::needless_range_loop)] + for ow in 0..out_width { + or[ow] += bias; + } + } + } + }, + ); + }); + + output + .to_shape([batch_size, out_channels, out_depth, out_height, out_width]) + .unwrap() + .into_dyn() + .into_shared() +} + +pub(crate) fn conv_transpose3d( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvTransposeOptions<3>, +) -> SharedArray { + let [dilation_depth, dilation_height, dilation_width] = options.dilation; + let [padding_depth, padding_height, padding_width] = options.padding; + let [stride_depth, stride_height, stride_width] = options.stride; + let [out_padding_depth, out_padding_height, out_padding_width] = options.padding_out; + let [batch_size, _in_channels, in_depth, in_height, in_width] = x.shape().try_into().unwrap(); + let [ + in_channels, + out_channels, + kernel_depth, + kernel_height, + kernel_width, + ] = weight.shape().try_into().unwrap(); + + let out_depth = calculate_conv_transpose_output_size( + kernel_depth, + stride_depth, + padding_depth, + out_padding_depth, + dilation_depth, + in_depth, + ); + let out_height = calculate_conv_transpose_output_size( + kernel_height, + stride_height, + padding_height, + out_padding_height, + dilation_height, + in_height, + ); + let out_width = calculate_conv_transpose_output_size( + kernel_width, + stride_width, + padding_width, + out_padding_width, + dilation_width, + in_width, + ); + + let x = x; + let mut output = Array5::zeros(Dim([ + batch_size, + out_channels * options.groups, + out_depth, + out_height, + out_width, + ])); + + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * out_channels * options.groups).for_each(|k| unsafe { + let b = k / (out_channels * options.groups); + let oc = k % out_channels; + let g = (k / out_channels) % options.groups; + + let output = unsafe_shared_out.get(); + + let oc_out = oc + (out_channels * g); + let ic_start = g * (in_channels / options.groups); + let ic_end = ic_start + in_channels / options.groups; + + for ic in ic_start..ic_end { + for id in 0..in_depth { + for ih in 0..in_height { + for iw in 0..in_width { + for kd in 0..kernel_depth { + for kh in 0..kernel_height { + for kw in 0..kernel_width { + let od = id * stride_depth + kd * dilation_depth; + let oh = ih * stride_height + kh * dilation_height; + let ow = iw * stride_width + kw * dilation_width; + + if od >= out_depth + padding_depth + || oh >= out_height + padding_height + || ow >= out_width + padding_width + || od < padding_depth + || oh < padding_height + || ow < padding_width + { + continue; + } + + let od = od - padding_depth; + let oh = oh - padding_height; + let ow = ow - padding_width; + + output[[b, oc_out, od, oh, ow]] += + x[[b, ic, id, ih, iw]] * weight[[ic, oc, kd, kh, kw]]; + } + } + } + } + } + } + } + + if let Some(bias) = &bias { + for od in 0..out_depth { + for oh in 0..out_height { + for ow in 0..out_width { + output[[b, oc_out, od, oh, ow]] += bias[oc_out]; + } + } + } + } + }); + }); + + output.into_dyn().into_shared() +} diff --git a/crates/burn-ndarray/src/ops/deform_conv.rs b/crates/burn-ndarray/src/ops/deform_conv.rs new file mode 100644 index 0000000..390010b --- /dev/null +++ b/crates/burn-ndarray/src/ops/deform_conv.rs @@ -0,0 +1,662 @@ +use burn_backend::ops::{DeformConvOptions, conv::calculate_conv_output_size}; +use core::ops::AddAssign; +use ndarray::{ + Array2, Array4, ArrayView2, ArrayView3, ArrayView4, ArrayView6, ArrayViewMut2, Axis, Dim, Ix4, + Zip, s, +}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use crate::{FloatNdArrayElement, NdArrayTensor, ShapeOps, SharedArray, iter_par, run_par}; + +use super::matmul::matmul; + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn deform_im2col_kernel( + out_y: usize, + out_x: usize, + input: ArrayView2, + offset: ArrayView3, + mask: Option>, + mut columns: ArrayViewMut2, + args: DeformConvOptions<2>, + (kernel_h, kernel_w): (usize, usize), +) { + // position shape: [in_channels, batch_size, out_h, out_w] + // columns shape: [[in_channels, kernel_h, kernel_w], [batch_size, out_h, out_w]] + + let (height, width) = input.dim(); + + for kernel_y in 0..kernel_h { + for kernel_x in 0..kernel_w { + let mask_value = mask + .map(|it| it[[kernel_y, kernel_x]]) + .unwrap_or_else(|| F::from_elem(1.0)); + + let offset = offset.slice(s![kernel_y, kernel_x, ..]); + let y = F::from_elem(out_y * args.stride[0] + kernel_y * args.dilation[0]) + - F::from_elem(args.padding[0]) + + offset[0]; + let x = F::from_elem(out_x * args.stride[1] + kernel_x * args.dilation[1]) + - F::from_elem(args.padding[1]) + + offset[1]; + + let interpolated = bilinear_interpolate(input, height, width, y, x); + + columns[[kernel_y, kernel_x]] = mask_value * interpolated; + } + } +} + +fn bilinear_interpolate( + input: ArrayView2, + height: usize, + width: usize, + y: F, + x: F, +) -> F { + // To simplify code + let y = y.to_f32(); + let x = x.to_f32(); + + let mut result = F::from_elem(0.0); + if y > -1.0 && height as f32 > y && x > -1.0 && width as f32 > x { + let y_low = f32::floor(y); + let x_low = f32::floor(x); + let y_high = (y_low + 1.) as usize; + let x_high = (x_low + 1.) as usize; + + let zero = F::from_elem(0.0); + let v1: F = if y_low >= 0. && x_low >= 0. { + input[[y_low as usize, x_low as usize]] + } else { + zero + }; + let v2: F = if y_low >= 0. && x_high < width { + input[[y_low as usize, x_high]] + } else { + zero + }; + let v3: F = if y_high < height && x_low >= 0. { + input[[y_high, x_low as usize]] + } else { + zero + }; + let v4: F = if y_high < height && x_high < width { + input[[y_high, x_high]] + } else { + zero + }; + + let l_y = y - y_low; + let l_x = x - x_low; + let h_y = 1.0 - l_y; + let h_x = 1.0 - l_x; + + let w1 = F::from_elem(h_y * h_x); + let w2 = F::from_elem(h_y * l_x); + let w3 = F::from_elem(l_y * h_x); + let w4 = F::from_elem(l_y * l_x); + + result = w1 * v1 + w2 * v2 + w3 * v3 + w4 * v4; + } + result +} + +pub(crate) fn deform_conv2d( + input: SharedArray, + offset: SharedArray, + weight: SharedArray, + mask: Option>, + bias: Option>, + args: DeformConvOptions<2>, +) -> SharedArray +where + NdArrayTensor: From>, +{ + let [batch_size, _, in_height, in_width] = input.shape().dims(); + let [out_channels, _, kernel_h, kernel_w] = weight.shape().dims(); + let groups = args.weight_groups; + + let weight = weight.as_standard_layout(); + + let out_h = calculate_conv_output_size( + kernel_h, + args.stride[0], + args.padding[0], + args.dilation[0], + in_height, + ); + let out_w = calculate_conv_output_size( + kernel_w, + args.stride[1], + args.padding[1], + args.dilation[1], + in_width, + ); + let out_dims = (out_h, out_w); + + let input = input.into_dimensionality::().unwrap(); + let offset = offset.into_dimensionality::().unwrap(); + let mask = mask.as_ref().map(|it| { + it.to_shape(( + batch_size, + args.offset_groups, + kernel_h, + kernel_w, + out_h, + out_w, + )) + .unwrap() + }); + + let columns = deform_im2col( + input.view(), + offset.view(), + mask.as_ref().map(|it| it.view()), + args, + out_dims, + (kernel_h, kernel_w), + ); + + let (col_size_0, col_size_1) = columns.dim(); + let col_size_0 = col_size_0 / groups; + let out_c_per_group = out_channels / groups; + + let weight = weight + .to_shape((groups, out_c_per_group, col_size_0)) + .unwrap(); + let columns = columns.to_shape((groups, col_size_0, col_size_1)).unwrap(); + let out = matmul( + weight.to_owned().into_dyn().into_shared(), + columns.to_owned().into_dyn().into_shared(), + ); + + let mut out = out + .into_shape_with_order((out_channels, batch_size, out_h, out_w)) + .unwrap(); + out.swap_axes(0, 1); + + if let Some(bias) = bias { + let bias = bias.to_shape((1, out_channels, 1, 1)).unwrap(); + out.add_assign(&bias); + } + + out.into_dyn().into_shared() +} + +pub(crate) fn deform_im2col( + input: ArrayView4, + offset: ArrayView4, + mask: Option>, + args: DeformConvOptions<2>, + out_dims: (usize, usize), + kernel_dims: (usize, usize), +) -> Array2 { + let (batch_size, in_channels, _, _) = input.dim(); + let (kernel_h, kernel_w) = kernel_dims; + let (out_h, out_w) = out_dims; + let channels_per_offset_group = in_channels / args.offset_groups; + + let mut columns = Array4::zeros(Dim([ + in_channels, + kernel_h, + kernel_w, + batch_size * out_h * out_w, + ])); + + let groups = args.offset_groups; + + run_par!(|| { + iter_par!(columns.axis_iter_mut(Axis(3))) + .enumerate() + .for_each(|(index, mut columns)| { + let out_x = index % out_w; + let out_y = (index / out_w) % out_h; + let batch = (index / (out_w * out_h)) % batch_size; + let offset = offset.slice(s![batch, .., out_y, out_x]); + let offset = offset.to_shape((groups, kernel_h, kernel_w, 2)).unwrap(); + let mask = mask + .as_ref() + .map(|it| it.slice(s![batch, .., .., .., out_y, out_x])); + columns + .axis_iter_mut(Axis(0)) + .enumerate() + .for_each(|(in_channel, mut columns)| { + let group_index = in_channel / channels_per_offset_group; + deform_im2col_kernel( + out_y, + out_x, + input.slice(s![batch, in_channel, .., ..]), + offset.slice(s![group_index, .., .., ..]), + mask.as_ref().map(|it| it.slice(s![group_index, .., ..])), + columns.view_mut(), + args.clone(), + kernel_dims, + ); + }); + }); + }); + + columns + // Columns is created here, so we know it's contiguous + .into_shape_with_order(( + in_channels * kernel_h * kernel_w, + batch_size * out_h * out_w, + )) + .unwrap() +} + +pub mod backward { + #[cfg(target_has_atomic = "32")] + use core::sync::atomic::Ordering; + + use atomic_float::AtomicF32; + use ndarray::{Array1, Array5, ArrayView4, ArrayView6, Ix4}; + + use super::*; + + pub(crate) type DeformConv2dBackward = ( + SharedArray, + SharedArray, + SharedArray, + Option>, + Option>, + ); + + /// Calculate the [deformable 2D convolution](crate::ops::ModuleOps::deform_conv2d) backward pass using convolutions. + pub(crate) fn deform_conv2d_backward( + input: SharedArray, + offset: SharedArray, + weight: SharedArray, + mask: Option>, + bias: Option>, + out_grad: SharedArray, + args: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + let [batch_size, out_channels, out_h, out_w] = out_grad.shape().dims(); + let [_, _, kernel_h, kernel_w] = weight.shape().dims(); + let groups = args.weight_groups; + let out_c_per_group = out_channels / groups; + let col_shape_1 = batch_size * out_h * out_w; + let mut out_grad = out_grad.into_dimensionality::().unwrap(); + + let gradient_bias = bias.map(|_| { + let out_grad = out_grad + .clone() + .sum_axis(Axis(0)) + .sum_axis(Axis(1)) + .sum_axis(Axis(1)); + + out_grad.into_dyn().into_shared() + }); + + out_grad.swap_axes(0, 1); + let out_grad = out_grad + .to_shape((groups, out_c_per_group, col_shape_1)) + .unwrap(); + + let input = input.into_dimensionality::().unwrap(); + let offset = offset.into_dimensionality::().unwrap(); + let mask = mask.map(|it| { + it.into_shape_with_order(( + batch_size, + args.offset_groups, + kernel_h, + kernel_w, + out_h, + out_w, + )) + .unwrap() + }); + + let (input_gradient, offset_gradient, mask_gradient) = backward_gradient_inputs( + input.view(), + weight, + offset.view(), + mask.as_ref().map(|it| it.view()), + out_grad.view(), + &args, + (kernel_h, kernel_w), + ); + + let weight_grad = compute_weight_grad( + input.view(), + offset.view(), + mask.as_ref().map(|it| it.view()), + out_grad.view(), + args, + (kernel_h, kernel_w), + (out_h, out_w), + ); + + ( + input_gradient, + offset_gradient, + weight_grad, + mask_gradient, + gradient_bias, + ) + } + + fn compute_weight_grad( + input: ArrayView4, + offset: ArrayView4, + mask: Option>, + out_grad: ArrayView3, + options: DeformConvOptions<2>, + kernel_dims: (usize, usize), + out_dims: (usize, usize), + ) -> SharedArray { + let in_channels = input.dim().1; + let (groups, out_c_per_group, _) = out_grad.dim(); + let (kernel_h, kernel_w) = kernel_dims; + + let in_c_per_group = in_channels / groups; + + let columns = deform_im2col(input, offset, mask, options, out_dims, kernel_dims); + let (col_size_0, col_size_1) = columns.dim(); + let col_size_0 = col_size_0 / groups; + + let mut columns = columns.to_shape((groups, col_size_0, col_size_1)).unwrap(); + columns.swap_axes(1, 2); + + let grad_weight = matmul( + out_grad.to_owned().into_dyn().into_shared(), + columns.to_owned().into_dyn().into_shared(), + ); + + let grad_weight = grad_weight + .into_shape_with_order((out_c_per_group * groups, in_c_per_group, kernel_h, kernel_w)) + .unwrap(); + grad_weight.into_dyn().into_shared() + } + + type InputGradients = (SharedArray, SharedArray, Option>); + + fn backward_gradient_inputs( + image: ArrayView4, + weight: SharedArray, + offset: ArrayView4, + mask: Option>, + out_grad: ArrayView3, + args: &DeformConvOptions<2>, + kernel_dims: (usize, usize), + ) -> InputGradients { + let input_shape = image.dim(); + let in_channels = input_shape.1; + let [out_channels, in_c_per_group, kernel_h, kernel_w] = weight.shape().dims(); + let (batch_size, _, out_h, out_w) = offset.dim(); + + let groups = args.weight_groups; + let out_c_per_group = out_channels / groups; + + let col_shape_0 = in_c_per_group * kernel_h * kernel_w; + + let mut weight = weight + .to_shape((groups, out_c_per_group, col_shape_0)) + .unwrap(); + weight.swap_axes(1, 2); + let columns = matmul( + weight.to_owned().into_dyn().into_shared(), + out_grad.to_owned().into_dyn().into_shared(), + ); + + let columns = columns + .to_shape((in_channels, kernel_h, kernel_w, batch_size, out_h, out_w)) + .unwrap(); + + let (offset_gradient, mask_gradient) = compute_offset_and_mask_gradient( + columns.view(), + image.view(), + offset, + mask, + args, + kernel_dims, + ); + + let input_gradient = + compute_input_grad(columns.view(), offset, mask, args, kernel_dims, input_shape); + + (input_gradient, offset_gradient, mask_gradient) + } + + fn compute_offset_and_mask_gradient( + columns: ArrayView6, + image: ArrayView4, + offset: ArrayView4, + mask: Option>, + args: &DeformConvOptions<2>, + kernel_dims: (usize, usize), + ) -> (SharedArray, Option>) { + let (kernel_h, kernel_w) = kernel_dims; + let (_, in_channels, height, width) = image.dim(); + let (batch_size, offset_channels, out_h, out_w) = offset.dim(); + let offs_groups = args.offset_groups; + let channels_per_offset_group = in_channels / args.offset_groups; + + let mut grad_offset = Array5::zeros(( + offs_groups, + kernel_h, + kernel_w, + 2, + batch_size * out_h * out_w, + )); + let mut grad_mask = + Array4::zeros((offs_groups, kernel_h, kernel_w, batch_size * out_h * out_w)); + + grad_mask + .axis_iter_mut(Axis(3)) + .zip(grad_offset.axis_iter_mut(Axis(4))) + .enumerate() + .for_each(|(index, (mut grad_mask, mut grad_offset))| { + let out_x = index % out_w; + let out_y = (index / out_w) % out_h; + let batch = index / (out_w * out_h); + let offset = offset.slice(s![batch, .., out_y, out_x]); + let offset = offset + .to_shape((offs_groups, kernel_h, kernel_w, 2)) + .unwrap(); + let mask: Option> = mask + .as_ref() + .map(|mask| mask.slice(s![batch, .., .., .., out_y, out_x])); + let columns = columns.slice(s![.., .., .., batch, out_y, out_x]); + let image = image.slice(s![batch, .., .., ..]); + + for ((group, kernel_y, kernel_x), grad_mask) in grad_mask.indexed_iter_mut() { + let grad_mask: &mut F = grad_mask; + let mut grad_offset = grad_offset.slice_mut(s![group, kernel_y, kernel_x, ..]); + let offset = offset.slice(s![group, kernel_y, kernel_x, ..]); + let mask = mask.map(|it| it[[group, kernel_y, kernel_x]]); + let columns = columns.slice(s![.., kernel_y, kernel_x]); + let group_offset = group * channels_per_offset_group; + let image = image.slice(s![group_offset.., .., ..]); + let y = F::from_elem(out_y * args.stride[0] + kernel_y * args.dilation[0]) + - F::from_elem(args.padding[0]) + + offset[0]; + let x = F::from_elem(out_x * args.stride[1] + kernel_x * args.dilation[1]) + - F::from_elem(args.padding[1]) + + offset[1]; + for (i, grad_offset) in grad_offset.iter_mut().enumerate() { + let is_y_direction = i % 2 == 0; + let use_mask = mask.is_some(); + + for channel in 0..channels_per_offset_group { + let mask = mask.unwrap_or_else(|| F::one()); + let image = image.index_axis(Axis(0), channel); + let weight = + get_coordinate_weight(image, height, width, y, x, is_y_direction); + *grad_offset += mask * weight * columns[channel]; + if use_mask && is_y_direction { + *grad_mask += columns[channel] + * bilinear_interpolate(image, height, width, y, x); + } + } + } + } + }); + + let mask_gradient = mask.map(|_| { + let mut grad_mask = grad_mask + .into_shape_with_order((offset_channels / 2, batch_size, out_h, out_w)) + .unwrap(); + grad_mask.swap_axes(0, 1); + grad_mask.into_dyn().into_shared() + }); + let mut grad_offset = grad_offset + .into_shape_with_order((offset_channels, batch_size, out_h, out_w)) + .unwrap(); + grad_offset.swap_axes(0, 1); + let offset_gradient = grad_offset.into_dyn().into_shared(); + (offset_gradient, mask_gradient) + } + + fn get_coordinate_weight( + input: ArrayView2, + height: usize, + width: usize, + y: F, + x: F, + is_y_direction: bool, + ) -> F { + let y = y.to_f32(); + let x = x.to_f32(); + + let y_low = f32::floor(y); + let x_low = f32::floor(x); + let y_high = y_low + 1.; + let x_high = x_low + 1.; + + let valid_y_low = y_low >= 0. && y_low < height as f32; + let valid_y_high = y_high >= 0. && y_high < height as f32; + let valid_x_low = x_low >= 0. && x_low < width as f32; + let valid_x_high = x_high >= 0. && x_high < width as f32; + + let bottom_left = if valid_y_low && valid_x_low { + input[[y_low as usize, x_low as usize]] + } else { + F::zero() + }; + let bottom_right = if valid_y_low && valid_x_high { + input[[y_low as usize, x_high as usize]] + } else { + F::zero() + }; + let top_left = if valid_y_high && valid_x_low { + input[[y_high as usize, x_low as usize]] + } else { + F::zero() + }; + let top_right = if valid_y_high && valid_x_high { + input[[y_high as usize, x_high as usize]] + } else { + F::zero() + }; + + if is_y_direction { + let delta_x = F::from_elem(x - x_low); + delta_x * (top_right - bottom_right) + (F::one() - delta_x) * (top_left - bottom_left) + } else { + let delta_y = F::from_elem(y - y_low); + delta_y * (top_right - top_left) + (F::one() - delta_y) * (bottom_right - bottom_left) + } + } + + fn compute_input_grad( + columns: ArrayView6, + offset: ArrayView4, + mask: Option>, + args: &DeformConvOptions<2>, + kernel_dims: (usize, usize), + input_shape: (usize, usize, usize, usize), + ) -> SharedArray { + let (batch_size, in_channels, height, width) = input_shape; + let (kernel_h, kernel_w) = kernel_dims; + let offs_groups = args.offset_groups; + let channels_per_offset_group = in_channels / offs_groups; + + let grad_in = + Array4::from_shape_simple_fn((batch_size, in_channels, height, width), || { + AtomicF32::new(0.0) + }); + + let compute_for_each = |(in_channel, kernel_y, kernel_x, batch, out_y, out_x), col: &F| { + let group = in_channel / channels_per_offset_group; + let offset = offset.slice(s![batch, .., out_y, out_x]); + let offset = offset + .to_shape((offs_groups, kernel_h, kernel_w, 2)) + .unwrap(); + let offset = offset.slice(s![group, kernel_y, kernel_x, ..]); + let offset = [offset[0], offset[1]]; + let mask = mask + .as_ref() + .map(|it| it[[batch, group, kernel_y, kernel_x, out_y, out_x]].to_f32()); + let y = F::from_elem(out_y * args.stride[0] + kernel_y * args.dilation[0]) + - F::from_elem(args.padding[0]) + + offset[0]; + let x = F::from_elem(out_x * args.stride[1] + kernel_x * args.dilation[1]) + - F::from_elem(args.padding[1]) + + offset[1]; + let grad_in = grad_in.slice(s![batch, in_channel, .., ..]); + deform_col2img_kernel(y.to_f32(), x.to_f32(), mask, col.to_f32(), grad_in); + }; + + // `for_each` expects a 2-tuple argument with `.into_par_iter()`, but 2 separate arguments otherwise + #[cfg(feature = "multi-threads")] + run_par!(|| { + iter_par!(Zip::indexed(columns)) + .for_each(|(args0, args1)| compute_for_each(args0, args1)) + }); + + #[cfg(not(feature = "multi-threads"))] + run_par!(|| { iter_par!(Zip::indexed(columns)).for_each(&compute_for_each) }); + + let grad_in: Array1 = grad_in + .into_iter() + .map(|it| F::from_elem(it.into_inner())) + .collect(); + let grad_in = grad_in + .into_shape_with_order((batch_size, in_channels, height, width)) + .unwrap(); + grad_in.into_dyn().into_shared() + } + + fn deform_col2img_kernel( + y: f32, + x: f32, + mask: Option, + col: f32, + grad_input: ArrayView2, + ) { + let (height, width) = grad_input.dim(); + let mask_value = mask.unwrap_or(1.0); + + for dy in -1..=1 { + for dx in -1..=1 { + let yp = f32::floor(y) + dy as f32; + let xp = f32::floor(x) + dx as f32; + + if yp >= 0.0 + && yp < height as f32 + && xp >= 0.0 + && xp < width as f32 + && f32::abs(y - yp) < 1.0 + && f32::abs(x - xp) < 1.0 + { + let weight = (1.0 - f32::abs(y - yp)) * (1.0 - f32::abs(x - xp)); + + #[cfg_attr(not(target_has_atomic = "32"), allow(unused))] + let value = mask_value * weight * col; + + #[cfg(target_has_atomic = "32")] + grad_input[[yp as usize, xp as usize]].fetch_add(value, Ordering::AcqRel); + #[cfg(not(target_has_atomic = "32"))] + panic!("Can't use deformable convolution backwards pass without atomics"); + } + } + } + } +} diff --git a/crates/burn-ndarray/src/ops/grid_sample.rs b/crates/burn-ndarray/src/ops/grid_sample.rs new file mode 100644 index 0000000..256c2fd --- /dev/null +++ b/crates/burn-ndarray/src/ops/grid_sample.rs @@ -0,0 +1,214 @@ +use burn_backend::ElementConversion; +use burn_backend::ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use ndarray::Array4; + +use crate::SharedArray; +use crate::{FloatNdArrayElement, UnsafeSharedRef, iter_range_par, run_par}; + +/// Sample a tensor using grid-based sampling. +/// +/// # Arguments +/// +/// * `tensor` - The tensor being sampled from, must be contiguous with shape (N, C, H_in, W_in) +/// * `grid` - A tensor of locations, with shape (N, H_out, W_out, 2). Values are [-1, 1]. +/// A [x = -1, y = -1] means top-left, and [x = 1, y = 1] means bottom-right +/// * `options` - Grid sampling options (mode, padding_mode, align_corners) +/// +/// # Returns +/// +/// A tensor with shape (N, C, H_out, W_out) +pub(crate) fn grid_sample_2d( + tensor: SharedArray, + grid: SharedArray, + options: GridSampleOptions, +) -> SharedArray { + match options.mode { + InterpolateMode::Bilinear => (), + _ => todo!( + "grid_sample_2d with {:?} mode is not implemented", + options.mode + ), + } + + let tensor = tensor.into_dimensionality::().unwrap(); + let grid = grid.into_dimensionality::().unwrap(); + + let (batch_size, channels, height_in, width_in) = tensor.dim(); + let (b, height_out, width_out, d) = grid.dim(); + assert!(batch_size == b); + assert!(2 == d); + + let mut output = Array4::zeros((batch_size, channels, height_out, width_out)); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + let sample_count = batch_size * channels * height_out * width_out; + let strides = ( + channels * height_out * width_out, + height_out * width_out, + width_out, + ); + + let align = options.align_corners; + let pad_mode = options.padding_mode; + + run_par!(|| { + iter_range_par!(0, sample_count).for_each(|id| { + let (b, c, y, x) = ( + id / strides.0, + id % strides.0 / strides.1, + id % strides.1 / strides.2, + id % strides.2, + ); + + let sample_x = grid[(b, y, x, 0)].elem::(); + let sample_y = grid[(b, y, x, 1)].elem::(); + + // Convert normalized grid coordinates [-1, 1] to pixel coordinates + let (px, py) = if align { + // align_corners=true: x_pixel = (x_norm + 1) * (width - 1) / 2 + // Maps -1 to 0 and 1 to width - 1 + let px = (sample_x + 1.0) * ((width_in - 1) as f64) / 2.0; + let py = (sample_y + 1.0) * ((height_in - 1) as f64) / 2.0; + (px, py) + } else { + // align_corners=false: x_pixel = (x_norm + 1) * width / 2 - 0.5 + // Maps -1 to -0.5 and 1 to width - 0.5 + let px = (sample_x + 1.0) * (width_in as f64) / 2.0 - 0.5; + let py = (sample_y + 1.0) * (height_in as f64) / 2.0 - 0.5; + (px, py) + }; + + // Bilinear interpolation with the specified padding mode + let val = + bilinear_interpolate(&tensor, b, c, px, py, width_in, height_in, pad_mode, align); + + unsafe { + let output = unsafe_shared_out.get(); + output[(b, c, y, x)] = val.elem(); + } + }); + }); + + output.into_dyn().into_shared() +} + +/// Bilinear interpolation at a point with configurable padding mode. +#[allow(clippy::too_many_arguments)] +fn bilinear_interpolate( + source: &ndarray::ArrayBase>, + b: usize, + c: usize, + x: f64, + y: f64, + width: usize, + height: usize, + padding_mode: GridSamplePaddingMode, + align_corners: bool, +) -> f64 +where + E: FloatNdArrayElement, + S: ndarray::Data, +{ + // Handle inf/nan coordinates + if !x.is_finite() || !y.is_finite() { + return match padding_mode { + GridSamplePaddingMode::Zeros => 0.0, + GridSamplePaddingMode::Border => { + // Clamp to center of image for inf/nan + let cx = ((width - 1) as f64 / 2.0).clamp(0.0, (width - 1) as f64); + let cy = ((height - 1) as f64 / 2.0).clamp(0.0, (height - 1) as f64); + source[(b, c, cy as usize, cx as usize)].elem::() + } + GridSamplePaddingMode::Reflection => 0.0, // Simplified: treat as zeros for inf/nan + }; + } + + // Apply padding mode to get actual sampling coordinates + let (x, y) = match padding_mode { + GridSamplePaddingMode::Border => { + // Clamp coordinates to valid range [0, size-1] + let x = x.clamp(0.0, (width - 1) as f64); + let y = y.clamp(0.0, (height - 1) as f64); + (x, y) + } + GridSamplePaddingMode::Reflection => { + // Reflect coordinates at boundaries + let x = reflect_coordinate(x, width, align_corners); + let y = reflect_coordinate(y, height, align_corners); + (x, y) + } + GridSamplePaddingMode::Zeros => (x, y), // Keep as-is, handle out-of-bounds in read + }; + + // Get the four corner indices + let x0 = x.floor() as i64; + let y0 = y.floor() as i64; + let x1 = x0.saturating_add(1); + let y1 = y0.saturating_add(1); + + // Compute interpolation weights (fractional part) + let x_frac = x - x.floor(); + let y_frac = y - y.floor(); + + // Helper to read a value based on padding mode + let read_value = |xi: i64, yi: i64| -> f64 { + match padding_mode { + GridSamplePaddingMode::Zeros => { + // Return 0 for out-of-bounds + if xi >= 0 && xi < width as i64 && yi >= 0 && yi < height as i64 { + source[(b, c, yi as usize, xi as usize)].elem::() + } else { + 0.0 + } + } + GridSamplePaddingMode::Border | GridSamplePaddingMode::Reflection => { + // Coordinates should already be in valid range after clamping/reflection + let xi = xi.clamp(0, (width - 1) as i64) as usize; + let yi = yi.clamp(0, (height - 1) as i64) as usize; + source[(b, c, yi, xi)].elem::() + } + } + }; + + // Read the four corners + let v00 = read_value(x0, y0); + let v01 = read_value(x0, y1); + let v10 = read_value(x1, y0); + let v11 = read_value(x1, y1); + + // Bilinear interpolation weights + let w00 = (1.0 - x_frac) * (1.0 - y_frac); + let w01 = (1.0 - x_frac) * y_frac; + let w10 = x_frac * (1.0 - y_frac); + let w11 = x_frac * y_frac; + + v00 * w00 + v01 * w01 + v10 * w10 + v11 * w11 +} + +/// Reflect a coordinate at the boundaries using a triangle wave pattern. +/// +/// For align_corners=true: reflects within [0, size-1] +/// For align_corners=false: reflects within [-0.5, size-0.5] +fn reflect_coordinate(coord: f64, size: usize, align_corners: bool) -> f64 { + let size_f = size as f64; + let (min_val, max_val) = if align_corners { + (0.0, size_f - 1.0) + } else { + (-0.5, size_f - 0.5) + }; + + let span = max_val - min_val; + if span <= 0.0 { + return min_val; + } + + // Triangle wave formula: span - |((x mod 2*span) - span)| + let period = 2.0 * span; + let x = (coord - min_val).abs(); + let x_mod = x - (x / period).floor() * period; + span - (x_mod - span).abs() + min_val +} diff --git a/crates/burn-ndarray/src/ops/int_tensor.rs b/crates/burn-ndarray/src/ops/int_tensor.rs new file mode 100644 index 0000000..ddc676b --- /dev/null +++ b/crates/burn-ndarray/src/ops/int_tensor.rs @@ -0,0 +1,519 @@ +// Language +use crate::rand::get_seeded_rng; +use alloc::vec::Vec; +use burn_backend::backend::ExecutionError; +use burn_backend::ops::IntTensorOps; +use burn_backend::tensor::{FloatTensor, IntTensor}; +use burn_backend::{Distribution, IntDType, Scalar, TensorMetadata}; + +use burn_backend::ElementConversion; +use burn_std::{BoolDType, FloatDType}; + +// Current crate +use crate::SharedArray; +use crate::execute_with_int_dtype; +use crate::ops::matmul::matmul; +use crate::{ExpElement, NdArrayDevice, SEED, execute_with_int_out_dtype, slice}; +use crate::{NdArray, cast_to_dtype, execute_with_dtype, tensor::NdArrayTensor}; +use crate::{cat_with_dtype, execute_with_float_out_dtype}; + +// Workspace crates +use super::{NdArrayBitOps, NdArrayMathOps, NdArrayOps}; +use burn_backend::{DType, Shape, TensorData}; + +impl IntTensorOps for NdArray { + fn int_from_data(data: TensorData, _device: &NdArrayDevice) -> NdArrayTensor { + if data.dtype.is_int() || data.dtype.is_uint() { + NdArrayTensor::from_data(data) + } else { + unimplemented!("Unsupported dtype for `int_from_data`: {:?}", data.dtype) + } + } + + async fn int_into_data(tensor: NdArrayTensor) -> Result { + Ok(tensor.into_data()) + } + + fn int_to_device(tensor: NdArrayTensor, _device: &NdArrayDevice) -> NdArrayTensor { + tensor + } + + fn int_reshape(tensor: NdArrayTensor, shape: Shape) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::reshape(array, shape)) + } + + fn int_slice(tensor: NdArrayTensor, slices: &[burn_backend::Slice]) -> NdArrayTensor { + slice!(tensor, slices) + } + + fn int_empty(shape: Shape, device: &NdArrayDevice, dtype: IntDType) -> NdArrayTensor { + Self::int_zeros(shape, device, dtype) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + execute_with_int_dtype!((lhs, rhs), matmul) + } + + fn int_mask_where( + tensor: NdArrayTensor, + mask: NdArrayTensor, + source: NdArrayTensor, + ) -> NdArrayTensor { + execute_with_int_dtype!((tensor, source), |tensor, source| { + NdArrayOps::mask_where(tensor, mask.bool(), source) + }) + } + + fn int_mask_fill(tensor: NdArrayTensor, mask: NdArrayTensor, value: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::mask_fill( + array, + mask.bool(), + value.elem() + )) + } + + fn int_slice_assign( + tensor: NdArrayTensor, + slices: &[burn_backend::Slice], + value: NdArrayTensor, + ) -> NdArrayTensor { + execute_with_int_dtype!((tensor, value), |tensor, value| NdArrayOps::slice_assign( + tensor, slices, value + )) + } + + fn int_cat(tensors: Vec, dim: usize) -> NdArrayTensor { + cat_with_dtype!(tensors, dim, [I64, I32, I16, I8, U64, U32, U16, U8]) + } + + fn int_equal(lhs: NdArrayTensor, rhs: NdArrayTensor, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::equal) + } + + fn int_equal_elem(lhs: NdArrayTensor, rhs: Scalar, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::equal_elem(array, rhs.elem())) + } + + fn int_greater(lhs: NdArrayTensor, rhs: NdArrayTensor, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::greater) + } + + fn int_greater_elem(lhs: NdArrayTensor, rhs: Scalar, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::greater_elem(array, rhs.elem())) + } + + fn int_greater_equal( + lhs: NdArrayTensor, + rhs: NdArrayTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::greater_equal) + } + + fn int_greater_equal_elem( + lhs: NdArrayTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::greater_equal_elem( + array, + rhs.elem() + )) + } + + fn int_lower(lhs: NdArrayTensor, rhs: NdArrayTensor, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::lower) + } + + fn int_lower_elem(lhs: NdArrayTensor, rhs: Scalar, _out_dtype: BoolDType) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::lower_elem(array, rhs.elem())) + } + + fn int_lower_equal( + lhs: NdArrayTensor, + rhs: NdArrayTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::lower_equal) + } + + fn int_lower_equal_elem( + lhs: NdArrayTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::lower_equal_elem( + array, + rhs.elem() + )) + } + + fn int_add(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::add) + } + + fn int_add_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::add_scalar(array, rhs.elem())) + } + + fn int_sub(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::sub) + } + + fn int_sub_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::sub_scalar(array, rhs.elem())) + } + + fn int_mul(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::mul) + } + + fn int_mul_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::mul_scalar(array, rhs.elem())) + } + + fn int_div(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::div) + } + + fn int_div_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::div_scalar(array, rhs.elem())) + } + + fn int_remainder(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayMathOps::remainder) + } + + fn int_remainder_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayMathOps::remainder_scalar( + array, + rhs.elem() + )) + } + + fn int_sum(tensor: NdArrayTensor) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!(tensor, E, |array: SharedArray| NdArrayMathOps::sum_view( + array.view() + )) + } + + fn int_sum_dim(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::sum_dim(array, dim)) + } + + fn int_prod(tensor: NdArrayTensor) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!( + tensor, + E, + |array: SharedArray| NdArrayMathOps::prod_view(array.view()) + ) + } + + fn int_prod_dim(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::prod_dim(array, dim)) + } + + fn int_mean(tensor: NdArrayTensor) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!( + tensor, + E, + |array: SharedArray| NdArrayMathOps::mean_view(array.view()) + ) + } + + fn int_mean_dim(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::mean_dim(array, dim)) + } + + fn int_max(tensor: NdArrayTensor) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!(tensor, E, |array: SharedArray| NdArrayMathOps::max_view( + array.view() + )) + } + + fn int_min(tensor: NdArrayTensor) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!(tensor, E, |array: SharedArray| NdArrayMathOps::min_view( + array.view() + )) + } + + fn int_cumsum(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::cumsum(array, dim)) + } + + fn int_cumprod(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::cumprod(array, dim)) + } + + fn int_cummin(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::cummin(array, dim)) + } + + fn int_cummax(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::cummax(array, dim)) + } + + fn int_gather(dim: usize, tensor: NdArrayTensor, indices: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!(tensor, E, |array| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayOps::gather( + dim, array, idx_array + )) + }) + } + + fn int_scatter_add( + dim: usize, + tensor: NdArrayTensor, + indices: NdArrayTensor, + value: NdArrayTensor, + ) -> NdArrayTensor { + execute_with_int_dtype!((tensor, value), I, |tensor, value| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayOps::::scatter( + dim, tensor, idx_array, value + )) + }) + } + + fn int_scatter_nd( + data: NdArrayTensor, + indices: NdArrayTensor, + values: NdArrayTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> NdArrayTensor { + execute_with_int_dtype!((data, values), I, |data, values| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayOps::::scatter_nd( + data, idx_array, values, reduction + )) + }) + } + + fn int_gather_nd(data: NdArrayTensor, indices: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!(data, E, |array| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayOps::gather_nd(array, idx_array)) + }) + } + + fn int_select(tensor: NdArrayTensor, dim: usize, indices: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!(tensor, E, |array| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayMathOps::select( + array, dim, idx_array + )) + }) + } + + fn int_select_add( + tensor: NdArrayTensor, + dim: usize, + indices: NdArrayTensor, + value: NdArrayTensor, + ) -> NdArrayTensor { + execute_with_int_dtype!((tensor, value), I, |tensor, value| -> NdArrayTensor { + execute_with_int_dtype!(indices, |idx_array| NdArrayMathOps::::select_assign( + tensor, dim, idx_array, value + )) + }) + } + fn int_argmax(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!(tensor, E, |array: SharedArray| { + NdArrayMathOps::argmax_view::(array.view(), dim) + }) + } + + fn int_argtopk(_tensor: NdArrayTensor, _dim: usize, _k: usize) -> NdArrayTensor { + unimplemented!("argtopk not implemented for ndarray"); + } + + fn int_argmin(tensor: NdArrayTensor, dim: usize) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_dtype!(tensor, E, |array: SharedArray| { + NdArrayMathOps::argmin_view::(array.view(), dim) + }) + } + + fn int_clamp_min(tensor: NdArrayTensor, min: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::clamp_min(array, min.elem())) + } + + fn int_clamp_max(tensor: NdArrayTensor, max: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::clamp_max(array, max.elem())) + } + + fn int_clamp(tensor: NdArrayTensor, min: Scalar, max: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayMathOps::clamp( + array, + min.elem(), + max.elem() + )) + } + + fn int_abs(tensor: NdArrayTensor) -> NdArrayTensor { + match tensor.dtype() { + DType::I64 | DType::I32 | DType::I16 | DType::I8 => { + execute_with_dtype!(tensor, I, NdArrayMathOps::abs, [ + I64 => i64, I32 => i32, I16 => i16, I8 => i8 + ]) + } + // Already unsigned + DType::U64 | DType::U32 | DType::U16 | DType::U8 => tensor, + other => panic!("Unsupported dtype: {other:?}"), + } + } + + fn int_into_float(tensor: NdArrayTensor, out_dtype: FloatDType) -> FloatTensor { + execute_with_float_out_dtype!(out_dtype, F, { + execute_with_int_dtype!(tensor, IntElem, |array: SharedArray| { + array.mapv(|a: IntElem| a.elem::()).into_shared() + }) + }) + } + + fn int_swap_dims(tensor: NdArrayTensor, dim1: usize, dim2: usize) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::swap_dims(array, dim1, dim2)) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &NdArrayDevice, + dtype: IntDType, + ) -> NdArrayTensor { + let mut seed = SEED.lock().unwrap(); + let mut rng = seed.take().unwrap_or_else(get_seeded_rng); + + let effective_distribution = if distribution == Distribution::Default { + Distribution::Uniform(0.0, 255.0) // Assuming UniformInt is the integer variant + } else { + distribution + }; + + let tensor = execute_with_int_out_dtype!( + dtype, + I, + Self::int_from_data( + TensorData::random::(shape, effective_distribution, &mut rng), + device, + ) + ); + *seed = Some(rng); + tensor + } + + fn int_powi(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), I, |lhs, rhs| NdArrayMathOps::elementwise_op( + lhs, + rhs, + |a: &I, b: &I| { (a.elem::().pow(b.elem::())).elem() } + )) + } + + fn int_permute(tensor: NdArrayTensor, axes: &[usize]) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::permute(array, axes)) + } + + fn int_flip(tensor: NdArrayTensor, axes: &[usize]) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::flip(array, axes)) + } + + fn int_sign(tensor: NdArrayTensor) -> NdArrayTensor { + match tensor.dtype() { + DType::I64 | DType::I32 | DType::I16 | DType::I8 => { + execute_with_dtype!(tensor, I, NdArrayMathOps::sign_op, [ + I64 => i64, I32 => i32, I16 => i16, I8 => i8 + ]) + } + DType::U64 | DType::U32 | DType::U16 | DType::U8 => { + Self::int_greater_elem(tensor, 0.into(), BoolDType::Native) + } + other => panic!("Unsupported dtype: {other:?}"), + } + } + + fn int_expand(tensor: NdArrayTensor, shape: Shape) -> NdArrayTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::expand(array, shape)) + } + + fn bitwise_and(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayBitOps::bitand) + } + + fn bitwise_and_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayBitOps::bitand_scalar(array, rhs.elem())) + } + + fn bitwise_or(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayBitOps::bitor) + } + + fn bitwise_or_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayBitOps::bitor_scalar(array, rhs.elem())) + } + + fn bitwise_xor(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), NdArrayBitOps::bitxor) + } + + fn bitwise_xor_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, |array| NdArrayBitOps::bitxor_scalar(array, rhs.elem())) + } + + fn bitwise_not(tensor: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!(tensor, NdArrayBitOps::bitnot) + } + + fn bitwise_left_shift(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), I, |lhs, rhs| { + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &I, b: &I| { + (a.elem::() << (b.elem::())).elem() + }) + }) + } + + fn bitwise_left_shift_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, I, |array| { + NdArrayMathOps::elementwise_op_scalar(array, |a: I| { + (a.elem::() << rhs.elem::()).elem() + }) + }) + } + + fn bitwise_right_shift(lhs: NdArrayTensor, rhs: NdArrayTensor) -> NdArrayTensor { + execute_with_int_dtype!((lhs, rhs), I, |lhs, rhs| { + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &I, b: &I| { + (a.elem::() >> (b.elem::())).elem() + }) + }) + } + + fn bitwise_right_shift_scalar(lhs: NdArrayTensor, rhs: Scalar) -> NdArrayTensor { + execute_with_int_dtype!(lhs, I, |array| { + NdArrayMathOps::elementwise_op_scalar(array, |a: I| { + (a.elem::() >> rhs.elem::()).elem() + }) + }) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + execute_with_int_dtype!(tensor, |array| cast_to_dtype(array, dtype.into())) + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + execute_with_int_dtype!(tensor, |array| NdArrayOps::unfold(array, dim, size, step)) + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + execute_with_int_dtype!(lhs, I, |array| { + NdArrayMathOps::elementwise_op_scalar(array, |a: I| a.powi_elem(rhs.elem())) + }) + } +} diff --git a/crates/burn-ndarray/src/ops/interpolate.rs b/crates/burn-ndarray/src/ops/interpolate.rs new file mode 100644 index 0000000..af9d50d --- /dev/null +++ b/crates/burn-ndarray/src/ops/interpolate.rs @@ -0,0 +1,397 @@ +use burn_backend::ElementConversion; +use ndarray::{Array4, ArrayBase, DataOwned}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use crate::{FloatNdArrayElement, ShapeOps, SharedArray, UnsafeSharedRef, iter_range_par, run_par}; + +pub(crate) fn nearest_interpolate( + x: SharedArray, + output_size: [usize; 2], +) -> SharedArray { + let x = x.into_dimensionality::().unwrap(); + + let (batch_size, channels, in_height, in_width) = x.dim(); + let [out_height, out_width] = output_size; + + let y_ratio = (in_height as f64) / (out_height as f64); + let x_ratio = (in_width as f64) / (out_width as f64); + + let out_element_num = batch_size * channels * out_height * out_width; + let strides = ( + channels * out_height * out_width, + out_height * out_width, + out_width, + ); + + let mut output = Array4::zeros((batch_size, channels, out_height, out_width)); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, out_element_num).for_each(|id| { + let (b, c, h, w) = ( + id / strides.0, + id % strides.0 / strides.1, + id % strides.1 / strides.2, + id % strides.2, + ); + + let y_in = (y_ratio * h as f64).floor() as usize; + let x_in = (x_ratio * w as f64).floor() as usize; + + unsafe { + let output = unsafe_shared_out.get(); + output[(b, c, h, w)] = x[(b, c, y_in, x_in)]; + } + }); + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn nearest_interpolate_backward( + x: SharedArray, + grad: SharedArray, + output_size: [usize; 2], +) -> SharedArray { + let [batch_size, channels, input_height, input_width] = x.shape().dims(); + let [output_height, output_width] = output_size; + + let mut output_grad = + Array4::from_elem((batch_size, channels, input_height, input_width), 0.elem()); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output_grad); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output_grad = unsafe_shared_out.get(); + + for oh in 0..output_height { + for ow in 0..output_width { + let ih = start_index(oh, output_height, input_height); + let iw = start_index(ow, output_width, input_width); + + output_grad[[b, c, ih, iw]] += grad[[b, c, oh, ow]] + } + } + }) + }); + + output_grad.into_dyn().into_shared() +} + +fn start_index(output_size_index: usize, output_size: usize, input_size: usize) -> usize { + ((output_size_index as f32 * input_size as f32) / output_size as f32).floor() as usize +} + +// clamp ceil(frac) to stay within bounds in case of floating-point imprecision +pub(crate) fn ceil_clamp(frac: f64, max: usize) -> f64 { + frac.ceil().min(max as f64) +} + +pub(crate) fn bilinear_interpolate( + x: SharedArray, + output_size: [usize; 2], + align_corners: bool, +) -> SharedArray { + let x = x.into_dimensionality::().unwrap(); + + let (batch_size, channels, in_height, in_width) = x.dim(); + let [out_height, out_width] = output_size; + + let out_element_num = batch_size * channels * out_height * out_width; + let strides = ( + channels * out_height * out_width, + out_height * out_width, + out_width, + ); + + let mut output = Array4::zeros((batch_size, channels, out_height, out_width)); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, out_element_num).for_each(|id| { + let (b, c, h, w) = ( + id / strides.0, + id % strides.0 / strides.1, + id % strides.1 / strides.2, + id % strides.2, + ); + + let (y_frac, x_frac) = if align_corners { + let y_ratio = ((in_height - 1) as f64) / (core::cmp::max(out_height - 1, 1) as f64); + let x_ratio = ((in_width - 1) as f64) / (core::cmp::max(out_width - 1, 1) as f64); + (y_ratio * h as f64, x_ratio * w as f64) + } else { + let y_frac = (h as f64 + 0.5) * (in_height as f64 / out_height as f64) - 0.5; + let x_frac = (w as f64 + 0.5) * (in_width as f64 / out_width as f64) - 0.5; + ( + y_frac.clamp(0.0, (in_height - 1) as f64), + x_frac.clamp(0.0, (in_width - 1) as f64), + ) + }; + let val = + bilinear_interpolate_single(&x, b, c, x_frac, y_frac, in_width - 1, in_height - 1); + + unsafe { + let output = unsafe_shared_out.get(); + output[(b, c, h, w)] = val.elem(); + } + }); + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn bicubic_interpolate( + x: SharedArray, + output_size: [usize; 2], + align_corners: bool, +) -> SharedArray { + fn cubic_interp1d(x0: f64, x1: f64, x2: f64, x3: f64, t: f64) -> f64 { + fn cubic_convolution1(x: f64, a: f64) -> f64 { + ((a + 2.0) * x - (a + 3.0)) * x * x + 1.0 + } + + fn cubic_convolution2(x: f64, a: f64) -> f64 { + ((a * x - 5.0 * a) * x + 8.0 * a) * x - 4.0 * a + } + + let coeffs = [ + cubic_convolution2(t + 1.0, -0.75), + cubic_convolution1(t, -0.75), + cubic_convolution1(1.0 - t, -0.75), + cubic_convolution2(2.0 - t, -0.75), + ]; + + x0 * coeffs[0] + x1 * coeffs[1] + x2 * coeffs[2] + x3 * coeffs[3] + } + + let x = x.into_dimensionality::().unwrap(); + + let (batch_size, channels, in_height, in_width) = x.dim(); + let [out_height, out_width] = output_size; + + let out_element_num = batch_size * channels * out_height * out_width; + let strides = ( + channels * out_height * out_width, + out_height * out_width, + out_width, + ); + + let mut output = Array4::zeros((batch_size, channels, out_height, out_width)); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, out_element_num).for_each(|id| { + let (b, c, h, w) = ( + id / strides.0, + id % strides.0 / strides.1, + id % strides.1 / strides.2, + id % strides.2, + ); + + let (y_frac, x_frac) = if align_corners { + let y_ratio = ((in_height - 1) as f64) / (core::cmp::max(out_height - 1, 1) as f64); + let x_ratio = ((in_width - 1) as f64) / (core::cmp::max(out_width - 1, 1) as f64); + (y_ratio * h as f64, x_ratio * w as f64) + } else { + let y_frac = (h as f64 + 0.5) * (in_height as f64 / out_height as f64) - 0.5; + let x_frac = (w as f64 + 0.5) * (in_width as f64 / out_width as f64) - 0.5; + (y_frac, x_frac) + }; + let y0 = y_frac.floor(); + let yw = y_frac - y0; + let y_in = y0 as isize; + + let x0 = x_frac.floor(); + let xw = x_frac - x0; + let x_in = x0 as isize; + + let max_h = (in_height - 1) as isize; + let max_w = (in_width - 1) as isize; + + let ys_in = [ + (y_in - 1).clamp(0, max_h) as usize, + y_in.clamp(0, max_h) as usize, + (y_in + 1).clamp(0, max_h) as usize, + (y_in + 2).clamp(0, max_h) as usize, + ]; + + let xs_in = [ + (x_in - 1).clamp(0, max_w) as usize, + x_in.clamp(0, max_w) as usize, + (x_in + 1).clamp(0, max_w) as usize, + (x_in + 2).clamp(0, max_w) as usize, + ]; + + let coefficients = ys_in.map(|y| { + cubic_interp1d( + x[(b, c, y, xs_in[0])].elem(), + x[(b, c, y, xs_in[1])].elem(), + x[(b, c, y, xs_in[2])].elem(), + x[(b, c, y, xs_in[3])].elem(), + xw, + ) + }); + + let result = cubic_interp1d( + coefficients[0], + coefficients[1], + coefficients[2], + coefficients[3], + yw, + ) + .elem(); + + unsafe { + let output = unsafe_shared_out.get(); + output[(b, c, h, w)] = result; + } + }); + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn lanczos3_interpolate( + x: SharedArray, + output_size: [usize; 2], + align_corners: bool, +) -> SharedArray { + fn lanczos3_weight(x: f64) -> f64 { + if x == 0.0 { + return 1.0; + } + let abs_x = x.abs(); + if abs_x >= 3.0 { + return 0.0; + } + let pi = core::f64::consts::PI; + let pi_x = pi * x; + let pi_x_over_3 = pi_x / 3.0; + (pi_x.sin() * pi_x_over_3.sin()) / (pi_x * pi_x_over_3) + } + + let x = x.into_dimensionality::().unwrap(); + + let (batch_size, channels, in_height, in_width) = x.dim(); + let [out_height, out_width] = output_size; + + let out_element_num = batch_size * channels * out_height * out_width; + let strides = ( + channels * out_height * out_width, + out_height * out_width, + out_width, + ); + + let mut output = Array4::zeros((batch_size, channels, out_height, out_width)); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, out_element_num).for_each(|id| { + let (b, c, h, w) = ( + id / strides.0, + id % strides.0 / strides.1, + id % strides.1 / strides.2, + id % strides.2, + ); + + let (y_frac, x_frac) = if align_corners { + let y_ratio = ((in_height - 1) as f64) / (core::cmp::max(out_height - 1, 1) as f64); + let x_ratio = ((in_width - 1) as f64) / (core::cmp::max(out_width - 1, 1) as f64); + (y_ratio * h as f64, x_ratio * w as f64) + } else { + let y_frac = (h as f64 + 0.5) * (in_height as f64 / out_height as f64) - 0.5; + let x_frac = (w as f64 + 0.5) * (in_width as f64 / out_width as f64) - 0.5; + (y_frac, x_frac) + }; + + let y0 = y_frac.floor(); + let x0 = x_frac.floor(); + let max_h = (in_height - 1) as isize; + let max_w = (in_width - 1) as isize; + + // 6x6 separable Lanczos3 filter (skip out-of-bounds positions) + let mut result = 0.0; + let mut weight_sum = 0.0; + for ky in -2..=3 { + let yi = y0 as isize + ky; + if yi < 0 || yi > max_h { + continue; + } + let y_idx = yi as usize; + let wy = lanczos3_weight(y_frac - (y0 + ky as f64)); + for kx in -2..=3 { + let xi = x0 as isize + kx; + if xi < 0 || xi > max_w { + continue; + } + let x_idx = xi as usize; + let wx = lanczos3_weight(x_frac - (x0 + kx as f64)); + let w = wy * wx; + let pixel: f64 = x[(b, c, y_idx, x_idx)].elem(); + result += pixel * w; + weight_sum += w; + } + } + if weight_sum != 0.0 { + result /= weight_sum; + } + + unsafe { + let output = unsafe_shared_out.get(); + output[(b, c, h, w)] = result.elem(); + } + }); + }); + + output.into_dyn().into_shared() +} + +/// Sample an element of the source array with bilinear interpolation +/// +/// * `source` - The tensor to read from. Has shape (batch_size, channels, height, width) +/// * `b` - The batch to read from +/// * `c` - The channel to read from +/// * `x` - The x position to read in the array +/// * `y` - The y position to read in the array +/// * `x_max` - The max x position (inclusive) +/// * `y_max` - The max y position (inclusive) +/// +/// # Returns +/// +/// The interpolated value read from the array +pub(crate) fn bilinear_interpolate_single( + source: &ArrayBase>, + b: usize, + c: usize, + x: f64, + y: f64, + x_max: usize, + y_max: usize, +) -> f64 +where + E: FloatNdArrayElement, + S: DataOwned, +{ + let y0 = y.floor(); + let y1 = ceil_clamp(y, y_max); + let yw = y - y0; + + let x0 = x.floor(); + let x1 = ceil_clamp(x, x_max); + let xw = x - x0; + + let (x0, x1, y0, y1) = (x0 as usize, x1 as usize, y0 as usize, y1 as usize); + + let p_a = source[(b, c, y0, x0)].elem::() * (1.0 - xw) * (1.0 - yw); + let p_b = source[(b, c, y0, x1)].elem::() * xw * (1.0 - yw); + let p_c = source[(b, c, y1, x0)].elem::() * (1.0 - xw) * yw; + let p_d = source[(b, c, y1, x1)].elem::() * xw * yw; + + p_a + p_b + p_c + p_d +} diff --git a/crates/burn-ndarray/src/ops/macros.rs b/crates/burn-ndarray/src/ops/macros.rs new file mode 100644 index 0000000..b3ac4f9 --- /dev/null +++ b/crates/burn-ndarray/src/ops/macros.rs @@ -0,0 +1,107 @@ +macro_rules! keepdim { + ( + $dim:expr, + $self:expr, + mean + ) => {{ + // Get shape first (via reference), then pass ownership to avoid clone + let mut shape = $self.shape().into_shape(); + shape[$dim] = 1; + let tensor: SharedArray = mean_dim($self, $dim); + NdArrayOps::reshape(tensor, shape) + }}; + ( + $dim:expr, + $self:expr, + sum + ) => {{ + // Get shape first (via reference), then pass ownership to avoid clone + let mut shape = $self.shape().into_shape(); + shape[$dim] = 1; + let tensor: SharedArray = sum_dim($self, $dim); + NdArrayOps::reshape(tensor, shape) + }}; + ( + $dim:expr, + $self:expr, + prod + ) => {{ + // Get shape first (via reference), then pass ownership to avoid clone + let mut shape = $self.shape().into_shape(); + shape[$dim] = 1; + let tensor: SharedArray = prod_dim($self, $dim); + NdArrayOps::reshape(tensor, shape) + }}; +} + +use burn_backend::ElementConversion; +pub(crate) use keepdim; +use ndarray::{Axis, Zip}; + +use crate::{SharedArray, element::NdArrayElement}; + +pub(crate) fn mean_dim(tensor: SharedArray, dim: usize) -> SharedArray { + tensor.mean_axis(Axis(dim)).unwrap().into_shared() +} + +pub(crate) fn sum_dim(tensor: SharedArray, dim: usize) -> SharedArray { + tensor.sum_axis(Axis(dim)).into_shared() +} + +pub(crate) fn prod_dim(tensor: SharedArray, dim: usize) -> SharedArray { + tensor + .fold_axis(Axis(dim), 1.elem::(), |acc, &x| acc.mul(x.elem())) + .into_shared() +} + +/// Generic cumulative operation function with closure-based operation. +pub(crate) fn cumulative_with_op(tensor: SharedArray, dim: usize, op: F) -> SharedArray +where + E: NdArrayElement, + F: Fn(&mut E, &E), +{ + let axis = Axis(dim); + let shape = tensor.shape().to_vec(); + // Use into_owned() instead of to_owned() - only copies if shared, avoids copy if unique + let mut result = tensor.into_owned(); + let dim_size = shape[dim]; + + for i in 1..dim_size { + let prev = result.index_axis(axis, i - 1).to_owned(); + let mut current = result.index_axis_mut(axis, i); + Zip::from(&mut current).and(&prev).for_each(&op); + } + + result.into_shared() +} + +// Define all cumulative operation functions using the generic function +pub(crate) fn cumsum_dim(tensor: SharedArray, dim: usize) -> SharedArray { + cumulative_with_op(tensor, dim, |c, &p| *c = c.add(p.elem())) +} + +pub(crate) fn cumprod_dim(tensor: SharedArray, dim: usize) -> SharedArray { + cumulative_with_op(tensor, dim, |c, &p| *c = c.mul(p.elem())) +} + +pub(crate) fn cummin_dim>( + tensor: SharedArray, + dim: usize, +) -> SharedArray { + cumulative_with_op(tensor, dim, |c, &p| { + if p < *c { + *c = p; + } + }) +} + +pub(crate) fn cummax_dim>( + tensor: SharedArray, + dim: usize, +) -> SharedArray { + cumulative_with_op(tensor, dim, |c, &p| { + if p > *c { + *c = p; + } + }) +} diff --git a/crates/burn-ndarray/src/ops/matmul.rs b/crates/burn-ndarray/src/ops/matmul.rs new file mode 100644 index 0000000..6b17a43 --- /dev/null +++ b/crates/burn-ndarray/src/ops/matmul.rs @@ -0,0 +1,368 @@ +use crate::UnsafeSharedRef; +use crate::{NdArrayElement, ShapeOps, SharedArray, iter_range_par, ops::NdArrayOps, run_par}; + +use alloc::{vec, vec::Vec}; +use burn_backend::ElementConversion; +use burn_backend::Shape; +use ndarray::{IxDyn, s}; + +pub(crate) fn matmul( + lhs: SharedArray, + rhs: SharedArray, +) -> SharedArray { + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + let ndims = shape_lhs.num_dims(); + let m = shape_lhs[ndims - 2]; // # of left rows + let k = shape_rhs[ndims - 2]; // # of left cols and right rows + let n = shape_rhs[ndims - 1]; // # of right cols + + let (out_shape, strides_lhs, strides_rhs, strides_out) = output_shape(shape_lhs, shape_rhs); + let l_mat_size = m * k; // size of matrix component of left array + let r_mat_size = k * n; // size of matrix component of right array + let out_mat_size = m * n; // size of matrix component of output array + + let num_l_batches = shape_lhs.num_elements() / l_mat_size; + let num_r_batches = shape_rhs.num_elements() / r_mat_size; + let num_out_batches = out_shape.num_elements() / out_mat_size; + + let lhs_array = NdArrayOps::reshape(lhs, Shape::new([num_l_batches, m, k])); + let rhs_array = NdArrayOps::reshape(rhs, Shape::new([num_r_batches, k, n])); + + let alpha: E = 1.0.elem(); + let beta: E = 0.0.elem(); + + let out = run_par!(|| { + let mut out_array = ndarray::Array3::::zeros((num_out_batches, m, n)); + let unsafe_shared_out_array = UnsafeSharedRef::new(&mut out_array); + + iter_range_par!(0, num_out_batches).for_each(|out_batch| { + // Here, we: + // 1. Un-flatten the output batch into a component-based batch index. + // 2. Use the strides for left and right batch indices to convert it to a flattened + // batch for left and right. + let out_index = strides_out.unflatten(out_batch); + let l_batch = strides_lhs.flatten(&out_index); + let r_batch = strides_rhs.flatten(&out_index); + + let lhs_slice = lhs_array.slice(s!(l_batch, .., ..)); + let rhs_slice = rhs_array.slice(s!(r_batch, .., ..)); + + unsafe { + let mut out_slice = unsafe_shared_out_array + .get() + .slice_mut(s!(out_batch, .., ..)); + + ndarray::linalg::general_mat_mul( + alpha, + &lhs_slice, + &rhs_slice, + beta, + &mut out_slice, + ) + } + }); + + out_array.into_shared().into_dyn() + }); + + NdArrayOps::reshape(out, out_shape) +} + +#[derive(Debug, PartialEq)] +struct Strides { + strides: Vec, +} +impl Strides { + fn new(strides: Vec) -> Self { + Strides { strides } + } + + fn unflatten(&self, linear_index: usize) -> Vec { + let mut coord = Vec::with_capacity(self.strides.len()); + let mut rem = linear_index; + for stride in self.strides.iter() { + coord.push(rem / stride); + rem %= stride; + } + coord + } + + fn flatten(&self, index: &Vec) -> usize { + assert_eq!(self.strides.len(), index.len()); + self.strides + .iter() + .zip(index) + .map(|(stride, index)| stride * index) + .sum() + } +} + +/// Compute the (broadcasted) output shape of matrix multiplication, along with strides for +/// the non-matrix dimensions of all arrays. +/// +/// # Arguments +/// * `lsh`: Shape of the first (left-hand) matrix multiplication argument. +/// * `rsh`: Shape of the second (right-hand) matrix multiplication argument. +/// +/// # Panics +/// * If `D` is not at least 2. +/// * If the matrix multiplication dimensions (last 2) are incompatible. +/// * If any other dimension is not the same for both tensors, or equal to 1. (Any dimension where +/// one dim is equal to 1 is broadcast.) +fn output_shape(lsh: &[usize], rsh: &[usize]) -> (Shape, Strides, Strides, Strides) { + let ndims = lsh.num_dims(); + if ndims < 2 { + panic!( + "Matrix multiplication requires an array with at least 2 dimensions. Got Rank {}", + ndims + ); + } + + // Fetch matrix dimensions and check compatibility. + let l_rows = lsh[ndims - 2]; + let l_cols = lsh[ndims - 1]; + let r_rows = rsh[ndims - 2]; + let r_cols = rsh[ndims - 1]; + if l_cols != r_rows { + panic!( + "Dimensions are incompatible for matrix multiplication: LHS columns ({}) != ({})", + l_cols, r_rows + ); + } + // Set matrix dimensions of the output shape. + let mut osh = vec![0; ndims]; + osh[ndims - 2] = l_rows; + osh[ndims - 1] = r_cols; + + // Set other array dimensions, broadcasting as necessary. + // Compute the strides inline. + let mut cur_l_stride: usize = 1; + let mut cur_r_stride: usize = 1; + let mut cur_o_stride: usize = 1; + let mut l_strides = Vec::with_capacity(ndims - 2); + let mut r_strides = Vec::with_capacity(ndims - 2); + let mut o_strides = Vec::with_capacity(ndims - 2); + for i in (0..ndims - 2).rev() { + let l_dim = lsh[i]; + let r_dim = rsh[i]; + + // Compatible dimensions are: + // 1. Both dimensions are equal. + // 2. One of the dimensions is equal to 1. + let o_dim: usize; + if l_dim == r_dim { + o_dim = l_dim; // both dimensions are equal + l_strides.push(cur_l_stride); + r_strides.push(cur_r_stride); + } else if l_dim == 1 { + o_dim = r_dim; // broadcast the left + l_strides.push(0); + r_strides.push(cur_r_stride); + } else if r_dim == 1 { + o_dim = l_dim; // broadcast the right + l_strides.push(cur_l_stride); + r_strides.push(0); + } else { + panic!("Dimensions differ and cannot be broadcasted."); + } + osh[i] = o_dim; + o_strides.push(cur_o_stride); + cur_o_stride *= o_dim; + + cur_l_stride *= l_dim; + cur_r_stride *= r_dim; + } + l_strides.reverse(); + r_strides.reverse(); + o_strides.reverse(); + + ( + Shape::from(osh), + Strides::new(l_strides), + Strides::new(r_strides), + Strides::new(o_strides), + ) +} + +pub(crate) fn cross( + lhs: SharedArray, + rhs: SharedArray, + dim: usize, +) -> SharedArray { + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + let ndims = shape_lhs.num_dims(); + + // Broadcast the shapes except along dim + let mut broadcast_shape = vec![0; ndims]; + for i in 0..ndims { + if i == dim { + broadcast_shape[i] = shape_lhs[i]; // already checked to be 3 + } else { + let l = shape_lhs[i]; + let r = shape_rhs[i]; + if l == r { + broadcast_shape[i] = l; + } else if l == 1 { + broadcast_shape[i] = r; + } else if r == 1 { + broadcast_shape[i] = l; + } else { + panic!("Tensors are not broadcastable along dimension {}", i); + } + } + } + + // Broadcast lhs and rhs + let lhs_broadcast = if shape_lhs == broadcast_shape.as_slice() { + lhs + } else { + NdArrayOps::expand(lhs, Shape::from(broadcast_shape.clone())) + }; + let rhs_broadcast = if shape_rhs == broadcast_shape.as_slice() { + rhs + } else { + NdArrayOps::expand(rhs, Shape::from(broadcast_shape.clone())) + }; + + // Now, move dim to the last dimension + let mut perm = (0..ndims).collect::>(); + perm.remove(dim); + perm.push(dim); + + let lhs_permuted = NdArrayOps::permute(lhs_broadcast, &perm); + let rhs_permuted = NdArrayOps::permute(rhs_broadcast, &perm); + + // Reshape to (*, 3) + let total_elements = lhs_permuted.shape().num_elements(); + let batch_size = total_elements / 3; + let lhs_reshaped = NdArrayOps::reshape(lhs_permuted, Shape::new([batch_size, 3])); + let rhs_reshaped = NdArrayOps::reshape(rhs_permuted, Shape::new([batch_size, 3])); + + // Compute cross product + let mut result = ndarray::ArrayD::::zeros(IxDyn(&[batch_size, 3])); + for i in 0..batch_size { + let a1 = lhs_reshaped[IxDyn(&[i, 0])]; + let a2 = lhs_reshaped[IxDyn(&[i, 1])]; + let a3 = lhs_reshaped[IxDyn(&[i, 2])]; + let b1 = rhs_reshaped[IxDyn(&[i, 0])]; + let b2 = rhs_reshaped[IxDyn(&[i, 1])]; + let b3 = rhs_reshaped[IxDyn(&[i, 2])]; + result[IxDyn(&[i, 0])] = a2.mul(b3).sub(a3.mul(b2)); + result[IxDyn(&[i, 1])] = a3.mul(b1).sub(a1.mul(b3)); + result[IxDyn(&[i, 2])] = a1.mul(b2).sub(a2.mul(b1)); + } + + let result_shared = result.into_shared(); + + // Reshape back to the broadcast shape with dim at the end + let mut result_shape = broadcast_shape; + result_shape.remove(dim); + result_shape.push(3); + let result_reshaped = NdArrayOps::reshape(result_shared, Shape::from(result_shape)); + + // Permute back + let mut inv_perm = vec![0; ndims]; + for (i, &p) in perm.iter().enumerate() { + inv_perm[p] = i; + } + NdArrayOps::permute(result_reshaped, &inv_perm) +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Strides { + fn empty() -> Self { + Strides { + strides: Vec::with_capacity(0), + } + } + } + + #[test] + fn test_output_shape() { + // plain matrix multiply + assert_eq!( + output_shape(&[5, 3], &[3, 7]), + ( + Shape::from([5, 7]), + Strides::empty(), + Strides::empty(), + Strides::empty() + ) + ); + // matrix multiply with one extra stack dimension + assert_eq!( + output_shape(&[4, 5, 3], &[4, 3, 7]), + ( + Shape::from([4, 5, 7]), + Strides::new(vec![1]), + Strides::new(vec![1]), + Strides::new(vec![1]) + ) + ); + // rank 3, broadcast left + assert_eq!( + output_shape(&[1, 5, 3], &[4, 3, 7]), + ( + Shape::from([4, 5, 7]), + Strides::new(vec![0]), + Strides::new(vec![1]), + Strides::new(vec![1]) + ) + ); + // rank 3, broadcast right + assert_eq!( + output_shape(&[4, 5, 3], &[1, 3, 7]), + ( + Shape::from([4, 5, 7]), + Strides::new(vec![1]), + Strides::new(vec![0]), + Strides::new(vec![1]) + ) + ); + // rank 4, multi broadcast + assert_eq!( + output_shape(&[1, 4, 5, 3], &[8, 1, 3, 7]), + ( + Shape::from([8, 4, 5, 7]), + Strides::new(vec![0, 1]), + Strides::new(vec![1, 0]), + Strides::new(vec![4, 1]) + ) + ); + // rank 5, multi-broadcast + assert_eq!( + output_shape(&[1, 3, 4, 5, 3], &[8, 3, 1, 3, 7]), + ( + Shape::from([8, 3, 4, 5, 7]), + Strides::new(vec![0, 4, 1]), + Strides::new(vec![3, 1, 0]), + Strides::new(vec![12, 4, 1]) + ) + ) + } + + #[test] + #[should_panic( + expected = "Matrix multiplication requires an array with at least 2 dimensions." + )] + fn test_output_shape_too_small() { + output_shape(&[4], &[4]); + } + + #[test] + #[should_panic(expected = "Dimensions are incompatible for matrix multiplication: LHS columns")] + fn test_output_shape_bad_matrix_dims() { + output_shape(&[5, 3], &[4, 7]); + } + + #[test] + #[should_panic(expected = "Dimensions differ and cannot be broadcasted.")] + fn test_output_shape_non_broadcast() { + output_shape(&[4, 5, 3], &[2, 3, 7]); + } +} diff --git a/crates/burn-ndarray/src/ops/maxpool.rs b/crates/burn-ndarray/src/ops/maxpool.rs new file mode 100644 index 0000000..2a162cf --- /dev/null +++ b/crates/burn-ndarray/src/ops/maxpool.rs @@ -0,0 +1,247 @@ +use crate::{ + ShapeOps, SharedArray, + element::{FloatNdArrayElement, IntNdArrayElement}, + iter_range_par, + ops::padding::apply_padding_4d, + run_par, + sharing::UnsafeSharedRef, +}; + +use burn_backend::ElementConversion; +use burn_backend::ops::conv::calculate_pool_output_size; +use ndarray::Array4; + +pub(crate) fn max_pool2d( + x: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> SharedArray { + let [kernel_height, kernel_width] = kernel_size; + let [padding_height, padding_width] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + let [batch_size, channels, x_height, x_width] = x.shape().dims(); + let inf = (-f32::INFINITY).elem::(); + + let out_height = calculate_pool_output_size( + kernel_height, + stride_height, + padding_height, + dilation_height, + x_height, + ceil_mode, + ); + let out_width = calculate_pool_output_size( + kernel_width, + stride_width, + padding_width, + dilation_width, + x_width, + ceil_mode, + ); + + // Calculate extra padding needed for ceil_mode + // The maximum input position accessed is: (out_size - 1) * stride + (kernel_size - 1) * dilation + // This must be < input_size + 2 * total_padding + let max_ih = + (out_height.saturating_sub(1)) * stride_height + (kernel_height - 1) * dilation_height; + let max_iw = (out_width.saturating_sub(1)) * stride_width + (kernel_width - 1) * dilation_width; + let padded_height = x_height + 2 * padding_height; + let padded_width = x_width + 2 * padding_width; + let extra_pad_h = max_ih.saturating_sub(padded_height.saturating_sub(1)); + let extra_pad_w = max_iw.saturating_sub(padded_width.saturating_sub(1)); + let total_padding = [padding_height + extra_pad_h, padding_width + extra_pad_w]; + + let x = apply_padding_4d::(x, total_padding, inf); + + // Offset to account for extra padding (extra_pad is added on both sides by apply_padding_4d) + let offset_h = extra_pad_h; + let offset_w = extra_pad_w; + + let mut output = Array4::from_elem((batch_size, channels, out_height, out_width), inf); + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output = unsafe_shared_out.get(); + + for oh in 0..out_height { + for ow in 0..out_width { + let mut max_val = inf; + + for kh in 0..kernel_height { + let ih = offset_h + oh * stride_height + kh * dilation_height; + + for kw in 0..kernel_width { + let iw = offset_w + ow * stride_width + kw * dilation_width; + + let val = x[[b, c, ih, iw]]; + + if val > max_val { + max_val = val; + } + } + } + + output[[b, c, oh, ow]] = max_val; + } + } + }) + }); + + output.into_dyn().into_shared() +} + +pub(crate) fn max_pool2d_with_indices( + x: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (SharedArray, SharedArray) { + let [kernel_height, kernel_width] = kernel_size; + let [padding_height, padding_width] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + let [batch_size, channels, x_height, x_width] = x.shape().dims(); + let inf = (-f32::INFINITY).elem::(); + + let out_height = calculate_pool_output_size( + kernel_height, + stride_height, + padding_height, + dilation_height, + x_height, + ceil_mode, + ); + let out_width = calculate_pool_output_size( + kernel_width, + stride_width, + padding_width, + dilation_width, + x_width, + ceil_mode, + ); + + // Calculate extra padding needed for ceil_mode + let max_ih = + (out_height.saturating_sub(1)) * stride_height + (kernel_height - 1) * dilation_height; + let max_iw = (out_width.saturating_sub(1)) * stride_width + (kernel_width - 1) * dilation_width; + let padded_height = x_height + 2 * padding_height; + let padded_width = x_width + 2 * padding_width; + let extra_pad_h = max_ih.saturating_sub(padded_height.saturating_sub(1)); + let extra_pad_w = max_iw.saturating_sub(padded_width.saturating_sub(1)); + let total_padding = [padding_height + extra_pad_h, padding_width + extra_pad_w]; + + let x = apply_padding_4d::(x, total_padding, inf); + + // Offset to account for extra padding + let offset_h = extra_pad_h; + let offset_w = extra_pad_w; + + let mut output = Array4::from_elem((batch_size, channels, out_height, out_width), inf); + let mut indices = Array4::::zeros((batch_size, channels, out_height, out_width)); + + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + let unsafe_shared_indices = UnsafeSharedRef::new(&mut indices); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output = unsafe_shared_out.get(); + let indices = unsafe_shared_indices.get(); + + for oh in 0..out_height { + for ow in 0..out_width { + let mut max_val = inf; + let mut index = 0; + + for kh in 0..kernel_height { + let ih = offset_h + oh * stride_height + kh * dilation_height; + + for kw in 0..kernel_width { + let iw = offset_w + ow * stride_width + kw * dilation_width; + let val = x[[b, c, ih, iw]]; + + if val > max_val { + max_val = val; + + // Calculate index in original (unpadded) input + let ih_orig = ih as i64 - (total_padding[0]) as i64; + let iw_orig = iw as i64 - (total_padding[1]) as i64; + + // Clamp to valid range for index calculation + let ih_clamped = ih_orig.max(0).min(x_height as i64 - 1); + let iw_clamped = iw_orig.max(0).min(x_width as i64 - 1); + + index = ih_clamped * x_width as i64 + iw_clamped; + } + } + } + + output[[b, c, oh, ow]] = max_val; + indices[[b, c, oh, ow]] = index.elem(); + } + } + }) + }); + + let output = output.into_dyn().into_shared(); + let indices = indices.into_dyn().into_shared(); + + (output, indices) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn max_pool2d_backward( + x: SharedArray, + _kernel_size: [usize; 2], + _stride: [usize; 2], + _padding: [usize; 2], + _dilation: [usize; 2], + _ceil_mode: bool, + output_grad: SharedArray, + indices: SharedArray, +) -> SharedArray { + let [_batch_size, _channels, height, width] = output_grad.shape().dims(); + let [batch_size, channels, height_x, width_x] = x.shape().dims(); + + let output_grad = output_grad; + let indices = indices; + + let mut output = Array4::zeros((batch_size, channels, height_x, width_x)); + + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + run_par!(|| { + iter_range_par!(0, batch_size * channels).for_each(|k| unsafe { + let b = k / channels; + let c = k % channels; + + let output = unsafe_shared_out.get(); + + for h in 0..height { + for w in 0..width { + let index = indices[[b, c, h, w]].elem::(); + let grad = output_grad[[b, c, h, w]]; + + let index_h = index as usize / width_x; + let index_w = index as usize % width_x; + + output[[b, c, index_h, index_w]] += grad; + } + } + }); + }); + + output.into_dyn().into_shared() +} diff --git a/crates/burn-ndarray/src/ops/mod.rs b/crates/burn-ndarray/src/ops/mod.rs new file mode 100644 index 0000000..f4f215e --- /dev/null +++ b/crates/burn-ndarray/src/ops/mod.rs @@ -0,0 +1,24 @@ +mod activation; +mod base; +mod bool_tensor; +mod int_tensor; +mod module; +mod qtensor; +#[cfg(feature = "simd")] +mod simd; +mod tensor; +mod transaction; + +pub(crate) mod adaptive_avgpool; +pub(crate) mod avgpool; +pub(crate) mod conv; +pub(crate) mod deform_conv; +pub(crate) mod grid_sample; +pub(crate) mod interpolate; +pub(crate) mod macros; +pub(crate) mod matmul; +pub(crate) mod maxpool; +pub(crate) mod padding; +pub(crate) mod quantization; + +pub(crate) use base::*; diff --git a/crates/burn-ndarray/src/ops/module.rs b/crates/burn-ndarray/src/ops/module.rs new file mode 100644 index 0000000..59f32ca --- /dev/null +++ b/crates/burn-ndarray/src/ops/module.rs @@ -0,0 +1,396 @@ +use super::{ + adaptive_avgpool::{adaptive_avg_pool2d, adaptive_avg_pool2d_backward}, + avgpool::{avg_pool2d, avg_pool2d_backward}, + conv::{conv_transpose2d, conv_transpose3d, conv2d, conv3d}, + deform_conv::{backward::deform_conv2d_backward, deform_conv2d}, + interpolate::{ + bicubic_interpolate, bilinear_interpolate, lanczos3_interpolate, nearest_interpolate, + }, + maxpool::{max_pool2d, max_pool2d_backward, max_pool2d_with_indices}, +}; +use crate::ops::interpolate::nearest_interpolate_backward; +#[cfg(feature = "simd")] +use crate::ops::simd::{ + avgpool::try_avg_pool2d_simd, conv::try_conv2d_simd, maxpool::try_max_pool2d_simd, +}; +use crate::{ + NdArray, SharedArray, execute_with_int_dtype, execute_with_int_out_dtype, tensor::NdArrayTensor, +}; +use burn_backend::{ + TensorMetadata, + ops::{attention::attention_fallback, *}, + tensor::FloatTensor, +}; +use burn_std::IntDType; + +macro_rules! module_op { + // Module op with inputs (inp), optional (opt) and arguments (args). + // Converts NdArrayStorage to SharedArray for compatibility with existing operations. + (inp($($x:tt),+), opt($($opt:tt),*), $element:ident, $op:expr) => {{ + #[allow(unused_parens, unreachable_patterns)] + match ($($x),+) { + ($(NdArrayTensor::F32($x)),+) => { + type $element = f32; + $op( + $($x.into_shared()),+ + $(, $opt.map(|o| match o { NdArrayTensor::F32(val) => val.into_shared(), _ => panic!("Optional argument type mismatch") }))* + ) + } + ($(NdArrayTensor::F64($x)),+) => { + type $element = f64; + $op( + $($x.into_shared()),+ + $(, $opt.map(|o| match o { NdArrayTensor::F64(val) => val.into_shared(), _ => panic!("Optional argument type mismatch") }))* + ) + } + _ => panic!("Data type mismatch"), + } + }}; +} + +impl ModuleOps for NdArray { + fn conv2d( + x: NdArrayTensor, + weight: NdArrayTensor, + bias: Option, + options: ConvOptions<2>, + ) -> NdArrayTensor { + module_op!(inp(x, weight), opt(bias), E, |x, weight, bias| { + #[cfg(feature = "simd")] + let (x, weight, bias) = match try_conv2d_simd(x, weight, bias, options.clone()) { + Ok(out) => return out.into(), + Err(args) => args, + }; + conv2d::(x, weight, bias, options).into() + }) + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + module_op!( + inp(x, offset, weight), + opt(mask, bias), + E, + |x, offset, weight, mask, bias| deform_conv2d::( + x, offset, weight, mask, bias, options + ) + .into() + ) + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + module_op!( + inp(x, offset, weight, output_grad), + opt(mask, bias), + E, + |x, offset, weight, output_grad, mask, bias| { + let (x, offset, weight, mask, bias) = deform_conv2d_backward::( + x, + offset, + weight, + mask, + bias, + output_grad, + options, + ); + DeformConv2dBackward::new( + x.into(), + offset.into(), + weight.into(), + mask.map(|m| m.into()), + bias.map(|b| b.into()), + ) + } + ) + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + module_op!(inp(x, weight), opt(bias), E, |x, weight, bias| { + conv_transpose2d::(x, weight, bias, options).into() + }) + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + module_op!(inp(x), opt(), E, |x| { + #[cfg(feature = "simd")] + let x = match if ceil_mode { + // SIMD path doesn't support ceil_mode yet, skip it + Err(x) + } else { + try_avg_pool2d_simd(x, kernel_size, stride, padding, count_include_pad) + } { + Ok(out) => return out.into(), + Err(x) => x, + }; + avg_pool2d::( + x, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ) + .into() + }) + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + module_op!(inp(x, grad), opt(), E, |x, grad| avg_pool2d_backward::( + x, + grad, + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode + ) + .into()) + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + module_op!(inp(x), opt(), E, |x| { + #[cfg(feature = "simd")] + let x = match if ceil_mode { + // SIMD path doesn't support ceil_mode yet, skip it + Err(x) + } else { + try_max_pool2d_simd(x, kernel_size, stride, padding, dilation) + } { + Ok(out) => return out.into(), + Err(x) => x, + }; + max_pool2d::(x, kernel_size, stride, padding, dilation, ceil_mode).into() + }) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + execute_with_int_out_dtype!(indices_dtype, I, { + module_op!(inp(x), opt(), E, |x| { + let (output, indices) = max_pool2d_with_indices::( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ); + MaxPool2dWithIndices::new(output.into(), indices.into()) + }) + }) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: NdArrayTensor, + ) -> MaxPool2dBackward { + execute_with_int_dtype!(indices, IntElem, |idx_s: SharedArray| { + module_op!(inp(x, output_grad), opt(), E, |x, output_grad| { + let output = max_pool2d_backward::( + x, + kernel_size, + stride, + padding, + dilation, + ceil_mode, + output_grad, + idx_s, + ); + MaxPool2dBackward::new(output.into()) + }) + }) + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + module_op!(inp(x), opt(), E, |x| adaptive_avg_pool2d::( + x, + output_size + ) + .into()) + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + module_op!(inp(x, grad), opt(), E, |x, grad| { + adaptive_avg_pool2d_backward::(x, grad).into() + }) + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + match options.mode { + InterpolateMode::Nearest => { + module_op!(inp(x), opt(), E, |x| nearest_interpolate::( + x, + output_size + ) + .into()) + } + InterpolateMode::NearestExact => { + panic!("nearest exact interpolation is not supported for ndarray backend") + } + InterpolateMode::Bilinear => { + let align_corners = options.align_corners; + module_op!(inp(x), opt(), E, |x| bilinear_interpolate::( + x, + output_size, + align_corners + ) + .into()) + } + InterpolateMode::Bicubic => { + let align_corners = options.align_corners; + module_op!(inp(x), opt(), E, |x| bicubic_interpolate::( + x, + output_size, + align_corners + ) + .into()) + } + InterpolateMode::Lanczos3 => { + let align_corners = options.align_corners; + module_op!(inp(x), opt(), E, |x| lanczos3_interpolate::( + x, + output_size, + align_corners + ) + .into()) + } + } + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + match options.mode { + InterpolateMode::Nearest => module_op!(inp(x, grad), opt(), E, |x, grad| { + nearest_interpolate_backward::(x, grad, output_size).into() + }), + InterpolateMode::NearestExact => { + panic!("nearest exact interpolation backward is not supported for ndarray backend") + } + InterpolateMode::Bilinear => { + panic!("bilinear interpolation backward is not supported for ndarray backend") + } + InterpolateMode::Bicubic => { + panic!("bicubic interpolation backward is not supported for ndarray backend") + } + InterpolateMode::Lanczos3 => { + panic!("lanczos3 interpolation backward is not supported for ndarray backend") + } + } + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + module_op!(inp(x, weight), opt(bias), E, |x, weight, bias| conv3d::( + x, weight, bias, options + ) + .into()) + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + module_op!(inp(x, weight), opt(bias), E, |x, weight, bias| { + conv_transpose3d::(x, weight, bias, options).into() + }) + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + ) -> FloatTensor { + attention_fallback::(query, key, value, mask, attn_bias, options) + } + + fn rfft( + _signal: FloatTensor, + _dim: usize, + _n: Option, + ) -> (FloatTensor, FloatTensor) { + todo!("rfft is not supported for ndarray") + } + + fn irfft( + _spectrum_re: FloatTensor, + _spectrum_im: FloatTensor, + _dim: usize, + _n: Option, + ) -> FloatTensor { + todo!("irfft is not supported for ndarray") + } +} diff --git a/crates/burn-ndarray/src/ops/padding.rs b/crates/burn-ndarray/src/ops/padding.rs new file mode 100644 index 0000000..d9c6fd3 --- /dev/null +++ b/crates/burn-ndarray/src/ops/padding.rs @@ -0,0 +1,72 @@ +use crate::{NdArrayElement, SharedArray}; +use ndarray::{Array4, Array5}; + +use super::NdArrayOps; + +pub(crate) fn apply_padding_4d( + x: SharedArray, + padding: [usize; 2], + elem: E, +) -> SharedArray { + let [batch_size, input_channels, height, width] = x.shape().try_into().unwrap(); + let [padding_height, padding_width] = padding; + let padded_height = height + 2 * padding_height; + let padded_width = width + 2 * padding_width; + + let x_new = Array4::from_elem( + (batch_size, input_channels, padded_height, padded_width), + elem, + ); + let mut x_new = x_new.into_shared().into_dyn(); + + x_new = NdArrayOps::slice_assign( + x_new, + &[ + burn_backend::Slice::from(0..batch_size), + burn_backend::Slice::from(0..input_channels), + burn_backend::Slice::from(padding_height..height + padding_height), + burn_backend::Slice::from(padding_width..width + padding_width), + ], + x, + ); + + x_new +} + +pub(crate) fn apply_padding_5d( + x: SharedArray, + padding: [usize; 3], + elem: E, +) -> SharedArray { + let [batch_size, input_channels, depth, height, width] = x.shape().try_into().unwrap(); + let [padding_depth, padding_height, padding_width] = padding; + let padded_depth = depth + 2 * padding_depth; + let padded_height = height + 2 * padding_height; + let padded_width = width + 2 * padding_width; + + let x_new = Array5::from_elem( + ( + batch_size, + input_channels, + padded_depth, + padded_height, + padded_width, + ), + elem, + ); + let mut x_new = x_new.into_shared().into_dyn(); + + x_new = NdArrayOps::slice_assign( + x_new, + &[ + burn_backend::Slice::from(0..batch_size), + burn_backend::Slice::from(0..input_channels), + burn_backend::Slice::from(padding_depth..depth + padding_depth), + burn_backend::Slice::from(padding_height..height + padding_height), + burn_backend::Slice::from(padding_width..width + padding_width), + ], + x, + ); + + x_new +} diff --git a/crates/burn-ndarray/src/ops/qtensor.rs b/crates/burn-ndarray/src/ops/qtensor.rs new file mode 100644 index 0000000..543458b --- /dev/null +++ b/crates/burn-ndarray/src/ops/qtensor.rs @@ -0,0 +1,343 @@ +use alloc::{vec, vec::Vec}; + +use burn_backend::{ + DType, ExecutionError, Shape, TensorData, TensorMetadata, + ops::QTensorOps, + quantization::{ + QParams, QuantLevel, QuantMode, QuantScheme, QuantStore, QuantValue, + QuantizationParametersPrimitive, QuantizedBytes, + }, + tensor::{FloatTensor, IntTensor, QuantizedTensor}, +}; +use burn_std::{FloatDType, IntDType}; + +use crate::{ + NdArray, NdArrayDevice, NdArrayQTensor, NdArrayTensor, SharedArray, element::QuantElement, + execute_with_dtype, execute_with_int_dtype, execute_with_int_out_dtype, + execute_with_numeric_dtype, slice, +}; + +use super::quantization::{QuantizationStrategy, SymmetricQuantization}; +use super::{NdArrayMathOps, NdArrayOps}; + +impl QTensorOps for NdArray { + fn q_from_data(data: TensorData, _device: &NdArrayDevice) -> QuantizedTensor { + match data.dtype { + DType::QFloat(scheme) => { + let shape = data.shape.clone(); + let num_elements = data.num_elements(); + let q_bytes = QuantizedBytes { + bytes: data.into_bytes(), + scheme, + num_elements, + }; + + match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: QuantValue::Q8F | QuantValue::Q8S, + .. + } => { + // We can load QuantStore::U32 w/ QuantizedBytes impl + let (values, qparams) = q_bytes.into_vec_i8(); + let data = TensorData::new(values, shape); + // Overwrite storage + let scheme = scheme.with_store(QuantStore::Native); + + let qparams = qparams + .scales + .into_iter() + .map(|scales| QParams { scales }) + .collect(); + + NdArrayQTensor { + qtensor: NdArrayTensor::from_data(data), + scheme, + qparams, + } + } + QuantScheme { + value: + QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S + | QuantValue::E2M1 + | QuantValue::E4M3 + | QuantValue::E5M2, + .. + } => unimplemented!("from_data not supported for scheme {scheme:?}"), + } + } + _ => panic!( + "Invalid dtype (expected DType::QFloat, got {:?})", + data.dtype + ), + } + } + + fn quantize( + tensor: FloatTensor, + scheme: &QuantScheme, + qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + let shape = tensor.shape(); + let data_f = tensor.into_data(); + let scales = qparams.scales.into_data().convert::(); + + // Implement with ndarray instead of QuantizationStrategy? + let (data, qparams) = match scheme { + QuantScheme { + level: QuantLevel::Tensor, + mode: QuantMode::Symmetric, + #[cfg(not(feature = "export_tests"))] + value: QuantValue::Q8F | QuantValue::Q8S, + // For tests, "native" sub-byte quant serves as a reference for value equality. + // Values are stored as i8 regardless. + #[cfg(feature = "export_tests")] + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S, + store: QuantStore::Native, + .. + } => { + let scales = scales.iter().next().unwrap(); + let strategy = QuantizationStrategy::PerTensorSymmetric( + SymmetricQuantization::init(scales, scheme.value), + ); + let values = strategy.quantize(data_f.as_slice().unwrap()); + ( + TensorData::quantized(values, shape.clone(), *scheme, &[scales]), + vec![QParams { scales }], + ) + } + QuantScheme { + level: QuantLevel::Block(block_size), + mode: QuantMode::Symmetric, + #[cfg(not(feature = "export_tests"))] + value: QuantValue::Q8F | QuantValue::Q8S, + #[cfg(feature = "export_tests")] + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S, + store: QuantStore::Native, + .. + } => { + let scales = scales.as_slice().unwrap(); + let (strategy, qparams) = scales + .iter() + .map(|&s| { + ( + SymmetricQuantization::init(s, scheme.value), + QParams { scales: s }, + ) + }) + .unzip(); + let strategy = QuantizationStrategy::PerBlockSymmetric(strategy, *block_size); + let values = strategy.quantize(data_f.as_slice().unwrap()); + ( + TensorData::quantized(values, shape.clone(), *scheme, scales), + qparams, + ) + } + scheme => unimplemented!("Quantization not supported for scheme {scheme:?}"), + }; + + let num_elements = data.num_elements(); + let q_bytes = QuantizedBytes { + bytes: data.into_bytes(), + scheme: *scheme, + num_elements, + }; + let (values, _) = q_bytes.into_vec_i8(); + let data = TensorData::new(values, shape); + + NdArrayQTensor { + qtensor: NdArrayTensor::from_data(data), + scheme: *scheme, + qparams, + } + } + + fn dequantize(tensor: QuantizedTensor, dtype: FloatDType) -> FloatTensor { + let strategy = tensor.strategy(); + let scheme = tensor.scheme; + let shape = tensor.shape(); + let data = match tensor.qtensor { + NdArrayTensor::I8(storage) => { + let data = storage.into_shared().into_iter().collect(); + dequantize(data, shape, scheme, &strategy, dtype.into()) + } + _ => unreachable!(), + }; + NdArrayTensor::from_data(data) + } + + fn q_to_device( + tensor: QuantizedTensor, + _device: &NdArrayDevice, + ) -> QuantizedTensor { + tensor + } + + fn q_reshape(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + NdArrayQTensor { + qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::reshape(array, shape) + }), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + async fn q_into_data(tensor: QuantizedTensor) -> Result { + let shape = tensor.qtensor.shape(); + let scales = tensor.qparams.iter().map(|q| q.scales).collect::>(); + Ok(execute_with_numeric_dtype!( + tensor.qtensor, + E, + |array: SharedArray| { + let values = array.into_iter().collect(); + TensorData::quantized(values, shape, tensor.scheme, &scales) + } + )) + } + + fn q_swap_dims( + tensor: QuantizedTensor, + dim1: usize, + dim2: usize, + ) -> QuantizedTensor { + NdArrayQTensor { + qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::swap_dims(array, dim1, dim2) + }), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_permute(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + NdArrayQTensor { + qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::permute(array, axes) + }), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_flip(tensor: QuantizedTensor, axes: &[usize]) -> QuantizedTensor { + NdArrayQTensor { + qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::flip(array, axes) + }), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_gather( + dim: usize, + tensor: QuantizedTensor, + indices: IntTensor, + ) -> QuantizedTensor { + let qtensor = execute_with_int_dtype!(indices, IntElem, |idx_array: SharedArray< + IntElem, + >| + -> NdArrayTensor { + execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::gather(dim, array, idx_array) + }) + }); + NdArrayQTensor { + qtensor, + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_select( + tensor: QuantizedTensor, + dim: usize, + indices: IntTensor, + ) -> QuantizedTensor { + let qtensor = execute_with_int_dtype!(indices, IntElem, |idx_array: SharedArray< + IntElem, + >| + -> NdArrayTensor { + execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayMathOps::select(array, dim, idx_array) + }) + }); + NdArrayQTensor { + qtensor, + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_slice( + tensor: QuantizedTensor, + slices: &[burn_backend::Slice], + ) -> QuantizedTensor { + NdArrayQTensor { + qtensor: slice!(tensor.qtensor, slices), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } + + fn q_argmax(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + execute_with_int_out_dtype!(out_dtype, I, { + execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayMathOps::argmax::(array, dim) + }) + }) + } + + fn q_argmin(tensor: QuantizedTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + execute_with_int_out_dtype!(out_dtype, I, { + execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayMathOps::argmin::(array, dim) + }) + }) + } + + fn q_expand(tensor: QuantizedTensor, shape: Shape) -> QuantizedTensor { + NdArrayQTensor { + qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray| { + NdArrayOps::expand(array, shape) + }), + scheme: tensor.scheme, + qparams: tensor.qparams, + } + } +} + +fn dequantize( + data: Vec, + shape: Shape, + scheme: QuantScheme, + strategy: &QuantizationStrategy, + dtype: DType, +) -> TensorData { + let qparams = match strategy { + QuantizationStrategy::PerTensorSymmetric(quant) => vec![quant.scale], + QuantizationStrategy::PerBlockSymmetric(quant, _block_size) => { + quant.iter().map(|q| q.scale).collect() + } + }; + let q_bytes = QuantizedBytes::new(data, scheme, &qparams); + let (values, _qparams) = q_bytes.into_vec_i8(); + TensorData::new(strategy.dequantize(&values), shape).convert_dtype(dtype) +} diff --git a/crates/burn-ndarray/src/ops/quantization.rs b/crates/burn-ndarray/src/ops/quantization.rs new file mode 100644 index 0000000..adaf1b1 --- /dev/null +++ b/crates/burn-ndarray/src/ops/quantization.rs @@ -0,0 +1,218 @@ +use alloc::vec::Vec; +use num_traits::{Float, PrimInt}; + +use burn_backend::quantization::{BlockSize, QuantValue}; + +// NOTE: this mainly serves as a simple reference implementation. +// The de/quantization ops should be refactored to use ndarray. + +/// Quantization strategy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuantizationStrategy { + /// Per-tensor symmetric quantization. + PerTensorSymmetric(SymmetricQuantization), + /// Per-block symmetric quantization. + PerBlockSymmetric(Vec>, BlockSize), +} + +impl QuantizationStrategy { + /// Quantize the values to a lower precision data type. + pub fn quantize(&self, values: &[f32]) -> Vec { + match self { + QuantizationStrategy::PerTensorSymmetric(strategy) => strategy.quantize(values), + QuantizationStrategy::PerBlockSymmetric(strategy, block_size) => { + let block_elems = block_size.num_elements(); + let num_blocks = strategy.len(); + let numel = values.len(); + assert_eq!( + numel / block_elems, + num_blocks, + "Invalid per-block quantization with num blocks {num_blocks} and {numel} values" + ); + values + .chunks(block_elems) + .enumerate() + .flat_map(|(block_id, block)| strategy[block_id].quantize(block)) + .collect() + } + } + } + + /// Dequantize the values to a higher precision data type. + pub fn dequantize(&self, values: &[i8]) -> Vec { + match self { + QuantizationStrategy::PerTensorSymmetric(strategy) => strategy.dequantize(values), + QuantizationStrategy::PerBlockSymmetric(strategy, block_size) => { + let block_elems = block_size.num_elements(); + let num_blocks = strategy.len(); + let numel = values.len(); + assert_eq!( + numel / block_elems, + num_blocks, + "Invalid per-block quantization with block size {block_elems}, num blocks {num_blocks} and {numel} values" + ); + values + .chunks(block_elems) + .enumerate() + .flat_map(|(block_id, block)| strategy[block_id].dequantize(block)) + .collect() + } + } + } +} + +/// Quantization scheme to convert elements of a higher precision data type `E` to a lower precision +/// data type `Q` and vice-versa. +pub trait Quantization { + /// Returns the quantization range `[a, b]`. + fn range(&self) -> (E, E); + /// Convert the values to a lower precision data type. + fn quantize(&self, values: &[E]) -> Vec; + /// Convert a single value to a lower precision data type. + fn quantize_one(&self, value: E) -> Q; + /// Convert the values back to a higher precision data type. + fn dequantize(&self, values: &[Q]) -> Vec; + /// Convert a single value back to a higher precision data type. + fn dequantize_one(&self, value: Q) -> E; +} + +fn valid_scale(mut scale: E) -> E { + // If scale is 0 (most likely due to a tensor full of zeros), we arbitrarily adjust the + // scale to 0.1 to avoid division by zero. + if scale.eq(&E::zero()) { + scale = E::from(0.1).unwrap(); + } + scale +} + +/// Symmetric quantization scheme. +#[derive(Debug, Clone, Copy)] +pub struct SymmetricQuantization { + /// The scaling factor. + pub scale: E, + // The quantization value data type. + value: QuantValue, +} + +impl SymmetricQuantization { + /// Initialize a symmetric quantization scheme with the given parameters. + pub fn init(scale: E, value: QuantValue) -> Self { + Self { + scale: valid_scale(scale), + value, + } + } + + #[allow(dead_code)] + /// Create a new quantization scheme for an input range `[alpha, beta]`. + fn new(alpha: E, beta: E, value: QuantValue) -> Self { + let (a, b) = value.range(); + let a = E::from(a).unwrap(); + let b = E::from(b).unwrap(); + + // Compute scale to convert a floating point value in range `[-alpha, alpha]` to the quantized range + let alpha = alpha.abs().max(beta.abs()); + let scale = valid_scale((alpha + alpha) / (b - a)); + Self { scale, value } + } +} + +impl Quantization for SymmetricQuantization { + fn quantize(&self, values: &[E]) -> Vec { + values.iter().map(|x| self.quantize_one(*x)).collect() + } + + fn dequantize(&self, values: &[Q]) -> Vec { + values.iter().map(|x_q| self.dequantize_one(*x_q)).collect() + } + + fn quantize_one(&self, value: E) -> Q { + let (a, b) = self.range(); + + // x_q = clamp(round(x / scale), a, b) + Q::from(value.div(self.scale).round().clamp(a, b)).unwrap() + } + + fn dequantize_one(&self, value: Q) -> E { + // x = scale * x_q + self.scale * E::from(value).unwrap() + } + + fn range(&self) -> (E, E) { + let (a, b) = self.value.range(); + let a = E::from(a).unwrap(); + let b = E::from(b).unwrap(); + (a, b) + } +} + +impl PartialEq for SymmetricQuantization { + fn eq(&self, other: &Self) -> bool { + self.scale == other.scale + } +} + +impl Eq for SymmetricQuantization {} + +#[cfg(test)] +mod tests { + use burn_backend::TensorData; + + use super::*; + use alloc::vec; + + #[test] + fn test_int8_symmetric_quantization() { + let x: [f32; 4] = [-1.8, -1.0, 0.0, 0.5]; + let expected_q = vec![-127, -71, 0, 35]; + let expected_d = vec![-1.8, -1.0062993, 0.0, 0.496063]; + + let symmetric = SymmetricQuantization::::new(-1.8, 0.5, QuantValue::Q8S); + + let q: Vec = symmetric.quantize(&x); + assert_eq!(q, expected_q); + + let d = symmetric.dequantize(&expected_q); + + assert_eq!(d, expected_d); + } + + #[test] + fn test_int8_symmetric_quantization_per_block() { + let x: [f32; 8] = [-1.8, -1.0, 0.0, 0.5, -1.8, -1.0, 0.0, 0.5]; + let expected_q = vec![-127, -71, 0, 35, -127, -71, 0, 35]; + let expected_d = vec![ + -1.8, -1.0062993, 0.0, 0.496063, -1.8, -1.0062993, 0.0, 0.496063, + ]; + + let symmetric = SymmetricQuantization::::new(-1.8, 0.5, QuantValue::Q8S); + let strategy = QuantizationStrategy::PerBlockSymmetric( + vec![symmetric, symmetric], + BlockSize::new([4]), + ); + + let q: Vec = strategy.quantize(&x); + assert_eq!(q, expected_q); + + let d = symmetric.dequantize(&expected_q); + + assert_eq!(d, expected_d); + } + + #[test] + fn should_support_dequantize() { + let strategy = QuantizationStrategy::PerTensorSymmetric(SymmetricQuantization { + scale: 0.1, + value: QuantValue::Q8S, + }); + + let output = strategy.dequantize(&[-127i8, -77, -26, 25, 76, 127]); + + let output = TensorData::new(output, [2, 3]); + + output.assert_approx_eq::( + &TensorData::from([[-12.7, -7.7, -2.6], [2.5, 7.6, 12.7]]), + Default::default(), + ); + } +} diff --git a/crates/burn-ndarray/src/ops/simd/avgpool.rs b/crates/burn-ndarray/src/ops/simd/avgpool.rs new file mode 100644 index 0000000..41d5ba6 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/avgpool.rs @@ -0,0 +1,443 @@ +use core::{marker::PhantomData, mem::transmute}; + +use crate::{SharedArray, iter_range_par, run_par, sharing::UnsafeSharedRef}; + +use burn_backend::DType; +use burn_backend::{Element, ElementConversion}; +use bytemuck::Zeroable; +use macerator::{Simd, VAdd, VDiv}; +use ndarray::{Array4, s}; +use nhwc::avg_pool_nhwc; + +use super::should_use_simd; + +#[macerator::with_simd] +fn is_accelerated(_x: PhantomData) -> bool { + ::is_accelerated::() && ::is_accelerated::() +} + +pub(crate) fn try_avg_pool2d_simd( + x: SharedArray, + ksize: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + with_pad: bool, +) -> Result, SharedArray> { + // Strides must be unit, dilation isn't supported, rows must be contiguous + if x.strides()[1] != 1 || !should_use_simd(x.shape()[1]) { + return Err(x); + } + + match E::dtype() { + DType::F64 if is_accelerated::(PhantomData) => Ok(cast(avg_pool_nhwc::( + cast(x), + ksize, + stride, + padding, + with_pad, + ))), + DType::F32 if is_accelerated::(PhantomData) => Ok(cast(avg_pool_nhwc::( + cast(x), + ksize, + stride, + padding, + with_pad, + ))), + _ => Err(x), + } +} + +fn cast(tensor: SharedArray) -> SharedArray { + unsafe { transmute::, SharedArray>(tensor) } +} + +mod nhwc { + use itertools::Itertools; + use macerator::{Simd, Vector, vload_unaligned, vstore_unaligned}; + use ndarray::{ArrayView3, ArrayViewMut3}; + use seq_macro::seq; + + use crate::ops::simd::lanes; + + use super::*; + + // Until you can use associated constants as array size, we need to hardcode this. + // The most common config (x86-v3) has 16 registers, so use half of them for accumulators. + const BLOCK_REGISTERS: usize = 8; + + pub(crate) fn avg_pool_nhwc( + x: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + with_pad: bool, + ) -> SharedArray { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + let [batch_size, channels, x_height, x_width] = x.shape().try_into().unwrap(); + let lanes = lanes::(); + + let ch_block = lanes * BLOCK_REGISTERS; + + let out_height = ((x_height + 2 * pad_h - (kernel_height - 1) - 1) / stride_height) + 1; + let out_width = ((x_width + 2 * pad_w - (kernel_width - 1) - 1) / stride_width) + 1; + + let mut output = unsafe { + Array4::::uninit((batch_size, out_height, out_width, channels)).assume_init() + }; + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + let x = x.view(); + let x = x.permuted_axes(vec![0, 2, 3, 1]); + + // Floor division ensures `blocks * lanes * blocking factor` is always `<= out_channels`. + // An exclusive loop will always have `lanes * blocking factor` elements in bounds. + let blocks = channels / ch_block; + let blocks_end = blocks * ch_block; + // Floor division means simd_end is always divisible by `lanes` and `<= out_channels`. An + // exclusive loop will always have `lanes` elements in bounds. + let simd_end = channels / lanes * lanes; + let num_simd_unblocked = (simd_end - blocks_end) / lanes; + let remainder = channels - simd_end; + + run_par!(|| { + // SAFETY: Loop ranges are non-overlapping, so the unsafe shared reference is safe. + iter_range_par!(0, batch_size * blocks).for_each(|k| unsafe { + let block = k % blocks; + let b = k / blocks; + + let output = unsafe_shared_out.get(); + + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + + loop_blocked(x, out, kernel_size, stride, padding, with_pad, block); + }); + // SAFETY: See `loop_unblocked` + iter_range_par!(0, batch_size * num_simd_unblocked).for_each(|k| unsafe { + let ch = (k % num_simd_unblocked) * lanes + blocks_end; + let b = k / num_simd_unblocked; + + let output = unsafe_shared_out.get(); + + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + + loop_unblocked(x, out, kernel_size, stride, padding, with_pad, ch); + }); + // SAFETY: Loop ranges are non-overlapping, so the unsafe shared reference is safe. + iter_range_par!(0, batch_size * remainder).for_each(|k| unsafe { + let ch = (k % remainder) + simd_end; + let b = k / remainder; + + let output = unsafe_shared_out.get(); + + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + + loop_scalar(x, out, kernel_size, stride, padding, with_pad, ch); + }); + }); + + output = output.permuted_axes([0, 3, 1, 2]); + + output.into_dyn().into_shared() + } + + /// Execute the blocked (unrolled) portion of the pool. + #[allow( + clippy::too_many_arguments, + clippy::erasing_op, + clippy::identity_op, + unused_mut + )] + #[macerator::with_simd] + fn loop_blocked<'a, S: Simd, E: Element + VAdd + VDiv>( + x: ArrayView3<'a, E>, + mut out: ArrayViewMut3<'a, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + with_pad: bool, + block: usize, + ) where + 'a: 'a, + { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + let lanes = E::lanes::(); + + let ch_block = lanes * BLOCK_REGISTERS; + + // If pixels are more than `padding` from the edges, the in pixel cannot be out of bounds + for oh in pad_h..out_height.saturating_sub(pad_h) { + for ow in pad_w..out_width.saturating_sub(pad_w) { + seq!(N in 0..8 { + let mut sum~N: Vector = Zeroable::zeroed(); + }); + let ch = block * ch_block; + let ch_end = ch + ch_block; + let mut out = out.slice_mut(s![oh, ow, ch..ch_end]); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw - pad_w; + let x = x.slice(s![ih, iw, ch..ch_end]); + + seq!(N in 0..8 { + // SAFETY: + // Load a full vector from x[N * lanes]. This is bounds checked by the + // slice above. + sum~N += unsafe { vload_unaligned(&x[N * lanes]) }; + }); + } + } + + let count = kernel_height * kernel_width; + let count = (count as u64).elem::(); + let count_v = count.splat(); + seq!(N in 0..8 { + let s~N = sum~N / count_v; + // SAFETY: + // Store a full vector to out[N * lanes]. This is bounds checked by the + // slice above. + unsafe { vstore_unaligned(&mut out[N * lanes], s~N) }; + }); + } + } + + // Border pixels need bounds checks + if (pad_h, pad_w) != (0, 0) { + let v_borders = (0..pad_h) + .chain(out_height.saturating_sub(pad_h)..out_height) + .cartesian_product(0..out_width); + let h_borders = (0..out_height) + .cartesian_product((0..pad_w).chain(out_width.saturating_sub(pad_w)..out_width)); + + for (oh, ow) in v_borders.chain(h_borders) { + seq!(N in 0..8 { + let mut sum~N: Vector = Zeroable::zeroed(); + }); + let mut count: usize = 0; + let ch = block * ch_block; + let ch_end = ch + ch_block; + let mut out = out.slice_mut(s![oh, ow, ch..ch_end]); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + count += 1; + + let x = x.slice(s![ih, iw, ch..ch_end]); + + seq!(N in 0..8 { + // SAFETY: + // Load a full vector from x[N * lanes]. This is bounds checked by the + // slice above. + sum~N += unsafe { vload_unaligned(&x[N * lanes]) }; + }); + } + } + + if with_pad { + count = kernel_height * kernel_width; + } + + let count = (count as u64).elem::(); + let count_v = count.splat(); + seq!(N in 0..8 { + let s~N = sum~N / count_v; + // SAFETY: + // Store a full vector to out[N * lanes]. This is bounds checked by the + // slice above. + unsafe { vstore_unaligned(&mut out[N * lanes], s~N) }; + }); + } + } + } + + /// Execute the unblocked (not unrolled) portion of the pool. + /// + /// SAFETY: Safe as long as `ch + simd_lanes <= out_channels`. + #[allow(clippy::too_many_arguments, unused_mut)] + #[macerator::with_simd] + unsafe fn loop_unblocked<'a, S: Simd, E: Element + VAdd + VDiv>( + x: ArrayView3<'a, E>, + mut out: ArrayViewMut3<'a, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + with_pad: bool, + ch: usize, + ) where + 'a: 'a, + { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + + // If pixels are not within padding range, bounds checks are always true + for oh in pad_h..out_height - pad_h { + for ow in pad_w..out_width - pad_w { + let mut sum: Vector = Zeroable::zeroed(); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw - pad_w; + // Load a full vector from `x`. In bounds as long as `out_channels >= ch + lanes` + let s0 = unsafe { vload_unaligned(&x[[ih, iw, ch]]) }; + sum += s0; + } + } + + let count = kernel_height * kernel_width; + let count: E = (count as u64).elem(); + let count_v = count.splat(); + let s0 = sum / count_v; + // Store a full vector to `out`. In bounds as long as `out_channels >= ch + lanes`. + unsafe { vstore_unaligned(&mut out[[oh, ow, ch]], s0) }; + } + } + + // Border pixels need bounds checks + if (pad_h, pad_w) != (0, 0) { + let v_borders = (0..pad_h) + .chain(out_height.saturating_sub(pad_h)..out_height) + .cartesian_product(0..out_width); + let h_borders = (0..out_height) + .cartesian_product((0..pad_w).chain(out_width.saturating_sub(pad_w)..out_width)); + + for (oh, ow) in v_borders.chain(h_borders) { + let mut sum: Vector = Zeroable::zeroed(); + let mut count: usize = 0; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + count += 1; + + // Load a full vector from `x`. In bounds as long as `out_channels >= ch + lanes` + sum += unsafe { vload_unaligned(&x[[ih, iw, ch]]) }; + } + } + + if with_pad { + count = kernel_height * kernel_width; + } + + let count = (count as u64).elem::(); + let count_v = count.splat(); + let s0 = sum / count_v; + // Store a full vector to `out`. In bounds as long as `out_channels >= ch + lanes`. + unsafe { vstore_unaligned(&mut out[[oh, ow, ch]], s0) }; + } + } + } + + /// Execute scalar portion of the pooling + #[allow(clippy::too_many_arguments)] + fn loop_scalar( + x: ArrayView3<'_, E>, + mut out: ArrayViewMut3<'_, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + with_pad: bool, + ch: usize, + ) { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + + // If pixels are not within padding range, bounds checks are always true + for oh in pad_h..out_height.saturating_sub(pad_h) { + for ow in pad_w..out_width.saturating_sub(pad_w) { + let mut sum: E = Zeroable::zeroed(); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw - pad_w; + sum = sum + x[[ih, iw, ch]]; + } + } + + let count = (kernel_height * kernel_width) as u64; + out[[oh, ow, ch]] = sum / count.elem(); + } + } + + // Border pixels need bounds checks + if (pad_h, pad_w) != (0, 0) { + let v_borders = (0..pad_h) + .chain(out_height.saturating_sub(pad_h)..out_height) + .cartesian_product(0..out_width); + let h_borders = (0..out_height) + .cartesian_product((0..pad_w).chain(out_width.saturating_sub(pad_w)..out_width)); + + for (oh, ow) in v_borders.chain(h_borders) { + let mut sum: E = Zeroable::zeroed(); + let mut count: usize = 0; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + count += 1; + sum = sum + x[[ih, iw, ch]]; + } + } + + if with_pad { + count = kernel_height * kernel_width; + } + + out[[oh, ow, ch]] = sum / (count as u64).elem(); + } + } + } +} diff --git a/crates/burn-ndarray/src/ops/simd/base.rs b/crates/burn-ndarray/src/ops/simd/base.rs new file mode 100644 index 0000000..005316f --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/base.rs @@ -0,0 +1,115 @@ +use core::{marker::PhantomData, mem::MaybeUninit}; + +use macerator::{Arch, Scalar, Simd}; +use ndarray::{ArcArray, ArrayD, IxDyn, ShapeBuilder}; + +/// Whether SIMD instructions are worth using +#[cfg(all( + any( + target_arch = "x86", + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "wasm32", + target_arch = "loongarch64" + ), + not(test) +))] +pub fn should_use_simd(len: usize) -> bool { + len >= 32 +} + +/// Whether SIMD instructions are worth using +#[cfg(all( + not(any( + target_arch = "x86", + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "wasm32", + target_arch = "loongarch64" + )), + not(test) +))] +pub fn should_use_simd(_len: usize) -> bool { + false +} + +#[cfg(test)] +pub fn should_use_simd(_len: usize) -> bool { + true +} + +pub(crate) fn lanes() -> usize { + #[allow(non_camel_case_types)] + struct lanes<__T0>(__T0); + + impl ::macerator::WithSimd for lanes> { + type Output = usize; + #[inline(always)] + fn with_simd<__S: ::macerator::Simd>(self) -> ::Output { + let Self(__ty) = self; + #[allow(unused_unsafe)] + unsafe { + lanes_simd::<__S, E>(__ty) + } + } + } + (Arch::new()).dispatch(lanes(PhantomData::)) +} + +fn lanes_simd(_ty: PhantomData) -> usize { + E::lanes::() +} + +pub(crate) fn uninit_array_like(reference: &ArcArray) -> ArrayD { + let shape = reference.raw_dim(); + let strides = reference.strides(); + let strides = strides.iter().map(|it| *it as usize).collect::>(); + let shape_strides = shape.strides(IxDyn(&strides)); + let size = reference.len(); + let mut out_data: Vec> = Vec::with_capacity(size); + unsafe { out_data.set_len(size) }; + unsafe { ArrayD::from_shape_vec_unchecked(shape_strides, out_data).assume_init() } +} + +pub trait MinMax { + fn min(self, other: Self) -> Self; + fn max(self, other: Self) -> Self; +} + +macro_rules! impl_minmax { + ($ty: ty) => { + impl MinMax for $ty { + fn min(self, other: Self) -> Self { + Ord::min(self, other) + } + fn max(self, other: Self) -> Self { + Ord::max(self, other) + } + } + }; + ($($ty: ty),*) => { + $(impl_minmax!($ty);)* + } +} + +impl_minmax!(u8, i8, u16, i16, u32, i32, u64, i64); + +impl MinMax for f32 { + fn min(self, other: Self) -> Self { + self.min(other) + } + + fn max(self, other: Self) -> Self { + self.max(other) + } +} + +impl MinMax for f64 { + fn min(self, other: Self) -> Self { + self.min(other) + } + + fn max(self, other: Self) -> Self { + self.max(other) + } +} diff --git a/crates/burn-ndarray/src/ops/simd/binary.rs b/crates/burn-ndarray/src/ops/simd/binary.rs new file mode 100644 index 0000000..dae3ed5 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/binary.rs @@ -0,0 +1,299 @@ +use core::{marker::PhantomData, slice}; + +use burn_backend::Element; +use macerator::{ + Scalar, Simd, VAdd, VBitAnd, VBitOr, VBitXor, VDiv, VMul, VOrd, VSub, Vector, vload_unaligned, + vstore_unaligned, +}; +use ndarray::ArrayD; +use seq_macro::seq; + +use crate::{NdArrayElement, SharedArray, ops::simd::uninit_array_like}; + +use super::{ + MinMax, + binary_elemwise::{ + VecAdd, VecBitAnd, VecBitOr, VecBitXor, VecDiv, VecMax, VecMin, VecMul, VecSub, + }, + should_use_simd, +}; + +pub trait SimdBinop { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector; + fn apply(lhs: T, rhs: T) -> Out; + fn is_accelerated() -> bool; +} + +impl SimdBinop for VecAdd { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs + rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs + rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecDiv { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs / rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs / rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecMul { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs * rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs * rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecSub { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs - rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs - rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecMin { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs.min(rhs) + } + + fn apply(lhs: T, rhs: T) -> T { + MinMax::min(lhs, rhs) + } + + fn is_accelerated() -> bool { + ::is_min_max_accelerated::() + } +} + +impl SimdBinop for VecMax { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs.max(rhs) + } + + fn apply(lhs: T, rhs: T) -> T { + MinMax::max(lhs, rhs) + } + + fn is_accelerated() -> bool { + ::is_min_max_accelerated::() + } +} + +impl SimdBinop for VecBitAnd { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs & rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs.bitand(rhs) + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecBitOr { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs | rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs.bitor(rhs) + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl SimdBinop for VecBitXor { + fn apply_vec(lhs: Vector, rhs: Vector) -> Vector { + lhs ^ rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs.bitxor(rhs) + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +#[macerator::with_simd] +fn is_accelerated>( + _x: PhantomData<(T, Out, Op)>, +) -> bool { + Op::is_accelerated::() +} + +#[allow(clippy::result_large_err)] +pub fn try_binary_simd< + E: Element, + EOut: Element, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdBinop, +>( + lhs: SharedArray, + rhs: SharedArray, +) -> Result, (SharedArray, SharedArray)> { + let lhs_len = lhs.len(); + let rhs_len = rhs.len(); + if !should_use_simd(lhs_len.max(rhs_len)) + || !lhs.is_standard_layout() + || !rhs.is_standard_layout() + || lhs.shape() != rhs.shape() + || !is_accelerated::(PhantomData) + { + return Err((lhs, rhs)); + } + // Used to assert traits based on the dynamic `DType`. + let lhs = unsafe { core::mem::transmute::, SharedArray>(lhs) }; + let rhs = unsafe { core::mem::transmute::, SharedArray>(rhs) }; + let out = binary_simd_same::(lhs, rhs); + + // Used to assert traits based on the dynamic `DType`. + let out = unsafe { core::mem::transmute::, SharedArray>(out) }; + Ok(out) +} + +fn binary_simd_same< + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdBinop, +>( + lhs: SharedArray, + rhs: SharedArray, +) -> SharedArray { + let out = if lhs.is_unique() { + let mut buf = lhs.into_owned(); + let lhs = buf.as_slice_mut().unwrap(); + let rhs = rhs.as_slice().unwrap(); + let out = + unsafe { core::mem::transmute::<&mut [T], &mut [Out]>(unsafe_alias_slice_mut(lhs)) }; + binary(lhs, rhs, out, PhantomData::); + unsafe { core::mem::transmute::, ArrayD>(buf) } + } else if rhs.is_unique() { + let mut buf = rhs.into_owned(); + let lhs = lhs.as_slice().unwrap(); + let rhs = buf.as_slice_mut().unwrap(); + let out = + unsafe { core::mem::transmute::<&mut [T], &mut [Out]>(unsafe_alias_slice_mut(rhs)) }; + binary(lhs, rhs, out, PhantomData::); + unsafe { core::mem::transmute::, ArrayD>(buf) } + } else { + let mut out = uninit_array_like(&lhs); + let lhs = lhs.as_slice().unwrap(); + let rhs = rhs.as_slice().unwrap(); + let out_slice = out.as_slice_mut().unwrap(); + binary(lhs, rhs, out_slice, PhantomData::); + out + }; + out.into_shared() +} + +#[allow(clippy::erasing_op, clippy::identity_op)] +#[macerator::with_simd] +fn binary< + 'a, + S: Simd, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdBinop, +>( + lhs: &'a [T], + rhs: &'a [T], + out: &'a mut [Out], + _op: PhantomData, +) where + 'a: 'a, +{ + let lanes = T::lanes::(); + let mut chunks_lhs = lhs.chunks_exact(8 * lanes); + let mut chunks_rhs = rhs.chunks_exact(8 * lanes); + let mut chunks_out = out.chunks_exact_mut(8 * lanes); + while let Some(((lhs, rhs), out)) = chunks_lhs + .next() + .zip(chunks_rhs.next()) + .zip(chunks_out.next()) + { + seq!(N in 0..8 { + // Load one full vector from `lhs`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let lhs~N = unsafe { vload_unaligned::(&lhs[N * lanes]) }; + // Load one full vector from `rhs`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let rhs~N = unsafe { vload_unaligned(&rhs[N * lanes]) }; + let s~N = Op::apply_vec(lhs~N, rhs~N); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + unsafe { vstore_unaligned(&mut out[N * lanes], s~N) }; + }); + } + let mut chunks_lhs = chunks_lhs.remainder().chunks_exact(lanes); + let mut chunks_rhs = chunks_rhs.remainder().chunks_exact(lanes); + let mut chunks_out = chunks_out.into_remainder().chunks_exact_mut(lanes); + while let Some(((lhs, rhs), out)) = chunks_lhs + .next() + .zip(chunks_rhs.next()) + .zip(chunks_out.next()) + { + // Load one full vector from `lhs`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let lhs0 = unsafe { vload_unaligned::(lhs.as_ptr()) }; + // Load one full vector from `rhs`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let rhs0 = unsafe { vload_unaligned(rhs.as_ptr()) }; + let s0 = Op::apply_vec(lhs0, rhs0); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + unsafe { vstore_unaligned(out.as_mut_ptr(), s0) }; + } + + for ((lhs, rhs), out) in chunks_lhs + .remainder() + .iter() + .zip(chunks_rhs.remainder()) + .zip(chunks_out.into_remainder()) + { + *out = Op::apply(*lhs, *rhs) + } +} + +/// Unsafely alias a slice to use as an inline argument +fn unsafe_alias_slice_mut<'a, T>(slice: &mut [T]) -> &'a mut [T] { + let ptr = slice.as_mut_ptr(); + let len = slice.len(); + unsafe { slice::from_raw_parts_mut(ptr, len) } +} diff --git a/crates/burn-ndarray/src/ops/simd/binary_elemwise.rs b/crates/burn-ndarray/src/ops/simd/binary_elemwise.rs new file mode 100644 index 0000000..7534da5 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/binary_elemwise.rs @@ -0,0 +1,419 @@ +use core::marker::PhantomData; + +use bytemuck::cast; +use macerator::{ + Scalar, Simd, VAdd, VBitAnd, VBitOr, VBitXor, VDiv, VMul, VOrd, VSub, Vector, vload, + vload_unaligned, vstore, vstore_unaligned, +}; +use ndarray::ArrayD; +use seq_macro::seq; + +use crate::{NdArrayElement, SharedArray, ops::simd::uninit_array_like}; + +use super::{MinMax, should_use_simd}; + +pub trait ScalarSimdBinop { + type Rhs: Copy; + type RhsVec: Copy; + fn splat(rhs: Self::Rhs) -> Self::RhsVec; + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector; + fn apply(lhs: T, rhs: Self::Rhs) -> Out; + fn is_accelerated() -> bool; +} + +pub struct VecAdd; +pub struct VecDiv; +pub struct VecMul; +pub struct VecSub; +pub struct VecMin; +pub struct VecMax; +pub struct VecClamp; +pub struct VecBitAnd; +pub struct VecBitOr; +pub struct VecBitXor; + +impl ScalarSimdBinop for VecAdd { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs + rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs + rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecDiv { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs / rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs / rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecMul { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs * rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs * rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecSub { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs - rhs + } + + fn apply(lhs: T, rhs: T) -> T { + lhs - rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecMin { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs.min(rhs) + } + + fn apply(lhs: T, rhs: T) -> T { + lhs.min(rhs) + } + + fn is_accelerated() -> bool { + ::is_min_max_accelerated::() + } +} + +impl ScalarSimdBinop for VecMax { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs.max(rhs) + } + + fn apply(lhs: T, rhs: T) -> T { + lhs.max(rhs) + } + + fn is_accelerated() -> bool { + ::is_min_max_accelerated::() + } +} + +impl ScalarSimdBinop for VecClamp { + type Rhs = (T, T); + type RhsVec = (Vector, Vector); + + fn splat((min, max): Self::Rhs) -> Self::RhsVec { + (min.splat(), max.splat()) + } + + fn apply_vec(lhs: Vector, (min, max): Self::RhsVec) -> Vector { + lhs.min(max).max(min) + } + + fn apply(lhs: T, (min, max): Self::Rhs) -> T { + lhs.min(max).max(min) + } + + fn is_accelerated() -> bool { + ::is_min_max_accelerated::() + } +} + +impl ScalarSimdBinop for VecBitAnd { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs & rhs + } + + fn apply(lhs: T, rhs: Self::Rhs) -> T { + lhs & rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecBitOr { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs | rhs + } + + fn apply(lhs: T, rhs: Self::Rhs) -> T { + lhs | rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +impl ScalarSimdBinop for VecBitXor { + type Rhs = T; + type RhsVec = Vector; + + fn splat(rhs: Self::Rhs) -> Self::RhsVec { + rhs.splat() + } + + fn apply_vec(lhs: Vector, rhs: Self::RhsVec) -> Vector { + lhs ^ rhs + } + + fn apply(lhs: T, rhs: Self::Rhs) -> T { + lhs ^ rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +#[macerator::with_simd] +fn is_accelerated>( + _x: PhantomData<(T, Out, Op)>, +) -> bool { + Op::is_accelerated::() +} + +pub fn try_binary_scalar_simd< + E: NdArrayElement, + EOut: NdArrayElement, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: ScalarSimdBinop, +>( + input: SharedArray, + elem: Op::Rhs, +) -> Result, SharedArray> { + if !should_use_simd(input.len()) + || input.as_slice_memory_order().is_none() + || !is_accelerated::(PhantomData) + { + return Err(input); + } + // Used to assert traits based on the dynamic `DType`. + let input = unsafe { core::mem::transmute::, SharedArray>(input) }; + let out = if size_of::() == size_of::() + && align_of::() >= align_of::() + && input.is_unique() + { + unsafe { binary_scalar_simd_inplace::(input, elem) } + } else { + binary_scalar_simd_owned::(input, elem) + }; + // Used to assert traits based on the dynamic `DType`. + let out = unsafe { core::mem::transmute::, SharedArray>(out) }; + Ok(out) +} + +/// Execute operation in place on an owned tensor +/// SAFETY: +/// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. +unsafe fn binary_scalar_simd_inplace< + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: ScalarSimdBinop, +>( + input: SharedArray, + elem: Op::Rhs, +) -> SharedArray { + let mut buffer = input.into_owned(); + let slice = buffer.as_slice_memory_order_mut().unwrap(); + unsafe { binary_scalar_slice_inplace::(slice, elem, PhantomData) }; + // Buffer has the same elem size and is filled with the operation output, so this is safe + let out = unsafe { core::mem::transmute::, ArrayD>(buffer) }; + out.into_shared() +} + +/// Create a new copy of the tensor as the output +fn binary_scalar_simd_owned< + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: ScalarSimdBinop, +>( + input: SharedArray, + elem: Op::Rhs, +) -> SharedArray { + let mut out = uninit_array_like(&input); + let input = input.as_slice_memory_order().unwrap(); + let out_slice = out.as_slice_memory_order_mut().unwrap(); + binary_scalar_slice::(input, out_slice, elem, PhantomData); + out.into_shared() +} + +#[inline(always)] +#[allow(clippy::erasing_op, clippy::identity_op)] +#[macerator::with_simd] +fn binary_scalar_slice< + 'a, + S: Simd, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: ScalarSimdBinop, +>( + input: &'a [T], + out: &'a mut [Out], + rhs: Op::Rhs, + _op: PhantomData, +) where + 'a: 'a, +{ + let lanes = T::lanes::(); + let mut chunks_input = input.chunks_exact(8 * lanes); + let mut chunks_out = out.chunks_exact_mut(8 * lanes); + let rhs_vec = Op::splat::(rhs); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + seq!(N in 0..8 { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let s~N = unsafe { vload_unaligned(&input[N * lanes]) }; + let s~N = Op::apply_vec(s~N, rhs_vec); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + unsafe { vstore_unaligned(&mut out[N * lanes], s~N) }; + }); + } + let mut chunks_input = chunks_input.remainder().chunks_exact(lanes); + let mut chunks_out = chunks_out.into_remainder().chunks_exact_mut(lanes); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let s0 = unsafe { vload_unaligned(input.as_ptr()) }; + let s0 = Op::apply_vec(s0, rhs_vec); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + unsafe { vstore_unaligned(out.as_mut_ptr(), s0) }; + } + + for (input, out) in chunks_input + .remainder() + .iter() + .zip(chunks_out.into_remainder()) + { + *out = Op::apply(*input, rhs) + } +} + +/// Execute operation in line. +/// SAFETY: +/// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. +#[inline(always)] +#[macerator::with_simd] +unsafe fn binary_scalar_slice_inplace< + 'a, + S: Simd, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: ScalarSimdBinop, +>( + buf: &'a mut [T], + rhs: Op::Rhs, + _op: PhantomData<(Out, Op)>, +) where + 'a: 'a, +{ + let (head, main, tail) = unsafe { buf.align_to_mut::>() }; + for elem in head.iter_mut().chain(tail) { + *elem = cast(Op::apply(*elem, rhs)); + } + let mut chunks = main.chunks_exact_mut(8); + let rhs = Op::splat::(rhs); + for elem in chunks.by_ref() { + seq!(N in 0..8 { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s~N = unsafe { vload(&elem[N] as *const _ as *const T) }; + let s~N = Op::apply_vec(s~N, rhs); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { vstore_unaligned(&mut elem[N] as *mut _ as *mut Out, s~N) }; + }); + } + + for elem in chunks.into_remainder() { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s0 = unsafe { vload(elem as *const _ as *const T) }; + + let s0 = Op::apply_vec(s0, rhs); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { vstore(elem as *mut _ as *mut Out, s0) }; + } +} diff --git a/crates/burn-ndarray/src/ops/simd/cmp.rs b/crates/burn-ndarray/src/ops/simd/cmp.rs new file mode 100644 index 0000000..c9f8c0e --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/cmp.rs @@ -0,0 +1,374 @@ +use core::{marker::PhantomData, slice}; + +use burn_backend::Element; +use macerator::{Mask, Scalar, Simd, VEq, VOrd, Vector, vload_unaligned}; +use ndarray::ArrayD; +use seq_macro::seq; + +use crate::{NdArrayElement, SharedArray, ops::simd::uninit_array_like}; + +use super::should_use_simd; + +pub trait SimdCmpOp { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask; + fn apply(lhs: T, rhs: T) -> bool; + fn is_accelerated() -> bool; +} + +pub struct VecEquals; + +impl SimdCmpOp for VecEquals { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask { + lhs.eq(rhs) + } + + fn apply(lhs: T, rhs: T) -> bool { + lhs == rhs + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +pub struct VecGreater; + +impl SimdCmpOp for VecGreater { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask { + lhs.gt(rhs) + } + + fn apply(lhs: T, rhs: T) -> bool { + lhs > rhs + } + + fn is_accelerated() -> bool { + ::is_cmp_accelerated::() + } +} + +pub struct VecGreaterEq; + +impl SimdCmpOp for VecGreaterEq { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask { + lhs.ge(rhs) + } + + fn apply(lhs: T, rhs: T) -> bool { + lhs >= rhs + } + + fn is_accelerated() -> bool { + ::is_cmp_accelerated::() + } +} + +pub struct VecLowerEq; + +impl SimdCmpOp for VecLowerEq { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask { + lhs.le(rhs) + } + + fn apply(lhs: T, rhs: T) -> bool { + lhs <= rhs + } + + fn is_accelerated() -> bool { + ::is_cmp_accelerated::() + } +} + +pub struct VecLower; + +impl SimdCmpOp for VecLower { + fn apply_vec(lhs: Vector, rhs: Vector) -> Mask { + lhs.lt(rhs) + } + + fn apply(lhs: T, rhs: T) -> bool { + lhs < rhs + } + + fn is_accelerated() -> bool { + ::is_cmp_accelerated::() + } +} + +#[macerator::with_simd] +fn is_accelerated>(_x: PhantomData<(T, Op)>) -> bool { + Op::is_accelerated::() +} + +#[allow(clippy::result_large_err)] +pub fn try_cmp_simd>( + lhs: SharedArray, + rhs: SharedArray, +) -> Result, (SharedArray, SharedArray)> { + let lhs_len = lhs.len(); + let rhs_len = rhs.len(); + if !should_use_simd(lhs_len.max(rhs_len)) + || !lhs.is_standard_layout() + || !rhs.is_standard_layout() + || lhs.shape() != rhs.shape() + || !is_accelerated::(PhantomData) + { + return Err((lhs, rhs)); + } + // Used to assert traits based on the dynamic `DType`. + let lhs = unsafe { core::mem::transmute::, SharedArray>(lhs) }; + let rhs = unsafe { core::mem::transmute::, SharedArray>(rhs) }; + let out = cmp_simd_same::(lhs, rhs); + + Ok(out) +} + +fn cmp_simd_same>( + lhs: SharedArray, + rhs: SharedArray, +) -> SharedArray { + let out = if lhs.is_unique() && size_of::() == size_of::() { + let mut buf = lhs.into_owned(); + let lhs = buf.as_slice_mut().unwrap(); + let rhs = rhs.as_slice().unwrap(); + let out = + unsafe { core::mem::transmute::<&mut [T], &mut [bool]>(unsafe_alias_slice_mut(lhs)) }; + cmp(lhs, rhs, out, PhantomData::); + unsafe { core::mem::transmute::, ArrayD>(buf) } + } else if rhs.is_unique() && size_of::() == size_of::() { + let mut buf = rhs.into_owned(); + let lhs = lhs.as_slice().unwrap(); + let rhs = buf.as_slice_mut().unwrap(); + let out = + unsafe { core::mem::transmute::<&mut [T], &mut [bool]>(unsafe_alias_slice_mut(rhs)) }; + cmp(lhs, rhs, out, PhantomData::); + unsafe { core::mem::transmute::, ArrayD>(buf) } + } else { + let mut out = uninit_array_like(&lhs); + let lhs = lhs.as_slice().unwrap(); + let rhs = rhs.as_slice().unwrap(); + let out_slice = out.as_slice_mut().unwrap(); + cmp(lhs, rhs, out_slice, PhantomData::); + out + }; + out.into_shared() +} + +#[allow(clippy::erasing_op, clippy::identity_op)] +#[macerator::with_simd] +fn cmp<'a, S: Simd, T: NdArrayElement + Scalar, Op: SimdCmpOp>( + lhs: &'a [T], + rhs: &'a [T], + out: &'a mut [bool], + _op: PhantomData, +) where + 'a: 'a, +{ + let lanes = T::lanes::(); + let mut chunks_lhs = lhs.chunks_exact(8 * lanes); + let mut chunks_rhs = rhs.chunks_exact(8 * lanes); + let mut chunks_out = out.chunks_exact_mut(8 * lanes); + while let Some(((lhs, rhs), out)) = chunks_lhs + .next() + .zip(chunks_rhs.next()) + .zip(chunks_out.next()) + { + seq!(N in 0..8 { + // Load one full vector from `lhs`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let lhs~N = unsafe { vload_unaligned::(&lhs[N * lanes]) }; + // Load one full vector from `rhs`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let rhs~N = unsafe { vload_unaligned(&rhs[N * lanes]) }; + let s~N = Op::apply_vec(lhs~N, rhs~N); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + unsafe { T::mask_store_as_bool(&mut out[N * lanes], s~N) }; + }); + } + let mut chunks_lhs = chunks_lhs.remainder().chunks_exact(lanes); + let mut chunks_rhs = chunks_rhs.remainder().chunks_exact(lanes); + let mut chunks_out = chunks_out.into_remainder().chunks_exact_mut(lanes); + while let Some(((lhs, rhs), out)) = chunks_lhs + .next() + .zip(chunks_rhs.next()) + .zip(chunks_out.next()) + { + // Load one full vector from `lhs`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let lhs0 = unsafe { vload_unaligned::(lhs.as_ptr()) }; + // Load one full vector from `rhs`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let rhs0 = unsafe { vload_unaligned(rhs.as_ptr()) }; + let s0 = Op::apply_vec(lhs0, rhs0); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + unsafe { T::mask_store_as_bool(out.as_mut_ptr(), s0) }; + } + + for ((lhs, rhs), out) in chunks_lhs + .remainder() + .iter() + .zip(chunks_rhs.remainder()) + .zip(chunks_out.into_remainder()) + { + *out = Op::apply(*lhs, *rhs) + } +} + +/// Unsafely alias a slice to use as an inline argument +fn unsafe_alias_slice_mut<'a, T>(slice: &mut [T]) -> &'a mut [T] { + let ptr = slice.as_mut_ptr(); + let len = slice.len(); + unsafe { slice::from_raw_parts_mut(ptr, len) } +} + +pub use elemwise::try_cmp_scalar_simd; + +mod elemwise { + use bytemuck::cast; + use macerator::vload; + + use super::*; + + pub fn try_cmp_scalar_simd>( + input: SharedArray, + elem: T, + ) -> Result, SharedArray> { + if !should_use_simd(input.len()) + || input.as_slice_memory_order().is_none() + || !is_accelerated::(PhantomData) + { + return Err(input); + } + // Used to assert traits based on the dynamic `DType`. + let input = unsafe { core::mem::transmute::, SharedArray>(input) }; + let out = if size_of::() == size_of::() + && align_of::() >= align_of::() + && input.is_unique() + { + unsafe { cmp_scalar_simd_inplace::(input, elem) } + } else { + cmp_scalar_simd_owned::(input, elem) + }; + Ok(out) + } + + /// Execute operation in place on an owned tensor + /// SAFETY: + /// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. + unsafe fn cmp_scalar_simd_inplace>( + input: SharedArray, + elem: T, + ) -> SharedArray { + let mut buffer = input.into_owned(); + let slice = buffer.as_slice_memory_order_mut().unwrap(); + unsafe { cmp_scalar_slice_inplace::(slice, elem, PhantomData) }; + // Buffer has the same elem size and is filled with the operation output, so this is safe + let out = unsafe { core::mem::transmute::, ArrayD>(buffer) }; + out.into_shared() + } + + /// Create a new copy of the tensor as the output + fn cmp_scalar_simd_owned>( + input: SharedArray, + elem: T, + ) -> SharedArray { + let mut out = uninit_array_like(&input); + let input = input.as_slice_memory_order().unwrap(); + let out_slice = out.as_slice_memory_order_mut().unwrap(); + cmp_scalar_slice::(input, out_slice, elem, PhantomData); + out.into_shared() + } + + #[inline(always)] + #[allow(clippy::erasing_op, clippy::identity_op)] + #[macerator::with_simd] + fn cmp_scalar_slice<'a, S: Simd, T: NdArrayElement + Scalar, Op: SimdCmpOp>( + input: &'a [T], + out: &'a mut [bool], + rhs: T, + _op: PhantomData, + ) where + 'a: 'a, + { + let lanes = T::lanes::(); + let mut chunks_input = input.chunks_exact(8 * lanes); + let mut chunks_out = out.chunks_exact_mut(8 * lanes); + let rhs_vec = rhs.splat::(); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + seq!(N in 0..8 { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let s~N = unsafe { vload_unaligned(&input[N * lanes]) }; + let s~N = Op::apply_vec(s~N, rhs_vec); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + unsafe { T::mask_store_as_bool(&mut out[N * lanes], s~N) }; + }); + } + let mut chunks_input = chunks_input.remainder().chunks_exact(lanes); + let mut chunks_out = chunks_out.into_remainder().chunks_exact_mut(lanes); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let s0 = unsafe { vload_unaligned(input.as_ptr()) }; + let s0 = Op::apply_vec(s0, rhs_vec); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + unsafe { T::mask_store_as_bool(out.as_mut_ptr(), s0) }; + } + + for (input, out) in chunks_input + .remainder() + .iter() + .zip(chunks_out.into_remainder()) + { + *out = Op::apply(*input, rhs) + } + } + + /// Execute operation in line. + /// SAFETY: + /// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. + #[inline(always)] + #[macerator::with_simd] + unsafe fn cmp_scalar_slice_inplace<'a, S: Simd, T: NdArrayElement + Scalar, Op: SimdCmpOp>( + buf: &'a mut [T], + rhs: T, + _op: PhantomData, + ) where + 'a: 'a, + { + let (head, main, tail) = unsafe { buf.align_to_mut::>() }; + for elem in head.iter_mut().chain(tail) { + *elem = cast(Op::apply(*elem, rhs)); + } + let mut chunks = main.chunks_exact_mut(8); + let rhs = rhs.splat::(); + for elem in chunks.by_ref() { + seq!(N in 0..8 { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s~N = unsafe { vload(&elem[N] as *const _ as *const T) }; + let s~N = Op::apply_vec(s~N, rhs); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { T::mask_store_as_bool(&mut elem[N] as *mut _ as *mut bool, s~N) }; + }); + } + + for elem in chunks.into_remainder() { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s0 = unsafe { vload(elem as *const _ as *const T) }; + + let s0 = Op::apply_vec(s0, rhs); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { T::mask_store_as_bool(elem as *mut _ as *mut bool, s0) }; + } + } +} diff --git a/crates/burn-ndarray/src/ops/simd/conv.rs b/crates/burn-ndarray/src/ops/simd/conv.rs new file mode 100644 index 0000000..496a1ad --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/conv.rs @@ -0,0 +1,494 @@ +use core::{marker::PhantomData, mem::transmute}; + +use burn_backend::{ + DType, Element, + ops::{ConvOptions, conv::calculate_conv_output_size}, +}; +use bytemuck::Zeroable; +use macerator::{Simd, VMulAdd, Vector, vload_unaligned, vstore_unaligned}; +use ndarray::{ + ArcArray1, Array4, ArrayView3, ArrayView4, ArrayViewMut2, ArrayViewMut3, Dim, Ix1, Ix4, s, +}; +use seq_macro::seq; + +use crate::{FloatNdArrayElement, SharedArray, UnsafeSharedRef, iter_range_par, run_par}; + +type Args = (SharedArray, SharedArray, Option>); + +#[allow(clippy::result_large_err)] +pub fn try_conv2d_simd( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvOptions<2>, +) -> Result, Args> { + match E::dtype() { + DType::F64 => conv2d::(x, weight, bias, options, PhantomData), + DType::F32 => conv2d::(x, weight, bias, options, PhantomData), + DType::I64 => conv2d::(x, weight, bias, options, PhantomData), + DType::I32 => conv2d::(x, weight, bias, options, PhantomData), + DType::I16 => conv2d::(x, weight, bias, options, PhantomData), + DType::U64 => conv2d::(x, weight, bias, options, PhantomData), + DType::U32 => conv2d::(x, weight, bias, options, PhantomData), + DType::U16 => conv2d::(x, weight, bias, options, PhantomData), + _ => Err((x, weight, bias)), + } +} + +fn cast(tensor: SharedArray) -> SharedArray { + unsafe { transmute::, SharedArray>(tensor) } +} + +/// Out-channel last SIMD accelerated direct convolution. Loop order and register blocking based on +/// E. Georganas, S. Avancha, K. Banerjee, D. Kalamkar, G. Henry, H. Pabst, A. Heinecke (2018). +/// Anatomy Of High-Performance Deep Learning Convolutions On SIMD Architectures. +/// SC '18, Article 6, pp. 1-12. arXiv:1808.05567. . +#[allow(clippy::result_large_err)] +fn conv2d( + x: SharedArray, + weight: SharedArray, + bias: Option>, + options: ConvOptions<2>, + _ty: PhantomData, +) -> Result, Args> { + let [out_channels, _, k_height, k_width] = weight.shape().try_into().unwrap(); + let channels_per_group = out_channels / options.groups; + + #[macerator::with_simd] + fn precheck(_ty: PhantomData) -> (usize, bool) { + (E::lanes::(), E::is_accelerated::()) + } + + let (lanes, accelerated) = precheck::(PhantomData); + + if !accelerated || !channels_per_group.is_multiple_of(lanes) { + return Err((x, weight, bias)); + } + + let x = cast::<_, E>(x); + let weight = cast::<_, E>(weight); + let bias = bias.map(|bias| cast::<_, E>(bias)); + + let [batch_size, _in_channels, in_height, in_width] = x.shape().try_into().unwrap(); + let [dilate_h, dilate_w] = options.dilation; + let [stride_h, stride_w] = options.stride; + let [pad_h, pad_w] = options.padding; + let padded = options.padding != [0, 0]; + let strided = options.stride != [1, 1] || options.dilation != [1, 1]; + let grouped = options.groups != 1; + + let out_height = calculate_conv_output_size(k_height, stride_h, pad_h, dilate_h, in_height); + let out_width = calculate_conv_output_size(k_width, stride_w, pad_w, dilate_w, in_width); + + let x = x.into_dimensionality::().unwrap(); + let weights = weight.into_dimensionality::().unwrap(); + let weights = weights.permuted_axes([1, 2, 3, 0]); + let weights = weights.as_standard_layout(); + let bias = bias.map(|bias| bias.into_dimensionality::().unwrap()); + // floor division means `(oc_blocks - 1) * lanes` can never be greater than `out_channels - lanes`. + let oc_blocks = out_channels / lanes; + + let mut out = unsafe { + Array4::::uninit(Dim([batch_size, out_height, out_width, out_channels])).assume_init() + }; + let unsafe_shared_out = UnsafeSharedRef::new(&mut out); + + run_par!(|| { + // SAFETY: Slices are guaranteed to be non-overlapping, so having an unsafe shared reference + // is safe. `oc_blocks * lanes` must be `<= out_channels` to satisfy safety of inner function. + iter_range_par!(0, batch_size * oc_blocks).for_each(|k| unsafe { + let b = k / oc_blocks; + let ob = k % oc_blocks; + let x = x.slice(s![b, .., .., ..]); + let out = unsafe_shared_out.get(); + let mut out = out.slice_mut(s![b, .., .., ..]); + let w = weights.view(); + + match (padded, strided, grouped) { + (true, true, true) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (true, false, true) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (false, true, true) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (false, false, true) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (true, true, false) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (true, false, false) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (false, true, false) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + (false, false, false) => { + conv2d_launch::(x, w, &bias, &mut out, &options, ob) + } + } + }); + }); + + let output = out.permuted_axes([0, 3, 1, 2]); + Ok(cast(output.into_dyn().into_shared())) +} + +/// Size of register blocks, we need to hardcode this because Rust and the `seq` macro don't support +/// using associated constants as constant parameters. 8 works for all semi-modern CPUs but might +/// not be perfectly optimized for AVX-512 capable CPUs (which probably should use 16). +/// This should always be conservative, since oversizing it will cause register spills and that's +/// **much** worse than the performance lost with lower values. +const REGISTER_BLOCK: usize = 8; +inner_with_register_blocking_size!(8); + +/// Run a loop of conv2d. +/// # SAFETY +/// See `conv2d_inner_nopad`, `conv2d_inner_nopad_nostride`, `conv2d_remainder`. +/// Required preconditions: `ob * simd_lanes` must be `<= out_channels - simd_lanes`, `weights` and +/// `out` must have unit stride for the out channels. +#[inline(always)] +#[macerator::with_simd] +unsafe fn conv2d_launch< + 'a, + S: Simd, + E: VMulAdd, + const PAD: bool, + const STRIDE: bool, + const GROUPS: bool, +>( + x: ArrayView3<'a, E>, + weights: ArrayView4<'a, E>, + bias: &'a Option>, + out: &'a mut ArrayViewMut3<'a, E>, + options: &'a ConvOptions<2>, + ob: usize, +) where + 'a: 'a, +{ + let (in_channels, k_height, k_width, out_channels) = weights.dim(); + let (out_height, out_width, _) = out.dim(); + let channels_per_group = out_channels / options.groups; + let lanes = E::lanes::(); + + let [mut pad_h, mut pad_w] = options.padding; + let [stride_h, stride_w] = options.stride; + let [dilate_h, dilate_w] = options.dilation; + + // Trick compiler into inlining 0 to padding + if !PAD { + pad_h = 0; + pad_w = 0; + } + + let oc_b = channels_per_group.min(lanes); + let ow_b = REGISTER_BLOCK; + + let ow_start = pad_w.min(out_width); + let ow_width = out_width.saturating_sub(2 * pad_w); + let oh_start = pad_h.min(out_height); + let oh_end = out_height.saturating_sub(pad_h); + + let ow_blocks = ow_width / ow_b; + let oc = ob * oc_b; + let group = oc / channels_per_group; + let mut ic_off = group * in_channels; + if !GROUPS { + ic_off = 0; + } + + unsafe { + let bias = if let Some(bias) = &bias { + vload_unaligned::(&bias[oc]) + } else { + Zeroable::zeroed() + }; + + for oh in oh_start..oh_end { + let mut out = out.slice_mut(s![oh, .., ..]); + for ow_block in 0..ow_blocks { + let ow = ow_block * ow_b + ow_start; + + #[allow(clippy::if_same_then_else)] + if STRIDE { + conv2d_inner_nopad( + &x, &weights, &mut out, bias, oh, ow, oc, ic_off, stride_h, stride_w, + dilate_h, dilate_w, k_height, k_width, pad_h, pad_w, + ); + } else { + conv2d_inner_nopad_nostride( + &x, &weights, &mut out, bias, oh, ow, oc, ic_off, k_height, k_width, pad_h, + pad_w, + ); + } + } + } + conv2d_remainder( + x, + weights, + out, + bias, + oc, + ic_off, + ow_blocks * ow_b, + stride_h, + stride_w, + dilate_h, + dilate_w, + pad_h, + pad_w, + k_height, + k_width, + ); + } +} + +/// Execute the non-unrolled and/or padded portion of the convolution. This has more checks and is +/// much slower, so we want to minimize the amount of pixels that need to be processed by this +/// +/// SAFETY: `oc` must be an index that's at most `out_channels - simd_lanes`, so the full vector +/// is in bounds. Weights and `out` must be channels last (with `stride == 1`). +#[allow(clippy::too_many_arguments)] +#[inline(always)] +unsafe fn conv2d_remainder( + x: ArrayView3, + weights: ArrayView4, + out: &mut ArrayViewMut3, + bias: Vector, + oc: usize, + ic_off: usize, + owb_end: usize, + stride_h: usize, + stride_w: usize, + dilate_h: usize, + dilate_w: usize, + pad_h: usize, + pad_w: usize, + k_height: usize, + k_width: usize, +) { + let in_channels = weights.shape()[0]; + let (_, in_height, in_width) = x.dim(); + let (out_height, out_width, _) = out.dim(); + let oh_start = pad_h.min(out_height); + let oh_end = out_height.saturating_sub(pad_h); + let ow_start = pad_w.min(out_width); + + let height1 = in_height + pad_h; + let width1 = in_width + pad_w; + + for oh in (0..oh_start).chain(oh_end..out_height) { + for ow in 0..out_width { + let mut acc = bias; + + for ic in 0..in_channels { + for kh in 0..k_height { + let ih = oh * stride_h + kh * dilate_h; + if (ih < pad_h) | (ih >= height1) { + continue; + } + let ih = ih - pad_h; + + for kw in 0..k_width { + let iw = ow * stride_w + kw * dilate_w; + if (iw < pad_w) | (iw >= width1) { + continue; + } + let iw = iw - pad_w; + + // Load a full vector from the weights. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and out channels are last. + // We need to ensure the weights are reshaped appropriately. + let f0 = unsafe { vload_unaligned(&weights[[ic, kh, kw, oc]]) }; + + // The loop bounds ensure `ic`, `ih` and `iw` are always in bounds, but the + // compiler can't prove this. We can't use `as_slice` with fixed bounds + // because we want to support arbitrary input layouts. So an unchecked load + // is used. + let i0 = unsafe { x.uget([ic_off + ic, ih, iw]) }.splat::(); + acc = i0.mul_add(f0, acc); + } + } + } + + // Store a full vector from the output. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and oc stride is 1. We create `out` with + // channels last, so this always holds. + unsafe { vstore_unaligned(&mut out[[oh, ow, oc]], acc) }; + } + } + for ow in (0..ow_start).chain(owb_end..out_width) { + for oh in 0..out_height { + let mut acc = bias; + + for ic in 0..in_channels { + for kh in 0..k_height { + let ih = oh * stride_h + kh * dilate_h; + if (ih < pad_h) | (ih >= height1) { + continue; + } + let ih = ih - pad_h; + + for kw in 0..k_width { + let iw = ow * stride_w + kw * dilate_w; + if (iw < pad_w) | (iw >= width1) { + continue; + } + let iw = iw - pad_w; + + // Load a full vector from the weights. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and out channels are last. + // We need to ensure the weights are reshaped appropriately. + let f0 = unsafe { vload_unaligned(&weights[[ic, kh, kw, oc]]) }; + + // The loop bounds ensure `ic`, `ih` and `iw` are always in bounds, but the + // compiler can't prove this. We can't use `as_slice` with fixed bounds + // because we want to support arbitrary input layouts. So an unchecked load + // is used. + let i0 = unsafe { x.uget([ic_off + ic, ih, iw]) }.splat::(); + acc = i0.mul_add(f0, acc); + } + } + } + + // Store a full vector from the output. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and oc stride is 1. We create `out` with + // channels last, so this always holds. + unsafe { vstore_unaligned(&mut out[[oh, ow, oc]], acc) }; + } + } +} + +macro_rules! inner_with_register_blocking_size { + ($rb: literal) => { + /// Execute the unrolled and unpadded portion of the convolution. Any pixel that is more than + /// `pad_h` away from the horizontal border, and `pad_w` away from the vertical border is + /// guaranteed to always be in bounds (because of the way out size is calculated). + /// + /// SAFETY: `oc` must be an index that's at most `out_channels - simd_lanes`, so the full vector + /// is in bounds. Weights and `out` must be channels last (with `stride == 1`). + #[allow(clippy::erasing_op, clippy::identity_op, clippy::too_many_arguments)] + #[inline(always)] + unsafe fn conv2d_inner_nopad( + x: &ArrayView3, + weights: &ArrayView4, + out: &mut ArrayViewMut2, + bias: Vector, + oh: usize, + ow: usize, + oc: usize, + ic_off: usize, + stride_h: usize, + stride_w: usize, + dilate_h: usize, + dilate_w: usize, + k_height: usize, + k_width: usize, + pad_h: usize, + pad_w: usize, + ) { + let in_channels = weights.shape()[0]; + + seq!(N in 0..$rb { + let mut acc~N = bias; + }); + + for ic in 0..in_channels { + for kh in 0..k_height { + let ih = oh * stride_h + kh * dilate_h - pad_h; + + for kw in 0..k_width { + // Load a full vector from the weights. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and out channels are last. + // We need to ensure the weights are reshaped appropriately. + let f0 = unsafe { vload_unaligned(&weights[[ic, kh, kw, oc]]) }; + let iw = ow * stride_w + kw * dilate_w - pad_w; + + seq!(N in 0..$rb { + // The loop bounds ensure `ic`, `ih` and `iw` are always in bounds, but the + // compiler can't prove this. We can't use `as_slice` with fixed bounds + // because we want to support arbitrary input layouts. So an unchecked load + // is used. + let i~N = unsafe { x.uget([ic + ic_off, ih, iw + N * stride_w]) }.splat::(); + }); + seq!(N in 0..$rb { + acc~N = i~N.mul_add(f0, acc~N); + }); + } + } + } + + seq!(N in 0..$rb { + // Store a full vector from the output. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and oc stride is 1. We create `out` with + // channels last, so this always holds. + unsafe { vstore_unaligned(&mut out[[ow + N, oc]], acc~N) }; + }); + } + + /// Execute the unrolled and unpadded portion of the convolution. Any pixel that is more than + /// `pad_h` away from the horizontal border, and `pad_w` away from the vertical border is + /// guaranteed to always be in bounds (because of the way out size is calculated). + /// + /// SAFETY: `oc` must be an index that's at most `out_channels - simd_lanes`, so the full vector + /// is in bounds. Weights and `out` must be channels last (with `stride == 1`). + #[allow(clippy::erasing_op, clippy::identity_op, clippy::too_many_arguments)] + #[inline(always)] + unsafe fn conv2d_inner_nopad_nostride( + x: &ArrayView3, + weights: &ArrayView4, + out: &mut ArrayViewMut2, + bias: Vector, + oh: usize, + ow: usize, + oc: usize, + ic_off: usize, + k_height: usize, + k_width: usize, + pad_h: usize, + pad_w: usize, + ) { + let in_channels = weights.shape()[0]; + + seq!(N in 0..$rb { + let mut acc~N = bias; + }); + + for ic in 0..in_channels { + for kh in 0..k_height { + let ih = oh + kh - pad_h; + + for kw in 0..k_width { + // Load a full vector from the weights. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and out channels are last. + // We need to ensure the weights are reshaped appropriately. + let f0 = unsafe { vload_unaligned(&weights[[ic, kh, kw, oc]]) }; + let iw = ow + kw - pad_w; + + seq!(N in 0..$rb { + // The loop bounds ensure `ic`, `ih` and `iw` are always in bounds, but the + // compiler can't prove this. We can't use `as_slice` with fixed bounds + // because we want to support arbitrary input layouts. So an unchecked load + // is used. + let i~N = unsafe { x.uget([ic + ic_off, ih, iw + N]) }.splat::(); + }); + seq!(N in 0..$rb { + acc~N = i~N.mul_add(f0, acc~N); + }); + } + } + } + + seq!(N in 0..$rb { + // Store a full vector from the output. This is guaranteed to be in bounds + // as long as `oc <= out_channels - simd_lanes` and oc stride is 1. We create `out` with + // channels last, so this always holds. + unsafe { vstore_unaligned(&mut out[[ow + N, oc]], acc~N) }; + }); + } + }; +} +pub(crate) use inner_with_register_blocking_size; diff --git a/crates/burn-ndarray/src/ops/simd/maxpool.rs b/crates/burn-ndarray/src/ops/simd/maxpool.rs new file mode 100644 index 0000000..804f5e9 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/maxpool.rs @@ -0,0 +1,395 @@ +use core::{marker::PhantomData, mem::transmute}; + +use crate::{SharedArray, iter_range_par, run_par, sharing::UnsafeSharedRef}; + +use burn_backend::{BoolStore, DType, Element, quantization::QuantValue}; +use macerator::{Simd, VOrd}; +use ndarray::{Array4, s}; +use nhwc::max_pool2d_nhwc; + +use super::{MinMax, should_use_simd}; + +#[macerator::with_simd] +fn is_accelerated_impl(_x: PhantomData) -> bool { + ::is_min_max_accelerated::() +} + +fn is_accelerated() -> bool { + is_accelerated_impl::(PhantomData) +} + +macro_rules! launch_kernel { + ($ty: ty, $func: ident, $x: expr, $($arg: expr),*) => { + match <$ty as Element>::dtype() { + DType::F64 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::F32 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::I64 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::I32 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::I16 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::I8 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::U64 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::U32 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::U16 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::U8 if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::Bool(BoolStore::Native) if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + DType::QFloat(scheme) => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S if is_accelerated::() => Ok(cast($func::(cast($x), $($arg),*))), + _ => Err($x) + }, + _ => Err($x), + } + }; +} + +pub(crate) fn try_max_pool2d_simd( + x: SharedArray, + ksize: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], +) -> Result, SharedArray> { + let [_, c, _, _] = x.shape().try_into().unwrap(); + if !should_use_simd(c) || x.strides()[1] != 1 { + return Err(x); + } + + launch_kernel!(E, max_pool2d_nhwc, x, ksize, stride, padding, dilation) +} + +fn cast(tensor: SharedArray) -> SharedArray { + unsafe { transmute::, SharedArray>(tensor) } +} + +mod nhwc { + use burn_backend::ElementOrdered; + use itertools::Itertools; + use macerator::{Simd, vload_unaligned, vstore_unaligned}; + use ndarray::{ArrayView3, ArrayViewMut3, Ix4}; + use seq_macro::seq; + + use crate::ops::simd::lanes; + + use super::*; + + // Until you can use associated constants as array size, we need to hardcode this. + // The most common config (x86-v3) has 16 registers, so use half of them for accumulators. + const BLOCK_REGISTERS: usize = 8; + + pub(crate) fn max_pool2d_nhwc( + x: SharedArray, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ) -> SharedArray { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + let [batch_size, channels, x_height, x_width] = x.shape().try_into().unwrap(); + let lanes = lanes::(); + + let ch_block = lanes * BLOCK_REGISTERS; + + let out_height = ((x_height + 2 * pad_h - dilation_height * (kernel_height - 1) - 1) + / stride_height) + + 1; + let out_width = + ((x_width + 2 * pad_w - dilation_width * (kernel_width - 1) - 1) / stride_width) + 1; + + let mut output = unsafe { + Array4::::uninit((batch_size, out_height, out_width, channels)).assume_init() + }; + let unsafe_shared_out = UnsafeSharedRef::new(&mut output); + + let x = x.into_dimensionality::().unwrap(); + let x = x.view(); + let x = x.permuted_axes([0, 2, 3, 1]); + + // Floor division ensures `blocks * lanes * blocking factor` is always `<= out_channels`. + // An exclusive loop will always have `lanes * blocking factor` elements in bounds. + let blocks = channels / ch_block; + let blocks_end = blocks * ch_block; + // Floor division means simd_end is always divisible by `lanes` and `<= out_channels`. An + // exclusive loop will always have `lanes` elements in bounds. + let simd_end = channels / lanes * lanes; + let simd_unblocked = (simd_end - blocks_end) / lanes; + let remainder = channels - simd_end; + + run_par!(|| { + // SAFETY: Loop ranges are non-overlapping, so the unsafe shared reference is safe. + iter_range_par!(0, batch_size * blocks).for_each(|k| unsafe { + let block = k % blocks; + let b = k / blocks; + + let output = unsafe_shared_out.get(); + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + loop_blocked(x, out, kernel_size, stride, padding, dilation, block); + }); + // SAFETY: See `loop_unblocked` + iter_range_par!(0, batch_size * simd_unblocked).for_each(|k| unsafe { + let ch = (k % simd_unblocked) * lanes + blocks_end; + let b = k / simd_unblocked; + + let output = unsafe_shared_out.get(); + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + loop_unblocked(x, out, kernel_size, stride, padding, dilation, ch); + }); + // SAFETY: Loop ranges are non-overlapping, so the unsafe shared reference is safe. + iter_range_par!(0, batch_size * remainder).for_each(|k| unsafe { + let ch = (k % remainder) + simd_end; + let b = k / remainder; + + let output = unsafe_shared_out.get(); + let x = x.slice(s![b, .., .., ..]); + let out = output.slice_mut(s![b, .., .., ..]); + loop_scalar(x, out, kernel_size, stride, padding, dilation, ch); + }); + }); + + output = output.permuted_axes([0, 3, 1, 2]); + + output.into_dyn().into_shared() + } + + /// Execute the blocked (unrolled) portion of the pool. + #[allow( + clippy::too_many_arguments, + clippy::erasing_op, + clippy::identity_op, + unused_mut + )] + #[inline(always)] + #[macerator::with_simd] + fn loop_blocked<'a, S: Simd, E: ElementOrdered + VOrd + MinMax>( + x: ArrayView3<'a, E>, + mut out: ArrayViewMut3<'a, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + block: usize, + ) where + 'a: 'a, + { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + let lanes = E::lanes::(); + let ch_block = lanes * BLOCK_REGISTERS; + + let min = E::MIN.splat::(); + // If outside padding area, kernels are guaranteed to be in bounds + for oh in pad_h..out_height.saturating_sub(pad_h) { + for ow in pad_w..out_width.saturating_sub(pad_w) { + seq!(N in 0..8 { + let mut acc~N = min; + }); + let ch = block * ch_block; + let ch_end = ch + ch_block; + let mut out = out.slice_mut(s![oh, ow, ch..ch_end]); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh * dilation_height - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw * dilation_width - pad_w; + let x = x.slice(s![ih, iw, ch..ch_end]); + + seq!(N in 0..8 { + // SAFETY: + // Load a full vector from x[N * lanes]. This is bounds checked by the + // slice above. + acc~N = acc~N.max(unsafe { vload_unaligned(&x[N * lanes]) }); + }); + } + } + + seq!(N in 0..8 { + // SAFETY: + // Store a full vector to out[N * lanes]. This is bounds checked by the + // slice above. + unsafe { vstore_unaligned(&mut out[N * lanes], acc~N) }; + }); + } + } + + // Border pixels need bounds checks + if (pad_h, pad_w) != (0, 0) { + let v_borders = (0..pad_h) + .chain(out_height.saturating_sub(pad_h)..out_height) + .cartesian_product(0..out_width); + let h_borders = (0..out_height) + .cartesian_product((0..pad_w).chain(out_width.saturating_sub(pad_w)..out_width)); + + for (oh, ow) in v_borders.chain(h_borders) { + seq!(N in 0..8 { + let mut acc~N = min; + }); + let ch = block * ch_block; + let ch_end = ch + ch_block; + let mut out = out.slice_mut(s![oh, ow, ch..ch_end]); + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh * dilation_height; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw * dilation_width; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + + let x = x.slice(s![ih, iw, ch..ch_end]); + + seq!(N in 0..8 { + // SAFETY: + // Load a full vector from x[N * lanes]. This is bounds checked by the + // slice above. + acc~N = acc~N.max(unsafe { vload_unaligned(&x[N * lanes]) }); + }); + } + } + + seq!(N in 0..8 { + // SAFETY: + // Store a full vector to out[N * lanes]. This is bounds checked by the + // slice above. + unsafe { vstore_unaligned(&mut out[N * lanes], acc~N) }; + }); + } + } + } + + /// Execute the unblocked (not unrolled) portion of the pool. + /// + /// SAFETY: Safe as long as `ch + simd_lanes <= out_channels`. + #[allow(clippy::too_many_arguments, unused_mut)] + #[inline(always)] + #[macerator::with_simd] + unsafe fn loop_unblocked<'a, S: Simd, E: ElementOrdered + VOrd + MinMax>( + x: ArrayView3<'a, E>, + mut out: ArrayViewMut3<'a, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ch: usize, + ) where + 'a: 'a, + { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + + for oh in pad_h..out_height.saturating_sub(pad_h) { + for ow in pad_w..out_width.saturating_sub(pad_w) { + let mut acc = E::MIN.splat::(); + let out = &mut out[[oh, ow, ch]]; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh * dilation_height - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw * dilation_width - pad_w; + // Load a full vector from `x`. In bounds as long as `out_channels >= ch + lanes` + acc = acc.max(unsafe { vload_unaligned(&x[[ih, iw, ch]]) }); + } + } + // Store a full vector to `out`. In bounds as long as `out_channels >= ch + lanes`. + unsafe { vstore_unaligned(out, acc) }; + } + } + + // Border pixels need bounds checks + if (pad_h, pad_w) != (0, 0) { + let v_borders = (0..pad_h) + .chain(out_height.saturating_sub(pad_h)..out_height) + .cartesian_product(0..out_width); + let h_borders = (0..out_height) + .cartesian_product((0..pad_w).chain(out_width.saturating_sub(pad_w)..out_width)); + + for (oh, ow) in v_borders.chain(h_borders) { + let mut acc = E::MIN.splat::(); + let out = &mut out[[oh, ow, ch]]; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh * dilation_height; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw * dilation_width; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + // Load a full vector from `x`. In bounds as long as `out_channels >= ch + lanes` + acc = acc.max(unsafe { vload_unaligned(&x[[ih, iw, ch]]) }); + } + } + // Store a full vector to `out`. In bounds as long as `out_channels >= ch + lanes`. + unsafe { vstore_unaligned(out, acc) }; + } + } + } + + fn loop_scalar( + x: ArrayView3<'_, E>, + mut out: ArrayViewMut3<'_, E>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ch: usize, + ) { + let [kernel_height, kernel_width] = kernel_size; + let [pad_h, pad_w] = padding; + let [stride_height, stride_width] = stride; + let [dilation_height, dilation_width] = dilation; + + let (x_height, x_width, _) = x.dim(); + let (out_height, out_width, _) = out.dim(); + + for oh in 0..out_height { + for ow in 0..out_width { + let mut acc = E::MIN; + + for kh in 0..kernel_height { + let ih = oh * stride_height + kh * dilation_height; + if ih < pad_h || ih >= x_height + pad_h { + continue; + } + let ih = ih - pad_h; + + for kw in 0..kernel_width { + let iw = ow * stride_width + kw * dilation_width; + if iw < pad_w || iw >= x_width + pad_w { + continue; + } + let iw = iw - pad_w; + acc = acc.max(x[[ih, iw, ch]]); + } + } + + out[[oh, ow, ch]] = acc; + } + } + } +} diff --git a/crates/burn-ndarray/src/ops/simd/mod.rs b/crates/burn-ndarray/src/ops/simd/mod.rs new file mode 100644 index 0000000..2032f30 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod avgpool; +mod base; +pub(crate) mod binary; +pub(crate) mod binary_elemwise; +pub(crate) mod cmp; +pub(crate) mod conv; +pub(crate) mod maxpool; +pub(crate) mod unary; + +pub use base::*; diff --git a/crates/burn-ndarray/src/ops/simd/unary.rs b/crates/burn-ndarray/src/ops/simd/unary.rs new file mode 100644 index 0000000..68d2626 --- /dev/null +++ b/crates/burn-ndarray/src/ops/simd/unary.rs @@ -0,0 +1,234 @@ +use core::marker::PhantomData; + +use bytemuck::cast; +use macerator::{ + Scalar, Simd, VAbs, VBitNot, VRecip, Vector, vload, vload_unaligned, vstore, vstore_unaligned, +}; +use ndarray::ArrayD; +use num_traits::Signed; +use seq_macro::seq; + +use crate::{NdArrayElement, SharedArray}; + +use super::should_use_simd; + +pub trait SimdUnop { + fn apply_vec(input: Vector) -> Vector; + fn apply(input: T) -> Out; + fn is_accelerated() -> bool; +} + +pub struct RecipVec; + +impl SimdUnop for RecipVec { + fn apply_vec(input: Vector) -> Vector { + input.recip() + } + + fn apply(input: f32) -> f32 { + input.recip() + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +pub struct VecAbs; + +impl SimdUnop for VecAbs { + fn apply_vec(input: Vector) -> Vector { + input.abs() + } + + fn apply(input: T) -> T { + input.abs() + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +pub struct VecBitNot; + +impl SimdUnop for VecBitNot { + fn apply_vec(input: Vector) -> Vector { + !input + } + + fn apply(input: T) -> T { + input.not() + } + + fn is_accelerated() -> bool { + ::is_accelerated::() + } +} + +#[macerator::with_simd] +fn is_accelerated>( + _x: PhantomData<(T, Out, Op)>, +) -> bool { + Op::is_accelerated::() +} + +pub fn try_unary_simd< + E: NdArrayElement, + EOut: NdArrayElement, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdUnop, +>( + input: SharedArray, +) -> Result, SharedArray> { + if !should_use_simd(input.len()) + || input.as_slice_memory_order().is_none() + || !is_accelerated::(PhantomData) + { + return Err(input); + } + // Used to assert traits based on the dynamic `DType`. + let input = unsafe { core::mem::transmute::, SharedArray>(input) }; + let out = if size_of::() == size_of::() + && align_of::() >= align_of::() + && input.is_unique() + { + unsafe { unary_scalar_simd_inplace::(input) } + } else { + unary_scalar_simd_owned::(input) + }; + // Used to assert traits based on the dynamic `DType`. + let out = unsafe { core::mem::transmute::, SharedArray>(out) }; + Ok(out) +} + +/// Execute operation in line. +/// SAFETY: +/// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. +unsafe fn unary_scalar_simd_inplace< + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdUnop, +>( + input: SharedArray, +) -> SharedArray { + let mut buffer = input.into_owned(); + let slice = buffer.as_slice_memory_order_mut().unwrap(); + // This is only called when in and out have the same size, so it's safe + unsafe { unary_slice_inplace::(slice, PhantomData) }; + // Buffer has the same elem size and is filled with the operation output, so this is safe + let out = unsafe { core::mem::transmute::, ArrayD>(buffer) }; + out.into_shared() +} + +fn unary_scalar_simd_owned< + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdUnop, +>( + input: SharedArray, +) -> SharedArray { + let mut out = unsafe { ArrayD::uninit(input.shape()).assume_init() }; + let input = input.as_slice_memory_order().unwrap(); + let out_slice = out.as_slice_memory_order_mut().unwrap(); + unary_slice::(input, out_slice, PhantomData); + out.into_shared() +} + +#[allow(clippy::erasing_op, clippy::identity_op)] +#[macerator::with_simd] +fn unary_slice< + 'a, + S: Simd, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdUnop, +>( + input: &'a [T], + out: &'a mut [Out], + _op: PhantomData, +) where + 'a: 'a, +{ + let lanes = T::lanes::(); + let mut chunks_input = input.chunks_exact(8 * lanes); + let mut chunks_out = out.chunks_exact_mut(8 * lanes); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + seq!(N in 0..8 { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + let s~N = unsafe { vload_unaligned(&input[N * lanes]) }; + let s~N = Op::apply_vec::(s~N); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == 8 * lanes` + unsafe { vstore_unaligned(&mut out[N * lanes], s~N) }; + }); + } + let mut chunks_input = chunks_input.remainder().chunks_exact(lanes); + let mut chunks_out = chunks_out.into_remainder().chunks_exact_mut(lanes); + while let Some((input, out)) = chunks_input.next().zip(chunks_out.next()) { + // Load one full vector from `input`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + let s0 = unsafe { vload_unaligned(input.as_ptr()) }; + let s0 = Op::apply_vec::(s0); + // Store one full vector to `out`. + // SAFETY: Guaranteed to be in bounds because `len == lanes` + unsafe { vstore_unaligned(out.as_mut_ptr(), s0) }; + } + + for (input, out) in chunks_input + .remainder() + .iter() + .zip(chunks_out.into_remainder()) + { + *out = Op::apply(*input) + } +} + +/// Execute operation in line. +/// SAFETY: +/// Must ensure `size_of:: == size_of::` and `align_of:: >= align_of::`. +#[macerator::with_simd] +unsafe fn unary_slice_inplace< + 'a, + S: Simd, + T: NdArrayElement + Scalar, + Out: NdArrayElement + Scalar, + Op: SimdUnop, +>( + buf: &'a mut [T], + _op: PhantomData<(Out, Op)>, +) where + 'a: 'a, +{ + let (head, main, tail) = unsafe { buf.align_to_mut::>() }; + for elem in head.iter_mut().chain(tail) { + *elem = cast(Op::apply(*elem)); + } + let mut chunks = main.chunks_exact_mut(8); + for elem in chunks.by_ref() { + seq!(N in 0..8 { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s~N = unsafe { vload(&elem[N] as *const _ as *const T) }; + let s~N = Op::apply_vec::(s~N); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { vstore(&mut elem[N] as *mut _ as *mut Out, s~N) }; + }); + } + + for elem in chunks.into_remainder() { + // Load a full vector from the aligned portion of the buffer. + // SAFETY: `align_to_mut` guarantees we're aligned to `T::Vector`'s size, and there is + // always a full vector in bounds. + let s0 = unsafe { vload(elem as *const _ as *const T) }; + + let s0 = Op::apply_vec::(s0); + // Store a full vector at the same position as the input. Cast is safe because `Out` is + // size and align compatible + unsafe { vstore(elem as *mut _ as *mut Out, s0) }; + } +} diff --git a/crates/burn-ndarray/src/ops/tensor.rs b/crates/burn-ndarray/src/ops/tensor.rs new file mode 100644 index 0000000..ea00483 --- /dev/null +++ b/crates/burn-ndarray/src/ops/tensor.rs @@ -0,0 +1,769 @@ +// Language +use alloc::vec::Vec; +use burn_backend::backend::ExecutionError; +use burn_backend::ops::GridSampleOptions; +use burn_backend::tensor::FloatTensor; +use burn_backend::{TensorMetadata, element::cast::ToElement}; +use burn_std::{BoolDType, IntDType}; + +// Current crate +use super::{ + NdArrayMathOps, NdArrayOps, + matmul::{cross, matmul}, +}; +use crate::{ + NdArray, cast_to_dtype, cat_with_dtype, execute_with_int_dtype, tensor::NdArrayTensor, +}; +use crate::{NdArrayDevice, SEED, execute_with_float_out_dtype, execute_with_int_out_dtype, slice}; +use crate::{SharedArray, element::ExpElement}; +use crate::{execute_with_float_dtype, ops::grid_sample::grid_sample_2d}; + +// Workspace crates +use crate::rand::get_seeded_rng; +use burn_backend::{Distribution, FloatDType, Scalar}; +use burn_backend::{ElementConversion, Shape, TensorData, ops::FloatTensorOps}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use libm::erf; + +#[cfg(feature = "std")] +#[allow(dead_code)] +fn round_ties_even_wrapper(x: f64) -> f64 { + x.round_ties_even() +} + +#[cfg(not(feature = "std"))] +#[allow(dead_code)] +fn round_ties_even_wrapper(x: f64) -> f64 { + if (x - x.floor()) == 0.5 { + (x * 0.5).round() * 2.0 + } else { + x.round() + } +} + +impl FloatTensorOps for NdArray { + fn float_from_data(data: TensorData, _device: &NdArrayDevice) -> FloatTensor { + NdArrayTensor::from_data(data) + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &NdArrayDevice, + dtype: FloatDType, + ) -> FloatTensor { + let mut seed = SEED.lock().unwrap(); + let mut rng = seed.take().unwrap_or_else(get_seeded_rng); + let tensor = execute_with_float_out_dtype!( + dtype, + E, + Self::float_from_data( + TensorData::random::(shape, distribution, &mut rng), + device, + ) + ); + + *seed = Some(rng); + tensor + } + + async fn float_into_data(tensor: FloatTensor) -> Result { + Ok(tensor.into_data()) + } + + fn float_to_device(tensor: FloatTensor, _device: &NdArrayDevice) -> FloatTensor { + tensor + } + + fn float_empty(shape: Shape, device: &NdArrayDevice, dtype: FloatDType) -> FloatTensor { + Self::float_zeros(shape, device, dtype) + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), NdArrayMathOps::add) + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::add_scalar(array, rhs.elem()) + }) + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), NdArrayMathOps::sub) + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::sub_scalar(array, rhs.elem()) + }) + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), NdArrayMathOps::mul) + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::mul_scalar(array, rhs.elem()) + }) + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), NdArrayMathOps::div) + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::div_scalar(array, rhs.elem()) + }) + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), NdArrayMathOps::remainder) + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::remainder_scalar(array, rhs.elem()) + }) + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), matmul) + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| cross(lhs, rhs, dim)) + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::recip(array) + }) + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::swap_dims(array, dim1, dim2) + }) + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::reshape(array, shape) + }) + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: NdArrayTensor, + ) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::gather(dim, array, idx_array) + }) + } + ) + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: NdArrayTensor, + value: FloatTensor, + ) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!((tensor, value), |tensor, value| NdArrayOps::scatter( + dim, tensor, idx_array, value + )) + } + ) + } + + fn float_scatter_nd( + data: FloatTensor, + indices: NdArrayTensor, + values: FloatTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!((data, values), |data, values| NdArrayOps::scatter_nd( + data, idx_array, values, reduction + )) + } + ) + } + + fn float_gather_nd(data: FloatTensor, indices: NdArrayTensor) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!(data, FloatElem, |array: SharedArray| { + NdArrayOps::gather_nd(array, idx_array) + }) + } + ) + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: NdArrayTensor, + ) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::select(array, dim, idx_array) + }) + } + ) + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: NdArrayTensor, + value: FloatTensor, + ) -> FloatTensor { + execute_with_int_dtype!( + indices, + IntElem, + |idx_array: SharedArray| -> NdArrayTensor { + execute_with_float_dtype!((tensor, value), |tensor, value| { + NdArrayMathOps::select_assign(tensor, dim, idx_array, value) + }) + } + ) + } + + fn float_slice(tensor: FloatTensor, slices: &[burn_backend::Slice]) -> FloatTensor { + slice!(tensor, slices) + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[burn_backend::Slice], + value: FloatTensor, + ) -> FloatTensor { + execute_with_float_dtype!((tensor, value), |tensor, value| { + NdArrayOps::slice_assign(tensor, slices, value) + }) + } + + fn float_mask_where( + tensor: FloatTensor, + mask: NdArrayTensor, + value: FloatTensor, + ) -> FloatTensor { + execute_with_float_dtype!((tensor, value), |tensor, value| { + NdArrayOps::mask_where(tensor, mask.bool(), value) + }) + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: NdArrayTensor, + value: Scalar, + ) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::mask_fill(array, mask.bool(), value.elem()) + }) + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| { NdArrayMathOps::equal(lhs, rhs) }) + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::equal_elem(array, rhs.elem()) + }) + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| { NdArrayMathOps::greater(lhs, rhs) }) + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::greater_elem(array, rhs.elem()) + }) + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| { + NdArrayMathOps::greater_equal(lhs, rhs) + }) + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::greater_equal_elem(array, rhs.elem()) + }) + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| { NdArrayMathOps::lower(lhs, rhs) }) + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::lower_elem(array, rhs.elem()) + }) + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!((lhs, rhs), |lhs, rhs| { + NdArrayMathOps::lower_equal(lhs, rhs) + }) + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + _out_dtype: BoolDType, + ) -> NdArrayTensor { + execute_with_float_dtype!(lhs, FloatElem, |array: SharedArray| { + NdArrayMathOps::lower_equal_elem(array, rhs.elem()) + }) + } + + fn float_detach(tensor: FloatTensor) -> FloatTensor { + tensor + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + // Use view() for zero-copy on borrowed storage + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::mean_view(array.view()) + }) + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + // Use view() for zero-copy on borrowed storage + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::sum_view(array.view()) + }) + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::mean_dim(array, dim) + }) + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::cumsum(array, dim) + }) + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::cumprod(array, dim) + }) + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::cummin(array, dim) + }) + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::cummax(array, dim) + }) + } + + fn float_sum_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::sum_dim(array, dim) + }) + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_out_dtype!(out_dtype, I, { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::argmax_view::(array.view(), dim) + }) + }) + } + + fn float_argtopk( + _tensor: FloatTensor, + _dim: usize, + _k: usize, + _out_dtype: IntDType, + ) -> NdArrayTensor { + unimplemented!("float_argtopk not implemented for ndarray") + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> NdArrayTensor { + // Use view() for zero-copy on borrowed storage + execute_with_int_out_dtype!(out_dtype, I, { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::argmin_view::(array.view(), dim) + }) + }) + } + + fn float_exp(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array.mapv_into(|a: FloatElem| a.exp_elem()).into_shared() + }) + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array.mapv_into(|a: FloatElem| a.log_elem()).into_shared() + }) + } + + fn float_prod(tensor: FloatTensor) -> FloatTensor { + // Use view() for zero-copy on borrowed storage + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::prod_view(array.view()) + }) + } + + fn float_prod_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::prod_dim(array, dim) + }) + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + // Use view() for zero-copy on borrowed storage + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::max_view(array.view()) + }) + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + // Use view() for zero-copy on borrowed storage + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::min_view(array.view()) + }) + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array.mapv_into(|a: FloatElem| a.log1p_elem()).into_shared() + }) + } + + fn float_powf_scalar_impl(tensor: FloatTensor, value: Scalar) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| a.powf_elem(value.elem())) + .into_shared() + }) + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array.mapv_into(|a: FloatElem| a.sqrt_elem()).into_shared() + }) + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::abs(array) + }) + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).cos().elem()) + .into_shared() + }) + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).cosh().elem()) + .into_shared() + }) + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).sin().elem()) + .into_shared() + }) + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).sinh().elem()) + .into_shared() + }) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).tan().elem()) + .into_shared() + }) + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).tanh().elem()) + .into_shared() + }) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).acos().elem()) + .into_shared() + }) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).acosh().elem()) + .into_shared() + }) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).asin().elem()) + .into_shared() + }) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).asinh().elem()) + .into_shared() + }) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).atan().elem()) + .into_shared() + }) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).atanh().elem()) + .into_shared() + }) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), FloatElem, |lhs, rhs| { + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &FloatElem, b: &FloatElem| a.atan2(*b)) + }) + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| round_ties_even_wrapper(a.to_f64()).elem()) + .into_shared() + }) + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).floor().elem()) + .into_shared() + }) + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).ceil().elem()) + .into_shared() + }) + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| (a.to_f64()).trunc().elem()) + .into_shared() + }) + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array + .mapv_into(|a: FloatElem| erf(a.to_f64()).elem()) + .into_shared() + }) + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + cat_with_dtype!(tensors, dim, [F64, F32]) + } + + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::clamp_min(array, min.elem()) + }) + } + + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::clamp_max(array, max.elem()) + }) + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::clamp(array, min.elem(), max.elem()) + }) + } + + fn float_into_int(tensor: FloatTensor, out_dtype: IntDType) -> NdArrayTensor { + execute_with_int_out_dtype!(out_dtype, I, { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + array.mapv(|a: FloatElem| a.elem::()).into_shared() + }) + }) + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), FloatElem, |lhs, rhs| { + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &FloatElem, b: &FloatElem| a.powf(*b)) + }) + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::permute(array, axes) + }) + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::flip(array, axes) + }) + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayMathOps::sign_op(array) + }) + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::expand(array, shape) + }) + } + + fn float_cast(tensor: FloatTensor, dtype: FloatDType) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + cast_to_dtype(array, dtype.into()) + }) + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: GridSampleOptions, + ) -> FloatTensor { + execute_with_float_dtype!((tensor, grid), |tensor, grid| grid_sample_2d( + tensor, grid, options + )) + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + execute_with_float_dtype!(tensor, FloatElem, |array: SharedArray| { + NdArrayOps::unfold(array, dim, size, step) + }) + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + execute_with_float_dtype!((lhs, rhs), FloatElem, |lhs, rhs| { + NdArrayMathOps::elementwise_op(lhs, rhs, |a: &FloatElem, b: &FloatElem| a.hypot(*b)) + }) + } +} diff --git a/crates/burn-ndarray/src/ops/transaction.rs b/crates/burn-ndarray/src/ops/transaction.rs new file mode 100644 index 0000000..c482e8f --- /dev/null +++ b/crates/burn-ndarray/src/ops/transaction.rs @@ -0,0 +1,8 @@ +use crate::NdArray; +use burn_backend::distributed::DistributedOps; +use burn_backend::ops::TransactionOps; + +impl TransactionOps for NdArray {} + +// DistributedOps has default implementations; NdArray does not support collective operations. +impl DistributedOps for NdArray {} diff --git a/crates/burn-ndarray/src/parallel.rs b/crates/burn-ndarray/src/parallel.rs new file mode 100644 index 0000000..a665761 --- /dev/null +++ b/crates/burn-ndarray/src/parallel.rs @@ -0,0 +1,76 @@ +/// Macro for running a function in parallel. +#[cfg(feature = "multi-threads")] +#[macro_export(local_inner_macros)] +macro_rules! run_par { + ( + $func:expr + ) => {{ + use rayon::prelude::*; + + #[allow(clippy::redundant_closure_call)] + rayon::scope(|_| $func()) + }}; +} + +/// Macro for running a function in parallel. +#[cfg(not(feature = "multi-threads"))] +#[macro_export(local_inner_macros)] +macro_rules! run_par { + ( + $func:expr + ) => {{ $func() }}; +} + +/// Macro for iterating in parallel. +#[cfg(not(feature = "multi-threads"))] +#[macro_export(local_inner_macros)] +macro_rules! iter_par { + ( + $iter:expr + ) => {{ $iter }}; +} + +/// Macro for iterating in parallel. +#[cfg(feature = "multi-threads")] +#[macro_export(local_inner_macros)] +macro_rules! iter_par { + ( + $iter:expr + ) => {{ $iter.into_par_iter() }}; +} + +/// Macro for iterating in parallel. +#[cfg(feature = "multi-threads")] +#[macro_export(local_inner_macros)] +macro_rules! iter_slice_par { + ( + $slice:expr + ) => {{ $slice.into_par_iter() }}; +} + +/// Macro for iterating in parallel. +#[cfg(not(feature = "multi-threads"))] +#[macro_export(local_inner_macros)] +macro_rules! iter_slice_par { + ( + $slice:expr + ) => {{ $slice.iter() }}; +} + +/// Macro for iterating over a range in parallel. +#[cfg(feature = "multi-threads")] +#[macro_export(local_inner_macros)] +macro_rules! iter_range_par { + ( + $start:expr, $end:expr + ) => {{ ($start..$end).into_par_iter() }}; +} + +/// Macro for iterating over a range in parallel. +#[cfg(not(feature = "multi-threads"))] +#[macro_export(local_inner_macros)] +macro_rules! iter_range_par { + ( + $start:expr, $end:expr + ) => {{ ($start..$end) }}; +} diff --git a/crates/burn-ndarray/src/rand.rs b/crates/burn-ndarray/src/rand.rs new file mode 100644 index 0000000..94b9bcd --- /dev/null +++ b/crates/burn-ndarray/src/rand.rs @@ -0,0 +1,36 @@ +//! Random number generation utilities for burn-ndarray + +#[cfg(not(feature = "std"))] +use rand::rngs::SmallRng; +#[cfg(feature = "std")] +use rand::rngs::StdRng; + +/// Type alias for the RNG used by burn-ndarray +#[cfg(feature = "std")] +pub type NdArrayRng = StdRng; +#[cfg(not(feature = "std"))] +pub type NdArrayRng = SmallRng; + +#[cfg(not(feature = "std"))] +use rand::SeedableRng; + +/// Get a seeded random number generator +/// +/// For std builds, uses OS entropy. +/// For no_std builds, uses a compile-time random seed. +#[cfg(feature = "std")] +pub fn get_seeded_rng() -> NdArrayRng { + // Use the standard implementation from burn-std + burn_std::rand::get_seeded_rng() +} + +/// Get a seeded random number generator +/// +/// For std builds, uses OS entropy. +/// For no_std builds, uses a compile-time random seed. +#[cfg(not(feature = "std"))] +pub fn get_seeded_rng() -> NdArrayRng { + // Use compile-time random seed for no_std + const SEED: u64 = const_random::const_random!(u64); + SmallRng::seed_from_u64(SEED) +} diff --git a/crates/burn-ndarray/src/sharing.rs b/crates/burn-ndarray/src/sharing.rs new file mode 100644 index 0000000..75d5142 --- /dev/null +++ b/crates/burn-ndarray/src/sharing.rs @@ -0,0 +1,19 @@ +use core::cell::UnsafeCell; + +/// Similar to `SyncUnsafeCell` see [Rust issues](https://github.com/rust-lang/rust/issues/95439). +pub(crate) struct UnsafeSharedRef<'a, T> { + cell: UnsafeCell<&'a mut T>, +} + +unsafe impl Sync for UnsafeSharedRef<'_, T> {} + +impl<'a, T> UnsafeSharedRef<'a, T> { + pub fn new(data: &'a mut T) -> Self { + Self { + cell: UnsafeCell::new(data), + } + } + pub unsafe fn get(&self) -> &'a mut T { + unsafe { core::ptr::read(self.cell.get()) } + } +} diff --git a/crates/burn-ndarray/src/storage.rs b/crates/burn-ndarray/src/storage.rs new file mode 100644 index 0000000..7eeca47 --- /dev/null +++ b/crates/burn-ndarray/src/storage.rs @@ -0,0 +1,506 @@ +//! Copy-on-write storage for zero-copy tensor loading. +//! +//! This module provides `NdArrayStorage`, which enables true zero-copy loading +//! from burnpack files. When data is borrowed from external memory (like mmap'd files +//! or static data), it remains zero-copy until a mutating operation is performed, +//! at which point it's copied (copy-on-write semantics). +//! +//! This integrates with ndarray's existing COW patterns - operations that check +//! `is_unique()` will see borrowed data as non-unique, triggering the allocation path. + +use burn_backend::Element; +use burn_std::{Bytes, Shape}; +use core::mem; +use ndarray::{ArcArray, ArrayView, IxDyn}; + +/// Storage that supports both owned data and borrowed (zero-copy) data. +/// +/// # Copy-on-Write Semantics +/// +/// - **Borrowed**: Data from external source (burnpack, mmap, static). +/// Reports `is_unique() == false` to trigger copy on mutation. +/// - **Owned**: Standard `ArcArray` with built-in COW via Arc refcount. +/// +/// # Example +/// +/// ```ignore +/// // Zero-copy load +/// let storage = NdArrayStorage::from_borrowed(bytes, shape); +/// storage.is_unique(); // false - will copy on mutation +/// +/// // Read operations use view() - zero-copy +/// let view = storage.view(); +/// +/// // Mutation converts to owned +/// let owned = storage.into_owned(); // Copies here +/// ``` +#[derive(Debug)] +pub enum NdArrayStorage { + /// Borrowed from external source (e.g., burnpack zero-copy load). + /// Keeps `Bytes` alive to ensure the referenced memory is valid. + Borrowed { + /// Source bytes - keeps external memory alive via reference counting + bytes: Bytes, + /// Shape of the tensor + shape: Shape, + }, + + /// Standard owned storage with ArcArray COW semantics. + Owned(ArcArray), +} + +impl Clone for NdArrayStorage { + fn clone(&self) -> Self { + match self { + // For borrowed data, clone the Bytes (cheap Arc clone) and shape + Self::Borrowed { bytes, shape } => Self::Borrowed { + bytes: bytes.clone(), + shape: shape.clone(), + }, + // For owned data, clone the ArcArray (cheap Arc clone) + Self::Owned(arr) => Self::Owned(arr.clone()), + } + } +} + +impl NdArrayStorage { + /// Create borrowed storage from external bytes. + /// + /// Returns the bytes and shape back on failure (misaligned or too small), + /// enabling zero-copy even for native allocations by avoiding defensive cloning. + /// + /// # Requirements + /// + /// The caller must ensure that: + /// - The `Bytes` contain valid data for the element type `E` + /// - The data is contiguous in row-major (C) order matching the provided shape + /// + /// These requirements are upheld when loading from `TensorData` (burnpack, etc.) + /// which always stores data contiguously in row-major order. + pub fn from_borrowed(bytes: Bytes, shape: impl Into) -> Result { + let shape = shape.into(); + // Validate alignment + let ptr = bytes.as_ptr(); + if !(ptr as usize).is_multiple_of(mem::align_of::()) { + return Err((bytes, shape)); + } + + // Validate size (using checked arithmetic to prevent overflow) + let num_elements = match shape + .iter() + .try_fold(1usize, |acc, &dim| acc.checked_mul(dim)) + { + Some(n) => n, + None => return Err((bytes, shape)), + }; + let expected_size = match num_elements.checked_mul(mem::size_of::()) { + Some(s) => s, + None => return Err((bytes, shape)), + }; + if bytes.len() < expected_size { + return Err((bytes, shape)); + } + + Ok(Self::Borrowed { bytes, shape }) + } + + /// Create owned storage from an ArcArray. + #[inline] + pub fn from_owned(array: ArcArray) -> Self { + Self::Owned(array) + } + + /// Returns whether this storage is uniquely owned and can be mutated in-place. + /// + /// - **Borrowed**: Always returns `false` to trigger copy-on-write. + /// - **Owned**: Delegates to `ArcArray::is_unique()`. + /// + /// This integrates with existing SIMD code patterns like: + /// ```ignore + /// if tensor.is_unique() { + /// // mutate in place + /// } else { + /// // allocate new + /// } + /// ``` + #[inline] + pub fn is_unique(&self) -> bool { + match self { + Self::Borrowed { .. } => false, // Force copy path + Self::Owned(arr) => arr.is_unique(), + } + } + + /// Get a read-only view of the data. + /// + /// This is zero-copy for both borrowed and owned variants. + #[inline] + pub fn view(&self) -> ArrayView<'_, E, IxDyn> { + match self { + Self::Borrowed { bytes, shape } => { + let ptr = bytes.as_ptr() as *const E; + let dim = IxDyn(shape); + // SAFETY: + // - `bytes` is kept alive for the lifetime of `self` + // - Alignment was validated in `from_borrowed` + // - Size was validated in `from_borrowed` + unsafe { ArrayView::from_shape_ptr(dim, ptr) } + } + Self::Owned(arr) => arr.view(), + } + } + + /// Convert to owned ArcArray. + /// + /// - **Borrowed**: Copies the data into a new ArcArray. + /// - **Owned + unique**: Returns the array without copying. + /// - **Owned + shared**: Clones the data. + pub fn into_owned(self) -> ArcArray { + match self { + Self::Borrowed { bytes, shape } => { + let ptr = bytes.as_ptr() as *const E; + let dim = IxDyn(&shape); + // SAFETY: Same as view() - bytes is valid for this scope + let view = unsafe { ArrayView::from_shape_ptr(dim, ptr) }; + view.to_owned().into_shared() + } + Self::Owned(arr) => arr, + } + } + + /// Convert to shared ArcArray, suitable for returning from operations. + /// + /// This is equivalent to `into_owned()` but named for clarity. + #[inline] + pub fn into_shared(self) -> ArcArray { + self.into_owned() + } + + /// Get the shape of the tensor. + pub fn shape(&self) -> &[usize] { + match self { + Self::Borrowed { shape, .. } => shape, + Self::Owned(arr) => arr.shape(), + } + } + + /// Get the number of dimensions. + #[inline] + pub fn ndim(&self) -> usize { + self.shape().len() + } + + /// Get the total number of elements. + #[inline] + pub fn len(&self) -> usize { + self.shape().iter().product() + } + + /// Check if the tensor is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns `true` if this is borrowed (zero-copy) storage. + #[inline] + pub fn is_borrowed(&self) -> bool { + matches!(self, Self::Borrowed { .. }) + } + + /// Returns `true` if this is owned storage. + #[inline] + pub fn is_owned(&self) -> bool { + matches!(self, Self::Owned(_)) + } + + /// Ensure owned and return mutable reference to the ArcArray. + /// + /// Converts borrowed to owned if necessary. + pub fn ensure_owned(&mut self) -> &mut ArcArray { + if let Self::Borrowed { bytes, shape } = self { + let ptr = bytes.as_ptr() as *const E; + let dim = IxDyn(shape); + // SAFETY: Same as view() + let view = unsafe { ArrayView::from_shape_ptr(dim, ptr) }; + *self = Self::Owned(view.to_owned().into_shared()); + } + match self { + Self::Owned(arr) => arr, + Self::Borrowed { .. } => unreachable!(), + } + } +} + +/// Convert from ArcArray to NdArrayStorage. +impl From> for NdArrayStorage { + fn from(array: ArcArray) -> Self { + Self::Owned(array) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{vec, vec::Vec}; + use burn_std::Bytes; + + #[test] + fn test_borrowed_is_not_unique() { + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + assert!(!storage.is_unique()); + assert!(storage.is_borrowed()); + } + + #[test] + fn test_owned_unique_when_single_ref() { + let array = ndarray::ArrayD::from_elem(IxDyn(&[2, 2]), 1.0f32).into_shared(); + let storage = NdArrayStorage::from_owned(array); + + assert!(storage.is_unique()); + assert!(storage.is_owned()); + } + + #[test] + fn test_owned_not_unique_when_cloned() { + let array = ndarray::ArrayD::from_elem(IxDyn(&[2, 2]), 1.0f32).into_shared(); + let storage = NdArrayStorage::from_owned(array); + let _clone = storage.clone(); + + assert!(!storage.is_unique()); + } + + #[test] + fn test_view_zero_copy() { + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + let view = storage.view(); + assert_eq!(view[[0, 0]], 1.0); + assert_eq!(view[[1, 1]], 4.0); + } + + #[test] + fn test_into_owned_copies_borrowed() { + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + let owned = storage.into_owned(); + assert_eq!(owned[[0, 0]], 1.0); + assert_eq!(owned[[1, 1]], 4.0); + } + + #[test] + fn test_from_borrowed_validates_alignment() { + use burn_std::AllocationProperty; + + // Test 1: Properly aligned data should succeed + let aligned_data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let aligned_bytes = Bytes::from_elems(aligned_data); + + // Verify test setup - should be 4-byte aligned for f32 + assert_eq!( + (aligned_bytes.as_ptr() as usize) % core::mem::align_of::(), + 0, + "Test setup: f32 data should be properly aligned" + ); + + let result = NdArrayStorage::::from_borrowed(aligned_bytes, [2, 2]); + assert!( + result.is_ok(), + "from_borrowed should succeed for properly aligned data" + ); + + // Test 2: Misaligned data should fail + // Create a buffer large enough to find a misaligned offset + // (static data placement varies by platform, so we find an offset dynamically) + let buffer: &[u8] = &[0u8; 32]; + let shared = bytes::Bytes::from_static(buffer); + let base = shared.as_ptr() as usize; + let align = core::mem::align_of::(); + + // Find an offset in 1..align that produces misalignment (at least one must exist) + let misalign_offset = (1..align) + .find(|&off| !(base + off).is_multiple_of(align)) + .expect("Should find a misaligned offset"); + + let sliced = shared.slice(misalign_offset..(misalign_offset + 16)); + let misaligned_bytes = Bytes::from_shared(sliced, AllocationProperty::Other); + + // Verify test setup - should NOT be 4-byte aligned + assert_ne!( + (misaligned_bytes.as_ptr() as usize) % align, + 0, + "Test setup: sliced data should be misaligned for f32" + ); + + let result = NdArrayStorage::::from_borrowed(misaligned_bytes, [4]); + assert!( + result.is_err(), + "from_borrowed should return Err for misaligned data" + ); + } + + #[test] + fn test_insufficient_size_returns_err() { + // Create bytes that are too small for the requested shape + let data: Vec = vec![1.0, 2.0]; // 8 bytes + let bytes = Bytes::from_elems(data); + + // Try to create storage for 4 elements (needs 16 bytes) + let result = NdArrayStorage::::from_borrowed(bytes, [4]); + assert!( + result.is_err(), + "from_borrowed should return Err when bytes are too small" + ); + } + + // ========================================================================== + // Zero-copy hardening tests + // These tests verify the zero-copy guarantee is maintained. If any of these + // fail, it indicates a regression in zero-copy functionality. + // ========================================================================== + + #[test] + fn test_zero_copy_native_allocation() { + // CRITICAL: Verify that native allocations (Bytes::from_elems) are zero-copy + // on initial load. The view() must return a pointer to the SAME memory. + // + // Note: Native allocations copy on clone (this is expected), but the initial + // load is still zero-copy, avoiding an extra copy in the common case where + // the tensor is used without cloning. + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let original_ptr = bytes.as_ptr(); + + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + // Initial load must be zero-copy + let view = storage.view(); + let view_ptr = view.as_ptr() as *const u8; + + assert_eq!( + original_ptr, view_ptr, + "ZERO-COPY REGRESSION: native allocation view() must return pointer to original bytes" + ); + + // Verify data integrity + assert_eq!(view[[0, 0]], 1.0); + assert_eq!(view[[0, 1]], 2.0); + assert_eq!(view[[1, 0]], 3.0); + assert_eq!(view[[1, 1]], 4.0); + } + + #[test] + fn test_zero_copy_shared_bytes_pointer_identity() { + // CRITICAL: Test with SharedBytesAllocationController for true zero-copy. + // This simulates the actual burnpack/mmap loading path. + use burn_std::AllocationProperty; + + // Create static-like data using bytes::Bytes + let data: &[u8] = &[ + 0, 0, 128, 63, // 1.0f32 in little-endian + 0, 0, 0, 64, // 2.0f32 + 0, 0, 64, 64, // 3.0f32 + 0, 0, 128, 64, // 4.0f32 + ]; + let shared = bytes::Bytes::from_static(data); + let original_ptr = shared.as_ptr(); + + // Create Bytes with SharedBytesAllocationController + let bytes = Bytes::from_shared(shared, AllocationProperty::Other); + + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + // Verify pointer identity + let view_ptr = storage.view().as_ptr() as *const u8; + assert_eq!( + original_ptr, view_ptr, + "ZERO-COPY REGRESSION: SharedBytes view must point to original static data" + ); + + // Clone should also share the same memory + let cloned = storage.clone(); + let cloned_ptr = cloned.view().as_ptr() as *const u8; + assert_eq!( + original_ptr, cloned_ptr, + "ZERO-COPY REGRESSION: SharedBytes clone must share memory" + ); + } + + #[test] + fn test_clone_borrowed_stays_borrowed() { + // Verify that cloning borrowed storage produces another borrowed storage. + // Note: The underlying Bytes may or may not share memory depending on + // the allocation controller (native allocations copy, file-backed may share). + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + let cloned = storage.clone(); + + // Both should still be borrowed (the storage type is preserved) + assert!( + storage.is_borrowed(), + "ZERO-COPY REGRESSION: original should remain borrowed after clone" + ); + assert!( + cloned.is_borrowed(), + "ZERO-COPY REGRESSION: clone should be borrowed type" + ); + + // Both should report not unique (important for COW behavior) + assert!( + !storage.is_unique(), + "ZERO-COPY REGRESSION: original should not be unique after clone" + ); + assert!( + !cloned.is_unique(), + "ZERO-COPY REGRESSION: clone should not be unique" + ); + + // Data should be identical + assert_eq!(storage.view(), cloned.view(), "Clone should have same data"); + } + + #[test] + fn test_zero_copy_triggers_copy_on_mutation() { + // Verify that into_owned() on borrowed data creates a NEW allocation + // (this is the "copy" in copy-on-write) + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let original_ptr = bytes.as_ptr(); + + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + assert!(storage.is_borrowed(), "should start as borrowed"); + + let owned = storage.into_owned(); + let owned_ptr = owned.as_ptr() as *const u8; + + assert_ne!( + original_ptr, owned_ptr, + "into_owned() on borrowed data MUST allocate new memory (copy-on-write)" + ); + } + + #[test] + fn test_borrowed_reports_not_unique() { + // CRITICAL: Borrowed storage must report is_unique() == false + // This is what triggers copy-on-write in mutation operations + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let storage = NdArrayStorage::::from_borrowed(bytes, [2, 2]).expect("should create"); + + assert!( + !storage.is_unique(), + "ZERO-COPY REGRESSION: borrowed storage MUST report is_unique() == false \ + to trigger copy-on-write. If this is true, mutations will corrupt shared data!" + ); + } +} diff --git a/crates/burn-ndarray/src/tensor.rs b/crates/burn-ndarray/src/tensor.rs new file mode 100644 index 0000000..baa3df7 --- /dev/null +++ b/crates/burn-ndarray/src/tensor.rs @@ -0,0 +1,971 @@ +use burn_backend::{ + AllocationProperty, DType, Element, Shape, TensorData, TensorMetadata, + quantization::{QParams, QuantLevel, QuantMode, QuantScheme, QuantValue}, +}; +use burn_std::BoolStore; + +use crate::ops::quantization::{QuantizationStrategy, SymmetricQuantization}; +use crate::{NdArrayDevice, NdArrayStorage}; +use alloc::vec::Vec; +use ndarray::{ArcArray, ArrayD, IxDyn}; + +/// Concrete storage type for ndarray (owned with COW semantics via Arc) +pub type SharedArray = ArcArray; + +/// Tensor primitive used by the [ndarray backend](crate::NdArray). +/// +/// Supports both owned and borrowed (zero-copy) data via `NdArrayStorage`. +/// When data is borrowed from external sources (like burnpack files), +/// it remains zero-copy until a mutating operation is performed. +#[derive(Debug, Clone)] +#[allow(missing_docs)] +pub enum NdArrayTensor { + F64(NdArrayStorage), + F32(NdArrayStorage), + I64(NdArrayStorage), + I32(NdArrayStorage), + I16(NdArrayStorage), + I8(NdArrayStorage), + U64(NdArrayStorage), + U32(NdArrayStorage), + U16(NdArrayStorage), + U8(NdArrayStorage), + Bool(NdArrayStorage), +} + +impl NdArrayTensor { + /// Extract bool array, converting to owned if necessary. + pub(crate) fn bool(self) -> SharedArray { + match self { + NdArrayTensor::Bool(storage) => storage.into_shared(), + _ => unimplemented!("Expected bool tensor, got {:?}", self.dtype()), + } + } + + /// Returns true if this tensor uses borrowed (zero-copy) storage. + #[inline] + pub fn is_borrowed(&self) -> bool { + macro_rules! check { + ($($variant:ident),*) => { + match self { + $(NdArrayTensor::$variant(s) => s.is_borrowed(),)* + } + }; + } + check!(F64, F32, I64, I32, I16, I8, U64, U32, U16, U8, Bool) + } +} + +pub(crate) fn cast_to_dtype(array: SharedArray, dtype: DType) -> NdArrayTensor +where + NdArrayTensor: From>, +{ + fn cast(array: SharedArray) -> SharedArray { + array.mapv(|a| a.elem()).into_shared() + } + + if E1::dtype() == dtype { + return array.into(); + } + + match dtype { + DType::F64 => cast::(array).into(), + DType::F32 => cast::(array).into(), + DType::Flex32 => cast::(array).into(), + DType::I64 => cast::(array).into(), + DType::I32 => cast::(array).into(), + DType::I16 => cast::(array).into(), + DType::I8 => cast::(array).into(), + DType::U64 => cast::(array).into(), + DType::U32 => cast::(array).into(), + DType::U16 => cast::(array).into(), + DType::U8 => cast::(array).into(), + DType::Bool(BoolStore::Native) => cast::(array).into(), + dtype => panic!("Unsupported dtype: {dtype:?}"), + } +} + +macro_rules! impl_from { + ($($ty: ty => $dtype: ident),*) => { + // From SharedArray (owned) -> NdArrayTensor + $(impl From> for NdArrayTensor { + fn from(value: SharedArray<$ty>) -> NdArrayTensor { + NdArrayTensor::$dtype(NdArrayStorage::from_owned(value)) + } + })* + + // From NdArrayStorage -> NdArrayTensor + $(impl From> for NdArrayTensor { + fn from(value: NdArrayStorage<$ty>) -> NdArrayTensor { + NdArrayTensor::$dtype(value) + } + })* + }; +} + +impl_from!( + f64 => F64, f32 => F32, + i64 => I64, i32 => I32, i16 => I16, i8 => I8, + u64 => U64, u32 => U32, u16 => U16, u8 => U8, + bool => Bool +); + +/// Macro to execute an operation on a given element type. +/// +/// Extracts the storage from NdArrayTensor, converts to SharedArray, and passes to operation. +/// +/// # Panics +/// Since there is no automatic type cast at this time, binary operations for different +/// floating point precision data types will panic with a data type mismatch. +#[macro_export] +macro_rules! execute_with_dtype { + (($lhs:expr, $rhs:expr),$element:ident, $op:expr, [$($dtype: ident => $ty: ty),*]) => {{ + let lhs_dtype = burn_backend::TensorMetadata::dtype(&$lhs); + let rhs_dtype = burn_backend::TensorMetadata::dtype(&$rhs); + match ($lhs, $rhs) { + $( + ($crate::NdArrayTensor::$dtype(lhs), $crate::NdArrayTensor::$dtype(rhs)) => { + #[allow(unused)] + type $element = $ty; + // Convert storage to SharedArray for compatibility with existing operations + $op(lhs.into_shared(), rhs.into_shared()).into() + } + )* + _ => panic!( + "Data type mismatch (lhs: {:?}, rhs: {:?})", + lhs_dtype, rhs_dtype + ), + } + }}; + // Binary op: type automatically inferred by the compiler + (($lhs:expr, $rhs:expr), $op:expr) => {{ + $crate::execute_with_dtype!(($lhs, $rhs), E, $op) + }}; + + // Binary op: generic type cannot be inferred for an operation + (($lhs:expr, $rhs:expr), $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!(($lhs, $rhs), $element, $op, [ + F64 => f64, F32 => f32, + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8, + Bool => bool + ]) + }}; + + ($tensor:expr, $element:ident, $op:expr, [$($dtype: ident => $ty: ty),*]) => {{ + match $tensor { + $( + $crate::NdArrayTensor::$dtype(storage) => { + #[allow(unused)] + type $element = $ty; + // Convert to SharedArray for compatibility with most operations + $op(storage.into_shared()).into() + } + )* + #[allow(unreachable_patterns)] + other => unimplemented!("unsupported dtype: {:?}", other.dtype()) + } + }}; + // Unary op: type automatically inferred by the compiler + ($tensor:expr, $op:expr) => {{ + $crate::execute_with_dtype!($tensor, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($tensor:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!($tensor, $element, $op, [ + F64 => f64, F32 => f32, + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8, + Bool => bool + ]) + }}; +} + +/// Macro to execute an operation a given element type. +/// Only handles float types. +/// +/// # Panics +/// Since there is no automatic type cast at this time, binary operations for different +/// floating point precision data types will panic with a data type mismatch. +#[macro_export] +macro_rules! execute_with_float_dtype { + // Binary op: type automatically inferred by the compiler + (($lhs:expr, $rhs:expr), $op:expr) => {{ + $crate::execute_with_float_dtype!(($lhs, $rhs), E, $op) + }}; + + // Binary op: generic type cannot be inferred for an operation + (($lhs:expr, $rhs:expr), $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!(($lhs, $rhs), $element, $op, [ + F64 => f64, F32 => f32 + ]) + }}; + + // Unary op: type automatically inferred by the compiler + ($tensor:expr, $op:expr) => {{ + $crate::execute_with_float_dtype!($tensor, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($tensor:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!($tensor, $element, $op, [ + F64 => f64, F32 => f32 + ]) + }}; +} + +/// Macro to execute an operation a given element type. +/// Only handles int types. +/// +/// # Panics +/// Since there is no automatic type cast at this time, binary operations for different +/// floating point precision data types will panic with a data type mismatch. +#[macro_export] +macro_rules! execute_with_int_dtype { + // Binary op: type automatically inferred by the compiler + (($lhs:expr, $rhs:expr), $op:expr) => {{ + $crate::execute_with_int_dtype!(($lhs, $rhs), E, $op) + }}; + + // Binary op: generic type cannot be inferred for an operation + (($lhs:expr, $rhs:expr), $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!(($lhs, $rhs), $element, $op, [ + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8 + ]) + }}; + + // Unary op: type automatically inferred by the compiler + ($tensor:expr, $op:expr) => {{ + $crate::execute_with_int_dtype!($tensor, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($tensor:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!($tensor, $element, $op, [ + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8 + ]) + }}; +} + +/// Macro to execute an operation a given element type. +/// Only handles numeric types +/// +/// # Panics +/// Since there is no automatic type cast at this time, binary operations for different +/// floating point precision data types will panic with a data type mismatch. +#[macro_export] +macro_rules! execute_with_numeric_dtype { + // Binary op: type automatically inferred by the compiler + (($lhs:expr, $rhs:expr), $op:expr) => {{ + $crate::execute_with_numeric_dtype!(($lhs, $rhs), E, $op) + }}; + + // Binary op: generic type cannot be inferred for an operation + (($lhs:expr, $rhs:expr), $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!(($lhs, $rhs), $element, $op, [ + F64 => f64, F32 => f32, + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8 + ]) + }}; + + // Unary op: type automatically inferred by the compiler + ($tensor:expr, $op:expr) => {{ + $crate::execute_with_numeric_dtype!($tensor, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($tensor:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_dtype!($tensor, $element, $op, [ + F64 => f64, F32 => f32, + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8 + ]) + }}; +} + +/// Macro to execute a cat operation on a given set of element types. +/// +/// Uses zero-copy views from storage for concatenation. +/// +/// # Panics +/// Since there is no automatic type cast at this time, binary operations for different +/// floating point precision data types will panic with a data type mismatch. +#[macro_export] +macro_rules! cat_with_dtype { + ($tensors: expr, $dim: expr, [$($dtype: ident),*]) => { + match &$tensors[0] { + $(NdArrayTensor::$dtype(_) => { + let tensors = $tensors + .iter() + .map(|t| { + if let NdArrayTensor::$dtype(storage) = t { + // Use storage.view() for zero-copy access + storage.view() + } else { + panic!("Concatenate data type mismatch (expected {:?}, got {:?})", $tensors[0].dtype(), t.dtype()) + } + }) + .collect::>(); + NdArrayOps::concatenate(&tensors, $dim).into() + })* + _ => panic!("Unsupported dtype: {:?}", $tensors[0].dtype()) + } + }; +} + +/// Macro to execute an operation that returns a given element type. +#[macro_export] +macro_rules! execute_with_float_out_dtype { + ($out_dtype:expr, $element:ident, $op:expr, [$($dtype: ident => $ty: ty),*]) => {{ + match $out_dtype { + $( + burn_std::FloatDType::$dtype => { + #[allow(unused)] + type $element = $ty; + $op + } + )* + #[allow(unreachable_patterns)] + other => unimplemented!("unsupported dtype: {other:?}") + } + }}; + // Unary op: type automatically inferred by the compiler + ($out_dtype:expr, $op:expr) => {{ + $crate::execute_with_float_out_dtype!($out_dtype, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($out_dtype:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_float_out_dtype!($out_dtype, $element, $op, [ + F64 => f64, F32 => f32 + ]) + }}; +} + +/// Macro to execute an operation that returns a given element type. +#[macro_export] +macro_rules! execute_with_int_out_dtype { + ($out_dtype:expr, $element:ident, $op:expr, [$($dtype: ident => $ty: ty),*]) => {{ + match $out_dtype { + $( + burn_std::IntDType::$dtype => { + #[allow(unused)] + type $element = $ty; + $op + } + )* + #[allow(unreachable_patterns)] + other => unimplemented!("unsupported dtype: {other:?}") + } + }}; + // Unary op: type automatically inferred by the compiler + ($out_dtype:expr, $op:expr) => {{ + $crate::execute_with_int_out_dtype!($out_dtype, E, $op) + }}; + + // Unary op: generic type cannot be inferred for an operation + ($out_dtype:expr, $element:ident, $op:expr) => {{ + $crate::execute_with_int_out_dtype!($out_dtype, $element, $op, [ + I64 => i64, I32 => i32, I16 => i16, I8 => i8, + U64 => u64, U32 => u32, U16 => u16, U8 => u8 + ]) + }}; +} + +impl TensorMetadata for NdArrayTensor { + type Device = NdArrayDevice; + fn dtype(&self) -> DType { + match self { + NdArrayTensor::F64(_) => DType::F64, + NdArrayTensor::F32(_) => DType::F32, + NdArrayTensor::I64(_) => DType::I64, + NdArrayTensor::I32(_) => DType::I32, + NdArrayTensor::I16(_) => DType::I16, + NdArrayTensor::I8(_) => DType::I8, + NdArrayTensor::U64(_) => DType::U64, + NdArrayTensor::U32(_) => DType::U32, + NdArrayTensor::U16(_) => DType::U16, + NdArrayTensor::U8(_) => DType::U8, + NdArrayTensor::Bool(_) => DType::Bool(BoolStore::Native), + } + } + + fn shape(&self) -> Shape { + // Use storage's shape method (works for both borrowed and owned) + macro_rules! get_shape { + ($($variant:ident),*) => { + match self { + $(NdArrayTensor::$variant(storage) => Shape::from(storage.shape().to_vec()),)* + } + }; + } + get_shape!(F64, F32, I64, I32, I16, I8, U64, U32, U16, U8, Bool) + } + + fn rank(&self) -> usize { + self.shape().num_dims() + } + + fn device(&self) -> NdArrayDevice { + NdArrayDevice::Cpu + } + + fn can_mut(&self) -> bool { + // NdArray storage is copy-on-write (`ArcArray`) without a public + // uniqueness check at this level; in-place ops resolve sharing + // themselves, so conservatively report the buffer as shared. + false + } +} + +pub(crate) trait ShapeOps { + fn num_dims(self) -> usize; + fn num_elements(self) -> usize; + fn dims(self) -> [usize; N]; + fn into_shape(self) -> Shape; +} + +impl ShapeOps for &[usize] { + fn num_dims(self) -> usize { + self.len() + } + + fn num_elements(self) -> usize { + self.iter().product() + } + + fn dims(self) -> [usize; N] { + self.try_into().unwrap() + } + + fn into_shape(self) -> Shape { + Shape::from(self) + } +} + +mod utils { + use burn_std::tensor::is_contiguous; + + use super::*; + + impl NdArrayTensor { + pub(crate) fn into_data(self) -> TensorData { + let shape = self.shape(); + let contiguous = self.is_contiguous(); + + fn inner( + shape: Shape, + is_contiguous: bool, + array: ArcArray, + ) -> TensorData { + let vec = if is_contiguous { + match array.try_into_owned_nocopy() { + Ok(owned) => { + let (mut vec, offset) = owned.into_raw_vec_and_offset(); + if let Some(offset) = offset { + vec.drain(..offset); + } + if vec.len() > shape.num_elements() { + vec.drain(shape.num_elements()..vec.len()); + } + vec + } + Err(array) => array.into_iter().collect(), + } + } else { + array.into_iter().collect() + }; + + TensorData::new(vec, shape) + } + + // Convert storage to owned array before extracting data + execute_with_dtype!(self, |arr| inner(shape, contiguous, arr)) + } + + pub(crate) fn is_contiguous(&self) -> bool { + // For borrowed data, we assume it's contiguous (it came from TensorData which is contiguous) + // For owned data, we check the strides + macro_rules! check_contiguous { + ($($variant:ident),*) => { + match self { + $(NdArrayTensor::$variant(storage) => { + match storage { + NdArrayStorage::Borrowed { .. } => { + // Borrowed storage requires contiguous row-major data + // (see NdArrayStorage::from_borrowed documentation) + true + } + NdArrayStorage::Owned(array) => { + let shape = array.shape(); + let mut strides = Vec::with_capacity(array.strides().len()); + for &stride in array.strides() { + if stride <= 0 { + return false; + } + strides.push(stride as usize); + } + is_contiguous(shape, &strides) + } + } + })* + } + }; + } + check_contiguous!(F64, F32, I64, I32, I16, I8, U64, U32, U16, U8, Bool) + } + } +} + +/// Converts a slice of usize to a typed dimension. +#[macro_export(local_inner_macros)] +macro_rules! to_typed_dims { + ( + $n:expr, + $dims:expr, + justdim + ) => {{ + let mut dims = [0; $n]; + for i in 0..$n { + dims[i] = $dims[i]; + } + let dim: Dim<[usize; $n]> = Dim(dims); + dim + }}; +} + +/// Reshapes an array into a tensor. +#[macro_export(local_inner_macros)] +macro_rules! reshape { + ( + ty $ty:ty, + n $n:expr, + shape $shape:expr, + array $array:expr + ) => {{ + let dim = $crate::to_typed_dims!($n, $shape, justdim); + let array = match $array.is_standard_layout() { + // Move the array into the new shape rather than going through + // `to_shape`: the latter returns a borrowed view here, which + // `into_shared` then clones, copying the buffer on every reshape. + // Moving rewrites the dimensions in place, and the buffer stays + // shared for copy-on-write like in any other operation. + true => { + match $array.into_shape_with_order(dim) { + Ok(val) => val, + Err(err) => { + core::panic!("Shape should be compatible shape={dim:?}: {err:?}"); + } + } + }, + false => $array.to_shape(dim).unwrap().as_standard_layout().into_shared(), + }; + array.into_dyn() + }}; + ( + ty $ty:ty, + shape $shape:expr, + array $array:expr, + d $D:expr + ) => {{ + match $D { + 1 => reshape!(ty $ty, n 1, shape $shape, array $array), + 2 => reshape!(ty $ty, n 2, shape $shape, array $array), + 3 => reshape!(ty $ty, n 3, shape $shape, array $array), + 4 => reshape!(ty $ty, n 4, shape $shape, array $array), + 5 => reshape!(ty $ty, n 5, shape $shape, array $array), + 6 => reshape!(ty $ty, n 6, shape $shape, array $array), + _ => core::panic!("NdArray supports arrays up to 6 dimensions, received: {}", $D), + } + }}; +} + +/// Slice a tensor +#[macro_export] +macro_rules! slice { + ($tensor:expr, $slices:expr) => { + slice!($tensor, $slices, F64, F32, I64, I32, I16, I8, U64, U32, U16, U8, Bool) + }; + ($tensor:expr, $slices:expr, $($variant:ident),*) => { + match $tensor { + $(NdArrayTensor::$variant(s) => { NdArrayOps::slice(s.view(), $slices).into() })* + } + }; +} + +impl NdArrayTensor { + /// Create a new [ndarray tensor](NdArrayTensor) from [data](TensorData). + /// + /// This method attempts zero-copy loading when possible. If the data has properly + /// aligned bytes that can be borrowed, it creates a borrowed tensor. Otherwise, + /// it falls back to copying the data. + /// + /// Zero-copy loading works when: + /// - The data's bytes are properly aligned for the element type + /// - The bytes can be borrowed (e.g., from mmap'd file or static data) + pub fn from_data(data: TensorData) -> NdArrayTensor { + // Only use Borrowed storage for non-native allocations (e.g., burnpack mmap/file). + // For native Rust heap allocations (the common case), go directly to owned storage: + // `from_data_owned` reclaims the Vec zero-copy via `into_vec`, while + // Borrowed storage would trigger a full memcopy on every single operation. + if data.bytes.property() != AllocationProperty::Native { + match Self::try_from_data_borrowed(data) { + Ok(tensor) => return tensor, + Err(data) => return Self::from_data_owned(data), + } + } + Self::from_data_owned(data) + } + + /// Try to create a tensor with borrowed storage (zero-copy). + /// + /// Takes ownership of TensorData and returns it back on failure. + /// No cloning occurs - bytes are moved into storage or returned on failure. + /// + /// Returns `Err(data)` if borrowing is not possible (e.g., misaligned data). + fn try_from_data_borrowed(data: TensorData) -> Result { + let TensorData { + bytes, + shape, + dtype, + } = data; + + macro_rules! try_borrow { + ($ty:ty, $variant:ident, $bytes:expr, $shape:expr) => { + match NdArrayStorage::<$ty>::from_borrowed($bytes, $shape) { + Ok(storage) => return Ok(NdArrayTensor::$variant(storage)), + Err((bytes, shape)) => (bytes, shape), + } + }; + } + + // Try to create borrowed storage; get bytes back on failure + let (bytes, shape) = match dtype { + DType::F64 => try_borrow!(f64, F64, bytes, shape), + DType::F32 => try_borrow!(f32, F32, bytes, shape), + DType::I64 => try_borrow!(i64, I64, bytes, shape), + DType::I32 => try_borrow!(i32, I32, bytes, shape), + DType::I16 => try_borrow!(i16, I16, bytes, shape), + DType::I8 => try_borrow!(i8, I8, bytes, shape), + DType::U64 => try_borrow!(u64, U64, bytes, shape), + DType::U32 => try_borrow!(u32, U32, bytes, shape), + DType::U16 => try_borrow!(u16, U16, bytes, shape), + DType::U8 => try_borrow!(u8, U8, bytes, shape), + DType::Bool(BoolStore::Native) => try_borrow!(bool, Bool, bytes, shape), + _ => (bytes, shape), // QFloat not supported for zero-copy + }; + + Err(TensorData { + bytes, + shape, + dtype, + }) + } + + /// Create a tensor with owned storage. + /// + /// This may or may not copy data depending on whether the underlying bytes + /// can be reclaimed (via `try_into_vec`). If bytes are uniquely owned, + /// no copy occurs; otherwise data is copied to a new allocation. + fn from_data_owned(data: TensorData) -> NdArrayTensor { + let shape = data.shape.to_vec(); // TODO: into_vec + + macro_rules! execute { + ($data: expr, [$($dtype: pat => $ty: ty),*]) => { + match $data.dtype { + $( $dtype => { + match data.into_vec::<$ty>() { + Ok(vec) => unsafe { ArrayD::from_shape_vec_unchecked(shape, vec) }.into_shared(), + Err(err) => panic!("Data should have the same element type as the tensor {err:?}"), + }.into() + }, )* + other => unimplemented!("Unsupported dtype {other:?}"), + } + }; + } + + execute!(data, [ + DType::F64 => f64, DType::F32 => f32, + DType::I64 => i64, DType::I32 => i32, DType::I16 => i16, DType::I8 => i8, + DType::U64 => u64, DType::U32 => u32, DType::U16 => u16, DType::U8 => u8, + DType::Bool(BoolStore::Native) => bool + ]) + } +} + +/// A quantized tensor for the ndarray backend. +#[derive(Clone, Debug)] +pub struct NdArrayQTensor { + /// The quantized tensor. + pub qtensor: NdArrayTensor, + /// The quantization scheme. + pub scheme: QuantScheme, + /// The quantization parameters. + pub qparams: Vec>, +} + +impl NdArrayQTensor { + /// Returns the quantization strategy, including quantization parameters, for the given tensor. + pub fn strategy(&self) -> QuantizationStrategy { + match self.scheme { + QuantScheme { + level: QuantLevel::Tensor, + mode: QuantMode::Symmetric, + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::E4M3 + | QuantValue::E5M2 + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::E2M1 + | QuantValue::Q2F + | QuantValue::Q2S, + .. + } => QuantizationStrategy::PerTensorSymmetric(SymmetricQuantization::init( + self.qparams[0].scales, + self.scheme.value, + )), + QuantScheme { + level: QuantLevel::Block(block_size), + mode: QuantMode::Symmetric, + value: + QuantValue::Q8F + | QuantValue::Q8S + | QuantValue::E4M3 + | QuantValue::E5M2 + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::E2M1 + | QuantValue::Q2F + | QuantValue::Q2S, + .. + } => QuantizationStrategy::PerBlockSymmetric( + self.qparams + .iter() + .map(|q| SymmetricQuantization::init(q.scales, self.scheme.value)) + .collect(), + block_size, + ), + } + } +} + +impl TensorMetadata for NdArrayQTensor { + type Device = NdArrayDevice; + fn dtype(&self) -> DType { + DType::QFloat(self.scheme) + } + + fn shape(&self) -> Shape { + self.qtensor.shape() + } + + fn rank(&self) -> usize { + self.shape().num_dims() + } + + fn device(&self) -> Self::Device { + NdArrayDevice::Cpu + } + + fn can_mut(&self) -> bool { + self.qtensor.can_mut() + } +} + +#[cfg(test)] +mod tests { + use crate::NdArray; + use alloc::vec; + + use super::*; + use burn_backend::{ + Distribution, + ops::{FloatTensorOps, QTensorOps}, + quantization::{QuantStore, QuantizationParametersPrimitive}, + }; + use burn_std::rand::get_seeded_rng; + + #[test] + fn should_support_into_and_from_data_1d() { + let data_expected = TensorData::random::( + Shape::new([3]), + Distribution::Default, + &mut get_seeded_rng(), + ); + let tensor = NdArrayTensor::from_data(data_expected.clone()); + + let data_actual = tensor.into_data(); + + assert_eq!(data_expected, data_actual); + } + + #[test] + fn should_support_into_and_from_data_2d() { + let data_expected = TensorData::random::( + Shape::new([2, 3]), + Distribution::Default, + &mut get_seeded_rng(), + ); + let tensor = NdArrayTensor::from_data(data_expected.clone()); + + let data_actual = tensor.into_data(); + + assert_eq!(data_expected, data_actual); + } + + #[test] + fn should_support_into_and_from_data_3d() { + let data_expected = TensorData::random::( + Shape::new([2, 3, 4]), + Distribution::Default, + &mut get_seeded_rng(), + ); + let tensor = NdArrayTensor::from_data(data_expected.clone()); + + let data_actual = tensor.into_data(); + + assert_eq!(data_expected, data_actual); + } + + #[test] + fn should_support_into_and_from_data_4d() { + let data_expected = TensorData::random::( + Shape::new([2, 3, 4, 2]), + Distribution::Default, + &mut get_seeded_rng(), + ); + let tensor = NdArrayTensor::from_data(data_expected.clone()); + + let data_actual = tensor.into_data(); + + assert_eq!(data_expected, data_actual); + } + + #[test] + fn should_support_qtensor_strategy() { + type B = NdArray; + let scale: f32 = 0.009_019_608; + let device = Default::default(); + + let tensor = B::float_from_data(TensorData::from([-1.8f32, -1.0, 0.0, 0.5]), &device); + let scheme = QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native); + let qparams = QuantizationParametersPrimitive { + scales: B::float_from_data(TensorData::from([scale]), &device), + }; + let qtensor: NdArrayQTensor = B::quantize(tensor, &scheme, qparams); + + assert_eq!(qtensor.scheme(), scheme); + assert_eq!( + qtensor.strategy(), + QuantizationStrategy::PerTensorSymmetric(SymmetricQuantization::init( + scale, + QuantValue::Q8S + )) + ); + } + + // ========================================================================== + // Zero-copy integration tests + // These tests verify end-to-end zero-copy behavior through NdArrayTensor. + // ========================================================================== + + #[test] + fn zero_copy_creates_borrowed_storage_for_non_native() { + // Verify that from_data creates borrowed storage for non-native allocations + // (e.g. burnpack mmap/file data tagged with AllocationProperty::Other or File). + // Native heap allocations intentionally use Owned storage for performance. + use burn_backend::AllocationProperty; + use burn_std::Bytes; + + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + // Tag as Other to simulate burnpack / mmap data (non-native backing storage) + let non_native_bytes = Bytes::from_shared( + bytes::Bytes::copy_from_slice(&bytes), + AllocationProperty::Other, + ); + let tensor_data = TensorData::from_bytes(non_native_bytes, Shape::new([2, 2]), DType::F32); + + let tensor = NdArrayTensor::from_data(tensor_data); + + match &tensor { + NdArrayTensor::F32(storage) => { + assert!( + storage.is_borrowed(), + "ZERO-COPY REGRESSION: from_data should create borrowed storage \ + for non-native (e.g. burnpack) TensorData" + ); + assert!( + !storage.is_unique(), + "ZERO-COPY REGRESSION: borrowed storage must report is_unique() == false" + ); + } + _ => panic!("Expected F32 tensor"), + } + } + + #[test] + fn native_alloc_creates_owned_storage() { + // Native heap allocations must use Owned storage to avoid the memcpy. + use burn_std::Bytes; + + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); // AllocationProperty::Native + let tensor_data = TensorData::from_bytes(bytes, Shape::new([2, 2]), DType::F32); + + let tensor = NdArrayTensor::from_data(tensor_data); + + match &tensor { + NdArrayTensor::F32(storage) => { + assert!( + !storage.is_borrowed(), + "PERF REGRESSION: from_data must NOT create borrowed storage \ + for native TensorData" + ); + } + _ => panic!("Expected F32 tensor"), + } + } + + #[test] + fn zero_copy_data_integrity() { + // Verify data is correctly accessible through borrowed storage + use burn_std::Bytes; + + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let bytes = Bytes::from_elems(data); + let tensor_data = TensorData::from_bytes(bytes, Shape::new([2, 2]), DType::F32); + + let tensor = NdArrayTensor::from_data(tensor_data); + + match &tensor { + NdArrayTensor::F32(storage) => { + let view = storage.view(); + assert_eq!(view[[0, 0]], 1.0); + assert_eq!(view[[0, 1]], 2.0); + assert_eq!(view[[1, 0]], 3.0); + assert_eq!(view[[1, 1]], 4.0); + } + _ => panic!("Expected F32 tensor"), + } + } + + #[test] + fn zero_copy_fallback_when_bytes_owned() { + // When TensorData owns bytes exclusively, it may use the copy path + // This is expected behavior - verify it still works correctly + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0]); + let tensor = NdArrayTensor::from_data(data.clone()); + let result = tensor.into_data(); + + assert_eq!(data, result, "Data should round-trip correctly"); + } +} diff --git a/crates/burn-nn/Cargo.toml b/crates/burn-nn/Cargo.toml new file mode 100644 index 0000000..dd4d09b --- /dev/null +++ b/crates/burn-nn/Cargo.toml @@ -0,0 +1,41 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Neural network building blocks for the Burn deep learning framework" +documentation = "https://docs.rs/burn-nn" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-nn" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-nn" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "burn-core/default"] +doc = [ + "std", + # Doc features + "burn-core/doc", +] +std = ["burn-core/std", "num-traits/std"] + +tracing = ["burn-core/tracing"] + +[dependencies] + +# ** Please make sure all dependencies support no_std when std is disabled ** +burn-core = { workspace = true } + +num-traits = { workspace = true } + +[dev-dependencies] +burn-core = { workspace = true, features = ["autodiff"] } +rstest = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-nn/README.md b/crates/burn-nn/README.md new file mode 100644 index 0000000..daef9bc --- /dev/null +++ b/crates/burn-nn/README.md @@ -0,0 +1,3 @@ +# Burn Neural Networks + +Core building blocks for Burn neural networks. \ No newline at end of file diff --git a/crates/burn-nn/src/activation/activation_wrapper.rs b/crates/burn-nn/src/activation/activation_wrapper.rs new file mode 100644 index 0000000..9beb962 --- /dev/null +++ b/crates/burn-nn/src/activation/activation_wrapper.rs @@ -0,0 +1,621 @@ +use burn_core as burn; + +use crate::Identity; +use crate::activation::{ + Celu, CeluConfig, Elu, EluConfig, Gelu, HardShrink, HardShrinkConfig, HardSigmoid, + HardSigmoidConfig, HardSwish, LeakyRelu, LeakyReluConfig, PRelu, PReluConfig, Relu, Selu, + Shrink, ShrinkConfig, Sigmoid, SoftShrink, SoftShrinkConfig, Softplus, SoftplusConfig, + Softsign, SwiGlu, SwiGluConfig, Tanh, ThresholdedRelu, ThresholdedReluConfig, +}; +use burn::config::Config; +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// [`Activation`] Configuration. +#[derive(Config, Debug)] +#[non_exhaustive] +pub enum ActivationConfig { + /// Identity activation layer. + Identity, + + /// [`Gelu`] activation layer. + Gelu, + + /// [`Gelu`] activation layer with tanh approximation. + GeluApproximate, + + /// [`PRelu`] activation layer. + PRelu(PReluConfig), + + /// [`Relu`] activation layer. + Relu, + + /// [`LeakyRelu`] activation layer. + LeakyRelu(LeakyReluConfig), + + /// [`SwiGlu`] activation layer. + SwiGlu(SwiGluConfig), + + /// [`Selu`] activation layer. + Selu, + + /// [`Sigmoid`] activation layer. + Sigmoid, + + /// [`Tanh`] activation layer. + Tanh, + + /// [`HardSigmoid`] activation layer. + HardSigmoid(HardSigmoidConfig), + + /// [`HardSwish`] activation layer. + HardSwish, + + /// [`Softplus`] activation layer. + Softplus(SoftplusConfig), + + /// [`Softsign`] activation layer. + Softsign, + + /// [`Elu`] activation layer. + Elu(EluConfig), + + /// [`Celu`] activation layer. + Celu(CeluConfig), + + /// [`ThresholdedRelu`] activation layer. + ThresholdedRelu(ThresholdedReluConfig), + + /// [`HardShrink`] activation layer. + HardShrink(HardShrinkConfig), + + /// [`SoftShrink`] activation layer. + SoftShrink(SoftShrinkConfig), + + /// [`Shrink`] activation layer. + Shrink(ShrinkConfig), +} + +impl From for ActivationConfig { + fn from(config: PReluConfig) -> Self { + Self::PRelu(config) + } +} + +impl From for ActivationConfig { + fn from(config: LeakyReluConfig) -> Self { + Self::LeakyRelu(config) + } +} + +impl From for ActivationConfig { + fn from(config: SwiGluConfig) -> Self { + Self::SwiGlu(config) + } +} + +impl From for ActivationConfig { + fn from(config: HardSigmoidConfig) -> Self { + Self::HardSigmoid(config) + } +} + +impl From for ActivationConfig { + fn from(config: SoftplusConfig) -> Self { + Self::Softplus(config) + } +} + +impl From for ActivationConfig { + fn from(config: EluConfig) -> Self { + Self::Elu(config) + } +} + +impl From for ActivationConfig { + fn from(config: CeluConfig) -> Self { + Self::Celu(config) + } +} + +impl From for ActivationConfig { + fn from(config: ThresholdedReluConfig) -> Self { + Self::ThresholdedRelu(config) + } +} + +impl From for ActivationConfig { + fn from(config: HardShrinkConfig) -> Self { + Self::HardShrink(config) + } +} + +impl From for ActivationConfig { + fn from(config: SoftShrinkConfig) -> Self { + Self::SoftShrink(config) + } +} + +impl From for ActivationConfig { + fn from(config: ShrinkConfig) -> Self { + Self::Shrink(config) + } +} + +impl ActivationConfig { + /// Initialize a wrapped activation layer. + pub fn init(&self, device: &Device) -> Activation { + match self { + ActivationConfig::Identity => Activation::Identity(Identity::new()), + ActivationConfig::Relu => Relu.into(), + ActivationConfig::LeakyRelu(conf) => conf.init().into(), + ActivationConfig::Gelu => Gelu::new().into(), + ActivationConfig::GeluApproximate => Gelu::new_approximate().into(), + ActivationConfig::PRelu(conf) => conf.init(device).into(), + ActivationConfig::SwiGlu(conf) => conf.init(device).into(), + ActivationConfig::HardSigmoid(conf) => conf.init().into(), + ActivationConfig::HardSwish => HardSwish.into(), + ActivationConfig::Softplus(conf) => conf.init().into(), + ActivationConfig::Selu => Selu.into(), + ActivationConfig::Sigmoid => Sigmoid.into(), + ActivationConfig::Tanh => Tanh.into(), + ActivationConfig::Softsign => Softsign.into(), + ActivationConfig::Elu(conf) => conf.init().into(), + ActivationConfig::Celu(conf) => conf.init().into(), + ActivationConfig::HardShrink(conf) => conf.init().into(), + ActivationConfig::SoftShrink(conf) => conf.init().into(), + ActivationConfig::Shrink(conf) => conf.init().into(), + ActivationConfig::ThresholdedRelu(conf) => conf.init().into(), + } + } +} + +/// Activation Layer Wrapper. +/// +/// Provides support for many in-built `burn::nn` activations. +#[derive(Module, Debug)] +#[non_exhaustive] +#[allow(clippy::large_enum_variant)] +pub enum Activation { + /// Identity activation layer. + Identity(Identity), + + /// [`Gelu`] activation layer. + Gelu(Gelu), + + /// [`PRelu`] activation layer. + PRelu(PRelu), + + /// [`Relu`] activation layer. + Relu(Relu), + + /// [`LeakyRelu`] activation layer. + LeakyRelu(LeakyRelu), + + /// [`SwiGlu`] activation layer. + SwiGlu(SwiGlu), + + /// [`Selu`] activation layer. + Selu(Selu), + + /// [`Sigmoid`] activation layer. + Sigmoid(Sigmoid), + + /// [`Tanh`] activation layer. + Tanh(Tanh), + + /// [`HardSigmoid`] activation layer. + HardSigmoid(HardSigmoid), + + /// [`HardSwish`] activation layer. + HardSwish(HardSwish), + + /// [`Softplus`] activation layer. + Softplus(Softplus), + + /// [`Softsign`] activation layer. + Softsign(Softsign), + + /// [`Elu`] activation layer. + Elu(Elu), + + /// [`Celu`] activation layer. + Celu(Celu), + + /// [`ThresholdedRelu`] activation layer. + ThresholdedRelu(ThresholdedRelu), + + /// [`HardShrink`] activation layer. + HardShrink(HardShrink), + + /// [`SoftShrink`] activation layer. + SoftShrink(SoftShrink), + + /// [`Shrink`] activation layer. + Shrink(Shrink), +} + +impl From for Activation { + fn from(layer: Identity) -> Self { + Self::Identity(layer) + } +} + +impl From for Activation { + fn from(layer: Gelu) -> Self { + Self::Gelu(layer) + } +} + +impl From for Activation { + fn from(layer: PRelu) -> Self { + Self::PRelu(layer) + } +} + +impl From for Activation { + fn from(layer: Relu) -> Self { + Self::Relu(layer) + } +} + +impl From for Activation { + fn from(layer: LeakyRelu) -> Self { + Self::LeakyRelu(layer) + } +} + +impl From for Activation { + fn from(layer: SwiGlu) -> Self { + Self::SwiGlu(layer) + } +} + +impl From for Activation { + fn from(layer: Selu) -> Self { + Self::Selu(layer) + } +} + +impl From for Activation { + fn from(layer: Sigmoid) -> Self { + Self::Sigmoid(layer) + } +} + +impl From for Activation { + fn from(layer: Tanh) -> Self { + Self::Tanh(layer) + } +} + +impl From for Activation { + fn from(layer: HardSigmoid) -> Self { + Self::HardSigmoid(layer) + } +} + +impl From for Activation { + fn from(layer: HardSwish) -> Self { + Self::HardSwish(layer) + } +} + +impl From for Activation { + fn from(layer: Softplus) -> Self { + Self::Softplus(layer) + } +} + +impl From for Activation { + fn from(layer: Softsign) -> Self { + Self::Softsign(layer) + } +} + +impl From for Activation { + fn from(layer: Elu) -> Self { + Self::Elu(layer) + } +} + +impl From for Activation { + fn from(layer: Celu) -> Self { + Self::Celu(layer) + } +} + +impl From for Activation { + fn from(layer: ThresholdedRelu) -> Self { + Self::ThresholdedRelu(layer) + } +} + +impl From for Activation { + fn from(layer: HardShrink) -> Self { + Self::HardShrink(layer) + } +} + +impl From for Activation { + fn from(layer: SoftShrink) -> Self { + Self::SoftShrink(layer) + } +} + +impl From for Activation { + fn from(layer: Shrink) -> Self { + Self::Shrink(layer) + } +} + +impl Activation { + /// Forward pass. + pub fn forward(&self, input: Tensor) -> Tensor { + match self { + Activation::Identity(layer) => layer.forward(input), + Activation::Relu(layer) => layer.forward(input), + Activation::LeakyRelu(layer) => layer.forward(input), + Activation::Gelu(layer) => layer.forward(input), + Activation::PRelu(layer) => layer.forward(input), + Activation::SwiGlu(layer) => layer.forward(input), + Activation::HardSigmoid(layer) => layer.forward(input), + Activation::HardSwish(layer) => layer.forward(input), + Activation::Softplus(layer) => layer.forward(input), + Activation::Selu(layer) => layer.forward(input), + Activation::Sigmoid(layer) => layer.forward(input), + Activation::Tanh(layer) => layer.forward(input), + Activation::Softsign(layer) => layer.forward(input), + Activation::Elu(layer) => layer.forward(input), + Activation::Celu(layer) => layer.forward(input), + Activation::ThresholdedRelu(layer) => layer.forward(input), + Activation::HardShrink(layer) => layer.forward(input), + Activation::SoftShrink(layer) => layer.forward(input), + Activation::Shrink(layer) => layer.forward(input), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::module::Module; + + fn make_input(device: &Device) -> Tensor<2> { + Tensor::from_data([[-1.0, -0.5, 0.0], [1.0, 0.5, 0.0]], device) + } + + fn expect_tensor(actual: Tensor, expected: Tensor) { + actual.to_data().assert_eq(&expected.to_data(), true); + } + + fn check_stateless_config_output( + config: ActivationConfig, + input: Tensor, + expected: Tensor, + device: &Device, + ) { + let act = config.init(device); + let output = act.forward(input); + expect_tensor(output, expected); + } + + #[test] + fn test_identity() { + let device = Default::default(); + let input = make_input(&device); + + let expected = input.clone(); + + check_stateless_config_output(ActivationConfig::Identity, input, expected, &device) + } + + #[test] + fn test_gelu() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Gelu::new().forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Gelu, input, expected, &device) + } + + #[test] + fn test_gelu_approximate() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Gelu::new_approximate().forward(input.clone()); + + check_stateless_config_output(ActivationConfig::GeluApproximate, input, expected, &device) + } + + #[test] + fn test_prelu() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = PReluConfig::new(); + let expected = inner_config.init(&device).forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_relu() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Relu.forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Relu, input, expected, &device) + } + + #[test] + fn test_leaky_relu() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = LeakyReluConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_swi_glu() { + let device = Default::default(); + let input = make_input(&device); + + let d_input = input.shape()[1]; + let d_output = 2 * d_input; + + let inner_config = SwiGluConfig::new(d_input, d_output); + let mut reference = inner_config.init(&device); + + let config: ActivationConfig = inner_config.into(); + let layer = config.init(&device); + + // Access tensors via forward pass to trigger lazy initialization, then clone weights. + let layer_output = layer.forward(input.clone()); + + match &layer { + Activation::SwiGlu(inner) => { + let state = inner.clone().into_record(); + reference = reference.load_record(state); + } + _ => unreachable!(), + }; + + expect_tensor(layer_output, reference.forward(input.clone())) + } + + #[test] + fn test_selu() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Selu.forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Selu, input, expected, &device) + } + + #[test] + fn test_sigmoid() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Sigmoid.forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Sigmoid, input, expected, &device) + } + + #[test] + fn test_tanh() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Tanh.forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Tanh, input, expected, &device) + } + + #[test] + fn test_hard_sigmoid() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = HardSigmoidConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_softsign() { + let device = Default::default(); + let input = make_input(&device); + + let expected = Softsign.forward(input.clone()); + + check_stateless_config_output(ActivationConfig::Softsign, input, expected, &device) + } + + #[test] + fn test_elu() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = EluConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_softplus() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = SoftplusConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_celu() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = CeluConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_thresholded_relu() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = ThresholdedReluConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_hard_shrink() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = HardShrinkConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_soft_shrink() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = SoftShrinkConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } + + #[test] + fn test_shrink() { + let device = Default::default(); + let input = make_input(&device); + + let inner_config = ShrinkConfig::new(); + let expected = inner_config.init().forward(input.clone()); + + check_stateless_config_output(inner_config.into(), input, expected, &device) + } +} diff --git a/crates/burn-nn/src/activation/celu.rs b/crates/burn-nn/src/activation/celu.rs new file mode 100644 index 0000000..f0f99f7 --- /dev/null +++ b/crates/burn-nn/src/activation/celu.rs @@ -0,0 +1,101 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::celu; + +/// CELU (Continuously Differentiable Exponential Linear Unit) layer. +/// +/// Applies the CELU function element-wise: +/// `celu(x) = max(0, x) + min(0, alpha * (exp(x / alpha) - 1))` +/// +/// Should be created with [CeluConfig](CeluConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Celu { + /// The alpha value for the CELU formulation. + pub alpha: f64, +} + +/// Configuration to create a [Celu](Celu) layer using the [init function](CeluConfig::init). +#[derive(Config, Debug)] +pub struct CeluConfig { + /// The alpha value for the CELU formulation. Default is 1.0 + #[config(default = "1.0")] + pub alpha: f64, +} + +impl CeluConfig { + /// Initialize a new [Celu](Celu) Layer + pub fn init(&self) -> Celu { + Celu { alpha: self.alpha } + } +} + +impl ModuleDisplay for Celu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("alpha", &self.alpha).optional() + } +} + +impl Celu { + /// Forward pass for the Celu layer. + /// + /// See [celu](burn::tensor::activation::celu) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + celu(input, self.alpha) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_celu_forward() { + let device = Default::default(); + let model = CeluConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.5, -0.5, -1.0]]), &device); + let out = model.forward(input); + // celu(0.5, 1) = 0.5 + // celu(-0.5, 1) = 1 * (exp(-0.5) - 1) = -0.393469 + // celu(-1.0, 1) = 1 * (exp(-1) - 1) = -0.632121 + let expected = TensorData::from([[0.5, -0.393469, -0.632121]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_celu_with_alpha() { + let device = Default::default(); + let model = CeluConfig::new().with_alpha(2.0).init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.0, -2.0]]), &device); + let out = model.forward(input); + // celu(0, 2) = 0 + // celu(-2, 2) = 2 * (exp(-1) - 1) = -1.264241 + let expected = TensorData::from([[0.0, -1.264241]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = CeluConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "Celu {alpha: 1}"); + } +} diff --git a/crates/burn-nn/src/activation/elu.rs b/crates/burn-nn/src/activation/elu.rs new file mode 100644 index 0000000..beb35ef --- /dev/null +++ b/crates/burn-nn/src/activation/elu.rs @@ -0,0 +1,82 @@ +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn_core as burn; + +use burn::tensor::activation::elu; + +/// ELU (Exponential Linear Unit) layer. +/// +/// Should be created with [EluConfig](EluConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Elu { + /// The alpha value. + pub alpha: f64, +} +/// Configuration to create an [Elu](Elu) layer using the [init function](EluConfig::init). +#[derive(Config, Debug)] +pub struct EluConfig { + /// The alpha value. Default is 1.0 + #[config(default = "1.0")] + pub alpha: f64, +} +impl EluConfig { + /// Initialize a new [Elu](Elu) Layer + pub fn init(&self) -> Elu { + Elu { alpha: self.alpha } + } +} + +impl ModuleDisplay for Elu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("alpha", &self.alpha).optional() + } +} + +impl Elu { + /// Forward pass for the ELU layer. + /// + /// See [elu](burn::tensor::activation::elu) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + elu(input, self.alpha) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_elu_forward() { + let device = Default::default(); + let model = EluConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.4410, -0.2507]]), &device); + let out = model.forward(input); + // elu(0.4410, 1.0) = 0.4410 + // elu(-0.2507, 1.0) = 1.0 * (exp(-0.2507) - 1) = -0.22186 + let expected = TensorData::from([[0.4410, -0.22186]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = EluConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "Elu {alpha: 1}"); + } +} diff --git a/crates/burn-nn/src/activation/gelu.rs b/crates/burn-nn/src/activation/gelu.rs new file mode 100644 index 0000000..5a0f917 --- /dev/null +++ b/crates/burn-nn/src/activation/gelu.rs @@ -0,0 +1,78 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the Gaussian Error Linear Units function element-wise. +/// +/// See also [gelu](burn::tensor::activation::gelu) +/// +/// When `approximate` is true, uses the tanh approximation: +/// `0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))` +#[derive(Module, Debug, Default)] +pub struct Gelu { + /// Whether to use tanh approximation. + pub approximate: bool, +} + +impl Gelu { + /// Create the module with exact GELU. + pub fn new() -> Self { + Self::default() + } + + /// Create the module with tanh approximation. + pub fn new_approximate() -> Self { + Self { approximate: true } + } + + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + if self.approximate { + burn::tensor::activation::gelu_approximate(input) + } else { + burn::tensor::activation::gelu(input) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + + type FT = f32; + + #[test] + fn display() { + let layer = Gelu::new(); + + assert_eq!(alloc::format!("{layer}"), "Gelu {\n approximate: false\n}"); + } + + #[test] + fn forward_approximate() { + let device = Default::default(); + let input = Tensor::<2>::from_data([[-1.0, 0.0, 1.0], [0.5, -0.5, 2.0]], &device); + + let output = Gelu::new_approximate().forward(input); + + // PyTorch: torch.nn.functional.gelu(x, approximate="tanh") + let expected = Tensor::<2>::from_data( + [ + [-0.1588079929, 0.0000000000, 0.8411920071], + [0.3457140028, -0.1542859972, 1.9545977116], + ], + &device, + ); + + output + .into_data() + .assert_approx_eq::(&expected.into_data(), Tolerance::rel_abs(1e-5, 1e-5)); + } +} diff --git a/crates/burn-nn/src/activation/glu.rs b/crates/burn-nn/src/activation/glu.rs new file mode 100644 index 0000000..d596ff3 --- /dev/null +++ b/crates/burn-nn/src/activation/glu.rs @@ -0,0 +1,50 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the gated linear unit function. +/// +/// See also [glu](burn::tensor::activation::glu) +#[derive(Module, Debug, Default)] +pub struct GLU { + dim: usize, +} + +impl GLU { + /// Create the module. + /// + /// # Arguments + /// * `dim` - The dimension on which to split the input. + pub fn new(dim: usize) -> Self { + Self { dim } + } + + /// Applies the gated linear unit function. + /// + /// GLU(a,b)=a⊗σ(b) where `a` is the first half of the input matrices and `b` is the second half. + /// + /// **Note**: + /// * The size of the input tensor along `dim` must be divisible by 2. + /// + /// ### Arguments + /// * `tensor` - The input tensor. + /// + /// ### Returns + /// * A tensor with the same shape as the input, except the size along `dim` is halved. + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::glu(input, self.dim) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = GLU::new(1); + + assert_eq!(alloc::format!("{layer}"), "GLU {\n dim: 1\n}"); + } +} diff --git a/crates/burn-nn/src/activation/hard_shrink.rs b/crates/burn-nn/src/activation/hard_shrink.rs new file mode 100644 index 0000000..fada3ea --- /dev/null +++ b/crates/burn-nn/src/activation/hard_shrink.rs @@ -0,0 +1,94 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::hard_shrink; + +/// Hard Shrink layer. +/// +/// Applies the Hard Shrink function element-wise: +/// `hard_shrink(x) = x if |x| > lambda else 0` +/// +/// Should be created with [HardShrinkConfig](HardShrinkConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct HardShrink { + /// The lambda value for the Hard Shrink formulation. + pub lambda: f64, +} + +/// Configuration to create a [HardShrink](HardShrink) layer using the [init function](HardShrinkConfig::init). +#[derive(Config, Debug)] +pub struct HardShrinkConfig { + /// The lambda value for the Hard Shrink formulation. Default is 0.5 + #[config(default = "0.5")] + pub lambda: f64, +} + +impl HardShrinkConfig { + /// Initialize a new [HardShrink](HardShrink) Layer + pub fn init(&self) -> HardShrink { + HardShrink { + lambda: self.lambda, + } + } +} + +impl ModuleDisplay for HardShrink { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("lambda", &self.lambda).optional() + } +} + +impl HardShrink { + /// Forward pass for the Hard Shrink layer. + /// + /// See [hard_shrink](burn::tensor::activation::hard_shrink) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + hard_shrink(input, self.lambda) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + + #[test] + fn test_hard_shrink_forward() { + let device = Default::default(); + let model = HardShrinkConfig::new().init(); + let input = Tensor::<2>::from_data([[0.5, -0.5, -1.0], [8.0, 0.3, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -1.0], [8.0, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn test_hard_shrink_with_lambda() { + let device = Default::default(); + let model = HardShrinkConfig::new().with_lambda(0.2).init(); + let input = Tensor::<2>::from_data([[0.1, -0.1, -0.3], [0.5, 0.1, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -0.3], [0.5, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn display() { + let config = HardShrinkConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "HardShrink {lambda: 0.5}"); + } +} diff --git a/crates/burn-nn/src/activation/hard_sigmoid.rs b/crates/burn-nn/src/activation/hard_sigmoid.rs new file mode 100644 index 0000000..a28014a --- /dev/null +++ b/crates/burn-nn/src/activation/hard_sigmoid.rs @@ -0,0 +1,94 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::hard_sigmoid; + +/// Hard Sigmoid layer. +/// +/// Should be created with [HardSigmoidConfig](HardSigmoidConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct HardSigmoid { + /// The alpha value. + pub alpha: f64, + /// The beta value. + pub beta: f64, +} +/// Configuration to create a [Hard Sigmoid](HardSigmoid) layer using the [init function](HardSigmoidConfig::init). +#[derive(Config, Debug)] +pub struct HardSigmoidConfig { + /// The alpha value. Default is 0.2 + #[config(default = "0.2")] + pub alpha: f64, + /// The beta value. Default is 0.5 + #[config(default = "0.5")] + pub beta: f64, +} +impl HardSigmoidConfig { + /// Initialize a new [Hard Sigmoid](HardSigmoid) Layer + pub fn init(&self) -> HardSigmoid { + HardSigmoid { + alpha: self.alpha, + beta: self.beta, + } + } +} + +impl ModuleDisplay for HardSigmoid { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("alpha", &self.alpha) + .add("beta", &self.beta) + .optional() + } +} + +impl HardSigmoid { + /// Forward pass for the Hard Sigmoid layer. + /// + /// See [hard_sigmoid](burn::tensor::activation::hard_sigmoid) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + hard_sigmoid(input, self.alpha, self.beta) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_hard_sigmoid_forward() { + let device = Default::default(); + let model = HardSigmoidConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.4410, -0.2507]]), &device); + let out = model.forward(input); + let expected = TensorData::from([[0.5882, 0.44986]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = HardSigmoidConfig::new().init(); + assert_eq!( + alloc::format!("{config}"), + "HardSigmoid {alpha: 0.2, beta: 0.5}" + ); + } +} diff --git a/crates/burn-nn/src/activation/hard_swish.rs b/crates/burn-nn/src/activation/hard_swish.rs new file mode 100644 index 0000000..5db7e3e --- /dev/null +++ b/crates/burn-nn/src/activation/hard_swish.rs @@ -0,0 +1,53 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; +use burn::tensor::activation::hard_swish; + +/// Hard Swish layer. +#[derive(Module, Debug, Default)] +pub struct HardSwish; + +impl HardSwish { + /// Create the module. + pub fn new() -> Self { + Self + } + + /// Forward pass for the Hard Swish layer. + /// + /// See [hard_swish](burn::tensor::activation::hard_swish) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + hard_swish(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_hard_swish_forward() { + let device = Default::default(); + let model = HardSwish::new(); + + let input = Tensor::<2>::from_data(TensorData::from([[3.0f32, -3.0], [0.0, 1.0]]), &device); + let out = model.forward(input); + let expected = TensorData::from([[3.0f32, 0.0], [0.0, 0.6666667]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let layer = HardSwish::new(); + assert_eq!(alloc::format!("{layer}"), "HardSwish"); + } +} diff --git a/crates/burn-nn/src/activation/leaky_relu.rs b/crates/burn-nn/src/activation/leaky_relu.rs new file mode 100644 index 0000000..a424ced --- /dev/null +++ b/crates/burn-nn/src/activation/leaky_relu.rs @@ -0,0 +1,121 @@ +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn_core as burn; + +use burn::tensor::activation::leaky_relu; + +/// Leaky ReLu layer. +/// +/// Should be created with [LeakyReluConfig](LeakyReluConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct LeakyRelu { + /// The negative slope. + pub negative_slope: f64, +} +/// Configuration to create a [Leaky Relu](LeakyRelu) layer using the [init function](LeakyReluConfig::init). +#[derive(Config, Debug)] +pub struct LeakyReluConfig { + /// The negative slope. Default is 0.01 + #[config(default = "0.01")] + pub negative_slope: f64, +} +impl LeakyReluConfig { + /// Initialize a new [Leaky Relu](LeakyRelu) Layer + pub fn init(&self) -> LeakyRelu { + LeakyRelu { + negative_slope: self.negative_slope, + } + } +} + +impl ModuleDisplay for LeakyRelu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("negative_slope", &self.negative_slope) + .optional() + } +} + +impl LeakyRelu { + /// Forward pass for the Leaky ReLu layer. + /// + /// See [leaky_relu](burn::tensor::activation::leaky_relu) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + leaky_relu(input, self.negative_slope) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_leaky_relu_forward() { + let device = Default::default(); + let model: LeakyRelu = LeakyReluConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.4410, -0.2507]]), &device); + let out = model.forward(input); + let expected = TensorData::from([[0.4410, -0.002507]]); + out.to_data().assert_eq(&expected, false); + } + #[test] + fn test_leaky_relu_forward_multi_dim() { + let input = [ + [ + [-1.0222, 1.5810, 0.3457, -1.3530], + [0.0231, 0.8681, 0.2473, -0.0377], + [0.3520, -1.1199, 1.2219, 0.2804], + ], + [ + [1.0002, 0.7259, 0.8779, 0.2084], + [1.5615, -0.1057, -0.4886, -1.5184], + [-0.5523, -0.2741, -0.0210, -1.1352], + ], + ]; + let expected = TensorData::from([ + [ + [-1.0222e-02, 1.5810e+00, 3.457e-01, -1.3530e-02], + [2.31e-02, 8.681e-01, 2.473e-01, -3.77e-04], + [3.52e-01, -1.1199e-02, 1.2219e+00, 2.804e-01], + ], + [ + [1.0002e+00, 7.259e-01, 8.779e-01, 2.084e-01], + [1.5615e+00, -1.057e-03, -4.886e-03, -1.5184e-02], + [-5.523e-03, -2.741e-03, -2.1e-04, -1.1352e-02], + ], + ]); + + let device = Default::default(); + let model = LeakyReluConfig::new().init(); + let input_data = Tensor::<3>::from_data(TensorData::from(input), &device); + let actual_output = model.forward(input_data); + actual_output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()) + } + + #[test] + fn display() { + let config = LeakyReluConfig::new().init(); + assert_eq!( + alloc::format!("{config}"), + "LeakyRelu {negative_slope: 0.01}" + ); + } +} diff --git a/crates/burn-nn/src/activation/mod.rs b/crates/burn-nn/src/activation/mod.rs new file mode 100644 index 0000000..ced6eef --- /dev/null +++ b/crates/burn-nn/src/activation/mod.rs @@ -0,0 +1,68 @@ +//! # Activation Layers +//! +//! Users who desire a selectable activation function should +//! consider [`Activation`], which provides an abstraction over: +//! * [`Relu`] - the default, +//! * ['PRelu'] +//! * [`Gelu`] +//! * [`LeakyRelu`] +//! * [`SwiGlu`] +//! * [`Selu`] +//! * [`Sigmoid`] +//! * [`HardSigmoid`] +//! * [`HardSwish`] +//! * [`Softplus`] +//! * [`Softsign`] +//! * [`Tanh`] +//! * [`Elu`] +//! * [`Celu`] +//! * [`ThresholdedRelu`] +//! +//! The activation layer [`GLU`] has shape-changing behaviors +//! not compatible with the common API, and is not included +//! in the abstraction wrappers. + +mod activation_wrapper; + +// These are pub(crate) for dual-export in `nn` without re-exporting +// all of `nn.activation`, or manually listing each symbol. +pub(crate) mod celu; +pub(crate) mod elu; +pub(crate) mod gelu; +pub(crate) mod glu; +pub(crate) mod hard_shrink; +pub(crate) mod hard_sigmoid; +pub(crate) mod hard_swish; +pub(crate) mod leaky_relu; +pub(crate) mod prelu; +pub(crate) mod relu; +pub(crate) mod selu; +pub(crate) mod shrink; +pub(crate) mod sigmoid; +pub(crate) mod soft_shrink; +pub(crate) mod softplus; +pub(crate) mod softsign; +pub(crate) mod swiglu; +pub(crate) mod tanh; +pub(crate) mod thresholded_relu; + +pub use activation_wrapper::*; +pub use celu::*; +pub use elu::*; +pub use gelu::*; +pub use glu::*; +pub use hard_shrink::*; +pub use hard_sigmoid::*; +pub use hard_swish::*; +pub use leaky_relu::*; +pub use prelu::*; +pub use relu::*; +pub use selu::*; +pub use shrink::*; +pub use sigmoid::*; +pub use soft_shrink::*; +pub use softplus::*; +pub use softsign::*; +pub use swiglu::*; +pub use tanh::*; +pub use thresholded_relu::*; diff --git a/crates/burn-nn/src/activation/prelu.rs b/crates/burn-nn/src/activation/prelu.rs new file mode 100644 index 0000000..62e88a2 --- /dev/null +++ b/crates/burn-nn/src/activation/prelu.rs @@ -0,0 +1,87 @@ +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_core as burn; + +/// Parametric Relu layer. +/// +/// Should be created using [PReluConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct PRelu { + /// the weights learnt for PReLu. can be of shape \[1\] or \[num_parameters\] in which case it must + /// be the same as number of channels in the input tensor + pub alpha: Param>, + + /// Alpha value for the PRelu layer + pub alpha_value: f64, +} + +impl ModuleDisplay for PRelu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [num_parameters] = self.alpha.shape().dims(); + + content + .add("num_parameters", &num_parameters) + .add("alpha_value", &self.alpha_value) + .optional() + } +} + +/// Configuration to create a [Parametric Relu](PRelu) layer using the [init function](PReluConfig::init). +#[derive(Config, Debug)] +pub struct PReluConfig { + /// The number of parameters. + #[config(default = "1")] + pub num_parameters: usize, + /// The learnable weight alpha. Default is 0.25 + #[config(default = "0.25")] + pub alpha: f64, +} + +impl PReluConfig { + /// Initialize a new [Parametric Relu](PRelu) Layer + pub fn init(&self, device: &Device) -> PRelu { + PRelu { + // alpha is a tensor of length num_parameters + alpha: Initializer::Constant { value: self.alpha }.init([self.num_parameters], device), + alpha_value: self.alpha, + } + } +} + +impl PRelu { + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + /// + /// See also [prelu](burn::tensor::activation::prelu) for more information. + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::prelu(input, self.alpha.val()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = PReluConfig::new().init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "PRelu {num_parameters: 1, alpha_value: 0.25, params: 1}" + ); + } +} diff --git a/crates/burn-nn/src/activation/relu.rs b/crates/burn-nn/src/activation/relu.rs new file mode 100644 index 0000000..16537de --- /dev/null +++ b/crates/burn-nn/src/activation/relu.rs @@ -0,0 +1,38 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the rectified linear unit function element-wise +/// See also [relu](burn::tensor::activation::relu) +/// +#[derive(Module, Debug, Default)] +pub struct Relu; + +impl Relu { + /// Create the module. + pub fn new() -> Self { + Self {} + } + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::relu(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Relu::new(); + + assert_eq!(alloc::format!("{layer}"), "Relu"); + } +} diff --git a/crates/burn-nn/src/activation/selu.rs b/crates/burn-nn/src/activation/selu.rs new file mode 100644 index 0000000..55bc617 --- /dev/null +++ b/crates/burn-nn/src/activation/selu.rs @@ -0,0 +1,37 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the Scaled Exponential Linear Unit function element-wise. +/// See also [selu](burn::tensor::activation::selu) +#[derive(Module, Debug, Default)] +pub struct Selu; + +impl Selu { + /// Create the module. + pub fn new() -> Self { + Self {} + } + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::selu(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Selu::new(); + + assert_eq!(alloc::format!("{layer}"), "Selu"); + } +} diff --git a/crates/burn-nn/src/activation/shrink.rs b/crates/burn-nn/src/activation/shrink.rs new file mode 100644 index 0000000..6f6fbd5 --- /dev/null +++ b/crates/burn-nn/src/activation/shrink.rs @@ -0,0 +1,110 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::shrink; + +/// Shrink layer. +/// +/// Applies the Shrink function element-wise: +/// `shrink(x) = x - bias if x > lambda, x + bias if x < -lambda, 0 otherwise` +/// +/// Should be created with [ShrinkConfig](ShrinkConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Shrink { + /// The lambda value for the Shrink formulation. + pub lambda: f64, + /// The bias value for the Shrink formulation. + // Usually bias = lambda, but need this to handle onnx spec https://onnx.ai/onnx/operators/onnx__Shrink.html + pub bias: f64, +} + +/// Configuration to create a [Shrink](Shrink) layer using the [init function](ShrinkConfig::init). +#[derive(Config, Debug)] +pub struct ShrinkConfig { + /// The lambda value for the Shrink formulation. Default is 0.5 + #[config(default = "0.5")] + pub lambda: f64, + /// The bias value for the Shrink formulation. Default is 0.5. + #[config(default = "0.5")] + pub bias: f64, +} + +impl ShrinkConfig { + /// Initialize a new [Shrink](Shrink) Layer + pub fn init(&self) -> Shrink { + Shrink { + lambda: self.lambda, + bias: self.bias, + } + } +} + +impl ModuleDisplay for Shrink { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("lambda", &self.lambda) + .add("bias", &self.bias) + .optional() + } +} + +impl Shrink { + /// Forward pass for the Shrink layer. + /// + /// See [shrink](burn::tensor::activation::shrink) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + shrink(input, self.lambda, self.bias) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + + #[test] + fn test_shrink_forward() { + let device = Default::default(); + let model = ShrinkConfig::new().init(); + let input = Tensor::<2>::from_data([[0.5, -0.5, -1.0], [8.0, 0.3, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -0.5], [7.5, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn test_shrink_with_lambda_and_bias() { + let device = Default::default(); + let model = ShrinkConfig::new() + .with_lambda(0.25) + .with_bias(0.125) + .init(); + let input = Tensor::<2>::from_data([[0.125, -0.125, -0.5], [0.75, 0.1, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -0.375], [0.625, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn display() { + let config = ShrinkConfig::new().init(); + assert_eq!( + alloc::format!("{config}"), + "Shrink {lambda: 0.5, bias: 0.5}" + ); + } +} diff --git a/crates/burn-nn/src/activation/sigmoid.rs b/crates/burn-nn/src/activation/sigmoid.rs new file mode 100644 index 0000000..ce9d517 --- /dev/null +++ b/crates/burn-nn/src/activation/sigmoid.rs @@ -0,0 +1,37 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the sigmoid function element-wise +/// See also [sigmoid](burn::tensor::activation::sigmoid) +#[derive(Module, Debug, Default)] +pub struct Sigmoid; + +impl Sigmoid { + /// Create the module. + pub fn new() -> Self { + Self {} + } + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::sigmoid(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Sigmoid::new(); + + assert_eq!(alloc::format!("{layer}"), "Sigmoid"); + } +} diff --git a/crates/burn-nn/src/activation/soft_shrink.rs b/crates/burn-nn/src/activation/soft_shrink.rs new file mode 100644 index 0000000..255329b --- /dev/null +++ b/crates/burn-nn/src/activation/soft_shrink.rs @@ -0,0 +1,94 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::soft_shrink; + +/// Soft Shrink layer. +/// +/// Applies the Soft Shrink function element-wise: +/// `soft_shrink(x) = x - lambda if x > lambda, x + lambda if x < -lambda, 0 otherwise` +/// +/// Should be created with [SoftShrinkConfig](SoftShrinkConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct SoftShrink { + /// The lambda value for the Soft Shrink formulation. + pub lambda: f64, +} + +/// Configuration to create a [SoftShrink](SoftShrink) layer using the [init function](SoftShrinkConfig::init). +#[derive(Config, Debug)] +pub struct SoftShrinkConfig { + /// The lambda value for the Soft Shrink formulation. Default is 0.5 + #[config(default = "0.5")] + pub lambda: f64, +} + +impl SoftShrinkConfig { + /// Initialize a new [SoftShrink](SoftShrink) Layer + pub fn init(&self) -> SoftShrink { + SoftShrink { + lambda: self.lambda, + } + } +} + +impl ModuleDisplay for SoftShrink { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("lambda", &self.lambda).optional() + } +} + +impl SoftShrink { + /// Forward pass for the Soft Shrink layer. + /// + /// See [soft_shrink](burn::tensor::activation::soft_shrink) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + soft_shrink(input, self.lambda) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + + #[test] + fn test_soft_shrink_forward() { + let device = Default::default(); + let model = SoftShrinkConfig::new().init(); + let input = Tensor::<2>::from_data([[0.5, -0.5, -1.0], [8.0, 0.3, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -0.5], [7.5, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn test_soft_shrink_with_lambda() { + let device = Default::default(); + let model = SoftShrinkConfig::new().with_lambda(0.25).init(); + let input = Tensor::<2>::from_data([[0.125, -0.125, -0.5], [0.75, 0.1, 0.0]], &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0_f32, 0.0, -0.25], [0.5, 0.0, 0.0]]); + assert_eq!(out.into_data(), expected); + } + + #[test] + fn display() { + let config = SoftShrinkConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "SoftShrink {lambda: 0.5}"); + } +} diff --git a/crates/burn-nn/src/activation/softplus.rs b/crates/burn-nn/src/activation/softplus.rs new file mode 100644 index 0000000..5358f66 --- /dev/null +++ b/crates/burn-nn/src/activation/softplus.rs @@ -0,0 +1,102 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::activation::softplus; + +/// Softplus layer. +/// +/// Applies the softplus function element-wise: +/// `softplus(x) = (1/beta) * log(1 + exp(beta * x))` +/// +/// Should be created with [SoftplusConfig](SoftplusConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Softplus { + /// The beta value. + pub beta: f64, +} + +/// Configuration to create a [Softplus](Softplus) layer using the [init function](SoftplusConfig::init). +#[derive(Config, Debug)] +pub struct SoftplusConfig { + /// The beta value. Default is 1.0 + #[config(default = "1.0")] + pub beta: f64, +} + +impl SoftplusConfig { + /// Initialize a new [Softplus](Softplus) Layer + pub fn init(&self) -> Softplus { + Softplus { beta: self.beta } + } +} + +impl ModuleDisplay for Softplus { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("beta", &self.beta).optional() + } +} + +impl Softplus { + /// Forward pass for the Softplus layer. + /// + /// See [softplus](burn::tensor::activation::softplus) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + softplus(input, self.beta) + } +} + +#[cfg(test)] +#[allow(clippy::approx_constant)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_softplus_forward() { + let device = Default::default(); + let model = SoftplusConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.0, 1.0, -1.0]]), &device); + let out = model.forward(input); + // softplus(0) = log(2) ≈ 0.6931 + // softplus(1) = log(1 + e) ≈ 1.3133 + // softplus(-1) = log(1 + e^-1) ≈ 0.3133 + let expected = TensorData::from([[0.6931, 1.3133, 0.3133]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_softplus_with_beta() { + let device = Default::default(); + let model = SoftplusConfig::new().with_beta(2.0).init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.0, 1.0]]), &device); + let out = model.forward(input); + // softplus(0, beta=2) = (1/2) * log(1 + exp(0)) = 0.5 * log(2) ≈ 0.3466 + // softplus(1, beta=2) = (1/2) * log(1 + exp(2)) = 0.5 * log(8.389) ≈ 1.0635 + let expected = TensorData::from([[0.3466, 1.0635]]); + out.to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = SoftplusConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "Softplus {beta: 1}"); + } +} diff --git a/crates/burn-nn/src/activation/softsign.rs b/crates/burn-nn/src/activation/softsign.rs new file mode 100644 index 0000000..3a9ed3d --- /dev/null +++ b/crates/burn-nn/src/activation/softsign.rs @@ -0,0 +1,37 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the softsign function element-wise +/// See also [softsign](burn::tensor::activation::softsign) +#[derive(Module, Debug, Default)] +pub struct Softsign; + +impl Softsign { + /// Create the module. + pub fn new() -> Self { + Self {} + } + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::softsign(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Softsign::new(); + + assert_eq!(alloc::format!("{layer}"), "Softsign"); + } +} diff --git a/crates/burn-nn/src/activation/swiglu.rs b/crates/burn-nn/src/activation/swiglu.rs new file mode 100644 index 0000000..4f7b8b8 --- /dev/null +++ b/crates/burn-nn/src/activation/swiglu.rs @@ -0,0 +1,150 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::activation::silu; +use burn::tensor::{Device, Tensor}; + +use crate::{Linear, LinearConfig, LinearLayout}; + +/// Configuration to create a [SwiGlu](SwiGlu) activation layer using the [init function](SwiGluConfig::init). +#[derive(Config, Debug)] +pub struct SwiGluConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the output features. + pub d_output: usize, + /// If a bias should be applied during the linear transformation. Default behaviour is False + /// for SwiGLU activation implementations. + #[config(default = false)] + pub bias: bool, + /// The type of function used to initialize the linear layer parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + /// The layout in which the linear parameters are stored. + #[config(default = "LinearLayout::Row")] + pub layout: LinearLayout, +} + +/// Applies the SwiGLU or Swish Gated Linear Unit to the input tensor. +/// The SwiGLU activation function is defined as: +/// `SwiGLU(x) = Swish(W_inner * x + b_inner) * (W_outer * x + b_outer)` +/// +/// Should be created with [SwiGluConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct SwiGlu { + /// The inner linear layer for Swish activation function + /// with `d_input` input features and `d_output` output features. + pub linear_inner: Linear, + /// The outer linear layer for element wise multiplication + /// with `d_input` input features and `d_output` output features. + pub linear_outer: Linear, +} + +impl ModuleDisplay for SwiGlu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, d_output] = self.linear_inner.weight.shape().dims(); + content + .add("d_input", &d_input) + .add("d_output", &d_output) + .add("bias", &self.linear_inner.bias.is_some()) + .optional() + } +} + +impl SwiGluConfig { + /// Initialize a new [SwiGLU](SwiGlu) activation layer. + pub fn init(&self, device: &Device) -> SwiGlu { + SwiGlu { + linear_inner: LinearConfig::new(self.d_input, self.d_output) + .with_bias(self.bias) + .with_initializer(self.initializer.clone()) + .with_layout(self.layout) + .init(device), + linear_outer: LinearConfig::new(self.d_input, self.d_output) + .with_bias(self.bias) + .with_initializer(self.initializer.clone()) + .with_layout(self.layout) + .init(device), + } + } +} + +impl SwiGlu { + /// Applies the Swish Gated Linear Unit to the input tensor. + /// + /// # Shapes + /// + /// - input: `[batch_size, seq_length, d_input]` + /// - output: `[batch_size, seq_length, d_output]` + pub fn forward(&self, input: Tensor) -> Tensor { + let x = self.linear_inner.forward(input.clone()); + let x = silu(x); + x.mul(self.linear_outer.forward(input)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_swiglu_forward_no_bias() { + let device = Device::default(); + device.seed(0); + + let config = SwiGluConfig::new(3, 3).with_initializer(Initializer::Constant { value: 0.5 }); + let swiglu = config.init(&device); + let input = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let output = swiglu.forward(input); + let expected_output = Tensor::<2>::from_data( + [[8.5732, 8.5732, 8.5732], [56.2189, 56.2189, 56.2189]], + &device, + ); + output + .to_data() + .assert_approx_eq::(&expected_output.to_data(), Tolerance::default()); + } + + #[test] + fn test_swiglu_forward_with_bias() { + let device = Device::default(); + device.seed(0); + + let config = SwiGluConfig::new(3, 3) + .with_bias(true) + .with_initializer(Initializer::Constant { value: 0.5 }); + let swiglu = config.init(&device); + let input = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + let output = swiglu.forward(input); + let expected_output = Tensor::<2>::from_data( + [[11.8909, 11.8909, 11.8909], [63.9785, 63.9785, 63.9785]], + &device, + ); + output + .to_data() + .assert_approx_eq::(&expected_output.to_data(), Tolerance::default()); + } + + #[test] + fn display() { + let config = SwiGluConfig::new(3, 5); + let swiglu = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{swiglu}"), + "SwiGlu {d_input: 3, d_output: 5, bias: false, params: 30}" + ); + } +} diff --git a/crates/burn-nn/src/activation/tanh.rs b/crates/burn-nn/src/activation/tanh.rs new file mode 100644 index 0000000..cee074a --- /dev/null +++ b/crates/burn-nn/src/activation/tanh.rs @@ -0,0 +1,37 @@ +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Applies the tanh activation function element-wise +/// See also [tanh](burn::tensor::activation::tanh) +#[derive(Module, Debug, Default)] +pub struct Tanh; + +impl Tanh { + /// Create the module. + pub fn new() -> Self { + Self {} + } + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + burn::tensor::activation::tanh(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Tanh::new(); + + assert_eq!(alloc::format!("{layer}"), "Tanh"); + } +} diff --git a/crates/burn-nn/src/activation/thresholded_relu.rs b/crates/burn-nn/src/activation/thresholded_relu.rs new file mode 100644 index 0000000..6e6f31a --- /dev/null +++ b/crates/burn-nn/src/activation/thresholded_relu.rs @@ -0,0 +1,79 @@ +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn_core as burn; + +use burn::tensor::activation::thresholded_relu; + +/// Thresholded ReLU layer. +/// +/// Should be created with [ThresholdedReluConfig](ThresholdedReluConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct ThresholdedRelu { + /// The alpha threshold. + pub alpha: f64, +} + +/// Configuration to create a [ThresholdedRelu](ThresholdedRelu) layer using the [init function](ThresholdedReluConfig::init). +#[derive(Config, Debug)] +pub struct ThresholdedReluConfig { + /// The alpha threshold. Default is 1.0 + #[config(default = "1.0")] + pub alpha: f64, +} + +impl ThresholdedReluConfig { + /// Initialize a new [ThresholdedRelu](ThresholdedRelu) layer. + pub fn init(&self) -> ThresholdedRelu { + ThresholdedRelu { alpha: self.alpha } + } +} + +impl ModuleDisplay for ThresholdedRelu { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("alpha", &self.alpha).optional() + } +} + +impl ThresholdedRelu { + /// Forward pass for the Thresholded ReLU layer. + /// + /// See [thresholded_relu](burn::tensor::activation::thresholded_relu) for more information. + /// + /// # Shapes + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + thresholded_relu(input, self.alpha) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + + #[test] + fn test_thresholded_relu_forward() { + let device = Default::default(); + let model = ThresholdedReluConfig::new().init(); + let input = Tensor::<2>::from_data(TensorData::from([[0.5, 1.5, -0.2]]), &device); + let out = model.forward(input); + let expected = TensorData::from([[0.0, 1.5, 0.0]]); + out.to_data().assert_eq(&expected, false); + } + + #[test] + fn display() { + let config = ThresholdedReluConfig::new().init(); + assert_eq!(alloc::format!("{config}"), "ThresholdedRelu {alpha: 1}"); + } +} diff --git a/crates/burn-nn/src/lib.rs b/crates/burn-nn/src/lib.rs new file mode 100644 index 0000000..d248e12 --- /dev/null +++ b/crates/burn-nn/src/lib.rs @@ -0,0 +1,29 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "256"] + +//! Burn neural network module. + +/// Loss module +pub mod loss; + +/// Neural network modules implementations. +pub mod modules; +pub use modules::*; + +pub mod activation; +pub use activation::{ + celu::*, elu::*, gelu::*, glu::*, hard_shrink::*, hard_sigmoid::*, leaky_relu::*, prelu::*, + relu::*, selu::*, shrink::*, sigmoid::*, soft_shrink::*, softplus::*, softsign::*, swiglu::*, + tanh::*, thresholded_relu::*, +}; + +mod padding; + +pub use padding::*; + +// For backward compat, `burn::nn::Initializer` +pub use burn_core::module::Initializer; + +extern crate alloc; diff --git a/crates/burn-nn/src/loss/binary_cross_entropy.rs b/crates/burn-nn/src/loss/binary_cross_entropy.rs new file mode 100644 index 0000000..40ca7ff --- /dev/null +++ b/crates/burn-nn/src/loss/binary_cross_entropy.rs @@ -0,0 +1,409 @@ +use burn_core as burn; + +use alloc::vec::Vec; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::activation::log_sigmoid; +use burn::tensor::{Device, Int, Tensor}; +use burn::{config::Config, module::Module}; + +/// Configuration to create a [Binary Cross-entropy loss](BinaryCrossEntropyLoss) using the [init function](BinaryCrossEntropyLossConfig::init). +#[derive(Config, Debug)] +pub struct BinaryCrossEntropyLossConfig { + /// Create weighted binary cross-entropy with a weight for each class. + /// + /// The loss of a specific sample will simply be multiplied by its label weight. + pub weights: Option>, + + /// Create binary cross-entropy with label smoothing according to [When Does Label Smoothing Help?](https://arxiv.org/abs/1906.02629). + /// + /// Hard labels {0, 1} will be changed to `y_smoothed = y(1 - a) + a / num_classes`. + /// Alpha = 0 would be the same as default. + pub smoothing: Option, + + /// Treat the inputs as logits, applying a sigmoid activation when computing the loss. + #[config(default = false)] + pub logits: bool, +} + +impl BinaryCrossEntropyLossConfig { + /// Initialize [Binary Cross-entropy loss](BinaryCrossEntropyLoss). + pub fn init(&self, device: &Device) -> BinaryCrossEntropyLoss { + self.assertions(); + BinaryCrossEntropyLoss { + weights: self + .weights + .as_ref() + .map(|e| Tensor::<1>::from_floats(e.as_slice(), device)), + smoothing: self.smoothing, + logits: self.logits, + } + } + + fn assertions(&self) { + if let Some(alpha) = self.smoothing { + assert!( + (0.0..=1.).contains(&alpha), + "Alpha of Cross-entropy loss with smoothed labels should be in interval [0, 1]. Got {alpha}" + ); + }; + if let Some(weights) = self.weights.as_ref() { + assert!( + weights.iter().all(|e| e > &0.), + "Weights of cross-entropy have to be positive." + ); + } + } +} + +/// Calculate the binary cross entropy loss from the input logits and the targets. +/// +/// Should be created using [BinaryCrossEntropyLossConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct BinaryCrossEntropyLoss { + /// Weights for cross-entropy. + pub weights: Option>, + /// Label smoothing alpha. + pub smoothing: Option, + /// Treat the inputs as logits + pub logits: bool, +} + +impl ModuleDisplay for BinaryCrossEntropyLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("weights", &self.weights) + .add("smoothing", &self.smoothing) + .add("logits", &self.logits) + .optional() + } +} + +impl BinaryCrossEntropyLoss { + /// Compute the criterion on the input tensor. + /// + /// # Shapes + /// + /// Binary: + /// - logits: `[batch_size]` + /// - targets: `[batch_size]` + /// + /// Multi-label: + /// - logits: `[batch_size, num_classes]` + /// - targets: `[batch_size, num_classes]` + pub fn forward(&self, logits: Tensor, targets: Tensor) -> Tensor<1> { + self.assertions(&logits, &targets); + + let mut targets_float = targets.clone().float(); + let shape = targets.dims(); + + if let Some(alpha) = self.smoothing { + let num_classes = if D > 1 { shape[D - 1] } else { 2 }; + targets_float = targets_float * (1. - alpha) + alpha / num_classes as f32; + } + + let mut loss = if self.logits { + // Numerically stable by combining `log(sigmoid(x))` with `log_sigmoid(x)` + (targets_float.neg() + 1.) * logits.clone() - log_sigmoid(logits) + } else { + // - (target * log(input) + (1 - target) * log(1 - input)) + // https://github.com/tracel-ai/burn/issues/2739: clamp at -100.0 to avoid undefined values + (targets_float.clone() - 1) * logits.clone().neg().log1p().clamp_min(-100.0) + - targets_float * logits.log().clamp_min(-100.0) + }; + + if let Some(weights) = &self.weights { + let weights = if D > 1 { + weights.clone().expand(shape) + } else { + // Flatten targets and expand resulting weights to make it compatible with + // Tensor for binary 1-D case + weights + .clone() + .gather(0, targets.flatten(0, 0)) + .expand(shape) + }; + loss = loss * weights; + } + + loss.mean() + } + + fn assertions(&self, logits: &Tensor, targets: &Tensor) { + let logits_dims = logits.dims(); + let targets_dims = targets.dims(); + assert!( + logits_dims == targets_dims, + "Shape of targets ({targets_dims:?}) should correspond to outer shape of logits ({logits_dims:?})." + ); + + if let Some(weights) = &self.weights + && D > 1 + { + let targets_classes = targets_dims[D - 1]; + let weights_classes = weights.dims()[0]; + assert!( + weights_classes == targets_classes, + "The number of classes ({weights_classes}) does not match the weights provided ({targets_classes})." + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + use burn::tensor::{TensorData, activation::sigmoid}; + type FT = f32; + + #[test] + fn test_binary_cross_entropy_preds_all_correct() { + let device = Default::default(); + let preds = Tensor::<1>::from_floats([1.0, 0.0, 1.0, 0.0], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([1, 0, 1, 0]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .init(&device) + .forward(preds, targets) + .into_data(); + + let loss_expected = TensorData::from([0.000]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::default()); + } + + #[test] + fn test_binary_cross_entropy_preds_all_incorrect() { + let device = Default::default(); + let preds = Tensor::<1>::from_floats([0.0, 1.0, 0.0, 1.0], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([1, 0, 1, 0]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .init(&device) + .forward(preds, targets) + .into_data(); + + let loss_expected = TensorData::from([100.000]); // clamped value + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::default()); + } + + #[test] + fn test_binary_cross_entropy() { + // import torch + // from torch import nn + // input = torch.tensor([0.8271, 0.9626, 0.3796, 0.2355]) + // target = torch.tensor([0., 1., 0., 1.]) + // loss = nn.BCELoss() + // sigmoid = nn.Sigmoid() + // out = loss(sigmoid(input), target) # tensor(0.7491) + + let device = Default::default(); + let logits = Tensor::<1>::from_floats([0.8271, 0.9626, 0.3796, 0.2355], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([0, 1, 0, 1]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .init(&device) + .forward(sigmoid(logits), targets) + .into_data(); + + let loss_expected = TensorData::from([0.7491]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::relative(1e-4)); + } + + #[test] + fn test_binary_cross_entropy_with_logits() { + let device = Default::default(); + let logits = Tensor::<1>::from_floats([0.8271, 0.9626, 0.3796, 0.2355], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([0, 1, 0, 1]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_logits(true) + .init(&device) + .forward(logits, targets) + .into_data(); + + let loss_expected = TensorData::from([0.7491]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::relative(1e-4)); + } + + #[test] + fn test_binary_cross_entropy_with_weights() { + // import torch + // from torch import nn + // input = torch.tensor([0.8271, 0.9626, 0.3796, 0.2355]) + // target = torch.tensor([0, 1, 0, 1]) + // weights = torch.tensor([3., 7.]).gather(0, target) + // loss = nn.BCELoss(weights) + // sigmoid = nn.Sigmoid() + // out = loss(sigmoid(input), target.float()) # tensor(3.1531) + + let device = Default::default(); + let logits = Tensor::<1>::from_floats([0.8271, 0.9626, 0.3796, 0.2355], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([0, 1, 0, 1]), &device); + let weights = [3., 7.]; + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_weights(Some(weights.to_vec())) + .init(&device) + .forward(sigmoid(logits), targets) + .into_data(); + + let loss_expected = TensorData::from([3.1531]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::relative(1e-4)); + } + + #[test] + fn test_binary_cross_entropy_with_smoothing() { + // import torch + // from torch import nn + // input = torch.tensor([0.8271, 0.9626, 0.3796, 0.2355]) + // target = torch.tensor([0., 1., 0., 1.]) + // target_smooth = target * (1 - 0.1) + (0.1 / 2) + // loss = nn.BCELoss() + // sigmoid = nn.Sigmoid() + // out = loss(sigmoid(input), target_smooth) # tensor(0.7490) + + let device = Default::default(); + let logits = Tensor::<1>::from_floats([0.8271, 0.9626, 0.3796, 0.2355], &device); + let targets = Tensor::<1, Int>::from_data(TensorData::from([0, 1, 0, 1]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_smoothing(Some(0.1)) + .init(&device) + .forward(sigmoid(logits), targets) + .into_data(); + + let loss_expected = TensorData::from([0.7490]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::relative(1e-4)); + } + + #[test] + fn test_binary_cross_entropy_multilabel() { + // import torch + // from torch import nn + // input = torch.tensor([[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]]) + // target = torch.tensor([[1., 0., 1.], [1., 0., 0.]]) + // weights = torch.tensor([3., 7., 0.9]) + // loss = nn.BCEWithLogitsLoss() + // out = loss(input, target) # tensor(0.7112) + + let device = Default::default(); + let logits = Tensor::<2>::from_floats( + [[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]], + &device, + ); + let targets = + Tensor::<2, Int>::from_data(TensorData::from([[1, 0, 1], [1, 0, 0]]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_logits(true) + .init(&device) + .forward(logits, targets) + .into_data(); + + let loss_expected = TensorData::from([0.7112]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::relative(1e-4)); + } + + #[test] + fn test_binary_cross_entropy_multilabel_with_weights() { + // import torch + // from torch import nn + // input = torch.tensor([[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]]) + // target = torch.tensor([[1., 0., 1.], [1., 0., 0.]]) + // loss = nn.BCEWithLogitsLoss() + // out = loss(input, target) # tensor(3.1708) + + let device = Default::default(); + let logits = Tensor::<2>::from_floats( + [[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]], + &device, + ); + let targets = + Tensor::<2, Int>::from_data(TensorData::from([[1, 0, 1], [1, 0, 0]]), &device); + let weights = [3., 7., 0.9]; + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_logits(true) + .with_weights(Some(weights.to_vec())) + .init(&device) + .forward(logits, targets) + .into_data(); + + let loss_expected = TensorData::from([3.1708]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::default()); + } + + #[test] + fn test_binary_cross_entropy_multilabel_with_smoothing() { + // import torch + // from torch import nn + // input = torch.tensor([[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]]) + // target = torch.tensor([[1., 0., 1.], [1., 0., 0.]]) + // target_smooth = target * (1 - 0.1) + (0.1 / 3) + // loss = nn.BCELoss() + // sigmoid = nn.Sigmoid() + // out = loss(sigmoid(input), target_smooth) # tensor(0.7228) + + let device = Default::default(); + let logits = Tensor::<2>::from_floats( + [[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]], + &device, + ); + let targets = + Tensor::<2, Int>::from_data(TensorData::from([[1, 0, 1], [1, 0, 0]]), &device); + + let loss_actual = BinaryCrossEntropyLossConfig::new() + .with_smoothing(Some(0.1)) + .init(&device) + .forward(sigmoid(logits), targets) + .into_data(); + + let loss_expected = TensorData::from([0.7228]); + loss_actual.assert_approx_eq::(&loss_expected, Tolerance::default()); + } + + #[test] + #[should_panic = "The number of classes"] + fn multilabel_weights_should_match_target() { + // import torch + // from torch import nn + // input = torch.tensor([[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]]) + // target = torch.tensor([[1., 0., 1.], [1., 0., 0.]]) + // loss = nn.BCEWithLogitsLoss() + // out = loss(input, target) # tensor(3.1708) + + let device = Default::default(); + let logits = Tensor::<2>::from_floats( + [[0.5150, 0.3097, 0.7556], [0.4974, 0.9879, 0.1564]], + &device, + ); + let targets = + Tensor::<2, Int>::from_data(TensorData::from([[1, 0, 1], [1, 0, 0]]), &device); + let weights = [3., 7.]; + + let _loss = BinaryCrossEntropyLossConfig::new() + .with_logits(true) + .with_weights(Some(weights.to_vec())) + .init(&device) + .forward(logits, targets); + } + + #[test] + fn display() { + let config = + BinaryCrossEntropyLossConfig::new().with_weights(Some(alloc::vec![3., 7., 0.9])); + let loss = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{loss}"), + "BinaryCrossEntropyLoss {weights: Tensor {rank: 1, shape: [3]}, smoothing: None, logits: false}" + ); + } +} diff --git a/crates/burn-nn/src/loss/cosine_embedding.rs b/crates/burn-nn/src/loss/cosine_embedding.rs new file mode 100644 index 0000000..0d94811 --- /dev/null +++ b/crates/burn-nn/src/loss/cosine_embedding.rs @@ -0,0 +1,294 @@ +use alloc::format; + +use burn::tensor::linalg::cosine_similarity; + +use burn_core as burn; + +use crate::loss::reduction::Reduction; +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::{Int, Tensor, activation::relu}; + +/// Configuration for CosineEmbeddingLoss. +#[derive(Config, Debug)] +pub struct CosineEmbeddingLossConfig { + /// Margin for negative samples. + #[config(default = 0.0)] + pub margin: f32, + + /// Specifies the reduction to apply to the output. + #[config(default = "Reduction::Mean")] + pub reduction: Reduction, +} + +impl CosineEmbeddingLossConfig { + /// Initialize CosineEmbeddingLoss. + pub fn init(&self) -> CosineEmbeddingLoss { + CosineEmbeddingLoss { + margin: self.margin, + reduction: self.reduction.clone(), + } + } +} + +/// Cosine embedding loss between two tensors. +/// +/// Measures cosine distance between tensors. +/// Used for learning embeddings or similarity. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct CosineEmbeddingLoss { + /// Margin value. Default: 0.0 + pub margin: f32, + + /// Reduction method + #[module(skip)] + pub reduction: Reduction, +} + +impl Default for CosineEmbeddingLoss { + fn default() -> Self { + CosineEmbeddingLossConfig::new().init() + } +} + +impl ModuleDisplay for CosineEmbeddingLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("margin", &self.margin) + .add("reduction", format!("{:?}", self.reduction).as_str()) + .optional() + } +} + +impl CosineEmbeddingLoss { + /// Creates a new instance + pub fn new() -> Self { + CosineEmbeddingLossConfig::new().init() + } + + /// Compute loss with reduction. + /// + /// # Shapes + /// + /// - input1: ``[batch_size, embedding_dim]`` + /// - input2: ``[batch_size, embedding_dim]`` + /// - target: ``[batch_size]`` with values 1 or -1 + /// + /// # Returns + /// + /// Loss tensor of shape ``[1]`` + pub fn forward( + &self, + input1: Tensor<2>, + input2: Tensor<2>, + target: Tensor<1, Int>, + ) -> Tensor<1> { + let tensor = self.forward_no_reduction(input1, input2, target); + match &self.reduction { + Reduction::Mean | Reduction::Auto => tensor.mean(), + Reduction::Sum => tensor.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Compute loss without applying reduction. + /// + /// # Arguments + /// + /// * `input1` - First input tensor of shape ``[batch_size, embedding_dim]`` + /// * `input2` - Second input tensor of shape ``[batch_size, embedding_dim]`` + /// * `target` - Target tensor of shape ``[batch_size]`` with values 1 or -1 + /// + /// # Returns + /// + /// Tensor of per-element losses with shape ``[batch_size]`` + pub fn forward_no_reduction( + &self, + input1: Tensor<2>, + input2: Tensor<2>, + target: Tensor<1, Int>, + ) -> Tensor<1> { + self.assertions(&input1, &input2, &target); + + // cos_sim shape: [batch_size, 1] + let cos_sim = cosine_similarity(input1, input2, 1, None); + // cos_sim shape: [batch_size] + let cos_sim: Tensor<1> = cos_sim.squeeze_dim(1); + + let mut loss = cos_sim.zeros_like(); + + // Similar pairs (target == 1) - Formula: L = 1 - cos_sim + let similar_mask = target.clone().equal_elem(1); + let similar_loss = cos_sim.clone().neg().add_scalar(1); + loss = loss.mask_where(similar_mask, similar_loss); + + // Dissimilar pairs (target == -1) - Formula: L = max(0, cos_sim - margin) + let dissimilar_mask = target.equal_elem(-1); + let dissimilar_loss = relu(cos_sim.clone().sub_scalar(self.margin)); + loss = loss.mask_where(dissimilar_mask, dissimilar_loss); + + // return loss shape: [batch_size] + loss + } + + fn assertions(&self, input1: &Tensor<2>, input2: &Tensor<2>, target: &Tensor<1, Int>) { + let [batch_size1, dim1] = input1.dims(); + let [batch_size2, dim2] = input2.dims(); + let [batch_size_target] = target.dims(); + + assert_eq!( + batch_size1, batch_size2, + "Batch size of input1 ({batch_size1}) must match batch size of input2 ({batch_size2})" + ); + + assert_eq!( + dim1, dim2, + "Embedding dimension of input1 ({dim1}) must match embedding dimension of input2 ({dim2})" + ); + + assert_eq!( + batch_size1, batch_size_target, + "Batch size of inputs ({batch_size1}) must match batch size of target ({batch_size_target})" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn cosine_embedding_loss_positive_target() { + let device = Default::default(); + + // Two identical vectors should have cosine similarity of 1 + let input1 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + let input2 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + // Target 1 means that inputs should be similar + let target = Tensor::<1, Int>::from_data(TensorData::from([1, 1]), &device); + + let loss = CosineEmbeddingLossConfig::new().init(); + let loss_no_reduction = + loss.forward_no_reduction(input1.clone(), input2.clone(), target.clone()); + let loss_mean = loss.forward(input1.clone(), input2.clone(), target.clone()); + + let loss_sum = loss.forward(input1, input2, target); + + // For identical vectors, 1 - cos_sim = 1 - 1 = 0 + let expected_no_reduction = TensorData::from([0.0, 0.0]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected_no_reduction, Tolerance::default()); + + let expected_mean = TensorData::from([0.0]); + loss_mean + .into_data() + .assert_approx_eq::(&expected_mean, Tolerance::default()); + + let expected_sum = TensorData::from([0.0]); + loss_sum + .into_data() + .assert_approx_eq::(&expected_sum, Tolerance::default()); + } + + #[test] + fn cosine_embedding_loss_negative_target() { + let device = Default::default(); + + // Two identical vectors should have cosine similarity of 1 + let input1 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + let input2 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + // Target -1 means that inputs should be dissimilar + let target = Tensor::<1, Int>::from_data(TensorData::from([-1, -1]), &device); + + // With margin 0.0, max(0, cos_sim - margin) = max(0, 1 - 0) = 1 + let loss = CosineEmbeddingLossConfig::new().init(); + let loss_no_reduction = + loss.forward_no_reduction(input1.clone(), input2.clone(), target.clone()); + let loss_mean = loss.forward(input1.clone(), input2.clone(), target.clone()); + + // Create a loss with Sum reduction for testing + let loss_sum_config = CosineEmbeddingLossConfig::new().with_reduction(Reduction::Sum); + let loss_sum = + loss_sum_config + .init() + .forward(input1.clone(), input2.clone(), target.clone()); + + let expected_no_reduction = TensorData::from([1.0, 1.0]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected_no_reduction, Tolerance::default()); + + let expected_mean = TensorData::from([1.0]); + loss_mean + .into_data() + .assert_approx_eq::(&expected_mean, Tolerance::default()); + + let expected_sum = TensorData::from([2.0]); + loss_sum + .into_data() + .assert_approx_eq::(&expected_sum, Tolerance::default()); + + // With margin 0.5, max(0, cos_sim - margin) = max(0, 1 - 0.5) = 0.5 + let loss_with_margin = CosineEmbeddingLossConfig::new().with_margin(0.5).init(); + let loss_with_margin = loss_with_margin.forward(input1, input2, target); + + let expected = TensorData::from([0.5]); + loss_with_margin + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn cosine_embedding_loss_mixed_targets() { + let device = Default::default(); + + let input1 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + let input2 = Tensor::<2>::from_data(TensorData::from([[1.0, 0.0], [0.0, 1.0]]), &device); + + // Mixed targets + let target = Tensor::<1, Int>::from_data(TensorData::from([1, -1]), &device); + + let loss = CosineEmbeddingLossConfig::new().init(); + let loss_no_reduction = + loss.forward_no_reduction(input1.clone(), input2.clone(), target.clone()); + let loss_mean = loss.forward(input1, input2, target); + + let expected_no_reduction = TensorData::from([0.0, 1.0]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected_no_reduction, Tolerance::default()); + + let expected_mean = TensorData::from([0.5]); + loss_mean + .into_data() + .assert_approx_eq::(&expected_mean, Tolerance::default()); + } + + #[test] + fn display() { + let config = CosineEmbeddingLossConfig::new().with_margin(0.5); + let loss = config.init(); + + assert_eq!( + alloc::format!("{loss}"), + "CosineEmbeddingLoss {margin: 0.5, reduction: Mean}" + ); + } +} diff --git a/crates/burn-nn/src/loss/cross_entropy.rs b/crates/burn-nn/src/loss/cross_entropy.rs new file mode 100644 index 0000000..a34ad41 --- /dev/null +++ b/crates/burn-nn/src/loss/cross_entropy.rs @@ -0,0 +1,514 @@ +use burn_core as burn; +use burn_core::tensor::IndexingUpdateOp; + +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::activation::log_softmax; +use burn::tensor::{Bool, Device, Int, Tensor}; +use burn::{config::Config, module::Module}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +/// Configuration to create a [Cross-entropy loss](CrossEntropyLoss) using the [init function](CrossEntropyLossConfig::init). +#[derive(Config, Debug)] +pub struct CrossEntropyLossConfig { + /// Create padded cross entropy. + /// + /// Prevents pad tokens from impacting loss calculation. + pub pad_tokens: Option>, + + /// Create weighted cross-entropy. + /// + /// The loss of a specific sample will simply be given by: weight * log(p(x)) * 1, + /// + /// # Pre-conditions + /// - The order of the weight vector should correspond to the label integer assignment. + /// - Targets assigned negative Int's will not be allowed. + pub weights: Option>, + + /// Create cross-entropy with label smoothing. + /// + /// Hard labels {0, 1} will be changed to y_smoothed = y(1 - a) + a / nr_classes. + /// Alpha = 0 would be the same as default. + pub smoothing: Option, + + /// Create cross-entropy with probabilities as input instead of logits. + /// + #[config(default = true)] + pub logits: bool, +} + +impl CrossEntropyLossConfig { + /// Initialize [Cross-entropy loss](CrossEntropyLoss). + pub fn init(&self, device: &Device) -> CrossEntropyLoss { + self.assertions(); + CrossEntropyLoss { + pad_tokens: self.pad_tokens.clone(), + weights: self + .weights + .as_ref() + .map(|e| Tensor::<1>::from_floats(e.as_slice(), device)), + smoothing: self.smoothing, + logits: self.logits, + } + } + + fn assertions(&self) { + if let Some(alpha) = self.smoothing { + assert!( + (0.0..=1.).contains(&alpha), + "Alpha of Cross-entropy loss with smoothed labels should be in interval [0, 1]. Got {alpha}" + ); + }; + if let Some(weights) = self.weights.as_ref() { + assert!( + weights.iter().all(|e| e > &0.), + "Weights of cross-entropy have to be positive." + ); + } + } +} + +/// Calculate the cross entropy loss from the input logits and the targets. +/// +/// Should be created using [CrossEntropyLossConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct CrossEntropyLoss { + /// Pad tokens to ignore in the loss calculation. + pub pad_tokens: Option>, + /// Weights for cross-entropy. + pub weights: Option>, + /// Label smoothing factor. + pub smoothing: Option, + /// Use logits as input. + pub logits: bool, +} + +impl ModuleDisplay for CrossEntropyLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let pad_tokens = if let Some(pad_tokens) = &self.pad_tokens { + alloc::format!("Vec<0..{}>", pad_tokens.len()) + } else { + "None".to_string() + }; + + content + .add("pad_tokens", &pad_tokens) + .add("weights", &self.weights) + .add("smoothing", &self.smoothing) + .add("logits", &self.logits) + .optional() + } +} + +impl CrossEntropyLoss { + /// For backward compatibility. + pub fn new(pad_index: Option, device: &Device) -> Self { + CrossEntropyLossConfig::new() + .with_pad_tokens(pad_index.map(|e| vec![e])) + .init(device) + } + + /// Compute the criterion on the input tensor. + /// + /// # Shapes + /// + /// - logits: `[batch_size, num_targets]` + /// - targets: `[batch_size]` + pub fn forward(&self, logits: Tensor<2>, targets: Tensor<1, Int>) -> Tensor<1> { + Self::assertions(logits.clone(), targets.clone()); + match self.smoothing { + Some(alpha) => self.forward_smoothed(logits, targets, alpha), + _ => self.forward_default(logits, targets), + } + } + + fn forward_smoothed( + &self, + logits: Tensor<2>, + targets: Tensor<1, Int>, + alpha: f32, + ) -> Tensor<1> { + let mask = self.padding_mask(&targets); + let tensor = if self.logits { + log_softmax(logits, 1) + } else { + logits.log() + }; + let [batch_size, nr_classes] = tensor.dims(); + let tensor = tensor + * Self::compute_smoothed_targets([batch_size, nr_classes], targets.clone(), alpha); + + match &self.weights { + Some(weights) => { + let tensor = tensor + * weights + .clone() + .reshape([1, nr_classes]) + .repeat_dim(0, batch_size); + let weights = weights.clone().gather(0, targets); + let tensor = Self::apply_mask_2d(tensor, mask); + tensor.sum().neg() / weights.sum() + } + None => { + let tensor = Self::apply_mask_2d(tensor, mask); + tensor.sum_dim(1).mean().neg() + } + } + } + + fn forward_default(&self, logits: Tensor<2>, targets: Tensor<1, Int>) -> Tensor<1> { + let [batch_size] = targets.dims(); + + let mask = self.padding_mask(&targets); + let target_indices = targets.clone().reshape([batch_size, 1]); + let tensor = if self.logits { + log_softmax(logits, 1).gather(1, target_indices) + } else { + // TODO: finfo stable eps + let finfo = logits.dtype().finfo().unwrap(); + let eps = finfo.min_positive.sqrt(); + logits.clamp_min(eps).gather(1, target_indices).log() + }; + + match &self.weights { + Some(weights) => { + let weights = weights.clone().gather(0, targets); + let tensor = tensor.reshape([batch_size]) * weights.clone(); + let tensor = Self::apply_mask_1d(tensor, mask); + tensor.sum().neg() / weights.sum() + } + None => { + let tensor = Self::apply_mask_1d(tensor.reshape([batch_size]), mask); + tensor.mean().neg() + } + } + } + + fn compute_smoothed_targets( + shape: [usize; 2], + targets: Tensor<1, Int>, + alpha: f32, + ) -> Tensor<2> { + let [batch_size, nr_classes] = shape; + let device = &targets.device(); + let targets_matrix = Tensor::<2>::zeros(shape, device).scatter( + 1, + targets.reshape([batch_size, 1]), + Tensor::ones([batch_size, 1], device), + IndexingUpdateOp::Add, + ); + targets_matrix * (1. - alpha) + alpha / nr_classes as f32 + } + + fn padding_mask(&self, targets: &Tensor<1, Int>) -> Option> { + let mut mask = None; + if let Some(pad_tokens) = &self.pad_tokens { + let mut res = targets.clone().equal_elem(pad_tokens[0] as i64).int(); + for x in pad_tokens { + res = res + targets.clone().equal_elem(*x as i64).int(); + } + mask = Some(res.greater_elem(0)); + } + + mask + } + + fn apply_mask_1d(mut tensor: Tensor<1>, mask: Option>) -> Tensor<1> { + if let Some(mask) = mask { + tensor = tensor.mask_fill(mask, 0); + } + + tensor + } + + fn apply_mask_2d(mut tensor: Tensor<2>, mask: Option>) -> Tensor<2> { + if let Some(mask) = mask { + let [batch_size, nr_classes] = tensor.dims(); + tensor = tensor.mask_fill(mask.reshape([batch_size, 1]).repeat_dim(1, nr_classes), 0); + } + + tensor + } + + fn assertions(logits: Tensor<2>, targets: Tensor<1, Int>) { + let [logits_height, _] = logits.dims(); + let [targets_height] = targets.dims(); + assert!( + logits_height == targets_height, + "Shape of targets ({targets_height}) should correspond to outer shape of logits ({logits_height})." + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + use burn::tensor::{Distribution, TensorData, loss::cross_entropy_with_logits}; + type FT = f32; + + macro_rules! setup { + () => {{ + let [batch_size, num_targets] = [4, 5]; + let device = Default::default(); + let logits = Tensor::<2>::random( + [batch_size, num_targets], + Distribution::Normal(0., 1.0), + &device, + ); + let targets = Tensor::<1, Int>::from_data(TensorData::from([2, 0, 4, 1]), &device); + let targets_logits = Tensor::<2>::from_data( + TensorData::from([ + [0.0, 0.0, 1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + ]), + &device, + ); + (logits, targets, targets_logits) + }}; + } + + macro_rules! setup_padded { + () => {{ + let [batch_size, num_targets, pad_index] = [4, 5, 1]; + let device = Default::default(); + let logits = Tensor::<2>::random( + [batch_size, num_targets], + Distribution::Normal(0., 1.0), + &device, + ); + let targets = + Tensor::<1, Int>::from_data(TensorData::from([2, 0, 4, pad_index as i64]), &device); + let targets_logits = Tensor::<2>::from_data( + TensorData::from([ + [0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]), + &device, + ); + (logits, targets, targets_logits) + }}; + } + + #[test] + fn test_cross_entropy_loss_with_weights() { + let (logits, targets, targets_logits) = setup!(); + let weights = vec![1.0, 2., 3., 4., 5.]; + let device = Default::default(); + let loss_1 = CrossEntropyLossConfig::new() + .with_weights(Some(weights.clone())) + .init(&device) + .forward(logits.clone(), targets); + let tensor = log_softmax(logits, 1); + let loss_2 = tensor + * targets_logits + * Tensor::<1>::from_floats(weights.as_slice(), &device) + .unsqueeze() + .repeat_dim(0, 4); + let loss_2 = loss_2.sum().neg() / (1. + 2. + 3. + 5.); + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_label_smoothing_with_weights_and_alpha_zero() { + let (logits, targets, _) = setup!(); + let device = Default::default(); + let weights = vec![1.0, 2., 3., 4., 5.]; + let loss_1 = CrossEntropyLossConfig::new() + .with_weights(Some(weights.clone())) + .init(&device) + .forward(logits.clone(), targets.clone()); + let loss_2 = CrossEntropyLossConfig::new() + .with_weights(Some(weights.clone())) + .with_smoothing(Some(0.)) + .init(&device) + .forward(logits.clone(), targets); + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_cross_entropy_loss() { + let (logits, targets, targets_logits) = setup!(); + let device = Default::default(); + let loss_1 = CrossEntropyLossConfig::new() + .init(&device) + .forward(logits.clone(), targets); + let loss_2 = cross_entropy_with_logits(logits, targets_logits); + + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_label_smoothing_alpha_equal_zero() { + let (logits, targets, _) = setup!(); + let device = Default::default(); + let loss_1 = CrossEntropyLossConfig::new() + .init(&device) + .forward(logits.clone(), targets.clone()); + let loss_2 = CrossEntropyLossConfig::new() + .with_smoothing(Some(0.)) + .init(&device) + .forward(logits, targets); + + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_cross_entropy_loss_with_pad_token() { + let (logits, targets, targets_logits) = setup_padded!(); + let pad_index = 1; + + let loss_1 = CrossEntropyLossConfig::new() + .with_pad_tokens(Some(vec![pad_index, 2])) + .init(&logits.device()) + .forward(logits.clone(), targets); + let loss_2 = cross_entropy_with_logits(logits, targets_logits); + + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_label_smoothing_with_zero_alpha_and_pad_token() { + let (logits, targets, _) = setup_padded!(); + let pad_index = 1; + + let loss_1 = CrossEntropyLossConfig::new() + .with_pad_tokens(Some(vec![pad_index, 2])) + .init(&logits.device()) + .forward(logits.clone(), targets.clone()); + let loss_2 = CrossEntropyLossConfig::new() + .with_pad_tokens(Some(vec![pad_index, 2])) + .with_smoothing(Some(0.)) + .init(&logits.device()) + .forward(logits.clone(), targets); + + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_label_smoothing_target_conversion() { + let (logits, targets, _) = setup!(); + let smoothed_targets = + CrossEntropyLoss::compute_smoothed_targets(logits.dims(), targets, 0.05); + let targets_logits = Tensor::<2>::from_data( + TensorData::from([ + [0.01, 0.01, 0.96, 0.01, 0.01], + [0.96, 0.01, 0.01, 0.01, 0.01], + [0.01, 0.01, 0.01, 0.01, 0.96], + [0.01, 0.96, 0.01, 0.01, 0.01], + ]), + &Default::default(), + ); + smoothed_targets + .into_data() + .assert_approx_eq::(&targets_logits.into_data(), Tolerance::default()); + } + + #[test] + fn test_label_smoothing() { + let (logits, targets, _) = setup!(); + let device = Default::default(); + let loss_1 = CrossEntropyLossConfig::new() + .with_smoothing(Some(0.05)) + .init(&device) + .forward(logits.clone(), targets); + let targets_logits = Tensor::<2>::from_data( + TensorData::from([ + [0.01, 0.01, 0.96, 0.01, 0.01], + [0.96, 0.01, 0.01, 0.01, 0.01], + [0.01, 0.01, 0.01, 0.01, 0.96], + [0.01, 0.96, 0.01, 0.01, 0.01], + ]), + &device, + ); + + let x = log_softmax(logits, 1); + let loss_2 = (x * targets_logits).sum_dim(1).mean().neg(); + + loss_1 + .into_data() + .assert_approx_eq::(&loss_2.into_data(), Tolerance::default()); + } + + #[test] + fn test_logits_flag_affects_output() { + let device = Default::default(); + + let probs = Tensor::<2>::from_data( + TensorData::from([ + [0.1, 0.2, 0.7, 0.0, 0.0], + [0.7, 0.1, 0.1, 0.1, 0.0], + [0.2, 0.2, 0.2, 0.2, 0.2], + [0.0, 0.3, 0.3, 0.2, 0.2], + ]), + &device, + ); + + let targets = Tensor::<1, Int>::from_data(TensorData::from([2, 0, 4, 1]), &device); + + let loss_logits = CrossEntropyLossConfig::new() + .init(&device) + .forward(probs.clone(), targets.clone()); + + let loss_probs = CrossEntropyLossConfig::new() + .with_logits(false) + .init(&device) + .forward(probs, targets); + + // They must differ if logits flag is implemented correctly + let loss_logits = loss_logits.into_data(); + let loss_probs = loss_probs.into_data(); + + loss_logits.assert_approx_eq::(&TensorData::from([1.354197]), Tolerance::default()); + loss_probs.assert_approx_eq::(&TensorData::from([0.88169014]), Tolerance::default()); + + assert_ne!( + loss_logits.as_slice::().unwrap(), + loss_probs.as_slice::().unwrap(), + "logits flag should change computation (log_softmax vs log)" + ); + } + + #[test] + fn display() { + let config = CrossEntropyLossConfig::new() + .with_weights(Some(alloc::vec![3., 7., 0.9])) + .with_smoothing(Some(0.5)); + let loss = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{loss}"), + "CrossEntropyLoss {pad_tokens: None, weights: Tensor {rank: 1, shape: [3]}, smoothing: 0.5, logits: true}" + ); + } + + // TODO: backward tests +} diff --git a/crates/burn-nn/src/loss/ctc.rs b/crates/burn-nn/src/loss/ctc.rs new file mode 100644 index 0000000..a130795 --- /dev/null +++ b/crates/burn-nn/src/loss/ctc.rs @@ -0,0 +1,1417 @@ +#![allow(clippy::excessive_precision)] + +use super::Reduction; +use burn::config::Config; +use burn::module::Module; +use burn::tensor::{Int, Tensor}; +use burn_core as burn; + +/// Configuration for the [CTC Loss](CTCLoss) module. +#[derive(Config, Debug)] +pub struct CTCLossConfig { + /// The index number used to represent the blank label. Default value is `0`. + #[config(default = 0)] + pub blank: usize, + /// Whether to zero infinite losses and the associated gradients. Default value is `false`. + #[config(default = false)] + pub zero_infinity: bool, +} + +impl CTCLossConfig { + /// Initialize a new [CTC Loss](CTCLoss) module + pub fn init(&self) -> CTCLoss { + CTCLoss { + blank: self.blank, + zero_infinity: self.zero_infinity, + } + } +} + +/// Computes the Connectionist Temporal Classification (CTC) loss. +/// +/// Calculates the loss between a continuous (unsegmented) time series and a target sequence. +/// CTC sums over the probability of all possible alignments of the input to the target, +/// producing a loss value that is differentiable with respect to each input node. +/// +/// The input to this loss is expected to be **log-probabilities** (e.g,, via `log_softmax`), +/// not raw logits. +/// +/// # References +/// +/// - [Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks](https://www.cs.toronto.edu/~graves/icml_2006.pdf) +/// +/// # Example +/// +/// ```rust,ignore +/// use burn::tensor::{Tensor, Int}; +/// use burn::tensor::activation::log_softmax; +/// use burn::nn::loss::{CTCLossConfig, CTCLoss}; +/// +/// let device = Default::default(); +/// +/// // Initialize CTC Loss with default configuration +/// let ctc_loss = CTCLossConfig::new().init(); +/// +/// // Initialize CTC Loss with custom configuration +/// let ctc_loss = CTCLossConfig::new() +/// .with_blank(1) +/// .with_zero_infinity(true) +/// .init(); +/// +/// // Prepare inputs (Logits shape: [Time, Batch, Class]) +/// // In your actual code, the logits would be the output of your model +/// let logits = Tensor::<3>::ones([10, 2, 5], &device); +/// let log_probs = log_softmax(logits, 2); +/// +/// // Targets shape: [Batch, Max_Target_Len] +/// // Note: Targets should not contain the blank index (1). +/// let targets = Tensor::<2, Int>::from_data([[0, 2], [3, 4]], &device); +/// +/// // Lengths shape: [Batch] +/// let input_lengths = Tensor::<1, Int>::from_data([10, 8], &device); +/// let target_lengths = Tensor::<1, Int>::from_data([2, 2], &device); +/// +/// // Compute loss +/// let loss = ctc_loss.forward(log_probs, targets, input_lengths, target_lengths); +/// ``` +#[derive(Module, Debug)] +pub struct CTCLoss { + blank: usize, + zero_infinity: bool, +} + +impl CTCLoss { + /// Computes the CTC loss for the input log-probabilities and targets with no reduction applied. + /// + /// # Arguments + /// + /// - `log_probs`: The log-probabilities of the outputs (e.g., from `log_softmax`). + /// - `targets`: A 2D tensor containing the target class indices. These indices should not + /// include the blank index used in CTC loss. The targets are padded to the length of the longest sequence. + /// - `input_lengths`: A 1D tensor containing the actual length of the input sequence for each batch. This + /// allows retrieving the actual sequence of log-probabilities from `log_probs` if the batch contains + /// sequences of varying lengths. + /// - `target_lengths`: A 1D tensor containing the actual length of the target sequence for each target + /// sequence in `targets`. + /// + /// # Returns + /// + /// - A 1D tensor of shape `[batch_size]` containing the loss for each sample. + /// + /// # Shapes + /// + /// - `log_probs`: `[time_steps, batch_size, num_classes]` where `num_classes` includes blank. + /// - `targets`: `[batch_size, max_target_length]` + /// - `input_lengths`: `[batch_size]` + /// - `target_lengths`: `[batch_size]` + pub fn forward( + &self, + log_probs: Tensor<3>, + targets: Tensor<2, Int>, + input_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + ) -> Tensor<1> { + let [max_input_length, batch_size, num_classes] = log_probs.dims(); + let max_target_len = targets.dims()[1]; + let input_lengths_len = input_lengths.dims()[0]; + let target_lengths_len = target_lengths.dims()[0]; + self.assertions( + batch_size, + num_classes, + targets.clone(), + input_lengths_len, + target_lengths_len, + ); + self.length_assertions( + input_lengths.clone(), + target_lengths.clone(), + max_target_len, + max_input_length, + ); + + let mut loss = burn::tensor::module::ctc_loss( + log_probs, + targets, + input_lengths, + target_lengths, + self.blank, + ); + + if self.zero_infinity { + let inf_mask = loss.clone().is_inf(); + loss = loss.clone().mask_where(inf_mask, loss.clone().zeros_like()); + } + + loss + } + + /// Computes the CTC loss for the input log-probabilities and targets with reduction. + /// + /// # Arguments + /// + /// - `log_probs`: The log-probabilities of the outputs (e.g., from `log_softmax`). + /// - `targets`: A 2D tensor containing the target class indices. These indices should not + /// include the blank index used in CTC loss. The targets are padded to the length of the longest sequence. + /// - `input_lengths`: A 1D tensor containing the actual length of the input sequence for each batch. This + /// allows retrieving the actual sequence of log-probabilities from `log_probs` if the batch contains + /// sequences of varying lengths. + /// - `target_lengths`: A 1D tensor containing the actual length of the target sequence for each target + /// sequence in `targets`. + /// - `reduction`: The reduction stratey to apply to the loss tensor containing the CTC loss values for + /// each sample (e.g., mean, sum). For the mean reduction strategy, the output losses will be divided + /// by the target lengths and then the mean over the batch is taken. This follows PyTorch's behavior. + /// + /// # Returns + /// + /// - A 1D tensor of shape `[1]` containing the reduced loss value. + /// + /// # Shapes + /// + /// - `log_probs`: `[time_steps, batch_size, num_classes]` where `num_classes` includes blank. + /// - `targets`: `[batch_size, max_target_length]` + /// - `input_lengths`: `[batch_size]` + /// - `target_lengths`: `[batch_size]` + /// + /// # Panics + /// - If `reduction` is not one of `Reduction::Auto`, `Reduction::Mean`, and `Reduction::Sum`. + /// - If `blank` index is greater than or equal to `num_classes`. + /// - If the batch dimension of `log_probs`, `targets`, `input_lengths`, and `target_lengths` do not match. + pub fn forward_with_reduction( + &self, + log_probs: Tensor<3>, + targets: Tensor<2, Int>, + input_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + reduction: Reduction, + ) -> Tensor<1> { + let ctc_loss_tensor = + self.forward(log_probs, targets, input_lengths, target_lengths.clone()); + + match reduction { + Reduction::Auto | Reduction::Mean => { + // Following PyTorch's behavior where the output losses are divided + // by the target lengths and then the mean over the batch is taken + let target_lengths_float = target_lengths.float(); + ctc_loss_tensor.div(target_lengths_float).mean() + } + Reduction::Sum => ctc_loss_tensor.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Checks the per-element length invariants required by the alpha + /// recursion. These require reading the length tensors from the device, + /// so the checks are gated behind `cfg(debug_assertions)` to avoid the + /// device-to-host sync in release builds. + /// + /// Validated: + /// - `target_lengths[i] >= 0` + /// - `target_lengths[i] <= max_target_len` + /// - `input_lengths[i] >= target_lengths[i]` + /// - `input_lengths[i] <= max_input_length` + #[allow(unused_variables)] + fn length_assertions( + &self, + input_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + max_target_len: usize, + max_input_length: usize, + ) { + #[cfg(debug_assertions)] + { + let target_lengths_data = target_lengths.into_data(); + let input_lengths_data = input_lengths.into_data(); + let target_iter = target_lengths_data.iter::(); + let input_iter = input_lengths_data.iter::(); + for (i, (tl, il)) in target_iter.zip(input_iter).enumerate() { + assert!(tl >= 0, "target_lengths[{i}] = {tl} must be non-negative"); + assert!( + tl as usize <= max_target_len, + "target_lengths[{i}] = {tl} exceeds the targets tensor width {max_target_len}" + ); + assert!( + il >= tl, + "input_lengths[{i}] = {il} must be >= target_lengths[{i}] = {tl} \ + (no valid CTC alignment otherwise)" + ); + assert!( + il as usize <= max_input_length, + "input_lengths[{i}] = {il} exceeds the log_probs time dimension \ + {max_input_length}" + ); + } + } + } + + fn assertions( + &self, + batch_size: usize, + num_classes: usize, + targets: Tensor<2, Int>, + input_lengths_len: usize, + target_lengths_len: usize, + ) { + assert!( + self.blank < num_classes, + "blank index {} must be less than num_classes {}", + self.blank, + num_classes + ); + assert_eq!( + targets.dims()[0], + batch_size, + "targets batch dimension {} must equal batch_size {}", + targets.dims()[0], + batch_size + ); + assert_eq!( + input_lengths_len, batch_size, + "input_lengths length {} must equal batch_size {}", + input_lengths_len, batch_size + ); + assert_eq!( + target_lengths_len, batch_size, + "target_lengths length {} must equal batch_size {}", + target_lengths_len, batch_size + ); + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{TensorData, Tolerance}; + + use super::*; + + // --------------------------------------------------------------- + // Assertions + // --------------------------------------------------------------- + + #[test] + #[should_panic(expected = "blank index")] + fn test_ctc_loss_panics_invalid_blank_index() { + let device = Default::default(); + // blank=5 is out of bounds for num_classes=3 + let ctc = CTCLossConfig::new().with_blank(5).init(); + + let log_probs = Tensor::<3>::zeros([2, 1, 3], &device); + let targets = Tensor::<2, Int>::from_data([[1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2], &device); + let target_lengths = Tensor::<1, Int>::from_data([1], &device); + + ctc.forward(log_probs, targets, input_lengths, target_lengths); + } + + #[test] + #[should_panic(expected = "must equal batch_size")] + fn test_ctc_loss_panics_mismatched_batch_size() { + let device = Default::default(); + let ctc = CTCLossConfig::new().init(); + + // Logits batch size = 2 + let log_probs = Tensor::<3>::zeros([2, 2, 3], &device); + // Targets batch size = 1 (Mismatch) + let targets = Tensor::<2, Int>::from_data([[1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2, 2], &device); + let target_lengths = Tensor::<1, Int>::from_data([1, 1], &device); + + ctc.forward(log_probs, targets, input_lengths, target_lengths); + } + + #[test] + #[should_panic(expected = "input_lengths length")] + fn test_ctc_loss_panics_input_lengths_mismatch() { + let device = Default::default(); + let ctc = CTCLossConfig::new().init(); + + // Logits batch size = 2 + let log_probs = Tensor::<3>::zeros([2, 2, 3], &device); + let targets = Tensor::<2, Int>::from_data([[1], [2]], &device); + + // Input lengths size = 1 (Mismatch) + let input_lengths = Tensor::<1, Int>::from_data([2], &device); + let target_lengths = Tensor::<1, Int>::from_data([1, 1], &device); + + ctc.forward(log_probs, targets, input_lengths, target_lengths); + } + + #[test] + #[should_panic(expected = "target_lengths length")] + fn test_ctc_loss_panics_target_lengths_mismatch() { + let device = Default::default(); + let ctc = CTCLossConfig::new().init(); + + // Logits batch size = 2 + let log_probs = Tensor::<3>::zeros([2, 2, 3], &device); + let targets = Tensor::<2, Int>::from_data([[1], [2]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2, 2], &device); + + // Target lengths size = 1 (Mismatch) + let target_lengths = Tensor::<1, Int>::from_data([1], &device); + + ctc.forward(log_probs, targets, input_lengths, target_lengths); + } + + // --------------------------------------------------------------- + // Edge Case & Config Tests + // --------------------------------------------------------------- + + #[test] + fn test_ctc_loss_repeated_labels_minimum_input_length() { + // T=3, N=1, C=2, blank=0, target=[1, 1], uniform P = 1/2. + // + // The minimum T for target [1, 1] is 3: the only valid path is (1, 0, 1). + // prob = (1/2)^3 = 1/8 + // Loss = -ln(1/8) = 3 * ln(2) + let device = Default::default(); + let ctc = CTCLossConfig::new().init(); + + let log_probs = Tensor::<3>::full([3, 1, 2], 0.5_f32.ln(), &device); + let targets = Tensor::<2, Int>::from_data([[1_i32, 1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([3_i32], &device); + let target_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + loss.into_data().assert_approx_eq::( + &TensorData::from([3.0 * 2.0_f32.ln()]), + Tolerance::rel_abs(1e-3, 1e-3), + ); + } + + #[test] + fn test_ctc_loss_custom_blank_uniform() { + // T=3, N=1, C=3, blank=2, target=[0, 1], uniform P = 1/3. + // + // Two distinct labels, 3 classes, 3 time steps, just with + // blank=2 instead of 0. + // 5 valid paths → total = 5/27 + // Loss = -ln(5/27) + let device = Default::default(); + let ctc = CTCLossConfig::new().with_blank(2).init(); + + let log_probs = Tensor::<3>::full([3, 1, 3], (1.0_f32 / 3.0).ln(), &device); + let targets = Tensor::<2, Int>::from_data([[0_i32, 1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([3_i32], &device); + let target_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + + loss.into_data().assert_approx_eq::( + &TensorData::from([-(5.0_f32 / 27.0).ln()]), + Tolerance::default(), + ); + } + + // --------------------------------------------------------------- + // zero_infinity tests + // --------------------------------------------------------------- + + #[test] + fn test_ctc_loss_zero_infinity_produces_inf_when_disabled() { + // T=2, N=1, C=3, blank=0, target=[1, 1], input_length=2 + // Target [1, 1] requires at least 3 time steps → no valid paths → loss = +inf + let device = Default::default(); + let ctc = CTCLossConfig::new().with_zero_infinity(false).init(); + + let log_probs = Tensor::<3>::full([2, 1, 3], (1.0_f32 / 3.0).ln(), &device); + let targets = Tensor::<2, Int>::from_data([[1_i32, 1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + let target_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + let loss_data = loss.into_data().to_vec::().unwrap(); + assert!( + loss_data[0].is_infinite() && loss_data[0] > 0.0, + "Expected +inf, got {}", + loss_data[0] + ); + } + + #[test] + fn test_ctc_loss_zero_infinity_masks_inf_when_enabled() { + // Same inputs as above, but zero_infinity=true → loss should be 0.0 + let device = Default::default(); + let ctc = CTCLossConfig::new().with_zero_infinity(true).init(); + + let log_probs = Tensor::<3>::full([2, 1, 3], (1.0_f32 / 3.0).ln(), &device); + let targets = Tensor::<2, Int>::from_data([[1_i32, 1]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + let target_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + + loss.into_data() + .assert_approx_eq::(&TensorData::from([0.0]), Tolerance::default()); + } + + #[test] + fn test_ctc_loss_zero_infinity_does_not_affect_finite_loss() { + // Verify that zero_infinity=true does not change a finite loss value. + let device = Default::default(); + let ctc = CTCLossConfig::new().with_zero_infinity(true).init(); + + let log_probs = Tensor::<3>::full([2, 1, 2], 0.5_f32.ln(), &device); + let targets = Tensor::<2, Int>::from_data([[1_i32]], &device); + let input_lengths = Tensor::<1, Int>::from_data([2_i32], &device); + let target_lengths = Tensor::<1, Int>::from_data([1_i32], &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + + loss.into_data() + .assert_approx_eq::(&TensorData::from([-(0.75_f32).ln()]), Tolerance::default()); + } +} + +#[cfg(test)] +mod pytorch_comparison_tests { + use super::*; + use burn::tensor::activation::log_softmax; + use burn_core::tensor::{Device, TensorData, Tolerance}; + + /// Deterministic logits: sin((t*7 + n*13 + c*3) * 0.1). + fn generate_logits(t_size: usize, n_size: usize, c_size: usize, device: &Device) -> Tensor<3> { + let mut data = Vec::with_capacity(t_size * n_size * c_size); + for t in 0..t_size { + for n in 0..n_size { + for c in 0..c_size { + data.push(((t * 7 + n * 13 + c * 3) as f32 * 0.1).sin()); + } + } + } + Tensor::<3>::from_data(TensorData::new(data, [t_size, n_size, c_size]), device) + } + + /// Runs a CTC forward + backward test and asserts against expected values from PyTorch. + /// + /// This helper performs the following steps: + /// 1. Generates deterministic logits using a sine-wave formula. + /// 2. Computes the CTC loss (forward pass). + /// 3. Asserts the computed loss matches `expected_losses`. + /// 4. Backpropagates the sum of the loss. + /// 5. Asserts the resulting gradients w.r.t. logits match `expected_grad_flat`. + /// + /// # Arguments + /// + /// - `expected_losses`: per-sample loss values from PyTorch (reduction='none'). + /// - `expected_grad_flat`: flattened gradient of sum(loss) w.r.t. logits. + #[allow(clippy::too_many_arguments)] + fn run_comparison( + label: &str, + t_size: usize, + n_size: usize, + c_size: usize, + targets_flat: Vec, + target_shape: [usize; 2], + input_lengths: Vec, + target_lengths: Vec, + blank: usize, + expected_losses: &[f32], + expected_grad_flat: &[f32], + ) { + let device = Device::default().autodiff(); + let ctc = CTCLossConfig::new().with_blank(blank).init(); + + let logits = generate_logits(t_size, n_size, c_size, &device).require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + + let targets = + Tensor::<2, Int>::from_data(TensorData::new(targets_flat, target_shape), &device); + let input_lengths = + Tensor::<1, Int>::from_data(TensorData::new(input_lengths, [n_size]), &device); + let target_lengths = + Tensor::<1, Int>::from_data(TensorData::new(target_lengths, [n_size]), &device); + + let loss = ctc.forward(log_probs, targets, input_lengths, target_lengths); + + println!("=== {} ===", label); + println!(" Loss: {loss}"); + + let tolerance = Tolerance::rel_abs(1e-3, 1e-3); + loss.to_data() + .assert_approx_eq::(&TensorData::from(expected_losses), tolerance); + + let loss_sum = loss.sum(); + let grads = loss_sum.backward(); + let logits_grad = logits.grad(&grads).unwrap(); + + logits_grad + .reshape::<1, _>([-1]) + .into_data() + .assert_approx_eq::(&TensorData::from(expected_grad_flat), tolerance); + } + + #[test] + fn test_ctc_loss_uniform_input_lengths() { + // T=5, N=3, C=4, all input_lengths = 5 + // Expected losses and gradient from PyTorch + let expected_losses = [3.5236570835113525_f32, 3.495313882827759, 4.262677192687988]; + let expected_grad_flat = [ + -0.1679008007_f32, + -0.4595540464, + 0.2795598209, + 0.3478950262, + -0.3913056254, + -0.0832268298, + 0.2535884976, + 0.2209439576, + -0.0502742566, + 0.2766197622, + 0.2054125518, + -0.4317580462, + -0.0544800088, + -0.3144550920, + 0.0847885981, + 0.2841464877, + -0.1844545156, + -0.2063435912, + 0.2222184092, + 0.1685796976, + 0.0278018005, + 0.2657383382, + -0.0336986706, + -0.2598414719, + -0.0482986756, + -0.0098767160, + -0.1533526182, + 0.2115280181, + -0.1380317956, + -0.2198686600, + 0.2042596638, + 0.1536407918, + 0.0534787849, + 0.1819230020, + -0.2805589139, + 0.0451571345, + -0.0895631388, + 0.1996460557, + -0.2741115987, + 0.1640286744, + -0.2200077325, + -0.1693530381, + 0.2101601064, + 0.1792006642, + 0.0398471877, + -0.1131042913, + -0.2363226712, + 0.3095797896, + -0.2163617164, + 0.2740726173, + -0.2124865055, + 0.1547756046, + -0.4312027395, + -0.0446923785, + 0.2330704331, + 0.2428246588, + -0.0050083841, + -0.6256869435, + 0.2689785957, + 0.3617166877, + ]; + run_comparison( + "T=5, N=3, C=4 (uniform input lengths)", + 5, + 3, + 4, + vec![1, 2, 0, 1, 0, 0, 3, 2, 1], + [3, 3], + vec![5, 5, 5], + vec![2, 1, 3], + 0, + &expected_losses, + &expected_grad_flat, + ); + } + + #[test] + fn test_ctc_loss_repeated_labels() { + // T=8, N=4, C=6, includes consecutive repeated label [1,1,2] + // Expected losses and gradient from PyTorch + let expected_losses = [ + 8.84203052520752_f32, + 9.023029327392578, + 9.398024559020996, + 9.008068084716797, + ]; + let expected_grad_flat = [ + -0.2766432464, + -0.5202965736, + 0.1523768753, + 0.1896236390, + 0.2200277001, + 0.2349116206, + -0.1854365915, + 0.2031330466, + -0.4260218740, + 0.1678018719, + 0.1360142529, + 0.1045092493, + -0.6603536606, + 0.2278252542, + 0.1691786796, + 0.1262856424, + 0.0972681716, + 0.0397959016, + -0.0894432291, + -0.5457318425, + 0.1490373611, + 0.1462858170, + 0.1569476575, + 0.1829041988, + -0.2842915654, + -0.4220107496, + 0.1822281033, + 0.1889107376, + 0.1791101843, + 0.1560532600, + -0.1155678406, + 0.2295538932, + -0.2645366490, + -0.0288553704, + 0.1027252972, + 0.0766806602, + -0.5448347330, + 0.2031028718, + 0.1589304954, + 0.1322451383, + 0.1189499870, + -0.0683937520, + -0.0873993114, + -0.3051757514, + -0.2355299890, + 0.1586059481, + 0.2018169016, + 0.2676822543, + -0.3225219846, + -0.2611543834, + 0.1922984123, + 0.1632783115, + 0.1297036558, + 0.0983960181, + -0.1507159024, + 0.2256962359, + -0.1040333956, + -0.1514528394, + 0.0985243544, + 0.0819815546, + -0.2940836251, + 0.1586865336, + 0.1468491107, + 0.1485087872, + 0.1639631987, + -0.3239239752, + -0.0767390430, + -0.0434846729, + -0.4023587406, + -0.0052628326, + 0.2273432612, + 0.3005020022, + -0.2598774135, + -0.2188862711, + 0.1678501070, + 0.1352078766, + 0.1002781317, + 0.0754275694, + -0.1502914876, + 0.1930875033, + -0.0709601715, + -0.2219523191, + 0.1243555173, + 0.1257609427, + -0.0574148744, + 0.1152269915, + 0.1307857931, + 0.1599020809, + 0.2068412602, + -0.5553412437, + -0.0536844917, + 0.0758557543, + -0.2106334567, + -0.2509877980, + 0.1757438034, + 0.2637061775, + -0.1759711355, + -0.2431350052, + 0.1071053818, + 0.1259848624, + 0.1004033238, + 0.0856125653, + -0.1173698306, + 0.1213828772, + -0.1768893301, + -0.2070008069, + 0.1709136516, + 0.2089634240, + 0.0153109450, + 0.0967332721, + 0.1268781722, + 0.1706230640, + 0.2291058898, + -0.6386513710, + -0.0536664203, + 0.1378114969, + 0.0360041447, + -0.2989685237, + -0.0084722806, + 0.1872915775, + -0.1523490399, + -0.2111770809, + -0.0390694551, + 0.1366800815, + 0.1302325875, + 0.1356829405, + -0.0982905105, + -0.0127884001, + -0.3586881459, + -0.0259541404, + 0.2114149332, + 0.2843062580, + -0.0324133746, + 0.1084750593, + 0.1447229236, + 0.1862253845, + 0.2259712219, + -0.6329812407, + -0.1173689738, + 0.1914442331, + 0.1654772907, + -0.1376858056, + -0.2194855511, + 0.1176188141, + -0.1529908478, + -0.0606661662, + -0.3384291232, + 0.1524862647, + 0.1777049750, + 0.2218948901, + -0.0923086405, + -0.2855934799, + -0.3215619624, + 0.1726681292, + 0.2303666323, + 0.2964293361, + -0.2508065701, + 0.1479703039, + 0.1753441393, + 0.1917535067, + 0.1919818372, + -0.4562432170, + -0.2350299209, + 0.2257601619, + 0.1863904297, + 0.0388212129, + -0.2966264784, + 0.0806845874, + -0.1992894858, + 0.1068909168, + -0.5761897564, + 0.1624972969, + 0.2155302167, + 0.2905607820, + -0.1168124676, + -0.6870660186, + 0.1488010883, + 0.1881926507, + 0.2230074406, + 0.2438773215, + -0.5771554708, + 0.1980127096, + 0.1924194694, + 0.1714663208, + 0.1415647417, + -0.1263078004, + -0.3408652246, + 0.2292248607, + 0.1707807332, + 0.1269564927, + -0.2634142637, + 0.0773174241, + ]; + run_comparison( + "T=8, N=4, C=6 (repeated labels)", + 8, + 4, + 6, + vec![1, 1, 2, 0, 2, 3, 2, 1, 5, 0, 0, 0, 1, 2, 3, 4], + [4, 4], + vec![8, 8, 8, 8], + vec![3, 4, 1, 4], + 0, + &expected_losses, + &expected_grad_flat, + ); + } + + #[test] + fn test_ctc_loss_long_sequence() { + // T=10, N=2, C=8 + // Expected losses and gradient from PyTorch + let expected_losses = [12.629399299621582, 12.298524856567383]; + let expected_grad_flat = [ + -0.2570972741, + -0.6013792753, + 0.1061997041, + 0.1321590245, + 0.1533492655, + 0.1637226790, + 0.1598964781, + 0.1431493312, + -0.2540431321, + 0.1788398325, + -0.4038805366, + 0.1477340311, + 0.1197479516, + 0.0920107216, + 0.0686140805, + 0.0509770736, + -0.1364373565, + -0.3724762201, + 0.1489177048, + -0.0966964588, + 0.1463697106, + 0.1275274903, + 0.1033692732, + 0.0794258416, + -0.1771971881, + 0.2073454857, + -0.3109439015, + 0.1249521226, + -0.0101635465, + 0.0692621097, + 0.0533472970, + 0.0433975980, + -0.1398337185, + -0.0874802172, + 0.1705365479, + -0.2174201906, + 0.1150254831, + 0.0460043959, + 0.0647982135, + 0.0483694859, + -0.2332949787, + 0.1969220787, + -0.1270586401, + 0.1098557115, + -0.1364655048, + 0.0715296715, + 0.0553609394, + 0.0631506816, + -0.2169117928, + 0.0929956511, + 0.1624538749, + -0.2009791434, + 0.0904926360, + -0.0248185843, + 0.0532633252, + 0.0435040221, + -0.2313277274, + 0.1497355998, + -0.0024202778, + 0.1029939279, + -0.2776987851, + 0.0963881761, + 0.0351882279, + 0.1271408647, + -0.2590557337, + 0.1577988416, + 0.1429322213, + -0.1401246637, + 0.0866033062, + -0.1151762009, + 0.0683368817, + 0.0586853735, + -0.1322475076, + 0.0806737095, + 0.0528722852, + 0.0920089707, + -0.3037962914, + 0.1280544847, + -0.1391123086, + 0.2215466499, + -0.1918463260, + 0.1376975775, + 0.1160097718, + -0.0549413785, + 0.0970225409, + -0.2708687484, + 0.1147320047, + 0.0521945432, + -0.0504456684, + -0.0012221609, + 0.0644332916, + 0.0818370953, + -0.1036835983, + 0.1512031406, + -0.4072600305, + 0.2651379406, + -0.0681083873, + 0.0860663429, + 0.0810486302, + 0.0434282124, + 0.1056238264, + -0.2994530201, + 0.1729898751, + -0.1215954795, + -0.0481944978, + -0.1697723418, + 0.0725984722, + 0.0692019314, + 0.0859903544, + 0.1680216491, + -0.4071443677, + 0.2292988002, + -0.0205532499, + 0.0566616580, + 0.0326749459, + 0.0861379728, + 0.1142501161, + -0.0448331088, + 0.2054910213, + -0.4298293889, + -0.0647637174, + -0.4240962267, + 0.1013666242, + -0.0110451467, + 0.1519176364, + 0.1661346704, + -0.0719586164, + 0.1524447650, + -0.0496110357, + 0.0562372655, + -0.1889088154, + 0.1013496071, + 0.1339637935, + 0.1694275290, + 0.2007708699, + -0.4232292175, + -0.0401752405, + -0.2951072752, + 0.1443216652, + -0.2857291698, + 0.1489982456, + 0.1327733696, + 0.1096193567, + 0.0852990299, + -0.0413062274, + 0.0820900649, + -0.7903561592, + 0.1329460591, + 0.1535883099, + 0.1631743014, + 0.1585651338, + 0.1412984729, + -0.1033771932, + 0.1799504310, + 0.1697744429, + -0.5749052763, + 0.1189445183, + 0.0911802500, + 0.0679325759, + 0.0505003072, + ]; + run_comparison( + "T=10, N=2, C=8", + 10, + 2, + 8, + vec![1, 3, 5, 7, 2, 2, 4, 6, 1, 3], + [2, 5], + vec![10, 10], + vec![5, 5], + 0, + &expected_losses, + &expected_grad_flat, + ); + } + + #[test] + fn test_ctc_loss_mixed_input_lengths() { + // T=12, N=3, C=5, input_lengths=[12, 7, 10] + // Expected losses and gradient from PyTorch + let expected_losses = [10.595505714416504, 6.8078508377075195, 7.705057144165039]; + let expected_grad_flat = [ + -0.4790987670, + -0.2554937005, + 0.1991624236, + 0.2478453964, + 0.2875846624, + -0.3495813310, + 0.2268397957, + 0.2150714993, + -0.2442178279, + 0.1518878639, + -0.2764556706, + 0.2474014312, + -0.2137086987, + 0.1371368915, + 0.1056260392, + -0.2729502618, + -0.3609606028, + 0.2159237266, + 0.2238420397, + 0.1941450834, + -0.2953839302, + 0.1920599341, + 0.1974952668, + -0.2054278404, + 0.1112565696, + -0.1719199270, + 0.2299505472, + -0.2864859998, + 0.1497263014, + 0.0787290633, + -0.2035763413, + -0.3042884767, + 0.2126964629, + 0.1810975969, + 0.1140707731, + -0.2759391963, + 0.0975771844, + 0.1823379993, + -0.1112988219, + 0.1073228419, + -0.1336459517, + 0.1869296581, + -0.1996247321, + 0.1846873760, + -0.0383463502, + -0.2254105806, + -0.1834360659, + 0.1925925612, + 0.1462381780, + 0.0700158924, + -0.2259973884, + -0.0393539183, + 0.1802661419, + -0.0571591072, + 0.1422442794, + -0.0609069727, + 0.1089282706, + -0.0313654318, + 0.2186669111, + -0.2353227735, + -0.2840364873, + -0.0632198900, + 0.1755636632, + 0.1377806067, + 0.0339120962, + -0.1904856712, + -0.2139032930, + 0.1827126741, + 0.0056131603, + 0.2160631120, + -0.0243270602, + -0.0070458520, + 0.1070247591, + 0.2239368409, + -0.2995886803, + -0.2955487072, + 0.0309870224, + 0.1654911339, + 0.1581364125, + -0.0590658709, + -0.2191396207, + -0.3791662455, + 0.1803640425, + 0.1225430891, + 0.2953987718, + -0.0436352938, + -0.1575258970, + 0.1785279512, + 0.1756918877, + -0.1530586481, + -0.1834939867, + 0.0909025446, + 0.1423641294, + 0.1959712654, + -0.2457439601, + -0.3619639874, + -0.3929221630, + 0.1820438206, + 0.2454170734, + 0.3274252713, + -0.0628800318, + -0.2567180395, + 0.2112283260, + 0.0507859327, + 0.0575838275, + -0.0587697029, + 0.1174769849, + 0.0783569664, + 0.2290501744, + -0.3661144078, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + -0.0725664943, + -0.1532069892, + 0.2162397504, + -0.1248963475, + 0.1344300956, + -0.0362483934, + 0.1295878887, + -0.0502482466, + 0.2470482886, + -0.2901395261, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + -0.1349253207, + 0.0867646411, + 0.1998746395, + -0.2658679783, + 0.1141540110, + -0.0705668628, + 0.1519546807, + -0.2509805560, + 0.2475892603, + -0.0779965296, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + -0.2338010073, + 0.2471641302, + 0.1834627241, + -0.3026831448, + 0.1058573127, + -0.1155209392, + 0.1921830922, + -0.4129956067, + 0.2229512781, + 0.1133821756, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + -0.2636392713, + 0.2323469073, + -0.2913427949, + 0.1800564528, + 0.1425786912, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + 0.0000000000, + ]; + run_comparison( + "T=12, N=3, C=5 (mixed input lengths)", + 12, + 3, + 5, + vec![1, 4, 2, 0, 3, 1, 0, 0, 2, 4, 1, 3], + [3, 4], + vec![12, 7, 10], + vec![3, 2, 4], + 0, + &expected_losses, + &expected_grad_flat, + ); + } + + #[test] + fn test_ctc_loss_sum_reduction() { + // Same inputs as comparison_uniform_input_lengths, sum reduction + let device = Device::default().autodiff(); + let ctc = CTCLossConfig::new().init(); + + let logits = generate_logits(5, 3, 4, &device).require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + let targets = Tensor::<2, Int>::from_data( + TensorData::new(vec![1_i32, 2, 0, 1, 0, 0, 3, 2, 1], [3, 3]), + &device, + ); + let il = Tensor::<1, Int>::from_data([5_i32, 5, 5], &device); + let tl = Tensor::<1, Int>::from_data([2_i32, 1, 3], &device); + + let loss = ctc.forward_with_reduction(log_probs, targets, il, tl, Reduction::Sum); + + let expected_sum = 11.2816486359_f32; // Expected value from PyTorch + loss.to_data() + .assert_approx_eq::(&TensorData::from([expected_sum]), Tolerance::default()); + + let grads = loss.backward(); + let logits_grad = logits.grad(&grads).unwrap(); + // Expected gradient from PyTorch + let expected_grad = [ + -0.1679008007_f32, + -0.4595540464, + 0.2795598209, + 0.3478950262, + -0.3913056254, + -0.0832268298, + 0.2535884976, + 0.2209439576, + -0.0502742566, + 0.2766197622, + 0.2054125518, + -0.4317580462, + -0.0544800088, + -0.3144550920, + 0.0847885981, + 0.2841464877, + -0.1844545156, + -0.2063435912, + 0.2222184092, + 0.1685796976, + 0.0278018005, + 0.2657383382, + -0.0336986706, + -0.2598414719, + -0.0482986756, + -0.0098767160, + -0.1533526182, + 0.2115280181, + -0.1380317956, + -0.2198686600, + 0.2042596638, + 0.1536407918, + 0.0534787849, + 0.1819230020, + -0.2805589139, + 0.0451571345, + -0.0895631388, + 0.1996460557, + -0.2741115987, + 0.1640286744, + -0.2200077325, + -0.1693530381, + 0.2101601064, + 0.1792006642, + 0.0398471877, + -0.1131042913, + -0.2363226712, + 0.3095797896, + -0.2163617164, + 0.2740726173, + -0.2124865055, + 0.1547756046, + -0.4312027395, + -0.0446923785, + 0.2330704331, + 0.2428246588, + -0.0050083841, + -0.6256869435, + 0.2689785957, + 0.3617166877, + ]; + logits_grad + .reshape::<1, _>([-1]) + .into_data() + .assert_approx_eq::(&TensorData::from(expected_grad), Tolerance::default()); + } + + #[test] + fn test_ctc_loss_mean_reduction() { + let device = Device::default().autodiff(); + let ctc = CTCLossConfig::new().init(); + + let logits = generate_logits(5, 3, 4, &device).require_grad(); + let log_probs = log_softmax(logits.clone(), 2); + let targets = Tensor::<2, Int>::from_data( + TensorData::new(vec![1_i32, 2, 0, 1, 0, 0, 3, 2, 1], [3, 3]), + &device, + ); + let il = Tensor::<1, Int>::from_data([5_i32, 5, 5], &device); + let tl = Tensor::<1, Int>::from_data([2_i32, 1, 3], &device); + + println!( + "{:?} {:?} {:?} {:?}", + log_probs.device(), + targets.device(), + il.device(), + tl.device() + ); + let loss = ctc.forward_with_reduction(log_probs, targets, il, tl, Reduction::Mean); + + let expected_mean = 2.2260115147_f32; // Expected value from PyTorch + loss.to_data() + .assert_approx_eq::(&TensorData::from([expected_mean]), Tolerance::default()); + + let grads = loss.backward(); + let logits_grad = logits.grad(&grads).unwrap(); + // Expected gradient from PyTorch + let expected_grad = [ + -0.0279834662_f32, + -0.0765923411, + 0.0465933047, + 0.0579825081, + -0.1304352134, + -0.0277422778, + 0.0845294967, + 0.0736479908, + -0.0055860290, + 0.0307355281, + 0.0228236169, + -0.0479731150, + -0.0090800021, + -0.0524091832, + 0.0141314333, + 0.0473577492, + -0.0614848398, + -0.0687812045, + 0.0740728080, + 0.0561932363, + 0.0030890885, + 0.0295264814, + -0.0037442972, + -0.0288712755, + -0.0080497796, + -0.0016461194, + -0.0255587716, + 0.0352546684, + -0.0460105985, + -0.0732895583, + 0.0680865571, + 0.0512135960, + 0.0059420872, + 0.0202136654, + -0.0311732125, + 0.0050174589, + -0.0149271907, + 0.0332743451, + -0.0456852652, + 0.0273381118, + -0.0733359158, + -0.0564510152, + 0.0700533763, + 0.0597335547, + 0.0044274656, + -0.0125671430, + -0.0262580756, + 0.0343977548, + -0.0360602848, + 0.0456787720, + -0.0354144201, + 0.0257959347, + -0.1437342465, + -0.0148974592, + 0.0776901469, + 0.0809415579, + -0.0005564869, + -0.0695207715, + 0.0298865121, + 0.0401907414, + ]; + logits_grad + .reshape::<1, _>([-1]) + .into_data() + .assert_approx_eq::(&TensorData::from(expected_grad), Tolerance::default()); + } +} diff --git a/crates/burn-nn/src/loss/huber.rs b/crates/burn-nn/src/loss/huber.rs new file mode 100644 index 0000000..f4fc5f5 --- /dev/null +++ b/crates/burn-nn/src/loss/huber.rs @@ -0,0 +1,208 @@ +use burn_core as burn; + +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::{config::Config, module::Module}; + +use super::Reduction; + +/// Configuration to create a [Huber loss](HuberLoss). +#[derive(Config, Debug)] +pub struct HuberLossConfig { + /// The bound where the Huber loss function changes from quadratic to linear behaviour. + pub delta: f32, +} + +impl HuberLossConfig { + /// Initialize [Huber loss](HuberLoss). + pub fn init(&self) -> HuberLoss { + self.assertions(); + HuberLoss { + delta: self.delta, + lin_bias: self.delta * self.delta * 0.5, + } + } + + fn assertions(&self) { + assert!( + self.delta >= 0., // This also tests for normality + "Delta for Huber loss must be a non-negative number." + ); + } +} + +/// Calculate the Huber loss between the inputs and the target. +/// +/// The loss for each element of the residuals `r = targets - predictions` is given by +/// +/// ```text +/// L(r) = 0.5 * r^2 if |r| <= d +/// L(r) = 0.5 * d^2 + d * (|r| - d) if |r| > d +/// ``` +/// +/// where `d` is the configured `delta`. In particular, this is equal to the +/// [L2 Loss](super::MseLoss) for residuals with magnitude smaller than `delta`, +/// but behaves linearly instead of quadratically for large residuals. +/// +/// This loss function is less sensitive to outliers than the mean squared error loss. +/// +/// See also: +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct HuberLoss { + /// The bound where the Huber loss function changes from quadratic to linear behaviour. + pub delta: f32, + /// Precomputed value for the linear bias. + pub lin_bias: f32, // delta * delta * 0.5 precomputed +} + +impl ModuleDisplay for HuberLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("delta", &self.delta) + .add("lin_bias", &self.lin_bias) + .optional() + } +} + +impl HuberLoss { + /// Compute the loss element-wise for the predictions and targets, then reduce + /// to a single loss value. + /// + /// `Reduction::Auto` behaves as `Reduction::Mean`. + /// + /// # Shapes + /// + /// - predictions: \[...dims\] + /// - targets: \[...dims\] + /// - output: \[1\] + pub fn forward( + &self, + predictions: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let loss = self.forward_no_reduction(predictions, targets); + match reduction { + Reduction::Mean | Reduction::Auto => loss.mean(), + Reduction::Sum => loss.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + /// Compute the loss element-wise for the predictions and targets. + /// + /// # Shapes + /// + /// - predictions: [...dims] + /// - targets: [...dims] + /// - output: [...dims] + pub fn forward_no_reduction( + &self, + predictions: Tensor, + targets: Tensor, + ) -> Tensor { + let residuals = targets - predictions; + self.forward_residuals(residuals) + } + /// Compute the loss element-wise for the given residuals. + /// + /// # Shapes + /// + /// - residuals: [...dims] + /// - output: [...dims] + pub fn forward_residuals(&self, residuals: Tensor) -> Tensor { + let is_large = residuals.clone().abs().greater_elem(self.delta); + // We are interested in `sign(r)` when `abs(r) > self.delta`. Note that the + // `sign()` function, in general, suffers from a jump at 0. + // Instead the following tensor implements `delta * sign(r)` for values outside + // the bound: + let softsign = residuals.clone().clamp(-self.delta, self.delta); + + // 0.5 * d^2 + d * (|r| - d) = + // d * |r| - 0.5 * d^2 + // Moreover |r| = sign(r) * r + let outside = softsign.mul(residuals.clone()).sub_scalar(self.lin_bias); + + let inside = residuals.square().mul_scalar(0.5); + inside.mask_where(is_large, outside) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_huber_loss() { + let predict = TensorData::from([-2., -0.5, 0., 0.3, 1.]); + let targets = TensorData::from([0., 0., 0., 0., 0.]); + + let device = Default::default(); + + let predict = Tensor::<1>::from_data(predict, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let huber = HuberLossConfig::new(0.5).init(); + + let loss_sum = huber.forward(predict.clone(), targets.clone(), Reduction::Sum); + let loss = huber.forward(predict.clone(), targets.clone(), Reduction::Auto); + let loss_no_reduction = huber.forward_no_reduction(predict, targets); + + let expected = TensorData::from([0.875, 0.125, 0., 0.045, 0.375]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([0.284]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([1.42]); + loss_sum + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[cfg(feature = "std")] + #[test] + fn test_huber_ad_loss() { + use burn::tensor::Device; + let device = Device::default().autodiff(); + let predict = TensorData::from([-2., -0.5, 0., 0.3, 1.]); + let targets = TensorData::from([0., 0., 0., 0., 0.]); + + let predict = Tensor::<1>::from_data(predict, &device).require_grad(); + let targets = Tensor::<1>::from_data(targets, &device); + + let loss = HuberLossConfig::new(0.5).init(); + let loss = loss.forward_no_reduction(predict.clone(), targets); + + let grads = loss.backward(); + let grads_predict = predict.grad(&grads).unwrap(); + + let expected = TensorData::from([-0.5, -0.5, 0., 0.3, 0.5]); + grads_predict + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = HuberLossConfig::new(0.5); + let loss = config.init(); + + assert_eq!( + alloc::format!("{loss}"), + "HuberLoss {delta: 0.5, lin_bias: 0.125}" + ); + } +} diff --git a/crates/burn-nn/src/loss/kldiv.rs b/crates/burn-nn/src/loss/kldiv.rs new file mode 100644 index 0000000..324be47 --- /dev/null +++ b/crates/burn-nn/src/loss/kldiv.rs @@ -0,0 +1,200 @@ +use burn_core as burn; + +use super::Reduction; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::{config::Config, module::Module}; + +/// Configuration to create a [KLDiv loss](KLDivLoss). +#[derive(Config, Debug)] +pub struct KLDivLossConfig { + /// Specifies whether target is the log space. Default: False. + #[config(default = false)] + pub log_target: bool, +} + +impl KLDivLossConfig { + /// Initialize [KLDiv Loss](KLDivLoss). + pub fn init(&self) -> KLDivLoss { + KLDivLoss { + log_target: self.log_target, + } + } +} + +/// Kullback-Leibler Divergence Loss +/// +/// KL Divergence shows the difference between two probability distributions by measuring information loss +/// +/// KLDivLoss = +/// ```tex +/// y_{true} \cdot (\log{y_{true}} - \log{y_{pred}}) +/// ``` +/// By default, the loss expects the input in the log-space. +/// The targets may also be provided in the log-space if `log_target` is true. +/// +/// See +/// - [Kullback–Leibler divergence](https://en.wikipedia.org/wiki/Kullback-Leibler_divergence) +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct KLDivLoss { + /// Specifies whether target is the log space. Default: False. + pub log_target: bool, +} + +impl ModuleDisplay for KLDivLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("log_target", &self.log_target).optional() + } +} + +impl KLDivLoss { + /// Compute the criterion on the input tensor. + /// + /// `Reduction::Auto` behaves as `Reduction::BatchMean`,`Reduction::Mean` dose not align with the math definition. + /// + /// # Shapes + /// + /// - predictions: \[batch_size,num_targets\] + /// - targets: \[batch_size,num_targets\] + /// - output: \[1\] + pub fn forward( + &self, + predictions: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let loss = self.forward_no_reduction(predictions, targets); + match reduction { + Reduction::BatchMean | Reduction::Auto => { + let batch_size = loss.dims()[0] as f32; + loss.sum().div_scalar(batch_size) + } + Reduction::Mean => loss.mean(), + Reduction::Sum => loss.sum(), + } + } + /// Compute the criterion on the input tensor without reducing. + pub fn forward_no_reduction( + &self, + predictions: Tensor, + targets: Tensor, + ) -> Tensor { + match self.log_target { + true => targets.clone().exp().mul(targets.sub(predictions)), + false => { + let epsilon = targets + .dtype() + .finfo() + .unwrap_or(burn::tensor::FloatDType::F32.finfo()) + .min_positive; + let log_target = targets.clone().clamp(epsilon, 1.0).log(); + targets.mul(log_target.sub(predictions)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_kl_div_loss() { + let predict = TensorData::from([[-1.0, -0.5], [-2.0, -0.2]]); + let targets = TensorData::from([[0.4, 0.6], [0.1, 0.9]]); + + let device = Default::default(); + let predict = Tensor::<2>::from_data(predict, &device); + let targets = Tensor::<2>::from_data(targets, &device); + + let kl_loss = KLDivLossConfig { log_target: false }.init(); + + let loss_sum = kl_loss.forward(predict.clone(), targets.clone(), Reduction::Sum); + let loss_batch_mean = + kl_loss.forward(predict.clone(), targets.clone(), Reduction::BatchMean); + let loss_no_reduction = kl_loss.forward_no_reduction(predict, targets); + + let expected_no_reduction = + TensorData::from([[0.0334837139, -0.0064953566], [-0.0302585065, 0.0851755068]]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected_no_reduction, Tolerance::absolute(1e-5)); + + let expected_sum = TensorData::from([0.08191]); + loss_sum + .into_data() + .assert_approx_eq::(&expected_sum, Tolerance::absolute(1e-5)); + + let expected_batch_mean = TensorData::from([0.04095]); + loss_batch_mean + .into_data() + .assert_approx_eq::(&expected_batch_mean, Tolerance::absolute(1e-5)); + } + + #[test] + fn test_kl_div_loss_log_target() { + let device = Default::default(); + let predict = Tensor::<1>::from_data([-1.0, -2.0], &device); + let targets = Tensor::<1>::from_data([-0.5, -1.5], &device); + + let kl_loss = KLDivLossConfig { log_target: true }.init(); + + let loss_no_reduction = kl_loss.forward_no_reduction(predict.clone(), targets.clone()); + let expected_none = TensorData::from([0.3032653299, 0.1115650801]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected_none, Tolerance::absolute(1e-5)); + + let loss_batch_mean = + kl_loss.forward(predict.clone(), targets.clone(), Reduction::BatchMean); + let expected_bm = TensorData::from([0.207415204965]); + loss_batch_mean + .into_data() + .assert_approx_eq::(&expected_bm, Tolerance::absolute(1e-5)); + + let loss_sum = kl_loss.forward(predict, targets, Reduction::Sum); + let expected_sum = TensorData::from([0.414830409931]); + loss_sum + .into_data() + .assert_approx_eq::(&expected_sum, Tolerance::absolute(1e-5)); + } + + #[cfg(feature = "std")] + #[test] + fn test_kl_div_ad_loss() { + use burn::tensor::Device; + let device = Device::default().autodiff(); + let predict = Tensor::<2>::from_data([[-1.0, -0.5]], &device).require_grad(); + let targets = Tensor::<2>::from_data([[0.4, 0.6]], &device); + + let kl_loss = KLDivLossConfig { log_target: false }.init(); + let loss = kl_loss.forward(predict.clone(), targets, Reduction::Sum); + + let grads = loss.backward(); + let grads_predict = predict.grad(&grads).unwrap(); + + // d/d_pred [target * (log_target - pred)] = -target + let expected = TensorData::from([[-0.4, -0.6]]); + grads_predict + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = KLDivLossConfig { log_target: true }; + let loss = config.init(); + + assert_eq!(alloc::format!("{loss}"), "KLDivLoss {log_target: true}"); + } +} diff --git a/crates/burn-nn/src/loss/lp_loss.rs b/crates/burn-nn/src/loss/lp_loss.rs new file mode 100644 index 0000000..b03357c --- /dev/null +++ b/crates/burn-nn/src/loss/lp_loss.rs @@ -0,0 +1,636 @@ +use super::Reduction; +use burn::config::Config; +use burn::module::Module; +use burn::tensor::Tensor; +use burn_core as burn; + +/// Configuration for the [Lp Loss](LpLoss) module. +/// +/// # Example +/// +/// ```ignore +/// use burn_nn::loss::{LpLossConfig, Reduction}; +/// +/// // Create L1 loss (MAE when using mean reduction) +/// let l1_loss = LpLossConfig::l1(); +/// +/// // Create L2 loss (MSE when using mean reduction) +/// let l2_loss = LpLossConfig::l2(); +/// +/// // Create custom Lp loss with p=3 +/// let l3_loss = LpLossConfig::new(3.0).init(); +/// ``` +#[derive(Config, Debug)] +pub struct LpLossConfig { + /// The exponent `p` determining the type of error measurement. + /// + /// Common values: + /// - `p = 1.0`: L1 loss (MAE with mean reduction) - robust to outliers + /// - `p = 2.0`: L2 loss (MSE with mean reduction) - standard choice, differentiable everywhere + /// - `p > 2.0`: Increasingly sensitive to large errors (outliers) + /// - `0 < p < 1`: More robust to outliers than L1 (quasi-norm) + pub p: f64, +} + +impl LpLossConfig { + /// Initializes a [Lp Loss](LpLoss) module. + /// + /// # Panics + /// + /// Panics if `p <= 0`. + pub fn init(&self) -> LpLoss { + self.assertions(); + LpLoss { p: self.p } + } + + /// Creates L1 loss (p=1). + /// + /// When used with `Reduction::Mean`, this computes Mean Absolute Error (MAE). + /// When used with `Reduction::Sum`, this computes Sum of Absolute Errors (SAE). + pub fn l1() -> LpLoss { + LpLoss { p: 1.0 } + } + + /// Creates L2 loss (p=2). + /// + /// When used with `Reduction::Mean`, this computes Mean Squared Error (MSE). + /// When used with `Reduction::Sum`, this computes Sum of Squared Errors (SSE). + pub fn l2() -> LpLoss { + LpLoss { p: 2.0 } + } + + fn assertions(&self) { + assert!(self.p > 0.0, "The order of the norm p must be positive.") + } +} + +/// Computes the Lp Loss between predictions and targets. +/// +/// This loss function computes the element-wise p-th power of absolute errors, +/// then reduces them via mean or sum. +/// +/// # Mathematical Definition +/// +/// For predictions `ŷ` and targets `y`, the element-wise loss is: +/// +/// ```text +/// Lᵢ = |ŷᵢ - yᵢ|ᵖ +/// ``` +/// +/// With mean reduction (default), the final loss is: +/// +/// ```text +/// L = (1/n) × Σᵢ |ŷᵢ - yᵢ|ᵖ +/// ``` +/// +/// # Notes +/// +/// - This implementation computes `|error|^p`, **not** the Lp norm `(Σ|error|^p)^(1/p)`. +/// - The `p = 1` case uses an optimized `abs()` operation. +/// - The `p = 2` case uses an optimized computation `error * error` instead of `powf`. +/// +/// # Example +/// +/// ```ignore +/// use burn_nn::loss::{LpLossConfig, Reduction}; +/// use burn::tensor::Tensor; +/// +/// // Create L2 loss +/// let l2_loss = LpLossConfig::l2(); +/// +/// let predictions: Tensor = /* model output */; +/// let targets: Tensor = /* ground truth */; +/// +/// // Compute loss with mean reduction (MSE) +/// let mse = l2_loss.forward(predictions.clone(), targets.clone(), Reduction::Mean); +/// +/// // Compute loss with sum reduction (SSE) +/// let sse = l2_loss.forward(predictions.clone(), targets.clone(), Reduction::Sum); +/// +/// // Compute loss with no reduction +/// let unreduced_l2_loss = l2_loss.forward_no_reduction(predictions, targets); +/// ``` +#[derive(Module, Debug)] +pub struct LpLoss { + /// The order of the norm (e.g., 1 for L1, 2 for L2). + /// Equivalently, the exponent `p` for computing `|error|^p`. + pub p: f64, +} + +impl LpLoss { + /// Computes the element-wise loss `|error|^p` with reduction. + /// + /// # Arguments + /// + /// * `predictions` - The model's predicted values. + /// * `targets` - The ground truth target values. + /// * `reduction` - Specifies how to reduce the element-wise losses: + /// - `Reduction::Mean` or `Reduction::Auto`: Returns the mean of all element-wise losses. + /// - `Reduction::Sum`: Returns the sum of all element-wise losses. + /// + /// # Returns + /// + /// A scalar tensor containing the reduced loss value. + /// + /// # Shapes + /// + /// - predictions: `[...dims]` - Any shape + /// - targets: `[...dims]` - Must match predictions shape + /// - output: `[1]` - Scalar loss value + pub fn forward( + &self, + predictions: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let unreduced_loss = self.forward_no_reduction(predictions, targets); + + match reduction { + Reduction::Mean | Reduction::Auto => unreduced_loss.mean(), + Reduction::Sum => unreduced_loss.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Computes the element-wise loss `|error|^p` without reduction. + /// + /// # Arguments + /// + /// * `predictions` - The model's predicted values. + /// * `targets` - The ground truth target values. + /// + /// # Returns + /// + /// A tensor of the same shape as the inputs, containing `|prediction - target|^p` + /// for each element. + /// + /// # Shapes + /// + /// - predictions: `[...dims]` - Any shape + /// - targets: `[...dims]` - Must match predictions shape + /// - output: `[...dims]` - Same shape as inputs + pub fn forward_no_reduction( + &self, + predictions: Tensor, + targets: Tensor, + ) -> Tensor { + let error = predictions.sub(targets); + + // Use simplified/optimized expressions for common cases (p = 1, p = 2) + if self.p == 1.0 { + // L1 loss + error.abs() + } else if self.p == 2.0 { + // L2 loss + error.clone().mul(error) + } else { + error.abs().powf_scalar(self.p) + } + } + + /// Computes the element-wise loss `|error|^p` with reduction over specified dimensions. + /// + /// Calculates element-wise `|predictions - targets|^p`, then takes the mean + /// over the specified dimensions. Useful for per-sample or per-channel losses (e.g., when + /// working with images). + /// + /// Dimensions can be provided in any order. They are sorted internally and + /// reduced from highest to lowest to ensure indices remain valid. + /// + /// # Arguments + /// + /// * `predictions` - The model's predicted values. + /// * `targets` - The ground truth target values. + /// * `dims` - Dimensions to reduce over. + /// + /// # Returns + /// + /// A tensor with the specified dimensions reduced to size 1. + /// + /// # Example + /// + /// ```ignore + /// // Image tensor: [batch, C, H, W] + /// let l2_loss = LpLossConfig::l2(); + /// + /// // Per-image MSE for PSNR: reduce over C, H, W → [batch, 1, 1, 1] + /// let mse_per_image = l2_loss.forward_reduce_dims(predictions, targets, &[1, 2, 3]); + /// ``` + pub fn forward_reduce_dims( + &self, + predictions: Tensor, + targets: Tensor, + dims: &[usize], + ) -> Tensor { + let error = self.forward_no_reduction(predictions, targets); + + // Sort the dimensions to ascending order + let mut sorted_dims = dims.to_vec(); + sorted_dims.sort(); + + // Reduce over specified dimensions + error.mean_dims(sorted_dims.as_slice()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_lp_loss_l1_constructor() { + let loss_func_l1 = LpLossConfig::l1(); + let loss_func_p1 = LpLossConfig::new(1.0).init(); + assert_eq!(loss_func_l1.p, 1.0); + assert_eq!(loss_func_l1.p, loss_func_p1.p); + } + + #[test] + fn test_lp_loss_l2_constructor() { + let loss_func_l2 = LpLossConfig::l2(); + let loss_func_p2 = LpLossConfig::new(2.0).init(); + assert_eq!(loss_func_l2.p, 2.0); + assert_eq!(loss_func_l2.p, loss_func_p2.p); + } + + #[test] + fn test_lp_loss_l1() { + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 2.0]]), &device); + + let loss_func = LpLossConfig::l1(); + let loss_no_reduction = + loss_func.forward_no_reduction(predictions.clone(), targets.clone()); + let loss_auto = loss_func.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_sum = loss_func.forward(predictions, targets, Reduction::Sum); + + let expected = TensorData::from([[1.0, 1.0], [0.0, 2.0]]); + loss_no_reduction.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([1.0]); + loss_auto.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([4.0]); + loss_sum.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_l2() { + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 2.0]]), &device); + + let loss_func = LpLossConfig::l2(); + let loss_no_reduction = + loss_func.forward_no_reduction(predictions.clone(), targets.clone()); + let loss_auto = loss_func.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_sum = loss_func.forward(predictions, targets, Reduction::Sum); + + let expected = TensorData::from([[1.0, 1.0], [0.0, 4.0]]); + loss_no_reduction.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([1.5]); + loss_auto.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([6.0]); + loss_sum.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_p_half() { + // L0.5 quasi-norm: more robust to outliers than L1 + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 0.0]]), &device); + + let loss_func = LpLossConfig::new(0.5).init(); + let loss_no_reduction = + loss_func.forward_no_reduction(predictions.clone(), targets.clone()); + let loss_auto = loss_func.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_sum = loss_func.forward(predictions, targets, Reduction::Sum); + + // |1-2|^0.5 = 1, |2-1|^0.5 = 1, |3-3|^0.5 = 0, |4-0|^0.5 = 2 + let expected = TensorData::from([[1.0, 1.0], [0.0, 2.0]]); + loss_no_reduction.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([1.0]); + loss_auto.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([4.0]); + loss_sum.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_p3() { + // L3 norm: more sensitive to outliers than L2 + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 2.0]]), &device); + + let loss_func = LpLossConfig::new(3.0).init(); + let loss_no_reduction = + loss_func.forward_no_reduction(predictions.clone(), targets.clone()); + let loss_auto = loss_func.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_sum = loss_func.forward(predictions, targets, Reduction::Sum); + + // |1-2|^3 = 1, |2-1|^3 = 1, |3-3|^3 = 0, |4-2|^3 = 8 + let expected = TensorData::from([[1.0, 1.0], [0.0, 8.0]]); + loss_no_reduction.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([2.5]); + loss_auto.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([10.0]); + loss_sum.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_zero_error() { + // Test when predictions exactly match targets + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = predictions.clone(); + + let loss_func_l1 = LpLossConfig::l1(); + let loss_func_l2 = LpLossConfig::l2(); + + let l1_loss = loss_func_l1.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let l2_loss = loss_func_l2.forward(predictions, targets, Reduction::Auto); + + let expected = TensorData::from([0.0]); + l1_loss.into_data().assert_eq(&expected, false); + l2_loss.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_negative_errors() { + // Test that negative errors are handled correctly (absolute value) + let device = Default::default(); + let predictions = Tensor::<1>::from_data(TensorData::from([1.0, 2.0, 3.0]), &device); + let targets = Tensor::<1>::from_data(TensorData::from([3.0, 4.0, 5.0]), &device); + let loss_func_l1 = LpLossConfig::l1(); + let loss_func_p1 = LpLossConfig::new(1.0).init(); + + let loss_no_reduction_l1 = + loss_func_l1.forward_no_reduction(predictions.clone(), targets.clone()); + let loss_no_reduction_p1 = loss_func_p1.forward_no_reduction(predictions, targets); + + // All errors are negative: 1-3=-2, 2-4=-2, 3-5=-2, but |error| = 2 + let expected = TensorData::from([2.0, 2.0, 2.0]); + loss_no_reduction_l1.into_data().assert_eq(&expected, false); + loss_no_reduction_p1.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_lp_loss_3d_tensor() { + let device = Default::default(); + let predictions = Tensor::<3>::from_data( + TensorData::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]), + &device, + ); + let targets = Tensor::<3>::from_data( + TensorData::from([[[0.0, 2.0], [3.0, 5.0]], [[4.0, 6.0], [7.0, 10.0]]]), + &device, + ); + let loss_func_l2 = LpLossConfig::l2(); + let loss_func_p2 = LpLossConfig::new(2.0).init(); + + let loss_l2 = loss_func_l2.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_p2 = loss_func_p2.forward(predictions, targets, Reduction::Auto); + + // Errors: 1, 0, 0, -1, 1, 0, 0, -2 + // Squared: 1, 0, 0, 1, 1, 0, 0, 4 + // Mean: 7/8 = 0.875 + let expected = TensorData::from([0.875]); + loss_l2.into_data().assert_eq(&expected, false); + loss_p2.into_data().assert_eq(&expected, false); + } + + #[test] + #[should_panic(expected = "The order of the norm p must be positive.")] + fn test_lp_loss_negative_p_panics() { + let _ = LpLossConfig::new(-1.0).init(); + } + + #[test] + #[should_panic(expected = "The order of the norm p must be positive.")] + fn test_lp_loss_zero_p_panics() { + let _ = LpLossConfig::new(0.0).init(); + } + + #[test] + fn test_lp_loss_fractional_p() { + // Test p = 1.5 + let device = Default::default(); + let predictions = Tensor::<1>::from_data(TensorData::from([0.0, 4.0]), &device); + + let targets = Tensor::<1>::from_data(TensorData::from([1.0, 0.0]), &device); + + let loss_func = LpLossConfig::new(1.5).init(); + let loss_no_reduction = loss_func.forward_no_reduction(predictions, targets); + + // |0-1|^1.5 = 1, |4-0|^1.5 = 8 + let expected = TensorData::from([1.0, 8.0]); + loss_no_reduction.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_forward_reduce_dims_single_dim() { + let device = Default::default(); + // Shape: [2, 3] + let predictions = Tensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ); + let targets = Tensor::<2>::from_data( + TensorData::from([[0.0, 2.0, 6.0], [1.0, 5.0, 6.0]]), + &device, + ); + let loss_func_l2 = LpLossConfig::l2(); + let loss_func_p2 = LpLossConfig::new(2.0).init(); + + // Reduce over dim 1 -> should give [2, 1] shape + let loss_l2 = loss_func_l2.forward_reduce_dims(predictions.clone(), targets.clone(), &[1]); + let loss_p2 = loss_func_p2.forward_reduce_dims(predictions, targets, &[1]); + + // Errors row 0: [1, 0, -3] -> squared: [1, 0, 9] -> mean: 10/3 + // Errors row 1: [3, 0, 0] -> squared: [9, 0, 0] -> mean: 3 + let expected = TensorData::from([[10.0 / 3.0], [3.0]]); + loss_l2 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + loss_p2 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_first_dim() { + let device = Default::default(); + // Shape: [2, 3] + let predictions = Tensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ); + let targets = Tensor::<2>::from_data( + TensorData::from([[0.0, 2.0, 6.0], [1.0, 5.0, 6.0]]), + &device, + ); + let loss_func = LpLossConfig::l2(); + + // Reduce over dim 0 -> should give [1, 3] shape + let loss = loss_func.forward_reduce_dims(predictions, targets, &[0]); + + // Squared errors: [[1, 0, 9], [9, 0, 0]] + // Mean over dim 0: [5, 0, 4.5] + let expected = TensorData::from([[5.0, 0.0, 4.5]]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_multiple_dims() { + let device = Default::default(); + // Shape: [2, 2, 2] + let predictions = Tensor::<3>::from_data( + TensorData::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]), + &device, + ); + let targets = Tensor::<3>::from_data( + TensorData::from([[[0.0, 2.0], [3.0, 6.0]], [[4.0, 6.0], [7.0, 10.0]]]), + &device, + ); + let loss_func = LpLossConfig::l2(); + + // Reduce over dims 1 and 2 -> should give [2, 1, 1] shape + let loss = loss_func.forward_reduce_dims(predictions, targets, &[1, 2]); + + // Batch 0 errors: [[1, 0], [0, -2]] -> squared: [[1, 0], [0, 4]] -> mean: 5/4 = 1.25 + // Batch 1 errors: [[1, 0], [0, -2]] -> squared: [[1, 0], [0, 4]] -> mean: 5/4 = 1.25 + let expected = TensorData::from([[[1.25]], [[1.25]]]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_all_dims() { + let device = Default::default(); + // Shape: [2, 2] + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 2.0]]), &device); + let loss_func = LpLossConfig::l2(); + + // Reduce over all dims -> should give [1, 1] shape + let loss = loss_func.forward_reduce_dims(predictions, targets, &[0, 1]); + + // Errors: [[-1, 1], [0, 2]] -> squared: [[1, 1], [0, 4]] -> mean: 1.5 + let expected = TensorData::from([[1.5]]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_image_batch() { + // Simulate per-image loss for [batch, C, H, W] tensor (common use case for PSNR) + let device = Default::default(); + // Shape: [2, 1, 2, 2] (batch=2, C=1, H=2, W=2) + let predictions = Tensor::<4>::from_data( + TensorData::from([ + [[[1.0, 2.0], [3.0, 4.0]]], // Image 1 + [[[5.0, 6.0], [7.0, 8.0]]], // Image 2 + ]), + &device, + ); + let targets = Tensor::<4>::from_data( + TensorData::from([ + [[[0.0, 2.0], [3.0, 6.0]]], // Target 1 + [[[5.0, 5.0], [7.0, 7.0]]], // Target 2 + ]), + &device, + ); + let loss_func = LpLossConfig::l2(); + + // Reduce over C, H, W (dims 1, 2, 3) to get per-image MSE + let loss = loss_func.forward_reduce_dims(predictions, targets, &[1, 2, 3]); + + // Image 1 errors: [[1, 0], [0, -2]] -> squared: [[1, 0], [0, 4]] -> mean: 1.25 + // Image 2 errors: [[0, 1], [0, 1]] -> squared: [[0, 1], [0, 1]] -> mean: 0.5 + let expected = TensorData::from([[[[1.25]]], [[[0.5]]]]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_with_p1() { + let device = Default::default(); + // Shape: [2, 3] + let predictions = Tensor::<2>::from_data( + TensorData::from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), + &device, + ); + let targets = Tensor::<2>::from_data( + TensorData::from([[0.0, 5.0, 3.0], [1.0, 5.0, 9.0]]), + &device, + ); + let loss_func = LpLossConfig::l1(); + + // Reduce over dim 1 -> should give [2, 1] shape + let loss = loss_func.forward_reduce_dims(predictions, targets, &[1]); + + // Abs errors row 0: [1, 3, 0] -> mean: 4/3 + // Abs errors row 1: [3, 0, 3] -> mean: 2 + let expected = TensorData::from([[4.0 / 3.0], [2.0]]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_forward_reduce_dims_empty_dims() { + // Reducing over no dimensions should return the unreduced loss + let device = Default::default(); + let predictions = + Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[0.0, 2.0], [3.0, 6.0]]), &device); + let loss_func = LpLossConfig::l2(); + let loss_reduce_dims = + loss_func.forward_reduce_dims(predictions.clone(), targets.clone(), &[]); + let loss_no_reduction = loss_func.forward_no_reduction(predictions, targets); + + // Should be equivalent + loss_reduce_dims + .into_data() + .assert_eq(&loss_no_reduction.into_data(), true); + } + + #[test] + fn test_forward_reduce_dims_zero_error() { + let device = Default::default(); + // Shape: [2, 2, 2] + let predictions = Tensor::<3>::from_data( + TensorData::from([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]), + &device, + ); + let targets = predictions.clone(); + let loss_func = LpLossConfig::l2(); + let loss = loss_func.forward_reduce_dims(predictions, targets, &[1, 2]); + + // All zeros, reduced to shape: [2, 1, 1] + let expected = TensorData::from([[[0.0]], [[0.0]]]); + loss.into_data().assert_eq(&expected, false); + } +} diff --git a/crates/burn-nn/src/loss/mod.rs b/crates/burn-nn/src/loss/mod.rs new file mode 100644 index 0000000..69017a6 --- /dev/null +++ b/crates/burn-nn/src/loss/mod.rs @@ -0,0 +1,25 @@ +mod binary_cross_entropy; +mod cosine_embedding; +mod cross_entropy; +mod ctc; +mod huber; +mod kldiv; +mod lp_loss; +mod mse; +mod poisson; +mod reduction; +mod rnnt; +mod smooth_l1; + +pub use binary_cross_entropy::*; +pub use cosine_embedding::*; +pub use cross_entropy::*; +pub use ctc::*; +pub use huber::*; +pub use kldiv::*; +pub use lp_loss::*; +pub use mse::*; +pub use poisson::*; +pub use reduction::*; +pub use rnnt::*; +pub use smooth_l1::*; diff --git a/crates/burn-nn/src/loss/mse.rs b/crates/burn-nn/src/loss/mse.rs new file mode 100644 index 0000000..2a99e9a --- /dev/null +++ b/crates/burn-nn/src/loss/mse.rs @@ -0,0 +1,86 @@ +use burn_core as burn; + +use crate::loss::reduction::Reduction; + +use burn::module::Module; +use burn::tensor::Tensor; + +/// Calculate the mean squared error loss from the input logits and the targets. +#[derive(Module, Debug)] +pub struct MseLoss; + +impl Default for MseLoss { + fn default() -> Self { + Self::new() + } +} + +impl MseLoss { + /// Create the criterion. + pub fn new() -> Self { + Self + } + + /// Compute the criterion on the input tensor. + /// + /// # Shapes + /// + /// - logits: [batch_size, num_targets] + /// - targets: [batch_size, num_targets] + pub fn forward( + &self, + logits: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let tensor = self.forward_no_reduction(logits, targets); + match reduction { + Reduction::Mean | Reduction::Auto => tensor.mean(), + Reduction::Sum => tensor.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Compute the criterion on the input tensor without reducing. + pub fn forward_no_reduction( + &self, + logits: Tensor, + targets: Tensor, + ) -> Tensor { + logits.sub(targets).square() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + + #[test] + fn test_mse_loss() { + let device = Default::default(); + let logits = Tensor::<2>::from_data(TensorData::from([[1.0, 2.0], [3.0, 4.0]]), &device); + + let targets = Tensor::<2>::from_data(TensorData::from([[2.0, 1.0], [3.0, 2.0]]), &device); + + let mse = MseLoss::new(); + let loss_no_reduction = mse.forward_no_reduction(logits.clone(), targets.clone()); + let loss = mse.forward(logits.clone(), targets.clone(), Reduction::Auto); + let loss_sum = mse.forward(logits, targets, Reduction::Sum); + + let expected = TensorData::from([[1.0, 1.0], [0.0, 4.0]]); + loss_no_reduction.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([1.5]); + loss.into_data().assert_eq(&expected, false); + + let expected = TensorData::from([6.0]); + loss_sum.into_data().assert_eq(&expected, false); + } + + #[test] + fn display() { + let loss = MseLoss::new(); + assert_eq!(alloc::format!("{loss}"), "MseLoss"); + } +} diff --git a/crates/burn-nn/src/loss/poisson.rs b/crates/burn-nn/src/loss/poisson.rs new file mode 100644 index 0000000..e54c746 --- /dev/null +++ b/crates/burn-nn/src/loss/poisson.rs @@ -0,0 +1,405 @@ +use burn_core as burn; +use core::f32::consts::PI; + +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::{config::Config, module::Module}; + +use super::Reduction; + +/// Configuration for creating a [PoissonNllLoss](PoissonNllLoss) instance. +/// +/// This configuration allows customization of the Poisson Negative Log Likelihood (NLL) loss +/// behavior, such as whether the input is in log-space, whether to include the Stirling +/// approximation term, and a small epsilon value to avoid numerical instability. +#[derive(Config, Debug)] +pub struct PoissonNllLossConfig { + /// If `true`, the predictions are expected to be in log-space. + /// + /// When `log_input` is `true`, the loss is computed as: + /// ```text + /// L(predictions, target) = exp(predictions) - target * predictions + /// ``` + /// When `log_input` is `false`, the loss is computed as: + /// ```text + /// L(predictions, target) = predictions - target * log(predictions + eps) + /// ``` + #[config(default = true)] + pub log_input: bool, + /// Whether to compute the full loss, including the Stirling approximation term. + /// + /// When `full` is `true`, the Stirling approximation term is added to the loss: + /// ```text + /// target * log(target) - target + 0.5 * log(2 * PI * target) + /// ``` + #[config(default = false)] + pub full: bool, + /// A small value to avoid evaluation of `log(0)` when `log_input` is `false`. + /// + /// This epsilon value is added to the predictions to ensure numerical stability + /// when computing the logarithm. + #[config(default = 1e-8)] + pub eps: f64, +} + +impl PoissonNllLossConfig { + /// Initializes a [PoissonNllLoss](PoissonNllLoss) instance with the current configuration. + /// + /// # Panics + /// - Panics if `eps` is not a positive number. + pub fn init(&self) -> PoissonNllLoss { + self.assertions(); + PoissonNllLoss { + log_input: self.log_input, + full: self.full, + eps: self.eps, + } + } + + /// Validates the configuration parameters. + /// + /// # Panics + /// - Panics if `eps` is not a positive number. + fn assertions(&self) { + assert!( + self.eps > 0., + "eps for PoissonNllLoss must be a positive number." + ); + } +} + +/// Negative Log Likelihood (NLL) loss with a Poisson distribution assumption for the target. +/// +/// This loss function is used when the target values are assumed to follow a Poisson distribution. +/// The loss is defined as: +/// ```text +/// target ~ Poisson(input) +/// L(predictions, target) = predictions - target * log(predictions) + log(target!) +/// ``` +/// The last term (`log(target!)`) can be omitted or approximated using Stirling's formula. +/// The approximation is applied for `target > 1`, while for `target <= 1`, zeros are added to the loss. +/// +/// For more details, see: +/// +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct PoissonNllLoss { + /// If `true`, the predictions are expected to be in log-space. + pub log_input: bool, + /// Whether to compute the full loss, including the Stirling approximation term. + pub full: bool, + /// A small value to avoid evaluation of `log(0)` when `log_input` is `false`. + pub eps: f64, +} + +impl ModuleDisplay for PoissonNllLoss { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("log_input", &self.log_input) + .add("full", &self.full) + .add("eps", &self.eps) + .optional() + } +} + +impl PoissonNllLoss { + /// Computes the loss element-wise for the given predictions and targets, then reduces + /// the result to a single loss value. + /// + /// # Arguments + /// - `predictions`: The predicted values. + /// - `targets`: The target values. + /// - `reduction`: The reduction method to apply. `Reduction::Auto` behaves as `Reduction::Mean`. + /// + /// # Shapes + /// - `predictions`: `[...dims]` + /// - `targets`: `[...dims]` + /// - `output`: `[1]` + /// + /// # Panics + /// - Panics if the shapes of `predictions` and `targets` do not match. + /// - Panics if any target value is negative. + /// - Panics if `log_input` is `false` and any prediction value is negative. + pub fn forward( + &self, + predictions: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let loss = self.forward_no_reduction(predictions, targets); + match reduction { + Reduction::Mean | Reduction::Auto => loss.mean(), + Reduction::Sum => loss.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Computes the loss element-wise for the given predictions and targets without reduction. + /// + /// # Arguments + /// - `predictions`: The predicted values. + /// - `targets`: The target values. + /// + /// # Shapes + /// - `predictions`: `[...dims]` + /// - `targets`: `[...dims]` + /// - `output`: `[...dims]` + /// + /// # Panics + /// - Panics if the shapes of `predictions` and `targets` do not match. + /// - Panics if any target value is negative. + /// - Panics if `log_input` is `false` and any prediction value is negative. + pub fn forward_no_reduction( + &self, + predictions: Tensor, + targets: Tensor, + ) -> Tensor { + self.assertions(&predictions, &targets); + let mut loss; + if self.log_input { + loss = predictions.clone().exp() - targets.clone() * predictions; + } else { + loss = predictions.clone() - targets.clone() * (predictions + self.eps).log(); + } + if self.full { + let log_stirling_term = targets.clone() * targets.clone().log() - targets.clone() + + (targets.clone() * 2. * PI).log() * 0.5; + loss = loss + + log_stirling_term + .mask_where(targets.clone().lower_equal_elem(1), targets.zeros_like()); + } + loss + } + + /// Validates the input tensors for the loss computation. + /// + /// # Panics + /// - Panics if the shapes of `predictions` and `targets` do not match. + /// - Panics if any target value is negative. + /// - Panics if `log_input` is `false` and any prediction value is negative. + fn assertions(&self, predictions: &Tensor, targets: &Tensor) { + let predictions_dims = predictions.dims(); + let targets_dims = targets.dims(); + assert!( + predictions_dims == targets_dims, + "Shape of targets ({targets_dims:?}) should correspond to outer shape of predictions ({predictions_dims:?})." + ); + assert!( + targets + .clone() + .greater_equal_elem(0.) + .all() + .into_scalar::(), + "All the values of `targets` must be non-negative." + ); + if !self.log_input { + assert!( + predictions + .clone() + .greater_equal_elem(0.) + .all() + .into_scalar::(), + "When `log_input` is `false`, all the values of `predictions` must be non-negative." + ); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::approx_constant)] + + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_poisson_nll_loss() { + let predictions = TensorData::from([0., 0., -40., 1., 2., 3.]); + let targets = TensorData::from([1., 4.5, 2.5, 0., 0., 2.]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().init(); + + let loss_sum = poisson.forward(predictions.clone(), targets.clone(), Reduction::Sum); + let loss = poisson.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_no_reduction = poisson.forward_no_reduction(predictions, targets); + + let expected = TensorData::from([1.0000, 1.0000, 100.0000, 2.7183, 7.3891, 14.0855]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([21.0321]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([126.1929]); + loss_sum + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_poisson_nll_loss_no_log_input() { + let predictions = TensorData::from([0.0, 0.5, 1.0, 1.0, 2.71828, 7.38905, 20.0855]); + let targets = TensorData::from([2., 3., 1., 4.5, 0., 0., 2.]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().with_log_input(false).init(); + + let loss_no_reduction = poisson.forward_no_reduction(predictions.clone(), targets.clone()); + + let expected = TensorData::from([36.84136, 2.579441, 1.0, 1.0, 2.71828, 7.38905, 14.0855]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_poisson_nll_loss_full() { + let predictions = TensorData::from([0., 0., -40., 1., 2., 3.]); + let targets = TensorData::from([1., 4.5, 2.5, 0., 0., 2.]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().with_full(true).init(); + + let loss_sum = poisson.forward(predictions.clone(), targets.clone(), Reduction::Sum); + let loss = poisson.forward(predictions.clone(), targets.clone(), Reduction::Auto); + let loss_no_reduction = poisson.forward_no_reduction(predictions, targets); + + let expected = TensorData::from([1.0000, 4.9393, 101.1678, 2.7183, 7.3891, 14.7373]); + loss_no_reduction + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([21.9920]); + loss.into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([131.9518]); + loss_sum + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[cfg(feature = "std")] + #[test] + fn test_poisson_nll_loss_gradients() { + use burn::tensor::Device; + let device = Device::default().autodiff(); + + let predictions = TensorData::from([0., 0., -40., 1., 2., 3.]); + let targets = TensorData::from([1., 4.5, 2.5, 0., 0., 2.]); + + let predictions1 = Tensor::<1>::from_data(predictions, &device).require_grad(); + let predictions2 = predictions1.clone(); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().with_full(false).init(); + let poisson_full = PoissonNllLossConfig::new().with_full(true).init(); + + let loss_sum = poisson.forward(predictions1.clone(), targets.clone(), Reduction::Sum); + let loss_full_sum = + poisson_full.forward(predictions2.clone(), targets.clone(), Reduction::Sum); + + let grads = loss_sum.backward(); + let grads_full = loss_full_sum.backward(); + + let grads_predictions1 = predictions1.grad(&grads).unwrap(); + let grads_predictions2 = predictions2.grad(&grads_full).unwrap(); + + let expected = TensorData::from([0.0000, -3.5000, -2.5000, 2.7183, 7.3891, 18.0855]); + + grads_predictions1 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + grads_predictions2 + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + #[should_panic = "eps for PoissonNllLoss must be a positive number."] + fn test_negative_eps() { + let _poisson = PoissonNllLossConfig::new().with_eps(0.).init(); + } + + #[test] + #[should_panic = "All the values of `targets` must be non-negative."] + fn test_targets_with_negative_values() { + let predictions = TensorData::from([0., 0., -40., 1., 2., 3., 4.]); + let targets = TensorData::from([1., 4.5, 2.5, 0., 0., 2., -0.42]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().init(); + + let _loss = poisson.forward(predictions.clone(), targets.clone(), Reduction::Auto); + } + + #[test] + #[should_panic = "Shape of targets"] + fn test_shape_tensors() { + let predictions = TensorData::from([0., 1., 2.]); + let targets = TensorData::from([0., 1.]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().init(); + + let _loss = poisson.forward_no_reduction(predictions.clone(), targets.clone()); + } + + #[test] + #[should_panic = "When `log_input` is `false`, all the values of `predictions` must be non-negative."] + fn test_exp_predictions_non_negative() { + let predictions = TensorData::from([0.3, -0.1, 0.4]); + let targets = TensorData::from([0., 1., 0.]); + + let device = Default::default(); + + let predictions = Tensor::<1>::from_data(predictions, &device); + let targets = Tensor::<1>::from_data(targets, &device); + + let poisson = PoissonNllLossConfig::new().with_log_input(false).init(); + + let _loss = poisson.forward_no_reduction(predictions.clone(), targets.clone()); + } + + #[test] + fn display() { + let config = PoissonNllLossConfig::new(); + let loss = config.init(); + + assert_eq!( + alloc::format!("{loss}"), + "PoissonNllLoss {log_input: true, full: false, eps: 0.00000001}" + ); + } +} diff --git a/crates/burn-nn/src/loss/reduction.rs b/crates/burn-nn/src/loss/reduction.rs new file mode 100644 index 0000000..ddb15fe --- /dev/null +++ b/crates/burn-nn/src/loss/reduction.rs @@ -0,0 +1,19 @@ +use burn_core as burn; + +use burn::config::Config; + +/// The reduction type for the loss. +#[derive(Config, Debug)] +pub enum Reduction { + /// The mean of the losses will be returned. + Mean, + + /// The sum of the losses will be returned. + Sum, + + /// The sum of the losses divided by the batch_size will be returned. + BatchMean, + + /// The mean of the losses will be returned. + Auto, +} diff --git a/crates/burn-nn/src/loss/rnnt.rs b/crates/burn-nn/src/loss/rnnt.rs new file mode 100644 index 0000000..3440baa --- /dev/null +++ b/crates/burn-nn/src/loss/rnnt.rs @@ -0,0 +1,766 @@ +use super::Reduction; +use alloc::vec; +use burn::config::Config; +use burn::module::Module; +use burn::tensor::{Bool, Device, Int, Tensor, s}; +use burn_core as burn; +use core::f32; + +/// Configuration for [RNNTLoss](RNNTLoss). +#[derive(Config, Debug)] +pub struct RNNTLossConfig { + /// Index of the blank label in the vocabulary. Default: `0`. + #[config(default = 0)] + pub blank: usize, + /// Treat the inputs as logits, applying a log-softmax on the last dimension internally. + /// If `false`, the input must already be log-probabilities. Default: `true`. + #[config(default = true)] + pub logits: bool, +} + +impl RNNTLossConfig { + /// Initializes a [RNNTLoss](RNNTLoss) module. + pub fn init(&self) -> RNNTLoss { + RNNTLoss { + blank: self.blank, + logits: self.logits, + } + } +} + +/// RNN Transducer (RNNT) loss, as described in +/// [Sequence Transduction with Recurrent Neural Networks](https://arxiv.org/abs/1211.3711). +/// +/// Computes the negative log-likelihood over a 2D lattice of encoder time steps (T) +/// and output labels (U), marginalizing over all valid alignments. +/// +/// # Example +/// +/// ```rust,ignore +/// let rnnt = RNNTLossConfig::new().init(); +/// +/// // logits: [B, T, U+1, V] from the joiner network +/// let loss = rnnt.forward(logits, targets, logit_lengths, target_lengths); +/// ``` +#[derive(Module, Debug)] +pub struct RNNTLoss { + blank: usize, + logits: bool, +} + +impl RNNTLoss { + /// Computes per-sample RNNT loss (no reduction). Returns shape `[B]`. + /// + /// - `logits`: `[B, T, U+1, V]` — joiner output (raw logits or log-probs) + /// - `targets`: `[B, U]` — target label indices (must not contain blank) + /// - `logit_lengths`: `[B]` — actual encoder lengths per sample + /// - `target_lengths`: `[B]` — actual target lengths per sample + pub fn forward( + &self, + logits: Tensor<4>, + targets: Tensor<2, Int>, + logit_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + ) -> Tensor<1> { + let device = logits.device(); + let [b, max_t, max_up1, v] = logits.dims(); + let max_u = max_up1 - 1; + + self.check_inputs(b, v, &targets, &logit_lengths, &target_lengths, max_u); + + let log_probs = if self.logits { + let vocab_dim = 3; // last dim of [B, T, U+1, V] + burn::tensor::activation::log_softmax(logits, vocab_dim) + } else { + logits + }; + + let (lpb, lpl) = self.extract_log_probs(log_probs, targets); + let u_mask = self.create_u_mask(&target_lengths, b, max_up1, &device); + let neg_inf = Tensor::<2>::full([b, max_up1], f32::NEG_INFINITY, &device); + + // Forward pass: compute log_alpha across the (T, U) lattice + let mut alpha = self.init_alpha(&lpl, b, max_up1, &device); + alpha = neg_inf.clone().mask_where(u_mask.clone(), alpha); + + let logit_lengths_exp = logit_lengths.clone().reshape([b, 1]).expand([b, max_up1]); + + for t in 1..max_t { + let new = self.step_alpha(&alpha, &lpb, &lpl, t); + let new = neg_inf.clone().mask_where(u_mask.clone(), new); + + // Only update alpha for samples where t < logit_lengths[b] + let valid = logit_lengths_exp.clone().greater_elem(t as i64); + alpha = alpha.mask_where(valid, new); + } + + self.gather_loss(alpha, &lpb, logit_lengths, target_lengths, b) + } + + /// Computes RNNT loss with the given reduction. Returns shape `[1]`. + pub fn forward_with_reduction( + &self, + logits: Tensor<4>, + targets: Tensor<2, Int>, + logit_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + reduction: Reduction, + ) -> Tensor<1> { + let loss = self.forward(logits, targets, logit_lengths, target_lengths); + match reduction { + Reduction::Auto | Reduction::Mean => loss.mean(), + Reduction::Sum => loss.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Gathers `log_prob_blank[B, T, U+1]` and `log_prob_label[B, T, U]` from the full + /// log-probability tensor by indexing into the vocab dimension. + fn extract_log_probs( + &self, + log_probs: Tensor<4>, + targets: Tensor<2, Int>, + ) -> (Tensor<3>, Tensor<3>) { + let [b, max_t, max_up1, v] = log_probs.dims(); + let max_u = max_up1 - 1; + let vocab_dim = 3; + + // Blank probabilities: slice log_probs in vocab dim using the blank index + let lpb = log_probs + .clone() + .slice_dim(vocab_dim, self.blank) + .squeeze_dim::<3>(vocab_dim); + + // Label probabilities: gather target labels across vocab dim (only first U positions) + let tgt = targets + .reshape([b, 1, max_u, 1]) + .expand([b, max_t, max_u, 1]); + let lpl = log_probs + .slice(s![.., .., 0..max_u, 0..v]) + .gather(vocab_dim, tgt) + .squeeze_dim::<3>(vocab_dim); + + (lpb, lpl) + } + + /// Sets up log_alpha at t=0: `alpha(0,0) = 0`, then cumsum of label probs along u. + fn init_alpha(&self, lpl: &Tensor<3>, b: usize, max_up1: usize, device: &Device) -> Tensor<2> { + // Label probs at t=0 + let lpl_0 = lpl.clone().slice(s![.., 0..1, ..]).squeeze_dim::<2>(1); + let zero_col = Tensor::<2>::zeros([b, 1], device); + let prefix = Tensor::cat(vec![zero_col, lpl_0.slice(s![.., 0..(max_up1 - 1)])], 1); + + prefix.cumsum(1) + } + + /// Boolean mask `[B, U+1]` that is true where `u <= target_lengths[b]`. + fn create_u_mask( + &self, + target_lengths: &Tensor<1, Int>, + b: usize, + max_up1: usize, + device: &Device, + ) -> Tensor<2, Bool> { + let indices = Tensor::<1, Int>::arange(0..max_up1 as i64, device) + .reshape([1, max_up1]) + .expand([b, max_up1]); + let lengths = target_lengths.clone().reshape([b, 1]).expand([b, max_up1]); + indices.lower_equal(lengths) + } + + /// One time step of the forward recurrence: + /// + /// alpha(t, u) = logaddexp( + /// alpha(t-1, u) + blank(t-1, u), + /// alpha(t, u-1) + label(t, u-1), + /// ) + fn step_alpha( + &self, + alpha: &Tensor<2>, + lpb: &Tensor<3>, + lpl: &Tensor<3>, + t: usize, + ) -> Tensor<2> { + let [b, max_up1] = alpha.dims(); + let device = alpha.device(); + + // Blank transition: alpha(t-1, :) + blank_prob(t-1, :) + let blank_prob = lpb + .clone() + .slice(s![.., (t - 1)..t, ..]) + .squeeze_dim::<2>(1); + let from_blank = alpha.clone().add(blank_prob); + + let mut new = Tensor::<2>::full([b, max_up1], f32::NEG_INFINITY, &device); + new = new.slice_assign(s![.., 0..1], from_blank.clone().slice(s![.., 0..1])); + + // Label probs at time t + let label_prob = lpl + .clone() + .slice(s![.., t..(t + 1), ..]) + .squeeze_dim::<2>(1); + + for u in 1..max_up1 { + let via_blank = from_blank.clone().slice(s![.., u..(u + 1)]); + let via_label = new + .clone() + .slice(s![.., (u - 1)..u]) + .add(label_prob.clone().slice(s![.., (u - 1)..u])); + new = new.slice_assign(s![.., u..(u + 1)], self.log_sum_exp(via_blank, via_label)); + } + new + } + + /// Extracts `-(alpha(T_b, U_b) + blank(T_b, U_b))` for each sample in the batch. + fn gather_loss( + &self, + alpha: Tensor<2>, + lpb: &Tensor<3>, + logit_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + b: usize, + ) -> Tensor<1> { + let device = alpha.device(); + // Anchor the index dtype on `u_idx` so all three coordinate tensors share a + // common int dtype before stacking - `cat` panics on dtype mismatch and the + // caller's lengths may not use the device's default IntElem. + let u_idx = target_lengths; + let int_dtype = u_idx.dtype(); + let t_idx = logit_lengths.sub_scalar(1).cast(int_dtype); + let b_idx = Tensor::<1, Int>::arange(0..b as i64, (&device, int_dtype)); + + let alpha_tu: Tensor<1> = + alpha.gather_nd(Tensor::stack::<2>(vec![b_idx.clone(), u_idx.clone()], 1)); + let lpb_tu: Tensor<1> = lpb + .clone() + .gather_nd(Tensor::stack::<2>(vec![b_idx, t_idx, u_idx], 1)); + + alpha_tu.add(lpb_tu).neg() + } + + fn check_inputs( + &self, + b: usize, + v: usize, + targets: &Tensor<2, Int>, + logit_lengths: &Tensor<1, Int>, + target_lengths: &Tensor<1, Int>, + max_u: usize, + ) { + assert!( + self.blank < v, + "blank index {} must be less than vocab_size {}", + self.blank, + v + ); + assert_eq!( + targets.dims()[0], + b, + "targets batch dimension {} must equal batch_size {}", + targets.dims()[0], + b + ); + assert_eq!( + targets.dims()[1], + max_u, + "targets length dimension {} must equal max_target_len (max_u) {}", + targets.dims()[1], + max_u + ); + assert_eq!( + logit_lengths.dims()[0], + b, + "logit_lengths length {} must equal batch_size {}", + logit_lengths.dims()[0], + b + ); + assert_eq!( + target_lengths.dims()[0], + b, + "target_lengths length {} must equal batch_size {}", + target_lengths.dims()[0], + b + ); + } + + /// Numerically stable `log(exp(a) + exp(b))`, handling `-inf` inputs. + fn log_sum_exp(&self, a: Tensor, b: Tensor) -> Tensor { + let a_inf = a.clone().equal_elem(f32::NEG_INFINITY); + let b_inf = b.clone().equal_elem(f32::NEG_INFINITY); + + // Replace -inf with 0 to prevent NaN in the subtraction (masked out below) + let a_safe = a.clone().mask_fill(a_inf.clone(), 0.0); + let b_safe = b.clone().mask_fill(b_inf.clone(), 0.0); + + // log(exp(a) + exp(b)) = max(a,b) + log(1 + exp(-|a-b|)) + let max = a_safe.clone().max_pair(b_safe.clone()); + let result = max.add(a_safe.sub(b_safe).abs().neg().exp().add_scalar(1.0).log()); + + // If a=-inf, result is b; if b=-inf, result is a; if both -inf, stays -inf + let result = result.mask_where(a_inf, b); + result.mask_where(b_inf, a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::{TensorData, Tolerance}; + const NUM_LABELS: usize = 2; // vocab size for simple unit tests + + #[test] + fn config_defaults() { + let cfg = RNNTLossConfig::new(); + assert_eq!(cfg.blank, 0); + assert!(cfg.logits); + } + + #[test] + #[should_panic(expected = "blank index")] + fn panics_on_invalid_blank() { + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().with_blank(5).init(); + rnnt.forward( + Tensor::<4>::zeros([1, 2, 2, 3], &dev), + Tensor::<2, Int>::from_data([[1_i32]], &dev), + Tensor::<1, Int>::from_data([2], &dev), + Tensor::<1, Int>::from_data([1], &dev), + ); + } + + #[test] + #[should_panic(expected = "must equal batch_size")] + fn panics_on_batch_mismatch() { + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().init(); + rnnt.forward( + Tensor::<4>::zeros([2, 3, 2, 3], &dev), + Tensor::<2, Int>::from_data([[1_i32]], &dev), + Tensor::<1, Int>::from_data([3, 3], &dev), + Tensor::<1, Int>::from_data([1, 1], &dev), + ); + } + + #[test] + #[should_panic(expected = "logit_lengths length")] + fn panics_on_logit_lengths_mismatch() { + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().init(); + rnnt.forward( + Tensor::<4>::zeros([2, 3, 2, 3], &dev), + Tensor::<2, Int>::from_data([[1_i32], [2]], &dev), + Tensor::<1, Int>::from_data([3], &dev), + Tensor::<1, Int>::from_data([1, 1], &dev), + ); + } + + #[test] + #[should_panic(expected = "target_lengths length")] + fn panics_on_target_lengths_mismatch() { + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().init(); + rnnt.forward( + Tensor::<4>::zeros([2, 3, 2, 3], &dev), + Tensor::<2, Int>::from_data([[1_i32], [2]], &dev), + Tensor::<1, Int>::from_data([3, 3], &dev), + Tensor::<1, Int>::from_data([1], &dev), + ); + } + + #[test] + fn single_token_uniform_probs() { + // B=1, T=2, U=1, V=2, uniform probs: P(blank) = P(label) = 1/V + // + // Two alignment paths (label emitted at t=0 or t=1), each with T+U emissions: + // total_prob = T * (1/V)^(T+1) = 2 * (1/2)^3 = 1/4 + // loss = -ln(1/4) = 2*ln(2) + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().with_logits(false).init(); + let time_steps = 2; + let target_len = 1; + let v = NUM_LABELS as f32; + let log_uniform = (1.0 / v).ln(); + + let loss = rnnt.forward( + Tensor::<4>::full( + [1, time_steps, target_len + 1, NUM_LABELS], + log_uniform, + &dev, + ), + Tensor::<2, Int>::from_data([[1_i32]], &dev), + Tensor::<1, Int>::from_data([time_steps as i64], &dev), + Tensor::<1, Int>::from_data([target_len as i64], &dev), + ); + // Each path: T-1 blanks + U labels + 1 final blank = T + U emissions + let num_paths = time_steps as f32; + let emissions_per_path = (time_steps + target_len) as f32; + let total_prob = num_paths * v.powf(-emissions_per_path); + let expected_loss = -total_prob.ln(); + loss.into_data().assert_approx_eq::( + &TensorData::from([expected_loss]), + Tolerance::absolute(1e-4), + ); + } + + #[test] + fn empty_target() { + // B=1, T=3, U=0, V=2, uniform probs: only the all-blanks path exists. + // + // Single path with T emissions (T-1 blanks + 1 final blank, all at u=0): + // total_prob = (1/V)^T = (1/2)^3 = 1/8 + // loss = T*ln(V) = 3*ln(2) + let dev = Default::default(); + let rnnt = RNNTLossConfig::new().with_logits(false).init(); + let time_steps = 3; + let target_len = 0; + let v = NUM_LABELS as f32; + let log_uniform = (1.0 / v).ln(); + + let loss = rnnt.forward( + Tensor::<4>::full([1, time_steps, 2, NUM_LABELS], log_uniform, &dev), + Tensor::<2, Int>::from_data([[1_i32]], &dev), + Tensor::<1, Int>::from_data([time_steps as i64], &dev), + Tensor::<1, Int>::from_data([target_len as i64], &dev), + ); + // T + U = T emissions total for U=0 + let expected_loss = -v.powf(-((time_steps + target_len) as f32)).ln(); + loss.into_data().assert_approx_eq::( + &TensorData::from([expected_loss]), + Tolerance::absolute(1e-4), + ); + } + + #[test] + fn logits_equivalence() { + // Verify that logits=true (internal log_softmax on raw logits) + // gives the same loss as logits=false with external log_softmax. + let dev = Default::default(); + let [bs, time_steps, up1, vocab] = [1, 2, 3, 4]; + let num_elements = bs * time_steps * up1 * vocab; + let target_len = up1 - 1; + + let data: Vec = (0..num_elements).map(|i| (i as f32 * 0.3).sin()).collect(); + let logits = Tensor::<4>::from_data( + burn_core::tensor::TensorData::new(data, [bs, time_steps, up1, vocab]), + &dev, + ); + let targets = Tensor::<2, Int>::from_data([[1_i32, 2]], &dev); + let logit_lengths = Tensor::<1, Int>::from_data([time_steps as i64], &dev); + let target_lengths = Tensor::<1, Int>::from_data([target_len as i64], &dev); + + let vocab_dim = 3; + let fused = RNNTLossConfig::new().with_logits(true).init().forward( + logits.clone(), + targets.clone(), + logit_lengths.clone(), + target_lengths.clone(), + ); + + let log_probs = burn::tensor::activation::log_softmax(logits, vocab_dim); + let manual = RNNTLossConfig::new().with_logits(false).init().forward( + log_probs, + targets, + logit_lengths, + target_lengths, + ); + + fused + .into_data() + .assert_approx_eq::(&manual.into_data(), Tolerance::absolute(1e-4)); + } +} + +/// Tests comparing forward loss and backward gradients against torchaudio.functional.rnnt_loss. +/// +/// Logits are generated deterministically via sin((b*11+t*7+u*13+v*3)*0.1) so the same +/// values can be reproduced in a Python script for cross-checking. +#[cfg(test)] +#[allow(clippy::identity_op, clippy::too_many_arguments)] +mod pytorch_comparison_tests { + use super::*; + use burn::tensor::{TensorData, Tolerance}; + fn tol() -> Tolerance { + Tolerance::absolute(1e-3) + } + + /// Deterministic logits matching the Python reference generator. + /// Uses coprime coefficients to avoid repeating patterns across dimensions. + fn make_logits(bs: usize, t: usize, u: usize, v: usize, dev: &Device) -> Tensor<4> { + let mut data = Vec::with_capacity(bs * t * u * v); + for bi in 0..bs { + for ti in 0..t { + for ui in 0..u { + for vi in 0..v { + let idx = bi * 11 + ti * 7 + ui * 13 + vi * 3; + data.push((idx as f32 * 0.1).sin()); + } + } + } + } + Tensor::from_data(TensorData::new(data, [bs, t, u, v]), dev) + } + + /// Checks that gradients along the vocab dim sum to ~0 at every (b, t, u) position. + /// This must hold because log_softmax is applied on the last dim, + /// and the Jacobian of log_softmax has the property that each row sums to zero. + fn check_vocab_grad_sums(grad: &[f32], bs: usize, t: usize, up1: usize, v: usize) { + for bi in 0..bs { + for ti in 0..t { + for ui in 0..up1 { + let base = ((bi * t + ti) * up1 + ui) * v; + let sum: f32 = (0..v).map(|vi| grad[base + vi]).sum(); + TensorData::from([sum]) + .assert_approx_eq::(&TensorData::from([0.0f32]), tol()); + } + } + } + } + + /// Returns the V-sized gradient slice at position (b, t, u) in a flattened [B, T, U+1, V] grad. + fn grad_at( + grad: &[f32], + b: usize, + t: usize, + u: usize, + max_t: usize, + up1: usize, + v: usize, + ) -> &[f32] { + let base = ((b * max_t + t) * up1 + u) * v; + &grad[base..base + v] + } + + /// Asserts that a gradient slice at position (b, t, u) matches expected values. + fn assert_grad( + grad: &[f32], + b: usize, + t: usize, + u: usize, + max_t: usize, + up1: usize, + v: usize, + expected: &[f32], + ) { + TensorData::from(grad_at(grad, b, t, u, max_t, up1, v)) + .assert_approx_eq::(&TensorData::from(expected), tol()); + } + + #[test] + fn basic_b1() { + // B=1, T=4, U+1=3, V=3, targets=[1,2] + let dev = Device::default().autodiff(); + let rnnt = RNNTLossConfig::new().init(); + let logits = make_logits(1, 4, 3, 3, &dev).require_grad(); + + let loss = rnnt.forward( + logits.clone(), + Tensor::<2, Int>::from_data([[1_i32, 2]], &dev), + Tensor::<1, Int>::from_data([4_i32], &dev), + Tensor::<1, Int>::from_data([2_i32], &dev), + ); + loss.clone() + .into_data() + .assert_approx_eq::(&TensorData::from([4.4491f32]), tol()); + + let grads = loss.sum().backward(); + let grad = logits + .grad(&grads) + .unwrap() + .into_data() + .to_vec::() + .unwrap(); + + // Spot-check first, middle, and last (t, u) positions against torchaudio + assert_grad(&grad, 0, 0, 0, 4, 3, 3, &[-0.2041, -0.2246, 0.4287]); + assert_grad(&grad, 0, 2, 0, 4, 3, 3, &[0.0079, -0.0640, 0.0561]); + assert_grad(&grad, 0, 3, 2, 4, 3, 3, &[-0.6899, 0.3231, 0.3667]); + check_vocab_grad_sums(&grad, 1, 4, 3, 3); + } + + #[test] + fn batched_b2() { + // B=2, T=5, U+1=4, V=4, targets=[[1,2,3],[2,1,3]] + let dev = Device::default().autodiff(); + let rnnt = RNNTLossConfig::new().init(); + let logits = make_logits(2, 5, 4, 4, &dev).require_grad(); + + let loss = rnnt.forward( + logits.clone(), + Tensor::<2, Int>::from_data(TensorData::new(vec![1_i32, 2, 3, 2, 1, 3], [2, 3]), &dev), + Tensor::<1, Int>::from_data([5_i32, 5], &dev), + Tensor::<1, Int>::from_data([3_i32, 3], &dev), + ); + loss.clone() + .into_data() + .assert_approx_eq::(&TensorData::from([7.9356f32, 7.2033]), tol()); + + let grads = loss.sum().backward(); + let grad = logits + .grad(&grads) + .unwrap() + .into_data() + .to_vec::() + .unwrap(); + + // Spot-check: first position of each sample, and last position + assert_grad(&grad, 0, 0, 0, 5, 4, 4, &[-0.3161, -0.3113, 0.2796, 0.3479]); + assert_grad(&grad, 1, 0, 0, 5, 4, 4, &[-0.2766, 0.2602, -0.2248, 0.2411]); + assert_grad(&grad, 0, 4, 3, 5, 4, 4, &[-0.8216, 0.2296, 0.2786, 0.3133]); + assert_grad(&grad, 1, 4, 3, 5, 4, 4, &[-0.7185, 0.2735, 0.2437, 0.2012]); + check_vocab_grad_sums(&grad, 2, 5, 4, 4); + } + + #[test] + fn variable_lengths_b3() { + // B=3, T=6, U+1=4, V=5 + // logit_lengths=[6,4,5], target_lengths=[3,2,1] + // Tests that masking works correctly for variable-length sequences. + let dev = Device::default().autodiff(); + let rnnt = RNNTLossConfig::new().init(); + let logits = make_logits(3, 6, 4, 5, &dev).require_grad(); + + let loss = rnnt.forward( + logits.clone(), + Tensor::<2, Int>::from_data( + TensorData::new(vec![1_i32, 2, 3, 4, 1, 0, 2, 0, 0], [3, 3]), + &dev, + ), + Tensor::<1, Int>::from_data([6_i32, 4, 5], &dev), + Tensor::<1, Int>::from_data([3_i32, 2, 1], &dev), + ); + loss.clone() + .into_data() + .assert_approx_eq::(&TensorData::from([10.7458f32, 8.0196, 8.3316]), tol()); + + let grads = loss.sum().backward(); + let grad = logits + .grad(&grads) + .unwrap() + .into_data() + .to_vec::() + .unwrap(); + let stride = 4 * 5; // U+1 * V per time step + let zeros = vec![0.0f32; 5]; + + // Sample 0 (full length=6): spot-check first and last active positions + assert_grad( + &grad, + 0, + 0, + 0, + 6, + 4, + 5, + &[-0.4232, -0.3114, 0.1992, 0.2478, 0.2876], + ); + assert_grad( + &grad, + 0, + 5, + 3, + 6, + 4, + 5, + &[-0.8016, 0.2170, 0.2172, 0.1991, 0.1683], + ); + + // Sample 1 (logit_length=4): gradients beyond t=3 should be zero + assert_grad( + &grad, + 1, + 0, + 0, + 6, + 4, + 5, + &[-0.2502, 0.2160, 0.2173, 0.2002, -0.3833], + ); + let sample1_t4_start = 1 * 6 * stride + 4 * stride; + for i in 0..(2 * stride) { + // t=4 and t=5 should all be zero + assert!( + grad[sample1_t4_start + i].abs() < 1e-3, + "sample 1, t>=4: grad[{}] = {} (expected 0)", + i, + grad[sample1_t4_start + i] + ); + } + + // Sample 1 (target_length=2): u=3 positions should be zero within active time steps + for ti in 0..4 { + assert_grad(&grad, 1, ti, 3, 6, 4, 5, &zeros); + } + + // Sample 2 (logit_length=5): t=5 should be zero + let sample2_t5_start = 2 * 6 * stride + 5 * stride; + for i in 0..stride { + assert!( + grad[sample2_t5_start + i].abs() < 1e-3, + "sample 2, t=5: grad[{}] = {} (expected 0)", + i, + grad[sample2_t5_start + i] + ); + } + + check_vocab_grad_sums(&grad, 3, 6, 4, 5); + } + + #[test] + fn sum_reduction() { + let dev = Device::default().autodiff(); + let rnnt = RNNTLossConfig::new().init(); + let logits = make_logits(2, 5, 4, 4, &dev).require_grad(); + let tgt = + Tensor::<2, Int>::from_data(TensorData::new(vec![1_i32, 2, 3, 2, 1, 3], [2, 3]), &dev); + let il = Tensor::<1, Int>::from_data([5_i32, 5], &dev); + let tl = Tensor::<1, Int>::from_data([3_i32, 3], &dev); + + let loss = rnnt.forward_with_reduction(logits.clone(), tgt, il, tl, Reduction::Sum); + // 7.9356 + 7.2033 = 15.1389 + loss.clone() + .into_data() + .assert_approx_eq::(&TensorData::from([15.1389f32]), tol()); + + let grads = loss.backward(); + let g = logits + .grad(&grads) + .unwrap() + .into_data() + .to_vec::() + .unwrap(); + TensorData::from(&g[..4]).assert_approx_eq::( + &TensorData::from([-0.3161f32, -0.3113, 0.2796, 0.3479]), + tol(), + ); + } + + #[test] + fn mean_reduction() { + let dev = Device::default().autodiff(); + let rnnt = RNNTLossConfig::new().init(); + let logits = make_logits(2, 5, 4, 4, &dev).require_grad(); + let tgt = + Tensor::<2, Int>::from_data(TensorData::new(vec![1_i32, 2, 3, 2, 1, 3], [2, 3]), &dev); + let il = Tensor::<1, Int>::from_data([5_i32, 5], &dev); + let tl = Tensor::<1, Int>::from_data([3_i32, 3], &dev); + + let loss = rnnt.forward_with_reduction(logits.clone(), tgt, il, tl, Reduction::Mean); + // 15.1389 / 2 = 7.5694 + loss.clone() + .into_data() + .assert_approx_eq::(&TensorData::from([7.5694f32]), tol()); + + // Gradients should be half the sum-reduction gradients (mean over batch of 2) + let grads = loss.backward(); + let g = logits + .grad(&grads) + .unwrap() + .into_data() + .to_vec::() + .unwrap(); + TensorData::from(&g[..4]).assert_approx_eq::( + &TensorData::from([-0.1581f32, -0.1557, 0.1398, 0.1739]), + tol(), + ); + } +} diff --git a/crates/burn-nn/src/loss/smooth_l1.rs b/crates/burn-nn/src/loss/smooth_l1.rs new file mode 100644 index 0000000..529e203 --- /dev/null +++ b/crates/burn-nn/src/loss/smooth_l1.rs @@ -0,0 +1,501 @@ +use super::Reduction; +use burn::config::Config; +use burn::module::Module; +use burn::tensor::Tensor; +use burn_core as burn; + +/// Configuration for the [SmoothL1Loss](SmoothL1Loss) module. +/// +/// Smooth L1 loss combines L1 and L2 loss, using L2 loss for small errors (below beta) +/// and L1 loss for large errors (above beta). This makes it less sensitive to outliers +/// than MSE while maintaining smooth gradients near zero. +/// +/// # Example +/// +/// ```ignore +/// use burn_nn::loss::{SmoothL1LossConfig, Reduction}; +/// +/// // Create Smooth L1 loss with default beta=1.0 +/// let smooth_l1 = SmoothL1LossConfig::new().init(); +/// +/// // Create with custom beta +/// let smooth_l1_custom = SmoothL1LossConfig::new().with_beta(0.5).init(); +/// ``` +#[derive(Config, Debug)] +pub struct SmoothL1LossConfig { + /// Specifies the threshold at which to change between L1 and L2 loss. + /// The value must be positive. Default: 1.0 + #[config(default = 1.0)] + pub beta: f32, +} + +impl SmoothL1LossConfig { + /// Initializes a [Smooth L1 Loss](SmoothL1Loss) module. + /// + /// # Panics + /// + /// Panics if `beta <= 0`. + pub fn init(&self) -> SmoothL1Loss { + self.assertions(); + SmoothL1Loss { beta: self.beta } + } + + fn assertions(&self) { + assert!(self.beta > 0.0, "The parameter beta must be positive.") + } +} + +/// Computes the Smooth L1 Loss between predictions and targets. +/// +/// This loss function uses L2 loss for small errors (below beta) and L1 loss for +/// large errors (above beta), providing robustness to outliers while maintaining +/// smooth gradients near |x - y| = 0. +/// +/// # Mathematical Definition +/// +/// For predictions `x` and targets `y`, the element-wise loss is: +/// +/// - L_i = 0.5 * (x_i - y_i)² / beta , if |x_i - y_i| < beta +/// - L_i = |x_i - y_i| - 0.5 * beta , otherwise +/// +/// # Notes +/// +/// Smooth L1 loss is closely related to HuberLoss since it is equivalent to HuberLoss +/// scaled by `1/beta`: +/// `SmoothL1(x, y, beta) = Huber(x, y, beta) / beta` +/// +/// This leads to the following differences: +/// +/// - As beta approaches 0, Smooth L1 loss converges to L1Loss, while HuberLoss converges to 0. +/// When beta = 0, Smooth L1 loss is equivalent to L1 loss. Thus, the `beta` +/// parameter in Burn must be positive. L1Loss should be used for beta = 0. +/// - As beta approaches positive infinity, Smooth L1 loss converges to a constant 0 loss, while +/// HuberLoss converges to L2Loss. +/// +/// # Example +/// +/// ```rust,ignore +/// use burn_nn::loss::{SmoothL1LossConfig, Reduction}; +/// use burn::tensor::Tensor; +/// +/// // Create Smooth L1 loss with the default beta=1.0 +/// let smooth_l1 = SmoothL1LossConfig::new().init(); +/// +/// let predictions: Tensor = /* model output */; +/// let targets: Tensor = /* ground truth */; +/// +/// // Compute element-wise loss without reduction +/// let element_wise = smooth_l1.forward(predictions.clone(), targets.clone()); +/// +/// // Compute loss with mean reduction +/// let loss = smooth_l1.forward_with_reduction(predictions.clone(), targets.clone(), Reduction::Mean); +/// +/// // Per-image loss: reduce over C, H, W → [batch, 1, 1, 1] +/// let loss_per_image = smooth_l1.forward_reduce_dims(predictions, targets, &[1, 2, 3]); +/// ``` +#[derive(Module, Debug)] +pub struct SmoothL1Loss { + /// Specifies the threshold at which to change between L1 and L2 loss. + /// The value must be positive. Default: 1.0 + pub beta: f32, +} + +impl SmoothL1Loss { + /// Computes the element-wise smooth L1 loss without reduction. + /// + /// # Arguments + /// + /// - `predictions` - The model's predicted values. + /// - `targets` - The ground truth target values. + /// + /// # Returns + /// + /// A tensor of the same shape as the inputs, containing the smooth L1 loss + /// for each element. + /// + /// # Shapes + /// + /// - predictions: `[...dims]` - Any shape + /// - targets: `[...dims]` - Must match predictions shape + /// - output: `[...dims]` - Same shape as inputs + pub fn forward(&self, predictions: Tensor, targets: Tensor) -> Tensor { + let error = predictions.sub(targets); + let abs_error = error.clone().abs(); + + // The L1 case: |error| - 0.5 * beta (when |error| >= beta) + let l1_loss = abs_error.clone().sub_scalar(0.5 * self.beta); + + // The L2 case: 0.5 * (error)^2 / beta (when |error| < beta) + let l2_loss = error.square().mul_scalar(0.5).div_scalar(self.beta); + + let l2_mask = abs_error.lower_elem(self.beta); + l1_loss.mask_where(l2_mask, l2_loss) + } + + /// Computes the smooth L1 loss with reduction. + /// + /// # Arguments + /// + /// - `predictions` - The model's predicted values. + /// - `targets` - The ground truth target values. + /// - `reduction` - Specifies how to reduce the element-wise losses: + /// - `Reduction::Mean` or `Reduction::Auto`: Returns the mean of all element-wise losses. + /// - `Reduction::Sum`: Returns the sum of all element-wise losses. + /// + /// # Returns + /// + /// A scalar tensor containing the reduced loss value. + /// + /// # Shapes + /// + /// - predictions: `[...dims]` - Any shape + /// - targets: `[...dims]` - Must match predictions shape + /// - output: `[1]` - Scalar loss value + pub fn forward_with_reduction( + &self, + predictions: Tensor, + targets: Tensor, + reduction: Reduction, + ) -> Tensor<1> { + let unreduced_loss = self.forward(predictions, targets); + + match reduction { + Reduction::Mean | Reduction::Auto => unreduced_loss.mean(), + Reduction::Sum => unreduced_loss.sum(), + other => panic!("{other:?} reduction is not supported"), + } + } + + /// Computes the smooth L1 loss with reduction over specified dimensions. + /// + /// Calculates element-wise smooth L1 loss, then takes the mean + /// over the specified dimensions. Useful for per-sample or per-channel losses. + /// + /// Dimensions can be provided in any order. They are sorted internally and + /// reduced from highest to lowest to ensure indices remain valid. + /// + /// # Arguments + /// + /// - `predictions` - The model's predicted values. + /// - `targets` - The ground truth target values. + /// - `dims` - Dimensions to reduce over. + /// + /// # Returns + /// + /// A tensor with the specified dimensions reduced to size 1. + /// + /// # Example + /// + /// ```ignore + /// // Consider image tensor with shape [batch, C, H, W] + /// let smooth_l1 = SmoothL1LossConfig::new().init(); + /// + /// // Per-image loss: reduce over C, H, W → [batch, 1, 1, 1] + /// let loss_per_image = smooth_l1.forward_reduce_dims(predictions, targets, &[1, 2, 3]); + /// ``` + pub fn forward_reduce_dims( + &self, + predictions: Tensor, + targets: Tensor, + dims: &[usize], + ) -> Tensor { + let error = self.forward(predictions, targets); + + // Sort the dimensions to ascending order + let mut sorted_dims = dims.to_vec(); + sorted_dims.sort(); + + // Reduce over specified dimensions + error.mean_dims(sorted_dims.as_slice()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + + type FT = f32; + + // ========================================================================= + // Configuration Tests + // ========================================================================= + + #[test] + fn test_smooth_l1_config_default_beta() { + let loss = SmoothL1LossConfig::new().init(); + assert_eq!(loss.beta, 1.0); + } + + #[test] + fn test_smooth_l1_config_custom_beta() { + let loss = SmoothL1LossConfig::new().with_beta(2.5).init(); + assert_eq!(loss.beta, 2.5); + } + + #[test] + #[should_panic(expected = "The parameter beta must be positive")] + fn test_smooth_l1_config_beta_zero_panics() { + SmoothL1LossConfig::new().with_beta(0.0).init(); + } + + #[test] + #[should_panic(expected = "The parameter beta must be positive")] + fn test_smooth_l1_config_beta_negative_panics() { + SmoothL1LossConfig::new().with_beta(-1.0).init(); + } + + // ========================================================================= + // Forward Pass (Element-wise) Tests + // ========================================================================= + + #[test] + fn test_smooth_l1_forward_l2_region() { + // Beta = 1.0, errors = 0.0 and 0.5 (both < beta, use L2 formula) + // L2 formula: 0.5 * error^2 / beta + // error = 0.0 -> loss = 0.5 * 0.0 / 1.0 = 0.0 + // error = 0.5 -> loss = 0.5 * 0.25 / 1.0 = 0.125 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 0.5]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 0.0]]), &device); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([[0.0_f32, 0.125]]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_forward_l1_region() { + // Beta = 1.0, errors = 0.0 and 2.0 (2.0 >= beta, use L1 formula) + // L1 formula: |error| - 0.5 * beta + // L2 formula: 0.5 * (error)^2 / beta + // error = 0.0 -> loss = 0.0 + // error = 2.0 -> loss = 2.0 - 0.5 = 1.5 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 2.0]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 0.0]]), &device); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([[0.0_f32, 1.5]]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_forward_zero_error() { + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<2>::from_data(TensorData::from([[1.0_f32, 2.0, 3.0]]), &device); + let targets = predictions.clone(); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([[0.0_f32, 0.0, 0.0]]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_forward_negative_errors() { + // Ensure absolute value is used correctly + // L1 formula: |error| - 0.5 * beta + // L2 formula: 0.5 * (error)^2 / beta + // Beta = 1.0, error = -3.0 (L1: 3.0 - 0.5 = 2.5) + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<1>::from_data(TensorData::from([-3.0_f32]), &device); + let targets = Tensor::<1>::zeros([1], &device); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([2.5_f32]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_forward_mixed_regions() { + // Test with errors in both L1 and L2 regions + // Beta = 1.0 + // L1 formula: |error| - 0.5 * beta + // L2 formula: 0.5 * (error)^2 / beta + // error = 0.5 -> L2: 0.5 * 0.25 / 1 = 0.125 + // error = 1.5 -> L1: 1.5 - 0.5 = 1.0 + // error = 3.0 -> L1: 3.0 - 0.5 = 2.5 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<1>::from_data(TensorData::from([0.5_f32, 1.5, 3.0]), &device); + let targets = Tensor::<1>::zeros([3], &device); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([0.125_f32, 1.0, 2.5]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_custom_beta_values() { + // Test with beta = 0.5 + // error = 0.25 (< beta): L2 = 0.5 * 0.0625 / 0.5 = 0.0625 + // error = 1.0 (>= beta): L1 = 1.0 - 0.25 = 0.75 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().with_beta(0.5).init(); + + let predictions = Tensor::<1>::from_data(TensorData::from([0.25_f32, 1.0]), &device); + let targets = Tensor::<1>::zeros([2], &device); + + let output = loss.forward(predictions, targets); + let expected = TensorData::from([0.0625_f32, 0.75]); + output.into_data().assert_eq(&expected, false); + } + + // ========================================================================= + // forward_with_reduction Tests + // ========================================================================= + + #[test] + fn test_smooth_l1_reduction_mean() { + // Errors: 0.5 (L2: 0.125), 2.0 (L1: 1.5) + // Mean: (0.125 + 1.5) / 2 = 0.8125 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<2>::from_data(TensorData::from([[0.5_f32, 2.0]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 0.0]]), &device); + + let output = loss.forward_with_reduction(predictions, targets, Reduction::Mean); + let expected = TensorData::from([0.8125_f32]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_reduction_sum() { + // Errors: 0.5 (L2: 0.125), 2.0 (L1: 1.5) + // Sum: 1.625 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<2>::from_data(TensorData::from([[0.5_f32, 2.0]]), &device); + let targets = Tensor::<2>::from_data(TensorData::from([[0.0_f32, 0.0]]), &device); + + let output = loss.forward_with_reduction(predictions, targets, Reduction::Sum); + let expected = TensorData::from([1.625_f32]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_reduction_auto_equals_mean() { + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<1>::from_data(TensorData::from([2.0_f32]), &device); + let targets = Tensor::<1>::zeros([1], &device); + + let mean_out = + loss.forward_with_reduction(predictions.clone(), targets.clone(), Reduction::Mean); + let auto_out = loss.forward_with_reduction(predictions, targets, Reduction::Auto); + + mean_out.into_data().assert_eq(&auto_out.into_data(), false); + } + + // ========================================================================= + // Dimension Reduction Tests + // ========================================================================= + + #[test] + fn test_smooth_l1_forward_reduce_dims_single_dim() { + // Beta = 2.0 + // L1 formula: |error| - 0.5 * beta + // L2 formula: 0.5 * (error)^2 / beta + // Row 0: errors [0.0, 1.0, 4.0] + // error = 0.0 -> L2: 0.0 + // error = 1.0 -> L2: 0.5 * 1.0 / 2.0 = 0.25 + // error = 4.0 -> L1: 4.0 - 1.0 = 3.0 + // Mean = 3.25 / 3 = 1.083333... + // Row 1: errors [0.0, 0.0, 0.0] -> Mean = 0.0 + let device = Default::default(); + let loss = SmoothL1LossConfig::new().with_beta(2.0).init(); + + let predictions = Tensor::<2>::from_data( + TensorData::from([[0.0_f32, 1.0, 4.0], [5.0_f32, 5.0, 5.0]]), + &device, + ); + let targets = Tensor::<2>::from_data( + TensorData::from([[0.0_f32, 0.0, 0.0], [5.0_f32, 5.0, 5.0]]), + &device, + ); + + let output = loss.forward_reduce_dims(predictions, targets, &[1]); + let expected = TensorData::from([[3.25_f32 / 3.0], [0.0]]); // 3.25/3 = 1.0833... + output + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_smooth_l1_forward_reduce_dims_image_batch() { + // Simulate per-image Smooth L1 loss for [batch, C, H, W] tensor + // (common in object detection like Fast R-CNN) + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); // beta = 1.0 + + // Shape: [2, 1, 2, 2] (batch=2, C=1, H=2, W=2) + let predictions = Tensor::<4>::from_data( + TensorData::from([ + [[[0.5_f32, 2.0], [0.0, 3.0]]], // Image 1 + [[[1.0_f32, 0.0], [0.5, 1.5]]], // Image 2 + ]), + &device, + ); + let targets = Tensor::<4>::zeros([2, 1, 2, 2], &device); + + // Reduce over C, H, W (dims 1, 2, 3) to get per-image loss + let output = loss.forward_reduce_dims(predictions, targets, &[1, 2, 3]); + + // Image 1: losses [[0.125, 1.5], [0.0, 2.5]] -> mean: 4.125 / 4 = 1.03125 + // Image 2: losses [[0.5, 0.0], [0.125, 1.0]] -> mean: 1.625 / 4 = 0.40625 + let expected = TensorData::from([[[[1.03125_f32]]], [[[0.40625_f32]]]]); + output.into_data().assert_eq(&expected, false); + } + + #[test] + fn test_smooth_l1_forward_reduce_dims_unsorted() { + // Test that unsorted dimensions are handled correctly (sorted internally) + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = Tensor::<3>::from_data( + TensorData::from([[[1.0_f32, 2.0], [3.0, 4.0]], [[5.0_f32, 6.0], [7.0, 8.0]]]), + &device, + ); + let targets = Tensor::<3>::zeros([2, 2, 2], &device); + + // Pass dims in reverse order + let output = loss.forward_reduce_dims(predictions.clone(), targets.clone(), &[2, 1]); + let expected_output = loss.forward_reduce_dims(predictions, targets, &[1, 2]); + + output + .into_data() + .assert_eq(&expected_output.into_data(), false); + } + + #[test] + fn test_smooth_l1_forward_reduce_dims_empty_dims() { + // Reducing over no dimensions should return the unreduced loss + let device = Default::default(); + let loss = SmoothL1LossConfig::new().init(); + + let predictions = + Tensor::<2>::from_data(TensorData::from([[0.5_f32, 2.0], [0.0, 3.0]]), &device); + let targets = Tensor::<2>::zeros([2, 2], &device); + + let loss_reduce_dims = loss.forward_reduce_dims(predictions.clone(), targets.clone(), &[]); + let loss_no_reduction = loss.forward(predictions, targets); + + loss_reduce_dims + .into_data() + .assert_eq(&loss_no_reduction.into_data(), false); + } +} diff --git a/crates/burn-nn/src/modules/attention/cross_attention.rs b/crates/burn-nn/src/modules/attention/cross_attention.rs new file mode 100644 index 0000000..d555588 --- /dev/null +++ b/crates/burn-nn/src/modules/attention/cross_attention.rs @@ -0,0 +1,618 @@ +//! Cross-Attention Module for Burn +//! +//! Features: +//! - Asymmetric Input Shapes (Query vs Context) +//! - Grouped Query Attention (GQA) & Multi-Query Attention (MQA) support +//! - Quantization-Safe Masking (min_float) +//! - Sparse-Ready (quiet_softmax) +//! - KV Caching for Streaming Inference + +use crate::cache::TensorCache; +use crate::modules::{Linear, LinearConfig}; +use crate::{Dropout, DropoutConfig}; +use burn_core as burn; + +use burn::{ + config::Config, + module::{Initializer, Module}, + tensor::{ + Bool, Device, Tensor, + activation::{quiet_softmax, softmax}, + }, +}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +#[derive(Config, Debug)] +/// Configuration to create a [CrossAttention](CrossAttention) layer using the [init function](CrossAttentionConfig::init). +pub struct CrossAttentionConfig { + /// Dimension of the Query (e.g., Decoder state). + pub d_model: usize, + /// Dimension of the Context (e.g., Encoder audio embeddings). + pub d_context: usize, + /// Number of heads for the Query. + pub n_heads: usize, + /// Number of heads for Key/Value (Set to 1 for MQA, set to n_heads for MHA). + pub n_heads_kv: usize, + /// Dimension of a single head. + pub d_head: usize, + /// Dropout rate. + #[config(default = 0.1)] + pub dropout: f64, + /// Masking value. Use -1.0e4 for f16/bf16 safety. + #[config(default = -1.0e4)] + pub min_float: f64, + /// Use quiet_softmax to allow zero-attention (good for sparse/quantized models). + #[config(default = false)] + pub quiet_softmax: bool, +} + +#[derive(Module, Debug)] +/// The Cross attention module +/// +/// # Params +/// +/// - `query`: [`Linear`] layer with `d_model` input and output features. +/// - `key`: [`Linear`] layer with `d_model` input and output features. +/// - `value`: [`Linear`] layer with `d_model` input and output features. +/// - `output`: [`Linear`] layer with `d_model` input and output features. +/// +/// Should be created with [CrossAttentionConfig]. +pub struct CrossAttention { + query: Linear, + key: Linear, + value: Linear, + output: Linear, + dropout: Dropout, + + n_heads: usize, + n_heads_kv: usize, + d_head: usize, + scale: f64, + min_float: f64, + quiet_softmax: bool, +} + +/// Cache for the [Cross Attention](CrossAttention) layer. +/// +/// To be used during inference when context is constant. +pub struct CrossAttentionCache { + /// Cached key tensor. + pub k: TensorCache<4>, + /// Cached value tensor. + pub v: TensorCache<4>, +} + +impl CrossAttentionCache { + /// Create a new empty cache. + pub fn new() -> Self { + Self { + k: TensorCache::empty(), + v: TensorCache::empty(), + } + } +} + +impl Default for CrossAttentionCache { + fn default() -> Self { + Self::new() + } +} + +impl CrossAttentionConfig { + /// Initializes a new cross-attention module. + /// + /// # Arguments + /// + /// * `device` - The device on which to initialize the module. + /// + /// # Returns + /// + /// A new [CrossAttention] module. + pub fn init(&self, device: &Device) -> CrossAttention { + // Safety Rail for GQA + assert_eq!( + self.n_heads % self.n_heads_kv, + 0, + "Query heads must be divisible by KV heads" + ); + + let init_linear = |in_dim, out_dim| { + LinearConfig::new(in_dim, out_dim) + .with_initializer(Initializer::KaimingUniform { + gain: 1.0 / (self.d_head as f64).sqrt(), + fan_out_only: false, + }) + .init(device) + }; + + CrossAttention { + // ADVICE: Asymmetric Projections + query: init_linear(self.d_model, self.n_heads * self.d_head), + key: init_linear(self.d_context, self.n_heads_kv * self.d_head), + value: init_linear(self.d_context, self.n_heads_kv * self.d_head), + output: init_linear(self.n_heads * self.d_head, self.d_model), + + dropout: DropoutConfig::new(self.dropout).init(), + n_heads: self.n_heads, + n_heads_kv: self.n_heads_kv, + d_head: self.d_head, + scale: (self.d_head as f64).sqrt().recip(), + min_float: self.min_float, + quiet_softmax: self.quiet_softmax, + } + } +} + +impl CrossAttention { + /// Applies cross-attention to query using context as key and value. + /// + /// # Arguments + /// + /// * `query` - Query tensor of shape `[batch, seq_len_query, d_model]`. + /// * `context` - Context tensor of shape `[batch, seq_len_context, d_context]`. + /// * `mask` - Optional attention mask of shape `[batch, seq_len_context]` where `true` indicates positions to mask. + /// + /// # Returns + /// + /// Output tensor of shape `[batch, seq_len_query, d_model]`. + pub fn forward( + &self, + query: Tensor<3>, + context: Tensor<3>, + mask: Option>, + ) -> Tensor<3> { + let [batch, l_q, _] = query.dims(); + let [_, l_k, _] = context.dims(); + + // 1. Projections + let q = self.query.forward(query); + let k = self.key.forward(context.clone()); + let v = self.value.forward(context); + + // 2. Reshape Heads + // Q: [Batch, Heads, L_q, D_head] + let q = q + .reshape([batch, l_q, self.n_heads, self.d_head]) + .swap_dims(1, 2); + + // K, V: [Batch, Heads_KV, L_k, D_head] + let k = k + .reshape([batch, l_k, self.n_heads_kv, self.d_head]) + .swap_dims(1, 2); + let v = v + .reshape([batch, l_k, self.n_heads_kv, self.d_head]) + .swap_dims(1, 2); + + // 3. GQA Expansion + // ADVICE: Handle GQA by repeating KV heads to match Query heads + let (k, v) = if self.n_heads != self.n_heads_kv { + let n_rep = self.n_heads / self.n_heads_kv; + (self.repeat_kv(k, n_rep), self.repeat_kv(v, n_rep)) + } else { + (k, v) + }; + + // 4. Score Calculation + let scores = q.matmul(k.transpose()) * self.scale; + + // 5. Masking + // ADVICE: Use min_float for F16/FP8 safety + let scores = if let Some(mask) = mask { + let mask = mask.reshape([batch, 1, 1, l_k]); + scores.mask_fill(mask, self.min_float) + } else { + scores + }; + + // 6. Softmax + // ADVICE: Optional Quiet Softmax for sparse networks + let weights = if self.quiet_softmax { + quiet_softmax(scores, 3) + } else { + softmax(scores, 3) + }; + + let weights = self.dropout.forward(weights); + + // 7. Aggregate & Output + let output = weights.matmul(v); + let output = output + .swap_dims(1, 2) + .reshape([batch, l_q, self.n_heads * self.d_head]); + + self.output.forward(output) + } + + /// Applies cross-attention to query using context as key and value. + /// + /// This method uses a cache to avoid recomputing key and value tensors when the context is the same. + /// + /// # Arguments + /// + /// * `query` - Query tensor of shape `[batch, seq_len_query, d_model]`. + /// * `context` - Context tensor of shape `[batch, seq_len_context, d_context]`. + /// * `mask` - Optional attention mask of shape `[batch, seq_len_context]` where `true` indicates positions to mask. + /// * `cache` - The cache to use. + /// + /// # Returns + /// + /// Output tensor of shape `[batch, seq_len_query, d_model]`. + pub fn forward_cache( + &self, + query: Tensor<3>, + context: Tensor<3>, + mask: Option>, + cache: &mut CrossAttentionCache, + ) -> Tensor<3> { + let [batch, l_q, _] = query.dims(); + + // 1. Projections + let q = self.query.forward(query); + + let k_compute = |context: Tensor<3>| { + let [batch, l_k, _] = context.dims(); + self.key + .forward(context) + .reshape([batch, l_k, self.n_heads_kv, self.d_head]) + .swap_dims(1, 2) + }; + let v_compute = |context: Tensor<3>| { + let [batch, l_k, _] = context.dims(); + self.value + .forward(context) + .reshape([batch, l_k, self.n_heads_kv, self.d_head]) + .swap_dims(1, 2) + }; + + let k = cache.k.forward_full(context.clone(), k_compute); + let v = cache.v.forward_full(context, v_compute); + + let [_, _, l_k, _] = k.dims(); + + // 2. Reshape Heads + // Q: [Batch, Heads, L_q, D_head] + let q = q + .reshape([batch, l_q, self.n_heads, self.d_head]) + .swap_dims(1, 2); + + // K, V are already in their correct shape from k_compute and v_compute + + // 3. GQA Expansion + // ADVICE: Handle GQA by repeating KV heads to match Query heads + let (k, v) = if self.n_heads != self.n_heads_kv { + let n_rep = self.n_heads / self.n_heads_kv; + (self.repeat_kv(k, n_rep), self.repeat_kv(v, n_rep)) + } else { + (k, v) + }; + + // 4. Score Calculation + let scores = q.matmul(k.transpose()) * self.scale; + + // 5. Masking + // ADVICE: Use min_float for F16/FP8 safety + let scores = if let Some(mask) = mask { + let mask = mask.reshape([batch, 1, 1, l_k]); + scores.mask_fill(mask, self.min_float) + } else { + scores + }; + + // 6. Softmax + // ADVICE: Optional Quiet Softmax for sparse networks + let weights = if self.quiet_softmax { + quiet_softmax(scores, 3) + } else { + softmax(scores, 3) + }; + + let weights = self.dropout.forward(weights); + + // 7. Aggregate & Output + let output = weights.matmul(v); + let output = output + .swap_dims(1, 2) + .reshape([batch, l_q, self.n_heads * self.d_head]); + + self.output.forward(output) + } + + /// Helper for Grouped Query Attention + fn repeat_kv(&self, x: Tensor<4>, n_rep: usize) -> Tensor<4> { + let [b, h, l, d] = x.dims(); + x.reshape([b, h, 1, l, d]) + .expand([b, h, n_rep, l, d]) + .reshape([b, h * n_rep, l, d]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::{Distribution, Int, Shape, Tensor, Tolerance}; + + #[test] + fn test_cross_attention_mha_shapes() { + let [ + batch_size, + seq_len_query, + seq_len_context, + d_model, + d_context, + n_heads, + d_head, + ] = [7, 13, 15, 32, 40, 4, 8]; + let device = Default::default(); + let config = CrossAttentionConfig { + d_model, + d_context, + n_heads, + n_heads_kv: n_heads, // MHA case + d_head, + dropout: 0.1, + min_float: -1.0e4, + quiet_softmax: false, + }; + let cross_attn = config.init(&device); + + let query = Tensor::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + let context = Tensor::random( + [batch_size, seq_len_context, d_context], + Distribution::Default, + &device, + ); + + let output = cross_attn.forward(query, context, None); + + assert_eq!( + output.shape(), + Shape::new([batch_size, seq_len_query, d_model]), + "Output should have the correct shape", + ); + } + + #[test] + fn test_cross_attention_gqa_shapes() { + let [ + batch_size, + seq_len_query, + seq_len_context, + d_model, + d_context, + n_heads, + n_heads_kv, + d_head, + ] = [7, 13, 15, 32, 40, 4, 2, 8]; + let device = Default::default(); + let config = CrossAttentionConfig { + d_model, + d_context, + n_heads, + n_heads_kv, // GQA case + d_head, + dropout: 0.1, + min_float: -1.0e4, + quiet_softmax: false, + }; + let cross_attn = config.init(&device); + + let query = Tensor::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + let context = Tensor::random( + [batch_size, seq_len_context, d_context], + Distribution::Default, + &device, + ); + + let output = cross_attn.forward(query, context, None); + + assert_eq!( + output.shape(), + Shape::new([batch_size, seq_len_query, d_model]), + "Output should have the correct shape", + ); + } + + #[test] + fn test_cross_attention_mqa_shapes() { + let [ + batch_size, + seq_len_query, + seq_len_context, + d_model, + d_context, + n_heads, + d_head, + ] = [7, 13, 15, 32, 40, 4, 8]; + let device = Default::default(); + let config = CrossAttentionConfig { + d_model, + d_context, + n_heads, + n_heads_kv: 1, // MQA case + d_head, + dropout: 0.1, + min_float: -1.0e4, + quiet_softmax: false, + }; + let cross_attn = config.init(&device); + + let query = Tensor::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + let context = Tensor::random( + [batch_size, seq_len_context, d_context], + Distribution::Default, + &device, + ); + + let output = cross_attn.forward(query, context, None); + + assert_eq!( + output.shape(), + Shape::new([batch_size, seq_len_query, d_model]), + "Output should have the correct shape", + ); + } + + #[test] + fn test_cross_attention_mask() { + let [ + batch_size, + seq_len_query, + seq_len_context, + d_model, + d_context, + n_heads, + d_head, + ] = [3, 6, 8, 12, 16, 4, 3]; + let num_padded = 2; + let device = Default::default(); + let config = CrossAttentionConfig { + d_model, + d_context, + n_heads, + n_heads_kv: n_heads, + d_head, + dropout: 0.0, // No dropout for deterministic test + min_float: -1.0e4, + quiet_softmax: false, + }; + let cross_attn = config.init(&device); + + // Create a padding mask for the context + let mut mask: Tensor<2, Int> = Tensor::zeros([batch_size, seq_len_context], &device); + mask = mask.slice_assign( + [0..batch_size, seq_len_context - num_padded..seq_len_context], + Tensor::ones([batch_size, num_padded], &device), + ); + let mask_bool = mask.equal_elem(1); + + let query = Tensor::<3>::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + + let context_1 = Tensor::<3>::random( + [batch_size, seq_len_context, d_context], + Distribution::Default, + &device, + ); + + // Change the padded part of the context tensor + let context_2 = context_1.clone().slice_assign( + [ + 0..batch_size, + seq_len_context - num_padded..seq_len_context, + 0..d_context, + ], + Tensor::random( + [batch_size, num_padded, d_context], + Distribution::Default, + &device, + ), + ); + + // The outputs should be the same since the changed part is masked. + let output_1 = cross_attn.forward(query.clone(), context_1, Some(mask_bool.clone())); + let output_2 = cross_attn.forward(query, context_2, Some(mask_bool)); + + output_1 + .into_data() + .assert_approx_eq(&output_2.into_data(), Tolerance::::default()); + } + + #[test] + #[should_panic] + fn test_gqa_panic_if_n_heads_not_divisible_by_n_heads_kv() { + let device = Default::default(); + let config = CrossAttentionConfig { + d_model: 32, + d_context: 32, + n_heads: 5, + n_heads_kv: 2, + d_head: 8, + dropout: 0.1, + min_float: -1.0e4, + quiet_softmax: false, + }; + config.init(&device); + } + + #[test] + fn test_cross_attention_cache() { + let [ + batch_size, + seq_len_query, + seq_len_context, + d_model, + d_context, + n_heads, + d_head, + ] = [3, 6, 8, 12, 16, 4, 3]; + let device = Default::default(); + let config = CrossAttentionConfig { + d_model, + d_context, + n_heads, + n_heads_kv: n_heads, + d_head, + dropout: 0.0, // No dropout for deterministic test + min_float: -1.0e4, + quiet_softmax: false, + }; + let cross_attn = config.init(&device); + + let query1 = Tensor::<3>::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + let context = Tensor::<3>::random( + [batch_size, seq_len_context, d_context], + Distribution::Default, + &device, + ); + + // First forward pass, no cache + let output1 = cross_attn.forward(query1.clone(), context.clone(), None); + + // Second forward pass with cache + let mut cache = CrossAttentionCache::new(); + let output2 = cross_attn.forward_cache(query1.clone(), context.clone(), None, &mut cache); + + // The two outputs should be identical + output1 + .into_data() + .assert_approx_eq(&output2.into_data(), Tolerance::::default()); + + // Third forward pass with different query, but same context and cache + let query2 = Tensor::<3>::random( + [batch_size, seq_len_query, d_model], + Distribution::Default, + &device, + ); + let output3 = cross_attn.forward_cache(query2.clone(), context.clone(), None, &mut cache); + + // For control, do a forward pass without cache with query2 + let output4 = cross_attn.forward(query2.clone(), context.clone(), None); + + // output3 and output4 should be identical + output3 + .into_data() + .assert_approx_eq(&output4.into_data(), Tolerance::::default()); + } +} diff --git a/crates/burn-nn/src/modules/attention/mask.rs b/crates/burn-nn/src/modules/attention/mask.rs new file mode 100644 index 0000000..f123027 --- /dev/null +++ b/crates/burn-nn/src/modules/attention/mask.rs @@ -0,0 +1,155 @@ +use burn_core as burn; +use burn_core::config::Config; + +use alloc::vec::Vec; + +use burn::tensor::{Bool, Device, Int, Shape, Tensor, TensorData}; + +/// Generate an autoregressive attention mask. +/// +/// The mask can be used in Transformer modules to train models to generate tensors sequentially. +pub fn generate_autoregressive_mask( + batch_size: usize, + seq_length: usize, + device: &Device, +) -> Tensor<3, Bool> { + let mask = Tensor::<2, Bool>::tril_mask([seq_length, seq_length], 0, device); + mask.expand([batch_size, seq_length, seq_length]) +} + +/// Generate a padding attention mask. +pub struct GeneratePaddingMask { + /// The generated tensor. + pub tensor: Tensor<2, Int>, + + /// The generated mask. + pub mask: Tensor<2, Bool>, +} + +/// Defines an enumeration to specify sequence length options for padding +#[derive(Config, Debug, Copy)] +pub enum SeqLengthOption { + /// No maximum length; use the longest sequence + NoMax, + /// Maximum length specified, truncate if necessary + Max(usize), + /// Fixed length, pad or truncate to this exact length + Fixed(usize), +} + +impl From> for SeqLengthOption { + fn from(val: Option) -> Self { + match val { + Some(max) => SeqLengthOption::Max(max), + None => SeqLengthOption::NoMax, + } + } +} + +/// Generates a padding attention mask for a batch of token sequences. +/// +/// # Arguments +/// +/// * `pad_token` - The token ID used for padding +/// * `tokens_list` - Vector of token sequences (each sequence is a vector of token IDs) +/// * `seq_length` - Sequence length option (NoMax, Max, or Fixed) +/// * `device` - The device for tensor operations +/// +/// # Returns +/// +/// A `GeneratePaddingMask` containing the padded tensor and corresponding mask +pub fn generate_padding_mask( + pad_token: usize, + tokens_list: Vec>, + seq_length: impl Into, + device: &Device, +) -> GeneratePaddingMask { + let tokens_max = || { + tokens_list + .iter() + .map(|tokens| tokens.len()) + .max() + .unwrap_or(1) + }; + + let size = match seq_length.into() { + SeqLengthOption::NoMax => tokens_max(), + SeqLengthOption::Max(max) => usize::min(tokens_max(), max), + SeqLengthOption::Fixed(limit) => limit, + }; + let batch_size = tokens_list.len(); + + let mut tensor = Tensor::zeros([batch_size, size], device); + tensor = tensor.add_scalar(pad_token as i64); + + for (index, tokens) in tokens_list.into_iter().enumerate() { + let seq_length = tokens.len().min(size); + tensor = tensor.slice_assign( + [index..index + 1, 0..seq_length], + Tensor::from_data( + TensorData::new( + tokens.into_iter().take(size).map(|e| e as i64).collect(), + Shape::new([1, seq_length]), + ), + device, + ), + ); + } + + let mask = tensor.clone().equal_elem(pad_token as i64); + + GeneratePaddingMask { tensor, mask } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use burn::tensor::TensorData; + + #[test] + fn test_generate_autoregressive_mask() { + let device = Default::default(); + + let mask = generate_autoregressive_mask(2, 3, &device); + + mask.into_data().assert_eq( + &TensorData::from([ + [ + [false, true, true], + [false, false, true], + [false, false, false], + ], + [ + [false, true, true], + [false, false, true], + [false, false, false], + ], + ]), + false, + ); + } + + #[test] + fn test_generate_padding_mask() { + let device = Default::default(); + let tokens = vec![ + vec![3, 3, 3], + vec![3, 3, 3], + vec![3, 3, 3, 4], + vec![3, 3, 3, 4, 10, 15], + ]; + + let mask = generate_padding_mask(0, tokens, None, &device); + + mask.mask.into_data().assert_eq( + &TensorData::from([ + [false, false, false, true, true, true], + [false, false, false, true, true, true], + [false, false, false, false, true, true], + [false, false, false, false, false, false], + ]), + false, + ); + } +} diff --git a/crates/burn-nn/src/modules/attention/mha.rs b/crates/burn-nn/src/modules/attention/mha.rs new file mode 100644 index 0000000..51fc2ee --- /dev/null +++ b/crates/burn-nn/src/modules/attention/mha.rs @@ -0,0 +1,578 @@ +use burn_core as burn; + +use crate::activation::Gelu; +use crate::cache::TensorCache; +use crate::{Dropout, DropoutConfig, Linear, LinearConfig}; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::{Bool, Device, Tensor}; + +use burn::tensor::activation::{quiet_softmax, softmax}; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Configuration to create a [Multi Head Attention](MultiHeadAttention) layer using the [init function](MultiHeadAttentionConfig::init). +#[derive(Config, Debug)] +pub struct MultiHeadAttentionConfig { + /// The size of each linear layer. + pub d_model: usize, + /// The number of heads. + pub n_heads: usize, + /// The dropout rate. Default: 0.1 + #[config(default = 0.1)] + pub dropout: f64, + /// The minimum value a float can take. Default: -1.0e4 + /// This is used to mask attention scores before calculating attention weights. + /// A value too low might result in NaN. + #[config(default = -1.0e4)] + pub min_float: f64, + /// Use "quiet softmax" instead of regular softmax. + /// + /// - Usage may improve performance by allowing attention heads to deposit no information (if the sequence contains no information relevant to that head). + /// - Usage may reduce the entropy of weights in the model, enhancing quantization and compression. + /// + /// Reference: + #[config(default = false)] + pub quiet_softmax: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + + /// Enable bias for the key [Linear] layer. + #[config(default = true)] + pub key_bias: bool, + + /// Enable bias for the value [Linear] layer. + #[config(default = true)] + pub value_bias: bool, + + /// Enable bias for the query [Linear] layer. + #[config(default = true)] + pub query_bias: bool, + + /// Enable bias for the output [Linear] layer. + #[config(default = true)] + pub output_bias: bool, +} + +/// The multihead attention module as describe in the paper [Attention Is All You Need](https://arxiv.org/abs/1706.03762). +/// +/// # Params +/// +/// - `query`: [`Linear`] layer with `d_model` input and output features. +/// - `key`: [`Linear`] layer with `d_model` input and output features. +/// - `value`: [`Linear`] layer with `d_model` input and output features. +/// - `output`: [`Linear`] layer with `d_model` input and output features. +/// +/// Should be created with [MultiHeadAttentionConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct MultiHeadAttention { + /// Linear layer to transform the input features into the query space. + pub query: Linear, + /// Linear layer to transform the input features into the key space. + pub key: Linear, + /// Linear layer to transform the input features into the value space. + pub value: Linear, + /// Linear layer to transform the output features back to the original space. + pub output: Linear, + /// Dropout layer. + pub dropout: Dropout, + /// Activation function. + pub activation: Gelu, + /// The size of each linear layer. + pub d_model: usize, + /// The number of heads. + pub n_heads: usize, + /// Size of the key and query vectors. + pub d_k: usize, + /// Minimum value a float can take. + pub min_float: f64, + /// Use "quiet softmax" instead of regular softmax. + pub quiet_softmax: bool, +} + +impl ModuleDisplay for MultiHeadAttention { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("d_model", &self.d_model) + .add("n_heads", &self.n_heads) + .add("d_k", &self.d_k) + .add("dropout", &self.dropout.prob) + .add("min_float", &self.min_float) + .add("quiet_softmax", &self.quiet_softmax) + .optional() + } +} + +/// [Multihead attention](MultiHeadAttention) forward pass input argument. +#[derive(Debug, Clone)] +pub struct MhaInput { + /// Shape `[batch_size, seq_length_1, d_model]` + query: Tensor<3>, + /// Shape `[batch_size, seq_length_2, d_model]` + key: Tensor<3>, + /// Shape `[batch_size, seq_length_2, d_model]` + value: Tensor<3>, + mask_pad: Option>, + mask_attn: Option>, +} + +impl MultiHeadAttentionConfig { + /// Initialize a new [multihead attention](MultiHeadAttention) module. + pub fn init(&self, device: &Device) -> MultiHeadAttention { + let linear_cfg = LinearConfig::new(self.d_model, self.d_model) + .with_initializer(self.initializer.clone()); + + MultiHeadAttention { + query: linear_cfg.clone().with_bias(self.query_bias).init(device), + key: linear_cfg.clone().with_bias(self.key_bias).init(device), + value: linear_cfg.clone().with_bias(self.value_bias).init(device), + output: linear_cfg.clone().with_bias(self.output_bias).init(device), + dropout: DropoutConfig::new(self.dropout).init(), + activation: Gelu::new(), + n_heads: self.n_heads, + d_k: self.d_model / self.n_heads, + min_float: self.min_float, + quiet_softmax: self.quiet_softmax, + d_model: self.d_model, + } + } +} + +impl MhaInput { + /// Create a [multihead attention](MultiHeadAttention) input argument + /// by setting the query, key and value to the given tensor. + /// + /// # Shape + /// - tensor: `[batch_size, seq_length, d_model]` + pub fn self_attn(tensor: Tensor<3>) -> Self { + Self { + query: tensor.clone(), + key: tensor.clone(), + value: tensor, + mask_pad: None, + mask_attn: None, + } + } + + /// Create a [multihead attention](MultiHeadAttention) input argument. + pub fn new(query: Tensor<3>, key: Tensor<3>, value: Tensor<3>) -> Self { + Self { + query, + key, + value, + mask_pad: None, + mask_attn: None, + } + } + + /// Register the padding mask. + pub fn mask_pad(mut self, mask_pad: Tensor<2, Bool>) -> Self { + self.mask_pad = Some(mask_pad); + self + } + + /// Register the attention mask. + pub fn mask_attn(mut self, mask_attn: Tensor<3, Bool>) -> Self { + self.mask_attn = Some(mask_attn); + self + } +} + +/// [Multihead attention](MultiHeadAttention) outputs. +#[derive(Debug, Clone)] +pub struct MhaOutput { + /// The attention weights `[batch_size, n_heads, seq_length_1, seq_length_2]`. + pub weights: Tensor<4>, + /// The context tensor `[batch_size, seq_length_1, d_model]`. + pub context: Tensor<3>, +} + +impl MultiHeadAttention { + /// Applies the forward pass on the input tensors. + /// + /// See [MultiHeadAttention](MultiHeadAttention) for more information. + /// + /// # Shapes + /// + /// - query: `[batch_size, seq_length_1, d_model]` + /// - key: `[batch_size, seq_length_2, d_model]` + /// - value: `[batch_size, seq_length_2, d_model]` + /// - output: `[batch_size, seq_length_1, d_model]` + pub fn forward(&self, input: MhaInput) -> MhaOutput { + let [batch_size, seq_length_1, d_model] = input.query.dims(); + + let query = self.attention_linear(input.query, &self.query); + let key = self.attention_linear(input.key, &self.key); + let value = self.attention_linear(input.value, &self.value); + + let attn_scores = self.attn_scores(query, key); + let weights = self.attn_weights(attn_scores, input.mask_pad, input.mask_attn); + + let context = weights.clone().matmul(value); + let context = context + .swap_dims(1, 2) + .reshape([batch_size, seq_length_1, d_model]); + let context = self.output.forward(context); + + MhaOutput { weights, context } + } + + /// Applies the forward pass using a cache. + /// + /// # Shapes + /// + /// - query: `[batch_size, seq_length_1, d_model]` + /// - key: `[batch_size, seq_length_2, d_model]` + /// - value: `[batch_size, seq_length_2, d_model]` + /// - output: `[batch_size, seq_length_1, d_model]` + pub fn forward_cache(&self, input: MhaInput, cache: &mut MhaCache) -> MhaOutput { + let [batch_size, seq_length_1, d_model] = input.query.dims(); + + let query = cache + .query + .forward(input.query, |t| self.attention_linear(t, &self.query)); + let key = cache + .key + .forward(input.key, |t| self.attention_linear(t, &self.key)); + let value = cache + .value + .forward(input.value, |t| self.attention_linear(t, &self.value)); + + let attn_scores = self.attn_scores(query, key); + let weights = self.attn_weights(attn_scores, input.mask_pad, input.mask_attn); + + let context = weights.clone().matmul(value); + let context = context + .swap_dims(1, 2) + .reshape([batch_size, seq_length_1, d_model]); + + let context = cache.output.forward(context, |t| self.output.forward(t)); + + MhaOutput { weights, context } + } + + fn attn_scores(&self, query: Tensor<4>, key: Tensor<4>) -> Tensor<4> { + let attn_scores = query + .matmul(key.transpose()) + .div_scalar((self.d_k as f32).sqrt()); + + self.dropout.forward(attn_scores) + } + + fn attn_weights( + &self, + mut attn_scores: Tensor<4>, + mask_pad: Option>, + mask_attn: Option>, + ) -> Tensor<4> { + if let Some(mask_pad) = mask_pad { + let [batch_size, seq_length] = mask_pad.dims(); + + attn_scores = attn_scores.mask_fill( + mask_pad.reshape([batch_size, 1, 1, seq_length]), + self.min_float, + ); + } + + if let Some(mask_attn) = mask_attn { + let [batch_size, seq_length_1, seq_length_2] = mask_attn.dims(); + + attn_scores = attn_scores.mask_fill( + mask_attn.reshape([batch_size, 1, seq_length_1, seq_length_2]), + self.min_float, + ); + } + + if self.quiet_softmax { + quiet_softmax(attn_scores, 3) + } else { + softmax(attn_scores, 3) + } + } + + fn attention_linear(&self, x: Tensor<3>, linear: &Linear) -> Tensor<4> { + let [batch_size, seq_length, _d_model] = x.dims(); + linear + .forward(x) + .reshape([batch_size, seq_length, self.n_heads, self.d_k]) + .swap_dims(1, 2) + } +} + +/// Cache for the [Multi Head Attention](MultiHeadAttention) layer. +/// +/// To be used during inference when decoding tokens. +pub struct MhaCache { + query: MhaLinearCache<4>, + key: MhaLinearCache<4>, + value: MhaLinearCache<4>, + output: MhaLinearCache<3>, +} + +enum MhaLinearCache { + Autoregressive(TensorCache, usize), + Full(TensorCache), +} + +impl MhaCache { + /// Initialize a cache for autoregressive inference. + pub fn autoregressive() -> Self { + Self { + query: MhaLinearCache::Autoregressive(TensorCache::empty(), 2), + key: MhaLinearCache::Autoregressive(TensorCache::empty(), 2), + value: MhaLinearCache::Autoregressive(TensorCache::empty(), 2), + output: MhaLinearCache::Autoregressive(TensorCache::empty(), 1), + } + } + + /// Initialize a cache for autoregressive inference, but with a fixed memory used for keys and + /// values (cross-attention). + pub fn autoregressive_cross_attention() -> Self { + Self { + query: MhaLinearCache::Autoregressive(TensorCache::empty(), 2), + key: MhaLinearCache::Full(TensorCache::empty()), + value: MhaLinearCache::Full(TensorCache::empty()), + output: MhaLinearCache::Autoregressive(TensorCache::empty(), 1), + } + } +} + +impl MhaLinearCache { + pub fn forward) -> Tensor>( + &mut self, + tensor: Tensor<3>, + func: F, + ) -> Tensor { + match self { + MhaLinearCache::Autoregressive(cache, dim) => { + cache.forward_autoregressive(tensor, *dim, func) + } + MhaLinearCache::Full(cache) => cache.forward_full(tensor, func), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attention::generate_autoregressive_mask; + use alloc::vec::Vec; + use burn::tensor::Int; + use burn::tensor::Tolerance; + use burn::tensor::{Distribution, Shape}; + + #[test] + fn test_enable_bias() { + let d_model = 32; + let n_heads = 4; + let device = Default::default(); + + let base_cfg = MultiHeadAttentionConfig::new(d_model, n_heads); + + // Default, everything is enabled. + let mha = base_cfg.init(&device); + assert!(mha.query.bias.is_some()); + assert!(mha.key.bias.is_some()); + assert!(mha.value.bias.is_some()); + assert!(mha.output.bias.is_some()); + + let mha = base_cfg.clone().with_query_bias(false).init(&device); + assert!(mha.query.bias.is_none()); + assert!(mha.key.bias.is_some()); + assert!(mha.value.bias.is_some()); + assert!(mha.output.bias.is_some()); + + let mha = base_cfg.clone().with_key_bias(false).init(&device); + assert!(mha.query.bias.is_some()); + assert!(mha.key.bias.is_none()); + assert!(mha.value.bias.is_some()); + assert!(mha.output.bias.is_some()); + + let mha = base_cfg.clone().with_value_bias(false).init(&device); + assert!(mha.query.bias.is_some()); + assert!(mha.key.bias.is_some()); + assert!(mha.value.bias.is_none()); + assert!(mha.output.bias.is_some()); + + let mha = base_cfg.clone().with_output_bias(false).init(&device); + assert!(mha.query.bias.is_some()); + assert!(mha.key.bias.is_some()); + assert!(mha.value.bias.is_some()); + assert!(mha.output.bias.is_none()); + } + + #[test] + fn test_self_attention_shapes() { + let [batch_size, seq_length, d_model, n_heads] = [7, 13, 32, 4]; + let device = Default::default(); + let mha = MultiHeadAttentionConfig::new(d_model, n_heads).init(&device); + let input = MhaInput::self_attn(Tensor::random( + [batch_size, seq_length, d_model], + Distribution::Default, + &device, + )); + + let output = mha.forward(input); + + assert_eq!( + output.context.shape(), + Shape::new([batch_size, seq_length, d_model]), + "Context should have the correct shape", + ); + assert_eq!( + output.weights.shape(), + Shape::new([batch_size, n_heads, seq_length, seq_length]), + "Weights should have the correct shape", + ); + } + + #[test] + fn test_generic_mha_shapes() { + let [batch_size, seq_length_1, seq_length_2, d_model, n_heads] = [7, 13, 15, 32, 4]; + let mha = MultiHeadAttentionConfig::new(d_model, n_heads).init(&Default::default()); + let device = Default::default(); + let input = MhaInput::new( + Tensor::random( + [batch_size, seq_length_1, d_model], + Distribution::Default, + &device, + ), + Tensor::random( + [batch_size, seq_length_2, d_model], + Distribution::Default, + &device, + ), + Tensor::random( + [batch_size, seq_length_2, d_model], + Distribution::Default, + &device, + ), + ); + + let output = mha.forward(input); + + assert_eq!( + output.context.shape(), + Shape::new([batch_size, seq_length_1, d_model]), + "Context should have the correct shape", + ); + assert_eq!( + output.weights.shape(), + Shape::new([batch_size, n_heads, seq_length_1, seq_length_2]), + "Weights should have the correct shape", + ); + } + + #[test] + fn test_self_attention_mask_pad() { + let [batch_size, seq_length, d_model, n_heads, num_padded] = [3, 6, 32, 2, 2]; + let device = Default::default(); + let mha = MultiHeadAttentionConfig::new(d_model, n_heads).init(&device); + + // Create a padding mask + let mask_pad: Tensor<2, Int> = Tensor::zeros([batch_size, seq_length], &device); + let mask_pad = mask_pad.slice_assign( + [0..batch_size, seq_length - num_padded..seq_length], + Tensor::ones([batch_size, num_padded], &device), + ); + let mask_pad = mask_pad.equal_elem(1).to_device(&device); + + let tensor_1 = Tensor::<3>::random( + [batch_size, seq_length, d_model], + Distribution::Default, + &device, + ); + // Change the end of the tensor + let tensor_2 = tensor_1.clone().slice_assign( + [ + 0..batch_size, + seq_length - num_padded..seq_length, + 0..d_model, + ], + Tensor::random( + [batch_size, num_padded, d_model], + Distribution::Default, + &device, + ), + ); + + let input_1 = MhaInput::self_attn(tensor_1).mask_pad(mask_pad.clone()); + let input_2 = MhaInput::self_attn(tensor_2).mask_pad(mask_pad); + + let output_1 = mha.forward(input_1); + let output_2 = mha.forward(input_2); + + // Check that the beginning of each tensor is the same + output_1 + .context + .slice([0..batch_size, 0..seq_length - num_padded, 0..d_model]) + .into_data() + .assert_approx_eq( + &output_2 + .context + .slice([0..batch_size, 0..seq_length - num_padded, 0..d_model]) + .into_data(), + Tolerance::::default(), + ); + } + + #[test] + fn test_autoregressive_mask_should_have_same_output_as_autoregressive_decoding() { + let [batch_size, seq_length, d_model, n_heads] = [3, 4, 12, 2]; + let device = Default::default(); + let mha = MultiHeadAttentionConfig::new(d_model, n_heads).init(&device); + + let tensor = Tensor::<3>::random( + [batch_size, seq_length, d_model], + Distribution::Default, + &device, + ); + let mask_attn = generate_autoregressive_mask(batch_size, seq_length, &tensor.device()); + let input = MhaInput::self_attn(tensor.clone()).mask_attn(mask_attn); + + let output_1 = mha.forward(input); + let mut output_2 = Vec::new(); + let mut cache = MhaCache::autoregressive(); + + for i in 1..seq_length + 1 { + let tensor = tensor.clone().slice([0..batch_size, 0..i, 0..d_model]); + let input = MhaInput::self_attn(tensor); + let next_tok = mha.forward_cache(input, &mut cache).context.slice([ + 0..batch_size, + i - 1..i, + 0..d_model, + ]); + output_2.push(next_tok); + } + + let output_2 = Tensor::cat(output_2, 1); + + output_1 + .context + .into_data() + .assert_approx_eq::(&output_2.into_data(), Tolerance::default()); + } + + #[test] + fn display() { + let config = MultiHeadAttentionConfig::new(2, 4); + let mha = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{mha}"), + "MultiHeadAttention {d_model: 2, n_heads: 4, d_k: 0, \ + dropout: 0.1, min_float: -10000, quiet_softmax: false, params: 24}" + ); + } +} diff --git a/crates/burn-nn/src/modules/attention/mod.rs b/crates/burn-nn/src/modules/attention/mod.rs new file mode 100644 index 0000000..460e473 --- /dev/null +++ b/crates/burn-nn/src/modules/attention/mod.rs @@ -0,0 +1,7 @@ +mod cross_attention; +mod mask; +mod mha; + +pub use cross_attention::*; +pub use mask::*; +pub use mha::*; diff --git a/crates/burn-nn/src/modules/cache/autoregressive.rs b/crates/burn-nn/src/modules/cache/autoregressive.rs new file mode 100644 index 0000000..1769741 --- /dev/null +++ b/crates/burn-nn/src/modules/cache/autoregressive.rs @@ -0,0 +1,51 @@ +use alloc::vec; +use burn_core as burn; + +use super::{CacheState, TensorCache}; +use burn::tensor::Tensor; + +impl TensorCache { + pub(crate) fn forward_autoregressive( + &mut self, + tensor: Tensor<3>, + dim_cat: usize, + func: F, + ) -> Tensor + where + F: Fn(Tensor<3>) -> Tensor, + { + let mut tensor_old = CacheState::Empty; + core::mem::swap(&mut self.state, &mut tensor_old); + + let tensor_new = match tensor_old { + CacheState::Value(tensor_old) => { + let [batch_size, seq_length, d_model] = tensor.dims(); + let next_seq_token = + tensor.slice([0..batch_size, (seq_length - 1)..seq_length, 0..d_model]); + let next_seq_token = func(next_seq_token); + + Tensor::cat(vec![tensor_old, next_seq_token], dim_cat) + } + _ => func(tensor), + }; + + self.state = CacheState::Value(tensor_new.clone()); + tensor_new + } + + pub(crate) fn forward_full(&mut self, tensor: Tensor<3>, func: F) -> Tensor + where + F: Fn(Tensor<3>) -> Tensor, + { + let mut tensor_old = CacheState::Empty; + core::mem::swap(&mut self.state, &mut tensor_old); + + let tensor_new = match tensor_old { + CacheState::Value(tensor_old) => tensor_old, + _ => func(tensor), + }; + + self.state = CacheState::Value(tensor_new.clone()); + tensor_new + } +} diff --git a/crates/burn-nn/src/modules/cache/base.rs b/crates/burn-nn/src/modules/cache/base.rs new file mode 100644 index 0000000..b0d547a --- /dev/null +++ b/crates/burn-nn/src/modules/cache/base.rs @@ -0,0 +1,26 @@ +use burn_core as burn; + +use burn::tensor::Tensor; + +pub(crate) enum CacheState { + Value(T), + Empty, +} + +/// A cache for a tensor. +pub struct TensorCache { + pub(crate) state: CacheState>, +} + +impl TensorCache { + /// Creates a new empty cache. + /// + /// # Returns + /// + /// The empty cache. + pub fn empty() -> Self { + Self { + state: CacheState::Empty, + } + } +} diff --git a/crates/burn-nn/src/modules/cache/mod.rs b/crates/burn-nn/src/modules/cache/mod.rs new file mode 100644 index 0000000..8050cb4 --- /dev/null +++ b/crates/burn-nn/src/modules/cache/mod.rs @@ -0,0 +1,4 @@ +mod autoregressive; +mod base; + +pub use base::*; diff --git a/crates/burn-nn/src/modules/conv/checks.rs b/crates/burn-nn/src/modules/conv/checks.rs new file mode 100644 index 0000000..0e24c27 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/checks.rs @@ -0,0 +1,22 @@ +pub(crate) fn checks_channels_div_groups(channels_in: usize, channels_out: usize, groups: usize) { + let channels_in_div_by_group = channels_in.is_multiple_of(groups); + let channels_out_div_by_group = channels_out.is_multiple_of(groups); + + if !channels_in_div_by_group || !channels_out_div_by_group { + panic!( + "Both channels must be divisible by the number of groups. Got \ + channels_in={channels_in}, channels_out={channels_out}, groups={groups}" + ); + } +} + +// https://github.com/tracel-ai/burn/issues/2676 +/// Only symmetric padding is currently supported. As such, using `Same` padding with an even kernel +/// size is not supported as it will not produce the same output size. +pub(crate) fn check_same_padding_support(kernel_size: &[usize]) { + for k in kernel_size.iter() { + if k % 2 == 0 { + unimplemented!("Same padding with an even kernel size is not supported"); + } + } +} diff --git a/crates/burn-nn/src/modules/conv/conv1d.rs b/crates/burn-nn/src/modules/conv/conv1d.rs new file mode 100644 index 0000000..bbbcf02 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv1d.rs @@ -0,0 +1,295 @@ +use alloc::format; + +use burn_core as burn; + +use crate::{PaddingConfig1d, conv::checks}; +use burn::tensor::{Device, Tensor, module::conv1d, ops::PaddedConvOptions}; +use burn::{ + config::Config, + module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay, Param}, +}; + +/// Configuration to create a [1D convolution](Conv1d) layer using the [init function](Conv1dConfig::init). +#[derive(Config, Debug)] +pub struct Conv1dConfig { + /// The number of input channels. + pub channels_in: usize, + /// The number of output channels. + pub channels_out: usize, + /// The size of the kernel. + pub kernel_size: usize, + /// The stride of the convolution. + #[config(default = "1")] + pub stride: usize, + /// Spacing between kernel elements. + #[config(default = "1")] + pub dilation: usize, + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig1d::Valid")] + pub padding: PaddingConfig1d, + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 1D convolution over input tensors. +/// +/// Should be created with [Conv1dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Conv1d { + /// Tensor of shape `[channels_out, channels_in / groups, kernel_size]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: usize, + /// Size of the kernel. + pub kernel_size: usize, + /// Spacing between kernel elements. + pub dilation: usize, + /// Controls the connections between input and output channels. + pub groups: usize, + /// Padding configuration. + #[module(skip)] + pub padding: PaddingConfig1d, +} + +impl ModuleDisplay for Conv1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + // Format stride/dilation as strings + let stride = format!("{:?}", self.stride); + let kernel_size = format!("{:?}", self.kernel_size); + let dilation = format!("{:?}", self.dilation); + + // Extract channels in/out from weight dims + let [channels_out, group_channels_in, _] = self.weight.dims(); + let channels_in = group_channels_in * self.groups; + let ch_out = format!("{:?}", channels_out); + let ch_in = format!("{:?}", channels_in); + + content + .add("ch_in", &ch_in) + .add("ch_out", &ch_out) + .add("stride", &stride) + .add("kernel_size", &kernel_size) + .add("dilation", &dilation) + .add("groups", &self.groups) + .add_debug_attribute("padding", &self.padding) + .optional() + } +} +impl Conv1dConfig { + /// Initialize a new [conv1d](Conv1d) module. + pub fn init(&self, device: &Device) -> Conv1d { + checks::checks_channels_div_groups(self.channels_in, self.channels_out, self.groups); + + let shape = [ + self.channels_out, + self.channels_in / self.groups, + self.kernel_size, + ]; + + let fan_in: usize = self.channels_in / self.groups * self.kernel_size; + let fan_out = self.channels_out / self.groups * self.kernel_size; + + let weight = self + .initializer + .init_with(shape, Some(fan_in), Some(fan_out), device); + let mut bias = None; + + if self.bias { + bias = + Some( + self.initializer + .init_with([self.channels_out], Some(fan_in), None, device), + ); + } + + Conv1d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + padding: self.padding.clone(), + dilation: self.dilation, + groups: self.groups, + } + } +} + +impl Conv1d { + /// Applies the forward pass on the input tensor. + /// + /// See [conv1d](burn::tensor::module::conv1d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, length_in]` + /// - output: `[batch_size, channels_out, length_out]` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + let length = input.dims()[2]; + + // Calculate padding as pair - handles Same, Valid, and Explicit uniformly + let (left, right) = + self.padding + .calculate_padding_1d_pair(length, self.kernel_size, self.stride); + + let options = PaddedConvOptions::asymmetric( + [self.stride], + [left], + [right], + [self.dilation], + self.groups, + ); + + conv1d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + options, + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::ElementConversion; + type FT = f32; + + use super::*; + use burn::tensor::TensorData; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = Conv1dConfig::new(5, 5, 5); + let k = (config.channels_in * config.kernel_size) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = Conv1dConfig::new(5, 5, 5).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight + .to_data() + .assert_eq(&TensorData::zeros::(conv.weight.shape()), false); + } + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = Conv1dConfig::new(4, 4, 2) + .with_padding(PaddingConfig1d::Same) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=4, length=5] + let input = Tensor::<3>::ones([1, 4, 5], &device); + let output = conv.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 4, 5]); + } + + #[test] + fn display() { + let config = Conv1dConfig::new(5, 5, 5); + let conv = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{conv}"), + "Conv1d {ch_in: 5, ch_out: 5, stride: 1, kernel_size: 5, dilation: 1, groups: 1, padding: Valid, params: 130}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = Conv1dConfig::new(5, 3, 3); + let conv = config.init(&Default::default()); + + let input = Tensor::<3>::zeros([1, 4, 10], &Default::default()); + let _ = conv.forward(input); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create conv with asymmetric padding: left=1, right=2 + let config = Conv1dConfig::new(2, 3, 3) + .with_padding(PaddingConfig1d::Explicit(1, 2)) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = conv.forward(input); + + // With asymmetric padding (1, 2), input length 4 becomes 4+1+2=7 + // Output length = (7 - 3) / 1 + 1 = 5 + assert_eq!(output.dims(), [1, 3, 5]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create conv with symmetric explicit padding: left=2, right=2 + let config = Conv1dConfig::new(2, 3, 3) + .with_padding(PaddingConfig1d::Explicit(2, 2)) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = conv.forward(input); + + // With symmetric padding (2, 2), input length 4 becomes 4+2+2=8 + // Output length = (8 - 3) / 1 + 1 = 6 + assert_eq!(output.dims(), [1, 3, 6]); + } + + #[test] + fn initializer_fan_out() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::XavierUniform { gain: 1.0 }; + + let config = Conv1dConfig::new(2, 2, 1).with_initializer(init.clone()); + let c = config.init(&device); + + let _ = c.weight.val(); + } +} diff --git a/crates/burn-nn/src/modules/conv/conv2d.rs b/crates/burn-nn/src/modules/conv/conv2d.rs new file mode 100644 index 0000000..661c36e --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv2d.rs @@ -0,0 +1,346 @@ +use alloc::format; + +use burn_core as burn; + +use crate::PaddingConfig2d; +use burn::config::Config; +use burn::module::Initializer; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::conv2d; +use burn::tensor::ops::PaddedConvOptions; + +use crate::conv::checks; + +/// Configuration to create a [2D convolution](Conv2d) layer, using the [init function](Conv2dConfig::init). +#[derive(Config, Debug)] +pub struct Conv2dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The stride of the convolution. + #[config(default = "[1, 1]")] + pub stride: [usize; 2], + /// Spacing between kernel elements. + #[config(default = "[1, 1]")] + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig2d::Valid")] + pub padding: PaddingConfig2d, + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 2D convolution over input tensors. +/// +/// Should be created with [Conv2dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Conv2d { + /// Tensor of shape `[channels_out, channels_in / groups, kernel_size_1, kernel_size_2]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: [usize; 2], + /// Size of the kernel. + pub kernel_size: [usize; 2], + /// Spacing between kernel elements. + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + pub groups: usize, + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig2d, +} + +impl Conv2dConfig { + /// Initialize a new [conv2d](Conv2d) module. + pub fn init(&self, device: &Device) -> Conv2d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.groups); + + let shape = [ + self.channels[1], + self.channels[0] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + ]; + + let k = self.kernel_size.iter().product::(); + let fan_in = self.channels[0] / self.groups * k; + let fan_out = self.channels[1] / self.groups * k; + + let weight = self + .initializer + .init_with(shape, Some(fan_in), Some(fan_out), device); + let mut bias = None; + + if self.bias { + bias = Some(self.initializer.init_with( + [self.channels[1]], + Some(fan_in), + Some(fan_out), + device, + )); + } + + Conv2d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + padding: self.padding.clone(), + groups: self.groups, + } + } +} + +impl ModuleDisplay for Conv2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + // Format the stride, kernel_size and dilation as strings, formatted as arrays instead of indexed. + let stride = format!("{:?}", self.stride); + let kernel_size = format!("{:?}", self.kernel_size); + let dilation = format!("{:?}", self.dilation); + let [channels_out, group_channels_in, _, _] = self.weight.dims(); + let channels_in = group_channels_in * self.groups; + let ch_out = format!("{:?}", channels_out); + let ch_in = format!("{:?}", channels_in); + content + .add("ch_in", &ch_in) + .add("ch_out", &ch_out) + .add("stride", &stride) + .add("kernel_size", &kernel_size) + .add("dilation", &dilation) + .add("groups", &self.groups) + .add_debug_attribute("padding", &self.padding) + .optional() + } +} + +impl Conv2d { + /// Applies the forward pass on the input tensor. + /// + /// See [conv2d](burn::tensor::module::conv2d) for more information. + /// + /// # Shapes + /// - `input`: `[batch_size, channels_in, height_in, width_in]` + /// - `output`: `[batch_size, channels_out, height_out, width_out]` + /// + /// # Example + /// ```rust,ignore + /// use burn::nn::conv::Conv2dConfig; + /// use burn::tensor::Tensor; + /// + /// // Assuming backend type alias `B` + /// let device = Default::default(); + /// let conv = Conv2dConfig::new([3, 8], [3, 3]).init(&device); + /// + /// let x = Tensor::::zeros([1, 3, 28, 28], &device); + /// let y = conv.forward(x); + /// + /// println!("{:?}", y.dims()); // [1, 8, 26, 26] + /// ``` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let [_batch_size, _channels_in, height_in, width_in] = input.dims(); + + // Calculate padding as pairs - handles Same, Valid, and Explicit uniformly + let ((top, bottom), (left, right)) = self.padding.calculate_padding_2d_pairs( + height_in, + width_in, + &self.kernel_size, + &self.stride, + ); + + let options = PaddedConvOptions::asymmetric( + self.stride, + [top, left], + [bottom, right], + self.dilation, + self.groups, + ); + + conv2d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + options, + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ElementConversion, Tolerance}; + + use super::*; + use burn::tensor::TensorData; + type FT = f32; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = Conv2dConfig::new([5, 1], [5, 5]); + let k = (config.channels[0] * config.kernel_size[0] * config.kernel_size[1]) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = Conv2dConfig::new([5, 2], [5, 5]).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn initializer_fan_out() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, // test that fan_out is passed to `init_with()` + }; + + let config = Conv2dConfig::new([5, 1], [5, 5]).with_initializer(init.clone()); + let c = config.init(&device); + let _ = c.weight.val(); // initializes param + + assert_eq!(config.initializer, init); + } + + #[test] + fn initializer_fan_with_groups_is_valid() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, + }; + + let config = Conv2dConfig::new([4, 4], [1, 1]) + .with_initializer(init.clone()) + .with_groups(4); + let _ = config.init(&device); + + assert_eq!(config.initializer, init); + } + + #[test] + #[should_panic = "Both channels must be divisible by the number of groups."] + fn channels_with_groups_is_invalid() { + let device = Default::default(); + let config = Conv2dConfig::new([1, 4], [1, 1]).with_groups(4); + let _ = config.init(&device); + } + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = Conv2dConfig::new([4, 4], [2, 2]) + .with_padding(PaddingConfig2d::Same) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=4, height=5, width=5] + let input = Tensor::<4>::ones([1, 4, 5, 5], &device); + let output = conv.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 4, 5, 5]); + } + + #[test] + fn display() { + let config = Conv2dConfig::new([5, 1], [5, 5]); + let conv = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{conv}"), + "Conv2d {ch_in: 5, ch_out: 1, stride: [1, 1], kernel_size: [5, 5], dilation: [1, 1], groups: 1, padding: Valid, params: 126}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = Conv2dConfig::new([5, 3], [3, 3]); + let conv = config.init(&Default::default()); + + let input = Tensor::<4>::zeros([1, 4, 10, 10], &Default::default()); + let _ = conv.forward(input); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create conv with asymmetric padding: top=1, left=2, bottom=3, right=4 + let config = Conv2dConfig::new([2, 3], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 2, 3, 4)) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = conv.forward(input); + + // Height: 4 + 1 + 3 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 4 = 11, output = (11 - 3) / 1 + 1 = 9 + assert_eq!(output.dims(), [1, 3, 6, 9]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create conv with symmetric explicit padding: top=2, left=2, bottom=2, right=2 + let config = Conv2dConfig::new([2, 3], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)) + .with_initializer(Initializer::Constant { value: 1.0 }) + .with_bias(false); + let conv = config.init(&device); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = conv.forward(input); + + // Height: 4 + 2 + 2 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 2 = 9, output = (9 - 3) / 1 + 1 = 7 + assert_eq!(output.dims(), [1, 3, 6, 7]); + } +} diff --git a/crates/burn-nn/src/modules/conv/conv3d.rs b/crates/burn-nn/src/modules/conv/conv3d.rs new file mode 100644 index 0000000..cf540b4 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv3d.rs @@ -0,0 +1,273 @@ +use alloc::format; + +use burn_core as burn; + +use crate::PaddingConfig3d; +use burn::config::Config; +use burn::module::Initializer; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::conv3d; +use burn::tensor::ops::ConvOptions; + +use crate::conv::checks; + +/// Configuration to create a [3D convolution](Conv3d) layer, using the [init function](Conv3dConfig::init). +#[derive(Config, Debug)] +pub struct Conv3dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 3], + /// The stride of the convolution. + #[config(default = "[1, 1, 1]")] + pub stride: [usize; 3], + /// Spacing between kernel elements. + #[config(default = "[1, 1, 1]")] + pub dilation: [usize; 3], + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + #[config(default = "PaddingConfig3d::Valid")] + pub padding: PaddingConfig3d, + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 3D convolution over input tensors. +/// +/// Should be created with [Conv3dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Conv3d { + /// Tensor of shape `[channels_out, channels_in / groups, kernel_size_1, kernel_size_2, kernel_size_3]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: [usize; 3], + /// Size of the kernel. + pub kernel_size: [usize; 3], + /// Spacing between kernel elements. + pub dilation: [usize; 3], + /// Controls the connections between input and output channels. + pub groups: usize, + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig3d, +} + +impl Conv3dConfig { + /// Initialize a new [conv3d](Conv3d) module. + pub fn init(&self, device: &Device) -> Conv3d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.groups); + if self.padding == PaddingConfig3d::Same { + checks::check_same_padding_support(&self.kernel_size); + } + + let shape = [ + self.channels[1], + self.channels[0] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + self.kernel_size[2], + ]; + + let k = self.kernel_size.iter().product::(); + let fan_in = self.channels[0] / self.groups * k; + let fan_out = self.channels[1] / self.groups * k; + + let weight = self + .initializer + .init_with(shape, Some(fan_in), Some(fan_out), device); + let mut bias = None; + + if self.bias { + bias = Some(self.initializer.init_with( + [self.channels[1]], + Some(fan_in), + Some(fan_out), + device, + )); + } + + Conv3d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + padding: self.padding.clone(), + groups: self.groups, + } + } +} + +impl ModuleDisplay for Conv3d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + // Format arrays as strings (consistent with Conv2d/Conv1d). + let stride = format!("{:?}", self.stride); + let kernel_size = format!("{:?}", self.kernel_size); + let dilation = format!("{:?}", self.dilation); + + // Weight dims: [channels_out, channels_in/groups, k1, k2, k3] + let [channels_out, group_channels_in, _, _, _] = self.weight.dims(); + let channels_in = group_channels_in * self.groups; + let ch_out = format!("{:?}", channels_out); + let ch_in = format!("{:?}", channels_in); + + content + .add("ch_in", &ch_in) + .add("ch_out", &ch_out) + .add("stride", &stride) + .add("kernel_size", &kernel_size) + .add("dilation", &dilation) + .add("groups", &self.groups) + .add_debug_attribute("padding", &self.padding) + .optional() + } +} + +impl Conv3d { + /// Applies the forward pass on the input tensor. + /// + /// See [conv3d](burn::tensor::module::conv3d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, depth_in, height_in, width_in]` + /// - output: `[batch_size, channels_out, depth_out, height_out, width_out]` + pub fn forward(&self, input: Tensor<5>) -> Tensor<5> { + let [_batch_size, _channels_in, depth_in, height_in, width_in] = input.dims(); + let padding = self.padding.calculate_padding_3d( + depth_in, + height_in, + width_in, + &self.kernel_size, + &self.stride, + ); + conv3d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + ConvOptions::new(self.stride, padding, self.dilation, self.groups), + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + use super::*; + use burn::tensor::TensorData; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = Conv3dConfig::new([5, 1], [5, 5, 5]); + let k = (config.channels[0] + * config.kernel_size[0] + * config.kernel_size[1] + * config.kernel_size[2]) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = Conv3dConfig::new([5, 2], [5, 5, 5]).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn initializer_fan_out() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, // test that fan_out is passed to `init_with()` + }; + let config = Conv3dConfig::new([5, 1], [5, 5, 5]).with_initializer(init.clone()); + let c = config.init(&device); + let _ = c.weight.val(); // initializes param + + assert_eq!(config.initializer, init); + } + + #[test] + fn initializer_fan_with_groups_is_valid() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, + }; + + let config = Conv3dConfig::new([4, 4], [1, 1, 1]) + .with_initializer(init.clone()) + .with_groups(4); + let _ = config.init(&device); + + assert_eq!(config.initializer, init); + } + + #[test] + #[should_panic = "Same padding with an even kernel size is not supported"] + fn same_with_even_kernel_is_invalid() { + let device = Default::default(); + let config = Conv3dConfig::new([4, 4], [2, 2, 2]).with_padding(PaddingConfig3d::Same); + let _ = config.init(&device); + } + + #[test] + fn display() { + let config = Conv3dConfig::new([5, 1], [5, 5, 5]); + let conv = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{conv}"), + "Conv3d {ch_in: 5, ch_out: 1, stride: [1, 1, 1], kernel_size: [5, 5, 5], dilation: [1, 1, 1], groups: 1, padding: Valid, params: 626}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = Conv3dConfig::new([5, 3], [3, 3, 3]); + let conv = config.init(&Default::default()); + + let input = Tensor::<5>::zeros([1, 4, 10, 10, 10], &Default::default()); + let _ = conv.forward(input); + } +} diff --git a/crates/burn-nn/src/modules/conv/conv_transpose1d.rs b/crates/burn-nn/src/modules/conv/conv_transpose1d.rs new file mode 100644 index 0000000..037f954 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv_transpose1d.rs @@ -0,0 +1,214 @@ +use alloc::format; + +use burn_core as burn; + +use crate::conv::checks; +use burn::config::Config; +use burn::module::Content; +use burn::module::DisplaySettings; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::ModuleDisplay; +use burn::module::Param; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::conv_transpose1d; +use burn::tensor::ops::ConvTransposeOptions; + +/// Configuration to create an [1D transposed convolution](ConvTranspose1d) layer +/// using the [init function](ConvTranspose1dConfig::init). +#[derive(Config, Debug)] +pub struct ConvTranspose1dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: usize, + /// The stride of the convolution. + #[config(default = "1")] + pub stride: usize, + /// Spacing between kernel elements. + #[config(default = "1")] + pub dilation: usize, + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + #[config(default = "0")] + pub padding: usize, + /// The padding output configuration. + #[config(default = "0")] + pub padding_out: usize, + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 1D transposed convolution over input tensors. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct ConvTranspose1d { + /// Tensor of shape `[channels_in, channels_out / groups, kernel_size]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: usize, + /// Size of the kernel. + pub kernel_size: usize, + /// Spacing between kernel elements. + pub dilation: usize, + /// Controls the connections between input and output channels. + pub groups: usize, + /// The padding configuration. + pub padding: usize, + /// The padding output configuration. + pub padding_out: usize, + /// The number of channels. + pub channels: [usize; 2], +} + +impl ModuleDisplay for ConvTranspose1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("channels", &format!("{:?}", self.channels)) + .add("stride", &self.stride) + .add("kernel_size", &self.kernel_size) + .add("dilation", &self.dilation) + .add("groups", &self.groups) + .add("padding", &self.padding) + .add("padding_out", &self.padding_out) + .optional() + } +} + +impl ConvTranspose1dConfig { + /// Initialize a new [conv transpose 1d](ConvTranspose1d) module. + pub fn init(&self, device: &Device) -> ConvTranspose1d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.groups); + + let shape = [ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size, + ]; + + let fan_in = self.channels[1] / self.groups * self.kernel_size; + let weight = self + .initializer + .init_with(shape, Some(fan_in), None, device); + let mut bias = None; + + if self.bias { + bias = Some( + self.initializer + .init_with([self.channels[1]], Some(fan_in), None, device), + ); + } + + ConvTranspose1d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + groups: self.groups, + padding: self.padding, + padding_out: self.padding_out, + channels: self.channels, + } + } +} + +impl ConvTranspose1d { + /// Applies the forward pass on the input tensor. + /// + /// See also [conv_transpose1d](burn::tensor::module::conv_transpose1d). + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, length_in]` + /// - output: `[batch_size, channels_out, length_out]` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + conv_transpose1d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + ConvTransposeOptions::new( + [self.stride], + [self.padding], + [self.padding_out], + [self.dilation], + self.groups, + ), + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ElementConversion, Tolerance}; + + use super::*; + use burn::tensor::TensorData; + type FT = f32; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = ConvTranspose1dConfig::new([5, 1], 5); + let k = (config.channels[1] * config.kernel_size) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = ConvTranspose1dConfig::new([5, 2], 5).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn display() { + let config = ConvTranspose1dConfig::new([5, 2], 5); + let conv = config.init(&Default::default()); + + assert_eq!( + format!("{conv}"), + "ConvTranspose1d {channels: [5, 2], stride: 1, kernel_size: 5, dilation: 1, groups: 1, padding: 0, padding_out: 0, params: 52}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = ConvTranspose1dConfig::new([5, 3], 3); + let conv = config.init(&Default::default()); + + let input = Tensor::<3>::zeros([1, 4, 10], &Default::default()); + let _ = conv.forward(input); + } +} diff --git a/crates/burn-nn/src/modules/conv/conv_transpose2d.rs b/crates/burn-nn/src/modules/conv/conv_transpose2d.rs new file mode 100644 index 0000000..bd92476 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv_transpose2d.rs @@ -0,0 +1,215 @@ +use alloc::format; + +use burn_core as burn; + +use crate::conv::checks; +use burn::config::Config; +use burn::module::Content; +use burn::module::DisplaySettings; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::ModuleDisplay; +use burn::module::Param; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::conv_transpose2d; +use burn::tensor::ops::ConvTransposeOptions; + +/// Configuration to create an [2D transposed convolution](ConvTranspose2d) layer +/// using the [init function](ConvTranspose2dConfig::init). +#[derive(Config, Debug)] +pub struct ConvTranspose2dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The stride of the convolution. + #[config(default = "[1, 1]")] + pub stride: [usize; 2], + /// Spacing between kernel elements. + #[config(default = "[1, 1]")] + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + #[config(default = "[0, 0]")] + pub padding: [usize; 2], + /// The padding output configuration. + #[config(default = "[0, 0]")] + pub padding_out: [usize; 2], + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 2D transposed convolution over input tensors. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct ConvTranspose2d { + /// Tensor of shape `[channels_in, channels_out / groups, kernel_size_1, kernel_size_2]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: [usize; 2], + /// Size of the kernel. + pub kernel_size: [usize; 2], + /// Spacing between kernel elements. + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + pub groups: usize, + /// Padding configuration. + pub padding: [usize; 2], + /// Padding output configuration. + pub padding_out: [usize; 2], + /// Number of channels. + pub channels: [usize; 2], +} + +impl ModuleDisplay for ConvTranspose2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("channels", &format!("{:?}", self.channels)) + .add("stride", &format!("{:?}", self.stride)) + .add("kernel_size", &format!("{:?}", self.kernel_size)) + .add("dilation", &format!("{:?}", self.dilation)) + .add("groups", &self.groups) + .add("padding", &format!("{:?}", self.padding)) + .add("padding_out", &format!("{:?}", self.padding_out)) + .optional() + } +} + +impl ConvTranspose2dConfig { + /// Initialize a new [conv transpose 2d](ConvTranspose2d) module. + pub fn init(&self, device: &Device) -> ConvTranspose2d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.groups); + + let shape = [ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + ]; + + let fan_in = self.channels[1] / self.groups * self.kernel_size.iter().product::(); + let weight = self + .initializer + .init_with(shape, Some(fan_in), None, device); + let mut bias = None; + + if self.bias { + bias = Some( + self.initializer + .init_with([self.channels[1]], Some(fan_in), None, device), + ); + } + + ConvTranspose2d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + groups: self.groups, + padding: self.padding, + padding_out: self.padding_out, + channels: self.channels, + } + } +} + +impl ConvTranspose2d { + /// Applies the forward pass on the input tensor. + /// + /// See also [conv_transpose2d](burn::tensor::module::conv_transpose2d). + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, height_in, width_in]` + /// - output: `[batch_size, channels_out, height_out, width_out]` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + conv_transpose2d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + ConvTransposeOptions::new( + self.stride, + self.padding, + self.padding_out, + self.dilation, + self.groups, + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = ConvTranspose2dConfig::new([5, 1], [5, 5]); + let k = (config.channels[1] * config.kernel_size[0] * config.kernel_size[1]) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = + ConvTranspose2dConfig::new([5, 2], [5, 5]).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn display() { + let config = ConvTranspose2dConfig::new([5, 2], [5, 5]); + let conv = config.init(&Default::default()); + + assert_eq!( + format!("{conv}"), + "ConvTranspose2d {channels: [5, 2], stride: [1, 1], kernel_size: [5, 5], dilation: [1, 1], groups: 1, padding: [0, 0], padding_out: [0, 0], params: 252}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = ConvTranspose2dConfig::new([5, 3], [3, 3]); + let conv = config.init(&Default::default()); + + let input = Tensor::<4>::zeros([1, 4, 10, 10], &Default::default()); + let _ = conv.forward(input); + } +} diff --git a/crates/burn-nn/src/modules/conv/conv_transpose3d.rs b/crates/burn-nn/src/modules/conv/conv_transpose3d.rs new file mode 100644 index 0000000..d9d0c37 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/conv_transpose3d.rs @@ -0,0 +1,220 @@ +use alloc::format; + +use burn_core as burn; + +use crate::conv::checks; +use burn::config::Config; +use burn::module::Content; +use burn::module::DisplaySettings; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::ModuleDisplay; +use burn::module::Param; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::conv_transpose3d; +use burn::tensor::ops::ConvTransposeOptions; + +/// Configuration to create an [3D transposed convolution](ConvTranspose3d) layer +/// using the [init function](ConvTranspose3dConfig::init). +#[derive(Config, Debug)] +pub struct ConvTranspose3dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 3], + /// The stride of the convolution. + #[config(default = "[1, 1, 1]")] + pub stride: [usize; 3], + /// Spacing between kernel elements. + #[config(default = "[1, 1, 1]")] + pub dilation: [usize; 3], + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub groups: usize, + /// The padding configuration. + #[config(default = "[0, 0, 0]")] + pub padding: [usize; 3], + /// The padding output configuration. + #[config(default = "[0, 0, 0]")] + pub padding_out: [usize; 3], + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a 3D transposed convolution over input tensors. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct ConvTranspose3d { + /// Tensor of shape `[channels_in, channels_out / groups, kernel_size_1, kernel_size_2, kernel_size_3]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: [usize; 3], + /// Size of the kernel. + pub kernel_size: [usize; 3], + /// Spacing between kernel elements. + pub dilation: [usize; 3], + /// Controls the connections between input and output channels. + pub groups: usize, + /// Padding configuration. + pub padding: [usize; 3], + /// Padding output configuration. + pub padding_out: [usize; 3], + /// Number of channels. + pub channels: [usize; 2], +} + +impl ModuleDisplay for ConvTranspose3d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("channels", &format!("{:?}", self.channels)) + .add("stride", &format!("{:?}", self.stride)) + .add("kernel_size", &format!("{:?}", self.kernel_size)) + .add("dilation", &format!("{:?}", self.dilation)) + .add("groups", &self.groups) + .add("padding", &format!("{:?}", self.padding)) + .add("padding_out", &format!("{:?}", self.padding_out)) + .optional() + } +} + +impl ConvTranspose3dConfig { + /// Initialize a new [conv transpose 2d](ConvTranspose3d) module. + pub fn init(&self, device: &Device) -> ConvTranspose3d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.groups); + + let shape = [ + self.channels[0], + self.channels[1] / self.groups, + self.kernel_size[0], + self.kernel_size[1], + self.kernel_size[2], + ]; + + let fan_in = self.channels[1] / self.groups * self.kernel_size.iter().product::(); + let weight = self + .initializer + .init_with(shape, Some(fan_in), None, device); + let mut bias = None; + + if self.bias { + bias = Some( + self.initializer + .init_with([self.channels[1]], Some(fan_in), None, device), + ); + } + + ConvTranspose3d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + groups: self.groups, + padding: self.padding, + padding_out: self.padding_out, + channels: self.channels, + } + } +} + +impl ConvTranspose3d { + /// Applies the forward pass on the input tensor. + /// + /// See also [conv_transpose3d](burn::tensor::module::conv_transpose3d). + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, depth_in, height_in, width_in]` + /// - output: `[batch_size, channels_out, depth_out, height_out, width_out]` + pub fn forward(&self, input: Tensor<5>) -> Tensor<5> { + conv_transpose3d( + input, + self.weight.val(), + self.bias.as_ref().map(|bias| bias.val()), + ConvTransposeOptions::new( + self.stride, + self.padding, + self.padding_out, + self.dilation, + self.groups, + ), + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + use super::*; + use burn::tensor::TensorData; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = ConvTranspose3dConfig::new([5, 1], [5, 5, 5]); + let k = (config.channels[1] + * config.kernel_size[0] + * config.kernel_size[1] + * config.kernel_size[2]) as f64; + let k = (config.groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = + ConvTranspose3dConfig::new([5, 2], [5, 5, 5]).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn display() { + let config = ConvTranspose3dConfig::new([5, 2], [5, 5, 5]); + let conv = config.init(&Default::default()); + + assert_eq!( + format!("{conv}"), + "ConvTranspose3d {channels: [5, 2], stride: [1, 1, 1], kernel_size: [5, 5, 5], dilation: [1, 1, 1], groups: 1, padding: [0, 0, 0], padding_out: [0, 0, 0], params: 1252}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = ConvTranspose3dConfig::new([5, 3], [3, 3, 3]); + let conv = config.init(&Default::default()); + + let input = Tensor::<5>::zeros([1, 4, 10, 10, 10], &Default::default()); + let _ = conv.forward(input); + } +} diff --git a/crates/burn-nn/src/modules/conv/deform_conv2d.rs b/crates/burn-nn/src/modules/conv/deform_conv2d.rs new file mode 100644 index 0000000..5306e1a --- /dev/null +++ b/crates/burn-nn/src/modules/conv/deform_conv2d.rs @@ -0,0 +1,293 @@ +use alloc::format; +use burn::tensor::ops::DeformConvOptions; + +use burn_core as burn; + +use crate::PaddingConfig2d; +use burn::config::Config; +use burn::module::Initializer; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::deform_conv2d; + +use crate::conv::checks; + +/// Configuration to create a [deformable 2D convolution](DeformConv2d) layer, using the [init function](DeformConv2dConfig::init). +#[derive(Config, Debug)] +pub struct DeformConv2dConfig { + /// The number of channels. + pub channels: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The stride of the convolution. + #[config(default = "[1, 1]")] + pub stride: [usize; 2], + /// Spacing between kernel elements. + #[config(default = "[1, 1]")] + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + #[config(default = "1")] + pub weight_groups: usize, + /// Offset groups. + #[config(default = "1")] + pub offset_groups: usize, + /// The padding configuration. + /// + /// ### Warning + /// Only symmetric padding is currently supported. As such, using `Same` padding with an even kernel + /// size is not supported as it will not produce the same output size. + #[config(default = "PaddingConfig2d::Valid")] + pub padding: PaddingConfig2d, + /// If bias should be added to the output. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0),fan_out_only:false}" + )] + pub initializer: Initializer, +} + +/// Applies a deformable 2D convolution over input tensors. +/// +/// Should be created with [DeformConv2dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct DeformConv2d { + /// Tensor of shape `[channels_out, channels_in / groups, kernel_size_1, kernel_size_2]` + pub weight: Param>, + /// Tensor of shape `[channels_out]` + pub bias: Option>>, + /// Stride of the convolution. + pub stride: [usize; 2], + /// Size of the kernel. + pub kernel_size: [usize; 2], + /// Spacing between kernel elements. + pub dilation: [usize; 2], + /// Controls the connections between input and output channels. + pub weight_groups: usize, + /// Offset groups. + pub offset_groups: usize, + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig2d, +} + +impl DeformConv2dConfig { + /// Initialize a new [DeformConv2d](DeformConv2d) module. + pub fn init(&self, device: &Device) -> DeformConv2d { + checks::checks_channels_div_groups(self.channels[0], self.channels[1], self.weight_groups); + if self.padding == PaddingConfig2d::Same { + checks::check_same_padding_support(&self.kernel_size); + } + + let shape = [ + self.channels[1], + self.channels[0] / self.weight_groups, + self.kernel_size[0], + self.kernel_size[1], + ]; + + let k = self.kernel_size.iter().product::(); + let fan_in = self.channels[0] / self.weight_groups * k; + let fan_out = self.channels[1] / self.weight_groups * k; + + let weight = self + .initializer + .init_with(shape, Some(fan_in), Some(fan_out), device); + let mut bias = None; + + if self.bias { + bias = Some(self.initializer.init_with( + [self.channels[1]], + Some(fan_in), + Some(fan_out), + device, + )); + } + + DeformConv2d { + weight, + bias, + stride: self.stride, + kernel_size: self.kernel_size, + dilation: self.dilation, + padding: self.padding.clone(), + weight_groups: self.weight_groups, + offset_groups: self.weight_groups, + } + } +} + +impl ModuleDisplay for DeformConv2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + // Format the stride, kernel_size and dilation as strings, formatted as arrays instead of indexed. + let stride = format!("{:?}", self.stride); + let kernel_size = format!("{:?}", self.kernel_size); + let dilation = format!("{:?}", self.dilation); + + content + .add("stride", &stride) + .add("kernel_size", &kernel_size) + .add("dilation", &dilation) + .add("weight_groups", &self.weight_groups) + .add("offset_groups", &self.offset_groups) + .add_debug_attribute("padding", &self.padding) + .optional() + } +} + +impl DeformConv2d { + /// Applies the forward pass on the input tensor. + /// + /// See [deform_conv2d](burn::tensor::module::deform_conv2d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels_in, height_in, width_in]` + /// - offset: `[batch_size, 2 * offset_groups * kernel_height * kernel_width, height_out, width_out]` + /// - mask: `[batch_size, offset_groups * kernel_height * kernel_width, height_out, width_out]` + /// - output: `[batch_size, channels_out, height_out, width_out]` + pub fn forward( + &self, + input: Tensor<4>, + offset: Tensor<4>, + mask: Option>, + ) -> Tensor<4> { + let [_batch_size, _channels_in, height_in, width_in] = input.dims(); + let padding = + self.padding + .calculate_padding_2d(height_in, width_in, &self.kernel_size, &self.stride); + deform_conv2d( + input, + offset, + self.weight.val(), + mask, + self.bias.as_ref().map(|bias| bias.val()), + DeformConvOptions::new( + self.stride, + padding, + self.dilation, + self.weight_groups, + self.offset_groups, + ), + ) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + use super::*; + use burn::tensor::TensorData; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = DeformConv2dConfig::new([5, 1], [5, 5]); + let k = (config.channels[0] * config.kernel_size[0] * config.kernel_size[1]) as f64; + let k = (config.offset_groups as f64 / k).sqrt().elem::(); + let conv = config.init(&device); + + conv.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = DeformConv2dConfig::new([5, 2], [5, 5]).with_initializer(Initializer::Zeros); + let conv = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + conv.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(conv.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn initializer_fan_out() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, // test that fan_out is passed to `init_with()` + }; + + let config = DeformConv2dConfig::new([5, 1], [5, 5]).with_initializer(init.clone()); + let c = config.init(&device); + let _ = c.weight.val(); // initializes param + + assert_eq!(config.initializer, init); + } + + #[test] + fn initializer_fan_with_groups_is_valid() { + let device = Device::default(); + device.seed(0); + + let init = Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: true, + }; + + let config = DeformConv2dConfig::new([4, 4], [1, 1]) + .with_initializer(init.clone()) + .with_weight_groups(4); + let _ = config.init(&device); + + assert_eq!(config.initializer, init); + } + + #[test] + #[should_panic = "Both channels must be divisible by the number of groups."] + fn channels_with_groups_is_invalid() { + let device = Default::default(); + let config = DeformConv2dConfig::new([1, 4], [1, 1]).with_weight_groups(4); + let _ = config.init(&device); + } + + #[test] + #[should_panic = "Same padding with an even kernel size is not supported"] + fn same_with_even_kernel_is_invalid() { + let device = Default::default(); + let config = DeformConv2dConfig::new([4, 4], [2, 2]).with_padding(PaddingConfig2d::Same); + let _ = config.init(&device); + } + + #[test] + fn display() { + let config = DeformConv2dConfig::new([5, 1], [5, 5]); + let conv = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{conv}"), + "DeformConv2d {stride: [1, 1], kernel_size: [5, 5], dilation: [1, 1], weight_groups: 1, offset_groups: 1, padding: Valid, params: 126}" + ); + } + + #[test] + #[should_panic = "Number of channels in input tensor and input channels of convolution must be equal. got: 4, expected: 5"] + fn input_channels_mismatch() { + let config = DeformConv2dConfig::new([5, 3], [3, 3]); + let conv = config.init(&Default::default()); + + let input = Tensor::<4>::zeros([1, 4, 10, 10], &Default::default()); + let offset = Tensor::<4>::zeros([1, 2 * 3 * 3, 10, 10], &Default::default()); + let _ = conv.forward(input, offset, None); + } +} diff --git a/crates/burn-nn/src/modules/conv/mod.rs b/crates/burn-nn/src/modules/conv/mod.rs new file mode 100644 index 0000000..9c83141 --- /dev/null +++ b/crates/burn-nn/src/modules/conv/mod.rs @@ -0,0 +1,17 @@ +mod conv1d; +mod conv2d; +mod conv3d; +mod conv_transpose1d; +mod conv_transpose2d; +mod conv_transpose3d; +mod deform_conv2d; + +pub(crate) mod checks; + +pub use conv_transpose1d::*; +pub use conv_transpose2d::*; +pub use conv_transpose3d::*; +pub use conv1d::*; +pub use conv2d::*; +pub use conv3d::*; +pub use deform_conv2d::*; diff --git a/crates/burn-nn/src/modules/dropout.rs b/crates/burn-nn/src/modules/dropout.rs new file mode 100644 index 0000000..ef9be1f --- /dev/null +++ b/crates/burn-nn/src/modules/dropout.rs @@ -0,0 +1,118 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::{Distribution, Tensor}; + +/// Configuration to create a [Dropout](Dropout) layer using the [init function](DropoutConfig::init). +#[derive(Config, Debug)] +pub struct DropoutConfig { + /// The probability of randomly zeroes some elements of the input tensor during training. + pub prob: f64, +} + +/// Set at random some elements of the input tensor to zero during training. +/// +/// This is an effective regularization technique as describe in the paper +/// [Improving neural networks by preventing co-adaptation of feature detectors](https://arxiv.org/abs/1207.0580). +/// +/// The input is also scaled during training to `1 / (1 - prob_keep)`. +/// +/// Should be created with [DropoutConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Dropout { + /// The probability of randomly zeroes some elements of the input tensor during training. + pub prob: f64, +} + +impl DropoutConfig { + /// Initialize a new [dropout](Dropout) module. + pub fn init(&self) -> Dropout { + if self.prob < 0.0 || self.prob > 1.0 { + panic!( + "Dropout probability should be between 0 and 1, but got {}", + self.prob + ); + } + Dropout { prob: self.prob } + } +} + +impl Dropout { + /// Applies the forward pass on the input tensor. + /// + /// See [Dropout](Dropout) for more information. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + if !input.device().is_autodiff() || self.prob == 0.0 { + return input; + } + + let prob_keep = 1.0 - self.prob; + let random = input.random_like(Distribution::Bernoulli(prob_keep)); + let x = input * random; + + x * (1.0 / prob_keep) + } +} + +impl ModuleDisplay for Dropout { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("prob", &self.prob).optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Shape; + + #[cfg(feature = "std")] + #[test] + fn with_ad_backend_should_mark_input() { + use burn::tensor::Device; + let device = Device::default().autodiff(); + let tensor = Tensor::<2>::ones(Shape::new([100, 100]), &device); + let dropout = DropoutConfig::new(0.5).init(); + + let output = dropout.forward(tensor.clone()); + + assert_ne!(tensor.to_data(), output.to_data()); + } + + #[test] + fn without_ad_backend_should_not_change_input() { + let tensor = Tensor::<2>::ones(Shape::new([100, 100]), &Default::default()); + let dropout = DropoutConfig::new(0.5).init(); + + let output = dropout.forward(tensor.clone()); + + assert_eq!(tensor.to_data(), output.to_data()); + } + + #[test] + fn display() { + let config = DropoutConfig::new(0.5); + let layer = config.init(); + + assert_eq!(alloc::format!("{layer}"), "Dropout {prob: 0.5}"); + } + + #[test] + #[should_panic = "Dropout probability should be between 0 and 1,"] + fn dropout_prob_invalid() { + let config = DropoutConfig::new(-10.); + let _layer = config.init(); + } +} diff --git a/crates/burn-nn/src/modules/embedding.rs b/crates/burn-nn/src/modules/embedding.rs new file mode 100644 index 0000000..1fc5481 --- /dev/null +++ b/crates/burn-nn/src/modules/embedding.rs @@ -0,0 +1,109 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::Param; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Int; +use burn::tensor::{Device, Tensor}; + +use burn::tensor::module::embedding; + +/// Configuration to create an [Embedding](Embedding) layer using the [init function](EmbeddingConfig::init). +#[derive(Config, Debug)] +pub struct EmbeddingConfig { + /// The number of embedding vectors. + pub n_embedding: usize, + /// The size of each vector. + pub d_model: usize, + /// The type of function used to initialize neural network parameters + #[config(default = "Initializer::Normal{mean:0.0, std:1.0}")] + pub initializer: Initializer, +} + +/// Lookup table to store a fix number of vectors. +/// +/// Should be created with [EmbeddingConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Embedding { + /// The learnable weights of the module of shape `[n_embedding, d_model]` initialized + /// from a normal distribution `N(0, 1)`. + pub weight: Param>, +} + +impl ModuleDisplay for Embedding { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [n_embedding, d_model] = self.weight.shape().dims(); + content + .add("n_embedding", &n_embedding) + .add("d_model", &d_model) + .optional() + } +} + +impl EmbeddingConfig { + /// Initialize a new [embedding](Embedding) module. + pub fn init(&self, device: &Device) -> Embedding { + let weight = self + .initializer + .init([self.n_embedding, self.d_model], device); + + Embedding { weight } + } +} + +impl Embedding { + /// Applies the forward pass on the input tensor. + /// + /// See also [embedding](burn::tensor::module::embedding). + /// + /// # Shapes + /// + /// - input: `[batch_size, seq_length]` + /// - output: `[batch_size, seq_length, d_model]` + pub fn forward(&self, input: Tensor<2, Int>) -> Tensor<3> { + embedding(self.weight.val(), input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = EmbeddingConfig::new(5, 5).with_initializer(Initializer::Zeros); + let embed = config.init(&Default::default()); + + assert_eq!(config.initializer, Initializer::Zeros); + embed.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(embed.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn display() { + let config = EmbeddingConfig::new(100, 10); + let embed = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{embed}"), + "Embedding {n_embedding: 100, d_model: 10, params: 1000}" + ); + } +} diff --git a/crates/burn-nn/src/modules/identity.rs b/crates/burn-nn/src/modules/identity.rs new file mode 100644 index 0000000..718880c --- /dev/null +++ b/crates/burn-nn/src/modules/identity.rs @@ -0,0 +1,32 @@ +use burn_core as burn; + +use burn::Tensor; +use burn::module::Module; + +/// Identity Module. +#[derive(Module, Default, Debug)] +pub struct Identity; + +impl Identity { + /// Create the module. + pub fn new() -> Self { + Self {} + } + + /// Forward pass, returns the input tensor. + pub fn forward(&self, input: Tensor) -> Tensor { + input + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let layer = Identity::new(); + + assert_eq!(alloc::format!("{layer}"), "Identity"); + } +} diff --git a/crates/burn-nn/src/modules/interpolate/interpolate1d.rs b/crates/burn-nn/src/modules/interpolate/interpolate1d.rs new file mode 100644 index 0000000..5e3acea --- /dev/null +++ b/crates/burn-nn/src/modules/interpolate/interpolate1d.rs @@ -0,0 +1,257 @@ +use alloc::format; + +use burn::tensor::module::interpolate; + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::InterpolateOptions; + +use super::InterpolateMode; + +/// Configuration for the 1D interpolation module. +/// +/// This struct defines the configuration options for the 1D interpolation operation. +/// It allows specifying the output size, scale factor, and interpolation mode. +#[derive(Config, Debug)] +pub struct Interpolate1dConfig { + /// Output size of the interpolated tensor. + /// If specified, this takes precedence over `scale_factor`. + #[config(default = "None")] + pub output_size: Option, + + /// Scale factor for resizing the input tensor. + /// This is used when `output_size` is not specified. + #[config(default = "None")] + pub scale_factor: Option, + + /// Interpolation mode to use for resizing. + /// Determines how the output values are calculated. + #[config(default = "InterpolateMode::Nearest")] + pub mode: InterpolateMode, + + /// If `true`, the input and output tensors are aligned by their corner pixels. + /// If `false`, half-pixel coordinate mapping is used instead. + #[config(default = true)] + pub align_corners: bool, +} + +/// Interpolate module for resizing 1D tensors with shape [N, C, L]. +/// +/// This struct represents a 1D interpolation module that can resize tensors +/// using various interpolation methods. It provides flexibility in specifying +/// either an output size or a scale factor for resizing, along with options +/// for the interpolation mode. +/// +/// The module can be used to upsample or downsample 1D tensors, preserving the +/// number of channels and batch size while adjusting the length dimension. +/// +/// The module can be created using the [Interpolate1dConfig] struct and the +/// `init` method, which returns an instance of the [Interpolate1d] struct. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Interpolate1d { + /// Output size of the interpolated tensor + pub output_size: Option, + + /// Scale factor for resizing the input tensor + pub scale_factor: Option, + + /// Interpolation mode used for resizing + #[module(skip)] + pub mode: InterpolateMode, + + /// Whether to align corner pixels + pub align_corners: bool, +} + +impl Interpolate1dConfig { + /// Initialize the interpolation module + pub fn init(self) -> Interpolate1d { + Interpolate1d { + output_size: self.output_size, + scale_factor: self.scale_factor, + mode: self.mode, + align_corners: self.align_corners, + } + } +} + +impl Interpolate1d { + /// Performs the forward pass of the 1D interpolation module + /// + /// # Arguments + /// + /// * `input` - Input tensor with shape [N, C, L] + /// + /// # Returns + /// + /// Resized tensor with shape [N, C, L'], where L' is determined by + /// the output_size or scale_factor specified in the module configuration + /// + /// # Example + /// + /// ```ignore + /// let input = Tensor::::random([1, 3, 64], Distribution::Uniform(0.0, 1.0), &device); + /// let interpolate = Interpolate1dConfig::new() + /// .with_output_size(Some(128)) + /// .init(); + /// let output = interpolate.forward(input); + /// assert_eq!(output.dims(), [1, 3, 128]); + /// ``` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + let output_size = calculate_output_size(input.dims(), self.output_size, self.scale_factor); + + // Use the interpolate operation to resize the temporal input tensor + // by adding a new dimension for the interpolation axis + let input = input.unsqueeze_dim(2); + + let result = interpolate( + input, + [1, output_size], + InterpolateOptions::new(self.mode.clone().into()) + .with_align_corners(self.align_corners), + ); + + result.squeeze_dims(&[2]) + } +} + +/// Calculate output size based on input dimensions, output size, and scale factor +/// +/// # Arguments +/// +/// * `input_dims` - Input dimensions of the tensor +/// * `output_size` - Output size for the interpolated tensor +/// * `scale_factor` - Scale factor for resizing the tensor +/// +/// # Returns +/// +/// Output size for the interpolated tensor +/// +/// # Panics +/// +/// Panics if neither output_size nor scale_factor is provided +/// or if the scale factor is too large +fn calculate_output_size( + input_dims: [usize; 3], + output_size: Option, + scale_factor: Option, +) -> usize { + match (output_size, scale_factor) { + (Some(output_size), None) => { + // Use provided + output_size + } + (None, Some(scale_factor)) => { + // Calculate output size based on scale factor + let [_, _, l] = input_dims; + + let new_dim = (l as f64) * (scale_factor as f64); + + if new_dim > usize::MAX as f64 { + panic!("Scale factor is too large"); + } + + new_dim as usize + } + _ => panic!("Either output_size or scale_factor must be provided"), + } +} + +impl ModuleDisplay for Interpolate1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add_debug_attribute("mode", &self.mode) + .add("output_size", &format!("{:?}", self.output_size)) + .add("scale_factor", &self.scale_factor) + .optional() + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Distribution; + + use super::*; + #[test] + fn test_calculate_output_size() { + let input_dims = [1, 1, 4]; + + let output_size = calculate_output_size(input_dims, Some(2), None); + assert_eq!(output_size, 2); + + let output_size = calculate_output_size(input_dims, None, Some(2.0)); + assert_eq!(output_size, 8); + + let output_size = calculate_output_size(input_dims, None, Some(0.5)); + assert_eq!(output_size, 2); + + let output_size = calculate_output_size(input_dims, None, Some(1.5)); + assert_eq!(output_size, 6); + } + + #[test] + #[should_panic(expected = "Either output_size or scale_factor must be provided")] + fn test_panic() { + let input_dims = [1, 1, 4]; + calculate_output_size(input_dims, None, None); + } + + #[test] + #[should_panic(expected = "Scale factor is too large")] + fn test_large_scale_factor() { + let input_dims = [1, 1, usize::MAX - 1]; + calculate_output_size(input_dims, None, Some(2.0)); + } + + #[test] + fn test_module() { + let input = Tensor::<3>::random( + [2, 3, 4], + Distribution::Uniform(0.0, 1.0), + &Default::default(), + ); + + // Test with output_size + let config = Interpolate1dConfig::new().with_output_size(Some(8)); + let interpolate = config.init(); + let output = interpolate.forward(input.clone()); + assert_eq!(output.dims(), [2, 3, 8]); + + // Test with scale_factor + let config = Interpolate1dConfig::new().with_scale_factor(Some(0.5)); + let interpolate = config.init(); + let output = interpolate.forward(input.clone()); + assert_eq!(output.dims(), [2, 3, 2]); + + // Test with different interpolation mode + let config = Interpolate1dConfig::new() + .with_output_size(Some(6)) + .with_mode(InterpolateMode::Linear); + let interpolate = config.init(); + let output = interpolate.forward(input); + assert_eq!(output.dims(), [2, 3, 6]); + } + + #[test] + fn display() { + let config = Interpolate1dConfig::new().with_output_size(Some(20)); + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "Interpolate1d {mode: Nearest, output_size: Some(20), \ + scale_factor: None}" + ); + } +} diff --git a/crates/burn-nn/src/modules/interpolate/interpolate2d.rs b/crates/burn-nn/src/modules/interpolate/interpolate2d.rs new file mode 100644 index 0000000..e99fb40 --- /dev/null +++ b/crates/burn-nn/src/modules/interpolate/interpolate2d.rs @@ -0,0 +1,259 @@ +use alloc::format; + +use burn::tensor::module::interpolate; + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::InterpolateOptions; + +use super::InterpolateMode; + +/// Configuration for the 2D interpolation module. +/// +/// This struct defines the configuration options for the 2D interpolation operation. +/// It allows specifying the output size, scale factor, and interpolation mode. +#[derive(Config, Debug)] +pub struct Interpolate2dConfig { + /// Output size of the interpolated tensor. + /// If specified, this takes precedence over `scale_factor`. + #[config(default = "None")] + pub output_size: Option<[usize; 2]>, + + /// Scale factor for resizing the input tensor. + /// This is used when `output_size` is not specified. + #[config(default = "None")] + pub scale_factor: Option<[f32; 2]>, + + /// Interpolation mode to use for resizing. + /// Determines how the output values are calculated. + #[config(default = "InterpolateMode::Nearest")] + pub mode: InterpolateMode, + + /// If `true`, the input and output tensors are aligned by their corner pixels. + /// If `false`, half-pixel coordinate mapping is used instead. + #[config(default = true)] + pub align_corners: bool, +} + +/// Interpolate module for resizing tensors with shape [N, C, H, W]. +/// +/// This struct represents an interpolation module that can resize tensors +/// using various interpolation methods. It provides flexibility in specifying +/// either an output size or a scale factor for resizing, along with options +/// for the interpolation mode. +/// +/// The module can be used to upsample or downsample tensors, preserving the +/// number of channels and batch size while adjusting the height and width +/// dimensions. +/// +/// The module can be created using the [Interpolate2dConfig] struct and the +/// `init` method, which returns an instance of the [Interpolate2d] struct. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Interpolate2d { + /// Output size of the interpolated tensor + pub output_size: Option<[usize; 2]>, + + /// Scale factor for resizing the input tensor + pub scale_factor: Option<[f32; 2]>, + + /// Interpolation mode used for resizing + #[module(skip)] + pub mode: InterpolateMode, + + /// Whether to align corner pixels + pub align_corners: bool, +} + +impl Interpolate2dConfig { + /// Initialize the interpolation module + pub fn init(self) -> Interpolate2d { + Interpolate2d { + output_size: self.output_size, + scale_factor: self.scale_factor, + mode: self.mode, + align_corners: self.align_corners, + } + } +} +impl Interpolate2d { + /// Performs the forward pass of the interpolation module + /// + /// # Arguments + /// + /// * `input` - Input tensor with shape [N, C, H, W] + /// + /// # Returns + /// + /// Resized tensor with shape [N, C, H', W'], where H' and W' are determined by + /// the output_size or scale_factor specified in the module configuration + /// + /// # Example + /// + /// ```ignore + /// let input = Tensor::::random([1, 3, 64, 64], Distribution::Uniform(0.0, 1.0), &device); + /// let interpolate = Interpolate2dConfig::new() + /// .with_output_size(Some([128, 128])) + /// .init(); + /// let output = interpolate.forward(input); + /// assert_eq!(output.dims(), [1, 3, 128, 128]); + /// ``` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let output_size = calculate_output_size(input.dims(), self.output_size, self.scale_factor); + interpolate( + input, + output_size, + InterpolateOptions::new(self.mode.clone().into()) + .with_align_corners(self.align_corners), + ) + } +} + +/// Calculates the output size for tensor interpolation. +/// +/// # Arguments +/// +/// * `input_dims` - The dimensions of the input tensor [N, C, H, W]. +/// * `output_size` - Optional desired output size [H', W']. +/// * `scale_factor` - Optional scale factor for height and width [scale_h, scale_w]. +/// +/// # Returns +/// +/// A tuple [H', W'] representing the calculated output size. +/// +/// # Panics +/// +/// Panics if neither `output_size` nor `scale_factor` is provided, +/// or if the scale factor results in dimensions exceeding usize::MAX. +fn calculate_output_size( + input_dims: [usize; 4], + output_size: Option<[usize; 2]>, + scale_factor: Option<[f32; 2]>, +) -> [usize; 2] { + match (output_size, scale_factor) { + (Some(output_size), None) => { + // Use provided + output_size + } + (None, Some(scale_factor)) => { + // Calculate output size based on scale factor + let [_, _, h, w] = input_dims; + + let new_dim_h = (h as f64) * (scale_factor[0] as f64); + + if new_dim_h > usize::MAX as f64 { + panic!("Scale factor for height is too large"); + } + + let new_dim_w = (w as f64) * (scale_factor[1] as f64); + + if new_dim_w > usize::MAX as f64 { + panic!("Scale factor for width is too large"); + } + + [new_dim_h as usize, new_dim_w as usize] + } + _ => panic!("Either output_size or scale_factor must be provided"), + } +} + +impl ModuleDisplay for Interpolate2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add_debug_attribute("mode", &self.mode) + .add("output_size", &format!("{:?}", self.output_size)) + .add("scale_factor", &self.scale_factor) + .optional() + } +} +#[cfg(test)] +mod tests { + use burn::tensor::Distribution; + + use super::*; + + #[test] + fn test_calculate_output_size() { + let input_dims = [1, 1, 4, 4]; + + let output_size = calculate_output_size(input_dims, Some([2, 2]), None); + assert_eq!(output_size, [2, 2]); + + let output_size = calculate_output_size(input_dims, None, Some([2.0, 2.0])); + assert_eq!(output_size, [8, 8]); + + let output_size = calculate_output_size([1, 1, 4, 4], None, Some([0.5, 0.5])); + assert_eq!(output_size, [2, 2]); + + let output_size = calculate_output_size([1, 1, 4, 4], None, Some([2.0, 1.5])); + assert_eq!(output_size, [8, 6]); + } + + #[test] + #[should_panic(expected = "Either output_size or scale_factor must be provided")] + fn test_missing_params() { + calculate_output_size([1, 1, 4, 4], None, None); + } + + #[test] + #[should_panic(expected = "Scale factor for height is too large")] + fn test_infinite_height() { + calculate_output_size([1, 1, usize::MAX - 1, 4], None, Some([2.0, 1.0])); + } + + #[test] + #[should_panic(expected = "Scale factor for width is too large")] + fn test_infinite_width() { + calculate_output_size([1, 1, 4, usize::MAX - 1], None, Some([1.0, 2.0])); + } + + #[test] + fn test_module() { + let input = Tensor::<4>::random( + [2, 3, 4, 4], + Distribution::Uniform(0.0, 1.0), + &Default::default(), + ); + + // Test with output_size + let config = Interpolate2dConfig::new().with_output_size(Some([8, 8])); + let interpolate = config.init(); + let output = interpolate.forward(input.clone()); + assert_eq!(output.dims(), [2, 3, 8, 8]); + + // Test with scale_factor + let config = Interpolate2dConfig::new().with_scale_factor(Some([0.5, 0.5])); + let interpolate = config.init(); + let output = interpolate.forward(input.clone()); + assert_eq!(output.dims(), [2, 3, 2, 2]); + + // Test with different interpolation mode + let config = Interpolate2dConfig::new() + .with_output_size(Some([6, 6])) + .with_mode(InterpolateMode::Linear); + let interpolate = config.init(); + let output = interpolate.forward(input); + assert_eq!(output.dims(), [2, 3, 6, 6]); + } + + #[test] + fn display() { + let config = Interpolate2dConfig::new().with_output_size(Some([20, 20])); + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "Interpolate2d {mode: Nearest, output_size: Some([20, 20]), \ + scale_factor: None}" + ); + } +} diff --git a/crates/burn-nn/src/modules/interpolate/mod.rs b/crates/burn-nn/src/modules/interpolate/mod.rs new file mode 100644 index 0000000..ce56965 --- /dev/null +++ b/crates/burn-nn/src/modules/interpolate/mod.rs @@ -0,0 +1,64 @@ +mod interpolate1d; +mod interpolate2d; + +pub use interpolate1d::*; +pub use interpolate2d::*; + +use burn_core as burn; + +use burn::config::Config; +use burn::tensor::ops::InterpolateMode as OpsInterpolateMode; + +/// Algorithm used for downsampling and upsampling +/// +/// This enum defines different interpolation modes for resampling data. +#[derive(Config, Debug)] +pub enum InterpolateMode { + /// Nearest-neighbor floor interpolation + /// + /// This mode selects the value with floor mapping to a sample point for each output pixel. + /// It is applicable for both temporal and spatial data. + Nearest, + + /// Nearest-neighbor exact interpolation + /// + /// This mode selects the value of the nearest sample point for each output pixel. + /// It is applicable for both temporal and spatial data. + NearestExact, + + /// Linear interpolation + /// + /// This mode calculates the output value using linear + /// interpolation between nearby sample points. + /// + /// It is applicable for both temporal and spatial data. + Linear, + + /// Cubic interpolation + /// + /// This mode uses cubic interpolation to calculate the output value + /// based on surrounding sample points. + /// + /// It is applicable for both temporal and spatial data and generally + /// provides smoother results than linear interpolation. + Cubic, + + /// Lanczos3 interpolation + /// + /// This mode uses a 6-tap sinc-based Lanczos filter (a=3) to calculate + /// the output value. It generally provides high-quality results, + /// especially for downsampling. + Lanczos, +} + +impl From for OpsInterpolateMode { + fn from(mode: InterpolateMode) -> Self { + match mode { + InterpolateMode::Nearest => OpsInterpolateMode::Nearest, + InterpolateMode::NearestExact => OpsInterpolateMode::NearestExact, + InterpolateMode::Linear => OpsInterpolateMode::Bilinear, + InterpolateMode::Cubic => OpsInterpolateMode::Bicubic, + InterpolateMode::Lanczos => OpsInterpolateMode::Lanczos3, + } + } +} diff --git a/crates/burn-nn/src/modules/linear.rs b/crates/burn-nn/src/modules/linear.rs new file mode 100644 index 0000000..1c765b6 --- /dev/null +++ b/crates/burn-nn/src/modules/linear.rs @@ -0,0 +1,317 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Param; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::module::linear; +use burn::tensor::{Device, Tensor}; + +/// Configuration to create a [`Linear`] layer using the [init function](LinearConfig::init). +#[derive(Config, Debug)] +pub struct LinearConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the output features. + pub d_output: usize, + /// If a bias should be applied during the linear transformation. + #[config(default = true)] + pub bias: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + /// The layout in which the linear parameters are stored. + #[config(default = "LinearLayout::Row")] + pub layout: LinearLayout, +} + +#[derive(Config, Debug, Copy)] +/// The layout in which the linear parameters are stored. +/// +/// This can have performance impacts. +pub enum LinearLayout { + /// Parameters are stored in Row major. + Row, + /// Parameters are stored in Col major. + Col, +} + +/// Applies a linear transformation to the input tensor. +/// +/// Should be created with [LinearConfig] +/// +/// `O = IW + b` +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Linear { + /// Matrix of shape `[d_input, d_output]` initialized from a uniform distribution: + /// `U(-k, k)`, where `k = sqrt(1 / d_input)` + pub weight: Param>, + /// Vector of size `d_output` initialized from a uniform distribution: + /// `U(-k, k)`, where `k = sqrt(1 / d_input)` + pub bias: Option>>, +} + +impl LinearConfig { + /// Initialize a new [`Linear`] module. + pub fn init(&self, device: &Device) -> Linear { + let weight = match self.layout { + LinearLayout::Row => { + let shape = [self.d_input, self.d_output]; + self.initializer + .init_with(shape, Some(self.d_input), Some(self.d_output), device) + } + LinearLayout::Col => { + let shape = [self.d_output, self.d_input]; + + self.initializer + .init_with(shape, Some(self.d_output), Some(self.d_input), device) + // The param is already transposed when init. We re-transpose to have + // [d_output, d_input] while saving. + .save_mapper(move |tensor| { + let device = tensor.device(); + device.sync().unwrap(); + let tensor = tensor.transpose(); + device.sync().unwrap(); + tensor + }) + // When loading from record we have to transpose. + .load_mapper(move |tensor| { + let device = tensor.device(); + device.sync().unwrap(); + let tensor = tensor.transpose(); + device.sync().unwrap(); + + tensor + }) + // When loading from initialization, we have to transpose. + .init_mapper(|tensor| { + let device = tensor.device(); + device.sync().unwrap(); + let tensor = tensor.transpose(); + device.sync().unwrap(); + tensor + }) + } + }; + let bias = if self.bias { + Some(self.initializer.init_with( + [self.d_output], + Some(self.d_input), + Some(self.d_output), + device, + )) + } else { + None + }; + + Linear { weight, bias } + } +} + +impl Linear { + /// Applies the forward pass on the input tensor. + /// + /// # Arguments + /// + /// - `input` - The input tensor of shape `[..., d_input]`. + /// + /// # Shapes + /// + /// - input: `[..., d_input]` + /// - output: `[..., d_output]` + /// + /// # Returns + /// + /// The transformed tensor of shape `[..., d_output]`. + pub fn forward(&self, input: Tensor) -> Tensor { + linear( + input, + self.weight.val(), + self.bias.as_ref().map(|b| b.val()), + ) + } +} + +impl ModuleDisplay for Linear { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, d_output] = self.weight.shape().dims(); + content + .add("d_input", &d_input) + .add("d_output", &d_output) + .add("bias", &self.bias.is_some()) + .optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::module::ParamId; + use burn::store::ModuleRecord; + use burn::tensor::ElementConversion; + use burn::tensor::Tolerance; + use burn::tensor::{Shape, TensorData}; + type FT = f32; + + #[test] + fn initializer_default() { + let device = Device::default(); + device.seed(0); + + let config = LinearConfig::new(5, 5); + let k = (1.0 / config.d_input as f64).sqrt().elem::(); + let linear = config.init(&device); + + assert_eq!( + config.initializer, + Initializer::KaimingUniform { + gain: 1.0 / 3.0f64.sqrt(), + fan_out_only: false + } + ); + linear.weight.to_data().assert_within_range(-k..k); + } + + #[test] + fn initializer_zeros() { + let device = Device::default(); + device.seed(0); + + let config = LinearConfig::new(5, 5).with_initializer(Initializer::Zeros); + let linear = config.init(&device); + + assert_eq!(config.initializer, Initializer::Zeros); + linear.weight.to_data().assert_approx_eq::( + &TensorData::zeros::(linear.weight.shape()), + Tolerance::default(), + ); + } + + #[test] + fn test_linear_forward_no_bias() { + let device = Device::default(); + device.seed(0); + + let value = 2.; + let config = LinearConfig::new(2, 3) + .with_initializer(Initializer::Constant { value }) + .with_bias(false); + let linear = config.init(&device); + + let input = Tensor::<2>::ones(Shape::new([1, 2]), &device); + let result = linear.forward(input); + let expected_result = Tensor::<2>::from_data([[4., 4., 4.]], &device); + + assert_eq!(result.into_data(), expected_result.into_data()); + } + + #[test] + fn test_linear_forward_with_bias() { + let device = Device::default(); + device.seed(0); + + let device = Device::default(); + + let value = 2.; + let config = LinearConfig::new(2, 3).with_initializer(Initializer::Constant { value }); + let linear = config.init(&device); + + let input = Tensor::<2>::ones(Shape::new([1, 2]), &device); + let result = linear.forward(input); + let expected_result = Tensor::<2>::from_data([[6., 6., 6.]], &device); + + assert_eq!(result.into_data(), expected_result.into_data()); + } + + #[test] + fn test_linear_1d() { + let device = Device::default(); + device.seed(0); + + let value = 2.; + let config = LinearConfig::new(2, 3).with_initializer(Initializer::Constant { value }); + let linear = config.init(&device); + + let input_1d = Tensor::<1>::ones(Shape::new([2]), &device); + let input_2d = Tensor::<2>::ones(Shape::new([1, 2]), &device); + + let result_1d = linear.forward(input_1d).unsqueeze::<2>(); + let result_2d = linear.forward(input_2d); + + assert_eq!(result_1d.into_data(), result_2d.into_data()); + } + + #[test] + fn display() { + let config = LinearConfig::new(3, 5); + let linear = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{linear}"), + "Linear {d_input: 3, d_output: 5, bias: true, params: 20}" + ); + } + + #[test] + fn layout() { + let device = Default::default(); + // The `Col` layout exposes the weight matrix as `[d_input, d_output]`. + let linear = LinearConfig::new(6, 12) + .with_layout(LinearLayout::Col) + .init(&device); + assert_eq!(linear.weight.dims(), [6, 12], "Shape is as configured"); + } + + #[test] + fn round_trip_burnpack() { + let device = Default::default(); + let linear = LinearConfig::new(6, 12).init(&device); + + let weight_before = linear.weight.val().to_data(); + let data = linear.into_record().into_bytes().unwrap(); + + let linear = LinearConfig::new(6, 12) + .init(&device) + .load_record(ModuleRecord::from_bytes(data).unwrap()); + + linear + .weight + .val() + .to_data() + .assert_eq(&weight_before, true); + } + + #[test] + fn col_row_same_result() { + let device = Default::default(); + let config_col = LinearConfig::new(6, 12).with_layout(LinearLayout::Col); + let linear_col = config_col.init(&device); + let signal = Tensor::<2>::random([8, 6], burn::tensor::Distribution::Default, &device); + let value = linear_col.forward(signal.clone()); + + let data_1 = value.into_data(); + + let weights = linear_col.weight.val().into_data(); + let weights = Tensor::from_data(weights, &device); + + let linear = Linear { + weight: Param::initialized(ParamId::new(), weights), + bias: linear_col + .bias + .map(|b| Param::initialized(ParamId::new(), b.val())), + }; + + let value = linear.forward(signal); + let data_2 = value.into_data(); + + data_1.assert_approx_eq::(&data_2, Default::default()); + } +} diff --git a/crates/burn-nn/src/modules/mod.rs b/crates/burn-nn/src/modules/mod.rs new file mode 100644 index 0000000..d08d91f --- /dev/null +++ b/crates/burn-nn/src/modules/mod.rs @@ -0,0 +1,41 @@ +/// Attention module +pub mod attention; + +/// Cache module +pub mod cache; + +/// Convolution module +pub mod conv; + +/// Pooling module +pub mod pool; + +/// Transformer module +pub mod transformer; + +mod identity; +/// Interpolate module +pub mod interpolate; + +mod dropout; +mod embedding; +mod linear; +mod noise; +mod pos_encoding; +mod rnn; +mod rope_encoding; +mod unfold; + +pub mod norm; + +pub use norm::{batch::*, group::*, instance::*, layer::*, local_response::*, rms::*}; + +pub use dropout::*; +pub use embedding::*; +pub use identity::*; +pub use linear::*; +pub use noise::*; +pub use pos_encoding::*; +pub use rnn::*; +pub use rope_encoding::*; +pub use unfold::*; diff --git a/crates/burn-nn/src/modules/noise.rs b/crates/burn-nn/src/modules/noise.rs new file mode 100644 index 0000000..aead989 --- /dev/null +++ b/crates/burn-nn/src/modules/noise.rs @@ -0,0 +1,116 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::{Distribution, Tensor}; + +/// Configuration to create a [GaussianNoise](GaussianNoise) layer using the [init function](GaussianNoiseConfig::init). +#[derive(Config, Debug)] +pub struct GaussianNoiseConfig { + /// Standard deviation of the normal noise distribution. + pub std: f64, +} + +/// Add pseudorandom Gaussian noise to an arbitrarily shaped tensor. +/// +/// This is an effective regularization technique that also contributes to data augmentation. +/// Please keep in mind that the value of [std](GaussianNoise::std) should be chosen with care in order to avoid +/// distortion. +/// +/// Should be created with [GaussianNoiseConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct GaussianNoise { + /// Standard deviation of the normal noise distribution. + pub std: f64, +} + +impl GaussianNoiseConfig { + /// Initialize a new [Gaussian noise](GaussianNoise) module. + pub fn init(&self) -> GaussianNoise { + if self.std.is_sign_negative() { + panic!( + "Standard deviation is required to be non-negative, but got {}", + self.std + ); + } + GaussianNoise { std: self.std } + } +} + +impl GaussianNoise { + /// Applies the forward pass on the input tensor. + /// + /// See [GaussianNoise](GaussianNoise) for more information. + /// + /// # Shapes + /// + /// - input: `[..., any]` + /// - output: `[..., any]` + pub fn forward(&self, input: Tensor) -> Tensor { + if input.device().is_autodiff() && self.std != 0.0 { + let noise = Tensor::random( + input.shape(), + Distribution::Normal(0.0, self.std), + &input.device(), + ); + input + noise + } else { + input + } + } +} + +impl ModuleDisplay for GaussianNoise { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("std", &self.std).optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::{Device, Shape}; + + #[cfg(feature = "std")] + #[test] + fn with_ad_backend_should_mark_input() { + let device = Device::default().autodiff(); + let tensor = Tensor::<2>::ones(Shape::new([100, 100]), &device); + let noise = GaussianNoiseConfig::new(0.5).init(); + + let output = noise.forward(tensor.clone()); + + assert_ne!(tensor.to_data(), output.to_data()); + } + + #[test] + fn without_ad_backend_should_not_change_input() { + let tensor = Tensor::<2>::ones(Shape::new([100, 100]), &Default::default()); + let noise = GaussianNoiseConfig::new(0.5).init(); + + let output = noise.forward(tensor.clone()); + + assert_eq!(tensor.to_data(), output.to_data()); + } + + #[test] + #[should_panic(expected = "Standard deviation is required to be non-negative")] + fn negative_std_should_panic() { + GaussianNoiseConfig { std: -0.5 }.init(); + } + + #[test] + fn display() { + let config = GaussianNoiseConfig::new(0.5); + let layer = config.init(); + + assert_eq!(alloc::format!("{layer}"), "GaussianNoise {std: 0.5}"); + } +} diff --git a/crates/burn-nn/src/modules/norm/batch.rs b/crates/burn-nn/src/modules/norm/batch.rs new file mode 100644 index 0000000..8cb51ed --- /dev/null +++ b/crates/burn-nn/src/modules/norm/batch.rs @@ -0,0 +1,485 @@ +use burn_core as burn; + +use burn::module::Initializer; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::{Device, Tensor}; +use burn::{ + config::Config, + module::{Module, Param, RunningState}, +}; + +/// [`BatchNorm`] Configuration. +/// +/// Used to create a [`BatchNorm`] layer using the [`BatchNormConfig::init`]. +#[derive(Config, Debug)] +pub struct BatchNormConfig { + /// The number of features. + pub num_features: usize, + /// A value required for numerical stability. Default: 1e-5 + #[config(default = 1e-5)] + pub epsilon: f64, + /// Momentum used to update the metrics. Default: 0.1 + #[config(default = 0.1)] + pub momentum: f64, +} + +/// Applies Batch Normalization over a tensor. +/// +/// Based upon the paper [Batch Normalization](https://arxiv.org/abs/1502.03167). +/// +/// Assumes input tensor is of shape ``[batch_size, channels, ...]``. +/// +/// `Y = norm(X) * γ + β` +/// +/// Where: +/// - `X` is the input tensor +/// - `Y` is the output tensor +/// - `norm` is the normalization function +/// - `γ` is the learnable weight +/// - `β` is the learnable bias +/// +/// Should be created using [`BatchNormConfig`]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct BatchNorm { + /// The learnable weight gamma. + pub gamma: Param>, + /// The learnable weight beta. + pub beta: Param>, + /// The running mean. + pub running_mean: RunningState>, + /// The running variance. + pub running_var: RunningState>, + /// Momentum used to update the metrics. + pub momentum: f64, + /// A value required for numerical stability. + pub epsilon: f64, +} + +impl BatchNormConfig { + /// Initializes a new [batch norm](BatchNorm) module. + pub fn init(&self, device: &Device) -> BatchNorm { + let gamma = Initializer::Ones.init([self.num_features], device); + let beta = Initializer::Zeros.init([self.num_features], device); + + let running_mean = Tensor::zeros([self.num_features], device); + let running_var = Tensor::ones([self.num_features], device); + + BatchNorm { + gamma, + beta, + running_mean: RunningState::new(running_mean), + running_var: RunningState::new(running_var), + momentum: self.momentum, + epsilon: self.epsilon, + } + } +} + +impl BatchNorm { + /// Applies the forward pass on the input tensor. + /// + /// See [`BatchNorm`] for more information. + /// + /// # Shapes + /// + /// - `input`: ``[batch_size, channels, ...]`` + /// - `output`: ``[batch_size, channels, ...]`` + /// + /// # Panics + /// + /// This function will panic if the input tensor has rank < 2. + pub fn forward(&self, input: Tensor) -> Tensor { + // Should be move to a compilation error when const generic support that kind of + // validation. https://github.com/rust-lang/rust/issues/76560 + if D < 2 { + panic!( + "BatchNorm can only be applied on tensors of rank >= 2 with the following shape \ + [batch_size, channels, ...], received {}D tensor", + D + ); + } + + match input.device().is_autodiff() { + true => self.forward_train(input), + false => self.forward_inference(input), + } + } + + fn forward_inference(&self, input: Tensor) -> Tensor { + let device = input.device(); + let channels = input.dims()[1]; + let mean = self.running_mean.value().to_device(&device); + let var = self.running_var.value().to_device(&device); + + let mut shape = [1; D]; + shape[1] = channels; + + self.forward_shared(input, mean.reshape(shape), var.reshape(shape)) + } + + fn forward_train(&self, input: Tensor) -> Tensor { + let device = input.device(); + let dims = input.dims(); + let batch_size = dims[0]; + let channels = dims[1]; + + let mut shape_unsqueeze = [1; D]; + let mut flatten_size = batch_size; + shape_unsqueeze[1] = channels; + + for dim in dims.iter().take(D).skip(2) { + flatten_size *= dim; + } + + let mean = input + .clone() + .swap_dims(0, 1) + .reshape([channels, flatten_size]) + .mean_dim(1) + .reshape(shape_unsqueeze); + + let var = input + .clone() + .sub(mean.clone()) + .square() + .swap_dims(0, 1) + .reshape([channels, flatten_size]) + .mean_dim(1) + .reshape(shape_unsqueeze); + + let running_mean = self.running_mean.value_sync().to_device(&device); + let running_var = self.running_var.value_sync().to_device(&device); + + let running_mean = running_mean.mul_scalar(1.0 - self.momentum).add( + mean.clone() + .detach() + .mul_scalar(self.momentum) + .reshape([channels]), + ); + let running_var = running_var.mul_scalar(1.0 - self.momentum).add( + var.clone() + .detach() + .mul_scalar(self.momentum) + .reshape([channels]), + ); + + self.running_mean.update(running_mean.detach()); + self.running_var.update(running_var.detach()); + + self.forward_shared(input, mean, var) + } + + fn forward_shared( + &self, + x: Tensor, + mean: Tensor, + var: Tensor, + ) -> Tensor { + let channels = x.dims()[1]; + let mut shape = [1; D]; + shape[1] = channels; + + let std = var.add_scalar(self.epsilon).sqrt(); + + let x = x.sub(mean); + let x = x.div(std); + + let x = x.mul(self.gamma.val().reshape(shape)); + + x.add(self.beta.val().reshape(shape)) + } +} + +impl ModuleDisplay for BatchNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [num_features] = self.beta.shape().dims(); + + content + .add("num_features", &num_features) + .add("momentum", &self.momentum) + .add("epsilon", &self.epsilon) + .optional() + } +} + +#[cfg(feature = "std")] +#[cfg(test)] +mod tests_1d { + use super::*; + use burn::module::AutodiffModule; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn batch_norm_forward_train() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + + let output = module.forward(input_tensor(&device)); + + output + .to_data() + .assert_approx_eq::(&expected_train(), Tolerance::rel_abs(0.1, 0.001)); + } + + #[test] + fn batch_norm_forward_inference() { + let device = Device::default(); + let device_autodiff = device.clone().autodiff(); + let module = BatchNormConfig::new(3).init(&device_autodiff); + + module.forward(input_tensor(&device_autodiff)); + let module = module.valid(); + let output = module.forward(input_tensor(&device)); + + output + .to_data() + .assert_approx_eq::(&expected_valid(), Tolerance::default()); + } + + fn expected_valid() -> TensorData { + TensorData::from([ + [[0.9409, 0.6976], [0.5892, 0.8774], [0.9106, 0.6844]], + [[0.6012, 0.0782], [-0.0394, 0.9270], [0.6181, 0.5492]], + ]) + } + + fn expected_train() -> TensorData { + TensorData::from([ + [ + [1.1483e+00, 3.7521e-01], + [1.6272e-03, 7.5067e-01], + [1.6204e+00, -4.5168e-02], + ], + [ + [6.8856e-02, -1.5923e+00], + [-1.6318e+00, 8.7949e-01], + [-5.3368e-01, -1.0416e+00], + ], + ]) + } + + fn input_tensor(device: &Device) -> Tensor<3> { + Tensor::<3>::from_floats( + [ + [[0.9601, 0.7277], [0.6272, 0.9034], [0.9378, 0.7230]], + [[0.6356, 0.1362], [0.0249, 0.9509], [0.6600, 0.5945]], + ], + device, + ) + } + + #[test] + fn batch_norm_forward_train_inference() { + let device = Device::default(); + let device_autodiff = device.clone().autodiff(); + let module = BatchNormConfig::new(3).init(&device_autodiff); + + module.forward(input_tensor(&device_autodiff)); + let module = module.valid(); + let output = module.forward(input_tensor(&device)); + + output + .to_data() + .assert_approx_eq::(&expected_valid(), Tolerance::default()); + + let module = module.train(); + let output = module.forward(input_tensor(&device_autodiff)); + output + .to_data() + .assert_approx_eq::(&expected_train(), Tolerance::default()); + } +} + +#[cfg(feature = "std")] +#[cfg(test)] +mod tests_2d { + use super::*; + use burn::module::AutodiffModule; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn batch_norm_forward_train() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + + let output = module.forward(input_tensor(&device)); + + let expected = TensorData::from([ + [ + [[1.5136, 0.7506], [-1.2216, 0.1477]], + [[0.3135, 1.2252], [-0.4150, 0.6130]], + [[1.4186, 0.3372], [-1.5183, 1.5262]], + ], + [ + [[0.4483, -1.1914], [-1.2010, 0.7537]], + [[-1.6752, 1.3822], [-0.5058, -0.9381]], + [[0.0200, -0.3097], [-0.5715, -0.9026]], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(0.1, 0.001)); + } + + #[test] + fn batch_norm_forward_inference() { + let device = Device::default(); + let device_autodiff = device.clone().autodiff(); + let module = BatchNormConfig::new(3).init(&device_autodiff); + + module.forward(input_tensor(&device_autodiff)); + let module = module.valid(); + let output = module.forward(input_tensor(&device)); + + let expected = TensorData::from([ + [ + [[0.9538, 0.7103], [0.0808, 0.5179]], + [[0.6015, 0.8910], [0.3703, 0.6966]], + [[0.9171, 0.6912], [0.3037, 0.9395]], + ], + [ + [[0.6138, 0.0904], [0.0874, 0.7113]], + [[-0.0297, 0.9408], [0.3415, 0.2042]], + [[0.6250, 0.5561], [0.5013, 0.4323]], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn batch_norm_running_mean() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + + let _output = module.forward(input_tensor(&device)); + + let running_mean = module.running_mean.value_sync(); + + let expected = TensorData::from([0.0499, 0.0532, 0.0656]); + running_mean + .reshape([3]) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn batch_norm_running_var() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + + let _output = module.forward(input_tensor(&device)); + + let running_var = module.running_var.value_sync(); + + let expected = TensorData::from([0.9106, 0.9105, 0.9045]); + running_var + .reshape([3]) + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn batch_norm_running_mean_inner_module() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + + let _output = module.forward(input_tensor(&device)); + + let module_valid = module.valid(); + let running_mean = module_valid.running_mean.value(); + let running_mean_after = module.running_mean.value(); + + running_mean_after + .into_data() + .assert_approx_eq::(&running_mean.into_data(), Tolerance::default()); + } + + #[test] + fn batch_norm_grads() { + let device = Device::default().autodiff(); + let module = BatchNormConfig::new(3).init(&device); + let input = input_tensor(&device).require_grad(); + + let output = module.forward(input.clone()); + + let grads = output.backward(); + + let tolerance = Tolerance::rel_abs(0.1, 0.001); + let expected = TensorData::from([0.0000e+00, -5.9035e-07, -6.0011e-07]); + module + .gamma + .grad(&grads) + .unwrap() + .reshape([3]) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([8., 8., 8.]); + module + .beta + .grad(&grads) + .unwrap() + .reshape([3]) + .into_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([ + [ + [[0.0000e+00, 0.0000e+00], [0.0000e+00, 0.0000e+00]], + [[7.6400e-08, 2.9848e-07], [-1.0110e-07, 1.4933e-07]], + [[5.3570e-07, 1.2732e-07], [-5.7336e-07, 5.7632e-07]], + ], + [ + [[0.0000e+00, 0.0000e+00], [0.0000e+00, 0.0000e+00]], + [[-4.0807e-07, 3.3673e-07], [-1.2323e-07, -2.2854e-07]], + [[7.5642e-09, -1.1695e-07], [-2.1582e-07, -3.4078e-07]], + ], + ]); + input + .grad(&grads) + .unwrap() + .into_data() + .assert_approx_eq::(&expected, tolerance); + } + + fn input_tensor(device: &Device) -> Tensor<4> { + Tensor::<4>::from_floats( + [ + [ + [[0.9601, 0.7277], [0.1270, 0.5441]], + [[0.6272, 0.9034], [0.4066, 0.7179]], + [[0.9378, 0.7230], [0.3544, 0.9591]], + ], + [ + [[0.6356, 0.1362], [0.1333, 0.7287]], + [[0.0249, 0.9509], [0.3791, 0.2481]], + [[0.6600, 0.5945], [0.5424, 0.4767]], + ], + ], + device, + ) + } + + #[test] + fn display() { + let batch_norm = BatchNormConfig::new(3).init(&Default::default()); + + assert_eq!( + format!("{batch_norm}"), + "BatchNorm {num_features: 3, momentum: 0.1, epsilon: 0.00001, params: 12}" + ); + } +} diff --git a/crates/burn-nn/src/modules/norm/group.rs b/crates/burn-nn/src/modules/norm/group.rs new file mode 100644 index 0000000..0f53761 --- /dev/null +++ b/crates/burn-nn/src/modules/norm/group.rs @@ -0,0 +1,331 @@ +use burn::module::Initializer; +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::Param; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// Configuration to create a [GroupNorm](GroupNorm) layer using the [init function](GroupNormConfig::init). +#[derive(Debug, Config)] +pub struct GroupNormConfig { + /// The number of groups to separate the channels into + pub num_groups: usize, + /// The number of channels expected in the input + pub num_channels: usize, + /// A value required for numerical stability. Default: 1e-5 + #[config(default = 1e-5)] + pub epsilon: f64, + /// A boolean value that when set to `true`, this module has learnable + /// per-channel affine parameters initialized to ones (for weights) + /// and zeros (for biases). Default: `true` + #[config(default = true)] + pub affine: bool, +} + +/// Applies Group Normalization over a mini-batch of inputs as described in the paper [Group Normalization](https://arxiv.org/abs/1803.08494). +/// +/// `Y = groupnorm(X) * γ + β` +/// +/// Where: +/// - `X` is the input tensor +/// - `Y` is the output tensor +/// - `γ` is the learnable weight +/// - `β` is the learnable bias +/// +/// Should be created using [GroupNormConfig](GroupNormConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct GroupNorm { + /// The learnable weight + pub gamma: Option>>, + /// The learnable bias + pub beta: Option>>, + /// The number of groups to separate the channels into + pub num_groups: usize, + /// The number of channels expected in the input + pub num_channels: usize, + /// A value required for numerical stability + pub epsilon: f64, + /// A boolean value that when set to `true`, this module has learnable + pub affine: bool, +} + +impl ModuleDisplay for GroupNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("num_groups", &self.num_groups) + .add("num_channels", &self.num_channels) + .add("epsilon", &self.epsilon) + .add("affine", &self.affine) + .optional() + } +} + +impl GroupNormConfig { + /// Initialize a new [group norm](GroupNorm) module. + pub fn init(&self, device: &Device) -> GroupNorm { + assert_eq!( + self.num_channels % self.num_groups, + 0, + "The number of channels must be divisible by the number of groups" + ); + + let (gamma, beta) = if self.affine { + let gamma = Initializer::Ones.init([self.num_channels], device); + let beta = Initializer::Zeros.init([self.num_channels], device); + + (Some(gamma), Some(beta)) + } else { + (None, None) + }; + + GroupNorm { + num_groups: self.num_groups, + num_channels: self.num_channels, + gamma, + beta, + epsilon: self.epsilon, + affine: self.affine, + } + } +} + +impl GroupNorm { + /// Applies the forward pass on the input tensor. + /// + /// See [GroupNorm](GroupNorm) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, num_channels, *]` + /// - output: `[batch_size, num_channels, *]` + pub fn forward(&self, input: Tensor) -> Tensor { + if input.shape()[1] != self.num_channels { + panic!( + "The number of channels in the input tensor should be equal to the number of channels in the GroupNorm module. Expected {}, got {}", + self.num_channels, + input.shape()[1] + ); + } + + let gamma = self.gamma.as_ref().map(|x| x.val()); + let beta = self.beta.as_ref().map(|x| x.val()); + + group_norm( + input, + gamma, + beta, + self.num_groups, + self.epsilon, + self.affine, + ) + } +} + +/// Applies Group Normalization over a mini-batch of inputs as described in the paper [Group Normalization](https://arxiv.org/abs/1803.08494). +/// +/// `Y = groupnorm(X) * γ + β` +/// +/// Where: +/// - `X` is the input tensor +/// - `Y` is the output tensor +/// - `γ` is the learnable weight +/// - `β` is the learnable bias +/// +pub(crate) fn group_norm( + input: Tensor, + gamma: Option>, + beta: Option>, + num_groups: usize, + epsilon: f64, + affine: bool, +) -> Tensor { + if (beta.is_none() || gamma.is_none()) && affine { + panic!("Affine is set to true, but gamma or beta is None"); + } + + let shape = input.shape(); + if shape.num_elements() <= 2 { + panic!( + "input rank for GroupNorm should be at least 3, but got {}", + shape.num_elements() + ); + } + + let batch_size = shape[0]; + let num_channels = shape[1]; + + let hidden_size = shape[2..].iter().product::() * num_channels / num_groups; + let input = input.reshape([batch_size, num_groups, hidden_size]); + + let mean = input.clone().sum_dim(2) / hidden_size as f64; + let input = input.sub(mean); + + let var = input.clone().square().sum_dim(2) / hidden_size as f64; + let input_normalized = input.div(var.add_scalar(epsilon).sqrt()); + + if affine { + let mut affine_shape = [1; D]; + affine_shape[1] = num_channels; + + input_normalized + .reshape(shape) + .mul(gamma.clone().unwrap().reshape(affine_shape)) + .add(beta.clone().unwrap().reshape(affine_shape)) + } else { + input_normalized.reshape(shape) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn group_norm_forward_affine_false() { + let device = Default::default(); + let module = GroupNormConfig::new(2, 6).with_affine(false).init(&device); + + assert!(module.gamma.is_none()); + assert!(module.beta.is_none()); + + let input = Tensor::<3>::from_data( + TensorData::from([ + [ + [-0.3034, 0.2726, -0.9659], + [-1.1845, -1.3236, 0.0172], + [1.9507, 1.2554, -0.8625], + [1.0682, 0.3604, 0.3985], + [-0.4957, -0.4461, -0.9721], + [1.5157, -0.1546, -0.5596], + ], + [ + [-1.6698, -0.4040, -0.7927], + [0.3736, -0.0975, -0.1351], + [-0.9461, 0.5461, -0.6334], + [-1.0919, -0.1158, 0.1213], + [-0.9535, 0.1281, 0.4372], + [-0.2845, 0.3488, 0.5641], + ], + ]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([ + [ + [-0.1653, 0.3748, -0.7866], + [-0.9916, -1.1220, 0.1353], + [1.9485, 1.2965, -0.6896], + [1.2769, 0.3628, 0.4120], + [-0.7427, -0.6786, -1.3578], + [1.8547, -0.3022, -0.8252], + ], + [ + [-1.9342, 0.0211, -0.5793], + [1.2223, 0.4945, 0.4365], + [-0.8163, 1.4887, -0.3333], + [-1.7960, -0.0392, 0.3875], + [-1.5469, 0.3998, 0.9561], + [-0.3428, 0.7970, 1.1845], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn group_norm_forward_affine_true() { + let device = Default::default(); + let module = GroupNormConfig::new(3, 6).with_affine(true).init(&device); + + let tolerance = Tolerance::permissive(); + module + .gamma + .as_ref() + .expect("gamma should not be None") + .val() + .to_data() + .assert_approx_eq::(&TensorData::ones::([6]), tolerance); + + module + .beta + .as_ref() + .expect("beta should not be None") + .val() + .to_data() + .assert_approx_eq::(&TensorData::zeros::([6]), tolerance); + + let input = Tensor::<3>::from_data( + TensorData::from([ + [ + [0.3345, 0.4429, 0.6639], + [0.5041, 0.4175, 0.8437], + [0.6159, 0.3758, 0.4071], + [0.5417, 0.5785, 0.7671], + [0.3837, 0.9883, 0.0420], + [0.4808, 0.8989, 0.6144], + ], + [ + [0.3930, 0.2098, 0.0602], + [0.2298, 0.9425, 0.0333], + [0.7409, 0.8172, 0.8879], + [0.4846, 0.0486, 0.2029], + [0.6741, 0.9765, 0.6864], + [0.2827, 0.5534, 0.2125], + ], + ]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([ + [ + [-1.1694, -0.5353, 0.7572], + [-0.1775, -0.6838, 1.8087], + [0.5205, -1.3107, -1.0723], + [-0.0459, 0.2351, 1.6734], + [-0.5796, 1.3218, -1.6544], + [-0.2744, 1.0406, 0.1459], + ], + [ + [0.2665, -0.3320, -0.8205], + [-0.2667, 2.0612, -0.9085], + [0.6681, 0.9102, 1.1345], + [-0.1453, -1.5287, -1.0389], + [0.4253, 1.5962, 0.4731], + [-1.0903, -0.0419, -1.3623], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, tolerance); + } + + #[test] + fn display() { + let config = GroupNormConfig::new(3, 6); + let group_norm = config.init(&Default::default()); + + assert_eq!( + format!("{group_norm}"), + "GroupNorm {num_groups: 3, num_channels: 6, epsilon: 0.00001, affine: true, params: 12}" + ); + } +} diff --git a/crates/burn-nn/src/modules/norm/instance.rs b/crates/burn-nn/src/modules/norm/instance.rs new file mode 100644 index 0000000..a64c87f --- /dev/null +++ b/crates/burn-nn/src/modules/norm/instance.rs @@ -0,0 +1,223 @@ +use burn_core as burn; + +use crate::norm::group_norm; +use burn::config::Config; +use burn::module::Initializer; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::module::{Module, Param}; +use burn::tensor::{Device, Tensor}; + +/// Configuration to create a [InstanceNorm](InstanceNorm) layer using the [init function](InstanceNormConfig::init). +#[derive(Debug, Config)] +pub struct InstanceNormConfig { + /// The number of channels expected in the input + pub num_channels: usize, + /// A value required for numerical stability. Default: 1e-5 + #[config(default = 1e-5)] + pub epsilon: f64, + /// A boolean value that when set to `true`, this module has learnable + /// per-channel affine parameters initialized to ones (for weights) + /// and zeros (for biases). Default: `true` + #[config(default = true)] + pub affine: bool, +} + +/// Applies Instance Normalization over a tensor as described in the paper [Instance Normalization](https://arxiv.org/abs/1607.08022) +/// +/// Should be created using [InstanceNormConfig](InstanceNormConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct InstanceNorm { + /// The learnable weight + pub gamma: Option>>, + /// The learnable bias + pub beta: Option>>, + /// The number of channels expected in the input + pub num_channels: usize, + /// A value required for numerical stability + pub epsilon: f64, + /// A boolean value that when set to `true`, this module has learnable + pub affine: bool, +} + +impl ModuleDisplay for InstanceNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("num_channels", &self.num_channels) + .add("epsilon", &self.epsilon) + .add("affine", &self.affine) + .optional() + } +} + +impl InstanceNormConfig { + /// Initialize a new [instance norm](InstanceNorm) module. + pub fn init(&self, device: &Device) -> InstanceNorm { + let (gamma, beta) = if self.affine { + let gamma = Initializer::Ones.init([self.num_channels], device); + let beta = Initializer::Zeros.init([self.num_channels], device); + + (Some(gamma), Some(beta)) + } else { + (None, None) + }; + + InstanceNorm { + gamma, + beta, + num_channels: self.num_channels, + epsilon: self.epsilon, + affine: self.affine, + } + } +} + +impl InstanceNorm { + /// Applies the forward pass on the input tensor. + /// + /// See also [InstanceNormConfig](InstanceNormConfig) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, num_channels, *]` + /// - output: `[batch_size, num_channels, *]` + pub fn forward(&self, input: Tensor) -> Tensor { + // Instance norm is equivalent to group norm when the number of groups is equal to the number of channels. + let num_groups = self.num_channels; + + let gamma = self.gamma.as_ref().map(|x| x.val()); + let beta = self.beta.as_ref().map(|x| x.val()); + + group_norm(input, gamma, beta, num_groups, self.epsilon, self.affine) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn instance_norm_forward_affine_false() { + let device = Default::default(); + let module = InstanceNormConfig::new(6).with_affine(false).init(&device); + + let input = Tensor::<3>::from_data( + TensorData::from([ + [ + [-0.3034, 0.2726, -0.9659], + [-1.1845, 1.4078, 0.9774], + [0.3963, -1.3738, 1.4125], + [1.0682, 0.3604, 0.3985], + [-0.4957, -0.4461, -0.9721], + [1.5157, -0.1546, -0.5596], + ], + [ + [-1.6698, -0.4040, -0.7927], + [0.3736, -0.0975, -0.1351], + [-0.9461, 0.5461, -0.6334], + [-1.0919, -0.1158, 0.1213], + [-0.9535, 0.1281, 0.4372], + [-0.2845, 0.3488, 0.5641], + ], + ]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([ + [ + [0.0569, 1.1952, -1.2522], + [-1.3971, 0.8883, 0.5088], + [0.2183, -1.3192, 1.1009], + [1.4126, -0.7649, -0.6477], + [0.5999, 0.8091, -1.409], + [1.39, -0.4696, -0.9205], + ], + [ + [-1.3492, 1.0417, 0.3075], + [1.411, -0.6243, -0.7867], + [-0.9363, 1.386, -0.4497], + [-1.3899, 0.4692, 0.9208], + [-1.3822, 0.4319, 0.9503], + [-1.3714, 0.3868, 0.9846], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn instance_norm_forward_affine_true() { + let device = Default::default(); + let module = InstanceNormConfig::new(6).with_affine(true).init(&device); + + let input = Tensor::<3>::from_data( + TensorData::from([ + [ + [0.3345, 0.4429, 0.6639], + [0.5041, 0.4175, 0.8437], + [0.6159, 0.3758, 0.4071], + [0.5417, 0.5785, 0.7671], + [0.3837, 0.9883, 0.0420], + [0.4808, 0.8989, 0.6144], + ], + [ + [0.3930, 0.2098, 0.0602], + [0.2298, 0.9425, 0.0333], + [0.7409, 0.8172, 0.8879], + [0.4846, 0.0486, 0.2029], + [0.6741, 0.9765, 0.6864], + [0.2827, 0.5534, 0.2125], + ], + ]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([ + [ + [-1.06458, -0.2738, 1.33838], + [-0.45848, -0.92929, 1.38777], + [1.40388, -0.84877, -0.55511], + [-0.88515, -0.51245, 1.3976], + [-0.22397, 1.32124, -1.09727], + [-1.05468, 1.34316, -0.28848], + ], + [ + [1.26372, -0.08229, -1.18144], + [-0.44049, 1.38403, -0.94354], + [-1.23828, 0.03109, 1.2072], + [1.32524, -1.08999, -0.23524], + [-0.75061, 1.4132, -0.66259], + [-0.45469, 1.38697, -0.93228], + ], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = InstanceNormConfig::new(6); + let instance_norm = config.init(&Default::default()); + + assert_eq!( + format!("{instance_norm}"), + "InstanceNorm {num_channels: 6, epsilon: 0.00001, affine: true, params: 12}" + ); + } +} diff --git a/crates/burn-nn/src/modules/norm/layer.rs b/crates/burn-nn/src/modules/norm/layer.rs new file mode 100644 index 0000000..b781e2e --- /dev/null +++ b/crates/burn-nn/src/modules/norm/layer.rs @@ -0,0 +1,239 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Content; +use burn::module::DisplaySettings; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::ModuleDisplay; +use burn::module::Param; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::module::layer_norm; + +/// Configuration to create a [LayerNorm](LayerNorm) layer using the [init function](LayerNormConfig::init). +#[derive(Debug, Config)] +pub struct LayerNormConfig { + /// The size of the input features. + pub d_model: usize, + /// A value required for numerical stability. Default: 1e-5 + #[config(default = 1e-5)] + pub epsilon: f64, + /// If a bias (beta) should be applied during the normalization. Default: true + #[config(default = true)] + pub bias: bool, +} + +/// Applies Layer Normalization over an input tensor as described in the paper [Layer Normalization](https://arxiv.org/abs/1607.06450). +/// +/// `Y = norm(X) * γ + β` +/// +/// Where: +/// - `X` is the input tensor +/// - `Y` is the output tensor +/// - `γ` is the learnable weight (scale) +/// - `β` is the learnable bias (optional) +/// +/// Should be created using [LayerNormConfig](LayerNormConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct LayerNorm { + /// The learnable weight (scale). + pub gamma: Param>, + /// The learnable bias (optional). + pub beta: Option>>, + /// A value required for numerical stability. + epsilon: f64, +} + +impl LayerNormConfig { + /// Initialize a new [layer norm](LayerNorm) module. + pub fn init(&self, device: &Device) -> LayerNorm { + let gamma = Initializer::Ones.init([self.d_model], device); + let beta = if self.bias { + Some(Initializer::Zeros.init([self.d_model], device)) + } else { + None + }; + + LayerNorm { + gamma, + beta, + epsilon: self.epsilon, + } + } +} + +impl LayerNorm { + /// Applies the forward pass on the input tensor. + /// + /// See the [LayerNorm](LayerNorm) documentation for more information. + /// + /// # Shapes + /// + /// - input: `[..., any, d_model]` + /// - output: `[..., any, d_model]` + pub fn forward(&self, input: Tensor) -> Tensor { + layer_norm( + input, + self.gamma.val(), + self.beta.as_ref().map(|b| b.val()), + self.epsilon, + ) + } +} + +impl ModuleDisplay for LayerNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_model] = self.gamma.shape().dims(); + content + .add("d_model", &d_model) + .add("epsilon", &self.epsilon) + .add("bias", &self.beta.is_some()) + .optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn layer_norm_forward() { + let device = Default::default(); + let module = LayerNormConfig::new(10).init(&device); + let input = Tensor::<2>::from_data( + TensorData::from([[ + -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728, + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + -0.4990, -1.9680, 1.6178, -0.7486, -0.6470, 0.8576, 0.0461, 1.1111, -0.2614, 0.4915, + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn layer_norm_forward_large_epsilon() { + let device = Default::default(); + let module = LayerNormConfig::new(10).with_epsilon(1e-1).init(&device); + let input = Tensor::<2>::from_data( + TensorData::from([[ + -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728, + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + -0.4863, -1.9180, 1.5766, -0.7295, -0.6305, 0.8358, 0.0449, 1.0828, -0.2548, 0.4790, + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn layer_norm_forward_no_bias() { + let device = Default::default(); + let module = LayerNormConfig::new(10).with_bias(false).init(&device); + let input = Tensor::<2>::from_data( + TensorData::from([[ + -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728, + ]]), + &device, + ); + + let output = module.forward(input); + + // With bias=false, output matches the bias=true case (beta is zero-initialized + // by default), confirming the `None` branch in the backend hook produces the + // pre-beta result. + let expected = TensorData::from([[ + -0.4990, -1.9680, 1.6178, -0.7486, -0.6470, 0.8576, 0.0461, 1.1111, -0.2614, 0.4915, + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[cfg(feature = "std")] + #[test] + fn layer_norm_backward() { + let device = Device::default().autodiff(); + let module = LayerNormConfig::new(2).init(&device); + let tensor_1 = Tensor::<2>::from_data(TensorData::from([[0.0, 1.0], [3.0, 4.0]]), &device) + .require_grad(); + let tensor_2 = Tensor::<2>::from_data(TensorData::from([[6.0, 7.0], [9.0, 10.0]]), &device) + .require_grad(); + + let x = tensor_1.clone().matmul(tensor_2.clone()); + + let output = module.forward(x); + let grads = output.backward(); + + let tensor_1_grad = tensor_1.grad(&grads).unwrap(); + let tensor_2_grad = tensor_2.grad(&grads).unwrap(); + let gamma_grad = module.gamma.grad(&grads).unwrap(); + let beta_grad = module.beta.as_ref().unwrap().grad(&grads).unwrap(); + + let expected = TensorData::from([-2.0, 2.0]); + gamma_grad + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::from([2.0, 2.0]); + beta_grad + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::zeros::(tensor_1_grad.shape()); + tensor_1_grad + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + + let expected = TensorData::zeros::(tensor_2_grad.shape()); + tensor_2_grad + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = LayerNormConfig::new(6); + let layer_norm = config.init(&Default::default()); + + assert_eq!( + format!("{layer_norm}"), + "LayerNorm {d_model: 6, epsilon: 0.00001, bias: true, params: 12}" + ); + } + + #[test] + fn display_no_bias() { + let config = LayerNormConfig::new(6).with_bias(false); + let layer_norm = config.init(&Default::default()); + + assert_eq!( + format!("{layer_norm}"), + "LayerNorm {d_model: 6, epsilon: 0.00001, bias: false, params: 6}" + ); + } +} diff --git a/crates/burn-nn/src/modules/norm/local_response.rs b/crates/burn-nn/src/modules/norm/local_response.rs new file mode 100644 index 0000000..1048bab --- /dev/null +++ b/crates/burn-nn/src/modules/norm/local_response.rs @@ -0,0 +1,678 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::module::avg_pool1d; +use burn::tensor::ops::PadMode; + +/// Configuration to create a [LocalResponseNorm](LocalResponseNorm) layer +/// using the [init function](LocalResponseNormConfig::init). +#[derive(Config, Debug)] +pub struct LocalResponseNormConfig { + /// Number of channels in the sliding normalization window. + pub size: usize, + /// Scaling parameter. Default: 0.0001 + #[config(default = 0.0001)] + pub alpha: f64, + /// Exponent. Default: 0.75 + #[config(default = 0.75)] + pub beta: f64, + /// Bias constant (called `bias` in ONNX). Default: 1.0 + #[config(default = 1.0)] + pub k: f64, +} + +impl LocalResponseNormConfig { + /// Initialize a new [LocalResponseNorm](LocalResponseNorm) module. + /// + /// # Panics + /// + /// Panics if `size` is 0. + pub fn init(&self) -> LocalResponseNorm { + assert!(self.size > 0, "size must be greater than 0."); + + LocalResponseNorm { + size: self.size, + alpha: self.alpha, + beta: self.beta, + k: self.k, + } + } +} + +/// Applies Local Response Normalization as described in +/// [ImageNet Classification with Deep Convolutional Neural Networks](https://papers.nips.cc/paper/2012/hash/c399862d3b9d6b76c8436e924a68c45b-Abstract.html). +/// +/// `Y = X / (k + (alpha / size) * sum(X^2))^beta` +/// +/// Where the sum is computed over a sliding window of `size` channels. +/// +/// For odd `size`, the window is centered on each channel position. +/// For even `size`, the window uses asymmetric padding and includes the current +/// channel plus one extra channel on the positive side. +/// +/// Should be created using [LocalResponseNormConfig](LocalResponseNormConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct LocalResponseNorm { + /// Number of channels in the sliding window. + size: usize, + /// Scaling parameter. + alpha: f64, + /// Exponent. + beta: f64, + /// Bias constant. + k: f64, +} + +impl LocalResponseNorm { + /// Applies Local Response Normalization on the input tensor. + /// + /// # Shapes + /// + /// - input: `[N, C, D1, D2, ..., Dk]` (rank >= 3) + /// - output: same shape as input + /// + /// # Panics + /// + /// Panics if the input tensor rank is less than 3. + pub fn forward(&self, input: Tensor) -> Tensor { + assert!( + D >= 3, + "LocalResponseNorm requires input rank >= 3, got {D}" + ); + + let shape = input.dims(); + let n = shape[0]; + let c = shape[1]; + let d_flat: usize = shape[2..].iter().product(); + + // Square the input + let squared = input.clone().square(); + + // Flatten spatial dims: [N, C, D1..Dk] -> [N, C, D_flat] + let flat: Tensor<3> = squared.reshape([n, c, d_flat]); + + // Move channel to last dim: [N, D_flat, C] + let transposed = flat.swap_dims(1, 2); + + // Batch all spatial positions: [N*D_flat, 1, C] + let batched: Tensor<3> = transposed.reshape([n * d_flat, 1, c]); + + let pad_left = (self.size - 1) / 2; + let pad_right = self.size / 2; + let square_avg = if pad_left != pad_right { + let padded = batched.pad((pad_left, pad_right, 0, 0), PadMode::Constant(0.0)); + avg_pool1d(padded, self.size, 1, 0, true, false) + } else { + avg_pool1d(batched, self.size, 1, pad_left, true, false) + }; + + // Restore shape: [N*D_flat, 1, C] -> [N, D_flat, C] -> [N, C, D_flat] -> original + let unbatched: Tensor<3> = square_avg.reshape([n, d_flat, c]); + let untransposed = unbatched.swap_dims(1, 2); + let square_avg_restored: Tensor = untransposed.reshape(shape); + + // Apply LRN formula: output = input / (k + alpha * avg(x^2))^beta + input + / square_avg_restored + .mul_scalar(self.alpha) + .add_scalar(self.k) + .powf_scalar(self.beta) + } +} + +impl ModuleDisplay for LocalResponseNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("size", &self.size) + .add("alpha", &self.alpha) + .add("beta", &self.beta) + .add("k", &self.k) + .optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + + type FT = f32; + + // --- Correctness tests (values from PyTorch, torch.manual_seed(42)) --- + + #[test] + fn forward_default_params() { + // size=5, alpha=0.0001, beta=0.75, k=1.0, input [1,3,4,4] + let device = Default::default(); + let module = LocalResponseNormConfig::new(5).init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [ + [1.9269, 1.4873, 0.9007, -2.1055], + [0.6784, -1.2345, -0.0431, -1.6047], + [-0.7521, 1.6487, -0.3925, -1.4036], + [-0.7279, -0.5594, -0.7688, 0.7624], + ], + [ + [1.6423, -0.1596, -0.4974, 0.4396], + [-0.7581, 1.0783, 0.8008, 1.6806], + [1.2791, 1.2964, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516, 0.8599], + ], + [ + [-1.3847, -0.8712, -0.2234, 1.7174], + [0.3189, -0.4245, 0.3057, -0.7746], + [-1.5576, 0.9956, -0.8798, -0.6011], + [-1.2742, 2.1228, -1.2347, -0.4879], + ], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [ + [1.9267, 1.4872, 0.9007, -2.1053], + [0.6784, -1.2345, -0.0431, -1.6045], + [-0.7521, 1.6486, -0.3925, -1.4035], + [-0.7279, -0.5594, -0.7688, 0.7624], + ], + [ + [1.6421, -0.1596, -0.4974, 0.4395], + [-0.7581, 1.0783, 0.8008, 1.6805], + [1.2790, 1.2963, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516, 0.8598], + ], + [ + [-1.3845, -0.8712, -0.2234, 1.7172], + [0.3189, -0.4245, 0.3057, -0.7745], + [-1.5575, 0.9956, -0.8798, -0.6011], + [-1.2741, 2.1226, -1.2346, -0.4879], + ], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_custom_params() { + // size=3, alpha=0.001, beta=0.5, k=2.0, input [1,4,3,3] + let device = Default::default(); + let module = LocalResponseNormConfig::new(3) + .with_alpha(0.001) + .with_beta(0.5) + .with_k(2.0) + .init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [ + [1.9269, 1.4873, 0.9007], + [-2.1055, 0.6784, -1.2345], + [-0.0431, -1.6047, -0.7521], + ], + [ + [1.6487, -0.3925, -1.4036], + [-0.7279, -0.5594, -0.7688], + [0.7624, 1.6423, -0.1596], + ], + [ + [-0.4974, 0.4396, 0.3189], + [-0.4245, 0.3057, -0.7746], + [0.0349, 0.3211, 1.5736], + ], + [ + [-0.8455, -1.2742, 2.1228], + [-1.2347, -0.4879, -1.4181], + [0.8963, 0.0499, 2.2667], + ], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [ + [1.3618, 1.0515, 0.6368], + [-1.4882, 0.4797, -0.8728], + [-0.0305, -1.1342, -0.5318], + ], + [ + [1.1652, -0.2775, -0.9923], + [-0.5145, -0.3955, -0.5435], + [0.5391, 1.1608, -0.1128], + ], + [ + [-0.3516, 0.3108, 0.2254], + [-0.3001, 0.2162, -0.5476], + [0.0247, 0.2270, 1.1120], + ], + [ + [-0.5978, -0.9008, 1.5005], + [-0.8729, -0.3450, -1.0025], + [0.6337, 0.0353, 1.6018], + ], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_even_size() { + // size=2, alpha=0.0001, beta=0.75, k=1.0, input [1,3,2,2] + let device = Default::default(); + let module = LocalResponseNormConfig::new(2).init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [[0.3367, 0.1288], [0.2345, 0.2303]], + [[-1.1229, -0.1863], [2.2082, -0.6380]], + [[0.4617, 0.2674], [0.5349, 0.8094]], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [[0.3367, 0.1288], [0.2345, 0.2303]], + [[-1.1228, -0.1863], [2.2078, -0.6380]], + [[0.4616, 0.2673], [0.5348, 0.8093]], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_even_size_uses_asymmetric_positive_side_window() { + let device = Default::default(); + let module = LocalResponseNormConfig::new(2) + .with_alpha(1.0) + .with_beta(1.0) + .with_k(0.0) + .init(); + let input = Tensor::<3>::from_data(TensorData::from([[[1.0], [2.0], [4.0]]]), &device); + + let output = module.forward(input); + + // For size=2, the implementation pads asymmetrically and uses: + // c0 -> avg([1^2, 2^2]) = 2.5 + // c1 -> avg([2^2, 4^2]) = 10.0 + // c2 -> avg([4^2, 0]) = 8.0 + let expected = TensorData::from([[[0.4], [0.2], [0.5]]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(1e-5, 1e-6)); + } + + #[test] + fn forward_3d() { + // size=3, input [1,4,6] + let device = Default::default(); + let module = LocalResponseNormConfig::new(3).init(); + let input = Tensor::<3>::from_data( + TensorData::from([[ + [1.9269, 1.4873, 0.9007, -2.1055, 0.6784, -1.2345], + [-0.0431, -1.6047, 0.3559, -0.6866, -0.4934, 0.2415], + [-1.1109, 0.0915, -2.3169, -0.2168, -0.3097, -0.3957], + [0.8034, -0.6216, -0.5920, -0.0631, -0.8286, 0.3309], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [1.9267, 1.4871, 0.9007, -2.1053, 0.6784, -1.2345], + [-0.0431, -1.6045, 0.3558, -0.6865, -0.4933, 0.2415], + [-1.1109, 0.0915, -2.3166, -0.2168, -0.3097, -0.3957], + [0.8034, -0.6216, -0.5919, -0.0631, -0.8285, 0.3309], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_5d() { + // size=3, input [1,3,2,2,2] + let device = Default::default(); + let module = LocalResponseNormConfig::new(3).init(); + let input = Tensor::<5>::from_data( + TensorData::from([[ + [ + [[1.9269, 1.4873], [0.9007, -2.1055]], + [[0.6784, -1.2345], [-0.0431, -1.6047]], + ], + [ + [[0.3559, -0.6866], [-0.4934, 0.2415]], + [[-1.1109, 0.0915], [-2.3169, -0.2168]], + ], + [ + [[-0.3097, -0.3957], [0.8034, -0.6216]], + [[-0.5920, -0.0631], [-0.8286, 0.3309]], + ], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [ + [[1.9267, 1.4872], [0.9007, -2.1053]], + [[0.6784, -1.2345], [-0.0431, -1.6046]], + ], + [ + [[0.3558, -0.6866], [-0.4933, 0.2415]], + [[-1.1108, 0.0915], [-2.3166, -0.2168]], + ], + [ + [[-0.3097, -0.3957], [0.8034, -0.6216]], + [[-0.5920, -0.0631], [-0.8284, 0.3309]], + ], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + // --- Edge case tests --- + + #[test] + fn forward_size_1() { + // size=1: window covers only self-channel, input [1,3,3,3] + let device = Default::default(); + let module = LocalResponseNormConfig::new(1).init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [ + [1.9269, 1.4873, 0.9007], + [-2.1055, 0.6784, -1.2345], + [-0.0431, -1.6047, -0.7521], + ], + [ + [1.6487, -0.3925, 0.2415], + [-1.1109, 0.0915, -2.3169], + [-0.2168, -1.3847, -0.8712], + ], + [ + [-0.2234, -0.6216, -0.5920], + [-0.0631, -0.8286, 0.3309], + [-1.5576, 0.9956, -0.8798], + ], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [ + [1.9264, 1.4870, 0.9007], + [-2.1048, 0.6784, -1.2344], + [-0.0431, -1.6044, -0.7521], + ], + [ + [1.6484, -0.3925, 0.2415], + [-1.1108, 0.0915, -2.3160], + [-0.2168, -1.3845, -0.8712], + ], + [ + [-0.2234, -0.6216, -0.5920], + [-0.0631, -0.8285, 0.3309], + [-1.5573, 0.9956, -0.8797], + ], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_c_less_than_size() { + // C=2 < size=5, input [1,2,3,3] + let device = Default::default(); + let module = LocalResponseNormConfig::new(5).init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [ + [1.9269, 1.4873, -0.4974], + [0.4396, -0.7581, 1.0783], + [0.8008, 1.6806, 0.3559], + ], + [ + [-0.6866, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516], + [0.8599, -0.3097, -0.3957], + ], + ]]), + &device, + ); + + let output = module.forward(input); + + let expected = TensorData::from([[ + [ + [1.9268, 1.4872, -0.4974], + [0.4396, -0.7581, 1.0783], + [0.8008, 1.6805, 0.3559], + ], + [ + [-0.6866, 0.6104, 1.3347], + [-0.2316, 0.0418, -0.2516], + [0.8598, -0.3097, -0.3957], + ], + ]]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::rel_abs(5e-3, 1e-4)); + } + + #[test] + fn forward_multi_batch() { + // N=2, size=5, input [2,3,4,4] + let device = Default::default(); + let module = LocalResponseNormConfig::new(5).init(); + let input = Tensor::<4>::from_data( + TensorData::from([ + [ + [ + [1.9269, 1.4873, 0.9007, -2.1055], + [0.6784, -1.2345, -0.0431, -1.6047], + [-0.7521, 1.6487, -0.3925, -1.4036], + [-0.7279, -0.5594, -0.7688, 0.7624], + ], + [ + [1.6423, -0.1596, -0.4974, 0.4396], + [-0.7581, 1.0783, 0.8008, 1.6806], + [1.2791, 1.2964, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516, 0.8599], + ], + [ + [-1.3847, -0.8712, -0.2234, 1.7174], + [0.3189, -0.4245, 0.3057, -0.7746], + [-1.5576, 0.9956, -0.8798, -0.6011], + [-1.2742, 2.1228, -1.2347, -0.4879], + ], + ], + [ + [ + [-0.9138, -0.6581, 0.0780, 0.5258], + [-0.4880, 1.1914, -0.8140, -0.7360], + [-1.4032, 0.0360, -0.0635, 0.6756], + [-0.0978, 1.8446, -1.1845, 1.3835], + ], + [ + [1.4451, 0.8564, 2.2181, 0.5232], + [0.3466, -0.1973, -1.0546, 1.2780], + [-0.1722, 0.5238, 0.0566, 0.4263], + [0.5750, -0.6417, -2.2064, -0.7508], + ], + [ + [0.0109, -0.3387, -1.3407, -0.5854], + [0.5362, 0.5246, 1.1412, 0.0516], + [0.7440, -0.4816, -1.0495, 0.6039], + [-1.7223, -0.8278, 1.3347, 0.4835], + ], + ], + ]), + &device, + ); + + let output = module.forward(input); + + let out_data = output.to_data(); + assert_eq!(out_data.shape, [2, 3, 4, 4].into()); + let expected_full = TensorData::from([ + [ + [ + [1.9267, 1.4872, 0.9007, -2.1053], + [0.6784, -1.2345, -0.0431, -1.6045], + [-0.7521, 1.6486, -0.3925, -1.4035], + [-0.7279, -0.5594, -0.7688, 0.7624], + ], + [ + [1.6421, -0.1596, -0.4974, 0.4395], + [-0.7581, 1.0783, 0.8008, 1.6805], + [1.2790, 1.2963, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516, 0.8598], + ], + [ + [-1.3845, -0.8712, -0.2234, 1.7172], + [0.3189, -0.4245, 0.3057, -0.7745], + [-1.5575, 0.9956, -0.8798, -0.6011], + [-1.2741, 2.1226, -1.2346, -0.4879], + ], + ], + [ + [ + [-0.9138, -0.6581, 0.0780, 0.5258], + [-0.4880, 1.1913, -0.8140, -0.7360], + [-1.4032, 0.0360, -0.0635, 0.6756], + [-0.0978, 1.8445, -1.1844, 1.3835], + ], + [ + [1.4451, 0.8564, 2.2179, 0.5232], + [0.3466, -0.1973, -1.0545, 1.2780], + [-0.1722, 0.5238, 0.0566, 0.4263], + [0.5750, -0.6417, -2.2061, -0.7508], + ], + [ + [0.0109, -0.3387, -1.3405, -0.5854], + [0.5362, 0.5246, 1.1411, 0.0516], + [0.7439, -0.4816, -1.0494, 0.6039], + [-1.7222, -0.8277, 1.3345, 0.4835], + ], + ], + ]); + out_data.assert_approx_eq::(&expected_full, Tolerance::rel_abs(5e-3, 1e-4)); + } + + // --- Validation / panic tests --- + + #[test] + #[should_panic(expected = "size must be greater than 0")] + fn config_size_zero_panics() { + LocalResponseNormConfig::new(0).init(); + } + + #[test] + #[should_panic(expected = "LocalResponseNorm requires input rank >= 3")] + fn forward_rank_2_panics() { + let module = LocalResponseNormConfig::new(3).init(); + let input = Tensor::<2>::zeros([2, 4], &Default::default()); + module.forward(input); + } + + // --- Autodiff --- + + #[cfg(feature = "std")] + #[test] + fn backward() { + use burn_core::tensor::Device; + + let device = Device::default().autodiff(); + let module = LocalResponseNormConfig::new(5).init(); + let input = Tensor::<4>::from_data( + TensorData::from([[ + [ + [1.9269, 1.4873, 0.9007, -2.1055], + [0.6784, -1.2345, -0.0431, -1.6047], + [-0.7521, 1.6487, -0.3925, -1.4036], + [-0.7279, -0.5594, -0.7688, 0.7624], + ], + [ + [1.6423, -0.1596, -0.4974, 0.4396], + [-0.7581, 1.0783, 0.8008, 1.6806], + [1.2791, 1.2964, 0.6105, 1.3347], + [-0.2316, 0.0418, -0.2516, 0.8599], + ], + [ + [-1.3847, -0.8712, -0.2234, 1.7174], + [0.3189, -0.4245, 0.3057, -0.7746], + [-1.5576, 0.9956, -0.8798, -0.6011], + [-1.2742, 2.1228, -1.2347, -0.4879], + ], + ]]), + &device, + ) + .require_grad(); + + let output = module.forward(input.clone()); + let grads = output.sum().backward(); + let input_grad = input.grad(&grads).unwrap(); + + assert_eq!(input_grad.dims(), [1, 3, 4, 4]); + + let expected_grad = TensorData::from([[ + [ + [0.9997, 0.9999, 1.0000, 0.9999], + [1.0000, 0.9999, 1.0000, 0.9999], + [0.9999, 0.9997, 1.0000, 0.9999], + [0.9999, 1.0000, 0.9999, 1.0000], + ], + [ + [0.9998, 1.0000, 1.0000, 0.9999], + [1.0000, 1.0000, 1.0000, 0.9999], + [1.0000, 0.9998, 1.0000, 1.0000], + [1.0000, 0.9999, 1.0000, 0.9999], + ], + [ + [1.0000, 1.0000, 1.0000, 0.9999], + [1.0000, 1.0000, 1.0000, 0.9999], + [0.9999, 0.9998, 1.0000, 0.9999], + [0.9999, 0.9998, 0.9999, 1.0000], + ], + ]]); + input_grad + .to_data() + .assert_approx_eq::(&expected_grad, Tolerance::rel_abs(5e-3, 1e-4)); + } + + // --- Display --- + + #[test] + fn display() { + let config = LocalResponseNormConfig::new(5); + let module = config.init(); + assert_eq!( + format!("{module}"), + "LocalResponseNorm {size: 5, alpha: 0.0001, beta: 0.75, k: 1}" + ); + } +} diff --git a/crates/burn-nn/src/modules/norm/mod.rs b/crates/burn-nn/src/modules/norm/mod.rs new file mode 100644 index 0000000..48e0777 --- /dev/null +++ b/crates/burn-nn/src/modules/norm/mod.rs @@ -0,0 +1,30 @@ +//! # Normalization Layers +//! +//! Users who wish to provide an abstraction over swappable normalization +//! layers can use the [`Normalization`] wrapper, with support for: +//! * [`Normalization::Batch`] - [`BatchNorm`] +//! * [`Normalization::Group`] - [`GroupNorm`] +//! * [`Normalization::Instance`] - [`InstanceNorm`] +//! * [`Normalization::Layer`] - [`LayerNorm`] +//! * [`Normalization::Rms`] - [`RmsNorm`] +//! +//! [`NormalizationConfig`] can be used as a generic normalization policy: +//! * Construct a config with arbitrary input features (we suggest `0`). +//! * Clone and match that config to the target input layer, +//! using the [`NormalizationConfig::with_num_features()`] method. +pub(crate) mod batch; +pub(crate) mod group; +pub(crate) mod instance; +pub(crate) mod layer; +pub(crate) mod local_response; +pub(crate) mod rms; + +mod normalization_wrapper; + +pub use batch::*; +pub use group::*; +pub use instance::*; +pub use layer::*; +pub use local_response::*; +pub use normalization_wrapper::*; +pub use rms::*; diff --git a/crates/burn-nn/src/modules/norm/normalization_wrapper.rs b/crates/burn-nn/src/modules/norm/normalization_wrapper.rs new file mode 100644 index 0000000..e6450b2 --- /dev/null +++ b/crates/burn-nn/src/modules/norm/normalization_wrapper.rs @@ -0,0 +1,397 @@ +use burn_core as burn; + +use crate::{ + BatchNorm, BatchNormConfig, GroupNorm, GroupNormConfig, Identity, InstanceNorm, + InstanceNormConfig, LayerNorm, LayerNormConfig, RmsNorm, RmsNormConfig, +}; +use burn::prelude::{Config, Module}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// ['Normalization'] Configuration. +/// +/// The enum is non-exhaustive to prepare for future additions. +/// +/// Can be used as a generic configuration for normalization layers: +/// * Construct a config with arbitrary input features (we suggest `0`). +/// * Clone and match that config to the target input layer, +/// using the [`NormalizationConfig::with_num_features()`] method. +#[derive(Config, Debug)] +#[non_exhaustive] +pub enum NormalizationConfig { + /// ['Identity'] Configuration. + Identity, + + /// ['BatchNorm'] Configuration. + Batch(BatchNormConfig), + + /// ['GroupNorm'] Configuration. + Group(GroupNormConfig), + + /// ['InstanceNorm'] Configuration. + Instance(InstanceNormConfig), + + /// ['LayerNorm'] Configuration. + Layer(LayerNormConfig), + + /// ['RmsNorm'] Configuration. + Rms(RmsNormConfig), +} + +impl From for NormalizationConfig { + fn from(config: BatchNormConfig) -> Self { + Self::Batch(config) + } +} + +impl From for NormalizationConfig { + fn from(config: GroupNormConfig) -> Self { + Self::Group(config) + } +} + +impl From for NormalizationConfig { + fn from(config: InstanceNormConfig) -> Self { + Self::Instance(config) + } +} + +impl From for NormalizationConfig { + fn from(config: LayerNormConfig) -> Self { + Self::Layer(config) + } +} + +impl From for NormalizationConfig { + fn from(config: RmsNormConfig) -> Self { + Self::Rms(config) + } +} + +impl NormalizationConfig { + /// Initialize a ['Norm'] layer. + pub fn init(&self, device: &Device) -> Normalization { + match self { + NormalizationConfig::Identity => Identity::new().into(), + NormalizationConfig::Batch(config) => config.init(device).into(), + NormalizationConfig::Group(config) => config.init(device).into(), + NormalizationConfig::Instance(config) => config.init(device).into(), + NormalizationConfig::Layer(config) => config.init(device).into(), + NormalizationConfig::Rms(config) => config.init(device).into(), + } + } + + /// Set the number of features. + pub fn with_num_features(self, num_features: usize) -> Self { + match self { + NormalizationConfig::Identity => self, + NormalizationConfig::Batch(config) => BatchNormConfig { + num_features, + ..config + } + .into(), + NormalizationConfig::Group(config) => GroupNormConfig { + num_channels: num_features, + ..config + } + .into(), + NormalizationConfig::Instance(config) => InstanceNormConfig { + num_channels: num_features, + ..config + } + .into(), + NormalizationConfig::Layer(config) => LayerNormConfig { + d_model: num_features, + ..config + } + .into(), + NormalizationConfig::Rms(config) => RmsNormConfig { + d_model: num_features, + ..config + } + .into(), + } + } + + /// Get the number of features. + pub fn num_features(&self) -> usize { + match self { + NormalizationConfig::Identity => 0, + NormalizationConfig::Batch(config) => config.num_features, + NormalizationConfig::Group(config) => config.num_channels, + NormalizationConfig::Instance(config) => config.num_channels, + NormalizationConfig::Layer(config) => config.d_model, + NormalizationConfig::Rms(config) => config.d_model, + } + } +} + +/// Normalization Layer Wrapper +/// +/// Provides support for built-in ``burn::nn::norm`` norm layers: +/// * [`Normalization::Batch`] - [`BatchNorm`] +/// * [`Normalization::Group`] - [`GroupNorm`] +/// * [`Normalization::Instance`] - [`InstanceNorm`] +/// * [`Normalization::Layer`] - [`LayerNorm`] +/// * [`Normalization::Rms`] - [`RmsNorm`] +/// +/// The enum is non-exhaustive, to prepare for future additions. +#[derive(Module, Debug)] +#[non_exhaustive] +pub enum Normalization { + /// ['Identity'] layer. + Identity(Identity), + + /// [`BatchNorm`] layer. + Batch(BatchNorm), + + /// [`GroupNorm`] layer. + Group(GroupNorm), + + /// ['InstanceNorm'] layer. + Instance(InstanceNorm), + + /// [`LayerNorm`] layer. + Layer(LayerNorm), + + /// ['RmsNorm'] layer. + Rms(RmsNorm), +} + +impl From for Normalization { + fn from(layer: Identity) -> Self { + Self::Identity(layer) + } +} + +impl From for Normalization { + fn from(layer: BatchNorm) -> Self { + Self::Batch(layer) + } +} + +impl From for Normalization { + fn from(layer: GroupNorm) -> Self { + Self::Group(layer) + } +} + +impl From for Normalization { + fn from(layer: InstanceNorm) -> Self { + Self::Instance(layer) + } +} + +impl From for Normalization { + fn from(layer: LayerNorm) -> Self { + Self::Layer(layer) + } +} + +impl From for Normalization { + fn from(layer: RmsNorm) -> Self { + Self::Rms(layer) + } +} + +impl Normalization { + /// Applies normalization to a tensor. + /// + /// The normalization contract depends upon the wrapped norm layer; + /// but all norm layers assume an input of at least rank 2; + /// and produce an output of the same rank and shape. + pub fn forward(&self, input: Tensor) -> Tensor { + match self { + Normalization::Identity(norm) => norm.forward(input), + Normalization::Batch(norm) => norm.forward(input), + Normalization::Group(norm) => norm.forward(input), + Normalization::Instance(norm) => norm.forward(input), + Normalization::Layer(norm) => norm.forward(input), + Normalization::Rms(norm) => norm.forward(input), + } + } + + /// Get the number of features. + pub fn num_features(&self) -> usize { + match self { + Normalization::Identity(_) => 0, + Normalization::Batch(norm) => norm.gamma.shape()[0], + Normalization::Group(norm) => norm.num_channels, + Normalization::Instance(norm) => norm.num_channels, + Normalization::Layer(norm) => norm.gamma.shape()[0], + Normalization::Rms(norm) => norm.gamma.shape()[0], + } + } +} + +#[cfg(feature = "std")] +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_match_feature_size() { + let config: NormalizationConfig = BatchNormConfig::new(0).into(); + assert_eq!(config.num_features(), 0); + let config = config.with_num_features(12); + assert_eq!(config.num_features(), 12); + + let config: NormalizationConfig = GroupNormConfig::new(4, 0).into(); + assert_eq!(config.num_features(), 0); + let config = config.with_num_features(12); + assert_eq!(config.num_features(), 12); + + let config: NormalizationConfig = InstanceNormConfig::new(0).into(); + assert_eq!(config.num_features(), 0); + let config = config.with_num_features(12); + assert_eq!(config.num_features(), 12); + + let config: NormalizationConfig = LayerNormConfig::new(0).into(); + assert_eq!(config.num_features(), 0); + let config = config.with_num_features(12); + assert_eq!(config.num_features(), 12); + + let config: NormalizationConfig = RmsNormConfig::new(0).into(); + assert_eq!(config.num_features(), 0); + let config = config.with_num_features(12); + assert_eq!(config.num_features(), 12); + } + + #[test] + fn test_identity_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, num_features, 3, 4], &device); + + let config: NormalizationConfig = NormalizationConfig::Identity; + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 0); + + let expected = input.clone(); + let output = layer.forward(input); + + output.to_data().assert_eq(&expected.to_data(), true); + } + + #[test] + fn test_batch_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, num_features, 3, 4], &device); + + let config: NormalizationConfig = BatchNormConfig::new(12).into(); + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 12); + + let expected = match &layer { + Normalization::Batch(inner) => inner.forward(input.clone()), + _ => panic!("Unexpected layer type"), + }; + + let output = layer.forward(input); + + output.to_data().assert_eq(&expected.to_data(), true); + } + + #[test] + fn test_group_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, num_features, 3, 4], &device); + + let config: NormalizationConfig = GroupNormConfig::new(3, num_features).into(); + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 12); + + let expected = match &layer { + Normalization::Group(inner) => inner.forward(input.clone()), + _ => panic!("Unexpected layer type"), + }; + + let output = layer.forward(input); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + fn test_instance_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, num_features, 3, 4], &device); + + let config: NormalizationConfig = InstanceNormConfig::new(num_features).into(); + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 12); + + let expected = match &layer { + Normalization::Instance(inner) => inner.forward(input.clone()), + _ => panic!("Unexpected layer type"), + }; + + let output = layer.forward(input); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + fn test_layer_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, 3, 4, num_features], &device); + + let config: NormalizationConfig = LayerNormConfig::new(num_features).into(); + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 12); + + let expected = match &layer { + Normalization::Layer(inner) => inner.forward(input.clone()), + _ => panic!("Unexpected layer type"), + }; + + let output = layer.forward(input); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + fn test_rms_norm() { + let device = Device::default().autodiff(); + + let num_features = 12; + let input: Tensor<4> = Tensor::ones([2, 3, 4, num_features], &device); + + let config: NormalizationConfig = RmsNormConfig::new(num_features).into(); + + let layer = config.init(&device); + assert_eq!(layer.num_features(), 12); + + let expected = match &layer { + Normalization::Rms(inner) => inner.forward(input.clone()), + _ => panic!("Unexpected layer type"), + }; + + let output = layer.forward(input); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-nn/src/modules/norm/rms.rs b/crates/burn-nn/src/modules/norm/rms.rs new file mode 100644 index 0000000..462c378 --- /dev/null +++ b/crates/burn-nn/src/modules/norm/rms.rs @@ -0,0 +1,131 @@ +use burn::tensor::DType; + +use burn_core as burn; + +use burn::config::Config; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::Param; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// Configuration to create a [RMS Norm](RmsNorm) layer using the [init function](RmsNormConfig::init). +#[derive(Config, Debug)] +pub struct RmsNormConfig { + /// The size of the input features. + pub d_model: usize, + /// A value required for numerical stability. Default: 1e-5 + #[config(default = 1e-5)] + pub epsilon: f64, +} + +impl RmsNormConfig { + /// Initialize a new [RMS Norm](RmsNorm) module. + /// + /// # Panics + /// + /// Panics if `epsilon` is not positive. + pub fn init(&self, device: &Device) -> RmsNorm { + assert!(self.epsilon > 0.0, "epsilon must be positive."); + + let gamma = Initializer::Ones.init([self.d_model], device); + + RmsNorm { + gamma, + epsilon: self.epsilon, + } + } +} + +/// Applies RMS Normalization over an input tensor along the last dimension. +/// +/// `Y = X / sqrt(mean(X^2) + eps) * gamma` +/// +/// Where: +/// - `X` is the input tensor +/// - `Y` is the output tensor +/// - `gamma` is the learnable weight +/// - `mean` is the mean operation +/// - `eps` is a small value to avoid division by zero. +/// +/// Should be created using the [RmsNormConfig](RmsNormConfig) configuration. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct RmsNorm { + /// The learnable parameter to scale the normalized tensor + pub gamma: Param>, + /// A value required for numerical stability + pub epsilon: f64, +} + +impl RmsNorm { + /// Applies the forward pass on the input tensor. + /// + /// See the [RmsNorm](RmsNorm) documentation for more information. + /// + /// # Shapes + /// + /// - input: `[..., any, d_model]` + /// - output: `[..., any, d_model]` + pub fn forward(&self, x: Tensor) -> Tensor { + // Calculate the root-mean-square norm of the input tensor along the last dimension + let dtype = x.dtype(); + let rms = (x.clone().cast(DType::F32).square().mean_dim(D - 1) + self.epsilon).sqrt(); + (x / rms.cast(dtype)) * self.gamma.val().unsqueeze() + } +} + +impl ModuleDisplay for RmsNorm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_model] = self.gamma.shape().dims(); + content + .add("d_model", &d_model) + .add("epsilon", &self.epsilon) + .optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use burn::tensor::TensorData; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn rms_norm_forward() { + let device = Default::default(); + let module = RmsNormConfig::new(3).with_epsilon(1e-5).init(&device); + + let input = Tensor::arange(0..9, &device).float().reshape([3, 3]); + let output = module.forward(input); + + let expected = TensorData::from([ + [0.0000, 0.7746, 1.5492], + [0.7348, 0.9798, 1.2247], + [0.8514, 0.9933, 1.1352], + ]); + output + .to_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn display() { + let config = RmsNormConfig::new(6); + let layer_norm = config.init(&Default::default()); + + assert_eq!( + format!("{layer_norm}"), + "RmsNorm {d_model: 6, epsilon: 0.00001, params: 6}" + ); + } +} diff --git a/crates/burn-nn/src/modules/pool/adaptive_avg_pool1d.rs b/crates/burn-nn/src/modules/pool/adaptive_avg_pool1d.rs new file mode 100644 index 0000000..e8f4864 --- /dev/null +++ b/crates/burn-nn/src/modules/pool/adaptive_avg_pool1d.rs @@ -0,0 +1,76 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; + +use burn::tensor::module::adaptive_avg_pool1d; + +/// Configuration to create a [1D adaptive avg pooling](AdaptiveAvgPool1d) layer using the [init function](AdaptiveAvgPool1dConfig::init). +#[derive(Config, Debug)] +pub struct AdaptiveAvgPool1dConfig { + /// The size of the output. + pub output_size: usize, +} + +/// Applies a 1D adaptive avg pooling over input tensors. +/// +/// Should be created with [AdaptiveAvgPool1dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct AdaptiveAvgPool1d { + /// The size of the output. + pub output_size: usize, +} + +impl ModuleDisplay for AdaptiveAvgPool1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content.add("output_size", &self.output_size).optional() + } +} + +impl AdaptiveAvgPool1dConfig { + /// Initialize a new [adaptive avg pool 1d](AdaptiveAvgPool1d) module. + pub fn init(&self) -> AdaptiveAvgPool1d { + AdaptiveAvgPool1d { + output_size: self.output_size, + } + } +} + +impl AdaptiveAvgPool1d { + /// Applies the forward pass on the input tensor. + /// + /// See [adaptive_avg_pool1d](burn::tensor::module::adaptive_avg_pool1d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, length]` + /// - output: `[batch_size, channels, length_out]` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + adaptive_avg_pool1d(input, self.output_size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let config = AdaptiveAvgPool1dConfig::new(3); + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "AdaptiveAvgPool1d {output_size: 3}" + ); + } +} diff --git a/crates/burn-nn/src/modules/pool/adaptive_avg_pool2d.rs b/crates/burn-nn/src/modules/pool/adaptive_avg_pool2d.rs new file mode 100644 index 0000000..ba70b9b --- /dev/null +++ b/crates/burn-nn/src/modules/pool/adaptive_avg_pool2d.rs @@ -0,0 +1,78 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; + +use burn::tensor::module::adaptive_avg_pool2d; + +/// Configuration to create a [2D adaptive avg pooling](AdaptiveAvgPool2d) layer using the [init function](AdaptiveAvgPool2dConfig::init). +#[derive(Config, Debug)] +pub struct AdaptiveAvgPool2dConfig { + /// The size of the output. + pub output_size: [usize; 2], +} + +/// Applies a 2D adaptive avg pooling over input tensors. +/// +/// Should be created with [AdaptiveAvgPool2dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct AdaptiveAvgPool2d { + /// The size of the output. + pub output_size: [usize; 2], +} + +impl ModuleDisplay for AdaptiveAvgPool2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let output_size = alloc::format!("{:?}", self.output_size); + + content.add("output_size", &output_size).optional() + } +} + +impl AdaptiveAvgPool2dConfig { + /// Initialize a new [adaptive avg pool 2d](AdaptiveAvgPool2d) module. + pub fn init(&self) -> AdaptiveAvgPool2d { + AdaptiveAvgPool2d { + output_size: self.output_size, + } + } +} + +impl AdaptiveAvgPool2d { + /// Applies the forward pass on the input tensor. + /// + /// See [adaptive_avg_pool2d](burn::tensor::module::adaptive_avg_pool2d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, height_in, width_in]` + /// - output: `[batch_size, channels, height_out, width_out]` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + adaptive_avg_pool2d(input, self.output_size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let config = AdaptiveAvgPool2dConfig::new([3, 3]); + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "AdaptiveAvgPool2d {output_size: [3, 3]}" + ); + } +} diff --git a/crates/burn-nn/src/modules/pool/avg_pool1d.rs b/crates/burn-nn/src/modules/pool/avg_pool1d.rs new file mode 100644 index 0000000..06f771d --- /dev/null +++ b/crates/burn-nn/src/modules/pool/avg_pool1d.rs @@ -0,0 +1,219 @@ +use burn_core as burn; + +use crate::PaddingConfig1d; +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::PadMode; + +use burn::tensor::module::avg_pool1d; + +/// Configuration to create a [1D avg pooling](AvgPool1d) layer using the [init function](AvgPool1dConfig::init). +#[derive(Config, Debug)] +pub struct AvgPool1dConfig { + /// The size of the kernel. + pub kernel_size: usize, + /// The stride. + #[config(default = "kernel_size")] + pub stride: usize, + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig1d::Valid")] + pub padding: PaddingConfig1d, + /// If the padding is counted in the denominator when computing the average. + #[config(default = "true")] + pub count_include_pad: bool, + /// If true, use ceiling instead of floor for output size calculation. + #[config(default = "false")] + pub ceil_mode: bool, +} + +/// Applies a 1D avg pooling over input tensors. +/// +/// Should be created with [AvgPool1dConfig](AvgPool1dConfig). +/// +/// # Remarks +/// +/// The zero-padding values will be included in the calculation +/// of the average. This means that the zeros are counted as +/// legitimate values, and they contribute to the denominator +/// when calculating the average. This is equivalent to +/// `torch.nn.AvgPool2d` with `count_include_pad=True`. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct AvgPool1d { + /// The stride. + pub stride: usize, + /// The size of the kernel. + pub kernel_size: usize, + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig1d, + /// If the padding is counted in the denominator when computing the average. + pub count_include_pad: bool, + /// If true, use ceiling instead of floor for output size calculation. + pub ceil_mode: bool, +} + +impl ModuleDisplay for AvgPool1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("kernel_size", &self.kernel_size) + .add("stride", &self.stride) + .add_debug_attribute("padding", &self.padding) + .add("count_include_pad", &self.count_include_pad) + .add("ceil_mode", &self.ceil_mode) + .optional() + } +} + +impl AvgPool1dConfig { + /// Initialize a new [avg pool 1d](AvgPool1d) module. + pub fn init(&self) -> AvgPool1d { + AvgPool1d { + stride: self.stride, + kernel_size: self.kernel_size, + padding: self.padding.clone(), + count_include_pad: self.count_include_pad, + ceil_mode: self.ceil_mode, + } + } +} + +impl AvgPool1d { + /// Applies the forward pass on the input tensor. + /// + /// See [avg_pool1d](burn::tensor::module::avg_pool1d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, length_in]` + /// - output: `[batch_size, channels, length_out]` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + let [_batch_size, _channels, length] = input.dims(); + + // Calculate padding as pair - handles Same, Valid, and Explicit uniformly + let (left, right) = + self.padding + .calculate_padding_1d_pair(length, self.kernel_size, self.stride); + + // TODO: Move asymmetric padding to functional level via PoolOptions + // See: https://github.com/tracel-ai/burn/issues/4362 + // Handle asymmetric padding by applying explicit pad operation first + if left != right { + // Burn's pad takes (left, right, top, bottom) for the last two dimensions + // For 1D (NCL format), we only pad L (last dim), so top/bottom = 0 + let padded = input.pad((left, right, 0, 0), PadMode::Constant(0.0)); + // Use zero padding for the pool operation since we already padded + avg_pool1d( + padded, + self.kernel_size, + self.stride, + 0, + self.count_include_pad, + self.ceil_mode, + ) + } else { + // Symmetric padding + avg_pool1d( + input, + self.kernel_size, + self.stride, + left, + self.count_include_pad, + self.ceil_mode, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = AvgPool1dConfig::new(2) + .with_stride(1) + .with_padding(PaddingConfig1d::Same); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=5] + let input = Tensor::<3>::ones([1, 2, 5], &device); + let output = pool.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 2, 5]); + } + + #[test] + fn display() { + let config = AvgPool1dConfig::new(3); + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "AvgPool1d {kernel_size: 3, stride: 3, padding: Valid, count_include_pad: true, ceil_mode: false}" + ); + } + + #[rstest] + #[case(1)] + #[case(2)] + fn default_strides_match_kernel_size(#[case] kernel_size: usize) { + let config = AvgPool1dConfig::new(kernel_size); + + assert_eq!( + config.stride, kernel_size, + "Expected stride ({:?}) to match kernel size ({:?}) in default AvgPool1dConfig::new constructor", + config.stride, config.kernel_size + ); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create avg pool with asymmetric padding: left=1, right=2 + let config = AvgPool1dConfig::new(3) + .with_stride(1) + .with_padding(PaddingConfig1d::Explicit(1, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = pool.forward(input); + + // With asymmetric padding (1, 2), input length 4 becomes 4+1+2=7 + // Output length = (7 - 3) / 1 + 1 = 5 + assert_eq!(output.dims(), [1, 2, 5]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create avg pool with symmetric explicit padding: left=2, right=2 + let config = AvgPool1dConfig::new(3) + .with_stride(1) + .with_padding(PaddingConfig1d::Explicit(2, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = pool.forward(input); + + // With symmetric padding (2, 2), input length 4 becomes 4+2+2=8 + // Output length = (8 - 3) / 1 + 1 = 6 + assert_eq!(output.dims(), [1, 2, 6]); + } +} diff --git a/crates/burn-nn/src/modules/pool/avg_pool2d.rs b/crates/burn-nn/src/modules/pool/avg_pool2d.rs new file mode 100644 index 0000000..b3d5acb --- /dev/null +++ b/crates/burn-nn/src/modules/pool/avg_pool2d.rs @@ -0,0 +1,222 @@ +use burn_core as burn; + +use crate::PaddingConfig2d; +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::PadMode; + +use burn::tensor::module::avg_pool2d; + +/// Configuration to create a [2D avg pooling](AvgPool2d) layer using the [init function](AvgPool2dConfig::init). +#[derive(Config, Debug)] +pub struct AvgPool2dConfig { + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The strides. + #[config(default = "kernel_size")] + pub strides: [usize; 2], + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig2d::Valid")] + pub padding: PaddingConfig2d, + /// If the padding is counted in the denominator when computing the average. + #[config(default = "true")] + pub count_include_pad: bool, + /// If true, use ceiling instead of floor for output size calculation. + #[config(default = "false")] + pub ceil_mode: bool, +} + +/// Applies a 2D avg pooling over input tensors. +/// +/// Should be created with [AvgPool2dConfig](AvgPool2dConfig). +/// +/// # Remarks +/// +/// The zero-padding values will be included in the calculation +/// of the average. This means that the zeros are counted as +/// legitimate values, and they contribute to the denominator +/// when calculating the average. This is equivalent to +/// `torch.nn.AvgPool2d` with `count_include_pad=True`. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct AvgPool2d { + /// Stride of the pooling. + pub stride: [usize; 2], + /// Size of the kernel. + pub kernel_size: [usize; 2], + /// Padding configuration. + #[module(skip)] + pub padding: PaddingConfig2d, + /// If the padding is counted in the denominator when computing the average. + pub count_include_pad: bool, + /// If true, use ceiling instead of floor for output size calculation. + pub ceil_mode: bool, +} + +impl ModuleDisplay for AvgPool2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("kernel_size", &alloc::format!("{:?}", self.kernel_size)) + .add("stride", &alloc::format!("{:?}", self.stride)) + .add_debug_attribute("padding", &self.padding) + .add("count_include_pad", &self.count_include_pad) + .add("ceil_mode", &self.ceil_mode) + .optional() + } +} + +impl AvgPool2dConfig { + /// Initialize a new [avg pool 2d](AvgPool2d) module. + pub fn init(&self) -> AvgPool2d { + AvgPool2d { + stride: self.strides, + kernel_size: self.kernel_size, + padding: self.padding.clone(), + count_include_pad: self.count_include_pad, + ceil_mode: self.ceil_mode, + } + } +} + +impl AvgPool2d { + /// Applies the forward pass on the input tensor. + /// + /// See [avg_pool2d](burn::tensor::module::avg_pool2d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, height_in, width_in]` + /// - output: `[batch_size, channels, height_out, width_out]` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let [_batch_size, _channels_in, height_in, width_in] = input.dims(); + + // Calculate padding as pairs - handles Same, Valid, and Explicit uniformly + let ((top, bottom), (left, right)) = self.padding.calculate_padding_2d_pairs( + height_in, + width_in, + &self.kernel_size, + &self.stride, + ); + + // TODO: Move asymmetric padding to functional level via PoolOptions + // See: https://github.com/tracel-ai/burn/issues/4362 + // Handle asymmetric padding by applying explicit pad operation first + if top != bottom || left != right { + // Burn's pad takes (left, right, top, bottom) for the last two dimensions + let padded = input.pad((left, right, top, bottom), PadMode::Constant(0.0)); + // Use zero padding for the pool operation since we already padded + avg_pool2d( + padded, + self.kernel_size, + self.stride, + [0, 0], + self.count_include_pad, + self.ceil_mode, + ) + } else { + // Symmetric padding + avg_pool2d( + input, + self.kernel_size, + self.stride, + [top, left], + self.count_include_pad, + self.ceil_mode, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = AvgPool2dConfig::new([2, 2]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Same); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=5, width=5] + let input = Tensor::<4>::ones([1, 2, 5, 5], &device); + let output = pool.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 2, 5, 5]); + } + + #[test] + fn display() { + let config = AvgPool2dConfig::new([3, 3]); + + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "AvgPool2d {kernel_size: [3, 3], stride: [3, 3], padding: Valid, count_include_pad: true, ceil_mode: false}" + ); + } + + #[rstest] + #[case([2, 2])] + #[case([1, 2])] + fn default_strides_match_kernel_size(#[case] kernel_size: [usize; 2]) { + let config = AvgPool2dConfig::new(kernel_size); + + assert_eq!( + config.strides, kernel_size, + "Expected strides ({:?}) to match kernel size ({:?}) in default AvgPool2dConfig::new constructor", + config.strides, config.kernel_size + ); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create avg pool with asymmetric padding: top=1, left=2, bottom=3, right=4 + let config = AvgPool2dConfig::new([3, 3]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Explicit(1, 2, 3, 4)); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = pool.forward(input); + + // Height: 4 + 1 + 3 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 4 = 11, output = (11 - 3) / 1 + 1 = 9 + assert_eq!(output.dims(), [1, 2, 6, 9]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create avg pool with symmetric explicit padding: top=2, left=2, bottom=2, right=2 + let config = AvgPool2dConfig::new([3, 3]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = pool.forward(input); + + // Height: 4 + 2 + 2 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 2 = 9, output = (9 - 3) / 1 + 1 = 7 + assert_eq!(output.dims(), [1, 2, 6, 7]); + } +} diff --git a/crates/burn-nn/src/modules/pool/max_pool1d.rs b/crates/burn-nn/src/modules/pool/max_pool1d.rs new file mode 100644 index 0000000..f2df065 --- /dev/null +++ b/crates/burn-nn/src/modules/pool/max_pool1d.rs @@ -0,0 +1,213 @@ +use burn_core as burn; + +use crate::PaddingConfig1d; +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::PadMode; + +use burn::tensor::module::max_pool1d; + +/// Configuration to create a [1D max pooling](MaxPool1d) layer using the [init function](MaxPool1dConfig::init). +#[derive(Config, Debug)] +pub struct MaxPool1dConfig { + /// The size of the kernel. + pub kernel_size: usize, + /// The stride. + #[config(default = "kernel_size")] + pub stride: usize, + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig1d::Valid")] + pub padding: PaddingConfig1d, + /// The dilation. + #[config(default = "1")] + pub dilation: usize, + /// If true, use ceiling instead of floor for output size calculation. + #[config(default = "false")] + pub ceil_mode: bool, +} + +/// Applies a 1D max pooling over input tensors. +/// +/// Should be created with [MaxPool1dConfig](MaxPool1dConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct MaxPool1d { + /// The stride. + pub stride: usize, + /// The size of the kernel. + pub kernel_size: usize, + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig1d, + /// The dilation. + pub dilation: usize, + /// If true, use ceiling instead of floor for output size calculation. + pub ceil_mode: bool, +} + +impl ModuleDisplay for MaxPool1d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("kernel_size", &self.kernel_size) + .add("stride", &self.stride) + .add_debug_attribute("padding", &self.padding) + .add("dilation", &self.dilation) + .add("ceil_mode", &self.ceil_mode) + .optional() + } +} + +impl MaxPool1dConfig { + /// Initialize a new [max pool 1d](MaxPool1d) module. + pub fn init(&self) -> MaxPool1d { + MaxPool1d { + stride: self.stride, + kernel_size: self.kernel_size, + padding: self.padding.clone(), + dilation: self.dilation, + ceil_mode: self.ceil_mode, + } + } +} + +impl MaxPool1d { + /// Applies the forward pass on the input tensor. + /// + /// See [max_pool1d](burn::tensor::module::max_pool1d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, length_in]` + /// - output: `[batch_size, channels, length_out]` + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + let [_batch_size, _channels, length] = input.dims(); + + // Calculate padding as pair - handles Same, Valid, and Explicit uniformly + let (left, right) = + self.padding + .calculate_padding_1d_pair(length, self.kernel_size, self.stride); + + // TODO: Move asymmetric padding to functional level via PoolOptions + // See: https://github.com/tracel-ai/burn/issues/4362 + // Handle asymmetric padding by applying explicit pad operation first + if left != right { + // For 1D (NCL format), pad the length dimension with (left, right) + // and no padding for channel dimension (top=0, bottom=0) + // Use -inf for max pooling so padded values don't affect the max + let padded = input.pad((left, right, 0, 0), PadMode::Constant(f32::NEG_INFINITY)); + // Use zero padding for the pool operation since we already padded + max_pool1d( + padded, + self.kernel_size, + self.stride, + 0, + self.dilation, + self.ceil_mode, + ) + } else { + // Symmetric padding + max_pool1d( + input, + self.kernel_size, + self.stride, + left, + self.dilation, + self.ceil_mode, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = MaxPool1dConfig::new(2) + .with_stride(1) + .with_padding(PaddingConfig1d::Same); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=5] + let input = Tensor::<3>::ones([1, 2, 5], &device); + let output = pool.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 2, 5]); + } + + #[test] + fn display() { + let config = MaxPool1dConfig::new(3); + + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "MaxPool1d {kernel_size: 3, stride: 3, padding: Valid, dilation: 1, ceil_mode: false}" + ); + } + + #[rstest] + #[case(1)] + #[case(2)] + fn default_strides_match_kernel_size(#[case] kernel_size: usize) { + let config = MaxPool1dConfig::new(kernel_size); + + assert_eq!( + config.stride, kernel_size, + "Expected stride ({:?}) to match kernel size ({:?}) in default MaxPool1dConfig::new constructor", + config.stride, config.kernel_size + ); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create max pool with asymmetric padding: left=1, right=2 + let config = MaxPool1dConfig::new(3) + .with_stride(1) + .with_padding(PaddingConfig1d::Explicit(1, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = pool.forward(input); + + // With asymmetric padding (1, 2), input length 4 becomes 4+1+2=7 + // Output length = (7 - 3) / 1 + 1 = 5 + assert_eq!(output.dims(), [1, 2, 5]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create max pool with symmetric explicit padding: left=2, right=2 + let config = MaxPool1dConfig::new(3) + .with_stride(1) + .with_padding(PaddingConfig1d::Explicit(2, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, length=4] + let input = Tensor::<3>::ones([1, 2, 4], &device); + let output = pool.forward(input); + + // With symmetric padding (2, 2), input length 4 becomes 4+2+2=8 + // Output length = (8 - 3) / 1 + 1 = 6 + assert_eq!(output.dims(), [1, 2, 6]); + } +} diff --git a/crates/burn-nn/src/modules/pool/max_pool2d.rs b/crates/burn-nn/src/modules/pool/max_pool2d.rs new file mode 100644 index 0000000..6061093 --- /dev/null +++ b/crates/burn-nn/src/modules/pool/max_pool2d.rs @@ -0,0 +1,218 @@ +use burn_core as burn; + +use crate::PaddingConfig2d; +use burn::config::Config; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Tensor; +use burn::tensor::ops::PadMode; + +use burn::tensor::module::max_pool2d; + +/// Configuration to create a [2D max pooling](MaxPool2d) layer using the [init function](MaxPool2dConfig::init). +#[derive(Debug, Config)] +pub struct MaxPool2dConfig { + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The strides. + #[config(default = "kernel_size")] + pub strides: [usize; 2], + /// The padding configuration. + /// + /// Supports symmetric and asymmetric padding. `Same` padding with even kernel sizes + /// will automatically use asymmetric padding to preserve input dimensions. + #[config(default = "PaddingConfig2d::Valid")] + pub padding: PaddingConfig2d, + /// The dilation. + #[config(default = "[1, 1]")] + pub dilation: [usize; 2], + /// If true, use ceiling instead of floor for output size calculation. + #[config(default = "false")] + pub ceil_mode: bool, +} + +/// Applies a 2D max pooling over input tensors. +/// +/// Should be created with [MaxPool2dConfig](MaxPool2dConfig). +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct MaxPool2d { + /// The strides. + pub stride: [usize; 2], + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The padding configuration. + #[module(skip)] + pub padding: PaddingConfig2d, + /// The dilation. + pub dilation: [usize; 2], + /// If true, use ceiling instead of floor for output size calculation. + pub ceil_mode: bool, +} + +impl ModuleDisplay for MaxPool2d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("kernel_size", &alloc::format!("{:?}", self.kernel_size)) + .add("stride", &alloc::format!("{:?}", self.stride)) + .add_debug_attribute("padding", &self.padding) + .add("dilation", &alloc::format!("{:?}", self.dilation)) + .add("ceil_mode", &self.ceil_mode) + .optional() + } +} + +impl MaxPool2dConfig { + /// Initialize a new [max pool 2d](MaxPool2d) module. + pub fn init(&self) -> MaxPool2d { + MaxPool2d { + stride: self.strides, + kernel_size: self.kernel_size, + padding: self.padding.clone(), + dilation: self.dilation, + ceil_mode: self.ceil_mode, + } + } +} + +impl MaxPool2d { + /// Applies the forward pass on the input tensor. + /// + /// See [max_pool2d](burn::tensor::module::max_pool2d) for more information. + /// + /// # Shapes + /// + /// - input: `[batch_size, channels, height_in, width_in]` + /// - output: `[batch_size, channels, height_out, width_out]` + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let [_batch_size, _channels_in, height_in, width_in] = input.dims(); + + // Calculate padding as pairs - handles Same, Valid, and Explicit uniformly + let ((top, bottom), (left, right)) = self.padding.calculate_padding_2d_pairs( + height_in, + width_in, + &self.kernel_size, + &self.stride, + ); + + // TODO: Move asymmetric padding to functional level via PoolOptions + // See: https://github.com/tracel-ai/burn/issues/4362 + // Handle asymmetric padding by applying explicit pad operation first + if top != bottom || left != right { + // Burn's pad takes (left, right, top, bottom) for the last two dimensions + // Use -inf for max pooling so padded values don't affect the max + let padded = input.pad( + (left, right, top, bottom), + PadMode::Constant(f32::NEG_INFINITY), + ); + // Use zero padding for the pool operation since we already padded + max_pool2d( + padded, + self.kernel_size, + self.stride, + [0, 0], + self.dilation, + self.ceil_mode, + ) + } else { + // Symmetric padding + max_pool2d( + input, + self.kernel_size, + self.stride, + [top, left], + self.dilation, + self.ceil_mode, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn same_with_even_kernel_uses_asymmetric_padding() { + let device = Default::default(); + let config = MaxPool2dConfig::new([2, 2]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Same); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=5, width=5] + let input = Tensor::<4>::ones([1, 2, 5, 5], &device); + let output = pool.forward(input); + + // Same padding should preserve spatial dimensions + assert_eq!(output.dims(), [1, 2, 5, 5]); + } + + #[test] + fn display() { + let config = MaxPool2dConfig::new([3, 3]); + + let layer = config.init(); + + assert_eq!( + alloc::format!("{layer}"), + "MaxPool2d {kernel_size: [3, 3], stride: [3, 3], padding: Valid, dilation: [1, 1], ceil_mode: false}" + ); + } + + #[rstest] + #[case([2, 2])] + #[case([1, 2])] + fn default_strides_match_kernel_size(#[case] kernel_size: [usize; 2]) { + let config = MaxPool2dConfig::new(kernel_size); + + assert_eq!( + config.strides, kernel_size, + "Expected strides ({:?}) to match kernel size ({:?}) in default MaxPool2dConfig::new constructor", + config.strides, config.kernel_size + ); + } + + #[test] + fn asymmetric_padding_forward() { + let device = Default::default(); + // Create max pool with asymmetric padding: top=1, left=2, bottom=3, right=4 + let config = MaxPool2dConfig::new([3, 3]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Explicit(1, 2, 3, 4)); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = pool.forward(input); + + // Height: 4 + 1 + 3 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 4 = 11, output = (11 - 3) / 1 + 1 = 9 + assert_eq!(output.dims(), [1, 2, 6, 9]); + } + + #[test] + fn symmetric_explicit_padding_forward() { + let device = Default::default(); + // Create max pool with symmetric explicit padding: top=2, left=2, bottom=2, right=2 + let config = MaxPool2dConfig::new([3, 3]) + .with_strides([1, 1]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)); + let pool = config.init(); + + // Input: [batch=1, channels=2, height=4, width=5] + let input = Tensor::<4>::ones([1, 2, 4, 5], &device); + let output = pool.forward(input); + + // Height: 4 + 2 + 2 = 8, output = (8 - 3) / 1 + 1 = 6 + // Width: 5 + 2 + 2 = 9, output = (9 - 3) / 1 + 1 = 7 + assert_eq!(output.dims(), [1, 2, 6, 7]); + } +} diff --git a/crates/burn-nn/src/modules/pool/mod.rs b/crates/burn-nn/src/modules/pool/mod.rs new file mode 100644 index 0000000..622a4b6 --- /dev/null +++ b/crates/burn-nn/src/modules/pool/mod.rs @@ -0,0 +1,13 @@ +mod adaptive_avg_pool1d; +mod adaptive_avg_pool2d; +mod avg_pool1d; +mod avg_pool2d; +mod max_pool1d; +mod max_pool2d; + +pub use adaptive_avg_pool1d::*; +pub use adaptive_avg_pool2d::*; +pub use avg_pool1d::*; +pub use avg_pool2d::*; +pub use max_pool1d::*; +pub use max_pool2d::*; diff --git a/crates/burn-nn/src/modules/pos_encoding.rs b/crates/burn-nn/src/modules/pos_encoding.rs new file mode 100644 index 0000000..409491b --- /dev/null +++ b/crates/burn-nn/src/modules/pos_encoding.rs @@ -0,0 +1,289 @@ +use burn_core as burn; + +use alloc::vec::Vec; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; + +use burn::tensor::TensorData; +use burn::tensor::{Device, Tensor}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Configuration to create a [PositionalEncoding](PositionalEncoding) layer using the [init function](PositionalEncodingConfig::init). +#[derive(Config, Debug)] +pub struct PositionalEncodingConfig { + /// Maximum sequence size to use. + #[config(default = "5_000")] + pub max_sequence_size: usize, + + /// The size of each vector. + pub d_model: usize, + + /// Max time scale to use. + #[config(default = "10_000")] + pub max_timescale: usize, +} + +/// Positional encoding layer for transformer models. +/// +/// This layer adds positional information to the input embeddings, allowing the transformer model +/// to take into account the order of the sequence. The positional encoding is added to the input +/// embeddings by computing a set of sinusoidal functions with different frequencies and phases. +/// +/// Sinusoids are used for positional embedding introduced in +/// [Attention is all you need](https://arxiv.org/abs/1706.03762). +/// +/// The reference implementation can be found here: +/// [LANGUAGE MODELING WITH NN.TRANSFORMER AND TORCHTEXT +/// ](https://pytorch.org/tutorials/beginner/transformer_tutorial.html) +/// +/// Should be created using [PositionalEncodingConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct PositionalEncoding { + /// The sinusoids used to add positional information to the input embeddings. + pub sinusoids: Tensor<3>, + /// The maximum sequence size to use. + pub max_sequence_size: usize, + /// Max time scale to use. + pub max_timescale: usize, +} + +impl ModuleDisplay for PositionalEncoding { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [_, _, d_model] = self.sinusoids.shape().dims(); + content + .add("d_model", &d_model) + .add("max_sequence_size", &self.max_sequence_size) + .add("max_timescale", &self.max_timescale) + .optional() + } +} + +impl PositionalEncodingConfig { + /// Initialize a new [PositionalEncoding](PositionalEncoding) module. + pub fn init(&self, device: &Device) -> PositionalEncoding { + let sinusoids = generate_sinusoids( + self.max_sequence_size, + self.d_model, + self.max_timescale, + device, + ) + .unsqueeze::<3>(); + + PositionalEncoding { + sinusoids, + max_sequence_size: self.max_sequence_size, + max_timescale: self.max_timescale, + } + } +} + +impl PositionalEncoding { + /// Applies the forward pass on the input tensor by adding the sinusoids to the input. + /// + /// # Shapes + /// + /// * input: [batch_size, seq_length, d_model] + /// * output: [batch_size, seq_length, d_model] + /// + /// + /// # Panics + /// + /// * Panics if the input sequence length is greater than the maximum sequence size. + /// * Panics if the input d_model is not equal to the d_model of the sinusoids. + pub fn forward(&self, input: Tensor<3>) -> Tensor<3> { + let [_, seq_length, d_model_input] = input.dims(); + + let [batch_size, max_sequence_size, d_model] = self.sinusoids.dims(); + + assert!( + max_sequence_size >= seq_length, + "max_sequence_size({max_sequence_size}) must be greater or equal than length({seq_length})" + ); + + assert!( + d_model_input == d_model, + "d_model({d_model_input}) of the input must be equal to d_model of encoding({d_model})" + ); + + let slices = [0..batch_size, 0..seq_length, 0..d_model]; + + input.add(self.sinusoids.clone().slice(slices)) + } +} + +/// Returns sinusoids for positional embedding introduced in +/// [Attention is all you need](https://arxiv.org/abs/1706.03762). +/// +/// The reference implementation can be found here: +/// [LANGUAGE MODELING WITH NN.TRANSFORMER AND TORCHTEXT +/// ](https://pytorch.org/tutorials/beginner/transformer_tutorial.html) +/// +/// # Arguments +/// +/// * `length` - The length of the sequence. +/// * `d_model` - The size of each vector. +/// * `max_timescale` - The maximum time scale to use. +/// +/// # Returns +/// +/// A tensor of shape [length, d_model] containing the sinusoids. +pub fn generate_sinusoids( + length: usize, + d_model: usize, + max_timescale: usize, + device: &Device, +) -> Tensor<2> { + assert!(d_model.is_multiple_of(2), "d_model must be even"); + assert!( + max_timescale >= length, + "max_timescale must be greater than or equal to length" + ); + + // Calculate the increment for the logarithmic timescale + let log_timescale_increment = -(max_timescale as f32).ln() / d_model as f32; + + // Create a vector to hold the sinusoids + let mut scaled_time_sin_cos = Vec::with_capacity(length); + + // Loop over each position in the sequence + for i in 0..length { + // Create a vector to hold the sinusoids for this position + let mut row = Vec::with_capacity(d_model / 2); + // Loop over each dimension of the sinusoids + for k in (0..d_model).step_by(2) { + // Calculate the division term for this dimension + let div_term = (k as f32 * log_timescale_increment).exp(); + // Calculate the sine and cosine values for this dimension and position + row.push((div_term * i as f32).sin()); + row.push((div_term * i as f32).cos()); + } + + // Add the sinusoids for this position to the vector + scaled_time_sin_cos.push(row); + } + + // Convert the sinusoids to a tensor and return it + let data = TensorData::new( + scaled_time_sin_cos.into_iter().flatten().collect(), + [length, d_model], + ); + + Tensor::<2>::from_data(data, device) +} + +#[cfg(test)] +mod tests { + + use super::*; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_module() { + let d_model = 6; + let length = 3; + + // expected to broadcast + let batch_size = 2; + + let device = Default::default(); + let pe = PositionalEncodingConfig::new(d_model).init(&device); + + // Use a tensor of zeros as input for easy verification of the output + // The output should be the sinusoids broadcasted to the input shape + let tensor = Tensor::zeros([batch_size, length, d_model], &device); + + let output = pe.forward(tensor); + + assert_eq!(&*output.shape(), [batch_size, length, d_model]); + + let expected = Tensor::<3>::from_floats( + [ + [ + [0.00000, 1.00000, 0.00000, 1.00000, 0.00000, 1.00000], + [0.84147, 0.54030, 0.04640, 0.99892, 0.00215, 1.00000], + [0.90930, -0.41615, 0.09270, 0.99569, 0.00431, 0.99999], + ], + [ + [0.00000, 1.00000, 0.00000, 1.00000, 0.00000, 1.00000], + [0.84147, 0.54030, 0.04640, 0.99892, 0.00215, 1.00000], + [0.90930, -0.41615, 0.09270, 0.99569, 0.00431, 0.99999], + ], + ], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + fn test_generate_sinusoids() { + let device = Default::default(); + let sinusoids = generate_sinusoids(12, 6, 10_000, &device); + + // The values are taken from the pytorch reference implementation + let expected = Tensor::<2>::from_floats( + [ + [0.00000, 1.00000, 0.00000, 1.00000, 0.00000, 1.00000], + [0.84147, 0.54030, 0.04640, 0.99892, 0.00215, 1.00000], + [0.90930, -0.41615, 0.09270, 0.99569, 0.00431, 0.99999], + [0.14112, -0.98999, 0.13880, 0.99032, 0.00646, 0.99998], + [-0.75680, -0.65364, 0.18460, 0.98281, 0.00862, 0.99996], + [-0.95892, 0.28366, 0.23000, 0.97319, 0.01077, 0.99994], + [-0.27942, 0.96017, 0.27491, 0.96147, 0.01293, 0.99992], + [0.65699, 0.75390, 0.31922, 0.94768, 0.01508, 0.99989], + [0.98936, -0.14550, 0.36285, 0.93185, 0.01723, 0.99985], + [0.41212, -0.91113, 0.40570, 0.91401, 0.01939, 0.99981], + [-0.54402, -0.83907, 0.44767, 0.89420, 0.02154, 0.99977], + [-0.99999, 0.00443, 0.48868, 0.87246, 0.02370, 0.99972], + ], + &device, + ); + sinusoids + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + #[should_panic] + fn d_model_input_should_match() { + let d_model = 8; + let device = Default::default(); + let pe = PositionalEncodingConfig::new(d_model).init(&device); + let input = Tensor::zeros([1, 5, 10], &device); + let _output = pe.forward(input); + } + + #[test] + #[should_panic] + fn input_length_should_be_less_than_max_len() { + let d_model = 8; + let device = Default::default(); + let pe = PositionalEncodingConfig::new(d_model).init(&device); + let input = Tensor::zeros([1, 6_000, d_model], &device); + let _output = pe.forward(input); + } + + #[test] + fn display() { + let config = PositionalEncodingConfig::new(4); + let pe = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{pe}"), + "PositionalEncoding {d_model: 4, max_sequence_size: 5000, max_timescale: 10000}" + ); + } +} diff --git a/crates/burn-nn/src/modules/rnn/basic.rs b/crates/burn-nn/src/modules/rnn/basic.rs new file mode 100644 index 0000000..d063d8d --- /dev/null +++ b/crates/burn-nn/src/modules/rnn/basic.rs @@ -0,0 +1,698 @@ +use burn_core as burn; + +use crate::GateController; +use crate::activation::{Activation, ActivationConfig}; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// A RnnState is used to store hidden state in RNN. +pub struct RnnState { + /// The hidden state. + pub hidden: Tensor, +} + +impl RnnState { + /// Initialize a new [RNN State](RnnState). + pub fn new(hidden: Tensor) -> Self { + Self { hidden } + } +} + +/// Configuration to create a [Rnn](Rnn) module using the [init function](RnnConfig::init). +#[derive(Config, Debug)] +pub struct RnnConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the Rnn transformation. + pub bias: bool, + /// Rnn initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// If true, the input tensor is expected to be `[batch_size, seq_length, input_size]`. + /// If false, the input tensor is expected to be `[seq_length, batch_size, input_size]`. + #[config(default = true)] + pub batch_first: bool, + /// If true, process the sequence in reverse order. + /// This is useful for implementing reverse-direction RNNs (e.g., ONNX reverse direction). + #[config(default = false)] + pub reverse: bool, + /// Optional hidden state clip threshold. If provided, hidden state values are clipped + /// to the range `[-clip, +clip]` after each timestep. This can help prevent + /// exploding values during inference. + pub clip: Option, + /// Activation function applied to the hidden state before computing hidden output. + /// Default is Tanh, which is standard for Rnn. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, +} + +/// The Rnn module. This implementation is for a unidirectional, stateless, Rnn. +/// Should be created with [RnnConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Rnn { + /// gate controller for Rnn (has single gate). + pub gate: GateController, + /// The hidden state of the Rnn. + pub d_hidden: usize, + /// If true, input is `[batch_size, seq_length, input_size]`. + /// If false, input is `[seq_length, batch_size, input_size]`. + pub batch_first: bool, + /// If true, process the sequence in reverse order. + pub reverse: bool, + /// Optional hidden state clip threshold. + pub clip: Option, + /// Activation function for hidden output. + pub hidden_activation: Activation, +} + +impl ModuleDisplay for Rnn { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self.gate.input_transform.weight.shape().dims(); + let bias = self.gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .optional() + } +} + +impl RnnConfig { + /// Initialize a new [Rnn](Rnn) module. + pub fn init(&self, device: &Device) -> Rnn { + let d_output = self.d_hidden; + + let new_gate = || { + GateController::new( + self.d_input, + d_output, + self.bias, + self.initializer.clone(), + device, + ) + }; + + Rnn { + gate: new_gate(), + d_hidden: self.d_hidden, + batch_first: self.batch_first, + reverse: self.reverse, + clip: self.clip, + hidden_activation: self.hidden_activation.init(device), + } + } +} + +impl Rnn { + /// Applies the forward pass on the input tensor. This RNN implementation + /// returns the state for each element in a sequence (i.e., across seq_length) and a final state. + /// + /// ## Parameters: + /// - batched_input: The input tensor of shape: + /// - `[batch_size, sequence_length, input_size]` if `batch_first` is true (default) + /// - `[sequence_length, batch_size, input_size]` if `batch_first` is false + /// - state: An optional `RnnState` representing the initial hidden state. + /// The state tensor has shape `[batch_size, hidden_size]`. + /// If no initial state is provided, these tensors are initialized to zeros. + /// + /// ## Returns: + /// - output: A tensor represents the output features of Rnn. Shape: + /// - `[batch_size, sequence_length, hidden_size]` if `batch_first` is true + /// - `[sequence_length, batch_size, hidden_size]` if `batch_first` is false + /// - state: A `RnnState` represents the final hidden state. The hidden state tensor has the shape + /// `[batch_size, hidden_size]`. + pub fn forward( + &self, + batched_input: Tensor<3>, + state: Option>, + ) -> (Tensor<3>, RnnState<2>) { + // Convert to batch-first layout internally if needed + let batched_input = if self.batch_first { + batched_input + } else { + batched_input.swap_dims(0, 1) + }; + + let device = batched_input.device(); + let [batch_size, seq_length, _] = batched_input.dims(); + + // Process sequence in forward or reverse order based on config + let (output, state) = if self.reverse { + self.forward_iter( + batched_input.iter_dim(1).rev().zip((0..seq_length).rev()), + state, + batch_size, + seq_length, + &device, + ) + } else { + self.forward_iter( + batched_input.iter_dim(1).zip(0..seq_length), + state, + batch_size, + seq_length, + &device, + ) + }; + + // Convert output back to seq-first layout if needed + let output = if self.batch_first { + output + } else { + output.swap_dims(0, 1) + }; + + (output, state) + } + + fn forward_iter, usize)>>( + &self, + input_timestep_iter: I, + state: Option>, + batch_size: usize, + seq_length: usize, + device: &Device, + ) -> (Tensor<3>, RnnState<2>) { + let mut batched_hidden_state = + Tensor::empty([batch_size, seq_length, self.d_hidden], device); + + let mut hidden_state = match state { + Some(state) => state.hidden, + None => Tensor::zeros([batch_size, self.d_hidden], device), + }; + + for (input_t, t) in input_timestep_iter { + let input_t = input_t.squeeze_dim(1); + + // Compute gate output: h_t = activation(W_i @ x_t + W_h @ h_{t-1} + b) + let biased_gate_sum = self + .gate + .gate_product(input_t.clone(), hidden_state.clone()); + + let output_values = self.hidden_activation.forward(biased_gate_sum); + + // Update hidden state + hidden_state = output_values; + + // Apply hidden state clipping if configured + if let Some(clip) = self.clip { + hidden_state = hidden_state.clamp(-clip, clip); + } + + let unsqueezed_hidden_state = hidden_state.clone().unsqueeze_dim(1); + + // store the hidden state for this timestep + batched_hidden_state = batched_hidden_state.slice_assign( + [0..batch_size, t..(t + 1), 0..self.d_hidden], + unsqueezed_hidden_state.clone(), + ); + } + + (batched_hidden_state, RnnState::new(hidden_state)) + } +} + +/// Configuration to create a [BiRnn](BiRnn) module using the [init function](BiRnnConfig::init). +#[derive(Config, Debug)] +pub struct BiRnnConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the BiRnn transformation. + pub bias: bool, + /// BiRnn initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// If true, the input tensor is expected to be `[batch_size, seq_length, input_size]`. + /// If false, the input tensor is expected to be `[seq_length, batch_size, input_size]`. + #[config(default = true)] + pub batch_first: bool, + /// Optional hidden state clip threshold. + pub clip: Option, + /// Activation function applied to the hidden state before computing hidden output. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, +} + +/// The BiRnn module. This implementation is for Bidirectional RNN. +/// Should be created with [BiRnnConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct BiRnn { + /// RNN for the forward direction. + pub forward: Rnn, + /// RNN for the reverse direction. + pub reverse: Rnn, + /// The size of the hidden state. + pub d_hidden: usize, + /// If true, input is `[batch_size, seq_length, input_size]`. + /// If false, input is `[seq_length, batch_size, input_size]`. + pub batch_first: bool, +} + +impl ModuleDisplay for BiRnn { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self.forward.gate.input_transform.weight.shape().dims(); + let bias = self.forward.gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .optional() + } +} + +impl BiRnnConfig { + /// Initialize a new [Bidirectional RNN](BiRnn) module. + pub fn init(&self, device: &Device) -> BiRnn { + // Internal RNNs always use batch_first=true; BiRnn handles layout conversion + let base_config = RnnConfig::new(self.d_input, self.d_hidden, self.bias) + .with_initializer(self.initializer.clone()) + .with_batch_first(true) + .with_clip(self.clip) + .with_hidden_activation(self.hidden_activation.clone()); + + BiRnn { + forward: base_config.clone().init(device), + reverse: base_config.init(device), + d_hidden: self.d_hidden, + batch_first: self.batch_first, + } + } +} + +impl BiRnn { + /// Applies the forward pass on the input tensor. This Bidirectional RNN implementation + /// returns the state for each element in a sequence (i.e., across seq_length) and a final state. + /// + /// ## Parameters: + /// - batched_input: The input tensor of shape: + /// - `[batch_size, sequence_length, input_size]` if `batch_first` is true (default) + /// - `[sequence_length, batch_size, input_size]` if `batch_first` is false + /// - state: An optional `RnnState` representing the hidden state. + /// Each state tensor has shape `[2, batch_size, hidden_size]`. + /// If no initial state is provided, these tensors are initialized to zeros. + /// + /// ## Returns: + /// - output: A tensor represents the output features of RNN. Shape: + /// - `[batch_size, sequence_length, hidden_size * 2]` if `batch_first` is true + /// - `[sequence_length, batch_size, hidden_size * 2]` if `batch_first` is false + /// - state: A `RnnState` represents the final forward and reverse states. + /// The `state.hidden` have the shape `[2, batch_size, hidden_size]`. + pub fn forward( + &self, + batched_input: Tensor<3>, + state: Option>, + ) -> (Tensor<3>, RnnState<3>) { + // Convert to batch-first layout internally if needed + let batched_input = if self.batch_first { + batched_input + } else { + batched_input.swap_dims(0, 1) + }; + + let device = batched_input.clone().device(); + let [batch_size, seq_length, _] = batched_input.shape().dims(); + + let [init_state_forward, init_state_reverse] = match state { + Some(state) => { + let hidden_state_forward = state + .hidden + .clone() + .slice([0..1, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + let hidden_state_reverse = state + .hidden + .slice([1..2, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + + [ + Some(RnnState::new(hidden_state_forward)), + Some(RnnState::new(hidden_state_reverse)), + ] + } + None => [None, None], + }; + + // forward direction + let (batched_hidden_state_forward, final_state_forward) = self + .forward + .forward(batched_input.clone(), init_state_forward); + + // reverse direction + let (batched_hidden_state_reverse, final_state_reverse) = self.reverse.forward_iter( + batched_input.iter_dim(1).rev().zip((0..seq_length).rev()), + init_state_reverse, + batch_size, + seq_length, + &device, + ); + + let output = Tensor::cat( + [batched_hidden_state_forward, batched_hidden_state_reverse].to_vec(), + 2, + ); + + // Convert output back to seq-first layout if needed + let output = if self.batch_first { + output + } else { + output.swap_dims(0, 1) + }; + + let state = RnnState::new(Tensor::stack( + [final_state_forward.hidden, final_state_reverse.hidden].to_vec(), + 0, + )); + + (output, state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Linear; + use burn::module::Param; + use burn::tensor::{Distribution, TensorData}; + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + fn create_single_feature_gate_controller( + weights: f32, + biases: f32, + device: &Device, + ) -> GateController { + let record_1 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + let record_2 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + GateController::create_with_weights(record_1, record_2) + } + + #[test] + fn test_with_uniform_initializer() { + let device = Device::default(); + device.seed(0); + + let config = RnnConfig::new(5, 5, false) + .with_initializer(Initializer::Uniform { min: 0.0, max: 1.0 }); + let rnn = config.init(&device); + + let gate_to_data = |gate: GateController| gate.input_transform.weight.val().to_data(); + + gate_to_data(rnn.gate).assert_within_range::(0.elem()..1.elem()); + } + + /// Test forward pass with simple input vector. + /// + /// Simple RNN: h_t = tanh(W_input @ x_t + W_hidden @ h_{t-1} + b) + /// With input=0.1, weight_input=0.5, bias=0.0, h_0=0.0, weight_hidden=0.5 + /// h_t = tanh(0.5*0.1 + 0.5*0) = tanh(0.05) = 0.04995 + #[test] + fn test_forward_single_input_single_feature() { + let device = Device::default(); + device.seed(0); + + let config = RnnConfig::new(1, 1, false); + let device = Default::default(); + let mut rnn = config.init(&device); + + rnn.gate = create_single_feature_gate_controller(0.5, 0.0, &device); + + // single timestep with single feature + let input = Tensor::<3>::from_data(TensorData::from([[[0.1]]]), &device); + + let (output, state) = rnn.forward(input, None); + + let tolerance = Tolerance::default(); + let expected = TensorData::from([[0.04995]]); + state + .hidden + .to_data() + .assert_approx_eq::(&expected, tolerance); + + output + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0) + .to_data() + .assert_approx_eq::(&state.hidden.to_data(), tolerance); + } + + #[test] + fn test_batched_forward_pass_batch_of_one() { + let device = Default::default(); + let rnn = RnnConfig::new(64, 1024, true).init(&device); + let batched_input = Tensor::<3>::random([1, 2, 64], Distribution::Default, &device); + + let (output, state) = rnn.forward(batched_input, None); + assert_eq!(output.dims(), [1, 2, 1024]); + assert_eq!(state.hidden.dims(), [1, 1024]); + } + + #[test] + #[cfg(feature = "std")] + fn test_batched_backward_pass() { + use burn::tensor::Shape; + let device = Device::default().autodiff(); + let rnn = RnnConfig::new(64, 32, true).init(&device); + let shape: Shape = [8, 10, 64].into(); + let batched_input = Tensor::<3>::random(shape, Distribution::Default, &device); + + let (output, _) = rnn.forward(batched_input.clone(), None); + let fake_loss = output; + let grads = fake_loss.backward(); + + let some_gradient = rnn.gate.hidden_transform.weight.grad(&grads).unwrap(); + + // Asserts that the gradients exist and are non-zero + assert_ne!( + some_gradient + .any() + .into_data() + .iter::() + .next() + .unwrap(), + 0.0 + ); + } + + #[test] + fn test_bidirectional() { + let device = Device::default(); + device.seed(0); + + let config = BiRnnConfig::new(2, 3, true); + let mut rnn = config.init(&device); + + fn create_gate_controller( + input_weights: [[f32; D1]; D2], + input_biases: [f32; D1], + hidden_weights: [[f32; D1]; D1], + hidden_biases: [f32; D1], + device: &Device, + ) -> GateController { + let input_record = Linear { + weight: Param::from_data(TensorData::from(input_weights), device), + bias: Some(Param::from_data(TensorData::from(input_biases), device)), + }; + let hidden_record = Linear { + weight: Param::from_data(TensorData::from(hidden_weights), device), + bias: Some(Param::from_data(TensorData::from(hidden_biases), device)), + }; + GateController::create_with_weights(input_record, hidden_record) + } + + // [batch_size=1, seq_length=4, input_size=2] + let input = Tensor::<3>::from_data( + TensorData::from([[ + [0.949, -0.861], + [0.892, 0.927], + [-0.173, -0.301], + [-0.081, 0.992], + ]]), + &device, + ); + + // [2, batch_size=1, hidden_size=3] + let h0 = Tensor::<3>::from_data( + TensorData::from([[[0.280, 0.360, -1.242]], [[-0.588, 0.729, -0.788]]]), + &device, + ); + + rnn.forward.gate = create_gate_controller( + // input_weights: [input_size=2, hidden_size=3] + [[0.367, 0.091, 0.342], [0.322, 0.533, 0.059]], + // input_biases: [hidden_size=3] + [-0.196, 0.354, 0.209], + // hidden_weights: [hidden_size=3, hidden_size=3] + [ + [-0.320, 0.232, -0.165], + [0.093, -0.572, -0.315], + [-0.467, 0.325, 0.046], + ], + // hidden_biases: [hidden_size=3] + [0.181, -0.190, -0.245], + &device, + ); + + rnn.reverse.gate = create_gate_controller( + [[-0.055, 0.506, 0.247], [-0.369, 0.178, -0.258]], + [0.540, -0.164, 0.033], + [ + [0.159, 0.180, -0.037], + [-0.443, 0.485, -0.488], + [0.098, -0.085, -0.140], + ], + [-0.510, 0.105, 0.114], + &device, + ); + + // [batch_size=1, sequence_length=4, hidden_size * 2 = 6] + // The expected output values were computed from PyTorch + let expected_output_with_init_state = TensorData::from([[ + [0.5226, -0.6370, 0.0210, 0.0685, 0.3867, 0.3602], + [0.3580, 0.8431, 0.4129, -0.3175, 0.4374, 0.1766], + [-0.3837, -0.2703, -0.3957, -0.1542, -0.1122, 0.0725], + [0.5059, 0.5527, 0.1244, -0.6779, 0.3725, -0.3387], + ]]); + let expected_output_without_init_state = TensorData::from([[ + [0.0560, -0.2056, 0.2334, 0.0892, 0.3912, 0.3607], + [0.4340, 0.7378, 0.3714, -0.2394, 0.4235, 0.2002], + [-0.3962, -0.2097, -0.3798, 0.0532, -0.2067, 0.1727], + [0.5075, 0.5298, 0.1083, -0.3200, 0.0764, -0.1282], + ]]); + + //`[2, batch_size=1, hidden_size=3]` + let expected_hn_with_init_state = + TensorData::from([[[0.5059, 0.5527, 0.1244]], [[0.0685, 0.3867, 0.3602]]]); + let expected_hn_without_init_state = + TensorData::from([[[0.5075, 0.5298, 0.1083]], [[0.0892, 0.3912, 0.3607]]]); + + let (output_with_init_state, state_with_init_state) = + rnn.forward(input.clone(), Some(RnnState::new(h0))); + let (output_without_init_state, state_without_init_state) = rnn.forward(input, None); + + let tolerance = Tolerance::permissive(); + output_with_init_state + .to_data() + .assert_approx_eq::(&expected_output_with_init_state, tolerance); + output_without_init_state + .to_data() + .assert_approx_eq::(&expected_output_without_init_state, tolerance); + state_with_init_state + .hidden + .to_data() + .assert_approx_eq::(&expected_hn_with_init_state, tolerance); + state_without_init_state + .hidden + .to_data() + .assert_approx_eq::(&expected_hn_without_init_state, tolerance); + } + + #[test] + fn display_rnn() { + let config = RnnConfig::new(2, 3, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "Rnn {d_input: 2, d_hidden: 3, bias: true, params: 21}" + ); + } + + #[test] + fn display_birnn() { + let config = BiRnnConfig::new(2, 3, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "BiRnn {d_input: 2, d_hidden: 3, bias: true, params: 42}" + ); + } + + #[test] + fn test_rnn_clipping() { + let device = Default::default(); + + // Create Rnn with clipping enabled + let clip_value = 0.3; + let config = RnnConfig::new(4, 8, true).with_clip(Some(clip_value)); + let rnn = config.init(&device); + + let input = Tensor::<3>::random([2, 5, 4], Distribution::Default, &device); + let (_, state) = rnn.forward(input, None); + + // Verify output values are within the clip range + let hidden_state: Vec = state.hidden.to_data().to_vec().unwrap(); + for val in hidden_state { + assert!( + val >= -clip_value as f32 && val <= clip_value as f32, + "Value {} is outside clip range [-{}, {}]", + val, + clip_value, + clip_value + ); + } + } + + #[test] + fn test_forward_reverse_sequence() { + let device = Device::default(); + device.seed(0); + + // Create RNN with reverse=true to process sequence in reverse order + let config = RnnConfig::new(1, 1, false).with_reverse(true); + let mut rnn = config.init(&device); + + rnn.gate = create_single_feature_gate_controller(0.5, 0.0, &device); + + // Create input with 3 timesteps: [0.1, 0.2, 0.3] + // Shape: [batch_size=1, seq_length=3, input_features=1] + let input = Tensor::<3>::from_data(TensorData::from([[[0.1], [0.2], [0.3]]]), &device); + + let (output, state) = rnn.forward(input, None); + + // With reverse=true and weight=0.5, sequence is processed in reverse: + // t=2 (last): h = tanh(0.5*0.3 + 0.5*0) = tanh(0.15) ≈ 0.1488850 + // t=1 (mid): h = tanh(0.5*0.2 + 0.5*0.1488850) ≈ 0.17269433 + // t=0 (first): h = tanh(0.5*0.1 + 0.5*0.17269433) ≈ 0.135508 + let expected_final_hidden = TensorData::from([[0.135508]]); + + let tolerance = Tolerance::default(); + state + .hidden + .to_data() + .assert_approx_eq::(&expected_final_hidden, tolerance); + + // Verify output tensor has correct shape and matches state at final timestep + assert_eq!(output.dims(), [1, 3, 1]); + } +} diff --git a/crates/burn-nn/src/modules/rnn/gate_controller.rs b/crates/burn-nn/src/modules/rnn/gate_controller.rs new file mode 100644 index 0000000..cc64c38 --- /dev/null +++ b/crates/burn-nn/src/modules/rnn/gate_controller.rs @@ -0,0 +1,75 @@ +use burn_core as burn; + +use crate::{Linear, LinearConfig, LinearLayout}; +use burn::module::{Initializer, Module}; +use burn::tensor::{Device, Tensor}; + +/// A GateController represents a gate in an LSTM cell. An +/// LSTM cell generally contains three gates: an input gate, +/// forget gate, and output gate. Additionally, cell gate +/// is just used to compute the cell state. +/// +/// An Lstm gate is modeled as two linear transformations. +/// The results of these transformations are used to calculate +/// the gate's output. +#[derive(Module, Debug)] +pub struct GateController { + /// Represents the affine transformation applied to input vector + pub input_transform: Linear, + /// Represents the affine transformation applied to the hidden state + pub hidden_transform: Linear, +} + +impl GateController { + /// Initialize a new [gate_controller](GateController) module. + pub fn new( + d_input: usize, + d_output: usize, + bias: bool, + initializer: Initializer, + device: &Device, + ) -> Self { + Self { + input_transform: LinearConfig { + d_input, + d_output, + bias, + initializer: initializer.clone(), + layout: LinearLayout::Row, + } + .init(device), + hidden_transform: LinearConfig { + d_input: d_output, + d_output, + bias, + initializer, + layout: LinearLayout::Row, + } + .init(device), + } + } + + /// Helper function for performing weighted matrix product for a gate and adds + /// bias, if any. + /// + /// Mathematically, performs `Wx*X + Wh*H + b`, where: + /// Wx = weight matrix for the connection to input vector X + /// Wh = weight matrix for the connection to hidden state H + /// X = input vector + /// H = hidden state + /// b = bias terms + pub fn gate_product(&self, input: Tensor<2>, hidden: Tensor<2>) -> Tensor<2> { + self.input_transform.forward(input) + self.hidden_transform.forward(hidden) + } + + /// Used to initialize a gate controller with known weight layers, + /// allowing for predictable behavior. Used only for testing in + /// lstm. + #[cfg(test)] + pub fn create_with_weights(input_transform: Linear, hidden_transform: Linear) -> Self { + Self { + input_transform, + hidden_transform, + } + } +} diff --git a/crates/burn-nn/src/modules/rnn/gru.rs b/crates/burn-nn/src/modules/rnn/gru.rs new file mode 100644 index 0000000..bba73e8 --- /dev/null +++ b/crates/burn-nn/src/modules/rnn/gru.rs @@ -0,0 +1,998 @@ +use burn_core as burn; + +use super::gate_controller::GateController; +use crate::activation::{Activation, ActivationConfig}; +use burn::config::Config; +use burn::module::Initializer; +use burn::module::Module; +use burn::module::{Content, DisplaySettings, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// Configuration to create a [gru](Gru) module using the [init function](GruConfig::init). +#[derive(Config, Debug)] +pub struct GruConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the Gru transformation. + pub bias: bool, + /// If reset gate should be applied after weight multiplication. + /// + /// This configuration option controls how the reset gate is applied to the hidden state. + /// * `true` - (Default) Match the initial arXiv version of the paper [Learning Phrase Representations using RNN Encoder-Decoder for + /// Statistical Machine Translation (v1)](https://arxiv.org/abs/1406.1078v1) and apply the reset gate after multiplication by + /// the weights. This matches the behavior of [PyTorch GRU](https://pytorch.org/docs/stable/generated/torch.nn.GRU.html#torch.nn.GRU). + /// * `false` - Match the most recent revision of [Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine + /// Translation (v3)](https://arxiv.org/abs/1406.1078) and apply the reset gate before the weight multiplication. + /// + /// The differing implementations can give slightly different numerical results and have different efficiencies. For more + /// motivation for why the `true` can be more efficient see [Optimizing RNNs with Differentiable Graphs](https://svail.github.io/diff_graphs). + /// + /// To set this field to `false` use [`with_reset_after`](`GruConfig::with_reset_after`). + #[config(default = "true")] + pub reset_after: bool, + /// Gru initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// Activation function for the update and reset gates. + /// Default is Sigmoid, which is standard for GRU gates. + #[config(default = "ActivationConfig::Sigmoid")] + pub gate_activation: ActivationConfig, + /// Activation function for the new/candidate gate. + /// Default is Tanh, which is standard for GRU. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, + /// Optional hidden state clip threshold. If provided, hidden state values are clipped + /// to the range `[-clip, +clip]` after each timestep. This can help prevent + /// exploding values during inference. + pub clip: Option, +} + +/// The Gru (Gated recurrent unit) module. This implementation is for a unidirectional, stateless, Gru. +/// +/// Introduced in the paper: [Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation](https://arxiv.org/abs/1406.1078). +/// +/// Should be created with [GruConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Gru { + /// The update gate controller. + pub update_gate: GateController, + /// The reset gate controller. + pub reset_gate: GateController, + /// The new gate controller. + pub new_gate: GateController, + /// The size of the hidden state. + pub d_hidden: usize, + /// If reset gate should be applied after weight multiplication. + pub reset_after: bool, + /// Activation function for gates (update, reset). + pub gate_activation: Activation, + /// Activation function for new/candidate gate. + pub hidden_activation: Activation, + /// Optional hidden state clip threshold. + pub clip: Option, +} + +impl ModuleDisplay for Gru { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self.update_gate.input_transform.weight.shape().dims(); + let bias = self.update_gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .add("reset_after", &self.reset_after) + .optional() + } +} + +impl GruConfig { + /// Initialize a new [gru](Gru) module. + pub fn init(&self, device: &Device) -> Gru { + let d_output = self.d_hidden; + + let update_gate = GateController::new( + self.d_input, + d_output, + self.bias, + self.initializer.clone(), + device, + ); + let reset_gate = GateController::new( + self.d_input, + d_output, + self.bias, + self.initializer.clone(), + device, + ); + let new_gate = GateController::new( + self.d_input, + d_output, + self.bias, + self.initializer.clone(), + device, + ); + + Gru { + update_gate, + reset_gate, + new_gate, + d_hidden: self.d_hidden, + reset_after: self.reset_after, + gate_activation: self.gate_activation.init(device), + hidden_activation: self.hidden_activation.init(device), + clip: self.clip, + } + } +} + +impl Gru { + /// Applies the forward pass on the input tensor. This GRU implementation + /// returns a state tensor with dimensions `[batch_size, sequence_length, hidden_size]`. + /// + /// # Parameters + /// - batched_input: `[batch_size, sequence_length, input_size]`. + /// - state: An optional tensor representing an initial cell state with dimensions + /// `[batch_size, hidden_size]`. If none is provided, an empty state will be used. + /// + /// # Returns + /// - output: `[batch_size, sequence_length, hidden_size]` + pub fn forward(&self, batched_input: Tensor<3>, state: Option>) -> Tensor<3> { + let device = batched_input.device(); + let [batch_size, seq_length, _] = batched_input.shape().dims(); + + self.forward_iter( + batched_input.iter_dim(1).zip(0..seq_length), + state, + batch_size, + seq_length, + &device, + ) + .0 + } + + /// Forward pass variant that accepts an iterator over timesteps. + /// Used by BiGru to process sequences in either direction. + /// + /// # Parameters + /// - input_timestep_iter: Iterator yielding (input_tensor, timestep_index) pairs. + /// The timestep_index determines where in the output tensor to store results. + /// - state: Optional initial hidden state with shape `[batch_size, hidden_size]`. + /// - batch_size: Batch size of the input. + /// - seq_length: Sequence length of the input. + /// - device: Device to create tensors on. + /// + /// # Returns + /// - output: `[batch_size, sequence_length, hidden_size]` + /// - final_hidden: Final hidden state `[batch_size, hidden_size]` + pub(crate) fn forward_iter, usize)>>( + &self, + input_timestep_iter: I, + state: Option>, + batch_size: usize, + seq_length: usize, + device: &Device, + ) -> (Tensor<3>, Tensor<2>) { + let mut batched_hidden_state = + Tensor::empty([batch_size, seq_length, self.d_hidden], device); + + let mut hidden_t = match state { + Some(state) => state, + None => Tensor::zeros([batch_size, self.d_hidden], device), + }; + + for (input_t, t) in input_timestep_iter { + let input_t = input_t.squeeze_dim(1); + + // u(pdate)g(ate) tensors + let biased_ug_input_sum = + self.gate_product(&input_t, &hidden_t, None, &self.update_gate); + let update_values = self.gate_activation.forward(biased_ug_input_sum); + + // r(eset)g(ate) tensors + let biased_rg_input_sum = + self.gate_product(&input_t, &hidden_t, None, &self.reset_gate); + let reset_values = self.gate_activation.forward(biased_rg_input_sum); + + // n(ew)g(ate) tensor + let biased_ng_input_sum = if self.reset_after { + self.gate_product(&input_t, &hidden_t, Some(&reset_values), &self.new_gate) + } else { + let reset_t = hidden_t.clone().mul(reset_values); + self.gate_product(&input_t, &reset_t, None, &self.new_gate) + }; + let candidate_state = self.hidden_activation.forward(biased_ng_input_sum); + + // calculate linear interpolation between previous hidden state and candidate state: + // h_t = (1 - z_t) * g_t + z_t * h_{t-1} + let one_minus_z = update_values.clone().neg().add_scalar(1.0); + hidden_t = candidate_state.mul(one_minus_z) + update_values.mul(hidden_t); + + // Apply hidden state clipping if configured + if let Some(clip) = self.clip { + hidden_t = hidden_t.clamp(-clip, clip); + } + + let unsqueezed_hidden_state = hidden_t.clone().unsqueeze_dim(1); + + batched_hidden_state = batched_hidden_state.slice_assign( + [0..batch_size, t..(t + 1), 0..self.d_hidden], + unsqueezed_hidden_state, + ); + } + + (batched_hidden_state, hidden_t) + } + + /// Helper function for performing weighted matrix product for a gate and adds + /// bias, if any, and optionally applies reset to hidden state. + /// + /// Mathematically, performs `Wx*X + r .* (Wh*H + b)`, where: + /// Wx = weight matrix for the connection to input vector X + /// Wh = weight matrix for the connection to hidden state H + /// X = input vector + /// H = hidden state + /// b = bias terms + /// r = reset state + fn gate_product( + &self, + input: &Tensor<2>, + hidden: &Tensor<2>, + reset: Option<&Tensor<2>>, + gate: &GateController, + ) -> Tensor<2> { + let input_product = input.clone().matmul(gate.input_transform.weight.val()); + let hidden_product = hidden.clone().matmul(gate.hidden_transform.weight.val()); + + let input_part = match &gate.input_transform.bias { + Some(bias) => input_product + bias.val().unsqueeze(), + None => input_product, + }; + + let hidden_part = match &gate.hidden_transform.bias { + Some(bias) => hidden_product + bias.val().unsqueeze(), + None => hidden_product, + }; + + match reset { + Some(r) => input_part + r.clone().mul(hidden_part), + None => input_part + hidden_part, + } + } +} + +/// Configuration to create a [BiGru](BiGru) module using the [init function](BiGruConfig::init). +#[derive(Config, Debug)] +pub struct BiGruConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the BiGru transformation. + pub bias: bool, + /// If reset gate should be applied after weight multiplication. + #[config(default = "true")] + pub reset_after: bool, + /// BiGru initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// If true, the input tensor is expected to be `[batch_size, seq_length, input_size]`. + /// If false, the input tensor is expected to be `[seq_length, batch_size, input_size]`. + #[config(default = true)] + pub batch_first: bool, + /// Activation function for the update and reset gates. + #[config(default = "ActivationConfig::Sigmoid")] + pub gate_activation: ActivationConfig, + /// Activation function for the new/candidate gate. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, + /// Optional hidden state clip threshold. + pub clip: Option, +} + +/// The BiGru module. This implementation is for Bidirectional GRU. +/// +/// Based on the paper: [Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation](https://arxiv.org/abs/1406.1078). +/// +/// Should be created with [BiGruConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct BiGru { + /// GRU for the forward direction. + pub forward: Gru, + /// GRU for the reverse direction. + pub reverse: Gru, + /// The size of the hidden state. + pub d_hidden: usize, + /// If true, input is `[batch_size, seq_length, input_size]`. + /// If false, input is `[seq_length, batch_size, input_size]`. + pub batch_first: bool, +} + +impl ModuleDisplay for BiGru { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self + .forward + .update_gate + .input_transform + .weight + .shape() + .dims(); + let bias = self.forward.update_gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .optional() + } +} + +impl BiGruConfig { + /// Initialize a new [Bidirectional GRU](BiGru) module. + pub fn init(&self, device: &Device) -> BiGru { + // Internal GRUs always use batch_first=true; BiGru handles layout conversion + let base_config = GruConfig::new(self.d_input, self.d_hidden, self.bias) + .with_initializer(self.initializer.clone()) + .with_reset_after(self.reset_after) + .with_gate_activation(self.gate_activation.clone()) + .with_hidden_activation(self.hidden_activation.clone()) + .with_clip(self.clip); + + BiGru { + forward: base_config.clone().init(device), + reverse: base_config.init(device), + d_hidden: self.d_hidden, + batch_first: self.batch_first, + } + } +} + +impl BiGru { + /// Applies the forward pass on the input tensor. This Bidirectional GRU implementation + /// returns the state for each element in a sequence (i.e., across seq_length) and a final state. + /// + /// ## Parameters: + /// - batched_input: The input tensor of shape: + /// - `[batch_size, sequence_length, input_size]` if `batch_first` is true (default) + /// - `[sequence_length, batch_size, input_size]` if `batch_first` is false + /// - state: An optional tensor representing the initial hidden state with shape + /// `[2, batch_size, hidden_size]`. If no initial state is provided, it is initialized to zeros. + /// + /// ## Returns: + /// - output: A tensor representing the output features. Shape: + /// - `[batch_size, sequence_length, hidden_size * 2]` if `batch_first` is true + /// - `[sequence_length, batch_size, hidden_size * 2]` if `batch_first` is false + /// - state: The final forward and reverse hidden states stacked along dimension 0 + /// with shape `[2, batch_size, hidden_size]`. + pub fn forward( + &self, + batched_input: Tensor<3>, + state: Option>, + ) -> (Tensor<3>, Tensor<3>) { + // Convert to batch-first layout internally if needed + let batched_input = if self.batch_first { + batched_input + } else { + batched_input.swap_dims(0, 1) + }; + + let device = batched_input.clone().device(); + let [batch_size, seq_length, _] = batched_input.shape().dims(); + + let [init_state_forward, init_state_reverse] = match state { + Some(state) => { + let hidden_state_forward = state + .clone() + .slice([0..1, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + let hidden_state_reverse = state + .slice([1..2, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + + [Some(hidden_state_forward), Some(hidden_state_reverse)] + } + None => [None, None], + }; + + // forward direction + let (batched_hidden_state_forward, final_state_forward) = self.forward.forward_iter( + batched_input.clone().iter_dim(1).zip(0..seq_length), + init_state_forward, + batch_size, + seq_length, + &device, + ); + + // reverse direction + let (batched_hidden_state_reverse, final_state_reverse) = self.reverse.forward_iter( + batched_input.iter_dim(1).rev().zip((0..seq_length).rev()), + init_state_reverse, + batch_size, + seq_length, + &device, + ); + + let output = Tensor::cat( + [batched_hidden_state_forward, batched_hidden_state_reverse].to_vec(), + 2, + ); + + // Convert output back to seq-first layout if needed + let output = if self.batch_first { + output + } else { + output.swap_dims(0, 1) + }; + + let state = Tensor::stack([final_state_forward, final_state_reverse].to_vec(), 0); + + (output, state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Linear; + use burn::module::Param; + use burn::tensor::Tolerance; + use burn::tensor::{Distribution, TensorData}; + + type FT = f32; + + fn init_gru(reset_after: bool, device: &Device) -> Gru { + fn create_gate_controller(weights: f32, biases: f32, device: &Device) -> GateController { + let record_1 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + let record_2 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + GateController::create_with_weights(record_1, record_2) + } + + let config = GruConfig::new(1, 1, false).with_reset_after(reset_after); + let mut gru = config.init(device); + + gru.update_gate = create_gate_controller(0.5, 0.0, device); + gru.reset_gate = create_gate_controller(0.6, 0.0, device); + gru.new_gate = create_gate_controller(0.7, 0.0, device); + gru + } + + /// Test forward pass with simple input vector. + /// + /// z_t = sigmoid(0.5*0.1 + 0.5*0) = 0.5125 + /// r_t = sigmoid(0.6*0.1 + 0.*0) = 0.5150 + /// g_t = tanh(0.7*0.1 + 0.7*0) = 0.0699 + /// + /// h_t = z_t * h' + (1 - z_t) * g_t = 0.0341 + #[test] + fn tests_forward_single_input_single_feature() { + let device = Device::default(); + device.seed(0); + + let mut gru = init_gru(false, &device); + + let input = Tensor::<3>::from_data(TensorData::from([[[0.1]]]), &device); + let expected = TensorData::from([[0.034]]); + + // Reset gate applied to hidden state before the matrix multiplication + let state = gru.forward(input.clone(), None); + + let output = state + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0); + + let tolerance = Tolerance::default(); + output + .to_data() + .assert_approx_eq::(&expected, tolerance); + + // Reset gate applied to hidden state after the matrix multiplication + gru.reset_after = true; // override forward behavior + let state = gru.forward(input, None); + + let output = state + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0); + + output + .to_data() + .assert_approx_eq::(&expected, tolerance); + } + + #[test] + fn tests_forward_seq_len_3() { + let device = Device::default(); + device.seed(0); + let mut gru = init_gru(true, &device); + + let input = Tensor::<3>::from_data(TensorData::from([[[0.1], [0.2], [0.3]]]), &device); + let expected = TensorData::from([[0.0341], [0.0894], [0.1575]]); + + let result = gru.forward(input.clone(), None); + let output = result + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0); + + let tolerance = Tolerance::default(); + output + .to_data() + .assert_approx_eq::(&expected, tolerance); + + // Reset gate applied to hidden state before the matrix multiplication + gru.reset_after = false; // override forward behavior + let state = gru.forward(input, None); + + let output = state + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0); + + output + .to_data() + .assert_approx_eq::(&expected, tolerance); + } + + #[test] + fn test_batched_forward_pass() { + let device = Default::default(); + let gru = GruConfig::new(64, 1024, true).init(&device); + let batched_input = Tensor::<3>::random([8, 10, 64], Distribution::Default, &device); + + let hidden_state = gru.forward(batched_input, None); + + assert_eq!(&*hidden_state.shape(), [8, 10, 1024]); + } + + #[test] + fn display() { + let config = GruConfig::new(2, 8, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "Gru {d_input: 2, d_hidden: 8, bias: true, reset_after: true, params: 288}" + ); + } + + #[test] + fn test_bigru_batched_forward_pass() { + let device = Default::default(); + let bigru = BiGruConfig::new(64, 1024, true).init(&device); + let batched_input = Tensor::<3>::random([8, 10, 64], Distribution::Default, &device); + + let (output, state) = bigru.forward(batched_input, None); + + // Output should have hidden_size * 2 features (forward + reverse concatenated) + assert_eq!(&*output.shape(), [8, 10, 2048]); + // State should have shape [2, batch_size, hidden_size] + assert_eq!(&*state.shape(), [2, 8, 1024]); + } + + #[test] + fn test_bigru_with_initial_state() { + let device = Default::default(); + let bigru = BiGruConfig::new(32, 64, true).init(&device); + let batched_input = Tensor::<3>::random([4, 5, 32], Distribution::Default, &device); + let initial_state = Tensor::<3>::random([2, 4, 64], Distribution::Default, &device); + + let (output, state) = bigru.forward(batched_input, Some(initial_state)); + + assert_eq!(&*output.shape(), [4, 5, 128]); + assert_eq!(&*state.shape(), [2, 4, 64]); + } + + #[test] + fn test_bigru_seq_first() { + let device = Default::default(); + let bigru = BiGruConfig::new(32, 64, true) + .with_batch_first(false) + .init(&device); + // Input shape: [seq_length, batch_size, input_size] when batch_first=false + let batched_input = Tensor::<3>::random([5, 4, 32], Distribution::Default, &device); + + let (output, state) = bigru.forward(batched_input, None); + + // Output shape: [seq_length, batch_size, hidden_size * 2] + assert_eq!(&*output.shape(), [5, 4, 128]); + assert_eq!(&*state.shape(), [2, 4, 64]); + } + + /// Test BiGru against PyTorch reference implementation. + /// Expected values computed with PyTorch nn.GRU(bidirectional=True). + #[test] + fn test_bigru_against_pytorch() { + let device = Device::default(); + device.seed(0); + + let config = BiGruConfig::new(2, 3, true); + let mut bigru = config.init(&device); + + fn create_gate_controller( + input_weights: [[f32; D1]; D2], + input_biases: [f32; D1], + hidden_weights: [[f32; D1]; D1], + hidden_biases: [f32; D1], + device: &Device, + ) -> GateController { + let input_record = Linear { + weight: Param::from_data(TensorData::from(input_weights), device), + bias: Some(Param::from_data(TensorData::from(input_biases), device)), + }; + let hidden_record = Linear { + weight: Param::from_data(TensorData::from(hidden_weights), device), + bias: Some(Param::from_data(TensorData::from(hidden_biases), device)), + }; + GateController::create_with_weights(input_record, hidden_record) + } + + let input = Tensor::<3>::from_data( + TensorData::from([[ + [0.949, -0.861], + [0.892, 0.927], + [-0.173, -0.301], + [-0.081, 0.992], + ]]), + &device, + ); + let h0 = Tensor::<3>::from_data( + TensorData::from([[[0.280, 0.360, -1.242]], [[-0.588, 0.729, -0.788]]]), + &device, + ); + + // Forward GRU gates (weights from PyTorch with seed 42, transposed for burn) + bigru.forward.update_gate = create_gate_controller( + [[-0.2811, 0.5090, 0.5018], [0.3391, -0.4236, 0.1081]], + [0.2932, -0.3519, -0.5715], + [ + [-0.3471, 0.5214, 0.0961], + [0.0545, -0.4904, -0.1875], + [-0.5702, 0.4457, 0.3568], + ], + [-0.0100, 0.4518, -0.4102], + &device, + ); + + bigru.forward.reset_gate = create_gate_controller( + [[0.4414, -0.1353, -0.1265], [0.4792, 0.5304, 0.1165]], + [-0.2524, 0.3333, 0.1033], + [ + [-0.2695, -0.0677, -0.4557], + [0.1472, -0.2345, -0.2662], + [-0.2660, 0.3830, -0.1630], + ], + [0.1663, 0.2391, 0.1826], + &device, + ); + + bigru.forward.new_gate = create_gate_controller( + [[0.4266, 0.2784, 0.4451], [0.0782, -0.0815, 0.0853]], + [-0.2231, -0.4428, 0.4737], + [ + [0.0900, -0.1821, 0.2430], + [0.4665, 0.1551, 0.5155], + [0.0631, -0.1566, 0.3337], + ], + [0.0364, -0.3941, 0.1780], + &device, + ); + + // Reverse GRU gates + bigru.reverse.update_gate = create_gate_controller( + [[-0.3444, 0.1924, -0.4765], [0.5193, 0.5556, -0.5727]], + [0.1090, 0.1779, -0.5385], + [ + [0.1221, 0.3925, 0.5287], + [-0.1472, -0.4187, -0.1948], + [0.3441, -0.3082, -0.2047], + ], + [0.0016, -0.2148, -0.0400], + &device, + ); + + bigru.reverse.reset_gate = create_gate_controller( + [[-0.1988, -0.1203, -0.3422], [0.1769, 0.4788, -0.3443]], + [-0.5053, -0.3676, 0.5771], + [ + [-0.3936, 0.3504, -0.4486], + [0.3063, -0.1370, -0.2914], + [-0.2334, 0.3303, 0.1760], + ], + [-0.5080, -0.2488, -0.3456], + &device, + ); + + bigru.reverse.new_gate = create_gate_controller( + [[-0.4517, 0.2339, 0.4797], [-0.3884, 0.2067, -0.2982]], + [-0.3792, -0.1922, 0.0903], + [ + [-0.5586, -0.0762, -0.3944], + [-0.3306, -0.4191, -0.4898], + [0.1442, 0.0135, -0.3179], + ], + [-0.3912, -0.3963, -0.3368], + &device, + ); + + // Expected values from PyTorch + let expected_output_with_init = TensorData::from([[ + [0.24537, 0.14018, 0.19449, -0.49777, -0.15647, 0.48392], + [0.27468, -0.14514, 0.56205, -0.60381, -0.04986, 0.15683], + [-0.04062, -0.33486, 0.52330, -0.42244, -0.12644, -0.12034], + [-0.11743, -0.53873, 0.54429, -0.64943, 0.30127, -0.41943], + ]]); + + let expected_hn_with_init = TensorData::from([ + [[-0.11743, -0.53873, 0.54429]], + [[-0.49777, -0.15647, 0.48392]], + ]); + + let expected_output_without_init = TensorData::from([[ + [0.07452, -0.08247, 0.46677, -0.46770, -0.18086, 0.47519], + [0.15843, -0.27144, 0.65781, -0.50286, -0.12806, 0.14884], + [-0.10704, -0.41573, 0.53954, -0.24794, -0.24003, -0.10294], + [-0.16505, -0.57952, 0.53565, -0.23598, -0.07137, -0.28937], + ]]); + + let expected_hn_without_init = TensorData::from([ + [[-0.16505, -0.57952, 0.53565]], + [[-0.46770, -0.18086, 0.47519]], + ]); + + let (output_with_init, hn_with_init) = bigru.forward(input.clone(), Some(h0)); + let (output_without_init, hn_without_init) = bigru.forward(input, None); + + let tolerance = Tolerance::permissive(); + output_with_init + .to_data() + .assert_approx_eq::(&expected_output_with_init, tolerance); + output_without_init + .to_data() + .assert_approx_eq::(&expected_output_without_init, tolerance); + hn_with_init + .to_data() + .assert_approx_eq::(&expected_hn_with_init, tolerance); + hn_without_init + .to_data() + .assert_approx_eq::(&expected_hn_without_init, tolerance); + } + + #[test] + fn bigru_display() { + let config = BiGruConfig::new(2, 8, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "BiGru {d_input: 2, d_hidden: 8, bias: true, params: 576}" + ); + } + + #[test] + fn test_gru_custom_activations() { + let device = Default::default(); + + // Create GRU with custom activations (ReLU instead of Sigmoid/Tanh) + let config = GruConfig::new(4, 8, true) + .with_gate_activation(ActivationConfig::Relu) + .with_hidden_activation(ActivationConfig::Relu); + let gru = config.init(&device); + + let input = Tensor::<3>::random([2, 3, 4], Distribution::Default, &device); + + // Should run without panicking and produce valid output + let output = gru.forward(input, None); + assert_eq!(&*output.shape(), [2, 3, 8]); + } + + #[test] + fn test_bigru_custom_activations() { + let device = Default::default(); + + // Create BiGRU with custom activations + let config = BiGruConfig::new(4, 8, true) + .with_gate_activation(ActivationConfig::Relu) + .with_hidden_activation(ActivationConfig::Relu); + let bigru = config.init(&device); + + let input = Tensor::<3>::random([2, 3, 4], Distribution::Default, &device); + + let (output, state) = bigru.forward(input, None); + assert_eq!(&*output.shape(), [2, 3, 16]); // hidden_size * 2 + assert_eq!(&*state.shape(), [2, 2, 8]); + } + + #[test] + fn test_gru_clipping() { + let device = Default::default(); + + // Create GRU with clipping enabled + let clip_value = 0.5; + let config = GruConfig::new(4, 8, true).with_clip(Some(clip_value)); + let gru = config.init(&device); + + let input = Tensor::<3>::random([2, 5, 4], Distribution::Default, &device); + + let output = gru.forward(input, None); + + // Verify output values are within the clip range + let output_data: Vec = output.to_data().to_vec().unwrap(); + for val in output_data { + assert!( + val >= -clip_value as f32 && val <= clip_value as f32, + "Value {} is outside clip range [-{}, {}]", + val, + clip_value, + clip_value + ); + } + } + + #[test] + fn test_bigru_clipping() { + let device = Default::default(); + + // Create BiGRU with clipping enabled + let clip_value = 0.3; + let config = BiGruConfig::new(4, 8, true).with_clip(Some(clip_value)); + let bigru = config.init(&device); + + let input = Tensor::<3>::random([2, 5, 4], Distribution::Default, &device); + + let (output, state) = bigru.forward(input, None); + + // Verify output values are within the clip range + let output_data: Vec = output.to_data().to_vec().unwrap(); + for val in output_data { + assert!( + val >= -clip_value as f32 && val <= clip_value as f32, + "Output value {} is outside clip range [-{}, {}]", + val, + clip_value, + clip_value + ); + } + + // Verify state values are within the clip range + let state_data: Vec = state.to_data().to_vec().unwrap(); + for val in state_data { + assert!( + val >= -clip_value as f32 && val <= clip_value as f32, + "State value {} is outside clip range [-{}, {}]", + val, + clip_value, + clip_value + ); + } + } + + /// Test Gru against PyTorch reference implementation. + /// Expected values computed with PyTorch nn.GRU (seed=42 for weights, seed=123 for input). + #[test] + fn test_gru_against_pytorch() { + let device = Device::default(); + device.seed(0); + + let config = GruConfig::new(2, 3, true); + let mut gru = config.init(&device); + + fn create_gate_controller( + input_weights: [[f32; D1]; D2], + input_biases: [f32; D1], + hidden_weights: [[f32; D1]; D1], + hidden_biases: [f32; D1], + device: &Device, + ) -> GateController { + let input_record = Linear { + weight: Param::from_data(TensorData::from(input_weights), device), + bias: Some(Param::from_data(TensorData::from(input_biases), device)), + }; + let hidden_record = Linear { + weight: Param::from_data(TensorData::from(hidden_weights), device), + bias: Some(Param::from_data(TensorData::from(hidden_biases), device)), + }; + GateController::create_with_weights(input_record, hidden_record) + } + + // Input: [batch=1, seq=4, input=2] + let input = Tensor::<3>::from_data( + TensorData::from([[ + [-0.11147, 0.12036], + [-0.36963, -0.24042], + [-1.19692, 0.20927], + [-0.97236, -0.75505], + ]]), + &device, + ); + + // Initial hidden state: [batch=1, hidden=3] + let h0 = Tensor::<2>::from_data(TensorData::from([[0.3239, -0.10852, 0.21033]]), &device); + + // Update gate (z) - weights from PyTorch, transposed for Burn's Row layout + gru.update_gate = create_gate_controller( + [[-0.2811, 0.5090, 0.5018], [0.3391, -0.4236, 0.1081]], + [0.2932, -0.3519, -0.5715], + [ + [-0.3471, 0.5214, 0.0961], + [0.0545, -0.4904, -0.1875], + [-0.5702, 0.4457, 0.3568], + ], + [-0.0100, 0.4518, -0.4102], + &device, + ); + + // Reset gate (r) + gru.reset_gate = create_gate_controller( + [[0.4414, -0.1353, -0.1265], [0.4792, 0.5304, 0.1165]], + [-0.2524, 0.3333, 0.1033], + [ + [-0.2695, -0.0677, -0.4557], + [0.1472, -0.2345, -0.2662], + [-0.2660, 0.3830, -0.1630], + ], + [0.1663, 0.2391, 0.1826], + &device, + ); + + // New gate (n) + gru.new_gate = create_gate_controller( + [[0.4266, 0.2784, 0.4451], [0.0782, -0.0815, 0.0853]], + [-0.2231, -0.4428, 0.4737], + [ + [0.0900, -0.1821, 0.2430], + [0.4665, 0.1551, 0.5155], + [0.0631, -0.1566, 0.3337], + ], + [0.0364, -0.3941, 0.1780], + &device, + ); + + // Expected values from PyTorch + let expected_output_with_h0 = TensorData::from([[ + [0.05665, -0.34932, 0.43267], + [-0.1737, -0.49246, 0.38099], + [-0.35401, -0.68099, 0.05061], + [-0.47854, -0.70427, -0.13648], + ]]); + + let expected_output_no_h0 = TensorData::from([[ + [-0.0985, -0.31661, 0.36126], + [-0.24563, -0.47784, 0.34609], + [-0.39497, -0.67659, 0.03083], + [-0.50146, -0.70066, -0.14894], + ]]); + + let output_with_h0 = gru.forward(input.clone(), Some(h0)); + let output_no_h0 = gru.forward(input, None); + + let tolerance = Tolerance::permissive(); + output_with_h0 + .to_data() + .assert_approx_eq::(&expected_output_with_h0, tolerance); + output_no_h0 + .to_data() + .assert_approx_eq::(&expected_output_no_h0, tolerance); + } +} diff --git a/crates/burn-nn/src/modules/rnn/lstm.rs b/crates/burn-nn/src/modules/rnn/lstm.rs new file mode 100644 index 0000000..16302db --- /dev/null +++ b/crates/burn-nn/src/modules/rnn/lstm.rs @@ -0,0 +1,856 @@ +use burn_core as burn; + +use crate::GateController; +use crate::activation::{Activation, ActivationConfig}; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// A LstmState is used to store cell state and hidden state in LSTM. +pub struct LstmState { + /// The cell state. + pub cell: Tensor, + /// The hidden state. + pub hidden: Tensor, +} + +impl LstmState { + /// Initialize a new [LSTM State](LstmState). + pub fn new(cell: Tensor, hidden: Tensor) -> Self { + Self { cell, hidden } + } +} + +/// Configuration to create a [Lstm](Lstm) module using the [init function](LstmConfig::init). +#[derive(Config, Debug)] +pub struct LstmConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the Lstm transformation. + pub bias: bool, + /// Lstm initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// If true, the input tensor is expected to be `[batch_size, seq_length, input_size]`. + /// If false, the input tensor is expected to be `[seq_length, batch_size, input_size]`. + #[config(default = true)] + pub batch_first: bool, + /// If true, process the sequence in reverse order. + /// This is useful for implementing reverse-direction LSTMs (e.g., ONNX reverse direction). + #[config(default = false)] + pub reverse: bool, + /// Optional cell state clip threshold. If provided, cell state values are clipped + /// to the range `[-clip, +clip]` after each timestep. This can help prevent + /// exploding values during inference. + pub clip: Option, + /// If true, couples the input and forget gates: `f_t = 1 - i_t`. + /// This reduces the number of parameters and is based on GRU-style simplification. + #[config(default = false)] + pub input_forget: bool, + /// Activation function for the input, forget, and output gates. + /// Default is Sigmoid, which is standard for LSTM gates. + #[config(default = "ActivationConfig::Sigmoid")] + pub gate_activation: ActivationConfig, + /// Activation function for the cell gate (candidate cell state). + /// Default is Tanh, which is standard for LSTM. + #[config(default = "ActivationConfig::Tanh")] + pub cell_activation: ActivationConfig, + /// Activation function applied to the cell state before computing hidden output. + /// Default is Tanh, which is standard for LSTM. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, +} + +/// The Lstm module. This implementation is for a unidirectional, stateless, Lstm. +/// +/// Introduced in the paper: [Long Short-Term Memory](https://www.researchgate.net/publication/13853244). +/// +/// Should be created with [LstmConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Lstm { + /// The input gate regulates which information to update and store in the cell state at each time step. + pub input_gate: GateController, + /// The forget gate is used to control which information to discard or keep in the memory cell at each time step. + /// Note: When `input_forget` is true, this gate is not used (forget = 1 - input). + pub forget_gate: GateController, + /// The output gate determines which information from the cell state to output at each time step. + pub output_gate: GateController, + /// The cell gate is used to compute the cell state that stores and carries information through time. + pub cell_gate: GateController, + /// The hidden state of the LSTM. + pub d_hidden: usize, + /// If true, input is `[batch_size, seq_length, input_size]`. + /// If false, input is `[seq_length, batch_size, input_size]`. + pub batch_first: bool, + /// If true, process the sequence in reverse order. + pub reverse: bool, + /// Optional cell state clip threshold. + pub clip: Option, + /// If true, couples input and forget gates: f_t = 1 - i_t. + pub input_forget: bool, + /// Activation function for gates (input, forget, output). + pub gate_activation: Activation, + /// Activation function for cell gate (candidate cell state). + pub cell_activation: Activation, + /// Activation function for hidden output. + pub hidden_activation: Activation, +} + +impl ModuleDisplay for Lstm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self.input_gate.input_transform.weight.shape().dims(); + let bias = self.input_gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .optional() + } +} + +impl LstmConfig { + /// Initialize a new [lstm](Lstm) module. + pub fn init(&self, device: &Device) -> Lstm { + let d_output = self.d_hidden; + + let new_gate = || { + GateController::new( + self.d_input, + d_output, + self.bias, + self.initializer.clone(), + device, + ) + }; + + Lstm { + input_gate: new_gate(), + forget_gate: new_gate(), + output_gate: new_gate(), + cell_gate: new_gate(), + d_hidden: self.d_hidden, + batch_first: self.batch_first, + reverse: self.reverse, + clip: self.clip, + input_forget: self.input_forget, + gate_activation: self.gate_activation.init(device), + cell_activation: self.cell_activation.init(device), + hidden_activation: self.hidden_activation.init(device), + } + } +} + +impl Lstm { + /// Applies the forward pass on the input tensor. This LSTM implementation + /// returns the state for each element in a sequence (i.e., across seq_length) and a final state. + /// + /// ## Parameters: + /// - batched_input: The input tensor of shape: + /// - `[batch_size, sequence_length, input_size]` if `batch_first` is true (default) + /// - `[sequence_length, batch_size, input_size]` if `batch_first` is false + /// - state: An optional `LstmState` representing the initial cell state and hidden state. + /// Each state tensor has shape `[batch_size, hidden_size]`. + /// If no initial state is provided, these tensors are initialized to zeros. + /// + /// ## Returns: + /// - output: A tensor represents the output features of LSTM. Shape: + /// - `[batch_size, sequence_length, hidden_size]` if `batch_first` is true + /// - `[sequence_length, batch_size, hidden_size]` if `batch_first` is false + /// - state: A `LstmState` represents the final states. Both `state.cell` and `state.hidden` have the shape + /// `[batch_size, hidden_size]`. + pub fn forward( + &self, + batched_input: Tensor<3>, + state: Option>, + ) -> (Tensor<3>, LstmState<2>) { + // Convert to batch-first layout internally if needed + let batched_input = if self.batch_first { + batched_input + } else { + batched_input.swap_dims(0, 1) + }; + + let device = batched_input.device(); + let [batch_size, seq_length, _] = batched_input.dims(); + + // Process sequence in forward or reverse order based on config + let (output, state) = if self.reverse { + self.forward_iter( + batched_input.iter_dim(1).rev().zip((0..seq_length).rev()), + state, + batch_size, + seq_length, + &device, + ) + } else { + self.forward_iter( + batched_input.iter_dim(1).zip(0..seq_length), + state, + batch_size, + seq_length, + &device, + ) + }; + + // Convert output back to seq-first layout if needed + let output = if self.batch_first { + output + } else { + output.swap_dims(0, 1) + }; + + (output, state) + } + + fn forward_iter, usize)>>( + &self, + input_timestep_iter: I, + state: Option>, + batch_size: usize, + seq_length: usize, + device: &Device, + ) -> (Tensor<3>, LstmState<2>) { + let mut batched_hidden_state = + Tensor::empty([batch_size, seq_length, self.d_hidden], device); + + let (mut cell_state, mut hidden_state) = match state { + Some(state) => (state.cell, state.hidden), + None => ( + Tensor::zeros([batch_size, self.d_hidden], device), + Tensor::zeros([batch_size, self.d_hidden], device), + ), + }; + + for (input_t, t) in input_timestep_iter { + let input_t = input_t.squeeze_dim(1); + + // i(nput)g(ate) tensors + let biased_ig_input_sum = self + .input_gate + .gate_product(input_t.clone(), hidden_state.clone()); + let input_values = self.gate_activation.forward(biased_ig_input_sum); + + // f(orget)g(ate) tensors - either computed or coupled to input gate + let forget_values = if self.input_forget { + // Coupled mode: f_t = 1 - i_t + input_values.clone().neg().add_scalar(1.0) + } else { + let biased_fg_input_sum = self + .forget_gate + .gate_product(input_t.clone(), hidden_state.clone()); + self.gate_activation.forward(biased_fg_input_sum) + }; + + // o(output)g(ate) tensors + let biased_og_input_sum = self + .output_gate + .gate_product(input_t.clone(), hidden_state.clone()); + let output_values = self.gate_activation.forward(biased_og_input_sum); + + // c(ell)g(ate) tensors + let biased_cg_input_sum = self + .cell_gate + .gate_product(input_t.clone(), hidden_state.clone()); + let candidate_cell_values = self.cell_activation.forward(biased_cg_input_sum); + + cell_state = forget_values * cell_state.clone() + input_values * candidate_cell_values; + + // Apply cell state clipping if configured + if let Some(clip) = self.clip { + cell_state = cell_state.clamp(-clip, clip); + } + + hidden_state = output_values * self.hidden_activation.forward(cell_state.clone()); + + let unsqueezed_hidden_state = hidden_state.clone().unsqueeze_dim(1); + + // store the hidden state for this timestep + batched_hidden_state = batched_hidden_state.slice_assign( + [0..batch_size, t..(t + 1), 0..self.d_hidden], + unsqueezed_hidden_state.clone(), + ); + } + + ( + batched_hidden_state, + LstmState::new(cell_state, hidden_state), + ) + } +} + +/// Configuration to create a [BiLstm](BiLstm) module using the [init function](BiLstmConfig::init). +#[derive(Config, Debug)] +pub struct BiLstmConfig { + /// The size of the input features. + pub d_input: usize, + /// The size of the hidden state. + pub d_hidden: usize, + /// If a bias should be applied during the BiLstm transformation. + pub bias: bool, + /// BiLstm initializer + #[config(default = "Initializer::XavierNormal{gain:1.0}")] + pub initializer: Initializer, + /// If true, the input tensor is expected to be `[batch_size, seq_length, input_size]`. + /// If false, the input tensor is expected to be `[seq_length, batch_size, input_size]`. + #[config(default = true)] + pub batch_first: bool, + /// Optional cell state clip threshold. + pub clip: Option, + /// If true, couples the input and forget gates. + #[config(default = false)] + pub input_forget: bool, + /// Activation function for the input, forget, and output gates. + #[config(default = "ActivationConfig::Sigmoid")] + pub gate_activation: ActivationConfig, + /// Activation function for the cell gate (candidate cell state). + #[config(default = "ActivationConfig::Tanh")] + pub cell_activation: ActivationConfig, + /// Activation function applied to the cell state before computing hidden output. + #[config(default = "ActivationConfig::Tanh")] + pub hidden_activation: ActivationConfig, +} + +/// The BiLstm module. This implementation is for Bidirectional LSTM. +/// +/// Introduced in the paper: [Framewise phoneme classification with bidirectional LSTM and other neural network architectures](https://www.cs.toronto.edu/~graves/ijcnn_2005.pdf). +/// +/// Should be created with [BiLstmConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct BiLstm { + /// LSTM for the forward direction. + pub forward: Lstm, + /// LSTM for the reverse direction. + pub reverse: Lstm, + /// The size of the hidden state. + pub d_hidden: usize, + /// If true, input is `[batch_size, seq_length, input_size]`. + /// If false, input is `[seq_length, batch_size, input_size]`. + pub batch_first: bool, +} + +impl ModuleDisplay for BiLstm { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_input, _] = self + .forward + .input_gate + .input_transform + .weight + .shape() + .dims(); + let bias = self.forward.input_gate.input_transform.bias.is_some(); + + content + .add("d_input", &d_input) + .add("d_hidden", &self.d_hidden) + .add("bias", &bias) + .optional() + } +} + +impl BiLstmConfig { + /// Initialize a new [Bidirectional LSTM](BiLstm) module. + pub fn init(&self, device: &Device) -> BiLstm { + // Internal LSTMs always use batch_first=true; BiLstm handles layout conversion + let base_config = LstmConfig::new(self.d_input, self.d_hidden, self.bias) + .with_initializer(self.initializer.clone()) + .with_batch_first(true) + .with_clip(self.clip) + .with_input_forget(self.input_forget) + .with_gate_activation(self.gate_activation.clone()) + .with_cell_activation(self.cell_activation.clone()) + .with_hidden_activation(self.hidden_activation.clone()); + + BiLstm { + forward: base_config.clone().init(device), + reverse: base_config.init(device), + d_hidden: self.d_hidden, + batch_first: self.batch_first, + } + } +} + +impl BiLstm { + /// Applies the forward pass on the input tensor. This Bidirectional LSTM implementation + /// returns the state for each element in a sequence (i.e., across seq_length) and a final state. + /// + /// ## Parameters: + /// - batched_input: The input tensor of shape: + /// - `[batch_size, sequence_length, input_size]` if `batch_first` is true (default) + /// - `[sequence_length, batch_size, input_size]` if `batch_first` is false + /// - state: An optional `LstmState` representing the initial cell state and hidden state. + /// Each state tensor has shape `[2, batch_size, hidden_size]`. + /// If no initial state is provided, these tensors are initialized to zeros. + /// + /// ## Returns: + /// - output: A tensor represents the output features of LSTM. Shape: + /// - `[batch_size, sequence_length, hidden_size * 2]` if `batch_first` is true + /// - `[sequence_length, batch_size, hidden_size * 2]` if `batch_first` is false + /// - state: A `LstmState` represents the final forward and reverse states. Both `state.cell` and + /// `state.hidden` have the shape `[2, batch_size, hidden_size]`. + pub fn forward( + &self, + batched_input: Tensor<3>, + state: Option>, + ) -> (Tensor<3>, LstmState<3>) { + // Convert to batch-first layout internally if needed + let batched_input = if self.batch_first { + batched_input + } else { + batched_input.swap_dims(0, 1) + }; + + let device = batched_input.clone().device(); + let [batch_size, seq_length, _] = batched_input.shape().dims(); + + let [init_state_forward, init_state_reverse] = match state { + Some(state) => { + let cell_state_forward = state + .cell + .clone() + .slice([0..1, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + let hidden_state_forward = state + .hidden + .clone() + .slice([0..1, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + let cell_state_reverse = state + .cell + .slice([1..2, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + let hidden_state_reverse = state + .hidden + .slice([1..2, 0..batch_size, 0..self.d_hidden]) + .squeeze_dim(0); + + [ + Some(LstmState::new(cell_state_forward, hidden_state_forward)), + Some(LstmState::new(cell_state_reverse, hidden_state_reverse)), + ] + } + None => [None, None], + }; + + // forward direction + let (batched_hidden_state_forward, final_state_forward) = self + .forward + .forward(batched_input.clone(), init_state_forward); + + // reverse direction + let (batched_hidden_state_reverse, final_state_reverse) = self.reverse.forward_iter( + batched_input.iter_dim(1).rev().zip((0..seq_length).rev()), + init_state_reverse, + batch_size, + seq_length, + &device, + ); + + let output = Tensor::cat( + [batched_hidden_state_forward, batched_hidden_state_reverse].to_vec(), + 2, + ); + + // Convert output back to seq-first layout if needed + let output = if self.batch_first { + output + } else { + output.swap_dims(0, 1) + }; + + let state = LstmState::new( + Tensor::stack( + [final_state_forward.cell, final_state_reverse.cell].to_vec(), + 0, + ), + Tensor::stack( + [final_state_forward.hidden, final_state_reverse.hidden].to_vec(), + 0, + ), + ); + + (output, state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Linear; + use burn::module::Param; + use burn::tensor::{Distribution, TensorData}; + use burn::tensor::{ElementConversion, Tolerance}; + type FT = f32; + + #[test] + fn test_with_uniform_initializer() { + let device = Device::default(); + device.seed(0); + + let config = LstmConfig::new(5, 5, false) + .with_initializer(Initializer::Uniform { min: 0.0, max: 1.0 }); + let lstm = config.init(&Default::default()); + + let gate_to_data = |gate: GateController| gate.input_transform.weight.val().to_data(); + + gate_to_data(lstm.input_gate).assert_within_range::(0.elem()..1.elem()); + gate_to_data(lstm.forget_gate).assert_within_range::(0.elem()..1.elem()); + gate_to_data(lstm.output_gate).assert_within_range::(0.elem()..1.elem()); + gate_to_data(lstm.cell_gate).assert_within_range::(0.elem()..1.elem()); + } + + /// Test forward pass with simple input vector. + /// + /// f_t = sigmoid(0.7*0.1 + 0.7*0) = sigmoid(0.07) = 0.5173928 + /// i_t = sigmoid(0.5*0.1 + 0.5*0) = sigmoid(0.05) = 0.5123725 + /// o_t = sigmoid(1.1*0.1 + 1.1*0) = sigmoid(0.11) = 0.5274723 + /// c_t = tanh(0.9*0.1 + 0.9*0) = tanh(0.09) = 0.0892937 + /// C_t = f_t * 0 + i_t * c_t = 0 + 0.5123725 * 0.0892937 = 0.04575243 + /// h_t = o_t * tanh(C_t) = 0.5274723 * tanh(0.04575243) = 0.5274723 * 0.04568173 = 0.024083648 + #[test] + fn test_forward_single_input_single_feature() { + let device = Device::default(); + device.seed(0); + + let config = LstmConfig::new(1, 1, false); + let mut lstm = config.init(&device); + + fn create_gate_controller(weights: f32, biases: f32, device: &Device) -> GateController { + let record_1 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + let record_2 = Linear { + weight: Param::from_data(TensorData::from([[weights]]), device), + bias: Some(Param::from_data(TensorData::from([biases]), device)), + }; + GateController::create_with_weights(record_1, record_2) + } + + lstm.input_gate = create_gate_controller(0.5, 0.0, &device); + lstm.forget_gate = create_gate_controller(0.7, 0.0, &device); + lstm.cell_gate = create_gate_controller(0.9, 0.0, &device); + lstm.output_gate = create_gate_controller(1.1, 0.0, &device); + + // single timestep with single feature + let input = Tensor::<3>::from_data(TensorData::from([[[0.1]]]), &device); + + let (output, state) = lstm.forward(input, None); + + let expected = TensorData::from([[0.046]]); + let tolerance = Tolerance::default(); + state + .cell + .to_data() + .assert_approx_eq::(&expected, tolerance); + + let expected = TensorData::from([[0.0242]]); + state + .hidden + .to_data() + .assert_approx_eq::(&expected, tolerance); + + output + .select(0, Tensor::arange(0..1, &device)) + .squeeze_dim::<2>(0) + .to_data() + .assert_approx_eq::(&state.hidden.to_data(), tolerance); + } + + #[test] + fn test_batched_forward_pass() { + let device = Default::default(); + let lstm = LstmConfig::new(64, 1024, true).init(&device); + let batched_input = Tensor::<3>::random([8, 10, 64], Distribution::Default, &device); + + let (output, state) = lstm.forward(batched_input, None); + + assert_eq!(output.dims(), [8, 10, 1024]); + assert_eq!(state.cell.dims(), [8, 1024]); + assert_eq!(state.hidden.dims(), [8, 1024]); + } + + #[test] + fn test_batched_forward_pass_batch_of_one() { + let device = Default::default(); + let lstm = LstmConfig::new(64, 1024, true).init(&device); + let batched_input = Tensor::<3>::random([1, 2, 64], Distribution::Default, &device); + + let (output, state) = lstm.forward(batched_input, None); + + assert_eq!(output.dims(), [1, 2, 1024]); + assert_eq!(state.cell.dims(), [1, 1024]); + assert_eq!(state.hidden.dims(), [1, 1024]); + } + + #[test] + #[cfg(feature = "std")] + fn test_batched_backward_pass() { + use burn::tensor::Shape; + let device = Device::default().autodiff(); + let lstm = LstmConfig::new(64, 32, true).init(&device); + let shape: Shape = [8, 10, 64].into(); + let batched_input = Tensor::<3>::random(shape, Distribution::Default, &device); + + let (output, _) = lstm.forward(batched_input.clone(), None); + let fake_loss = output; + let grads = fake_loss.backward(); + + let some_gradient = lstm + .output_gate + .hidden_transform + .weight + .grad(&grads) + .unwrap(); + + // Asserts that the gradients exist and are non-zero + assert_ne!( + some_gradient + .any() + .into_data() + .iter::() + .next() + .unwrap(), + 0.0 + ); + } + + #[test] + fn test_bidirectional() { + let device = Device::default(); + device.seed(0); + + let config = BiLstmConfig::new(2, 3, true); + let mut lstm = config.init(&device); + + fn create_gate_controller( + input_weights: [[f32; D1]; D2], + input_biases: [f32; D1], + hidden_weights: [[f32; D1]; D1], + hidden_biases: [f32; D1], + device: &Device, + ) -> GateController { + let input_record = Linear { + weight: Param::from_data(TensorData::from(input_weights), device), + bias: Some(Param::from_data(TensorData::from(input_biases), device)), + }; + let hidden_record = Linear { + weight: Param::from_data(TensorData::from(hidden_weights), device), + bias: Some(Param::from_data(TensorData::from(hidden_biases), device)), + }; + GateController::create_with_weights(input_record, hidden_record) + } + + let input = Tensor::<3>::from_data( + TensorData::from([[ + [0.949, -0.861], + [0.892, 0.927], + [-0.173, -0.301], + [-0.081, 0.992], + ]]), + &device, + ); + let h0 = Tensor::<3>::from_data( + TensorData::from([[[0.280, 0.360, -1.242]], [[-0.588, 0.729, -0.788]]]), + &device, + ); + let c0 = Tensor::<3>::from_data( + TensorData::from([[[0.723, 0.397, -0.262]], [[0.471, 0.613, 1.885]]]), + &device, + ); + + lstm.forward.input_gate = create_gate_controller( + [[0.367, 0.091, 0.342], [0.322, 0.533, 0.059]], + [-0.196, 0.354, 0.209], + [ + [-0.320, 0.232, -0.165], + [0.093, -0.572, -0.315], + [-0.467, 0.325, 0.046], + ], + [0.181, -0.190, -0.245], + &device, + ); + + lstm.forward.forget_gate = create_gate_controller( + [[-0.342, -0.084, -0.420], [-0.432, 0.119, 0.191]], + [0.315, -0.413, -0.041], + [ + [0.453, 0.063, 0.561], + [0.211, 0.149, 0.213], + [-0.499, -0.158, 0.068], + ], + [-0.431, -0.535, 0.125], + &device, + ); + + lstm.forward.cell_gate = create_gate_controller( + [[-0.046, -0.382, 0.321], [-0.533, 0.558, 0.004]], + [-0.358, 0.282, -0.078], + [ + [-0.358, 0.109, 0.139], + [-0.345, 0.091, -0.368], + [-0.508, 0.221, -0.507], + ], + [0.502, -0.509, -0.247], + &device, + ); + + lstm.forward.output_gate = create_gate_controller( + [[-0.577, -0.359, 0.216], [-0.550, 0.268, 0.243]], + [-0.227, -0.274, 0.039], + [ + [-0.383, 0.449, 0.222], + [-0.357, -0.093, 0.449], + [-0.106, 0.236, 0.360], + ], + [-0.361, -0.209, -0.454], + &device, + ); + + lstm.reverse.input_gate = create_gate_controller( + [[-0.055, 0.506, 0.247], [-0.369, 0.178, -0.258]], + [0.540, -0.164, 0.033], + [ + [0.159, 0.180, -0.037], + [-0.443, 0.485, -0.488], + [0.098, -0.085, -0.140], + ], + [-0.510, 0.105, 0.114], + &device, + ); + + lstm.reverse.forget_gate = create_gate_controller( + [[-0.154, -0.432, -0.547], [-0.369, -0.310, -0.175]], + [0.141, 0.004, 0.055], + [ + [-0.005, -0.277, -0.515], + [-0.011, -0.101, -0.365], + [0.426, 0.379, 0.337], + ], + [-0.382, 0.331, -0.176], + &device, + ); + + lstm.reverse.cell_gate = create_gate_controller( + [[-0.571, 0.228, -0.287], [-0.331, 0.110, 0.219]], + [-0.206, -0.546, 0.462], + [ + [0.449, -0.240, 0.071], + [-0.045, 0.131, 0.124], + [0.138, -0.201, 0.191], + ], + [-0.030, 0.211, -0.352], + &device, + ); + + lstm.reverse.output_gate = create_gate_controller( + [[0.491, -0.442, 0.333], [0.313, -0.121, -0.070]], + [-0.387, -0.250, 0.066], + [ + [-0.030, 0.268, 0.299], + [-0.019, -0.280, -0.314], + [0.466, -0.365, -0.248], + ], + [-0.398, -0.199, -0.566], + &device, + ); + + let expected_output_with_init_state = TensorData::from([[ + [0.23764, -0.03442, 0.04414, -0.15635, -0.03366, -0.05798], + [0.00473, -0.02254, 0.02988, -0.16510, -0.00306, 0.08742], + [0.06210, -0.06509, -0.05339, -0.01710, 0.02091, 0.16012], + [-0.03420, 0.07774, -0.09774, -0.02604, 0.12584, 0.20872], + ]]); + let expected_output_without_init_state = TensorData::from([[ + [0.08679, -0.08776, -0.00528, -0.15969, -0.05322, -0.08863], + [-0.02577, -0.05057, 0.00033, -0.17558, -0.03679, 0.03142], + [0.02942, -0.07411, -0.06044, -0.03601, -0.09998, 0.04846], + [-0.04026, 0.07178, -0.10189, -0.07349, -0.04576, 0.05550], + ]]); + let expected_hn_with_init_state = TensorData::from([ + [[-0.03420, 0.07774, -0.09774]], + [[-0.15635, -0.03366, -0.05798]], + ]); + let expected_cn_with_init_state = TensorData::from([ + [[-0.13593, 0.17125, -0.22395]], + [[-0.45425, -0.11206, -0.12908]], + ]); + let expected_hn_without_init_state = TensorData::from([ + [[-0.04026, 0.07178, -0.10189]], + [[-0.15969, -0.05322, -0.08863]], + ]); + let expected_cn_without_init_state = TensorData::from([ + [[-0.15839, 0.15923, -0.23569]], + [[-0.47407, -0.17493, -0.19643]], + ]); + + let (output_with_init_state, state_with_init_state) = + lstm.forward(input.clone(), Some(LstmState::new(c0, h0))); + let (output_without_init_state, state_without_init_state) = lstm.forward(input, None); + + let tolerance = Tolerance::permissive(); + output_with_init_state + .to_data() + .assert_approx_eq::(&expected_output_with_init_state, tolerance); + output_without_init_state + .to_data() + .assert_approx_eq::(&expected_output_without_init_state, tolerance); + state_with_init_state + .hidden + .to_data() + .assert_approx_eq::(&expected_hn_with_init_state, tolerance); + state_with_init_state + .cell + .to_data() + .assert_approx_eq::(&expected_cn_with_init_state, tolerance); + state_without_init_state + .hidden + .to_data() + .assert_approx_eq::(&expected_hn_without_init_state, tolerance); + state_without_init_state + .cell + .to_data() + .assert_approx_eq::(&expected_cn_without_init_state, tolerance); + } + + #[test] + fn display_lstm() { + let config = LstmConfig::new(2, 3, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "Lstm {d_input: 2, d_hidden: 3, bias: true, params: 84}" + ); + } + + #[test] + fn display_bilstm() { + let config = BiLstmConfig::new(2, 3, true); + + let layer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{layer}"), + "BiLstm {d_input: 2, d_hidden: 3, bias: true, params: 168}" + ); + } +} diff --git a/crates/burn-nn/src/modules/rnn/mod.rs b/crates/burn-nn/src/modules/rnn/mod.rs new file mode 100644 index 0000000..b651874 --- /dev/null +++ b/crates/burn-nn/src/modules/rnn/mod.rs @@ -0,0 +1,15 @@ +mod gate_controller; + +/// Basic RNN. +pub mod basic; + +/// Gated Recurrent Unit module. +pub mod gru; + +/// Long Short-Term Memory module. +pub mod lstm; + +pub use basic::*; +pub use gate_controller::*; +pub use gru::*; +pub use lstm::*; diff --git a/crates/burn-nn/src/modules/rope_encoding.rs b/crates/burn-nn/src/modules/rope_encoding.rs new file mode 100644 index 0000000..48784a8 --- /dev/null +++ b/crates/burn-nn/src/modules/rope_encoding.rs @@ -0,0 +1,627 @@ +use burn_core as burn; + +use alloc::vec; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Int; +use burn::tensor::{Device, Tensor}; +use core::ops::Range; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Configuration to create a [RotaryEncoding](RotaryEncoding) layer using the [init function](RotaryEncodingConfig::init). +#[derive(Config, Debug)] +pub struct RotaryEncodingConfig { + /// Maximum sequence length of input + pub max_sequence_length: usize, + + /// Size of the input embedding or hidden dimension + pub d_model: usize, + + /// Scaling factor for frequency computation. Defaults to 10000.0 + #[config(default = "10000.0")] + pub theta: f32, +} + +impl RotaryEncodingConfig { + /// Initialize a new [RotaryEncoding](RotaryEncoding) module. + /// + /// # Panics + /// + /// Panics if the size of input embedding dimension is not even. + /// Panics if the theta parameter is not positive. + pub fn init(&self, device: &Device) -> RotaryEncoding { + self.initialize(|x| x, device) + } + + /// Initialize a new [RotaryEncoding](RotaryEncoding) module with a custom frequency scaling function. + /// This is useful to apply different RoPE extensions. + /// + /// # Panics + /// + /// Panics if the size of input embedding dimension is not even. + /// Panics if the theta parameter is not positive. + pub fn init_with_frequency_scaling( + &self, + scaling: impl Fn(Tensor<1>) -> Tensor<1>, + device: &Device, + ) -> RotaryEncoding { + self.initialize(scaling, device) + } + + /// Initialize a new [RotaryEncoding](RotaryEncoding) module. + /// + /// # Panics + /// + /// Panics if the size of input embedding dimension is not even. + /// Panics if the theta parameter is not positive. + fn initialize( + &self, + scaling: impl Fn(Tensor<1>) -> Tensor<1>, + device: &Device, + ) -> RotaryEncoding { + assert_eq!( + self.d_model % 2, + 0, + "The input embedding dimension must be even" + ); + assert!( + self.theta > 0.0, + "Theta parameter must be positive (default: 10000)." + ); + + // Calculate the rotation frequencies for positional embeddings based on the formula + // `theta = 1 / (theta ^ (2i / d_model)) for i in [0..d_model/2]` + let exponent = Tensor::<1, Int>::arange_step(0..self.d_model as i64, 2, device) + .float() + .div_scalar(self.d_model as f32); + + // Calculate (10000 ^ (2i / d_model)) by using the log base property `exp(log(10000) * (2i / d_model))` + // This is done since burn doesn't support exponentiation of scalar to tensor + let theta = exponent.mul_scalar(self.theta.ln()).exp().recip(); + + let theta = scaling(theta); + + let freq_complex = + RotaryEncoding::compute_rotary_frequencies(0..self.max_sequence_length, theta.clone()); + + RotaryEncoding { + freq_complex, + theta, + start_offset: 0, + } + } +} + +/// A module that applies rotary positional encoding to a tensor. +/// Rotary Position Encoding or Embedding (RoPE), is a type of position embedding which encodes +/// absolute positional information with rotation matrix and naturally incorporates +/// explicit relative position dependency in self-attention formulation. +/// +/// Introduced in the paper: [RoFormer: Enhanced Transformer with Rotary Position Embedding](https://arxiv.org/abs/2104.09864) +/// +/// Should be created using [RotaryEncodingConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct RotaryEncoding { + /// Complex frequency tensor of shape (max_sequence_length, d_model, 2) with real and imaginary components + // Essentially a cache of pre-computed RoPE values. + pub freq_complex: Tensor<3>, + /// Frequency vector used to compute/apply the complex rotations. + pub theta: Tensor<1>, + start_offset: usize, +} + +impl ModuleDisplay for RotaryEncoding { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [max_sequence_length, d_model, _] = self.freq_complex.shape().dims(); + content + .add("d_model", &d_model) + .add("max_sequence_length", &max_sequence_length) + .optional() + } +} + +#[allow(clippy::single_range_in_vec_init)] +impl RotaryEncoding { + /// Applies rotary positional encoding to a tensor of dimensions (..., seq_len, d_model) + /// + /// # Arguments: + /// * `x` - Input tensor of shape (..., seq_len, d_model). Accommodate both 3D and 4D tensors + /// for (batch size, seq_len, hidden_dim) or (batch size, num_heads, seq_len, hidden_dim) + /// respectively. + /// + /// # Returns: + /// Output tensor with the same shape as input tensor after applying rotary encoding. + /// + /// # Panics + /// If the input tensor does not have at least 2 dimensions for sequence length and hidden dimension. + pub fn forward(&self, x: Tensor) -> Tensor { + self.apply(x, 0) + } + + /// Applies rotary positional encoding to a tensor of dimensions (..., seq_len, d_model) + /// + /// # Arguments: + /// * `x` - Input tensor of shape (..., seq_len, d_model). Accommodate both 3D and 4D tensors + /// for (batch size, seq_len, hidden_dim) or (batch size, num_heads, seq_len, hidden_dim) + /// respectively. + /// * `start` - Sequence start position index. + /// + /// # Returns: + /// Output tensor with the same shape as input tensor after applying rotary encoding. + /// + /// # Panics + /// If the input tensor does not have at least 2 dimensions for sequence length and hidden dimension. + pub fn apply(&self, x: Tensor, start: usize) -> Tensor { + assert!( + D >= 2, + "Input tensor must have at least 2 dimensions for sequence length and hidden dimension" + ); + + let device = x.device(); + let input_shape = x.shape(); + + // Extract the sequence length and embedding dimension, other dimensions are kept generic + // to allow both 3D and 4D tensors i.e. batch_size or (batch_size, num_heads) + let (seq_len, d_model) = (x.dims()[D - 2], x.dims()[D - 1]); + let dummy_dim_size = input_shape.num_elements() / (seq_len * d_model); + + // Create a dummy tensor with signed ones based on the 2D rotation matrix + // [[cos, -sin], [sin, cos]] + let sign_tensor = + Tensor::<2>::from_floats([[1.0, 0.0, 0.0, 1.0], [0.0, -1.0, 1.0, 0.0]], &device); + + // Rotate input using the frequency tensor. Slice the frequencies till input sequence length + let out: Tensor<4> = x + .reshape([dummy_dim_size, seq_len, d_model / 2, 2]) + .matmul(sign_tensor.unsqueeze()) + .reshape([dummy_dim_size, seq_len, d_model, 2]) + * self + .freq_complex + .clone() + .slice([start..start + seq_len]) + .unsqueeze(); + + // Sum the real and imaginary components to get output tensor and reshape to original shape + out.sum_dim(-1).reshape(input_shape) + } + + /// Shifts the pre-computed rotary frequency to cover a new range of positions. + /// + /// This method updates the internal frequency tensor `freq_complex` to store + /// the rotary positional encodings for a new window of positions starting at `start`. + pub fn shift(&mut self, start: usize) { + let max_seq_len = self.freq_complex.dims()[0]; + assert!( + start > self.start_offset, + "Shift start position must be monotonically increasing" + ); + + let current_end = self.start_offset + max_seq_len; + + if start >= current_end { + // Overwrite the whole buffer + let new_freqs = + Self::compute_rotary_frequencies(start..start + max_seq_len, self.theta.clone()); + self.freq_complex + .inplace(|freqs| freqs.slice_assign([0..max_seq_len], new_freqs)); + } else { + // Shift the tail + let num_keep = current_end - start; + let start_rel = start - self.start_offset; + let tail_freqs = self.freq_complex.clone().slice([start_rel..max_seq_len]); + self.freq_complex + .inplace(|freqs| freqs.slice_assign([0..num_keep], tail_freqs)); + // Compute the rest and assign + let new_freqs = Self::compute_rotary_frequencies( + current_end..start + max_seq_len, + self.theta.clone(), + ); + self.freq_complex + .inplace(|freqs| freqs.slice_assign([num_keep..max_seq_len], new_freqs)); + } + self.start_offset = start; + } + + /// Resets the pre-computed rotary frequency window to start at position 0. + pub fn reset(&mut self) { + if self.start_offset == 0 { + return; + } + + let max_seq_len = self.freq_complex.dims()[0]; + let freqs = Self::compute_rotary_frequencies(0..max_seq_len, self.theta.clone()); + self.freq_complex + .inplace(|freq_complex| freq_complex.slice_assign([0..max_seq_len], freqs)); + self.start_offset = 0; + } + + /// Computes the positional rotation frequencies (cosine and sine values) used in RoPE. + /// + /// # Arguments + /// - `range`: Range of position indices `[start, end)`. + /// - `theta`: 1D tensor of shape `(d_model / 2)` containing base angular frequencies. + /// + /// # Returns + /// Tensor of shape `(range.len(), d_model, 2)` containing `[cos, sin]` pairs for each position and frequency. + fn compute_rotary_frequencies(range: Range, theta: Tensor<1>) -> Tensor<3> { + let d_model = theta.dims()[0] * 2; + let num_positions = range.end - range.start; + + // Generate frequency values for positional embeddings + let frequencies: Tensor<2> = + Tensor::<1, Int>::arange(range.start as i64..range.end as i64, &theta.device()) + .float() + .unsqueeze() + .transpose() + .repeat_dim(1, d_model / 2) + * theta.unsqueeze(); + + // Convert frequency values to complex numbers (polar form) + let p_cos = frequencies.clone().cos(); + let p_sin = frequencies.sin(); + + Tensor::cat(vec![p_cos, p_sin], 1) + .reshape([num_positions, 2, d_model / 2]) + .transpose() + .unsqueeze_dim::<4>(2) + .repeat_dim(2, 2) + .reshape([num_positions, d_model, 2]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_rotary_encoding_forward() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + let input = Tensor::<3>::from_floats( + [ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ], + &device, + ); + + // Input = [Batch size, Num of heads, Seq_len, d_model] + let input = input.unsqueeze::<4>(); + + let output = rotary_encoding.forward(input); + let expected_output = Tensor::<3>::from_floats( + [ + [ + [1.0000, 2.0000, 3.0000, 4.0000], + [-2.3473, 7.4492, 6.9197, 8.0696], + ], + [ + [9.0000, 10.0000, 11.0000, 12.0000], + [-4.7567, 18.5034, 14.8393, 16.1492], + ], + ], + &device, + ); + + output + .squeeze_dim::<3>(0) + .to_data() + .assert_approx_eq::(&expected_output.to_data(), Tolerance::default()); + } + + #[test] + fn test_rotary_encoding_3d() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + let input = Tensor::<3>::from_floats( + [ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ], + &device, + ); + + // Input = [Batch size, Num of heads, Seq_len, d_model] + // let input = input.unsqueeze::<4>(); + + let output = rotary_encoding.forward(input); + let expected_output = Tensor::<3>::from_floats( + [ + [ + [1.0000, 2.0000, 3.0000, 4.0000], + [-2.3473, 7.4492, 6.9197, 8.0696], + ], + [ + [9.0000, 10.0000, 11.0000, 12.0000], + [-4.7567, 18.5034, 14.8393, 16.1492], + ], + ], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected_output.to_data(), Tolerance::default()); + } + + #[test] + fn test_zero_input_rotary_encoding_forward() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + // Use a tensor of exact zeros as input. The output rotary embedding should be zeros as well + let input = Tensor::<4>::zeros([1, 2, 2, 4], &device); + + let output = rotary_encoding.forward(input); + let expected_output = Tensor::<3>::from_floats( + [ + [ + [0.0000, 0.0000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.0000, 0.0000], + ], + [ + [0.0000, 0.0000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.0000, 0.0000], + ], + ], + &device, + ); + + output + .squeeze_dim::<3>(0) + .to_data() + .assert_approx_eq::(&expected_output.to_data(), Tolerance::default()); + } + + #[test] + #[should_panic] + fn test_valid_input_hidden_dim() { + // Hidden dimension must be even to be able to split into real and imaginary components + // for rotation + let d_model = 15; + let device = Default::default(); + let pe = RotaryEncodingConfig::new(10, d_model).init(&device); + let input = Tensor::<3>::zeros([1, 5, d_model], &device); + let _output = pe.forward(input); + } + + #[test] + fn test_rotary_encoding_frequencies() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(2, 8).init(&device); + + let expected_freqs = Tensor::<3>::from_floats( + [ + [ + [1.0000, 0.0000], + [1.0000, 0.0000], + [1.0000, 0.0000], + [1.0000, 0.0000], + ], + [ + [5.4030e-01, 8.4147e-01], + [9.9500e-01, 9.9833e-02], + [9.9995e-01, 9.9998e-03], + [9.9999e-01, 9.9999e-04], + ], + ], + &device, + ) + .unsqueeze_dim::<4>(2) + .repeat_dim(2, 2) + .reshape([2, 8, 2]); + + rotary_encoding + .freq_complex + .to_data() + .assert_approx_eq::(&expected_freqs.to_data(), Tolerance::default()); + } + + fn apply_freq_scaling_by_parts(freqs: Tensor<1>) -> Tensor<1> { + // Adapted from: https://github.com/meta-llama/llama-models/blob/main/models/llama3/reference_impl/model.py#L45 + let scale_factor = 8.; + let low_freq_factor = 1.; + let high_freq_factor = 4.; + let old_context_len = 8192.; + + let low_freq_wavelen = old_context_len / low_freq_factor; + let high_freq_wavelen = old_context_len / high_freq_factor; + + let wavelen = freqs.clone().recip().mul_scalar(2. * core::f32::consts::PI); + + // if wavelen >= high_freq_wavelen + let cond = wavelen.clone().greater_equal_elem(high_freq_wavelen); + let smooth = wavelen + .clone() + .recip() + .mul_scalar(old_context_len) + .sub_scalar(low_freq_factor) + .div_scalar(high_freq_factor - low_freq_factor); + // (1 - smooth) * freq / scale_factor + smooth * freq + let new_freqs = smooth + .clone() + .neg() + .add_scalar(1.) + .mul(freqs.clone().div_scalar(scale_factor)) + .add(smooth.clone().mul(freqs.clone())); + let new_freqs = freqs.clone().mask_where(cond, new_freqs); + + // if wavelen > low_freq_wavelen + let cond = wavelen.clone().greater_elem(low_freq_wavelen); + let new_freqs = new_freqs.mask_where(cond, freqs.clone().div_scalar(scale_factor)); + + // if wavelen < high_freq_wavelen + let cond = wavelen.lower_elem(high_freq_wavelen); + new_freqs.mask_where(cond, freqs) + } + + #[test] + fn test_rotary_encoding_with_frequency_scaling() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(2, 8) + .init_with_frequency_scaling(apply_freq_scaling_by_parts, &device); + + let expected_freqs = Tensor::<3>::from_floats( + [ + [ + [1.0000, 0.0000], + [1.0000, 0.0000], + [1.0000, 0.0000], + [1.0000, 0.0000], + ], + [ + [5.4030e-01, 8.4148e-01], + [9.9500e-01, 9.9833e-02], + [9.9995e-01, 9.9998e-03], + [1.0000, 2.1361e-04], + ], + ], + &device, + ) + .unsqueeze_dim::<4>(2) + .repeat_dim(2, 2) + .reshape([2, 8, 2]); + + rotary_encoding + .freq_complex + .to_data() + .assert_approx_eq::(&expected_freqs.to_data(), Tolerance::default()); + } + + #[test] + fn test_rotary_encoding_shift_full() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + // Input = [Batch size, Num of heads, Seq_len, d_model] + let input = Tensor::<3>::from_floats( + [ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ], + &device, + ) + .unsqueeze::<4>(); + + // Initializing for a bigger cache (e.g., max_seq_len = 10) should give the same result + // as using a smaller cache of pre-computed RoPE frequencies that are shifted to the same + // initial position + let expected_output = rotary_encoding.apply(input.clone(), 6); + + let mut rotary_encoding = RotaryEncodingConfig::new(4, 4).init(&device); + rotary_encoding.shift(6); // start > 4 will perform a full re-compute + + let output = rotary_encoding.apply(input, 0); + + output + .into_data() + .assert_approx_eq::(&expected_output.into_data(), Tolerance::default()); + } + + #[test] + fn test_rotary_encoding_shift() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + // Input = [Batch size, Num of heads, Seq_len, d_model] + let input = Tensor::<3>::from_floats( + [ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ], + &device, + ) + .unsqueeze::<4>(); + + // Initializing for a bigger cache (e.g., max_seq_len = 10) should give the same result + // as using a smaller cache of pre-computed RoPE frequencies that are shifted to the same + // initial position + let expected_output = rotary_encoding.apply(input.clone(), 2); + + let mut rotary_encoding = RotaryEncodingConfig::new(4, 4).init(&device); + rotary_encoding.shift(2); // start < 4 will shift the (current_end - start) freqs and compute the rest + + let output = rotary_encoding.apply(input, 0); + + output + .into_data() + .assert_approx_eq::(&expected_output.into_data(), Tolerance::default()); + } + + #[test] + fn test_rotary_encoding_shift_multiple() { + let device = Default::default(); + let mut rotary_encoding = RotaryEncodingConfig::new(4, 4).init(&device); + rotary_encoding.shift(2); + rotary_encoding.shift(5); + } + + #[test] + #[should_panic = "Shift start position must be monotonically increasing"] + fn test_rotary_encoding_shift_should_increase() { + let device = Default::default(); + let mut rotary_encoding = RotaryEncodingConfig::new(4, 4).init(&device); + rotary_encoding.shift(6); + rotary_encoding.shift(4); // should be monotonically increasing + } + + #[test] + fn test_rotary_encoding_reset_after_shift() { + let device = Default::default(); + let rotary_encoding = RotaryEncodingConfig::new(10, 4).init(&device); + + let input = Tensor::<3>::from_floats( + [ + [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + [[9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0]], + ], + &device, + ) + .unsqueeze::<4>(); + + let expected_output = rotary_encoding.apply(input.clone(), 0); + let expected_shifted_output = rotary_encoding.apply(input.clone(), 2); + + let mut rotary_encoding = RotaryEncodingConfig::new(4, 4).init(&device); + rotary_encoding.shift(2); + rotary_encoding.reset(); + + assert_eq!(rotary_encoding.start_offset, 0); + let output = rotary_encoding.apply(input.clone(), 0); + output + .into_data() + .assert_approx_eq::(&expected_output.into_data(), Tolerance::default()); + + // Reset must also allow subsequent shifts from the beginning again. + rotary_encoding.shift(2); + let output = rotary_encoding.apply(input, 0); + output + .into_data() + .assert_approx_eq::(&expected_shifted_output.into_data(), Tolerance::default()); + } + + #[test] + fn display() { + let config = RotaryEncodingConfig::new(10, 4); + let pe = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{pe}"), + "RotaryEncoding {d_model: 4, max_sequence_length: 10}" + ); + } +} diff --git a/crates/burn-nn/src/modules/transformer/decoder.rs b/crates/burn-nn/src/modules/transformer/decoder.rs new file mode 100644 index 0000000..0b261df --- /dev/null +++ b/crates/burn-nn/src/modules/transformer/decoder.rs @@ -0,0 +1,571 @@ +use burn_core as burn; + +use alloc::vec::Vec; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::{Bool, Device, Tensor}; + +use crate::activation::ActivationConfig; +use crate::cache::TensorCache; +use crate::{ + Dropout, DropoutConfig, LayerNorm, LayerNormConfig, + attention::{MhaCache, MhaInput, MultiHeadAttention, MultiHeadAttentionConfig}, +}; + +use super::{PositionWiseFeedForward, PositionWiseFeedForwardConfig}; + +/// Configuration to create a [Transformer Decoder](TransformerDecoder) layer using the [init function](TransformerDecoderConfig::init). +#[derive(Config, Debug)] +pub struct TransformerDecoderConfig { + /// The size of the model. + pub d_model: usize, + /// The size of the position-wise feed-forward network. + pub d_ff: usize, + /// The number of attention heads. + pub n_heads: usize, + /// The number of layers. + pub n_layers: usize, + /// The dropout rate. Default: 0.1 + #[config(default = 0.1)] + pub dropout: f64, + /// Layer norm will be applied first instead of after the other modules. + #[config(default = false)] + pub norm_first: bool, + /// Use "quiet softmax" instead of regular softmax. + /// + /// - Usage may improve performance by allowing attention heads to deposit no information (if the sequence contains no information relevant to that head). + /// - Usage may reduce the entropy of weights in the model, enhancing quantization and compression. + /// + /// Reference: + #[config(default = false)] + pub quiet_softmax: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + /// The activation function used in the position-wise feed-forward network. Default: Gelu + #[config(default = "ActivationConfig::Gelu")] + pub activation: ActivationConfig, + /// The epsilon value for layer normalization. Default: 1e-5 + #[config(default = 1e-5)] + pub layer_norm_eps: f64, +} + +/// The transformer decoder module as describe in the paper [Attention Is All You Need](https://arxiv.org/abs/1706.03762). +/// +/// # Params +/// +/// - layers: transformer decoder layers with `d_model` input and output features. +/// +/// Should be created using [TransformerDecoderConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct TransformerDecoder { + /// Transformer decoder layers. + pub layers: Vec, + + /// The size of the model. + pub d_model: usize, + + /// The size of the position-wise feed-forward network. + pub d_ff: usize, + + /// The number of attention heads. + pub n_heads: usize, + + /// The number of layers. + pub n_layers: usize, + + /// The dropout rate. Default: 0.1 + pub dropout: f64, + + /// Layer norm will be applied first instead of after the other modules. + pub norm_first: bool, + + /// Use "quiet softmax" instead of regular softmax. + pub quiet_softmax: bool, +} + +impl ModuleDisplay for TransformerDecoder { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("d_model", &self.d_model) + .add("d_ff", &self.d_ff) + .add("n_heads", &self.n_heads) + .add("n_layers", &self.n_layers) + .add("dropout", &self.dropout) + .add("norm_first", &self.norm_first) + .add("quiet_softmax", &self.quiet_softmax) + .optional() + } +} + +impl TransformerDecoderConfig { + /// Initialize a new [Transformer Decoder](TransformerDecoder) module. + pub fn init(&self, device: &Device) -> TransformerDecoder { + let layers = (0..self.n_layers) + .map(|_| TransformerDecoderLayer::new(self, device)) + .collect::>(); + + TransformerDecoder { + layers, + d_model: self.d_model, + d_ff: self.d_ff, + n_heads: self.n_heads, + n_layers: self.n_layers, + dropout: self.dropout, + norm_first: self.norm_first, + quiet_softmax: self.quiet_softmax, + } + } +} + +/// [Transformer Decoder](TransformerDecoder) forward pass input argument. +#[derive(Debug)] +pub struct TransformerDecoderInput { + target: Tensor<3>, + target_mask_pad: Option>, + target_mask_attn: Option>, + memory: Tensor<3>, + memory_mask_pad: Option>, + memory_mask_attn: Option>, +} + +impl TransformerDecoderInput { + /// Create a [transformer decoder](TransformerDecoder) input argument. + pub fn new(target: Tensor<3>, memory: Tensor<3>) -> Self { + Self { + target, + target_mask_pad: None, + target_mask_attn: None, + memory, + memory_mask_pad: None, + memory_mask_attn: None, + } + } + + /// Register the memory padding mask. + pub fn memory_mask_pad(mut self, mask_pad: Tensor<2, Bool>) -> Self { + self.memory_mask_pad = Some(mask_pad); + self + } + + /// Register the memory attention mask. + pub fn memory_mask_attn(mut self, mask_attn: Tensor<3, Bool>) -> Self { + self.memory_mask_attn = Some(mask_attn); + self + } + + /// Register the target padding mask. + pub fn target_mask_pad(mut self, mask_pad: Tensor<2, Bool>) -> Self { + self.target_mask_pad = Some(mask_pad); + self + } + + /// Register the target attention mask. + pub fn target_mask_attn(mut self, mask_attn: Tensor<3, Bool>) -> Self { + self.target_mask_attn = Some(mask_attn); + self + } +} + +/// [Transformer Decoder](TransformerDecoder) layer module. +#[derive(Module, Debug)] +pub struct TransformerDecoderLayer { + /// Cross-attention module. + pub cross_attn: MultiHeadAttention, + /// Self-attention module. + pub self_attn: MultiHeadAttention, + /// Position-wise feed-forward module. + pub pwff: PositionWiseFeedForward, + /// First layer norm. + pub norm_1: LayerNorm, + /// Second layer norm. + pub norm_2: LayerNorm, + /// Third layer norm. + pub norm_3: LayerNorm, + /// Dropout. + pub dropout: Dropout, + /// Whether to apply norm first. + pub norm_first: bool, +} + +/// Autoregressive cache for a single [Transformer Decoder Layer](TransformerDecoderLayer). +pub struct TransformerDecoderLayerAutoregressiveCache { + /// Cross-attention cache. + pub cross_attn: MhaCache, + /// Self-attention cache. + pub self_attn: MhaCache, + /// Position-wise feed-forward cache. + pub pwff: TensorCache<3>, + /// First layer norm cache. + pub norm_1: TensorCache<3>, + /// Second layer norm cache. + pub norm_2: TensorCache<3>, + /// Third layer norm cache. + pub norm_3: TensorCache<3>, +} + +impl TransformerDecoderLayerAutoregressiveCache { + /// Create an empty cache. + pub fn empty() -> Self { + Self { + cross_attn: MhaCache::autoregressive_cross_attention(), + self_attn: MhaCache::autoregressive(), + pwff: TensorCache::empty(), + norm_1: TensorCache::empty(), + norm_2: TensorCache::empty(), + norm_3: TensorCache::empty(), + } + } +} + +/// Autoregressive cache for the [Transformer Decoder](TransformerDecoder) layer. +/// +/// To be used during inference when decoding tokens. +pub struct TransformerDecoderAutoregressiveCache { + layers: Vec, +} + +impl TransformerDecoderAutoregressiveCache { + fn empty(num_layers: usize) -> Self { + Self { + layers: (0..num_layers) + .map(|_| TransformerDecoderLayerAutoregressiveCache::empty()) + .collect(), + } + } +} + +impl TransformerDecoderLayer { + /// Create a new [TransformerDecoderLayer](TransformerDecoderLayer). + pub fn new(config: &TransformerDecoderConfig, device: &Device) -> Self { + let self_attn = MultiHeadAttentionConfig::new(config.d_model, config.n_heads) + .with_initializer(config.initializer.clone()) + .with_dropout(config.dropout) + .with_quiet_softmax(config.quiet_softmax) + .init(device); + + let cross_attn = MultiHeadAttentionConfig::new(config.d_model, config.n_heads) + .with_initializer(config.initializer.clone()) + .with_dropout(config.dropout) + .with_quiet_softmax(config.quiet_softmax) + .init(device); + let norm_1 = LayerNormConfig::new(config.d_model) + .with_epsilon(config.layer_norm_eps) + .init(device); + let norm_2 = LayerNormConfig::new(config.d_model) + .with_epsilon(config.layer_norm_eps) + .init(device); + let norm_3 = LayerNormConfig::new(config.d_model) + .with_epsilon(config.layer_norm_eps) + .init(device); + let dropout = DropoutConfig::new(config.dropout).init(); + let pwff = PositionWiseFeedForwardConfig::new(config.d_model, config.d_ff) + .with_initializer(config.initializer.clone()) + .with_dropout(config.dropout) + .with_activation(config.activation.clone()) + .init(device); + + Self { + cross_attn, + self_attn, + norm_1, + norm_2, + norm_3, + pwff, + dropout, + norm_first: config.norm_first, + } + } + + /// Applies the TransformerDecoder forward pass to the input tensor. + pub fn forward(&self, mut input: TransformerDecoderInput) -> TransformerDecoderInput { + // Self attention residual path. + let x = input.target; + let mut residual_path = x.clone(); + + // Normalize. + if self.norm_first { + residual_path = self.norm_3.forward(residual_path); + } + + // Self attention. + let mut self_attn_input = MhaInput::self_attn(residual_path); + if let Some(mask_pad) = &input.target_mask_pad { + self_attn_input = self_attn_input.mask_pad(mask_pad.clone()); + } + if let Some(mask_attn) = &input.target_mask_attn { + self_attn_input = self_attn_input.mask_attn(mask_attn.clone()); + } + let residual_path = self.self_attn.forward(self_attn_input).context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Cross attention residual path. + // Normalize. + let residual_path = if self.norm_first { + self.norm_1.forward(x.clone()) + } else { + x = self.norm_1.forward(x); + x.clone() + }; + + // Cross attention. + let mut cross_attn_input = + MhaInput::new(residual_path, input.memory.clone(), input.memory.clone()); + if let Some(mask_pad) = &input.memory_mask_pad { + cross_attn_input = cross_attn_input.mask_pad(mask_pad.clone()); + } + if let Some(mask_attn) = &input.memory_mask_attn { + cross_attn_input = cross_attn_input.mask_attn(mask_attn.clone()); + } + let residual_path = self.cross_attn.forward(cross_attn_input).context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Feed forward residual path. + // Normalize. + let residual_path = if self.norm_first { + self.norm_2.forward(x.clone()) + } else { + x = self.norm_2.forward(x); + x.clone() + }; + + let residual_path = self.pwff.forward(residual_path); + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Main path. + // Normalize. + if !self.norm_first { + x = self.norm_3.forward(x) + } + + input.target = x; + input + } + + /// Applies the forward pass using an autoregressive cache. + pub fn forward_autoregressive_inference( + &self, + mut input: TransformerDecoderInput, + cache: &mut TransformerDecoderLayerAutoregressiveCache, + ) -> TransformerDecoderInput { + // Self attention residual path. + let x = input.target; + let mut residual_path = x.clone(); + + // Normalize. + if self.norm_first { + residual_path = cache + .norm_3 + .forward_autoregressive(residual_path, 1, |x| self.norm_3.forward(x)); + } + + // Self attention. + let mut self_attn_input = MhaInput::self_attn(residual_path); + if let Some(mask_pad) = &input.target_mask_pad { + self_attn_input = self_attn_input.mask_pad(mask_pad.clone()); + } + if let Some(mask_attn) = &input.target_mask_attn { + self_attn_input = self_attn_input.mask_attn(mask_attn.clone()); + } + let residual_path = self + .self_attn + .forward_cache(self_attn_input, &mut cache.self_attn) + .context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Cross attention residual path. + // Normalize. + let residual_path = if self.norm_first { + cache + .norm_1 + .forward_autoregressive(x.clone(), 1, |x| self.norm_1.forward(x)) + } else { + x = cache + .norm_1 + .forward_autoregressive(x, 1, |x| self.norm_1.forward(x)); + x.clone() + }; + + // Cross attention. + let mut cross_attn_input = + MhaInput::new(residual_path, input.memory.clone(), input.memory.clone()); + if let Some(mask_pad) = &input.memory_mask_pad { + cross_attn_input = cross_attn_input.mask_pad(mask_pad.clone()); + } + if let Some(mask_attn) = &input.memory_mask_attn { + cross_attn_input = cross_attn_input.mask_attn(mask_attn.clone()); + } + let residual_path = self + .cross_attn + .forward_cache(cross_attn_input, &mut cache.cross_attn) + .context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Feed forward residual path. + // Normalize. + let residual_path = if self.norm_first { + cache + .norm_2 + .forward_autoregressive(x.clone(), 1, |x| self.norm_2.forward(x)) + } else { + x = cache + .norm_2 + .forward_autoregressive(x, 1, |x| self.norm_2.forward(x)); + x.clone() + }; + + let residual_path = cache + .pwff + .forward_autoregressive(residual_path, 1, |x| self.pwff.forward(x)); + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Main path. + // Normalize. + if !self.norm_first { + x = cache + .norm_3 + .forward_autoregressive(x, 1, |x| self.norm_3.forward(x)) + } + + input.target = x; + input + } +} + +impl TransformerDecoder { + /// Applies the forward pass. + pub fn forward(&self, mut input: TransformerDecoderInput) -> Tensor<3> { + for layer in self.layers.iter() { + input = layer.forward(input); + } + + input.target + } + + /// Applies the forward pass on the input using autoregressive cache. + pub fn forward_autoregressive_inference( + &self, + mut input: TransformerDecoderInput, + cache: &mut TransformerDecoderAutoregressiveCache, + ) -> Tensor<3> { + for i in 0..self.layers.len() { + let layer = self.layers.get(i).unwrap(); + let cache = cache.layers.get_mut(i).unwrap(); + + input = layer.forward_autoregressive_inference(input, cache); + } + + input.target + } + /// Create an empty autoregressive cache. + pub fn new_autoregressive_cache(&self) -> TransformerDecoderAutoregressiveCache { + TransformerDecoderAutoregressiveCache::empty(self.layers.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attention::generate_autoregressive_mask; + + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_autoregressive_norm_last() { + let [d_model, d_ff, n_heads, num_layers] = [12, 24, 2, 3]; + let device = Device::default(); + device.seed(0); + + test_autoregressive( + TransformerDecoderConfig::new(d_model, d_ff, n_heads, num_layers) + .with_norm_first(false), + ) + } + + #[test] + fn test_autoregressive_norm_first() { + let [d_model, d_ff, n_heads, num_layers] = [12, 24, 2, 3]; + let device = Device::default(); + device.seed(0); + + test_autoregressive( + TransformerDecoderConfig::new(d_model, d_ff, n_heads, num_layers).with_norm_first(true), + ) + } + + fn test_autoregressive(config: TransformerDecoderConfig) { + let device = Default::default(); + let [batch_size, seq_length, d_model] = [3, 4, config.d_model]; + let transformer = config.init(&device); + + let memory = Tensor::arange(0..(batch_size * seq_length * d_model) as i64, &device) + .float() + .reshape([batch_size, seq_length, d_model]); + let target = Tensor::arange(0..(batch_size * seq_length * d_model) as i64, &device) + .float() + .reshape([batch_size, seq_length, d_model]); + let mask_attn = generate_autoregressive_mask(batch_size, seq_length, &target.device()); + let input = TransformerDecoderInput::new(target.clone(), memory.clone()) + .target_mask_attn(mask_attn); + + // Normal forward using masking. + let output_1 = transformer.forward(input); + + // Forward using the autoregressive cache. + let mut output_2 = Vec::new(); + let mut cache = transformer.new_autoregressive_cache(); + + for i in 1..seq_length + 1 { + let target = target.clone().slice([0..batch_size, 0..i, 0..d_model]); + + let mask_attn = generate_autoregressive_mask(batch_size, i, &target.device()); + let input = TransformerDecoderInput::new(target.clone(), memory.clone()) + .target_mask_attn(mask_attn); + let next_tok = transformer // Greedy sampling + .forward_autoregressive_inference(input, &mut cache) + .slice([0..batch_size, i - 1..i, 0..d_model]); + output_2.push(next_tok); + } + + let output_2 = Tensor::cat(output_2, 1); + + // Should produce the same tokens. + let tolerance = Tolerance::rel_abs(5e-3, 1e-4); + output_1 + .into_data() + .assert_approx_eq::(&output_2.into_data(), tolerance); + } + + #[test] + fn display() { + let config = TransformerDecoderConfig::new(2, 4, 2, 3); + let transformer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{transformer}"), + "TransformerDecoder {d_model: 2, d_ff: 4, n_heads: 2, n_layers: 3, \ + dropout: 0.1, norm_first: false, quiet_softmax: false, params: 246}" + ); + } +} diff --git a/crates/burn-nn/src/modules/transformer/encoder.rs b/crates/burn-nn/src/modules/transformer/encoder.rs new file mode 100644 index 0000000..2b4bae6 --- /dev/null +++ b/crates/burn-nn/src/modules/transformer/encoder.rs @@ -0,0 +1,489 @@ +use burn_core as burn; + +use alloc::vec::Vec; + +use super::{PositionWiseFeedForward, PositionWiseFeedForwardConfig}; +use crate::{ + Dropout, DropoutConfig, LayerNorm, LayerNormConfig, + activation::ActivationConfig, + attention::{MhaCache, MhaInput, MultiHeadAttention, MultiHeadAttentionConfig}, + cache::TensorCache, +}; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::{Bool, Device, Tensor}; + +/// Configuration to create a [Transformer Encoder](TransformerEncoder) layer using the [init function](TransformerEncoderConfig::init). +#[derive(Config, Debug)] +pub struct TransformerEncoderConfig { + /// The size of the model. + pub d_model: usize, + /// The size of the position-wise feed-forward network. + pub d_ff: usize, + /// The number of attention heads. + pub n_heads: usize, + /// The number of layers. + pub n_layers: usize, + /// The dropout rate. Default: 0.1 + #[config(default = 0.1)] + pub dropout: f64, + /// Layer norm will be applied first instead of after the other modules. + #[config(default = false)] + pub norm_first: bool, + /// Use "quiet softmax" instead of regular softmax. + /// + /// - Usage may improve performance by allowing attention heads to deposit no information (if the sequence contains no information relevant to that head). + /// - Usage may reduce the entropy of weights in the model, enhancing quantization and compression. + /// + /// Reference: + #[config(default = false)] + pub quiet_softmax: bool, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + /// The activation function used in the position-wise feed-forward network. Default: Gelu + #[config(default = "ActivationConfig::Gelu")] + pub activation: ActivationConfig, + /// The epsilon value for layer normalization. Default: 1e-5 + #[config(default = 1e-5)] + pub layer_norm_eps: f64, +} + +/// The transformer encoder module as describe in the paper [Attention Is All You Need](https://arxiv.org/abs/1706.03762). +/// +/// # Params +/// +/// - layers: transformer encoder layers with `d_model` input and output features. +/// +/// Should be created using [TransformerEncoderConfig] +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct TransformerEncoder { + /// The transformer encoder layers. + pub layers: Vec, + + /// The size of the model. + pub d_model: usize, + + /// The size of the position-wise feed-forward network. + pub d_ff: usize, + + /// The number of attention heads. + pub n_heads: usize, + + /// The number of layers. + pub n_layers: usize, + + /// The dropout rate. Default: 0.1 + pub dropout: f64, + + /// Layer norm will be applied first instead of after the other modules. + pub norm_first: bool, + + /// Use "quiet softmax" instead of regular softmax. + pub quiet_softmax: bool, +} + +impl ModuleDisplay for TransformerEncoder { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("d_model", &self.d_model) + .add("d_ff", &self.d_ff) + .add("n_heads", &self.n_heads) + .add("n_layers", &self.n_layers) + .add("dropout", &self.dropout) + .add("norm_first", &self.norm_first) + .add("quiet_softmax", &self.quiet_softmax) + .optional() + } +} + +/// [Transformer Encoder](TransformerEncoder) forward pass input argument. +#[derive(Debug)] +pub struct TransformerEncoderInput { + tensor: Tensor<3>, + mask_pad: Option>, + mask_attn: Option>, +} + +impl TransformerEncoderInput { + /// Create a [transformer encoder](TransformerEncoder) input argument. + pub fn new(tensor: Tensor<3>) -> Self { + Self { + tensor, + mask_pad: None, + mask_attn: None, + } + } + + /// Register the padding mask. + pub fn mask_pad(mut self, mask_pad: Tensor<2, Bool>) -> Self { + self.mask_pad = Some(mask_pad); + self + } + + /// Register the attention mask. + pub fn mask_attn(mut self, mask_attn: Tensor<3, Bool>) -> Self { + self.mask_attn = Some(mask_attn); + self + } +} +impl TransformerEncoderConfig { + /// Initialize a new [transformer encoder](TransformerEncoder) module. + pub fn init(&self, device: &Device) -> TransformerEncoder { + let layers = (0..self.n_layers) + .map(|_| TransformerEncoderLayer::new(self, device)) + .collect::>(); + + TransformerEncoder { + layers, + d_model: self.d_model, + d_ff: self.d_ff, + n_heads: self.n_heads, + n_layers: self.n_layers, + dropout: self.dropout, + norm_first: self.norm_first, + quiet_softmax: self.quiet_softmax, + } + } +} + +impl TransformerEncoder { + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - tensor: `[batch_size, seq_length, d_model]` + /// - output: `[batch_size, seq_length, d_model]` + pub fn forward(&self, input: TransformerEncoderInput) -> Tensor<3> { + let mut x = input.tensor; + + for layer in self.layers.iter() { + x = layer.forward(x, input.mask_pad.clone(), input.mask_attn.clone()); + } + + x + } + /// Applies the forward pass on the input tensor using autoregressive cache. + /// + /// # Shapes + /// + /// - tensor: `[batch_size, seq_length, d_model]` + /// - output: `[batch_size, seq_length, d_model]` + pub fn forward_autoregressive_inference( + &self, + input: TransformerEncoderInput, + cache: &mut TransformerEncoderAutoregressiveCache, + ) -> Tensor<3> { + let mut x = input.tensor; + + for i in 0..self.layers.len() { + let layer = self.layers.get(i).unwrap(); + let cache = cache.layers.get_mut(i).unwrap(); + + x = layer.forward_autoregressive_inference( + x, + input.mask_pad.clone(), + input.mask_attn.clone(), + cache, + ); + } + + x + } + + /// Create an empty autoregressive cache. + pub fn new_autoregressive_cache(&self) -> TransformerEncoderAutoregressiveCache { + TransformerEncoderAutoregressiveCache::empty(self.layers.len()) + } +} + +/// Transformer encoder layer module. +#[derive(Module, Debug)] +pub struct TransformerEncoderLayer { + /// Multi-head self-attention sub-layer. + pub mha: MultiHeadAttention, + /// Position-wise feed-forward sub-layer. + pub pwff: PositionWiseFeedForward, + /// Layer normalization applied around the feed-forward sub-layer. + pub norm_1: LayerNorm, + /// Layer normalization applied around the attention sub-layer. + pub norm_2: LayerNorm, + /// Dropout module applied to residual connections. + pub dropout: Dropout, + /// If `true`, apply layer normalization before sub-layers (pre-norm), + /// otherwise apply it after (post-norm). + pub norm_first: bool, +} + +impl TransformerEncoderLayer { + /// Create a new transformer encoder layer from the given configuration. + pub fn new(config: &TransformerEncoderConfig, device: &Device) -> Self { + let mha = MultiHeadAttentionConfig::new(config.d_model, config.n_heads) + .with_initializer(config.initializer.clone()) + .with_dropout(config.dropout) + .with_quiet_softmax(config.quiet_softmax) + .init(device); + let norm_1 = LayerNormConfig::new(config.d_model) + .with_epsilon(config.layer_norm_eps) + .init(device); + let norm_2 = LayerNormConfig::new(config.d_model) + .with_epsilon(config.layer_norm_eps) + .init(device); + let dropout = DropoutConfig::new(config.dropout).init(); + let pwff = PositionWiseFeedForwardConfig::new(config.d_model, config.d_ff) + .with_initializer(config.initializer.clone()) + .with_dropout(config.dropout) + .with_activation(config.activation.clone()) + .init(device); + + Self { + mha, + norm_1, + norm_2, + pwff, + dropout, + norm_first: config.norm_first, + } + } + + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[batch_size, seq_length, d_model]` + /// - output: `[batch_size, seq_length, d_model]` + pub fn forward( + &self, + input: Tensor<3>, + mask_pad: Option>, + mask_attn: Option>, + ) -> Tensor<3> { + // Multi-head attention residual path. + let x = input; + let mut residual_path = x.clone(); + + // Normalize. + if self.norm_first { + residual_path = self.norm_2.forward(residual_path) + } + + // Multi-head attention. + let mut input_mhs = MhaInput::self_attn(residual_path); + if let Some(mask_pad) = mask_pad { + input_mhs = input_mhs.mask_pad(mask_pad); + } + if let Some(mask_attn) = mask_attn { + input_mhs = input_mhs.mask_attn(mask_attn); + } + let residual_path = self.mha.forward(input_mhs).context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Feed forward residual path. + // Normalize. + let residual_path = if self.norm_first { + self.norm_1.forward(x.clone()) + } else { + x = self.norm_1.forward(x); + x.clone() + }; + + // Feed forward. + let residual_path = self.pwff.forward(residual_path); + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Main path. + // Normalize. + if !self.norm_first { + x = self.norm_2.forward(x) + } + + x + } + + /// Applies the forward pass using an autoregressive cache. + pub fn forward_autoregressive_inference( + &self, + input: Tensor<3>, + mask_pad: Option>, + mask_attn: Option>, + cache: &mut TransformerEncoderLayerAutoregressiveCache, + ) -> Tensor<3> { + // Multi-head attention residual path. + let x = input; + let mut residual_path = x.clone(); + + // Normalize. + if self.norm_first { + residual_path = cache + .norm_2 + .forward_autoregressive(residual_path, 1, |x| self.norm_2.forward(x)) + } + + // Multi-head attention. + let mut input_mhs = MhaInput::self_attn(residual_path); + if let Some(mask_pad) = mask_pad { + input_mhs = input_mhs.mask_pad(mask_pad); + } + if let Some(mask_attn) = mask_attn { + input_mhs = input_mhs.mask_attn(mask_attn); + } + let residual_path = self.mha.forward_cache(input_mhs, &mut cache.mha).context; + + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Feed forward residual path. + // Normalize. + let residual_path = if self.norm_first { + cache + .norm_1 + .forward_autoregressive(x.clone(), 1, |x| self.norm_1.forward(x)) + } else { + x = cache + .norm_1 + .forward_autoregressive(x, 1, |x| self.norm_1.forward(x)); + x.clone() + }; + + // Feed forward. + let residual_path = cache + .pwff + .forward_autoregressive(residual_path, 1, |x| self.pwff.forward(x)); + let residual_path = self.dropout.forward(residual_path); + let mut x = x + residual_path; + + // Main path. + // Normalize. + if !self.norm_first { + x = cache + .norm_2 + .forward_autoregressive(x, 1, |x| self.norm_2.forward(x)) + } + + x + } +} + +/// Autoregressive cache for a single [Transformer Encoder Layer](TransformerEncoderLayer). +pub struct TransformerEncoderLayerAutoregressiveCache { + /// Multi-head attention cache. + pub mha: MhaCache, + /// Position-wise feed-forward cache. + pub pwff: TensorCache<3>, + /// First layer norm cache. + pub norm_1: TensorCache<3>, + /// Second layer norm cache. + pub norm_2: TensorCache<3>, +} + +impl TransformerEncoderLayerAutoregressiveCache { + /// Create an empty cache. + pub fn empty() -> Self { + Self { + mha: MhaCache::autoregressive(), + pwff: TensorCache::empty(), + norm_1: TensorCache::empty(), + norm_2: TensorCache::empty(), + } + } +} + +/// Autoregressive cache for the [Transformer Encoder](TransformerEncoder) layer. +/// +/// To be used during inference when decoding tokens. +pub struct TransformerEncoderAutoregressiveCache { + layers: Vec, +} + +impl TransformerEncoderAutoregressiveCache { + fn empty(num_layers: usize) -> Self { + Self { + layers: (0..num_layers) + .map(|_| TransformerEncoderLayerAutoregressiveCache::empty()) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attention::generate_autoregressive_mask; + use burn::tensor::Distribution; + use burn::tensor::Tolerance; + type FT = f32; + + #[test] + fn test_autoregressive_norm_last() { + let [d_model, d_ff, n_heads, num_layers] = [12, 24, 2, 3]; + test_autoregressive( + TransformerEncoderConfig::new(d_model, d_ff, n_heads, num_layers) + .with_norm_first(false), + ) + } + + #[test] + fn test_autoregressive_norm_first() { + let [d_model, d_ff, n_heads, num_layers] = [12, 24, 2, 3]; + test_autoregressive( + TransformerEncoderConfig::new(d_model, d_ff, n_heads, num_layers).with_norm_first(true), + ) + } + + fn test_autoregressive(config: TransformerEncoderConfig) { + let [batch_size, seq_length, d_model] = [3, 4, config.d_model]; + let device = Default::default(); + let transformer = config.init(&device); + + let tensor = Tensor::<3>::random( + [batch_size, seq_length, d_model], + Distribution::Default, + &device, + ); + let mask_attn = generate_autoregressive_mask(batch_size, seq_length, &tensor.device()); + let input = TransformerEncoderInput::new(tensor.clone()).mask_attn(mask_attn); + + let output_1 = transformer.forward(input); + let mut output_2 = Vec::new(); + let mut cache = transformer.new_autoregressive_cache(); + + for i in 1..seq_length + 1 { + let tensor = tensor.clone().slice([0..batch_size, 0..i, 0..d_model]); + let input = TransformerEncoderInput::new(tensor.clone()); + let next_tok = transformer + .forward_autoregressive_inference(input, &mut cache) + .slice([0..batch_size, i - 1..i, 0..d_model]); + output_2.push(next_tok); + } + + let output_2 = Tensor::cat(output_2, 1); + + output_1 + .into_data() + .assert_approx_eq::(&output_2.into_data(), Tolerance::permissive()); + } + + #[test] + fn display() { + let config = TransformerEncoderConfig::new(2, 4, 2, 3); + let transformer = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{transformer}"), + "TransformerEncoder {d_model: 2, d_ff: 4, n_heads: 2, \ + n_layers: 3, dropout: 0.1, norm_first: false, quiet_softmax: false, params: 162}" + ); + } +} diff --git a/crates/burn-nn/src/modules/transformer/mod.rs b/crates/burn-nn/src/modules/transformer/mod.rs new file mode 100644 index 0000000..54397c6 --- /dev/null +++ b/crates/burn-nn/src/modules/transformer/mod.rs @@ -0,0 +1,7 @@ +mod decoder; +mod encoder; +mod pwff; + +pub use decoder::*; +pub use encoder::*; +pub use pwff::*; diff --git a/crates/burn-nn/src/modules/transformer/pwff.rs b/crates/burn-nn/src/modules/transformer/pwff.rs new file mode 100644 index 0000000..6c4d051 --- /dev/null +++ b/crates/burn-nn/src/modules/transformer/pwff.rs @@ -0,0 +1,128 @@ +use burn_core as burn; + +use crate::activation::{Activation, ActivationConfig}; +use crate::{Dropout, DropoutConfig, Linear, LinearConfig}; +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Initializer, Module, ModuleDisplay}; +use burn::tensor::{Device, Tensor}; + +/// Configuration to create a [position-wise feed-forward](PositionWiseFeedForward) layer using the [init function](PositionWiseFeedForwardConfig::init). +#[derive(Config, Debug)] +pub struct PositionWiseFeedForwardConfig { + /// The size of the input and output features. + pub d_model: usize, + /// The size of the hidden inner features. + pub d_ff: usize, + /// The dropout rate. Default: 0.1 + #[config(default = 0.1)] + pub dropout: f64, + /// The type of function used to initialize neural network parameters + #[config( + default = "Initializer::KaimingUniform{gain:1.0/num_traits::Float::sqrt(3.0), fan_out_only:false}" + )] + pub initializer: Initializer, + /// The activation function used between the two linear layers. Default: Gelu + #[config(default = "ActivationConfig::Gelu")] + pub activation: ActivationConfig, +} + +/// Applies the position-wise feed-forward network to the input tensor from the paper [Attention Is All You Need](https://arxiv.org/pdf/1706.03762v7). +/// +/// # Params +/// +/// - linear inner: Linear layer with `d_model` input features and `d_ff` output features. +/// - linear outer: Linear layer with `d_ff` input features and `d_model` output features. +/// +/// `FFN(x) = max(0, xW1 + b1)W2 + b2` +/// +/// Should be created using [PositionWiseFeedForwardConfig] +/// +/// # Notes +/// +/// The `activation` field is currently marked `#[module(skip)]` for backward +/// compatibility with records saved before this field was introduced (when +/// the activation was always `Gelu` and had no state). This means activation +/// state is **not persisted** when saving or loading records. +/// +/// For stateless activations (GELU, ReLU, etc.) this has no effect. +/// **If you are using `SwiGLU`, its learnable parameters will not be saved or +/// loaded correctly.** +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct PositionWiseFeedForward { + /// Linear layer with `d_model` input features and `d_ff` output features. + pub linear_inner: Linear, + /// Linear layer with `d_ff` input features and `d_model` output features. + pub linear_outer: Linear, + /// Dropout layer. + pub dropout: Dropout, + /// Activation function. + #[module(skip)] // for backward compatibility with previous `gelu` field name + pub activation: Activation, +} + +impl ModuleDisplay for PositionWiseFeedForward { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let [d_model, dff] = self.linear_inner.weight.shape().dims(); + + content + .add("d_model", &d_model) + .add("d_ff", &dff) + .add("prob", &self.dropout.prob) + .optional() + } +} + +impl PositionWiseFeedForwardConfig { + /// Initialize a new [position-wise feed-forward](PositionWiseFeedForward) module. + pub fn init(&self, device: &Device) -> PositionWiseFeedForward { + PositionWiseFeedForward { + linear_inner: LinearConfig::new(self.d_model, self.d_ff) + .with_initializer(self.initializer.clone()) + .init(device), + linear_outer: LinearConfig::new(self.d_ff, self.d_model) + .with_initializer(self.initializer.clone()) + .init(device), + dropout: DropoutConfig::new(self.dropout).init(), + activation: self.activation.init(device), + } + } +} + +impl PositionWiseFeedForward { + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - tensor: `[batch_size, seq_length, d_model]` + /// - output: `[batch_size, seq_length, d_model]` + pub fn forward(&self, input: Tensor) -> Tensor { + let x = self.linear_inner.forward(input); + let x = self.activation.forward(x); + let x = self.dropout.forward(x); + + self.linear_outer.forward(x) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let config = PositionWiseFeedForwardConfig::new(2, 4); + let pwff = config.init(&Default::default()); + + assert_eq!( + alloc::format!("{pwff}"), + "PositionWiseFeedForward {d_model: 2, d_ff: 4, prob: 0.1, params: 22}" + ); + } +} diff --git a/crates/burn-nn/src/modules/unfold.rs b/crates/burn-nn/src/modules/unfold.rs new file mode 100644 index 0000000..c16fe9c --- /dev/null +++ b/crates/burn-nn/src/modules/unfold.rs @@ -0,0 +1,103 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; + +use burn::tensor::Tensor; +use burn::tensor::module::unfold4d; +use burn::tensor::ops::UnfoldOptions; + +/// Configuration to create an [unfold 4d](Unfold4d) layer using the [init function](Unfold4dConfig::init). +#[derive(Config, Debug)] +pub struct Unfold4dConfig { + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The stride of the convolution. + #[config(default = "[1, 1]")] + pub stride: [usize; 2], + /// Spacing between kernel elements. + #[config(default = "[1, 1]")] + pub dilation: [usize; 2], + /// The padding configuration. + #[config(default = "[0, 0]")] + pub padding: [usize; 2], +} + +/// Four-dimensional unfolding. +/// +/// Should be created with [Unfold4dConfig]. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Unfold4d { + /// The size of the kernel. + pub kernel_size: [usize; 2], + /// The stride of the convolution. + pub stride: [usize; 2], + /// Spacing between kernel elements. + pub dilation: [usize; 2], + /// The padding configuration. + pub padding: [usize; 2], +} + +impl ModuleDisplay for Unfold4d { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("kernel_size", &alloc::format!("{:?}", self.kernel_size)) + .add("stride", &alloc::format!("{:?}", self.stride)) + .add("dilation", &alloc::format!("{:?}", self.dilation)) + .add("padding", &alloc::format!("{:?}", self.padding)) + .optional() + } +} + +impl Unfold4dConfig { + /// Initializes a new [Unfold4d] module. + pub fn init(&self) -> Unfold4d { + Unfold4d { + kernel_size: self.kernel_size, + stride: self.stride, + dilation: self.dilation, + padding: self.padding, + } + } +} + +impl Unfold4d { + /// Applies the forward pass on the input tensor. + /// + /// See [unfold4d](burn::tensor::module::unfold4d) for more information. + /// + /// # Shapes + /// + /// input: `[batch_size, channels_in, height, width]` + /// returns: `[batch_size, channels_in * kernel_size_1 * kernel_size_2, number of blocks]` + pub fn forward(&self, input: Tensor<4>) -> Tensor<3> { + unfold4d( + input, + self.kernel_size, + UnfoldOptions::new(self.stride, self.padding, self.dilation), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + let config = Unfold4dConfig::new([3, 3]); + let unfold = config.init(); + + assert_eq!( + alloc::format!("{unfold}"), + "Unfold4d {kernel_size: [3, 3], stride: [1, 1], dilation: [1, 1], padding: [0, 0]}" + ); + } +} diff --git a/crates/burn-nn/src/padding.rs b/crates/burn-nn/src/padding.rs new file mode 100644 index 0000000..cfacfc6 --- /dev/null +++ b/crates/burn-nn/src/padding.rs @@ -0,0 +1,247 @@ +use burn_core as burn; + +use burn::config::Config; + +/// Calculate asymmetric padding for "same" convolution. +/// Returns (start_padding, end_padding) where start is applied first (top/left). +/// For odd total padding, the extra pad goes to the end (bottom/right) following ONNX convention. +fn calculate_same_padding(kernel_size: usize, stride: usize, size_in: usize) -> (usize, usize) { + let size_out = size_in.div_ceil(stride); // ceil division for same padding + let total_padding = if size_out > 0 { + let needed = (size_out - 1) * stride + kernel_size; + needed.saturating_sub(size_in) + } else { + 0 + }; + let pad_start = total_padding / 2; + let pad_end = total_padding - pad_start; + (pad_start, pad_end) +} + +/// Padding configuration for 1D operators. +#[derive(Config, Debug, PartialEq)] +pub enum PaddingConfig1d { + /// Dynamically calculates padding to ensure output size matches input size. + Same, + /// No padding applied. + Valid, + /// Applies explicit padding values. + /// Format: (left, right) + /// For symmetric padding, use the same value for both (e.g., `Explicit(1, 1)`). + Explicit(usize, usize), +} + +impl PaddingConfig1d { + /// Calculate padding as (left, right) pair for 1D operations. + /// For `Same` padding, this computes the actual asymmetric padding if needed. + pub(crate) fn calculate_padding_1d_pair( + &self, + length: usize, + kernel_size: usize, + stride: usize, + ) -> (usize, usize) { + match self { + Self::Valid => (0, 0), + Self::Same => calculate_same_padding(kernel_size, stride, length), + Self::Explicit(left, right) => (*left, *right), + } + } +} + +/// Padding configuration for 2D operators. +#[derive(Config, Debug, PartialEq)] +pub enum PaddingConfig2d { + /// Dynamically calculates padding to preserve input dimensions in output. + Same, + /// No padding applied. + Valid, + /// Applies explicit padding values. + /// Format: (top, left, bottom, right) + /// For symmetric padding, use matching values (e.g., `Explicit(1, 1, 1, 1)`). + Explicit(usize, usize, usize, usize), +} + +impl PaddingConfig2d { + /// Calculate padding as ((top, bottom), (left, right)) pairs for 2D operations. + /// For `Same` padding, this computes the actual asymmetric padding if needed. + pub(crate) fn calculate_padding_2d_pairs( + &self, + height: usize, + width: usize, + kernel_size: &[usize; 2], + stride: &[usize; 2], + ) -> ((usize, usize), (usize, usize)) { + match self { + Self::Valid => ((0, 0), (0, 0)), + Self::Same => { + let (top, bottom) = calculate_same_padding(kernel_size[0], stride[0], height); + let (left, right) = calculate_same_padding(kernel_size[1], stride[1], width); + ((top, bottom), (left, right)) + } + Self::Explicit(top, left, bottom, right) => ((*top, *bottom), (*left, *right)), + } + } + + /// Calculate symmetric padding for 2D operations. + /// Returns padding values [height, width] (same for both sides). + /// Panics if asymmetric padding is detected. + pub(crate) fn calculate_padding_2d( + &self, + height: usize, + width: usize, + kernel_size: &[usize; 2], + stride: &[usize; 2], + ) -> [usize; 2] { + let ((top, bottom), (left, right)) = + self.calculate_padding_2d_pairs(height, width, kernel_size, stride); + if top != bottom || left != right { + panic!("Asymmetric padding should be handled via calculate_padding_2d_pairs()") + } + [top, left] + } +} + +/// Padding configuration for 3D operators. +#[derive(Config, Debug, PartialEq)] +pub enum PaddingConfig3d { + /// Dynamically calculates padding to preserve input dimensions in output. + Same, + /// No padding applied. + Valid, + /// Applies explicit symmetric padding values. + /// Format: (depth, height, width) — same padding on both sides of each dimension. + Explicit(usize, usize, usize), +} + +impl PaddingConfig3d { + /// Calculate symmetric padding for 3D operations. + /// Returns padding values [depth, height, width] (same for both sides). + pub(crate) fn calculate_padding_3d( + &self, + depth: usize, + height: usize, + width: usize, + kernel_size: &[usize; 3], + stride: &[usize; 3], + ) -> [usize; 3] { + match self { + Self::Valid => [0, 0, 0], + Self::Same => { + let (front, back) = calculate_same_padding(kernel_size[0], stride[0], depth); + let (top, bottom) = calculate_same_padding(kernel_size[1], stride[1], height); + let (left, right) = calculate_same_padding(kernel_size[2], stride[2], width); + if front != back || top != bottom || left != right { + panic!( + "Asymmetric 3D 'Same' padding is not supported. \ + Use odd kernel sizes for symmetric padding." + ) + } + [front, top, left] + } + Self::Explicit(depth, height, width) => [*depth, *height, *width], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== PaddingConfig1d Tests ==================== + + #[test] + fn test_padding_config_1d_calculate_pair_valid() { + let padding = PaddingConfig1d::Valid; + assert_eq!(padding.calculate_padding_1d_pair(10, 3, 1), (0, 0)); + } + + #[test] + fn test_padding_config_1d_calculate_pair_explicit() { + let padding = PaddingConfig1d::Explicit(1, 2); + assert_eq!(padding.calculate_padding_1d_pair(10, 3, 1), (1, 2)); + } + + #[test] + fn test_padding_config_1d_calculate_pair_same() { + let padding = PaddingConfig1d::Same; + // kernel=3, stride=1, length=10: total=2, start=1, end=1 + assert_eq!(padding.calculate_padding_1d_pair(10, 3, 1), (1, 1)); + } + + // ==================== PaddingConfig2d Tests ==================== + + #[test] + fn test_padding_config_2d_calculate_pairs_valid() { + let padding = PaddingConfig2d::Valid; + assert_eq!( + padding.calculate_padding_2d_pairs(10, 10, &[3, 3], &[1, 1]), + ((0, 0), (0, 0)) + ); + } + + #[test] + fn test_padding_config_2d_calculate_pairs_explicit() { + let padding = PaddingConfig2d::Explicit(1, 2, 3, 4); + assert_eq!( + padding.calculate_padding_2d_pairs(10, 10, &[3, 3], &[1, 1]), + ((1, 3), (2, 4)) + ); + } + + #[test] + fn test_padding_config_2d_calculate_symmetric_valid() { + let padding = PaddingConfig2d::Valid; + assert_eq!( + padding.calculate_padding_2d(10, 10, &[3, 3], &[1, 1]), + [0, 0] + ); + } + + #[test] + fn test_padding_config_2d_calculate_symmetric_explicit() { + let padding = PaddingConfig2d::Explicit(2, 3, 2, 3); + assert_eq!( + padding.calculate_padding_2d(10, 10, &[3, 3], &[1, 1]), + [2, 3] + ); + } + + #[test] + #[should_panic( + expected = "Asymmetric padding should be handled via calculate_padding_2d_pairs" + )] + fn test_padding_config_2d_calculate_symmetric_asymmetric_panics() { + let padding = PaddingConfig2d::Explicit(1, 2, 3, 4); + let _ = padding.calculate_padding_2d(10, 10, &[3, 3], &[1, 1]); + } + + // ==================== PaddingConfig3d Tests ==================== + + #[test] + fn test_padding_config_3d_calculate_valid() { + let padding = PaddingConfig3d::Valid; + assert_eq!( + padding.calculate_padding_3d(10, 10, 10, &[3, 3, 3], &[1, 1, 1]), + [0, 0, 0] + ); + } + + #[test] + fn test_padding_config_3d_calculate_explicit() { + let padding = PaddingConfig3d::Explicit(1, 2, 3); + assert_eq!( + padding.calculate_padding_3d(10, 10, 10, &[3, 3, 3], &[1, 1, 1]), + [1, 2, 3] + ); + } + + #[test] + fn test_padding_config_3d_calculate_same_odd_kernel() { + let padding = PaddingConfig3d::Same; + // kernel=3, stride=1: total=2, symmetric (1,1) per dim + assert_eq!( + padding.calculate_padding_3d(10, 10, 10, &[3, 3, 3], &[1, 1, 1]), + [1, 1, 1] + ); + } +} diff --git a/crates/burn-nn/tests/quantize.rs b/crates/burn-nn/tests/quantize.rs new file mode 100644 index 0000000..1cde05a --- /dev/null +++ b/crates/burn-nn/tests/quantize.rs @@ -0,0 +1,162 @@ +use burn_core as burn; + +use burn::module::{Module, Quantizer}; +use burn::tensor::{ + Distribution, Tensor, Tolerance, + quantization::{Calibration, QuantLevel, QuantParam, QuantScheme, QuantValue}, +}; +use burn_nn::{ + LinearConfig, + transformer::{TransformerEncoder, TransformerEncoderConfig, TransformerEncoderInput}, +}; + +fn should_quantize_module Tensor>( + module: M, + scheme: QuantScheme, + func: F, + tolerance: Tolerance, +) { + let result = func(&module); + + let calibration = Calibration::MinMax; + let mut quantizer = Quantizer { + calibration, + scheme, + }; + let q_module = module.quantize_weights(&mut quantizer); + let q_result = func(&q_module); + + result + .into_data() + .assert_approx_eq::(&q_result.into_data(), tolerance); +} + +#[test] +fn should_quantize_transformer() { + let device = Default::default(); + let transformer: TransformerEncoder = + TransformerEncoderConfig::new(128, 256, 2, 2).init(&device); + let signal = Tensor::random([2, 32, 128], Distribution::Default, &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([32])) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.forward(TransformerEncoderInput::new(signal.clone())), + Tolerance::rel_abs(1e-2, 2e-2), // slightly higher abs tolerance (permissive: 1e-2) + ); +} + +#[test] +fn should_quantize_linear_128_256() { + let device = Default::default(); + let transformer = LinearConfig::new(128, 256).with_bias(false).init(&device); + let signal = Tensor::<2>::random([1, 128], Distribution::Default, &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Tensor) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.forward(signal.clone()), + Tolerance::permissive(), + ); +} + +#[test] +fn should_quantize_linear() { + let device = Default::default(); + let transformer = LinearConfig::new(32, 32).with_bias(false).init(&device); + let signal = Tensor::<2>::random([1, 32], Distribution::Default, &device); + // Default scheme should select supported QuantStore default + // TODO: set native if dtype is supported by the test backend + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Tensor) + // .with_store(QuantStore::Native) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.forward(signal.clone()), + Tolerance::permissive(), + ); +} + +#[test] +fn should_quantize_linear_weights() { + let device = Default::default(); + let transformer = LinearConfig::new(32, 32).with_bias(false).init(&device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::Tensor) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.weight.val().dequantize(), + Tolerance::permissive(), + ); +} + +#[test] +fn should_quantize_linear_blocks() { + let device = Default::default(); + let transformer = LinearConfig::new(32, 32).with_bias(false).init(&device); + let signal = Tensor::<2>::random([1, 32], Distribution::Default, &device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([16])) + // .with_store(QuantStore::Native) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.forward(signal.clone()), + Tolerance::permissive(), + ); +} + +#[test] +fn should_quantize_linear_weights_blocks() { + let device = Default::default(); + let transformer = LinearConfig::new(32, 32).with_bias(false).init(&device); + let scheme = device + .settings() + .quantization + .scheme + .with_value(QuantValue::Q8S) + .with_level(QuantLevel::block([16])) + // .with_store(QuantStore::Native) + .with_param(QuantParam::F32); + + should_quantize_module( + transformer, + scheme, + |tr| tr.weight.val().dequantize(), + Tolerance::permissive(), + ); +} diff --git a/crates/burn-no-std-tests/Cargo.toml b/crates/burn-no-std-tests/Cargo.toml new file mode 100644 index 0000000..d09e247 --- /dev/null +++ b/crates/burn-no-std-tests/Cargo.toml @@ -0,0 +1,26 @@ +[package] +authors = [ + "nathanielsimard ", + "Dilshod Tadjibaev (@antimora)", +] +edition.workspace = true +license.workspace = true +name = "burn-no-std-tests" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-no-std-tests" +version.workspace = true + +[lints] +workspace = true + +[features] +default = [] + +[dependencies] + +# ** Please make sure all dependencies support no_std ** + +burn = { workspace = true } +burn-flex = { workspace = true } + +burn-store = { workspace = true, features = ["safetensors", "burnpack"] } diff --git a/crates/burn-no-std-tests/README.md b/crates/burn-no-std-tests/README.md new file mode 100644 index 0000000..0dae942 --- /dev/null +++ b/crates/burn-no-std-tests/README.md @@ -0,0 +1,29 @@ +The `burn-no-std-tests` contains integration tests aimed to check `no_std` compatibility of `burn`, `burn-core`, `burn-tensor` and `burn-flex` packages. + +Currently there is only a minimal test that checks if mnist model can be built with `no_std`. More tests should be added to check completeness. + +The continuous integration (CI) should build with additional targets: + + * `wasm32-unknown-unknown` - WebAssembly + * `thumbv7m-none-eabi` - ARM Cortex-M3 + * `thumbv6m-none-eabi` - ARM Cortex-M0+ + +Shell commands to build and test the package: + +```sh + +# install the new targets if not installed previously +rustup target add thumbv6m-none-eabi +rustup target add thumbv7m-none-eabi +rustup target add wasm32-unknown-unknown + +# build for various targets +cargo build # regular build +cargo build --target thumbv7m-none-eabi +cargo build --target wasm32-unknown-unknown +RUSTFLAGS="--cfg portable_atomic_unsafe_assume_single_core" cargo build --target thumbv6m-none-eabi + +# test +cargo test + + ``` \ No newline at end of file diff --git a/crates/burn-no-std-tests/src/burnpack.rs b/crates/burn-no-std-tests/src/burnpack.rs new file mode 100644 index 0000000..76fb2dd --- /dev/null +++ b/crates/burn-no-std-tests/src/burnpack.rs @@ -0,0 +1,158 @@ +// Test Burnpack storage in no-std environment + +use burn::{ + module::Module, + nn, + tensor::{Device, Tensor}, +}; + +use burn_store::{BurnpackStore, ModuleSnapshot, PathFilter}; + +/// Simple model for testing Burnpack storage +#[derive(Module, Debug)] +pub struct TestModel { + linear1: nn::Linear, + linear2: nn::Linear, + batch_norm: nn::BatchNorm, +} + +impl TestModel { + pub fn new(device: &Device) -> Self { + Self { + linear1: nn::LinearConfig::new(10, 20).init(device), + linear2: nn::LinearConfig::new(20, 10).init(device), + batch_norm: nn::BatchNormConfig::new(10).init(device), + } + } + + pub fn forward(&self, x: Tensor<2>) -> Tensor<2> { + let x = self.linear1.forward(x); + let x = self.linear2.forward(x); + // Apply batch norm (expand to 3D, apply, then squeeze back) + let x: Tensor<3> = x.unsqueeze_dim(2); + let x = self.batch_norm.forward(x); + x.squeeze_dim(2) + } +} + +/// Test basic Burnpack save and load in no-std +pub fn test_burnpack_basic(device: &Device) { + // Create a model + let model = TestModel::new(device); + + // Save to bytes (no file I/O in no-std) + let mut save_store = BurnpackStore::from_bytes(None); + model + .save_into(&mut save_store) + .expect("Failed to save model"); + + // Get the serialized bytes + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load from bytes + let mut load_store = BurnpackStore::from_bytes(Some(bytes)); + let mut loaded_model = TestModel::new(device); + let result = loaded_model + .load_from(&mut load_store) + .expect("Failed to load model"); + + // Verify all tensors were loaded + assert!(result.is_success(), "Should have no errors"); + assert!(!result.applied.is_empty(), "Should have loaded tensors"); + + // Test that the model still works + let input = Tensor::<2>::ones([2, 10], device); + let _output = loaded_model.forward(input); +} + +/// Test Burnpack with filtering in no-std +pub fn test_burnpack_filtering(device: &Device) { + let model = TestModel::new(device); + + // Save only linear1 weights + let filter = PathFilter::new() + .with_full_path("linear1.weight") + .with_full_path("linear1.bias"); + let mut save_store = BurnpackStore::from_bytes(None).with_filter(filter); + model + .save_into(&mut save_store) + .expect("Failed to save filtered model"); + + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load with partial loading allowed + let mut load_store = BurnpackStore::from_bytes(Some(bytes)).allow_partial(true); + let mut partial_model = TestModel::new(device); + let result = partial_model + .load_from(&mut load_store) + .expect("Failed to load partial model"); + + // Verify that only linear1 was loaded + assert_eq!(result.applied.len(), 2, "Should have loaded 2 tensors"); + assert!(!result.missing.is_empty(), "Should have missing tensors"); +} + +/// Test Burnpack with metadata in no-std +pub fn test_burnpack_metadata(device: &Device) { + let model = TestModel::new(device); + + // Save with metadata + let mut save_store = BurnpackStore::from_bytes(None) + .metadata("version", "1.0.0") + .metadata("environment", "no-std") + .metadata("model_type", "test"); + model + .save_into(&mut save_store) + .expect("Failed to save model with metadata"); + + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load and verify it works + let mut load_store = BurnpackStore::from_bytes(Some(bytes)); + let mut loaded_model = TestModel::new(device); + let result = loaded_model + .load_from(&mut load_store) + .expect("Failed to load model with metadata"); + + assert!(result.is_success(), "Should load successfully"); +} + +// Note: Key remapping test is omitted as KeyRemapper requires std feature + +// Note: Regex filtering test is omitted as with_regex requires std feature + +/// Test Burnpack with match_all in no-std +pub fn test_burnpack_match_all(device: &Device) { + let model = TestModel::new(device); + + // Save with match_all (should save everything) + let mut save_store = BurnpackStore::from_bytes(None).match_all(); + model + .save_into(&mut save_store) + .expect("Failed to save model"); + + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load everything + let mut load_store = BurnpackStore::from_bytes(Some(bytes)); + let mut loaded_model = TestModel::new(device); + let result = loaded_model + .load_from(&mut load_store) + .expect("Failed to load model"); + + assert!(result.is_success(), "Should load successfully"); + // linear1 (weight, bias) + linear2 (weight, bias) + batch_norm (4 params) + assert_eq!(result.applied.len(), 8, "Should load all 8 tensors"); + assert!(result.missing.is_empty(), "Should have no missing tensors"); + assert!(result.unused.is_empty(), "Should have no unused tensors"); +} + +/// Run all Burnpack no-std tests +pub fn run_all_tests(device: &Device) { + test_burnpack_basic(device); + test_burnpack_filtering(device); + test_burnpack_metadata(device); + // test_burnpack_remapping requires KeyRemapper which needs std + // test_burnpack_regex_filter requires with_regex which needs std + test_burnpack_match_all(device); +} diff --git a/crates/burn-no-std-tests/src/conv.rs b/crates/burn-no-std-tests/src/conv.rs new file mode 100644 index 0000000..5262af8 --- /dev/null +++ b/crates/burn-no-std-tests/src/conv.rs @@ -0,0 +1,49 @@ +// Originally copied from the burn/examples/mnist package + +use burn::{ + config::Config, + module::Module, + nn, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct ConvBlock { + conv: nn::conv::Conv2d, + pool: nn::pool::MaxPool2d, + activation: nn::Gelu, +} + +#[derive(Config, Debug)] +pub struct ConvBlockConfig { + channels: [usize; 2], + #[config(default = "[3, 3]")] + kernel_size: [usize; 2], +} + +impl ConvBlock { + pub fn new(config: &ConvBlockConfig, device: &Device) -> Self { + let conv = nn::conv::Conv2dConfig::new(config.channels, config.kernel_size) + .with_padding(nn::PaddingConfig2d::Same) + .init(device); + let pool = nn::pool::MaxPool2dConfig::new(config.kernel_size) + .with_strides([1, 1]) + .with_padding(nn::PaddingConfig2d::Same) + .init(); + let activation = nn::Gelu::new(); + + Self { + conv, + pool, + activation, + } + } + + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let x = self.conv.forward(input.clone()); + let x = self.pool.forward(x); + let x = self.activation.forward(x); + + (x + input) / 2.0 + } +} diff --git a/crates/burn-no-std-tests/src/lib.rs b/crates/burn-no-std-tests/src/lib.rs new file mode 100644 index 0000000..58de35e --- /dev/null +++ b/crates/burn-no-std-tests/src/lib.rs @@ -0,0 +1,9 @@ +#![no_std] + +pub mod burnpack; +pub mod conv; +pub mod mlp; +pub mod model; +pub mod safetensors; + +extern crate alloc; diff --git a/crates/burn-no-std-tests/src/mlp.rs b/crates/burn-no-std-tests/src/mlp.rs new file mode 100644 index 0000000..17f78ce --- /dev/null +++ b/crates/burn-no-std-tests/src/mlp.rs @@ -0,0 +1,67 @@ +// Originally copied from the burn/examples/mnist package + +use alloc::vec::Vec; + +use burn::{ + config::Config, + module::Module, + nn, + tensor::{Device, Tensor}, +}; + +/// Configuration to create a [Multilayer Perceptron](Mlp) layer. +#[derive(Config, Debug)] +pub struct MlpConfig { + /// The number of layers. + #[config(default = 3)] + pub num_layers: usize, + /// The dropout rate. + #[config(default = 0.5)] + pub dropout: f64, + /// The size of each layer. + #[config(default = 256)] + pub d_model: usize, +} + +/// Multilayer Perceptron module. +#[derive(Module, Debug)] +pub struct Mlp { + linears: Vec, + dropout: nn::Dropout, + activation: nn::Relu, +} + +impl Mlp { + /// Create the module from the given configuration. + pub fn new(config: &MlpConfig, device: &Device) -> Self { + let mut linears = Vec::with_capacity(config.num_layers); + + for _ in 0..config.num_layers { + linears.push(nn::LinearConfig::new(config.d_model, config.d_model).init(device)); + } + + Self { + linears, + dropout: nn::DropoutConfig::new(0.3).init(), + activation: nn::Relu::new(), + } + } + + /// Applies the forward pass on the input tensor. + /// + /// # Shapes + /// + /// - input: `[batch_size, d_model]` + /// - output: `[batch_size, d_model]` + pub fn forward(&self, input: Tensor<2>) -> Tensor<2> { + let mut x = input; + + for linear in self.linears.iter() { + x = linear.forward(x); + x = self.dropout.forward(x); + x = self.activation.forward(x); + } + + x + } +} diff --git a/crates/burn-no-std-tests/src/model.rs b/crates/burn-no-std-tests/src/model.rs new file mode 100644 index 0000000..ecf3b78 --- /dev/null +++ b/crates/burn-no-std-tests/src/model.rs @@ -0,0 +1,66 @@ +// Originally copied from the burn/examples/mnist package + +use crate::{ + conv::{ConvBlock, ConvBlockConfig}, + mlp::{Mlp, MlpConfig}, +}; + +use burn::{ + config::Config, + module::Module, + nn, + tensor::{Device, Tensor}, +}; + +#[derive(Config, Debug)] +pub struct MnistConfig { + #[config(default = 42)] + pub seed: u64, + + pub mlp: MlpConfig, + + #[config(default = 784)] + pub input_size: usize, + + #[config(default = 10)] + pub output_size: usize, +} + +#[derive(Module, Debug)] +pub struct Model { + mlp: Mlp, + conv: ConvBlock, + input: nn::Linear, + output: nn::Linear, + num_classes: usize, +} + +impl Model { + pub fn new(config: &MnistConfig, device: &Device) -> Self { + let mlp = Mlp::new(&config.mlp, device); + let input = nn::LinearConfig::new(config.input_size, config.mlp.d_model).init(device); + let output = nn::LinearConfig::new(config.mlp.d_model, config.output_size).init(device); + let conv = ConvBlock::new(&ConvBlockConfig::new([1, 1]), device); + + Self { + mlp, + conv, + output, + input, + num_classes: config.output_size, + } + } + + pub fn forward(&self, input: Tensor<3>) -> Tensor<2> { + let [batch_size, height, width] = input.dims(); + + let x = input.reshape([batch_size, 1, height, width]).detach(); + let x = self.conv.forward(x); + let x = x.reshape([batch_size, height * width]); + + let x = self.input.forward(x); + let x = self.mlp.forward(x); + + self.output.forward(x) + } +} diff --git a/crates/burn-no-std-tests/src/safetensors.rs b/crates/burn-no-std-tests/src/safetensors.rs new file mode 100644 index 0000000..06bc554 --- /dev/null +++ b/crates/burn-no-std-tests/src/safetensors.rs @@ -0,0 +1,111 @@ +// Test SafeTensors storage in no-std environment + +use burn::{ + module::Module, + nn, + tensor::{Device, Tensor}, +}; + +use burn_store::{ModuleSnapshot, SafetensorsStore}; + +/// Simple model for testing SafeTensors storage +#[derive(Module, Debug)] +pub struct TestModel { + linear1: nn::Linear, + linear2: nn::Linear, +} + +impl TestModel { + pub fn new(device: &Device) -> Self { + Self { + linear1: nn::LinearConfig::new(10, 20).init(device), + linear2: nn::LinearConfig::new(20, 10).init(device), + } + } + + pub fn forward(&self, x: Tensor<2>) -> Tensor<2> { + let x = self.linear1.forward(x); + self.linear2.forward(x) + } +} + +/// Test basic SafeTensors save and load in no-std +pub fn test_safetensors_basic(device: &Device) { + // Create a model + let model = TestModel::new(device); + + // Save to bytes (no file I/O in no-std) + let mut save_store = SafetensorsStore::from_bytes(None); + model + .save_into(&mut save_store) + .expect("Failed to save model"); + + // Get the serialized bytes + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load from bytes + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = TestModel::new(device); + loaded_model + .load_from(&mut load_store) + .expect("Failed to load model"); + + // Test that the model still works + let input = Tensor::<2>::ones([2, 10], device); + let _output = loaded_model.forward(input); +} + +/// Test SafeTensors with filtering in no-std +pub fn test_safetensors_filtering(device: &Device) { + let model = TestModel::new(device); + + // Save only linear1 weights + let mut save_store = SafetensorsStore::from_bytes(None) + .with_full_path("linear1.weight") + .with_full_path("linear1.bias"); + model + .save_into(&mut save_store) + .expect("Failed to save filtered model"); + + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load with partial loading allowed + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)).allow_partial(true); + let mut partial_model = TestModel::new(device); + let result = partial_model + .load_from(&mut load_store) + .expect("Failed to load partial model"); + + // Verify that only linear1 was loaded + assert_eq!(result.applied.len(), 2, "Should have loaded 2 tensors"); + assert!(!result.missing.is_empty(), "Should have missing tensors"); +} + +/// Test SafeTensors with metadata in no-std +pub fn test_safetensors_metadata(device: &Device) { + let model = TestModel::new(device); + + // Save with metadata + let mut save_store = SafetensorsStore::from_bytes(None) + .metadata("version", "1.0.0") + .metadata("environment", "no-std"); + model + .save_into(&mut save_store) + .expect("Failed to save model with metadata"); + + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load and verify it works + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = TestModel::new(device); + loaded_model + .load_from(&mut load_store) + .expect("Failed to load model with metadata"); +} + +/// Run all SafeTensors no-std tests +pub fn run_all_tests(device: &Device) { + test_safetensors_basic(device); + test_safetensors_filtering(device); + test_safetensors_metadata(device); +} diff --git a/crates/burn-no-std-tests/tests/burnpack_tests.rs b/crates/burn-no-std-tests/tests/burnpack_tests.rs new file mode 100644 index 0000000..3e174f2 --- /dev/null +++ b/crates/burn-no-std-tests/tests/burnpack_tests.rs @@ -0,0 +1,10 @@ +extern crate alloc; + +#[test] +fn test_burnpack_no_std() { + use burn_no_std_tests::burnpack; + let device = Default::default(); + + // Run all Burnpack tests + burnpack::run_all_tests(&device); +} diff --git a/crates/burn-no-std-tests/tests/safetensors_tests.rs b/crates/burn-no-std-tests/tests/safetensors_tests.rs new file mode 100644 index 0000000..3f3484c --- /dev/null +++ b/crates/burn-no-std-tests/tests/safetensors_tests.rs @@ -0,0 +1,10 @@ +extern crate alloc; + +#[test] +fn test_safetensors_no_std() { + use burn_no_std_tests::safetensors; + let device = Default::default(); + + // Run all SafeTensors tests + safetensors::run_all_tests(&device); +} diff --git a/crates/burn-no-std-tests/tests/test_integration.rs b/crates/burn-no-std-tests/tests/test_integration.rs new file mode 100644 index 0000000..9839a75 --- /dev/null +++ b/crates/burn-no-std-tests/tests/test_integration.rs @@ -0,0 +1,28 @@ +#![no_std] // Must keep it for testing + +use burn_no_std_tests::mlp::*; +use burn_no_std_tests::model::*; + +use burn::tensor::{Distribution, Tensor}; + +#[test] +fn test_mnist_model_with_random_input() { + // Model configurations + let device = Default::default(); + let mlp_config = MlpConfig::new(); + let mnist_config = MnistConfig::new(mlp_config); + let mnist_model = Model::new(&mnist_config, &device); + + // Pass a fixed seed for random, otherwise a build generated random seed is used + device.seed(mnist_config.seed); + + // Some random input + let input_shape = [1, 28, 28]; + let input = Tensor::<3>::random(input_shape, Distribution::Default, &device); + + // Run through the model + let output = mnist_model.forward(input); + + assert_eq!(&*output.shape(), [1, 10]); + assert!(output.to_data().iter::().all(|x| x <= 1.0)); +} diff --git a/crates/burn-optim/Cargo.toml b/crates/burn-optim/Cargo.toml new file mode 100644 index 0000000..68bdf71 --- /dev/null +++ b/crates/burn-optim/Cargo.toml @@ -0,0 +1,52 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Optimizer building blocks for the Burn deep learning framework" +documentation = "https://docs.rs/burn-optim" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-optim" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-optim" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "burn-core/default"] +doc = [ + "std", + # Doc features + "burn-core/doc", +] +std = ["burn-core/std", "burn-pack/std", "num-traits/std", "serde/std", "log"] +tracing = ["burn-core/tracing"] + +# collective = ["burn-collective"] + +[dependencies] + +# ** Please make sure all dependencies support no_std when std is disabled ** +burn-core = { workspace = true, features = ["autodiff", "extension"] } +burn-derive = { workspace = true } +burn-pack = { workspace = true, default-features = false } + +num-traits = { workspace = true } +derive-new = { workspace = true } +log = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } + +# The same implementation of HashMap in std but with no_std support (only alloc crate is needed) +hashbrown = { workspace = true, features = ["serde"] } # no_std compatible + +[dev-dependencies] +burn-nn = { workspace = true, features = ["default"] } +burn-flex = { workspace = true, features = ["default"] } +burn-autodiff = { workspace = true, features = ["default"] } +rstest = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-optim/README.md b/crates/burn-optim/README.md new file mode 100644 index 0000000..84e45eb --- /dev/null +++ b/crates/burn-optim/README.md @@ -0,0 +1,3 @@ +# Burn Optimizers + +Core building blocks for Burn optimizers. \ No newline at end of file diff --git a/crates/burn-optim/src/grad_clipping/base.rs b/crates/burn-optim/src/grad_clipping/base.rs new file mode 100644 index 0000000..e76fbd5 --- /dev/null +++ b/crates/burn-optim/src/grad_clipping/base.rs @@ -0,0 +1,139 @@ +use burn_core as burn; + +use burn::{config::Config, tensor::Tensor}; + +/// Gradient Clipping provides a way to mitigate exploding gradients +#[derive(Config, Debug)] +pub enum GradientClippingConfig { + /// Clip the gradient by value. + Value(f32), + + /// Clip the gradient by norm. + Norm(f32), +} + +impl GradientClippingConfig { + /// Initialize the gradient clipping. + /// + /// # Returns + /// + /// The gradient clipping. + pub fn init(&self) -> GradientClipping { + match self { + GradientClippingConfig::Value(val) => GradientClipping::Value(*val), + GradientClippingConfig::Norm(val) => GradientClipping::Norm(*val), + } + } +} + +/// Gradient Clipping provides a way to mitigate exploding gradients +/// by clipping every component of the gradient by value or by norm during +/// backpropagation. +#[derive(Clone)] +pub enum GradientClipping { + /// Clip the gradient by value. + Value(f32), + + /// Clip the gradient by norm. + Norm(f32), +} + +impl GradientClipping { + /// Clip the gradient. + /// + /// # Arguments + /// + /// * `grad` - The gradient to clip. + /// + /// # Returns + /// + /// The clipped gradient. + pub fn clip_gradient(&self, grad: Tensor) -> Tensor { + match self { + GradientClipping::Value(threshold) => self.clip_by_value(grad, *threshold), + GradientClipping::Norm(max_norm) => self.clip_by_norm(grad, *max_norm), + } + } + + fn clip_by_value(&self, grad: Tensor, threshold: f32) -> Tensor { + let greater_mask = grad.clone().greater_elem(threshold); + let lower_mask = grad.clone().lower_elem(-threshold); + + let clipped_grad = grad.mask_fill(greater_mask, threshold); + + clipped_grad.mask_fill(lower_mask, -threshold) + } + + fn clip_by_norm(&self, grad: Tensor, threshold: f32) -> Tensor { + let norm = Self::l2_norm(grad.clone()); + let min_positive = grad + .dtype() + .finfo() + .unwrap_or(burn::tensor::FloatDType::F32.finfo()) + .min_positive; + let clip_coef = threshold / norm.add_scalar(min_positive); + let clip_coef_clamped = clip_coef.clamp_max(1.0); + grad.mul(clip_coef_clamped.unsqueeze()) + } + + fn l2_norm(tensor: Tensor) -> Tensor<1> { + let squared = tensor.square(); + let sum = squared.sum(); + sum.sqrt() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tensor; + + #[test] + fn test_clip_by_value() { + let gradient: Tensor<2> = Tensor::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &Default::default(), + ); + + let clipped_gradient = GradientClipping::Value(0.5).clip_gradient(gradient); + let clipped_gradient_data = clipped_gradient.into_data(); + + for value in clipped_gradient_data.iter::() { + assert!(value <= 0.5); + } + } + + #[test] + fn test_clip_by_norm() { + let gradient: Tensor<2> = Tensor::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &Default::default(), + ); + + let clipped_gradient = GradientClipping::Norm(2.2).clip_gradient(gradient); + let clipped_gradient_data = clipped_gradient.into_data(); + + for value in clipped_gradient_data.iter::() { + assert!(value <= 0.88); + } + } + #[test] + fn test_clip_by_norm_no_clipping() { + let gradient: Tensor<2> = Tensor::from_floats( + [[0.3, 0.4, 0.5, 0.2], [0.1, 0.6, 0.3, 0.4]], + &Default::default(), + ); + + let clipped_gradient = GradientClipping::Norm(2.2).clip_gradient(gradient.clone()); + + clipped_gradient + .into_data() + .assert_eq(&gradient.into_data(), true); + } +} diff --git a/crates/burn-optim/src/grad_clipping/mod.rs b/crates/burn-optim/src/grad_clipping/mod.rs new file mode 100644 index 0000000..096c94e --- /dev/null +++ b/crates/burn-optim/src/grad_clipping/mod.rs @@ -0,0 +1,2 @@ +mod base; +pub use base::*; diff --git a/crates/burn-optim/src/lib.rs b/crates/burn-optim/src/lib.rs new file mode 100644 index 0000000..9a24cb3 --- /dev/null +++ b/crates/burn-optim/src/lib.rs @@ -0,0 +1,28 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "256"] + +//! Burn optimizers. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; + +/// Optimizer module. +mod optim; +pub use optim::*; + +/// Gradient clipping module. +pub mod grad_clipping; + +/// Learning rate scheduler module. +#[cfg(feature = "std")] +pub mod lr_scheduler; + +/// Type alias for the learning rate. +/// +/// LearningRate also implements [learning rate scheduler](crate::lr_scheduler::LrScheduler) so it +/// can be used for constant learning rate. +pub type LearningRate = f64; // We could potentially change the type. diff --git a/crates/burn-optim/src/lr_scheduler/base.rs b/crates/burn-optim/src/lr_scheduler/base.rs new file mode 100644 index 0000000..b2a9152 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/base.rs @@ -0,0 +1,312 @@ +use std::fmt::Debug; + +use alloc::collections::BTreeMap; +pub(super) use alloc::string::String; +use alloc::vec::Vec; +use burn_core as burn; +use burn_core::config::Config; + +use crate::lr_scheduler::composed::ComposedLrSchedulerConfig; +use crate::lr_scheduler::cosine::CosineAnnealingLrSchedulerConfig; +use crate::lr_scheduler::exponential::ExponentialLrSchedulerConfig; +use crate::lr_scheduler::linear::LinearLrSchedulerConfig; +use crate::lr_scheduler::noam::NoamLrSchedulerConfig; +use crate::lr_scheduler::step::StepLrSchedulerConfig; +use crate::{RecordState, StateSink, StateSource, join_path}; +use burn::store::RecordError; +use burn::tensor::{Bytes, Device}; +use burn_pack::{Reader, Scalar, Writer}; + +use crate::LearningRate; + +macro_rules! impl_from_for_scheduler { + ($($variant:ident($config:ident)),* $(,)?) => { + $( + impl From<$config> for LrSchedulerConfig { + fn from(config: $config) -> Self { + LrSchedulerConfig::$variant(config) + } + } + )* + }; +} + +/// Learning rate scheduler defines how the learning rate will evolve during training. +pub trait LrScheduler: LrSchedulerClone + Send + Sync { + /// Perform the scheduler step, potentially updating its state, and returning the effective + /// learning rate. + fn step(&mut self) -> LearningRate; + + /// Get the current state of the scheduler as a [record](LrSchedulerRecord). + fn to_record(&self) -> LrSchedulerRecord; + + /// Load the state of the scheduler from a [record](LrSchedulerRecord). + fn load_record(&mut self, record: LrSchedulerRecord); +} + +/// Implements the clone of a boxed [`LrScheduler`]. +pub trait LrSchedulerClone { + /// Clones a boxed [`LrScheduler`]. + fn clone_box(&self) -> Box; +} + +impl LrSchedulerClone for T +where + T: 'static + LrScheduler + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Box { + self.as_ref().clone_box() + } +} + +/// A wrapper over a dynamic [`LrScheduler`]. +#[derive(Clone)] +pub struct DynLrScheduler { + scheduler: Box, +} + +impl DynLrScheduler { + /// Perform the scheduler step, potentially updating its state, and returning the effective + /// learning rate. + pub fn step(&mut self) -> LearningRate { + self.scheduler.step() + } + + /// Get the current state of the scheduler as a [record](LrSchedulerRecord). + pub fn to_record(&self) -> LrSchedulerRecord { + self.scheduler.to_record() + } + + /// Load the state of the scheduler from a [record](LrSchedulerRecord). + pub fn load_record(mut self, record: LrSchedulerRecord) -> Self { + self.scheduler.load_record(record); + self + } +} + +impl From for DynLrScheduler +where + S: LrScheduler + 'static, +{ + fn from(scheduler: S) -> Self { + Self { + scheduler: Box::new(scheduler), + } + } +} + +/// The serialized state of a [learning rate scheduler](LrScheduler), stored in the +/// [burnpack](burn_pack) format. +/// +/// Scheduler state is just a handful of scalars (step counters, current learning rate), so the +/// record holds named typed scalars and no tensors. Composed schedulers nest their children's +/// records under an index prefix via [`with_record`](Self::with_record) / [`record`](Self::record). +#[derive(Default, Clone, Debug)] +pub struct LrSchedulerRecord { + scalars: BTreeMap, +} + +impl LrSchedulerRecord { + /// Create an empty record. + pub fn new() -> Self { + Self::default() + } + + /// Whether the record holds no scalars. + pub fn is_empty(&self) -> bool { + self.scalars.is_empty() + } + + /// Store a scalar under `key`. + pub fn with_scalar>(mut self, key: &str, value: V) -> Self { + self.scalars.insert(String::from(key), value.into()); + self + } + + /// Read the scalar stored under `key`, if present and of a compatible type. + pub fn scalar>(&self, key: &str) -> Option { + self.scalars + .get(key) + .copied() + .and_then(|scalar| V::try_from(scalar).ok()) + } + + /// Merge a child `record`'s scalars under `prefix` (used to compose schedulers). + pub fn with_record(mut self, prefix: &str, record: LrSchedulerRecord) -> Self { + for (key, value) in record.scalars { + self.scalars.insert(join_path(prefix, &key), value); + } + self + } + + /// Extract the child record previously merged under `prefix`. + pub fn record(&self, prefix: &str) -> LrSchedulerRecord { + let head = join_path(prefix, ""); + let scalars = self + .scalars + .iter() + .filter_map(|(key, value)| { + key.strip_prefix(&head) + .map(|stripped| (String::from(stripped), *value)) + }) + .collect(); + LrSchedulerRecord { scalars } + } + + /// Build a record from a scheduler [state](RecordState). + /// + /// Scheduler state is scalar-only, so this reuses the same [`RecordState`] decomposition as + /// optimizer states (it panics in debug builds if a tensor leaf is produced). + pub fn from_state(state: &S) -> Self { + let mut sink = StateSink::default(); + state.state_flatten("", &mut sink); + debug_assert!( + sink.tensors.is_empty(), + "learning rate scheduler state is expected to be scalar-only" + ); + Self { + scalars: sink.scalars.into_iter().collect(), + } + } + + /// Reconstruct a scheduler [state](RecordState) from this record. + /// + /// Uses the default device; scheduler state is scalar-only so no tensor is ever allocated. + pub fn into_state(&self) -> Option { + let mut source = StateSource::new(self.scalars.clone()); + S::state_unflatten("", &mut source, &Device::default()) + } + + /// Serialize the record to an in-memory burnpack byte buffer. + pub fn into_bytes(self) -> Result { + Ok(self.into_writer().into_bytes()?) + } + + /// Reconstruct a record from an in-memory burnpack byte buffer. + pub fn from_bytes(bytes: Bytes) -> Result { + let reader = Reader::from_bytes(bytes)?; + Ok(Self { + scalars: reader.scalars().clone(), + }) + } + + /// Save the record to a burnpack file on disk. + #[cfg(feature = "std")] + pub fn save>(self, path: P) -> Result<(), RecordError> { + self.into_writer().write_to_file(path)?; + Ok(()) + } + + /// Load the record from a burnpack file on disk. + #[cfg(feature = "std")] + pub fn load>(path: P) -> Result { + let reader = Reader::from_file(path)?; + Ok(Self { + scalars: reader.scalars().clone(), + }) + } + + fn into_writer(self) -> Writer { + let mut writer = Writer::new(Vec::new()); + for (key, value) in &self.scalars { + writer = writer.with_scalar(key, *value); + } + writer + } +} + +/// An enum for possible learning rate scheduler configs. +#[derive(Config, Debug)] +pub enum LrSchedulerConfig { + /// A constant learning rate. + Constant(LearningRate), + /// A [`LinearLrSchedulerConfig`] + Linear(LinearLrSchedulerConfig), + /// A [`CosineAnnealingLrSchedulerConfig`] + Cosine(CosineAnnealingLrSchedulerConfig), + /// A [`ExponentialLrSchedulerConfig`] + Exponential(ExponentialLrSchedulerConfig), + /// A [`NoamLrSchedulerConfig`] + Noam(NoamLrSchedulerConfig), + /// A [`StepLrSchedulerConfig`] + Step(StepLrSchedulerConfig), + /// A [`ComposedLrSchedulerConfig`] + Composed(ComposedLrSchedulerConfig), +} + +impl_from_for_scheduler!( + Constant(LearningRate), + Linear(LinearLrSchedulerConfig), + Cosine(CosineAnnealingLrSchedulerConfig), + Exponential(ExponentialLrSchedulerConfig), + Noam(NoamLrSchedulerConfig), + Step(StepLrSchedulerConfig), + Composed(ComposedLrSchedulerConfig), +); + +#[cfg(test)] +pub(super) mod test_utils { + use super::*; + + // A small tolerance for learning rate comparisons. Depending on how learning rates are + // computed, floating-point arithmetic error might exceed f64::EPSILON, so a larger value is + // used here. + const LOOSE_EPSILON: LearningRate = 1e-10; + + pub fn check_lr_sequence(mut scheduler: S, expected_lrs: I) + where + I: IntoIterator, + S: LrScheduler, + { + expected_lrs + .into_iter() + .enumerate() + .for_each(|(i, expected)| { + let lr = scheduler.step(); + assert!( + (lr - expected).abs() < LOOSE_EPSILON, + "Scheduled learning rate {lr} is not approximately equal to the expected value \ + {expected} at step {i}", + ); + }); + } + + // save_at_step is the number of steps to run the scheduler before saving and loading back its + // state. + pub fn check_save_load(mut scheduler: S, save_at_step: usize) + where + S: Clone + LrScheduler, + { + let mut truth = scheduler.clone(); + // Consume some steps before saving and loading back + (0..save_at_step).for_each(|_| { + truth.step(); + scheduler.step(); + }); + let rec = scheduler.to_record(); + scheduler.load_record(rec); + + // Validate that the scheduler resumes from where it left off. + compare_steps(&mut scheduler, &mut truth, save_at_step); + } + + // Check if two schedulers produce the same learning rate sequences over the specified number of + // steps. + pub fn compare_steps(a: &mut S, b: &mut S, num_steps: usize) { + (0..num_steps).for_each(|i| { + let lr_a = a.step(); + let lr_b = b.step(); + assert!( + (lr_a - lr_b).abs() < LOOSE_EPSILON, + "The two learning rates ({lr_a}, {lr_b}) at position {i} in the remaining \ + sequences are not approximately equal", + ); + }); + } +} diff --git a/crates/burn-optim/src/lr_scheduler/composed.rs b/crates/burn-optim/src/lr_scheduler/composed.rs new file mode 100644 index 0000000..b843e79 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/composed.rs @@ -0,0 +1,162 @@ +use burn_core::{self as burn}; + +use super::cosine::CosineAnnealingLrSchedulerConfig; +use super::exponential::ExponentialLrSchedulerConfig; +use super::linear::LinearLrSchedulerConfig; +use super::noam::NoamLrSchedulerConfig; +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::LearningRate; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; +use crate::lr_scheduler::step::StepLrSchedulerConfig; +use crate::lr_scheduler::{DynLrScheduler, LrSchedulerConfig}; + +use burn::config::Config; + +/// Compose multiple [learning rate schedulers](LrScheduler) together. +#[derive(Config, Debug)] +pub struct ComposedLrSchedulerConfig { + #[config(default = "Vec::new()")] + schedulers: Vec, + #[config(default = "SchedulerReduction::Prod")] + reduction: SchedulerReduction, +} + +/// Compose multiple [learning rate schedulers](LrScheduler) together. +#[derive(Clone)] +pub struct ComposedLrScheduler { + schedulers: Vec, + reduction: SchedulerReduction, +} + +/// Defines how the learning rates generated by the schedulers are combined. +#[derive(Config, Debug, Copy)] +pub enum SchedulerReduction { + /// All learning rates are averaged. + Avg, + /// All learning rates are summed. + Sum, + /// All learning rates are multiplied. + Prod, +} + +impl ComposedLrSchedulerConfig { + /// Initialize the learning rate scheduler. + pub(crate) fn build(&self) -> Result { + let mut schedulers: Vec = Vec::with_capacity(self.schedulers.len()); + for config in self.schedulers.iter() { + let config = match config { + LrSchedulerConfig::Constant(lr) => (*lr).into(), + LrSchedulerConfig::Linear(config) => config.build()?.into(), + LrSchedulerConfig::Cosine(config) => config.build()?.into(), + LrSchedulerConfig::Exponential(config) => config.build()?.into(), + LrSchedulerConfig::Noam(config) => config.build()?.into(), + LrSchedulerConfig::Step(config) => config.build()?.into(), + LrSchedulerConfig::Composed(config) => config.build()?.into(), + }; + schedulers.push(config); + } + + Ok(ComposedLrScheduler { + schedulers, + reduction: self.reduction, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } + + /// Appends a [constant learning rate](crate::lr_scheduler::constant::ConstantLr). + pub fn constant(mut self, lr: LearningRate) -> Self { + self.schedulers.push(LrSchedulerConfig::Constant(lr)); + self + } + + /// Appends a [linear scheduler](crate::lr_scheduler::linear::LinearLrScheduler). + pub fn linear(mut self, config: LinearLrSchedulerConfig) -> Self { + self.schedulers.push(LrSchedulerConfig::Linear(config)); + self + } + + /// Appends a [cosine scheduler](ComposedLrSchedulerConfig). + pub fn cosine(mut self, config: CosineAnnealingLrSchedulerConfig) -> Self { + self.schedulers.push(LrSchedulerConfig::Cosine(config)); + self + } + + /// Appends an [exponential scheduler](crate::lr_scheduler::exponential::ExponentialLrScheduler). + pub fn exponential(mut self, config: ExponentialLrSchedulerConfig) -> Self { + self.schedulers.push(LrSchedulerConfig::Exponential(config)); + self + } + + /// Appends a [noam scheduler](crate::lr_scheduler::noam::NoamLrScheduler). + pub fn noam(mut self, config: NoamLrSchedulerConfig) -> Self { + self.schedulers.push(LrSchedulerConfig::Noam(config)); + self + } + + /// Appends a [step scheduler](crate::lr_scheduler::step::StepLrScheduler). + pub fn step(mut self, config: StepLrSchedulerConfig) -> Self { + self.schedulers.push(LrSchedulerConfig::Step(config)); + self + } + + /// Appends a [composed scheduler](ComposedLrScheduler). + pub fn composed(mut self, config: Self) -> Self { + self.schedulers.push(LrSchedulerConfig::Composed(config)); + self + } +} + +impl ComposedLrScheduler { + /// Add a custom learning rate scheduler to existing ones. + pub fn with_custom_scheduler(mut self, scheduler: S) -> Self { + self.schedulers.push(scheduler.into()); + self + } +} + +impl LrScheduler for ComposedLrScheduler { + fn step(&mut self) -> LearningRate { + let mut step = match self.reduction { + SchedulerReduction::Avg => 0.0, + SchedulerReduction::Sum => 0.0, + SchedulerReduction::Prod => 1.0, + }; + let num_scheduler = self.schedulers.len() as f64; + + for lr in self.schedulers.iter_mut().map(|s| s.step()) { + step = match self.reduction { + SchedulerReduction::Avg => step + (lr / num_scheduler), + SchedulerReduction::Sum => step + lr, + SchedulerReduction::Prod => step * lr, + } + } + + step + } + + fn to_record(&self) -> LrSchedulerRecord { + let mut record = LrSchedulerRecord::new(); + for (index, item) in self.schedulers.iter().enumerate() { + let sub = item.to_record(); + record = record.with_record(&index.to_string(), sub); + } + record + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + self.schedulers = self + .schedulers + .clone() + .iter_mut() + .enumerate() + .map(|(index, item)| { + let sub = record.record(&index.to_string()); + item.clone().load_record(sub) + }) + .collect(); + } +} diff --git a/crates/burn-optim/src/lr_scheduler/constant.rs b/crates/burn-optim/src/lr_scheduler/constant.rs new file mode 100644 index 0000000..3e87836 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/constant.rs @@ -0,0 +1,42 @@ +use super::{LrScheduler, LrSchedulerRecord}; +use crate::LearningRate; + +/// Constant learning rate implementing [learning rate scheduler](LrScheduler). +/// +/// # Notes +/// +/// You can also use [learning rate](LearningRate) which the same effect. +#[derive(new, Clone, Debug)] +pub struct ConstantLr { + lr: LearningRate, +} + +impl From for ConstantLr { + fn from(lr: LearningRate) -> Self { + Self { lr } + } +} + +impl LrScheduler for ConstantLr { + fn step(&mut self) -> LearningRate { + self.lr + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::new() + } + + fn load_record(&mut self, _record: LrSchedulerRecord) {} +} + +impl LrScheduler for LearningRate { + fn step(&mut self) -> LearningRate { + *self + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::new() + } + + fn load_record(&mut self, _record: LrSchedulerRecord) {} +} diff --git a/crates/burn-optim/src/lr_scheduler/cosine.rs b/crates/burn-optim/src/lr_scheduler/cosine.rs new file mode 100644 index 0000000..76c9b00 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/cosine.rs @@ -0,0 +1,204 @@ +use burn_core as burn; + +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::LearningRate; +use crate::RecordState; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; +use burn::config::Config; + +/// The configuration for creating a [Cosine Annealing learning rate scheduler with warm +/// restarts](CosineAnnealingLrScheduler). +/// +/// This scheduler returns the learning rate `initial_lr` at the first step, then changes it by +/// following a cosine function. After `num_iters` iterations, the learning rate is reset to +/// `initial_lr`. +#[derive(Config, Debug)] +pub struct CosineAnnealingLrSchedulerConfig { + // The initial learning rate. + initial_lr: LearningRate, + // The final learning rate. + #[config(default = 0.0)] + min_lr: LearningRate, + // The number of iterations between two restarts. The two restart iterations themselves are not + // included. + num_iters: usize, +} + +impl CosineAnnealingLrSchedulerConfig { + /// Initializes a [Cosine learning rate scheduler](CosineAnnealingLrScheduler). + pub(crate) fn build(&self) -> Result { + if self.initial_lr <= 0. || self.initial_lr > 1. { + return Err("Initial learning rate must be greater than 0 and at most 1".into()); + } + if self.min_lr < 0.0 || self.min_lr > self.initial_lr { + return Err( + "Minimum learning rate must be at least 0 and at most equal to the initial \ + learning rate" + .into(), + ); + } + if self.num_iters == 0 { + return Err("Number of iterations must be at least 1".into()); + } + + Ok(CosineAnnealingLrScheduler { + min_lr: self.min_lr, + max_lr: self.initial_lr, + num_iters: self.num_iters, + current_iter: usize::MAX, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + /// + /// # Errors + /// + /// An error will be returned if any of the following conditions is true: + /// + /// * `initial_lr` is out of range (0.0, 1.0] + /// * `min_lr` is out of range [0.0, `initial_lr`] + /// * `num_iters` is 0 + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } +} + +/// A Cosine Annealing learning rate scheduler. +/// +/// This scheduler is described in [SGDR: Stochastic Gradient Descent with Warm +/// Restarts](https://arxiv.org/abs/1608.03983). See [CosineAnnealingLrSchedulerConfig] for more +/// information. +#[derive(Clone, Copy, Debug)] +pub struct CosineAnnealingLrScheduler { + min_lr: LearningRate, + max_lr: LearningRate, + num_iters: usize, + current_iter: usize, +} + +impl LrScheduler for CosineAnnealingLrScheduler { + fn step(&mut self) -> LearningRate { + // Make current_iter overflow from usize::MAX to 0 to get the initial learning rate on the + // first call. We could've used i64 with an initial value -1, but keeping it in usize saves + // us from some type casting here. + self.current_iter = self.current_iter.wrapping_add(1) % (self.num_iters + 1); + self.min_lr + + 0.5 + * (self.max_lr - self.min_lr) + * (1.0 + + (self.current_iter as f64 / self.num_iters as f64 * std::f64::consts::PI) + .cos()) + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::from_state(&CosineAnnealingLrSchedulerState { + current_iter: self.current_iter, + }) + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + if let Some(state) = record.into_state::() { + self.current_iter = state.current_iter; + } + } +} + +/// The serializable state of a [cosine annealing scheduler](CosineAnnealingLrScheduler). +#[derive(RecordState, Clone, Debug)] +pub struct CosineAnnealingLrSchedulerState { + current_iter: usize, +} + +#[cfg(test)] +mod tests { + use super::super::test_utils; + use super::*; + + #[test] + fn config_initial_lr_too_low() { + let r = CosineAnnealingLrSchedulerConfig::new(0., 10).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_initial_lr_too_high() { + let r = CosineAnnealingLrSchedulerConfig::new(1.5, 10).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_min_lr_too_low() { + let r = CosineAnnealingLrSchedulerConfig::new(0.5, 10) + .with_min_lr(-0.1) + .build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Minimum learning rate must be at least 0 and at most equal to the initial learning \ + rate", + "Error messages should match", + ); + } + + #[test] + fn config_min_lr_too_high() { + let r = CosineAnnealingLrSchedulerConfig::new(0.5, 10) + .with_min_lr(0.6) + .build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Minimum learning rate must be at least 0 and at most equal to the initial learning \ + rate", + "Error messages should match", + ); + } + + #[test] + fn config_num_iters_too_low() { + let r = CosineAnnealingLrSchedulerConfig::new(0.5, 0).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Number of iterations must be at least 1", + "Error messages should match", + ); + } + + #[test] + fn test_lr_change() { + const INITIAL_LR: LearningRate = 0.5; + const MIN_LR: LearningRate = 0.1; + + let scheduler = CosineAnnealingLrSchedulerConfig::new(INITIAL_LR, 2) + .with_min_lr(MIN_LR) + .build() + .unwrap(); + let expected_lrs = [ + INITIAL_LR, // cos(0) + (INITIAL_LR + MIN_LR) * 0.5, // cos(PI/2) + MIN_LR, // cos(PI) + INITIAL_LR, // restart + ]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_save_and_load() { + const NUM_ITERS: usize = 9; + let scheduler = CosineAnnealingLrSchedulerConfig::new(1.0, NUM_ITERS) + .build() + .unwrap(); + test_utils::check_save_load(scheduler, NUM_ITERS / 3 * 2); + } +} diff --git a/crates/burn-optim/src/lr_scheduler/exponential.rs b/crates/burn-optim/src/lr_scheduler/exponential.rs new file mode 100644 index 0000000..2c457ae --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/exponential.rs @@ -0,0 +1,153 @@ +use burn_core as burn; + +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::LearningRate; +use crate::RecordState; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; +use burn::config::Config; + +/// The configuration for creating an [exponential learning rate scheduler](ExponentialLrScheduler). +/// +/// This scheduler returns the learning rate `initial_lr` at the first step, then multiplies it by +/// a constant `gamma` at every iteration. At any iteration `i` (which starts from 0), the learning +/// rate is given by `initial_lr * gamma^i`. +#[derive(Config, Debug)] +pub struct ExponentialLrSchedulerConfig { + // The initial learning rate. + initial_lr: LearningRate, + // The constant that the learning rate is multiplied by on each iteration. + gamma: f64, +} + +impl ExponentialLrSchedulerConfig { + /// Initializes a [exponential learning rate scheduler](ExponentialLrScheduler). + pub(crate) fn build(&self) -> Result { + if self.initial_lr <= 0. || self.initial_lr > 1. { + return Err("Initial learning rate must be greater than 0 and at most 1".into()); + } + if self.gamma <= 0. || self.gamma > 1. { + return Err("Gamma must be greater than 0 and at most 1".into()); + } + + Ok(ExponentialLrScheduler { + // Such an initial value eliminates the need for special-case handling of the first + // learning rate. + previous_lr: self.initial_lr / self.gamma, + gamma: self.gamma, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + /// + /// # Errors + /// + /// An error will be returned if any of the following conditions is true: + /// + /// * `initial_lr` is out of range (0.0, 1.0] + /// * `gamma` is out of range (0.0, 1.0] + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } +} + +/// A exponential learning rate scheduler. +/// +/// See [ExponentialLrSchedulerConfig] for more information. +#[derive(Clone, Copy, Debug)] +pub struct ExponentialLrScheduler { + // The previous iteration's learning rate. + previous_lr: LearningRate, + // The constant that the learning rate is multiplied by on each iteration. + gamma: f64, +} + +impl LrScheduler for ExponentialLrScheduler { + fn step(&mut self) -> LearningRate { + self.previous_lr *= self.gamma; + self.previous_lr + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::from_state(&ExponentialLrSchedulerState { + previous_lr: self.previous_lr, + }) + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + if let Some(state) = record.into_state::() { + self.previous_lr = state.previous_lr; + } + } +} + +/// The serializable state of an [exponential scheduler](ExponentialLrScheduler). +#[derive(RecordState, Clone, Debug)] +pub struct ExponentialLrSchedulerState { + // `f64` (not the `LearningRate` alias) so the derive recognizes it as a scalar leaf. + previous_lr: f64, +} + +#[cfg(test)] +mod tests { + use super::super::test_utils; + use super::*; + + #[test] + fn config_initial_lr_too_low() { + let r = ExponentialLrSchedulerConfig::new(0., 0.5).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_initial_lr_too_high() { + let r = ExponentialLrSchedulerConfig::new(1.5, 0.5).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_gamma_too_low() { + let r = ExponentialLrSchedulerConfig::new(0.5, 0.0).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Gamma must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_gamma_too_high() { + let r = ExponentialLrSchedulerConfig::new(0.5, 1.5).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Gamma must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn test_lr_change() { + let scheduler = ExponentialLrSchedulerConfig::new(0.8, 0.1).build().unwrap(); + let expected_lrs = [0.8, 0.08, 0.008, 0.0008, 0.00008]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_save_and_load() { + let scheduler = ExponentialLrSchedulerConfig::new(0.083, 0.3) + .build() + .unwrap(); + test_utils::check_save_load(scheduler, 7); + } +} diff --git a/crates/burn-optim/src/lr_scheduler/linear.rs b/crates/burn-optim/src/lr_scheduler/linear.rs new file mode 100644 index 0000000..08affd2 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/linear.rs @@ -0,0 +1,186 @@ +use burn_core as burn; + +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::LearningRate; +use crate::RecordState; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; +use burn::config::Config; + +/// The configuration for creating a [linear learning rate scheduler](LinearLrScheduler). +/// +/// This scheduler returns the learning rate `initial_lr` at the first step, then changes it by a +/// constant amount on each iteration until reaching a final learning rate `final_lr`. The +/// `num_iters` parameter controls how many iterations are needed to go from `initial_lr` to +/// `final_lr`. +#[derive(Config, Debug)] +pub struct LinearLrSchedulerConfig { + // The initial learning rate. + initial_lr: LearningRate, + // The final learning rate. + final_lr: LearningRate, + // The number of iterations before reaching the final learning rate. + num_iters: usize, +} + +impl LinearLrSchedulerConfig { + /// Initializes a [linear learning rate scheduler](LinearLrScheduler). + pub(crate) fn build(&self) -> Result { + if self.initial_lr <= 0. || self.initial_lr > 1. { + return Err("Initial learning rate must be greater than 0 and at most 1".into()); + } + if self.final_lr < 0. || self.final_lr > 1. { + return Err("Final learning rate must be at least 0 and at most 1".into()); + } + if self.num_iters == 0 { + return Err("Number of iterations must be at least 1".into()); + } + + Ok(LinearLrScheduler { + final_lr: self.final_lr, + step_size: (self.final_lr - self.initial_lr) / self.num_iters as f64, + remaining_iters: self.num_iters + 1, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + /// + /// # Errors + /// + /// An error will be returned if any of the following conditions is true: + /// + /// * `initial_lr` is out of range (0.0, 1.0] + /// * `final_lr` is out of range [0.0, 1.0] + /// * `num_iters` is 0 + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } +} + +/// A linear learning rate scheduler. +/// +/// See [LinearLrSchedulerConfig] for more information. +#[derive(Clone, Copy, Debug)] +pub struct LinearLrScheduler { + // The final learning rate after the linear changing process stops. + final_lr: LearningRate, + // The amount that the learning rate changes by on each iteration. + step_size: f64, + // The number of iterations left before reaching the final learning rate. + remaining_iters: usize, +} + +impl LrScheduler for LinearLrScheduler { + fn step(&mut self) -> LearningRate { + self.remaining_iters -= (self.remaining_iters != 0) as usize; + self.final_lr - self.step_size * self.remaining_iters as f64 + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::from_state(&LinearLrSchedulerState { + remaining_iters: self.remaining_iters, + }) + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + if let Some(state) = record.into_state::() { + self.remaining_iters = state.remaining_iters; + } + } +} + +/// The serializable state of a [linear scheduler](LinearLrScheduler). +#[derive(RecordState, Clone, Debug)] +pub struct LinearLrSchedulerState { + remaining_iters: usize, +} + +#[cfg(test)] +mod tests { + use super::super::test_utils; + use super::*; + + #[test] + fn config_initial_lr_too_low() { + let r = LinearLrSchedulerConfig::new(0., 0.5, 100).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_initial_lr_too_high() { + let r = LinearLrSchedulerConfig::new(1.5, 0.5, 100).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Initial learning rate must be greater than 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_final_lr_too_low() { + let r = LinearLrSchedulerConfig::new(0.5, -0.5, 100).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Final learning rate must be at least 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_final_lr_too_high() { + let r = LinearLrSchedulerConfig::new(0.5, 1.5, 100).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Final learning rate must be at least 0 and at most 1", + "Error messages should match", + ); + } + + #[test] + fn config_num_iters_too_low() { + let r = LinearLrSchedulerConfig::new(0.9, 0.1, 0).build(); + assert!(r.is_err(), "Should return an error"); + assert_eq!( + r.unwrap_err(), + "Number of iterations must be at least 1", + "Error messages should match", + ); + } + + #[test] + fn test_lr_decreasing() { + let scheduler = LinearLrSchedulerConfig::new(0.9, 0.5, 4).build().unwrap(); + let expected_lrs = [0.9, 0.8, 0.7, 0.6, 0.5, 0.5]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_lr_increasing() { + let scheduler = LinearLrSchedulerConfig::new(0.01, 0.04, 3).build().unwrap(); + let expected_lrs = [0.01, 0.02, 0.03, 0.04, 0.04]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_lr_unchanging() { + let scheduler = LinearLrSchedulerConfig::new(0.3, 0.3, 2).build().unwrap(); + let expected_lrs = [0.3, 0.3, 0.3, 0.3]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_save_and_load() { + const NUM_ITERS: usize = 6; + let scheduler = LinearLrSchedulerConfig::new(1.0, 0.01, NUM_ITERS) + .build() + .unwrap(); + test_utils::check_save_load(scheduler, NUM_ITERS / 3 * 2); + } +} diff --git a/crates/burn-optim/src/lr_scheduler/mod.rs b/crates/burn-optim/src/lr_scheduler/mod.rs new file mode 100644 index 0000000..9c50451 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/mod.rs @@ -0,0 +1,27 @@ +/// Constant learning rate scheduler +pub mod constant; + +/// Composed learning rate scheduler +pub mod composed; + +/// Linear learning rate scheduler +pub mod linear; + +/// Learning rate policies +pub mod module_lr_scheduler; + +/// Noam learning rate scheduler +pub mod noam; + +/// Exponential learning rate scheduler +pub mod exponential; + +/// Cosine learning rate scheduler +pub mod cosine; + +/// Step learning rate scheduler +pub mod step; + +mod base; + +pub use base::*; diff --git a/crates/burn-optim/src/lr_scheduler/module_lr_scheduler.rs b/crates/burn-optim/src/lr_scheduler/module_lr_scheduler.rs new file mode 100644 index 0000000..4a7aa7f --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/module_lr_scheduler.rs @@ -0,0 +1,381 @@ +use burn_core::{self as burn, module::ParamId}; + +use burn::config::Config; +use burn_core::module::ParamGroup; + +use crate::{ + LearningRate, + lr_scheduler::{DynLrScheduler, LrScheduler, LrSchedulerConfig, LrSchedulerRecord}, +}; + +#[derive(Clone)] +struct LrGroup { + group: ParamGroup, + lr: f64, +} + +/// Determines what learning rate to use for a given trainable parameter. +#[derive(Clone, Default)] +pub struct ModuleLearningRate { + groups: Vec, +} + +impl From for ModuleLearningRate { + fn from(value: LearningRate) -> Self { + Self { + groups: vec![LrGroup { + group: ParamGroup::all(), + lr: value, + }], + } + } +} + +impl ModuleLearningRate { + /// Get the effective learning rate for the given parameter. + pub fn lr_from_param(&self, id: ParamId, path: Option<&str>) -> LearningRate { + self.groups + .iter() + .filter_map(|val| val.group.matches(&id, path).then_some(val.lr)) + .next_back() + .expect("Should match at least one parameter group.") + } + + /// Get the base learning rate value which's group matches all parameters. + pub fn base(&self) -> LearningRate { + self.groups + .first() + .expect("Should have at least one learning rate.") + .lr + } +} + +#[derive(Config, Debug)] +struct LrSchedulerGroupConfig { + group: ParamGroup, + scheduler: LrSchedulerConfig, +} + +#[derive(new, Clone)] +struct LrSchedulerGroup { + group: ParamGroup, + scheduler: DynLrScheduler, +} + +/// Configuration for a [ModuleLrScheduler]. +#[derive(Config, Debug)] +pub struct ModuleLrSchedulerConfig { + base: LrSchedulerConfig, + #[config(default = "Vec::new()")] + scheduler_groups: Vec, +} + +/// A learning rate scheduler that maps specific parameter groups to dedicated sub-schedulers. +/// +/// This allows heterogeneous learning rate schedules across different layers of a model +/// (e.g., discriminative layer training or fine-tuning). +#[derive(Clone)] +pub struct ModuleLrScheduler { + groups: Vec, +} + +impl ModuleLrSchedulerConfig { + /// Initialize a new learning rate policy scheduler. + pub fn init(&self) -> Result { + let mut groups = Vec::with_capacity(self.scheduler_groups.len()); + + let base = match &self.base { + LrSchedulerConfig::Constant(lr) => (*lr).into(), + LrSchedulerConfig::Linear(config) => config.build()?.into(), + LrSchedulerConfig::Cosine(config) => config.build()?.into(), + LrSchedulerConfig::Exponential(config) => config.build()?.into(), + LrSchedulerConfig::Noam(config) => config.build()?.into(), + LrSchedulerConfig::Step(config) => config.build()?.into(), + LrSchedulerConfig::Composed(config) => config.build()?.into(), + }; + groups.push(LrSchedulerGroup { + group: ParamGroup::all(), + scheduler: base, + }); + + for group in self.scheduler_groups.iter() { + let scheduler = match &group.scheduler { + LrSchedulerConfig::Constant(lr) => (*lr).into(), + LrSchedulerConfig::Linear(config) => config.build()?.into(), + LrSchedulerConfig::Cosine(config) => config.build()?.into(), + LrSchedulerConfig::Exponential(config) => config.build()?.into(), + LrSchedulerConfig::Noam(config) => config.build()?.into(), + LrSchedulerConfig::Step(config) => config.build()?.into(), + LrSchedulerConfig::Composed(config) => config.build()?.into(), + }; + groups.push(LrSchedulerGroup::new(group.group.clone(), scheduler)); + } + + Ok(ModuleLrScheduler { groups }) + } + + /// Add a new parameter group to the scheduler's policy. + pub fn with_group( + mut self, + group: ParamGroup, + scheduler: impl Into, + ) -> Self { + self.scheduler_groups.push(LrSchedulerGroupConfig { + group, + scheduler: scheduler.into(), + }); + self + } +} + +impl ModuleLrScheduler { + /// Create a [ModuleLrScheduler]. + /// + /// # Arguments + /// + /// * `scheduler` - The policy's default learning rate scheduler. + pub fn new(scheduler: S) -> Self { + Self { + groups: vec![LrSchedulerGroup { + group: ParamGroup::all(), + scheduler: scheduler.into(), + }], + } + } + + /// Perform the scheduler step of every scheduler and returns the effective learning rate policy. + pub fn step(&mut self) -> ModuleLearningRate { + let groups = self + .groups + .iter_mut() + .map(|s| { + let lr = s.scheduler.step(); + + LrGroup { + group: s.group.clone(), + lr, + } + }) + .collect(); + + ModuleLearningRate { groups } + } + + /// Get the current state of the schedulers as a [record](LrSchedulerRecord). + pub fn to_record(&self) -> super::LrSchedulerRecord { + let mut record = LrSchedulerRecord::new(); + for (index, item) in self.groups.iter().enumerate() { + let sub = item.scheduler.to_record(); + record = record.with_record(&index.to_string(), sub); + } + + record + } + + /// Load the state of the schedulers from a [record](LrSchedulerRecord). + pub fn load_record(mut self, record: super::LrSchedulerRecord) -> Self { + self.groups = self + .groups + .into_iter() + .enumerate() + .map(|(index, item)| { + let sub = record.record(&index.to_string()); + let scheduler = item.scheduler.load_record(sub); + + LrSchedulerGroup { + group: item.group, + scheduler, + } + }) + .collect(); + + self + } + + /// Add a new parameter group to the scheduler's policy. + pub fn with_group(mut self, group: ParamGroup, scheduler: impl Into) -> Self { + self.groups.push(LrSchedulerGroup { + group, + scheduler: scheduler.into(), + }); + self + } +} + +impl From for ModuleLrScheduler +where + S: LrScheduler + 'static, +{ + fn from(value: S) -> Self { + Self::new(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lr_scheduler::linear::LinearLrSchedulerConfig; + use burn_core::module::ParamGroup; + + const EPSILON: f64 = 1e-10; + + fn check_approx(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < EPSILON, + "expected {expected}, got {actual}", + ); + } + + #[test] + fn step_yields_constant_default_lr() { + let mut scheduler = ModuleLrScheduler::new(0.01_f64); + for _ in 0..3 { + check_approx(scheduler.step().base(), 0.01); + } + } + + #[test] + fn step_advances_linear_default_scheduler() { + let linear = LinearLrSchedulerConfig::new(0.9, 0.5, 4).build().unwrap(); + let mut scheduler = ModuleLrScheduler::new(linear); + + let expected = [0.9, 0.8, 0.7, 0.6, 0.5, 0.5]; + for expected_lr in expected { + check_approx(scheduler.step().base(), expected_lr); + } + } + + #[test] + fn save_load_preserves_default_scheduler_state() { + let make = + || ModuleLrScheduler::new(LinearLrSchedulerConfig::new(1.0, 0.1, 9).build().unwrap()); + + let mut original = make(); + let mut truth = make(); + + for _ in 0..5 { + original.step(); + truth.step(); + } + + let record = original.to_record(); + let mut restored = make().load_record(record); + + for _ in 0..4 { + check_approx(restored.step().base(), truth.step().base()); + } + } + + #[test] + fn group_param_gets_group_lr() { + let id_group = ParamId::new(); + let id_default = ParamId::new(); + + let mut scheduler = ModuleLrSchedulerConfig::new(0.001.into()) + .with_group(ParamGroup::from_ids(vec![id_group.clone()]), 0.1) + .init() + .unwrap(); + + let policy = scheduler.step(); + // id_group is in the explicit group = group LR + check_approx(policy.lr_from_param(id_group, None), 0.1); + // id_default is not in any group = default LR + check_approx(policy.lr_from_param(id_default, None), 0.001); + } + + #[test] + fn path_group_matches_param_by_path_substring() { + let mut scheduler = ModuleLrSchedulerConfig::new(0.001.into()) + .with_group(ParamGroup::from_predicate("backbone"), 0.1) + .init() + .unwrap(); + + let policy = scheduler.step(); + let id = ParamId::new(); + + check_approx( + policy.lr_from_param(id.clone(), Some("model.backbone.layer.weight")), + 0.1, + ); + check_approx( + policy.lr_from_param(id, Some("model.head.layer.weight")), + 0.001, + ); + } + + #[test] + fn multiple_groups_are_independent() { + let id_a = ParamId::new(); + let id_b = ParamId::new(); + let id_default = ParamId::new(); + + let mut scheduler = + ModuleLrSchedulerConfig::new(LinearLrSchedulerConfig::new(0.001, 0.0001, 4).into()) + .with_group( + ParamGroup::from_ids(vec![id_a.clone()]), + LinearLrSchedulerConfig::new(0.1, 0.01, 4), + ) + .with_group( + ParamGroup::from_ids(vec![id_b.clone()]), + LinearLrSchedulerConfig::new(0.5, 0.05, 4), + ) + .init() + .unwrap(); + + // Each group returns its own initial LR + let policy = scheduler.step(); + check_approx(policy.lr_from_param(id_a.clone(), None), 0.1); + check_approx(policy.lr_from_param(id_b.clone(), None), 0.5); + check_approx(policy.lr_from_param(id_default.clone(), None), 0.001); + + // All three schedulers advanced; LRs are strictly between initial and final + let policy = scheduler.step(); + let lr_a = policy.lr_from_param(id_a, None); + let lr_b = policy.lr_from_param(id_b, None); + let lr_default = policy.lr_from_param(id_default, None); + assert!( + lr_a < 0.1 && lr_a > 0.01, + "group-a LR should have decayed: {lr_a}" + ); + assert!( + lr_b < 0.5 && lr_b > 0.05, + "group-b LR should have decayed: {lr_b}" + ); + assert!( + lr_default < 0.001 && lr_default > 0.0001, + "default LR should have decayed: {lr_default}" + ); + } + + #[test] + fn save_load_with_groups_preserves_state() { + let id_group = ParamId::new(); + + let make = || { + ModuleLrSchedulerConfig::new(LinearLrSchedulerConfig::new(0.01, 0.001, 9).into()) + .with_group( + ParamGroup::from_ids(vec![id_group.clone()]), + LinearLrSchedulerConfig::new(0.1, 0.01, 9), + ) + .init() + .unwrap() + }; + + let mut original = make(); + let mut truth = make(); + + for _ in 0..5 { + original.step(); + truth.step(); + } + + let record = original.to_record(); + let mut restored = make().load_record(record); + + for _ in 0..4 { + let lr_restored = restored.step().lr_from_param(id_group.clone(), None); + let lr_truth = truth.step().lr_from_param(id_group.clone(), None); + check_approx(lr_restored, lr_truth); + } + } +} diff --git a/crates/burn-optim/src/lr_scheduler/noam.rs b/crates/burn-optim/src/lr_scheduler/noam.rs new file mode 100644 index 0000000..f0c72d4 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/noam.rs @@ -0,0 +1,147 @@ +use burn_core as burn; + +use burn::config::Config; + +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::LearningRate; +use crate::RecordState; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; + +/// Configuration to create a [noam](NoamLrScheduler) learning rate scheduler. +#[derive(Config, Debug)] +pub struct NoamLrSchedulerConfig { + /// The overall scale factor for the learning rate decay. + factor: f64, + /// The number of steps before the exponential decay stats. + #[config(default = 4000)] + warmup_steps: usize, + /// The size of the model. + #[config(default = 512)] + model_size: usize, +} + +/// Noam learning rate scheduler as described in [Attention Is All You Need](https://arxiv.org/abs/1706.03762). +#[derive(Clone, Debug)] +pub struct NoamLrScheduler { + warmup_steps: f64, + embedding_size: f64, + factor: f64, + step: f64, +} + +impl NoamLrSchedulerConfig { + /// Initialize a new [noam](NoamLrScheduler) learning rate scheduler. + pub(crate) fn build(&self) -> Result { + if self.warmup_steps == 0 { + return Err( + "Number of steps before exponential decay starts must be greater than 0".into(), + ); + } + if self.model_size == 0 { + return Err("Model size must be greater than 0".into()); + } + + Ok(NoamLrScheduler { + warmup_steps: self.warmup_steps as f64, + embedding_size: self.model_size as f64, + factor: self.factor, + step: 0.0, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + /// + /// # Errors + /// + /// An error will be returned if any of the following conditions is true: + /// + /// * `warmup_steps` is 0 + /// * `model_size` is 0 + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } +} + +impl LrScheduler for NoamLrScheduler { + fn step(&mut self) -> LearningRate { + self.step += 1.0; + + let arg1 = self.step.powf(-0.5); + let arg2 = self.step * self.warmup_steps.powf(-1.5); + + self.factor * self.embedding_size.powf(-0.5) * f64::min(arg1, arg2) + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::from_state(&NoamLrSchedulerState { step: self.step }) + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + if let Some(state) = record.into_state::() { + self.step = state.step; + } + } +} + +/// The serializable state of a [noam scheduler](NoamLrScheduler). +#[derive(RecordState, Clone, Debug)] +pub struct NoamLrSchedulerState { + step: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_warmup_steps_invalid() { + let r = NoamLrSchedulerConfig::new(0.1).with_warmup_steps(0).build(); + assert!(r.is_err(), "Should return an error"); + } + + #[test] + fn test_config_warmup_steps_valid() { + let r = NoamLrSchedulerConfig::new(0.1).with_warmup_steps(1).build(); + assert!(r.is_ok(), "Should return a success value"); + } + + #[test] + fn test_config_model_size_invalid() { + let r = NoamLrSchedulerConfig::new(0.1).with_model_size(0).build(); + assert!(r.is_err(), "Should return an error"); + } + + #[test] + fn test_config_model_size_valid() { + let r = NoamLrSchedulerConfig::new(0.1).with_model_size(1).build(); + assert!(r.is_ok(), "Should return a success value"); + } + + #[test] + fn test_function_increase_and_decrease() { + let warmup_steps = 100; + let mut scheduler = NoamLrSchedulerConfig::new(10.0) + .with_warmup_steps(warmup_steps) + .build() + .unwrap(); + let mut lr_current = 0.0; + + for _ in 0..warmup_steps { + let lr = scheduler.step(); + assert!( + lr > lr_current, + "Learning rate should increase before the warmup_steps is reached." + ); + lr_current = lr; + } + + for _ in 0..warmup_steps { + let lr = scheduler.step(); + assert!( + lr < lr_current, + "Learning rate should decrease after the warmup_steps is reached." + ); + lr_current = lr; + } + } +} diff --git a/crates/burn-optim/src/lr_scheduler/step.rs b/crates/burn-optim/src/lr_scheduler/step.rs new file mode 100644 index 0000000..ebf4443 --- /dev/null +++ b/crates/burn-optim/src/lr_scheduler/step.rs @@ -0,0 +1,233 @@ +use burn_core as burn; + +use burn::config::Config; + +use super::{LrScheduler, LrSchedulerRecord, String}; +use crate::lr_scheduler::module_lr_scheduler::ModuleLrScheduler; +use crate::{LearningRate, RecordState}; + +/// The configuration for create a [step learning rate scheduler](StepLrScheduler). +/// +/// This scheduler returns the learning rate `initial_lr` from the start, and keeps doing so until +/// the same value has been given for `step_size` times. Then it multiplies the learning rate by +/// `gamma` before repeating the process. +/// +/// Gamma values out of range (0.0, 1.0) and non-positive initial learning rates are acceptable, but +/// a warning log will be output for such a value in case of mistyping. +/// +/// ## Notes +/// +/// The [step](StepLrScheduler::step) method of the scheduler panics if it is called more than +/// `i32::MAX + 1` times. +#[derive(Config, Debug)] +pub struct StepLrSchedulerConfig { + // The learning rate at the initial step. + initial_lr: LearningRate, + // The number of iterations over which the learning rate remains unchanged before the next + // update. + step_size: usize, + /// The factor by which the learning rate is multiplied with each update. Default: 0.1. + #[config(default = 0.1)] + gamma: f64, +} + +impl StepLrSchedulerConfig { + /// Initializes a [step learning rate scheduler](StepLrScheduler). + pub(crate) fn build(&self) -> Result { + if self.step_size == 0 { + return Err("Step size must be greater than 0".into()); + } + + // Atypical values of `initial_lr` and `gamma` are not rejected because they might be useful + // in some cases like debugging (e.g., https://datascience.stackexchange.com/q/89518). + if self.initial_lr <= 0.0 { + log::warn!( + "Initial learning rate value of {} is not a positive number. Ignore this warning \ + if it is intended.", + self.initial_lr + ); + } + if self.gamma <= 0.0 || self.gamma >= 1.0 { + log::warn!( + "Gamma value of {} is out of range (0.0, 1.0). Ignore this warning if it is \ + intended.", + self.gamma + ); + } + + Ok(StepLrScheduler { + init_lr: self.initial_lr, + step_size: self.step_size, + gamma: self.gamma, + iter_idx: -1, + }) + } + + /// Initializes a [module learning rate scheduler](ModuleLrScheduler). + /// + /// # Errors + /// + /// An error will be returned if `step_size` is 0. + pub fn init(&self) -> Result { + self.build().map(|s| s.into()) + } +} + +/// Step learning rate scheduler. +#[derive(Clone, Debug)] +pub struct StepLrScheduler { + init_lr: LearningRate, + step_size: usize, + gamma: f64, + // The index of the current iteration. + // `i32` is used for avoiding truncating the exponent when taking powers of `gamma`. + iter_idx: i32, +} + +impl LrScheduler for StepLrScheduler { + fn step(&mut self) -> LearningRate { + self.iter_idx = self + .iter_idx + .checked_add(1) + .expect("`.step()` should be called no more than `i32::MAX + 1` times"); + // Type casting below causes no truncation, as all the values fall within the ranges. + self.init_lr + * self + .gamma + .powi((self.iter_idx as usize / self.step_size) as i32) + } + + fn to_record(&self) -> LrSchedulerRecord { + LrSchedulerRecord::from_state(&StepLrSchedulerState { + iter_idx: self.iter_idx, + }) + } + + fn load_record(&mut self, record: LrSchedulerRecord) { + if let Some(state) = record.into_state::() { + self.iter_idx = state.iter_idx; + } + } +} + +/// The serializable state of a [step learning rate scheduler](StepLrScheduler). +#[derive(RecordState, Clone, Debug)] +pub struct StepLrSchedulerState { + iter_idx: i32, +} + +#[cfg(test)] +mod tests { + use super::super::test_utils; + use super::*; + + // Warning logs for initial LR and gamma are not tested because there seems no straightforward + // way to do it. + // + // Creating a mock logger that collects logs into `String` for later examination seems a possible + // solution, but unit tests run in the same process in parallel, where the single logger would + // be shared by multiple tests, so logs from different tests would be mixed up with no easy way + // to separate them. + // Using "--test-threads=1" could prevent mixup, but whether the ability to test logging is + // worth the slowdown would be a question. Also, using a primitive provided by `std` to + // synchronize the logger across tests is not an option since we need to support `no-std`. + // Maybe the mocking approach can be reconsidered after we are given an option to run tests in + // separate processes like what the issue below is proposing: + // https://github.com/rust-lang/rust/issues/47506 + // + // As a side note, a helper crate exists for the exact purpose: + // https://crates.io/crates/testing_logger + // but the crate has been unmaintained and using it would introduce another dependency. + + #[test] + fn test_config_step_size_zero() { + let r = StepLrSchedulerConfig::new(1.0, 0).build(); + assert!(r.is_err(), "Should return an error"); + } + + #[test] + fn test_config_step_size_nonzero() { + let r = StepLrSchedulerConfig::new(1.0, 1).build(); + assert!(r.is_ok(), "Should return a success value"); + } + + #[test] + fn test_config_default_gamma() { + const INIT_LR: LearningRate = 0.4; + const STEP_SIZE: usize = 2; + + let mut default = StepLrSchedulerConfig::new(INIT_LR, STEP_SIZE) + .build() + .unwrap(); + let mut explicit = StepLrSchedulerConfig::new(INIT_LR, STEP_SIZE) + .with_gamma(0.1) + .build() + .unwrap(); + test_utils::compare_steps(&mut default, &mut explicit, 3 * STEP_SIZE); + } + + #[test] + fn test_lr_decreasing() { + let scheduler = StepLrSchedulerConfig::new(0.5, 3) + .with_gamma(0.1) + .build() + .unwrap(); + let expected_lrs = [0.5, 0.5, 0.5, 0.05, 0.05, 0.05, 0.005, 0.005, 0.005]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_lr_increasing() { + let scheduler = StepLrSchedulerConfig::new(0.1, 2) + .with_gamma(2.0) + .build() + .unwrap(); + let expected_lrs = [0.1, 0.1, 0.2, 0.2, 0.4, 0.4]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_lr_unchanging() { + let scheduler = StepLrSchedulerConfig::new(3.1, 1) + .with_gamma(1.0) + .build() + .unwrap(); + let expected_lrs = [3.1, 3.1, 3.1]; + test_utils::check_lr_sequence(scheduler, expected_lrs); + } + + #[test] + fn test_save_and_load() { + const STEP_SIZE: usize = 10; + + let scheduler = StepLrSchedulerConfig::new(0.007, STEP_SIZE) + .with_gamma(0.03) + .build() + .unwrap(); + test_utils::check_save_load(scheduler, 3 * STEP_SIZE / 2); + } + + // It's too time consuming to actually run a scheduler `i32::MAX` steps, so an approach that + // depends on private fields is used to implement the test. + #[test] + fn test_number_of_calls_within_limit() { + // Create a scheduler that has already run `i32::MAX` steps + let mut scheduler = StepLrSchedulerConfig::new(0.1, 2).build().unwrap(); + scheduler.load_record(LrSchedulerRecord::from_state(&StepLrSchedulerState { + iter_idx: i32::MAX - 1, + })); + scheduler.step(); + } + + #[test] + #[should_panic = "i32::MAX"] + fn test_number_of_calls_over_limit() { + // Create a scheduler that has already run `i32::MAX` steps + let mut scheduler = StepLrSchedulerConfig::new(0.1, 2).build().unwrap(); + scheduler.load_record(LrSchedulerRecord::from_state(&StepLrSchedulerState { + iter_idx: i32::MAX - 1, + })); + scheduler.step(); + scheduler.step(); + } +} diff --git a/crates/burn-optim/src/optim/adagrad.rs b/crates/burn-optim/src/optim/adagrad.rs new file mode 100644 index 0000000..509168c --- /dev/null +++ b/crates/burn-optim/src/optim/adagrad.rs @@ -0,0 +1,286 @@ +use burn_core as burn; + +use crate::RecordState; + +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +use super::{ + Optimizer, + decay::{WeightDecay, WeightDecayConfig}, + module_optimizer::ModuleOptimizer, +}; +use crate::{LearningRate, grad_clipping::GradientClippingConfig}; + +/// AdaGrad configuration. +#[derive(Config, Debug)] +pub struct AdaGradConfig { + #[config(default = 0.)] + lr_decay: f64, + #[config(default = 1e-5)] + epsilon: f32, + /// [Weight decay](WeightDecayConfig) config. + weight_decay: Option, + /// [Gradient Clipping](GradientClippingConfig) config. + grad_clipping: Option, +} + +/// AdaGrad optimizer +#[derive(Clone)] +pub struct AdaGrad { + lr_decay: LrDecay, + weight_decay: Option, +} + +/// AdaGrad state. +#[derive(RecordState, Clone, new)] +pub struct AdaGradState { + lr_decay: LrDecayState, +} + +impl Optimizer for AdaGrad { + type State = AdaGradState; + + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + mut grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + let mut state_lr_decay = None; + + if let Some(state) = state { + state_lr_decay = Some(state.lr_decay); + } + + if let Some(weight_decay) = &self.weight_decay { + grad = weight_decay.transform(grad, tensor.clone()); + } + + let (grad, state_lr_decay) = self.lr_decay.transform(grad, lr, state_lr_decay); + + let state = AdaGradState::new(state_lr_decay); + + (tensor - grad, Some(state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.lr_decay = state.lr_decay.to_device(device); + state + } +} + +impl AdaGradConfig { + /// Build an [`AdaGrad`] from the config. + pub(crate) fn build(&self) -> AdaGrad { + AdaGrad { + lr_decay: LrDecay { + lr_decay: self.lr_decay, + epsilon: self.epsilon, + }, + weight_decay: self.weight_decay.as_ref().map(WeightDecay::new), + } + } + + /// Initialize AdaGrad optimizer. + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.grad_clipping { + optim = optim.with_grad_clipping(config.init()); + } + optim + } +} + +/// Learning rate decay state (also includes sum state). +#[derive(RecordState, new, Clone)] +pub struct LrDecayState { + time: usize, + sum: Tensor, +} + +#[derive(Clone)] +struct LrDecay { + lr_decay: f64, + epsilon: f32, +} + +impl LrDecay { + pub fn transform( + &self, + grad: Tensor, + lr: LearningRate, + lr_decay_state: Option>, + ) -> (Tensor, LrDecayState) { + let state = if let Some(mut state) = lr_decay_state { + state.sum = state.sum.add(grad.clone().square()); + state.time += 1; + state + } else { + LrDecayState::new(1, grad.clone().square()) + }; + + let new_lr = lr / (1. + (state.time as f64 - 1.) * self.lr_decay); + + let grad = grad + .div(state.sum.clone().sqrt().add_scalar(self.epsilon)) + .mul_scalar(new_lr); + + (grad, state) + } +} + +impl LrDecayState { + /// Move state to device. + /// + /// # Arguments + /// + /// * `device` - Device to move state to. + /// + /// # Returns + /// + /// Returns state moved to device. + pub fn to_device(mut self, device: &Device) -> Self { + self.sum = self.sum.to_device(device); + self + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::*; + use crate::GradientsParams; + use burn::module::Param; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + const LEARNING_RATE: LearningRate = 0.01; + + #[test] + fn test_adagrad_optimizer_save_load_state() { + let device = Device::default().autodiff(); + let linear = LinearConfig::new(6, 6).init(&device); + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + let mut optimizer = create_adagrad(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let bytes = optimizer.into_bytes().unwrap(); + assert!(!bytes.is_empty()); + + #[cfg(feature = "std")] + optimizer + .save(std::env::temp_dir().as_path().join("test_optim_adagrad")) + .unwrap(); + + let state_optim_before = optimizer.to_record(); + let optimizer = create_adagrad().from_bytes(bytes).unwrap(); + let state_optim_after = optimizer.to_record(); + + assert_eq!(state_optim_before.len(), state_optim_after.len()); + } + + #[test] + fn test_adagrad_optimizer_with_numbers() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdaGradConfig::new() + .with_epsilon(1e-8) + .with_lr_decay(0.5) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [-0.334989, 0.123011, 0.389911, 0.305611, 0.071511, 0.052711], + [ + 0.066144, -0.030056, -0.378256, 0.243444, 0.183944, -0.303756, + ], + [ + -0.033462, 0.020138, -0.310662, 0.233938, -0.292462, 0.298538, + ], + [ + -0.312636, -0.236036, -0.386136, -0.312736, -0.090736, 0.147964, + ], + [ + 0.315896, -0.232304, 0.357596, -0.187004, 0.365496, -0.044504, + ], + [-0.030305, -0.026405, 0.111395, 0.177695, 0.014895, 0.368895], + ]); + let bias_expected = TensorData::from([ + -0.405214, 0.073686, -0.111714, 0.102886, 0.121886, -0.001714, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.val().into_data(), + state_updated.bias.unwrap().val().into_data(), + ); + + let tolerance = Tolerance::absolute(1e-6); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + + fn create_adagrad() -> ModuleOptimizer { + let config = AdaGradConfig::new(); + AdaGrad { + lr_decay: LrDecay { + lr_decay: config.lr_decay, + epsilon: config.epsilon, + }, + weight_decay: config.weight_decay.as_ref().map(WeightDecay::new), + } + .into() + } +} diff --git a/crates/burn-optim/src/optim/adam.rs b/crates/burn-optim/src/optim/adam.rs new file mode 100644 index 0000000..3e65c07 --- /dev/null +++ b/crates/burn-optim/src/optim/adam.rs @@ -0,0 +1,551 @@ +use burn_core as burn; + +use crate::RecordState; + +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +use super::{ + Optimizer, + decay::{WeightDecay, WeightDecayConfig}, + module_optimizer::ModuleOptimizer, +}; +use crate::{LearningRate, grad_clipping::GradientClippingConfig}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Adam configuration. +#[derive(Config, Debug)] +pub struct AdamConfig { + /// Parameter for Adam. + #[config(default = 0.9)] + beta_1: f32, + /// Parameter for Adam. + #[config(default = 0.999)] + beta_2: f32, + /// A value required for numerical stability. + #[config(default = 1e-5)] + epsilon: f32, + /// Whether to use AMSGrad algorithm + #[config(default = false)] + amsgrad: bool, + /// [Weight decay](WeightDecayConfig) config. + weight_decay: Option, + /// [Gradient Clipping](GradientClippingConfig) config. + grad_clipping: Option, +} + +/// Adam optimizer. +/// +/// See: +/// - [Adam: A Method for Stochastic Optimization](https://arxiv.org/pdf/1412.6980.pdf). +/// - [On the Convergence of Adam and Beyond](https://openreview.net/forum?id=ryQu7f-RZ) +#[derive(Clone)] +pub struct Adam { + momentum: AdaptiveMomentum, + weight_decay: Option, +} + +/// Adam state. +#[derive(RecordState, Clone, new)] +pub struct AdamState { + /// The current adaptive momentum. + pub momentum: AdaptiveMomentumState, +} + +impl Optimizer for Adam { + type State = AdamState; + + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + mut grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + let mut state_momentum = None; + + if let Some(state) = state { + state_momentum = Some(state.momentum); + } + + if let Some(weight_decay) = &self.weight_decay { + grad = weight_decay.transform(grad, tensor.clone()); + } + + let (grad, state_momentum) = self.momentum.transform(grad, state_momentum); + + let state = AdamState::new(state_momentum); + let delta = grad.mul_scalar(lr); + + (tensor - delta, Some(state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.momentum = state.momentum.to_device(device); + state + } +} + +impl AdamConfig { + /// Build an [`Adam`] from the config. + pub(crate) fn build(&self) -> Adam { + Adam { + momentum: AdaptiveMomentum { + beta_1: self.beta_1, + beta_2: self.beta_2, + epsilon: self.epsilon, + amsgrad: self.amsgrad, + }, + weight_decay: self.weight_decay.as_ref().map(WeightDecay::new), + } + } + + /// Initialize Adam optimizer. + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.grad_clipping { + optim = optim.with_grad_clipping(config.init()); + } + optim + } +} + +/// Adaptive momentum state. +#[derive(RecordState, new, Clone)] +pub struct AdaptiveMomentumState { + /// The number of iterations aggregated. + pub time: usize, + /// The first order momentum. + pub moment_1: Tensor, + /// The second order momentum. + pub moment_2: Tensor, + /// Max of second order momentum (for AMSGrad) + #[new(default)] + pub max_moment_2: Option>, +} + +#[derive(Clone)] +struct AdaptiveMomentum { + beta_1: f32, + beta_2: f32, + epsilon: f32, + amsgrad: bool, +} + +impl AdaptiveMomentum { + pub fn transform( + &self, + grad: Tensor, + momentum_state: Option>, + ) -> (Tensor, AdaptiveMomentumState) { + let state = if let Some(mut state) = momentum_state { + let factor = 1.0 - self.beta_1; + state.moment_1 = state + .moment_1 + .mul_scalar(self.beta_1) + .add(grad.clone().mul_scalar(factor)); + + let factor = 1.0 - self.beta_2; + state.moment_2 = state + .moment_2 + .mul_scalar(self.beta_2) + .add(grad.square().mul_scalar(factor)); + if self.amsgrad { + let max_v = state + .max_moment_2 + .take() + .unwrap_or_else(|| state.moment_2.clone()); + + let new_max = max_v.max_pair(state.moment_2.clone()); + state.max_moment_2 = Some(new_max); + } + + state.time += 1; + + state + } else { + let factor = 1.0 - self.beta_1; + let moment_1 = grad.clone().mul_scalar(factor); + + let factor = 1.0 - self.beta_2; + let moment_2 = grad.square().mul_scalar(factor); + let max_moment_2 = self.amsgrad.then(|| moment_2.clone()); + AdaptiveMomentumState { + time: 1, + moment_1, + moment_2, + max_moment_2, + } + }; + + let time = state.time as i32; + let bias_correction2_sqrt = (1.0 - self.beta_2.powi(time)).sqrt(); + let combined_factor = bias_correction2_sqrt / (1.0 - self.beta_1.powi(time)); + + let v_to_use = if self.amsgrad { + state.max_moment_2.as_ref().unwrap_or(&state.moment_2) + } else { + &state.moment_2 + }; + + let grad = state.moment_1.clone().mul_scalar(combined_factor).div( + v_to_use + .clone() + .sqrt() + .add_scalar(self.epsilon * bias_correction2_sqrt), + ); + (grad, state) + } +} + +impl AdaptiveMomentumState { + /// Move state to device. + /// + /// # Arguments + /// + /// * `device` - Device to move state to. + /// + /// # Returns + /// + /// Returns state moved to device. + pub fn to_device(mut self, device: &Device) -> Self { + self.moment_1 = self.moment_1.to_device(device); + self.moment_2 = self.moment_2.to_device(device); + self.max_moment_2 = self.max_moment_2.map(|tensor| tensor.to_device(device)); + self + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::*; + use crate::GradientsParams; + use burn::module::Param; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + const LEARNING_RATE: LearningRate = 0.01; + + #[test] + fn test_adam_optimizer_save_load_state() { + let device = Device::default().autodiff(); + let linear = LinearConfig::new(6, 6).init(&device); + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + let mut optimizer = create_adam(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let bytes = optimizer.into_bytes().unwrap(); + assert!(!bytes.is_empty()); + + #[cfg(feature = "std")] + optimizer + .save(std::env::temp_dir().as_path().join("test_optim_adam")) + .unwrap(); + + let state_optim_before = optimizer.to_record(); + let optimizer = create_adam().from_bytes(bytes).unwrap(); + let state_optim_after = optimizer.to_record(); + + assert_eq!(state_optim_before.len(), state_optim_after.len()); + } + + /// A burnpack round-trip must restore the full state — the moment tensors, amsgrad's optional + /// `max_moment_2`, and the `time` step counter — so that a subsequent step produces identical + /// parameters whether taken on the original or the reloaded optimizer. + #[test] + fn test_adam_state_survives_burnpack_round_trip() { + let device = Device::default().autodiff(); + let mut linear = LinearConfig::new(6, 6).init(&device); + let mut optimizer = AdamConfig::new().with_amsgrad(true).init(); + + // Warm up the optimizer state over a few steps. + for i in 1..=3 { + let x = Tensor::<2>::ones([2, 6], &device) + .mul_scalar(i as f32 * 0.1) + .require_grad(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + } + + // Round-trip the optimizer state through the burnpack format. No device is needed on load: + // each parameter's state is migrated to that parameter's device on the next step. + let bytes = optimizer.into_bytes().unwrap(); + let mut reloaded = AdamConfig::new() + .with_amsgrad(true) + .init() + .from_bytes(bytes) + .unwrap(); + + // One more identical step on each optimizer must yield identical parameters. + let x = Tensor::<2>::ones([2, 6], &device) + .mul_scalar(0.4) + .require_grad(); + let grads_original = + GradientsParams::from_grads(linear.forward(x.clone()).backward(), &linear); + let grads_reloaded = GradientsParams::from_grads(linear.forward(x).backward(), &linear); + + let from_original = optimizer.step(LEARNING_RATE.into(), linear.clone(), grads_original); + let from_reloaded = reloaded.step(LEARNING_RATE.into(), linear, grads_reloaded); + + let weight_original = from_original.weight.to_data(); + let weight_reloaded = from_reloaded.weight.to_data(); + weight_original.assert_approx_eq::(&weight_reloaded, Tolerance::absolute(1e-6)); + } + + #[test] + fn test_adam_optimizer_with_amsgrad_50_steps() { + let device = Device::default().autodiff(); + let mut linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + + let mut optimizer = AdamConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_amsgrad(true) + .with_weight_decay(Some(WeightDecayConfig::new(0.5))) + .init(); + + for i in 1..=50 { + let x = Tensor::<2>::ones([2, 6], &device) + .mul_scalar(i as f32 * 0.1) + .require_grad(); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + } + + let state_updated = linear; + let weight_updated = state_updated.weight.to_data(); + let bias_updated = state_updated.bias.unwrap().to_data(); + + let weights_expected = TensorData::from([ + [ + -0.9125810265541077, + -0.45855265855789185, + -0.1915993094444275, + -0.2759990692138672, + -0.5099529027938843, + -0.5287043452262878, + ], + [ + -0.5181325674057007, + -0.6139854788780212, + -0.9574727416038513, + -0.34102925658226013, + -0.400514155626297, + -0.8847861886024475, + ], + [ + -0.614483118057251, + -0.5611032247543335, + -0.8887064456939697, + -0.34762972593307495, + -0.8708556890487671, + -0.2830044627189636, + ], + [ + -0.8904699683189392, + -0.8151527643203735, + -0.9621278643608093, + -0.8905676603317261, + -0.671261191368103, + -0.4333854615688324, + ], + [ + -0.26599061489105225, + -0.8119961023330688, + -0.22424538433551788, + -0.7672406435012817, + -0.2163349837064743, + -0.6258266568183899, + ], + [ + -0.611397922039032, + -0.6075160503387451, + -0.4701341986656189, + -0.4039117991924286, + -0.5663845539093018, + -0.21262989938259125, + ], + ]); + let bias_expected = TensorData::from([ + -0.8817203044891357, + -0.4038999378681183, + -0.5889149308204651, + -0.37475723028182983, + -0.3557940721511841, + -0.47914788126945496, + ]); + + let tolerance = Tolerance::absolute(1e-5); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + } + #[test] + fn test_adam_optimizer_with_numbers() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdamConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_weight_decay(Some(WeightDecayConfig::new(0.5))) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [-0.340528, 0.118929, 0.384336, 0.300010, 0.066034, 0.047154], + [ + 0.057757, -0.036690, -0.386649, 0.235010, 0.175624, -0.312133, + ], + [ + -0.038940, 0.016306, -0.316151, 0.228410, -0.297819, 0.293047, + ], + [ + -0.317929, -0.239100, -0.391449, -0.318087, -0.095948, 0.142651, + ], + [ + 0.310050, -0.235909, 0.351736, -0.192888, 0.359710, -0.050343, + ], + [-0.035840, -0.030203, 0.105840, 0.172110, 0.009440, 0.363346], + ]); + let bias_expected = TensorData::from([ + -0.410499, 0.068401, -0.116999, 0.097601, 0.116601, -0.006999, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + let tolerance = Tolerance::absolute(1e-2); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + #[test] + fn test_adam_optimizer_no_nan() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + + let x = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdamConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_weight_decay(Some(WeightDecayConfig::new(0.5))) + .init(); + + let grads = linear.forward(x.clone()).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + assert!(!state_updated.weight.to_data().as_slice::().unwrap()[0].is_nan()); + } + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + + fn create_adam() -> ModuleOptimizer { + let config = AdamConfig::new(); + Adam { + momentum: AdaptiveMomentum { + beta_1: config.beta_1, + beta_2: config.beta_2, + epsilon: config.epsilon, + amsgrad: config.amsgrad, + }, + weight_decay: config.weight_decay.as_ref().map(WeightDecay::new), + } + .into() + } +} diff --git a/crates/burn-optim/src/optim/adamw.rs b/crates/burn-optim/src/optim/adamw.rs new file mode 100644 index 0000000..7195f94 --- /dev/null +++ b/crates/burn-optim/src/optim/adamw.rs @@ -0,0 +1,586 @@ +use burn_core as burn; + +use crate::RecordState; +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +use super::{AdaptiveMomentumState, Optimizer, module_optimizer::ModuleOptimizer}; +use crate::{LearningRate, grad_clipping::GradientClippingConfig}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// [`AdamW`] Configuration. +#[derive(Config, Debug)] +pub struct AdamWConfig { + /// Parameter for AdamW. + #[config(default = 0.9)] + beta_1: f32, + /// Parameter for AdamW. + #[config(default = 0.999)] + beta_2: f32, + /// A value required for numerical stability. + #[config(default = 1e-5)] + epsilon: f32, + /// Weight decay config. + #[config(default = 1e-4)] + weight_decay: f32, + + /// Cautious weight decay config. + /// + /// See: + #[config(default = false)] + cautious_weight_decay: bool, + + /// Whether to use AMSGrad algorithm + #[config(default = false)] + amsgrad: bool, + /// [Gradient Clipping](GradientClippingConfig) config. + grad_clipping: Option, +} + +/// AdamW optimizer. +/// +/// See: +/// - [Decoupled Weight Decay Regularization, Loshchilov and Hutter, 2019](https://arxiv.org/abs/1711.05101). +/// - [Cautious Weight Decay, 2025](https://arxiv.org/abs/2510.12402) +/// - [On the Convergence of Adam and Beyond](https://openreview.net/forum?id=ryQu7f-RZ) +/// +/// Configured by [`AdamWConfig`]. +#[derive(Clone)] +pub struct AdamW { + momentum: AdaptiveMomentumW, + weight_decay: f32, + cautious_weight_decay: bool, +} + +/// AdamW state. +#[derive(RecordState, Clone, new)] +pub struct AdamWState { + /// Th current adaptive momentum state. + pub momentum: AdaptiveMomentumState, +} + +impl Optimizer for AdamW { + type State = AdamWState; + + /// A single optimization step for any tensor that represents the parameters of a model. + fn step( + &self, + // Learning rate. + lr: LearningRate, + // Any tensor that represents the parameters of a model. + tensor: Tensor, + // Gradient of the loss w.r.t. the parameters. + grad: Tensor, + // State of the optimizer. + state: Option>, + ) -> (Tensor, Option>) { + let (raw_delta, momentum_state) = self.momentum.transform(grad, state.map(|s| s.momentum)); + + let decay_rate = lr * (self.weight_decay as f64); + + let decayed_tensor = if decay_rate == 0.0 { + tensor.clone() + } else if self.cautious_weight_decay { + // Cautious weight decay. + // See: https://arxiv.org/abs/2510.12402 + let tensor_pos = tensor.clone().greater_equal_elem(0.0); + let grad_pos = momentum_state.moment_1.clone().greater_equal_elem(0.0); + let differ = tensor_pos.not_equal(grad_pos); + + // Zero out the decay where the decay is counter to the update direction. + tensor.clone() - tensor.mul_scalar(decay_rate).mask_fill(differ, 0.0) + } else { + tensor.clone().mul_scalar(1.0 - decay_rate) + }; + + let tensor_updated = decayed_tensor - raw_delta.mul_scalar(lr); + + let state = AdamWState { + momentum: momentum_state, + }; + + (tensor_updated, Some(state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.momentum = state.momentum.to_device(device); + state + } +} + +impl AdamWConfig { + /// Build an [`AdamW`] from the config. + pub(crate) fn build(&self) -> AdamW { + AdamW { + momentum: AdaptiveMomentumW { + beta_1: self.beta_1, + beta_2: self.beta_2, + epsilon: self.epsilon, + amsgrad: self.amsgrad, + }, + weight_decay: self.weight_decay, + cautious_weight_decay: self.cautious_weight_decay, + } + } + + /// Initialize AdamW optimizer. + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.grad_clipping { + optim = optim.with_grad_clipping(config.init()); + } + optim + } +} + +#[derive(Clone)] +struct AdaptiveMomentumW { + beta_1: f32, + beta_2: f32, + epsilon: f32, + amsgrad: bool, +} + +impl AdaptiveMomentumW { + pub fn transform( + &self, + grad: Tensor, + state: Option>, + ) -> (Tensor, AdaptiveMomentumState) { + let factor_1 = 1.0 - self.beta_1; + let factor_2 = 1.0 - self.beta_2; + + let state = if let Some(mut state) = state { + // Update first moment estimate. + state.moment_1 = state + .moment_1 + .mul_scalar(self.beta_1) + .add(grad.clone().mul_scalar(factor_1)); + + // Update second moment estimate. + state.moment_2 = state + .moment_2 + .mul_scalar(self.beta_2) + .add(grad.square().mul_scalar(factor_2)); + + if self.amsgrad { + let max_v = state + .max_moment_2 + .take() + .unwrap_or_else(|| state.moment_2.clone()); + state.max_moment_2 = Some(max_v.max_pair(state.moment_2.clone())); + } + + // Update time. + state.time += 1; + + state + } else { + // Initialize first moment estimate. + let moment_1 = grad.clone().mul_scalar(factor_1); + + // Initialize second moment estimate. + let moment_2 = grad.square().mul_scalar(factor_2); + let max_moment_2 = self.amsgrad.then(|| moment_2.clone()); + AdaptiveMomentumState { + time: 1, + moment_1, + moment_2, + max_moment_2, + } + }; + + let time: i32 = state.time as i32; + + // Compute bias-corrected first and second moment estimates. + let moment_1_corrected = state + .moment_1 + .clone() + .div_scalar(1f32 - self.beta_1.powi(time)); + + let v_to_use = if self.amsgrad { + state.max_moment_2.as_ref().unwrap_or(&state.moment_2) + } else { + &state.moment_2 + }; + + let moment_2_corrected = v_to_use.clone().div_scalar(1f32 - self.beta_2.powi(time)); + + let update_delta = + moment_1_corrected.div(moment_2_corrected.sqrt().add_scalar(self.epsilon)); + + (update_delta, state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::GradientsParams; + use burn::module::Param; + use burn::tensor::Tolerance; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + type FT = f32; + + const LEARNING_RATE: LearningRate = 0.01; + + #[test] + fn test_adamw_optimizer_save_load_state() { + let device = Device::default().autodiff(); + let linear = LinearConfig::new(6, 6).init(&device); + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + let mut optimizer = create_adamw(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let bytes = optimizer.into_bytes().unwrap(); + assert!(!bytes.is_empty()); + + #[cfg(feature = "std")] + optimizer + .save(std::env::temp_dir().as_path().join("test_optim_adamw")) + .unwrap(); + + let state_optim_before = optimizer.to_record(); + let optimizer = create_adamw().from_bytes(bytes).unwrap(); + let state_optim_after = optimizer.to_record(); + + assert_eq!(state_optim_before.len(), state_optim_after.len()); + } + #[test] + fn test_adamw_optimizer_with_amsgrad_50_steps() { + let device = Device::default().autodiff(); + let mut linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + + let mut optimizer = AdamWConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_amsgrad(true) + .with_weight_decay(0.5) + .init(); + + for i in 1..=50 { + let x = Tensor::<2>::ones([2, 6], &device) + .mul_scalar(i as f32 * 0.1) + .require_grad(); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + } + + let state_updated = linear; + let weight_updated = state_updated.weight.to_data(); + let bias_updated = state_updated.bias.unwrap().to_data(); + + let weights_expected = TensorData::from([ + [ + -0.7822558283805847, + -0.42578864097595215, + -0.21805696189403534, + -0.28366872668266296, + -0.46587175130844116, + -0.4805040955543518, + ], + [ + -0.4722539782524109, + -0.5471276640892029, + -0.8181359767913818, + -0.33425918221473694, + -0.3805687427520752, + -0.7601516842842102, + ], + [ + -0.5475167632102966, + -0.5057991743087769, + -0.763265073299408, + -0.3393959403038025, + -0.7490996718406677, + -0.28911691904067993, + ], + [ + -0.7646660208702087, + -0.7050473093986511, + -0.8218720555305481, + -0.7647438049316406, + -0.5919585227966309, + -0.40617525577545166, + ], + [ + -0.27588561177253723, + -0.7025567889213562, + -0.24343004822731018, + -0.6672990918159485, + -0.23728127777576447, + -0.556389570236206, + ], + [ + -0.5451040267944336, + -0.5420684814453125, + -0.4348171353340149, + -0.3832150399684906, + -0.5099242925643921, + -0.23440153896808624, + ], + ]); + let bias_expected = TensorData::from([ + -0.7473056316375732, + -0.3745720386505127, + -0.5188710689544678, + -0.35184532403945923, + -0.33705732226371765, + -0.4332566559314728, + ]); + + let tolerance = Tolerance::absolute(1e-5); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + } + #[test] + fn test_adamw_optimizer_with_numbers() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdamWConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_weight_decay(0.5) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [-0.337295, 0.117827, 0.380358, 0.296868, 0.065232, 0.046534], + [ + 0.057032, -0.036518, -0.382951, 0.232516, 0.173738, -0.309182, + ], + [ + -0.038703, 0.016052, -0.313155, 0.225982, -0.295039, 0.289981, + ], + [ + -0.314920, -0.237394, -0.387704, -0.315067, -0.095153, 0.141081, + ], + [ + 0.306815, -0.234226, 0.348083, -0.191115, 0.356002, -0.049993, + ], + [-0.035634, -0.030083, 0.104636, 0.170244, 0.009196, 0.359580], + ]); + let bias_expected = TensorData::from([ + -0.406555, 0.067568, -0.115982, 0.096477, 0.115287, -0.007080, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + let tolerance = Tolerance::absolute(1e-2); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + #[test] + fn test_adamw_optimizer_with_numbers_cautious() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, -0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdamWConfig::new() + .with_cautious_weight_decay(true) + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_weight_decay(0.5) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [-0.337295, 0.117827, 0.380358, 0.296868, 0.065232, 0.046534], + [ + 0.057032, -0.036518, -0.382951, 0.232516, 0.173738, -0.309182, + ], + [ + -0.038703, 0.016052, -0.313155, 0.225982, -0.295039, 0.289981, + ], + [ + -0.314920, -0.237394, -0.387704, -0.315067, -0.095153, 0.141081, + ], + [ + 0.306815, -0.234226, 0.348083, -0.191115, 0.356002, -0.049993, + ], + [ + -0.035634, -0.030083, 0.104636, 0.170244, 0.009196, 0.37061332, + ], + ]); + let bias_expected = TensorData::from([ + -0.406555, 0.067568, -0.115982, 0.096477, 0.115287, -0.007080, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + let tolerance = Tolerance::absolute(1e-2); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + #[test] + fn test_adam_optimizer_no_nan() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + + let x = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdamWConfig::new() + .with_epsilon(1e-8) + .with_beta_1(0.9) + .with_beta_2(0.999) + .with_weight_decay(0.5) + .init(); + + let grads = linear.forward(x.clone()).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + assert!(!state_updated.weight.to_data().as_slice::().unwrap()[0].is_nan()); + } + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + + fn create_adamw() -> ModuleOptimizer { + let config = AdamWConfig::new(); + AdamW { + momentum: AdaptiveMomentumW { + beta_1: config.beta_1, + beta_2: config.beta_2, + epsilon: config.epsilon, + amsgrad: config.amsgrad, + }, + weight_decay: config.weight_decay, + cautious_weight_decay: false, + } + .into() + } +} diff --git a/crates/burn-optim/src/optim/adan.rs b/crates/burn-optim/src/optim/adan.rs new file mode 100644 index 0000000..fae141f --- /dev/null +++ b/crates/burn-optim/src/optim/adan.rs @@ -0,0 +1,448 @@ +use burn_core as burn; + +use crate::RecordState; +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +use super::{Optimizer, module_optimizer::ModuleOptimizer}; +use crate::{LearningRate, grad_clipping::GradientClippingConfig}; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// [`Adan`] Configuration. +/// +/// See: +/// - [Adan: Adaptive Nesterov Momentum Algorithm for Faster Optimizing Deep Models](https://arxiv.org/abs/2208.06677). +#[derive(Config, Debug)] +pub struct AdanConfig { + /// Parameter for the first moment. + #[config(default = 0.98)] + beta_1: f32, + /// Parameter for the gradient-difference momentum. + #[config(default = 0.92)] + beta_2: f32, + /// Parameter for the second moment. + #[config(default = 0.99)] + beta_3: f32, + /// A value required for numerical stability. + #[config(default = 1e-8)] + epsilon: f32, + /// Weight decay factor. + #[config(default = 0.0)] + weight_decay: f32, + /// Disable proximal weight decay and use the decoupled update instead. + #[config(default = false)] + no_prox: bool, + /// [Gradient Clipping](GradientClippingConfig) config. + grad_clipping: Option, +} + +/// Adan optimizer. +/// +/// See: +/// - [Adan: Adaptive Nesterov Momentum Algorithm for Faster Optimizing Deep Models](https://arxiv.org/abs/2208.06677). +/// +/// Configured by [`AdanConfig`]. +#[derive(Clone)] +pub struct Adan { + momentum: AdaptiveNesterovMomentum, + weight_decay: f32, + no_prox: bool, +} + +/// Adan state. +#[derive(RecordState, Clone, new)] +pub struct AdanState { + /// The current adaptive Nesterov momentum state. + pub momentum: AdaptiveNesterovMomentumState, +} + +impl Optimizer for Adan { + type State = AdanState; + + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + let (raw_delta, momentum_state) = self.momentum.transform(grad, state.map(|s| s.momentum)); + + let decay_rate = lr * (self.weight_decay as f64); + let delta = raw_delta.mul_scalar(lr); + + let tensor_updated = if self.no_prox { + if decay_rate == 0.0 { + tensor - delta + } else { + tensor.mul_scalar(1.0 - decay_rate) - delta + } + } else { + let updated = tensor - delta; + if decay_rate == 0.0 { + updated + } else { + updated.div_scalar(1.0 + decay_rate) + } + }; + + (tensor_updated, Some(AdanState::new(momentum_state))) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.momentum = state.momentum.to_device(device); + state + } +} + +impl AdanConfig { + /// Build an [`Adan`] from the config. + pub(crate) fn build(&self) -> Adan { + Adan { + momentum: AdaptiveNesterovMomentum { + beta_1: self.beta_1, + beta_2: self.beta_2, + beta_3: self.beta_3, + epsilon: self.epsilon, + }, + weight_decay: self.weight_decay, + no_prox: self.no_prox, + } + } + + /// Initialize Adan optimizer. + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.grad_clipping { + optim = optim.with_grad_clipping(config.init()); + } + optim + } +} + +/// Adaptive Nesterov momentum state. +#[derive(RecordState, Clone, new)] +pub struct AdaptiveNesterovMomentumState { + /// The number of iterations aggregated. + pub time: usize, + /// The first order momentum. + pub exp_avg: Tensor, + /// The gradient-difference weighted second order momentum. + pub exp_avg_sq: Tensor, + /// The gradient-difference momentum. + pub exp_avg_diff: Tensor, + /// The negated previous gradient. + pub neg_pre_grad: Tensor, +} + +#[derive(Clone)] +struct AdaptiveNesterovMomentum { + beta_1: f32, + beta_2: f32, + beta_3: f32, + epsilon: f32, +} + +impl AdaptiveNesterovMomentum { + pub fn transform( + &self, + grad: Tensor, + state: Option>, + ) -> (Tensor, AdaptiveNesterovMomentumState) { + let state = if let Some(mut state) = state { + let grad_diff = state.neg_pre_grad.clone().add(grad.clone()); + let grad_diff_sq = grad_diff + .clone() + .mul_scalar(self.beta_2) + .add(grad.clone()) + .square(); + + state.exp_avg = state + .exp_avg + .mul_scalar(self.beta_1) + .add(grad.clone().mul_scalar(1.0 - self.beta_1)); + state.exp_avg_diff = state + .exp_avg_diff + .mul_scalar(self.beta_2) + .add(grad_diff.mul_scalar(1.0 - self.beta_2)); + state.exp_avg_sq = state + .exp_avg_sq + .mul_scalar(self.beta_3) + .add(grad_diff_sq.mul_scalar(1.0 - self.beta_3)); + state.neg_pre_grad = grad.mul_scalar(-1.0); + state.time += 1; + state + } else { + AdaptiveNesterovMomentumState::new( + 1, + grad.clone().mul_scalar(1.0 - self.beta_1), + grad.clone().square().mul_scalar(1.0 - self.beta_3), + grad.zeros_like(), + grad.clone().mul_scalar(-1.0), + ) + }; + + let time = state.time as i32; + let denom = state + .exp_avg_sq + .clone() + .sqrt() + .div_scalar((1.0 - self.beta_3.powi(time)).sqrt()) + .add_scalar(self.epsilon); + let update = state + .exp_avg + .clone() + .div_scalar(1.0 - self.beta_1.powi(time)) + .div(denom.clone()) + .add( + state + .exp_avg_diff + .clone() + .mul_scalar(self.beta_2) + .div_scalar(1.0 - self.beta_2.powi(time)) + .div(denom), + ); + + (update, state) + } +} + +impl AdaptiveNesterovMomentumState { + #[allow(clippy::wrong_self_convention)] + fn to_device(mut self, device: &Device) -> Self { + self.exp_avg = self.exp_avg.to_device(device); + self.exp_avg_sq = self.exp_avg_sq.to_device(device); + self.exp_avg_diff = self.exp_avg_diff.to_device(device); + self.neg_pre_grad = self.neg_pre_grad.to_device(device); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::GradientsParams; + use burn::module::Param; + use burn::tensor::Tolerance; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + type FT = f32; + + const LEARNING_RATE: LearningRate = 0.01; + + #[test] + fn test_adan_optimizer_save_load_state() { + let device = Device::default().autodiff(); + let linear = LinearConfig::new(6, 6).init(&device); + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + let mut optimizer = create_adan(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let bytes = optimizer.into_bytes().unwrap(); + assert!(!bytes.is_empty()); + + #[cfg(feature = "std")] + optimizer + .save(std::env::temp_dir().as_path().join("test_optim_adan")) + .unwrap(); + + let state_optim_before = optimizer.to_record(); + let optimizer = create_adan().from_bytes(bytes).unwrap(); + let state_optim_after = optimizer.to_record(); + + assert_eq!(state_optim_before.len(), state_optim_after.len()); + } + + #[test] + fn test_adan_optimizer_with_numbers() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdanConfig::new() + .with_beta_1(0.98) + .with_beta_2(0.92) + .with_beta_3(0.99) + .with_epsilon(1e-8) + .with_weight_decay(0.02) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [ + -0.34034607, + 0.11747075, + 0.38426402, + 0.29999772, + 0.06599136, + 0.04719888, + ], + [ + 0.0644293, + -0.031732224, + -0.37979296, + 0.24165839, + 0.18218218, + -0.30532277, + ], + [ + -0.038910445, + 0.01466812, + -0.31599957, + 0.2283826, + -0.29780683, + 0.2929568, + ], + [ + -0.3178632, + -0.24129382, + -0.39133376, + -0.31796312, + -0.09605193, + 0.14255258, + ], + [ + 0.31026322, + -0.23771758, + 0.3519465, + -0.19243571, + 0.35984334, + -0.049992695, + ], + [ + -0.03577819, + -0.031879753, + 0.10586514, + 0.17213862, + 0.009403733, + 0.36326218, + ], + ]); + let bias_expected = TensorData::from([ + -0.4103378, + 0.06837065, + -0.116955206, + 0.097558975, + 0.11655137, + -0.006999196, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + let tolerance = Tolerance::absolute(1e-5); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + #[test] + fn test_adan_optimizer_no_nan() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + + let x = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = AdanConfig::new() + .with_epsilon(1e-8) + .with_weight_decay(0.02) + .init(); + + let grads = linear.forward(x.clone()).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + assert!(!state_updated.weight.to_data().as_slice::().unwrap()[0].is_nan()); + } + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + + fn create_adan() -> ModuleOptimizer { + let config = AdanConfig::new(); + Adan { + momentum: AdaptiveNesterovMomentum { + beta_1: config.beta_1, + beta_2: config.beta_2, + beta_3: config.beta_3, + epsilon: config.epsilon, + }, + weight_decay: config.weight_decay, + no_prox: config.no_prox, + } + .into() + } +} diff --git a/crates/burn-optim/src/optim/base.rs b/crates/burn-optim/src/optim/base.rs new file mode 100644 index 0000000..abb41c2 --- /dev/null +++ b/crates/burn-optim/src/optim/base.rs @@ -0,0 +1,51 @@ +use burn_core::Tensor; + +use burn_core::module::ParamId; +use burn_core::tensor::Device; + +use super::GradientsParams; +use alloc::vec::Vec; + +#[derive(Default)] +/// Exposes multiple gradients for each parameter. +pub struct MultiGradientsParams { + /// Each [GradientsParams] has its associated [Device]. + pub grads: Vec<(GradientsParams, Device)>, +} + +impl MultiGradientsParams { + /// Removes the gradients for the given [parameter id](ParamId). + /// + /// Potentially accumulates the gradients from multiple sources using a device associated with + /// a parameter id. The same parameter will be accumulated using the same device during + /// all training. + pub fn remove(&mut self, id: ParamId) -> Option<(Tensor, Device)> { + let (mut tensor, device, index) = self.select(id)?; + + for (i, (grads, _)) in self.grads.iter_mut().enumerate() { + if i == index { + continue; + } + + if let Some(grad) = grads.remove::(id) { + tensor = tensor + grad.to_device(&device); + } + } + + Some((tensor, device)) + } + + fn select(&mut self, id: ParamId) -> Option<(Tensor, Device, usize)> { + let id_val = id.val() as usize; + for i in 0..self.grads.len() { + let selected_device_index = (id_val + i) % self.grads.len(); + + if let Some(acc) = self.grads[selected_device_index].0.remove::(id) { + let device = &self.grads[selected_device_index].1; + return Some((acc.to_device(device), device.clone(), selected_device_index)); + } + } + + None + } +} diff --git a/crates/burn-optim/src/optim/decay.rs b/crates/burn-optim/src/optim/decay.rs new file mode 100644 index 0000000..332fb7c --- /dev/null +++ b/crates/burn-optim/src/optim/decay.rs @@ -0,0 +1,64 @@ +use burn_core as burn; + +use crate::RecordState; +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// Configuration to create [weight decay](WeightDecay). +#[derive(Config, Debug)] +pub struct WeightDecayConfig { + /// L2 penalty. + pub penalty: f32, +} + +/// State of [weight decay](WeightDecay). +#[derive(RecordState, Clone, new)] +pub struct WeightDecayState { + pub(crate) grad_last_step: Tensor, +} + +/// Weight decay implementation that transforms gradients. +#[derive(Clone)] +pub struct WeightDecay { + penalty: f32, +} + +impl WeightDecay { + /// Creates a new [weight decay](WeightDecay) from a [config](WeightDecayConfig). + pub fn new(config: &WeightDecayConfig) -> Self { + Self { + penalty: config.penalty, + } + } + + /// Transforms a gradient. + /// + /// # Arguments + /// + /// * `grad` - Gradient to transform. + /// * `tensor` - Tensor param of the last iteration. + /// + /// # Returns + /// + /// * `grad` - Transformed gradient. + pub fn transform(&self, grad: Tensor, tensor: Tensor) -> Tensor { + tensor.mul_scalar(self.penalty).add(grad) + } +} + +impl WeightDecayState { + /// Moves the state to a device. + /// + /// # Arguments + /// + /// * `device` - Device to move the state to. + /// + /// # Returns + /// + /// * `self` - Moved state. + pub fn to_device(mut self, device: &Device) -> Self { + self.grad_last_step = self.grad_last_step.to_device(device); + self + } +} diff --git a/crates/burn-optim/src/optim/grad_accum.rs b/crates/burn-optim/src/optim/grad_accum.rs new file mode 100644 index 0000000..6fdce36 --- /dev/null +++ b/crates/burn-optim/src/optim/grad_accum.rs @@ -0,0 +1,119 @@ +use burn_core as burn; + +use core::marker::PhantomData; + +use burn::module::{AutodiffModule, ModuleVisitor, Param}; +use burn::tensor::Tensor; + +use super::GradientsParams; + +/// Accumulate gradients into a single [GradientsParams] object. +pub struct GradientsAccumulator { + grads: GradientsParams, + phantom: PhantomData, +} + +impl Default for GradientsAccumulator { + fn default() -> Self { + Self::new() + } +} + +impl GradientsAccumulator { + /// Create a new gradients accumulator. + pub fn new() -> Self { + Self { + grads: GradientsParams::new(), + phantom: PhantomData, + } + } +} + +impl GradientsAccumulator { + /// Accumulate the given gradients for each parameter in the given module. + pub fn accumulate(&mut self, module: &M, grads: GradientsParams) + where + M: AutodiffModule, + { + let mut visitor = ModuleGradsAccumulator::::new(&mut self.grads, grads); + module.visit(&mut visitor); + } + + /// Return the accumulated gradients and reset the accumulator state. + pub fn grads(&mut self) -> GradientsParams { + let mut grads = GradientsParams::new(); + core::mem::swap(&mut self.grads, &mut grads); + + grads + } +} + +#[derive(new)] +struct ModuleGradsAccumulator<'a, M> { + grads: &'a mut GradientsParams, + grads_new: GradientsParams, + phantom: PhantomData, +} + +impl ModuleVisitor for ModuleGradsAccumulator<'_, M> { + fn visit_float(&mut self, param: &Param>) { + let grad_updated = match self.grads_new.remove::(param.id) { + Some(new) => match self.grads.remove::(param.id) { + Some(grad) => grad.add(new), + None => new, + }, + None => match self.grads.remove::(param.id) { + Some(grad) => grad, + None => return, + }, + }; + + self.grads.register::(param.id, grad_updated); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::{Device, Distribution}; + use burn_nn::{Linear, LinearConfig}; + + #[test] + fn test_accumulate_gradients_one_step() { + let device = Device::default().autodiff(); + let mut accumulator = GradientsAccumulator::new(); + let layer = layer(&device); + let loss = layer.forward(random_tensor(&device)); + let grads = GradientsParams::from_grads(loss.backward(), &layer); + + accumulator.accumulate(&layer, grads); + + let grads = accumulator.grads(); + assert!(!grads.is_empty()) + } + + #[test] + fn test_accumulate_gradients_two_steps() { + let device = Device::default().autodiff(); + let mut accumulator = GradientsAccumulator::new(); + let layer = layer(&device); + let loss_1 = layer.forward(random_tensor(&device)); + let loss_2 = layer.forward(random_tensor(&device)); + let grads_1 = GradientsParams::from_grads(loss_1.backward(), &layer); + let grads_2 = GradientsParams::from_grads(loss_2.backward(), &layer); + + accumulator.accumulate(&layer, grads_1); + accumulator.accumulate(&layer, grads_2); + + let grads = accumulator.grads(); + assert_eq!(grads.len(), 2) + } + + fn layer(device: &Device) -> Linear { + LinearConfig::new(20, 20).init(device) + } + + fn random_tensor(device: &Device) -> Tensor<2> { + Tensor::<2>::random([2, 20], Distribution::Default, device) + } +} diff --git a/crates/burn-optim/src/optim/grads.rs b/crates/burn-optim/src/optim/grads.rs new file mode 100644 index 0000000..5ff60be --- /dev/null +++ b/crates/burn-optim/src/optim/grads.rs @@ -0,0 +1,130 @@ +use burn_core as burn; + +use burn::{ + Tensor, + tensor::{Device, Gradients, container::TensorContainer}, +}; + +use burn::module::{AutodiffModule, ParamId}; + +use super::visitor::{GradientsParamsChangeDevice, GradientsParamsConverter}; + +/// Data type that contains gradients for parameters. +#[derive(Default, Debug)] +pub struct GradientsParams { + container: TensorContainer, +} + +impl GradientsParams { + /// Creates a new [GradientsParams](GradientsParams). + pub fn new() -> Self { + Self::default() + } + + /// Extract each tensor gradients for the given [module](AutodiffModule). + /// + /// Note: This consumes the gradients. See ['from_module'] to extract gradients only for + /// a specific module. + pub fn from_grads(grads: Gradients, module: &M) -> Self { + let mut grads = grads; + Self::from_module(&mut grads, module) + } + + /// Extract each tensor gradients for the given [module](AutodiffModule). + pub fn from_module(grads: &mut Gradients, module: &M) -> Self { + let mut grads_params = GradientsParams::new(); + let mut visitor = GradientsParamsConverter::::new(grads, &mut grads_params, None); + module.visit(&mut visitor); + grads_params + } + + /// Extract tensor gradients for the given [module](AutodiffModule) and given parameters. + pub fn from_params( + grads: &mut Gradients, + module: &M, + params: &[ParamId], + ) -> Self { + let mut grads_params = GradientsParams::new(); + let mut visitor = + GradientsParamsConverter::::new(grads, &mut grads_params, Some(params.to_vec())); + module.visit(&mut visitor); + grads_params + } + + /// Get the gradients for the given [parameter id](ParamId). + /// + /// # Notes + /// + /// You should use [remove](GradientsParams::remove) if you want to get the gradients + /// only one time. + pub fn get(&self, id: ParamId) -> Option> { + self.container.get(&id) + } + + /// Remove the gradients for the given [parameter id](ParamId). + pub fn remove(&mut self, id: ParamId) -> Option> { + self.container.remove(&id) + } + + /// Register a gradients tensor for the given [parameter id](ParamId). + /// + /// # Notes + /// + /// If a tensor is already registered for the given [parameter id](ParamId), it will be replaced. + pub fn register(&mut self, id: ParamId, value: Tensor) { + // TODO: always call value.inner() to make sure? + self.container.register(id, value) + } + + /// The number of gradients tensors registered. + pub fn len(&self) -> usize { + self.container.len() + } + + /// If any tensor is contained. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Change the device of each tensor gradients registered for the given [module](AutodiffModule). + pub fn to_device(mut self, device: &Device, module: &M) -> Self { + let mut visitor = GradientsParamsChangeDevice::::new(device, &mut self); + module.visit(&mut visitor); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::module::{Module, list_param_ids}; + use burn::tensor::Distribution; + use burn_nn::{Linear, LinearConfig}; + + #[test] + fn test_convert_grads() { + let device = Device::default().autodiff(); + let layer_1 = layer(&device); + let mut layer_2 = layer_1.clone(); + layer_2 = layer_2.fork(&device); + let loss_1 = layer_1.forward(random_tensor(&device)); + let loss_2 = layer_2.forward(random_tensor(&device)); + let grads_1 = GradientsParams::from_grads(loss_1.backward(), &layer_1); + let grads_2 = GradientsParams::from_grads(loss_2.backward(), &layer_2); + + let param_ids_1 = list_param_ids(&layer_1); + let param_ids_2 = list_param_ids(&layer_2); + + assert_eq!(param_ids_1, param_ids_2); + assert_eq!(grads_1.len(), param_ids_1.len()); + assert_eq!(grads_2.len(), param_ids_2.len()); + } + + fn layer(device: &Device) -> Linear { + LinearConfig::new(20, 20).init(device) + } + + fn random_tensor(device: &Device) -> Tensor<2> { + Tensor::<2>::random([2, 20], Distribution::Default, device) + } +} diff --git a/crates/burn-optim/src/optim/lbfgs.rs b/crates/burn-optim/src/optim/lbfgs.rs new file mode 100644 index 0000000..b2fd261 --- /dev/null +++ b/crates/burn-optim/src/optim/lbfgs.rs @@ -0,0 +1,1075 @@ +#![allow(clippy::excessive_precision)] + +use burn_core as burn; + +use super::GradientsParams; +use crate::{LearningRate, OptimizerRecord}; +use crate::{RecordState, StateSink, StateSource}; +use burn::config::Config; +use burn::module::{AutodiffModule, Module, ModuleMapper, ModuleVisitor, Param}; +use burn::store::RecordError; +use burn::tensor::{Bytes, Device, Tensor, TensorData}; +use serde::{Deserialize, Serialize}; + +use alloc::vec; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Cubic Interpolate +/// +/// Uses two points (x1, f1), (x2, f2) and their first derivatives g1,g2 to construct +/// a cubic interpolant and return its minimum within the given bounds. +fn cubic_interpolate( + x1: f64, + f1: f64, + g1: f64, + x2: f64, + f2: f64, + g2: f64, + bounds: Option<(f64, f64)>, +) -> f64 { + // Compute bounds of interpolation area + let (min_bound, max_bound) = bounds.unwrap_or(if x1 <= x2 { (x1, x2) } else { (x2, x1) }); + // Code for most common case: cubic interpolation of 2 points + // with function and derivative values for both + // Solution in this case (where x2 is the farthest point) + // d1 = g1 + g2 - 3*(f1 - f2) / (x1-x2); + // d2 = sqrt(d1^2 - g1 * g2); + // min_pos = x2 - (x2 - x1)*((g2 + d2 - d1)/(g2 - g1 + 2*d2)); + // t_new = min(max(min_pos,min_bound), max_bound); + let d1 = g1 + g2 - 3.0 * (f1 - f2) / (x1 - x2); + let d2_square = d1 * d1 - g1 * g2; + + if d2_square >= 0.0 { + let d2 = d2_square.sqrt(); + let min_pos = if x1 <= x2 { + x2 - (x2 - x1) * ((g2 + d2 - d1) / (g2 - g1 + 2.0 * d2)) + } else { + x1 - (x1 - x2) * ((g1 + d2 - d1) / (g1 - g2 + 2.0 * d2)) + }; + min_pos.max(min_bound).min(max_bound) + } else { + (min_bound + max_bound) / 2.0 + } +} +/// Auxiliary Struct For Strong_Wolfe +struct LineSearchSample { + // step size + t: f64, + // loss + f: f64, + // gradient + g: Tensor<1>, + // directional derivative + gtd: f64, +} + +#[allow(clippy::too_many_arguments)] +fn strong_wolfe( + // obj_func(x,step size,direction) -> (loss,grad) + obj_func: &mut F, + x: &Tensor<1>, + // initial step size + mut t: f64, + d: &Tensor<1>, + f: f64, + g: Tensor<1>, + gtd: f64, + c1: f64, + c2: f64, + tolerance_change: f64, + max_ls: usize, +) -> (f64, Tensor<1>, f64, usize) +where + F: FnMut(&Tensor<1>, f64, &Tensor<1>) -> (f64, Tensor<1>), +{ + let d_norm: f64 = d.clone().abs().max().into_scalar(); + + // evaluate objective and gradient using initial step + let (mut f_new, mut g_new) = obj_func(x, t, d); + let mut ls_func_evals = 1; + let mut gtd_new = g_new.clone().dot(d.clone()).into_scalar(); + + // bracket an interval [t_prev,t] containing a point satisfying the Wolfe criteria + let (mut t_prev, mut f_prev, mut g_prev, mut gtd_prev) = (0.0, f, g.clone(), gtd); + let mut done = false; + let mut ls_iter = 0; + + // the interval [low,high] using for Zoom phase + let mut bracket: Option<[LineSearchSample; 2]> = None; + // point which satisfy the wolfe condition + let mut wolfe_bracket: Option = None; + while ls_iter < max_ls { + // Checking Conditions. + + // Checking the Armijo Condition and function value increasing condition. + // Armijo: f(x+t*d) <= f(x) + c_1 t gtd + if f_new > (f + c1 * t * gtd) || (ls_iter > 1 && f_new >= f_prev) { + bracket = Some([ + LineSearchSample { + t: t_prev, + f: f_prev, + g: g_prev, + gtd: gtd_prev, + }, + LineSearchSample { + t, + f: f_new, + g: g_new.clone(), + gtd: gtd_new, + }, + ]); + break; + } + + // Checking Strong Wolfe Condition + // |gtd_new| <= -c_2 gtd + if gtd_new.abs() <= -c2 * gtd { + wolfe_bracket = Some(LineSearchSample { + t, + f: f_new, + g: g_new.clone(), + gtd: gtd_new, + }); + done = true; + break; + } + + // gtd_new >=0 , there must be a local minimum in the interval. + if gtd_new >= 0.0 { + bracket = Some([ + LineSearchSample { + t: t_prev, + f: f_prev, + g: g_prev, + gtd: gtd_prev, + }, + LineSearchSample { + t, + f: f_new, + g: g_new.clone(), + gtd: gtd_new, + }, + ]); + break; + } + + // interpolate + let min_step = t + 0.01 * (t - t_prev); + let max_step = t * 10.0; + let t_next = cubic_interpolate( + t_prev, + f_prev, + gtd_prev, + t, + f_new, + gtd_new, + Some((min_step, max_step)), + ); + t_prev = t; + f_prev = f_new; + g_prev = g_new; + gtd_prev = gtd_new; + + // next step + t = t_next; + (f_new, g_new) = obj_func(x, t, d); + ls_func_evals += 1; + gtd_new = g_new.clone().dot(d.clone()).into_scalar(); + ls_iter += 1; + } + if let Some(sample) = wolfe_bracket { + return (sample.f, sample.g, sample.t, ls_func_evals); + } + + let mut bracket = bracket.unwrap_or_else(|| { + [ + LineSearchSample { + t: 0.0, + f, + g: g.clone(), + gtd, + }, + LineSearchSample { + t, + f: f_new, + g: g_new.clone(), + gtd: gtd_new, + }, + ] + }); + + // zoom phase + let mut insuf_progress = false; + + // find high and low points in bracket + let (mut low_idx, mut high_idx) = if bracket[0].f <= bracket[1].f { + (0, 1) + } else { + (1, 0) + }; + + while !done && ls_iter < max_ls { + let diff = (bracket[1].t - bracket[0].t).abs(); + // line-search bracket is so small + if diff * d_norm < tolerance_change { + break; + } + + // compute new trial value + t = cubic_interpolate( + bracket[0].t, + bracket[0].f, + bracket[0].gtd, + bracket[1].t, + bracket[1].f, + bracket[1].gtd, + None, + ); + + let b_min = bracket[0].t.min(bracket[1].t); + let b_max = bracket[0].t.max(bracket[1].t); + let eps = 0.1 * (b_max - b_min); + + if (b_max - t).min(t - b_min) < eps { + // interpolation close to boundary + if insuf_progress || t >= b_max || t <= b_min { + t = if (t - b_max).abs() < (t - b_min).abs() { + b_max - eps + } else { + b_min + eps + }; + insuf_progress = false; + } else { + insuf_progress = true; + } + } else { + insuf_progress = false; + } + + // Evaluate new point + (f_new, g_new) = obj_func(x, t, d); + + ls_func_evals += 1; + gtd_new = g_new.clone().dot(d.clone()).into_scalar(); + ls_iter += 1; + + let armijo_holds = f_new <= (f + c1 * t * gtd) && f_new < bracket[low_idx].f; + + if !armijo_holds { + bracket[high_idx] = LineSearchSample { + t, + f: f_new, + g: g_new, + gtd: gtd_new, + }; + } else { + if gtd_new.abs() <= -c2 * gtd { + return (f_new, g_new, t, ls_func_evals); + } + + if gtd_new * (bracket[high_idx].t - bracket[low_idx].t) >= 0.0 { + bracket[high_idx] = LineSearchSample { + t: bracket[low_idx].t, + f: bracket[low_idx].f, + g: bracket[low_idx].g.clone(), + gtd: bracket[low_idx].gtd, + }; + } + bracket[low_idx] = LineSearchSample { + t, + f: f_new, + g: g_new, + gtd: gtd_new, + }; + } + + if bracket[0].f <= bracket[1].f { + low_idx = 0; + high_idx = 1; + } else { + low_idx = 1; + high_idx = 0; + } + } + // return stuff + ( + bracket[low_idx].f, + bracket[low_idx].g.clone(), + bracket[low_idx].t, + ls_func_evals, + ) +} + +/// Strategy for the line search optimization phase +#[derive(Clone, Default, Debug, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LineSearchFn { + /// No line search performed + #[default] + None, + /// strong wolfe conditions + /// + /// See: + StrongWolfe, +} + +/// LBFGS Configuration. +#[derive(Config, Debug)] +pub struct LBFGSConfig { + /// Maximal number of iterations per optimization step (default: 20) + #[config(default = 20)] + pub max_iter: usize, + /// Update history size (default: 100). + #[config(default = 100)] + pub history_size: usize, + /// Termination tolerance on first order optimality (default: 1e-7). + #[config(default = 1e-7)] + pub tolerance_grad: f64, + /// Termination tolerance on function value/parameter changes (default: 1e-9). + #[config(default = 1e-9)] + pub tolerance_change: f64, + /// Maximal number of function evaluations per optimization step (default: max_iter * 1.25). + #[config(default = "None")] + pub max_eval: Option, + /// Either ‘strong_wolfe’ or None (default: None). + #[config(default = "LineSearchFn::None")] + pub line_search_fn: LineSearchFn, +} + +impl LBFGSConfig { + /// Initialize AdamW optimizer + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module + pub fn init(&self) -> LBFGS { + // by default max_eval = max_iter * 5/4 + let max_eval = self.max_eval.unwrap_or(self.max_iter * 5 / 4); + LBFGS { + config: LBFGSConfig { + max_iter: self.max_iter, + history_size: self.history_size, + tolerance_grad: self.tolerance_grad, + tolerance_change: self.tolerance_change, + max_eval: Some(max_eval), + line_search_fn: self.line_search_fn, + }, + state: Default::default(), + } + } +} + +/// Collects gradients in module visit order. +struct FlattenGradsVisitorInner<'a> { + grads: &'a GradientsParams, + tensors: &'a mut Vec>, +} + +impl ModuleVisitor for FlattenGradsVisitorInner<'_> { + fn visit_float(&mut self, param: &Param>) { + if let Some(g) = self.grads.get::(param.id) { + let numel = g.shape().num_elements(); + self.tensors.push(g.reshape([numel])); + } + } +} + +/// Flatten params to inner backend 1D tensor. +fn flatten_params_inner(module: &M) -> Tensor<1> { + let mut tensors = Vec::new(); + let mut visitor = FlattenParamsVisitorInner { + tensors: &mut tensors, + }; + module.visit(&mut visitor); + if tensors.is_empty() { + return Tensor::empty([0], &module.devices()[0].clone().inner()); + } + Tensor::cat(tensors, 0) +} + +struct FlattenParamsVisitorInner<'a> { + tensors: &'a mut Vec>, +} + +impl ModuleVisitor for FlattenParamsVisitorInner<'_> { + fn visit_float(&mut self, param: &Param>) { + let t = param.val().inner(); + let numel = t.shape().num_elements(); + self.tensors.push(t.reshape([numel])); + } +} + +/// Flatten gradients for a module. +fn flatten_grads_inner(module: &M, grads: &GradientsParams) -> Tensor<1> { + let mut tensors = Vec::new(); + let mut visitor = FlattenGradsVisitorInner { + grads, + tensors: &mut tensors, + }; + module.visit(&mut visitor); + if tensors.is_empty() { + return Tensor::empty([0], &module.devices()[0].clone().inner()); + } + Tensor::cat(tensors, 0) +} + +/// Mapper that assigns each float param from a flat inner-backend 1D tensor. +struct ParamsFromFlatMapperInner<'a> { + flat: &'a Tensor<1>, + offset: &'a mut usize, +} + +impl ParamsFromFlatMapperInner<'_> { + fn take_slice(&mut self, numel: usize) -> Tensor<1> { + let start = *self.offset; + *self.offset += numel; + self.flat.clone().slice(start..*self.offset) + } +} + +impl ModuleMapper for ParamsFromFlatMapperInner<'_> { + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + let numel = tensor.shape().num_elements(); + let slice_1d = self.take_slice(numel); + let new_inner = slice_1d.reshape(tensor.shape()); + let new_tensor = Tensor::from_inner(new_inner).require_grad(); + Param::from_mapped_value(id, new_tensor, mapper) + } +} + +/// Overwrite module parameters from a flat inner-backend 1D tensor +fn set_params_from_flat_inner(module: M, flat: Tensor<1>) -> M { + let mut offset = 0; + let mut mapper = ParamsFromFlatMapperInner { + flat: &flat, + offset: &mut offset, + }; + module.map(&mut mapper) +} + +/// L-BFGS optimizer state +#[derive(Clone, RecordState)] +pub struct LBFGSState { + /// Historical displacement vectors + pub history_s: Vec>, + /// Historical gradient difference vectors + pub history_y: Vec>, + /// Search direction + pub d: Option>, + /// Step size from the previous iteration + pub t: Option, + /// Flattened gradient from the previous iteration + pub prev_flat_grad: Option>, + /// Loss value from the previous iteration + pub prev_loss: Option, + /// Global iteration count + pub g_iter: usize, +} + +impl LBFGSState { + /// The device of the state's tensors, if any have been populated. + fn current_device(&self) -> Option { + self.prev_flat_grad + .as_ref() + .or(self.d.as_ref()) + .or(self.history_s.first()) + .map(|t| t.device()) + } + + /// Moves all historical tensors to the target device. + pub fn to_device(self, device: &Device) -> Self { + Self { + history_s: self + .history_s + .into_iter() + .map(|t| t.to_device(device)) + .collect(), + history_y: self + .history_y + .into_iter() + .map(|t| t.to_device(device)) + .collect(), + d: self.d.map(|t| t.to_device(device)), + t: self.t, + prev_flat_grad: self.prev_flat_grad.map(|t| t.to_device(device)), + prev_loss: self.prev_loss, + g_iter: self.g_iter, + } + } +} +impl Default for LBFGSState { + fn default() -> Self { + Self { + history_s: Vec::new(), + history_y: Vec::new(), + d: None, + t: Some(1.0), + prev_flat_grad: None, + prev_loss: None, + g_iter: 0, + } + } +} + +/// L-BFGS optimizer. +/// +/// Ported from [pytorch](https://github.com/pytorch/pytorch/torch/optim/lbfgs.py). Heavily inspired by [miniFunc](https://www.cs.ubc.ca/~schmidtm/Software/minFunc.html) +/// +/// See also: +/// - [L-BFGS](https://en.wikipedia.org/wiki/Limited-memory_BFGS) +/// +/// # Note +/// This optimizer is memory intensive +#[derive(Clone)] +pub struct LBFGS { + config: LBFGSConfig, + state: LBFGSState, +} + +impl LBFGS { + /// Decompose the optimizer state into a serializable [`OptimizerRecord`] (burnpack format). + /// + /// L-BFGS keeps a single global state rather than per-parameter state, so its tensors are + /// named directly (e.g. `history_s.0`) and carry no parameter id. + pub fn to_record(&self) -> OptimizerRecord { + let mut sink = StateSink::default(); + RecordState::state_flatten(&self.state, "", &mut sink); + + let tensors = sink + .tensors + .into_iter() + .map(|(name, data)| { + burn_pack::Tensor::new(name, data.dtype, data.shape, None, data.bytes) + }) + .collect(); + let scalars = sink.scalars.into_iter().collect(); + + OptimizerRecord { + tensors, + scalars, + paths: Default::default(), + } + } + + /// Load the optimizer state from an [`OptimizerRecord`]. + /// + /// State tensors are materialized on the default device; the state is migrated to the gradient + /// device on the next [`step`](LBFGS::step), so no device argument is needed. + pub fn load_record(mut self, record: OptimizerRecord) -> Self { + let device = Device::default(); + let mut source = StateSource::new(record.scalars); + for tensor in record.tensors { + let data = TensorData::from_bytes(tensor.bytes, tensor.shape, tensor.dtype); + source.insert_tensor(tensor.name, data); + } + if let Some(state) = LBFGSState::state_unflatten("", &mut source, &device) { + self.state = state; + } + self + } + + /// Serialize the optimizer state to an in-memory burnpack byte buffer. + pub fn into_bytes(&self) -> Result { + self.to_record().into_bytes() + } + + /// Load the optimizer state from an in-memory burnpack byte buffer. + pub fn from_bytes(self, bytes: Bytes) -> Result { + Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?)) + } + + /// Save the optimizer state to a burnpack file on disk. + #[cfg(feature = "std")] + pub fn save>(&self, path: P) -> Result<(), RecordError> { + self.to_record().save(path) + } + + /// Load the optimizer state from a burnpack file on disk. + #[cfg(feature = "std")] + pub fn load>(self, path: P) -> Result { + Ok(self.load_record(OptimizerRecord::load(path)?)) + } + + /// A single optimization step for any tensor that represents the parameters of a model. + pub fn step(&mut self, lr: LearningRate, mut module: M, mut closure: F) -> (M, f64) + where + M: AutodiffModule + Clone, + F: FnMut(M) -> (f64, GradientsParams), + { + // evaluate initial f(x) and df/dx + let (mut loss, grads) = closure(module.clone()); + let mut current_evals = 1; + + let mut flat_grad = flatten_grads_inner::(&module, &grads); + let mut x_flat = flatten_params_inner::(&module); + + // Migrate the state to the gradient's device when they differ (e.g. just after loading a + // record on the default device). This is a no-op once the state is built from gradients. + let device = flat_grad.device(); + if self.state.current_device().is_some_and(|d| d != device) { + self.state = core::mem::take(&mut self.state).to_device(&device); + } + + let opt_cond = + flat_grad.clone().abs().max().into_scalar::() <= self.config.tolerance_grad; + // optimal condition + if opt_cond { + return (module, loss); + } + + // tensors cached in state + let mut d = self + .state + .d + .take() + .unwrap_or_else(|| flat_grad.clone().neg()); + let mut t = self.state.t.unwrap_or(lr); + let mut prev_flat_grad = self.state.prev_flat_grad.take(); + + let mut n_iter = 0; + + // optimize for a max of max_iter iterations + while n_iter < self.config.max_iter { + // keep track of nb of iterations + n_iter += 1; + self.state.g_iter += 1; + + // compute gradient descent direction + if self.state.g_iter == 1 { + d = flat_grad.clone().neg(); + self.state.history_s.clear(); + self.state.history_y.clear(); + } else { + // do lbfgs update (update memory) + if let Some(pg) = prev_flat_grad.as_ref() { + let y = flat_grad.clone().sub(pg.clone()); + let s = d.clone().mul_scalar(t); + + let ys: f64 = y.clone().dot(s.clone()).into_scalar(); + + if ys > 1e-10 { + // updating memory + if self.state.history_s.len() >= self.config.history_size { + // shift history by one (limited-memory) + self.state.history_s.remove(0); + self.state.history_y.remove(0); + } + self.state.history_s.push(s); + self.state.history_y.push(y); + } + } + + // compute the approximate (L-BFGS) inverse Hessian + // multiplied by the gradient + let num_old = self.state.history_s.len(); + let mut q = flat_grad.clone().neg(); + let mut alphas: Vec> = + vec![Tensor::zeros([1], &flat_grad.device().inner()); num_old]; + + if num_old > 0 { + // multiply by initial Hessian + // r/d is the final direction + for i in (0..num_old).rev() { + let s = &self.state.history_s[i]; + let y = &self.state.history_y[i]; + let rho = y.clone().dot(s.clone()).powf_scalar(-1.0); + let alpha = rho.clone().mul(s.clone().dot(q.clone())); + alphas[i] = alpha.clone(); + q = q.sub(y.clone().mul(alpha)); + } + + let last_s = &self.state.history_s[num_old - 1]; + let last_y = &self.state.history_y[num_old - 1]; + let ys = last_y.clone().dot(last_s.clone()); + let yy = last_y.clone().dot(last_y.clone()); + let h_diag = ys.div(yy); + + let mut r = q.mul(h_diag); + + for ((s, y), alpha) in self + .state + .history_s + .iter() + .zip(self.state.history_y.iter()) + .zip(alphas) + .take(num_old) + { + let rho = y.clone().dot(s.clone()).powf_scalar(-1.0); + + let beta = rho.mul(y.clone().dot(r.clone())); + + r = r.add(s.clone().mul(alpha.sub(beta))); + } + d = r; + } else { + d = q; + } + } + + prev_flat_grad = Some(flat_grad.clone()); + let prev_loss_iter = loss; + + // compute step len + if self.state.g_iter == 1 { + let grad_l1: f64 = flat_grad.clone().abs().sum().into_scalar(); + t = (1.0f64 / grad_l1).min(1.0) * lr; + } else { + t = lr; + } + + // directional derivative + let gtd = flat_grad.clone().dot(d.clone()).into_scalar(); + + if gtd > -self.config.tolerance_change { + break; + } + + let ls_func_evals; + + if let LineSearchFn::StrongWolfe = self.config.line_search_fn { + // perform line search, using user function + let mut obj_func = |current_x: &Tensor<1>, step: f64, dir: &Tensor<1>| { + let update = dir.clone().mul_scalar(step); + let new_x = current_x.clone().add(update); + let tmp_module = set_params_from_flat_inner::(module.clone(), new_x); + let (l, g) = closure(tmp_module); + (l, flatten_grads_inner::(&module, &g)) + }; + + let (ls_f, ls_g, ls_t, evals) = strong_wolfe( + &mut obj_func, + &x_flat, + t, + &d, + loss, + flat_grad.clone(), + gtd, + 1e-4, + 0.9, + self.config.tolerance_change, + self.config.max_eval.unwrap() - current_evals, + ); + + loss = ls_f; + flat_grad = ls_g; + t = ls_t; + ls_func_evals = evals; + + x_flat = x_flat.add(d.clone().mul_scalar(t)); + module = set_params_from_flat_inner::(module, x_flat.clone()); + } else { + // no line search, simply move with fixed-step + let step_vec = d.clone().mul_scalar(t); + x_flat = x_flat.add(step_vec); + module = set_params_from_flat_inner::(module, x_flat.clone()); + // re-evaluate function only if not in last iteration + // the reason we do this: in a stochastic setting, + // no use to re-evaluate that function here + let (new_loss, new_grads) = closure(module.clone()); + loss = new_loss; + flat_grad = flatten_grads_inner::(&module, &new_grads); + ls_func_evals = 1; + } + + // update func eval + current_evals += ls_func_evals; + + // check conditions + + if current_evals >= self.config.max_eval.unwrap() { + break; + } + + if flat_grad.clone().abs().max().into_scalar::() <= self.config.tolerance_grad { + break; + } + + if d.clone().mul_scalar(t).abs().max().into_scalar::() + <= self.config.tolerance_change + { + break; + } + + if (loss - prev_loss_iter).abs() < self.config.tolerance_change { + break; + } + } + self.state.d = Some(d); + self.state.t = Some(t); + self.state.prev_flat_grad = prev_flat_grad; + self.state.prev_loss = Some(loss); + (module, loss) + } + /// Moves the optimizer state to the specified device. + pub fn to_device(self, device: &Device) -> Self { + Self { + config: self.config, + // History tensors reside in InnerBackend, so we convert the device accordingly + state: self.state.to_device(device), + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::GradientsParams; + use burn::module::Param; + use burn::tensor::{Tensor, TensorData}; + use burn_nn::Linear; + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + #[test] + fn test_cubic_interpolate() { + let tolerance = 1e-8; + + // basic + let (x1, f1, g1, x2, f2, g2) = (-1.0, 1.0, -2.0, 1.0, 1.0, 2.0); + let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, None); + assert!( + (result - 0.00000).abs() < tolerance, + "Basic: Result {} should be close to 0.0", + result + ); + + // bound + let (x1, f1, g1, x2, f2, g2) = (0.0, 0.25, -1.0, 1.0, 0.25, 1.0); + let bounds = Some((0.6, 1.0)); + let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, bounds); + assert!( + (result - 0.6000000000).abs() < tolerance, + "Bound: Result {} should be clamped to 0.6", + result + ); + + // d2_square < 0,should return mid value + let (x1, f1, g1, x2, f2, g2) = (0.0, 0.0, 10.0, 1.0, 5.0, 10.0); + let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, Some((0.0, 1.0))); + assert!( + (result - 0.5000000).abs() < tolerance, + "Fallback: Result {} should be midpoint 0.5", + result + ); + + // asymmetric + let (x1, f1, g1, x2, f2, g2) = (0.0, 1.0, -5.0, 1.0, 0.5, 1.0); + let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, None); + assert!( + (result - 0.4606553370833684).abs() < tolerance, + "Asymmetric: Result {} should be 0.4606553370833684", + result + ); + + // not good value + let (x1, f1, g1, x2, f2, g2) = ( + 1.231232145, + -0.12567458754, + 9.1231243007, + 8.239105015, + -100.9012398021, + 123201321.0293982, + ); + let result_1 = cubic_interpolate(x1, f1, g1, x2, f2, g2, None); + let result_2 = cubic_interpolate(x1, f1, g1, x2, f2, g2, Some((-4.4, 4.4))); + assert!( + (result_1 - 5.9031480234724434).abs() < tolerance, + "not good value 1: Result {} should be 5.9031480234724434", + result + ); + assert!( + (result_2 - 4.4000000000000004).abs() < tolerance, + "not good value 2: Result {} should be 4.4000000000000004", + result + ); + } + #[test] + fn test_strong_wolfe_direct_comparison() { + let device = Device::default().autodiff(); + let tol = 1e-6; + + { + let x = Tensor::<1>::from_floats([2.1321912957_f64], &device); + let d = Tensor::<1>::from_floats([0.91312321_f64], &device); + let t_initial = 1.213132_f64; + fn func(x_base: &Tensor<1>, t_val: f64, d_vec: &Tensor<1>) -> (f64, Tensor<1>) { + let curr_x = x_base.clone().add(d_vec.clone().mul_scalar(t_val)); + let x2 = curr_x.clone().mul(curr_x.clone()); + let x3 = x2.clone().mul(curr_x.clone()); + let x4 = x2.clone().mul(x2.clone()); + + // f(x) = x^4 - 2*x^2 + x + let f_elements = x4 - x2.mul_scalar(2.0) + curr_x.clone(); + + let f_val = f_elements.sum().into_scalar(); + + // g(x) = 4*x^3 - 4*x + 1 + let g = x3.mul_scalar(4.0) - curr_x.clone().mul_scalar(4.0) + + Tensor::ones_like(&curr_x); + + (f_val, g) + } + let (f_init, g_init) = func(&x, 0.0, &d); + let gtd_init = g_init.clone().dot(d.clone()).into_scalar::(); + println!("Initial State: f={},gtd = {}", f_init, gtd_init); + assert!((f_init - 13.7080059052).abs() < tol); + assert!((gtd_init - 28.5305728912).abs() < tol); + let mut obj_func = |xb: &Tensor<1>, tv: f64, dv: &Tensor<1>| func(xb, tv, dv); + + let (f_final, _g_final, t_final, evals) = strong_wolfe( + &mut obj_func, + &x, + t_initial, + &d, + f_init, + g_init, + gtd_init, + 1e-4, // c1 + 0.9, // c2 + 1e-9, // tolerance_change + 10, // max_ls + ); + let g_f = _g_final.into_scalar::(); + println!( + "f_final:{:?},_g_final:{:?},t_final:{:?},evals:{:?}", + f_final, g_f, t_final, evals + ); + assert!((f_final - 13.708005905151367).abs() < tol); + assert!((g_f - 31.2450428009).abs() < tol); + assert!((t_final - 0.0).abs() < tol); + assert!((evals == 11)); + } + } + #[test] + fn test_lbfgs_strong_wolfe_comparison() { + let device = Device::default().autodiff(); + let tol = 1e-5; + let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device); + let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device); + let weight = TensorData::from([[0.5f64]]); + let bias = TensorData::from([0.1f64]); + let module = given_linear_layer(weight, bias, &device); + + let mut optimizer = LBFGSConfig::new() + .with_line_search_fn(LineSearchFn::StrongWolfe) + .init(); + let mut closure = |mod_in: Linear| { + let output = mod_in.forward(x_data.clone()); + let loss = burn_nn::loss::MseLoss::new().forward( + output, + y_true.clone(), + burn_nn::loss::Reduction::Sum, + ); + + let grads = loss.backward(); + let grads_params = GradientsParams::from_grads(grads, &mod_in); + + (loss.into_scalar::(), grads_params) + }; + let initial_loss = closure(module.clone()).0; + assert!((initial_loss - 50.1300048828).abs() < tol); + let (updated_module, final_loss) = optimizer.step(0.001, module, &mut closure); + assert!((final_loss - 0.0234732367).abs() < tol); + let optimized_data: f64 = updated_module.weight.val().into_scalar(); + let optimized_bias: f64 = updated_module.bias.as_ref().unwrap().val().into_scalar(); + assert!((optimized_data - 2.0570652485).abs() < tol); + assert!((optimized_bias - 0.8106800914).abs() < tol); + } + + // A burnpack round-trip of the L-BFGS state (which holds `Vec` history buffers, optional + // tensors and optional scalars) must restore enough that a further step agrees with the original. + #[test] + fn test_lbfgs_burnpack_round_trip() { + let device = Device::default().autodiff(); + let tol = 1e-6; + let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device); + let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device); + let module = given_linear_layer( + TensorData::from([[0.5f64]]), + TensorData::from([0.1f64]), + &device, + ); + + let make_closure = || { + let x = x_data.clone(); + let y = y_true.clone(); + move |mod_in: Linear| { + let output = mod_in.forward(x.clone()); + let loss = burn_nn::loss::MseLoss::new().forward( + output, + y.clone(), + burn_nn::loss::Reduction::Sum, + ); + let grads = loss.backward(); + let grads_params = GradientsParams::from_grads(grads, &mod_in); + (loss.into_scalar::(), grads_params) + } + }; + + let mut optimizer = LBFGSConfig::new() + .with_line_search_fn(LineSearchFn::StrongWolfe) + .init(); + let (module, _) = optimizer.step(0.001, module, &mut make_closure()); + + // Round-trip the optimizer state. State tensors live on the inner (non-autodiff) backend. + let bytes = optimizer.into_bytes().unwrap(); + let mut reloaded = LBFGSConfig::new() + .with_line_search_fn(LineSearchFn::StrongWolfe) + .init() + .from_bytes(bytes) + .unwrap(); + + // A further identical step on each optimizer must agree — exercising the restored history. + let (_, loss_original) = optimizer.step(0.001, module.clone(), &mut make_closure()); + let (_, loss_reloaded) = reloaded.step(0.001, module, &mut make_closure()); + assert!( + (loss_original - loss_reloaded).abs() < tol, + "losses differ after burnpack round-trip: {loss_original} vs {loss_reloaded}" + ); + } + + #[test] + fn test_lbfgs_no_strong_wolfe_comparison() { + let device = Device::default().autodiff(); + let tol = 1e-5; + let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device); + let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device); + let weight = TensorData::from([[0.5f64]]); + let bias = TensorData::from([0.1f64]); + let module = given_linear_layer(weight, bias, &device); + + let mut optimizer = LBFGSConfig::new() + .with_line_search_fn(LineSearchFn::None) + .init(); + let mut closure = |mod_in: Linear| { + let output = mod_in.forward(x_data.clone()); + let loss = burn_nn::loss::MseLoss::new().forward( + output, + y_true.clone(), + burn_nn::loss::Reduction::Sum, + ); + + let grads = loss.backward(); + let grads_params = GradientsParams::from_grads(grads, &mod_in); + + (loss.into_scalar::(), grads_params) + }; + let initial_loss = closure(module.clone()).0; + assert!((initial_loss - 50.1300048828).abs() < tol); + let (updated_module, final_loss) = optimizer.step(0.001, module, &mut closure); + assert!((final_loss - 48.2181930542).abs() < tol); + let optimized_data: f64 = updated_module.weight.val().into_scalar(); + let optimized_bias: f64 = updated_module.bias.as_ref().unwrap().val().into_scalar(); + + assert!((optimized_data - 0.5302446192).abs() < tol); + assert!((optimized_bias - 0.1142520783).abs() < tol); + } +} diff --git a/crates/burn-optim/src/optim/mod.rs b/crates/burn-optim/src/optim/mod.rs new file mode 100644 index 0000000..68631f0 --- /dev/null +++ b/crates/burn-optim/src/optim/mod.rs @@ -0,0 +1,34 @@ +/// Weight decay module for optimizers. +pub mod decay; + +/// Momentum module for optimizers. +pub mod momentum; + +mod adagrad; +mod adam; +mod adamw; +mod adan; +mod base; +mod grad_accum; +mod grads; +mod lbfgs; +mod module; +mod muon; +mod rmsprop; +mod sgd; +mod state; +mod visitor; + +pub use adagrad::*; +pub use adam::*; +pub use adamw::*; +pub use adan::*; +pub use base::*; +pub use grad_accum::*; +pub use grads::*; +pub use lbfgs::*; +pub use module::*; +pub use muon::*; +pub use rmsprop::*; +pub use sgd::*; +pub use state::*; diff --git a/crates/burn-optim/src/optim/module/base.rs b/crates/burn-optim/src/optim/module/base.rs new file mode 100644 index 0000000..b9cbab2 --- /dev/null +++ b/crates/burn-optim/src/optim/module/base.rs @@ -0,0 +1,210 @@ +use alloc::sync::Arc; +use core::any::Any; + +use crate::{RecordState, StateSink, StateSource}; +use burn_core as burn; +use burn_core::tensor::kind::BridgeTensor; + +use crate::LearningRate; +use burn::tensor::{Device, Tensor}; + +/// An opinionated trait to simplify the process of implementing an optimizer. +/// +/// Implementations don't have to handle missing gradients, loading and exporting records, +/// navigate the module parameter structure, handle tracked and untracked tensors, and the likes. +/// Wrap one in a [`ModuleOptimizer`](crate::optim::ModuleOptimizer) to optimize a whole module. +pub trait Optimizer: Send + Sync + Clone + 'static { + /// The state of the optimizer for a single parameter of rank `D`. + /// + /// It implements [`RecordState`] (which itself requires `Send + Sync + 'static`) so it can be + /// decomposed into named tensors and scalars for the burnpack format. + type State: Clone + RecordState; + + /// The optimizer step is performed for one tensor at a time with its gradient and state. + /// + /// Note that the state is passed as parameter, so implementations don't have to handle + /// the saving and loading of recorded states. + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + grad: Tensor, + state: Option>, + ) -> (Tensor, Option>); + + /// Change the device of the state. + /// + /// This function will be called accordingly to have the state on the same device as the + /// gradient and the tensor when the [step](Optimizer::step) function is called. + fn to_device(state: Self::State, device: &Device) -> Self::State; +} + +/// A type-erased optimizer state for a single parameter. +/// +/// It wraps a concrete `O::State` together with its rank `D` so that the rank can be recovered +/// when the state is later interpreted by the originating [`Optimizer`] (during a step, a device +/// transfer or serialization). +#[derive(Clone)] +pub struct DynState { + state: Arc, + rank: usize, +} + +impl DynState { + /// Erase a concrete optimizer state of rank `rank`. + pub fn create(state: T, rank: usize) -> Self { + Self { + state: Arc::new(state), + rank, + } + } + + /// Recover the concrete state by value, moving it out when this is the only handle and + /// cloning only when the underlying state is still shared. Panics if `T` does not match the + /// stored type. + pub fn downcast(self) -> T { + let state = self + .state + .downcast::() + .expect("The dynamic optimizer state should match the optimizer state type."); + Arc::try_unwrap(state).unwrap_or_else(|state| (*state).clone()) + } + + /// Borrow the concrete state without cloning. Panics if `T` does not match the stored type. + pub fn downcast_ref(&self) -> &T { + self.state + .downcast_ref::() + .expect("The dynamic optimizer state should match the optimizer state type.") + } + + /// The rank of the parameter this state belongs to. + pub fn rank(&self) -> usize { + self.rank + } +} + +/// Dispatch a runtime `rank` to a body parameterized by a `const D: usize`. +macro_rules! dispatch_rank { + ($rank:expr, $d:ident => $body:block) => { + match $rank { + 0 => { + const $d: usize = 0; + $body + } + 1 => { + const $d: usize = 1; + $body + } + 2 => { + const $d: usize = 2; + $body + } + 3 => { + const $d: usize = 3; + $body + } + 4 => { + const $d: usize = 4; + $body + } + 5 => { + const $d: usize = 5; + $body + } + 6 => { + const $d: usize = 6; + $body + } + 7 => { + const $d: usize = 7; + $body + } + 8 => { + const $d: usize = 8; + $body + } + other => panic!("Unsupported tensor rank for optimizer state: {other}"), + } + }; +} + +/// Object-safe view over an [`Optimizer`], allowing [`ModuleOptimizer`](crate::optim::ModuleOptimizer) +/// to stay non-generic. Rank-generic operations are dispatched on a runtime rank. +pub trait DynOptimizer: Send + Sync { + /// Perform an optimizer step for a single parameter of the given `rank`. + fn step_dyn( + &self, + rank: usize, + lr: LearningRate, + tensor: BridgeTensor, + grad: BridgeTensor, + state: Option, + ) -> (BridgeTensor, Option); + + /// Move a state to the given device. + fn to_device_dyn(&self, state: DynState, device: &Device) -> DynState; + + /// Decompose a state into named tensors and scalars under `prefix`. + fn state_flatten(&self, prefix: &str, state: &DynState, out: &mut StateSink); + + /// Rebuild a state of the given `rank` from named tensors and scalars under `prefix`. + /// + /// Returns `None` when the record does not contain a reconstructable state for this parameter + /// (e.g. a truncated or foreign file); the caller leaves that parameter without state, so it is + /// re-initialized on the next step. + fn state_unflatten( + &self, + rank: usize, + prefix: &str, + src: &mut StateSource, + device: &Device, + ) -> Option; +} + +impl DynOptimizer for O { + fn step_dyn( + &self, + rank: usize, + lr: LearningRate, + tensor: BridgeTensor, + grad: BridgeTensor, + state: Option, + ) -> (BridgeTensor, Option) { + dispatch_rank!(rank, D => { + let (tensor, state) = self.step( + lr, + Tensor::::from_bridge(tensor), + Tensor::::from_bridge(grad), + state.map(|state| state.downcast::>()), + ); + + (tensor.into_bridge(), state.map(|state| DynState::create(state, D))) + }) + } + + fn to_device_dyn(&self, state: DynState, device: &Device) -> DynState { + dispatch_rank!(state.rank(), D => { + let state = O::to_device::(state.downcast::>(), device); + DynState::create(state, D) + }) + } + + fn state_flatten(&self, prefix: &str, state: &DynState, out: &mut StateSink) { + dispatch_rank!(state.rank(), D => { + RecordState::state_flatten(state.downcast_ref::>(), prefix, out); + }) + } + + fn state_unflatten( + &self, + rank: usize, + prefix: &str, + src: &mut StateSource, + device: &Device, + ) -> Option { + dispatch_rank!(rank, D => { + let state = as RecordState>::state_unflatten(prefix, src, device)?; + Some(DynState::create(state, D)) + }) + } +} diff --git a/crates/burn-optim/src/optim/module/mod.rs b/crates/burn-optim/src/optim/module/mod.rs new file mode 100644 index 0000000..4219067 --- /dev/null +++ b/crates/burn-optim/src/optim/module/mod.rs @@ -0,0 +1,10 @@ +mod base; +pub use base::*; + +/// Adaptor module for optimizers. +pub mod module_optimizer; +pub use module_optimizer::*; + +/// Record module for optimizers. +pub mod record; +pub use record::*; diff --git a/crates/burn-optim/src/optim/module/module_optimizer.rs b/crates/burn-optim/src/optim/module/module_optimizer.rs new file mode 100644 index 0000000..d98e6e5 --- /dev/null +++ b/crates/burn-optim/src/optim/module/module_optimizer.rs @@ -0,0 +1,749 @@ +use burn_core as burn; +use burn_core::module::ParamGroup; + +use super::Optimizer; +use crate::lr_scheduler::module_lr_scheduler::ModuleLearningRate; +use crate::{ + DynOptimizer, DynState, MultiGradientsParams, OptimizerRecord, StateSink, StateSource, + grad_clipping::GradientClipping, optim::GradientsParams, optim::state::join_path, +}; + +use alloc::collections::BTreeMap; +use alloc::string::ToString; +use alloc::sync::Arc; +use alloc::vec::Vec; +use burn::module::{AutodiffModule, ModuleMapper, Param, ParamId}; +use burn::store::RecordError; +use burn::tensor::{Bytes, Device, Tensor, TensorData}; +use hashbrown::HashMap; + +/// Scalar key (per parameter) under which the parameter's state rank is persisted. +/// +/// Reserved: a custom [`Optimizer::State`](crate::Optimizer::State) must not have a top-level +/// scalar field named `__rank`, as it would collide with this key in the record. +const RANK_KEY: &str = "__rank"; + +#[derive(Clone)] +struct OptimizerGroup { + group: ParamGroup, + optim: Arc, + grad_clipping: Option, +} + +impl OptimizerGroup { + pub(crate) fn set_gradient_clipping(&mut self, gradient_clipping: GradientClipping) { + self.grad_clipping = Some(gradient_clipping) + } +} + +/// Keep a reference to the optimizer to avoid matching every step. +#[derive(Clone)] +struct OptimizationContext { + optim: Arc, + grad_clipping: Option, + path: Option, + state: DynState, +} + +/// Optimizes a whole module by applying a per-parameter [`Optimizer`] to each of its parameters. +/// +/// It is non-generic over the module and optimizer: any `O: Optimizer` is type-erased behind a +/// dynamic optimizer, and per-parameter states are kept as type-erased states keyed by +/// [`ParamId`](burn::module::ParamId). Build one with `optimizer.into()` or +/// `OptimizerConfig::init()`. +/// +/// It is possible to use different optimizers for different parameters. To do so, use the +/// [ModuleOptimizer::with_group] function to add an optimizer for all parameters matching the +/// provided group. +#[derive(Clone)] +pub struct ModuleOptimizer { + optimizers: Vec, + param_context: HashMap, +} + +impl From for ModuleOptimizer +where + O: Optimizer, +{ + fn from(optim: O) -> Self { + Self { + param_context: HashMap::new(), + optimizers: vec![OptimizerGroup { + group: ParamGroup::all(), + optim: Arc::new(optim), + grad_clipping: None, + }], + } + } +} + +impl ModuleOptimizer { + /// Check if the optimizer has gradient clipping. + /// If there are multiple optimizers, checks if any group has gradient clipping. + pub fn has_gradient_clipping(&self) -> bool { + self.optimizers.iter().any(|g| g.grad_clipping.is_some()) + } + + /// Access the gradient clipping. + /// If there are multiple optimizers, returns the first optimizer's [GradientClipping]. + pub fn grad_clipping(&self) -> Option<&GradientClipping> { + self.optimizers + .first() + .expect("Should have at least one optimizer") + .grad_clipping + .as_ref() + } + + /// Sets the gradient clipping. + /// If there are multiple optimizers, assigns it to the first one. + /// + /// # Arguments + /// + /// * `gradient_clipping` - The gradient clipping. + /// + /// # Returns + /// + /// The optimizer. + pub fn with_grad_clipping(mut self, gradient_clipping: GradientClipping) -> Self { + self.optimizers + .first_mut() + .expect("Should have at least one optimizer") + .set_gradient_clipping(gradient_clipping); + self + } + + fn step_common( + &mut self, + lr_policy: ModuleLearningRate, + module: M, + mut grads: GradAdaptor, + ) -> M { + module.map(&mut ModuleOptimizerMapper::new( + self.optimizers.iter().collect(), + &mut self.param_context, + &mut grads, + lr_policy, + )) + } + + /// Adds an optimizer specific to a parameter group. + /// + /// Parameters matching this group will be optimized using the provided optimizer + /// and gradient clipping configuration. + /// + /// ### Matching Rules + /// * **Precedence:** If a parameter matches multiple groups, the *last* group added takes precedence. + /// * **Fallback:** The first optimizer added must match all parameters to act as a global fallback. + /// + /// ### Side Effects + /// * **State Reset:** Adding a new group will reset any existing optimizer states for parameters + /// that match the new group. + pub fn with_group( + mut self, + group: ParamGroup, + optim: O, + grad_clipping: Option, + ) -> Self + where + O: DynOptimizer + 'static, + { + self.optimizers.push(OptimizerGroup { + group: group.clone(), + optim: Arc::new(optim), + grad_clipping, + }); + self.param_context + .retain(|id, param_state| !group.matches(id, param_state.path.as_deref())); + self + } +} + +impl ModuleOptimizer { + /// Update the `module` parameters with the given `gradients`, advancing the optimizer state. + pub fn step( + &mut self, + lr_module: ModuleLearningRate, + module: M, + grads: GradientsParams, + ) -> M { + self.step_common(lr_module, module, grads.into()) + } + + /// Like [`step`](Self::step), but accumulating gradients sourced from multiple devices. + pub fn step_multi( + &mut self, + lr_module: ModuleLearningRate, + module: M, + grads: MultiGradientsParams, + ) -> M { + self.step_common(lr_module, module, grads.into()) + } + + fn optim_from_param( + &self, + id: ParamId, + path: Option<&str>, + ) -> (&'_ Arc, Option) { + self.optimizers + .iter() + .filter_map(|val| { + val.group + .matches(&id, path) + .then_some((&val.optim, val.grad_clipping.clone())) + }) + .next_back() + .expect("Should match at least one parameter group.") + } + + /// Decompose the optimizer state into a serializable [`OptimizerRecord`]. + pub fn to_record(&self) -> OptimizerRecord { + let mut tensors = Vec::new(); + let mut scalars = BTreeMap::new(); + let mut paths = BTreeMap::new(); + + for (id, param_state) in self.param_context.iter() { + let prefix = id.val().to_string(); + let mut sink = StateSink::default(); + param_state + .optim + .state_flatten(&prefix, ¶m_state.state, &mut sink); + + // Persist the parameter rank explicitly so the state can be reconstructed even when it + // carries no tensors, and without inferring the rank from tensor shapes. + scalars.insert( + join_path(&prefix, RANK_KEY), + burn_pack::Scalar::from(param_state.state.rank()), + ); + // Save parameter path to be able to match to the right group when loading. + if let Some(path) = ¶m_state.path { + paths.insert(prefix, path.clone()); + } + + for (name, data) in sink.tensors { + tensors.push(burn_pack::Tensor::new( + name, + data.dtype, + data.shape, + Some(id.val()), + data.bytes, + )); + } + for (name, value) in sink.scalars { + scalars.insert(name, value); + } + } + + OptimizerRecord { + tensors, + scalars, + paths, + } + } + + /// Load the optimizer state from an [`OptimizerRecord`]. + /// + /// State tensors are materialized on the default device; no device argument is needed because + /// each parameter's state is migrated to that parameter's (gradient's) device on the next + /// [`step`](ModuleOptimizer::step) — see the `to_device` call in the step path. The load device + /// is therefore irrelevant to correctness. + pub fn load_record(mut self, record: OptimizerRecord) -> Self { + let device = Device::default(); + let mut ranks: BTreeMap = BTreeMap::new(); + let mut paths: BTreeMap = BTreeMap::new(); + + // Recover each parameter's rank from its persisted `__rank` scalar (authoritative). Keys + // are `"{param_id}.__rank"`, so strip the dotted suffix to recover the id. + let suffix = alloc::format!(".{RANK_KEY}"); + for (name, value) in record.scalars.iter() { + if let Some(id_str) = name.strip_suffix(&suffix) + && let (Ok(id), Ok(rank)) = (id_str.parse::(), usize::try_from(*value)) + { + ranks.insert(id, rank); + } + } + + for (name, path) in record.paths.iter() { + if let Ok(id) = name.parse::() { + paths.insert(id, path.to_string()); + } + } + + let mut source = StateSource::new(record.scalars); + + for tensor in record.tensors { + let id = tensor + .param_id + .expect("Optimizer record tensors should carry a parameter id."); + let name = tensor.name; + let data = TensorData::from_bytes(tensor.bytes, tensor.shape, tensor.dtype); + // Fall back to inferring rank from a tensor shape if no `__rank` scalar was present. + ranks.entry(id).or_insert(data.shape.len()); + source.insert_tensor(name, data); + } + + let mut states = HashMap::new(); + for (id, rank) in ranks { + let prefix = id.to_string(); + let path = paths.get(&id); + let (optim, grad_clipping) = + self.optim_from_param(id.into(), path.map(|path| path.as_str())); + // Skip parameters whose state can't be reconstructed (truncated/foreign record); they + // are re-initialized lazily on the next step rather than aborting the load. + if let Some(state) = optim.state_unflatten(rank, &prefix, &mut source, &device) { + states.insert( + ParamId::from(id), + OptimizationContext { + optim: optim.clone(), + path: path.cloned(), + state, + grad_clipping, + }, + ); + } + } + + self.param_context = states; + self + } + + /// Serialize the optimizer state to an in-memory burnpack byte buffer. + pub fn into_bytes(&self) -> Result { + self.to_record().into_bytes() + } + + /// Load the optimizer state from an in-memory burnpack byte buffer. + pub fn from_bytes(self, bytes: Bytes) -> Result { + Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?)) + } + + /// Save the optimizer state to a burnpack file on disk. + #[cfg(feature = "std")] + pub fn save>(&self, path: P) -> Result<(), RecordError> { + self.to_record().save(path) + } + + /// Load the optimizer state from a burnpack file on disk. + #[cfg(feature = "std")] + pub fn load>(self, path: P) -> Result { + Ok(self.load_record(OptimizerRecord::load(path)?)) + } +} + +/// Wrapper to unify the `remove` method for [GradientsParams] and [MultiGradientsParams]. +pub enum GradAdaptor { + /// Wrapper for [`GradientsParams`]. + Single(GradientsParams), + + /// Wrapper for [`MultiGradientsParams`]. + Multi(MultiGradientsParams), +} + +impl From for GradAdaptor { + fn from(grads: GradientsParams) -> Self { + Self::Single(grads) + } +} + +impl From for GradAdaptor { + fn from(grads: MultiGradientsParams) -> Self { + Self::Multi(grads) + } +} + +impl GradAdaptor { + /// Remove a gradient parameter by ID. + /// + /// # Returns + /// Maybe the (tensor, device) pair. + pub fn remove(&mut self, id: ParamId) -> Option<(Tensor, Device)> { + match self { + GradAdaptor::Single(grads) => grads.remove(id).map(|t| { + let device = t.device(); + (t, device) + }), + GradAdaptor::Multi(grads) => grads.remove(id), + } + } +} + +struct ModuleOptimizerMapper<'a> { + path: Vec, + optimizer_groups: Vec<&'a OptimizerGroup>, + states: &'a mut HashMap, + grads: &'a mut GradAdaptor, + lr_module: ModuleLearningRate, +} + +impl<'a> ModuleOptimizerMapper<'a> { + pub(crate) fn new( + optimizer_groups: Vec<&'a OptimizerGroup>, + states: &'a mut HashMap, + grads: &'a mut GradAdaptor, + lr_module: ModuleLearningRate, + ) -> Self { + Self { + path: vec![], + optimizer_groups, + states, + grads, + lr_module, + } + } + + fn optimizer_from_param( + &self, + id: ParamId, + path: Option<&str>, + ) -> (Arc, Option) { + self.optimizer_groups + .iter() + .filter_map(|val| { + val.group + .matches(&id, path) + .then_some((val.optim.clone(), val.grad_clipping.clone())) + }) + .next_back() + .expect("Should match at least one parameter group.") + } +} + +impl ModuleMapper for ModuleOptimizerMapper<'_> { + fn enter_module(&mut self, name: &str, _container_type: &str) { + self.path.push(name.to_string()); + } + + fn exit_module(&mut self, _name: &str, _container_type: &str) { + self.path.pop(); + } + + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + let grad = self.grads.remove(id); + + let tensor = if let Some((grad, device)) = grad { + let is_require_grad = tensor.is_require_grad(); + #[cfg(feature = "std")] + let is_distributed = tensor.is_distributed(); + + let entry = self.states.remove_entry(&id); + let key = entry.as_ref().map(|(k, _)| *k); + let tensor = if tensor.device() != device { + tensor.to_device(&device) + } else { + tensor + }; + + let path = self.path.join("."); + let (optim, grad_clipping, existing_dyn_state) = match entry.map(|(_, s)| s) { + Some(OptimizationContext { + optim, + grad_clipping, + state, + .. + }) => (optim, grad_clipping, Some(state)), + None => { + let (optim, grad_clipping) = + self.optimizer_from_param(id, Some(path.as_str())).clone(); + (optim, grad_clipping, None) + } + }; + + debug_assert_eq!( + grad.device(), + device, + "The gradient is on the provided device" + ); + let clipped_grad: Tensor = if let Some(g_clipping) = grad_clipping.as_ref() { + g_clipping.clip_gradient(grad) + } else { + grad + }; + + debug_assert_eq!( + tensor.device(), + device, + "Tensor and gradients are on the same device." + ); + + let lr = self.lr_module.lr_from_param(id, Some(path.as_str())); + let (tensor, state) = optim.step_dyn( + D, + lr, + tensor.inner().into_bridge(), + clipped_grad.into_bridge(), + existing_dyn_state.map(|s| optim.to_device_dyn(s, &device)), + ); + + if let Some(state) = state { + self.states.insert( + key.unwrap_or(id), + OptimizationContext { + optim, + path: Some(path), + state, + grad_clipping, + }, + ); + } + + let mut tensor = Tensor::from_inner(Tensor::from_bridge(tensor)); + + if is_require_grad { + tensor = tensor.require_grad(); + } + #[cfg(feature = "std")] + if is_distributed { + tensor = tensor.set_distributed(id) + } + + tensor + } else { + tensor + }; + + Param::from_mapped_value(id, tensor, mapper) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AdamConfig, GradientsParams, SgdConfig, + lr_scheduler::module_lr_scheduler::ModuleLearningRate, + }; + use burn::module::ParamGroup; + use burn::tensor::{Distribution, Tensor, Tolerance}; + use burn_derive::Module; + use burn_nn::{Linear, LinearConfig}; + + #[derive(Module, Debug)] + struct TwoLayerModel { + layer_a: Linear, + layer_b: Linear, + } + + fn make_model(device: &Device) -> TwoLayerModel { + TwoLayerModel { + layer_a: LinearConfig::new(4, 4).init(device), + layer_b: LinearConfig::new(4, 4).init(device), + } + } + + fn make_grads(model: &TwoLayerModel, x: Tensor<2>) -> GradientsParams { + let out = model.layer_a.forward(x.clone()) + model.layer_b.forward(x); + GradientsParams::from_grads(out.mean().backward(), model) + } + + fn lr() -> ModuleLearningRate { + ModuleLearningRate::from(0.01_f64) + } + + fn sgd() -> ModuleOptimizer { + ModuleOptimizer::from(SgdConfig::new().init()) + } + + /// to_record / load_record must fully preserve a stateful optimizer's internal state so that + /// a step on the restored optimizer is numerically identical to one taken on the original. + #[test] + fn default_optimizer_state_survives_round_trip() { + let device = Device::default().autodiff(); + let mut model = make_model(&device); + let mut optim: ModuleOptimizer = AdamConfig::new().init(); + + for _ in 0..3 { + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + model = optim.step(lr(), model.clone(), make_grads(&model, x)); + } + + let record = optim.to_record(); + let mut reloaded: ModuleOptimizer = AdamConfig::new().init().load_record(record); + + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + let grads_a = make_grads(&model, x.clone()); + let grads_b = make_grads(&model, x); + let from_orig = optim.step(lr(), model.clone(), grads_a); + let from_reload = reloaded.step(lr(), model, grads_b); + + from_orig + .layer_a + .weight + .val() + .into_data() + .assert_approx_eq::( + &from_reload.layer_a.weight.val().into_data(), + Tolerance::absolute(1e-6), + ); + } + + /// The paths saved in OptimizerRecord enable load_record to route each parameter to the + /// correct group optimizer. A step on the restored optimizer must be numerically identical + /// to one on the original — for both the group (Adam) and the default (SGD) optimizer. + #[test] + fn group_optimizer_routes_correctly_after_record_round_trip() { + let device = Device::default().autodiff(); + let mut model = make_model(&device); + + let make_optim = || { + sgd().with_group( + ParamGroup::from_predicate("layer_a"), + AdamConfig::new().build(), + None, + ) + }; + let mut optim = make_optim(); + + for _ in 0..3 { + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + model = optim.step(lr(), model.clone(), make_grads(&model, x)); + } + + let record = optim.to_record(); + let mut reloaded = make_optim().load_record(record); + + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + let grads_a = make_grads(&model, x.clone()); + let grads_b = make_grads(&model, x); + let from_orig = optim.step(lr(), model.clone(), grads_a); + let from_reload = reloaded.step(lr(), model, grads_b); + + from_orig + .layer_a + .weight + .val() + .into_data() + .assert_approx_eq::( + &from_reload.layer_a.weight.val().into_data(), + Tolerance::absolute(1e-6), + ); + from_orig + .layer_b + .weight + .val() + .into_data() + .assert_approx_eq::( + &from_reload.layer_b.weight.val().into_data(), + Tolerance::absolute(1e-6), + ); + } + + /// Adding a group after training must clear accumulated state for matching params + /// while leaving state for non-matching params untouched. + #[test] + fn with_group_clears_state_for_matching_params() { + let device = Device::default().autodiff(); + let mut model = make_model(&device); + let mut optim: ModuleOptimizer = AdamConfig::new().init(); + + for _ in 0..2 { + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + model = optim.step(lr(), model.clone(), make_grads(&model, x)); + } + + // Switch layer_a to SGD. Its Adam state must be cleared. + optim = optim.with_group( + ParamGroup::from_predicate("layer_a"), + SgdConfig::new().build(), + None, + ); + + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + _ = optim.step(lr(), model.clone(), make_grads(&model, x)); + + let record = optim.to_record(); + + let time_key_count = record.scalars.keys().filter(|k| k.contains("time")).count(); + assert_eq!( + time_key_count, 2, + "only layer_b params should carry Adam's time scalar after group switch" + ); + } + + #[test] + fn group_grad_clipping_applies_only_to_matching_params() { + let device = Device::default().autodiff(); + let mut model = make_model(&device); + + let lr_value = 0.01; + let threshold = 0.01_f32; + let bound = (lr_value * threshold as f64) as f32; + + let mut optim = sgd().with_group( + ParamGroup::from_predicate("layer_a"), + SgdConfig::new().build(), + Some(GradientClipping::Value(threshold)), + ); + + // Large inputs produce gradients that clearly exceed the clipping threshold. + let x = Tensor::<2>::random([2, 4], Distribution::Uniform(100.0, 200.0), &device); + let weight_a_before = model.layer_a.weight.val(); + let weight_b_before = model.layer_b.weight.val(); + + model = optim.step( + ModuleLearningRate::from(lr_value), + model.clone(), + make_grads(&model, x), + ); + + let diff_a = weight_a_before - model.layer_a.weight.val(); + let diff_b = weight_b_before - model.layer_b.weight.val(); + + let eps = 1e-6_f32; + for value in diff_a.into_data().iter::() { + assert!( + value.abs() <= bound + eps, + "layer_a's group grad_clipping should keep every update within lr * threshold" + ); + } + assert!( + diff_b + .into_data() + .iter::() + .any(|value| value.abs() > bound + eps), + "layer_b uses the default optimizer and should not be clipped" + ); + } + + /// An OptimizerRecord with empty paths map must load cleanly. + /// Parameters default to the default optimizer. + #[test] + fn record_without_paths_loads_without_panic() { + let device = Device::default().autodiff(); + let mut model = make_model(&device); + let mut optim: ModuleOptimizer = AdamConfig::new().init(); + + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + model = optim.step(lr(), model.clone(), make_grads(&model, x)); + + // Simulate a record with empty paths. + let mut record = optim.to_record(); + record.paths.clear(); + + let mut optim_loaded: ModuleOptimizer = AdamConfig::new().init().load_record(record); + + let x = Tensor::<2>::random([2, 4], Distribution::Default, &device); + let grads_a = make_grads(&model, x.clone()); + let grads_b = make_grads(&model, x); + let from_orig = optim.step(lr(), model.clone(), grads_a); + let from_reload = optim_loaded.step(lr(), model, grads_b); + + from_orig + .layer_a + .weight + .val() + .into_data() + .assert_approx_eq::( + &from_reload.layer_a.weight.val().into_data(), + Tolerance::absolute(1e-6), + ); + from_orig + .layer_b + .weight + .val() + .into_data() + .assert_approx_eq::( + &from_reload.layer_b.weight.val().into_data(), + Tolerance::absolute(1e-6), + ); + } +} diff --git a/crates/burn-optim/src/optim/module/record/mod.rs b/crates/burn-optim/src/optim/module/record/mod.rs new file mode 100644 index 0000000..4f9bbdd --- /dev/null +++ b/crates/burn-optim/src/optim/module/record/mod.rs @@ -0,0 +1,92 @@ +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; + +use burn::store::RecordError; +use burn::tensor::Bytes; +use burn_core as burn; + +use burn_pack::{Reader, Scalar, Writer}; + +/// A serialized optimizer state, stored in the [burnpack](burn_pack) format. +/// +/// Unlike a module record (keyed by module path), an optimizer record is keyed per parameter: +/// each parameter's state is decomposed into tensors named `"{param_id}.{field}"` (carrying the +/// originating `param_id`) plus a few typed scalar entries kept in the burnpack scalar map. +/// +/// Obtain one from a [`ModuleOptimizer`](crate::optim::ModuleOptimizer) with +/// [`to_record`](crate::optim::ModuleOptimizer::to_record), then save it +/// ([`save`](Self::save) / [`into_bytes`](Self::into_bytes)) or apply it back with +/// [`load_record`](crate::optim::ModuleOptimizer::load_record). +#[derive(Default)] +pub struct OptimizerRecord { + pub(crate) tensors: Vec, + pub(crate) scalars: BTreeMap, + pub(crate) paths: BTreeMap, +} + +impl core::fmt::Debug for OptimizerRecord { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("OptimizerRecord") + .field("num_tensors", &self.tensors.len()) + .field("num_scalars", &self.scalars.len()) + .finish() + } +} + +impl OptimizerRecord { + /// The number of tensors in the record. + pub fn len(&self) -> usize { + self.tensors.len() + } + + /// Whether the record holds no tensors. + pub fn is_empty(&self) -> bool { + self.tensors.is_empty() + } + + /// Serialize the record to an in-memory burnpack byte buffer. + pub fn into_bytes(self) -> Result { + Ok(self.into_writer().into_bytes()?) + } + + /// Reconstruct a record from an in-memory burnpack byte buffer. + pub fn from_bytes(bytes: Bytes) -> Result { + Self::from_reader(Reader::from_bytes(bytes)?) + } + + /// Save the record to a burnpack file on disk. + #[cfg(feature = "std")] + pub fn save>(self, path: P) -> Result<(), RecordError> { + self.into_writer().write_to_file(path)?; + Ok(()) + } + + /// Load a record from a burnpack file on disk. + #[cfg(feature = "std")] + pub fn load>(path: P) -> Result { + Self::from_reader(Reader::from_file(path)?) + } + + fn into_writer(self) -> Writer { + let mut writer = Writer::new(self.tensors); + for (key, value) in &self.scalars { + writer = writer.with_scalar(key, *value); + } + for (key, value) in &self.paths { + writer = writer.with_metadata(key, value); + } + writer + } + + fn from_reader(reader: Reader) -> Result { + let scalars = reader.scalars().clone(); + let paths = reader.metadata().clone(); + let tensors = reader.into_tensors()?; + Ok(Self { + tensors, + scalars, + paths, + }) + } +} diff --git a/crates/burn-optim/src/optim/momentum.rs b/crates/burn-optim/src/optim/momentum.rs new file mode 100644 index 0000000..ac0163f --- /dev/null +++ b/crates/burn-optim/src/optim/momentum.rs @@ -0,0 +1,94 @@ +use burn_core as burn; + +use crate::RecordState; +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::{ElementConversion, Tensor}; + +/// Configuration to create [momentum](Momentum). +#[derive(Config, Debug)] +pub struct MomentumConfig { + /// Momentum factor + #[config(default = 0.9)] + pub momentum: f64, + /// Dampening factor. + #[config(default = 0.1)] + pub dampening: f64, + /// Enables Nesterov momentum, see [On the importance of initialization and + /// momentum in deep learning](http://www.cs.toronto.edu/~hinton/absps/momentum.pdf). + #[config(default = false)] + pub nesterov: bool, +} + +/// State of [momentum](Momentum). +#[derive(RecordState, Clone, new)] +pub struct MomentumState { + velocity: Tensor, +} + +/// Momentum implementation that transforms gradients. +#[derive(Clone)] +pub struct Momentum { + momentum: f32, + dampening: f64, + nesterov: bool, +} + +impl Momentum { + /// Creates a new [momentum](Momentum) from a [config](MomentumConfig). + pub fn new(config: &MomentumConfig) -> Self { + Self { + momentum: config.momentum.elem(), + dampening: config.dampening, + nesterov: config.nesterov, + } + } + + /// Transforms a gradient. + /// + /// # Arguments + /// + /// * `grad` - Gradient to transform. + /// * `state` - State of the optimizer. + /// + /// # Returns + /// + /// * `grad` - Transformed gradient. + /// * `state` - State of the optimizer. + pub fn transform( + &self, + grad: Tensor, + state: Option>, + ) -> (Tensor, MomentumState) { + let velocity = if let Some(state) = state { + grad.clone() + .mul_scalar(1.0 - self.dampening) + .add(state.velocity.mul_scalar(self.momentum)) + } else { + grad.clone() + }; + + let grad = match self.nesterov { + true => velocity.clone().mul_scalar(self.momentum).add(grad), + false => velocity.clone(), + }; + + (grad, MomentumState::new(velocity)) + } +} + +impl MomentumState { + /// Moves the state to a device. + /// + /// # Arguments + /// + /// * `device` - Device to move the state to. + /// + /// # Returns + /// + /// * `self` - Moved state. + pub fn to_device(mut self, device: &Device) -> Self { + self.velocity = self.velocity.to_device(device); + self + } +} diff --git a/crates/burn-optim/src/optim/muon.rs b/crates/burn-optim/src/optim/muon.rs new file mode 100644 index 0000000..65ec271 --- /dev/null +++ b/crates/burn-optim/src/optim/muon.rs @@ -0,0 +1,762 @@ +use burn_core as burn; + +use crate::RecordState; + +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; +use serde::{Deserialize, Serialize}; + +use super::{ + Optimizer, + decay::WeightDecayConfig, + module_optimizer::ModuleOptimizer, + momentum::{Momentum, MomentumConfig, MomentumState}, +}; +use crate::LearningRate; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float as _; + +/// Learning rate adjustment method for Muon optimizer. +/// +/// Muon adjusts the learning rate based on parameter shape to maintain consistent +/// RMS across rectangular matrices. +/// +/// # References +/// +/// - Original: [Muon: An optimizer for hidden layers](https://kellerjordan.github.io/posts/muon/) +/// - Moonshot: [Muon is Scalable for LLM Training](https://arxiv.org/pdf/2502.16982) +#[derive(Clone, Default, Debug, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdjustLrFn { + /// Keller Jordan's original method: `lr * sqrt(max(1, A/B))` + /// + /// This scales the learning rate based on the aspect ratio of the weight matrix, + /// ensuring that tall matrices (more rows than columns) get proportionally larger + /// learning rates. + /// + /// # Example + /// + /// For a [1024, 512] matrix: `lr * sqrt(1024/512) = lr * 1.414` + #[default] + Original, + + /// Moonshot's method: `lr * 0.2 * sqrt(max(A, B))` + /// + /// This method is designed to match AdamW's RMS, allowing Muon to directly reuse + /// learning rates and weight decay values tuned for AdamW without retuning. + /// + /// # Example + /// + /// For a [1024, 512] matrix: `lr * 0.2 * sqrt(1024) = lr * 6.4` + MatchRmsAdamW, +} + +impl AdjustLrFn { + /// Calculate the learning rate adjustment ratio for a given parameter shape. + /// + /// # Arguments + /// + /// * `shape` - Parameter shape (uses first two dimensions) + /// + /// # Returns + /// + /// Adjustment ratio to multiply with the base learning rate + fn adjustment_ratio(&self, shape: &[usize]) -> f64 { + if shape.len() < 2 { + return 1.0; + } + + let a = shape[0] as f64; + let b = shape[1] as f64; + + match self { + Self::Original => { + // sqrt(max(1, A/B)) + let ratio = a / b; + ratio.max(1.0).sqrt() + } + Self::MatchRmsAdamW => { + // 0.2 * sqrt(max(A, B)) + 0.2 * a.max(b).sqrt() + } + } + } +} + +/// Muon configuration. +/// +/// Muon is an optimizer specifically designed for 2D parameters of neural network +/// hidden layers (weight matrices). Other parameters such as biases and embeddings +/// should be optimized using a standard method such as AdamW. +/// +/// # Learning Rate Adjustment +/// +/// Muon adjusts the learning rate based on parameter shape to maintain consistent +/// RMS across rectangular matrices. Two methods are available: +/// +/// - **Original**: Uses `sqrt(max(1, A/B))` where A and B are the first two dimensions. +/// This is Keller Jordan's method and is the default. +/// +/// - **MatchRmsAdamW**: Uses `0.2 * sqrt(max(A, B))`. This is Moonshot's method +/// designed to match AdamW's RMS, allowing direct reuse of AdamW hyperparameters. +/// +/// # Example +/// +/// ```ignore +/// use burn_optim::{MuonConfig, AdjustLrFn}; +/// +/// // Using default (Original) method +/// let optimizer = MuonConfig::new().init(); +/// +/// // Using MatchRmsAdamW for AdamW-compatible hyperparameters +/// let optimizer = MuonConfig::new() +/// .with_adjust_lr_fn(AdjustLrFn::MatchRmsAdamW) +/// .init(); +/// ``` +/// +/// # References +/// +/// - [Muon: An optimizer for hidden layers in neural networks](https://kellerjordan.github.io/posts/muon/) +/// - [Muon is Scalable for LLM Training](https://arxiv.org/pdf/2502.16982) +/// - [PyTorch Implementation](https://github.com/pytorch/pytorch/blob/main/torch/optim/muon.py) +/// - [Original Implementation](https://github.com/KellerJordan/Muon) +#[derive(Config, Debug)] +pub struct MuonConfig { + /// [Weight decay](WeightDecayConfig) config. + weight_decay: Option, + + /// [Momentum](MomentumConfig) config. + /// + /// Muon always uses momentum. Default configuration: + /// - momentum: 0.95 + /// - dampening: 0.0 + /// - nesterov: true + #[config(default = "MomentumConfig { momentum: 0.95, dampening: 0.0, nesterov: true }")] + momentum: MomentumConfig, + + /// Newton-Schulz iteration coefficients (a, b, c). + /// + /// These coefficients are selected to maximize the slope at zero for the + /// quintic iteration. Default values are from Keller Jordan's implementation. + #[config(default = "(3.4445, -4.775, 2.0315)")] + ns_coefficients: (f32, f32, f32), + + /// Epsilon for numerical stability. + #[config(default = 1e-7)] + epsilon: f32, + + /// Number of Newton-Schulz iteration steps. + #[config(default = 5)] + ns_steps: usize, + + /// Learning rate adjustment method. + /// + /// Controls how the learning rate is adjusted based on parameter shape. + /// See [`AdjustLrFn`] for available methods. + #[config(default = "AdjustLrFn::Original")] + adjust_lr_fn: AdjustLrFn, +} + +impl MuonConfig { + /// Build a [`Muon`] from the config. + pub(crate) fn build(&self) -> Muon { + let momentum = Momentum::new(&self.momentum); + let weight_decay_penalty = self.weight_decay.as_ref().map(|wd| wd.penalty); + + Muon { + momentum, + ns_params: NewtonSchulzParams::new(self.ns_coefficients, self.ns_steps), + weight_decay_penalty, + epsilon: self.epsilon, + adjust_lr_fn: self.adjust_lr_fn, + } + } + + /// Initialize Muon optimizer. + /// + /// # Returns + /// + /// Returns an optimizer adaptor that can be used to optimize a module. + /// + /// # Example + /// + /// ```ignore + /// use burn_optim::{MuonConfig, AdjustLrFn, decay::WeightDecayConfig}; + /// + /// // Basic configuration with default (Original) LR adjustment + /// let optimizer = MuonConfig::new() + /// .with_weight_decay(Some(WeightDecayConfig::new(0.01))) + /// .init(); + /// + /// // With AdamW-compatible settings using MatchRmsAdamW + /// let optimizer = MuonConfig::new() + /// .with_adjust_lr_fn(AdjustLrFn::MatchRmsAdamW) + /// .with_weight_decay(Some(WeightDecayConfig::new(0.1))) + /// .init(); + /// + /// // Custom momentum and NS settings + /// let optimizer = MuonConfig::new() + /// .with_momentum(MomentumConfig { + /// momentum: 0.9, + /// dampening: 0.1, + /// nesterov: false, + /// }) + /// .with_ns_steps(7) + /// .init(); + /// ``` + pub fn init(&self) -> ModuleOptimizer { + ModuleOptimizer::from(self.build()) + } +} + +/// Parameters for Newton-Schulz orthogonalization. +#[derive(Clone, Copy)] +struct NewtonSchulzParams { + a: f32, + b: f32, + c: f32, + steps: usize, +} + +impl NewtonSchulzParams { + fn new(coefficients: (f32, f32, f32), steps: usize) -> Self { + Self { + a: coefficients.0, + b: coefficients.1, + c: coefficients.2, + steps, + } + } +} + +/// Muon optimizer. +/// +/// Muon internally runs standard SGD-momentum, and then performs an orthogonalization +/// post-processing step, in which each 2D parameter's update is replaced with the +/// nearest orthogonal matrix. For efficient orthogonalization we use a Newton-Schulz +/// iteration, which has the advantage that it can be stably run in bfloat16 on the GPU. +/// +/// # Important Notes +/// +/// 1. **Only for 2D+ parameters**: Muon is designed for weight matrices. Use AdamW +/// or SGD for biases, embeddings, and layer norms. +/// +/// 2. **Learning rate adjustment**: Muon automatically adjusts the learning rate based +/// on parameter shape. See [`AdjustLrFn`] for details. +/// +/// 3. **Weight decay timing**: Unlike typical optimizers, Muon applies weight decay +/// AFTER orthogonalization but uses the original (unadjusted) learning rate for it. +#[derive(Clone)] +pub struct Muon { + momentum: Momentum, + ns_params: NewtonSchulzParams, + weight_decay_penalty: Option, + epsilon: f32, + adjust_lr_fn: AdjustLrFn, +} + +impl Muon { + /// Adjust learning rate based on parameter shape. + /// + /// # Arguments + /// + /// * `lr` - Base learning rate + /// * `shape` - Parameter shape (uses first two dimensions) + /// + /// # Returns + /// + /// Adjusted learning rate + /// + /// ```ignore + /// // For a [1024, 512] weight matrix with lr=0.01: + /// // Original: 0.01 * sqrt(1024/512) = 0.01 * 1.414 = 0.01414 + /// // MatchRmsAdamW: 0.01 * 0.2 * sqrt(1024) = 0.01 * 0.2 * 32 = 0.064 + /// ``` + fn adjust_lr(&self, lr: LearningRate, shape: &[usize]) -> LearningRate { + lr * self.adjust_lr_fn.adjustment_ratio(shape) + } + + /// Perform Newton-Schulz orthogonalization on a gradient tensor. + /// + /// This computes the zeroth power (orthogonalization) of the input matrix G + /// using a quintic Newton-Schulz iteration. + /// + /// # Algorithm + /// + /// 1. Transpose if tall matrix (A > B) + /// 2. Normalize: X = X / ||X|| + /// 3. For k steps: + /// - A = X @ X^T + /// - B = b*A + c*A^2 + /// - X = a*X + B@X + /// 4. Transpose back if needed + /// + /// # References + /// + /// - Original: https://github.com/KellerJordan/Muon/blob/master/muon.py + /// - PyTorch: https://github.com/pytorch/pytorch/blob/main/torch/optim/muon.py + fn zeropower_via_newtonschulz(&self, g: Tensor) -> Tensor { + let shape = g.shape(); + let dim_m2 = shape[D - 2]; + let dim_m1 = shape[D - 1]; + + // Step 1: Transpose if tall matrix (more rows than columns) + let (mut x, needs_transpose) = if dim_m2 > dim_m1 { + (g.swap_dims(D - 2, D - 1), true) + } else { + (g, false) + }; + + // Step 2: Normalize by Frobenius norm + // X = X / (||X|| + epsilon) + let norm = x + .clone() + .powf_scalar(2.0) + .sum() + .sqrt() + .clamp_min(self.epsilon) + .unsqueeze(); + + x = x.div(norm); + + // Step 3: Newton-Schulz iteration + // This is the quintic iteration with coefficients (a, b, c) + let NewtonSchulzParams { a, b, c, steps } = self.ns_params; + + for _ in 0..steps { + // A = X @ X^T + let x_t = x.clone().swap_dims(D - 2, D - 1); + let a_matrix = x.clone().matmul(x_t); + + // B = b*A + c*A@A + let a_squared = a_matrix.clone().matmul(a_matrix.clone()); + let b_matrix = a_matrix.mul_scalar(b).add(a_squared.mul_scalar(c)); + + // X = a*X + B@X + x = x.clone().mul_scalar(a).add(b_matrix.matmul(x.clone())); + } + + // Step 4: Restore transpose if it was a tall matrix + if needs_transpose { + x = x.swap_dims(D - 2, D - 1); + } + + x + } +} + +/// Muon state. +#[derive(RecordState, Clone, new)] +pub struct MuonState { + /// Current momentum state + pub momentum: MomentumState, +} + +impl Optimizer for Muon { + type State = MuonState; + + /// Perform a single Muon optimization step. + /// + /// # Algorithm + /// + /// 1. Apply momentum to gradient + /// 2. Orthogonalize update via Newton-Schulz + /// 3. Adjust learning rate based on parameter shape + /// 4. Apply weight decay (using original lr) + /// 5. Update parameter (using adjusted lr) + /// + /// # Notes + /// + /// Unlike typical optimizers, the weight decay and parameter update use + /// different learning rates: + /// - Weight decay uses the original `lr` + /// - Parameter update uses the shape-adjusted `lr` + /// + /// # Panics + /// This function will panic if the input tensors are not 2D. + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + assert!( + D == 2, + "Newton-Schulz iteration requires 2D tensors, got {}D", + D + ); + + // Step 1: Apply momentum + let state_momentum = state.map(|s| s.momentum); + let (grad, new_momentum_state) = self.momentum.transform(grad, state_momentum); + + // Step 2: Orthogonalize via Newton-Schulz + let update = self.zeropower_via_newtonschulz(grad); + + // Step 3: Adjust learning rate based on parameter shape + let adjusted_lr = self.adjust_lr(lr, &tensor.shape()); + + // Step 4: Apply weight decay (using ORIGINAL lr, not adjusted) + // Muon applies weight decay AFTER orthogonalization + let tensor = if let Some(penalty) = self.weight_decay_penalty { + let decay_factor = 1.0 - lr * penalty as f64; + tensor.mul_scalar(decay_factor) + } else { + tensor + }; + + // Step 5: Update parameter (using ADJUSTED lr) + let delta = update.mul_scalar(adjusted_lr); + let new_state = MuonState::new(new_momentum_state); + + (tensor - delta, Some(new_state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.momentum = state.momentum.to_device(device); + state + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{GradientsParams, Optimizer}; + use burn::module::Param; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + const TOLERANCE: f64 = 1e-8; + + fn given_linear_layer_no_bias(weight: TensorData) -> Linear { + let device = Device::default().autodiff(); + Linear { + weight: Param::from_data(weight, &device), + bias: None, // No bias for Muon optimizer + } + } + + #[test] + fn test_adjust_lr_fn_original() { + let method = AdjustLrFn::Original; + + // Square matrix [512, 512] -> sqrt(1) = 1.0 + let ratio = method.adjustment_ratio(&[512, 512]); + assert!((ratio - 1.0).abs() < TOLERANCE); + + // Tall matrix [1024, 512] -> sqrt(2) ≈ 1.414 + let ratio = method.adjustment_ratio(&[1024, 512]); + let expected = (2.0f64).sqrt(); + assert!((ratio - expected).abs() < TOLERANCE); + + // Wide matrix [512, 1024] -> max(1, 0.5) = 1.0 + let ratio = method.adjustment_ratio(&[512, 1024]); + assert!((ratio - 1.0).abs() < TOLERANCE); + } + + #[test] + fn test_adjust_lr_fn_match_rms_adamw() { + let method = AdjustLrFn::MatchRmsAdamW; + + // [1024, 512] -> 0.2 * sqrt(1024) = 6.4 + let ratio = method.adjustment_ratio(&[1024, 512]); + let expected = 0.2 * 1024.0f64.sqrt(); + assert!((ratio - expected).abs() < TOLERANCE); + + // [512, 512] -> 0.2 * sqrt(512) ≈ 4.525 + let ratio = method.adjustment_ratio(&[512, 512]); + let expected = 0.2 * 512.0f64.sqrt(); + assert!((ratio - expected).abs() < TOLERANCE); + } + + #[test] + #[should_panic(expected = "Newton-Schulz iteration requires 2D tensors, got 1D")] + fn test_1d_tensor_panics() { + let device = Default::default(); + let config = MuonConfig::new(); + let optim = Muon { + momentum: Momentum::new(&config.momentum), + ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps), + weight_decay_penalty: None, + epsilon: config.epsilon, + adjust_lr_fn: config.adjust_lr_fn, + }; + + let tensor_1d = Tensor::<1>::zeros([512], &device); + let grad_1d = Tensor::<1>::ones([512], &device); + + let _ = optim.step(0.01, tensor_1d, grad_1d, None); + } + + #[test] + fn test_muon_optimizer_save_load_state() { + let device = Device::default().autodiff(); + // Use Linear layer WITHOUT bias for Muon optimizer + let linear = LinearConfig::new(6, 6) + .with_bias(false) // No bias - only 2D weight matrix + .init(&device); + + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + + let mut optimizer = MuonConfig::new().init(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(0.01.into(), linear, grads); + + let state_before = optimizer.to_record(); + let bytes = optimizer.into_bytes().unwrap(); + + let optimizer_loaded = MuonConfig::new().init().from_bytes(bytes).unwrap(); + let state_after = optimizer_loaded.to_record(); + + assert_eq!(state_before.len(), state_after.len()); + } + + #[test] + fn test_muon_with_weight_decay() { + let device = Device::default().autodiff(); + // Create Linear layer WITHOUT bias for Muon + let linear = given_linear_layer_no_bias(TensorData::from([ + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + ])); + + let x = Tensor::<2>::from_floats([[0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]], &device) + .require_grad(); + + let mut optimizer = MuonConfig::new() + .with_weight_decay(Some(WeightDecayConfig::new(0.01))) + .init(); + + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(0.01.into(), linear, grads); + + let state = linear; + let weight = state.weight.to_data(); + + for val in weight.as_slice::().unwrap() { + assert!( + *val < 1.0, + "Weight should be reduced by weight decay, got {}", + val + ); + } + } + + #[test] + fn test_newton_schulz_orthogonalization() { + let device = Default::default(); + let matrix = Tensor::<2>::from_floats([[1.0, 0.5], [0.5, 1.0]], &device); + + let config = MuonConfig::new(); + let muon = Muon { + momentum: Momentum::new(&config.momentum), + ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps), + weight_decay_penalty: None, + epsilon: config.epsilon, + adjust_lr_fn: config.adjust_lr_fn, + }; + + let orthogonalized = muon.zeropower_via_newtonschulz(matrix); + let o_t = orthogonalized.clone().transpose(); + let product = orthogonalized.matmul(o_t); + + let data = product.into_data(); + let values = data.as_slice::().unwrap(); + + assert!( + (values[0] - 1.0).abs() < 0.1, + "Product[0,0] should be ~1.0, got {}", + values[0] + ); + assert!( + (values[3] - 1.0).abs() < 0.1, + "Product[1,1] should be ~1.0, got {}", + values[3] + ); + } + + #[test] + fn test_tall_matrix_transpose() { + // Test that tall matrices (A > B) are transposed during Newton-Schulz iteration + // and then transposed back + let device = Default::default(); + + // Create a tall matrix: [8, 4] (more rows than columns) + let tall_matrix = Tensor::<2>::from_floats( + [ + [1.0, 0.5, 0.3, 0.2], + [0.5, 1.0, 0.4, 0.1], + [0.3, 0.4, 1.0, 0.5], + [0.2, 0.1, 0.5, 1.0], + [0.1, 0.2, 0.3, 0.4], + [0.4, 0.3, 0.2, 0.1], + [0.2, 0.4, 0.1, 0.3], + [0.3, 0.1, 0.4, 0.2], + ], + &device, + ); + + let config = MuonConfig::new(); + let muon = Muon { + momentum: Momentum::new(&config.momentum), + ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps), + weight_decay_penalty: None, + epsilon: config.epsilon, + adjust_lr_fn: config.adjust_lr_fn, + }; + + // Perform Newton-Schulz orthogonalization + let orthogonalized = muon.zeropower_via_newtonschulz(tall_matrix.clone()); + + // Verify shape is preserved (should be transposed internally but returned in original shape) + let original_shape = tall_matrix.shape(); + let result_shape = orthogonalized.shape(); + assert_eq!( + original_shape.dims::<2>(), + result_shape.dims::<2>(), + "Shape should be preserved: [8, 4]" + ); + + // Verify output is different from input (orthogonalization happened) + let original_data = tall_matrix.into_data(); + let result_data = orthogonalized.into_data(); + assert_ne!( + original_data.as_slice::().unwrap(), + result_data.as_slice::().unwrap(), + "Orthogonalized matrix should differ from input" + ); + + // For comparison, test a wide matrix [4, 8] should NOT be transposed + let wide_matrix = Tensor::<2>::from_floats( + [ + [1.0, 0.5, 0.3, 0.2, 0.1, 0.4, 0.2, 0.3], + [0.5, 1.0, 0.4, 0.1, 0.2, 0.3, 0.4, 0.1], + [0.3, 0.4, 1.0, 0.5, 0.3, 0.2, 0.1, 0.4], + [0.2, 0.1, 0.5, 1.0, 0.4, 0.1, 0.3, 0.2], + ], + &device, + ); + + let orthogonalized_wide = muon.zeropower_via_newtonschulz(wide_matrix.clone()); + + // Verify wide matrix shape is also preserved + let wide_original_shape = wide_matrix.shape(); + let wide_result_shape = orthogonalized_wide.shape(); + assert_eq!( + wide_original_shape.dims::<2>(), + wide_result_shape.dims::<2>(), + "Wide matrix shape should be preserved: [4, 8]" + ); + } + + #[test] + fn test_zero_gradient() { + // Test that Muon handles zero gradients gracefully + let device = Default::default(); + + let tensor = Tensor::<2>::from_floats( + [ + [1.0, 0.5, 0.3, 0.2], + [0.5, 1.0, 0.4, 0.1], + [0.3, 0.4, 1.0, 0.5], + [0.2, 0.1, 0.5, 1.0], + ], + &device, + ); + + // Zero gradient - all zeros + let zero_grad = Tensor::<2>::zeros([4, 4], &device); + + let config = MuonConfig::new(); + let muon = Muon { + momentum: Momentum::new(&config.momentum), + ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps), + weight_decay_penalty: None, + epsilon: config.epsilon, + adjust_lr_fn: config.adjust_lr_fn, + }; + + // Should not panic or produce NaN + let (updated_tensor, state) = muon.step(0.01, tensor.clone(), zero_grad, None); + + // Verify state was created + assert!(state.is_some()); + + // With zero gradient and no weight decay, tensor should remain unchanged + let original_data = tensor.into_data(); + let updated_data = updated_tensor.clone().into_data(); + + let original_vals = original_data.as_slice::().unwrap(); + let updated_vals = updated_data.as_slice::().unwrap(); + + for (orig, upd) in original_vals.iter().zip(updated_vals.iter()) { + assert!( + (orig - upd).abs() < 1e-6, + "With zero gradient, tensor should remain unchanged (or very close)" + ); + } + + // Verify no NaN values + for val in updated_vals { + assert!( + !val.is_nan(), + "Result should not contain NaN values with zero gradient" + ); + } + + // Test with weight decay - should still work + let muon_with_decay = Muon { + momentum: Momentum::new(&config.momentum), + ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps), + weight_decay_penalty: Some(0.01), + epsilon: config.epsilon, + adjust_lr_fn: config.adjust_lr_fn, + }; + + let tensor2 = Tensor::<2>::from_floats( + [ + [1.0, 0.5, 0.3, 0.2], + [0.5, 1.0, 0.4, 0.1], + [0.3, 0.4, 1.0, 0.5], + [0.2, 0.1, 0.5, 1.0], + ], + &device, + ); + let zero_grad2 = Tensor::<2>::zeros([4, 4], &device); + + let (updated_tensor_decay, _) = + muon_with_decay.step(0.01, tensor2.clone(), zero_grad2, None); + + // With zero gradient but with weight decay, tensor should be slightly reduced + let updated_decay_data = updated_tensor_decay.into_data(); + let updated_decay_vals = updated_decay_data.as_slice::().unwrap(); + + for val in updated_decay_vals { + assert!( + !val.is_nan(), + "Result should not contain NaN with zero gradient and weight decay" + ); + } + + // With weight decay, values should be slightly smaller than original + let original_vals2 = tensor2.into_data().as_slice::().unwrap().to_vec(); + for (orig, upd) in original_vals2.iter().zip(updated_decay_vals.iter()) { + if orig.abs() > 1e-6 { + // Non-zero values should be reduced by weight decay + assert!( + upd.abs() < orig.abs(), + "Weight decay should reduce magnitude: original={}, updated={}", + orig, + upd + ); + } + } + } +} diff --git a/crates/burn-optim/src/optim/rmsprop.rs b/crates/burn-optim/src/optim/rmsprop.rs new file mode 100644 index 0000000..2ace0e4 --- /dev/null +++ b/crates/burn-optim/src/optim/rmsprop.rs @@ -0,0 +1,534 @@ +use burn_core as burn; + +use crate::RecordState; + +use super::{ + Optimizer, + decay::{WeightDecay, WeightDecayConfig}, + module_optimizer::ModuleOptimizer, +}; +use crate::{LearningRate, grad_clipping::GradientClippingConfig}; + +use burn::config::Config; +use burn::tensor::{Device, Tensor}; + +/// Configuration to create the [RmsProp](RmsProp) optimizer. +#[derive(Config, Debug)] +pub struct RmsPropConfig { + /// Smoothing constant. + #[config(default = 0.99)] + alpha: f32, + /// momentum for RmsProp. + #[config(default = 0.9)] + momentum: f32, + /// A value required for numerical stability. + #[config(default = 1e-5)] + epsilon: f32, + /// if True, compute the centered RmsProp, the gradient is normalized by an estimation of its variance + #[config(default = false)] + centered: bool, + /// [Weight decay](WeightDecayConfig) config. + weight_decay: Option, + /// [Gradient Clipping](GradientClippingConfig) config. + grad_clipping: Option, +} + +impl RmsPropConfig { + /// Build a [`RmsProp`] from the config. + pub(crate) fn build(&self) -> RmsProp { + let weight_decay = self.weight_decay.as_ref().map(WeightDecay::new); + RmsProp { + alpha: self.alpha, + centered: self.centered, + weight_decay, + momentum: RmsPropMomentum { + momentum: self.momentum, + epsilon: self.epsilon, + }, + } + } + + /// Initialize RmsProp optimizer. + /// + /// # Returns + /// + /// Returns an optimizer that can be used to optimize a module. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.grad_clipping { + optim = optim.with_grad_clipping(config.init()); + } + + optim + } +} + +/// Optimizer that implements stochastic gradient descent with momentum. +/// The optimizer can be configured with [RmsPropConfig](RmsPropConfig). +#[derive(Clone)] +pub struct RmsProp { + alpha: f32, + // epsilon: f32, + centered: bool, + // momentum: Option, + momentum: RmsPropMomentum, + weight_decay: Option, +} + +impl Optimizer for RmsProp { + type State = RmsPropState; + + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + mut grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + // fetch state for params + let mut state_square_avg = None; + let mut state_centered = None; + let mut state_momentum = None; + if let Some(state) = state { + state_square_avg = Some(state.square_avg); + state_centered = Some(state.centered); + state_momentum = state.momentum; + } + + // weight_decay transform + if let Some(weight_decay) = &self.weight_decay { + grad = weight_decay.transform(grad, tensor.clone()); + } + + // square_avg transform + let (grad, state_square_avg) = + SquareAvgState::transform(self.alpha, grad, state_square_avg); + + // centered transform + let (grad, state_square_avg, state_centered) = CenteredState::transform( + self.alpha, + self.centered, + grad, + state_square_avg, + state_centered, + ); + + // momentum transform + let (grad, state_centered, state_momentum) = + self.momentum + .transform(grad, state_centered, state_momentum); + + // transition state + let state = RmsPropState::new(state_square_avg, state_centered, state_momentum); + + // tensor param transform + let delta = grad.mul_scalar(lr); + (tensor - delta, Some(state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.square_avg = state.square_avg.to_device(device); + state.centered = state.centered.to_device(device); + state.momentum = state.momentum.map(|momentum| momentum.to_device(device)); + state + } +} + +/// State of [RmsProp](RmsProp) +#[derive(RecordState, Clone, new)] +pub struct RmsPropState { + /// Current squared average state. + pub square_avg: SquareAvgState, + /// Current centered state + pub centered: CenteredState, + /// Current gradient momentum, if any. + pub momentum: Option>, +} + +/// [SquareAvgState](SquareAvgState) is to store and pass optimizer step params. +#[derive(RecordState, Clone, new)] +pub struct SquareAvgState { + /// Current squared average. + pub square_avg: Tensor, +} + +impl SquareAvgState { + /// transform [SquareAvgState] to the next step + fn transform(alpha: f32, grad: Tensor, state: Option) -> (Tensor, Self) { + match state { + Some(state) => { + let square_avg = state + .square_avg + .mul_scalar(alpha) + .add(grad.clone().square().mul_scalar(1. - alpha)); + (grad, Self { square_avg }) + } + _ => { + let square_avg = grad.clone().square().mul_scalar(1. - alpha); + (grad, Self { square_avg }) + } + } + } + + /// Moves the state to a device. + /// + /// # Arguments + /// + /// * `device` - Device to move the state to. + /// + /// # Returns + /// + /// * `self` - Moved state. + pub fn to_device(mut self, device: &Device) -> Self { + self.square_avg = self.square_avg.to_device(device); + self + } +} + +/// [CenteredState](CenteredState) is to store and pass optimizer step params. +#[derive(RecordState, Clone, new)] +pub struct CenteredState { + /// The averaged gradient to calculate the centered gradient, if available. + pub grad_avg: Option>, + /// The current average value. + pub avg: Tensor, +} + +impl CenteredState { + /// transform [CenteredState] to the next step + fn transform( + alpha: f32, + centered: bool, + grad: Tensor, + square_avg_state: SquareAvgState, + centered_state: Option, + ) -> (Tensor, SquareAvgState, Self) { + if centered { + let grad_avg_constant = grad.clone().mul_scalar(1. - alpha); + let grad_avg = match centered_state { + Some(state) => state + .grad_avg + .map_or(grad_avg_constant.clone(), move |grad_avg| { + grad_avg.mul_scalar(alpha).add(grad_avg_constant) + }), + _ => grad_avg_constant, + }; + let avg = square_avg_state + .square_avg + .clone() + .sub(grad_avg.clone().square()); + + ( + grad, + square_avg_state, + Self { + grad_avg: Some(grad_avg), + avg, + }, + ) + } else { + ( + grad, + square_avg_state.clone(), + Self { + grad_avg: None, + avg: square_avg_state.square_avg, + }, + ) + } + } + + /// Moves the state to a device. + /// + /// # Arguments + /// + /// * `device` - Device to move the state to. + /// + /// # Returns + /// + /// * `self` - Moved state. + pub fn to_device(mut self, device: &Device) -> Self { + self.grad_avg = self.grad_avg.map(|grad_avg| grad_avg.to_device(device)); + self.avg = self.avg.to_device(device); + self + } +} + +/// [RmsPropMomentum](RmsPropMomentum) is to store config status for optimizer. +/// (, which is stored in [optimizer](RmsProp) itself and not passed in during `step()` calculation) +#[derive(Clone)] +pub struct RmsPropMomentum { + momentum: f32, + epsilon: f32, +} + +impl RmsPropMomentum { + /// transform [grad](Tensor) and [RmsPropMomentumState] to the next step + fn transform( + &self, + grad: Tensor, + centered_state: CenteredState, + momentum_state: Option>, + ) -> (Tensor, CenteredState, Option>) { + let grad = grad.div(centered_state.avg.clone().sqrt().add_scalar(self.epsilon)); + + if self.momentum > 0. { + let buf = match momentum_state { + Some(state) => state.buf.mul_scalar(self.momentum).add(grad), + _ => grad, + }; + ( + buf.clone(), + centered_state, + Some(RmsPropMomentumState { buf }), + ) + } else { + (grad, centered_state, None) + } + } +} + +/// [RmsPropMomentumState](RmsPropMomentumState) is to store and pass optimizer step params. +#[derive(RecordState, Clone, new)] +pub struct RmsPropMomentumState { + buf: Tensor, +} + +impl RmsPropMomentumState { + /// Moves the state to a device. + /// + /// # Arguments + /// + /// * `device` - Device to move the state to. + /// + /// # Returns + /// + /// * `self` - Moved state. + pub fn to_device(mut self, device: &Device) -> Self { + self.buf = self.buf.to_device(device); + self + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::*; + use crate::optim::GradientsParams; + use burn::module::Param; + use burn::tensor::{Distribution, Tensor, TensorData}; + use burn_nn::{Linear, LinearConfig}; + + type FT = f32; + + const LEARNING_RATE: LearningRate = 0.01; + + #[test] + fn test_rmsprop_optimizer_save_load_state() { + let device = Device::default().autodiff(); + let linear = LinearConfig::new(6, 6).init(&device); + let x = Tensor::<2>::random([2, 6], Distribution::Default, &device); + let mut optimizer = create_rmsprop(); + let grads = linear.forward(x).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let _linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let bytes = optimizer.into_bytes().unwrap(); + assert!(!bytes.is_empty()); + + #[cfg(feature = "std")] + optimizer + .save(std::env::temp_dir().as_path().join("test_optim_rmsprop")) + .unwrap(); + + let state_optim_before = optimizer.to_record(); + let optimizer = create_rmsprop().from_bytes(bytes).unwrap(); + let state_optim_after = optimizer.to_record(); + + assert_eq!(state_optim_before.len(), state_optim_after.len()); + } + + /// used for test differences and debug + #[test] + fn test_rmsprop_optimizer_with_numbers_basic() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [1., 1., 1., 1., 1., 1.], + [1., 1., 1., 1., 1., 1.], + [1., 1., 1., 1., 1., 1.], + [1., 1., 1., 1., 1., 1.], + [1., 1., 1., 1., 1., 1.], + [1., 1., 1., 1., 1., 1.], + ]), + TensorData::from([0.5, 0.5, 0.5, 0.5, 0.5, 0.5]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = RmsPropConfig::new() + .with_alpha(0.99) + .with_epsilon(1e-8) + .with_weight_decay(WeightDecayConfig::new(0.05).into()) + .with_momentum(0.9) + .with_centered(false) + .init(); + + // println!("linear is {:?}", linear); + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + // println!("linear is {:?}", linear); + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + // println!("linear is {:?}", linear); + let state_updated = linear; + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + // println!("\nweight_updated\n{:?}", weight_updated); + // println!("\nbias_updated\n{:?}", bias_updated); + + let weights_expected = TensorData::from([ + [0.743937, 0.743937, 0.743937, 0.743937, 0.743937, 0.743937], + [0.783809, 0.783809, 0.783809, 0.783809, 0.783809, 0.783809], + [0.742881, 0.742881, 0.742881, 0.742881, 0.742881, 0.742881], + [0.740366, 0.740366, 0.740366, 0.740366, 0.740366, 0.740366], + [0.748005, 0.748005, 0.748005, 0.748005, 0.748005, 0.748005], + [0.743710, 0.743710, 0.743710, 0.743710, 0.743710, 0.743710], + ]); + let bias_expected = + TensorData::from([0.239199, 0.239199, 0.239199, 0.239199, 0.239199, 0.239199]); + + let tolerance = Tolerance::absolute(1e-6); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + #[test] + fn test_rmsprop_optimizer_with_numbers() { + let device = Device::default().autodiff(); + let linear = given_linear_layer( + TensorData::from([ + [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671], + [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922], + [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130], + [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626], + [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304], + [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833], + ]), + TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]), + &device, + ); + let x_1 = Tensor::<2>::from_floats( + [ + [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310], + [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883], + ], + &device, + ) + .require_grad(); + let x_2 = Tensor::<2>::from_floats( + [ + [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528], + [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085], + ], + &device, + ) + .require_grad(); + + let mut optimizer = RmsPropConfig::new() + .with_alpha(0.99) + .with_epsilon(1e-8) + .with_weight_decay(WeightDecayConfig::new(0.05).into()) + .with_momentum(0.9) + .with_centered(false) + .init(); + + let grads = linear.forward(x_1).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let grads = linear.forward(x_2).backward(); + let grads = GradientsParams::from_grads(grads, &linear); + let linear = optimizer.step(LEARNING_RATE.into(), linear, grads); + + let state_updated = linear; + let weights_expected = TensorData::from([ + [ + -0.576399, -0.118494, 0.148353, 0.064070, -0.169983, -0.188779, + ], + [ + -0.135571, -0.231448, -0.578445, 0.041143, -0.018162, -0.504207, + ], + [ + -0.275990, -0.222397, -0.553153, -0.008625, -0.534956, 0.055967, + ], + [ + -0.557575, -0.480979, -0.631072, -0.557675, -0.335686, -0.096997, + ], + [ + 0.078313, -0.469618, 0.119993, -0.424341, 0.127890, -0.281912, + ], + [ + -0.271996, -0.268097, -0.130324, -0.064037, -0.226805, 0.127126, + ], + ]); + let bias_expected = TensorData::from([ + -0.651299, -0.172400, -0.357800, -0.143200, -0.124200, -0.247800, + ]); + + let (weight_updated, bias_updated) = ( + state_updated.weight.to_data(), + state_updated.bias.unwrap().to_data(), + ); + + // println!("\nweight_updated\n{:?}", weight_updated); + // println!("\nbias_updated\n{:?}", bias_updated); + + let tolerance = Tolerance::absolute(1e-6); + bias_updated.assert_approx_eq::(&bias_expected, tolerance); + weight_updated.assert_approx_eq::(&weights_expected, tolerance); + } + + fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear { + Linear { + weight: Param::from_data(weight, device), + bias: Some(Param::from_data(bias, device)), + } + } + + fn create_rmsprop() -> ModuleOptimizer { + RmsPropConfig { + alpha: 0.99, + epsilon: 1e-9, + centered: false, + weight_decay: Some(WeightDecayConfig { penalty: 0.05 }), + momentum: 0.9, + grad_clipping: None, + } + .init() + } +} diff --git a/crates/burn-optim/src/optim/sgd.rs b/crates/burn-optim/src/optim/sgd.rs new file mode 100644 index 0000000..82f63b9 --- /dev/null +++ b/crates/burn-optim/src/optim/sgd.rs @@ -0,0 +1,176 @@ +use burn_core as burn; + +use super::Optimizer; +use super::decay::{WeightDecay, WeightDecayConfig}; +use super::module_optimizer::ModuleOptimizer; +use super::momentum::{Momentum, MomentumConfig, MomentumState}; +use crate::LearningRate; +use crate::RecordState; +use crate::grad_clipping::GradientClippingConfig; +use burn::config::Config; +use burn::tensor::Device; +use burn::tensor::Tensor; + +/// Configuration to create the [Sgd](Sgd) optimizer. +#[derive(Config, Debug)] +pub struct SgdConfig { + /// [Weight decay](WeightDecayConfig) config. + weight_decay: Option, + /// [Momentum](MomentumConfig) config. + momentum: Option, + /// [Gradient Clipping](GradientClippingConfig) config. + gradient_clipping: Option, +} + +/// Optimizer that implements stochastic gradient descent with momentum. +/// +/// The optimizer can be configured with [SgdConfig](SgdConfig). +#[derive(Clone)] +pub struct Sgd { + momentum: Option, + weight_decay: Option, +} + +/// State of [Sgd](Sgd). +#[derive(RecordState, Clone, new)] +pub struct SgdState { + /// The current state of the momentum (if any). + pub momentum: Option>, +} + +impl SgdConfig { + /// Build a [`Sgd`] from the config. + pub(crate) fn build(&self) -> Sgd { + Sgd { + momentum: self.momentum.as_ref().map(Momentum::new), + weight_decay: self.weight_decay.as_ref().map(WeightDecay::new), + } + } + + /// Initializes the SGD optimizer from the configuration. + pub fn init(&self) -> ModuleOptimizer { + let mut optim = ModuleOptimizer::from(self.build()); + if let Some(config) = &self.gradient_clipping { + optim = optim.with_grad_clipping(config.init()); + } + optim + } +} + +impl Optimizer for Sgd { + type State = SgdState; + + fn step( + &self, + lr: LearningRate, + tensor: Tensor, + mut grad: Tensor, + state: Option>, + ) -> (Tensor, Option>) { + let mut state_momentum = None; + + if let Some(state) = state { + state_momentum = state.momentum; + } + + if let Some(weight_decay) = &self.weight_decay { + grad = weight_decay.transform(grad, tensor.clone()); + } + + if let Some(momentum) = &self.momentum { + let (grad_out, state) = momentum.transform(grad, state_momentum); + state_momentum = Some(state); + grad = grad_out; + } + + let state = SgdState::new(state_momentum); + let delta = grad.mul_scalar(lr); + + (tensor - delta, Some(state)) + } + + fn to_device(mut state: Self::State, device: &Device) -> Self::State { + state.momentum = state.momentum.map(|state| state.to_device(device)); + state + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{grad_clipping::GradientClipping, optim::GradientsParams}; + use burn::tensor::{Distribution, Shape}; + use burn_nn::{Linear, LinearConfig}; + + const LEARNING_RATE: LearningRate = 0.02; + + #[test] + fn with_updated_params_should_have_state() { + let device = Device::default().autodiff(); + let layer = layer(&device); + let mut optim = sgd_with_all(); + let loss = layer.forward(random_tensor(&device)); + let grads = loss.backward(); + let grads = GradientsParams::from_grads(grads, &layer); + let _layer = optim.step(LEARNING_RATE.into(), layer, grads); + + let record = optim.to_record(); + + assert!(!record.is_empty()); + } + + #[test] + fn without_updated_params_should_not_have_state() { + let optim = sgd_with_all(); + let record = optim.to_record(); + assert!(record.is_empty()); + } + + #[test] + fn can_attach_gradient_clipping() { + let optim = sgd_with_all().with_grad_clipping(GradientClipping::Value(0.5)); + assert!(optim.has_gradient_clipping()); + } + + #[test] + fn should_load_state() { + let device = Device::default().autodiff(); + let layer = layer(&device); + let mut optim = sgd_with_all(); + let loss = layer.forward(random_tensor(&device)); + let grads = loss.backward(); + let grads = GradientsParams::from_grads(grads, &layer); + let _layer = optim.step(LEARNING_RATE.into(), layer, grads); + + let record = optim.to_record(); + let bytes = optim.into_bytes().unwrap(); + let optim_new = sgd_with_all(); + let record_new = optim_new.to_record(); + let optim_new = optim_new.from_bytes(bytes).unwrap(); + let state_restored = optim_new.to_record(); + + assert_ne!(record.len(), record_new.len()); + assert_eq!(record.len(), state_restored.len()); + } + + fn random_tensor(device: &Device) -> Tensor<2> { + Tensor::<2>::random(Shape::new([2, 20]), Distribution::Default, device) + } + + fn layer(device: &Device) -> Linear { + LinearConfig::new(20, 20).init(device) + } + + fn sgd_with_all() -> ModuleOptimizer { + SgdConfig { + weight_decay: Some(WeightDecayConfig { penalty: 0.05 }), + momentum: Some(MomentumConfig { + momentum: 0.9, + dampening: 0.1, + nesterov: true, + }), + gradient_clipping: None, + } + .init() + } +} diff --git a/crates/burn-optim/src/optim/state.rs b/crates/burn-optim/src/optim/state.rs new file mode 100644 index 0000000..788b898 --- /dev/null +++ b/crates/burn-optim/src/optim/state.rs @@ -0,0 +1,282 @@ +//! Decomposition of a state struct into named tensors and scalars for the burnpack format. +//! +//! Used for both optimizer state — keyed per-[`ParamId`](burn_core::module::ParamId) rather than +//! per module path — and learning-rate scheduler state. Each state is usually a small struct +//! holding a few tensors plus scalar bookkeeping (e.g. a step counter). [`RecordState`] flattens +//! such a struct into a flat list of named tensors plus a few typed [scalars](burn_pack::Scalar), +//! and reconstructs it on load. +//! +//! Implementations are generated with `#[derive(RecordState)]`; the derive supports the field +//! shapes `Tensor`, `Option>`, `Vec>`, scalars, optional scalars, nested +//! states and optional nested states. Scalar fields rely on `burn_pack`'s [`From`]/[`TryFrom`] +//! conversions to [`Scalar`](burn_pack::Scalar). + +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use burn_core::tensor::{Device, TensorData}; +use burn_pack::Scalar; + +pub use burn_derive::RecordState; + +/// Join a `prefix` and a `leaf` into a dot-separated path (`"prefix.leaf"`). +/// +/// An empty prefix yields the leaf unchanged, so a top-level call can pass `""`. +pub fn join_path(prefix: &str, leaf: &str) -> String { + if prefix.is_empty() { + return String::from(leaf); + } + let mut path = String::with_capacity(prefix.len() + 1 + leaf.len()); + path.push_str(prefix); + path.push('.'); + path.push_str(leaf); + path +} + +/// Join a `prefix` and a numeric `index` into a dot-separated path (`"prefix.3"`). +/// +/// Used by the derive to name the elements of a `Vec` field. +pub fn join_index(prefix: &str, index: usize) -> String { + format!("{prefix}.{index}") +} + +/// Accumulates the named tensors and scalars produced while flattening a [`RecordState`]. +#[derive(Default, Debug)] +pub struct StateSink { + /// The collected `(name, data)` tensor leaves. + pub tensors: Vec<(String, TensorData)>, + /// The collected `(name, value)` scalar leaves. + pub scalars: Vec<(String, Scalar)>, +} + +impl StateSink { + /// Record a tensor leaf named `{prefix}.{leaf}`. + pub fn push_tensor(&mut self, prefix: &str, leaf: &str, data: TensorData) { + self.tensors.push((join_path(prefix, leaf), data)); + } + + /// Record a scalar leaf named `{prefix}.{leaf}`. + pub fn push_scalar(&mut self, prefix: &str, leaf: &str, value: Scalar) { + self.scalars.push((join_path(prefix, leaf), value)); + } +} + +/// Provides the named tensors and scalars consumed while reconstructing an [`RecordState`]. +/// +/// Tensors are taken (removed) by name so the same source can feed several parameters in turn; +/// scalars are looked up by name and left in place. +#[derive(Default, Debug)] +pub struct StateSource { + tensors: BTreeMap, + scalars: BTreeMap, +} + +impl StateSource { + /// Create a source from an existing scalar map (e.g. the burnpack scalars). + pub fn new(scalars: BTreeMap) -> Self { + Self { + tensors: BTreeMap::new(), + scalars, + } + } + + /// Register a tensor under its full `name`. + pub fn insert_tensor(&mut self, name: String, data: TensorData) { + self.tensors.insert(name, data); + } + + /// Take the tensor named `{prefix}.{leaf}`, if present. + pub fn take_tensor(&mut self, prefix: &str, leaf: &str) -> Option { + self.tensors.remove(&join_path(prefix, leaf)) + } + + /// Read the scalar named `{prefix}.{leaf}`, if present. + pub fn take_scalar(&mut self, prefix: &str, leaf: &str) -> Option { + self.scalars.get(&join_path(prefix, leaf)).copied() + } + + /// Whether any tensor or scalar leaf was recorded under `prefix` (a key beginning with + /// `"{prefix}."`). + /// + /// Used by the derive to tell an absent optional nested state (nothing recorded) apart from a + /// present one whose leaves all happen to be optional. + pub fn has_under(&self, prefix: &str) -> bool { + let pat = join_path(prefix, ""); + self.tensors.keys().any(|k| k.starts_with(&pat)) + || self.scalars.keys().any(|k| k.starts_with(&pat)) + } +} + +/// A type that can be flattened into named tensors and scalars and rebuilt from them. +/// +/// Generated with `#[derive(RecordState)]`. The `prefix` threads the parameter identity (and any +/// nested field path) through the recursion; leaves are named `{prefix}.{field}`. +pub trait RecordState: Sized + Send + Sync + 'static { + /// Flatten `self` into `out`, naming every leaf under `prefix`. + fn state_flatten(&self, prefix: &str, out: &mut StateSink); + + /// Rebuild a value from `src`, reading leaves named under `prefix`. + /// + /// Returns `None` when a required leaf is absent. The derive uses [`StateSource::has_under`] to + /// presence-test optional nested states, so an `Option` field is `None` exactly when + /// nothing was recorded under its path. + /// + /// A scalar leaf that is present but holds an incompatible [`Scalar`] variant (e.g. a + /// hand-edited or forward-version file) is treated the same as absent. + fn state_unflatten(prefix: &str, src: &mut StateSource, device: &Device) -> Option; +} + +/// The empty state — for stateless values (e.g. a constant learning rate scheduler). +impl RecordState for () { + fn state_flatten(&self, _prefix: &str, _out: &mut StateSink) {} + + fn state_unflatten(_prefix: &str, _src: &mut StateSource, _device: &Device) -> Option { + Some(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Tensor; + use burn_core as burn; + + /// Flatten `state` then rebuild it from the produced tensors and scalars. + fn round_trip(state: &T) -> Option { + let mut sink = StateSink::default(); + state.state_flatten("p", &mut sink); + + let scalars: BTreeMap = sink.scalars.into_iter().collect(); + let mut source = StateSource::new(scalars); + for (name, data) in sink.tensors { + source.insert_tensor(name, data); + } + + T::state_unflatten("p", &mut source, &Device::default()) + } + + fn tensor(values: &[f32]) -> Tensor<1> { + Tensor::from_data(TensorData::from(values), &Device::default()) + } + + fn data(t: &Tensor<1>) -> Vec { + t.clone().into_data().to_vec().unwrap() + } + + #[derive(RecordState, Clone, Debug)] + struct Inner { + weight: Tensor, + step: i64, + } + + #[derive(RecordState, Clone, Debug)] + struct Full { + t: Tensor, + opt_tensor: Option>, + history: Vec>, + count: usize, + opt_scalar: Option, + nested: Inner, + opt_nested: Option>, + } + + #[test] + fn all_field_kinds_round_trip() { + let state = Full::<1> { + t: tensor(&[1.0, 2.0]), + opt_tensor: Some(tensor(&[3.0])), + history: vec![tensor(&[4.0]), tensor(&[5.0, 6.0])], + count: 7, + opt_scalar: Some(8.5), + nested: Inner { + weight: tensor(&[9.0]), + step: 10, + }, + opt_nested: Some(Inner { + weight: tensor(&[11.0]), + step: 12, + }), + }; + + let out = round_trip(&state).unwrap(); + + assert_eq!(data(&out.t), vec![1.0, 2.0]); + assert_eq!(data(&out.opt_tensor.unwrap()), vec![3.0]); + assert_eq!(out.history.len(), 2); + assert_eq!(data(&out.history[0]), vec![4.0]); + assert_eq!(data(&out.history[1]), vec![5.0, 6.0]); + assert_eq!(out.count, 7); + assert_eq!(out.opt_scalar, Some(8.5)); + assert_eq!(data(&out.nested.weight), vec![9.0]); + assert_eq!(out.nested.step, 10); + let opt_nested = out.opt_nested.unwrap(); + assert_eq!(data(&opt_nested.weight), vec![11.0]); + assert_eq!(opt_nested.step, 12); + } + + #[test] + fn absent_optionals_round_trip_to_none() { + let state = Full::<1> { + t: tensor(&[1.0]), + opt_tensor: None, + history: vec![], + count: 0, + opt_scalar: None, + nested: Inner { + weight: tensor(&[2.0]), + step: 0, + }, + opt_nested: None, + }; + + let out = round_trip(&state).unwrap(); + + assert!(out.opt_tensor.is_none()); + assert!(out.history.is_empty()); + assert!(out.opt_scalar.is_none()); + assert!(out.opt_nested.is_none()); + } + + /// Regression: an optional nested state whose fields are all optional must come back `None` + /// when nothing was recorded under its path — it must not be resurrected as `Some`. + #[derive(RecordState, Clone, Debug)] + struct AllOptional { + x: Option>, + y: Option, + } + + #[derive(RecordState, Clone, Debug)] + struct OuterOpt { + inner: Option>, + } + + #[test] + fn optional_all_optional_nested_stays_none() { + let state = OuterOpt::<1> { inner: None }; + let out = round_trip(&state).unwrap(); + assert!(out.inner.is_none()); + } + + #[test] + fn optional_all_optional_nested_present_with_content() { + let state = OuterOpt::<1> { + inner: Some(AllOptional { + x: Some(tensor(&[1.0, 2.0])), + y: None, + }), + }; + let out = round_trip(&state).unwrap(); + let inner = out.inner.expect("present because a leaf was recorded"); + assert_eq!(data(&inner.x.unwrap()), vec![1.0, 2.0]); + assert!(inner.y.is_none()); + } + + #[test] + fn missing_required_tensor_yields_none() { + // A source with nothing recorded cannot rebuild a struct that has a required tensor leaf. + let mut source = StateSource::new(BTreeMap::new()); + assert!(Inner::<1>::state_unflatten("p", &mut source, &Device::default()).is_none()); + } +} diff --git a/crates/burn-optim/src/optim/visitor.rs b/crates/burn-optim/src/optim/visitor.rs new file mode 100644 index 0000000..739ac41 --- /dev/null +++ b/crates/burn-optim/src/optim/visitor.rs @@ -0,0 +1,57 @@ +use burn_core as burn; + +use super::GradientsParams; +use burn::module::{AutodiffModule, ModuleVisitor, Param, ParamId}; +use burn::tensor::{Device, Gradients, Tensor}; +use core::marker::PhantomData; + +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; + +#[derive(new)] +pub struct GradientsParamsConverter<'a, M: AutodiffModule> { + grads: &'a mut Gradients, + grads_params: &'a mut GradientsParams, + phatom: PhantomData, + filter: Option>, +} + +#[derive(new)] +pub struct GradientsParamsChangeDevice<'a, M: AutodiffModule> { + device: &'a Device, + grads: &'a mut GradientsParams, + phatom: PhantomData, +} + +impl ModuleVisitor for GradientsParamsConverter<'_, M> +where + M: AutodiffModule, +{ + fn visit_float(&mut self, param: &Param>) { + if let Some(filter) = self.filter.as_ref() + && !filter.contains(¶m.id) + { + return; + } + + let Some(grad) = param.val().grad_remove(self.grads) else { + return; + }; + + self.grads_params.register(param.id, grad); + } +} + +impl ModuleVisitor for GradientsParamsChangeDevice<'_, M> +where + M: AutodiffModule, +{ + fn visit_float(&mut self, param: &Param>) { + let Some(grad) = self.grads.remove::(param.id) else { + return; + }; + + self.grads + .register::(param.id, grad.to_device(self.device)); + } +} diff --git a/crates/burn-pack/Cargo.toml b/crates/burn-pack/Cargo.toml new file mode 100644 index 0000000..54eb2b8 --- /dev/null +++ b/crates/burn-pack/Cargo.toml @@ -0,0 +1,36 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +categories = ["science", "no-std", "embedded", "wasm"] +description = "The burnpack binary serialization format for Burn" +documentation = "https://docs.rs/burn-pack" +edition.workspace = true +keywords = [ + "deep-learning", + "machine-learning", + "tensor", + "storage", + "serialization", +] +license.workspace = true +name = "burn-pack" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-pack" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["burn-std/std", "byteorder/std", "ciborium/std"] + +[dependencies] +burn-std = { workspace = true } + +# External dependencies +byteorder = { workspace = true, default-features = false } +ciborium = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/burn-pack/README.md b/crates/burn-pack/README.md new file mode 100644 index 0000000..de9acf8 --- /dev/null +++ b/crates/burn-pack/README.md @@ -0,0 +1,38 @@ +# Burn Pack + +> The **burnpack** binary serialization format for the Burn deep learning framework + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-pack.svg)](https://crates.io/crates/burn-pack) +[![Documentation](https://docs.rs/burn-pack/badge.svg)](https://docs.rs/burn-pack) + +`burn-pack` reads and writes the burnpack container format. It is tensor-library-agnostic and +dependency-light: it depends only on [`burn-std`](https://crates.io/crates/burn-std) (for `DType` +/ `Bytes`), `serde`, and a CBOR codec — it knows the on-disk format but has no notion of Burn +modules. Tensor data is `Bytes`-native and read lazily from files (256-byte aligned for +zero-copy mmap and efficient GPU transfers), and the reader is hardened against malformed input. + +If you just want to save and load Burn models, use the higher-level +[`burn-core`](https://crates.io/crates/burn-core) record API or +[`burn-store`](https://crates.io/crates/burn-store) (which adds PyTorch/SafeTensors interop). +Reach for `burn-pack` directly only to produce or consume the raw format. + +## Usage + +```rust +use burn_pack::{Bytes, DType, Reader, Tensor, Writer}; + +let raw: Vec = [1.0f32, 2.0, 3.0, 4.0].iter().flat_map(|v| v.to_le_bytes()).collect(); +let tensor = Tensor::new("weight".to_string(), DType::F32, vec![2, 2], None, Bytes::from_bytes_vec(raw)); + +let packed = Writer::new(vec![tensor]).into_bytes().unwrap(); +let reader = Reader::from_bytes(packed).unwrap(); +assert_eq!(reader.into_tensors().unwrap()[0].shape.to_vec(), vec![2, 2]); +``` + +Use `Writer::write_to_file` / `Reader::from_file` for disk I/O (the default `std` feature; disable +it for no-std targets). See the [docs](https://docs.rs/burn-pack) for the format layout and the +full API. + +## License + +This project is dual-licensed under MIT and Apache-2.0. diff --git a/crates/burn-pack/src/base.rs b/crates/burn-pack/src/base.rs new file mode 100644 index 0000000..6b525d0 --- /dev/null +++ b/crates/burn-pack/src/base.rs @@ -0,0 +1,390 @@ +//! Core types and constants for the Burnpack file format. +//! +//! See the [parent module](crate::burnpack) for the complete file format specification. + +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; +use burn_std::DType; +use byteorder::{ByteOrder, LittleEndian}; +use serde::{Deserialize, Serialize}; + +/// Magic number identifying a Burnpack file: "BURN" in ASCII (0x4255524E) +/// When written to file in little-endian format, appears as "NRUB" bytes +pub const MAGIC_NUMBER: u32 = 0x4255524E; + +/// Current format version +pub const FORMAT_VERSION: u16 = 0x0001; + +/// Size of the magic number in bytes +pub const MAGIC_SIZE: usize = 4; + +/// Size of the format version in bytes +pub const VERSION_SIZE: usize = 2; + +/// Size of the metadata size field in bytes +pub const METADATA_SIZE_FIELD_SIZE: usize = 4; + +/// Total header size (computed from components) +pub const HEADER_SIZE: usize = MAGIC_SIZE + VERSION_SIZE + METADATA_SIZE_FIELD_SIZE; + +/// Alignment for tensor data in bytes. +/// +/// All tensor data is aligned to 256-byte boundaries to enable efficient +/// memory-mapped (mmap) zero-copy loading. This alignment ensures: +/// - Proper pointer alignment for all tensor element types (f64 requires 8-byte alignment) +/// - Cache-line friendly access (most CPUs use 64-byte cache lines) +/// - GPU memory alignment (CUDA prefers 256-byte for coalesced access) +/// - Future-proofing for wider SIMD (AVX-512 = 64 bytes, future AVX-1024 = 128 bytes) +/// +/// Industry alignment choices: +/// - 256-byte: GGUF, MLX, ncnn, MNN, TNN, vLLM-AWQ, Marlin (15+ formats) +/// - 64-byte: SafeTensors (minimum for AVX-512) +/// - 4096-byte: Core ML +/// +/// 256-byte alignment has negligible overhead for typical tensor sizes while +/// providing maximum compatibility with current and future hardware. +pub const TENSOR_ALIGNMENT: u64 = 256; + +/// Calculate the byte offset where the tensor data section starts. +/// +/// The data section is padded to start at a 256-byte aligned position +/// so that all tensor offsets (which are relative to data section) result +/// in properly aligned absolute file positions for mmap zero-copy access. +/// +/// This function must be used consistently by both writer and reader. +#[inline] +pub fn aligned_data_section_start(metadata_size: usize) -> usize { + let unaligned_start = (HEADER_SIZE + metadata_size) as u64; + // Keep multiplication in u64 space to avoid overflow on 32-bit systems + (unaligned_start.div_ceil(TENSOR_ALIGNMENT) * TENSOR_ALIGNMENT) as usize +} + +// Security limits to prevent DoS attacks via resource exhaustion +// These can be adjusted based on your use case + +/// Maximum allowed metadata size (100 MB) +/// Prevents memory exhaustion attacks via oversized metadata claims +pub const MAX_METADATA_SIZE: u32 = 100 * 1024 * 1024; + +/// Maximum allowed tensor size per tensor +/// Prevents memory exhaustion attacks via oversized tensor claims +/// 32-bit platforms: 2 GB limit (to fit within usize range) +/// 64-bit platforms: 10 GB limit +#[cfg(target_pointer_width = "32")] +pub const MAX_TENSOR_SIZE: usize = 2 * 1024 * 1024 * 1024; +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_TENSOR_SIZE: usize = 10 * 1024 * 1024 * 1024; + +/// Maximum allowed number of tensors (100,000) +/// Prevents resource exhaustion via excessive tensor counts +pub const MAX_TENSOR_COUNT: usize = 100_000; + +/// Maximum CBOR deserialization recursion depth (128 levels) +/// Prevents stack overflow attacks via deeply nested CBOR structures +pub const MAX_CBOR_RECURSION_DEPTH: usize = 128; + +/// Maximum allowed file size (100 GB) +/// Prevents resource exhaustion from extremely large files +/// This limit applies to file-based loading (mmap and buffered) +#[cfg(feature = "std")] +pub const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024 * 1024; + +/// Byte range for magic number in header +pub const fn magic_range() -> core::ops::Range { + let start = 0; + let end = start + MAGIC_SIZE; + start..end +} + +/// Byte range for format version in header +pub const fn version_range() -> core::ops::Range { + let start = MAGIC_SIZE; + let end = start + VERSION_SIZE; + start..end +} + +/// Byte range for metadata size field in header +pub const fn metadata_size_range() -> core::ops::Range { + let start = MAGIC_SIZE + VERSION_SIZE; + let end = start + METADATA_SIZE_FIELD_SIZE; + start..end +} + +// Compile-time validation that ranges are correct +const _: () = assert!(MAGIC_SIZE + VERSION_SIZE + METADATA_SIZE_FIELD_SIZE == HEADER_SIZE); + +/// Header structure for Burnpack files +#[derive(Debug, Clone, Copy)] +pub struct Header { + /// Magic number (4 bytes): 0x4255524E ("BURN") + pub magic: u32, + /// Format version (2 bytes) + pub version: u16, + /// Size of CBOR metadata in bytes (4 bytes) + pub metadata_size: u32, +} + +impl Header { + /// Create a new header with the given metadata size + pub fn new(metadata_size: u32) -> Self { + Self { + magic: MAGIC_NUMBER, + version: FORMAT_VERSION, + metadata_size, + } + } + + /// Serialize header into bytes + pub fn into_bytes(self) -> [u8; HEADER_SIZE] { + let mut bytes = [0u8; HEADER_SIZE]; + LittleEndian::write_u32(&mut bytes[magic_range()], self.magic); + LittleEndian::write_u16(&mut bytes[version_range()], self.version); + LittleEndian::write_u32(&mut bytes[metadata_size_range()], self.metadata_size); + bytes + } + + /// Deserialize header from bytes + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_SIZE { + return Err(Error::InvalidHeader); + } + + let magic = LittleEndian::read_u32(&bytes[magic_range()]); + if magic != MAGIC_NUMBER { + return Err(Error::InvalidMagicNumber); + } + + let version = LittleEndian::read_u16(&bytes[version_range()]); + let metadata_size = LittleEndian::read_u32(&bytes[metadata_size_range()]); + + Ok(Self { + magic, + version, + metadata_size, + }) + } +} + +/// A typed scalar value stored alongside tensors in a burnpack container. +/// +/// Scalars are kept in the CBOR metadata section (not the tensor data section), so they carry +/// no alignment cost. The field is optional in the format: files written before scalar support +/// simply omit it, and readers default it to empty. +/// +/// Convert to/from the primitive numeric and boolean types with [`From`] / [`TryFrom`] +/// (e.g. `Scalar::from(3usize)`, `u32::try_from(scalar)`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Scalar { + /// A signed integer. + Int(i64), + /// An unsigned integer. + UInt(u64), + /// A floating-point number. + Float(f64), + /// A boolean. + Bool(bool), +} + +/// Error returned when a [`Scalar`] cannot be converted to a requested primitive type +/// (wrong variant or out of range). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScalarConversionError; + +impl core::fmt::Display for ScalarConversionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "scalar value does not fit the requested type") + } +} + +impl core::error::Error for ScalarConversionError {} + +macro_rules! impl_scalar_int { + ($($t:ty => $variant:ident),* $(,)?) => { + $( + impl From<$t> for Scalar { + fn from(value: $t) -> Self { + Scalar::$variant(value as _) + } + } + + impl TryFrom for $t { + type Error = ScalarConversionError; + fn try_from(scalar: Scalar) -> Result { + match scalar { + Scalar::Int(v) => v.try_into().map_err(|_| ScalarConversionError), + Scalar::UInt(v) => v.try_into().map_err(|_| ScalarConversionError), + _ => Err(ScalarConversionError), + } + } + } + )* + }; +} + +impl_scalar_int!( + i8 => Int, i16 => Int, i32 => Int, i64 => Int, isize => Int, + u8 => UInt, u16 => UInt, u32 => UInt, u64 => UInt, usize => UInt, +); + +impl From for Scalar { + fn from(value: f64) -> Self { + Scalar::Float(value) + } +} + +impl From for Scalar { + fn from(value: f32) -> Self { + Scalar::Float(value as f64) + } +} + +impl From for Scalar { + fn from(value: bool) -> Self { + Scalar::Bool(value) + } +} + +impl TryFrom for f64 { + type Error = ScalarConversionError; + fn try_from(scalar: Scalar) -> Result { + match scalar { + Scalar::Float(v) => Ok(v), + Scalar::Int(v) => Ok(v as f64), + Scalar::UInt(v) => Ok(v as f64), + _ => Err(ScalarConversionError), + } + } +} + +impl TryFrom for f32 { + type Error = ScalarConversionError; + fn try_from(scalar: Scalar) -> Result { + // Mirror `f64`'s acceptance of integer variants; float reads may be lossy (documented). + match scalar { + Scalar::Float(v) => Ok(v as f32), + Scalar::Int(v) => Ok(v as f32), + Scalar::UInt(v) => Ok(v as f32), + _ => Err(ScalarConversionError), + } + } +} + +impl TryFrom for bool { + type Error = ScalarConversionError; + fn try_from(scalar: Scalar) -> Result { + match scalar { + Scalar::Bool(v) => Ok(v), + _ => Err(ScalarConversionError), + } + } +} + +/// Metadata structure serialized with CBOR +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Metadata { + /// Tensor descriptors mapped by name for efficient lookup + pub tensors: BTreeMap, + /// Optional additional metadata + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, + /// Optional typed scalars mapped by name. + /// + /// Defaulted on read for backward compatibility with files written before scalar support. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub scalars: BTreeMap, +} + +/// Individual tensor descriptor +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct TensorDescriptor { + /// Data type of the tensor + pub dtype: DType, + /// Tensor shape dimensions + pub shape: Vec, + /// Byte offsets in data section (start, end) + pub data_offsets: (u64, u64), + /// Parameter ID for training state persistence matching. + /// Generated automatically if not present during loading. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub param_id: Option, +} + +/// Error types for Burnpack operations +#[derive(Debug)] +pub enum Error { + InvalidHeader, + InvalidMagicNumber, + InvalidVersion, + MetadataSerializationError(String), + MetadataDeserializationError(String), + IoError(String), + TensorNotFound(String), + TensorBytesSizeMismatch(String), + ValidationError(String), +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Error::InvalidHeader => write!(f, "Invalid header: insufficient bytes"), + Error::InvalidMagicNumber => write!(f, "Invalid magic number"), + Error::InvalidVersion => write!(f, "Unsupported version"), + Error::MetadataSerializationError(e) => { + write!(f, "Metadata serialization error: {}", e) + } + Error::MetadataDeserializationError(e) => { + write!(f, "Metadata deserialization error: {}", e) + } + Error::IoError(e) => write!(f, "I/O error: {}", e), + Error::TensorNotFound(name) => write!(f, "Tensor not found: {}", name), + Error::TensorBytesSizeMismatch(e) => { + write!(f, "Tensor bytes size mismatch: {}", e) + } + Error::ValidationError(e) => write!(f, "Validation error: {}", e), + } + } +} + +impl core::error::Error for Error {} + +#[cfg(test)] +mod scalar_tests { + use super::*; + + #[test] + fn int_round_trips_through_checked_conversion() { + assert_eq!(i32::try_from(Scalar::from(-5i32)).unwrap(), -5); + assert_eq!(u8::try_from(Scalar::from(200u8)).unwrap(), 200); + assert_eq!(usize::try_from(Scalar::from(42usize)).unwrap(), 42); + } + + #[test] + fn out_of_range_int_conversion_is_rejected() { + // u64 value beyond i32::MAX cannot become i32. + let big = Scalar::from(5_000_000_000u64); + assert!(i32::try_from(big).is_err()); + // Negative value cannot become u32. + assert!(u32::try_from(Scalar::from(-1i32)).is_err()); + // 300 does not fit in u8. + assert!(u8::try_from(Scalar::from(300u32)).is_err()); + } + + #[test] + fn float_and_bool_variant_mismatches_are_rejected() { + // An integer field must not read a stored float. + assert!(i64::try_from(Scalar::Float(1.5)).is_err()); + // A bool field must not read a stored int. + assert!(bool::try_from(Scalar::Int(1)).is_err()); + // A float field must not read a stored bool. + assert!(f64::try_from(Scalar::Bool(true)).is_err()); + } + + #[test] + fn float_accepts_int_variants_symmetrically() { + assert_eq!(f64::try_from(Scalar::Int(3)).unwrap(), 3.0); + assert_eq!(f32::try_from(Scalar::Int(3)).unwrap(), 3.0); + assert_eq!(f64::try_from(Scalar::Float(2.5)).unwrap(), 2.5); + assert_eq!(f32::try_from(Scalar::Float(2.5)).unwrap(), 2.5); + } +} diff --git a/crates/burn-pack/src/lib.rs b/crates/burn-pack/src/lib.rs new file mode 100644 index 0000000..a4a030b --- /dev/null +++ b/crates/burn-pack/src/lib.rs @@ -0,0 +1,130 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # Burn Pack +//! +//! The **burnpack** binary serialization format for the Burn deep learning framework. +//! +//! `burn-pack` is intentionally minimal and tensor-library-agnostic: it depends only on +//! `burn-std` (for [`DType`] / [`Bytes`]), `serde`, and a CBOR codec. It knows how to read +//! and write the burnpack container format but has no notion of Burn modules or tensors. +//! Higher layers (e.g. `burn-core`) bridge between [`Tensor`] entries and their own +//! tensor/snapshot types. +//! +//! Write a pack with [`Writer`], read one with [`Reader`]; both operate on [`Tensor`] +//! entries that carry the format-level metadata plus a lazy provider of the raw +//! little-endian bytes. +//! +//! ``` +//! use burn_pack::{Bytes, DType, Reader, Tensor, Writer}; +//! +//! // A 2x2 f32 tensor, as raw little-endian bytes. +//! let raw: Vec = [1.0f32, 2.0, 3.0, 4.0] +//! .iter() +//! .flat_map(|v| v.to_le_bytes()) +//! .collect(); +//! let tensor = Tensor::new( +//! "weight".to_string(), +//! DType::F32, +//! vec![2, 2], +//! Some(42), // optional param id +//! Bytes::from_bytes_vec(raw), +//! ); +//! +//! // Write to an in-memory buffer ... +//! let packed = Writer::new(vec![tensor]) +//! .with_metadata("producer", "burn-pack docs") +//! .into_bytes() +//! .unwrap(); +//! +//! // ... and read it back. +//! let reader = Reader::from_bytes(packed).unwrap(); +//! assert_eq!(reader.metadata()["producer"], "burn-pack docs"); +//! // Consume the reader to get the tensors (zero-copy views into the source). +//! let tensors = reader.into_tensors().unwrap(); +//! assert_eq!(tensors.len(), 1); +//! assert_eq!(tensors[0].name, "weight"); +//! assert_eq!(tensors[0].shape.to_vec(), vec![2, 2]); +//! assert_eq!(tensors[0].param_id, Some(42)); +//! ``` +//! +//! # File format +//! +//! A burnpack file has three parts: a fixed-size header, a CBOR metadata blob, and a +//! 256-byte-aligned tensor data section. All multi-byte integers are little-endian. +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────┐ +//! │ Header — 10 bytes ([`HEADER_SIZE`]) │ +//! │ magic : u32 — 0x4255524E "BURN" ([`MAGIC_NUMBER`]) │ +//! │ version : u16 — format version ([`FORMAT_VERSION`]) │ +//! │ metadata_size : u32 — byte length of the CBOR metadata │ +//! ├──────────────────────────────────────────────────────────────┤ +//! │ Metadata — CBOR, `metadata_size` bytes │ +//! │ tensors : map │ +//! │ dtype : [`DType`] │ +//! │ shape : list │ +//! │ data_offsets : (start, end) relative to the data section │ +//! │ param_id : optional u64 (training-state identity) │ +//! │ metadata : map user key/value pairs │ +//! ├──────────────────────────────────────────────────────────────┤ +//! │ Padding to the next 256-byte boundary │ +//! │ ([`aligned_data_section_start`]) │ +//! ├──────────────────────────────────────────────────────────────┤ +//! │ Tensor data section │ +//! │ each tensor's bytes start on a 256-byte boundary │ +//! │ ([`TENSOR_ALIGNMENT`]) for aligned, lazy file-backed │ +//! │ loading (see [`Bytes::from_file`]). │ +//! │ tensors sliced zero-copy. │ +//! └──────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Why 256-byte alignment +//! +//! Aligning every tensor to a 256-byte boundary ([`TENSOR_ALIGNMENT`]) lets a reader +//! memory-map the file and hand out tensor slices without copying, while satisfying the +//! alignment requirements of every element type (including 8-byte `f64`), cache lines, +//! and GPU coalesced access. 256 bytes matches the choice made by GGUF, MLX, ncnn, and +//! other major formats. +//! +//! ## Safety limits +//! +//! Reading is hardened against malicious or corrupt inputs. The reader rejects files +//! that exceed any of the following before allocating for them: +//! +//! - [`MAX_METADATA_SIZE`] — largest CBOR metadata blob +//! - [`MAX_TENSOR_COUNT`] — largest number of tensors +//! - [`MAX_TENSOR_SIZE`] — largest single tensor +//! - [`MAX_CBOR_RECURSION_DEPTH`] — deepest CBOR nesting (stack-overflow guard) +//! - [`MAX_FILE_SIZE`] — largest file accepted by the file loaders (std only) +//! +//! It also validates that the file is large enough to contain every tensor it claims, +//! returning [`Error::ValidationError`] otherwise. +//! +//! ## Feature Flags +//! +//! - `std`: Enables file I/O ([`Reader::from_file`] / [`Writer::write_to_file`]) (default) + +extern crate alloc; + +mod base; +mod reader; +mod tensor; +mod writer; + +#[cfg(feature = "std")] +pub use base::MAX_FILE_SIZE; +pub use base::{ + Error, FORMAT_VERSION, HEADER_SIZE, Header, MAGIC_NUMBER, MAX_CBOR_RECURSION_DEPTH, + MAX_METADATA_SIZE, MAX_TENSOR_COUNT, MAX_TENSOR_SIZE, Scalar, ScalarConversionError, + TENSOR_ALIGNMENT, aligned_data_section_start, +}; +pub use reader::Reader; +pub use tensor::Tensor; +pub use writer::Writer; + +/// The canonical file extension for burnpack files (without the leading dot). +pub const EXTENSION: &str = "bpk"; + +// Re-export the core types so callers can build [`Tensor`] entries and inspect descriptors +// without depending on `burn-std` directly. +pub use burn_std::{Bytes, DType, Shape}; diff --git a/crates/burn-pack/src/reader.rs b/crates/burn-pack/src/reader.rs new file mode 100644 index 0000000..cc252ec --- /dev/null +++ b/crates/burn-pack/src/reader.rs @@ -0,0 +1,382 @@ +use super::base::{ + Error, FORMAT_VERSION, HEADER_SIZE, Header, MAX_CBOR_RECURSION_DEPTH, MAX_METADATA_SIZE, + MAX_TENSOR_COUNT, MAX_TENSOR_SIZE, Metadata, TensorDescriptor, aligned_data_section_start, +}; +use super::tensor::Tensor; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use burn_std::{Bytes, Shape}; + +#[cfg(feature = "std")] +use super::base::MAX_FILE_SIZE; +#[cfg(feature = "std")] +use alloc::vec; +#[cfg(feature = "std")] +use std::fs::File; +#[cfg(feature = "std")] +use std::io::Read; +#[cfg(feature = "std")] +use std::path::Path; + +/// Reader for loading burnpack containers. +pub struct Reader { + metadata: Metadata, + source: Source, + /// Absolute byte offset where the (256-byte aligned) tensor data section starts. + data_offset: usize, +} + +impl Reader { + /// Load a pack from an in-memory [`Bytes`] buffer. + /// + /// Loading is lazy: only the header and metadata are parsed here. The buffer is kept as-is + /// (no copy, no share) and is only turned into zero-copy [`Bytes::view`] windows when you + /// consume the reader with [`into_tensors`](Self::into_tensors). For large models, prefer + /// [`from_file`](Self::from_file), which keeps tensor data file-backed and lazy. + pub fn from_bytes(bytes: Bytes) -> Result { + let header = read_header(&bytes)?; + let metadata_end = HEADER_SIZE + .checked_add(header.metadata_size as usize) + .ok_or(Error::InvalidHeader)?; + if bytes.len() < metadata_end { + return Err(Error::InvalidHeader); + } + let metadata = parse_metadata(&bytes[HEADER_SIZE..metadata_end])?; + + let available = bytes.len(); + Self::assemble(&header, metadata, Source::Memory(bytes), available) + } + + /// Load a pack from a file. + /// + /// Only the header and metadata are read up front; the whole file is wrapped in a single + /// lazy [`Bytes::from_file`] source, and each tensor's data is a [`Bytes::view`] window into + /// it, read from disk only when accessed. This integrates with the Burn ecosystem's file + /// allocation (pinned-memory staging) for fast file-to-GPU transfers. + /// + /// If `path` has no extension and does not exist as given, the canonical + /// [`crate::EXTENSION`] (`.bpk`) is appended. + #[cfg(feature = "std")] + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + let path = if path.extension().is_none() && !path.exists() { + path.with_extension(crate::EXTENSION) + } else { + path.to_path_buf() + }; + + let mut file = File::open(&path).map_err(io_err)?; + + let file_size = file.metadata().map_err(io_err)?.len(); + if file_size > MAX_FILE_SIZE { + return Err(Error::ValidationError(format!( + "File size {file_size} bytes exceeds maximum allowed size of {MAX_FILE_SIZE} bytes" + ))); + } + + let mut header_bytes = [0u8; HEADER_SIZE]; + file.read_exact(&mut header_bytes).map_err(io_err)?; + let header = read_header(&header_bytes)?; + + let mut metadata_bytes = vec![0u8; header.metadata_size as usize]; + file.read_exact(&mut metadata_bytes).map_err(io_err)?; + let metadata = parse_metadata(&metadata_bytes)?; + + let source = Source::File(Bytes::from_file(path.as_path(), file_size, 0)); + Self::assemble(&header, metadata, source, file_size as usize) + } + + /// Finish construction once the header, metadata, and data source are known. + /// + /// Centralizes the truncation check and the aligned data-section offset so both + /// [`from_bytes`](Self::from_bytes) and [`from_file`](Self::from_file) stay in sync. + /// `available` is the number of bytes the source can actually supply. + fn assemble( + header: &Header, + metadata: Metadata, + source: Source, + available: usize, + ) -> Result { + let metadata_end = HEADER_SIZE + header.metadata_size as usize; + validate_total_size(&metadata, metadata_end, available)?; + + Ok(Self { + metadata, + source, + data_offset: aligned_data_section_start(header.metadata_size as usize), + }) + } + + /// Consume the reader, returning all tensors in sorted (alphabetical) name order. + /// + /// Each tensor's bytes are a zero-copy [`Bytes::view`] window into the source — no per-tensor + /// copy. For an in-memory source the buffer is [shared](Bytes::shared) once here (a cheap + /// `Arc` move, never a data copy) to make those views available; for a file source the windows + /// are file-backed and read lazily on access. Consuming `self` lets us hand the source's + /// ownership to the views directly, so loading never reads or copies tensor data eagerly. + pub fn into_tensors(self) -> Result, Error> { + let Reader { + metadata, + source, + data_offset, + } = self; + + // Make the source view-capable: a plain in-memory buffer has no zero-copy window until + // it's shared behind an `Arc`, whereas a file-backed source already windows lazily (and + // must NOT be shared, or every view would materialize the whole file). + let source = match source { + Source::Memory(bytes) => bytes.shared(), + #[cfg(feature = "std")] + Source::File(bytes) => bytes, + }; + + let mut tensors = Vec::with_capacity(metadata.tensors.len()); + for (name, descriptor) in &metadata.tensors { + let (start, end) = tensor_range(data_offset, name, descriptor)?; + let bytes = source.view(start, end).map_err(|_| { + Error::ValidationError(format!( + "Tensor '{name}' data range {start}..{end} could not be viewed (source is {} bytes)", + source.len() + )) + })?; + tensors.push(make_tensor(name, descriptor, bytes)?); + } + Ok(tensors) + } + + /// The user-supplied key/value metadata stored alongside the tensors. + /// + /// For per-tensor info (dtype/shape/param id), use [`into_tensors`](Self::into_tensors) — for a + /// file-backed reader that does not read any tensor data until a tensor's bytes are accessed. + pub fn metadata(&self) -> &alloc::collections::BTreeMap { + &self.metadata.metadata + } + + /// The typed scalars stored alongside the tensors. + /// + /// Empty for files written before scalar support. + pub fn scalars(&self) -> &alloc::collections::BTreeMap { + &self.metadata.scalars + } + + /// The names of all tensors in the pack, in sorted (alphabetical) order. + pub fn tensor_names(&self) -> Vec<&str> { + self.metadata.tensors.keys().map(|n| n.as_str()).collect() + } + + /// Read a single tensor's raw little-endian bytes by name (always copies). + /// + /// Returns [`Error::TensorNotFound`] if no tensor with that name exists. + pub fn tensor_data(&self, name: &str) -> Result, Error> { + let descriptor = self + .metadata + .tensors + .get(name) + .ok_or_else(|| Error::TensorNotFound(name.to_string()))?; + let (start, end) = tensor_range(self.data_offset, name, descriptor)?; + + match &self.source { + #[cfg(feature = "std")] + Source::File(bytes) => { + // A file-backed view reads just this tensor's range from disk. + let view = bytes.view(start, end).map_err(|_| { + Error::ValidationError(format!( + "Tensor '{name}' data range {start}..{end} could not be viewed" + )) + })?; + let slice: &[u8] = &view; + Ok(slice.to_vec()) + } + Source::Memory(bytes) => Ok(memory_chunk(bytes, start, end)?.to_vec()), + } + } +} + +/// Compute and validate the absolute `[start, end)` byte range of a tensor. +fn tensor_range( + data_offset: usize, + name: &str, + descriptor: &TensorDescriptor, +) -> Result<(usize, usize), Error> { + let to_usize = |offset: u64| -> Result { + offset.try_into().map_err(|_| { + Error::ValidationError(format!( + "Tensor '{name}' has corrupted offset data: offset {offset} exceeds platform maximum" + )) + }) + }; + let overflow = || { + Error::ValidationError(format!( + "Tensor '{name}' has corrupted offset data: overflow" + )) + }; + + let start = data_offset + .checked_add(to_usize(descriptor.data_offsets.0)?) + .ok_or_else(overflow)?; + let end = data_offset + .checked_add(to_usize(descriptor.data_offsets.1)?) + .ok_or_else(overflow)?; + + if end < start { + return Err(Error::ValidationError(format!( + "Tensor '{name}' has corrupted offset data: end {end} < start {start}" + ))); + } + if end - start > MAX_TENSOR_SIZE { + return Err(Error::ValidationError(format!( + "Tensor '{name}' size {} exceeds maximum allowed size of {MAX_TENSOR_SIZE} bytes (potential DoS attack)", + end - start + ))); + } + Ok((start, end)) +} + +/// Parse and validate a header from a buffer that starts with it. +fn read_header(buf: &[u8]) -> Result { + if buf.len() < HEADER_SIZE { + return Err(Error::InvalidHeader); + } + let header = Header::from_bytes(&buf[..HEADER_SIZE])?; + if header.version > FORMAT_VERSION { + return Err(Error::InvalidVersion); + } + if header.metadata_size > MAX_METADATA_SIZE { + return Err(Error::ValidationError(format!( + "Metadata size {} exceeds maximum allowed size of {MAX_METADATA_SIZE} bytes (potential DoS attack)", + header.metadata_size + ))); + } + Ok(header) +} + +/// Deserialize the CBOR metadata and validate the tensor count. +fn parse_metadata(bytes: &[u8]) -> Result { + let metadata: Metadata = + ciborium::de::from_reader_with_recursion_limit(bytes, MAX_CBOR_RECURSION_DEPTH) + .map_err(|e| Error::MetadataDeserializationError(e.to_string()))?; + if metadata.tensors.len() > MAX_TENSOR_COUNT { + return Err(Error::ValidationError(format!( + "File contains {} tensors, exceeding maximum of {MAX_TENSOR_COUNT} (potential DoS attack)", + metadata.tensors.len() + ))); + } + Ok(metadata) +} + +/// Ensure the available bytes can hold every tensor the metadata claims. +fn validate_total_size( + metadata: &Metadata, + metadata_end: usize, + available: usize, +) -> Result<(), Error> { + if metadata.tensors.is_empty() { + return Ok(()); + } + let max_offset = metadata + .tensors + .values() + .map(|t| t.data_offsets.1) + .max() + .unwrap_or(0); + let max_offset: usize = max_offset.try_into().map_err(|_| { + Error::ValidationError(format!("Data offset {max_offset} exceeds platform maximum")) + })?; + let min_size = metadata_end + .checked_add(max_offset) + .ok_or_else(|| Error::ValidationError("File size calculation overflow".into()))?; + if available < min_size { + return Err(Error::ValidationError(format!( + "File truncated: expected at least {min_size} bytes, got {available} bytes" + ))); + } + Ok(()) +} + +/// Borrow a tensor's `[start, end)` range out of an in-memory buffer, bounds-checked. +fn memory_chunk(source: &Bytes, start: usize, end: usize) -> Result<&[u8], Error> { + let data: &[u8] = source; + data.get(start..end).ok_or_else(|| { + Error::ValidationError(format!( + "Tensor data range {start}..{end} is out of bounds (buffer is {} bytes)", + data.len() + )) + }) +} + +/// Build a [`Tensor`] entry from a descriptor + its data bytes. +fn make_tensor(name: &str, descriptor: &TensorDescriptor, bytes: Bytes) -> Result { + let shape = descriptor + .shape + .iter() + .map(|&s| { + s.try_into().map_err(|_| { + Error::ValidationError(format!( + "Tensor '{name}' has corrupted shape data: dimension {s} exceeds platform maximum" + )) + }) + }) + .collect::, Error>>()?; + + Ok(Tensor::new( + name.to_string(), + descriptor.dtype, + Shape::from(shape), + descriptor.param_id, + bytes, + )) +} + +/// Where a [`Reader`] gets its tensor data from. +/// +/// Both variants hold a single [`Bytes`] spanning the whole container, and tensors are carved +/// out of it with zero-copy [`Bytes::view`] windows. +enum Source { + /// The whole pack lives in memory. + Memory(Bytes), + /// The pack lives in a file; tensor data is read lazily via file-backed [`Bytes::view`]. + #[cfg(feature = "std")] + File(Bytes), +} + +#[cfg(feature = "std")] +fn io_err(e: std::io::Error) -> Error { + Error::IoError(e.to_string()) +} + +// Verifies the on-disk layout invariant (256-byte tensor alignment), which needs access to the +// internal `TensorDescriptor` offsets. Public round-trip/error tests live in `tests/`. +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::{TENSOR_ALIGNMENT, Tensor, Writer}; + use burn_std::DType; + + fn tensor(name: &str, elems: usize) -> Tensor { + Tensor::new( + name.to_string(), + DType::F32, + alloc::vec![elems], + None, + Bytes::from_bytes_vec(alloc::vec![0u8; elems * 4]), + ) + } + + #[test] + fn tensor_offsets_are_256_aligned() { + // Odd sizes force the writer to insert padding between tensors. + let packed = Writer::new(vec![tensor("a", 3), tensor("b", 1), tensor("c", 2)]) + .into_bytes() + .unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + + for (name, descriptor) in &reader.metadata.tensors { + assert_eq!( + descriptor.data_offsets.0 % TENSOR_ALIGNMENT, + 0, + "tensor '{name}' start offset is not 256-aligned" + ); + } + } +} diff --git a/crates/burn-pack/src/tensor.rs b/crates/burn-pack/src/tensor.rs new file mode 100644 index 0000000..eac913f --- /dev/null +++ b/crates/burn-pack/src/tensor.rs @@ -0,0 +1,56 @@ +//! Tensor-library-agnostic tensor entry for the burnpack format. +//! +//! The burnpack reader and writer operate on [`Tensor`] values, which carry only the +//! format-level information (name, dtype, shape, optional param id) plus the raw +//! little-endian [`Bytes`]. Keeping the bytes as [`Bytes`] (rather than a custom buffer +//! type) integrates with the rest of the Burn ecosystem: a reader can hand out +//! file-backed bytes ([`Bytes::from_file`]) for fast, lazy file-to-GPU transfers, while a +//! writer simply consumes already-materialized bytes. + +use alloc::string::String; + +use burn_std::{Bytes, DType, Shape}; + +/// A single tensor in a burnpack container, decoupled from any tensor library. +/// +/// The [`bytes`](Self::bytes) field holds the tensor's data in little-endian layout, +/// matching the element count implied by [`shape`](Self::shape) and [`dtype`](Self::dtype). +/// When produced by a [`Reader`](crate::Reader) loading from a file, the bytes are +/// file-backed and only read from disk when accessed. +#[derive(Clone)] +pub struct Tensor { + /// Fully-qualified tensor name (e.g. `"encoder.layer1.weight"`). + pub name: String, + /// Data type of the tensor. + pub dtype: DType, + /// Tensor shape. + pub shape: Shape, + /// Optional parameter id, used to preserve identities for stateful training. + pub param_id: Option, + /// The tensor's raw little-endian bytes. + pub bytes: Bytes, +} + +impl Tensor { + /// Create a tensor entry from its metadata and raw bytes. + pub fn new( + name: String, + dtype: DType, + shape: impl Into, + param_id: Option, + bytes: Bytes, + ) -> Self { + Self { + name, + dtype, + shape: shape.into(), + param_id, + bytes, + } + } + + /// Number of raw bytes the tensor occupies. + pub fn byte_len(&self) -> usize { + self.bytes.len() + } +} diff --git a/crates/burn-pack/src/writer.rs b/crates/burn-pack/src/writer.rs new file mode 100644 index 0000000..c2a1120 --- /dev/null +++ b/crates/burn-pack/src/writer.rs @@ -0,0 +1,411 @@ +use super::base::{ + Error, FORMAT_VERSION, HEADER_SIZE, Header, MAGIC_NUMBER, Metadata, Scalar, TENSOR_ALIGNMENT, + TensorDescriptor, aligned_data_section_start, +}; +use super::tensor::Tensor; +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use burn_std::Bytes; + +#[cfg(feature = "std")] +use std::fs::File; +#[cfg(feature = "std")] +use std::io::{Read, Write}; +#[cfg(feature = "std")] +use std::path::Path; + +/// Align an offset to the specified alignment boundary. +/// +/// Returns the smallest value >= `offset` that is a multiple of `alignment`. +#[inline] +const fn align_offset(offset: u64, alignment: u64) -> u64 { + offset.div_ceil(alignment) * alignment +} + +/// Maximum number of bytes materialized from a single tensor at a time while +/// streaming its data into a [`Sink`]. +/// +/// Large device-resident tensors are read back to host memory lazily, one +/// [`Bytes::view`] window at a time, instead of all at once. This keeps the +/// transient (often pinned) host staging buffer bounded by this size regardless +/// of how large the tensor is. The value is a multiple of [`TENSOR_ALIGNMENT`] +/// so each window starts on an aligned device offset. +const WRITE_CHUNK_SIZE: usize = 8 * 1024 * 1024; + +/// Writer for creating Burnpack files +pub struct Writer { + /// Tensors to write + pub(crate) tensors: Vec, + /// Metadata key-value pairs + pub(crate) metadata: BTreeMap, + /// Typed scalars keyed by name + pub(crate) scalars: BTreeMap, +} + +impl Writer { + /// Create a new writer + pub fn new(tensors: Vec) -> Self { + Self { + tensors, + metadata: BTreeMap::new(), + scalars: BTreeMap::new(), + } + } + + /// Builder pattern: add metadata and return self + pub fn with_metadata(mut self, key: &str, value: &str) -> Self { + self.metadata.insert(key.to_string(), value.to_string()); + self + } + + /// Builder pattern: add a typed scalar and return self. + pub fn with_scalar(mut self, key: &str, value: Scalar) -> Self { + self.scalars.insert(key.to_string(), value); + self + } + + /// Calculate the total size needed for the burnpack data. + /// + /// This is useful when you want to pre-allocate a buffer for `write_into()`. + /// The size includes padding bytes for both metadata alignment and tensor alignment. + pub fn size(&self) -> Result { + Ok(self.plan()?.total_size()) + } + + /// Write burnpack data into a caller-provided buffer. + /// + /// The buffer must be large enough to hold all data. Use `size()` to determine + /// the required buffer size. If the buffer is too small, this will return an error. + /// + /// This allows the caller to control buffer allocation, enabling optimizations like: + /// - Buffer reuse across multiple writes + /// - Custom allocators + /// - Pinned memory for GPU transfers + /// + /// # Arguments + /// + /// * `buffer` - Mutable slice to write data into. Must be at least `size()` bytes. + pub fn write_into(self, buffer: &mut [u8]) -> Result<(), Error> { + let layout = self.plan()?; + let total_size = layout.total_size(); + + if buffer.len() < total_size { + return Err(Error::IoError(format!( + "Buffer too small: need {} bytes, got {} bytes", + total_size, + buffer.len() + ))); + } + + let mut sink = BufferSink { buffer, offset: 0 }; + self.write_container(&layout, &mut sink) + } + + /// Write to a byte buffer (convenience method). + /// + /// This allocates a buffer internally and writes the burnpack data. + /// For more control over buffer allocation, use `size()` + `write_into()`. + pub fn into_bytes(self) -> Result { + let layout = self.plan()?; + let mut buffer = vec![0u8; layout.total_size()]; + + let mut sink = BufferSink { + buffer: &mut buffer, + offset: 0, + }; + self.write_container(&layout, &mut sink)?; + + Ok(Bytes::from_bytes_vec(buffer)) + } + + /// Write directly to a file (more memory efficient for large models). + /// + /// If `path` has no extension, the canonical [`crate::EXTENSION`] (`.bpk`) is appended. + #[cfg(feature = "std")] + pub fn write_to_file>(self, path: P) -> Result<(), Error> { + let path = path.as_ref(); + let path = if path.extension().is_none() { + path.with_extension(crate::EXTENSION) + } else { + path.to_path_buf() + }; + + let layout = self.plan()?; + let file = File::create(path).map_err(|e| Error::IoError(e.to_string()))?; + + let mut sink = FileSink { file }; + self.write_container(&layout, &mut sink)?; + + sink.file.flush().map_err(|e| Error::IoError(e.to_string())) + } + + /// Build the complete on-disk layout: header, serialized metadata, and the + /// position and size of the (aligned) tensor data section. + fn plan(&self) -> Result { + let (metadata, metadata_bytes, data_size) = self.build_metadata()?; + + let metadata_size: u32 = metadata_bytes.len().try_into().map_err(|_| { + Error::IoError(format!( + "Metadata size {} exceeds maximum of {} bytes", + metadata_bytes.len(), + u32::MAX + )) + })?; + + let header = Header { + magic: MAGIC_NUMBER, + version: FORMAT_VERSION, + metadata_size, + }; + + let data_section_start = aligned_data_section_start(metadata_bytes.len()); + + Ok(Layout { + metadata, + metadata_bytes, + header, + data_section_start, + data_size, + }) + } + + /// Serialize the metadata structure (tensor descriptors + key-value pairs) to CBOR. + /// + /// Also returns the size of the tensor data section, computed while assigning offsets. + fn build_metadata(&self) -> Result<(Metadata, Vec, usize), Error> { + let (tensors, data_size) = self.build_descriptors()?; + let metadata = Metadata { + tensors, + metadata: self.metadata.clone(), + scalars: self.scalars.clone(), + }; + + let mut metadata_bytes = Vec::new(); + ciborium::ser::into_writer(&metadata, &mut metadata_bytes) + .map_err(|e| Error::MetadataSerializationError(e.to_string()))?; + + Ok((metadata, metadata_bytes, data_size)) + } + + /// Build tensor descriptors, assigning each tensor an aligned offset within + /// the data section so that absolute file positions are mmap-friendly. + /// + /// Returns the descriptors plus the total data-section size — the running offset after the + /// last tensor. Offsets only grow, so this is also the highest descriptor end offset. + fn build_descriptors(&self) -> Result<(BTreeMap, usize), Error> { + let mut tensors = BTreeMap::new(); + let mut current_offset = 0u64; + + for tensor in &self.tensors { + let data_len = tensor.bytes.len() as u64; + + // Align the start offset for mmap zero-copy support. + let aligned_start = align_offset(current_offset, TENSOR_ALIGNMENT); + let end = aligned_start.checked_add(data_len).ok_or_else(|| { + Error::IoError(format!( + "Tensor offset overflow: {} + {} exceeds maximum", + aligned_start, data_len + )) + })?; + + // Descriptors are keyed by name, but the tensor data is written from the + // (ordered) `self.tensors` list. A duplicate name would collapse to a single + // descriptor while still writing two data blocks, corrupting the container. + if tensors + .insert( + tensor.name.clone(), + TensorDescriptor { + dtype: tensor.dtype, + shape: tensor.shape.iter().map(|&s| s as u64).collect(), + data_offsets: (aligned_start, end), + param_id: tensor.param_id, + }, + ) + .is_some() + { + return Err(Error::ValidationError(format!( + "Duplicate tensor name '{}'", + tensor.name + ))); + } + + current_offset = end; + } + + Ok((tensors, current_offset as usize)) + } + + /// Emit the full container — header, metadata, alignment padding, then tensor data + /// — into `sink`, which decides where the bytes ultimately land. + fn write_container(self, layout: &Layout, sink: &mut impl Sink) -> Result<(), Error> { + sink.write(&layout.header.into_bytes())?; + sink.write(&layout.metadata_bytes)?; + + // Pad so the data section starts at its aligned position. + let unaligned_data_start = HEADER_SIZE + layout.metadata_bytes.len(); + if layout.data_section_start > unaligned_data_start { + sink.pad(layout.data_section_start - unaligned_data_start)?; + } + + self.write_tensors(&layout.metadata, sink) + } + + /// Write each tensor's data into `sink`, inserting alignment padding between + /// tensors so every tensor lands at its descriptor's aligned offset. + fn write_tensors(self, metadata: &Metadata, sink: &mut impl Sink) -> Result<(), Error> { + // Position within the data section (relative to its aligned start). + let mut data_offset = 0usize; + + for tensor in self.tensors.into_iter() { + let (aligned_offset, data) = Self::resolve_tensor(tensor, metadata)?; + + if aligned_offset > data_offset { + sink.pad(aligned_offset - data_offset)?; + data_offset = aligned_offset; + } + + Self::write_tensor_data(&data, sink)?; + data_offset += data.len(); + } + + Ok(()) + } + + /// Stream a single tensor's bytes into `sink`, materializing at most + /// [`WRITE_CHUNK_SIZE`] bytes at a time. + /// + /// When the backing supports zero-copy windows — device-resident + /// ([lazy](burn_std::Bytes) device readback), file, or shared buffers — each + /// chunk is taken as a [`Bytes::view`] and read just-in-time, then dropped + /// before the next one. A large device tensor is therefore copied to host in + /// bounded pieces rather than through one big (pinned) staging buffer, so the + /// whole tensor never has to be resident at once. + /// + /// Backings without a zero-copy window (e.g. a plain heap `Vec`) are already + /// host-resident, so [`Bytes::view`] reports it can't window them and the + /// remaining bytes are written in a single pass. + fn write_tensor_data(data: &Bytes, sink: &mut impl Sink) -> Result<(), Error> { + let len = data.len(); + let mut offset = 0; + + while offset < len { + let end = (offset + WRITE_CHUNK_SIZE).min(len); + match data.view(offset, end) { + Ok(chunk) => { + sink.write(&chunk)?; + offset = end; + } + // No zero-copy window available (already host-resident): write + // whatever remains in one shot. View support is a property of the + // backing, so this only ever happens on the first iteration. + Err(_) => { + sink.write(&data[offset..])?; + break; + } + } + } + + Ok(()) + } + + /// Look up a tensor's aligned offset from the metadata and validate that its + /// bytes match the length the descriptor reserved for it. + fn resolve_tensor(tensor: Tensor, metadata: &Metadata) -> Result<(usize, Bytes), Error> { + let descriptor = metadata.tensors.get(&tensor.name).ok_or_else(|| { + Error::IoError(format!( + "Internal error: tensor '{}' not found in metadata", + tensor.name + )) + })?; + + let (start, end) = descriptor.data_offsets; + let declared_len = (end - start) as usize; + let actual_len = tensor.bytes.len(); + if actual_len != declared_len { + return Err(Error::TensorBytesSizeMismatch(format!( + "tensor '{}' has inconsistent length (expected {}, got {})", + tensor.name, declared_len, actual_len + ))); + } + + Ok((start as usize, tensor.bytes)) + } +} + +/// The computed on-disk layout of a burnpack container. +/// +/// Captures everything needed to emit the bytes: the serialized metadata, the +/// header, where the aligned data section begins, and how large it is. Built once +/// via [`Writer::plan`] and shared by `size`, `write_into`, `to_bytes`, and +/// `write_to_file`. +struct Layout { + metadata: Metadata, + metadata_bytes: Vec, + header: Header, + data_section_start: usize, + data_size: usize, +} + +impl Layout { + /// Total number of bytes the container occupies. + fn total_size(&self) -> usize { + self.data_section_start + self.data_size + } +} + +/// A sequential destination for the bytes of a burnpack container. +/// +/// Padding and data are written in order; each implementation advances its own +/// cursor, letting the writer stay agnostic about whether bytes land in a buffer +/// or a file. +trait Sink { + /// Write `count` zero bytes of alignment padding. + fn pad(&mut self, count: usize) -> Result<(), Error>; + /// Write `data` verbatim. + fn write(&mut self, data: &[u8]) -> Result<(), Error>; +} + +/// Sink that copies into a caller-provided buffer. +struct BufferSink<'a> { + buffer: &'a mut [u8], + offset: usize, +} + +impl Sink for BufferSink<'_> { + fn pad(&mut self, count: usize) -> Result<(), Error> { + self.buffer[self.offset..self.offset + count].fill(0); + self.offset += count; + Ok(()) + } + + fn write(&mut self, data: &[u8]) -> Result<(), Error> { + self.buffer[self.offset..self.offset + data.len()].copy_from_slice(data); + self.offset += data.len(); + Ok(()) + } +} + +/// Sink that streams directly to a file. +#[cfg(feature = "std")] +struct FileSink { + file: File, +} + +#[cfg(feature = "std")] +impl Sink for FileSink { + fn pad(&mut self, count: usize) -> Result<(), Error> { + // Stream zeros without allocating a `count`-sized buffer per call. + std::io::copy(&mut std::io::repeat(0).take(count as u64), &mut self.file) + .map(|_| ()) + .map_err(|e| Error::IoError(e.to_string())) + } + + fn write(&mut self, data: &[u8]) -> Result<(), Error> { + self.file + .write_all(data) + .map_err(|e| Error::IoError(e.to_string())) + } +} diff --git a/crates/burn-pack/tests/alignment.rs b/crates/burn-pack/tests/alignment.rs new file mode 100644 index 0000000..3ed6e20 --- /dev/null +++ b/crates/burn-pack/tests/alignment.rs @@ -0,0 +1,61 @@ +//! Alignment guarantees and buffer-sizing (`size`/`write_into`). + +mod common; + +use burn_pack::{HEADER_SIZE, Reader, TENSOR_ALIGNMENT, Writer, aligned_data_section_start}; +use common::{f32_tensor, read_f32}; + +#[test] +fn data_section_start_is_aligned() { + let align = TENSOR_ALIGNMENT as usize; + for metadata_size in [0usize, 1, 10, 245, 246, 247, 4096] { + let start = aligned_data_section_start(metadata_size); + assert_eq!(start % align, 0, "data section start must be 256-aligned"); + assert!( + start >= HEADER_SIZE + metadata_size, + "must clear header+metadata" + ); + assert!( + start < HEADER_SIZE + metadata_size + align, + "minimal padding" + ); + } +} + +// Per-tensor offset alignment is verified by an in-crate unit test (it needs the internal +// tensor descriptors); see `src/reader.rs`. + +#[test] +fn size_matches_to_bytes_length() { + let writer = Writer::new(vec![ + f32_tensor("a", &[1.0, 2.0, 3.0], &[3], None), + f32_tensor("b", &[4.0], &[1], None), + ]); + + let size = writer.size().unwrap(); + let bytes = writer.into_bytes().unwrap(); + assert_eq!(size, bytes.len()); +} + +#[test] +fn write_into_matches_to_bytes_and_round_trips() { + // `write_into` and `into_bytes` each consume the writer, so build one per call. + let make_writer = || Writer::new(vec![f32_tensor("w", &[1.0, 2.0, 3.0, 4.0], &[2, 2], None)]); + + let mut buffer = vec![0u8; make_writer().size().unwrap()]; + make_writer().write_into(&mut buffer).unwrap(); + + let from_to_bytes = make_writer().into_bytes().unwrap(); + assert_eq!(&buffer[..], &from_to_bytes[..]); + + let reader = Reader::from_bytes(burn_pack::Bytes::from_bytes_vec(buffer)).unwrap(); + let tensors = reader.into_tensors().unwrap(); + assert_eq!(read_f32(&tensors[0]), vec![1.0, 2.0, 3.0, 4.0]); +} + +#[test] +fn write_into_rejects_too_small_buffer() { + let writer = Writer::new(vec![f32_tensor("w", &[1.0, 2.0], &[2], None)]); + let mut buffer = vec![0u8; writer.size().unwrap() - 1]; + assert!(writer.write_into(&mut buffer).is_err()); +} diff --git a/crates/burn-pack/tests/common/mod.rs b/crates/burn-pack/tests/common/mod.rs new file mode 100644 index 0000000..bc917f3 --- /dev/null +++ b/crates/burn-pack/tests/common/mod.rs @@ -0,0 +1,44 @@ +//! Shared helpers for the burn-pack format integration tests. +//! +//! Included by multiple test binaries; not every helper is used by every one. +#![allow(dead_code)] + +use burn_pack::{Bytes, DType, Tensor}; + +/// Build an f32 [`Tensor`] entry from values + shape. +pub fn f32_tensor(name: &str, values: &[f32], shape: &[usize], param_id: Option) -> Tensor { + let raw: Vec = values.iter().flat_map(|v| v.to_le_bytes()).collect(); + Tensor::new( + name.to_string(), + DType::F32, + shape.to_vec(), + param_id, + Bytes::from_bytes_vec(raw), + ) +} + +/// Build a [`Tensor`] entry from raw little-endian bytes with an explicit dtype. +pub fn raw_tensor( + name: &str, + dtype: DType, + shape: &[usize], + bytes: Vec, + param_id: Option, +) -> Tensor { + Tensor::new( + name.to_string(), + dtype, + shape.to_vec(), + param_id, + Bytes::from_bytes_vec(bytes), + ) +} + +/// Decode a tensor entry's bytes as f32 values. +pub fn read_f32(tensor: &Tensor) -> Vec { + let slice: &[u8] = &tensor.bytes; + slice + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} diff --git a/crates/burn-pack/tests/errors.rs b/crates/burn-pack/tests/errors.rs new file mode 100644 index 0000000..7ccbd1c --- /dev/null +++ b/crates/burn-pack/tests/errors.rs @@ -0,0 +1,95 @@ +//! Malformed / malicious input handling. + +mod common; + +use burn_pack::{ + Bytes, Error, FORMAT_VERSION, Header, MAGIC_NUMBER, MAX_METADATA_SIZE, Reader, Writer, +}; +use common::f32_tensor; + +fn header_bytes(version: u16, metadata_size: u32) -> Bytes { + let header = Header { + magic: MAGIC_NUMBER, + version, + metadata_size, + }; + Bytes::from_bytes_vec(header.into_bytes().to_vec()) +} + +#[test] +fn rejects_too_short_input() { + assert!(matches!( + Reader::from_bytes(Bytes::from_bytes_vec(vec![0u8; 4])), + Err(Error::InvalidHeader) + )); +} + +#[test] +fn rejects_bad_magic() { + let mut bytes = vec![0u8; 10]; + bytes[..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); + assert!(matches!( + Reader::from_bytes(Bytes::from_bytes_vec(bytes)), + Err(Error::InvalidMagicNumber) + )); +} + +#[test] +fn rejects_future_version() { + let bytes = header_bytes(FORMAT_VERSION + 1, 0); + assert!(matches!( + Reader::from_bytes(bytes), + Err(Error::InvalidVersion) + )); +} + +#[test] +fn rejects_oversized_metadata_claim() { + // The reader bails out on the metadata-size claim before allocating for it. + let bytes = header_bytes(FORMAT_VERSION, MAX_METADATA_SIZE + 1); + assert!(matches!( + Reader::from_bytes(bytes), + Err(Error::ValidationError(_)) + )); +} + +#[test] +fn rejects_metadata_size_past_eof() { + // Header claims more metadata than the buffer actually contains. + let bytes = header_bytes(FORMAT_VERSION, 4096); + assert!(Reader::from_bytes(bytes).is_err()); +} + +#[test] +fn rejects_duplicate_tensor_names() { + // Descriptors are keyed by name but data is written from the tensor list: a duplicate + // name must be rejected up front, not silently corrupt the container. + let writer = Writer::new(vec![ + f32_tensor("w", &[1.0, 2.0], &[2], None), + f32_tensor("w", &[3.0, 4.0], &[2], None), + ]); + assert!(matches!( + writer.into_bytes(), + Err(Error::ValidationError(_)) + )); +} + +#[test] +fn rejects_truncated_data_section() { + // A valid pack, truncated well into its data section, must be rejected (not silently + // read). Use a large tensor and drop half the file so we are unambiguously below the + // size the metadata claims. + let values: Vec = (0..512).map(|i| i as f32).collect(); + let packed = Writer::new(vec![f32_tensor("w", &values, &[512], None)]) + .into_bytes() + .unwrap(); + + let slice: &[u8] = &packed; + let mut bytes = slice.to_vec(); + bytes.truncate(bytes.len() / 2); + + assert!(matches!( + Reader::from_bytes(Bytes::from_bytes_vec(bytes)), + Err(Error::ValidationError(_)) + )); +} diff --git a/crates/burn-pack/tests/header.rs b/crates/burn-pack/tests/header.rs new file mode 100644 index 0000000..a69f869 --- /dev/null +++ b/crates/burn-pack/tests/header.rs @@ -0,0 +1,44 @@ +//! Header encoding / decoding and format constants. + +use burn_pack::{Error, FORMAT_VERSION, HEADER_SIZE, Header, MAGIC_NUMBER}; + +#[test] +fn header_constants() { + assert_eq!(HEADER_SIZE, 10); + // "BURN" in ASCII. + assert_eq!(MAGIC_NUMBER, 0x4255524E); + assert_eq!(MAGIC_NUMBER.to_le_bytes(), *b"NRUB"); +} + +#[test] +fn header_round_trip() { + let header = Header::new(1234); + let bytes = header.into_bytes(); + assert_eq!(bytes.len(), HEADER_SIZE); + + let decoded = Header::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.magic, MAGIC_NUMBER); + assert_eq!(decoded.version, FORMAT_VERSION); + assert_eq!(decoded.metadata_size, 1234); +} + +#[test] +fn header_rejects_bad_magic() { + let bad = Header { + magic: 0xDEAD_BEEF, + version: FORMAT_VERSION, + metadata_size: 0, + }; + assert!(matches!( + Header::from_bytes(&bad.into_bytes()), + Err(Error::InvalidMagicNumber) + )); +} + +#[test] +fn header_rejects_short_input() { + assert!(matches!( + Header::from_bytes(&[0u8; 4]), + Err(Error::InvalidHeader) + )); +} diff --git a/crates/burn-pack/tests/round_trip.rs b/crates/burn-pack/tests/round_trip.rs new file mode 100644 index 0000000..a6705e4 --- /dev/null +++ b/crates/burn-pack/tests/round_trip.rs @@ -0,0 +1,308 @@ +//! Write → read round-trip coverage for the burnpack format. + +mod common; + +use burn_pack::{Bytes, DType, Reader, Scalar, Tensor, Writer}; +use common::{f32_tensor, raw_tensor, read_f32}; + +#[test] +fn single_tensor_round_trip() { + let tensor = f32_tensor("weight", &[1.0, 2.0, 3.0, 4.0], &[2, 2], Some(7)); + + let packed = Writer::new(vec![tensor]).into_bytes().unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + let tensors = reader.into_tensors().unwrap(); + + assert_eq!(tensors.len(), 1); + let t = &tensors[0]; + assert_eq!(t.name, "weight"); + assert_eq!(t.dtype, DType::F32); + assert_eq!(t.shape.to_vec(), vec![2, 2]); + assert_eq!(t.param_id, Some(7)); + assert_eq!(t.byte_len(), 16); + assert_eq!(read_f32(t), vec![1.0, 2.0, 3.0, 4.0]); +} + +#[test] +fn multiple_tensors_returned_sorted_by_name() { + // Insert out of order; the reader yields them sorted (BTreeMap ordering). + let packed = Writer::new(vec![ + f32_tensor("zebra", &[9.0], &[1], None), + f32_tensor("alpha", &[1.0], &[1], None), + f32_tensor("mango", &[5.0], &[1], None), + ]) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + // Read names before consuming the reader, then consume it for the tensors. + assert_eq!(reader.tensor_names(), vec!["alpha", "mango", "zebra"]); + let names: Vec<_> = reader + .into_tensors() + .unwrap() + .iter() + .map(|t| t.name.clone()) + .collect(); + + assert_eq!(names, vec!["alpha", "mango", "zebra"]); +} + +#[test] +fn tensors_with_varied_sizes_map_to_correct_names() { + // On-disk data order = write order (z, a, m), which differs from the name-sorted order the + // reader returns. The odd sizes force different alignment gaps between tensors, so this + // exercises the gap/offset arithmetic of the view-based loader: each tensor's zero-copy + // window must still come back attached to the right name. + let z: Vec = (0..5).map(|i| 100.0 + i as f32).collect(); + let a: Vec = (0..3).map(|i| i as f32).collect(); + let m: Vec = (0..7).map(|i| 50.0 + i as f32).collect(); + + let packed = Writer::new(vec![ + f32_tensor("zebra", &z, &[5], None), + f32_tensor("alpha", &a, &[3], None), + f32_tensor("mango", &m, &[7], None), + ]) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + let tensors = reader.into_tensors().unwrap(); + + let names: Vec<_> = tensors.iter().map(|t| t.name.clone()).collect(); + assert_eq!(names, vec!["alpha", "mango", "zebra"]); + assert_eq!(read_f32(&tensors[0]), a); + assert_eq!(read_f32(&tensors[1]), m); + assert_eq!(read_f32(&tensors[2]), z); +} + +#[test] +fn user_metadata_round_trip() { + let packed = Writer::new(vec![f32_tensor("w", &[1.0], &[1], None)]) + .with_metadata("producer", "burn-pack") + .with_metadata("format", "burnpack") + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + assert_eq!(reader.metadata()["producer"], "burn-pack"); + assert_eq!(reader.metadata()["format"], "burnpack"); + // Per-tensor info comes from the tensors themselves. + let tensors = reader.into_tensors().unwrap(); + assert_eq!(tensors[0].dtype, DType::F32); + assert_eq!(tensors[0].shape.to_vec(), vec![1]); +} + +#[test] +fn param_id_present_and_absent() { + let packed = Writer::new(vec![ + f32_tensor("with_id", &[1.0], &[1], Some(123)), + f32_tensor("without_id", &[2.0], &[1], None), + ]) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + let tensors = reader.into_tensors().unwrap(); + let with = tensors.iter().find(|t| t.name == "with_id").unwrap(); + let without = tensors.iter().find(|t| t.name == "without_id").unwrap(); + + assert_eq!(with.param_id, Some(123)); + assert_eq!(without.param_id, None); +} + +#[test] +fn empty_pack() { + let packed = Writer::new(vec![]).into_bytes().unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + assert!(reader.tensor_names().is_empty()); + assert!(reader.into_tensors().unwrap().is_empty()); +} + +#[test] +fn dtype_and_byte_len_preserved() { + // (dtype, bytes-per-element) + let cases = [ + (DType::F32, 4usize), + (DType::F64, 8), + (DType::I64, 8), + (DType::I32, 4), + (DType::I8, 1), + (DType::U8, 1), + (DType::BF16, 2), + (DType::F16, 2), + ]; + + let n = 3; + let tensors = cases + .iter() + .enumerate() + .map(|(i, (dtype, elem))| { + let bytes = vec![i as u8 + 1; n * elem]; + raw_tensor(&format!("t{i}"), *dtype, &[n], bytes, None) + }) + .collect(); + + let packed = Writer::new(tensors).into_bytes().unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + let read = reader.into_tensors().unwrap(); + + for (i, (dtype, elem)) in cases.iter().enumerate() { + let t = read.iter().find(|t| t.name == format!("t{i}")).unwrap(); + assert_eq!(t.dtype, *dtype, "dtype preserved for t{i}"); + assert_eq!(t.shape.to_vec(), vec![n]); + assert_eq!(t.byte_len(), n * elem, "byte_len preserved for t{i}"); + let materialized: &[u8] = &t.bytes; + assert_eq!(materialized, &vec![i as u8 + 1; n * elem][..]); + } +} + +#[test] +fn in_memory_round_trip() { + let packed = Writer::new(vec![f32_tensor("w", &[1.5, 2.5, 3.5], &[3], None)]) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + let tensors = reader.into_tensors().unwrap(); + assert_eq!(read_f32(&tensors[0]), vec![1.5, 2.5, 3.5]); +} + +// Reading from a file backs each tensor with a file-backed `Bytes::view` window, read lazily on +// access. +#[cfg(feature = "std")] +#[test] +fn file_backed_tensors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("zc.bpk"); + Writer::new(vec![f32_tensor("w", &[1.5, 2.5, 3.5], &[3], None)]) + .write_to_file(&path) + .unwrap(); + + let reader = Reader::from_file(&path).unwrap(); + let tensors = reader.into_tensors().unwrap(); + assert_eq!(read_f32(&tensors[0]), vec![1.5, 2.5, 3.5]); +} + +// A `shared()` tensor exposes zero-copy `view` windows, so the writer streams it +// in bounded chunks rather than materializing it whole. Data larger than the +// writer's internal chunk size exercises the multi-chunk path (several full +// windows plus a partial tail); the concatenated windows must reproduce the +// original bytes exactly. +#[test] +fn shared_tensor_chunked_write_round_trip() { + // Comfortably larger than the writer's 8 MiB chunk size, and not a whole + // multiple of it, so the final chunk is partial. + let len = 8 * 1024 * 1024 + 4096; + let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + + let bytes = Bytes::from_bytes_vec(data.clone()).shared(); + let tensor = Tensor::new("big".to_string(), DType::U8, vec![len], None, bytes); + + let packed = Writer::new(vec![tensor]).into_bytes().unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + let read = reader.into_tensors().unwrap(); + + assert_eq!(read.len(), 1); + let materialized: &[u8] = &read[0].bytes; + assert_eq!(materialized.len(), len); + assert_eq!(materialized, &data[..]); +} + +#[test] +fn read_single_tensor_data_by_name() { + let packed = Writer::new(vec![ + f32_tensor("a", &[1.0, 2.0], &[2], None), + f32_tensor("b", &[3.0], &[1], None), + ]) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + let raw = reader.tensor_data("a").unwrap(); + let values: Vec = raw + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + assert_eq!(values, vec![1.0, 2.0]); + + assert!(reader.tensor_data("missing").is_err()); +} + +#[cfg(feature = "std")] +#[test] +fn file_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("model.bpk"); + + Writer::new(vec![f32_tensor( + "weight", + &[1.0, 2.0, 3.0, 4.0], + &[2, 2], + Some(1), + )]) + .with_metadata("producer", "burn-pack") + .write_to_file(&path) + .unwrap(); + + let reader = Reader::from_file(&path).unwrap(); + assert_eq!(reader.metadata()["producer"], "burn-pack"); + let tensors = reader.into_tensors().unwrap(); + assert_eq!(tensors.len(), 1); + assert_eq!(read_f32(&tensors[0]), vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(tensors[0].param_id, Some(1)); +} + +#[test] +fn extensionless_path_appends_bpk() { + let dir = tempfile::tempdir().unwrap(); + // No extension on the path: the writer should append `.bpk`, and the reader should find it + // when given the same extension-less path. + let path = dir.path().join("model"); + + Writer::new(vec![f32_tensor("weight", &[1.0, 2.0], &[2], Some(7))]) + .write_to_file(&path) + .unwrap(); + + assert!( + path.with_extension("bpk").exists(), + "writer should have created `model.bpk`" + ); + assert!( + !path.exists(), + "no extension-less `model` file should exist" + ); + + let reader = Reader::from_file(&path).unwrap(); + let tensors = reader.into_tensors().unwrap(); + assert_eq!(read_f32(&tensors[0]), vec![1.0, 2.0]); + assert_eq!(tensors[0].param_id, Some(7)); +} + +#[test] +fn typed_scalars_round_trip() { + let packed = Writer::new(vec![f32_tensor("w", &[1.0], &[1], None)]) + .with_scalar("step", Scalar::UInt(42)) + .with_scalar("lr", Scalar::Float(0.001)) + .with_scalar("flag", Scalar::Bool(true)) + .with_scalar("offset", Scalar::Int(-3)) + .into_bytes() + .unwrap(); + + let reader = Reader::from_bytes(packed).unwrap(); + let scalars = reader.scalars(); + assert_eq!(scalars.get("step"), Some(&Scalar::UInt(42))); + assert_eq!(scalars.get("lr"), Some(&Scalar::Float(0.001))); + assert_eq!(scalars.get("flag"), Some(&Scalar::Bool(true))); + assert_eq!(scalars.get("offset"), Some(&Scalar::Int(-3))); +} + +#[test] +fn scalars_absent_by_default() { + // A pack written without scalars exposes an empty scalar map (backward compatible: files + // predating scalar support simply omit the field and default to empty on read). + let packed = Writer::new(vec![f32_tensor("w", &[1.0], &[1], None)]) + .into_bytes() + .unwrap(); + let reader = Reader::from_bytes(packed).unwrap(); + assert!(reader.scalars().is_empty()); +} diff --git a/crates/burn-remote/Cargo.toml b/crates/burn-remote/Cargo.toml new file mode 100644 index 0000000..0dac4a8 --- /dev/null +++ b/crates/burn-remote/Cargo.toml @@ -0,0 +1,81 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Backend router decorator over the network." +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-remote" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-router-remote" +documentation = "https://docs.rs/burn-router-remote" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["client", "server", "iroh", "websocket"] +doc = [] +tracing = [ + "burn-communication?/tracing", + "burn-ir/tracing", + "burn-router/tracing", + "burn-std/tracing", + "burn-backend/tracing", +] + +client = ["tokio/sync"] +# Cache recurring op-groups on the server and invoke them by id. Wraps `RemoteBackend` in `Fusion`. +fusion = ["client", "dep:burn-fusion", "burn-router/fusion"] +server = ["tokio/sync"] +iroh = ["dep:iroh", "tokio/io-util"] +websocket = [ + "server", + "dep:burn-communication", + "burn-communication/websocket", + "burn-communication/data-service", + "dep:tokio-util", +] + + +[dependencies] +burn-ir = { workspace = true, features = ["default"] } +burn-backend = { workspace = true, features = [ + "default", + "serde", # For DTypeUsageSet to be de/serialized +] } +burn-std = { workspace = true, features = ["default"] } +burn-router = { workspace = true, features = ["default"] } +burn-fusion = { workspace = true, optional = true } +# Native WebSocket transport; optional so the Iroh client stays wasm-compatible. +burn-communication = { workspace = true, optional = true } + +bytes = { workspace = true } +log = { workspace = true } +rand = { workspace = true } + +tokio = { workspace = true, features = ["sync", "io-util"] } +serde = { workspace = true, features = ["derive"] } +serde_bytes = { workspace = true } +rmp-serde = { workspace = true } +iroh = { workspace = true, optional = true } + +tokio-util = { workspace = true, optional = true } + +# Native server runtime: multi-thread, timers, signals. The wasm build uses the JS event loop/timers. +[target.'cfg(not(target_family = "wasm"))'.dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "time", "signal"] } + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] +wasm-bindgen-futures = { workspace = true } +gloo-timers = { version = "0.3", features = ["futures"] } +futures-util = { workspace = true } + +[dev-dependencies] +burn-flex = { workspace = true, features = ["default"] } +burn-tensor = { workspace = true, features = ["default", "remote-websocket"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-remote/README.md b/crates/burn-remote/README.md new file mode 100644 index 0000000..6a6d4be --- /dev/null +++ b/crates/burn-remote/README.md @@ -0,0 +1,84 @@ +# Burn Remote + +Burn Remote executes tensor operations on compute peers reached through +[Iroh](https://iroh.computer/). Iroh is the primary transport: peers are identified by +cryptographic endpoint IDs, direct connections are preferred, and configured relays are used +when NAT traversal cannot establish a direct path. + +## Client + +Applications own the Iroh endpoint configuration. This keeps identity persistence, relay policy, +address lookup, and fleet discovery outside Burn: + +```rust,ignore +use burn::{Device, Tensor}; +use burn::backend::remote::{Endpoint, RemoteNode, endpoint::presets}; + +let endpoint = Endpoint::builder(presets::N0) + // .secret_key(persistent_secret_key) + // .relay_mode(custom_relay_mode) + // .address_lookup(platform_lookup) + .bind() + .await?; +let node = RemoteNode::from_endpoint(endpoint); + +// Supplied by your platform, invitation, or other discovery mechanism. +let compute_peer = fleet.lookup("gpu-worker-7").await?; +let device = Device::remote_iroh(&node, compute_peer, 0); + +let output = Tensor::<1>::from_floats([1.0, 2.0], &device) * 2.0; +``` + +`RemoteNode` is process-level. Clone it rather than creating one per device: clones share one +Iroh endpoint, peer connection pool, and multiplexed QUIC connections. + +Platforms can issue a `RemoteTicket` containing an `EndpointAddr` and opaque authorization bytes. +Burn passes the credential to the compute peer's `PeerAuthorizer`; signature format, expiry, +tenant policy, and fleet membership remain application concerns. + +## Compute peer + +```rust,ignore +use burn::{Device, server::{self, Channel}}; +use burn::backend::remote::RemoteNode; + +let node = RemoteNode::bind().await?; +println!("compute peer: {}", node.endpoint().addr()); + +server::start_async( + Device::cuda(0), + Channel::Iroh { node }, +).await; +``` + +For an endpoint shared with other Iroh protocols, register Burn's composable handler in the +application router: + +```rust,ignore +use burn_remote::BURN_REMOTE_ALPN; +use iroh::protocol::Router; + +let burn = node + .protocol::(devices) + .with_authorizer(|request| platform.verify(request.peer, request.credential)); + +let router = Router::builder(node.endpoint().clone()) + .accept(BURN_REMOTE_ALPN, burn) + .accept(MY_OTHER_ALPN, other_protocol) + .spawn(); +``` + +## Tensor movement + +Moving a tensor between different Iroh compute peers does not route the payload through the +client. The destination peer opens an authenticated stream directly to the source peer. Each +transfer uses a random, short-lived capability bound to the destination's authenticated endpoint +identity and limited to the number of downloads requested by the operation. + +Multiple devices hosted by the same compute peer retain the in-process fast path. +Tensor movement between an Iroh peer and a legacy WebSocket peer is not supported. + +## WebSocket compatibility + +The `websocket` feature preserves `Device::remote("ws://host:port", index)` and +`Channel::WebSocket`. It is intended for compatibility; new integrations should use Iroh. diff --git a/crates/burn-remote/src/client/base.rs b/crates/burn-remote/src/client/base.rs new file mode 100644 index 0000000..cf59770 --- /dev/null +++ b/crates/burn-remote/src/client/base.rs @@ -0,0 +1,68 @@ +use super::{RemoteDevice, service::RemoteService}; +use burn_backend::{DeviceHandle, backend::Device}; + +/// A thin handle to a `RemoteService` running on its own device-runner thread. +/// +/// Every `RouterClient` method delegates to `handle.submit` / `submit_blocking`, so all +/// connection state, the tokio runtime, the response-demux task, and the op batch buffer +/// live on the service side. +pub struct RemoteClient { + pub(crate) device: RemoteDevice, + pub(crate) handle: DeviceHandle, +} + +impl RemoteClient { + pub fn init(device: RemoteDevice) -> Self { + // `DeviceHandle::new` initializes the service the first time it's called for a given + // device id. `RemoteService::init` is deliberately cheap — it records the endpoint but + // does NOT connect, because cubecl holds a process-global lock across it; the actual + // connect + handshake happens lazily on first use (or via `ensure_connected`). + // Subsequent calls return a handle to the existing service. + let handle = DeviceHandle::::new(device.to_id()); + Self { device, handle } + } + + /// Force the lazily-established connection to be opened now, populating the device's + /// settings/device-count cells. Used by the settings path (`RemoteDevice::defaults` / + /// `enumerate`), which needs the handshake reply before any op has flushed. Runs the + /// connect on the service's runner thread, so it can't sit under cubecl's global lock. + pub(crate) fn ensure_connected(&self) { + self.handle + .submit_blocking(|s| s.ensure_connected()) + .expect("Service call failed"); + } + + /// Establish the session asynchronously, the way the browser requires. + /// + /// The connect + handshake cannot block the single browser thread, so it runs off the device + /// handle: the service hands back the connection parameters, the network round-trip happens + /// with `.await`, and the opened session is installed back into the service. A no-op once the + /// session is up. + #[cfg(target_family = "wasm")] + pub(crate) async fn connect_async(&self) { + use crate::client::service::wasm_connect; + + let Some(plan) = self + .handle + .submit_blocking(|s| s.wasm_connect_plan()) + .expect("Service call failed") + else { + return; + }; + + let connected = wasm_connect(plan).await; + + self.handle + .submit_blocking(move |s| s.wasm_install(connected)) + .expect("Service call failed"); + } +} + +impl Clone for RemoteClient { + fn clone(&self) -> Self { + Self { + device: self.device.clone(), + handle: self.handle.clone(), + } + } +} diff --git a/crates/burn-remote/src/client/channel.rs b/crates/burn-remote/src/client/channel.rs new file mode 100644 index 0000000..d45ac8f --- /dev/null +++ b/crates/burn-remote/src/client/channel.rs @@ -0,0 +1,70 @@ +use burn_backend::Shape; +use burn_ir::TensorIr; +use burn_router::{RouterChannel, RouterTensor, get_client}; + +use super::{ + RemoteClient, + runner::{RemoteBridge, RemoteDevice, RemoteTensorHandle}, +}; + +/// A local channel with direct connection to the backend runner clients. +pub struct RemoteChannel; + +impl RouterChannel for RemoteChannel { + type Device = RemoteDevice; + type Bridge = RemoteBridge; + type Client = RemoteClient; + + fn name(device: &Self::Device) -> String { + format!("remote-{device:?}") + } + + fn init_client(device: &Self::Device) -> Self::Client { + RemoteClient::init(device.clone()) + } + + fn get_tensor_handle(tensor: &TensorIr, client: &Self::Client) -> RemoteTensorHandle { + RemoteTensorHandle { + client: client.clone(), + tensor: tensor.clone(), + } + } + + fn register_tensor( + _client: &Self::Client, + _handle: RemoteTensorHandle, + _shape: Shape, + _dtype: burn_backend::DType, + ) -> RouterTensor { + // This function is normally only used to move a tensor from a device to another. + // + // In other words, to change the client. + panic!("Can't register manually a tensor on a remote channel."); + } + + fn change_client_backend( + tensor: RouterTensor, + target_device: &Self::Device, // target device + ) -> RouterTensor { + // Get tensor handle from current client + let original_client = tensor.client.clone(); + let desc = tensor.into_ir(); + let handle = Self::get_tensor_handle(&desc, &original_client); + + let handle = handle.change_backend(target_device); + + let id = handle.tensor.id; + + let target_client = get_client::(target_device); + let router_tensor: RouterTensor = + RouterTensor::new(id, handle.tensor.shape, handle.tensor.dtype, target_client); + + router_tensor + } +} + +impl Clone for RemoteChannel { + fn clone(&self) -> Self { + RemoteChannel + } +} diff --git a/crates/burn-remote/src/client/custom_op.rs b/crates/burn-remote/src/client/custom_op.rs new file mode 100644 index 0000000..e3d07bc --- /dev/null +++ b/crates/burn-remote/src/client/custom_op.rs @@ -0,0 +1,94 @@ +//! Client-side helper for registering [custom operations](CustomOpIr) on the remote backend. +//! +//! A backend extension ships its op to the server as `OperationIr::Custom`, where a registered +//! [`CustomOpRegistry`](burn_router::CustomOpRegistry) handler executes it. How the op is registered +//! on the client differs by whether the `fusion` feature is enabled — the remote backend is a plain +//! [`BackendRouter`](burn_router::BackendRouter) without it and a +//! [`Fusion`](burn_fusion::Fusion)-wrapped one with it — and so does the tensor primitive it returns +//! (`RouterTensor` vs `FusionTensor`). [`CustomOpClient`] hides that difference: the same code builds +//! and registers a custom op, and gets back `FloatTensor` either way. + +use burn_backend::tensor::FloatTensor; +use burn_ir::{CustomOpIr, OperationIr, TensorId}; + +use crate::client::RemoteChannel; +use crate::{RemoteBackend, RemoteDevice}; + +/// The router channel used by the remote backend. Transport-erased, so it carries custom ops over +/// whichever transport the device was opened on. +type Channel = RemoteChannel; + +/// A client for registering [custom operations](CustomOpIr) on the remote backend, transparently +/// across the `fusion` feature. +/// +/// Drop-in for the lower-level client a backend extension would otherwise reach for: allocate output +/// ids with [`create_empty_handle`](Self::create_empty_handle), build a [`CustomOpIr`], then +/// [`register`](Self::register) it. With `fusion` enabled the op joins the cached op-graph (via the +/// fusion client); without it the op streams through the router client. Either way the op reaches the +/// server, and the returned tensors are the matching `FloatTensor`. +pub struct CustomOpClient { + #[cfg(not(feature = "fusion"))] + inner: ::Client, + #[cfg(feature = "fusion")] + inner: burn_fusion::client::GlobalFusionClient>, + /// Kept so the fusion path can build the op's unfused handler, which needs the device to reach + /// the router client when the op isn't fused into a graph. + #[cfg(feature = "fusion")] + device: RemoteDevice, +} + +impl CustomOpClient { + /// Create a client bound to the given remote device. + pub fn new(device: &RemoteDevice) -> Self { + #[cfg(not(feature = "fusion"))] + let inner = burn_router::get_client::(device); + #[cfg(feature = "fusion")] + let inner = burn_fusion::get_client::>(device); + + Self { + inner, + #[cfg(feature = "fusion")] + device: device.clone(), + } + } + + /// Allocate a fresh, uninitialized tensor id for a custom op output. + /// + /// Use it to build the output [`TensorIr`](burn_ir::TensorIr)s of the [`CustomOpIr`] passed to + /// [`register`](Self::register). The id is allocated by the same client that registers the op, so + /// it is consistent with how that client tracks tensors (fusion ids under `fusion`, router ids + /// otherwise). + pub fn create_empty_handle(&self) -> TensorId { + #[cfg(not(feature = "fusion"))] + { + use burn_router::RouterClient; + self.inner.create_empty_handle() + } + #[cfg(feature = "fusion")] + { + self.inner.create_empty_handle() + } + } + + /// Register a custom op and return its (uninitialized) output tensors. + pub fn register(&self, op: CustomOpIr) -> Vec> { + #[cfg(not(feature = "fusion"))] + { + use burn_router::RouterClient; + self.inner.register(OperationIr::Custom(op)) + } + #[cfg(feature = "fusion")] + { + use burn_fusion::stream::StreamId; + use burn_router::CustomOperation; + // The op runs on the server through the `CustomOpRegistry`. When it's fused into a + // cached op-graph it travels as part of the graph; when it isn't (a one-op segment), + // `CustomOperation` is the unfused fallback that ships it through the router client. + self.inner.register( + StreamId::current(), + OperationIr::Custom(op.clone()), + CustomOperation::::new(op, self.device.clone()), + ) + } + } +} diff --git a/crates/burn-remote/src/client/mod.rs b/crates/burn-remote/src/client/mod.rs new file mode 100644 index 0000000..fd13df2 --- /dev/null +++ b/crates/burn-remote/src/client/mod.rs @@ -0,0 +1,11 @@ +mod base; +mod channel; +mod custom_op; +mod runner; +pub(crate) mod runtime; +pub(crate) mod service; + +pub use base::*; +pub use channel::*; +pub use custom_op::CustomOpClient; +pub use runner::RemoteDevice; diff --git a/crates/burn-remote/src/client/runner.rs b/crates/burn-remote/src/client/runner.rs new file mode 100644 index 0000000..413d720 --- /dev/null +++ b/crates/burn-remote/src/client/runner.rs @@ -0,0 +1,516 @@ +use super::{RemoteChannel, RemoteClient, service}; +use crate::shared::{LocalTransferId, TaskResponseContent, TensorRemote, TransferCapability}; +use crate::{PeerAddr, PeerId}; +use burn_backend::{DeviceId, DeviceOps, ExecutionError, StreamId, TensorData}; +use burn_ir::TensorIr; +use burn_router::{MultiBackendBridge, RouterClient, RouterTensor, get_client}; +use burn_std::DeviceSettings; +use burn_std::{backtrace::BackTrace, future::DynFut}; +#[cfg(feature = "websocket")] +use std::sync::Arc; +use std::sync::Mutex; + +use super::runtime::Executor; +use service::RemoteEndpoint; + +// It is very important to block on any request made via the service, since ordering is +// crucial when registering operations or creating tensors. The `DeviceHandle` queue +// preserves submission order, so `submit` is sufficient for cheap fire-and-forget ops; we +// only `submit_blocking` for paths that need to read the service's response. +impl RouterClient for RemoteClient { + type Device = RemoteDevice; + + fn register_op(&self, op: burn_ir::OperationIr) { + let stream_id = StreamId::current(); + // Device ids in the op's payload are *client* remote device ids; rewrite them to + // server-local device indices the server can resolve to its own backend devices. Applies + // to every op — only ops that actually carry device ids are rewritten. + let op = self.resolve_devices(op); + self.handle.submit(move |s| s.register_op(stream_id, op)); + } + + fn read_tensor_async( + &self, + tensor: burn_ir::TensorIr, + ) -> DynFut> { + // Issue the request synchronously so ordering is preserved relative to subsequent + // submissions; the returned future just awaits the server's response. + let stream_id = StreamId::current(); + let rx = self + .handle + .submit_blocking(move |s| s.read_tensor(stream_id, tensor)) + .expect("Service call failed"); + + Box::pin(async move { + match rx.await { + Ok(TaskResponseContent::ReadTensor(res)) => res, + Ok(_) => panic!("Invalid response type for ReadTensor"), + Err(e) => Err(ExecutionError::Generic { + reason: format!("Failed to read tensor: {e:?}"), + backtrace: BackTrace::capture(), + }), + } + }) + } + + fn register_tensor_data(&self, data: TensorData) -> RouterTensor { + let shape = data.shape.clone(); + let dtype = data.dtype; + let id = service::new_tensor_id(); + + // Fire-and-forget: the outgoing batch flushes itself once buffered data bytes (or the task + // count) cross their threshold — see `OutgoingBatch` — so no explicit flush is needed here. + let stream_id = StreamId::current(); + self.handle + .submit(move |s| s.register_tensor(stream_id, id, data)); + + RouterTensor::new(id, shape, dtype, self.clone()) + } + + fn device(&self) -> Self::Device { + self.device.clone() + } + + fn sync(&self) -> Result<(), ExecutionError> { + let stream_id = StreamId::current(); + self.handle + .submit_blocking(|s| s.sync(stream_id)) + .expect("Service call failed") + } + + fn seed(&self, seed: u64) { + self.handle.submit(move |s| s.seed(seed)); + } + + fn create_empty_handle(&self) -> burn_ir::TensorId { + service::new_tensor_id() + } + + fn register_alias(&self, new_id: burn_ir::TensorId, src_id: burn_ir::TensorId) { + let stream_id = StreamId::current(); + self.handle + .submit(move |s| s.register_alias(stream_id, new_id, src_id)); + } + + fn dtype_usage(&self, dtype: burn_std::DType) -> burn_backend::DTypeUsageSet { + self.handle + .submit_blocking(move |s| s.dtype_usage(dtype)) + .expect("Service call failed") + } + + fn flush(&self) { + self.handle.submit_blocking(|s| s.flush()).unwrap(); + } + + fn register_and_execute_graph( + &self, + graph_id: burn_ir::GraphId, + relative_graph: Vec, + bindings: burn_ir::GraphBindings, + ) { + let stream_id = StreamId::current(); + self.handle.submit(move |s| { + s.register_and_execute_graph(stream_id, graph_id, relative_graph, bindings) + }); + } + + fn execute_graph(&self, graph_id: burn_ir::GraphId, bindings: burn_ir::GraphBindings) { + let stream_id = StreamId::current(); + self.handle + .submit(move |s| s.execute_graph(stream_id, graph_id, bindings)); + } +} + +impl RemoteClient { + /// Rewrite the device ids carried by an op so the server can resolve them. + /// + /// This runs for every op, but only ops that carry device ids (currently the collective ops) + /// are affected On the client, the participating devices are identified by their *remote* + /// device ids (`type_id = 0`, `index_id = ` the local-registry index that encodes + /// `address`+device index). The server can't reverse that registry hash, so we translate each + /// id to the plain server-local device index (kept in `index_id`, `type_id` left 0). The + /// server then maps each index to its own backend device id before executing — see + /// `RemoteServer::resolve_devices`. + /// + /// Only same-server collectives are supported for now: every participating device must live + /// on the same address as the tensor's device. A cross-server group panics with a clear + /// message rather than silently reducing the wrong devices. + fn resolve_devices(&self, mut op: burn_ir::OperationIr) -> burn_ir::OperationIr { + use burn_ir::{DistributedOperationIr, OperationIr}; + + if let OperationIr::Distributed(DistributedOperationIr::AllReduce(desc)) = &mut op { + let local_peer = self.device.peer_id(); + for id in desc.device_ids.iter_mut() { + let (endpoint, device_index) = service::endpoint_for(id.index_id as u32).expect( + "an all_reduce device must be a registered remote device on this process", + ); + assert_eq!( + endpoint.peer_id(), + local_peer, + "cross-peer all_reduce is not supported yet: the tensor is on `{local_peer}` \ + but the collective includes a device on `{}`", + endpoint.peer_id(), + ); + id.type_id = 0; + id.index_id = device_index as u16; + } + log::trace!("All-reduce on {:?} ({local_peer}): {desc:?}", self.device); + } + + op + } +} + +#[derive(Clone, Debug)] +/// A remote compute device identified by its endpoint and device index. +/// +/// Two RemoteDevices with the same endpoint but different indices point at distinct devices on +/// the same peer; each gets its own registry id and service connection, and transfers between +/// them take the same-peer fast path. +pub struct RemoteDevice { + pub(crate) endpoint: RemoteEndpoint, + /// Device index on the remote peer. + pub(crate) device_index: u32, + /// Local registry id for this device. + pub(crate) id: u32, +} + +impl RemoteDevice { + /// Create a legacy WebSocket device from a URL. + #[cfg(feature = "websocket")] + pub fn websocket(address: &str, device_index: usize) -> Self { + let endpoint = RemoteEndpoint::WebSocket { + address: burn_communication::Address::from(address), + authorization: Arc::from([]), + }; + let device_index = device_index as u32; + let id = service::register_endpoint(endpoint.clone(), Executor::capture(), device_index); + Self { + endpoint, + device_index, + id, + } + } + + /// Create an Iroh remote device dialing `peer` from `endpoint`. + /// + /// The application owns the Iroh endpoint; Burn dials the compute peer from it. + #[cfg(feature = "iroh")] + pub fn iroh(endpoint: &iroh::Endpoint, peer: iroh::EndpointAddr, device_index: usize) -> Self { + Self::iroh_authorized(endpoint, peer, device_index, Vec::new()) + } + + /// Like [`iroh`](Self::iroh), but carries an authorization credential the server's PeerAuthorizer will check. + #[cfg(feature = "iroh")] + pub fn iroh_authorized( + endpoint: &iroh::Endpoint, + peer: iroh::EndpointAddr, + device_index: usize, + authorization: Vec, + ) -> Self { + let node = crate::transport::iroh::node::RemoteNode::from_endpoint(endpoint.clone()); + let endpoint = RemoteEndpoint::Iroh { + node, + peer, + authorization: authorization.into(), + }; + let device_index = device_index as u32; + let id = service::register_endpoint(endpoint.clone(), Executor::capture(), device_index); + Self { + endpoint, + device_index, + id, + } + } + + /// Forces the client connection to be established immediately using the default protocol. + /// This is a no-op if the connection is already up for this device. + pub fn connect(&self) { + // `get_client` initializes the (lazy) service if needed; `ensure_connected` then opens + // the sockets and runs the handshake on the runner thread, so the settings/device-count + // cells are populated by the time we return. + get_client::(self).ensure_connected(); + } + + /// Establish the session asynchronously. Browser entry point: wasm cannot block to connect, + /// so call and await this once before using the device. No-op if already connected. + #[cfg(target_family = "wasm")] + pub async fn connect_async(&self) { + get_client::(self).connect_async().await; + } + + /// Initialize the client for this device using a custom protocol channel. + /// + /// Only creates the lazy service; the socket and handshake open on first use. Call + /// [`connect`](Self::connect) when the connection and device settings are needed immediately. + pub fn connect_with_channel>(&self) { + // `get_client` forces service initialization if the client doesn't exist yet; + // `RemoteService::init` records the endpoint but defers the connect to first use. + get_client::(self); + } + + /// The stable identity of the compute peer. + pub fn peer_id(&self) -> PeerId { + self.endpoint.peer_id() + } + + /// The peer identity plus its current dialing hints. + pub fn peer_addr(&self) -> PeerAddr { + self.endpoint.peer_addr() + } + + /// The peer address as a string. Prefer `peer_addr` for typed access. + pub fn address(&self) -> String { + self.peer_addr().to_string() + } + + /// The index of this device on its server. + pub fn device_index(&self) -> usize { + self.device_index as usize + } + + /// List every device hosted by the WebSocket server at `address`. + /// + /// Connects to index 0 to read the device count from the init handshake, then returns one + /// RemoteDevice per index. Remaining indices connect lazily on first use, matching the + /// behavior of [`Device::enumerate`](burn_backend::tensor::Device) for local backends. + #[cfg(feature = "websocket")] + pub fn enumerate_websocket(address: &str) -> Vec { + // Device 0 always exists (a server must host at least one device); connecting to it + // populates the device-count cell for its registry id. + let device = Self::websocket(address, 0); + device.connect(); + + let count = service::device_count_for(device.id) + .expect("Device count populated by the init handshake during connect"); + + (0..count as usize) + .map(|index| Self::websocket(address, index)) + .collect() + } + + /// List every device hosted by an Iroh peer. + #[cfg(feature = "iroh")] + pub fn enumerate_iroh(endpoint: &iroh::Endpoint, peer: iroh::EndpointAddr) -> Vec { + let device = Self::iroh(endpoint, peer.clone(), 0); + device.connect(); + let count = service::device_count_for(device.id) + .expect("Device count populated by the init handshake during connect"); + (0..count as usize) + .map(|index| Self::iroh(endpoint, peer.clone(), index)) + .collect() + } +} + +impl PartialEq for RemoteDevice { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for RemoteDevice {} + +impl Default for RemoteDevice { + fn default() -> Self { + #[cfg(feature = "websocket")] + { + let address = match std::env::var("BURN_REMOTE_ADDRESS") { + Ok(address) => address, + Err(_) => String::from("ws://127.0.0.1:3000"), + }; + + Self::websocket(&address, 0) + } + #[cfg(not(feature = "websocket"))] + panic!( + "RemoteDevice::default requires the `websocket` compatibility feature; construct an Iroh device through RemoteNode::device" + ) + } +} + +impl burn_std::device::Device for RemoteDevice { + fn from_id(device_id: DeviceId) -> Self { + if device_id.type_id != 0 { + panic!("Invalid device id: {device_id} (expected type 0)"); + } + let (endpoint, device_index) = service::endpoint_for(device_id.index_id as u32) + .unwrap_or_else(|| panic!("Invalid device id: {device_id}")); + Self { + endpoint, + device_index, + id: device_id.index_id as u32, + } + } + + fn to_id(&self) -> DeviceId { + DeviceId { + type_id: 0, + index_id: self.id as u16, + } + } +} + +impl DeviceOps for RemoteDevice { + fn defaults(&self) -> DeviceSettings { + // Lazy-connect on first access. Callers like `Device::configure` or + // `Device::default()`-driven dispatch can hit `defaults` before the user has + // triggered any op, so we need to establish the session here. `connect` is + // idempotent — a no-op once the client has been initialized for this device. + if !service::has_settings(self.id) { + self.connect(); + } + service::settings_for(self.id) + } +} + +pub struct RemoteBridge; + +pub struct RemoteTensorHandle { + pub(crate) client: RemoteClient, + pub(crate) tensor: TensorIr, +} + +static TRANSFER_COUNTER: Mutex> = Mutex::new(None); + +/// Allocate the next process-unique [`LocalTransferId`] for a same-peer transfer. +/// +/// The id keys the server's transfer rendezvous (`local_comm` / `external_comm`), so two +/// transfers that are ever in flight at the same time MUST get distinct ids; otherwise a `take` +/// can pick up the wrong (or an overwritten) exposed primitive and its peer hangs forever. The +/// counter is incremented **in place** in the static; `LocalTransferId` is `Copy`, so the +/// earlier `transfer_counter.unwrap()` copied the value out and incremented a throwaway local, +/// leaving every transfer after the first sharing id 1 (harmless sequentially, a deadlock under +/// concurrency). +fn get_next_transfer_id() -> LocalTransferId { + let mut transfer_counter = TRANSFER_COUNTER.lock().unwrap(); + match transfer_counter.as_mut() { + Some(id) => { + id.next(); + *id + } + None => { + let id = LocalTransferId::from(0); + *transfer_counter = Some(id); + id + } + } +} + +impl RemoteTensorHandle { + /// Move the tensor to `target_device`, picking the cheapest path. + /// + /// When the source and target live on the **same** server (same address, different device + /// index), the data never leaves the process — see [`change_backend_local`]. Otherwise we + /// fall back to the cross-server path that streams the data server-to-server without the + /// client ever seeing it. + pub(crate) fn change_backend(self, target_device: &RemoteDevice) -> Self { + if self.client.device.peer_id() == target_device.peer_id() { + self.change_backend_local(target_device) + } else { + assert_eq!( + self.client.device.peer_addr().is_iroh(), + target_device.peer_addr().is_iroh(), + "Moving a tensor between Iroh and legacy WebSocket compute peers is not supported" + ); + self.change_backend_remote(target_device) + } + } + + /// Same-host transfer: hand the device-resident primitive from the source session to the + /// target session on the same server, which moves it with the inner backend's `to_device`. + /// No host round-trip. + fn change_backend_local(mut self, target_device: &RemoteDevice) -> Self { + // The stream of the calling user thread: the source reads the tensor back on the + // stream that produced it, and the target registers the result on the stream that + // will consume it — same contract as the regular op/tensor registration paths. + let stream_id = StreamId::current(); + let transfer_id = get_next_transfer_id(); + let tensor = self.tensor.clone(); + self.client.handle.submit(move |s| { + s.expose_tensor_local(stream_id, tensor, transfer_id); + }); + // Force the expose (and the ops producing the tensor) onto the wire now — same reason + // as the cross-server path below. + self.client.handle.flush_queue(); + + let target_client = get_client::(target_device); + let new_id = service::new_tensor_id(); + target_client.handle.submit(move |s| { + s.register_tensor_local(stream_id, transfer_id, new_id); + }); + target_client.handle.flush_queue(); + + self.tensor.id = new_id; + self.client = target_client; + + self + } + + /// Changes the backend of the tensor via a dWebSocket. + /// We ask the original server to expose the tensor, then ask the target server to fetch + /// the tensor. The target server will open a new network connection to the original server + /// to download the data. + /// This way the client never sees the tensor's data, and we avoid a bottleneck. + fn change_backend_remote(mut self, target_device: &RemoteDevice) -> Self { + // See `change_backend_local`: carry the calling thread's stream so the source readback + // and the target registration land on the client streams, not arbitrary server threads. + let stream_id = StreamId::current(); + let capability = TransferCapability::random(); + let tensor = self.tensor.clone(); + let target = target_device.peer_id(); + self.client.handle.submit(move |s| { + s.expose_tensor_remote(stream_id, tensor, 1, capability, target); + }); + // `submit` only enqueues the closure on the device-runner queue; the runner + // wouldn't drain it until 32 ops accumulated. `flush_queue` forces the runner + // thread to run the closure now, and `expose_tensor_remote` itself flushes the + // service batch onto the wire — so the source server receives the expose before + // the target server starts trying to download. + self.client.handle.flush_queue(); + + let target_client = get_client::(target_device); + + let peer = self.client.device.peer_addr(); + let new_id = service::new_tensor_id(); + target_client.handle.submit(move |s| { + s.register_tensor_remote(stream_id, TensorRemote { capability, peer }, new_id); + }); + // Same as the source side: drain the closure queue so it runs now and + // `register_tensor_remote` flushes the registration onto the target's wire. + target_client.handle.flush_queue(); + + self.tensor.id = new_id; + self.client = target_client; + + self + } +} + +impl MultiBackendBridge for RemoteBridge { + type TensorHandle = RemoteTensorHandle; + type Device = RemoteDevice; + + fn change_backend_float( + tensor: Self::TensorHandle, + _shape: burn_backend::Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle { + tensor.change_backend(target_device) + } + + fn change_backend_int( + tensor: Self::TensorHandle, + _shape: burn_backend::Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle { + tensor.change_backend(target_device) + } + + fn change_backend_bool( + tensor: Self::TensorHandle, + _shape: burn_backend::Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle { + tensor.change_backend(target_device) + } +} diff --git a/crates/burn-remote/src/client/runtime.rs b/crates/burn-remote/src/client/runtime.rs new file mode 100644 index 0000000..d1ea429 --- /dev/null +++ b/crates/burn-remote/src/client/runtime.rs @@ -0,0 +1,104 @@ +//! The client session runtime. + +/// Process-global Tokio runtime for sessions opened outside an ambient runtime. +/// +/// Fallback for synchronous callers (scripts, REPLs, notebooks) and the legacy WebSocket path. A +/// native Iroh node also binds on this runtime so its endpoint and session tasks share one executor. +/// When a device is built inside an existing runtime, that one is used instead and this fallback is +/// never created. +#[cfg(not(target_family = "wasm"))] +pub(crate) fn blocking_runtime() -> &'static tokio::runtime::Runtime { + use std::sync::OnceLock; + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Can build the Burn Remote blocking runtime") + }) +} + +/// Executor for a remote session's writer and response-demux tasks. +/// +/// Captured once at device construction and carried by the device registry, so the session reuses +/// whatever runtime owns its transport. On native this wraps a Tokio runtime handle: the ambient +/// runtime if one is active, otherwise the shared [`blocking_runtime`]. In the browser Iroh runs on +/// the JS event loop; tasks are spawned with `spawn_local` and blocking calls are unavailable. +#[derive(Clone, Debug)] +pub(crate) enum Executor { + #[cfg(not(target_family = "wasm"))] + Tokio(tokio::runtime::Handle), + #[cfg(target_family = "wasm")] + WasmLocal, +} + +/// Handle to a spawned session task. Joinable on native; a no-op in the browser where tasks +/// run on the event loop and cannot be awaited. +pub(crate) struct SpawnHandle { + #[cfg(not(target_family = "wasm"))] + inner: tokio::task::JoinHandle<()>, +} + +impl Executor { + /// Capture the executor for a new session at device-construction time. + /// + /// Native: the ambient Tokio runtime if one is active, otherwise the shared [`blocking_runtime`]. + /// Browser: the JS event loop. The result is stored in the device registry. + #[cfg(not(target_family = "wasm"))] + pub(crate) fn capture() -> Self { + match tokio::runtime::Handle::try_current() { + Ok(handle) => Self::Tokio(handle), + Err(_) => Self::Tokio(blocking_runtime().handle().clone()), + } + } + + #[cfg(target_family = "wasm")] + pub(crate) fn capture() -> Self { + Self::WasmLocal + } + + pub(crate) fn block_on(&self, future: F) -> F::Output { + match self { + #[cfg(not(target_family = "wasm"))] + Self::Tokio(handle) => handle.block_on(future), + #[cfg(target_family = "wasm")] + Self::WasmLocal => { + core::mem::drop(future); + panic!( + "Blocking remote calls are not supported on wasm. Establish the session with \ + `RemoteDevice::connect_async(...).await` and read tensors with \ + `into_data_async().await`." + ) + } + } + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn spawn(&self, future: F) -> SpawnHandle + where + F: core::future::Future + Send + 'static, + { + match self { + Self::Tokio(handle) => SpawnHandle { + inner: handle.spawn(future), + }, + } + } + + /// Spawn a session task on the browser event loop. The Iroh streams these tasks own are not + /// `Send`, which is why the wasm path uses `spawn_local` rather than the native `spawn`. + #[cfg(target_family = "wasm")] + pub(crate) fn spawn(&self, future: F) -> SpawnHandle + where + F: core::future::Future + 'static, + { + wasm_bindgen_futures::spawn_local(future); + SpawnHandle {} + } + + /// Wait for a spawned task to finish. No-op in the browser. + #[cfg(not(target_family = "wasm"))] + pub(crate) fn join(&self, handle: SpawnHandle) { + let _ = self.block_on(handle.inner); + } +} diff --git a/crates/burn-remote/src/client/service.rs b/crates/burn-remote/src/client/service.rs new file mode 100644 index 0000000..0238105 --- /dev/null +++ b/crates/burn-remote/src/client/service.rs @@ -0,0 +1,687 @@ +use crate::metrics::{MetricSide, TelemetryLogger, logger_task}; +use crate::shared::{ + LocalTransferId, PROTOCOL_VERSION, RemoteMessage, RequestId, SessionId, SessionInfo, + SessionInit, Task, TaskResponse, TaskResponseContent, TensorRemote, TransferCapability, +}; +use crate::telemetry::{CHANNEL_CAPACITY, TelemetryEvent, TelemetryProbe, serialized_len}; +use burn_backend::{ + DTypeUsageSet, ExecutionError, TensorData, + backend::{DeviceId, DeviceService, ServerUtilitiesHandle}, +}; +use burn_ir::{OperationIr, TensorId, TensorIr}; +use burn_std::{DType, DeviceSettings, id::StreamId}; +// Only the native `sync` path captures a backtrace; the wasm path returns without blocking. +#[cfg(not(target_family = "wasm"))] +use burn_std::backtrace::BackTrace; +use std::sync::{Arc, OnceLock}; +use tokio::sync::oneshot; + +mod batch; +mod conn; +mod pending; +mod registry; +mod writer; + +use batch::OutgoingBatch; +use conn::{ResponseChannel, open_channels}; +use pending::{PendingResponses, Responder}; +use writer::SubmitWriter; + +use super::runtime::Executor; +pub(crate) use conn::{RemoteEndpoint, SubmitChannel}; +use registry::{device_count_cell, executor_for, settings_cell}; +pub(crate) use registry::{device_count_for, has_settings, new_tensor_id, settings_for}; +pub(crate) use registry::{endpoint_for, register_endpoint}; + +/// All the state owned by the device-runner thread for a single remote device. +/// +/// `RemoteService` lives behind a [`DeviceHandle`](burn_backend::DeviceHandle); every call +/// from the `RouterClient` shim hops onto the runner thread via the device handle's +/// `submit` / `submit_blocking`, so the service has exclusive access to the connection, +/// the callback map, and the outgoing task buffer without any locking on its own state. +/// +/// The service mirrors that submit-style API internally: fire-and-forget calls go +/// through [`submit`](Self::submit) (push onto the [`batch`](Self::batch), flush once it +/// reaches the configured flush threshold), and response-producing calls go through +/// [`submit_blocking`](Self::submit_blocking) (push, then flush right away so the request +/// is enqueued before we await the oneshot). +/// +/// The moving parts are split into focused types: [`OutgoingBatch`] buffers tasks and +/// decides when to flush, [`SubmitWriter`] owns the socket and serializes frames off the +/// runner thread, and [`PendingResponses`] correlates response-producing requests with the +/// caller awaiting each reply. +/// +/// All tokio work (connecting, the writer task, awaiting responses, the response-demux +/// task) happens on the [`Executor`] captured from the device's endpoint. The caller never +/// sees a runtime handle. +pub struct RemoteService { + executor: Executor, + /// Where to connect on first use. The connection is established lazily (see + /// [`ensure_connected`](Self::ensure_connected)) rather than in [`init`](Self::init), + /// because cubecl holds a process-global device-registry lock across `init` — opening the + /// sockets there would serialize every remote device's setup behind that lock. + endpoint: RemoteEndpoint, + device_index: u32, + /// Owns the request socket and serializes outgoing frames off the runner thread. + /// `None` until the first task (or a settings read) triggers the lazy connect. + writer: Option, + /// Buffers outgoing tasks and signals when a batch is ready for the wire. + batch: OutgoingBatch, + /// Request-id allocation + the callbacks awaiting response-producing tasks. + pending: PendingResponses, + /// Emits this device's telemetry (the ops and graphs it sends). + probe: TelemetryProbe, + /// Shared cell populated from the init handshake (read by `RemoteDevice::defaults`). + settings: Arc>, + /// Shared cell populated from the init handshake (read by `RemoteDevice::enumerate`). + device_count: Arc>, + session_id: SessionId, + closed: bool, +} + +impl DeviceService for RemoteService { + fn init(device_id: DeviceId) -> Self { + let (id, endpoint, device_index) = Self::resolve_endpoint(device_id); + // The executor was captured at device-construction time (in the runtime that owns the + // transport) and stored in the registry alongside the endpoint; the service just reuses it. + let executor = executor_for(id).expect("device registered with a captured executor"); + let session_id = SessionId::new(); + + let probe = if TelemetryLogger::enabled() { + TelemetryProbe::new(CHANNEL_CAPACITY) + } else { + TelemetryProbe::disabled() + }; + if let Some(task) = logger_task(&probe, MetricSide::Client) { + executor.spawn(task); + } + + // Lazy connect: `init` must return promptly. cubecl holds a process-global + // device-registry lock across this call (to make device-handle creation atomic), so + // doing the blocking network connect + handshake here would serialize every remote + // device's setup behind that lock — N devices would connect strictly one at a time. + // Instead we record the endpoint and open the sockets on the first real use, off the + // lock and on the device-runner thread (see `ensure_connected`). + Self { + executor, + endpoint, + device_index, + writer: None, + batch: { + let cfg = burn_std::config::config(); + let remote = cfg.remote(); + OutgoingBatch::new(remote.flush_threshold, remote.flush_bytes_threshold) + }, + pending: PendingResponses::new(), + probe, + settings: settings_cell(id), + device_count: device_count_cell(id), + session_id, + closed: false, + } + } + + fn utilities(&self) -> ServerUtilitiesHandle { + // The remote backend surfaces device settings through the endpoint registry + // (`RemoteDevice::defaults` → `settings_for`), not through this handle, so the device + // layer never reads what we return here. Handing back an empty handle keeps `init` — + // and the cubecl lock it runs under — free of the blocking network handshake. + Arc::new(()) + } +} + +/// Construction helpers for [`RemoteService::init`], one per step of bringing a connection +/// up. Kept separate from the public submit-style API below. +impl RemoteService { + /// Resolve a device id to its registry index, parsed network [`Address`], and the device + /// index to select on the server. + fn resolve_endpoint(device_id: DeviceId) -> (u32, RemoteEndpoint, u32) { + let id = device_id.index_id as u32; + let (endpoint, device_index) = endpoint_for(id) + .unwrap_or_else(|| panic!("No endpoint registered for device id {device_id}")); + (id, endpoint, device_index) + } + + /// Native synchronous wrapper over [`open_channels`](conn::open_channels): blocks the runner + /// thread until the streams are open. + #[cfg(not(target_family = "wasm"))] + fn connect_streams( + executor: &Executor, + endpoint: &RemoteEndpoint, + ) -> (SubmitChannel, ResponseChannel) { + executor + .block_on(open_channels(endpoint)) + .unwrap_or_else(|err: String| panic!("{err}")) + } + + /// Send the session-init handshake on both streams and wait for the device settings the + /// server replies with on the response stream. Both streams carry the same `Vec` + /// wire format; the handshake is just a single-element batch. + async fn handshake_async( + request: &mut SubmitChannel, + response: &mut ResponseChannel, + endpoint: &RemoteEndpoint, + session_id: SessionId, + device_index: u32, + ) -> (DeviceSettings, u32) { + let init_bytes: bytes::Bytes = rmp_serde::to_vec(&vec![RemoteMessage::Init( + SessionInit::new(session_id, device_index, endpoint.authorization().to_vec()), + )]) + .expect("Can serialize RemoteMessage::Init") + .into(); + + let result: Result<(DeviceSettings, u32), String> = async { + request.send(init_bytes).await?; + + let msg = response + .recv() + .await? + .expect("Server disconnected during initialization"); + let reply: TaskResponse = + rmp_serde::from_slice(&msg).expect("Can deserialize init handshake payload"); + + match reply.content { + TaskResponseContent::Init(SessionInfo { + version, + settings, + device_count, + .. + }) => { + if version != PROTOCOL_VERSION { + panic!( + "Server uses Burn Remote protocol version {version}, expected {PROTOCOL_VERSION}" + ); + } + Ok((settings, device_count)) + } + other => panic!("Expected Init response, got {other:?}"), + } + } + .await; + + result.unwrap_or_else(|err| { + panic!( + "Failed to initialize remote session at {}: {err}", + endpoint.peer_addr() + ) + }) + } + + /// Native synchronous wrapper over [`handshake_async`](Self::handshake_async). + #[cfg(not(target_family = "wasm"))] + fn handshake( + executor: &Executor, + request: &mut SubmitChannel, + response: &mut ResponseChannel, + endpoint: &RemoteEndpoint, + session_id: SessionId, + device_index: u32, + ) -> (DeviceSettings, u32) { + executor.block_on(Self::handshake_async( + request, + response, + endpoint, + session_id, + device_index, + )) + } + + /// Spawn the response-demux task: route each [`TaskResponse`] to its pending callback by + /// [`RequestId`] via the [`Responder`]. Lives on the service runtime; exits when the + /// response stream closes. + fn spawn_response_demux( + executor: &Executor, + mut response: ResponseChannel, + responder: Responder, + ) { + // Detached: the task owns the response stream and runs until it closes. + let _demux = executor.spawn(async move { + loop { + match response.recv().await { + Ok(Some(msg)) => { + let reply: TaskResponse = match rmp_serde::from_slice(&msg) { + Ok(r) => r, + Err(err) => { + log::error!("Failed to deserialize remote response: {err:?}"); + continue; + } + }; + if !responder.complete(reply.id, reply.content) { + log::warn!("No pending callback for response id {:?}", reply.id); + } + } + Ok(None) => { + log::warn!("Remote response stream closed"); + break; + } + Err(err) => { + log::warn!("Remote response stream error: {err:?}"); + break; + } + } + } + + // The response stream is gone (clean close or error): the server will never answer + // any in-flight or future request on this connection. Fail every waiting caller and + // gate new ones so they error out instead of blocking forever on a dead server. + responder.disconnect(); + }); + } +} + +/// Session state captured from a [`RemoteService`] to drive an asynchronous (wasm) connect. +#[cfg(target_family = "wasm")] +pub(crate) struct WasmConnectPlan { + endpoint: RemoteEndpoint, + session_id: SessionId, + device_index: u32, + responder: Responder, +} + +/// A session opened by [`wasm_connect`], ready to be installed back into the service. +#[cfg(target_family = "wasm")] +pub(crate) struct WasmConnected { + writer: SubmitWriter, + settings: DeviceSettings, + device_count: u32, +} + +/// Open and hand-shake a session on the browser event loop. +/// +/// This is the async counterpart of [`RemoteService::ensure_connected`]: it runs the parts that +/// would block (connecting the Iroh streams, the init round-trip) with `.await`, then spawns the +/// response-demux and writer tasks with `spawn_local`. The returned [`WasmConnected`] is `Send`, +/// so the caller can install it back into the service through the device handle. +#[cfg(target_family = "wasm")] +pub(crate) async fn wasm_connect(plan: WasmConnectPlan) -> WasmConnected { + let executor = Executor::WasmLocal; + + let (mut request, mut response) = open_channels(&plan.endpoint) + .await + .unwrap_or_else(|err| panic!("{err}")); + let (settings, device_count) = RemoteService::handshake_async( + &mut request, + &mut response, + &plan.endpoint, + plan.session_id, + plan.device_index, + ) + .await; + + RemoteService::spawn_response_demux(&executor, response, plan.responder); + let writer = SubmitWriter::spawn(&executor, request); + + WasmConnected { + writer, + settings, + device_count, + } +} + +impl RemoteService { + /// Buffer a fire-and-forget op. The buffer is flushed automatically once it reaches the + /// configured flush threshold. + pub fn register_op(&mut self, stream_id: StreamId, op: OperationIr) { + // An op streamed individually (not part of a cached graph) is an unfused op. + self.probe + .emit(|| TelemetryEvent::unfused_op(self.session_id, stream_id, &op)); + self.submit_task(Task::RegisterOperation(stream_id, op)); + } + + /// Buffer a fire-and-forget "register a reusable op-graph and run its first invocation" task. + pub fn register_and_execute_graph( + &mut self, + stream_id: StreamId, + graph_id: burn_ir::GraphId, + relative_graph: Vec, + bindings: burn_ir::GraphBindings, + ) { + // The first invocation both registers (one-time graph cost) and executes (a replay). + self.probe.emit(|| { + TelemetryEvent::graph_registered( + self.session_id, + graph_id, + &relative_graph, + serialized_len(&relative_graph), + ) + }); + self.probe.emit(|| { + TelemetryEvent::graph_executed( + self.session_id, + graph_id, + stream_id, + serialized_len(&bindings), + ) + }); + self.submit_task(Task::RegisterAndExecuteGraph { + stream_id, + graph_id, + relative_graph, + bindings, + }); + } + + /// Buffer a fire-and-forget "execute a registered graph" task. + pub fn execute_graph( + &mut self, + stream_id: StreamId, + graph_id: burn_ir::GraphId, + bindings: burn_ir::GraphBindings, + ) { + self.probe.emit(|| { + TelemetryEvent::graph_executed( + self.session_id, + graph_id, + stream_id, + serialized_len(&bindings), + ) + }); + self.submit_task(Task::ExecuteGraph { + stream_id, + graph_id, + bindings, + }); + } + + pub fn register_tensor(&mut self, stream_id: StreamId, id: TensorId, data: TensorData) { + self.submit_task(Task::RegisterTensor(stream_id, id, data)); + self.flush(); + } + + /// Buffer a fire-and-forget "alias `src_id` under `new_id`" task. FIFO submission keeps it + /// after whatever materialized `src_id`, so the server has the source handle when it runs. + pub fn register_alias(&mut self, stream_id: StreamId, new_id: TensorId, src_id: TensorId) { + self.submit_task(Task::RegisterAlias { + stream_id, + new_id, + src_id, + }); + } + + /// Register a tensor produced by a cross-server transfer, flushing immediately. + /// + /// This is a coordination point with no following barrier on this client — the caller + /// (`change_backend`) switches to the target client right after submitting it — so we + /// flush right away instead of relying on a later op to piggy-back the flush. Otherwise + /// the task would sit buffered below the flush threshold and the target server would + /// never start the download. + pub fn register_tensor_remote( + &mut self, + stream_id: StreamId, + tensor: TensorRemote, + new_id: TensorId, + ) { + self.submit_task(Task::RegisterTensorRemote(stream_id, tensor, new_id)); + self.flush(); + } + + /// Expose a tensor for a cross-server transfer, flushing immediately. + /// + /// Flushed for the same reason as [`register_tensor_remote`](Self::register_tensor_remote): + /// the source server must actually receive the expose so the target server's download can + /// complete, and the caller has no later barrier on this client to carry the flush. + pub fn expose_tensor_remote( + &mut self, + stream_id: StreamId, + tensor: TensorIr, + count: u32, + capability: TransferCapability, + target: crate::PeerId, + ) { + self.submit_task(Task::ExposeTensorRemote { + stream_id, + tensor, + count, + capability, + target, + }); + self.flush(); + } + + /// Expose a tensor for a same-host transfer, flushing immediately. + /// + /// Source side of the local path; flushed for the same reason as + /// [`expose_tensor_remote`](Self::expose_tensor_remote). + pub fn expose_tensor_local( + &mut self, + stream_id: StreamId, + tensor: TensorIr, + transfer_id: LocalTransferId, + ) { + self.submit_task(Task::ExposeTensorLocal { + stream_id, + tensor, + transfer_id, + }); + self.flush(); + } + + /// Register a tensor produced by a same-host transfer, flushing immediately. + /// + /// Target side of the local path; flushed for the same reason as + /// [`register_tensor_remote`](Self::register_tensor_remote). + pub fn register_tensor_local( + &mut self, + stream_id: StreamId, + transfer_id: LocalTransferId, + new_id: TensorId, + ) { + self.submit_task(Task::RegisterTensorLocal { + stream_id, + transfer_id, + new_id, + }); + self.flush(); + } + + pub fn seed(&mut self, seed: u64) { + self.submit_task(Task::Seed(seed)); + } + + /// Initiate a tensor read. The returned receiver resolves when the server response + /// arrives. + /// + /// The request id rides on the task itself; the server echoes it back so the + /// response-demux task can hand the response to the right pending callback. + pub fn read_tensor( + &mut self, + stream_id: StreamId, + tensor: TensorIr, + ) -> oneshot::Receiver { + self.submit_request(|id| Task::ReadTensor(id, stream_id, tensor)) + } + + pub fn sync(&mut self, stream_id: StreamId) -> Result<(), ExecutionError> { + #[cfg(not(target_family = "wasm"))] + { + let rx = self.submit_request(|id| Task::SyncBackend(id, stream_id)); + match self.executor.block_on(rx) { + Ok(TaskResponseContent::SyncBackend(res)) => res, + Ok(other) => panic!("Invalid response for SyncBackend: {other:?}"), + Err(_) => Err(ExecutionError::Generic { + reason: "Remote response channel closed before sync completed".into(), + backtrace: BackTrace::capture(), + }), + } + } + #[cfg(target_family = "wasm")] + { + // The browser thread cannot block to await completion. Flushing forces every pending + // operation onto the wire; the writer's FIFO ordering means anything submitted after + // this `sync` still executes after it on the peer, and results are observed through + // async reads. There is no host-side barrier to wait on, so we return immediately. + let _ = stream_id; + self.flush(); + Ok(()) + } + } + + pub fn dtype_usage(&mut self, dtype: DType) -> DTypeUsageSet { + let rx = self.submit_request(|id| Task::DTypeUsage(id, dtype)); + match self.executor.block_on(rx) { + Ok(TaskResponseContent::DTypeUsage(set)) => set, + Ok(other) => panic!("Invalid response for DTypeUsage: {other:?}"), + Err(_) => panic!("Remote response channel closed before dtype_usage completed"), + } + } + + /// Buffer a fire-and-forget compute task. Thin wrapper over [`submit`](Self::submit) + /// that wraps the task in [`RemoteMessage::Task`]. + fn submit_task(&mut self, task: Task) { + self.submit(RemoteMessage::Task(task)); + } + + /// Issue a response-producing compute task: allocate its [`RequestId`], register the + /// pending callback, and flush immediately so it's enqueued before the caller awaits + /// the returned receiver. `make_task` builds the task from the freshly allocated id. + fn submit_request( + &mut self, + make_task: impl FnOnce(RequestId) -> Task, + ) -> oneshot::Receiver { + let request_id = self.pending.next_id(); + let rx = self.pending.register(request_id); + self.submit_blocking(RemoteMessage::Task(make_task(request_id))); + rx + } + + /// Append a task to the outgoing buffer; flush only once it hits the threshold. + /// + /// Use this for fire-and-forget tasks. The runner thread is single-threaded, so + /// pushing into the buffer is just a `Vec::push` — no locking, no tokio hop, no + /// network send. + fn submit(&mut self, task: RemoteMessage) { + if self.batch.push(task) { + self.flush(); + } + } + + /// Append a task and flush the buffer immediately. + /// + /// Use this when the caller is about to await a response: flushing enqueues the batch + /// to the writer task, and the FIFO writer guarantees it reaches the wire before any + /// later frame — so the response can't be missed. We no longer block on the actual + /// send; correctness comes from FIFO enqueue order plus the oneshot await. + fn submit_blocking(&mut self, task: RemoteMessage) { + self.batch.push(task); + self.flush(); + } + + /// Open the session socket and run the init handshake — exactly once. + /// + /// Called lazily from the device-runner thread: from [`flush`](Self::flush) on the first + /// task, or via [`RemoteClient::ensure_connected`](super::RemoteClient) on the settings + /// path. Never from [`init`](Self::init), so the blocking connect + handshake runs off + /// cubecl's global device-registry lock and devices connect in parallel. Always runs on + /// the single runner thread, so the idempotent check needs no locking. + pub fn ensure_connected(&mut self) { + if self.writer.is_some() { + return; + } + + #[cfg(not(target_family = "wasm"))] + { + log::debug!( + "Connecting to {} (device {}) ...", + self.endpoint.peer_addr(), + self.device_index + ); + let (mut request, mut response) = Self::connect_streams(&self.executor, &self.endpoint); + let (settings, device_count) = Self::handshake( + &self.executor, + &mut request, + &mut response, + &self.endpoint, + self.session_id, + self.device_index, + ); + + // Publish to the shared cells so `RemoteDevice::defaults`/`enumerate` can read them. + let _ = self.settings.set(settings); + let _ = self.device_count.set(device_count); + + Self::spawn_response_demux(&self.executor, response, self.pending.responder()); + self.writer = Some(SubmitWriter::spawn(&self.executor, request)); + } + + #[cfg(target_family = "wasm")] + panic!( + "Remote session to {} is not connected. On wasm, establish it with \ + `RemoteDevice::connect_async(...).await` before running tensor operations.", + self.endpoint.peer_addr() + ); + } + + /// Capture everything needed to open the session asynchronously, or `None` if it is already + /// connected. The browser cannot block the runner thread, so the connect + handshake runs + /// off the device handle (see [`wasm_connect`]) and the result is handed back through + /// [`wasm_install`](Self::wasm_install). Every captured value is `Send`, so it can cross the + /// device handle even though the Iroh streams it later owns are not. + #[cfg(target_family = "wasm")] + pub(crate) fn wasm_connect_plan(&mut self) -> Option { + if self.writer.is_some() { + return None; + } + Some(WasmConnectPlan { + endpoint: self.endpoint.clone(), + session_id: self.session_id, + device_index: self.device_index, + responder: self.pending.responder(), + }) + } + + /// Install a connection opened by [`wasm_connect`] and publish its handshake results. + #[cfg(target_family = "wasm")] + pub(crate) fn wasm_install(&mut self, connected: WasmConnected) { + if self.writer.is_some() { + // A concurrent connect already installed a session; drop this one rather than + // leaking two writers for the same device. + return; + } + let _ = self.settings.set(connected.settings); + let _ = self.device_count.set(connected.device_count); + self.writer = Some(connected.writer); + } + + /// Hand whatever's currently buffered to the writer task as one batch (the writer + /// serializes it off the runner thread). No-op when the buffer is empty; otherwise opens + /// the connection first if it isn't already up. + pub fn flush(&mut self) { + if self.batch.is_empty() { + return; + } + self.ensure_connected(); + log::trace!("Flush session: {}", self.session_id); + let batch = self.batch.take(); + let writer = self + .writer + .as_ref() + .expect("writer is set by ensure_connected"); + writer.send(&self.executor, batch); + } +} + +impl Drop for RemoteService { + fn drop(&mut self) { + if self.closed { + return; + } + self.closed = true; + + // If we never connected, there's no server-side session to close and no writer to + // drain — whatever was buffered never had a connection to go out on, so just drop it. + if self.writer.is_none() { + return; + } + + // Best-effort teardown: append Close to whatever's still buffered and let the + // writer drain + flush it before we join the task, so the runtime isn't torn down + // mid-send. Serialization happens in the writer task now, so Drop can't panic on it. + self.batch.push(RemoteMessage::Close(self.session_id)); + let batch = self.batch.take(); + let writer = self + .writer + .as_mut() + .expect("writer present (checked above)"); + writer.shutdown(&self.executor, Some(batch)); + } +} diff --git a/crates/burn-remote/src/client/service/batch.rs b/crates/burn-remote/src/client/service/batch.rs new file mode 100644 index 0000000..fd6aa89 --- /dev/null +++ b/crates/burn-remote/src/client/service/batch.rs @@ -0,0 +1,142 @@ +//! Outgoing task buffering. + +use crate::shared::{RemoteMessage, Task}; + +/// Accumulates outgoing [`RemoteMessage`]s on the runner thread and decides when a batch is ready +/// for the wire. +/// +/// Pure buffering logic — no runtime, no socket. [`push`](Self::push) reports when a flush +/// threshold is reached so the caller can flush; [`take`](Self::take) drains the buffer into a +/// frame, leaving it empty for the next batch. +/// +/// Two thresholds drive a flush, whichever hits first: +/// - **task count** — bounds latency for streams of many tiny ops; +/// - **buffered data bytes** — bounds how much tensor data sits unsent, so a big upload (or several +/// moderate ones) goes out promptly instead of waiting on the count threshold. Tracking a running +/// sum, rather than flushing on each individual large message, lets small tensors keep batching +/// while still capping the in-flight data. +/// +/// Batching is wire-level only: every task keeps its own `StreamId`/`RequestId`, so the +/// server sees the same per-task semantics whether tasks arrive batched or one per frame. +pub(crate) struct OutgoingBatch { + tasks: Vec, + threshold: usize, + /// Running sum of [`data_len`] over the buffered tasks; reset by [`take`](Self::take). + bytes: usize, + bytes_threshold: usize, +} + +/// Size of the bulk tensor data a message carries, in bytes (0 for metadata-only messages). Drives +/// the byte-based flush threshold. +fn data_len(msg: &RemoteMessage) -> usize { + match msg { + RemoteMessage::Task(Task::RegisterTensor(_, _, data)) => data.bytes.len(), + _ => 0, + } +} + +impl OutgoingBatch { + /// Create a buffer that signals a flush once `threshold` tasks *or* `bytes_threshold` buffered + /// data bytes accumulate. + pub(crate) fn new(threshold: usize, bytes_threshold: usize) -> Self { + Self { + tasks: Vec::with_capacity(threshold), + threshold, + bytes: 0, + bytes_threshold, + } + } + + /// Append a task. Returns `true` once the buffer has reached either flush threshold, i.e. + /// the caller should [`take`](Self::take) and send. + pub(crate) fn push(&mut self, task: RemoteMessage) -> bool { + self.bytes += data_len(&task); + self.tasks.push(task); + self.tasks.len() >= self.threshold || self.bytes >= self.bytes_threshold + } + + pub(crate) fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + /// Drain the accumulated tasks, leaving the buffer empty for the next batch. + pub(crate) fn take(&mut self) -> Vec { + self.bytes = 0; + std::mem::take(&mut self.tasks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::Task; + use burn_backend::{StreamId, TensorData}; + use burn_ir::TensorId; + + /// A metadata-only task (contributes 0 to the byte counter). + fn task() -> RemoteMessage { + RemoteMessage::Task(Task::Seed(0)) + } + + /// A `RegisterTensor` carrying `n` bytes of data. + fn data_task(n: usize) -> RemoteMessage { + RemoteMessage::Task(Task::RegisterTensor( + StreamId::current(), + TensorId::new(0), + TensorData::new(vec![0u8; n], [n]), + )) + } + + // A large byte threshold so these count-driven tests aren't affected by the byte trigger. + const NO_BYTES: usize = usize::MAX; + + #[test] + fn push_signals_flush_only_at_threshold() { + let mut batch = OutgoingBatch::new(3, NO_BYTES); + assert!(!batch.push(task())); // 1 + assert!(!batch.push(task())); // 2 + assert!(batch.push(task())); // 3 == threshold + } + + #[test] + fn threshold_of_one_flushes_every_push() { + let mut batch = OutgoingBatch::new(1, NO_BYTES); + assert!(batch.push(task())); + assert!(batch.push(task())); + } + + #[test] + fn flushes_once_buffered_bytes_reach_threshold() { + // Count threshold high enough that only the byte threshold can trigger. + let mut batch = OutgoingBatch::new(100, 1000); + assert!(!batch.push(data_task(400))); // 400 buffered + assert!(!batch.push(data_task(400))); // 800 buffered + assert!(batch.push(data_task(400))); // 1200 >= 1000 -> flush + } + + #[test] + fn take_resets_the_byte_counter() { + let mut batch = OutgoingBatch::new(100, 1000); + assert!(!batch.push(data_task(900))); + batch.take(); // drains tasks and resets the byte sum + // Counter restarted from zero, so 900 again does not trip the 1000-byte threshold. + assert!(!batch.push(data_task(900))); + } + + #[test] + fn take_drains_and_resets() { + let mut batch = OutgoingBatch::new(4, NO_BYTES); + assert!(batch.is_empty()); + + batch.push(task()); + batch.push(task()); + assert!(!batch.is_empty()); + + let drained = batch.take(); + assert_eq!(drained.len(), 2); + + // Taking resets the buffer, so a subsequent take yields nothing. + assert!(batch.is_empty()); + assert_eq!(batch.take().len(), 0); + } +} diff --git a/crates/burn-remote/src/client/service/conn.rs b/crates/burn-remote/src/client/service/conn.rs new file mode 100644 index 0000000..f93743d --- /dev/null +++ b/crates/burn-remote/src/client/service/conn.rs @@ -0,0 +1,190 @@ +//! The client's transport connection layer. +//! +//! All `cfg(feature = ...)` transport selection on the client lives here: how to reach a peer +//! ([`RemoteEndpoint`]), the two halves of an opened session +//! ([`SubmitChannel`] / [`ResponseChannel`]), and the connect logic ([`open_channels`]). The +//! service and the registry are written against these and stay transport-agnostic. + +use std::sync::Arc; + +use crate::transport::link::{FrameSink, FrameSource}; +use crate::{PeerAddr, PeerId}; + +#[cfg(feature = "iroh")] +use crate::transport::iroh::node::RemoteNode; +#[cfg(feature = "websocket")] +use burn_communication::{Address, ProtocolClient}; + +/// Everything needed to establish a session with a remote compute peer. +#[derive(Clone, Debug)] +pub(crate) enum RemoteEndpoint { + #[cfg(feature = "iroh")] + Iroh { + node: RemoteNode, + peer: iroh::EndpointAddr, + authorization: Arc<[u8]>, + }, + #[cfg(feature = "websocket")] + WebSocket { + address: Address, + authorization: Arc<[u8]>, + }, +} + +impl RemoteEndpoint { + pub(crate) fn peer_addr(&self) -> PeerAddr { + match self { + #[cfg(feature = "iroh")] + Self::Iroh { peer, .. } => PeerAddr::Iroh(peer.clone()), + #[cfg(feature = "websocket")] + Self::WebSocket { address, .. } => PeerAddr::WebSocket(address.clone()), + } + } + + pub(crate) fn peer_id(&self) -> PeerId { + self.peer_addr().id() + } + + pub(crate) fn authorization(&self) -> &[u8] { + match self { + #[cfg(feature = "iroh")] + Self::Iroh { authorization, .. } => authorization, + #[cfg(feature = "websocket")] + Self::WebSocket { authorization, .. } => authorization, + } + } + + /// The stable registry key for this endpoint (identity + authorization, no mutable dialing + /// hints), so the same compute peer reuses one device id across reconnects. + pub(crate) fn key(&self) -> EndpointKey { + match self { + #[cfg(feature = "iroh")] + Self::Iroh { + node, + peer, + authorization, + .. + } => EndpointKey::Iroh { + local: node.id(), + remote: peer.id, + authorization: authorization.clone(), + }, + #[cfg(feature = "websocket")] + Self::WebSocket { + address, + authorization, + .. + } => EndpointKey::WebSocket { + address: address.clone(), + authorization: authorization.clone(), + }, + } + } +} + +/// Stable identity used to deduplicate endpoints in the device registry. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) enum EndpointKey { + #[cfg(feature = "iroh")] + Iroh { + local: iroh::EndpointId, + remote: iroh::EndpointId, + authorization: Arc<[u8]>, + }, + #[cfg(feature = "websocket")] + WebSocket { + address: Address, + authorization: Arc<[u8]>, + }, +} + +/// Outgoing half of an opened session (the submit stream). +pub(crate) enum SubmitChannel { + #[cfg(feature = "iroh")] + Iroh(iroh::endpoint::SendStream), + #[cfg(feature = "websocket")] + WebSocket(Box), +} + +impl SubmitChannel { + pub(crate) async fn send(&mut self, bytes: bytes::Bytes) -> Result<(), String> { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(stream) => FrameSink::send(stream, bytes).await, + #[cfg(feature = "websocket")] + Self::WebSocket(sink) => FrameSink::send(sink.as_mut(), bytes).await, + } + } + + pub(crate) async fn close(&mut self) -> Result<(), String> { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(stream) => FrameSink::close(stream).await, + #[cfg(feature = "websocket")] + Self::WebSocket(sink) => FrameSink::close(sink.as_mut()).await, + } + } +} + +/// Incoming half of an opened session (the response stream). +pub(crate) enum ResponseChannel { + #[cfg(feature = "iroh")] + Iroh(iroh::endpoint::RecvStream), + #[cfg(feature = "websocket")] + WebSocket(Box), +} + +impl ResponseChannel { + pub(crate) async fn recv(&mut self) -> Result, String> { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(stream) => FrameSource::recv(stream).await, + #[cfg(feature = "websocket")] + Self::WebSocket(stream) => FrameSource::recv(stream.as_mut()).await, + } + } +} + +/// Open the session for `endpoint`, returning its submit + response halves. +/// +/// Done up front so a missing server surfaces here rather than on the first op, and the demux / +/// writer tasks can be spawned on already-open streams. +pub(crate) async fn open_channels( + endpoint: &RemoteEndpoint, +) -> Result<(SubmitChannel, ResponseChannel), String> { + match endpoint { + #[cfg(feature = "iroh")] + RemoteEndpoint::Iroh { node, peer, .. } => { + let (send, recv) = node + .open_stream( + &PeerAddr::Iroh(peer.clone()), + crate::transport::iroh::node::StreamKind::Session, + ) + .await?; + Ok((SubmitChannel::Iroh(send), ResponseChannel::Iroh(recv))) + } + #[cfg(feature = "websocket")] + RemoteEndpoint::WebSocket { address, .. } => { + use burn_communication::websocket::WsClient; + // One full-duplex socket per session, split into the submit (sink) + response (source) + // halves — matching the Iroh single-stream model. + let channel = WsClient::connect(address.clone(), "session") + .await + .map_err(|err| connect_error("session", &endpoint.peer_addr(), &err))?; + let (sink, source) = channel.split(); + Ok(( + SubmitChannel::WebSocket(Box::new(sink)), + ResponseChannel::WebSocket(Box::new(source)), + )) + } + } +} + +/// Actionable panic message for a failed channel connect. +#[cfg(feature = "websocket")] +fn connect_error(route: &str, peer: &PeerAddr, err: &E) -> String { + format!( + "Failed to open remote '{route}' channel to {peer}: {err:?}. \ + Is a `burn-remote` compute node running at that peer?" + ) +} diff --git a/crates/burn-remote/src/client/service/pending.rs b/crates/burn-remote/src/client/service/pending.rs new file mode 100644 index 0000000..ff4745f --- /dev/null +++ b/crates/burn-remote/src/client/service/pending.rs @@ -0,0 +1,201 @@ +//! Request/response correlation. + +use crate::shared::{RequestId, TaskResponseContent}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use tokio::sync::oneshot; + +/// The callbacks awaiting replies, plus whether the connection is still live. +/// +/// Both live under one mutex so that registering a callback and tearing every callback down on +/// disconnect are mutually exclusive: a registration can't slip into the map *after* the +/// response-demux task has already drained it on disconnect and left it to wait for a reply that +/// will never come. +struct State { + callbacks: HashMap>, + /// Cleared by [`Responder::disconnect`] once the response stream is gone; gates new + /// registrations so a post-disconnect request fails fast instead of parking forever. + connected: bool, +} + +type SharedState = Arc>; + +/// Correlates response-producing requests with the caller awaiting each one. +/// +/// Each response-producing task ([`ReadTensor`](crate::shared::Task::ReadTensor), +/// `SyncBackend`, `DTypeUsage`) carries a [`RequestId`]; the server echoes it on the +/// response. The runner thread [`register`](Self::register)s a [`oneshot`] callback before +/// sending the task, and the response-demux task delivers the reply through a [`Responder`]. +/// +/// The state is guarded by a plain [`std::sync::Mutex`]: the lock is only ever held for a single +/// insert/remove/drain and never across an `.await`, so neither the runner thread nor the demux +/// task needs the tokio runtime to touch it. +pub(crate) struct PendingResponses { + state: SharedState, + next_id: RequestId, +} + +impl PendingResponses { + pub(crate) fn new() -> Self { + Self { + state: Arc::new(Mutex::new(State { + callbacks: HashMap::new(), + connected: true, + })), + next_id: 0, + } + } + + /// Allocate the next monotonic [`RequestId`]. + pub(crate) fn next_id(&mut self) -> RequestId { + let id = self.next_id; + self.next_id += 1; + id + } + + /// Register a callback for `id`, returning the receiver the caller awaits for the reply. + /// + /// If the connection has already dropped (the demux task called [`Responder::disconnect`]), + /// the sender is dropped immediately, so the returned receiver resolves to a `RecvError` right + /// away rather than blocking on a response the dead server will never send. + pub(crate) fn register(&self, id: RequestId) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + let mut state = self.state.lock().unwrap(); + if state.connected { + state.callbacks.insert(id, tx); + } + // Disconnected: drop `tx` here, leaving `rx` already-closed. + rx + } + + /// A cheap, cloneable handle the response-demux task uses to deliver replies. + pub(crate) fn responder(&self) -> Responder { + Responder { + state: self.state.clone(), + } + } +} + +/// Delivers responses to the callbacks registered in [`PendingResponses`]. Held by the +/// response-demux task, decoupled from the [`PendingResponses`] the runner thread owns. +pub(crate) struct Responder { + state: SharedState, +} + +impl Responder { + /// Deliver `content` to the caller waiting on `id`. Returns `false` if no callback is + /// registered (unknown id, or the caller dropped its receiver), in which case the + /// response is discarded. + pub(crate) fn complete(&self, id: RequestId, content: TaskResponseContent) -> bool { + match self.state.lock().unwrap().callbacks.remove(&id) { + Some(tx) => { + // Receiver dropped is fine (caller no longer cares). + let _ = tx.send(content); + true + } + None => false, + } + } + + /// Mark the connection dropped and fail every pending caller. + /// + /// Called by the response-demux task when the response stream closes or errors (server down, + /// connection reset). Clearing the map drops every registered sender, so each waiting receiver + /// resolves to a `RecvError` that the callers (`sync`, `read_tensor`, `dtype_usage`) translate + /// into an error instead of blocking forever. Clearing `connected` makes any later request + /// fail fast in [`PendingResponses::register`] rather than registering a doomed callback. + pub(crate) fn disconnect(&self) { + let mut state = self.state.lock().unwrap(); + state.connected = false; + state.callbacks.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn content() -> TaskResponseContent { + TaskResponseContent::SyncBackend(Ok(())) + } + + #[test] + fn next_id_is_monotonic() { + let mut pending = PendingResponses::new(); + assert_eq!(pending.next_id(), 0); + assert_eq!(pending.next_id(), 1); + assert_eq!(pending.next_id(), 2); + } + + #[test] + fn register_then_complete_delivers_to_receiver() { + let mut pending = PendingResponses::new(); + let id = pending.next_id(); + let mut rx = pending.register(id); + + assert!(pending.responder().complete(id, content())); + // `try_recv` resolves synchronously once the sender has fired — no runtime needed. + assert!(matches!( + rx.try_recv(), + Ok(TaskResponseContent::SyncBackend(Ok(()))) + )); + } + + #[test] + fn complete_unknown_id_returns_false() { + let pending = PendingResponses::new(); + assert!(!pending.responder().complete(42, content())); + } + + #[test] + fn complete_consumes_the_callback() { + let mut pending = PendingResponses::new(); + let id = pending.next_id(); + let _rx = pending.register(id); + + assert!(pending.responder().complete(id, content())); + // The callback was removed on first delivery; a duplicate response finds nothing. + assert!(!pending.responder().complete(id, content())); + } + + #[test] + fn disconnect_fails_every_pending_caller() { + let mut pending = PendingResponses::new(); + let id0 = pending.next_id(); + let id1 = pending.next_id(); + let mut rx0 = pending.register(id0); + let mut rx1 = pending.register(id1); + + // The response stream died with both requests still in flight. + pending.responder().disconnect(); + + // Both receivers resolve immediately with an error instead of hanging. + assert!(matches!( + rx0.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + assert!(matches!( + rx1.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + } + + #[test] + fn register_after_disconnect_returns_a_closed_receiver() { + let mut pending = PendingResponses::new(); + pending.responder().disconnect(); + + // A request issued after the connection dropped must not park forever. + let id = pending.next_id(); + let mut rx = pending.register(id); + assert!(matches!( + rx.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + + // And it was never inserted, so a late response for it finds nothing. + assert!(!pending.responder().complete(id, content())); + } +} diff --git a/crates/burn-remote/src/client/service/registry.rs b/crates/burn-remote/src/client/service/registry.rs new file mode 100644 index 0000000..4dc2875 --- /dev/null +++ b/crates/burn-remote/src/client/service/registry.rs @@ -0,0 +1,144 @@ +//! Process-global registry connecting Burn's compact `DeviceId` to rich remote endpoints. + +use burn_ir::TensorId; +use burn_std::DeviceSettings; +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +use super::conn::{EndpointKey, RemoteEndpoint}; +use crate::client::runtime::Executor; + +static TENSOR_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn new_tensor_id() -> TensorId { + TensorId::new(TENSOR_ID_COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +struct EndpointRegistry { + next_index: u32, + by_endpoint: HashMap<(EndpointKey, u32), u32>, + by_index: HashMap, +} + +#[derive(Clone)] +struct EndpointEntry { + endpoint: RemoteEndpoint, + executor: Executor, + device_index: u32, + settings: Arc>, + device_count: Arc>, +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +fn registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + Mutex::new(EndpointRegistry { + next_index: 0, + by_endpoint: HashMap::new(), + by_index: HashMap::new(), + }) + }) +} + +pub(crate) fn register_endpoint( + endpoint: RemoteEndpoint, + executor: Executor, + device_index: u32, +) -> u32 { + let key = (endpoint.key(), device_index); + let mut registry = registry().lock().unwrap(); + if let Some(id) = registry.by_endpoint.get(&key).copied() { + // Refresh mutable dialing hints + the captured runtime while preserving the stable device + // id and settings cells. + let entry = registry.by_index.get_mut(&id).unwrap(); + entry.endpoint = endpoint; + entry.executor = executor; + return id; + } + + let id = registry.next_index; + registry.next_index += 1; + registry.by_endpoint.insert(key, id); + registry.by_index.insert( + id, + EndpointEntry { + endpoint, + executor, + device_index, + settings: Arc::new(OnceLock::new()), + device_count: Arc::new(OnceLock::new()), + }, + ); + id +} + +pub(crate) fn endpoint_for(id: u32) -> Option<(RemoteEndpoint, u32)> { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .map(|entry| (entry.endpoint.clone(), entry.device_index)) +} + +/// The runtime captured for `id`'s device at construction, used to drive its session tasks. +pub(crate) fn executor_for(id: u32) -> Option { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .map(|entry| entry.executor.clone()) +} + +pub(crate) fn settings_for(id: u32) -> DeviceSettings { + *settings_cell(id) + .get() + .expect("Remote service has not connected to this device yet") +} + +pub(crate) fn has_settings(id: u32) -> bool { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .is_some_and(|entry| entry.settings.get().is_some()) +} + +pub(crate) fn settings_cell(id: u32) -> Arc> { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .expect("Device id not registered") + .settings + .clone() +} + +pub(crate) fn device_count_cell(id: u32) -> Arc> { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .expect("Device id not registered") + .device_count + .clone() +} + +pub(crate) fn device_count_for(id: u32) -> Option { + registry() + .lock() + .unwrap() + .by_index + .get(&id) + .and_then(|entry| entry.device_count.get().copied()) +} diff --git a/crates/burn-remote/src/client/service/writer.rs b/crates/burn-remote/src/client/service/writer.rs new file mode 100644 index 0000000..2d3419f --- /dev/null +++ b/crates/burn-remote/src/client/service/writer.rs @@ -0,0 +1,137 @@ +//! Outgoing-frame writer task. + +use crate::client::runtime::{Executor, SpawnHandle}; +use crate::client::service::SubmitChannel; +use crate::shared::RemoteMessage; +use tokio::sync::mpsc; + +/// Bound on task batches queued for the writer task on native targets. +/// +/// [`send`](SubmitWriter::send) enqueues here instead of touching the socket directly. +/// Bounded so a stalled socket surfaces as backpressure on the runner thread (a `send` +/// blocks only when the writer has fallen this many batches behind) rather than as unbounded +/// memory growth. The browser cannot block the runner, so the wasm build uses an unbounded +/// channel and never applies backpressure. +#[cfg(not(target_family = "wasm"))] +const WRITE_QUEUE_CAP: usize = 16; + +#[cfg(not(target_family = "wasm"))] +type BatchSender = mpsc::Sender>; +#[cfg(target_family = "wasm")] +type BatchSender = mpsc::UnboundedSender>; + +/// Owns the submit channel on the service runtime and turns task batches into wire frames. +/// +/// The runner thread hands raw [`RemoteMessage`] batches to [`send`](Self::send) over a channel; +/// the writer task serializes and `await`s each socket send fully before pulling the next, so +/// frames reach the wire in FIFO order without ever parking the runner thread on serialization or +/// the network. Serializing here (rather than on the runner thread) lets encoding one frame — +/// which for `RegisterTensor` carries full tensor payloads — overlap with the runner registering +/// the next op. That single-task FIFO drain is also what guarantees a frame is fully flushed +/// before the next begins — the socket sink itself offers no such queue. +pub(crate) struct SubmitWriter { + /// `Option` so [`shutdown`](Self::shutdown) can drop the sender to signal the task to + /// finish once it has drained. + tx: Option, + /// Joined on shutdown on native; on wasm the detached task drains on the event loop. + handle: Option, +} + +impl SubmitWriter { + /// Spawn the writer task on `runtime`, taking ownership of the submit `channel`. + pub(crate) fn spawn(runtime: &Executor, mut channel: SubmitChannel) -> Self { + #[cfg(not(target_family = "wasm"))] + let (tx, mut rx) = mpsc::channel::>(WRITE_QUEUE_CAP); + #[cfg(target_family = "wasm")] + let (tx, mut rx) = mpsc::unbounded_channel::>(); + + let handle = runtime.spawn(async move { + while let Some(batch) = rx.recv().await { + let bytes: bytes::Bytes = match rmp_serde::to_vec(&batch) { + Ok(b) => b.into(), + Err(err) => { + log::error!("Failed to serialize outgoing task batch: {err:?}; dropping"); + continue; + } + }; + if let Err(err) = channel.send(bytes).await { + log::warn!("Remote submit writer send failed: {err:?}; closing writer"); + return; + } + } + let _ = channel.close().await; + }); + Self { + tx: Some(tx), + handle: Some(handle), + } + } + + /// Enqueue a task batch for serialization + the wire. + /// + /// Native: a non-blocking [`try_send`](mpsc::Sender::try_send) fast path; only when the writer + /// has fallen [`WRITE_QUEUE_CAP`] batches behind (socket backpressure) do we block the runner + /// thread waiting for room. Wasm: an unbounded, always non-blocking send. + #[cfg(not(target_family = "wasm"))] + pub(crate) fn send(&self, runtime: &Executor, batch: Vec) { + let Some(tx) = self.tx.as_ref() else { + log::warn!("Remote submit writer already shut down; dropping outgoing batch"); + return; + }; + match tx.try_send(batch) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(batch)) => { + // Backpressure: wait for the writer to drain a slot. FIFO order is preserved + // because this runs on the single runner thread, after the fast-path sends. + if runtime.block_on(tx.send(batch)).is_err() { + log::warn!( + "Remote submit writer task has exited (server disconnected?); dropping outgoing batch" + ); + } + } + // The writer task exits if a socket send fails (server gone / connection reset). + // Drop the batch with a warning rather than panicking on the runner thread, which + // would crash the caller's thread instead of surfacing a recoverable disconnect. + Err(mpsc::error::TrySendError::Closed(_)) => { + log::warn!( + "Remote submit writer task has exited (server disconnected?); dropping outgoing batch" + ); + } + } + } + + #[cfg(target_family = "wasm")] + pub(crate) fn send(&self, _runtime: &Executor, batch: Vec) { + let Some(tx) = self.tx.as_ref() else { + log::warn!("Remote submit writer already shut down; dropping outgoing batch"); + return; + }; + if tx.send(batch).is_err() { + log::warn!( + "Remote submit writer task has exited (server disconnected?); dropping outgoing batch" + ); + } + } + + /// Best-effort teardown: enqueue an optional final batch, drop the sender so the writer + /// drains and exits, then (on native) join it so the runtime isn't torn down mid-send. In the + /// browser the task is detached and finishes draining on the event loop after the sender drops. + pub(crate) fn shutdown(&mut self, runtime: &Executor, final_batch: Option>) { + if let (Some(batch), Some(tx)) = (final_batch, self.tx.as_ref()) { + #[cfg(not(target_family = "wasm"))] + let _ = runtime.block_on(tx.send(batch)); + #[cfg(target_family = "wasm")] + let _ = tx.send(batch); + } + // Drop the sender so the writer's `rx.recv()` returns `None` once it has drained. + self.tx.take(); + if let Some(handle) = self.handle.take() { + #[cfg(not(target_family = "wasm"))] + runtime.join(handle); + #[cfg(target_family = "wasm")] + let _ = handle; + } + #[cfg(target_family = "wasm")] + let _ = runtime; + } +} diff --git a/crates/burn-remote/src/lib.rs b/crates/burn-remote/src/lib.rs new file mode 100644 index 0000000..a42b2eb --- /dev/null +++ b/crates/burn-remote/src/lib.rs @@ -0,0 +1,1004 @@ +//! Peer-to-peer remote tensor execution for Burn. +//! +//! Iroh is the primary transport. Applications own an Iroh [`Endpoint`] and build remote devices +//! from it; a server hosts compute on its own endpoint. Compute sessions use bidirectional QUIC +//! streams, while cross-peer tensor movement uses independent authenticated streams without +//! routing payloads through the controlling client. +//! +//! The optional `websocket` feature retains the legacy address-and-port transport. + +#[cfg(feature = "client")] +mod client; + +#[cfg(feature = "server")] +pub mod server; + +pub(crate) mod shared; +pub mod telemetry; +mod transport; + +pub use burn_ir as ir; +pub use burn_router::RouterClient; + +/// Network-traffic savings metric for op-graph caching, shared by the client device service and the +/// server session worker. +#[cfg(any(feature = "client", feature = "server"))] +pub(crate) mod metrics; + +#[cfg(feature = "iroh")] +pub use iroh::{Endpoint, EndpointAddr, EndpointId}; +#[cfg(feature = "iroh")] +pub use transport::iroh::RemoteSecret; +#[cfg(feature = "iroh")] +pub use transport::iroh::node::BURN_REMOTE_ALPN; +pub use transport::{PeerAddr, PeerId}; + +#[cfg(feature = "client")] +mod __client { + use super::*; + + use burn_router::BackendRouter; + + /// The remote backend allows you to run computation on a remote device. + /// + /// Iroh is the primary transport. Applications own an Iroh [`Endpoint`], resolve a compute peer + /// through their own discovery/control plane, and construct devices from the endpoint and the + /// peer's address with [`RemoteDevice::iroh`] (or the `Device::remote_iroh` facade). + /// + /// ```rust, ignore + /// let endpoint = Endpoint::builder(presets::N0).bind().await?; + /// let remote = RemoteDevice::iroh(&endpoint, compute_peer, 0); + /// ``` + /// + /// For backends that aren't part of `DispatchDevice` but implement + /// `BackendIr`, build a [`server::RemoteServerBuilder`] directly with the + /// concrete backend type parameter — that is also how custom operations + /// (backend extensions) are hosted, via + /// [`custom_op`](server::RemoteServerBuilder::custom_op). + #[cfg(not(feature = "fusion"))] + pub type RemoteBackend = BackendRouter; + + /// With the `fusion` feature enabled, the remote backend is wrapped in + /// [`Fusion`](burn_fusion::Fusion) — exactly like the CubeCL backends — so recurring groups of + /// operations are cached on the server and invoked by id, sending a repeated computation + /// (e.g. a model block per step) over the network once instead of every step. + #[cfg(feature = "fusion")] + pub type RemoteBackend = burn_fusion::Fusion>; + + pub use client::{CustomOpClient, RemoteChannel, RemoteDevice}; +} +#[cfg(feature = "client")] +pub use __client::*; + +#[cfg(all(test, feature = "client", feature = "server"))] +mod tests { + use burn_flex::Flex; + use burn_tensor::{Device, DeviceType, Distribution, Tensor}; + + /// Run `body` on a worker thread and fail the test if it doesn't finish within `timeout`. + /// + /// A deadlock in the remote backend manifests as a hung worker, so without a watchdog the + /// test would block the whole suite forever. We can't forcibly kill the hung thread (it's + /// parked on a blocking recv deep in the backend), so on timeout we panic from the test + /// thread and let the process exit carry the stuck worker away. + fn with_deadlock_watchdog(timeout: std::time::Duration, body: impl FnOnce() + Send + 'static) { + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + body(); + let _ = tx.send(()); + }); + match rx.recv_timeout(timeout) { + Ok(()) => { + handle.join().expect("worker thread panicked"); + } + Err(_) => panic!( + "Deadlock: the remote multi-device workload did not finish within {timeout:?}" + ), + } + } + + #[test] + pub fn test_to_device_over_websocket() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3000) + .start_async(), + ); + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3010) + .start_async(), + ); + + // Give the servers a moment to bind before clients try to connect. + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device_1 = Device::remote_websocket("ws://localhost:3000", 0); + let device_2 = Device::remote_websocket("ws://localhost:3010", 0); + + // Some random input on device 1. + let input_shape = [1, 28, 28]; + let input = Tensor::<3>::random(input_shape, Distribution::Default, &device_1); + let numbers_expected: Vec = input.to_data().to_vec().unwrap(); + + // Move tensor to device 2. + let input = input.to_device(&device_2); + let numbers: Vec = input.to_data().to_vec().unwrap(); + assert_eq!(numbers, numbers_expected); + + // Move tensor back to device 1. + let input = input.to_device(&device_1); + let numbers: Vec = input.to_data().to_vec().unwrap(); + assert_eq!(numbers, numbers_expected); + + rt.shutdown_background(); + } + + /// End-to-end backend extension over the wire: the client ships a custom op as + /// `OperationIr::Custom`, and the server executes it through a handler registered on the + /// builder. Mirrors how a backend extension hosts its ops — the user hand-writes the client + /// side (here, building the `CustomOpIr`) and registers the server handler. + /// + /// Only runs without `fusion`, since it drives the router client (`RemoteBackend`) directly. + #[test] + #[cfg(not(feature = "fusion"))] + pub fn test_custom_op_over_websocket() { + use crate::{RemoteBackend, RemoteDevice}; + use burn_backend::{Scalar, TensorData, TensorMetadata, ops::FloatTensorOps}; + use burn_ir::{CustomOpIr, OperationIr, ScalarIr, TensorIr}; + use burn_router::RouterClient; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + // Host a "scale" custom op: multiply the input float tensor by a scalar argument. + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3200) + .custom_op("scale", |handles, ir, _device| { + let input = handles.get_float_tensor::(&ir.inputs[0]); + let factor: Scalar = ir.scalars[0].into(); + let output = Flex::float_mul_scalar(input, factor); + handles.register_float_tensor::(&ir.outputs[0].id, output); + }) + .start_async(), + ); + + // Give the server a moment to bind before the client connects. + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Drive the remote backend directly (no autodiff/dispatch glue). A real backend extension + // would wrap this in a hand-written `impl MyExt for RemoteBackend`. + let device = RemoteDevice::websocket("ws://localhost:3200", 0); + let input = >::float_from_data( + TensorData::from([2.0f32, 4.0, 6.0]), + &device, + ); + + // Client side: build the custom op (input tensor + the scale factor as a scalar) and ship + // it through the remote client as `OperationIr::Custom`. + let client = input.client.clone(); + let shape = input.shape(); + let dtype = input.dtype(); + let out_ir = TensorIr::uninit(client.create_empty_handle(), shape, dtype); + let desc = CustomOpIr::with_scalars( + "scale", + &[input.into_ir()], + &[out_ir], + vec![ScalarIr::Float(3.0)], + ); + let out = client.register(OperationIr::Custom(desc)).remove(0); + + let data = rt + .block_on(>::float_into_data(out)) + .unwrap(); + let values: Vec = data.to_vec().unwrap(); + assert_eq!(values, vec![6.0, 12.0, 18.0]); + + rt.shutdown_background(); + } + + /// The Iroh counterpart of [`test_custom_op_over_websocket`]: the server hosts a "scale" handler + /// registered on the protocol's custom-op registry, and the client ships the op as + /// `OperationIr::Custom`. Confirms custom ops travel the merged session path over Iroh just as + /// they do over WebSocket. + /// + /// Only runs without `fusion`, since it drives the router client (`RemoteBackend`) directly. + #[test] + #[cfg(not(feature = "fusion"))] + pub fn test_custom_op_over_iroh() { + use crate::{ + BURN_REMOTE_ALPN, RemoteBackend, RemoteDevice, + server::{AllowAll, CustomOpRegistry, IrohRemoteProtocol}, + telemetry::TelemetryProbe, + }; + use burn_backend::{Scalar, TensorData, TensorMetadata, ops::FloatTensorOps}; + use burn_ir::{CustomOpIr, OperationIr, ScalarIr, TensorIr}; + use burn_router::RouterClient; + use iroh::{Endpoint, RelayMode, endpoint::presets, protocol::Router}; + + async fn local_endpoint() -> Endpoint { + Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .unwrap() + .bind() + .await + .unwrap() + } + + // Server on its own runtime, hosting a "scale" custom op (multiply the input by a scalar). + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + let server = rt.block_on(local_endpoint()); + let server_addr = server.addr(); + + let mut custom_ops = CustomOpRegistry::::default(); + custom_ops.register("scale", |handles, ir, _device| { + let input = handles.get_float_tensor::(&ir.inputs[0]); + let factor: Scalar = ir.scalars[0].into(); + let output = Flex::float_mul_scalar(input, factor); + handles.register_float_tensor::(&ir.outputs[0].id, output); + }); + + let protocol = IrohRemoteProtocol::::new( + server.clone(), + vec![Default::default()], + std::sync::Arc::new(AllowAll), + TelemetryProbe::disabled(), + custom_ops, + ); + + // spawn() must run in a runtime context so iroh can schedule its tasks. + let router = { + let _guard = rt.enter(); + Router::builder(server) + .accept(BURN_REMOTE_ALPN, protocol) + .spawn() + }; + + let client = rt.block_on(local_endpoint()); + let device = RemoteDevice::iroh(&client, server_addr, 0); + + // Client side: build the custom op and ship it as `OperationIr::Custom`, exactly as the + // WebSocket test does -- only the transport differs. + let input = >::float_from_data( + TensorData::from([2.0f32, 4.0, 6.0]), + &device, + ); + let remote_client = input.client.clone(); + let shape = input.shape(); + let dtype = input.dtype(); + let out_ir = TensorIr::uninit(remote_client.create_empty_handle(), shape, dtype); + let desc = CustomOpIr::with_scalars( + "scale", + &[input.into_ir()], + &[out_ir], + vec![ScalarIr::Float(3.0)], + ); + let out = remote_client.register(OperationIr::Custom(desc)).remove(0); + + let data = rt + .block_on(>::float_into_data(out)) + .unwrap(); + let values: Vec = data.to_vec().unwrap(); + assert_eq!(values, vec![6.0, 12.0, 18.0]); + + rt.block_on(router.shutdown()).unwrap(); + } + + /// A single server hosting multiple devices: two indices on the same address resolve to + /// two distinct sessions (distinct interpreters/runner threads). Moving a tensor between + /// them exercises the multi-device path within one host. + #[test] + pub fn test_multi_device_single_server() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + // One server, two devices. + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![ + Default::default(), + Default::default(), + ]) + .port(3030) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device_0 = Device::remote_websocket("ws://localhost:3030", 0); + let device_1 = Device::remote_websocket("ws://localhost:3030", 1); + + // Distinct indices on the same address must be distinct devices. + assert_ne!(device_0, device_1); + + let input_shape = [1, 28, 28]; + let input = Tensor::<3>::random(input_shape, Distribution::Default, &device_0); + let numbers_expected: Vec = input.to_data().to_vec().unwrap(); + + // Move tensor to the second device on the same host and back. + let input = input.to_device(&device_1); + let numbers: Vec = input.to_data().to_vec().unwrap(); + assert_eq!(numbers, numbers_expected); + + let input = input.to_device(&device_0); + let numbers: Vec = input.to_data().to_vec().unwrap(); + assert_eq!(numbers, numbers_expected); + + rt.shutdown_background(); + } + + /// Concurrent multi-device regression (DDP-style): two user threads, each pinned to a device, + /// running simultaneously and each iteration moving a tensor to the *other* device and back. + /// + /// This used to deadlock because the client's transfer-id counter never persisted its + /// increment (`LocalTransferId` is `Copy`, so the increment landed on a throwaway local). + /// Every same-host transfer after the first reused the same id; sequentially that's harmless + /// (each expose is taken before the next), but two transfers in flight at once then collided + /// in the server's `local_comm` rendezvous and one `take` hung forever. Single-threaded + /// workloads never tripped it — `into_data` serializes each iteration — so the bug only shows + /// up under genuine concurrency. + #[test] + fn test_multi_device_concurrent_to_device_deadlock() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![ + Default::default(), + Default::default(), + ]) + .port(3060) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + with_deadlock_watchdog(std::time::Duration::from_secs(30), || { + let device0 = Device::remote_websocket("ws://localhost:3060", 0); + let device1 = Device::remote_websocket("ws://localhost:3060", 1); + + let run = |home: Device, away: Device| { + move || { + for _ in 0..100 { + let t = Tensor::<2>::random([8, 8], Distribution::Default, &home); + // home -> away, op there, away -> home. + let t = t.to_device(&away); + let t = t * 2.0; + let t = t.to_device(&home); + let _ = t.sum().into_data(); + } + } + }; + + let h0 = std::thread::spawn(run(device0.clone(), device1.clone())); + let h1 = std::thread::spawn(run(device1, device0)); + h0.join().unwrap(); + h1.join().unwrap(); + }); + + rt.shutdown_background(); + } + + /// `Device::enumerate(DeviceType::remote_websocket(addr))` lists every device the server hosts, by + /// connecting once and reading the device count off the init handshake. + #[test] + pub fn test_enumerate_remote_devices() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + // One server hosting three devices. + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![ + Default::default(), + Default::default(), + Default::default(), + ]) + .port(3040) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let devices = + Device::enumerate(DeviceType::remote_websocket("ws://localhost:3040")).into_vec(); + + // The server reports its three devices, in index order. + assert_eq!(devices.len(), 3); + assert_eq!( + devices[0], + Device::remote_websocket("ws://localhost:3040", 0) + ); + assert_eq!( + devices[1], + Device::remote_websocket("ws://localhost:3040", 1) + ); + assert_eq!( + devices[2], + Device::remote_websocket("ws://localhost:3040", 2) + ); + + // Distinct indices are distinct devices. + assert_ne!(devices[0], devices[1]); + assert_ne!(devices[1], devices[2]); + + // The enumerated devices are usable: run an op on the last one. + let input = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0]], &devices[2]); + let numbers: Vec = (input * 2.0).to_data().to_vec().unwrap(); + assert_eq!(numbers, vec![2.0, 4.0, 6.0, 8.0]); + + rt.shutdown_background(); + } + + /// Exercises the cross-backend transfer body: local tensor → remote (data round-trip + /// through `TensorData`), an op on the remote, then remote → local. + /// Run `body` on a worker thread and report whether it finished within `timeout`. + /// + /// Unlike [`with_deadlock_watchdog`], a panic inside `body` counts as "finished": these error + /// tests assert that a failure *surfaces* (as an `Err` or a panic) instead of hanging, so all + /// that matters is the thread came back. Returns `false` if it was still running at the + /// deadline — i.e. the call hung. + fn finishes_within(timeout: std::time::Duration, body: impl FnOnce() + Send + 'static) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + // Swallow panics: a disconnected read panicking on the error path is an acceptable + // "didn't hang" outcome and must not abort the whole test process. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)); + let _ = tx.send(()); + }); + rx.recv_timeout(timeout).is_ok() + } + + /// When the server goes down, a client call that awaits a response (here a tensor read) must + /// fail promptly instead of blocking forever. Before the fix the response-demux task just + /// exited on the closed stream, leaving every pending callback — and any later request — + /// parked on a oneshot that would never be completed. + #[test] + fn test_server_down_does_not_hang_client() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3070) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device = Device::remote_websocket("ws://localhost:3070", 0); + + // One successful round-trip so the sockets are actually up and the demux task is running. + let input = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0]], &device); + let warmup: Vec = (input * 2.0).to_data().to_vec().unwrap(); + assert_eq!(warmup, vec![2.0, 4.0, 6.0, 8.0]); + + // Kill the server: dropping its runtime closes the listener and both client sockets. + rt.shutdown_timeout(std::time::Duration::from_millis(100)); + // Let the client's response-demux observe the closed stream and fail pending callers. + std::thread::sleep(std::time::Duration::from_millis(500)); + + // A read now has no server to answer it. It must error out (which `to_data` surfaces as a + // panic), not hang. + let finished = finishes_within(std::time::Duration::from_secs(10), move || { + let t = Tensor::<2>::from_floats([[5.0, 6.0], [7.0, 8.0]], &device); + let _ = (t * 2.0).to_data(); + }); + assert!( + finished, + "client hung waiting for a response after the server went down" + ); + } + + /// A client that disconnects abruptly mid-session (socket dropped, no `Close`) must not wedge + /// the server: it should clean the session up and keep serving everyone else. We drive a raw + /// connection here because a `Device`'s client is process-cached and never dropped mid-test. + #[test] + fn test_client_disconnect_handled_cleanly_by_server() { + use crate::shared::{RemoteMessage, SessionId, Task}; + use burn_communication::{CommunicationChannel, Message, ProtocolClient}; + use std::str::FromStr; + + type Client = burn_communication::websocket::WsClient; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3090) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Raw client: connect a submit stream, init a session and send one task so the server + // spawns the session worker, then drop the socket without a `Close` to mimic a crash. + { + let rtc = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let address = burn_communication::Address::from_str("ws://localhost:3090").unwrap(); + let session_id = SessionId::new(); + + rtc.block_on(async { + let mut submit = Client::connect(address, "session") + .await + .expect("raw session connect"); + + let frame = |msgs: Vec| -> Message { + Message::new(rmp_serde::to_vec(&msgs).unwrap().into()) + }; + + submit + .send(frame(vec![RemoteMessage::Init( + crate::shared::SessionInit::new(session_id, 0, vec![]), + )])) + .await + .expect("send init"); + submit + .send(frame(vec![RemoteMessage::Task(Task::Seed(0))])) + .await + .expect("send task"); + // Drop `submit` here (end of block): the server sees the stream end without a + // `Close` and must run the cleanup path. + }); + } + + // Give the server a moment to tear the abandoned session down. + std::thread::sleep(std::time::Duration::from_millis(500)); + + // The server must have survived: a fresh, normal client on the same server still works. + let finished = finishes_within(std::time::Duration::from_secs(10), || { + let device = Device::remote_websocket("ws://localhost:3090", 0); + let input = Tensor::<2>::from_floats([[10.0, 20.0]], &device); + let numbers: Vec = (input * 3.0).to_data().to_vec().unwrap(); + assert_eq!(numbers, vec![30.0, 60.0]); + }); + assert!( + finished, + "server stopped serving after a client disconnected abruptly" + ); + + rt.shutdown_timeout(std::time::Duration::from_millis(100)); + } + + #[test] + pub fn test_to_device_local_to_remote() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3020) + .start_async(), + ); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let local = Device::default(); + let remote = Device::remote_websocket("ws://localhost:3020", 0); + + // Create on local, move to remote. + let input = Tensor::<2>::from_floats([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &local); + let on_remote = input.clone().to_device(&remote); + + // Run an op while on the remote. + let doubled = on_remote * 2.0; + + // Move back to local and verify. + let back = doubled.to_device(&local); + let numbers: Vec = back.to_data().to_vec().unwrap(); + assert_eq!(numbers, vec![2.0, 4.0, 6.0, 8.0, 10.0, 12.0]); + + rt.shutdown_background(); + } +} + +#[cfg(all(test, feature = "fusion", feature = "server"))] +mod fusion_tests { + use crate::{RemoteBackend, RemoteDevice, client::RemoteChannel}; + use burn_backend::{Backend, Shape, TensorData}; + use burn_router::BackendRouter; + + // `RemoteBackend` is `Fusion` under the `fusion` feature; `PlainRemote` is the + // unwrapped router backend, used as the reference to compare against. + type PlainRemote = BackendRouter; + + fn input() -> TensorData { + TensorData::from([[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]]) + } + + /// Run the same small multi-op graph `iters` times on backend `B`, reading the result each + /// iteration. Every iteration has an identical op structure, so a fusion backend registers the + /// optimization once and replays it by id on the later iterations. + /// + /// The graph deliberately includes a reshape, so one of the intermediate tensors (`c`) has a + /// *different* shape than the inputs/outputs — exercising the server's reconstruction of + /// intermediate shapes from the shape-dim map (rather than them being sent per replay). + fn run>( + device: &RemoteDevice, + iters: usize, + ) -> Vec> { + let mut out = Vec::new(); + for _ in 0..iters { + let a = B::float_from_data(input(), device); // [2, 3] + let b = B::float_exp(a); // [2, 3] + let c = B::float_reshape(b, Shape::from([3, 2])); // [3, 2] intermediate (distinct shape) + let d = B::float_log(c); // [3, 2] + let data = burn_std::reader::try_read_sync(B::float_into_data(d)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + out.push(data.to_vec::().unwrap()); + } + out + } + + /// The fusion-enabled remote backend must produce exactly the same results as the plain remote + /// backend across a repeated computation that exercises register-once + replay-by-id. + #[test] + fn fusion_matches_plain_remote() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3100) + .start_async(), + ); + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3110) + .start_async(), + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let plain_device = RemoteDevice::websocket("ws://localhost:3100", 0); + let fused_device = RemoteDevice::websocket("ws://localhost:3110", 0); + + let iters = 5; + let expected = run::(&plain_device, iters); + let actual = run::(&fused_device, iters); + + assert_eq!(actual.len(), iters); + assert_eq!(expected.len(), iters); + for (a, e) in actual.iter().zip(expected.iter()) { + assert_eq!(a.len(), e.len()); + for (av, ev) in a.iter().zip(e.iter()) { + assert!( + (av - ev).abs() < 1e-5, + "fusion result {av} differs from plain remote {ev}" + ); + } + } + + rt.shutdown_background(); + } + + /// A *source* custom op (no tensor inputs — it builds a tensor from scalars on the server) whose + /// output is then consumed by a follow-up op, read back, repeated to exercise register-once + + /// replay. This mirrors the server-side data-loader extension pattern and isolates it from the + /// training stack — if the fusion graph mishandles a source custom op's outputs, it surfaces here + /// as a "Should have handle for tensor ..." panic on the server. + #[test] + fn fusion_custom_source_op_then_followup() { + use crate::client::CustomOpClient; + use burn_backend::DType; + use burn_backend::ops::FloatTensorOps; + use burn_flex::Flex; + use burn_ir::{CustomOpIr, OperationOutput, ScalarIr, TensorIr}; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3120) + .custom_op("make_floats", |handles, ir, device| { + // Build a 1-D float tensor from the op's scalars — a pure source (no inputs). + let values: Vec = ir.scalars.iter().map(|s| s.elem::()).collect(); + let n = values.len(); + let tensor = Flex::float_from_data(TensorData::new(values, [n]), device); + handles.register_float_tensor::(&ir.outputs[0].id, tensor); + }) + .start_async(), + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device = RemoteDevice::websocket("ws://localhost:3120", 0); + + for i in 0..5 { + let client = CustomOpClient::new(&device); + let out_ir = + TensorIr::uninit(client.create_empty_handle(), Shape::from([3]), DType::F32); + let made = client + .register(CustomOpIr::with_scalars( + "make_floats", + &[], + &[out_ir], + vec![ + ScalarIr::Float(1.0), + ScalarIr::Float(2.0), + ScalarIr::Float(3.0), + ], + )) + .output(); + + // Follow-up op consuming the source output — forces a graph that references the custom + // op's output as an input, the scenario that breaks during training. + let doubled = >::float_exp(made); + let data = burn_std::reader::try_read_sync(>::float_into_data(doubled)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + + let values = data.to_vec::().unwrap(); + let expected = [1.0f32.exp(), 2.0f32.exp(), 3.0f32.exp()]; + for (a, e) in values.iter().zip(expected.iter()) { + assert!((a - e).abs() < 1e-4, "iter {i}: {a} vs {e}"); + } + } + + rt.shutdown_background(); + } + + /// Read a source custom op's output *directly* (it is the boundary output, with no follow-up op + /// consuming it). This is what the data loader does — `batch.tokens.to_data()` — and the case the + /// other two tests don't cover (they always feed the output into another op first). + #[test] + fn fusion_read_source_output_directly() { + use crate::client::CustomOpClient; + use burn_backend::DType; + use burn_backend::ops::FloatTensorOps; + use burn_flex::Flex; + use burn_ir::{CustomOpIr, OperationOutput, ScalarIr, TensorIr}; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3140) + .custom_op("make_floats", |handles, ir, device| { + let values: Vec = ir.scalars.iter().map(|s| s.elem::()).collect(); + let n = values.len(); + let tensor = Flex::float_from_data(TensorData::new(values, [n]), device); + handles.register_float_tensor::(&ir.outputs[0].id, tensor); + }) + .start_async(), + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device = RemoteDevice::websocket("ws://localhost:3140", 0); + + for i in 0..5 { + let client = CustomOpClient::new(&device); + let out_ir = + TensorIr::uninit(client.create_empty_handle(), Shape::from([3]), DType::F32); + let made = client + .register(CustomOpIr::with_scalars( + "make_floats", + &[], + &[out_ir], + vec![ + ScalarIr::Float(1.0), + ScalarIr::Float(2.0), + ScalarIr::Float(3.0), + ], + )) + .output(); + + // Read the source output directly — no follow-up op. + let data = burn_std::reader::try_read_sync(>::float_into_data(made)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + let values = data.to_vec::().unwrap(); + assert_eq!(values, vec![1.0, 2.0, 3.0], "iter {i}"); + } + + rt.shutdown_background(); + } + + /// Closer to the data-loader: a source custom op with *three* outputs that are consumed at + /// *different depths* of the following graph (one immediately, one mid-graph, one only at the + /// end — like `tokens`/`mask`/`labels`). Exercises a source op's outputs surviving across many + /// ops before being bound as inputs, under register-once + replay. + #[test] + fn fusion_custom_source_multi_output_long_lived() { + use crate::client::CustomOpClient; + use burn_backend::DType; + use burn_backend::ops::FloatTensorOps; + use burn_flex::Flex; + use burn_ir::{CustomOpIr, OperationOutput, ScalarIr, TensorIr}; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3130) + .custom_op("make3", |handles, ir, device| { + // 9 scalars → three [3] outputs (chunks of 3). + let values: Vec = ir.scalars.iter().map(|s| s.elem::()).collect(); + for (i, out) in ir.outputs.iter().enumerate() { + let chunk = values[i * 3..(i + 1) * 3].to_vec(); + let tensor = Flex::float_from_data(TensorData::new(chunk, [3]), device); + handles.register_float_tensor::(&out.id, tensor); + } + }) + .start_async(), + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device = RemoteDevice::websocket("ws://localhost:3130", 0); + + for i in 0..5 { + let client = CustomOpClient::new(&device); + let mk = |client: &CustomOpClient| { + TensorIr::uninit(client.create_empty_handle(), Shape::from([3]), DType::F32) + }; + let [a, b, c] = client + .register(CustomOpIr::with_scalars( + "make3", + &[], + &[mk(&client), mk(&client), mk(&client)], + (1..=9).map(|v| ScalarIr::Float(v as f64)).collect(), + )) + .outputs::<3>(); + + // a consumed immediately, b mid-graph, c only at the end — so b and c are source-op + // outputs that survive across several ops before being bound as inputs. + type B = RemoteBackend; + let t = >::float_exp(a); + let t = >::float_add(t, b); + let t = >::float_log(t); + let t = >::float_add(t, c); + let data = + burn_std::reader::try_read_sync(>::float_into_data(t)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + + let values = data.to_vec::().unwrap(); + // a=[1,2,3], b=[4,5,6], c=[7,8,9]; t = log(exp(a)+b) + c + let expected: Vec = (0..3) + .map(|k| { + let a = (k + 1) as f32; + let b = (k + 4) as f32; + let c = (k + 7) as f32; + (a.exp() + b).ln() + c + }) + .collect(); + for (g, e) in values.iter().zip(expected.iter()) { + assert!((g - e).abs() < 1e-3, "iter {i}: {g} vs {e}"); + } + } + + rt.shutdown_background(); + } + + /// Regression guard for the `free_handle` drop-suppression override + /// (`RouterFusionRuntime::free_handle`): a *second live reference* to a source op's output is + /// held across the graph drain that consumes the first one. The override removes the drained + /// block's container entry and bumps the handle refcount so `RouterTensor::drop` doesn't + /// re-register a redundant server `Drop`; if that bookkeeping mishandles a surviving clone, the + /// retained tensor's id is freed too early and the *next* graph that uses it panics on the + /// server with "Should have handle for tensor ..." (or reads back garbage). Looping exercises + /// register-once + replay so the bug would surface on a later iteration even if the first slips + /// through. + #[test] + fn fusion_custom_source_output_clone_survives_drain() { + use crate::client::CustomOpClient; + use burn_backend::DType; + use burn_backend::ops::FloatTensorOps; + use burn_flex::Flex; + use burn_ir::{CustomOpIr, OperationOutput, ScalarIr, TensorIr}; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .unwrap(); + + rt.spawn( + crate::server::RemoteServerBuilder::::new(vec![Default::default()]) + .port(3150) + .custom_op("make_floats", |handles, ir, device| { + let values: Vec = ir.scalars.iter().map(|s| s.elem::()).collect(); + let n = values.len(); + let tensor = Flex::float_from_data(TensorData::new(values, [n]), device); + handles.register_float_tensor::(&ir.outputs[0].id, tensor); + }) + .start_async(), + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let device = RemoteDevice::websocket("ws://localhost:3150", 0); + + type B = RemoteBackend; + for i in 0..5 { + let client = CustomOpClient::new(&device); + let out_ir = + TensorIr::uninit(client.create_empty_handle(), Shape::from([3]), DType::F32); + let made = client + .register(CustomOpIr::with_scalars( + "make_floats", + &[], + &[out_ir], + vec![ + ScalarIr::Float(1.0), + ScalarIr::Float(2.0), + ScalarIr::Float(3.0), + ], + )) + .output(); + + // A second live reference to the same source output. Kept alive across the drain that + // consumes `made` below — this is the scenario the override's refcount bump must not + // free. + let kept = made.clone(); + + // Consume `made` in a graph and read it back, forcing a drain that frees the block's + // handles while `kept` still references the source output's id. + let exp = >::float_exp(made); + let exp_data = + burn_std::reader::try_read_sync(>::float_into_data(exp)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + let exp_values = exp_data.to_vec::().unwrap(); + let exp_expected = [1.0f32.exp(), 2.0f32.exp(), 3.0f32.exp()]; + for (g, e) in exp_values.iter().zip(exp_expected.iter()) { + assert!((g - e).abs() < 1e-3, "iter {i} (exp): {g} vs {e}"); + } + + // Now use the retained clone in a *new* graph. If the drain above freed the id out from + // under it, this read fails server-side or returns garbage. + let log = >::float_log(kept); + let log_data = + burn_std::reader::try_read_sync(>::float_into_data(log)) + .expect("remote read should resolve synchronously") + .expect("read should succeed"); + let log_values = log_data.to_vec::().unwrap(); + let log_expected = [1.0f32.ln(), 2.0f32.ln(), 3.0f32.ln()]; + for (g, e) in log_values.iter().zip(log_expected.iter()) { + assert!((g - e).abs() < 1e-3, "iter {i} (log): {g} vs {e}"); + } + } + + rt.shutdown_background(); + } +} diff --git a/crates/burn-remote/src/metrics.rs b/crates/burn-remote/src/metrics.rs new file mode 100644 index 0000000..75a2eaa --- /dev/null +++ b/crates/burn-remote/src/metrics.rs @@ -0,0 +1,148 @@ +//! Logs op-graph caching (fusion) savings as `[remote ...]` lines, gated behind the runtime log +//! level (`[remote]` in `burn.toml`, or `BURN_REMOTE_LOG`). A best-effort [`TelemetryProbe`] +//! subscriber; client and server each run their own, distinguished by a [`MetricSide`] label. +//! +//! [`TelemetryProbe`]: crate::telemetry::TelemetryProbe + +use core::fmt; + +use burn_ir::GraphId; +use burn_std::config::config; +use burn_std::config::log_remote; +use burn_std::config::remote::RemoteLogLevel; + +use crate::telemetry::{ + OpClass, TelemetryEvent, TelemetryProbe, TelemetrySubscription, TrafficAggregator, +}; + +const MIB: u64 = 1024 * 1024; + +/// Which side of the connection a logger reports, used to label its log lines. +#[derive(Clone, Copy, Debug)] +pub(crate) enum MetricSide { + /// The client: bytes it would have sent vs. bytes it actually sent. + Client, + /// The server: bytes it would have received vs. bytes it actually received. + Server, +} + +impl fmt::Display for MetricSide { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MetricSide::Client => f.write_str("client"), + MetricSide::Server => f.write_str("server"), + } + } +} + +/// A best-effort telemetry subscriber that logs op-graph caching savings. +pub(crate) struct TelemetryLogger { + side: MetricSide, + aggregator: TrafficAggregator, + /// Highest whole-MiB savings already reported, so `Basic` logging emits at most one line per + /// additional mebibyte saved instead of one per replay. + logged_mib: u64, +} + +impl TelemetryLogger { + pub(crate) fn new(side: MetricSide) -> Self { + Self { + side, + aggregator: TrafficAggregator::default(), + logged_mib: 0, + } + } + + /// Whether remote logging is configured. + pub(crate) fn enabled() -> bool { + level() != RemoteLogLevel::Disabled + } + + /// Drain and log until the probe's senders are dropped. + pub(crate) async fn run(mut self, mut events: TelemetrySubscription) { + while let Some(event) = events.recv().await { + self.aggregator.apply(&event); + match event.as_ref() { + TelemetryEvent::GraphRegistered { + graph, ops, bytes, .. + } => self.log_registration(*graph, ops.len(), *bytes), + TelemetryEvent::GraphExecuted { .. } => self.log_progress(), + _ => {} + } + } + } + + fn log_registration(&self, graph: GraphId, ops: usize, bytes: usize) { + let side = self.side; + log_remote(RemoteLogLevel::Full, || { + format!( + "[remote {side}] registered graph {graph:?}: {ops} ops, {bytes} bytes (sent once)" + ) + }); + } + + fn log_progress(&mut self) { + let snapshot = self.aggregator.snapshot(); + let saved = snapshot.saved(); + let saved_pct = percentage(saved, snapshot.baseline); + let side = self.side; + + if level() >= RemoteLogLevel::Full { + let fused_pct = percentage(snapshot.fused_ops, snapshot.total_ops()); + let breakdown = format_unfused_by_kind(&self.aggregator.unfused_by_kind()); + log_remote(RemoteLogLevel::Full, || { + format!( + "[remote {side}] op-graph caching saved {saved} bytes ({saved_pct:.1}% of \ + baseline); fused {fused_pct:.1}% of {} ops total; unfused by kind: [{breakdown}]", + snapshot.total_ops() + ) + }); + } else { + // Basic: self-throttle to one summary per additional mebibyte saved. + let mib = saved / MIB; + if mib > self.logged_mib { + self.logged_mib = mib; + log_remote(RemoteLogLevel::Basic, || { + format!( + "[remote {side}] op-graph caching has saved ~{mib} MiB ({saved} bytes, \ + {saved_pct:.1}% of baseline) of network traffic" + ) + }); + } + } + } +} + +/// The logger's drain loop for `probe`, or `None` when remote logging is off. Spawn it detached. +pub(crate) fn logger_task( + probe: &TelemetryProbe, + side: MetricSide, +) -> Option + Send + 'static> { + if !TelemetryLogger::enabled() { + return None; + } + let subscription = probe.subscribe()?; + Some(TelemetryLogger::new(side).run(subscription)) +} + +fn level() -> RemoteLogLevel { + config().remote().logger.level +} + +fn format_unfused_by_kind(entries: &[(OpClass, u64)]) -> String { + entries + .iter() + .map(|(kind, count)| format!("{}={count}", kind.label())) + .collect::>() + .join(", ") +} + +/// `part` as a percentage of `whole`. Zero when `whole` is 0 (nothing measured yet), so the first +/// log line can't divide by zero. +fn percentage(part: u64, whole: u64) -> f64 { + if whole == 0 { + 0.0 + } else { + part as f64 / whole as f64 * 100.0 + } +} diff --git a/crates/burn-remote/src/server/builder.rs b/crates/burn-remote/src/server/builder.rs new file mode 100644 index 0000000..8808fb9 --- /dev/null +++ b/crates/burn-remote/src/server/builder.rs @@ -0,0 +1,163 @@ +use burn_backend::tensor::Device; +use burn_ir::{BackendIr, CustomOpIr, HandleContainer}; +use burn_router::CustomOpRegistry; + +/// Transport used to serve remote clients. +/// +/// Selects the protocol the [`RemoteServerBuilder`] serves on. +#[derive(Clone)] +pub enum Channel { + /// WebSocket server bound to `0.0.0.0:port`. + #[cfg(feature = "websocket")] + WebSocket { + /// Port to bind on. + port: u16, + }, + /// Iroh peer-to-peer transport. The server's address is `secret.id()`; clients dial that. + #[cfg(feature = "iroh")] + Iroh { + /// The server's stable identity, its address knob (like a port for WebSocket). + secret: Box, + }, +} + +/// Default port used when none is configured on the builder. +#[cfg(feature = "websocket")] +const DEFAULT_PORT: u16 = 3000; + +impl core::fmt::Debug for Channel { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #[cfg(feature = "websocket")] + Channel::WebSocket { port } => f.debug_struct("WebSocket").field("port", port).finish(), + // Show the public identity, never the secret key material. + #[cfg(feature = "iroh")] + Channel::Iroh { secret } => f.debug_struct("Iroh").field("id", &secret.id()).finish(), + } + } +} + +impl Default for Channel { + fn default() -> Self { + #[cfg(feature = "websocket")] + return Channel::WebSocket { port: DEFAULT_PORT }; + // Without WebSocket the default is Iroh on a fresh random identity; a host that wants a + // dialable address sets its own secret with [`Channel::Iroh`]. + #[cfg(all(feature = "iroh", not(feature = "websocket")))] + return Channel::Iroh { + secret: Box::new(crate::RemoteSecret::random()), + }; + } +} + +/// Builder for a remote-execution server. +/// +/// Configures the transport ([`channel`](Self::channel) / [`port`](Self::port)) and the custom +/// operation handlers ([`custom_op`](Self::custom_op) / [`custom_ops`](Self::custom_ops)), then +/// starts the server with [`start`](Self::start) (blocking) or [`start_async`](Self::start_async). +/// +/// The builder is generic over the concrete backend `B`: custom ops are typed by `B`, since their +/// handlers call into `B`'s primitives. A backend extension hosts its ops here — the server-side +/// counterpart of the client building `OperationIr::Custom`. Custom ops are served the same way over +/// either transport. +/// +/// ```rust,ignore +/// RemoteServerBuilder::new(devices) +/// .port(3000) +/// .custom_op("fused_matmul_add_relu", |handles, ir, _device| { +/// let ([lhs, rhs, bias], [out]) = ir.as_fixed::<3, 1>(); +/// let lhs = handles.get_float_tensor::(lhs); +/// let rhs = handles.get_float_tensor::(rhs); +/// let bias = handles.get_float_tensor::(bias); +/// let result = ::fused_matmul_add_relu(lhs, rhs, bias); +/// handles.register_float_tensor::(&out.id, result); +/// }) +/// .start(); +/// ``` +pub struct RemoteServerBuilder { + devices: Vec>, + channel: Channel, + custom_ops: CustomOpRegistry, +} + +impl RemoteServerBuilder { + /// Create a new builder hosting the given devices. + /// + /// `devices` is indexed by the device index a client selects at session init; `devices[0]` is + /// the default device. Must be non-empty. Defaults to WebSocket on port `3000` (or Iroh when + /// WebSocket is not compiled in) with no custom ops. + pub fn new(devices: Vec>) -> Self { + Self { + devices, + channel: Channel::default(), + custom_ops: CustomOpRegistry::default(), + } + } + + /// Select the transport (protocol and its configuration). + pub fn channel(mut self, channel: Channel) -> Self { + self.channel = channel; + self + } + + /// Set the port, keeping the WebSocket transport. Convenience over [`channel`](Self::channel). + #[cfg(feature = "websocket")] + pub fn port(mut self, port: u16) -> Self { + self.channel = Channel::WebSocket { port }; + self + } + + /// Register a handler for a [custom operation](burn_ir::OperationIr::Custom), keyed by `id`. + /// + /// The `id` must match the one the client puts in its [`CustomOpIr`]. Chainable; registering + /// the same id twice keeps the last handler. + pub fn custom_op(mut self, id: &str, handler: F) -> Self + where + F: Fn(&mut HandleContainer, &CustomOpIr, &B::Device) + Send + Sync + 'static, + { + self.custom_ops.register(id, handler); + self + } + + /// Replace the custom-op handlers with a prebuilt [`CustomOpRegistry`]. + pub fn custom_ops(mut self, custom_ops: CustomOpRegistry) -> Self { + self.custom_ops = custom_ops; + self + } + + /// Start the server on the caller's async runtime, serving until shutdown. + #[cfg(not(target_family = "wasm"))] + pub async fn start_async(self) { + // The backend is hosted on an async runtime: tensor readbacks must materialize + // eagerly rather than deferring a blocking device→host copy onto an executor worker. + burn_std::set_runtime_kind(burn_std::RuntimeKind::Async); + + match self.channel { + #[cfg(feature = "websocket")] + Channel::WebSocket { port } => { + crate::transport::websocket::start_websocket_async::( + self.devices, + port, + self.custom_ops, + ) + .await; + } + #[cfg(feature = "iroh")] + Channel::Iroh { secret } => { + crate::transport::iroh::server::start_iroh_async::( + *secret, + self.devices, + self.custom_ops, + ) + .await; + } + } + } + + /// Start the server, blocking the current thread until shutdown. + #[cfg(not(target_family = "wasm"))] + pub fn start(self) { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build the tokio runtime"); + runtime.block_on(self.start_async()); + } +} diff --git a/crates/burn-remote/src/server/local_comm.rs b/crates/burn-remote/src/server/local_comm.rs new file mode 100644 index 0000000..82c058f --- /dev/null +++ b/crates/burn-remote/src/server/local_comm.rs @@ -0,0 +1,93 @@ +//! In-process communication between two sessions hosted by the same server. +//! +//! Whenever sessions on the **same** server (same address, different device index) need to +//! hand device-resident data to each other, there is no reason to round-trip through the host: +//! both sessions' [`TensorInterpreter`](burn_router::TensorInterpreter)s live in the same +//! process, so the source can hand its primitive straight to the target, which moves it onto +//! its own device with the inner backend's `to_device`. This is the same-host counterpart of the +//! cross-server tensor transfer, which moves data *between* servers over the network. +//! +//! Today this backs cross-device `to_device`, but the mechanism is general: any same-host +//! collective (e.g. all-reduce) that needs ranks to exchange primitives uses the same +//! expose/take rendezvous. +//! +//! This registry is the rendezvous point. The source session exposes a primitive under a +//! [`LocalTransferId`]; the target session takes it. Either may arrive first — the source op +//! and the target op travel on separate connections with no cross-connection ordering — so the +//! taker waits on a [`Notify`] until the primitive shows up. + +use crate::shared::{LocalTransferId, SessionId}; +use burn_ir::{BackendIr, HandleKind}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, Notify}; + +/// A primitive parked in the registry, tagged with the session that exposed it so the entry can +/// be reclaimed if that session goes away before its target takes it. +struct Pending { + source: SessionId, + tensor: HandleKind, +} + +/// Rendezvous registry for same-host communication, keyed by [`LocalTransferId`]. +pub(crate) struct LocalCommService { + /// Primitives exposed by source sessions, waiting to be taken by their target. + pending: Mutex>>, + /// Wakes takers whenever a new primitive is exposed. + notify: Arc, +} + +impl LocalCommService { + pub fn new() -> Self { + Self { + pending: Mutex::new(HashMap::new()), + notify: Arc::new(Notify::new()), + } + } + + /// Expose `tensor` under `transfer_id`, waking any session already waiting for it. `source` is + /// the session exposing it, so a stranded entry can be purged if that session closes before its + /// target takes the tensor (see [`purge_session`](Self::purge_session)). + pub async fn expose( + &self, + source: SessionId, + transfer_id: LocalTransferId, + tensor: HandleKind, + ) { + self.pending + .lock() + .await + .insert(transfer_id, Pending { source, tensor }); + self.notify.notify_waiters(); + } + + /// Take the primitive exposed under `transfer_id`, waiting until it is exposed. + pub async fn take(&self, transfer_id: LocalTransferId) -> HandleKind { + loop { + // Register interest *before* checking the map so an `expose` that lands between + // the check and the await can't slip through the gap and leave us parked forever. + let notified = self.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + if let Some(pending) = self.pending.lock().await.remove(&transfer_id) { + return pending.tensor; + } + + notified.await; + } + } + + /// Drop every primitive `session` exposed that no target ever took. + /// + /// Called when a session closes. In normal operation every exposed primitive is taken (and so + /// removed) by its target, but if the source session tears down first — a crash, a dropped + /// connection, a transfer whose target never runs — its entry would otherwise sit in the map + /// for the server's lifetime, pinning device memory. Purging by source session reclaims it. + pub async fn purge_session(&self, session: SessionId) { + self.pending + .lock() + .await + .retain(|_, pending| pending.source != session); + } +} diff --git a/crates/burn-remote/src/server/mod.rs b/crates/burn-remote/src/server/mod.rs new file mode 100644 index 0000000..1452181 --- /dev/null +++ b/crates/burn-remote/src/server/mod.rs @@ -0,0 +1,17 @@ +pub(crate) mod local_comm; +pub(crate) mod pump; +pub(crate) mod service; +pub(crate) mod session; +pub(crate) mod spawn; +pub(crate) mod transfer; +pub(crate) mod worker; + +mod builder; + +pub use builder::{Channel, RemoteServerBuilder}; +pub use burn_router::{CustomOpHandler, CustomOpRegistry}; + +#[cfg(feature = "iroh")] +pub use crate::transport::iroh::protocol::{ + AllowAll, AuthorizationRequest, IrohRemoteProtocol, PeerAuthorizer, RemoteProtocol, +}; diff --git a/crates/burn-remote/src/server/pump.rs b/crates/burn-remote/src/server/pump.rs new file mode 100644 index 0000000..4c64fae --- /dev/null +++ b/crates/burn-remote/src/server/pump.rs @@ -0,0 +1,143 @@ +//! The transport-agnostic session pump. +//! +//! One session is one duplex link. [`drive_session`] reads the init handshake, authorizes the peer, +//! binds the session, replies with the device settings, then concurrently drains task responses to +//! the sink (a detached writer) while forwarding submitted task batches from the source to the +//! session worker. This is the single implementation both transports (iroh, websocket) drive — the +//! per-transport modules only build the [`FrameSource`]/[`FrameSink`] halves and the authorizer. + +use std::sync::Arc; + +use crate::PeerId; +use crate::server::service::{SessionService, parse_init_handshake}; +use crate::server::spawn::spawn_detached; +use crate::shared::{ + PROTOCOL_VERSION, RemoteMessage, SessionInfo, SessionInit, TaskResponse, TaskResponseContent, +}; +use crate::transport::link::{FrameSink, FrameSource}; + +/// Drive one session to completion over a duplex link. +/// +/// `authorize` runs once, after the init handshake is parsed and before the session is bound — it +/// is where a transport with an authenticated peer identity (iroh) enforces its policy; transports +/// without one (websocket) pass an allow-all closure. `server_peer_id` is echoed to the client in +/// the handshake response (the server's own identity, or `None` for websocket). +/// +/// Returns `Err` on a protocol violation or a transport error; the caller logs it. A clean client +/// `Close` (or stream end) returns `Ok(())`. +pub(crate) async fn drive_session( + mut source: Src, + mut sink: Snk, + service: Arc, + server_peer_id: Option, + authorize: A, +) -> Result<(), String> +where + Src: FrameSource, + Snk: FrameSink, + S: SessionService, + A: FnOnce(&SessionInit) -> Result<(), String>, +{ + // The session stream opens with exactly one `Init` frame. + let handshake = source + .recv() + .await? + .ok_or_else(|| "Session stream closed before initialization".to_string())?; + let init = parse_init_handshake(&handshake)?; + + // Authorize before any session state is created. + authorize(&init)?; + + // Bind the session (creating it + its worker on demand) and claim its response receiver. + let task_sender = service + .session_task_sender(init.session_id, init.device_index) + .await; + let mut responses = service + .take_response_receiver(init.session_id, init.device_index) + .await?; + + // Reply with the selected device's settings + this server's identity, so the client can fill in + // `RemoteDevice::defaults`/`enumerate` without an extra round-trip. + let info = TaskResponse { + id: 0, + content: TaskResponseContent::Init(SessionInfo { + version: PROTOCOL_VERSION, + settings: service.device_settings(init.device_index), + device_count: service.device_count(), + peer_id: server_peer_id, + }), + }; + let info = rmp_serde::to_vec(&info) + .map_err(|err| format!("Failed to encode session handshake response: {err}"))?; + sink.send(info.into()).await?; + + // Detached writer: drain the session's responses onto the sink until the queue closes (every + // sender — the worker and any in-flight readback task — has dropped). + let (writer_done, writer_result) = tokio::sync::oneshot::channel(); + spawn_detached(async move { + let result = async { + while let Some(response) = responses.recv().await { + let bytes = rmp_serde::to_vec(&response) + .map_err(|err| format!("Failed to encode task response: {err}"))?; + sink.send(bytes.into()).await?; + } + sink.close().await + } + .await; + let _ = writer_done.send(result); + }); + + // Reader loop: forward each submitted task batch to the session worker in arrival order. + let result = loop { + let Some(frame) = source.recv().await? else { + break Ok(()); + }; + let messages: Vec = rmp_serde::from_slice(&frame) + .map_err(|err| format!("Invalid remote task batch: {err}"))?; + let mut close = false; + let mut protocol_error = None; + for message in messages { + match message { + RemoteMessage::Task(task) => { + task_sender + .send(task) + .await + .map_err(|_| "Session worker stopped".to_string())?; + } + RemoteMessage::Close(id) if id == init.session_id => { + close = true; + break; + } + RemoteMessage::Close(id) => { + protocol_error = Some(format!( + "Session {} attempted to close unrelated session {id}", + init.session_id + )); + break; + } + RemoteMessage::Init(_) => { + protocol_error = Some("A session stream cannot be initialized twice".into()); + break; + } + } + } + if let Some(err) = protocol_error { + break Err(err); + } + if close { + break Ok(()); + } + }; + + // Teardown: drop our task sender and close the session so its worker drains and exits, which + // closes the response queue and ends the writer; then await the writer so we don't tear the + // runtime down mid-send. + drop(task_sender); + service.close(init.session_id).await; + match writer_result.await { + Ok(Ok(())) => {} + Ok(Err(err)) => log::warn!("Session response writer failed: {err}"), + Err(_) => log::warn!("Session response writer stopped before finishing"), + } + result +} diff --git a/crates/burn-remote/src/server/service/base.rs b/crates/burn-remote/src/server/service/base.rs new file mode 100644 index 0000000..4da3b4f --- /dev/null +++ b/crates/burn-remote/src/server/service/base.rs @@ -0,0 +1,72 @@ +//! Shared helpers for the connection handlers. + +use crate::shared::{PROTOCOL_VERSION, RemoteMessage, SessionInit}; + +/// Decode the single `RemoteMessage::Init` a fresh submit (or fetch) socket opens with. +/// +/// The handshake frame must hold exactly one message and it must be an `Init`; anything else is +/// a protocol error. +pub(crate) fn parse_init_handshake(bytes: &[u8]) -> Result { + let mut messages = rmp_serde::from_slice::>(bytes) + .map_err(|err| format!("Failed to decode init handshake: {err:?}"))?; + + match messages.pop() { + Some(RemoteMessage::Init(init)) if messages.is_empty() => { + if init.version != PROTOCOL_VERSION { + return Err(format!( + "Unsupported Burn Remote protocol version {} (expected {PROTOCOL_VERSION})", + init.version + )); + } + Ok(init) + } + other => Err(format!( + "Init handshake expected a single RemoteMessage::Init, got {other:?}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::SessionId; + + fn encode(messages: &[RemoteMessage]) -> Vec { + rmp_serde::to_vec(messages).unwrap() + } + + #[test] + fn handshake_accepts_a_single_init() { + let id = SessionId::new(); + let bytes = encode(&[RemoteMessage::Init(SessionInit::new(id, 3, vec![]))]); + let init = parse_init_handshake(&bytes).unwrap(); + assert_eq!(init.session_id, id); + assert_eq!(init.device_index, 3); + } + + #[test] + fn handshake_rejects_an_empty_batch() { + assert!(parse_init_handshake(&encode(&[])).is_err()); + } + + #[test] + fn handshake_rejects_more_than_one_message() { + let id = SessionId::new(); + let bytes = encode(&[ + RemoteMessage::Init(SessionInit::new(id, 0, vec![])), + RemoteMessage::Close(id), + ]); + assert!(parse_init_handshake(&bytes).is_err()); + } + + #[test] + fn handshake_rejects_a_non_init() { + let id = SessionId::new(); + assert!(parse_init_handshake(&encode(&[RemoteMessage::Close(id)])).is_err()); + } + + #[test] + fn handshake_rejects_garbage() { + assert!(parse_init_handshake(&[0xff, 0x00, 0x13]).is_err()); + } +} diff --git a/crates/burn-remote/src/server/service/mod.rs b/crates/burn-remote/src/server/service/mod.rs new file mode 100644 index 0000000..91b1017 --- /dev/null +++ b/crates/burn-remote/src/server/service/mod.rs @@ -0,0 +1,52 @@ +//! The session-layer service trait, plus the init-handshake helper shared by the session pump. +//! +//! The pump ([`drive_session`](crate::server::pump::drive_session)) is written against +//! [`SessionService`] rather than a concrete type, so the submit-loop teardown logic stays testable +//! against a fake service with no backend and no live socket. + +mod base; + +pub(crate) use base::parse_init_handshake; + +use std::future::Future; + +use burn_std::DeviceSettings; +use tokio::sync::mpsc; + +use crate::shared::{SessionId, Task, TaskResponse}; + +/// What the session pump needs from the session layer: bind a session to its worker channel, claim +/// its response receiver, read the device metadata for the handshake, and tear a session down. +/// +/// Async methods return `impl Future + Send` so a session future built on them stays `Send` and can +/// be spawned by the server. The production implementation is +/// [`SessionManager`](super::session::SessionManager). +pub(crate) trait SessionService: Send + Sync + 'static { + /// The channel forwarding tasks to `session_id`'s worker, creating the session (and spawning + /// its worker) on demand. + fn session_task_sender( + &self, + session_id: SessionId, + device_index: u32, + ) -> impl Future> + Send; + + /// Claim the session's result receiver. Errors if a receiver was already taken — the protocol + /// allows only one session stream per session. + fn take_response_receiver( + &self, + session_id: SessionId, + device_index: u32, + ) -> impl Future, String>> + Send; + + /// The default settings of the device at `device_index`, returned on the handshake so the + /// client can resolve op dtypes without an extra round-trip. + fn device_settings(&self, device_index: u32) -> DeviceSettings; + + /// The number of devices this server hosts, returned on the handshake so the client can + /// enumerate every device behind the address. + fn device_count(&self) -> u32; + + /// Drop the session, letting its worker drain and exit. A `close` for an unknown session is a + /// no-op. + fn close(&self, session_id: SessionId) -> impl Future + Send; +} diff --git a/crates/burn-remote/src/server/session.rs b/crates/burn-remote/src/server/session.rs new file mode 100644 index 0000000..9c8311d --- /dev/null +++ b/crates/burn-remote/src/server/session.rs @@ -0,0 +1,222 @@ +use burn_backend::tensor::Device; +use burn_ir::BackendIr; +use burn_router::{CustomOpRegistry, TensorInterpreter}; +use std::{ + collections::HashMap, + sync::{Arc, Once}, +}; +use tokio::sync::{Mutex, mpsc}; + +use crate::metrics::{MetricSide, logger_task}; +use crate::server::local_comm::LocalCommService; +use crate::server::service::SessionService; +use crate::server::spawn::spawn_detached; +use crate::server::transfer::TensorTransfer; +use crate::server::worker::SessionHandler; +use crate::shared::{SessionId, Task, TaskResponse}; +use crate::telemetry::{TelemetryEvent, TelemetryProbe}; + +/// Capacity for the per-session response queue. +/// +/// Sized larger than the typical in-flight read/sync count so that request processing +/// doesn't block on backpressure during a burst, but small enough that a stuck response +/// writer surfaces as a backpressure stall rather than memory growth. +const RESPONSE_CHANNEL_CAPACITY: usize = 64; + +/// Coordinates per-session state. +/// +/// Each [`Session`] owns a dedicated [`SessionHandler`] that holds the session's +/// [`TensorInterpreter`] with its own [`HandleContainer`](burn_ir::HandleContainer) — different +/// sessions never share tensor handles, so concurrent sessions can't race on each other's backend +/// state. Cross-session tensor transfers go through `external_comm` (cross-server) or `local_comm` +/// (same-host), each of which has its own rendezvous. +/// +/// Tasks run on the handler's worker threads, not the submit handler: the latter only decodes the +/// incoming batch and forwards each [`Task`] to the session over a bounded channel. Inside the +/// session a dispatcher routes each task to a per-stream worker thread, so per-stream ordering is +/// preserved while independent streams — and other sessions — keep making progress even when one +/// stream is parked on a blocking op (a same-host transfer rendezvous or an all-reduce barrier). +pub struct SessionManager +where + B: BackendIr, + T: TensorTransfer, +{ + /// All devices this server hosts, indexed by the device index the client selects at + /// session init. `devices[0]` is the default device (`DeviceIndex::Default`). + devices: Vec>, + pub(crate) transfer: Arc, + /// Rendezvous registry for same-host tensor transfers between this server's sessions. + pub(crate) local_comm: Arc>, + /// Custom-op handlers shared (read-only) with every session's interpreter. + custom_ops: CustomOpRegistry, + sessions: Mutex>, + probe: TelemetryProbe, + /// Spawns the telemetry logger once, on the first session. + logger: Once, +} + +struct Session { + /// Inbound channel to the session's dispatcher thread; cloned once per submit connection. + task_sender: mpsc::Sender, + receiver: Option>, +} + +impl SessionManager +where + B: BackendIr, + T: TensorTransfer, +{ + pub fn new(devices: Vec>, transfer: Arc) -> Self { + assert!( + !devices.is_empty(), + "A remote server must host at least one device" + ); + Self { + devices, + transfer, + local_comm: Arc::new(LocalCommService::new()), + custom_ops: CustomOpRegistry::default(), + sessions: Mutex::new(HashMap::new()), + probe: TelemetryProbe::disabled(), + logger: Once::new(), + } + } + + /// Register custom-op handlers, shared read-only with every session's interpreter. + pub fn with_custom_ops(mut self, custom_ops: CustomOpRegistry) -> Self { + self.custom_ops = custom_ops; + self + } + + /// Emit telemetry into `probe`. + pub fn with_telemetry(mut self, probe: TelemetryProbe) -> Self { + self.probe = probe; + self + } + + fn ensure_logger(&self) { + self.logger.call_once(|| { + if let Some(task) = logger_task(&self.probe, MetricSide::Server) { + spawn_detached(task); + } + }); + } + + /// Resolve the device at `device_index`. + /// + /// The index is validated against the server's device count on the client init handshake, so + /// an out-of-range index here is a protocol/configuration error (e.g. a client enumerating + /// more devices than this server hosts). Fail loudly rather than silently collapsing onto + /// device 0 — for a collective that would reduce a device against itself and silently corrupt + /// the result instead of producing a clear failure. + pub(crate) fn device(&self, device_index: u32) -> Device { + self.devices + .get(device_index as usize) + .cloned() + .unwrap_or_else(|| { + panic!( + "Requested device index {device_index} but server hosts only {} device(s)", + self.devices.len() + ) + }) + } + + async fn with_session( + &self, + session_id: SessionId, + device_index: u32, + f: impl FnOnce(&mut Session) -> R, + ) -> R { + self.ensure_logger(); + let mut sessions = self.sessions.lock().await; + let entry = sessions.entry(session_id).or_insert_with(|| { + let (sender, receiver) = mpsc::channel(RESPONSE_CHANNEL_CAPACITY); + // The session is pinned to its device for its whole lifetime; the first handler + // (request or response) to touch it fixes the device index. Spawn the handler that + // owns the runner — this runs inside the tokio runtime, so the handler can capture + // the runtime handle its worker threads need for the async parts of a task. + let runner = TensorInterpreter::with_custom_ops( + self.device(device_index), + self.custom_ops.clone(), + ); + let task_sender = SessionHandler::spawn( + session_id, + runner, + sender, + self.transfer.clone(), + self.local_comm.clone(), + self.probe.clone(), + ); + self.probe.emit(|| TelemetryEvent::SessionOpened { + session: session_id, + device: device_index, + }); + Session { + task_sender, + receiver: Some(receiver), + } + }); + f(entry) + } +} + +impl SessionService for SessionManager +where + B: BackendIr, + T: TensorTransfer, +{ + /// Resolve the channel used to forward [`Task`]s to `session_id`'s dispatcher thread, + /// creating the session (and spawning its handler) on demand. The pump resolves this once and + /// reuses it for every task, instead of re-locking the sessions map per task. + async fn session_task_sender( + &self, + session_id: SessionId, + device_index: u32, + ) -> mpsc::Sender { + self.with_session(session_id, device_index, |s| s.task_sender.clone()) + .await + } + + /// Take the response receiver for `session_id`. + /// + /// Returns `Err` if a responder has already been registered for this session — the protocol + /// allows only one session stream per session. + async fn take_response_receiver( + &self, + session_id: SessionId, + device_index: u32, + ) -> Result, String> { + self.with_session(session_id, device_index, |s| { + s.receiver + .take() + .ok_or_else(|| format!("Response receiver already taken for session {session_id}")) + }) + .await + } + + /// The device settings for `device_index`, used by the handshake before any session-specific + /// runner is needed. + fn device_settings(&self, device_index: u32) -> burn_std::DeviceSettings { + use burn_backend::backend::DeviceOps; + self.device(device_index).defaults() + } + + /// The total number of devices this server hosts. Sent to the client on the init handshake so + /// it can enumerate every device behind the address (see [`RemoteDevice::enumerate`]). + fn device_count(&self) -> u32 { + self.devices.len() as u32 + } + + /// Drop the session, detaching its handler. Removing the map entry drops the inbound task + /// sender it holds; once the pump also drops its cloned task sender, the dispatcher's channel + /// closes, so the per-stream workers drain and exit and the handler flushes its runner, + /// releasing any backend state held only by this session's tensors. + async fn close(&self, session_id: SessionId) { + let mut sessions = self.sessions.lock().await; + if sessions.remove(&session_id).is_some() { + self.probe.emit(|| TelemetryEvent::SessionClosed { + session: session_id, + }); + } + } +} diff --git a/crates/burn-remote/src/server/spawn.rs b/crates/burn-remote/src/server/spawn.rs new file mode 100644 index 0000000..c819d0f --- /dev/null +++ b/crates/burn-remote/src/server/spawn.rs @@ -0,0 +1,47 @@ +use core::future::Future; + +#[cfg(not(target_family = "wasm"))] +pub(crate) fn spawn_detached(future: F) +where + F: Future + Send + 'static, +{ + tokio::spawn(future); +} + +#[cfg(target_family = "wasm")] +pub(crate) fn spawn_detached(future: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(future); +} + +/// Resolve when the process is asked to stop (Ctrl+C, or `SIGTERM` on Unix). +/// +/// The single shutdown trigger shared by the turnkey WebSocket and Iroh server entry points. +#[cfg(all( + not(target_family = "wasm"), + any(feature = "websocket", feature = "iroh") +))] +pub(crate) async fn os_shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/crates/burn-remote/src/server/transfer.rs b/crates/burn-remote/src/server/transfer.rs new file mode 100644 index 0000000..8671c7c --- /dev/null +++ b/crates/burn-remote/src/server/transfer.rs @@ -0,0 +1,40 @@ +//! The tensor-transfer seam between the session worker and a transport. +//! +//! Server-to-server tensor movement is independent from the compute-session transport: a tensor is +//! *exposed* on the source server and *downloaded* by the target server without routing the data +//! through the controlling client. Each transport implements this its own way — +//! [`IrohTransfer`](crate::transport::iroh::IrohTransfer) over authenticated Iroh streams, +//! [`WebSocketTransfer`](crate::transport::websocket::WebSocketTransfer) over the legacy data +//! service — and the session worker drives it through this trait, knowing nothing about the wire. + +use std::future::Future; + +use burn_backend::TensorData; +use burn_ir::BackendIr; + +use crate::shared::TransferCapability; +use crate::{PeerAddr, PeerId}; + +/// Server-to-server tensor movement, independent from the compute-session transport. +pub(crate) trait TensorTransfer: Send + Sync + 'static { + fn expose_data( + &self, + data: TensorData, + max_downloads: u32, + capability: TransferCapability, + target: PeerId, + ) -> impl Future + Send; + + fn download_tensor( + &self, + remote: PeerAddr, + capability: TransferCapability, + ) -> impl Future> + Send; + + fn fail( + &self, + capability: TransferCapability, + target: PeerId, + reason: String, + ) -> impl Future + Send; +} diff --git a/crates/burn-remote/src/server/worker.rs b/crates/burn-remote/src/server/worker.rs new file mode 100644 index 0000000..6479e4d --- /dev/null +++ b/crates/burn-remote/src/server/worker.rs @@ -0,0 +1,457 @@ +//! Per-session handler and its worker thread. +//! +//! A [`SessionHandler`] owns everything that is constant for the lifetime of a session — the +//! session's [`TensorInterpreter`] (with its own [`HandleContainer`](burn_ir::HandleContainer)), +//! the cross-server / same-host comm services, the result sender, the runtime handle and the +//! session id — and exposes a single [`process_task`](SessionHandler::process_task) method that +//! runs one task against that state. +//! +//! The submit handler does no work itself: it decodes the incoming message batch and forwards +//! each [`Task`] to the session's worker over a bounded channel. The worker drives the session's +//! tasks in **global submission order** (a single FIFO), preserving every ordering the protocol +//! relies on — including *cross-stream* ones. A tensor produced on one client stream (say a +//! dataloader thread) and consumed on another (the main thread) is only safe because the producer +//! task is applied before the consumer task; the interpreter looks up input handles eagerly and +//! panics if one is missing, so the order the client submitted in must be preserved verbatim. +//! Per-stream parallelism would break exactly this, so the stream id on a task is used only to set +//! the backend's thread-local stream (via [`StreamId::executes`]), not to reorder work. +//! +//! ## Why a dedicated OS thread, not a tokio task +//! +//! Some tasks are **synchronously blocking** — a same-host transfer's +//! [`RegisterTensorLocal`](crate::shared::Task::RegisterTensorLocal) waits on the rendezvous +//! (`local_comm.take`) for its source session to expose the primitive, and a collective op +//! (all-reduce / sync-collective) parks until *every* participating device reaches the barrier. +//! Running those on a shared tokio worker would tie up a runtime thread; once more devices are +//! blocked than the runtime has workers, the remaining devices can never be scheduled to reach the +//! barrier and it deadlocks. Giving each session its own OS thread keeps that blocking off the +//! shared runtime, so a barrier (or rendezvous) on one session can't stall another session's +//! worker or a runtime thread. +//! +//! In the browser the worker instead runs on the JS event loop via `spawn_local`, so a browser peer +//! cannot host co-located collective / same-host-transfer participants (the single thread would +//! stall on the blocking tasks above). Ordinary compute is unaffected. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use burn_ir::{BackendIr, GraphBindings, GraphId}; +use burn_router::{Graph, TensorInterpreter}; +use burn_std::id::StreamId; +#[cfg(not(target_family = "wasm"))] +use tokio::runtime::Handle; +use tokio::sync::mpsc; + +use crate::server::local_comm::LocalCommService; +use crate::server::spawn::spawn_detached; +use crate::server::transfer::TensorTransfer; +use crate::shared::{RequestId, SessionId, Task, TaskResponse, TaskResponseContent}; +use crate::telemetry::{ + TelemetryEvent, TelemetryProbe, TransferPhase, TransferScope, serialized_len, +}; +use burn_ir::OperationIr; + +/// Capacity of the per-session task channel feeding the worker thread. +/// +/// The submit handler forwards with an `await`ing send, so a full channel applies async +/// backpressure (the submit task yields and stops reading the socket) rather than blocking an OS +/// thread or growing memory without bound. Sized so a burst of fire-and-forget ops doesn't stall +/// the submit loop while the worker is mid-task. +const TASK_CHANNEL_CAPACITY: usize = 64; + +/// Everything constant for the lifetime of a session. +/// +/// Owned by the session's worker thread, which runs every task against this one interpreter and +/// these comm services. Held behind an [`Arc`] only so detached readback tasks can be spawned with +/// a clone of the result sender; the interpreter itself is single-owner here. +pub(crate) struct SessionHandler +where + B: BackendIr, + T: TensorTransfer, +{ + session_id: SessionId, + runner: TensorInterpreter, + response_sender: mpsc::Sender, + transfer: Arc, + local_comm: Arc>, + /// Cache of client-registered reusable op-graphs, keyed by id (see + /// [`Task::RegisterAndExecuteGraph`]). + /// + /// `Mutex` only for interior mutability under `process_task(&self)`. Each graph is held behind + /// an [`Arc`] so a replay clones the handle out under a short lock and runs `replay_graph` + /// *after* releasing it — the lock guards only the map, never the backend dispatch. That keeps + /// the cache from becoming a serialization point if graph execution is ever driven from more + /// than the current single-FIFO worker. + graphs: Mutex>, + probe: TelemetryProbe, +} + +impl SessionHandler +where + B: BackendIr, + T: TensorTransfer, +{ + /// Start the session worker; the worker runs until every clone of the returned sender is + /// dropped, then flushes and exits. + pub(crate) fn spawn( + session_id: SessionId, + runner: TensorInterpreter, + response_sender: mpsc::Sender, + transfer: Arc, + local_comm: Arc>, + probe: TelemetryProbe, + ) -> mpsc::Sender { + let handler = SessionHandler { + session_id, + runner, + response_sender, + transfer, + local_comm, + graphs: Mutex::new(HashMap::new()), + probe, + }; + let (sender, receiver) = mpsc::channel(TASK_CHANNEL_CAPACITY); + handler.drive(receiver); + sender + } + + // Must be called from within the runtime that owns the endpoint: it captures `Handle::current`. + #[cfg(not(target_family = "wasm"))] + fn drive(self, receiver: mpsc::Receiver) { + let handle = Handle::current(); + let session_id = self.session_id; + std::thread::Builder::new() + .name(format!("burn-remote-session-{session_id}")) + .spawn(move || handle.block_on(self.run(receiver))) + .expect("Failed to spawn session worker thread"); + } + + #[cfg(target_family = "wasm")] + fn drive(self, receiver: mpsc::Receiver) { + spawn_detached(self.run(receiver)); + } + + /// Drain the task channel, running each task to completion in arrival order, then tear the + /// session down in an order that releases its memory. + async fn run(mut self, mut receiver: mpsc::Receiver) { + let session_id = self.session_id; + + log::debug!("Session {session_id} worker started"); + while let Some(task) = receiver.recv().await { + if let Err(err) = self.process_task(task).await { + // One task failing doesn't tear down the session: read/sync/dtype failures surface + // to the client through their response, fire-and-forget failures are logged here, + // and the worker keeps processing subsequent tasks. + log::error!("Task on session {session_id} failed: {err}"); + } + } + + // Reclaim any same-host transfers this session exposed that no target ever took, so a + // half-finished transfer doesn't strand device memory in the shared registry. + self.local_comm.purge_session(session_id).await; + + // The task channel closed: every submit connection bound to this session has gone away + // (clean `Close` or disconnect). + log::debug!("Session {session_id} worker draining and exiting"); + + // Destructure so we control drop order explicitly. The `..` fields (response sender, comm + // services) drop here: dropping the response sender closes the fetch writer's queue, ending + // its task. + let SessionHandler { runner, .. } = self; + let device = runner.device(); + + // Flush outstanding backend work before dropping the runner so the session's tensors aren't + // freed with GPU work still queued against them. + if let Err(err) = runner.sync() { + log::warn!("runner.sync() at session {session_id} close failed: {err:?}"); + } + if let Err(err) = B::sync(&device) { + log::warn!("B::sync(device) at session {session_id} close failed: {err:?}"); + } + + // Drop the runner: this frees every tensor handle the session held back to the backend's + // allocator. Must happen before `memory_cleanup`, otherwise the memory is still live and + // nothing is reclaimable. + drop(runner); + + // Hand the freed memory back instead of leaving it parked in the allocator's pool for a + // session that no longer exists. A no-op on backends that don't pool (the `Backend` + // default), but on cubecl backends (wgpu/cuda) this returns the session's device memory so + // a long-lived server doesn't accumulate it across session churn. + B::memory_cleanup(&device); + } + + /// Execute a single [`Task`] against this session's state. + /// + /// Sync work is wrapped in [`StreamId::executes`](burn_std::id::StreamId::executes) so the + /// runner's thread-local stream id matches the one the client assigned to this op. + /// Response-producing tasks carry their own [`RequestId`] for routing the response back to the + /// right pending callback on the client. Async work (data-service transfers, + /// `read_tensor_async`) runs without a stream context — the relevant stream id is captured into + /// the future at construction time via `executes`. + async fn process_task(&mut self, task: Task) -> Result<(), String> { + match task { + Task::RegisterOperation(stream_id, op) => { + // An op received individually (not as part of a cached graph) is an unfused op. + self.emit_op(stream_id, &op); + stream_id.executes(|| self.runner.register_op(op)); + Ok(()) + } + Task::RegisterAndExecuteGraph { + stream_id, + graph_id, + relative_graph, + bindings, + } => { + self.probe.emit(|| { + TelemetryEvent::graph_registered( + self.session_id, + graph_id, + &relative_graph, + serialized_len(&relative_graph), + ) + }); + self.emit_graph_executed(graph_id, stream_id, &bindings); + stream_id.executes(|| { + let graph = Graph::new(relative_graph); + self.graphs.lock().unwrap().insert(graph_id, graph.clone()); + graph.replay(&mut self.runner, bindings); + }); + Ok(()) + } + Task::ExecuteGraph { + stream_id, + graph_id, + bindings, + } => { + let graph = { + let cache = self.graphs.lock().unwrap(); + cache + .get(&graph_id) + .cloned() + .ok_or_else(|| format!("Execute of unknown graph {graph_id:?}"))? + }; + self.emit_graph_executed(graph_id, stream_id, &bindings); + stream_id.executes(|| graph.replay(&mut self.runner, bindings)); + Ok(()) + } + Task::RegisterTensor(stream_id, id, data) => { + stream_id.executes(|| self.runner.register_tensor_data_id(id, data)); + Ok(()) + } + Task::RegisterAlias { + stream_id, + new_id, + src_id, + } => { + stream_id.executes(|| self.runner.register_alias(new_id, src_id)); + Ok(()) + } + Task::RegisterTensorRemote(stream_id, remote, new_id) => { + log::trace!( + "Registering remote tensor (transfer {:?} from {:?})", + remote.capability, + remote.peer, + ); + let peer = Some(format!("{:?}", remote.peer)); + self.emit_transfer(peer.clone(), TransferScope::Remote, TransferPhase::Started); + let data = match self + .transfer + .download_tensor(remote.peer.clone(), remote.capability) + .await + { + Some(data) => { + self.emit_transfer(peer, TransferScope::Remote, TransferPhase::Completed); + data + } + None => { + self.emit_transfer(peer, TransferScope::Remote, TransferPhase::Failed); + return Err(format!( + "Failed to download tensor for transfer {:?} from {:?}", + remote.capability, remote.peer, + )); + } + }; + // Register on the client stream that will consume `new_id`, carried over the + // wire — not the arbitrary tokio worker running this task. + stream_id.executes(|| self.runner.register_tensor_data_id(new_id, data)); + Ok(()) + } + Task::ExposeTensorLocal { + stream_id, + tensor, + transfer_id, + } => { + // Source side of a same-host transfer. Grab the device-resident primitive + // (no host readback) and park it in the registry for the target session to + // pick up. Runs in order on this session's worker, so it is ordered after the op + // that produced `tensor` — the handle is guaranteed present. Read it back on the + // client stream that produced it, carried over the wire. + let kind = stream_id.executes(|| self.runner.get_tensor(&tensor)); + self.local_comm + .expose(self.session_id, transfer_id, kind) + .await; + Ok(()) + } + Task::RegisterTensorLocal { + stream_id, + transfer_id, + new_id, + } => { + // Target side of a same-host transfer. Wait for the source to expose the + // primitive, then move it onto this session's device and register it. Awaited + // in order so subsequent ops on this session that consume `new_id` see it + // registered first — same ordering contract as `RegisterTensorRemote`. The wait + // blocks only this session's worker, not the source session's. + let kind = self.local_comm.take(transfer_id).await; + stream_id.executes(|| self.runner.register_tensor_to_device(new_id, kind)); + self.emit_transfer(None, TransferScope::Local, TransferPhase::Completed); + Ok(()) + } + Task::ExposeTensorRemote { + stream_id, + tensor, + count, + capability, + target, + } => { + log::trace!("Exposing tensor (transfer {capability:?})"); + // Same shape as `ReadTensor`: the sync part of `read_tensor_async` runs in order + // to preserve stream ordering, but the readback + expose are detached so a + // cross-server hand-off doesn't stall this session's op registration on a + // GPU→host copy. A target that downloads before the expose lands simply blocks on + // the data service's `new_tensor_notify`, so there is no race. + let fut = stream_id.executes(|| self.runner.read_tensor_async(tensor)); + let transfer = self.transfer.clone(); + let probe = self.probe.clone(); + let session_id = self.session_id; + let peer = Some(format!("{target:?}")); + probe.emit(|| TelemetryEvent::Transfer { + session: session_id, + peer: peer.clone(), + scope: TransferScope::Remote, + phase: TransferPhase::Started, + }); + spawn_detached(async move { + match fut.await { + Ok(data) => { + transfer.expose_data(data, count, capability, target).await; + probe.emit(|| TelemetryEvent::Transfer { + session: session_id, + peer, + scope: TransferScope::Remote, + phase: TransferPhase::Completed, + }); + } + Err(e) => { + let reason = format!( + "read_tensor_async for transfer {capability:?} failed: {e:?}" + ); + log::error!( + "read_tensor_async for transfer {capability:?} failed: {e:?}" + ); + transfer.fail(capability, target, reason).await; + probe.emit(|| TelemetryEvent::Transfer { + session: session_id, + peer, + scope: TransferScope::Remote, + phase: TransferPhase::Failed, + }); + } + } + }); + Ok(()) + } + Task::Seed(seed) => { + self.runner.seed(seed); + Ok(()) + } + Task::ReadTensor(request_id, stream_id, tensor) => { + // `read_tensor_async` is sync at construction — it locks the context and + // captures the tensor's position in the command stream — and returns a future + // for the actual host readback. Run the sync part in order (so ordering vs. later + // ops is preserved), then detach the readback await onto its own task. Awaiting it + // here would stall the worker on the GPU→host copy and stop us registering + // subsequent ops, draining the device queue into a bubble. The client demuxes + // responses by request id, so out-of-order completion is fine. + self.probe.emit(|| TelemetryEvent::Read { + session: self.session_id, + request: request_id, + }); + let fut = stream_id.executes(|| self.runner.read_tensor_async(tensor)); + let sender = self.response_sender.clone(); + spawn_detached(async move { + // Under an async runtime the backend reads eagerly (see `RuntimeKind::Async`), + // so `data` is already host-resident here — the fetch handler only serializes + // resident bytes and no blocking device→host copy runs on the shared runtime. + let data = fut.await; + if sender + .send(TaskResponse { + content: TaskResponseContent::ReadTensor(data), + id: request_id, + }) + .await + .is_err() + { + log::warn!( + "Response receiver dropped before read for request {request_id} could be sent" + ); + } + }); + Ok(()) + } + Task::SyncBackend(request_id, stream_id) => { + self.probe.emit(|| TelemetryEvent::Sync { + session: self.session_id, + request: request_id, + }); + let res = stream_id.executes(|| self.runner.sync()); + self.send_response(request_id, TaskResponseContent::SyncBackend(res)) + .await + } + Task::DTypeUsage(request_id, dtype) => { + let res = self.runner.dtype_usage(dtype); + self.send_response(request_id, TaskResponseContent::DTypeUsage(res)) + .await + } + } + } + + async fn send_response( + &self, + request_id: RequestId, + content: TaskResponseContent, + ) -> Result<(), String> { + self.response_sender + .send(TaskResponse { + content, + id: request_id, + }) + .await + .map_err(|_| { + format!( + "Response receiver dropped before result for request {request_id} could be sent" + ) + }) + } + + fn emit_op(&self, stream: StreamId, op: &OperationIr) { + self.probe + .emit(|| TelemetryEvent::unfused_op(self.session_id, stream, op)); + } + + fn emit_transfer(&self, peer: Option, scope: TransferScope, phase: TransferPhase) { + self.probe.emit(|| TelemetryEvent::Transfer { + session: self.session_id, + peer, + scope, + phase, + }); + } + + fn emit_graph_executed(&self, graph: GraphId, stream: StreamId, bindings: &GraphBindings) { + self.probe.emit(|| { + TelemetryEvent::graph_executed(self.session_id, graph, stream, serialized_len(bindings)) + }); + } +} diff --git a/crates/burn-remote/src/shared/mod.rs b/crates/burn-remote/src/shared/mod.rs new file mode 100644 index 0000000..6e4ad92 --- /dev/null +++ b/crates/burn-remote/src/shared/mod.rs @@ -0,0 +1,4 @@ +mod task; + +#[allow(unused_imports)] +pub(crate) use task::*; diff --git a/crates/burn-remote/src/shared/task.rs b/crates/burn-remote/src/shared/task.rs new file mode 100644 index 0000000..4b36ac6 --- /dev/null +++ b/crates/burn-remote/src/shared/task.rs @@ -0,0 +1,236 @@ +use burn_backend::{DTypeUsageSet, ExecutionError, TensorData}; +use burn_ir::{GraphBindings, GraphId, OperationIr, TensorId, TensorIr}; +use burn_std::{ + DType, DeviceSettings, + id::{IdGenerator, StreamId}, +}; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +use crate::{PeerAddr, PeerId}; + +/// Current Burn Remote application-protocol version. +pub const PROTOCOL_VERSION: u16 = 1; + +/// Routing id for a task whose result is fetched back. +/// +/// Only the result-producing tasks ([`Task::ReadTensor`], [`Task::SyncBackend`], +/// [`Task::DTypeUsage`]) carry a `RequestId`; the server echoes it on its [`TaskResponse`] so +/// the client demultiplexes results back to the right pending callback. Fire-and-forget tasks +/// have no id because no result ever comes back. Collective ops (all-reduce, sync-collective) +/// are plain fire-and-forget [`OperationIr`]s carried by [`Task::RegisterOperation`]. +pub type RequestId = u64; + +/// Process-local rendezvous id for moving tensors between devices on one compute peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct LocalTransferId(u64); + +impl LocalTransferId { + #[cfg(feature = "client")] + pub fn next(&mut self) { + self.0 += 1; + } +} + +impl From for LocalTransferId { + fn from(value: u64) -> Self { + Self(value) + } +} + +/// Unforgeable, bounded-use capability authorizing a peer-to-peer tensor download. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TransferCapability([u8; 32]); + +impl TransferCapability { + #[cfg(feature = "client")] + pub fn random() -> Self { + Self(rand::random()) + } + + /// Deterministic compatibility key for the legacy 64-bit WebSocket transfer service. + #[cfg(feature = "websocket")] + pub(crate) fn legacy_id(self) -> u64 { + u64::from_le_bytes( + self.0[..8] + .try_into() + .expect("slice has exactly eight bytes"), + ) + } +} + +/// Unique identifier that can represent a session. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub struct SessionId { + id: u64, +} + +impl Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SessionId({})", self.id) + } +} + +impl SessionId { + /// Create a new [session id](SessionId). + #[allow(dead_code)] + pub fn new() -> Self { + Self { + id: IdGenerator::generate(), + } + } + + /// The underlying numeric identifier. + pub fn value(&self) -> u64 { + self.id + } +} + +/// A single message on a session's stream: either a session-lifecycle signal (`Init`/`Close`) or a +/// [`Task`] to run. +#[allow(missing_docs, clippy::large_enum_variant)] +#[derive(Serialize, Deserialize, Debug)] +pub enum RemoteMessage { + /// A unit of work to run within the bound session. + Task(Task), + /// Open a session bound to the device at the given index on the server. + Init(SessionInit), + Close(SessionId), +} + +/// Client-side session handshake. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SessionInit { + pub version: u16, + pub session_id: SessionId, + pub device_index: u32, + /// Opaque application credential interpreted by the compute node's authorizer. + #[serde(with = "serde_bytes")] + pub authorization: Vec, +} + +impl SessionInit { + #[cfg(any(feature = "client", test))] + pub fn new(session_id: SessionId, device_index: u32, authorization: Vec) -> Self { + Self { + version: PROTOCOL_VERSION, + session_id, + device_index, + authorization, + } + } +} + +/// Server-side session handshake response. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, Debug)] +pub struct SessionInfo { + pub version: u16, + pub settings: DeviceSettings, + pub device_count: u32, + /// Authenticated identity of the compute node, when the transport provides one. + pub peer_id: Option, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TensorRemote { + pub capability: TransferCapability, + pub peer: PeerAddr, +} + +#[allow(missing_docs, clippy::large_enum_variant)] +#[derive(Serialize, Deserialize, Debug)] +pub enum Task { + Seed(u64), + /// A single [`OperationIr`] tagged with the stream it was issued on. + /// + /// Each op is sent independently — batching across streams would lose stream + /// identity, and the server-side backend (fusion, etc.) handles any batching of + /// its own. + RegisterOperation(StreamId, OperationIr), + /// Register a reusable group of operations under `graph_id` (relative form) *and* immediately + /// execute it with `bindings`. + /// + /// Registering a new graph always coincides with its first execution, so the two are combined + /// into a single task to avoid a redundant round-trip on a cache miss. The server caches the + /// graph (per session) and replays it on every subsequent [`ExecuteGraph`](Task::ExecuteGraph). + /// This is how the fusion-enabled client avoids re-sending a recurring op sequence (e.g. a + /// model block) on every step. + RegisterAndExecuteGraph { + stream_id: StreamId, + graph_id: GraphId, + relative_graph: Vec, + bindings: GraphBindings, + }, + /// Replay an already-registered graph with the given concrete bindings. + ExecuteGraph { + stream_id: StreamId, + graph_id: GraphId, + bindings: GraphBindings, + }, + RegisterTensor(StreamId, TensorId, TensorData), + /// Register `new_id` as an alias of `src_id` — a second server handle over the same buffer. + /// + /// Emitted by the fusion layer's cross-stream sharing so a tensor used on multiple streams + /// gets one server id per stream view, each freeable independently. Ordered after the task + /// that materializes `src_id`, like every other op on the session's FIFO worker. + RegisterAlias { + stream_id: StreamId, + new_id: TensorId, + src_id: TensorId, + }, + RegisterTensorRemote(StreamId, TensorRemote, TensorId), + ExposeTensorRemote { + stream_id: StreamId, + tensor: TensorIr, + count: u32, + capability: TransferCapability, + target: PeerId, + }, + /// Source side of a same-host transfer: hand the device-resident primitive for `tensor` + /// to the server's local comm registry under `transfer_id`. No host readback — the + /// counterpart [`RegisterTensorLocal`](Task::RegisterTensorLocal), running on the + /// target session of the same server, moves it onto the target device via the inner + /// backend's `to_device`. + /// + /// `stream_id` is the client stream that produced `tensor`, so the server reads it back on + /// the same stream the producing ops ran on rather than an arbitrary server thread. + ExposeTensorLocal { + stream_id: StreamId, + tensor: TensorIr, + transfer_id: LocalTransferId, + }, + /// Target side of a same-host transfer: wait for `transfer_id` to be exposed, move the + /// primitive onto this session's device, and register it under `new_id`. + /// + /// `stream_id` is the client stream that will consume `new_id`, so the registration lands + /// on the same stream as the ops that use the transferred tensor. + RegisterTensorLocal { + stream_id: StreamId, + transfer_id: LocalTransferId, + new_id: TensorId, + }, + ReadTensor(RequestId, StreamId, TensorIr), + SyncBackend(RequestId, StreamId), + DTypeUsage(RequestId, DType), +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, Debug)] +pub struct TaskResponse { + pub content: TaskResponseContent, + pub id: RequestId, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, Debug)] +pub enum TaskResponseContent { + /// Server responds with the selected device's settings plus the total number of devices + /// it hosts (so the client can enumerate them, see [`RemoteDevice::enumerate`]). + Init(SessionInfo), + ReadTensor(Result), + SyncBackend(Result<(), ExecutionError>), + DTypeUsage(DTypeUsageSet), +} diff --git a/crates/burn-remote/src/telemetry.rs b/crates/burn-remote/src/telemetry.rs new file mode 100644 index 0000000..6fb0aa4 --- /dev/null +++ b/crates/burn-remote/src/telemetry.rs @@ -0,0 +1,531 @@ +//! Best-effort server telemetry. +//! +//! Session workers emit [`TelemetryEvent`]s into a [`TelemetryProbe`]; a monitoring view holds a +//! [`TelemetrySubscription`] and renders them. The probe never blocks compute: events are built +//! only while a subscriber is attached and are dropped on lag, so a slow or idle viewer cannot +//! apply backpressure to a worker. + +use std::collections::HashMap; +use std::sync::Arc; + +use burn_backend::{DType, Shape}; +use burn_ir::{GraphId, NumericOperationIr, OperationIr, TensorId}; +use burn_std::id::StreamId; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +use crate::shared::{RequestId, SessionId}; + +/// A tensor as it appears in the dataflow graph: identity plus the shape and dtype it was +/// produced with. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TensorRef { + pub id: TensorId, + pub shape: Shape, + pub dtype: DType, +} + +/// One operation inside a cached graph, in relative form (positional tensor ids). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphOp { + pub kind: OpClass, + pub inputs: Vec, + pub outputs: Vec, +} + +impl GraphOp { + pub(crate) fn summarize(op: &OperationIr) -> Self { + Self { + kind: classify(op), + inputs: op.inputs().map(|tensor| tensor.id).collect(), + outputs: op + .outputs() + .map(|tensor| TensorRef { + id: tensor.id, + shape: tensor.shape.clone(), + dtype: tensor.dtype, + }) + .collect(), + } + } +} + +/// Coarse operation category, used to colour and aggregate the op stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum OpClass { + Matmul, + Conv, + Linear, + Activation, + Reduction, + Elementwise, + Compare, + Cast, + Index, + Reshape, + Init, + Random, + Drop, + Custom, + Distributed, + Other, +} + +impl OpClass { + /// Every variant, in a stable order for layout and iteration. + pub const ALL: [OpClass; 16] = [ + OpClass::Matmul, + OpClass::Conv, + OpClass::Linear, + OpClass::Activation, + OpClass::Reduction, + OpClass::Elementwise, + OpClass::Compare, + OpClass::Cast, + OpClass::Index, + OpClass::Reshape, + OpClass::Init, + OpClass::Random, + OpClass::Drop, + OpClass::Custom, + OpClass::Distributed, + OpClass::Other, + ]; + + /// Stable lowercase label for display and aggregation. + pub fn label(self) -> &'static str { + match self { + OpClass::Matmul => "matmul", + OpClass::Conv => "conv", + OpClass::Linear => "linear", + OpClass::Activation => "activation", + OpClass::Reduction => "reduction", + OpClass::Elementwise => "elementwise", + OpClass::Compare => "compare", + OpClass::Cast => "cast", + OpClass::Index => "index", + OpClass::Reshape => "reshape", + OpClass::Init => "init", + OpClass::Random => "random", + OpClass::Drop => "drop", + OpClass::Custom => "custom", + OpClass::Distributed => "distributed", + OpClass::Other => "other", + } + } +} + +/// Classify an [`OperationIr`] into a coarse [`OpClass`]. +pub fn classify(op: &OperationIr) -> OpClass { + use OperationIr as O; + match op { + O::NumericFloat(_, n) | O::NumericInt(_, n) => classify_numeric(n), + O::Float(_, f) => classify_float(f), + O::Module(m) => classify_module(m), + O::Activation(_) => OpClass::Activation, + O::BaseFloat(b) | O::BaseInt(b) | O::BaseBool(b) => classify_base(b), + O::Init(_) => OpClass::Init, + O::Custom(_) => OpClass::Custom, + O::Drop(_) => OpClass::Drop, + O::Distributed(_) => OpClass::Distributed, + O::Bool(_) | O::Int(_) => OpClass::Elementwise, + } +} + +fn classify_numeric(op: &NumericOperationIr) -> OpClass { + use NumericOperationIr as N; + match op { + N::Mean(_) + | N::MeanDim(_) + | N::Sum(_) + | N::SumDim(_) + | N::Prod(_) + | N::ProdDim(_) + | N::Max(_) + | N::MaxDim(_) + | N::MaxDimWithIndices(_) + | N::Min(_) + | N::MinDim(_) + | N::MinDimWithIndices(_) + | N::MaxAbs(_) + | N::MaxAbsDim(_) + | N::ArgMax(_) + | N::ArgMin(_) + | N::ArgTopK(_) + | N::TopK(_) + | N::CumSum(_) + | N::CumProd(_) + | N::CumMin(_) + | N::CumMax(_) + | N::Sort(_) + | N::SortWithIndices(_) + | N::ArgSort(_) => OpClass::Reduction, + N::Greater(_) + | N::GreaterElem(_) + | N::GreaterEqual(_) + | N::GreaterEqualElem(_) + | N::Lower(_) + | N::LowerElem(_) + | N::LowerEqual(_) + | N::LowerEqualElem(_) => OpClass::Compare, + N::Full(_) | N::IntRandom(_) => OpClass::Init, + _ => OpClass::Elementwise, + } +} + +fn classify_float(op: &burn_ir::FloatOperationIr) -> OpClass { + use burn_ir::FloatOperationIr as F; + match op { + F::Matmul(_) => OpClass::Matmul, + F::Random(_) => OpClass::Random, + F::IntoInt(_) | F::Quantize(_) | F::Dequantize(_) => OpClass::Cast, + _ => OpClass::Elementwise, + } +} + +fn classify_module(op: &burn_ir::ModuleOperationIr) -> OpClass { + let name = format!("{op:?}"); + if name.starts_with("Linear") || name.starts_with("Embedding") { + OpClass::Linear + } else if name.starts_with("Conv") || name.starts_with("DeformableConv") { + OpClass::Conv + } else { + OpClass::Other + } +} + +fn classify_base(op: &burn_ir::BaseOperationIr) -> OpClass { + use burn_ir::BaseOperationIr as B; + match op { + B::Reshape(_) + | B::SwapDims(_) + | B::Permute(_) + | B::Flip(_) + | B::Expand(_) + | B::RepeatDim(_) + | B::Cat(_) + | B::Unfold(_) => OpClass::Reshape, + B::Cast(_) => OpClass::Cast, + B::Empty(_) | B::Ones(_) | B::Zeros(_) => OpClass::Init, + B::Equal(_) + | B::EqualElem(_) + | B::NotEqual(_) + | B::NotEqualElem(_) + | B::All(_) + | B::Any(_) + | B::AllDim(_) + | B::AnyDim(_) => OpClass::Compare, + _ => OpClass::Index, + } +} + +/// Whether a transfer crosses hosts or stays on this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransferScope { + Local, + Remote, +} + +/// Lifecycle point of a tensor transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransferPhase { + Started, + Completed, + Failed, +} + +/// A single observation from a session worker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TelemetryEvent { + SessionOpened { + session: SessionId, + device: u32, + }, + SessionClosed { + session: SessionId, + }, + Op { + session: SessionId, + stream: StreamId, + kind: OpClass, + inputs: Vec, + outputs: Vec, + }, + TensorDropped { + session: SessionId, + tensor: TensorId, + }, + Transfer { + session: SessionId, + peer: Option, + scope: TransferScope, + phase: TransferPhase, + }, + Read { + session: SessionId, + request: RequestId, + }, + Sync { + session: SessionId, + request: RequestId, + }, + GraphRegistered { + session: SessionId, + graph: GraphId, + ops: Vec, + bytes: usize, + }, + GraphExecuted { + session: SessionId, + graph: GraphId, + stream: StreamId, + bindings_bytes: usize, + }, +} + +impl TelemetryEvent { + pub(crate) fn unfused_op(session: SessionId, stream: StreamId, op: &OperationIr) -> Self { + match op { + OperationIr::Drop(tensor) => TelemetryEvent::TensorDropped { + session, + tensor: tensor.id, + }, + _ => { + let GraphOp { + kind, + inputs, + outputs, + } = GraphOp::summarize(op); + TelemetryEvent::Op { + session, + stream, + kind, + inputs, + outputs, + } + } + } + } + + pub(crate) fn graph_registered( + session: SessionId, + graph: GraphId, + ops: &[OperationIr], + bytes: usize, + ) -> Self { + TelemetryEvent::GraphRegistered { + session, + graph, + ops: ops.iter().map(GraphOp::summarize).collect(), + bytes, + } + } + + pub(crate) fn graph_executed( + session: SessionId, + graph: GraphId, + stream: StreamId, + bindings_bytes: usize, + ) -> Self { + TelemetryEvent::GraphExecuted { + session, + graph, + stream, + bindings_bytes, + } + } +} + +pub(crate) const CHANNEL_CAPACITY: usize = 4096; + +/// Cloneable handle a worker emits into. Inert until a [`TelemetrySubscription`] is attached. +#[derive(Clone)] +pub struct TelemetryProbe { + tx: Option>>, +} + +impl TelemetryProbe { + /// A probe that drops everything. Worker emit points compile to a cheap no-op against it. + pub fn disabled() -> Self { + Self { tx: None } + } + + /// Create an active probe with no initial subscription. Subscribers attach later with + /// [`subscribe`](Self::subscribe); the probe stays inert until at least one is listening. + pub fn new(capacity: usize) -> Self { + let (tx, _rx) = broadcast::channel(capacity); + Self { tx: Some(tx) } + } + + /// Create an active probe and its first subscription. `capacity` bounds the per-subscriber + /// backlog; older events are dropped once a subscriber falls that far behind. + pub fn channel(capacity: usize) -> (Self, TelemetrySubscription) { + let (tx, rx) = broadcast::channel(capacity); + (Self { tx: Some(tx) }, TelemetrySubscription { rx }) + } + + /// Attach another subscription, or `None` if this probe is disabled. + pub fn subscribe(&self) -> Option { + self.tx + .as_ref() + .map(|tx| TelemetrySubscription { rx: tx.subscribe() }) + } + + /// Whether building and emitting an event is worthwhile right now. + #[inline] + pub fn is_active(&self) -> bool { + self.tx.as_ref().is_some_and(|tx| tx.receiver_count() > 0) + } + + /// Emit an event, building it only when a subscriber is listening. + #[inline] + pub fn emit(&self, event: impl FnOnce() -> TelemetryEvent) { + if let Some(tx) = &self.tx + && tx.receiver_count() > 0 + { + let _ = tx.send(Arc::new(event())); + } + } +} + +/// Receiving end of a [`TelemetryProbe`]. +pub struct TelemetrySubscription { + rx: broadcast::Receiver>, +} + +/// Outcome of a non-blocking drain. +pub enum DrainStatus { + /// The channel is still open; `lagged` counts events dropped before this drain. + Open { lagged: u64 }, + /// Every sender has been dropped. + Closed, +} + +impl TelemetrySubscription { + /// Drain all currently buffered events into `sink` without blocking, reporting whether the + /// channel is still open and how many events were dropped to lag. Intended to be called once + /// per UI frame. + pub fn drain_into(&mut self, sink: &mut Vec>) -> DrainStatus { + use broadcast::error::TryRecvError; + let mut lagged = 0; + loop { + match self.rx.try_recv() { + Ok(event) => sink.push(event), + Err(TryRecvError::Empty) => return DrainStatus::Open { lagged }, + Err(TryRecvError::Lagged(n)) => lagged += n, + Err(TryRecvError::Closed) => return DrainStatus::Closed, + } + } + } + + /// Await the next event, skipping lag gaps. Returns `None` once every sender is dropped. + pub async fn recv(&mut self) -> Option> { + use broadcast::error::RecvError; + loop { + match self.rx.recv().await { + Ok(event) => return Some(event), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => return None, + } + } + } +} + +struct GraphCost { + bytes: u64, + ops: u64, +} + +/// A running fold of the op-graph caching economics over a telemetry stream. +#[derive(Default)] +pub struct TrafficAggregator { + graphs: HashMap, + baseline: u64, + actual: u64, + fused_ops: u64, + unfused_ops: u64, + unfused_by_kind: HashMap, +} + +impl TrafficAggregator { + pub fn apply(&mut self, event: &TelemetryEvent) { + match event { + TelemetryEvent::GraphRegistered { + graph, ops, bytes, .. + } => { + self.actual += *bytes as u64; + self.graphs.insert( + *graph, + GraphCost { + bytes: *bytes as u64, + ops: ops.len() as u64, + }, + ); + } + TelemetryEvent::GraphExecuted { + graph, + bindings_bytes, + .. + } => { + if let Some(cost) = self.graphs.get(graph) { + self.baseline += cost.bytes; + self.fused_ops += cost.ops; + } + self.actual += *bindings_bytes as u64; + } + TelemetryEvent::Op { kind, .. } => { + self.unfused_ops += 1; + *self.unfused_by_kind.entry(*kind).or_default() += 1; + } + TelemetryEvent::TensorDropped { .. } => { + self.unfused_ops += 1; + *self.unfused_by_kind.entry(OpClass::Drop).or_default() += 1; + } + _ => {} + } + } + + pub fn snapshot(&self) -> FusionSnapshot { + FusionSnapshot { + fused_ops: self.fused_ops, + unfused_ops: self.unfused_ops, + baseline: self.baseline, + actual: self.actual, + } + } + + /// Unfused-op tally by class, highest first. + pub fn unfused_by_kind(&self) -> Vec<(OpClass, u64)> { + let mut entries: Vec<_> = self + .unfused_by_kind + .iter() + .map(|(kind, count)| (*kind, *count)) + .collect(); + entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.label().cmp(b.0.label()))); + entries + } +} + +pub struct FusionSnapshot { + pub fused_ops: u64, + pub unfused_ops: u64, + pub baseline: u64, + pub actual: u64, +} + +impl FusionSnapshot { + /// Baseline a non-fusion peer would have moved, minus what fusion actually moved. + pub fn saved(&self) -> u64 { + self.baseline.saturating_sub(self.actual) + } + + pub fn total_ops(&self) -> u64 { + self.fused_ops + self.unfused_ops + } +} + +pub(crate) fn serialized_len(value: &T) -> usize { + rmp_serde::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(0) +} diff --git a/crates/burn-remote/src/transport/identity.rs b/crates/burn-remote/src/transport/identity.rs new file mode 100644 index 0000000..39a058c --- /dev/null +++ b/crates/burn-remote/src/transport/identity.rs @@ -0,0 +1,109 @@ +use core::fmt; + +#[cfg(feature = "websocket")] +use burn_communication::Address; +use serde::{Deserialize, Serialize}; + +/// Stable identity of a Burn Remote peer. +/// +/// Network paths are deliberately not part of the identity. An Iroh peer keeps the same +/// identity while moving between direct addresses and relays. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Debug)] +pub enum PeerId { + /// An Iroh endpoint, authenticated by its public key. + #[cfg(feature = "iroh")] + Iroh(iroh::EndpointId), + /// A legacy WebSocket endpoint. + #[cfg(feature = "websocket")] + WebSocket(Address), +} + +impl fmt::Display for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(id) => write!(f, "iroh://{id}"), + #[cfg(feature = "websocket")] + Self::WebSocket(address) => address.fmt(f), + } + } +} + +// Only the Iroh server resolves a `PeerId` back to an endpoint id (to address tensor transfers). +#[cfg(all(feature = "iroh", feature = "server"))] +impl PeerId { + /// The Iroh endpoint id, or `None` for a non-Iroh peer. + pub(crate) fn into_iroh_id(self) -> Option { + #[cfg(feature = "websocket")] + return match self { + Self::Iroh(id) => Some(id), + Self::WebSocket(_) => None, + }; + #[cfg(not(feature = "websocket"))] + { + let Self::Iroh(id) = self; + Some(id) + } + } +} + +/// A peer identity plus the mutable network paths that may be used to reach it. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Debug)] +pub enum PeerAddr { + /// An Iroh endpoint address. It may contain direct and relay paths, or only an endpoint id + /// when the configured Iroh address lookup can resolve it. + #[cfg(feature = "iroh")] + Iroh(iroh::EndpointAddr), + /// A legacy WebSocket address. + #[cfg(feature = "websocket")] + WebSocket(Address), +} + +impl PeerAddr { + /// Return the stable peer identity, excluding dialing hints. + pub fn id(&self) -> PeerId { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(address) => PeerId::Iroh(address.id), + #[cfg(feature = "websocket")] + Self::WebSocket(address) => PeerId::WebSocket(address.clone()), + } + } + + /// Return true when this is an Iroh peer. + pub fn is_iroh(&self) -> bool { + match self { + #[cfg(feature = "iroh")] + Self::Iroh(_) => true, + #[cfg(feature = "websocket")] + Self::WebSocket(_) => false, + } + } +} + +impl fmt::Display for PeerAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.id().fmt(f) + } +} + +#[cfg(feature = "iroh")] +impl From for PeerAddr { + fn from(value: iroh::EndpointAddr) -> Self { + Self::Iroh(value) + } +} + +#[cfg(feature = "iroh")] +impl From for PeerAddr { + fn from(value: iroh::EndpointId) -> Self { + Self::Iroh(value.into()) + } +} + +#[cfg(feature = "websocket")] +impl From

for PeerAddr { + fn from(value: Address) -> Self { + Self::WebSocket(value) + } +} diff --git a/crates/burn-remote/src/transport/iroh/link.rs b/crates/burn-remote/src/transport/iroh/link.rs new file mode 100644 index 0000000..8e6b413 --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/link.rs @@ -0,0 +1,27 @@ +//! Iroh implementations of the session-link frame traits. +//! +//! An Iroh session is one bidirectional QUIC stream; its two halves are already separate owned +//! values (`SendStream` / `RecvStream`), so they map directly onto [`FrameSink`] / [`FrameSource`]. + +use bytes::Bytes; +use iroh::endpoint::{RecvStream, SendStream}; + +use super::node::{recv_frame, send_frame}; +use crate::transport::link::{FrameSink, FrameSource}; + +impl FrameSink for SendStream { + async fn send(&mut self, frame: Bytes) -> Result<(), String> { + send_frame(self, &frame).await + } + + async fn close(&mut self) -> Result<(), String> { + self.finish() + .map_err(|err| format!("Failed to finish Iroh stream: {err}")) + } +} + +impl FrameSource for RecvStream { + async fn recv(&mut self) -> Result, String> { + recv_frame(self).await.map(|frame| frame.map(Bytes::from)) + } +} diff --git a/crates/burn-remote/src/transport/iroh/mod.rs b/crates/burn-remote/src/transport/iroh/mod.rs new file mode 100644 index 0000000..4b415ff --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/mod.rs @@ -0,0 +1,21 @@ +//! Iroh transport implementation. +//! +//! Self-contained: everything `cfg(feature = "iroh")`-specific that the session, transfer, and +//! client layers depend on lives under this module. + +mod secret; +pub use secret::RemoteSecret; + +mod link; +pub mod node; + +#[cfg(feature = "server")] +pub mod protocol; +#[cfg(all(feature = "server", not(target_family = "wasm")))] +pub mod server; +#[cfg(feature = "server")] +mod time; +#[cfg(feature = "server")] +mod transfer; +#[cfg(feature = "server")] +pub(crate) use transfer::IrohTransfer; diff --git a/crates/burn-remote/src/transport/iroh/node.rs b/crates/burn-remote/src/transport/iroh/node.rs new file mode 100644 index 0000000..b18d167 --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/node.rs @@ -0,0 +1,247 @@ +//! Process-level Iroh endpoint used by Burn Remote clients and compute nodes. + +use std::{collections::HashMap, sync::Arc}; + +use iroh::{ + Endpoint, EndpointAddr, EndpointId, + endpoint::{Connection, RecvStream, SendStream}, +}; +use tokio::sync::Mutex; +use tokio::sync::OnceCell; + +use crate::{PeerAddr, PeerId}; + +/// ALPN used by the version-one Burn Remote protocol. +pub const BURN_REMOTE_ALPN: &[u8] = b"burn/remote/1"; + +/// Identifies the purpose of a bidirectional stream inside a shared Iroh connection. +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +pub(crate) enum StreamKind { + Session, + TensorTransfer, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct StreamHeader { + version: u16, + kind: StreamKind, +} + +const STREAM_VERSION: u16 = 1; +const MAX_FRAME_SIZE: usize = 1024 * 1024 * 1024; + +struct RemoteNodeInner { + endpoint: Endpoint, + connections: Mutex>>>, +} + +/// A process-level Burn Remote networking node. +/// +/// Clone this handle freely. Every clone shares one Iroh [`Endpoint`] and one connection pool, +/// so all remote devices in the process multiplex their sessions over the same peer connection. +#[derive(Clone)] +pub struct RemoteNode { + inner: Arc, +} + +impl core::fmt::Debug for RemoteNode { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RemoteNode") + .field("endpoint_id", &self.id()) + .finish_non_exhaustive() + } +} + +impl RemoteNode { + /// Use an application-configured Iroh endpoint. + /// + /// Applications serving Burn Remote must include [`BURN_REMOTE_ALPN`] in the endpoint's + /// accepted ALPN list, or route that ALPN to Burn's protocol handler. + /// + /// On native client builds, the runtime that drives a device's session is captured when the + /// device is created (see [`RemoteNode::device`]), not here — so create devices from the + /// Tokio runtime that owns this endpoint. + pub fn from_endpoint(endpoint: Endpoint) -> Self { + Self { + inner: Arc::new(RemoteNodeInner { + endpoint, + connections: Mutex::new(HashMap::new()), + }), + } + } + + /// The cryptographic identity of this node. + pub fn id(&self) -> EndpointId { + self.inner.endpoint.id() + } + + /// Access the underlying endpoint for relay, discovery, router, and observability setup. + pub fn endpoint(&self) -> &Endpoint { + &self.inner.endpoint + } + + pub(crate) async fn open_stream( + &self, + peer: &PeerAddr, + kind: StreamKind, + ) -> Result<(SendStream, RecvStream), String> { + // Only the Iroh variant remains when the websocket transport is compiled out. + #[cfg_attr( + not(feature = "websocket"), + allow(clippy::infallible_destructuring_match) + )] + let peer = match peer { + PeerAddr::Iroh(peer) => peer, + #[cfg(feature = "websocket")] + PeerAddr::WebSocket(_) => { + return Err("Iroh node cannot open a stream to a non-Iroh peer".into()); + } + }; + let connection = self.connection(peer.clone()).await?; + let (mut send, recv) = connection + .open_bi() + .await + .map_err(|err| format!("Failed to open Iroh stream to {}: {err}", peer.id))?; + let header = rmp_serde::to_vec(&StreamHeader { + version: STREAM_VERSION, + kind, + }) + .map_err(|err| format!("Failed to encode Iroh stream header: {err}"))?; + send_frame(&mut send, &header).await?; + Ok((send, recv)) + } + + #[cfg(feature = "server")] + pub(crate) async fn accept_stream( + connection: &Connection, + ) -> Result, String> { + let (send, mut recv) = match connection.accept_bi().await { + Ok(stream) => stream, + Err(err) => { + if connection.close_reason().is_some() { + return Ok(None); + } + return Err(format!("Failed to accept Iroh stream: {err}")); + } + }; + let Some(frame) = recv_frame(&mut recv).await? else { + return Ok(None); + }; + let header: StreamHeader = rmp_serde::from_slice(&frame) + .map_err(|err| format!("Invalid Iroh stream header: {err}"))?; + if header.version != STREAM_VERSION { + return Err(format!( + "Unsupported Burn Remote stream version {} (expected {STREAM_VERSION})", + header.version + )); + } + Ok(Some((header.kind, send, recv))) + } + + async fn connection(&self, peer: EndpointAddr) -> Result { + loop { + let cell = { + let mut connections = self.inner.connections.lock().await; + connections + .entry(peer.id) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + + if let Some(connection) = cell.get() + && connection.close_reason().is_some() + { + let mut connections = self.inner.connections.lock().await; + if connections + .get(&peer.id) + .is_some_and(|current| Arc::ptr_eq(current, &cell)) + { + connections.remove(&peer.id); + } + continue; + } + + let endpoint = self.inner.endpoint.clone(); + let peer_for_connect = peer.clone(); + let connection = cell + .get_or_try_init(|| async move { + endpoint + .connect(peer_for_connect.clone(), BURN_REMOTE_ALPN) + .await + .map_err(|err| { + format!( + "Failed to connect to Iroh peer {}: {err}", + peer_for_connect.id + ) + }) + }) + .await?; + return Ok(connection.clone()); + } + } + + #[cfg(feature = "server")] + pub(crate) async fn remember_connection(&self, connection: Connection) { + let remote = connection.remote_id(); + let cell = { + let mut connections = self.inner.connections.lock().await; + match connections.get(&remote) { + Some(cell) + if cell + .get() + .is_some_and(|existing| existing.close_reason().is_none()) => + { + return; + } + _ => { + let cell = Arc::new(OnceCell::new()); + connections.insert(remote, cell.clone()); + cell + } + } + }; + let _ = cell.set(connection); + } +} + +pub(crate) async fn send_frame(send: &mut SendStream, bytes: &[u8]) -> Result<(), String> { + if bytes.len() > MAX_FRAME_SIZE { + return Err(format!( + "Burn Remote frame is too large: {} bytes (max {MAX_FRAME_SIZE})", + bytes.len() + )); + } + send.write_all(&(bytes.len() as u64).to_le_bytes()) + .await + .map_err(|err| format!("Failed to write Iroh frame length: {err}"))?; + send.write_all(bytes) + .await + .map_err(|err| format!("Failed to write Iroh frame: {err}"))?; + Ok(()) +} + +pub(crate) async fn recv_frame(recv: &mut RecvStream) -> Result>, String> { + let mut length = [0u8; 8]; + match recv.read_exact(&mut length).await { + Ok(_) => {} + Err(iroh::endpoint::ReadExactError::FinishedEarly(0)) => return Ok(None), + Err(err) => return Err(format!("Failed to read Iroh frame length: {err}")), + } + let length = u64::from_le_bytes(length) as usize; + if length > MAX_FRAME_SIZE { + return Err(format!( + "Peer sent an oversized Burn Remote frame: {length} bytes (max {MAX_FRAME_SIZE})" + )); + } + let mut bytes = vec![0; length]; + recv.read_exact(&mut bytes) + .await + .map_err(|err| format!("Failed to read Iroh frame: {err}"))?; + Ok(Some(bytes)) +} + +impl From<&RemoteNode> for PeerId { + fn from(value: &RemoteNode) -> Self { + PeerId::Iroh(value.id()) + } +} diff --git a/crates/burn-remote/src/transport/iroh/protocol.rs b/crates/burn-remote/src/transport/iroh/protocol.rs new file mode 100644 index 0000000..0c7ca2a --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/protocol.rs @@ -0,0 +1,201 @@ +//! Iroh protocol handler for Burn Remote compute and tensor-transfer streams. + +use std::{fmt, sync::Arc}; + +use burn_backend::tensor::Device; +use burn_ir::BackendIr; +use burn_router::CustomOpRegistry; +use iroh::{ + Endpoint, EndpointId, + endpoint::{Connection, RecvStream, SendStream}, + protocol::{AcceptError, DynProtocolHandler, ProtocolHandler}, +}; + +use crate::{ + PeerId, + server::{pump::drive_session, session::SessionManager, spawn::spawn_detached}, + telemetry::TelemetryProbe, +}; + +use super::{ + IrohTransfer, + node::{RemoteNode, StreamKind}, +}; + +/// Information presented to a compute node before a remote session is accepted. +pub struct AuthorizationRequest<'a> { + /// Authenticated Iroh identity of the connecting peer. + pub peer: EndpointId, + /// Compute-device index requested by the peer. + pub device_index: u32, + /// Opaque credential supplied by the application when creating the remote device. + pub credential: &'a [u8], +} + +/// Application authorization policy for incoming compute sessions. +pub trait PeerAuthorizer: Send + Sync + 'static { + /// Return `Ok(())` to allow the session, or a user-facing rejection reason. + fn authorize(&self, request: AuthorizationRequest<'_>) -> Result<(), String>; +} + +impl PeerAuthorizer for F +where + F: Fn(AuthorizationRequest<'_>) -> Result<(), String> + Send + Sync + 'static, +{ + fn authorize(&self, request: AuthorizationRequest<'_>) -> Result<(), String> { + self(request) + } +} + +#[derive(Debug, Default)] +pub struct AllowAll; + +impl PeerAuthorizer for AllowAll { + fn authorize(&self, _request: AuthorizationRequest<'_>) -> Result<(), String> { + Ok(()) + } +} + +/// Iroh protocol handler for Burn Remote compute and tensor-transfer streams. +/// +/// Register this handler in an existing Iroh `Router` to compose Burn with other application +/// protocols on the same endpoint. +pub struct IrohRemoteProtocol { + node: RemoteNode, + sessions: Arc>>, + transfer: Arc>, + authorizer: Arc, +} + +impl fmt::Debug for IrohRemoteProtocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IrohRemoteProtocol") + .field("endpoint_id", &self.node.id()) + .finish_non_exhaustive() + } +} + +impl IrohRemoteProtocol { + /// Create a handler hosting `devices` on `node`. + pub fn new( + endpoint: Endpoint, + devices: Vec>, + authorizer: Arc, + probe: TelemetryProbe, + custom_ops: CustomOpRegistry, + ) -> Self { + let node = RemoteNode::from_endpoint(endpoint); + let transfer = Arc::new(IrohTransfer::new(node.clone())); + + let sessions = Arc::new( + SessionManager::new(devices.to_vec(), transfer.clone()) + .with_telemetry(probe.clone()) + .with_custom_ops(custom_ops.clone()), + ); + Self { + node, + sessions, + transfer, + authorizer, + } + } + + /// Drive an accepted session stream through the shared [`drive_session`] pump. + /// + /// The Iroh-specific parts are just the authenticated peer identity (`remote_id`, checked by the + /// application's [`PeerAuthorizer`]) and this server's own id, echoed to the client. + async fn handle_session( + sessions: Arc>>, + authorizer: Arc, + server_id: EndpointId, + remote_id: EndpointId, + send: SendStream, + recv: RecvStream, + ) -> Result<(), String> { + drive_session( + recv, + send, + sessions, + Some(PeerId::Iroh(server_id)), + |init| { + authorizer.authorize(AuthorizationRequest { + peer: remote_id, + device_index: init.device_index, + credential: &init.authorization, + }) + }, + ) + .await + } +} + +/// A backend-erased Burn Remote protocol handler. +/// +/// The dispatch layer resolves a `Device` to a concrete backend and builds an +/// [`IrohRemoteProtocol`]; this wraps it as a single non-generic type, so an application can +/// register Burn on its own Iroh `Router` without naming a backend. Hand it directly to +/// `RouterBuilder::accept` under [`BURN_REMOTE_ALPN`](super::node::BURN_REMOTE_ALPN). +pub struct RemoteProtocol(Box); + +impl RemoteProtocol { + /// Erase a concrete protocol handler behind this non-generic type. + pub fn new(handler: impl ProtocolHandler) -> Self { + Self(handler.into()) + } +} + +impl fmt::Debug for RemoteProtocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RemoteProtocol").finish_non_exhaustive() + } +} + +impl From for Box { + fn from(protocol: RemoteProtocol) -> Self { + protocol.0 + } +} + +impl ProtocolHandler for IrohRemoteProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let remote_id = connection.remote_id(); + self.node.remember_connection(connection.clone()).await; + loop { + let Some((kind, send, recv)) = RemoteNode::accept_stream(&connection) + .await + .map_err(user_error)? + else { + return Ok(()); + }; + + match kind { + StreamKind::Session => { + let sessions = self.sessions.clone(); + let authorizer = self.authorizer.clone(); + let server_id = self.node.id(); + spawn_detached(async move { + if let Err(err) = Self::handle_session( + sessions, authorizer, server_id, remote_id, send, recv, + ) + .await + { + log::warn!("Rejected or failed Iroh remote session: {err}"); + } + }); + } + StreamKind::TensorTransfer => { + let transfer = self.transfer.clone(); + spawn_detached(async move { + if let Err(err) = transfer.handle_stream(remote_id, send, recv).await { + log::warn!("Iroh tensor-transfer stream failed: {err}"); + } + }); + } + } + } + } +} + +fn user_error(reason: String) -> AcceptError { + AcceptError::from_err(std::io::Error::other(reason)) +} diff --git a/crates/burn-remote/src/transport/iroh/secret.rs b/crates/burn-remote/src/transport/iroh/secret.rs new file mode 100644 index 0000000..df80211 --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/secret.rs @@ -0,0 +1,35 @@ +//! Server identity for the Iroh transport. + +/// A compute server's stable identity: the secret stays on the server, and the public +/// [`id`](Self::id) it yields is the address clients dial. Generate one with [`random`](Self::random) +/// and persist [`to_bytes`](Self::to_bytes) for a stable address across restarts, or derive it from a +/// seed with [`from_bytes`](Self::from_bytes). +#[derive(Clone)] +pub struct RemoteSecret(iroh::SecretKey); + +impl RemoteSecret { + /// A fresh random identity. Persist [`to_bytes`](Self::to_bytes) to reuse the same address later. + pub fn random() -> Self { + Self(iroh::SecretKey::generate()) + } + + /// A deterministic identity from 32 seed bytes (e.g. a hash of an application name). + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(iroh::SecretKey::from_bytes(&bytes)) + } + + /// The raw 32 bytes, to persist and reload a stable identity. + pub fn to_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } + + /// The public identity clients dial. + pub fn id(&self) -> iroh::EndpointId { + self.0.public() + } + + #[cfg(feature = "server")] + pub(crate) fn secret_key(&self) -> iroh::SecretKey { + self.0.clone() + } +} diff --git a/crates/burn-remote/src/transport/iroh/server.rs b/crates/burn-remote/src/transport/iroh/server.rs new file mode 100644 index 0000000..c6bf312 --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/server.rs @@ -0,0 +1,54 @@ +use crate::server::spawn::os_shutdown_signal; +use crate::telemetry::TelemetryProbe; +use crate::transport::iroh::node::BURN_REMOTE_ALPN; +use crate::transport::iroh::protocol::{AllowAll, IrohRemoteProtocol}; +#[cfg(not(target_family = "wasm"))] +use burn_backend::tensor::Device; +#[cfg(not(target_family = "wasm"))] +use burn_ir::BackendIr; +#[cfg(not(target_family = "wasm"))] +use burn_router::CustomOpRegistry; +use iroh::{Endpoint, endpoint::presets, protocol::Router}; +use std::sync::Arc; + +/// Serve Burn Remote over Iroh until the process receives its shutdown signal. +/// +/// Binds a server endpoint with the stable identity carried by `secret` and hosts `devices` as the +/// sole protocol on it. Reached through [`RemoteServerBuilder`](super::RemoteServerBuilder) (the +/// single turnkey entry point); use [`RemoteNode::protocol`] for composition with other protocols. +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn start_iroh_async( + secret: crate::RemoteSecret, + devices: Vec>, + custom_ops: CustomOpRegistry, +) { + let endpoint = Endpoint::builder(presets::N0) + .secret_key(secret.secret_key()) + .alpns(vec![BURN_REMOTE_ALPN.to_vec()]) + .bind() + .await + .expect("Can bind the Burn Remote server endpoint"); + + let probe = if crate::metrics::TelemetryLogger::enabled() { + TelemetryProbe::new(crate::telemetry::CHANNEL_CAPACITY) + } else { + TelemetryProbe::disabled() + }; + + let protocol = IrohRemoteProtocol::new( + endpoint.clone(), + devices, + Arc::new(AllowAll), + probe, + custom_ops, + ); + + let router = Router::builder(endpoint) + .accept(BURN_REMOTE_ALPN, protocol) + .spawn(); + + os_shutdown_signal().await; + if let Err(err) = router.shutdown().await { + log::warn!("Burn Remote Iroh router shutdown failed: {err}"); + } +} diff --git a/crates/burn-remote/src/transport/iroh/time.rs b/crates/burn-remote/src/transport/iroh/time.rs new file mode 100644 index 0000000..e6f931a --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/time.rs @@ -0,0 +1,29 @@ +use core::{future::Future, time::Duration}; + +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; +} + +#[cfg(target_family = "wasm")] +pub(crate) async fn sleep(duration: Duration) { + gloo_timers::future::sleep(duration).await; +} + +/// `Err(())` if `duration` elapses before `future` completes. +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn timeout(duration: Duration, future: F) -> Result { + tokio::time::timeout(duration, future).await.map_err(|_| ()) +} + +#[cfg(target_family = "wasm")] +pub(crate) async fn timeout(duration: Duration, future: F) -> Result { + use futures_util::future::{Either, select}; + + let timer = gloo_timers::future::sleep(duration); + futures_util::pin_mut!(future, timer); + match select(future, timer).await { + Either::Left((output, _)) => Ok(output), + Either::Right(((), _)) => Err(()), + } +} diff --git a/crates/burn-remote/src/transport/iroh/transfer.rs b/crates/burn-remote/src/transport/iroh/transfer.rs new file mode 100644 index 0000000..ba2f209 --- /dev/null +++ b/crates/burn-remote/src/transport/iroh/transfer.rs @@ -0,0 +1,241 @@ +//! Authenticated tensor transfer over independent Iroh streams. +//! +//! Server-to-server tensor movement rides its own bidirectional stream +//! ([`StreamKind::TensorTransfer`]), separate from the compute session: a tensor is exposed under a +//! [`TransferCapability`] for a specific target peer, and only that peer can download it. + +use std::collections::HashMap; +use std::sync::Arc; + +use burn_backend::TensorData; +use burn_ir::BackendIr; +use tokio::sync::{Mutex, Notify}; + +use super::node::{RemoteNode, StreamKind, recv_frame, send_frame}; +use crate::server::transfer::TensorTransfer; +use crate::shared::TransferCapability; +use crate::{PeerAddr, PeerId}; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +enum TransferMessage { + Request(TransferCapability), + Tensor(TensorData), + Denied(String), +} + +struct ExposedTensor { + bytes: bytes::Bytes, + target: iroh::EndpointId, + downloads: u32, + max_downloads: u32, +} + +/// Authenticated tensor transfer service carried on independent Iroh streams. +pub(crate) struct IrohTransfer { + node: RemoteNode, + exposed: Arc>>, + exposed_notify: Notify, + _backend: core::marker::PhantomData, +} + +impl IrohTransfer { + pub(crate) fn new(node: RemoteNode) -> Self { + Self { + node, + exposed: Arc::new(Mutex::new(HashMap::new())), + exposed_notify: Notify::new(), + _backend: core::marker::PhantomData, + } + } + + pub(crate) async fn handle_stream( + &self, + remote: iroh::EndpointId, + mut send: iroh::endpoint::SendStream, + mut recv: iroh::endpoint::RecvStream, + ) -> Result<(), String> { + let request = recv_frame(&mut recv) + .await? + .ok_or_else(|| "Tensor-transfer stream closed before its request".to_string())?; + let TransferMessage::Request(capability) = rmp_serde::from_slice(&request) + .map_err(|err| format!("Invalid tensor-transfer request: {err}"))? + else { + return Err("Expected a tensor-transfer request".into()); + }; + + let response = match self.take(capability, remote).await { + Ok(bytes) => bytes, + Err(reason) => rmp_serde::to_vec(&TransferMessage::Denied(reason)) + .map(bytes::Bytes::from) + .map_err(|err| format!("Failed to encode tensor-transfer denial: {err}"))?, + }; + send_frame(&mut send, &response).await?; + send.finish() + .map_err(|err| format!("Failed to finish tensor-transfer stream: {err}"))?; + Ok(()) + } + + async fn take( + &self, + capability: TransferCapability, + remote: iroh::EndpointId, + ) -> Result { + super::time::timeout(TRANSFER_WAIT_TIMEOUT, async { + loop { + let notified = self.exposed_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let mut exposed = self.exposed.lock().await; + if let Some(mut tensor) = exposed.remove(&capability) { + if tensor.target != remote { + exposed.insert(capability, tensor); + return Err(format!( + "Transfer capability is not authorized for peer {remote}" + )); + } + tensor.downloads += 1; + let bytes = tensor.bytes.clone(); + if tensor.downloads < tensor.max_downloads { + exposed.insert(capability, tensor); + } + return Ok(bytes); + } + } + notified.as_mut().await; + } + }) + .await + .map_err(|_| format!("Timed out waiting for tensor transfer {capability:?}"))? + } + + async fn expose_response( + &self, + bytes: bytes::Bytes, + max_downloads: u32, + capability: TransferCapability, + target: iroh::EndpointId, + ) { + self.exposed.lock().await.insert( + capability, + ExposedTensor { + bytes, + target, + downloads: 0, + max_downloads, + }, + ); + self.exposed_notify.notify_waiters(); + + let exposed = self.exposed.clone(); + crate::server::spawn::spawn_detached(async move { + super::time::sleep(TRANSFER_CAPABILITY_TTL).await; + exposed.lock().await.remove(&capability); + }); + } +} + +const TRANSFER_WAIT_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(300); +const TRANSFER_CAPABILITY_TTL: core::time::Duration = core::time::Duration::from_secs(300); + +impl TensorTransfer for IrohTransfer { + async fn expose_data( + &self, + data: TensorData, + max_downloads: u32, + capability: TransferCapability, + target: PeerId, + ) { + let Some(target) = target.into_iroh_id() else { + log::error!("An Iroh tensor transfer cannot target a non-Iroh peer"); + return; + }; + let bytes = match rmp_serde::to_vec(&TransferMessage::Tensor(data)) { + Ok(bytes) => bytes::Bytes::from(bytes), + Err(err) => { + log::error!("Failed to encode tensor transfer {capability:?}: {err}"); + return; + } + }; + self.expose_response(bytes, max_downloads, capability, target) + .await; + } + + async fn download_tensor( + &self, + remote: PeerAddr, + capability: TransferCapability, + ) -> Option { + match &remote { + PeerAddr::Iroh(_) => {} + #[cfg(feature = "websocket")] + PeerAddr::WebSocket(_) => { + log::error!("An Iroh compute node cannot download from a non-Iroh peer"); + return None; + } + } + let (mut send, mut recv) = match self + .node + .open_stream(&remote, StreamKind::TensorTransfer) + .await + { + Ok(streams) => streams, + Err(err) => { + log::error!("{err}"); + return None; + } + }; + let request = match rmp_serde::to_vec(&TransferMessage::Request(capability)) { + Ok(request) => request, + Err(err) => { + log::error!("Failed to encode tensor-transfer request: {err}"); + return None; + } + }; + if let Err(err) = send_frame(&mut send, &request).await { + log::error!("{err}"); + return None; + } + let _ = send.finish(); + let response = match recv_frame(&mut recv).await { + Ok(Some(response)) => response, + Ok(None) => { + log::error!("Tensor-transfer peer closed without a response"); + return None; + } + Err(err) => { + log::error!("{err}"); + return None; + } + }; + match rmp_serde::from_slice(&response) { + Ok(TransferMessage::Tensor(data)) => Some(data), + Ok(TransferMessage::Denied(reason)) => { + log::error!("Tensor transfer denied: {reason}"); + None + } + Ok(TransferMessage::Request(_)) => { + log::error!("Tensor-transfer peer returned a request instead of tensor data"); + None + } + Err(err) => { + log::error!("Invalid tensor-transfer response: {err}"); + None + } + } + } + + async fn fail(&self, capability: TransferCapability, target: PeerId, reason: String) { + let Some(target) = target.into_iroh_id() else { + return; + }; + let bytes = match rmp_serde::to_vec(&TransferMessage::Denied(reason)) { + Ok(bytes) => bytes.into(), + Err(err) => { + log::error!("Failed to encode tensor-transfer failure: {err}"); + return; + } + }; + self.expose_response(bytes, 1, capability, target).await; + } +} diff --git a/crates/burn-remote/src/transport/link.rs b/crates/burn-remote/src/transport/link.rs new file mode 100644 index 0000000..175b9e7 --- /dev/null +++ b/crates/burn-remote/src/transport/link.rs @@ -0,0 +1,44 @@ +//! The session-link abstraction. +//! +//! A session is a duplex, length-framed message channel: the client submits a stream of +//! [`RemoteMessage`](crate::shared::RemoteMessage)s and the server returns a stream of +//! [`TaskResponse`](crate::shared::TaskResponse)s. Every transport realizes this as one +//! bidirectional stream, split into an outgoing [`FrameSink`] and an incoming [`FrameSource`] so +//! the response-writer task can own the sink while the request-reader loop owns the source. +//! +//! Frames are opaque `Bytes` here; encoding/decoding to the protocol types lives in the session +//! pump (server) and client service, so the transport layer only moves bytes. + +use bytes::Bytes; +use core::future::Future; + +/// `Send` on native targets, unconstrained in the browser. +/// +/// Iroh streams are `!Send` on wasm (they live on the JS event loop), so the link traits cannot +/// require `Send` unconditionally. The real `Send` requirement is applied where session tasks are +/// spawned, via the cfg'd [`spawn_detached`](crate::server::spawn::spawn_detached) / +/// [`Executor`](crate::client::service::Executor) helpers — exactly as the concrete channel enums +/// did before this abstraction existed. +#[cfg(not(target_family = "wasm"))] +pub(crate) trait MaybeSend: Send {} +#[cfg(not(target_family = "wasm"))] +impl MaybeSend for T {} +#[cfg(target_family = "wasm")] +pub(crate) trait MaybeSend {} +#[cfg(target_family = "wasm")] +impl MaybeSend for T {} + +/// The outgoing half of a session link: writes length-framed messages to the peer. +pub(crate) trait FrameSink: MaybeSend + 'static { + /// Send one already-encoded frame. + fn send(&mut self, frame: Bytes) -> impl Future> + MaybeSend; + + /// Finish the stream; no more frames will be sent. + fn close(&mut self) -> impl Future> + MaybeSend; +} + +/// The incoming half of a session link: reads length-framed messages from the peer. +pub(crate) trait FrameSource: MaybeSend + 'static { + /// Receive the next frame, or `None` when the peer closes the stream cleanly. + fn recv(&mut self) -> impl Future, String>> + MaybeSend; +} diff --git a/crates/burn-remote/src/transport/mod.rs b/crates/burn-remote/src/transport/mod.rs new file mode 100644 index 0000000..29c6ae1 --- /dev/null +++ b/crates/burn-remote/src/transport/mod.rs @@ -0,0 +1,18 @@ +//! Transport layer for Burn Remote. +//! +//! `burn-remote` owns the transport seam: identity (`PeerId`/`PeerAddr`) is transport-agnostic and +//! lives here, and each concrete transport (iroh, websocket) is a self-contained submodule that +//! plugs into the session and transfer layers. The rest of the crate is written against these +//! abstractions and stays `cfg`-free; the `cfg(feature = ...)` selection between transports is +//! contained to this module. + +mod identity; +pub use identity::{PeerAddr, PeerId}; + +pub(crate) mod link; + +#[cfg(feature = "iroh")] +pub mod iroh; + +#[cfg(feature = "websocket")] +pub(crate) mod websocket; diff --git a/crates/burn-remote/src/transport/websocket/link.rs b/crates/burn-remote/src/transport/websocket/link.rs new file mode 100644 index 0000000..a85b6d9 --- /dev/null +++ b/crates/burn-remote/src/transport/websocket/link.rs @@ -0,0 +1,59 @@ +//! WebSocket implementations of the session-link frame traits. +//! +//! A WebSocket session is one full-duplex socket; `burn_communication` splits it into independent +//! send/receive halves, which map directly onto [`FrameSink`] / [`FrameSource`]. The inherent +//! `send`/`recv`/`close` on each half do the binary framing; here we only adapt the message type +//! (`Message` ↔ `Bytes`) and the error type (`String`). + +use bytes::Bytes; + +use burn_communication::Message; +use burn_communication::websocket::{WsClientSink, WsClientStream, WsServerSink, WsServerStream}; + +use crate::transport::link::{FrameSink, FrameSource}; + +impl FrameSink for WsServerSink { + async fn send(&mut self, frame: Bytes) -> Result<(), String> { + WsServerSink::send(self, Message::new(frame)) + .await + .map_err(|err| err.to_string()) + } + + async fn close(&mut self) -> Result<(), String> { + WsServerSink::close(self) + .await + .map_err(|err| err.to_string()) + } +} + +impl FrameSource for WsServerStream { + async fn recv(&mut self) -> Result, String> { + WsServerStream::recv(self) + .await + .map(|message| message.map(|message| message.data)) + .map_err(|err| err.to_string()) + } +} + +impl FrameSink for WsClientSink { + async fn send(&mut self, frame: Bytes) -> Result<(), String> { + WsClientSink::send(self, Message::new(frame)) + .await + .map_err(|err| err.to_string()) + } + + async fn close(&mut self) -> Result<(), String> { + WsClientSink::close(self) + .await + .map_err(|err| err.to_string()) + } +} + +impl FrameSource for WsClientStream { + async fn recv(&mut self) -> Result, String> { + WsClientStream::recv(self) + .await + .map(|message| message.map(|message| message.data)) + .map_err(|err| err.to_string()) + } +} diff --git a/crates/burn-remote/src/transport/websocket/mod.rs b/crates/burn-remote/src/transport/websocket/mod.rs new file mode 100644 index 0000000..795df86 --- /dev/null +++ b/crates/burn-remote/src/transport/websocket/mod.rs @@ -0,0 +1,17 @@ +//! WebSocket transport implementation — the legacy address-and-port transport. +//! +//! Self-contained: the session uses one full-duplex socket (split into [`FrameSink`]/[`FrameSource`] +//! halves in [`link`]) driven by the shared session pump, and the turnkey server lives in [`server`]. +//! +//! [`FrameSink`]: crate::transport::link::FrameSink +//! [`FrameSource`]: crate::transport::link::FrameSource + +mod link; + +#[cfg(feature = "server")] +mod transfer; + +#[cfg(not(target_family = "wasm"))] +mod server; +#[cfg(not(target_family = "wasm"))] +pub(crate) use server::start_websocket_async; diff --git a/crates/burn-remote/src/transport/websocket/server.rs b/crates/burn-remote/src/transport/websocket/server.rs new file mode 100644 index 0000000..7eb5c24 --- /dev/null +++ b/crates/burn-remote/src/transport/websocket/server.rs @@ -0,0 +1,69 @@ +//! The turnkey WebSocket compute server. + +use std::sync::Arc; + +use burn_backend::tensor::Device; +use burn_ir::BackendIr; +use burn_router::CustomOpRegistry; +use tokio_util::sync::CancellationToken; + +use burn_communication::{ + ProtocolServer, + external_comm::{ExternalCommServer, ExternalCommService}, + websocket::{WebSocket, WsServer, WsServerChannel}, +}; + +use super::transfer::WebSocketTransfer; +use crate::server::{pump::drive_session, session::SessionManager, spawn::os_shutdown_signal}; + +/// Serve a WebSocket compute node on the given port, until shutdown. +/// +/// The session protocol is a single full-duplex `/session` socket per session (split into a sink + +/// source and driven by the shared [`drive_session`] pump); cross-server tensor transfers ride the +/// same server via [`route_external_comm`](ExternalCommServer::route_external_comm). Driven through +/// [`RemoteServerBuilder`](crate::server::RemoteServerBuilder) rather than called directly. +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn start_websocket_async( + devices: Vec>, + port: u16, + custom_ops: CustomOpRegistry, +) { + let cancel_token = CancellationToken::new(); + let external = Arc::new(ExternalCommService::::new(cancel_token)); + let transfer = Arc::new(WebSocketTransfer { + inner: external.clone(), + }); + let probe = if crate::metrics::TelemetryLogger::enabled() { + crate::telemetry::TelemetryProbe::new(crate::telemetry::CHANNEL_CAPACITY) + } else { + crate::telemetry::TelemetryProbe::disabled() + }; + let sessions = Arc::new( + SessionManager::new(devices, transfer) + .with_custom_ops(custom_ops) + .with_telemetry(probe), + ); + + let server = WsServer::new(port) + .route("/session", { + let sessions = sessions.clone(); + move |channel: WsServerChannel| { + let sessions = sessions.clone(); + async move { + let (sink, source) = channel.split(); + // WebSocket has no authenticated peer identity, so the server presents none + // (`peer_id: None`) and authorizes every session. + if let Err(err) = + drive_session(source, sink, sessions, None, |_init| Ok(())).await + { + log::warn!("WebSocket remote session failed: {err}"); + } + } + } + }) + .route_external_comm(external); + + if let Err(err) = server.serve(os_shutdown_signal()).await { + log::error!("Burn Remote WebSocket server stopped: {err:?}"); + } +} diff --git a/crates/burn-remote/src/transport/websocket/transfer.rs b/crates/burn-remote/src/transport/websocket/transfer.rs new file mode 100644 index 0000000..65929a7 --- /dev/null +++ b/crates/burn-remote/src/transport/websocket/transfer.rs @@ -0,0 +1,74 @@ +//! Legacy WebSocket tensor transfer, carried over the `burn_communication` data service. + +use std::sync::Arc; + +use burn_backend::TensorData; +use burn_ir::BackendIr; + +use crate::server::transfer::TensorTransfer; +use crate::shared::TransferCapability; +use crate::{PeerAddr, PeerId}; + +pub(crate) struct WebSocketTransfer { + pub(crate) inner: Arc< + burn_communication::external_comm::ExternalCommService< + B, + burn_communication::websocket::WebSocket, + >, + >, +} + +impl Clone for WebSocketTransfer { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl TensorTransfer for WebSocketTransfer { + async fn expose_data( + &self, + data: TensorData, + max_downloads: u32, + capability: TransferCapability, + _target: PeerId, + ) { + self.inner + .expose_data(data, max_downloads, capability_to_legacy_id(capability)) + .await; + } + + async fn download_tensor( + &self, + remote: PeerAddr, + capability: TransferCapability, + ) -> Option { + // Only the WebSocket arm remains when the Iroh transport is compiled out. + #[cfg_attr(not(feature = "iroh"), allow(clippy::infallible_destructuring_match))] + let address = match remote { + PeerAddr::WebSocket(address) => address, + #[cfg(feature = "iroh")] + PeerAddr::Iroh(_) => { + log::error!("A WebSocket compute node cannot download from a non-WebSocket peer"); + return None; + } + }; + self.inner + .download_tensor(address, capability_to_legacy_id(capability)) + .await + } + + async fn fail(&self, _capability: TransferCapability, _target: PeerId, reason: String) { + log::error!("Legacy WebSocket tensor transfer failed before exposure: {reason}"); + } +} + +fn capability_to_legacy_id( + capability: TransferCapability, +) -> burn_communication::external_comm::TensorTransferId { + // WebSocket is a compatibility transport without authenticated peer identity. Preserve its old + // transfer service while deriving a collision-resistant-enough rendezvous key from the + // capability. Iroh uses the complete capability and enforces the destination identity. + capability.legacy_id().into() +} diff --git a/crates/burn-remote/tests/iroh.rs b/crates/burn-remote/tests/iroh.rs new file mode 100644 index 0000000..9ff8eea --- /dev/null +++ b/crates/burn-remote/tests/iroh.rs @@ -0,0 +1,210 @@ +#![cfg(all(feature = "client", feature = "server", feature = "iroh"))] + +use burn_flex::Flex; +use burn_ir::BackendIr; +use burn_remote::{ + BURN_REMOTE_ALPN, RemoteDevice, + server::{AllowAll, IrohRemoteProtocol}, + telemetry::TelemetryProbe, +}; +use burn_tensor::{Device, Tensor}; +use iroh::{Endpoint, RelayMode, endpoint::presets, protocol::Router}; + +async fn local_endpoint() -> Endpoint { + Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .unwrap() + .bind() + .await + .unwrap() +} + +fn spawn_router( + endpoint: Endpoint, + authorizer: impl burn_remote::server::PeerAuthorizer, + probe: TelemetryProbe, +) -> Router { + let protocol = IrohRemoteProtocol::::new( + endpoint.clone(), + vec![Default::default()], + std::sync::Arc::new(authorizer), + probe, + burn_remote::server::CustomOpRegistry::default(), + ); + Router::builder(endpoint) + .accept(BURN_REMOTE_ALPN, protocol) + .spawn() +} + +#[tokio::test(flavor = "multi_thread")] +async fn executes_over_iroh_session_stream() { + let server = local_endpoint().await; + let client = local_endpoint().await; + let router = spawn_router::(server.clone(), AllowAll, TelemetryProbe::disabled()); + + let remote = RemoteDevice::iroh(&client, server.addr(), 0); + remote.connect(); + let device = Device::new(remote); + + let output = Tensor::<1>::from_floats([1.0, 2.0, 3.0], &device) * 2.0; + assert_eq!( + output.to_data().to_vec::().unwrap(), + vec![2.0, 4.0, 6.0] + ); + + router.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn transfers_tensor_directly_between_iroh_compute_peers() { + let source_server = local_endpoint().await; + let target_server = local_endpoint().await; + let client = local_endpoint().await; + + let source_router = + spawn_router::(source_server.clone(), AllowAll, TelemetryProbe::disabled()); + let target_router = + spawn_router::(target_server.clone(), AllowAll, TelemetryProbe::disabled()); + + let source_remote = RemoteDevice::iroh(&client, source_server.addr(), 0); + let target_remote = RemoteDevice::iroh(&client, target_server.addr(), 0); + source_remote.connect(); + target_remote.connect(); + let source = Device::new(source_remote); + let target = Device::new(target_remote); + + let tensor = Tensor::<1>::from_floats([3.0, 5.0, 7.0], &source); + let transferred = tensor.to_device(&target); + assert_eq!( + transferred.to_data().to_vec::().unwrap(), + vec![3.0, 5.0, 7.0] + ); + + source_router.shutdown().await.unwrap(); + target_router.shutdown().await.unwrap(); +} + +/// The synchronous client path used by scripts, REPLs and Rust notebooks: no `async`, no ambient +/// runtime in the calling code. The device is created on the client's runtime (so the session +/// reuses it, the way [`RemoteNode::bind_blocking`] does internally) and every operation is then +/// driven synchronously off it. +#[test] +fn synchronous_client_round_trip() { + // Server on its own local runtime, kept alive for the duration of the test. + let server_runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let server_guard = server_runtime.enter(); + let server = server_runtime.block_on(local_endpoint()); + let router = spawn_router::(server.clone(), AllowAll, TelemetryProbe::disabled()); + let server_addr = server.addr(); + drop(server_guard); + + // Client on a node that owns its runtime, used entirely synchronously from this (non-runtime) + // thread, exactly what a notebook cell does. + let client_runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let client_endpoint = client_runtime.block_on(local_endpoint()); + + // Create the device on the client's runtime so the session captures it; the round-trip below + // then runs from this non-runtime thread, exactly what a notebook cell does. + let remote = { + let _guard = client_runtime.enter(); + RemoteDevice::iroh(&client_endpoint, server_addr, 0) + }; + remote.connect(); + let device = Device::new(remote); + + let output = Tensor::<1>::from_floats([1.0, 2.0, 3.0], &device) * 2.0; + assert_eq!( + output.to_data().to_vec::().unwrap(), + vec![2.0, 4.0, 6.0] + ); + + server_runtime.block_on(router.shutdown()).unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn passes_application_credentials_to_the_peer_authorizer() { + let server = local_endpoint().await; + let client = local_endpoint().await; + let router = spawn_router::( + server.clone(), + |request: burn_remote::server::AuthorizationRequest<'_>| { + (request.credential == b"fleet-ticket") + .then_some(()) + .ok_or_else(|| "invalid fleet ticket".to_string()) + }, + TelemetryProbe::disabled(), + ); + let remote = RemoteDevice::iroh_authorized(&client, server.addr(), 0, b"fleet-ticket".to_vec()); + remote.connect(); + let device = Device::new(remote); + let data = Tensor::<1>::from_floats([4.0], &device).to_data(); + assert_eq!(data.to_vec::().unwrap(), vec![4.0]); + + router.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "fusion")] +async fn fused_compute_surfaces_as_graph_telemetry() { + use burn_remote::telemetry::{TelemetryEvent, TelemetryProbe, TrafficAggregator}; + use std::time::Duration; + + let server = local_endpoint().await; + let client = local_endpoint().await; + + let (probe, mut events) = TelemetryProbe::channel(4096); + let router = spawn_router::(server.clone(), AllowAll, probe); + let remote = RemoteDevice::iroh(&client, server.addr(), 0); + remote.connect(); + let device = Device::new(remote); + + // A multi-op float expression fuses into a cached graph; running it twice forces a replay, and + // each read flushes the fusion stream so the server actually executes the graph. + for _ in 0..2 { + let x = Tensor::<1>::from_floats([1.0, 2.0, 3.0], &device); + let y = ((x * 2.0) + 1.0).exp().log(); + let _ = y.to_data(); + } + + // Fold the stream the same way a logger or dashboard would, and check the derived economics. + let mut aggregator = TrafficAggregator::default(); + let (mut saw_registered, mut saw_executed) = (false, false); + let collect = async { + while !(saw_registered && saw_executed && aggregator.snapshot().fused_ops > 0) { + let Some(event) = events.recv().await else { + break; + }; + aggregator.apply(&event); + match event.as_ref() { + TelemetryEvent::GraphRegistered { ops, bytes, .. } => { + saw_registered = !ops.is_empty() && *bytes > 0 + } + TelemetryEvent::GraphExecuted { .. } => saw_executed = true, + _ => {} + } + } + }; + tokio::time::timeout(Duration::from_secs(10), collect) + .await + .expect("fused-path telemetry did not arrive in time"); + + assert!( + saw_registered, + "expected a GraphRegistered event carrying the graph's ops and size" + ); + assert!(saw_executed, "expected a GraphExecuted replay heartbeat"); + assert!( + aggregator.snapshot().fused_ops > 0, + "the aggregator should price the replayed graph's ops as fused" + ); + + router.shutdown().await.unwrap(); +} diff --git a/crates/burn-rl/Cargo.toml b/crates/burn-rl/Cargo.toml new file mode 100644 index 0000000..9458a20 --- /dev/null +++ b/crates/burn-rl/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "RL crate for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-rl" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-rl" +documentation = "https://docs.rs/burn-rl" +version.workspace = true + +[dependencies] +burn-core = { workspace = true, features = [ + "dataset", + "std", +] } + +derive-new.workspace = true +log = { workspace = true } +rand.workspace = true + +[dev-dependencies] +# Test backend +burn-core = { workspace = true, features = ["default", "flex"]} + +[lints] +workspace = true diff --git a/crates/burn-rl/LICENSE-APACHE b/crates/burn-rl/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-rl/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-rl/LICENSE-MIT b/crates/burn-rl/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-rl/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-rl/README.md b/crates/burn-rl/README.md new file mode 100644 index 0000000..0b0221b --- /dev/null +++ b/crates/burn-rl/README.md @@ -0,0 +1,6 @@ +# Burn RL + + + + diff --git a/crates/burn-rl/src/environment/base.rs b/crates/burn-rl/src/environment/base.rs new file mode 100644 index 0000000..16af869 --- /dev/null +++ b/crates/burn-rl/src/environment/base.rs @@ -0,0 +1,46 @@ +/// The result of taking a step in an environment. +pub struct StepResult { + /// The updated state. + pub next_state: S, + /// The reward. + pub reward: f64, + /// If the environment reached a terminal state. + pub done: bool, + /// If the environment reached its max length. + pub truncated: bool, +} + +/// Trait to be implemented for a RL environment. +pub trait Environment { + /// The type of the state. + type State; + /// The type of actions. + type Action; + + /// The maximum number of step for one episode. + const MAX_STEPS: usize; + + /// Returns the current state. + fn state(&self) -> Self::State; + /// Take a step in the environment given an action. + fn step(&mut self, action: Self::Action) -> StepResult; + /// Reset the environment to an initial state. + fn reset(&mut self); +} + +/// Trait to define how to initialize an environment. +/// By default, any function returning an environment implements it. +pub trait EnvironmentInit: Clone { + /// Initialize the environment. + fn init(&self) -> E; +} + +impl EnvironmentInit for F +where + F: Fn() -> E + Clone, + E: Environment, +{ + fn init(&self) -> E { + (self)() + } +} diff --git a/crates/burn-rl/src/environment/mod.rs b/crates/burn-rl/src/environment/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-rl/src/environment/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-rl/src/lib.rs b/crates/burn-rl/src/lib.rs new file mode 100644 index 0000000..a02018d --- /dev/null +++ b/crates/burn-rl/src/lib.rs @@ -0,0 +1,163 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! A library for training reinforcement learning agents. + +/// Module for implementing an environment. +pub mod environment; +/// Module for implementing a policy. +pub mod policy; +/// Transition buffer. +pub mod transition_buffer; + +pub use environment::*; +pub use policy::*; +pub use transition_buffer::*; + +#[cfg(test)] +pub(crate) mod tests { + use crate::{Batchable, Policy, PolicyState}; + + use burn_core::tensor::Device; + + /// Mock policy for testing + /// + /// Calling `forward()` with a [MockObservation](MockObservation) (list of f32) returns a [MockActionDistribution](MockActionDistribution) + /// containing a list of 0s of the same length as the observation. + /// + /// Calling `action()` with a [MockObservation](MockObservation) (list of f32) returns a [MockAction](MockAction) with a list of actions of the same length as the observation. + /// The actions are all 1 if the call is requested as deterministic, or else 0. + #[derive(Clone)] + pub(crate) struct MockPolicy {} + + impl MockPolicy { + pub fn new() -> Self { + Self {} + } + } + + impl Policy for MockPolicy { + type Observation = MockObservation; + type ActionDistribution = MockActionDistribution; + type Action = MockAction; + type ActionContext = MockActionContext; + type PolicyState = MockPolicyState; + + fn forward(&mut self, obs: Self::Observation) -> Self::ActionDistribution { + let mut dists = vec![]; + + for _ in obs.0 { + dists.push(MockActionDistribution(vec![0.])); + } + MockActionDistribution::batch(dists) + } + + fn action( + &mut self, + obs: Self::Observation, + deterministic: bool, + ) -> (Self::Action, Vec) { + let mut actions = vec![]; + let mut contexts = vec![]; + + for _ in obs.0 { + if deterministic { + actions.push(MockAction(vec![1])); + } else { + actions.push(MockAction(vec![0])); + } + contexts.push(MockActionContext); + } + + (MockAction::batch(actions), contexts) + } + + fn update(&mut self, _update: Self::PolicyState) {} + + fn state(&self) -> Self::PolicyState { + MockPolicyState + } + + fn load_record(self, _record: ::Record) -> Self { + self + } + + fn to_device(self, _device: &Device) -> Self { + self + } + } + + /// Mock observation for testing represented as a vector of f32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockObservation(pub Vec); + + /// Mock action for testing represented as a vector of i32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockAction(pub Vec); + + /// Mock action distribution for testing represented as a vector of i32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockActionDistribution(Vec); + + #[derive(Clone)] + pub(crate) struct MockActionContext; + + /// Mock policy state for testing represented as an arbitrary `usize` that has no effect on the policy. + #[derive(Clone)] + pub(crate) struct MockPolicyState; + + #[derive(Clone)] + pub(crate) struct MockRecord { + _item: usize, + } + + impl PolicyState for MockPolicyState { + type Record = MockRecord; + + fn into_record(self) -> Self::Record { + MockRecord { _item: 0 } + } + + fn load_record(&self, _record: Self::Record) -> Self { + self.clone() + } + } + + impl Batchable for MockObservation { + fn batch(items: Vec) -> Self { + MockObservation(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + vec![MockObservation(self.0)] + } + } + + impl Batchable for MockAction { + fn batch(items: Vec) -> Self { + MockAction(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + let mut actions = vec![]; + for a in self.0 { + actions.push(MockAction(vec![a])); + } + actions + } + } + + impl Batchable for MockActionDistribution { + fn batch(items: Vec) -> Self { + MockActionDistribution(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + let mut dists = vec![]; + for _ in self.0 { + dists.push(MockActionDistribution(vec![0.])); + } + dists + } + } +} diff --git a/crates/burn-rl/src/policy/async_policy.rs b/crates/burn-rl/src/policy/async_policy.rs new file mode 100644 index 0000000..f6a4ec3 --- /dev/null +++ b/crates/burn-rl/src/policy/async_policy.rs @@ -0,0 +1,486 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc::{self, Sender}, + }, + thread::spawn, +}; + +use burn_core::tensor::Device; + +use crate::{ActionContext, Batchable, Policy, PolicyState}; + +#[derive(Clone)] +struct PolicyInferenceServer { + // `num_agents` used to make sure autobatching doesn't block the agents if they are less than the autobatch size. + num_agents: Arc, + max_autobatch_size: usize, + inner_policy: P, + batch_action: Vec>, + batch_logits: Vec>, +} + +impl

PolicyInferenceServer

+where + P: Policy, + P::Observation: Clone + Batchable, + P::ActionDistribution: Clone + Batchable, + P::Action: Clone + Batchable, + P::ActionContext: Clone, +{ + pub fn new(max_autobatch_size: usize, inner_policy: P) -> Self { + Self { + num_agents: Arc::new(AtomicUsize::new(0)), + max_autobatch_size, + inner_policy, + batch_action: vec![], + batch_logits: vec![], + } + } + + pub fn push_action(&mut self, item: ActionItem) { + self.batch_action.push(item); + if self.len_actions() + >= self + .num_agents + .load(Ordering::Relaxed) + .min(self.max_autobatch_size) + { + self.flush_actions(); + } + } + + pub fn push_logits(&mut self, item: ForwardItem) { + self.batch_logits.push(item); + if self.len_logits() + >= self + .num_agents + .load(Ordering::Relaxed) + .min(self.max_autobatch_size) + { + self.flush_logits(); + } + } + + pub fn len_actions(&self) -> usize { + self.batch_action.len() + } + + pub fn len_logits(&self) -> usize { + self.batch_logits.len() + } + + pub fn flush_actions(&mut self) { + if self.len_actions() == 0 { + return; + } + let input: Vec<_> = self + .batch_action + .iter() + .map(|m| m.inference_state.clone()) + .collect(); + // Only deterministic if all actions are requested as deterministic. + let deterministic = self.batch_action.iter().all(|item| item.deterministic); + let (actions, context) = self + .inner_policy + .action(P::Observation::batch(input), deterministic); + let actions: Vec<_> = actions.unbatch(); + + for (i, item) in self.batch_action.iter().enumerate() { + item.sender + .send(ActionContext { + context: vec![context[i].clone()], + action: actions[i].clone(), + }) + .expect("Autobatcher should be able to send resulting actions."); + } + self.batch_action.clear(); + } + + pub fn flush_logits(&mut self) { + if self.len_logits() == 0 { + return; + } + let input: Vec<_> = self + .batch_logits + .iter() + .map(|m| m.inference_state.clone()) + .collect(); + let output = self.inner_policy.forward(P::Observation::batch(input)); + let logits: Vec<_> = output.unbatch(); + for (i, item) in self.batch_logits.iter().enumerate() { + item.sender + .send(logits[i].clone()) + .expect("Autobatcher should be able to send resulting probabilities."); + } + self.batch_logits.clear(); + } + + pub fn update_policy(&mut self, policy_update: P::PolicyState) { + if self.len_actions() > 0 { + self.flush_actions(); + } + if self.len_logits() > 0 { + self.flush_logits(); + } + self.inner_policy.update(policy_update); + } + + pub fn policy_to_device(&mut self, device: &Device) { + self.inner_policy = self.inner_policy.clone().to_device(device); + } + + pub fn state(&self) -> P::PolicyState { + self.inner_policy.state() + } + + pub fn increment_agents(&mut self, num: usize) { + self.num_agents.fetch_add(num, Ordering::Relaxed); + } + + pub fn decrement_agents(&mut self, num: usize) { + self.num_agents.fetch_sub(num, Ordering::Relaxed); + if self.len_actions() + >= self + .num_agents + .load(Ordering::Relaxed) + .min(self.max_autobatch_size) + { + self.flush_actions(); + } + if self.len_logits() + >= self + .num_agents + .load(Ordering::Relaxed) + .min(self.max_autobatch_size) + { + self.flush_logits(); + } + } +} + +enum InferenceMessage { + ActionMessage(ActionItem), + ForwardMessage(ForwardItem), + PolicyUpdate(P::PolicyState), + ToDevice(Device), + PolicyRequest(Sender), + IncrementAgents(usize), + DecrementAgents(usize), +} + +#[derive(Clone)] +struct ActionItem { + sender: Sender>>, + inference_state: S, + deterministic: bool, +} + +#[derive(Clone)] +struct ForwardItem { + sender: Sender, + inference_state: S, +} + +/// An asynchronous policy using an inference server with autobatching. +#[derive(Clone)] +pub struct AsyncPolicy { + inference_state_sender: Sender>, +} + +impl

AsyncPolicy

+where + P: Policy + Clone + Send + 'static, + P::ActionContext: Clone + Send, + P::PolicyState: Send, + P::Observation: Clone + Send + Batchable, + P::ActionDistribution: Clone + Send + Batchable, + P::Action: Clone + Send + Batchable, +{ + /// Create the policy. + /// + /// # Arguments + /// + /// * `autobatch_size` - Number of observations to accumulate before running a pass of inference. + /// * `inner_policy` - The policy used to take actions. + pub fn new(autobatch_size: usize, inner_policy: P) -> Self { + let (sender, receiver) = std::sync::mpsc::channel(); + let mut autobatcher = PolicyInferenceServer::new(autobatch_size, inner_policy.clone()); + spawn(move || { + loop { + match receiver.recv() { + Ok(msg) => match msg { + InferenceMessage::ActionMessage(item) => autobatcher.push_action(item), + InferenceMessage::ForwardMessage(item) => autobatcher.push_logits(item), + InferenceMessage::PolicyUpdate(update) => autobatcher.update_policy(update), + InferenceMessage::ToDevice(device) => autobatcher.policy_to_device(&device), + InferenceMessage::PolicyRequest(sender) => sender + .send(autobatcher.state()) + .expect("Autobatcher should be able to send current policy state."), + InferenceMessage::IncrementAgents(num) => autobatcher.increment_agents(num), + InferenceMessage::DecrementAgents(num) => autobatcher.decrement_agents(num), + }, + Err(err) => { + log::error!("Error in AsyncPolicy : {}", err); + break; + } + } + } + }); + + Self { + inference_state_sender: sender, + } + } + + /// Increment the number of agents using the inference server. + pub fn increment_agents(&self, num: usize) { + self.inference_state_sender + .send(InferenceMessage::IncrementAgents(num)) + .expect("Can send message to autobatcher.") + } + + /// Decrement the number of agents using the inference server. + pub fn decrement_agents(&self, num: usize) { + self.inference_state_sender + .send(InferenceMessage::DecrementAgents(num)) + .expect("Can send message to autobatcher.") + } +} + +impl

Policy for AsyncPolicy

= + TransitionBatch<

::Observation,

::Action>; + +/// Learner for a policy. +pub trait PolicyLearner +where + ::Observation: Clone + Batchable, + ::ActionDistribution: Clone + Batchable, + ::Action: Clone + Batchable, +{ + /// Additional context of a training step. + type TrainContext; + /// The policy to train. + type InnerPolicy: Policy; + /// The record of the learner. + type Record; + + /// Execute a training step on the policy. + fn train( + &mut self, + input: LearnerTransitionBatch, + ) -> RLTrainOutput::PolicyState>; + /// Returns the learner's current policy for validation. + fn policy(&self) -> Self::InnerPolicy; + /// Update the learner's policy. + fn update_policy(&mut self, update: Self::InnerPolicy); + /// Convert the learner's state into a record. + fn record(&self) -> Self::Record; + /// Load the learner's state from a record. + fn load_record(self, record: Self::Record) -> Self; + /// Returns the device used for training. + fn device(&self) -> Device; +} diff --git a/crates/burn-rl/src/policy/mod.rs b/crates/burn-rl/src/policy/mod.rs new file mode 100644 index 0000000..fa7e967 --- /dev/null +++ b/crates/burn-rl/src/policy/mod.rs @@ -0,0 +1,5 @@ +mod async_policy; +mod base; + +pub use async_policy::*; +pub use base::*; diff --git a/crates/burn-rl/src/transition_buffer/base.rs b/crates/burn-rl/src/transition_buffer/base.rs new file mode 100644 index 0000000..01a386e --- /dev/null +++ b/crates/burn-rl/src/transition_buffer/base.rs @@ -0,0 +1,239 @@ +use burn_core::{Tensor, prelude::Device, tensor::Distribution}; +use derive_new::new; + +use super::SliceAccess; + +/// A state transition in an environment. +#[derive(Clone, new)] +pub struct Transition { + /// The initial state. + pub state: S, + /// The state after the step was taken. + pub next_state: S, + /// The action taken in the step. + pub action: A, + /// The reward. + pub reward: Tensor<1>, + /// If the environment has reached a terminal state. + pub done: Tensor<1>, +} + +/// A batch of transitions. +pub struct TransitionBatch { + /// Batched initial states. + pub states: SB, + /// Batched resulting states. + pub next_states: SB, + /// Batched actions. + pub actions: AB, + /// Batched rewards. + pub rewards: Tensor<2>, + /// Batched flags for terminal states. + pub dones: Tensor<2>, +} + +/// A tensor-backed circular buffer for transitions. +/// +/// Uses [`SliceAccess`] to store state and action batches in contiguous +/// tensor storage, enabling efficient random sampling via `select`. +/// The buffer lazily initializes its storage on the first `push` call. +pub struct TransitionBuffer { + states: Option, + next_states: Option, + actions: Option, + rewards: Option>, + dones: Option>, + capacity: usize, + write_head: usize, + len: usize, + device: Device, +} + +impl TransitionBuffer { + /// Creates a new buffer. Storage is lazily allocated on the first `push`. + pub fn new(capacity: usize, device: &Device) -> Self { + Self { + states: None, + next_states: None, + actions: None, + rewards: None, + dones: None, + capacity, + write_head: 0, + len: 0, + device: device.clone(), + } + } + + fn ensure_init(&mut self, state: &SB, next_state: &SB, action: &AB) { + if self.states.is_none() { + self.states = Some(SB::zeros_like(state, self.capacity, &self.device)); + self.next_states = Some(SB::zeros_like(next_state, self.capacity, &self.device)); + self.actions = Some(AB::zeros_like(action, self.capacity, &self.device)); + self.rewards = Some(Tensor::zeros([self.capacity, 1], &self.device)); + self.dones = Some(Tensor::zeros([self.capacity, 1], &self.device)); + } + } + + /// Add a transition, overwriting the oldest if full. + pub fn push(&mut self, state: SB, next_state: SB, action: AB, reward: f32, done: bool) { + self.ensure_init(&state, &next_state, &action); + + let idx = self.write_head % self.capacity; + + self.states + .as_mut() + .unwrap() + .slice_assign_inplace(idx, state); + self.next_states + .as_mut() + .unwrap() + .slice_assign_inplace(idx, next_state); + self.actions + .as_mut() + .unwrap() + .slice_assign_inplace(idx, action); + + let reward = Tensor::from_data([[reward]], &self.device); + self.rewards + .as_mut() + .unwrap() + .inplace(|r| r.slice_assign(idx..idx + 1, reward)); + + let done_val = if done { 1.0f32 } else { 0.0 }; + let done = Tensor::from_data([[done_val]], &self.device); + self.dones + .as_mut() + .unwrap() + .inplace(|d| d.slice_assign(idx..idx + 1, done)); + + self.write_head += 1; + if self.len < self.capacity { + self.len += 1; + } + } + + /// Sample a random batch of transitions. + pub fn sample(&self, batch_size: usize) -> TransitionBatch { + assert!(batch_size <= self.len, "batch_size exceeds buffer length"); + + let indices = Tensor::<1>::random( + [batch_size], + Distribution::Uniform(0.0, self.len as f64), + &self.device, + ) + .int(); + + TransitionBatch { + states: self + .states + .as_ref() + .unwrap() + .clone() + .select(0, indices.clone()), + next_states: self + .next_states + .as_ref() + .unwrap() + .clone() + .select(0, indices.clone()), + actions: self + .actions + .as_ref() + .unwrap() + .clone() + .select(0, indices.clone()), + rewards: self + .rewards + .as_ref() + .unwrap() + .clone() + .select(0, indices.clone()), + dones: self.dones.as_ref().unwrap().clone().select(0, indices), + } + } + + /// Current number of stored transitions. + pub fn len(&self) -> usize { + self.len + } + + /// Whether the buffer is empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Buffer capacity. + pub fn capacity(&self) -> usize { + self.capacity + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type TB = Tensor<2>; + + fn push_transition(buffer: &mut TransitionBuffer, device: &Device, val: f32) { + let state = Tensor::<2>::from_data([[val, val]], device); + let next_state = Tensor::<2>::from_data([[val + 1.0, val + 1.0]], device); + let action = Tensor::<2>::from_data([[val]], device); + buffer.push(state, next_state, action, val, false); + } + + #[test] + fn push_increment_len() { + let device = Default::default(); + let mut buffer = TransitionBuffer::::new(5, &device); + + assert_eq!(buffer.len(), 0); + assert!(buffer.is_empty()); + + push_transition(&mut buffer, &device, 1.0); + assert_eq!(buffer.len(), 1); + + push_transition(&mut buffer, &device, 2.0); + assert_eq!(buffer.len(), 2); + } + + #[test] + fn push_overwrites_when_full() { + let device = Default::default(); + let mut buffer = TransitionBuffer::::new(3, &device); + + for i in 0..5 { + push_transition(&mut buffer, &device, i as f32); + } + + assert_eq!(buffer.len(), 3); + assert_eq!(buffer.capacity(), 3); + } + + #[test] + fn sample_returns_correct_shapes() { + let device = Default::default(); + let mut buffer = TransitionBuffer::::new(10, &device); + + for i in 0..5 { + push_transition(&mut buffer, &device, i as f32); + } + + let batch = buffer.sample(3); + assert_eq!(batch.states.dims(), [3, 2]); + assert_eq!(batch.next_states.dims(), [3, 2]); + assert_eq!(batch.actions.dims(), [3, 1]); + assert_eq!(batch.rewards.dims(), [3, 1]); + assert_eq!(batch.dones.dims(), [3, 1]); + } + + #[test] + #[should_panic(expected = "batch_size exceeds buffer length")] + fn sample_panics_when_batch_too_large() { + let device = Default::default(); + let mut buffer = TransitionBuffer::::new(5, &device); + + push_transition(&mut buffer, &device, 1.0); + buffer.sample(5); + } +} diff --git a/crates/burn-rl/src/transition_buffer/mod.rs b/crates/burn-rl/src/transition_buffer/mod.rs new file mode 100644 index 0000000..806834d --- /dev/null +++ b/crates/burn-rl/src/transition_buffer/mod.rs @@ -0,0 +1,5 @@ +mod base; +mod slice_access; + +pub use base::*; +pub use slice_access::*; diff --git a/crates/burn-rl/src/transition_buffer/slice_access.rs b/crates/burn-rl/src/transition_buffer/slice_access.rs new file mode 100644 index 0000000..2d3b0a8 --- /dev/null +++ b/crates/burn-rl/src/transition_buffer/slice_access.rs @@ -0,0 +1,36 @@ +use burn_core::prelude::*; + +/// Trait for types that support tensor-like slice operations, +/// enabling storage in a [`TransitionBuffer`](super::TransitionBuffer). +/// +/// Implement this trait for any type that wraps tensors and can be stored +/// in a replay buffer. The buffer uses these operations for: +/// - Pre-allocating storage (`zeros_like`) +/// - Writing transitions (`slice_assign_inplace`) +/// - Sampling batches (`select`) +pub trait SliceAccess: Clone + Sized { + /// Create zeroed storage matching the shape of `sample` but with `capacity` rows + /// along the first dimension. + fn zeros_like(sample: &Self, capacity: usize, device: &Device) -> Self; + + /// Select rows at the given indices along the specified dimension. + fn select(self, dim: usize, indices: Tensor<1, Int>) -> Self; + + /// Assign `value` at row `index` along the first dimension, in place. + fn slice_assign_inplace(&mut self, index: usize, value: Self); +} + +impl SliceAccess for Tensor<2> { + fn zeros_like(sample: &Self, capacity: usize, device: &Device) -> Self { + let feature_dim = sample.dims()[1]; + Tensor::zeros([capacity, feature_dim], device) + } + + fn select(self, dim: usize, indices: Tensor<1, Int>) -> Self { + Tensor::select(self, dim, indices) + } + + fn slice_assign_inplace(&mut self, index: usize, value: Self) { + self.inplace(|t| t.slice_assign(index..index + 1, value)); + } +} diff --git a/crates/burn-rocm/Cargo.toml b/crates/burn-rocm/Cargo.toml new file mode 100644 index 0000000..ebd44a4 --- /dev/null +++ b/crates/burn-rocm/Cargo.toml @@ -0,0 +1,44 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "ROCm HIP backend for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "gpu", "rocm", "hip"] +license.workspace = true +name = "burn-rocm" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-rocm" +documentation = "https://docs.rs/burn-rocm" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["fusion", "burn-cubecl/default", "cubecl/default"] +doc = ["burn-cubecl/doc"] +std = ["burn-cubecl/std", "cubecl/std"] + +tracing = [ + "cubecl/tracing", + "burn-cubecl/tracing", + "burn-backend/tracing", + "burn-fusion?/tracing", +] + +fusion = ["burn-fusion", "burn-cubecl/fusion"] +autotune = ["burn-cubecl/autotune"] +autotune-checks = ["burn-cubecl/autotune-checks"] + +[dependencies] +cubecl = { workspace = true, features = ["hip"] } +burn-cubecl = { workspace = true, features = ["default"] } +burn-backend = { workspace = true, features = [ + "default", + "cubecl-hip", +] } +burn-fusion = { workspace = true, optional = true, features = ["default"] } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-rocm/README.md b/crates/burn-rocm/README.md new file mode 100644 index 0000000..583bbec --- /dev/null +++ b/crates/burn-rocm/README.md @@ -0,0 +1,7 @@ +# burn-rocm + +Backend using ROCm HIP runtime. + +To execute the tests for this backend set an environment variable called `ROCM_PATH` or `CUBECL_ROCM_PATH` to the installation path of ROCm. It is often `/opt/rocm`. + +For now this backend requires the version `6.2.2` of ROCm or a compatible version. diff --git a/crates/burn-rocm/src/lib.rs b/crates/burn-rocm/src/lib.rs new file mode 100644 index 0000000..2b7e562 --- /dev/null +++ b/crates/burn-rocm/src/lib.rs @@ -0,0 +1,14 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +extern crate alloc; + +use burn_cubecl::CubeBackend; + +pub use cubecl::hip::AmdDevice as RocmDevice; + +use cubecl::hip::HipRuntime; + +#[cfg(not(feature = "fusion"))] +pub type Rocm = CubeBackend; + +#[cfg(feature = "fusion")] +pub type Rocm = burn_fusion::Fusion>; diff --git a/crates/burn-router/Cargo.toml b/crates/burn-router/Cargo.toml new file mode 100644 index 0000000..94b95c4 --- /dev/null +++ b/crates/burn-router/Cargo.toml @@ -0,0 +1,53 @@ +[package] +authors = [ + "laggui ", + "nathanielsimard ", +] +categories = ["science"] +description = "Multi-backend router decorator for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-router" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-router" +documentation = "https://docs.rs/burn-router" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["burn-backend/std", "burn-std/std", "burn-ir/std"] +# Client-side operation fusion / caching for router backends (e.g. the remote backend). Requires +# std (the fusion engine uses threads). +fusion = ["std", "dep:burn-fusion", "dep:serde"] +doc = ["default"] +tracing = [ + "burn-backend/tracing", + "burn-ir/tracing", + "burn-std/tracing", +] + +[dependencies] +burn-ir = { workspace = true } +burn-backend = { workspace = true } +burn-fusion = { workspace = true, optional = true } +burn-std = { workspace = true } +hashbrown = { workspace = true } +spin = { workspace = true } +log = { workspace = true } +serde = { workspace = true, optional = true } + +[dev-dependencies] +burn-tensor = { workspace = true } +burn-flex = { workspace = true, features = ["default"] } +burn-wgpu = { workspace = true, features = [ + "std", +] } + + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-router/README.md b/crates/burn-router/README.md new file mode 100644 index 0000000..be3e69a --- /dev/null +++ b/crates/burn-router/README.md @@ -0,0 +1,3 @@ +# Burn Router + +A multi-backend extension that forwards the tensor operations to the appropriate backend. diff --git a/crates/burn-router/src/backend.rs b/crates/burn-router/src/backend.rs new file mode 100644 index 0000000..cbba4a6 --- /dev/null +++ b/crates/burn-router/src/backend.rs @@ -0,0 +1,69 @@ +use super::{RouterChannel, RouterClient, RouterTensor, get_client}; +use alloc::{format, string::String}; +use burn_backend::{Backend, BackendTypes, DType, ExecutionError}; +use core::marker::PhantomData; + +/// A backend that forwards the tensor operations to the appropriate backend (given multiple backends). +pub struct BackendRouter { + r: PhantomData, +} + +impl core::fmt::Debug for BackendRouter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("router")) + } +} + +impl Clone for BackendRouter { + fn clone(&self) -> Self { + Self { r: PhantomData } + } +} + +impl Default for BackendRouter { + fn default() -> Self { + Self { r: PhantomData } + } +} + +impl BackendTypes for BackendRouter { + type Device = R::Device; + + type FloatTensorPrimitive = RouterTensor; + type IntTensorPrimitive = RouterTensor; + type BoolTensorPrimitive = RouterTensor; + type QuantizedTensorPrimitive = RouterTensor; + + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for BackendRouter { + fn name(device: &Self::Device) -> String { + format!("router<{}>", R::name(device)) + } + + fn seed(device: &Self::Device, seed: u64) { + let client = get_client::(device); + client.seed(seed); + } + + fn sync(device: &Self::Device) -> Result<(), ExecutionError> { + let client = get_client::(device); + client.sync() + } + + fn dtype_usage(device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet { + let client = get_client::(device); + client.dtype_usage(dtype) + } + + fn device_count(_: u16) -> usize { + // This is what was there before, not sure if it's actually correct + 1 + } + + fn flush(device: &Self::Device) { + let client = get_client::(device); + client.flush(); + } +} diff --git a/crates/burn-router/src/bridge/base.rs b/crates/burn-router/src/bridge/base.rs new file mode 100644 index 0000000..8e61854 --- /dev/null +++ b/crates/burn-router/src/bridge/base.rs @@ -0,0 +1,32 @@ +use burn_backend::{Shape, backend::DeviceOps}; + +/// Allows tensors to be transferred between multiple backends. +pub trait MultiBackendBridge: Send + Sync + 'static { + /// The type that can be used to point to a tensor of any kind. + type TensorHandle; + /// Device type used by the backends. + type Device: DeviceOps; + + /// Change the backend of the given float tensor. + fn change_backend_float( + tensor: Self::TensorHandle, + shape: Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle; + + /// Change the backend of the given int tensor. + fn change_backend_int( + tensor: Self::TensorHandle, + shape: Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle; + + /// Change the backend of the given bool tensor. + fn change_backend_bool( + tensor: Self::TensorHandle, + shape: Shape, + target_device: &Self::Device, + ) -> Self::TensorHandle; + + // TODO: change_backend_quantized +} diff --git a/crates/burn-router/src/bridge/mod.rs b/crates/burn-router/src/bridge/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-router/src/bridge/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-router/src/channel/base.rs b/crates/burn-router/src/channel/base.rs new file mode 100644 index 0000000..c206919 --- /dev/null +++ b/crates/burn-router/src/channel/base.rs @@ -0,0 +1,60 @@ +use alloc::string::String; +use burn_backend::{DType, Shape, backend::DeviceOps}; +use burn_ir::TensorIr; + +use crate::{MultiBackendBridge, RouterClient, RouterTensor, get_client}; + +/// Type alias for `
::TensorHandle`. +pub type TensorHandle
=
::TensorHandle; + +/// Defines the connection channel and operations for a setup with multiple backend router clients. +pub trait RouterChannel: Clone + Send + Sync + 'static + Sized { + /// Device type. + type Device: DeviceOps; + /// A bridge that can transfer tensors between multiple backends. + type Bridge: MultiBackendBridge; + /// Client type. + type Client: RouterClient; + + /// Name of the channel. + fn name(device: &Self::Device) -> String; + + /// Initialize a new client for the given device. + fn init_client(device: &Self::Device) -> Self::Client; + + /// Get the tensor handle corresponding to the [tensor representation](TensorIr). + fn get_tensor_handle(tensor: &TensorIr, client: &Self::Client) -> TensorHandle; + + /// Create a tensor with the given handle and shape. + fn register_tensor( + client: &Self::Client, + handle: TensorHandle, + shape: Shape, + dtype: DType, + ) -> RouterTensor; + + /// Change the tensor to a different client backend. + fn change_client_backend( + tensor: RouterTensor, + device: &Self::Device, // target device + ) -> RouterTensor { + // Get tensor handle from current client + let original_client = tensor.client.clone(); + let desc = tensor.into_ir(); + let mut handle = Self::get_tensor_handle(&desc, &original_client); + + if desc.dtype.is_float() { + handle = Self::Bridge::change_backend_float(handle, desc.shape.clone(), device); + } else if desc.dtype.is_int() { + handle = Self::Bridge::change_backend_int(handle, desc.shape.clone(), device); + } else if desc.dtype.is_bool() { + handle = Self::Bridge::change_backend_bool(handle, desc.shape.clone(), device); + } else { + unimplemented!() + } + + // Register tensor handle on target client + let target_client = get_client::(device); + Self::register_tensor(&target_client, handle, desc.shape, desc.dtype) + } +} diff --git a/crates/burn-router/src/channel/mod.rs b/crates/burn-router/src/channel/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-router/src/channel/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-router/src/client/base.rs b/crates/burn-router/src/client/base.rs new file mode 100644 index 0000000..122bf1d --- /dev/null +++ b/crates/burn-router/src/client/base.rs @@ -0,0 +1,161 @@ +use crate::{RouterChannel, RouterTensor}; +use alloc::boxed::Box; +use alloc::vec::Vec; +use burn_backend::{ + DType, TensorData, + backend::{DeviceId, DeviceOps, ExecutionError}, +}; +use burn_ir::{GraphBindings, GraphId, OperationIr, TensorId, TensorIr}; +use burn_std::future::DynFut; +use core::ops::DerefMut; +use hashbrown::HashMap; +use spin::Mutex; + +/// Type alias for `::Client`. +pub type Client = ::Client; +pub(crate) static CLIENTS: RouterClientLocator = RouterClientLocator::new(); + +type Key = (core::any::TypeId, DeviceId); + +/// Define how to interact with the interpreter. +pub trait RouterClient: Clone + Send + Sync + Sized { + /// Device type. + type Device: DeviceOps; + + /// Register a new tensor operation to be executed by the interpreter (server). + fn register_op(&self, op: OperationIr); + /// Register a new tensor operation to be executed by the interpreter (server). + /// + /// Returns the new (uninitialized) output tensor(s) generated by the registered operation. + fn register(&self, op: OperationIr) -> Vec> { + let out = op + .outputs() + .map(|output| { + RouterTensor::new(output.id, output.shape.clone(), output.dtype, self.clone()) + }) + .collect(); + self.register_op(op); + + out + } + /// Read the values contained by a tensor. + fn read_tensor_async(&self, tensor: TensorIr) -> DynFut>; + /// Sync the interpreter, ensure that all computations are finished. + fn sync(&self) -> Result<(), ExecutionError>; + /// Eagerly submit the operations registered so far without waiting for them to complete. + /// + /// Unlike [`sync`](Self::sync), this does not block on results — it only ensures the + /// buffered operations are handed off for execution (and, for the remote backend, sent to + /// the server) instead of sitting in a local buffer. + fn flush(&self); + /// Create a new (uninitialized) empty tensor and returns its corresponding [tensor id](TensorId). + fn create_empty_handle(&self) -> TensorId; + /// Create a new [RouterTensor] from the tensor data. + fn register_tensor_data(&self, data: TensorData) -> RouterTensor; + /// Get the current device used by all operations handled by this client. + fn device(&self) -> Self::Device; + /// Seed the interpreter. + fn seed(&self, seed: u64); + /// Returns the supported data type usage set + fn dtype_usage(&self, dtype: DType) -> burn_backend::DTypeUsageSet; + + /// Register a reusable group of operations (in relative form) under `graph_id` *and* run its + /// first invocation with `bindings`, so it can later be replayed by id with + /// [`execute_graph`](Self::execute_graph). + /// + /// Registration always coincides with the first execution, so they're combined to save a + /// round-trip on a cache miss. Used by the fusion layer to avoid re-sending a recurring + /// op-graph. + fn register_and_execute_graph( + &self, + graph_id: GraphId, + relative_graph: Vec, + bindings: GraphBindings, + ); + + /// Replay a previously [registered](Self::register_and_execute_graph) graph with the given + /// concrete bindings. + fn execute_graph(&self, graph_id: GraphId, bindings: GraphBindings); + + /// Register `new_id` as an alias of `src_id` — a second handle over the same backing buffer. + /// + /// Used by the fusion layer's cross-stream sharing (see + /// [`FusionRuntime::alias_handle`](burn_fusion::FusionRuntime::alias_handle)): when a tensor is + /// shared to another stream, that stream's view needs its own id so that consuming it (a + /// `ReadWrite` last-use) frees only this alias, leaving the original handle valid. The server + /// clones the source handle (an `Arc`-style refcount on the device buffer) under `new_id`. + fn register_alias(&self, new_id: TensorId, src_id: TensorId); +} + +pub(crate) struct RouterClientLocator { + clients: Mutex>>>, +} + +/// Get the client for the given device +pub fn get_client(device: &R::Device) -> Client { + CLIENTS.client::(device) +} + +/// Initialize a new client for the given device. +/// +/// If a (global) seed was previously set, the client seed is set. +fn new_client(device: &R::Device) -> Client { + R::init_client(device) +} + +impl RouterClientLocator { + /// Create a new client locator. + pub const fn new() -> Self { + Self { + clients: Mutex::new(None), + } + } + + /// Get the router client for the given device. + /// + /// If a client isn't already initialized, it is created. + pub fn client(&self, device: &R::Device) -> Client { + let device_id = device.id(); + let client_id = (core::any::TypeId::of::(), device_id); + let mut clients = self.clients.lock(); + + if clients.is_none() { + let client = new_client::(device); + Self::register_inner::(client_id, client, &mut clients); + } + + match clients.deref_mut() { + Some(clients) => match clients.get(&client_id) { + Some(client) => { + let client: &Client = client.downcast_ref().unwrap(); + client.clone() + } + None => { + let client = new_client::(device); + let any = Box::new(client.clone()); + clients.insert(client_id, any); + client + } + }, + _ => unreachable!(), + } + } + + fn register_inner( + key: Key, + client: Client, + clients: &mut Option>>, + ) { + if clients.is_none() { + *clients = Some(HashMap::new()); + } + + if let Some(clients) = clients { + if clients.contains_key(&key) { + panic!("Client already created for device {key:?}"); + } + + clients.insert(key, Box::new(client)); + } + } +} diff --git a/crates/burn-router/src/client/mod.rs b/crates/burn-router/src/client/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-router/src/client/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-router/src/custom_op.rs b/crates/burn-router/src/custom_op.rs new file mode 100644 index 0000000..0c76b42 --- /dev/null +++ b/crates/burn-router/src/custom_op.rs @@ -0,0 +1,152 @@ +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use burn_backend::backend::BackendTypes; +use burn_ir::{BackendIr, CustomOpIr, HandleContainer}; +use hashbrown::HashMap; + +/// A handler that executes a single custom operation against a backend's tensor handles. +/// +/// It reads its input tensors (and any scalar arguments) out of `handles`, runs the actual backend +/// computation on the interpreter's `device`, and registers the resulting output tensor(s) back +/// into `handles` under the ids declared by the [`CustomOpIr`]. The `device` lets a handler create +/// brand-new tensors (e.g. a custom op with no tensor inputs, like a server-side data loader). This +/// is the server-side counterpart of the client building an `OperationIr::Custom` — see +/// [`CustomOpRegistry`]. +pub type CustomOpHandler = Arc< + dyn Fn( + &mut HandleContainer<::Handle>, + &CustomOpIr, + &::Device, + ) + Send + + Sync, +>; + +/// A set of [custom operation handlers](CustomOpHandler), keyed by the custom op's id. +/// +/// The remote backend ships custom ops to the server as `OperationIr::Custom(CustomOpIr)`; the +/// server's [`TensorInterpreter`](crate::TensorInterpreter) can't know how to execute an arbitrary +/// custom op on its own, so it looks the id up in this registry and calls the matching handler. +/// +/// The registry is *owned* state (cheap to clone — handlers live behind an [`Arc`]), built once +/// before the server starts and shared read-only across every session's interpreter. There is no +/// global/static registry to manage. +#[derive(Clone)] +pub struct CustomOpRegistry { + handlers: HashMap>, +} + +impl Default for CustomOpRegistry { + fn default() -> Self { + Self { + handlers: HashMap::new(), + } + } +} + +impl CustomOpRegistry { + /// Create a new, empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Register the handler executed when a custom op with the given `id` reaches the interpreter. + /// + /// The `id` must match the one the client puts in its [`CustomOpIr`]. Registering the same id + /// twice replaces the previous handler. + pub fn register(&mut self, id: &str, handler: F) + where + F: Fn(&mut HandleContainer, &CustomOpIr, &B::Device) + Send + Sync + 'static, + { + self.handlers.insert(id.to_string(), Arc::new(handler)); + } + + /// Get the handler registered for `id`, if any. + pub(crate) fn get(&self, id: &str) -> Option<&CustomOpHandler> { + self.handlers.get(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TensorInterpreter; + use burn_backend::{DType, Scalar, Shape, TensorData, ops::FloatTensorOps}; + use burn_flex::Flex; + use burn_ir::{OperationIr, ScalarIr, TensorId, TensorIr}; + use std::sync::Mutex; + + #[test] + fn custom_op_is_dispatched_with_scalars() { + // A handler that scales a float tensor by a scalar argument, and records what it received so + // the test can assert the interpreter routed the op (and its scalars) to it. + let seen_scalars = Arc::new(Mutex::new(None)); + let seen_scalars_handler = seen_scalars.clone(); + + let mut registry = CustomOpRegistry::::new(); + registry.register("scale", move |handles, ir, _device| { + let input = handles.get_float_tensor::(&ir.inputs[0]); + let factor: Scalar = ir.scalars[0].into(); + let output = Flex::float_mul_scalar(input, factor); + handles.register_float_tensor::(&ir.outputs[0].id, output); + *seen_scalars_handler.lock().unwrap() = Some(ir.scalars.clone()); + }); + + let mut interp = TensorInterpreter::::with_custom_ops(Default::default(), registry); + let input = interp.register_tensor_data_desc(TensorData::from([2.0f32, 4.0])); + let output = TensorIr::uninit(TensorId::new(1_000_000), Shape::from([2]), DType::F32); + + let desc = + CustomOpIr::with_scalars("scale", &[input], &[output], vec![ScalarIr::Float(3.0)]); + interp.register_op(OperationIr::Custom(desc)); + + // The interpreter routed the op to our handler, carrying the scalar we shipped. (The handler + // also read its input and registered an output; either failing would panic before here.) + assert_eq!( + seen_scalars.lock().unwrap().clone(), + Some(vec![ScalarIr::Float(3.0)]) + ); + } + + #[test] + fn custom_op_can_create_tensors_from_the_device() { + // A no-input handler that builds a tensor from scratch on the interpreter's device — the + // shape of a server-side data loader. Exercises the `&device` passed to the handler. + use burn_backend::ops::IntTensorOps; + + let mut registry = CustomOpRegistry::::new(); + registry.register("load", |handles, ir, device| { + let values: Vec = ir.scalars.iter().map(|s| s.elem::()).collect(); + let n = values.len(); + let tensor = Flex::int_from_data(TensorData::new(values, [n]), device); + handles.register_int_tensor::(&ir.outputs[0].id, tensor); + }); + + let mut interp = TensorInterpreter::::with_custom_ops(Default::default(), registry); + let out = TensorIr::uninit(TensorId::new(2_000_000), Shape::from([3]), DType::I64); + let desc = CustomOpIr::with_scalars( + "load", + &[], + &[out.clone()], + vec![ScalarIr::Int(10), ScalarIr::Int(20), ScalarIr::Int(30)], + ); + interp.register_op(OperationIr::Custom(desc)); + + // The handler created the tensor on the device and registered it under the output id. + let data = interp.get_tensor(&TensorIr { + status: burn_ir::TensorStatus::ReadOnly, + ..out + }); + match data { + burn_ir::HandleKind::Int(_) => {} + _ => panic!("expected an int tensor"), + } + } + + #[test] + #[should_panic(expected = "No custom-op handler registered")] + fn unregistered_custom_op_panics() { + let mut interp = TensorInterpreter::::new(Default::default()); + let desc = CustomOpIr::new("missing", &[], &[]); + interp.register_op(OperationIr::Custom(desc)); + } +} diff --git a/crates/burn-router/src/fusion.rs b/crates/burn-router/src/fusion.rs new file mode 100644 index 0000000..fb903a1 --- /dev/null +++ b/crates/burn-router/src/fusion.rs @@ -0,0 +1,576 @@ +//! Generic client-side graph caching for any [router backend](crate::BackendRouter). +//! +//! Wrapping a router backend as [`Fusion`](burn_fusion::Fusion) turns recurring groups of tensor +//! operations into reusable, client-cached graphs. A single greedy [`RouterFuser`] accumulates +//! every operation and is drained only at a sync point, so one graph covers each connected block +//! between syncs. On execution the group is registered once on the backend (via the +//! [`RouterClient`]) and thereafter invoked by id with only the changing bindings — for the remote +//! backend this means a recurring computation (e.g. a model block) crosses the network once +//! instead of every step. +//! +//! `burn-fusion` names its hook `Optimization`; here that hook *is* a cached graph execution +//! ([`RouterGraphExecution`]), to distinguish it from a compute backend's kernel fusion. + +use std::collections::HashSet; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicU64, Ordering}; + +use burn_backend::DType; +use burn_backend::ops::FloatTensorOps; +use burn_fusion::stream::{Context, Operation, OrderedExecution}; +use burn_fusion::{ + FuserProperties, FuserStatus, FusionBackend, FusionRuntime, NumOperations, OperationFuser, + Optimization, +}; +use burn_ir::{ + BackendIr, CustomOpIr, GraphBindings, GraphId, Handle, HandleContainer, OperationIr, ScalarIr, + TensorHandle, TensorId, TensorIr, TensorStatus, +}; +use serde::{Deserialize, Serialize}; + +use burn_std::config::config; + +use crate::{BackendRouter, RouterChannel, RouterClient, RouterTensor, get_client}; + +static GRAPH_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_graph_id() -> GraphId { + GraphId(GRAPH_ID_COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +// The router backend already implements `Backend`; these two impls add the `BackendIr` + +// `FusionBackend` glue so it can be wrapped as `Fusion>`. All four tensor +// primitives are `RouterTensor`, so the handle conversions are the identity (mirroring the +// `impl BackendIr for Fusion` in burn-fusion). +impl BackendIr for BackendRouter { + type Handle = RouterTensor; + + fn float_tensor(handle: TensorHandle) -> burn_backend::tensor::FloatTensor { + handle.handle + } + + fn int_tensor(handle: TensorHandle) -> burn_backend::tensor::IntTensor { + handle.handle + } + + fn bool_tensor(handle: TensorHandle) -> burn_backend::tensor::BoolTensor { + handle.handle + } + + fn quantized_tensor( + handle: TensorHandle, + ) -> burn_backend::tensor::QuantizedTensor { + handle.handle + } + + fn float_tensor_handle(tensor: burn_backend::tensor::FloatTensor) -> Self::Handle { + tensor + } + + fn int_tensor_handle(tensor: burn_backend::tensor::IntTensor) -> Self::Handle { + tensor + } + + fn bool_tensor_handle(tensor: burn_backend::tensor::BoolTensor) -> Self::Handle { + tensor + } + + fn quantized_tensor_handle( + tensor: burn_backend::tensor::QuantizedTensor, + ) -> Self::Handle { + tensor + } +} + +impl FusionBackend for BackendRouter { + type FusionRuntime = RouterFusionRuntime; + type FullPrecisionBackend = Self; + + fn cast_float(tensor: burn_backend::tensor::FloatTensor, dtype: DType) -> Self::Handle { + Self::float_cast(tensor, dtype.into()) + } +} + +/// The [fusion runtime](FusionRuntime) for a [router backend](BackendRouter). +/// +/// Its [handle](FusionRuntime::FusionHandle) is a [`RouterTensor`] — a lightweight reference to a +/// backend-resident tensor plus the client to reach it — and its "optimization" is a backend-cached +/// op-graph (see [`RouterGraphExecution`]). +pub struct RouterFusionRuntime { + _p: PhantomData, +} + +impl core::fmt::Debug for RouterFusionRuntime { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("RouterFusionRuntime") + } +} + +impl FusionRuntime for RouterFusionRuntime { + type OptimizationState = RouterGraphExecutionState; + type Optimization = RouterGraphExecution; + type FusionHandle = RouterTensor; + type FusionDevice = R::Device; + + fn fusers(device: R::Device) -> Vec>> { + vec![Box::new(RouterFuser::::new(device))] + } + + fn alias_handle(handle: &RouterTensor) -> RouterTensor { + // The router handle is a thin id into a server-side tensor, so a bare `clone()` would keep + // the same server id — every cross-stream alias would collapse onto one server handle, and + // the first stream to consume it (`ReadWrite`) would free it for the others. Instead mint a + // fresh id and have the server register it as an alias of the same buffer (a refcounted + // clone), so each stream's view frees independently. Mirrors local backends, where the + // default `clone()` already yields an independent `HandleContainer` entry over a shared + // `Arc` buffer. + let id = handle.client.create_empty_handle(); + handle.client.register_alias(id, handle.id); + RouterTensor::new( + id, + handle.shape.clone(), + handle.dtype, + handle.client.clone(), + ) + } + + fn free_handle(handles: &mut HandleContainer>, tensor: &TensorIr) { + // Only `ReadWrite` (last-use) nodes are freed here, matching `HandleContainer::free`. + if tensor.status != TensorStatus::ReadWrite { + return; + } + // The drained block already freed this tensor server-side: every consumed op (a `Drop`, or + // a `ReadWrite` input of a compute op) is replayed on the server and pops its handle. So + // remove the client entry WITHOUT letting `RouterTensor::drop` register a *second*, + // redundant `Drop` for the same id. Bumping the refcount makes that drop a no-op. + if let Some(Handle::Existing(handle)) = handles.remove_handle(tensor.id) { + handle + .count + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } + } +} + +/// A greedy operation fuser that records every operation and never closes itself. +/// +/// Because [`status`](OperationFuser::status) always reports [`FuserStatus::Open`], the fusion +/// engine keeps deferring in lazy mode and only drains the queue at a sync point (a read, `sync`, +/// or `flush`). At that point [`finish`](OperationFuser::finish) yields a single +/// [`RouterGraphExecution`] covering the whole accumulated (connected) block. +pub struct RouterFuser { + device: R::Device, + ops: Vec, + score: u64, + score_max: u64, + num_since_max_unchanged: usize, + /// Close the graph once it reaches this many ops, if set (`FusionConfig::max_graph_size`). + max_graph_size: Option, + /// Close the graph once the score hasn't reached a new max for this many consecutive ops + /// (`FusionConfig::growth_patience`). + growth_patience: usize, +} + +impl RouterFuser { + fn new(device: R::Device) -> Self { + let cfg = config(); + let fusion = cfg.fusion(); + Self { + device, + ops: Vec::new(), + score: 0, + score_max: 0, + num_since_max_unchanged: 0, + max_graph_size: fusion.max_graph_size, + growth_patience: fusion.growth_patience, + } + } + + /// Value-based fusion score for the currently accumulated ops. + /// + /// Benefit: estimated % of serialized bytes caching saves per replay (the relative graph vs the + /// per-replay bindings — see [`estimate_saved_pct`]), weighted by `FACTOR_SAVED`. Cost: a + /// penalty that grows with op count past `FREE_OPS`, so the score *peaks* at a "right-sized" + /// graph and then decays once the per-op overhead outweighs the savings. + /// + /// Floored at 1 for any non-empty graph: a score of 0 makes `find_best_optimization_index` + /// treat the graph as "don't fuse" (streaming every op unfused → the cache never replays). The + /// `+1` doesn't move the argmax, so it doesn't change which size wins. + fn score(&self) -> u64 { + const FACTOR_SAVED: u64 = 100; // weight per % point saved → benefit in 0..=10_000 + const FREE_OPS: usize = 64; // ops below this are not penalized + const PENALTY_PER_OP: u64 = 0; // penalty per op beyond FREE_OPS + + let benefit = estimate_saved_pct(&self.ops) * FACTOR_SAVED; + let penalty = (self.ops.len().saturating_sub(FREE_OPS) as u64) * PENALTY_PER_OP; + benefit.saturating_sub(penalty) + 1 + } +} + +impl Clone for RouterFuser { + fn clone(&self) -> Self { + Self { + device: self.device.clone(), + ops: self.ops.clone(), + score: self.score, + score_max: self.score_max, + num_since_max_unchanged: self.num_since_max_unchanged, + max_graph_size: self.max_graph_size, + growth_patience: self.growth_patience, + } + } +} + +impl OperationFuser> for RouterFuser { + fn fuse(&mut self, operation: &OperationIr) { + self.ops.push(operation.clone()); + + self.score = self.score(); + if self.score > self.score_max { + self.score_max = self.score; + self.num_since_max_unchanged = 0; + } else { + self.num_since_max_unchanged += 1; + } + } + + fn finish(&mut self) -> RouterGraphExecution { + let ops = core::mem::take(&mut self.ops); + RouterGraphExecution::new(ops, self.device.clone()) + } + + fn reset(&mut self) { + self.ops.clear(); + } + + fn status(&self) -> FuserStatus { + let over_max = self.max_graph_size.is_some_and(|max| self.len() > max); + if self.num_since_max_unchanged >= self.growth_patience || over_max { + FuserStatus::Closed + } else { + FuserStatus::Open + } + } + + fn properties(&self) -> FuserProperties { + FuserProperties { + score: self.score, + ready: self.ops.len() > 1, + } + } + + fn len(&self) -> usize { + self.ops.len() + } + + fn clone_dyn(&self) -> Box>> { + Box::new(self.clone()) + } +} + +/// Unfused execution of a single [custom op](OperationIr::Custom) on a router backend. +/// +/// Every built-in op reaches the backend through a `BackendRouter` method, so when the fuser leaves +/// it unfused (a one-op segment — e.g. a source op whose output is read right away) `burn-fusion` +/// runs that method op by op. A custom op has no such method, so it needs this handler: it is the +/// [`Operation`] registered alongside the op's IR, and runs only on the unfused path. When the op is +/// instead fused into a [`RouterGraphExecution`] it ships as part of the graph and this never runs. +/// +/// It mirrors what a built-in op does on that path: resolve the fused input handles to their +/// backend tensor ids, ship the op through the [`RouterClient`], and bind the outputs so downstream +/// ops find them. (Without it the op would silently do nothing and the backend would never create +/// the output handle — "Should have handle for tensor ..." on the next access.) +pub struct CustomOperation { + ir: CustomOpIr, + device: R::Device, +} + +impl core::fmt::Debug for CustomOperation { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CustomOperation") + .field("id", &self.ir.id) + .finish() + } +} + +impl CustomOperation { + /// Create the unfused handler for `ir`, executed on `device`. + pub fn new(ir: CustomOpIr, device: R::Device) -> Self { + Self { ir, device } + } +} + +impl Operation> for CustomOperation { + fn execute(&self, handles: &mut HandleContainer>) { + let client = get_client::(&self.device); + + // Map each fused input handle to its backend tensor id. `into_ir` carries the + // refcount/free semantics, so a last-use (`ReadWrite`) input still frees correctly. + let inputs: Vec = self + .ir + .inputs + .iter() + .map(|input| handles.get_handle(&input.id, &input.status).into_ir()) + .collect(); + + // Mint fresh backend ids for the outputs. + let outputs: Vec> = self + .ir + .outputs + .iter() + .map(|out| { + RouterTensor::new( + client.create_empty_handle(), + out.shape.clone(), + out.dtype, + client.clone(), + ) + }) + .collect(); + + // Ship the op to the backend with the translated ids; scalars travel unchanged. + client.register_op(OperationIr::Custom(CustomOpIr { + id: self.ir.id.clone(), + inputs, + outputs: outputs.iter().map(|out| out.to_ir_out()).collect(), + scalars: self.ir.scalars.clone(), + })); + + // Bind the outputs under their fused ids so any downstream op resolves them. + for (out, tensor) in self.ir.outputs.iter().zip(outputs) { + handles.register_handle(out.id, tensor); + } + } +} + +/// A reusable group of operations, registered once on the backend and thereafter invoked by id. +/// +/// The recorded [`graph`](Vec) is in *relative* form (positional tensor ids, relative +/// shape-dim ids, scalar placeholders), which is invariant across invocations — that is what lets +/// the backend cache it and the client reuse it. On each [`execute`](Optimization::execute) only +/// the concrete bindings are computed from the [`Context`] and sent; the (large) graph itself +/// travels only on the first invocation. +pub struct RouterGraphExecution { + graph: Vec, + device: R::Device, + /// Relative ids of the graph's boundary, precomputed once from the (static) graph so each + /// replay is O(boundary) instead of re-scanning every tensor. Inputs are tensors not produced + /// by a compute op (external data / prior results, incl. `Init`/`from_data` outputs); outputs + /// are compute-produced tensors that survive (neither consumed in place nor dropped here). + input_ids: Vec, + output_ids: Vec, + /// Backend-side id, assigned and registered on the first execution and reused afterwards. + graph_id: Option, + _p: PhantomData, +} + +/// Serializable state for a [`RouterGraphExecution`]. +/// +/// The backend id is intentionally not serialized — it is per-connection state, so a deserialized +/// graph re-registers itself on first use. +#[derive(Serialize, Deserialize)] +pub struct RouterGraphExecutionState { + graph: Vec, +} + +impl RouterGraphExecution { + fn new(graph: Vec, device: R::Device) -> Self { + let (input_ids, output_ids) = classify_boundary(&graph); + Self { + graph, + device, + input_ids, + output_ids, + graph_id: None, + _p: PhantomData, + } + } +} + +/// Precompute the graph's boundary as `(input relative ids, surviving-output relative ids)`. +/// +/// - **Inputs** are tensors that aren't produced by a *compute* op: external data and prior-block +/// results read by the graph, plus `Init`/`from_data` outputs (whose handle is registered +/// out-of-band, so they're really inputs even though an `Init` op "produces" them). +/// - **Outputs** are compute-produced tensors that survive — neither consumed in place +/// (`ReadWrite` anywhere) nor dropped within the graph. This mirrors the fusion engine's own +/// `drain_queue` freeing logic, keeping client-side handle state consistent with the unfused path. +/// +/// Intermediate tensors (compute-produced and consumed/dropped here) are in neither list — the +/// replay owns their ids — so a replay only ever touches the boundary. +fn classify_boundary(graph: &[OperationIr]) -> (Vec, Vec) { + let mut referenced: HashSet = HashSet::new(); + let mut compute_produced: HashSet = HashSet::new(); + let mut consumed: HashSet = HashSet::new(); + for op in graph { + if let OperationIr::Drop(tensor) = op { + consumed.insert(tensor.id); + } + if !matches!(op, OperationIr::Init(_)) { + for tensor in op.outputs() { + compute_produced.insert(tensor.id); + } + } + for tensor in op.nodes() { + referenced.insert(tensor.id); + if tensor.status == TensorStatus::ReadWrite { + consumed.insert(tensor.id); + } + } + } + + let inputs = referenced + .iter() + .filter(|id| !compute_produced.contains(id)) + .copied() + .collect(); + let outputs = compute_produced + .iter() + .filter(|id| !consumed.contains(id)) + .copied() + .collect(); + (inputs, outputs) +} + +/// Estimate the % of serialized bytes that caching this op-graph saves per replay. +/// +/// Dependency-free structural estimate (reuses [`classify_boundary`]): the baseline is the whole +/// relative graph's bytes (op overhead + each tensor's id/dtype/status + its dims); the per-replay +/// bindings are just the boundary `(relative id, concrete id)` pairs plus the distinct dim table. +/// Scalars/ranges travel in both and cancel in the ratio. Returns 0..=100. +fn estimate_saved_pct(ops: &[OperationIr]) -> u64 { + if ops.is_empty() { + return 0; + } + const TENSOR: u64 = 10; // id (≈8) + dtype + status + const PER_DIM: u64 = 8; + const OP_OVERHEAD: u64 = 8; // variant tag + small bookkeeping + const PAIR: u64 = 16; // a (relative id, concrete id) binding entry + + let mut baseline = 0u64; + let mut dims: HashSet = HashSet::new(); + for op in ops { + baseline += OP_OVERHEAD; + for tensor in op.nodes().into_iter().chain(op.outputs()) { + baseline += TENSOR; + for dim in tensor.shape.iter() { + baseline += PER_DIM; + dims.insert(*dim); + } + } + } + + let (inputs, outputs) = classify_boundary(ops); + let bindings = (inputs.len() + outputs.len()) as u64 * PAIR + dims.len() as u64 * PER_DIM; + + let saved = baseline.saturating_sub(bindings); + (saved * 100 / baseline).min(100) +} + +impl core::fmt::Debug for RouterGraphExecution { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RouterGraphExecution") + .field("len", &self.graph.len()) + .finish() + } +} + +impl NumOperations for RouterGraphExecution { + fn len(&self) -> usize { + self.graph.len() + } + + fn name(&self) -> &'static str { + "RouterGraphExecution" + } +} + +impl Optimization> for RouterGraphExecution { + fn execute( + &mut self, + context: &mut Context>, + _execution: &OrderedExecution>, + ) { + let client = get_client::(&self.device); + + // Walk only the precomputed boundary — never every tensor — so a replay is O(boundary). + // Inputs reuse their resident concrete id; surviving outputs get a fresh id and a handle. + // Intermediates are never touched here: the replay owns their ids and derives their shapes + // from the shape table below. + let mut tensors: Vec<(TensorId, TensorId)> = + Vec::with_capacity(self.input_ids.len() + self.output_ids.len()); + + for &input_id in &self.input_ids { + let global_id = match context.tensors.get(&input_id) { + Some(global) => global.id, + None => continue, + }; + if let Some(handle) = context.handles.get_handle_ref(&global_id) { + tensors.push((input_id, handle.id())); + } + } + + for &output_id in &self.output_ids { + // Extract owned metadata first so the `context.tensors` borrow ends before we touch + // `context.handles`. + let output = context + .tensors + .get(&output_id) + .map(|global| (global.id, global.shape.clone(), global.dtype)); + if let Some((fusion_id, shape, dtype)) = output { + let concrete_id = client.create_empty_handle(); + tensors.push((output_id, concrete_id)); + let handle = RouterTensor::new(concrete_id, shape, dtype, client.clone()); + context.handles.register_handle(fusion_id, handle); + } + } + + // Dense shape-dim table indexed by relative dim id (dim ids are dense `0..N`). + let mut shapes = vec![0usize; context.shapes_relative2global.len()]; + for (relative, concrete) in context.shapes_relative2global.iter() { + if *relative < shapes.len() { + shapes[*relative] = *concrete; + } + } + + // Concrete scalar values, indexed by their placeholder id. + let mut scalars = vec![ScalarIr::UInt(0); context.scalars.len()]; + for (scalar_id, value) in context.scalars.iter() { + let idx = scalar_id.value as usize; + if idx < scalars.len() { + scalars[idx] = *value; + } + } + + // Concrete slice ranges, indexed by their placeholder id (carried in a relative range's + // `start`). Cheap to clone — a few `Slice`s per slice op — and, like scalars, they can + // change between invocations of the same cached graph, so they travel every time. + let ranges = context.ranges.clone(); + + let bindings = GraphBindings { + tensors, + shapes, + scalars, + ranges, + }; + match self.graph_id { + // Already registered: replay by id, sending only the bindings. + Some(id) => client.execute_graph(id, bindings), + // First invocation: register the relative graph and execute it in a single round-trip. + None => { + let id = next_graph_id(); + self.graph_id = Some(id); + client.register_and_execute_graph(id, self.graph.clone(), bindings); + } + }; + } + + fn to_state(&self) -> RouterGraphExecutionState { + RouterGraphExecutionState { + graph: self.graph.clone(), + } + } + + fn from_state(device: &R::Device, state: RouterGraphExecutionState) -> Self { + Self::new(state.graph, device.clone()) + } +} diff --git a/crates/burn-router/src/graph.rs b/crates/burn-router/src/graph.rs new file mode 100644 index 0000000..a2c44a0 --- /dev/null +++ b/crates/burn-router/src/graph.rs @@ -0,0 +1,137 @@ +//! A cached relative op-graph and its replay against a [`TensorInterpreter`]. +//! +//! A router server registers a recurring op sequence once as a [`Graph`] (in *relative* form: +//! positional tensor ids, relative shape dims, placeholder scalars/ranges), then replays it by id +//! with only the per-invocation [`GraphBindings`]. This is the server-side counterpart of the +//! client's cached optimization — it turns a recurring computation (e.g. a model block per step) +//! into one registration plus cheap replays. + +use core::sync::atomic::{AtomicU64, Ordering}; + +use alloc::sync::Arc; +use alloc::vec::Vec; + +use burn_backend::Slice; +use burn_ir::{BackendIr, GraphBindings, IrVisitorMut, OperationIr, ScalarIr, TensorId, TensorIr}; +use hashbrown::HashMap; + +use crate::TensorInterpreter; + +/// Server-allocated ids for a replay's intermediate tensors carry this high bit so they can never +/// collide with client-allocated ids (whose monotonic counter never reaches `1 << 63`). The bit is +/// purely server-internal: intermediates are produced and freed within a single replay and are +/// never referenced by the client. +const INTERMEDIATE_ID_BIT: u64 = 1 << 63; +static INTERMEDIATE_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn alloc_intermediate_id() -> TensorId { + let value = INTERMEDIATE_ID_COUNTER.fetch_add(1, Ordering::Relaxed); + TensorId::new(value | INTERMEDIATE_ID_BIT) +} + +/// A cached relative op-graph, registered once and [replayed](Graph::replay) by id with +/// per-invocation [`GraphBindings`]. +/// +/// Cheap to clone — the op list is shared behind an [`Arc`]. That lets a server keep its graph +/// cache behind a short-lived lock: clone the [`Graph`] handle out under the lock, then replay +/// after releasing it, so the lock never spans the backend dispatch. +#[derive(Clone, Debug)] +pub struct Graph { + ops: Arc>, +} + +impl Graph { + /// Wrap a relative op-graph so it can be replayed. + pub fn new(ops: Vec) -> Self { + Self { ops: Arc::new(ops) } + } + + /// Number of operations in the graph. + pub fn len(&self) -> usize { + self.ops.len() + } + + /// Whether the graph has no operations. + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Replay the graph against `interpreter`, rebinding the relative form to concrete tensors. + /// + /// The graph is in relative form: its tensor ids are positional, shape dims are relative ids, + /// and scalars/ranges are placeholders. `bindings` arrive packaged the way the replay uses + /// them — the boundary `tensors` map is moved straight into the working id table and grown with + /// intermediate ids on demand, and `shapes` is a dense table indexed by relative dim id. For + /// each op we: + /// - resolve every tensor id to its boundary binding, or a freshly allocated intermediate id + /// (memoized so all references to one intermediate agree); + /// - rewrite every tensor's shape dims in place via the shape table (so intermediates get + /// correct shapes too, without being sent); + /// - substitute scalar placeholders and restore concrete slice ranges; + /// + /// then hand the rebound op to the unchanged [`TensorInterpreter::register_op`], reproducing the + /// exact sequence of global ops the client would have streamed op-by-op. + pub fn replay( + &self, + interpreter: &mut TensorInterpreter, + bindings: GraphBindings, + ) { + let GraphBindings { + tensors, + shapes, + scalars, + ranges, + } = bindings; + // The boundary map *is* the working id table — seeded here, intermediates added on demand. + let mut ids: HashMap = tensors.into_iter().collect(); + for op in self.ops.iter() { + let mut op = op.clone(); + let mut visitor = ReplayVisitor { + ids: &mut ids, + shapes: &shapes, + scalars: &scalars, + ranges: &ranges, + }; + op.visit_mut(&mut visitor); + interpreter.register_op(op); + } + } +} + +/// Rebinds a relative op's tensors, scalars, and ranges to their concrete values during replay. +struct ReplayVisitor<'a> { + /// The working id table; intermediates are allocated on demand and memoized here so all + /// references to one intermediate agree. Persists across ops within a replay. + ids: &'a mut HashMap, + shapes: &'a [usize], + scalars: &'a [ScalarIr], + ranges: &'a [Slice], +} + +impl IrVisitorMut for ReplayVisitor<'_> { + fn visit_tensor_mut(&mut self, tensor: &mut TensorIr) { + tensor.id = *self + .ids + .entry(tensor.id) + .or_insert_with(alloc_intermediate_id); + for dim in tensor.shape.iter_mut() { + *dim = self.shapes.get(*dim).copied().unwrap_or(*dim); + } + } + + fn visit_scalar_mut(&mut self, scalar: &mut ScalarIr) { + if let ScalarIr::UInt(placeholder) = *scalar + && (placeholder as usize) < self.scalars.len() + { + *scalar = self.scalars[placeholder as usize]; + } + } + + fn visit_range_mut(&mut self, range: &mut Slice) { + // Restore concrete slice bounds: relativization replaced each range with a placeholder + // whose `start` is the binding id (see `OperationConverter::relative_range`). + if let Some(concrete) = self.ranges.get(range.start as usize) { + *range = *concrete; + } + } +} diff --git a/crates/burn-router/src/interpreter.rs b/crates/burn-router/src/interpreter.rs new file mode 100644 index 0000000..94c7ba2 --- /dev/null +++ b/crates/burn-router/src/interpreter.rs @@ -0,0 +1,2170 @@ +use core::sync::atomic::{AtomicU64, Ordering}; + +use super::{RouterClient, RouterTensor}; +use crate::CustomOpRegistry; +use crate::{ + binary_bool_ops, binary_float_cmp_ops, binary_float_ops, binary_int_cmp_ops, binary_int_ops, + reduce_float_dim_ops, reduce_float2int_dim_ops, reduce_int_dim_ops, scalar_float_cmp_ops, + scalar_float_ops, scalar_int_cmp_ops, scalar_int_ops, unary_float_ops, unary_int_ops, +}; +use alloc::boxed::Box; +use burn_backend::{ + Backend, DType, DeviceOps, ExecutionError, Shape, TensorData, distributed::DistributedOps, + tensor::IndexingUpdateOp, +}; +use burn_ir::{ + ActivationOperationIr, BackendIr, BaseOperationIr, BoolOperationIr, FloatOperationIr, + HandleContainer, HandleKind, IntOperationIr, ModuleOperationIr, NumericOperationIr, + OperationIr, TensorId, TensorIr, TensorStatus, +}; +use burn_std::{DeviceSettings, future::DynFut}; + +/// An interpreter's context contains a [handle container](HandleContainer) to manage +/// (i.e., fetch and update) existing tensors. +pub struct InterpreterContext { + /// Handle container to retrieve tensors based on their intermediate representation. + handles: HandleContainer, +} + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +impl InterpreterContext { + /// Create a new (uninitialized) empty tensor and returns its corresponding [tensor id](TensorId). + fn create_empty_handle(&mut self) -> TensorId { + let value = COUNTER.fetch_add(1, Ordering::Relaxed); + TensorId::new(value) + } +} + +/// A tensor interpreter is responsible for executing tensor operations for a given [intermediate backend](BackendIr). +/// +/// Single-owner and **not** `Clone`: the interpreter is driven by exactly one thread (the remote +/// server's per-session worker), so the handle container is owned directly rather than behind a +/// lock — every op takes `&mut self`, with no per-op `Mutex` on the hot path. +pub struct TensorInterpreter { + context: InterpreterContext, + device: B::Device, + /// Handlers for [custom operations](OperationIr::Custom), keyed by id. Shared read-only across + /// every session, so executing a custom op is a map lookup plus a call. + custom_ops: CustomOpRegistry, +} + +impl core::fmt::Debug for TensorInterpreter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("TensorInterpreter") + .field("device", &self.device) + .finish() + } +} + +impl TensorInterpreter { + /// Create a new interpreter without any custom operation handlers. + pub fn new(device: B::Device) -> Self { + Self::with_custom_ops(device, CustomOpRegistry::default()) + } + + /// Create a new interpreter with the given [custom operation handlers](CustomOpRegistry). + pub fn with_custom_ops(device: B::Device, custom_ops: CustomOpRegistry) -> Self { + Self { + context: InterpreterContext { + handles: HandleContainer::new(), + }, + device, + custom_ops, + } + } + + /// Get the tensor handle for the given [tensor representation](TensorIr). + pub fn get_tensor_handle(&mut self, tensor: &TensorIr) -> B::Handle { + let handles = &mut self.context.handles; + handles.get_tensor_handle(tensor).handle + } + + /// Take the typed backend primitive for `tensor`, dispatching on its dtype. + /// + /// Unlike [`get_tensor_handle`](Self::get_tensor_handle) (which returns the opaque + /// `B::Handle`), this returns the concrete float/int/bool primitive so the caller can hand + /// it to `B::*_to_device`. Used by the same-host transfer path, which moves a tensor between + /// two interpreters living in the same server process without a host round-trip. + pub fn get_tensor(&mut self, tensor: &TensorIr) -> HandleKind { + let handles = &mut self.context.handles; + let dtype = tensor.dtype; + if dtype.is_float() { + HandleKind::Float(handles.get_float_tensor::(tensor)) + } else if dtype.is_int() { + HandleKind::Int(handles.get_int_tensor::(tensor)) + } else if dtype.is_bool() { + HandleKind::Bool(handles.get_bool_tensor::(tensor)) + } else { + todo!("Local transfer of {dtype:?} tensors is not supported yet"); + } + } + + /// Move a primitive produced on another interpreter's device onto this interpreter's device + /// and register it under `id`. + /// + /// The counterpart of [`get_tensor`](Self::get_tensor): the source interpreter hands over its + /// primitive, and the destination calls `B::*_to_device` onto its own device. When both + /// interpreters share the same device, the backend's `to_device` is a cheap no-op. + pub fn register_tensor_to_device(&mut self, id: TensorId, tensor: HandleKind) { + let ctx = &mut self.context; + match tensor { + HandleKind::Float(tensor) => { + let tensor = B::float_to_device(tensor, &self.device); + ctx.handles.register_float_tensor::(&id, tensor); + } + HandleKind::Int(tensor) => { + let tensor = B::int_to_device(tensor, &self.device); + ctx.handles.register_int_tensor::(&id, tensor); + } + HandleKind::Bool(tensor) => { + let tensor = B::bool_to_device(tensor, &self.device); + ctx.handles.register_bool_tensor::(&id, tensor); + } + HandleKind::Quantized(_) => { + todo!("Local transfer of quantized tensors is not supported yet"); + } + } + } + + /// Create a tensor with the given handle and shape. + pub fn register_tensor( + &mut self, + handle: B::Handle, + shape: Shape, + dtype: DType, + client: C, + ) -> RouterTensor { + let ctx = &mut self.context; + let id = ctx.create_empty_handle(); + + ctx.handles.register_handle(id, handle); + + RouterTensor::new(id, shape, dtype, client) + } + + /// Register `new_id` as an alias of `src_id`: a second handle over the same backing buffer. + /// + /// Used for the fusion layer's cross-stream sharing (see + /// [`RouterClient::register_alias`](crate::RouterClient::register_alias)). Cloning the backend + /// handle is a cheap `Arc`-style refcount bump, so the buffer survives until *both* ids are + /// freed — consuming the alias on one stream can't pull it out from under the other. + pub fn register_alias(&mut self, new_id: TensorId, src_id: TensorId) { + let ctx = &mut self.context; + let handle = ctx + .handles + .get_handle_ref(&src_id) + .expect("alias source tensor must be materialized before it is aliased") + .clone(); + ctx.handles.register_handle(new_id, handle); + } + + /// Register a tensor from its data and id. + pub fn register_tensor_data_id(&mut self, id: TensorId, data: TensorData) { + let ctx = &mut self.context; + let dtype = data.dtype; + + if dtype.is_float() { + let tensor = B::float_from_data(data, &self.device); + ctx.handles.register_float_tensor::(&id, tensor) + } else if dtype.is_int() { + let tensor = B::int_from_data(data, &self.device); + ctx.handles.register_int_tensor::(&id, tensor) + } else if dtype.is_bool() { + let tensor = B::bool_from_data(data, &self.device); + ctx.handles.register_bool_tensor::(&id, tensor) + } else if let DType::QFloat(_) = dtype { + todo!(); + } + } + + /// Register a tensor and returns its intermediate representation. + pub fn register_tensor_data_desc(&mut self, data: TensorData) -> TensorIr { + let ctx = &mut self.context; + let id = ctx.create_empty_handle(); + let shape = data.shape.clone(); + let dtype = data.dtype; + + if dtype.is_float() { + let tensor = B::float_from_data(data, &self.device); + ctx.handles.register_float_tensor::(&id, tensor) + } else if dtype.is_int() { + let tensor = B::int_from_data(data, &self.device); + ctx.handles.register_int_tensor::(&id, tensor) + } else if dtype.is_bool() { + let tensor = B::bool_from_data(data, &self.device); + ctx.handles.register_bool_tensor::(&id, tensor) + } else if let DType::QFloat(_) = dtype { + todo!(); + } + + TensorIr { + id, + shape, + status: TensorStatus::ReadWrite, + dtype, + } + } + + /// Get the device default settings. + pub fn device_settings(&self) -> DeviceSettings { + self.device.defaults() + } +} + +impl TensorInterpreter { + /// Execute a tensor operation. + pub fn register_op(&mut self, op: OperationIr) { + // Remove unused tensor handles + let ctx = &mut self.context; + + let handles = &mut ctx.handles; + match &op { + // For every op: get the input(s), execute the operation and register the output(s) + OperationIr::BaseFloat(op) => match op { + BaseOperationIr::Reshape(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_reshape(tensor, desc.out.shape.clone()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::SwapDims(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_swap_dims(tensor, desc.dim1, desc.dim2); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Permute(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_permute(tensor, &desc.axes); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Flip(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_flip(tensor, &desc.axes); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Expand(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_expand(tensor, desc.out.shape.clone()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Unfold(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_unfold(tensor, desc.dim, desc.size, desc.step); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Slice(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + + let output = B::float_slice(tensor, &desc.ranges); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::SliceAssign(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let value = handles.get_float_tensor::(&desc.value); + + let output = B::float_slice_assign(tensor, &desc.ranges, value); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Gather(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::float_gather(desc.dim, tensor, indices); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Scatter(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_float_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::float_scatter_add(desc.dim, tensor, indices, value) + } + _ => unimplemented!(), + }; + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::ScatterNd(desc) => { + let data = handles.get_float_tensor::(&desc.data); + let indices = handles.get_int_tensor::(&desc.indices); + let values = handles.get_float_tensor::(&desc.values); + + let output = B::float_scatter_nd(data, indices, values, desc.reduction); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::GatherNd(desc) => { + let data = handles.get_float_tensor::(&desc.data); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::float_gather_nd(data, indices); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Select(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::float_select(tensor, desc.dim, indices); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::SelectAssign(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_float_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::float_select_add(tensor, desc.dim, indices, value) + } + _ => unimplemented!(), + }; + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskWhere(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + let value = handles.get_float_tensor::(&desc.value); + + let output = B::float_mask_where(tensor, mask, value); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskFill(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + + let output = B::float_mask_fill(tensor, mask, desc.value.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Equal(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_equal) + } + BaseOperationIr::EqualElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_equal_elem) + } + BaseOperationIr::RepeatDim(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + + let output = B::float_repeat_dim(tensor, desc.dim, desc.times); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cat(desc) => { + let tensors = desc + .tensors + .iter() + .map(|tensor| handles.get_float_tensor::(tensor)) + .collect(); + + let output = B::float_cat(tensors, desc.dim); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cast(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_cast(tensor, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Empty(desc) => { + let shape = desc.out.shape.clone(); + let output = B::float_empty(shape, &self.device, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Ones(desc) => { + let shape = desc.out.shape.clone(); + let output = B::float_ones(shape, &self.device, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::Zeros(desc) => { + let shape = desc.out.shape.clone(); + let output = B::float_zeros(shape, &self.device, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BaseOperationIr::NotEqual(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_not_equal) + } + BaseOperationIr::NotEqualElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_not_equal_elem) + } + BaseOperationIr::All(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_all(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Any(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_any(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AllDim(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_all_dim(tensor, desc.axis, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AnyDim(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_any_dim(tensor, desc.axis, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + }, + OperationIr::BaseInt(op) => match op { + BaseOperationIr::Reshape(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_reshape(tensor, desc.out.shape.clone()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::SwapDims(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_swap_dims(tensor, desc.dim1, desc.dim2); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Permute(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_permute(tensor, &desc.axes); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Flip(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_flip(tensor, &desc.axes); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Expand(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_expand(tensor, desc.out.shape.clone()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Unfold(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_unfold(tensor, desc.dim, desc.size, desc.step); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Slice(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + + let output = B::int_slice(tensor, &desc.ranges); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::SliceAssign(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let value = handles.get_int_tensor::(&desc.value); + + let output = B::int_slice_assign(tensor, &desc.ranges, value); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Gather(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::int_gather(desc.dim, tensor, indices); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Scatter(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_int_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::int_scatter_add(desc.dim, tensor, indices, value) + } + _ => unimplemented!(), + }; + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::ScatterNd(desc) => { + let data = handles.get_int_tensor::(&desc.data); + let indices = handles.get_int_tensor::(&desc.indices); + let values = handles.get_int_tensor::(&desc.values); + + let output = B::int_scatter_nd(data, indices, values, desc.reduction); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::GatherNd(desc) => { + let data = handles.get_int_tensor::(&desc.data); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::int_gather_nd(data, indices); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Select(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::int_select(tensor, desc.dim, indices); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::SelectAssign(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_int_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::int_select_add(tensor, desc.dim, indices, value) + } + _ => unimplemented!(), + }; + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskWhere(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + let value = handles.get_int_tensor::(&desc.value); + + let output = B::int_mask_where(tensor, mask, value); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskFill(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + + let output = B::int_mask_fill(tensor, mask, desc.value.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Equal(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_equal) + } + BaseOperationIr::EqualElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_equal_elem) + } + BaseOperationIr::RepeatDim(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + + let output = B::int_repeat_dim(tensor, desc.dim, desc.times); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cat(desc) => { + let tensors = desc + .tensors + .iter() + .map(|tensor| handles.get_int_tensor::(tensor)) + .collect(); + + let output = B::int_cat(tensors, desc.dim); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cast(_) => unreachable!(), + BaseOperationIr::Empty(desc) => { + let shape = desc.out.shape.clone(); + let output = B::int_empty(shape, &self.device, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Ones(desc) => { + let shape = desc.out.shape.clone(); + let output = B::int_ones(shape, &self.device, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::Zeros(desc) => { + let shape = desc.out.shape.clone(); + let output = B::int_zeros(shape, &self.device, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + BaseOperationIr::NotEqual(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_not_equal) + } + BaseOperationIr::NotEqualElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_not_equal_elem) + } + BaseOperationIr::All(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_all(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Any(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_any(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AllDim(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_all_dim(tensor, desc.axis, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AnyDim(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_any_dim(tensor, desc.axis, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + }, + OperationIr::BaseBool(op) => match op { + BaseOperationIr::Reshape(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_reshape(tensor, desc.out.shape.clone()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::SwapDims(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_swap_dims(tensor, desc.dim1, desc.dim2); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Permute(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_permute(tensor, &desc.axes); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Flip(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_flip(tensor, &desc.axes); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Expand(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_expand(tensor, desc.out.shape.clone()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Unfold(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_unfold(tensor, desc.dim, desc.size, desc.step); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Slice(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + + let output = B::bool_slice(tensor, &desc.ranges); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::SliceAssign(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let value = handles.get_bool_tensor::(&desc.value); + + let output = B::bool_slice_assign(tensor, &desc.ranges, value); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Gather(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::bool_gather(desc.dim, tensor, indices); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Scatter(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_bool_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::bool_scatter_or(desc.dim, tensor, indices, value) + } + _ => unimplemented!(), + }; + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::ScatterNd(_) => { + unreachable!("scatter_nd not supported for bool tensors") + } + BaseOperationIr::GatherNd(_) => { + unreachable!("gather_nd not supported for bool tensors") + } + BaseOperationIr::Select(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::bool_select(tensor, desc.dim, indices); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::SelectAssign(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let indices = handles.get_int_tensor::(&desc.indices); + let value = handles.get_bool_tensor::(&desc.value); + + let output = match desc.update { + IndexingUpdateOp::Add => { + B::bool_select_or(tensor, desc.dim, indices, value) + } + _ => unimplemented!(), + }; + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskWhere(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + let value = handles.get_bool_tensor::(&desc.value); + + let output = B::bool_mask_where(tensor, mask, value); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::MaskFill(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + let mask = handles.get_bool_tensor::(&desc.mask); + + let output = B::bool_mask_fill(tensor, mask, desc.value.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Equal(desc) => { + let lhs = handles.get_bool_tensor::(&desc.lhs); + let rhs = handles.get_bool_tensor::(&desc.rhs); + + let output = B::bool_equal(lhs, rhs); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::EqualElem(desc) => { + let lhs = handles.get_bool_tensor::(&desc.lhs); + + let output = B::bool_equal_elem(lhs, desc.rhs.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::RepeatDim(desc) => { + let tensor = handles.get_bool_tensor::(&desc.tensor); + + let output = B::bool_repeat_dim(tensor, desc.dim, desc.times); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cat(desc) => { + let tensors = desc + .tensors + .iter() + .map(|tensor| handles.get_bool_tensor::(tensor)) + .collect(); + + let output = B::bool_cat(tensors, desc.dim); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Cast(_) => unreachable!(), + BaseOperationIr::Empty(desc) => { + let shape = desc.out.shape.clone(); + let output = B::bool_empty(shape, &self.device, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Zeros(desc) => { + let shape = desc.out.shape.clone(); + let output = B::bool_zeros(shape, &self.device, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Ones(desc) => { + let shape = desc.out.shape.clone(); + let output = B::bool_ones(shape, &self.device, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::NotEqual(desc) => { + let lhs = handles.get_bool_tensor::(&desc.lhs); + let rhs = handles.get_bool_tensor::(&desc.rhs); + let output = B::bool_not_equal(lhs, rhs); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::NotEqualElem(desc) => { + let lhs = handles.get_bool_tensor::(&desc.lhs); + let output = B::bool_not_equal_elem(lhs, desc.rhs.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::All(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + let output = B::bool_all(tensor); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::Any(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + let output = B::bool_any(tensor); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AllDim(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + let output = B::bool_all_dim(tensor, desc.axis); + handles.register_bool_tensor::(&desc.out.id, output); + } + BaseOperationIr::AnyDim(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + let output = B::bool_any_dim(tensor, desc.axis); + handles.register_bool_tensor::(&desc.out.id, output); + } + }, + OperationIr::NumericFloat(_dtype, op) => match op { + NumericOperationIr::Add(desc) => { + binary_float_ops!(handles, desc, B::float_add) + } + NumericOperationIr::AddScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_add_scalar) + } + NumericOperationIr::Sub(desc) => { + binary_float_ops!(handles, desc, B::float_sub) + } + NumericOperationIr::SubScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_sub_scalar) + } + NumericOperationIr::Div(desc) => { + binary_float_ops!(handles, desc, B::float_div) + } + NumericOperationIr::DivScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_div_scalar) + } + NumericOperationIr::Rem(desc) => { + binary_float_ops!(handles, desc, B::float_remainder) + } + NumericOperationIr::RemScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_remainder_scalar) + } + NumericOperationIr::Mul(desc) => { + binary_float_ops!(handles, desc, B::float_mul) + } + NumericOperationIr::MulScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_mul_scalar) + } + NumericOperationIr::Abs(desc) => { + unary_float_ops!(handles, desc, B::float_abs) + } + NumericOperationIr::Full(desc) => { + let shape = desc.out.shape.clone(); + let output = B::float_full( + shape, + desc.value.into(), + &self.device, + desc.out.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::MeanDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_mean_dim( + tensor, axis + )) + } + NumericOperationIr::Mean(desc) => { + unary_float_ops!(handles, desc, B::float_mean) + } + NumericOperationIr::Sum(desc) => { + unary_float_ops!(handles, desc, B::float_sum) + } + NumericOperationIr::SumDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_sum_dim( + tensor, axis + )) + } + NumericOperationIr::Prod(desc) => { + unary_float_ops!(handles, desc, B::float_prod) + } + NumericOperationIr::ProdDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_prod_dim( + tensor, axis + )) + } + NumericOperationIr::Greater(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_greater) + } + NumericOperationIr::GreaterElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_greater_elem) + } + NumericOperationIr::GreaterEqual(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_greater_equal) + } + NumericOperationIr::GreaterEqualElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_greater_equal_elem) + } + NumericOperationIr::Lower(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_lower) + } + NumericOperationIr::LowerElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_lower_elem) + } + NumericOperationIr::LowerEqual(desc) => { + binary_float_cmp_ops!(handles, desc, B::float_lower_equal) + } + NumericOperationIr::LowerEqualElem(desc) => { + scalar_float_cmp_ops!(handles, desc, B::float_lower_equal_elem) + } + NumericOperationIr::ArgMax(desc) => { + reduce_float2int_dim_ops!(handles, desc, |tensor, axis, _, dtype| { + B::float_argmax(tensor, axis, dtype) + }) + } + NumericOperationIr::ArgTopK(desc) => { + reduce_float2int_dim_ops!(handles, desc, B::float_argtopk) + } + NumericOperationIr::ArgMin(desc) => { + reduce_float2int_dim_ops!(handles, desc, |tensor, axis, _, dtype| { + B::float_argmin(tensor, axis, dtype) + }) + } + NumericOperationIr::Max(desc) => { + unary_float_ops!(handles, desc, B::float_max) + } + NumericOperationIr::MaxDimWithIndices(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + + let (output, output_idx) = B::float_max_dim_with_indices( + tensor, + desc.dim, + desc.out_indices.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + handles.register_int_tensor::(&desc.out_indices.id, output_idx); + } + NumericOperationIr::MinDimWithIndices(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + + let (output, output_idx) = B::float_min_dim_with_indices( + tensor, + desc.dim, + desc.out_indices.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + handles.register_int_tensor::(&desc.out_indices.id, output_idx); + } + NumericOperationIr::Min(desc) => { + unary_float_ops!(handles, desc, B::float_min) + } + NumericOperationIr::MaxDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_max_dim( + tensor, axis + )) + } + NumericOperationIr::TopK(desc) => { + reduce_float_dim_ops!(handles, desc, B::float_topk) + } + NumericOperationIr::MinDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_min_dim( + tensor, axis + )) + } + NumericOperationIr::MaxAbs(desc) => { + unary_float_ops!(handles, desc, B::float_max_abs) + } + NumericOperationIr::MaxAbsDim(desc) => { + reduce_float_dim_ops!(handles, desc, |tensor, axis, _| B::float_max_abs_dim( + tensor, axis + )) + } + NumericOperationIr::Clamp(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + + let output = B::float_clamp(tensor, desc.min.into(), desc.max.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::IntRandom(_) => unreachable!(), + NumericOperationIr::Powi(desc) => { + let lhs = handles.get_float_tensor::(&desc.lhs); + let rhs = handles.get_int_tensor::(&desc.rhs); + let output = (B::float_powi)(lhs, rhs); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::PowiScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_powi_scalar) + } + NumericOperationIr::CumSum(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_cumsum(tensor, desc.axis); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumProd(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_cumprod(tensor, desc.axis); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumMin(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_cummin(tensor, desc.axis); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumMax(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_cummax(tensor, desc.axis); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::Neg(desc) => { + unary_float_ops!(handles, desc, B::float_neg) + } + NumericOperationIr::Sign(desc) => { + unary_float_ops!(handles, desc, B::float_sign) + } + NumericOperationIr::ClampMin(desc) => { + scalar_float_ops!(handles, desc, B::float_clamp_min) + } + NumericOperationIr::ClampMax(desc) => { + scalar_float_ops!(handles, desc, B::float_clamp_max) + } + NumericOperationIr::Sort(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = B::float_sort(tensor, desc.dim, desc.descending); + handles.register_float_tensor::(&desc.out.id, output); + } + NumericOperationIr::SortWithIndices(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let (values, indices) = B::float_sort_with_indices( + tensor, + desc.dim, + desc.descending, + desc.out_indices.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, values); + handles.register_int_tensor::(&desc.out_indices.id, indices); + } + NumericOperationIr::ArgSort(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + let output = + B::float_argsort(tensor, desc.dim, desc.descending, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + }, + OperationIr::NumericInt(_dtype, op) => match op { + NumericOperationIr::Add(desc) => { + binary_int_ops!(handles, desc, B::int_add) + } + NumericOperationIr::AddScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_add_scalar) + } + NumericOperationIr::Sub(desc) => { + binary_int_ops!(handles, desc, B::int_sub) + } + NumericOperationIr::SubScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_sub_scalar) + } + NumericOperationIr::Div(desc) => { + binary_int_ops!(handles, desc, B::int_div) + } + NumericOperationIr::DivScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_div_scalar) + } + NumericOperationIr::Rem(desc) => { + binary_int_ops!(handles, desc, B::int_remainder) + } + NumericOperationIr::RemScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_remainder_scalar) + } + NumericOperationIr::Mul(desc) => { + binary_int_ops!(handles, desc, B::int_mul) + } + NumericOperationIr::MulScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_mul_scalar) + } + NumericOperationIr::Abs(desc) => { + unary_int_ops!(handles, desc, B::int_abs) + } + NumericOperationIr::Full(desc) => { + let shape = desc.out.shape.clone(); + let output = B::int_full( + shape, + desc.value.into(), + &self.device, + desc.out.dtype.into(), + ); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::MeanDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_mean_dim( + tensor, axis + )) + } + NumericOperationIr::Mean(desc) => { + unary_int_ops!(handles, desc, B::int_mean) + } + NumericOperationIr::Sum(desc) => { + unary_int_ops!(handles, desc, B::int_sum) + } + NumericOperationIr::SumDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_sum_dim( + tensor, axis + )) + } + NumericOperationIr::Prod(desc) => { + unary_int_ops!(handles, desc, B::int_prod) + } + NumericOperationIr::ProdDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_prod_dim( + tensor, axis + )) + } + NumericOperationIr::Greater(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_greater) + } + NumericOperationIr::GreaterElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_greater_elem) + } + NumericOperationIr::GreaterEqual(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_greater_equal) + } + NumericOperationIr::GreaterEqualElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_greater_equal_elem) + } + NumericOperationIr::Lower(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_lower) + } + NumericOperationIr::LowerElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_lower_elem) + } + NumericOperationIr::LowerEqual(desc) => { + binary_int_cmp_ops!(handles, desc, B::int_lower_equal) + } + NumericOperationIr::LowerEqualElem(desc) => { + scalar_int_cmp_ops!(handles, desc, B::int_lower_equal_elem) + } + NumericOperationIr::ArgMax(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_argmax( + tensor, axis + )) + } + NumericOperationIr::ArgTopK(desc) => { + reduce_int_dim_ops!(handles, desc, B::int_argtopk) + } + NumericOperationIr::ArgMin(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_argmin( + tensor, axis + )) + } + NumericOperationIr::Max(desc) => { + unary_int_ops!(handles, desc, B::int_max) + } + NumericOperationIr::MaxDimWithIndices(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + + let (output, output_idx) = B::int_max_dim_with_indices(tensor, desc.dim); + handles.register_int_tensor::(&desc.out.id, output); + handles.register_int_tensor::(&desc.out_indices.id, output_idx); + } + NumericOperationIr::MinDimWithIndices(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + + let (output, output_idx) = B::int_min_dim_with_indices(tensor, desc.dim); + handles.register_int_tensor::(&desc.out.id, output); + handles.register_int_tensor::(&desc.out_indices.id, output_idx); + } + NumericOperationIr::Min(desc) => { + unary_int_ops!(handles, desc, B::int_min) + } + NumericOperationIr::MaxDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_max_dim( + tensor, axis + )) + } + NumericOperationIr::TopK(desc) => { + reduce_int_dim_ops!(handles, desc, B::int_topk) + } + NumericOperationIr::MinDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_min_dim( + tensor, axis + )) + } + NumericOperationIr::MaxAbs(desc) => { + unary_int_ops!(handles, desc, B::int_max_abs) + } + NumericOperationIr::MaxAbsDim(desc) => { + reduce_int_dim_ops!(handles, desc, |tensor, axis, _| B::int_max_abs_dim( + tensor, axis + )) + } + NumericOperationIr::Clamp(desc) => { + let tensor = handles.get_int_tensor::(&desc.tensor); + + let output = B::int_clamp(tensor, desc.min.into(), desc.max.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::IntRandom(desc) => { + let shape = desc.out.shape.clone(); + + let output = B::int_random( + shape, + desc.distribution, + &self.device, + desc.out.dtype.into(), + ); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::Powi(desc) => { + let lhs = handles.get_int_tensor::(&desc.lhs); + let rhs = handles.get_int_tensor::(&desc.rhs); + + let output = B::int_powi(lhs, rhs); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::PowiScalar(desc) => { + scalar_int_ops!(handles, desc, B::int_powi_scalar) + } + NumericOperationIr::CumSum(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_cumsum(tensor, desc.axis); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumProd(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_cumprod(tensor, desc.axis); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumMin(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_cummin(tensor, desc.axis); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::CumMax(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_cummax(tensor, desc.axis); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::Neg(desc) => { + unary_int_ops!(handles, desc, B::int_neg) + } + NumericOperationIr::Sign(desc) => { + unary_int_ops!(handles, desc, B::int_sign) + } + NumericOperationIr::ClampMin(desc) => { + scalar_int_ops!(handles, desc, B::int_clamp_min) + } + NumericOperationIr::ClampMax(desc) => { + scalar_int_ops!(handles, desc, B::int_clamp_max) + } + NumericOperationIr::Sort(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_sort(tensor, desc.dim, desc.descending); + handles.register_int_tensor::(&desc.out.id, output); + } + NumericOperationIr::SortWithIndices(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let (values, indices) = + B::int_sort_with_indices(tensor, desc.dim, desc.descending); + handles.register_int_tensor::(&desc.out.id, values); + handles.register_int_tensor::(&desc.out_indices.id, indices); + } + NumericOperationIr::ArgSort(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + let output = B::int_argsort(tensor, desc.dim, desc.descending); + handles.register_int_tensor::(&desc.out.id, output); + } + }, + OperationIr::Bool(op) => match op { + BoolOperationIr::IntoFloat(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_into_float(tensor, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + BoolOperationIr::IntoInt(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_into_int(tensor, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + BoolOperationIr::Not(desc) => { + let tensor = handles.get_bool_tensor::(&desc.input); + + let output = B::bool_not(tensor); + handles.register_bool_tensor::(&desc.out.id, output); + } + BoolOperationIr::And(desc) => { + binary_bool_ops!(handles, desc, B::bool_and) + } + BoolOperationIr::Or(desc) => { + binary_bool_ops!(handles, desc, B::bool_or) + } + BoolOperationIr::Xor(desc) => { + binary_bool_ops!(handles, desc, B::bool_xor) + } + }, + OperationIr::Int(op) => match op { + IntOperationIr::IntoFloat(desc) => { + let tensor = handles.get_int_tensor::(&desc.input); + + let output = B::int_into_float(tensor, desc.out.dtype.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + IntOperationIr::Matmul(desc) => { + binary_int_ops!(handles, desc, B::int_matmul) + } + IntOperationIr::BitwiseAnd(desc) => { + binary_int_ops!(handles, desc, B::bitwise_and) + } + IntOperationIr::BitwiseAndScalar(desc) => { + scalar_int_ops!(handles, desc, B::bitwise_and_scalar) + } + IntOperationIr::BitwiseOr(desc) => { + binary_int_ops!(handles, desc, B::bitwise_or) + } + IntOperationIr::BitwiseOrScalar(desc) => { + scalar_int_ops!(handles, desc, B::bitwise_or_scalar) + } + IntOperationIr::BitwiseXor(desc) => { + binary_int_ops!(handles, desc, B::bitwise_xor) + } + IntOperationIr::BitwiseXorScalar(desc) => { + scalar_int_ops!(handles, desc, B::bitwise_xor_scalar) + } + IntOperationIr::BitwiseNot(desc) => { + unary_int_ops!(handles, desc, B::bitwise_not) + } + IntOperationIr::BitwiseLeftShift(desc) => { + binary_int_ops!(handles, desc, B::bitwise_left_shift) + } + IntOperationIr::BitwiseRightShift(desc) => { + binary_int_ops!(handles, desc, B::bitwise_right_shift) + } + IntOperationIr::BitwiseLeftShiftScalar(desc) => { + scalar_int_ops!(handles, desc, B::bitwise_left_shift_scalar) + } + IntOperationIr::BitwiseRightShiftScalar(desc) => { + scalar_int_ops!(handles, desc, B::bitwise_right_shift_scalar) + } + }, + OperationIr::Float(_dtype, op) => match op { + FloatOperationIr::Exp(desc) => { + unary_float_ops!(handles, desc, B::float_exp) + } + FloatOperationIr::Powf(desc) => { + binary_float_ops!(handles, desc, B::float_powf) + } + FloatOperationIr::Log(desc) => { + unary_float_ops!(handles, desc, B::float_log) + } + FloatOperationIr::Log1p(desc) => { + unary_float_ops!(handles, desc, B::float_log1p) + } + FloatOperationIr::Erf(desc) => { + unary_float_ops!(handles, desc, B::float_erf) + } + FloatOperationIr::PowfScalar(desc) => { + scalar_float_ops!(handles, desc, B::float_powf_scalar) + } + FloatOperationIr::Sqrt(desc) => { + unary_float_ops!(handles, desc, B::float_sqrt) + } + FloatOperationIr::Cos(desc) => { + unary_float_ops!(handles, desc, B::float_cos) + } + FloatOperationIr::Sin(desc) => { + unary_float_ops!(handles, desc, B::float_sin) + } + FloatOperationIr::Tanh(desc) => { + unary_float_ops!(handles, desc, B::float_tanh) + } + FloatOperationIr::Tan(desc) => unary_float_ops!(handles, desc, B::float_tan), + FloatOperationIr::Cosh(desc) => unary_float_ops!(handles, desc, B::float_cosh), + FloatOperationIr::Sinh(desc) => unary_float_ops!(handles, desc, B::float_sinh), + FloatOperationIr::ArcCos(desc) => unary_float_ops!(handles, desc, B::float_acos), + FloatOperationIr::ArcCosh(desc) => unary_float_ops!(handles, desc, B::float_acosh), + FloatOperationIr::ArcSin(desc) => unary_float_ops!(handles, desc, B::float_asin), + FloatOperationIr::ArcSinh(desc) => unary_float_ops!(handles, desc, B::float_asinh), + FloatOperationIr::ArcTan(desc) => unary_float_ops!(handles, desc, B::float_atan), + FloatOperationIr::ArcTanh(desc) => unary_float_ops!(handles, desc, B::float_atanh), + FloatOperationIr::ArcTan2(desc) => binary_float_ops!(handles, desc, B::float_atan2), + FloatOperationIr::Hypot(desc) => binary_float_ops!(handles, desc, B::float_hypot), + FloatOperationIr::Round(desc) => { + unary_float_ops!(handles, desc, B::float_round) + } + FloatOperationIr::Floor(desc) => { + unary_float_ops!(handles, desc, B::float_floor) + } + FloatOperationIr::Ceil(desc) => { + unary_float_ops!(handles, desc, B::float_ceil) + } + FloatOperationIr::Trunc(desc) => { + unary_float_ops!(handles, desc, B::float_trunc) + } + FloatOperationIr::IntoInt(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_into_int(tensor, desc.out.dtype.into()); + handles.register_int_tensor::(&desc.out.id, output); + } + FloatOperationIr::Matmul(desc) => { + binary_float_ops!(handles, desc, B::float_matmul) + } + FloatOperationIr::Cross(desc) => { + let lhs = handles.get_float_tensor::(&desc.lhs); + let rhs = handles.get_float_tensor::(&desc.rhs); + let output = B::float_cross(lhs, rhs, desc.dim); + handles.register_float_tensor::(&desc.out.id, output); + } + FloatOperationIr::Random(desc) => { + let shape = desc.out.shape.clone(); + + let output = B::float_random( + shape, + desc.distribution, + &self.device, + desc.out.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + FloatOperationIr::Recip(desc) => { + unary_float_ops!(handles, desc, B::float_recip) + } + FloatOperationIr::Quantize(_) => todo!(), + FloatOperationIr::Dequantize(_) => todo!(), + FloatOperationIr::IsNan(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_is_nan(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + FloatOperationIr::IsInf(desc) => { + let tensor = handles.get_float_tensor::(&desc.input); + + let output = B::float_is_inf(tensor, desc.out.dtype.into()); + handles.register_bool_tensor::(&desc.out.id, output); + } + FloatOperationIr::GridSample2d(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let grid = handles.get_float_tensor::(&desc.grid); + + let output = B::float_grid_sample_2d(tensor, grid, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + }, + OperationIr::Module(op) => match op { + ModuleOperationIr::Embedding(desc) => { + let weights = handles.get_float_tensor::(&desc.weights); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::embedding(weights, indices); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::EmbeddingBackward(desc) => { + let weights = handles.get_float_tensor::(&desc.weights); + let indices = handles.get_int_tensor::(&desc.indices); + let output_grad = handles.get_float_tensor::(&desc.out_grad); + + let output = B::embedding_backward(weights, output_grad, indices); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Linear(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::linear(x, weight, bias); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::LinearXBackward(desc) => { + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::linear_x_backward(weight, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::LinearWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::linear_weight_backward(x, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::LinearBiasBackward(desc) => { + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::linear_bias_backward(output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv1d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv1d(x, weight, bias, desc.clone().options.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv1dXBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = + B::conv1d_x_backward(x, weight, output_grad, desc.clone().options.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv1dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv1d_weight_backward( + x, + weight, + output_grad, + desc.clone().options.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv1dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv1d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv2d(x, weight, bias, desc.clone().options.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv2dXBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = + B::conv2d_x_backward(x, weight, output_grad, desc.clone().options.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv2dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv2d_weight_backward( + x, + weight, + output_grad, + desc.clone().options.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv2dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv2d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv3d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv3d(x, weight, bias, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv3dXBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = + B::conv3d_x_backward(x, weight, output_grad, desc.clone().options.into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv3dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv3d_weight_backward( + x, + weight, + output_grad, + desc.clone().options.into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Conv3dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + + let output = B::conv3d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::DeformableConv2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let offset = handles.get_float_tensor::(&desc.offset); + let mask = desc + .mask + .as_ref() + .map(|mask| handles.get_float_tensor::(mask)); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::deform_conv2d( + x, + offset, + weight, + mask, + bias, + desc.options.clone().into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::DeformableConv2dBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let offset = handles.get_float_tensor::(&desc.offset); + let mask = desc + .mask + .as_ref() + .map(|mask| handles.get_float_tensor::(mask)); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + let output_grad = handles.get_float_tensor::(&desc.out_grad); + + let output = B::deform_conv2d_backward( + x, + offset, + weight, + mask, + bias, + output_grad, + desc.options.clone().into(), + ); + + handles.register_float_tensor::(&desc.input_grad.id, output.x_grad); + handles.register_float_tensor::(&desc.offset_grad.id, output.offset_grad); + handles.register_float_tensor::(&desc.weight_grad.id, output.weight_grad); + if let Some((mask_grad, field)) = output.mask_grad.zip(desc.mask_grad.as_ref()) + { + handles.register_float_tensor::(&field.id, mask_grad); + } + if let Some((bias_grad, field)) = output.bias_grad.zip(desc.bias_grad.as_ref()) + { + handles.register_float_tensor::(&field.id, bias_grad); + } + } + ModuleOperationIr::ConvTranspose1d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose1d(x, weight, bias, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose2d(x, weight, bias, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose3d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let bias = desc + .bias + .as_ref() + .map(|bias| handles.get_float_tensor::(bias)); + + let output = B::conv_transpose3d(x, weight, bias, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AvgPool1d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::avg_pool1d( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.count_include_pad, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AvgPool2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::avg_pool2d( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.count_include_pad, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AvgPool1dBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let grad = handles.get_float_tensor::(&desc.grad); + + let output = B::avg_pool1d_backward( + x, + grad, + desc.kernel_size, + desc.stride, + desc.padding, + desc.count_include_pad, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AvgPool2dBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let grad = handles.get_float_tensor::(&desc.grad); + + let output = B::avg_pool2d_backward( + x, + grad, + desc.kernel_size, + desc.stride, + desc.padding, + desc.count_include_pad, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AdaptiveAvgPool1d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::adaptive_avg_pool1d(x, desc.output_size); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AdaptiveAvgPool2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::adaptive_avg_pool2d(x, desc.output_size); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AdaptiveAvgPool1dBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let grad = handles.get_float_tensor::(&desc.grad); + + let output = B::adaptive_avg_pool1d_backward(x, grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::AdaptiveAvgPool2dBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let grad = handles.get_float_tensor::(&desc.grad); + + let output = B::adaptive_avg_pool2d_backward(x, grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::MaxPool1d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::max_pool1d( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::MaxPool1dWithIndices(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::max_pool1d_with_indices( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + desc.out_indices.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output.output); + handles.register_int_tensor::(&desc.out_indices.id, output.indices); + } + ModuleOperationIr::MaxPool1dWithIndicesBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let output_grad = handles.get_float_tensor::(&desc.grad); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::max_pool1d_with_indices_backward( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + output_grad, + indices, + ); + handles.register_float_tensor::(&desc.out.id, output.x_grad); + } + ModuleOperationIr::MaxPool2d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::max_pool2d( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::MaxPool2dWithIndices(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::max_pool2d_with_indices( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + desc.out_indices.dtype.into(), + ); + handles.register_float_tensor::(&desc.out.id, output.output); + handles.register_int_tensor::(&desc.out_indices.id, output.indices); + } + ModuleOperationIr::MaxPool2dWithIndicesBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let output_grad = handles.get_float_tensor::(&desc.grad); + let indices = handles.get_int_tensor::(&desc.indices); + + let output = B::max_pool2d_with_indices_backward( + x, + desc.kernel_size, + desc.stride, + desc.padding, + desc.dilation, + desc.ceil_mode, + output_grad, + indices, + ); + handles.register_float_tensor::(&desc.out.id, output.x_grad); + } + ModuleOperationIr::Interpolate(desc) => { + let x = handles.get_float_tensor::(&desc.x); + + let output = B::interpolate(x, desc.output_size, desc.options.clone().into()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::InterpolateBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let grad = handles.get_float_tensor::(&desc.grad); + + let output = B::interpolate_backward( + x, + grad, + desc.output_size, + desc.options.clone().into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Rfft(desc) => { + let signal = handles.get_float_tensor::(&desc.signal); + let (out_re, out_im) = B::rfft(signal, desc.dim, desc.n); + + handles.register_float_tensor::(&desc.out_re.id, out_re); + handles.register_float_tensor::(&desc.out_im.id, out_im); + } + ModuleOperationIr::IRfft(desc) => { + let spectrum_re = handles.get_float_tensor::(&desc.input_re); + let spectrum_im = handles.get_float_tensor::(&desc.input_im); + let signal = B::irfft(spectrum_re, spectrum_im, desc.dim, desc.n); + + handles.register_float_tensor::(&desc.out_signal.id, signal); + } + ModuleOperationIr::Attention(desc) => { + let query = handles.get_float_tensor::(&desc.query); + let key = handles.get_float_tensor::(&desc.key); + let value = handles.get_float_tensor::(&desc.value); + let mask = desc.mask.as_ref().map(|m| handles.get_bool_tensor::(m)); + let attn_bias = desc + .attn_bias + .as_ref() + .map(|ab| handles.get_float_tensor::(ab)); + + let output = B::attention( + query, + key, + value, + mask, + attn_bias, + desc.options.clone().into(), + ); + + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::CtcLoss(desc) => { + let log_probs = handles.get_float_tensor::(&desc.log_probs); + let targets = handles.get_int_tensor::(&desc.targets); + let input_lengths = handles.get_int_tensor::(&desc.input_lengths); + let target_lengths = handles.get_int_tensor::(&desc.target_lengths); + + let output = B::ctc_loss( + log_probs, + targets, + input_lengths, + target_lengths, + desc.blank, + ); + + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::CtcLossBackward(desc) => { + let log_probs = handles.get_float_tensor::(&desc.log_probs); + let targets = handles.get_int_tensor::(&desc.targets); + let input_lengths = handles.get_int_tensor::(&desc.input_lengths); + let target_lengths = handles.get_int_tensor::(&desc.target_lengths); + let grad_loss = handles.get_float_tensor::(&desc.grad_loss); + + let output = B::ctc_loss_backward( + log_probs, + targets, + input_lengths, + target_lengths, + grad_loss, + desc.blank, + ); + + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::LayerNorm(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let gamma = handles.get_float_tensor::(&desc.gamma); + let beta = desc.beta.as_ref().map(|b| handles.get_float_tensor::(b)); + let output = B::layer_norm(input, gamma, beta, desc.epsilon.elem()); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::Unfold4d(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let options = burn_backend::ops::UnfoldOptions::new( + desc.options.stride, + desc.options.padding, + desc.options.dilation, + ); + let output = B::unfold4d(x, desc.kernel_size, options); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose1dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose1d_weight_backward( + x, + weight, + output_grad, + desc.options.clone().into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose1dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose1d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose2dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose2d_weight_backward( + x, + weight, + output_grad, + desc.options.clone().into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose2dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose2d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose3dWeightBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let weight = handles.get_float_tensor::(&desc.weight); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose3d_weight_backward( + x, + weight, + output_grad, + desc.options.clone().into(), + ); + handles.register_float_tensor::(&desc.out.id, output); + } + ModuleOperationIr::ConvTranspose3dBiasBackward(desc) => { + let x = handles.get_float_tensor::(&desc.x); + let bias = handles.get_float_tensor::(&desc.bias); + let output_grad = handles.get_float_tensor::(&desc.output_grad); + let output = B::conv_transpose3d_bias_backward(x, bias, output_grad); + handles.register_float_tensor::(&desc.out.id, output); + } + }, + OperationIr::Activation(op) => match op { + ActivationOperationIr::Relu(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let output = B::relu(input); + handles.register_float_tensor::(&desc.out.id, output); + } + ActivationOperationIr::ReluBackward(desc) => { + let output = handles.get_float_tensor::(&desc.lhs); + let grad = handles.get_float_tensor::(&desc.rhs); + let result = B::relu_backward(output, grad); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::LeakyRelu(desc) => { + let input = handles.get_float_tensor::(&desc.lhs); + let result = B::leaky_relu(input, desc.rhs.into()); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::PRelu(desc) => { + let input = handles.get_float_tensor::(&desc.lhs); + let alpha = handles.get_float_tensor::(&desc.rhs); + let result = B::prelu(input, alpha); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::Gelu(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::gelu(input); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::GeluBackward(desc) => { + let x = handles.get_float_tensor::(&desc.lhs); + let grad = handles.get_float_tensor::(&desc.rhs); + let result = B::gelu_backward(x, grad); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::Sigmoid(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::sigmoid(input); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::SigmoidBackward(desc) => { + let output = handles.get_float_tensor::(&desc.lhs); + let grad = handles.get_float_tensor::(&desc.rhs); + let result = B::sigmoid_backward(output, grad); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::HardSigmoid(desc) => { + let input = handles.get_float_tensor::(&desc.tensor); + let result = B::hard_sigmoid(input, desc.alpha.into(), desc.beta.into()); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::LogSigmoid(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::log_sigmoid(input); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::LogSigmoidBackward(desc) => { + let x = handles.get_float_tensor::(&desc.lhs); + let grad = handles.get_float_tensor::(&desc.rhs); + let result = B::log_sigmoid_backward(x, grad); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::Softmax(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::softmax(input, desc.axis); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::LogSoftmax(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::log_softmax(input, desc.axis); + handles.register_float_tensor::(&desc.out.id, result); + } + ActivationOperationIr::Softmin(desc) => { + let input = handles.get_float_tensor::(&desc.input); + let result = B::softmin(input, desc.axis); + handles.register_float_tensor::(&desc.out.id, result); + } + }, + OperationIr::Custom(desc) => match self.custom_ops.get(&desc.id) { + Some(handler) => handler(handles, desc, &self.device), + None => panic!( + "No custom-op handler registered for `{}`. Register one on the server via \ + `CustomOpRegistry`/the server builder before starting it.", + desc.id + ), + }, + OperationIr::Init(_) => { + // Nothing to do. + } + OperationIr::Drop(repr) => { + handles.remove_handle(repr.id); + } + OperationIr::Distributed(op) => match op { + burn_ir::DistributedOperationIr::AllReduce(desc) => { + let tensor = handles.get_float_tensor::(&desc.tensor); + let device_ids = desc.device_ids.iter().map(|id| (*id).into()).collect(); + + let output = >::all_reduce(tensor, desc.op, device_ids); + // Safety: the collective tensor is resolved through the normal op stream + // (a `SyncCollective` op follows), so the handle is valid once that runs. + let output = unsafe { output.assume_resolved() }; + B::flush(&self.device); + handles.register_float_tensor::(&desc.out.id, output); + } + burn_ir::DistributedOperationIr::SyncCollective => B::sync_collective(&self.device), + }, + } + } + + /// Read a tensor's data, returning a future for the host readback. + pub fn read_tensor_async( + &mut self, + tensor: TensorIr, + ) -> DynFut> { + let ctx = &mut self.context; + + enum Output { + Float(B::FloatTensorPrimitive), + Int(B::IntTensorPrimitive), + Bool(B::BoolTensorPrimitive), + } + + let tensor = if tensor.dtype.is_float() { + let tensor = ctx.handles.get_float_tensor::(&tensor); + Output::::Float(tensor) + } else if tensor.dtype.is_int() { + let tensor = ctx.handles.get_int_tensor::(&tensor); + Output::Int(tensor) + } else if tensor.dtype.is_bool() { + let tensor = ctx.handles.get_bool_tensor::(&tensor); + Output::Bool(tensor) + } else if let DType::QFloat(_) = tensor.dtype { + todo!() + } else { + unimplemented!() + }; + + match tensor { + Output::Float(val) => Box::pin(B::float_into_data(val)), + Output::Int(val) => Box::pin(B::int_into_data(val)), + Output::Bool(val) => Box::pin(B::bool_into_data(val)), + } + } + + /// The device this interpreter executes on. + pub fn device(&self) -> B::Device { + self.device.clone() + } + + /// Block until all queued backend work has completed. + pub fn sync(&self) -> Result<(), ExecutionError> { + B::sync(&self.device) + } + + /// Seed the backend's RNG. + pub fn seed(&self, seed: u64) { + B::seed(&self.device, seed) + } + + /// The set of supported usages for `dtype` on this backend. + pub fn dtype_usage(&self, dtype: DType) -> burn_backend::DTypeUsageSet { + B::dtype_usage(&self.device, dtype) + } +} diff --git a/crates/burn-router/src/lib.rs b/crates/burn-router/src/lib.rs new file mode 100644 index 0000000..13a4d3d --- /dev/null +++ b/crates/burn-router/src/lib.rs @@ -0,0 +1,31 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "138"] + +//! Burn multi-backend router. + +mod backend; +mod bridge; +mod channel; +mod client; +mod custom_op; +#[cfg(feature = "fusion")] +mod fusion; +mod graph; +mod interpreter; +mod ops; +mod tensor; + +pub use backend::*; +pub use bridge::*; +pub use channel::*; +pub use client::*; +pub use custom_op::*; +#[cfg(feature = "fusion")] +pub use fusion::*; +pub use graph::*; +pub use interpreter::*; +pub use tensor::*; + +extern crate alloc; diff --git a/crates/burn-router/src/ops/activation.rs b/crates/burn-router/src/ops/activation.rs new file mode 100644 index 0000000..bcfa04f --- /dev/null +++ b/crates/burn-router/src/ops/activation.rs @@ -0,0 +1,166 @@ +use crate::{BackendRouter, RouterChannel, RouterClient}; +use burn_backend::{Scalar, ops::ActivationOps, tensor::FloatTensor}; +use burn_ir::{ + ActivationOperationIr, BinaryOpIr, DimOpIr, HardSigmoidOpIr, OperationIr, OperationOutput, + ScalarOpIr, UnaryOpIr, +}; + +impl ActivationOps for BackendRouter { + fn relu(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::Relu(desc))) + .output() + } + + fn relu_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + let client = output.client.clone(); + let desc = BinaryOpIr::create(output.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Activation( + ActivationOperationIr::ReluBackward(desc), + )) + .output() + } + + fn leaky_relu(tensor: FloatTensor, negative_slope: Scalar) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ScalarOpIr::create(tensor.into_ir(), negative_slope.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Activation(ActivationOperationIr::LeakyRelu( + desc, + ))) + .output() + } + + fn prelu(tensor: FloatTensor, alpha: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = BinaryOpIr::create(tensor.into_ir(), alpha.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Activation(ActivationOperationIr::PRelu(desc))) + .output() + } + + fn gelu(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::Gelu(desc))) + .output() + } + + fn gelu_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + let client = x.client.clone(); + let desc = BinaryOpIr::create(x.into_ir(), grad.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation( + ActivationOperationIr::GeluBackward(desc), + )) + .output() + } + + fn sigmoid(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::Sigmoid( + desc, + ))) + .output() + } + + fn sigmoid_backward(output: FloatTensor, grad: FloatTensor) -> FloatTensor { + let client = output.client.clone(); + let desc = BinaryOpIr::create(output.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Activation( + ActivationOperationIr::SigmoidBackward(desc), + )) + .output() + } + + fn hard_sigmoid(tensor: FloatTensor, alpha: Scalar, beta: Scalar) -> FloatTensor { + let client = tensor.client.clone(); + let desc = HardSigmoidOpIr::create(tensor.into_ir(), alpha.into(), beta.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Activation(ActivationOperationIr::HardSigmoid( + desc, + ))) + .output() + } + + fn log_sigmoid(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::LogSigmoid( + desc, + ))) + .output() + } + + fn log_sigmoid_backward(x: FloatTensor, grad: FloatTensor) -> FloatTensor { + let client = x.client.clone(); + let desc = BinaryOpIr::create(x.into_ir(), grad.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Activation( + ActivationOperationIr::LogSigmoidBackward(desc), + )) + .output() + } + + fn softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::Softmax( + desc, + ))) + .output() + } + + fn log_softmax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::LogSoftmax( + desc, + ))) + .output() + } + + fn softmin(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::Activation(ActivationOperationIr::Softmin( + desc, + ))) + .output() + } +} diff --git a/crates/burn-router/src/ops/binary.rs b/crates/burn-router/src/ops/binary.rs new file mode 100644 index 0000000..b00e36a --- /dev/null +++ b/crates/burn-router/src/ops/binary.rs @@ -0,0 +1,69 @@ +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_float_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let rhs = $handles.get_float_tensor::(&$desc.rhs); + let output = $ops(lhs, rhs); + + $handles.register_float_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_float_cmp_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let rhs = $handles.get_float_tensor::(&$desc.rhs); + let output = $ops(lhs, rhs, $desc.out.dtype.into()); + + $handles.register_bool_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_int_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.lhs); + let rhs = $handles.get_int_tensor::(&$desc.rhs); + let output = $ops(lhs, rhs); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_int_cmp_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.lhs); + let rhs = $handles.get_int_tensor::(&$desc.rhs); + let output = $ops(lhs, rhs, $desc.out.dtype.into()); + + $handles.register_bool_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! binary_bool_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_bool_tensor::(&$desc.lhs); + let rhs = $handles.get_bool_tensor::(&$desc.rhs); + let output = $ops(lhs, rhs); + + $handles.register_bool_tensor::(&$desc.out.id, output); + }}; +} diff --git a/crates/burn-router/src/ops/bool_tensor.rs b/crates/burn-router/src/ops/bool_tensor.rs new file mode 100644 index 0000000..92139d7 --- /dev/null +++ b/crates/burn-router/src/ops/bool_tensor.rs @@ -0,0 +1,433 @@ +use alloc::vec::Vec; +use burn_backend::backend::ExecutionError; +use burn_std::{BoolDType, FloatDType, IntDType}; + +use crate::{BackendRouter, RouterChannel, RouterClient, get_client}; +use burn_backend::ops::BoolTensorOps; +use burn_backend::tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}; +use burn_backend::{Scalar, Shape, Slice, TensorData}; +use burn_ir::{ + BaseOperationIr, BinaryOpIr, BoolOperationIr, CastOpIr, CatOpIr, CreationOpIr, FlipOpIr, + GatherOpIr, InitOperationIr, MaskFillOpIr, MaskWhereOpIr, OperationIr, OperationOutput, + PermuteOpIr, ReduceDimOpIr, ReduceOpIr, RepeatDimOpIr, ScalarOpIr, ScatterOpIr, + SelectAssignOpIr, SelectOpIr, ShapeOpIr, SliceAssignOpIr, SliceOpIr, SwapDimsOpIr, UnaryOpIr, + UnfoldOpIr, +}; + +impl BoolTensorOps for BackendRouter { + fn bool_empty(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Empty(desc))) + .output() + } + + fn bool_zeros(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Zeros(desc))) + .output() + } + + fn bool_ones(shape: Shape, device: &Device, dtype: BoolDType) -> BoolTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Ones(desc))) + .output() + } + + async fn bool_into_data(tensor: BoolTensor) -> Result { + tensor.into_data().await + } + + fn bool_from_data(data: TensorData, device: &Device) -> BoolTensor { + let client = get_client::(device); + let out = client.register_tensor_data(data); + let desc = InitOperationIr { + out: out.to_ir_out(), + }; + + // Call register op when output is already initialized + client.register_op(OperationIr::Init(desc)); + + out + } + + fn bool_into_int(tensor: BoolTensor, out_dtype: IntDType) -> IntTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Bool(BoolOperationIr::IntoInt(desc))) + .output() + } + + fn bool_into_float(tensor: BoolTensor, out_dtype: FloatDType) -> FloatTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Bool(BoolOperationIr::IntoFloat(desc))) + .output() + } + + fn bool_to_device(tensor: BoolTensor, device: &Device) -> BoolTensor { + if &tensor.client.device() == device { + return tensor; + } + R::change_client_backend(tensor, device) + } + + fn bool_reshape(tensor: BoolTensor, shape: Shape) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Reshape(desc))) + .output() + } + + fn bool_slice(tensor: BoolTensor, slices: &[Slice]) -> BoolTensor { + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Slice(desc))) + .output() + } + + fn bool_slice_assign( + tensor: BoolTensor, + slices: &[burn_backend::Slice], + value: BoolTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::SliceAssign(desc))) + .output() + } + + fn bool_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Equal(desc))) + .output() + } + + fn bool_not(tensor: BoolTensor) -> BoolTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Bool(BoolOperationIr::Not(desc))) + .output() + } + + fn bool_and(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Bool(BoolOperationIr::And(desc))) + .output() + } + + fn bool_or(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Bool(BoolOperationIr::Or(desc))) + .output() + } + + fn bool_xor(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::Bool(BoolOperationIr::Xor(desc))) + .output() + } + + fn bool_not_equal(lhs: BoolTensor, rhs: BoolTensor) -> BoolTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseBool(BaseOperationIr::NotEqual(desc))) + .output() + } + + fn bool_not_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + let client = lhs.client.clone(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs.into(), || client.create_empty_handle()); + client + .register(OperationIr::BaseBool(BaseOperationIr::NotEqualElem(desc))) + .output() + } + + fn bool_all(tensor: BoolTensor) -> BoolTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = + ReduceOpIr::create_bool(tensor.into_ir(), dtype, || client.create_empty_handle()); + client + .register(OperationIr::BaseBool(BaseOperationIr::All(desc))) + .output() + } + + fn bool_any(tensor: BoolTensor) -> BoolTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = + ReduceOpIr::create_bool(tensor.into_ir(), dtype, || client.create_empty_handle()); + client + .register(OperationIr::BaseBool(BaseOperationIr::Any(desc))) + .output() + } + + fn bool_all_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, dtype, || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseBool(BaseOperationIr::AllDim(desc))) + .output() + } + + fn bool_any_dim(tensor: BoolTensor, dim: usize) -> BoolTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, dtype, || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseBool(BaseOperationIr::AnyDim(desc))) + .output() + } + + fn bool_swap_dims(tensor: BoolTensor, dim1: usize, dim2: usize) -> BoolTensor { + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::SwapDims(desc))) + .output() + } + + fn bool_permute(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Permute(desc))) + .output() + } + + fn bool_flip(tensor: BoolTensor, axes: &[usize]) -> BoolTensor { + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Flip(desc))) + .output() + } + + fn bool_expand(tensor: BoolTensor, shape: Shape) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Expand(desc))) + .output() + } + + fn bool_cat(tensors: Vec>, dim: usize) -> BoolTensor { + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Cat(desc))) + .output() + } + + fn bool_repeat_dim(tensor: BoolTensor, dim: usize, times: usize) -> BoolTensor { + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::RepeatDim(desc))) + .output() + } + + fn bool_unfold( + tensor: BoolTensor, + dim: usize, + size: usize, + step: usize, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Unfold(desc))) + .output() + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::MaskWhere(desc))) + .output() + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::MaskFill(desc))) + .output() + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Gather(desc))) + .output() + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Scatter(desc))) + .output() + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + let dtype = lhs.dtype; + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, dtype, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::EqualElem(desc))) + .output() + } + + fn bool_select( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseBool(BaseOperationIr::Select(desc))) + .output() + } + + fn bool_select_or( + tensor: BoolTensor, + dim: usize, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseBool(BaseOperationIr::SelectAssign(desc))) + .output() + } +} diff --git a/crates/burn-router/src/ops/distributed.rs b/crates/burn-router/src/ops/distributed.rs new file mode 100644 index 0000000..19a1316 --- /dev/null +++ b/crates/burn-router/src/ops/distributed.rs @@ -0,0 +1,42 @@ +use alloc::vec::Vec; + +use burn_backend::{ + DeviceId, + distributed::{CollectiveTensor, DistributedOps, ReduceOperation}, + tensor::{Device, FloatTensor}, +}; +use burn_ir::{AllReduceOpIr, DeviceIdIr, DistributedOperationIr, OperationIr, OperationOutput}; + +use crate::{BackendRouter, RouterChannel, RouterClient, get_client}; + +impl DistributedOps for BackendRouter { + fn all_reduce( + tensor: FloatTensor, + op: ReduceOperation, + device_ids: Vec, + ) -> CollectiveTensor { + let client = tensor.client.clone(); + let device_ids = device_ids.into_iter().map(DeviceIdIr::from).collect(); + let desc = AllReduceOpIr::create(tensor.into_ir(), op, device_ids, || { + client.create_empty_handle() + }); + + let output = client + .register(OperationIr::Distributed(DistributedOperationIr::AllReduce( + desc, + ))) + .output(); + + CollectiveTensor::new(output) + } + + fn sync_collective(device: &Device) { + // Fire-and-forget, like `all_reduce`: register a `SyncCollective` op on the device's + // stream instead of a blocking call. The interpreter resolves it through the normal op + // stream like any other distributed op. + let client = get_client::(device); + client.register_op(OperationIr::Distributed( + DistributedOperationIr::SyncCollective, + )); + } +} diff --git a/crates/burn-router/src/ops/int_tensor.rs b/crates/burn-router/src/ops/int_tensor.rs new file mode 100644 index 0000000..1ca348d --- /dev/null +++ b/crates/burn-router/src/ops/int_tensor.rs @@ -0,0 +1,1312 @@ +use alloc::vec::Vec; +use burn_backend::backend::ExecutionError; +use burn_std::{BoolDType, FloatDType}; + +use crate::{BackendRouter, RouterChannel, RouterClient, get_client}; +use burn_backend::tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}; +use burn_backend::{Distribution, IntDType, Scalar, Shape, Slice, TensorData, ops::IntTensorOps}; +use burn_ir::{ + BaseOperationIr, BinaryOpIr, CastOpIr, CatOpIr, ClampOpIr, CreationOpIr, DimOpIr, FlipOpIr, + FullOpIr, GatherNdOpIr, GatherOpIr, InitOperationIr, IntOperationIr, MaskFillOpIr, + MaskWhereOpIr, MatmulOpIr, NumericOperationIr, OperationIr, OperationOutput, PermuteOpIr, + RandomOpIr, ReduceDimOpIr, ReduceDimWithIndicesOpIr, ReduceOpIr, RepeatDimOpIr, ScalarOpIr, + ScatterNdOpIr, ScatterOpIr, SelectAssignOpIr, SelectOpIr, ShapeOpIr, SliceAssignOpIr, + SliceOpIr, SortOpIr, SortWithIndicesOpIr, SwapDimsOpIr, UnaryOpIr, UnfoldOpIr, +}; + +impl IntTensorOps for BackendRouter { + fn int_empty(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Empty(desc))) + .output() + } + + async fn int_into_data(tensor: IntTensor) -> Result { + tensor.into_data().await + } + + fn int_from_data(data: TensorData, device: &Device) -> IntTensor { + let client = get_client::(device); + let out = client.register_tensor_data(data); + let desc = InitOperationIr { + out: out.to_ir_out(), + }; + + // Call register op when output is already initialized + client.register_op(OperationIr::Init(desc)); + + out + } + + fn int_to_device(tensor: IntTensor, device: &Device) -> IntTensor { + if &tensor.client.device() == device { + return tensor; + } + R::change_client_backend(tensor, device) + } + + fn int_reshape(tensor: IntTensor, shape: Shape) -> IntTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Reshape(desc))) + .output() + } + + fn int_slice(tensor: IntTensor, slices: &[Slice]) -> IntTensor { + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Slice(desc))) + .output() + } + + fn int_slice_assign( + tensor: IntTensor, + slices: &[burn_backend::Slice], + value: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::SliceAssign(desc))) + .output() + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = MatmulOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::Matmul(desc))) + .output() + } + + fn int_mask_where( + tensor: IntTensor, + mask: BoolTensor, + value: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::MaskWhere(desc))) + .output() + } + + fn int_mask_fill( + tensor: IntTensor, + mask: BoolTensor, + value: Scalar, + ) -> IntTensor { + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::MaskFill(desc))) + .output() + } + + fn int_gather( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Gather(desc))) + .output() + } + + fn int_scatter_add( + dim: usize, + tensor: IntTensor, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Scatter(desc))) + .output() + } + + fn int_scatter_nd( + data: IntTensor, + indices: IntTensor, + values: IntTensor, + reduction: IndexingUpdateOp, + ) -> IntTensor { + let client = data.client.clone(); + let desc = ScatterNdOpIr::create( + data.into_ir(), + indices.into_ir(), + values.into_ir(), + reduction, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseInt(BaseOperationIr::ScatterNd(desc))) + .output() + } + + fn int_gather_nd(data: IntTensor, indices: IntTensor) -> IntTensor { + let client = data.client.clone(); + let desc = GatherNdOpIr::create(data.into_ir(), indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::GatherNd(desc))) + .output() + } + + fn int_select( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Select(desc))) + .output() + } + + fn int_select_add( + tensor: IntTensor, + dim: usize, + indices: IntTensor, + value: IntTensor, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseInt(BaseOperationIr::SelectAssign(desc))) + .output() + } + + fn int_cat(tensors: Vec>, dim: usize) -> IntTensor { + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Cat(desc))) + .output() + } + + fn int_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Equal(desc))) + .output() + } + + fn int_equal_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::EqualElem(desc))) + .output() + } + + fn int_greater( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::Greater(desc), + )) + .output() + } + + fn int_greater_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterElem(desc), + )) + .output() + } + + fn int_greater_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterEqual(desc), + )) + .output() + } + + fn int_greater_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::GreaterEqualElem(desc), + )) + .output() + } + + fn int_lower( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::Lower(desc), + )) + .output() + } + + fn int_lower_elem(lhs: IntTensor, rhs: Scalar, out_dtype: BoolDType) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerElem(desc), + )) + .output() + } + + fn int_lower_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerEqual(desc), + )) + .output() + } + + fn int_lower_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.lhs.dtype, + NumericOperationIr::LowerEqualElem(desc), + )) + .output() + } + + fn int_add(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Add(desc), + )) + .output() + } + + fn int_add_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::AddScalar(desc), + )) + .output() + } + + fn int_sub(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Sub(desc), + )) + .output() + } + + fn int_sub_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::SubScalar(desc), + )) + .output() + } + + fn int_mul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Mul(desc), + )) + .output() + } + + fn int_mul_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MulScalar(desc), + )) + .output() + } + + fn int_div(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Div(desc), + )) + .output() + } + + fn int_div_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::DivScalar(desc), + )) + .output() + } + + fn int_remainder(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Rem(desc), + )) + .output() + } + + fn int_remainder_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::RemScalar(desc), + )) + .output() + } + + fn int_zeros(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Zeros(desc))) + .output() + } + + fn int_ones(shape: Shape, device: &Device, dtype: IntDType) -> IntTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Ones(desc))) + .output() + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + let client = get_client::(device); + let dtype = dtype.into(); + let value = fill_value.into(); + let desc = FullOpIr::create(shape, dtype, value, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Full(desc), + )) + .output() + } + + fn int_sum(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Sum(desc), + )) + .output() + } + + fn int_sum_dim(tensor: IntTensor, axis: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = + ReduceDimOpIr::create(tensor.into_ir(), axis, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::SumDim(desc), + )) + .output() + } + + fn int_prod(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Prod(desc), + )) + .output() + } + + fn int_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ProdDim(desc), + )) + .output() + } + + fn int_mean(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Mean(desc), + )) + .output() + } + + fn int_mean_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MeanDim(desc), + )) + .output() + } + + fn int_cumsum(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::CumSum(desc), + )) + .output() + } + + fn int_cumprod(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::CumProd(desc), + )) + .output() + } + + fn int_cummin(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::CumMin(desc), + )) + .output() + } + + fn int_cummax(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::CumMax(desc), + )) + .output() + } + + fn int_argmax(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ArgMax(desc), + )) + .output() + } + + fn int_argtopk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ArgTopK(desc), + )) + .output() + } + + fn int_topk(tensor: IntTensor, dim: usize, k: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::TopK(desc), + )) + .output() + } + + fn int_argmin(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ArgMin(desc), + )) + .output() + } + + fn int_clamp(tensor: IntTensor, min: Scalar, max: Scalar) -> IntTensor { + let client = tensor.client.clone(); + let min = min.into(); + let max = max.into(); + let desc = ClampOpIr::create(tensor.into_ir(), min, max, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Clamp(desc), + )) + .output() + } + + fn int_abs(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Abs(desc), + )) + .output() + } + + fn int_into_float(tensor: IntTensor, out_dtype: FloatDType) -> FloatTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::IntoFloat(desc))) + .output() + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::SwapDims(desc))) + .output() + } + + fn int_max(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Max(desc), + )) + .output() + } + + fn int_max_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MaxDim(desc), + )) + .output() + } + + fn int_max_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + let dtype = tensor.dtype; + let client = tensor.client.clone(); + let desc = ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, dtype, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.tensor.dtype, + NumericOperationIr::MaxDimWithIndices(desc), + )) + .outputs() + .into() + } + + fn int_max_abs(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MaxAbs(desc), + )) + .output() + } + + fn int_max_abs_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MaxAbsDim(desc), + )) + .output() + } + + fn int_min(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Min(desc), + )) + .output() + } + + fn int_min_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MinDim(desc), + )) + .output() + } + + fn int_min_dim_with_indices( + tensor: IntTensor, + dim: usize, + ) -> (IntTensor, IntTensor) { + let dtype = tensor.dtype; + let client = tensor.client.clone(); + let desc = ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, dtype, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::MinDimWithIndices(desc), + )) + .outputs() + .into() + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: IntDType, + ) -> IntTensor { + let client = get_client::(device); + let dtype = dtype.into(); + let desc = RandomOpIr::create(shape, dtype, distribution, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + dtype, + NumericOperationIr::IntRandom(desc), + )) + .output() + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Permute(desc))) + .output() + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Expand(desc))) + .output() + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Flip(desc))) + .output() + } + + fn int_repeat_dim(tensor: IntTensor, dim: usize, times: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::RepeatDim(desc))) + .output() + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseAnd(desc))) + .output() + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseOr(desc))) + .output() + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseXor(desc))) + .output() + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseNot(desc))) + .output() + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseAndScalar(desc))) + .output() + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseOrScalar(desc))) + .output() + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseXorScalar(desc))) + .output() + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseLeftShift(desc))) + .output() + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseLeftShiftScalar( + desc, + ))) + .output() + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseRightShift(desc))) + .output() + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Int(IntOperationIr::BitwiseRightShiftScalar( + desc, + ))) + .output() + } + + fn int_cast(tensor: IntTensor, dtype: burn_backend::IntDType) -> IntTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Cast(desc))) + .output() + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseInt(BaseOperationIr::Unfold(desc))) + .output() + } + + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Powi(desc), + )) + .output() + } + + fn int_neg(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Neg(desc), + )) + .output() + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Sign(desc), + )) + .output() + } + + fn int_clamp_min(tensor: IntTensor, min: Scalar) -> IntTensor { + let client = tensor.client.clone(); + let desc = ScalarOpIr::create(tensor.into_ir(), min.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ClampMin(desc), + )) + .output() + } + + fn int_clamp_max(tensor: IntTensor, max: Scalar) -> IntTensor { + let client = tensor.client.clone(); + let desc = ScalarOpIr::create(tensor.into_ir(), max.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::ClampMax(desc), + )) + .output() + } + + fn int_not_equal( + lhs: IntTensor, + rhs: IntTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::NotEqual(desc))) + .output() + } + + fn int_not_equal_elem( + lhs: IntTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + ScalarOpIr::create_comparison(lhs.into_ir(), rhs.into(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::NotEqualElem(desc))) + .output() + } + + fn int_all(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create_bool(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::All(desc))) + .output() + } + + fn int_any(tensor: IntTensor, out_dtype: BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create_bool(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::Any(desc))) + .output() + } + + fn int_all_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::AllDim(desc))) + .output() + } + + fn int_any_dim(tensor: IntTensor, dim: usize, out_dtype: BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseInt(BaseOperationIr::AnyDim(desc))) + .output() + } + + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + let client = tensor.client.clone(); + let desc = SortOpIr::create(tensor.into_ir(), dim, descending, || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::Sort(desc), + )) + .output() + } + + fn int_sort_with_indices( + tensor: IntTensor, + dim: usize, + descending: bool, + ) -> (IntTensor, IntTensor) { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = SortWithIndicesOpIr::create(tensor.into_ir(), dim, descending, dtype, || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::SortWithIndices(desc), + )) + .outputs() + .into() + } + + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = SortOpIr::create_arg(tensor.into_ir(), dim, descending, dtype, || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericInt( + dtype, + NumericOperationIr::ArgSort(desc), + )) + .output() + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericInt( + desc.out.dtype, + NumericOperationIr::PowiScalar(desc), + )) + .output() + } +} diff --git a/crates/burn-router/src/ops/mod.rs b/crates/burn-router/src/ops/mod.rs new file mode 100644 index 0000000..6d8c4be --- /dev/null +++ b/crates/burn-router/src/ops/mod.rs @@ -0,0 +1,10 @@ +mod activation; +mod binary; +mod bool_tensor; +mod distributed; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; +mod unary; diff --git a/crates/burn-router/src/ops/module.rs b/crates/burn-router/src/ops/module.rs new file mode 100644 index 0000000..c73d5ab --- /dev/null +++ b/crates/burn-router/src/ops/module.rs @@ -0,0 +1,1068 @@ +use alloc::boxed::Box; + +use burn_backend::ops::{ + AttentionModuleOptions, ConvOptions, ConvTransposeOptions, DeformConv2dBackward, + DeformConvOptions, InterpolateOptions, MaxPool1dBackward, MaxPool1dWithIndices, + MaxPool2dBackward, MaxPool2dWithIndices, ModuleOps, +}; +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor}; +use burn_ir::*; +use burn_std::IntDType; + +use crate::{BackendRouter, RouterChannel, RouterClient}; + +impl ModuleOps for BackendRouter { + fn embedding(weights: FloatTensor, indices: IntTensor) -> FloatTensor { + let client = weights.client.clone(); + let desc = EmbeddingOpIr::create(weights.into_ir(), indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module(ModuleOperationIr::Embedding(desc))) + .output() + } + + fn embedding_backward( + weights: FloatTensor, + output_grad: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + let client = weights.client.clone(); + let desc = EmbeddingBackwardOpIr::create( + weights.into_ir(), + output_grad.into_ir(), + indices.into_ir(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::EmbeddingBackward( + desc, + ))) + .output() + } + + fn linear( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = LinearOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Linear(desc))) + .output() + } + + fn linear_x_backward( + weight: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = weight.client.clone(); + let desc = LinearXBackwardOpIr::create(weight.into_ir(), output_grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module(ModuleOperationIr::LinearXBackward( + desc, + ))) + .output() + } + + fn linear_weight_backward( + x: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = LinearWeightBackwardOpIr::create(x.into_ir(), output_grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module( + ModuleOperationIr::LinearWeightBackward(desc), + )) + .output() + } + + fn linear_bias_backward(output_grad: FloatTensor) -> FloatTensor { + let client = output_grad.client.clone(); + let desc = + LinearBiasBackwardOpIr::create(output_grad.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Module(ModuleOperationIr::LinearBiasBackward( + desc, + ))) + .output() + } + + fn layer_norm( + tensor: FloatTensor, + gamma: FloatTensor, + beta: Option>, + epsilon: f64, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = LayerNormOpIr::create( + tensor.into_ir(), + gamma.into_ir(), + beta.map(|b| b.into_ir()), + epsilon, + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module(ModuleOperationIr::LayerNorm(desc))) + .output() + } + + fn unfold4d( + x: FloatTensor, + kernel_size: [usize; 2], + options: burn_backend::ops::UnfoldOptions, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Unfold4dOpIr::create( + x.into_ir(), + kernel_size, + Unfold4dOptionsIr { + stride: options.stride, + padding: options.padding, + dilation: options.dilation, + }, + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module(ModuleOperationIr::Unfold4d(desc))) + .output() + } + + fn conv_transpose1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose1dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose1dWeightBackward(desc), + )) + .output() + } + + fn conv_transpose1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose1dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose1dBiasBackward(desc), + )) + .output() + } + + fn conv_transpose2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose2dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose2dWeightBackward(desc), + )) + .output() + } + + fn conv_transpose2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose2dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose2dBiasBackward(desc), + )) + .output() + } + + fn conv_transpose3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose3dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose3dWeightBackward(desc), + )) + .output() + } + + fn conv_transpose3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose3dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::Module( + ModuleOperationIr::ConvTranspose3dBiasBackward(desc), + )) + .output() + } + + fn conv1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<1>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv1dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv1d(desc))) + .output() + } + + fn conv1d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv1dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv1dXBackward( + desc, + ))) + .output() + } + + fn conv1d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<1>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv1dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module( + ModuleOperationIr::Conv1dWeightBackward(desc), + )) + .output() + } + + fn conv1d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv1dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv1dBiasBackward( + desc, + ))) + .output() + } + + fn conv2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv2dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv2d(desc))) + .output() + } + + fn conv2d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv2dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv2dXBackward( + desc, + ))) + .output() + } + + fn conv2d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv2dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module( + ModuleOperationIr::Conv2dWeightBackward(desc), + )) + .output() + } + + fn conv2d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv2dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv2dBiasBackward( + desc, + ))) + .output() + } + + fn conv3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvOptions<3>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv3dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv3d(desc))) + .output() + } + + fn conv3d_x_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv3dXBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv3dXBackward( + desc, + ))) + .output() + } + + fn conv3d_weight_backward( + x: FloatTensor, + weight: FloatTensor, + output_grad: FloatTensor, + options: ConvOptions<3>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv3dWeightBackwardOpIr::create( + x.into_ir(), + weight.into_ir(), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module( + ModuleOperationIr::Conv3dWeightBackward(desc), + )) + .output() + } + + fn conv3d_bias_backward( + x: FloatTensor, + bias: FloatTensor, + output_grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = Conv3dBiasBackwardOpIr::create( + x.into_ir(), + bias.into_ir(), + output_grad.into_ir(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Conv3dBiasBackward( + desc, + ))) + .output() + } + + fn conv_transpose1d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<1>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose1dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::ConvTranspose1d( + desc, + ))) + .output() + } + + fn conv_transpose2d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose2dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::ConvTranspose2d( + desc, + ))) + .output() + } + + fn conv_transpose3d( + x: FloatTensor, + weight: FloatTensor, + bias: Option>, + options: ConvTransposeOptions<3>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = ConvTranspose3dOpIr::create( + x.into_ir(), + weight.into_ir(), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::ConvTranspose3d( + desc, + ))) + .output() + } + + fn avg_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = AvgPool1dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::AvgPool1d(desc))) + .output() + } + + fn avg_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = AvgPool2dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::AvgPool2d(desc))) + .output() + } + + fn avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = AvgPool1dBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::AvgPool1dBackward( + desc, + ))) + .output() + } + + fn avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = AvgPool2dBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::AvgPool2dBackward( + desc, + ))) + .output() + } + + fn max_pool1d( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = MaxPool1dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::MaxPool1d(desc))) + .output() + } + + fn max_pool2d( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = MaxPool2dOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::MaxPool2d(desc))) + .output() + } + + fn max_pool1d_with_indices( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool1dWithIndices { + let client = x.client.clone(); + let desc = MaxPool1dWithIndicesOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype.into(), + || client.create_empty_handle(), + ); + + let [out, out_indices] = client + .register(OperationIr::Module( + ModuleOperationIr::MaxPool1dWithIndices(desc), + )) + .outputs(); + + MaxPool1dWithIndices::new(out, out_indices) + } + + fn max_pool2d_with_indices( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + let client = x.client.clone(); + let desc = MaxPool2dWithIndicesOpIr::create( + x.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype.into(), + || client.create_empty_handle(), + ); + + let [out, out_indices] = client + .register(OperationIr::Module( + ModuleOperationIr::MaxPool2dWithIndices(desc), + )) + .outputs(); + + MaxPool2dWithIndices::new(out, out_indices) + } + + fn max_pool1d_with_indices_backward( + x: FloatTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool1dBackward { + let client = x.client.clone(); + + let desc = MaxPool1dWithIndicesBackwardOpIr::create( + x.into_ir(), + output_grad.into_ir(), + indices.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + let out = client + .register(OperationIr::Module( + ModuleOperationIr::MaxPool1dWithIndicesBackward(desc), + )) + .output(); + + MaxPool1dBackward::new(out) + } + + fn max_pool2d_with_indices_backward( + x: FloatTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: FloatTensor, + indices: IntTensor, + ) -> MaxPool2dBackward { + let client = x.client.clone(); + + let desc = MaxPool2dWithIndicesBackwardOpIr::create( + x.into_ir(), + output_grad.into_ir(), + indices.into_ir(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + || client.create_empty_handle(), + ); + + let out = client + .register(OperationIr::Module( + ModuleOperationIr::MaxPool2dWithIndicesBackward(desc), + )) + .output(); + + MaxPool2dBackward::new(out) + } + + fn adaptive_avg_pool1d(x: FloatTensor, output_size: usize) -> FloatTensor { + let client = x.client.clone(); + + let desc = AdaptiveAvgPool1dOpIr::create(x.into_ir(), output_size, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool1d( + desc, + ))) + .output() + } + + fn adaptive_avg_pool2d(x: FloatTensor, output_size: [usize; 2]) -> FloatTensor { + let client = x.client.clone(); + + let desc = AdaptiveAvgPool2dOpIr::create(x.into_ir(), output_size, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module(ModuleOperationIr::AdaptiveAvgPool2d( + desc, + ))) + .output() + } + + fn adaptive_avg_pool1d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + + let desc = AdaptiveAvgPool1dBackwardOpIr::create(x.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module( + ModuleOperationIr::AdaptiveAvgPool1dBackward(desc), + )) + .output() + } + + fn adaptive_avg_pool2d_backward( + x: FloatTensor, + grad: FloatTensor, + ) -> FloatTensor { + let client = x.client.clone(); + + let desc = AdaptiveAvgPool2dBackwardOpIr::create(x.into_ir(), grad.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module( + ModuleOperationIr::AdaptiveAvgPool2dBackward(desc), + )) + .output() + } + + fn interpolate( + x: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = InterpolateOpIr::create(x.into_ir(), output_size, options.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Module(ModuleOperationIr::Interpolate(desc))) + .output() + } + + fn interpolate_backward( + x: FloatTensor, + grad: FloatTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = InterpolateBackwardOpIr::create( + x.into_ir(), + grad.into_ir(), + output_size, + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::InterpolateBackward( + desc, + ))) + .output() + } + + fn deform_conv2d( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, + ) -> FloatTensor { + let client = x.client.clone(); + let desc = DeformConv2dOpIr::create( + x.into_ir(), + offset.into_ir(), + weight.into_ir(), + mask.map(|mask| mask.into_ir()), + bias.map(|bias| bias.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::DeformableConv2d( + Box::new(desc), + ))) + .output() + } + + fn deform_conv2d_backward( + x: FloatTensor, + offset: FloatTensor, + weight: FloatTensor, + mask: Option>, + bias: Option>, + output_grad: FloatTensor, + options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + let client = x.client.clone(); + let has_bias = bias.is_some(); + let has_mask = mask.is_some(); + + let desc = DeformConv2dBackwardOpIr::create( + x.into_ir(), + offset.into_ir(), + weight.into_ir(), + mask.map(|mask| mask.into_ir()), + bias.map(|bias| bias.into_ir()), + output_grad.into_ir(), + options.into(), + || client.create_empty_handle(), + ); + let mut outputs = client + .register(OperationIr::Module( + ModuleOperationIr::DeformableConv2dBackward(Box::new(desc)), + )) + .into_iter(); + + // When the number of outputs is variable, the order is important + let input_grad = outputs.next().unwrap(); + let offset_grad = outputs.next().unwrap(); + let weight_grad = outputs.next().unwrap(); + let mask_grad = has_mask.then(|| outputs.next().unwrap()); + let bias_grad = has_bias.then(|| outputs.next().unwrap()); + + DeformConv2dBackward::new(input_grad, offset_grad, weight_grad, mask_grad, bias_grad) + } + + fn attention( + query: FloatTensor, + key: FloatTensor, + value: FloatTensor, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, + ) -> FloatTensor { + let client = query.client.clone(); + let desc = AttentionOpIr::create( + query.into_ir(), + key.into_ir(), + value.into_ir(), + mask.map(|m: BoolTensor| m.into_ir()), + attn_bias.map(|ab| ab.into_ir()), + options.into(), + || client.create_empty_handle(), + ); + + client + .register(OperationIr::Module(ModuleOperationIr::Attention(desc))) + .output() + } + + fn rfft( + _signal: FloatTensor, + _dim: usize, + _n: Option, + ) -> (FloatTensor, FloatTensor) { + todo!("rfft is not supported for backend-router") + } + + fn irfft( + _spectrum_re: FloatTensor, + _spectrum_im: FloatTensor, + _dim: usize, + _n: Option, + ) -> FloatTensor { + todo!("irfft is not supported for backend-router") + } +} diff --git a/crates/burn-router/src/ops/qtensor.rs b/crates/burn-router/src/ops/qtensor.rs new file mode 100644 index 0000000..6740611 --- /dev/null +++ b/crates/burn-router/src/ops/qtensor.rs @@ -0,0 +1,64 @@ +use burn_backend::{ + ExecutionError, FloatDType, Shape, TensorData, + ops::QTensorOps, + quantization::{QuantScheme, QuantizationParametersPrimitive}, + tensor::{Device, FloatTensor, QuantizedTensor}, +}; + +use crate::{BackendRouter, RouterChannel}; + +impl QTensorOps for BackendRouter { + fn q_from_data(_data: TensorData, _device: &Device) -> QuantizedTensor { + unimplemented!() + } + + fn quantize( + _tensor: FloatTensor, + _scheme: &QuantScheme, + _qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + unimplemented!() + } + + fn quantize_dynamic( + _tensor: FloatTensor, + _scheme: &QuantScheme, + ) -> QuantizedTensor { + unimplemented!() + } + + fn dequantize(_tensor: QuantizedTensor, _dtype: FloatDType) -> FloatTensor { + unimplemented!() + } + + fn q_to_device( + _tensor: QuantizedTensor, + _device: &Device, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_reshape(_tensor: QuantizedTensor, _shape: Shape) -> QuantizedTensor { + unimplemented!() + } + + async fn q_into_data(_tensor: QuantizedTensor) -> Result { + unimplemented!() + } + + fn q_swap_dims( + _tensor: QuantizedTensor, + _dim1: usize, + _dim2: usize, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_permute(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_flip(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } +} diff --git a/crates/burn-router/src/ops/tensor.rs b/crates/burn-router/src/ops/tensor.rs new file mode 100644 index 0000000..3243721 --- /dev/null +++ b/crates/burn-router/src/ops/tensor.rs @@ -0,0 +1,1593 @@ +use alloc::vec::Vec; +use burn_backend::Scalar; +use burn_backend::backend::ExecutionError; +use burn_std::{BoolDType, IntDType}; + +use crate::{BackendRouter, RouterChannel, RouterClient, get_client}; +use burn_backend::tensor::{BoolTensor, Device, FloatTensor, IndexingUpdateOp, IntTensor}; +use burn_backend::{Distribution, FloatDType, Shape, Slice, TensorData, ops::FloatTensorOps}; +use burn_ir::{ + BaseOperationIr, BinaryOpIr, CastOpIr, CatOpIr, ClampOpIr, CreationOpIr, CrossOpIr, DimOpIr, + FlipOpIr, FloatOperationIr, FullOpIr, GatherNdOpIr, GatherOpIr, GridSample2dOpIr, + InitOperationIr, MaskFillOpIr, MaskWhereOpIr, MatmulOpIr, NumericOperationIr, OperationIr, + OperationOutput, PermuteOpIr, RandomOpIr, ReduceDimOpIr, ReduceDimWithIndicesOpIr, ReduceOpIr, + RepeatDimOpIr, ScalarOpIr, ScatterNdOpIr, ScatterOpIr, SelectAssignOpIr, SelectOpIr, ShapeOpIr, + SliceAssignOpIr, SliceOpIr, SortOpIr, SortWithIndicesOpIr, SwapDimsOpIr, UnaryOpIr, UnfoldOpIr, +}; + +impl FloatTensorOps for BackendRouter { + fn float_from_data(data: TensorData, device: &Device) -> FloatTensor { + let client = get_client::(device); + let out = client.register_tensor_data(data); + let desc = InitOperationIr { + out: out.to_ir_out(), + }; + + // Call register op when output is already initialized + client.register_op(OperationIr::Init(desc)); + + out + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + let client = get_client::(device); + let dtype = dtype.into(); + let desc = RandomOpIr::create(shape, dtype, distribution, || client.create_empty_handle()); + + client + .register(OperationIr::Float(dtype, FloatOperationIr::Random(desc))) + .output() + } + + fn float_zeros(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Zeros(desc))) + .output() + } + + fn float_ones(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Ones(desc))) + .output() + } + + fn float_full( + shape: Shape, + fill_value: Scalar, + device: &Device, + dtype: FloatDType, + ) -> FloatTensor { + let client = get_client::(device); + let dtype = dtype.into(); + let value = fill_value.into(); + let desc = FullOpIr::create(shape, dtype, value, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Full(desc), + )) + .output() + } + + async fn float_into_data(tensor: FloatTensor) -> Result { + tensor.into_data().await + } + + fn float_to_device(tensor: FloatTensor, device: &Device) -> FloatTensor { + if &tensor.client.device() == device { + return tensor; + } + R::change_client_backend(tensor, device) + } + + fn float_into_int(tensor: FloatTensor, out_dtype: IntDType) -> IntTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.input.dtype, + FloatOperationIr::IntoInt(desc), + )) + .output() + } + + fn float_empty(shape: Shape, device: &Device, dtype: FloatDType) -> FloatTensor { + let client = get_client::(device); + let desc = CreationOpIr::create(shape, dtype.into(), || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Empty(desc))) + .output() + } + + fn float_add(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Add(desc), + )) + .output() + } + + fn float_add_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::AddScalar(desc), + )) + .output() + } + + fn float_clamp(tensor: FloatTensor, min: Scalar, max: Scalar) -> FloatTensor { + let client = tensor.client.clone(); + let min = min.into(); + let max = max.into(); + let desc = ClampOpIr::create(tensor.into_ir(), min, max, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Clamp(desc), + )) + .output() + } + + fn float_sub(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Sub(desc), + )) + .output() + } + + fn float_sub_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::SubScalar(desc), + )) + .output() + } + + fn float_mul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Mul(desc), + )) + .output() + } + + fn float_mul_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MulScalar(desc), + )) + .output() + } + + fn float_div(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Div(desc), + )) + .output() + } + + fn float_div_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::DivScalar(desc), + )) + .output() + } + + fn float_remainder(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Rem(desc), + )) + .output() + } + + fn float_remainder_scalar(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::RemScalar(desc), + )) + .output() + } + + fn float_matmul(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = MatmulOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Matmul(desc), + )) + .output() + } + + fn float_cross( + lhs: FloatTensor, + rhs: FloatTensor, + dim: usize, + ) -> FloatTensor { + let client = lhs.client.clone(); + let desc = CrossOpIr::create(lhs.into_ir(), rhs.into_ir(), dim, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Cross(desc), + )) + .output() + } + + fn float_grid_sample_2d( + tensor: FloatTensor, + grid: FloatTensor, + options: burn_backend::ops::GridSampleOptions, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = + GridSample2dOpIr::create(tensor.into_ir(), grid.into_ir(), options.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::GridSample2d(desc), + )) + .output() + } + + fn float_swap_dims(tensor: FloatTensor, dim1: usize, dim2: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = SwapDimsOpIr::create(tensor.into_ir(), dim1, dim2, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::SwapDims(desc))) + .output() + } + + fn float_reshape(tensor: FloatTensor, shape: Shape) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::reshape(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Reshape(desc))) + .output() + } + + fn float_gather( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = GatherOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Gather(desc))) + .output() + } + + fn float_scatter_add( + dim: usize, + tensor: FloatTensor, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ScatterOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Scatter(desc))) + .output() + } + + fn float_scatter_nd( + data: FloatTensor, + indices: IntTensor, + values: FloatTensor, + reduction: IndexingUpdateOp, + ) -> FloatTensor { + let client = data.client.clone(); + let desc = ScatterNdOpIr::create( + data.into_ir(), + indices.into_ir(), + values.into_ir(), + reduction, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::ScatterNd(desc))) + .output() + } + + fn float_gather_nd(data: FloatTensor, indices: IntTensor) -> FloatTensor { + let client = data.client.clone(); + let desc = GatherNdOpIr::create(data.into_ir(), indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::GatherNd(desc))) + .output() + } + + fn float_select( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = SelectOpIr::create(tensor.into_ir(), dim, indices.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Select(desc))) + .output() + } + + fn float_select_add( + tensor: FloatTensor, + dim: usize, + indices: IntTensor, + value: FloatTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = SelectAssignOpIr::create( + tensor.into_ir(), + dim, + indices.into_ir(), + value.into_ir(), + IndexingUpdateOp::Add, + || client.create_empty_handle(), + ); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::SelectAssign(desc))) + .output() + } + + fn float_slice(tensor: FloatTensor, slices: &[Slice]) -> FloatTensor { + let client = tensor.client.clone(); + let desc = SliceOpIr::create(tensor.into_ir(), slices.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Slice(desc))) + .output() + } + + fn float_slice_assign( + tensor: FloatTensor, + slices: &[burn_backend::Slice], + value: FloatTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = + SliceAssignOpIr::create(tensor.into_ir(), slices.into(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::SliceAssign(desc))) + .output() + } + + fn float_mask_where( + tensor: FloatTensor, + mask: BoolTensor, + value: FloatTensor, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = MaskWhereOpIr::create(tensor.into_ir(), mask.into_ir(), value.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::MaskWhere(desc))) + .output() + } + + fn float_mask_fill( + tensor: FloatTensor, + mask: BoolTensor, + value: Scalar, + ) -> FloatTensor { + let client = tensor.client.clone(); + let value = value.into(); + let desc = MaskFillOpIr::create(tensor.into_ir(), mask.into_ir(), value, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::MaskFill(desc))) + .output() + } + + fn float_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Equal(desc))) + .output() + } + + fn float_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::EqualElem(desc))) + .output() + } + + fn float_greater( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::Greater(desc), + )) + .output() + } + + fn float_greater_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterElem(desc), + )) + .output() + } + + fn float_greater_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterEqual(desc), + )) + .output() + } + + fn float_greater_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::GreaterEqualElem(desc), + )) + .output() + } + + fn float_lower( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::Lower(desc), + )) + .output() + } + + fn float_lower_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerElem(desc), + )) + .output() + } + + fn float_lower_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerEqual(desc), + )) + .output() + } + + fn float_lower_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create_comparison(lhs.into_ir(), rhs, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.lhs.dtype, + NumericOperationIr::LowerEqualElem(desc), + )) + .output() + } + + fn float_sum(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Sum(desc), + )) + .output() + } + + fn float_sum_dim(tensor: FloatTensor, axis: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = + ReduceDimOpIr::create(tensor.into_ir(), axis, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::SumDim(desc), + )) + .output() + } + + fn float_prod(tensor: IntTensor) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Prod(desc), + )) + .output() + } + + fn float_prod_dim(tensor: IntTensor, dim: usize) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::ProdDim(desc), + )) + .output() + } + + fn float_mean(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Mean(desc), + )) + .output() + } + + fn float_mean_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MeanDim(desc), + )) + .output() + } + + fn float_cumsum(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::CumSum(desc), + )) + .output() + } + + fn float_cumprod(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::CumProd(desc), + )) + .output() + } + + fn float_cummin(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::CumMin(desc), + )) + .output() + } + + fn float_cummax(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = DimOpIr::create(tensor.into_ir(), dim, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::CumMax(desc), + )) + .output() + } + + fn float_exp(lhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = UnaryOpIr::create(lhs.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Exp(desc), + )) + .output() + } + + fn float_log(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Log(desc), + )) + .output() + } + + fn float_log1p(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Log1p(desc), + )) + .output() + } + + fn float_powf_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::PowfScalar(desc), + )) + .output() + } + + fn float_sqrt(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Sqrt(desc), + )) + .output() + } + + fn float_abs(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Abs(desc), + )) + .output() + } + + fn float_cos(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Cos(desc), + )) + .output() + } + + fn float_cosh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Cosh(desc), + )) + .output() + } + + fn float_sin(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Sin(desc), + )) + .output() + } + + fn float_sinh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Sinh(desc), + )) + .output() + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Tan(desc), + )) + .output() + } + + fn float_tanh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Tanh(desc), + )) + .output() + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcCos(desc), + )) + .output() + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcCosh(desc), + )) + .output() + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcSin(desc), + )) + .output() + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcSinh(desc), + )) + .output() + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcTan(desc), + )) + .output() + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcTanh(desc), + )) + .output() + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::ArcTan2(desc), + )) + .output() + } + + fn float_hypot(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Hypot(desc), + )) + .output() + } + + fn float_round(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Round(desc), + )) + .output() + } + + fn float_floor(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Floor(desc), + )) + .output() + } + + fn float_ceil(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Ceil(desc), + )) + .output() + } + + fn float_trunc(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Trunc(desc), + )) + .output() + } + + fn float_recip(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Recip(desc), + )) + .output() + } + + fn float_erf(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Erf(desc), + )) + .output() + } + + fn float_cat(tensors: Vec>, dim: usize) -> FloatTensor { + let client = tensors.first().unwrap().client.clone(); + let tensors = tensors.into_iter().map(|t| t.into_ir()).collect(); + let desc = CatOpIr::create(tensors, dim, || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Cat(desc))) + .output() + } + + fn float_argmax(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgMax(desc), + )) + .output() + } + + fn float_argtopk( + tensor: FloatTensor, + dim: usize, + k: usize, + out_dtype: IntDType, + ) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, k, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgTopK(desc), + )) + .output() + } + + fn float_topk(tensor: FloatTensor, dim: usize, k: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, k, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::TopK(desc), + )) + .output() + } + + fn float_repeat_dim(tensor: FloatTensor, dim: usize, times: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = RepeatDimOpIr::create(tensor.into_ir(), dim, times, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::RepeatDim(desc))) + .output() + } + + fn float_argmin(tensor: FloatTensor, dim: usize, out_dtype: IntDType) -> IntTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_arg(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.input.dtype, + NumericOperationIr::ArgMin(desc), + )) + .output() + } + + fn float_max(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Max(desc), + )) + .output() + } + + fn float_max_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MaxDim(desc), + )) + .output() + } + + fn float_max_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let client = tensor.client.clone(); + let desc = + ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, indices_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.tensor.dtype, + NumericOperationIr::MaxDimWithIndices(desc), + )) + .outputs() + .into() + } + + fn float_max_abs(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MaxAbs(desc), + )) + .output() + } + + fn float_max_abs_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MaxAbsDim(desc), + )) + .output() + } + + fn float_min(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Min(desc), + )) + .output() + } + + fn float_min_dim(tensor: FloatTensor, dim: usize) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create(tensor.into_ir(), dim, 1, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::MinDim(desc), + )) + .output() + } + + fn float_min_dim_with_indices( + tensor: FloatTensor, + dim: usize, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let client = tensor.client.clone(); + let desc = + ReduceDimWithIndicesOpIr::create(tensor.into_ir(), dim, indices_dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.tensor.dtype, + NumericOperationIr::MinDimWithIndices(desc), + )) + .outputs() + .into() + } + + fn float_powf(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::Float( + desc.out.dtype, + FloatOperationIr::Powf(desc), + )) + .output() + } + + fn float_neg(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Neg(desc), + )) + .output() + } + + fn float_sign(tensor: FloatTensor) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnaryOpIr::create(tensor.into_ir(), || client.create_empty_handle()); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Sign(desc), + )) + .output() + } + + fn float_clamp_min(tensor: FloatTensor, min: Scalar) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ScalarOpIr::create(tensor.into_ir(), min.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::ClampMin(desc), + )) + .output() + } + + fn float_clamp_max(tensor: FloatTensor, max: Scalar) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ScalarOpIr::create(tensor.into_ir(), max.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::ClampMax(desc), + )) + .output() + } + + fn float_not_equal( + lhs: FloatTensor, + rhs: FloatTensor, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + BinaryOpIr::create_comparison(lhs.into_ir(), rhs.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::NotEqual(desc))) + .output() + } + + fn float_not_equal_elem( + lhs: FloatTensor, + rhs: Scalar, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let client = lhs.client.clone(); + let desc = + ScalarOpIr::create_comparison(lhs.into_ir(), rhs.into(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::NotEqualElem(desc))) + .output() + } + + fn float_all(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create_bool(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::All(desc))) + .output() + } + + fn float_any(tensor: FloatTensor, out_dtype: burn_std::BoolDType) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceOpIr::create_bool(tensor.into_ir(), out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::Any(desc))) + .output() + } + + fn float_all_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::AllDim(desc))) + .output() + } + + fn float_any_dim( + tensor: FloatTensor, + dim: usize, + out_dtype: burn_std::BoolDType, + ) -> BoolTensor { + let client = tensor.client.clone(); + let desc = ReduceDimOpIr::create_bool(tensor.into_ir(), dim, 1, out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::BaseFloat(BaseOperationIr::AnyDim(desc))) + .output() + } + + fn float_sort(tensor: FloatTensor, dim: usize, descending: bool) -> FloatTensor { + let client = tensor.client.clone(); + let desc = SortOpIr::create(tensor.into_ir(), dim, descending, || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Sort(desc), + )) + .output() + } + + fn float_sort_with_indices( + tensor: FloatTensor, + dim: usize, + descending: bool, + indices_dtype: IntDType, + ) -> (FloatTensor, IntTensor) { + let client = tensor.client.clone(); + let desc = SortWithIndicesOpIr::create( + tensor.into_ir(), + dim, + descending, + indices_dtype.into(), + || client.create_empty_handle(), + ); + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::SortWithIndices(desc), + )) + .outputs() + .into() + } + + fn float_argsort( + tensor: FloatTensor, + dim: usize, + descending: bool, + out_dtype: IntDType, + ) -> IntTensor { + let client = tensor.client.clone(); + let dtype = tensor.dtype; + let desc = + SortOpIr::create_arg(tensor.into_ir(), dim, descending, out_dtype.into(), || { + client.create_empty_handle() + }); + client + .register(OperationIr::NumericFloat( + dtype, + NumericOperationIr::ArgSort(desc), + )) + .output() + } + + fn float_powi(lhs: FloatTensor, rhs: IntTensor) -> FloatTensor { + let client = lhs.client.clone(); + let desc = BinaryOpIr::create(lhs.into_ir(), rhs.into_ir(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::Powi(desc), + )) + .output() + } + + fn float_powi_scalar_impl(lhs: FloatTensor, rhs: Scalar) -> FloatTensor { + let client = lhs.client.clone(); + let rhs = rhs.into(); + let desc = ScalarOpIr::create(lhs.into_ir(), rhs, || client.create_empty_handle()); + + client + .register(OperationIr::NumericFloat( + desc.out.dtype, + NumericOperationIr::PowiScalar(desc), + )) + .output() + } + + fn float_permute(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + let client = tensor.client.clone(); + let desc = PermuteOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Permute(desc))) + .output() + } + + fn float_expand(tensor: FloatTensor, shape: Shape) -> FloatTensor { + let client = tensor.client.clone(); + let desc = ShapeOpIr::expand(tensor.into_ir(), shape, || client.create_empty_handle()); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Expand(desc))) + .output() + } + + fn float_flip(tensor: FloatTensor, axes: &[usize]) -> FloatTensor { + let client = tensor.client.clone(); + let desc = FlipOpIr::create(tensor.into_ir(), axes.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Flip(desc))) + .output() + } + + fn float_cast(tensor: FloatTensor, dtype: burn_backend::FloatDType) -> FloatTensor { + let client = tensor.client.clone(); + let desc = CastOpIr::create(tensor.into_ir(), dtype.into(), || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Cast(desc))) + .output() + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + let client = tensor.client.clone(); + let desc = UnfoldOpIr::create(tensor.into_ir(), dim, size, step, || { + client.create_empty_handle() + }); + + client + .register(OperationIr::BaseFloat(BaseOperationIr::Unfold(desc))) + .output() + } +} diff --git a/crates/burn-router/src/ops/transaction.rs b/crates/burn-router/src/ops/transaction.rs new file mode 100644 index 0000000..ce7b9e0 --- /dev/null +++ b/crates/burn-router/src/ops/transaction.rs @@ -0,0 +1,5 @@ +use burn_backend::ops::TransactionOps; + +use crate::{BackendRouter, RouterChannel}; + +impl TransactionOps for BackendRouter {} diff --git a/crates/burn-router/src/ops/unary.rs b/crates/burn-router/src/ops/unary.rs new file mode 100644 index 0000000..0e6cb01 --- /dev/null +++ b/crates/burn-router/src/ops/unary.rs @@ -0,0 +1,160 @@ +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs.into()); + + $handles.register_float_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float_dim_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs); + + $handles.register_float_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_float_dim_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let input = $handles.get_float_tensor::(&$desc.input); + let output = $ops(input, $desc.axis, $desc.accumulator_len); + + $handles.register_float_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_float2int_dim_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let input = $handles.get_float_tensor::(&$desc.input); + let output = $ops( + input, + $desc.axis, + $desc.accumulator_len, + $desc.out.dtype.into(), + ); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! reduce_int_dim_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let input = $handles.get_int_tensor::(&$desc.input); + let output = $ops(input, $desc.axis, $desc.accumulator_len); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float2int_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_float_cmp_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs.into(), $desc.out.dtype.into()); + + $handles.register_bool_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! unary_float_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_float_tensor::(&$desc.input); + let output = $ops(lhs); + + $handles.register_float_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_int_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs.into()); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_int_dim_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! scalar_int_cmp_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.lhs); + let output = $ops(lhs, $desc.rhs.into(), $desc.out.dtype.into()); + + $handles.register_bool_tensor::(&$desc.out.id, output); + }}; +} + +#[allow(missing_docs)] +#[macro_export(local_inner_macros)] +macro_rules! unary_int_ops { + ( + $handles:expr, $desc:expr, $ops:expr + ) => {{ + let lhs = $handles.get_int_tensor::(&$desc.input); + let output = $ops(lhs); + + $handles.register_int_tensor::(&$desc.out.id, output); + }}; +} diff --git a/crates/burn-router/src/tensor.rs b/crates/burn-router/src/tensor.rs new file mode 100644 index 0000000..2b6bb60 --- /dev/null +++ b/crates/burn-router/src/tensor.rs @@ -0,0 +1,158 @@ +use core::sync::atomic::{AtomicU32, Ordering}; + +use alloc::format; +use alloc::{sync::Arc, vec::Vec}; + +use super::RouterClient; +use burn_backend::{DType, Shape, TensorData, TensorMetadata, backend::ExecutionError}; +use burn_ir::{TensorId, TensorIr, TensorStatus}; + +/// Tensor primitive for the [router backend](crate::BackendRouter). +pub struct RouterTensor { + pub(crate) id: TensorId, + pub(crate) shape: Shape, + pub(crate) dtype: DType, + /// The client that has this tensor + pub client: C, + pub(crate) count: Arc, +} + +impl TensorMetadata for RouterTensor { + type Device = C::Device; + fn dtype(&self) -> DType { + self.dtype + } + + fn shape(&self) -> Shape { + self.shape.clone() + } + + fn rank(&self) -> usize { + self.shape.num_dims() + } + + fn device(&self) -> Self::Device { + self.client.device() + } + + fn can_mut(&self) -> bool { + // Same sharing rule as `into_ir`: a handle cloned on its stream + // (count > 1) is read-only for the runner, a unique one is read-write. + self.count.load(Ordering::Acquire) <= 1 + } +} + +impl RouterTensor { + /// The id identifying this tensor on its [client](RouterClient). + pub fn id(&self) -> TensorId { + self.id + } + + /// Create a new router tensor. + pub fn new(id: TensorId, shape: Shape, dtype: DType, client: C) -> Self { + Self { + id, + shape, + dtype, + client, + count: Arc::new(AtomicU32::new(1)), + } + } + + pub(crate) async fn into_data(self) -> Result { + self.client.clone().read_tensor_async(self.into_ir()).await + } + + /// Get the ir for this tensor + pub fn into_ir(mut self) -> TensorIr { + let count = self.count.load(Ordering::Relaxed); + let status = self.status(count); + let mut shape_out = Shape::from(Vec::::new()); + core::mem::swap(&mut self.shape, &mut shape_out); + + if let TensorStatus::ReadWrite = status { + // Avoids an unwanted drop on the same thread. + // + // Since `drop` is called after `into_ir`, we must not register a drop if the tensor + // was consumed with a `ReadWrite` status. + self.count.fetch_add(1, Ordering::Relaxed); + } + + TensorIr { + status, + shape: shape_out, + id: self.id, + dtype: self.dtype, + } + } + + pub(crate) fn to_ir_out(&self) -> TensorIr { + TensorIr { + status: TensorStatus::NotInit, + shape: self.shape.clone(), + id: self.id, + dtype: self.dtype, + } + } + + pub(crate) fn status(&self, count: u32) -> TensorStatus { + if count <= 1 { + TensorStatus::ReadWrite + } else { + TensorStatus::ReadOnly + } + } +} + +impl core::fmt::Debug for RouterTensor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str( + format!( + "{{ id: {:?}, shape: {:?}, dtype: {:?}, device: {:?} }}", + self.id, + self.shape, + self.dtype, + self.client.device(), + ) + .as_str(), + ) + } +} + +impl Clone for RouterTensor { + fn clone(&self) -> Self { + self.count.fetch_add(1, Ordering::Relaxed); + + Self { + id: self.id, + shape: self.shape.clone(), + client: self.client.clone(), + dtype: self.dtype, + count: self.count.clone(), + } + } +} + +impl Drop for RouterTensor { + fn drop(&mut self) { + let count = self.count.fetch_sub(1, Ordering::Relaxed); + + match self.status(count) { + TensorStatus::ReadWrite => { + let id = self.id; + let mut shape = Shape::from(Vec::::new()); + core::mem::swap(&mut shape, &mut self.shape); + + let ir = TensorIr { + id, + shape, + status: TensorStatus::ReadWrite, + dtype: self.dtype, + }; + self.client.register_op(burn_ir::OperationIr::Drop(ir)); + } + TensorStatus::ReadOnly => {} + TensorStatus::NotInit => {} + } + } +} diff --git a/crates/burn-std/Cargo.toml b/crates/burn-std/Cargo.toml new file mode 100644 index 0000000..ddcde75 --- /dev/null +++ b/crates/burn-std/Cargo.toml @@ -0,0 +1,73 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +categories = [] +description = "Core types and utilities shared across the Burn ecosystem." +documentation = "https://docs.rs/burn-std" +edition.workspace = true +keywords = [] +license.workspace = true +name = "burn-std" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-std" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "cubecl-common/default"] +doc = ["default"] +std = ["cubecl-common/std", "num-traits/std", "rand/std"] +tracing = ["cubecl-common/tracing"] + +network = ["dep:indicatif", "dep:reqwest", "dep:tokio"] + +[dependencies] +ahash = { workspace = true } +hashbrown = { workspace = true } +bytemuck = { workspace = true, features = ["extern_crate_alloc"] } +derive-new = { workspace = true } +enumset = { workspace = true } +half = { workspace = true, features = ["bytemuck"] } +num-traits = { workspace = true } +rand = { workspace = true, default-features = false } +rand_distr = { workspace = true } +serde = { workspace = true } +smallvec = { workspace = true, features = ["serde"] } +spin = { workspace = true } +thiserror = { workspace = true } + +# ParamId +data-encoding = { workspace = true } +uuid = { workspace = true } + +cubecl-common = { workspace = true, default-features = false, features = [ + "serde", + "shared-bytes", +] } +cubecl-zspace = { workspace = true, default-features = false } +# Enable extra-platforms for portable-atomic support on targets without native atomics (e.g., thumbv6m) +# This is needed because cubecl-common's shared-bytes feature pulls in bytes +bytes = { workspace = true } + +# Network downloader +indicatif = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } + +[dev-dependencies] +dashmap = { workspace = true } +paste = { workspace = true } +rand = { workspace = true, features = ["thread_rng"] } +serde_json = { workspace = true, features = ["alloc"] } +serial_test = { workspace = true } +tempfile = { workspace = true } + +# Enable extra-platforms for bytes on targets without native atomics (e.g., thumbv6m-none-eabi) +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +bytes = { workspace = true, features = ["extra-platforms"] } +portable-atomic-util = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-std/LICENSE-APACHE b/crates/burn-std/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-std/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-std/LICENSE-MIT b/crates/burn-std/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-std/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-std/README.md b/crates/burn-std/README.md new file mode 100644 index 0000000..6969baf --- /dev/null +++ b/crates/burn-std/README.md @@ -0,0 +1,7 @@ +# Burn Standard Library + +`burn-std` provides the core types and utilities shared across the Burn ecosystem. +It includes foundational definitions for shapes, indexing, and data types. + +This crate supports both `std` and `no_std` environments and must compile with +`cargo build --no-default-features` as well. diff --git a/crates/burn-std/src/config/autodiff.rs b/crates/burn-std/src/config/autodiff.rs new file mode 100644 index 0000000..1038a2f --- /dev/null +++ b/crates/burn-std/src/config/autodiff.rs @@ -0,0 +1,43 @@ +use cubecl_common::config::logger::{LogLevel, LoggerConfig}; + +/// Configuration for autodiff in Burn. +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct AutodiffConfig { + /// Logger configuration for autodiff logs. + #[serde(default)] + pub logger: LoggerConfig, +} + +/// Log levels for autodiff logging. +#[derive( + Default, + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +pub enum AutodiffLogLevel { + /// Autodiff logging is disabled. + #[default] + #[serde(rename = "disabled")] + Disabled, + + /// Log backward graph size and the checkpoint strategy applied per forward pass. + #[serde(rename = "basic")] + Basic, + + /// Additionally log each tensor that gets checkpointed or recomputed. + #[serde(rename = "medium")] + Medium, + + /// Log every graph node traversal and recomputation event. + #[serde(rename = "full")] + Full, +} + +impl LogLevel for AutodiffLogLevel {} diff --git a/crates/burn-std/src/config/base.rs b/crates/burn-std/src/config/base.rs new file mode 100644 index 0000000..b668e69 --- /dev/null +++ b/crates/burn-std/src/config/base.rs @@ -0,0 +1,104 @@ +use cubecl_common::config::RuntimeConfig; +use cubecl_common::stub::Arc; + +use super::autodiff::AutodiffConfig; +use super::fusion::FusionConfig; +use super::remote::RemoteConfig; + +/// Static mutex holding the global Burn configuration, initialized as `None`. +static BURN_GLOBAL_CONFIG: spin::Mutex>> = spin::Mutex::new(None); + +/// Represents the global configuration for Burn. +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BurnConfig { + /// Configuration for operation fusion. + #[serde(default)] + fusion: FusionConfig, + + /// Configuration for autodiff. + #[serde(default)] + autodiff: AutodiffConfig, + + /// Configuration for the remote backend. + #[serde(default)] + remote: RemoteConfig, +} + +impl BurnConfig { + /// Returns a reference to the operation-fusion configuration. + pub fn fusion(&self) -> &FusionConfig { + &self.fusion + } + + /// Returns a reference to the autodiff configuration. + pub fn autodiff(&self) -> &AutodiffConfig { + &self.autodiff + } + + /// Returns a reference to the remote-backend configuration. + pub fn remote(&self) -> &RemoteConfig { + &self.remote + } +} + +impl RuntimeConfig for BurnConfig { + fn storage() -> &'static spin::Mutex>> { + &BURN_GLOBAL_CONFIG + } + + fn file_names() -> &'static [&'static str] { + &["burn.toml", "Burn.toml"] + } + + // Match cubecl-common's `std_io` cfg: only available on platforms where + // the trait method exists. See cubecl-common's build.rs. + #[cfg(all( + feature = "std", + any( + target_os = "windows", + target_os = "linux", + target_os = "macos", + target_os = "android" + ) + ))] + fn override_from_env(mut self) -> Self { + use super::fusion::FusionLogLevel; + use super::remote::RemoteLogLevel; + + if let Ok(val) = std::env::var("BURN_FUSION_LOG") { + let level = match val.to_ascii_lowercase().as_str() { + "disabled" | "off" | "0" => FusionLogLevel::Disabled, + "basic" => FusionLogLevel::Basic, + "medium" => FusionLogLevel::Medium, + "full" | "1" => FusionLogLevel::Full, + _ => self.fusion.logger.level, + }; + self.fusion.logger.level = level; + // Default to stderr so tests can see the output via `cargo test -- --nocapture`. + if level != FusionLogLevel::Disabled { + self.fusion.logger.stderr = true; + } + } + + if let Ok(val) = std::env::var("BURN_FUSION_MAX_EXPLORATIONS") + && let Ok(n) = val.parse::() + { + self.fusion.beam_search.max_explorations = Some(n); + } + + if let Ok(val) = std::env::var("BURN_REMOTE_LOG") { + let level = match val.to_ascii_lowercase().as_str() { + "disabled" | "off" | "0" => RemoteLogLevel::Disabled, + "basic" | "1" => RemoteLogLevel::Basic, + "full" | "2" => RemoteLogLevel::Full, + _ => self.remote.logger.level, + }; + self.remote.logger.level = level; + if level != RemoteLogLevel::Disabled { + self.remote.logger.stderr = true; + } + } + + self + } +} diff --git a/crates/burn-std/src/config/fusion.rs b/crates/burn-std/src/config/fusion.rs new file mode 100644 index 0000000..52fd606 --- /dev/null +++ b/crates/burn-std/src/config/fusion.rs @@ -0,0 +1,117 @@ +use cubecl_common::config::logger::{LogLevel, LoggerConfig}; + +/// Configuration for operation fusion in Burn. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct FusionConfig { + /// Logger configuration for fusion logs. + #[serde(default)] + pub logger: LoggerConfig, + + /// Beam search configuration used when exploring fusion opportunities. + #[serde(default)] + pub beam_search: BeamSearchConfig, + + /// Maximum number of operations in a single client-cached graph (router graph caching). + /// + /// The greedy graph fuser keeps accumulating ops into one cached graph; once it reaches this + /// many ops the graph is closed and dispatched. `None` (the default) sets no size cap, leaving + /// [`growth_patience`](Self::growth_patience) to decide when a graph stops being worth growing; + /// set a value to also bound the per-graph memory and the cost of building and replaying any + /// single graph. + #[serde(default)] + pub max_graph_size: Option, + + /// Close the current graph once its fusion score hasn't reached a new maximum for this many + /// consecutive ops (router graph caching). + /// + /// The fuser scores the accumulated ops as they grow and tracks the best score so far; once + /// this many ops have been added without beating it, the graph has stopped getting more worth + /// caching and is closed. Higher values keep growing the graph longer in search of a better + /// score. + #[serde(default = "default_growth_patience")] + pub growth_patience: usize, +} + +impl Default for FusionConfig { + fn default() -> Self { + Self { + logger: LoggerConfig::default(), + beam_search: BeamSearchConfig::default(), + max_graph_size: None, + growth_patience: default_growth_patience(), + } + } +} + +fn default_growth_patience() -> usize { + 32 +} + +/// Beam search configuration controlling how the fusion optimizer explores independent blocks +/// of operations. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BeamSearchConfig { + /// Maximum number of independent blocks explored during the fusion search. + /// + /// Higher values can find better fusion opportunities at the cost of more cache misses + /// in the fusion cache. + #[serde(default = "default_max_blocks")] + pub max_blocks: usize, + + /// Stop exploring new optimizations after this many explorations (per stream). + /// + /// Each cache miss runs the (relatively expensive) optimizer to build a new optimization. A + /// graph whose relative form changes every step never caches, so it re-explores forever. Once + /// this cap is reached, cache-missing segments execute *unfused* (no optimizer work, nothing + /// added to the cache) instead. Cache *hits* are unaffected, so already-cached stable graphs + /// keep replaying. `None` (the default) never disables exploration. + #[serde(default)] + pub max_explorations: Option, +} + +impl Default for BeamSearchConfig { + fn default() -> Self { + Self { + max_blocks: default_max_blocks(), + max_explorations: None, + } + } +} + +fn default_max_blocks() -> usize { + 5 +} + +/// Log levels for fusion logging. +#[derive( + Default, + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +pub enum FusionLogLevel { + /// Fusion logging is disabled. + #[default] + #[serde(rename = "disabled")] + Disabled, + + /// Log the final execution strategy selected per stream (single vs composed). + #[serde(rename = "basic")] + Basic, + + /// Log block merge/split decisions and cache hit/miss events. + #[serde(rename = "medium")] + Medium, + + /// Log every registration, rejection and scoring decision. + #[serde(rename = "full")] + Full, +} + +impl LogLevel for FusionLogLevel {} diff --git a/crates/burn-std/src/config/logger.rs b/crates/burn-std/src/config/logger.rs new file mode 100644 index 0000000..11a5884 --- /dev/null +++ b/crates/burn-std/src/config/logger.rs @@ -0,0 +1,186 @@ +use alloc::{string::String, vec::Vec}; +use core::fmt::Display; +use cubecl_common::{ + config::{ + RuntimeConfig, + logger::{LogLevel, LoggerConfig, LoggerSinks}, + }, + stub::Arc, +}; + +use super::{ + autodiff::AutodiffLogLevel, base::BurnConfig, fusion::FusionLogLevel, remote::RemoteLogLevel, +}; + +static BURN_LOGGER: spin::Mutex> = spin::Mutex::new(None); + +#[cfg(feature = "std")] +std::thread_local! { + static LOCAL_CONFIG: std::cell::OnceCell> = + const { std::cell::OnceCell::new() }; +} + +/// Returns the current [`BurnConfig`], cached in thread-local storage on native targets. +/// +/// On the first call from a given thread this fetches the global config via +/// [`BurnConfig::get`] (which locks a spin mutex) and caches the `Arc` thread-locally. +/// Subsequent calls on the same thread only pay an `Arc` clone. On `no_std` builds this +/// is equivalent to [`BurnConfig::get`]. +/// +/// Safe because [`BurnConfig::set`] panics after the first read, so the cached snapshot +/// matches the global singleton for the whole program lifetime. +pub fn config() -> Arc { + #[cfg(feature = "std")] + { + LOCAL_CONFIG.with(|cell| cell.get_or_init(BurnConfig::get).clone()) + } + #[cfg(not(feature = "std"))] + { + BurnConfig::get() + } +} + +/// Central logging utility for Burn, managing one sink registry shared across subsystems. +#[derive(Debug)] +pub struct Logger { + sinks: LoggerSinks, + fusion_index: Vec, + autodiff_index: Vec, + remote_index: Vec, + /// The configuration snapshot the logger was initialized with. + pub config: Arc, +} + +impl Default for Logger { + fn default() -> Self { + Self::new() + } +} + +impl Logger { + /// Creates a new `Logger` from the current global `BurnConfig`. + /// + /// Note that creating a logger is somewhat expensive because it opens file handles for any + /// sink configured with a file path. + pub fn new() -> Self { + let config = BurnConfig::get(); + let mut sinks = LoggerSinks::new(); + + let fusion_index = register_enabled( + &mut sinks, + &config.fusion().logger, + config.fusion().logger.level != FusionLogLevel::Disabled, + ); + let autodiff_index = register_enabled( + &mut sinks, + &config.autodiff().logger, + config.autodiff().logger.level != AutodiffLogLevel::Disabled, + ); + let remote_index = register_enabled( + &mut sinks, + &config.remote().logger, + config.remote().logger.level != RemoteLogLevel::Disabled, + ); + + Self { + sinks, + fusion_index, + autodiff_index, + remote_index, + config, + } + } + + /// Writes `msg` to all configured fusion sinks. + pub fn log_fusion(&mut self, msg: &S) { + self.sinks.log(&self.fusion_index, msg); + } + + /// Writes `msg` to all configured autodiff sinks. + pub fn log_autodiff(&mut self, msg: &S) { + self.sinks.log(&self.autodiff_index, msg); + } + + /// Writes `msg` to all configured remote-backend sinks. + pub fn log_remote(&mut self, msg: &S) { + self.sinks.log(&self.remote_index, msg); + } + + /// Returns the current fusion log level. + pub fn log_level_fusion(&self) -> FusionLogLevel { + self.config.fusion().logger.level + } + + /// Returns the current remote-backend log level. + pub fn log_level_remote(&self) -> RemoteLogLevel { + self.config.remote().logger.level + } + + /// Returns the current autodiff log level. + pub fn log_level_autodiff(&self) -> AutodiffLogLevel { + self.config.autodiff().logger.level + } +} + +fn register_enabled( + sinks: &mut LoggerSinks, + config: &LoggerConfig, + enabled: bool, +) -> Vec { + if enabled { + sinks.register(config) + } else { + Vec::new() + } +} + +/// Emit a fusion log message when the configured level is at least `level`. +/// +/// The message is only constructed when logging is enabled. +pub fn log_fusion(level: FusionLogLevel, f: F) +where + F: FnOnce() -> String, +{ + let current = config().fusion().logger.level; + if current < level { + return; + } + let msg = f(); + let mut guard = BURN_LOGGER.lock(); + let logger = guard.get_or_insert_with(Logger::new); + logger.log_fusion(&msg); +} + +/// Emit an autodiff log message when the configured level is at least `level`. +/// +/// The message is only constructed when logging is enabled. +pub fn log_autodiff(level: AutodiffLogLevel, f: F) +where + F: FnOnce() -> String, +{ + let current = config().autodiff().logger.level; + if current < level { + return; + } + let msg = f(); + let mut guard = BURN_LOGGER.lock(); + let logger = guard.get_or_insert_with(Logger::new); + logger.log_autodiff(&msg); +} + +/// Emit a remote-backend log message when the configured level is at least `level`. +/// +/// The message is only constructed when logging is enabled. +pub fn log_remote(level: RemoteLogLevel, f: F) +where + F: FnOnce() -> String, +{ + let current = config().remote().logger.level; + if current < level { + return; + } + let msg = f(); + let mut guard = BURN_LOGGER.lock(); + let logger = guard.get_or_insert_with(Logger::new); + logger.log_remote(&msg); +} diff --git a/crates/burn-std/src/config/mod.rs b/crates/burn-std/src/config/mod.rs new file mode 100644 index 0000000..478be3d --- /dev/null +++ b/crates/burn-std/src/config/mod.rs @@ -0,0 +1,14 @@ +/// Autodiff config module. +pub mod autodiff; +/// Fusion config module. +pub mod fusion; +/// Remote backend config module. +pub mod remote; + +mod base; +mod logger; + +pub use base::*; +pub use cubecl_common::config::RuntimeConfig; +pub use cubecl_common::config::logger::{LogCrateLevel, LogLevel, LoggerConfig, LoggerSinks}; +pub use logger::*; diff --git a/crates/burn-std/src/config/remote.rs b/crates/burn-std/src/config/remote.rs new file mode 100644 index 0000000..90546be --- /dev/null +++ b/crates/burn-std/src/config/remote.rs @@ -0,0 +1,75 @@ +use cubecl_common::config::logger::{LogLevel, LoggerConfig}; + +/// Configuration for the remote backend. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct RemoteConfig { + /// Logger configuration for remote-backend logs (e.g. the bytes-saved metric of the + /// fusion / op-graph caching feature). + #[serde(default)] + pub logger: LoggerConfig, + + /// Flush the outgoing task buffer once this many tasks have accumulated. + /// + /// Wire-level batching only — every task keeps its own stream/request id, so the server sees + /// the same per-task semantics. Larger values batch more aggressively (fewer, bigger frames); + /// smaller values cut latency for chains of fire-and-forget submits. + #[serde(default = "default_flush_threshold")] + pub flush_threshold: usize, + + /// Flush once this many bytes of buffered tensor data accumulate, independent of + /// [`flush_threshold`](Self::flush_threshold). + /// + /// Bounds how much tensor data sits unsent so large uploads go out promptly while small ops keep + /// batching. Larger batches fewer/bigger frames; smaller cuts latency for data-heavy streams. + #[serde(default = "default_flush_bytes_threshold")] + pub flush_bytes_threshold: usize, +} + +impl Default for RemoteConfig { + fn default() -> Self { + Self { + logger: LoggerConfig::default(), + flush_threshold: default_flush_threshold(), + flush_bytes_threshold: default_flush_bytes_threshold(), + } + } +} + +fn default_flush_threshold() -> usize { + 4 +} + +fn default_flush_bytes_threshold() -> usize { + 1024 * 1024 +} + +/// Log levels for remote-backend logging. +#[derive( + Default, + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +pub enum RemoteLogLevel { + /// Remote logging is disabled. + #[default] + #[serde(rename = "disabled")] + Disabled, + + /// Log periodic summaries — e.g. the cumulative network bytes saved by op-graph caching, + /// emitted once per additional mebibyte saved. + #[serde(rename = "basic")] + Basic, + + /// Log every optimization registration and replay, with per-message sizes. + #[serde(rename = "full")] + Full, +} + +impl LogLevel for RemoteLogLevel {} diff --git a/crates/burn-std/src/data/compare.rs b/crates/burn-std/src/data/compare.rs new file mode 100644 index 0000000..090f491 --- /dev/null +++ b/crates/burn-std/src/data/compare.rs @@ -0,0 +1,428 @@ +use alloc::format; +use alloc::string::String; +use num_traits::{Float, ToPrimitive}; + +use super::TensorData; +use crate::{BoolStore, DType, Element, ElementOrdered, bf16, f16}; + +/// The tolerance used to compare to floating point numbers. +/// +/// Generally, two numbers `x` and `y` are approximately equal if +/// +/// ```text +/// |x - y| < max(R * (|x + y|), A) +/// ``` +/// +/// where `R` is the relative tolerance and `A` is the absolute tolerance. +/// +/// +/// The most common way to initialize this struct is to use `Tolerance::::default()`. +/// In that case, the relative and absolute tolerances are computed using an heuristic based +/// on the EPSILON and MIN_POSITIVE values of the given floating point type `F`. +/// +/// Another common initialization is `Tolerance::::rel_abs(1e-4, 1e-5).set_half_precision_relative(1e-2)`. +/// This will use a sane default to manage values too close to 0.0 and +/// use different relative tolerances depending on the floating point precision. +#[derive(Debug, Clone, Copy)] +pub struct Tolerance { + relative: F, + absolute: F, +} + +impl Default for Tolerance { + fn default() -> Self { + Self::balanced() + } +} + +impl Tolerance { + /// Create a tolerance with strict precision setting. + pub fn strict() -> Self { + Self { + relative: F::from(0.00).unwrap(), + absolute: F::from(64).unwrap() * F::min_positive_value(), + } + } + /// Create a tolerance with balanced precision setting. + pub fn balanced() -> Self { + Self { + relative: F::from(0.005).unwrap(), // 0.5% + absolute: F::from(1e-5).unwrap(), + } + } + + /// Create a tolerance with permissive precision setting. + pub fn permissive() -> Self { + Self { + relative: F::from(0.01).unwrap(), // 1.0% + absolute: F::from(0.01).unwrap(), + } + } + /// When comparing two numbers, this uses both the relative and absolute differences. + /// + /// That is, `x` and `y` are approximately equal if + /// + /// ```text + /// |x - y| < max(R * (|x + y|), A) + /// ``` + /// + /// where `R` is the `relative` tolerance and `A` is the `absolute` tolerance. + pub fn rel_abs(relative: FF, absolute: FF) -> Self { + let relative = Self::check_relative(relative); + let absolute = Self::check_absolute(absolute); + + Self { relative, absolute } + } + + /// When comparing two numbers, this uses only the relative difference. + /// + /// That is, `x` and `y` are approximately equal if + /// + /// ```text + /// |x - y| < R * max(|x|, |y|) + /// ``` + /// + /// where `R` is the relative `tolerance`. + pub fn relative(tolerance: FF) -> Self { + let relative = Self::check_relative(tolerance); + + Self { + relative, + absolute: F::from(0.0).unwrap(), + } + } + + /// When comparing two numbers, this uses only the absolute difference. + /// + /// That is, `x` and `y` are approximately equal if + /// + /// ```text + /// |x - y| < A + /// ``` + /// + /// where `A` is the absolute `tolerance`. + pub fn absolute(tolerance: FF) -> Self { + let absolute = Self::check_absolute(tolerance); + + Self { + relative: F::from(0.0).unwrap(), + absolute, + } + } + + /// Change the relative tolerance to the given one. + pub fn set_relative(mut self, tolerance: FF) -> Self { + self.relative = Self::check_relative(tolerance); + self + } + + /// Change the relative tolerance to the given one only if `F` is half precision. + pub fn set_half_precision_relative(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 2 { + self.relative = Self::check_relative(tolerance); + } + self + } + + /// Change the relative tolerance to the given one only if `F` is single precision. + pub fn set_single_precision_relative(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 4 { + self.relative = Self::check_relative(tolerance); + } + self + } + + /// Change the relative tolerance to the given one only if `F` is double precision. + pub fn set_double_precision_relative(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 8 { + self.relative = Self::check_relative(tolerance); + } + self + } + + /// Change the absolute tolerance to the given one. + pub fn set_absolute(mut self, tolerance: FF) -> Self { + self.absolute = Self::check_absolute(tolerance); + self + } + + /// Change the absolute tolerance to the given one only if `F` is half precision. + pub fn set_half_precision_absolute(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 2 { + self.absolute = Self::check_absolute(tolerance); + } + self + } + + /// Change the absolute tolerance to the given one only if `F` is single precision. + pub fn set_single_precision_absolute(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 4 { + self.absolute = Self::check_absolute(tolerance); + } + self + } + + /// Change the absolute tolerance to the given one only if `F` is double precision. + pub fn set_double_precision_absolute(mut self, tolerance: FF) -> Self { + if core::mem::size_of::() == 8 { + self.absolute = Self::check_absolute(tolerance); + } + self + } + + /// Checks if `x` and `y` are approximately equal given the tolerance. + pub fn approx_eq(&self, x: F, y: F) -> bool { + // See the accepted answer here + // https://stackoverflow.com/questions/4915462/how-should-i-do-floating-point-comparison + + // This also handles the case where both a and b are infinity so that we don't need + // to manage it in the rest of the function. + if x == y { + return true; + } + + let diff = (x - y).abs(); + let max = F::max(x.abs(), y.abs()); + + diff < self.absolute.max(self.relative * max) + } + + fn check_relative(tolerance: FF) -> F { + let tolerance = F::from(tolerance).unwrap(); + assert!(tolerance <= F::one()); + tolerance + } + + fn check_absolute(tolerance: FF) -> F { + let tolerance = F::from(tolerance).unwrap(); + assert!(tolerance >= F::zero()); + tolerance + } +} + +impl TensorData { + /// Asserts the data is equal to another data. + /// + /// # Arguments + /// + /// * `other` - The other data. + /// * `strict` - If true, the data types must the be same. + /// Otherwise, the comparison is done in the current data type. + /// + /// # Panics + /// + /// Panics if the data is not equal. + #[track_caller] + pub fn assert_eq(&self, other: &Self, strict: bool) { + if strict { + assert_eq!( + self.dtype, other.dtype, + "Data types differ ({:?} != {:?})", + self.dtype, other.dtype + ); + } + + match self.dtype { + DType::F64 => self.assert_eq_elem::(other), + DType::F32 | DType::Flex32 => self.assert_eq_elem::(other), + DType::F16 => self.assert_eq_elem::(other), + DType::BF16 => self.assert_eq_elem::(other), + DType::I64 => self.assert_eq_elem::(other), + DType::I32 => self.assert_eq_elem::(other), + DType::I16 => self.assert_eq_elem::(other), + DType::I8 => self.assert_eq_elem::(other), + DType::U64 => self.assert_eq_elem::(other), + DType::U32 => self.assert_eq_elem::(other), + DType::U16 => self.assert_eq_elem::(other), + DType::U8 => self.assert_eq_elem::(other), + DType::Bool(BoolStore::Native) => self.assert_eq_elem::(other), + DType::Bool(BoolStore::U8) => self.assert_eq_elem::(other), + DType::Bool(BoolStore::U32) => self.assert_eq_elem::(other), + DType::QFloat(q) => { + // Strict or not, it doesn't make sense to compare quantized data to not quantized data for equality + let q_other = if let DType::QFloat(q_other) = other.dtype { + q_other + } else { + panic!("Quantized data differs from other not quantized data") + }; + + // Data equality mostly depends on input quantization type, but we also check level + if q.value == q_other.value && q.level == q_other.level { + self.assert_eq_elem::(other) + } else { + panic!("Quantization schemes differ ({q:?} != {q_other:?})") + } + } + } + } + + #[track_caller] + fn assert_eq_elem(&self, other: &Self) { + let mut message = String::new(); + if self.shape != other.shape { + message += format!( + "\n => Shape is different: {:?} != {:?}", + self.shape, other.shape + ) + .as_str(); + } + + let mut num_diff = 0; + let max_num_diff = 5; + for (i, (a, b)) in self.iter::().zip(other.iter::()).enumerate() { + if !a.eq(&b) { + // Only print the first 5 different values. + if num_diff < max_num_diff { + message += format!("\n => Position {i}: {a} != {b}").as_str(); + } + num_diff += 1; + } + } + + if num_diff >= max_num_diff { + message += format!("\n{} more errors...", num_diff - max_num_diff).as_str(); + } + + if !message.is_empty() { + panic!("Tensors are not eq:{message}"); + } + } + + /// Asserts the data is approximately equal to another data. + /// + /// # Arguments + /// + /// * `other` - The other data. + /// * `tolerance` - The tolerance of the comparison. + /// + /// # Panics + /// + /// Panics if the data is not approximately equal. + #[track_caller] + pub fn assert_approx_eq(&self, other: &Self, tolerance: Tolerance) { + let mut message = String::new(); + if self.shape != other.shape { + message += format!( + "\n => Shape is different: {:?} != {:?}", + self.shape, other.shape + ) + .as_str(); + } + + let iter = self.iter::().zip(other.iter::()); + + let mut num_diff = 0; + let max_num_diff = 5; + + for (i, (a, b)) in iter.enumerate() { + //if they are both nan, then they are equally nan + let both_nan = a.is_nan() && b.is_nan(); + //this works for both infinities + let both_inf = + a.is_infinite() && b.is_infinite() && ((a > F::zero()) == (b > F::zero())); + + if both_nan || both_inf { + continue; + } + + if !tolerance.approx_eq(F::from(a).unwrap(), F::from(b).unwrap()) { + // Only print the first 5 different values. + if num_diff < max_num_diff { + let diff_abs = ToPrimitive::to_f64(&(a - b).abs()).unwrap(); + let max = F::max(a.abs(), b.abs()); + let diff_rel = diff_abs / ToPrimitive::to_f64(&max).unwrap(); + + let tol_rel = ToPrimitive::to_f64(&tolerance.relative).unwrap(); + let tol_abs = ToPrimitive::to_f64(&tolerance.absolute).unwrap(); + + message += format!( + "\n => Position {i}: {a} != {b}\n diff (rel = {diff_rel:+.2e}, abs = {diff_abs:+.2e}), tol (rel = {tol_rel:+.2e}, abs = {tol_abs:+.2e})" + ) + .as_str(); + } + num_diff += 1; + } + } + + if num_diff >= max_num_diff { + message += format!("\n{} more errors...", num_diff - 5).as_str(); + } + + if !message.is_empty() { + panic!("Tensors are not approx eq:{message}"); + } + } + + /// Asserts each value is within a given range. + /// + /// # Arguments + /// + /// * `range` - The range. + /// + /// # Panics + /// + /// If any value is not within the half-open range bounded inclusively below + /// and exclusively above (`start..end`). + pub fn assert_within_range(&self, range: core::ops::Range) { + for elem in self.iter::() { + if elem.cmp(&range.start).is_lt() || elem.cmp(&range.end).is_ge() { + panic!("Element ({elem:?}) is not within range {range:?}"); + } + } + } + + /// Asserts each value is within a given inclusive range. + /// + /// # Arguments + /// + /// * `range` - The range. + /// + /// # Panics + /// + /// If any value is not within the half-open range bounded inclusively (`start..=end`). + pub fn assert_within_range_inclusive( + &self, + range: core::ops::RangeInclusive, + ) { + let start = range.start(); + let end = range.end(); + + for elem in self.iter::() { + if elem.cmp(start).is_lt() || elem.cmp(end).is_gt() { + panic!("Element ({elem:?}) is not within range {range:?}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_assert_appox_eq_limit() { + let data1 = TensorData::from([[3.0, 5.0, 6.0]]); + let data2 = TensorData::from([[3.03, 5.0, 6.0]]); + + data1.assert_approx_eq::(&data2, Tolerance::absolute(3e-2)); + data1.assert_approx_eq::(&data2, Tolerance::absolute(3e-2)); + } + + #[test] + #[should_panic] + fn should_assert_approx_eq_above_limit() { + let data1 = TensorData::from([[3.0, 5.0, 6.0]]); + let data2 = TensorData::from([[3.031, 5.0, 6.0]]); + + data1.assert_approx_eq::(&data2, Tolerance::absolute(1e-2)); + } + + #[test] + #[should_panic] + fn should_assert_approx_eq_check_shape() { + let data1 = TensorData::from([[3.0, 5.0, 6.0, 7.0]]); + let data2 = TensorData::from([[3.0, 5.0, 6.0]]); + + data1.assert_approx_eq::(&data2, Tolerance::absolute(1e-2)); + } +} diff --git a/crates/burn-std/src/data/mod.rs b/crates/burn-std/src/data/mod.rs new file mode 100644 index 0000000..cf5d2dc --- /dev/null +++ b/crates/burn-std/src/data/mod.rs @@ -0,0 +1,5 @@ +mod compare; +mod tensor; + +pub use compare::*; +pub use tensor::*; diff --git a/crates/burn-std/src/data/tensor.rs b/crates/burn-std/src/data/tensor.rs new file mode 100644 index 0000000..2aa3b66 --- /dev/null +++ b/crates/burn-std/src/data/tensor.rs @@ -0,0 +1,936 @@ +use core::f32; + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use bytemuck::{AnyBitPattern, CheckedBitPattern, Zeroable, cast_mut, checked::CheckedCastError}; +use rand::Rng; +use thiserror::Error; + +use crate::Scalar; +use crate::distribution::Distribution; +use crate::element::{Element, ElementConversion}; +use crate::tensor::DType; +use crate::{ + BoolStore, Bytes, QuantLevel, QuantMode, QuantScheme, QuantValue, QuantizedBytes, Shape, bf16, + f16, +}; + +use serde::{Deserialize, Serialize}; + +/// Data structure for tensors. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TensorData { + /// The values of the tensor (as bytes). + pub bytes: Bytes, + + /// The shape of the tensor. + #[serde(with = "shape_inner")] + pub shape: Shape, + + /// The data type of the tensor. + pub dtype: DType, +} + +// For backward compatibility with shape `Vec` +mod shape_inner { + use crate::SmallVec; + + use super::*; + + pub fn serialize( + shape: &Shape, + serializer: S, + ) -> Result { + shape.as_slice().serialize(serializer) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result { + let dims = SmallVec::<[usize; _]>::deserialize(deserializer)?; + Ok(Shape::new_raw(dims)) + } +} + +impl TensorData { + /// Creates a new tensor data structure. + pub fn new>(value: Vec, shape: S) -> Self { + // Ensure shape is valid + let shape = shape.into(); + Self::check_data_len(&value, &shape); + + Self { + bytes: Bytes::from_elems(value), + shape, + dtype: E::dtype(), + } + } + + /// Creates a new quantized tensor data structure. + pub fn quantized>( + value: Vec, + shape: S, + scheme: QuantScheme, + qparams: &[f32], + ) -> Self { + let shape = shape.into(); + Self::check_data_len(&value, &shape); + + let q_bytes = QuantizedBytes::new(value, scheme, qparams); + + Self { + bytes: q_bytes.bytes, + shape, + dtype: DType::QFloat(q_bytes.scheme), + } + } + + /// Creates a new tensor data structure from raw bytes. + pub fn from_bytes>(bytes: Bytes, shape: S, dtype: DType) -> Self { + Self { + bytes, + shape: shape.into(), + dtype, + } + } + + /// Creates a new tensor data structure from raw bytes stored in a vector. + /// + /// Prefer [`TensorData::new`] or [`TensorData::quantized`] over this method unless you are + /// certain that the bytes representation is valid. + pub fn from_bytes_vec>(bytes: Vec, shape: S, dtype: DType) -> Self { + Self { + bytes: Bytes::from_bytes_vec(bytes), + shape: shape.into(), + dtype, + } + } + + // Check that the input vector contains a correct number of elements + fn check_data_len(data: &[E], shape: &Shape) { + let expected_data_len = Self::numel(shape); + let num_data = data.len(); + assert_eq!( + expected_data_len, num_data, + "Shape {shape:?} is invalid for input of size {num_data:?}", + ); + } + + /// Returns the immutable slice view of the tensor data. + pub fn as_slice(&self) -> Result<&[E], DataError> { + if self.matches_target_dtype::() { + match E::dtype() { + // The only way to create a bool `TensorData` with invalid values is by unsafely modifying + // the dtype. This should be considered unsafe to begin with, so we unsafely cast bool + // to u8 to skip bit validation. Validation iterates through the entire vector, so it's slow. + DType::Bool(BoolStore::Native) => { + let slice = bytemuck::checked::try_cast_slice::<_, u8>(&self.bytes) + .map_err(DataError::CastError)?; + Ok(unsafe { core::mem::transmute::<&[u8], &[E]>(slice) }) + } + _ => bytemuck::checked::try_cast_slice(&self.bytes).map_err(DataError::CastError), + } + } else { + Err(DataError::TypeMismatch(format!( + "Invalid target element type (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ))) + } + } + + /// Returns the mutable slice view of the tensor data. + /// + /// # Panics + /// If the target element type is different from the stored element type. + pub fn as_mut_slice(&mut self) -> Result<&mut [E], DataError> { + if self.matches_target_dtype::() { + match E::dtype() { + // The only way to create a bool `TensorData` with invalid values is by unsafely modifying + // the dtype. This should be considered unsafe to begin with, so we unsafely cast bool + // to u8 to skip bit validation. Validation iterates through the entire vector, so it's slow. + DType::Bool(BoolStore::Native) => { + let slice = bytemuck::checked::try_cast_slice_mut::<_, u8>(&mut self.bytes) + .map_err(DataError::CastError)?; + Ok(unsafe { core::mem::transmute::<&mut [u8], &mut [E]>(slice) }) + } + _ => bytemuck::checked::try_cast_slice_mut(&mut self.bytes) + .map_err(DataError::CastError), + } + } else { + Err(DataError::TypeMismatch(format!( + "Invalid target element type (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ))) + } + } + + /// Returns the tensor data as a vector of scalar values. + pub fn to_vec(&self) -> Result, DataError> { + Ok(self.as_slice()?.to_vec()) + } + + /// Returns the tensor data as a vector of scalar values. + pub fn into_vec(self) -> Result, DataError> { + // This means we cannot call `into_vec` for QFloat + if !self.matches_target_dtype::() { + return Err(DataError::TypeMismatch(format!( + "Invalid target element type (expected {:?}, got {:?})", + self.dtype, + E::dtype() + ))); + } + + match E::dtype() { + // The only way to create a bool `TensorData` with invalid values is by unsafely modifying + // the dtype. This should be considered unsafe to begin with, so we unsafely cast bool + // to u8 to skip bit validation. Validation iterates through the entire vector, so it's slow. + DType::Bool(BoolStore::Native) => { + let vec = self.into_vec_unchecked::()?; + Ok(unsafe { core::mem::transmute::, Vec>(vec) }) + } + _ => self.into_vec_unchecked(), + } + } + + /// Returns the tensor data as a vector of scalar values. Does not check dtype. + fn into_vec_unchecked(self) -> Result, DataError> { + let mut me = self; + me.bytes = match me.bytes.try_into_vec::() { + Ok(elems) => return Ok(elems), + Err(bytes) => bytes, + }; + + // The bytes might have been deserialized and allocated with a different align. + // In that case, we have to memcopy the data into a new vector, more suitably allocated + Ok(bytemuck::checked::try_cast_slice(me.as_bytes()) + .map_err(DataError::CastError)? + .to_vec()) + } + + fn matches_target_dtype(&self) -> bool { + let target_dtype = E::dtype(); + match self.dtype { + DType::Bool(BoolStore::U8) => { + matches!(target_dtype, DType::U8 | DType::Bool(BoolStore::U8)) + } + DType::Bool(BoolStore::U32) => { + matches!(target_dtype, DType::U32 | DType::Bool(BoolStore::U32)) + } + dtype => dtype == target_dtype, + } + } + + /// Returns an iterator over the values of the tensor data. + pub fn iter(&self) -> Box + '_> { + if E::dtype() == self.dtype { + Box::new(bytemuck::checked::cast_slice(&self.bytes).iter().copied()) + } else { + match self.dtype { + DType::I8 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &i8| e.elem::()), + ), + DType::I16 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &i16| e.elem::()), + ), + DType::I32 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &i32| e.elem::()), + ), + DType::I64 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &i64| e.elem::()), + ), + DType::U8 => Box::new(self.bytes.iter().map(|e| e.elem::())), + DType::U16 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &u16| e.elem::()), + ), + DType::U32 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &u32| e.elem::()), + ), + DType::U64 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &u64| e.elem::()), + ), + DType::BF16 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &bf16| e.elem::()), + ), + DType::F16 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &f16| e.elem::()), + ), + DType::F32 | DType::Flex32 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &f32| e.elem::()), + ), + DType::F64 => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &f64| e.elem::()), + ), + // bool is a byte value equal to either 0 or 1 + DType::Bool(BoolStore::Native) | DType::Bool(BoolStore::U8) => { + Box::new(self.bytes.iter().map(|e| e.elem::())) + } + DType::Bool(BoolStore::U32) => Box::new( + bytemuck::checked::cast_slice(&self.bytes) + .iter() + .map(|e: &u32| e.elem::()), + ), + DType::QFloat(scheme) => match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: + QuantValue::Q8F + | QuantValue::Q8S + // Represent sub-byte values as i8 + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S, + .. + } => { + // Quantized int8 values + let q_bytes = QuantizedBytes { + bytes: self.bytes.clone(), + scheme, + num_elements: self.num_elements(), + }; + let (values, _) = q_bytes.into_vec_i8(); + + Box::new( + values + .iter() + .map(|e: &i8| e.elem::()) + .collect::>() + .into_iter(), + ) + } + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: + QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1, + .. + } => { + unimplemented!("Not yet implemented for iteration"); + } + }, + } + } + } + + /// Returns the rank (the number of dimensions). + pub fn rank(&self) -> usize { + self.shape.len() + } + + /// Returns the total number of elements of the tensor data. + pub fn num_elements(&self) -> usize { + Self::numel(&self.shape) + } + + fn numel(shape: &[usize]) -> usize { + shape.iter().product() + } + + /// Populates the data with random values. + pub fn random>( + shape: S, + distribution: Distribution, + rng: &mut R, + ) -> Self { + let shape = shape.into(); + let num_elements = Self::numel(&shape); + let mut data = Vec::with_capacity(num_elements); + + for _ in 0..num_elements { + data.push(E::random(distribution, rng)); + } + + TensorData::new(data, shape) + } + + /// Populates the data with zeros. + pub fn zeros>(shape: S) -> TensorData { + let shape = shape.into(); + let num_elements = Self::numel(&shape); + let mut data = Vec::::with_capacity(num_elements); + + for _ in 0..num_elements { + data.push(0.elem()); + } + + TensorData::new(data, shape) + } + + /// Populates the data with ones. + pub fn ones>(shape: S) -> TensorData { + let shape = shape.into(); + let num_elements = Self::numel(&shape); + let mut data = Vec::::with_capacity(num_elements); + + for _ in 0..num_elements { + data.push(1.elem()); + } + + TensorData::new(data, shape) + } + + /// Populates the data with the given value + pub fn full>(shape: S, fill_value: E) -> TensorData { + let shape = shape.into(); + let num_elements = Self::numel(&shape); + let mut data = Vec::::with_capacity(num_elements); + for _ in 0..num_elements { + data.push(fill_value) + } + + TensorData::new(data, shape) + } + + /// Populates the data with the given value + pub fn full_dtype, S: Into>( + shape: S, + fill_value: E, + dtype: DType, + ) -> TensorData { + let fill_value = fill_value.into(); + match dtype { + DType::F64 => Self::full::(shape, fill_value.elem()), + DType::F32 | DType::Flex32 => Self::full::(shape, fill_value.elem()), + DType::F16 => Self::full::(shape, fill_value.elem()), + DType::BF16 => Self::full::(shape, fill_value.elem()), + DType::I64 => Self::full::(shape, fill_value.elem()), + DType::I32 => Self::full::(shape, fill_value.elem()), + DType::I16 => Self::full::(shape, fill_value.elem()), + DType::I8 => Self::full::(shape, fill_value.elem()), + DType::U64 => Self::full::(shape, fill_value.elem()), + DType::U32 => Self::full::(shape, fill_value.elem()), + DType::U16 => Self::full::(shape, fill_value.elem()), + DType::U8 => Self::full::(shape, fill_value.elem()), + DType::Bool(BoolStore::Native) => Self::full::(shape, fill_value.elem()), + DType::Bool(BoolStore::U8) => { + Self::full::(shape, fill_value.elem()).into_bool_u8() + } + DType::Bool(BoolStore::U32) => { + Self::full::(shape, fill_value.elem()).into_bool_u32() + } + DType::QFloat(_) => unreachable!(), + } + } + + // Unchecked, used to overwrite the dtype + fn into_bool_u8(mut self) -> Self { + self.dtype = DType::Bool(BoolStore::U8); + self + } + + // Unchecked, used to overwrite the dtype + fn into_bool_u32(mut self) -> Self { + self.dtype = DType::Bool(BoolStore::U32); + self + } + + /// Converts the data to a different element type. + pub fn convert(self) -> Self { + self.convert_dtype(E::dtype()) + } + + /// Converts the data to a different element type. + pub fn convert_dtype(self, dtype: DType) -> Self { + if dtype == self.dtype { + self + } else if dtype.size() == self.dtype.size() + && !matches!( + self.dtype, + DType::Bool(BoolStore::Native) | DType::QFloat(_) + ) + && !matches!(dtype, DType::Bool(BoolStore::Native) | DType::QFloat(_)) + { + match self.dtype { + DType::F64 => self.convert_inplace_dtype::(dtype), + DType::F32 | DType::Flex32 => self.convert_inplace_dtype::(dtype), + DType::F16 => self.convert_inplace_dtype::(dtype), + DType::BF16 => self.convert_inplace_dtype::(dtype), + DType::I64 => self.convert_inplace_dtype::(dtype), + DType::I32 => self.convert_inplace_dtype::(dtype), + DType::I16 => self.convert_inplace_dtype::(dtype), + DType::I8 => self.convert_inplace_dtype::(dtype), + DType::U64 => self.convert_inplace_dtype::(dtype), + DType::U32 => self.convert_inplace_dtype::(dtype), + DType::U16 => self.convert_inplace_dtype::(dtype), + DType::U8 => self.convert_inplace_dtype::(dtype), + DType::Bool(BoolStore::U8) => self.convert_inplace_dtype::(dtype), + DType::Bool(BoolStore::U32) => self.convert_inplace_dtype::(dtype), + DType::Bool(BoolStore::Native) | DType::QFloat(_) => unreachable!(), + } + } else { + match self.dtype { + DType::F64 => self.convert_clone_dtype::(dtype), + DType::F32 | DType::Flex32 => self.convert_clone_dtype::(dtype), + DType::F16 => self.convert_clone_dtype::(dtype), + DType::BF16 => self.convert_clone_dtype::(dtype), + DType::I64 => self.convert_clone_dtype::(dtype), + DType::I32 => self.convert_clone_dtype::(dtype), + DType::I16 => self.convert_clone_dtype::(dtype), + DType::I8 => self.convert_clone_dtype::(dtype), + DType::U64 => self.convert_clone_dtype::(dtype), + DType::U32 => self.convert_clone_dtype::(dtype), + DType::U16 => self.convert_clone_dtype::(dtype), + DType::U8 => self.convert_clone_dtype::(dtype), + DType::Bool(BoolStore::Native) => self.convert_clone_dtype::(dtype), + DType::Bool(BoolStore::U8) => self.convert_clone_dtype::(dtype), + DType::Bool(BoolStore::U32) => self.convert_clone_dtype::(dtype), + DType::QFloat(_) => unreachable!(), + } + } + } + + fn convert_inplace_dtype(self, dtype: DType) -> Self { + match dtype { + DType::F64 => self.convert_inplace::(), + DType::F32 | DType::Flex32 => self.convert_inplace::(), + DType::F16 => self.convert_inplace::(), + DType::BF16 => self.convert_inplace::(), + DType::I64 => self.convert_inplace::(), + DType::I32 => self.convert_inplace::(), + DType::I16 => self.convert_inplace::(), + DType::I8 => self.convert_inplace::(), + DType::U64 => self.convert_inplace::(), + DType::U32 => self.convert_inplace::(), + DType::U16 => self.convert_inplace::(), + DType::U8 => self.convert_inplace::(), + DType::Bool(BoolStore::U8) => self.convert_inplace::().into_bool_u8(), + DType::Bool(BoolStore::U32) => self.convert_inplace::().into_bool_u32(), + DType::Bool(BoolStore::Native) | DType::QFloat(_) => unreachable!(), + } + } + + fn convert_inplace( + mut self, + ) -> Self { + for x in bytemuck::cast_slice_mut::<_, Current>(&mut self.bytes) { + let t: Target = x.elem(); + let x = cast_mut::<_, Target>(x); + *x = t; + } + + self.dtype = Target::dtype(); + + self + } + + fn convert_clone_dtype(self, dtype: DType) -> Self { + match dtype { + DType::F64 => self.convert_clone::(), + DType::F32 | DType::Flex32 => self.convert_clone::(), + DType::F16 => self.convert_clone::(), + DType::BF16 => self.convert_clone::(), + DType::I64 => self.convert_clone::(), + DType::I32 => self.convert_clone::(), + DType::I16 => self.convert_clone::(), + DType::I8 => self.convert_clone::(), + DType::U64 => self.convert_clone::(), + DType::U32 => self.convert_clone::(), + DType::U16 => self.convert_clone::(), + DType::U8 => self.convert_clone::(), + DType::Bool(BoolStore::Native) => self.convert_clone::(), + DType::Bool(BoolStore::U8) => self.convert_clone::().into_bool_u8(), + DType::Bool(BoolStore::U32) => self.convert_clone::().into_bool_u32(), + DType::QFloat(_) => unreachable!(), + } + } + + fn convert_clone( + self, + ) -> Self { + let this = bytemuck::checked::cast_slice::<_, Current>(&self.bytes); + let mut out: Vec = ::alloc::vec![Zeroable::zeroed(); self.num_elements()]; + + for (x, out) in this.iter().zip(&mut out) { + *out = x.elem(); + } + + Self::new(out, self.shape) + } + + /// Returns the data as a slice of bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the bytes representation of the data. + pub fn into_bytes(self) -> Bytes { + self.bytes + } +} + +impl From<[E; A]> for TensorData { + fn from(elems: [E; A]) -> Self { + TensorData::new(elems.to_vec(), [A]) + } +} + +impl From<[usize; A]> for TensorData { + fn from(elems: [usize; A]) -> Self { + TensorData::new(elems.iter().map(|&e| e as i64).collect(), [A]) + } +} + +impl From<&[usize]> for TensorData { + fn from(elems: &[usize]) -> Self { + let mut data = Vec::with_capacity(elems.len()); + for elem in elems.iter() { + data.push(*elem as i64); + } + + TensorData::new(data, [elems.len()]) + } +} + +impl From<&[E]> for TensorData { + fn from(elems: &[E]) -> Self { + let mut data = Vec::with_capacity(elems.len()); + for elem in elems.iter() { + data.push(*elem); + } + + TensorData::new(data, [elems.len()]) + } +} + +impl From<[[E; B]; A]> for TensorData { + fn from(elems: [[E; B]; A]) -> Self { + let mut data = Vec::with_capacity(A * B); + for elem in elems.into_iter().take(A) { + for elem in elem.into_iter().take(B) { + data.push(elem); + } + } + + TensorData::new(data, [A, B]) + } +} + +impl From<[[[E; C]; B]; A]> + for TensorData +{ + fn from(elems: [[[E; C]; B]; A]) -> Self { + let mut data = Vec::with_capacity(A * B * C); + + for elem in elems.into_iter().take(A) { + for elem in elem.into_iter().take(B) { + for elem in elem.into_iter().take(C) { + data.push(elem); + } + } + } + + TensorData::new(data, [A, B, C]) + } +} + +impl + From<[[[[E; D]; C]; B]; A]> for TensorData +{ + fn from(elems: [[[[E; D]; C]; B]; A]) -> Self { + let mut data = Vec::with_capacity(A * B * C * D); + + for elem in elems.into_iter().take(A) { + for elem in elem.into_iter().take(B) { + for elem in elem.into_iter().take(C) { + for elem in elem.into_iter().take(D) { + data.push(elem); + } + } + } + } + + TensorData::new(data, [A, B, C, D]) + } +} + +impl + From<[[[[[Elem; E]; D]; C]; B]; A]> for TensorData +{ + fn from(elems: [[[[[Elem; E]; D]; C]; B]; A]) -> Self { + let mut data = Vec::with_capacity(A * B * C * D * E); + + for elem in elems.into_iter().take(A) { + for elem in elem.into_iter().take(B) { + for elem in elem.into_iter().take(C) { + for elem in elem.into_iter().take(D) { + for elem in elem.into_iter().take(E) { + data.push(elem); + } + } + } + } + } + + TensorData::new(data, [A, B, C, D, E]) + } +} +impl core::fmt::Display for TensorData { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let fmt = match self.dtype { + DType::F64 => format!("{:?}", self.as_slice::().unwrap()), + DType::F32 | DType::Flex32 => format!("{:?}", self.as_slice::().unwrap()), + DType::F16 => format!("{:?}", self.as_slice::().unwrap()), + DType::BF16 => format!("{:?}", self.as_slice::().unwrap()), + DType::I64 => format!("{:?}", self.as_slice::().unwrap()), + DType::I32 => format!("{:?}", self.as_slice::().unwrap()), + DType::I16 => format!("{:?}", self.as_slice::().unwrap()), + DType::I8 => format!("{:?}", self.as_slice::().unwrap()), + DType::U64 => format!("{:?}", self.as_slice::().unwrap()), + DType::U32 => format!("{:?}", self.as_slice::().unwrap()), + DType::U16 => format!("{:?}", self.as_slice::().unwrap()), + DType::U8 => format!("{:?}", self.as_slice::().unwrap()), + DType::Bool(BoolStore::Native) => format!("{:?}", self.as_slice::().unwrap()), + DType::Bool(BoolStore::U8) => format!("{:?}", self.as_slice::().unwrap()), + DType::Bool(BoolStore::U32) => format!("{:?}", self.as_slice::().unwrap()), + DType::QFloat(scheme) => match scheme { + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: + QuantValue::Q8F + | QuantValue::Q8S + // Display sub-byte values as i8 + | QuantValue::Q4F + | QuantValue::Q4S + | QuantValue::Q2F + | QuantValue::Q2S, + .. + } => { + format!("{:?} {scheme:?}", self.iter::().collect::>()) + }, + QuantScheme { + level: QuantLevel::Tensor | QuantLevel::Block(_), + mode: QuantMode::Symmetric, + value: + QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1, + .. + } => { + unimplemented!("Can't format yet"); + } + }, + }; + f.write_str(fmt.as_str()) + } +} + +/// The things that can go wrong when manipulating tensor data. +#[derive(Debug, Error)] +pub enum DataError { + /// Failed to cast the values to a specified element type. + #[error("Failed to cast values to the specified element type.\nError:\n {0}")] + CastError(CheckedCastError), + /// Invalid target element type. + #[error("{0}")] + TypeMismatch(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shape; + use alloc::vec; + use rand::{ + SeedableRng, + rngs::{StdRng, SysRng}, + }; + + #[test] + fn should_have_rank() { + let shape = [3, 5, 6]; + let data = TensorData::random::( + shape, + Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + + assert_eq!(data.rank(), 3); + } + + #[test] + fn into_vec_should_yield_same_value_as_iter() { + let shape = [3, 5, 6]; + let data = TensorData::random::( + shape, + Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + + let expected = data.iter::().collect::>(); + let actual = data.into_vec::().unwrap(); + + assert_eq!(expected, actual); + } + + #[test] + #[should_panic] + fn into_vec_should_assert_wrong_dtype() { + let shape = [3, 5, 6]; + let data = TensorData::random::( + shape, + Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + + data.into_vec::().unwrap(); + } + + #[test] + fn should_have_right_num_elements() { + let shape = [3, 5, 6]; + let num_elements: usize = shape.iter().product(); + let data = TensorData::random::( + shape, + Distribution::Default, + &mut StdRng::try_from_rng(&mut SysRng).unwrap(), + ); + + assert_eq!(num_elements, data.bytes.len() / 4); // f32 stored as u8s + assert_eq!(num_elements, data.as_slice::().unwrap().len()); + } + + #[test] + fn should_have_right_shape() { + let data = TensorData::from([[3.0, 5.0, 6.0]]); + assert_eq!(data.shape, shape![1, 3]); + + let data = TensorData::from([[4.0, 5.0, 8.0], [3.0, 5.0, 6.0]]); + assert_eq!(data.shape, shape![2, 3]); + + let data = TensorData::from([3.0, 5.0, 6.0]); + assert_eq!(data.shape, shape![3]); + } + + #[test] + fn should_convert_bytes_correctly() { + let mut vector: Vec = Vec::with_capacity(5); + vector.push(2.0); + vector.push(3.0); + let data1 = TensorData::new(vector, vec![2]); + + let factor = core::mem::size_of::() / core::mem::size_of::(); + assert_eq!(data1.bytes.len(), 2 * factor); + assert_eq!(data1.bytes.capacity(), 5 * factor); + } + + #[test] + fn should_convert_bytes_correctly_inplace() { + fn test_precision() { + let data = TensorData::new((0..32).collect(), [32]); + for (i, val) in data + .clone() + .convert::() + .into_vec::() + .unwrap() + .into_iter() + .enumerate() + { + assert_eq!(i as u32, val.elem::()) + } + } + test_precision::(); + test_precision::(); + test_precision::(); + test_precision::(); + } + + macro_rules! test_dtypes { + ($test_name:ident, $($dtype:ty),*) => { + $( + paste::paste! { + #[test] + fn [<$test_name _ $dtype:snake>]() { + let full_dtype = TensorData::full_dtype([2, 16], 4, <$dtype>::dtype()); + let full = TensorData::full::<$dtype, _>([2, 16], 4.elem()); + assert_eq!(full_dtype, full); + } + } + )* + }; +} + + test_dtypes!( + should_create_with_dtype, + bool, + i8, + i16, + i32, + i64, + u8, + u16, + u32, + u64, + f16, + bf16, + f32, + f64 + ); + + #[test] + fn should_serialize_deserialize_tensor_data() { + let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]); + assert_eq!( + data.as_bytes(), + [ + 0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 64, 64, 0, 0, 128, 64, 0, 0, 160, 64, 0, 0, 192, + 64 + ] + ); + let serialized = serde_json::to_string(&data).unwrap(); + let deserialized: TensorData = serde_json::from_str(&serialized).unwrap(); + assert_eq!(data, deserialized); + } + + #[test] + fn should_deserialize_tensor_data_with_shape_inner() { + // TensorData `shape` was previously a Vec. + let serialized = r#"{ + "bytes": [0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 64, 64, 0, 0, 128, 64, 0, 0, 160, 64, 0, 0, 192, 64], + "shape": [2, 3], + "dtype": "F32" + }"#; + + let data: TensorData = serde_json::from_str(serialized).unwrap(); + assert_eq!(data.shape, shape![2, 3]); + assert_eq!( + data.as_slice::().unwrap(), + &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + ); + } + + #[test] + fn should_serialize_shape_as_flat_array() { + // Ensure the new Shape serializes identically to how Vec used to, + // i.e. as a flat JSON array, not as an object like `{"dims": [2, 3]}`. + let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]); + let serialized = serde_json::to_string(&data).unwrap(); + let json: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(json["shape"], serde_json::json!([2, 3])); + } +} diff --git a/crates/burn-std/src/device_settings.rs b/crates/burn-std/src/device_settings.rs new file mode 100644 index 0000000..0047b62 --- /dev/null +++ b/crates/burn-std/src/device_settings.rs @@ -0,0 +1,125 @@ +//! Device data types: settings and errors. + +use alloc::format; +use alloc::string::String; +use cubecl_common::backtrace::BackTrace; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{BoolDType, DType, FloatDType, IntDType, QuantConfig}; + +/// Settings controlling the default data types for a specific device. +/// +/// These settings are managed in a global registry that enforces strict initialization semantics: +/// +/// 1. Manual Initialization: You can set these once at the start of your program using `set_default_dtypes`. +/// 2. Default Initialization: If an operation (like creating a tensor) occurs before manual initialization, +/// the settings are permanently locked to their default values. +/// 3. Immutability: Once initialized, settings cannot be changed. This ensures consistent behavior across +/// all threads and operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceSettings { + /// Default floating-point data type. + pub float_dtype: FloatDType, + /// Default integer data type. + pub int_dtype: IntDType, + /// Default bool data type. + pub bool_dtype: BoolDType, + /// Quantization configuration. + pub quantization: QuantConfig, +} + +impl DeviceSettings { + /// Creates a new [`DeviceSettings`] from any types convertible into the dtype kinds and the [quantization config](QuantConfig). + pub fn new( + float_dtype: impl Into, + int_dtype: impl Into, + bool_dtype: impl Into, + quantization: QuantConfig, + ) -> Self { + Self { + float_dtype: float_dtype.into(), + int_dtype: int_dtype.into(), + bool_dtype: bool_dtype.into(), + quantization, + } + } + + /// Creates a new [`DeviceSettings`] from any types convertible into the dtype kinds. + pub fn with_dtypes( + float_dtype: impl Into, + int_dtype: impl Into, + bool_dtype: impl Into, + ) -> Self { + Self::new(float_dtype, int_dtype, bool_dtype, Default::default()) + } +} + +/// Errors returned by device-related operations. +/// +/// Examples include attempting to use an unsupported data type on a device or initialization +/// errors like attempting to change a settings in an invalid context. +#[derive(Debug, Error)] +pub enum DeviceError { + /// Unsupported data type by the device. + #[error("Device {device} does not support the requested data type {dtype:?}")] + UnsupportedDType { + /// The string representation of the device. + device: String, + /// The data type that caused the error. + dtype: DType, + }, + /// Device settings have already been initialized. + #[error("Device {device} settings have already been initialized")] + AlreadyInitialized { + /// The string representation of the device. + device: String, + }, +} + +impl DeviceError { + /// Helper to create a [`DeviceError::UnsupportedDType`] from any device. + pub fn unsupported_dtype(device: &D, dtype: DType) -> Self { + Self::UnsupportedDType { + device: format!("{device:?}"), + dtype, + } + } + + /// Helper to create a [`DeviceError::AlreadyInitialized`] from any device. + pub fn already_initialized(device: &D) -> Self { + Self::AlreadyInitialized { + device: format!("{device:?}"), + } + } +} + +/// An error that can happen when syncing a device. +#[derive(Error, Serialize, Deserialize)] +pub enum ExecutionError { + /// A generic error happened during execution. + /// + /// The backtrace and context information should be included in the reason string. + #[error("An error happened during execution\nCaused by:\n {reason}")] + WithContext { + /// The reason of the error. + reason: String, + }, + /// A generic error happened during execution thrown in the Burn project. + /// + /// The full context isn't captured by the string alone. + #[error("An error happened during execution\nCaused by:\n {reason}")] + Generic { + /// The reason of the error. + reason: String, + /// The backtrace. + #[serde(skip)] + backtrace: BackTrace, + }, +} + +impl core::fmt::Debug for ExecutionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("{self}")) + } +} diff --git a/crates/burn-std/src/distributed.rs b/crates/burn-std/src/distributed.rs new file mode 100644 index 0000000..2fb765e --- /dev/null +++ b/crates/burn-std/src/distributed.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +/// The different ways to execute the reduce operation. +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)] +pub enum ReduceOperation { + /// The sum of the values. + Sum, + /// The mean of the values. + Mean, +} + +/// Parameter struct for setting up and getting parameters for distributed operations. +#[derive(Clone, Debug)] +pub struct DistributedConfig { + /// How to execute the all_reduce operation. + pub all_reduce_op: ReduceOperation, +} diff --git a/crates/burn-std/src/distribution.rs b/crates/burn-std/src/distribution.rs new file mode 100644 index 0000000..d16ebc1 --- /dev/null +++ b/crates/burn-std/src/distribution.rs @@ -0,0 +1,125 @@ +//! Random value distributions used to initialize and populate tensor data. + +use rand::{Rng, RngExt, distr::StandardUniform}; + +use super::element::{Element, ElementConversion}; + +/// Distribution for random value of a tensor. +#[derive(Debug, Default, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Distribution { + /// Uniform distribution from 0 (inclusive) to 1 (exclusive). + #[default] + Default, + + /// Bernoulli distribution with the given probability. + Bernoulli(f64), + + /// Uniform distribution `[low, high)`. + Uniform(f64, f64), + + /// Normal distribution with the given mean and standard deviation. + Normal(f64, f64), +} + +/// Distribution sampler for random value of a tensor. +#[derive(new)] +pub struct DistributionSampler<'a, E, R> +where + StandardUniform: rand::distr::Distribution, + E: rand::distr::uniform::SampleUniform, + R: Rng, +{ + kind: DistributionSamplerKind, + rng: &'a mut R, +} + +/// Distribution sampler kind for random value of a tensor. +pub enum DistributionSamplerKind +where + StandardUniform: rand::distr::Distribution, + E: rand::distr::uniform::SampleUniform, +{ + /// Standard distribution. + Standard(rand::distr::StandardUniform), + + /// Uniform distribution. + Uniform(rand::distr::Uniform), + + /// Bernoulli distribution. + Bernoulli(rand::distr::Bernoulli), + + /// Normal distribution. + Normal(rand_distr::Normal), +} + +impl DistributionSampler<'_, E, R> +where + StandardUniform: rand::distr::Distribution, + E: rand::distr::uniform::SampleUniform, + E: Element, + R: Rng, +{ + /// Sames a random value from the distribution. + pub fn sample(&mut self) -> E { + match &self.kind { + DistributionSamplerKind::Standard(distribution) => self.rng.sample(distribution), + DistributionSamplerKind::Uniform(distribution) => self.rng.sample(distribution), + DistributionSamplerKind::Bernoulli(distribution) => { + if self.rng.sample(distribution) { + 1.elem() + } else { + 0.elem() + } + } + DistributionSamplerKind::Normal(distribution) => self.rng.sample(distribution).elem(), + } + } +} + +impl Distribution { + /// Creates a new distribution sampler. + /// + /// # Arguments + /// + /// * `rng` - The random number generator. + /// + /// # Returns + /// + /// The distribution sampler. + pub fn sampler(self, rng: &'_ mut R) -> DistributionSampler<'_, E, R> + where + R: Rng, + E: Element + rand::distr::uniform::SampleUniform, + StandardUniform: rand::distr::Distribution, + { + let kind = match self { + Distribution::Default => { + DistributionSamplerKind::Standard(rand::distr::StandardUniform {}) + } + Distribution::Uniform(low, high) => DistributionSamplerKind::Uniform( + rand::distr::Uniform::new(low.elem::(), high.elem::()).unwrap(), + ), + Distribution::Bernoulli(prob) => { + DistributionSamplerKind::Bernoulli(rand::distr::Bernoulli::new(prob).unwrap()) + } + Distribution::Normal(mean, std) => { + DistributionSamplerKind::Normal(rand_distr::Normal::new(mean, std).unwrap()) + } + }; + + DistributionSampler::new(kind, rng) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_distribution_default() { + let dist: Distribution = Default::default(); + + assert_eq!(dist, Distribution::Default); + assert_eq!(Distribution::default(), Distribution::Default); + } +} diff --git a/crates/burn-std/src/element/base.rs b/crates/burn-std/src/element/base.rs new file mode 100644 index 0000000..ee22ff9 --- /dev/null +++ b/crates/burn-std/src/element/base.rs @@ -0,0 +1,290 @@ +use core::cmp::Ordering; +use rand::Rng; + +use crate::distribution::Distribution; +use crate::{BoolStore, DType, bf16, f16, flex32}; + +use super::cast::ToElement; + +/// Core element trait for tensor values. +/// +/// This trait defines the minimal set of capabilities required for a type to be +/// stored and manipulated as a tensor element across all backends. +pub trait Element: + ToElement + + ElementRandom + + ElementConversion + + ElementEq + + bytemuck::CheckedBitPattern + + bytemuck::NoUninit + + bytemuck::Zeroable + + core::fmt::Debug + + core::fmt::Display + + Default + + Send + + Sync + + Copy + + 'static +{ + /// The dtype of the element. + fn dtype() -> DType; +} + +/// Ordered element trait for tensor values. +/// +/// This trait extends [`Element`] with ordering semantics, enabling comparison +/// and order-dependent operations in generic Rust implementations. +/// +/// Backends that implement these operations entirely at the device level do +/// not rely on this trait. It only constrains the scalar type for generic Rust code. +pub trait ElementOrdered: Element + ElementComparison + ElementLimits {} + +/// Element conversion trait for tensor. +pub trait ElementConversion { + /// Converts an element to another element. + /// + /// # Arguments + /// + /// * `elem` - The element to convert. + /// + /// # Returns + /// + /// The converted element. + fn from_elem(elem: E) -> Self; + + /// Converts and returns the converted element. + fn elem(self) -> E; +} + +/// Element trait for random value of a tensor. +pub trait ElementRandom { + /// Returns a random value for the given distribution. + /// + /// # Arguments + /// + /// * `distribution` - The distribution to sample from. + /// * `rng` - The random number generator. + /// + /// # Returns + /// + /// The random value. + fn random(distribution: Distribution, rng: &mut R) -> Self; +} + +/// Element trait for equality of a tensor. +pub trait ElementEq { + /// Returns whether `self` and `other` are equal. + fn eq(&self, other: &Self) -> bool; +} + +/// Element ordering trait. +pub trait ElementComparison { + /// Returns and [Ordering] between `self` and `other`. + fn cmp(&self, other: &Self) -> Ordering; +} + +/// Element limits trait. +pub trait ElementLimits { + /// The minimum representable value + const MIN: Self; + /// The maximum representable value + const MAX: Self; +} + +/// Macro to implement the element trait for a type. +#[macro_export] +macro_rules! make_element { + ( + ty $type:ident, + convert $convert:expr, + random $random:expr, + cmp $cmp:expr, + dtype $dtype:expr + ) => { + make_element!(ty $type, convert $convert, random $random, cmp $cmp, dtype $dtype, min $type::MIN, max $type::MAX); + }; + ( + ty $type:ident, + convert $convert:expr, + random $random:expr, + cmp $cmp:expr, + dtype $dtype:expr, + min $min:expr, + max $max:expr + ) => { + impl Element for $type { + #[inline(always)] + fn dtype() -> $crate::DType { + $dtype + } + } + impl ElementEq for $type { + fn eq(&self, other: &Self) -> bool { + self == other + } + } + + impl ElementConversion for $type { + #[inline(always)] + fn from_elem(elem: E) -> Self { + #[allow(clippy::redundant_closure_call)] + $convert(&elem) + } + #[inline(always)] + fn elem(self) -> E { + E::from_elem(self) + } + } + + impl ElementRandom for $type { + fn random(distribution: Distribution, rng: &mut R) -> Self { + #[allow(clippy::redundant_closure_call)] + $random(distribution, rng) + } + } + + impl ElementComparison for $type { + fn cmp(&self, other: &Self) -> Ordering { + let a = self.elem::<$type>(); + let b = other.elem::<$type>(); + #[allow(clippy::redundant_closure_call)] + $cmp(&a, &b) + } + } + + impl ElementLimits for $type { + const MIN: Self = $min; + const MAX: Self = $max; + } + + impl ElementOrdered for $type {} + + }; +} + +make_element!( + ty f64, + convert ToElement::to_f64, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &f64, b: &f64| a.total_cmp(b), + dtype DType::F64 +); + +make_element!( + ty f32, + convert ToElement::to_f32, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &f32, b: &f32| a.total_cmp(b), + dtype DType::F32 +); + +make_element!( + ty i64, + convert ToElement::to_i64, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &i64, b: &i64| Ord::cmp(a, b), + dtype DType::I64 +); + +make_element!( + ty u64, + convert ToElement::to_u64, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &u64, b: &u64| Ord::cmp(a, b), + dtype DType::U64 +); + +make_element!( + ty i32, + convert ToElement::to_i32, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &i32, b: &i32| Ord::cmp(a, b), + dtype DType::I32 +); + +make_element!( + ty u32, + convert ToElement::to_u32, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &u32, b: &u32| Ord::cmp(a, b), + dtype DType::U32 +); + +make_element!( + ty i16, + convert ToElement::to_i16, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &i16, b: &i16| Ord::cmp(a, b), + dtype DType::I16 +); + +make_element!( + ty u16, + convert ToElement::to_u16, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &u16, b: &u16| Ord::cmp(a, b), + dtype DType::U16 +); + +make_element!( + ty i8, + convert ToElement::to_i8, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &i8, b: &i8| Ord::cmp(a, b), + dtype DType::I8 +); + +make_element!( + ty u8, + convert ToElement::to_u8, + random |distribution: Distribution, rng: &mut R| distribution.sampler(rng).sample(), + cmp |a: &u8, b: &u8| Ord::cmp(a, b), + dtype DType::U8 +); + +make_element!( + ty f16, + convert ToElement::to_f16, + random |distribution: Distribution, rng: &mut R| { + let sample: f32 = distribution.sampler(rng).sample(); + f16::from_elem(sample) + }, + cmp |a: &f16, b: &f16| a.total_cmp(b), + dtype DType::F16 +); +make_element!( + ty bf16, + convert ToElement::to_bf16, + random |distribution: Distribution, rng: &mut R| { + let sample: f32 = distribution.sampler(rng).sample(); + bf16::from_elem(sample) + }, + cmp |a: &bf16, b: &bf16| a.total_cmp(b), + dtype DType::BF16 +); + +make_element!( + ty flex32, + convert |elem: &dyn ToElement| flex32::from_f32(elem.to_f32()), + random |distribution: Distribution, rng: &mut R| { + let sample: f32 = distribution.sampler(rng).sample(); + flex32::from_elem(sample) + }, + cmp |a: &flex32, b: &flex32| a.total_cmp(b), + dtype DType::Flex32, + min flex32::from_f32(f16::MIN.to_f32_const()), + max flex32::from_f32(f16::MAX.to_f32_const()) +); + +make_element!( + ty bool, + convert ToElement::to_bool, + random |distribution: Distribution, rng: &mut R| { + let sample: u8 = distribution.sampler(rng).sample(); + bool::from_elem(sample) + }, + cmp |a: &bool, b: &bool| Ord::cmp(a, b), + dtype DType::Bool(BoolStore::Native), + min false, + max true +); diff --git a/crates/burn-std/src/element/cast.rs b/crates/burn-std/src/element/cast.rs new file mode 100644 index 0000000..792adeb --- /dev/null +++ b/crates/burn-std/src/element/cast.rs @@ -0,0 +1,705 @@ +use core::mem::size_of; + +use crate::{bf16, f16}; + +/// A generic trait for converting a value to a number. +/// Adapted from num_traits::ToPrimitive to support [bool]. +/// +/// A value can be represented by the target type when it lies within +/// the range of scalars supported by the target type. +/// For example, a negative integer cannot be represented by an unsigned +/// integer type, and an `i64` with a very high magnitude might not be +/// convertible to an `i32`. +/// On the other hand, conversions with possible precision loss or truncation +/// are admitted, like an `f32` with a decimal part to an integer type, or +/// even a large `f64` saturating to `f32` infinity. +/// +/// The methods *panic* when the value cannot be represented by the target type. +pub trait ToElement { + /// Converts the value of `self` to an `isize`. + #[inline] + fn to_isize(&self) -> isize { + ToElement::to_isize(&self.to_i64()) + } + + /// Converts the value of `self` to an `i8`. + #[inline] + fn to_i8(&self) -> i8 { + ToElement::to_i8(&self.to_i64()) + } + + /// Converts the value of `self` to an `i16`. + #[inline] + fn to_i16(&self) -> i16 { + ToElement::to_i16(&self.to_i64()) + } + + /// Converts the value of `self` to an `i32`. + #[inline] + fn to_i32(&self) -> i32 { + ToElement::to_i32(&self.to_i64()) + } + + /// Converts the value of `self` to an `i64`. + fn to_i64(&self) -> i64; + + /// Converts the value of `self` to an `i128`. + /// + /// The default implementation converts through `to_i64()`. Types implementing + /// this trait should override this method if they can represent a greater range. + #[inline] + fn to_i128(&self) -> i128 { + i128::from(self.to_i64()) + } + + /// Converts the value of `self` to a `usize`. + #[inline] + fn to_usize(&self) -> usize { + ToElement::to_usize(&self.to_u64()) + } + + /// Converts the value of `self` to a `u8`. + #[inline] + fn to_u8(&self) -> u8 { + ToElement::to_u8(&self.to_u64()) + } + + /// Converts the value of `self` to a `u16`. + #[inline] + fn to_u16(&self) -> u16 { + ToElement::to_u16(&self.to_u64()) + } + + /// Converts the value of `self` to a `u32`. + #[inline] + fn to_u32(&self) -> u32 { + ToElement::to_u32(&self.to_u64()) + } + + /// Converts the value of `self` to a `u64`. + fn to_u64(&self) -> u64; + + /// Converts the value of `self` to a `u128`. + /// + /// The default implementation converts through `to_u64()`. Types implementing + /// this trait should override this method if they can represent a greater range. + #[inline] + fn to_u128(&self) -> u128 { + u128::from(self.to_u64()) + } + + /// Converts the value of `self` to an `f16`. Overflows may map to positive + /// or negative infinity. + #[inline] + fn to_f16(&self) -> f16 { + f16::from_f32(self.to_f32()) + } + + /// Converts the value of `self` to an `bf16`. Overflows may map to positive + /// or negative infinity. + #[inline] + fn to_bf16(&self) -> bf16 { + bf16::from_f32(self.to_f32()) + } + + /// Converts the value of `self` to an `f32`. Overflows may map to positive + /// or negative infinity. + #[inline] + fn to_f32(&self) -> f32 { + ToElement::to_f32(&self.to_f64()) + } + + /// Converts the value of `self` to an `f64`. Overflows may map to positive + /// or negative infinity. + /// + /// The default implementation tries to convert through `to_i64()`, and + /// failing that through `to_u64()`. Types implementing this trait should + /// override this method if they can represent a greater range. + #[inline] + fn to_f64(&self) -> f64 { + ToElement::to_f64(&self.to_u64()) + } + + /// Converts the value of `self` to a bool. + /// Rust only considers 0 and 1 to be valid booleans, but for compatibility, C semantics are + /// adopted (anything that's not 0 is true). + /// + /// The default implementation tries to convert through `to_i64()`, and + /// failing that through `to_u64()`. Types implementing this trait should + /// override this method if they can represent a greater range. + #[inline] + fn to_bool(&self) -> bool { + ToElement::to_bool(&self.to_u64()) + } +} + +macro_rules! impl_to_element_int_to_int { + ($SrcT:ident : $( $(#[$cfg:meta])* fn $method:ident -> $DstT:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $DstT { + let min = $DstT::MIN as $SrcT; + let max = $DstT::MAX as $SrcT; + if size_of::<$SrcT>() <= size_of::<$DstT>() || (min <= *self && *self <= max) { + *self as $DstT + } else { + panic!( + "Element cannot be represented in the target type: {:?}({:?}) => {:?}", + core::any::type_name::<$SrcT>(), + self, + core::any::type_name::<$DstT>(), + ) + } + } + )*} +} + +macro_rules! impl_to_element_int_to_uint { + ($SrcT:ident : $( $(#[$cfg:meta])* fn $method:ident -> $DstT:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $DstT { + let max = $DstT::MAX as $SrcT; + if 0 <= *self && (size_of::<$SrcT>() <= size_of::<$DstT>() || *self <= max) { + *self as $DstT + } else { + panic!( + "Element cannot be represented in the target type: {:?}({:?}) => {:?}", + core::any::type_name::<$SrcT>(), + self, + core::any::type_name::<$DstT>(), + ) + } + } + )*} +} + +macro_rules! impl_to_element_int { + ($T:ident) => { + impl ToElement for $T { + impl_to_element_int_to_int! { $T: + fn to_isize -> isize; + fn to_i8 -> i8; + fn to_i16 -> i16; + fn to_i32 -> i32; + fn to_i64 -> i64; + fn to_i128 -> i128; + } + + impl_to_element_int_to_uint! { $T: + fn to_usize -> usize; + fn to_u8 -> u8; + fn to_u16 -> u16; + fn to_u32 -> u32; + fn to_u64 -> u64; + fn to_u128 -> u128; + } + + #[inline] + fn to_f32(&self) -> f32 { + *self as f32 + } + #[inline] + fn to_f64(&self) -> f64 { + *self as f64 + } + #[inline] + fn to_bool(&self) -> bool { + *self != 0 + } + } + }; +} + +impl_to_element_int!(isize); +impl_to_element_int!(i8); +impl_to_element_int!(i16); +impl_to_element_int!(i32); +impl_to_element_int!(i64); +impl_to_element_int!(i128); + +macro_rules! impl_to_element_uint_to_int { + ($SrcT:ident : $( $(#[$cfg:meta])* fn $method:ident -> $DstT:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $DstT { + let max = $DstT::MAX as $SrcT; + if size_of::<$SrcT>() < size_of::<$DstT>() || *self <= max { + *self as $DstT + } else { + panic!( + "Element cannot be represented in the target type: {:?}({:?}) => {:?}", + core::any::type_name::<$SrcT>(), + self, + core::any::type_name::<$DstT>(), + ) + } + } + )*} +} + +macro_rules! impl_to_element_uint_to_uint { + ($SrcT:ident : $( $(#[$cfg:meta])* fn $method:ident -> $DstT:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $DstT { + let max = $DstT::MAX as $SrcT; + if size_of::<$SrcT>() <= size_of::<$DstT>() || *self <= max { + *self as $DstT + } else { + panic!( + "Element cannot be represented in the target type: {:?}({:?}) => {:?}", + core::any::type_name::<$SrcT>(), + self, + core::any::type_name::<$DstT>(), + ) + } + } + )*} +} + +macro_rules! impl_to_element_uint { + ($T:ident) => { + impl ToElement for $T { + impl_to_element_uint_to_int! { $T: + fn to_isize -> isize; + fn to_i8 -> i8; + fn to_i16 -> i16; + fn to_i32 -> i32; + fn to_i64 -> i64; + fn to_i128 -> i128; + } + + impl_to_element_uint_to_uint! { $T: + fn to_usize -> usize; + fn to_u8 -> u8; + fn to_u16 -> u16; + fn to_u32 -> u32; + fn to_u64 -> u64; + fn to_u128 -> u128; + } + + #[inline] + fn to_f32(&self) -> f32 { + *self as f32 + } + #[inline] + fn to_f64(&self) -> f64 { + *self as f64 + } + #[inline] + fn to_bool(&self) -> bool { + *self != 0 + } + } + }; +} + +impl_to_element_uint!(usize); +impl_to_element_uint!(u8); +impl_to_element_uint!(u16); +impl_to_element_uint!(u32); +impl_to_element_uint!(u64); +impl_to_element_uint!(u128); + +macro_rules! impl_to_element_float_to_float { + ($SrcT:ident : $( fn $method:ident -> $DstT:ident ; )*) => {$( + #[inline] + fn $method(&self) -> $DstT { + // We can safely cast all values, whether NaN, +-inf, or finite. + // Finite values that are reducing size may saturate to +-inf. + *self as $DstT + } + )*} +} + +macro_rules! float_to_int_unchecked { + // SAFETY: Must not be NaN or infinite; must be representable as the integer after truncating. + // We already checked that the float is in the exclusive range `(MIN-1, MAX+1)`. + ($float:expr => $int:ty) => { + unsafe { $float.to_int_unchecked::<$int>() } + }; +} + +macro_rules! impl_to_element_float_to_signed_int { + ($f:ident : $( $(#[$cfg:meta])* fn $method:ident -> $i:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $i { + // Float as int truncates toward zero, so we want to allow values + // in the exclusive range `(MIN-1, MAX+1)`. + if size_of::<$f>() > size_of::<$i>() { + // With a larger size, we can represent the range exactly. + const MIN_M1: $f = $i::MIN as $f - 1.0; + const MAX_P1: $f = $i::MAX as $f + 1.0; + if *self > MIN_M1 && *self < MAX_P1 { + return float_to_int_unchecked!(*self => $i); + } + } else { + // We can't represent `MIN-1` exactly, but there's no fractional part + // at this magnitude, so we can just use a `MIN` inclusive boundary. + const MIN: $f = $i::MIN as $f; + // We can't represent `MAX` exactly, but it will round up to exactly + // `MAX+1` (a power of two) when we cast it. + const MAX_P1: $f = $i::MAX as $f; + if *self >= MIN && *self < MAX_P1 { + return float_to_int_unchecked!(*self => $i); + } + } + panic!("Float cannot be represented in the target signed int type") + } + )*} +} + +macro_rules! impl_to_element_float_to_unsigned_int { + ($f:ident : $( $(#[$cfg:meta])* fn $method:ident -> $u:ident ; )*) => {$( + #[inline] + $(#[$cfg])* + fn $method(&self) -> $u { + // Float as int truncates toward zero, so we want to allow values + // in the exclusive range `(-1, MAX+1)`. + if size_of::<$f>() > size_of::<$u>() { + // With a larger size, we can represent the range exactly. + const MAX_P1: $f = $u::MAX as $f + 1.0; + if *self > -1.0 && *self < MAX_P1 { + return float_to_int_unchecked!(*self => $u); + } + } else { + // We can't represent `MAX` exactly, but it will round up to exactly + // `MAX+1` (a power of two) when we cast it. + // (`u128::MAX as f32` is infinity, but this is still ok.) + const MAX_P1: $f = $u::MAX as $f; + if *self > -1.0 && *self < MAX_P1 { + return float_to_int_unchecked!(*self => $u); + } + } + panic!("Float cannot be represented in the target unsigned int type") + } + )*} +} + +macro_rules! impl_to_element_float { + ($T:ident) => { + impl ToElement for $T { + impl_to_element_float_to_signed_int! { $T: + fn to_isize -> isize; + fn to_i8 -> i8; + fn to_i16 -> i16; + fn to_i32 -> i32; + fn to_i64 -> i64; + fn to_i128 -> i128; + } + + impl_to_element_float_to_unsigned_int! { $T: + fn to_usize -> usize; + fn to_u8 -> u8; + fn to_u16 -> u16; + fn to_u32 -> u32; + fn to_u64 -> u64; + fn to_u128 -> u128; + } + + impl_to_element_float_to_float! { $T: + fn to_f32 -> f32; + fn to_f64 -> f64; + } + + #[inline] + fn to_bool(&self) -> bool { + *self != 0.0 + } + } + }; +} + +impl_to_element_float!(f32); +impl_to_element_float!(f64); + +impl ToElement for f16 { + #[inline] + fn to_i64(&self) -> i64 { + Self::to_f32(*self).to_i64() + } + #[inline] + fn to_u64(&self) -> u64 { + Self::to_f32(*self).to_u64() + } + #[inline] + fn to_i8(&self) -> i8 { + Self::to_f32(*self).to_i8() + } + #[inline] + fn to_u8(&self) -> u8 { + Self::to_f32(*self).to_u8() + } + #[inline] + fn to_i16(&self) -> i16 { + Self::to_f32(*self).to_i16() + } + #[inline] + fn to_u16(&self) -> u16 { + Self::to_f32(*self).to_u16() + } + #[inline] + fn to_i32(&self) -> i32 { + Self::to_f32(*self).to_i32() + } + #[inline] + fn to_u32(&self) -> u32 { + Self::to_f32(*self).to_u32() + } + #[inline] + fn to_f16(&self) -> f16 { + *self + } + #[inline] + fn to_f32(&self) -> f32 { + Self::to_f32(*self) + } + #[inline] + fn to_f64(&self) -> f64 { + Self::to_f64(*self) + } + #[inline] + fn to_bool(&self) -> bool { + *self != f16::from_f32_const(0.0) + } +} + +impl ToElement for bf16 { + #[inline] + fn to_i64(&self) -> i64 { + Self::to_f32(*self).to_i64() + } + #[inline] + fn to_u64(&self) -> u64 { + Self::to_f32(*self).to_u64() + } + #[inline] + fn to_i8(&self) -> i8 { + Self::to_f32(*self).to_i8() + } + #[inline] + fn to_u8(&self) -> u8 { + Self::to_f32(*self).to_u8() + } + #[inline] + fn to_i16(&self) -> i16 { + Self::to_f32(*self).to_i16() + } + #[inline] + fn to_u16(&self) -> u16 { + Self::to_f32(*self).to_u16() + } + #[inline] + fn to_i32(&self) -> i32 { + Self::to_f32(*self).to_i32() + } + #[inline] + fn to_u32(&self) -> u32 { + Self::to_f32(*self).to_u32() + } + #[inline] + fn to_bf16(&self) -> bf16 { + *self + } + #[inline] + fn to_f32(&self) -> f32 { + Self::to_f32(*self) + } + #[inline] + fn to_f64(&self) -> f64 { + Self::to_f64(*self) + } + #[inline] + fn to_bool(&self) -> bool { + *self != bf16::from_f32_const(0.0) + } +} + +impl ToElement for crate::flex32 { + #[inline] + fn to_i64(&self) -> i64 { + Self::to_f32(*self).to_i64() + } + #[inline] + fn to_u64(&self) -> u64 { + Self::to_f32(*self).to_u64() + } + #[inline] + fn to_i8(&self) -> i8 { + Self::to_f32(*self).to_i8() + } + #[inline] + fn to_u8(&self) -> u8 { + Self::to_f32(*self).to_u8() + } + #[inline] + fn to_i16(&self) -> i16 { + Self::to_f32(*self).to_i16() + } + #[inline] + fn to_u16(&self) -> u16 { + Self::to_f32(*self).to_u16() + } + #[inline] + fn to_i32(&self) -> i32 { + Self::to_f32(*self).to_i32() + } + #[inline] + fn to_u32(&self) -> u32 { + Self::to_f32(*self).to_u32() + } + #[inline] + fn to_f32(&self) -> f32 { + Self::to_f32(*self) + } + #[inline] + fn to_f64(&self) -> f64 { + Self::to_f64(*self) + } + #[inline] + fn to_bool(&self) -> bool { + *self != crate::flex32::from_f32(0.0) + } +} + +impl ToElement for bool { + #[inline] + fn to_i64(&self) -> i64 { + *self as i64 + } + #[inline] + fn to_u64(&self) -> u64 { + *self as u64 + } + #[inline] + fn to_i8(&self) -> i8 { + *self as i8 + } + #[inline] + fn to_u8(&self) -> u8 { + *self as u8 + } + #[inline] + fn to_i16(&self) -> i16 { + *self as i16 + } + #[inline] + fn to_u16(&self) -> u16 { + *self as u16 + } + #[inline] + fn to_i32(&self) -> i32 { + *self as i32 + } + #[inline] + fn to_u32(&self) -> u32 { + *self as u32 + } + #[inline] + fn to_f32(&self) -> f32 { + self.to_u8() as f32 + } + #[inline] + fn to_f64(&self) -> f64 { + self.to_u8() as f64 + } + #[inline] + fn to_bool(&self) -> bool { + *self + } +} + +mod tests { + #[allow(unused_imports)] + use super::*; + + #[test] + fn to_element_float() { + let f32_toolarge = 1e39f64; + assert_eq!(f32_toolarge.to_f32(), f32::INFINITY); + assert_eq!((-f32_toolarge).to_f32(), f32::NEG_INFINITY); + assert_eq!((f32::MAX as f64).to_f32(), f32::MAX); + assert_eq!((-f32::MAX as f64).to_f32(), -f32::MAX); + assert_eq!(f64::INFINITY.to_f32(), f32::INFINITY); + assert_eq!((f64::NEG_INFINITY).to_f32(), f32::NEG_INFINITY); + assert!((f64::NAN).to_f32().is_nan()); + } + + #[test] + #[should_panic] + fn to_element_signed_to_u8_underflow() { + let _x = (-1i8).to_u8(); + } + + #[test] + #[should_panic] + fn to_element_signed_to_u16_underflow() { + let _x = (-1i8).to_u16(); + } + + #[test] + #[should_panic] + fn to_element_signed_to_u32_underflow() { + let _x = (-1i8).to_u32(); + } + + #[test] + #[should_panic] + fn to_element_signed_to_u64_underflow() { + let _x = (-1i8).to_u64(); + } + + #[test] + #[should_panic] + fn to_element_signed_to_u128_underflow() { + let _x = (-1i8).to_u128(); + } + + #[test] + #[should_panic] + fn to_element_signed_to_usize_underflow() { + let _x = (-1i8).to_usize(); + } + + #[test] + #[should_panic] + fn to_element_unsigned_to_u8_overflow() { + let _x = 256.to_u8(); + } + + #[test] + #[should_panic] + fn to_element_unsigned_to_u16_overflow() { + let _x = 65_536.to_u16(); + } + + #[test] + #[should_panic] + fn to_element_unsigned_to_u32_overflow() { + let _x = 4_294_967_296u64.to_u32(); + } + + #[test] + #[should_panic] + fn to_element_unsigned_to_u64_overflow() { + let _x = 18_446_744_073_709_551_616u128.to_u64(); + } + + #[test] + fn to_element_int_to_float() { + assert_eq!((-1).to_f32(), -1.0); + assert_eq!((-1).to_f64(), -1.0); + assert_eq!(255.to_f32(), 255.0); + assert_eq!(65_535.to_f64(), 65_535.0); + } + + #[test] + fn to_element_float_to_int() { + assert_eq!((-1.0).to_i8(), -1); + assert_eq!(1.0.to_u8(), 1); + assert_eq!(1.8.to_u16(), 1); + assert_eq!(123.456.to_u32(), 123); + } +} diff --git a/crates/burn-std/src/element/mod.rs b/crates/burn-std/src/element/mod.rs new file mode 100644 index 0000000..c1f7884 --- /dev/null +++ b/crates/burn-std/src/element/mod.rs @@ -0,0 +1,10 @@ +//! Traits and helpers for working with element types and conversions. + +mod base; +mod scalar; + +/// Tensor element casting. +pub mod cast; + +pub use base::*; +pub use scalar::*; diff --git a/crates/burn-std/src/element/scalar.rs b/crates/burn-std/src/element/scalar.rs new file mode 100644 index 0000000..709102d --- /dev/null +++ b/crates/burn-std/src/element/scalar.rs @@ -0,0 +1,111 @@ +use crate::{BoolStore, DType, bf16, f16}; +use num_traits::ToPrimitive; + +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +use num_traits::Float; + +use crate::{Element, ElementConversion}; + +/// A scalar element. +#[derive(Clone, Copy, Debug)] +#[allow(missing_docs)] +pub enum Scalar { + Float(f64), + Int(i64), + UInt(u64), + Bool(bool), +} + +impl Scalar { + /// Creates a scalar with the specified data type. + /// + /// # Note + /// [`QFloat`](DType::QFloat) scalars are represented as float for element-wise operations. + pub fn new(value: E, dtype: &DType) -> Self { + if dtype.is_float() | matches!(dtype, &DType::QFloat(_)) { + Self::Float(value.elem()) + } else if dtype.is_int() { + Self::Int(value.elem()) + } else if dtype.is_uint() { + Self::UInt(value.elem()) + } else if dtype.is_bool() { + match dtype { + DType::Bool(BoolStore::Native) => Self::Bool(value.elem()), + DType::Bool(BoolStore::U8) | DType::Bool(BoolStore::U32) => { + Self::UInt(value.elem()) + } + _ => unreachable!(), + } + } else { + unimplemented!("Scalar not supported for {dtype:?}") + } + } + + /// Converts and returns the converted element. + pub fn elem(self) -> E { + match self { + Self::Float(x) => x.elem(), + Self::Int(x) => x.elem(), + Self::UInt(x) => x.elem(), + Self::Bool(x) => x.elem(), + } + } + + /// Returns the exact integer value, if valid. + pub fn try_as_integer(&self) -> Option { + match self { + Scalar::Float(x) => (x.floor() == *x).then(|| Self::Int(x.to_i64().unwrap())), + Scalar::Int(_) | Scalar::UInt(_) => Some(*self), + Scalar::Bool(x) => Some(Scalar::Int(*x as i64)), + } + } +} + +macro_rules! impl_from_scalar { + ($($ty:ty => $variant:ident),+ $(,)?) => { + $( + impl From<$ty> for Scalar { + fn from(value: $ty) -> Self { + Scalar::$variant(value.elem()) + } + } + )+ + }; +} + +impl_from_scalar! { + f64 => Float, f32 => Float, f16 => Float, bf16 => Float, + i64 => Int, i32 => Int, i16 => Int, i8 => Int, + u64 => UInt, u32 => UInt, u16 => UInt, u8 => UInt, bool => Bool, +} + +// CubeCL requirement +impl ToPrimitive for Scalar { + fn to_i64(&self) -> Option { + match self { + Scalar::Float(x) => x.to_i64(), + Scalar::UInt(x) => x.to_i64(), + Scalar::Int(x) => Some(*x), + Scalar::Bool(x) => Some(*x as i64), + } + } + + fn to_u64(&self) -> Option { + match self { + Scalar::Float(x) => x.to_u64(), + Scalar::UInt(x) => Some(*x), + Scalar::Int(x) => x.to_u64(), + Scalar::Bool(x) => Some(*x as u64), + } + } + + fn to_f64(&self) -> Option { + match self { + Scalar::Float(x) => Some(*x), + Scalar::UInt(x) => x.to_f64(), + Scalar::Int(x) => x.to_f64(), + Scalar::Bool(x) => (*x as u8).to_f64(), + } + } +} diff --git a/crates/burn-std/src/id.rs b/crates/burn-std/src/id.rs new file mode 100644 index 0000000..4533792 --- /dev/null +++ b/crates/burn-std/src/id.rs @@ -0,0 +1,193 @@ +//! # Unique Identifiers +use crate::rand::gen_random; + +/// Simple ID generator. +pub struct IdGenerator {} + +impl IdGenerator { + /// Generates a new ID. + pub fn generate() -> u64 { + // Generate a random u64 (18,446,744,073,709,551,615 combinations) + let random_bytes: [u8; 8] = gen_random(); + u64::from_le_bytes(random_bytes) + } +} + +pub use cubecl_common::stream_id::StreamId; + +use core::hash::{BuildHasher, Hasher}; + +use alloc::str::FromStr; +use data_encoding::BASE32_DNSSEC; +use serde::{Deserialize, Serialize}; + +// Hashbrown changed its default hasher in 0.15, but there are some issues +// https://github.com/rust-lang/hashbrown/issues/577 +// Also, `param_serde_deserialize_legacy_uuid` doesn't pass with the default hasher. +type DefaultHashBuilder = core::hash::BuildHasherDefault; + +/// Unique ID for a parameter of a module. +#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ParamId { + value: u64, +} + +impl From for ParamId { + fn from(value: u64) -> Self { + Self { value } + } +} + +impl Default for ParamId { + fn default() -> Self { + Self::new() + } +} + +impl ParamId { + /// Create a new parameter ID. + pub fn new() -> Self { + Self { + value: IdGenerator::generate(), + } + } + + /// Gets the internal value of the id. + pub fn val(&self) -> u64 { + self.value + } +} + +impl FromStr for ParamId { + type Err = &'static str; // Or a custom error type if preferred + + /// Construct a param id from str. + /// + /// Preserves compatibility with previous formats (6 bytes, 16-byte uuid). + /// + /// # Returns + /// A `Result` containing the `ParamId` when valid. + fn from_str(encoded: &str) -> Result { + let u64_id: Option = match BASE32_DNSSEC.decode(encoded.as_bytes()) { + Ok(bytes) => { + let mut buffer = [0u8; 8]; + buffer[..bytes.len()].copy_from_slice(&bytes); + Some(u64::from_le_bytes(buffer)) + } + Err(_) => match uuid::Uuid::try_parse(encoded) { + // Backward compatibility with uuid parameter identifiers + Ok(id) => { + // Hash the 128-bit uuid to 64-bit + // Though not *theoretically* unique, the probability of a collision should be extremely low + let mut hasher = DefaultHashBuilder::default().build_hasher(); + // let mut hasher = DefaultHasher::new(); + hasher.write(id.as_bytes()); + Some(hasher.finish()) + } + Err(_) => None, + }, + }; + u64_id.map(Self::from).ok_or("Invalid id.") + } +} + +impl core::fmt::Display for ParamId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let encoded = BASE32_DNSSEC.encode(&self.value.to_le_bytes()); + f.write_str(&encoded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloc::collections::BTreeSet; + use alloc::string::ToString; + + #[cfg(feature = "std")] + use dashmap::DashSet; //Concurrent HashMap + #[cfg(feature = "std")] + use std::{sync::Arc, thread}; + + #[test] + fn uniqueness_test() { + const IDS_CNT: usize = 10_000; + + let mut set: BTreeSet = BTreeSet::new(); + + for _i in 0..IDS_CNT { + assert!(set.insert(IdGenerator::generate())); + } + + assert_eq!(set.len(), IDS_CNT); + } + + #[cfg(feature = "std")] + #[test] + fn thread_safety_test() { + const NUM_THREADS: usize = 10; + const NUM_REPEATS: usize = 1_000; + const EXPECTED_TOTAL_IDS: usize = NUM_THREADS * NUM_REPEATS; + + let set: Arc> = Arc::new(DashSet::new()); + + let mut handles = vec![]; + + for _ in 0..NUM_THREADS { + let set = set.clone(); + + let handle = thread::spawn(move || { + for _i in 0..NUM_REPEATS { + assert!(set.insert(IdGenerator::generate())); + } + }); + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + assert_eq!(set.len(), EXPECTED_TOTAL_IDS); + } + + #[test] + fn param_serde_try_deserialize() { + let val = ParamId::from(123456u64); + let deserialized = ParamId::from_str(&val.to_string()).unwrap(); + assert_eq!(val, deserialized); + + assert_eq!(ParamId::from_str("invalid_id"), Err("Invalid id.")); + } + + #[test] + fn param_serde_deserialize() { + let val = ParamId::from(123456u64); + let deserialized = ParamId::from_str(&val.to_string()).unwrap(); + assert_eq!(val, deserialized); + } + + #[test] + fn param_serde_deserialize_legacy() { + let legacy_val = [45u8; 6]; + let param_id = ParamId::from_str(&BASE32_DNSSEC.encode(&legacy_val)).unwrap(); + assert_eq!(param_id.val().to_le_bytes()[0..6], legacy_val); + assert_eq!(param_id.val().to_le_bytes()[6..], [0, 0]); + } + + #[test] + fn param_serde_deserialize_legacy_uuid() { + // Ensure support for legacy uuid deserialization and make sure it results in the same output + let legacy_id = "30b82c23-788d-4d63-a743-ada258d5f13c"; + let param_id1 = ParamId::from_str(legacy_id).unwrap(); + let param_id2 = ParamId::from_str(legacy_id).unwrap(); + assert_eq!(param_id1, param_id2); + } + + #[test] + #[should_panic = "Invalid id."] + fn param_serde_deserialize_invalid_id() { + let invalid_uuid = "30b82c23-788d-4d63-ada258d5f13c"; + let _ = ParamId::from_str(invalid_uuid).unwrap(); + } +} diff --git a/crates/burn-std/src/lib.rs b/crates/burn-std/src/lib.rs new file mode 100644 index 0000000..f05178f --- /dev/null +++ b/crates/burn-std/src/lib.rs @@ -0,0 +1,88 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! # Burn Standard Library +//! +//! This library contains core types and utilities shared across Burn, including shapes, indexing, +//! and data types. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; + +/// Id module contains types for unique identifiers. +pub mod id; + +/// Tensor utilities. +pub mod tensor; +pub use tensor::*; + +/// Tensor data representation and helpers. +pub mod data; +pub use data::*; + +/// Random value distributions. +pub mod distribution; +pub use distribution::*; + +/// Traits for tensor element types and conversions. +pub mod element; +pub use element::*; + +mod device_settings; +pub use device_settings::*; + +/// Runtime kind of the host program (async / sync / no-std). +pub mod runtime_kind; +pub use runtime_kind::*; + +/// Distributed configurations. +pub mod distributed; + +/// Configuration types for tensor operations (conv, pool, interpolate, pad, etc). +pub mod ops; +pub use ops::*; + +/// Burn runtime configurations. +pub mod config; + +/// Common Errors. +pub use cubecl_zspace::errors::{self, *}; + +/// Network utilities. +#[cfg(feature = "network")] +pub mod network; + +/// An ID unique to any unordered combination of devices, used by collective / +/// communication primitives (distributed training etc.). +/// +/// Mirrors `cubecl_runtime::server::CommunicationId` so that the +/// `burn_fusion::FusionUtilities::initialized_comms` set (and other consumers) +/// can be reused without depending on cubecl directly. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct CommunicationId { + /// Stable hash of the (sorted) set of device ids that participate. + pub id: u64, +} + +impl From> for CommunicationId { + fn from(mut value: alloc::vec::Vec) -> Self { + use core::hash::{Hash, Hasher}; + // Sort so any permutation of the same devices yields the same id. + value.sort(); + let mut hasher = ahash::AHasher::default(); + value.hash(&mut hasher); + CommunicationId { + id: hasher.finish(), + } + } +} + +pub use cubecl_common::bytes::*; +pub use cubecl_common::device_handle::DeviceHandle; +pub use cubecl_common::*; +pub use half::{bf16, f16}; + +pub use cubecl_common::flex32; diff --git a/crates/burn-std/src/network.rs b/crates/burn-std/src/network.rs new file mode 100644 index 0000000..621cc10 --- /dev/null +++ b/crates/burn-std/src/network.rs @@ -0,0 +1,57 @@ +//! # Common Network Utilities + +/// Network download utilities. +pub mod downloader { + use indicatif::{ProgressBar, ProgressState, ProgressStyle}; + use reqwest::Client; + use std::io::Write; + + /// Download the file at the specified url. + /// File download progress is reported with the help of a [progress bar](indicatif). + /// + /// # Arguments + /// + /// * `url` - The file URL to download. + /// * `message` - The message to display on the progress bar during download. + /// + /// # Returns + /// + /// A vector of bytes containing the downloaded file data. + #[tokio::main(flavor = "current_thread")] + pub async fn download_file_as_bytes(url: &str, message: &str) -> Vec { + // Get file from web + let mut response = Client::new().get(url).send().await.unwrap(); + let total_size = response.content_length().unwrap(); + + // Pretty progress bar + let pb = ProgressBar::new(total_size); + let msg = message.to_owned(); + pb.set_style( + ProgressStyle::with_template( + "{msg}\n {wide_bar:.cyan/blue} {bytes}/{total_bytes} ({eta})", + ) + .unwrap() + .with_key( + "eta", + |state: &ProgressState, w: &mut dyn std::fmt::Write| { + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + }, + ) + .progress_chars("▬ "), + ); + pb.set_message(msg.clone()); + + // Read stream into bytes + let mut downloaded: u64 = 0; + let mut bytes: Vec = Vec::with_capacity(total_size as usize); + while let Some(chunk) = response.chunk().await.unwrap() { + let num_bytes = bytes.write(&chunk).unwrap(); + let new = std::cmp::min(downloaded + (num_bytes as u64), total_size); + downloaded = new; + pb.set_position(new); + } + pb.finish_with_message(msg); + + bytes + } +} diff --git a/crates/burn-std/src/ops.rs b/crates/burn-std/src/ops.rs new file mode 100644 index 0000000..40d4a48 --- /dev/null +++ b/crates/burn-std/src/ops.rs @@ -0,0 +1,480 @@ +//! Configuration types for tensor operations. + +use crate::ElementConversion; +use core::num::NonZeroUsize; + +/// Check that the parameter value is non-zero. +// NOTE: for now we keep usize but we could refactor the parameters to hold `NonZeroUsize`. +pub(crate) fn check_nonzero(value: usize, msg: &str) -> usize { + NonZeroUsize::new(value).expect(msg); + value +} + +/// Convolution options. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct ConvOptions { + /// Stride (non-zero). + pub stride: [usize; N], + + /// Padding. + pub padding: [usize; N], + + /// Dilation (non-zero). + pub dilation: [usize; N], + + /// Groups (non-zero). + pub groups: usize, +} + +impl ConvOptions { + /// Constructs a new `ConvOptions`. + pub fn new( + stride: [usize; N], + padding: [usize; N], + dilation: [usize; N], + groups: usize, + ) -> Self { + Self { + stride: stride.map(|s| check_nonzero(s, "stride must be non-zero")), + padding, + dilation: dilation.map(|d| check_nonzero(d, "dilation must be non-zero")), + groups: check_nonzero(groups, "groups must be non-zero"), + } + } +} + +/// Convolution options with support for asymmetric padding. +/// +/// Wraps [`ConvOptions`] (which represents symmetric padding for the backend op) +/// and adds optional asymmetric padding. When asymmetric padding is specified, +/// the functional convolution layer applies an explicit pad operation before +/// dispatching to the backend. +/// +/// Implements `From>` for backward compatibility. +#[derive(Debug, Clone)] +pub struct PaddedConvOptions { + /// The underlying convolution options for the backend. + pub options: ConvOptions, + /// Padding at the end of each dimension (e.g., bottom/right for 2D). + /// If `None`, padding is symmetric (same as `options.padding`). + /// If `Some`, specifies different end-padding per dimension. + pub padding_end: Option<[usize; N]>, +} + +impl PaddedConvOptions { + /// Creates options with asymmetric padding. + /// + /// `padding_start` is stored in `ConvOptions::padding`. + /// `padding_end` specifies the end padding per dimension. + pub fn asymmetric( + stride: [usize; N], + padding_start: [usize; N], + padding_end: [usize; N], + dilation: [usize; N], + groups: usize, + ) -> Self { + let options = ConvOptions::new(stride, padding_start, dilation, groups); + if padding_start == padding_end { + Self { + options, + padding_end: None, + } + } else { + Self { + options, + padding_end: Some(padding_end), + } + } + } + + /// Returns true if padding is asymmetric. + pub fn is_asymmetric(&self) -> bool { + self.padding_end.is_some() + } +} + +impl From> for PaddedConvOptions { + fn from(options: ConvOptions) -> Self { + Self { + options, + padding_end: None, + } + } +} + +/// Deformable convolution options. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct DeformConvOptions { + /// Stride (non-zero). + pub stride: [usize; N], + + /// Padding. + pub padding: [usize; N], + + /// Dilation (non-zero). + pub dilation: [usize; N], + + /// Weight Groups (non-zero). + pub weight_groups: usize, + + /// Offset Groups (non-zero). + pub offset_groups: usize, +} + +impl DeformConvOptions { + /// Constructs a new `DeformConvOptions`. + pub fn new( + stride: [usize; N], + padding: [usize; N], + dilation: [usize; N], + weight_groups: usize, + offset_groups: usize, + ) -> Self { + Self { + stride: stride.map(|s| check_nonzero(s, "stride must be non-zero")), + padding, + dilation: dilation.map(|d| check_nonzero(d, "dilation must be non-zero")), + weight_groups: check_nonzero(weight_groups, "weight groups must be non-zero"), + offset_groups: check_nonzero(offset_groups, "offset groups must be non-zero"), + } + } +} + +/// Transposed convolution options. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct ConvTransposeOptions { + /// Stride (non-zero). + pub stride: [usize; N], + + /// Padding. + pub padding: [usize; N], + + /// Padding out. + pub padding_out: [usize; N], + + /// Dilation (non-zero). + pub dilation: [usize; N], + + /// Groups (non-zero). + pub groups: usize, +} + +impl ConvTransposeOptions { + /// Constructs a new `ConvTransposeOptions`. + pub fn new( + stride: [usize; N], + padding: [usize; N], + padding_out: [usize; N], + dilation: [usize; N], + groups: usize, + ) -> Self { + Self { + stride: stride.map(|s| check_nonzero(s, "stride must be non-zero")), + padding, + padding_out, + dilation: dilation.map(|d| check_nonzero(d, "dilation must be non-zero")), + groups: check_nonzero(groups, "groups must be non-zero"), + } + } +} + +/// Unfold operation options. +#[derive(Debug, Clone)] +pub struct UnfoldOptions { + /// The number of positions to slide over the input tensor in each dimension. + /// A stride of `[1, 1]` will slide the kernel one pixel at a time. + pub stride: [usize; 2], + + /// The number of zero-padding pixels added to each side of the input tensor in each dimension. + pub padding: [usize; 2], + + /// The spacing between the blocks (patches) in the original input tensor. + pub dilation: [usize; 2], +} + +impl UnfoldOptions { + /// Constructs a new `UnfoldOptions`. + pub fn new(stride: [usize; 2], padding: [usize; 2], dilation: [usize; 2]) -> Self { + Self { + stride: stride.map(|s| check_nonzero(s, "stride must be non-zero")), + padding, + dilation: dilation.map(|d| check_nonzero(d, "dilation must be non-zero")), + } + } +} + +/// Algorithm used. +#[derive(new, Debug, Clone, serde::Deserialize, serde::Serialize)] +pub enum InterpolateMode { + /// Nearest-neighbor floor interpolation. + /// Matches the legacy behavior of OpenCV’s INTER_NEAREST. It results in a bottom-right shift when resizing. + /// + Nearest, + + /// Nearest-neighbor exact interpolation. + /// + NearestExact, + + /// Bilinear interpolation. + /// + Bilinear, + + /// Bicubic interpolation. + /// + Bicubic, + + /// Lanczos3 interpolation (6-tap sinc-based filter). + /// + Lanczos3, +} + +/// Interpolation options. +#[derive(Debug, Clone)] +pub struct InterpolateOptions { + /// Algorithm used. + pub mode: InterpolateMode, + /// If `true`, the input and output tensors are aligned by their corner pixels. + /// If `false`, half-pixel coordinate mapping is used instead. + pub align_corners: bool, +} + +impl InterpolateOptions { + /// Create new interpolate options with the given mode. + /// Defaults to `align_corners = true`. + pub fn new(mode: InterpolateMode) -> Self { + Self { + mode, + align_corners: true, + } + } + + /// Set align_corners. + pub fn with_align_corners(mut self, align_corners: bool) -> Self { + self.align_corners = align_corners; + self + } +} + +/// Padding mode for grid sampling when coordinates are out of bounds. +/// +/// Matches PyTorch's `padding_mode` parameter in `grid_sample`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)] +pub enum GridSamplePaddingMode { + /// Fill with zeros for out-of-bounds coordinates. + #[default] + Zeros, + /// Clamp coordinates to the border (use nearest edge value). + Border, + /// Reflect coordinates at the boundary. + Reflection, +} + +/// Options for grid sampling operations. +#[derive(Debug, Clone)] +pub struct GridSampleOptions { + /// Interpolation mode (bilinear, nearest, or bicubic). + pub mode: InterpolateMode, + /// Padding mode for out-of-bounds coordinates. + pub padding_mode: GridSamplePaddingMode, + /// If `true`, grid values of -1 and 1 correspond to the corner pixels. + /// If `false`, they correspond to the corner points of the corner pixels + /// (i.e., -1 maps to -0.5 and 1 maps to size - 0.5 in pixel coordinates). + pub align_corners: bool, +} + +impl Default for GridSampleOptions { + fn default() -> Self { + Self { + mode: InterpolateMode::Bilinear, + padding_mode: GridSamplePaddingMode::Zeros, + align_corners: false, + } + } +} + +impl From for GridSampleOptions { + fn from(value: InterpolateMode) -> Self { + GridSampleOptions::new(value) + } +} + +impl GridSampleOptions { + /// Create new grid sample options with the given interpolation mode. + /// + /// Uses default values for padding_mode (Zeros) and align_corners (false). + pub fn new(mode: InterpolateMode) -> Self { + Self { + mode, + ..Default::default() + } + } + + /// Set the padding mode. + pub fn with_padding_mode(mut self, padding_mode: GridSamplePaddingMode) -> Self { + self.padding_mode = padding_mode; + self + } + + /// Set align_corners. + pub fn with_align_corners(mut self, align_corners: bool) -> Self { + self.align_corners = align_corners; + self + } +} + +/// Padding mode for tensor pad operations. +/// +/// Defines how values are filled when padding a tensor beyond its original boundaries. +/// Padding can be applied to any dimension of a tensor. +/// +/// # Modes +/// +/// - [`Constant`](PadMode::Constant): Fill with a specified value (default: 0.0) +/// - [`Reflect`](PadMode::Reflect): Mirror values at boundary, excluding edge (requires padding < dim_size) +/// - [`Edge`](PadMode::Edge): Replicate boundary values +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum PadMode { + /// Fill padded regions with a constant value. + /// + /// # Example + /// For tensor `[1, 2, 3]` with padding 2 on the left and value 0: + /// Result: `[0, 0, 1, 2, 3]` + Constant(f32), + + /// Reflect values at the boundary, excluding the edge value. + /// + /// Padding must be less than the dimension size (i.e., `padding < dim_size`). + /// + /// # Example + /// For tensor `[1, 2, 3, 4]` with padding 2 on the left: + /// Result: `[3, 2, 1, 2, 3, 4]` (reflects from index 1, not 0) + Reflect, + + /// Replicate the edge values. + /// + /// # Example + /// For tensor `[1, 2, 3, 4]` with padding 2 on the left: + /// Result: `[1, 1, 1, 2, 3, 4]` + Edge, +} + +impl Default for PadMode { + fn default() -> Self { + PadMode::Constant(0.0) + } +} + +impl From for PadMode { + fn from(value: E) -> Self { + PadMode::Constant(value.elem()) + } +} + +/// Options for the attention module. +#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct AttentionModuleOptions { + /// Custom scale factor applied to QK^T. When `None`, defaults to `1/sqrt(head_dim)`. + pub scale: Option, + + /// Soft capping applied before softmax: `softcap * tanh(scores / softcap)`. + /// Used by Gemma-2 and similar models. Must be positive when set. + pub softcap: Option, + + /// When `true`, applies causal (autoregressive) masking so that each query position + /// can only attend to key positions at or before it. This is more efficient than + /// passing an explicit lower-triangular bool mask because backends can use optimized + /// kernel paths (e.g. flash attention with causal mode). + pub is_causal: bool, +} + +/// Computation to be used to update the existing values in indexed assignment operations (scatter/select). +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum IndexingUpdateOp { + /// Overwrite existing values. + Assign, + /// Performs an addition. + Add, + /// Multiply existing values. + Mul, + /// Take element-wise minimum. + Min, + /// Take element-wise maximum. + Max, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic = "stride must be non-zero"] + fn conv_options_stride_zero() { + let _opt = ConvOptions::new([0, 1], [0, 0], [1, 1], 1); + } + + #[test] + #[should_panic = "dilation must be non-zero"] + fn conv_options_dilation_zero() { + let _opt = ConvOptions::new([1, 1], [0, 0], [0, 0], 1); + } + + #[test] + #[should_panic = "groups must be non-zero"] + fn conv_options_groups_zero() { + let _opt = ConvOptions::new([1, 1], [0, 0], [1, 1], 0); + } + + #[test] + #[should_panic = "stride must be non-zero"] + fn conv_transpose_options_stride_zero() { + let _opt = ConvTransposeOptions::new([0, 1], [0, 0], [0, 0], [1, 1], 1); + } + + #[test] + #[should_panic = "dilation must be non-zero"] + fn conv_transpose_options_dilation_zero() { + let _opt = ConvTransposeOptions::new([1, 1], [0, 0], [0, 0], [0, 0], 1); + } + + #[test] + #[should_panic = "groups must be non-zero"] + fn conv_transpose_options_groups_zero() { + let _opt = ConvTransposeOptions::new([1, 1], [0, 0], [0, 0], [1, 1], 0); + } + + #[test] + #[should_panic = "stride must be non-zero"] + fn deform_conv_options_stride_zero() { + let _opt = DeformConvOptions::new([0, 1], [0, 0], [1, 1], 1, 1); + } + + #[test] + #[should_panic = "dilation must be non-zero"] + fn deform_conv_options_dilation_zero() { + let _opt = DeformConvOptions::new([1, 1], [0, 0], [0, 0], 1, 1); + } + + #[test] + #[should_panic = "weight groups must be non-zero"] + fn deform_conv_options_weights_groups_zero() { + let _opt = DeformConvOptions::new([1, 1], [0, 0], [1, 1], 0, 1); + } + + #[test] + #[should_panic = "offset groups must be non-zero"] + fn deform_conv_options_offset_groups_zero() { + let _opt = DeformConvOptions::new([1, 1], [0, 0], [1, 1], 1, 0); + } + + #[test] + #[should_panic = "stride must be non-zero"] + fn unfold_options_stride_zero() { + let _opt = UnfoldOptions::new([0, 1], [0, 0], [1, 1]); + } + + #[test] + #[should_panic = "dilation must be non-zero"] + fn unfold_options_dilation_zero() { + let _opt = UnfoldOptions::new([1, 1], [0, 0], [0, 0]); + } +} diff --git a/crates/burn-std/src/runtime_kind.rs b/crates/burn-std/src/runtime_kind.rs new file mode 100644 index 0000000..79e7f0d --- /dev/null +++ b/crates/burn-std/src/runtime_kind.rs @@ -0,0 +1,62 @@ +//! Runtime kind of the host program. +//! +//! Some backend decisions depend not on the device but on *how the host program itself is +//! being driven* — whether `main` runs on an asynchronous runtime, a synchronous +//! thread-based runtime, or a restricted no-std environment. This module stores that kind +//! in a process global so backends can read it and adapt their behavior. +//! +//! The canonical example is tensor readback (`into_data`): deferring the device→host copy +//! lazily is fine under a sync/threaded runtime (a later blocking read just parks a thread), +//! but under an async runtime the same blocking read parks an executor worker and starves +//! the runtime, so the read must materialize eagerly instead. + +use core::sync::atomic::{AtomicU8, Ordering}; + +/// How the host program is being driven. +/// +/// Set once near program start with [`set_runtime_kind`]; read anywhere with +/// [`runtime_kind`]. Defaults to [`RuntimeKind::Sync`]. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RuntimeKind { + /// Synchronous, thread-based runtime (the default). + /// + /// Blocking readbacks are fine here, so tensor reads may defer the device→host copy + /// lazily and materialize it on first access. + #[default] + Sync = 0, + /// Asynchronous runtime (e.g. tokio). + /// + /// Blocking a runtime worker starves the executor, so tensor reads must materialize + /// eagerly inside the awaited future rather than deferring a blocking copy. + Async = 1, + /// Restricted no-std environment. + NoStd = 2, +} + +impl RuntimeKind { + fn from_u8(value: u8) -> Self { + match value { + 1 => RuntimeKind::Async, + 2 => RuntimeKind::NoStd, + _ => RuntimeKind::Sync, + } + } +} + +static RUNTIME_KIND: AtomicU8 = AtomicU8::new(RuntimeKind::Sync as u8); + +/// Declare the [kind](RuntimeKind) of runtime the host program is running on. +/// +/// Intended to be called once near program start (e.g. when a remote server declares that +/// it hosts the backend on an async runtime). Backends read it via [`runtime_kind`]. +pub fn set_runtime_kind(kind: RuntimeKind) { + RUNTIME_KIND.store(kind as u8, Ordering::Relaxed); +} + +/// Return the currently declared [kind](RuntimeKind) of runtime the host program runs on. +/// +/// Defaults to [`RuntimeKind::Sync`] until [`set_runtime_kind`] is called. +pub fn runtime_kind() -> RuntimeKind { + RuntimeKind::from_u8(RUNTIME_KIND.load(Ordering::Relaxed)) +} diff --git a/crates/burn-std/src/tensor/container.rs b/crates/burn-std/src/tensor/container.rs new file mode 100644 index 0000000..b092e28 --- /dev/null +++ b/crates/burn-std/src/tensor/container.rs @@ -0,0 +1,86 @@ +use alloc::boxed::Box; +use core::any::Any; + +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use hashbrown::HashMap; + +#[cfg(feature = "std")] +use std::collections::HashMap; + +/// Contains tensor of arbitrary dimension. +#[derive(Debug)] +pub struct TensorContainer { + tensors: HashMap>, +} + +impl Default for TensorContainer +where + ID: core::hash::Hash + PartialEq + Eq + core::fmt::Debug, +{ + fn default() -> Self { + Self::new() + } +} + +impl TensorContainer +where + ID: core::hash::Hash + PartialEq + Eq + core::fmt::Debug, +{ + /// Create an empty container. + pub fn new() -> Self { + Self { + tensors: HashMap::new(), + } + } + + /// Get a tensor with the given ID. + pub fn get(&self, id: &ID) -> Option { + let grad = self.tensors.get(id)?; + + let tensor = grad.downcast_ref::().unwrap(); + + Some(tensor.clone()) + } + + /// Get a mutable reference to the tensor with the given ID. + pub fn get_mut_ref(&mut self, id: &ID) -> Option<&mut T> { + let grad = self.tensors.get_mut(id)?; + + let tensor = grad.downcast_mut::().unwrap(); + + Some(tensor) + } + + /// Register a new tensor for the given ID. + /// + /// # Notes + /// + /// If a tensor is already registered for the given ID, it will be replaced. + pub fn register(&mut self, id: ID, value: T) { + self.tensors.insert(id, Box::new(value)); + } + + /// Remove a tensor for the given ID and returns it. + pub fn remove(&mut self, id: &ID) -> Option { + self.tensors + .remove(id) + .map(|item| *item.downcast::().unwrap()) + } + + /// The number of tensors registered. + pub fn len(&self) -> usize { + self.tensors.len() + } + + /// If any tensor is contained. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Get id of every tensor in the container + pub fn ids(&self) -> Vec<&ID> { + self.tensors.keys().collect() + } +} diff --git a/crates/burn-std/src/tensor/dtype.rs b/crates/burn-std/src/tensor/dtype.rs new file mode 100644 index 0000000..c3fa02b --- /dev/null +++ b/crates/burn-std/src/tensor/dtype.rs @@ -0,0 +1,408 @@ +//! Tensor data type. + +use serde::{Deserialize, Serialize}; + +use crate::tensor::quantization::{QuantScheme, QuantStore, QuantValue}; +use crate::{bf16, f16}; + +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum DType { + F64, + F32, + Flex32, + F16, + BF16, + I64, + I32, + I16, + I8, + U64, + U32, + U16, + U8, + Bool(BoolStore), + QFloat(QuantScheme), +} + +// Conversions between `DType` and cubecl's `ElemType` / `StorageType` are +// intentionally not implemented here so that `burn-std` does not depend on +// `cubecl`. Backend code that needs them should call the named functions in +// `burn_backend::cubecl` (e.g. `elem_type_to_dtype`, `dtype_to_elem_type`, +// `dtype_to_storage_type`). + +impl DType { + /// Returns the size of a type in bytes. + pub const fn size(&self) -> usize { + match self { + DType::F64 => core::mem::size_of::(), + DType::F32 => core::mem::size_of::(), + DType::Flex32 => core::mem::size_of::(), + DType::F16 => core::mem::size_of::(), + DType::BF16 => core::mem::size_of::(), + DType::I64 => core::mem::size_of::(), + DType::I32 => core::mem::size_of::(), + DType::I16 => core::mem::size_of::(), + DType::I8 => core::mem::size_of::(), + DType::U64 => core::mem::size_of::(), + DType::U32 => core::mem::size_of::(), + DType::U16 => core::mem::size_of::(), + DType::U8 => core::mem::size_of::(), + DType::Bool(store) => match store { + BoolStore::Native => core::mem::size_of::(), + BoolStore::U8 => core::mem::size_of::(), + BoolStore::U32 => core::mem::size_of::(), + }, + DType::QFloat(scheme) => match scheme.store { + QuantStore::Native => match scheme.value { + QuantValue::Q8F | QuantValue::Q8S => core::mem::size_of::(), + // e2m1 native is automatically packed by the kernels, so the actual storage is + // 8 bits wide. + QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => { + core::mem::size_of::() + } + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S => { + // Sub-byte values have fractional size + 0 + } + }, + QuantStore::PackedU32(_) => core::mem::size_of::(), + QuantStore::PackedNative(_) => match scheme.value { + QuantValue::E2M1 => core::mem::size_of::(), + _ => 0, + }, + }, + } + } + /// Returns true if the data type is a floating point type. + pub fn is_float(&self) -> bool { + matches!( + self, + DType::F64 | DType::F32 | DType::Flex32 | DType::F16 | DType::BF16 + ) + } + /// Returns true if the data type is a signed integer type. + pub fn is_int(&self) -> bool { + matches!(self, DType::I64 | DType::I32 | DType::I16 | DType::I8) + } + /// Returns true if the data type is an unsigned integer type. + pub fn is_uint(&self) -> bool { + matches!(self, DType::U64 | DType::U32 | DType::U16 | DType::U8) + } + + /// Returns true if the data type is a boolean type + pub fn is_bool(&self) -> bool { + matches!(self, DType::Bool(_)) + } + + /// Returns float precision info if this is a float dtype, `None` otherwise. + /// + /// Analogous to `torch.finfo(dtype)` or `numpy.finfo(dtype)`. + pub const fn finfo(&self) -> Option { + match self { + DType::F64 => Some(FloatDType::F64.finfo()), + DType::F32 => Some(FloatDType::F32.finfo()), + DType::Flex32 => Some(FloatDType::Flex32.finfo()), + DType::F16 => Some(FloatDType::F16.finfo()), + DType::BF16 => Some(FloatDType::BF16.finfo()), + _ => None, + } + } + + /// Returns the data type name. + pub fn name(&self) -> &'static str { + match self { + DType::F64 => "f64", + DType::F32 => "f32", + DType::Flex32 => "flex32", + DType::F16 => "f16", + DType::BF16 => "bf16", + DType::I64 => "i64", + DType::I32 => "i32", + DType::I16 => "i16", + DType::I8 => "i8", + DType::U64 => "u64", + DType::U32 => "u32", + DType::U16 => "u16", + DType::U8 => "u8", + DType::Bool(store) => match store { + BoolStore::Native => "bool", + BoolStore::U8 => "bool(u8)", + BoolStore::U32 => "bool(u32)", + }, + DType::QFloat(_) => "qfloat", + } + } +} + +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum FloatDType { + F64, + F32, + Flex32, + F16, + BF16, +} + +/// Numerical precision properties for a floating-point dtype. +/// +/// Equivalent to NumPy's `finfo` / PyTorch's `torch.finfo`. All values are +/// widened to `f64` so they can be inspected without knowing the concrete +/// element type at compile time. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FloatInfo { + /// Machine epsilon: smallest value such that `1.0 + epsilon != 1.0`. + pub epsilon: f64, + /// Largest representable finite value. + pub max: f64, + /// Most negative representable finite value. + pub min: f64, + /// Smallest positive normal value. + pub min_positive: f64, +} + +impl FloatDType { + /// Returns numerical precision properties for this float dtype. + /// + /// Analogous to `torch.finfo(dtype)` or `numpy.finfo(dtype)`. + pub const fn finfo(self) -> FloatInfo { + match self { + FloatDType::F64 => FloatInfo { + epsilon: f64::EPSILON, + max: f64::MAX, + min: f64::MIN, + min_positive: f64::MIN_POSITIVE, // ~2.225e-308 + }, + FloatDType::F32 => FloatInfo { + epsilon: f32::EPSILON as f64, + max: f32::MAX as f64, + min: f32::MIN as f64, + min_positive: f32::MIN_POSITIVE as f64, // ~1.175e-38 + }, + // Flex32 stores as f32 but computes at reduced (f16-like) precision. + // Use f16 precision limits so stability code stays safe. + FloatDType::Flex32 => FloatInfo { + epsilon: f16::EPSILON.to_f64_const(), + max: f16::MAX.to_f64_const(), + min: f16::MIN.to_f64_const(), + min_positive: f16::MIN_POSITIVE.to_f64_const(), // ~6.104e-5 + }, + FloatDType::F16 => FloatInfo { + epsilon: f16::EPSILON.to_f64_const(), + max: f16::MAX.to_f64_const(), + min: f16::MIN.to_f64_const(), + min_positive: f16::MIN_POSITIVE.to_f64_const(), // ~6.104e-5 + }, + FloatDType::BF16 => FloatInfo { + epsilon: bf16::EPSILON.to_f64_const(), + max: bf16::MAX.to_f64_const(), + min: bf16::MIN.to_f64_const(), + min_positive: bf16::MIN_POSITIVE.to_f64_const(), // ~1.175e-38 + }, + } + } +} + +impl From for FloatDType { + fn from(value: DType) -> Self { + match value { + DType::F64 => FloatDType::F64, + DType::F32 => FloatDType::F32, + DType::Flex32 => FloatDType::Flex32, + DType::F16 => FloatDType::F16, + DType::BF16 => FloatDType::BF16, + _ => panic!("Expected float data type, got {value:?}"), + } + } +} + +impl From for DType { + fn from(value: FloatDType) -> Self { + match value { + FloatDType::F64 => DType::F64, + FloatDType::F32 => DType::F32, + FloatDType::Flex32 => DType::Flex32, + FloatDType::F16 => DType::F16, + FloatDType::BF16 => DType::BF16, + } + } +} + +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum IntDType { + I64, + I32, + I16, + I8, + U64, + U32, + U16, + U8, +} + +impl From for IntDType { + fn from(value: DType) -> Self { + match value { + DType::I64 => IntDType::I64, + DType::I32 => IntDType::I32, + DType::I16 => IntDType::I16, + DType::I8 => IntDType::I8, + DType::U64 => IntDType::U64, + DType::U32 => IntDType::U32, + DType::U16 => IntDType::U16, + DType::U8 => IntDType::U8, + _ => panic!("Expected int data type, got {value:?}"), + } + } +} + +impl From for DType { + fn from(value: IntDType) -> Self { + match value { + IntDType::I64 => DType::I64, + IntDType::I32 => DType::I32, + IntDType::I16 => DType::I16, + IntDType::I8 => DType::I8, + IntDType::U64 => DType::U64, + IntDType::U32 => DType::U32, + IntDType::U16 => DType::U16, + IntDType::U8 => DType::U8, + } + } +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +/// Data type used to store boolean values. +pub enum BoolStore { + /// Stored as native boolean type (e.g. `bool`). + Native, + /// Stored as 8-bit unsigned integer. + U8, + /// Stored as 32-bit unsigned integer. + U32, +} + +/// Boolean dtype. +/// +/// This is currently an alias to [`BoolStore`], since it only varies by the storage representation. +pub type BoolDType = BoolStore; + +#[allow(deprecated)] +impl From for BoolDType { + fn from(value: DType) -> Self { + match value { + DType::Bool(store) => match store { + BoolStore::Native => BoolDType::Native, + BoolStore::U8 => BoolDType::U8, + BoolStore::U32 => BoolDType::U32, + }, + // For compat BoolElem associated type + DType::U8 => BoolDType::U8, + DType::U32 => BoolDType::U32, + _ => panic!("Expected bool data type, got {value:?}"), + } + } +} + +impl From for DType { + fn from(value: BoolDType) -> Self { + match value { + BoolDType::Native => DType::Bool(BoolStore::Native), + BoolDType::U8 => DType::Bool(BoolStore::U8), + BoolDType::U32 => DType::Bool(BoolStore::U32), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finfo_f32() { + let info = FloatDType::F32.finfo(); + assert_eq!(info.epsilon, f32::EPSILON as f64); + assert_eq!(info.max, f32::MAX as f64); + assert_eq!(info.min, f32::MIN as f64); + assert_eq!(info.min_positive, f32::MIN_POSITIVE as f64); + } + + #[test] + fn finfo_f64() { + let info = FloatDType::F64.finfo(); + assert_eq!(info.epsilon, f64::EPSILON); + assert_eq!(info.max, f64::MAX); + assert_eq!(info.min, f64::MIN); + assert_eq!(info.min_positive, f64::MIN_POSITIVE); + } + + #[test] + fn finfo_f16() { + let info = FloatDType::F16.finfo(); + assert_eq!(info.epsilon, f16::EPSILON.to_f64_const()); + assert!(info.epsilon > 0.0); + assert!(info.min_positive > 0.0); + // f16 epsilon is much larger than f32 + assert!(info.epsilon > FloatDType::F32.finfo().epsilon); + } + + #[test] + fn finfo_bf16() { + let info = FloatDType::BF16.finfo(); + assert_eq!(info.epsilon, bf16::EPSILON.to_f64_const()); + assert!(info.epsilon > 0.0); + assert!(info.min_positive > 0.0); + // bf16 epsilon is larger than f32 (fewer mantissa bits) + assert!(info.epsilon > FloatDType::F32.finfo().epsilon); + } + + #[test] + fn finfo_flex32_uses_f16_limits() { + let flex = FloatDType::Flex32.finfo(); + let f16_info = FloatDType::F16.finfo(); + assert_eq!(flex.epsilon, f16_info.epsilon); + assert_eq!(flex.min_positive, f16_info.min_positive); + } + + #[test] + fn dtype_finfo_delegates_to_float_dtype() { + assert_eq!(DType::F32.finfo(), Some(FloatDType::F32.finfo())); + assert_eq!(DType::F64.finfo(), Some(FloatDType::F64.finfo())); + assert_eq!(DType::F16.finfo(), Some(FloatDType::F16.finfo())); + assert_eq!(DType::BF16.finfo(), Some(FloatDType::BF16.finfo())); + assert_eq!(DType::Flex32.finfo(), Some(FloatDType::Flex32.finfo())); + } + + #[test] + fn dtype_finfo_returns_none_for_non_float() { + assert!(DType::I32.finfo().is_none()); + assert!(DType::U8.finfo().is_none()); + assert!(DType::Bool(BoolStore::Native).finfo().is_none()); + } + + #[test] + fn finfo_invariants() { + for dtype in [ + FloatDType::F64, + FloatDType::F32, + FloatDType::F16, + FloatDType::BF16, + FloatDType::Flex32, + ] { + let info = dtype.finfo(); + assert!(info.epsilon > 0.0, "{dtype:?}: epsilon must be positive"); + assert!( + info.min_positive > 0.0, + "{dtype:?}: min_positive must be positive" + ); + assert!(info.max > 0.0, "{dtype:?}: max must be positive"); + assert!(info.min < 0.0, "{dtype:?}: min must be negative"); + assert!( + info.max > info.min_positive, + "{dtype:?}: max > min_positive" + ); + } + } +} diff --git a/crates/burn-std/src/tensor/matmul.rs b/crates/burn-std/src/tensor/matmul.rs new file mode 100644 index 0000000..a517f28 --- /dev/null +++ b/crates/burn-std/src/tensor/matmul.rs @@ -0,0 +1,352 @@ +use crate::tensor::{Metadata, Shape, Strides}; + +/// Accelerated matmul tiles consume rows in chunks of this size; a row count +/// that is a multiple of it already tiles cleanly. +const ROW_TILE: usize = 32; + +/// The action to take for a batched matmul with a broadcast rhs. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum MatmulTransformAction { + /// Launch the matmul as-is. + Keep, + /// Fold every lhs/out batch dim into the rows: `[.., b, m, k] @ [.., 1, k, n]` + /// runs as one `[b*m, k] @ [k, n]` matmul against the shared rhs, instead of + /// `b` matmuls that each re-read it. + MergeBatches { + /// The merged row count (batches × rows). + rows: usize, + }, +} + +impl MatmulTransformAction { + /// Apply the action to one merged operand (lhs or out): fold the batch dims + /// into the rows, in place. A pure metadata rewrite — the buffer is shared. + /// + /// Requires batch-contiguous rows (see [MatmulTransformAnalysis]); the merge + /// keeps the rank, with every batch dim set to 1. + pub fn apply(&self, meta: &mut Metadata) { + let rows = match self { + MatmulTransformAction::Keep => return, + MatmulTransformAction::MergeBatches { rows } => *rows, + }; + + let rank = meta.rank(); + let stride_rows = merged_row_stride(meta.shape(), meta.strides()) + .expect("The action requires batch-contiguous rows"); + + for i in 0..rank - 2 { + meta.shape_mut()[i] = 1; + meta.strides_mut()[i] = rows * stride_rows; + } + meta.shape_mut()[rank - 2] = rows; + meta.strides_mut()[rank - 2] = stride_rows; + } +} + +/// The facts of a batched matmul `lhs @ rhs` relevant to folding its batches +/// into its rows. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct MatmulTransformAnalysis { + /// Product of the lhs batch dims. + batches: usize, + /// Rows of one batch (`m`). + rows: usize, + /// Columns of the output (`n`). + cols: usize, + /// The rhs is shared by every batch, and each operand's rows advance with a + /// single stride across batch boundaries (no holes), so the fold is a pure + /// view. + mergeable: bool, +} + +impl MatmulTransformAnalysis { + /// Analyze from shapes alone: the operands are assumed batch-contiguous, as + /// holds for freshly materialized tensors. Use [Self::from_metadata] when + /// the layouts are known. + /// + /// The rhs may have a lower rank than the lhs (implicit broadcast). + pub fn from_shapes(lhs: &Shape, rhs: &Shape) -> Self { + Self::new(lhs, rhs, true) + } + + /// Analyze from full metadata: on top of the shape facts, the lhs and out + /// rows must advance with a single stride across batch boundaries. + pub fn from_metadata(lhs: &Metadata, rhs: &Metadata, out: &Metadata) -> Self { + let mergeable = merged_row_stride(lhs.shape(), lhs.strides()).is_some() + && merged_row_stride(out.shape(), out.strides()).is_some(); + + Self::new(lhs.shape(), rhs.shape(), mergeable) + } + + fn new(lhs: &Shape, rhs: &Shape, rows_contiguous: bool) -> Self { + let rank = lhs.num_dims(); + let rank_rhs = rhs.num_dims(); + + let batches = lhs[..rank - 2].iter().product(); + let rows = lhs[rank - 2]; + let cols = rhs[rank_rhs - 1]; + + // Every batch dim the rhs actually has must be broadcast. + let rhs_shared = rhs[..rank_rhs - 2].iter().all(|&dim| dim == 1); + + Self { + batches, + rows, + cols, + mergeable: rhs_shared && rows_contiguous, + } + } +} + +/// Decides the [action](MatmulTransformAction) to take for a batched matmul, +/// given its [analysis](MatmulTransformAnalysis). +#[derive(Debug, Default, Clone, Copy)] +pub enum MatmulTransformPolicy { + /// Merge the batches into the rows when the fold is a pure view and the + /// merged problem tiles better: per-batch rows that already fill row tiles + /// stay batched (a batched matmul beats one big matmul at that scale), tiny + /// row counts merge when that brings the output closer to square. + #[default] + BetterTiling, + /// Never transform. + Never, +} + +impl MatmulTransformPolicy { + /// The action to take for a matmul with the given analysis. + pub fn action(&self, analysis: &MatmulTransformAnalysis) -> MatmulTransformAction { + match self { + MatmulTransformPolicy::Never => MatmulTransformAction::Keep, + MatmulTransformPolicy::BetterTiling => { + if !analysis.mergeable || analysis.batches == 1 { + return MatmulTransformAction::Keep; + } + + // Rows already fill the tiles: keep the batched form. + if analysis.rows.is_multiple_of(ROW_TILE) { + return MatmulTransformAction::Keep; + } + + let rows = analysis.batches * analysis.rows; + + // The merge must bring the output closer to square. + if !squarer(rows, analysis.rows, analysis.cols) { + return MatmulTransformAction::Keep; + } + + MatmulTransformAction::MergeBatches { rows } + } + } + } +} + +/// Whether an output of `rows_new` x `cols` is closer to square than +/// `rows` x `cols`. +fn squarer(rows_new: usize, rows: usize, cols: usize) -> bool { + // min(a, c) / max(a, c) > min(b, c) / max(b, c), cross-multiplied to stay + // in integers. + rows_new.min(cols) * rows.max(cols) > rows.min(cols) * rows_new.max(cols) +} + +/// The single stride with which rows advance across batch boundaries, when the +/// batch and row dims form a hole-free chain — the requirement for folding the +/// batches into the rows as a pure view. Dims of size 1 carry arbitrary strides +/// and never anchor the chain. +fn merged_row_stride(shape: &Shape, strides: &Strides) -> Option { + let rank = shape.num_dims(); + let mut chained: Option<(usize, usize)> = None; + + for i in (0..rank - 1).rev() { + if shape[i] == 1 { + continue; + } + match chained { + None => chained = Some((strides[i], strides[i] * shape[i])), + Some((row_stride, expected)) => { + if strides[i] != expected { + return None; + } + chained = Some((row_stride, strides[i] * shape[i])); + } + } + } + + match chained { + Some((row_stride, _)) => Some(row_stride), + // Every merged dim is a unit dim: a single row, any stride works. + None => Some(shape[rank - 1] * strides[rank - 1]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shape; + + fn analysis(lhs: &[usize], rhs: &[usize]) -> MatmulTransformAnalysis { + MatmulTransformAnalysis::from_shapes(&Shape::from(lhs), &Shape::from(rhs)) + } + + fn action(analysis: &MatmulTransformAnalysis) -> MatmulTransformAction { + MatmulTransformPolicy::default().action(analysis) + } + + #[test] + fn merges_batched_vec_mat() { + // A decode step: 16 broadcast vec-mats fold into one 16-row matmul. + let analysis = analysis(&[16, 1, 4096], &[4096, 14336]); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 16 } + ); + } + + #[test] + fn keeps_row_tiled_batches() { + // A prefill step: 512 rows per batch already fill the row tiles; the + // batched matmul beats one big matmul. + let analysis = analysis(&[8, 512, 4096], &[4096, 14336]); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn keeps_single_batch() { + let analysis = analysis(&[1, 1, 4096], &[4096, 14336]); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn keeps_matrix() { + let analysis = analysis(&[16, 4096], &[4096, 14336]); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn keeps_batched_rhs() { + // The rhs differs per batch: nothing is shared, nothing to fold. + let analysis = analysis(&[8, 1, 64], &[8, 64, 32]); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn merges_with_broadcast_rhs_rank() { + let analysis = analysis(&[8, 1, 64], &[1, 64, 32]); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 8 } + ); + } + + #[test] + fn keeps_when_merge_leaves_square() { + // 1000 rows tower over 64 cols; folding to 4000 only makes the output + // less square. + let analysis = analysis(&[4, 1000, 64], &[64, 64]); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn merges_multiple_batch_dims() { + let analysis = analysis(&[2, 8, 1, 4096], &[4096, 14336]); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 16 } + ); + } + + #[test] + fn never_policy_keeps() { + let analysis = analysis(&[16, 1, 4096], &[4096, 14336]); + + assert_eq!( + MatmulTransformPolicy::Never.action(&analysis), + MatmulTransformAction::Keep + ); + } + + #[test] + fn metadata_merges_pitched_rows() { + // Padded batches with one row each still fold: the merged matrix reads + // its rows with the single (pitched) stride 8192. + let lhs = Metadata::new(shape![16, 1, 4096], crate::strides![8192, 4096, 1]); + let rhs = Metadata::new(shape![1, 4096, 14336], crate::strides![0, 14336, 1]); + let out = Metadata::new(shape![16, 1, 14336], crate::strides![14336, 14336, 1]); + + let analysis = MatmulTransformAnalysis::from_metadata(&lhs, &rhs, &out); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 16 } + ); + } + + #[test] + fn metadata_keeps_batch_holes() { + // Rows are contiguous within a batch but batches leave a gap: no single + // row stride can express the fold. + let lhs = Metadata::new(shape![4, 3, 8], crate::strides![48, 8, 1]); + let rhs = Metadata::new(shape![1, 8, 32], crate::strides![0, 32, 1]); + let out = Metadata::new(shape![4, 3, 32], crate::strides![96, 32, 1]); + + let analysis = MatmulTransformAnalysis::from_metadata(&lhs, &rhs, &out); + + assert_eq!(action(&analysis), MatmulTransformAction::Keep); + } + + #[test] + fn metadata_merges_contiguous() { + let lhs = Metadata::new(shape![16, 1, 4096], crate::strides![4096, 4096, 1]); + let rhs = Metadata::new(shape![1, 4096, 14336], crate::strides![0, 14336, 1]); + let out = Metadata::new(shape![16, 1, 14336], crate::strides![14336, 14336, 1]); + + let analysis = MatmulTransformAnalysis::from_metadata(&lhs, &rhs, &out); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 16 } + ); + } + + #[test] + fn metadata_ignores_unit_dim_strides() { + // Size-1 dims carry arbitrary strides; they must not anchor the chain. + let lhs = Metadata::new(shape![16, 1, 4096], crate::strides![4096, 12345, 1]); + let rhs = Metadata::new(shape![1, 4096, 14336], crate::strides![0, 14336, 1]); + let out = Metadata::new(shape![16, 1, 14336], crate::strides![14336, 99999, 1]); + + let analysis = MatmulTransformAnalysis::from_metadata(&lhs, &rhs, &out); + + assert_eq!( + action(&analysis), + MatmulTransformAction::MergeBatches { rows: 16 } + ); + } + + #[test] + fn apply_folds_batches_in_place() { + let mut lhs = Metadata::new(shape![16, 1, 4096], crate::strides![4096, 4096, 1]); + + MatmulTransformAction::MergeBatches { rows: 16 }.apply(&mut lhs); + + assert_eq!(lhs.shape(), &shape![1, 16, 4096]); + assert_eq!(lhs.strides()[1], 4096); + assert_eq!(lhs.strides()[2], 1); + } + + #[test] + fn apply_keep_is_noop() { + let mut lhs = Metadata::new(shape![16, 1, 4096], crate::strides![4096, 4096, 1]); + + MatmulTransformAction::Keep.apply(&mut lhs); + + assert_eq!(lhs.shape(), &shape![16, 1, 4096]); + } +} diff --git a/crates/burn-std/src/tensor/mod.rs b/crates/burn-std/src/tensor/mod.rs new file mode 100644 index 0000000..a52718d --- /dev/null +++ b/crates/burn-std/src/tensor/mod.rs @@ -0,0 +1,336 @@ +/// Generic container for storing tensors keyed by an id. +pub mod container; +/// Tensor data type definitions. +pub mod dtype; +/// Batched matmul transformation utilities. +pub mod matmul; +/// Quantization data representation. +pub mod quantization; +/// Tensor shape utilities. +pub mod shape; +/// Tensor slicing utilities. +pub mod slice; + +pub use dtype::*; +pub use matmul::*; +pub use quantization::*; +pub use shape::*; +pub use slice::*; + +pub use cubecl_zspace::indexing::{self, *}; +pub use cubecl_zspace::{Strides, metadata::Metadata, strides}; + +/// Check if the current tensor is contiguous. +/// +/// A tensor is considered contiguous if its elements are stored in memory +/// such that the stride at position `k` is equal to the product of the shapes +/// of all dimensions greater than `k`. +/// +/// This means that strides increase as you move from the rightmost to the leftmost dimension. +pub fn is_contiguous(shape: &[usize], strides: &[usize]) -> bool { + if shape.is_empty() { + return true; + } + + for (&expected, &stride) in contiguous_strides(shape).iter().zip(strides) { + if expected != stride { + return false; + } + } + + true +} + +/// Computes the strides for a contiguous tensor with the given shape. +/// +/// In a contiguous row-major tensor, the stride for each dimension +/// equals the product of all dimension sizes to its right. +pub fn contiguous_strides(shape: &[usize]) -> Strides { + let mut strides = strides![0; shape.len()]; + let mut current = 1; + + for (i, &dim) in shape.iter().enumerate().rev() { + strides[i] = current; + current *= dim; + } + + strides +} + +/// The action to take for a reshape operation. +#[derive(Debug)] +pub enum ReshapeAction { + /// Updating the strides is sufficient to handle the reshape. + UpdateStrides { + /// The new strides. + strides: Strides, + }, + /// The strides are not compatible, we should recompute the buffer. + Recompute, + /// The strides are already correct. + NoChange, +} + +/// The reshape kind. +#[derive(Debug, PartialEq)] +pub enum ReshapeAnalysis { + /// Original tensor is contiguous, can update the strides. + IsContiguous, + /// Original tensor is highly permuted, can't update the strides. + HighlyPermuted, + /// Only batch dimensions are added, can update the strides. + Broadcasted, + /// Dimensions are only split, can update the strides. + Split, + /// Original tensor is bigger than output shape. + SmallerRank, + /// New shape is the same. + NoChange, +} + +impl ReshapeAnalysis { + /// Returns the proper action to take for the current analysis. + pub fn action(&self, shape: &[usize], strides: &[usize], shape_new: &[usize]) -> ReshapeAction { + match self { + ReshapeAnalysis::IsContiguous => ReshapeAction::UpdateStrides { + strides: contiguous_strides(shape_new), + }, + ReshapeAnalysis::NoChange => ReshapeAction::NoChange, + ReshapeAnalysis::HighlyPermuted | ReshapeAnalysis::SmallerRank => { + ReshapeAction::Recompute + } + ReshapeAnalysis::Broadcasted => { + let shape_rank = shape.len(); + let shape_new_rank = shape_new.len(); + let n_new_batch = shape_new_rank - shape_rank; + let num_elems = shape.iter().product::(); + let strides_new = broadcast_strides(n_new_batch, shape_rank, num_elems, strides); + + ReshapeAction::UpdateStrides { + strides: strides_new, + } + } + ReshapeAnalysis::Split => { + let strides_new = split_strides(shape, strides, shape_new); + + ReshapeAction::UpdateStrides { + strides: strides_new, + } + } + } + } +} + +/// Returns the proper action to take when reshaping a tensor. +pub fn reshape_action(shape: &Shape, strides: &Strides, shape_new: &Shape) -> ReshapeAction { + reshape_analysis(shape, Some(strides), shape_new).action(shape, strides, shape_new) +} + +/// Calculate the new strides given added batch dimensions. +pub fn broadcast_strides( + n_new_batch: usize, + rank_prev: usize, + num_elems: usize, + strides: &[usize], +) -> Strides { + let mut strides_new = strides![num_elems; rank_prev + n_new_batch]; + + for (i, s) in strides.iter().enumerate() { + strides_new[i + n_new_batch] = *s; + } + + strides_new +} + +/// Calculate the new strides given added split dimensions. +pub fn split_strides(shape: &[usize], strides: &[usize], shape_new: &[usize]) -> Strides { + let mut strides_new = strides![1; shape_new.len()]; + + // Unit dims in the old shape never anchor a group of new dims, and their + // stride can be arbitrary (0 for broadcast views, pitched values, ...). + // Skip them so a real new dim never inherits a unit dim's stride — + // e.g. reshaping [26, 1] with strides [1, 0] (a `repeat_dim` broadcast + // view) to [26, 1, 1] must keep stride 1 on dim 0; propagating the 0 + // would make every index along dim 0 alias the first element. + let skip_unit_dims = |mut idx: usize| { + while idx > 0 && shape[idx] == 1 { + idx -= 1; + } + idx + }; + + let mut old_idx = skip_unit_dims(shape.len() - 1); + let mut current_stride = strides[old_idx]; + let mut dim_prod = 1; + + for (i, dim) in shape_new.iter().enumerate().rev() { + dim_prod *= *dim; + strides_new[i] = current_stride; + if *dim == 1 { + continue; + } else if dim_prod == shape[old_idx] { + old_idx = skip_unit_dims(old_idx.saturating_sub(1)); + current_stride = strides[old_idx]; + dim_prod = 1; + } else { + current_stride *= *dim; + } + } + + strides_new +} + +/// Returns the analysis of a reshape operation. +pub fn reshape_analysis( + shape: &Shape, + strides: Option<&Strides>, + shape_new: &Shape, +) -> ReshapeAnalysis { + let shape_rank = shape.len(); + let shape_new_rank = shape_new.len(); + + let is_contiguous = match strides { + Some(strides) => is_contiguous(shape, strides), + None => false, + }; + + if is_contiguous { + return ReshapeAnalysis::IsContiguous; + } + + if shape_new_rank < shape_rank { + return ReshapeAnalysis::SmallerRank; + } + + let n_new_batch = shape_new_rank - shape_rank; + + match n_new_batch > 0 { + true => { + if shape.as_ref() == &shape_new[n_new_batch..shape_new_rank] + && shape_new[0..n_new_batch].iter().all(|it| *it == 1) + { + return ReshapeAnalysis::Broadcasted; + } else { + let mut dim_prod = 1; + let mut old_idx = 0; + for dim in shape_new.iter() { + dim_prod *= *dim; + + // We need to ignore unit dims because they don't affect analysis and break + // things because they match the default `dim_prod`. If we don't do this, + // reshapes like [2, 3] to [2, 3, 1] will panic from out of bounds access. + if *dim == 1 { + continue; + } else if dim_prod == shape[old_idx] { + dim_prod = 1; + old_idx += 1; + } else if dim_prod > shape[old_idx] { + return ReshapeAnalysis::HighlyPermuted; + } + } + return ReshapeAnalysis::Split; + } + } + + false => { + if shape == shape_new { + return ReshapeAnalysis::NoChange; + } + } + }; + + ReshapeAnalysis::HighlyPermuted +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reshape_analysis_is_contiguous() { + let analysis = reshape_analysis( + &[32, 1, 1, 1].into(), + Some(&[1, 1, 1, 1].into()), + &[1, 1, 32, 1, 1, 1].into(), + ); + + assert_eq!(analysis, ReshapeAnalysis::IsContiguous) + } + + #[test] + fn test_reshape_analysis_is_contiguous_2() { + let analysis = reshape_analysis( + &[32, 1, 1, 8].into(), + Some(&[8, 8, 8, 1].into()), + &[1, 1, 32, 1, 1, 8].into(), + ); + + assert_eq!(analysis, ReshapeAnalysis::IsContiguous) + } + + #[test] + fn test_reshape_analysis_broadcasted_batch() { + let analysis = reshape_analysis( + &[32, 1, 1, 1].into(), + Some(&[1, 32, 32, 32].into()), + &[1, 1, 32, 1, 1, 1].into(), + ); + + assert_eq!(analysis, ReshapeAnalysis::Broadcasted) + } + + #[test] + fn test_reshape_analysis_unsqueeze_split() { + // Unsqueeze + let analysis = reshape_analysis( + &[32, 1, 1, 1].into(), + Some(&[1, 32, 32, 32].into()), + &[32, 1, 1, 1, 1].into(), + ); + + assert_eq!(analysis, ReshapeAnalysis::Split) + } + + #[test] + fn test_reshape_analysis_split() { + let analysis = reshape_analysis( + &[32, 1, 1, 1].into(), + Some(&[1, 32, 32, 32].into()), + &[4, 8, 1, 1, 1].into(), + ); + + assert_eq!(analysis, ReshapeAnalysis::Split) + } + + #[test] + fn test_split_strides_trailing_unit_dim_broadcast_view() { + // A `repeat_dim` broadcast view: [26, 1] with strides [1, 0], + // unsqueezed to [26, 1, 1]. Dim 0 must keep stride 1 — propagating + // the unit dim's stride 0 makes every row alias row 0 (this breaks + // e.g. `scatter` index tensors built via unsqueeze + repeat). + let strides = split_strides(&[26, 1], &[1, 0], &[26, 1, 1]); + assert_eq!(strides.as_ref(), &[1, 1, 1]); + } + + #[test] + fn test_split_strides_trailing_unit_dims_arbitrary_strides() { + // Unit dims can carry arbitrary strides (broadcast 0, pitched + // values, ...). They must not anchor the stride walk. + let strides = split_strides(&[32, 1, 1, 1], &[1, 32, 32, 32], &[32, 1, 1, 1, 1]); + assert_eq!(strides.as_ref(), &[1, 1, 1, 1, 1]); + } + + #[test] + fn test_split_strides_split_of_broadcast_dim_keeps_zero() { + // Splitting a real broadcast (stride 0) dim keeps 0 on the split + // parts; the leading real dim keeps its stride. + let strides = split_strides(&[26, 16], &[1, 0], &[26, 4, 4]); + assert_eq!(strides.as_ref(), &[1, 0, 0]); + } + + #[test] + fn test_split_strides_plain_unsqueeze() { + let strides = split_strides(&[26, 16], &[16, 1], &[26, 16, 1]); + assert_eq!(strides.as_ref(), &[16, 1, 1]); + } +} diff --git a/crates/burn-std/src/tensor/quantization.rs b/crates/burn-std/src/tensor/quantization.rs new file mode 100644 index 0000000..23fb308 --- /dev/null +++ b/crates/burn-std/src/tensor/quantization.rs @@ -0,0 +1,432 @@ +//! Quantization data representation. + +// Re-exported types +pub use cubecl_common::quant::scheme::{ + BlockSize, QuantLevel, QuantMode, QuantParam, QuantScheme, QuantStore, QuantValue, +}; + +/// Alignment (in bytes) for quantization parameters in serialized tensor data. +/// +/// NOTE: This is currently f32-based since scales were originally always f32. +/// With `QuantParam` now supporting different precisions (F16, BF16, etc.), +/// this alignment may need to be revisited in the future. +pub const QPARAM_ALIGN: usize = core::mem::align_of::(); + +use alloc::vec::Vec; +use core::any::TypeId; +use num_traits::PrimInt; +use serde::{Deserialize, Serialize}; + +use crate::{DType, Metadata, Shape, bytes::Bytes}; + +/// Configuration for a device quantization behavior. +/// +/// This configuration determines how tensors are quantized and how quantization rules +/// propagate through operations on a given device. It is applied once during device +/// initialization. See also the [device settings](crate::DeviceSettings). +#[derive(new, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct QuantConfig { + /// Defines how a tensor is quantized. + pub scheme: QuantScheme, + /// How quantization is propagated during computation. + pub propagation: QuantPropagation, + // NOTE: accumulation is currently unused, only scheme and propagation have an impact + // /// The precision used for the accumulation in various kernels. + // pub acc: QuantAcc, +} + +#[derive( + Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, +)] +/// The precision of accumulating elements. +pub enum QuantAcc { + /// Full precision. + #[default] + F32, + /// Half precision. + F16, + /// bfloat16 precision. + BF16, +} + +/// Calibration method used to compute the quantization range mapping. +pub enum Calibration { + /// Computes quantization range mapping based on the min and max values. + MinMax, + /// Absolute-mean calibration for BitNet b1.58-style `{-1, 0, +1}` weight quantization. + /// + /// The range is `[-γ, +γ]` where γ = `mean(|W|)` per tensor or per block (BitNet b1.58 + /// §3.1). Use with `QuantValue::Q2S` and `QuantStore::PackedU32` for 2-bit packed storage. + AbsMean, +} + +/// Specify if the output of an operation is quantized using the scheme of the input +/// or returned unquantized. +#[derive( + Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, +)] +pub enum QuantPropagation { + /// The output is quantized using the scheme of the input. + Propagate, + /// The output is not quantized. + #[default] + Inhibit, +} + +/// The quantization tensor data parameters. +#[derive(Clone, Debug)] +pub struct QParams { + /// The scaling factor. + pub scales: S, +} + +/// A quantization parameter tensor descriptor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QParamTensor { + /// Start of the tensor in the buffer + pub offset_start: usize, + /// Offset of tensor end from the end of the buffer + pub offset_end: usize, + /// Metadata of the tensor + pub metadata: Metadata, + /// Data type of the tensor + pub dtype: DType, +} + +/// Calculate the shape of the quantization parameters for a given tensor and level +pub fn params_shape(data_shape: &Shape, level: QuantLevel) -> Shape { + match level { + QuantLevel::Tensor => Shape::new([1]), + QuantLevel::Block(block_size) => { + let mut params_shape = data_shape.clone(); + let block_size = block_size.to_dim_vec(data_shape.num_dims()); + + for (shape, block_size) in params_shape.iter_mut().zip(block_size) { + *shape = (*shape).div_ceil(block_size as usize); + } + + params_shape + } + } +} + +/// Quantized data bytes representation. +/// +/// # Notes +/// 1) The quantized values are packed into 32-bit unsigned integers. For example, int8 +/// quantized values pack 4 grouped values into a single `u32`. When unpacking these values, +/// we make sure to retrieve only the meaningful values (and ignore the alignment padding). +/// 2) Quantization parameters are appended to the tensor data. +/// As such, the last bytes always correspond to the scale parameter. +/// If the quantization scheme includes an offset (zero-point) parameter, it is next to last. +pub struct QuantizedBytes { + /// The quantized values and quantization parameters represented as bytes. + pub bytes: Bytes, + /// The quantization scheme. + pub scheme: QuantScheme, + /// The number of quantized elements. + pub num_elements: usize, +} + +impl QuantizedBytes { + /// Creates a new quantized bytes representation. + pub fn new( + value: Vec, + scheme: QuantScheme, + scales: &[f32], + ) -> Self { + let num_elements = value.len(); + // Only used for 8-bit quantization data comparison in tests + if TypeId::of::() != TypeId::of::() { + panic!("Invalid quantized type"); + } + + // Re-interpret `Vec` as `Vec` with `Vec::from_raw_parts` + let i8s: Vec = bytemuck::allocation::cast_vec(value); + let mut bytes = Bytes::from_elems(i8s); + + let scales = match scheme.level { + QuantLevel::Tensor => &scales[..1], + QuantLevel::Block(_block_size) => scales, + }; + let scale_bytes = encode_scales(scales, scheme.param); + bytes.extend_from_byte_slice_aligned(scale_bytes.as_slice(), QPARAM_ALIGN); + + Self { + bytes, + scheme, + num_elements, + } + } + + /// Returns the int8 quantized values with the quantization parameters. + pub fn into_vec_i8(self) -> (Vec, QParams>) { + let param = self.scheme.param; + let (values, (qparams, num_params)) = self.split_values_off(); + + // Quantization parameters are added at the end of the tensor data. + // As such, the last bytes always correspond to the scale parameter(s), + // stored at the scheme's param dtype. For example, per-block + // quantization can have multiple parameters for a single tensor: + // [scale, scale, scale, ...] + let scales_size = scale_size(param) * num_params; + let scales = decode_scales(&qparams[qparams.len() - scales_size..], param); + + (values, QParams { scales }) + } + + fn split_i8_values(self, scale_bytes: usize) -> (Vec, Vec) { + let mut values = read_bytes_to_i8(self.bytes); + + let values_end = values.len() - scale_bytes; + let qparams = values.split_off(values_end); + + (values, bytemuck::cast_vec(qparams)) + } + + /// Splits the quantized values of the tensor from the quantization parameters. + /// + /// Returns the values in i8 and a newly allocated vector containing the + /// quantization parameter bytes. + fn split_values_off(self) -> (Vec, (Vec, usize)) { + let num_params = match self.scheme.level { + QuantLevel::Tensor => 1, + QuantLevel::Block(block_size) => self.num_elements / block_size.num_elements(), + }; + let scale_bytes = scale_size(self.scheme.param) * num_params; + + if let QuantStore::PackedU32(packed_dim) = self.scheme.store { + assert_eq!( + packed_dim, 0, + "Packing must be on innermost dimension for splitting off values" + ); + } + + let (values, qparams) = match self.scheme.store { + QuantStore::Native => self.split_i8_values(scale_bytes), + QuantStore::PackedU32(_) => match self.scheme.value { + QuantValue::Q8F | QuantValue::Q8S => self.split_i8_values(scale_bytes), + QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S => { + let split_at = self.bytes.len() - scale_bytes; + let qparams = self.bytes[split_at..].to_vec(); + let values = bytemuck::cast_slice::<_, u32>(&self.bytes[..split_at]); + // Sub-byte values are unpacked as i8s for value equality tests + let values = unpack_q_to_i8s(values, self.num_elements, &self.scheme.value); + (values, qparams) + } + QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => { + unimplemented!("Not yet supported") + } + }, + QuantStore::PackedNative(_) => unimplemented!("Not yet supported"), + }; + + (values, (qparams, num_params)) + } +} + +/// Bytes per stored scale entry for the given param dtype. +fn scale_size(param: QuantParam) -> usize { + match param { + QuantParam::F32 => 4, + QuantParam::F16 | QuantParam::BF16 => 2, + QuantParam::UE8M0 | QuantParam::UE4M3 => 1, + } +} + +/// Decode stored scale entries into f32. +fn decode_scales(bytes: &[u8], param: QuantParam) -> Vec { + match param { + QuantParam::F32 => bytes + .chunks_exact(4) + .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])) + .collect(), + QuantParam::F16 => bytes + .chunks_exact(2) + .map(|c| crate::f16::from_ne_bytes([c[0], c[1]]).to_f32()) + .collect(), + QuantParam::BF16 => bytes + .chunks_exact(2) + .map(|c| crate::bf16::from_ne_bytes([c[0], c[1]]).to_f32()) + .collect(), + QuantParam::UE8M0 | QuantParam::UE4M3 => unimplemented!("Not yet supported"), + } +} + +/// Encode f32 scales at the param dtype for serialization. +fn encode_scales(scales: &[f32], param: QuantParam) -> Vec { + match param { + QuantParam::F32 => scales.iter().flat_map(|s| s.to_ne_bytes()).collect(), + QuantParam::F16 => scales + .iter() + .flat_map(|s| crate::f16::from_f32(*s).to_ne_bytes()) + .collect(), + QuantParam::BF16 => scales + .iter() + .flat_map(|s| crate::bf16::from_f32(*s).to_ne_bytes()) + .collect(), + QuantParam::UE8M0 | QuantParam::UE4M3 => unimplemented!("Not yet supported"), + } +} + +fn read_bytes_to_i8(bytes: Bytes) -> Vec { + match bytes.try_into_vec::() { + Ok(val) => val, + // Safety, + // + // `Vec` can be Re-interpreted as `Vec` since they share the same alignment. + Err(bytes) => unsafe { core::mem::transmute::, Vec>(bytes.to_vec()) }, + } +} + +/// Pack signed 8-bit integer values into a sequence of unsigned 32-bit integers. +pub fn pack_i8s_to_u32s(values: Vec) -> Vec { + // Shift and combine groups of four 8-bit values into a u32. + // Same as doing this: + // let result = (d_u8 & 0xFF) << 24 | (c_u8 & 0xFF) << 16 | (b_u8 & 0xFF) << 8 | (a_u8 & 0xFF); + #[cfg(target_endian = "big")] + { + values + .chunks(4) + .map(|x| { + x.iter() + .enumerate() + .fold(0u32, |acc, (i, x)| acc | (*x as u32 & 0xFF) << (i * 8)) + }) + .collect() + } + + // The order of bytes in little endian matches the above description, we just need to + // handle padding when the number of values is not a factor of 4 + #[cfg(target_endian = "little")] + { + let mut values = values; + let remainder = values.len() % 4; + if remainder != 0 { + // Pad with zeros + values.extend(core::iter::repeat_n(0, 4 - remainder)); + } + + let len = values.len() / 4; + let capacity = values.capacity() / 4; + + // Pre-forget the old vec and re-interpret as u32 + let mut values = core::mem::ManuallyDrop::new(values); + let ptr = values.as_mut_ptr() as *mut u32; + + unsafe { Vec::from_raw_parts(ptr, len, capacity) } + } +} + +/// Unpack integer values into a sequence of signed 8-bit integers. +pub(crate) fn unpack_q_to_i8s( + values: &[Q], + numel: usize, + value: &QuantValue, +) -> Vec { + let size_store = size_of::() * 8; + let size_quant = value.size_bits(); + let num_quants = size_store / size_quant; + let mask = Q::from((1 << size_quant) - 1).unwrap(); + let sign_shift = 8 - size_quant; // sign extension for sub-byte values + values + .iter() + .enumerate() + .flat_map(|(i, &packed)| { + // A single u32 could contain less than four 8-bit values... + let n = core::cmp::min(num_quants, numel - i * num_quants); + // Extract each 8-bit segment from u32 and cast back to i8 + // Same as doing this (when 4 values are fully packed): + // let a = (packed & 0xFF) as i8; + // let b = ((packed >> 8) & 0xFF) as i8; + // let c = ((packed >> 16) & 0xFF) as i8; + // let d = ((packed >> 24) & 0xFF) as i8; + (0..n).map(move |i| { + let raw = (packed >> (i * size_quant) & mask).to_u8().unwrap(); + ((raw << sign_shift) as i8) >> sign_shift + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloc::vec; + + #[test] + fn should_pack_i8s_to_u32() { + let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127]); + + assert_eq!(packed, vec![2147287680]); + } + + #[test] + fn should_pack_i8s_to_u32_padded() { + let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55]); + let packed_padded = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55, 0, 0, 0]); + + assert_eq!(packed, vec![2147287680, 55]); + assert_eq!(packed, packed_padded); + } + + #[test] + fn should_unpack_u32s_to_i8s() { + let unpacked = unpack_q_to_i8s(&[2147287680u32], 4, &QuantValue::Q8S); + + assert_eq!(unpacked, vec![-128, 2, -3, 127]); + } + + #[test] + fn should_unpack_u32s_to_i8s_padded() { + let unpacked = unpack_q_to_i8s(&[55u32], 1, &QuantValue::Q8S); + + assert_eq!(unpacked, vec![55]); + } + + #[test] + fn should_unpack_u32s_to_i8s_arange() { + let unpacked = unpack_q_to_i8s( + &[ + 0u32, 286331136, 286331153, 572657937, 572662306, 857874978, 858993459, 858993459, + 1145324612, 1145324612, 1431655748, 1431655765, 1717982549, 1717986918, 2003199590, + 2004318071, + ], + 128, + &QuantValue::Q4S, + ); + + assert_eq!( + unpacked, + vec![ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7 + ] + ); + } + + #[test] + fn should_pack_unpack_quantization_parameters_per_tensor_symmetric() { + // Quantized [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]] + let scale = 0.03937008; + let values = vec![0i8, 25, 51, 76, 102, 127]; + + let q_bytes = QuantizedBytes::new( + values.clone(), + QuantScheme::default() + .with_value(QuantValue::Q8S) + .with_store(QuantStore::Native), + &[scale], + ); + + let (q_values, qparams) = q_bytes.into_vec_i8(); + + assert_eq!(qparams.scales, vec![scale]); + + assert_eq!(q_values, values); + } +} diff --git a/crates/burn-std/src/tensor/shape.rs b/crates/burn-std/src/tensor/shape.rs new file mode 100644 index 0000000..12313a9 --- /dev/null +++ b/crates/burn-std/src/tensor/shape.rs @@ -0,0 +1,271 @@ +//! Tensor shape definition. + +use super::{Slice, SliceArg}; +use alloc::vec::Vec; +use core::ops::Range; + +pub use crate::errors::ExpressionError; + +pub use cubecl_zspace::{MetadataError, Shape, SmallVec, calculate_matmul_output, shape}; + +/// Slice-related ops on [`Shape`] +pub trait SliceOps: Sized { + /// Convert shape dimensions to full covering ranges (0..dim) for each dimension. + fn into_ranges(self) -> Vec>; + /// Converts slice arguments into an array of slice specifications for the shape. + /// + /// This method returns an array of `Slice` objects that can be used for slicing operations. + /// The slices are clamped to the shape's dimensions. Similar to `into_ranges()`, but + /// allows custom slice specifications instead of full ranges. + /// For creating complex slice specifications, use the [`s!`] macro. + /// + /// # Arguments + /// + /// * `slices` - An array of slice specifications, where each element can be: + /// - A range (e.g., `2..5`) + /// - An index + /// - A `Slice` object + /// - The output of the [`s!`] macro for advanced slicing + /// + /// # Behavior + /// + /// - Supports partial and full slicing in any number of dimensions. + /// - Missing ranges are treated as full slices if D > D2. + /// - Handles negative indices by wrapping around from the end of the dimension. + /// - Clamps ranges to the shape's dimensions if they exceed the bounds. + /// + /// # Returns + /// + /// An array of `Slice` objects corresponding to the provided slice specifications, + /// clamped to the shape's actual dimensions. + /// + /// # Examples + /// + /// ```rust + /// use burn_std::{Shape, Slice, s, SliceOps}; + /// + /// fn example() { + /// // 1D slicing + /// let slices = Shape::new([4]).into_slices(1..4); + /// assert_eq!(slices[0].to_range(4), 1..3); + /// + /// // 2D slicing + /// let slices = Shape::new([3, 4]).into_slices(s![1..4, 0..2]); + /// assert_eq!(slices[0].to_range(3), 1..3); + /// assert_eq!(slices[1].to_range(4), 0..2); + /// + /// // Using negative indices + /// let slices = Shape::new([3]).into_slices(..-2); + /// assert_eq!(slices[0].to_range(3), 0..1); + /// + /// // Using the slice macro to select different ranges + /// let slices = Shape::new([2, 3, 4]).into_slices(s![.., 1..-1]); + /// assert_eq!(slices[0].to_range(2), 0..2); + /// assert_eq!(slices[1].to_range(3), 1..2); + /// } + /// ``` + /// + /// # See Also + /// + /// - [`s!`] - The recommended macro for creating slice specifications + /// - [`Shape::into_ranges`] - Convert to full covering ranges + /// + /// [`s!`]: crate::s! + fn into_slices(self, slices: S) -> Vec + where + S: SliceArg; + /// Compute the output shape from the given slices. + fn slice(self, slices: &[Slice]) -> Result; +} + +impl SliceOps for Shape { + fn into_ranges(self) -> Vec> { + self.iter().map(|&d| 0..d).collect() + } + + fn into_slices(self, slices: S) -> Vec + where + S: SliceArg, + { + slices.into_slices(&self) + } + + fn slice(mut self, slices: &[Slice]) -> Result { + if slices.len() > self.rank() { + return Err(MetadataError::RankMismatch { + left: self.rank(), + right: slices.len(), + }); + } + + slices + .iter() + .zip(self.iter_mut()) + .for_each(|(slice, dim_size)| *dim_size = slice.output_size(*dim_size)); + + Ok(self) + } +} + +#[cfg(test)] +#[allow(clippy::identity_op, reason = "useful for clarity")] +mod tests { + use super::*; + use crate::s; + use alloc::vec; + + #[test] + fn test_into_ranges() { + let dims = [2, 3, 4, 5]; + let shape = Shape::new(dims); + assert_eq!(shape.into_ranges(), vec![0..2, 0..3, 0..4, 0..5]); + } + + #[allow(clippy::single_range_in_vec_init)] + #[test] + fn test_into_slices() { + let slices = Shape::new([3]).into_slices(1..4); + assert_eq!(slices[0].to_range(3), 1..3); + + let slices = Shape::new([3, 4]).into_slices(s![1..4, 0..2]); + assert_eq!(slices[0].to_range(3), 1..3); + assert_eq!(slices[1].to_range(4), 0..2); + + let slices = Shape::new([3]).into_slices(..-2); + assert_eq!(slices[0].to_range(3), 0..1); + + let slices = Shape::new([2, 3, 4]).into_slices(s![.., 1..-1]); + assert_eq!(slices[0].to_range(2), 0..2); + assert_eq!(slices[1].to_range(3), 1..2); + + let slices = Shape::new([2, 3, 4]).into_slices(s![..20, 2]); + assert_eq!(slices[0].to_range(2), 0..2); + assert_eq!(slices[1].to_range(3), 2..3); + } + + #[test] + fn test_shape_as_slice() { + let dims = [2, 3, 4, 5]; + let shape = Shape::new(dims); + + assert_eq!(shape.as_slice(), dims.as_slice()); + + // Deref coercion + let shape_slice: &[usize] = &shape; + assert_eq!(shape_slice, *&[2, 3, 4, 5]); + } + + #[test] + fn test_shape_as_mut_slice() { + let mut dims = [2, 3, 4, 5]; + let mut shape = Shape::new(dims); + + let shape_mut = shape.as_mut_slice(); + assert_eq!(shape_mut, dims.as_mut_slice()); + shape_mut[1] = 6; + + assert_eq!(shape_mut, &[2, 6, 4, 5]); + + let mut shape = Shape::new(dims); + let shape = &mut shape[..]; + shape[1] = 6; + + assert_eq!(shape, shape_mut) + } + + #[test] + fn test_shape_slice_output_shape_basic() { + // Test basic slicing with step=1 + let slices = [ + Slice::new(0, Some(5), 1), // 5 elements + Slice::new(2, Some(8), 1), // 6 elements + ]; + let original_shape = Shape::new([10, 10, 10]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([5, 6, 10])); + } + + #[test] + fn test_shape_slice_output_shape_with_positive_steps() { + // Test slicing with various positive steps + let slices = [ + Slice::new(0, Some(10), 2), // [0,2,4,6,8] -> 5 elements + Slice::new(1, Some(9), 3), // [1,4,7] -> 3 elements + Slice::new(0, Some(7), 4), // [0,4] -> 2 elements + ]; + let original_shape = Shape::new([20, 20, 20, 30]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([5, 3, 2, 30])); + } + + #[test] + fn test_shape_slice_output_shape_with_negative_steps() { + // Test slicing with negative steps (backward iteration) + let slices = [ + Slice::new(0, Some(10), -1), // 10 elements traversed backward + Slice::new(2, Some(8), -2), // [7,5,3] -> 3 elements + ]; + let original_shape = Shape::new([20, 20, 20]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([10, 3, 20])); + } + + #[test] + fn test_shape_slice_output_shape_mixed_steps() { + // Test with a mix of positive, negative, and unit steps + let slices = [ + Slice::from_range_stepped(1..6, 1), // 5 elements + Slice::from_range_stepped(0..10, -3), // [9,6,3,0] -> 4 elements + Slice::from_range_stepped(2..14, 4), // [2,6,10] -> 3 elements + ]; + let original_shape = Shape::new([20, 20, 20]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([5, 4, 3])); + } + + #[test] + fn test_shape_slice_output_shape_partial_dims() { + // Test when slices has fewer dimensions than original shape + let slices = [ + Slice::from_range_stepped(2..7, 2), // [2,4,6] -> 3 elements + ]; + let original_shape = Shape::new([10, 20, 30, 40]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([3, 20, 30, 40])); + } + + #[test] + fn test_shape_slice_output_shape_edge_cases() { + // Test edge cases with small ranges and large steps + let slices = [ + Slice::from_range_stepped(0..1, 1), // Single element + Slice::from_range_stepped(0..10, 100), // Step larger than range -> 1 element + Slice::from_range_stepped(5..5, 1), // Empty range -> 0 elements + ]; + let original_shape = Shape::new([10, 20, 30]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([1, 1, 0])); + } + + #[test] + fn test_shape_slice_output_shape_empty() { + // Test with no slice infos (should return original shape) + let slices = []; + let original_shape = Shape::new([10, 20, 30]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([10, 20, 30])); + } + + #[test] + fn test_shape_slice_output_shape_uneven_division() { + // Test cases where range size doesn't divide evenly by step + let slices = [ + Slice::from_range_stepped(0..7, 3), // ceil(7/3) = 3 elements: [0,3,6] + Slice::from_range_stepped(0..11, 4), // ceil(11/4) = 3 elements: [0,4,8] + Slice::from_range_stepped(1..10, 5), // ceil(9/5) = 2 elements: [1,6] + ]; + let original_shape = Shape::new([20, 20, 20]); + let result = original_shape.slice(&slices).unwrap(); + assert_eq!(result, Shape::new([3, 3, 2])); + } +} diff --git a/crates/burn-std/src/tensor/slice.rs b/crates/burn-std/src/tensor/slice.rs new file mode 100644 index 0000000..7a90e44 --- /dev/null +++ b/crates/burn-std/src/tensor/slice.rs @@ -0,0 +1,937 @@ +//! Tensor slice utilities. + +use crate::Shape; +use crate::indexing::AsIndex; +use alloc::format; +use alloc::vec::Vec; +use core::fmt::{Display, Formatter}; +use core::ops::{Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}; +use core::str::FromStr; + +/// Trait for slice arguments that can be converted into an array of slices. +/// This allows the `slice` method to accept both single slices (from `s![..]`) +/// and arrays of slices (from `s![.., ..]` or `[0..5, 1..3]`). +pub trait SliceArg { + /// Convert to an vec of slices with clamping to shape dimensions. + /// + /// Returns a [Slice] for each dimension in `shape`. + fn into_slices(self, shape: &Shape) -> Vec; +} + +impl + Clone> SliceArg for &[S] { + fn into_slices(self, shape: &Shape) -> Vec { + assert!( + self.len() <= shape.num_dims(), + "Too many slices provided for shape, got {} but expected at most {}", + self.len(), + shape.num_dims() + ); + + shape + .iter() + .enumerate() + .map(|(i, dim_size)| { + let slice = if i >= self.len() { + Slice::full() + } else { + self[i].clone().into() + }; + // Apply shape clamping by converting to range and back + let clamped_range = slice.to_range(*dim_size); + Slice::new( + clamped_range.start as isize, + Some(clamped_range.end as isize), + slice.step(), + ) + }) + .collect::>() + } +} + +impl SliceArg for &Vec { + fn into_slices(self, shape: &Shape) -> Vec { + self.as_slice().into_slices(shape) + } +} + +impl SliceArg for [T; R] +where + T: Into + Clone, +{ + fn into_slices(self, shape: &Shape) -> Vec { + self.as_slice().into_slices(shape) + } +} + +impl SliceArg for T +where + T: Into, +{ + fn into_slices(self, shape: &Shape) -> Vec { + let slice: Slice = self.into(); + [slice].as_slice().into_slices(shape) + } +} + +/// Slice argument constructor for tensor indexing. +/// +/// The `s![]` macro is used to create multi-dimensional slice specifications for tensors. +/// It converts various range syntax forms into a `&[Slice]` that can be used with +/// `tensor.slice()` and `tensor.slice_assign()` operations. +/// +/// # Syntax Overview +/// +/// ## Basic Forms +/// +/// * **`s![index]`** - Index a single element (produces a subview with that axis removed) +/// * **`s![range]`** - Slice a range of elements +/// * **`s![range;step]`** - Slice a range with a custom step +/// * **`s![dim1, dim2, ...]`** - Multiple dimensions, each can be any of the above forms +/// +/// ## Range Types +/// +/// All standard Rust range types are supported: +/// * **`a..b`** - From `a` (inclusive) to `b` (exclusive) +/// * **`a..=b`** - From `a` to `b` (both inclusive) +/// * **`a..`** - From `a` to the end +/// * **`..b`** - From the beginning to `b` (exclusive) +/// * **`..=b`** - From the beginning to `b` (inclusive) +/// * **`..`** - The full range (all elements) +/// +/// ## Negative Indices +/// +/// Negative indices count from the end of the axis: +/// * **`-1`** refers to the last element +/// * **`-2`** refers to the second-to-last element +/// * And so on... +/// +/// This works in all range forms: `s![-3..-1]`, `s![-2..]`, `s![..-1]` +/// +/// ## Step Syntax +/// +/// Steps control the stride between selected elements: +/// * **`;step`** after a range specifies the step +/// * **Positive steps** select every nth element going forward +/// * **Negative steps** select every nth element going backward +/// * Default step is `1` when not specified +/// * Step cannot be `0` +/// +/// ### Negative Step Behavior +/// +/// With negative steps, the range bounds still specify *which* elements to include, +/// but the traversal order is reversed: +/// +/// * `s![0..5;-1]` selects indices `[4, 3, 2, 1, 0]` (not `[0, 1, 2, 3, 4]`) +/// * `s![2..8;-2]` selects indices `[7, 5, 3]` (starting from 7, going backward by 2) +/// * `s![..;-1]` reverses the entire axis +/// +/// This matches the semantics of NumPy and the ndarray crate. +/// +/// # Examples +/// +/// ## Basic Slicing +/// +/// ```rust,ignore +/// use burn_tensor::{Tensor, s}; +/// +/// # fn example(tensor: Tensor) { +/// // Select rows 0-5 (exclusive) +/// let subset = tensor.slice(s![0..5, .., ..]); +/// +/// // Select the last row +/// let last_row = tensor.slice(s![-1, .., ..]); +/// +/// // Select columns 2, 3, 4 +/// let cols = tensor.slice(s![.., 2..5, ..]); +/// +/// // Select a single element at position [1, 2, 3] +/// let element = tensor.slice(s![1, 2, 3]); +/// # } +/// ``` +/// +/// ## Slicing with Steps +/// +/// ```rust,ignore +/// use burn_tensor::{Tensor, s}; +/// +/// # fn example(tensor: Tensor) { +/// // Select every 2nd row +/// let even_rows = tensor.slice(s![0..10;2, ..]); +/// +/// // Select every 3rd column +/// let cols = tensor.slice(s![.., 0..9;3]); +/// +/// // Select every 2nd element in reverse order +/// let reversed_even = tensor.slice(s![10..0;-2, ..]); +/// # } +/// ``` +/// +/// ## Reversing Dimensions +/// +/// ```rust,ignore +/// use burn_tensor::{Tensor, s}; +/// +/// # fn example(tensor: Tensor) { +/// // Reverse the first dimension +/// let reversed = tensor.slice(s![..;-1, ..]); +/// +/// // Reverse both dimensions +/// let fully_reversed = tensor.slice(s![..;-1, ..;-1]); +/// +/// // Reverse a specific range +/// let range_reversed = tensor.slice(s![2..8;-1, ..]); +/// # } +/// ``` +/// +/// ## Complex Multi-dimensional Slicing +/// +/// ```rust,ignore +/// use burn_tensor::{Tensor, s}; +/// +/// # fn example(tensor: Tensor) { +/// // Mix of different slice types +/// let complex = tensor.slice(s![ +/// 0..10;2, // Every 2nd element from 0 to 10 +/// .., // All elements in dimension 1 +/// 5..15;-3, // Every 3rd element from 14 down to 5 +/// -1 // Last element in dimension 3 +/// ]); +/// +/// // Using inclusive ranges +/// let inclusive = tensor.slice(s![2..=5, 1..=3, .., ..]); +/// +/// // Negative indices with steps +/// let from_end = tensor.slice(s![-5..-1;2, .., .., ..]); +/// # } +/// ``` +/// +/// ## Slice Assignment +/// +/// ```rust,ignore +/// use burn_tensor::{Tensor, s}; +/// +/// # fn example(tensor: Tensor, values: Tensor) { +/// // Assign to every 2nd row +/// let tensor = tensor.slice_assign(s![0..10;2, ..], values); +/// +/// // Assign to a reversed slice +/// let tensor = tensor.slice_assign(s![..;-1, 0..5], values); +/// # } +/// ``` +#[macro_export] +macro_rules! s { + // Empty - should not happen + [] => { + compile_error!("Empty slice specification") + }; + + // Single expression with step + [$range:expr; $step:expr] => { + { + #[allow(clippy::reversed_empty_ranges)] + { + $crate::tensor::Slice::from_range_stepped($range, $step) + } + } + }; + + // Single expression without step (no comma after) + [$range:expr] => { + { + #[allow(clippy::reversed_empty_ranges)] + { + $crate::tensor::Slice::from($range) + } + } + }; + + // Two or more expressions with first having step + [$range:expr; $step:expr, $($rest:tt)*] => { + { + #[allow(clippy::reversed_empty_ranges)] + { + $crate::s!(@internal [$crate::tensor::Slice::from_range_stepped($range, $step)] $($rest)*) + } + } + }; + + // Two or more expressions with first not having step + [$range:expr, $($rest:tt)*] => { + { + #[allow(clippy::reversed_empty_ranges)] + { + $crate::s!(@internal [$crate::tensor::Slice::from($range)] $($rest)*) + } + } + }; + + // Internal: finished parsing + (@internal [$($acc:expr),*]) => { + [$($acc),*] + }; + + // Internal: parse range with step followed by comma + (@internal [$($acc:expr),*] $range:expr; $step:expr, $($rest:tt)*) => { + $crate::s!(@internal [$($acc,)* $crate::tensor::Slice::from_range_stepped($range, $step as isize)] $($rest)*) + }; + + // Internal: parse range with step at end + (@internal [$($acc:expr),*] $range:expr; $step:expr) => { + $crate::s!(@internal [$($acc,)* $crate::tensor::Slice::from_range_stepped($range, $step as isize)]) + }; + + // Internal: parse range without step followed by comma + (@internal [$($acc:expr),*] $range:expr, $($rest:tt)*) => { + $crate::s!(@internal [$($acc,)* $crate::tensor::Slice::from($range)] $($rest)*) + }; + + // Internal: parse range without step at end + (@internal [$($acc:expr),*] $range:expr) => { + $crate::s!(@internal [$($acc,)* $crate::tensor::Slice::from($range)]) + }; +} + +/// A slice specification for a single tensor dimension. +/// +/// This struct represents a range with an optional step, used for advanced indexing +/// operations on tensors. It is typically created using the [`s!`] macro rather than +/// constructed directly. +/// +/// # Fields +/// +/// * `start` - The starting index (inclusive). Negative values count from the end. +/// * `end` - The ending index (exclusive). `None` means to the end of the dimension. +/// * `step` - The stride between elements. Must be non-zero. +/// +/// # Index Interpretation +/// +/// - **Positive indices**: Count from the beginning (0-based) +/// - **Negative indices**: Count from the end (-1 is the last element) +/// - **Bounds checking**: Indices are clamped to valid ranges +/// +/// # Step Behavior +/// +/// - **Positive step**: Traverse forward through the range +/// - **Negative step**: Traverse backward through the range +/// - **Step size**: Determines how many elements to skip +/// +/// # Examples +/// +/// While you typically use the [`s!`] macro, you can also construct slices directly: +/// +/// ```rust,ignore +/// use burn_tensor::Slice; +/// +/// // Equivalent to s![2..8] +/// let slice1 = Slice::new(2, Some(8), 1); +/// +/// // Equivalent to s![0..10;2] +/// let slice2 = Slice::new(0, Some(10), 2); +/// +/// // Equivalent to s![..;-1] (reverse) +/// let slice3 = Slice::new(0, None, -1); +/// ``` +/// +/// See also the [`s!`] macro for the preferred way to create slices. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Slice { + /// Slice start index. + pub start: isize, + /// Slice end index (exclusive). + pub end: Option, + /// Step between elements (default: 1). + pub step: isize, +} + +/// Defines an [`Iterator`] over a [`Slice`]. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SliceIter { + slice: Slice, + current: isize, +} + +impl Iterator for SliceIter { + type Item = isize; + + fn next(&mut self) -> Option { + let next = self.current; + self.current += self.slice.step; + + if let Some(end) = self.slice.end { + if self.slice.is_reversed() { + if next <= end { + return None; + } + } else if next >= end { + return None; + } + } + + Some(next) + } +} + +/// Note: Unbounded [`Slice`]s produce infinite iterators. +impl IntoIterator for Slice { + type Item = isize; + type IntoIter = SliceIter; + + fn into_iter(self) -> Self::IntoIter { + SliceIter { + slice: self, + current: self.start, + } + } +} + +impl Default for Slice { + fn default() -> Self { + Self::full() + } +} + +impl Slice { + /// Creates a new slice with start, end, and step + pub const fn new(start: isize, end: Option, step: isize) -> Self { + assert!(step != 0, "Step cannot be zero"); + Self { start, end, step } + } + + /// Creates a slice that represents the full range. + pub const fn full() -> Self { + Self::new(0, None, 1) + } + + /// Creates a slice that represents a single index + pub fn index(idx: isize) -> Self { + Self { + start: idx, + end: handle_signed_inclusive_end(idx), + step: 1, + } + } + + /// Converts the slice to a vector. + pub fn into_vec(self) -> Vec { + assert!( + self.end.is_some(), + "Slice must have an end to convert to a vector: {self:?}" + ); + self.into_iter().collect() + } + + /// Clips the slice to a maximum size. + /// + /// # Example + /// + /// ```rust,ignore + /// assert_eq!( + /// Slice::new(0, None, 1).bound_to(10), + /// Slice::new(0, Some(10), 1)); + /// assert_eq!( + /// Slice::new(0, Some(5), 1).bound_to(10), + /// Slice::new(0, Some(5), 1)); + /// assert_eq!( + /// Slice::new(0, None, -1).bound_to(10), + /// Slice::new(0, Some(-11), -1)); + /// assert_eq!( + /// Slice::new(0, Some(-5), -1).bound_to(10), + /// Slice::new(0, Some(-5), -1)); + /// ``` + pub fn bound_to(self, size: usize) -> Self { + let mut bounds = size as isize; + + if let Some(end) = self.end { + if end > 0 { + bounds = end.min(bounds); + } else { + bounds = end.max(-(bounds + 1)); + } + } else if self.is_reversed() { + bounds = -(bounds + 1); + } + + Self { + end: Some(bounds), + ..self + } + } + + /// Creates a slice with a custom step + pub fn with_step(start: isize, end: Option, step: isize) -> Self { + assert!(step != 0, "Step cannot be zero"); + Self { start, end, step } + } + + /// Creates a slice from a range with a specified step + pub fn from_range_stepped>(range: R, step: isize) -> Self { + assert!(step != 0, "Step cannot be zero"); + let mut slice = range.into(); + slice.step = step; + slice + } + + /// Returns the step of the slice + pub fn step(&self) -> isize { + self.step + } + + /// Returns the range for this slice given a dimension size + pub fn range(&self, size: usize) -> Range { + self.to_range(size) + } + + /// Convert this slice to a range for a dimension of the given size. + /// + /// # Arguments + /// + /// * `size` - The size of the dimension to slice. + /// + /// # Returns + /// + /// A `Range` representing the slice bounds. + pub fn to_range(&self, size: usize) -> Range { + // Always return a valid range with start <= end + // The step information will be handled separately + let start = convert_signed_index(self.start, size); + let end = match self.end { + Some(end) => convert_signed_index(end, size), + None => size, + }; + start..end + } + + /// Converts the slice into a range and step tuple + pub fn to_range_and_step(&self, size: usize) -> (Range, isize) { + let range = self.to_range(size); + (range, self.step) + } + + /// Returns true if the step is negative + pub fn is_reversed(&self) -> bool { + self.step < 0 + } + + /// Calculates the output size for this slice operation + pub fn output_size(&self, dim_size: usize) -> usize { + let range = self.to_range(dim_size); + // Handle empty slices (start >= end) + if range.start >= range.end { + return 0; + } + let len = range.end - range.start; + if self.step.unsigned_abs() == 1 { + len + } else { + len.div_ceil(self.step.unsigned_abs()) + } + } +} + +fn convert_signed_index(index: isize, size: usize) -> usize { + if index < 0 { + (size as isize + index).max(0) as usize + } else { + (index as usize).min(size) + } +} + +fn handle_signed_inclusive_end(end: isize) -> Option { + match end { + -1 => None, + end => Some(end + 1), + } +} + +impl From> for Slice { + fn from(r: Range) -> Self { + Self { + start: r.start.as_index(), + end: Some(r.end.as_index()), + step: 1, + } + } +} + +impl From> for Slice { + fn from(r: RangeInclusive) -> Self { + Self { + start: r.start().as_index(), + end: handle_signed_inclusive_end(r.end().as_index()), + step: 1, + } + } +} + +impl From> for Slice { + fn from(r: RangeFrom) -> Self { + Self { + start: r.start.as_index(), + end: None, + step: 1, + } + } +} + +impl From> for Slice { + fn from(r: RangeTo) -> Self { + Self { + start: 0, + end: Some(r.end.as_index()), + step: 1, + } + } +} + +impl From> for Slice { + fn from(r: RangeToInclusive) -> Self { + Self { + start: 0, + end: handle_signed_inclusive_end(r.end.as_index()), + step: 1, + } + } +} + +impl From for Slice { + fn from(_: RangeFull) -> Self { + Self { + start: 0, + end: None, + step: 1, + } + } +} + +impl From for Slice { + fn from(i: usize) -> Self { + Slice::index(i as isize) + } +} + +impl From for Slice { + fn from(i: isize) -> Self { + Slice::index(i) + } +} + +impl From for Slice { + fn from(i: i32) -> Self { + Slice::index(i as isize) + } +} + +impl From for Slice { + fn from(i: i64) -> Self { + Slice::index(i as isize) + } +} + +impl Display for Slice { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + if self.step == 1 + && let Some(end) = self.end + && self.start == end - 1 + { + f.write_fmt(format_args!("{}", self.start)) + } else { + if self.start != 0 { + f.write_fmt(format_args!("{}", self.start))?; + } + f.write_str("..")?; + if let Some(end) = self.end { + f.write_fmt(format_args!("{}", end))?; + } + if self.step != 1 { + f.write_fmt(format_args!(";{}", self.step))?; + } + Ok(()) + } + } +} + +impl FromStr for Slice { + type Err = crate::ExpressionError; + + fn from_str(source: &str) -> Result { + let mut s = source.trim(); + + let parse_int = |v: &str| -> Result { + v.parse::().map_err(|e| { + crate::ExpressionError::parse_error( + format!("Invalid integer: '{v}': {}", e), + source, + ) + }) + }; + + let mut start: isize = 0; + let mut end: Option = None; + let mut step: isize = 1; + + if let Some((head, tail)) = s.split_once(";") { + step = parse_int(tail)?; + s = head; + } + + if s.is_empty() { + return Err(crate::ExpressionError::parse_error( + "Empty expression", + source, + )); + } + + if let Some((start_s, end_s)) = s.split_once("..") { + if !start_s.is_empty() { + start = parse_int(start_s)?; + } + if !end_s.is_empty() { + if let Some(end_s) = end_s.strip_prefix('=') { + end = Some(parse_int(end_s)? + 1); + } else { + end = Some(parse_int(end_s)?); + } + } + } else { + start = parse_int(s)?; + end = Some(start + 1); + } + + if step == 0 { + return Err(crate::ExpressionError::invalid_expression( + "Step cannot be zero", + source, + )); + } + + Ok(Slice::new(start, end, step)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_slice_to_str() { + assert_eq!(Slice::new(0, None, 1).to_string(), ".."); + + assert_eq!(Slice::new(0, Some(1), 1).to_string(), "0"); + + assert_eq!(Slice::new(0, Some(10), 1).to_string(), "..10"); + assert_eq!(Slice::new(1, Some(10), 1).to_string(), "1..10"); + + assert_eq!(Slice::new(-3, Some(10), -2).to_string(), "-3..10;-2"); + } + + #[test] + fn test_slice_from_str() { + assert_eq!("1".parse::(), Ok(Slice::new(1, Some(2), 1))); + assert_eq!("..".parse::(), Ok(Slice::new(0, None, 1))); + assert_eq!("..3".parse::(), Ok(Slice::new(0, Some(3), 1))); + assert_eq!("..=3".parse::(), Ok(Slice::new(0, Some(4), 1))); + + assert_eq!("-12..3".parse::(), Ok(Slice::new(-12, Some(3), 1))); + assert_eq!("..;-1".parse::(), Ok(Slice::new(0, None, -1))); + + assert_eq!("..=3;-2".parse::(), Ok(Slice::new(0, Some(4), -2))); + + assert_eq!( + "..;0".parse::(), + Err(crate::ExpressionError::invalid_expression( + "Step cannot be zero", + "..;0" + )) + ); + + assert_eq!( + "".parse::(), + Err(crate::ExpressionError::parse_error("Empty expression", "")) + ); + assert_eq!( + "a".parse::(), + Err(crate::ExpressionError::parse_error( + "Invalid integer: 'a': invalid digit found in string", + "a" + )) + ); + assert_eq!( + "..a".parse::(), + Err(crate::ExpressionError::parse_error( + "Invalid integer: 'a': invalid digit found in string", + "..a" + )) + ); + assert_eq!( + "a:b:c".parse::(), + Err(crate::ExpressionError::parse_error( + "Invalid integer: 'a:b:c': invalid digit found in string", + "a:b:c" + )) + ); + } + + #[test] + fn test_slice_output_size() { + // Test the output_size method directly + assert_eq!(Slice::new(0, Some(10), 1).output_size(10), 10); + assert_eq!(Slice::new(0, Some(10), 2).output_size(10), 5); + assert_eq!(Slice::new(0, Some(10), 3).output_size(10), 4); // ceil(10/3) + assert_eq!(Slice::new(0, Some(10), -1).output_size(10), 10); + assert_eq!(Slice::new(0, Some(10), -2).output_size(10), 5); + assert_eq!(Slice::new(2, Some(8), -3).output_size(10), 2); // ceil(6/3) + assert_eq!(Slice::new(5, Some(5), 1).output_size(10), 0); // empty range + } + + #[test] + fn test_bound_to() { + assert_eq!( + Slice::new(0, None, 1).bound_to(10), + Slice::new(0, Some(10), 1) + ); + assert_eq!( + Slice::new(0, Some(5), 1).bound_to(10), + Slice::new(0, Some(5), 1) + ); + + assert_eq!( + Slice::new(0, None, -1).bound_to(10), + Slice::new(0, Some(-11), -1) + ); + assert_eq!( + Slice::new(0, Some(-5), -1).bound_to(10), + Slice::new(0, Some(-5), -1) + ); + } + + #[test] + fn test_slice_iter() { + assert_eq!( + Slice::new(2, Some(3), 1).into_iter().collect::>(), + vec![2] + ); + assert_eq!( + Slice::new(3, Some(-1), -1).into_iter().collect::>(), + vec![3, 2, 1, 0] + ); + + assert_eq!(Slice::new(3, Some(-1), -1).into_vec(), vec![3, 2, 1, 0]); + + assert_eq!( + Slice::new(3, None, 2) + .into_iter() + .take(3) + .collect::>(), + vec![3, 5, 7] + ); + assert_eq!( + Slice::new(3, None, 2) + .bound_to(8) + .into_iter() + .collect::>(), + vec![3, 5, 7] + ); + } + + #[test] + #[should_panic( + expected = "Slice must have an end to convert to a vector: Slice { start: 0, end: None, step: 1 }" + )] + fn test_unbound_slice_into_vec() { + Slice::new(0, None, 1).into_vec(); + } + + #[test] + fn into_slices_should_return_for_all_shape_dims() { + let slice = s![1]; + let shape = Shape::new([2, 3, 1]); + + let slices = slice.into_slices(&shape); + + assert_eq!(slices.len(), shape.len()); + + assert_eq!(slices[0], Slice::new(1, Some(2), 1)); + assert_eq!(slices[1], Slice::new(0, Some(3), 1)); + assert_eq!(slices[2], Slice::new(0, Some(1), 1)); + + let slice = s![1, 0..2]; + let slices = slice.into_slices(&shape); + + assert_eq!(slices.len(), shape.len()); + + assert_eq!(slices[0], Slice::new(1, Some(2), 1)); + assert_eq!(slices[1], Slice::new(0, Some(2), 1)); + assert_eq!(slices[2], Slice::new(0, Some(1), 1)); + + let slice = s![..]; + let slices = slice.into_slices(&shape); + + assert_eq!(slices.len(), shape.len()); + + assert_eq!(slices[0], Slice::new(0, Some(2), 1)); + assert_eq!(slices[1], Slice::new(0, Some(3), 1)); + assert_eq!(slices[2], Slice::new(0, Some(1), 1)); + } + + #[test] + fn into_slices_all_dimensions() { + let slice = s![1, ..2, ..]; + let shape = Shape::new([2, 3, 1]); + + let slices = slice.into_slices(&shape); + + assert_eq!(slices.len(), shape.len()); + + assert_eq!(slices[0], Slice::new(1, Some(2), 1)); + assert_eq!(slices[1], Slice::new(0, Some(2), 1)); + assert_eq!(slices[2], Slice::new(0, Some(1), 1)); + } + + #[test] + fn into_slices_supports_empty_dimensions() { + let slice = s![.., 1, ..]; + let shape = Shape::new([0, 3, 1]); + + let slices = slice.into_slices(&shape); + + assert_eq!(slices.len(), shape.len()); + + assert_eq!(slices[0], Slice::new(0, Some(0), 1)); + assert_eq!(slices[1], Slice::new(1, Some(2), 1)); + assert_eq!(slices[2], Slice::new(0, Some(1), 1)); + } + + #[test] + #[should_panic = "Too many slices provided for shape"] + fn into_slices_should_match_shape_rank() { + let slice = s![.., 1, ..]; + let shape = Shape::new([3, 1]); + + let _ = slice.into_slices(&shape); + } + + #[test] + fn should_support_const_and_full() { + static SLICES: [Slice; 2] = [Slice::full(), Slice::new(2, None, 1)]; + assert_eq!(SLICES[0], Slice::new(0, None, 1)); + assert_eq!(SLICES[1], Slice::new(2, None, 1)); + } + + #[test] + fn should_support_default() { + assert_eq!(Slice::default(), Slice::new(0, None, 1)); + } + + #[test] + fn should_support_copy() { + let mut slice = Slice::new(1, Some(3), 2); + let slice_copy = slice; + + slice.end = Some(4); + + assert_eq!(slice, Slice::new(1, Some(4), 2)); + assert_eq!(slice_copy, Slice::new(1, Some(3), 2)); + } +} diff --git a/crates/burn-std/tests/config.rs b/crates/burn-std/tests/config.rs new file mode 100644 index 0000000..2d919b7 --- /dev/null +++ b/crates/burn-std/tests/config.rs @@ -0,0 +1,80 @@ +/// Tests for loading `burn.toml` from disk. +/// +/// The `cubecl` feature toggles whether `BurnConfig` has a `cubecl` sub-config. We still +/// want `burn.toml` files that contain `[cubecl.*]` sections to load cleanly either way — +/// serde's default behaviour is to ignore unknown top-level fields, and these tests lock +/// that in. +#[cfg(feature = "std")] +mod config_loading { + use burn_std::config::autodiff::AutodiffLogLevel; + use burn_std::config::fusion::FusionLogLevel; + use burn_std::config::{BurnConfig, RuntimeConfig}; + use std::io::Write; + use tempfile::NamedTempFile; + + const EMPTY_TOML: &str = ""; + + const MINIMAL_TOML: &str = r#" +[fusion.beam_search] +max_blocks = 3 +"#; + + const FULL_TOML: &str = r#" +[fusion.beam_search] +max_blocks = 10 + +[fusion.logger] +level = "medium" +stdout = true + +[autodiff.logger] +level = "basic" + +[cubecl.autotune] +level = "full" + +[cubecl.compilation.logger] +level = "full" +stdout = true +"#; + + fn write_toml(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("create temp toml"); + file.write_all(content.as_bytes()).expect("write toml"); + file.flush().expect("flush toml"); + file + } + + #[test] + fn load_empty_toml_uses_defaults() { + let file = write_toml(EMPTY_TOML); + let config = BurnConfig::from_file_path(file.path()).expect("parse empty toml"); + + assert_eq!(config.fusion().beam_search.max_blocks, 5); + assert_eq!(config.fusion().logger.level, FusionLogLevel::Disabled); + assert_eq!(config.autodiff().logger.level, AutodiffLogLevel::Disabled); + } + + #[test] + fn load_minimal_toml_overrides_defaults() { + let file = write_toml(MINIMAL_TOML); + let config = BurnConfig::from_file_path(file.path()).expect("parse minimal toml"); + + assert_eq!(config.fusion().beam_search.max_blocks, 3); + // Untouched sections keep defaults. + assert_eq!(config.autodiff().logger.level, AutodiffLogLevel::Disabled); + } + + /// The important case: a single `burn.toml` with both Burn and CubeCL sections must + /// load whether or not the `cubecl` feature is enabled on `burn-std`. + #[test] + fn load_full_toml_ignores_cubecl_when_feature_off() { + let file = write_toml(FULL_TOML); + let config = BurnConfig::from_file_path(file.path()).expect("parse full toml"); + + assert_eq!(config.fusion().beam_search.max_blocks, 10); + assert_eq!(config.fusion().logger.level, FusionLogLevel::Medium); + assert!(config.fusion().logger.stdout); + assert_eq!(config.autodiff().logger.level, AutodiffLogLevel::Basic); + } +} diff --git a/crates/burn-store/Cargo.toml b/crates/burn-store/Cargo.toml new file mode 100644 index 0000000..10a8af0 --- /dev/null +++ b/crates/burn-store/Cargo.toml @@ -0,0 +1,97 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Storage and serialization infrastructure for Burn" +documentation = "https://docs.rs/burn-store" +edition.workspace = true +keywords = [ + "deep-learning", + "machine-learning", + "tensor", + "storage", + "serialization", +] +license.workspace = true +name = "burn-store" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-store" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "pytorch", "safetensors", "burnpack", "memmap"] +memmap = ["std", "dep:memmap2"] +std = [ + "dep:memmap2", + "safetensors/std", + "burn-core/std", + "burn-pack?/std", + "dep:regex", + "byteorder/std", +] + +# The burnpack format lives in the `burn-pack` crate; this enables the BurnpackStore over it. +burnpack = ["dep:burn-pack"] +cuda = ["burn-core/cuda"] +metal = ["wgpu", "burn-core/metal"] +tch = ["burn-core/tch"] +wgpu = ["burn-core/wgpu"] + +safetensors = ["dep:safetensors"] + +pytorch = ["std", "zip", "serde", "tar", "dep:thiserror", "dep:num-traits", "dep:burn-tensor"] + +[dependencies] +burn-core = { workspace = true } +burn-pack = { workspace = true, optional = true } +burn-tensor = { workspace = true, optional = true } + +# External dependencies +byteorder = { workspace = true, default-features = false } +bytes = { workspace = true } +ciborium = { workspace = true, optional = true } +half = { workspace = true } +hashbrown = { workspace = true, features = ["serde"] } +memmap2 = { workspace = true, optional = true } +num-traits = { workspace = true, optional = true } +regex = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +textdistance = { workspace = true } +thiserror = { workspace = true, optional = true } +zip = { workspace = true, optional = true } +tar = { workspace = true, optional = true } + +# Workaround to force broken minor version to update +lzma-rust2 = { workspace = true, optional = true } + +safetensors = { workspace = true, optional = true } + +[dev-dependencies] +# burn-import = { path = "../burn-import", version = "0.22.0" } # disabled (circular dep in publish, only for bench) +burn-core = { workspace = true, features = ["flex"] } +burn-nn = { workspace = true } + +divan = "0.1" +tempfile = { workspace = true } + +[[bench]] +harness = false +name = "resnet18_loading" + +[[bench]] +harness = false +name = "unified_loading" + +[[bench]] +harness = false +name = "unified_saving" + +[[bench]] +harness = false +name = "zero_copy_loading" + +# Enable extra-platforms for bytes on targets without native atomics (e.g., thumbv6m-none-eabi) +[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies] +bytes = { workspace = true, features = ["extra-platforms"] } diff --git a/crates/burn-store/MIGRATION.md b/crates/burn-store/MIGRATION.md new file mode 100644 index 0000000..1d50964 --- /dev/null +++ b/crates/burn-store/MIGRATION.md @@ -0,0 +1,325 @@ +# Migration Guide: burn-import to burn-store + +This guide helps you migrate from the deprecated `burn-import` recorders (`PyTorchFileRecorder`, +`SafetensorsFileRecorder`) to the new `burn-store` API (`PytorchStore`, `SafetensorsStore`). + +## Overview + +The new `burn-store` API provides: + +- **Simpler API**: Load directly into models instead of records +- **Fluent builder pattern**: Chain configuration methods +- **Better error handling**: Detailed load results with applied/missing/errors info +- **Bidirectional support**: Both load and save operations +- **More features**: Filtering, partial loading, metadata, zero-copy loading + +## Quick Migration + +### PyTorch Files (.pt/.pth) + +**Before (burn-import):** + +```rust +use burn::record::{FullPrecisionSettings, Recorder}; +use burn_import::pytorch::{LoadArgs, PyTorchFileRecorder}; + +// Load into a record, then create model from record +let record: ModelRecord = PyTorchFileRecorder::::default() + .load("model.pt".into(), &device) + .expect("Failed to load"); + +let model = Model::init(&device).load_record(record); +``` + +**After (burn-store):** + +```rust +use burn_store::{ModuleSnapshot, PytorchStore}; + +// Initialize model, then load weights directly +let mut model = Model::init(&device); +let mut store = PytorchStore::from_file("model.pt"); +model.load_from(&mut store).expect("Failed to load"); +``` + +### SafeTensors Files (.safetensors) + +**Before (burn-import):** + +```rust +use burn::record::{FullPrecisionSettings, Recorder}; +use burn_import::safetensors::{AdapterType, LoadArgs, SafetensorsFileRecorder}; + +let record: ModelRecord = SafetensorsFileRecorder::::default() + .load("model.safetensors".into(), &device) + .expect("Failed to load"); + +let model = Model::init(&device).load_record(record); +``` + +**After (burn-store):** + +```rust +use burn_store::{ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore}; + +let mut model = Model::init(&device); + +// For SafeTensors exported from PyTorch, use the adapter +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_from_adapter(PyTorchToBurnAdapter); +model.load_from(&mut store).expect("Failed to load"); + +// For native Burn SafeTensors, no adapter needed +let mut store = SafetensorsStore::from_file("model.safetensors"); +model.load_from(&mut store).expect("Failed to load"); +``` + +## API Mapping + +### PyTorchFileRecorder Options + +| burn-import | burn-store | +| ---------------------------------------------- | ------------------------------------------- | +| `LoadArgs::new(path)` | `PytorchStore::from_file(path)` | +| `.with_key_remap(pattern, replacement)` | `.with_key_remapping(pattern, replacement)` | +| `.with_top_level_key(key)` | `.with_top_level_key(key)` | +| `.with_debug_print()` | _(use tracing/logging instead)_ | +| `PyTorchFileRecorder::` | _(precision handled automatically)_ | + +### SafetensorsFileRecorder Options + +| burn-import | burn-store | +| -------------------------------------------------- | ------------------------------------------- | +| `LoadArgs::new(path)` | `SafetensorsStore::from_file(path)` | +| `.with_key_remap(pattern, replacement)` | `.with_key_remapping(pattern, replacement)` | +| `.with_adapter_type(AdapterType::PyTorch)` | `.with_from_adapter(PyTorchToBurnAdapter)` | +| `.with_adapter_type(AdapterType::NoAdapter)` | _(default, no adapter)_ | +| `.with_debug_print()` | _(use tracing/logging instead)_ | +| `SafetensorsFileRecorder::` | _(precision handled automatically)_ | + +## Detailed Examples + +### Key Remapping + +**Before:** + +```rust +let args = LoadArgs::new("model.pt".into()) + .with_key_remap("conv\\.(.*)", "$1") + .with_key_remap("^old_prefix\\.", "new_prefix."); + +let record: ModelRecord = PyTorchFileRecorder::::default() + .load(args, &device)?; +``` + +**After:** + +```rust +let mut store = PytorchStore::from_file("model.pt") + .with_key_remapping("conv\\.(.*)", "$1") + .with_key_remapping("^old_prefix\\.", "new_prefix."); + +model.load_from(&mut store)?; +``` + +### Top-Level Key Access + +**Before:** + +```rust +let args = LoadArgs::new("checkpoint.pt".into()) + .with_top_level_key("state_dict"); + +let record: ModelRecord = PyTorchFileRecorder::::default() + .load(args, &device)?; +``` + +**After:** + +```rust +let mut store = PytorchStore::from_file("checkpoint.pt") + .with_top_level_key("state_dict"); + +model.load_from(&mut store)?; +``` + +### PyTorch Adapter for SafeTensors + +**Before:** + +```rust +use burn_import::safetensors::{AdapterType, LoadArgs}; + +let args = LoadArgs::new("pytorch_model.safetensors".into()) + .with_adapter_type(AdapterType::PyTorch); + +let record: ModelRecord = SafetensorsFileRecorder::::default() + .load(args, &device)?; +``` + +**After:** + +```rust +use burn_store::{PyTorchToBurnAdapter, SafetensorsStore}; + +let mut store = SafetensorsStore::from_file("pytorch_model.safetensors") + .with_from_adapter(PyTorchToBurnAdapter); + +model.load_from(&mut store)?; +``` + +## New Features in burn-store + +### Partial Loading + +Handle missing tensors gracefully: + +```rust +let mut store = PytorchStore::from_file("model.pt") + .allow_partial(true); + +let result = model.load_from(&mut store)?; +println!("Loaded: {:?}", result.applied); +println!("Missing: {:?}", result.missing); +``` + +### Filtering + +Load only specific tensors: + +```rust +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_regex(r"^encoder\..*") // Only encoder layers + .allow_partial(true); + +model.load_from(&mut store)?; +``` + +### Saving Models + +Save models (not supported by old recorders): + +```rust +// Save to SafeTensors +let mut store = SafetensorsStore::from_file("output.safetensors") + .metadata("version", "1.0"); +model.save_into(&mut store)?; + +// Save to Burnpack (native format) +let mut store = BurnpackStore::from_file("output.bpk"); +model.save_into(&mut store)?; +``` + +### Load Results + +Get detailed information about loading: + +```rust +let result = model.load_from(&mut store)?; + +// Print the full result for debugging - shows applied, skipped, missing, and errors +println!("{}", result); + +// Or access individual fields +println!("Applied: {} tensors", result.applied.len()); +println!("Skipped: {} tensors", result.skipped.len()); +println!("Missing: {:?}", result.missing); +println!("Errors: {:?}", result.errors); + +// Check if fully successful +if result.is_success() { + println!("All tensors loaded successfully"); +} +``` + +The `LoadResult` implements `Display`, so printing it shows a formatted summary with suggestions for +common issues (e.g., using `allow_partial(true)` for missing tensors). + +## Updating Cargo.toml + +**Before:** + +```toml +[dependencies] +burn-import = { version = "0.x", features = ["pytorch", "safetensors"] } +``` + +**After:** + +```toml +[dependencies] +burn-store = { version = "0.x", features = ["pytorch", "safetensors"] } +``` + +## Common Migration Issues + +### 1. Model vs Record + +The new API loads directly into models, not records. Update your model initialization: + +```rust +// Before: Create record, then model from record +let record = recorder.load(...)?; +let model = Model::init(&device).load_record(record); + +// After: Create model, then load into it +let mut model = Model::init(&device); +model.load_from(&mut store)?; +``` + +### 2. Inference Functions + +If you had functions that took `ModelRecord`, update them to take `Model`: + +```rust +// Before +fn infer(record: ModelRecord) { + let model = Model::init(&device).load_record(record); + // ... +} + +// After +fn infer(model: Model) { + // Model already has weights loaded + // ... +} +``` + +### 3. Precision Settings + +The old API required explicit precision settings. The new API handles this automatically: + +```rust +// Before: Had to specify FullPrecisionSettings or HalfPrecisionSettings +PyTorchFileRecorder::::default() + +// After: Precision handled automatically based on tensor dtype +PytorchStore::from_file("model.pt") +``` + +### 4. Error Handling + +The new API provides richer error information: + +```rust +// Before: Simple Result +let record = recorder.load(args, &device)?; + +// After: LoadResult with detailed info +let result = model.load_from(&mut store)?; + +// Print the result to see a helpful summary with suggestions +println!("{}", result); + +// Or handle specific issues programmatically +if !result.errors.is_empty() { + for (path, error) in &result.errors { + eprintln!("Error loading {}: {}", path, error); + } +} +``` + +## See Also + +- [burn-store README](README.md) - Full documentation +- [import-model-weights example](../../examples/import-model-weights/) - Working example diff --git a/crates/burn-store/README.md b/crates/burn-store/README.md new file mode 100644 index 0000000..e36d7d8 --- /dev/null +++ b/crates/burn-store/README.md @@ -0,0 +1,90 @@ +# Burn Store + +> Advanced model storage and serialization for the Burn deep learning framework + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-store.svg)](https://crates.io/crates/burn-store) +[![Documentation](https://docs.rs/burn-store/badge.svg)](https://docs.rs/burn-store) + +A comprehensive storage library for Burn that enables efficient model serialization, cross-framework +interoperability, and advanced tensor management. + +> **Migrating from burn-import?** See the [Migration Guide](MIGRATION.md) for help moving from +> `PyTorchFileRecorder`/`SafetensorsFileRecorder` to the new Store API. + +## Features + +- **Burnpack Format** - Native Burn format with CBOR metadata, memory-mapped loading, ParamId + persistence for stateful training, and no-std support +- **SafeTensors Format** - Industry-standard format for secure and efficient tensor serialization +- **PyTorch Support** - Direct loading of PyTorch .pth/.pt files with automatic weight + transformation +- **Zero-Copy Loading** - Memory-mapped files and lazy tensor materialization for optimal + performance +- **Flexible Filtering** - Load/save specific model subsets with regex, exact paths, or custom + predicates +- **Tensor Remapping** - Rename tensors during load/save for framework compatibility +- **Half-Precision Storage** - Automatic F32/F16 conversion with smart defaults for reduced model + file size +- **No-std Support** - Burnpack and SafeTensors formats available in embedded and WASM environments + +## Quick Start + +```rust +use burn_store::{ModuleSnapshot, PytorchStore, SafetensorsStore, BurnpackStore, HalfPrecisionAdapter}; + +// Load from PyTorch +let mut store = PytorchStore::from_file("model.pt"); +model.load_from(&mut store)?; + +// Load from SafeTensors (with PyTorch adapter) +let mut store = SafetensorsStore::from_file("model.safetensors") + .with_from_adapter(PyTorchToBurnAdapter); +model.load_from(&mut store)?; + +// Save to Burnpack +let mut store = BurnpackStore::from_file("model.bpk"); +model.save_into(&mut store)?; + +// Save with half-precision (F32 -> F16, ~50% smaller files) +let adapter = HalfPrecisionAdapter::new(); +let mut store = BurnpackStore::from_file("model_f16.bpk") + .with_to_adapter(adapter.clone()); +model.save_into(&mut store)?; + +// Load half-precision back (F16 -> F32, same adapter) +let mut store = BurnpackStore::from_file("model_f16.bpk") + .with_from_adapter(adapter); +model.load_from(&mut store)?; +``` + +## Documentation + +For comprehensive documentation including: + +- Exporting weights from PyTorch +- Loading weights into Burn models +- Saving models to various formats +- Advanced features (filtering, remapping, partial loading, zero-copy) +- API reference and troubleshooting + +See the **[Burn Book - Saving and Loading](../../burn-book/src/saving-and-loading.md)** chapter. + +## Running Benchmarks + +```bash +# Generate model files (one-time setup) +uv run benches/generate_unified_models.py + +# Run loading benchmarks +cargo bench --bench unified_loading + +# Run saving benchmarks +cargo bench --bench unified_saving + +# With specific backend +cargo bench --bench unified_loading --features metal +``` + +## License + +This project is dual-licensed under MIT and Apache-2.0. diff --git a/crates/burn-store/benches/download_resnet18.py b/crates/burn-store/benches/download_resnet18.py new file mode 100644 index 0000000..6393465 --- /dev/null +++ b/crates/burn-store/benches/download_resnet18.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.8" +# dependencies = [ +# "torch", +# "torchvision", +# ] +# /// +""" +Download ResNet18 PyTorch model for benchmarking. +This script downloads a pre-trained ResNet18 model from PyTorch Hub +and saves it in a format suitable for benchmarking. +""" + +import os +import sys +import tempfile +from pathlib import Path + +import torch +import torchvision.models as models + +def download_resnet18(): + """Download ResNet18 model and save to temp directory.""" + + # Create a temporary directory for the model + temp_dir = Path(tempfile.gettempdir()) / "burn_resnet18_benchmark" + temp_dir.mkdir(parents=True, exist_ok=True) + + output_path = temp_dir / "resnet18.pth" + + # Check if already downloaded + if output_path.exists(): + file_size_mb = output_path.stat().st_size / (1024 * 1024) + print(f"✅ ResNet18 already exists at: {output_path}") + print(f" Size: {file_size_mb:.1f} MB") + return str(output_path) + + print("📥 Downloading ResNet18 model...") + + try: + # Download pre-trained ResNet18 model + model = models.resnet18(pretrained=True) + + # Save the model state dict (this is what burn-store reads) + # Using the legacy format for compatibility + torch.save(model.state_dict(), output_path, _use_new_zipfile_serialization=False) + + file_size_mb = output_path.stat().st_size / (1024 * 1024) + print(f"✅ Successfully downloaded ResNet18 to: {output_path}") + print(f" Size: {file_size_mb:.1f} MB") + print(f" Format: PyTorch legacy format") + + # Verify it's readable + state_dict = torch.load(output_path, map_location='cpu') + print(f" Tensors: {len(state_dict)} tensors") + + # Print a few tensor names and shapes for verification + print("\n Sample tensors:") + for i, (name, tensor) in enumerate(state_dict.items()): + if i < 3: + print(f" - {name}: {list(tensor.shape)}") + + return str(output_path) + + except Exception as e: + print(f"❌ Failed to download ResNet18: {e}") + sys.exit(1) + +def main(): + """Main entry point.""" + path = download_resnet18() + + # Write the path to a file that the benchmark can read + bench_config = Path(tempfile.gettempdir()) / "burn_resnet18_benchmark" / "path.txt" + bench_config.write_text(path) + + print(f"\n💡 Model ready for benchmarking") + print(f" Run: cargo bench --bench resnet18_loading") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/crates/burn-store/benches/generate_unified_models.py b/crates/burn-store/benches/generate_unified_models.py new file mode 100644 index 0000000..6216d22 --- /dev/null +++ b/crates/burn-store/benches/generate_unified_models.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.8" +# dependencies = [ +# "torch", +# "safetensors", +# "packaging", +# "numpy", +# ] +# /// +""" +Generate a large model (~312MB) in both PyTorch and SafeTensors formats for unified benchmarking. + +Usage: + uv run benches/generate_unified_models.py + +The script will create model files in /tmp/simple_bench_models/ directory. +""" + +import torch +import torch.nn as nn +import os +from pathlib import Path +import tempfile +from safetensors.torch import save_file + +def get_temp_dir(): + """Get the appropriate temp directory.""" + temp_dir = Path(tempfile.gettempdir()) / "simple_bench_models" + temp_dir.mkdir(parents=True, exist_ok=True) + return temp_dir + +class LargeModel(nn.Module): + """Large model with 20 layers to match Rust benchmark.""" + def __init__(self): + super().__init__() + self.layers = nn.ModuleList() + + # Create a model with 20 layers matching the Rust LargeModel + for i in range(20): + in_size = 1024 if i == 0 else 2048 + out_size = 2048 + self.layers.append(nn.Linear(in_size, out_size)) + + print(f"Created model with {len(self.layers)} layers") + + def forward(self, x): + for layer in self.layers: + x = layer(x) + return x + +def calculate_model_size(model): + """Calculate the size of the model in MB.""" + total_params = sum(p.numel() for p in model.parameters()) + size_mb = (total_params * 4) / (1024 * 1024) # 4 bytes per float32 + return total_params, size_mb + +def initialize_weights(model): + """Initialize model weights with random values.""" + for param in model.parameters(): + if param.dim() > 1: + nn.init.xavier_uniform_(param) + else: + nn.init.zeros_(param) + +def save_pytorch_format(model, output_dir): + """Save model in PyTorch format.""" + pt_path = output_dir / "large_model.pt" + + # Save as checkpoint with model_state_dict (common format) + checkpoint = { + 'model_state_dict': model.state_dict(), + 'metadata': { + 'model_type': 'large_benchmark_model', + 'num_layers': len(model.layers), + } + } + torch.save(checkpoint, pt_path) + + return pt_path + +def save_safetensors_format(model, output_dir): + """Save model in SafeTensors format.""" + st_path = output_dir / "large_model.safetensors" + + # Convert state dict to safetensors format + state_dict = model.state_dict() + # Ensure all tensors are contiguous and on CPU + state_dict = {k: v.contiguous().cpu() for k, v in state_dict.items()} + + # Save with metadata + metadata = { + 'model_type': 'large_benchmark_model', + 'num_layers': str(len(model.layers)), + } + save_file(state_dict, st_path, metadata=metadata) + + return st_path + +def verify_files(pt_path, st_path): + """Verify the saved files can be loaded.""" + # Verify PyTorch file + checkpoint = torch.load(pt_path, map_location='cpu') + pt_keys = set(checkpoint['model_state_dict'].keys()) + print(f" PyTorch file: {len(pt_keys)} tensors") + + # Verify SafeTensors file + from safetensors import safe_open + with safe_open(st_path, framework="pt", device="cpu") as f: + st_keys = set(f.keys()) + print(f" SafeTensors file: {len(st_keys)} tensors") + + # Check keys match + if pt_keys != st_keys: + print(" ⚠️ Warning: Keys don't match between formats!") + else: + print(" ✓ Keys match between formats") + +def main(): + print("🔧 Generating unified benchmark model files...") + print("") + + output_dir = get_temp_dir() + print(f"📁 Output directory: {output_dir}") + print("") + + # Set random seed for reproducibility + torch.manual_seed(42) + + # Create the large model + print("📝 Creating large model...") + model = LargeModel() + + # Calculate and display model size + total_params, size_mb = calculate_model_size(model) + print(f" Total parameters: {total_params:,}") + print(f" Model size: {size_mb:.2f} MB") + print("") + + # Initialize weights + print("🎲 Initializing weights...") + initialize_weights(model) + + # Save in PyTorch format + print("💾 Saving PyTorch format...") + pt_path = save_pytorch_format(model, output_dir) + pt_size_mb = pt_path.stat().st_size / (1024 * 1024) + print(f" Saved: {pt_path}") + print(f" File size: {pt_size_mb:.2f} MB") + print("") + + # Save in SafeTensors format + print("💾 Saving SafeTensors format...") + st_path = save_safetensors_format(model, output_dir) + st_size_mb = st_path.stat().st_size / (1024 * 1024) + print(f" Saved: {st_path}") + print(f" File size: {st_size_mb:.2f} MB") + print("") + + # Verify files + print("🔍 Verifying saved files...") + verify_files(pt_path, st_path) + print("") + + print(f"✅ Model files generated successfully!") + print("") + print("📊 Summary:") + print(f" PyTorch file: {pt_path.name} ({pt_size_mb:.2f} MB)") + print(f" SafeTensors file: {st_path.name} ({st_size_mb:.2f} MB)") + print("") + print("💡 To run the unified benchmark:") + print(" cargo bench --bench unified_loading") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/crates/burn-store/benches/resnet18_loading.rs b/crates/burn-store/benches/resnet18_loading.rs new file mode 100644 index 0000000..df8604e --- /dev/null +++ b/crates/burn-store/benches/resnet18_loading.rs @@ -0,0 +1,213 @@ +//! Benchmark for ResNet18 loading to verify lazy loading memory usage. +//! +//! resnet18.pth is pytorch's legacy file format. +//! +//! This benchmark loads a ResNet18 model and materializes all tensors +//! to ensure memory usage stays reasonable with lazy loading. +//! +//! Run the benchmark: +//! ```bash +//! cargo bench --bench resnet18_loading +//! ``` + +use burn_store::pytorch::PytorchReader; +use divan::{AllocProfiler, Bencher}; +use std::path::PathBuf; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +#[allow(clippy::manual_range_contains)] +fn main() { + // Check if ResNet18 file exists + let path = resnet18_path(); + if !path.exists() { + eprintln!("❌ ResNet18 model not found!"); + eprintln!(); + eprintln!("Please download it first by running:"); + eprintln!(" python benches/download_resnet18.py"); + eprintln!(); + eprintln!("Or if you don't have Python/PyTorch installed:"); + eprintln!(" uv run benches/download_resnet18.py"); + eprintln!(); + eprintln!("Expected location: {}", path.display()); + std::process::exit(1); + } + + // Verify file size is reasonable + let metadata = std::fs::metadata(&path).expect("Failed to read file metadata"); + let size_mb = metadata.len() as f64 / 1_048_576.0; + + if size_mb < 40.0 || size_mb > 50.0 { + eprintln!( + "⚠️ Warning: ResNet18 file size ({:.1} MB) seems unusual", + size_mb + ); + eprintln!("Expected size is around 45 MB"); + } + + println!("✅ Found ResNet18 model at: {}", path.display()); + println!("📦 File size: {:.1} MB", size_mb); + println!("📊 Running ResNet18 loading benchmarks...\n"); + + // Run divan benchmarks + divan::main(); +} + +/// Get the path to ResNet18 model file +fn resnet18_path() -> PathBuf { + // First try to read from the path file created by download script + let temp_dir = std::env::temp_dir(); + let config_file = temp_dir.join("burn_resnet18_benchmark").join("path.txt"); + + if config_file.exists() + && let Ok(path_str) = std::fs::read_to_string(&config_file) + { + let path = PathBuf::from(path_str.trim()); + if path.exists() { + return path; + } + } + + // Fallback to default location + temp_dir + .join("burn_resnet18_benchmark") + .join("resnet18.pth") +} + +#[divan::bench(sample_count = 10)] +fn load_resnet18_metadata(bencher: Bencher) { + let path = resnet18_path(); + + bencher.bench_local(|| { + let reader = PytorchReader::new(&path).expect("Failed to load ResNet18"); + let metadata = reader.metadata(); + + // Just access metadata without materializing tensors + assert_eq!(metadata.tensor_count, 122); + }); +} + +#[divan::bench(sample_count = 5)] +fn load_resnet18_materialize_all(bencher: Bencher) { + let path = resnet18_path(); + + bencher.bench_local(|| { + let reader = PytorchReader::new(&path).expect("Failed to load ResNet18"); + let keys = reader.keys(); + + let mut total_bytes = 0usize; + + // Materialize all tensors one by one + for key in &keys { + let tensor = reader.get(key).expect("Failed to get tensor"); + // Materialize the tensor data + let _data = tensor.to_data().expect("Failed to materialize tensor data"); + total_bytes += tensor.data_len(); + } + + // Verify we processed all the data + assert!(total_bytes > 40_000_000); // Should be ~45MB + }); +} + +#[divan::bench(sample_count = 5)] +fn load_resnet18_materialize_sequential(bencher: Bencher) { + let path = resnet18_path(); + + bencher.bench_local(|| { + let reader = PytorchReader::new(&path).expect("Failed to load ResNet18"); + let keys = reader.keys(); + + // Materialize tensors one at a time, letting previous ones be dropped + // This simulates processing tensors sequentially without keeping all in memory + for key in &keys { + let tensor = reader.get(key).expect("Failed to get tensor"); + let data = tensor.to_data().expect("Failed to materialize tensor data"); + + // Do minimal work with the data to prevent optimization + let sum = match data.dtype { + burn_core::tensor::DType::F32 => data + .as_slice::() + .map(|s| s.iter().sum::()) + .unwrap_or(0.0) as f64, + burn_core::tensor::DType::F64 => data + .as_slice::() + .map(|s| s.iter().sum::()) + .unwrap_or(0.0), + _ => 0.0, + }; + + // Use the sum to prevent dead code elimination + std::hint::black_box(sum); + } + }); +} + +#[divan::bench(sample_count = 10)] +fn load_resnet18_largest_tensor(bencher: Bencher) { + let path = resnet18_path(); + + bencher.bench_local(|| { + let reader = PytorchReader::new(&path).expect("Failed to load ResNet18"); + + // Find and materialize only the largest tensor + // This tests peak memory for a single tensor operation + let keys = reader.keys(); + let mut largest_key = String::new(); + let mut largest_size = 0usize; + + for key in &keys { + let tensor = reader.get(key).expect("Failed to get tensor"); + let size = tensor.data_len(); + if size > largest_size { + largest_size = size; + largest_key = key.clone(); + } + } + + // Materialize the largest tensor + let tensor = reader + .get(&largest_key) + .expect("Failed to get largest tensor"); + let _data = tensor.to_data().expect("Failed to materialize tensor data"); + + assert!(largest_size > 9_000_000); // Should be ~9MB for layer4.0.conv2.weight + }); +} + +#[divan::bench(sample_count = 10)] +fn load_resnet18_memory_profile(bencher: Bencher) { + let path = resnet18_path(); + + bencher + .with_inputs(|| path.clone()) + .bench_local_values(|path| { + let reader = PytorchReader::new(&path).expect("Failed to load ResNet18"); + let keys = reader.keys(); + + let mut peak_single_tensor = 0usize; + let mut total_data = 0usize; + + // Process each tensor and track memory + for key in &keys { + let tensor = reader.get(key).expect("Failed to get tensor"); + let tensor_size = tensor.data_len(); + + // Track largest single tensor + if tensor_size > peak_single_tensor { + peak_single_tensor = tensor_size; + } + + // Materialize the tensor + let data = tensor.to_data().expect("Failed to materialize tensor data"); + total_data += tensor_size; + + // Drop data immediately to test lazy loading memory efficiency + drop(data); + } + + // Return stats for verification + (peak_single_tensor, total_data) + }); +} diff --git a/crates/burn-store/benches/unified_loading.rs b/crates/burn-store/benches/unified_loading.rs new file mode 100644 index 0000000..60b8efa --- /dev/null +++ b/crates/burn-store/benches/unified_loading.rs @@ -0,0 +1,321 @@ +#![recursion_limit = "256"] + +//! Unified benchmark comparing all loading methods: +//! - BurnpackStore (new native format) +//! - NamedMpkFileRecorder (old native format) +//! - SafetensorsStore (new) +//! - SafetensorsFileRecorder (old) +//! - PytorchStore (new) +//! - PyTorchFileRecorder (old) +//! +//! Before running this benchmark, generate the model files: +//! ```bash +//! cd crates/burn-store +//! uv run benches/generate_unified_models.py +//! ``` +//! +//! Then run the benchmark: +//! ```bash +//! cargo bench --bench unified_loading +//! ``` + +use burn_core as burn; + +use burn_core::module::Module; +use burn_core::prelude::*; +use burn_core::store::ModuleRecord; +// use burn_import::pytorch::{LoadArgs, PyTorchFileRecorder}; +// use burn_import::safetensors::SafetensorsFileRecorder; +use burn_nn as nn; +use burn_store::{ + BurnpackStore, ModuleSnapshot, PyTorchToBurnAdapter, PytorchStore, SafetensorsStore, +}; +use divan::{AllocProfiler, Bencher}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +// Use the same LargeModel as other benchmarks for fair comparison +#[derive(Module, Debug)] +struct LargeModel { + layers: Vec, +} + +impl LargeModel { + fn new(device: &Device) -> Self { + let mut layers = Vec::new(); + // Create a model with 20 layers - same as safetensor_loading benchmark + for i in 0..20 { + let in_size = if i == 0 { 1024 } else { 2048 }; + layers.push(nn::LinearConfig::new(in_size, 2048).init(device)); + } + Self { layers } + } +} + +/// Get the path to the model files +fn get_model_dir() -> PathBuf { + std::env::temp_dir().join("simple_bench_models") +} + +/// Generate Burnpack and NamedMpk files from existing SafeTensors file +fn generate_burn_formats(st_path: &Path, bp_path: &Path, mpk_path: &Path) { + let device = Device::flex(); + + // Load the model from SafeTensors + let mut model = LargeModel::new(&device); + let mut store = SafetensorsStore::from_file(st_path).with_from_adapter(PyTorchToBurnAdapter); + model + .load_from(&mut store) + .expect("Failed to load from SafeTensors"); + + // Save as Burnpack + if !bp_path.exists() { + println!(" Creating Burnpack file..."); + let mut burnpack_store = BurnpackStore::from_file(bp_path); + model + .save_into(&mut burnpack_store) + .expect("Failed to save as Burnpack"); + } + + // Save as NamedMpk + if !mpk_path.exists() { + println!(" Creating NamedMpk file..."); + model + .save_file(mpk_path) + .expect("Failed to save as NamedMpk"); + } +} + +/// Get paths to the model files +fn get_model_paths() -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let dir = get_model_dir(); + ( + dir.join("large_model.bpk"), + dir.join("large_model.mpk"), + dir.join("large_model.safetensors"), + dir.join("large_model.pt"), + ) +} + +/// Check if model files exist +fn check_model_files() -> Result<(), String> { + let (_, _, st_path, pt_path) = get_model_paths(); + + // For now, only check safetensors and pytorch files (will generate burnpack/mpk later) + if !st_path.exists() || !pt_path.exists() { + return Err(format!( + "\n❌ Model files not found!\n\ + \n\ + Please generate the model files first by running:\n\ + \n\ + cd crates/burn-store\n\ + uv run benches/generate_unified_models.py\n\ + \n\ + Expected files:\n\ + - {}\n\ + - {}\n", + st_path.display(), + pt_path.display() + )); + } + + Ok(()) +} + +fn main() { + // Check if model files exist before running benchmarks + match check_model_files() { + Ok(()) => { + let (bp_path, mpk_path, st_path, pt_path) = get_model_paths(); + + // First, generate Burnpack and MPK files if they don't exist + if !bp_path.exists() || !mpk_path.exists() { + println!("⏳ Generating Burnpack and NamedMpk files from SafeTensors..."); + generate_burn_formats(&st_path, &bp_path, &mpk_path); + } + + let bp_size = fs::metadata(&bp_path) + .ok() + .map(|m| m.len() as f64 / 1_048_576.0); + let mpk_size = fs::metadata(&mpk_path) + .ok() + .map(|m| m.len() as f64 / 1_048_576.0); + let st_size = fs::metadata(&st_path).unwrap().len() as f64 / 1_048_576.0; + let pt_size = fs::metadata(&pt_path).unwrap().len() as f64 / 1_048_576.0; + + println!("✅ Found model files:"); + if let Some(size) = bp_size { + println!(" Burnpack: {} ({:.1} MB)", bp_path.display(), size); + } + if let Some(size) = mpk_size { + println!(" NamedMpk: {} ({:.1} MB)", mpk_path.display(), size); + } + println!(" SafeTensors: {} ({:.1} MB)", st_path.display(), st_size); + println!(" PyTorch: {} ({:.1} MB)", pt_path.display(), pt_size); + println!(); + println!("🚀 Running unified loading benchmarks..."); + println!(); + println!("Comparing 6 loading methods:"); + println!(" 1. BurnpackStore (new native format - lazy loading)"); + println!(" 2. NamedMpkFileRecorder (old native format - loads all to memory)"); + println!(" 3. SafetensorsStore (new)"); + println!(" 4. SafetensorsFileRecorder (old)"); + println!(" 5. PytorchStore (new)"); + println!(" 6. PyTorchFileRecorder (old)"); + println!(); + println!("Available backends:"); + println!(" - Flex (CPU)"); + #[cfg(feature = "wgpu")] + println!(" - WGPU (GPU)"); + #[cfg(feature = "cuda")] + println!(" - CUDA (NVIDIA GPU)"); + #[cfg(feature = "tch")] + println!(" - LibTorch"); + #[cfg(feature = "metal")] + println!(" - Metal (Apple GPU)"); + println!(); + + divan::main(); + } + Err(msg) => { + eprintln!("{}", msg); + std::process::exit(1); + } + } +} + +// Macro to generate benchmarks for each backend +macro_rules! bench_backend { + ($device:expr, $mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name, sample_count = 10)] + mod $mod_name { + use super::*; + + #[divan::bench] + fn burnpack_store(bencher: Bencher) { + let (bp_path, _, _, _) = get_model_paths(); + let file_size = fs::metadata(&bp_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + let mut store = BurnpackStore::from_file(bp_path.clone()); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + #[divan::bench] + fn namedmpk_recorder(bencher: Bencher) { + let (_, mpk_path, _, _) = get_model_paths(); + let file_size = fs::metadata(&mpk_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let model = LargeModel::new(&device); + model.load_record(ModuleRecord::load(&mpk_path).expect("Failed to load")); + }); + } + + #[divan::bench] + fn safetensors_store(bencher: Bencher) { + let (_, _, st_path, _) = get_model_paths(); + let file_size = fs::metadata(&st_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + let mut store = SafetensorsStore::from_file(st_path.clone()) + .with_from_adapter(PyTorchToBurnAdapter); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + // #[divan::bench] + // fn safetensors_recorder(bencher: Bencher) { + // let (_, _, st_path, _) = get_model_paths(); + // let file_size = fs::metadata(&st_path).unwrap().len(); + + // bencher + // .counter(divan::counter::BytesCount::new(file_size)) + // .bench(|| { + // let device: Device = $device.into(); + // let recorder = SafetensorsFileRecorder::::default(); + // let record = recorder + // .load(st_path.clone().into(), &device) + // .expect("Failed to load"); + // let _model = LargeModel::new(&device).load_record(record); + // }); + // } + + #[divan::bench] + fn pytorch_store(bencher: Bencher) { + let (_, _, _, pt_path) = get_model_paths(); + let file_size = fs::metadata(&pt_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device: Device = $device.into(); + let mut model = LargeModel::new(&device); + let mut store = PytorchStore::from_file(pt_path.clone()) + .with_top_level_key("model_state_dict") + .allow_partial(true); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + // #[divan::bench] + // fn pytorch_recorder(bencher: Bencher) { + // let (_, _, _, pt_path) = get_model_paths(); + // let file_size = fs::metadata(&pt_path).unwrap().len(); + + // bencher + // .counter(divan::counter::BytesCount::new(file_size)) + // .bench(|| { + // let device: Device = $device.into(); + // let recorder = PyTorchFileRecorder::::default(); + // let load_args = + // LoadArgs::new(pt_path.clone()).with_top_level_key("model_state_dict"); + // let record = recorder.load(load_args, &device).expect("Failed to load"); + // let _model = LargeModel::new(&device).load_record(record); + // }); + // } + } + }; +} + +// Generate benchmarks for each backend +bench_backend!(Device::flex(), ndarray_backend, "NdArray Backend (CPU)"); + +#[cfg(feature = "wgpu")] +bench_backend!( + Device::wgpu(Default::default()), + wgpu_backend, + "WGPU Backend (GPU)" +); + +#[cfg(feature = "cuda")] +bench_backend!( + CudaDevice::default(), + cuda_backend, + "CUDA Backend (NVIDIA GPU)" +); + +#[cfg(feature = "tch")] +bench_backend!(Device::libtorch(), tch_backend, "LibTorch Backend"); + +#[cfg(feature = "metal")] +bench_backend!( + Device::wgpu(Default::default()), + metal_backend, + "Metal Backend (Apple GPU)" +); diff --git a/crates/burn-store/benches/unified_saving.rs b/crates/burn-store/benches/unified_saving.rs new file mode 100644 index 0000000..a4c31f5 --- /dev/null +++ b/crates/burn-store/benches/unified_saving.rs @@ -0,0 +1,175 @@ +#![recursion_limit = "256"] + +//! Unified benchmark comparing all saving methods: +//! - BurnpackStore (new native format) +//! - NamedMpkFileRecorder (old native format) +//! - SafetensorsStore (new) +//! +//! Before running this benchmark, ensure the directory exists: +//! ```bash +//! mkdir -p /tmp/simple_bench_models +//! ``` +//! +//! Then run the benchmark: +//! ```bash +//! cargo bench --bench unified_saving +//! ``` +use burn_core as burn; + +use burn_core::module::Module; +use burn_core::prelude::*; +use burn_nn as nn; +use burn_store::{BurnpackStore, ModuleSnapshot, SafetensorsStore}; +use divan::{AllocProfiler, Bencher}; +use std::fs; +use std::path::PathBuf; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +// Use the same LargeModel as other benchmarks for fair comparison +#[derive(Module, Debug)] +struct LargeModel { + layers: Vec, +} + +impl LargeModel { + fn new(device: &Device) -> Self { + let mut layers = Vec::new(); + // Create a model with 20 layers - same as loading benchmarks + for i in 0..20 { + let in_size = if i == 0 { 1024 } else { 2048 }; + layers.push(nn::LinearConfig::new(in_size, 2048).init(device)); + } + Self { layers } + } +} + +/// Get the path to the output directory +fn get_output_dir() -> PathBuf { + std::env::temp_dir().join("simple_bench_models_saving") +} + +/// Ensure output directory exists +fn ensure_output_dir() -> Result<(), String> { + let dir = get_output_dir(); + if !dir.exists() { + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create output directory: {}", e))?; + } + Ok(()) +} + +fn main() { + match ensure_output_dir() { + Ok(()) => { + println!("✅ Output directory ready: {}", get_output_dir().display()); + println!(); + println!("🚀 Running unified saving benchmarks..."); + println!(); + println!("Comparing 3 saving methods:"); + println!(" 1. BurnpackStore (new native format)"); + println!(" 2. NamedMpkFileRecorder (old native format)"); + println!(" 3. SafetensorsStore (new)"); + println!(); + println!("Available backends:"); + println!(" - Flex (CPU)"); + #[cfg(feature = "wgpu")] + println!(" - WGPU (GPU)"); + #[cfg(feature = "cuda")] + println!(" - CUDA (NVIDIA GPU)"); + #[cfg(feature = "tch")] + println!(" - LibTorch"); + #[cfg(feature = "metal")] + println!(" - Metal (Apple GPU)"); + println!(); + + divan::main(); + } + Err(msg) => { + eprintln!("❌ {}", msg); + std::process::exit(1); + } + } +} + +// Macro to generate benchmarks for each backend +macro_rules! bench_backend { + ($device:expr, $mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name, sample_count = 10)] + mod $mod_name { + use super::*; + + #[divan::bench] + fn burnpack_store(bencher: Bencher) { + bencher.bench(|| { + let device = $device; + let model = LargeModel::new(&device); + let output_path = get_output_dir().join("test_burnpack.bpk"); + let mut store = BurnpackStore::from_file(output_path.clone()).overwrite(true); + model + .save_into(&mut store) + .expect("Failed to save with BurnpackStore"); + // Clean up + let _ = fs::remove_file(output_path); + }); + } + + #[divan::bench] + fn namedmpk_recorder(bencher: Bencher) { + bencher.bench(|| { + let device = $device; + let model = LargeModel::new(&device); + let output_path = get_output_dir().join("test_namedmpk.mpk"); + model + .save_file(output_path.clone()) + .expect("Failed to save with NamedMpkFileRecorder"); + // Clean up + let _ = fs::remove_file(output_path); + }); + } + + #[divan::bench] + fn safetensors_store(bencher: Bencher) { + bencher.bench(|| { + let device = $device; + let model = LargeModel::new(&device); + let output_path = get_output_dir().join("test_safetensors_store.safetensors"); + let mut store = SafetensorsStore::from_file(output_path.clone()); + model + .save_into(&mut store) + .expect("Failed to save with SafetensorsStore"); + // Clean up + let _ = fs::remove_file(output_path); + }); + } + } + }; +} + +// Generate benchmarks for each backend +bench_backend!(Device::flex(), ndarray_backend, "NdArray Backend (CPU)"); + +#[cfg(feature = "wgpu")] +bench_backend!( + Device::wgpu(Default::default()), + wgpu_backend, + "WGPU Backend (GPU)" +); + +#[cfg(feature = "cuda")] +bench_backend!( + CudaDevice::default(), + cuda_backend, + "CUDA Backend (NVIDIA GPU)" +); + +#[cfg(feature = "tch")] +bench_backend!(Device::libtorch(), tch_backend, "LibTorch Backend"); + +#[cfg(feature = "metal")] +bench_backend!( + Device::wgpu(Default::default()), + metal_backend, + "Metal Backend (Apple GPU)" +); diff --git a/crates/burn-store/benches/zero_copy_loading.rs b/crates/burn-store/benches/zero_copy_loading.rs new file mode 100644 index 0000000..59cb54c --- /dev/null +++ b/crates/burn-store/benches/zero_copy_loading.rs @@ -0,0 +1,483 @@ +#![recursion_limit = "256"] + +//! Benchmark comparing different loading modes for BurnpackStore. +//! +//! This benchmark measures the performance difference between: +//! - `from_file()` - File-based loading (reader handles lazy/mmap internally) +//! - `from_static()` - Zero-copy mode from static bytes (stays in .rodata) +//! - `from_bytes()` - Loading from owned bytes +//! - `from_bytes()` with shared `Bytes` - Loading from shared/arc bytes +//! +//! ## Understanding the Results +//! +//! The key difference is how tensor data reaches the backend: +//! - **from_file**: File → reader (lazy/mmap) → backend +//! - **from_bytes**: Owned bytes → copy to backend +//! - **from_static**: Static .rodata → zero-copy slice → backend +//! - **shared bytes**: Arc'd bytes → zero-copy clone → backend +//! +//! GPU backends that can consume `Bytes` directly will show larger benefits +//! from zero-copy paths. +//! +//! ## Running the benchmark +//! +//! Before running this benchmark, generate the model files: +//! ```bash +//! cd crates/burn-store +//! uv run benches/generate_unified_models.py +//! ``` +//! +//! Then run the benchmark: +//! ```bash +//! cargo bench --bench zero_copy_loading +//! ``` + +use burn_core as burn; + +use burn_core::module::Module; +use burn_core::prelude::*; +use burn_core::tensor::{AllocationProperty, Bytes}; +use burn_nn as nn; +use burn_store::{ + BurnpackStore, ModuleSnapshot, ModuleStore, PyTorchToBurnAdapter, SafetensorsStore, +}; +use divan::{AllocProfiler, Bencher}; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +// Static storage for embedded model bytes (simulating include_bytes!) +static STATIC_MODEL_BYTES: OnceLock<&'static [u8]> = OnceLock::new(); + +// Use the same LargeModel as other benchmarks for fair comparison +#[derive(Module, Debug)] +struct LargeModel { + layers: Vec, +} + +impl LargeModel { + fn new(device: &Device) -> Self { + let mut layers = Vec::new(); + // Create a model with 20 layers - same as unified_loading benchmark + for i in 0..20 { + let in_size = if i == 0 { 1024 } else { 2048 }; + layers.push(nn::LinearConfig::new(in_size, 2048).init(device)); + } + Self { layers } + } +} + +/// Get the path to the model files +fn get_model_dir() -> PathBuf { + std::env::temp_dir().join("simple_bench_models") +} + +/// Get path to Burnpack model file +fn get_burnpack_path() -> PathBuf { + get_model_dir().join("large_model.bpk") +} + +/// Generate Burnpack file from existing SafeTensors file if needed +fn ensure_burnpack_file() { + let bp_path = get_burnpack_path(); + let st_path = get_model_dir().join("large_model.safetensors"); + + if bp_path.exists() { + return; + } + + if !st_path.exists() { + panic!( + "\n❌ SafeTensors model file not found!\n\ + \n\ + Please generate the model files first by running:\n\ + \n\ + cd crates/burn-store\n\ + uv run benches/generate_unified_models.py\n\ + \n\ + Expected file: {}\n", + st_path.display() + ); + } + + println!("⏳ Generating Burnpack file from SafeTensors..."); + + let device = Device::flex(); + + // Load from SafeTensors + let mut model = LargeModel::new(&device); + let mut store = SafetensorsStore::from_file(&st_path).with_from_adapter(PyTorchToBurnAdapter); + model + .load_from(&mut store) + .expect("Failed to load from SafeTensors"); + + // Save as Burnpack + let mut burnpack_store = BurnpackStore::from_file(&bp_path); + model + .save_into(&mut burnpack_store) + .expect("Failed to save as Burnpack"); + + println!("✅ Created Burnpack file: {}", bp_path.display()); +} + +/// Initialize static model bytes (simulating include_bytes! at runtime for benchmarks) +fn get_static_model_bytes() -> &'static [u8] { + STATIC_MODEL_BYTES.get_or_init(|| { + let bp_path = get_burnpack_path(); + let bytes = fs::read(&bp_path).expect("Failed to read Burnpack file"); + // Leak the bytes to get a 'static lifetime (acceptable for benchmarks) + Box::leak(bytes.into_boxed_slice()) + }) +} + +fn main() { + // Ensure Burnpack file exists + ensure_burnpack_file(); + + let bp_path = get_burnpack_path(); + let file_size = fs::metadata(&bp_path).unwrap().len() as f64 / 1_048_576.0; + + println!("✅ Found Burnpack model file:"); + println!(" Path: {}", bp_path.display()); + println!(" Size: {:.1} MB", file_size); + println!(); + println!("🚀 Running loading mode benchmarks..."); + println!(); + println!("Comparing loading modes:"); + println!(" 1. file - from_file() - lazy/mmap file loading"); + println!(" 2. static_bytes - from_bytes() with Vec copy from static"); + println!(" 3. static_zero_copy - from_static() - zero-copy from static"); + println!(" 4. memory_shared - from_bytes() with shared Bytes (cheap Arc clone)"); + println!(); + println!("Available backends:"); + println!(" - Flex (CPU)"); + #[cfg(feature = "wgpu")] + println!(" - WGPU (GPU)"); + #[cfg(feature = "cuda")] + println!(" - CUDA (NVIDIA GPU)"); + #[cfg(feature = "tch")] + println!(" - LibTorch"); + #[cfg(feature = "metal")] + println!(" - Metal (Apple GPU)"); + println!(); + + // Pre-initialize static bytes before benchmarks + let _ = get_static_model_bytes(); + + divan::main(); +} + +// Macro to generate benchmarks for each backend +macro_rules! bench_backend { + ($device:expr, $mod_name:ident, $backend_name:literal) => { + #[divan::bench_group(name = $backend_name, sample_count = 10)] + mod $mod_name { + use super::*; + + /// File-based loading (reader handles lazy/mmap internally) + #[divan::bench] + fn file(bencher: Bencher) { + let bp_path = get_burnpack_path(); + let file_size = fs::metadata(&bp_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + let mut store = BurnpackStore::from_file(&bp_path); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + /// Static bytes with copy (simulating old behavior: copy to Vec first) + #[divan::bench] + fn static_bytes(bencher: Bencher) { + let static_bytes = get_static_model_bytes(); + let file_size = static_bytes.len() as u64; + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + + let bytes = Bytes::from_bytes_vec(static_bytes.to_vec()); + let mut store = BurnpackStore::from_bytes(Some(bytes)); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + /// Static bytes with zero-copy (from_static keeps data in .rodata) + #[divan::bench] + fn static_zero_copy(bencher: Bencher) { + let static_bytes = get_static_model_bytes(); + let file_size = static_bytes.len() as u64; + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + + let mut store = BurnpackStore::from_static(static_bytes); + model.load_from(&mut store).expect("Failed to load"); + }); + } + + /// In-memory shared bytes (cheap Arc clone, zero-copy) + #[divan::bench] + fn memory_shared(bencher: Bencher) { + let static_bytes = get_static_model_bytes(); + let file_size = static_bytes.len() as u64; + + // Pre-create shared bytes outside the benchmark loop + let shared = bytes::Bytes::from_static(static_bytes); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let device = $device; + let mut model = LargeModel::new(&device); + + let bytes = Bytes::from_shared(shared.clone(), AllocationProperty::Other); + let mut store = BurnpackStore::from_bytes(Some(bytes)); + model.load_from(&mut store).expect("Failed to load"); + }); + } + } + }; +} + +// ============================================================================= +// Zero-copy verification (proves operations use static region data) +// ============================================================================= + +/// Verify that zero-copy loading actually uses data from the static region. +/// This runs once at startup to prove correctness before benchmarking. +#[divan::bench_group(name = "Zero-Copy Verification", sample_count = 1)] +mod verification { + use super::*; + + /// Verify data is readable and correct using sum().into_scalar(). + /// Note: sum() triggers COW copy, so this shows ops work correctly on zero-copy data. + #[divan::bench] + fn verify_ops_produce_correct_results() { + let static_bytes = get_static_model_bytes(); + + let device = Device::flex(); + let mut model = LargeModel::new(&device); + let mut store = BurnpackStore::from_static(static_bytes); + model.load_from(&mut store).expect("Failed to load"); + + // Compute sum of first layer weight - proves data is valid + let weight = model.layers[0].weight.val(); + let sum: f32 = weight.sum().into_scalar(); + + assert!(sum.is_finite(), "Sum should be finite"); + println!("✅ Verified: Operations on zero-copy data produce valid results"); + println!(" - First layer sum: {:.4}", sum); + } + + /// Verify operations produce correct results on zero-copy data + #[divan::bench] + fn verify_operations_on_static_data() { + let static_bytes = get_static_model_bytes(); + + // Load model with zero-copy + let device = Device::flex(); + let mut model = LargeModel::new(&device); + let mut store = BurnpackStore::from_static(static_bytes); + model.load_from(&mut store).expect("Failed to load"); + + // Perform operations on the loaded weights + let weight = model.layers[0].weight.val(); + let shape = weight.shape(); + + // Test 1: Sum should be finite (not NaN or Inf) + let sum: f32 = weight.clone().sum().to_data().to_vec().unwrap()[0]; + assert!( + sum.is_finite(), + "Operation failed: sum is not finite ({})", + sum + ); + + // Test 2: Matrix multiply with itself transposed (W @ W.T) + let transposed = weight.clone().transpose(); + let matmul_result = weight.clone().matmul(transposed); + let matmul_sum: f32 = matmul_result.sum().to_data().to_vec().unwrap()[0]; + assert!( + matmul_sum.is_finite(), + "Matmul failed: result sum is not finite ({})", + matmul_sum + ); + + // Test 3: Element-wise operations + let doubled = weight.clone() * 2.0; + let doubled_sum: f32 = doubled.sum().to_data().to_vec().unwrap()[0]; + assert!( + (doubled_sum - sum * 2.0).abs() < 1e-3, + "Element-wise op failed: doubled_sum ({}) != sum*2 ({})", + doubled_sum, + sum * 2.0 + ); + + println!("✅ Verified: Operations on zero-copy data produce correct results"); + println!(" - Weight shape: {:?}", shape.as_slice()); + println!(" - Sum: {:.4}", sum); + println!(" - Matmul result sum: {:.4}", matmul_sum); + } + + /// Compare from_static vs from_bytes: verify both produce identical results + #[divan::bench] + fn verify_static_vs_bytes_equality() { + let static_bytes = get_static_model_bytes(); + let device = Device::flex(); + + // Load with from_static (zero-copy) + let mut model_zc = LargeModel::new(&device); + let mut store_zc = BurnpackStore::from_static(static_bytes); + model_zc + .load_from(&mut store_zc) + .expect("Failed to load zero-copy"); + + // Load with from_bytes (copy) + let mut model_copy = LargeModel::new(&device); + let bytes = Bytes::from_bytes_vec(static_bytes.to_vec()); + let mut store_copy = BurnpackStore::from_bytes(Some(bytes)); + model_copy + .load_from(&mut store_copy) + .expect("Failed to load copy"); + + // Compare weights from both models + for (i, (layer_zc, layer_copy)) in model_zc + .layers + .iter() + .zip(model_copy.layers.iter()) + .enumerate() + { + let weight_zc = layer_zc.weight.val(); + let weight_copy = layer_copy.weight.val(); + + // Check shapes match + assert_eq!( + weight_zc.shape(), + weight_copy.shape(), + "Layer {} weight shapes don't match", + i + ); + + // Check values match (using sum as a proxy) + let sum_zc: f32 = weight_zc.clone().sum().to_data().to_vec().unwrap()[0]; + let sum_copy: f32 = weight_copy.clone().sum().to_data().to_vec().unwrap()[0]; + assert!( + (sum_zc - sum_copy).abs() < 1e-6, + "Layer {} weight sums don't match: zero-copy={}, copy={}", + i, + sum_zc, + sum_copy + ); + } + + println!( + "✅ Verified: Zero-copy and copy loading produce identical results for all {} layers", + model_zc.layers.len() + ); + } +} + +// ============================================================================= +// Store-only benchmarks (no backend allocation overhead) +// These show the TRUE zero-copy benefit at the store level +// ============================================================================= + +#[divan::bench_group(name = "Store Only (no backend)", sample_count = 10)] +mod store_only { + use super::*; + + /// File-based store - measures store overhead only + #[divan::bench] + fn file(bencher: Bencher) { + let bp_path = get_burnpack_path(); + let file_size = fs::metadata(&bp_path).unwrap().len(); + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let mut store = BurnpackStore::from_file(&bp_path); + let snapshots = store.get_all_snapshots().expect("Failed to get snapshots"); + for snapshot in snapshots.values() { + let _data = snapshot.to_data().expect("Failed to get tensor data"); + } + }); + } + + /// Static bytes with copy - measures store overhead only + #[divan::bench] + fn static_bytes(bencher: Bencher) { + let static_bytes = get_static_model_bytes(); + let file_size = static_bytes.len() as u64; + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let bytes = Bytes::from_bytes_vec(static_bytes.to_vec()); + let mut store = BurnpackStore::from_bytes(Some(bytes)); + let snapshots = store.get_all_snapshots().expect("Failed to get snapshots"); + for snapshot in snapshots.values() { + let _data = snapshot.to_data().expect("Failed to get tensor data"); + } + }); + } + + /// Static bytes with zero-copy - measures store overhead only + #[divan::bench] + fn static_zero_copy(bencher: Bencher) { + let static_bytes = get_static_model_bytes(); + let file_size = static_bytes.len() as u64; + + bencher + .counter(divan::counter::BytesCount::new(file_size)) + .bench(|| { + let mut store = BurnpackStore::from_static(static_bytes); + let snapshots = store.get_all_snapshots().expect("Failed to get snapshots"); + for snapshot in snapshots.values() { + let _data = snapshot.to_data().expect("Failed to get tensor data"); + } + }); + } +} + +// ============================================================================= +// Full model loading benchmarks (includes backend allocation) +// ============================================================================= + +// Generate benchmarks for each backend +bench_backend!(Device::flex(), ndarray_backend, "NdArray Backend (CPU)"); + +#[cfg(feature = "wgpu")] +bench_backend!( + Device::wgpu(Default::default()), + wgpu_backend, + "WGPU Backend (GPU)" +); + +#[cfg(feature = "cuda")] +bench_backend!( + Device::cuda(Default::default()), + cuda_backend, + "CUDA Backend (NVIDIA GPU)" +); + +#[cfg(feature = "tch")] +bench_backend!(LibTorchDevice::default(), tch_backend, "LibTorch Backend"); + +#[cfg(feature = "metal")] +bench_backend!( + Device::wgpu(Default::default()), + metal_backend, + "Metal Backend (Apple GPU)" +); diff --git a/crates/burn-store/examples/burnpack_inspect.rs b/crates/burn-store/examples/burnpack_inspect.rs new file mode 100644 index 0000000..0f02cc4 --- /dev/null +++ b/crates/burn-store/examples/burnpack_inspect.rs @@ -0,0 +1,144 @@ +//! Example: Generate a Burnpack file for inspection +//! +//! This example creates a simple Burnpack file that you can examine to understand the format. +//! +//! Usage: +//! cargo run --example burnpack-inspect [output_path] +//! +//! Example: +//! cargo run --example burnpack-inspect sample.bpk +//! cargo run --example burnpack-inspect /tmp/test.bpk +//! +//! After generating the file, examine it with: +//! hexdump -C sample.bpk | head -100 +//! xxd sample.bpk | head -100 +//! hexyl sample.bpk +use burn_core::{self as burn, tensor::Device}; + +use burn_core::module::Module; +use burn_nn::{Linear, LinearConfig}; +use burn_store::{BurnpackStore, ModuleSnapshot}; +use std::env; + +// Simple model with a few layers +#[derive(Module, Debug)] +struct SampleModel { + linear1: Linear, + linear2: Linear, + linear3: Linear, +} + +impl SampleModel { + fn new(device: &Device) -> Self { + Self { + linear1: LinearConfig::new(128, 64).init(device), + linear2: LinearConfig::new(64, 32).init(device), + linear3: LinearConfig::new(32, 10).init(device), + } + } +} + +fn main() { + // Get output path from command line or use default + let output_path = env::args() + .nth(1) + .unwrap_or_else(|| "sample.bpk".to_string()); + + println!("Creating sample Burnpack file: {}", output_path); + println!(); + + // Create a simple model + let device = Default::default(); + let model = SampleModel::new(&device); + + // Save to Burnpack format with metadata + let mut store = BurnpackStore::from_file(&output_path) + .overwrite(true) + .metadata("format", "burnpack") + .metadata("description", "Sample file for examining Burnpack format") + .metadata("version", env!("CARGO_PKG_VERSION")) + .metadata("author", "Burn Example"); + + model.save_into(&mut store).expect("Failed to save model"); + + println!("✅ Successfully created: {}", output_path); + println!(); + println!("📋 File Structure:"); + println!(" ┌─────────────────────────────────────┐"); + println!(" │ Header (10 bytes) │"); + println!(" ├─────────────────────────────────────┤"); + println!(" │ - Magic: 0x4E525542 (BURN in LE) │"); + println!(" │ - Version: 0x0001 (2 bytes) │"); + println!(" │ - Metadata size: (4 bytes, u32 LE) │"); + println!(" ├─────────────────────────────────────┤"); + println!(" │ Metadata (CBOR format) │"); + println!(" ├─────────────────────────────────────┤"); + println!(" │ - Tensor descriptors │"); + println!(" │ * name, dtype, shape, offsets │"); + println!(" │ - User metadata │"); + println!(" ├─────────────────────────────────────┤"); + println!(" │ Tensor Data (raw bytes, LE) │"); + println!(" ├─────────────────────────────────────┤"); + println!(" │ - linear1.weight [64, 128] │"); + println!(" │ - linear1.bias [64] │"); + println!(" │ - linear2.weight [32, 64] │"); + println!(" │ - linear2.bias [32] │"); + println!(" │ - linear3.weight [10, 32] │"); + println!(" │ - linear3.bias [10] │"); + println!(" └─────────────────────────────────────┘"); + println!(); + println!("📊 Model Contents:"); + println!(" - linear1.weight: [64, 128] = 8,192 params → 32,768 bytes"); + println!(" - linear1.bias: [64] = 64 params → 256 bytes"); + println!(" - linear2.weight: [32, 64] = 2,048 params → 8,192 bytes"); + println!(" - linear2.bias: [32] = 32 params → 128 bytes"); + println!(" - linear3.weight: [10, 32] = 320 params → 1,280 bytes"); + println!(" - linear3.bias: [10] = 10 params → 40 bytes"); + println!(" ───────────────────────────────────────────────────────"); + + let total_params = 8192 + 64 + 2048 + 32 + 320 + 10; + let total_bytes = total_params * 4; + println!( + " Total: {} parameters = {} KB", + total_params, + total_bytes / 1024 + ); + println!(); + + // Get actual file size + if let Ok(metadata) = std::fs::metadata(&output_path) { + let file_size = metadata.len(); + println!( + "📦 File size: {} bytes ({:.2} KB)", + file_size, + file_size as f64 / 1024.0 + ); + } + + println!(); + println!("🔍 Inspection Commands:"); + println!(); + println!(" # View first 100 bytes in hex:"); + println!(" hexdump -C {} | head -20", output_path); + println!(); + println!(" # View header only (10 bytes):"); + println!(" head -c 10 {} | hexdump -C", output_path); + println!(); + println!(" # View with prettier hex viewer (if installed):"); + println!(" hexyl {} | head -50", output_path); + println!(); + println!(" # View in binary format:"); + println!(" xxd -b {} | head -20", output_path); + println!(); + println!(" # Extract and examine header:"); + println!(" # Magic (bytes 0-3): Should be 42 55 52 4E (BURN)"); + println!(" # Version (bytes 4-5): Should be 01 00"); + println!(" # Metadata size (bytes 6-9): u32 little-endian"); + println!(); + println!(" # Load back the model:"); + println!( + " # let mut store = BurnpackStore::from_file(\"{}\");", + output_path + ); + println!(" # model.load_from(&mut store)?;"); +} diff --git a/crates/burn-store/examples/half_precision.rs b/crates/burn-store/examples/half_precision.rs new file mode 100644 index 0000000..edb325b --- /dev/null +++ b/crates/burn-store/examples/half_precision.rs @@ -0,0 +1,86 @@ +//! Example: Save and load a model with half-precision (F32 <-> F16) +//! +//! Demonstrates using HalfPrecisionAdapter to automatically convert between +//! F32 and F16 during saving/loading. The same adapter instance handles both +//! directions. +//! +//! Usage: +//! cargo run -p burn-store --example half_precision + +use burn_core::module::Module; +use burn_core::{self as burn, tensor::Device}; +use burn_nn::{LayerNorm, LayerNormConfig, Linear, LinearConfig}; +use burn_store::{BurnpackStore, HalfPrecisionAdapter, ModuleSnapshot}; + +// A model with mixed layer types to show selective conversion +#[derive(Module, Debug)] +struct DemoModel { + linear1: Linear, + norm: LayerNorm, + linear2: Linear, +} + +impl DemoModel { + fn new(device: &Device) -> Self { + Self { + linear1: LinearConfig::new(128, 64).init(device), + norm: LayerNormConfig::new(64).init(device), + linear2: LinearConfig::new(64, 10).init(device), + } + } +} + +fn main() { + let device = Default::default(); + let model = DemoModel::new(&device); + + // 1) Save at full F32 precision (baseline) + let dir = tempfile::tempdir().expect("Failed to create temp dir"); + let path_f32 = dir.path().join("model_f32"); + let path_f16 = dir.path().join("model_f16"); + let path_mixed = dir.path().join("model_mixed"); + + let mut store = BurnpackStore::from_file(path_f32.to_str().unwrap()).overwrite(true); + model.save_into(&mut store).expect("Failed to save F32"); + let size_f32 = std::fs::metadata(format!("{}.bpk", path_f32.display())) + .map(|m| m.len()) + .unwrap_or(0); + + // 2) Save with default half-precision (all default modules get F16) + let adapter = HalfPrecisionAdapter::new(); + let mut store = BurnpackStore::from_file(path_f16.to_str().unwrap()) + .overwrite(true) + .with_to_adapter(adapter.clone()); + model.save_into(&mut store).expect("Failed to save F16"); + let size_f16 = std::fs::metadata(format!("{}.bpk", path_f16.display())) + .map(|m| m.len()) + .unwrap_or(0); + + // 3) Save with without_module: keep LayerNorm at F32 + let adapter_no_norm = HalfPrecisionAdapter::new().without_module("LayerNorm"); + let mut store = BurnpackStore::from_file(path_mixed.to_str().unwrap()) + .overwrite(true) + .with_to_adapter(adapter_no_norm); + model.save_into(&mut store).expect("Failed to save mixed"); + let size_mixed = std::fs::metadata(format!("{}.bpk", path_mixed.display())) + .map(|m| m.len()) + .unwrap_or(0); + + println!("F32 (full precision): {} bytes", size_f32); + println!("F16 (default modules): {} bytes", size_f16); + println!("Mixed (norm stays F32): {} bytes", size_mixed); + println!( + "F16 savings: {:.1}%", + (1.0 - size_f16 as f64 / size_f32 as f64) * 100.0 + ); + + // 4) Round-trip: load the F16 file back to F32 with the same adapter + let mut load_store = + BurnpackStore::from_file(path_f16.to_str().unwrap()).with_from_adapter(adapter); + let mut model2 = DemoModel::new(&device); + let result = model2.load_from(&mut load_store).expect("Failed to load"); + println!( + "\nRound-trip loaded {} tensors successfully", + result.applied.len() + ); +} diff --git a/crates/burn-store/pytorch-tests/Cargo.toml b/crates/burn-store/pytorch-tests/Cargo.toml new file mode 100644 index 0000000..0ef49f9 --- /dev/null +++ b/crates/burn-store/pytorch-tests/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pytorch-tests" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dev-dependencies] +burn = { workspace = true, features = ["default", "flex"] } +burn-store = { workspace = true, features = ["default", "std", "pytorch"] } +serde = { workspace = true } +float-cmp = { workspace = true } diff --git a/crates/burn-store/pytorch-tests/src/lib.rs b/crates/burn-store/pytorch-tests/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/burn-store/pytorch-tests/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/burn-store/pytorch-tests/tests/batch_norm/batch_norm2d.pt b/crates/burn-store/pytorch-tests/tests/batch_norm/batch_norm2d.pt new file mode 100644 index 0000000000000000000000000000000000000000..abb1088275efb0bf497feb2386fc868feb7cd897 GIT binary patch literal 2361 zcmbW3%Wl&^6ow~lleXqkdWCW?1)5Nr`jVvW0+1qHb)6o2QZ7<~ zO(j_H1R(aj0?z;okbor%cmNhiY?))HsqLny?U9Zg*`Dt|p83yAs&a21Ap-+sSB#Tm zq^?ojQr)JvCK~gGMz#FlvnJ8nbV8im;!TyJf{oHEGx>?^5;7Jz&(q4QaV z-6g*=l8Mzk z{BUl0j8%g9ziMW?R3lW%GyAQE;dceIebMG}DBc=G7w;QKY7Wcd85noCiDt~_0 zc);3;VD7CvP3Gdt`M~_X>DiiNKh?g0LV8CB9fJNefsdc~%|DvI;S)ate#>oIKH8VH_XCqU$-7L_{}$iP-RGOe%)x zY$i!Yv~5RYjBiOsxN#%_(Y}iV40cAviUIx_%0Z}(%5AFn>3$Y5?-cJ&uPMO{r&VGGK61sc1DrYj++XK({&UFxYl3{FL j)xd;=JjPRl1sjKdJIHtJVHVilRXH6z9b}{M{qWs?M9T=C literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/batch_norm/export_weights.py b/crates/burn-store/pytorch-tests/tests/batch_norm/export_weights.py new file mode 100755 index 0000000..24a65c9 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/batch_norm/export_weights.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.norm1 = nn.BatchNorm2d(5) + + def forward(self, x): + x = self.norm1(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + # Condition batch norm (each forward will affect the running stats) + x1 = torch.ones(1, 5, 2, 2) - 0.5 + _ = model(x1) + model.eval() # Set to eval mode to freeze running stats + # Save the model after the first forward + torch.save(model.state_dict(), "batch_norm2d.pt") + + x2 = torch.ones(1, 5, 2, 2) - 0.3 + print("Input shape: {}", x2.shape) + output = model(x2) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/batch_norm/mod.rs b/crates/burn-store/pytorch-tests/tests/batch_norm/mod.rs new file mode 100644 index 0000000..2d5540e --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/batch_norm/mod.rs @@ -0,0 +1,61 @@ +use burn::{ + module::Module, + nn::{BatchNorm, BatchNormConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + norm1: BatchNorm, +} + +impl Net { + pub fn new(device: &Device) -> Self { + Self { + norm1: BatchNormConfig::new(5).init(device), // Python model uses BatchNorm2d(5) + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.norm1.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + #[test] + fn batch_norm2d() { + let device = Default::default(); + let mut model = Net::new(&device); + let mut store = PytorchStore::from_file("tests/batch_norm/batch_norm2d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::ones([1, 5, 2, 2], &device) - 0.3; + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [[0.68515635, 0.68515635], [0.68515635, 0.68515635]], + [[0.68515635, 0.68515635], [0.68515635, 0.68515635]], + [[0.68515635, 0.68515635], [0.68515635, 0.68515635]], + [[0.68515635, 0.68515635], [0.68515635, 0.68515635]], + [[0.68515635, 0.68515635], [0.68515635, 0.68515635]], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/boolean/boolean.pt b/crates/burn-store/pytorch-tests/tests/boolean/boolean.pt new file mode 100644 index 0000000000000000000000000000000000000000..f75eb2a5da4593055b570dc9717f0ab5b8db48bc GIT binary patch literal 1244 zcmbtU&2G~`5MIa4PYvX!J#iosw;+LQJ2XzJf{=xLu@I&R$@0Z=oNa4i;;y}^M5RiV zN)Ekp;K)nxBJc)CJOwvq@?+VCAR)#Yt-U*+znO1m*S5qhLK+S7o3+Uf8OL!%{psGs z=e~UQHZqMSaSL`;34%C^Xu!jGno2K|2~Ftac^Ghskq1XiaBA@9@$C4RCaem(-@^Nje8}*3hKFT6mlL>WSKwt zaTm2h-^|h^Pcs%QV^1-SgTGUh7>l0*5s>`L^@khO5`8myi#*H7S3;S_;eW8ZZeK$B zZ=X-zRDc?NGkuq68NIwHKBYz)zJW4-ydoN1x!^G zt*`3(Xn&*+hw2EkJp38H=s||)wk2S)*~!WGNE;UlRz62%bbjwU1*8I57jsbanN%9t}L} eU_sjK`!9lLc?X-s^YOBRd_Id8T$*{-U+Ax literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/boolean/export_weights.py b/crates/burn-store/pytorch-tests/tests/boolean/export_weights.py new file mode 100755 index 0000000..1b2e020 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/boolean/export_weights.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + buffer = torch.tensor([True, False, True]) + self.register_buffer("buffer", buffer, persistent=True) + + def forward(self, x): + x = self.buffer + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "boolean.pt") + + input = torch.ones(3, 3) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/boolean/mod.rs b/crates/burn-store/pytorch-tests/tests/boolean/mod.rs new file mode 100644 index 0000000..7387a63 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/boolean/mod.rs @@ -0,0 +1,53 @@ +use burn::{ + module::{Module, Param, ParamId}, + tensor::{Bool, Device, Tensor, TensorData}, +}; + +#[derive(Module, Debug)] +pub struct Net { + buffer: Param>, +} + +impl Net { + /// Create a new model with placeholder values. + pub fn init(device: &Device) -> Self { + Self { + buffer: Param::initialized( + ParamId::new(), + Tensor::from_bool(TensorData::from([false, false, false]), device), + ), + } + } + + /// Forward pass of the model. + pub fn forward(&self, _x: Tensor<2>) -> Tensor<1, Bool> { + self.buffer.val() + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::TensorData; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + #[test] + fn boolean() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/boolean/boolean.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<2>::ones([3, 3], &device); + + let output = model.forward(input); + + let expected = Tensor::<1, Bool>::from_bool(TensorData::from([true, false, true]), &device); + + assert_eq!(output.to_data(), expected.to_data()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/buffer/buffer.pt b/crates/burn-store/pytorch-tests/tests/buffer/buffer.pt new file mode 100644 index 0000000000000000000000000000000000000000..5a80651e31e0f46c2c9491d5def9a0a8e427bb76 GIT binary patch literal 1239 zcmah}OKa3n6uy~sUeS4I7gBJ?6aOrOl|A*&HGLOWjo^ZHH?)lE+yXRcf5Vr`aRLDQlAlqazJ2;?WXXu_5J~p~hrfz{N~~xJV56{-CBo{S%vjl| za#ne15JNP?Ijh1V%$fA*2$Q(M+0A`ci!NB*ssg}%N4bMVvBqM*X~9pzZe6h3i{Pg; zjG!F0Z3s~mO=Qz3SMGd%{^1W;p_;j*HA!np+T@(m0O`=Roh)l>4t*eZuM6NAHQ#%S^p@V`Mez|0J;(RnJJ@`-H`TSywZX0+K~ef6wc8tw)F(sG(O}#i zDj;fiI3B>asw$)Yc+kfzg>UV78!|*S4FQu~P0nU}G;kv&nK{aWH;=xV0uG?h6Y#2^ zvgpmDsin|g*XX!evd~T5>nUg-s!IYVCz-`=ve8OmzpSxWpOa~nN{n=_?$QY*_1aNpakQrQAD7bAJMVuH?ga_?o+rKGx00sa6 literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/buffer/export_weights.py b/crates/burn-store/pytorch-tests/tests/buffer/export_weights.py new file mode 100755 index 0000000..ecd678c --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/buffer/export_weights.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + buffer = torch.ones(3, 3) + self.register_buffer("buffer", buffer, persistent=True) + + def forward(self, x): + x = self.buffer + x + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "buffer.pt") + + input = torch.ones(3, 3) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/buffer/mod.rs b/crates/burn-store/pytorch-tests/tests/buffer/mod.rs new file mode 100644 index 0000000..8e10afe --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/buffer/mod.rs @@ -0,0 +1,52 @@ +use burn::{ + module::{Module, Param}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + buffer: Param>, +} + +impl Net { + /// Create a new model with placeholder values. + pub fn init(device: &Device) -> Self { + Self { + buffer: Param::from_tensor(Tensor::zeros([3, 3], device)), + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<2>) -> Tensor<2> { + self.buffer.val() + x + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + #[test] + fn buffer() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/buffer/buffer.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<2>::ones([3, 3], &device); + + let output = model.forward(input); + + let expected = Tensor::<2>::ones([3, 3], &device) * 2.0; + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/complex_nested/complex_nested.pt b/crates/burn-store/pytorch-tests/tests/complex_nested/complex_nested.pt new file mode 100644 index 0000000000000000000000000000000000000000..1fca425d11206b00307e33f278d652fa48290bab GIT binary patch literal 14606 zcmc(m3tUav`~Q!mLJ~p}l7^6+?&+-OIMNV>Cb!T~Do#qZ>y!{qqbp5PNu`@yDndxN zvz{&EGBGZpp)n0HF1gLPG$zL7zfY%`Z`b+G_xt_5UjN-|y-sJJecsP!J?q(PJ++?g zT|9+F-RZ>%%Z&Cq2ChxYLI3t6_@*Wbe@$e3>|e`mbL%X_6* z!YFx2`~yA2#>;{w-o8P-mIZn(5BB!;Tqg1I3-S+K7Gm0q>27G~qRK&nxGUXPdNIn1 zuk@Pc>+dd^t2o(<>EY6iyN1uGxOA5r3s;>b9>i#fIT2!IvBHQ6(^stOpe$iDIkK62`!p`OIZRhwe7x0)zehy!}=#TkYlU$Bb@+HD|^su!b$z zv2yG<1$MlIF>+=mxD5Y$Y>0axW88*n!Aw+8`7KlvIn`7_HIp#r&Wwf2=+3Er!K;@o zcb9mGy@HlW0^L1)ygV681-&Ik9R2-P{uLrt%^_mRSaSxs@t?#Q96MHF$VS2loEg$# z)J)n0E3{E=#Rxf2f`pmOfeg8*QdY#6$k0Li&lsDcz}rfgsm_dDhcRryH;>`xZQ$0- zG!CvJVW!L7q7up2%iS_Vftx8|9Gn@)PSm!Ju~}_^lbG2G;G7nqlN|Vk0_ZGZ<~lR; zI??y97@PkWPKg^06F##*!CTnEbCL5FDR_$|%o1nDwL_4Z7S3hXKg@il}rD>5N+=A&23SZOoRdz*@B9a zqoNh47zq>W%xrP#*8wH>`PQ~1D<)1s`nrV_FDGqNkhuG1yEBv69K#Cdbmr!y7GjH= zt(jzw$hmn3NBq;xDH3LXY^2zBTd z#bhcJWl5N9XC}u*z8K0m&3@k9hGNQRaybF&5@t^`B$C-H@6tX6V84XPb7t~egqZ$0 z+68S9Cd>g2!42y{IiiDSyQeX~Cm?O?iQHu^!g^r_bA*ReR4$@1)9B+n1 zGR1NoClr7Z2~+CKlm%~K%3XSLo#7^PiMxF9W-6LjJei>jcb3P{4GcD~tRY^3LEQSy zunygVTzYdKwOX&4lN*>yaq}mA+J0ipXHIQkzEON8kF8eUYFXu((;JvGU0om_b>{2_ z<{Wo{Qjkk~x(V~`2Bxa>FDvFv=KKccLRS|w)4$umTvX6|aQd3VjJdRdxvV%RPZYR; zX0B{tsudW;8B^x!2IgAxnc$%1#!e2ZYJHL`Z{FtuL*AUa%$c-#gWBgz%hyW0{N-8E zG~51vCQU85ll;ZkTO#rGl3R#`pNFV$g5<4h3j=vp?JdX$b|Qb8tyw4dR+c*dwJ|dq zMjwqsYrR~^NoJ^L{CiNZjDygJs`%~?Ki1%nD0qMCE4FFHP;eTXN!+@9g=b0_A^%z? z(Hr|4Xqvu*%H_r^o&Gbj2JylZXA;8&bg>E0k#7nBm7C!DKEPizl_rCcGXY|o$U+gKz-(SbD z>QmoC{oM!f;-^(W_4^6K65oN|lrM7hSKmK3*t<4ddPL?T{yKzoBLGW z{!WKM=Q=pY=>1zAr}vqYRTzf`|cGvmwS~qOgu;B_O&uA5B?AKP5+5~L)STz zJ8N?L+CPR&{vYg{{S*80#b16j_qSGcjC}2ox4-kZdFTE%pJvPdxBhN_7xQ@1$(=R1 zp3nOKH1>b!zr{c4zr63A9di5sWBghEf3|P+PwaOVd-8Mt2k~e9Pwf96#^0p>CjROc z3`L86IVg@EPvd?TgYv81VEf?(@ckBu?Nn~oeDAbBCJ2_ zCy4V%BlO5wQYk2q4Lvp$-FjHdcuAM(e)T`-sT$ zvY|$x0!~+EgW=Fo(3GWt18=OtsJk1n`V5ubUt3NV=p8}dxNR63>I}7KClj^ZA)pbp zA0&<*VDvnT&Rsehbu+IM-oRA2PmbfflT~EShy=X2I~fxlRiVyI2-B2#V88AM67%RT ztv{wIST|@N-h3E@(kqQN4Sok{eYlvW#?OPI+lSc1A${UB!OEN}?!j_buP=rpr0)OCAF+)ke)Z#)XodhK1heoRl~F>7q} zqj`M!;f%#2Su=>p~lCfhC^0MN~ogVK2C$niF>N^~| zB!$?joOEPrc&uT!u#9-sv5FM;UB%R`e8EGUA;xKHB9jL zt?sD%Y9*-caw2Qr+T*h~CNR`E41I4e!PI-2bl$vRh$YiniN z=Y|lT+c?4Qn~|8hFpR3a{e|W`hErW#AISGVg4)(!ACIc@(xLG6z=&;!{!Kre0}oKoA51wjd@^GgYu>==g9 zg=^{M_i9ikS^`$3`?2Ai5QZBlftJ^A%TUmE?O2)?Ru0tB|N5j?rL1l z^;Fn*==h%>dk4 zl8e)R-h>h2$+&lv5lp*x2n^{Ic=_E(oVdXiHN{h5oUriNTlj@O5e-7A?tu#Ly&2-*6Pfakt4&CH=AXhec3#{WLulScD%(I%1sT zd?4WkxTdfiQz8mL<(E`!`m~oc=@di4F_t*lSkW8fdP0-d2`KIziT2YipnT#$j5qK@ z(XC9Gr^E-O-=<>W+(WeKw2V5Y%!9J?y`k`d0A4LifZCCc5P5b0#{6hQLJi_U>+K3~ zT(TK?{l6`*{iFl$OWqQxQg}tcZ_j0CY6pY&UR|tvG6TfcMNn6757mb+lZ=nwQlrtmz&=F}hQ0Mh-sX&oWj>`S zRxyJ75)Z00I2Oy;C1Cy->1^>vtPjzo`=>8OwGoCy{X-9^PD~Q$RoV!wy%}nGBNF0^ zQlawIDVqTM42(H95zI~dgZ&X>C}DbG-00zO+3RcI6|Uj#k7Rt|G=Y3~GZUx(mWlN{ z!jN}=Flg$og$XIKXqcKpj{VkkeO_8!ni ze)vaHzqLEe8>tP$#jCI>H5aR#=F;OUkD%SFU1Z_A6Xjo}(-UX)83&VECr9*a@iu7Z-pLb~kZG0X|;E66!AkltWYuqjZ4+Dl^L z*>-jKRGNv&j=iBqT8;-_FUHWy6-2F6m-bm1g!PLc4r{FWMWOBwp72NJF7u1j@(&S?Zsd@!i{f855Ii?J=94+ycR}#=(1;pXg zSExx3K-rsNP|K``BCR#VXwFpffo3Q9Nq zD9~$ghr&d4e8K)EYuLI0b<68%yxLC8FHNH%6?ss?rsBnIb~x`+GP=JRhE>AP+IRz-}T8yzQZhN7!?Eo z#e6CaVPtM8*9D=bgGps^w&3u`(MUszXqm7dROQ*xFfTsjU;CBLE+~XX-bV0rV^HZr zE+&^7!|vgkXt=QiE+5T9ZNXyTpV}f5tt=*n{0tDYnV>nKn1;R_gZ#&rNVV}pnOkNu zt~y_aY{XY6=^YKc)TIKqxY2}Oo{6V-8KdN@>%{cjcIdX-4nxy|QM3DT!rNP4&b!(` zZ>Mv)MC?AOns|vy_w|L}Vs~OmNE*7WUJr+}xjD^5Bxn+?Cwjd@iEcw8&YL+8oNS-V zQcHPI;}-{+H8B`aXh@{X2Y~MQKInNP2r3q*!YegLbh3(|`UXAVg9NSyOveVE2G)K5 zh9;ach2k$P;rXvi@t2;e_`W0)!tSMG1n)3SJ);V0s}G@L(-Ew^WDeCoPQqg`neZ%T zEjZ=xgsK@am|@Tz>7H3oxH$!j@StGY7ZDI(eu=nUwt}lm#$mvL3PG0jTAUZ%NO=5+ zit3XAp#7wh4*WC=d#z6A^2rp4>nngYS`6Mf?Sc&fxpe29UD)LL11Y|}0p49shkJ|O z(c*VexcyNMN{=t7s1D7CGOsvlotO@Go||cZ@fM6>50e!{1y3E)#&_lA(0A`@%=XGh z(V#-$&;MCw|9U08SZIRAbPSeGWiV%kq0IQnAY|1lz~uv<)@?2#BY#+im1ouqG#?(M zynXv8?LIaWtsk7Cyek*(5fi9;&24`ZqI|9e#yu)53M+N_6@!5l?=uepL274 z1bMVI2Y21&=7-!w$a-6f^~QUM{pETh-Q8Po>;6jgGy_!mxDD+`tiYPB$LX`&V2F?I zk8Uy-f%1}jR87>EXu2JRdP66{#l`Dzwe4=SK4paaf6ayHrDJi@-DBuxxRDmc6bYK1 zABFpu5=iZp{^X@aI=)GNPIDdzC~rqU8Sixs(c3u=M7J#kE`C;|rXiiUKIZ&p)srsm zkperf9>D~!-XI9?h5a9-V9mA!qLnimrlA1mi$-GcjFDhIVl=p?$6=*Tg-vy&28|JY z4azDZpbE3l+UNr1|MrJ$$kcMI=eY_Fe3Jm$=3L&hD~#CAR0e653DJuEm87oPN$QMU zA;4+_)JJGTiUpU${ZfEMVL8Og=1Vl@@)4h!sp#@SOrs=wfUNwU+Pn256U!WXp&q}L8@ybW%Kv!!C`shupuvorq=k7h8xGB z?meY3`md?7OELM~WDm|$6+v;74L-ddjyLx0N2NDupq+RO@<)z`P;oi6S(O1&jUbx4 zcmQgiKS$q}9)Mr76LD=uF;;!j2kFCPc;}LdgG?E$o;ZWvKlB^XGui;R=Lpbpt_5b; zrjw6XVnJ#wl|5S!PDRx+nrl)7!)Z9W=IuZ~@qP%^)+4(XO~Jy_qnil)vPhzRYd7fj*oPk$9L35C1Hv0+ zOdB06L376*BJ#T;sN22>tNDBy^SKt$>%Nm{PVb8bflHvV@eU=P70__H7kQt&7n)Ls zkPC)MSoHXnpza$VQ2!(XMy=GDX zC$nec>-)Pg`eiQK)*J*SHAA$Cj)j=uL)8982(f==Oid5$#lWCU{C3Y4tgTT8=6nh+ zU6Tg<;~Ozlq7J-d9h=b;5+UWG57rtBAz;KeWbj83@=K#hm8%dM?|(&RKZwLZulItR zhlA|ixPDlZRtPZ%G$2`}FIruR$3fo|K;ugStIEb;LL`@y>ia>}Ll08lw1PCwPeg}T z$56UCM|LtR631yBKsWCpvIC<`(eddb@HI`tqVW?keoz$Fe@YS*?w>F z`(9YvyC-HWjDi)*_d>2`DjH_SLulZ9)T7>l>7Gu=n(yUeemqKtYuMoV$6&@DuI?_4 zuvS7Zoh_*JqdGbp>;$j;Tx|G58)xJPqN`N_^g5LXxs&&Rp;ZrRF=-sS>^3DL(PP=5 zZuuzW#bf=FWORw10`o$mh-ic#Dcbh4OnLMOa5X7`_xda0Q)CcUX=>1qy76eKzYFU& z9f0w9Con?P4-&E{afx3JytzZj>kmuNdd_;noAt6H{pBuvp*n=d`?(^o(b`5!XFmvE zY=z>mJlgPR6}Y}M0sRVltSLKBbJm4HvE@#p6~2$?U78E@?Gi}XUjapDdV}hl9!MUm zp=Yo^tnB8Cn+vDn^%a{jH1`Nf2J-Pz@(FygjKG0ad!V6lFZnSk7wrTGp>moYtxWPK zwUv~rxmrNwrLqdi7q2PL{)ynp-ZH3>jD;6(`$O?&N2pzAKn%7=LdiJ^e&e16)m+YQ z{nU&caNZ9F(S@|;=dtM7{}3iyQYu=OjfR&cQ1yHrHM{{tERLt=R%KyzL9DE9@i|&Q z#~&-DcLYh6KIk;T1N6)mQbxTT$;5r2mw!RvyXH9LuKElcUXP?+qjsP{mKEfDC=rDE zzazJkJg`9$3Q2j{DD0OHE^80Ko9ixIJf59&+Q102stZB%V+^`|=Oj3JRvGPRA=;f5 z!&22P==#M?(sO+}R@DOdr@3--X&FYyx?|dpU!vyrGJ5BEPfWcTOHO@Of_lHD$!boQ zP($GatQhnQO&E7y#&_`{cFR1WI5>}nd=>}wDY2wVB@cK*lBnkTJA&nhd6=5`kSt7h z##b)KfERF$zI`b|Kg)9LU$6;9XJ5$HRg^&SB_CM$XbyUQT?{yX3i36+Au5~G@aV{u zXuWA7O}Om{`Hf>qb>mLd<|W{}`6=M&nG474laN1mB%NC`0`(eZk}zH-*6Hg)@i0nk zA0&b2_7b?bOC8QnmZHjM80R74;qx5c`}ZMQ@%9 zaz1%cty!f|ZSq9sVm^XM<97q~D~GVutxz^b7gm&(Ke|p|3+W<;}Gr zRT@Lc&ASJ%*Mc38Km7&aC7aoNqp1v@D#9Y<$-#@L{Ut0zrj^cBhHuBcB zzXtKtJKd);<@N>D{@SN%xNQkj=J~KLqnEG)s$E%DUnn#&j%5w=wh49XLxe^*Tye!d zME=>$pto)h=;>z>QH(WxxG))O999Zl9}N`-O!Y#tK+3w=`mwcx#t3!P3PxTJ0?`eBh{b4@V z57wcT8sS36Biqpc=L>mXd@bX}=nEfp@5hdQ_yNZ(2xOgp%OLUG{|lO4^P!RT5PqDp zk=>{iFVq`4nTp;FA=Q(wlCrUrSeFwL_I>Oc*4N%csCRw^R-Q}%Bf%CFJ^E6}>;45B zG0_rhtJVtLzTHjV=*aMXOf-D^-iK`pUXA)6*Rke{zY;#z?aSs{5cY6wB%2(ekB#5& z0-Hz9tUR85-ESz>bH$bN_IL53+j+cjH<J_aJ-nyNLF}XD;p7=fH z1rBA)GHamM^+#xszMXaQRBDdfF~8O&8gel!Z(Zvc$Q#(H4sJWQUq=}ns?8zq?|cW@ zvwwg84&+Vf)DE|eJGP^p_Wl0zeaZ99G+?(AI1VVleD%t}52H3GG(;`vp$L}EC zTgWQwSPFfEd$L-l5g?uPK^8iH0Em@xN%e}UY~@Zfw9->%4<9!XCfHu0=k%OdzH}zU zY&`*s3}h(!DT7v(7lS}Ui4A&RhoU*V>9+IzS=}`}q2AdT;ye5u6kWOly$6kCSKBMG zHNR`H(_EwQ!0A++SEho|7Bg9cEk&R?_Bj}Ai~!r~%0hdUVZuWTXR`@A-;-7MrwF6g z4`v6As=(^ue44AO#HOYU!-OHf(+dI{p=hQvtvh-Tlr)D3FHWw2;pZ&a#sXcT?#-S; z)0+cXTg@F9Xs02xGjU+|m|3wG5AkMgoh{GN{%IbGAEqjF{c&+u|1psFS*Q0CR}L`p-``K|^PfN8%jc2*y8fi49PyvjKbZBBTe_OH za=v^fk+-f#;4|L%_B}OjE}jeW3iNjO^suXeT!Ct*2!4A;>jJd4jE1fl9dxuVB5TVKb7kF~>M-q~ zqg7#8TSh}y472u();CjIhP-gLQyu2*8D0Cdp(}>PKWB(Fy3k?Sp3${W8@ggxwP$E` z=~HpvE_7J8XSDvqv8_*QyJAdg&uIO}Ub|4|9$M*aTsnkD>%Yg^%5WRg1)@W!x6aYq zGUVxUr!qQ(dh7hUErUO<3r2^&w9cK|x}&TMMu$jmo&L5(=#B4OMu$Leox!$6, + norm1: BatchNorm, + fc1: Linear, + fc2: Linear, +} + +impl Net { + pub fn init(device: &Device) -> Self { + let conv_blocks = vec![ + ConvBlock { + conv: Conv2dConfig::new([2, 4], [3, 2]).init(device), + norm: BatchNormConfig::new(4).init(device), // matches conv output channels + }, + ConvBlock { + conv: Conv2dConfig::new([4, 6], [3, 2]).init(device), + norm: BatchNormConfig::new(6).init(device), // matches conv output channels + }, + ]; + let norm1 = BatchNormConfig::new(6).init(device); + let fc1 = LinearConfig::new(120, 12).init(device); + let fc2 = LinearConfig::new(12, 10).init(device); + + Self { + conv_blocks, + norm1, + fc1, + fc2, + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<2> { + let x = self.conv_blocks[0].forward(x); + let x = self.conv_blocks[1].forward(x); + let x = self.norm1.forward(x); + let x = x.reshape([0, -1]); + let x = self.fc1.forward(x); + let x = relu(x); + let x = self.fc2.forward(x); + + log_softmax(x, 1) + } +} + +impl ConvBlock { + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let x = self.conv.forward(x); + + self.norm.forward(x) + } +} + +/// Partial model to test loading of partial records. +#[derive(Module, Debug)] +pub struct PartialNet { + conv1: ConvBlock, +} + +impl PartialNet { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let conv1 = ConvBlock { + conv: Conv2dConfig::new([2, 4], [3, 2]).init(device), + norm: BatchNormConfig::new(4).init(device), // matches conv output channels + }; + Self { conv1 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.conv1.forward(x) + } +} + +/// Model with extra fields to test loading of records (e.g. from a different model). +#[derive(Module, Debug)] +pub struct PartialWithExtraNet { + conv1: ConvBlock, + extra_field: bool, // This field is not present in the pytorch model +} + +impl PartialWithExtraNet { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let conv1 = ConvBlock { + conv: Conv2dConfig::new([2, 4], [3, 2]).init(device), + norm: BatchNormConfig::new(4).init(device), // matches conv output channels + }; + + Self { + conv1, + extra_field: true, + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.conv1.forward(x) + } +} + +fn model_test(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::ones([1, 2, 9, 6], &device) - 0.5; + + let output = model.forward(input); + + let expected = Tensor::<2>::from_data( + [[ + -2.306_613, + -2.058_945_4, + -2.298_372_7, + -2.358_294, + -2.296_395_5, + -2.416_090_5, + -2.107_669, + -2.428_420_8, + -2.526_469, + -2.319_918_6, + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); +} + +#[test] +fn full_record() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/complex_nested/complex_nested.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + model_test(model, 1e-8); +} + +#[test] +fn full_record_autodiff() { + let device = Device::default().autodiff(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/complex_nested/complex_nested.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); +} + +#[test] +fn half_record() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/complex_nested/complex_nested.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + model_test(model, 1e-4); +} + +#[test] +fn partial_model_loading() { + let device = Default::default(); + let mut model = PartialNet::init(&device); + + // Load the full model but rename "conv_blocks.0.*" to "conv1.*" + let mut store = PytorchStore::from_file("tests/complex_nested/complex_nested.pt") + .with_key_remapping("conv_blocks\\.0\\.(.*)", "conv1.$1") + .allow_partial(true); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::ones([1, 2, 9, 6], &device) - 0.5; + + let output = model.forward(input); + + // get the sum of all elements in the output tensor for quick check + let sum = output.sum(); + + assert!((sum.into_scalar::() - 4.871538).abs() < 0.000002); +} + +#[test] +fn extra_field_model_loading() { + let device = Default::default(); + let mut model = PartialWithExtraNet::init(&device); + + // Load the full model but rename "conv_blocks.0.*" to "conv1.*" + let mut store = PytorchStore::from_file("tests/complex_nested/complex_nested.pt") + .with_key_remapping("conv_blocks\\.0\\.(.*)", "conv1.$1") + .allow_partial(true); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::ones([1, 2, 9, 6], &device) - 0.5; + + let output = model.forward(input); + + // get the sum of all elements in the output tensor for quick check + let sum = output.sum(); + + assert!((sum.into_scalar::() - 4.871538).abs() < 0.000002); + + assert!(model.extra_field); +} diff --git a/crates/burn-store/pytorch-tests/tests/config/export_weights.py b/crates/burn-store/pytorch-tests/tests/config/export_weights.py new file mode 100755 index 0000000..8dd194b --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/config/export_weights.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.fc1 = nn.Linear(2, 3) + self.fc2 = nn.Linear(3, 4, bias=False) + + def forward(self, x): + x = self.fc1(x) + x = F.relu(x) # Add relu so that PyTorch optimizer does not combine fc1 and fc2 + x = self.fc2(x) + + return x + +CONFIG = { + "n_head": 2, + "n_layer": 3, + "d_model": 512, + "some_float": 0.1, + "some_int": 1, + "some_bool": True, + "some_str": "hello", + "some_list_int": [1, 2, 3], + "some_list_str": ["hello", "world"], + "some_list_float": [0.1, 0.2, 0.3], + "some_dict": { + "some_key": "some_value" + } +} + +class ModelWithBias(nn.Module): + def __init__(self): + super(ModelWithBias, self).__init__() + self.fc1 = nn.Linear(2, 3) + + def forward(self, x): + x = self.fc1(x) + + return x + + +def main(): + + model = Model().to(torch.device("cpu")) + + weights_with_config = { + "my_model": model.state_dict(), + "my_config": CONFIG + } + + torch.save(weights_with_config, "weights_with_config.pt") + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/config/mod.rs b/crates/burn-store/pytorch-tests/tests/config/mod.rs new file mode 100644 index 0000000..6c8deb3 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/config/mod.rs @@ -0,0 +1,53 @@ +#![allow(clippy::too_many_arguments)] // To mute derive Config warning +use std::collections::HashMap; + +use burn::config::Config; + +#[allow(clippy::too_many_arguments)] +#[derive(Debug, PartialEq, Config)] +struct NetConfig { + n_head: usize, + n_layer: usize, + d_model: usize, + some_float: f64, + some_int: i32, + some_bool: bool, + some_str: String, + some_list_int: Vec, + some_list_str: Vec, + some_list_float: Vec, + some_dict: HashMap, +} + +#[cfg(test)] +mod tests { + use burn_store::pytorch::PytorchReader; + + use super::*; + + #[test] + fn test_net_config() { + let config_expected = NetConfig { + n_head: 2, + n_layer: 3, + d_model: 512, + some_float: 0.1, + some_int: 1, + some_bool: true, + some_str: "hello".to_string(), + some_list_int: vec![1, 2, 3], + some_list_str: vec!["hello".to_string(), "world".to_string()], + some_list_float: vec![0.1, 0.2, 0.3], + some_dict: { + let mut map = HashMap::new(); + map.insert("some_key".to_string(), "some_value".to_string()); + map + }, + }; + let path = "tests/config/weights_with_config.pt"; + let top_level_key = Some("my_config"); + let config: NetConfig = PytorchReader::load_config(path, top_level_key).unwrap(); + + assert_eq!(config, config_expected); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/config/weights_with_config.pt b/crates/burn-store/pytorch-tests/tests/config/weights_with_config.pt new file mode 100644 index 0000000000000000000000000000000000000000..3c19e80b5ea126917ca923fb50ed63e14b26c7f3 GIT binary patch literal 2280 zcmbW3O>7%Q6vxNj#7^9#Ewm|ZK1wJMwrT3vYddkEvB3gIXayJp(yCRoT6-tirE72B z?l@^G8AU>gsCpARZz(}L@ z&dmG0|IB-D$2nQ(Luga4C2Eqvj;mCg9k*|VGe zDj-o|_N5KAWb4=hU$JOg7A_LgF1w-U934AYf6^?H5cp5#K@gP?UA|I`hCM#?6^ZQ> zjj&oFrsalI2Nx=)rK<#&UE5LDq!9Frq7tN5E?uduVhDI9p{!+VXQX<6X!dLyLaXON$7)3r-j zU15g~vrFkuuCaEVA0VGG%LF2_kGtao1>3eD`r6(KmpC9P{nWLAEz5?OGE9H-wU+4; zl@a30kPu}bjg^4grkVNNGR%ucj6>FK$I>B*l>;plPvOEcJSy(0TuOi8$z4i+7vl}@ z>C|a@!edo9v#0bt-hjouO|NNI1w+cMRKhtqII#YCVh1ucQuNmuj0&|M=;x zKTp+v+xf8e`pdU&GDUFd_S;9P8rFL$n(VG9n$3*&RD|kBKD+bmi?yk%fjaYve);N+ zA?lCyUOs2K`;@X7v8T`GXiff=PJJBxb#eOKTdC|5OSQ|}AJwdjU*G)T_nlP!-7ixI z*M`&Yk9=Rdc6DF<&O7hbn8rgt|8XTk^|9Vv;}{Bd)Yz#0Y(`)rVe*Czcichb{H zx^_@=M5kLo-*y3Y&b6Ez2+TC>j|-=FN$kdF?e23w&l!X+vX2~X)Q(SWH~zn{+dn7w N^VuM^V(Y!Ne*^syKwkg= literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/conv1d/conv1d.pt b/crates/burn-store/pytorch-tests/tests/conv1d/conv1d.pt new file mode 100644 index 0000000000000000000000000000000000000000..b1c43ee30ab6a13869041fb4956a6a08ee55408b GIT binary patch literal 1805 zcmah~%Wo1v7+)3&bZr4!+gkgOYO9zi?D9qh8)DYPJxKA9O)t2>rfghGhaFmNLNqqD z(Ss*Fc+n;v)Ohr0=*f7{laItdz$SXECpDVr%);XCDrLx*h1vN%=KH=GGMv>$5KbrY z&u|m{M4>#t$P@!b0Sf+wOEQ~w6SKx~oh%f}vMd&$RGwF;GnJxP5sN3K0;B+OC<`n; zz6VjOME)gFnkzv-7GSwjDEV_$D9H+ytBCokBo}i~oL9<~++vUdR-cb|Ae@3$3UeZ` zH6Ro#mj!sXF-d_g-h$T9z|LE-#zGZ1I41I)IUJ?}GDj~&^t<+#-fV<@2NC-SC(<<1>Syn+`gP|XSU0E>D+E!58)}VnS2!;>` za|Bxr@=J>!DtqYRcm0J??#StmqjSxY=gb3Td$8tvjmu^fqiN|7R8E!5)Jlq z7KQK9_5dbpV5(%83xWeRa8RAADtUi~b2uL@jcz_h3B!?TTXqjIqLn>=6^dnCP&Pee zuDjL+vILZ2DL`ZcSsm=jksisdK-CjrEmRb??kV ziX^{o^sU`bJ$d@BP9<-ou(KagE9*tBaBSyn%HWJ)(-WPYU75X;BwjApvChxi4+rr< z!m*uB(x4M$(}~VH?Q$?px>8=x>*MtAqp8iCr#9%Bf%^2tc%7E-CW+7F3+(IOhjk9` z9Y=d#E@D#i)$B4h?d@nRL}7)u59ZF3h#g0Jg)t(eDfrdmD`G_wWNBHzmq$)2vgtra zK~o}_csLY_G7KG!#1fHsI2MY9>1ZMviv)voBAj5tOgIvc2C>VJ@2|b_A%{3JoCTY0 z-F*7P;l?iv!*mat!TQ$s7=YibeF0h2Ne5^%RNrw%=#~k}n4$Wn zFhcQVs#it1Gv-QPZI{9D)?a9|hs;=g4aN-EEczd6%KpDQGMwFhxCt4z9%+H`{{$Y| i^Et{n9^Vlm9tfwtdWLT%H1O*T*QNe)AQWETSo;Ty0*|Nw literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/conv1d/export_weights.py b/crates/burn-store/pytorch-tests/tests/conv1d/export_weights.py new file mode 100755 index 0000000..bda180e --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv1d/export_weights.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.Conv1d(2, 2, 2) + self.conv2 = nn.Conv1d(2, 2, 2, bias=False) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "conv1d.pt") + + input = torch.rand(1, 2, 6) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/conv1d/mod.rs b/crates/burn-store/pytorch-tests/tests/conv1d/mod.rs new file mode 100644 index 0000000..236bd31 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv1d/mod.rs @@ -0,0 +1,97 @@ +use burn::{ + module::Module, + nn::conv::{Conv1d, Conv1dConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv1d, + conv2: Conv1d, +} + +impl Net { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let conv1 = Conv1dConfig::new(2, 2, 2).init(device); + let conv2 = Conv1dConfig::new(2, 2, 2).with_bias(false).init(device); + + Self { conv1, conv2 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<3>) -> Tensor<3> { + let x = self.conv1.forward(x); + + self.conv2.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + fn conv1d(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<3>::from_data( + [[ + [ + 0.93708336, 0.65559506, 0.31379688, 0.19801933, 0.41619217, 0.28432965, + ], + [ + 0.33977574, + 0.523_940_8, + 0.798_063_9, + 0.77176833, + 0.01122457, + 0.80996025, + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<3>::from_data( + [[ + [0.02987457, 0.03134188, 0.04234261, -0.02437721], + [-0.03788019, -0.02972012, -0.00806090, -0.01981254], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn conv1d_full_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv1d/conv1d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv1d(model, 1e-7); + } + + #[test] + fn conv1d_half_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv1d/conv1d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv1d(model, 1e-4); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/conv2d/conv2d.pt b/crates/burn-store/pytorch-tests/tests/conv2d/conv2d.pt new file mode 100644 index 0000000000000000000000000000000000000000..3f19ad2b4c045be2e429c61cada0d329ee7423dc GIT binary patch literal 1805 zcmbVNNoW&M7=D?gNjkMnv~};|ZYr9!X_~efC6tKF!6c%_9E@3pX6&@-%e+)76ucC1 zp@?{@Ac7v$iWC%F6FiCN$>rd|iwNqeCwDyf{%oCzR?$EFgtvU(`~Ls^@1G#A)gfdw zqF>B}no%+{GUiO#QgITujt)ryzX?Uvc8p0TGm<1EiI^FY+1=TckQGt`Vv?{Jb%wFV z5?Ms4fyiM!B8Y=&f|(@AWRq!YEJs91W@A|)krSmg2z z5WFai8zXF0T0)G=xG7D~P3zJnUI#X%4Ln1zney?>BwoyGCh?LILo>lGVZ5{&oU;<# zG8KmtFQ*(-(+ahwl?qL(N{p)sZVltM+yriq)KgutVS&UU^SGm^d5tAPXAxdIf!7st zGbUtZnsmIL*T|8&N+fv01m2id%{lR=3B0*Dmy;9LAa5{U9X`J8CQTlAaKREVBU7aW z65~Y3z~u(~4aypjL_#D1C?|bySL&$>@P#QG(e?@@#AdSx{C!G3b#?uHboNv)_vmRy z;lshxd1kJYYZ|4DhBO1UH8*P!T~cOf5IbN3$b%-UYxfrkYCCmx@Q#~JW@|33q_ zeHXaYo;SYG3w>eNm1yDN{cL``v$Zf~_>ec>IN9%izMo?r4{(=0&K3@54sjoMKJk6J zHN#DR8R34+T`qv`_vc@&huFav*4>1372V~^3-}RovB2=`!r)$pqQMttXhW_FgSB}1 zvXB+yl6W)@%_AnJ0)E@SX2Q|a<#Kg99QJOv$LsFt^0+)*c2~F8+W`W z?JiI`t>60HLKT7sc@2oIc0T2UFu{V#YWiR;SXugJ6;MkLDTAvos1~g(zeW{$-#ohX zSZbllic>?)v?D51+Z?q}<)y2k&`gz3XDw8D+f6FLX8J8DL(4T%i&fr1n+hAA$Ns)L vg1lb8wG3(3ZmWXfI{{Ythn@!Zl@fwcTybdzIukWm4)S%yk%6LMe`)VeKAV-_ literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/conv2d/export_weights.py b/crates/burn-store/pytorch-tests/tests/conv2d/export_weights.py new file mode 100755 index 0000000..cdc3f76 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv2d/export_weights.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.Conv2d(2, 2, (2,2)) + self.conv2 = nn.Conv2d(2, 2, (2,2), bias=False) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "conv2d.pt") + + input = torch.rand(1, 2, 5, 5) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/conv2d/mod.rs b/crates/burn-store/pytorch-tests/tests/conv2d/mod.rs new file mode 100644 index 0000000..fca78a7 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv2d/mod.rs @@ -0,0 +1,133 @@ +use burn::{ + module::Module, + nn::conv::{Conv2d, Conv2dConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv2d, + conv2: Conv2d, +} + +impl Net { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let conv1 = Conv2dConfig::new([2, 2], [2, 2]).init(device); + let conv2 = Conv2dConfig::new([2, 2], [2, 2]) + .with_bias(false) + .init(device); + + Self { conv1, conv2 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let x = self.conv1.forward(x); + + self.conv2.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn conv2d(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::from_data( + [[ + [ + [ + 0.024_595_8, + 0.25883394, + 0.93905586, + 0.416_715_5, + 0.713_979_7, + ], + [0.267_644_3, 0.990_609, 0.28845078, 0.874_962_4, 0.505_920_8], + [0.23659128, 0.757_007_4, 0.23458993, 0.64705235, 0.355_621_4], + [0.445_182_8, 0.01930594, 0.26160914, 0.771_317, 0.37846136], + [ + 0.99802476, + 0.900_794_2, + 0.476_588_2, + 0.16625845, + 0.804_481_1, + ], + ], + [ + [ + 0.65517855, + 0.17679012, + 0.824_772_3, + 0.803_550_9, + 0.943_447_5, + ], + [0.21972018, 0.417_697, 0.49031407, 0.57302874, 0.12054086], + [0.14518881, 0.772_002_3, 0.38275403, 0.744_236_7, 0.52850497], + [0.664_172_4, 0.60994434, 0.681_799_7, 0.74785537, 0.03694397], + [ + 0.751_675_7, + 0.148_438_4, + 0.12274551, + 0.530_407_2, + 0.414_796_4, + ], + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [-0.02502128, 0.00250649, 0.04841233], + [0.04589614, -0.00296854, 0.01991477], + [0.02920526, 0.059_497_3, 0.04326791], + ], + [ + [-0.04825336, 0.080_190_9, -0.02375088], + [0.02885434, 0.09638263, -0.07460806], + [0.02004079, 0.06244051, 0.035_887_1], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn conv2d_full_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv2d/conv2d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv2d(model, 1e-7); + } + + #[test] + fn conv2d_half_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv2d/conv2d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv2d(model, 1e-4); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/conv_transpose1d/conv_transpose1d.pt b/crates/burn-store/pytorch-tests/tests/conv_transpose1d/conv_transpose1d.pt new file mode 100644 index 0000000000000000000000000000000000000000..7929d6e0a33e62ff2dd16f1aedebe56c9d02b11e GIT binary patch literal 1875 zcmaKtOK%cU6vu}F3LV?pR$B_KijP{FC}ntKgfzsAiL;R6BcvM~U{WTI441jI+JtCq zYNHERx^SaST&SPH(3NqaD<6p;z$UtESK4Tz_b`Pp(CHzUf&2KK|2^m2J1O35p(wkZ z>M|$QOXZc)Vis3~l3G?&kuA&=1T1*VH)SqqrRKrz(fPb0%VHi&N=aodR|;Z9EL@QC zm_gKZ8kszqK7s}i>s=D1g(60D9xIi6(VMMeNmiL`Ma)$txsb(TNmVM@ML&bg9#6(b z)~RHsuplCfwuIqiMZj0IP6k;sCNjr|dNOA4QLZ8zZ^y_!$5T8FpEcCWTgVU78e$$6 zLC*n<`qHRB!?Z-@Btb<3MdGn&DQZL(9W2^-8l%AmX%!9erd2eo5e{K=IE_YH+wwQJ zHL7Fyk%KUZnz1%D&ZZid#u~?HB8`qzYv^dEmnh2K60ra=M#maz-JT5TrO@#jI?;&7 zqF7N$pwUU*q-LzmJ)l!HG+EU3`O)bbnrie_)tooQ+w3owCN^J@fI&+crrkvib*DXd z2a5`%mP__)d2b}2e902VvIzLq_;+UyQ3TBx@1R26c-Zlbk4v(8cGJgx&r3g75?B8U z8|y3Y>+ZR7f~L1OhS#1YUcdQVXX5u0pll~>=G;UTwEdKg8z^JBq{B$r?fJ)X>fLf3 z{QPQ`S39_^- zz%`PU3S4s1z+p!q*k~{i2(zp&9E!w3(O@JH3HrjZa3tjS`(nWu8)So_XxI-bzkRv; z(L)qMOYtTU+q(JM4~G*5bY^%Dc4FEOflG(@MNVB8rgg45IqfCu&~dI4*roHcGK8?5koMxyL*UZgTM3D_KDzDoF{azj)?Sd^s_Ue*=Wj|!Nt1V2mz2FLE5-Np uoY`G`)O@yufwv7<^SMLv1@@bu!N|JC&^Fv}#9%nZTO0puWEITU=Kcqx&9g!P literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/conv_transpose1d/export_weights.py b/crates/burn-store/pytorch-tests/tests/conv_transpose1d/export_weights.py new file mode 100755 index 0000000..a15250c --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv_transpose1d/export_weights.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.ConvTranspose1d(2, 2, 2) + self.conv2 = nn.ConvTranspose1d(2, 2, 2, bias=False) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "conv_transpose1d.pt") + + input = torch.rand(1, 2, 2) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/conv_transpose1d/mod.rs b/crates/burn-store/pytorch-tests/tests/conv_transpose1d/mod.rs new file mode 100644 index 0000000..27ef048 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/conv_transpose1d/mod.rs @@ -0,0 +1,86 @@ +use burn::{ + module::Module, + nn::conv::{ConvTranspose1d, ConvTranspose1dConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: ConvTranspose1d, + conv2: ConvTranspose1d, +} + +impl Net { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let conv1 = ConvTranspose1dConfig::new([2, 2], 2).init(device); + let conv2 = ConvTranspose1dConfig::new([2, 2], 2) + .with_bias(false) + .init(device); + + Self { conv1, conv2 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<3>) -> Tensor<3> { + let x = self.conv1.forward(x); + + self.conv2.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn conv_transpose1d(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<3>::from_data( + [[[0.93708336, 0.65559506], [0.31379688, 0.19801933]]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<3>::from_data( + [[ + [0.02935525, 0.01119324, -0.01356167, -0.00682688], + [0.01644749, -0.01429807, 0.00083987, 0.00279229], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn conv_transpose1d_full() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv_transpose1d/conv_transpose1d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv_transpose1d(model, 1e-8); + } + + #[test] + fn conv_transpose1d_half() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv_transpose1d/conv_transpose1d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv_transpose1d(model, 1e-4); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/conv_transpose2d/conv_transpose2d.pt b/crates/burn-store/pytorch-tests/tests/conv_transpose2d/conv_transpose2d.pt new file mode 100644 index 0000000000000000000000000000000000000000..07b0fbea3bfa2259ddd3e35ad4d6e95a881f4a1e GIT binary patch literal 2003 zcmb7_NoW&M7=R~}wc}RTE^6IvDw<@HCev0kNRUXGgGoh=Js2m+(2S0m{4+0gfr6JJ zE{KQ+Q4m27Zit{@T@pNr=*i{a!HWp$sV5f{55AX4Lndj&zVIjHE#LdzoBzFu3050L zxm;93>7ZJuw3-`DB2CQcc~zJE8E-~Jq9=b)iALo1TAjCCD`yk z3j*e3mRQX~Fnb8*B$%pNN)~mPo5k~HZCQiM!n~|gpb@lUo%}eH# z&TV-6^lFkW67$rrQGP47Ch;%x%}vbNXoQ=&xv{{B&+#y|^3h}BanJiC@uG*j_Pegqg|@cPQg zE*j5HqPamg)zfI;DJP#UX|kxu$3$|CCFM*svhM$c*uLICAjGo1P>|~n_VscBuGbd` z^>bVw#|492us;|I^!ozD@{(iYZ#On15fdy#wtDl{9>@;TU@A@T;d)H%A?!3Eta!Qx zO!X|+b83%7n~Ae~I*xIG>mjxIY=*Sr`?^7<3}NdbwaI0MTx<&Q*F$RaqusQe7=HUT zm{hW|o>Ke#x=oZf(^00+teD_%Y^)6KvTmwk$lH#Tso!7Z)rQLva Self { + let conv1 = ConvTranspose2dConfig::new([2, 2], [2, 2]).init(device); + let conv2 = ConvTranspose2dConfig::new([2, 2], [2, 2]) + .with_bias(false) + .init(device); + + Self { conv1, conv2 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let x = self.conv1.forward(x); + + self.conv2.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn conv_transpose2d(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::from_data( + [[ + [[0.024_595_8, 0.25883394], [0.93905586, 0.416_715_5]], + [[0.713_979_7, 0.267_644_3], [0.990_609, 0.28845078]], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [0.04547675, 0.01879685, -0.01636661, 0.00310803], + [0.02090115, 0.01192738, -0.048_240_2, 0.02252235], + [0.03249975, -0.00460748, 0.05003899, 0.04029131], + [0.02185687, -0.10226749, -0.06508022, -0.01267705], + ], + [ + [0.00277598, -0.00513832, -0.059_048_3, 0.00567626], + [-0.03149522, -0.195_757_4, 0.03474613, 0.01997269], + [-0.10096474, 0.00679589, 0.041_919_7, -0.02464108], + [-0.03174751, 0.02963913, -0.02703723, -0.01860938], + ], + ]], + &device, + ); + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn conv_transpose2d_full() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv_transpose2d/conv_transpose2d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv_transpose2d(model, 1e-7); + } + + #[test] + fn conv_transpose2d_half() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/conv_transpose2d/conv_transpose2d.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + conv_transpose2d(model, 1e-4); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/debug_test.pt b/crates/burn-store/pytorch-tests/tests/debug_test.pt new file mode 100644 index 0000000000000000000000000000000000000000..da686a38578c202c1736a4455e23f4568c8778c5 GIT binary patch literal 1598 zcmbVMOK;Oa5Z*kR1Q%4&veqbIb z5=&Pqy*$K+5NY)(%S=)v-SaK}VDeF7*DEpm3T%Pxi0j*%}M7OVF@0u*{DanmcLByPI; z)DQM7ZXVDegc^E#N!&YjVFR&0|2le$bkUQsS4ks+J#Bz#d2WBOM+aPQ%W?SAMkhR} z4J5Gjg8fWGar9&YWpYa-u=|ozAA6>^n{(Gaq2sxndlWg0Z-2j%gZk*n9E#+o$N^PV zll8XtzXS*wZ9&0pxWGr-$mC)9)BDTt5j~m7GLc0lvtPVE7nQn%6%Rl3AN{-kYosPt ztR8PZq=5?xcx9o8P1n)e*XJQ1HyiavYg5)_MNu0YN?p}7xlvbDMbTO#B)Z)gR2g5EJwNO(5+;&_yBA!6s8191Rx238`;zu`>)h|N?SN0jnv1ZM}vOT;-d zKXE8gk>v=~*C^C%65$DT;{2lcG9#`+W(|!9cA&mupvuJb)It`B7B07({KS%Ah#*%XYXl=u zjX@z>1T&CME+{Qz_m(Ruv}5khi$DkS~H0 zs3AT#wImVbuR?yXynsdoP?Vuiu(nVLYV!}|^S9rk-?tM8x1d~ScS%Gtf=K1=V9 zzErjU-nX;+W&F+d7dOtf6Z+@2f8)M}eGg_O?+g8W-9AfW$-W!TGxw$SeXzf>bMoGK zeTsIfjBfiYu6o-A8d=+Jw%oLzK}yHrYTVo99oXI><9VS zU~P(91<)&#i>P^ zi8+~7iJXJHTAG^~7+M;e zn1WpSH+-&-2GE5d9N^6eax6UOBBwzikOT_A%*W{ZkyDQ_irxTVsz&ICr(1LbkRw9? z#ei!l20)S=x^CnM5Jb^^0_aVI_0Z@;*Nz-A3MkrzfJFmBJN7sW@MdGvf$EZD)`jZ_ sWqA;Q(LSI&4g@ Self { + let embed = EmbeddingConfig::new(10, 3).init(device); + Self { embed } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<2, Int>) -> Tensor<3> { + self.embed.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn embedding(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<2, Int>::from_data([[1, 2, 4, 5], [4, 3, 2, 9]], &device); + + let output = model.forward(input); + + let expected = Tensor::<3>::from_data( + [ + [ + [-1.609_484_9, -0.10016718, -0.609_188_9], + [-0.97977227, -1.609_096_3, -0.712_144_6], + [-0.22227049, 1.687_113_4, -0.32062083], + [-0.29934573, 1.879_345_7, -0.07213178], + ], + [ + [-0.22227049, 1.687_113_4, -0.32062083], + [0.303_722, -0.777_314_3, -0.25145486], + [-0.97977227, -1.609_096_3, -0.712_144_6], + [-0.02878714, 2.357_111, -1.037_338_7], + ], + ], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn embedding_full_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/embedding/embedding.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + embedding(model, 1e-3); + } + + #[test] + fn embedding_half_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/embedding/embedding.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + embedding(model, 1e-3); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_false.pt b/crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_false.pt new file mode 100644 index 0000000000000000000000000000000000000000..27e893591fb9065c19f43b79e1d22d420642face GIT binary patch literal 1766 zcma)7T}V?=9KYLi>dep5GCxu(o0{{^O`YVu(1;OU#Hj?spmBG1@?N}dp4~I0NH9<# z=piIBv@fFGk_rpTyz`~#B`C`t>Ltt9(w4UZOXPb^FRLFyL-?1{eI{Fb)B3p zjiNF$sei1TN~eG?IN-hXmSUUBr67Y0C<9u>~$mHQ~Uw< zP@@6rj7C=m-lpJ{d>?>mku3&??B`KWqkyEJ$W4fQS^p%@Ip4DJwunOz)ZdY)X!L!R+R^e(ee!TxEnc&=jjrUeT3W|`TJI0d+%978ex6`X{`9ghIuEd2-%f`{ zE`ZRD?^D8(C#J7vYk!>PjcGwT@Boa^8d)TG7m0{j5_j4OaBN9)|TSl3Z z^G0jTVXxf@aWwuG13B^1t-e!Q%t|~pa~*X!IkPF8d<6s~UY5@DWE8k1uftyTe<9`; znzq?!y0yvLY;A08X|h^dX_{_sv|7v-v)M{F+bmYvW+5tT?@X;2u|ncbPD8|QO|IC^ zDJL)DY}+}O2o>816>%`rxGj@HZ8csZR%|Ji#$gp>`AM-Njg$x!TO08}m3a821~MlC z#iqI}PSN#LK+(cVM2byET^v#&o^VMeB`*{wr`I2i0#xYgx1h)*BDQ&bL&iO}XJmk4 a!~4ibf*Ol1Ag`TVdib9n?;`6XYyScY48SP> literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_true.pt b/crates/burn-store/pytorch-tests/tests/enum_module/enum_depthwise_true.pt new file mode 100644 index 0000000000000000000000000000000000000000..e9488a6ef1681d1c4d03853b687ceb245431f795 GIT binary patch literal 2288 zcmbW3UuaWT9LG#qoc-!Qc zd(yJ8FhQy~Mu$F#4>}4(eBXQXR@j3$^`TG(qWB^VL{MSE1kbrQ#(QIKS9iim!uj+0 zeSg36`<;7J*wu!R*Nfh31IUe}`N~|jAeD%`sAy7_lq=FiAxCn7(pfc`aH7+v&pGq? zqN+-Hq7>&f?qs<5xIlB<#z~%F0Jan^0SLS9VXfw8ONwt_Gr;I@k+A|Kih6i`X#SE1y z;K92Ic)!NThn?7gUD!pS!-qTQ$AujPxJxpVwu8XVH0;WBTJ_B+ISoFNsd2eYHsAtu z${y?_&_$uxxdh$Vu>`vf%pL+Cr{R-#ZkEh$_EEkjd7KROt%!eLn-;_5@jX+oe16HODkP~fW|=o1xfCXm7& z@0Zv9{9?q35bG)1tQ$m~EwgUsdmB1jXnLjP;!q^VfJ6W-sIUu!218RAN)}J%cK9q#a%t|@FO|C9`;N#9lva5M!Tp6 z*0<8(vyX$AOdQ-+9Q-%^)6Zki^!V2zzxVyO|NUs9rQLerBoqE^8Lp?4$T5%a{Wkjz zEepiDy~kr{q^0G6p1hh=R&uKHLyq0VS*4In3~xgS9}&Z$XqXQPf)Ej=B4IJi3!!L8 z6sIO5q2OdB%15UnlToH}?R>|ek1Aw6g&j<6DwzNP literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/enum_module/export_weights.py b/crates/burn-store/pytorch-tests/tests/enum_module/export_weights.py new file mode 100755 index 0000000..7a22623 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/enum_module/export_weights.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import torch +from torch import nn, Tensor + +class DwsConv(nn.Module): + """Depthwise separable convolution.""" + + def __init__(self, in_channels: int, out_channels: int, kernel_size: int) -> None: + super().__init__() + # Depthwise conv + self.dconv = nn.Conv2d(in_channels, in_channels, kernel_size, groups=in_channels) + # Pointwise conv + self.pconv = nn.Conv2d(in_channels, out_channels, kernel_size=1, groups=1) + + def forward(self, x: Tensor) -> Tensor: + x = self.dconv(x) + return self.pconv(x) + + +class Model(nn.Module): + def __init__(self, depthwise: bool = False) -> None: + super().__init__() + self.conv = DwsConv(2, 2, 3) if depthwise else nn.Conv2d(2, 2, 3) + + def forward(self, x: Tensor) -> Tensor: + return self.conv(x) + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "enum_depthwise_false.pt") + + input = torch.rand(1, 2, 5, 5) + + print("Depthwise is False") + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + print("Depthwise is True") + model = Model(depthwise=True).to(torch.device("cpu")) + torch.save(model.state_dict(), "enum_depthwise_true.pt") + + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/enum_module/mod.rs b/crates/burn-store/pytorch-tests/tests/enum_module/mod.rs new file mode 100644 index 0000000..539980c --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/enum_module/mod.rs @@ -0,0 +1,196 @@ +use burn::{ + module::Module, + nn::conv::{Conv2d, Conv2dConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +#[allow(clippy::large_enum_variant)] +pub enum Conv { + DwsConv(DwsConv), + Conv(Conv2d), +} + +#[derive(Module, Debug)] +pub struct DwsConv { + dconv: Conv2d, + pconv: Conv2d, +} + +#[derive(Module, Debug)] +pub struct Net { + conv: Conv, +} + +impl Net { + /// Create a new model with DwsConv variant. + pub fn init_dws_conv(device: &Device) -> Self { + let dconv = Conv2dConfig::new([2, 2], [3, 3]) + .with_groups(2) + .init(device); + let pconv = Conv2dConfig::new([2, 2], [1, 1]) + .with_groups(1) + .init(device); + Net { + conv: Conv::DwsConv(DwsConv { dconv, pconv }), + } + } + + /// Create a new model with Conv variant. + pub fn init_conv(device: &Device) -> Self { + let conv2d_config = Conv2dConfig::new([2, 2], [3, 3]); + Net { + conv: Conv::Conv(conv2d_config.init(device)), + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + match &self.conv { + Conv::DwsConv(dws_conv) => { + let x = dws_conv.dconv.forward(x); + dws_conv.pconv.forward(x) + } + Conv::Conv(conv) => conv.forward(x), + } + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + #[test] + fn depthwise_false() { + let device = Default::default(); + let mut model = Net::init_conv(&device); + let mut store = PytorchStore::from_file("tests/enum_module/enum_depthwise_false.pt"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + let input = Tensor::<4>::from_data( + [[ + [ + [0.713_979_7, 0.267_644_3, 0.990_609, 0.28845078, 0.874_962_4], + [0.505_920_8, 0.23659128, 0.757_007_4, 0.23458993, 0.64705235], + [0.355_621_4, 0.445_182_8, 0.01930594, 0.26160914, 0.771_317], + [0.37846136, 0.99802476, 0.900_794_2, 0.476_588_2, 0.16625845], + [ + 0.804_481_1, + 0.65517855, + 0.17679012, + 0.824_772_3, + 0.803_550_9, + ], + ], + [ + [0.943_447_5, 0.21972018, 0.417_697, 0.49031407, 0.57302874], + [0.12054086, 0.14518881, 0.772_002_3, 0.38275403, 0.744_236_7], + [0.52850497, 0.664_172_4, 0.60994434, 0.681_799_7, 0.74785537], + [ + 0.03694397, + 0.751_675_7, + 0.148_438_4, + 0.12274551, + 0.530_407_2, + ], + [0.414_796_4, 0.793_662, 0.21043217, 0.05550903, 0.863_884_4], + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [0.35449377, -0.02832414, 0.490_976_1], + [0.29709217, 0.332_586_3, 0.30594018], + [0.18101373, 0.30932188, 0.30558896], + ], + [ + [-0.17683622, -0.13244139, -0.05608707], + [0.23467252, -0.07038684, 0.255_044_1], + [-0.241_931_3, -0.20476191, -0.14468731], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } + + #[test] + fn depthwise_true() { + let device = Default::default(); + let mut model = Net::init_dws_conv(&device); + let mut store = PytorchStore::from_file("tests/enum_module/enum_depthwise_true.pt"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::from_data( + [[ + [ + [0.713_979_7, 0.267_644_3, 0.990_609, 0.28845078, 0.874_962_4], + [0.505_920_8, 0.23659128, 0.757_007_4, 0.23458993, 0.64705235], + [0.355_621_4, 0.445_182_8, 0.01930594, 0.26160914, 0.771_317], + [0.37846136, 0.99802476, 0.900_794_2, 0.476_588_2, 0.16625845], + [ + 0.804_481_1, + 0.65517855, + 0.17679012, + 0.824_772_3, + 0.803_550_9, + ], + ], + [ + [0.943_447_5, 0.21972018, 0.417_697, 0.49031407, 0.57302874], + [0.12054086, 0.14518881, 0.772_002_3, 0.38275403, 0.744_236_7], + [0.52850497, 0.664_172_4, 0.60994434, 0.681_799_7, 0.74785537], + [ + 0.03694397, + 0.751_675_7, + 0.148_438_4, + 0.12274551, + 0.530_407_2, + ], + [0.414_796_4, 0.793_662, 0.21043217, 0.05550903, 0.863_884_4], + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [0.77874625, 0.859_017_6, 0.834_283_5], + [0.773_056_4, 0.73817325, 0.78292674], + [0.710_775_2, 0.747_187_2, 0.733_264_4], + ], + [ + [-0.44891885, -0.49027523, -0.394_170_7], + [-0.43836114, -0.33961445, -0.387_311_5], + [-0.581_134_3, -0.34197026, -0.535_035_7], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/group_norm/export_weights.py b/crates/burn-store/pytorch-tests/tests/group_norm/export_weights.py new file mode 100755 index 0000000..9e44950 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/group_norm/export_weights.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.norm1 = nn.GroupNorm(2, 6) + + def forward(self, x): + x = self.norm1(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "group_norm.pt") + + x2 = torch.rand(1, 6, 2, 2) + print("Input shape: {}", x2.shape) + print("Input: {}", x2) + output = model(x2) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/group_norm/group_norm.pt b/crates/burn-store/pytorch-tests/tests/group_norm/group_norm.pt new file mode 100644 index 0000000000000000000000000000000000000000..aa8b439c76bf39c85bf4768398dc279cd23d26a4 GIT binary patch literal 1578 zcmbVM&2G~`5Z?T`saq&bOKJHN6;yHrq;{J>t3r`1Qc(C2Jh*dg#<*Uj9+CR7Bv~35z@7Tl(x)J{@Y&)UTe(KqT$EeuEDZyBz)IcGX zUOQgLB{*%9Ahg|*857Tscr$cbvFEo<;&h`RG>>+8oDl?Lj^ZME(&{)kJAm-2A6VqY zV3Ef;BSp`Mm@_i;YahmVPU8s9Ki81Po#1&bb%ObKF6dYwc(IAE82m^~%d;YU)uoZ# ztUF+m@RHkSxHe>128P0bVTIt;CcYl`@R~7CSjg zBbpR^OG`yY?kq08-NScWSc{`pN!RA`e@=g?4SEr9eQG&YQG+c<>kV-NNVcl%vGq8w z%xr(YM~Lr$jFTs)8yr;$K3&E#+GAzF{kKI`sr|18gpM1N929L8eiqlM8t{K9Dqc_| zsmj)DiYWC&gZTHpfBk%U`~ni#_`|BJ%7d8%?D7!l$EVKAG}Qq<{id5}n~hZJCqHsR z&+@&u7Cb=HYpcq`nF0^nL#l~ly)KG!rCcrV?9?jdvMh>XRj$grm5NlAWwBnBB?(mi z-oIZLs6udEOM%$Y17&PrT zoUL{e{$&E3CM1~vwuFU5bb$^hCi<{J#_?=&Hxu}eN&Mup>l(*B8p7^o9*+p%HG`df hKhR`?)#=m+qd1te=o<_uYOt+qx&CpEqTu}C>|bw(RK)-Q literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/group_norm/mod.rs b/crates/burn-store/pytorch-tests/tests/group_norm/mod.rs new file mode 100644 index 0000000..a02cd42 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/group_norm/mod.rs @@ -0,0 +1,90 @@ +use burn::{ + module::Module, + nn::{GroupNorm, GroupNormConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + norm1: GroupNorm, +} + +impl Net { + /// Create a new model from the given record. + pub fn init(device: &Device) -> Self { + let norm1 = GroupNormConfig::new(2, 6).init(device); + Self { norm1 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.norm1.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn group_norm(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::from_data( + [[ + [[0.757_631_6, 0.27931088], [0.40306926, 0.73468447]], + [[0.02928156, 0.799_858_6], [0.39713734, 0.75437194]], + [[0.569_508_5, 0.43877792], [0.63868046, 0.524_665_9]], + [[0.682_614_1, 0.305_149_5], [0.46354562, 0.45498633]], + [[0.572_472, 0.498_002_6], [0.93708336, 0.65559506]], + [[0.31379688, 0.19801933], [0.41619217, 0.28432965]], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [[1.042_578_5, -1.122_016_7], [-0.56195974, 0.938_733_6]], + [[-2.253_500_7, 1.233_672_9], [-0.588_804_1, 1.027_827_3]], + [[0.19124532, -0.40036356], [0.504_276_5, -0.01168585]], + [[1.013_829_2, -0.891_984_6], [-0.09224463, -0.13546038]], + [[0.45772314, 0.08172822], [2.298_641_4, 0.877_410_4]], + [[-0.84832406, -1.432_883_4], [-0.331_331_5, -0.997_103_7]], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn group_norm_full() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/group_norm/group_norm.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + group_norm(model, 1e-3); + } + + #[test] + fn group_norm_half() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/group_norm/group_norm.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + group_norm(model, 1e-3); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/integer/export_weights.py b/crates/burn-store/pytorch-tests/tests/integer/export_weights.py new file mode 100755 index 0000000..151dda3 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/integer/export_weights.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + buffer = torch.tensor([1, 2, 3]) + self.register_buffer("buffer", buffer, persistent=True) + + def forward(self, x): + x = self.buffer + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "integer.pt") + + input = torch.ones(3, 3) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/integer/integer.pt b/crates/burn-store/pytorch-tests/tests/integer/integer.pt new file mode 100644 index 0000000000000000000000000000000000000000..bcf22cd73fec4048e737481f2ffbe0d3f1a6f9df GIT binary patch literal 1244 zcma)6OK;Oa5MIZ5)j%HQ0tX^-BBrh*yY{9Mm5R7T zhyxc6J@(3v;lz;}QvV1$v18eWppG?K+dH4XnQvxonS33gb{qX=J+zC01;uk5@B1!w z#gliTp?A=vWS?rD7lk4AXb>$D;Z^M882c{*j|v1mJR+RkWM7^xXEPj=nn$D9I~JWK z4Z=ilVmw_2q3=+5|REn*sEhXUj575M z{lRrpQUzf8c{zUz8GvtR8XzTS64%8iI1bz}ICEk8oWM79@w&vB43ad`WJT39`RSOs z_T)erOUz_VR%K>, +} + +impl Net { + /// Create a new model with placeholder values. + pub fn init(device: &Device) -> Self { + Self { + buffer: Param::initialized( + ParamId::new(), + Tensor::<1, Int>::from_data(TensorData::from([0, 0, 0]), device), + ), + } + } + + /// Forward pass of the model. + pub fn forward(&self, _x: Tensor<2>) -> Tensor<1, Int> { + self.buffer.val() + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::DType; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + fn integer(model: Net) { + let device = Default::default(); + + let input = Tensor::<2>::ones([3, 3], &device); + + let output = model.forward(input); + let data = output.to_data(); + + // The .pt file stores int64 (PyTorch's default int dtype); we pin + // that here to catch a regression where the loader silently casts + // to the backend's native IntElem (i32 for Flex). + assert_eq!(data.dtype, DType::I64); + + let values = data.iter::().collect::>(); + assert_eq!(values, vec![1i64, 2, 3]); + } + + #[test] + fn integer_full_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/integer/integer.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + integer(model); + } + + #[test] + fn integer_half_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/integer/integer.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + integer(model); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/key_remap/export_weights.py b/crates/burn-store/pytorch-tests/tests/key_remap/export_weights.py new file mode 100755 index 0000000..1b7490b --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/key_remap/export_weights.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class ConvModule(nn.Module): + def __init__(self): + super(ConvModule, self).__init__() + self.conv1 = nn.Conv2d(2, 2, (2,2)) + self.conv2 = nn.Conv2d(2, 2, (2,2), bias=False) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + return x + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv = ConvModule() + + def forward(self, x): + x = self.conv(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "key_remap.pt") + + input = torch.rand(1, 2, 5, 5) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/key_remap/key_remap.pt b/crates/burn-store/pytorch-tests/tests/key_remap/key_remap.pt new file mode 100644 index 0000000000000000000000000000000000000000..088a943941c280a54596bb80401726e6c46135ad GIT binary patch literal 2018 zcmbW2Pi)gx9LF6uO@db-Z5bO-O2)q>6H8*JP14d>%tM)w4@-(BLJtd0Vl`e(l3)B% zRHR7d&;x%|RS0na2_eA&4K(#IiEbldr>WXb0}dRJCWHi>dV)o0na z{oecjejmTz?>(o)4ktx@`wQ{FbWQTrW?D?mrIjDa$$p~WM#aFfX<_GHD3&7Ye-Qw28YSj z6t$2=a!D&!vy&VH9e#htjl~+CDUHkEG$c$?ElcRA(aAt(#)0RA!IkMCfhsj{i(Ukt zv`G0y+V~;Z;1@lj6F)f+FlUPd?IQ$Tqp&fv*~*twBn>tdaqyzEXgFtKbI~o*2)x*- zNY6mG=$L^X!%Z)OEu-+U^)NRhpEm8qNXi5gl3v=Tu*F*jtBvMZJq@bs1@Z{dM^K z0|OlR>k!ZfYFaLs65XD==**^n@EVhrTD4qyDW9oaa#KhylUhg$eU^GLm5zR^DhR1E z2{V&;w#|hZNn1UPml`pJA?pzeCx!T$G3+tT&lk@R^MCxctNHT#3k`ZHz;BuOvANWL zocH{DsWBFc^Of|GL?z8O?mSvdq@K=>FvCv%;MR@Jxi22xyPl;Rggp0Vl0Ss0Nn49t zu#>Y%fnPVd@fHiu(+SG=%ddoEKJ;XPY-G~b;uyAba7iJ$PL8{YYkcA8(?t5At9j(s zSo8Pas*R~YUvt*|vf=&d+=%et7*8)A=5M^3Z~jm|!N2r_KkhLRib?$EJD-(CC+LJ*Az?RxX>>$PY@I8Ix?ys_;L%$vY#ad6qJ&cJ+u?OJYgd~K$f|z4Qq9@b=H!!aRomw9G1`vZDmlA+-jo$zTZ`{{I?eJA*Go!t vxel~c{T*K}>&bVI*!%xQeE1Suz&?p#ojx?(nm~qAVkbE~vBpi2`NrILrupeK literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/key_remap/mod.rs b/crates/burn-store/pytorch-tests/tests/key_remap/mod.rs new file mode 100644 index 0000000..bb8c422 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/key_remap/mod.rs @@ -0,0 +1,117 @@ +use burn::{ + module::Module, + nn::conv::{Conv2d, Conv2dConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv2d, + conv2: Conv2d, +} + +impl Net { + /// Create a new model. + pub fn init(device: &Device) -> Self { + let conv1 = Conv2dConfig::new([2, 2], [2, 2]).init(device); + let conv2 = Conv2dConfig::new([2, 2], [2, 2]) + .with_bias(false) + .init(device); + Self { conv1, conv2 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let x = self.conv1.forward(x); + + self.conv2.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + #[test] + fn key_remap() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/key_remap/key_remap.pt") + .with_key_remapping("conv\\.(.*)", "$1"); // Remove "conv" prefix, e.g. "conv.conv1" -> "conv1" + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::from_data( + [[ + [ + [ + 0.024_595_8, + 0.25883394, + 0.93905586, + 0.416_715_5, + 0.713_979_7, + ], + [0.267_644_3, 0.990_609, 0.28845078, 0.874_962_4, 0.505_920_8], + [0.23659128, 0.757_007_4, 0.23458993, 0.64705235, 0.355_621_4], + [0.445_182_8, 0.01930594, 0.26160914, 0.771_317, 0.37846136], + [ + 0.99802476, + 0.900_794_2, + 0.476_588_2, + 0.16625845, + 0.804_481_1, + ], + ], + [ + [ + 0.65517855, + 0.17679012, + 0.824_772_3, + 0.803_550_9, + 0.943_447_5, + ], + [0.21972018, 0.417_697, 0.49031407, 0.57302874, 0.12054086], + [0.14518881, 0.772_002_3, 0.38275403, 0.744_236_7, 0.52850497], + [0.664_172_4, 0.60994434, 0.681_799_7, 0.74785537, 0.03694397], + [ + 0.751_675_7, + 0.148_438_4, + 0.12274551, + 0.530_407_2, + 0.414_796_4, + ], + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [-0.02502128, 0.00250649, 0.04841233], + [0.04589614, -0.00296854, 0.01991477], + [0.02920526, 0.059_497_3, 0.04326791], + ], + [ + [-0.04825336, 0.080_190_9, -0.02375088], + [0.02885434, 0.09638263, -0.07460806], + [0.02004079, 0.06244051, 0.035_887_1], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/key_remap_chained/export_weights.py b/crates/burn-store/pytorch-tests/tests/key_remap_chained/export_weights.py new file mode 100755 index 0000000..95da5a4 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/key_remap_chained/export_weights.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +import torch +from torch import nn, Tensor + + +class ConvBlock(nn.Module): + def __init__(self, in_channels: int, out_channels: int): + super().__init__() + self.block = nn.Sequential( + nn.Conv2d(in_channels, out_channels, 1, bias=False), + nn.BatchNorm2d(out_channels), + ) + + def forward(self, x: Tensor) -> Tensor: + return self.block(x) + + +class Model(nn.Module): + def __init__(self): + super().__init__() + self.conv = nn.Conv2d(3, 6, 3, bias=False) + self.bn = nn.BatchNorm2d(6) + self.layer = nn.Sequential(ConvBlock(6, 6), ConvBlock(6, 6)) + + def forward(self, x: Tensor) -> Tensor: + x = self.conv(x) + x = self.bn(x) + x = self.layer(x) + + return x + + +def main(): + torch.set_printoptions(precision=8) + torch.manual_seed(42) + + model = Model() + + input = torch.rand(1, 3, 4, 4) + model(input) # condition batch norm + model.eval() + + with torch.no_grad(): + print(f"Input shape: {input.shape}") + print("Input type: {}", input.dtype) + print(f"Input: {input}") + output = model(input) + + print(f"Output: {output}") + print(f"Output Shape: {output.shape}") + + torch.save(model.state_dict(), "key_remap.pt") + + +if __name__ == "__main__": + main() diff --git a/crates/burn-store/pytorch-tests/tests/key_remap_chained/key_remap.pt b/crates/burn-store/pytorch-tests/tests/key_remap_chained/key_remap.pt new file mode 100644 index 0000000000000000000000000000000000000000..8c2b11b39d470c3ef74a52aac38835f2cd9d6177 GIT binary patch literal 7052 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 -> Tensor<4>; +} + +/// Conv2d + BatchNorm block. +#[derive(Module, Debug)] +pub struct ConvBlock { + conv: Conv2d, + bn: BatchNorm, +} + +impl ForwardModule for ConvBlock { + fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let out = self.conv.forward(input); + self.bn.forward(out) + } +} + +impl ConvBlock { + pub fn new(in_channels: usize, out_channels: usize, device: &Device) -> Self { + let conv = Conv2dConfig::new([in_channels, out_channels], [1, 1]) + .with_bias(false) + .init(device); + let bn = BatchNormConfig::new(out_channels).init(device); + + Self { conv, bn } + } +} + +/// Collection of sequential blocks. +#[derive(Module, Debug)] +pub struct ModuleBlock { + blocks: Vec, +} + +impl ModuleBlock { + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let mut out = input; + for block in &self.blocks { + out = block.forward(out); + } + out + } +} + +impl ModuleBlock { + pub fn new(device: &Device) -> Self { + let blocks = vec![ConvBlock::new(6, 6, device), ConvBlock::new(6, 6, device)]; + + Self { blocks } + } +} + +#[derive(Module, Debug)] +pub struct Model { + conv: Conv2d, + bn: BatchNorm, + layer: ModuleBlock, +} + +impl Model { + pub fn new(device: &Device) -> Self { + let conv = Conv2dConfig::new([3, 6], [3, 3]) + .with_bias(false) + .init(device); + let bn = BatchNormConfig::new(6).init(device); + + let layer = ModuleBlock::new(device); + + Self { conv, bn, layer } + } + + pub fn forward(&self, input: Tensor<4>) -> Tensor<4> { + let out = self.conv.forward(input); + let out = self.bn.forward(out); + self.layer.forward(out) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + #[test] + #[should_panic] + fn key_remap_chained_missing_pattern() { + // Loading record should fail due to missing pattern to map the layer.blocks + let device = Default::default(); + let mut model: Model<_> = Model::new(&device); + let mut store = PytorchStore::from_file("tests/key_remap_chained/key_remap.pt") + // Map *.block.0.* -> *.conv.* + .with_key_remapping("(.+)\\.block\\.0\\.(.+)", "$1.conv.$2") + // Map *.block.1.* -> *.bn.* + .with_key_remapping("(.+)\\.block\\.1\\.(.+)", "$1.bn.$2"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + } + + #[test] + fn key_remap_chained() { + let device = Default::default(); + let mut model: Model<_> = Model::new(&device); + let mut store = PytorchStore::from_file("tests/key_remap_chained/key_remap.pt") + // Map *.block.0.* -> *.conv.* + .with_key_remapping("(.+)\\.block\\.0\\.(.+)", "$1.conv.$2") + // Map *.block.1.* -> *.bn.* + .with_key_remapping("(.+)\\.block\\.1\\.(.+)", "$1.bn.$2") + // Map layer.[i].* -> layer.blocks.[i].* + .with_key_remapping("layer\\.([0-9])\\.(.+)", "layer.blocks.$1.$2"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::from_data( + [[ + [ + [0.76193494, 0.626_546_1, 0.49510366, 0.11974698], + [0.07161391, 0.03232569, 0.704_681, 0.254_516], + [0.399_373_7, 0.21224737, 0.40888822, 0.14808255], + [0.17329216, 0.665_855_4, 0.351_401_8, 0.808_671_6], + ], + [ + [0.33959562, 0.13321638, 0.41178054, 0.257_626_3], + [0.347_029_2, 0.02400219, 0.77974546, 0.15189773], + [0.75130886, 0.726_892_1, 0.85721636, 0.11647397], + [0.859_598_4, 0.263_624_2, 0.685_534_6, 0.96955734], + ], + [ + [0.42948407, 0.49613327, 0.38488472, 0.08250773], + [0.73995143, 0.00364107, 0.81039995, 0.87411255], + [0.972_853_2, 0.38206023, 0.08917904, 0.61241513], + [0.77621365, 0.00234562, 0.38650817, 0.20027226], + ], + ]], + &device, + ); + let expected = Tensor::<4>::from_data( + [[ + [[0.198_967_1, 0.17847246], [0.06883702, 0.20012866]], + [[0.17582723, 0.11344293], [0.05444185, 0.13307181]], + [[0.192_229_5, 0.20391327], [0.06150475, 0.22688155]], + [[0.00230906, -0.02177845], [0.01129148, 0.00925517]], + [[0.14751078, 0.14433631], [0.05498439, 0.29049855]], + [[0.16868964, 0.133_269_3], [0.06917118, 0.35094324]], + ]], + &device, + ); + + let output = model.forward(input); + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/layer_norm/export_weights.py b/crates/burn-store/pytorch-tests/tests/layer_norm/export_weights.py new file mode 100755 index 0000000..04b4234 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/layer_norm/export_weights.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.norm1 = nn.LayerNorm(2) + + def forward(self, x): + x = self.norm1(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "layer_norm.pt") + + x2 = torch.rand(1, 2, 2, 2) + print("Input shape: {}", x2.shape) + print("Input: {}", x2) + output = model(x2) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/layer_norm/layer_norm.pt b/crates/burn-store/pytorch-tests/tests/layer_norm/layer_norm.pt new file mode 100644 index 0000000000000000000000000000000000000000..f792f58493169290733e79727fb9ec71cb906636 GIT binary patch literal 1578 zcmbVMOK;Oa5Z=5~w@~t+w7jHp3IY;4b(%y`70E)r_&^$jWO1<^XBAtxao64y6)B>Z z95_*N;sn12i7QB)Iq*l=Nz!zkCOk6I$ky(D-+VhWJ1s31M<|^}S1gA%k!zk}-{^b( zQK@TEvv~ZDvX{q#=gSWLNy9jt|X*u$!$(N&ZO0fsYR+R={kITyZ+n?)D z>SB;FI{3AdWGcaz%gCSxF9Ysh5>=(Tss@D4n&6<|{4n_g8WH?Y3hGxBNUBmlJxlE|;qnS+0wsxGzb2)k?iuugan*)ypENY{=g~7nnlumKFiA zv&q3XaSk>@wqTCT6Y%Yq4}ssacfk?NI)9#lZ=XzvVT(!S7$!|S59g~NhJRcDm*(Mo z3A3T-+iW;-(WebFkLQ!S8^V7G;}@5`r6rS(r?C66Co=+g&0rVbbCyi78k_oH37_BU!yS)Bj? literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/layer_norm/mod.rs b/crates/burn-store/pytorch-tests/tests/layer_norm/mod.rs new file mode 100644 index 0000000..a0def47 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/layer_norm/mod.rs @@ -0,0 +1,81 @@ +use burn::{ + module::Module, + nn::{LayerNorm, LayerNormConfig}, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + norm1: LayerNorm, +} + +impl Net { + /// Create a new model. + pub fn init(device: &Device) -> Self { + let norm1 = LayerNormConfig::new(2).init(device); + Self { norm1 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.norm1.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + fn layer_norm(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::from_data( + [[ + [[0.757_631_6, 0.27931088], [0.40306926, 0.73468447]], + [[0.02928156, 0.799_858_6], [0.39713734, 0.75437194]], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [[0.99991274, -0.999_912_5], [-0.999_818_3, 0.999_818_3]], + [[-0.999_966_2, 0.99996626], [-0.99984336, 0.99984336]], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn layer_norm_full() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/layer_norm/layer_norm.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + layer_norm(model, 1e-3); + } + + #[test] + fn layer_norm_half() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/layer_norm/layer_norm.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + layer_norm(model, 1e-3); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/linear/export_weights.py b/crates/burn-store/pytorch-tests/tests/linear/export_weights.py new file mode 100755 index 0000000..5d76638 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/linear/export_weights.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.fc1 = nn.Linear(2, 3) + self.fc2 = nn.Linear(3, 4, bias=False) + + def forward(self, x): + x = self.fc1(x) + x = F.relu(x) # Add relu so that PyTorch optimizer does not combine fc1 and fc2 + x = self.fc2(x) + + return x + + +class ModelWithBias(nn.Module): + def __init__(self): + super(ModelWithBias, self).__init__() + self.fc1 = nn.Linear(2, 3) + + def forward(self, x): + x = self.fc1(x) + + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + model_with_bias = ModelWithBias().to(torch.device("cpu")) + + torch.save(model.state_dict(), "linear.pt") + torch.save(model_with_bias.state_dict(), "linear_with_bias.pt") + + input = torch.rand(1, 2, 2, 2) + print("Input shape: {}", input.shape) + print("Input: {}", input) + + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + print("Model with bias") + output = model_with_bias(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + + + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/linear/linear.pt b/crates/burn-store/pytorch-tests/tests/linear/linear.pt new file mode 100644 index 0000000000000000000000000000000000000000..fd5f4c9284beb23c2988f0be800e05d15a5905b9 GIT binary patch literal 1805 zcmbVNO-vI(6yCPbmbE|;lpg_6REk3Xwxw;fHEBpC*@LD2HH6q~+l5_ODX%-lXhOmT z661xKa8<9$(TmYgZy4iAZpNFa!NhPh%E36ZZE<&NiTaY4-JPB9d*8hGW+u*>>L|); zrK+r%YNaG`mgn-WEQdJf+>8{9Hd7N?TQp`eIZ5I(NX*U3^u>IZ&-2+)F@tEJ1`}X# zBxy`NmGL-l@#3_Az?ea~d`57l3P_Y>I+f?s1yRbT5I-yD@~L?*4JL;pX~8HN&*Y|g zs4GF}n3UttrP3e`^+^Ms;|6omgrAwY0$5lx0_!+Su_o5I3=OQ2H7o;tS0G9bAh0E% zF-ew6!3*t} z!G@rNFx0W8W$46Wu!^n{%Mk?K3Fs*-KyR`ED@ont5l8Y1M-`QQjwBwX;Mf8jS8_1V z=VhE)=w}VGvQVrYPAtGlK{euqQwuPlj1=UwGtOG98(r_|Rh%H=@tQ@pQ^S=ar*9)Z zN6HtA4(h9{t-1P35)qPk5^yQJ)oI4kiPsL;NS&_)B&&A0W6`sJA59&(^nU-|^T@`V zhsEvdwxV(8aineL^vj*zcM+m!^~0pC1FIpvpQ61KD2hD)w0`Tiw&H~AS!8bfb(q9_ zvbnKk!|{mk7c->Ef;SfR|6dkXUahQ;?)FC(6JO4K{CX|2nVl*=x-qog_xbXAFufM0 z2HIcPe1`L5yMf5qnaLv282;Ja;>P-j*VfoXwN*5hdoUKYSBYe3!ua;V^c5p!BVL=a zhhi#>P9@bcpBFhvyu*>hBPC{IQCC%f>XK_lJV77BjCefm5q}`$5BdU3z~^Q>OlZW- zczq#1wos4>1c*xag{~hCtdMw|H4w3TC!cz|G?O11)18BQu)6i_8sI0q`^w`DGlw1qgzGFgS7^nPNBPSC!@;lDfD_<6jBJ)dg Fe*qW|lK21s literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/linear/linear_with_bias.pt b/crates/burn-store/pytorch-tests/tests/linear/linear_with_bias.pt new file mode 100644 index 0000000000000000000000000000000000000000..dc4c68f4ad38183ab070801c18385829db5548d8 GIT binary patch literal 1614 zcmb7F&1=+95P!Sd{b;OOzuIoCdJySWbesKhH%NO35|DT3x*`M-LpGZ>sj=xx-nLXI zcxl0d9z69RUcB^F5xj}jg9oqbLGUDaQSc=G0lrCUo82_l4otGV%>3pzA8)pf6FmrJ zGAJZj)Qc>0PFEde(WHjbG*x%JrBXFNf5(!&UkXTOU81%GO$S>;Vj6q3Uqqd_Nd8JKF%jFbDZ?;XVrBHp&wH;-l$dN=YC#RX0 z%PiFyo%HxFoMhQ5z3v}!Bq_(34Np>Xg1xo*Hc8_YC7Ej&;RKGal3pCgu~ou7Fo4Mc zO8Oe)pbV)39mt0qGFU95$MCTVu-VUPWS?zpTd+sP=#?`5)1oh zadrL6=G#}lUN4P3*?g2XHv2cY3Gw6SNnkX4>&NXO#s>6XjLt?dDoEm)y%@#czngsa zn}0KFfAwN+l!`CCBBdiwGJWbrg+-qvpNh zySihlmU&NwYp9qlNj&#|BnnlYuhn>7sFW|2i^XcCTo!nquaqi+P_6M5p(vF2VpRZ^ zpVppF=NLoKI*tL^?adJ!<1BpWkjOpK4HF!~;SPw0?0$q`wr@Z;PH=PvJ8%XV?GTPX zz}+ChS?B~=j|3@ng9KX(gS;anY;A>@Yz Self { + let fc1 = LinearConfig::new(2, 3).init(device); + let fc2 = LinearConfig::new(3, 4).with_bias(false).init(device); + let relu = Relu; + + Self { fc1, fc2, relu } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let x = self.fc1.forward(x); + let x = self.relu.forward(x); + + self.fc2.forward(x) + } +} + +#[derive(Module, Debug)] +struct NetWithBias { + fc1: Linear, +} + +impl NetWithBias { + /// Create a new model. + pub fn init(device: &Device) -> Self { + let fc1 = LinearConfig::new(2, 3).init(device); + + Self { fc1 } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.fc1.forward(x) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + fn linear_test(model: Net, precision: f32) { + let device = Default::default(); + + let input = Tensor::<4>::from_data( + [[ + [[0.63968194, 0.97427773], [0.830_029_9, 0.04443115]], + [[0.024_595_8, 0.25883394], [0.93905586, 0.416_715_5]], + ]], + &device, + ); + + let output = model.forward(input); + let expected = Tensor::<4>::from_data( + [[ + [ + [0.09778349, -0.13756673, 0.04962806, 0.08856435], + [0.03163241, -0.02848549, 0.01437942, 0.11905234], + ], + [ + [0.07628226, -0.10757702, 0.03656857, 0.03824598], + [0.05443089, -0.06904714, 0.02744314, 0.09997337], + ], + ]], + &device, + ); + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(precision)); + } + + #[test] + fn linear_full_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/linear/linear.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + linear_test(model, 1e-7); + } + + #[test] + fn linear_half_precision() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/linear/linear.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + linear_test(model, 1e-4); + } + + #[test] + fn linear_with_bias() { + let device = Default::default(); + + let mut model = NetWithBias::init(&device); + let mut store = PytorchStore::from_file("tests/linear/linear_with_bias.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::from_data( + [[ + [[0.63968194, 0.97427773], [0.830_029_9, 0.04443115]], + [[0.024_595_8, 0.25883394], [0.93905586, 0.416_715_5]], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [-0.00432095, -1.107_101_2, 0.870_691_4], + [0.024_595_5, -0.954_462_9, 0.48518157], + ], + [ + [0.34315687, -0.757_384_2, 0.548_288], + [-0.06608963, -1.072_072_7, 0.645_800_5], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/missing_module_field/export_weights.py b/crates/burn-store/pytorch-tests/tests/missing_module_field/export_weights.py new file mode 100755 index 0000000..e65585c --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/missing_module_field/export_weights.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.Conv2d(2, 2, (2,2)) + + def forward(self, x): + x = self.conv1(x) + return x + + +def main(): + torch.set_printoptions(precision=8) + torch.manual_seed(1) + model = Model().to(torch.device("cpu")) + torch.save(model.state_dict(), "missing_module_field.pt") + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/missing_module_field/missing_module_field.pt b/crates/burn-store/pytorch-tests/tests/missing_module_field/missing_module_field.pt new file mode 100644 index 0000000000000000000000000000000000000000..8162e99421762dc7382a92fad9e97a408b59bf4c GIT binary patch literal 1702 zcmb7FPiWIn7=KOwE^az?&dqIA5i#3bo2=d1OI}3 z|JVd@gH_EmwL)H6HF9NLk*;Zqo(tw=BnOHsI-hWW^ld7eHFRCcBF!k6^u|+F<;KC~h zg7h7gsM>>I|1um9>87-dCYx}tig{I+YTwX`@JRh86{Te2R|Lldhbg+ZdEs~!PN-zBY-R#U z!Ry-^Nk6`TuSGoBH93B8%04-nn@BN868OXkklxvJ4Iy12#A~tdqaLh_cz5D4z}XX% zy9Zf5G4nsHe|0^)yBL4_{^-WftM_Wu)?|EW<@v^z|4Q8V>rpKojK+(p^RZ%ztG)R0 zE|&au|17=eik}%C*myAY`OPzlsuAUx-<9||teSW)<&hrBIX)5EopS3A>pxO4aO~A< zqT^-o>pE#9;@x$OgMht`MuqT+sa*&O(S-Ta)BI%$vl8#lJPl6TGY9I)GnJAi>)I`u zw6dh-_{8y@g>Vs;jYe5^VJl_74_Am&E`8 literal 0 HcmV?d00001 diff --git a/crates/burn-store/pytorch-tests/tests/missing_module_field/mod.rs b/crates/burn-store/pytorch-tests/tests/missing_module_field/mod.rs new file mode 100644 index 0000000..a213b6c --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/missing_module_field/mod.rs @@ -0,0 +1,36 @@ +use burn::{module::Module, nn::conv::Conv2d, tensor::Device}; + +#[derive(Module, Debug)] +#[allow(unused)] +pub struct Net { + do_not_exist_in_pt: Conv2d, +} + +#[cfg(test)] +mod tests { + + use burn::nn::conv::Conv2dConfig; + use burn_store::{ModuleSnapshot, PytorchStore}; + + use super::*; + + impl Net { + pub fn init(device: &Device) -> Self { + Self { + do_not_exist_in_pt: Conv2dConfig::new([2, 2], [2, 2]).init(device), + } + } + } + + #[test] + #[should_panic(expected = "do_not_exist_in_pt")] + fn should_fail_if_struct_field_is_missing() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = + PytorchStore::from_file("tests/missing_module_field/missing_module_field.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/export_weights.py b/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/export_weights.py new file mode 100755 index 0000000..9a76930 --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/export_weights.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + num_layers = 5 # Number of repeated convolutional layers + + # Create a list to store the layers + layers = [] + for _ in range(num_layers): + layers.append(nn.Conv2d(2, 2, kernel_size=3, padding=1, bias=True)) + layers.append(nn.ReLU(inplace=True)) + + # Use nn.Sequential to create a single module from the layers + self.fc = nn.Sequential(*layers) + + def forward(self, x): + x = self.fc(x) + return x + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + torch.save(model.state_dict(), "non_contiguous_indexes.pt") + + input = torch.rand(1, 2, 5, 5) + print("Input shape: {}", input.shape) + print("Input: {}", input) + output = model(input) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + +if __name__ == '__main__': + main() diff --git a/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/mod.rs b/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/mod.rs new file mode 100644 index 0000000..5b2753e --- /dev/null +++ b/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/mod.rs @@ -0,0 +1,109 @@ +use burn::{ + module::Module, + nn::{ + PaddingConfig2d, + conv::{Conv2d, Conv2dConfig}, + }, + tensor::{Device, Tensor, activation::relu}, +}; + +#[derive(Module, Debug)] +pub struct Net { + fc: Vec, +} + +impl Net { + /// Create a new model with placeholder values. + pub fn init(device: &Device) -> Self { + let conv2d_config = Conv2dConfig::new([2, 2], [3, 3]).with_padding(PaddingConfig2d::Same); + // The PyTorch file has 5 Conv2d layers at non-contiguous indices (0, 2, 4, 6, 8) + // in the Sequential (alternating with ReLU layers) + let fc = vec![ + conv2d_config.init(device), + conv2d_config.init(device), + conv2d_config.init(device), + conv2d_config.init(device), + conv2d_config.init(device), + ]; + Net { fc } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + self.fc.iter().fold(x, |x_i, conv| relu(conv.forward(x_i))) + } +} + +#[cfg(test)] +mod tests { + + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PytorchStore}; + type FT = f32; + + use super::*; + + #[test] + fn non_contiguous_indexes() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = + PytorchStore::from_file("tests/non_contiguous_indexes/non_contiguous_indexes.pt"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::from_data( + [[ + [ + [ + 0.67890584, + 0.307_537_2, + 0.265_156_2, + 0.528_318_8, + 0.86194897, + ], + [0.14828813, 0.73480314, 0.821_220_7, 0.989_098_6, 0.15003455], + [0.62109494, 0.13028657, 0.926_875_1, 0.30604684, 0.80117637], + [0.514_885_7, 0.46105868, 0.484_046_1, 0.58499724, 0.73569804], + [0.58018994, 0.65252745, 0.05023766, 0.864_268_7, 0.935_932], + ], + [ + [0.913_302_9, 0.869_611_3, 0.139_184_3, 0.314_65, 0.94086266], + [0.11917073, 0.953_610_6, 0.10675198, 0.14779574, 0.744_439], + [0.14075547, 0.38544965, 0.863_745_9, 0.89604443, 0.97287786], + [0.39854127, 0.11136961, 0.99230546, 0.39348692, 0.29428244], + [0.621_886_9, 0.15033776, 0.828_640_1, 0.81336635, 0.10325938], + ], + ]], + &device, + ); + + let output = model.forward(input); + + let expected = Tensor::<4>::from_data( + [[ + [ + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.04485746, 0.03582812, 0.03432692, 0.02892298, 0.013_844_3], + ], + [ + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000], + ], + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::absolute(1e-7)); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/non_contiguous_indexes.pt b/crates/burn-store/pytorch-tests/tests/non_contiguous_indexes/non_contiguous_indexes.pt new file mode 100644 index 0000000000000000000000000000000000000000..ff4b515e7dac124e6a47ed040d9ccb1323e33b54 GIT binary patch literal 4746 zcmbuD3vd(18Gz4{pK@%2Da2s>B8JK}mSsuSBi1fhNra7JEXzCu7-U&KEMZylZ=HEf zRY;2=q47*YLLSDDOdA@SfkFzM44_+@OxsMG@`e^t5+F1wO;U(SX-p*zpbyOVJ$ zx;wpQ(;RBq_>N5ym&@<<(k{m1_XT8)J?(T4-Ttu0#mImvvq6&Fp2?}Law(LGwX~VsSXFo}Wh*kE$x(o~U4 zn<7G};C>Fl8RdzMrbZb}6M*>)Ot(Qn#7LzG8x@LhYA6znibsq}c%vBt-~k3oZ7?%p zq=^{K5+OA3UBO5$HY$rU`knwRXJEDsDk4T2!AK#(RYRp9jB0r!lK^;#froAINW@4V zF*1t~dYC5|)rpPjqm1SYKnnwp+Mpp~q!)}9h;RmI6pXARMoqktO#n1Au+RpJdV{by z3~_YPjFTUouq6DT+U0ic5v5>h5SE1p#2UIMzzsB5Zb=H*CE>>xT7s~`9q!;NL3pIW z${;vIT~*K;1gEG=1#LlaiMrI#9t2v{rGZsJ=n!>PgF6TwQI{502cc8cr2}sexM}rg5Fp&u8)#ElEotdxZ&l1&z+DO7tZ}PbF;yC`y0xumXg^G@Hst4H{K)ryd7+hrj z@dyHWh96ei|yg-iMAqhK;Uy7aVrwWRhdm?qsNWhf;Br%qQd5@tnZu`zh(R*+dx(0;g7X7pEi8Wo-Z0k z^9m_~hx?J&^<(z6gXcn(GB*ySc32FTg_hV=*B--SBvFmHKC4aC1mrLx%g+N zcc7u<=_KWM)A7?=bFn*TF+OWaBd>m3$TsiZajZp>g}yG=k!j{~^2wu8e7~!W-TKZa zs8ilzASSr=-XV%ZhJEezwmY@S^m^wvhdYPoG;H|bKgwEp~{_T&c8LJ<)5`E z>BA1RXP|^MXMD)EyzmNQZq7!3SagDQ7H?-aWv{@CURJSvlhX0=E zdXL@p=c}Qtp-JSxz(MpNy#rm_xs1hIkDv=lgJ@{^B>eie8R7h&sk5FMfBto2%Kw7( zKV|9Do#D7$d!Ml0&ObyT}_$^2t#ui`1+;$u4=Z9M2`Y*nhQX z$cZCIP)+{;TGu=s=e~27)!3TQm1753a>LFJEtyNQlPcH+9-rFi4eothQHv&j1|8S!8zu%*)mL+EBBdEyXe*Y3+F4ZmN3hxb~^%QKAO z=Wq^lGc^AE8^)A>Z;3SY`eTxC+{VsJKN>y%h1BeL^?zh_WH!miJOQX>{Kz}AyJHc* zn&MmhnWut!AYNoec=8X>Jszjmv%$%4h8&)Dv#Dq_kt(%Lsnu5NlzOeYTBR`|Y z5fAx2SoDgOFv=)XMD9$7QGk`TL*WUV>im<>VWv$&p9LH!as0c>CC+nkp~g=WD%a z-BdupjmP9D%aVgH7f?ah69K&^6#GyzO}%!4yi+Z%L#c~F!)W*Wxjf4czFfm9+D$Y- zS8Yq0+CI4eC^;CM91Pk83>3Y3^WpM3TZ1oGw1zGvisHMza7;-J5i)&xJGlG*`fs9o zlBqU6e24>wb-+^#df0UOn!0t&#L)#vJ4!=oUzJ*tEH{<9+-SC1Qj7gIcIvXCNV3#Y z+Uy-ve*J}ys;m~^hMpOhY&!Y;e3aS#tj&D~(r|tPpxlZ5X3H3!8mTl3-^LpW1zYX0 zdrny9W+zR=_hMy+=rd0g&p;Z_ch2ez{vNAZEM(HZX)xamYct>& Self { + Self { + conv1: Conv2dConfig::new([2, 2], [2, 2]).init(device), + } + } + } + + #[test] + #[should_panic] + fn should_fail_if_not_found() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/top_level_key/top_level_key.pt"); + model + .load_from(&mut store) + .expect("Should decode state successfully"); + } + + #[test] + fn should_load() { + let device = Default::default(); + let mut model = Net::init(&device); + let mut store = PytorchStore::from_file("tests/top_level_key/top_level_key.pt") + .with_top_level_key("my_state_dict"); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + } +} diff --git a/crates/burn-store/pytorch-tests/tests/top_level_key/top_level_key.pt b/crates/burn-store/pytorch-tests/tests/top_level_key/top_level_key.pt new file mode 100644 index 0000000000000000000000000000000000000000..2658e0779ef6c84ab36e813396b3582a2ad66eaa GIT binary patch literal 1660 zcmbVNPiWIn7*Er#>*A(!cAcBLdDtP7*(O`Mw$m0VQji=>D?;qSkhYH{EL-}T*Xo3V zmmNeA5pRNsNA=)Em|}Mn!HfU!w2KJhL3UKU`Cil2CTm6g%14{L_x*mq_kG{{Dv4en zMFj%XE(=kE6f(<-0cyZdmcd%2q#`w3UN$o60F`gqRk{u-c?d_ZtSJ^!5l~8c5kXI} zXc`76BHdiIn6s4hfdyi;8Q&~Jw@8N*c3Ir}98`(+Q_!@+>;B^>1{91w>Q zWU?Z~inRSfa1Sd6L?3?gAUJqUBdUfF+?#{@WTq{xpsN-f)-bQ;*KAxK?$-uH8o?2a zC(`TifaqC=K^tKd!B7s4RqODeJb+P@6@XNdH#pv8m|$i6mV$@s@Nn}aHBhl|ir^8^ zW66Cxyl}D(r!?}eY89-oB=!d${RnLVd@kZr&!UY{Ay?4~YY3Pm{h9PgyJ|bt%jXRp zAp;PC_2lPqKNdi|H$jk!y9mhXB3vddbV2{k)9csm>x-$EuctP@UcAwuw`Wqp<%gTw zlNVBf?{^ycNIX@}o=KLoeB<$lS4rv9&6CWcFLfd`w0Y~;`xo~WxtYT!i)Vp~t{VCkm0VUuFJ;o~Ukl-592bvsTp}tg2y=6>s30Ubj^`3W zA`#=}qw$4UG$sfG@HME+?4bhqFX$Qo+T@c;7 zKob4_*;eGF_jntF%m-pS-w(*J#daD0j}XIgWJFUFtqo*Z68oA@ejG*i+k1ZjC_;wh literal 0 HcmV?d00001 diff --git a/crates/burn-store/safetensors-tests/Cargo.toml b/crates/burn-store/safetensors-tests/Cargo.toml new file mode 100644 index 0000000..86abed7 --- /dev/null +++ b/crates/burn-store/safetensors-tests/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "safetensors-tests" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dev-dependencies] +burn = { workspace = true, features = ["default"] } +burn-flex = { workspace = true, features = ["default"] } +burn-autodiff = { workspace = true, features = ["default"] } +burn-store = { workspace = true, features = ["default", "std", "safetensors"] } +serde = { workspace = true } +float-cmp = { workspace = true } diff --git a/crates/burn-store/safetensors-tests/src/lib.rs b/crates/burn-store/safetensors-tests/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/burn-store/safetensors-tests/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/burn-store/safetensors-tests/tests/multi_layer/mod.rs b/crates/burn-store/safetensors-tests/tests/multi_layer/mod.rs new file mode 100644 index 0000000..45d38d8 --- /dev/null +++ b/crates/burn-store/safetensors-tests/tests/multi_layer/mod.rs @@ -0,0 +1,90 @@ +use burn::{ + module::Module, + nn::{ + BatchNorm, BatchNormConfig, Linear, LinearConfig, PaddingConfig2d, Relu, + conv::{Conv2d, Conv2dConfig}, + }, + tensor::{Device, Tensor}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv2d, + norm1: BatchNorm, + fc1: Linear, + relu: Relu, +} + +impl Net { + pub fn new(device: &Device) -> Self { + Self { + conv1: Conv2dConfig::new([3, 4], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .init(device), + norm1: BatchNormConfig::new(4).init(device), + fc1: LinearConfig::new(4 * 8 * 8, 16).init(device), + relu: Relu::new(), + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<2> { + let x = self.conv1.forward(x); + let x = self.norm1.forward(x); + let x = self.relu.forward(x); + // Flatten all dimensions except the batch dimension + let x = x.flatten(1, 3); + self.fc1.forward(x) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + use burn_store::{ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore}; + + use super::*; + + #[test] + fn multi_layer_model() { + let device = Default::default(); + let mut model = Net::new(&device); + let mut store = SafetensorsStore::from_file("tests/multi_layer/multi_layer.safetensors") + .with_from_adapter(PyTorchToBurnAdapter); + + model + .load_from(&mut store) + .expect("Should decode state successfully"); + + let input = Tensor::<4>::ones([1, 3, 8, 8], &device); + + let output = model.forward(input); + + // Note: Expected values should be updated based on the actual output from the PyTorch model + let expected = Tensor::<2>::from_data( + [[ + 0.04971555, + -0.16849735, + 0.05182848, + -0.18032673, + 0.23138367, + 0.05041867, + 0.13005908, + -0.32202929, + -0.07915690, + -0.03232457, + -0.19790289, + -0.17476529, + -0.19627589, + -0.21757686, + -0.31376451, + 0.08377837, + ]], + &device, + ); + + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); + } +} diff --git a/crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.py b/crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.py new file mode 100755 index 0000000..487462d --- /dev/null +++ b/crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import torch +import torch.nn as nn +import torch.nn.functional as F +from safetensors.torch import save_file + + +class Model(nn.Module): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.Conv2d(3, 4, kernel_size=3, padding=1) + self.norm1 = nn.BatchNorm2d(4) + self.flatten = nn.Flatten() + self.fc1 = nn.Linear(4 * 8 * 8, 16) # Changed for smaller input size + + def forward(self, x): + x = self.conv1(x) + x = self.norm1(x) + x = F.relu(x) + x = self.flatten(x) + x = self.fc1(x) + return x + + +def main(): + + torch.set_printoptions(precision=8) + torch.manual_seed(1) + + model = Model().to(torch.device("cpu")) + + # Use a smaller input size + # 1 batch, 3 channels (RGB), 8x8 image (small input) + x1 = torch.ones(1, 3, 8, 8) + _ = model(x1) + model.eval() # Set to eval mode to freeze running stats + # Save the model to safetensors after the first forward + save_file(model.state_dict(), "multi_layer.safetensors") + + x2 = torch.ones(1, 3, 8, 8) + print("Input shape: {}", x2.shape) + output = model(x2) + print("Output: {}", output) + print("Output Shape: {}", output.shape) + + +if __name__ == "__main__": + main() diff --git a/crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.safetensors b/crates/burn-store/safetensors-tests/tests/multi_layer/multi_layer.safetensors new file mode 100644 index 0000000000000000000000000000000000000000..ce34270d87425eeba62b7ec275b57922243bb962 GIT binary patch literal 17624 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 U9n$S1J<{#}n5Ek@Ff^cG01fx*t^fc4 literal 0 HcmV?d00001 diff --git a/crates/burn-store/safetensors-tests/tests/test_mod.rs b/crates/burn-store/safetensors-tests/tests/test_mod.rs new file mode 100644 index 0000000..4a39d58 --- /dev/null +++ b/crates/burn-store/safetensors-tests/tests/test_mod.rs @@ -0,0 +1 @@ +mod multi_layer; diff --git a/crates/burn-store/src/adapter.rs b/crates/burn-store/src/adapter.rs new file mode 100644 index 0000000..102601a --- /dev/null +++ b/crates/burn-store/src/adapter.rs @@ -0,0 +1,1022 @@ +//! Module adapters for transforming tensor snapshots during save/load +//! +//! This module provides adapters for: +//! - PyTorch/Burn format conversion (weight transposition, parameter renaming) +//! - Mixed-precision storage (F32/F16 dtype casting via [`HalfPrecisionAdapter`]) +//! - Adapter chaining for composing multiple transformations + +use crate::TensorSnapshot; + +use alloc::boxed::Box; +use alloc::format; +use alloc::rc::Rc; +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec; + +use burn_core::tensor::shape; +use burn_core::tensor::{DType, TensorData}; +use hashbrown::HashSet; + +// Module type names as they appear in the container_type field +// These come from the Module derive macro which uses stringify! on the struct name +// Format: "Struct:TypeName" for user-defined structs +mod module_names { + // The actual string constants that match what the Module derive macro produces + pub const LINEAR: &str = "Struct:Linear"; + pub const BATCH_NORM: &str = "Struct:BatchNorm"; + pub const LAYER_NORM: &str = "Struct:LayerNorm"; + pub const GROUP_NORM: &str = "Struct:GroupNorm"; + pub const EMBEDDING: &str = "Struct:Embedding"; + pub const CONV1D: &str = "Struct:Conv1d"; + pub const CONV2D: &str = "Struct:Conv2d"; + pub const CONV3D: &str = "Struct:Conv3d"; + pub const CONV_TRANSPOSE1D: &str = "Struct:ConvTranspose1d"; + pub const CONV_TRANSPOSE2D: &str = "Struct:ConvTranspose2d"; + pub const CONV_TRANSPOSE3D: &str = "Struct:ConvTranspose3d"; + pub const DEFORM_CONV2D: &str = "Struct:DeformConv2d"; + pub const INSTANCE_NORM: &str = "Struct:InstanceNorm"; + pub const RMS_NORM: &str = "Struct:RmsNorm"; + pub const PRELU: &str = "Struct:PRelu"; +} + +/// Trait for adapting tensor snapshots between different module formats +pub trait ModuleAdapter: Send + Sync { + /// Adapt a tensor snapshot based on its container type and parameter name + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot; + + /// Get alternative parameter name to try during matching + /// + /// When looking for a parameter in a module, this method provides an alternative + /// name to try if the direct name doesn't match. This enables matching parameters + /// with different naming conventions (e.g., PyTorch's "weight" vs Burn's "gamma"). + /// + /// # Arguments + /// * `param_name` - The parameter name we're looking for + /// * `container_type` - The type of container module (e.g., "BatchNorm") + /// + /// # Returns + /// Alternative parameter name to try, or None if no alternative exists + fn get_alternative_param_name( + &self, + _param_name: &str, + _container_type: &str, + ) -> Option { + None + } + + /// Clone the adapter into a boxed trait object + fn clone_box(&self) -> Box; + + /// Chain adapters together, applying `self` first and then `next`. + /// + /// This is useful when multiple transformations are required when importing model weights + /// (e.g. PyTorch -> Burn layout conversion, then dtype casting, then custom remapping). + /// + /// The semantics follow a simple pipeline: + /// - `adapt`: `next.adapt(&self.adapt(snapshot))` + /// - `get_alternative_param_name`: try `self` first; if it returns an alternative name, + /// try `next` with that name, otherwise return the first alternative name. + fn chain
(self, next: A) -> ChainAdapter + where + Self: Sized + 'static, + A: ModuleAdapter + 'static, + { + ChainAdapter::new(self, next) + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +/// Adapter that applies two adapters in sequence. +/// +/// This allows composing smaller adapters instead of creating one large monolithic adapter. +#[derive(Clone)] +pub struct ChainAdapter { + first: Box, + second: Box, +} + +impl ChainAdapter { + /// Create a new adapter chain. + pub fn new(first: A, second: B) -> Self + where + A: ModuleAdapter + 'static, + B: ModuleAdapter + 'static, + { + Self { + first: Box::new(first), + second: Box::new(second), + } + } +} + +impl ModuleAdapter for ChainAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + let snapshot = self.first.adapt(snapshot); + self.second.adapt(&snapshot) + } + + fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option { + if let Some(name) = self + .first + .get_alternative_param_name(param_name, container_type) + { + self.second + .get_alternative_param_name(&name, container_type) + .or(Some(name)) + } else { + self.second + .get_alternative_param_name(param_name, container_type) + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Identity adapter that passes tensors through unchanged +#[derive(Debug, Clone, Default)] +pub struct IdentityAdapter; + +impl ModuleAdapter for IdentityAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + snapshot.clone() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Returns the default set of module types that `HalfPrecisionAdapter` converts. +/// +/// Includes: Linear, Embedding, all Conv variants, LayerNorm, GroupNorm, +/// InstanceNorm, RmsNorm, PRelu. +/// +/// Excludes BatchNorm by default because `running_var` underflows in F16. +fn default_half_precision_modules() -> HashSet { + let modules = [ + module_names::LINEAR, + module_names::EMBEDDING, + module_names::CONV1D, + module_names::CONV2D, + module_names::CONV3D, + module_names::CONV_TRANSPOSE1D, + module_names::CONV_TRANSPOSE2D, + module_names::CONV_TRANSPOSE3D, + module_names::DEFORM_CONV2D, + module_names::LAYER_NORM, + module_names::GROUP_NORM, + module_names::INSTANCE_NORM, + module_names::RMS_NORM, + module_names::PRELU, + ]; + modules.iter().map(|s| s.to_string()).collect() +} + +/// Adapter for mixed-precision (F32/F16) model storage. +/// +/// Auto-detects conversion direction from the snapshot's dtype: +/// - F32 source -> cast to F16 (typical for saving) +/// - F16 source -> cast to F32 (typical for loading) +/// - Other dtypes -> passed through unchanged +/// +/// The same instance works for both `with_to_adapter` (save) and `with_from_adapter` (load). +/// +/// By default, converts weights in: Linear, Embedding, Conv*, LayerNorm, GroupNorm, +/// InstanceNorm, RmsNorm, PRelu. BatchNorm is excluded because `running_var` underflows in F16. +/// +/// # Examples +/// +/// Default usage (same adapter for save and load): +/// ```rust +/// # use burn_store::HalfPrecisionAdapter; +/// let adapter = HalfPrecisionAdapter::new(); +/// // store.with_to_adapter(adapter.clone()); // F32 -> F16 on save +/// // store.with_from_adapter(adapter); // F16 -> F32 on load +/// ``` +/// +/// Exclude a module type: +/// ```rust +/// # use burn_store::HalfPrecisionAdapter; +/// let adapter = HalfPrecisionAdapter::new() +/// .without_module("LayerNorm"); +/// ``` +/// +/// Add a custom module type: +/// ```rust +/// # use burn_store::HalfPrecisionAdapter; +/// let adapter = HalfPrecisionAdapter::new() +/// .with_module("CustomLayer"); +/// ``` +#[derive(Debug, Clone)] +pub struct HalfPrecisionAdapter { + modules: HashSet, +} + +impl HalfPrecisionAdapter { + /// Create a new adapter with the default set of modules. + pub fn new() -> Self { + Self { + modules: default_half_precision_modules(), + } + } + + /// Add a module type to convert. Accepts both short (`"MyLayer"`) and + /// qualified (`"Struct:MyLayer"`) forms. + /// + /// Note: short names are mapped to `"Struct:Name"`. If you have an Enum-based + /// module, use the qualified form `"Enum:MyModule"` explicitly. + pub fn with_module(mut self, module_type: impl Into) -> Self { + let name = module_type.into(); + if name.contains(':') { + self.modules.insert(name); + } else { + self.modules.insert(format!("Struct:{}", name)); + } + self + } + + /// Remove a module type from conversion. Accepts both short and qualified forms. + pub fn without_module(mut self, module_type: impl Into) -> Self { + let name = module_type.into(); + let key = if name.contains(':') { + name + } else { + format!("Struct:{}", name) + }; + assert!( + self.modules.contains(&key), + "without_module called with '{}' which is not in the module set", + key + ); + self.modules.remove(&key); + self + } + + /// Check whether the tensor belongs to a module that should be converted. + fn should_convert(&self, snapshot: &TensorSnapshot) -> bool { + snapshot + .module_type() + .is_some_and(|mt| self.modules.contains(&mt)) + } +} + +impl Default for HalfPrecisionAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ModuleAdapter for HalfPrecisionAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + // Determine target dtype from source: F32 -> F16, F16 -> F32, anything else -> skip + let target_dtype = match snapshot.dtype { + DType::F32 => DType::F16, + DType::F16 => DType::F32, + _ => return snapshot.clone(), + }; + + if !self.should_convert(snapshot) { + return snapshot.clone(); + } + + let original_data_fn = snapshot.clone_data_fn(); + + let cast_data_fn = Rc::new(move || { + let data = original_data_fn()?; + Ok(data.convert_dtype(target_dtype)) + }); + + TensorSnapshot::from_closure( + cast_data_fn, + target_dtype, + snapshot.shape.clone(), + snapshot.path_stack.clone().unwrap_or_default(), + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Adapter for converting from PyTorch format to Burn format +/// +/// Handles: +/// - Linear layer weight transposition (PyTorch: [out, in] → Burn: [in, out]) +/// - Normalization parameter renaming (weight → gamma, bias → beta) +#[derive(Debug, Clone, Default)] +pub struct PyTorchToBurnAdapter; + +impl ModuleAdapter for PyTorchToBurnAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + adapt_pytorch_tensor(snapshot, PyTorchConversionDirection::PyTorchToBurn) + } + + fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option { + // For PyTorch->Burn: When looking for Burn names (gamma/beta), try PyTorch names (weight/bias) + if is_normalization_layer(container_type) { + burn_norm_param_to_pytorch(param_name).map(|s| s.to_string()) + } else { + None + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Adapter for converting from Burn format to PyTorch format +/// +/// Handles: +/// - Linear layer weight transposition (Burn: [in, out] → PyTorch: [out, in]) +/// - Normalization parameter renaming (gamma → weight, beta → bias) +#[derive(Debug, Clone, Default)] +pub struct BurnToPyTorchAdapter; + +impl ModuleAdapter for BurnToPyTorchAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + adapt_pytorch_tensor(snapshot, PyTorchConversionDirection::BurnToPyTorch) + } + + fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option { + // For Burn->PyTorch: When looking for PyTorch names (weight/bias), try Burn names (gamma/beta) + if is_normalization_layer(container_type) { + pytorch_norm_param_to_burn(param_name).map(|s| s.to_string()) + } else { + None + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Direction of PyTorch conversion for parameter naming +#[derive(Debug, Clone, Copy)] +enum PyTorchConversionDirection { + PyTorchToBurn, + BurnToPyTorch, +} + +/// Check if container type is a normalization layer +fn is_normalization_layer(container_type: &str) -> bool { + matches!( + container_type, + module_names::BATCH_NORM + | module_names::LAYER_NORM + | module_names::GROUP_NORM + | module_names::RMS_NORM + ) +} + +/// Map PyTorch normalization parameter name to Burn +fn pytorch_norm_param_to_burn(param_name: &str) -> Option<&'static str> { + match param_name { + "weight" => Some("gamma"), + "bias" => Some("beta"), + _ => None, + } +} + +/// Map Burn normalization parameter name to PyTorch +fn burn_norm_param_to_pytorch(param_name: &str) -> Option<&'static str> { + match param_name { + "gamma" => Some("weight"), + "beta" => Some("bias"), + _ => None, + } +} + +/// Core tensor adaptation logic for PyTorch format conversions +fn adapt_pytorch_tensor( + snapshot: &TensorSnapshot, + direction: PyTorchConversionDirection, +) -> TensorSnapshot { + // Extract path and parameter name + let (path_stack, param_name) = match get_path_and_param(snapshot) { + Some(result) => result, + None => return snapshot.clone(), + }; + + // Get module type for matching (ignores Vec/Array wrappers) + let module_type = match snapshot.module_type() { + Some(mt) => mt, + None => return snapshot.clone(), // No user-defined module found + }; + + // Linear: transpose weight (bidirectional - same operation both ways) + if module_type == module_names::LINEAR && param_name == "weight" && snapshot.shape.len() == 2 { + return transpose_2d_tensor(snapshot); + } + + // Normalization layers: rename parameters based on direction + if is_normalization_layer(&module_type) { + let new_name = match direction { + PyTorchConversionDirection::PyTorchToBurn => pytorch_norm_param_to_burn(param_name), + PyTorchConversionDirection::BurnToPyTorch => burn_norm_param_to_pytorch(param_name), + }; + + if let Some(new_name) = new_name { + return rename_parameter(snapshot, path_stack, new_name); + } + } + + snapshot.clone() +} + +/// Extract path stack and parameter name from snapshot +fn get_path_and_param(snapshot: &TensorSnapshot) -> Option<(&[String], &str)> { + let path_stack = snapshot.path_stack.as_ref()?; + let param_name = path_stack.last()?.as_str(); + Some((path_stack.as_slice(), param_name)) +} + +/// Rename a parameter in the snapshot +fn rename_parameter( + snapshot: &TensorSnapshot, + path_stack: &[String], + new_name: &str, +) -> TensorSnapshot { + let mut new_path = path_stack.to_vec(); + *new_path.last_mut().unwrap() = new_name.to_string(); + + TensorSnapshot::from_closure( + snapshot.clone_data_fn(), + snapshot.dtype, + snapshot.shape.clone(), + new_path, + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ) +} + +/// Transpose a 2D tensor +fn transpose_2d_tensor(snapshot: &TensorSnapshot) -> TensorSnapshot { + if snapshot.shape.len() != 2 { + return snapshot.clone(); + } + + let original_data_fn = snapshot.clone_data_fn(); + let dtype = snapshot.dtype; + let transposed_shape = shape![snapshot.shape[1], snapshot.shape[0]]; + + // Create a lazy closure that transposes when called + let transposed_data_fn = Rc::new(move || { + let data = original_data_fn()?; + Ok(transpose_tensor_data(data)) + }); + + TensorSnapshot::from_closure( + transposed_data_fn, + dtype, + transposed_shape, + snapshot.path_stack.clone().unwrap_or_default(), + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ) +} + +/// Transpose tensor data (assumes 2D shape is already validated) +fn transpose_tensor_data(data: TensorData) -> TensorData { + let shape = &data.shape; + let rows = shape[0]; + let cols = shape[1]; + let transposed_shape = vec![cols, rows]; + + // Get the raw bytes and element size + let bytes = data.as_bytes(); + let element_size = data.dtype.size(); + + // Create a new buffer for transposed data + let mut transposed_bytes = vec![0u8; bytes.len()]; + + // Transpose at the byte level - works for any data type + for i in 0..rows { + for j in 0..cols { + let src_idx = (i * cols + j) * element_size; + let dst_idx = (j * rows + i) * element_size; + + // Copy the bytes for this element + transposed_bytes[dst_idx..dst_idx + element_size] + .copy_from_slice(&bytes[src_idx..src_idx + element_size]); + } + } + + // Create new TensorData from transposed bytes + TensorData::from_bytes_vec(transposed_bytes, transposed_shape, data.dtype) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::rc::Rc; + use alloc::sync::Arc; + use burn_core::tensor::{DType, Shape, TensorData}; + use core::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn test_module_names_match_burn_nn() { + // If these types are renamed or moved in `burn-nn`, this test will fail to compile. + #[allow(unused_imports)] + use burn_nn::{ + BatchNorm, Embedding, GroupNorm, InstanceNorm, LayerNorm, Linear, PRelu, RmsNorm, + conv::{ + Conv1d, Conv2d, Conv3d, ConvTranspose1d, ConvTranspose2d, ConvTranspose3d, + DeformConv2d, + }, + }; + + assert_eq!(module_names::LINEAR, "Struct:Linear"); + assert_eq!(module_names::BATCH_NORM, "Struct:BatchNorm"); + assert_eq!(module_names::LAYER_NORM, "Struct:LayerNorm"); + assert_eq!(module_names::GROUP_NORM, "Struct:GroupNorm"); + assert_eq!(module_names::EMBEDDING, "Struct:Embedding"); + assert_eq!(module_names::CONV1D, "Struct:Conv1d"); + assert_eq!(module_names::CONV2D, "Struct:Conv2d"); + assert_eq!(module_names::CONV3D, "Struct:Conv3d"); + assert_eq!(module_names::CONV_TRANSPOSE1D, "Struct:ConvTranspose1d"); + assert_eq!(module_names::CONV_TRANSPOSE2D, "Struct:ConvTranspose2d"); + assert_eq!(module_names::CONV_TRANSPOSE3D, "Struct:ConvTranspose3d"); + assert_eq!(module_names::DEFORM_CONV2D, "Struct:DeformConv2d"); + assert_eq!(module_names::INSTANCE_NORM, "Struct:InstanceNorm"); + assert_eq!(module_names::RMS_NORM, "Struct:RmsNorm"); + assert_eq!(module_names::PRELU, "Struct:PRelu"); + } + + fn create_test_snapshot(path: &str, shape: Shape, container_type: &str) -> TensorSnapshot { + let path_parts: Vec = path.split('.').map(|s| s.to_string()).collect(); + let values = vec![1.0f32; shape.iter().product()]; + let data = TensorData::new(values, shape.clone()); + + TensorSnapshot::from_closure( + Rc::new(move || Ok(data.clone())), + DType::F32, + shape, + path_parts, + vec![container_type.to_string()], + burn_core::module::ParamId::new(), + ) + } + + #[test] + fn test_pytorch_to_burn_linear_weight() { + let adapter = PyTorchToBurnAdapter; + + // Linear layer weight should be transposed + let snapshot = create_test_snapshot("fc.weight", shape![10, 5], module_names::LINEAR); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.shape, shape![5, 10]); + + // Linear layer bias should not be transposed + let snapshot = create_test_snapshot("fc.bias", shape![10], module_names::LINEAR); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.shape, shape![10]); + } + + #[test] + fn test_pytorch_to_burn_norm_params() { + let adapter = PyTorchToBurnAdapter; + + // BatchNorm weight -> gamma + let snapshot = create_test_snapshot("norm.weight", shape![10], module_names::BATCH_NORM); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.full_path(), "norm.gamma"); + + // BatchNorm bias -> beta + let snapshot = create_test_snapshot("norm.bias", shape![10], module_names::BATCH_NORM); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.full_path(), "norm.beta"); + } + + #[test] + fn test_burn_to_pytorch_linear_weight() { + let adapter = BurnToPyTorchAdapter; + + // Linear layer weight should be transposed + let snapshot = create_test_snapshot("fc.weight", shape![5, 10], module_names::LINEAR); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.shape, shape![10, 5]); + } + + #[test] + fn test_burn_to_pytorch_norm_params() { + let adapter = BurnToPyTorchAdapter; + + // BatchNorm gamma -> weight + let snapshot = create_test_snapshot("norm.gamma", shape![10], module_names::BATCH_NORM); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.full_path(), "norm.weight"); + + // BatchNorm beta -> bias + let snapshot = create_test_snapshot("norm.beta", shape![10], module_names::BATCH_NORM); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.full_path(), "norm.bias"); + } + + #[test] + fn test_transpose_different_dtypes() { + // Test that transpose works for different data types + + // Test with F32 + let f32_data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]); + let transposed = transpose_tensor_data(f32_data); + assert_eq!(transposed.shape, shape![3, 2]); + let values = transposed.to_vec::().unwrap(); + assert_eq!(values, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]); + + // Test with I32 + let i32_data = TensorData::new(vec![1i32, 2, 3, 4, 5, 6], [2, 3]); + let transposed = transpose_tensor_data(i32_data); + assert_eq!(transposed.shape, shape![3, 2]); + let values = transposed.to_vec::().unwrap(); + assert_eq!(values, vec![1, 4, 2, 5, 3, 6]); + + // Test with F64 + let f64_data = TensorData::new(vec![1.0f64, 2.0, 3.0, 4.0], [2, 2]); + let transposed = transpose_tensor_data(f64_data); + assert_eq!(transposed.shape, shape![2, 2]); + let values = transposed.to_vec::().unwrap(); + assert_eq!(values, vec![1.0, 3.0, 2.0, 4.0]); + } + + #[test] + fn test_no_container_info() { + let adapter = PyTorchToBurnAdapter; + + // Without container info, adapter returns unchanged for non-norm parameters + let mut snapshot = create_test_snapshot("fc.weight", shape![10, 5], module_names::LINEAR); + snapshot.container_stack = None; + + // Without container info, no transformation occurs for linear layers + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.shape, shape![10, 5]); // No transposition without container info + + // Test a non-linear, non-norm parameter - should pass through unchanged + let mut snapshot2 = create_test_snapshot("other.weight", shape![10, 5], "Struct:Other"); + snapshot2.container_stack = None; + let adapted2 = adapter.adapt(&snapshot2); + assert_eq!(adapted2.shape, shape![10, 5]); // No transposition + } + + #[derive(Clone)] + struct RenameParamAdapter { + from: &'static str, + to: &'static str, + called: Arc, + } + + impl ModuleAdapter for RenameParamAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + self.called.fetch_add(1, Ordering::Relaxed); + + let path_stack = match snapshot.path_stack.as_ref() { + Some(stack) => stack, + None => return snapshot.clone(), + }; + let param = match path_stack.last() { + Some(p) => p.as_str(), + None => return snapshot.clone(), + }; + if param != self.from { + return snapshot.clone(); + } + + let mut new_path = path_stack.to_vec(); + *new_path.last_mut().unwrap() = self.to.to_string(); + + TensorSnapshot::from_closure( + snapshot.clone_data_fn(), + snapshot.dtype, + snapshot.shape.clone(), + new_path, + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ) + } + + fn get_alternative_param_name( + &self, + _param_name: &str, + _container_type: &str, + ) -> Option { + None + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + #[derive(Clone)] + struct AltNameAdapter { + from: &'static str, + to: &'static str, + called: Arc, + } + + impl ModuleAdapter for AltNameAdapter { + fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot { + TensorSnapshot::from_closure( + snapshot.clone_data_fn(), + snapshot.dtype, + snapshot.shape.clone(), + snapshot.path_stack.clone().unwrap_or_default(), + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ) + } + + fn get_alternative_param_name( + &self, + param_name: &str, + _container_type: &str, + ) -> Option { + self.called.fetch_add(1, Ordering::Relaxed); + if param_name == self.from { + Some(self.to.to_string()) + } else { + None + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + #[test] + fn test_chain_adapter_pipes_adapt() { + let called1 = Arc::new(AtomicUsize::new(0)); + let called2 = Arc::new(AtomicUsize::new(0)); + + let a = RenameParamAdapter { + from: "weight", + to: "a", + called: called1.clone(), + }; + let b = RenameParamAdapter { + from: "a", + to: "b", + called: called2.clone(), + }; + + let chain = a.chain(b); + let snapshot = create_test_snapshot("fc.weight", shape![2, 2], module_names::LINEAR); + let adapted = chain.adapt(&snapshot); + + assert_eq!(adapted.full_path(), "fc.b"); + assert_eq!(called1.load(Ordering::Relaxed), 1); + assert_eq!(called2.load(Ordering::Relaxed), 1); + } + + #[test] + fn test_chain_adapter_alternative_name_pipes_and_fallbacks() { + let called1 = Arc::new(AtomicUsize::new(0)); + let called2 = Arc::new(AtomicUsize::new(0)); + + let a = AltNameAdapter { + from: "gamma", + to: "weight", + called: called1.clone(), + }; + let b = AltNameAdapter { + from: "weight", + to: "scale", + called: called2.clone(), + }; + + let chain = a.chain(b); + let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM); + assert_eq!(alt.as_deref(), Some("scale")); + assert_eq!(called1.load(Ordering::Relaxed), 1); + assert_eq!(called2.load(Ordering::Relaxed), 1); + + // If the second adapter doesn't have a mapping for the first alternative, + // fall back to the first alternative name. + let called1 = Arc::new(AtomicUsize::new(0)); + let called2 = Arc::new(AtomicUsize::new(0)); + let a = AltNameAdapter { + from: "gamma", + to: "weight", + called: called1.clone(), + }; + let b = AltNameAdapter { + from: "something-else", + to: "unused", + called: called2.clone(), + }; + let chain = a.chain(b); + let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM); + assert_eq!(alt.as_deref(), Some("weight")); + assert_eq!(called1.load(Ordering::Relaxed), 1); + assert_eq!(called2.load(Ordering::Relaxed), 1); + + // If the first adapter doesn't provide an alternative, try the second with the original name. + let called1 = Arc::new(AtomicUsize::new(0)); + let called2 = Arc::new(AtomicUsize::new(0)); + let a = AltNameAdapter { + from: "something-else", + to: "unused", + called: called1.clone(), + }; + let b = AltNameAdapter { + from: "gamma", + to: "weight", + called: called2.clone(), + }; + let chain = a.chain(b); + let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM); + assert_eq!(alt.as_deref(), Some("weight")); + assert_eq!(called1.load(Ordering::Relaxed), 1); + assert_eq!(called2.load(Ordering::Relaxed), 1); + + // clone_box must preserve behavior. + let boxed = chain.clone_box(); + let alt = boxed.get_alternative_param_name("gamma", module_names::LAYER_NORM); + assert_eq!(alt.as_deref(), Some("weight")); + } + + #[test] + fn test_half_precision_f32_to_f16() { + let adapter = HalfPrecisionAdapter::new(); + let snapshot = create_test_snapshot("fc.weight", shape![2, 3], module_names::LINEAR); + + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.dtype, DType::F16); + assert_eq!(adapted.shape, shape![2, 3]); + + let data = adapted.to_data().unwrap(); + assert_eq!(data.dtype, DType::F16); + } + + #[test] + fn test_half_precision_f16_to_f32() { + let adapter = HalfPrecisionAdapter::new(); + + // Create an F16 snapshot + let values = vec![1.0f32; 6]; + let data = TensorData::new(values, shape![2, 3]).convert_dtype(DType::F16); + let path_parts = vec!["fc".to_string(), "weight".to_string()]; + let snapshot = TensorSnapshot::from_closure( + Rc::new(move || Ok(data.clone())), + DType::F16, + shape![2, 3], + path_parts, + vec![module_names::LINEAR.to_string()], + burn_core::module::ParamId::new(), + ); + + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.dtype, DType::F32); + } + + #[test] + fn test_half_precision_skips_batch_norm() { + let adapter = HalfPrecisionAdapter::new(); + + // BatchNorm is excluded by default + let snapshot = create_test_snapshot("norm.weight", shape![10], module_names::BATCH_NORM); + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.dtype, DType::F32); // unchanged + } + + #[test] + fn test_half_precision_converts_default_modules() { + let adapter = HalfPrecisionAdapter::new(); + + // Linear + let snapshot = create_test_snapshot("fc.weight", shape![2, 3], module_names::LINEAR); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + + // Embedding + let snapshot = create_test_snapshot("emb.weight", shape![100, 64], module_names::EMBEDDING); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + + // Conv2d + let snapshot = + create_test_snapshot("conv.weight", shape![3, 3, 3, 3], module_names::CONV2D); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + + // LayerNorm (included by default) + let snapshot = create_test_snapshot("norm.gamma", shape![10], module_names::LAYER_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + + // GroupNorm + let snapshot = create_test_snapshot("gn.gamma", shape![10], module_names::GROUP_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + + // RmsNorm + let snapshot = create_test_snapshot("rms.weight", shape![10], module_names::RMS_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + } + + #[test] + fn test_half_precision_without_module() { + let adapter = HalfPrecisionAdapter::new().without_module("LayerNorm"); + + // LayerNorm removed from conversion set + let snapshot = create_test_snapshot("norm.gamma", shape![10], module_names::LAYER_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F32); + + // Linear still converted + let snapshot = create_test_snapshot("fc.weight", shape![2, 3], module_names::LINEAR); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + } + + #[test] + fn test_half_precision_with_module() { + let adapter = HalfPrecisionAdapter::new().with_module("CustomLayer"); + + // Custom module should now be converted + let snapshot = create_test_snapshot("custom.weight", shape![5], "Struct:CustomLayer"); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + } + + #[test] + fn test_half_precision_with_qualified_name() { + let adapter = HalfPrecisionAdapter::new().with_module("Struct:CustomLayer"); + + let snapshot = create_test_snapshot("custom.weight", shape![5], "Struct:CustomLayer"); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + } + + #[test] + fn test_half_precision_chain() { + let adapter = PyTorchToBurnAdapter.chain(HalfPrecisionAdapter::new()); + + let snapshot = create_test_snapshot("fc.weight", shape![10, 5], module_names::LINEAR); + let adapted = adapter.adapt(&snapshot); + + // Should be both transposed and cast + assert_eq!(adapted.shape, shape![5, 10]); + assert_eq!(adapted.dtype, DType::F16); + } + + #[test] + fn test_half_precision_skips_no_container() { + let adapter = HalfPrecisionAdapter::new(); + let mut snapshot = create_test_snapshot("fc.weight", shape![2, 3], module_names::LINEAR); + snapshot.container_stack = None; + + // No module type info: skip + let adapted = adapter.adapt(&snapshot); + assert_eq!(adapted.dtype, DType::F32); + } + + #[test] + fn test_half_precision_skips_non_float() { + use burn_core::tensor::quantization::QuantScheme; + + let adapter = HalfPrecisionAdapter::new(); + + // QFloat source: skip + let qfloat_dtype = DType::QFloat(QuantScheme::default()); + let snapshot = create_test_snapshot("fc.weight", shape![2, 3], module_names::LINEAR); + let qfloat_snapshot = TensorSnapshot::from_closure( + snapshot.clone_data_fn(), + qfloat_dtype, + snapshot.shape.clone(), + snapshot.path_stack.clone().unwrap_or_default(), + snapshot.container_stack.clone().unwrap_or_default(), + snapshot.tensor_id.unwrap_or_default(), + ); + let adapted = adapter.adapt(&qfloat_snapshot); + assert_eq!(adapted.dtype, qfloat_dtype); + } + + #[test] + fn test_half_precision_default_module_count() { + let adapter = HalfPrecisionAdapter::new(); + // 14 modules: Linear, Embedding, Conv1d-3d, ConvTranspose1d-3d, + // DeformConv2d, LayerNorm, GroupNorm, InstanceNorm, RmsNorm, PRelu + assert_eq!(adapter.modules.len(), 14); + } + + #[test] + fn test_half_precision_without_module_qualified() { + let adapter = HalfPrecisionAdapter::new().without_module("Struct:LayerNorm"); + + let snapshot = create_test_snapshot("norm.gamma", shape![10], module_names::LAYER_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F32); + } + + #[test] + fn test_half_precision_with_module_batch_norm_opt_in() { + let adapter = HalfPrecisionAdapter::new().with_module("BatchNorm"); + + let snapshot = create_test_snapshot("bn.weight", shape![10], module_names::BATCH_NORM); + assert_eq!(adapter.adapt(&snapshot).dtype, DType::F16); + } +} diff --git a/crates/burn-store/src/applier.rs b/crates/burn-store/src/applier.rs new file mode 100644 index 0000000..648c095 --- /dev/null +++ b/crates/burn-store/src/applier.rs @@ -0,0 +1,585 @@ +//! Applier that correctly applies tensor snapshots with adapter support + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use hashbrown::{HashMap, HashSet}; + +use burn_core::module::{ModuleMapper, Param}; +use burn_core::tensor::{Bool, Device, Int, Shape, Tensor}; + +use crate::apply_result::{ApplyError, ApplyResult}; +use crate::{ModuleAdapter, PathFilter, TensorSnapshot}; + +/// Applier that applies tensor snapshots to module parameters +/// with proper adapter support using container type information +pub struct Applier { + /// Map of tensor paths to their snapshots + snapshots: HashMap, + /// Current path in the module hierarchy + path_stack: Vec, + /// Current container type stack in the module hierarchy + container_stack: Vec, + /// Optional filter for selective application + filter: Option, + /// Optional adapter to transform tensors based on container types + adapter: Option>, + /// Successfully applied tensor paths + applied: Vec, + /// Skipped tensor paths + skipped: HashSet, + /// Errors encountered during application + errors: Vec, + /// Track visited paths with their container stacks (in dot notation) to find missing tensors + visited_paths: HashMap, + /// Skip enum variant names when matching paths + /// When true, "feature.BaseConv.weight" will also try to match "feature.weight" + skip_enum_variants: bool, +} + +impl Applier { + /// Create a new applier with snapshots, optional filter, and optional adapter + /// + /// # Arguments + /// + /// * `views` - A vector of TensorSnapshot objects to apply + /// * `filter` - An optional [`PathFilter`] to determine which tensors to apply. + /// When `None`, all available tensors are applied. + /// * `adapter` - Optional adapter to transform tensors based on container types + /// * `skip_enum_variants` - Skip enum variant names when matching paths + pub fn new( + views: Vec, + filter: Option, + adapter: Option>, + skip_enum_variants: bool, + ) -> Self { + let views_map: HashMap = views + .into_iter() + .map(|view| (view.full_path(), view)) + .collect(); + + Self { + snapshots: views_map, + path_stack: Vec::new(), + container_stack: Vec::new(), + filter, + adapter, + applied: Vec::new(), + skipped: HashSet::new(), + errors: Vec::new(), + visited_paths: HashMap::new(), + skip_enum_variants, + } + } + + /// Get the current path in the module hierarchy + fn current_path(&self) -> String { + self.path_stack.join(".") + } + + /// Get the current module type (last Struct/Enum in container stack) + fn current_module_type(&self) -> Option<&str> { + self.container_stack + .iter() + .rev() + .find(|ct| ct.starts_with("Struct:") || ct.starts_with("Enum:")) + .map(|s| s.as_str()) + } + + /// Check if a tensor should be applied based on filter + fn should_apply(&self) -> bool { + match &self.filter { + None => true, + Some(f) => f.matches_with_container_path(&self.path_stack, &self.container_stack), + } + } + + /// Convert the applier into a result + pub fn into_result(self) -> ApplyResult { + let mut unused: Vec = self + .snapshots + .keys() + .filter(|path| !self.visited_paths.contains_key(*path) && !self.skipped.contains(*path)) + .cloned() + .collect(); + // Sort for stable output order + unused.sort(); + + // Create a set of successfully applied paths for efficient lookup + let applied_set: HashSet = self.applied.iter().cloned().collect(); + + // Extract paths that have errors - these are not "missing", they were found but had issues + let errored_paths: HashSet = self + .errors + .iter() + .map(|e| match e { + ApplyError::ShapeMismatch { path, .. } => path.clone(), + ApplyError::DTypeMismatch { path, .. } => path.clone(), + ApplyError::AdapterError { path, .. } => path.clone(), + ApplyError::LoadError { path, .. } => path.clone(), + }) + .collect(); + + // A path is missing if it was visited but not successfully applied, not skipped, and didn't have an error + // Store both the path and its container stack (in dot notation) + let mut missing: Vec<(String, String)> = self + .visited_paths + .into_iter() + .filter(|(p, _)| { + !applied_set.contains(p) && !self.skipped.contains(p) && !errored_paths.contains(p) + }) + .collect(); + // Sort for stable output order (by path) + missing.sort_by(|a, b| a.0.cmp(&b.0)); + + // Convert skipped HashSet to sorted Vec for stable output + let mut skipped: Vec = self.skipped.into_iter().collect(); + skipped.sort(); + + ApplyResult { + applied: self.applied, + skipped, + missing, + unused, + errors: self.errors, + } + } + + /// Apply a tensor snapshot with shape validation and optional adapter transformation + /// Returns None if snapshot not found, filtered, or validation fails + fn apply_tensor( + &mut self, + target_device: &Device, + target_shape: Shape, + ) -> Option> + where + K: burn_core::tensor::kind::Basic, + { + let path = self.current_path(); + let container_stack_str = self.container_stack.join("."); + self.visited_paths.insert(path.clone(), container_stack_str); + + // Try to get snapshot with original path first + let mut snapshot = self.snapshots.get(&path).cloned(); + + // If not found and we have an adapter, try alternative parameter names + if snapshot.is_none() + && let Some(ref adapter) = self.adapter + && let Some(module_type) = self.current_module_type() + { + // Get alternative name based on current module type (user-defined module only) + let param_name = self.path_stack.last()?; + + if let Some(alt_name) = adapter.get_alternative_param_name(param_name, module_type) { + // Build alternative path with parameter name substitution + let mut alt_path_stack = self.path_stack.clone(); + *alt_path_stack.last_mut().unwrap() = alt_name.clone(); + let alt_path = alt_path_stack.join("."); + + // Try to get snapshot with alternative name + snapshot = self.snapshots.get(&alt_path).cloned(); + + // Don't mark the alternative path as visited - only the original Burn path + // should be tracked. The alternative path is just for lookup. + } + } + + let mut snapshot = snapshot?; + + // Apply adapter transformation using current container_stack context (for data transformation like transpose) + if let Some(ref adapter) = self.adapter { + // Create a temporary snapshot with current context for adaptation + let snapshot_with_context = TensorSnapshot::from_closure( + snapshot.clone_data_fn(), + snapshot.dtype, + snapshot.shape.clone(), + self.path_stack.clone(), + self.container_stack.clone(), + snapshot.tensor_id.unwrap_or_default(), + ); + + // Transform using adapter (handles transpose) + snapshot = adapter.adapt(&snapshot_with_context); + } + + // Check if we should apply based on filter + if !self.should_apply() { + self.skipped.insert(path.clone()); + return None; + } + + // Load tensor data + let data = match snapshot.to_data() { + Ok(data) => data, + Err(e) => { + self.errors.push(ApplyError::LoadError { + path: path.clone(), + message: format!("Failed to load tensor data: {:?}", e), + }); + return None; // Signal caller to fall back to initialization + } + }; + + // Validate shape + if data.shape != target_shape { + self.errors.push(ApplyError::ShapeMismatch { + path: path.clone(), + expected: target_shape, + found: data.shape, + }); + return None; // Signal caller to fall back to initialization + } + + self.applied.push(path); + Some(Tensor::from_data(data, (target_device, snapshot.dtype))) + } +} + +impl ModuleMapper for Applier { + fn enter_module(&mut self, name: &str, container_type: &str) { + // Always track the container type for proper module type detection + self.container_stack.push(container_type.to_string()); + + // Only add to path if it's not an enum variant (when skip_enum_variants is enabled) + // This ensures paths are built without enum variant names from the start + if !self.skip_enum_variants || !container_type.starts_with("Enum:") { + self.path_stack.push(name.to_string()); + } + } + + fn exit_module(&mut self, _name: &str, container_type: &str) { + self.container_stack.pop(); + + // Only pop from path if we added it (not an enum variant when skip_enum_variants is enabled) + if !self.skip_enum_variants || !container_type.starts_with("Enum:") { + self.path_stack.pop(); + } + } + + fn map_float(&mut self, param: Param>) -> Param> { + let param_id = param.id; + let target_device = param.lazy_device(); + let target_shape = param.lazy_shape(); + + // Try to apply snapshot with shape validation + match self.apply_tensor(&target_device, target_shape) { + Some(tensor) => { + // We have a tensor to apply - load it + param.transform_for_load(tensor, param_id) + } + None => { + // No snapshot, filtered, or validation failed - return param unchanged + param + } + } + } + + fn map_int(&mut self, param: Param>) -> Param> { + let param_id = param.id; + let target_device = param.lazy_device(); + let target_shape = param.lazy_shape(); + + // Try to apply snapshot with shape validation + match self.apply_tensor(&target_device, target_shape) { + Some(tensor) => { + // We have a tensor to apply - load it + param.transform_for_load(tensor, param_id) + } + None => { + // No snapshot, filtered, or validation failed - return param unchanged + param + } + } + } + + fn map_bool( + &mut self, + param: Param>, + ) -> Param> { + let param_id = param.id; + let target_device = param.lazy_device(); + let target_shape = param.lazy_shape(); + + // Try to apply snapshot with shape validation + match self.apply_tensor(&target_device, target_shape) { + Some(tensor) => { + // We have a tensor to apply - load it + param.transform_for_load(tensor, param_id) + } + None => { + // No snapshot, filtered, or validation failed - return param unchanged + param + } + } + } +} + +#[cfg(all(test, feature = "std", target_has_atomic = "ptr"))] +mod tests { + use super::*; + use burn_core::module::{ModuleMapper, Param, ParamId}; + use burn_core::tensor::{DType, Tensor, TensorData}; + + #[test] + fn root_level_parameters() { + let device = Default::default(); + + // Create root-level parameters (not inside any module) + let weight = Param::>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let bias = Param::>::from_data([5.0, 6.0], &device); + + // Create snapshots with root-level paths (single-element path, no nested modules) + let weight_snapshot = crate::TensorSnapshot::from_data( + weight.val().to_data(), + vec!["weight".to_string()], // root-level parameter name + vec![], // no container + ParamId::new(), + ); + + let bias_snapshot = crate::TensorSnapshot::from_data( + bias.val().to_data(), + vec!["bias".to_string()], // root-level parameter name + vec![], // no container + ParamId::new(), + ); + + // Create applier with root-level snapshots + let mut applier = Applier::new(vec![weight_snapshot, bias_snapshot], None, None, false); + + // Create new params to load into + let weight_target = Param::initialized(ParamId::new(), Tensor::<2>::zeros([2, 2], &device)); + let bias_target = Param::initialized(ParamId::new(), Tensor::<1>::zeros([2], &device)); + + // Apply using the ModuleMapper interface - simulate module traversal + // Enter "weight" path (as if we're visiting a field named "weight") + applier.enter_module("weight", ""); + let weight_loaded = applier.map_float(weight_target); + applier.exit_module("weight", ""); + + // Enter "bias" path (as if we're visiting a field named "bias") + applier.enter_module("bias", ""); + let bias_loaded = applier.map_float(bias_target); + applier.exit_module("bias", ""); + + // Verify values were loaded + let weight_data = weight_loaded.val().to_data().to_vec::().unwrap(); + let bias_data = bias_loaded.val().to_data().to_vec::().unwrap(); + + assert_eq!(weight_data, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(bias_data, vec![5.0, 6.0]); + + // Verify applier result + let result = applier.into_result(); + assert_eq!(result.applied.len(), 2); + assert_eq!(result.errors.len(), 0); + } + + /// Test that the applier preserves dtype when loading tensor data. + /// This is a regression test for the bug where F16 tensors were being + /// loaded as F32 because `Tensor::from_data` was used instead of + /// without specifying the target dtype + #[test] + fn dtype_preservation_f64() { + let device = Default::default(); + + // Create TensorData with F64 dtype explicitly + let f64_data = TensorData::new(vec![1.0f64, 2.0, 3.0, 4.0], [2, 2]); + assert_eq!(f64_data.dtype, DType::F64, "Test setup: data should be F64"); + + // Create a snapshot with F64 data + let snapshot = crate::TensorSnapshot::from_data( + f64_data.clone(), + vec!["weight".to_string()], + vec![], + ParamId::new(), + ); + assert_eq!( + snapshot.dtype, + DType::F64, + "Snapshot should preserve F64 dtype" + ); + + // Create applier with the F64 snapshot + let mut applier = Applier::new(vec![snapshot], None, None, false); + + // Create target parameter + let target = Param::initialized( + ParamId::new(), + Tensor::<2>::zeros([2, 2], (&device, DType::F64)), + ); + + // Apply the snapshot + applier.enter_module("weight", ""); + let loaded = applier.map_float(target); + applier.exit_module("weight", ""); + + // Verify dtype is preserved - this would fail before the fix + // because the data would be converted to the backend's default FloatElem + assert_eq!( + loaded.val().dtype(), + DType::F64, + "Loaded tensor should have F64 dtype" + ); + + // Verify data values are correct + let loaded_data = loaded.val().to_data().to_vec::().unwrap(); + assert_eq!(loaded_data, vec![1.0, 2.0, 3.0, 4.0]); + + // Verify applier result + let result = applier.into_result(); + assert_eq!(result.applied.len(), 1); + assert_eq!(result.errors.len(), 0); + } + + /// Test that F32 dtype is preserved when loading (verifies we didn't break F32 handling) + #[test] + fn dtype_preservation_f32() { + let device = Default::default(); + + // Create TensorData with F32 dtype + let f32_data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0], [2, 2]); + assert_eq!(f32_data.dtype, DType::F32); + + // Create a snapshot with F32 data + let snapshot = crate::TensorSnapshot::from_data( + f32_data.clone(), + vec!["weight".to_string()], + vec![], + ParamId::new(), + ); + assert_eq!(snapshot.dtype, DType::F32); + + // Create applier with the F32 snapshot + let mut applier = Applier::new(vec![snapshot], None, None, false); + + // Create target parameter + let target = Param::initialized(ParamId::new(), Tensor::<2>::zeros([2, 2], &device)); + + // Apply the snapshot + applier.enter_module("weight", ""); + let loaded = applier.map_float(target); + applier.exit_module("weight", ""); + + // Verify dtype is F32 + assert_eq!(loaded.val().dtype(), DType::F32); + + // Verify data values + let loaded_data = loaded.val().to_data().to_vec::().unwrap(); + assert_eq!(loaded_data, vec![1.0, 2.0, 3.0, 4.0]); + } + + /// Test that F16 dtype is correctly preserved in TensorSnapshot. + /// + /// Verifies the snapshot layer preserves the F16 dtype tag through a + /// round-trip; does not materialize the data through a backend. + #[test] + fn dtype_preservation_f16_snapshot() { + use half::f16; + + // Create TensorData with F16 dtype using the half crate + let f16_values: Vec = vec![ + f16::from_f32(1.0), + f16::from_f32(2.0), + f16::from_f32(3.0), + f16::from_f32(4.0), + ]; + let f16_data = TensorData::new(f16_values.clone(), [2, 2]); + assert_eq!( + f16_data.dtype, + DType::F16, + "TensorData should have F16 dtype" + ); + + // Create a snapshot with F16 data + let snapshot = crate::TensorSnapshot::from_data( + f16_data.clone(), + vec!["weight".to_string()], + vec![], + ParamId::new(), + ); + + // Verify snapshot preserves F16 dtype + assert_eq!( + snapshot.dtype, + DType::F16, + "TensorSnapshot should preserve F16 dtype" + ); + + // Verify the data can be retrieved with correct dtype + let retrieved_data = snapshot.to_data().expect("Should be able to retrieve data"); + assert_eq!( + retrieved_data.dtype, + DType::F16, + "Retrieved data should have F16 dtype" + ); + + // Verify the actual values are preserved + let retrieved_values: Vec = retrieved_data + .to_vec() + .expect("Should be able to convert to f16 vec"); + assert_eq!( + retrieved_values, f16_values, + "F16 values should be preserved" + ); + + // Note: To fully test F16 tensor creation, you would need a backend + // that supports F16 (like CUDA or WebGPU). The applier fix ensures + // that `Tensor::from_data(data, (device, snapshot.dtype))` is + // called with DType::F16, which will correctly create an F16 tensor + // on backends that support it. + } + + /// Test that BF16 dtype is correctly preserved in TensorSnapshot. + #[test] + fn dtype_preservation_bf16_snapshot() { + use half::bf16; + + // Create TensorData with BF16 dtype + let bf16_values: Vec = vec![ + bf16::from_f32(1.0), + bf16::from_f32(2.0), + bf16::from_f32(3.0), + bf16::from_f32(4.0), + ]; + let bf16_data = TensorData::new(bf16_values.clone(), [2, 2]); + assert_eq!( + bf16_data.dtype, + DType::BF16, + "TensorData should have BF16 dtype" + ); + + // Create a snapshot with BF16 data + let snapshot = crate::TensorSnapshot::from_data( + bf16_data.clone(), + vec!["weight".to_string()], + vec![], + ParamId::new(), + ); + + // Verify snapshot preserves BF16 dtype + assert_eq!( + snapshot.dtype, + DType::BF16, + "TensorSnapshot should preserve BF16 dtype" + ); + + // Verify the data can be retrieved with correct dtype + let retrieved_data = snapshot.to_data().expect("Should be able to retrieve data"); + assert_eq!( + retrieved_data.dtype, + DType::BF16, + "Retrieved data should have BF16 dtype" + ); + + // Verify the actual values are preserved + let retrieved_values: Vec = retrieved_data + .to_vec() + .expect("Should be able to convert to bf16 vec"); + assert_eq!( + retrieved_values, bf16_values, + "BF16 values should be preserved" + ); + } +} diff --git a/crates/burn-store/src/apply_result.rs b/crates/burn-store/src/apply_result.rs new file mode 100644 index 0000000..310a4e7 --- /dev/null +++ b/crates/burn-store/src/apply_result.rs @@ -0,0 +1,300 @@ +//! Result types and diagnostics for tensor application operations + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use burn_core::tensor::{DType, Shape}; + +/// Error types that can occur during tensor application +#[derive(Debug, Clone)] +pub enum ApplyError { + /// Shape mismatch between expected and actual tensor + ShapeMismatch { + /// Path of the tensor + path: String, + /// Expected shape + expected: Shape, + /// Found shape + found: Shape, + }, + /// Data type mismatch between expected and actual tensor + DTypeMismatch { + /// Path of the tensor + path: String, + /// Expected data type + expected: DType, + /// Found data type + found: DType, + }, + /// Error from adapter transformation + AdapterError { + /// Path of the tensor + path: String, + /// Error message + message: String, + }, + /// Error loading tensor data + LoadError { + /// Path of the tensor + path: String, + /// Error message + message: String, + }, +} + +impl core::fmt::Display for ApplyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::ShapeMismatch { + path, + expected, + found, + } => { + write!( + f, + "Shape mismatch for '{}': expected {:?}, found {:?}", + path, expected, found + ) + } + Self::DTypeMismatch { + path, + expected, + found, + } => { + write!( + f, + "DType mismatch for '{}': expected {:?}, found {:?}", + path, expected, found + ) + } + Self::AdapterError { path, message } => { + write!(f, "Adapter error for '{}': {}", path, message) + } + Self::LoadError { path, message } => { + write!(f, "Load error for '{}': {}", path, message) + } + } + } +} + +impl core::error::Error for ApplyError {} + +/// Result of applying tensor snapshots to a module +#[derive(Clone)] +pub struct ApplyResult { + /// Successfully applied tensor paths + pub applied: Vec, + /// Skipped tensor paths (due to filter) + pub skipped: Vec, + /// Missing tensor paths with their container stacks in dot notation (path, container_stack) + /// Container stack shows the hierarchy: "Struct:Model.Struct:Linear" or "Struct:Model.Enum:ConvType.Struct:Linear" + pub missing: Vec<(String, String)>, + /// Unused tensor paths (in snapshots but not in module) + pub unused: Vec, + /// Errors encountered during application + pub errors: Vec, +} + +impl ApplyResult { + /// Try to strip enum variant from a path + /// e.g., "field.BaseConv.weight" -> "field.weight" + fn strip_enum_variant(path: &str) -> Option { + let segments: Vec<&str> = path.split('.').collect(); + + // Find segments that look like enum variants (CamelCase in middle of path) + let variant_indices: Vec = segments + .iter() + .enumerate() + .filter(|(i, segment)| { + *i > 0 && *i < segments.len() - 1 // Not first or last + && !segment.is_empty() + && segment.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) + && segment.len() > 1 + && segment.chars().skip(1).any(|c| c.is_lowercase()) + }) + .map(|(i, _)| i) + .collect(); + + if variant_indices.is_empty() { + return None; + } + + // Remove the first found variant and return the modified path + let mut result_segments = segments.clone(); + result_segments.remove(variant_indices[0]); + Some(result_segments.join(".")) + } + + /// Find similar paths for a given missing path (for "Did you mean?" suggestions) + fn find_similar_paths(&self, missing_path: &str, max_suggestions: usize) -> Vec { + // First, try exact match with enum variant stripped + if let Some(stripped) = Self::strip_enum_variant(missing_path) + && self.unused.contains(&stripped) + { + return vec![stripped]; + } + + // Fall back to Jaro similarity (used by Elixir for "did you mean?" suggestions) + // Jaro gives higher weight to matching prefixes, ideal for hierarchical tensor paths + let mut similarities: Vec<(String, f64)> = self + .unused + .iter() + .map(|available| { + let similarity = textdistance::nstr::jaro(missing_path, available); + (available.clone(), similarity) + }) + .collect(); + + // Sort by similarity (higher = more similar) + similarities + .sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(core::cmp::Ordering::Equal)); + + // Only suggest paths with >= 70% similarity + const SIMILARITY_THRESHOLD: f64 = 0.7; + similarities + .into_iter() + .filter(|(_, sim)| *sim >= SIMILARITY_THRESHOLD) + .take(max_suggestions) + .map(|(path, _)| path) + .collect() + } +} + +impl ApplyResult { + /// Check if the apply operation was successful (no errors) + /// Note: Missing tensors are not considered errors when allow_partial is true + pub fn is_success(&self) -> bool { + self.errors.is_empty() + } +} + +impl core::fmt::Debug for ApplyResult { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // Delegate to Display for comprehensive output + core::fmt::Display::fmt(self, f) + } +} + +impl core::fmt::Display for ApplyResult { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + writeln!(f, "┌─ Tensor Loading Summary ─────────────────────────")?; + writeln!(f, "│")?; + writeln!( + f, + "│ ✓ Successfully applied: {} tensors", + self.applied.len() + )?; + writeln!(f, "│ ⊘ Skipped (filtered): {} tensors", self.skipped.len())?; + writeln!( + f, + "│ ✗ Missing in source: {} tensors", + self.missing.len() + )?; + writeln!(f, "│ ? Unused in target: {} tensors", self.unused.len())?; + writeln!(f, "│ ! Errors: {} errors", self.errors.len())?; + + if !self.missing.is_empty() { + writeln!(f, "│")?; + writeln!( + f, + "├─ Missing Tensors (requested by model but not found in source)" + )?; + writeln!(f, "│")?; + + // Use actual container stack data to detect enum variants + // Count how many missing paths have "Enum:" in their container stack + let enum_variant_missing: Vec<_> = self + .missing + .iter() + .filter(|(_, stack)| stack.contains("Enum:")) + .collect(); + + if !enum_variant_missing.is_empty() { + writeln!( + f, + "│ ⚠️ {} paths contain enum variants (detected from container stack)", + enum_variant_missing.len() + )?; + writeln!( + f, + "│ Burn includes enum variant names in paths, but PyTorch doesn't." + )?; + writeln!( + f, + "│ Example: Burn has 'field.BaseConv.weight', PyTorch has 'field.weight'" + )?; + writeln!(f, "│")?; + writeln!( + f, + "│ 💡 Solution 1: Enable skip_enum_variants flag (simplest):" + )?; + writeln!(f, "│")?; + writeln!( + f, + "│ let mut store = PytorchStore::from_file(\"model.pth\")" + )?; + writeln!(f, "│ .skip_enum_variants(true); // ← Add this")?; + writeln!(f, "│")?; + writeln!( + f, + "│ 💡 Solution 2: Remap enum keys in source (most precise):" + )?; + writeln!(f, "│")?; + writeln!( + f, + "│ let mut store = SafetensorsStore::from_file(\"model.safetensors\")" + )?; + writeln!( + f, + "│ .with_key_remapping(r\"field\\.(\\w+)\", \"field.BaseConv.$1\");" + )?; + writeln!(f, "│")?; + } + + writeln!(f, "│ First 10 missing tensors:")?; + for (path, _) in self.missing.iter().take(10) { + writeln!(f, "│ • {}", path)?; + + // Show "Did you mean?" suggestions for this path + let suggestions = self.find_similar_paths(path, 1); + if !suggestions.is_empty() { + writeln!(f, "│ Did you mean: '{}'?", suggestions[0])?; + } + } + if self.missing.len() > 10 { + writeln!(f, "│ ... and {} more", self.missing.len() - 10)?; + } + } + + if !self.unused.is_empty() { + writeln!(f, "│")?; + writeln!(f, "├─ Unused Tensors (in source but not used by model)")?; + writeln!(f, "│")?; + writeln!(f, "│ First 10 unused tensors:")?; + for path in self.unused.iter().take(10) { + writeln!(f, "│ • {}", path)?; + } + if self.unused.len() > 10 { + writeln!(f, "│ ... and {} more", self.unused.len() - 10)?; + } + } + + if !self.errors.is_empty() { + writeln!(f, "│")?; + writeln!(f, "├─ Errors")?; + writeln!(f, "│")?; + for error in self.errors.iter().take(10) { + writeln!(f, "│ ⚠️ {}", error)?; + } + if self.errors.len() > 10 { + writeln!(f, "│ ... and {} more", self.errors.len() - 10)?; + } + } + + writeln!(f, "│")?; + write!(f, "└───────────────────────────────────────────────────")?; + + Ok(()) + } +} diff --git a/crates/burn-store/src/bridge.rs b/crates/burn-store/src/bridge.rs new file mode 100644 index 0000000..2184c54 --- /dev/null +++ b/crates/burn-store/src/bridge.rs @@ -0,0 +1,51 @@ +//! Bridge between [`TensorSnapshot`] (burn-core) and [`burn_pack::Tensor`] +//! (the tensor-agnostic burnpack format entry), used by [`BurnpackStore`](crate::BurnpackStore). + +use alloc::format; +use alloc::rc::Rc; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use burn_pack::{Error as PackError, Tensor as PackTensor}; + +use super::TensorSnapshot; +use burn_core::module::ParamId; +use burn_core::tensor::TensorData; + +/// Convert a [`TensorSnapshot`] into a [`PackTensor`] entry, materializing its data. +pub fn snapshot_to_tensor(snapshot: &TensorSnapshot) -> Result { + let data = snapshot + .to_data() + .map_err(|e| PackError::IoError(format!("{e:?}")))?; + Ok(PackTensor::new( + snapshot.full_path(), + snapshot.dtype, + snapshot.shape.clone(), + snapshot.tensor_id.map(|id| id.val()), + data.bytes, + )) +} + +/// Convert a [`PackTensor`] entry into a lazy [`TensorSnapshot`]. +/// +/// The tensor's [`Bytes`](burn_pack::Bytes) may be file-backed (from [`Reader::from_file`](burn_pack::Reader::from_file)), +/// in which case the data is only read from disk when the snapshot is materialized. +pub fn tensor_to_snapshot(tensor: PackTensor) -> TensorSnapshot { + let dtype = tensor.dtype; + let shape = tensor.shape.clone(); + let path_stack: Vec = tensor.name.split('.').map(|s| s.to_string()).collect(); + let tensor_id = tensor.param_id.map(ParamId::from).unwrap_or_default(); + + let bytes = tensor.bytes; + let shape_for_closure = shape.clone(); + let data_fn = Rc::new(move || { + Ok(TensorData::from_bytes( + bytes.clone(), + shape_for_closure.clone(), + dtype, + )) + }); + + TensorSnapshot::from_closure(data_fn, dtype, shape, path_stack, vec![], tensor_id) +} diff --git a/crates/burn-store/src/burnpack.rs b/crates/burn-store/src/burnpack.rs new file mode 100644 index 0000000..d85f50f --- /dev/null +++ b/crates/burn-store/src/burnpack.rs @@ -0,0 +1,512 @@ +#[cfg(feature = "std")] +use std::path::PathBuf; + +#[cfg(feature = "std")] +use crate::KeyRemapper; +use crate::bridge; +use crate::{ + IdentityAdapter, ModuleAdapter, ModuleSnapshot, ModuleStore, PathFilter, TensorSnapshot, +}; +use alloc::boxed::Box; +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use burn_core::tensor::Bytes; +use burn_pack::{Error as PackError, Reader, Tensor as PackTensor, Writer}; + +/// Store mode for BurnpackStore +enum StoreMode { + #[cfg(feature = "std")] + File(PathBuf), + Bytes(Option), +} + +/// BurnpackStore - A Burn-specific file format store using CBOR for metadata +pub struct BurnpackStore { + /// Store mode - either file path or bytes + mode: StoreMode, + /// Optional filter for selective loading/saving + filter: Option, + /// Additional metadata + metadata: BTreeMap, + /// Allow partial loading (ignore missing tensors) + allow_partial: bool, + /// Validate tensors during loading (check shapes and dtypes) + validate: bool, + /// Allow overwriting existing files (default: false) + overwrite: bool, + /// Automatically append .bpk extension if not present (default: true) + #[cfg(feature = "std")] + auto_extension: bool, + /// Key remapper for tensor name transformations + #[cfg(feature = "std")] + remapper: KeyRemapper, + /// Adapter applied when loading (source -> Burn) + from_adapter: Box, + /// Adapter applied when saving (Burn -> target) + to_adapter: Box, + /// Reader for loading + reader: Option, + /// Cached tensor snapshots (parsed once, reused) + snapshots_cache: Option>, +} + +impl BurnpackStore { + /// Get the default metadata that includes Burn framework information. + /// + /// This includes: + /// - `format`: "burnpack" + /// - `producer`: "burn" + /// - `version`: The version of burn-store crate (from CARGO_PKG_VERSION) + /// + /// These metadata fields are automatically added to all saved models. + pub fn default_metadata() -> BTreeMap { + let mut metadata = BTreeMap::new(); + metadata.insert("format".into(), "burnpack".into()); + metadata.insert("producer".into(), "burn".into()); + metadata.insert("version".into(), env!("CARGO_PKG_VERSION").into()); + metadata + } + /// Create a new store from a file path + /// + /// By default, automatically appends `.bpk` extension if the path doesn't have one. + /// Use `.auto_extension(false)` to disable this behavior. + /// + /// # Examples + /// + /// ```no_run + /// # use burn_store::BurnpackStore; + /// // Automatically appends .bpk + /// let store = BurnpackStore::from_file("model"); // creates "model.bpk" + /// + /// // Already has extension, no append + /// let store = BurnpackStore::from_file("model.bpk"); // uses "model.bpk" + /// let store = BurnpackStore::from_file("model.myext"); // uses "model.myext" + /// + /// // Disable auto-extension + /// let store = BurnpackStore::from_file("model").auto_extension(false); // uses "model" + /// ``` + #[cfg(feature = "std")] + pub fn from_file>(path: P) -> Self { + Self { + mode: StoreMode::File(path.as_ref().to_path_buf()), + filter: None, + metadata: Self::default_metadata(), + allow_partial: false, + validate: true, + overwrite: false, + #[cfg(feature = "std")] + auto_extension: true, + #[cfg(feature = "std")] + remapper: KeyRemapper::new(), + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + reader: None, + snapshots_cache: None, + } + } + + /// Create a new store from bytes (for reading) or empty (for writing) + pub fn from_bytes(bytes: Option) -> Self { + Self { + mode: StoreMode::Bytes(bytes), + filter: None, + metadata: Self::default_metadata(), + allow_partial: false, + validate: true, + overwrite: false, + #[cfg(feature = "std")] + auto_extension: false, // Not used for bytes mode + #[cfg(feature = "std")] + remapper: KeyRemapper::new(), + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + reader: None, + snapshots_cache: None, + } + } + + /// Create a new store from static bytes with zero-copy loading enabled. + /// + /// This is optimized for embedded model weights where the data lives in the + /// binary's `.rodata` section. Tensor data is sliced without copying, keeping + /// the static reference alive. + /// + /// # Example + /// + /// ```ignore + /// static MODEL_DATA: &[u8] = include_bytes!("model.bpk"); + /// let store = BurnpackStore::from_static(MODEL_DATA); + /// ``` + pub fn from_static(data: &'static [u8]) -> Self { + use burn_core::tensor::AllocationProperty; + + // Create bytes::Bytes from static data (zero-copy, stays in .rodata) + let shared = bytes::Bytes::from_static(data); + + // Wrap in cubecl Bytes with shared-bytes allocation controller + let bytes = Bytes::from_shared(shared, AllocationProperty::Other); + + Self { + mode: StoreMode::Bytes(Some(bytes)), + filter: None, + metadata: Self::default_metadata(), + allow_partial: false, + validate: true, + overwrite: false, + #[cfg(feature = "std")] + auto_extension: false, + #[cfg(feature = "std")] + remapper: KeyRemapper::new(), + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + reader: None, + snapshots_cache: None, + } + } + + /// Add metadata key-value pair + pub fn metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } + + /// Clear all metadata (including defaults) + /// + /// This removes all metadata including the default format, producer, and version fields. + /// Use with caution as some tools may expect these fields to be present. + pub fn clear_metadata(mut self) -> Self { + self.metadata.clear(); + self + } + + /// Allow partial loading (ignore missing tensors) + /// + /// When set to `true`, the store will not fail if some tensors are missing + /// during loading. This is useful when loading a subset of a model's parameters. + /// + /// Default: `false` + pub fn allow_partial(mut self, allow: bool) -> Self { + self.allow_partial = allow; + self + } + + /// Enable or disable validation during loading + /// + /// When validation is enabled, the store will check that loaded tensors + /// match the expected shapes and data types. Disabling validation can + /// improve performance but may lead to runtime errors if data is corrupted. + /// + /// Default: `true` + pub fn validate(mut self, validate: bool) -> Self { + self.validate = validate; + self + } + + /// Allow overwriting existing files when saving + /// + /// When set to `false`, attempting to save to an existing file will result in an error. + /// When set to `true`, existing files will be overwritten without warning. + /// + /// Default: `false` + pub fn overwrite(mut self, overwrite: bool) -> Self { + self.overwrite = overwrite; + self + } + + /// Enable or disable automatic .bpk extension appending + /// + /// When enabled (default), automatically appends `.bpk` to the file path + /// if no extension is detected. If an extension is already present, it is preserved. + /// + /// When disabled, uses the exact path provided without modification. + /// + /// Default: `true` + /// + /// # Examples + /// + /// ```no_run + /// # use burn_store::BurnpackStore; + /// // With auto_extension enabled (default) + /// let store = BurnpackStore::from_file("model"); // -> "model.bpk" + /// + /// // With auto_extension disabled + /// let store = BurnpackStore::from_file("model") + /// .auto_extension(false); // -> "model" + /// ``` + #[cfg(feature = "std")] + pub fn auto_extension(mut self, enable: bool) -> Self { + self.auto_extension = enable; + self + } + + /// Set the adapter for loading tensors (converting from source format to Burn). + pub fn with_from_adapter(mut self, adapter: impl ModuleAdapter + 'static) -> Self { + self.from_adapter = Box::new(adapter); + self + } + + /// Set the adapter for saving tensors (converting from Burn to target format). + pub fn with_to_adapter(mut self, adapter: impl ModuleAdapter + 'static) -> Self { + self.to_adapter = Box::new(adapter); + self + } + + /// Set path filter for selective loading/saving + pub fn with_filter(mut self, filter: PathFilter) -> Self { + self.filter = Some(filter); + self + } + + /// Add regex pattern to filter + #[cfg(feature = "std")] + pub fn with_regex(mut self, pattern: &str) -> Self { + let filter = self.filter.unwrap_or_default(); + self.filter = Some(filter.with_regex(pattern)); + self + } + + /// Add exact path to filter + pub fn with_full_path(mut self, path: impl Into) -> Self { + let filter = self.filter.unwrap_or_default(); + self.filter = Some(filter.with_full_path(path)); + self + } + + /// Match all tensors (no filtering) + pub fn match_all(mut self) -> Self { + self.filter = Some(PathFilter::new().match_all()); + self + } + + /// Set key remapper for tensor name transformations during loading + #[cfg(feature = "std")] + pub fn remap(mut self, remapper: KeyRemapper) -> Self { + self.remapper = remapper; + self + } + + /// Add a single regex pattern for key remapping + #[cfg(feature = "std")] + pub fn with_remap_pattern(mut self, from: S1, to: S2) -> Self + where + S1: AsRef, + S2: Into, + { + self.remapper = self + .remapper + .add_pattern(from.as_ref(), to.into()) + .expect("Invalid regex pattern"); + self + } + + /// Set the path filter + pub fn filter(mut self, filter: PathFilter) -> Self { + self.filter = Some(filter); + self + } + + /// Get the bytes after writing (only valid for bytes mode after collecting) + pub fn get_bytes(&self) -> Result { + // `collect_from` caches the written bytes back into `self.mode` for bytes mode. + match &self.mode { + StoreMode::Bytes(Some(bytes)) => Ok(bytes.clone()), + _ => Err(PackError::IoError("No bytes available".into())), + } + } + + /// Process the file path with auto-extension logic + #[cfg(feature = "std")] + fn process_path(&self, path: &std::path::Path) -> PathBuf { + if !self.auto_extension { + return path.to_path_buf(); + } + + // Check if path already has an extension + if path.extension().is_some() { + // Has extension, use as-is + return path.to_path_buf(); + } + + // No extension, append .bpk + let mut new_path = path.to_path_buf(); + new_path.set_extension("bpk"); + new_path + } + + /// Ensure the reader is initialized, loading from storage if needed + fn ensure_reader(&mut self) -> Result<&Reader, PackError> { + if self.reader.is_none() { + let reader = match &self.mode { + #[cfg(feature = "std")] + StoreMode::File(path) => { + let final_path = self.process_path(path); + Reader::from_file(&final_path)? + } + StoreMode::Bytes(Some(bytes)) => Reader::from_bytes(bytes.clone())?, + StoreMode::Bytes(None) => { + return Err(PackError::IoError("No bytes to read from".into())); + } + }; + self.reader = Some(reader); + } + + self.reader + .as_ref() + .ok_or_else(|| PackError::IoError("Reader not initialized".into())) + } +} + +impl ModuleStore for BurnpackStore { + type Error = PackError; + + fn collect_from(&mut self, module: &M) -> Result<(), Self::Error> { + // Invalidate cache since we're writing new data + self.snapshots_cache = None; + self.reader = None; + + // Collect snapshots from module with adapter + let snapshots = module.collect(self.filter.clone(), Some(self.to_adapter.clone()), false); + + // Bridge snapshots to tensor-agnostic burnpack entries (materializing their data) + let tensors: Vec = snapshots + .iter() + .map(bridge::snapshot_to_tensor) + .collect::>()?; + + // Initialize writer with tensors + let mut writer = Writer::new(tensors); + + // Add metadata using builder pattern + for (key, value) in &self.metadata { + writer = writer.with_metadata(key.as_str(), value.as_str()); + } + + // Write to storage based on mode, consuming the writer. For bytes mode the generated + // bytes are cached back into `self.mode` so `get_bytes` can return them afterwards. + match &self.mode { + #[cfg(feature = "std")] + StoreMode::File(path) => { + // Process path with auto-extension logic + let final_path = self.process_path(path); + + // Check if file exists and overwrite is disabled + if final_path.exists() && !self.overwrite { + return Err(PackError::IoError(format!( + "File already exists: {}. Use .overwrite(true) to overwrite.", + final_path.display() + ))); + } + writer.write_to_file(&final_path)?; + } + StoreMode::Bytes(_) => { + // Generate and store the bytes + let bytes_data = writer.into_bytes()?; + // Update mode with bytes - this pattern is irrefutable in no-std mode + #[cfg_attr(not(feature = "std"), allow(irrefutable_let_patterns))] + let StoreMode::Bytes(bytes_ref) = &mut self.mode else { + unreachable!("We just matched Bytes variant"); + }; + *bytes_ref = Some(bytes_data); + } + } + + Ok(()) + } + + fn apply_to( + &mut self, + module: &mut M, + ) -> Result { + // Get all snapshots using the cached method + let snapshots: Vec = self.get_all_snapshots()?.values().cloned().collect(); + + // Apply all snapshots at once to the module + // Burnpack is Burn's native format, so no enum variant skipping needed + // Filter is applied here during apply, not during cache population + let result = module.apply( + snapshots, + self.filter.clone(), + Some(self.from_adapter.clone()), + false, + ); + + // Validate if needed + if self.validate && !result.errors.is_empty() { + return Err(PackError::ValidationError(format!( + "Import errors: {:?}", + result.errors + ))); + } + + // Check for missing tensors if partial loading is not allowed + if !self.allow_partial && !result.missing.is_empty() { + return Err(PackError::ValidationError(format!( + "Missing tensors: {:?}", + result.missing + ))); + } + + Ok(result) + } + + fn get_snapshot(&mut self, name: &str) -> Result, Self::Error> { + // Ensure cache is populated + self.ensure_snapshots_cache()?; + Ok(self.snapshots_cache.as_ref().unwrap().get(name)) + } + + fn get_all_snapshots(&mut self) -> Result<&BTreeMap, Self::Error> { + // Ensure cache is populated + self.ensure_snapshots_cache()?; + Ok(self.snapshots_cache.as_ref().unwrap()) + } + + fn keys(&mut self) -> Result, Self::Error> { + // Always use the cache to ensure remapping is applied consistently + Ok(self.get_all_snapshots()?.keys().cloned().collect()) + } +} + +impl BurnpackStore { + /// Ensure the snapshots cache is populated + fn ensure_snapshots_cache(&mut self) -> Result<(), PackError> { + if self.snapshots_cache.is_some() { + return Ok(()); + } + + // Ensure reader is loaded + self.ensure_reader()?; + + // Consume the reader, bridging its tensors to snapshots. File-backed readers keep the + // tensor data lazy (read on materialization); in-memory shared sources are zero-copy. + // Taking the reader hands the source's ownership to the tensor views, so nothing is read + // or copied eagerly. The snapshots cache below is what's reused on later calls. + let reader = self + .reader + .take() + .expect("reader initialized by ensure_reader"); + let snapshots: Vec = reader + .into_tensors()? + .into_iter() + .map(bridge::tensor_to_snapshot) + .collect(); + + // Apply remapping if configured (but NOT filtering - that's done at apply time) + #[cfg(feature = "std")] + let snapshots = if !self.remapper.patterns.is_empty() { + let (remapped, _remapped_names) = self.remapper.remap(snapshots); + remapped + } else { + snapshots + }; + + // Build the cache as BTreeMap + let cache: BTreeMap = + snapshots.into_iter().map(|s| (s.full_path(), s)).collect(); + + self.snapshots_cache = Some(cache); + Ok(()) + } +} diff --git a/crates/burn-store/src/collector.rs b/crates/burn-store/src/collector.rs new file mode 100644 index 0000000..db2c74b --- /dev/null +++ b/crates/burn-store/src/collector.rs @@ -0,0 +1,1137 @@ +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use burn_core::tensor::{Bool, Int, Tensor}; + +use crate::{ModuleAdapter, PathFilter, TensorSnapshot}; +use burn_core::module::{ModuleVisitor, Param, ParamId}; + +/// Collects tensor views from modules without copying data. +/// +/// This collector traverses a module hierarchy and creates lightweight views +/// of tensors that can be materialized to `TensorData` on demand. +/// +/// # Examples +/// +/// ## Collect all tensors +/// ```rust,no_run +/// # use burn_store::Collector; +/// let collector = Collector::new(None, None, false); +/// // Use with module.visit(&mut collector); +/// let all_tensors = collector.tensors; +/// ``` +/// +/// ## Filter with single pattern +/// ```rust,no_run +/// # use burn_store::{Collector, PathFilter}; +/// let filter = PathFilter::new().with_regex(r"^encoder\..*"); +/// let collector = Collector::new(Some(filter), None, false); +/// // Use with module.visit(&mut collector); +/// // Only collects tensors starting with "encoder." +/// ``` +/// +/// ## Filter with multiple patterns (OR union) +/// ```rust,no_run +/// # use burn_store::{Collector, PathFilter}; +/// let filter = PathFilter::new() +/// .with_regex(r"^encoder\..*") // Match all encoder tensors +/// .with_regex(r".*\.bias$"); // OR match any bias tensors +/// let collector = Collector::new(Some(filter), None, false); +/// // Use with module.visit(&mut collector); +/// // Collects tensors matching ANY of the patterns +/// ``` +pub struct Collector { + /// Collection of tensor views + pub tensors: Vec, + path_stack: Vec, + container_stack: Vec, + filter: Option, + adapter: Option>, + /// Skip enum variant names when building paths + /// When true, enum variant names are not included in tensor paths + skip_enum_variants: bool, +} + +impl Default for Collector { + fn default() -> Self { + Self::new(None, None, false) + } +} + +impl Collector { + /// Create a new tensor view collector with an optional filter and adapter. + /// + /// # Arguments + /// + /// * `filter` - An optional [`PathFilter`] to determine which tensors to collect. + /// When `None`, all tensors are collected. + /// * `adapter` - Optional adapter to transform tensors based on container types. + /// Applied to all collected tensors before returning. + /// * `skip_enum_variants` - Skip enum variant names when building paths. + /// When true, paths will not include enum variant names (e.g., "feature.weight" + /// instead of "feature.BaseConv.weight"). Useful when exporting to formats + /// like PyTorch that don't use enum variants. + /// + /// # Examples + /// + /// ```rust,no_run + /// # use burn_store::{Collector, PathFilter}; + /// // Collect all tensors without adapter + /// let collector = Collector::new(None, None, false); + /// + /// // Use PathFilter builder + /// let filter = PathFilter::new() + /// .with_regex(r"^encoder\..*") + /// .with_full_path("decoder.weight"); + /// let collector = Collector::new(Some(filter), None, false); + /// + /// // Skip enum variants for PyTorch export + /// let collector = Collector::new(None, None, true); + /// ``` + pub fn new( + filter: Option, + adapter: Option>, + skip_enum_variants: bool, + ) -> Self { + Self { + tensors: Vec::new(), + path_stack: Vec::new(), + container_stack: Vec::new(), + filter, + adapter, + skip_enum_variants, + } + } + + /// Apply the adapter to collected tensors and return the result. + pub fn into_tensors(self) -> Vec { + if let Some(adapter) = self.adapter { + self.tensors + .into_iter() + .map(|snapshot| adapter.adapt(&snapshot)) + .collect() + } else { + self.tensors + } + } + + fn should_collect(&self, path: &[String], container_stack: &[String]) -> bool { + // If filter is present, use it; otherwise collect all + match &self.filter { + None => true, + Some(f) => f.matches_with_container_path(path, container_stack), + } + } +} + +impl ModuleVisitor for Collector { + fn enter_module(&mut self, name: &str, container_type: &str) { + // Always track the container type for proper filtering and module type detection + self.container_stack.push(container_type.to_string()); + + // Only add to path if it's not an enum variant (when skip_enum_variants is enabled) + // This ensures paths are built without enum variant names from the start + if !self.skip_enum_variants || !container_type.starts_with("Enum:") { + self.path_stack.push(name.to_string()); + } + } + + fn exit_module(&mut self, _name: &str, container_type: &str) { + self.container_stack.pop(); + + // Only pop from path if we added it (not an enum variant when skip_enum_variants is enabled) + if !self.skip_enum_variants || !container_type.starts_with("Enum:") { + self.path_stack.pop(); + } + } + + fn visit_float(&mut self, param: &Param>) { + if self.should_collect(&self.path_stack, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_float( + ¶m.transform_for_save().val(), + self.path_stack.clone(), + self.container_stack.clone(), + param.id, + )); + } + } + + fn visit_int(&mut self, param: &Param>) { + if self.should_collect(&self.path_stack, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_int( + ¶m.transform_for_save().val(), + self.path_stack.clone(), + self.container_stack.clone(), + param.id, + )); + } + } + + fn visit_bool(&mut self, param: &Param>) { + if self.should_collect(&self.path_stack, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_bool( + ¶m.transform_for_save().val(), + self.path_stack.clone(), + self.container_stack.clone(), + param.id, + )); + } + } + + fn visit_float_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + // For path-based visits, we use the current container stack for filtering + if self.should_collect(path, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_float( + tensor, + path.to_vec(), + self.container_stack.clone(), + id, + )); + } + } + + fn visit_int_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + if self.should_collect(path, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_int( + tensor, + path.to_vec(), + self.container_stack.clone(), + id, + )); + } + } + + fn visit_bool_with_path( + &mut self, + path: &[String], + id: ParamId, + tensor: &Tensor, + ) { + if self.should_collect(path, &self.container_stack) { + self.tensors.push(TensorSnapshot::from_bool( + tensor, + path.to_vec(), + self.container_stack.clone(), + id, + )); + } + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + use burn_core as burn; + + use alloc::collections::BTreeMap; + use alloc::string::String; + use burn_core::module::{Module, Param}; + use burn_core::tensor::{Device, shape}; + use burn_nn::LinearConfig; + + #[test] + fn tensor_snapshot_collector() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + let mut collector = Collector::new(None, None, false); + let id = ParamId::new(); + + // Collect a tensor + collector.visit_float_with_path(&["model".to_string(), "weight".to_string()], id, &tensor); + + assert_eq!(collector.tensors.len(), 1); + assert_eq!(collector.tensors[0].full_path(), "model.weight"); + + // Verify the tensor can be converted to data + let view = &collector.tensors[0]; + let data = view.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 2]); + } + + #[test] + fn root_level_parameters() { + use burn_core::module::ModuleVisitor; + + let device = Default::default(); + + // Create root-level parameters (single-element path, not nested in modules) + let weight = Param::>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let bias = Param::>::from_data([5.0, 6.0], &device); + + let mut collector = Collector::new(None, None, false); + + // Simulate module traversal for root-level parameters + // Enter "weight" path (as if we're visiting a field named "weight") + ModuleVisitor::enter_module(&mut collector, "weight", ""); + ModuleVisitor::visit_float(&mut collector, &weight); + ModuleVisitor::exit_module(&mut collector, "weight", ""); + + // Enter "bias" path (as if we're visiting a field named "bias") + ModuleVisitor::enter_module(&mut collector, "bias", ""); + ModuleVisitor::visit_float(&mut collector, &bias); + ModuleVisitor::exit_module(&mut collector, "bias", ""); + + // Verify both parameters were collected + assert_eq!(collector.tensors.len(), 2); + + // Verify paths are correct (single-element paths) + assert_eq!(collector.tensors[0].full_path(), "weight"); + assert_eq!(collector.tensors[1].full_path(), "bias"); + + // Verify data is correct + let weight_data = collector.tensors[0] + .to_data() + .unwrap() + .to_vec::() + .unwrap(); + let bias_data = collector.tensors[1] + .to_data() + .unwrap() + .to_vec::() + .unwrap(); + + assert_eq!(weight_data, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(bias_data, vec![5.0, 6.0]); + } + + #[test] + #[cfg(target_has_atomic = "ptr")] + fn tensor_snapshot_collector_with_filter() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + let filter = PathFilter::new().with_regex(r"^encoder\..*"); + let mut collector = Collector::new(Some(filter), None, false); + let id = ParamId::new(); + + // This should be collected + collector.visit_float_with_path( + &["encoder".to_string(), "weight".to_string()], + id, + &tensor, + ); + // This should NOT be collected + collector.visit_float_with_path( + &["decoder".to_string(), "weight".to_string()], + id, + &tensor, + ); + + assert_eq!(collector.tensors.len(), 1); + assert_eq!(collector.tensors[0].full_path(), "encoder.weight"); + } + + #[test] + #[cfg(target_has_atomic = "ptr")] + fn tensor_snapshot_collector_with_multiple_filters() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + // Multiple patterns - collect if matches ANY (OR union) + let filter = PathFilter::new() + .with_regex(r"^encoder\..*") // Match encoder.* + .with_regex(r".*\.bias$"); // Match *.bias + let mut collector = Collector::new(Some(filter), None, false); + let id = ParamId::new(); + + // These should be collected + collector.visit_float_with_path( + &["encoder".to_string(), "weight".to_string()], + id, + &tensor, + ); // matches first pattern + collector.visit_float_with_path(&["decoder".to_string(), "bias".to_string()], id, &tensor); // matches second pattern + collector.visit_float_with_path(&["encoder".to_string(), "bias".to_string()], id, &tensor); // matches both patterns + + // This should NOT be collected + collector.visit_float_with_path( + &["decoder".to_string(), "weight".to_string()], + id, + &tensor, + ); // matches neither + + assert_eq!(collector.tensors.len(), 3); + let paths: Vec = collector.tensors.iter().map(|v| v.full_path()).collect(); + assert!(paths.contains(&"encoder.weight".to_string())); + assert!(paths.contains(&"decoder.bias".to_string())); + assert!(paths.contains(&"encoder.bias".to_string())); + assert!(!paths.contains(&"decoder.weight".to_string())); + } + + #[test] + fn tensor_snapshot_collector_with_predicate() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + // Use predicate function for filtering + fn filter_fn(path: &str, _container_path: &str) -> bool { + path.starts_with("encoder.") || path == "decoder.bias" + } + let filter = PathFilter::new().with_predicate(filter_fn); + let mut collector = Collector::new(Some(filter), None, false); + let id = ParamId::new(); + + // These should be collected + collector.visit_float_with_path( + &["encoder".to_string(), "weight".to_string()], + id, + &tensor, + ); + collector.visit_float_with_path(&["encoder".to_string(), "bias".to_string()], id, &tensor); + collector.visit_float_with_path(&["decoder".to_string(), "bias".to_string()], id, &tensor); + + // This should NOT be collected + collector.visit_float_with_path( + &["decoder".to_string(), "weight".to_string()], + id, + &tensor, + ); + collector.visit_float_with_path(&["other".to_string(), "tensor".to_string()], id, &tensor); + + assert_eq!(collector.tensors.len(), 3); + let paths: Vec = collector.tensors.iter().map(|v| v.full_path()).collect(); + assert!(paths.contains(&"encoder.weight".to_string())); + assert!(paths.contains(&"encoder.bias".to_string())); + assert!(paths.contains(&"decoder.bias".to_string())); + assert!(!paths.contains(&"decoder.weight".to_string())); + assert!(!paths.contains(&"other.tensor".to_string())); + } + + #[test] + fn tensor_snapshot_collector_predicate_with_complex_logic() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + // Complex predicate with multiple conditions + fn complex_filter(path: &str, _container_path: &str) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + if parts.len() != 3 { + return false; + } + // Only collect if it's layer1 or layer2, and it's a weight tensor + (parts[1] == "layer1" || parts[1] == "layer2") && parts[2] == "weight" + } + let filter = PathFilter::new().with_predicate(complex_filter); + let mut collector = Collector::new(Some(filter), None, false); + let id = ParamId::new(); + + // These should be collected + collector.visit_float_with_path( + &[ + "model".to_string(), + "layer1".to_string(), + "weight".to_string(), + ], + id, + &tensor, + ); + collector.visit_float_with_path( + &[ + "model".to_string(), + "layer2".to_string(), + "weight".to_string(), + ], + id, + &tensor, + ); + + // These should NOT be collected + collector.visit_float_with_path( + &[ + "model".to_string(), + "layer1".to_string(), + "bias".to_string(), + ], + id, + &tensor, + ); + collector.visit_float_with_path( + &[ + "model".to_string(), + "layer3".to_string(), + "weight".to_string(), + ], + id, + &tensor, + ); + collector.visit_float_with_path( + &["encoder".to_string(), "weight".to_string()], + id, + &tensor, + ); // wrong structure + + assert_eq!(collector.tensors.len(), 2); + let paths: Vec = collector.tensors.iter().map(|v| v.full_path()).collect(); + assert!(paths.contains(&"model.layer1.weight".to_string())); + assert!(paths.contains(&"model.layer2.weight".to_string())); + assert!(!paths.contains(&"model.layer1.bias".to_string())); + assert!(!paths.contains(&"model.layer3.weight".to_string())); + assert!(!paths.contains(&"encoder.weight".to_string())); + } + + // Test visitor that collects tensor paths + struct TensorPathCollector { + pub paths: BTreeMap)>, + path_stack: Vec, + } + + impl TensorPathCollector { + fn new() -> Self { + Self { + paths: BTreeMap::new(), + path_stack: Vec::new(), + } + } + + fn current_path(&self) -> String { + self.path_stack.join(".") + } + } + + impl ModuleVisitor for TensorPathCollector { + fn enter_module(&mut self, name: &str, _container_type: &str) { + self.path_stack.push(name.to_string()); + } + + fn exit_module(&mut self, _name: &str, _container_type: &str) { + self.path_stack.pop(); + } + + fn visit_float(&mut self, param: &Param>) { + let path = self.current_path(); + if !path.is_empty() { + self.paths.insert( + path, + (param.id, param.transform_for_save().val().shape().to_vec()), + ); + } + } + + fn visit_int(&mut self, param: &Param>) { + let path = self.current_path(); + if !path.is_empty() { + self.paths.insert( + path, + (param.id, param.transform_for_save().val().shape().to_vec()), + ); + } + } + + fn visit_bool(&mut self, param: &Param>) { + let path = self.current_path(); + if !path.is_empty() { + self.paths.insert( + path, + (param.id, param.transform_for_save().val().shape().to_vec()), + ); + } + } + } + + // Simple nested module for testing + #[derive(Module, Debug)] + struct InnerModule { + weight: Param>, + bias: Param>, + } + + #[derive(Module, Debug)] + struct OuterModule { + layer1: InnerModule, + layer2: InnerModule, + } + + impl InnerModule { + fn new(device: &Device) -> Self { + Self { + weight: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device), + bias: Param::from_data([5.0, 6.0], device), + } + } + } + + impl OuterModule { + fn new(device: &Device) -> Self { + Self { + layer1: InnerModule::new(device), + layer2: InnerModule::new(device), + } + } + } + + #[test] + fn nested_module_path_tracking() { + let device = Default::default(); + let module = OuterModule::new(&device); + + let mut collector = TensorPathCollector::new(); + module.visit(&mut collector); + + let paths = collector.paths; + + // Verify we have the expected paths + // Note: Param fields are themselves modules, so we get an extra level + assert!(paths.contains_key("layer1.weight"), "Missing layer1.weight"); + assert!(paths.contains_key("layer1.bias"), "Missing layer1.bias"); + assert!(paths.contains_key("layer2.weight"), "Missing layer2.weight"); + assert!(paths.contains_key("layer2.bias"), "Missing layer2.bias"); + + // Verify the shapes are correct + assert_eq!(paths.get("layer1.weight").unwrap().1, vec![2, 2]); + assert_eq!(paths.get("layer1.bias").unwrap().1, vec![2]); + assert_eq!(paths.get("layer2.weight").unwrap().1, vec![2, 2]); + assert_eq!(paths.get("layer2.bias").unwrap().1, vec![2]); + } + + #[test] + fn linear_module_paths() { + let device = Default::default(); + let config = LinearConfig::new(10, 20).with_bias(true); + let linear = config.init(&device); + + let mut collector = TensorPathCollector::new(); + linear.visit(&mut collector); + + let paths = collector.paths; + + // Linear module has weight and optional bias + assert!(paths.contains_key("weight")); + assert!(paths.contains_key("bias")); + + // Check dimensions + assert_eq!(paths.get("weight").unwrap().1, vec![10, 20]); + assert_eq!(paths.get("bias").unwrap().1, vec![20]); + } + + // Deep nesting test structures (4+ levels) + #[derive(Module, Debug)] + struct Level4Module { + weight: Param>, + bias: Param>, + } + + #[derive(Module, Debug)] + struct Level3Module { + layer: Level4Module, + extra: Level4Module, + } + + #[derive(Module, Debug)] + struct Level2Module { + block1: Level3Module, + block2: Level3Module, + } + + #[derive(Module, Debug)] + struct Level1Module { + encoder: Level2Module, + decoder: Level2Module, + } + + #[derive(Module, Debug)] + struct DeepModel { + backbone: Level1Module, + head: Level4Module, + } + + impl Level4Module { + fn new(device: &Device) -> Self { + Self { + weight: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device), + bias: Param::from_data([5.0, 6.0], device), + } + } + } + + impl Level3Module { + fn new(device: &Device) -> Self { + Self { + layer: Level4Module::new(device), + extra: Level4Module::new(device), + } + } + } + + impl Level2Module { + fn new(device: &Device) -> Self { + Self { + block1: Level3Module::new(device), + block2: Level3Module::new(device), + } + } + } + + impl Level1Module { + fn new(device: &Device) -> Self { + Self { + encoder: Level2Module::new(device), + decoder: Level2Module::new(device), + } + } + } + + impl DeepModel { + fn new(device: &Device) -> Self { + Self { + backbone: Level1Module::new(device), + head: Level4Module::new(device), + } + } + } + + #[test] + fn deep_module_path_tracking() { + let device = Default::default(); + let model = DeepModel::new(&device); + + let mut collector = Collector::new(None, None, false); + model.visit(&mut collector); + + let views = collector.tensors; + let paths: Vec = views.iter().map(|v| v.full_path()).collect(); + + // Test 5-level deep paths + assert!(paths.contains(&"backbone.encoder.block1.layer.weight".to_string())); + assert!(paths.contains(&"backbone.encoder.block1.layer.bias".to_string())); + assert!(paths.contains(&"backbone.encoder.block1.extra.weight".to_string())); + assert!(paths.contains(&"backbone.encoder.block1.extra.bias".to_string())); + + assert!(paths.contains(&"backbone.encoder.block2.layer.weight".to_string())); + assert!(paths.contains(&"backbone.encoder.block2.layer.bias".to_string())); + assert!(paths.contains(&"backbone.encoder.block2.extra.weight".to_string())); + assert!(paths.contains(&"backbone.encoder.block2.extra.bias".to_string())); + + assert!(paths.contains(&"backbone.decoder.block1.layer.weight".to_string())); + assert!(paths.contains(&"backbone.decoder.block1.layer.bias".to_string())); + assert!(paths.contains(&"backbone.decoder.block1.extra.weight".to_string())); + assert!(paths.contains(&"backbone.decoder.block1.extra.bias".to_string())); + + assert!(paths.contains(&"backbone.decoder.block2.layer.weight".to_string())); + assert!(paths.contains(&"backbone.decoder.block2.layer.bias".to_string())); + assert!(paths.contains(&"backbone.decoder.block2.extra.weight".to_string())); + assert!(paths.contains(&"backbone.decoder.block2.extra.bias".to_string())); + + // Test 2-level paths + assert!(paths.contains(&"head.weight".to_string())); + assert!(paths.contains(&"head.bias".to_string())); + + // Total should be 18 tensors (16 from backbone + 2 from head) + assert_eq!(views.len(), 18); + + // Verify data can be materialized + let view = views + .iter() + .find(|v| v.full_path() == "backbone.encoder.block1.layer.weight") + .unwrap(); + let data = view.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 2]); + } + + #[test] + fn deep_module_filtered_export() { + let device = Default::default(); + let model = DeepModel::new(&device); + + // Test filtering at different depths + #[cfg(target_has_atomic = "ptr")] + { + let filter = PathFilter::new().with_regex(r"^backbone\.encoder\..*"); + let mut collector = Collector::new(Some(filter), None, false); + model.visit(&mut collector); + assert_eq!(collector.tensors.len(), 8); // Only encoder tensors + } + + // Test filtering specific blocks + #[cfg(target_has_atomic = "ptr")] + { + let filter = PathFilter::new().with_regex(r".*\.block1\..*"); + let mut collector = Collector::new(Some(filter), None, false); + model.visit(&mut collector); + assert_eq!(collector.tensors.len(), 8); // block1 in both encoder and decoder + } + + // Test filtering by tensor type at any depth + #[cfg(target_has_atomic = "ptr")] + { + let filter = PathFilter::new().with_regex(r".*\.weight$"); + let mut collector = Collector::new(Some(filter), None, false); + model.visit(&mut collector); + assert_eq!(collector.tensors.len(), 9); // All weight tensors + } + + // Test complex multi-pattern filtering + #[cfg(target_has_atomic = "ptr")] + { + let filter = PathFilter::new() + .with_regex(r"^backbone\.encoder\.block1\..*") // All encoder.block1 tensors + .with_regex(r"^backbone\.decoder\..*\.bias$") // All decoder biases + .with_regex(r"^head\.weight$"); // Head weight only + let mut collector = Collector::new(Some(filter), None, false); + model.visit(&mut collector); + + // Should have: + // - 4 from encoder.block1 (2 weights + 2 biases) + // - 4 decoder biases + // - 1 head weight + assert_eq!(collector.tensors.len(), 9); + + let paths: Vec = collector.tensors.iter().map(|v| v.full_path()).collect(); + assert!(paths.contains(&"backbone.encoder.block1.layer.weight".to_string())); + assert!(paths.contains(&"backbone.decoder.block1.layer.bias".to_string())); + assert!(paths.contains(&"head.weight".to_string())); + assert!(!paths.contains(&"head.bias".to_string())); // Not included + } + } + + use crate::traits::ModuleSnapshot; + use burn_nn::Linear; + use hashbrown::HashMap; + + // Test module with Option fields + #[derive(Module, Debug)] + struct OptionalFieldModule { + required: Param>, + optional: Option>>, + } + + impl OptionalFieldModule { + fn new_with_optional(device: &Device) -> Self { + Self { + required: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device), + optional: Some(Param::from_data([5.0, 6.0], device)), + } + } + + fn new_without_optional(device: &Device) -> Self { + Self { + required: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device), + optional: None, + } + } + } + + #[test] + fn optional_field_module_with_value() { + let device = Default::default(); + let module = OptionalFieldModule::new_with_optional(&device); + + let views: HashMap = module + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + assert_eq!(views.len(), 2); + assert!(views.contains_key("required")); + assert!(views.contains_key("optional")); + } + + #[test] + fn optional_field_module_without_value() { + let device = Default::default(); + let module = OptionalFieldModule::new_without_optional(&device); + + let views: HashMap = module + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + assert_eq!(views.len(), 1); + assert!(views.contains_key("required")); + assert!(!views.contains_key("optional")); + } + + // Test Vec of modules + #[derive(Module, Debug)] + struct VecModule { + layers: Vec, + } + + impl VecModule { + fn new(device: &Device, num_layers: usize) -> Self { + Self { + layers: (0..num_layers) + .map(|_| LinearConfig::new(10, 10).init(device)) + .collect(), + } + } + } + + // Test tuple of modules + #[derive(Module, Debug)] + struct TupleModule { + layers: (Linear, Linear, Linear), + } + + impl TupleModule { + fn new(device: &Device) -> Self { + Self { + layers: ( + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + ), + } + } + } + + #[test] + fn vec_module_collect() { + let device = Default::default(); + let module = VecModule::new(&device, 3); + + let views: HashMap = module + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + // With the fix, all Vec items should now be properly indexed and visited + assert_eq!(views.len(), 6); // 3 layers × 2 tensors each = 6 tensors + + // Check that all indexed paths exist + assert!(views.contains_key("layers.0.weight")); + assert!(views.contains_key("layers.0.bias")); + assert!(views.contains_key("layers.1.weight")); + assert!(views.contains_key("layers.1.bias")); + assert!(views.contains_key("layers.2.weight")); + assert!(views.contains_key("layers.2.bias")); + } + + #[test] + fn tuple_module_collect() { + let device = Default::default(); + let module = TupleModule::new(&device); + + let snapshots = module.collect(None, None, false); + assert_eq!(snapshots.len(), 6); + + let views: HashMap = + snapshots.into_iter().map(|v| (v.full_path(), v)).collect(); + + assert_eq!(views.len(), 6); + + assert!(views.contains_key("layers.0.weight")); + assert!(views.contains_key("layers.0.bias")); + assert!(views.contains_key("layers.1.weight")); + assert!(views.contains_key("layers.1.bias")); + assert!(views.contains_key("layers.2.weight")); + assert!(views.contains_key("layers.2.bias")); + } + + // Test array of modules + #[derive(Module, Debug)] + struct ArrayModule { + layers: [Linear; 3], + } + + impl ArrayModule { + fn new(device: &Device) -> Self { + Self { + layers: [ + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + ], + } + } + } + + #[test] + fn array_module_collect() { + let device = Default::default(); + let module = ArrayModule::new(&device); + + let views: HashMap = module + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + // All array items should be properly indexed + assert_eq!(views.len(), 6); // 3 layers × 2 tensors each = 6 tensors + + // Check indexed paths + for i in 0..3 { + assert!(views.contains_key(&format!("layers.{}.weight", i))); + assert!(views.contains_key(&format!("layers.{}.bias", i))); + } + } + + // Test enum modules + #[derive(Module, Debug)] + enum EnumModule { + LayerA(Linear), + LayerB(Linear), + LayerC(Linear), + } + + #[test] + fn enum_module_collect() { + let device = Default::default(); + + // Test variant A + let module_a = EnumModule::LayerA(LinearConfig::new(10, 20).init(&device)); + let views_a: HashMap = module_a + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + // Should have the variant name in the path + assert_eq!(views_a.len(), 2); + assert!(views_a.contains_key("LayerA.weight")); + assert!(views_a.contains_key("LayerA.bias")); + + // Test variant B + let module_b = EnumModule::LayerB(LinearConfig::new(10, 20).init(&device)); + let views_b: HashMap = module_b + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + assert_eq!(views_b.len(), 2); + assert!(views_b.contains_key("LayerB.weight")); + assert!(views_b.contains_key("LayerB.bias")); + } + + // Container type tracking tests + #[test] + fn linear_container_type() { + let device = Default::default(); + + #[derive(Module, Debug)] + struct ModelWithLinear { + linear: Linear, + } + + impl ModelWithLinear { + fn new(device: &Device) -> Self { + Self { + linear: LinearConfig::new(10, 20).init(device), + } + } + } + + let model = ModelWithLinear::new(&device); + + let views: HashMap = model + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + // Check that tensors inside Linear layers have "Struct:Linear" as their module type + for (path, view) in views.iter() { + if path == "linear.weight" || path == "linear.bias" { + assert_eq!( + view.module_type(), + Some("Struct:Linear".to_string()), + "Tensor '{}' should have module type 'Struct:Linear'", + path + ); + } + } + } + + #[test] + fn complex_model_container_types() { + let device = Default::default(); + + #[derive(Module, Debug)] + struct ComplexModel { + linear_layers: [Linear; 2], + vec_layers: Vec, + single_linear: Linear, + } + + impl ComplexModel { + fn new(device: &Device) -> Self { + Self { + linear_layers: [ + LinearConfig::new(100, 50).init(device), + LinearConfig::new(50, 10).init(device), + ], + vec_layers: vec![ + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + ], + single_linear: LinearConfig::new(10, 1).init(device), + } + } + } + + let model = ComplexModel::new(&device); + + let views: HashMap = model + .collect(None, None, false) + .into_iter() + .map(|v| (v.full_path(), v)) + .collect(); + + // Should have 10 tensors total + assert_eq!(views.len(), 10); + + // Verify different module types + for (_path, view) in views.iter() { + assert_eq!(view.module_type(), Some("Struct:Linear".to_string())); + } + } + + #[test] + fn collect_with_container_filter() { + let device = Default::default(); + + #[derive(Module, Debug)] + struct FilterTestModel { + layers: Vec, + } + + impl FilterTestModel { + fn new(device: &Device) -> Self { + Self { + layers: vec![ + LinearConfig::new(10, 10).init(device), + LinearConfig::new(10, 10).init(device), + ], + } + } + } + + let model = FilterTestModel::new(&device); + + // Filter to only collect tensors from Linear modules + let filter = PathFilter::new().with_predicate(|_path, container_path| { + container_path.split('.').next_back() == Some("Struct:Linear") + }); + + let linear_views: Vec = model.collect(Some(filter), None, false); + + // All collected tensors should be from Linear modules + for view in linear_views.iter() { + assert_eq!( + view.module_type(), + Some("Struct:Linear".to_string()), + "All tensors should be from Linear modules" + ); + } + + // Should have collected all Linear tensors + assert_eq!(linear_views.len(), 4); + } +} diff --git a/crates/burn-store/src/filter.rs b/crates/burn-store/src/filter.rs new file mode 100644 index 0000000..1d995b6 --- /dev/null +++ b/crates/burn-store/src/filter.rs @@ -0,0 +1,625 @@ +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use core::fmt; + +#[cfg(feature = "std")] +use regex::Regex; + +/// A sophisticated path filter that supports multiple matching strategies. +/// +/// The filter uses an OR logic - a path is included if it matches ANY of the configured criteria. +/// This allows for flexible and powerful filtering configurations. +/// +/// # Examples +/// +/// ```rust,no_run +/// # use burn_store::PathFilter; +/// // Create a filter that matches encoder paths or any weight path +/// let filter = PathFilter::new() +/// .with_regex(r"^encoder\..*") +/// .with_regex(r".*\.weight$") +/// .with_full_path("special_tensor"); +/// +/// // Check if a path should be included +/// if filter.matches("encoder.layer1.weight") { +/// // This will match due to both regex patterns +/// } +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PathFilter { + /// Compiled regex patterns for matching paths + #[cfg(feature = "std")] + regex_patterns: Vec, + + /// Exact full paths to match + exact_paths: Vec, + + /// Predicate functions for custom matching logic based on path and container path + /// Note: These cannot be cloned, so we store them separately + predicates: Vec bool>, + + /// If true, matches all paths (overrides other filters) + match_all: bool, +} + +impl PathFilter { + /// Create a new empty filter (matches nothing by default) + pub fn new() -> Self { + Self::default() + } + + /// Create a filter that matches all paths + pub fn all() -> Self { + Self { + match_all: true, + ..Default::default() + } + } + + /// Create a filter that matches nothing + pub fn none() -> Self { + Self::default() + } + + /// Add a regex pattern for matching paths + #[cfg(feature = "std")] + pub fn with_regex>(mut self, pattern: S) -> Self { + if let Ok(regex) = Regex::new(pattern.as_ref()) { + self.regex_patterns.push(regex); + } + // TODO: Consider returning Result to handle regex compilation errors + self + } + + /// Add multiple regex patterns + #[cfg(feature = "std")] + pub fn with_regexes(mut self, patterns: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + for pattern in patterns { + if let Ok(regex) = Regex::new(pattern.as_ref()) { + self.regex_patterns.push(regex); + } + } + self + } + + /// Add an exact full path to match + pub fn with_full_path>(mut self, path: S) -> Self { + self.exact_paths.push(path.into()); + self + } + + /// Add multiple exact full paths + pub fn with_full_paths(mut self, paths: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.exact_paths.extend(paths.into_iter().map(|p| p.into())); + self + } + + /// Add a predicate function for custom matching based on path and container path + pub fn with_predicate(mut self, predicate: fn(&str, &str) -> bool) -> Self { + self.predicates.push(predicate); + self + } + + /// Add multiple predicates + pub fn with_predicates(mut self, predicates: I) -> Self + where + I: IntoIterator bool>, + { + self.predicates.extend(predicates); + self + } + + /// Set to match all paths + pub fn match_all(mut self) -> Self { + self.match_all = true; + self + } + + /// Check if a path matches this filter (assumes empty container path for backward compatibility) + pub fn matches(&self, path: &str) -> bool { + self.matches_with_container_path_str(path, "") + } + + /// Check if a path and container type match this filter (for backward compatibility) + pub fn matches_with_container(&self, path: &str, container_type: &str) -> bool { + // For backward compatibility, treat single container type as the full path + self.matches_with_container_path_str(path, container_type) + } + + /// Check if a path and container path match this filter + pub fn matches_with_container_path(&self, path: &[String], container_stack: &[String]) -> bool { + let path_str = path.join("."); + let container_path = container_stack.join("."); + self.matches_with_container_path_str(&path_str, &container_path) + } + + /// Check if a path and container path (dot-notated strings) match this filter + pub fn matches_with_container_path_str(&self, path: &str, container_path: &str) -> bool { + // If match_all is set, always return true + if self.match_all { + return true; + } + + // If no filters are configured, match nothing + if self.is_empty() { + return false; + } + + // Check exact path matches + if self.exact_paths.iter().any(|p| p == path) { + return true; + } + + // Check regex patterns (on the path) + #[cfg(feature = "std")] + { + for regex in &self.regex_patterns { + if regex.is_match(path) { + return true; + } + } + } + + // Check predicates with container path + if self + .predicates + .iter() + .any(|pred| pred(path, container_path)) + { + return true; + } + + false + } + + /// Check if the filter is empty (matches nothing) + pub fn is_empty(&self) -> bool { + if self.match_all { + return false; + } + + #[cfg(feature = "std")] + let regex_empty = self.regex_patterns.is_empty(); + #[cfg(not(feature = "std"))] + let regex_empty = true; + + self.exact_paths.is_empty() && self.predicates.is_empty() && regex_empty + } + + /// Get the number of filter criteria configured + pub fn criteria_count(&self) -> usize { + if self.match_all { + return 1; + } + + #[allow(unused_mut)] + let mut count = self.exact_paths.len() + self.predicates.len(); + + #[cfg(feature = "std")] + { + count += self.regex_patterns.len(); + } + + count + } + + /// Clear all regex patterns + #[cfg(feature = "std")] + pub fn clear_regex(&mut self) -> &mut Self { + self.regex_patterns.clear(); + self + } + + /// Clear all exact paths + pub fn clear_paths(&mut self) -> &mut Self { + self.exact_paths.clear(); + self + } + + /// Clear all predicates + pub fn clear_predicates(&mut self) -> &mut Self { + self.predicates.clear(); + self + } + + /// Clear all filters + pub fn clear(&mut self) -> &mut Self { + #[cfg(feature = "std")] + self.clear_regex(); + + self.clear_paths().clear_predicates(); + self.match_all = false; + self + } + + /// Create a filter from regex patterns only + #[cfg(feature = "std")] + pub fn from_regex_patterns(patterns: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::new().with_regexes(patterns) + } + + /// Create a filter from exact paths only + pub fn from_paths(paths: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::new().with_full_paths(paths) + } + + /// Create a filter from a single predicate + pub fn from_predicate(predicate: fn(&str, &str) -> bool) -> Self { + Self::new().with_predicate(predicate) + } + + /// Combine with another filter using OR logic + pub fn or(mut self, other: Self) -> Self { + if self.match_all || other.match_all { + return Self::all(); + } + + #[cfg(feature = "std")] + { + self.regex_patterns.extend(other.regex_patterns); + } + + self.exact_paths.extend(other.exact_paths); + self.predicates.extend(other.predicates); + + self + } +} + +impl fmt::Display for PathFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.match_all { + return write!(f, "PathFilter::all()"); + } + + if self.is_empty() { + return write!(f, "PathFilter::none()"); + } + + write!(f, "PathFilter[")?; + + let mut parts = Vec::new(); + + #[cfg(feature = "std")] + if !self.regex_patterns.is_empty() { + parts.push(format!("regex: {:?}", self.regex_patterns)); + } + + if !self.exact_paths.is_empty() { + parts.push(format!("paths: {:?}", self.exact_paths)); + } + + if !self.predicates.is_empty() { + parts.push(format!("predicates: {}", self.predicates.len())); + } + + write!(f, "{}]", parts.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_filter() { + let filter = PathFilter::new(); + assert!(filter.is_empty()); + assert!(!filter.matches("encoder.weight")); + assert!(!filter.matches("decoder.bias")); + } + + #[test] + fn match_all() { + let filter = PathFilter::all(); + assert!(!filter.is_empty()); + assert!(filter.matches("encoder.weight")); + assert!(filter.matches("decoder.bias")); + assert!(filter.matches("anything")); + } + + #[test] + fn exact_paths() { + let filter = PathFilter::new() + .with_full_path("encoder.weight") + .with_full_path("decoder.bias"); + + assert!(filter.matches("encoder.weight")); + assert!(filter.matches("decoder.bias")); + assert!(!filter.matches("encoder.bias")); + assert!(!filter.matches("decoder.weight")); + } + + #[test] + #[cfg(feature = "std")] + fn regex_patterns() { + let filter = PathFilter::new() + .with_regex(r"^encoder\..*") + .with_regex(r".*\.weight$"); + + assert!(filter.matches("encoder.layer1.bias")); + assert!(filter.matches("decoder.weight")); + assert!(filter.matches("encoder.weight")); + assert!(!filter.matches("decoder.bias")); + } + + #[test] + fn predicates() { + fn contains_norm(path: &str, _container_path: &str) -> bool { + path.contains("norm") + } + + fn is_short(path: &str, _container_path: &str) -> bool { + path.len() < 10 + } + + let filter = PathFilter::new() + .with_predicate(contains_norm) + .with_predicate(is_short); + + assert!(filter.matches("norm.weight")); + assert!(filter.matches("layer.norm.bias")); + assert!(filter.matches("bias")); + assert!(!filter.matches("encoder.decoder.weight.long.name")); + } + + #[test] + fn combined_filters() { + let filter = PathFilter::new() + .with_full_path("special.tensor") + .with_predicate(|path, _container_path| path.contains("attention")); + + #[cfg(feature = "std")] + let filter = filter.with_regex(r"^encoder\..*"); + + assert!(filter.matches("special.tensor")); + assert!(filter.matches("self_attention.query")); + + #[cfg(feature = "std")] + assert!(filter.matches("encoder.anything")); + + assert!(!filter.matches("decoder.weight")); + } + + #[test] + fn or_combination() { + let encoder_filter = PathFilter::new().with_full_path("encoder.weight"); + let decoder_filter = PathFilter::new().with_full_path("decoder.bias"); + + let combined = encoder_filter.or(decoder_filter); + + assert!(combined.matches("encoder.weight")); + assert!(combined.matches("decoder.bias")); + assert!(!combined.matches("model.head.weight")); + } + + #[test] + #[cfg(feature = "std")] + fn common_patterns() { + // Test encoder pattern + let encoder = PathFilter::new().with_regex(r"^encoder\..*"); + assert!(encoder.matches("encoder.weight")); + assert!(!encoder.matches("decoder.weight")); + + // Test weights-only pattern + let weights = PathFilter::new().with_regex(r".*\.weight$"); + assert!(weights.matches("encoder.weight")); + assert!(weights.matches("decoder.weight")); + assert!(!weights.matches("encoder.bias")); + + // Test layer-specific patterns + let layers = PathFilter::new() + .with_regex(r"(^|.*\.)layers\.0\.") + .with_regex(r"(^|.*\.)layers\.2\.") + .with_regex(r"(^|.*\.)layers\.4\."); + assert!(layers.matches("model.layers.0.weight")); + assert!(layers.matches("layers.2.bias")); + assert!(!layers.matches("layers.1.weight")); + } + + #[test] + fn criteria_count() { + let filter = PathFilter::new() + .with_full_path("path1") + .with_full_path("path2") + .with_predicate(|_, _| true); + + #[cfg(feature = "std")] + let filter = filter.with_regex(".*"); + + #[cfg(feature = "std")] + assert_eq!(filter.criteria_count(), 4); + + #[cfg(not(feature = "std"))] + assert_eq!(filter.criteria_count(), 3); + } + + #[test] + fn clear_operations() { + let mut filter = PathFilter::new().with_full_path("test"); + + filter.clear_paths(); + assert!(!filter.matches("test")); + + filter.clear(); + assert!(filter.is_empty()); + } + + #[test] + fn container_predicates() { + // Filter that matches only Linear module weights + let linear_weights = PathFilter::new().with_predicate(|path, container_path| { + container_path.split('.').next_back() == Some("Linear") && path.ends_with(".weight") + }); + + assert!(linear_weights.matches_with_container("layer1.weight", "Linear")); + assert!(!linear_weights.matches_with_container("layer1.weight", "Conv2d")); + assert!(!linear_weights.matches_with_container("layer1.bias", "Linear")); + + // Filter for specific container types + let conv_only = PathFilter::new().with_predicate(|_path, container_path| { + let last = container_path.split('.').next_back(); + last == Some("Conv2d") || last == Some("ConvTranspose2d") + }); + + assert!(conv_only.matches_with_container("encoder.weight", "Conv2d")); + assert!(conv_only.matches_with_container("decoder.weight", "ConvTranspose2d")); + assert!(!conv_only.matches_with_container("fc.weight", "Linear")); + + // Combine path and container predicates + let combined = PathFilter::new() + .with_predicate(|path, _container_path| path.starts_with("encoder.")) + .with_predicate(|_path, container_path| { + container_path.split('.').next_back() == Some("BatchNorm2d") + }); + + // Should match either condition (OR logic) + assert!(combined.matches_with_container("encoder.layer1", "Linear")); + assert!(combined.matches_with_container("decoder.bn", "BatchNorm2d")); + assert!(!combined.matches_with_container("decoder.layer", "Linear")); + } + + #[test] + fn container_predicate_with_regex() { + // Combine regex patterns with container predicates + #[cfg(feature = "std")] + { + let filter = PathFilter::new() + .with_regex(r"^encoder\..*") + .with_predicate(|path, container_path| { + container_path.split('.').next_back() == Some("Linear") + && path.contains(".bias") + }); + + // Matches due to regex + assert!(filter.matches_with_container("encoder.layer1.weight", "Conv2d")); + // Matches due to container predicate + assert!(filter.matches_with_container("decoder.fc.bias", "Linear")); + // Doesn't match either + assert!(!filter.matches_with_container("decoder.conv.weight", "Conv2d")); + } + } + + #[test] + fn container_stack_predicates() { + // Filter using full container path - only tensors nested in a specific hierarchy + let nested_filter = PathFilter::new().with_predicate(|_path, container_path| { + // Check if tensor is nested within: Model -> TransformerBlock -> Linear + let parts: Vec<&str> = container_path.split('.').collect(); + parts.len() >= 3 + && parts[0] == "Model" + && parts[1] == "TransformerBlock" + && parts[2] == "Linear" + }); + + assert!(nested_filter.matches_with_container_path_str( + "encoder.weight", + "Model.TransformerBlock.Linear.Param" + )); + assert!( + !nested_filter + .matches_with_container_path_str("decoder.weight", "Model.Decoder.Linear.Param") + ); + assert!(!nested_filter.matches_with_container_path_str( + "encoder.weight", + "Model.TransformerBlock.Conv2d.Param" + )); + + // Filter that checks for specific depth in hierarchy + let depth_filter = PathFilter::new().with_predicate(|_path, container_path| { + let parts: Vec<&str> = container_path.split('.').collect(); + parts.len() == 4 && parts.get(2) == Some(&"Linear") + }); + + assert!(depth_filter.matches_with_container_path_str( + "model.layer.weight", + "Model.TransformerBlock.Linear.Param" + )); + assert!( + !depth_filter + .matches_with_container_path_str("model.weight", "Model.TransformerBlock.Conv2d") + ); // Too shallow + + // Filter that checks any Linear in the path (not just the last) + let any_linear = PathFilter::new() + .with_predicate(|_path, container_path| container_path.contains("Linear")); + + assert!( + any_linear.matches_with_container_path_str( + "some.path", + "Model.TransformerBlock.Linear.Param" + ) + ); + assert!( + any_linear.matches_with_container_path_str("other.path", "Model.Decoder.Linear.Param") + ); + assert!( + !any_linear.matches_with_container_path_str( + "conv.path", + "Model.TransformerBlock.Conv2d.Param" + ) + ); + } + + #[test] + fn container_path_dot_notation() { + // Filter using dot-notated container path + let dot_filter = PathFilter::new().with_predicate(|_path, container_path| { + container_path.starts_with("Model.TransformerBlock") + }); + + // Test with matches_with_container_path + assert!( + dot_filter.matches_with_container_path_str("weight", "Model.TransformerBlock.Linear") + ); + assert!(!dot_filter.matches_with_container_path_str("weight", "Model.Decoder.Linear")); + + // Filter that checks for specific patterns in container path + let pattern_filter = PathFilter::new().with_predicate(|_path, container_path| { + // Match any path that has Linear after a block + container_path.contains("Block.Linear") || container_path.contains("Block.Conv") + }); + + assert!( + pattern_filter + .matches_with_container_path_str("weight", "Model.TransformerBlock.Linear") + ); + assert!(pattern_filter.matches_with_container_path_str("weight", "Model.ResBlock.Conv2d")); + assert!(!pattern_filter.matches_with_container_path_str("weight", "Model.Linear.Param")); + + // Filter combining path and container path patterns + let combined = PathFilter::new().with_predicate(|path, container_path| { + // Only weights in Linear layers that are inside blocks + path.ends_with(".weight") + && container_path.contains("Block") + && container_path.split('.').next_back() == Some("Linear") + }); + + assert!( + combined + .matches_with_container_path_str("layer.weight", "Model.TransformerBlock.Linear") + ); + assert!( + !combined + .matches_with_container_path_str("layer.bias", "Model.TransformerBlock.Linear") + ); + assert!(!combined.matches_with_container_path_str("layer.weight", "Model.Decoder.Linear")); + } +} diff --git a/crates/burn-store/src/keyremapper.rs b/crates/burn-store/src/keyremapper.rs new file mode 100644 index 0000000..f7bebd9 --- /dev/null +++ b/crates/burn-store/src/keyremapper.rs @@ -0,0 +1,674 @@ +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use regex::{self, Regex}; + +use crate::TensorSnapshot; + +/// Key remapper for transforming tensor names. +/// +/// This allows mapping tensor names from one naming convention to another, +/// which is useful for loading models from different frameworks or versions. +/// +/// # Examples +/// +/// ```rust +/// # use burn_store::KeyRemapper; +/// // Create a key remapper +/// let remapper = KeyRemapper::new() +/// .add_pattern(r"^pytorch\.(.*)", "burn.$1").expect("valid regex") // pytorch.layer -> burn.layer +/// .add_pattern(r"\.gamma$", ".weight").expect("valid regex"); // layer.gamma -> layer.weight +/// +/// // Use remapper with stores +/// // store.remap(remapper) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct KeyRemapper { + /// Pattern-based remapping rules (regex pattern, replacement string) + pub patterns: Vec<(Regex, String)>, +} + +impl KeyRemapper { + /// Create a new empty key remapper + pub fn new() -> Self { + Self::default() + } + + /// Add a remapping pattern (compiles regex) + /// + /// # Arguments + /// + /// * `from` - Source pattern (regex string) + /// * `to` - Replacement string (can include capture groups like `$1`) + /// + /// # Returns + /// + /// * `Ok(Self)` - Updated remapping configuration + /// * `Err(regex::Error)` - If regex compilation fails + pub fn add_pattern(mut self, from: S1, to: S2) -> Result + where + S1: AsRef, + S2: Into, + { + let regex = Regex::new(from.as_ref())?; + self.patterns.push((regex, to.into())); + Ok(self) + } + + /// Create from a list of compiled regex patterns + pub fn from_compiled_patterns(patterns: Vec<(Regex, String)>) -> Self { + Self { patterns } + } + + /// Create from string patterns (will compile to regex) + /// + /// # Arguments + /// + /// * `patterns` - Vector of (pattern, replacement) tuples + /// + /// # Returns + /// + /// * `Ok(Self)` - New remapping configuration + /// * `Err(regex::Error)` - If any regex compilation fails + pub fn from_patterns(patterns: Vec<(S1, S2)>) -> Result + where + S1: AsRef, + S2: Into, + { + let mut compiled_patterns = Vec::new(); + for (pattern, replacement) in patterns { + let regex = Regex::new(pattern.as_ref())?; + compiled_patterns.push((regex, replacement.into())); + } + Ok(Self { + patterns: compiled_patterns, + }) + } + + /// Create from an iterator of patterns + /// + /// # Arguments + /// + /// * `iter` - Iterator yielding (pattern, replacement) tuples + /// + /// # Returns + /// + /// * `Ok(Self)` - New remapping configuration + /// * `Err(regex::Error)` - If any regex compilation fails + pub fn from_pattern_iter(iter: I) -> Result + where + I: IntoIterator, + S1: AsRef, + S2: Into, + { + let patterns: Result, _> = iter + .into_iter() + .map(|(from, to)| Ok((Regex::new(from.as_ref())?, to.into()))) + .collect(); + Ok(Self { + patterns: patterns?, + }) + } + + /// Check if the remapping is empty + pub fn is_empty(&self) -> bool { + self.patterns.is_empty() + } + + /// Convert to the format expected by remap_tensor_paths_with_patterns + pub fn to_regex_pairs(&self) -> Vec<(Regex, String)> { + self.patterns.clone() + } + + /// Remap tensor paths using the configured patterns. + /// + /// # Arguments + /// + /// * `tensors` - Vec of TensorSnapshots to remap + /// + /// # Returns + /// + /// A tuple containing: + /// * The remapped Vec of TensorSnapshots with updated paths + /// * A vector of (new_path, original_path) showing the transformations + pub fn remap( + &self, + mut tensors: Vec, + ) -> (Vec, Vec<(String, String)>) { + if self.patterns.is_empty() { + let remapped_names = tensors + .iter() + .map(|v| { + let path = v.full_path(); + (path.clone(), path) + }) + .collect(); + return (tensors, remapped_names); + } + + let mut remapped_snapshots = Vec::new(); + let mut remapped_names = Vec::new(); + + for mut snapshot in tensors.drain(..) { + let original_path = snapshot.full_path(); + let mut new_path = original_path.clone(); + + // Apply all patterns to get the new path + for (pattern, replacement) in &self.patterns { + if pattern.is_match(&new_path) { + new_path = pattern + .replace_all(&new_path, replacement.as_str()) + .to_string(); + } + } + + // Update the snapshot's internal path_stack if the path changed + if new_path != original_path + && let Some(ref mut path_stack) = snapshot.path_stack + { + *path_stack = new_path.split('.').map(|s| s.to_string()).collect(); + } + + remapped_names.push((new_path.clone(), original_path)); + remapped_snapshots.push(snapshot); + } + + (remapped_snapshots, remapped_names) + } +} + +/// Map tensor paths to have contiguous numeric indices. +/// +/// This function detects numeric indices in tensor paths and renumbers them +/// to be contiguous (0, 1, 2, ...) while preserving their relative order. +/// It handles nested sequential structures by processing ALL numeric indices +/// in each path independently based on their position context. +/// +/// This is useful when loading PyTorch models that have gaps in layer numbering, +/// such as when using `nn.Sequential` with mixed layer types (e.g., Conv2d + ReLU +/// where only Conv2d has parameters). +/// +/// # Example +/// +/// Simple case - input paths: +/// - `fc.0.weight`, `fc.0.bias` +/// - `fc.2.weight`, `fc.2.bias` +/// - `fc.4.weight`, `fc.4.bias` +/// +/// Output paths: +/// - `fc.0.weight`, `fc.0.bias` +/// - `fc.1.weight`, `fc.1.bias` +/// - `fc.2.weight`, `fc.2.bias` +/// +/// Nested case - input paths: +/// - `feature.layers.0.conv_block.0.weight` +/// - `feature.layers.0.conv_block.2.weight` +/// - `feature.layers.2.conv_block.0.weight` +/// - `feature.layers.2.conv_block.2.weight` +/// +/// Output paths: +/// - `feature.layers.0.conv_block.0.weight` +/// - `feature.layers.0.conv_block.1.weight` +/// - `feature.layers.1.conv_block.0.weight` +/// - `feature.layers.1.conv_block.1.weight` +/// +/// # Arguments +/// +/// * `tensors` - Vec of TensorSnapshots to map +/// +/// # Returns +/// +/// A tuple containing: +/// * The mapped Vec of TensorSnapshots with updated paths +/// * A vector of (new_path, original_path) showing the transformations +pub fn map_indices_contiguous( + mut tensors: Vec, +) -> (Vec, Vec<(String, String)>) { + if tensors.is_empty() { + return (tensors, Vec::new()); + } + + // Step 1: Collect all paths and find all index positions + // For each index position (identified by prefix using ORIGINAL indices), + // collect all indices seen at that position. + // + // Key: prefix using original path (e.g., "feature.layers." or "feature.layers.0.conv_block.") + // Value: BTreeMap of original_index -> new_index + let mut index_maps: BTreeMap> = BTreeMap::new(); + + // First pass: collect all indices at each position using original prefixes + for snapshot in &tensors { + let path = snapshot.full_path(); + let parts: Vec<&str> = path.split('.').collect(); + + // Check each part for numeric indices + for (i, part) in parts.iter().enumerate() { + if let Ok(index) = part.parse::() { + // The prefix is everything before this index (using original path) + let prefix = if i > 0 { + format!("{}.", parts[..i].join(".")) + } else { + String::new() + }; + + index_maps + .entry(prefix) + .or_default() + .entry(index) + .or_insert(usize::MAX); // Placeholder + } + } + } + + // Second pass: assign contiguous indices for each position + for indices in index_maps.values_mut() { + let mut sorted_indices: Vec = indices.keys().cloned().collect(); + sorted_indices.sort(); + + for (new_idx, old_idx) in sorted_indices.into_iter().enumerate() { + indices.insert(old_idx, new_idx); + } + } + + // Third pass: apply the remapping to all tensors + // We use original prefixes for lookup since that's how we collected indices + let mut mapped_snapshots = Vec::new(); + let mut transformations = Vec::new(); + + for mut snapshot in tensors.drain(..) { + let original_path = snapshot.full_path(); + let new_path = remap_all_indices_with_original_prefix(&original_path, &index_maps); + + // Update the snapshot's internal path_stack if the path changed + if new_path != original_path + && let Some(ref mut path_stack) = snapshot.path_stack + { + *path_stack = new_path.split('.').map(|s| s.to_string()).collect(); + } + + transformations.push((new_path, original_path)); + mapped_snapshots.push(snapshot); + } + + (mapped_snapshots, transformations) +} + +/// Remap all numeric indices in a path using the provided index maps. +/// Uses original path prefixes for lookup. +fn remap_all_indices_with_original_prefix( + path: &str, + index_maps: &BTreeMap>, +) -> String { + let parts: Vec<&str> = path.split('.').collect(); + let mut result_parts: Vec = Vec::with_capacity(parts.len()); + + for (i, part) in parts.iter().enumerate() { + if let Ok(index) = part.parse::() { + // Build the prefix from ORIGINAL parts (not remapped) + let prefix = if i > 0 { + format!("{}.", parts[..i].join(".")) + } else { + String::new() + }; + + // Look up the new index using original prefix + if let Some(index_map) = index_maps.get(&prefix) + && let Some(&new_index) = index_map.get(&index) + { + result_parts.push(new_index.to_string()); + continue; + } + } + // Not a numeric index or no mapping found, keep as-is + result_parts.push((*part).to_string()); + } + + result_parts.join(".") +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use burn_core::module::ParamId; + use burn_core::tensor::{Bytes, DType, TensorData, shape}; + + fn create_test_tensor_snapshot(name: &str) -> TensorSnapshot { + let data = TensorData { + bytes: Bytes::from_bytes_vec(vec![1, 2, 3, 4]), + shape: shape![2, 2], + dtype: DType::F32, + }; + let path_parts: Vec = name.split('.').map(|s| s.to_string()).collect(); + TensorSnapshot::from_data(data, path_parts, vec!["Test".to_string()], ParamId::new()) + } + + #[test] + fn test_key_remapper_basic() { + let remapper = KeyRemapper::new() + .add_pattern(r"^encoder\.", "transformer.encoder.") + .expect("valid regex"); + + let tensors = vec![ + create_test_tensor_snapshot("encoder.layer1.weight"), + create_test_tensor_snapshot("decoder.layer1.weight"), + ]; + + let (remapped, transformations) = remapper.remap(tensors); + + // Check that remapped views exist with correct paths + assert!( + remapped + .iter() + .any(|v| v.full_path() == "transformer.encoder.layer1.weight") + ); + assert!( + remapped + .iter() + .any(|v| v.full_path() == "decoder.layer1.weight") + ); + assert_eq!(remapped.len(), 2); + + // Check transformations + let encoder_transform = transformations + .iter() + .find(|(_new, old)| old == "encoder.layer1.weight") + .expect("should find encoder transformation"); + assert_eq!(encoder_transform.0, "transformer.encoder.layer1.weight"); + } + + #[test] + fn test_key_remapper_multiple_patterns() { + let remapper = KeyRemapper::new() + .add_pattern(r"^encoder\.", "transformer.encoder.") + .expect("valid regex") + .add_pattern(r"\.gamma$", ".weight") + .expect("valid regex"); + + let tensors = vec![create_test_tensor_snapshot("encoder.layer1.gamma")]; + + let (remapped, _) = remapper.remap(tensors); + + assert!( + remapped + .iter() + .any(|v| v.full_path() == "transformer.encoder.layer1.weight") + ); + assert_eq!(remapped.len(), 1); + } + + #[test] + fn test_key_remapper_from_patterns() { + let patterns = vec![(r"^pytorch\.", "burn."), (r"\.bias$", ".bias_param")]; + let remapper = KeyRemapper::from_patterns(patterns).expect("valid patterns"); + + let tensors = vec![create_test_tensor_snapshot("pytorch.linear.bias")]; + + let (remapped, _) = remapper.remap(tensors); + + assert!( + remapped + .iter() + .any(|v| v.full_path() == "burn.linear.bias_param") + ); + } + + #[test] + fn test_key_remapper_empty() { + let remapper = KeyRemapper::new(); + assert!(remapper.is_empty()); + + let tensors = vec![create_test_tensor_snapshot("test.weight")]; + + let (remapped, transformations) = remapper.remap(tensors); + + assert!(remapped.iter().any(|v| v.full_path() == "test.weight")); + assert_eq!(remapped.len(), 1); + assert_eq!(transformations.len(), 1); + assert_eq!( + transformations[0], + ("test.weight".to_string(), "test.weight".to_string()) + ); + } + + #[test] + fn test_map_indices_contiguous_basic() { + // Simulate PyTorch nn.Sequential with Conv2d (0, 2, 4) and ReLU (1, 3, 5) + // Only Conv2d layers have parameters + let tensors = vec![ + create_test_tensor_snapshot("fc.0.weight"), + create_test_tensor_snapshot("fc.0.bias"), + create_test_tensor_snapshot("fc.2.weight"), + create_test_tensor_snapshot("fc.2.bias"), + create_test_tensor_snapshot("fc.4.weight"), + create_test_tensor_snapshot("fc.4.bias"), + ]; + + let (reindexed, transformations) = map_indices_contiguous(tensors); + + // Check that indices are now contiguous + assert!(reindexed.iter().any(|v| v.full_path() == "fc.0.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.0.bias")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.1.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.1.bias")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.2.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.2.bias")); + assert_eq!(reindexed.len(), 6); + + // Check transformations + let transform_2_to_1 = transformations + .iter() + .find(|(_, old)| old == "fc.2.weight") + .expect("should find fc.2.weight transformation"); + assert_eq!(transform_2_to_1.0, "fc.1.weight"); + + let transform_4_to_2 = transformations + .iter() + .find(|(_, old)| old == "fc.4.weight") + .expect("should find fc.4.weight transformation"); + assert_eq!(transform_4_to_2.0, "fc.2.weight"); + } + + #[test] + fn test_map_indices_contiguous_already_contiguous() { + // Already contiguous indices should remain unchanged + let tensors = vec![ + create_test_tensor_snapshot("fc.0.weight"), + create_test_tensor_snapshot("fc.1.weight"), + create_test_tensor_snapshot("fc.2.weight"), + ]; + + let (reindexed, transformations) = map_indices_contiguous(tensors); + + assert!(reindexed.iter().any(|v| v.full_path() == "fc.0.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.1.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.2.weight")); + assert_eq!(reindexed.len(), 3); + + // All transformations should have same old and new paths + for (new, old) in &transformations { + assert_eq!(new, old); + } + } + + #[test] + fn test_map_indices_contiguous_multiple_prefixes() { + // Different prefixes should be mapped independently + let tensors = vec![ + create_test_tensor_snapshot("encoder.0.weight"), + create_test_tensor_snapshot("encoder.2.weight"), + create_test_tensor_snapshot("decoder.1.weight"), + create_test_tensor_snapshot("decoder.5.weight"), + ]; + + let (reindexed, _) = map_indices_contiguous(tensors); + + // encoder: 0, 2 -> 0, 1 + assert!( + reindexed + .iter() + .any(|v| v.full_path() == "encoder.0.weight") + ); + assert!( + reindexed + .iter() + .any(|v| v.full_path() == "encoder.1.weight") + ); + + // decoder: 1, 5 -> 0, 1 + assert!( + reindexed + .iter() + .any(|v| v.full_path() == "decoder.0.weight") + ); + assert!( + reindexed + .iter() + .any(|v| v.full_path() == "decoder.1.weight") + ); + } + + #[test] + fn test_map_indices_contiguous_no_indices() { + // Paths without indices should remain unchanged + let tensors = vec![ + create_test_tensor_snapshot("encoder.weight"), + create_test_tensor_snapshot("decoder.bias"), + ]; + + let (reindexed, transformations) = map_indices_contiguous(tensors); + + assert!(reindexed.iter().any(|v| v.full_path() == "encoder.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "decoder.bias")); + + for (new, old) in &transformations { + assert_eq!(new, old); + } + } + + #[test] + fn test_map_indices_contiguous_empty() { + let tensors: Vec = vec![]; + let (reindexed, transformations) = map_indices_contiguous(tensors); + + assert!(reindexed.is_empty()); + assert!(transformations.is_empty()); + } + + #[test] + fn test_map_indices_contiguous_mixed_indexed_and_non_indexed() { + // Mix of indexed and non-indexed paths + let tensors = vec![ + create_test_tensor_snapshot("fc.0.weight"), + create_test_tensor_snapshot("fc.2.weight"), + create_test_tensor_snapshot("output.weight"), // no index + ]; + + let (reindexed, _) = map_indices_contiguous(tensors); + + assert!(reindexed.iter().any(|v| v.full_path() == "fc.0.weight")); + assert!(reindexed.iter().any(|v| v.full_path() == "fc.1.weight")); // 2 -> 1 + assert!(reindexed.iter().any(|v| v.full_path() == "output.weight")); // unchanged + } + + #[test] + fn test_map_indices_contiguous_nested_sequential() { + // Test nested sequential structures like: + // feature = nn.Sequential(ConvBlock, ReLU, ConvBlock, ReLU, ConvBlock) + // where ConvBlock = nn.Sequential(Conv2d, ReLU, Conv2d) + // + // This produces paths like: + // feature.layers.0.conv_block.0.weight (layer 0, conv 0) + // feature.layers.0.conv_block.2.weight (layer 0, conv 2 - skipping ReLU at 1) + // feature.layers.2.conv_block.0.weight (layer 2 - skipping ReLU at 1, conv 0) + // feature.layers.2.conv_block.2.weight (layer 2, conv 2) + let tensors = vec![ + create_test_tensor_snapshot("feature.layers.0.conv_block.0.weight"), + create_test_tensor_snapshot("feature.layers.0.conv_block.2.weight"), + create_test_tensor_snapshot("feature.layers.2.conv_block.0.weight"), + create_test_tensor_snapshot("feature.layers.2.conv_block.2.weight"), + ]; + + let (mapped, transformations) = map_indices_contiguous(tensors); + + // Expected mapping: + // feature.layers: 0, 2 -> 0, 1 + // feature.layers.0.conv_block: 0, 2 -> 0, 1 + // feature.layers.2.conv_block: 0, 2 -> 0, 1 + // + // Result: + // feature.layers.0.conv_block.0.weight -> feature.layers.0.conv_block.0.weight + // feature.layers.0.conv_block.2.weight -> feature.layers.0.conv_block.1.weight + // feature.layers.2.conv_block.0.weight -> feature.layers.1.conv_block.0.weight + // feature.layers.2.conv_block.2.weight -> feature.layers.1.conv_block.1.weight + + assert!( + mapped + .iter() + .any(|v| v.full_path() == "feature.layers.0.conv_block.0.weight"), + "0.0 should stay as 0.0" + ); + assert!( + mapped + .iter() + .any(|v| v.full_path() == "feature.layers.0.conv_block.1.weight"), + "0.2 should become 0.1" + ); + assert!( + mapped + .iter() + .any(|v| v.full_path() == "feature.layers.1.conv_block.0.weight"), + "2.0 should become 1.0" + ); + assert!( + mapped + .iter() + .any(|v| v.full_path() == "feature.layers.1.conv_block.1.weight"), + "2.2 should become 1.1" + ); + + // Verify specific transformations + let t1 = transformations + .iter() + .find(|(_, old)| old == "feature.layers.2.conv_block.2.weight"); + assert_eq!( + t1.map(|(new, _)| new.as_str()), + Some("feature.layers.1.conv_block.1.weight"), + "2.2 should map to 1.1" + ); + } + + #[test] + fn test_map_indices_contiguous_deeply_nested() { + // Test with three levels of nesting + let tensors = vec![ + create_test_tensor_snapshot("a.0.b.0.c.0.weight"), + create_test_tensor_snapshot("a.0.b.0.c.2.weight"), + create_test_tensor_snapshot("a.0.b.2.c.0.weight"), + create_test_tensor_snapshot("a.2.b.0.c.0.weight"), + ]; + + let (mapped, _) = map_indices_contiguous(tensors); + + // a: 0, 2 -> 0, 1 + // a.0.b: 0, 2 -> 0, 1 + // a.2.b: 0 -> 0 + // a.0.b.0.c: 0, 2 -> 0, 1 + // a.0.b.2.c: 0 -> 0 + // a.2.b.0.c: 0 -> 0 + + assert!(mapped.iter().any(|v| v.full_path() == "a.0.b.0.c.0.weight")); + assert!( + mapped.iter().any(|v| v.full_path() == "a.0.b.0.c.1.weight"), + "a.0.b.0.c.2 should become a.0.b.0.c.1" + ); + assert!( + mapped.iter().any(|v| v.full_path() == "a.0.b.1.c.0.weight"), + "a.0.b.2.c.0 should become a.0.b.1.c.0" + ); + assert!( + mapped.iter().any(|v| v.full_path() == "a.1.b.0.c.0.weight"), + "a.2.b.0.c.0 should become a.1.b.0.c.0" + ); + } +} diff --git a/crates/burn-store/src/lib.rs b/crates/burn-store/src/lib.rs new file mode 100644 index 0000000..eea7f57 --- /dev/null +++ b/crates/burn-store/src/lib.rs @@ -0,0 +1,124 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # Burn Store +//! +//! Advanced model storage and serialization infrastructure for the Burn deep learning framework. +//! +//! This crate provides comprehensive functionality for storing and loading Burn modules +//! and their tensor data, with support for cross-framework interoperability, flexible filtering, +//! and efficient memory management through lazy materialization. +//! +//! ## Key Features +//! +//! - **Burnpack Format**: Native Burn format with CBOR metadata, ParamId persistence for stateful training, and no-std support +//! - **SafeTensors Format**: Industry-standard format for secure and efficient tensor serialization +//! - **PyTorch Compatibility**: Load PyTorch models directly into Burn with automatic weight transformation +//! - **Zero-Copy Loading**: Memory-mapped files and lazy tensor materialization for optimal performance +//! - **Flexible Filtering**: Load/save specific model subsets using regex, exact paths, or custom predicates +//! - **Tensor Remapping**: Rename tensors during load/save operations for framework compatibility +//! - **No-std Support**: Core functionality available in embedded and WASM environments +//! +//! ## Quick Start +//! +//! ### Basic Save and Load +//! +//! ```rust,ignore +//! use burn_store::{ModuleSnapshot, SafetensorsStore}; +//! +//! // Save a model +//! let mut store = SafetensorsStore::from_file("model.safetensors"); +//! model.save_into(&mut store)?; +//! +//! // Load a model +//! let mut store = SafetensorsStore::from_file("model.safetensors"); +//! model.load_from(&mut store)?; +//! ``` +//! +//! ### Loading PyTorch Models +//! +//! ```rust,ignore +//! use burn_store::PytorchStore; +//! +//! // Load PyTorch model (automatic weight transformation via PyTorchToBurnAdapter) +//! let mut store = PytorchStore::from_file("pytorch_model.pth") +//! .with_top_level_key("state_dict") // Access nested state dict if needed +//! .allow_partial(true); // Skip unknown tensors +//! +//! model.load_from(&mut store)?; +//! ``` +//! +//! ### Filtering and Remapping +//! +//! ```rust,no_run +//! # use burn_store::SafetensorsStore; +//! // Save only specific layers with renaming +//! let mut store = SafetensorsStore::from_file("encoder.safetensors") +//! .with_regex(r"^encoder\..*") // Filter: only encoder layers +//! .with_key_remapping(r"^encoder\.", "transformer.") // Rename: encoder.X -> transformer.X +//! .metadata("subset", "encoder_only"); +//! +//! // Use store with model.save_into(&mut store)?; +//! ``` +//! +//! ## Core Components +//! +//! - [`ModuleSnapshot`]: Extension trait for Burn modules providing `collect()` and `apply()` methods +//! - [`BurnpackStore`]: Native Burn format with ParamId persistence for stateful training workflows +//! - [`SafetensorsStore`]: Primary storage implementation supporting the SafeTensors format +//! - [`PytorchStore`]: PyTorch model loader supporting .pth and .pt files +//! - [`PathFilter`]: Flexible filtering system for selective tensor loading/saving +//! - [`KeyRemapper`]: Advanced tensor name remapping with regex patterns +//! - [`ModuleAdapter`]: Framework adapters for cross-framework compatibility +//! +//! ## Feature Flags +//! +//! - `std`: Enables file I/O and other std-only features (default) +//! - `safetensors`: Enables SafeTensors format support (default) + +extern crate alloc; + +mod adapter; +mod applier; +mod apply_result; +mod collector; +mod filter; +mod tensor_snapshot; +mod traits; + +pub use adapter::{ + BurnToPyTorchAdapter, ChainAdapter, HalfPrecisionAdapter, IdentityAdapter, ModuleAdapter, + PyTorchToBurnAdapter, +}; +pub use applier::Applier; +pub use apply_result::{ApplyError, ApplyResult}; +pub use collector::Collector; +pub use filter::PathFilter; +pub use tensor_snapshot::{TensorSnapshot, TensorSnapshotError}; +pub use traits::{ModuleSnapshot, ModuleStore}; + +#[cfg(feature = "std")] +mod keyremapper; +#[cfg(feature = "std")] +pub use keyremapper::{KeyRemapper, map_indices_contiguous}; + +/// Serde-based deserialization of nested values, used for importing model weights from external +/// formats (e.g. PyTorch's pickle `.pt` files). +#[cfg(feature = "pytorch")] +pub mod nested; + +#[cfg(feature = "pytorch")] +pub mod pytorch; +#[cfg(feature = "pytorch")] +pub use pytorch::{PytorchStore, PytorchStoreError}; + +#[cfg(feature = "safetensors")] +mod safetensors; +#[cfg(feature = "safetensors")] +pub use safetensors::{SafetensorsStore, SafetensorsStoreError}; + +#[cfg(feature = "burnpack")] +mod bridge; +#[cfg(feature = "burnpack")] +mod burnpack; +#[cfg(feature = "burnpack")] +pub use burnpack::BurnpackStore; diff --git a/crates/burn-store/src/nested/adapter.rs b/crates/burn-store/src/nested/adapter.rs new file mode 100644 index 0000000..b7b3e4f --- /dev/null +++ b/crates/burn-store/src/nested/adapter.rs @@ -0,0 +1,83 @@ +use super::data::NestedValue; + +/// A trait that defines the adapter for a Burn module. +/// +/// This is used to adapt an incoming module to a Burn module. +pub trait BurnModuleAdapter: Sized { + /// Adapts a module. + fn adapt(name: &str, data: NestedValue) -> NestedValue { + match name { + "BatchNorm" => Self::adapt_batch_norm(data), + "Conv1d" => Self::adapt_conv1d(data), + "Conv2d" => Self::adapt_conv2d(data), + "Conv3d" => Self::adapt_conv3d(data), + "ConvTranspose1d" => Self::adapt_conv_transpose_1d(data), + "ConvTranspose2d" => Self::adapt_conv_transpose_2d(data), + "ConvTranspose3d" => Self::adapt_conv_transpose_3d(data), + "Embedding" => Self::adapt_embedding(data), + "GroupNorm" => Self::adapt_group_norm(data), + "LayerNorm" => Self::adapt_layer_norm(data), + "Linear" => Self::adapt_linear(data), + _ => data, + } + } + + /// Adapts a linear module. + fn adapt_linear(data: NestedValue) -> NestedValue { + data + } + + /// Adapts a Convolution 1D module. + fn adapt_conv1d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts a Convolution 2D module. + fn adapt_conv2d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts a Convolution 3D module. + fn adapt_conv3d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts convolution transpose 1D module. + fn adapt_conv_transpose_1d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts convolution transpose 2D module. + fn adapt_conv_transpose_2d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts convolution transpose 2D module. + fn adapt_conv_transpose_3d(data: NestedValue) -> NestedValue { + data + } + + /// Adapts embedding module. + fn adapt_embedding(data: NestedValue) -> NestedValue { + data + } + + /// Adapts group normalization module. + fn adapt_group_norm(data: NestedValue) -> NestedValue { + data + } + + /// Adapts layer normalization module. + fn adapt_layer_norm(data: NestedValue) -> NestedValue { + data + } + + /// Adapts batch normalization module. + fn adapt_batch_norm(data: NestedValue) -> NestedValue { + data + } +} + +/// Default adapter that takes no action. +pub struct DefaultAdapter; +impl BurnModuleAdapter for DefaultAdapter {} diff --git a/crates/burn-store/src/nested/data.rs b/crates/burn-store/src/nested/data.rs new file mode 100644 index 0000000..0067d3a --- /dev/null +++ b/crates/burn-store/src/nested/data.rs @@ -0,0 +1,235 @@ +use std::collections::HashMap; + +use burn_tensor::Bytes; +use core::fmt; +use num_traits::cast::ToPrimitive; + +/// The main data structure used for deserialization. +/// +/// It can hold tree-like structures of nested maps and vectors. +#[derive(Clone)] +pub enum NestedValue { + /// The default value, which actually does not hold any value and it is used to indicate that + /// the value should be populated with the default value. It contains an optional string with + /// the originator field name. + Default(Option), + + /// A boolean value. + Bool(bool), + + /// A string value. + String(String), + + /// Floating point 32-bit value. + F32(f32), + + /// Floating point 64-bit value. + F64(f64), + + /// Signed 16-bit integer value. + I16(i16), + + /// Signed 32-bit integer value. + I32(i32), + + /// Signed 64-bit integer value. + I64(i64), + + /// Unsigned 8-bit integer value. + U8(u8), + + /// Unsigned 16-bit integer value used for bf16 and f16 serialization + U16(u16), + + /// Unsigned 64-bit integer value. + U64(u64), + + /// A map of nested values (typically used for structs) + Map(HashMap), + + /// A vector of nested values (typically used for vector of structs or numbers) + Vec(Vec), + + /// A vector of 8-bit unsigned integer values. + U8s(Vec), + + /// A vector of 16-bit unsigned integer values. + U16s(Vec), + + /// A vector of 32-bit floating point values. + F32s(Vec), + + /// An opaque vector of bytes, with alignment. + Bytes(Bytes), +} + +impl NestedValue { + /// Get the nested value as a map. + pub fn as_map(self) -> Option> { + match self { + NestedValue::Map(map) => Some(map), + _ => None, + } + } + + /// Get the nested value as a boolean. + pub fn as_bool(self) -> Option { + match self { + NestedValue::Bool(bool) => Some(bool), + _ => None, + } + } + + /// Get the nested value as a string. + pub fn as_string(self) -> Option { + match self { + NestedValue::String(string) => Some(string), + _ => None, + } + } + + /// Get the nested value as a f32. + pub fn as_f32(self) -> Option { + match self { + NestedValue::F32(f32) => Some(f32), + NestedValue::F64(f) => f.to_f32(), + _ => None, + } + } + + /// Get the nested value as a f64. + pub fn as_f64(self) -> Option { + match self { + NestedValue::F64(f64) => Some(f64), + NestedValue::F32(f) => f.to_f64(), + _ => None, + } + } + + /// Get the nested value as an i16. + pub fn as_i16(self) -> Option { + match self { + NestedValue::I16(i16) => Some(i16), + NestedValue::I32(i) => i.to_i16(), + NestedValue::I64(i) => i.to_i16(), + NestedValue::U16(u) => u.to_i16(), + NestedValue::U64(u) => u.to_i16(), + _ => None, + } + } + + /// Get the nested value as an i32. + pub fn as_i32(self) -> Option { + match self { + NestedValue::I32(i32) => Some(i32), + NestedValue::I16(i) => i.to_i32(), + NestedValue::I64(i) => i.to_i32(), + NestedValue::U16(u) => u.to_i32(), + NestedValue::U64(u) => u.to_i32(), + _ => None, + } + } + + /// Get the nested value as an i64. + pub fn as_i64(self) -> Option { + match self { + NestedValue::I64(i64) => Some(i64), + NestedValue::I16(i) => i.to_i64(), + NestedValue::I32(i) => i.to_i64(), + NestedValue::U16(u) => u.to_i64(), + NestedValue::U64(u) => u.to_i64(), + _ => None, + } + } + + /// Get the nested value as a u8. + pub fn as_u8(self) -> Option { + match self { + NestedValue::U8(u8) => Some(u8), + NestedValue::I16(i) => i.to_u8(), + NestedValue::I32(i) => i.to_u8(), + NestedValue::I64(i) => i.to_u8(), + NestedValue::U16(u) => u.to_u8(), + NestedValue::U64(u) => u.to_u8(), + _ => None, + } + } + + /// Get the nested value as a u16. + pub fn as_u16(self) -> Option { + match self { + NestedValue::U16(u16) => Some(u16), + NestedValue::I16(i) => i.to_u16(), + NestedValue::I32(i) => i.to_u16(), + NestedValue::I64(i) => i.to_u16(), + NestedValue::U64(u) => u.to_u16(), + _ => None, + } + } + + /// Get the nested value as a u64. + pub fn as_u64(self) -> Option { + match self { + NestedValue::U64(u64) => Some(u64), + NestedValue::I16(i) => i.to_u64(), + NestedValue::I32(i) => i.to_u64(), + NestedValue::I64(i) => i.to_u64(), + NestedValue::U16(u) => u.to_u64(), + _ => None, + } + } + + /// Get the nested value as a vector of bytes. + pub fn as_bytes(self) -> Option { + match self { + NestedValue::Bytes(u) => Some(u), + NestedValue::U8s(u) => Some(Bytes::from_elems(u)), + _ => None, + } + } +} + +fn write_vec_truncated( + vec: &[T], + f: &mut core::fmt::Formatter, +) -> fmt::Result { + write!(f, "Vec([")?; + for (i, v) in vec.iter().take(3).enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{v:?}")?; + } + write!(f, ", ...] len={})", vec.len()) +} + +impl fmt::Debug for NestedValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // Truncate values for vector + NestedValue::Vec(vec) if vec.len() > 3 => write_vec_truncated(vec, f), + NestedValue::U8s(vec) if vec.len() > 3 => write_vec_truncated(vec, f), + NestedValue::U16s(vec) if vec.len() > 3 => write_vec_truncated(vec, f), + NestedValue::F32s(vec) if vec.len() > 3 => write_vec_truncated(vec, f), + NestedValue::Bytes(bytes) if bytes.len() > 3 => write_vec_truncated(bytes, f), + // Handle other variants as usual + NestedValue::Default(origin) => f.debug_tuple("Default").field(origin).finish(), + NestedValue::Bool(b) => f.debug_tuple("Bool").field(b).finish(), + NestedValue::String(s) => f.debug_tuple("String").field(s).finish(), + NestedValue::F32(val) => f.debug_tuple("F32").field(val).finish(), + NestedValue::F64(val) => f.debug_tuple("F64").field(val).finish(), + NestedValue::I16(val) => f.debug_tuple("I16").field(val).finish(), + NestedValue::I32(val) => f.debug_tuple("I32").field(val).finish(), + NestedValue::I64(val) => f.debug_tuple("I64").field(val).finish(), + NestedValue::U8(val) => f.debug_tuple("U8").field(val).finish(), + NestedValue::U16(val) => f.debug_tuple("U16").field(val).finish(), + NestedValue::U64(val) => f.debug_tuple("U64").field(val).finish(), + NestedValue::Map(map) => f.debug_map().entries(map.iter()).finish(), + NestedValue::Vec(vec) => f.debug_list().entries(vec.iter()).finish(), + NestedValue::U8s(vec) => f.debug_list().entries(vec.iter()).finish(), + NestedValue::U16s(vec) => f.debug_list().entries(vec.iter()).finish(), + NestedValue::F32s(vec) => f.debug_list().entries(vec.iter()).finish(), + NestedValue::Bytes(bytes) => f.debug_list().entries(bytes.iter()).finish(), + } + } +} diff --git a/crates/burn-store/src/nested/de.rs b/crates/burn-store/src/nested/de.rs new file mode 100644 index 0000000..383b1f0 --- /dev/null +++ b/crates/burn-store/src/nested/de.rs @@ -0,0 +1,1006 @@ +use core::ptr; +use std::collections::HashMap; + +use super::data::NestedValue; +use super::{adapter::BurnModuleAdapter, error::Error}; + +use serde::de::{EnumAccess, VariantAccess}; +use serde::{ + de::{self, DeserializeSeed, IntoDeserializer, MapAccess, SeqAccess, Visitor}, + forward_to_deserialize_any, +}; + +const RECORD_ITEM_SUFFIX: &str = "RecordItem"; + +/// A deserializer for the nested value data structure. +pub struct Deserializer { + // This string starts with the input data and characters are truncated off + // the beginning as data is parsed. + value: Option, + default_for_missing_fields: bool, + phantom: std::marker::PhantomData, +} + +impl Deserializer { + /// Creates a new deserializer with the given nested value. + /// + /// # Arguments + /// + /// * `value` - A nested value. + /// * `default_for_missing_fields` - A boolean indicating whether to add missing fields with default value. + pub fn new(value: NestedValue, default_for_missing_fields: bool) -> Self { + Self { + value: Some(value), + default_for_missing_fields, + phantom: std::marker::PhantomData, + } + } +} + +impl<'de, A: BurnModuleAdapter> serde::Deserializer<'de> for Deserializer { + type Error = Error; + + fn deserialize_any(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_any is not implemented") + } + + fn deserialize_struct( + self, + name: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + let value = match self.value { + Some(value) => { + // Adapt modules + if let Some(name) = name.strip_suffix(RECORD_ITEM_SUFFIX) { + A::adapt(name, value) + } else { + value + } + } + None => { + return Err(de::Error::custom(format!( + "Expected some value but got {:?}", + self.value + ))); + } + }; + + match value { + NestedValue::Map(map) => { + // Add missing fields into the map with default value if needed. + let map = if self.default_for_missing_fields { + let mut map = map; + for field in fields.iter().map(|s| s.to_string()) { + map.entry(field.clone()) + .or_insert(NestedValue::Default(Some(field))); + } + map + } else { + map + }; + + visitor.visit_map(HashMapAccess::::new( + map, + self.default_for_missing_fields, + )) + } + + _ => Err(de::Error::custom(format!( + "Expected struct but got {value:?}" + ))), + } + } + + fn deserialize_string(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_string(self.value.unwrap().as_string().unwrap().to_string()) + } + + fn deserialize_ignored_any(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_unit() + } + + fn deserialize_map(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Some(NestedValue::Map(map)) => visitor.visit_map(HashMapAccess::::new( + map, + self.default_for_missing_fields, + )), + + _ => Err(de::Error::custom(format!( + "Expected map value but got {:?}", + self.value + ))), + } + } + + fn deserialize_bool(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_bool(self.value.unwrap().as_bool().unwrap()) + } + + fn deserialize_i8(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_i8 is not implemented") + } + + fn deserialize_i16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i16(self.value.unwrap().as_i16().unwrap().to_owned()) + } + + fn deserialize_i32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i32(self.value.unwrap().as_i32().unwrap().to_owned()) + } + + fn deserialize_i64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i64(self.value.unwrap().as_i64().unwrap().to_owned()) + } + + fn deserialize_u8(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u8(self.value.unwrap().as_u8().unwrap().to_owned()) + } + + fn deserialize_u16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u16(self.value.unwrap().as_u16().unwrap().to_owned()) + } + + fn deserialize_u32(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_u32 is not implemented") + } + + fn deserialize_u64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u64(self.value.unwrap().as_u64().unwrap().to_owned()) + } + + fn deserialize_f32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_f32(self.value.unwrap().as_f32().unwrap().to_owned()) + } + + fn deserialize_f64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_f64(self.value.unwrap().as_f64().unwrap().to_owned()) + } + + fn deserialize_char(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_char is not implemented") + } + + fn deserialize_str(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_str(self.value.unwrap().as_string().unwrap().as_ref()) + } + + fn deserialize_bytes(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_bytes is not implemented") + } + + fn deserialize_byte_buf(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + let bytes = self.value.unwrap().as_bytes().unwrap(); + match bytes.try_into_vec::() { + Ok(bytes) => visitor.visit_byte_buf(bytes), + Err(bytes) => visitor.visit_bytes(&bytes), + } + } + + fn deserialize_option(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + if let Some(value) = self.value { + visitor.visit_some(Deserializer::::new( + value, + self.default_for_missing_fields, + )) + } else { + visitor.visit_none() + } + } + + fn deserialize_unit(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_unit is not implemented") + } + + fn deserialize_unit_struct( + self, + _name: &'static str, + _visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_unit_struct is not implemented") + } + + fn deserialize_newtype_struct( + self, + _name: &'static str, + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + visitor.visit_newtype_struct(Deserializer::::new( + self.value.unwrap(), + self.default_for_missing_fields, + )) + } + + fn deserialize_seq(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + if let Some(value) = self.value { + match value { + NestedValue::Vec(_) => visitor.visit_seq(VecSeqAccess::::new( + value, + self.default_for_missing_fields, + )), + NestedValue::U8s(_) => visitor.visit_seq(VecSeqAccess::::new( + value, + self.default_for_missing_fields, + )), + NestedValue::U16s(_) => visitor.visit_seq(VecSeqAccess::::new( + value, + self.default_for_missing_fields, + )), + NestedValue::F32s(_) => visitor.visit_seq(VecSeqAccess::::new( + value, + self.default_for_missing_fields, + )), + _ => Err(de::Error::custom(format!("Expected Vec but got {value:?}"))), + } + } else { + Err(de::Error::custom("Expected Vec but got None")) + } + } + + fn deserialize_tuple(self, _len: usize, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_tuple is not implemented") + } + + fn deserialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + _visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_tuple_struct is not implemented") + } + + /// Deserializes an enum by attempting to match its variants against the provided data. + /// + /// This function attempts to deserialize an enum by iterating over its possible variants + /// and trying to deserialize the data into each until one succeeds. We need to do this + /// because we don't have a way to know which variant to deserialize from the data. + /// + /// This is similar to Serde's + /// [untagged enum deserialization](https://serde.rs/enum-representations.html#untagged), + /// but it's on the deserializer side. Using `#[serde(untagged)]` on the enum will force + /// using `deserialize_any`, which is not what we want because we want to use methods, such + /// as `visit_struct`. Also we do not wish to use auto generate code for Deserialize just + /// for enums because it will affect other serialization and deserialization, such + /// as JSON and Bincode. + /// + /// # Safety + /// The function uses an unsafe block to clone the `visitor`. This is necessary because + /// the `Visitor` trait does not have a `Clone` implementation, and we need to clone it + /// as we are going to use it multiple times. The Visitor is a code generated unit struct + /// with no states or mutations, so it is safe to clone it in this case. We mainly care + /// about the `visit_enum` method, which is the only method that will be called on the + /// cloned visitor. + fn deserialize_enum( + self, + _name: &'static str, + variants: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + fn clone_unsafely(thing: &T) -> T { + unsafe { + // Allocate memory for the clone. + let mut clone = std::mem::MaybeUninit::::uninit(); + // Get a mutable pointer to the allocated memory. + let clone_ptr = clone.as_mut_ptr(); + // Copy the memory + ptr::copy_nonoverlapping(thing as *const T, clone_ptr, 1); + // Assume the cloned data is initialized and convert it to an owned instance of T. + clone.assume_init() + } + } + + // Try each variant in order + for &variant in variants { + // clone visitor to avoid moving it + let cloned_visitor = clone_unsafely(&visitor); + let result = cloned_visitor.visit_enum(ProbeEnumAccess::::new( + self.value.clone().unwrap(), + variant.to_owned(), + self.default_for_missing_fields, + )); + + if result.is_ok() { + return result; + } + } + + Err(de::Error::custom("No variant match")) + } + + fn deserialize_identifier(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("deserialize_identifier is not implemented") + } +} + +/// A sequence access for a vector in the nested value data structure. +struct VecSeqAccess { + iter: Box>, + default_for_missing_fields: bool, + phantom: std::marker::PhantomData, +} + +// Concrete implementation for `Vec` +impl VecSeqAccess { + fn new(vec: NestedValue, default_for_missing_fields: bool) -> Self { + match vec { + NestedValue::Vec(v) => VecSeqAccess { + iter: Box::new(v.into_iter()), + default_for_missing_fields, + phantom: std::marker::PhantomData, + }, + _ => panic!("Invalid vec sequence"), + } + } +} + +// Concrete implementation for `Vec` +impl VecSeqAccess { + fn new(vec: NestedValue, default_for_missing_fields: bool) -> Self { + match vec { + NestedValue::U8s(v) => VecSeqAccess { + iter: Box::new(v.into_iter()), + default_for_missing_fields, + phantom: std::marker::PhantomData, + }, + _ => panic!("Invalid vec sequence"), + } + } +} + +// Concrete implementation for `Vec` +impl VecSeqAccess { + fn new(vec: NestedValue, default_for_missing_fields: bool) -> Self { + match vec { + NestedValue::U16s(v) => VecSeqAccess { + iter: Box::new(v.into_iter()), + default_for_missing_fields, + phantom: std::marker::PhantomData, + }, + _ => panic!("Invalid vec sequence"), + } + } +} + +// Concrete implementation for `Vec` +impl VecSeqAccess { + fn new(vec: NestedValue, default_for_missing_fields: bool) -> Self { + match vec { + NestedValue::F32s(v) => VecSeqAccess { + iter: Box::new(v.into_iter()), + default_for_missing_fields, + phantom: std::marker::PhantomData, + }, + _ => panic!("Invalid vec sequence"), + } + } +} + +// Concrete implementation for `Vec` +impl<'de, A> SeqAccess<'de> for VecSeqAccess +where + NestedValueWrapper: IntoDeserializer<'de, Error>, + A: BurnModuleAdapter, +{ + type Error = Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + let item = match self.iter.next() { + Some(v) => v, + None => return Ok(None), + }; + + seed.deserialize( + NestedValueWrapper::::new(item, self.default_for_missing_fields).into_deserializer(), + ) + .map(Some) + } +} + +// Concrete implementation for `Vec` +impl<'de, A> SeqAccess<'de> for VecSeqAccess +where + NestedValueWrapper: IntoDeserializer<'de, Error>, + A: BurnModuleAdapter, +{ + type Error = Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + let item = match self.iter.next() { + Some(v) => v, + None => return Ok(None), + }; + + seed.deserialize( + NestedValueWrapper::::new(NestedValue::U8(item), self.default_for_missing_fields) + .into_deserializer(), + ) + .map(Some) + } +} + +// Concrete implementation for `Vec` +impl<'de, A> SeqAccess<'de> for VecSeqAccess +where + NestedValueWrapper: IntoDeserializer<'de, Error>, + A: BurnModuleAdapter, +{ + type Error = Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + let item = match self.iter.next() { + Some(v) => v, + None => return Ok(None), + }; + + seed.deserialize( + NestedValueWrapper::::new(NestedValue::U16(item), self.default_for_missing_fields) + .into_deserializer(), + ) + .map(Some) + } +} + +// Concrete implementation for `Vec` +impl<'de, A> SeqAccess<'de> for VecSeqAccess +where + NestedValueWrapper: IntoDeserializer<'de, Error>, + A: BurnModuleAdapter, +{ + type Error = Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + let item = match self.iter.next() { + Some(v) => v, + None => return Ok(None), + }; + + seed.deserialize( + NestedValueWrapper::::new(NestedValue::F32(item), self.default_for_missing_fields) + .into_deserializer(), + ) + .map(Some) + } +} + +/// A map access for a map in the nested value data structure. +struct HashMapAccess { + iter: std::collections::hash_map::IntoIter, + next_value: Option, + default_for_missing_fields: bool, + phantom: std::marker::PhantomData, +} + +impl HashMapAccess { + fn new(map: HashMap, default_for_missing_fields: bool) -> Self { + HashMapAccess { + iter: map.into_iter(), + next_value: None, + default_for_missing_fields, + phantom: std::marker::PhantomData, + } + } +} + +impl<'de, A> MapAccess<'de> for HashMapAccess +where + String: IntoDeserializer<'de, Error>, + NestedValueWrapper: IntoDeserializer<'de, Error>, + A: BurnModuleAdapter, +{ + type Error = Error; + + fn next_key_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + match self.iter.next() { + Some((k, v)) => { + // Keep the value for the next call to next_value_seed. + self.next_value = Some(v); + // Deserialize the key. + seed.deserialize(k.into_deserializer()).map(Some) + } + None => Ok(None), + } + } + + fn next_value_seed(&mut self, seed: T) -> Result + where + T: DeserializeSeed<'de>, + { + match self.next_value.take() { + Some(NestedValue::Default(originator)) => { + seed.deserialize(DefaultDeserializer::new(originator)) + } + Some(v) => seed.deserialize( + NestedValueWrapper::new(v, self.default_for_missing_fields).into_deserializer(), + ), + None => seed.deserialize(DefaultDeserializer::new(None)), + } + } +} + +struct ProbeEnumAccess { + value: NestedValue, + current_variant: String, + default_for_missing_fields: bool, + phantom: std::marker::PhantomData, +} + +impl ProbeEnumAccess { + fn new(value: NestedValue, current_variant: String, default_for_missing_fields: bool) -> Self { + ProbeEnumAccess { + value, + current_variant, + default_for_missing_fields, + phantom: std::marker::PhantomData, + } + } +} + +impl<'de, A> EnumAccess<'de> for ProbeEnumAccess +where + A: BurnModuleAdapter, +{ + type Error = Error; + type Variant = Self; + + fn variant_seed(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error> + where + V: DeserializeSeed<'de>, + { + seed.deserialize(self.current_variant.clone().into_deserializer()) + .map(|v| (v, self)) + } +} + +impl<'de, A> VariantAccess<'de> for ProbeEnumAccess +where + A: BurnModuleAdapter, +{ + type Error = Error; + + fn newtype_variant_seed(self, seed: T) -> Result + where + T: DeserializeSeed<'de>, + { + let value = seed.deserialize( + NestedValueWrapper::::new(self.value, self.default_for_missing_fields) + .into_deserializer(), + )?; + Ok(value) + } + + fn unit_variant(self) -> Result<(), Self::Error> { + // Support tensor `DType` deserialization + match self.value { + NestedValue::Map(value) if value.contains_key("DType") => { + match value.get("DType") { + Some(NestedValue::String(variant)) => { + if *variant == self.current_variant { + Ok(()) + } else { + Err(Error::Other("Wrong variant".to_string())) // wrong match + } + } + _ => panic!("expected DType variant as string"), + } + } + _ => unimplemented!( + "unit variant is not implemented because it is not used in the burn module" + ), + } + } + + fn tuple_variant(self, _len: usize, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!("tuple variant is not implemented because it is not used in the burn module") + } + + fn struct_variant( + self, + _fields: &'static [&'static str], + _visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + unimplemented!( + "struct variant is not implemented because it is not used in the burn module" + ) + } +} + +/// A wrapper for the nested value data structure with a burn module adapter. +struct NestedValueWrapper { + value: NestedValue, + default_for_missing_fields: bool, + phantom: std::marker::PhantomData, +} + +impl NestedValueWrapper { + fn new(value: NestedValue, default_for_missing_fields: bool) -> Self { + Self { + value, + default_for_missing_fields, + phantom: std::marker::PhantomData, + } + } +} + +impl IntoDeserializer<'_, Error> for NestedValueWrapper { + type Deserializer = Deserializer; + + fn into_deserializer(self) -> Self::Deserializer { + Deserializer::::new(self.value, self.default_for_missing_fields) + } +} + +/// A default deserializer that always returns the default value. +struct DefaultDeserializer { + /// The originator field name (the top-level missing field name) + originator_field_name: Option, +} + +impl DefaultDeserializer { + fn new(originator_field_name: Option) -> Self { + Self { + originator_field_name, + } + } +} + +impl<'de> serde::Deserializer<'de> for DefaultDeserializer { + type Error = Error; + + fn deserialize_any(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + unimplemented!() + } + + fn deserialize_i32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i32(Default::default()) + } + + fn deserialize_f32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_f32(Default::default()) + } + + fn deserialize_i16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i16(Default::default()) + } + + fn deserialize_i64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i64(Default::default()) + } + + fn deserialize_u16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u16(Default::default()) + } + + fn deserialize_u64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u64(Default::default()) + } + + fn deserialize_f64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_f64(Default::default()) + } + + fn deserialize_bool(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_bool(Default::default()) + } + + fn deserialize_char(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_char(Default::default()) + } + + fn deserialize_str(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_str(Default::default()) + } + + fn deserialize_i8(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i8(Default::default()) + } + + fn deserialize_u8(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u8(Default::default()) + } + + fn deserialize_u32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u32(Default::default()) + } + + fn deserialize_option(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_none() + } + + fn deserialize_seq(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_seq(DefaultSeqAccess::new(None)) + } + + fn deserialize_string(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_string(Default::default()) + } + + fn deserialize_struct( + self, + name: &'static str, + _fields: &'static [&'static str], + _visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + // Return an error if the originator field name is not set + Err(Error::Other(format!( + "Missing source values for the '{}' field of type '{}'. Please verify the source data and ensure the field name is correct", + self.originator_field_name.unwrap_or("UNKNOWN".to_string()), + name, + ))) + } + + fn deserialize_tuple_struct( + self, + _name: &'static str, + len: usize, + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + visitor.visit_seq(DefaultSeqAccess::new(Some(len))) + } + + fn deserialize_tuple(self, len: usize, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_seq(DefaultSeqAccess::new(Some(len))) + } + + fn deserialize_map(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_map(DefaultMapAccess::new()) + } + + forward_to_deserialize_any! { + u128 bytes byte_buf unit unit_struct newtype_struct + enum identifier ignored_any + } +} + +/// A default sequence access that always returns None (empty sequence). +pub struct DefaultSeqAccess { + size: Option, +} + +impl Default for DefaultSeqAccess { + fn default() -> Self { + Self::new(None) + } +} + +impl DefaultSeqAccess { + /// Creates a new default sequence access with the given size hint. + pub fn new(size: Option) -> Self { + DefaultSeqAccess { size } + } +} + +impl<'de> SeqAccess<'de> for DefaultSeqAccess { + type Error = Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + match self.size { + Some(0) => Ok(None), + Some(ref mut size) => { + *size -= 1; + seed.deserialize(DefaultDeserializer::new(None)).map(Some) + } + None => Ok(None), + } + } + + fn size_hint(&self) -> Option { + self.size + } +} + +/// A default map access that always returns None (empty map). +pub struct DefaultMapAccess; + +impl Default for DefaultMapAccess { + fn default() -> Self { + Self::new() + } +} + +impl DefaultMapAccess { + /// Creates a new default map access. + pub fn new() -> Self { + DefaultMapAccess + } +} + +impl<'de> MapAccess<'de> for DefaultMapAccess { + type Error = Error; + + fn next_key_seed(&mut self, _seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + // Since this is a default implementation, we'll just return None. + Ok(None) + } + + fn next_value_seed(&mut self, _seed: T) -> Result + where + T: DeserializeSeed<'de>, + { + unimplemented!("This should never be called since next_key_seed always returns None") + } + + fn size_hint(&self) -> Option { + // Since this is a default implementation, we'll just return None. + None + } +} diff --git a/crates/burn-store/src/nested/error.rs b/crates/burn-store/src/nested/error.rs new file mode 100644 index 0000000..6b645f3 --- /dev/null +++ b/crates/burn-store/src/nested/error.rs @@ -0,0 +1,31 @@ +/// The error type for nested-value serde. +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Failed to deserialize. + #[error("failed to deserialize: {0}")] + Deserialize(#[from] serde::de::value::Error), + + /// Failed to serialize. + #[error("failed to serialize")] + Serialize(String), + + /// Encountered an invalid state. + #[error("invalid state")] + InvalidState, + + /// Other error. + #[error("other error: {0}")] + Other(String), +} + +impl serde::de::Error for Error { + fn custom(msg: T) -> Self { + Error::Deserialize(serde::de::value::Error::custom(msg.to_string())) + } +} + +impl serde::ser::Error for Error { + fn custom(msg: T) -> Self { + Error::Serialize(msg.to_string()) + } +} diff --git a/crates/burn-store/src/nested/mod.rs b/crates/burn-store/src/nested/mod.rs new file mode 100644 index 0000000..de363d1 --- /dev/null +++ b/crates/burn-store/src/nested/mod.rs @@ -0,0 +1,14 @@ +//! Serde-based deserialization of nested values, used for importing model weights from external +//! formats (e.g. PyTorch's pickle `.pt` files) into Burn modules via `burn-store`. + +/// The adapter trait that is used to convert the nested value to the module type. +pub mod adapter; + +/// The main data structure used for deserialization. +pub mod data; + +/// The deserializer that converts a nested value into a typed item. +pub mod de; + +/// Error types. +pub mod error; diff --git a/crates/burn-store/src/pytorch/lazy_data.rs b/crates/burn-store/src/pytorch/lazy_data.rs new file mode 100644 index 0000000..30a2260 --- /dev/null +++ b/crates/burn-store/src/pytorch/lazy_data.rs @@ -0,0 +1,570 @@ +//! Lazy data loading support for PyTorch files. +//! +//! This module provides abstractions for lazy loading of tensor data from PyTorch files, +//! avoiding the need to load all data into memory upfront. + +use alloc::string::String; +use alloc::vec::Vec; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Read, Seek}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; +use zip::ZipArchive; + +/// A data source that can lazily load tensor data. +#[derive(Clone)] +pub enum LazyDataSource { + /// ZIP archive with lazy loading + Zip(Arc>), + /// TAR archive format (older torchvision models) + Tar(Arc>), + /// Legacy format with multiple storages in single blob + LegacyMultiStorage(Arc>), +} + +/// ZIP archive source for lazy loading +pub struct ZipSource { + path: PathBuf, + // Cache the file list to avoid reopening archive repeatedly + file_list: Vec<(String, u64, u64)>, // (name, offset, compressed_size) +} + +/// TAR archive source for lazy loading (older torchvision models like AlexNet, SqueezeNet) +/// +/// Older PyTorch/torchvision models (pre-1.6) use TAR format instead of ZIP. +/// The TAR archive contains: +/// - `sys_info`: System info pickle (endianness, type sizes) +/// - `pickle`: OrderedDict mapping tensor names to storage keys +/// - `tensors`: Tensor metadata pickles (unused, metadata is embedded in pickle) +/// - `storages`: Storage count + sequential (metadata pickle, element count, raw data) +pub struct TarSource { + /// Cached storage map: storage_key -> (offset_in_storages, size_bytes) + storage_map: HashMap, + /// The raw storages data (kept in memory for TAR format) + storages_data: Vec, +} + +/// Legacy multi-storage source for old PyTorch format (0.1.10 - 1.5) +/// +/// Legacy format stores tensor data as concatenated raw binary without explicit +/// storage boundaries. This source tracks storage usage during tensor parsing +/// to build a storage map for lazy loading. +/// +/// ## Storage Layout +/// - Pickle metadata with tensor definitions +/// - List of storage keys (determines concatenation order) +/// - Raw binary blob with all storages concatenated +pub struct LegacyMultiStorageSource { + path: PathBuf, + data_offset: u64, + #[allow(dead_code)] + data_size: u64, + // Map of storage_key -> (offset_in_blob, size) + storage_map: RwLock>>, + // Storage keys in order (for boundary calculation) + storage_keys: RwLock>>, + // Track storage usage as tensors are accessed + storage_usage: RwLock>, // key -> max_bytes_needed +} + +impl ZipSource { + /// Create a new ZIP source + pub fn new(path: PathBuf) -> std::io::Result { + let file = File::open(&path)?; + let reader = BufReader::new(file); + let mut archive = ZipArchive::new(reader)?; + + // Cache file metadata + let mut file_list = Vec::new(); + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + let offset = file.data_start(); + let compressed_size = file.compressed_size(); + file_list.push(( + name, + offset.expect("should have an offset"), + compressed_size, + )); + } + + Ok(Self { path, file_list }) + } + + /// Check if a file exists in the archive + pub fn contains(&self, name: &str) -> bool { + self.file_list.iter().any(|(n, _, _)| n == name) + } + + /// Get list of data files (excluding pickle files) + pub fn data_files(&self) -> Vec { + self.file_list + .iter() + .filter(|(name, _, _)| name.starts_with("data/") || name.contains("/data/")) + .filter(|(name, _, _)| !name.ends_with(".pkl") && !name.ends_with("/")) + .map(|(name, _, _)| name.clone()) + .collect() + } + + /// Read a specific file from the archive + pub fn read_file(&self, name: &str) -> std::io::Result> { + let file = File::open(&self.path)?; + let reader = BufReader::new(file); + let mut archive = ZipArchive::new(reader)?; + + let mut file = archive.by_name(name)?; + let mut contents = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut contents)?; + Ok(contents) + } + + /// Read a portion of a file + pub fn read_file_range( + &self, + name: &str, + offset: usize, + length: usize, + ) -> std::io::Result> { + let file = File::open(&self.path)?; + let reader = BufReader::new(file); + let mut archive = ZipArchive::new(reader)?; + + let mut file = archive.by_name(name)?; + let mut buffer = vec![0u8; length]; + + // Skip to offset + let mut skip_buffer = vec![0u8; offset.min(8192)]; + let mut skipped = 0; + while skipped < offset { + let to_skip = (offset - skipped).min(skip_buffer.len()); + file.read_exact(&mut skip_buffer[..to_skip])?; + skipped += to_skip; + } + + // Read the requested data + file.read_exact(&mut buffer)?; + Ok(buffer) + } +} + +impl LegacyMultiStorageSource { + /// Create a new legacy multi-storage source + pub fn new(path: PathBuf, data_offset: u64, data_size: u64) -> Self { + Self { + path, + data_offset, + data_size, + storage_map: RwLock::new(None), + storage_keys: RwLock::new(None), + storage_usage: RwLock::new(HashMap::new()), + } + } + + /// Set the ordered storage keys from the pickle + pub fn set_storage_keys(&self, keys: Vec) { + let mut storage_keys = self + .storage_keys + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *storage_keys = Some(keys); + } + + /// Track storage usage from tensor access + /// This is called from within tensor loading closures + pub fn track_storage_usage(&self, storage_key: &str, offset: usize, size: usize) { + let mut usage = self + .storage_usage + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let max_extent = offset + size; + usage + .entry(storage_key.to_string()) + .and_modify(|current| *current = (*current).max(max_extent)) + .or_insert(max_extent); + + // Try to build storage map if we have enough information + drop(usage); + self.try_build_storage_map(); + } + + /// Try to build the storage map from tracked usage + fn try_build_storage_map(&self) { + // Only build if we don't already have a map + if self + .storage_map + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() + { + return; + } + + // Check if we have storage keys + let keys_guard = self + .storage_keys + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(ref keys) = *keys_guard { + let usage = self + .storage_usage + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // Only build if we have usage info for all storages + if keys.iter().all(|k| usage.contains_key(k)) { + let mut map = HashMap::new(); + let mut current_offset = 0u64; + + for key in keys { + if let Some(&size) = usage.get(key) { + // Each storage in the binary section is preceded by an 8 byte + // element count (u64 little-endian). Skip it by adding 8 to the + // data offset, and include it in the stride to the next storage. + map.insert(key.clone(), (current_offset + 8, size as u64)); + current_offset += 8 + size as u64; + } + } + + // Set the storage map + drop(keys_guard); + drop(usage); + let mut storage_map = self + .storage_map + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *storage_map = Some(map); + } + } + } + + /// Read data for a specific storage key + /// Only loads the specific storage portion, never the entire blob + pub fn read(&self, key: &str) -> std::io::Result> { + // Extract numeric key from paths like "data/0" or just "0" + let storage_key = key.split('/').next_back().unwrap_or(key); + + // Get storage map - must be available for lazy loading to work + let storage_map = self + .storage_map + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some(ref map) = *storage_map + && let Some(&(offset, size)) = map.get(storage_key) + { + // Load only this specific storage + let mut file = File::open(&self.path)?; + file.seek(std::io::SeekFrom::Start(self.data_offset + offset))?; + + let mut buffer = vec![0u8; size as usize]; + file.read_exact(&mut buffer)?; + return Ok(buffer); + } + + // NO FALLBACK! If we don't have storage boundaries, we cannot load data lazily + // The storage map MUST be built from tensor metadata for lazy loading to work + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Storage boundaries not available for key '{}'. Cannot perform lazy loading.", + storage_key + ), + )) + } +} + +impl TarSource { + /// Create a new TAR source by parsing storages data. + /// + /// # Arguments + /// * `storages_data` - Raw storages blob with structure: + /// - Count pickle (number of storages) + /// - For each storage: metadata pickle + u64 num_elements + raw binary data + pub fn new(storages_data: Vec) -> std::io::Result { + use super::pickle_reader::{read_pickle, storage_type_to_element_size}; + use std::io::Cursor; + + let mut storage_map = HashMap::new(); + let mut pos = 0usize; + + // First, read the count of storages + let mut cursor = Cursor::new(&storages_data[pos..]); + let storage_count = + if let Ok(super::pickle_reader::Object::Int(count)) = read_pickle(&mut cursor) { + pos += cursor.position() as usize; + count as usize + } else { + 0 + }; + + // Parse each storage entry + for _i in 0..storage_count { + if pos >= storages_data.len() { + break; + } + + // Read the storage metadata pickle: (storage_key, device, storage_type) + let mut cursor = Cursor::new(&storages_data[pos..]); + if let Ok(obj) = read_pickle(&mut cursor) { + let pickle_size = cursor.position() as usize; + pos += pickle_size; + + // Extract storage info from pickle tuple + let (storage_key, storage_type) = match obj { + super::pickle_reader::Object::Tuple(tuple) if tuple.len() >= 3 => { + let key = match &tuple[0] { + super::pickle_reader::Object::Int(i) => i.to_string(), + super::pickle_reader::Object::String(s) => s.clone(), + _ => continue, + }; + // tuple[1] is device (e.g., "cpu") + // tuple[2] is storage type class + let stype = match &tuple[2] { + super::pickle_reader::Object::Class { name, .. } => name.clone(), + other => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Expected Class for storage type, got {:?}", other), + )); + } + }; + (key, stype) + } + _ => continue, + }; + + // Read the number of elements (u64 little-endian) + if pos + 8 > storages_data.len() { + break; + } + let num_elements = u64::from_le_bytes([ + storages_data[pos], + storages_data[pos + 1], + storages_data[pos + 2], + storages_data[pos + 3], + storages_data[pos + 4], + storages_data[pos + 5], + storages_data[pos + 6], + storages_data[pos + 7], + ]) as usize; + pos += 8; + + // Determine element size from storage type + let element_size = storage_type_to_element_size(&storage_type) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let data_size = num_elements * element_size; + + // Store the offset to raw data and its size + storage_map.insert(storage_key, (pos, data_size)); + + // Skip the raw binary data + pos += data_size; + } else { + break; + } + } + + Ok(Self { + storage_map, + storages_data, + }) + } + + /// Read data for a specific storage key + pub fn read_file(&self, key: &str) -> std::io::Result> { + // Extract the storage key from paths like "data/0" + let storage_key = key.split('/').next_back().unwrap_or(key); + + if let Some(&(offset, size)) = self.storage_map.get(storage_key) + && offset + size <= self.storages_data.len() + { + return Ok(self.storages_data[offset..offset + size].to_vec()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Storage key '{}' not found in TAR archive", storage_key), + )) + } + + /// Read a range of data for a specific storage key (avoids double allocation) + pub fn read_file_range( + &self, + key: &str, + offset: usize, + length: usize, + ) -> std::io::Result> { + let storage_key = key.split('/').next_back().unwrap_or(key); + + if let Some(&(storage_offset, storage_size)) = self.storage_map.get(storage_key) + && storage_offset + storage_size <= self.storages_data.len() + { + let start = storage_offset + offset; + let end = (storage_offset + offset + length).min(storage_offset + storage_size); + return Ok(self.storages_data[start..end].to_vec()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Storage key '{}' not found in TAR archive", storage_key), + )) + } + + /// Check if a storage key exists + pub fn contains(&self, key: &str) -> bool { + let storage_key = key.split('/').next_back().unwrap_or(key); + self.storage_map.contains_key(storage_key) + } + + /// Get list of storage keys + pub fn keys(&self) -> Vec { + self.storage_map.keys().cloned().collect() + } +} + +impl LazyDataSource { + /// Create from a ZIP file + pub fn from_zip(path: impl AsRef) -> std::io::Result { + Ok(Self::Zip(Arc::new(Mutex::new(ZipSource::new( + path.as_ref().to_path_buf(), + )?)))) + } + + /// Create from a TAR archive's storages data + pub fn from_tar(storages_data: &[u8]) -> std::io::Result { + Ok(Self::Tar(Arc::new(Mutex::new(TarSource::new( + storages_data.to_vec(), + )?)))) + } + + /// Create from a legacy multi-storage file + pub fn from_legacy_multi_storage( + path: impl AsRef, + data_offset: u64, + data_size: u64, + ) -> Self { + Self::LegacyMultiStorage(Arc::new(Mutex::new(LegacyMultiStorageSource::new( + path.as_ref().to_path_buf(), + data_offset, + data_size, + )))) + } + + /// Read data for a specific key + pub fn read(&self, key: &str) -> std::io::Result> { + match self { + Self::Zip(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.read_file(key) + } + Self::Tar(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.read_file(key) + } + Self::LegacyMultiStorage(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.read(key) + } + } + } + + /// Read a portion of data for a specific key + pub fn read_range(&self, key: &str, offset: usize, length: usize) -> std::io::Result> { + match self { + Self::Zip(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.read_file_range(key, offset, length) + } + Self::Tar(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.read_file_range(key, offset, length) + } + Self::LegacyMultiStorage(source) => { + // For legacy format, read only the requested range + let storage_key = key.split('/').next_back().unwrap_or(key); + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // Get storage boundaries + let storage_map = source + .storage_map + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(ref map) = *storage_map + && let Some(&(storage_offset, storage_size)) = map.get(storage_key) + { + // Calculate actual file position + let file_offset = source.data_offset + storage_offset + offset as u64; + let read_length = length.min((storage_size as usize).saturating_sub(offset)); + + // Read only the requested range + let mut file = File::open(&source.path)?; + file.seek(std::io::SeekFrom::Start(file_offset))?; + + let mut buffer = vec![0u8; read_length]; + file.read_exact(&mut buffer)?; + Ok(buffer) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Storage boundaries not available for key '{}'. Cannot perform lazy loading.", + storage_key + ), + )) + } + } + } + } + + /// Check if a key exists + pub fn contains(&self, key: &str) -> bool { + match self { + Self::Zip(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.contains(key) + } + Self::Tar(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.contains(key) + } + Self::LegacyMultiStorage(_) => true, // Legacy format has all data + } + } + + /// Get list of available keys (for ZIP sources) + pub fn keys(&self) -> Vec { + match self { + Self::Zip(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.data_files() + } + Self::Tar(source) => { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.keys() + } + Self::LegacyMultiStorage(_) => vec![], // Legacy format doesn't have distinct keys + } + } +} diff --git a/crates/burn-store/src/pytorch/mod.rs b/crates/burn-store/src/pytorch/mod.rs new file mode 100644 index 0000000..5eecf6b --- /dev/null +++ b/crates/burn-store/src/pytorch/mod.rs @@ -0,0 +1,48 @@ +//! PyTorch format support for burn-store. +//! +//! This module provides comprehensive support for loading PyTorch model files (.pth, .pt) +//! into Burn, with automatic weight transformation and flexible configuration options. +//! +//! ## Features +//! +//! - **Direct .pth/.pt file loading**: Load PyTorch checkpoint and state dict files +//! - **Automatic weight transformation**: `PyTorchToBurnAdapter` is applied by default: +//! - Linear layer weights are automatically transposed +//! - Normalization parameters are renamed (gamma → weight, beta → bias) +//! - Conv2d weights maintain their format +//! - **Flexible filtering**: Load only specific layers or parameters +//! - **Key remapping**: Rename tensors during loading to match your model structure +//! - **Partial loading**: Continue even when some tensors are missing +//! +//! ## Example +//! +//! ```rust,ignore +//! use burn_store::PytorchStore; +//! +//! // Load a PyTorch model (PyTorchToBurnAdapter is applied automatically) +//! let mut store = PytorchStore::from_file("model.pth") +//! .with_top_level_key("state_dict") // Access nested state dict +//! .with_regex(r"^encoder\..*") // Only load encoder layers +//! .with_key_remapping(r"^fc\.", "linear.") // Rename fc -> linear +//! .allow_partial(true); // Skip missing tensors +//! +//! let mut model = MyModel::new(&device); +//! let result = model.load_from(&mut store)?; +//! +//! println!("Loaded {} tensors", result.applied.len()); +//! if !result.missing.is_empty() { +//! println!("Missing tensors: {:?}", result.missing); +//! } +//! ``` + +pub mod lazy_data; +pub mod pickle_reader; +pub mod reader; +pub mod store; + +#[cfg(test)] +pub mod tests; + +// Main public interface +pub use reader::{PytorchError, PytorchReader}; +pub use store::{PytorchStore, PytorchStoreError}; diff --git a/crates/burn-store/src/pytorch/pickle_reader.rs b/crates/burn-store/src/pytorch/pickle_reader.rs new file mode 100644 index 0000000..5362859 --- /dev/null +++ b/crates/burn-store/src/pytorch/pickle_reader.rs @@ -0,0 +1,1538 @@ +//! Just enough pickle support to be able to read PyTorch checkpoints. +//! +//! This implementation is based on the candle project's pickle loader with significant +//! modifications for improved separation of concerns and extended PyTorch compatibility. +//! +//! Original source: +//! +//! Modifications include: +//! - Lazy tensor data loading for memory efficiency +//! - Extended PyTorch version compatibility (0.1.10 - 2.x) +//! - Better separation of pickle parsing and tensor extraction +//! - Support for both legacy and modern PyTorch formats +use crate::TensorSnapshot; +use crate::pytorch::lazy_data::LazyDataSource; +use alloc::rc::Rc; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use burn_core::module::ParamId; +use burn_core::tensor::{BoolStore, DType, TensorData}; +use byteorder::{LittleEndian, ReadBytesExt}; +use half::{bf16, f16}; +use std::collections::HashMap; +use std::io::{self, BufRead}; +use std::sync::Arc; + +/// Error type for pickle operations +#[derive(Debug)] +pub enum PickleError { + Io(io::Error), + InvalidOpCode(u8), + InvalidProtocol(u8), + UnexpectedOpCode(OpCode), + UnsupportedType(String), + InvalidData(String), + StackUnderflow, + MemoNotFound(u32), + InvalidShapeOrType, +} + +impl From for PickleError { + fn from(e: io::Error) -> Self { + PickleError::Io(e) + } +} + +impl std::fmt::Display for PickleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PickleError::Io(e) => write!(f, "IO error: {}", e), + PickleError::InvalidOpCode(code) => write!( + f, + "Invalid pickle opcode: 0x{:02x}. The file may be corrupted or use an unsupported pickle protocol.", + code + ), + PickleError::InvalidProtocol(proto) => write!( + f, + "Invalid or unsupported pickle protocol version: {}. Supported versions are 2-5.", + proto + ), + PickleError::UnexpectedOpCode(op) => { + write!(f, "Unexpected pickle opcode {:?} in current context", op) + } + PickleError::UnsupportedType(ty) => write!( + f, + "Unsupported Python type '{}'. This may indicate a full model save rather than a state_dict.", + ty + ), + PickleError::InvalidData(msg) => write!(f, "Invalid data in pickle file: {}", msg), + PickleError::StackUnderflow => { + write!(f, "Pickle stack underflow - the file may be corrupted") + } + PickleError::MemoNotFound(idx) => write!( + f, + "Pickle memo reference {} not found - the file may be corrupted", + idx + ), + PickleError::InvalidShapeOrType => { + write!(f, "Invalid tensor shape or data type in PyTorch file") + } + } + } +} + +impl std::error::Error for PickleError {} + +type Result = std::result::Result; + +/// Convert PyTorch storage type name to element size in bytes. +/// +/// This is used to calculate storage sizes for lazy loading. +/// The storage type names follow PyTorch's naming convention (e.g., "FloatStorage", "BFloat16Storage"). +/// +/// Returns an error for unknown storage types to avoid silently loading garbage data. +pub fn storage_type_to_element_size(storage_type: &str) -> std::result::Result { + match storage_type { + "DoubleStorage" | "LongStorage" | "ComplexFloatStorage" => Ok(8), + "FloatStorage" | "IntStorage" | "ComplexHalfStorage" => Ok(4), + "HalfStorage" | "BFloat16Storage" | "ShortStorage" => Ok(2), + "ByteStorage" | "CharStorage" | "BoolStorage" => Ok(1), + _ => Err(format!("Unknown storage type: {}", storage_type)), + } +} + +// https://docs.juliahub.com/Pickle/LAUNc/0.1.0/opcode/ +#[repr(u8)] +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum OpCode { + // https://github.com/python/cpython/blob/ed25f097160b5cbb0c9a1f9a746d2f1bbc96515a/Lib/pickletools.py#L2123 + Proto = 0x80, + Global = b'c', + BinPut = b'q', + LongBinPut = b'r', + EmptyTuple = b')', + Reduce = b'R', + Mark = b'(', + BinUnicode = b'X', + ShortBinString = b'U', + BinInt = b'J', + Int = b'I', + Tuple = b't', + BinPersId = b'Q', + BinInt1 = b'K', + BinInt2 = b'M', + Tuple1 = 0x85, + Tuple2 = 0x86, + Tuple3 = 0x87, + NewTrue = 0x88, + NewFalse = 0x89, + None = b'N', + BinGet = b'h', + LongBinGet = b'j', + SetItem = b's', + SetItems = b'u', + EmptyDict = b'}', + Dict = b'd', + Build = b'b', + Stop = b'.', + NewObj = 0x81, + EmptyList = b']', + List = b'l', + BinFloat = b'G', + Append = b'a', + Appends = b'e', + Long1 = 0x8a, + Memoize = 0x94, +} + +// Avoid using FromPrimitive so as not to drag another dependency. +impl TryFrom for OpCode { + type Error = u8; + fn try_from(value: u8) -> std::result::Result { + match value { + 0x80 => Ok(Self::Proto), + b'c' => Ok(Self::Global), + b'q' => Ok(Self::BinPut), + b'r' => Ok(Self::LongBinPut), + b')' => Ok(Self::EmptyTuple), + b'R' => Ok(Self::Reduce), + b'(' => Ok(Self::Mark), + b'X' => Ok(Self::BinUnicode), + b'U' => Ok(Self::ShortBinString), + b'J' => Ok(Self::BinInt), + b'I' => Ok(Self::Int), + b't' => Ok(Self::Tuple), + b'Q' => Ok(Self::BinPersId), + b'K' => Ok(Self::BinInt1), + b'M' => Ok(Self::BinInt2), + b'N' => Ok(Self::None), + 0x85 => Ok(Self::Tuple1), + 0x86 => Ok(Self::Tuple2), + 0x87 => Ok(Self::Tuple3), + 0x88 => Ok(Self::NewTrue), + 0x89 => Ok(Self::NewFalse), + b'h' => Ok(Self::BinGet), + b'j' => Ok(Self::LongBinGet), + b's' => Ok(Self::SetItem), + b'u' => Ok(Self::SetItems), + b'}' => Ok(Self::EmptyDict), + b'd' => Ok(Self::Dict), + b'b' => Ok(Self::Build), + b'.' => Ok(Self::Stop), + 0x81 => Ok(Self::NewObj), + b']' => Ok(Self::EmptyList), + b'l' => Ok(Self::List), + b'G' => Ok(Self::BinFloat), + b'a' => Ok(Self::Append), + b'e' => Ok(Self::Appends), + 0x8a => Ok(Self::Long1), + 0x94 => Ok(Self::Memoize), + value => Err(value), + } + } +} + +fn read_to_newline(r: &mut R) -> Result> { + let mut data: Vec = Vec::with_capacity(32); + r.read_until(b'\n', &mut data)?; + data.pop(); + if data.last() == Some(&b'\r') { + data.pop(); + } + Ok(data) +} + +fn buf_to_str(buf: &[u8]) -> Result { + String::from_utf8(buf.to_vec()) + .map_err(|e| PickleError::InvalidData(format!("Invalid UTF-8: {}", e))) +} + +#[derive(Debug, Clone)] +pub enum Object { + Class { + module_name: String, + name: String, + }, + String(String), + Int(i64), + Float(f64), + Bool(bool), + None, + Tuple(Vec), + List(Vec), + Dict(HashMap), + Persistent(Vec), + PersistentTuple(Vec), + Reduce { + callable: Box, + args: Box, + }, + Build { + callable: Box, + args: Box, + }, + TorchParam(TensorSnapshot), +} + +fn rebuild_from_type_v2( + o: Object, + memo: &mut HashMap, + data_source: &Option>, +) -> Result { + let args = if let Object::Tuple(args) = o { + if args.is_empty() { + return Err(PickleError::InvalidData( + "rebuild_from_type_v2: empty args".to_string(), + )); + } + args + } else { + return Err(PickleError::InvalidData(format!( + "rebuild_from_type_v2: expected tuple got {:?}", + o + ))); + }; + let func = &args[0]; + match func { + Object::Class { module_name, name } => { + let module_name = module_name.as_str(); + let name = name.as_str(); + // For rebuild_tensor_v2, the args might already be in a tuple + let actual_args = if args.len() == 2 && matches!(&args[1], Object::Tuple(_)) { + // If there's only one arg and it's a tuple, use it directly + args[1].clone() + } else { + // Otherwise, wrap the remaining args in a tuple + Object::Tuple(args[1..].to_vec()) + }; + if module_name == "torch._utils" && name == "_rebuild_tensor_v2" { + rebuild_tensor_v2(actual_args, memo, data_source) + } else if module_name == "torch._utils" && name == "_rebuild_tensor" { + // Legacy _rebuild_tensor (PyTorch < 1.6) + // Same as v2 but with fewer arguments: (storage, storage_offset, size, stride) + rebuild_tensor(actual_args, memo, data_source) + } else if module_name == "torch._tensor" && name == "_rebuild_from_type_v2" { + rebuild_from_type_v2(actual_args, memo, data_source) + } else if module_name == "torch._utils" && name == "_rebuild_parameter" { + rebuild_parameter(actual_args, memo, data_source) + } else if module_name == "collections" && name == "OrderedDict" { + // OrderedDict is treated as a regular Dict in our implementation + Ok(Object::Dict(HashMap::new())) + } else { + Err(PickleError::UnsupportedType(format!( + "{}.{}", + module_name, name + ))) + } + } + _ => Err(PickleError::InvalidData(format!( + "rebuild_from_type_v2: expected class got {:?}", + func + ))), + } +} + +fn rebuild_parameter( + args: Object, + memo: &mut HashMap, + data_source: &Option>, +) -> Result { + let args = if let Object::Tuple(args) = args { + if args.is_empty() { + return Err(PickleError::InvalidData( + "rebuild_parameter: empty args".to_string(), + )); + } + args + } else { + return Err(PickleError::InvalidData(format!( + "rebuild_parameter: expected tuple got {:?}", + args + ))); + }; + let data = &args[0]; + let tensor = match data { + Object::Reduce { + callable: _, + args: _, + } => rebuild_from_type_v2(data.clone(), memo, data_source)?, + _ => data.clone(), + }; + Ok(tensor) +} + +/// Parse storage argument and extract storage info and tuple. +fn parse_storage_arg(arg: &Object, fn_name: &str) -> Result<(Vec, Option>)> { + match arg { + Object::Persistent(data) => Ok((data.clone(), None)), + Object::PersistentTuple(tuple) => Ok((vec![], Some(tuple.clone()))), + // Also accept regular Tuple for TAR format compatibility + Object::Tuple(tuple) => Ok((vec![], Some(tuple.clone()))), + _ => Err(PickleError::InvalidData(format!( + "{}: expected persistent id got {:?}", + fn_name, arg + ))), + } +} + +/// Parse shape argument. +fn parse_shape_arg(arg: &Object, fn_name: &str) -> Result> { + match arg { + Object::Tuple(shape) => shape + .iter() + .map(|x| match x { + Object::Int(i) => Ok(*i as usize), + _ => Err(PickleError::InvalidData( + "shape must contain ints".to_string(), + )), + }) + .collect::>>(), + _ => Err(PickleError::InvalidData(format!( + "{}: expected shape tuple got {:?}", + fn_name, arg + ))), + } +} + +/// Legacy _rebuild_tensor function for PyTorch < 1.6. +/// Thin wrapper that parses 4 arguments and calls rebuild_tensor_impl. +fn rebuild_tensor( + args: Object, + _memo: &mut HashMap, + data_source: &Option>, +) -> Result { + let args = if let Object::Tuple(args) = args { + args + } else { + return Err(PickleError::InvalidData(format!( + "rebuild_tensor: expected tuple got {:?}", + args + ))); + }; + + if args.len() < 4 { + return Err(PickleError::InvalidData(format!( + "rebuild_tensor: expected at least 4 args, got {}", + args.len() + ))); + } + + let (storage_info, storage_tuple) = parse_storage_arg(&args[0], "rebuild_tensor")?; + let storage_offset = match &args[1] { + Object::Int(offset) => *offset as usize, + _ => 0, + }; + let shape = parse_shape_arg(&args[2], "rebuild_tensor")?; + + rebuild_tensor_impl( + storage_info, + storage_tuple, + storage_offset, + shape, + data_source, + ) +} + +/// Modern _rebuild_tensor_v2 function for PyTorch >= 1.6. +/// Thin wrapper that parses 5+ arguments and calls rebuild_tensor_impl. +fn rebuild_tensor_v2( + args: Object, + _memo: &mut HashMap, + data_source: &Option>, +) -> Result { + let args = if let Object::Tuple(args) = args { + args + } else { + return Err(PickleError::InvalidData(format!( + "rebuild_tensor_v2: expected tuple got {:?}", + args + ))); + }; + + if args.len() < 5 { + return Err(PickleError::InvalidData(format!( + "rebuild_tensor_v2: expected at least 5 args, got {}", + args.len() + ))); + } + + let (storage_info, storage_tuple) = parse_storage_arg(&args[0], "rebuild_tensor_v2")?; + let storage_offset = match &args[1] { + Object::Int(offset) => *offset as usize, + _ => 0, + }; + let shape = parse_shape_arg(&args[2], "rebuild_tensor_v2")?; + // args[3] is stride (unused) + // args[4] is requires_grad (unused) + // args[5] is backward_hooks (unused) + + rebuild_tensor_impl( + storage_info, + storage_tuple, + storage_offset, + shape, + data_source, + ) +} + +/// Helper to convert storage type name to DType. +fn storage_type_to_dtype(storage_type: &str) -> Result { + match storage_type { + "FloatStorage" => Ok(DType::F32), + "DoubleStorage" => Ok(DType::F64), + "HalfStorage" => Ok(DType::F16), + "BFloat16Storage" => Ok(DType::BF16), + "LongStorage" => Ok(DType::I64), + "IntStorage" => Ok(DType::I32), + "ShortStorage" => Ok(DType::I16), + "CharStorage" => Ok(DType::I8), + "ByteStorage" => Ok(DType::U8), + "BoolStorage" => Ok(DType::Bool(BoolStore::Native)), + _ => Err(PickleError::InvalidData(format!( + "Unknown storage type: {}", + storage_type + ))), + } +} + +/// Core implementation for rebuilding tensors. +/// Shared by both rebuild_tensor (legacy) and rebuild_tensor_v2 (modern). +fn rebuild_tensor_impl( + storage_info: Vec, + storage_tuple: Option>, + storage_offset: usize, + shape: Vec, + data_source: &Option>, +) -> Result { + // Parse the storage info to extract dtype and storage key + // The persistent ID is typically a tuple like: ('storage', 'FloatStorage', '0', 'cpu', 4) + let (dtype, storage_key, storage_total_elements) = if let Some(tuple) = storage_tuple { + // Direct tuple access + if tuple.len() >= 3 { + let storage_type = match &tuple[1] { + Object::String(s) => s.as_str(), + Object::Class { + module_name: _, + name, + } => name.as_str(), + other => { + return Err(PickleError::InvalidData(format!( + "Expected storage type as String or Class, got {:?}", + other + ))); + } + }; + let dtype = storage_type_to_dtype(storage_type)?; + let key = match &tuple[2] { + Object::String(s) => s.clone(), + other => { + return Err(PickleError::InvalidData(format!( + "Expected storage key as String, got {:?}", + other + ))); + } + }; + // Extract total element count from index 4 + let total_elements = match tuple.get(4) { + Some(Object::Int(n)) => Some(*n as usize), + _ => None, + }; + (dtype, key, total_elements) + } else { + return Err(PickleError::InvalidData(format!( + "Storage tuple too short, expected at least 3 elements, got {}", + tuple.len() + ))); + } + } else if !storage_info.is_empty() { + // Legacy string-based parsing + let storage_str = String::from_utf8_lossy(&storage_info); + if storage_str.starts_with("Tuple(") { + // Parse from the debug representation we stored + let parts: Vec<&str> = storage_str + .trim_start_matches("Tuple(") + .trim_end_matches(")") + .split(", ") + .map(|s| { + let trimmed = s.trim_matches('"'); + if let Some(inner) = trimmed + .strip_prefix("Object::String(\"") + .and_then(|s| s.strip_suffix("\")")) + { + inner + } else { + trimmed + } + }) + .collect(); + + if parts.len() >= 3 { + let dtype = storage_type_to_dtype(parts[1])?; + (dtype, parts[2].to_string(), None) + } else { + return Err(PickleError::InvalidData(format!( + "Storage info tuple too short, expected at least 3 parts, got {}", + parts.len() + ))); + } + } else { + return Err(PickleError::InvalidData(format!( + "Invalid storage info format: {}", + storage_str + ))); + } + } else { + return Err(PickleError::InvalidData( + "No storage information available".to_string(), + )); + }; + + // If no data source, we can't load tensor data + let data_source = match data_source { + Some(ds) => ds.clone(), + None => { + return Err(PickleError::InvalidData( + "Cannot load tensor data without a data source".to_string(), + )); + } + }; + + // Create clones for the closure + let data_source_clone = data_source.clone(); + let shape_clone = shape.clone(); + + // Find the correct data file key + let data_file_key = { + let exact_key = format!("data/{}", storage_key); + if data_source.contains(&exact_key) { + exact_key + } else { + // Try other patterns + data_source + .keys() + .into_iter() + .find(|key| { + key.ends_with(&format!("/data/{}", storage_key)) + || (key.contains("/data/") && key.rsplit('/').next() == Some(&storage_key)) + }) + .unwrap_or_else(|| format!("data/{}", storage_key)) + } + }; + + // There is no guarantee that the storage size equals the python object size. + // The size of the object can be smaller than the storage (e.g., uncloned views + // of a tensor are saved to the .pt/.pth files). Thus, the actual storage size + // should be used, which can be computed using the total number of elements + // in the storage. + if let LazyDataSource::LegacyMultiStorage(ref source) = *data_source { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let bytes_needed = match storage_total_elements { + Some(total) => total * dtype.size(), + None => (storage_offset + shape.iter().product::()) * dtype.size(), + }; + source.track_storage_usage(&storage_key, 0, bytes_needed); + } + + // Create a TensorSnapshot with a closure that loads the actual data on-demand + Ok(Object::TorchParam(TensorSnapshot::from_closure( + Rc::new(move || { + // Load data only when needed + if let Ok(data) = data_source_clone.read(&data_file_key) { + // Parse the binary data based on dtype + let num_elements = shape_clone.iter().product::().max(1); + + // Use dtype.size() to get element size in bytes + let element_size = dtype.size(); + + // Apply storage offset + let offset_bytes = storage_offset * element_size; + if offset_bytes >= data.len() { + return Ok(TensorData::new( + vec![0.0f32; num_elements], + shape_clone.clone(), + )); + } + + let data_slice = &data[offset_bytes..]; + let available_elements = data_slice.len() / element_size; + let elements_to_read = num_elements.min(available_elements); + + // Convert bytes to the appropriate type + match dtype { + DType::F32 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let bytes = [ + data_slice[i * element_size], + data_slice[i * element_size + 1], + data_slice[i * element_size + 2], + data_slice[i * element_size + 3], + ]; + values.push(f32::from_le_bytes(bytes)); + } + // Pad with zeros if needed + values.resize(num_elements, 0.0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::F64 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(f64::from_le_bytes(bytes)); + } + values.resize(num_elements, 0.0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::I64 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(i64::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::I32 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 4]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(i32::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::I16 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 2]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(i16::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::I8 => { + let mut values = Vec::with_capacity(num_elements); + for &byte in data_slice.iter().take(elements_to_read) { + values.push(byte as i8); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::Bool(BoolStore::Native) => { + let mut values = Vec::with_capacity(num_elements); + for &byte in data_slice.iter().take(elements_to_read) { + values.push(byte != 0); + } + values.resize(num_elements, false); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::F16 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 2]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(f16::from_le_bytes(bytes)); + } + values.resize(num_elements, f16::ZERO); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::BF16 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 2]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(bf16::from_le_bytes(bytes)); + } + values.resize(num_elements, bf16::ZERO); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::U8 => { + let mut values = Vec::with_capacity(num_elements); + for &byte in data_slice.iter().take(elements_to_read) { + values.push(byte); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::U16 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 2]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(u16::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::U32 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 4]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(u32::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + DType::U64 => { + let mut values = Vec::with_capacity(num_elements); + for i in 0..elements_to_read { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice( + &data_slice[i * element_size..(i + 1) * element_size], + ); + values.push(u64::from_le_bytes(bytes)); + } + values.resize(num_elements, 0); + Ok(TensorData::new(values, shape_clone.clone())) + } + _ => { + // For any remaining unsupported types, return an error + Err(crate::TensorSnapshotError::DataError(format!( + "Unsupported dtype for tensor data reading: {:?}", + dtype + ))) + } + } + } else { + // If no data file found, return zeros of the appropriate type + let num_elements = shape_clone.iter().product::().max(1); + match dtype { + DType::F32 => Ok(TensorData::new( + vec![0.0f32; num_elements], + shape_clone.clone(), + )), + DType::F64 => Ok(TensorData::new( + vec![0.0f64; num_elements], + shape_clone.clone(), + )), + DType::F16 => Ok(TensorData::new( + vec![f16::ZERO; num_elements], + shape_clone.clone(), + )), + DType::BF16 => Ok(TensorData::new( + vec![bf16::ZERO; num_elements], + shape_clone.clone(), + )), + DType::I64 => Ok(TensorData::new( + vec![0i64; num_elements], + shape_clone.clone(), + )), + DType::I32 => Ok(TensorData::new( + vec![0i32; num_elements], + shape_clone.clone(), + )), + DType::I16 => Ok(TensorData::new( + vec![0i16; num_elements], + shape_clone.clone(), + )), + DType::I8 => Ok(TensorData::new( + vec![0i8; num_elements], + shape_clone.clone(), + )), + DType::U8 => Ok(TensorData::new( + vec![0u8; num_elements], + shape_clone.clone(), + )), + DType::U16 => Ok(TensorData::new( + vec![0u16; num_elements], + shape_clone.clone(), + )), + DType::U32 => Ok(TensorData::new( + vec![0u32; num_elements], + shape_clone.clone(), + )), + DType::U64 => Ok(TensorData::new( + vec![0u64; num_elements], + shape_clone.clone(), + )), + DType::Bool(BoolStore::Native) => Ok(TensorData::new( + vec![false; num_elements], + shape_clone.clone(), + )), + _ => { + // For any remaining unsupported types, return an error + Err(crate::TensorSnapshotError::DataError(format!( + "Unsupported dtype for tensor data reading: {:?}", + dtype + ))) + } + } + } + }), + dtype, + shape.into(), + vec![], // path_stack + vec![], // container_stack + ParamId::new(), // tensor_id + ))) +} + +pub struct Stack { + stack: Vec, + memo: HashMap, + data_source: Option>, +} + +impl Default for Stack { + fn default() -> Self { + Self::new() + } +} + +impl Stack { + pub fn new() -> Self { + // For cases where no data source is needed (pure pickle without tensor data) + Self { + stack: Vec::new(), + memo: HashMap::new(), + data_source: None, + } + } + + pub fn with_data_source(data_source: Arc) -> Self { + Self { + stack: Vec::new(), + memo: HashMap::new(), + data_source: Some(data_source), + } + } + + fn push(&mut self, o: Object) { + self.stack.push(o) + } + + fn pop(&mut self) -> Result { + match self.stack.pop() { + None => Err(PickleError::StackUnderflow), + Some(o) => Ok(o), + } + } + + fn top(&self) -> Result { + match self.stack.last() { + None => Err(PickleError::StackUnderflow), + Some(o) => Ok(o.clone()), + } + } + + fn pop_to_marker(&mut self) -> Result> { + let marker_pos = self + .stack + .iter() + .rposition(|o| { + matches!(o, Object::Class { module_name, name } + if module_name == "mark" && name == "mark") + }) + .ok_or(PickleError::InvalidData("marker not found".to_string()))?; + + let result = self.stack.split_off(marker_pos + 1); + self.stack.pop(); // Remove the marker + Ok(result) + } + + fn last_mut(&mut self) -> Result<&mut Object> { + match self.stack.last_mut() { + None => Err(PickleError::StackUnderflow), + Some(o) => Ok(o), + } + } + + fn push_mark(&mut self) { + self.stack.push(Object::Class { + module_name: "mark".to_string(), + name: "mark".to_string(), + }); + } + + fn memo_get(&self, idx: u32) -> Result { + self.memo + .get(&idx) + .cloned() + .ok_or(PickleError::MemoNotFound(idx)) + } + + fn memo_put(&mut self, idx: u32, obj: Object) { + self.memo.insert(idx, obj); + } + + fn memo_len(&self) -> usize { + self.memo.len() + } +} + +fn read_global(r: &mut R, stack: &mut Stack) -> Result<()> { + let module_name = buf_to_str(&read_to_newline(r)?)?; + let name = buf_to_str(&read_to_newline(r)?)?; + stack.push(Object::Class { module_name, name }); + Ok(()) +} + +fn read_long1(r: &mut R, stack: &mut Stack) -> Result<()> { + let len = r.read_u8()? as usize; + let mut data = vec![0u8; len]; + r.read_exact(&mut data)?; + // Handle little-endian signed integer + let mut value = 0i64; + for (i, &byte) in data.iter().enumerate().take(8) { + // Only process up to 8 bytes for i64, and use wrapping to avoid overflow + value |= (byte as i64).wrapping_shl((i as u32) * 8); + } + // Handle sign extension for negative numbers + if len < 8 && data.last().is_some_and(|&b| b & 0x80 != 0) { + // Sign extend + for i in len..8 { + value |= 0xffi64.wrapping_shl((i as u32) * 8); + } + } + stack.push(Object::Int(value)); + Ok(()) +} + +fn read_string(r: &mut R, stack: &mut Stack, len: usize) -> Result<()> { + let mut data = vec![0u8; len]; + r.read_exact(&mut data)?; + let s = buf_to_str(&data)?; + stack.push(Object::String(s)); + Ok(()) +} + +fn read_bin_int(r: &mut R, stack: &mut Stack) -> Result<()> { + let v = r.read_i32::()?; + stack.push(Object::Int(v as i64)); + Ok(()) +} + +fn read_int(r: &mut R, stack: &mut Stack) -> Result<()> { + // INT opcode reads an integer as ASCII string followed by newline + let line = read_to_newline(r)?; + let s = buf_to_str(&line)?; + let v = s + .parse::() + .map_err(|e| PickleError::InvalidData(format!("Invalid INT value '{}': {}", s, e)))?; + stack.push(Object::Int(v)); + Ok(()) +} + +fn read_bin_int1(r: &mut R, stack: &mut Stack) -> Result<()> { + let v = r.read_u8()?; + stack.push(Object::Int(v as i64)); + Ok(()) +} + +fn read_bin_int2(r: &mut R, stack: &mut Stack) -> Result<()> { + let v = r.read_u16::()?; + stack.push(Object::Int(v as i64)); + Ok(()) +} + +fn read_bin_float(r: &mut R, stack: &mut Stack) -> Result<()> { + // Python's BINFLOAT uses big-endian encoding + let v = r.read_f64::()?; + stack.push(Object::Float(v)); + Ok(()) +} + +pub fn read_pickle(r: &mut R) -> Result { + // For pure pickle without tensor data, no data source is needed + read_pickle_with_optional_data(r, None) +} + +/// Skip over a pickle without parsing it fully +/// This is useful for legacy format where we need to skip the main object +/// that contains tensors but we don't have a data source yet +pub fn skip_pickle(r: &mut R) -> Result<()> { + // Read the protocol marker if present + let mut first_byte = [0u8; 1]; + r.read_exact(&mut first_byte)?; + + if first_byte[0] == 0x80 { + // PROTO marker - read protocol version + let mut proto_version = [0u8; 1]; + r.read_exact(&mut proto_version)?; + } + // If not PROTO, the first byte is an opcode - continue to main loop + + // Helper to skip until newline + fn skip_line(r: &mut R) -> Result<()> { + let mut buf = Vec::new(); + r.read_until(b'\n', &mut buf)?; + Ok(()) + } + + // Helper to skip length-prefixed data + fn skip_length_prefixed(r: &mut R, length: usize) -> Result<()> { + let mut skip_buf = vec![0u8; length.min(8192)]; + let mut skipped = 0; + while skipped < length { + let to_skip = (length - skipped).min(skip_buf.len()); + r.read_exact(&mut skip_buf[..to_skip])?; + skipped += to_skip; + } + Ok(()) + } + + // Process first byte if it wasn't PROTO + let mut pending_byte = if first_byte[0] != 0x80 { + Some(first_byte[0]) + } else { + None + }; + + // Scan until we find STOP (0x2e) opcode + loop { + let byte = if let Some(b) = pending_byte.take() { + b + } else { + let mut byte = [0u8; 1]; + r.read_exact(&mut byte)?; + byte[0] + }; + + match byte { + 0x2e => { + // STOP - end of pickle + break; + } + // === Newline-terminated string opcodes === + 0x63 => { + // GLOBAL - two newline-terminated strings (module\nname\n) + skip_line(r)?; + skip_line(r)?; + } + 0x69 => { + // INST - two newline-terminated strings + skip_line(r)?; + skip_line(r)?; + } + 0x53 => { + // STRING - quoted string ending with newline + skip_line(r)?; + } + 0x46 | 0x49 | 0x4c => { + // FLOAT, INT, LONG - newline-terminated ASCII + skip_line(r)?; + } + 0x50 => { + // PERSID - newline-terminated persistent ID + skip_line(r)?; + } + // === Length-prefixed binary opcodes === + 0x58 | 0x42 | 0x43 | 0x54 | 0x55 | 0x56 | 0x8c | 0x8d | 0x8e => { + // String/bytes opcodes with length prefixes + let length = match byte { + 0x43 | 0x55 | 0x8c => { + // SHORT versions - 1 byte length + let mut len_byte = [0u8; 1]; + r.read_exact(&mut len_byte)?; + len_byte[0] as usize + } + 0x42 | 0x54 | 0x58 | 0x56 => { + // Regular versions - 4 byte length + let mut len_bytes = [0u8; 4]; + r.read_exact(&mut len_bytes)?; + u32::from_le_bytes(len_bytes) as usize + } + 0x8d | 0x8e => { + // 8-byte length versions + let mut len_bytes = [0u8; 8]; + r.read_exact(&mut len_bytes)?; + u64::from_le_bytes(len_bytes) as usize + } + _ => 0, + }; + skip_length_prefixed(r, length)?; + } + // === Fixed-size integer opcodes === + 0x4b => { + // BININT1 - 1 byte + let mut buf = [0u8; 1]; + r.read_exact(&mut buf)?; + } + 0x4d => { + // BININT2 - 2 bytes + let mut buf = [0u8; 2]; + r.read_exact(&mut buf)?; + } + 0x4a => { + // BININT - 4 bytes (signed int) + let mut buf = [0u8; 4]; + r.read_exact(&mut buf)?; + } + 0x47 => { + // BINFLOAT - 8 bytes + let mut buf = [0u8; 8]; + r.read_exact(&mut buf)?; + } + // === Variable-length integer opcodes === + 0x8a => { + // LONG1 - 1 byte length, then that many bytes + let mut len_byte = [0u8; 1]; + r.read_exact(&mut len_byte)?; + let length = len_byte[0] as usize; + skip_length_prefixed(r, length)?; + } + 0x8b => { + // LONG4 - 4 byte length, then that many bytes + let mut len_bytes = [0u8; 4]; + r.read_exact(&mut len_bytes)?; + let length = u32::from_le_bytes(len_bytes) as usize; + skip_length_prefixed(r, length)?; + } + // === Memo opcodes === + 0x71 | 0x68 => { + // BINPUT, BINGET - 1 byte index + let mut buf = [0u8; 1]; + r.read_exact(&mut buf)?; + } + 0x72 | 0x6a => { + // LONG_BINPUT, LONG_BINGET - 4 byte index + let mut buf = [0u8; 4]; + r.read_exact(&mut buf)?; + } + 0x67 | 0x70 => { + // GET, PUT - newline-terminated decimal index + skip_line(r)?; + } + // === Extension opcodes === + 0x82 => { + // EXT1 - 1 byte code + let mut buf = [0u8; 1]; + r.read_exact(&mut buf)?; + } + 0x83 => { + // EXT2 - 2 byte code + let mut buf = [0u8; 2]; + r.read_exact(&mut buf)?; + } + 0x84 => { + // EXT4 - 4 byte code + let mut buf = [0u8; 4]; + r.read_exact(&mut buf)?; + } + // === Frame opcode (protocol 4+) === + 0x95 => { + // FRAME - 8 byte frame size (we don't actually use framing, just skip the size) + let mut buf = [0u8; 8]; + r.read_exact(&mut buf)?; + } + // === Opcodes with no additional data === + // These just manipulate the stack or are markers + 0x28 | 0x29 | 0x30 | 0x31 | 0x32 | // MARK, TUPLE, POP, POP_MARK, DUP + 0x4e | 0x52 | 0x5d | 0x5b | 0x7d | // NONE, REDUCE, LIST, EMPTY_LIST, EMPTY_DICT + 0x61 | 0x62 | 0x64 | 0x65 | 0x73 | // APPEND, BUILD, DICT, APPENDS, SETITEM + 0x74 | 0x75 | 0x85 | 0x86 | 0x87 | // TUPLE, SETITEMS, TUPLE1, TUPLE2, TUPLE3 + 0x88 | 0x89 | 0x8f | 0x90 | 0x91 | // NEWTRUE, NEWFALSE, STACK_GLOBAL, MEMOIZE, EMPTY_SET + 0x92 | 0x93 | 0x94 | 0x51 | 0x81 => { // ADDITEMS, FROZENSET, NEWOBJ, BINPERSID, NEWOBJ_EX + // No additional data to skip + } + _ => { + // Unknown opcode - assume no additional data + // This is a best-effort approach + } + } + } + + Ok(()) +} + +pub fn read_pickle_with_data( + r: &mut R, + data_source: Arc, +) -> Result { + read_pickle_with_optional_data(r, Some(data_source)) +} + +fn get_dict_key(obj: Object) -> Result { + match obj { + Object::String(s) => Ok(s), + Object::Int(i) => Ok(i.to_string()), + _ => Err(PickleError::InvalidData(format!( + "dict key must be a valid type, got {obj:?}" + ))), + } +} + +pub fn read_pickle_with_optional_data( + r: &mut R, + data_source: Option>, +) -> Result { + let mut stack = match data_source { + Some(ds) => Stack::with_data_source(ds), + None => Stack::new(), + }; + loop { + let op_code = r.read_u8()?; + let op_code = OpCode::try_from(op_code).map_err(PickleError::InvalidOpCode)?; + match op_code { + OpCode::Proto => { + let version = r.read_u8()?; + if version > 5 { + return Err(PickleError::InvalidProtocol(version)); + } + } + OpCode::Global => read_global(r, &mut stack)?, + OpCode::BinInt => read_bin_int(r, &mut stack)?, + OpCode::Int => read_int(r, &mut stack)?, + OpCode::BinInt1 => read_bin_int1(r, &mut stack)?, + OpCode::BinInt2 => read_bin_int2(r, &mut stack)?, + OpCode::BinFloat => read_bin_float(r, &mut stack)?, + OpCode::BinUnicode => { + let len = r.read_u32::()? as usize; + read_string(r, &mut stack, len)? + } + OpCode::ShortBinString => { + let len = r.read_u8()? as usize; + read_string(r, &mut stack, len)? + } + OpCode::Long1 => read_long1(r, &mut stack)?, + OpCode::None => stack.push(Object::None), + OpCode::NewTrue => stack.push(Object::Bool(true)), + OpCode::NewFalse => stack.push(Object::Bool(false)), + OpCode::EmptyTuple => stack.push(Object::Tuple(Vec::new())), + OpCode::EmptyList => stack.push(Object::List(Vec::new())), + OpCode::EmptyDict => stack.push(Object::Dict(HashMap::new())), + OpCode::Tuple => { + let objs = stack.pop_to_marker()?; + stack.push(Object::Tuple(objs)) + } + OpCode::Tuple1 => { + let obj = stack.pop()?; + stack.push(Object::Tuple(vec![obj])) + } + OpCode::Tuple2 => { + let obj2 = stack.pop()?; + let obj1 = stack.pop()?; + stack.push(Object::Tuple(vec![obj1, obj2])) + } + OpCode::Tuple3 => { + let obj3 = stack.pop()?; + let obj2 = stack.pop()?; + let obj1 = stack.pop()?; + stack.push(Object::Tuple(vec![obj1, obj2, obj3])) + } + OpCode::Append => { + let value = stack.pop()?; + match stack.last_mut()? { + Object::List(list) => list.push(value), + _ => return Err(PickleError::UnexpectedOpCode(op_code)), + } + } + OpCode::Appends => { + let objs = stack.pop_to_marker()?; + match stack.last_mut()? { + Object::List(list) => list.extend(objs), + _ => return Err(PickleError::UnexpectedOpCode(op_code)), + } + } + OpCode::SetItem => { + let value = stack.pop()?; + let key = stack.pop()?; + match stack.last_mut()? { + Object::Dict(dict) => { + if let Object::String(key) = key { + dict.insert(key, value); + } else { + return Err(PickleError::InvalidData( + "dict key must be a string".to_string(), + )); + } + } + _ => return Err(PickleError::UnexpectedOpCode(op_code)), + } + } + OpCode::SetItems => { + let mut objs = stack.pop_to_marker()?; + if objs.len() % 2 != 0 { + return Err(PickleError::InvalidData( + "setitems requires even number of objects".to_string(), + )); + } + match stack.last_mut()? { + Object::Dict(dict) => { + while !objs.is_empty() { + let key = objs.remove(0); + let value = objs.remove(0); + let key = get_dict_key(key)?; + dict.insert(key, value); + } + } + _ => return Err(PickleError::UnexpectedOpCode(op_code)), + } + } + OpCode::BinPut => { + let idx = r.read_u8()? as u32; + let obj = stack.top()?; + stack.memo_put(idx, obj); + } + OpCode::LongBinPut => { + let idx = r.read_u32::()?; + let obj = stack.top()?; + stack.memo_put(idx, obj); + } + OpCode::BinGet => { + let idx = r.read_u8()? as u32; + let obj = stack.memo_get(idx)?; + stack.push(obj); + } + OpCode::LongBinGet => { + let idx = r.read_u32::()?; + let obj = stack.memo_get(idx)?; + stack.push(obj); + } + OpCode::Mark => stack.push_mark(), + OpCode::BinPersId => { + let pid = stack.pop()?; + match pid { + Object::String(s) => { + stack.push(Object::Persistent(s.into_bytes())); + } + Object::Tuple(tuple) => { + // The persistent ID is a tuple (e.g., ('storage', 'FloatStorage', '0', 'cpu', 4)) + // Store it as a PersistentTuple for proper handling + stack.push(Object::PersistentTuple(tuple)); + } + _ => { + return Err(PickleError::InvalidData(format!( + "persistent id must be a string or tuple, got {:?}", + pid + ))); + } + } + } + OpCode::Reduce => { + let args = stack.pop()?; + let callable = stack.pop()?; + + // Check if this is an OrderedDict + if let Object::Class { module_name, name } = &callable { + if module_name == "collections" && name == "OrderedDict" { + // OrderedDict can be created with items: OrderedDict([(key1, val1), ...]) + // The args is typically a tuple containing a list of [key, value] pairs + let mut dict = HashMap::new(); + + // Extract items from args + let items = match &args { + Object::Tuple(tuple) if !tuple.is_empty() => { + // Args is a tuple, get the first element (the list of items) + match &tuple[0] { + Object::List(list) => Some(list.clone()), + _ => None, + } + } + Object::List(list) => Some(list.clone()), + _ => None, + }; + + if let Some(items) = items { + for item in items { + // Each item is a list/tuple of [key, value] + match item { + Object::List(pair) | Object::Tuple(pair) if pair.len() >= 2 => { + if let Object::String(key) = &pair[0] { + dict.insert(key.clone(), pair[1].clone()); + } + } + _ => {} + } + } + } + + stack.push(Object::Dict(dict)); + } else { + let _obj = Object::Reduce { + callable: Box::new(callable.clone()), + args: Box::new(args.clone()), + }; + let obj = rebuild_from_type_v2( + Object::Tuple(vec![callable, args]), + &mut stack.memo, + &stack.data_source, + )?; + stack.push(obj); + } + } else { + let _obj = Object::Reduce { + callable: Box::new(callable.clone()), + args: Box::new(args.clone()), + }; + let obj = rebuild_from_type_v2( + Object::Tuple(vec![callable, args]), + &mut stack.memo, + &stack.data_source, + )?; + stack.push(obj); + } + } + OpCode::Build => { + let args = stack.pop()?; + let obj = stack.pop()?; + match obj { + Object::Dict(mut dict) => { + // For dicts, BUILD updates with the args + if let Object::Dict(update) = args { + dict.extend(update); + } + stack.push(Object::Dict(dict)); + } + _ => { + stack.push(Object::Build { + callable: Box::new(obj), + args: Box::new(args), + }); + } + } + } + OpCode::NewObj => { + let args = stack.pop()?; + let cls = stack.pop()?; + stack.push(Object::Reduce { + callable: Box::new(cls), + args: Box::new(args), + }); + } + OpCode::Dict => { + let objs = stack.pop_to_marker()?; + let mut dict = HashMap::new(); + if objs.len() % 2 != 0 { + return Err(PickleError::InvalidData( + "dict requires even number of objects".to_string(), + )); + } + for chunk in objs.chunks(2) { + let key = get_dict_key(chunk[0].clone())?; + dict.insert(key, chunk[1].clone()); + } + stack.push(Object::Dict(dict)); + } + OpCode::List => { + let objs = stack.pop_to_marker()?; + stack.push(Object::List(objs)); + } + OpCode::Memoize => { + // Store top of stack in memo without popping + // The memo index is the current number of items in the memo + let obj = stack.top()?; + let idx = stack.memo_len() as u32; + stack.memo_put(idx, obj); + } + OpCode::Stop => break, + } + } + stack.pop() +} + +/// Load tensors from a pickle file (PyTorch checkpoint format) +pub fn read_pickle_tensors(reader: &mut R) -> Result> { + let obj = read_pickle(reader)?; + + // Extract tensors from the loaded object + let mut tensors = HashMap::new(); + let mut path = Vec::new(); + extract_tensors(&obj, &mut path, &mut tensors); + + Ok(tensors) +} + +fn extract_tensors<'a>( + obj: &'a Object, + path: &mut Vec<&'a str>, + tensors: &mut HashMap, +) { + match obj { + Object::Dict(dict) => { + for (key, value) in dict { + path.push(key); + extract_tensors(value, path, tensors); + path.pop(); + } + } + Object::TorchParam(snapshot) => { + // Only allocate the string here when we actually insert + tensors.insert(path.join("."), snapshot.clone()); + } + _ => {} + } +} diff --git a/crates/burn-store/src/pytorch/reader.rs b/crates/burn-store/src/pytorch/reader.rs new file mode 100644 index 0000000..8b3f4d1 --- /dev/null +++ b/crates/burn-store/src/pytorch/reader.rs @@ -0,0 +1,1125 @@ +//! PyTorch file reader implementation. +//! +//! This module provides support for reading PyTorch checkpoint files (.pt/.pth). +//! +//! # Supported Formats +//! +//! ## 1. Modern ZIP Format (PyTorch 1.6+) +//! Files are ZIP archives containing: +//! - `data.pkl` or `archive/data.pkl`: Pickled tensor metadata +//! - `data/` directory: Binary tensor data files +//! +//! ## 2. TAR Format (older torchvision models like AlexNet, SqueezeNet) +//! TAR archives containing: +//! - `sys_info`: System info pickle (endianness, type sizes) +//! - `pickle`: OrderedDict mapping tensor names to storage keys +//! - `tensors`: Tensor metadata (unused, metadata is in pickle) +//! - `storages`: Count pickle + sequential (metadata, num_elements, raw data) +//! +//! ## 3. Legacy Pickle Format (PyTorch 0.1.10 - 1.5) +//! Sequential pickle streams with the structure: +//! - Magic number pickle (0x1950a86a20f9469cfc6c) +//! - Protocol version pickle (e.g., 1001) +//! - System info pickle (endianness, type sizes) +//! - Model data pickle (state_dict or full model) +//! +//! ## 4. Simple Pickle Format +//! Direct pickle file with a dictionary at the root, commonly used for +//! manually saved state_dicts. +//! +//! # Compatibility +//! +//! The reader handles backward compatibility by detecting the file format +//! automatically. Files from PyTorch 0.1.10 through current versions are +//! supported, though full model saves (vs state_dict) may have limitations +//! as they contain Python code references. + +use crate::TensorSnapshot; +use crate::nested::{adapter::DefaultAdapter, data::NestedValue, de::Deserializer}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::path::Path; + +use super::lazy_data::LazyDataSource; +use super::pickle_reader::{Object, PickleError, read_pickle, read_pickle_with_data}; +use std::sync::Arc; + +/// Error type for PyTorch file operations +#[derive(Debug)] +pub enum PytorchError { + /// IO error + Io(std::io::Error), + /// Pickle parsing error + Pickle(PickleError), + /// Zip archive error + Zip(zip::result::ZipError), + /// TAR archive error + Tar(std::io::Error), + /// Invalid file format + InvalidFormat(String), + /// Key not found + KeyNotFound(String), + /// Serde deserialization error + Serde(crate::nested::error::Error), +} + +impl From for PytorchError { + fn from(e: std::io::Error) -> Self { + PytorchError::Io(e) + } +} + +impl From for PytorchError { + fn from(e: PickleError) -> Self { + PytorchError::Pickle(e) + } +} + +impl From for PytorchError { + fn from(e: zip::result::ZipError) -> Self { + PytorchError::Zip(e) + } +} + +impl From for PytorchError { + fn from(e: crate::nested::error::Error) -> Self { + PytorchError::Serde(e) + } +} + +impl std::fmt::Display for PytorchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PytorchError::Io(e) => write!(f, "IO error: {}", e), + PytorchError::Pickle(e) => write!( + f, + "Pickle parsing error: {}. This may indicate an unsupported PyTorch file format or corrupted file.", + e + ), + PytorchError::Zip(e) => write!(f, "Zip archive error: {}", e), + PytorchError::Tar(e) => write!(f, "TAR archive error: {}", e), + PytorchError::InvalidFormat(msg) => write!(f, "Invalid PyTorch file format: {}", msg), + PytorchError::KeyNotFound(key) => write!( + f, + "Key '{}' not found in PyTorch file. Available keys may be listed with the keys() method.", + key + ), + PytorchError::Serde(e) => write!(f, "Serde deserialization error: {}", e), + } + } +} + +impl std::error::Error for PytorchError {} + +type Result = std::result::Result; + +/// Metadata about a PyTorch file +/// +/// Contains information about the file format, version, and other properties +/// that can be useful for debugging or compatibility checking. +#[derive(Debug, Clone)] +pub struct PytorchMetadata { + /// Format version (e.g., "1.0" for modern ZIP format) + pub format_version: Option, + /// File format type (ZIP, Legacy, or Pickle) + pub format_type: FileFormat, + /// Byte order (endianness) - currently only LittleEndian is supported + pub byte_order: ByteOrder, + /// Whether the file has storage alignment information + pub has_storage_alignment: bool, + /// PyTorch version that saved the file (if available) + pub pytorch_version: Option, + /// Number of tensors in the file + pub tensor_count: usize, + /// Total size of tensor data in bytes (if available) + pub total_data_size: Option, +} + +impl PytorchMetadata { + /// Check if this is a modern format file (ZIP-based, PyTorch 1.6+) + pub fn is_modern_format(&self) -> bool { + matches!(self.format_type, FileFormat::Zip) + } + + /// Check if this is a legacy format file (PyTorch 0.1.10 - 1.5) + pub fn is_legacy_format(&self) -> bool { + matches!(self.format_type, FileFormat::Legacy) + } +} + +/// File format type +#[derive(Debug, Clone, PartialEq)] +pub enum FileFormat { + /// ZIP-based format (PyTorch 1.6+) + Zip, + /// TAR-based format (older torchvision models) + Tar, + /// Legacy format (PyTorch 0.1.10 - 1.5) + Legacy, + /// Simple pickle file + Pickle, +} + +/// Byte order (endianness) +#[derive(Debug, Clone, PartialEq)] +pub enum ByteOrder { + LittleEndian, + BigEndian, +} + +/// PyTorch checkpoint reader +/// +/// This is the main interface for reading PyTorch checkpoint files (.pt/.pth). +/// It supports multiple PyTorch formats including modern ZIP-based format (1.6+), +/// legacy format (0.1.10-1.5), and simple pickle files. +/// +/// # Example +/// ```rust,no_run +/// # use burn_store::pytorch::PytorchReader; +/// # fn example() -> Result<(), Box> { +/// // Load a checkpoint file +/// let reader = PytorchReader::new("model.pt")?; +/// +/// // Get tensor names +/// let keys = reader.keys(); +/// +/// // Access a specific tensor +/// if let Some(tensor) = reader.get("conv1.weight") { +/// let data = tensor.to_data(); // Materializes the tensor +/// } +/// +/// // Check file metadata +/// println!("Format: {:?}", reader.metadata().format_type); +/// println!("Tensor count: {}", reader.metadata().tensor_count); +/// # Ok(()) +/// # } +/// ``` +pub struct PytorchReader { + tensors: HashMap, + metadata: PytorchMetadata, +} + +impl PytorchReader { + /// Load a PyTorch checkpoint file + /// + /// # Arguments + /// * `path` - Path to the PyTorch file (.pt or .pth) + /// + /// # Returns + /// A `PytorchReader` with lazy-loaded tensors and metadata + pub fn new>(path: P) -> Result { + let (tensors, metadata) = load_pytorch_file_with_metadata(path.as_ref(), None)?; + Ok(Self { tensors, metadata }) + } + + /// Load a PyTorch checkpoint with a specific top-level key + /// + /// Many PyTorch checkpoints store the model weights under a specific key + /// like "state_dict", "model", or "model_state_dict". + /// + /// # Arguments + /// * `path` - Path to the PyTorch file + /// * `key` - Top-level key to extract (e.g., "state_dict") + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::pytorch::PytorchReader; + /// # fn example() -> Result<(), Box> { + /// let reader = PytorchReader::with_top_level_key("checkpoint.pt", "state_dict")?; + /// # Ok(()) + /// # } + /// ``` + pub fn with_top_level_key>(path: P, key: &str) -> Result { + let (tensors, metadata) = load_pytorch_file_with_metadata(path.as_ref(), Some(key))?; + Ok(Self { tensors, metadata }) + } + + /// Load from a reader + /// + /// This method is useful when loading from non-file sources like memory buffers. + /// Note: Metadata detection is limited when loading from a reader. + /// + /// # Arguments + /// * `reader` - Any type implementing `Read` + /// * `top_level_key` - Optional key to extract + pub fn from_reader(reader: R, top_level_key: Option<&str>) -> Result { + // For reader-based loading, we don't have full metadata access + let tensors = load_from_reader(reader, top_level_key)?; + let metadata = PytorchMetadata { + format_version: None, + format_type: FileFormat::Pickle, // Default assumption + byte_order: ByteOrder::LittleEndian, + has_storage_alignment: false, + pytorch_version: None, + tensor_count: tensors.len(), + total_data_size: None, + }; + Ok(Self { tensors, metadata }) + } + + /// Get all tensor names + pub fn keys(&self) -> Vec { + self.tensors.keys().cloned().collect() + } + + /// Get a tensor by name + pub fn get(&self, name: &str) -> Option<&TensorSnapshot> { + self.tensors.get(name) + } + + /// Get all tensors + pub fn tensors(&self) -> &HashMap { + &self.tensors + } + + /// Take ownership of all tensors + pub fn into_tensors(self) -> HashMap { + self.tensors + } + + /// Get metadata about the loaded file + /// + /// Provides information about the file format, version, endianness, etc. + pub fn metadata(&self) -> &PytorchMetadata { + &self.metadata + } + + /// Get the number of tensors in the file + pub fn len(&self) -> usize { + self.tensors.len() + } + + /// Check if the file contains no tensors + pub fn is_empty(&self) -> bool { + self.tensors.is_empty() + } + + /// Read raw pickle data from a PyTorch file + /// + /// This is useful for extracting configuration or metadata that isn't tensor data. + /// Returns a simplified JSON-like structure that can be easily converted to other formats. + /// + /// # Arguments + /// * `path` - Path to the PyTorch file + /// * `top_level_key` - Optional key to extract from the top-level dictionary + /// + /// # Returns + /// A `PickleValue` representing the pickle data structure + pub fn read_pickle_data>( + path: P, + top_level_key: Option<&str>, + ) -> Result { + read_pickle_as_value(path.as_ref(), top_level_key) + } + + /// Load and deserialize configuration data from a PyTorch file + /// + /// This method reads configuration or metadata stored in PyTorch checkpoint files + /// and deserializes it into the specified type. It's particularly useful for + /// extracting model configurations that might be saved alongside model weights. + /// + /// # Arguments + /// * `path` - Path to the PyTorch file (.pt or .pth) + /// * `top_level_key` - Optional key to extract specific data within the pickle file. + /// If `None`, the entire content is deserialized. + /// + /// # Type Parameters + /// * `D` - The target type to deserialize into. Must implement `DeserializeOwned`. + /// + /// # Returns + /// A `Result` containing the deserialized configuration data, or an `Error` if + /// reading or deserialization fails. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::pytorch::PytorchReader; + /// # use serde::Deserialize; + /// # fn example() -> Result<(), Box> { + /// #[derive(Debug, Deserialize)] + /// struct ModelConfig { + /// hidden_size: usize, + /// num_layers: usize, + /// } + /// + /// let config: ModelConfig = PytorchReader::load_config("model.pth", Some("config"))?; + /// # Ok(()) + /// # } + /// ``` + pub fn load_config(path: P, top_level_key: Option<&str>) -> Result + where + D: DeserializeOwned, + P: AsRef, + { + // Read the PyTorch file and extract the pickle data + let pickle_value = Self::read_pickle_data(path, top_level_key)?; + + // Convert PickleValue to NestedValue + let nested_value = convert_pickle_to_nested_value(pickle_value)?; + + // Create a deserializer with the default adapter + let deserializer = Deserializer::::new(nested_value, false); + + // Deserialize the nested value into the target type + let value = D::deserialize(deserializer)?; + Ok(value) + } +} + +/// Simplified representation of pickle data +/// +/// This enum provides a JSON-like structure that's easier to work with +/// than the internal pickle Object type. +#[derive(Debug, Clone, PartialEq)] +pub enum PickleValue { + /// None/null value + None, + /// Boolean value + Bool(bool), + /// Integer value + Int(i64), + /// Floating point value + Float(f64), + /// String value + String(String), + /// List/array of values + List(Vec), + /// Dictionary/map of string keys to values + Dict(HashMap), + /// Binary data + Bytes(Vec), +} + +/// Internal function to load a PyTorch file with metadata +fn load_pytorch_file_with_metadata( + path: &Path, + top_level_key: Option<&str>, +) -> Result<(HashMap, PytorchMetadata)> { + // First, try to read as a zip file + if let Ok(file) = File::open(path) + && let Ok(mut archive) = zip::ZipArchive::new(BufReader::new(file)) + { + // PyTorch saves the main data in various locations within the zip + let mut pickle_data = Vec::new(); + let mut pickle_found = false; + + // Try different common pickle file locations + let possible_pickle_paths = [ + "data.pkl", + "archive/data.pkl", + // Look for any .pkl file in the root or first-level directories + ]; + + for pickle_path in &possible_pickle_paths { + if archive.by_name(pickle_path).is_ok() { + let mut pickle_file = archive.by_name(pickle_path)?; + pickle_file.read_to_end(&mut pickle_data)?; + pickle_found = true; + break; + } + } + + // If not found in standard locations, search for any .pkl file + if !pickle_found { + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + drop(file); // Release the borrow + + if name.ends_with("data.pkl") { + let mut file = archive.by_index(i)?; + file.read_to_end(&mut pickle_data)?; + pickle_found = true; + break; + } + } + } + + if !pickle_found { + return Err(PytorchError::InvalidFormat( + "No data.pkl file found in ZIP archive. Expected PyTorch 1.6+ format with data.pkl or archive/data.pkl".to_string(), + )); + } + + // Check for format version (optional) + let format_version = if let Ok(mut version_file) = archive.by_name(".format_version") { + let mut version_data = Vec::new(); + version_file.read_to_end(&mut version_data)?; + let version_str = String::from_utf8_lossy(&version_data); + let version = version_str.trim().to_string(); + Some(version) + } else { + None + }; + + // Check for byteorder file to detect endianness + let is_big_endian = if let Ok(mut byteorder_file) = archive.by_name("byteorder") { + let mut byteorder_data = Vec::new(); + byteorder_file.read_to_end(&mut byteorder_data)?; + let byteorder_str = String::from_utf8_lossy(&byteorder_data); + byteorder_str.trim() == "big" + } else { + false // Default to little-endian if no byteorder file + }; + + if is_big_endian { + // Big-endian files are not yet supported as they require different byte order conversion + // TODO: To support big-endian files, we need to: + // 1. Pass endianness info through to pickle_reader + // 2. Use from_be_bytes instead of from_le_bytes for tensor data + // 3. Handle byte swapping for all numeric types (f32, f64, i32, etc.) + return Err(PytorchError::InvalidFormat( + "Big-endian PyTorch files are not yet supported. The file was saved on a big-endian system and requires byte order conversion.".to_string() + )); + } + + // Check for storage alignment file + let has_storage_alignment = archive.by_name(".storage_alignment").is_ok(); + + // Check for PyTorch version (if saved) + let pytorch_version = if let Ok(mut version_file) = archive.by_name("version") { + let mut version_data = Vec::new(); + version_file.read_to_end(&mut version_data)?; + Some(String::from_utf8_lossy(&version_data).trim().to_string()) + } else { + None + }; + + // Create a lazy data source instead of loading all data upfront + let data_source = Arc::new(LazyDataSource::from_zip(path)?); + + // Calculate total data size without loading + let mut total_data_size = 0usize; + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name(); + + // Look for data files - they can be in various locations + let is_data_file = (name.contains("/data/") + || name.starts_with("data/") + || name.starts_with("archive/data/")) + && !name.ends_with(".pkl") + && !name.ends_with("/"); + + if is_data_file { + total_data_size += file.size() as usize; + } + } + + // Parse the pickle data with lazy data source + let mut pickle_reader = BufReader::new(pickle_data.as_slice()); + let obj = read_pickle_with_data(&mut pickle_reader, data_source)?; + + // Extract tensors with their data + let tensors = extract_tensors_with_data(obj, top_level_key)?; + + // Create metadata + let metadata = PytorchMetadata { + format_version, + format_type: FileFormat::Zip, + byte_order: if is_big_endian { + ByteOrder::BigEndian + } else { + ByteOrder::LittleEndian + }, + has_storage_alignment, + pytorch_version, + tensor_count: tensors.len(), + total_data_size: Some(total_data_size), + }; + + return Ok((tensors, metadata)); + } + + // If not a zip or zip reading failed, try TAR format + if is_tar_file(path) { + return load_tar_pytorch_file_with_metadata(path, top_level_key); + } + + // Try reading as a plain pickle file + let mut file = File::open(path)?; + + // Check for PyTorch legacy format (starts with magic number as pickled integer) + let mut header = [0u8; 15]; + // Use read() instead of read_exact() to handle files smaller than 15 bytes + let bytes_read = file.read(&mut header)?; + file.seek(std::io::SeekFrom::Start(0))?; + + // Only check for legacy format if we have enough bytes + // PyTorch legacy format detection (PyTorch 0.1.10 - 1.3) + // Reference: https://github.com/pytorch/pytorch/blob/main/torch/serialization.py#L65 + // + // These files use sequential pickle streams with metadata before the actual data. + // Format structure: + // 1. Magic number (0x1950a86a20f9469cfc6c) stored as LONG1 pickle + // 2. Protocol version (e.g., 1001) + // 3. System info dict (protocol_version, little_endian, type_sizes) + // 4. Actual model data (state_dict or full model) + // 5. Storage keys list (pickle) + // 6. Raw binary data for each storage + // + // The pattern is: 0x80 0x02 0x8a 0x0a (PROTO 2, LONG1 with 10 bytes) + // followed by 10 bytes of magic number (little-endian), then 0x2e (STOP) + let is_legacy_format = bytes_read >= 15 + && header[0] == 0x80 // PROTO opcode + && header[1] == 0x02 // Protocol version 2 + && header[2] == 0x8a // LONG1 opcode + && header[3] == 0x0a // 10 bytes follow + // Magic number 0x1950a86a20f9469cfc6c in little-endian + && header[4] == 0x6c + && header[5] == 0xfc + && header[6] == 0x9c + && header[7] == 0x46 + && header[8] == 0xf9 + && header[9] == 0x20 + && header[10] == 0x6a + && header[11] == 0xa8 + && header[12] == 0x50 + && header[13] == 0x19 + && header[14] == 0x2e; // STOP opcode + + if is_legacy_format { + return load_legacy_pytorch_file_with_metadata(path, top_level_key); + } + + // Standard pickle file + // This might be a pickle with tensor references, so we need to handle that case + // For plain pickle files without a separate data section, we can't use lazy loading + // so we'll just create empty placeholder tensors for the structure + let file = File::open(path)?; + let mut reader = BufReader::new(file); + + // Try reading without data source first + match read_pickle(&mut reader) { + Ok(obj) => { + let tensors = extract_tensors_with_data(obj, top_level_key)?; + let tensor_count = tensors.len(); + Ok(( + tensors, + PytorchMetadata { + format_version: None, + format_type: FileFormat::Pickle, + byte_order: ByteOrder::LittleEndian, + has_storage_alignment: false, + pytorch_version: None, + tensor_count, + total_data_size: None, + }, + )) + } + Err(e) + if e.to_string() + .contains("Cannot load tensor data without a data source") => + { + // This pickle file contains tensor data but we're trying to read it without + // providing a data source. This shouldn't happen in normal usage as PyTorch + // files with actual tensor data should be in ZIP or legacy format. + Err(PytorchError::InvalidFormat( + "Pickle file contains tensor data but no data source is available. This file should be loaded as ZIP or legacy format.".to_string() + )) + } + Err(e) => Err(PytorchError::Pickle(e)), + } +} + +/// Load from a reader +fn load_from_reader( + reader: R, + top_level_key: Option<&str>, +) -> Result> { + let mut buf_reader = BufReader::new(reader); + + // Try reading without data source + match read_pickle(&mut buf_reader) { + Ok(obj) => extract_tensors_with_data(obj, top_level_key), + Err(e) + if e.to_string() + .contains("Cannot load tensor data without a data source") => + { + // This reader contains tensor data but we can't load it without a file path + Err(PytorchError::InvalidFormat( + "Reader contains tensor data but no data source is available. Use file-based loading instead.".to_string() + )) + } + Err(e) => Err(PytorchError::Pickle(e)), + } +} + +/// Extract tensors from a parsed pickle object +fn extract_tensors_with_data( + obj: Object, + top_level_key: Option<&str>, +) -> Result> { + let dict = match obj { + Object::Dict(dict) => { + if let Some(key) = top_level_key { + // Extract the nested dictionary if a top-level key is specified + match dict.get(key) { + Some(Object::Dict(nested)) => nested.clone(), + _ => { + return Err(PytorchError::KeyNotFound(format!( + "Top-level key '{}' not found or is not a dictionary. Available top-level keys in file: {:?}", + key, + dict.keys().collect::>() + ))); + } + } + } else { + dict + } + } + _ => { + return Err(PytorchError::InvalidFormat( + "Expected a dictionary at the root of the PyTorch file, but found a different type. The file may be a full model save rather than a state_dict.".to_string(), + )); + } + }; + + let mut tensors = HashMap::new(); + let mut path = Vec::new(); + extract_tensors_recursive(&Object::Dict(dict), &mut path, &mut tensors); + Ok(tensors) +} + +/// Recursively extract tensors from an object +fn extract_tensors_recursive<'a>( + obj: &'a Object, + path: &mut Vec<&'a str>, + tensors: &mut HashMap, +) { + match obj { + Object::Dict(dict) => { + for (key, value) in dict { + path.push(key); + extract_tensors_recursive(value, path, tensors); + path.pop(); + } + } + Object::TorchParam(snapshot) => { + // The TensorSnapshot already contains the data loading closure + // Only allocate the string here when we actually insert + tensors.insert(path.join("."), snapshot.clone()); + } + _ => {} + } +} + +/// Load a legacy PyTorch file with metadata +fn load_legacy_pytorch_file_with_metadata( + path: &Path, + top_level_key: Option<&str>, +) -> Result<(HashMap, PytorchMetadata)> { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + + // Skip metadata pickles + // 1. Magic number + let _ = read_pickle(&mut reader).map_err(|e| { + PytorchError::InvalidFormat(format!( + "Failed to read magic number from legacy format: {}", + e + )) + })?; + + // 2. Protocol version + let _ = read_pickle(&mut reader).map_err(|e| { + PytorchError::InvalidFormat(format!( + "Failed to read protocol version from legacy format: {}", + e + )) + })?; + + // 3. System info + let _ = read_pickle(&mut reader).map_err(|e| { + PytorchError::InvalidFormat(format!( + "Failed to read system info from legacy format: {}", + e + )) + })?; + + // Save position before main pickle + let main_pickle_pos = reader.stream_position()?; + + // 4. Skip main object - it might contain tensors so we can't parse it yet + // We'll re-read it with a data source later + use crate::pytorch::pickle_reader::skip_pickle; + skip_pickle(&mut reader).map_err(|e| { + PytorchError::InvalidFormat(format!( + "Failed to skip main object in legacy format: {}", + e + )) + })?; + + // 5. Storage keys list (sorted keys as written by PyTorch) + let storage_keys = match read_pickle(&mut reader) { + Ok(Object::List(keys)) => keys + .into_iter() + .filter_map(|obj| match obj { + Object::String(s) => Some(s), + _ => None, + }) + .collect::>(), + _ => vec![], + }; + + // 6. Raw binary data starts here + let data_start_pos = reader.stream_position()?; + let file_size = reader.seek(SeekFrom::End(0))?; + let data_size = file_size - data_start_pos; + + // Create a lazy data source for legacy multi-storage format + let data_source = Arc::new(LazyDataSource::from_legacy_multi_storage( + path, + data_start_pos, + data_size, + )); + + // Set storage keys BEFORE parsing the main pickle + // This is critical because track_storage_usage() is called during parsing + // and it needs storage_keys to build the storage map + if let LazyDataSource::LegacyMultiStorage(ref source) = *data_source + && !storage_keys.is_empty() + { + let source = source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + source.set_storage_keys(storage_keys.clone()); + } + + // Now re-read the main pickle with lazy data source + reader.seek(SeekFrom::Start(main_pickle_pos))?; + let main_obj = read_pickle_with_data(&mut reader, data_source.clone())?; + + // Extract tensors normally + let tensors = extract_tensors_with_data(main_obj, top_level_key)?; + + // Create metadata for legacy format + let metadata = PytorchMetadata { + format_version: None, // Legacy format doesn't have version files + format_type: FileFormat::Legacy, + byte_order: ByteOrder::LittleEndian, // Legacy format is little-endian + has_storage_alignment: false, + pytorch_version: None, // Could parse from protocol version, but not reliable + tensor_count: tensors.len(), + total_data_size: Some(data_size as usize), + }; + + Ok((tensors, metadata)) +} + +/// Check if a file is a TAR archive +fn is_tar_file(path: &Path) -> bool { + if let Ok(mut file) = File::open(path) { + // TAR files have "ustar" magic at offset 257 + let mut header = [0u8; 263]; + if file.read_exact(&mut header).is_ok() { + // Check for "ustar" magic at offset 257 + return &header[257..262] == b"ustar"; + } + } + false +} + +/// Load a TAR format PyTorch file with metadata +fn load_tar_pytorch_file_with_metadata( + path: &Path, + top_level_key: Option<&str>, +) -> Result<(HashMap, PytorchMetadata)> { + use tar::Archive; + + let file = File::open(path)?; + let mut archive = Archive::new(BufReader::new(file)); + + // Extract the main entries from the TAR archive + let mut sys_info_data: Option> = None; + let mut pickle_data: Option> = None; + let mut storages_data: Option> = None; + + for entry in archive.entries().map_err(PytorchError::Tar)? { + let mut entry = entry.map_err(PytorchError::Tar)?; + let entry_path = entry + .path() + .map_err(PytorchError::Tar)? + .to_string_lossy() + .to_string(); + + // Skip PAX headers + if entry_path.contains("@PaxHeader") { + continue; + } + + // Normalize path (remove ./ prefix if present) + let normalized = entry_path.trim_start_matches("./"); + + match normalized { + "sys_info" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data).map_err(PytorchError::Tar)?; + sys_info_data = Some(data); + } + "pickle" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data).map_err(PytorchError::Tar)?; + pickle_data = Some(data); + } + "storages" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data).map_err(PytorchError::Tar)?; + storages_data = Some(data); + } + _ => {} + } + } + + // Validate required entries + let pickle_data = pickle_data.ok_or_else(|| { + PytorchError::InvalidFormat("TAR file missing 'pickle' entry".to_string()) + })?; + let storages_data = storages_data.ok_or_else(|| { + PytorchError::InvalidFormat("TAR file missing 'storages' entry".to_string()) + })?; + + // Parse sys_info to check endianness + let is_little_endian = if let Some(ref data) = sys_info_data { + parse_tar_sys_info(data)? + } else { + true // Default to little-endian + }; + + if !is_little_endian { + return Err(PytorchError::InvalidFormat( + "Big-endian TAR PyTorch files are not supported".to_string(), + )); + } + + // Create TarSource for lazy loading + let data_source = Arc::new(LazyDataSource::from_tar(&storages_data)?); + + // Parse the pickle (OrderedDict of name -> storage_key) + let mut pickle_reader = BufReader::new(pickle_data.as_slice()); + let obj = read_pickle_with_data(&mut pickle_reader, data_source)?; + + // Extract tensors + let tensors = extract_tensors_with_data(obj, top_level_key)?; + + let metadata = PytorchMetadata { + format_version: None, + format_type: FileFormat::Tar, + byte_order: ByteOrder::LittleEndian, + has_storage_alignment: false, + pytorch_version: None, + tensor_count: tensors.len(), + total_data_size: Some(storages_data.len()), + }; + + Ok((tensors, metadata)) +} + +/// Parse sys_info pickle from TAR format to extract endianness +fn parse_tar_sys_info(data: &[u8]) -> Result { + let mut reader = BufReader::new(data); + let obj = read_pickle(&mut reader)?; + + if let Object::Dict(dict) = obj + && let Some(Object::Bool(little_endian)) = dict.get("little_endian") + { + return Ok(*little_endian); + } + + Ok(true) // Default assumption +} + +/// Read pickle data from a PyTorch file as a simplified value +fn read_pickle_as_value(path: &Path, top_level_key: Option<&str>) -> Result { + use crate::pytorch::lazy_data::LazyDataSource; + use crate::pytorch::pickle_reader::{read_pickle, read_pickle_with_data}; + use std::sync::Arc; + + // Try to open as ZIP first + if let Ok(file) = File::open(path) + && let Ok(mut archive) = zip::ZipArchive::new(BufReader::new(file)) + { + // Read pickle data from ZIP + let mut pickle_data = Vec::new(); + + // Try standard locations + for pickle_path in &["data.pkl", "archive/data.pkl"] { + if let Ok(mut pickle_file) = archive.by_name(pickle_path) { + pickle_file.read_to_end(&mut pickle_data)?; + break; + } + } + + // If not found, search for any .pkl file + if pickle_data.is_empty() { + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + drop(file); + + if name.ends_with("data.pkl") { + let mut file = archive.by_index(i)?; + file.read_to_end(&mut pickle_data)?; + break; + } + } + } + + if !pickle_data.is_empty() { + // Create a data source for the ZIP file + let data_source = LazyDataSource::from_zip(path)?; + let data_source_arc = Arc::new(data_source); + + let mut reader = BufReader::new(pickle_data.as_slice()); + let obj = read_pickle_with_data(&mut reader, data_source_arc)?; + return convert_object_to_value(obj, top_level_key); + } + } + + // Try as plain pickle file + // First attempt without data source (for pure metadata files) + let file = File::open(path)?; + let mut reader = BufReader::new(file); + + match read_pickle(&mut reader) { + Ok(obj) => convert_object_to_value(obj, top_level_key), + Err(e) + if e.to_string() + .contains("Cannot load tensor data without a data source") => + { + // File contains tensors, need to use full PytorchReader + // Use the regular reader to get proper tensor handling + let reader = PytorchReader::new(path)?; + + // Convert tensors to PickleValue structure + let mut result = std::collections::HashMap::new(); + for key in reader.keys() { + // For pickle value extraction, we just need the structure, not the actual data + result.insert( + key.clone(), + PickleValue::String(format!("", key)), + ); + } + + if let Some(key) = top_level_key { + Ok(PickleValue::Dict( + [(key.to_string(), PickleValue::Dict(result))] + .into_iter() + .collect(), + )) + } else { + Ok(PickleValue::Dict(result)) + } + } + Err(e) => Err(PytorchError::Pickle(e)), + } +} + +/// Convert internal Object to public PickleValue +fn convert_object_to_value(obj: Object, top_level_key: Option<&str>) -> Result { + use crate::pytorch::pickle_reader::Object; + + // If a top-level key is specified, extract it first + if let Some(key) = top_level_key + && let Object::Dict(dict) = obj + { + if let Some(value) = dict.get(key) { + return object_to_pickle_value(value.clone()); + } else { + return Err(PytorchError::KeyNotFound(format!( + "Key '{}' not found in pickle data", + key + ))); + } + } + + object_to_pickle_value(obj) +} + +/// Convert Object to PickleValue +fn object_to_pickle_value(obj: Object) -> Result { + use crate::pytorch::pickle_reader::Object; + + Ok(match obj { + Object::None => PickleValue::None, + Object::Bool(b) => PickleValue::Bool(b), + Object::Int(i) => PickleValue::Int(i), + Object::Float(f) => PickleValue::Float(f), + Object::String(s) => PickleValue::String(s), + Object::Persistent(data) => { + // Persistent data is raw bytes + PickleValue::Bytes(data) + } + Object::PersistentTuple(tuple) => { + // Convert persistent tuples to lists + let mut values = Vec::new(); + for item in tuple { + values.push(object_to_pickle_value(item)?); + } + PickleValue::List(values) + } + Object::List(list) => { + let mut values = Vec::new(); + for item in list { + values.push(object_to_pickle_value(item)?); + } + PickleValue::List(values) + } + Object::Dict(dict) => { + let mut map = HashMap::new(); + for (k, v) in dict { + map.insert(k, object_to_pickle_value(v)?); + } + PickleValue::Dict(map) + } + Object::Tuple(tuple) => { + // Convert tuples to lists in the public API + let mut values = Vec::new(); + for item in tuple { + values.push(object_to_pickle_value(item)?); + } + PickleValue::List(values) + } + Object::TorchParam(_) => { + // Skip tensor parameters in config reading + PickleValue::None + } + Object::Class { .. } | Object::Build { .. } | Object::Reduce { .. } => { + // Complex objects are represented as None for simplicity + PickleValue::None + } + }) +} + +/// Convert PickleValue to NestedValue for deserialization +fn convert_pickle_to_nested_value(value: PickleValue) -> Result { + Ok(match value { + PickleValue::None => NestedValue::Default(None), + PickleValue::Bool(b) => NestedValue::Bool(b), + PickleValue::Int(i) => NestedValue::I64(i), + PickleValue::Float(f) => NestedValue::F64(f), + PickleValue::String(s) => NestedValue::String(s), + PickleValue::List(list) => { + let mut vec = Vec::new(); + for item in list { + vec.push(convert_pickle_to_nested_value(item)?); + } + NestedValue::Vec(vec) + } + PickleValue::Dict(dict) => { + let mut map = HashMap::new(); + for (k, v) in dict { + map.insert(k, convert_pickle_to_nested_value(v)?); + } + NestedValue::Map(map) + } + PickleValue::Bytes(data) => { + // Convert bytes to a list of u8 values + let vec: Vec = data.into_iter().map(NestedValue::U8).collect(); + NestedValue::Vec(vec) + } + }) +} diff --git a/crates/burn-store/src/pytorch/store.rs b/crates/burn-store/src/pytorch/store.rs new file mode 100644 index 0000000..4cb2068 --- /dev/null +++ b/crates/burn-store/src/pytorch/store.rs @@ -0,0 +1,439 @@ +//! PyTorch store implementation for saving and loading models in PyTorch format. + +use crate::{ + ApplyResult, KeyRemapper, ModuleSnapshot, ModuleStore, PathFilter, PyTorchToBurnAdapter, + TensorSnapshot, map_indices_contiguous, +}; + +use alloc::collections::BTreeMap; + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; +use std::path::PathBuf; + +use super::reader::{PytorchError as ReaderError, PytorchReader}; + +/// Errors that can occur during PyTorch operations. +#[derive(Debug)] +pub enum PytorchStoreError { + /// Reader error. + Reader(ReaderError), + + /// I/O error. + Io(std::io::Error), + + /// Tensor not found. + TensorNotFound(String), + + /// Validation failed. + ValidationFailed(String), + + /// Other error. + Other(String), +} + +impl fmt::Display for PytorchStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Reader(e) => write!(f, "PyTorch reader error: {}", e), + Self::Io(e) => write!(f, "I/O error: {}", e), + Self::TensorNotFound(name) => write!(f, "Tensor not found: {}", name), + Self::ValidationFailed(msg) => write!(f, "Validation failed: {}", msg), + Self::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for PytorchStoreError {} + +impl From for PytorchStoreError { + fn from(e: ReaderError) -> Self { + PytorchStoreError::Reader(e) + } +} + +impl From for PytorchStoreError { + fn from(e: std::io::Error) -> Self { + PytorchStoreError::Io(e) + } +} + +/// PyTorch store for file-based storage only. +/// +/// This store allows loading models from PyTorch checkpoint files (.pt/.pth) +/// with automatic weight transformation using `PyTorchToBurnAdapter`. +/// Linear weights are automatically transposed and normalization parameters +/// are renamed (gamma -> weight, beta -> bias). +/// +/// Note that saving to PyTorch format is not yet supported. +pub struct PytorchStore { + pub(crate) path: PathBuf, + pub(crate) filter: PathFilter, + pub(crate) remapper: KeyRemapper, + pub(crate) validate: bool, + pub(crate) allow_partial: bool, + pub(crate) top_level_key: Option, + pub(crate) skip_enum_variants: bool, + /// Enable contiguous mapping of layer indices (default: true) + pub(crate) map_indices_contiguous: bool, + /// Cached tensor snapshots (parsed once, reused) + snapshots_cache: Option>, +} + +impl PytorchStore { + /// Create a store for loading from a PyTorch file. + /// + /// # Arguments + /// * `path` - Path to the PyTorch checkpoint file (.pt or .pth) + /// + /// # Example + /// ```rust,no_run + /// use burn_store::PytorchStore; + /// + /// let store = PytorchStore::from_file("model.pth"); + /// ``` + pub fn from_file(path: impl Into) -> Self { + Self { + path: path.into(), + filter: PathFilter::new(), + remapper: KeyRemapper::new(), + validate: true, + allow_partial: false, + top_level_key: None, + // PyTorch models never include enum variant names in paths + skip_enum_variants: true, + // Enable contiguous index mapping by default for PyTorch files + // This handles nn.Sequential models with gaps in layer indices + map_indices_contiguous: true, + snapshots_cache: None, + } + } + + /// Set a top-level key to extract tensors from. + /// + /// PyTorch files often contain nested dictionaries. Use this to extract + /// tensors from a specific top-level key like "state_dict" or "model_state_dict". + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// let store = PytorchStore::from_file("checkpoint.pth") + /// .with_top_level_key("model_state_dict"); + /// ``` + pub fn with_top_level_key(mut self, key: impl Into) -> Self { + self.top_level_key = Some(key.into()); + self + } + + /// Filter which tensors to load. + pub fn filter(mut self, filter: PathFilter) -> Self { + self.filter = filter; + self + } + + /// Add a regex pattern to filter tensors. + /// + /// Multiple patterns can be added and they work with OR logic. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// let store = PytorchStore::from_file("model.pth") + /// .with_regex(r"^encoder\..*") // Match all encoder tensors + /// .with_regex(r".*\.weight$"); // OR match any weight tensors + /// ``` + pub fn with_regex>(mut self, pattern: S) -> Self { + self.filter = self.filter.with_regex(pattern); + self + } + + /// Add multiple regex patterns to filter tensors. + pub fn with_regexes(mut self, patterns: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.filter = self.filter.with_regexes(patterns); + self + } + + /// Add an exact full path to match. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// let store = PytorchStore::from_file("model.pth") + /// .with_full_path("encoder.layer1.weight") + /// .with_full_path("decoder.output.bias"); + /// ``` + pub fn with_full_path>(mut self, path: S) -> Self { + self.filter = self.filter.with_full_path(path); + self + } + + /// Add multiple exact full paths to match. + pub fn with_full_paths(mut self, paths: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.filter = self.filter.with_full_paths(paths); + self + } + + /// Add a predicate function for custom filtering logic. + /// + /// The predicate receives the tensor path and container path. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// let store = PytorchStore::from_file("model.pth") + /// .with_predicate(|path, _| path.starts_with("encoder.") || path.ends_with(".bias")); + /// ``` + pub fn with_predicate(mut self, predicate: fn(&str, &str) -> bool) -> Self { + self.filter = self.filter.with_predicate(predicate); + self + } + + /// Add multiple predicate functions. + pub fn with_predicates(mut self, predicates: I) -> Self + where + I: IntoIterator bool>, + { + self.filter = self.filter.with_predicates(predicates); + self + } + + /// Set the filter to match all paths (disables filtering). + pub fn match_all(mut self) -> Self { + self.filter = self.filter.match_all(); + self + } + + /// Remap tensor names during load. + pub fn remap(mut self, remapper: KeyRemapper) -> Self { + self.remapper = remapper; + self + } + + /// Add a regex pattern to remap tensor names during load. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// let store = PytorchStore::from_file("model.pth") + /// .with_key_remapping(r"^encoder\.", "transformer.encoder.") // encoder.X -> transformer.encoder.X + /// .with_key_remapping(r"\.gamma$", ".weight"); // X.gamma -> X.weight + /// ``` + pub fn with_key_remapping( + mut self, + from_pattern: impl AsRef, + to_pattern: impl Into, + ) -> Self { + self.remapper = self + .remapper + .add_pattern(from_pattern, to_pattern) + .expect("Invalid regex pattern"); + self + } + + /// Set whether to validate tensors during loading (default: true). + pub fn validate(mut self, validate: bool) -> Self { + self.validate = validate; + self + } + + /// Allow partial loading of tensors (continue even if some tensors are missing). + pub fn allow_partial(mut self, allow: bool) -> Self { + self.allow_partial = allow; + self + } + + /// Skip enum variant names when matching tensor paths (default: true). + /// + /// When enabled, tensor paths from PyTorch that don't include enum variants + /// can be matched against Burn module paths that do include them. + /// For example, PyTorch path "feature.weight" can match Burn path "feature.BaseConv.weight". + /// + /// This defaults to `true` for PytorchStore since PyTorch models never include + /// enum variant names in their parameter paths. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// // Disable enum variant skipping (not typical) + /// let store = PytorchStore::from_file("model.pth") + /// .skip_enum_variants(false); + /// ``` + pub fn skip_enum_variants(mut self, skip: bool) -> Self { + self.skip_enum_variants = skip; + self + } + + /// Enable or disable automatic contiguous mapping of layer indices (default: true). + /// + /// When enabled, non-contiguous numeric indices in tensor paths are renumbered + /// to be contiguous. This is useful when loading PyTorch models that have gaps + /// in layer numbering, such as when using `nn.Sequential` with mixed layer types + /// (e.g., Conv2d layers at indices 0, 2, 4 with ReLU layers at 1, 3, 5). + /// + /// # Example + /// + /// With index mapping enabled (default): + /// - `fc.0.weight` → `fc.0.weight` + /// - `fc.2.weight` → `fc.1.weight` (gap filled) + /// - `fc.4.weight` → `fc.2.weight` (gap filled) + /// + /// # Arguments + /// + /// * `map` - `true` to enable contiguous index mapping, `false` to disable + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::PytorchStore; + /// // Disable contiguous index mapping if your model already has contiguous indices + /// let store = PytorchStore::from_file("model.pth") + /// .map_indices_contiguous(false); + /// ``` + pub fn map_indices_contiguous(mut self, map: bool) -> Self { + self.map_indices_contiguous = map; + self + } + + /// Apply remapping to tensor snapshots. + fn apply_remapping(&self, snapshots: Vec) -> Vec { + if self.remapper.is_empty() { + return snapshots; + } + + let (remapped, _) = self.remapper.remap(snapshots); + remapped + } + + /// Create a PytorchReader for the configured path and options. + fn create_reader(&self) -> Result { + let reader = if let Some(ref key) = self.top_level_key { + PytorchReader::with_top_level_key(&self.path, key)? + } else { + PytorchReader::new(&self.path)? + }; + Ok(reader) + } +} + +impl ModuleStore for PytorchStore { + type Error = PytorchStoreError; + + fn collect_from(&mut self, _module: &M) -> Result<(), Self::Error> { + // Saving to PyTorch format is not yet supported + Err(PytorchStoreError::Other( + "Saving to PyTorch format is not yet supported. Use other formats for saving." + .to_string(), + )) + } + + fn apply_to(&mut self, module: &mut M) -> Result { + // Get snapshots from cache + let snapshots: Vec = self.get_all_snapshots()?.values().cloned().collect(); + + // Get filter (convert to Option for apply) + let filter_opt = if self.filter.is_empty() { + None + } else { + Some(self.filter.clone()) + }; + + // Apply to module with PyTorchToBurnAdapter (always used for PyTorch files) + // This adapter handles: + // - Transposing linear weights from PyTorch format to Burn format + // - Renaming normalization parameters (gamma -> weight, beta -> bias) + // Filter is applied here during apply, not during cache population + let result = module.apply( + snapshots, + filter_opt, + Some(Box::new(PyTorchToBurnAdapter)), + self.skip_enum_variants, + ); + + // Validate if needed + if self.validate && !result.errors.is_empty() { + return Err(PytorchStoreError::ValidationFailed(format!( + "Import errors:\n{}", + result + ))); + } + + if !self.allow_partial && !result.missing.is_empty() { + return Err(PytorchStoreError::TensorNotFound(format!( + "\n{}\n\nHint: Use `.allow_partial(true)` on the store to get an `ApplyResult` \ + with structured missing/error info instead of a hard failure.", + result + ))); + } + + Ok(result) + } + + fn get_snapshot(&mut self, name: &str) -> Result, Self::Error> { + self.ensure_snapshots_cache()?; + Ok(self.snapshots_cache.as_ref().unwrap().get(name)) + } + + fn get_all_snapshots(&mut self) -> Result<&BTreeMap, Self::Error> { + self.ensure_snapshots_cache()?; + Ok(self.snapshots_cache.as_ref().unwrap()) + } + + fn keys(&mut self) -> Result, Self::Error> { + // Always use the cache to ensure remapping is applied consistently + Ok(self.get_all_snapshots()?.keys().cloned().collect()) + } +} + +impl PytorchStore { + /// Ensure the snapshots cache is populated + fn ensure_snapshots_cache(&mut self) -> Result<(), PytorchStoreError> { + if self.snapshots_cache.is_some() { + return Ok(()); + } + + let reader = self.create_reader()?; + + // Convert to tensor snapshots + let mut snapshots: Vec = reader + .into_tensors() + .into_iter() + .map(|(key, mut snapshot)| { + // Parse the key into path parts (split by '.') + let path_parts: Vec = key.split('.').map(|s| s.to_string()).collect(); + + // Set the path stack from the key + snapshot.path_stack = Some(path_parts); + snapshot.container_stack = None; + snapshot.tensor_id = None; + + snapshot + }) + .collect(); + + // Apply remapping (but NOT filtering - that's done at apply time) + snapshots = self.apply_remapping(snapshots); + + // Apply contiguous index mapping if enabled + // This must be done after remapping so that remapped paths are mapped + if self.map_indices_contiguous { + let (mapped, _) = map_indices_contiguous(snapshots); + snapshots = mapped; + } + + // Build cache as BTreeMap + let cache: BTreeMap = + snapshots.into_iter().map(|s| (s.full_path(), s)).collect(); + + self.snapshots_cache = Some(cache); + Ok(()) + } +} diff --git a/crates/burn-store/src/pytorch/tests/mod.rs b/crates/burn-store/src/pytorch/tests/mod.rs new file mode 100644 index 0000000..b9d78f0 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/mod.rs @@ -0,0 +1,2 @@ +pub mod reader; +pub mod store; diff --git a/crates/burn-store/src/pytorch/tests/reader/create_legacy_with_offsets.py b/crates/burn-store/src/pytorch/tests/reader/create_legacy_with_offsets.py new file mode 100644 index 0000000..4e690b0 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/reader/create_legacy_with_offsets.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["torch"] +# /// +"""Create a legacy format PyTorch file with specific storage offsets to test offset handling.""" + +import torch + +# Create tensors with known values at specific storage offsets +# This will help us verify we're reading from the correct location + +# Create a state dict with tensors that share storage +# This is common in PyTorch models (e.g., weight and transposed weight views) +state_dict = {} + +# Create a base tensor with known pattern +base_data = torch.arange(100, dtype=torch.float32) + +# tensor1: uses elements 10-19 (offset 10*4 = 40 bytes) +tensor1 = base_data[10:20].clone() +tensor1[:] = torch.arange(1.0, 1.1, 0.01)[:10] # 1.00, 1.01, 1.02, ... + +# tensor2: uses elements 30-35 (offset 30*4 = 120 bytes) +tensor2 = base_data[30:35].clone() +tensor2[:] = torch.arange(2.0, 2.5, 0.1)[:5] # 2.0, 2.1, 2.2, 2.3, 2.4 + +# tensor3: starts at beginning (offset 0) +tensor3 = base_data[:5].clone() +tensor3[:] = torch.arange(3.0, 3.5, 0.1)[:5] # 3.0, 3.1, 3.2, 3.3, 3.4 + +state_dict['tensor1'] = tensor1 +state_dict['tensor2'] = tensor2 +state_dict['tensor3'] = tensor3 + +# Save in legacy format +output_file = 'test_data/legacy_with_offsets.pt' +torch.save(state_dict, output_file, _use_new_zipfile_serialization=False) + +print(f"Created {output_file}") + +# Verify by loading +loaded = torch.load(output_file, weights_only=False) +print("\nVerification - expected values:") +for key, tensor in loaded.items(): + print(f" {key}: {tensor.tolist()}") + print(f" Storage offset: {tensor.storage_offset()}") + print(f" Storage size: {len(tensor.storage())}") + +# Also create a test with multiple tensors sharing the same storage +# This is important for proper offset handling +shared_storage = torch.randn(1000) + +# Create views into the same storage at different offsets +view1 = shared_storage[100:110] # offset 100 +view2 = shared_storage[500:520] # offset 500 +view3 = shared_storage[0:10] # offset 0 + +# Need to save these properly - PyTorch will handle the storage sharing +shared_dict = { + 'view1': view1.clone(), # Clone to avoid view issues + 'view2': view2.clone(), + 'view3': view3.clone(), +} + +output_file2 = 'test_data/legacy_shared_storage.pt' +torch.save(shared_dict, output_file2, _use_new_zipfile_serialization=False) +print(f"\nCreated {output_file2}") + +# Print exact values for test verification +print("\nExact test values for legacy_with_offsets.pt:") +print("tensor1 (10 elements starting at 1.0):") +print(" First 3 values: [1.00, 1.01, 1.02]") +print("tensor2 (5 elements starting at 2.0):") +print(" All values: [2.0, 2.1, 2.2, 2.3, 2.4]") +print("tensor3 (5 elements starting at 3.0):") +print(" All values: [3.0, 3.1, 3.2, 3.3, 3.4]") \ No newline at end of file diff --git a/crates/burn-store/src/pytorch/tests/reader/create_tar_format.py b/crates/burn-store/src/pytorch/tests/reader/create_tar_format.py new file mode 100644 index 0000000..28549f7 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/reader/create_tar_format.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Create TAR format test fixtures for burn-store integration tests. + +The TAR format was used by very early versions of PyTorch (pre 0.1.10). +Modern torch.save cannot create this format, so we construct it manually. + +TAR format structure: + - sys_info: pickle with {protocol_version, little_endian, type_sizes} + - pickle: pickle with OrderedDict containing _rebuild_tensor_v2 REDUCE calls + - storages: count_pickle + for each storage: (key, device, class) pickle + u64 num_elements + raw data +""" + +import io +import pickle +import struct +import tarfile +import os +from collections import OrderedDict + + +def create_sys_info(): + """Create sys_info pickle data.""" + sys_info = { + "protocol_version": 1000, + "little_endian": True, + "type_sizes": { + "short": 2, + "int": 4, + "long": 8, + }, + } + return pickle.dumps(sys_info, protocol=2) + + +def encode_tensor_data(values: list, storage_type: str) -> tuple: + """Encode tensor values to bytes and return (bytes, element_size).""" + fmt_map = { + "FloatStorage": (" bytes: + """ + Create the storages binary blob manually. + + Args: + tensors: List of (key, storage_type, element_size, data_bytes) tuples + """ + buffer = io.BytesIO() + + # Write storage count as pickle (simple integer) + pickle.dump(len(tensors), buffer, protocol=2) + + for key, storage_type, element_size, data_bytes in tensors: + # Manually construct the tuple pickle with GLOBAL class reference + # Format: (key, "cpu", ) + + tuple_buffer = io.BytesIO() + # Protocol 2 header + tuple_buffer.write(b'\x80\x02') + + # Build tuple with MARK + items + TUPLE + tuple_buffer.write(b'(') # MARK + + # First item: storage key (string) + write_string(tuple_buffer, key) + + # Second item: device "cpu" + tuple_buffer.write(b'U\x03cpu') + + # Third item: class reference using GLOBAL + tuple_buffer.write(b'c') # GLOBAL opcode + tuple_buffer.write(b'torch\n') # module + tuple_buffer.write(storage_type.encode('ascii') + b'\n') # name + + # End tuple + tuple_buffer.write(b't') # TUPLE + tuple_buffer.write(b'.') # STOP + + buffer.write(tuple_buffer.getvalue()) + + # Write num_elements as u64 little-endian + num_elements = len(data_bytes) // element_size + buffer.write(struct.pack(" bytes: + """ + Create the main pickle containing _rebuild_tensor_v2 REDUCE calls. + + For each tensor, we need: + - GLOBAL torch._utils _rebuild_tensor_v2 + - MARK + - args tuple: (persistent_id, offset, shape, stride, requires_grad, hooks) + - TUPLE + - REDUCE + + The persistent_id is a PersistentTuple: ('storage', , key, device, num_elements) + """ + buffer = io.BytesIO() + + # Protocol 2 header + buffer.write(b'\x80\x02') + + # Build OrderedDict: GLOBAL + EMPTY_LIST + items + TUPLE + REDUCE + # OrderedDict([('name1', tensor1), ('name2', tensor2)]) + + # GLOBAL collections OrderedDict + buffer.write(b'ccollections\nOrderedDict\n') + + # Start list for items + buffer.write(b'(') # MARK + buffer.write(b']') # EMPTY_LIST + + # For each tensor, add (name, rebuilt_tensor) to the list + for name, storage_key, storage_type, shape, num_elements in tensors_info: + # Calculate stride for row-major (C) order + stride = [] + s = 1 + for dim in reversed(shape): + stride.insert(0, s) + s *= dim + + # Build inner tuple: (name, tensor_value) + buffer.write(b'(') # MARK for (name, value) tuple + + # Write name + write_string(buffer, name) + + # Now build the tensor using _rebuild_tensor_v2 REDUCE + # GLOBAL torch._utils _rebuild_tensor_v2 + buffer.write(b'ctorch._utils\n_rebuild_tensor_v2\n') + + # Build args tuple for _rebuild_tensor_v2 + # (persistent_id, offset, shape, stride, requires_grad, backward_hooks) + buffer.write(b'(') # MARK for args tuple + + # arg 0: persistent_id tuple: ('storage', class, key, device, num_elements) + # This will be converted to PersistentTuple by the reader + buffer.write(b'(') # MARK for persistent_id + + write_string(buffer, 'storage') + + # Class reference - GLOBAL torch FloatStorage + buffer.write(b'c') + buffer.write(b'torch\n') + buffer.write(storage_type.encode('ascii') + b'\n') + + # Storage key + write_string(buffer, storage_key) + + # Device + buffer.write(b'U\x03cpu') + + # num_elements + write_int(buffer, num_elements) + + buffer.write(b't') # TUPLE - end persistent_id + + # arg 1: storage offset (0) + buffer.write(b'K\x00') + + # arg 2: shape tuple + buffer.write(b'(') + for dim in shape: + write_int(buffer, dim) + buffer.write(b't') + + # arg 3: stride tuple + buffer.write(b'(') + for s_val in stride: + write_int(buffer, s_val) + buffer.write(b't') + + # arg 4: requires_grad (False) + buffer.write(b'\x89') # NEWFALSE + + # arg 5: backward_hooks (empty OrderedDict) + buffer.write(b'ccollections\nOrderedDict\n') + buffer.write(b'(') + buffer.write(b']') + buffer.write(b't') + buffer.write(b'R') # REDUCE to create empty OrderedDict + + buffer.write(b't') # TUPLE - end args tuple + + buffer.write(b'R') # REDUCE - call _rebuild_tensor_v2 with args + + buffer.write(b't') # TUPLE - end (name, tensor) tuple + + buffer.write(b'a') # APPEND to list + + buffer.write(b't') # TUPLE - wrap list in tuple for REDUCE + buffer.write(b'R') # REDUCE - call OrderedDict with the list + buffer.write(b'.') # STOP + + return buffer.getvalue() + + +def create_tar_pytorch_file(filename: str, tensors: dict, dtypes: dict): + """ + Create a TAR format PyTorch file. + + Args: + filename: Output file path + tensors: Dict of tensor_name -> (values_list, shape) + dtypes: Dict of tensor_name -> storage_type + """ + # Prepare storage data + storage_list = [] # (key, storage_type, element_size, data_bytes) + tensors_info = [] # (name, storage_key, storage_type, shape, num_elements) + + for idx, (name, (values, shape)) in enumerate(tensors.items()): + storage_key = str(idx) + storage_type = dtypes[name] + data_bytes, element_size = encode_tensor_data(values, storage_type) + num_elements = len(values) + + storage_list.append((storage_key, storage_type, element_size, data_bytes)) + tensors_info.append((name, storage_key, storage_type, shape, num_elements)) + + # Create the three main entries + sys_info_data = create_sys_info() + pickle_data = create_main_pickle_manual(tensors_info) + storages_data = create_storages_blob_manual(storage_list) + + # Write TAR archive + os.makedirs(os.path.dirname(filename) or ".", exist_ok=True) + + with tarfile.open(filename, "w") as tar: + # Add sys_info + tarinfo = tarfile.TarInfo(name="sys_info") + tarinfo.size = len(sys_info_data) + tar.addfile(tarinfo, io.BytesIO(sys_info_data)) + + # Add pickle + tarinfo = tarfile.TarInfo(name="pickle") + tarinfo.size = len(pickle_data) + tar.addfile(tarinfo, io.BytesIO(pickle_data)) + + # Add storages + tarinfo = tarfile.TarInfo(name="storages") + tarinfo.size = len(storages_data) + tar.addfile(tarinfo, io.BytesIO(storages_data)) + + size = os.path.getsize(filename) + print(f"Created {filename} ({size} bytes)") + print(f" Tensors: {list(tensors.keys())}") + + +def main(): + # Create test_data directory + os.makedirs("test_data", exist_ok=True) + + # Test 1: Single float32 tensor + create_tar_pytorch_file( + "test_data/tar_float32.tar", + {"tensor": ([1.0, 2.5, -3.7, 0.0], [4])}, + {"tensor": "FloatStorage"}, + ) + + # Test 2: Single float64 tensor + create_tar_pytorch_file( + "test_data/tar_float64.tar", + {"tensor": ([1.1, 2.2, 3.3], [3])}, + {"tensor": "DoubleStorage"}, + ) + + # Test 3: Single int64 tensor + create_tar_pytorch_file( + "test_data/tar_int64.tar", + {"tensor": ([100, -200, 300, 0], [4])}, + {"tensor": "LongStorage"}, + ) + + # Test 4: Multiple tensors (weight + bias) + create_tar_pytorch_file( + "test_data/tar_weight_bias.tar", + { + "weight": ([0.1, 0.2, 0.3, 0.4, 0.5, 0.6], [2, 3]), + "bias": ([0.01, 0.02], [2]), + }, + { + "weight": "FloatStorage", + "bias": "FloatStorage", + }, + ) + + # Test 5: Different dtypes in one file + create_tar_pytorch_file( + "test_data/tar_multi_dtype.tar", + { + "float_tensor": ([1.5, 2.5, 3.5], [3]), + "double_tensor": ([1.111, 2.222], [2]), + "int_tensor": ([10, 20, 30, 40], [4]), + }, + { + "float_tensor": "FloatStorage", + "double_tensor": "DoubleStorage", + "int_tensor": "LongStorage", + }, + ) + + # Test 6: 2D tensor for shape verification + create_tar_pytorch_file( + "test_data/tar_2d_tensor.tar", + { + "matrix": ([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0], [3, 4]), + }, + {"matrix": "FloatStorage"}, + ) + + print("\nAll TAR format test files created!") + print("\nTo run tests: cargo test -p burn-store --features pytorch test_tar") + + +if __name__ == "__main__": + main() diff --git a/crates/burn-store/src/pytorch/tests/reader/mod.rs b/crates/burn-store/src/pytorch/tests/reader/mod.rs new file mode 100644 index 0000000..0786e35 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/reader/mod.rs @@ -0,0 +1,1278 @@ +//! Tests for PyTorch file reader functionality +//! +//! Floating-point comparison tolerances: +//! - F16/BF16: 1e-2 (~3 decimal digits precision) +//! - F32: 1e-6 (~7 decimal digits precision) +//! - F64: 1e-10 (~16 decimal digits precision) + +#![allow(clippy::needless_range_loop)] + +use crate::pytorch::PytorchReader; +// Import internal types for testing only +use crate::pytorch::reader::{ByteOrder, FileFormat}; +use burn_core::tensor::{BoolStore, DType, TensorData, Tolerance, shape}; +use std::path::PathBuf; + +fn test_data_path(filename: &str) -> PathBuf { + // Get the path relative to the crate root + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("pytorch") + .join("tests") + .join("reader") + .join("test_data") + .join(filename) +} + +#[test] +fn test_float32_tensor() { + let path = test_data_path("float32.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load float32.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 4); + assert!((values[0] - 1.0).abs() < 1e-6); + assert!((values[1] - 2.5).abs() < 1e-6); + assert!((values[2] - (-3.7)).abs() < 1e-6); + assert!((values[3] - 0.0).abs() < 1e-6); +} + +#[test] +fn test_float64_tensor() { + let path = test_data_path("float64.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load float64.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F64); + assert_eq!(tensor.shape, shape![3]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 3); + assert!((values[0] - 1.1).abs() < 1e-10); + assert!((values[1] - 2.2).abs() < 1e-10); + assert!((values[2] - 3.3).abs() < 1e-10); +} + +#[test] +fn test_int64_tensor() { + let path = test_data_path("int64.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load int64.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::I64); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[100, -200, 300, 0]); +} + +#[test] +fn test_int32_tensor() { + let path = test_data_path("int32.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load int32.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::I32); + assert_eq!(tensor.shape, shape![3]); + + let data = tensor.to_data().unwrap(); + // Convert to the appropriate element type + let data_converted = data.convert::(); + let values = data_converted.as_slice::().unwrap(); + assert_eq!(values, &[10, 20, -30]); +} + +#[test] +fn test_int16_tensor() { + let path = test_data_path("int16.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load int16.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::I16); + assert_eq!(tensor.shape, shape![3]); + + let data = tensor.to_data().unwrap(); + let data_converted = data.convert::(); + let values = data_converted.as_slice::().unwrap(); + assert_eq!(values, &[1000, -2000, 3000]); +} + +#[test] +fn test_int8_tensor() { + let path = test_data_path("int8.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load int8.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::I8); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let data_converted = data.convert::(); + let values = data_converted.as_slice::().unwrap(); + assert_eq!(values, &[127, -128, 0, 50]); +} + +#[test] +fn test_bool_tensor() { + let path = test_data_path("bool.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load bool.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::Bool(BoolStore::Native)); + assert_eq!(tensor.shape, shape![5]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[true, false, true, true, false]); +} + +#[test] +fn test_uint8_tensor() { + let path = test_data_path("uint8.pt"); + + let reader = PytorchReader::new(&path).expect("Failed to load uint8.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::U8); + assert_eq!(tensor.shape, shape![4]); + + // Verify actual U8 values [0, 128, 255, 42] from test_data.py + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[0, 128, 255, 42]); +} + +#[test] +fn test_float16_tensor() { + use half::f16; + + let path = test_data_path("float16.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load float16.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F16); + assert_eq!(tensor.shape, shape![3]); + + // Verify actual F16 values [1.5, -2.25, 3.125] from test_data.py + let data = tensor.to_data().unwrap(); + assert_eq!(data.shape, shape![3]); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 3); + assert!((values[0].to_f32() - 1.5).abs() < 1e-2); + assert!((values[1].to_f32() - (-2.25)).abs() < 1e-2); + assert!((values[2].to_f32() - 3.125).abs() < 1e-2); +} + +#[test] +fn test_bfloat16_tensor() { + use half::bf16; + + let path = test_data_path("bfloat16.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load bfloat16.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::BF16); + assert_eq!(tensor.shape, shape![3]); + + // Verify actual BF16 values [1.5, -2.5, 3.5] from test_data.py + let data = tensor.to_data().unwrap(); + assert_eq!(data.shape, shape![3]); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 3); + assert!((values[0].to_f32() - 1.5).abs() < 1e-2); + assert!((values[1].to_f32() - (-2.5)).abs() < 1e-2); + assert!((values[2].to_f32() - 3.5).abs() < 1e-2); +} + +#[test] +fn test_2d_tensor() { + let path = test_data_path("tensor_2d.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load tensor_2d.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![3, 2]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 6); + // Check flattened values [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + for (i, expected) in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0].iter().enumerate() { + assert!((values[i] - expected).abs() < 1e-6); + } +} + +#[test] +fn test_3d_tensor() { + let path = test_data_path("tensor_3d.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load tensor_3d.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![2, 3, 4]); + + let data = tensor.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 3, 4]); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 24); +} + +#[test] +fn test_4d_tensor() { + let path = test_data_path("tensor_4d.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load tensor_4d.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![2, 3, 2, 2]); + + let data = tensor.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 3, 2, 2]); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 24); +} + +#[test] +fn test_state_dict() { + let path = test_data_path("state_dict.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load state_dict.pt"); + let keys = reader.keys(); + + assert_eq!(keys.len(), 4); + assert!(keys.contains(&"weight".to_string())); + assert!(keys.contains(&"bias".to_string())); + assert!(keys.contains(&"running_mean".to_string())); + assert!(keys.contains(&"running_var".to_string())); + + // Check weight tensor + let weight = reader.get("weight").unwrap(); + assert_eq!(weight.shape, shape![3, 4]); + assert_eq!(weight.dtype, DType::F32); + + // Check bias tensor + let bias = reader.get("bias").unwrap(); + assert_eq!(bias.shape, shape![3]); + assert_eq!(bias.dtype, DType::F32); + + // Check running_mean (should be zeros) + let running_mean = reader.get("running_mean").unwrap(); + assert_eq!(running_mean.shape, shape![3]); + let mean_data = running_mean.to_data().unwrap(); + let mean_values = mean_data.as_slice::().unwrap(); + assert!(mean_values.iter().all(|&v| v.abs() < 1e-6)); + + // Check running_var (should be ones) + let running_var = reader.get("running_var").unwrap(); + assert_eq!(running_var.shape, shape![3]); + let var_data = running_var.to_data().unwrap(); + let var_values = var_data.as_slice::().unwrap(); + assert!(var_values.iter().all(|&v| (v - 1.0).abs() < 1e-6)); +} + +#[test] +fn test_nested_dict() { + let path = test_data_path("nested_dict.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load nested_dict.pt"); + let keys = reader.keys(); + + assert_eq!(keys.len(), 4); + assert!(keys.contains(&"layer1.weight".to_string())); + assert!(keys.contains(&"layer1.bias".to_string())); + assert!(keys.contains(&"layer2.weight".to_string())); + assert!(keys.contains(&"layer2.bias".to_string())); + + // Check layer1.weight and load data + let layer1_weight = reader.get("layer1.weight").unwrap(); + assert_eq!(layer1_weight.shape, shape![2, 3]); + assert_eq!(layer1_weight.dtype, DType::F32); + let data = layer1_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 6); // 2x3 = 6 elements + + // Check layer2.weight and load data + let layer2_weight = reader.get("layer2.weight").unwrap(); + assert_eq!(layer2_weight.shape, shape![4, 2]); + assert_eq!(layer2_weight.dtype, DType::F32); + let data = layer2_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 8); // 4x2 = 8 elements +} + +#[test] +fn test_checkpoint() { + let path = test_data_path("checkpoint.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load checkpoint.pt"); + let keys = reader.keys(); + + // Should have model_state_dict entries and optimizer entries + assert!(keys.contains(&"model_state_dict.fc1.weight".to_string())); + assert!(keys.contains(&"model_state_dict.fc1.bias".to_string())); + assert!(keys.contains(&"model_state_dict.fc2.weight".to_string())); + assert!(keys.contains(&"model_state_dict.fc2.bias".to_string())); + + // Check fc1.weight dimensions and load data + let fc1_weight = reader.get("model_state_dict.fc1.weight").unwrap(); + assert_eq!(fc1_weight.shape, shape![10, 5]); + let data = fc1_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 50); // 10x5 = 50 elements + + // Check fc2.weight dimensions and load data + let fc2_weight = reader.get("model_state_dict.fc2.weight").unwrap(); + assert_eq!(fc2_weight.shape, shape![3, 10]); + let data = fc2_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 30); // 3x10 = 30 elements +} + +#[test] +fn test_empty_tensor() { + let path = test_data_path("empty.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load empty.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.shape, shape![0]); // Empty tensor has shape [0] + assert_eq!(tensor.dtype, DType::F32); + + // Note: Empty tensors cannot be loaded with to_data() due to TensorData validation + // We verify the metadata is correct, which confirms the .pt file is being read +} + +#[test] +fn test_scalar_tensor() { + let path = test_data_path("scalar.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load scalar.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.shape, shape![]); // Scalar has empty shape + assert_eq!(tensor.dtype, DType::F32); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 1); + assert!((values[0] - 42.0).abs() < 1e-6); +} + +#[test] +fn test_large_shape() { + let path = test_data_path("large_shape.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load large_shape.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.shape, shape![100, 100]); + assert_eq!(tensor.dtype, DType::F32); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 10000); + + // Check specific non-zero values + assert!((values[0] - 1.0).abs() < 1e-6); // [0, 0] = 1.0 + assert!((values[5050] - 2.0).abs() < 1e-6); // [50, 50] = 2.0 + assert!((values[9999] - 3.0).abs() < 1e-6); // [99, 99] = 3.0 +} + +#[test] +fn test_mixed_types() { + let path = test_data_path("mixed_types.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load mixed_types.pt"); + let tensors = reader.tensors(); + + assert_eq!(tensors.len(), 4); + + // Check float32 tensor [1.0, 2.0] from test_data.py + let float32 = reader.get("float32").unwrap(); + assert_eq!(float32.dtype, DType::F32); + assert_eq!(float32.shape, shape![2]); + let data = float32.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert!((values[0] - 1.0).abs() < 1e-6); + assert!((values[1] - 2.0).abs() < 1e-6); + + // Check int64 tensor [100, 200] from test_data.py + let int64 = reader.get("int64").unwrap(); + assert_eq!(int64.dtype, DType::I64); + assert_eq!(int64.shape, shape![2]); + let data = int64.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[100, 200]); + + // Check bool tensor [True, False] from test_data.py + let bool_tensor = reader.get("bool").unwrap(); + assert_eq!(bool_tensor.dtype, DType::Bool(BoolStore::Native)); + assert_eq!(bool_tensor.shape, shape![2]); + let data = bool_tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[true, false]); + + // Check float64 tensor [1.1, 2.2] from test_data.py + let float64 = reader.get("float64").unwrap(); + assert_eq!(float64.dtype, DType::F64); + assert_eq!(float64.shape, shape![2]); + let data = float64.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert!((values[0] - 1.1).abs() < 1e-10); + assert!((values[1] - 2.2).abs() < 1e-10); +} + +#[test] +fn test_special_values() { + let path = test_data_path("special_values.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load special_values.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![5]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 5); + + // Check for special values + assert!(values[0].is_nan()); + assert!(values[1].is_infinite() && values[1] > 0.0); + assert!(values[2].is_infinite() && values[2] < 0.0); + assert!((values[3] - 0.0).abs() < 1e-6); + assert!((values[4] - 1.0).abs() < 1e-6); +} + +#[test] +fn test_extreme_values() { + let path = test_data_path("extreme_values.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load extreme_values.pt"); + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 4); + + // Very small positive + assert!(values[0] > 0.0 && values[0] < 1e-20); + // Very large positive + assert!(values[1] > 1e20); + // Very small negative + assert!(values[2] < 0.0 && values[2] > -1e-20); + // Very large negative + assert!(values[3] < -1e20); +} + +#[test] +fn test_parameter() { + let path = test_data_path("parameter.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load parameter.pt"); + let tensors = reader.tensors(); + + // nn.Parameter is typically saved as a regular tensor + assert_eq!(tensors.len(), 1); + let param = reader.get("param").unwrap(); + assert_eq!(param.shape, shape![3, 3]); + assert_eq!(param.dtype, DType::F32); + + let data = param.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 9); +} + +#[test] +fn test_buffers() { + let path = test_data_path("buffers.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load buffers.pt"); + let tensors = reader.tensors(); + + assert_eq!(tensors.len(), 2); + + // Check buffer1 (int32) + let buffer1 = reader.get("buffer1").unwrap(); + assert_eq!(buffer1.dtype, DType::I32); + assert_eq!(buffer1.shape, shape![3]); + let data1 = buffer1.to_data().unwrap(); + let data1_converted = data1.convert::(); + let values1 = data1_converted.as_slice::().unwrap(); + assert_eq!(values1, &[1, 2, 3]); + + // Check buffer2 (bool) + let buffer2 = reader.get("buffer2").unwrap(); + assert_eq!(buffer2.dtype, DType::Bool(BoolStore::Native)); + assert_eq!(buffer2.shape, shape![2]); + let data2 = buffer2.to_data().unwrap(); + let values2 = data2.as_slice::().unwrap(); + assert_eq!(values2, &[true, false]); +} + +#[test] +fn test_complex_structure() { + let path = test_data_path("complex_structure.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load complex_structure.pt"); + let keys = reader.keys(); + + // Should have nested structure tensors + assert!(keys.contains(&"state.encoder.layer_0.weight".to_string())); + assert!(keys.contains(&"state.encoder.layer_0.bias".to_string())); + assert!(keys.contains(&"state.encoder.layer_1.weight".to_string())); + assert!(keys.contains(&"state.encoder.layer_1.bias".to_string())); + assert!(keys.contains(&"state.decoder.weight".to_string())); + assert!(keys.contains(&"state.decoder.bias".to_string())); + + // Check encoder layer_0 weight and load data + let layer0_weight = reader.get("state.encoder.layer_0.weight").unwrap(); + assert_eq!(layer0_weight.shape, shape![4, 3]); + let data = layer0_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 12); // 4x3 = 12 elements + + // Check decoder weight and load data + let decoder_weight = reader.get("state.decoder.weight").unwrap(); + assert_eq!(decoder_weight.shape, shape![3, 2]); + let data = decoder_weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 6); // 3x2 = 6 elements +} + +#[test] +fn test_read_pytorch_tensors_convenience() { + // Test reading and materializing tensors into memory + let path = test_data_path("state_dict.pt"); + let reader = PytorchReader::new(&path).expect("Failed to read file"); + + let keys = reader.keys(); + assert_eq!(keys.len(), 4); + assert!(keys.contains(&"weight".to_string())); + assert!(keys.contains(&"bias".to_string())); + + // Check that data can be materialized + let weight = reader.get("weight").unwrap(); + let weight_data = weight.to_data().unwrap(); + assert_eq!(weight_data.shape, shape![3, 4]); + assert_eq!(weight_data.dtype, DType::F32); +} + +#[test] +fn test_with_top_level_key() { + // Test loading with a specific top-level key + let path = test_data_path("checkpoint.pt"); + + // Load only model_state_dict + let reader = PytorchReader::with_top_level_key(&path, "model_state_dict") + .expect("Failed to load with top-level key"); + + let keys = reader.keys(); + // Should only have model weights, not optimizer state + assert!(keys.contains(&"fc1.weight".to_string())); + assert!(keys.contains(&"fc1.bias".to_string())); + assert!(keys.contains(&"fc2.weight".to_string())); + assert!(keys.contains(&"fc2.bias".to_string())); + + // Should NOT have nested paths with model_state_dict prefix + assert!(!keys.contains(&"model_state_dict.fc1.weight".to_string())); +} + +#[test] +fn test_legacy_format() { + // Test loading PyTorch legacy format (pre-1.6) + let path = test_data_path("simple_legacy.pt"); + + // This file has the sequential pickle structure of legacy PyTorch format + let reader = PytorchReader::new(&path).expect("Failed to load legacy format"); + let keys = reader.keys(); + + // Should have the tensors from the state dict + assert!(keys.contains(&"weight".to_string()), "Missing 'weight' key"); + assert!(keys.contains(&"bias".to_string()), "Missing 'bias' key"); + assert!( + keys.contains(&"running_mean".to_string()), + "Missing 'running_mean' key" + ); + + // Check weight tensor + let weight = reader.get("weight").expect("weight not found"); + // Note: values for `weight` in simple_legacy.pt are randomly generated + assert_eq!(weight.shape, shape![2, 3]); + assert_eq!(weight.dtype, DType::F32); + + // Check bias tensor + let bias = reader.get("bias").expect("bias not found"); + assert_eq!(bias.shape, shape![2]); + assert_eq!(bias.dtype, DType::F32); + + // Verify bias values are all ones + let bias_data = bias.to_data().unwrap(); + let expected_bias_data = TensorData::new(vec![1.0_f32, 1.0], vec![2]); + bias_data.assert_approx_eq::(&expected_bias_data, Tolerance::default()); + + // Check running_mean tensor + let running_mean = reader.get("running_mean").expect("running_mean not found"); + assert_eq!(running_mean.shape, shape![2]); + assert_eq!(running_mean.dtype, DType::F32); + + // Verify running_mean values are accessible + let mean_data = running_mean.to_data().unwrap(); + let expected_mean_data = TensorData::new(vec![0.0_f32, 0.0], vec![2]); + mean_data.assert_approx_eq::(&expected_mean_data, Tolerance::default()); +} + +#[test] +fn test_legacy_uncloned_views() { + let path = test_data_path("legacy_uncloned_views.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load legacy format"); + let keys = reader.keys(); + + // Should have the tensors from the state dict + assert!( + keys.contains(&"tensor1".to_string()), + "Missing 'tensor1' key" + ); + assert!( + keys.contains(&"tensor2".to_string()), + "Missing 'tensor2' key" + ); + + // Check tensor1 + let tensor1 = reader.get("tensor1").expect("tensor1 not found"); + assert_eq!(tensor1.shape, shape![10]); + assert_eq!(tensor1.dtype, DType::F32); + let tensor1_data = tensor1.to_data().unwrap(); + let expected_tensor1_data = + TensorData::new(vec![10, 11, 12, 13, 14, 15, 16, 17, 18, 19], vec![10]); + tensor1_data.assert_approx_eq::(&expected_tensor1_data, Tolerance::default()); + + // Check tensor2 + let tensor2 = reader.get("tensor2").expect("tensor2 not found"); + assert_eq!(tensor2.shape, shape![10]); + assert_eq!(tensor2.dtype, DType::F32); + let tensor2_data = tensor2.to_data().unwrap(); + let expected_tensor2_data = + TensorData::new(vec![50, 51, 52, 53, 54, 55, 56, 57, 58, 59], vec![10]); + tensor2_data.assert_approx_eq::(&expected_tensor2_data, Tolerance::default()); +} + +#[test] +fn test_legacy_with_offsets() { + // Test with legacy format file that has storage offsets + let path = test_data_path("legacy_with_offsets.pt"); + let reader = PytorchReader::new(&path).expect("Should read legacy file with offsets"); + assert_eq!(reader.keys().len(), 3, "Should have 3 tensors"); + + let tensor1 = reader + .get("tensor1") + .expect("Legacy file should contain tensor1"); + assert_eq!(tensor1.shape, shape![10]); + let data1 = tensor1.to_data().unwrap(); + let expected_data1 = TensorData::new( + vec![ + 1.00_f32, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09, + ], + vec![10], + ); + data1.assert_approx_eq::(&expected_data1, Tolerance::default()); + + let tensor2 = reader + .get("tensor2") + .expect("Legacy file should contain tensor2"); + assert_eq!(tensor2.shape, shape![5]); + let data2 = tensor2.to_data().unwrap(); + let expected_data2 = TensorData::new(vec![2.0_f32, 2.1, 2.2, 2.3, 2.4], vec![5]); + data2.assert_approx_eq::(&expected_data2, Tolerance::default()); + + let tensor3 = reader + .get("tensor3") + .expect("Legacy file should contain tensor3"); + assert_eq!(tensor3.shape, shape![5]); + let data3 = tensor3.to_data().unwrap(); + let expected_data3 = TensorData::new(vec![3.0_f32, 3.1, 3.2, 3.3, 3.4], vec![5]); + data3.assert_approx_eq::(&expected_data3, Tolerance::default()); +} + +#[test] +fn test_legacy_shared_storage() { + // Test with legacy format file that has shared storage + let path = test_data_path("legacy_shared_storage.pt"); + let reader = PytorchReader::new(&path).expect("Should read legacy file with shared storage"); + + let keys = reader.keys(); + assert!(keys.len() >= 2, "Should have at least 2 tensors"); + + // Check that tensors exist and can be loaded + for key in &keys { + assert!(reader.get(key).is_some(), "Should have tensor: {}", key); + let tensor = reader.get(key).unwrap(); + let data = tensor.to_data().unwrap(); + + // Verify tensor data can be accessed + match tensor.dtype { + DType::F32 => { + let values = data.as_slice::().unwrap(); + assert!(!values.is_empty(), "Tensor {} should have data", key); + } + DType::I64 => { + let values = data.as_slice::().unwrap(); + assert!(!values.is_empty(), "Tensor {} should have data", key); + } + _ => { + // For other types, just verify we can convert to data + assert!(!data.shape.is_empty(), "Tensor {} should have shape", key); + } + } + } +} + +#[test] +fn test_metadata_zip_format() { + // Test that metadata is properly populated for ZIP format files + let path = test_data_path("float32.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load float32.pt"); + + // Check metadata + let metadata = reader.metadata(); + assert_eq!(metadata.format_type, FileFormat::Zip); + assert_eq!(metadata.byte_order, ByteOrder::LittleEndian); + assert_eq!(metadata.tensor_count, 1); + assert!(metadata.total_data_size.is_some()); + + // Check that metadata is accessible + assert!(metadata.is_modern_format()); + assert!(!metadata.is_legacy_format()); +} + +#[test] +fn test_metadata_legacy_format() { + // Test that metadata is properly populated for legacy format files + let path = test_data_path("simple_legacy.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load legacy file"); + + // Check metadata + let metadata = reader.metadata(); + assert_eq!(metadata.format_type, FileFormat::Legacy); + assert_eq!(metadata.byte_order, ByteOrder::LittleEndian); + assert_eq!(metadata.tensor_count, 3); // weight, bias, running_mean + assert!(metadata.total_data_size.is_some()); +} + +#[test] +fn test_legacy_metadata_detailed() { + // Detailed test to prove we load all metadata for legacy format files + let path = test_data_path("simple_legacy.pt"); + let reader = PytorchReader::new(&path).expect("Failed to load legacy file"); + + // Get and examine metadata + let metadata = reader.metadata(); + + // Verify the metadata is correct for legacy format + assert_eq!( + metadata.format_type, + FileFormat::Legacy, + "Should be Legacy format" + ); + assert_eq!( + metadata.byte_order, + ByteOrder::LittleEndian, + "Legacy format is little-endian" + ); + assert_eq!( + metadata.tensor_count, 3, + "Should have 3 tensors: weight, bias, running_mean" + ); + assert!( + metadata.total_data_size.is_some(), + "Should have total data size" + ); + assert!( + metadata.total_data_size.unwrap() > 0, + "Data size should be positive" + ); + + // Legacy format specifics + assert_eq!( + metadata.format_version, None, + "Legacy format doesn't have version file" + ); + assert_eq!( + metadata.pytorch_version, None, + "Legacy format doesn't store PyTorch version reliably" + ); + assert!( + !metadata.has_storage_alignment, + "Legacy format doesn't have storage alignment" + ); + + // Also verify we can access the tensors + let keys = reader.keys(); + assert!( + keys.contains(&"weight".to_string()), + "Should have weight tensor" + ); + assert!( + keys.contains(&"bias".to_string()), + "Should have bias tensor" + ); + assert!( + keys.contains(&"running_mean".to_string()), + "Should have running_mean tensor" + ); +} + +#[test] +fn test_small_invalid_file() { + // Test that we handle broken/invalid files gracefully + let path = test_data_path("broken.pt"); + + // Should fail gracefully with an appropriate error + let result = PytorchReader::new(&path); + assert!(result.is_err(), "Expected error for broken file"); + + // The error should be a pickle error since the file is too small to be valid + if let Err(e) = result { + let err_str = format!("{}", e); + assert!( + err_str.contains("Pickle") || err_str.contains("Invalid"), + "Error should mention pickle or invalid format: {}", + err_str + ); + } +} + +#[test] +fn test_read_pickle_data_basic() { + use crate::pytorch::reader::PickleValue; + + // Test reading pickle data from a checkpoint file + let path = test_data_path("checkpoint.pt"); + + // Read the entire pickle data + let data = PytorchReader::read_pickle_data(&path, None).expect("Failed to read pickle data"); + + // Should be a dictionary at the root + if let PickleValue::Dict(dict) = data { + // Check that expected keys exist + assert!(dict.contains_key("model_state_dict")); + assert!(dict.contains_key("optimizer_state_dict")); + assert!(dict.contains_key("epoch")); + assert!(dict.contains_key("loss")); + + // Check epoch value + if let Some(PickleValue::Int(epoch)) = dict.get("epoch") { + assert_eq!(*epoch, 42); + } else { + panic!("Expected epoch to be an integer"); + } + + // Check loss value + if let Some(PickleValue::Float(loss)) = dict.get("loss") { + assert!(*loss > 0.0 && *loss < 1.0, "Loss should be between 0 and 1"); + } else { + panic!("Expected loss to be a float"); + } + } else { + panic!("Expected root to be a dictionary"); + } +} + +#[test] +fn test_read_pickle_data_with_key() { + use crate::pytorch::reader::PickleValue; + + // Test reading specific key from checkpoint + let path = test_data_path("checkpoint.pt"); + + // Read only the model_state_dict + let data = PytorchReader::read_pickle_data(&path, Some("model_state_dict")) + .expect("Failed to read pickle data with key"); + + // Should get the model_state_dict directly + if let PickleValue::Dict(dict) = data { + // Should have model weights + assert!(dict.contains_key("fc1.weight")); + assert!(dict.contains_key("fc1.bias")); + assert!(dict.contains_key("fc2.weight")); + assert!(dict.contains_key("fc2.bias")); + + // Should NOT have optimizer keys + assert!(!dict.contains_key("optimizer_state_dict")); + assert!(!dict.contains_key("epoch")); + } else { + panic!("Expected model_state_dict to be a dictionary"); + } +} + +#[test] +fn test_read_pickle_data_nested_structure() { + use crate::pytorch::reader::PickleValue; + + // Test reading nested dictionary structure + let path = test_data_path("nested_dict.pt"); + + let data = + PytorchReader::read_pickle_data(&path, None).expect("Failed to read nested structure"); + + if let PickleValue::Dict(dict) = data { + // nested_dict.pt has a nested structure, not flat keys + // It should have layer1 and layer2 as nested dicts + assert!(!dict.is_empty(), "Dictionary should not be empty"); + + // The structure depends on how the file was saved + // It could be flat keys like "layer1.weight" or nested dicts + // Just verify it's a valid dict structure + for (_key, value) in dict.iter() { + // Values could be None (tensors), nested dicts, or other types + assert!( + matches!(value, PickleValue::None | PickleValue::Dict(_)), + "Values should be None or nested dicts" + ); + } + } else { + panic!("Expected nested_dict to be a dictionary"); + } +} + +#[test] +fn test_read_pickle_data_types() { + use crate::pytorch::reader::PickleValue; + + // Test various data types in mixed_types.pt + let path = test_data_path("mixed_types.pt"); + + let data = PytorchReader::read_pickle_data(&path, None).expect("Failed to read mixed types"); + + if let PickleValue::Dict(dict) = data { + // The file contains different tensor types + assert!(dict.len() >= 3, "Should have at least 3 tensor types"); + + // All tensor values should be None in pickle data + for (_key, value) in dict.iter() { + // All values should be None (tensors are not included in pickle data) + assert!( + matches!(value, PickleValue::None), + "Tensors should be None in pickle data" + ); + } + } else { + panic!("Expected mixed_types to be a dictionary"); + } +} + +#[test] +fn test_read_pickle_data_key_not_found() { + // Test error handling when key doesn't exist + let path = test_data_path("checkpoint.pt"); + + let result = PytorchReader::read_pickle_data(&path, Some("nonexistent_key")); + assert!(result.is_err()); + + if let Err(e) = result { + let err_str = format!("{}", e); + assert!( + err_str.contains("not found"), + "Error should mention key not found: {}", + err_str + ); + } +} + +#[test] +fn test_read_pickle_data_simple_pickle() { + use crate::pytorch::reader::PickleValue; + + // Test reading a simple pickle file (not ZIP) + // Note: simple_legacy.pt is a legacy format file, not a simple pickle + // It may return None because legacy format reading is different + let path = test_data_path("state_dict.pt"); // Use a proper simple pickle file + + let data = PytorchReader::read_pickle_data(&path, None).expect("Failed to read simple pickle"); + + // Should contain state dict entries + if let PickleValue::Dict(dict) = data { + // state_dict.pt has weight, bias, running_mean, running_var + assert!(dict.len() >= 3); + assert!(dict.contains_key("weight")); + assert!(dict.contains_key("bias")); + + // All tensor values should be None in pickle data + for (_key, value) in dict.iter() { + assert!(matches!(value, PickleValue::None)); + } + } else { + panic!("Expected state_dict to contain a dictionary"); + } +} + +#[test] +fn test_load_config_basic() { + let path = test_data_path("checkpoint.pt"); + + // Define a struct that matches part of the checkpoint data + #[derive(Debug, serde::Deserialize, PartialEq)] + struct CheckpointConfig { + epoch: i64, + loss: f64, + } + + // Load config + let config: CheckpointConfig = + PytorchReader::load_config(&path, None).expect("Failed to load config"); + + // Verify values - based on test_read_pickle_data_basic + assert_eq!(config.epoch, 42); + assert!((config.loss - 0.123).abs() < 1e-6); +} + +#[test] +fn test_load_config_with_top_level_key() { + // Test that we can extract a non-existent key and get an appropriate error + let path = test_data_path("checkpoint.pt"); + + #[derive(Debug, serde::Deserialize, PartialEq)] + struct DummyConfig { + field: String, + } + + // Try loading with a valid top-level key that exists but has wrong structure + let result: Result = PytorchReader::load_config(&path, Some("epoch")); + + // This should fail because epoch is an integer, not a struct with a field + assert!(result.is_err()); + + // Now test that we can load with a real key that has the right structure + // Since checkpoint.pt doesn't have nested configs, let's use nested_dict.pt + let path2 = test_data_path("nested_dict.pt"); + + // Try to extract a specific nested key if it exists + // Since nested_dict has complex structure, let's just verify we can read it + let data = PytorchReader::read_pickle_data(&path2, None).unwrap(); + + // Verify it's a dict + if let crate::pytorch::reader::PickleValue::Dict(dict) = data { + assert!(!dict.is_empty()); + } else { + panic!("Expected a dict"); + } +} + +#[test] +fn test_load_config_complex_types() { + // For this test, let's create a comprehensive test using checkpoint.pt + // which has both metadata and state_dict fields + let path = test_data_path("checkpoint.pt"); + + // Define a partial config that only captures metadata fields + #[derive(Debug, serde::Deserialize, PartialEq)] + struct PartialCheckpoint { + epoch: i64, + loss: f64, + // We skip model_state_dict and optimizer_state_dict + // as they contain tensor references that become None + } + + // Load partial config + let config: PartialCheckpoint = + PytorchReader::load_config(&path, None).expect("Failed to load config"); + + // Verify we can extract the metadata + assert_eq!(config.epoch, 42); + assert!((config.loss - 0.123).abs() < 1e-6); +} + +#[test] +fn test_load_config_key_not_found() { + let path = test_data_path("checkpoint.pt"); + + #[derive(Debug, serde::Deserialize)] + struct DummyConfig { + #[allow(dead_code)] + field: String, + } + + // Try to load with non-existent key + let result: Result = PytorchReader::load_config(&path, Some("nonexistent")); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("not found") || error.to_string().contains("Key")); +} + +#[test] +fn test_pickle_value_conversion() { + use crate::pytorch::reader::PickleValue; + + // Test that PickleValue provides useful data structures + let path = test_data_path("checkpoint.pt"); + let data = PytorchReader::read_pickle_data(&path, None).unwrap(); + + // Test pattern matching and data extraction + match data { + PickleValue::Dict(dict) => { + // Extract epoch as integer + if let Some(PickleValue::Int(epoch)) = dict.get("epoch") { + assert!(*epoch >= 0); + } + + // Extract loss as float + if let Some(PickleValue::Float(loss)) = dict.get("loss") { + assert!(loss.is_finite()); + } + + // Test nested access + if let Some(PickleValue::Dict(model_dict)) = dict.get("model_state_dict") { + assert!(!model_dict.is_empty()); + } + } + _ => panic!("Unexpected root type"), + } +} + +// ============================================================================ +// TAR Format Tests +// ============================================================================ +// The TAR format was used by very early versions of PyTorch (pre 0.1.10). +// These tests verify that we can correctly load models saved in this format. + +#[test] +fn test_tar_format_detection() { + // Test that is_tar_file correctly detects TAR files + let tar_path = test_data_path("tar_float32.tar"); + let zip_path = test_data_path("float32.pt"); + + // TAR file should be detected as TAR + let reader = PytorchReader::new(&tar_path).expect("Failed to load TAR file"); + let metadata = reader.metadata(); + assert_eq!(metadata.format_type, FileFormat::Tar); + + // ZIP file should NOT be detected as TAR + let reader = PytorchReader::new(&zip_path).expect("Failed to load ZIP file"); + let metadata = reader.metadata(); + assert_ne!(metadata.format_type, FileFormat::Tar); +} + +#[test] +fn test_tar_float32_tensor() { + let path = test_data_path("tar_float32.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_float32.tar"); + + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F32); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 4); + assert!((values[0] - 1.0).abs() < 1e-6); + assert!((values[1] - 2.5).abs() < 1e-6); + assert!((values[2] - (-3.7)).abs() < 1e-6); + assert!((values[3] - 0.0).abs() < 1e-6); +} + +#[test] +fn test_tar_float64_tensor() { + let path = test_data_path("tar_float64.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_float64.tar"); + + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::F64); + assert_eq!(tensor.shape, shape![3]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 3); + assert!((values[0] - 1.1).abs() < 1e-10); + assert!((values[1] - 2.2).abs() < 1e-10); + assert!((values[2] - 3.3).abs() < 1e-10); +} + +#[test] +fn test_tar_int64_tensor() { + let path = test_data_path("tar_int64.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_int64.tar"); + + let tensor = reader.get("tensor").expect("tensor key not found"); + assert_eq!(tensor.dtype, DType::I64); + assert_eq!(tensor.shape, shape![4]); + + let data = tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[100, -200, 300, 0]); +} + +#[test] +fn test_tar_multiple_tensors() { + // Test loading multiple tensors (weight + bias) with correct shapes + let path = test_data_path("tar_weight_bias.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_weight_bias.tar"); + + // Check weight tensor (2x3 matrix) + let weight = reader.get("weight").expect("weight key not found"); + assert_eq!(weight.dtype, DType::F32); + assert_eq!(weight.shape, shape![2, 3]); + + let data = weight.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 6); + assert!((values[0] - 0.1).abs() < 1e-6); + assert!((values[1] - 0.2).abs() < 1e-6); + assert!((values[5] - 0.6).abs() < 1e-6); + + // Check bias tensor (2-element vector) + let bias = reader.get("bias").expect("bias key not found"); + assert_eq!(bias.dtype, DType::F32); + assert_eq!(bias.shape, shape![2]); + + let data = bias.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 2); + assert!((values[0] - 0.01).abs() < 1e-6); + assert!((values[1] - 0.02).abs() < 1e-6); +} + +#[test] +fn test_tar_multi_dtype() { + // Test loading different dtypes from the same TAR file + let path = test_data_path("tar_multi_dtype.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_multi_dtype.tar"); + + // Float32 tensor + let float_tensor = reader + .get("float_tensor") + .expect("float_tensor key not found"); + assert_eq!(float_tensor.dtype, DType::F32); + let data = float_tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert!((values[0] - 1.5).abs() < 1e-6); + + // Float64 tensor + let double_tensor = reader + .get("double_tensor") + .expect("double_tensor key not found"); + assert_eq!(double_tensor.dtype, DType::F64); + let data = double_tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert!((values[0] - 1.111).abs() < 1e-10); + + // Int64 tensor + let int_tensor = reader.get("int_tensor").expect("int_tensor key not found"); + assert_eq!(int_tensor.dtype, DType::I64); + let data = int_tensor.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values, &[10, 20, 30, 40]); +} + +#[test] +fn test_tar_2d_tensor_shape() { + // Test that 2D tensor shapes are correctly preserved + let path = test_data_path("tar_2d_tensor.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_2d_tensor.tar"); + + let matrix = reader.get("matrix").expect("matrix key not found"); + assert_eq!(matrix.dtype, DType::F32); + assert_eq!(matrix.shape, shape![3, 4]); // 3 rows, 4 columns + + let data = matrix.to_data().unwrap(); + let values = data.as_slice::().unwrap(); + assert_eq!(values.len(), 12); + + // Verify values in row-major order + for i in 0..12 { + assert!((values[i] - (i as f32 + 1.0)).abs() < 1e-6); + } +} + +#[test] +fn test_tar_metadata() { + // Test that TAR metadata is correctly populated + let path = test_data_path("tar_float32.tar"); + let reader = PytorchReader::new(&path).expect("Failed to load tar_float32.tar"); + + let metadata = reader.metadata(); + assert_eq!(metadata.format_type, FileFormat::Tar); + assert_eq!(metadata.byte_order, ByteOrder::LittleEndian); + assert_eq!(metadata.tensor_count, 1); + assert!(metadata.total_data_size.is_some()); +} diff --git a/crates/burn-store/src/pytorch/tests/reader/simple_legacy.py b/crates/burn-store/src/pytorch/tests/reader/simple_legacy.py new file mode 100644 index 0000000..57610dd --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/reader/simple_legacy.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["torch"] +# /// +"""Create a simple legacy format PyTorch file.""" + +import torch + +# Create a simple state dict +state_dict = { + 'weight': torch.randn(2, 3), + 'bias': torch.ones(2), + 'running_mean': torch.zeros(2), +} + +# Save without using zip format (legacy format) +torch.save(state_dict, 'test_data/simple_legacy.pt', _use_new_zipfile_serialization=False) + +print("\nCreated simple_legacy.pt") + +# Verify +loaded = torch.load('test_data/simple_legacy.pt', weights_only=False) +print(f"Loaded {len(loaded)} tensors") +for key, val in loaded.items(): + print(f" {key}: shape {val.shape}, dtype {val.dtype}") + +# The storage has 100 elements while each tensor only uses 10 +base = torch.arange(100, dtype=torch.float32) +tensor1 = base[10:20] +tensor2 = base[50:60] +torch.save({'tensor1': tensor1, 'tensor2': tensor2}, 'test_data/legacy_uncloned_views.pt', + _use_new_zipfile_serialization=False) + +# Verify +print("\nCreated legacy_uncloned_views.pt") +loaded2 = torch.load('test_data/legacy_uncloned_views.pt', weights_only=False) +print(f"Loaded {len(loaded2)} tensors") +for key, val in loaded2.items(): + print(f" {key}: shape {val.shape}, dtype {val.dtype}") \ No newline at end of file diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data.py b/crates/burn-store/src/pytorch/tests/reader/test_data.py new file mode 100644 index 0000000..6020590 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/reader/test_data.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["torch", "numpy"] +# /// +""" +Generate test PyTorch .pt files for testing the burn-store PyTorch reader. +Run with: uv run test_files.py +""" + +import torch +import numpy as np +import os +from pathlib import Path + +# Create test directory +test_dir = Path(__file__).parent / "test_data" +test_dir.mkdir(exist_ok=True) + +def save_test_file(filename, data, description): + """Save a test file and print what was saved.""" + filepath = test_dir / filename + torch.save(data, filepath) + print(f"✓ {filename}: {description}") + return filepath + +# Test 1: Simple tensors of different types +print("\n=== Generating Basic Tensor Tests ===") + +# Float32 tensor (wrap in dict for compatibility) +float32_tensor = torch.tensor([1.0, 2.5, -3.7, 0.0], dtype=torch.float32) +save_test_file("float32.pt", {"tensor": float32_tensor}, "Float32 tensor [1.0, 2.5, -3.7, 0.0]") + +# Float64 tensor +float64_tensor = torch.tensor([1.1, 2.2, 3.3], dtype=torch.float64) +save_test_file("float64.pt", {"tensor": float64_tensor}, "Float64 tensor [1.1, 2.2, 3.3]") + +# Int64 tensor +int64_tensor = torch.tensor([100, -200, 300, 0], dtype=torch.int64) +save_test_file("int64.pt", {"tensor": int64_tensor}, "Int64 tensor [100, -200, 300, 0]") + +# Int32 tensor +int32_tensor = torch.tensor([10, 20, -30], dtype=torch.int32) +save_test_file("int32.pt", {"tensor": int32_tensor}, "Int32 tensor [10, 20, -30]") + +# Int16 tensor +int16_tensor = torch.tensor([1000, -2000, 3000], dtype=torch.int16) +save_test_file("int16.pt", {"tensor": int16_tensor}, "Int16 tensor [1000, -2000, 3000]") + +# Int8 tensor +int8_tensor = torch.tensor([127, -128, 0, 50], dtype=torch.int8) +save_test_file("int8.pt", {"tensor": int8_tensor}, "Int8 tensor [127, -128, 0, 50]") + +# Boolean tensor +bool_tensor = torch.tensor([True, False, True, True, False], dtype=torch.bool) +save_test_file("bool.pt", {"tensor": bool_tensor}, "Bool tensor [True, False, True, True, False]") + +# Float16 tensor (half precision) +float16_tensor = torch.tensor([1.5, -2.25, 3.125], dtype=torch.float16) +save_test_file("float16.pt", {"tensor": float16_tensor}, "Float16 tensor [1.5, -2.25, 3.125]") + +# BFloat16 tensor +bfloat16_tensor = torch.tensor([1.5, -2.5, 3.5], dtype=torch.bfloat16) +save_test_file("bfloat16.pt", {"tensor": bfloat16_tensor}, "BFloat16 tensor [1.5, -2.5, 3.5]") + +# UInt8 tensor +uint8_tensor = torch.tensor([0, 128, 255, 42], dtype=torch.uint8) +save_test_file("uint8.pt", {"tensor": uint8_tensor}, "UInt8 tensor [0, 128, 255, 42]") + +# Test 2: Multi-dimensional tensors +print("\n=== Generating Multi-dimensional Tensor Tests ===") + +# 2D tensor +tensor_2d = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=torch.float32) +save_test_file("tensor_2d.pt", {"tensor": tensor_2d}, "2D tensor shape (3, 2)") + +# 3D tensor +torch.manual_seed(42) +tensor_3d = torch.randn(2, 3, 4) * 10 +save_test_file("tensor_3d.pt", {"tensor": tensor_3d}, "3D tensor shape (2, 3, 4)") + +# 4D tensor (common for conv weights) +tensor_4d = torch.randn(2, 3, 2, 2) +save_test_file("tensor_4d.pt", {"tensor": tensor_4d}, "4D tensor shape (2, 3, 2, 2)") + +# Test 3: State dict (multiple tensors) +print("\n=== Generating State Dict Tests ===") + +state_dict = { + "weight": torch.randn(3, 4), + "bias": torch.randn(3), + "running_mean": torch.zeros(3), + "running_var": torch.ones(3), +} +save_test_file("state_dict.pt", state_dict, "State dict with 4 tensors") + +# Nested state dict +nested_dict = { + "layer1": { + "weight": torch.randn(2, 3), + "bias": torch.randn(2) + }, + "layer2": { + "weight": torch.randn(4, 2), + "bias": torch.randn(4) + } +} +save_test_file("nested_dict.pt", nested_dict, "Nested state dict") + +# Test 4: Model checkpoint format +print("\n=== Generating Model Checkpoint Tests ===") + +# Typical checkpoint format (use string keys for compatibility) +checkpoint = { + "model_state_dict": { + "fc1.weight": torch.randn(10, 5), + "fc1.bias": torch.randn(10), + "fc2.weight": torch.randn(3, 10), + "fc2.bias": torch.randn(3), + }, + "optimizer_state_dict": { + "state": { + "0": { # Use string key instead of integer + "momentum_buffer": torch.randn(10, 5) + } + } + }, + "epoch": 42, + "loss": 0.123 +} +save_test_file("checkpoint.pt", checkpoint, "Full checkpoint with model and optimizer state") + +# Test 5: Edge cases +print("\n=== Generating Edge Case Tests ===") + +# Empty tensor (1D with 0 elements) +empty_tensor = torch.zeros(0) +save_test_file("empty.pt", {"tensor": empty_tensor}, "Empty tensor") + +# Scalar tensor (0-dimensional) +scalar_tensor = torch.tensor(42.0) +save_test_file("scalar.pt", {"tensor": scalar_tensor}, "Scalar tensor (0-dim)") + +# Large shape but small data (testing shape vs actual data) +sparse_like = torch.zeros(100, 100) +sparse_like[0, 0] = 1.0 +sparse_like[50, 50] = 2.0 +sparse_like[99, 99] = 3.0 +save_test_file("large_shape.pt", {"tensor": sparse_like}, "Large shape (100, 100) mostly zeros") + +# Test 6: Mixed types in dict +print("\n=== Generating Mixed Type Tests ===") + +mixed_types = { + "float32": torch.tensor([1.0, 2.0], dtype=torch.float32), + "int64": torch.tensor([100, 200], dtype=torch.int64), + "bool": torch.tensor([True, False], dtype=torch.bool), + "float64": torch.tensor([1.1, 2.2], dtype=torch.float64), +} +save_test_file("mixed_types.pt", mixed_types, "Dict with mixed tensor types") + +# Test 7: Special values +print("\n=== Generating Special Value Tests ===") + +# NaN and Inf values +special_values = torch.tensor([float('nan'), float('inf'), float('-inf'), 0.0, 1.0]) +save_test_file("special_values.pt", {"tensor": special_values}, "Tensor with NaN and Inf") + +# Very small and very large values +extreme_values = torch.tensor([1e-30, 1e30, -1e-30, -1e30], dtype=torch.float32) +save_test_file("extreme_values.pt", {"tensor": extreme_values}, "Tensor with extreme values") + +# Test 8: Parameter wrapper (common in models) +print("\n=== Generating Parameter Tests ===") + +import torch.nn as nn +param = nn.Parameter(torch.randn(3, 3)) +param_dict = {"param": param} +save_test_file("parameter.pt", param_dict, "nn.Parameter wrapped tensor") + +# Test 9: Buffer-style tensors +print("\n=== Generating Buffer Tests ===") + +# Simulate model buffers +buffers = { + "buffer1": torch.tensor([1, 2, 3], dtype=torch.int32), + "buffer2": torch.tensor([True, False], dtype=torch.bool), +} +save_test_file("buffers.pt", buffers, "Model buffers") + +# Test 10: Complex nested structure +print("\n=== Generating Complex Structure Tests ===") + +complex_structure = { + "metadata": { + "version": 1, + "name": "test_model" + }, + "state": { + "encoder": { + "layer_0": { + "weight": torch.randn(4, 3), + "bias": torch.randn(4) + }, + "layer_1": { + "weight": torch.randn(2, 4), + "bias": torch.randn(2) + } + }, + "decoder": { + "weight": torch.randn(3, 2), + "bias": torch.randn(3) + } + }, + "config": { + "hidden_size": 4, + "num_layers": 2 + } +} +save_test_file("complex_structure.pt", complex_structure, "Complex nested structure") + +print(f"\n✅ Generated {len(list(test_dir.glob('*.pt')))} test files in {test_dir}") +print("\nTest files can be used to verify PyTorch reader functionality:") +print("- Different data types (float32, int64, bool, etc.)") +print("- Multi-dimensional tensors") +print("- State dicts and nested structures") +print("- Edge cases (empty, scalar, special values)") +print("- Model checkpoints and parameters") diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/bfloat16.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/bfloat16.pt new file mode 100644 index 0000000000000000000000000000000000000000..6e5c973b80a492584b1d695f424002c154ecb3e9 GIT binary patch literal 1584 zcmbVMJ#W)M7{0{KM@_3Homy3*j6tHQzsN^LDYB3Uj6@m)idc-}xWU4Wd-kOWBqXFN z0}?YM5*y+Vu=W=q!B8=D&kx z^~?y*zqBmRb#03{-XP%b_&wXVdp8}6@R+~c#uo@)Xye78+R_WfS6>FV-cu?Vt*JX- zL8W--)jiMOH;Hj*`vJ|MsSAk%5P|l?udhd-6vlMgHB@70$D21y*Xa-T?E%qLDZ$L1 z^7Qwb0s?lP;GbEV9L973%jkM6p!<~A9vqgYZq1PQOp)sl;@Y5LwEcY*d;nv*h9$He zYXDbm3YyxSUBL0qrQ?S;ApiW)kGJRNFQIF3{^(8{GYgP;$o=U}|K9oMzmCK>clC7h zf$clg0nbcuui^AGb>sh}2n|J6npcIoAW1bvl;m2yE-118NfWVN zTuu;uW{IL2NaI;AEhX^x>8V9RLjR`0tQkrP@CgeZn=g%KFKIo2Zqp_hrT({lT6#Wz sWemE=ZA5VBwh(8}6X;YlenVfSL2;*3A9LY%*vN8FP83r>us&S-7di@Ag#Z8m literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/bool.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/bool.pt new file mode 100644 index 0000000000000000000000000000000000000000..1ca216c001e5637a86526809086667754b247341 GIT binary patch literal 1556 zcmbtUON-M`6uxPv57Tx)sEjf&FuMxc-oDa~Fp5#L$-+)WDQ-fV-l@S%GdH>Ipdtv; zg}*^?=?`!t_%9S(>26l8Tq!O+XVTUrogz914k5Ys`_3cZIUPN@Mo2bGmaIT7kiP3W zm4PYD^5J7gQ_qu!aeI+?&Pgu~Xu(Fl>v72vu4g?e8)IQRzHE4`KenBL5gZ$nn#>cW zQZEAtA8wjM#*+a=Rw3HG;G4|XdJfgUw4M??v!@e1HRV}dn(}k6EX#Eq zW(nIJ`SM+Fz&tj%ZCgU-^5rhi37+rrf?w|Fne4MWldZQvh2B|Nb&*^QsxI%l-hnBM z3G;kl*VN*2@z9JaQosNH^(AJDzM6W2T$@qP3O7v09*z#!NNB2ZDto-!%kN496S#52 zza=0z`sxJMi5eu(eNRTZkKyJt&4 zB9a8*GTATF*T*RcjlMd3eja}DThAE3E@SYIZ-)2(-v3R~oCyF&s^T-x1_eAdQN4ye z(A4U`_ky+?^~O$0|-N4C1&(kJE<4vInf@nysN_w>4t+ z2ac;9i7bxir;q`isdr7xOd;%UpFEiU#Nwr9j3(vra+9G?<8X)*DnoGczKT?4gi~s4 zgwGrshGW~NK`f^~VDGtY(>2?7ErYO_z1F}h1g|#m*?>F|r+bvW=P}BTWBZhA6G{PR zdI>d7a2BZIBfJh&=Z<@j@jSs78u;RntE%kgFV2NefVpKIg>l6@n&j^iHaL_4Cc zQpt*3i})Eb_b0zy6)S~` zR4MUAsVI~xppx9Kae2Bg5Op;Mg8h>(*vc9BBQ&DtU=$r}z>N@cmllYJjx>KXNwA4F zLrKQLBqusInkU!@*-)MaU9u;}Z@?%#Si$KK{9^zMF0Q* literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/checkpoint.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/checkpoint.pt new file mode 100644 index 0000000000000000000000000000000000000000..5c732602c231315e2ded6ea82cde55da482b7b2a GIT binary patch literal 3258 zcmbVOc~BEq9A0h+TI5neQ1K|BawLQ#pxNg{8jnbgSY}dfn+ahdse$Ds3sfl9%0LHe z$MG1ej0ZZF%2+S@2UcNU+M+UQ)m}P|#}w^>)2ic8#beaIM>uxFVR18$-Qn%`{l53@ z@BQ9Suk-SzD1U!yz!gdbQDzHcURlIi3%R6R6K6^+T4~d20;%Qw*BZ|XNW~e(V0ZzW z%h-$#&crdsT&tOb9u?3t&LF{4d1h&1DPzsIaL~)lv39d1(OAq`Z4Qai&g2wZZMjB{ zDRi)QV~I=xz2o8xei-GzZ<_KM=;MM&v^LhnEpz=-0(}i0_?=|vXYj&zb5Sw$*U2~- zuvACsBs!mJi2vw3s$pP_+05E(jG42tg$~IQdoE*Vax<_%2^og9;iq%( z^(+`$YzV{4V2e0wfwhdW3%^EO1&lZN;Mwjk1ZPyhnLQ59s(=ZGAUspR7BGceae*V27W|H(Tgf$5$UMTUOJOpK&xVJ0T#vGxKJXDngt4*W^Ansi}8_NV6m zdXf?uP4})J&h)tHNTJ;r`05nScyVT1b|X_8(Tn;F|>d%YK~Aq z&MJ;!Nj7UWsu9QSHOFSoYl_fwCT|mesAmn^x*Lue0^vvsJ^&^uaY$Ie}$JLV3|*EuKyW*3=_dapA+Ej z=ev00&JxgCwu^r&Jp>#cw*f5r^$V16H&Z>~W;m)nc$#0ETn;YZn2HuHdCH&j$fg~O zx1i09C1~Rd#6R7W32qlE!TtrUVA3}blo{20M%yP~O=dQIEnk~NcnG;*)D4qp5L80}ux^q=+YxAfWU+`u}4gaqC z0$TZOU;6g1w}G_BU7%}yI=DKayxZ^E_NDnB;I)u3+;<4Jksy3Up@-+kue-+>Hto;RwEtUqu#wX*!_q2RUq>2$0+KA1bLlr=PNhW@v0Tx z{I}RE?xLpU`ZABXUwN#h+2bsjoUH&`qdZ&2#$QzD-ah~?ZBu}G=sdmfv;t+Y*&xka zjGUSd`u<0|!KIy{&h}&b&`i0CZjbVJ$}$7^4rLW;Z=Hua*_@X3s#;*IEJs`azD}Rr zu0$F7g`iPg(DGzVFnH0JiuARQd7U&<-LU0LIw{MWUQl_k#qaz&aI(%0HY*d*?4u#* zy-5xHpI|57GDC@ocX2D;&wP3Qhfpe^cYRe{K63N0p(zp)`Nv)P*)Ol3F;tQ;a$luBtzs!FMnDU;}o%{{81c1ow4b!jmwROo*;Q`Y^ptOdveNB1PbJIFNwA?f7)(5gtX+BA#H} zX9;-*oPX|A!V`tGCH>bXF;(A5=FT9jsKNbk{4^H#6aq1>bI2VoEO=r#X;9qZxD^mv zDy%PJ_@hDKGIzMJl!)PN_>6Urp5hJ{?j|uj3OC2@aNI_S#S6DYh{!t9V+!Cs8_*pu zOx73?{wFbh@NJ~m`TEZ4flc&E=pzX1dFs9Y*1ZdG4G(ZlU>s@vDFZ3M_;R&mKBOt3 L0`ZT$?|Sz?pPYFw literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/complex_structure.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/complex_structure.pt new file mode 100644 index 0000000000000000000000000000000000000000..5c2087c0eba4d3e2e17ad34914b0e6a1ea5cf781 GIT binary patch literal 3149 zcmb7GTWs586m~96Lza%Ul+w%AwWXAFtf_Nrnzf5NueF4%K^p{$(1dXlueNZKp7>9< zPKua>T4~~#sDm0YR4R}RE zB9g#UhDJSD%4hKHB6!R2n7*Qli&9ab!B>VofwTw5oR*Y18G%283dM{Z6iP@@HJ?zF zW=o2i6_AwI3PoWd?1QF&j-lZv#W@M~>LET(Er{qPebEPxryUrThJ9%lzGmi2(9HW0 zw9N1n@8(_0@C1)@S%%j0nM^@dr3}L9X}%YWSg4epR5Hj1PY$M_4Z%|>Xs;$ctB4wO z$XI~9SJnwa&?z_bZUkLb;3e44JC@+Ud6~p^BRH6Xo>KJ<>M4hyw+wv&*>6G#!_yeW zi=dyNIC=LnJcCtYc>_Aqvj_qy7?jQA5jcdyoCuz)hAqKiLO!I21Q86UfG+9mSxMi5 zP#MBj_9#R!j0ZuKz_@tlGQ_Y5%s!$+#Sx6AAYoyT!4VwhLNHbhTY_=IepC-hB4AQ5 zf!WFCW(xUJ${ZXk0~<*1!3(mI%}RMeQ_f0ooY(6pU&;y9m!!cY?<|#q(|n`%#=EmC zA2=zB__S?>_fiLIRCw@Ip_mhquu;1l+f^-eQtUM>MCh*lz4jKV8{+nK{nSt`x{ZPq zL{*u~=cGL1*ui>?W)UM%B7iv!f8bOp;`Rc%s8|gFvx`V7kox7=(8Kd?s!$b#REcO< zI(oN*>>6=<4eeC4h6d6$hG~waA6dZhze|^JT-S1@j?zdLPudOi7*3VZ@41PG) z&~vQP!u_{;c_GSVFMP|mA6#7}N>6?*H_=!n@p?*qc1lAW>)BA6ynd5eNY60w*X}cy zZe09|;NMyQUQ|(F7f@ zw#nGwr)vTHUWiZg4kCDK@-TMnPIAL!_H&38$JmN{O&E7D-%cD$x3|I>8*9G_ZUzqs zHQ;u1b1R~;O?H_OZ`DNHYR^_6V;3V8zHP7IQER6(y~kQnjJ?W?LgAUr1_ke?dH0MR z#*Fg1JxbV$V(cGglz;3|B32aRsW+pLp~5C3{1r84WIV@al;7=9VpbI6x$M~9ApGwn z9*=iw+2{aP7~{bqhiBVv)r6^iq^Eh0=kP{ow`*t%gtRJRn^%pDzSz!?K8_8?ky)O) WM@BGg`jpc^zVkT7Pm$;KXa5J3Dfi0& literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/empty.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/empty.pt new file mode 100644 index 0000000000000000000000000000000000000000..1985924f9d2cabe57c0504fedd98529cbfd33e85 GIT binary patch literal 1499 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 DChA$u literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/int16.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/int16.pt new file mode 100644 index 0000000000000000000000000000000000000000..624380e80e7ac64549497624f7a2d9dd96f5aaaa GIT binary patch literal 1563 zcmbtUzi-n(6u!jG58R{$L{X6{CWatE;v|mK2&#gG%wQzaRG=t}<=RJT;lw@rQUp>Z zB$NS(zk#)X0xP*JH~brRi!UF+0Nbfz3=Y6_u4AAfKWbV)Nzg7vNocY?eUt6cSe?NYW}pg zU*k!-RO;p!!ecA-J|>(G;kS1kmu~xi@?@c#VrvAF>2mC8?2SlP*cPQW1`XqlZ&$zvFy?D0pgR#) zRRl%8eI^0lIpOI2BIF<7`+rW(UqqWh{_t9Fokc*Mhuoju_8*;m{%c5_7ywGD;$hcf z4tSx1do{~cWZ~3Ogmtklwi-fHsMRE~TB}P<_SzDg^#=STn3rnn$1XW|b9kY|ZU;tN zO@ZLB@}tGE1QW4{_kkofYX6lO?l=4Lv58_zQbY~B8l%`^MHNs?RZWs5YQg0g%jb|K zw0;sC^-m!Ne#}nl-w9NNBrp(cyD6aBR!bb4@I7= z)oQ~$L-=f^F~)=oApA{--VXla$@(zERtO|JfWPuNf6&f90Vos2U!+e&6-> z4XPhv&u8?iQeHYcl%fgTZ+{%U0J)H>xvNN?bB8C_4ci(!``DqX(ms|xRqf?rrv(D8 zJR;xM*yfO{1#F^gp@6-|6uVF^RoOY&zH=qEMX8NJ!*u7{S?~emY7IqnHRh^@pemRD zO90PyE3^DMo6SJQv-e*rMK*?9t)diNMRdI~Tc|ZR0JK!Y$DYL; z@WcTB>efhAgnzDu&}&I8xh-@BQS7vuqSWbjg-%oG$!)18igK?jcRQjWfyxV?g%nF&lV*uqa5KU3 zDPoDNp9aUB1MXSGT~?_@1sXyc8NdC~0>*wFMaa-+X=waPwi3_*d-uX~A8VGrmdjlV r5$Cz>2n!D@#QggK9wipP;2mYbaDS%WWrK0>W@Vvk877ZleX#a7CZkL{ literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/int64.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/int64.pt new file mode 100644 index 0000000000000000000000000000000000000000..99611be4fc4fd945dcbca21b1d908a2c41da0472 GIT binary patch literal 1563 zcmbtUO^ee&7@mID*zO9txWXcO5Gt%~+UBDL7d1#O5zJPU>Lp~8ScBPQCz-A&h=LUH z>JRWJ_zT3Ne?ZXF9=v(llU@{WIOwqt7Hw6@=2 zaK6zPRwyE%EA^3u3n7HL=DDLA;Xei!hdH{0#ie0^evQ2`E-OWXE87ZEiW6K_aua;y zg<*J(V;RKu+<>|6o0e~xS8anZm^nYdM+vSCa6M?MN~QYp_V*93DHWu;@YHMQWOVB0 zUC-auiMDU~0i~Cv<1>e+wP*wN+pnLWfm+DL)a$4}rH)Rn>5e^ecP*F55DSGCd9Eo!({8rDewX0VhuHPIpL}n zD@zyuOMpptgXsMVV-xNm5uof%_u$|R{Rg>NSsk5=l*KoGcDb}J13P?tHM+UH{u_v! z8UR|V6p8`)al+OYHIur-hHcY>(@5eV!HBPDkL{ovt8?tkCPXc~KC00^1jR zeUasPP}zR|$+9FZxSC@56tl$E&w`UKsinZTXr=yEpv7j9$=k1|kl$it%?NS2a3RLR!wNC|zKCp@DxDDKbHyHYrYX;mpf*HTOc!TNCRZ#(Bs A!2kdN literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/int8.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/int8.pt new file mode 100644 index 0000000000000000000000000000000000000000..a02b42baa4071db75a186b9aee30ef6536db23cd GIT binary patch literal 1556 zcmbtU&x_MQ6rTRBX}c(NS!KbSP(j-?P18nM7o+5ogKb4AUP3kwpmk(T zXtyyks8Qd4V5`a*bT4j4>Bj^O^9)V##B*GdGO6R5yLEj^E!*RC7w=3hd!+l@`hmca zbgeclFoefW8e>c{K7_lmYq+=le;mmUQ|t&&a>ES!n)_3d*Rqrp`Wn)*Gg8!2Gji&g zX*#xzO=>w4kGtiLu!~1GEt7JDyEq^vO3DLL@#;OTP@H|)e))<~f%g)#uA;Mk*7f_2 zyJt}S0J|PzSC#5&@~jw5pnmiH^HYcxJehg}U0F~s64wpe8c+7{gsO^kEPK4#t7}Pw z0Jaaww=}yPJUM_hr1$~sJfzrxVyVit|Gak@iEUA8<9U6zzMcaefG5XLLf8MOs==$u z*2w~Ra)bo9{o{pSSDBCzc=GM#Wd(v)J!$?e4sL(F{k?5=BAm$U;}{h>|Qd+YsgR{^?IORv+-5 zmV$tT&JTA-1vVlUnFDcXSpC%q><3%Tp|NI)Glb>45n<@Fmhu@^dL~XX3+ZnyLh^n| z66ijT4XdXV!QN$63bJqTabWo2%Msv*C2%3NI5PYgn-S!I4Lm>RV`b9Q^7%^(!1>JO m016WoZ25f)(}>wmn4ioTZoc(8=db-?m4kL=m;!?H{@LG9Zch{d literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/large_shape.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/large_shape.pt new file mode 100644 index 0000000000000000000000000000000000000000..f1e94176474a4b977517d2b826f058f87f0e72fd GIT binary patch literal 41605 zcmeIwO=}xR0LJl=(55bz;A)Ge*vCEE?bMigC^QYFE~H zNTCo23FI2cC6u=GoO?b;OOBzJoI(lt0zGubvW!-)C<&pb|CnL5-koQk{q6MJ{Gt%0 zk~lWbiBCl2Cj;rFJN`&k0$=;((e0?)am0jrSS&^DM{ee{Z@e-#S#lF5i{->iBw{qs5syeZiH^=^H zPZhpB{h3z9tFCYZH+ZN@=8$`+PCn}Q<0z7S9md1d`7Q}$B7<+jzIK%J`89P)tB2THeEMYLCn45e~=m3^6{VLa@1E{hxQKN`+KgP#YR3+9n_&Tnu@lrs&=fv^2a zDwEKU!XJEX+IV5m?R@bLg{rH~MsuxIU8&aV?PjgsXs@hPo2}Yfv$@t@Yc=b&R-?As zYS@_LG_pm|6X| zIh#%>-;9%8UwemNU^Zy|j(R=8T*?GpHQAY+=pW`w8*)C-Q9?t~) z`-bYdg~FGI>z49g9VHYGe@%{m*)M0t>aPa}vqcIAmp=V%d~PrI+!K~k6wRN#e|+yh DRoZS( literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_shared_storage.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_shared_storage.pt new file mode 100644 index 0000000000000000000000000000000000000000..21d849c40f16c27dd051b2c1fd55aee2e5ce5829 GIT binary patch literal 729 zcmZ`$OK1~87)~}xldiT+YpP9a)MFG4&C5n4yP{H*)v6K8qLejmCf(KTZvNd#M4=E5 z0aF!f4%Xi#=r)kckRMRT;Cu z?#{s`44ZS%v)+!TO+c?g!YCf4(=3z9K%dejgosIynuKc5PfZ5u3f0AO~VvT=W zcw{dxzTuhYL1*RreQw7>x1DvKWiJ*Vb8>4RxBBs^9ct5EpI-U-;lKml{M^saA8K*) z$79@zT;s!m;LH85f7aBcbOY@by9G8FWlkoMbsDP^`i3|az@*c literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_uncloned_views.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_uncloned_views.pt new file mode 100644 index 0000000000000000000000000000000000000000..2b1fb2e23f80699514f370536baec2178bcdfc55 GIT binary patch literal 833 zcmZwDPiWIn9KiAPpPg}=RVOHT=)pq;DP5i21i?DS5aOS%ddNY^Y7&-6X!?@Zq2i#3 zlWvF%5g9V1h)5BUAtFOWiimjh=y6BAc~tP=H!I?0A)k=E_j|uTX~maS@6X!w@9ed1 zMR}uP z8=-sC4n<-?By)O-#nABsDN7(b7HJ8 zF*<&%FkUJZkC#Mhs~fY?q#NtJ6zLgF)zn2Xpv4x&&LxwfJ=>Jb9I6)r%MNU7$~C1b z)FV}~ONzm&7}~1cZ>K1R9lCK6&bHNdJN+t|8Lb#q*%MXSyW~X4_DL~P75k%qa78HF z^%|??EhhMXwNCL~=0*!0^sou#w4!8?MFC}0FpCCSXk!Jd=%9;rY@mlP_=<1X#CQC} zFDR3WlEeT8k--QK;1IGnj2w=lfH6$qB+8h=8JtB0m!RV^W?{jBhXy3B;}%-DjXSuD zHkNS@_pyQpc!)<>#bd1D2|9R+XLyb-Uf?BOVI8mW25+%}cX*Ev=;0$i;q&BQ$B4nz literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_with_offsets.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/legacy_with_offsets.pt new file mode 100644 index 0000000000000000000000000000000000000000..6cc448fdbff141e4a5ad242b3ace5143d5fb241a GIT binary patch literal 655 zcmZ`$%Wl&^6m|Sa=+HKWmPdFLmSGc-IBvT^;{{3=1`6rMm0DTBnbby>C)XZVqEe** zGPGsE8q^P9)irC@u;v^102Y;4aHkFeDeYa1H0Rto=iIp?^^iork2)uJUwnRAYmC&# zKQ%n}K&iWPilPh?F1XJlZ-*sm$YUsBgFAyv6bcbBkH!747lXR*5(LG|A@kC3m!+WX zfnIkF1k->g0t`oWHN1tffU={zy42wDHdGuv%l>*+qzH>so;0E43!eBv!^=b%rNm3v z^DK<|Ucq}iEdpx2?#d@AcJ{UzXvGuKiFi+}7a#%Mtzu8}zGYcvbJ<$4+O6dl7^iXk zVTN(Y69JWHj^dCUE;ywerVf0ZI%2-Sp;NM!^jX6C4?|xNAosd(QNVN;W=_qEJS~_F zFixNalXm9P3RHu#W1!8Pv@vqHByBDq1X68Yz(NMOeW;WZ^c-q0_LIUNk%(uL!9 v^y{yE3J(tHo%}tOYaf?A7?kbr-==NToVP!Htl9rK8@Ud0kC0nOZo~cqo9V8m literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/mixed_types.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/mixed_types.pt new file mode 100644 index 0000000000000000000000000000000000000000..031f4f5bd04b1a7cb1869525aa1c286e1d4bdcc6 GIT binary patch literal 2437 zcmbW3O>YuG7{>=_fv#FBZLPJ6N~=X%DX_rO8f~*__JWBNqaj`>FWa(l%XVPE*2JWV zX<|Qu#`L6#i66k52TvxR>_I)~$%`f?jo-j|SXg%!XhSC%hL?SQ|NrhY&od+wjvj>E zZqzb{P(NBzHsnHvY_7|dnL?Ii1M3e}N#xK2V{F-(-T}ug+uy12`!nTe3cJg^mRpmTUv{Hq;Q!dD5xv;3@35U6>DSV9J<0*V1-Af5d ziNqqfzev@KO~u<o)17F z!#v1AywyQS5+g@)P@q~cKI#kAb-BNOdG=<2eujCFk6^2hup~}*<-=B2d_VpneR%#o z=vJ#DFNt$qDK;~&uP>g%_&cw!wgtHV$ydIK!|#6(%`k3^TAS*|4$pz&=iSwNhu^;s z1sKIZee+f1vO+8HaTYpgMkz?*wJr>VBK&Mj;Ae$kEXv3Da9rTyK_M232BSh$5JFKt z&PPK*0bG8nX;Xf>CtxNMHZW|QJmx+%1V1pN-8p0>n48cT6a9zwB{RX&-K-#UGx0K@ zC2E-gwRA8m%iI)(8P=YKWi%rz!d%rJ263Mri)K4|FR{j(tC5ZW)-IklbT%{Q+G69E z=^@ynAGXGutAUN*YZuR3>)n75se0qj`-?F%BAa5|?OabCxD ogTOW3HP^TG4UNzSoB(vtIQUzH#{1y4c{=vif8`K|;ClVqe~Tz9H2?qr literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/nested_dict.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/nested_dict.pt new file mode 100644 index 0000000000000000000000000000000000000000..686286ceffb49f9efac9eed64b1d9121413d2134 GIT binary patch literal 2437 zcmbVO%Wo4$7#~04nB`H(g8)gLG`vg;j=dY!2{c$;rCX_cz!*_k?ZMsHOLoO#^R36> zAw@z$iHej{;fSiLJt85fDz%rgy|n6~hZZ5Is-9>MR2*7~f1vaE)tz@bDhIT)$^J7W>{EW_To8RAGp*vCks6h@IGtOZwLjG1KaUTqM?D2(S|!sN9j zc#Xlr6ejDi71)p32O5xr6b|L!aIKceYW>lX@3NmQu?4}%cHPBM@)oy?iSrfbqD~7- zhErv$qbhTr&1~=L#vf8><*UDbyMbGS(cManNW7KiZWnahS}b2Q%2ZXRHtR;7Mgy74 z@Mn;TV{|t#OipqJ7OqmGf`>y@(wo(7t&nX|Y8&WbW$yRAmo2v8EADb%4iC7iYd>bt(V@3LAK$~=VDxen?{t(>l>-}c^ep$s z+}CRp-=N;Rr{^DIA;IXSH`A#ns><|+^jhoQ>K}aiWoG=zVRzfw2bpuqPww+uU$|qG zKHUCJz#X(=-=eSDi+yJ&m?w;0z9u?-Nvbl|)7MAKW%q~s(TBI6-a8e({c(LAM%E(Z zJDCW8mtI2OymYdTiTf>lzL z*EeAxH7(7`X=yf{l#ffYB+jIznWUJO<=N!yarO~qIVC2iljt(z-ZkTFO)#=r01dZq z9&a@p!WUdy?;i3qc#F^|hyI?`B`<@o2KtG-#U#cNone+aqK*pY=kgYXVUFvOkE>mb z{1o0S9^fe6W7nY9PNSCi@!qWD@z2-8vx3g=j5m9DJYJw3`l282%?2L-_j-8AkN2MK zL2mvZ_VN174Du6rkKH6k@W@B7enMomNaSb}7YiM0GYHM6R)4pCpb5ExHvnr4hyQ8F U{w?_3x*hxKZv`R{9N!rG4=e31>i_@% literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/parameter.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/parameter.pt new file mode 100644 index 0000000000000000000000000000000000000000..53e3b29864233ed9a8eed2d513a896382612cf98 GIT binary patch literal 1655 zcmbVN&1=*^6rbI6KN_oUTY6Zm+JlEy>NfkmtNnl=xkNBlEQ^QHB5&35K*xmgWl{v6iK2HV`n6iYzS;M*EdK? z9i}9GLq)2f3cF;OEwNo9J~B5M~)XQmVD*gFO>+%Q24X#CIDvQo@nabU9+_pjWP3(Fso+6(KB#g@){LDj`H$Oh^ zz*WGb!%U(wA7+pn-LS2-P8)ZqBIo}UDHM3Ht&~8((iZs^XTiav1&pHwPQdCtiXBL$ zBG>;t?x7mnqSVHq;o-v9Y?8$XkJgYu^PC2#$~j4qYyT}klXjL`FU>{m=X`fYXm`KI zUv*!v>@U68y0*BTkh_UJ+y!+9@5)y$FlFG;>PFF7PMu!?*=Tu15!l~9U%U19{7)f? zW6$+2?_t+s6|ikUZR=K3k!PcW9VVSyrCO<2s+XmzR47zR`9iT$txCmuUCOgfxl}3C zinVI3UIdj--#(j|X7>lz)EEf%CkNjtWAGwie)s!xB#h@6c&GpX literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/scalar.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/scalar.pt new file mode 100644 index 0000000000000000000000000000000000000000..7b579f9b2fb9f0febcd5f05b162eb908449604dd GIT binary patch literal 1570 zcmbtU&x_Mg5Pog9KaA{lL0IUao?Fl~P13ZB$}T~3$-%Uu6fYs0K5H=B>`Pu(6a+y$ z_)q9R;N6P{kAf$~lX%mEp7!d|H=E{X4Wc_Rr0IL#%r}#7#?UJp2sIjL&6?-}3T(@@ z{NBu>7Ju}>)zl`so3*FqCj?Dv3{CMO@O)CTsps4Gd2>k}H{eVkA1obrW`@V+vB;5f zr!%cHM8IxZb4)5BgwtHlqPN07IkGV=u{#2(PAlwbA1z5uFHuq->!{m(VcVYTVw*bN zBH(WNGwkEpb;qV0X>=##93|X@oDXrW(?OO|U0A9>t6YWJDKvg|KQ?Hzb074xV zvUB4P-AP-LKe7kaFr*#@R1u$6&+PM|B%<+14x)6QTgZ1+XA|4}lAUaMVNK`vG<$6(m6VA1nE f>?UTvV3{&wxYMI|)$l!BT~!!fhN&aCAKv{9lp$AO literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/simple_legacy.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/simple_legacy.pt new file mode 100644 index 0000000000000000000000000000000000000000..61fb5fcba629e5122c0bbcd1fd41b7a1e9060402 GIT binary patch literal 614 zcmZuu%We}f6rJ%*5^CDClxKNJEQd{$OrkWiN*Aabi&xlqgGTAZ$`}oHuE)~~q)J)P zit-0YUBHf?K-uvlZ0IK-vEWV;AV_#&$;aoObMC!U@}6ct&-Z`afBE_8PGd?QU3hpO zgA?#A$8jb`Tb<}EJ`^UGT7ioMzJWiPv^Ep5P$M~1K;ChRpnW|NaW3D89K2)j171Ne zPqnd7VT615lFCAr`P?rxv>HQ=`9<;Dvx?}EkmJ;Xn^Y8<~3IMf^eQ4FJQ zrxSIXaC516YpJ+XD&Afw-m!3Z0QbtNz9=`j+W)fL{RdnQg{**oqI;r2Fxl!Uw^W{s i`uEw}y$}6w?N77w&0zMGeVMI&tnB^ePy3ED?f(M2D4+@e literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/special_values.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/special_values.pt new file mode 100644 index 0000000000000000000000000000000000000000..f33ad35ba690fb3386b0523b0be1e5ee5d2b7fe1 GIT binary patch literal 1626 zcmbVM&x_MQ6rOIkzq0Pl{o$ps;9|wKn>O9F1!W;IepcUlz zvF%vI99m?6{oRhmtYZH@(X|X}2R2%IOi?Qh(G2%|*P{uWxt@KmXbzY|e983i;lLps zGuSqVWr;2o3at!8_;AwdVwwy>B%QbxyBYkH=yEFoXJndcCE;uL2Q+P@7|k3Q$ViQ8 z)<}$L?wM`7gkYOFZqJu)cpdEF&Nato5|u7C=?bG~nsn7KHjGU6`pew=Hvq-G5qs@C zS{1Kdyz6>L7Bh#~^C6F}p$>YFE}Z<$nku^x+9YBkn&TG1zFIvw5AqGd>F|?(uBaQ9)!gf8_J0qN7MW+s9Yk zTPN3l8(k8!6(^VXu?Jn|JhHfqO{b%47fzKN9zm{EluCV1uFB=IS}BzkwOW2siHv5hbjuwFt^>sHQ+F(6tB=mdbhr;_D*BRE#1< z_#w9=7)_X!0*yaQtYM_nJJZm7^70&n&t-12?_{LF`j`)4Fo>IM9jAhIF!EBo=^+NT I;1a>vUkb);l>h($ literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/state_dict.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/state_dict.pt new file mode 100644 index 0000000000000000000000000000000000000000..d1869c0d8f846401c9833f51c9e3a85daa511800 GIT binary patch literal 2363 zcmbW3OK1~87{@2gqvaT~M-e89z-wvDs{!!F7Gf8T#+zVDk& z%U&MkUWZ;s!bzs3qO;DA9Q0*9_!%Dh zvtE4F#>y}thY)NWl7Z}%eYapx_Q{@Gu<4GbS*EFL$gqkf?u?z&Z9R7io8};QCyj-RmM)j=x`Xj5bVsru52^K7*}n$n?VS0kAv7-MRXIyJ_oTM z!2=n1upCXxjlsn2>FX9=8Zy&sZeIszqPH(PV%eiAQpR<=gqKMY{mw4N2=Udz{7tgf zWUfu@0=*2ex_gRh8u{X=UPO|})2XqXBz5i*y~IFj0)A`2!O2|PKpQwt8yLQXbc<}Z zBqrA#cgd1zAY|&q!{muCJ4qUoxweNeI7WLQRnfysV*f@B{CTi2IP+;}cKghUO6cV4 zL9y~P_4nP#?C&2hXYapwG8otR%9-SY%GJ|Tvxk4(tq`Y=Kl`5X*d>|uoQ798jY;Ar z)@e|FnEHA_OucO$nImlN!@ch$*~zTO?pwi*OJa;=FRgLCD-Kf|B);|LqKq>U8!M)(2mbE%8b)T+;X}YS^3;5a@@+cPQmeZxc%Ac+x#~H## zr*pc6dG}5~6TMnJZatlC-Hd*U)vsO$X*m!$QkCuY9<2em>fO@y`C=LakR*K{53D1f WCg4-`lgK#}{fqx{fCJ=t=h=S-91|D- literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tar_2d_tensor.tar b/crates/burn-store/src/pytorch/tests/reader/test_data/tar_2d_tensor.tar new file mode 100644 index 0000000000000000000000000000000000000000..abe84590c108c41e5f1702abfa5958208af434eb GIT binary patch literal 10240 zcmeH}(Msb$6o%6z+9kN)BX~JiC9T?CZUymXSQeB*ZY0EX)WMi(W==#Ggj~9B;iLE{ zUiuiGT3ca}-ep2rD{X^Nx*lyq-s-!NjT48%8RNiztwe*?-2h9 zftk*)3f1mp=B8ff|G~%Ju=Ia_cLe{pyDE$9ji0mS3zZW)-bKIcR9Q7W)Hy}sR7^In zGqUCGS#V$yZoDZAiI^S;qhVKO>dR7I0cJPGny zEw@N|LIyGzdRrg;fAHm)PgcWvWIeH-S>A!?ZL@Y*F>B01fB*=900@8p2!H?x lfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900{gW0O=bP5BGaQssG+!0R2}xE8Fb39T~|>9+6SJc5c%dPx#dsXzN@{71G_-q)puR)&0%#(RF zdOgrHP0SV21baori(ofty2@_;(!V<%mHz~#{&D_SIU(P2=eAet<~A!8`kQ*GTO8n9 z%P%aebNTTu-%1Zk5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X J0D)s5@C7=wmbm}` literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tar_float64.tar b/crates/burn-store/src/pytorch/tests/reader/test_data/tar_float64.tar new file mode 100644 index 0000000000000000000000000000000000000000..b6acf66feeafd43a8a48c4b17adcc61a2530a68e GIT binary patch literal 10240 zcmeH|%SyvQ6ozx@MFN74;BrLchAy7dj5 z^tLEcS3$x5gv?~l8O}_;lYgRCDiq078YP;mtRiz%(_1<^{@26CTBimJuJl_kGD$L$ak%7}5;956yAM0K)R9;a#XRK6M2wQudI=ou zq}76lN<4Fw+UvB!f~t0^xy&f7`c`1;kw_@5`IVqjPr_X=Ts9rXhIOSUjb=JiG1uwKd>r$KozEGX?$C}Fxo?wiFx@iW zq}MY`(?DM%8sM-f_bb>JO_jOIPx|-V?y3IUUK{!^vnGe!8_PEv8;czT`mK7YQT)vd zdw(nJ^LE#pz3JAR-ZqE^1V8`;KmY_l00ck)1V8`;KmY_l00ck)1V8`;KmY_l00ck) N1V8`;K;ZWXd;w;qp6CDo literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tar_int64.tar b/crates/burn-store/src/pytorch/tests/reader/test_data/tar_int64.tar new file mode 100644 index 0000000000000000000000000000000000000000..abf626cc16f15a211ca772bad528237b579f4d8b GIT binary patch literal 10240 zcmeH|!AiqG5QdX9Z6zT12p;z!rKxG_19*!fD1}@k)MRZJL(*g?g$jip^-X;XoshPN zBJ~s${3j%nompl#-|oNBD;=uzIWroxKHJFijLNee`o25b=eGIra4;~=`wh-%U%J#Z zEqF@%!2f#LSnJ#n!5M$`qR1$VvLsx}LaQvz&D)PwaLSoP5hXH|X{@GcZoLH#2WhpC zp;oU_=dE>ai=fRxJ~*~wI* zKBoV{<#=}{?DX$?F7)5*s?^mtR>YD|WJIiZ?c5cyEM$DGB67r|m~=>{Ix8aLd)^C6 zQi*oLLOzu$iNiV>E(eYfldk4Y(-~K)`Z_nP_lG*-Ow9hI6)j5NCf^YJ6uwFCM+T;Q zzNU1KqoV0Ouw69W}qc;-|THHv6JXG>ZM-w|E?^qopJS9 z+l9Fez<~e=fB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*>mHi0ia CCYj3s literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tar_multi_dtype.tar b/crates/burn-store/src/pytorch/tests/reader/test_data/tar_multi_dtype.tar new file mode 100644 index 0000000000000000000000000000000000000000..4524991d7f3f88b23d0745e4826d083d4d140e61 GIT binary patch literal 10240 zcmeH}L2J}N6vtI+U~?_`fz5-gV1(b|3%syi=e`Rp*zY(^56<=-C%P+ryb! z0@NZGO(lz__1*1!fJ^oVUBl z-M)`-kK!L8Fqh@)S_~`3n0lN4XS2zwC#?KGnUep9y(n(=g;%2GYf%C^UWd=SN_3(+ zmL-JzB~S02)vACTM59$#0)8)|ya!q9Fz>`^FYC%p=9h8EdAh6dpyC|YGslKcO!*5t z6sDtb>X-9A@gZRxe|a*3cU#U6C-^CNx>u?G6dTa3WgIW-H##O(hqu(>-B?G64ejZ)f5h0Uqh*mbk}LYzWe|C;=F!LZUq5{Qc4+g+R@(dDRsLuVh|KGP3G>Y5g-CYfCvx)B0vO)01+SpM1Tko0V1#= F@Ex#H6iNU9 literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tar_weight_bias.tar b/crates/burn-store/src/pytorch/tests/reader/test_data/tar_weight_bias.tar new file mode 100644 index 0000000000000000000000000000000000000000..24961dcf2cbd11ab4abc5789f8c2f151d14a2b35 GIT binary patch literal 10240 zcmeH{L2KJE6vrJoO^aZ#>u!TxbP9=^CYLU~j9s)*=rH(FjO%ELn6YB%xub-@9v8jK#sa4_)JBgXx)*B@@z zHtVBba(3#`0RcEEnCNZeo85^GmT!?Vj;ihDYO6$G+eP$B)RHb;> z>V!CrxhDU$ zr~Ka^j_&*aAlN7WH+d$t_JT>)T`*BQ^da{`x3k%PyIQ@yU40l|FPCGSk1>68y?k}_ zOQ15?!vbt0!0+B1^1GYEx;%|UfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U U1c(3;AOb{y2oM1x@P`Qe1le`i4*&oF literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tensor_2d.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/tensor_2d.pt new file mode 100644 index 0000000000000000000000000000000000000000..d27c30a5589b7f67f18a6da6b330c7024e1c1cb7 GIT binary patch literal 1591 zcmbVMO>5Lp6n&HFd>E@t+l>Q7x=@%xon}5VA7E(-l1&y)N0h-$NRv1Q(@9_QrlpE7 zAQtpThUsvd6aFdEgV#f+VVa)n+9q?nK_K4tE!(%PTaL*@Dz10wMMf99^iojnXt{iESfg)%4zrWE^&+{< zZN2=!^Y;y=AKHEZB~(S^PN0IUZ-0DxhGH?3Syze7vrcxe8?MtI?ArsTDiw~HJL7-d zHtHx~`-pxQpg3l-fEBXE3D`SkwugtMD(&;vJ=NqojJY;yc)sW-x<8)qv#`2bR@UaPldsVP;fje4b8Yc!iuz1^|d2G`sFV&wVP>9*lQYd!X`Fvw6A zMrd}Sxj34+?uj$;c3g=teTXnk+aONByQdH#xCbXUlM;G3j^-V)6hVKBpeKHd!}(9O z7J+wR8qSLU-9#NNo86e8uBLBH@i=rbPOqPFU_t!C2@3(Z@vD!S@Hw1k8SH4l~#rZ|?#wq$Ki6x181=%@nPCN`zNT7kKwvZu$4X7KU zwvaKoB)=#*LodFxBr~U&E50Z-sWdYuB_3*2nGshZlZHkFJ5Wb4P-S9zY9TX33zu6? zequ>5M3AeHC4v#C#-NZjf*D9B7nByVc}tWOvIlxIcr$r3d$Y6`a(MH2vv@PM7jkwc zC+Fwnq$ZbS=I0f2`4^?67Nw@RWG0tz6>@0?6>^sp@&py~7V8CgvvaI(Zdkh>Xe0;+ z;Pk#E0~eC__0sZ-auZA9%TkMqfmXOVu|Y$@5M<)^_jj5>js;=dCdx3FBAW>HeSBh0 zW_n(3YF>$(lOambB84(;rkM%I0rusEFPVUrgD`Ff2s79sIUuRBBsCutP;O3^q}vN~ zQBGz_Nlq%r4Xw71B|+%`gmJq;fWZdI4WLw|Z{X%+LAC=d)Mq(re$RDGkQHzgDO_=o zOX8-(w>H;b$O^I(fD0m>*L1V}50|)NUJrL6?<*;a<;lY^bEc+=@I}dsu*G}pszp}x6cF_6p?%e zPbjkZy=Ke>vf<*q^iaa_r@&x0OV97fMP%xFys(Uf#d;n-N-3I5Jk5EusB5MhNd%g?Z{E3fTDd8igxUY zD8QSIO$Vw=j#(G3A5>_A0F1s1D)4~70$7EB08k%TN)%KE00E>tWCiosfx1BIA!-3S CU4U@_ literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/tensor_4d.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/tensor_4d.pt new file mode 100644 index 0000000000000000000000000000000000000000..644a3f065cce4ddaa49d207ef1dd752d223c34d8 GIT binary patch literal 1655 zcmWIWW@cev;NW1u0747`3?->~#rZ|?CMo(Ui6x181=%@nPCN`zNT7kKwvZu$4X7KU zwvaKoB)=#*LodFxBr~U&E50Z-sWdYuB_3*2nGshZlZHkFJ5Wb4P-S9zY9TX33zu6? zequ>5M3AeHC4v#C#-NZjf*D9B7nByVc}tWOvIlxIXm~SuGXpVGNg;=ZH;*?9kj+?9 z$k~~koS&1Enp~2ZpI6M~UzCzsl$zp_nOwqE$fX%n$X!y%6I94stQX+T&hfhQ_SCIF zgF!d|XCO#2upkA3URr)pZemG%S!z)+&wngH|_2;=sdAcG>3 z&)`W!7Qfewxj;5toR=O-IQ|qE43KQl0|#DlYEdRI1yv=2(tLboikp)TehXm{2c-=x zOpHw|O%2QqjEu}o42_J<%*_o<&CN|MEDg;pP0S1pj4Uk;3=Kf8T+Vi6ng%coKsdmg z5#(Ta$$^}mgg_D~0J9uH*N>cqBvJIT0&^8YKUx+x}oU|T|07ADWGVdgrXgL zA`0+kW7C1^l4I6|>jxFuAONH9f(kq!umDyeAOO?{mJ$V(0YCtf4)A6L^Vos9K@~ literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/reader/test_data/uint8.pt b/crates/burn-store/src/pytorch/tests/reader/test_data/uint8.pt new file mode 100644 index 0000000000000000000000000000000000000000..97cfc77fe93cb324190f8467433bb8ff21e9aaab GIT binary patch literal 1563 zcmbtU&x_MQ6rTRBvE3DPafQWm6>ZZd{RJ*+kX&-GTTzOakWFW6G~4VX(-j3_K??d$ zc=TV8J$m1Z;=#k-Jb6)1;!WQ(X-v`<(S7g;lbP?E_uhQ(^|VZukYbU{*$O#FCf1mD zYD0q?)xAfys+=MB6Lye(#>gNKXimqTy1%XOHw2ch zR0ah=cyQ7fQI_!`g7SpZJN`d`Wd|uZBC^~d17CA*!tz>{v%;1}wCt1>wbYcIdTE-D zZBvt5&e#)fyF=>I;VsML0uwIv*=f#7eOC6WJ*`kY{&@KCHBh1VmS(+1&IegncN}-u z;QBswJ)l>WvvbKqE$T@9=Iiluj23-4^*ZU!sDsRP!?s4_T{`Bf(pP00_p&Lc?>0TBciIBs4CY^ z6aYE+c^Pl-eEaeOgiO$v-(C(Y5WeaO^Opp?{oSk4-M`m=gUB-hU`y3J>ROM!e8XdU_TT-{#s&_F;Y3Jyu0`)=fX(;&UL0jmm#5hWidHADif=Bt=xf>oJNgXsUo>u4|GkQ3o!>$EPf#7-n>@PBKR$Bl7 literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/pytorch/tests/store/mod.rs b/crates/burn-store/src/pytorch/tests/store/mod.rs new file mode 100644 index 0000000..324e629 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/store/mod.rs @@ -0,0 +1,1202 @@ +//! Comprehensive tests for PytorchStore with real model application +use burn_core as burn; + +use std::path::PathBuf; + +use crate::ModuleStore; +use crate::pytorch::PytorchStore; +use burn_core::module::Module; +use burn_core::tensor::Tensor; +use burn_nn::conv::{Conv2d, Conv2dConfig}; +use burn_nn::{Linear, LinearConfig}; + +/// Path to pytorch test files (now under burn-store) +fn pytorch_test_path(subdir: &str, filename: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("pytorch-tests") + .join("tests") + .join(subdir) + .join(filename) +} + +/// Path to burn-store test data files +fn test_data_path(filename: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("pytorch") + .join("tests") + .join("reader") + .join("test_data") + .join(filename) +} + +/// Path to store test data files +fn store_test_data_path(filename: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("pytorch") + .join("tests") + .join("store") + .join("test_data") + .join(filename) +} + +#[cfg(test)] +mod basic_tests { + use super::*; + + #[test] + fn test_store_creation() { + let store = PytorchStore::from_file("model.pth"); + assert!(store.validate); + assert!(!store.allow_partial); + assert!(store.top_level_key.is_none()); + // Contiguous index mapping is enabled by default for PyTorch files + assert!(store.map_indices_contiguous); + } + + #[test] + fn test_store_map_indices_contiguous_default() { + // Verify that map_indices_contiguous is enabled by default + let store = PytorchStore::from_file("model.pth"); + assert!( + store.map_indices_contiguous, + "map_indices_contiguous should be enabled by default" + ); + } + + #[test] + fn test_store_map_indices_contiguous_disabled() { + // Verify that we can disable map_indices_contiguous + let store = PytorchStore::from_file("model.pth").map_indices_contiguous(false); + assert!( + !store.map_indices_contiguous, + "map_indices_contiguous should be disabled after explicit call" + ); + } + + #[test] + fn test_store_with_top_level_key() { + let store = PytorchStore::from_file("model.pth").with_top_level_key("state_dict"); + assert_eq!(store.top_level_key, Some("state_dict".to_string())); + } + + #[test] + fn test_store_configuration() { + let store = PytorchStore::from_file("model.pth") + .validate(false) + .allow_partial(true) + .with_regex(r"^encoder\.") + .with_full_path("decoder.weight"); + + assert!(!store.validate); + assert!(store.allow_partial); + assert!(!store.filter.is_empty()); + } + + #[test] + fn test_store_with_remapping() { + let store = PytorchStore::from_file("model.pth").with_key_remapping(r"^old\.", "new."); + + assert!(!store.remapper.is_empty()); + } + + #[test] + fn test_store_save_not_supported() { + // Currently, saving to PyTorch format is not implemented + // The collect_from method always returns an error + let store = PytorchStore::from_file("test.pth"); + + // Just verify that store creation works + assert!(store.validate); + + // Note: Actually testing save would require a proper Module implementation + // which is complex. The implementation guarantees it returns an error. + } +} + +#[cfg(test)] +mod linear_model_tests { + use burn_core::tensor::Device; + + use super::*; + + #[derive(Module, Debug)] + pub struct SimpleLinearModel { + fc1: Linear, + fc2: Linear, + } + + impl SimpleLinearModel { + pub fn new(device: &Device) -> Self { + Self { + fc1: LinearConfig::new(2, 3).init(device), + fc2: LinearConfig::new(3, 4).init(device), + } + } + + pub fn forward(&self, x: Tensor<2>) -> Tensor<2> { + let x = self.fc1.forward(x); + self.fc2.forward(x) + } + } + + #[test] + fn test_load_linear_model() { + let device = Default::default(); + let path = pytorch_test_path("linear", "linear.pt"); + + // Create a model and load weights from PyTorch + let mut model = SimpleLinearModel::new(&device); + let mut store = PytorchStore::from_file(path).allow_partial(true); + + // Apply the PyTorch weights to our model + let result = store.apply_to(&mut model); + + assert!( + result.is_ok(), + "Failed to load linear model: {:?}", + result.err() + ); + + let result = result.unwrap(); + assert!(!result.applied.is_empty(), "No tensors were applied"); + + // Test forward pass with loaded weights + let input = Tensor::<2>::ones([1, 2], &device); + let output = model.forward(input); + + // Verify output shape + assert_eq!(&*output.shape(), [1, 4]); + } + + #[test] + fn test_load_linear_with_bias() { + let device = Default::default(); + let path = pytorch_test_path("linear", "linear_with_bias.pt"); + + // Single linear layer with bias + #[derive(Module, Debug)] + struct LinearWithBias { + fc1: Linear, + } + + let mut model = LinearWithBias { + fc1: LinearConfig::new(2, 3).init(&device), + }; + + let mut store = PytorchStore::from_file(path).allow_partial(true); + + let result = store.apply_to(&mut model); + assert!(result.is_ok(), "Failed to load model with bias"); + + // Verify biases were loaded + let result = result.unwrap(); + let bias_loaded = result.applied.iter().any(|s| s.contains("bias")); + assert!(bias_loaded, "Bias parameters not loaded"); + } + + #[test] + fn test_filter_layers() { + let device = Default::default(); + let path = pytorch_test_path("linear", "linear.pt"); + + let mut model = SimpleLinearModel::new(&device); + + // Only load fc1 layers + let mut store = PytorchStore::from_file(path) + .with_regex(r"^fc1\.") + .allow_partial(true); + + let result = store.apply_to(&mut model).unwrap(); + + // Should only have fc1 tensors + for tensor in &result.applied { + assert!(tensor.contains("fc1")); + assert!(!tensor.contains("fc2")); + } + } + + #[test] + fn test_remap_layer_names() { + let device = Default::default(); + let path = pytorch_test_path("linear", "linear.pt"); + + // Model with different layer names + #[derive(Module, Debug)] + struct RemappedModel { + linear1: Linear, + linear2: Linear, + } + + let mut model = RemappedModel { + linear1: LinearConfig::new(2, 3).init(&device), + linear2: LinearConfig::new(3, 4).init(&device), + }; + + let mut store = PytorchStore::from_file(path) + .with_key_remapping(r"^fc1\.", "linear1.") + .with_key_remapping(r"^fc2\.", "linear2.") + .allow_partial(true); + + let result = store.apply_to(&mut model); + assert!(result.is_ok(), "Failed to load with remapped names"); + + let result = result.unwrap(); + // Verify remapped names were applied + let has_linear1 = result.applied.iter().any(|s| s.contains("linear1")); + assert!(has_linear1, "Remapped names not applied"); + } +} + +#[cfg(test)] +mod conv_model_tests { + use burn_core::tensor::Device; + + use super::*; + + #[derive(Module, Debug)] + struct SimpleConvModel { + conv1: Conv2d, + conv2: Conv2d, + } + + impl SimpleConvModel { + pub fn new(device: &Device) -> Self { + Self { + conv1: Conv2dConfig::new([3, 16], [3, 3]).init(device), + conv2: Conv2dConfig::new([16, 32], [3, 3]).init(device), + } + } + } + + #[test] + fn test_load_conv2d_model() { + let device = Default::default(); + let path = pytorch_test_path("conv2d", "conv2d.pt"); + + // Check if file exists, skip if not + if !path.exists() { + println!("Skipping conv2d test - file not found: {:?}", path); + return; + } + + let mut model = SimpleConvModel::new(&device); + let mut store = PytorchStore::from_file(path).allow_partial(true); + + let result = store.apply_to(&mut model); + + if let Ok(result) = result { + assert!(!result.applied.is_empty(), "No conv tensors applied"); + + // Check for conv weights + let has_conv_weights = result.applied.iter().any(|s| s.contains("weight")); + assert!(has_conv_weights, "Conv weights not loaded"); + } + } + + #[test] + fn test_load_conv1d_model() { + let path = pytorch_test_path("conv1d", "conv1d.pt"); + + if !path.exists() { + println!("Skipping conv1d test - file not found: {:?}", path); + return; + } + + // Just test that we can create a store for conv1d files + let store = PytorchStore::from_file(path).allow_partial(true); + + assert!(store.allow_partial); + } +} + +#[cfg(test)] +mod complex_model_tests { + use super::*; + + #[test] + fn test_load_with_top_level_key() { + let path = test_data_path("checkpoint.pt"); + + // Just verify that we can create a store with top-level key + let store = PytorchStore::from_file(path) + .with_top_level_key("model_state_dict") + .allow_partial(true); + + assert_eq!(store.top_level_key, Some("model_state_dict".to_string())); + } + + #[test] + fn test_load_nested_structure() { + let path = test_data_path("complex_structure.pt"); + + // Just verify that we can create a store for nested structure + let store = PytorchStore::from_file(path).allow_partial(true); + + assert!(store.allow_partial); + } + + #[test] + fn test_legacy_format() { + let path = test_data_path("simple_legacy.pt"); + + if !path.exists() { + println!("Skipping legacy format test - file not found: {:?}", path); + return; + } + + // Just verify that we can create a store for legacy format + let store = PytorchStore::from_file(path).allow_partial(true); + + assert!(store.allow_partial); + + // Could load into an actual model if we had legacy model structure + } + + #[test] + fn test_key_remap_chained() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping key remap test - file not found: {:?}", path); + return; + } + + let device = Default::default(); + + // Model with different layer names that need remapping + #[derive(Module, Debug)] + struct RemappedChainModel { + convolution1: Linear, // Will be remapped from fc1 + linear2: Linear, // Will be remapped from fc2 + } + + let mut model = RemappedChainModel { + convolution1: LinearConfig::new(2, 3).init(&device), + linear2: LinearConfig::new(3, 4).init(&device), + }; + + // Chain multiple remappings + let mut store = PytorchStore::from_file(path) + .with_key_remapping(r"^fc1\.", "convolution1.") + .with_key_remapping(r"^fc2\.", "linear2.") + .allow_partial(true); + + let result = store.apply_to(&mut model); + + if let Ok(result) = result { + // Check that remapped names were applied + assert!( + !result.applied.is_empty(), + "No tensors were applied after remapping" + ); + } + } +} + +#[cfg(test)] +mod adapter_tests { + use burn_core::tensor::Device; + + use super::*; + + #[derive(Module, Debug)] + pub struct SimpleLinearModel { + fc1: Linear, + fc2: Linear, + } + + impl SimpleLinearModel { + pub fn new(device: &Device) -> Self { + Self { + fc1: LinearConfig::new(2, 3).init(device), + fc2: LinearConfig::new(3, 4).init(device), + } + } + } + + #[test] + fn test_pytorch_adapter_always_applied() { + // Test that PyTorchToBurnAdapter is always applied internally + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping adapter test - file not found: {:?}", path); + return; + } + + let device = Default::default(); + let mut model = SimpleLinearModel::new(&device); + + let mut store = PytorchStore::from_file(path).allow_partial(true); + + let result = store.apply_to(&mut model); + + // PyTorchToBurnAdapter is always applied internally + assert!( + result.is_ok(), + "Failed to load with internal PyTorchToBurnAdapter: {:?}", + result.err() + ); + assert!(!result.unwrap().applied.is_empty()); + } + + #[test] + fn test_pytorch_adapter_with_filtering() { + // Test that PyTorchToBurnAdapter works with filtering + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping filtering test - file not found: {:?}", path); + return; + } + + let device = Default::default(); + let mut model = SimpleLinearModel::new(&device); + + // Filter to exclude bias tensors + let mut store = PytorchStore::from_file(path) + .with_predicate(|path, _| !path.contains("bias")) + .allow_partial(true); + + let result = store.apply_to(&mut model).unwrap(); + + // Should not have any bias tensors due to filtering + for applied_path in &result.applied { + assert!( + !applied_path.contains("bias"), + "Bias tensor was not filtered: {}", + applied_path + ); + } + } +} + +#[cfg(test)] +mod error_handling_tests { + use burn_core::tensor::Device; + + use super::*; + + #[derive(Module, Debug)] + pub struct SimpleLinearModel { + fc1: Linear, + fc2: Linear, + } + + impl SimpleLinearModel { + pub fn new(device: &Device) -> Self { + Self { + fc1: LinearConfig::new(2, 3).init(device), + fc2: LinearConfig::new(3, 4).init(device), + } + } + } + + #[test] + fn test_missing_file() { + let device = Default::default(); + let mut model = SimpleLinearModel::new(&device); + let mut store = PytorchStore::from_file("nonexistent.pth"); + + let result = store.apply_to(&mut model); + + assert!(result.is_err()); + match result { + Err(crate::pytorch::PytorchStoreError::Reader(_)) => {} + _ => panic!("Expected reader error for missing file"), + } + } + + #[test] + fn test_invalid_top_level_key() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!( + "Skipping invalid top level key test - file not found: {:?}", + path + ); + return; + } + + let device = Default::default(); + let mut model = SimpleLinearModel::new(&device); + + let mut store = PytorchStore::from_file(path).with_top_level_key("nonexistent_key"); + + let result = store.apply_to(&mut model); + + assert!(result.is_err(), "Should fail with invalid top level key"); + } + + #[test] + fn test_strict_validation() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!( + "Skipping strict validation test - file not found: {:?}", + path + ); + return; + } + + let device = Default::default(); + let mut model = SimpleLinearModel::new(&device); + + // Apply very restrictive filter that matches nothing + let mut store = PytorchStore::from_file(path) + .with_regex(r"^this_will_never_match$") + .validate(true) + .allow_partial(false); + + let result = store.apply_to(&mut model); + + // Should fail because no tensors match and allow_partial is false + assert!( + result.is_err(), + "Should fail when no tensors match with allow_partial=false" + ); + } +} + +#[cfg(test)] +mod enum_variant_tests { + use burn_core::tensor::Device; + + use super::*; + use crate::ModuleSnapshot; + + /// Enum representing different convolution block types (similar to YOLOX architecture) + #[derive(Module, Debug)] + pub enum ConvBlock { + /// Base convolution block + BaseConv(Linear), + /// Depthwise separable convolution block + DwsConv(Linear), + } + + /// Model with enum field that will have variant names in Burn paths + #[derive(Module, Debug)] + pub struct ModelWithEnum { + /// Feature extractor with enum variants + feature: ConvBlock, + /// Output classifier + classifier: Linear, + } + + impl ModelWithEnum { + pub fn new(device: &Device) -> Self { + Self { + feature: ConvBlock::BaseConv(LinearConfig::new(3, 64).init(device)), + classifier: LinearConfig::new(64, 10).init(device), + } + } + } + + #[test] + fn test_enum_variant_path_mismatch() { + let device = Default::default(); + let mut model = ModelWithEnum::new(&device); + + // Load PyTorch model that was generated without enum variant names + // PyTorch paths: "feature.weight", "feature.bias", "classifier.weight", "classifier.bias" + // Burn paths: "feature.BaseConv.weight", "feature.BaseConv.bias", "classifier.weight", "classifier.bias" + // ^^^^^^^^ enum variant name is included in Burn but not PyTorch + + let pytorch_file = store_test_data_path("model_without_enum_variants.pt"); + + // Try to load from PyTorch format (without enum variants) + // Explicitly disable skip_enum_variants to demonstrate the mismatch problem + let mut store = PytorchStore::from_file(pytorch_file) + .skip_enum_variants(false) // Disable to show the mismatch + .allow_partial(true) // Allow partial to see what's missing + .validate(false); // Disable validation to get detailed missing info + + let result = store.apply_to(&mut model); + + // The load should succeed (allow_partial=true) but report missing tensors + match result { + Ok(apply_result) => { + // Verify we have missing tensors + assert!( + !apply_result.missing.is_empty(), + "Should have missing tensors due to enum variant path mismatch" + ); + + // Check that missing paths contain enum variants + let enum_missing: Vec<_> = apply_result + .missing + .iter() + .filter(|(_, container_stack)| container_stack.contains("Enum:")) + .collect(); + + assert!( + !enum_missing.is_empty(), + "Missing tensors should be detected as having enum containers" + ); + + // Verify the paths look like what we expect + let has_base_conv_path = apply_result + .missing + .iter() + .any(|(path, _)| path.contains("BaseConv")); + + assert!( + has_base_conv_path, + "Should have missing paths with 'BaseConv' enum variant. Missing: {:?}", + apply_result + .missing + .iter() + .map(|(p, _)| p) + .collect::>() + ); + + // Print the diagnostic output to show enum detection + println!("\n{}", apply_result); + + // Verify the diagnostic message mentions enum variants + let display_output = format!("{}", apply_result); + assert!( + display_output.contains("enum variant"), + "Display output should mention enum variants" + ); + } + Err(e) => panic!( + "Load should succeed with allow_partial=true, got error: {}", + e + ), + } + } + + #[test] + fn test_enum_variant_detection_in_container_stack() { + let device = Default::default(); + + // Create model with enum + let model = ModelWithEnum::new(&device); + + // Collect snapshots to inspect container stacks + let snapshots = model.collect(None, None, false); + + // Find a snapshot from inside the enum + let enum_snapshot = snapshots + .iter() + .find(|s| s.full_path().contains("feature")) + .expect("Should have feature snapshots"); + + // Verify container stack contains enum marker + if let Some(container_stack) = &enum_snapshot.container_stack { + let container_str = container_stack.join("."); + assert!( + container_str.contains("Enum:ConvBlock"), + "Container stack should contain Enum:ConvBlock marker. Got: {}", + container_str + ); + } else { + panic!("Snapshot should have container_stack"); + } + } + + #[test] + fn test_skip_enum_variants_feature() { + let device = Default::default(); + let mut model = ModelWithEnum::new(&device); + + // Load PyTorch model that was generated without enum variant names + // PyTorch paths: "feature.weight", "feature.bias", "classifier.weight", "classifier.bias" + // Burn paths: "feature.BaseConv.weight", "feature.BaseConv.bias", "classifier.weight", "classifier.bias" + + let pytorch_file = store_test_data_path("model_without_enum_variants.pt"); + + // Try to load with skip_enum_variants enabled + let mut store = PytorchStore::from_file(pytorch_file) + .skip_enum_variants(true) // Enable enum variant skipping + .allow_partial(true) + .validate(false); + + let result = store.apply_to(&mut model); + + // The load should succeed and all tensors should be loaded + match result { + Ok(apply_result) => { + println!("\n{}", apply_result); + + // With skip_enum_variants enabled, we should successfully load the feature tensors + let feature_applied = apply_result + .applied + .iter() + .filter(|path| path.contains("feature")) + .count(); + + assert!( + feature_applied > 0, + "Should have applied feature tensors with skip_enum_variants=true. Applied: {:?}", + apply_result.applied + ); + + // The feature tensors should NOT be in missing anymore + let feature_missing = apply_result + .missing + .iter() + .filter(|(path, _)| path.contains("feature")) + .count(); + + assert_eq!( + feature_missing, 0, + "Feature tensors should not be missing with skip_enum_variants=true. Missing: {:?}", + apply_result.missing + ); + } + Err(e) => panic!( + "Load with skip_enum_variants should succeed, got error: {}", + e + ), + } + } +} + +#[cfg(test)] +mod direct_access_tests { + use super::*; + + #[test] + fn test_get_all_snapshots() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let mut store = PytorchStore::from_file(path); + let snapshots = store.get_all_snapshots().unwrap(); + + // linear.pt should have fc1.weight, fc1.bias, fc2.weight, fc2.bias + assert!(!snapshots.is_empty(), "Should have snapshots"); + assert!( + snapshots.contains_key("fc1.weight"), + "Should contain fc1.weight" + ); + assert!( + snapshots.contains_key("fc1.bias"), + "Should contain fc1.bias" + ); + } + + #[test] + fn test_get_snapshot_existing() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let mut store = PytorchStore::from_file(path); + + // Get existing snapshot + let snapshot = store.get_snapshot("fc1.weight").unwrap(); + assert!(snapshot.is_some(), "Should find fc1.weight"); + + let snapshot = snapshot.unwrap(); + // Linear weight should be 2D + assert_eq!(snapshot.shape.len(), 2, "Weight should be 2D tensor"); + + // Verify we can load data + let data = snapshot.to_data().unwrap(); + assert!(!data.bytes.is_empty(), "Data should not be empty"); + } + + #[test] + fn test_get_snapshot_not_found() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let mut store = PytorchStore::from_file(path); + + // Get non-existent snapshot + let snapshot = store.get_snapshot("nonexistent.weight").unwrap(); + assert!(snapshot.is_none(), "Should not find nonexistent tensor"); + } + + #[test] + fn test_keys() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let mut store = PytorchStore::from_file(path); + let keys = store.keys().unwrap(); + + assert!(!keys.is_empty(), "Should have keys"); + assert!( + keys.contains(&"fc1.weight".to_string()), + "Keys should contain fc1.weight" + ); + assert!( + keys.contains(&"fc1.bias".to_string()), + "Keys should contain fc1.bias" + ); + } + + #[test] + fn test_keys_fast_path() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + // Create fresh store - cache should be empty + let mut store = PytorchStore::from_file(&path); + + // keys() should work without populating the full cache (fast path) + let keys = store.keys().unwrap(); + assert!(!keys.is_empty(), "Should have keys via fast path"); + + // Now call get_all_snapshots to populate cache + let snapshots = store.get_all_snapshots().unwrap(); + assert!(!snapshots.is_empty(), "Should have snapshots"); + + // keys() should now use the cached data + let keys2 = store.keys().unwrap(); + assert_eq!(keys.len(), keys2.len(), "Keys count should match"); + } + + #[test] + fn test_caching_behavior() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let mut store = PytorchStore::from_file(path); + + // First call populates cache + let snapshots1 = store.get_all_snapshots().unwrap(); + let count1 = snapshots1.len(); + + // Second call uses cache + let snapshots2 = store.get_all_snapshots().unwrap(); + let count2 = snapshots2.len(); + + assert_eq!(count1, count2, "Cached results should match"); + } + + #[test] + fn test_get_all_snapshots_with_remapping() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + // Create store with key remapping + let mut store = PytorchStore::from_file(path).with_key_remapping(r"^fc1\.", "linear1."); + + let snapshots = store.get_all_snapshots().unwrap(); + + // Should have remapped keys + assert!( + snapshots.contains_key("linear1.weight"), + "Should contain remapped key linear1.weight. Keys: {:?}", + snapshots.keys().collect::>() + ); + assert!( + snapshots.contains_key("linear1.bias"), + "Should contain remapped key linear1.bias" + ); + + // Original keys should not exist + assert!( + !snapshots.contains_key("fc1.weight"), + "Should not contain original key fc1.weight" + ); + } + + #[test] + fn test_get_snapshot_with_remapped_name() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + // Create store with key remapping + let mut store = PytorchStore::from_file(path).with_key_remapping(r"^fc1\.", "linear1."); + + // Should find by remapped name + let snapshot = store.get_snapshot("linear1.weight").unwrap(); + assert!(snapshot.is_some(), "Should find tensor by remapped name"); + + // Should NOT find by original name + let snapshot_orig = store.get_snapshot("fc1.weight").unwrap(); + assert!( + snapshot_orig.is_none(), + "Should not find tensor by original name after remapping" + ); + } + + #[test] + fn test_get_all_snapshots_ignores_filter() { + let path = pytorch_test_path("linear", "linear.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + // Create store with filter that only matches fc1 + let mut store = PytorchStore::from_file(path).with_regex(r"^fc1\."); + + // get_all_snapshots should return ALL tensors regardless of filter + let snapshots = store.get_all_snapshots().unwrap(); + + // Should have both fc1 and fc2 tensors + assert!( + snapshots.contains_key("fc1.weight"), + "Should contain fc1.weight" + ); + assert!( + snapshots.contains_key("fc2.weight"), + "Should contain fc2.weight (filter not applied to get_all_snapshots)" + ); + } +} + +/// Tests for contiguous index mapping feature +#[cfg(test)] +mod map_indices_contiguous_tests { + use burn_core::tensor::Device; + + use super::*; + + /// Model with a Vec of Conv2d layers that expects contiguous indices + #[derive(Module, Debug)] + struct SequentialConvModel { + fc: Vec, + } + + impl SequentialConvModel { + pub fn new(device: &Device, num_layers: usize) -> Self { + Self { + fc: (0..num_layers) + .map(|_| { + Conv2dConfig::new([2, 2], [3, 3]) + .with_bias(true) + .init(device) + }) + .collect(), + } + } + } + + #[test] + fn test_load_non_contiguous_indexes_with_mapping() { + // This test uses the non_contiguous_indexes.pt file which has: + // fc.0.weight, fc.0.bias, fc.2.weight, fc.2.bias, fc.4.weight, ... (non-contiguous) + // The Burn model expects fc.0, fc.1, fc.2, ... (contiguous) + + let path = pytorch_test_path("non_contiguous_indexes", "non_contiguous_indexes.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let device = Default::default(); + + // Create model with 5 conv layers (matching the PyTorch model) + let mut model = SequentialConvModel::new(&device, 5); + + // Load with contiguous index mapping enabled (default) + let mut store = PytorchStore::from_file(&path) + .map_indices_contiguous(true) + .allow_partial(true) + .validate(false); + + let result = store.apply_to(&mut model); + + match result { + Ok(apply_result) => { + println!("Applied tensors: {:?}", apply_result.applied); + println!("Missing tensors: {:?}", apply_result.missing); + println!("Unused tensors: {:?}", apply_result.unused); + + // All fc layers should be loaded successfully + assert!( + !apply_result.applied.is_empty(), + "Should have applied tensors" + ); + + // Verify we have tensors from all 5 layers + // With mapping: fc.0, fc.1, fc.2, fc.3, fc.4 + for i in 0..5 { + let has_weight = apply_result + .applied + .iter() + .any(|p| p.contains(&format!("fc.{}.weight", i))); + let has_bias = apply_result + .applied + .iter() + .any(|p| p.contains(&format!("fc.{}.bias", i))); + + assert!( + has_weight, + "Should have applied fc.{}.weight, applied: {:?}", + i, apply_result.applied + ); + assert!( + has_bias, + "Should have applied fc.{}.bias, applied: {:?}", + i, apply_result.applied + ); + } + + // There should be no missing tensors (assuming model matches) + let missing_fc: Vec<_> = apply_result + .missing + .iter() + .filter(|(p, _)| p.starts_with("fc.")) + .collect(); + assert!( + missing_fc.is_empty(), + "Should have no missing fc tensors with index mapping. Missing: {:?}", + missing_fc + ); + } + Err(e) => panic!("Failed to load with index mapping: {}", e), + } + } + + #[test] + fn test_load_non_contiguous_indexes_without_mapping() { + // This test verifies that loading fails or has missing tensors when + // map_indices_contiguous is disabled + + let path = pytorch_test_path("non_contiguous_indexes", "non_contiguous_indexes.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + let device = Default::default(); + + // Create model with 5 conv layers + let mut model = SequentialConvModel::new(&device, 5); + + // Load with contiguous index mapping DISABLED + let mut store = PytorchStore::from_file(&path) + .map_indices_contiguous(false) // Disable index mapping + .allow_partial(true) + .validate(false); + + let result = store.apply_to(&mut model); + + match result { + Ok(apply_result) => { + println!( + "Without index mapping - Applied tensors: {:?}", + apply_result.applied + ); + println!( + "Without index mapping - Missing tensors: {:?}", + apply_result.missing + ); + + // Without index mapping, we should have missing tensors for fc.1, fc.3 + // because the source has fc.0, fc.2, fc.4, fc.6, fc.8 but model expects fc.0-4 + let missing_fc: Vec<_> = apply_result + .missing + .iter() + .filter(|(p, _)| p.starts_with("fc.")) + .collect(); + + assert!( + !missing_fc.is_empty(), + "Should have missing fc tensors without index mapping (indices 1, 3 don't exist in file)" + ); + + // Specifically, fc.1 and fc.3 should be missing + let has_fc1_missing = apply_result + .missing + .iter() + .any(|(p, _)| p.starts_with("fc.1.")); + let has_fc3_missing = apply_result + .missing + .iter() + .any(|(p, _)| p.starts_with("fc.3.")); + + assert!( + has_fc1_missing || has_fc3_missing, + "Should have fc.1 or fc.3 missing. Missing: {:?}", + apply_result.missing + ); + } + Err(e) => panic!("Unexpected error: {}", e), + } + } + + #[test] + fn test_mapping_applied_to_keys() { + // Verify that the keys returned by the store are mapped + let path = pytorch_test_path("non_contiguous_indexes", "non_contiguous_indexes.pt"); + + if !path.exists() { + println!("Skipping test - file not found: {:?}", path); + return; + } + + // With index mapping enabled (default) + let mut store_mapped = PytorchStore::from_file(&path).map_indices_contiguous(true); + + let keys_mapped = store_mapped.keys().unwrap(); + println!("Keys with index mapping: {:?}", keys_mapped); + + // Should have contiguous keys: fc.0, fc.1, fc.2, fc.3, fc.4 + assert!( + keys_mapped.iter().any(|k| k.starts_with("fc.1.")), + "With index mapping, should have fc.1 (from fc.2)" + ); + assert!( + keys_mapped.iter().any(|k| k.starts_with("fc.2.")), + "With index mapping, should have fc.2 (from fc.4)" + ); + + // Without index mapping + let mut store_no_mapping = PytorchStore::from_file(&path).map_indices_contiguous(false); + + let keys_no_mapping = store_no_mapping.keys().unwrap(); + println!("Keys without index mapping: {:?}", keys_no_mapping); + + // Should have original non-contiguous keys: fc.0, fc.2, fc.4, fc.6, fc.8 + assert!( + keys_no_mapping.iter().any(|k| k.starts_with("fc.2.")), + "Without index mapping, should have original fc.2" + ); + assert!( + keys_no_mapping.iter().any(|k| k.starts_with("fc.4.")), + "Without index mapping, should have original fc.4" + ); + assert!( + !keys_no_mapping.iter().any(|k| k.starts_with("fc.1.")), + "Without index mapping, should NOT have fc.1 (not in original file)" + ); + } +} diff --git a/crates/burn-store/src/pytorch/tests/store/test_data/generate_enum_test.py b/crates/burn-store/src/pytorch/tests/store/test_data/generate_enum_test.py new file mode 100644 index 0000000..f735153 --- /dev/null +++ b/crates/burn-store/src/pytorch/tests/store/test_data/generate_enum_test.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Generate PyTorch test data for enum variant path mismatch testing. + +This script creates a PyTorch checkpoint that simulates how PyTorch models +export their state dicts WITHOUT enum variant names in the paths. + +Example: +- PyTorch path: "feature.weight" +- Burn path: "feature.BaseConv.weight" (includes enum variant "BaseConv") + +Run with: uv run generate_enum_test.py +""" + +import torch +import torch.nn as nn + + +class SimpleModel(nn.Module): + """ + Simple PyTorch model that represents what a Burn enum model would look like + WITHOUT the enum variant names in the path. + + In Burn, this would be: + struct ModelWithEnum { + feature: ConvBlock, // enum with BaseConv, DwsConv variants + classifier: Linear, + } + + But PyTorch exports it as flat paths without the enum variant names. + """ + def __init__(self): + super().__init__() + # This represents the "feature" field which is an enum in Burn + # PyTorch doesn't have enums, so it's just a Linear layer + # Path will be: "feature.weight" and "feature.bias" + self.feature = nn.Linear(3, 64) + + # This represents the "classifier" field + # Path will be: "classifier.weight" and "classifier.bias" + self.classifier = nn.Linear(64, 10) + + def forward(self, x): + x = self.feature(x) + x = torch.relu(x) + x = self.classifier(x) + return x + + +def generate_enum_variant_mismatch_test(): + """Generate test file demonstrating enum variant path mismatch.""" + model = SimpleModel() + + # Initialize with some deterministic weights for testing + torch.manual_seed(42) + for param in model.parameters(): + param.data.normal_(0, 0.1) + + # Save the state dict + # PyTorch paths: "feature.weight", "feature.bias", "classifier.weight", "classifier.bias" + # Burn paths: "feature.BaseConv.weight", "feature.BaseConv.bias", ... + # ^^^^^^^^ enum variant is missing in PyTorch + torch.save(model.state_dict(), "model_without_enum_variants.pt") + + print("Generated: model_without_enum_variants.pt") + print("\nPyTorch state dict keys:") + for key in model.state_dict().keys(): + shape = tuple(model.state_dict()[key].shape) + print(f" {key}: {shape}") + + print("\nExpected Burn paths (with enum variant):") + print(" feature.BaseConv.weight: (3, 64)") + print(" feature.BaseConv.bias: (64,)") + print(" classifier.weight: (64, 10)") + print(" classifier.bias: (10,)") + + print("\n⚠️ Notice: Burn includes 'BaseConv' enum variant, PyTorch doesn't!") + + +if __name__ == "__main__": + generate_enum_variant_mismatch_test() diff --git a/crates/burn-store/src/pytorch/tests/store/test_data/model_without_enum_variants.pt b/crates/burn-store/src/pytorch/tests/store/test_data/model_without_enum_variants.pt new file mode 100644 index 0000000000000000000000000000000000000000..1846aa2ca4ca662e8a3364edeb0198066c6daa0d GIT binary patch literal 6373 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 P{7d;W>O95$pYQz-^}Tz& literal 0 HcmV?d00001 diff --git a/crates/burn-store/src/safetensors/mod.rs b/crates/burn-store/src/safetensors/mod.rs new file mode 100644 index 0000000..b077ed1 --- /dev/null +++ b/crates/burn-store/src/safetensors/mod.rs @@ -0,0 +1,322 @@ +//! SafeTensors format support for Burn deep learning framework. +//! +//! [SafeTensors](https://github.com/huggingface/safetensors) is a simple, safe, and efficient format +//! for storing and loading tensors. It provides fast zero-copy deserialization and strong safety +//! guarantees, making it ideal for production environments. +//! +//! # Features +//! +//! - **Fast Loading**: Zero-copy tensor access using safetensors' built-in mechanisms +//! - **Safety**: Prevents arbitrary code execution during model loading +//! - **Efficiency**: Memory-mapped files enable lazy loading without reading entire file +//! - **Filtering**: Load only specific tensors using path filters +//! - **Remapping**: Transform tensor names during load/save operations +//! - **Metadata**: Store and retrieve custom metadata alongside tensors (automatic `format`, `producer` and `version` metadata included) +//! - **Cross-Platform**: Works on all platforms including no-std environments +//! +//! # Usage Examples +//! +//! ## Basic Save and Load +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot}; +//! +//! // Save a model to a file +//! let mut store = SafetensorsStore::from_file("model.safetensors"); +//! model.save_into(&mut store)?; +//! +//! // Load a model from a file +//! let mut store = SafetensorsStore::from_file("model.safetensors"); +//! let mut model = Model::new(&device); +//! model.load_from(&mut store)?; +//! ``` +//! +//! ## Memory-Based Operations +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot}; +//! +//! // Save to memory buffer +//! let mut store = SafetensorsStore::from_bytes(None); +//! model.save_into(&mut store)?; +//! let bytes = store.get_bytes()?; +//! +//! // Load from memory buffer +//! let mut store = SafetensorsStore::from_bytes(Some(bytes)); +//! let mut model = Model::new(&device); +//! model.load_from(&mut store)?; +//! ``` +//! +//! ## Advanced Features +//! +//! ### Filter Configuration with Builder Pattern +//! +//! ```rust,no_run +//! # use burn_store::SafetensorsStore; +//! // Filter with regex patterns (OR logic - matches any pattern) +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .with_regex(r"^encoder\..*") // Match all encoder tensors +//! .with_regex(r".*\.bias$"); // OR match any bias tensors +//! +//! // Filter with exact paths +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .with_full_path("encoder.weight") +//! .with_full_path("encoder.bias") +//! .with_full_paths(vec!["decoder.scale", "decoder.norm"]); +//! +//! // Custom filter logic with predicate +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .with_predicate(|path, _dtype| { +//! // Only save layer weights (not biases) +//! path.contains("layer") && path.ends_with("weight") +//! }); +//! +//! // Combine multiple filter methods +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .with_regex(r"^encoder\..*") // All encoder tensors +//! .with_full_path("decoder.scale") // Plus specific decoder.scale +//! .with_predicate(|path, _| { // Plus any projection tensors +//! path.contains("projection") +//! }); +//! +//! // Save or load all tensors (no filtering) +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .match_all(); +//! ``` +//! +//! ### Tensor Name Remapping +//! +//! Remap tensor names during load/save operations for compatibility between different frameworks: +//! +//! ```rust,no_run +//! # use burn_store::{SafetensorsStore, KeyRemapper}; +//! // Using builder pattern for common remapping patterns +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .with_key_remapping(r"^encoder\.", "transformer.encoder.") // encoder.X -> transformer.encoder.X +//! .with_key_remapping(r"\.gamma$", ".weight") // X.gamma -> X.weight +//! .with_key_remapping(r"\.beta$", ".bias"); // X.beta -> X.bias +//! +//! // Or using a pre-configured KeyRemapper for complex transformations +//! let remapper = KeyRemapper::new() +//! .add_pattern(r"^pytorch\.(.*)", "burn.$1").expect("valid regex") // pytorch.layer -> burn.layer +//! .add_pattern(r"^(.*)\.running_mean$", "$1.mean").expect("valid regex") // layer.running_mean -> layer.mean +//! .add_pattern(r"^(.*)\.running_var$", "$1.variance").expect("valid regex"); // layer.running_var -> layer.variance +//! +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! .remap(remapper); +//! ``` +//! +//! ### Framework Adapters +//! +//! Use adapters for automatic framework-specific transformations: +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot, PyTorchToBurnAdapter, BurnToPyTorchAdapter}; +//! +//! // Loading PyTorch model into Burn +//! let mut store = SafetensorsStore::from_file("pytorch_model.safetensors") +//! .with_from_adapter(PyTorchToBurnAdapter) // Transposes linear weights, renames norm params +//! .allow_partial(true); // PyTorch models may have extra tensors +//! +//! let mut burn_model = Model::new(&device); +//! burn_model.load_from(&mut store)?; +//! +//! // Saving Burn model for PyTorch +//! let mut store = SafetensorsStore::from_file("for_pytorch.safetensors") +//! .with_to_adapter(BurnToPyTorchAdapter); // Transposes weights back, renames for PyTorch +//! +//! burn_model.save_into(&mut store)?; +//! ``` +//! +//! ### Additional Configuration Options +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot}; +//! +//! let mut store = SafetensorsStore::from_file("model.safetensors") +//! // Add custom metadata +//! .metadata("version", "1.0.0") +//! .metadata("producer", "burn") +//! // Allow partial loading (continue even if some tensors are missing) +//! .allow_partial(true) +//! // Disable validation for faster loading +//! .validate(false); +//! +//! // Use the configured store +//! model.save_into(&mut store)?; // For saving +//! // or +//! model.load_from(&mut store)?; // For loading +//! ``` +//! +//! # Efficient Loading with SafeTensors +//! +//! SafeTensors provides efficient tensor loading through its zero-copy design: +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot}; +//! +//! let mut store = SafetensorsStore::from_file("large_model.safetensors"); +//! // Uses memory mapping (when available) for zero-copy access +//! // Falls back to buffered reading when mmap is not available +//! let mut model = Model::new(&device); +//! model.load_from(&mut store)?; +//! ``` +//! +//! The safetensors approach provides: +//! - Zero-copy views - tensors are accessed directly from the mapped file +//! - Lazy loading - only accessed tensors are materialized +//! - Efficient memory usage - no unnecessary data duplication +//! +//! # Lazy Loading and Inspection +//! +//! SafeTensors provides efficient inspection and selective loading through its +//! zero-copy design and built-in metadata handling: +//! +//! ```rust,ignore +//! use burn_store::SafetensorsStore; +//! +//! // Open a file - uses safetensors' efficient header reading +//! let store = SafetensorsStore::from_file("large_model.safetensors"); +//! +//! // List all tensor names from the metadata +//! let tensor_names = store.list_tensors()?; +//! println!("Model contains {} tensors", tensor_names.len()); +//! +//! // Get tensor metadata without loading tensor data +//! if let Some((shape, dtype)) = store.tensor_info("encoder.weight")? { +//! println!("Encoder weight shape: {:?}, dtype: {:?}", shape, dtype); +//! } +//! +//! // Selectively load tensors - safetensors handles efficient access +//! let encoder_tensors = store.load_tensors(&[ +//! "encoder.weight", +//! "encoder.bias", +//! "encoder.norm" +//! ])?; +//! +//! // Distributed loading: each worker loads only its assigned layers +//! // SafeTensors' zero-copy views ensure minimal memory usage +//! let worker_layers = match worker_id { +//! 0 => vec!["encoder.layer1", "encoder.layer2"], +//! 1 => vec!["encoder.layer3", "encoder.layer4"], +//! 2 => vec!["decoder.layer1", "decoder.layer2"], +//! _ => vec!["head.weight", "head.bias"], +//! }; +//! let worker_tensors = store.load_tensors(&worker_layers)?; +//! ``` +//! +//! # Builder Pattern API Reference +//! +//! The SafetensorsStore provides a fluent builder API for configuration: +//! +//! ## Filtering Methods +//! +//! - **`with_regex(pattern)`** - Add regex pattern to match tensor names (OR logic with multiple patterns) +//! - **`with_full_path(path)`** - Add exact tensor path to include +//! - **`with_full_paths(paths)`** - Add multiple exact tensor paths to include +//! - **`with_predicate(fn)`** - Add custom filter function `fn(&str, &str) -> bool` +//! - **`match_all()`** - Disable filtering, include all tensors +//! +//! ## Remapping Methods +//! +//! - **`with_key_remapping(from, to)`** - Add regex pattern to rename tensors +//! - **`remap(KeyRemapper)`** - Use a pre-configured KeyRemapper for complex transformations +//! +//! ## Adapter Methods +//! +//! - **`with_from_adapter(adapter)`** - Set adapter for loading (e.g., PyTorchToBurnAdapter) +//! - **`with_to_adapter(adapter)`** - Set adapter for saving (e.g., BurnToPyTorchAdapter) +//! +//! ## Configuration Methods +//! +//! - **`metadata(key, value)`** - Add custom metadata to saved files (in addition to automatic `format`, `producer` and `version`) +//! - **`allow_partial(bool)`** - Allow loading even if some tensors are missing +//! - **`validate(bool)`** - Enable/disable tensor validation during loading +//! +//! All methods return `Self` for chaining: +//! +//! ```rust,no_run +//! use burn_store::{SafetensorsStore, PyTorchToBurnAdapter}; +//! +//! let store = SafetensorsStore::from_file("model.safetensors") +//! .with_regex(r"^encoder\..*") +//! .with_key_remapping(r"\.gamma$", ".weight") +//! .with_from_adapter(PyTorchToBurnAdapter) +//! .allow_partial(true) +//! .metadata("version", "2.0"); +//! ``` +//! +//! # Working with Bytes +//! +//! For direct byte operations without files: +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot}; +//! +//! // Save to bytes with filtering and remapping +//! let mut store = SafetensorsStore::from_bytes(None) +//! .with_regex(r"^encoder\..*") // Only save encoder tensors +//! .with_key_remapping(r"^encoder\.", "transformer.") // Rename encoder.X -> transformer.X +//! .metadata("subset", "encoder_only"); +//! model.save_into(&mut store)?; +//! let bytes = store.get_bytes()?; +//! +//! // Load from bytes (allow partial since we only saved encoder) +//! let mut store = SafetensorsStore::from_bytes(Some(bytes)) +//! .with_key_remapping(r"^transformer\.", "encoder.") // Rename back: transformer.X -> encoder.X +//! .allow_partial(true); +//! let mut model = Model::new(&device); +//! let result = model.load_from(&mut store)?; +//! println!("Applied {} tensors", result.applied.len()); +//! ``` +//! +//! # Complete Example: PyTorch Model Migration +//! +//! Migrating a PyTorch model to Burn with filtering, remapping, and adapters: +//! +//! ```rust,ignore +//! use burn_store::{SafetensorsStore, ModuleSnapshot, PyTorchToBurnAdapter}; +//! +//! // Load PyTorch model with all transformations +//! let mut store = SafetensorsStore::from_file("pytorch_model.safetensors") +//! // Use PyTorch adapter for automatic transformations +//! .with_from_adapter(PyTorchToBurnAdapter) +//! // Only load transformer layers +//! .with_regex(r"^transformer\..*") +//! // Rename old layer names to new structure +//! .with_key_remapping(r"^transformer\.h\.(\d+)\.", "transformer.layer$1.") +//! // Skip unexpected tensors from PyTorch +//! .allow_partial(true) +//! // Add metadata about the conversion +//! .metadata("source", "pytorch") +//! .metadata("converted_by", "burn-store"); +//! +//! let mut model = TransformerModel::new(&device); +//! let result = model.load_from(&mut store)?; +//! +//! println!("Successfully loaded {} tensors", result.applied.len()); +//! if !result.missing.is_empty() { +//! println!("Missing tensors: {:?}", result.missing); +//! } +//! ``` +//! +//! # Format Details +//! +//! SafeTensors uses a simple binary format: +//! - **8 bytes**: Header size (unsigned little-endian 64-bit integer) +//! - **N bytes**: JSON header with tensor metadata +//! - Contains: `{"tensor_name": {"dtype": "F32", "shape": [1, 2, 3], "data_offsets": [start, end]}, ...}` +//! - Special key `__metadata__` for user-defined string metadata +//! - **Rest**: Raw tensor data (referenced by offsets in header) +//! +//! The format enables: +//! - **Secure loading**: No code execution, just data +//! - **Efficient access**: Use offsets to read only needed tensors +//! - **Simple parsing**: Standard JSON header with fixed structure + +mod store; + +pub use store::{SafetensorsStore, SafetensorsStoreError}; + +#[cfg(test)] +mod tests; diff --git a/crates/burn-store/src/safetensors/store.rs b/crates/burn-store/src/safetensors/store.rs new file mode 100644 index 0000000..7b299c3 --- /dev/null +++ b/crates/burn-store/src/safetensors/store.rs @@ -0,0 +1,1087 @@ +//! SafeTensors store implementation using the official safetensors crate. + +use crate::{ + ApplyResult, IdentityAdapter, ModuleAdapter, ModuleSnapshot, ModuleStore, PathFilter, + TensorSnapshot, +}; + +#[cfg(feature = "std")] +use crate::{KeyRemapper, map_indices_contiguous}; +use alloc::boxed::Box; +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use burn_core::module::ParamId; +use burn_core::tensor::{BoolStore, DType, TensorData}; +use core::fmt; +use core::ops::Deref; +use hashbrown::HashMap; + +// Arc is only available on targets with atomic pointers +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; + +// For targets without atomic pointers, we use Box instead +#[cfg(not(target_has_atomic = "ptr"))] +type Arc = Box; + +/// Errors that can occur during SafeTensors operations. +#[derive(Debug)] +pub enum SafetensorsStoreError { + /// SafeTensors crate error. + Safetensors(safetensors::SafeTensorError), + + /// I/O error. + #[cfg(feature = "std")] + Io(std::io::Error), + + /// Tensor not found. + TensorNotFound(String), + + /// Validation failed. + ValidationFailed(String), + + /// Other error. + Other(String), +} + +impl fmt::Display for SafetensorsStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Safetensors(e) => write!(f, "SafeTensors error: {}", e), + #[cfg(feature = "std")] + Self::Io(e) => write!(f, "I/O error: {}", e), + Self::TensorNotFound(name) => write!(f, "Tensor not found: {}", name), + Self::ValidationFailed(msg) => write!(f, "Validation failed: {}", msg), + Self::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl core::error::Error for SafetensorsStoreError {} + +impl From for SafetensorsStoreError { + fn from(e: safetensors::SafeTensorError) -> Self { + SafetensorsStoreError::Safetensors(e) + } +} + +#[cfg(feature = "std")] +impl From for SafetensorsStoreError { + fn from(e: std::io::Error) -> Self { + SafetensorsStoreError::Io(e) + } +} + +/// SafeTensors store supporting both file and memory storage. +pub enum SafetensorsStore { + /// File-based storage. + #[cfg(feature = "std")] + File(FileStore), + + /// Memory-based storage. + Memory(MemoryStore), +} + +impl Default for SafetensorsStore { + /// Create a default memory-based store. + fn default() -> Self { + Self::from_bytes(None) + } +} + +impl SafetensorsStore { + /// Get the default metadata that includes Burn framework information. + /// + /// This includes: + /// - `format`: "safetensors" + /// - `producer`: "burn" + /// - `version`: The version of burn-store crate (from CARGO_PKG_VERSION) + /// + /// These metadata fields are automatically added to all saved models. + pub fn default_metadata() -> HashMap { + let mut metadata = HashMap::new(); + metadata.insert("format".to_string(), "safetensors".to_string()); + metadata.insert("producer".to_string(), "burn".to_string()); + metadata.insert("version".to_string(), env!("CARGO_PKG_VERSION").to_string()); + metadata + } + + /// Create a store for loading from or saving to a file. + #[cfg(feature = "std")] + pub fn from_file(path: impl Into) -> Self { + Self::File(FileStore { + path: path.into(), + filter: PathFilter::new(), + remapper: KeyRemapper::new(), + metadata: Self::default_metadata(), + validate: true, + allow_partial: false, + overwrite: false, + skip_enum_variants: false, + // Contiguous index mapping is off by default for SafeTensors + // (SafeTensors files typically have clean, contiguous indices) + map_indices_contiguous: false, + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + snapshots_cache: None, + }) + } + + /// Create a store for working with bytes in memory. + pub fn from_bytes(bytes: Option>) -> Self { + Self::Memory(MemoryStore { + data: bytes.map(Arc::new), + filter: PathFilter::new(), + #[cfg(feature = "std")] + remapper: KeyRemapper::new(), + metadata: Self::default_metadata(), + validate: true, + allow_partial: false, + skip_enum_variants: false, + // Contiguous index mapping is off by default for SafeTensors + #[cfg(feature = "std")] + map_indices_contiguous: false, + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + snapshots_cache: None, + }) + } + + /// Filter which tensors to load/save. + pub fn filter(mut self, filter: PathFilter) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = filter, + Self::Memory(p) => p.filter = filter, + } + self + } + + /// Add a regex pattern to filter tensors. + /// + /// Multiple patterns can be added and they work with OR logic. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .with_regex(r"^encoder\..*") // Match all encoder tensors + /// .with_regex(r".*\.weight$"); // OR match any weight tensors + /// ``` + #[cfg(feature = "std")] + pub fn with_regex>(mut self, pattern: S) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_regex(pattern), + Self::Memory(p) => p.filter = p.filter.clone().with_regex(pattern), + } + self + } + + /// Add multiple regex patterns to filter tensors. + #[cfg(feature = "std")] + pub fn with_regexes(mut self, patterns: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_regexes(patterns), + Self::Memory(p) => p.filter = p.filter.clone().with_regexes(patterns), + } + self + } + + /// Add an exact full path to match. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .with_full_path("encoder.layer1.weight") + /// .with_full_path("decoder.output.bias"); + /// ``` + pub fn with_full_path>(mut self, path: S) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_full_path(path), + Self::Memory(p) => p.filter = p.filter.clone().with_full_path(path), + } + self + } + + /// Add multiple exact full paths to match. + pub fn with_full_paths(mut self, paths: I) -> Self + where + I: IntoIterator, + S: Into, + { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_full_paths(paths), + Self::Memory(p) => p.filter = p.filter.clone().with_full_paths(paths), + } + self + } + + /// Add a predicate function for custom filtering logic. + /// + /// The predicate receives the tensor path and container path. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .with_predicate(|path, _| path.starts_with("encoder.") || path.ends_with(".bias")); + /// ``` + pub fn with_predicate(mut self, predicate: fn(&str, &str) -> bool) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_predicate(predicate), + Self::Memory(p) => p.filter = p.filter.clone().with_predicate(predicate), + } + self + } + + /// Add multiple predicate functions. + pub fn with_predicates(mut self, predicates: I) -> Self + where + I: IntoIterator bool>, + { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().with_predicates(predicates), + Self::Memory(p) => p.filter = p.filter.clone().with_predicates(predicates), + } + self + } + + /// Set the filter to match all paths (disables filtering). + pub fn match_all(mut self) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.filter = p.filter.clone().match_all(), + Self::Memory(p) => p.filter = p.filter.clone().match_all(), + } + self + } + + /// Remap tensor names during load/save. + #[cfg(feature = "std")] + pub fn remap(mut self, remapper: KeyRemapper) -> Self { + match &mut self { + Self::File(p) => p.remapper = remapper, + Self::Memory(p) => p.remapper = remapper, + } + self + } + + /// Add a regex pattern to remap tensor names during load/save. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .with_key_remapping(r"^encoder\.", "transformer.encoder.") // encoder.X -> transformer.encoder.X + /// .with_key_remapping(r"\.gamma$", ".weight"); // X.gamma -> X.weight + /// ``` + #[cfg(feature = "std")] + pub fn with_key_remapping( + mut self, + from_pattern: impl AsRef, + to_pattern: impl Into, + ) -> Self { + match &mut self { + Self::File(p) => { + p.remapper = p + .remapper + .clone() + .add_pattern(from_pattern, to_pattern) + .expect("Invalid regex pattern"); + } + Self::Memory(p) => { + p.remapper = p + .remapper + .clone() + .add_pattern(from_pattern, to_pattern) + .expect("Invalid regex pattern"); + } + } + self + } + + /// Add metadata to be saved with the tensors. + pub fn metadata(mut self, key: impl Into, value: impl Into) -> Self { + let key = key.into(); + let value = value.into(); + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => { + p.metadata.insert(key, value); + } + Self::Memory(p) => { + p.metadata.insert(key, value); + } + } + self + } + + /// Clear all metadata including the default Burn framework metadata. + /// + /// This removes the automatic `format`, `producer` and `version` fields. + /// Use this when you need complete control over metadata or when + /// saving models for use with other frameworks. + pub fn clear_metadata(mut self) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => { + p.metadata.clear(); + } + Self::Memory(p) => { + p.metadata.clear(); + } + } + self + } + + /// Set whether to validate tensors during loading (default: true). + pub fn validate(mut self, validate: bool) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.validate = validate, + Self::Memory(p) => p.validate = validate, + } + self + } + + /// Allow partial loading of tensors (continue even if some tensors are missing). + pub fn allow_partial(mut self, allow: bool) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.allow_partial = allow, + Self::Memory(p) => p.allow_partial = allow, + } + self + } + + /// Skip enum variant names when loading or saving tensor paths. + /// + /// When enabled during **loading**, tensor paths from the source that don't include enum variants + /// can be matched against Burn module paths that do include them. + /// For example, source path "feature.weight" can match Burn path "feature.BaseConv.weight". + /// + /// When enabled during **saving**, enum variant names are omitted from the exported tensor paths, + /// making them compatible with PyTorch naming conventions. + /// For example, "feature.BaseConv.weight" becomes "feature.weight" in the exported file. + /// + /// This is useful when working with models from/to formats that don't include enum variant + /// names in their parameter paths (like PyTorch models). + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// // For PyTorch compatibility + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .skip_enum_variants(true); + /// ``` + pub fn skip_enum_variants(mut self, skip: bool) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.skip_enum_variants = skip, + Self::Memory(p) => p.skip_enum_variants = skip, + } + self + } + + /// Enable or disable automatic contiguous mapping of layer indices (default: false). + /// + /// When enabled, non-contiguous numeric indices in tensor paths are renumbered + /// to be contiguous. This is useful when loading models that have gaps + /// in layer numbering, such as PyTorch models using `nn.Sequential` with mixed + /// layer types (e.g., Conv2d layers at indices 0, 2, 4 with ReLU layers at 1, 3, 5). + /// + /// # Example + /// + /// With index mapping enabled: + /// - `fc.0.weight` → `fc.0.weight` + /// - `fc.2.weight` → `fc.1.weight` (gap filled) + /// - `fc.4.weight` → `fc.2.weight` (gap filled) + /// + /// # Arguments + /// + /// * `map` - `true` to enable contiguous index mapping, `false` to disable + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// // Enable contiguous index mapping for PyTorch-exported safetensors + /// let store = SafetensorsStore::from_file("model.safetensors") + /// .map_indices_contiguous(true); + /// ``` + #[cfg(feature = "std")] + pub fn map_indices_contiguous(mut self, map: bool) -> Self { + match &mut self { + Self::File(p) => p.map_indices_contiguous = map, + Self::Memory(p) => p.map_indices_contiguous = map, + } + self + } + + /// Set whether to overwrite existing files when saving (default: false). + /// + /// When set to `false`, attempting to save to an existing file will result in an error. + /// When set to `true`, existing files will be overwritten without warning. + /// + /// This setting only applies to file-based stores. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// let mut store = SafetensorsStore::from_file("model.safetensors") + /// .overwrite(true); + /// // Will overwrite if file exists when saving + /// ``` + #[cfg(feature = "std")] + pub fn overwrite(mut self, overwrite: bool) -> Self { + match &mut self { + Self::File(p) => p.overwrite = overwrite, + Self::Memory(_) => { + // Memory stores don't have overwrite semantics, ignore + } + } + self + } + + /// Set the adapter for loading tensors (converting from source format to Burn). + pub fn with_from_adapter(mut self, adapter: impl ModuleAdapter + 'static) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.from_adapter = Box::new(adapter), + Self::Memory(p) => p.from_adapter = Box::new(adapter), + } + self + } + + /// Set the adapter for saving tensors (converting from Burn to target format). + pub fn with_to_adapter(mut self, adapter: impl ModuleAdapter + 'static) -> Self { + match &mut self { + #[cfg(feature = "std")] + Self::File(p) => p.to_adapter = Box::new(adapter), + Self::Memory(p) => p.to_adapter = Box::new(adapter), + } + self + } + + /// Get saved bytes from memory-based store. + /// + /// # Example + /// ```rust,no_run + /// # use burn_store::SafetensorsStore; + /// # fn example() -> Result<(), Box> { + /// let mut store = SafetensorsStore::from_bytes(None); + /// // After saving model with collect_to()... + /// let bytes = store.get_bytes()?; + /// # Ok(()) + /// # } + /// ``` + pub fn get_bytes(&self) -> Result, SafetensorsStoreError> { + match self { + #[cfg(feature = "std")] + Self::File(_) => Err(SafetensorsStoreError::Other( + "Cannot get bytes from file-based store".to_string(), + )), + Self::Memory(p) => p + .data() + .map(|arc| arc.as_ref().clone()) + .ok_or_else(|| SafetensorsStoreError::Other("No data available".to_string())), + } + } +} + +/// File-based store. +#[cfg(feature = "std")] +pub struct FileStore { + path: std::path::PathBuf, + filter: PathFilter, + remapper: KeyRemapper, + metadata: HashMap, + validate: bool, + allow_partial: bool, + overwrite: bool, + skip_enum_variants: bool, + /// Enable contiguous mapping of layer indices (default: false) + map_indices_contiguous: bool, + from_adapter: Box, + to_adapter: Box, + /// Cached tensor snapshots (parsed once, reused) + snapshots_cache: Option>, +} + +/// Memory-based store. +pub struct MemoryStore { + data: Option>>, + filter: PathFilter, + #[cfg(feature = "std")] + remapper: KeyRemapper, + metadata: HashMap, + validate: bool, + allow_partial: bool, + skip_enum_variants: bool, + /// Enable contiguous mapping of layer indices (default: false) + #[cfg(feature = "std")] + map_indices_contiguous: bool, + from_adapter: Box, + to_adapter: Box, + /// Cached tensor snapshots (parsed once, reused) + snapshots_cache: Option>, +} + +impl Default for MemoryStore { + fn default() -> Self { + Self { + data: None, + filter: PathFilter::new(), + #[cfg(feature = "std")] + remapper: KeyRemapper::new(), + metadata: HashMap::new(), + validate: true, + allow_partial: false, + skip_enum_variants: false, + #[cfg(feature = "std")] + map_indices_contiguous: false, + from_adapter: Box::new(IdentityAdapter), + to_adapter: Box::new(IdentityAdapter), + snapshots_cache: None, + } + } +} + +impl MemoryStore { + #[cfg(test)] + pub(crate) fn data(&self) -> Option>> { + self.data.clone() + } + + #[cfg(not(test))] + fn data(&self) -> Option>> { + self.data.clone() + } + + #[cfg(test)] + pub(crate) fn set_data(&mut self, data: Vec) { + self.data = Some(Arc::new(data)); + } +} + +// Adapter to use TensorSnapshot directly with safetensors +#[derive(Debug)] +struct TensorSnapshotAdapter(TensorSnapshot); + +impl safetensors::View for TensorSnapshotAdapter { + fn dtype(&self) -> safetensors::Dtype { + // Convert from burn dtype to safetensors dtype + dtype_to_safetensors(self.0.dtype).unwrap_or(safetensors::Dtype::F32) + } + + fn shape(&self) -> &[usize] { + &self.0.shape + } + + fn data(&self) -> alloc::borrow::Cow<'_, [u8]> { + // Only materialize data when actually needed for serialization + let data = self + .0 + .to_data() + .unwrap_or_else(|e| panic!("Failed to get tensor data: {:?}", e)); + alloc::borrow::Cow::Owned(data.bytes.deref().to_vec()) + } + + fn data_len(&self) -> usize { + // Use the efficient data_len method from TensorSnapshot + self.0.data_len() + } +} + +impl ModuleStore for SafetensorsStore { + type Error = SafetensorsStoreError; + + fn collect_from(&mut self, module: &M) -> Result<(), Self::Error> { + // Invalidate cache since we're writing new data + match self { + #[cfg(feature = "std")] + Self::File(p) => p.snapshots_cache = None, + Self::Memory(p) => p.snapshots_cache = None, + } + + // Collect tensor snapshots from module with adapter + // The to_adapter converts from Burn format to target format for saving + let to_adapter = match self { + #[cfg(feature = "std")] + Self::File(p) => p.to_adapter.clone(), + Self::Memory(p) => p.to_adapter.clone(), + }; + let mut snapshots = module.collect(None, Some(to_adapter), self.get_skip_enum_variants()); + + // Apply filtering + snapshots = apply_filter(snapshots, self.get_filter()); + + // Apply remapping + #[cfg(feature = "std")] + { + snapshots = apply_remapping(snapshots, self.get_remapper()); + } + + // Get metadata (already includes format, producer and version from default_metadata) + let metadata = self.get_metadata().clone(); + + #[cfg(feature = "std")] + let std_metadata: std::collections::HashMap = metadata + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + // Write to storage + match self { + #[cfg(feature = "std")] + Self::File(p) => { + // Check if file exists and overwrite is disabled + if p.path.exists() && !p.overwrite { + return Err(SafetensorsStoreError::Other(format!( + "File already exists: {}. Use .overwrite(true) to overwrite.", + p.path.display() + ))); + } + + // Convert to safetensors format + let tensors = snapshots_to_safetensors(snapshots)?; + + // Use serialize_to_file which streams directly to disk + // This calls the lazy closures on-demand without buffering everything + safetensors::serialize_to_file(tensors, Some(std_metadata), &p.path)?; + Ok(()) + } + Self::Memory(p) => { + // For memory, we need to serialize to bytes + let tensors = snapshots_to_safetensors(snapshots)?; + // For no-std, serialize still needs std HashMap when std feature is enabled + #[cfg(feature = "std")] + let data = safetensors::serialize(tensors, Some(std_metadata))?; + + #[cfg(not(feature = "std"))] + let data = safetensors::serialize(tensors, Some(metadata))?; + p.data = Some(Arc::new(data)); + Ok(()) + } + } + } + + fn apply_to(&mut self, module: &mut M) -> Result { + // Get snapshots from cache + let snapshots: Vec = self.get_all_snapshots()?.values().cloned().collect(); + + // Get the adapter + let adapter: Box = match self { + #[cfg(feature = "std")] + Self::File(p) => p.from_adapter.clone(), + Self::Memory(p) => p.from_adapter.clone(), + }; + + // Get filter (cloned to Option for apply) + let filter = self.get_filter(); + let filter_opt = if filter.is_empty() { + None + } else { + Some(filter.clone()) + }; + + // Apply to module with adapter + // The adapter will be applied during module traversal with proper container info + // Filter is applied here during apply, not during cache population + let result = module.apply( + snapshots, + filter_opt, + Some(adapter), + self.get_skip_enum_variants(), + ); + + // Validate if needed + if self.get_validate() && !result.errors.is_empty() { + return Err(SafetensorsStoreError::ValidationFailed(format!( + "Import errors: {:?}", + result.errors + ))); + } + + if !self.get_allow_partial() && !result.missing.is_empty() { + return Err(SafetensorsStoreError::TensorNotFound(format!( + "\n{}\n\nHint: Use `.allow_partial(true)` on the store to get an `ApplyResult` \ + with structured missing/error info instead of a hard failure.", + result + ))); + } + + Ok(result) + } + + fn get_snapshot(&mut self, name: &str) -> Result, Self::Error> { + // Ensure cache is populated + self.ensure_snapshots_cache()?; + let cache = match self { + #[cfg(feature = "std")] + Self::File(p) => p.snapshots_cache.as_ref().unwrap(), + Self::Memory(p) => p.snapshots_cache.as_ref().unwrap(), + }; + Ok(cache.get(name)) + } + + fn get_all_snapshots(&mut self) -> Result<&BTreeMap, Self::Error> { + // Ensure cache is populated + self.ensure_snapshots_cache()?; + let cache = match self { + #[cfg(feature = "std")] + Self::File(p) => p.snapshots_cache.as_ref().unwrap(), + Self::Memory(p) => p.snapshots_cache.as_ref().unwrap(), + }; + Ok(cache) + } + + fn keys(&mut self) -> Result, Self::Error> { + // Always use the cache to ensure remapping is applied consistently + Ok(self.get_all_snapshots()?.keys().cloned().collect()) + } +} + +impl SafetensorsStore { + fn get_filter(&self) -> &PathFilter { + match self { + #[cfg(feature = "std")] + Self::File(p) => &p.filter, + Self::Memory(p) => &p.filter, + } + } + + #[cfg(feature = "std")] + fn get_remapper(&self) -> &KeyRemapper { + match self { + Self::File(p) => &p.remapper, + Self::Memory(p) => &p.remapper, + } + } + + fn get_metadata(&self) -> &HashMap { + match self { + #[cfg(feature = "std")] + Self::File(p) => &p.metadata, + Self::Memory(p) => &p.metadata, + } + } + + fn get_validate(&self) -> bool { + match self { + #[cfg(feature = "std")] + Self::File(p) => p.validate, + Self::Memory(p) => p.validate, + } + } + + fn get_allow_partial(&self) -> bool { + match self { + #[cfg(feature = "std")] + Self::File(p) => p.allow_partial, + Self::Memory(p) => p.allow_partial, + } + } + + fn get_skip_enum_variants(&self) -> bool { + match self { + #[cfg(feature = "std")] + Self::File(p) => p.skip_enum_variants, + Self::Memory(p) => p.skip_enum_variants, + } + } + + #[cfg(feature = "std")] + fn get_map_indices_contiguous(&self) -> bool { + match self { + Self::File(p) => p.map_indices_contiguous, + Self::Memory(p) => p.map_indices_contiguous, + } + } + + /// Ensure the snapshots cache is populated + fn ensure_snapshots_cache(&mut self) -> Result<(), SafetensorsStoreError> { + // Check if cache exists + let has_cache = match self { + #[cfg(feature = "std")] + Self::File(p) => p.snapshots_cache.is_some(), + Self::Memory(p) => p.snapshots_cache.is_some(), + }; + + if has_cache { + return Ok(()); + } + + // Load snapshots + #[allow(unused_mut)] + let mut snapshots = match self { + #[cfg(feature = "std")] + Self::File(p) => safetensors_to_snapshots_lazy_file(&p.path)?, + Self::Memory(p) => { + let data_arc = p + .data + .clone() + .ok_or_else(|| SafetensorsStoreError::Other("No data loaded".to_string()))?; + safetensors_to_snapshots_lazy(data_arc)? + } + }; + + // Apply remapping (but NOT filtering - that's done at apply time) + #[cfg(feature = "std")] + { + snapshots = match self { + Self::File(p) => apply_remapping(snapshots, &p.remapper), + Self::Memory(p) => apply_remapping(snapshots, &p.remapper), + }; + } + + // Apply contiguous index mapping if enabled + // This must be done after remapping so that remapped paths are mapped + #[cfg(feature = "std")] + if self.get_map_indices_contiguous() { + let (mapped, _) = map_indices_contiguous(snapshots); + snapshots = mapped; + } + + // Build cache as BTreeMap + let cache: BTreeMap = + snapshots.into_iter().map(|s| (s.full_path(), s)).collect(); + + // Store cache + match self { + #[cfg(feature = "std")] + Self::File(p) => p.snapshots_cache = Some(cache), + Self::Memory(p) => p.snapshots_cache = Some(cache), + } + + Ok(()) + } +} + +/// Apply filter to tensor snapshots. +fn apply_filter(mut snapshots: Vec, filter: &PathFilter) -> Vec { + if filter.is_empty() { + return snapshots; + } + + snapshots.retain(|snapshot| { + let path = snapshot.full_path(); + filter.matches(&path) + }); + + snapshots +} + +/// Apply remapping to tensor snapshots. +#[cfg(feature = "std")] +fn apply_remapping(snapshots: Vec, remapper: &KeyRemapper) -> Vec { + if remapper.is_empty() { + return snapshots; + } + + let (remapped, _) = remapper.remap(snapshots); + remapped +} + +/// Convert TensorSnapshots to safetensors format lazily. +fn snapshots_to_safetensors( + snapshots: Vec, +) -> Result, SafetensorsStoreError> { + let mut tensors = Vec::new(); + + for snapshot in snapshots { + let name = snapshot.full_path(); + // No need to materialize data - TensorSnapshot now has dtype and shape cached! + tensors.push((name, TensorSnapshotAdapter(snapshot))); + } + + Ok(tensors) +} + +/// Convert safetensors to TensorSnapshots with lazy loading. +fn safetensors_to_snapshots_lazy( + data_arc: Arc>, +) -> Result, SafetensorsStoreError> { + // Parse to get metadata + let tensors = safetensors::SafeTensors::deserialize(&data_arc)?; + let mut snapshots = Vec::new(); + + for (name, tensor_snapshot) in tensors.tensors() { + // Extract metadata without materializing data + let dtype = safetensor_dtype_to_burn(tensor_snapshot.dtype())?; + let shape = tensor_snapshot.shape(); + let path_parts: Vec = name.split('.').map(|s| s.to_string()).collect(); + + // Create a lazy closure that will deserialize only this tensor when needed + #[cfg(target_has_atomic = "ptr")] + let data_clone = Arc::clone(&data_arc); + #[cfg(not(target_has_atomic = "ptr"))] + let data_clone = data_arc.clone(); + let name_clone = name.to_string(); + let data_fn = alloc::rc::Rc::new(move || { + // Re-deserialize when needed (this is cheap, just parsing header) + let tensors = safetensors::SafeTensors::deserialize(&data_clone).map_err(|e| { + crate::TensorSnapshotError::IoError(format!( + "Failed to re-deserialize safetensors: {}", + e + )) + })?; + + // Find our specific tensor + let tensor = tensors.tensor(&name_clone).map_err(|e| { + crate::TensorSnapshotError::DataError(format!( + "Tensor '{}' not found: {}", + name_clone, e + )) + })?; + + // Now materialize just this tensor's data + let bytes = burn_core::tensor::Bytes::from_bytes_vec(tensor.data().to_vec()); + Ok(TensorData { + bytes, + shape: tensor.shape().into(), + dtype: safetensor_dtype_to_burn(tensor.dtype()) + .map_err(|_| crate::TensorSnapshotError::DataError("Invalid dtype".into()))?, + }) + }); + + let snapshot = TensorSnapshot::from_closure( + data_fn, + dtype, + shape.into(), + path_parts, + vec![], // Empty container_stack - will be filled during module traversal + ParamId::new(), + ); + snapshots.push(snapshot); + } + + Ok(snapshots) +} + +/// Convert safetensors to TensorSnapshots with true on-demand loading from file. +/// This reads only the header initially, then loads tensor data on demand. +#[cfg(feature = "std")] +fn safetensors_to_snapshots_lazy_file( + path: &std::path::Path, +) -> Result, SafetensorsStoreError> { + // Always use memory mapping for the most efficient access + use memmap2::MmapOptions; + + // Memory map the file for efficient access + let file = std::fs::File::open(path)?; + let mmap = unsafe { MmapOptions::new().map(&file)? }; + let mmap_arc = Arc::new(mmap); + + // Parse just to get metadata (safetensors won't copy data with mmap) + let tensors = safetensors::SafeTensors::deserialize(&mmap_arc)?; + let mut snapshots = Vec::new(); + + for (name, tensor_snapshot) in tensors.tensors() { + let dtype = safetensor_dtype_to_burn(tensor_snapshot.dtype())?; + let shape = tensor_snapshot.shape(); + let path_parts: Vec = name.split('.').map(|s| s.to_string()).collect(); + + // Create a lazy closure that accesses the mmap'd data + let mmap_clone = Arc::clone(&mmap_arc); + let name_clone = name.to_string(); + + let data_fn = alloc::rc::Rc::new(move || { + // Re-parse to get the tensor snapshot (this is cheap with mmap) + let tensors = safetensors::SafeTensors::deserialize(&mmap_clone).map_err(|e| { + crate::TensorSnapshotError::IoError(format!("Failed to deserialize: {}", e)) + })?; + let tensor = tensors.tensor(&name_clone).map_err(|e| { + crate::TensorSnapshotError::DataError(format!( + "Tensor '{}' not found: {}", + name_clone, e + )) + })?; + + // Only now do we actually copy the tensor data + Ok(TensorData { + bytes: burn_core::tensor::Bytes::from_bytes_vec(tensor.data().to_vec()), + shape: tensor.shape().into(), + dtype: safetensor_dtype_to_burn(tensor.dtype()) + .map_err(|_| crate::TensorSnapshotError::DataError("Invalid dtype".into()))?, + }) + }); + + let snapshot = TensorSnapshot::from_closure( + data_fn, + dtype, + shape.into(), + path_parts, + vec![], // Empty container_stack - will be filled during module traversal + ParamId::new(), + ); + snapshots.push(snapshot); + } + + Ok(snapshots) +} + +/// Helper to convert safetensors Dtype to burn DType. +fn safetensor_dtype_to_burn(dtype: safetensors::Dtype) -> Result { + use safetensors::Dtype; + + match dtype { + Dtype::F64 => Ok(DType::F64), + Dtype::F32 => Ok(DType::F32), + Dtype::F16 => Ok(DType::F16), + Dtype::BF16 => Ok(DType::BF16), + Dtype::I64 => Ok(DType::I64), + Dtype::I32 => Ok(DType::I32), + Dtype::I16 => Ok(DType::I16), + Dtype::I8 => Ok(DType::I8), + Dtype::U64 => Ok(DType::U64), + Dtype::U32 => Ok(DType::U32), + Dtype::U8 => Ok(DType::U8), + Dtype::BOOL => Ok(DType::Bool(BoolStore::Native)), + _ => Err(SafetensorsStoreError::Other(format!( + "Unsupported dtype: {:?}", + dtype + ))), + } +} + +/// Helper to convert DType to safetensors Dtype. +fn dtype_to_safetensors(dtype: DType) -> Result { + use safetensors::Dtype; + + match dtype { + DType::F64 => Ok(Dtype::F64), + DType::F32 | DType::Flex32 => Ok(Dtype::F32), // Flex32 is stored as F32 + DType::F16 => Ok(Dtype::F16), + DType::BF16 => Ok(Dtype::BF16), + DType::I64 => Ok(Dtype::I64), + DType::I32 => Ok(Dtype::I32), + DType::I16 => Ok(Dtype::I16), + DType::I8 => Ok(Dtype::I8), + DType::U64 => Ok(Dtype::U64), + DType::U32 => Ok(Dtype::U32), + DType::U16 => Err(SafetensorsStoreError::Other( + "U16 dtype not yet supported in safetensors".to_string(), + )), + DType::U8 => Ok(Dtype::U8), + DType::Bool(BoolStore::Native) => Ok(Dtype::BOOL), + DType::Bool(BoolStore::U32) => Ok(Dtype::U32), + DType::Bool(BoolStore::U8) => Ok(Dtype::U8), + DType::QFloat(_) => Err(SafetensorsStoreError::Other( + "Quantized tensors not yet supported in safetensors".to_string(), + )), + } +} diff --git a/crates/burn-store/src/safetensors/tests/adapter.rs b/crates/burn-store/src/safetensors/tests/adapter.rs new file mode 100644 index 0000000..5bd9d1f --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/adapter.rs @@ -0,0 +1,346 @@ +use burn_core as burn; + +use crate::{ + BurnToPyTorchAdapter, ModuleSnapshot, ModuleStore, PyTorchToBurnAdapter, SafetensorsStore, +}; +use burn_core::module::{Module, Param}; +use burn_core::tensor::{Device, Tensor}; +use burn_nn::{Linear, LinearConfig}; + +#[derive(Module, Debug)] +struct TestModel { + linear: Linear, + norm_weight: Param>, + norm_bias: Param>, +} + +impl TestModel { + fn new(device: &Device) -> Self { + Self { + linear: LinearConfig::new(4, 2).with_bias(true).init(device), + norm_weight: Param::from_data([1.0, 1.0], device), + norm_bias: Param::from_data([0.0, 0.0], device), + } + } +} + +#[test] +fn pytorch_to_burn_adapter_linear_transpose() { + let device = Default::default(); + let model = TestModel::new(&device); + + // Save with BurnToPyTorch adapter (will transpose linear weights) + let mut save_store = SafetensorsStore::from_bytes(None).with_to_adapter(BurnToPyTorchAdapter); + model.save_into(&mut save_store).unwrap(); + + // Load with PyTorchToBurn adapter (will transpose back) + let mut load_store = SafetensorsStore::from_bytes(None).with_from_adapter(PyTorchToBurnAdapter); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let mut model2 = TestModel::new(&device); + let result = model2.load_from(&mut load_store).unwrap(); + + // Should successfully load all tensors + assert!(!result.applied.is_empty()); + + // Verify the linear weights are the same after round-trip + let weight1 = model.linear.weight.val().to_data(); + let weight2 = model2.linear.weight.val().to_data(); + + assert_eq!(weight1.shape, weight2.shape); + let data1 = weight1.to_vec::().unwrap(); + let data2 = weight2.to_vec::().unwrap(); + + for (a, b) in data1.iter().zip(data2.iter()) { + assert!( + (a - b).abs() < 1e-6, + "Weights differ after adapter round-trip" + ); + } +} + +#[test] +fn pytorch_to_burn_adapter_norm_rename() { + let device = Default::default(); + + // Create a model with norm-like naming + #[derive(Module, Debug)] + struct NormModel { + norm_gamma: Param>, + norm_beta: Param>, + } + + impl NormModel { + fn new(device: &Device) -> Self { + Self { + norm_gamma: Param::from_data([1.0, 2.0, 3.0], device), + norm_beta: Param::from_data([0.1, 0.2, 0.3], device), + } + } + } + + let model = NormModel::new(&device); + + // Save with BurnToPyTorch adapter (will rename gamma->weight, beta->bias) + let mut save_store = SafetensorsStore::from_bytes(None).with_to_adapter(BurnToPyTorchAdapter); + model.save_into(&mut save_store).unwrap(); + + // The saved data should have PyTorch naming convention + // We can't directly verify the internal names, but we can verify round-trip works + + // Load with PyTorchToBurn adapter (will rename weight->gamma, bias->beta) + let mut load_store = SafetensorsStore::from_bytes(None).with_from_adapter(PyTorchToBurnAdapter); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let mut model2 = NormModel::new(&device); + let result = model2.load_from(&mut load_store).unwrap(); + + // Should load successfully + assert!(!result.applied.is_empty()); + + // Verify data is preserved + let gamma1 = model.norm_gamma.val().to_data().to_vec::().unwrap(); + let gamma2 = model2.norm_gamma.val().to_data().to_vec::().unwrap(); + let beta1 = model.norm_beta.val().to_data().to_vec::().unwrap(); + let beta2 = model2.norm_beta.val().to_data().to_vec::().unwrap(); + + assert_eq!(gamma1, gamma2); + assert_eq!(beta1, beta2); +} + +#[test] +fn no_adapter_preserves_original() { + let device = Default::default(); + let model = TestModel::new(&device); + + // Save without adapter + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).unwrap(); + + // Load without adapter + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let mut model2 = TestModel::new(&device); + let result = model2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); + assert!(!result.applied.is_empty()); + + // Verify data is exactly the same + let weight1 = model.linear.weight.val().to_data(); + let weight2 = model2.linear.weight.val().to_data(); + + assert_eq!(weight1.shape, weight2.shape); + assert_eq!( + weight1.to_vec::().unwrap(), + weight2.to_vec::().unwrap() + ); +} + +#[test] +#[cfg(all(feature = "std", target_has_atomic = "ptr"))] +fn adapter_with_pytorch_import() { + use crate::PyTorchToBurnAdapter; + + let device = Default::default(); + + // Reference the safetensors file from burn-store + let safetensors_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ); + + // Simple test model that matches some of the PyTorch structure + #[derive(Module, Debug)] + struct SimpleNet { + fc1: Linear, + } + + impl SimpleNet { + fn new(device: &Device) -> Self { + Self { + fc1: LinearConfig::new(4 * 8 * 8, 16).init(device), + } + } + } + + // Load with PyTorchToBurn adapter + let mut store = SafetensorsStore::from_file(safetensors_path) + .with_from_adapter(PyTorchToBurnAdapter) + .validate(false) + .allow_partial(true); + + let mut model = SimpleNet::new(&device); + let result = model.load_from(&mut store).unwrap(); + + // Should load some tensors (fc1 if it exists in the file) + // This mainly tests that the adapter works with real PyTorch files + assert!(!result.applied.is_empty() || !result.missing.is_empty()); +} + +#[test] +fn half_precision_adapter_round_trip() { + use crate::HalfPrecisionAdapter; + use burn_core::tensor::DType; + + let device = Default::default(); + let model = TestModel::new(&device); + + // Save with HalfPrecisionAdapter (F32 -> F16) + let adapter = HalfPrecisionAdapter::new(); + let mut save_store = SafetensorsStore::from_bytes(None).with_to_adapter(adapter.clone()); + model.save_into(&mut save_store).unwrap(); + + // Verify Linear tensors are F16, raw params stay F32 (no recognized module type) + let save_bytes = match &save_store { + SafetensorsStore::Memory(p) => p.data().unwrap().as_ref().clone(), + _ => panic!("Expected memory store"), + }; + let mut inspect_store = SafetensorsStore::from_bytes(Some(save_bytes.clone())); + let snapshots = inspect_store.get_all_snapshots().unwrap(); + for (name, snapshot) in snapshots.iter() { + if name.starts_with("linear") { + assert_eq!( + snapshot.dtype, + DType::F16, + "Linear tensor '{}' should be F16", + name + ); + } else { + assert_eq!( + snapshot.dtype, + DType::F32, + "Raw param '{}' should stay F32", + name + ); + } + } + + // Load back with same adapter (F16 -> F32) + let mut load_store = SafetensorsStore::from_bytes(Some(save_bytes)).with_from_adapter(adapter); + + let mut model2 = TestModel::new(&device); + let result = model2.load_from(&mut load_store).unwrap(); + + assert!(!result.applied.is_empty()); + + // Verify values are close (F32 -> F16 -> F32 has rounding) + let w1 = model.linear.weight.val().to_data().to_vec::().unwrap(); + let w2 = model2 + .linear + .weight + .val() + .to_data() + .to_vec::() + .unwrap(); + for (a, b) in w1.iter().zip(w2.iter()) { + assert!( + (a - b).abs() < 0.01, + "Weight values differ too much after F16 round-trip: {} vs {}", + a, + b + ); + } +} + +#[test] +fn half_precision_adapter_without_module() { + use crate::HalfPrecisionAdapter; + use burn_core::tensor::DType; + use burn_nn::{LayerNorm, LayerNormConfig}; + + #[derive(Module, Debug)] + struct MixedModel { + linear: Linear, + norm: LayerNorm, + } + + let device = Default::default(); + let model = MixedModel { + linear: LinearConfig::new(4, 2).with_bias(true).init(&device), + norm: LayerNormConfig::new(2).init(&device), + }; + + // Save: exclude LayerNorm from half-precision conversion + let adapter = HalfPrecisionAdapter::new().without_module("LayerNorm"); + let mut save_store = SafetensorsStore::from_bytes(None).with_to_adapter(adapter); + model.save_into(&mut save_store).unwrap(); + + // Verify: Linear tensors are F16, LayerNorm tensors remain F32 + let save_bytes = match &save_store { + SafetensorsStore::Memory(p) => p.data().unwrap().as_ref().clone(), + _ => panic!("Expected memory store"), + }; + let mut inspect_store = SafetensorsStore::from_bytes(Some(save_bytes)); + let snapshots = inspect_store.get_all_snapshots().unwrap(); + for (name, snapshot) in snapshots { + if name.starts_with("linear") { + assert_eq!( + snapshot.dtype, + DType::F16, + "Linear tensor '{}' should be F16", + name + ); + } else if name.starts_with("norm") { + assert_eq!( + snapshot.dtype, + DType::F32, + "LayerNorm tensor '{}' should stay F32", + name + ); + } + } +} + +#[test] +fn half_precision_adapter_default_converts_layer_norm() { + use crate::HalfPrecisionAdapter; + use burn_core::tensor::DType; + use burn_nn::{LayerNorm, LayerNormConfig}; + + #[derive(Module, Debug)] + struct NormModel { + linear: Linear, + norm: LayerNorm, + } + + let device = Default::default(); + let model = NormModel { + linear: LinearConfig::new(4, 2).with_bias(true).init(&device), + norm: LayerNormConfig::new(2).init(&device), + }; + + // Default adapter converts LayerNorm + let adapter = HalfPrecisionAdapter::new(); + let mut save_store = SafetensorsStore::from_bytes(None).with_to_adapter(adapter); + model.save_into(&mut save_store).unwrap(); + + let save_bytes = match &save_store { + SafetensorsStore::Memory(p) => p.data().unwrap().as_ref().clone(), + _ => panic!("Expected memory store"), + }; + let mut inspect_store = SafetensorsStore::from_bytes(Some(save_bytes)); + let snapshots = inspect_store.get_all_snapshots().unwrap(); + for (name, snapshot) in snapshots { + assert_eq!( + snapshot.dtype, + DType::F16, + "All tensors should be F16 by default, but '{}' is {:?}", + name, + snapshot.dtype + ); + } +} diff --git a/crates/burn-store/src/safetensors/tests/direct_access.rs b/crates/burn-store/src/safetensors/tests/direct_access.rs new file mode 100644 index 0000000..3171dfc --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/direct_access.rs @@ -0,0 +1,338 @@ +use burn_core as burn; + +use crate::{ModuleStore, SafetensorsStore}; +use burn_core::module::{Module, Param}; +use burn_core::tensor::{Device, Tensor, shape}; + +// Test module for direct access tests +#[derive(Module, Debug)] +struct DirectAccessTestModule { + weight: Param>, + bias: Param>, + nested: DirectAccessNestedModule, +} + +#[derive(Module, Debug)] +struct DirectAccessNestedModule { + gamma: Param>, + beta: Param>, +} + +impl DirectAccessTestModule { + fn new(device: &Device) -> Self { + Self { + weight: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device), + bias: Param::from_data([0.1, 0.2], device), + nested: DirectAccessNestedModule { + gamma: Param::from_data([1.0, 2.0], device), + beta: Param::from_data([0.5, 0.5], device), + }, + } + } +} + +#[test] +fn test_memory_get_all_snapshots() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + // Save module to memory + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + + // Get bytes and create load store + let bytes = save_store.get_bytes().unwrap(); + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + // Get all snapshots + let snapshots = load_store.get_all_snapshots().unwrap(); + + assert_eq!(snapshots.len(), 4); + assert!(snapshots.contains_key("weight")); + assert!(snapshots.contains_key("bias")); + assert!(snapshots.contains_key("nested.gamma")); + assert!(snapshots.contains_key("nested.beta")); +} + +#[test] +fn test_memory_get_snapshot_existing() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + let bytes = save_store.get_bytes().unwrap(); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + // Get existing snapshot + let snapshot = load_store.get_snapshot("weight").unwrap(); + assert!(snapshot.is_some()); + + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot.shape, shape![2, 2]); + + // Verify data + let data = snapshot.to_data().unwrap(); + let values: Vec = data.to_vec().unwrap(); + assert_eq!(values, vec![1.0, 2.0, 3.0, 4.0]); +} + +#[test] +fn test_memory_get_snapshot_nested() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + let bytes = save_store.get_bytes().unwrap(); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + // Get nested snapshot + let snapshot = load_store.get_snapshot("nested.gamma").unwrap(); + assert!(snapshot.is_some()); + + let snapshot = snapshot.unwrap(); + let data = snapshot.to_data().unwrap(); + let values: Vec = data.to_vec().unwrap(); + assert_eq!(values, vec![1.0, 2.0]); +} + +#[test] +fn test_memory_get_snapshot_not_found() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + let bytes = save_store.get_bytes().unwrap(); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + // Get non-existent snapshot + let snapshot = load_store.get_snapshot("nonexistent").unwrap(); + assert!(snapshot.is_none()); +} + +#[test] +fn test_memory_keys() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + let bytes = save_store.get_bytes().unwrap(); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + let keys = load_store.keys().unwrap(); + assert_eq!(keys.len(), 4); + assert!(keys.contains(&"weight".to_string())); + assert!(keys.contains(&"bias".to_string())); + assert!(keys.contains(&"nested.gamma".to_string())); + assert!(keys.contains(&"nested.beta".to_string())); +} + +#[test] +fn test_memory_caching_behavior() { + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let mut save_store = SafetensorsStore::from_bytes(None); + save_store.collect_from(&module).unwrap(); + let bytes = save_store.get_bytes().unwrap(); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + + // Call get_all_snapshots multiple times - should return same cached data + let snapshots1 = load_store.get_all_snapshots().unwrap(); + assert_eq!(snapshots1.len(), 4); + + let snapshots2 = load_store.get_all_snapshots().unwrap(); + assert_eq!(snapshots2.len(), 4); + + // Verify we can still access individual snapshots after caching + let snapshot = load_store.get_snapshot("bias").unwrap(); + assert!(snapshot.is_some()); +} + +// ============================================================================ +// Tests for FileStore variant +// ============================================================================ + +#[test] +#[cfg(feature = "std")] +fn test_file_get_all_snapshots() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_get_all_snapshots.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + let snapshots = load_store.get_all_snapshots().unwrap(); + + assert_eq!(snapshots.len(), 4); + assert!(snapshots.contains_key("weight")); + assert!(snapshots.contains_key("bias")); + assert!(snapshots.contains_key("nested.gamma")); + assert!(snapshots.contains_key("nested.beta")); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_get_snapshot_existing() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_get_snapshot.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + + let snapshot = load_store.get_snapshot("weight").unwrap(); + assert!(snapshot.is_some()); + + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot.shape, shape![2, 2]); + + let data = snapshot.to_data().unwrap(); + let values: Vec = data.to_vec().unwrap(); + assert_eq!(values, vec![1.0, 2.0, 3.0, 4.0]); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_get_snapshot_not_found() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_not_found.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + + let snapshot = load_store.get_snapshot("nonexistent").unwrap(); + assert!(snapshot.is_none()); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_keys() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_keys.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + + let keys = load_store.keys().unwrap(); + assert_eq!(keys.len(), 4); + assert!(keys.contains(&"weight".to_string())); + assert!(keys.contains(&"bias".to_string())); + assert!(keys.contains(&"nested.gamma".to_string())); + assert!(keys.contains(&"nested.beta".to_string())); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_keys_fast_path() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_keys_fast.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + // Create fresh store - cache should be empty + let mut load_store = SafetensorsStore::from_file(&path); + + // keys() should work without populating the full cache (fast path) + let keys = load_store.keys().unwrap(); + assert_eq!(keys.len(), 4); + + // Now call get_all_snapshots to populate cache + let snapshots = load_store.get_all_snapshots().unwrap(); + assert_eq!(snapshots.len(), 4); + + // keys() should now use the cached data + let keys2 = load_store.keys().unwrap(); + assert_eq!(keys2.len(), 4); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_caching_behavior() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_caching.safetensors"); + + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + + // First call populates cache + let snapshots1 = load_store.get_all_snapshots().unwrap(); + assert_eq!(snapshots1.len(), 4); + + // Second call uses cache + let snapshots2 = load_store.get_all_snapshots().unwrap(); + assert_eq!(snapshots2.len(), 4); +} + +#[test] +#[cfg(feature = "std")] +fn test_file_cache_invalidation_on_save() { + use tempfile::tempdir; + + let device = Default::default(); + let module = DirectAccessTestModule::new(&device); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_invalidation.safetensors"); + + // Create store, save, and populate cache + let mut store = SafetensorsStore::from_file(&path).overwrite(true); + store.collect_from(&module).unwrap(); + + let snapshots1 = store.get_all_snapshots().unwrap(); + assert_eq!(snapshots1.len(), 4); + + // Save again (this should invalidate cache) + store.collect_from(&module).unwrap(); + + // Cache should be repopulated with fresh data + let snapshots2 = store.get_all_snapshots().unwrap(); + assert_eq!(snapshots2.len(), 4); +} diff --git a/crates/burn-store/src/safetensors/tests/error_handling.rs b/crates/burn-store/src/safetensors/tests/error_handling.rs new file mode 100644 index 0000000..d70e76a --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/error_handling.rs @@ -0,0 +1,45 @@ +use crate::{ModuleSnapshot, SafetensorsStore}; +use burn_nn::LinearConfig; + +#[test] +fn shape_mismatch_errors() { + let device = Default::default(); + + // Create a module + let module = LinearConfig::new(2, 2).with_bias(true).init(&device); + + // Save module + let mut save_store = SafetensorsStore::from_bytes(None); + module.save_into(&mut save_store).unwrap(); + + // Try to load into incompatible module (different dimensions) + let mut incompatible_module = LinearConfig::new(3, 3).with_bias(true).init(&device); + + // Load without validation - should return errors in the result + let mut load_store = SafetensorsStore::from_bytes(None).validate(false); // Disable validation to get errors in result + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + // Get Arc and extract data + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + + let result = incompatible_module.load_from(&mut load_store).unwrap(); + + // Should have errors due to shape mismatch + assert!(!result.errors.is_empty()); + + // Try again with validation enabled - should return Err + let mut load_store_with_validation = SafetensorsStore::from_bytes(None).validate(true); + if let SafetensorsStore::Memory(ref mut p) = load_store_with_validation + && let SafetensorsStore::Memory(ref p_save) = save_store + { + // Get Arc and extract data + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + + let validation_result = incompatible_module.load_from(&mut load_store_with_validation); + assert!(validation_result.is_err()); +} diff --git a/crates/burn-store/src/safetensors/tests/file_io.rs b/crates/burn-store/src/safetensors/tests/file_io.rs new file mode 100644 index 0000000..56dcb06 --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/file_io.rs @@ -0,0 +1,273 @@ +use burn_core as burn; + +use crate::{ModuleSnapshot, ModuleStore, SafetensorsStore}; +use burn_core::module::{Module, Param}; +use burn_core::tensor::{Device, Tensor}; +use burn_nn::{Initializer, LinearConfig}; + +use tempfile::tempdir; + +// Define a test model with forward pass +#[derive(Module, Debug)] +struct ForwardTestModel { + linear1: burn_nn::Linear, + linear2: burn_nn::Linear, +} + +impl ForwardTestModel { + fn forward(&self, input: Tensor<2>) -> Tensor<2> { + let x = self.linear1.forward(input); + let x = burn::tensor::activation::gelu(x); + self.linear2.forward(x) + } +} + +// Define config for the model +#[derive(burn::config::Config, Debug)] +struct ForwardTestModelConfig { + input_size: usize, + hidden_size: usize, + output_size: usize, +} + +impl ForwardTestModelConfig { + fn init(&self, device: &Device) -> ForwardTestModel { + ForwardTestModel { + linear1: LinearConfig::new(self.input_size, self.hidden_size) + .with_bias(true) + .init(device), + linear2: LinearConfig::new(self.hidden_size, self.output_size) + .with_bias(true) + .init(device), + } + } +} + +#[derive(Module, Debug)] +pub struct ModuleBasic { + weight_basic: Param>, +} + +impl ModuleBasic { + fn new(device: &Device) -> Self { + Self { + weight_basic: Initializer::Normal { + std: 1.0, + mean: 0.0, + } + .init([20, 20], device), + } + } +} + +#[derive(Module, Debug)] +pub struct ModuleComposed { + weight: Param>, + basic: ModuleBasic, + tuple: (ModuleBasic, ModuleBasic), +} + +impl ModuleComposed { + fn new(device: &Device) -> Self { + let weight = Initializer::Normal { + std: 1.0, + mean: 0.0, + } + .init([20, 20], device); + + Self { + weight, + basic: ModuleBasic::new(device), + tuple: (ModuleBasic::new(device), ModuleBasic::new(device)), + } + } +} + +#[test] +fn file_based_loading() { + use std::fs; + + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Create temp file path + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("test_safetensors.st"); + + // Save to file + let mut save_store = SafetensorsStore::from_file(&file_path).metadata("test", "file_loading"); + + module.save_into(&mut save_store).unwrap(); + + // Verify file exists + assert!(file_path.exists()); + + // Load from file (will use memory-mapped loading if available) + let mut load_store = SafetensorsStore::from_file(&file_path); + + let mut loaded_module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + let result = loaded_module.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); + assert_eq!(result.applied.len(), 2); // weight and bias + + // Clean up + fs::remove_file(file_path).ok(); +} + +#[test] +fn test_store_overwrite_protection() { + use tempfile::tempdir; + + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Create temp directory and file path (file doesn't exist yet) + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_model.safetensors"); + + // First save - should succeed + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&module).unwrap(); + assert!(path.exists()); + + // Second save without overwrite flag - should fail + let mut save_store2 = SafetensorsStore::from_file(&path); + let result = save_store2.collect_from(&module); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("File already exists") + ); + + // Third save with overwrite flag - should succeed + let mut save_store3 = SafetensorsStore::from_file(&path).overwrite(true); + save_store3.collect_from(&module).unwrap(); + + // Verify file still exists and is valid + let mut load_store = SafetensorsStore::from_file(&path); + let mut module2 = LinearConfig::new(4, 2).with_bias(true).init(&device); + let result = load_store.apply_to(&mut module2).unwrap(); + assert!(result.is_success()); +} + +#[test] +fn test_store_overwrite_with_metadata() { + use tempfile::tempdir; + + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Create temp directory and file path + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("test_model_metadata.safetensors"); + + // First save with v1 metadata and overwrite enabled + let mut save_store = SafetensorsStore::from_file(&path) + .metadata("model_version", "v1") + .overwrite(true); + save_store.collect_from(&module).unwrap(); + + // Second save with v2 metadata and overwrite enabled + let mut save_store2 = SafetensorsStore::from_file(&path) + .metadata("model_version", "v2") + .overwrite(true); + save_store2.collect_from(&module).unwrap(); + + // Load and verify the metadata was updated to v2 + let mut load_store = SafetensorsStore::from_file(&path); + // Since we can't easily access metadata after loading, we just verify the file loads successfully + let mut module2 = LinearConfig::new(4, 2).with_bias(true).init(&device); + let result = module2.load_from(&mut load_store).unwrap(); + assert!(result.is_success()); +} + +#[test] +fn test_forward_pass_preservation_after_save_load() { + let device = Default::default(); + + // Create model config + let config = ForwardTestModelConfig { + input_size: 4, + hidden_size: 8, + output_size: 2, + }; + + // Initialize model1 with random weights + let model1 = config.init(&device); + + // Create random input + let input = Tensor::<2>::random( + [1, 4], + burn_core::tensor::Distribution::Uniform(-1.0, 1.0), + &device, + ); + + // Forward pass with model1 -> output1 + let output1 = model1.forward(input.clone()); + + // Save model1 weights + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("forward_test_model.safetensors"); + let mut save_store = SafetensorsStore::from_file(&path); + save_store.collect_from(&model1).unwrap(); + + // Initialize model2 with different random weights + let mut model2 = config.init(&device); + + // Forward pass with model2 -> output2 (should differ from output1) + let output2 = model2.forward(input.clone()); + + // Verify output2 differs from output1 (different random weights) + assert!( + !output1 + .clone() + .all_close(output2.clone(), Some(1e-6), Some(1e-6)), + "output2 should differ from output1 (different random initializations)" + ); + + // Load model1 weights into model2 + let mut load_store = SafetensorsStore::from_file(&path); + let result = load_store.apply_to(&mut model2).unwrap(); + assert!(result.is_success()); + assert_eq!(result.applied.len(), 4); // 2 weights + 2 biases + + // Forward pass with model2 (now has model1 weights) -> output3 + let output3 = model2.forward(input.clone()); + + // Verify output3 equals output1 (same weights) + assert!( + output1.all_close(output3, Some(1e-6), Some(1e-6)), + "output3 should equal output1 after loading weights" + ); +} + +#[test] +fn should_save_load_compose() { + let device = Device::default(); + let module_1 = ModuleComposed::new(&device); + let mut module_2 = ModuleComposed::new(&device); + assert_ne!(module_1.weight.to_data(), module_2.weight.to_data()); + assert_ne!( + module_1.basic.weight_basic.to_data(), + module_2.basic.weight_basic.to_data() + ); + + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("save_load_compose.safetensors"); + let mut store = SafetensorsStore::from_file(&path); + module_1.save_into(&mut store).unwrap(); + + let mut load_store = SafetensorsStore::from_file(&path); + let result = module_2.load_from(&mut load_store).unwrap(); + assert!(result.is_success()); + + assert_eq!(module_1.weight.to_data(), module_2.weight.to_data()); + assert_eq!( + module_1.basic.weight_basic.to_data(), + module_2.basic.weight_basic.to_data() + ); +} diff --git a/crates/burn-store/src/safetensors/tests/filtering.rs b/crates/burn-store/src/safetensors/tests/filtering.rs new file mode 100644 index 0000000..6f139bc --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/filtering.rs @@ -0,0 +1,167 @@ +use crate::{ModuleSnapshot, SafetensorsStore}; + +use super::round_trip::ComplexModule; + +#[test] +#[cfg(target_has_atomic = "ptr")] +fn filtered_export_import() { + let device = Default::default(); + let module1 = ComplexModule::new(&device); + let mut module2 = ComplexModule::new_zeros(&device); + + // Export only encoder tensors using the builder pattern + let mut save_store = SafetensorsStore::from_bytes(None).with_regex(r"^encoder\..*"); + module1.save_into(&mut save_store).unwrap(); + + // Import filtered tensors - need to allow partial since we only saved encoder tensors + let mut load_store = SafetensorsStore::from_bytes(None).allow_partial(true); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + // Get Arc and extract data + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + let result = module2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); + assert_eq!(result.applied.len(), 3); // encoder.weight, encoder.bias, encoder.norm + assert!(!result.missing.is_empty()); // decoder and layers tensors are missing +} + +#[test] +#[cfg(target_has_atomic = "ptr")] +fn builder_pattern_filtering() { + let device = Default::default(); + let module = ComplexModule::new(&device); + + // Test with_regex - multiple patterns (OR logic) + let mut store = SafetensorsStore::from_bytes(None) + .with_regex(r"^encoder\..*") // Match encoder tensors + .with_regex(r".*\.bias$"); // OR match any bias tensors + + let views = module.collect(None, None, false); + let filtered_count = views + .iter() + .filter(|v| { + let path = v.full_path(); + path.starts_with("encoder.") || path.ends_with(".bias") + }) + .count(); + + module.save_into(&mut store).unwrap(); + + // Verify we saved the expected number of tensors + if let SafetensorsStore::Memory(ref p) = store { + let data = p.data().unwrap(); + let tensors = safetensors::SafeTensors::deserialize(&data).unwrap(); + assert_eq!(tensors.len(), filtered_count); + } +} + +#[test] +fn builder_pattern_exact_paths() { + let device = Default::default(); + let module = ComplexModule::new(&device); + + // Test with_full_path and with_full_paths + let paths = vec!["encoder.weight", "decoder.scale"]; + let mut store = SafetensorsStore::from_bytes(None) + .with_full_path("encoder.norm") + .with_full_paths(paths.clone()); + + module.save_into(&mut store).unwrap(); + + // Verify only specified tensors were saved + if let SafetensorsStore::Memory(ref p) = store { + let data = p.data().unwrap(); + let tensors = safetensors::SafeTensors::deserialize(&data).unwrap(); + assert_eq!(tensors.len(), 3); // encoder.norm + encoder.weight + decoder.scale + + for (name, _) in tensors.tensors() { + assert!(name == "encoder.norm" || name == "encoder.weight" || name == "decoder.scale"); + } + } +} + +#[test] +fn builder_pattern_with_predicate() { + let device = Default::default(); + let module = ComplexModule::new(&device); + + // Test with_predicate - custom logic + let mut store = SafetensorsStore::from_bytes(None).with_predicate(|path, _| { + // Only save tensors with "layer" in the path and ending with "weight" + path.contains("layer") && path.ends_with("weight") + }); + + module.save_into(&mut store).unwrap(); + + // Verify only layer weights were saved + if let SafetensorsStore::Memory(ref p) = store { + let data = p.data().unwrap(); + let tensors = safetensors::SafeTensors::deserialize(&data).unwrap(); + + for (name, _) in tensors.tensors() { + assert!(name.contains("layer")); + assert!(name.ends_with("weight")); + } + } +} + +#[test] +fn builder_pattern_combined() { + let device = Default::default(); + let module = ComplexModule::new(&device); + + // Combine multiple filter methods + #[cfg(target_has_atomic = "ptr")] + { + let mut store = SafetensorsStore::from_bytes(None) + .with_regex(r"^encoder\..*") // All encoder tensors + .with_full_path("decoder.scale") // Plus specific decoder.scale + .with_predicate(|path, _| { + // Plus any projection tensors + path.contains("projection") + }); + + module.save_into(&mut store).unwrap(); + + if let SafetensorsStore::Memory(ref p) = store { + let data = p.data().unwrap(); + let tensors = safetensors::SafeTensors::deserialize(&data).unwrap(); + + // Should have encoder.*, decoder.scale, and projection tensors + let mut names = Vec::new(); + for (name, _) in tensors.tensors() { + names.push(name); + } + assert!(names.iter().any(|n| n == "encoder.weight")); + assert!(names.iter().any(|n| n == "encoder.bias")); + assert!(names.iter().any(|n| n == "encoder.norm")); + assert!(names.iter().any(|n| n == "decoder.scale")); + // decoder.projection.* should also be included due to predicate + assert!(names.iter().any(|n| n.contains("projection"))); + } + } +} + +#[test] +fn builder_pattern_match_all() { + let device = Default::default(); + let module = ComplexModule::new(&device); + + let all_views = module.collect(None, None, false); + let total_count = all_views.len(); + + // Test match_all - should save everything + let mut store = SafetensorsStore::from_bytes(None).match_all(); + + module.save_into(&mut store).unwrap(); + + if let SafetensorsStore::Memory(ref p) = store { + let data = p.data().unwrap(); + let tensors = safetensors::SafeTensors::deserialize(&data).unwrap(); + assert_eq!(tensors.len(), total_count); + } +} diff --git a/crates/burn-store/src/safetensors/tests/integration.rs b/crates/burn-store/src/safetensors/tests/integration.rs new file mode 100644 index 0000000..ef31e7b --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/integration.rs @@ -0,0 +1,169 @@ +use burn_core as burn; + +use crate::{ModuleSnapshot, SafetensorsStore}; +use burn_core::module::{Module, Param}; +use burn_core::tensor::{Device, Tensor}; + +// Integration tests demonstrating the SafeTensors store API +#[derive(Module, Debug)] +struct IntegrationTestModel { + encoder: IntegrationEncoderModule, + decoder: IntegrationDecoderModule, + head: IntegrationHeadModule, +} + +#[derive(Module, Debug)] +struct IntegrationEncoderModule { + layer1: IntegrationLinearLayer, + layer2: IntegrationLinearLayer, + norm: IntegrationNormLayer, +} + +#[derive(Module, Debug)] +struct IntegrationDecoderModule { + layer1: IntegrationLinearLayer, + layer2: IntegrationLinearLayer, + norm: IntegrationNormLayer, +} + +#[derive(Module, Debug)] +struct IntegrationHeadModule { + weight: Param>, + bias: Param>, +} + +#[derive(Module, Debug)] +struct IntegrationLinearLayer { + weight: Param>, + bias: Param>, +} + +#[derive(Module, Debug)] +struct IntegrationNormLayer { + scale: Param>, + shift: Param>, +} + +impl IntegrationTestModel { + fn new(device: &Device) -> Self { + Self { + encoder: IntegrationEncoderModule::new(device), + decoder: IntegrationDecoderModule::new(device), + head: IntegrationHeadModule::new(device), + } + } +} + +impl IntegrationEncoderModule { + fn new(device: &Device) -> Self { + Self { + layer1: IntegrationLinearLayer::new(device, 1), + layer2: IntegrationLinearLayer::new(device, 2), + norm: IntegrationNormLayer::new(device), + } + } +} + +impl IntegrationDecoderModule { + fn new(device: &Device) -> Self { + Self { + layer1: IntegrationLinearLayer::new(device, 3), + layer2: IntegrationLinearLayer::new(device, 4), + norm: IntegrationNormLayer::new(device), + } + } +} + +impl IntegrationHeadModule { + fn new(device: &Device) -> Self { + Self { + weight: Param::from_data([[5.0, 6.0], [7.0, 8.0]], device), + bias: Param::from_data([9.0, 10.0], device), + } + } +} + +impl IntegrationLinearLayer { + fn new(device: &Device, seed: i32) -> Self { + let weight_data = [ + [seed as f32, (seed + 1) as f32], + [(seed + 2) as f32, (seed + 3) as f32], + ]; + let bias_data = [(seed + 4) as f32, (seed + 5) as f32]; + + Self { + weight: Param::from_data(weight_data, device), + bias: Param::from_data(bias_data, device), + } + } +} + +impl IntegrationNormLayer { + fn new(device: &Device) -> Self { + Self { + scale: Param::from_data([1.0, 2.0], device), + shift: Param::from_data([0.1, 0.2], device), + } + } +} + +#[test] +fn basic_usage() { + let device = Default::default(); + let model = IntegrationTestModel::new(&device); + + // Save using new API (format, producer and version are automatically added) + let mut save_store = SafetensorsStore::from_bytes(None).metadata("model_name", "test_model"); + + // Use collect_to method + model.save_into(&mut save_store).unwrap(); + + // Load using new API + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let mut target_model = IntegrationTestModel::new(&device); + let result = target_model.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); + assert_eq!(result.applied.len(), 14); // All tensors should be applied + assert_eq!(result.errors.len(), 0); + assert_eq!(result.unused.len(), 0); +} + +#[test] +#[cfg(target_has_atomic = "ptr")] +fn with_filtering() { + let device = Default::default(); + let model = IntegrationTestModel::new(&device); + + // Save only encoder tensors using the builder pattern + let mut save_store = SafetensorsStore::from_bytes(None) + .with_regex(r"^encoder\..*") + .metadata("subset", "encoder_only"); + + model.save_into(&mut save_store).unwrap(); + + // Load into new model - need to allow partial loading since we only saved encoder tensors + let mut load_store = SafetensorsStore::from_bytes(None).allow_partial(true); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let mut target_model = IntegrationTestModel::new(&device); + let result = target_model.load_from(&mut load_store).unwrap(); + + // Only encoder tensors should be applied + assert_eq!(result.applied.len(), 6); // encoder has 6 tensors (2 layers × 2 + norm × 2) + + // Check that only encoder tensors were applied + for tensor_name in &result.applied { + assert!(tensor_name.starts_with("encoder.")); + } +} diff --git a/crates/burn-store/src/safetensors/tests/metadata.rs b/crates/burn-store/src/safetensors/tests/metadata.rs new file mode 100644 index 0000000..94e8b56 --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/metadata.rs @@ -0,0 +1,101 @@ +use crate::{ModuleSnapshot, SafetensorsStore}; +use burn_nn::LinearConfig; + +#[test] +fn default_metadata_included() { + // Verify that default metadata is automatically included + let default_metadata = SafetensorsStore::default_metadata(); + + // Check that format, producer and version are present + assert_eq!(default_metadata.get("format").unwrap(), "safetensors"); + assert_eq!(default_metadata.get("producer").unwrap(), "burn"); + assert!(default_metadata.contains_key("version")); + + // The version should be the crate version + let version = default_metadata.get("version").unwrap(); + assert!(!version.is_empty()); +} + +#[test] +fn metadata_preservation() { + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Write with metadata - note that format, producer and version are automatically added + let mut save_store = SafetensorsStore::from_bytes(None) + .metadata("model_type", "linear") + .metadata("custom_field", "test_value"); + + module.save_into(&mut save_store).unwrap(); + + // Verify metadata was saved (would need to add a method to check metadata) + // For now, just verify the round trip works + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + // Get Arc and extract data + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + + let mut module2 = LinearConfig::new(4, 2).with_bias(true).init(&device); + let result = module2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); +} + +#[test] +fn clear_metadata_removes_all() { + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Create store with custom metadata, then clear all + let mut save_store = SafetensorsStore::from_bytes(None) + .metadata("model_type", "linear") + .metadata("custom_field", "test_value") + .clear_metadata(); // Should remove all metadata including defaults + + module.save_into(&mut save_store).unwrap(); + + // Load and verify the module still works (metadata is optional) + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + + let mut module2 = LinearConfig::new(4, 2).with_bias(true).init(&device); + let result = module2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); +} + +#[test] +fn clear_then_add_custom_metadata() { + let device = Default::default(); + let module = LinearConfig::new(4, 2).with_bias(true).init(&device); + + // Clear all metadata, then add only custom ones + let mut save_store = SafetensorsStore::from_bytes(None) + .clear_metadata() + .metadata("only_custom", "value"); + + module.save_into(&mut save_store).unwrap(); + + // Verify round-trip works + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + + let mut module2 = LinearConfig::new(4, 2).with_bias(true).init(&device); + let result = module2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); +} diff --git a/crates/burn-store/src/safetensors/tests/mixed_datatypes.rs b/crates/burn-store/src/safetensors/tests/mixed_datatypes.rs new file mode 100644 index 0000000..116d22b --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/mixed_datatypes.rs @@ -0,0 +1,436 @@ +use burn_core as burn; + +use burn_core::module::{Module, Param, ParamId}; +use burn_core::tensor::{Bool, Device, Int, Tensor}; +use burn_nn as nn; + +use crate::{ModuleSnapshot, SafetensorsStore}; + +/// Simple model with different data types for testing +#[derive(Module, Debug)] +pub struct MixedDtypeModel { + // Standard neural network layers (float tensors) + linear: nn::Linear, + + // Direct tensor parameters of different types + float_tensor: Param>, + + int_tensor: Param>, + + bool_tensor: Param>, +} + +impl MixedDtypeModel { + pub fn new(device: &Device) -> Self { + Self { + linear: nn::LinearConfig::new(3, 3).init(device), + + // Simple float values + float_tensor: Param::from_tensor(Tensor::from_floats( + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + device, + )), + + // Simple integer values + int_tensor: Param::initialized( + ParamId::new(), + Tensor::from_ints([[1, 2, 3], [4, 5, 6]], device), + ), + + // Simple boolean values + bool_tensor: Param::initialized( + ParamId::new(), + Tensor::from_bool( + burn::tensor::TensorData::new( + vec![true, false, true, false, true, false], + [2, 3], + ), + device, + ), + ), + } + } +} + +#[cfg(test)] +#[allow(clippy::excessive_precision)] +mod tests { + use burn_core::tensor::{BoolStore, DType}; + + use super::*; + + #[test] + fn test_mixed_dtypes_round_trip() { + let device = Default::default(); + + // Create model with mixed data types + let model = MixedDtypeModel::new(&device); + + // Save to bytes + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load into a new model + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = MixedDtypeModel::new(&device); + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + // Verify float tensor is preserved + let orig_float = model.float_tensor.val().into_data(); + let loaded_float = loaded_model.float_tensor.val().into_data(); + assert_eq!(orig_float, loaded_float, "Float tensor not preserved"); + + // Verify integer tensor is preserved + let orig_int = model.int_tensor.val().into_data(); + let loaded_int = loaded_model.int_tensor.val().into_data(); + assert_eq!(orig_int, loaded_int, "Integer tensor not preserved"); + + // Verify boolean tensor is preserved + let orig_bool = model.bool_tensor.val().into_data(); + let loaded_bool = loaded_model.bool_tensor.val().into_data(); + assert_eq!(orig_bool, loaded_bool, "Boolean tensor not preserved"); + } + + #[test] + fn test_dtype_detection() { + let device = Default::default(); + + let model = MixedDtypeModel::new(&device); + let snapshots = model.collect(None, None, false); + + for snapshot in snapshots { + let path = snapshot.full_path(); + let dtype = snapshot.dtype; + + if path.contains("float_tensor") || path.contains("linear") { + assert_eq!( + dtype, + burn::tensor::DType::F32, + "Float tensor {} should have F32 dtype", + path + ); + } else if path.contains("int_tensor") { + assert!( + matches!( + dtype, + burn::tensor::DType::I64 + | burn::tensor::DType::I32 + | burn::tensor::DType::I16 + | burn::tensor::DType::I8 + ), + "Integer tensor {} should have integer dtype, got {:?}", + path, + dtype + ); + } else if path.contains("bool_tensor") { + assert_eq!( + dtype, + burn::tensor::DType::Bool(BoolStore::Native), + "Boolean tensor {} should have Bool dtype", + path + ); + } + } + } + + #[test] + fn test_extreme_values() { + let device = Device::default(); + + #[derive(Module, Debug)] + struct ExtremeValueModel { + large_floats: Param>, + small_floats: Param>, + large_ints: Param>, + } + + impl ExtremeValueModel { + fn new(device: &Device) -> Self { + Self { + large_floats: Param::from_tensor(Tensor::from_floats( + [1e30, -1e30, f32::MAX, f32::MIN], + device, + )), + small_floats: Param::from_tensor(Tensor::from_floats( + [1e-30, -1e-30, f32::MIN_POSITIVE, f32::EPSILON], + device, + )), + large_ints: Param::initialized( + ParamId::new(), + Tensor::from_ints([i32::MAX, i32::MIN, 0, -1], device), + ), + } + } + } + + let model = ExtremeValueModel::new(&device); + + // Save and load + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = ExtremeValueModel::new(&device); + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + // Check exact preservation + assert_eq!( + model.large_floats.val().into_data(), + loaded_model.large_floats.val().into_data(), + "Large floats not preserved" + ); + assert_eq!( + model.small_floats.val().into_data(), + loaded_model.small_floats.val().into_data(), + "Small floats not preserved" + ); + assert_eq!( + model.large_ints.val().into_data(), + loaded_model.large_ints.val().into_data(), + "Large integers not preserved" + ); + } + + #[test] + fn test_mixed_precision_floats() { + // Note: While SafeTensors format supports storing tensors with different precisions + // (F16, BF16, F32, F64, etc.) in the same file, Burn's backend architecture currently + // requires all tensors in a model instance to share the same floating-point precision. + // + // However, for storage purposes, SafeTensors can correctly save and load tensors + // with their original precision, preserving the data type information in the file format. + // This test demonstrates that different precision backends work correctly with SafeTensors. + + // Test with f32 backend + { + let device = Default::default(); + + let model = MixedDtypeModel::new(&device); + + // Save to bytes + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load and verify + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = MixedDtypeModel::new(&device); + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + assert_eq!( + model.float_tensor.val().into_data(), + loaded_model.float_tensor.val().into_data(), + "F32 float tensor not preserved" + ); + } + + // Test with f64 weights + { + let device = Default::default(); + + #[derive(Module, Debug)] + struct F64Model { + weight: Param>, + double_precision: Param>, + } + + let model = F64Model { + weight: Param::from_tensor(Tensor::ones([3, 3], (&device, DType::F64))), + double_precision: Param::from_tensor(Tensor::from_data( + [ + [1.234567890123456789, 2.345678901234567890], + [3.456789012345678901, 4.567890123456789012], + ], + (&device, DType::F64), + )), + }; + + // Save to bytes + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load and verify + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = F64Model { + weight: Param::from_tensor(Tensor::ones([3, 3], &device)), + double_precision: Param::from_tensor(Tensor::zeros([2, 2], &device)), + }; + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + let orig = model.double_precision.val().into_data(); + let loaded = loaded_model.double_precision.val().into_data(); + assert_eq!(orig, loaded, "F64 double precision not preserved"); + } + } + + #[test] + fn test_mixed_precision_integers() { + let device = Default::default(); + + #[derive(Module, Debug)] + struct MultiIntModel { + // Note: Burn's Tensor uses the backend's default int type + // We can't directly specify i8, i16, etc. in the type system + // But we can test with different values that would fit in different ranges + small_ints: Param>, // Values that fit in i8 + medium_ints: Param>, // Values that fit in i16 + large_ints: Param>, // Values that need i32/i64 + } + + let model = MultiIntModel { + small_ints: Param::initialized( + ParamId::new(), + Tensor::from_ints([127i32, -128, 0, 42], &device), + ), + medium_ints: Param::initialized( + ParamId::new(), + Tensor::from_ints([32767i32, -32768, 1000, -1000], &device), + ), + large_ints: Param::initialized( + ParamId::new(), + Tensor::from_ints([i32::MAX, i32::MIN, 1_000_000, -1_000_000], &device), + ), + }; + + // Save to bytes + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load and verify + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = MultiIntModel { + small_ints: Param::initialized(ParamId::new(), Tensor::zeros([4], &device)), + medium_ints: Param::initialized(ParamId::new(), Tensor::zeros([4], &device)), + large_ints: Param::initialized(ParamId::new(), Tensor::zeros([4], &device)), + }; + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + assert_eq!( + model.small_ints.val().into_data(), + loaded_model.small_ints.val().into_data(), + "Small ints (i8 range) not preserved" + ); + assert_eq!( + model.medium_ints.val().into_data(), + loaded_model.medium_ints.val().into_data(), + "Medium ints (i16 range) not preserved" + ); + assert_eq!( + model.large_ints.val().into_data(), + loaded_model.large_ints.val().into_data(), + "Large ints (i32 range) not preserved" + ); + } + + #[test] + fn test_comprehensive_mixed_types() { + let device = Default::default(); + + #[derive(Module, Debug)] + struct ComprehensiveModel { + // Neural network layers + linear1: nn::Linear, + conv2d: nn::conv::Conv2d, + + // Different tensor types + float32_weights: Param>, + integer_indices: Param>, + boolean_mask: Param>, + } + + let model = ComprehensiveModel { + linear1: nn::LinearConfig::new(4, 8).init(&device), + conv2d: nn::conv::Conv2dConfig::new([3, 16], [3, 3]).init(&device), + + float32_weights: Param::from_tensor(Tensor::from_floats( + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + &device, + )), + + integer_indices: Param::initialized( + ParamId::new(), + Tensor::from_ints( + [[0, 1, 2, 3], [10, 20, 30, 40], [100, 200, 300, 400]], + &device, + ), + ), + + boolean_mask: Param::initialized( + ParamId::new(), + Tensor::from_bool( + burn::tensor::TensorData::new( + vec![true, false, false, true, false, true, true, false], + [2, 4], + ), + &device, + ), + ), + }; + + // Collect all tensors + let snapshots = model.collect(None, None, false); + + // Verify we have all expected tensors + let paths: Vec = snapshots.iter().map(|s| s.full_path()).collect(); + assert!(paths.iter().any(|p| p.contains("linear1"))); + assert!(paths.iter().any(|p| p.contains("conv2d"))); + assert!(paths.iter().any(|p| p.contains("float32_weights"))); + assert!(paths.iter().any(|p| p.contains("integer_indices"))); + assert!(paths.iter().any(|p| p.contains("boolean_mask"))); + + // Save to bytes + let mut save_store = SafetensorsStore::from_bytes(None); + model.save_into(&mut save_store).expect("Failed to save"); + let bytes = save_store.get_bytes().expect("Failed to get bytes"); + + // Load into fresh model + let mut load_store = SafetensorsStore::from_bytes(Some(bytes)); + let mut loaded_model = ComprehensiveModel { + linear1: nn::LinearConfig::new(4, 8).init(&device), + conv2d: nn::conv::Conv2dConfig::new([3, 16], [3, 3]).init(&device), + float32_weights: Param::from_tensor(Tensor::zeros([2, 2, 2], &device)), + integer_indices: Param::initialized(ParamId::new(), Tensor::zeros([3, 4], &device)), + boolean_mask: Param::initialized( + ParamId::new(), + Tensor::from_bool( + burn::tensor::TensorData::new(vec![false; 8], [2, 4]), + &device, + ), + ), + }; + loaded_model + .load_from(&mut load_store) + .expect("Failed to load"); + + // Verify all data is preserved + assert_eq!( + model.float32_weights.val().into_data(), + loaded_model.float32_weights.val().into_data(), + "Float32 weights not preserved" + ); + assert_eq!( + model.integer_indices.val().into_data(), + loaded_model.integer_indices.val().into_data(), + "Integer indices not preserved" + ); + assert_eq!( + model.boolean_mask.val().into_data(), + loaded_model.boolean_mask.val().into_data(), + "Boolean mask not preserved" + ); + } +} diff --git a/crates/burn-store/src/safetensors/tests/mod.rs b/crates/burn-store/src/safetensors/tests/mod.rs new file mode 100644 index 0000000..1ac3037 --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/mod.rs @@ -0,0 +1,12 @@ +mod adapter; +mod direct_access; +mod error_handling; +#[cfg(feature = "std")] +mod file_io; +mod filtering; +mod integration; +mod metadata; +mod mixed_datatypes; +mod multi_layer_verify; +mod pytorch_import; +mod round_trip; diff --git a/crates/burn-store/src/safetensors/tests/multi_layer_verify.rs b/crates/burn-store/src/safetensors/tests/multi_layer_verify.rs new file mode 100644 index 0000000..31a3d4b --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/multi_layer_verify.rs @@ -0,0 +1,111 @@ +//! Tests for multi-layer model loading with SafeTensors format +use burn_core as burn; + +use burn_core::module::Module; +use burn_core::tensor::{Device, Tensor, Tolerance}; + +use burn_nn::{ + BatchNorm, BatchNormConfig, Linear, LinearConfig, PaddingConfig2d, Relu, + conv::{Conv2d, Conv2dConfig}, +}; + +/// Multi-layer neural network model for testing +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv2d, + norm1: BatchNorm, + fc1: Linear, + relu: Relu, +} + +impl Net { + /// Create a new network instance + pub fn new(device: &Device) -> Self { + Self { + conv1: Conv2dConfig::new([3, 4], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .init(device), + norm1: BatchNormConfig::new(4).init(device), + fc1: LinearConfig::new(4 * 8 * 8, 16).init(device), + relu: Relu::new(), + } + } + + /// Forward pass of the model + pub fn forward(&self, x: Tensor<4>) -> Tensor<2> { + let x = self.conv1.forward(x); + let x = self.norm1.forward(x); + let x = self.relu.forward(x); + // Flatten all dimensions except the batch dimension + let x = x.flatten(1, 3); + self.fc1.forward(x) + } +} + +use crate::{ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore}; + +/// Path to the multi_layer.safetensors test file +fn get_safetensors_path() -> &'static str { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ) +} + +#[test] +fn multi_layer_model() { + let device = Default::default(); + let safetensors_path = get_safetensors_path(); + + // Load model from SafeTensors file with PyTorch adapter + let mut store = SafetensorsStore::from_file(safetensors_path) + .with_from_adapter(PyTorchToBurnAdapter) + .validate(false) + .allow_partial(true); + + let mut model = Net::new(&device); + let result = model.load_from(&mut store).unwrap(); + + // Verify loading was successful + assert!( + !result.applied.is_empty(), + "Should have loaded some tensors" + ); + assert!( + result.errors.is_empty(), + "Should have no errors: {:?}", + result.errors + ); + + // Test forward pass + let input = Tensor::<4>::ones([1, 3, 8, 8], &device); + let output = model.forward(input); + + // Expected output values from PyTorch model + let expected = Tensor::<2>::from_data( + [[ + 0.04971555, + -0.16849735, + 0.05182848, + -0.18032673, + 0.23138367, + 0.05041867, + 0.13005908, + -0.32202929, + -0.07915690, + -0.03232457, + -0.19790289, + -0.17476529, + -0.19627589, + -0.21757686, + -0.31376451, + 0.08377837, + ]], + &device, + ); + + // Verify output matches expected values + output + .to_data() + .assert_approx_eq::(&expected.to_data(), Tolerance::default()); +} diff --git a/crates/burn-store/src/safetensors/tests/pytorch_import.rs b/crates/burn-store/src/safetensors/tests/pytorch_import.rs new file mode 100644 index 0000000..6e2c633 --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/pytorch_import.rs @@ -0,0 +1,201 @@ +use burn_core as burn; + +use crate::{ModuleSnapshot, SafetensorsStore}; +use burn_core::module::Module; +use burn_core::tensor::{Device, Tensor}; +use burn_nn::{ + BatchNorm, BatchNormConfig, Linear, LinearConfig, PaddingConfig2d, Relu, + conv::{Conv2d, Conv2dConfig}, +}; + +#[derive(Module, Debug)] +pub struct Net { + conv1: Conv2d, + norm1: BatchNorm, + fc1: Linear, + relu: Relu, +} + +impl Net { + pub fn new(device: &Device) -> Self { + Self { + conv1: Conv2dConfig::new([3, 4], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .init(device), + norm1: BatchNormConfig::new(4).init(device), + fc1: LinearConfig::new(4 * 8 * 8, 16).init(device), + relu: Relu::new(), + } + } + + /// Forward pass of the model. + pub fn forward(&self, x: Tensor<4>) -> Tensor<2> { + let x = self.conv1.forward(x); + let x = self.norm1.forward(x); + let x = self.relu.forward(x); + // Flatten all dimensions except the batch dimension + let x = x.flatten(1, 3); + self.fc1.forward(x) + } +} + +#[test] +#[cfg(all(feature = "std", target_has_atomic = "ptr"))] +fn multi_layer_model_import() { + let device = Default::default(); + + // Reference the safetensors file from burn-import + let safetensors_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ); + + // Load the model using SafetensorsStore + // Note: PyTorch and Burn have different conventions for linear layer weights + // PyTorch stores as [out_features, in_features], Burn as [in_features, out_features] + // Also, tensor names may differ (e.g., PyTorch uses different names for BatchNorm params) + let mut store = SafetensorsStore::from_file(safetensors_path) + .with_from_adapter(crate::PyTorchToBurnAdapter) // Use adapter to handle PyTorch format + .allow_partial(true); // Allow partial loading due to naming differences + let mut model = Net::new(&device); + + let result = model.load_from(&mut store).unwrap(); + + // With the adapter, weights should load correctly + assert!(!result.applied.is_empty()); + assert!( + result.errors.is_empty(), + "Should have no errors with adapter: {:?}", + result.errors + ); + + // Test forward pass with the loaded weights + // Note: Due to shape mismatches (PyTorch vs Burn conventions for linear layers), + // we can't directly compare outputs with PyTorch model. + // This test mainly verifies that the loading mechanism works. + let input = Tensor::<4>::ones([1, 3, 8, 8], &device); + let _output = model.forward(input); + + // Verify that some tensors were loaded successfully + // Conv and BatchNorm layers should load correctly + assert!(result.applied.iter().any(|n| n.contains("conv1"))); + assert!(result.applied.iter().any(|n| n.contains("norm1"))); +} + +#[test] +#[cfg(all(feature = "std", target_has_atomic = "ptr"))] +fn safetensors_round_trip_with_pytorch_model() { + let device = Default::default(); + + // Reference the safetensors file from burn-import + let safetensors_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ); + + // Load the model from PyTorch safetensors + let mut load_store = SafetensorsStore::from_file(safetensors_path) + .with_from_adapter(crate::PyTorchToBurnAdapter) // Use adapter to handle PyTorch format + .allow_partial(true); // Allow partial loading due to naming differences + let mut model = Net::new(&device); + let load_result = model.load_from(&mut load_store).unwrap(); + // With the adapter, weights should load correctly + assert!(!load_result.applied.is_empty()); + assert!( + load_result.errors.is_empty(), + "Should have no errors with adapter: {:?}", + load_result.errors + ); + + // Save the model to memory + // Note: format, producer and version are automatically added + let mut save_store = SafetensorsStore::from_bytes(None).metadata("source", "pytorch"); + model.save_into(&mut save_store).unwrap(); + + // Load into a new model + let mut model2 = Net::new(&device); + let mut load_store2 = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store2 + && let SafetensorsStore::Memory(ref p_save) = save_store + { + p.set_data(p_save.data().unwrap().as_ref().clone()); + } + + let result = model2.load_from(&mut load_store2).unwrap(); + assert!(!result.applied.is_empty()); + + // Verify both models produce the same output + let input = Tensor::<4>::ones([1, 3, 8, 8], &device); + let output1 = model.forward(input.clone()); + let output2 = model2.forward(input); + + // Check outputs are identical + let output1_data = output1.to_data().to_vec::().unwrap(); + let output2_data = output2.to_data().to_vec::().unwrap(); + + for (a, b) in output1_data.iter().zip(output2_data.iter()) { + assert!((a - b).abs() < 1e-7, "Outputs differ after round trip"); + } +} + +#[test] +#[cfg(all(feature = "std", target_has_atomic = "ptr"))] +fn partial_load_from_pytorch_model() { + let device = Default::default(); + + // Reference the safetensors file from burn-import + let safetensors_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ); + + // Load only conv1 and norm1 parameters (not fc1) + let mut store = SafetensorsStore::from_file(safetensors_path) + .validate(false) // Disable validation due to shape differences + .allow_partial(true); + + let mut model = Net::new(&device); + + // Save initial fc1 weights for comparison + let _initial_fc1_weight = model.fc1.weight.val().to_data(); + + let result = model.load_from(&mut store).unwrap(); + + // Should load available tensors (with some errors due to shape mismatch) + assert!(!result.applied.is_empty()); + + // fc1 weight should remain unchanged if not in the file + // or should be updated if it is in the file + // This test verifies that partial loading works correctly +} + +#[test] +#[cfg(all(feature = "std", target_has_atomic = "ptr"))] +fn verify_tensor_names_from_pytorch() { + let device = Default::default(); + + // Reference the safetensors file from burn-import + let safetensors_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/safetensors-tests/tests/multi_layer/multi_layer.safetensors" + ); + + // Create a model and load from PyTorch + let mut model = Net::new(&device); + let mut store = SafetensorsStore::from_file(safetensors_path) + .validate(false) // Disable validation due to shape differences + .allow_partial(true); // Allow partial loading due to naming differences + let result = model.load_from(&mut store).unwrap(); + + // Check that we loaded some tensors (with errors due to shape mismatch) + assert!(!result.applied.is_empty()); + + // Collect tensor names from the model + let views = model.collect(None, None, false); + let tensor_names: Vec = views.iter().map(|v| v.full_path()).collect(); + + // Verify expected tensor names are present + assert!(tensor_names.iter().any(|n| n.contains("conv1"))); + assert!(tensor_names.iter().any(|n| n.contains("norm1"))); + assert!(tensor_names.iter().any(|n| n.contains("fc1"))); +} diff --git a/crates/burn-store/src/safetensors/tests/round_trip.rs b/crates/burn-store/src/safetensors/tests/round_trip.rs new file mode 100644 index 0000000..4dace86 --- /dev/null +++ b/crates/burn-store/src/safetensors/tests/round_trip.rs @@ -0,0 +1,103 @@ +use burn_core as burn; + +use crate::{ModuleSnapshot, SafetensorsStore}; +use burn_core::module::{Module, Param}; +use burn_core::tensor::{Device, Tensor, shape}; +use burn_nn::{Linear, LinearConfig}; + +#[derive(Module, Debug)] +pub(super) struct ComplexModule { + pub encoder: EncoderModule, + pub decoder: DecoderModule, + pub layers: Vec, +} + +#[derive(Module, Debug)] +pub(super) struct EncoderModule { + pub weight: Param>, + pub bias: Param>, + pub norm: Param>, +} + +#[derive(Module, Debug)] +pub(super) struct DecoderModule { + pub projection: Linear, + pub scale: Param>, +} + +impl ComplexModule { + pub fn new(device: &Device) -> Self { + Self { + encoder: EncoderModule { + weight: Param::from_data( + [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]], + device, + ), + bias: Param::from_data([0.1, 0.2, 0.3], device), + norm: Param::from_data([1.0, 1.0, 1.0], device), + }, + decoder: DecoderModule { + projection: LinearConfig::new(4, 2).with_bias(true).init(device), + scale: Param::from_data([[0.5, 0.5], [0.5, 0.5]], device), + }, + layers: vec![ + LinearConfig::new(3, 4).with_bias(false).init(device), + LinearConfig::new(4, 3).with_bias(true).init(device), + ], + } + } + + pub fn new_zeros(device: &Device) -> Self { + Self { + encoder: EncoderModule { + weight: Param::from_tensor(Tensor::zeros([2, 2, 2], device)), + bias: Param::from_tensor(Tensor::zeros([3], device)), + norm: Param::from_tensor(Tensor::zeros([3], device)), + }, + decoder: DecoderModule { + projection: LinearConfig::new(4, 2).with_bias(true).init(device), + scale: Param::from_tensor(Tensor::zeros([2, 2], device)), + }, + layers: vec![ + LinearConfig::new(3, 4).with_bias(false).init(device), + LinearConfig::new(4, 3).with_bias(true).init(device), + ], + } + } +} + +#[test] +fn complex_module_round_trip() { + let device = Default::default(); + let module1 = ComplexModule::new(&device); + let mut module2 = ComplexModule::new_zeros(&device); + + // Save module1 using new store API + let mut save_store = SafetensorsStore::from_bytes(None); + module1.save_into(&mut save_store).unwrap(); + + // Load into module2 + let mut load_store = SafetensorsStore::from_bytes(None); + if let SafetensorsStore::Memory(ref mut p) = load_store + && let SafetensorsStore::Memory(ref p_save) = save_store + { + // Get Arc and extract data + let data_arc = p_save.data().unwrap(); + p.set_data(data_arc.as_ref().clone()); + } + let result = module2.load_from(&mut load_store).unwrap(); + + assert!(result.is_success()); + assert!(result.applied.len() > 5); + assert_eq!(result.errors.len(), 0); + + // Verify data was imported correctly + let module2_views = module2.collect(None, None, false); + let encoder_weight = module2_views + .iter() + .find(|v| v.full_path() == "encoder.weight") + .unwrap() + .to_data() + .unwrap(); + assert_eq!(encoder_weight.shape, shape![2, 2, 2]); +} diff --git a/crates/burn-store/src/tensor_snapshot.rs b/crates/burn-store/src/tensor_snapshot.rs new file mode 100644 index 0000000..0817659 --- /dev/null +++ b/crates/burn-store/src/tensor_snapshot.rs @@ -0,0 +1,617 @@ +use alloc::rc::Rc; +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec::Vec; +use burn_core::module::ParamId; +use burn_core::tensor::quantization::{QPARAM_ALIGN, QuantParam, params_shape}; +use burn_core::tensor::{Bool, DType, Int, Shape, Tensor, TensorData}; +use half::f16; + +/// Returns the byte size of a quantization parameter type. +// TODO: Add `size_bytes()` method to `QuantParam` in cubecl and use it here. +const fn quant_param_size(param: QuantParam) -> usize { + match param { + QuantParam::F32 => core::mem::size_of::(), + QuantParam::F16 | QuantParam::BF16 => core::mem::size_of::(), + QuantParam::UE8M0 | QuantParam::UE4M3 => core::mem::size_of::(), + } +} + +/// Error type for TensorSnapshot operations +#[derive(Debug, Clone)] +pub enum TensorSnapshotError { + /// I/O error occurred while loading tensor data + IoError(String), + /// Data corruption or invalid format + DataError(String), + /// Panic occurred while loading tensor data + PanicError(String), +} + +impl core::fmt::Display for TensorSnapshotError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::IoError(e) => write!(f, "I/O error: {}", e), + Self::DataError(e) => write!(f, "Data error: {}", e), + Self::PanicError(e) => write!(f, "Panic error: {}", e), + } + } +} + +impl core::error::Error for TensorSnapshotError {} + +/// A lightweight snapshot of a tensor that can lazily produce TensorData. +/// +/// TensorSnapshot stores a cloned tensor internally (which is cheap due to reference counting) +/// and only materializes the actual data when `to_data()` is called. This allows +/// efficient inspection of module structure without the overhead of copying all tensor data. +/// +/// The dtype and shape are cached for efficient access without requiring data materialization, +/// which is particularly useful for serialization formats that need metadata upfront. +pub struct TensorSnapshot { + /// Function to get tensor data when needed (Rc allows cloning) + data_fn: Rc Result>, + /// Data type of the tensor (cached for efficient access) + pub dtype: DType, + /// Shape of the tensor (cached for efficient access) + pub shape: Shape, + /// Path stack representing the module hierarchy + pub path_stack: Option>, + /// Container stack representing the container types at each level + pub container_stack: Option>, + /// Unique identifier for the tensor parameter + pub tensor_id: Option, +} + +impl TensorSnapshot { + /// Create a new tensor snapshot from a float tensor + pub fn from_float( + tensor: &Tensor, + path_stack: Vec, + container_stack: Vec, + tensor_id: ParamId, + ) -> Self { + let dtype = tensor.dtype(); + let shape = tensor.shape(); + let tensor = tensor.clone(); // Clone is cheap (reference counted) + Self { + data_fn: Rc::new(move || Ok(tensor.to_data())), + dtype, + shape, + path_stack: Some(path_stack), + container_stack: Some(container_stack), + tensor_id: Some(tensor_id), + } + } + + /// Create a new tensor snapshot from an int tensor + pub fn from_int( + tensor: &Tensor, + path_stack: Vec, + container_stack: Vec, + tensor_id: ParamId, + ) -> Self { + let dtype = tensor.dtype(); + let shape = tensor.shape(); + let tensor = tensor.clone(); // Clone is cheap (reference counted) + Self { + data_fn: Rc::new(move || Ok(tensor.to_data())), + dtype, + shape, + path_stack: Some(path_stack), + container_stack: Some(container_stack), + tensor_id: Some(tensor_id), + } + } + + /// Create a new tensor snapshot from a bool tensor + pub fn from_bool( + tensor: &Tensor, + path_stack: Vec, + container_stack: Vec, + tensor_id: ParamId, + ) -> Self { + let dtype = tensor.dtype(); + let shape = tensor.shape(); + let tensor = tensor.clone(); // Clone is cheap (reference counted) + Self { + data_fn: Rc::new(move || Ok(tensor.to_data())), + dtype, + shape, + path_stack: Some(path_stack), + container_stack: Some(container_stack), + tensor_id: Some(tensor_id), + } + } + + /// Convert to TensorData (this is where actual data copy happens) + #[cfg(feature = "std")] + pub fn to_data(&self) -> Result { + // Use AssertUnwindSafe since we're working with Rc which is not UnwindSafe + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.data_fn)())).unwrap_or_else( + |_| { + Err(TensorSnapshotError::PanicError( + "Panic occurred while loading tensor data".to_string(), + )) + }, + ) + } + + /// Convert to TensorData (this is where actual data copy happens) + #[cfg(not(feature = "std"))] + pub fn to_data(&self) -> Result { + (self.data_fn)() // Can't catch panics in no-std, do it when core::panic::AssertUnwindSafe is available + } + + /// Get the full path by joining the path stack + pub fn full_path(&self) -> String { + self.path_stack + .as_ref() + .map(|stack| stack.join(".")) + .unwrap_or_default() + } + + /// Get the full container path by joining the container stack + pub fn container_path(&self) -> String { + self.container_stack + .as_ref() + .map(|stack| stack.join(".")) + .unwrap_or_default() + } + + /// Get the module type (last Struct/Enum in the hierarchy) + /// + /// Returns the last user-defined module type, skipping primitive containers + /// like "Vec", "Array". This is useful for determining which user-defined + /// module a tensor belongs to. + /// + /// # Examples + /// - `Linear.weight` → `Some("Struct:Linear")` + /// - `Vec[0].weight` → `Some("Struct:Linear")` + /// - `Linear.bias` (Optional) → `Some("Struct:Linear")` + /// - `Vec[0]` (no module) → `None` + pub fn module_type(&self) -> Option { + self.container_stack.as_ref().and_then(|stack| { + // Find the last user-defined type (Struct: or Enum:) + stack + .iter() + .rev() + .find(|ct| ct.starts_with("Struct:") || ct.starts_with("Enum:")) + .cloned() + }) + } + + /// Get the immediate container type (last in the container stack) + /// + /// Returns the last element in the container stack, which could be a + /// user-defined type ("Struct:", "Enum:") or a collection type ("Vec", "Array"). + /// This is useful for understanding the full container hierarchy. + /// + /// # Examples + /// - `Linear.weight` → `"Struct:Linear"` + /// - `Vec[0].weight` → `"Struct:Linear"` (the Linear, not the Vec) + /// - `Vec[0]` → `"Vec"` + pub fn container_type(&self) -> String { + self.container_stack + .as_ref() + .and_then(|stack| stack.last()) + .cloned() + .unwrap_or_else(|| "Unknown".to_string()) + } + + /// Create a TensorSnapshot from a closure that produces TensorData + /// This is used internally for lazy loading + pub fn from_closure( + data_fn: Rc Result>, + dtype: DType, + shape: Shape, + path_stack: Vec, + container_stack: Vec, + tensor_id: ParamId, + ) -> Self { + Self { + data_fn, + dtype, + shape, + path_stack: Some(path_stack), + container_stack: Some(container_stack), + tensor_id: Some(tensor_id), + } + } + + /// Create a TensorSnapshot from TensorData directly + pub fn from_data( + data: TensorData, + path_stack: Vec, + container_stack: Vec, + tensor_id: ParamId, + ) -> Self { + let dtype = data.dtype; + let shape = data.shape.clone(); + Self { + data_fn: Rc::new(move || Ok(data.clone())), + dtype, + shape, + path_stack: Some(path_stack), + container_stack: Some(container_stack), + tensor_id: Some(tensor_id), + } + } + + /// Get the size of the tensor data in bytes without materializing it. + /// + /// For regular (non-quantized) types, this is simply `shape.product() * dtype.size()`. + /// + /// For quantized types (`QFloat`), this accounts for: + /// - The quantized values (packed according to the quantization scheme) + /// - Alignment padding (values are aligned to 4-byte boundary) + /// - Quantization parameters (scale values appended to the data) + pub fn data_len(&self) -> usize { + const BITS_PER_BYTE: usize = 8; + + let num_elements: usize = self.shape.iter().product(); + + match self.dtype { + DType::QFloat(scheme) => { + // Calculate value bytes using scheme's packing information + let num_storage_elements = num_elements.div_ceil(scheme.num_quants()); + let value_bytes = + num_storage_elements * (scheme.size_bits_stored() / BITS_PER_BYTE); + + // Calculate number of quantization parameters (scales) + let num_params = params_shape(&self.shape, scheme.level).num_elements(); + + let aligned_value_bytes = value_bytes.div_ceil(QPARAM_ALIGN) * QPARAM_ALIGN; + let scale_bytes = num_params * quant_param_size(scheme.param); + + aligned_value_bytes + scale_bytes + } + _ => num_elements * self.dtype.size(), + } + } + + /// Clone the data function for lazy composition + pub fn clone_data_fn(&self) -> Rc Result> { + self.data_fn.clone() + } +} + +impl Clone for TensorSnapshot { + fn clone(&self) -> Self { + // Clone lazily - keep the same data function + Self { + data_fn: self.data_fn.clone(), + dtype: self.dtype, + shape: self.shape.clone(), + path_stack: self.path_stack.clone(), + container_stack: self.container_stack.clone(), + tensor_id: self.tensor_id, + } + } +} + +impl core::fmt::Debug for TensorSnapshot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("TensorSnapshot") + .field("dtype", &self.dtype) + .field("shape", &self.shape) + .field("path_stack", &self.path_stack) + .field("container_stack", &self.container_stack) + .field("tensor_id", &self.tensor_id) + .finish() + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use alloc::string::ToString; + use burn_core::tensor::{BoolStore, Device, shape}; + + #[test] + fn tensor_view_float() { + let device = Default::default(); + let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + + let snapshot = TensorSnapshot::from_float( + &tensor, + vec!["test".to_string(), "weight".to_string()], + vec!["TestModule".to_string(), "Param".to_string()], + ParamId::new(), + ); + + // Test metadata access without materialization + assert_eq!(snapshot.dtype, DType::F32); + assert_eq!(snapshot.shape, shape![2, 2]); + assert_eq!(snapshot.full_path(), "test.weight"); + assert_eq!(snapshot.container_path(), "TestModule.Param"); + + // Test data materialization + let data = snapshot.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 2]); + assert_eq!(data.dtype, DType::F32); + } + + #[test] + fn tensor_view_int() { + let device = Default::default(); + let tensor = Tensor::<2, Int>::from_data([[1, 2], [3, 4]], &device); + + let snapshot = TensorSnapshot::from_int( + &tensor, + vec!["test".to_string(), "int".to_string()], + vec!["TestModule".to_string(), "Param".to_string()], + ParamId::new(), + ); + + // Test metadata access without materialization + let dtype: DType = device.settings().int_dtype.into(); + assert_eq!(snapshot.dtype, dtype); + assert_eq!(snapshot.shape, shape![2, 2]); + + let data = snapshot.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 2]); + assert_eq!(data.dtype, dtype); + } + + #[test] + fn tensor_view_bool() { + let device = Default::default(); + let tensor = Tensor::<2, Bool>::from_data([[true, false], [false, true]], &device); + + let snapshot = TensorSnapshot::from_bool( + &tensor, + vec!["test".to_string(), "bool".to_string()], + vec!["TestModule".to_string(), "Param".to_string()], + ParamId::new(), + ); + + // Test metadata access without materialization + assert_eq!(snapshot.dtype, DType::Bool(BoolStore::Native)); + assert_eq!(snapshot.shape, shape![2, 2]); + + let data = snapshot.to_data().unwrap(); + assert_eq!(data.shape, shape![2, 2]); + assert_eq!(data.dtype, DType::Bool(BoolStore::Native)); + } + + #[test] + fn data_len() { + let device = Device::default(); + let settings = device.settings(); + + // Test F32 tensor (4 bytes per element) + let tensor_f32 = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device); + let view_f32 = TensorSnapshot::from_float( + &tensor_f32, + vec!["test".to_string()], + vec!["Module".to_string()], + ParamId::new(), + ); + let dtype: DType = settings.float_dtype.into(); + assert_eq!(view_f32.data_len(), 4 * dtype.size()); // 4 elements * 4 bytes + + // Test int tensor + let tensor_i64 = Tensor::<3, Int>::from_data([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], &device); + let view_i64 = TensorSnapshot::from_int( + &tensor_i64, + vec!["test".to_string()], + vec!["Module".to_string()], + ParamId::new(), + ); + let dtype: DType = settings.int_dtype.into(); + assert_eq!(view_i64.data_len(), 8 * dtype.size()); // 8 elements * 8 bytes (I64) + + // Test Bool tensor (1 byte per element) + let tensor_bool = Tensor::<2, Bool>::from_data([[true, false], [false, true]], &device); + let view_bool = TensorSnapshot::from_bool( + &tensor_bool, + vec!["test".to_string()], + vec!["Module".to_string()], + ParamId::new(), + ); + let dtype: DType = settings.bool_dtype.into(); + assert_eq!(view_bool.data_len(), 4 * dtype.size()); // 4 elements * 1 byte + } + + #[test] + fn from_closure() { + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0]); + let dtype = data.dtype; + let shape = data.shape.clone(); + + let snapshot = TensorSnapshot::from_closure( + Rc::new(move || Ok(data.clone())), + dtype, + shape.clone(), + vec!["model".to_string(), "layer".to_string()], + vec!["Model".to_string(), "Layer".to_string()], + ParamId::new(), + ); + + // Test metadata access + assert_eq!(snapshot.dtype, DType::F32); + assert_eq!(snapshot.shape, shape![4]); + assert_eq!(snapshot.full_path(), "model.layer"); + assert_eq!(snapshot.data_len(), 16); // 4 * 4 bytes + + // Test data materialization + let materialized = snapshot.to_data().unwrap(); + assert_eq!(materialized.shape, shape![4]); + } + + #[test] + fn from_data() { + let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]); + let original_dtype = data.dtype; + let original_shape = data.shape.clone(); + + let snapshot = TensorSnapshot::from_data( + data, + vec!["encoder".to_string(), "weight".to_string()], + vec!["Struct:Encoder".to_string(), "Struct:Dense".to_string()], + ParamId::new(), + ); + + // Test metadata + assert_eq!(snapshot.dtype, original_dtype); + assert_eq!(snapshot.shape, original_shape); + assert_eq!(snapshot.full_path(), "encoder.weight"); + assert_eq!(snapshot.container_type(), "Struct:Dense"); + assert_eq!(snapshot.data_len(), 24); // 6 * 4 bytes + + // Test data materialization + let materialized = snapshot.to_data().unwrap(); + assert_eq!(materialized.shape, original_shape); + } + + #[test] + #[cfg(feature = "std")] + fn panic_catching_in_to_data() { + use alloc::rc::Rc; + + // Create a TensorSnapshot with a closure that panics + let snapshot = TensorSnapshot { + data_fn: Rc::new(|| panic!("Test panic in data_fn")), + dtype: DType::F32, + shape: shape![2, 2], + path_stack: Some(vec!["test".to_string()]), + container_stack: Some(vec!["Test".to_string()]), + tensor_id: Some(ParamId::new()), + }; + + // When std is available, to_data should catch the panic and return an error + let result = snapshot.to_data(); + assert!(result.is_err()); + + match result { + Err(TensorSnapshotError::PanicError(msg)) => { + assert!(msg.contains("Panic occurred")); + } + _ => panic!("Expected PanicError with panic message"), + } + } + + #[test] + fn error_propagation_in_closure() { + use alloc::rc::Rc; + + // Create a snapshot with a closure that returns an error + let snapshot = TensorSnapshot::from_closure( + Rc::new(|| Err(TensorSnapshotError::IoError("Simulated IO error".into()))), + DType::F32, + shape![2, 2], + vec!["error_test".into()], + vec![], + ParamId::new(), + ); + + // Should return an error when trying to get data + let result = snapshot.to_data(); + assert!(result.is_err()); + match result { + Err(TensorSnapshotError::IoError(msg)) => { + assert!(msg.contains("Simulated IO error")); + } + _ => panic!("Expected IoError"), + } + } + + #[test] + fn container_type_extraction() { + let device = Default::default(); + let tensor = Tensor::<1>::from_data([1.0, 2.0, 3.0], &device); + + let snapshot = TensorSnapshot::from_float( + &tensor, + vec![ + "model".to_string(), + "layer1".to_string(), + "weight".to_string(), + ], + vec![ + "Struct:Model".to_string(), + "Struct:Conv2d".to_string(), + "Struct:Param".to_string(), + ], + ParamId::new(), + ); + + assert_eq!(snapshot.container_type(), "Struct:Param"); + assert_eq!(snapshot.module_type(), Some("Struct:Param".to_string())); + assert_eq!( + snapshot.container_path(), + "Struct:Model.Struct:Conv2d.Struct:Param" + ); + assert_eq!(snapshot.full_path(), "model.layer1.weight"); + } + + #[test] + fn container_type_vs_module_type() { + let device = Default::default(); + let tensor = Tensor::<1>::from_data([1.0, 2.0, 3.0], &device); + + // Test case 1: Tensor inside a Vec + // container_stack: ["Struct:Model", "Vec", "Struct:Linear"] + let snapshot = TensorSnapshot::from_float( + &tensor, + vec![ + "model".to_string(), + "layers".to_string(), + "0".to_string(), + "weight".to_string(), + ], + vec![ + "Struct:Model".to_string(), + "Vec".to_string(), + "Struct:Linear".to_string(), + ], + ParamId::new(), + ); + + // container_type() returns the last element (Struct:Linear in this case) + assert_eq!(snapshot.container_type(), "Struct:Linear"); + // module_type() also returns Some(Struct:Linear) (skipping Vec) + assert_eq!(snapshot.module_type(), Some("Struct:Linear".to_string())); + + // Test case 2: Tensor that's just in a Vec + // container_stack: ["Vec"] + let snapshot2 = TensorSnapshot::from_float( + &tensor, + vec!["data".to_string(), "0".to_string()], + vec!["Vec".to_string()], + ParamId::new(), + ); + + // container_type() returns Vec + assert_eq!(snapshot2.container_type(), "Vec"); + // module_type() returns None (no Struct/Enum found) + assert_eq!(snapshot2.module_type(), None); + + // Test case 3: Nested collections + // container_stack: ["Struct:Model", "Vec", "Array", "Struct:Linear"] + let snapshot3 = TensorSnapshot::from_float( + &tensor, + vec![ + "model".to_string(), + "layers".to_string(), + "0".to_string(), + "sublayers".to_string(), + "1".to_string(), + "weight".to_string(), + ], + vec![ + "Struct:Model".to_string(), + "Vec".to_string(), + "Array".to_string(), + "Struct:Linear".to_string(), + ], + ParamId::new(), + ); + + // container_type() returns the immediate container + assert_eq!(snapshot3.container_type(), "Struct:Linear"); + // module_type() returns the last Struct/Enum + assert_eq!(snapshot3.module_type(), Some("Struct:Linear".to_string())); + } +} diff --git a/crates/burn-store/src/traits.rs b/crates/burn-store/src/traits.rs new file mode 100644 index 0000000..bb8f11b --- /dev/null +++ b/crates/burn-store/src/traits.rs @@ -0,0 +1,281 @@ +use alloc::boxed::Box; +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; + +use super::applier::Applier; +use super::apply_result::ApplyResult; +use crate::collector::Collector; +use crate::{ModuleAdapter, PathFilter, TensorSnapshot}; +use burn_core::module::Module; + +/// Extension trait for modules that provides tensor storage functionality. +/// +/// This trait provides convenient methods to collect and apply tensor snapshots from any Burn module. +/// Collection operations create lightweight tensor snapshots without immediately copying data. +/// Apply operations apply tensor data from snapshots to the corresponding tensors in the module. +pub trait ModuleSnapshot: Module { + /// Collects tensor snapshots for inspection without copying data. + /// + /// Returns a vector of `TensorSnapshot` objects that can lazily materialize the tensor data. + /// Each `TensorSnapshot` contains the full path accessible via `snapshot.full_path()`. + /// + /// # Arguments + /// + /// * `filter` - An optional [`PathFilter`] to determine which tensors to collect. + /// When `None`, all tensors are collected. + /// * `adapter` - Optional adapter to transform tensors based on container types. + /// Applied to all collected tensors before returning. + /// * `skip_enum_variants` - Skip enum variant names when building paths. + /// When true, paths will not include enum variant names (e.g., "feature.weight" + /// instead of "feature.BaseConv.weight"). Useful when exporting to formats + /// like PyTorch/SafeTensors that don't use enum variants. + fn collect( + &self, + filter: Option, + adapter: Option>, + skip_enum_variants: bool, + ) -> Vec { + let mut collector = Collector::new(filter, adapter, skip_enum_variants); + self.visit(&mut collector); + collector.into_tensors() + } + + /// Applies tensor snapshots to the module. + /// + /// This is the primary apply method that applies tensor data from `TensorSnapshot`s + /// to the corresponding tensors in the module. The snapshots are typically obtained + /// from `collect()` or loaded from storage. + /// + /// # Arguments + /// + /// * `snapshots` - A vector of TensorSnapshot objects + /// * `filter` - An optional [`PathFilter`] to determine which tensors to apply. + /// When `None`, all available tensors are applied. + /// * `adapter` - Optional adapter to transform tensors based on container types + /// * `skip_enum_variants` - Skip enum variant names when matching tensor paths + /// + /// # Returns + /// + /// An [`ApplyResult`] containing information about applied, skipped, missing, + /// and unused tensors, as well as any errors encountered. + /// + /// # Examples + /// + /// ```rust,ignore + /// use burn_store::PathFilter; + /// + /// // Apply all tensors + /// let result = model.apply(snapshots, None, None, false); + /// + /// // Apply only encoder tensors + /// let filter = PathFilter::new().with_regex(r"^encoder\..*"); + /// let result = model.apply(snapshots, Some(filter), None, false); + /// + /// // Apply with complex filter + /// let filter = PathFilter::new() + /// .with_regex(r"^encoder\..*") + /// .with_regex(r"^decoder\..*") + /// .with_full_path("head.weight"); + /// let result = model.apply(snapshots, Some(filter), None, false); + /// + /// // Apply with enum variant skipping (for PyTorch models) + /// let result = model.apply(snapshots, None, None, true); + /// ``` + fn apply( + &mut self, + snapshots: Vec, + filter: Option, + adapter: Option>, + skip_enum_variants: bool, + ) -> ApplyResult + where + Self: Sized, + { + let mut applier = Applier::new(snapshots, filter, adapter, skip_enum_variants); + + // Use unsafe to avoid cloning the entire module, which would double the memory usage + // We read the module out, map it, then write it back + // See https://github.com/tracel-ai/burn/issues/3754 + unsafe { + // Read the module out of self (moves it, leaving self in undefined state) + let module = core::ptr::read(self as *const Self); + + // Map the module to create a new one with updated tensors + let new_module = module.map(&mut applier); + + // Write the new module back to self + core::ptr::write(self as *mut Self, new_module); + } + + applier.into_result() + } + + /// Saves tensor snapshots into a [`ModuleStore`]. + /// + /// This method allows using a `ModuleStore` implementation to handle the + /// collection and writing logic in a configurable way. + /// + /// # Arguments + /// + /// * `store` - A mutable reference to a [`ModuleStore`] that will collect and save the tensors + fn save_into

(&self, store: &mut P) -> Result<(), P::Error> + where + P: ModuleStore, + { + store.collect_from(self) + } + + /// Loads tensor data from a [`ModuleStore`]. + /// + /// This method allows using a `ModuleStore` implementation to handle the + /// loading and application logic in a configurable way. + /// + /// # Arguments + /// + /// * `store` - A mutable reference to a [`ModuleStore`] that will load and apply tensors + fn load_from

(&mut self, store: &mut P) -> Result + where + P: ModuleStore, + { + store.apply_to(self) + } +} + +/// A trait for handling module storage operations. +/// +/// `ModuleStore` provides a unified interface for saving and loading module +/// tensor data with support for various storage formats and advanced features like filtering, +/// remapping, and metadata handling. +pub trait ModuleStore { + /// The error type that can be returned during storage operations. + /// + /// This should be a format-specific error type that provides detailed + /// information about what went wrong (e.g., I/O errors, format violations, + /// unsupported tensor types). + type Error: core::fmt::Debug + core::fmt::Display; + + /// Collect tensor data from a module and store it to storage. + /// + /// This method traverses the module structure, collects all tensor data + /// according to the store's configuration (filters, remapping, etc.), + /// and writes it to the underlying storage. + /// + /// # Arguments + /// + /// * `module` - The module to collect tensor data from. The module must + /// implement `ModuleSnapshot` to provide tensor access. + /// + /// # Returns + /// + /// * `Ok(())` - If all tensors were successfully collected and stored + /// * `Err(Self::Error)` - If an error occurred during collection or writing + fn collect_from(&mut self, module: &M) -> Result<(), Self::Error>; + + /// Load stored tensor data and apply it to a module. + /// + /// This method reads tensor data from storage and applies it to the provided + /// module. The operation is flexible and can handle partial matches, missing + /// tensors, and extra tensors in the storage. + /// + /// # Arguments + /// + /// * `module` - The module to apply tensor data to. The module must + /// implement `ModuleSnapshot` to allow tensor updates. + /// + /// # Returns + /// + /// * `Ok(ApplyResult)` - Detailed information about the apply operation: + /// - `applied`: List of successfully applied tensor names + /// - `missing`: Tensors expected by the module but not found in storage + /// - `skipped`: Tensors in storage that were not applied (filtered or not needed) + /// - `errors`: Non-critical errors that occurred during apply + /// * `Err(Self::Error)` - If a critical error prevented the apply operation + fn apply_to(&mut self, module: &mut M) -> Result; + + /// Get a single tensor snapshot by name. + /// + /// This method provides direct access to individual tensors in storage without + /// requiring a module. The returned `TensorSnapshot` uses lazy loading - tensor + /// data is only materialized when `to_data()` is called. + /// + /// **Note:** Key remapping is applied, so use the remapped name if configured. + /// Filters are NOT applied - use `apply_to()` for filtered loading. + /// + /// Results are cached after the first call for efficient repeated access. + /// + /// # Arguments + /// + /// * `name` - The tensor name/path (e.g., "encoder.layer1.weight") + /// + /// # Returns + /// + /// * `Ok(Some(&TensorSnapshot))` - Reference to the tensor snapshot if found + /// * `Ok(None)` - If no tensor with that name exists + /// * `Err(Self::Error)` - If an error occurred accessing storage + /// + /// # Example + /// + /// ```rust,ignore + /// let mut store = BurnpackStore::from_file("model.bpk"); + /// if let Some(snapshot) = store.get_snapshot("encoder.weight")? { + /// println!("Shape: {:?}", snapshot.shape); + /// println!("Dtype: {:?}", snapshot.dtype); + /// let data = snapshot.to_data()?; // Lazy load + /// } + /// ``` + fn get_snapshot(&mut self, name: &str) -> Result, Self::Error>; + + /// Get all tensor snapshots from storage as an ordered map. + /// + /// This method returns all tensors in storage as lazy-loading snapshots, + /// organized in a `BTreeMap` for efficient lookup by name. The map preserves + /// alphabetical ordering of tensor names. + /// + /// **Note:** This returns ALL tensors in storage, regardless of any filter + /// settings. Filters are only applied during `apply_to()`. Key remapping + /// IS applied, so tensor names reflect any configured remapping. + /// + /// Results are cached after the first call for efficient repeated access. + /// + /// # Returns + /// + /// * `Ok(&BTreeMap)` - Reference to all tensor snapshots + /// * `Err(Self::Error)` - If an error occurred accessing storage + /// + /// # Example + /// + /// ```rust,ignore + /// let mut store = SafetensorsStore::from_file("model.safetensors"); + /// let snapshots = store.get_all_snapshots()?; + /// for (name, snapshot) in snapshots { + /// println!("{}: {:?}", name, snapshot.shape); + /// } + /// ``` + fn get_all_snapshots(&mut self) -> Result<&BTreeMap, Self::Error>; + + /// Get all tensor names/keys in storage. + /// + /// This method returns the names of all tensors in storage. + /// Useful for inspecting storage contents or checking if specific tensors exist. + /// + /// **Note:** Returns ALL tensor names regardless of filter settings. + /// Key remapping IS applied, so names reflect any configured remapping. + /// + /// # Returns + /// + /// * `Ok(Vec)` - All tensor names in storage + /// * `Err(Self::Error)` - If an error occurred accessing storage + /// + /// # Example + /// + /// ```rust,ignore + /// let mut store = PytorchStore::from_file("model.pth"); + /// let keys = store.keys()?; + /// println!("Tensors in file: {:?}", keys); + /// ``` + fn keys(&mut self) -> Result, Self::Error>; +} + +// Blanket implementation for all modules +impl ModuleSnapshot for M {} diff --git a/crates/burn-tch/Cargo.toml b/crates/burn-tch/Cargo.toml new file mode 100644 index 0000000..6257242 --- /dev/null +++ b/crates/burn-tch/Cargo.toml @@ -0,0 +1,38 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "LibTorch backend for the Burn framework using the tch bindings." +documentation = "https://docs.rs/burn-tch" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "data"] +license.workspace = true +name = "burn-tch" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-tch" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["burn-backend/std"] +doc = ["tch/doc-only"] +tracing = [ + "burn-backend/tracing", +] + +[dependencies] +burn-backend = { workspace = true } + +libc = { workspace = true } +log = { workspace = true } +tch = { workspace = true, features = ["download-libtorch"] } +torch-sys = { workspace = true } # for build script lib dir detection + +[build-dependencies] +cc = "1.2.61" + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-tch/LICENSE-APACHE b/crates/burn-tch/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-tch/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-tch/LICENSE-MIT b/crates/burn-tch/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-tch/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-tch/README.md b/crates/burn-tch/README.md new file mode 100644 index 0000000..559a18e --- /dev/null +++ b/crates/burn-tch/README.md @@ -0,0 +1,246 @@ +# Burn Torch Backend + +[Burn](https://github.com/tracel-ai/burn) Torch backend + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-tch.svg)](https://crates.io/crates/burn-tch) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-tch/blob/master/README.md) + +This crate provides a Torch backend for [Burn](https://github.com/tracel-ai/burn) utilizing the +[`tch-rs`](https://github.com/LaurentMazare/tch-rs) crate, which offers a Rust interface to the +[PyTorch](https://pytorch.org/) C++ API. + +The backend supports CPU (multithreaded), [CUDA](https://pytorch.org/docs/stable/notes/cuda.html) +(multiple GPUs), and [MPS](https://pytorch.org/docs/stable/notes/mps.html) devices (MacOS). + +## Installation + +[`tch-rs`](https://github.com/LaurentMazare/tch-rs) requires the C++ PyTorch library (LibTorch) to +be available on your system. + +By default, the CPU distribution is installed for LibTorch v2.9.0 as required by `tch-rs`. + +

+CUDA + +To install the latest compatible CUDA distribution, set the `TORCH_CUDA_VERSION` environment +variable before the `tch-rs` dependency is retrieved with `cargo`. + +```shell +export TORCH_CUDA_VERSION=cu128 +``` + +On Windows: + +```powershell +$Env:TORCH_CUDA_VERSION = "cu128" +``` + +> Note: `tch` doesn't expose the downloaded libtorch directory on Windows when using the automatic +> download feature, so the `torch_cuda.dll` cannot be detected properly during build. In this case, +> you can set the `LIBTORCH` environment variable to point to the `libtorch/` folder in `torch-sys` +> `OUT_DIR` (or move the downloaded lib to a different folder and point to it). + +For example, running the validation sample for the first time could be done with the following +commands: + +```shell +export TORCH_CUDA_VERSION=cu128 +cargo run --bin cuda --release +``` + +**Important:** make sure your driver version is compatible with the selected CUDA version. A CUDA +Toolkit installation is not required since LibTorch ships with the appropriate CUDA runtimes. Having +the latest driver version is recommended, but you can always take a look at the +[toolkit driver version table](https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html#id4) +or +[minimum required driver version](https://docs.nvidia.com/deploy/cuda-compatibility/index.html#minor-version-compatibility) +(limited feature-set, might not work with all operations). + +

+ +Once your installation is complete, you should be able to build/run your project. You can also +validate your installation by running the appropriate `cpu`, `cuda` or `mps` sample as below. + +```shell +cargo run --bin cpu --release +cargo run --bin cuda --release +cargo run --bin mps --release +``` + +_Note: no MPS distribution is available for automatic download at this time, please check out the +[manual instructions](#metal-mps)._ + +### Manual Download + +To install `tch-rs` with a different LibTorch distribution, you will have to manually download the +desired LibTorch distribution. The instructions are detailed in the sections below for each +platform. + +| Compute Platform | CPU | GPU | Linux | MacOS | Windows | Android | iOS | WASM | +| :------------------------ | :----------------------------: | :-: | :---: | :---: | :-----: | :-----: | :-: | :--: | +| [CPU](#cpu) | Yes | No | Yes | Yes | Yes | Yes | Yes | No | +| [CUDA](#cuda) | Yes [[1]](#cpu-sup) | Yes | Yes | No | Yes | No | No | No | +| [Metal (MPS)](#metal-mps) | No | Yes | No | Yes | No | No | No | No | +| Vulkan | Yes | Yes | Yes | Yes | Yes | Yes | No | No | + +[1] The LibTorch CUDA distribution also comes with CPU support. + +#### CPU + +
+🐧 Linux + +First, download the LibTorch CPU distribution. + +```shell +wget -O libtorch.zip https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.9.0%2Bcpu.zip +unzip libtorch.zip +``` + +Then, point to that installation using the `LIBTORCH` and `LD_LIBRARY_PATH` environment variables +before building `burn-tch` or a crate which depends on it. + +```shell +export LIBTORCH=/absolute/path/to/libtorch/ +export LD_LIBRARY_PATH=/absolute/path/to/libtorch/lib:$LD_LIBRARY_PATH +``` + +

+ +
+🍎 Mac + +First, download the LibTorch CPU distribution. + +```shell +wget -O libtorch.zip https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.9.0.zip +unzip libtorch.zip +``` + +Then, point to that installation using the `LIBTORCH` and `DYLD_LIBRARY_PATH` environment variables +before building `burn-tch` or a crate which depends on it. + +```shell +export LIBTORCH=/absolute/path/to/libtorch/ +export DYLD_LIBRARY_PATH=/absolute/path/to/libtorch/lib:$DYLD_LIBRARY_PATH +``` + +

+ +
+🪟 Windows + +First, download the LibTorch CPU distribution. + +```powershell +wget https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.9.0%2Bcpu.zip -OutFile libtorch.zip +Expand-Archive libtorch.zip +``` + +Then, set the `LIBTORCH` environment variable and append the library to your path as with the +PowerShell commands below before building `burn-tch` or a crate which depends on it. + +```powershell +$Env:LIBTORCH = "/absolute/path/to/libtorch/" +$Env:Path += ";/absolute/path/to/libtorch/" +``` + +

+ +#### CUDA + +LibTorch 2.9.0 currently includes binary distributions with CUDA 12.6, 12.8 or 13.0 runtimes. The +manual installation instructions are detailed below for CUDA 12.6, but can be applied to the other +CUDA versions by replacing `cu126` with the corresponding version string (e.g., `cu130`). + +
+🐧 Linux + +First, download the LibTorch CUDA 12.6 distribution. + +```shell +wget -O libtorch.zip https://download.pytorch.org/libtorch/cu126/libtorch-shared-with-deps-2.9.0%2Bcu126.zip +unzip libtorch.zip +``` + +Then, point to that installation using the `LIBTORCH` and `LD_LIBRARY_PATH` environment variables +before building `burn-tch` or a crate which depends on it. + +```shell +export LIBTORCH=/absolute/path/to/libtorch/ +export LD_LIBRARY_PATH=/absolute/path/to/libtorch/lib:$LD_LIBRARY_PATH +``` + +**Note:** make sure your CUDA installation is in your `PATH` and `LD_LIBRARY_PATH`. + +

+ +
+🪟 Windows + +First, download the LibTorch CUDA 12.6 distribution. + +```powershell +wget https://download.pytorch.org/libtorch/cu126/libtorch-win-shared-with-deps-2.9.0%2Bcu126.zip -OutFile libtorch.zip +Expand-Archive libtorch.zip +``` + +Then, set the `LIBTORCH` environment variable and append the library to your path as with the +PowerShell commands below before building `burn-tch` or a crate which depends on it. + +```powershell +$Env:LIBTORCH = "/absolute/path/to/libtorch/" +$Env:Path += ";/absolute/path/to/libtorch/" +``` + +

+ +#### Metal (MPS) + +There is no official LibTorch distribution with MPS support at this time, so the easiest alternative +is to use a PyTorch installation. This requires a Python installation. + +_Note: MPS acceleration is available on MacOS 12.3+._ + +```shell +pip install torch==2.9.0 numpy==1.26.4 setuptools +export LIBTORCH_USE_PYTORCH=1 +export DYLD_LIBRARY_PATH=/path/to/pytorch/lib:$DYLD_LIBRARY_PATH +``` + +**Note:** if `venv` is used, it should be activated during coding and building, or the compiler may +not work properly. + +## Example Usage + +For a simple example, check out any of the test programs in [`src/bin/`](./src/bin/). Each program +sets the device to use and performs a simple element-wise addition. + +For a more complete example using the `tch` backend, take a loot at the +[Burn mnist example](https://github.com/tracel-ai/burn/tree/main/examples/mnist). + +## Too many environment variables? + +Try `.cargo/config.toml` ([cargo book](https://doc.rust-lang.org/cargo/reference/config.html#env)). + +Instead of setting the environments in your shell, you can manually add them to your +`.cargo/config.toml`: + +```toml +[env] +LD_LIBRARY_PATH = "/absolute/path/to/libtorch/lib" +LIBTORCH = "/absolute/path/to/libtorch/libtorch" +``` + +Or use bash commands below: + +```bash +mkdir .cargo +cat < .cargo/config.toml +[env] +LD_LIBRARY_PATH = "/absolute/path/to/libtorch/lib:$LD_LIBRARY_PATH" +LIBTORCH = "/absolute/path/to/libtorch/libtorch" +EOF +``` + +This will automatically include the old `LD_LIBRARY_PATH` value in the new one. diff --git a/crates/burn-tch/build.rs b/crates/burn-tch/build.rs new file mode 100644 index 0000000..7fb432b --- /dev/null +++ b/crates/burn-tch/build.rs @@ -0,0 +1,243 @@ +// The LIBTORCH environment variable can be used to specify the directory +// where libtorch has been installed. +// When not specified this script downloads the cpu version for libtorch +// and extracts it in OUT_DIR. +// +// On Linux, the TORCH_CUDA_VERSION environment variable can be used, +// like 9.0, 90, or cu90 to specify the version of CUDA to use for libtorch. + +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +const PYTHON_PRINT_PYTORCH_DETAILS: &str = r" +import torch +from torch.utils import cpp_extension +print('LIBTORCH_VERSION:', torch.__version__.split('+')[0]) +print('LIBTORCH_CXX11:', torch._C._GLIBCXX_USE_CXX11_ABI) +for include_path in cpp_extension.include_paths(): + print('LIBTORCH_INCLUDE:', include_path) +for library_path in cpp_extension.library_paths(): + print('LIBTORCH_LIB:', library_path) +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Os { + Linux, + Macos, + Windows, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +struct SystemInfo { + os: Os, + cxx11_abi: String, + libtorch_include_dirs: Vec, + libtorch_lib_dir: PathBuf, +} + +fn env_var_rerun(name: &str) -> Result { + println!("cargo:rerun-if-env-changed={name}"); + env::var(name) +} + +impl SystemInfo { + fn new() -> Option { + let os = match env::var("CARGO_CFG_TARGET_OS") + .expect("Unable to get TARGET_OS") + .as_str() + { + "linux" => Os::Linux, + "windows" => Os::Windows, + "macos" => Os::Macos, + os => panic!("unsupported TARGET_OS '{os}'"), + }; + // Locate the currently active Python binary, similar to: + // https://github.com/PyO3/maturin/blob/243b8ec91d07113f97a6fe74d9b2dcb88086e0eb/src/target.rs#L547 + let python_interpreter = match os { + Os::Windows => PathBuf::from("python.exe"), + Os::Linux | Os::Macos => { + if env::var_os("VIRTUAL_ENV").is_some() { + PathBuf::from("python") + } else { + PathBuf::from("python3") + } + } + }; + let mut libtorch_include_dirs = vec![]; + let mut libtorch_lib_dir = None; + let cxx11_abi = if env_var_rerun("LIBTORCH_USE_PYTORCH").is_ok() { + let output = std::process::Command::new(&python_interpreter) + .arg("-c") + .arg(PYTHON_PRINT_PYTORCH_DETAILS) + .output() + .expect("error running python interpreter"); + let mut cxx11_abi = None; + for line in String::from_utf8_lossy(&output.stdout).lines() { + match line.strip_prefix("LIBTORCH_CXX11: ") { + Some("True") => cxx11_abi = Some("1".to_owned()), + Some("False") => cxx11_abi = Some("0".to_owned()), + _ => {} + } + if let Some(path) = line.strip_prefix("LIBTORCH_INCLUDE: ") { + libtorch_include_dirs.push(PathBuf::from(path)) + } + if let Some(path) = line.strip_prefix("LIBTORCH_LIB: ") { + libtorch_lib_dir = Some(PathBuf::from(path)) + } + } + match cxx11_abi { + Some(cxx11_abi) => cxx11_abi, + None => panic!("no cxx11 abi returned by python {output:?}"), + } + } else { + let libtorch = Self::prepare_libtorch_dir(os)?; + let includes = env_var_rerun("LIBTORCH_INCLUDE") + .map(PathBuf::from) + .unwrap_or_else(|_| libtorch.clone()); + let lib = env_var_rerun("LIBTORCH_LIB") + .map(PathBuf::from) + .unwrap_or_else(|_| libtorch.clone()); + libtorch_include_dirs.push(includes.join("include")); + libtorch_include_dirs.push(includes.join("include/torch/csrc/api/include")); + if lib.ends_with("lib") { + // DEP_TCH_LIBTORCH_LIB might already point to /lib + libtorch_lib_dir = Some(lib); + } else { + libtorch_lib_dir = Some(lib.join("lib")); + } + env_var_rerun("LIBTORCH_CXX11_ABI").unwrap_or_else(|_| "1".to_owned()) + }; + let libtorch_lib_dir = libtorch_lib_dir?; + Some(Self { + os, + cxx11_abi, + libtorch_include_dirs, + libtorch_lib_dir, + }) + } + + fn check_system_location(os: Os) -> Option { + match os { + Os::Linux => Path::new("/usr/lib/libtorch.so") + .exists() + .then(|| PathBuf::from("/usr")), + _ => None, + } + } + + fn prepare_libtorch_dir(os: Os) -> Option { + if let Ok(libtorch) = env_var_rerun("DEP_TCH_LIBTORCH_LIB") { + Some(PathBuf::from(libtorch)) + } else if let Ok(libtorch) = env_var_rerun("LIBTORCH") { + Some(PathBuf::from(libtorch)) + } else if let Some(pathbuf) = Self::check_system_location(os) { + Some(pathbuf) + } else { + check_out_dir() + } + } + + fn make(&self, use_cuda: bool, use_hip: bool) { + let cuda_dependency = if use_cuda || use_hip { + "src/cuda_hack/dummy_cuda_dependency.cpp" + } else { + "src/cuda_hack/fake_cuda_dependency.cpp" + }; + println!("cargo:rerun-if-changed={cuda_dependency}"); + + match self.os { + Os::Linux | Os::Macos => { + cc::Build::new() + .cpp(true) + .pic(true) + .warnings(false) + .includes(&self.libtorch_include_dirs) + .flag(format!("-Wl,-rpath={}", self.libtorch_lib_dir.display())) + .flag("-std=c++17") + .flag(format!("-D_GLIBCXX_USE_CXX11_ABI={}", self.cxx11_abi)) + .files(&[cuda_dependency]) + .compile("burn-tch"); + } + Os::Windows => { + cc::Build::new() + .cpp(true) + .pic(true) + .warnings(false) + .includes(&self.libtorch_include_dirs) + .flag("/std:c++17") + .files(&[cuda_dependency]) + .compile("burn-tch"); + } + }; + } + + fn make_cpu() { + let cuda_dependency = "src/cuda_hack/fake_cuda_dependency.cpp"; + println!("cargo:rerun-if-changed={cuda_dependency}"); + + let os = env::var("CARGO_CFG_TARGET_OS").expect("Unable to get TARGET_OS"); + + match os.as_str() { + "windows" => { + cc::Build::new() + .cpp(true) + .pic(true) + .warnings(false) + .flag("/std:c++17") + .files(&[cuda_dependency]) + .compile("burn-tch"); + } + _ => { + cc::Build::new() + .cpp(true) + .pic(true) + .warnings(false) + .flag("-std=c++17") + .files(&[cuda_dependency]) + .compile("tch"); + } + }; + } +} + +fn check_out_dir() -> Option { + let out_dir = env_var_rerun("OUT_DIR").ok()?; + let libtorch_dir = PathBuf::from(out_dir).join("libtorch"); + libtorch_dir.exists().then_some(libtorch_dir) +} + +fn main() { + let system_info = SystemInfo::new(); + let out_dir = env_var_rerun("OUT_DIR").expect("Failed to get out dir"); + + let mut gpu_found = false; + let found_dir = system_info.is_some(); + if let Some(system_info) = &system_info { + let si_lib = &system_info.libtorch_lib_dir; + let use_cuda = + si_lib.join("libtorch_cuda.so").exists() || si_lib.join("torch_cuda.dll").exists(); + let use_hip = + si_lib.join("libtorch_hip.so").exists() || si_lib.join("torch_hip.dll").exists(); + + system_info.make(use_cuda, use_hip); + gpu_found = use_cuda || use_hip; + } else { + SystemInfo::make_cpu(); + } + let check_file = PathBuf::from(out_dir).join("tch_gpu_check.rs"); + if gpu_found { + fs::write(check_file, "#[allow(clippy::no_effect)]\n()").unwrap(); + } else { + let message = if !found_dir { + r#"Could not find libtorch dir. + + If you are trying to use the automatically downloaded version, the path is not directly available on Windows. Instead, try setting the `LIBTORCH` environment variable for the manual download instructions. + + If the library has already been downloaded in the torch-sys OUT_DIR, you can point the variable to this path (or move the downloaded lib and point to it)."# + } else { + "No libtorch_cuda or libtorch_hip found. Download the GPU version of libtorch to use a GPU device" + }; + fs::write(check_file, format!("panic!(\"{message}\")")).unwrap(); + } +} diff --git a/crates/burn-tch/src/backend.rs b/crates/burn-tch/src/backend.rs new file mode 100644 index 0000000..43d675e --- /dev/null +++ b/crates/burn-tch/src/backend.rs @@ -0,0 +1,184 @@ +use crate::IntoKind; + +use super::TchTensor; +use burn_backend::backend::{Backend, BackendTypes, DeviceId, DeviceOps, ExecutionError}; +use burn_backend::ops::IntTensorOps; +use burn_backend::{BoolStore, DType, DeviceSettings}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// The device struct when using the `tch` backend. +/// +/// Note that you need to provide the device index when using Cuda. +/// +/// # Example +/// +/// ```no_run +/// use burn_tch::LibTorchDevice; +/// +/// let device_gpu_1 = LibTorchDevice::Cuda(0); // First GPU +/// let device_gpu_2 = LibTorchDevice::Cuda(1); // Second GPU +/// let device_cpu = LibTorchDevice::Cpu; // CPU +/// let device_mps = LibTorchDevice::Mps; // Metal Performance Shaders +/// let device_vulkan = LibTorchDevice::Vulkan; // Vulkan +/// ``` +#[derive(Default)] +pub enum LibTorchDevice { + /// CPU device. + #[default] + Cpu, + + /// Cuda device with the given index. The index is the index of the Cuda device in the list of + /// all Cuda devices found on the system. + Cuda(usize), + + /// Metal Performance Shaders device. + Mps, + + /// Vulkan device. + Vulkan, +} + +impl From for tch::Device { + #[allow( + unreachable_code, + reason = "CUDA branch always panics if the library is missing" + )] + fn from(device: LibTorchDevice) -> Self { + match device { + LibTorchDevice::Cpu => tch::Device::Cpu, + LibTorchDevice::Cuda(_num) => { + include!(concat!(env!("OUT_DIR"), "/tch_gpu_check.rs")); + tch::Device::Cuda(_num) + } + LibTorchDevice::Mps => tch::Device::Mps, + LibTorchDevice::Vulkan => tch::Device::Vulkan, + } + } +} + +impl From for LibTorchDevice { + fn from(device: tch::Device) -> Self { + match device { + tch::Device::Cpu => LibTorchDevice::Cpu, + tch::Device::Cuda(num) => LibTorchDevice::Cuda(num), + tch::Device::Mps => LibTorchDevice::Mps, + tch::Device::Vulkan => LibTorchDevice::Vulkan, + } + } +} + +impl burn_backend::Device for LibTorchDevice { + fn from_id(device_id: DeviceId) -> Self { + match device_id.type_id { + 0 => Self::Cuda(device_id.index_id as usize), + 1 => Self::Mps, + 2 => Self::Cpu, + 3 => Self::Vulkan, + _ => LibTorchDevice::Cpu, + } + } + + fn to_id(&self) -> DeviceId { + match self { + LibTorchDevice::Cuda(index) => DeviceId::new(0, *index as u16), + LibTorchDevice::Mps => DeviceId::new(1, 0), + LibTorchDevice::Cpu => DeviceId::new(2, 0), + LibTorchDevice::Vulkan => DeviceId::new(3, 0), + } + } +} + +impl DeviceOps for LibTorchDevice { + fn defaults(&self) -> DeviceSettings { + DeviceSettings::new( + DType::F32, + DType::I64, + DType::Bool(BoolStore::Native), + Default::default(), + ) + } +} + +/// Tensor backend that uses `LibTorch` with the [tch] crate for executing tensor operations. +/// +/// This backend is compatible with a wide range of hardwares ranging from CPUs to GPUs, but +/// requires `LibTorch` to be installed correctly. The CPU version can be downloaded +/// automatically and the CUDA version as well by setting the `TORCH_CUDA_VERSION` environment +/// variable. For more complex configurations, check out the manual installation for +/// [burn-tch](https://github.com/tracel-ai/burn/tree/main/crates/burn-tch). +/// +/// Refer to the [tch] crate for more information. +#[derive(Clone, Copy, Default, Debug)] +pub struct LibTorch; + +impl BackendTypes for LibTorch { + type Device = LibTorchDevice; + + type FloatTensorPrimitive = TchTensor; + type IntTensorPrimitive = TchTensor; + type BoolTensorPrimitive = TchTensor; + type QuantizedTensorPrimitive = TchTensor; + + type GraphPrimitive = burn_backend::GraphUnsupported; +} + +impl Backend for LibTorch { + fn seed(_device: &Self::Device, seed: u64) { + tch::manual_seed(seed as i64); + } + + fn ad_enabled(_device: &Self::Device) -> bool { + false + } + + fn name(device: &Self::Device) -> String { + match device { + LibTorchDevice::Cpu => "libtorch", + LibTorchDevice::Cuda(_) => "libtorch", + LibTorchDevice::Mps => "libtorch", + LibTorchDevice::Vulkan => "libtorch", + } + .to_string() + } + + fn sync(device: &Self::Device) -> Result<(), ExecutionError> { + match device { + LibTorchDevice::Cpu => (), + LibTorchDevice::Cuda(index) => { + tch::Cuda::synchronize(*index as i64); + } + _ => { + let int_dtype = device.defaults().int_dtype; + // When there is no explicit way to synchronize, we write and read one value to sync + burn_backend::read_sync(Self::int_into_data(Self::int_zeros( + [1].into(), + device, + int_dtype, + ))) + .unwrap(); + } + }; + + Ok(()) + } + + fn dtype_usage( + _device: &Self::Device, + dtype: burn_backend::DType, + ) -> burn_backend::DTypeUsageSet { + if dtype.try_into_kind().is_ok() { + burn_backend::DTypeUsage::general() + } else { + burn_backend::DTypeUsageSet::empty() + } + } + + fn device_count(type_id: u16) -> usize { + match type_id { + 0 => tch::Cuda::device_count() as usize, + _ => 1, + } + } + + fn flush(_device: &Self::Device) {} +} diff --git a/crates/burn-tch/src/bin/cpu.rs b/crates/burn-tch/src/bin/cpu.rs new file mode 100644 index 0000000..15df92e --- /dev/null +++ b/crates/burn-tch/src/bin/cpu.rs @@ -0,0 +1,14 @@ +use burn_backend::{TensorMetadata, ops::FloatTensorOps}; +use burn_tch::{LibTorch, LibTorchDevice}; + +fn main() { + type B = LibTorch; + let device = LibTorchDevice::Cpu; + + // Creation of two tensors, the first with explicit values and the second one with ones, with the same shape as the first + let tensor_1 = B::float_from_data([[2f32, 3.], [4., 5.]].into(), &device); + let tensor_2 = B::float_ones(tensor_1.shape(), &device, tensor_1.dtype().into()); + + // Print the element-wise addition of the two tensors. + println!("{}", B::float_add(tensor_1, tensor_2)); +} diff --git a/crates/burn-tch/src/bin/cuda.rs b/crates/burn-tch/src/bin/cuda.rs new file mode 100644 index 0000000..8be1c39 --- /dev/null +++ b/crates/burn-tch/src/bin/cuda.rs @@ -0,0 +1,19 @@ +use burn_backend::{TensorMetadata, ops::FloatTensorOps}; +use burn_tch::{LibTorch, LibTorchDevice}; + +fn main() { + assert!( + tch::utils::has_cuda(), + "Could not detect valid CUDA configuration" + ); + + type B = LibTorch; + let device = LibTorchDevice::Cuda(0); + + // Creation of two tensors, the first with explicit values and the second one with ones, with the same shape as the first + let tensor_1 = B::float_from_data([[2f32, 3.], [4., 5.]].into(), &device); + let tensor_2 = B::float_ones(tensor_1.shape(), &device, tensor_1.dtype().into()); + + // Print the element-wise addition of the two tensors. + println!("{}", B::float_add(tensor_1, tensor_2)); +} diff --git a/crates/burn-tch/src/bin/mps.rs b/crates/burn-tch/src/bin/mps.rs new file mode 100644 index 0000000..2ecad4b --- /dev/null +++ b/crates/burn-tch/src/bin/mps.rs @@ -0,0 +1,16 @@ +use burn_backend::{TensorMetadata, ops::FloatTensorOps}; +use burn_tch::{LibTorch, LibTorchDevice}; + +fn main() { + assert!(tch::utils::has_mps(), "Could not detect MPS"); + + type B = LibTorch; + let device = LibTorchDevice::Mps; + + // Creation of two tensors, the first with explicit values and the second one with ones, with the same shape as the first + let tensor_1 = B::float_from_data([[2f32, 3.], [4., 5.]].into(), &device); + let tensor_2 = B::float_ones(tensor_1.shape(), &device, tensor_1.dtype().into()); + + // Print the element-wise addition of the two tensors. + println!("{}", B::float_add(tensor_1, tensor_2)); +} diff --git a/crates/burn-tch/src/cuda_hack/dummy_cuda_dependency.cpp b/crates/burn-tch/src/cuda_hack/dummy_cuda_dependency.cpp new file mode 100644 index 0000000..54c98e4 --- /dev/null +++ b/crates/burn-tch/src/cuda_hack/dummy_cuda_dependency.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include +using namespace std; +extern "C" { +void dummy_cuda_dependency(); +} + +struct cublasContext; + +namespace at { +namespace cuda { +cublasContext *getCurrentCUDABlasHandle(); +int warp_size(); +} // namespace cuda +} // namespace at +char *magma_strerror(int err); +void dummy_cuda_dependency() { + try { + at::cuda::getCurrentCUDABlasHandle(); + at::cuda::warp_size(); + } catch (std::exception &e) { + if (getenv("TCH_PRINT_CUDA_INIT_ERROR") != nullptr) { + std::cerr << "error initializing cuda: " << e.what() << std::endl; + } + } +} diff --git a/crates/burn-tch/src/cuda_hack/fake_cuda_dependency.cpp b/crates/burn-tch/src/cuda_hack/fake_cuda_dependency.cpp new file mode 100644 index 0000000..57c077e --- /dev/null +++ b/crates/burn-tch/src/cuda_hack/fake_cuda_dependency.cpp @@ -0,0 +1,5 @@ +extern "C" { +void dummy_cuda_dependency(); +} + +void dummy_cuda_dependency() {} diff --git a/crates/burn-tch/src/element.rs b/crates/burn-tch/src/element.rs new file mode 100644 index 0000000..36db5ce --- /dev/null +++ b/crates/burn-tch/src/element.rs @@ -0,0 +1,51 @@ +use burn_backend::Element; +use burn_backend::{bf16, f16}; + +/// The element type for the tch backend. +pub trait TchElement: Element + tch::kind::Element { + /// Returns the associated tensor kind for [`tch::kind::Element`]. + fn kind() -> tch::Kind { + Self::KIND + } +} + +impl TchElement for f64 {} +impl TchElement for f32 {} +impl TchElement for f16 {} +impl TchElement for bf16 { + fn kind() -> tch::Kind { + let mut kind = ::KIND; + // Incorrect kind mapping in tch definitions, force bfloat16 + if matches!(Self::dtype(), burn_backend::DType::BF16) && kind == tch::Kind::Half { + kind = tch::Kind::BFloat16 + } + kind + } +} + +impl TchElement for i64 {} +impl TchElement for i32 {} +impl TchElement for i16 {} +impl TchElement for i8 {} + +impl TchElement for u8 {} + +impl TchElement for bool {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_elem_kinds() { + assert_eq!(f64::kind(), tch::Kind::Double); + assert_eq!(f32::kind(), tch::Kind::Float); + assert_eq!(f16::kind(), tch::Kind::Half); + assert_eq!(bf16::kind(), tch::Kind::BFloat16); + assert_eq!(i64::kind(), tch::Kind::Int64); + assert_eq!(i32::kind(), tch::Kind::Int); + assert_eq!(i16::kind(), tch::Kind::Int16); + assert_eq!(i8::kind(), tch::Kind::Int8); + assert_eq!(bool::kind(), tch::Kind::Bool); + } +} diff --git a/crates/burn-tch/src/lib.rs b/crates/burn-tch/src/lib.rs new file mode 100644 index 0000000..4424a6b --- /dev/null +++ b/crates/burn-tch/src/lib.rs @@ -0,0 +1,14 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![allow(clippy::single_range_in_vec_init)] + +//! Burn Tch Backend + +mod backend; +mod element; +mod ops; +mod tensor; + +pub use backend::*; +pub use element::*; +pub use tensor::*; diff --git a/crates/burn-tch/src/ops/activation.rs b/crates/burn-tch/src/ops/activation.rs new file mode 100644 index 0000000..bbd1e23 --- /dev/null +++ b/crates/burn-tch/src/ops/activation.rs @@ -0,0 +1,61 @@ +use crate::{LibTorch, TchTensor}; +use burn_backend::ops::ActivationOps; + +impl ActivationOps for LibTorch { + fn relu(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.relu_(), |tensor| tensor.relu()) + } + + fn gelu(tensor: TchTensor) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.gelu_("none"), + |tensor| tensor.gelu("none"), + ) + } + + fn gelu_backward(tensor: TchTensor, grad: TchTensor) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.gelu_backward(&grad.tensor, "none"); + + TchTensor::from_existing(tensor, storage) + } + + fn sigmoid(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.sigmoid_(), |tensor| tensor.sigmoid()) + } + + fn log_sigmoid(tensor: TchTensor) -> TchTensor { + // NOTE: we don't override log_sigmoid_backward because Torch has a special backward + // formula that uses a buffer with computed values from the forward pass + + // no in-place log_sigmoid_ + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.log_sigmoid(); + + TchTensor::from_existing(tensor, storage) + } + + fn softmax(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.softmax(dim as i64, None); + TchTensor::from_existing(tensor, storage) + } + + fn log_softmax(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.log_softmax(dim as i64, None); + TchTensor::from_existing(tensor, storage) + } + + fn softmin(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.neg().softmax(dim as i64, None); + TchTensor::from_existing(tensor, storage) + } + + fn prelu(tensor: TchTensor, alpha: TchTensor) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.prelu(&alpha.tensor); + TchTensor::from_existing(tensor, storage) + } +} diff --git a/crates/burn-tch/src/ops/base.rs b/crates/burn-tch/src/ops/base.rs new file mode 100644 index 0000000..fb1eb7b --- /dev/null +++ b/crates/burn-tch/src/ops/base.rs @@ -0,0 +1,837 @@ +use burn_backend::tensor::IndexingUpdateOp; +use burn_backend::{Shape, TensorMetadata}; +use tch::Scalar; + +use crate::{LibTorchDevice, TchShape, TchTensor}; + +pub struct TchOps { + // e: PhantomData, +} + +impl TchOps { + pub fn to_device(tensor: TchTensor, device: &LibTorchDevice) -> TchTensor { + let device = (*device).into(); + + // We have to manually check if the device is the same, since when it's the case, we need to keep + // the same storage reference and not create a new one. + if tensor.tensor.device() == device { + return tensor; + } + + TchTensor::new(tensor.tensor.to(device)) + } + + pub fn reshape(tensor: TchTensor, shape: Shape) -> TchTensor { + let shape_tch: TchShape = shape.into(); + + TchTensor::from_existing(tensor.tensor.reshape(shape_tch.dims), tensor.storage) + } + + pub fn repeat_dim(tensor: TchTensor, dim: usize, times: usize) -> TchTensor { + let mut dims = vec![1; tensor.shape().num_dims()]; + dims[dim] = times as i64; + let tensor = tch::Tensor::repeat(&tensor.tensor, dims); + TchTensor::new(tensor) + } + + pub fn slice_with_steps(tensor: TchTensor, slices: &[burn_backend::Slice]) -> TchTensor { + let storage = tensor.storage.clone(); + let mut tensor = tensor.tensor.shallow_clone(); + + for (dim, slice) in slices.iter().enumerate() { + let dim_i64 = dim as i64; + // Convert slice to range using a dummy size (we'll use tensor dimensions) + let dim_size = tensor.size()[dim]; + let range = slice.to_range(dim_size as usize); + let start = range.start as i64; + let end = range.end as i64; + let step = slice.step as i64; + + if step > 0 { + // Forward stepping - use native slice + tensor = tensor.slice(dim_i64, Some(start), Some(end), step); + } else { + // Negative stepping - we need to handle the semantics correctly + // For negative steps, we iterate backwards from end-1 + // PyTorch's negative step works differently than our semantics + // We need to reverse the selected range + + // First get the slice with positive step + tensor = tensor.slice(dim_i64, Some(start), Some(end), 1); + + // Then reverse it and apply the step + if step == -1 { + // Simple reversal + tensor = tensor.flip([dim_i64]); + } else { + // Reverse and then take every nth element + tensor = tensor.flip([dim_i64]); + let abs_step = step.abs(); + tensor = tensor.slice(dim_i64, None, None, abs_step); + } + } + } + + TchTensor::partial(tensor, storage) + } + + pub fn slice_assign( + tensor: TchTensor, + slices: &[burn_backend::Slice], + value: TchTensor, + ) -> TchTensor { + // PyTorch's narrow operation only supports contiguous slices (step=1) + // For non-unit steps, we use advanced indexing as a workaround + let all_unit_steps = slices.iter().all(|s| s.step == 1); + + if all_unit_steps { + // Fast path: use narrow and copy_ for unit steps + let tch_shape = TchShape::from(tensor.shape()); + + // Copy the input tensor if we can't mutate it + let tensor_original: TchTensor = + tensor.unary_ops(|tensor| tensor, |tensor| tensor.copy()); + let tensor_original = tensor_original.tensor; + + let mut tensor = tensor_original.view_(tch_shape.dims); + + for (i, slice) in slices.iter().enumerate().take(slices.len()) { + // Convert Slice to range for narrow operation + let dim_size = tensor.size()[i] as usize; + let range = slice.to_range(dim_size); + let start = range.start as i64; + let length = (range.end - range.start) as i64; + + tensor = tensor.narrow(i as i64, start, length); + } + + tensor.copy_(&value.tensor); + TchTensor::new(tensor_original) + } else { + // Workaround for non-unit steps: use PyTorch's index_put operation + // This generates explicit indices for the slice and uses advanced indexing + let tensor_shape = tensor.shape(); + let dims = tensor_shape.clone(); + + // Copy the tensor since we'll modify it + let result_tensor = tensor.tensor.shallow_clone(); + + // Use advanced indexing to set the values + Self::slice_assign_with_advanced_indexing(result_tensor, slices, value.tensor, &dims) + } + } + + /// Generate indices for a slice with potentially non-unit step. + /// For negative steps, generates indices in reverse order. + fn generate_slice_indices(slice: &burn_backend::Slice, dim_size: usize) -> Vec { + let step = slice.step; + let range = slice.to_range(dim_size); + + let mut indices = Vec::new(); + + if step > 0 { + let mut idx = range.start as i64; + while idx < range.end as i64 { + indices.push(idx); + idx += step as i64; + } + } else if step < 0 { + // For negative steps, iterate backwards through the range + let mut idx = (range.end - 1) as i64; + while idx >= range.start as i64 { + indices.push(idx); + idx += step as i64; // step is negative, so this decreases + } + } + + indices + } + + /// Implementation using advanced indexing for non-unit steps. + /// Uses PyTorch's index_put operation to assign values at specific indices. + fn slice_assign_with_advanced_indexing( + mut tensor: tch::Tensor, + slices: &[burn_backend::Slice], + value: tch::Tensor, + dims: &[usize], + ) -> TchTensor { + // Generate all index combinations for the sliced regions + let mut index_sets: Vec> = Vec::new(); + for (i, slice) in slices.iter().enumerate() { + let dim_size = if i < dims.len() { dims[i] } else { 1 }; + let indices = Self::generate_slice_indices(slice, dim_size); + index_sets.push(indices); + } + + // For unsliced dimensions, include all indices + for &dim_size in dims.iter().skip(slices.len()) { + let indices: Vec = (0..dim_size as i64).collect(); + index_sets.push(indices); + } + + // Convert index sets to tensors for index_put + let mut final_indices = Vec::new(); + let total_elements = index_sets.iter().map(|s| s.len()).product::(); + + // Build flattened index arrays for each dimension using cartesian product + // This creates the index tensors needed for PyTorch's index_put operation + for dim_idx in 0..index_sets.len() { + let mut dim_indices = Vec::with_capacity(total_elements); + let repeat = index_sets[dim_idx + 1..] + .iter() + .map(|s| s.len()) + .product::() + .max(1); + let tile = index_sets[..dim_idx] + .iter() + .map(|s| s.len()) + .product::() + .max(1); + + for _ in 0..tile { + for &idx in &index_sets[dim_idx] { + for _ in 0..repeat { + dim_indices.push(idx); + } + } + } + + let indices_tensor = tch::Tensor::from_slice(&dim_indices).to_device(tensor.device()); + final_indices.push(indices_tensor); + } + + // PyTorch's index_put handles assignment correctly for negative steps + // following NumPy semantics: values[i] goes to selected_indices[i] + let value_flat = value.view(-1); + + // Use index_put to assign values - convert to Option + let final_indices_opt: Vec> = + final_indices.into_iter().map(Some).collect(); + tensor = tensor.index_put(&final_indices_opt, &value_flat, false); + + TchTensor::new(tensor) + } + + pub fn gather(dim: usize, tensor: TchTensor, indices: TchTensor) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.gather(dim as i64, &indices.tensor, false); + + TchTensor::from_existing(tensor, storage) + } + + pub fn scatter( + dim: usize, + tensor: TchTensor, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor + .tensor + .scatter_add(dim as i64, &indices.tensor, &value.tensor); + + TchTensor::from_existing(tensor, storage) + } + + /// Flatten K-dimensional index tuples into 1D linear offsets, suitable for + /// use with PyTorch's scatter/gather along dim 0 of a flattened tensor. + /// + /// `indices` has shape `[num_updates, k]`. + /// Returns a 1D tensor of shape `[num_updates * slice_size]`. + fn flatten_nd_indices( + indices: &tch::Tensor, + data_shape: &[i64], + k: usize, + slice_size: i64, + ) -> tch::Tensor { + // Compute per-dimension strides for the first K dims + let mut strides = vec![0i64; k]; + if k > 0 { + strides[k - 1] = slice_size; + for i in (0..k - 1).rev() { + strides[i] = strides[i + 1] * data_shape[i + 1]; + } + } + + // base_offsets: [num_updates] = sum(indices[:, j] * strides[j]) + let strides_tensor = tch::Tensor::from_slice(&strides).to_device(indices.device()); + let base_offsets = indices + .matmul(&strides_tensor.unsqueeze(-1)) + .squeeze_dim(-1); + + // Expand base_offsets to [num_updates, slice_size] and add intra-slice offsets + let slice_offsets = tch::Tensor::arange(slice_size, (tch::Kind::Int64, indices.device())); + let flat = base_offsets.unsqueeze(-1) + slice_offsets.unsqueeze(0); + flat.reshape([flat.size().iter().product::()]) + } + + pub fn scatter_nd( + tensor: TchTensor, + indices: TchTensor, + values: TchTensor, + reduction: IndexingUpdateOp, + ) -> TchTensor { + let data_shape: Vec = tensor.tensor.size(); + let idx_shape: Vec = indices.tensor.size(); + let m = idx_shape.len(); + let k = *idx_shape.last().unwrap() as usize; + let num_updates: i64 = idx_shape[..m - 1].iter().product(); + let total_elems: i64 = data_shape.iter().product(); + let slice_size: i64 = data_shape[k..].iter().product(); + + // Flatten indices [I0,..,I_{M-2}, K] -> [num_updates, K] + let flat_indices = indices.tensor.reshape([num_updates, k as i64]); + let linear_idx = Self::flatten_nd_indices(&flat_indices, &data_shape, k, slice_size); + + // Flatten data and values to 1D + let mut flat_data = tensor.tensor.reshape([total_elems]); + let flat_values = values.tensor.reshape([num_updates * slice_size]); + + let result = match reduction { + IndexingUpdateOp::Assign => flat_data.scatter_(0, &linear_idx, &flat_values), + IndexingUpdateOp::Add => flat_data.scatter_add(0, &linear_idx, &flat_values), + IndexingUpdateOp::Mul => flat_data.scatter_reduce(0, &linear_idx, &flat_values, "prod"), + IndexingUpdateOp::Min => flat_data.scatter_reduce(0, &linear_idx, &flat_values, "amin"), + IndexingUpdateOp::Max => flat_data.scatter_reduce(0, &linear_idx, &flat_values, "amax"), + }; + + let storage = tensor.storage.clone(); + TchTensor::from_existing(result.reshape(data_shape), storage) + } + + pub fn gather_nd(tensor: TchTensor, indices: TchTensor) -> TchTensor { + let data_shape: Vec = tensor.tensor.size(); + let idx_shape: Vec = indices.tensor.size(); + let m = idx_shape.len(); + let k = *idx_shape.last().unwrap() as usize; + let num_indices: i64 = idx_shape[..m - 1].iter().product(); + let total_elems: i64 = data_shape.iter().product(); + let slice_size: i64 = data_shape[k..].iter().product(); + + let flat_indices = indices.tensor.reshape([num_indices, k as i64]); + let linear_idx = Self::flatten_nd_indices(&flat_indices, &data_shape, k, slice_size); + + let flat_data = tensor.tensor.reshape([total_elems]); + let gathered = flat_data.index_select(0, &linear_idx); + + // Output shape: idx_shape[..m-1] ++ data_shape[k..] + let mut out_shape: Vec = idx_shape[..m - 1].to_vec(); + out_shape.extend_from_slice(&data_shape[k..]); + + let storage = tensor.storage.clone(); + TchTensor::from_existing(gathered.reshape(out_shape), storage) + } + + pub fn index_select_dim(tensor: TchTensor, dim: usize, indices: TchTensor) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.index_select(dim as i64, &indices.tensor); + + TchTensor::from_existing(tensor, storage) + } + + pub fn select_assign( + tensor: TchTensor, + dim: usize, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + tensor.clone().unary_ops( + |mut tensor| tensor.index_add_(dim as i64, &indices.tensor, &value.tensor), + |tensor| tensor.index_add(dim as i64, &indices.tensor, &value.tensor), + ) + } + + pub fn cat(tensors: Vec, dim: usize) -> TchTensor { + let tensors: Vec = tensors + .into_iter() + .map(|t| t.tensor.shallow_clone()) + .collect(); + let tensor = tch::Tensor::cat(&tensors, dim as i64); + + TchTensor::new(tensor) + } + + pub fn equal(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.eq_tensor_(rhs).to_kind(tch::Kind::Bool), + |lhs, rhs| rhs.eq_tensor_(lhs).to_kind(tch::Kind::Bool), + |lhs, rhs| lhs.eq_tensor(rhs), + ) + } + + pub fn equal_elem + Clone>(lhs: TchTensor, rhs: S) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.eq_(rhs.clone().into()).to_kind(tch::Kind::Bool), + |tensor| tensor.eq(rhs.clone().into()), + ) + } + + pub fn greater(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.greater_tensor_(rhs).to_kind(tch::Kind::Bool), + |lhs, rhs| rhs.less_tensor_(lhs).to_kind(tch::Kind::Bool), + |lhs, rhs| lhs.greater_tensor(rhs), + ) + } + + pub fn greater_elem + Clone>(lhs: TchTensor, rhs: S) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.greater_(rhs.clone().into()).to_kind(tch::Kind::Bool), + |tensor| tensor.greater(rhs.clone().into()), + ) + } + + pub fn greater_equal(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.greater_equal_tensor_(rhs).to_kind(tch::Kind::Bool), + |lhs, rhs| rhs.less_equal_tensor_(lhs).to_kind(tch::Kind::Bool), + |lhs, rhs| lhs.greater_equal_tensor(rhs), + ) + } + + pub fn greater_equal_elem + Clone>(lhs: TchTensor, rhs: S) -> TchTensor { + lhs.unary_ops( + |mut tensor| { + tensor + .greater_equal_(rhs.clone().into()) + .to_kind(tch::Kind::Bool) + }, + |tensor| tensor.greater_equal(rhs.clone().into()), + ) + } + + pub fn lower(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.less_tensor_(rhs).to_kind(tch::Kind::Bool), + |lhs, rhs| rhs.greater_tensor_(lhs).to_kind(tch::Kind::Bool), + |lhs, rhs| lhs.less_tensor(rhs), + ) + } + + pub fn lower_elem + Clone>(lhs: TchTensor, rhs: S) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.less_(rhs.clone().into()).to_kind(tch::Kind::Bool), + |tensor| tensor.less(rhs.clone().into()), + ) + } + + pub fn lower_equal(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.less_equal_tensor_(rhs).to_kind(tch::Kind::Bool), + |lhs, rhs| rhs.greater_equal_tensor_(lhs).to_kind(tch::Kind::Bool), + |lhs, rhs| lhs.less_equal_tensor(rhs), + ) + } + + pub fn lower_equal_elem + Clone>(lhs: TchTensor, rhs: S) -> TchTensor { + lhs.unary_ops( + |mut tensor| { + tensor + .less_equal_(rhs.clone().into()) + .to_kind(tch::Kind::Bool) + }, + |tensor| tensor.less_equal(rhs.clone().into()), + ) + } + + pub fn add(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_add_(rhs).unwrap(), + |lhs, rhs| rhs.f_add_(lhs).unwrap(), + |lhs, rhs| lhs.f_add(rhs).unwrap(), + ) + } + + pub fn sub(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_sub_(rhs).unwrap(), + |lhs, rhs| lhs.f_sub(rhs).unwrap(), + |lhs, rhs| lhs.f_sub(rhs).unwrap(), + ) + } + + pub fn mul(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_mul_(rhs).unwrap(), + |lhs, rhs| rhs.f_mul_(lhs).unwrap(), + |lhs, rhs| lhs.f_mul(rhs).unwrap(), + ) + } + + pub fn div(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_div_(rhs).unwrap(), + |lhs, rhs| lhs.f_div(rhs).unwrap(), + |lhs, rhs| lhs.f_div(rhs).unwrap(), + ) + } + + pub fn remainder(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_remainder_tensor_(rhs).unwrap(), + |lhs, rhs| lhs.f_remainder_tensor(rhs).unwrap(), + |lhs, rhs| lhs.f_remainder_tensor(rhs).unwrap(), + ) + } + + pub fn mean(tensor: TchTensor) -> TchTensor { + // view as 1d tensor + let tensor = tensor.tensor.mean(tensor.tensor.kind()).view(1); + TchTensor::new(tensor) + } + + pub fn mean_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchTensor::from_existing( + tensor + .tensor + .mean_dim(Some([dim as i64].as_slice()), true, tensor.tensor.kind()), + tensor.storage, + ) + } + + pub fn sum(tensor: TchTensor) -> TchTensor { + // view as 1d tensor + let tensor = tensor.tensor.sum(tensor.tensor.kind()).view(1); + TchTensor::new(tensor) + } + + pub fn sum_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchTensor::from_existing( + tensor.tensor.sum_dim_intlist( + Some([dim as i64].as_slice()), + true, + tensor.tensor.kind(), + ), + tensor.storage, + ) + } + + pub fn prod(tensor: TchTensor) -> TchTensor { + // view as 1d tensor + let tensor = tensor.tensor.prod(tensor.tensor.kind()).view(1); + TchTensor::new(tensor) + } + + pub fn prod_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchTensor::from_existing( + tensor + .tensor + .prod_dim_int(dim as i64, true, tensor.tensor.kind()), + tensor.storage, + ) + } + + pub fn topk(tensor: TchTensor, dim: usize, k: usize) -> TchTensor { + let (value, _indices) = tensor.tensor.topk(k as i64, dim as i64, true, true); + TchTensor::from_existing(value, tensor.storage) + } + + pub fn argtopk(tensor: TchTensor, dim: usize, k: usize) -> TchTensor { + let (_value, indices) = tensor.tensor.topk(k as i64, dim as i64, true, true); + TchTensor::from_existing(indices, tensor.storage) + } + + pub fn cumsum(tensor: TchTensor, dim: usize) -> TchTensor { + TchTensor::from_existing( + tensor.tensor.cumsum(dim as i64, tensor.tensor.kind()), + tensor.storage, + ) + } + + pub fn cumprod(tensor: TchTensor, dim: usize) -> TchTensor { + TchTensor::from_existing( + tensor.tensor.cumprod(dim as i64, tensor.tensor.kind()), + tensor.storage, + ) + } + + pub fn cummin(tensor: TchTensor, dim: usize) -> TchTensor { + let (values, _indices) = tensor.tensor.cummin(dim as i64); + TchTensor::from_existing(values, tensor.storage) + } + + pub fn cummax(tensor: TchTensor, dim: usize) -> TchTensor { + // cummax returns (values, indices) tuple in PyTorch, we only need values + let (values, _indices) = tensor.tensor.cummax(dim as i64); + TchTensor::from_existing(values, tensor.storage) + } + + pub fn argmax(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.argmax(dim as i64, true); + + TchTensor::from_existing(tensor, storage) + } + + pub fn argmin(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let tensor = tensor.tensor.argmin(dim as i64, true); + + TchTensor::from_existing(tensor, storage) + } + + pub fn max_dim(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let (tensor, _indices) = tensor.tensor.max_dim(dim as i64, true); + + TchTensor::from_existing(tensor, storage) + } + + pub fn max_dim_with_indices(tensor: TchTensor, dim: usize) -> (TchTensor, TchTensor) { + let storage = tensor.storage.clone(); + let (tensor, indices) = tensor.tensor.max_dim(dim as i64, true); + + let tensor = TchTensor::from_existing(tensor, storage); + let indices = TchTensor::new(indices); + + (tensor, indices) + } + + pub fn min_dim(tensor: TchTensor, dim: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let (tensor, _indices) = tensor.tensor.min_dim(dim as i64, true); + + TchTensor::from_existing(tensor, storage) + } + + pub fn min_dim_with_indices(tensor: TchTensor, dim: usize) -> (TchTensor, TchTensor) { + let storage = tensor.storage.clone(); + let (tensor, indices) = tensor.tensor.min_dim(dim as i64, true); + + let tensor = TchTensor::from_existing(tensor, storage); + let indices = TchTensor::new(indices); + + (tensor, indices) + } + + pub fn clamp_min + Clone + Copy>(tensor: TchTensor, min: S) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.clamp_min_(min), + |tensor| tensor.clamp_min(min), + ) + } + + pub fn clamp_max + Clone + Copy>(tensor: TchTensor, max: S) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.clamp_max_(max), + |tensor| tensor.clamp_max(max), + ) + } + + pub fn clamp + Clone + Copy>( + tensor: TchTensor, + min: S, + max: S, + ) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.clamp_(min, max), + |tensor| tensor.clamp(min, max), + ) + } + + pub fn swap_dims(tensor: TchTensor, dim1: usize, dim2: usize) -> TchTensor { + let tensor = tensor.tensor.transpose(dim1 as i64, dim2 as i64); + TchTensor::new(tensor) + } + + pub fn permute(tensor: TchTensor, axes: &[usize]) -> TchTensor { + let tensor = tensor + .tensor + .permute(axes.iter().map(|x| *x as i64).collect::>()); + TchTensor::new(tensor) + } + + pub fn flip(tensor: TchTensor, axes: &[usize]) -> TchTensor { + let dims = axes.iter().map(|x| *x as i64).collect::>(); + let tensor = tensor.tensor.flip(dims); + TchTensor::new(tensor) + } + + pub fn pow(tensor: TchTensor, exponent: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + tensor, + exponent, + |lhs, rhs| lhs.f_pow_tensor_(rhs).unwrap(), + |lhs, rhs| lhs.f_pow(rhs).unwrap(), + |lhs, rhs| lhs.f_pow(rhs).unwrap(), + ) + } + + pub fn sign(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.sign_(), |tensor| tensor.sign()) + } + + pub fn expand(tensor: TchTensor, shape: Shape) -> TchTensor { + let storage = tensor.storage.clone(); + let broadcasted_tensor = tensor.tensor.broadcast_to(TchShape::from(shape).dims); + TchTensor::from_existing(broadcasted_tensor, storage) + } + + pub fn unfold(tensor: TchTensor, dim: usize, size: usize, step: usize) -> TchTensor { + let storage = tensor.storage.clone(); + let uf_tensor = tensor.tensor.unfold(dim as i64, size as i64, step as i64); + + TchTensor::from_existing(uf_tensor, storage) + } + + pub fn sort(tensor: TchTensor, dim: usize, descending: bool) -> TchTensor { + TchTensor::new(tensor.tensor.sort(dim as i64, descending).0) + } + + pub fn sort_with_indices( + tensor: TchTensor, + dim: usize, + descending: bool, + ) -> (TchTensor, TchTensor) { + let sorted = tensor.tensor.sort(dim as i64, descending); + (TchTensor::new(sorted.0), TchTensor::new(sorted.1)) + } + + pub fn argsort(tensor: TchTensor, dim: usize, descending: bool) -> TchTensor { + TchTensor::new(tensor.tensor.argsort(dim as i64, descending)) + } + + pub fn bitwise_and(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_bitwise_and_tensor_(rhs).unwrap(), + |lhs, rhs| rhs.f_bitwise_and_tensor_(lhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_and_tensor(rhs).unwrap(), + ) + } + + pub fn bitwise_and_scalar + Clone>(tensor: TchTensor, scalar: S) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.f_bitwise_and_(scalar.clone().into()).unwrap(), + |tensor| tensor.f_bitwise_and(scalar.clone().into()).unwrap(), + ) + } + + pub fn bitwise_or(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_bitwise_or_tensor_(rhs).unwrap(), + |lhs, rhs| rhs.f_bitwise_or_tensor_(lhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_or_tensor(rhs).unwrap(), + ) + } + + pub fn bitwise_or_scalar + Clone>(tensor: TchTensor, scalar: S) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.f_bitwise_or_(scalar.clone().into()).unwrap(), + |tensor| tensor.f_bitwise_or(scalar.clone().into()).unwrap(), + ) + } + + pub fn bitwise_xor(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_bitwise_xor_tensor_(rhs).unwrap(), + |lhs, rhs| rhs.f_bitwise_xor_tensor_(lhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_xor_tensor(rhs).unwrap(), + ) + } + + pub fn bitwise_xor_scalar + Clone>(tensor: TchTensor, scalar: S) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.f_bitwise_xor_(scalar.clone().into()).unwrap(), + |tensor| tensor.f_bitwise_xor(scalar.clone().into()).unwrap(), + ) + } + + pub fn bitwise_not(tensor: TchTensor) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.f_bitwise_not_().unwrap(), + |tensor| tensor.f_bitwise_not().unwrap(), + ) + } + + pub fn bitwise_left_shift(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_bitwise_left_shift_(rhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_left_shift(rhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_left_shift(rhs).unwrap(), + ) + } + + pub fn bitwise_left_shift_scalar + Clone>( + tensor: TchTensor, + scalar: S, + ) -> TchTensor { + tensor.unary_ops( + |mut tensor| { + tensor + .f_bitwise_left_shift_tensor_scalar_(scalar.clone().into()) + .unwrap() + }, + |tensor| { + tensor + .f_bitwise_left_shift_tensor_scalar(scalar.clone().into()) + .unwrap() + }, + ) + } + + pub fn bitwise_right_shift(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_bitwise_right_shift_(rhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_right_shift(rhs).unwrap(), + |lhs, rhs| lhs.f_bitwise_right_shift(rhs).unwrap(), + ) + } + + pub fn bitwise_right_shift_scalar + Clone>( + tensor: TchTensor, + scalar: S, + ) -> TchTensor { + tensor.unary_ops( + |mut tensor| { + tensor + .f_bitwise_right_shift_tensor_scalar_(scalar.clone().into()) + .unwrap() + }, + |tensor| { + tensor + .f_bitwise_right_shift_tensor_scalar(scalar.clone().into()) + .unwrap() + }, + ) + } + + pub fn atan2(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.f_atan2_(rhs).unwrap(), + |lhs, rhs| lhs.f_atan2(rhs).unwrap(), + |lhs, rhs| lhs.f_atan2(rhs).unwrap(), + ) + } +} diff --git a/crates/burn-tch/src/ops/bool_tensor.rs b/crates/burn-tch/src/ops/bool_tensor.rs new file mode 100644 index 0000000..5ff05d8 --- /dev/null +++ b/crates/burn-tch/src/ops/bool_tensor.rs @@ -0,0 +1,221 @@ +use super::TchOps; +use crate::IntoKind; +use crate::{LibTorch, LibTorchDevice, TchShape, TchTensor}; +use burn_backend::BoolStore; +use burn_backend::ExecutionError; +use burn_backend::IntDType; +use burn_backend::Scalar; +use burn_backend::tensor::BoolTensor; +use burn_backend::tensor::IntTensor; +use burn_backend::{BoolDType, FloatDType}; +use burn_backend::{Shape, TensorData, TensorMetadata, ops::BoolTensorOps}; + +impl BoolTensorOps for LibTorch { + fn bool_from_data(data: TensorData, device: &LibTorchDevice) -> TchTensor { + match data.dtype { + burn_backend::DType::Bool(BoolStore::Native) => { + TchTensor::from_data::(data, (*device).into()) + } + _ => unimplemented!("Unsupported dtype for `bool_from_data`"), + } + } + + fn bool_repeat_dim(tensor: TchTensor, dim: usize, times: usize) -> TchTensor { + TchOps::repeat_dim(tensor, dim, times) + } + + async fn bool_into_data(tensor: TchTensor) -> Result { + let shape = tensor.shape(); + let tensor = Self::bool_reshape(tensor.clone(), Shape::new([shape.num_elements()])); + let values: Result, tch::TchError> = tensor.tensor.shallow_clone().try_into(); + Ok(TensorData::new(values.unwrap(), shape)) + } + + fn bool_to_device(tensor: TchTensor, device: &LibTorchDevice) -> TchTensor { + TchOps::to_device(tensor, device) + } + + fn bool_reshape(tensor: TchTensor, shape: Shape) -> TchTensor { + TchOps::reshape(tensor, shape) + } + + fn bool_empty(shape: Shape, device: &LibTorchDevice, _dtype: BoolDType) -> TchTensor { + let tensor = tch::Tensor::empty( + TchShape::from(shape).dims, + (tch::Kind::Bool, (*device).into()), + ); + + TchTensor::new(tensor) + } + + fn bool_zeros(shape: Shape, device: &LibTorchDevice, _dtype: BoolDType) -> TchTensor { + let tensor = tch::Tensor::zeros( + TchShape::from(shape).dims, + (tch::Kind::Bool, (*device).into()), + ); + + TchTensor::new(tensor) + } + + fn bool_ones(shape: Shape, device: &LibTorchDevice, _dtype: BoolDType) -> TchTensor { + let tensor = tch::Tensor::ones( + TchShape::from(shape).dims, + (tch::Kind::Bool, (*device).into()), + ); + + TchTensor::new(tensor) + } + + fn bool_slice(tensor: TchTensor, slices: &[burn_backend::Slice]) -> TchTensor { + TchOps::slice_with_steps(tensor, slices) + } + + fn bool_slice_assign( + tensor: TchTensor, + slices: &[burn_backend::Slice], + value: TchTensor, + ) -> TchTensor { + TchOps::slice_assign(tensor, slices, value) + } + + fn bool_cat(tensors: Vec, dim: usize) -> TchTensor { + TchOps::cat(tensors, dim) + } + + fn bool_equal(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::equal(lhs, rhs) + } + + fn bool_not(tensor: TchTensor) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.eq_(0).to_kind(tch::Kind::Bool), + |tensor| tensor.eq(0), + ) + } + + fn bool_and(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.logical_and_(rhs), + |lhs, rhs| rhs.logical_and_(lhs), + |lhs, rhs| lhs.logical_and(rhs), + ) + } + + fn bool_or(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + lhs, + rhs, + |lhs, rhs| lhs.logical_or_(rhs), + |lhs, rhs| rhs.logical_or_(lhs), + |lhs, rhs| lhs.logical_or(rhs), + ) + } + + fn bool_into_int(tensor: TchTensor, out_dtype: IntDType) -> TchTensor { + let tensor = tensor.tensor.to_kind(out_dtype.into_kind()); + TchTensor::new(tensor) + } + + fn bool_into_float(tensor: TchTensor, out_dtype: FloatDType) -> TchTensor { + let tensor = tensor.tensor.to_kind(out_dtype.into_kind()); + TchTensor::new(tensor) + } + + fn bool_swap_dims(tensor: TchTensor, dim1: usize, dim2: usize) -> TchTensor { + TchOps::swap_dims(tensor, dim1, dim2) + } + + fn bool_permute(tensor: TchTensor, axes: &[usize]) -> TchTensor { + TchOps::permute(tensor, axes) + } + + fn bool_flip(tensor: TchTensor, axes: &[usize]) -> TchTensor { + TchOps::flip(tensor, axes) + } + + async fn bool_argwhere(tensor: TchTensor, out_dtype: IntDType) -> TchTensor { + TchTensor::new(tensor.tensor.argwhere().to_kind(out_dtype.into_kind())) + } + + fn bool_select(tensor: TchTensor, dim: usize, indices: TchTensor) -> TchTensor { + TchOps::index_select_dim(tensor, dim, indices) + } + + fn bool_select_or( + tensor: TchTensor, + dim: usize, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + TchOps::select_assign(tensor, dim, indices, value) + } + + fn bool_expand(tensor: TchTensor, shape: Shape) -> TchTensor { + TchOps::expand(tensor, shape) + } + + fn bool_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + TchOps::unfold(tensor, dim, size, step) + } + + fn bool_mask_where( + tensor: BoolTensor, + mask: BoolTensor, + value: BoolTensor, + ) -> BoolTensor { + TchTensor::binary_ops_tensor( + tensor, + value, + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + ) + } + + fn bool_mask_fill( + tensor: BoolTensor, + mask: BoolTensor, + value: Scalar, + ) -> BoolTensor { + tensor.unary_ops( + |mut tensor| { + tensor + .f_masked_fill_(&mask.tensor, value.elem::()) + .unwrap() + }, + |tensor| { + tensor + .f_masked_fill(&mask.tensor, value.elem::()) + .unwrap() + }, + ) + } + + fn bool_gather( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + ) -> BoolTensor { + TchOps::gather(dim, tensor, indices) + } + + fn bool_scatter_or( + dim: usize, + tensor: BoolTensor, + indices: IntTensor, + value: BoolTensor, + ) -> BoolTensor { + TchOps::scatter(dim, tensor, indices, value) + } + + fn bool_equal_elem(lhs: BoolTensor, rhs: Scalar) -> BoolTensor { + TchOps::equal_elem(lhs, rhs.elem::()) + } +} diff --git a/crates/burn-tch/src/ops/int_tensor.rs b/crates/burn-tch/src/ops/int_tensor.rs new file mode 100644 index 0000000..9922f7b --- /dev/null +++ b/crates/burn-tch/src/ops/int_tensor.rs @@ -0,0 +1,540 @@ +use std::ops::Range; + +use burn_backend::{ + BoolDType, Distribution, ExecutionError, FloatDType, IntDType, Scalar, Shape, TensorData, + TensorMetadata, + ops::{FloatTensorOps, IntTensorOps}, + tensor::IntTensor, +}; + +use crate::{IntoKind, LibTorch, LibTorchDevice, TchShape, TchTensor}; + +use super::TchOps; + +impl IntTensorOps for LibTorch { + fn int_from_data(data: TensorData, device: &LibTorchDevice) -> TchTensor { + match data.dtype { + burn_backend::DType::I64 => TchTensor::from_data::(data, (*device).into()), + burn_backend::DType::I32 => TchTensor::from_data::(data, (*device).into()), + burn_backend::DType::I16 => TchTensor::from_data::(data, (*device).into()), + burn_backend::DType::I8 => TchTensor::from_data::(data, (*device).into()), + burn_backend::DType::U8 => TchTensor::from_data::(data, (*device).into()), + _ => unimplemented!("Unsupported dtype for `int_from_data`: {:?}", data.dtype), + } + } + + fn int_repeat_dim(tensor: TchTensor, dim: usize, times: usize) -> TchTensor { + TchOps::repeat_dim(tensor, dim, times) + } + + async fn int_into_data(tensor: TchTensor) -> Result { + let shape = tensor.shape(); + let tensor = Self::int_reshape(tensor.clone(), Shape::new([shape.num_elements()])); + let values: Result, tch::TchError> = tensor.tensor.shallow_clone().try_into(); + Ok(TensorData::new(values.unwrap(), shape)) + } + + fn int_to_device(tensor: TchTensor, device: &LibTorchDevice) -> TchTensor { + TchOps::to_device(tensor, device) + } + + fn int_reshape(tensor: TchTensor, shape: Shape) -> TchTensor { + TchOps::reshape(tensor, shape) + } + + fn int_empty(shape: Shape, device: &LibTorchDevice, dtype: IntDType) -> TchTensor { + let tensor = tch::Tensor::empty( + TchShape::from(shape).dims, + (dtype.into_kind(), (*device).into()), + ); + + TchTensor::new(tensor) + } + + fn int_slice(tensor: TchTensor, slices: &[burn_backend::Slice]) -> TchTensor { + TchOps::slice_with_steps(tensor, slices) + } + + fn int_slice_assign( + tensor: TchTensor, + slices: &[burn_backend::Slice], + value: TchTensor, + ) -> TchTensor { + TchOps::slice_assign(tensor, slices, value) + } + + fn int_cat(tensors: Vec, dim: usize) -> TchTensor { + TchOps::cat(tensors, dim) + } + + fn int_matmul(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + let int_dtype = lhs.dtype(); + let lhs = Self::int_into_float(lhs, FloatDType::F32); + let rhs = Self::int_into_float(rhs, FloatDType::F32); + let out = lhs.tensor.f_matmul(&rhs.tensor).unwrap(); + Self::float_into_int(TchTensor::new(out), int_dtype.into()) + } + + fn int_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::equal(lhs, rhs) + } + + fn int_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::equal_elem(lhs, rhs.elem::()) + } + + fn int_greater(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater(lhs, rhs) + } + + fn int_greater_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_elem(lhs, rhs.elem::()) + } + + fn int_greater_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_equal(lhs, rhs) + } + + fn int_greater_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_equal_elem(lhs, rhs.elem::()) + } + + fn int_lower(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower(lhs, rhs) + } + + fn int_lower_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_elem(lhs, rhs.elem::()) + } + + fn int_lower_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_equal(lhs, rhs) + } + + fn int_lower_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_equal_elem(lhs, rhs.elem::()) + } + + fn int_add(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::add(lhs, rhs) + } + + fn int_add_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.f_add_scalar_(rhs.elem::()).unwrap(), + |tensor| tensor.f_add_scalar(rhs.elem::()).unwrap(), + ) + } + + fn int_sub(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::sub(lhs, rhs) + } + + fn int_sub_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.f_sub_scalar_(rhs.elem::()).unwrap(), + |tensor| tensor.f_sub_scalar(rhs.elem::()).unwrap(), + ) + } + + fn int_mul(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::mul(lhs, rhs) + } + + fn int_mul_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + lhs.unary_ops( + |mut tensor| tensor.f_mul_scalar_(rhs.elem::()).unwrap(), + |tensor| tensor.f_mul_scalar(rhs.elem::()).unwrap(), + ) + } + + fn int_div(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + let dtype = lhs.tensor.kind(); + let copy = false; + let non_blocking = true; + let lhs: TchTensor = + TchTensor::new(lhs.tensor.to_dtype(tch::Kind::Float, non_blocking, copy)); + let rhs: TchTensor = + TchTensor::new(rhs.tensor.to_dtype(tch::Kind::Float, non_blocking, copy)); + + let out = TchOps::div(lhs, rhs); + + TchTensor::new(out.tensor.to_dtype(dtype, non_blocking, copy)) + } + + fn int_div_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let dtype = lhs.tensor.kind(); + let copy = false; + let non_blocking = true; + let lhs: TchTensor = + TchTensor::new(lhs.tensor.to_dtype(tch::Kind::Float, non_blocking, copy)); + + let out: TchTensor = lhs.unary_ops( + |mut tensor| tensor.f_div_scalar_(rhs.elem::()).unwrap(), + |tensor| tensor.f_div_scalar(rhs.elem::()).unwrap(), + ); + + TchTensor::new(out.tensor.to_dtype(dtype, non_blocking, copy)) + } + + fn int_remainder(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + let dtype = lhs.tensor.kind(); + let copy = false; + let non_blocking = true; + let lhs: TchTensor = + TchTensor::new(lhs.tensor.to_dtype(tch::Kind::Float, non_blocking, copy)); + let rhs: TchTensor = + TchTensor::new(rhs.tensor.to_dtype(tch::Kind::Float, non_blocking, copy)); + + let out = TchOps::remainder(lhs, rhs); + + TchTensor::new(out.tensor.to_dtype(dtype, non_blocking, copy)) + } + + fn int_remainder_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + lhs.unary_ops( + |tensor| tensor.f_remainder(rhs.elem::()).unwrap(), + |tensor| tensor.f_remainder(rhs.elem::()).unwrap(), + ) + } + + fn int_zeros(shape: Shape, device: &LibTorchDevice, dtype: IntDType) -> TchTensor { + let shape = TchShape::from(shape); + let device: tch::Device = (*device).into(); + + TchTensor::new(tch::Tensor::zeros(shape.dims, (dtype.into_kind(), device))) + } + + fn int_ones(shape: Shape, device: &LibTorchDevice, dtype: IntDType) -> TchTensor { + let shape = TchShape::from(shape); + let device: tch::Device = (*device).into(); + + TchTensor::new(tch::Tensor::ones(shape.dims, (dtype.into_kind(), device))) + } + + fn int_full( + shape: Shape, + fill_value: Scalar, + device: &LibTorchDevice, + dtype: IntDType, + ) -> TchTensor { + let shape = TchShape::from(shape); + let device: tch::Device = (*device).into(); + + TchTensor::new(tch::Tensor::full( + shape.dims, + fill_value.elem::(), + (dtype.into_kind(), device), + )) + } + + fn int_sum(tensor: TchTensor) -> TchTensor { + TchOps::sum(tensor) + } + + fn int_sum_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::sum_dim(tensor, dim) + } + + fn int_prod(tensor: TchTensor) -> TchTensor { + TchOps::prod(tensor) + } + + fn int_prod_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::prod_dim(tensor, dim) + } + + fn int_mean(tensor: TchTensor) -> TchTensor { + let dtype = tensor.tensor.kind(); + let tensor: TchTensor = + TchTensor::new(tensor.tensor.to_dtype(tch::Kind::Float, true, false)); + let output: TchTensor = TchTensor::new(TchOps::mean(tensor).tensor); + + TchTensor::new(output.tensor.to_dtype(dtype, true, false)) + } + + fn int_mean_dim(tensor: TchTensor, dim: usize) -> TchTensor { + let dtype = tensor.tensor.kind(); + let tensor: TchTensor = + TchTensor::new(tensor.tensor.to_dtype(tch::Kind::Float, true, false)); + + let output: TchTensor = TchTensor::new(TchOps::mean_dim(tensor, dim).tensor); + + TchTensor::new(output.tensor.to_dtype(dtype, true, false)) + } + + fn int_cumsum(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cumsum(tensor, dim) + } + + fn int_cumprod(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cumprod(tensor, dim) + } + + fn int_cummin(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cummin(tensor, dim) + } + + fn int_cummax(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cummax(tensor, dim) + } + + fn int_gather(dim: usize, tensor: TchTensor, indices: TchTensor) -> TchTensor { + TchOps::gather(dim, tensor, indices) + } + + fn int_scatter_add( + dim: usize, + tensor: TchTensor, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + TchOps::scatter(dim, tensor, indices, value) + } + + fn int_scatter_nd( + data: TchTensor, + indices: TchTensor, + values: TchTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> TchTensor { + TchOps::scatter_nd(data, indices, values, reduction) + } + + fn int_gather_nd(data: TchTensor, indices: TchTensor) -> TchTensor { + TchOps::gather_nd(data, indices) + } + + fn int_select(tensor: TchTensor, dim: usize, indices: TchTensor) -> TchTensor { + TchOps::index_select_dim(tensor, dim, indices) + } + + fn int_select_add( + tensor: TchTensor, + dim: usize, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + TchOps::select_assign(tensor, dim, indices, value) + } + + fn int_mask_where(tensor: TchTensor, mask: TchTensor, source: TchTensor) -> TchTensor { + TchTensor::binary_ops_tensor( + tensor, + source, + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + |tensor, source| source.f_where_self(&mask.tensor, tensor).unwrap(), + ) + } + + fn int_mask_fill(tensor: TchTensor, mask: TchTensor, value: Scalar) -> TchTensor { + let value = value.elem::(); + tensor.unary_ops( + |mut tensor| tensor.f_masked_fill_(&mask.tensor, value).unwrap(), + |tensor| tensor.f_masked_fill(&mask.tensor, value).unwrap(), + ) + } + + fn int_argmax(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::argmax(tensor, dim) + } + + fn int_argtopk(_tensor: TchTensor, _dim: usize, _k: usize) -> TchTensor { + panic!("argtopk not implemented for torch") + } + + fn int_topk(tensor: TchTensor, dim: usize, k: usize) -> TchTensor { + TchOps::topk(tensor, dim, k) + } + + fn int_argmin(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::argmin(tensor, dim) + } + + fn int_max_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::max_dim(tensor, dim) + } + + fn int_max_dim_with_indices(tensor: TchTensor, dim: usize) -> (TchTensor, TchTensor) { + TchOps::max_dim_with_indices(tensor, dim) + } + + fn int_min_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::min_dim(tensor, dim) + } + + fn int_min_dim_with_indices(tensor: TchTensor, dim: usize) -> (TchTensor, TchTensor) { + TchOps::min_dim_with_indices(tensor, dim) + } + + fn int_clamp_min(tensor: TchTensor, min: Scalar) -> TchTensor { + TchOps::clamp_min(tensor, min.elem::()) + } + + fn int_clamp_max(tensor: TchTensor, max: Scalar) -> TchTensor { + TchOps::clamp_max(tensor, max.elem::()) + } + + fn int_clamp(tensor: TchTensor, min: Scalar, max: Scalar) -> TchTensor { + TchOps::clamp(tensor, min.elem::(), max.elem::()) + } + + fn int_abs(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.abs_(), |tensor| tensor.abs()) + } + + fn int_into_float(tensor: TchTensor, out_dtype: FloatDType) -> TchTensor { + let tensor = tensor.tensor.to_kind(out_dtype.into_kind()); + TchTensor::new(tensor) + } + + fn int_swap_dims(tensor: IntTensor, dim1: usize, dim2: usize) -> IntTensor { + TchOps::swap_dims(tensor, dim1, dim2) + } + + fn int_random( + shape: Shape, + distribution: Distribution, + device: &LibTorchDevice, + dtype: IntDType, + ) -> TchTensor { + match distribution { + Distribution::Default => TchTensor::new(tch::Tensor::randint_low( + 0, + 255, + shape.iter().map(|i| *i as i64).collect::>(), + (dtype.into_kind(), (*device).into()), + )), + Distribution::Bernoulli(prob) => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor + .mut_ops(|tensor| tensor.f_bernoulli_float_(prob).unwrap()) + .unwrap() + } + Distribution::Uniform(from, to) => TchTensor::new(tch::Tensor::randint_low( + from as i64, + to as i64, + shape.iter().map(|i| *i as i64).collect::>(), + (dtype.into_kind(), (*device).into()), + )), + Distribution::Normal(mean, std) => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor.mut_ops(|tensor| tensor.normal_(mean, std)).unwrap() + } + } + } + + fn int_arange(range: Range, device: &LibTorchDevice, dtype: IntDType) -> TchTensor { + let device: tch::Device = (*device).into(); + let mut tensor = tch::Tensor::arange(range.end - range.start, (dtype.into_kind(), device)); + + if range.start != 0 { + tensor = tensor.f_add_scalar_(range.start).unwrap(); + } + + TchTensor::new(tensor) + } + + fn int_permute(tensor: IntTensor, axes: &[usize]) -> IntTensor { + TchOps::permute(tensor, axes) + } + + fn int_flip(tensor: IntTensor, axes: &[usize]) -> IntTensor { + TchOps::flip(tensor, axes) + } + + fn int_sign(tensor: IntTensor) -> IntTensor { + TchOps::sign(tensor) + } + + fn int_expand(tensor: IntTensor, shape: Shape) -> IntTensor { + TchOps::expand(tensor, shape) + } + + fn int_sort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + TchOps::sort(tensor, dim, descending) + } + + fn int_argsort(tensor: IntTensor, dim: usize, descending: bool) -> IntTensor { + TchOps::argsort(tensor, dim, descending) + } + + fn bitwise_and(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::bitwise_and(lhs, rhs) + } + + fn bitwise_or(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::bitwise_or(lhs, rhs) + } + + fn bitwise_xor(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::bitwise_xor(lhs, rhs) + } + + fn bitwise_not(tensor: IntTensor) -> IntTensor { + TchOps::bitwise_not(tensor) + } + + fn bitwise_and_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + TchOps::bitwise_and_scalar(lhs, rhs.elem::()) + } + + fn bitwise_or_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + TchOps::bitwise_or_scalar(lhs, rhs.elem::()) + } + + fn bitwise_xor_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + TchOps::bitwise_xor_scalar(lhs, rhs.elem::()) + } + + fn bitwise_left_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::bitwise_left_shift(lhs, rhs) + } + + fn bitwise_right_shift(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::bitwise_right_shift(lhs, rhs) + } + + fn bitwise_left_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + TchOps::bitwise_left_shift_scalar(lhs, rhs.elem::()) + } + + fn bitwise_right_shift_scalar(lhs: IntTensor, rhs: Scalar) -> IntTensor { + TchOps::bitwise_right_shift_scalar(lhs, rhs.elem::()) + } + + fn int_cast(tensor: IntTensor, dtype: IntDType) -> IntTensor { + // NOTE: when dtypes of inputs to an arithmetic operation differ, tch handles type + // promotion based on a set of rules: https://pytorch.org/docs/stable/tensor_attributes.html#type-promotion-doc + + // Type promotion is not automatic on all backends so this behavior might differ + let kind = dtype.into_kind(); + + if tensor.tensor.kind() == kind { + tensor + } else { + TchTensor::new(tensor.tensor.to_kind(kind)) + } + } + + fn int_unfold( + tensor: IntTensor, + dim: usize, + size: usize, + step: usize, + ) -> IntTensor { + TchOps::unfold(tensor, dim, size, step) + } + + fn int_powi(lhs: IntTensor, rhs: IntTensor) -> IntTensor { + TchOps::pow(lhs, rhs) + } + + fn int_powi_scalar_impl(lhs: IntTensor, rhs: Scalar) -> IntTensor { + lhs.unary_ops( + |mut tensor| tensor.f_pow_(rhs.elem::()).unwrap(), + |tensor| tensor.pow_tensor_scalar(rhs.elem::()), + ) + } +} diff --git a/crates/burn-tch/src/ops/mod.rs b/crates/burn-tch/src/ops/mod.rs new file mode 100644 index 0000000..cebe9d2 --- /dev/null +++ b/crates/burn-tch/src/ops/mod.rs @@ -0,0 +1,10 @@ +mod activation; +mod base; +mod bool_tensor; +mod int_tensor; +mod module; +mod qtensor; +mod tensor; +mod transaction; + +pub(crate) use base::*; diff --git a/crates/burn-tch/src/ops/module.rs b/crates/burn-tch/src/ops/module.rs new file mode 100644 index 0000000..c5f9e36 --- /dev/null +++ b/crates/burn-tch/src/ops/module.rs @@ -0,0 +1,642 @@ +use crate::{IntoKind, LibTorch, TchTensor}; +use burn_backend::{ + IntDType, TensorMetadata, + ops::{ + AttentionModuleOptions, ConvOptions, ConvTransposeOptions, DeformConv2dBackward, + DeformConvOptions, InterpolateMode, InterpolateOptions, MaxPool1dWithIndices, + MaxPool2dBackward, MaxPool2dWithIndices, ModuleOps, attention::attention_fallback, + }, + tensor::{FloatTensor, IntTensor}, +}; + +impl ModuleOps for LibTorch { + fn embedding(weights: TchTensor, indices: TchTensor) -> TchTensor { + // Workaround for MPS "Placeholder storage has not been allocated" error. + // See: https://github.com/pytorch/pytorch/issues/123995 + // MPS uses lazy allocation and the embedding operation (which uses index_select) + // can fail if the tensors haven't been materialized yet. + // We work around this by performing the embedding on CPU and transferring back to MPS. + if matches!(weights.tensor.device(), tch::Device::Mps) { + let cpu_weights = weights.tensor.to(tch::Device::Cpu); + let cpu_indices = indices.tensor.to(tch::Device::Cpu); + let result = tch::Tensor::embedding(&cpu_weights, &cpu_indices, -1, false, false) + .to(tch::Device::Mps); + return TchTensor::new(result); + } + + let tensor = tch::Tensor::embedding(&weights.tensor, &indices.tensor, -1, false, false); + TchTensor::new(tensor) + } + + fn embedding_backward(weights: TchTensor, output: TchTensor, indices: TchTensor) -> TchTensor { + let [n_embedding, _d_model] = weights.shape().dims(); + + // Workaround for MPS "Placeholder storage has not been allocated" error. + // See: https://github.com/pytorch/pytorch/issues/123995 + if matches!(output.tensor.device(), tch::Device::Mps) { + let cpu_output = output.tensor.to(tch::Device::Cpu); + let cpu_indices = indices.tensor.to(tch::Device::Cpu); + let result = tch::Tensor::embedding_backward( + &cpu_output, + &cpu_indices, + n_embedding as i64, + -1, + false, + false, + ) + .to(tch::Device::Mps); + return TchTensor::new(result); + } + + let tensor = tch::Tensor::embedding_backward( + &output.tensor, + &indices.tensor, + n_embedding as i64, + -1, + false, + false, + ); + + TchTensor::new(tensor) + } + + fn conv1d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvOptions<1>, + ) -> TchTensor { + let tensor = tch::Tensor::conv1d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.dilation.map(|i| i as i64), + options.groups as i64, + ); + + TchTensor::new(tensor) + } + + fn conv2d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvOptions<2>, + ) -> TchTensor { + let tensor = tch::Tensor::conv2d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.dilation.map(|i| i as i64), + options.groups as i64, + ); + + TchTensor::new(tensor) + } + + fn conv3d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvOptions<3>, + ) -> TchTensor { + let tensor = tch::Tensor::conv3d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.dilation.map(|i| i as i64), + options.groups as i64, + ); + + TchTensor::new(tensor) + } + + fn deform_conv2d( + _x: TchTensor, + _offset: TchTensor, + _weight: TchTensor, + _mask: Option, + _bias: Option, + _options: DeformConvOptions<2>, + ) -> TchTensor { + unimplemented!("Torch bindings don't support deform_conv2d"); + } + + fn deform_conv2d_backward( + _x: TchTensor, + _offset: TchTensor, + _weight: TchTensor, + _mask: Option, + _bias: Option, + _out_grad: TchTensor, + _options: DeformConvOptions<2>, + ) -> DeformConv2dBackward { + unimplemented!("Torch bindings don't support deform_conv2d"); + } + + fn conv_transpose1d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvTransposeOptions<1>, + ) -> TchTensor { + let tensor = tch::Tensor::conv_transpose1d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.padding_out.map(|i| i as i64), + options.groups as i64, + options.dilation.map(|i| i as i64), + ); + + TchTensor::new(tensor) + } + + fn conv_transpose2d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvTransposeOptions<2>, + ) -> TchTensor { + let tensor = tch::Tensor::conv_transpose2d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.padding_out.map(|i| i as i64), + options.groups as i64, + options.dilation.map(|i| i as i64), + ); + + TchTensor::new(tensor) + } + + fn conv_transpose3d( + x: TchTensor, + weight: TchTensor, + bias: Option, + options: ConvTransposeOptions<3>, + ) -> TchTensor { + let tensor = tch::Tensor::conv_transpose3d( + &x.tensor, + &weight.tensor, + bias.map(|t| t.tensor), + options.stride.map(|i| i as i64), + options.padding.map(|i| i as i64), + options.padding_out.map(|i| i as i64), + options.groups as i64, + options.dilation.map(|i| i as i64), + ); + + TchTensor::new(tensor) + } + + fn avg_pool1d( + x: TchTensor, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, + ) -> TchTensor { + let tensor = tch::Tensor::avg_pool1d( + &x.tensor, + [kernel_size as i64], + [stride as i64], + [padding as i64], + ceil_mode, + count_include_pad, + ); + + TchTensor::new(tensor) + } + fn avg_pool2d( + x: TchTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> TchTensor { + let tensor = tch::Tensor::avg_pool2d( + &x.tensor, + [kernel_size[0] as i64, kernel_size[1] as i64], + [stride[0] as i64, stride[1] as i64], + [padding[0] as i64, padding[1] as i64], + ceil_mode, + count_include_pad, + None, + ); + + TchTensor::new(tensor) + } + + fn avg_pool2d_backward( + x: TchTensor, + grad: TchTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, + ) -> TchTensor { + let tensor = tch::Tensor::avg_pool2d_backward( + &x.tensor, + &grad.tensor, + [kernel_size[0] as i64, kernel_size[1] as i64], + [stride[0] as i64, stride[1] as i64], + [padding[0] as i64, padding[1] as i64], + ceil_mode, + count_include_pad, + None, + ); + + TchTensor::new(tensor) + } + + fn max_pool1d( + x: TchTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + ) -> TchTensor { + let tensor = tch::Tensor::max_pool1d( + &x.tensor, + kernel_size as i64, + stride as i64, + padding as i64, + dilation as i64, + ceil_mode, + ); + + TchTensor::new(tensor) + } + + fn max_pool1d_with_indices( + x: TchTensor, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool1dWithIndices { + let (tensor, indices) = tch::Tensor::max_pool1d_with_indices( + &x.tensor, + kernel_size as i64, + stride as i64, + padding as i64, + dilation as i64, + ceil_mode, + ); + + MaxPool1dWithIndices::new( + TchTensor::new(tensor), + TchTensor::new(indices.to_kind(indices_dtype.into_kind())), + ) + } + + fn max_pool2d( + x: TchTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + ) -> TchTensor { + let tensor = tch::Tensor::max_pool2d( + &x.tensor, + [kernel_size[0] as i64, kernel_size[1] as i64], + [stride[0] as i64, stride[1] as i64], + [padding[0] as i64, padding[1] as i64], + [dilation[0] as i64, dilation[1] as i64], + ceil_mode, + ); + + TchTensor::new(tensor) + } + + fn max_pool2d_with_indices( + x: TchTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + indices_dtype: IntDType, + ) -> MaxPool2dWithIndices { + let (tensor, indices) = tch::Tensor::max_pool2d_with_indices( + &x.tensor, + [kernel_size[0] as i64, kernel_size[1] as i64], + [stride[0] as i64, stride[1] as i64], + [padding[0] as i64, padding[1] as i64], + [dilation[0] as i64, dilation[1] as i64], + ceil_mode, + ); + + MaxPool2dWithIndices::new( + TchTensor::new(tensor), + TchTensor::new(indices.to_kind(indices_dtype.into_kind())), + ) + } + + fn max_pool2d_with_indices_backward( + x: TchTensor, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: TchTensor, + indices: TchTensor, + ) -> MaxPool2dBackward { + let grad = tch::Tensor::max_pool2d_with_indices_backward( + &x.tensor, + &output_grad.tensor, + [kernel_size[0] as i64, kernel_size[1] as i64], + [stride[0] as i64, stride[1] as i64], + [padding[0] as i64, padding[1] as i64], + [dilation[0] as i64, dilation[1] as i64], + ceil_mode, + &indices.tensor, + ); + + MaxPool2dBackward::new(TchTensor::new(grad)) + } + + fn adaptive_avg_pool2d(x: TchTensor, output_size: [usize; 2]) -> TchTensor { + let tensor = tch::Tensor::adaptive_avg_pool2d(&x.tensor, output_size.map(|e| e as i64)); + + TchTensor::new(tensor) + } + + fn adaptive_avg_pool2d_backward(x: TchTensor, grad: TchTensor) -> TchTensor { + let tensor = tch::Tensor::internal_adaptive_avg_pool2d_backward(&x.tensor, &grad.tensor); + + TchTensor::new(tensor) + } + + fn adaptive_avg_pool1d(x: TchTensor, output_size: usize) -> TchTensor { + let tensor = tch::Tensor::adaptive_avg_pool1d(&x.tensor, output_size as i64); + + TchTensor::new(tensor) + } + + fn interpolate( + x: TchTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> TchTensor { + let output_size = output_size.map(|e| e as i64); + + let align_corners = options.align_corners; + let tensor = match options.mode { + InterpolateMode::Nearest => { + tch::Tensor::upsample_nearest2d(&x.tensor, output_size, None, None) + } + InterpolateMode::NearestExact => { + panic!("nearest exact interpolation is not supported by PyTorch/tch backend") + } + InterpolateMode::Bilinear => { + tch::Tensor::upsample_bilinear2d(&x.tensor, output_size, align_corners, None, None) + } + InterpolateMode::Bicubic => { + tch::Tensor::upsample_bicubic2d(&x.tensor, output_size, align_corners, None, None) + } + InterpolateMode::Lanczos3 => { + panic!("lanczos3 interpolation is not supported by PyTorch/tch backend") + } + }; + + TchTensor::new(tensor) + } + + fn interpolate_backward( + x: TchTensor, + grad: TchTensor, + output_size: [usize; 2], + options: InterpolateOptions, + ) -> TchTensor { + let output_size = output_size.map(|e| e as i64); + let [n, c, h_in, w_in] = x.shape().dims(); + let input_size = [n as i64, c as i64, h_in as i64, w_in as i64]; + let align_corners = options.align_corners; + + let tensor = match options.mode { + InterpolateMode::Nearest => tch::Tensor::upsample_nearest2d_backward( + &grad.tensor, + output_size, + input_size, + None, + None, + ), + InterpolateMode::NearestExact => { + panic!( + "nearest exact interpolation backward is not supported by PyTorch/tch backend" + ) + } + InterpolateMode::Bilinear => tch::Tensor::upsample_bilinear2d_backward( + &grad.tensor, + output_size, + input_size, + align_corners, + None, + None, + ), + InterpolateMode::Bicubic => tch::Tensor::upsample_bicubic2d_backward( + &grad.tensor, + output_size, + input_size, + align_corners, + None, + None, + ), + InterpolateMode::Lanczos3 => { + panic!("lanczos3 interpolation backward is not supported by PyTorch/tch backend") + } + }; + + TchTensor::new(tensor) + } + + fn attention( + query: TchTensor, + key: TchTensor, + value: TchTensor, + mask: Option, + attn_bias: Option, + options: AttentionModuleOptions, + ) -> TchTensor { + if attn_bias.is_some() { + return attention_fallback::(query, key, value, mask, attn_bias, options); + } + + TchTensor::new(tch::Tensor::scaled_dot_product_attention( + &query.tensor, + &key.tensor, + &value.tensor, + mask.map(|m| m.tensor), + 0., + options.is_causal, + options.scale, + false, + )) + } + + fn layer_norm( + tensor: TchTensor, + gamma: TchTensor, + beta: Option, + epsilon: f64, + ) -> TchTensor { + let shape = tensor.shape(); + let last_dim = shape[shape.num_dims() - 1] as i64; + + let tensor = tensor.tensor.layer_norm( + [last_dim], + Some(&gamma.tensor), + beta.as_ref().map(|b| &b.tensor), + epsilon, + true, + ); + + TchTensor::new(tensor) + } + + fn has_ctc_loss_backward() -> bool { + true + } + + fn ctc_loss( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + blank: usize, + ) -> FloatTensor { + // PyTorch's CTC requires int64 for targets and length tensors. + let targets_i64 = targets.tensor.to_kind(tch::Kind::Int64); + let input_lengths_i64 = input_lengths.tensor.to_kind(tch::Kind::Int64); + let target_lengths_i64 = target_lengths.tensor.to_kind(tch::Kind::Int64); + + // Reduction::None returns per-sample losses [N], matching the trait contract. + let tensor = tch::Tensor::ctc_loss_tensor( + &log_probs.tensor, + &targets_i64, + &input_lengths_i64, + &target_lengths_i64, + blank as i64, + tch::Reduction::None, + false, + ); + + TchTensor::new(tensor) + } + + fn ctc_loss_backward( + log_probs: FloatTensor, + targets: IntTensor, + input_lengths: IntTensor, + target_lengths: IntTensor, + grad_loss: FloatTensor, + blank: usize, + ) -> FloatTensor { + let targets_i64 = targets.tensor.to_kind(tch::Kind::Int64); + let input_lengths_i64 = input_lengths.tensor.to_kind(tch::Kind::Int64); + let target_lengths_i64 = target_lengths.tensor.to_kind(tch::Kind::Int64); + + // Recompute forward to get neg_log_likelihood and log_alpha (LibTorch's + // backward needs both). PyTorch caches log_alpha during the autograd + // forward; our trait has no caching slot for it, so we redo the alpha + // recursion here. This is still a single-call into LibTorch's fused + // kernel and avoids the ~40T host-side dispatches. + let (neg_log_likelihood, log_alpha) = tch::Tensor::internal_ctc_loss_tensor( + &log_probs.tensor, + &targets_i64, + &input_lengths_i64, + &target_lengths_i64, + blank as i64, + false, + ); + + let grad = tch::Tensor::internal_ctc_loss_backward_tensor( + &grad_loss.tensor, + &log_probs.tensor, + &targets_i64, + &input_lengths_i64, + &target_lengths_i64, + &neg_log_likelihood, + &log_alpha, + blank as i64, + false, + ); + + TchTensor::new(grad) + } + + fn rfft( + signal: FloatTensor, + dim: usize, + n: Option, + ) -> (FloatTensor, FloatTensor) { + let complex = signal + .tensor + .fft_rfft(n.map(|v| v as i64), dim as i64, "backward"); + let re = TchTensor::new(complex.real().contiguous()); + let im = TchTensor::new(complex.imag().contiguous()); + (re, im) + } + + fn irfft( + spectrum_re: FloatTensor, + spectrum_im: FloatTensor, + dim: usize, + n: Option, + ) -> FloatTensor { + let complex = tch::Tensor::complex(&spectrum_re.tensor, &spectrum_im.tensor); + TchTensor::new(complex.fft_irfft(n.map(|v| v as i64), dim as i64, "backward")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::{ + TensorData, Tolerance, + ops::{FloatTensorOps, IntTensorOps}, + read_sync, + }; + + type B = crate::LibTorch; + + #[test] + fn ctc_loss_uniform() { + // T=3, N=1, C=2, blank=0, target=[1, 1]. + // Only valid alignment is (1, 0, 1) with prob (1/2)^3. + // Loss = -ln(1/8) = 3 * ln(2) + let device = Default::default(); + let log_probs_data = vec![(0.5f32).ln(); 3 * 2]; + let log_probs = B::float_from_data(TensorData::new(log_probs_data, [3, 1, 2]), &device); + let targets = B::int_from_data(TensorData::from([[1i64, 1]]), &device); + let input_lengths = B::int_from_data(TensorData::from([3i64]), &device); + let target_lengths = B::int_from_data(TensorData::from([2i64]), &device); + + let loss = + >::ctc_loss(log_probs, targets, input_lengths, target_lengths, 0); + + let out = read_sync(B::float_into_data(loss)).unwrap(); + let expected = TensorData::from([3.0f32 * 2.0f32.ln()]); + out.assert_approx_eq::(&expected, Tolerance::rel_abs(1e-3, 1e-3)); + } +} diff --git a/crates/burn-tch/src/ops/qtensor.rs b/crates/burn-tch/src/ops/qtensor.rs new file mode 100644 index 0000000..201a236 --- /dev/null +++ b/crates/burn-tch/src/ops/qtensor.rs @@ -0,0 +1,129 @@ +use burn_backend::{ + ExecutionError, FloatDType, IntDType, Shape, TensorData, + ops::QTensorOps, + quantization::{QuantScheme, QuantizationParametersPrimitive}, + tensor::{Device, FloatTensor, IntTensor, QuantizedTensor}, +}; + +use crate::{LibTorch, LibTorchDevice}; + +impl QTensorOps for LibTorch { + fn q_from_data(_data: TensorData, _device: &LibTorchDevice) -> QuantizedTensor { + unimplemented!() + } + + fn quantize( + _tensor: FloatTensor, + _scheme: &QuantScheme, + _qparams: QuantizationParametersPrimitive, + ) -> QuantizedTensor { + unimplemented!() + } + + fn quantize_dynamic( + _tensor: FloatTensor, + _scheme: &QuantScheme, + ) -> QuantizedTensor { + unimplemented!() + } + + fn dequantize(_tensor: QuantizedTensor, _dtype: FloatDType) -> FloatTensor { + unimplemented!() + } + + fn q_to_device( + _tensor: QuantizedTensor, + _device: &Device, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_reshape(_tensor: QuantizedTensor, _shape: Shape) -> QuantizedTensor { + unimplemented!() + } + + async fn q_into_data(_tensor: QuantizedTensor) -> Result { + unimplemented!() + } + fn q_swap_dims( + _tensor: QuantizedTensor, + _dim1: usize, + _dim2: usize, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_permute(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_flip(_tensor: QuantizedTensor, _axes: &[usize]) -> QuantizedTensor { + unimplemented!() + } + + fn q_argmax( + _tensor: QuantizedTensor, + _dim: usize, + _out_dtype: IntDType, + ) -> IntTensor { + unimplemented!() + } + + fn q_argmin( + _tensor: QuantizedTensor, + _dim: usize, + _out_dtype: IntDType, + ) -> IntTensor { + unimplemented!() + } + + fn q_max_dim_with_indices( + _tensor: QuantizedTensor, + _dim: usize, + _indices_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + unimplemented!() + } + + fn q_max_dim(_tensor: QuantizedTensor, _dim: usize) -> QuantizedTensor { + unimplemented!() + } + + fn q_min_dim(_tensor: QuantizedTensor, _dim: usize) -> QuantizedTensor { + unimplemented!() + } + + fn q_min_dim_with_indices( + _tensor: QuantizedTensor, + _dim: usize, + _indices_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + unimplemented!() + } + + fn q_sort( + _tensor: QuantizedTensor, + _dim: usize, + _descending: bool, + ) -> QuantizedTensor { + unimplemented!() + } + + fn q_sort_with_indices( + _tensor: QuantizedTensor, + _dim: usize, + _descending: bool, + _indices_dtype: IntDType, + ) -> (QuantizedTensor, IntTensor) { + unimplemented!() + } + + fn q_argsort( + _tensor: QuantizedTensor, + _dim: usize, + _descending: bool, + _out_dtype: IntDType, + ) -> IntTensor { + unimplemented!() + } +} diff --git a/crates/burn-tch/src/ops/tensor.rs b/crates/burn-tch/src/ops/tensor.rs new file mode 100644 index 0000000..2892c43 --- /dev/null +++ b/crates/burn-tch/src/ops/tensor.rs @@ -0,0 +1,576 @@ +use super::TchOps; +use crate::{IntoKind, LibTorch, LibTorchDevice, TchShape, TchTensor}; +use burn_backend::backend::ExecutionError; +use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor}; +use burn_backend::{BoolDType, IntDType, Scalar, bf16, f16}; +use burn_backend::{ + DType, Distribution, FloatDType, Shape, TensorData, TensorMetadata, ops::FloatTensorOps, +}; + +impl FloatTensorOps for LibTorch { + fn float_from_data(data: TensorData, device: &LibTorchDevice) -> TchTensor { + match data.dtype { + DType::F64 => TchTensor::from_data::(data, (*device).into()), + DType::F32 => TchTensor::from_data::(data, (*device).into()), + DType::F16 => TchTensor::from_data::(data, (*device).into()), + DType::BF16 => TchTensor::from_data::(data, (*device).into()), + _ => unimplemented!("Unsupported dtype for `float_from_data`"), + } + } + + fn float_random( + shape: Shape, + distribution: Distribution, + device: &LibTorchDevice, + dtype: FloatDType, + ) -> TchTensor { + match distribution { + Distribution::Default => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor + .mut_ops(|tensor| tensor.rand_like_out(tensor)) + .unwrap() + } + Distribution::Bernoulli(prob) => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor + .mut_ops(|tensor| tensor.f_bernoulli_float_(prob).unwrap()) + .unwrap() + } + Distribution::Uniform(from, to) => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor.mut_ops(|tensor| tensor.uniform_(from, to)).unwrap() + } + Distribution::Normal(mean, std) => { + let mut tensor = TchTensor::empty(shape, *device, dtype.into()); + tensor.mut_ops(|tensor| tensor.normal_(mean, std)).unwrap() + } + } + } + + fn float_repeat_dim(tensor: TchTensor, dim: usize, times: usize) -> TchTensor { + TchOps::repeat_dim(tensor, dim, times) + } + + fn float_zeros(shape: Shape, device: &LibTorchDevice, dtype: FloatDType) -> TchTensor { + let shape = TchShape::from(shape); + let device: tch::Device = (*device).into(); + + TchTensor::new(tch::Tensor::zeros(shape.dims, (dtype.into_kind(), device))) + } + + fn float_ones(shape: Shape, device: &LibTorchDevice, dtype: FloatDType) -> TchTensor { + let shape = TchShape::from(shape); + let device: tch::Device = (*device).into(); + + TchTensor::new(tch::Tensor::ones(shape.dims, (dtype.into_kind(), device))) + } + + async fn float_into_data(tensor: TchTensor) -> Result { + let shape = tensor.shape(); + let tensor = Self::float_reshape(tensor.clone(), Shape::new([shape.num_elements()])); + Ok(match tensor.tensor.kind() { + tch::Kind::Half => { + let values = Vec::::try_from(&tensor).unwrap(); + TensorData::new(values, shape) + } + tch::Kind::Float => { + let values = Vec::::try_from(&tensor).unwrap(); + TensorData::new(values, shape) + } + tch::Kind::Double => { + let values = Vec::::try_from(&tensor).unwrap(); + TensorData::new(values, shape) + } + tch::Kind::BFloat16 => { + let values = Vec::::try_from(&tensor).unwrap(); + TensorData::new(values, shape) + } + _ => panic!("Not a valid float kind"), + }) + } + + fn float_to_device(tensor: TchTensor, device: &LibTorchDevice) -> TchTensor { + TchOps::to_device(tensor, device) + } + + fn float_empty(shape: Shape, device: &LibTorchDevice, dtype: FloatDType) -> TchTensor { + let tensor = tch::Tensor::empty( + TchShape::from(shape).dims, + (dtype.into_kind(), (*device).into()), + ); + + TchTensor::new(tensor) + } + + fn float_add(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::add(lhs, rhs) + } + + fn float_add_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let rhs: f64 = rhs.elem(); + + lhs.unary_ops( + |mut tensor| tensor.f_add_scalar_(rhs).unwrap(), + |tensor| tensor.f_add_scalar(rhs).unwrap(), + ) + } + + fn float_sub(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::sub(lhs, rhs) + } + + fn float_sub_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let rhs: f64 = rhs.elem(); + + lhs.unary_ops( + |mut tensor| tensor.f_sub_scalar_(rhs).unwrap(), + |tensor| tensor.f_sub_scalar(rhs).unwrap(), + ) + } + + fn float_mul(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::mul(lhs, rhs) + } + + fn float_mul_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let rhs: f64 = rhs.elem(); + + lhs.unary_ops( + |mut tensor| tensor.f_mul_scalar_(rhs).unwrap(), + |tensor| tensor.f_mul_scalar(rhs).unwrap(), + ) + } + + fn float_div(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::div(lhs, rhs) + } + + fn float_div_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let rhs: f64 = rhs.elem(); + + lhs.unary_ops( + |mut tensor| tensor.f_div_scalar_(rhs).unwrap(), + |tensor| tensor.f_div_scalar(rhs).unwrap(), + ) + } + + fn float_remainder(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::remainder(lhs, rhs) + } + + fn float_remainder_scalar(lhs: TchTensor, rhs: Scalar) -> TchTensor { + let rhs: f64 = rhs.elem(); + + lhs.unary_ops( + |tensor| tensor.f_remainder(rhs).unwrap(), + |tensor| tensor.f_remainder(rhs).unwrap(), + ) + } + + fn float_matmul(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + let tensor = lhs.tensor.matmul(&rhs.tensor); + TchTensor::new(tensor) + } + + fn float_cross(lhs: TchTensor, rhs: TchTensor, dim: usize) -> TchTensor { + let tensor = lhs.tensor.cross(&rhs.tensor, dim as i64); + TchTensor::new(tensor) + } + + fn float_recip(tensor: TchTensor) -> TchTensor { + TchTensor::new(tensor.tensor.reciprocal()) + } + + fn float_swap_dims(tensor: TchTensor, dim1: usize, dim2: usize) -> TchTensor { + TchOps::swap_dims(tensor, dim1, dim2) + } + + fn float_reshape(tensor: TchTensor, shape: Shape) -> TchTensor { + TchOps::reshape(tensor, shape) + } + + fn float_gather(dim: usize, tensor: TchTensor, indices: TchTensor) -> TchTensor { + TchOps::gather(dim, tensor, indices) + } + + fn float_scatter_add( + dim: usize, + tensor: TchTensor, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + TchOps::scatter(dim, tensor, indices, value) + } + + fn float_scatter_nd( + data: TchTensor, + indices: TchTensor, + values: TchTensor, + reduction: burn_backend::tensor::IndexingUpdateOp, + ) -> TchTensor { + TchOps::scatter_nd(data, indices, values, reduction) + } + + fn float_gather_nd(data: TchTensor, indices: TchTensor) -> TchTensor { + TchOps::gather_nd(data, indices) + } + + fn float_select(tensor: TchTensor, dim: usize, indices: TchTensor) -> TchTensor { + TchOps::index_select_dim(tensor, dim, indices) + } + + fn float_select_add( + tensor: TchTensor, + dim: usize, + indices: TchTensor, + value: TchTensor, + ) -> TchTensor { + TchOps::select_assign(tensor, dim, indices, value) + } + + fn float_slice(tensor: TchTensor, slices: &[burn_backend::Slice]) -> TchTensor { + TchOps::slice_with_steps(tensor, slices) + } + + fn float_slice_assign( + tensor: TchTensor, + slices: &[burn_backend::Slice], + value: TchTensor, + ) -> TchTensor { + TchOps::slice_assign(tensor, slices, value) + } + + fn float_mask_where(tensor: TchTensor, mask: TchTensor, value: TchTensor) -> TchTensor { + let output = value.tensor.where_self(&mask.tensor, &tensor.tensor); + + TchTensor::new(output) + } + + fn float_mask_fill(tensor: TchTensor, mask: TchTensor, value: Scalar) -> TchTensor { + let value: f64 = value.elem(); + + tensor.unary_ops( + |mut tensor| tensor.f_masked_fill_(&mask.tensor, value).unwrap(), + |tensor| tensor.f_masked_fill(&mask.tensor, value).unwrap(), + ) + } + + fn float_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::equal(lhs, rhs) + } + + fn float_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::equal_elem(lhs, rhs.elem::()) + } + + fn float_greater(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater(lhs, rhs) + } + + fn float_greater_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_elem(lhs, rhs.elem::()) + } + + fn float_greater_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_equal(lhs, rhs) + } + + fn float_greater_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::greater_equal_elem(lhs, rhs.elem::()) + } + + fn float_lower(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower(lhs, rhs) + } + + fn float_lower_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_elem(lhs, rhs.elem::()) + } + + fn float_lower_equal(lhs: TchTensor, rhs: TchTensor, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_equal(lhs, rhs) + } + + fn float_lower_equal_elem(lhs: TchTensor, rhs: Scalar, _out_dtype: BoolDType) -> TchTensor { + TchOps::lower_equal_elem(lhs, rhs.elem::()) + } + + fn float_mean(tensor: TchTensor) -> TchTensor { + TchOps::mean(tensor) + } + + fn float_sum(tensor: TchTensor) -> TchTensor { + TchOps::sum(tensor) + } + + fn float_sum_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::sum_dim(tensor, dim) + } + + fn float_mean_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::mean_dim(tensor, dim) + } + + fn float_cumsum(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cumsum(tensor, dim) + } + + fn float_cumprod(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cumprod(tensor, dim) + } + + fn float_cummin(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cummin(tensor, dim) + } + + fn float_cummax(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::cummax(tensor, dim) + } + + fn float_prod(tensor: TchTensor) -> TchTensor { + TchOps::prod(tensor) + } + + fn float_prod_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::prod_dim(tensor, dim) + } + + fn float_argmax(tensor: TchTensor, dim: usize, _indices_dtype: IntDType) -> TchTensor { + TchOps::argmax(tensor, dim) + } + + fn float_argtopk( + tensor: TchTensor, + dim: usize, + k: usize, + _indices_dtype: IntDType, + ) -> TchTensor { + TchOps::argtopk(tensor, dim, k) + } + + fn float_topk(tensor: TchTensor, dim: usize, k: usize) -> TchTensor { + TchOps::topk(tensor, dim, k) + } + + fn float_argmin(tensor: TchTensor, dim: usize, _out_dtype: IntDType) -> TchTensor { + TchOps::argmin(tensor, dim) + } + + fn float_max_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::max_dim(tensor, dim) + } + + fn float_max_dim_with_indices( + tensor: TchTensor, + dim: usize, + _indices_dtype: IntDType, + ) -> (TchTensor, TchTensor) { + TchOps::max_dim_with_indices(tensor, dim) + } + + fn float_min_dim(tensor: TchTensor, dim: usize) -> TchTensor { + TchOps::min_dim(tensor, dim) + } + + fn float_min_dim_with_indices( + tensor: TchTensor, + dim: usize, + _indices_dtype: IntDType, + ) -> (TchTensor, TchTensor) { + TchOps::min_dim_with_indices(tensor, dim) + } + + fn float_exp(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.exp_(), |tensor| tensor.exp()) + } + + fn float_log(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.log_(), |tensor| tensor.log()) + } + + fn float_log1p(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.log1p_(), |tensor| tensor.log1p()) + } + + fn float_powf_scalar_impl(tensor: TchTensor, value: Scalar) -> TchTensor { + tensor.unary_ops( + |mut tensor| tensor.f_pow_(value.elem::()).unwrap(), + |tensor| tensor.pow_tensor_scalar(value.elem::()), + ) + } + + fn float_sqrt(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.sqrt_(), |tensor| tensor.sqrt()) + } + + fn float_abs(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.abs_(), |tensor| tensor.abs()) + } + + fn float_cos(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.cos_(), |tensor| tensor.cos()) + } + + fn float_cosh(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.cosh_(), |tensor| tensor.cosh()) + } + + fn float_sin(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.sin_(), |tensor| tensor.sin()) + } + + fn float_sinh(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.sinh_(), |tensor| tensor.sinh()) + } + + fn float_tan(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.tan_(), |tensor| tensor.tan()) + } + + fn float_tanh(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.tanh_(), |tensor| tensor.tanh()) + } + + fn float_acos(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.acos_(), |tensor| tensor.acos()) + } + + fn float_acosh(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.acosh_(), |tensor| tensor.acosh()) + } + + fn float_asin(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.asin_(), |tensor| tensor.asin()) + } + + fn float_asinh(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.asinh_(), |tensor| tensor.asinh()) + } + + fn float_atan(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.atan_(), |tensor| tensor.atan()) + } + + fn float_atanh(tensor: FloatTensor) -> FloatTensor { + tensor.unary_ops(|mut tensor| tensor.atanh_(), |tensor| tensor.atanh()) + } + + fn float_atan2(lhs: FloatTensor, rhs: FloatTensor) -> FloatTensor { + TchOps::atan2(lhs, rhs) + } + + fn float_round(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.round_(), |tensor| tensor.round()) + } + + fn float_floor(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.floor_(), |tensor| tensor.floor()) + } + + fn float_ceil(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.ceil_(), |tensor| tensor.ceil()) + } + + fn float_trunc(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.trunc_(), |tensor| tensor.trunc()) + } + + fn float_erf(tensor: TchTensor) -> TchTensor { + tensor.unary_ops(|mut tensor| tensor.erf_(), |tensor| tensor.erf()) + } + + fn float_cat(tensors: Vec, dim: usize) -> TchTensor { + TchOps::cat(tensors, dim) + } + + fn float_clamp_min(tensor: TchTensor, min: Scalar) -> TchTensor { + TchOps::clamp_min(tensor, min.elem::()) + } + + fn float_clamp_max(tensor: TchTensor, max: Scalar) -> TchTensor { + TchOps::clamp_max(tensor, max.elem::()) + } + + fn float_clamp(tensor: TchTensor, min: Scalar, max: Scalar) -> TchTensor { + TchOps::clamp(tensor, min.elem::(), max.elem::()) + } + + fn float_into_int(tensor: TchTensor, _out_dtype: IntDType) -> TchTensor { + let tensor = tensor.tensor.to_kind(tch::Kind::Int64); + TchTensor::new(tensor) + } + + fn float_powf(lhs: TchTensor, rhs: TchTensor) -> TchTensor { + TchOps::pow(lhs, rhs) + } + + fn float_permute(tensor: TchTensor, axes: &[usize]) -> TchTensor { + TchOps::permute(tensor, axes) + } + + fn float_flip(tensor: TchTensor, axes: &[usize]) -> TchTensor { + TchOps::flip(tensor, axes) + } + + fn float_sign(tensor: TchTensor) -> TchTensor { + TchOps::sign(tensor) + } + + fn float_expand(tensor: TchTensor, shape: Shape) -> TchTensor { + TchOps::expand(tensor, shape) + } + + fn float_sort(tensor: TchTensor, dim: usize, descending: bool) -> TchTensor { + TchOps::sort(tensor, dim, descending) + } + + fn float_sort_with_indices( + tensor: TchTensor, + dim: usize, + descending: bool, + _indices_dtype: IntDType, + ) -> (TchTensor, TchTensor) { + TchOps::sort_with_indices(tensor, dim, descending) + } + + fn float_argsort( + tensor: TchTensor, + dim: usize, + descending: bool, + _out_dtype: IntDType, + ) -> IntTensor { + TchOps::argsort(tensor, dim, descending) + } + + fn float_cast(tensor: TchTensor, dtype: FloatDType) -> TchTensor { + // NOTE: when dtypes of inputs to an arithmetic operation differ, tch handles type + // promotion based on a set of rules: https://pytorch.org/docs/stable/tensor_attributes.html#type-promotion-doc + + // Type promotion is not automatic on all backends so this behavior might differ + let kind = dtype.into_kind(); + + if tensor.tensor.kind() == kind { + tensor + } else { + TchTensor::new(tensor.tensor.to_kind(kind)) + } + } + + fn float_unfold( + tensor: FloatTensor, + dim: usize, + size: usize, + step: usize, + ) -> FloatTensor { + TchOps::unfold(tensor, dim, size, step) + } + + fn float_is_nan(tensor: FloatTensor, _out_dtype: BoolDType) -> BoolTensor { + TchTensor::new(tensor.tensor.isnan()) + } + + fn float_is_inf(tensor: FloatTensor, _out_dtype: BoolDType) -> BoolTensor { + TchTensor::new(tensor.tensor.isinf()) + } +} diff --git a/crates/burn-tch/src/ops/transaction.rs b/crates/burn-tch/src/ops/transaction.rs new file mode 100644 index 0000000..7b5838b --- /dev/null +++ b/crates/burn-tch/src/ops/transaction.rs @@ -0,0 +1,9 @@ +use burn_backend::distributed::DistributedOps; +use burn_backend::ops::TransactionOps; + +use crate::LibTorch; + +impl TransactionOps for LibTorch {} + +// DistributedOps has default implementations; LibTorch does not support collective operations. +impl DistributedOps for LibTorch {} diff --git a/crates/burn-tch/src/tensor.rs b/crates/burn-tch/src/tensor.rs new file mode 100644 index 0000000..4f1f0c5 --- /dev/null +++ b/crates/burn-tch/src/tensor.rs @@ -0,0 +1,511 @@ +use crate::{LibTorchDevice, TchElement}; +use burn_backend::{BoolStore, DType, FloatDType, IntDType, Shape, TensorData, TensorMetadata}; +use libc::c_void; +use std::sync::Arc; + +/// A reference to a tensor storage. +/// +/// We manually implement `Sync` and `Send` unsafely, so even if we could use `Rc`, it isn't safe. +#[allow(clippy::arc_with_non_send_sync)] +pub type StorageRef = Arc<*mut c_void>; + +/// A reference to a tensor storage. +#[derive(PartialEq, Debug, Clone)] +pub enum Storage { + /// When a tensor is a partial view of another tensor. + View { + /// Storage reference for the whole buffer. + buffer_ref: StorageRef, + /// Storage reference for the partial buffer. + view_ref: StorageRef, + }, + /// When a tensor use all of its buffer. + Owned { + /// Storage reference for the whole buffer. + buffer_ref: StorageRef, + }, +} + +impl Storage { + /// Check if the storage can be used inplace. + pub fn can_mut(&self) -> bool { + match self { + Storage::View { + buffer_ref: start_ref, + view_ref, + } => Arc::strong_count(start_ref) == 1 && Arc::strong_count(view_ref) == 1, + Storage::Owned { + buffer_ref: start_ref, + } => Arc::strong_count(start_ref) == 1, + } + } + + /// Get the whole buffer reference. + pub fn buffer_ref(&self) -> &StorageRef { + match self { + Storage::View { + buffer_ref: start_ref, + view_ref: _, + } => start_ref, + Storage::Owned { + buffer_ref: start_ref, + } => start_ref, + } + } +} + +/// A tensor using the tch backend. +#[derive(Debug, PartialEq)] +pub struct TchTensor { + /// Handle to the tensor. Call methods on this field. + pub tensor: tch::Tensor, + + /// The tensor's storage + pub storage: Storage, +} + +impl TensorMetadata for TchTensor { + type Device = LibTorchDevice; + fn dtype(&self) -> DType { + match self.tensor.kind() { + tch::Kind::Uint8 => DType::U8, + tch::Kind::Int8 => DType::I8, + tch::Kind::Int16 => DType::I16, + tch::Kind::Int => DType::I32, + tch::Kind::Int64 => DType::I64, + tch::Kind::Half => DType::F16, + tch::Kind::Float => DType::F32, + tch::Kind::Double => DType::F64, + tch::Kind::Bool => DType::Bool(BoolStore::Native), + tch::Kind::BFloat16 => DType::BF16, + // Complex and quantization types are not valid/implemented. + _ => unimplemented!(), + } + } + + fn shape(&self) -> Shape { + Shape::from(self.tensor.size()) + } + + fn rank(&self) -> usize { + self.tensor.dim() + } + fn device(&self) -> Self::Device { + self.tensor.device().into() + } + + fn can_mut(&self) -> bool { + // The inherent method: unique storage and no broadcast stride. + TchTensor::can_mut(self) + } +} + +impl core::fmt::Display for TchTensor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.tensor) + } +} + +pub(crate) trait IntoKind { + fn try_into_kind(self) -> Result; + fn into_kind(self) -> tch::Kind + where + Self: Sized, + { + self.try_into_kind().unwrap() + } +} + +impl IntoKind for IntDType { + fn try_into_kind(self) -> Result { + let dtype: DType = self.into(); + dtype.try_into_kind() + } +} + +impl IntoKind for FloatDType { + fn try_into_kind(self) -> Result { + let dtype: DType = self.into(); + dtype.try_into_kind() + } +} + +impl IntoKind for DType { + fn try_into_kind(self) -> Result { + match self { + DType::F64 => Ok(tch::Kind::Double), + DType::F32 => Ok(tch::Kind::Float), + DType::Flex32 => Ok(tch::Kind::Float), + DType::F16 => Ok(tch::Kind::Half), + DType::BF16 => Ok(tch::Kind::BFloat16), + DType::I64 => Ok(tch::Kind::Int64), + // LibTorch backend currently forces I64 int dtype + // DType::I32 => Ok(tch::Kind::Int), + // DType::I16 => Ok(tch::Kind::Int16), + // DType::I8 => Ok(tch::Kind::Int8), + // DType::U8 => Ok(tch::Kind::Uint8), + DType::Bool(BoolStore::Native) => Ok(tch::Kind::Bool), + other => Err(tch::TchError::Kind(format!("Unsupported dtype {other:?}"))), + } + } +} + +impl TchTensor { + /// Create a new tensor. + /// + /// Note that if the tensor was created from an operation that may reuse the same tensor + /// storage as the parent, you should use [from_existing](TchTensor::from_existing) + /// instead. + pub fn new(tensor: tch::Tensor) -> Self { + #[allow(clippy::arc_with_non_send_sync)] + let storage = Storage::Owned { + buffer_ref: Arc::new(tensor.data_ptr()), + }; + + Self { tensor, storage } + } + + /// Create a tensor that was created from an operation executed on a parent tensor. + /// + /// If the child tensor shared the same storage as its parent, it will be cloned, effectively + /// tracking how much tensors point to the same memory space. + pub fn from_existing(tensor: tch::Tensor, storage_parent: Storage) -> Self { + let storage_child = tensor.data_ptr(); + let mut is_a_new_tensor = true; + + match &storage_parent { + Storage::View { + buffer_ref: start_ref, + view_ref, + } => { + if storage_child == *start_ref.as_ref() || storage_child == *view_ref.as_ref() { + is_a_new_tensor = false; + } + } + Storage::Owned { + buffer_ref: start_ref, + } => { + if storage_child == *start_ref.as_ref() { + is_a_new_tensor = false; + } + } + }; + + let storage = match is_a_new_tensor { + true => Storage::Owned { + #[allow(clippy::arc_with_non_send_sync)] + buffer_ref: Arc::new(storage_child), + }, + false => storage_parent.clone(), + }; + + Self { tensor, storage } + } + + /// Create a tensor that uses a part of its parent tensor such as slice and narrow. + pub fn partial(tensor: tch::Tensor, storage_parent: Storage) -> Self { + let storage = Storage::View { + buffer_ref: storage_parent.buffer_ref().clone(), + #[allow(clippy::arc_with_non_send_sync)] + view_ref: Arc::new(tensor.data_ptr()), + }; + Self { tensor, storage } + } +} + +// This is safe since we don't use autodiff from LibTorch. +// Also, atomic reference counting is used to know if the tensor's data can be reused. +// If there are multiple reference on the same tensor, it becomes read only. +unsafe impl Send for TchTensor {} +unsafe impl Sync for TchTensor {} + +impl TchTensor { + /// Checks if the tensor can be mutated in-place. + /// + /// Returns `true` if the tensor's stride does not contain zero (no broadcasting) + /// and the storage can be mutated. + pub fn can_mut(&self) -> bool { + let stride_contains_zero = self.tensor.stride().contains(&0); + + !stride_contains_zero && self.storage.can_mut() + } + + /// Executes an operation on a tensor if the data can be reused. + pub fn mut_ops tch::Tensor>( + &mut self, + func: F, + ) -> Option { + if !self.can_mut() { + return None; + } + + let data = self.storage.clone(); + Some(TchTensor::from_existing(func(&mut self.tensor), data)) + } + + /// Executes a unary operation, reusing the tensor data if possible. + pub fn unary_ops(self, fown: FOwn, fref: FRef) -> TchTensor + where + FOwn: Fn(tch::Tensor) -> tch::Tensor, + FRef: Fn(&tch::Tensor) -> tch::Tensor, + { + if !self.can_mut() { + return TchTensor::from_existing(fref(&self.tensor), self.storage); + } + + TchTensor::from_existing(fown(self.tensor), self.storage) + } + + /// Executes a binary operation, reusing the tensor data if possible. + pub fn binary_ops_tensor( + mut lhs: Self, + mut rhs: Self, + flmut: FLMut, + frmut: FRMut, + fref: FRef, + ) -> TchTensor + where + FLMut: Fn(&mut tch::Tensor, &tch::Tensor) -> tch::Tensor, + FRMut: Fn(&tch::Tensor, &mut tch::Tensor) -> tch::Tensor, + FRef: Fn(&tch::Tensor, &tch::Tensor) -> tch::Tensor, + { + let lhs_shape = lhs.shape(); + let rhs_shape = rhs.shape(); + + // Both lhs and rhs are expected to have the same rank + let d_out = lhs_shape.num_dims(); + let mut out_shape = Shape::from(vec![1usize; d_out]); + + for i in 0..d_out { + out_shape[i] = usize::max(lhs_shape[i], rhs_shape[i]); + } + + let num_elements_out = out_shape.num_elements(); + + // Attempt to mutate lhs tensor + if lhs_shape.num_elements() == num_elements_out + && let Some(output) = lhs.mut_ops(|lhs| flmut(lhs, &rhs.tensor)) + { + return output; + } + + // Attempt to mutate rhs tensor + if rhs_shape.num_elements() == num_elements_out + && let Some(output) = rhs.mut_ops(|rhs| frmut(&lhs.tensor, rhs)) + { + return output; + } + + let storage = lhs.storage; + let tensor = fref(&lhs.tensor, &rhs.tensor); + + TchTensor::from_existing(tensor, storage) + } +} + +impl Clone for TchTensor { + fn clone(&self) -> Self { + Self { + tensor: self.tensor.shallow_clone(), + storage: self.storage.clone(), + } + } +} + +/// A shape that can be used by LibTorch. +#[derive(Debug)] +pub struct TchShape { + /// The shape's dimensions. + pub dims: Vec, +} + +impl From for TchShape { + fn from(shape: Shape) -> Self { + TchShape { + dims: shape.iter().map(|d| *d as i64).collect(), + } + } +} + +impl From<&[usize]> for TchShape { + fn from(shape: &[usize]) -> Self { + TchShape { + dims: shape.iter().map(|d| *d as i64).collect(), + } + } +} + +impl TchTensor { + /// Creates a new tensor from a shape and a device. + /// + /// # Arguments + /// + /// * `data` - The tensor's data. + /// * `device` - The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// A new tensor. + pub fn from_data(data: TensorData, device: tch::Device) -> Self { + let shape_tch = TchShape::from(data.shape.as_slice()); + let tensor = + tch::Tensor::from_data_size(&data.bytes, &shape_tch.dims, E::kind()).to(device); + + Self::new(tensor) + } +} + +impl TchTensor { + /// Creates an empty tensor from a shape and a device. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// A new empty tensor. + pub fn empty(shape: Shape, device: LibTorchDevice, dtype: DType) -> Self { + let shape_tch = TchShape::from(shape); + let tensor = tch::Tensor::empty(shape_tch.dims, (dtype.into_kind(), device.into())); + + Self::new(tensor) + } +} + +// Adapted from `tch` to use patched `T::kind()` instead of `T::KIND` which is incorrect for bf16. +// TODO: remove when fixed in `tch` release (https://github.com/LaurentMazare/tch-rs/pull/996). +impl TryFrom<&TchTensor> for Vec { + type Error = tch::TchError; + fn try_from(tensor: &TchTensor) -> Result { + let tensor = &tensor.tensor; + let size = tensor.size(); + if size.len() != 1 { + Err(tch::TchError::Convert(format!( + "Attempting to convert a Tensor with {} dimensions to flat vector", + size.len() + )))?; + } + let numel = size[0] as usize; + let mut vec = vec![T::ZERO; numel]; + // Adapted to use patched `T::kind()` instead + // TODO: tensor.f_to_kind(T::KIND)?.f_copy_data(&mut vec, numel)?; + f_copy_data(&mut tensor.f_to_kind(T::kind())?, &mut vec, numel)?; + Ok(vec) + } +} + +unsafe fn ptr_to_string(ptr: *mut libc::c_char) -> Option { + if !ptr.is_null() { + unsafe { + let str = std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned(); + libc::free(ptr as *mut libc::c_void); + Some(str) + } + } else { + None + } +} + +/// Copies `numel` elements from `self` to `dst`. +fn f_copy_data( + tensor: &mut tch::Tensor, + dst: &mut [T], + numel: usize, +) -> Result<(), tch::TchError> { + if T::kind() != tensor.f_kind()? { + return Err(tch::TchError::Kind(format!( + "incoherent elt kind, {:?} != {:?}", + tensor.f_kind(), + T::kind() + ))); + } + if dst.len() < numel { + return Err(tch::TchError::Shape(format!("slice len < {numel}"))); + } + + unsafe { + torch_sys::at_copy_data( + tensor.as_mut_ptr(), + dst.as_mut_ptr() as *const c_void, + numel, + T::kind().elt_size_in_bytes(), + ); + match ptr_to_string(torch_sys::get_and_reset_last_err()) { + None => Ok(()), + Some(c_error) => Err(tch::TchError::Torch(c_error)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_backend::ops::FloatTensorOps; + use burn_backend::{Backend, quantization::QuantScheme, read_sync}; + + type B = crate::LibTorch; + + #[test] + fn should_have_bf16_kind() { + let data = TensorData::from([4.0, 4.0]); + let tensor_1: TchTensor = B::float_from_data(data, &Default::default()); + let tensor_2 = B::float_cast(tensor_1, DType::BF16.into()); + + assert_eq!(tensor_2.tensor.kind(), tch::Kind::BFloat16); + + let out = read_sync(B::float_into_data(tensor_2)).unwrap(); + + out.assert_eq(&TensorData::from([4.0, 4.0]), false); + } + + #[test] + fn should_support_dtypes() { + let device = Default::default(); + + assert!(B::supports_dtype(&device, DType::F64)); + assert!(B::supports_dtype(&device, DType::F32)); + assert!(B::supports_dtype(&device, DType::Flex32)); + assert!(B::supports_dtype(&device, DType::F16)); + assert!(B::supports_dtype(&device, DType::BF16)); + assert!(B::supports_dtype(&device, DType::I64)); + assert!(B::supports_dtype(&device, DType::I32)); + assert!(B::supports_dtype(&device, DType::I16)); + assert!(B::supports_dtype(&device, DType::I8)); + assert!(B::supports_dtype(&device, DType::U8)); + assert!(B::supports_dtype(&device, DType::Bool(BoolStore::Native))); + + assert!(!B::supports_dtype(&device, DType::U64)); + assert!(!B::supports_dtype(&device, DType::U32)); + assert!(!B::supports_dtype(&device, DType::U16)); + assert!(!B::supports_dtype( + &device, + DType::QFloat(QuantScheme::default()) + )); + } + + #[test] + fn should_support_from_bf16() { + let data = TensorData::from([[1.0], [1.]]).convert_dtype(DType::BF16); + let tensor_1: TchTensor = B::float_from_data(data, &Default::default()); + let data = TensorData::from([[2.0], [2.]]).convert_dtype(DType::BF16); + let tensor_2 = B::float_from_data(data, &Default::default()); + + let tensor_3 = B::float_add(tensor_1, tensor_2); + + assert_eq!(tensor_3.tensor.kind(), tch::Kind::BFloat16); + + let out = read_sync(B::float_into_data(tensor_3)).unwrap(); + + out.assert_eq(&TensorData::from([[3.0], [3.0]]), false); + } +} + +unsafe extern "C" { + /// Dummy function to get CUDA to link properly + pub fn dummy_cuda_dependency(); +} + +#[used] +static INIT_ARRAY: [unsafe extern "C" fn(); 1] = [dummy_cuda_dependency]; diff --git a/crates/burn-tensor/Cargo.toml b/crates/burn-tensor/Cargo.toml new file mode 100644 index 0000000..ed0416b --- /dev/null +++ b/crates/burn-tensor/Cargo.toml @@ -0,0 +1,80 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science", "no-std", "embedded", "wasm"] +description = "Tensor library with user-friendly APIs and automatic differentiation support" +documentation = "https://docs.rs/burn-tensor" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-tensor" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-tensor" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["std", "burn-dispatch/default"] +doc = ["default"] +std = [ + "num-traits/std", + "burn-std/std", + "burn-backend/std", + "burn-dispatch/std", + "colored", +] +tracing = ["burn-std/tracing", "burn-backend/tracing", "burn-dispatch/tracing"] + +cubecl = ["burn-backend/cubecl"] +cubecl-cuda = ["burn-backend/cubecl-cuda"] +cubecl-hip = ["burn-backend/cubecl-hip"] +cubecl-wgpu = ["burn-backend/cubecl-wgpu"] +cubecl-metal = ["burn-backend/cubecl-metal"] +cubecl-vulkan = ["burn-backend/cubecl-vulkan"] +cubecl-webgpu = ["burn-backend/cubecl-webgpu"] +cubecl-cpu = ["burn-backend/cubecl-cpu"] + +# Backends +cuda = ["burn-dispatch/cuda", "cubecl-cuda"] +rocm = ["burn-dispatch/rocm", "cubecl-hip"] +flex = ["burn-dispatch/flex"] +ndarray = ["burn-dispatch/ndarray"] +tch = ["burn-dispatch/tch"] +wgpu = ["burn-dispatch/wgpu", "cubecl-wgpu"] +vulkan = ["wgpu", "burn-dispatch/vulkan", "cubecl-vulkan"] +webgpu = ["wgpu", "burn-dispatch/webgpu", "cubecl-webgpu"] +metal = ["wgpu", "burn-dispatch/metal", "cubecl-metal"] +cpu = ["burn-dispatch/cpu", "cubecl-cpu"] +autodiff = ["burn-dispatch/autodiff"] +# Remote compute client over Iroh. +remote = ["std", "burn-dispatch/remote"] +# Host a remote compute server over Iroh. Wasm-compatible. +remote-server = ["remote", "burn-dispatch/remote-server"] +# Add the legacy WebSocket transport. +remote-websocket = ["remote", "burn-dispatch/remote-websocket"] + +# For backend extensions +extension = [] + +# Backend features +autotune = ["burn-dispatch/autotune"] +autotune-checks = ["burn-dispatch/autotune-checks"] +fusion = ["burn-dispatch/fusion"] + +[dependencies] +burn-std = { workspace = true } +burn-backend = { workspace = true } +burn-dispatch = { workspace = true } +burn-backend-extension = { workspace = true, optional = true } + +colored = { workspace = true, optional = true } +derive-new = { workspace = true } +num-traits = { workspace = true } + +# Serialization +serde = { workspace = true } + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs", "--html-in-header", "katex-header.html"] diff --git a/crates/burn-tensor/LICENSE-APACHE b/crates/burn-tensor/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-tensor/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-tensor/LICENSE-MIT b/crates/burn-tensor/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-tensor/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-tensor/README.md b/crates/burn-tensor/README.md new file mode 100644 index 0000000..b97bffd --- /dev/null +++ b/crates/burn-tensor/README.md @@ -0,0 +1,12 @@ +# Burn Tensor + +> [Burn](https://github.com/tracel-ai/burn) Tensor Library + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-tensor.svg)](https://crates.io/crates/burn-tensor) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-tensor/blob/master/README.md) + +This library provides the core abstractions required to run tensor operations with Burn. + +`Tensor`s are generic over the backend to allow users to perform operations using different +`Backend` implementations. Burn's tensors also support auto-differentiation thanks to the +`AutodiffBackend` trait. diff --git a/crates/burn-tensor/katex-header.html b/crates/burn-tensor/katex-header.html new file mode 120000 index 0000000..4a9eb97 --- /dev/null +++ b/crates/burn-tensor/katex-header.html @@ -0,0 +1 @@ +../../docs/katex-header.html \ No newline at end of file diff --git a/crates/burn-tensor/src/bridge/kind.rs b/crates/burn-tensor/src/bridge/kind.rs new file mode 100644 index 0000000..dda581d --- /dev/null +++ b/crates/burn-tensor/src/bridge/kind.rs @@ -0,0 +1,365 @@ +use alloc::vec::Vec; +use burn_backend::{TensorMetadata, TensorPrimitive, get_device_settings}; +use burn_dispatch::{Dispatch, DispatchTensor}; +use burn_std::DeviceSettings; + +/// A type-level representation of the kind of a float tensor +#[derive(Clone, Debug)] +pub struct Float; + +/// A type-level representation of the kind of a int tensor. +#[derive(Clone, Debug)] +pub struct Int; + +/// A type-level representation of the kind of a bool tensor. +#[derive(Clone, Debug)] +pub struct Bool; + +mod sealed { + pub trait Sealed {} +} + +impl sealed::Sealed for Float {} +impl sealed::Sealed for Int {} +impl sealed::Sealed for Bool {} + +/// A type-level representation of the kind of a tensor. +/// Metadata access is lazy. +/// +/// # Notes +/// This trait is intentionally sealed to keep the set of tensor kinds closed. +/// +/// Although exposed publicly, tensor kinds are not meant to be extensible: +/// the backend dispatch system, `DType`, and all tensor ops assume a fixed, +/// closed set of tensor kinds (e.g. Float, Int, Bool), each mapping directly to a +/// corresponding backend implementation. +pub trait TensorKind: sealed::Sealed + Clone + Send + Sync + core::fmt::Debug { + /// The tensor kind identifier. + const KIND: Kind; + + /// The name of the tensor kind. + fn name() -> &'static str { + Self::KIND.as_str() + } +} + +impl TensorKind for Float { + const KIND: Kind = Kind::Float; +} + +impl TensorKind for Int { + const KIND: Kind = Kind::Int; +} + +impl TensorKind for Bool { + const KIND: Kind = Kind::Bool; +} + +/// Represents the kind of a [`Tensor`](crate::Tensor). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// A float tensor kind. + Float, + /// An integer tensor kind. + Int, + /// A boolean tensor kind. + Bool, +} + +impl Kind { + /// Get the string representation of the [`Kind`]. + pub fn as_str(&self) -> &'static str { + match self { + Kind::Float => "Float", + Kind::Int => "Int", + Kind::Bool => "Bool", + } + } +} + +/// A type-tagged tensor at the bridge layer between the high-level tensor API +/// and the dispatch system. +/// +/// `BridgeTensor` serves as the runtime representation for the public tensor +/// kinds (Float, Int, Bool) and internal variants like quantized floats, wrapping +/// the uniform [`DispatchTensor`] used by the underlying dispatch layer. This +/// separation keeps tensor kind tracking out of the backends while avoiding +/// exposure of backend-level primitives in the public API. +pub struct BridgeTensor { + blob: bridge_opaque::Opaque, +} + +// Aligned, type-erased storage for `BridgeTensorVariant`. See `crate::macros` +// for why this indirection exists (it keeps the dispatch type tree out of +// downstream MIR). +burn_std::obfuscate!( + type: BridgeTensorVariant, + module: bridge_opaque, + derives: [Send, Sync] +); + +impl core::fmt::Debug for BridgeTensor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BridgeTensor") + .field("kind", &self.kind()) + .finish() + } +} + +impl Clone for BridgeTensor { + fn clone(&self) -> Self { + Self::new(self.as_variant().clone()) + } +} + +impl BridgeTensor { + fn as_variant(&self) -> &BridgeTensorVariant { + self.blob.as_ref() + } + + fn into_variant(self) -> BridgeTensorVariant { + self.blob.into_inner() + } + + fn new(inner: BridgeTensorVariant) -> Self { + Self { + blob: bridge_opaque::Opaque::new(inner), + } + } +} + +#[derive(Clone, Debug)] +/// Private type obfucated by Blob. +enum BridgeTensorVariant { + /// A boolean tensor. + Bool(DispatchTensor), + /// An integer tensor. + Int(DispatchTensor), + /// A floating-point tensor. + Float(DispatchTensor), + /// A quantized floating-point tensor. + QFloat(DispatchTensor), +} + +/// Runtime tag identifying which variant a [`BridgeTensor`] wraps. +/// +/// Exposed so callers can dispatch on the variant without having to reach the +/// private [`BridgeTensorVariant`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BridgeKind { + /// A boolean tensor. + Bool, + /// An integer tensor. + Int, + /// A floating-point tensor. + Float, + /// A quantized floating-point tensor. + QFloat, +} + +/// Switches visibility based on the `extension` feature: `pub` when enabled +/// (so backend-extension authors can call these), `pub(crate)` otherwise (so +/// burn-tensor's own ops keep working without leaking the dispatch types). +macro_rules! ext_fn { + ($(#[$meta:meta])* fn $($tt:tt)+) => { + #[cfg(feature = "extension")] + $(#[$meta])* + pub fn $($tt)+ + #[cfg(not(feature = "extension"))] + $(#[$meta])* + pub(crate) fn $($tt)+ + }; +} + +impl BridgeTensor { + ext_fn! { + /// Builds a bridge tensor that wraps a floating-point dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn float(tensor: DispatchTensor) -> Self { + Self::new(BridgeTensorVariant::Float(tensor)) + } + } + + ext_fn! { + /// Builds a bridge tensor that wraps an integer dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn int(tensor: DispatchTensor) -> Self { + Self::new(BridgeTensorVariant::Int(tensor)) + } + } + + ext_fn! { + /// Builds a bridge tensor that wraps a boolean dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn bool(tensor: DispatchTensor) -> Self { + Self::new(BridgeTensorVariant::Bool(tensor)) + } + } + + ext_fn! { + /// Builds a bridge tensor that wraps a quantized floating-point dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn qfloat(tensor: DispatchTensor) -> Self { + Self::new(BridgeTensorVariant::QFloat(tensor)) + } + } + + /// Returns the runtime tag identifying which variant this tensor wraps. + pub fn kind(&self) -> BridgeKind { + match self.as_variant() { + BridgeTensorVariant::Bool(_) => BridgeKind::Bool, + BridgeTensorVariant::Int(_) => BridgeKind::Int, + BridgeTensorVariant::Float(_) => BridgeKind::Float, + BridgeTensorVariant::QFloat(_) => BridgeKind::QFloat, + } + } + + /// Returns `true` if this tensor is the float variant. + pub fn is_float(&self) -> bool { + matches!(self.kind(), BridgeKind::Float) + } + + /// Returns `true` if this tensor is the int variant. + pub fn is_int(&self) -> bool { + matches!(self.kind(), BridgeKind::Int) + } + + /// Returns `true` if this tensor is the bool variant. + pub fn is_bool(&self) -> bool { + matches!(self.kind(), BridgeKind::Bool) + } + + /// Returns `true` if this tensor is the quantized float variant. + pub fn is_qfloat(&self) -> bool { + matches!(self.kind(), BridgeKind::QFloat) + } + + ext_fn! { + /// Consumes the bridge tensor and returns its variant tag together with + /// the underlying dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn into_parts(self) -> (BridgeKind, DispatchTensor) { + match self.into_variant() { + BridgeTensorVariant::Bool(t) => (BridgeKind::Bool, t), + BridgeTensorVariant::Int(t) => (BridgeKind::Int, t), + BridgeTensorVariant::Float(t) => (BridgeKind::Float, t), + BridgeTensorVariant::QFloat(t) => (BridgeKind::QFloat, t), + } + } + } + + ext_fn! { + /// Borrows the bridge tensor as its variant tag together with a reference + /// to the underlying dispatch tensor. + /// + /// Available with the `extension` feature for backend-extension authors. + fn as_parts(&self) -> (BridgeKind, &DispatchTensor) { + match self.as_variant() { + BridgeTensorVariant::Bool(t) => (BridgeKind::Bool, t), + BridgeTensorVariant::Int(t) => (BridgeKind::Int, t), + BridgeTensorVariant::Float(t) => (BridgeKind::Float, t), + BridgeTensorVariant::QFloat(t) => (BridgeKind::QFloat, t), + } + } + } + + /// Returns the dtype of the tensor. + pub fn dtype(&self) -> burn_std::DType { + match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor.dtype(), + BridgeTensorVariant::Int(tensor) => tensor.dtype(), + BridgeTensorVariant::Float(tensor) => tensor.dtype(), + BridgeTensorVariant::QFloat(tensor) => tensor.dtype(), + } + } + + /// Whether the tensor's buffer can be mutated in place (see + /// [`TensorMetadata::can_mut`]). + pub fn can_mut(&self) -> bool { + match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor.can_mut(), + BridgeTensorVariant::Int(tensor) => tensor.can_mut(), + BridgeTensorVariant::Float(tensor) => tensor.can_mut(), + BridgeTensorVariant::QFloat(tensor) => tensor.can_mut(), + } + } + + /// Returns the shape of the tensor. + pub fn shape(&self) -> burn_std::Shape { + match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor.shape(), + BridgeTensorVariant::Int(tensor) => tensor.shape(), + BridgeTensorVariant::Float(tensor) => tensor.shape(), + BridgeTensorVariant::QFloat(tensor) => tensor.shape(), + } + } + + /// Returns the number of dimensions of the tensor. + pub fn rank(&self) -> usize { + match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor.rank(), + BridgeTensorVariant::Int(tensor) => tensor.rank(), + BridgeTensorVariant::Float(tensor) => tensor.rank(), + BridgeTensorVariant::QFloat(tensor) => tensor.rank(), + } + } + + pub(crate) fn as_dispatch(&self) -> &DispatchTensor { + match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor, + BridgeTensorVariant::Int(tensor) => tensor, + BridgeTensorVariant::Float(tensor) => tensor, + BridgeTensorVariant::QFloat(tensor) => tensor, + } + } + + #[cfg(feature = "autodiff")] + pub(crate) fn as_float(&self) -> &DispatchTensor { + match self.as_variant() { + BridgeTensorVariant::Float(tensor) => tensor, + _ => panic!("Should be Float primitive kind"), + } + } + + pub(crate) fn into_dispatch_vec(tensors: Vec) -> Vec { + tensors.into_iter().map(Into::into).collect() + } + + pub(crate) fn into_float(self) -> DispatchTensor { + match self.into_variant() { + BridgeTensorVariant::Float(tensor) => tensor, + // Returns the dequantized float tensor. + BridgeTensorVariant::QFloat(tensor) => { + TensorPrimitive::::QFloat(tensor).tensor() + } + _ => panic!("Should be Float primitive kind"), + } + } + + pub(crate) fn device_settings(&self) -> DeviceSettings { + let device = match self.as_variant() { + BridgeTensorVariant::Bool(tensor) => tensor.device(), + BridgeTensorVariant::Int(tensor) => tensor.device(), + BridgeTensorVariant::Float(tensor) => tensor.device(), + BridgeTensorVariant::QFloat(tensor) => tensor.device(), + }; + + get_device_settings::(&device) + } +} + +impl From for DispatchTensor { + fn from(value: BridgeTensor) -> Self { + match value.into_variant() { + BridgeTensorVariant::Bool(tensor) => tensor, + BridgeTensorVariant::Int(tensor) => tensor, + BridgeTensorVariant::Float(tensor) => tensor, + BridgeTensorVariant::QFloat(tensor) => tensor, + } + } +} diff --git a/crates/burn-tensor/src/bridge/mod.rs b/crates/burn-tensor/src/bridge/mod.rs new file mode 100644 index 0000000..1245fa5 --- /dev/null +++ b/crates/burn-tensor/src/bridge/mod.rs @@ -0,0 +1,5 @@ +mod kind; +mod ops; + +pub use kind::*; +pub(crate) use ops::*; diff --git a/crates/burn-tensor/src/bridge/ops/autodiff.rs b/crates/burn-tensor/src/bridge/ops/autodiff.rs new file mode 100644 index 0000000..75ac383 --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/autodiff.rs @@ -0,0 +1,32 @@ +use crate::{bridge::BasicOps, ops::BridgeTensor}; + +/// Trait that list all operations that can be applied on all tensors on an autodiff backend. +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait BasicAutodiffOps: BasicOps { + /// Returns the inner tensor without the autodiff information. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// Users should prefer the [`Tensor::inner`](crate::Tensor::inner) + /// function, which is more high-level and designed for public use. + fn inner(tensor: BridgeTensor) -> BridgeTensor; + + /// Convert a tensor to the autodiff backend. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// Users should prefer the [`Tensor::from_inner`](crate::Tensor::from_inner) + /// function, which is more high-level and designed for public use. + fn from_inner(inner: BridgeTensor) -> BridgeTensor; +} diff --git a/crates/burn-tensor/src/bridge/ops/base.rs b/crates/burn-tensor/src/bridge/ops/base.rs new file mode 100644 index 0000000..2eee9cc --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/base.rs @@ -0,0 +1,727 @@ +use alloc::vec::Vec; +use burn_backend::{Scalar, TensorData, ops::TransactionPrimitive}; +use burn_dispatch::Dispatch; +use burn_std::{DType, ExecutionError, IndexingUpdateOp, Shape, Slice}; + +use crate::{ + Device, + ops::{BridgeTensor, TensorKind}, +}; + +/// Trait for the one basic op that still requires Backend +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait TransactionOp: BasicOps { + /// Read the data from the tensor using a transaction. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + fn register_transaction(tr: &mut TransactionPrimitive, tensor: BridgeTensor); +} + +/// Trait that list all operations that can be applied on all tensors. +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait BasicOps: TensorKind { + /// Creates an empty tensor with the given shape. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device on which the tensor will be allocated. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The empty tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For creating empty tensors, users should prefer the [`Tensor::empty`](crate::Tensor::empty) + /// function, which is more high-level and designed for public use. + fn empty(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor; + + /// Creates a tensor filled with zeros. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device on which the tensor will be allocated. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor filled with zeros. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For creating a tensor filled with zeros, users should prefer the [`Tensor::zeros`](crate::Tensor::zeros) + /// function, which is more high-level and designed for public use. + fn zeros(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor; + + /// Creates a tensor filled with ones. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `device` - The device on which the tensor will be allocated. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor filled with ones. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For creating a tensor filled with ones, users should prefer the [`Tensor::ones`](crate::Tensor::ones) + /// function, which is more high-level and designed for public use. + fn ones(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor; + + /// Creates a tensor of the given shape where each element is equal to the provided value. + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `fill_value` - The value with which to fill the tensor. + /// * `device` - The device on which the tensor will be allocated. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// The tensor filled with the specified value. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For creating full tensors, users should prefer the [`Tensor::full`](crate::Tensor::full) + /// function, which is more high-level and designed for public use. + fn full(shape: Shape, fill_value: Scalar, device: &Device, dtype: DType) -> BridgeTensor; + + /// Reshapes the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `shape` - The new shape of the tensor. + /// + /// # Returns + /// + /// The reshaped tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For reshaping a tensor, users should prefer the [`Tensor::reshape`](crate::Tensor::reshape) + /// function, which is more high-level and designed for public use. + fn reshape(tensor: BridgeTensor, shape: Shape) -> BridgeTensor; + + /// Transposes a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + fn transpose(tensor: BridgeTensor) -> BridgeTensor; + + /// Swaps two dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap. + /// * `dim2` - The second dimension to swap. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + fn swap_dims(tensor: BridgeTensor, dim1: usize, dim2: usize) -> BridgeTensor; + + /// Permutes the dimensions of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to permute the dimensions of. + /// * `axes` - The new order of the dimensions. + /// + /// # Returns + /// + /// The tensor with the dimensions permuted. + fn permute(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor; + + /// Flips the tensor along the given axes. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to flip. + /// * `axes` - The axes to flip the tensor along. + /// + /// # Returns + /// + /// The tensor with the axes flipped. + fn flip(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor; + + /// Select tensor elements corresponding to the given slices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `slices` - The slices specifying ranges and steps for each dimension. + /// + /// # Returns + /// + /// The selected elements. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For selecting elements of a tensor, users should prefer the [`Tensor::slice`](crate::Tensor::slice) + /// function, which is more high-level and designed for public use. + fn slice(tensor: BridgeTensor, slices: &[Slice]) -> BridgeTensor; + + /// Assigns the given value to the tensor elements corresponding to the given slices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `slices` - The slices specifying which elements to assign, including support for steps. + /// * `value` - The value to assign. + /// + /// # Returns + /// + /// The tensor with the assigned values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For assigning values to elements of a tensor, users should prefer the [`Tensor::slice_assign`](crate::Tensor::slice_assign) + /// function, which is more high-level and designed for public use. + fn slice_assign(tensor: BridgeTensor, slices: &[Slice], value: BridgeTensor) -> BridgeTensor; + + /// Select tensor elements along the given dimension corresponding to the given indices. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select from. + /// * `dim` - The dimension along which to select. + /// * `indices` - The indices of the elements to select. + /// + /// # Returns + /// + /// The selected tensor elements. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For selecting elements from a tensor along an axis, users should prefer the [`Tensor::select`](crate::Tensor::select) + /// function, which is more high-level and designed for public use. + fn select(tensor: BridgeTensor, dim: usize, indices: BridgeTensor) -> BridgeTensor; + + /// Assign the selected elements along the given dimension corresponding to the given indices + /// from the value tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to assign elements to. + /// * `dim` - The axis along which to assign elements. + /// * `indices` - The indices of the elements to assign. + /// * `values` - The values to assign to the tensor. + /// * `update` - The operation used to update the existing values at the indexed positions (e.g., add). + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is taken from the + /// corresponding element of the input tensor at the corresponding index along the specified axis, + /// except for the elements at the specified indices, which are taken from the corresponding + /// element of the values tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For assigning elements to a tensor along an axis, users should prefer the [`Tensor::select_assign`](crate::Tensor::select_assign) + /// function, which is more high-level and designed for public use. + fn select_assign( + tensor: BridgeTensor, + dim: usize, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor; + + /// Selects elements from a tensor based on a boolean mask. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to select elements from if the corresponding element of the mask is true. + /// * `mask` - The boolean mask to use for selecting elements. + /// * `source` - The tensor to select elements from when the corresponding element of the mask is false. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensors, where each element is taken from the + /// corresponding element of the left hand side tensor if the corresponding element of the mask + /// is true, and from the corresponding element of the right hand side tensor otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For selecting elements from a tensor based on a boolean mask, users should prefer the [`Tensor::mask_where`](crate::Tensor::mask_where) + /// function, which is more high-level and designed for public use. + fn mask_where(tensor: BridgeTensor, mask: BridgeTensor, source: BridgeTensor) -> BridgeTensor; + + /// Fills elements of a tensor based on a boolean mask. + /// + /// # Arguments + /// + /// * `tensor` - The tensor where will be overwritten with the value + /// when the corresponding element of the mask is true. + /// * `mask` - The boolean mask to use for filling elements. + /// * `value` - The value to fill elements with when the corresponding element of the mask is true. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensors, where each element is taken from the + /// corresponding element unmodified if the corresponding element of the mask is false, and + /// filled with the value otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For filling elements of a tensor based on a boolean mask, users should prefer the [`Tensor::mask_fill`](crate::Tensor::mask_fill) + /// function, which is more high-level and designed for public use. + fn mask_fill(tensor: BridgeTensor, mask: BridgeTensor, value: Scalar) -> BridgeTensor; + + /// Gathers elements from a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to gather elements. + /// * `tensor` - The tensor to gather elements from. + /// * `indices` - The indices of the elements to gather. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is taken from the + /// corresponding element of the input tensor at the corresponding index along the specified axis. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For gathering elements from a tensor along an axis, users should prefer the [`Tensor::gather`](crate::Tensor::gather) + /// function, which is more high-level and designed for public use. + fn gather(dim: usize, tensor: BridgeTensor, indices: BridgeTensor) -> BridgeTensor; + + /// Scatters elements into a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to scatter elements. + /// * `tensor` - The tensor to scatter elements into. + /// * `indices` - The indices of the elements to scatter. + /// * `values` - The values to scatter into the tensor. + /// * `update` - The operation used to update the existing values at the indexed positions (e.g., add). + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is taken from the + /// corresponding element of the input tensor at the corresponding index along the specified axis, + /// except for the elements at the specified indices, which are taken from the corresponding + /// element of the values tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For scattering elements into a tensor along an axis, users should prefer the [`Tensor::scatter`](crate::Tensor::scatter) + /// function, which is more high-level and designed for public use. + fn scatter( + dim: usize, + tensor: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor; + + /// Multi-dimensional scatter: update `data` at multi-index locations specified by `indices`. + fn scatter_nd( + data: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + reduction: IndexingUpdateOp, + ) -> BridgeTensor; + + /// Multi-dimensional gather: collect slices from `data` at multi-index locations. + fn gather_nd(data: BridgeTensor, indices: BridgeTensor) -> BridgeTensor; + + /// Returns the device on which the tensor is allocated. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The device on which the tensor is allocated. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the device of a tensor, users should prefer the [`Tensor::device`](crate::Tensor::device) + /// function, which is more high-level and designed for public use. + fn device(tensor: &BridgeTensor) -> Device; + + /// Moves the tensor to the given device. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `device` - The device on which the tensor will be moved. + /// + /// # Returns + /// + /// The tensor on the given device. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For moving a tensor to a device, users should prefer the [`Tensor::to_device`](crate::Tensor::to_device) + /// function, which is more high-level and designed for public use. + #[allow(clippy::wrong_self_convention)] + fn to_device(tensor: BridgeTensor, device: &Device) -> BridgeTensor; + + /// Extracts the data from the tensor asynchronously. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The data of the tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For extracting the data of a tensor, users should prefer the [`Tensor::into_data`](crate::Tensor::into_data) + /// function, which is more high-level and designed for public use. + #[allow(clippy::wrong_self_convention)] + fn into_data_async( + tensor: BridgeTensor, + ) -> impl Future> + Send; + + /// Creates a tensor from the given data enforcing the provided data type. + /// + /// # Arguments + /// + /// * `data` - The data of the tensor. + /// * `device` - The device on which the tensor will be allocated. + /// * `dtype` - The target data type. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For creating a tensor from data, users should prefer the [`Tensor::from_data`](crate::Tensor::from_data) + /// function, which is more high-level and designed for public use. + fn from_data(data: TensorData, device: &Device, dtype: DType) -> BridgeTensor; + + /// Repeat the tensor along the given dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// * `dim` - The dimension along which the tensor will be repeated. + /// * `times` - The number of times the tensor will be repeated. + /// + /// # Returns + /// + /// The repeated tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For repeating a tensor, users should prefer the [`Tensor::repeat_dim`](crate::Tensor::repeat_dim) + /// function, which is more high-level and designed for public use. + fn repeat_dim(tensor: BridgeTensor, dim: usize, times: usize) -> BridgeTensor; + + /// Concatenates the given tensors along the given dimension. + /// + /// # Arguments + /// + /// * `vectors` - The tensors to concatenate. + /// * `dim` - The dimension along which the tensors will be concatenated. + /// + /// # Returns + /// + /// The concatenated tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For concatenating tensors, users should prefer the [`Tensor::cat`](crate::Tensor::cat) + /// function, which is more high-level and designed for public use. + fn cat(vectors: Vec, dim: usize) -> BridgeTensor; + + /// Equates the given tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor of booleans indicating whether the corresponding elements are equal. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For equating tensors, users should prefer the [`Tensor::equal`](crate::Tensor::equal) + /// function, which is more high-level and designed for public use. + fn equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise equality between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding elements of the input tensors are equal, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise equality between two tensors, users should prefer the [`Tensor::equal_elem`](crate::Tensor::equal_elem) + /// function, which is more high-level and designed for public use. + fn equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Applies element-wise non-equality comparison between the given tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The tensor of booleans indicating whether the corresponding elements are equal. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For non-equality comparison of tensors, users should prefer the [`Tensor::not_equal`](crate::Tensor::not_equal) + /// function, which is more high-level and designed for public use. + fn not_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise non-equality between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding elements of the input tensors are equal, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise non-equality between two tensors, users should prefer the [`Tensor::not_equal_elem`](crate::Tensor::not_equal_elem) + /// function, which is more high-level and designed for public use. + fn not_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Tests if any element in the `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if any element in the input tensor evaluates to True, False otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. Users should prefer the [`Tensor::any`](crate::Tensor::any) + /// function, which is more high-level and designed for public use. + fn any(tensor: BridgeTensor) -> BridgeTensor; + + /// Tests if any element in the tensor evaluates to True along a given dimension dim. + /// + /// # Arguments + /// + /// * tensor - The tensor to test. + /// * dim - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor with the same size as input tensor, except in the dim axis where the size is 1. + /// Returns True if any element in the input tensor along the given dimension evaluates to True, False otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. Users should prefer the [`Tensor::any_dim`](crate::Tensor::any_dim) + /// function, which is more high-level and designed for public use. + fn any_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Tests if all elements in the `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with a single element, True if all elements in the input tensor evaluates to True, False otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. Users should prefer the [`Tensor::all`](crate::Tensor::all) + /// function, which is more high-level and designed for public use. + fn all(tensor: BridgeTensor) -> BridgeTensor; + + /// Tests if all elements in the `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. + /// + /// # Returns + /// + /// A boolean tensor with the same size as input `tensor`, except in the `dim` axis where the size is 1. + /// Returns True if all elements in the input tensor along the given dimension evaluate to True, False otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. Users should prefer the [`Tensor::all_dim`](crate::Tensor::all_dim) + /// function, which is more high-level and designed for public use. + fn all_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Broadcasts the given tensor to the specified shape. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to broadcast. + /// * `shape` - The shape to broadcast to. + /// + /// # Returns + /// + /// The broadcasted tensor. + fn expand(tensor: BridgeTensor, shape: Shape) -> BridgeTensor; + + /// Unfold windows along a dimension. + /// + /// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// # Warning + /// + /// For the `ndarray` and `candle` backends; this is not a view but a full copy. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor to unfold; of shape ``[pre=..., dim shape, post=...]`` + /// * `dim` - the dimension to unfold. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with shape ``[pre=..., windows, post=..., size]``. + fn unfold(tensor: BridgeTensor, dim: usize, size: usize, step: usize) -> BridgeTensor; +} diff --git a/crates/burn-tensor/src/bridge/ops/bool.rs b/crates/burn-tensor/src/bridge/ops/bool.rs new file mode 100644 index 0000000..1c65952 --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/bool.rs @@ -0,0 +1,256 @@ +use alloc::vec::Vec; +use burn_backend::{ + AutodiffBackend, Scalar, TensorData, TensorMetadata, + ops::{BoolTensorOps, TransactionPrimitive}, +}; +use burn_dispatch::Dispatch; +use burn_std::{DType, ExecutionError, IndexingUpdateOp, Shape, Slice}; + +use crate::{ + Bool, Device, + bridge::{BasicAutodiffOps, BasicOps, TransactionOp}, + ops::BridgeTensor, +}; + +impl TransactionOp for Bool { + fn register_transaction(tr: &mut TransactionPrimitive, tensor: BridgeTensor) { + tr.register_bool(tensor.into()); + } +} + +impl BasicOps for Bool { + fn empty(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + if !dtype.is_bool() { + panic!("Expected bool data type, got {dtype:?}"); + } + BridgeTensor::bool(Dispatch::bool_empty( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn zeros(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + if !dtype.is_bool() { + panic!("Expected bool data type, got {dtype:?}"); + } + BridgeTensor::bool(Dispatch::bool_zeros( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + fn ones(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + if !dtype.is_bool() { + panic!("Expected bool data type, got {dtype:?}"); + } + BridgeTensor::bool(Dispatch::bool_ones( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn full(shape: Shape, fill_value: Scalar, device: &Device, dtype: DType) -> BridgeTensor { + if !dtype.is_bool() { + panic!("Expected bool data type, got {dtype:?}"); + } + if fill_value.elem() { + BridgeTensor::bool(Dispatch::bool_ones( + shape, + device.as_dispatch(), + dtype.into(), + )) + } else { + BridgeTensor::bool(Dispatch::bool_zeros( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + } + + fn reshape(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_reshape(tensor.into(), shape)) + } + + fn transpose(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_transpose(tensor.into())) + } + + fn swap_dims(tensor: BridgeTensor, dim1: usize, dim2: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_swap_dims(tensor.into(), dim1, dim2)) + } + + fn slice(tensor: BridgeTensor, slices: &[Slice]) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_slice(tensor.into(), slices)) + } + + fn slice_assign(tensor: BridgeTensor, slices: &[Slice], value: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_slice_assign( + tensor.into(), + slices, + value.into(), + )) + } + + fn select(tensor: BridgeTensor, dim: usize, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_select(tensor.into(), dim, indices.into())) + } + + fn select_assign( + tensor: BridgeTensor, + dim: usize, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + match update { + IndexingUpdateOp::Add => BridgeTensor::bool(Dispatch::bool_select_or( + tensor.into(), + dim, + indices.into(), + values.into(), + )), + _ => unimplemented!(), + } + } + + fn mask_where(tensor: BridgeTensor, mask: BridgeTensor, source: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_mask_where( + tensor.into(), + mask.into(), + source.into(), + )) + } + + fn mask_fill(tensor: BridgeTensor, mask: BridgeTensor, value: Scalar) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_mask_fill(tensor.into(), mask.into(), value)) + } + + fn gather(dim: usize, tensor: BridgeTensor, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_gather(dim, tensor.into(), indices.into())) + } + + fn scatter( + dim: usize, + tensor: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + match update { + IndexingUpdateOp::Add => BridgeTensor::bool(Dispatch::bool_scatter_or( + dim, + tensor.into(), + indices.into(), + values.into(), + )), + _ => unimplemented!(), + } + } + + fn scatter_nd( + _data: BridgeTensor, + _indices: BridgeTensor, + _values: BridgeTensor, + _reduction: IndexingUpdateOp, + ) -> BridgeTensor { + panic!("scatter_nd is not supported for bool tensors") + } + + fn gather_nd(_data: BridgeTensor, _indices: BridgeTensor) -> BridgeTensor { + panic!("gather_nd is not supported for bool tensors") + } + + fn device(tensor: &BridgeTensor) -> Device { + Device::new(tensor.as_dispatch().device()) + } + + fn to_device(tensor: BridgeTensor, device: &Device) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_to_device( + tensor.into(), + device.as_dispatch(), + )) + } + + async fn into_data_async(tensor: BridgeTensor) -> Result { + Dispatch::bool_into_data(tensor.into()).await + } + + fn from_data(data: TensorData, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_from_data( + data.convert_dtype(dtype), + device.as_dispatch(), + )) + } + + fn repeat_dim(tensor: BridgeTensor, dim: usize, times: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_repeat_dim(tensor.into(), dim, times)) + } + + fn equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_equal(lhs.into(), rhs.into())) + } + + fn not_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_not_equal(lhs.into(), rhs.into())) + } + + fn equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_equal_elem(lhs.into(), rhs)) + } + + fn not_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_not_equal_elem(lhs.into(), rhs)) + } + + fn cat(vectors: Vec, dim: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_cat( + BridgeTensor::into_dispatch_vec(vectors), + dim, + )) + } + + fn any(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_any(tensor.into())) + } + + fn any_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_any_dim(tensor.into(), dim)) + } + + fn all(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_all(tensor.into())) + } + + fn all_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_all_dim(tensor.into(), dim)) + } + + fn permute(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_permute(tensor.into(), axes)) + } + + fn expand(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_expand(tensor.into(), shape)) + } + + fn flip(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_flip(tensor.into(), axes)) + } + + fn unfold(tensor: BridgeTensor, dim: usize, size: usize, step: usize) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_unfold(tensor.into(), dim, size, step)) + } +} + +impl BasicAutodiffOps for Bool { + fn inner(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_inner(tensor.into())) + } + + fn from_inner(inner: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_from_inner(inner.into())) + } +} diff --git a/crates/burn-tensor/src/bridge/ops/float.rs b/crates/burn-tensor/src/bridge/ops/float.rs new file mode 100644 index 0000000..e20f66c --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/float.rs @@ -0,0 +1,1025 @@ +use alloc::vec::Vec; +use burn_backend::{ + AutodiffBackend, Distribution, Scalar, TensorData, TensorMetadata, TensorPrimitive, + ops::{FloatTensorOps, QTensorOps, TransactionPrimitive}, +}; +use burn_dispatch::Dispatch; +use burn_std::{DType, ExecutionError, IndexingUpdateOp, Shape, Slice}; + +use crate::{ + Device, Float, + bridge::{BasicAutodiffOps, BasicOps, FloatMathOps, Numeric, Ordered, TransactionOp}, + ops::{BridgeKind, BridgeTensor}, +}; + +fn from_q_primitive(prim: TensorPrimitive) -> BridgeTensor { + match prim { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + } +} + +macro_rules! q_bin_ops { + ($lhs:ident, $rhs:ident, $op:ident, $q_op:ident) => {{ + let (lkind, lhs) = $lhs.into_parts(); + let (rkind, rhs) = $rhs.into_parts(); + match (lkind, rkind) { + (BridgeKind::Float, BridgeKind::Float) => BridgeTensor::float(Dispatch::$op(lhs, rhs)), + (BridgeKind::QFloat, BridgeKind::QFloat) => from_q_primitive(Dispatch::$q_op(lhs, rhs)), + (BridgeKind::QFloat, BridgeKind::Float) => { + let dtype = rhs.dtype(); + BridgeTensor::float(Dispatch::$op(Dispatch::dequantize(lhs, dtype.into()), rhs)) + } + (BridgeKind::Float, BridgeKind::QFloat) => { + let dtype = lhs.dtype(); + BridgeTensor::float(Dispatch::$op(lhs, Dispatch::dequantize(rhs, dtype.into()))) + } + _ => panic!("Should be Float primitive kind"), + } + }}; +} +impl TransactionOp for Float { + fn register_transaction(tr: &mut TransactionPrimitive, tensor: BridgeTensor) { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => tr.register_float(TensorPrimitive::Float(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } +} + +impl BasicOps for Float { + fn empty(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_empty( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn zeros(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_zeros( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + fn ones(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_ones( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn full(shape: Shape, fill_value: Scalar, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_full( + shape, + fill_value, + device.as_dispatch(), + dtype.into(), + )) + } + + fn reshape(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_reshape(tensor, shape)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_reshape(tensor, shape)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn transpose(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_transpose(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_transpose(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn swap_dims(tensor: BridgeTensor, dim1: usize, dim2: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_swap_dims(tensor, dim1, dim2)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_swap_dims(tensor, dim1, dim2)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn slice(tensor: BridgeTensor, slices: &[Slice]) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_slice(tensor, slices)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_slice(tensor, slices)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn slice_assign(tensor: BridgeTensor, slices: &[Slice], value: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_slice_assign( + tensor.into_float(), + slices, + value.into_float(), + )) + } + + fn select(tensor: BridgeTensor, dim: usize, indices: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::float_select(tensor, dim, indices.into())) + } + BridgeKind::QFloat => { + BridgeTensor::qfloat(Dispatch::q_select(tensor, dim, indices.into())) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn select_assign( + tensor: BridgeTensor, + dim: usize, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + // Select assign is ambiguous for QFloat + match update { + IndexingUpdateOp::Add => BridgeTensor::float(Dispatch::float_select_add( + tensor.into_float(), + dim, + indices.into(), + values.into_float(), + )), + other => unimplemented!("Unsupported update op {other:?}"), + } + } + + fn mask_where(tensor: BridgeTensor, mask: BridgeTensor, source: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_mask_where( + tensor.into_float(), + mask.into(), + source.into_float(), + )) + } + + fn mask_fill(tensor: BridgeTensor, mask: BridgeTensor, value: Scalar) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_mask_fill( + tensor.into_float(), + mask.into(), + value, + )) + } + + fn gather(dim: usize, tensor: BridgeTensor, indices: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::float_gather(dim, tensor, indices.into())) + } + BridgeKind::QFloat => { + BridgeTensor::qfloat(Dispatch::q_gather(dim, tensor, indices.into())) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn scatter( + dim: usize, + tensor: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + match update { + IndexingUpdateOp::Add => BridgeTensor::float(Dispatch::float_scatter_add( + dim, + tensor.into_float(), + indices.into(), + values.into_float(), + )), + other => unimplemented!("Unsupported update op {other:?}"), + } + } + + fn scatter_nd( + data: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + reduction: IndexingUpdateOp, + ) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_scatter_nd( + data.into_float(), + indices.into(), + values.into_float(), + reduction, + )) + } + + fn gather_nd(data: BridgeTensor, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_gather_nd(data.into_float(), indices.into())) + } + + fn device(tensor: &BridgeTensor) -> Device { + let (kind, tensor) = tensor.as_parts(); + match kind { + BridgeKind::Float => Device::new(tensor.device()), + BridgeKind::QFloat => Device::new(tensor.device()), + _ => panic!("Should be Float primitive kind"), + } + } + + fn to_device(tensor: BridgeTensor, device: &Device) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::float_to_device(tensor, device.as_dispatch())) + } + BridgeKind::QFloat => { + BridgeTensor::qfloat(Dispatch::q_to_device(tensor, device.as_dispatch())) + } + _ => panic!("Should be Float primitive kind"), + } + } + + async fn into_data_async(tensor: BridgeTensor) -> Result { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => Dispatch::float_into_data(tensor).await, + BridgeKind::QFloat => Dispatch::q_into_data(tensor).await, + _ => panic!("Should be Float primitive kind"), + } + } + + fn from_data(data: TensorData, device: &Device, dtype: DType) -> BridgeTensor { + if matches!(data.dtype, DType::QFloat(_)) { + // When the source is QFloat, there is no conversion path possible. + BridgeTensor::qfloat(Dispatch::q_from_data(data, device.as_dispatch())) + } else if dtype.is_float() { + BridgeTensor::float(Dispatch::float_from_data( + data.convert_dtype(dtype), + device.as_dispatch(), + )) + } else { + panic!("Expected float dtype, got {dtype:?}") + } + } + + fn repeat_dim(tensor: BridgeTensor, dim: usize, times: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::float_repeat_dim(tensor, dim, times)) + } + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_repeat_dim(tensor, dim, times)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn cat(vectors: Vec, dim: usize) -> BridgeTensor { + match vectors.first().unwrap().kind() { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_cat( + BridgeTensor::into_dispatch_vec(vectors), + dim, + )), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_cat( + BridgeTensor::into_dispatch_vec(vectors), + dim, + )), + _ => panic!("Should be Float primitive kind"), + } + } + + fn equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_equal(lhs, rhs.into_float(), bool_dtype)) + } + + fn not_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_not_equal(lhs, rhs.into_float(), bool_dtype)) + } + + fn equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_equal_elem(lhs, rhs, bool_dtype)) + } + + fn not_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_not_equal_elem(lhs, rhs, bool_dtype)) + } + + fn any(tensor: BridgeTensor) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + let tensor = tensor.into_float(); + BridgeTensor::bool(Dispatch::float_any(tensor, bool_dtype)) + } + + fn any_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + let tensor = tensor.into_float(); + BridgeTensor::bool(Dispatch::float_any_dim(tensor, dim, bool_dtype)) + } + + fn all(tensor: BridgeTensor) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + let tensor = tensor.into_float(); + BridgeTensor::bool(Dispatch::float_all(tensor, bool_dtype)) + } + + fn all_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + let tensor = tensor.into_float(); + BridgeTensor::bool(Dispatch::float_all_dim(tensor, dim, bool_dtype)) + } + + fn permute(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_permute(tensor, axes)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_permute(tensor, axes)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn expand(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_expand(tensor.into_float(), shape)) + } + + fn flip(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_flip(tensor, axes)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_flip(tensor, axes)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn unfold(tensor: BridgeTensor, dim: usize, size: usize, step: usize) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_unfold(tensor.into_float(), dim, size, step)) + } +} + +impl Numeric for Float { + fn add(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + q_bin_ops!(lhs, rhs, float_add, q_add) + } + + fn add_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = lhs.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_add_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_add_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn sub(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + q_bin_ops!(lhs, rhs, float_sub, q_sub) + } + + fn sub_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = lhs.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_sub_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_sub_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn div(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + q_bin_ops!(lhs, rhs, float_div, q_div) + } + + fn div_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = lhs.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_div_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_div_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + fn remainder(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_remainder( + lhs.into_float(), + rhs.into_float(), + )) + } + + fn remainder_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_remainder_scalar(lhs.into_float(), rhs)) + } + + fn mul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + q_bin_ops!(lhs, rhs, float_mul, q_mul) + } + + fn mul_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = lhs.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_mul_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_mul_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + fn neg(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_neg(tensor)), + BridgeKind::QFloat => match Dispatch::q_neg(tensor) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn sum(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_sum(tensor)), + BridgeKind::QFloat => match Dispatch::q_sum(tensor) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn sum_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_sum_dim(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_sum_dim(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn prod(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_prod(tensor)), + BridgeKind::QFloat => match Dispatch::q_prod(tensor) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn prod_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_prod_dim(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_prod_dim(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn mean(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_mean(tensor)), + BridgeKind::QFloat => match Dispatch::q_mean(tensor) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn mean_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_mean_dim(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_mean_dim(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn cumsum(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_cumsum(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_cumsum(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn cumprod(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_cumprod(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_cumprod(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn abs(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_abs(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_abs(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn powi(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + q_bin_ops!(lhs, rhs, float_powf, q_powf) + } + + fn powi_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = lhs.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_powi_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_powi_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: DType, + ) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_random( + shape, + distribution, + device.as_dispatch(), + dtype.into(), + )) + } + + fn sign(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_sign(tensor.into_float())) + } + + /// Applies the matrix multiplication operation. + /// + /// `C = AB` + /// + /// # Panics + /// + /// If the two tensors don't have a compatible shape. + fn matmul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let (lkind, lhs) = lhs.into_parts(); + let (rkind, rhs) = rhs.into_parts(); + match (lkind, rkind) { + (BridgeKind::Float, BridgeKind::Float) => { + BridgeTensor::float(Dispatch::float_matmul(lhs, rhs)) + } + (BridgeKind::Float, BridgeKind::QFloat) => from_q_primitive(Dispatch::q_matmul( + TensorPrimitive::Float(lhs), + TensorPrimitive::QFloat(rhs), + )), + (BridgeKind::QFloat, BridgeKind::Float) => from_q_primitive(Dispatch::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::Float(rhs), + )), + (BridgeKind::QFloat, BridgeKind::QFloat) => from_q_primitive(Dispatch::q_matmul( + TensorPrimitive::QFloat(lhs), + TensorPrimitive::QFloat(rhs), + )), + _ => panic!("Should be Float primitive kind"), + } + } +} +impl Ordered for Float { + fn sort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_sort(tensor, dim, descending)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_sort(tensor, dim, descending)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn sort_with_indices( + tensor: BridgeTensor, + dim: usize, + descending: bool, + ) -> (BridgeTensor, BridgeTensor) { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + let (values, indices) = + Dispatch::float_sort_with_indices(tensor, dim, descending, settings.int_dtype); + (BridgeTensor::float(values), BridgeTensor::int(indices)) + } + BridgeKind::QFloat => { + let (values, indices) = + Dispatch::q_sort_with_indices(tensor, dim, descending, settings.int_dtype); + (BridgeTensor::qfloat(values), BridgeTensor::int(indices)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn argsort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::int(Dispatch::float_argsort( + tensor, + dim, + descending, + settings.int_dtype, + )), + BridgeKind::QFloat => BridgeTensor::int(Dispatch::q_argsort( + tensor, + dim, + descending, + settings.int_dtype, + )), + _ => panic!("Should be Float primitive kind"), + } + } + + fn cummin(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_cummin(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_cummin(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn cummax(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_cummax(tensor, dim)), + BridgeKind::QFloat => match Dispatch::q_cummax(tensor, dim) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn greater(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_greater(lhs, rhs.into_float(), bool_dtype)) + } + + fn greater_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_greater_elem(lhs, rhs, bool_dtype)) + } + + fn greater_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_greater_equal( + lhs, + rhs.into_float(), + bool_dtype, + )) + } + + fn greater_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_greater_equal_elem(lhs, rhs, bool_dtype)) + } + + fn lower(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_lower(lhs, rhs.into_float(), bool_dtype)) + } + + fn lower_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_lower_elem(lhs, rhs, bool_dtype)) + } + + fn lower_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_lower_equal( + lhs, + rhs.into_float(), + bool_dtype, + )) + } + + fn lower_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + let lhs = lhs.into_float(); + BridgeTensor::bool(Dispatch::float_lower_equal_elem(lhs, rhs, bool_dtype)) + } + + fn argmax(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::int(Dispatch::float_argmax(tensor, dim, settings.int_dtype)) + } + BridgeKind::QFloat => { + BridgeTensor::int(Dispatch::q_argmax(tensor, dim, settings.int_dtype)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn argtopk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::int(Dispatch::float_argtopk(tensor, dim, k, settings.int_dtype)) + } + BridgeKind::QFloat => { + BridgeTensor::int(Dispatch::q_argtopk(tensor, dim, k, settings.int_dtype)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn argmin(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::int(Dispatch::float_argmin(tensor, dim, settings.int_dtype)) + } + BridgeKind::QFloat => { + BridgeTensor::int(Dispatch::q_argmin(tensor, dim, settings.int_dtype)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn max(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_max(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_max(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn max_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_max_dim(tensor, dim)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_max_dim(tensor, dim)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn topk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_topk(tensor, dim, k)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_topk(tensor, dim, k)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn max_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor) { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + let (values, indices) = + Dispatch::float_max_dim_with_indices(tensor, dim, settings.int_dtype); + (BridgeTensor::float(values), BridgeTensor::int(indices)) + } + BridgeKind::QFloat => { + let (values, indices) = + Dispatch::q_max_dim_with_indices(tensor, dim, settings.int_dtype); + (BridgeTensor::qfloat(values), BridgeTensor::int(indices)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn min(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_min(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_min(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn min_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_min_dim(tensor, dim)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_min_dim(tensor, dim)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn min_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor) { + let settings = tensor.device_settings(); + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => { + let (values, indices) = + Dispatch::float_min_dim_with_indices(tensor, dim, settings.int_dtype); + (BridgeTensor::float(values), BridgeTensor::int(indices)) + } + BridgeKind::QFloat => { + let (values, indices) = + Dispatch::q_min_dim_with_indices(tensor, dim, settings.int_dtype); + (BridgeTensor::qfloat(values), BridgeTensor::int(indices)) + } + _ => panic!("Should be Float primitive kind"), + } + } + + fn clamp(tensor: BridgeTensor, min: Scalar, max: Scalar) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_clamp(tensor, min, max)), + BridgeKind::QFloat => match Dispatch::q_clamp(tensor, min, max) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn clamp_min(tensor: BridgeTensor, min: Scalar) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_clamp_min(tensor, min)), + BridgeKind::QFloat => match Dispatch::q_clamp_min(tensor, min) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn clamp_max(tensor: BridgeTensor, max: Scalar) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_clamp_max(tensor, max)), + BridgeKind::QFloat => match Dispatch::q_clamp_max(tensor, max) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } + } + + fn max_abs(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_max_abs(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_max_abs(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn max_abs_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_max_abs_dim(tensor, dim)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_max_abs_dim(tensor, dim)), + _ => panic!("Should be Float primitive kind"), + } + } +} + +impl FloatMathOps for Float { + fn square(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_powi_scalar(tensor.into_float(), 2.into())) + } + fn sqrt(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_sqrt(tensor.into_float())) + } + fn cos(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_cos(tensor.into_float())) + } + + fn sin(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_sin(tensor.into_float())) + } + + fn tan(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_tan(tensor.into_float())) + } + + fn cosh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_cosh(tensor.into_float())) + } + + fn sinh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_sinh(tensor.into_float())) + } + + fn tanh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_tanh(tensor.into_float())) + } + + fn acos(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_acos(tensor.into_float())) + } + + fn acosh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_acosh(tensor.into_float())) + } + + fn asin(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_asin(tensor.into_float())) + } + + fn asinh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_asinh(tensor.into_float())) + } + + fn atan(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_atan(tensor.into_float())) + } + + fn atanh(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_atanh(tensor.into_float())) + } + + fn atan2(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_atan2(lhs.into_float(), rhs.into_float())) + } + + fn exp(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_exp(tensor.into_float())) + } + + fn log(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_log(tensor.into_float())) + } + + fn log1p(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_log1p(tensor.into_float())) + } +} + +impl BasicAutodiffOps for Float { + fn inner(tensor: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = tensor.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::inner(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_inner(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } + + fn from_inner(inner: BridgeTensor) -> BridgeTensor { + let (kind, tensor) = inner.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::from_inner(tensor)), + BridgeKind::QFloat => BridgeTensor::qfloat(Dispatch::q_from_inner(tensor)), + _ => panic!("Should be Float primitive kind"), + } + } +} diff --git a/crates/burn-tensor/src/bridge/ops/int.rs b/crates/burn-tensor/src/bridge/ops/int.rs new file mode 100644 index 0000000..77e141d --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/int.rs @@ -0,0 +1,493 @@ +use alloc::vec::Vec; +use burn_backend::{ + AutodiffBackend, Distribution, Scalar, TensorData, TensorMetadata, + ops::{IntTensorOps, TransactionPrimitive}, +}; +use burn_dispatch::Dispatch; +use burn_std::{DType, ExecutionError, IndexingUpdateOp, Shape, Slice}; + +use crate::{ + Device, Int, + bridge::{BasicAutodiffOps, BasicOps, Numeric, Ordered, TransactionOp}, + ops::BridgeTensor, +}; + +impl TransactionOp for Int { + fn register_transaction(tr: &mut TransactionPrimitive, tensor: BridgeTensor) { + tr.register_int(tensor.into()); + } +} +impl BasicOps for Int { + fn empty(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_empty( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn zeros(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_zeros( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + fn ones(shape: Shape, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_ones( + shape, + device.as_dispatch(), + dtype.into(), + )) + } + + fn full(shape: Shape, fill_value: Scalar, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_full( + shape, + fill_value, + device.as_dispatch(), + dtype.into(), + )) + } + + fn reshape(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_reshape(tensor.into(), shape)) + } + + fn transpose(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_transpose(tensor.into())) + } + + fn swap_dims(tensor: BridgeTensor, dim1: usize, dim2: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_swap_dims(tensor.into(), dim1, dim2)) + } + + fn slice(tensor: BridgeTensor, slices: &[Slice]) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_slice(tensor.into(), slices)) + } + + fn slice_assign(tensor: BridgeTensor, slices: &[Slice], value: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_slice_assign( + tensor.into(), + slices, + value.into(), + )) + } + + fn select(tensor: BridgeTensor, dim: usize, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_select(tensor.into(), dim, indices.into())) + } + + fn select_assign( + tensor: BridgeTensor, + dim: usize, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + match update { + IndexingUpdateOp::Add => BridgeTensor::int(Dispatch::int_select_add( + tensor.into(), + dim, + indices.into(), + values.into(), + )), + _ => unimplemented!(), + } + } + + fn mask_where(tensor: BridgeTensor, mask: BridgeTensor, source: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mask_where( + tensor.into(), + mask.into(), + source.into(), + )) + } + + fn mask_fill(tensor: BridgeTensor, mask: BridgeTensor, value: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mask_fill(tensor.into(), mask.into(), value)) + } + + fn gather(dim: usize, tensor: BridgeTensor, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_gather(dim, tensor.into(), indices.into())) + } + + fn scatter( + dim: usize, + tensor: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + update: IndexingUpdateOp, + ) -> BridgeTensor { + match update { + IndexingUpdateOp::Add => BridgeTensor::int(Dispatch::int_scatter_add( + dim, + tensor.into(), + indices.into(), + values.into(), + )), + _ => unimplemented!(), + } + } + + fn scatter_nd( + data: BridgeTensor, + indices: BridgeTensor, + values: BridgeTensor, + reduction: IndexingUpdateOp, + ) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_scatter_nd( + data.into(), + indices.into(), + values.into(), + reduction, + )) + } + + fn gather_nd(data: BridgeTensor, indices: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_gather_nd(data.into(), indices.into())) + } + + fn device(tensor: &BridgeTensor) -> Device { + Device::new(tensor.as_dispatch().device()) + } + + fn to_device(tensor: BridgeTensor, device: &Device) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_to_device(tensor.into(), device.as_dispatch())) + } + + async fn into_data_async(tensor: BridgeTensor) -> Result { + Dispatch::int_into_data(tensor.into()).await + } + + fn from_data(data: TensorData, device: &Device, dtype: DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_from_data( + data.convert_dtype(dtype), + device.as_dispatch(), + )) + } + + fn repeat_dim(tensor: BridgeTensor, dim: usize, times: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_repeat_dim(tensor.into(), dim, times)) + } + + fn equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_equal(lhs.into(), rhs.into(), bool_dtype)) + } + + fn not_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_not_equal(lhs.into(), rhs.into(), bool_dtype)) + } + + fn equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_equal_elem(lhs.into(), rhs, bool_dtype)) + } + + fn not_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_not_equal_elem(lhs.into(), rhs, bool_dtype)) + } + + fn cat(vectors: Vec, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cat( + BridgeTensor::into_dispatch_vec(vectors), + dim, + )) + } + + fn any(tensor: BridgeTensor) -> BridgeTensor { + let settings = tensor.device_settings(); + BridgeTensor::int(Dispatch::int_any(tensor.into(), settings.bool_dtype)) + } + + fn any_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_any_dim(tensor.into(), dim, bool_dtype)) + } + + fn all(tensor: BridgeTensor) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_all(tensor.into(), bool_dtype)) + } + + fn all_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + let bool_dtype = tensor.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_all_dim(tensor.into(), dim, bool_dtype)) + } + + fn permute(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_permute(tensor.into(), axes)) + } + + fn expand(tensor: BridgeTensor, shape: Shape) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_expand(tensor.into(), shape)) + } + + fn flip(tensor: BridgeTensor, axes: &[usize]) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_flip(tensor.into(), axes)) + } + + fn unfold(tensor: BridgeTensor, dim: usize, size: usize, step: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_unfold(tensor.into(), dim, size, step)) + } +} + +impl Numeric for Int { + fn add(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_add(lhs.into(), rhs.into())) + } + fn add_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_add_scalar(lhs.into(), rhs)) + } + fn sub(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sub(lhs.into(), rhs.into())) + } + fn sub_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sub_scalar(lhs.into(), rhs)) + } + fn div(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_div(lhs.into(), rhs.into())) + } + fn div_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_div_scalar(lhs.into(), rhs)) + } + fn remainder(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_remainder(lhs.into(), rhs.into())) + } + fn remainder_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_remainder_scalar(lhs.into(), rhs)) + } + fn mul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mul(lhs.into(), rhs.into())) + } + fn mul_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mul_scalar(lhs.into(), rhs)) + } + fn neg(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_neg(tensor.into())) + } + + fn sum(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sum(tensor.into())) + } + + fn sum_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sum_dim(tensor.into(), dim)) + } + + fn prod(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_prod(tensor.into())) + } + + fn prod_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_prod_dim(tensor.into(), dim)) + } + + fn mean(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mean(tensor.into())) + } + fn mean_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_mean_dim(tensor.into(), dim)) + } + fn cumsum(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cumsum(tensor.into(), dim)) + } + fn cumprod(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cumprod(tensor.into(), dim)) + } + + fn abs(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_abs(tensor.into())) + } + + fn powi(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_powi(lhs.into(), rhs.into())) + } + + fn powi_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_powi_scalar(lhs.into(), rhs)) + } + + fn random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: DType, + ) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_random( + shape, + distribution, + device.as_dispatch(), + dtype.into(), + )) + } + + fn sign(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sign(tensor.into())) + } + + /// Applies the matrix multiplication operation. + /// + /// `C = AB` + /// + /// # Panics + /// + /// If the two tensors don't have a compatible shape. + fn matmul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_matmul(lhs.into(), rhs.into())) + } +} + +impl Ordered for Int { + fn sort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_sort(tensor.into(), dim, descending)) + } + + fn sort_with_indices( + tensor: BridgeTensor, + dim: usize, + descending: bool, + ) -> (BridgeTensor, BridgeTensor) { + let (values, indices) = Dispatch::int_sort_with_indices(tensor.into(), dim, descending); + (BridgeTensor::int(values), BridgeTensor::int(indices)) + } + + fn argsort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_argsort(tensor.into(), dim, descending)) + } + + fn cummin(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cummin(tensor.into(), dim)) + } + + fn cummax(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cummax(tensor.into(), dim)) + } + + fn greater(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::int(Dispatch::int_greater(lhs.into(), rhs.into(), bool_dtype)) + } + + fn greater_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_greater_elem(lhs.into(), rhs, bool_dtype)) + } + + fn greater_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_greater_equal( + lhs.into(), + rhs.into(), + bool_dtype, + )) + } + + fn greater_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_greater_equal_elem( + lhs.into(), + rhs, + bool_dtype, + )) + } + + fn lower(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_lower(lhs.into(), rhs.into(), bool_dtype)) + } + + fn lower_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_lower_elem(lhs.into(), rhs, bool_dtype)) + } + + fn lower_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_lower_equal( + lhs.into(), + rhs.into(), + bool_dtype, + )) + } + + fn lower_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let bool_dtype = lhs.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::int_lower_equal_elem(lhs.into(), rhs, bool_dtype)) + } + + fn argmax(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_argmax(tensor.into(), dim)) + } + + fn argtopk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_argtopk(tensor.into(), dim, k)) + } + + fn topk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_topk(tensor.into(), dim, k)) + } + + fn argmin(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_argmin(tensor.into(), dim)) + } + + fn max(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_max(tensor.into())) + } + + fn max_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_max_dim(tensor.into(), dim)) + } + + fn max_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor) { + let (values, indices) = Dispatch::int_max_dim_with_indices(tensor.into(), dim); + (BridgeTensor::int(values), BridgeTensor::int(indices)) + } + + fn max_abs(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_max_abs(tensor.into())) + } + + fn max_abs_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_max_abs_dim(tensor.into(), dim)) + } + + fn min(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_min(tensor.into())) + } + + fn min_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_min_dim(tensor.into(), dim)) + } + + fn min_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor) { + let (values, indices) = Dispatch::int_min_dim_with_indices(tensor.into(), dim); + (BridgeTensor::int(values), BridgeTensor::int(indices)) + } + + fn clamp(tensor: BridgeTensor, min: Scalar, max: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_clamp(tensor.into(), min, max)) + } + + fn clamp_min(tensor: BridgeTensor, min: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_clamp_min(tensor.into(), min)) + } + + fn clamp_max(tensor: BridgeTensor, max: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_clamp_max(tensor.into(), max)) + } +} + +impl BasicAutodiffOps for Int { + fn inner(tensor: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_inner(tensor.into())) + } + + fn from_inner(inner: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_from_inner(inner.into())) + } +} diff --git a/crates/burn-tensor/src/bridge/ops/math.rs b/crates/burn-tensor/src/bridge/ops/math.rs new file mode 100644 index 0000000..443d2bc --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/math.rs @@ -0,0 +1,298 @@ +use crate::{bridge::Numeric, ops::BridgeTensor}; + +/// Trait that lists some floating-point mathematical operations are common to all float-like dtypes. +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait FloatMathOps: Numeric { + /// Applies element wise square operation + /// + #[cfg_attr(doc, doc = "$y_i = x^{2}$")] + #[cfg_attr(not(doc), doc = "`y = x^2`")] + fn square(tensor: BridgeTensor) -> BridgeTensor; + + /// Applies element wise exponential operation. + /// + #[cfg_attr(doc, doc = "$y_i = e^{x_i}$")] + #[cfg_attr(not(doc), doc = "`y = e^x`")] + fn exp(tensor: BridgeTensor) -> BridgeTensor; + + /// Applies the natural logarithm of one plus the input tensor, element-wise. + /// + #[cfg_attr(doc, doc = r#"$y_i = \log_e\(x_i + 1\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = log(x_i + 1)`")] + fn log1p(tensor: BridgeTensor) -> BridgeTensor; + + /// Applies element wise natural log operation *ln*. + /// + #[cfg_attr(doc, doc = r#"$y_i = \log_e\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = log(x_i)`")] + fn log(tensor: BridgeTensor) -> BridgeTensor; + + /// Applies element wise root square operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \sqrt{x_i}$"#)] + #[cfg_attr(not(doc), doc = "`y_i = sqrt(x_i)`")] + fn sqrt(tensor: BridgeTensor) -> BridgeTensor; + /// Returns a new tensor with cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with cosine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the cosine of a tensor, users should prefer the [`Tensor::cos`](crate::Tensor::cos) + /// function, which is more high-level and designed for public use. + fn cos(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with sine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the sine of a tensor, users should prefer the [`Tensor::sin`](crate::Tensor::sin) + /// function, which is more high-level and designed for public use. + fn sin(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with tangent values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the tangent of a tensor, users should prefer the [`Tensor::tan`](crate::Tensor::tan) + /// function, which is more high-level and designed for public use. + fn tan(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with hyperbolic cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic cosine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the hyperbolic cosine of a tensor, users should prefer the [`Tensor::cosh`](crate::Tensor::cosh) + /// function, which is more high-level and designed for public use. + fn cosh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with hyperbolic sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic sine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the hyperbolic sine of a tensor, users should prefer the [`Tensor::sinh`](crate::Tensor::sinh) + /// function, which is more high-level and designed for public use. + fn sinh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with hyperbolic tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with hyperbolic tangent values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the hyperbolic tangent of a tensor, users should prefer the [`Tensor::tanh`](crate::Tensor::tanh) + /// function, which is more high-level and designed for public use. + fn tanh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse cosine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse cosine of a tensor, users should prefer the [`Tensor::acos`](crate::Tensor::acos) + /// function, which is more high-level and designed for public use. + fn acos(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse hyperbolic cosine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse hyperbolic cosine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse hyperbolic cosine of a tensor, users should prefer the [`Tensor::acosh`](crate::Tensor::acosh) + /// function, which is more high-level and designed for public use. + fn acosh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse sine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse sine of a tensor, users should prefer the [`Tensor::asin`](crate::Tensor::asin) + /// function, which is more high-level and designed for public use. + fn asin(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse hyperbolic sine values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse hyperbolic sine values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse hyperbolic sine of a tensor, users should prefer the [`Tensor::asinh`](crate::Tensor::asinh) + /// function, which is more high-level and designed for public use. + fn asinh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse tangent values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse tangent of a tensor, users should prefer the [`Tensor::atan`](crate::Tensor::atan) + /// function, which is more high-level and designed for public use. + fn atan(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a new tensor with inverse hyperbolic tangent values. + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// + /// # Returns + /// + /// A tensor with the same shape as `tensor` with inverse hyperbolic tangent values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the inverse hyperbolic tangent of a tensor, users should prefer the [`Tensor::atanh`](crate::Tensor::atanh) + /// function, which is more high-level and designed for public use. + fn atanh(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns a tensor with the four-quadrant inverse tangent values of `y` and `x`. + /// + /// # Arguments + /// + /// * `lhs` - The tensor with y coordinates. + /// * `rhs` - The tensor with x coordinates. + /// + /// # Returns + /// + /// A tensor with the four-quadrant inverse tangent values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For the four-quadrant inverse tangent of two tensors, users should prefer the [`Tensor::atan2`](crate::Tensor::atan2) + /// function, which is more high-level and designed for public use. + fn atan2(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; +} diff --git a/crates/burn-tensor/src/bridge/ops/mod.rs b/crates/burn-tensor/src/bridge/ops/mod.rs new file mode 100644 index 0000000..801df0d --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/mod.rs @@ -0,0 +1,14 @@ +mod autodiff; +mod base; +mod bool; +mod float; +mod int; +mod math; +mod numeric; +mod ordered; + +pub(crate) use autodiff::*; +pub(crate) use base::*; +pub(crate) use math::*; +pub(crate) use numeric::*; +pub(crate) use ordered::*; diff --git a/crates/burn-tensor/src/bridge/ops/numeric.rs b/crates/burn-tensor/src/bridge/ops/numeric.rs new file mode 100644 index 0000000..a141469 --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/numeric.rs @@ -0,0 +1,499 @@ +use burn_backend::{Distribution, Scalar}; +use burn_std::{DType, Shape}; + +use crate::{Device, bridge::BasicOps, ops::BridgeTensor}; + +/// Trait that list all operations that can be applied on all numerical tensors. +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait Numeric: BasicOps { + /// Adds two tensors together. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The sum of the two tensors. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For adding tensors, users should prefer the [`Tensor::add`](crate::Tensor::add) + /// function, which is more high-level and designed for public use. + fn add(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Adds a scalar to a tensor element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The sum of the tensor and the scalar. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For adding a scalar to a tensor, users should prefer the [`Tensor::add_scalar`](crate::Tensor::add_scalar) + /// function, which is more high-level and designed for public use. + fn add_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Subtracts two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The difference of the two tensors. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For subtracting tensors, users should prefer the [`Tensor::sub`](crate::Tensor::sub) + /// function, which is more high-level and designed for public use. + fn sub(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Subtracts a scalar from a tensor element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The difference of the tensor and the scalar. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For subtracting a scalar from a tensor, users should prefer the [`Tensor::sub_scalar`](crate::Tensor::sub_scalar) + /// function, which is more high-level and designed for public use. + fn sub_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Divides two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The quotient of the two tensors. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For dividing tensors, users should prefer the [`Tensor::div`](crate::Tensor::div) + /// function, which is more high-level and designed for public use. + fn div(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Divides a tensor by a scalar element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The quotient of the tensor and the scalar. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For dividing a tensor by a scalar, users should prefer the [`Tensor::div_scalar`](crate::Tensor::div_scalar) + /// function, which is more high-level and designed for public use. + fn div_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Computes the modulo element-wise. The result is the *signed* remainder of the division and its absolute value is + /// less than that of the divisor. + /// + /// # Arguments + /// + /// * `lhs` - The dividend. + /// * `rhs` - The divisor. + /// + /// # Returns + /// + /// The modulo of the input tensor with the divisor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For performing the modulo operation, users should prefer the [`Tensor::remainder`](crate::Tensor::remainder) + /// function, which is more high-level and designed for public use. + fn remainder(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Computes the modulo element-wise. The result is the *signed* remainder of the division and its absolute value is + /// less than that of the divisor. + /// + /// # Arguments + /// + /// * `lhs` - The dividend. + /// * `rhs` - The divisor. + /// + /// # Returns + /// + /// The modulo of the input tensor with the divisor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For performing the modulo operation, users should prefer the [`Tensor::remainder_scalar`](crate::Tensor::remainder_scalar) + /// function, which is more high-level and designed for public use. + fn remainder_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Multiplies two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// The product of the two tensors. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For multiplying tensors, users should prefer the [`Tensor::mul`](crate::Tensor::mul) + /// function, which is more high-level and designed for public use. + fn mul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Multiplies a tensor by a scalar element-wise. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// The product of the tensor and the scalar. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For multiplying a tensor by a scalar, users should prefer the [`Tensor::mul_scalar`](crate::Tensor::mul_scalar) + /// function, which is more high-level and designed for public use. + fn mul_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Negates a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to negate. + /// + /// # Returns + /// + /// The negated tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For negating a tensor, users should prefer the [`Tensor::neg`](crate::Tensor::neg) + /// function, which is more high-level and designed for public use. + fn neg(tensor: BridgeTensor) -> BridgeTensor; + + /// Returns the signs of the elements of a tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor. + /// + /// # Returns + /// + /// The signs of the elements of the tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the signs of the elements of a tensor, users should prefer the [`Tensor::sign`](crate::Tensor::sign) + /// function, which is more high-level and designed for public use. + fn sign(tensor: BridgeTensor) -> BridgeTensor; + + /// Sums all the elements of the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// + /// # Returns + /// + /// The sum of all the elements of the tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For summing all the elements of a tensor, users should prefer the [`Tensor::sum`](crate::Tensor::sum) + /// function, which is more high-level and designed for public use. + fn sum(tensor: BridgeTensor) -> BridgeTensor; + + /// Sums all the elements of the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to sum. + /// * `dim` - The dimension along which to sum. + /// + /// # Returns + /// + /// The sum of all the elements of the tensor along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For summing all the elements of a tensor along a dimension, users should prefer the [`Tensor::sum_dim`](crate::Tensor::sum_dim) + /// function, which is more high-level and designed for public use. + fn sum_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Computes the product of all the elements of the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the product of. + /// + /// # Returns + /// + /// The product of all the elements of the tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the product of all the elements of a tensor, users should prefer the + /// [`Tensor::prod`](crate::Tensor::prod) function, which is more high-level and designed for public use. + fn prod(tensor: BridgeTensor) -> BridgeTensor; + + /// Computes the product of all the elements of the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the product of. + /// * `dim` - The dimension along which to compute the product. + /// + /// # Returns + /// + /// The product of all the elements of the tensor along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the product of all the elements of a tensor along a dimension, users should prefer the + /// [`Tensor::prod_dim`](crate::Tensor::prod_dim) function, which is more high-level and designed for public use. + fn prod_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Computes the mean of all the elements of the tensor. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the mean of. + /// + /// # Returns + /// + /// The mean of all the elements of the tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the mean of all the elements of a tensor, users should prefer the [`Tensor::mean`](crate::Tensor::mean) + /// function, which is more high-level and designed for public use. + fn mean(tensor: BridgeTensor) -> BridgeTensor; + + /// Computes the mean of all the elements of the tensor along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the mean of. + /// * `dim` - The dimension along which to compute the mean. + /// + /// # Returns + /// + /// The mean of all the elements of the tensor along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the mean of all the elements of a tensor along a dimension, users should prefer the + /// [`Tensor::mean_dim`](crate::Tensor::mean_dim) function, which is more high-level and designed for public use. + fn mean_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Computes the cumulative sum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative sum of. + /// * `dim` - The dimension along which to compute the cumulative sum. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is the cumulative sum + /// of all elements up to and including that position along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the cumulative sum of elements along a dimension, users should prefer the + /// [`Tensor::cumsum`](crate::Tensor::cumsum) function, which is more high-level and designed for public use. + fn cumsum(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Computes the cumulative product of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative product of. + /// * `dim` - The dimension along which to compute the cumulative product. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is the cumulative product + /// of all elements up to and including that position along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the cumulative product of elements along a dimension, users should prefer the + /// [`Tensor::cumprod`](crate::Tensor::cumprod) function, which is more high-level and designed for public use. + fn cumprod(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Calculate absolute value on all elements of a tensor + /// + /// # Arguments + /// + /// * `tensor` - The tensor to apply abs to. + /// + /// # Returns + /// + /// A tensor with absolute values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For calculating abs of the elements of a tensor, users should prefer the [`Tensor::abs`](crate::Tensor::abs) + /// function, which is more high-level and designed for public use. + fn abs(tensor: BridgeTensor) -> BridgeTensor; + + /// Element-wise power of a tensor + /// + /// # Arguments + /// * `tensor` - The tensor to apply power to. + /// * `power` - The power to apply to the tensor. + fn powi(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise power of a tensor to a scalar int + /// + /// # Arguments + /// * `tensor` - The tensor to apply power to. + /// * `power` - The power to apply to the tensor. + fn powi_scalar(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Create a random tensor. + /// + /// # Arguments + /// + /// * `shape` - The shape of the output tensor. + /// * `distribution` - The distribution used to sample. + /// * `device` - The device to use. + /// * `dtype` - The target data type. + /// + /// # Returns + /// + /// A new tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// Users should prefer the [`Tensor::random`](crate::Tensor::random) + /// function, which is more high-level and designed for public use. + fn random( + shape: Shape, + distribution: Distribution, + device: &Device, + dtype: DType, + ) -> BridgeTensor; + + /// Applies the matrix multiplication operation. + /// + /// ```math + /// C = AB + /// ``` + fn matmul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; +} diff --git a/crates/burn-tensor/src/bridge/ops/ordered.rs b/crates/burn-tensor/src/bridge/ops/ordered.rs new file mode 100644 index 0000000..269f10e --- /dev/null +++ b/crates/burn-tensor/src/bridge/ops/ordered.rs @@ -0,0 +1,643 @@ +use burn_backend::Scalar; + +use crate::{bridge::Numeric, ops::BridgeTensor}; + +/// Trait that list all operations that can be applied on all numerical tensors +/// whose elements have a well-defined ordering. +/// +/// This includes operations such as comparisons, minimum/maximum reductions, +/// and other order-dependent computations that are not strictly valid for all numerical +/// types. +/// +/// # Warnings +/// +/// This is an internal trait, use the public API provided by the [`Tensor`](crate::Tensor) struct. +pub(crate) trait Ordered: Numeric { + /// Sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where the elements are sorted by value. + /// + /// # Remarks + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// Users should prefer the [`Tensor::sort`](crate::Tensor::sort) + /// function, which is more high-level and designed for public use. + fn sort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor; + + /// Sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor and corresponding indices, where + /// the elements are sorted by value and the indices map back to the original input tensor. + /// + /// # Remarks + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For sorting the elements of a tensor, users should prefer the [`Tensor::sort_with_indices`](crate::Tensor::sort_with_indices) + /// function, which is more high-level and designed for public use. + fn sort_with_indices( + tensor: BridgeTensor, + dim: usize, + descending: bool, + ) -> (BridgeTensor, BridgeTensor); + + /// Returns the indices that sort the elements of the input `tensor` by value along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `tensor` - The input tensor. + /// * `dim` - The axis along which to sort. + /// * `descending` - The sorting order. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor the indices map back to the original input tensor. + /// + /// # Remarks + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// Users should prefer the [`Tensor::argsort`](crate::Tensor::argsort) + /// function, which is more high-level and designed for public use. + fn argsort(tensor: BridgeTensor, dim: usize, descending: bool) -> BridgeTensor; + + /// Computes the cumulative minimum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative minimum of. + /// * `dim` - The dimension along which to compute the cumulative minimum. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is the minimum + /// of all elements up to and including that position along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the cumulative minimum of elements along a dimension, users should prefer the + /// [`Tensor::cummin`](crate::Tensor::cummin) function, which is more high-level and designed for public use. + fn cummin(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Computes the cumulative maximum of elements along a dimension. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to compute the cumulative maximum of. + /// * `dim` - The dimension along which to compute the cumulative maximum. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor, where each element is the maximum + /// of all elements up to and including that position along the specified dimension. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For computing the cumulative maximum of elements along a dimension, users should prefer the + /// [`Tensor::cummax`](crate::Tensor::cummax) function, which is more high-level and designed for public use. + fn cummax(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Element-wise greater than comparison between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding element of the left hand side tensor is greater than the corresponding element + /// of the right hand side tensor, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise greater than comparison between two tensors, users should prefer the + /// [`Tensor::greater`](crate::Tensor::greater) function, which is more high-level and designed for public use. + fn greater(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise greater than comparison between a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensor, where each element is true if the + /// corresponding element of the left hand side tensor is greater than the right hand side + /// scalar, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise greater than comparison between a tensor and a scalar, users should prefer the + /// [`Tensor::greater_elem`](crate::Tensor::greater_elem) function, which is more high-level and designed for public use. + fn greater_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Element-wise greater than or equal comparison between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding element of the left hand side tensor is greater than or equal to the + /// corresponding element of the right hand side tensor, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise greater than or equal comparison between two tensors, users should prefer the + /// [`Tensor::greater_equal`](crate::Tensor::greater_equal) function, which is more high-level and designed for public use. + fn greater_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise greater than or equal comparison between a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensor, where each element is true if the + /// corresponding element of the left hand side tensor is greater than or equal to the right + /// hand side scalar, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise greater than or equal comparison between a tensor and a scalar, users should prefer the + /// [`Tensor::greater_equal_elem`](crate::Tensor::greater_equal_elem) function, which is more high-level and designed for public use. + fn greater_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Element-wise less than comparison between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding element of the left hand side tensor is less than the corresponding element of + /// the right hand side tensor, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise less than comparison between two tensors, users should prefer the + /// [`Tensor::lower`](crate::Tensor::lower) function, which is more high-level and designed for public use. + fn lower(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise less than comparison between a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensor, where each element is true if the + /// corresponding element of the left hand side tensor is less than the right hand side scalar, + /// and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise less than comparison between a tensor and a scalar, users should prefer the + /// [`Tensor::lower_elem`](crate::Tensor::lower_elem) function, which is more high-level and designed for public use. + fn lower_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Element-wise less than or equal comparison between two tensors. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side tensor. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors, where each element is true if the + /// corresponding element of the left hand side tensor is less than or equal to the corresponding + /// element of the right hand side tensor, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise less than or equal comparison between two tensors, users should prefer the + /// [`Tensor::lower_equal`](crate::Tensor::lower_equal) function, which is more high-level and designed for public use. + fn lower_equal(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor; + + /// Element-wise less than or equal comparison between a tensor and a scalar. + /// + /// # Arguments + /// + /// * `lhs` - The left hand side tensor. + /// * `rhs` - The right hand side scalar. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensor, where each element is true if the + /// corresponding element of the left hand side tensor is less than or equal to the right hand + /// side scalar, and false otherwise. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For element-wise less than or equal comparison between a tensor and a scalar, users should prefer the + /// [`Tensor::lower_equal_elem`](crate::Tensor::lower_equal_elem) function, which is more high-level and designed for public use. + fn lower_equal_elem(lhs: BridgeTensor, rhs: Scalar) -> BridgeTensor; + + /// Gets the indices of the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the indices of the maximum elements. + /// * `tensor` - The tensor to get the indices of the maximum elements from. + /// + /// # Returns + /// + /// A tensor where the dimension `dim` has size 1 and all other dimensions + /// are the same as the input tensor. Each element is the index of the maximum + /// value. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the indices of the maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::argmax`](crate::Tensor::argmax) function, which is more high-level and designed for public use. + fn argmax(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Gets the indices of the k maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the indices of the maximum elements. + /// * `tensor` - The tensor to get the indices of the maximum elements from. + /// * `k` - k maximum elements to get + /// + /// # Returns + /// + /// A tensor where the dimension `dim` has size `k` and all other dimensions + /// are the same as the input tensor. Each element is the index of one of the + /// `k` largest values along the specified axis. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the indices of the k maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::argtopk`](crate::Tensor::argtopk) function, which is more high-level and designed for public use. + fn argtopk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor; + + /// Gets the values of the k maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the values of the maximum elements. + /// * `tensor` - The tensor to get the values of the maximum elements from. + /// * `k` - k maximum elements to get + /// + /// # Returns + /// + /// A tensor where the dimension `dim` has size `k` and all other dimensions + /// are the same as the input tensor. Each element is the value of one of the + /// `k` largest values along the specified axis. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the values of the k maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::topk`](crate::Tensor::topk) function, which is more high-level and designed for public use. + fn topk(tensor: BridgeTensor, dim: usize, k: usize) -> BridgeTensor; + + /// Gets the indices of the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the indices of the minimum elements. + /// * `tensor` - The tensor to get the indices of the minimum elements from. + /// + /// # Returns + /// + /// A tensor where the dimension `dim` has size 1 and all other dimensions + /// are the same as the input tensor. Each element is the index of the minimum + /// value. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the indices of the minimum elements of a tensor along an axis, users should prefer the + /// [`Tensor::argmin`](crate::Tensor::argmin) function, which is more high-level and designed for public use. + fn argmin(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the maximum elements. + /// + /// # Returns + /// + /// A single-element tensor containing the maximum element of the input tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::max`](crate::Tensor::max) function, which is more high-level and designed for public use. + fn max(tensor: BridgeTensor) -> BridgeTensor; + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements from. + /// * `dim` - The axis along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the same rank as the input tensor, but the given dim set to a shape of 1. + /// Each element is the maximum element of the corresponding input dim. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::max_dim`](crate::Tensor::max_dim) function, which is more high-level and designed for public use. + fn max_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements from. + /// * `dim` - The axis along which to get the maximum elements. + /// + /// # Returns + /// + /// A tuple containing the maximum element of the input tensor, and a tensor with the same shape + /// as the input tensor, where each element is the index of the maximum element of the input tensor + /// at the corresponding index along the specified axis. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::max_dim_with_indices`](crate::Tensor::max_dim_with_indices) function, + /// which is more high-level and designed for public use. + fn max_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor); + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `dim` - The axis along which to get the maximum elements. + /// + /// # Returns + /// + /// A single-element tensor containing the maximum absolute element of the input tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the maximum absolute elements of a tensor, users should prefer the + /// [`Tensor::max_abs`](crate::Tensor::max_abs) function, which is more high-level and designed for public use. + fn max_abs(tensor: BridgeTensor) -> BridgeTensor; + + /// Gets the maximum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the maximum elements from. + /// * `dim` - The axis along which to get the maximum elements. + /// + /// # Returns + /// + /// A tensor with the same rank as the input tensor, but the given dim set to a shape of 1. + /// Each element is the maximum absolute element of the corresponding input dim. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the maximum elements of a tensor along an axis, users should prefer the + /// [`Tensor::max_abs_dim`](crate::Tensor::max_abs_dim) function, which is more high-level and designed for public use. + fn max_abs_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Gets the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements from. + /// + /// # Returns + /// + /// A single-element tensor containing the minimum element of the input tensor. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the minimum elements of a tensor along an axis, users should prefer the + /// [`Tensor::min`](crate::Tensor::min) function, which is more high-level and designed for public use. + fn min(tensor: BridgeTensor) -> BridgeTensor; + + /// Gets the minimum elements of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements from. + /// * `dim` - The axis along which to get the minimum elements. + /// + /// # Returns + /// + /// A tensor with the same rank as the input tensor, but the given dim set to a shape of 1. + /// Each element is the minimum element of the corresponding input dim. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the minimum elements of a tensor along an axis, users should prefer the + /// [`Tensor::min_dim`](crate::Tensor::min_dim) function, which is more high-level and designed for public use. + fn min_dim(tensor: BridgeTensor, dim: usize) -> BridgeTensor; + + /// Gets the minimum elements and indices of a tensor along an axis. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to get the minimum elements from. + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensor and corresponding indices, where + /// each element is the minimum element of the input tensor at the corresponding index + /// along the specified axis. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users, and not recommended to import + /// or use this function directly. + /// + /// For getting the minimum elements of a tensor along an axis, users should prefer the + /// [`Tensor::min_dim_with_indices`](crate::Tensor::min_dim_with_indices) function, which is more high-level and designed for public use. + fn min_dim_with_indices(tensor: BridgeTensor, dim: usize) -> (BridgeTensor, BridgeTensor); + + /// Clamp the tensor between the given min and max values. + /// + /// # Arguments + /// + /// * `min` - The minimum value. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped between the given min and max values. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users. + /// + /// For clamping a tensor between the given min and max values, users should prefer the + /// [`Tensor::clamp`](crate::Tensor::clamp) function, which is more high-level and designed for public use. + fn clamp(tensor: BridgeTensor, min: Scalar, max: Scalar) -> BridgeTensor; + + /// Clamps a tensor under a minimum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped under the given min value. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users. + /// + /// For clamping a tensor under a minimum value, users should prefer the + /// [`Tensor::clamp_min`](crate::Tensor::clamp_min) function, which is more high-level and designed for public use. + fn clamp_min(tensor: BridgeTensor, min: Scalar) -> BridgeTensor; + + /// Clamps a tensor over a maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped over the given max value. + /// + /// # Remarks + /// + /// This is a low-level function used internally by the library to call different backend functions + /// with static dispatch. It is not designed for direct usage by users. + /// + /// For clamping a tensor over a maximum value, users should prefer the + /// [`Tensor::clamp_max`](crate::Tensor::clamp_max) function, which is more high-level and designed for public use. + fn clamp_max(tensor: BridgeTensor, max: Scalar) -> BridgeTensor; +} diff --git a/crates/burn-tensor/src/device.rs b/crates/burn-tensor/src/device.rs new file mode 100644 index 0000000..5186068 --- /dev/null +++ b/crates/burn-tensor/src/device.rs @@ -0,0 +1,1060 @@ +pub use burn_std::{ + DeviceError, DeviceSettings, ExecutionError, backtrace::BackTrace, device::DeviceId, +}; + +use burn_backend::{Backend, DeviceOps}; +#[allow(unused)] +use burn_dispatch::DispatchDeviceId; +use burn_dispatch::{Dispatch, DispatchDevice}; +use burn_std::{BoolDType, FloatDType, IntDType, TensorData}; + +#[cfg(feature = "remote-websocket")] +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +/// A high-level device handle for tensor operations. +/// +/// [`Device`] provides a unified interface to interact with the underlying compute backend. +/// +/// Autodiff support is a property of the device rather than a separate type parameter. +#[cfg_attr( + feature = "autodiff", + doc = "Wrap a device with [`.autodiff()`](Device::autodiff) to enable automatic differentiation with the device." +)] +#[cfg_attr( + not(feature = "autodiff"), + doc = "Enable the `autodiff` feature to add automatic differentiation support to devices." +)] +/// +/// # Backend selection +/// +/// Enable the desired backend via Cargo feature flags, then call the +/// corresponding factory method: +/// +/// ```rust,ignore +/// // Default CUDA device (requires the `cuda` feature). +/// let device = Device::cuda(DeviceIndex::Default); +/// +/// // CUDA device at hardware index 1. +/// let device = Device::cuda(1); +/// +/// // WGPU with explicit selector (requires `wgpu`/`vulkan`/`metal`/`webgpu`). +/// let device = Device::wgpu(DeviceKind::DiscreteGpu(0)); +/// +/// // Default device for whichever backend is enabled. +/// let device = Default::default(); +/// ``` +/// +/// Available factory methods (each gated by its matching Cargo feature): +/// `Device::cpu`, `Device::cuda` / `Device::rocm` / `Device::libtorch_cuda` +/// (take an integer index or a [`DeviceIndex`]), `Device::wgpu` / +/// `Device::vulkan` / `Device::metal` / `Device::webgpu` (take a +/// [`DeviceKind`]), `Device::flex`, `Device::ndarray`, `Device::libtorch`, +/// `Device::libtorch_mps`, `Device::libtorch_vulkan`. +/// +/// # Autodiff +/// +/// Requires `autodiff` feature. +/// +/// Gradient computation is opt-in for a device: +/// +/// ```rust,ignore +/// let device = Device::default().autodiff(); +/// +/// // Tensors created on this device will track gradients +/// let x = Tensor::<1>::from_floats([1.0, 2.0, 3.0], &device); +/// ``` +pub struct Device { + blob: device_opaque::Opaque, +} + +// Aligned, type-erased storage for `DispatchDevice`. See `crate::macros` for +// why this indirection exists (it keeps the dispatch type tree out of +// downstream MIR). +burn_std::obfuscate!( + type: DispatchDevice, + module: device_opaque, + derives: [Send, Sync] +); + +impl Clone for Device { + fn clone(&self) -> Self { + Self::new(self.as_dispatch().clone()) + } +} + +impl Default for Device { + fn default() -> Self { + Self::new(DispatchDevice::default()) + } +} + +impl core::fmt::Debug for Device { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Device<{:?}>", self.as_dispatch()) + } +} + +// Manually implement both `eq` and `ne` to add documentation on equality. +#[allow(clippy::partialeq_ne_impl)] +impl PartialEq for Device { + /// Compares devices based on hardware identity. + /// + /// Returns `true` if both devices represent the same compute resource. + /// Note that this comparison ignores autodiff and checkpointing settings. + /// To check if two devices have identical capabilities, check [`Device::is_autodiff`]. + fn eq(&self, other: &Self) -> bool { + self.as_dispatch() == other.as_dispatch() + } + + /// Compares devices based on hardware identity. + /// + /// Returns `false` if both devices represent the same compute resource, + /// even if one has autodiff enabled and the other does not. + fn ne(&self, other: &Self) -> bool { + !self.eq(other) + } +} + +impl Eq for Device {} + +impl Device { + /// Wrap a backend-specific device in a unified [`Device`]. + /// + /// Used by: + /// - the backend-specific factory methods below (`Device::cuda`, etc.) + /// — these are the recommended entry points for downstream code; + /// - burn-tensor's bridge ops, which already hold a [`DispatchDevice`] + /// and just need to wrap it; + /// - direct callers (tests, type-erased helpers) that have a concrete + /// backend device type at hand. + /// + /// Anything convertible into [`DispatchDevice`] is accepted, including + /// `DispatchDevice` itself. + pub fn new(device: impl Into) -> Self { + Self { + blob: device_opaque::Opaque::new(device.into()), + } + } + + /// Borrow the underlying [`DispatchDevice`]. + /// + /// The inverse of [`Device::new`]. Useful to backend-extension authors who need to dispatch on + /// the concrete backend variant (e.g. matching `DispatchDevice::Remote(_)`). + pub fn as_dispatch(&self) -> &DispatchDevice { + self.blob.as_ref() + } + + /// Crate-internal owning extraction of the underlying dispatch device. + pub(crate) fn into_dispatch(self) -> DispatchDevice { + self.blob.into_inner() + } +} + +impl> From for Device { + fn from(device: D) -> Self { + Self::new(device) + } +} + +/// Selector for the hardware index of a backend whose devices are simply +/// indexed (e.g. CUDA, ROCm). +/// +/// Backend factory methods that take an index (`Device::cuda`, `Device::rocm`, +/// `Device::libtorch_cuda`) accept `impl Into`, so the common +/// shorthand is to pass a plain integer literal: +/// +/// ```rust,ignore +/// Device::cuda(0); // hardware index 0 +/// Device::cuda(DeviceIndex::Default); // backend-chosen default +/// ``` +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)] +pub enum DeviceIndex { + /// Target a specific hardware device by its index. + Specified(usize), + /// Let the backend pick its default device (typically index `0`). + #[default] + Default, +} + +impl DeviceIndex { + /// Construct a [`DeviceIndex::Specified`] from anything convertible into + /// a `usize`. + pub fn new(index: impl Into) -> Self { + Self::Specified(index.into()) + } + + /// Resolve to a concrete hardware index, defaulting to `0` for + /// [`DeviceIndex::Default`]. Backend factory methods are each gated by a + /// Cargo feature, so this looks dead when none of them are enabled. + #[allow(dead_code)] + fn resolve(self) -> usize { + match self { + DeviceIndex::Specified(i) => i, + DeviceIndex::Default => 0, + } + } +} + +impl From for DeviceIndex { + fn from(i: usize) -> Self { + Self::Specified(i) + } +} + +impl From for DeviceIndex { + fn from(i: u32) -> Self { + Self::Specified(i as usize) + } +} + +impl From for DeviceIndex { + fn from(i: u64) -> Self { + Self::Specified(i as usize) + } +} + +impl From for DeviceIndex { + fn from(i: i32) -> Self { + Self::Specified(usize::try_from(i).expect("device index must be non-negative")) + } +} + +impl From for DeviceIndex { + fn from(i: i64) -> Self { + Self::Specified(usize::try_from(i).expect("device index must be non-negative")) + } +} + +/// Selector for the more flexible backends whose device handle is a tagged +/// enum (e.g. WGPU, which can target a discrete/integrated/virtual GPU, a CPU +/// adapter, an externally-created wgpu setup, or just "best available"). +/// +/// The variants mirror `WgpuDevice` from cubecl so the mapping is direct, but +/// it is kept as a burn-owned enum so callers don't have to depend on cubecl. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Default)] +pub enum DeviceKind { + /// Discrete GPU with the given index. The index is the index of the discrete GPU in the list + /// of all discrete GPUs found on the system. + DiscreteGpu(usize), + + /// Integrated GPU with the given index. The index is the index of the integrated GPU in the + /// list of all integrated GPUs found on the system. + IntegratedGpu(usize), + + /// Virtual GPU with the given index. The index is the index of the virtual GPU in the list of + /// all virtual GPUs found on the system. + VirtualGpu(usize), + + /// CPU. + Cpu, + + /// The best available device found with the current graphics API. + /// + /// This will prioritize GPUs wgpu recognizes as "high power". Additionally, you can override this using + /// the `CUBECL_WGPU_DEFAULT_DEVICE` environment variable. This variable is spelled as if i was a `WgpuDevice`, + /// so for example `CUBECL_WGPU_DEFAULT_DEVICE=IntegratedGpu(1)` or `CUBECL_WGPU_DEFAULT_DEVICE=Cpu` + #[default] + DefaultDevice, + + /// Use an externally created, existing, wgpu setup. This is helpful when using `CubeCL` in conjunction + /// with some existing wgpu setup (eg. egui or bevy), as resources can be transferred in & out of `CubeCL`. + /// + /// # Notes + /// + /// This can be initialized with `init_device` from the wgpu runtime. + Existing(u32), +} + +impl Device { + /// Default CPU device backed by CubeCL's CPU backend. + #[cfg(feature = "cpu")] + pub fn cpu() -> Self { + Self::new(burn_dispatch::devices::CpuDevice::default()) + } + + /// CUDA device at the given hardware index. + /// + /// Accepts a plain integer (e.g. `Device::cuda(0)`) or a + /// [`DeviceIndex`] — use [`DeviceIndex::Default`] to let the backend + /// pick. + #[cfg(feature = "cuda")] + pub fn cuda(index: impl Into) -> Self { + Self::new(burn_dispatch::devices::CudaDevice::new( + index.into().resolve(), + )) + } + + /// ROCm/HIP device at the given hardware index. + /// + /// Same selector semantics as [`Device::cuda`]. + #[cfg(feature = "rocm")] + pub fn rocm(index: impl Into) -> Self { + Self::new(burn_dispatch::devices::RocmDevice::new( + index.into().resolve(), + )) + } + + /// Flex backend device. + #[cfg(feature = "flex")] + pub fn flex() -> Self { + Self::new(burn_dispatch::devices::FlexDevice) + } + + /// Default NdArray (CPU) device. + #[cfg(feature = "ndarray")] + pub fn ndarray() -> Self { + Self::new(burn_dispatch::devices::NdArrayDevice::default()) + } + + /// LibTorch CPU device. + #[cfg(feature = "tch")] + pub fn libtorch() -> Self { + Self::new(burn_dispatch::devices::LibTorchDevice::Cpu) + } + + /// LibTorch CUDA device at the given hardware index. + #[cfg(feature = "tch")] + pub fn libtorch_cuda(index: impl Into) -> Self { + Self::new(burn_dispatch::devices::LibTorchDevice::Cuda( + index.into().resolve(), + )) + } + + /// LibTorch Metal Performance Shaders (MPS) device. + #[cfg(feature = "tch")] + pub fn libtorch_mps() -> Self { + Self::new(burn_dispatch::devices::LibTorchDevice::Mps) + } + + /// LibTorch Vulkan device. + #[cfg(feature = "tch")] + pub fn libtorch_vulkan() -> Self { + Self::new(burn_dispatch::devices::LibTorchDevice::Vulkan) + } + + /// Legacy WebSocket remote device. New integrations should prefer [`Device::remote_iroh`]. + /// + /// Connects to a burn-remote WebSocket server at the given address. `index` selects which of + /// the server's devices to use; two devices with the same address but different indices target + /// distinct devices on the same host. + #[cfg(feature = "remote-websocket")] + pub fn remote_websocket(address: &str, index: impl Into) -> Self { + let index = index.into().resolve(); + let device = burn_dispatch::devices::RemoteDevice::websocket(address, index); + device.connect(); // initializes the connection (required to get the device default settings) + Self::new(device) + } + + /// Iroh peer-to-peer remote device. + /// + /// `endpoint` is the application-owned Iroh endpoint to dial from; `peer` is the compute + /// server's identity (from [`RemoteSecret::id`](burn_dispatch::backends::remote::RemoteSecret::id)), + /// optionally carrying direct/relay dialing hints. + /// On wasm, use [`remote_iroh_async`](Self::remote_iroh_async) instead since sessions cannot + /// be opened synchronously. + #[cfg(all(feature = "remote", not(target_family = "wasm")))] + pub fn remote_iroh( + endpoint: &burn_dispatch::backends::remote::Endpoint, + peer: impl Into, + index: impl Into, + ) -> Self { + let index = index.into().resolve(); + let device = + burn_dispatch::backends::remote::RemoteDevice::iroh(endpoint, peer.into(), index); + device.connect(); + Self::new(device) + } + + /// Browser counterpart of [`remote_iroh`](Self::remote_iroh). Wasm cannot block to connect, + /// so the session is established asynchronously before the device is returned. + #[cfg(all(feature = "remote", any(target_family = "wasm", doc)))] + pub async fn remote_iroh_async( + endpoint: &burn_dispatch::backends::remote::Endpoint, + peer: impl Into, + index: impl Into, + ) -> Self { + let index = index.into().resolve(); + let device = + burn_dispatch::backends::remote::RemoteDevice::iroh(endpoint, peer.into(), index); + device.connect_async().await; + Self::new(device) + } + + /// Like `remote_iroh`, but carries an authorization credential the server's PeerAuthorizer + /// will check. Use against servers that require a credential; open servers take `remote_iroh`. + #[cfg(all(feature = "remote", not(target_family = "wasm")))] + pub fn remote_iroh_authorized( + endpoint: &burn_dispatch::backends::remote::Endpoint, + peer: impl Into, + index: impl Into, + credential: Vec, + ) -> Self { + let index = index.into().resolve(); + let device = burn_dispatch::backends::remote::RemoteDevice::iroh_authorized( + endpoint, + peer.into(), + index, + credential, + ); + device.connect(); + Self::new(device) + } + + /// Browser counterpart of `remote_iroh_authorized`. Establishes the session asynchronously. + #[cfg(all(feature = "remote", any(target_family = "wasm", doc)))] + pub async fn remote_iroh_authorized_async( + endpoint: &burn_dispatch::backends::remote::Endpoint, + peer: impl Into, + index: impl Into, + credential: Vec, + ) -> Self { + let index = index.into().resolve(); + let device = burn_dispatch::backends::remote::RemoteDevice::iroh_authorized( + endpoint, + peer.into(), + index, + credential, + ); + device.connect_async().await; + Self::new(device) + } + + /// WGPU device, selected via [`DeviceKind`]. + /// + /// This variant uses the runtime [`AutoCompiler`](burn_dispatch::backends::wgpu::AutoCompiler) + /// to dispatch to the most appropriate shader language (WGSL, SPIR-V, or MSL) based on the + /// enabled features. + /// + /// For [`DeviceKind::DefaultDevice`], the adapter is picked by `wgpu`'s + /// selection heuristics (high-power GPU preferred, or overridden by + /// `CUBECL_WGPU_DEFAULT_DEVICE`). + /// + /// `Device::vulkan`, `Device::metal`, and `Device::webgpu` also use the Wgpu runtime, + /// but bypass runtime dispatch by pinning specific compilers at compile time. + #[cfg(feature = "wgpu")] + pub fn wgpu(device_kind: DeviceKind) -> Self { + Self::new(DispatchDevice::Wgpu(wgpu_device(device_kind))) + } + + #[cfg(all(feature = "wgpu", target_family = "wasm"))] + /// Asynchronously creates a WGPU device, initializing the client. + pub async fn wgpu_async(device_kind: DeviceKind) -> Self { + Self::new(DispatchDevice::Wgpu(wgpu_init_async(device_kind).await)) + } + + /// Vulkan-backed WGPU device, selected via [`DeviceKind`]. + /// + /// Pins the wgpu shader compiler to SPIR-V at compile time, avoiding + /// the runtime [`AutoCompiler`](burn_dispatch::backends::wgpu::AutoCompiler) dispatch. + #[cfg(feature = "vulkan")] + pub fn vulkan(device_kind: DeviceKind) -> Self { + Self::new(DispatchDevice::Vulkan(wgpu_device(device_kind))) + } + + /// Metal-backed WGPU device, selected via [`DeviceKind`]. + /// + /// Pins the wgpu shader compiler to MSL at compile time. + #[cfg(feature = "metal")] + pub fn metal(device_kind: DeviceKind) -> Self { + Self::new(DispatchDevice::Metal(wgpu_device(device_kind))) + } + + /// WebGPU-backed device, selected via [`DeviceKind`]. + /// + /// Pins the wgpu shader compiler to WGSL at compile time. + #[cfg(feature = "webgpu")] + pub fn webgpu(device_kind: DeviceKind) -> Self { + Self::new(DispatchDevice::WebGpu(wgpu_device(device_kind))) + } + + /// Enables autodiff on this device. + /// + /// Autodiff is a property of the device: tensors created on the returned device + /// will participate in the autodiff graph. + /// + /// Only first-order autodiff is supported. Calling this method on a device that + /// already has autodiff enabled will panic. + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Device::default().autodiff(); + /// let x = Tensor::<1>::from_floats([1.0, 2.0, 3.0], &device); + /// // x.backward() is now available + /// ``` + /// + /// # Panics + /// + /// Panics if autodiff is already enabled on this device. + #[cfg(feature = "autodiff")] + pub fn autodiff(self) -> Self { + match self.into_dispatch() { + DispatchDevice::Autodiff(_) => unimplemented!("Only first-order autodiff is supported"), + other => Self::new(DispatchDevice::autodiff(other)), + } + } + + /// Enables gradient checkpointing on the autodiff device. + /// + /// Gradient checkpointing recomputes activations during backpropagation for operations + /// marked as memory-bound, while compute-bound operations still cache their + /// output. This reduces peak memory usage at the cost of additional computation + /// for memory-bound ops. + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Device::default().autodiff().gradient_checkpointing(); + /// ``` + /// + /// # Panics + /// + /// Panics if autodiff is not enabled on this device. + #[cfg(feature = "autodiff")] + pub fn gradient_checkpointing(self) -> Self { + match self.into_dispatch() { + DispatchDevice::Autodiff(device) => { + use burn_dispatch::CheckpointingStrategy; + + Self::new(DispatchDevice::autodiff_checkpointed( + device.inner(), + CheckpointingStrategy::Balanced, + )) + } + _ => panic!("Autodiff is not enabled on this device"), + } + } + + /// Returns the underlying device, removing the autodiff capability if present. + /// + /// If autodiff is not enabled, this method returns the device as-is. + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Device::default().autodiff(); + /// let inner_device = device.inner(); + /// + /// assert!(!inner_device.is_autodiff()); + /// ``` + pub fn inner(self) -> Self { + if self.is_autodiff() { + Self::new(self.into_dispatch().inner()) + } else { + self + } + } + + /// Synchronize the device, waiting for all pending operations to complete. + /// + /// # Errors + /// + /// Returns an [`ExecutionError`] if an operation failed to execute. + pub fn sync(&self) -> Result<(), ExecutionError> { + Dispatch::sync(self.as_dispatch()) + } + + /// Flush the device's pending operations, handing them off for execution without waiting + /// for them to complete. + /// + /// Backends that buffer work hold registered operations in a local queue until enough + /// accumulate: the fusion backend batches ops to build optimizations, and the remote backend + /// batches them before sending them over the network. `flush` forces that queue out now — the + /// fusion backend processes its pending optimizations and the remote backend sends its batch to + /// the server. + /// + /// Unlike [`sync`](Self::sync), this does not block on results — it only ensures buffered + /// operations are dispatched instead of sitting idle. Eager backends, which execute each + /// operation as it is registered, have nothing buffered and treat this as a no-op. + pub fn flush(&self) { + Dispatch::flush(self.as_dispatch()) + } + + /// Seeds the random number generator for this device. + /// + /// Seeding before tensor operations that involve randomness (e.g. [`Tensor::random`](crate::Tensor::random)) + /// makes those operations reproducible in a single-threaded program. + /// + /// # Note + /// + /// Depending on the backend, the seed may be applied globally rather than scoped + /// to this specific device. It is guaranteed that at least this device will be seeded. + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Default::default(); + /// device.seed(42); + /// let t = Tensor::<1>::random([8], Distribution::Default, &device); + /// ``` + pub fn seed(&self, seed: u64) { + Dispatch::seed(self.as_dispatch(), seed) + } + + /// Returns `true` if autodiff (gradient tracking) is enabled on this device. + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Default::default(); + /// assert!(!device.is_autodiff()); + /// + /// let ad_device = device.autodiff(); + /// assert!(ad_device.is_autodiff()); + /// ``` + pub fn is_autodiff(&self) -> bool { + Dispatch::ad_enabled(self.as_dispatch()) + } + + /// Sets the current allocation mode to persistent. + pub fn memory_persistent_allocations< + Output: Send, + Input: Send, + Func: Fn(Input) -> Output + Send, + >( + &self, + input: Input, + func: Func, + ) -> Output { + Dispatch::memory_persistent_allocations(self.as_dispatch(), input, func) + } + + /// Triggers a memory cleanup on this device. + /// + /// The amount of memory reclaimed depends on the allocator implementation. + /// Calling this method does not guarantee that any memory will be freed. + pub fn memory_cleanup(&self) { + Dispatch::memory_cleanup(self.as_dispatch()); + } + + /// Prepares the given data for transfer between the CPU and accelerator devices such as GPUs. + /// + /// Depending on the backend, the data may be transferred to pinned memory + /// or another transfer-optimized format to improve transfer performance. + pub fn staging<'a, Iter>(&self, data: Iter) + where + Iter: Iterator, + { + Dispatch::staging(data, self.as_dispatch()); + } + + /// Returns the [`DeviceSettings`] for this device. + /// + /// Settings include the default float and integer data types used when creating + /// tensors on this device. + /// + /// See [`configure`](Device::configure) to configure them. + pub fn settings(&self) -> DeviceSettings { + burn_backend::get_device_settings::(self.as_dispatch()) + } + + /// Configures the [settings](DeviceSettings) for this device. + /// + /// This configures the dtype used when no explicit type is specified at tensor + /// creation time. + /// + /// Settings can only be initialized once per device, and must happen before any + /// tensor is created on the device. The first tensor operation will lock the device + /// to its defaults, causing subsequent initializations attempt to return + /// [`DeviceError::AlreadyInitialized`]. + /// + /// # Errors + /// + /// Returns [`DeviceError::AlreadyInitialized`] if settings have already been set + /// for this device (either by a prior call or because a tensor operation has + /// already occurred). + /// + /// # Example + /// + /// ```rust,ignore + /// let device = Default::default(); + /// + /// device.configure((FloatDType::F16, IntDType::I32))? + /// + /// // Float tensors will now use F16 + /// let floats = Tensor::<2>::zeros([2, 3], &device); + /// // Int tensors will now use I32 + /// let ints = Tensor::<2, Int>::zeros([2, 3], &device); + /// ``` + pub fn configure(&mut self, config: impl Into) -> Result<(), DeviceError> { + let mut config = config.into(); + + let defaults = self.as_dispatch().defaults(); + + let float_dtype = config.float_dtype.take().unwrap_or(defaults.float_dtype); + let int_dtype = config.int_dtype.take().unwrap_or(defaults.int_dtype); + let bool_dtype = config.bool_dtype.take().unwrap_or(defaults.bool_dtype); + + burn_backend::set_default_dtypes::( + self.as_dispatch(), + float_dtype, + int_dtype, + bool_dtype, + ) + } + + /// Retrieves all available [`Device`]s that match the given [`DeviceType`] filter. + /// + /// Local backends (CPU, CUDA, WGPU, …) enumerate the hardware found on the host. The + /// [`Remote`](DeviceType::Remote) variant instead lists every device hosted by the + /// `burn-remote` server at the given address — it connects to the server to learn how + /// many devices it exposes: + /// + /// ```rust,ignore + /// // Every CUDA device on this machine. + /// let local = Device::enumerate(DeviceType::Cuda); + /// + /// // Every device hosted by a remote server. + /// let remote = Device::enumerate(DeviceType::remote_websocket("ws://host:3000")); + /// + /// // Filters combine with `|`. + /// let both = Device::enumerate(DeviceType::Cuda | DeviceType::remote_websocket("ws://host:3000")); + /// ``` + pub fn enumerate(filter: impl Into) -> Devices { + #[allow(unused)] + let mut devices = Vec::new(); + + #[allow(clippy::never_loop)] // at least one backend is expected to be enabled. + for device_type in filter.into() { + #[allow(unused)] + let type_id = match device_type { + #[cfg(feature = "cpu")] + DeviceType::Cpu => DispatchDeviceId::Cpu, + #[cfg(feature = "cuda")] + DeviceType::Cuda => DispatchDeviceId::Cuda, + #[cfg(feature = "rocm")] + DeviceType::Rocm => DispatchDeviceId::Rocm, + #[cfg(feature = "wgpu")] + DeviceType::Wgpu => DispatchDeviceId::Wgpu, + #[cfg(feature = "metal")] + DeviceType::Metal => DispatchDeviceId::Metal, + #[cfg(feature = "vulkan")] + DeviceType::Vulkan => DispatchDeviceId::Vulkan, + #[cfg(feature = "webgpu")] + DeviceType::WebGpu => DispatchDeviceId::WebGpu, + #[cfg(feature = "flex")] + DeviceType::Flex => DispatchDeviceId::Flex, + #[cfg(feature = "ndarray")] + DeviceType::NdArray => DispatchDeviceId::NdArray, + #[cfg(feature = "tch")] + DeviceType::LibTorch => DispatchDeviceId::LibTorch, + // Remote devices are keyed by address, not a backend type id, so they take a + // dedicated enumeration path (connecting to the server for its device count). + #[cfg(feature = "remote-websocket")] + DeviceType::Remote(address) => { + for device in Dispatch::enumerate_remote_websocket(&address) { + devices.push(Device::new(device)); + } + continue; + } + }; + + #[allow(unreachable_code)] // need to have one backend enabled, so it is reachable + for device in Dispatch::enumerate(type_id) { + devices.push(Device::new(device)) + } + } + + Devices(devices) + } +} + +/// Map our backend-agnostic [`DeviceKind`] onto cubecl's `WgpuDevice` enum. +/// +/// Shared by [`Device::wgpu`], [`Device::vulkan`], [`Device::metal`], and +/// [`Device::webgpu`], which differ only in which Cargo feature gates them. +#[cfg(feature = "wgpu")] +fn wgpu_device(device_kind: DeviceKind) -> burn_dispatch::devices::WgpuDevice { + use burn_dispatch::devices::WgpuDevice; + match device_kind { + DeviceKind::DiscreteGpu(i) => WgpuDevice::DiscreteGpu(i), + DeviceKind::IntegratedGpu(i) => WgpuDevice::IntegratedGpu(i), + DeviceKind::VirtualGpu(i) => WgpuDevice::VirtualGpu(i), + DeviceKind::Cpu => WgpuDevice::Cpu, + DeviceKind::DefaultDevice => WgpuDevice::DefaultDevice, + DeviceKind::Existing(id) => WgpuDevice::Existing(id), + } +} + +#[cfg(all(feature = "wgpu", target_family = "wasm"))] +// TODO: this is only helpful for the default graphics api and runtime options.. we'd have to expose other methods but that leaks the types +// so we might have to introduce some wrapper types. +async fn wgpu_init_async(device_kind: DeviceKind) -> burn_dispatch::devices::WgpuDevice { + use burn_dispatch::backends::wgpu::{graphics::AutoGraphicsApi, init_setup_async}; + + let device = wgpu_device(device_kind); + init_setup_async::(&device, Default::default()).await; + device +} + +/// Represents the devices that can be used. +/// +/// `DeviceType` is used to filter the available device types for [`Device::enumerate`]. Most +/// variants are fieldless and select a backend's local hardware; [`Remote`](Self::Remote) +/// carries the network address of a `burn-remote` server whose devices should be listed. +/// +/// Variants combine into a [`DeviceFilter`] with the `|` operator, so a single +/// [`Device::enumerate`] call can span several backends and remote hosts. +#[allow(missing_docs)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceType { + #[cfg(feature = "cpu")] + Cpu, + #[cfg(feature = "cuda")] + Cuda, + #[cfg(feature = "rocm")] + Rocm, + #[cfg(feature = "wgpu")] + Wgpu, + #[cfg(feature = "metal")] + Metal, + #[cfg(feature = "vulkan")] + Vulkan, + #[cfg(feature = "webgpu")] + WebGpu, + #[cfg(feature = "flex")] + Flex, + #[cfg(feature = "ndarray")] + NdArray, + #[cfg(feature = "tch")] + LibTorch, + /// Devices hosted by the `burn-remote` server at the given address + /// (e.g. `"ws://host:3000"`). Unlike the other variants this is resolved at runtime by + /// connecting to the server, which reports how many devices it exposes. + #[cfg(feature = "remote-websocket")] + Remote(String), +} + +#[cfg(feature = "remote-websocket")] +impl DeviceType { + /// Filter selecting every device hosted by the `burn-remote` server at `address` + /// (e.g. `"ws://host:3000"`). + /// + /// Convenience for [`DeviceType::Remote`] that accepts anything string-like. + pub fn remote_websocket(address: impl Into) -> Self { + DeviceType::Remote(address.into()) + } +} + +/// A set of [`DeviceType`]s passed to [`Device::enumerate`]. +/// +/// Built from a single [`DeviceType`], a `Vec`, or by combining variants with the +/// `|` operator (`DeviceType::Cuda | DeviceType::Cpu`). Because [`DeviceType::Remote`] carries +/// an address, this is a plain list rather than a bitset. +#[derive(Debug, Clone, Default)] +pub struct DeviceFilter(Vec); + +impl DeviceFilter { + /// Create an empty filter. + pub fn new() -> Self { + Self::default() + } + + /// Add a [`DeviceType`] to the filter. + pub fn with(mut self, device_type: DeviceType) -> Self { + self.0.push(device_type); + self + } +} + +impl From for DeviceFilter { + fn from(value: DeviceType) -> Self { + DeviceFilter(vec![value]) + } +} + +impl From> for DeviceFilter { + fn from(value: Vec) -> Self { + DeviceFilter(value) + } +} + +impl IntoIterator for DeviceFilter { + type Item = DeviceType; + type IntoIter = alloc::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl core::ops::BitOr for DeviceType { + type Output = DeviceFilter; + fn bitor(self, rhs: Self) -> DeviceFilter { + DeviceFilter(vec![self, rhs]) + } +} + +impl core::ops::BitOr for DeviceFilter { + type Output = DeviceFilter; + fn bitor(mut self, rhs: DeviceType) -> DeviceFilter { + self.0.push(rhs); + self + } +} + +/// Configuration options used to initialize a device. +/// +/// Unlike [`DeviceSettings`], this type represents partial user-provided +/// configuration and does not require all settings to be specified. +/// +/// Any unspecified options will be resolved to device-specific defaults +/// when the device is initialized. +/// +/// Use [`Device::configure`] to apply this configuration to a device. +#[derive(new, Debug, Clone, Default)] +pub struct DeviceConfig { + /// Default floating-point data type. + pub float_dtype: Option, + + /// Default integer data type. + pub int_dtype: Option, + + /// Default boolean data type. + pub bool_dtype: Option, + // TODO: maybe quantization, but for now we keep this as device defaults +} + +impl DeviceConfig { + /// Sets the default floating-point data type for tensors created on the device. + pub fn float_dtype(mut self, dtype: impl Into) -> Self { + self.float_dtype = Some(dtype.into()); + self + } + + /// Sets the default integer data type for tensors created on the device. + pub fn int_dtype(mut self, dtype: impl Into) -> Self { + self.int_dtype = Some(dtype.into()); + self + } + + /// Sets the default boolean data type storage precision for tensors created on the device. + pub fn bool_dtype(mut self, dtype: impl Into) -> Self { + self.bool_dtype = Some(dtype.into()); + self + } +} + +impl From for DeviceConfig { + fn from(value: FloatDType) -> Self { + DeviceConfig::new(Some(value), None, None) + } +} + +impl From for DeviceConfig { + fn from(value: IntDType) -> Self { + DeviceConfig::new(None, Some(value), None) + } +} + +impl From for DeviceConfig { + fn from(value: BoolDType) -> Self { + DeviceConfig::new(None, None, Some(value)) + } +} + +impl From<(FloatDType, IntDType)> for DeviceConfig { + fn from(value: (FloatDType, IntDType)) -> Self { + DeviceConfig::new(Some(value.0), Some(value.1), None) + } +} + +/// A collection of [`Device`]s returned by [`Device::enumerate`]. +/// +/// This type provides bulk operations and transformations over multiple +/// devices, such as enabling autodiff or configuring the device settings. +/// +/// # Example +/// +/// ```rust,ignore +/// let mut devices = Device::enumerate(DeviceType::Cuda) +/// .autodiff(); +/// +/// devices.configure( +/// DeviceConfig::default().float_dtype(FloatDType::F16), +/// )?; +/// ``` +/// +/// `Devices` dereferences to a slice of [`Device`], so it can be iterated, +/// indexed, and passed anywhere a `&[Device]` is expected. +pub struct Devices(Vec); + +impl Devices { + /// Enables autodiff across all contained devices. + /// + /// Only first-order autodiff is supported. Calling this method on a device that + /// already has autodiff enabled will panic. + /// + /// See [`Device::autodiff`]. + #[cfg(feature = "autodiff")] + pub fn autodiff(mut self) -> Self { + for device in &mut self.0 { + *device = core::mem::take(device).autodiff(); + } + + self + } + + /// Configures the [settings](DeviceSettings) for all devices. + /// + /// This configures the dtype used when no explicit type is specified at tensor + /// creation time. + /// + /// Settings can only be initialized once per device, and must happen before any + /// tensor is created on the device. The first tensor operation will lock the device + /// to its defaults, causing subsequent initializations attempt to return + /// [`DeviceError::AlreadyInitialized`]. + /// + /// See [`Device::configure`]. + pub fn configure(&mut self, config: impl Into) -> Result<(), DeviceError> { + let config = config.into(); + for device in &mut self.0 { + device.configure(config.clone())?; + } + Ok(()) + } + + /// Returns the `Vec` of [`Device`]s. + pub fn into_vec(self) -> Vec { + self.0 + } +} + +// Loop over `&Devices` or `Devices` seamlessly +impl IntoIterator for Devices { + type Item = Device; + type IntoIter = alloc::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl core::ops::Deref for Devices { + type Target = [Device]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(all(test, feature = "flex", feature = "autodiff"))] +mod autodiff_move_tests { + use crate::{Device, Tensor}; + + // A non-tracked float tensor (e.g. a gradient) can be moved onto an autodiff device; it + // lands on the underlying hardware and stays non-tracked. Regression test for a panic in + // `float_to_device` ("Cannot move between autodiff and non-autodiff instances"). + #[test] + fn move_non_autodiff_float_tensor_to_autodiff_device() { + let device = Device::default(); + let ad_device = device.clone().autodiff(); + + let t = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0]], &device); + let moved = t.to_device(&ad_device); + + assert_eq!( + moved.to_data().to_vec::().unwrap(), + vec![1.0, 2.0, 3.0, 4.0] + ); + } +} diff --git a/crates/burn-tensor/src/lib.rs b/crates/burn-tensor/src/lib.rs new file mode 100644 index 0000000..3f5807f --- /dev/null +++ b/crates/burn-tensor/src/lib.rs @@ -0,0 +1,59 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! This library provides the core abstractions required to run tensor operations with Burn. +//! `Tensor`s are generic over the backend to allow users to perform operations using different `Backend` implementations. +//! Burn's tensors also support auto-differentiation thanks to the `AutodiffBackend` trait. +//! +//! # Note for contributors: `*_impl` helpers +//! +//! Throughout this crate (e.g. in `tensor::api::float`, `tensor::api::int`, +//! `tensor::api::bool`, `tensor::api::cast`, and `tensor::activation`), public +//! generic methods on `Tensor` that need to call into `burn_dispatch` are +//! routed through small non-generic helper functions named `*_impl`, grouped +//! together at the bottom of each file under a banner like: +//! +//! ```text +//! // ===================================================================== +//! // Non-generic implementation helpers (outlined from the generic API). +//! // ===================================================================== +//! ``` +//! +//! These helpers take and return only `BridgeTensor` (a type-erased blob — no +//! `DispatchTensor` or other `burn_dispatch` types appear in their +//! signatures). Because the helpers are not generic over `D`, they are +//! compiled once, and the MIR of the public generic methods does not mention +//! any `burn_dispatch` types. Downstream crates that monomorphize the public +//! generic API therefore never have to resolve the cubecl-backed type tree, +//! which drastically cuts compile times for user code. +//! +//! When adding a new public method that calls a `Dispatch::*` op, follow the +//! same pattern: keep the generic method body thin and forward to a +//! non-generic `*_impl` helper alongside the existing ones. + +#[macro_use] +extern crate derive_new; + +extern crate alloc; + +mod bridge; +mod tensor; + +pub(crate) use tensor::check::macros::check; +pub use tensor::*; + +// Re-exported types +pub use burn_std::{ + AllocationProperty, Bytes, bf16, f16, + reader::{read_sync, try_read_sync}, + stream_id::StreamId, +}; + +mod device; +pub use device::*; + +#[cfg(feature = "remote-server")] +pub mod server; + +pub(crate) use burn_backend::TensorPrimitive; diff --git a/crates/burn-tensor/src/server.rs b/crates/burn-tensor/src/server.rs new file mode 100644 index 0000000..9292552 --- /dev/null +++ b/crates/burn-tensor/src/server.rs @@ -0,0 +1,114 @@ +//! Remote-execution server entry points. +//! +//! Hosts a Burn server that executes tensor operations on behalf of remote clients. The backend is +//! selected by the Device passed in; the transport is selected by Channel. Iroh is the primary +//! transport; WebSocket is retained for compatibility. +//! +//! Two serving modes: +//! +//! - Turnkey (start / start_async): no Iroh exposure. Pick an identity with RemoteSecret and pass +//! it in a Channel::Iroh; clients dial its public id. +//! - Composed (protocol): for applications that own their own Iroh router. Burn hands back only +//! its protocol handler to register alongside the application's own protocols. +//! +//! User-defined backends that implement BackendIr but are not part of DispatchDevice use +//! burn_remote::server::RemoteServerBuilder directly; that is also how custom operations +//! (backend extensions) are hosted, over either transport. + +use std::sync::Arc; + +use crate::Device; +pub use burn_dispatch::backends::remote::server::{ + AllowAll, AuthorizationRequest, PeerAuthorizer, RemoteProtocol, +}; +pub use burn_dispatch::backends::remote::telemetry; +pub use burn_dispatch::backends::remote::{Endpoint, RemoteSecret}; +pub use burn_dispatch::devices::BURN_REMOTE_ALPN; + +use telemetry::TelemetryProbe; + +/// Transport used to serve remote clients. Re-exported from `burn-remote` (via `burn-dispatch`) so +/// the whole stack shares one definition. +pub use burn_dispatch::remote_server::Channel; + +/// Build Burn's protocol handler for `device`'s backend. +/// +/// Returns a builder to optionally attach telemetry and an authorizer before calling `build`. +/// Register the result on an application-owned Iroh router under BURN_REMOTE_ALPN. +pub fn protocol(device: Device, endpoint: &Endpoint) -> RemoteProtocolBuilder<'_> { + RemoteProtocolBuilder::new(device, endpoint) +} + +/// Configures optional telemetry and authorization for a RemoteProtocol handler. +pub struct RemoteProtocolBuilder<'a> { + device: Device, + endpoint: &'a Endpoint, + probe: Option, + authorizer: Option>, +} + +impl<'a> RemoteProtocolBuilder<'a> { + /// Create a new builder for `device`'s backend, to register on `endpoint`. + pub fn new(device: Device, endpoint: &'a Endpoint) -> Self { + Self { + device, + endpoint, + probe: None, + authorizer: None, + } + } + + /// Attach a telemetry probe for per-session monitoring. Pair with + /// [`telemetry::TelemetryProbe::channel`] to obtain a subscription a dashboard can drain. + pub fn with_telemetry(mut self, probe: TelemetryProbe) -> Self { + self.probe = Some(probe); + self + } + + /// Authorize or reject each incoming compute session. The policy receives the peer identity, + /// the requested device index, and the opaque credential carried by the client's ticket. + pub fn with_authorizer(mut self, authorizer: impl PeerAuthorizer) -> Self { + self.authorizer = Some(Arc::new(authorizer)); + self + } + + /// Build the backend-erased protocol handler. + pub fn build(self) -> RemoteProtocol { + burn_dispatch::remote_server::remote_protocol( + self.device.into_dispatch(), + self.endpoint, + self.probe.unwrap_or_else(TelemetryProbe::disabled), + self.authorizer.unwrap_or_else(|| Arc::new(AllowAll)), + ) + } +} + +impl<'a> From> for RemoteProtocol { + fn from(builder: RemoteProtocolBuilder<'a>) -> Self { + builder.build() + } +} + +/// Start a remote-execution server, blocking the current thread. +/// +/// The backend is determined by `device`: e.g. `Device::cuda(0)` runs ops on +/// CUDA, `Device::flex()` on the Flex CPU backend. Autodiff devices are +/// transparently stripped; the autodiff graph is a client-side concern. +/// +/// # Panics +/// +/// Panics if `device` selects a backend that doesn't support remote execution +/// (currently `LibTorch`, or a `Remote` device; hosting on a remote device +/// makes no sense). +#[cfg(not(target_family = "wasm"))] +pub fn start(device: Device, channel: Channel) { + burn_dispatch::remote_server::start(device.into_dispatch(), channel) +} + +/// Start a remote-execution server on the caller's async runtime. +/// +/// See [`start`] for backend-selection rules. +#[cfg(not(target_family = "wasm"))] +pub async fn start_async(device: Device, channel: Channel) { + burn_dispatch::remote_server::start_async(device.into_dispatch(), channel).await +} diff --git a/crates/burn-tensor/src/tensor/activation/base.rs b/crates/burn-tensor/src/tensor/activation/base.rs new file mode 100644 index 0000000..cf55806 --- /dev/null +++ b/crates/burn-tensor/src/tensor/activation/base.rs @@ -0,0 +1,658 @@ +use burn_backend::ops::ActivationOps; +use burn_dispatch::Dispatch; + +use crate::check::TensorCheck; +use crate::ops::BridgeTensor; +use crate::{Tensor, check, s}; + +/// Applies the rectified linear unit function element-wise +/// as described in the paper [Deep Learning using Rectified Linear Units (ReLU)](https://arxiv.org/pdf/1803.08375). +/// +#[cfg_attr(doc, doc = "$$\\text{ReLU}\\(x\\) = \\(x\\)^+ = \\max\\(0, x\\)$$")] +#[cfg_attr(not(doc), doc = "`ReLU(x) = max(0, x)`")] +pub fn relu(tensor: Tensor) -> Tensor { + tensor.relu() +} + +/// Applies the leaky rectified linear unit function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{LeakyReLU}\(x\) = \max\(0,x\) + \text{negative\\_slope} \cdot \min\(0, x\) +$$ + +or + +$$ +\text{LeakyReLU}(x) = + \begin{cases} + x & \text{if } x \geq 0 \newline + \text{negative\\_slope} \cdot x & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`f(x) =`\n- `x for x >= 0`\n- `negative_slope * x if x < 0`" +)] +pub fn leaky_relu(tensor: Tensor, negative_slope: f64) -> Tensor { + Tensor::new(leaky_relu_impl(tensor.primitive, negative_slope)) +} + +/// Applies the Gaussian Error Linear Units function as described in the paper +/// [Gaussian Error Linear Units (GELUs)](https://arxiv.org/pdf/1606.08415v3.pdf). +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{GELU}(x) += x \cdot \Phi(x) += x \cdot \frac{1}{2}\left(1 + \text{erf}\left(\frac{x}{\sqrt{2}}\right)\right) +$$ + +where $\Phi(x)$ is the cumulative distribution function for the Gaussian distribution. +"# +)] +#[cfg_attr( + not(doc), + doc = r#" +`GELU(x) = x * Φ(x) = x * 1/2 * (1 + erf(x / sqrt(2)))` + +where `Φ(x)` is the cumulative distribution function for the Gaussian distribution. +"# +)] +pub fn gelu(tensor: Tensor) -> Tensor { + Tensor::new(gelu_impl(tensor.primitive)) +} + +/// Applies the tanh-based approximate GELU function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{GELU\_approx}(x) += \frac{x}{2}\left(1 + \tanh\left(\sqrt{\frac{2}{\pi}}\left(x + 0.044715\,x^3\right)\right)\right) +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`GELU_approx(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))`" +)] +pub fn gelu_approximate(tensor: Tensor) -> Tensor { + /// sqrt(2/π) precomputed as FRAC_2_SQRT_PI * FRAC_1_SQRT_2 + const SQRT_2_OVER_PI: f64 = + core::f64::consts::FRAC_2_SQRT_PI * core::f64::consts::FRAC_1_SQRT_2; + + let x = tensor; + let inner = x.clone() + x.clone().powf_scalar(3.0) * 0.044715; + let inner = inner * SQRT_2_OVER_PI; + (x.clone() * (inner.tanh() + 1)) * 0.5 +} + +/// Applies Parametric ReLu activation function as described in the paper +/// [Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification](https://arxiv.org/pdf/1502.01852). +/// +/// - The tensor is assumed to be of shape `[batch_size, channels, ...]`. +/// - `alpha` is assumed to be of shape `[channels]` or `[1]`. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{PReLU}\(x\) = \max\(0,x\) + \alpha \cdot \min\(0, x\) +$$ + +or + +$$ +\text{PReLU}(x) = + \begin{cases} + x & \text{if } x \geq 0 \newline + \alpha x & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`PReLu(x) = max(0,x) + alpha * min(0,x)`")] +pub fn prelu(tensor: Tensor, alpha: Tensor<1>) -> Tensor { + check!(TensorCheck::check_prelu_shape::( + &tensor.shape(), + &alpha.shape() + )); + + let weight = if alpha.dims()[0] == 1 { + // if there is only 1 weight, then reshape it to (1,1,1... D times) so that the rank is D + alpha.reshape([1; D]) + } else { + // D>=2 because the case where D==1 and num_weights >1 is handled by check function + // there is more than 1 weight and rank is more than 2 + let num_weights = alpha.dims()[0]; + let mut s = [1; D]; + s[1] = num_weights; + // reshape the weights to (1, channels,1 ...) + alpha.reshape(s) + }; + + Tensor::new(prelu_impl(tensor.primitive, weight.primitive)) +} + +/// Applies the softmax function on the input tensor along the given dimension. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{softmax}\(x_i\) = \frac{\exp\(x_i\)}{\sum_j \exp\(x_j\)} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`softmax(x_i) = exp(x_i) / sum_j(exp(x_j))`")] +/// +/// # Arguments +/// - `dim`: the dimension along which Softmax will be computed. +/// +/// # Panics +/// - If `dim` is outside [0, D) +pub fn softmax(tensor: Tensor, dim: usize) -> Tensor { + check!(TensorCheck::dim_ops::("softmax", dim)); + + Tensor::new(softmax_impl(tensor.primitive, dim)) +} + +/// Applies the softmin function on the input tensor along the given dimension. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{softmin}\(x_i\) = \frac{\exp\(-x_i\)}{\sum_j \exp\(-x_j\)} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`softmin(x_i) = exp(-x_i) / sum_j(exp(-x_j)`")] +/// +/// # Arguments +/// - `dim`: the dimension along which Softmax will be computed. +/// +/// # Panics +/// - If `dim` is outside [0, D) +pub fn softmin(tensor: Tensor, dim: usize) -> Tensor { + check!(TensorCheck::dim_ops::("softmin", dim)); + + Tensor::new(softmin_impl(tensor.primitive, dim)) +} + +/// Applies the SoftPlus function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{softplus}\(x\) = \frac{1}{\beta}\log\(1 + \exp\(\beta x\)\) +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`softplus(x_i) = log(1 + exp(beta * x_i)) / beta`")] +/// +/// The SoftPlus function is a smooth approximation of the ReLU function. +pub fn softplus(tensor: Tensor, beta: f64) -> Tensor { + let tensor = (tensor.mul_scalar(beta).exp() + 1).log(); + tensor.div_scalar(beta) +} + +/// Applies the "quiet softmax" function on the input tensor along the given dimension. +/// +/// Also referred to as [`softmax1`](https://www.evanmiller.org/attention-is-off-by-one.html). +/// +/// This function is similar to the softmax function, but it allows for "no selection" when +/// all the outputs are close to zero. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{quiet\\_softmax}\(x_i\) = \frac{\exp\(x_i\)}{1 + \sum_j \exp\(x_j\)} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`quiet_softmax(x_i) = exp(x_i) / [ 1 + sum_j(exp(x_j)) ]`" +)] +/// +/// # Arguments +/// - `dim`: the dimension along which Softmax will be computed. +/// +/// # Panics +/// - If `dim` is outside [0, D) +pub fn quiet_softmax(tensor: Tensor, dim: usize) -> Tensor { + check!(TensorCheck::dim_ops::("softmax", dim)); + + let max_vals = tensor.clone().detach().max_dim(dim); + let exp_x = (tensor - max_vals.clone()).exp(); + let sum_exp = exp_x.clone().sum_dim(dim); + + exp_x.div(sum_exp + max_vals.neg().exp()) +} + +/// Applies the log softmax function on the input tensor along the given dimension. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{log\\_softmax}\(x_i\) += \log\left(\text{softmax}\(x_i\)\right) += \log\left(\frac{\exp\(x_i\)}{\sum_j \exp\(x_j\)}\right) +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`log_softmax(x_i) = log(softmax(x_i)) = log(exp(x_i) / sum_j(exp(x_j)))`" +)] +/// +/// # Arguments +/// - `dim`: the dimension along which Softmax will be computed. +/// +/// # Panics +/// - If `dim` is outside [0, D) +pub fn log_softmax(tensor: Tensor, dim: usize) -> Tensor { + check!(TensorCheck::dim_ops::("log softmax", dim)); + + Tensor::new(log_softmax_impl(tensor.primitive, dim)) +} + +/// Applies the sigmoid function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{sigmoid}\(x\) += \sigma(x) += \frac{1}{1 + \exp(-x)} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`sigmoid(x) = 1 / (1 + exp(-x))`")] +pub fn sigmoid(tensor: Tensor) -> Tensor { + Tensor::new(sigmoid_impl(tensor.primitive)) +} + +/// Applies the hard sigmoid function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{hard\\_sigmoid}\(x\) = \max(0, \min(1, \alpha \cdot x + \beta)) +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`hard_sigmoid(x) = max(0, min(1, alpha * x + beta))`")] +pub fn hard_sigmoid(tensor: Tensor, alpha: f64, beta: f64) -> Tensor { + Tensor::new(hard_sigmoid_impl(tensor.primitive, alpha, beta)) +} + +/// Applies the log sigmoid function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{log\\_sigmoid}\(x\) = \log\left(\frac{1}{1 + \exp(-x)}\right) +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`log_sigmoid(x) = log(1 / (1 + exp(-x)))`")] +pub fn log_sigmoid(tensor: Tensor) -> Tensor { + Tensor::new(log_sigmoid_impl(tensor.primitive)) +} + +/// Applies the SiLU function (also known as the swish function) element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{SiLU}\(x\) = x \cdot \sigma(x) = \frac{x}{1 + \exp(-x)} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`SiLU(x) = x * sigmoid(x) = x / (1 + exp(-x))`")] +pub fn silu(tensor: Tensor) -> Tensor { + tensor.clone().mul(sigmoid(tensor)) +} + +/// Applies the hard swish function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{hard\_swish}\(x\) = x \cdot \text{hard\_sigmoid}(x) = x \cdot \max(0, \min(1, \frac{x}{6} + 0.5)) +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`hard_swish(x) = x * hard_sigmoid(x) = x * max(0, min(1, x/6 + 0.5))`" +)] +pub fn hard_swish(tensor: Tensor) -> Tensor { + tensor.clone().mul(hard_sigmoid(tensor, 1.0 / 6.0, 0.5)) +} + +/// Applies the Mish function as described in the paper in +/// [Mish: A Self Regularized Non-Monotonic Neural Activation Function](https://arxiv.org/abs/1908.08681). +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{Mish}\(x\) += x \cdot \tanh(\text{Softplus}(x)) += \tanh\left(\log\(1 + \exp\(x\)\)\right) +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`mish(x) = x * tanh(softplus(x)) = tanh(log(1 + exp(x)))`" +)] +pub fn mish(tensor: Tensor) -> Tensor { + tensor.clone().mul(softplus(tensor, 1.0).tanh()) +} + +/// Applies the tanh function element-wise. +pub fn tanh(tensor: Tensor) -> Tensor { + tensor.tanh() +} + +/// Applies the Exponential Linear Unit function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{ELU}\(x\) = + \begin{cases} + x & \text{if } x > 0 \newline + \alpha \cdot (\exp(x) - 1) & \text{if } x \leq 0 + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`f(x) =`\n- `x for x > 0`\n- `alpha * (exp(x) - 1) for x <= 0`" +)] +pub fn elu(tensor: Tensor, alpha: f64) -> Tensor { + let mask = tensor.clone().lower_equal_elem(0); + let scaled = tensor.clone().exp().sub_scalar(1).mul_scalar(alpha); + tensor.mask_where(mask, scaled) +} + +/// Applies the Continuously Differentiable Exponential Linear Unit function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{CELU}(x) = + \begin{cases} + x & \text{if } x \geq 0 \newline + \alpha \cdot \left(\exp\left(\frac{x}{\alpha}\right) - 1\right) & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`celu(x) = max(0, x) + min(0, alpha * (exp(x / alpha) - 1))`" +)] +/// +/// See also [CELU](https://pytorch.org/docs/stable/generated/torch.nn.CELU.html) +/// +/// # Arguments +/// - `alpha`: scaling parameter for the negative part. +pub fn celu(tensor: Tensor, alpha: f64) -> Tensor { + let mask = tensor.clone().lower_equal_elem(0); + let scaled = tensor + .clone() + .div_scalar(alpha) + .exp() + .sub_scalar(1) + .mul_scalar(alpha); + tensor.mask_where(mask, scaled) +} + +/// Applies the Scaled Exponential Linear Unit function element-wise +/// as described in the paper [Self-Normalizing Neural Networks](https://arxiv.org/abs/1706.02515). +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{SELU}\(x\) = \gamma \cdot + \begin{cases} + x & \text{if } x > 0 \newline + \alpha \cdot (\exp(x) - 1) & \text{if } x \leq 0 + \end{cases} +$$ + +where $\alpha \approx 1.6733$ and $\gamma \approx 1.0507$. +"# +)] +#[cfg_attr( + not(doc), + doc = "`selu(x) = gamma * x if x > 0, gamma * alpha * (exp(x) - 1) if x <= 0`" +)] +pub fn selu(tensor: Tensor) -> Tensor { + // Constants from the SELU paper / ONNX spec + const ALPHA: f64 = 1.6732632423543772848170429916717_f64; + const GAMMA: f64 = 1.0507009873554804934193349852946_f64; + + let mask = tensor.clone().greater_equal_elem(0.0); + let positive = tensor.clone().mul_scalar(GAMMA); + let negative = tensor.exp().sub_scalar(1.0).mul_scalar(ALPHA * GAMMA); + + negative.mask_where(mask, positive) +} + +/// Applies the thresholded rectified linear unit function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{ThresholdedReLU}(x) = + \begin{cases} + x & \text{if } x > \alpha \newline + 0 & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`f(x) =`\n- `x if x > alpha`\n- `0 otherwise`")] +/// +/// # Arguments +/// - `alpha`: threshold value (default in ONNX is 1.0). +pub fn thresholded_relu(tensor: Tensor, alpha: f64) -> Tensor { + let mask = tensor.clone().lower_equal_elem(alpha); + tensor.mask_fill(mask, 0) +} + +/// Applies the gated linear unit function. +/// +/// GLU(a,b)=a⊗σ(b) where `a` is the first half of the input matrices and `b` is the second half. +/// +/// **Note**: +/// * The size of the input tensor along `dim` must be divisible by 2. +/// +/// ### Arguments +/// * `tensor` - The input tensor. +/// +/// ### Returns +/// * A tensor with the same shape as the input, except the size along `dim` is halved. +pub fn glu(tensor: Tensor, dim: usize) -> Tensor { + // TODO: Handle negative indices with AsIndex for compatibility with Pytorch nn.GLU. + + assert!( + tensor.dims()[dim].is_multiple_of(2), + "Input tensor along dimension {dim} must have an even size. N is divisible by 2." + ); + let new_len = tensor.dims()[dim] / 2; + + let a = tensor.clone().slice_dim(dim, s![0..new_len]); + let b = tensor.slice_dim(dim, s![new_len..new_len * 2]); + + a.mul(sigmoid(b)) +} + +/// Applies the Softsign function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{softsign}(x) = \frac{x}{1 + |x|} +$$ +"# +)] +#[cfg_attr(not(doc), doc = "`softsign(x_i) = x_i / (1 + |x_i|)`")] +pub fn softsign(tensor: Tensor) -> Tensor { + tensor.clone().div(tensor.abs() + 1) +} + +/// Applies the HardShrink function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{hard\_shrink}(x) = + \begin{cases} + x & \text{if } x > \lambda \newline + x & \text{if } x < -\lambda \newline + 0 & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`hard_shrink(x) = x if x > lambda, x if x < -lambda, 0 otherwise`" +)] +/// # Arguments +/// - `lambda`: the lambda value for the Hard Shrink formulation. Default is 0.5. +pub fn hard_shrink(tensor: Tensor, lambda: f64) -> Tensor { + let mask = tensor.clone().abs().lower_equal_elem(lambda); + tensor.mask_fill(mask, 0) +} + +/// Applies the SoftShrink function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{soft\_shrink}(x) = + \begin{cases} + x - \lambda & \text{if } x > \lambda \newline + x + \lambda & \text{if } x < -\lambda \newline + 0 & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`soft_shrink(x) = x - lambda if x > lambda, x + lambda if x < -lambda, 0 otherwise`" +)] +/// # Arguments +/// - `lambda`: the lambda value for the Soft Shrink formulation. Default is 0.5. +pub fn soft_shrink(tensor: Tensor, lambda: f64) -> Tensor { + shrink(tensor, lambda, lambda) +} + +/// Applies the Shrink function element-wise. +/// +#[cfg_attr( + doc, + doc = r#" +$$ +\text{shrink}(x) = + \begin{cases} + x - \text{bias} & \text{if } x > \lambda \newline + x + \text{bias} & \text{if } x < -\lambda \newline + 0 & \text{otherwise} + \end{cases} +$$ +"# +)] +#[cfg_attr( + not(doc), + doc = "`shrink(x) = x - bias if x > lambda, x + bias if x < -lambda, 0 otherwise`" +)] +/// # Arguments +/// - `lambda`: the lambda value for the Shrink formulation. +/// - `bias`: the bias value for the Shrink formulation. +pub fn shrink(tensor: Tensor, lambda: f64, bias: f64) -> Tensor { + let abs_tensor = tensor.clone().abs(); + let sign = tensor.clone().sign(); + let shrunk = tensor.sub(sign.mul_scalar(bias)); + let mask = abs_tensor.lower_equal_elem(lambda); + shrunk.mask_fill(mask, 0) +} + +// ========================================================================= +// Non-generic implementation helpers (outlined from the generic API). +// See the crate-level docs for the rationale behind this pattern. +// ========================================================================= + +fn leaky_relu_impl(p: BridgeTensor, negative_slope: f64) -> BridgeTensor { + BridgeTensor::float(Dispatch::leaky_relu(p.into_float(), negative_slope.into())) +} + +fn gelu_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::gelu(p.into_float())) +} + +fn prelu_impl(p: BridgeTensor, weight: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::prelu(p.into_float(), weight.into_float())) +} + +fn softmax_impl(p: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::float(Dispatch::softmax(p.into_float(), dim)) +} + +fn softmin_impl(p: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::float(Dispatch::softmin(p.into_float(), dim)) +} + +fn log_softmax_impl(p: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::float(Dispatch::log_softmax(p.into_float(), dim)) +} + +fn sigmoid_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::sigmoid(p.into_float())) +} + +fn hard_sigmoid_impl(p: BridgeTensor, alpha: f64, beta: f64) -> BridgeTensor { + BridgeTensor::float(Dispatch::hard_sigmoid( + p.into_float(), + alpha.into(), + beta.into(), + )) +} + +fn log_sigmoid_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::log_sigmoid(p.into_float())) +} diff --git a/crates/burn-tensor/src/tensor/activation/mod.rs b/crates/burn-tensor/src/tensor/activation/mod.rs new file mode 100644 index 0000000..cbcb6ac --- /dev/null +++ b/crates/burn-tensor/src/tensor/activation/mod.rs @@ -0,0 +1,3 @@ +mod base; + +pub use base::*; diff --git a/crates/burn-tensor/src/tensor/api/autodiff.rs b/crates/burn-tensor/src/tensor/api/autodiff.rs new file mode 100644 index 0000000..f393f10 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/autodiff.rs @@ -0,0 +1,117 @@ +use crate::{Tensor, kind::Autodiff}; + +#[cfg(feature = "autodiff")] +use crate::ops::BridgeTensor; +#[cfg(feature = "autodiff")] +use burn_backend::AutodiffBackend; +#[cfg(feature = "autodiff")] +use burn_dispatch::Dispatch; + +#[cfg(feature = "autodiff")] +type AutodiffGradients = ::Gradients; + +// Aligned, type-erased storage for `AutodiffGradients`. See `crate::macros` +// for why this indirection exists. +#[cfg(feature = "autodiff")] +burn_std::obfuscate!( + type: AutodiffGradients, + module: gradients_opaque, + derives: [Send] +); + +/// Gradients container used during the backward pass. +#[cfg(feature = "autodiff")] +pub struct Gradients { + blob: gradients_opaque::Opaque, +} + +#[cfg(feature = "autodiff")] +impl Gradients { + /// Crate-internal constructor wrapping the dispatch-level gradients. + pub(crate) fn from_inner(inner: AutodiffGradients) -> Self { + Self { + blob: gradients_opaque::Opaque::new(inner), + } + } + + /// Crate-internal borrow of the underlying gradients container. + pub(crate) fn as_inner(&self) -> &AutodiffGradients { + self.blob.as_ref() + } + + /// Crate-internal mutable borrow of the underlying gradients container. + pub(crate) fn as_inner_mut(&mut self) -> &mut AutodiffGradients { + self.blob.as_mut() + } +} + +#[cfg(feature = "autodiff")] +impl Tensor { + /// Backward pass of the tensor. + pub fn backward(&self) -> Gradients { + backward_impl(&self.primitive) + } + + /// Get the gradients of a tensor if it exist. + /// + /// Returns a new reference to the same tensor. Therefore the same grad tensor can + /// be accessed multiple times. If you only need to get the gradients one time, + /// consider using [grad_remove](Tensor::grad_remove) for better performance. + pub fn grad(&self, grads: &Gradients) -> Option> { + grad_impl(&self.primitive, grads).map(Tensor::new) + } + + /// Remove the grad tensor from the [grads](AutodiffBackend::Gradients) struct returning the result. + pub fn grad_remove(&self, grads: &mut Gradients) -> Option> { + grad_remove_impl(&self.primitive, grads).map(Tensor::new) + } + + /// Replace the grad tensor from the [grads](AutodiffBackend::Gradients) struct with the provided + /// gradient. + pub fn grad_replace(&self, grads: &mut Gradients, grad: Tensor) { + grad_replace_impl(&self.primitive, grads, grad.primitive) + } +} + +#[cfg(feature = "autodiff")] +fn backward_impl(p: &BridgeTensor) -> Gradients { + Gradients::from_inner(Dispatch::backward(p.clone().into_float())) +} + +#[cfg(feature = "autodiff")] +fn grad_impl(p: &BridgeTensor, grads: &Gradients) -> Option { + Dispatch::grad(p.as_float(), grads.as_inner()).map(BridgeTensor::float) +} + +#[cfg(feature = "autodiff")] +fn grad_remove_impl(p: &BridgeTensor, grads: &mut Gradients) -> Option { + Dispatch::grad_remove(p.as_float(), grads.as_inner_mut()).map(BridgeTensor::float) +} + +#[cfg(feature = "autodiff")] +fn grad_replace_impl(p: &BridgeTensor, grads: &mut Gradients, grad: BridgeTensor) { + Dispatch::grad_replace(p.as_float(), grads.as_inner_mut(), grad.into_float()) +} + +impl Tensor { + /// Returns the inner tensor without the autodiff information. + pub fn inner(self) -> Tensor { + Tensor::new(K::inner(self.primitive)) + } + + /// Convert a tensor to the autodiff backend. + /// + /// # Arguments + /// + /// * `inner` - The tensor to convert. + /// + /// # Returns + /// + /// The tensor converted to the autodiff backend. + pub fn from_inner(inner: Tensor) -> Self { + Self::new(K::from_inner(inner.primitive)) + } +} + +// TODO: a lot of the `tensor.inner` / `Tensor::from_inner(...)` are actually scoped to perform some operations +// so it might be cleaner and easier to manage the device etc. if we provide a method to scope the autodiff? diff --git a/crates/burn-tensor/src/tensor/api/base.rs b/crates/burn-tensor/src/tensor/api/base.rs new file mode 100644 index 0000000..fa124a2 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/base.rs @@ -0,0 +1,3454 @@ +#![allow(clippy::single_range_in_vec_init)] +use crate::check::unwrap_shape_reshape; +use crate::kind::Basic; +use crate::ops::BridgeTensor; + +use burn_backend::Scalar; + +use alloc::vec::Vec; + +use alloc::format; +use alloc::string::String; +use alloc::vec; + +use burn_std::ExecutionError; +use burn_std::{SliceOps, stub::RwLock}; +use core::iter::repeat; +use core::marker::PhantomData; +use core::{fmt::Debug, ops::Range}; +use serde::{Deserialize, Deserializer}; + +use crate::{AsIndex, Device, Slice, SliceArg, wrap_index}; +use crate::{Bool, ElementConversion, Float, Int, Shape, TensorData, check}; +use crate::{DType, Element}; +use crate::{IndexingUpdateOp, TensorCreationOptions}; +use crate::{cast::ToElement, check::TensorCheck}; +use serde::{Serialize, Serializer}; + +/// A tensor with a given backend, shape and data type. +/// +/// # Indexing +/// Indexing a tensor can be done using [`slice`](Tensor::slice) for all tensor types +/// or [`select`](Tensor::select) for numeric types. +/// +/// ## Example +/// +/// ```rust +/// use burn_tensor::Tensor; +/// use burn_tensor::Int; +/// +/// fn example() { +/// let device = Default::default(); +/// +/// let tensor = Tensor::<2>::from_data( +/// [ +/// [3.0, 4.9, 2.0], +/// [2.0, 1.9, 3.0], +/// [6.0, 1.5, 7.0], +/// [3.0, 4.9, 9.0], +/// ], +/// &device, +/// ); +/// +/// // Slice the tensor to get the second and third rows: +/// // [[2.0, 1.9, 3.0], [6.0, 1.5, 7.0]] +/// // The resulting tensor will have dimensions [2, 3]. +/// let slice = tensor.clone().slice([1..3]); +/// println!("{slice}"); +/// +/// // Slice the tensor to get the first two rows and the first 2 columns: +/// // [[3.0, 4.9], [2.0, 1.9]] +/// // The resulting tensor will have dimensions [2, 2]. +/// let slice = tensor.clone().slice([0..2, 0..2]); +/// println!("{slice}"); +/// +/// // Index the tensor along the dimension 1 to get the elements 0 and 2: +/// // [[3.0, 2.0], [2.0, 3.0], [6.0, 7.0], [3.0, 9.0]] +/// // The resulting tensor will have dimensions [4, 2] +/// let indices = Tensor::<1, Int>::from_data([0, 2], &device); +/// let indexed = tensor.select(1, indices); +/// println!("{indexed}"); +/// } +/// ``` +#[derive(new, Clone, Debug)] +pub struct Tensor +where + K: Basic, +{ + pub(crate) primitive: BridgeTensor, + _kind: PhantomData, +} + +impl From for Tensor +where + K: Basic, + T: Into, +{ + fn from(value: T) -> Self { + Tensor::from_data(value.into(), &Default::default()) + } +} + +impl Tensor +where + K: Basic, +{ + /// Executes an operation on the tensor and modifies its value. + /// + /// # Notes + /// + /// This won't necessarily reuse the same tensor data/buffer, but it should if there is + /// no other reference pointing to the same tensor. + /// + /// Wrapping operations with inplace is not an optimization, it's mainly there if you + /// want to mutate a tensor by using owned operations. A plausible usage would be to + /// update the weights of a mutable model reference. + pub fn inplace Self>(&mut self, func: F) { + let mut tensor_owned = Tensor::empty([0; D], &self.device()); + core::mem::swap(&mut tensor_owned, self); + + let mut tensor_new = func(tensor_owned); + core::mem::swap(&mut tensor_new, self); + } + + /// Returns the number of dimensions of the tensor. + pub fn rank(&self) -> usize { + self.primitive.rank() + } + + /// Returns the tensor primitive data type. + /// + /// # Note + /// Some element types are encoded in different primitive types depending on the backend + /// (e.g., bool could be encoded as `u8` or `u32`). + pub fn dtype(&self) -> DType { + self.primitive.dtype() + } + + /// Whether this tensor's buffer can be mutated in place — i.e. this handle + /// uniquely owns the allocation, so an in-place op writes it directly + /// instead of copying first (see `TensorMetadata::can_mut`). + /// + /// Backends that track buffer ownership (cubecl, fusion, tch) answer + /// precisely from the handle reference count; others conservatively return + /// `false` — they may alias the buffer, so an in-place write can't be + /// assumed safe. Useful to assert a hot-path op (e.g. a KV-cache + /// `slice_assign`) stays in place rather than silently copying. + pub fn can_mut(&self) -> bool { + self.primitive.can_mut() + } + + /// Create an empty tensor of the given shape. + /// + /// # Arguments + /// + /// - `shape`: The shape of the tensor. + /// - `device`: The device where the tensor will be created. + /// + /// # Example + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create an empty tensor with dimensions [2, 3, 4]. + /// let tensor = Tensor::<3>::empty([2, 3, 4], &device); + /// } + /// ``` + pub fn empty>(shape: S, options: impl Into) -> Self { + let opt = options.into(); + let shape = shape.into(); + let dtype = opt.resolve_dtype::(); + check!(TensorCheck::creation_ops::("Empty", &shape)); + Self::new(K::empty(shape, &opt.device, dtype)) + } + + /// Create a tensor of the given shape where each element is zero. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::zeros(Shape::new([2, 3]), &device); + /// println!("{tensor}"); + /// // [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] + /// } + /// ``` + pub fn zeros>(shape: S, options: impl Into) -> Self { + let opt = options.into(); + let shape = shape.into(); + let dtype = opt.resolve_dtype::(); + check!(TensorCheck::creation_ops::("Zeros", &shape)); + Self::new(K::zeros(shape, &opt.device, dtype)) + } + + /// Returns a new tensor with the same shape, dtype, and device as the current tensor filled with zeros. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.zeros_like(); + /// println!("{tensor}"); + /// // [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] + /// } + /// ``` + pub fn zeros_like(&self) -> Self { + Self::new(K::zeros(self.shape(), &self.device(), self.dtype())) + } + + /// Create a tensor of the given shape where each element is one. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::ones(Shape::new([2, 3]), &device); + /// println!("{tensor}"); + /// // [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]] + /// } + /// ``` + pub fn ones>(shape: S, options: impl Into) -> Self { + let opt = options.into(); + let shape = shape.into(); + let dtype = opt.resolve_dtype::(); + check!(TensorCheck::creation_ops::("Ones", &shape)); + Self::new(K::ones(shape, &opt.device, dtype)) + } + + /// Returns a new tensor with the same shape, dtype, and device as the current tensor filled with ones. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.ones_like(); + /// println!("{tensor}"); + /// // [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]] + /// } + /// ``` + pub fn ones_like(&self) -> Self { + Self::new(K::ones(self.shape(), &self.device(), self.dtype())) + } + + /// Create a tensor of the given shape where each element is equal to the provided value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::full(Shape::new([2, 3]), 5.0, &device); + /// println!("{tensor}"); + /// // [[5.0, 5.0, 5.0], [5.0, 5.0, 5.0]] + /// } + /// ``` + pub fn full, E: ElementConversion>( + shape: S, + fill_value: E, + options: impl Into, + ) -> Self { + let opt = options.into(); + let shape = shape.into(); + let dtype = opt.resolve_dtype::(); + check!(TensorCheck::creation_ops::("Full", &shape)); + Self::new(K::full( + shape, + Scalar::new(fill_value, &dtype), + &opt.device, + dtype, + )) + } + + /// Returns a new tensor with the same shape, dtype, and device as the current tensor, + /// filled with the provided value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.full_like(5.0); + /// println!("{tensor}"); + /// // [[5.0, 5.0, 5.0], [5.0, 5.0, 5.0]] + /// } + /// ``` + pub fn full_like(&self, fill_value: E) -> Self { + let dtype = self.dtype(); + Self::new(K::full( + self.shape(), + Scalar::new(fill_value, &dtype), + &self.device(), + dtype, + )) + } + + /// Returns the dimensions of the current tensor. + /// + /// # Example + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::ones([2, 3, 4], &device); + /// let dims = tensor.dims(); // [2, 3, 4] + /// println!("{dims:?}"); + /// } + /// ``` + pub fn dims(&self) -> [usize; D] { + Self::shape(self).dims() + } + + /// Returns the shape of the current tensor. + /// + /// # Example + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::ones([2, 3, 4], &device); + /// // Shape { dims: [2, 3, 4] } + /// let shape = tensor.shape(); + /// } + /// ``` + pub fn shape(&self) -> Shape { + self.primitive.shape() + } + + /// Reshape the tensor to have the given shape. + /// + /// The tensor has the same data and number of elements as the input. + /// + /// A `-1` in the shape is used to infer the remaining dimensions, e.g.: `[2, -1]` + /// will reshape the tensor with [2, 3, 4] dimensions to [2, 12]. + /// + /// A `0` in the shape instructs to keep the current dimension from the original tensor, + /// e.g.: `[2, 0, 4]` will reshape the tensor with [2, 3, 4] dimensions to [2, 3, 4]. + /// This is useful when reshaping tensors with unknown dimensions and combining with `-1` + /// to infer the remaining dimensions, e.g. `[0, -1]` will reshape the tensor + /// with [1, 3, 4] dimensions to [1, 12]. + /// + /// # Arguments + /// - `shape`: The new shape of the tensor. + /// + /// # Panics + /// - If the tensor contains more than one `-1` in the shape. + /// - If the tensor contains values that are not positive (other than -1). + /// - If the shape does not match the number of elements of the original shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a tensor with dimensions [2, 3, 4] + /// let tensor = Tensor::<3>::ones([2, 3, 4], &device); + /// // Reshape it to [2, 12], where 12 is inferred from the number of elements. + /// let reshaped = tensor.reshape([2, -1]); + /// println!("{reshaped}"); + /// } + /// ``` + pub fn reshape>(self, shape: S) -> Tensor { + // Convert reshape args to shape + let shape = shape.into_shape::(self.shape()); + Tensor::new(K::reshape(self.primitive, shape)) + } + + /// Transpose the tensor. + /// + /// For a 2D tensor, this is the standard matrix transpose. For `D > 2`, the transpose is + /// applied on the last two dimensions. For example, the transpose of a tensor with shape + /// `[1, 2, 3, 4]` will have shape `[1, 2, 4, 3]`. + /// + /// See also [`permute`](Tensor::permute). + /// + /// # Arguments + /// + /// * `tensor` - The tensor to transpose. + /// + /// # Returns + /// + /// The transposed tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor of shape [2, 3] + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// + /// // Transpose the tensor: + /// // [[1.0, 5.0], [-2.0, 9.0], [3.0, 6.0]] + /// // The resulting tensor will have dimensions [3, 2]. + /// let transposed = tensor.transpose(); + /// println!("{transposed}"); + /// } + /// ``` + pub fn transpose(self) -> Tensor { + Tensor::new(K::transpose(self.primitive)) + } + + /// Alias for `transpose`. + #[inline(always)] + pub fn t(self) -> Tensor { + self.transpose() + } + + /// Swaps two dimensions of a tensor. + /// + /// This is a no-op when `dim1 == dim2`, assuming both are within bounds. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to swap the dimensions of. + /// * `dim1` - The first dimension to swap, supports negative indexing. + /// * `dim2` - The second dimension to swap, supports negative indexing. + /// + /// # Returns + /// + /// The tensor with the dimensions swapped. + /// + /// # Panics + /// + /// When dimensions are out of bounds. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor of shape [2, 3] + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// + /// // Swap the dimensions 0 and -1 (equivalent to `tensor.transpose()`): + /// // [[1.0, 5.0], [-2.0, 9.0], [3.0, 6.0]] + /// // The resulting tensor will have dimensions [3, 2]. + /// let swapped = tensor.swap_dims(0, -1); + /// println!("{swapped}"); + /// } + /// ``` + pub fn swap_dims(self, dim1: Dim1, dim2: Dim2) -> Tensor + where + Dim1: AsIndex, + Dim2: AsIndex, + { + let dim1 = dim1.expect_dim_index(D); + let dim2 = dim2.expect_dim_index(D); + check!(TensorCheck::swap_dims::(dim1, dim2)); + if dim1 == dim2 { + self + } else { + Tensor::new(K::swap_dims(self.primitive, dim1, dim2)) + } + } + + /// Permute the dimensions of the tensor. + /// + /// This is a no-op when the resolved `axes` match the current order. + /// + /// # Arguments + /// + /// * `axes` - The new order of the dimensions. The length of the axes + /// must be equal to the number of dimensions of the tensor. + /// The values must be unique and in the range of the number of dimensions. + /// The values can be negative, in which case they are used as an offset from the end. + /// + /// # Returns + /// + /// The tensor with the dimensions permuted. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor of shape [3, 2] + /// let tensor = Tensor::<2>::from_data([[1.0, 5.0], [-2.0, 9.0], [3.0, 6.0]], &device); + /// + /// // Permute the dimensions 1 and 0: + /// // [[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]] + /// // The resulting tensor will have dimensions [3, 2]. + /// let permuted = tensor.permute([1, 0]); + /// println!("{permuted}"); + /// } + /// ``` + pub fn permute(self, axes: [Dim; D]) -> Tensor + where + Dim: AsIndex, + { + let mut no_op = true; + let mut fixed_axes = [0; D]; + for (i, axis) in axes.into_iter().enumerate() { + let dim = axis.expect_dim_index(D); + no_op &= dim == i; + fixed_axes[i] = dim; + } + + if no_op { + self + } else { + check!(TensorCheck::permute(fixed_axes)); + Tensor::new(K::permute(self.primitive, &fixed_axes)) + } + } + + /// Moves the dimension(s) of input at the position(s) in source to the position(s) in destination. + /// + /// Other dimensions of input that are not explicitly moved remain in their original order and appear + /// at the positions not specified in destination. + /// + /// # Arguments + /// + /// * `src` - The dimension(s) to move. The values must be unique and in the range of the number of dimensions. + /// The values can be negative, in which case they are used as an offset from the end. + /// + /// * `dst` - Destination positions for each of the original dims. These must also be unique. + /// + /// # Panics + /// + /// - If the source and destination dimensions are not of the same length. + /// - If the source and destination vectors contain duplicate values. + /// - If the source and destination vectors contain values that are out of bounds. + /// + /// # Returns + /// + /// The tensor with the dimensions moved. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 3D tensor of shape [3, 2, 1] + /// let tensor = Tensor::<3>::from_data([[[1.0], [5.0]], [[-2.0], [9.0]], [[3.0], [6.0]]], &device); + /// + /// // Move the dimensions 0 and 1: + /// // [[[1.0], [-2.0], [3.0]], [[5.0], [9.0], [6.0]]] + /// // The resulting tensor will have dimensions [2, 3, 1]. + /// let moved = tensor.movedim(1, 0); + /// println!("{moved}"); + /// } + /// ``` + /// + /// # Note + /// + /// This is a syntactic sugar for `permute`. It is used widely enough, so we define a separate Op + /// for it + pub fn movedim(self, src: S1, dst: S2) -> Tensor { + let source_dims = src.into_dim_vec::(); + let destination_dims = dst.into_dim_vec::(); + + check!(TensorCheck::movedim_args_length( + &source_dims, + &destination_dims + )); + + let mut m = [-1; D]; + for (&d, &s) in destination_dims.iter().zip(source_dims.iter()) { + m[d] = s as isize; + } + let mut axes: [isize; D] = [0; D]; + let mut source_i = 0; + for (dest_i, item) in axes.iter_mut().enumerate().take(D) { + *item = if m[dest_i] != -1 { + m[dest_i] + } else { + while source_dims.contains(&source_i) { + source_i += 1; + } + let result = source_i as isize; + source_i += 1; + result + }; + } + + self.permute(axes) + } + + /// Reverse the order of elements in the tensor along the given dimensions. + /// + /// # Arguments + /// + /// * `axes` - The dimensions to reverse. The values must be unique and in the range of the number of dimensions. + /// The values can be negative, in which case they are used as an offset from the end. + /// + /// # Returns + /// + /// The tensor with the axes flipped. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [4, 3] + /// let tensor = Tensor::<2>::from_data( + /// [ + /// [3.0, 4.9, 2.0], + /// [2.0, 1.9, 3.0], + /// [4.0, 5.9, 8.0], + /// [1.4, 5.8, 6.0], + /// ], + /// &device, + /// ); + /// + /// // Flip the elements in dimensions 0 and 1: + /// // [[6.0, 5.8, 1.4], + /// // [8.0, 5.9, 4.0], + /// // [3.0, 1.9, 2.0], + /// // [2.0, 4.9, 3.0]] + /// // The resulting tensor will have dimensions [4, 3]. + /// let flipped = tensor.flip([0, 1]); + /// println!("{flipped}"); + /// } + /// ``` + pub fn flip(self, axes: [isize; N]) -> Tensor { + // Convert the axes to usize and handle negative values without using vector + let mut transformed_axes: [usize; N] = [0; N]; + for (i, &x) in axes.iter().enumerate() { + transformed_axes[i] = if x < 0 { + (D as isize + x) as usize + } else { + x as usize + }; + } + + // Check if the axes are valid + check!(TensorCheck::flip(D, &transformed_axes)); + + Tensor::new(K::flip(self.primitive, &transformed_axes)) + } + + /// Flatten the tensor along a given range of dimensions. + /// + /// This function collapses the specified range of dimensions into a single dimension, + /// effectively flattening the tensor in that range. + /// + /// # Arguments + /// + /// - `start_dim`: The starting dimension of the range to be flattened, + /// supports negative indexing. + /// - `end_dim`: The ending dimension of the range to be flattened (inclusive), + /// supports negative indexing. + /// + /// # Type Parameters + /// + /// - `D2`: The resulting number of dimensions in the flattened tensor. + /// + /// # Returns + /// + /// A new `Tensor` instance with the specified range of dimensions flattened. + /// + /// # Example + /// + /// ```rust + /// + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 3D tensor with dimensions [2, 3, 4] + /// let tensor = Tensor::<3>::ones(Shape::new([2, 3, 4]), &device); + /// + /// // Flatten the tensor from dimensions 1 to 2 (inclusive). + /// // The resulting tensor will have dimensions [2, 12] + /// let flattened: Tensor<2> = tensor.flatten(1, 2); + /// println!("{flattened}"); + /// } + /// ``` + pub fn flatten( + self, + start_dim: impl AsIndex, + end_dim: impl AsIndex, + ) -> Tensor { + let start_dim = start_dim.expect_dim_index(D); + let end_dim = end_dim.expect_dim_index(D); + check!(TensorCheck::flatten::(start_dim, end_dim)); + let new_shape = self.shape().flatten_dims(start_dim, end_dim); + + Tensor::new(K::reshape(self.primitive, new_shape)) + } + + /// Squeeze the tensor along all dimensions, removing dimensions + /// of size one, and effectively reducing the rank of the tensor. + /// + /// # Type Parameters + /// + /// - `D2`: The resulting number of dimensions in the squeezed tensor. + /// + /// # Returns + /// + /// A new `Tensor` instance with the specified dimension removed. + /// + /// # Example + /// + /// ```rust + /// + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 4D tensor with dimensions [1, 3, 1, 3] + /// let tensor = Tensor::<4>::from_data( + /// [[[[3.0, 4.9, 2.0]], [[2.0, 1.9, 3.0]], [[4.0, 5.9, 8.0]]]], + /// &device, + /// ); + /// + /// // Squeeze the tensor dimensions. + /// // The resulting tensor will have dimensions [3, 3]. + /// let squeezed = tensor.squeeze::<2>(); + /// println!("{squeezed}"); + /// } + /// ``` + pub fn squeeze(self) -> Tensor { + let new_dims = self + .shape() + .iter() + .filter_map(|&dim| if dim == 1 { None } else { Some(dim) }) + .collect::>(); + check!(TensorCheck::squeeze_dims_len::(new_dims.len())); + + Tensor::new(K::reshape(self.primitive, new_dims.into())) + } + + /// Squeeze the tensor along the given dimension, removing the specified dimension + /// of size one, and effectively reducing the rank of the tensor by one. + /// + /// # Arguments + /// + /// - `dim`: The dimension to be squeezed. + /// + /// # Type Parameters + /// + /// - `D2`: The resulting number of dimensions in the squeezed tensor. + /// + /// # Panics + /// + /// If the size in the squeezed dimension is not 1. + /// + /// # Returns + /// + /// A new `Tensor` instance with the specified dimension removed. + /// + /// # Example + /// + /// ```rust + /// + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 3D tensor with dimensions [3, 1, 3] + /// let tensor = Tensor::<3>::from_data( + /// [[[3.0, 4.9, 2.0]], [[2.0, 1.9, 3.0]], [[4.0, 5.9, 8.0]]], + /// &device, + /// ); + /// + /// // Squeeze the dimension 1. + /// // The resulting tensor will have dimensions [3, 3]. + /// let squeezed = tensor.squeeze_dim::<2>(1); + /// println!("{squeezed}"); + /// } + /// ``` + pub fn squeeze_dim(self, dim: usize) -> Tensor { + check!(TensorCheck::squeeze::(dim, &self.shape())); + + let current_dims = self.shape(); + let mut new_dims: [usize; D2] = [0; D2]; + + new_dims[..dim].copy_from_slice(¤t_dims[..dim]); + new_dims[dim..].copy_from_slice(¤t_dims[dim + 1..]); + + check!(TensorCheck::squeeze_dims_len::(new_dims.len())); + Tensor::new(K::reshape(self.primitive, new_dims.into())) + } + + /// Removes specified dimensions of size 1 from a tensor's shape. This function takes a tensor and + /// an array of dimensions (`dims`) to be squeezed. If `dims` is provided, only the dimensions + /// specified in this array will be removed. Each dimension in `dims` should correspond to a size of 1 + /// in the tensor; otherwise, the dimension will not be squeezed. If `dims` is empty, all single-dimensional entries + /// in the tensor will be removed. If entries in `dims` are negative, then dimensions will be counted + /// from the back. + /// + /// # Arguments + /// + /// - `dims`: The dimension(s) to be squeezed. + /// + /// # Type Parameters + /// + /// - `D2`: The resulting number of dimensions in the squeezed tensor. + /// + /// # Returns + /// + /// A new `Tensor` instance with the specified dimensions removed. + /// + /// # Example + /// + /// ```rust + /// + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 4D tensor with dimensions [2, 1, 4, 1] + /// let tensor = Tensor::<4>::ones(Shape::new([2, 1, 4, 1]), &device); + /// + /// // Squeeze the dimensions 1 and 3. + /// // The resulting tensor will have dimensions [2, 4]. + /// let squeezed: Tensor<2> = tensor.squeeze_dims(&[1, 3]); + /// println!("{squeezed}"); + /// } + /// ``` + pub fn squeeze_dims(self, dims: &[isize]) -> Tensor { + let current_dims = self.shape(); + let mut dim_indices: Vec; + + // Check if dims is empty, if yes then assign dim_indices all single-dimensional entries + if dims.is_empty() { + dim_indices = current_dims + .iter() + .enumerate() + .filter_map(|(index, &dim)| if dim == 1 { Some(index) } else { None }) + .collect(); + } else { + // If negative dims, count from the back + dim_indices = dims + .iter() + .map(|&d| { + if d < 0 { + (current_dims.len() as isize + d) as usize + } else { + d as usize + } + }) + .collect(); + } + + // Sort indices and remove duplicates + dim_indices.sort_unstable(); + dim_indices.dedup(); + + // Make sure squeeze_dims doesn't result in a tensor with < 1 dimensions + check!(TensorCheck::squeeze_dims_input::( + &dim_indices, + ¤t_dims + )); + + // Calculate new dimensions + let mut new_dims = Vec::new(); + for (index, &dim_size) in current_dims.iter().enumerate() { + // Exclude the dimension if it's explicitly marked for squeezing + if dim_indices.contains(&index) { + check!(TensorCheck::squeeze::(index, ¤t_dims)); + continue; + } + new_dims.push(dim_size); + } + + // Check that after squeezing, we still respect the D2 size + check!(TensorCheck::squeeze_dims_len::(new_dims.len())); + + Tensor::new(K::reshape(self.primitive, new_dims.into())) + } + + /// Unsqueeze the current tensor. Create new leading dimensions to fit the given size. + /// + /// # Type Parameters + /// + /// - `D2`: The resulting number of dimensions in the unsqueezed tensor. + /// + /// # Panics + /// + /// If the output size `D2` is smaller than the current number of dimensions. + /// + /// # Returns + /// + /// A new `Tensor` instance with the specified dimensions added. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [3, 3] + /// let tensor = Tensor::<2>::ones(Shape::new([3, 3]), &device); + /// // Unsqueeze the tensor up to 4 dimensions. + /// // The resulting tensor will have dimensions [1, 1, 3, 3]. + /// let unsqueezed = tensor.unsqueeze::<4>(); + /// println!("{unsqueezed}"); + /// } + /// ``` + pub fn unsqueeze(self) -> Tensor { + check!(TensorCheck::unsqueeze::()); + + let mut dims = [1; D2]; + let num_ones = D2 - D; + let shape = self.shape(); + + dims[num_ones..(D + num_ones)].copy_from_slice(&shape[..D]); + + let shape = Shape::new(dims); + self.reshape(shape) + } + + /// Creates a new tensor with a dimension of size one inserted at the specified position. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [3, 3] + /// let tensor = Tensor::<2>::ones(Shape::new([3, 3]), &device); + /// // Unsqueeze the dimension 1. + /// // The resulting tensor will have dimensions [3, 1, 3]. + /// let unsqueezed: Tensor<3> = tensor.unsqueeze_dim(1); + /// println!("{unsqueezed}"); + /// } + /// ``` + pub fn unsqueeze_dim(self, dim: usize) -> Tensor { + check!(TensorCheck::unsqueeze_dim::(dim)); + + let mut dims = [1; D2]; + let shape = self.shape(); + + dims[0..dim].copy_from_slice(&shape[0..dim]); + + if dim < D { + dims[dim] = 1; + dims[(dim + 1)..].copy_from_slice(&shape[dim..]); + } else { + dims[dim] = 1; + } + + let shape = Shape::new(dims); + self.reshape(shape) + } + + /// Creates a new tensor with added dimensions of size one inserted at the specified indices. + /// The indices can be negative, in which case they are counted from the last to the first dimension. + /// the axes can contain duplicates, in which case the number of dimensions inserted at the index + /// is the number of duplicates. + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 3D tensor with dimensions [3, 4, 5] + /// let tensor = Tensor::<3>::ones(Shape::new([3, 4, 5]), &device); + /// // Unsqueeze the leading dimension (0) once and the trailing dimension (-1) twice. + /// // The resulting tensor will have dimensions [1, 3, 4, 5, 1, 1]. + /// let unsqueezed: Tensor<6> = tensor.unsqueeze_dims(&[0, -1, -1]); + /// println!("{unsqueezed}"); + /// } + /// ``` + pub fn unsqueeze_dims(self, axes: &[impl AsIndex]) -> Tensor { + let mut new_dims = [1; D2]; + let old_dims = self.shape(); + //for checking if the dimension is in the acceptable range + + //part 1: convert the negative indices to positive + let mut neg_offset = D2; + let mut dim_indices = axes + .iter() + .map(|d| { + let d = d.as_index(); + // check if the dimension is in the acceptable range + check!(TensorCheck::unsqueeze_dims::<{ D2 }>(d)); + (if d < 0 { + neg_offset -= 1; // handle multiple negative indices (decrease dim value in reverse) + d + neg_offset as isize + 1 + } else { + d + }) as usize + }) + .collect::>(); + + //sort the indices + dim_indices.sort_unstable(); + + // Per the documented semantics, duplicate axes mean "insert N dims at that index". + // After sorting, N insertions at position `i` logically occupy positions + // `i, i+1, ..., i+N-1` in the output, so bump each duplicate to the next slot. + // Example: sorted `[0, 0, 3]` becomes `[0, 1, 3]`, matching the intent of + // "two 1s starting at index 0, plus one 1 at index 3". + for i in 1..dim_indices.len() { + if dim_indices[i] <= dim_indices[i - 1] { + dim_indices[i] = dim_indices[i - 1] + 1; + } + } + + // Re-validate after normalization: bumping duplicates forward can push the + // last index past `D2 - 1` (e.g. `[2, 2]` targeting rank 3 normalizes to + // `[2, 3]`). The per-axis check above only runs on pre-normalization values, + // so we re-check here to surface a clear `TensorCheck` error instead of + // letting the copy loop panic on an out-of-bounds `old_dims` read. + for &dim_index in &dim_indices { + check!(TensorCheck::unsqueeze_dims::<{ D2 }>(dim_index as isize)); + } + + // Loop over the entries/indices of the `new_dims` array. + // When the current entry should be 1 from the unsqueeze operation, simply increment + // the index for `dims_indices` to account for "adding" its entry to `new_dims`. + // Otherwise, the dim from the current entry of `old_dims` should be copied to `new_dims`. + let mut dim_indices_curr_idx = 0; + let mut old_dims_curr_idx = 0; + for new_dims_curr_idx in 0..D2 { + // If all indices in `dim_indices` have been processed, then + // simply copy all the remaining dims from `old_dims` to `new_dims` + if dim_indices_curr_idx == dim_indices.len() { + new_dims[new_dims_curr_idx..].copy_from_slice(&old_dims[old_dims_curr_idx..]); + break; + } + + if new_dims_curr_idx == dim_indices[dim_indices_curr_idx] { + dim_indices_curr_idx += 1; + } else { + new_dims[new_dims_curr_idx] = old_dims[old_dims_curr_idx]; + old_dims_curr_idx += 1; + } + } + + //lastly, create the shape and reshape + let shape = Shape::new(new_dims); + self.reshape(shape) + } + + /// Roll operation along a specific dimension; wrapping around the elements. + /// + /// ## Parameters + /// + /// - `shift`: The roll extent; supports negative values and wraps around. + /// - `dim`: The dimension to roll; supports negative indexing. + /// + /// ## Returns + /// + /// A new tensor with the specified dimension rolled by the given shift amount. + pub fn roll_dim(self, shift: Shift, dim: Dim) -> Self + where + Shift: AsIndex, + Dim: AsIndex, + { + let dim = dim.expect_dim_index(D); + let size = self.shape()[dim]; + if size == 0 { + // If the dimension is empty, return the tensor as is. + return self; + } + + let shift = wrap_index(shift, size); + if shift == 0 { + // If the shift is zero, return the tensor as is. + return self; + } + + self.unchecked_roll_dim(shift, dim) + } + + /// Internal implementation of `roll_dim` that does not canonicalize dimensions or shifts. + /// + /// ## Parameters + /// + /// - `shift`: The number of positions to shift; must be (0 < shift < size). + /// - `dim`: The dimension to roll; must be a valid index for the tensor's shape. + /// + /// ## Returns + /// + /// A new tensor with the specified dimension rolled by the given shift amount. + #[inline(always)] + fn unchecked_roll_dim(self, shift: usize, dim: usize) -> Self { + #[cfg(debug_assertions)] + { + let size = self.shape()[dim]; + assert!( + 0 < shift && shift < size, + "Expected: 0 < shift < size: found shift={shift}, size={size}", + ); + assert!( + dim < self.shape().num_dims(), + "Expected: dim < num_dims: found dim={dim}, num_dims={size}", + ); + } + + Tensor::cat( + vec![ + self.clone().slice_dim(dim, shift..), + self.slice_dim(dim, ..shift), + ], + dim, + ) + } + + /// Roll operation. + /// + /// Note: unlike ``pytorch``, `dims` and `shifts` must have the same length. + /// + /// A given `dim` may be rolled multiple times, and the shifts will be applied sequentially. + /// + /// ## Parameters + /// + /// - `shifts`: A slice of shifts corresponding to each dimension; + /// supports negative values and wraps around. + /// - `dims`: A slice of dimensions to roll; supports negative indexing. + /// + /// ## Returns + /// + /// A new tensor with the specified dimensions rolled by the given shifts. + pub fn roll(self, shifts: &[Shift], dims: &[Dim]) -> Self + where + Shift: AsIndex, + Dim: AsIndex, + { + assert_eq!( + dims.len(), + shifts.len(), + "Dimensions and shifts must align; found dims={dims:#?}, shifts={shifts:#?}", + ); + + // This is a fair amount of complexity, which could be replaced + // by a simple canonicalization of `dims` and wrapping of `shifts`. + // The work is done here to ensure that any roll operation + // which could be a no-op is a no-op; simplifying the accounting + // needed by backend-specific implementations of the inner roll op. + + let item_count = dims.len(); + + let shape = self.shape(); + + // Accumulate the effective shifts for each dimension. + let mut accumulated_shifts: Vec = vec![0; shape.len()]; + for i in 0..item_count { + let dim = dims[i].expect_dim_index(D); + accumulated_shifts[dim] += shifts[i].as_index(); + } + + // Do this after we've checked the validity of `dims` and `shifts`. + if self.shape().num_elements() == 0 { + // If the tensor is empty, return it as is. + return self; + } + + // Wrap the accumulated shifts, and filter out empty dimensions. + let mut effective_dims: Vec = Vec::with_capacity(item_count); + let mut effective_shifts: Vec = Vec::with_capacity(item_count); + for dim in 0..shape.len() { + // `wrap_index` should inline, and has a fast-exit path for zero shifts. + let shift = wrap_index(accumulated_shifts[dim], shape[dim]); + if shift == 0 { + continue; + } + + effective_dims.push(dim); + effective_shifts.push(shift); + } + + // If no shifts are needed, return the original tensor. + if effective_shifts.is_empty() { + return self; + } + + // At this point: + // - `dims` contains the effective dimensions to roll, in index order, + // - `shifts` contains the effective usize shifts for each dimension. + // - Every shift is non-zero, and less than the size of the corresponding dimension. + self.unchecked_roll(&effective_shifts, &effective_dims) + } + + /// `roll` internal implementation. + /// + /// ## Parameters + /// + /// - `shifts`: A slice of shifts corresponding to each dimension; + /// must be non-empty, the same length as `dims`, and all ``1..``. + /// - `dims`: A slice of dimensions to roll; must be non-empty; + /// the same length as `shifts`, and must not contain repeats. + /// + /// ## Panics + /// + /// Panics if the shifts and dimensions do not align, or if dimensions contain repeats. + /// + /// ## Returns + /// + /// A new tensor with the specified dimensions rolled by the given shifts. + #[inline(always)] + fn unchecked_roll(self, shifts: &[usize], dims: &[usize]) -> Self { + #[cfg(debug_assertions)] + { + assert!(!shifts.is_empty()); + assert_eq!( + shifts.len(), + dims.len(), + "Shifts and dimensions must align; found {} shifts and {} dims", + shifts.len(), + dims.len() + ); + + let mut unique_dims = dims.to_vec(); + unique_dims.dedup(); + + assert_eq!( + unique_dims.len(), + dims.len(), + "Dimensions must not contain repeats; found {} unique dims and {} total dims", + unique_dims.len(), + dims.len() + ) + } + + let x = self.unchecked_roll_dim(shifts[0], dims[0]); + + if dims.len() == 1 { + x + } else { + x.unchecked_roll(&shifts[1..], &dims[1..]) + } + } + + /// Returns a tensor containing the elements selected from the given slices. + /// + /// This method provides flexible tensor slicing with support for various range types, + /// negative indices, and stepped slicing. The method accepts both single slices and + /// arrays of slices, with the [`s!`] macro providing convenient syntax for complex patterns. + /// + /// # Arguments + /// + /// * `slices` - Can be: + /// - A single range for 1D slicing (e.g., `0..5`, `..`, `2..`) + /// - An array of ranges (e.g., `[0..2, 1..4]`) + /// - The [`s!`] macro output for advanced slicing with steps + /// - a `&Vec` or `&[Slice]` + /// + /// # Behavior + /// + /// - Supports partial and full slicing in any number of dimensions + /// - Handles negative indices by wrapping from the end (-1 is the last element) + /// - Automatically clamps ranges that exceed tensor dimensions + /// - Supports stepped slicing for selecting every nth element + /// - Negative steps reverse the selection order + /// + /// # Panics + /// + /// - If the number of slices exceeds the tensor's dimensions + /// - If a range is descending (e.g., 2..1) or empty (e.g., 1..1) without negative step + /// - If a step is zero + /// + /// # Examples + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, s}; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// // Single dimension slicing - no brackets needed! + /// let tensor = Tensor::<1, burn_tensor::Int>::arange(0..10, &device); + /// let slice = tensor.clone().slice(2..8); // Simple range + /// assert_eq!(slice.into_data().to_vec::().unwrap(), vec![2, 3, 4, 5, 6, 7]); + /// + /// // Using s! macro for single dimension with step + /// let slice = tensor.clone().slice(s![0..10;2]); // Every 2nd element + /// assert_eq!(slice.into_data().to_vec::().unwrap(), vec![0, 2, 4, 6, 8]); + /// + /// // Reverse a dimension with negative step + /// let slice = tensor.slice(s![..;-1]); // Reverse entire tensor + /// assert_eq!(slice.into_data().to_vec::().unwrap(), vec![9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + /// + /// // Multi-dimensional slicing + /// let tensor = Tensor::<2>::ones(Shape::new([4, 6]), &device); + /// + /// // Array syntax for simple ranges + /// let slice = tensor.clone().slice([1..3, 2..5]); + /// assert_eq!(slice.dims(), [2, 3]); + /// + /// // Advanced multi-dimensional with s! macro + /// let slice = tensor.clone().slice(s![0..4;2, ..;-1]); // Every 2nd row, reverse columns + /// assert_eq!(slice.dims(), [2, 6]); + /// + /// // Complex 3D example with mixed slice types + /// let tensor = Tensor::<3>::ones(Shape::new([4, 6, 8]), &device); + /// let slice = tensor.slice(s![1..3, ..;2, -3..]); // Rows 1-2, every 2nd col, last 3 depth + /// assert_eq!(slice.dims(), [2, 3, 3]); + /// + /// // Using negative indices + /// let tensor = Tensor::<2>::ones(Shape::new([4, 6]), &device); + /// let slice = tensor.slice(s![-2.., ..-1]); // Last 2 rows, all but last column + /// assert_eq!(slice.dims(), [2, 5]); + /// } + /// ``` + /// + /// # See Also + /// + /// - [`s!`] - The recommended macro for creating complex slice specifications + /// - [`slice_assign`](Self::slice_assign) - Assign values to a slice + /// - [`slice_fill`](Self::slice_fill) - Fill a slice with a constant value + /// - [`slice_dim`](Self::slice_dim) - Slice a single dimension + /// + /// [`s!`]: crate::s! + pub fn slice(self, slices: S) -> Self + where + S: SliceArg, + { + let shape = self.shape(); + let slices = slices.into_slices(&shape); + + // Validate slices + check!(TensorCheck::slice::(&shape, &slices)); + + // Calculate output shape and check for empty slices + let mut output_dims = shape.clone(); + for (dim, slice) in slices.iter().enumerate() { + output_dims[dim] = slice.output_size(shape[dim]); + } + + // Return empty tensor if any dimension is 0 (empty slice) + if output_dims.contains(&0) { + return Self::empty(output_dims, &self.device()); + } + Self::new(K::slice(self.primitive, &slices)) + } + + /// Assigns values to a slice of the tensor and returns the updated tensor. + /// + /// This method supports advanced slicing with steps, including negative steps for reverse + /// assignment. Like `slice`, it accepts both single slices and arrays, with the [`s!`] macro + /// providing powerful syntax for complex patterns. + /// + /// # Arguments + /// + /// * `slices` - Slice specification (same format as `slice` method) + /// * `values` - Tensor with values to assign (must match slice dimensions) + /// + /// # Panics + /// + /// - If slices exceed tensor dimensions + /// - If values dimensions don't match the selected slice shape + /// - If a step is zero + /// + /// # Examples + /// + /// ```rust + /// use burn_tensor::{Tensor, s}; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// // Simple assignment to a sub-region + /// let mut tensor = Tensor::<2>::zeros([4, 6], &device); + /// let values = Tensor::<2>::ones([2, 3], &device); + /// tensor = tensor.slice_assign([1..3, 2..5], values); + /// // Now tensor[1..3, 2..5] contains ones + /// + /// // Single dimension assignment with step + /// let mut tensor = Tensor::<1>::zeros([10], &device); + /// let values = Tensor::<1>::ones([5], &device); + /// tensor = tensor.slice_assign(s![0..10;2], values); + /// // Now every 2nd element is 1: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0] + /// + /// // Reverse assignment with negative step + /// let mut tensor = Tensor::<1>::from_data([0.0, 1.0, 2.0, 3.0, 4.0], &device); + /// let values = Tensor::<1>::from_data([10.0, 11.0, 12.0, 13.0, 14.0], &device); + /// tensor = tensor.slice_assign(s![..;-1], values); + /// // Assigns in reverse: [14, 13, 12, 11, 10] + /// + /// // Complex multi-dimensional assignment + /// let mut tensor = Tensor::<3>::zeros([4, 6, 8], &device); + /// let values = Tensor::<3>::ones([2, 3, 3], &device); + /// tensor = tensor.slice_assign(s![0..4;2, ..;2, -3..], values); + /// // Assigns to every 2nd row, every 2nd column, last 3 in depth + /// + /// // Mixed syntax example + /// let mut tensor = Tensor::<2>::zeros([8, 8], &device); + /// let pattern = Tensor::<2>::ones([4, 4], &device); + /// tensor = tensor.slice_assign(s![..;2, ..;2], pattern); + /// // Creates a checkerboard pattern with ones + /// } + /// ``` + /// + /// # See Also + /// + /// - [`s!`] - The recommended macro for creating complex slice specifications + /// - [`slice`](Self::slice) - Extract a slice from a tensor + /// - [`slice_fill`](Self::slice_fill) - Fill a slice with a constant value + /// + /// [`s!`]: crate::s! + pub fn slice_assign(self, slices: S, values: Self) -> Self + where + S: SliceArg, + { + let shape = self.shape(); + let slices = slices.into_slices(&shape); + + // Check if any slice produces 0 elements (empty assignment). + // Empty assignments are no-ops and would cause issues in backend implementations. + let is_empty_assignment = slices + .iter() + .enumerate() + .any(|(i, slice)| slice.output_size(shape[i]) == 0); + + if is_empty_assignment { + return self; + } + + check!(TensorCheck::slice_assign::( + &shape, + &values.shape(), + &slices + )); + + Self::new(K::slice_assign(self.primitive, &slices, values.primitive)) + } + + /// Fills a slice of the tensor with a constant value and returns the updated tensor. + /// + /// Like other slice methods, accepts both single slices and arrays. However, this method + /// currently **does not support stepped slicing** - use [`slice_assign`](Self::slice_assign) + /// with a constant tensor for stepped patterns. + /// + /// # Arguments + /// + /// * `slices` - Slice specification (same format as `slice` method, but no steps) + /// * `value` - The value to fill the slice with + /// + /// # Panics + /// + /// - If slices exceed tensor dimensions + /// - If any slice has a step != 1 (not yet supported) + /// + /// # Examples + /// + /// ```rust + /// use burn_tensor::{Tensor, s}; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// // Simple fill for a single dimension + /// let mut tensor = Tensor::<1>::zeros([10], &device); + /// tensor = tensor.slice_fill(2..5, 1.0); + /// // Now tensor is [0, 0, 1, 1, 1, 0, 0, 0, 0, 0] + /// + /// // Multi-dimensional fill + /// let mut tensor = Tensor::<2>::zeros([4, 6], &device); + /// tensor = tensor.slice_fill([1..3, 2..5], -1.0); + /// // Fills the rectangle at rows 1-2, columns 2-4 with -1 + /// + /// // Using negative indices + /// let mut tensor = Tensor::<1>::zeros([10], &device); + /// tensor = tensor.slice_fill(-3.., 2.0); + /// // Fills the last 3 elements with 2.0 + /// + /// // Complex multi-dimensional example + /// let mut tensor = Tensor::<3>::ones([4, 6, 8], &device); + /// tensor = tensor.slice_fill(s![1..3, .., -2..], 0.0); + /// // Sets rows 1-2, all columns, last 2 in depth to 0 + /// + /// // Stepped slicing is supported + /// let mut tensor = Tensor::<1>::zeros([10], &device); + /// tensor = tensor.slice_fill(s![0..10;2], 1.0); + /// // Now every 2nd element is 1: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0] + /// } + /// ``` + /// + /// # See Also + /// + /// - [`s!`] - The macro for creating slice specifications with steps + /// - [`slice`](Self::slice) - Extract a slice from a tensor + /// - [`slice_assign`](Self::slice_assign) - Assign tensor values to a slice + /// + /// [`s!`]: crate::s! + pub fn slice_fill(self, slices: S, value: E) -> Self + where + S: SliceArg, + { + let shape = self.shape(); + let slices = slices.into_slices(&shape); + + check!(TensorCheck::slice::(&shape, &slices)); + + let slice_shape = shape.slice(&slices).unwrap(); + let value = Tensor::<1, K>::from_data([value], (&self.device(), self.dtype())); + let value = value.expand(slice_shape); + self.slice_assign(&slices, value) + } + + /// Returns a new tensor with the specified dimension sliced. + /// + /// # Arguments + /// + /// * `dim`: The dimension to slice. + /// * `slice`: The slice specification for the dimension. Can be a range (e.g., `2..5`), + /// slice with step (via `s!` macro, e.g., `s![0..10;2]`), or any type that implements `Into`. + /// + /// # Returns + /// + /// A new tensor with the specified dimension sliced. + /// + /// # Panics + /// + /// If the slice is out of bounds for the specified dimension. + /// + /// # Examples + /// + /// ```rust + /// # use burn_tensor::{Tensor, s}; + /// # + /// # fn example() { + /// # let device = Default::default(); + /// let tensor = Tensor::<3>::zeros([3, 4, 5], &device); + /// + /// // Simple range slicing + /// let sliced = tensor.clone().slice_dim(1, 1..3); + /// assert_eq!(sliced.shape().as_slice(), [3, 2, 5]); + /// + /// // Slicing with step - take every 2nd element + /// let sliced = tensor.clone().slice_dim(2, s![0..5;2]); + /// assert_eq!(sliced.shape().as_slice(), [3, 4, 3]); // Takes indices 0, 2, 4 + /// + /// // Reverse slicing with negative step + /// let sliced = tensor.clone().slice_dim(1, s![..;-1]); + /// assert_eq!(sliced.shape().as_slice(), [3, 4, 5]); // Reverses dimension 1 + /// + /// // Select from index 2 with step 3 + /// let sliced = tensor.clone().slice_dim(0, s![2..;3]); + /// assert_eq!(sliced.shape().as_slice(), [1, 4, 5]); // Takes only index 2 + /// + /// // Select single index (reduces dimension to size 1) + /// let sliced = tensor.slice_dim(0, 1); + /// assert_eq!(sliced.shape().as_slice(), [1, 4, 5]); + /// # } + /// ``` + /// + /// # See Also + /// + /// - [`slice`](Self::slice) - Slice multiple dimensions simultaneously + /// - [`s!`] - The macro for creating complex slice specifications + /// + /// [`s!`]: crate::s! + pub fn slice_dim(self, dim: usize, slice: S) -> Self + where + S: Into, + { + check!(TensorCheck::check_dim::(dim)); + let slice: Slice = slice.into(); + + let mut slices = vec![Slice::full(); D]; + slices[dim] = slice; + + self.slice(&slices) + } + + /// Returns the device of the current tensor. + pub fn device(&self) -> Device { + K::device(&self.primitive) + } + + /// Move the tensor to the given device. + pub fn to_device(self, device: &Device) -> Self { + Self::new(K::to_device(self.primitive, device)) + } + + /// Select tensor elements along the given dimension corresponding to the given indices. + /// + /// # Arguments + /// + /// * `dim` - The dimension to select from. Supports negative indexing. + /// * `indices` - The indices of the elements to select. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Int}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [4.0, 5.0, 6.0]], &device); + /// let indices = Tensor::<1, Int>::from_data([0], &device); + /// let tensor = tensor.select(0, indices); + /// println!("{tensor}"); + /// // [[1.0, -2.0, 3.0]] + /// } + /// ``` + pub fn select(self, dim: impl AsIndex, indices: Tensor<1, Int>) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::select::(dim)); + Self::new(K::select(self.primitive, dim, indices.primitive)) + } + + /// Assign the selected elements along the given dimension corresponding to the given indices + /// from the value tensor to the original tensor using sum reduction. + /// + /// # Note + /// For booleans, the sum operator is logical or. + /// + /// # Arguments + /// + /// * `dim` - The dimension along which to select. Supports negative indexing. + /// * `indices` - The indices to select from the tensor. + /// * `values` - The values to assign to the selected indices. + /// * `update` - The operation used to update the existing values at the indexed positions (e.g., add). + /// + /// # Example + /// + /// Example using a 3D tensor: + /// + /// `input[indices[i], j, k] += values[i, j, k]; // dim = 0` + /// `input[i, indices[j], k] += values[i, j, k]; // dim = 1` + /// `input[i, j, indices[k]] += values[i, j, k]; // dim = 2` + /// `input[i, j, indices[k]] += values[i, j, k]; // dim = -1 (same as dim = 2)` + /// + /// # Warning + /// + /// Not all backends have runtime bound checks for the indices, so make sure they are valid. + /// Otherwise, out of bounds indices could lead to unexpected results instead of panicking. + pub fn select_assign( + self, + dim: impl AsIndex, + indices: Tensor<1, Int>, + values: Tensor, + update: IndexingUpdateOp, + ) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::select_assign::( + dim, + &indices.shape(), + &values.shape() + )); + + Self::new(K::select_assign( + self.primitive, + dim, + indices.primitive, + values.primitive, + update, + )) + } + + /// Update the given tensor with the value tensor where the mask is true. + /// + /// This is similar to [mask_fill](Tensor::mask_fill), however the value is a tensor instead of + /// a scalar. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let mask = Tensor::<2, Bool>::from_data([[true, false, true], [false, true, false]], &device); + /// let value = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor.mask_where(mask, value); + /// println!("{tensor}"); + /// // [[2.0, -2.0, 4.0], [5.0, 2.0, 6.0]] + /// } + /// ``` + pub fn mask_where(self, mask: Tensor, value: Self) -> Self { + Self::new(K::mask_where( + self.primitive, + mask.primitive, + value.primitive, + )) + } + + /// Update the given tensor with the value where the mask is true. + /// + /// This is similar to [mask_where](Tensor::mask_where), however the value is a scalar instead of + /// a tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let mask = Tensor::<2, Bool>::from_data([[true, false, true], [false, true, false]], &device); + /// let tensor = tensor.mask_fill(mask, 3.0); + /// println!("{tensor}"); + /// // [[3.0, -2.0, 3.0], [5.0, 3.0, 6.0]] + /// } + /// ``` + pub fn mask_fill(self, mask: Tensor, value: E) -> Self { + let value = Scalar::new(value, &self.dtype()); + Self::new(K::mask_fill(self.primitive, mask.primitive, value)) + } + + /// Gather tensor elements corresponding to the given indices from the specified dim. + /// + /// Example using a 3D tensor: + /// + /// `output[i, j, k] = input[indices[i, j, k], j, k]; // dim = 0` + /// `output[i, j, k] = input[i, indices[i, j, k], k]; // dim = 1` + /// `output[i, j, k] = input[i, j, indices[i, j, k]]; // dim = 2` + /// + /// # Notes + /// + /// The index tensor should have the same shape as the original tensor except for the dim + /// specified. + /// + /// # Warning + /// Not all backends have runtime bound checks for the indices, so make sure the they are valid. + /// Otherwise, out of bounds indices could lead to unexpected results instead of panicking. + pub fn gather(self, dim: usize, indices: Tensor) -> Self { + check!(TensorCheck::gather::( + dim, + &self.shape(), + &indices.shape() + )); + + Self::new(K::gather(dim, self.primitive, indices.primitive)) + } + + /// Assign the gathered elements corresponding to the given indices along the specified dimension + /// from the value tensor to the original tensor using sum reduction. + /// + /// Example using a 3D tensor: + /// + /// `input[indices[i, j, k], j, k] += values[i, j, k]; // dim = 0` + /// `input[i, indices[i, j, k], k] += values[i, j, k]; // dim = 1` + /// `input[i, j, indices[i, j, k]] += values[i, j, k]; // dim = 2` + /// + /// # Arguments + /// * `dim` - The axis along which to scatter elements. + /// * `indices` - The indices of the elements to scatter. + /// * `values` - The values to scatter into the tensor. + /// * `update` - The operation used to update the existing values at the indexed positions (e.g., add). + /// + /// # Notes + /// + /// The index tensor should have the same shape as the original tensor except for the specified + /// dimension. The value and index tensors should have the same shape. + /// + /// Other references to the input tensor will not be modified by this operation. + /// + /// # Warning + /// Not all backends have runtime bound checks for the indices, so make sure the they are valid. + /// Otherwise, out of bounds indices could lead to unexpected results instead of panicking. + /// + /// # Panics + /// If the `update` is not `IndexingUpdateOp::Add`. Other operations are currently not implemented. + pub fn scatter( + self, + dim: usize, + indices: Tensor, + values: Self, + update: IndexingUpdateOp, + ) -> Self { + check!(TensorCheck::scatter::( + dim, + &self.shape(), + &indices.shape(), + &values.shape() + )); + + Self::new(K::scatter( + dim, + self.primitive, + indices.primitive, + values.primitive, + update, + )) + } + + /// Multi-dimensional scatter: update the tensor at locations given by `indices` using the specified `update` operation. + /// + /// The size of `indices`'s last axis (call it `K`) indexes the leading `K` dims of `self`; + /// the batch shape `indices.shape[0..M-1]` is preserved. `values` has shape + /// `indices.shape[0..M-1] ++ self.shape[K..D]`. Constraints: `K <= D` and `M >= 1`. + /// + /// # Arguments + /// * `indices` - The indices of the elements to scatter. + /// * `values` - The values to scatter into the tensor. + /// * `update` - The operation used to update the existing values at the indexed positions (e.g., add). + /// + /// # Note + /// + /// When `indices` contains duplicate entries, behavior varies by operation: + /// - For `Add`, accumulation is supported, though results may be non-deterministic on GPU + /// backends. + /// - For other operations (`Assign`, `Mul`, `Min`, `Max`), duplicate indices result in + /// undefined behavior for both the forward result and the backward gradients. + /// + /// For deterministic results and correct gradient calculation across all operations, + /// `indices` should contain unique entries. + /// + /// # Warning + /// + /// Not all backends have runtime bound checks for the indices, so make sure they are valid. + /// Otherwise, out of bounds indices could lead to unexpected results instead of panicking. + pub fn scatter_nd( + self, + indices: Tensor, + values: Tensor, + update: IndexingUpdateOp, + ) -> Self { + check!(TensorCheck::scatter_nd::( + &self.shape(), + &indices.shape(), + &values.shape() + )); + Self::new(K::scatter_nd( + self.primitive, + indices.primitive, + values.primitive, + update, + )) + } + + /// Multi-dimensional gather: collect slices from `self` at multi-index locations + /// specified by `indices`. + /// + /// The size of `indices`'s last axis (call it `K`) indexes the leading `K` dims of `self`; + /// the batch shape `indices.shape[0..M-1]` is preserved. The output has shape + /// `indices.shape[0..M-1] ++ self.shape[K..D]`. Constraints: `K <= D` and `M >= 1`. + /// + /// # Warning + /// + /// Not all backends have runtime bound checks for the indices, so make sure they are valid. + /// Otherwise, out of bounds indices could lead to unexpected results instead of panicking. + pub fn gather_nd( + self, + indices: Tensor, + ) -> Tensor { + check!(TensorCheck::gather_nd::(&indices.shape())); + Tensor::new(K::gather_nd(self.primitive, indices.primitive)) + } + + /// Converts the data of the current tensor. + /// + /// # Note + /// + /// For better performance, prefer using a [Transaction](crate::Transaction) when reading multiple + /// tensors at once. This may improve laziness, especially if executed on a different + /// thread in native environments. + pub fn into_data(self) -> TensorData { + into_data_sync_impl(self.primitive, K::KIND) + } + + /// Converts the data of the current tensor and returns any error that might have occurred since the + /// last time the device was synchronized. + /// + /// # Note + /// + /// For better performance, prefer using a [Transaction](crate::Transaction) when reading multiple + /// tensors at once. This may improve laziness, especially if executed on a different + /// thread in native environments. + pub fn try_into_data(self) -> Result { + try_into_data_sync_impl(self.primitive, K::KIND) + } + + /// Converts the data of the current tensor. + /// + /// # Note + /// + /// For better performance, prefer using a [Transaction](crate::Transaction) when reading multiple + /// tensors at once. This may improve laziness, especially if executed on a different + /// thread in native environments. + pub fn to_data(&self) -> TensorData { + self.clone().into_data() + } + + /// Returns the data of the current tensor. + pub fn into_data_async( + self, + ) -> impl core::future::Future> + Send { + into_data_async_impl(self.primitive, K::KIND) + } + + /// Returns the data of the current tensor. + pub fn to_data_async( + &self, + ) -> impl core::future::Future> + Send { + into_data_async_impl(self.primitive.clone(), K::KIND) + } + + /// Create a tensor from the given data on the given device. + pub fn from_data(data: T, options: impl Into) -> Self + where + T: Into, + { + let data = data.into(); + check!(TensorCheck::creation_ops::( + "From Data", + data.shape.as_slice() + )); + + // Use the given dtype when provided, otherwise default device dtype + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + + Self::new(K::from_data(data, &opt.device, dtype)) + } + + /// Repeat the tensor along the given dimension. + /// + /// The output tensor has the same shape, except along the given dimension. + /// + /// # Arguments + /// - `dim`: The dimension to repeat. + /// - `times`: The number of times to repeat the tensor along the given dimension in the new tensor. + /// + /// # Returns + /// + /// A new tensor with the given dimension repeated `times` times. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [3, 2] + /// let tensor = Tensor::<2>::from_data([[3.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// + /// // Repeat the tensor along the dimension 0 twice. + /// // [[3.0, 4.9], [2.0, 1.9], [4.0, 5.9], [3.0, 4.9], [2.0, 1.9], [4.0, 5.9]] + /// // The resulting tensor will have dimensions [6, 2]. + /// let repeated = tensor.repeat_dim(0, 2); + /// println!("{repeated}"); + /// } + /// ``` + pub fn repeat_dim(self, dim: usize, times: usize) -> Self { + if times > 0 { + Self::new(K::repeat_dim(self.primitive, dim, times)) + } else { + let shape = self.shape().repeat(dim, times).unwrap(); + Self::empty(shape, &self.device()) + } + } + + /// Repeat the tensor along the given dimensions. + /// # Arguments + /// - `sizes`: Borrowed slice of the number of times to repeat each dimension. + /// + /// # Returns + /// + /// A new tensor with the given dimensions repeated `times` times. + /// + /// # Panics + /// + /// If `sizes` contains more elements than the number of dimensions. + /// + /// # Example + /// + /// ```rust + /// + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [3, 2] + /// let tensor = Tensor::<2>::from_data([[3.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// + /// // Repeat the tensor along the dimension 0 twice and the dimension 0 once. + /// // [[3.0, 4.9], [2.0, 1.9], [4.0, 5.9], [3.0, 4.9], [2.0, 1.9], [4.0, 5.9]] + /// // The resulting tensor will have dimensions [6, 2]. + /// let repeated = tensor.repeat(&[2, 1]); + /// } + /// ``` + pub fn repeat(self, sizes: &[usize]) -> Self { + if sizes.contains(&0) { + let mut shape = self.shape(); + for (dim, ×) in sizes.iter().enumerate() { + shape = shape.repeat(dim, times).unwrap(); + } + + return Self::empty(shape, &self.device()); + } + + let mut tensor = self; + for (dim, ×) in sizes.iter().enumerate() { + if times > 1 { + tensor = tensor.repeat_dim(dim, times); + } + } + tensor + } + + /// Applies element-wise equal comparison. + /// + /// # Returns + /// A boolean tensor that is `true` where input is equal to `other` and `false` elsewhere. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let t1 = Tensor::<2>::from_data([[2.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// let t2 = Tensor::<2>::from_data([[3.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// // Compare the elements of the two 2D tensors with dimensions [3, 2]. + /// // [[false, true], [true, true], [true, true]] + /// let equal = t1.equal(t2); + /// println!("{equal}"); + /// } + /// ``` + pub fn equal(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("Equal", &self, &other)); + Tensor::new(K::equal(self.primitive, other.primitive)) + } + + /// Applies element-wise non-equality comparison. + /// + /// # Returns + /// A boolean tensor that is `true` where input is not equal to `other` and `false` elsewhere. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let t1 = Tensor::<2>::from_data([[2.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// let t2 = Tensor::<2>::from_data([[3.0, 4.9], [2.0, 1.9], [4.0, 5.9]], &device); + /// // Compare the elements of the two 2D tensors for inequality. + /// // [[true, false], [false, false], [false, false]] + /// let not_equal = t1.not_equal(t2); + /// println!("{not_equal}"); + /// } + /// ``` + pub fn not_equal(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("NotEqual", &self, &other)); + Tensor::new(K::not_equal(self.primitive, other.primitive)) + } + + /// Applies element wise equal comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.equal_elem(3.0); + /// println!("{tensor}"); + /// // [[false, false, true], [false, false, false]] + /// } + /// ``` + pub fn equal_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::equal_elem(self.primitive, other)) + } + + /// Applies element wise non-equality comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.not_equal_elem(3.0); + /// println!("{tensor}"); + /// // [[true, true, false], [true, true, true]] + /// } + /// ``` + pub fn not_equal_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::not_equal_elem(self.primitive, other)) + } + + /// Concatenates all tensors into a new one along the given dimension. + /// + /// # Panics + /// + /// - If `dim` is higher than the rank. + /// - If `tensors` is an empty vector. + /// - If all tensors don't have the same shape (the dimension `dim` is ignored). + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let t1 = Tensor::<2>::from_data([[3.0, 4.9, 2.0, 1.0], [2.0, 1.9, 3.0, 1.0]], &device); + /// let t2 = Tensor::<2>::from_data([[4.0, 5.9, 8.0], [1.4, 5.8, 6.0]], &device); + /// + /// // Concatenate the two tensors with shapes [2, 4] and [2, 3] along the dimension 1. + /// // [[3.0, 4.9, 2.0, 1.0, 4.0, 5.9, 8.0], [2.0, 1.9, 3.0, 1.0, 1.4, 5.8, 6.0]] + /// // The resulting tensor will have shape [2, 7]. + /// let concat = Tensor::cat(vec![t1, t2], 1); + /// println!("{concat}"); + /// } + /// ``` + pub fn cat(tensors: Vec, dim: usize) -> Self { + check!(TensorCheck::cat(tensors.as_slice(), dim)); + + // Filter out tensors with size 0 along the concatenation dimension. + // Empty tensors don't contribute to the output and would cause issues + // in backend implementations (e.g., division by zero in slice_assign). + // Safety: TensorCheck::cat ensures tensors is non-empty + let first_tensor = tensors.first().unwrap(); + let device = first_tensor.device(); + let mut shape = first_tensor.shape(); + + let non_empty_primitives: Vec<_> = tensors + .into_iter() + .filter(|t| t.shape()[dim] > 0) + .map(|t| t.primitive) + .collect(); + + // If all tensors were empty, return an empty tensor with size 0 on concat dim + if non_empty_primitives.is_empty() { + shape[dim] = 0; + return Self::empty(shape, &device); + } + + Self::new(K::cat(non_empty_primitives, dim)) + } + + /// Concatenates all tensors into a new one along a new dimension. + /// + /// # Panics + /// + /// - If all tensors don't have the same shape. + /// - If given dimension is not with range of 0..D2 + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let t1 = Tensor::<2>::from_data([[3.0, 4.9, 2.0], [2.0, 1.9, 3.0]], &device); + /// let t2 = Tensor::<2>::from_data([[4.0, 5.9, 8.0], [1.4, 5.8, 6.0]], &device); + /// let t3 = Tensor::<2>::from_data([[4.0, 5.9, 8.0], [1.4, 5.8, 6.0]], &device); + /// + /// // Concatenate the three tensors with shape [2, 3] along a new dimension, 0. + /// // [[[3.0, 4.9, 2.0], [2.0, 1.9, 3.0]], + /// // [[4.0, 5.9, 8.0], [1.4, 5.8, 6.0]], + /// // [[4.0, 5.9, 8.0], [1.4, 5.8, 6.0]]] + /// // The resulting tensor will have shape [3, 2, 3]. + /// let stacked= Tensor::stack::<3>(vec![t1, t2, t3], 0); + /// println!("{stacked}"); + /// } + /// ``` + pub fn stack(tensors: Vec>, dim: usize) -> Tensor { + check!(TensorCheck::stack::(tensors.as_slice(), dim)); + let tensors = tensors.into_iter().map(|t| t.unsqueeze_dim(dim)).collect(); + Tensor::::cat(tensors, dim) + } + + /// Iterate over slices of tensors alongside a given dimension. + /// + /// # Panics + /// + /// If given dimension is greater than or equal to tensor rank. + /// + /// # Returns + /// + /// A tensor iterator. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[3.0, 4.9, 2.0], [2.0, 1.9, 3.0]], &device); + /// // Given a 2D tensor with dimensions [2, 3], iterate over slices of tensors along the dimension 0. + /// let iter = tensor.iter_dim(0); + /// for (i,tensor) in iter.enumerate() { + /// println!("Tensor {}: {}", i, tensor); + /// // Tensor 0: Tensor { data: [[3.0, 4.9, 2.0]], ... } + /// // Tensor 1: Tensor { data: [[2.0, 1.9, 3.0]], ... } + /// } + /// } + /// ``` + pub fn iter_dim(self, dim: usize) -> DimIter { + check!(TensorCheck::dim_ops::("iter_dim", dim)); + DimIter::new(self, dim) + } + + /// Returns a new tensor with the given dimension narrowed to the given range. + /// + /// # Panics + /// + /// - If the dimension is greater than the number of dimensions of the tensor. + /// - If the given range exceeds the number of elements on the given dimension. + /// + /// # Returns + /// + /// A new tensor with the given dimension narrowed to the given range. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [4, 3] + /// let tensor = Tensor::<2>::from_data( + /// [ + /// [3.0, 4.9, 2.0], + /// [2.0, 1.9, 3.0], + /// [6.0, 1.5, 7.0], + /// [3.0, 4.9, 9.0], + /// ], + /// &device, + /// ); + /// // Narrow the tensor along the dimension 0, keeping 3 elements starting from index 1. + /// // [[2.0, 1.9, 3.0], [6.0, 1.5, 7.0], [3.0, 4.9, 9.0]] + /// // The resulting tensor will have dimensions [3, 3]. + /// let narrowed = tensor.narrow(0, 1, 3); + /// println!("{narrowed}"); + /// } + /// ``` + pub fn narrow(self, dim: usize, start: usize, length: usize) -> Self { + check!(TensorCheck::dim_ops::("narrow", dim)); + check!(TensorCheck::narrow(&self, dim, start, length)); + let dims = self.dims(); + + let ranges: [Range; D] = dims + .iter() + .enumerate() + .map(|(i, d)| { + if i == dim { + start..(start + length) + } else { + 0..*d + } + }) + .collect::>() + .try_into() + .unwrap(); + + Self::slice(self, ranges) + } + + /// Attempts to split the tensor into a specified number of chunks along a given dimension. + /// May return less chunks than requested if the tensor size is not divisible by the number of chunks. + /// + /// When the given dimension is evenly divisible by the number of chunks, the chunks will be of equal size. + /// Otherwise all chunks will be of equal size except for the last one. + /// + /// # Panics + /// + /// If the dimension is greater than the number of dimensions of the tensor. + /// + /// # Returns + /// A vector of tensors. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [4, 3] + /// let tensor = Tensor::<2>::from_data( + /// [ + /// [3.0, 4.9, 2.0], + /// [2.0, 1.9, 3.0], + /// [6.0, 1.5, 7.0], + /// [3.0, 4.9, 9.0], + /// ], + /// &device, + /// ); + /// // Split the tensor along the dimension 1 into 2 chunks. + /// // The first chuck will have shape [4, 2]: + /// // [[3.0, 4.9], [2.0, 1.9], [6.0, 1.5], [3.0, 4.9]] + /// // The second chunk will have shape [4, 1]: + /// // [[2.0], [3.0], [7.0], [9.0]] + /// let chunks = tensor.chunk(2, 1); + /// println!("{chunks:?}"); + /// } + /// ``` + pub fn chunk(self, chunks: usize, dim: usize) -> Vec { + check!(TensorCheck::dim_ops::("chunk", dim)); + let size = self.shape()[dim]; + if size < chunks { + return (0..size) + .map(|i| Self::narrow(self.clone(), dim, i, 1)) + .collect(); + } + + let mut tensors = Vec::with_capacity(chunks); + let mut sum_chunk_size = 0; + if size.is_multiple_of(chunks) { + let chunk_size = size / chunks; + for _ in 0..chunks { + tensors.push(Self::narrow(self.clone(), dim, sum_chunk_size, chunk_size)); + sum_chunk_size += chunk_size; + } + } else { + let chunk_size = (size / chunks) + 1; // assumes not divisible + for _ in 0..chunks - 1 { + tensors.push(Self::narrow(self.clone(), dim, sum_chunk_size, chunk_size)); + sum_chunk_size += chunk_size; + } + let remainder = size % chunk_size; + tensors.push(Self::narrow(self.clone(), dim, sum_chunk_size, remainder)); + } + + tensors + } + + /// Splits the tensor into chunks of a specified size along a given dimension. + /// Each chunk is a view of the original tensor. + /// + /// If the tensor size along the given dimension is not divisible by `split_size`, + /// then the last chunk will be smaller. + /// + /// # Panics + /// + /// If the specified dimension to split along is greater than the number of dimensions of the tensor. + /// + /// # Returns + /// + /// A vector of tensors. + /// + /// # Example + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 1D tensor with 5 elements + /// let tensor = Tensor::<1>::from_data([0.0, 1.0, 2.0, 3.0, 4.0], &device); + /// // Split the tensor into chunks of size 2 along dimension 0 + /// let chunks = tensor.split(2, 0); + /// // The result is a vector of tensors: + /// // [Tensor([0.0, 1.0]), Tensor([2.0, 3.0]), Tensor([4.0])] + /// println!("{:?}", chunks); + /// } + /// ``` + pub fn split(self, split_size: usize, dim: usize) -> Vec { + check!(TensorCheck::split::(&self.shape(), split_size, dim)); + let size = self.shape()[dim]; + let mut tensors = Vec::new(); + + let mut start = 0; + while start < size { + let length = usize::min(split_size, size - start); + tensors.push(Self::narrow(self.clone(), dim, start, length)); + start += length; + } + + tensors + } + + /// Splits the tensor into chunks with the specified sizes along a given dimension. + /// Each chunk is a view of the original tensor. + /// + /// The sizes of the chunks are specified in the `split_sizes` vector. The sum of the sizes + /// in `split_sizes` must equal the size of the tensor along the specified dimension. + /// + /// # Panics + /// + /// If the specified dimension to split along is greater than the number of dimensions of the tensor or + /// if the sum of `dim_sizes` does not equal the size of the tensor along `dim`. + /// + /// # Returns + /// + /// A vector of tensors. + /// + /// # Example + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 1D tensor with 5 elements + /// let tensor = Tensor::<1>::from_data([0.0, 1.0, 2.0, 3.0, 4.0], &device); + /// // Split the tensor into chunks with sizes [2, 3] along dimension 0 + /// let chunks = tensor.split_with_sizes(vec![2, 3], 0); + /// // The result is a vector of tensors: + /// // [Tensor([0.0, 1.0]), Tensor([2.0, 3.0, 4.0])] + /// println!("{:?}", chunks); + /// } + /// ``` + pub fn split_with_sizes(self, split_sizes: Vec, dim: usize) -> Vec { + check!(TensorCheck::split_with_sizes::( + &self.shape(), + &split_sizes, + dim + )); + let mut tensors = Vec::new(); + + let mut start = 0; + for length in split_sizes { + if length == 0 { + continue; + } + tensors.push(Self::narrow(self.clone(), dim, start, length)); + start += length; + } + + tensors + } + + /// Tests if any element in the `tensor` evaluates to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. All input tensor types (Float, Int, Bool) are supported. + /// + /// # Returns + /// + /// A boolean tensor `Tensor<1, Bool>` containing a single element, True if any element in the input tensor + /// evaluates to True, False otherwise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Bool>::from_data([[true,false,true],[false,true,false]], &device); + /// let tensor_two = Tensor::<2, Bool>::from_data([[false,false,false],[false,false,false]], &device); + /// + /// // Given a 2D tensor with dimensions [2, 3], test if any element in the tensor evaluates to True. + /// let any_tensor = tensor.any(); + /// println!("{}", any_tensor); + /// // Tensor { data: [true], ... } + /// + /// // Given a 2D tensor with dimensions [2, 3], test if any element in the tensor evaluates to True. + /// let any_tensor_two = tensor_two.any(); + /// println!("{}", any_tensor_two); + /// // Tensor { data: [false], ... } + /// } + /// ``` + pub fn any(self) -> Tensor<1, Bool> { + Tensor::new(K::any(self.primitive)) + } + + /// Tests if any element in the `tensor` evaluates to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. All input tensor types (Float, Int, Bool) are supported. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same shape as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if any element along this dim in the input + /// evaluates to True, False otherwise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = + /// Tensor::<2, Bool>::from_data([[true, false, false], [false, true, false]], &device); + /// // Check if any element in the tensor evaluates to True along the dimension 1. + /// // [[true], [true]], + /// let any_dim = tensor.clone().any_dim(1); + /// println!("{any_dim}"); + /// } + /// ``` + pub fn any_dim(self, dim: usize) -> Tensor { + Tensor::new(K::any_dim(self.primitive, dim)) + } + + /// Tests if all elements in the `tensor` evaluate to True. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. All input tensor types (Float, Int, Bool) are supported. + /// + /// # Returns + /// + /// A boolean tensor `Tensor<1, Bool>` with a single element, True if all elements in the input tensor + /// evaluate to True, False otherwise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = + /// Tensor::<2, Bool>::from_data([[true, false, true], [true, true, true]], &device); + /// // Check if all elements in the tensor evaluate to True (which is not the case). + /// // [false] + /// let all = tensor.all(); + /// println!("{all}"); + /// } + /// ``` + pub fn all(self) -> Tensor<1, Bool> { + Tensor::new(K::all(self.primitive)) + } + + /// Tests if all elements in the `tensor` evaluate to True along a given dimension `dim`. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to test. All input tensor types (Float, Int, Bool) are supported. + /// * `dim` - The axis along which to test. + /// + /// # Returns + /// + /// A boolean tensor `Tensor` with the same shape as input `tensor`, except in the `dim` axis + /// where the size is 1. The elem in the `dim` axis is True if all elements along this dim in the input + /// evaluates to True, False otherwise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = + /// Tensor::<2, Bool>::from_data([[true, true, false], [true, true, true]], &device); + /// // Check if all elements in the tensor evaluate to True along the dimension 1. + /// // [[true, true, false]] + /// let all_dim = tensor.clone().all_dim(0); + /// println!("{all_dim}"); + /// } + /// ``` + pub fn all_dim(self, dim: usize) -> Tensor { + Tensor::new(K::all_dim(self.primitive, dim)) + } + + /// Convert the tensor into a scalar. + /// + /// # Panics + /// + /// - If the tensor doesn't have one element. + /// - If the backend fails to read the tensor data synchronously. + /// + /// # Returns + /// + /// The scalar value of the tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[3.0]], &device); + /// // Convert the tensor with a single element into a scalar. + /// let scalar: f32 = tensor.into_scalar(); + /// println!("{scalar}"); + /// } + /// ``` + pub fn into_scalar(self) -> E { + check!(TensorCheck::into_scalar::(&self.shape())); + + let err_msg = + "Error while reading data: use `try_into_scalar` instead to catch the error at runtime"; + + let data = self.into_data(); + data.iter::().next().expect(err_msg) + } + + /// Convert the tensor into a scalar and returns any error that might have occurred since the + /// last time the device was synchronized. + /// + /// # Panics + /// + /// - If the tensor doesn't have one element. + /// - If the backend fails to read the tensor data synchronously. + /// + /// # Returns + /// + /// The scalar value of the tensor. + pub fn try_into_scalar(self) -> Result { + check!(TensorCheck::into_scalar::(&self.shape())); + + let err_msg = + "Error while reading data: use `try_into_scalar` instead to catch the error at runtime"; + + let data = self.try_into_data()?; + Ok(data.iter::().next().expect(err_msg)) + } + + /// Convert the tensor into a scalar. + /// + /// # Panics + /// + /// If the tensor doesn't have one element. + pub async fn into_scalar_async(self) -> Result { + check!(TensorCheck::into_scalar::(&self.shape())); + + let err_msg = + "Error while reading data: use `try_into_scalar` instead to catch the error at runtime"; + + let data = self.into_data_async().await?; + Ok(data.iter::().next().expect(err_msg)) + } + + /// Broadcast the tensor to the given shape. + /// + /// Only singleton dimensions can be expanded to a larger size. Other dimensions must have the same size + /// (which can be inferred with `-1`). + /// + /// # Arguments + /// + /// * `shape` - The shape to broadcast the tensor to. + /// Can contain -1 for dimensions that should be inferred. + /// The number of elements in the shape must be greater or equal as + /// the number of dimensions of the tensor. + /// + /// # Panics + /// + /// If the tensor cannot be broadcasted to the given shape. + /// + /// # Returns + /// + /// A new tensor with the given shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// // Create a 2D tensor with dimensions [3, 1] + /// let tensor = Tensor::<2>::from_data([[1.], [2.], [3.]], &device); + /// // Expand the tensor to a new shape [3, 4] + /// // [[1.0, 1.0, 1.0, 1.0], [2.0, 2.0, 2.0, 2.0], [3.0, 3.0, 3.0, 3.0]] + /// let expanded = tensor.expand([3, 4]); + /// println!("{}", expanded); + /// } + /// ``` + pub fn expand>(self, shape: S) -> Tensor { + let shape = shape.into_shape(&self.shape()); + check!(TensorCheck::expand::( + "expand", + &self.shape(), + &shape, + )); + + Tensor::::new(K::expand(self.primitive, shape)) + } + + /// Unfold windows along a dimension. + /// + /// Returns a view of the tensor with all complete windows of size `size` in dimension `dim`; + /// where windows are advanced by `step` at each index. + /// + /// The number of windows is `max(0, (shape[dim] - size).ceil_div(step))`. + /// + /// The new view will have the unfolded dimension replaced by two dimensions; + /// one in the position of the original dimension, with size equal to the number of windows, + /// and one appended to the right-most position, with size equal to `size`. + /// + /// # Warning + /// + /// For the `ndarray` backend; this is not a view but a copy + /// with duplicated data. + /// + /// # Arguments + /// + /// * `dim` - the dimension to unfold. + /// * `size` - the size of each unfolded window. + /// * `step` - the step between each window. + /// + /// # Returns + /// + /// A tensor view with the shape ``[pre=..., windows, post=..., size]``. + pub fn unfold( + self, + dim: I, + size: usize, + step: usize, + ) -> Tensor { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::unfold::( + "unfold", + &self.shape(), + dim, + size, + step, + )); + Tensor::::new(K::unfold(self.primitive, dim, size, step)) + } +} + +/// Iterator given by (Tensor::iter_dim). +pub struct DimIter +where + K: Basic, +{ + start: usize, + end: usize, + dim: usize, + ranges: [Range; D], + tensor: Tensor, +} + +impl Iterator for DimIter { + type Item = Tensor; + + fn next(&mut self) -> Option { + if self.start >= self.end { + return None; + } + + let mut ranges = self.ranges.clone(); + ranges[self.dim] = self.start..(self.start + 1); + + let slice = self.tensor.clone().slice(ranges); + self.start += 1; + + Some(slice) + } +} + +impl DoubleEndedIterator for DimIter { + fn next_back(&mut self) -> Option { + if self.start >= self.end { + return None; + } + + let mut ranges = self.ranges.clone(); + ranges[self.dim] = (self.end - 1)..self.end; + + let slice = self.tensor.clone().slice(ranges); + self.end = self.end.saturating_sub(1); + + Some(slice) + } +} + +impl DimIter { + fn new(tensor: Tensor, dim: usize) -> Self { + let dims = tensor.dims(); + let ranges = dims + .iter() + .map(|&dim| 0..dim) + .collect::>>(); + let ranges: [Range; D] = ranges.try_into().unwrap(); + Self { + end: dims[dim], + ranges, + start: 0, + dim, + tensor, + } + } +} + +struct DataIterFmt { + data: TensorData, + precision: Option, +} + +fn fmt_float(elem: E, precision: Option) -> String { + match precision { + Some(p) => format!("{elem:.p$}"), + None => fmt_elem(elem), + } +} + +fn fmt_elem(elem: E) -> String { + format!("{elem:?}") +} + +// TODO: refactor display +impl DataIterFmt { + fn next(&self) -> String { + match self.data.dtype { + DType::F64 => fmt_float(self.next_elem::(), self.precision), + DType::F32 | DType::Flex32 => fmt_float(self.next_elem::(), self.precision), + DType::F16 => fmt_float(self.next_elem::(), self.precision), + DType::BF16 => fmt_float(self.next_elem::(), self.precision), + DType::I64 => fmt_elem(self.next_elem::()), + DType::I32 => fmt_elem(self.next_elem::()), + DType::I16 => fmt_elem(self.next_elem::()), + DType::I8 => fmt_elem(self.next_elem::()), + DType::U64 => fmt_elem(self.next_elem::()), + DType::U32 => fmt_elem(self.next_elem::()), + DType::U16 => fmt_elem(self.next_elem::()), + DType::U8 => fmt_elem(self.next_elem::()), + DType::Bool(store) => match store { + burn_std::BoolStore::Native => fmt_elem(self.next_elem::()), + burn_std::BoolStore::U8 => fmt_elem(self.next_elem::().to_bool()), + burn_std::BoolStore::U32 => fmt_elem(self.next_elem::().to_bool()), + }, + DType::QFloat(_) => todo!(), // unreachable but we should fix that + } + } + + fn next_elem(&self) -> E { + self.data.iter::().next().unwrap() + } +} + +// The Display-formatting recursion used to live as generic methods on +// `Tensor` here. It has been outlined to non-generic free functions +// (`display_fmt_*`, `slice_bridge_by_kind`, `push_newline_indent_impl`) below, +// so it is compiled exactly once inside `burn-tensor` instead of being +// re-monomorphized for every `(D, K)` in downstream crates. That outlining is +// the difference between a ~7s and a ~0.5s incremental release rebuild for a +// program that just calls `println!("{tensor}")`. + +#[derive(Clone, Debug)] +/// Options for Tensor pretty printing +pub struct PrintOptions { + /// number of elements to start summarizing tensor + pub threshold: usize, + + /// number of starting elements and ending elements to display + pub edge_items: usize, + + /// Precision for floating point numbers + pub precision: Option, +} + +static PRINT_OPTS: RwLock = RwLock::new(PrintOptions::const_default()); + +impl PrintOptions { + /// Print options with default values + pub const fn const_default() -> Self { + Self { + threshold: 1000, + edge_items: 3, + precision: None, + } + } +} + +impl Default for PrintOptions { + fn default() -> Self { + Self::const_default() + } +} + +/// Set print options +pub fn set_print_options(options: PrintOptions) { + let mut print_opts = PRINT_OPTS.write().unwrap(); + *print_opts = options; +} + +/// Pretty print tensors +impl core::fmt::Display for Tensor +where + K: Basic, +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + display_fmt_impl(&self.primitive, K::KIND, K::name(), f) + } +} + +/// Trait used for movedim arguments +pub trait MovedimArgs { + /// Converts into a set of dimensions `Vec` for the `tensor.movedim()` function + fn into_dim_vec(self) -> Vec; +} + +impl MovedimArgs for Vec { + fn into_dim_vec(self) -> Vec { + let set = self + .iter() + .map(|&dim| { + if dim < 0 { + (D as i32 + dim) as usize + } else { + dim as usize + } + }) + .collect::>(); + check!(TensorCheck::movedim_args_vec::(&set)); + + set + } +} + +impl MovedimArgs for Vec { + fn into_dim_vec(self) -> Vec { + check!(TensorCheck::movedim_args_vec::(&self)); + self + } +} + +impl MovedimArgs for usize { + #[allow(clippy::vec_init_then_push)] + fn into_dim_vec(self) -> Vec { + check!(TensorCheck::movedim_args_usize::(self)); + + let mut set = Vec::with_capacity(1); + set.push(self); + + set + } +} + +impl MovedimArgs for i32 { + #[allow(clippy::vec_init_then_push)] + fn into_dim_vec(self) -> Vec { + check!(TensorCheck::movedim_args_i32::(self)); + + let dim = if self < 0 { + (D as i32 + self) as usize + } else { + self as usize + }; + + let mut set = Vec::with_capacity(1); + set.push(dim); + + set + } +} + +/// Trait used for reshape arguments. +pub trait ReshapeArgs: Debug { + /// Converts to a shape. + fn into_shape(self, source: Shape) -> Shape; +} + +impl ReshapeArgs for [I; D2] { + fn into_shape(self, source: Shape) -> Shape { + unwrap_shape_reshape(source.reshape(self)) + } +} + +impl ReshapeArgs for Shape { + fn into_shape(self, source: Shape) -> Shape { + unwrap_shape_reshape(source.reshape(self)) + } +} + +/// Trait used for broadcast arguments. +pub trait BroadcastArgs { + /// Converts to a shape. + fn into_shape(self, shape: &Shape) -> Shape; +} + +impl BroadcastArgs for Shape { + fn into_shape(self, _shape: &Shape) -> Shape { + self + } +} + +impl BroadcastArgs for [E; D2] { + // Passing -1 as the size for a dimension means not changing the size of that dimension. + fn into_shape(self, shape: &Shape) -> Shape { + if self.len() < shape.num_dims() { + panic!( + "Broadcast arguments must be greater than the number of dimensions! got {}, need at least {}", + self.len(), + shape.num_dims() + ); + } + + // Zip the two shapes in reverse order and replace -1 with the actual dimension value. + let new_shape: Vec<_> = self + .iter() + .rev() + .map(|x| { + let primitive = x.as_index(); + if primitive < -1 || primitive == 0 { + panic!( + "Broadcast arguments must be positive or -1! Got {}", + primitive + ); + } + primitive + }) + .zip(shape.iter().rev().chain(repeat(&0)).take(self.len())) // Pad the original shape with 0s + .map(|(x, &y)| if x == -1 { y } else { x as usize }) + .collect::>() + .into_iter() + .rev() + .collect(); + + if new_shape.contains(&0) { + panic!( + "Cannot substitute -1 for a non-existing dimension! Got {:?}", + new_shape + ); + } + + let new_shape: [usize; D2] = new_shape.try_into().unwrap(); + + Shape::from(new_shape) + } +} + +impl Serialize for Tensor +where + K: Basic, +{ + fn serialize(&self, serializer: S) -> Result { + let data = self.to_data(); + data.serialize(serializer) + } +} + +impl<'de, const D: usize, K> Deserialize<'de> for Tensor +where + K: Basic, +{ + fn deserialize>(deserializer: De) -> Result { + let tensor = Tensor::from_data(TensorData::deserialize(deserializer)?, &Device::default()); + Ok(tensor) + } +} + +/// Non-generic outline of `into_data_async`. The public method just calls this +/// helper, so its monomorphization (per `D`/`K`) is trivial — the heavy async +/// state-machine code lives here, compiled once inside `burn-tensor`. +async fn into_data_async_impl( + primitive: BridgeTensor, + kind: crate::ops::Kind, +) -> Result { + use crate::ops::{BasicOps, Kind}; + match kind { + Kind::Float => ::into_data_async(primitive).await, + Kind::Int => ::into_data_async(primitive).await, + Kind::Bool => ::into_data_async(primitive).await, + } +} + +fn slice_bridge_by_kind(p: BridgeTensor, slices: &[Slice], kind: crate::ops::Kind) -> BridgeTensor { + use crate::ops::{BasicOps, Kind}; + match kind { + Kind::Float => ::slice(p, slices), + Kind::Int => ::slice(p, slices), + Kind::Bool => ::slice(p, slices), + } +} + +#[allow(clippy::too_many_arguments)] +fn display_fmt_inner( + primitive: &BridgeTensor, + kind: crate::ops::Kind, + acc: &mut String, + depth: usize, + multi_index: &mut [usize], + range: (usize, usize), + precision: Option, + dims: &[usize], +) { + let (start, end) = range; + let rank = dims.len(); + for i in start..end { + if i > 0 { + acc.push_str(", "); + } + multi_index[depth] = i; + let slices: Vec = (0..rank) + .map(|d| Slice::from((multi_index[d] as i64)..((multi_index[d] + 1) as i64))) + .collect(); + let sliced = slice_bridge_by_kind(primitive.clone(), &slices, kind); + let data = burn_std::reader::try_read_sync(into_data_async_impl(sliced, kind)); + if let Some(Ok(data)) = data { + let elem = DataIterFmt { data, precision }.next(); + acc.push_str(&elem); + } else { + acc.push_str(""); + } + } +} + +fn push_newline_indent_impl(acc: &mut String, indent: usize) { + acc.push('\n'); + for _ in 0..indent { + acc.push(' '); + } +} + +#[allow(clippy::too_many_arguments)] +fn display_fmt_outer( + primitive: &BridgeTensor, + kind: crate::ops::Kind, + acc: &mut String, + depth: usize, + multi_index: &mut [usize], + print_options: &PrintOptions, + summarize: bool, + range: (usize, usize), + dims: &[usize], +) { + let (start, end) = range; + for i in start..end { + if i > start { + acc.push(','); + push_newline_indent_impl(acc, depth + 1); + } + acc.push('['); + multi_index[depth] = i; + display_fmt_recursive( + primitive, + kind, + acc, + depth + 1, + multi_index, + print_options, + summarize, + dims, + ); + acc.push(']'); + } +} + +#[allow(clippy::too_many_arguments)] +fn display_fmt_recursive( + primitive: &BridgeTensor, + kind: crate::ops::Kind, + acc: &mut String, + depth: usize, + multi_index: &mut [usize], + print_options: &PrintOptions, + summarize: bool, + dims: &[usize], +) { + let edge_items = print_options.edge_items; + + if depth == 0 { + acc.push('['); + } + + if depth == dims.len() - 1 { + if summarize && dims[depth] > 2 * edge_items { + display_fmt_inner( + primitive, + kind, + acc, + depth, + multi_index, + (0, edge_items), + print_options.precision, + dims, + ); + acc.push_str(", ..."); + display_fmt_inner( + primitive, + kind, + acc, + depth, + multi_index, + (dims[depth] - edge_items, dims[depth]), + print_options.precision, + dims, + ); + } else { + display_fmt_inner( + primitive, + kind, + acc, + depth, + multi_index, + (0, dims[depth]), + print_options.precision, + dims, + ); + } + } else if summarize && dims[depth] > 2 * edge_items { + display_fmt_outer( + primitive, + kind, + acc, + depth, + multi_index, + print_options, + summarize, + (0, edge_items), + dims, + ); + acc.push(','); + push_newline_indent_impl(acc, depth + 1); + acc.push_str("..."); + push_newline_indent_impl(acc, depth + 1); + display_fmt_outer( + primitive, + kind, + acc, + depth, + multi_index, + print_options, + summarize, + (dims[depth] - edge_items, dims[depth]), + dims, + ); + } else { + display_fmt_outer( + primitive, + kind, + acc, + depth, + multi_index, + print_options, + summarize, + (0, dims[depth]), + dims, + ); + } + + if depth == 0 { + acc.push(']'); + } +} + +fn display_fmt_impl( + primitive: &BridgeTensor, + kind: crate::ops::Kind, + kind_name: &str, + f: &mut core::fmt::Formatter<'_>, +) -> core::fmt::Result { + writeln!(f, "Tensor {{")?; + { + let mut po = { PRINT_OPTS.read().unwrap().clone() }; + if let Some(precision) = f.precision() { + po.precision = Some(precision); + } + let shape = primitive.shape(); + let dims: Vec = shape.iter().copied().collect(); + let mut acc = String::new(); + let mut multi_index = vec![0; dims.len()]; + let num_elements: usize = dims.iter().product(); + let summarize = num_elements > po.threshold; + display_fmt_recursive( + primitive, + kind, + &mut acc, + 0, + &mut multi_index, + &po, + summarize, + &dims, + ); + writeln!(f, " data:")?; + write!(f, "{acc}")?; + writeln!(f, ",")?; + } + writeln!(f, " shape: {},", primitive.shape())?; + let device = match kind { + crate::ops::Kind::Float => ::device(primitive), + crate::ops::Kind::Int => ::device(primitive), + crate::ops::Kind::Bool => ::device(primitive), + }; + writeln!(f, " device: {:?},", device)?; + writeln!(f, " kind: {:?},", kind_name)?; + let dtype = primitive.dtype(); + writeln!(f, " dtype: {:?},", dtype.name())?; + write!(f, "}}") +} + +fn try_into_data_sync_impl( + primitive: BridgeTensor, + kind: crate::ops::Kind, +) -> Result { + crate::try_read_sync(into_data_async_impl(primitive, kind)).expect( + "Failed to read tensor data synchronously. + This can happen on platforms that don't support blocking futures like WASM. + If possible, try using into_data_async instead.", + ) +} + +fn into_data_sync_impl(primitive: BridgeTensor, kind: crate::ops::Kind) -> TensorData { + try_into_data_sync_impl(primitive, kind).expect( + "Error while reading data: use `try_into_data` instead to catch the error at runtime", + ) +} + +#[cfg(test)] +mod tests { + use burn_std::SliceOps; + + use crate::{Shape, s}; + + #[test] + fn slice_range_single_dim_leading() { + let shape = Shape::new([8, 4]); + + // Half-open range + let slices = shape.clone().into_slices([0..5]); + assert_eq!(slices[0].to_range(8), 0..5); + let slices = shape.clone().into_slices([-3..-1]); + assert_eq!(slices[0].to_range(8), 5..7); + + // Inclusive range + let slices = shape.clone().into_slices([0..=4]); + assert_eq!(slices[0].to_range(8), 0..5); + let slices = shape.clone().into_slices([-2..=-1]); + assert_eq!(slices[0].to_range(8), 6..8); + + // Unbounded start + let slices = shape.clone().into_slices([..3]); + assert_eq!(slices[0].to_range(8), 0..3); + let slices = shape.clone().into_slices([..-5]); + assert_eq!(slices[0].to_range(8), 0..3); + + // Unbounded end + let slices = shape.clone().into_slices([5..]); + assert_eq!(slices[0].to_range(8), 5..8); + let slices = shape.clone().into_slices([-3..]); + assert_eq!(slices[0].to_range(8), 5..8); + + // Full range + let slices = shape.into_slices([..]); + assert_eq!(slices[0].to_range(8), 0..8); + } + + #[test] + fn test_negative_slice_indices() { + use crate::Slice; + + // Test negative indices conversion + let slice: Slice = (-3..-1).into(); + assert_eq!(slice.start, -3); + assert_eq!(slice.end, Some(-1)); + + // Test to_range conversion with size 8 + let range = slice.to_range(8); + assert_eq!(range, 5..7); + + // Test with shape slice + let shape = Shape::new([8, 4]); + let result = shape.clone().into_slices([-3..-1]); + assert_eq!(result[0].to_range(8), 5..7); + + // Test more negative index cases + let slice2: Slice = (-5..).into(); + assert_eq!(slice2.to_range(10), 5..10); + + let slice3: Slice = (..-2).into(); + assert_eq!(slice3.to_range(10), 0..8); + + // Test with s! macro - single dimension returns Slice directly + let slice4 = s![-3..-1]; + assert_eq!(slice4.start, -3); + assert_eq!(slice4.end, Some(-1)); + } + + #[test] + fn slice_range_multi_dim() { + let shape = Shape::new([8, 4]); + + // Multiple ways to provide ranges + let slices = shape.clone().into_slices([0..5, 0..4]); + assert_eq!(slices[0].to_range(8), 0..5); + assert_eq!(slices[1].to_range(4), 0..4); + + let slices = shape.clone().into_slices([0.., 0..]); + assert_eq!(slices[0].to_range(8), 0..8); + assert_eq!(slices[1].to_range(4), 0..4); + + let slices = shape.clone().into_slices([0..=7, 0..=3]); + assert_eq!(slices[0].to_range(8), 0..8); + assert_eq!(slices[1].to_range(4), 0..4); + + let slices = shape.clone().into_slices([0..5, 0..3]); + assert_eq!(slices[0].to_range(8), 0..5); + assert_eq!(slices[1].to_range(4), 0..3); + + let slices = shape.into_slices([0.., 0..]); + assert_eq!(slices[0].to_range(8), 0..8); + assert_eq!(slices[1].to_range(4), 0..4); + } + + #[test] + fn slice_range_multi_dim_index() { + let shape = Shape::new([8, 4]); + + // Indices (single integer) should also convert to correct range + let slices = shape.clone().into_slices([0, 2]); + assert_eq!(slices[0].to_range(8), 0..1); + assert_eq!(slices[1].to_range(4), 2..3); + + let slices = shape.into_slices([-1, -1]); + assert_eq!(slices[0].to_range(8), 7..8); + assert_eq!(slices[1].to_range(4), 3..4); + } + + #[test] + fn slice_range_multi_dim_heterogeneous() { + // Slice macro `s![]` can be used to provide different range types + let shape = Shape::new([8, 4, 2]); + let slice = s![0..5, .., -1]; + let slices = shape.into_slices(slice); + assert_eq!(slices[0].to_range(8), 0..5); + assert_eq!(slices[1].to_range(4), 0..4); + assert_eq!(slices[2].to_range(2), 1..2); + + let shape = Shape::new([8, 4, 2, 3]); + let slice = s![..=4, 0..=3, .., -2..]; + let slices = shape.into_slices(slice); + assert_eq!(slices[0].to_range(8), 0..5); + assert_eq!(slices[1].to_range(4), 0..4); + assert_eq!(slices[2].to_range(2), 0..2); + assert_eq!(slices[3].to_range(3), 1..3); + + let shape = Shape::new([3, 4]); + let slice = s![1..-1, ..]; + let slices = shape.into_slices(slice); + assert_eq!(slices[0].to_range(3), 1..2); + assert_eq!(slices[1].to_range(4), 0..4); + } +} diff --git a/crates/burn-tensor/src/tensor/api/bool.rs b/crates/burn-tensor/src/tensor/api/bool.rs new file mode 100644 index 0000000..2fbe26e --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/bool.rs @@ -0,0 +1,508 @@ +use crate::{Bool, Cast, Device, Int, Shape, Tensor, TensorData, ops::BridgeTensor}; +use alloc::{vec, vec::Vec}; +use burn_backend::ops::BoolTensorOps; +use burn_dispatch::Dispatch; + +use crate::try_read_sync; + +/// The part of the tensor to keep when creating a triangular mask. +enum TriPart { + /// Upper triangular part. + Upper, + + /// Lower triangular part. + Lower, + + /// Diagonal part. + Diagonal, +} + +impl Tensor { + /// Create a boolean tensor from data on the given device. + /// + /// # Arguments + /// + /// * `data` - The tensor data. + /// * `device` - The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// A boolean tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Bool>::from_bool([[true, false], [false, true]], &device); + /// println!("{tensor}"); + /// } + /// ``` + pub fn from_bool>(data: A, device: &Device) -> Self { + Self::from_data(data.into(), device) + } + + /// Convert the bool tensor into an int tensor. + /// + /// # Returns + /// + /// An integer tensor where `true` is converted to `1` and `false` to `0`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let bool_tensor = Tensor::<1, Bool>::from_bool([true, false, true], &device); + /// let int_tensor = bool_tensor.int(); + /// println!("{int_tensor}"); // [1, 0, 1] + /// } + /// ``` + pub fn int(self) -> Tensor { + let device = self.device(); + Tensor::new(bool_to_int_impl(self.primitive, device)) + } + + /// Convert the bool tensor into a float tensor. + /// + /// # Returns + /// + /// A float tensor where `true` is converted to `1.0` and `false` to `0.0`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let bool_tensor = Tensor::<1, Bool>::from_bool([true, false, true], &device); + /// let float_tensor = bool_tensor.float(); + /// println!("{float_tensor}"); // [1.0, 0.0, 1.0] + /// } + /// ``` + pub fn float(self) -> Tensor { + let device = self.device(); + Tensor::new(bool_to_float_impl(self.primitive, device)) + } + + /// Converts a bool tensor to the specified data type. + /// + /// Supports casting to [`IntDType`](crate::IntDType) (producing an int tensor) + /// or [`FloatDType`](crate::FloatDType) (producing a float tensor). + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool, IntDType, FloatDType}; + /// + /// fn example() { + /// let device = Default::default(); + /// let bool_tensor = Tensor::<1, Bool>::from_bool([true, false, true], &device); + /// + /// // Cast to int + /// let int_tensor = bool_tensor.clone().cast(IntDType::I64); + /// + /// // Cast to float + /// let float_tensor = bool_tensor.cast(FloatDType::F32); + /// } + /// ``` + #[must_use] + pub fn cast>(self, dtype: T) -> Tensor { + T::cast(self, dtype) + } + + /// Inverses boolean values. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Bool>::from_bool([[true, false], [false, true]], &device); + /// let inverted = tensor.bool_not(); + /// println!("{inverted}"); // [[false, true], [true, false]] + /// } + /// ``` + pub fn bool_not(self) -> Self { + Tensor::new(bool_not_impl(self.primitive)) + } + + /// Performs logical and (`&&`) on two boolean tensors. + /// + /// # Arguments + /// + /// * `rhs` - The right-hand side tensor for the AND operation. + /// + /// # Returns + /// + /// A boolean tensor where each element is the result of `self[i] && rhs[i]`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let a = Tensor::<2, Bool>::from_bool([[true, true], [false, false]], &device); + /// let b = Tensor::<2, Bool>::from_bool([[true, false], [true, false]], &device); + /// let result = a.bool_and(b); + /// println!("{result}"); // [[true, false], [false, false]] + /// } + /// ``` + pub fn bool_and(self, rhs: Tensor) -> Tensor { + Tensor::new(bool_and_impl(self.primitive, rhs.primitive)) + } + + /// Performs logical or (`||`) on two boolean tensors. + /// + /// # Arguments + /// + /// * `rhs` - The right-hand side tensor for the OR operation. + /// + /// # Returns + /// + /// A boolean tensor where each element is the result of `self[i] || rhs[i]`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let a = Tensor::<2, Bool>::from_bool([[true, true], [false, false]], &device); + /// let b = Tensor::<2, Bool>::from_bool([[true, false], [true, false]], &device); + /// let result = a.bool_or(b); + /// println!("{result}"); // [[true, true], [true, false]] + /// } + /// ``` + pub fn bool_or(self, rhs: Tensor) -> Tensor { + Tensor::new(bool_or_impl(self.primitive, rhs.primitive)) + } + + /// Performs logical xor (`^`) on two boolean tensors. + /// + /// # Arguments + /// + /// * `rhs` - The right-hand side tensor for the XOR operation. + /// + /// # Returns + /// + /// A boolean tensor where each element is the result of `self[i] ^ rhs[i]`. + /// Returns `true` when exactly one of the operands is `true`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let a = Tensor::<2, Bool>::from_bool([[true, true], [false, false]], &device); + /// let b = Tensor::<2, Bool>::from_bool([[true, false], [true, false]], &device); + /// let result = a.bool_xor(b); + /// println!("{result}"); // [[false, true], [true, false]] + /// } + /// ``` + pub fn bool_xor(self, rhs: Tensor) -> Tensor { + Tensor::new(bool_xor_impl(self.primitive, rhs.primitive)) + } + + /// Compute the indices of `true` elements in the tensor (i.e., non-zero for boolean tensors). + /// + /// # Returns + /// + /// A vector of tensors, one for each dimension of the given tensor, containing the indices of + /// the non-zero elements in that dimension. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Bool>::from_bool( + /// [[true, false, true], [false, true, false], [false, true, false]], + /// &device, + /// ); + /// let indices = tensor.nonzero(); + /// println!("{}", indices[0]); // [0, 0, 1, 2] + /// println!("{}", indices[1]); // [0, 2, 1, 1] + /// } + /// ``` + pub fn nonzero(self) -> Vec> { + try_read_sync(self.nonzero_async()) + .expect("Failed to read tensor data synchronously. Try using nonzero_async instead.") + } + + /// Compute the indices of `true` elements in the tensor (i.e., non-zero for boolean tensors). + /// + /// # Returns + /// + /// A vector of tensors, one for each dimension of the given tensor, containing the indices of + /// the non-zero elements in that dimension. + pub async fn nonzero_async(self) -> Vec> { + let indices = self.argwhere_async().await; + + if indices.shape().num_elements() == 0 { + // Return empty vec when all elements are zero + return vec![]; + } + + let dims = indices.shape(); + indices + .chunk(dims[1], 1) + .into_iter() + .map(|t| t.reshape(Shape::new([dims[0]]))) + .collect() + } + + /// Compute the indices of the elements that are true, grouped by element. + /// + /// # Returns + /// + /// A tensor containing the indices of all non-zero elements of the given tensor. Each row in the + /// result contains the indices of a non-zero element. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Bool>::from_bool( + /// [[true, false, true], [false, true, false], [false, true, false]], + /// &device, + /// ); + /// let indices = tensor.argwhere(); + /// println!("{indices}"); // [[0, 0], [0, 2], [1, 1], [2, 1]] + /// } + /// ``` + pub fn argwhere(self) -> Tensor<2, Int> { + try_read_sync(self.argwhere_async()) + .expect("Failed to read tensor data synchronously. Try using argwhere_async instead.") + } + + /// Compute the indices of the elements that are true, grouped by element. + /// + /// # Returns + /// + /// A tensor containing the indices of all non-zero elements of the given tensor. Each row in the + /// result contains the indices of a non-zero element. + pub async fn argwhere_async(self) -> Tensor<2, Int> { + let out_dtype = self.device().settings().int_dtype; + let inner = Dispatch::bool_argwhere(self.primitive.into(), out_dtype).await; + Tensor::new(BridgeTensor::int(inner)) + } + + /// Creates a mask for the upper, lower triangle, or diagonal of a matrix, which can be used to + /// fill the specified area with a value. + fn tri_mask>(shape: S, tri_part: TriPart, offset: i64, device: &Device) -> Self { + let shape: Shape = shape.into(); + let height = shape[D - 2]; + let width = shape[D - 1]; + + // Generate row and column index tensors. + let row_indices: Tensor<1, Int> = Tensor::arange(0..height as i64, device); + let col_indices: Tensor<1, Int> = Tensor::arange(0..width as i64, device); + + // Prepare shapes for broadcasting. + let mut row_shape = [1; D]; + row_shape[D - 2] = height; + let mut col_shape = [1; D]; + col_shape[D - 1] = width; + + // Reshape for broadcasting. + let row_broadcast: Tensor = row_indices.reshape(Shape::new(row_shape)); + let col_broadcast = col_indices.reshape(Shape::new(col_shape)); + + // Broadcasting trick to create a matrix that facilitates comparison for mask generation. + let matrix = row_broadcast.clone() - (col_broadcast.clone() - offset); + + // Select the appropriate comparison function based on `tri_part`. + let compare = match tri_part { + TriPart::Upper => Tensor::greater_elem, + TriPart::Lower => Tensor::lower_elem, + TriPart::Diagonal => Tensor::not_equal_elem, + }; + + // Generate and return the mask by applying the comparison to the matrix. + compare(matrix, 0).unsqueeze() + } + + /// Creates a mask for the upper triangle of a matrix, which can be used to fill the specified + /// area with a value. + /// + /// This function generates a boolean tensor representing the mask of the upper triangle of a matrix. + /// + /// # Arguments + /// + /// * `shape`: The shape of the matrix. + /// * `offset`: The offset from the diagonal, where 0 means the diagonal, and positive values shift + /// towards the upper triangle. + /// * `device`: The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// Returns a boolean tensor where `false` indicates the elements of the matrix that are part of the + /// upper triangle taking into account the specified `offset`. All other elements are `true`. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let mask = Tensor::<2, Bool>::triu_mask([3, 3], 0, &Default::default()); + /// println!("{mask}"); + /// // [[false, false, false], + /// // [true, false, false], + /// // [true, true, false]] + /// } + /// ``` + pub fn triu_mask>(shape: S, offset: i64, device: &Device) -> Self { + Self::tri_mask(shape, TriPart::Upper, offset, device) + } + + /// Creates a mask for the lower triangle of a matrix, which can be used to fill the specified + /// area with a value. + /// + /// This function generates a boolean tensor representing the mask of the lower triangle of a matrix. + /// + /// # Arguments + /// + /// * `shape`: The shape of the matrix. + /// * `offset`: The offset from the diagonal, where 0 means the diagonal, and negative values shift + /// towards the lower triangle. + /// * `device`: The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// Returns a boolean tensor where `false` indicates the elements of the matrix that are part of the + /// lower triangle taking into account the specified `offset`. All other elements are `true`. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let mask = Tensor::<2, Bool>::tril_mask([3, 3], 0, &Default::default()); + /// println!("{mask}"); + /// // [[false, true, true], + /// // [false, false, true], + /// // [false, false, false]] + /// } + /// ``` + pub fn tril_mask>(shape: S, offset: i64, device: &Device) -> Self { + Self::tri_mask(shape, TriPart::Lower, offset, device) + } + + /// Creates a mask for the diagonal of a matrix, which can be used to fill the specified + /// area with a value. + /// + /// This function generates a boolean tensor representing the mask of the diagonal of a matrix. + /// + /// # Arguments + /// + /// * `shape`: The shape of the matrix. + /// * `offset`: The offset from the diagonal, where 0 means the diagonal, and positive values shift + /// towards the upper triangle. + /// * `device`: The device on which the tensor will be allocated. + /// + /// # Returns + /// + /// Returns a boolean tensor where `false` indicates the elements of the matrix that are part of the + /// diagonal. All other elements are `true`. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Tensor, Bool}; + /// + /// fn example() { + /// let mask = Tensor::<2, Bool>::diag_mask([3, 3], 0, &Default::default()); + /// println!("{mask}"); + /// // [[false, true, true], + /// // [true, false, true], + /// // [true, true, false]] + /// } + /// ``` + pub fn diag_mask>(shape: S, offset: i64, device: &Device) -> Self { + Self::tri_mask(shape, TriPart::Diagonal, offset, device) + } +} + +// !tensor (bool only) +impl core::ops::Not for Tensor { + type Output = Tensor; + + fn not(self) -> Self::Output { + self.bool_not() + } +} + +// tensor & tensor (bool only) +impl core::ops::BitAnd for Tensor { + type Output = Tensor; + + fn bitand(self, tensor: Tensor) -> Self::Output { + self.bool_and(tensor) + } +} + +// tensor | tensor (bool only) +impl core::ops::BitOr for Tensor { + type Output = Tensor; + + fn bitor(self, tensor: Tensor) -> Self::Output { + self.bool_or(tensor) + } +} + +// tensor ^ tensor (bool only) +impl core::ops::BitXor for Tensor { + type Output = Tensor; + + fn bitxor(self, tensor: Tensor) -> Self::Output { + self.bool_xor(tensor) + } +} + +// ========================================================================= +// Non-generic implementation helpers (outlined from the generic API). +// See the crate-level docs for the rationale behind this pattern. +// ========================================================================= + +fn bool_to_int_impl(p: BridgeTensor, device: Device) -> BridgeTensor { + let out_dtype = device.settings().int_dtype; + BridgeTensor::int(Dispatch::bool_into_int(p.into(), out_dtype)) +} + +fn bool_to_float_impl(p: BridgeTensor, device: Device) -> BridgeTensor { + let out_dtype = device.settings().float_dtype; + BridgeTensor::float(Dispatch::bool_into_float(p.into(), out_dtype)) +} + +fn bool_not_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_not(p.into())) +} + +fn bool_and_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_and(lhs.into(), rhs.into())) +} + +fn bool_or_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_or(lhs.into(), rhs.into())) +} + +fn bool_xor_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_xor(lhs.into(), rhs.into())) +} diff --git a/crates/burn-tensor/src/tensor/api/cartesian_grid.rs b/crates/burn-tensor/src/tensor/api/cartesian_grid.rs new file mode 100644 index 0000000..1a9c32e --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/cartesian_grid.rs @@ -0,0 +1,56 @@ +use crate::{Device, Int, Shape, Tensor}; +use alloc::vec::Vec; + +/// Generates a cartesian grid for the given tensor shape on the specified device. +/// The generated tensor is of dimension `D2 = D + 1`, where each element at dimension D contains the cartesian grid coordinates for that element. +/// +/// # Arguments +/// +/// * `shape` - The shape specifying the dimensions of the tensor. +/// * `device` - The device to create the tensor on. +/// +/// # Panics +/// +/// Panics if `D2` is not equal to `D+1`. +/// +/// # Examples +/// +/// ```rust +/// use burn_tensor::Int; +/// use burn_tensor::{Shape, Tensor}; +/// fn example() { +/// let device = Default::default(); +/// let result: Tensor<3, _> = Tensor::<2, Int>::cartesian_grid([2, 3], &device); +/// println!("{}", result); +/// } +/// ``` +pub fn cartesian_grid, const D: usize, const D2: usize>( + shape: S, + device: &Device, +) -> Tensor { + if D2 != D + 1 { + panic!("D2 must equal D + 1 for Tensor::cartesian_grid") + } + + let dims = shape.into(); + let mut indices: Vec> = Vec::new(); + + for dim in 0..D { + let dim_range: Tensor<1, Int> = Tensor::arange(0..dims[dim] as i64, device); + + let mut shape = [1; D]; + shape[dim] = dims[dim]; + let mut dim_range = dim_range.reshape(shape); + + for (i, &item) in dims.iter().enumerate() { + if i == dim { + continue; + } + dim_range = dim_range.repeat_dim(i, item); + } + + indices.push(dim_range); + } + + Tensor::stack::(indices, D) +} diff --git a/crates/burn-tensor/src/tensor/api/cast.rs b/crates/burn-tensor/src/tensor/api/cast.rs new file mode 100644 index 0000000..62ac6a0 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/cast.rs @@ -0,0 +1,144 @@ +use burn_backend::ops::{BoolTensorOps, FloatTensorOps, IntTensorOps}; +use burn_backend::{DType, FloatDType, IntDType}; +use burn_dispatch::Dispatch; + +use crate::kind::Basic; +use crate::ops::BridgeTensor; +use crate::{Bool, Float, Int, Tensor}; + +/// Trait for types that represent a valid cast target from a tensor of kind `K`. +/// +/// The generic parameter `K` is the *input* tensor kind ([`Float`], [`Int`], or [`Bool`]). +/// Implementors declare the output kind and provide the actual cast logic. +pub trait Cast { + /// The output tensor kind after casting. + type OutputKind: Basic; + + /// Cast a tensor primitive to the target dtype. + fn cast(tensor: Tensor, dtype: Self) -> Tensor; +} + +// --- Float input impls --- + +impl Cast for FloatDType { + type OutputKind = Float; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + if tensor.primitive.is_float() { + let current: FloatDType = tensor.dtype().into(); + if current == dtype { + return tensor; + } + Tensor::new(float_cast_impl(tensor.primitive, dtype)) + } else { + panic!("Should be Float primitive kind"); + } + } +} + +impl Cast for IntDType { + type OutputKind = Int; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + Tensor::new(float_to_int_impl(tensor.primitive, dtype)) + } +} + +/// Backward-compatible impl: only float `DType` variants are accepted. +/// +/// # Panics +/// +/// Panics if `dtype` is not a float variant (e.g., `DType::I32`). +/// Use [`IntDType`] directly for cross-kind casting to int. +impl Cast for DType { + type OutputKind = Float; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + let float_dtype: FloatDType = dtype.into(); + >::cast(tensor, float_dtype) + } +} + +// --- Int input impls --- + +impl Cast for IntDType { + type OutputKind = Int; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + let current: IntDType = tensor.primitive.dtype().into(); + if current == dtype { + return tensor; + } + Tensor::new(int_cast_impl(tensor.primitive, dtype)) + } +} + +impl Cast for FloatDType { + type OutputKind = Float; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + Tensor::new(int_to_float_impl(tensor.primitive, dtype)) + } +} + +/// Backward-compatible impl: only int `DType` variants are accepted. +/// +/// # Panics +/// +/// Panics if `dtype` is not an int variant (e.g., `DType::F32`). +/// Use [`FloatDType`] directly for cross-kind casting to float. +impl Cast for DType { + type OutputKind = Int; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + let int_dtype: IntDType = dtype.into(); + >::cast(tensor, int_dtype) + } +} + +// --- Bool input impls --- + +impl Cast for IntDType { + type OutputKind = Int; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + Tensor::new(bool_cast_to_int_impl(tensor.primitive, dtype)) + } +} + +impl Cast for FloatDType { + type OutputKind = Float; + + fn cast(tensor: Tensor, dtype: Self) -> Tensor { + Tensor::new(bool_cast_to_float_impl(tensor.primitive, dtype)) + } +} + +// ========================================================================= +// Non-generic implementation helpers (outlined from the generic API). +// See the crate-level docs for the rationale behind this pattern. +// ========================================================================= + +fn float_cast_impl(p: BridgeTensor, dtype: FloatDType) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_cast(p.into_float(), dtype)) +} + +fn float_to_int_impl(p: BridgeTensor, dtype: IntDType) -> BridgeTensor { + BridgeTensor::int(Dispatch::float_into_int(p.into_float(), dtype)) +} + +fn int_cast_impl(p: BridgeTensor, dtype: IntDType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_cast(p.into(), dtype)) +} + +fn int_to_float_impl(p: BridgeTensor, dtype: FloatDType) -> BridgeTensor { + BridgeTensor::float(Dispatch::int_into_float(p.into(), dtype)) +} + +fn bool_cast_to_int_impl(p: BridgeTensor, dtype: IntDType) -> BridgeTensor { + BridgeTensor::bool(Dispatch::bool_into_int(p.into(), dtype)) +} + +fn bool_cast_to_float_impl(p: BridgeTensor, dtype: FloatDType) -> BridgeTensor { + BridgeTensor::float(Dispatch::bool_into_float(p.into(), dtype)) +} diff --git a/crates/burn-tensor/src/tensor/api/check.rs b/crates/burn-tensor/src/tensor/api/check.rs new file mode 100644 index 0000000..7e81351 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/check.rs @@ -0,0 +1,1753 @@ +use crate::bridge::{BasicOps, Ordered}; +use crate::{DType, Shape, Slice, Tensor, cast::ToElement}; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +/// The struct should always be used with the [check](crate::check) macro. +/// +/// This is a simple pub(crate) data structure that efficiently checks tensor operations and +/// formats clear error messages. It's crucial that the checks are really fast, but it doesn't matter +/// when a failed check is discovered since the program will panic. +/// +/// # Notes +/// +/// Failing tensor checks will always result in a panic. +/// As mentioned in [The Rust Programming Language book](https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html), +/// when there is no way to recover, panic should be used instead of a result. +/// +/// Most users will unwrap the results anyway, which will worsen the clarity of the code. Almost +/// all checks highlight programming errors, which means invalid programs that should be fixed. +/// Checks are not the ideal way to help users write correct programs, but they are still better +/// than backend errors. Other forms of compile-time validation could be developed, such as named +/// tensors, but we have to carefully evaluate the ease of use of the Tensor API. Adding overly +/// complex type validation checks might drastically worsen the API and result in harder-to-maintain +/// programs. +/// +/// # Design +/// +/// Maybe the Backend API should return a result for each operation, which would allow handling +/// all checks, even the ones that can't be efficiently checked before performing an operation, +/// such as the `index_select` operation. The downside of that approach is that all backend +/// implementation might re-implement the same checks, which may result in unnecessary code +/// duplication. Maybe a combination of both strategies could help to cover all use cases. +pub(crate) enum TensorCheck { + Ok, + Failed(FailedTensorCheck), +} + +impl TensorCheck { + /// Checks device and shape compatibility for element wise binary operations. + pub(crate) fn binary_ops_ew( + ops: &str, + lhs: &Tensor, + rhs: &Tensor, + ) -> Self { + Self::Ok + .binary_ops_device(ops, &lhs.device(), &rhs.device()) + .binary_ops_ew_shape::(ops, &lhs.shape(), &rhs.shape()) + } + + pub(crate) fn into_scalar(shape: &Shape) -> Self { + let mut check = Self::Ok; + + if shape.num_elements() != 1 { + check = check.register( + "Into Scalar", + TensorError::new("Only tensors with 1 element can be converted into scalar.") + .details(format!( + "Current tensor has {} elements", + shape.num_elements() + )), + ); + } + + check + } + + pub(crate) fn dim_ops(ops: &str, dim: usize) -> Self { + let mut check = Self::Ok; + + if dim >= D { + check = check.register( + ops, + TensorError::new("Given dimension is higher than the tensor rank.") + .details(format!("Tensor rank: '{D}', given dimension: '{dim}'.")), + ); + } + + check + } + + pub(crate) fn creation_ops(ops: &str, dims: &[usize]) -> Self { + let mut check = Self::Ok; + + if D == 0 { + check = check.register( + ops, + TensorError::new("Tried to create a 0-dim tensor, which is invalid.") + .details(format!("Tensor rank: '{D}', given dimensions: '{dims:?}'.")), + ); + } + + if dims.len() != D { + check = check.register( + ops, + TensorError::new("Given dimensions differ from the tensor rank.") + .details(format!("Tensor rank: '{D}', given dimensions: '{dims:?}'.")), + ); + } + + check + } + + pub(crate) fn narrow( + tensor: &Tensor, + dim: usize, + start: usize, + length: usize, + ) -> Self { + let mut check = Self::Ok; + + if length == 0 { + check = check.register( + "Narrow", + TensorError::new(format!( + "Can't narrow at dimension {dim}, length must be greater than 0", + )), + ); + } + + if start >= tensor.shape()[dim] { + check = check.register( + "Narrow", + TensorError::new(format!( + "Can't narrow at dimension {dim}, start exceeds the size of the tensor along \ + this dimension (Size={})", + tensor.shape()[dim] + )), + ); + } + + if start + length > tensor.shape()[dim] { + check = check.register( + "Narrow", + TensorError::new(format!( + "Can't narrow at dimension {dim}, start + length exceeds the size of the tensor \ + along this dimension (Size={})", + tensor.shape()[dim] + )), + ); + } + + check + } + + pub(crate) fn movedim_args_usize(dim: usize) -> Self { + let mut check = Self::Ok; + + if dim >= D { + check = check.register( + "Movedim", + TensorError::new( + "The given dimension exceeds the number of dimensions of the current tensor.", + ) + .details(format!( + "Current tensor has {D} dimensions, but the given dimension is {dim}.", + )), + ); + } + + check + } + + pub(crate) fn movedim_args_i32(dim: i32) -> Self { + let mut check = Self::Ok; + + if dim < -(D as i32) || dim >= D as i32 { + check = check.register( + "Movedim", + TensorError::new( + "The given dimension is out of bounds for the current tensor dimensions.", + ) + .details(format!( + "Current tensor has {D} dimensions, but the given dimension is {dim}.", + )), + ); + } + + check + } + + pub(crate) fn movedim_args_vec(dims: &Vec) -> Self { + let mut check = Self::Ok; + + // Check out of bounds + if dims.iter().any(|&x| x >= D) { + check = check.register( + "Movedim", + TensorError::new("The given dimensions are out of bounds.").details(format!( + "Current tensor has {D} dimensions, but the given dimensions are {dims:?}.", + )), + ); + } + + // Check there are no duplicates + for (i, &dim_i) in dims.iter().enumerate() { + for &dim_j in dims.iter().skip(i + 1) { + if dim_i == dim_j { + check = check.register( + "Movedim", + TensorError::new("The given dimensions contain duplicates.").details( + format!( + "The dimension {dim_i} is duplicated in the given dimensions {dims:?}.", + ), + ), + ); + } + } + } + + check + } + + pub(crate) fn movedim_args_length( + source_dims: &Vec, + destination_dims: &Vec, + ) -> Self { + let mut check = Self::Ok; + + if source_dims.len() != destination_dims.len() { + check = check.register( + "Movedim", + TensorError::new( + "The number of dimensions in source and destination must be equal.", + ) + .details(format!( + "Source dimensions: {source_dims:?}, Destination dimensions: {destination_dims:?}.", + )), + ) + } + + check + } + + pub(crate) fn flatten( + start_dim: usize, + end_dim: usize, + ) -> Self { + let mut check = Self::Ok; + + if start_dim > end_dim { + check = check.register( + "Flatten", + TensorError::new(format!( + "The start dim ({start_dim}) must be smaller than or equal to the end dim ({end_dim})" + )), + ); + } + + if D2 > D1 { + check = check.register( + "Flatten", + TensorError::new(format!( + "Result dim ({D2}) must be smaller than or equal to ({D1})" + )), + ); + } + + if D1 < end_dim + 1 { + check = check.register( + "Flatten", + TensorError::new(format!( + "The end dim ({end_dim}) must be smaller than the tensor dim ({D1})" + )), + ); + } + + if (D2 as i32) < (D1 as i32 - (end_dim as i32 - start_dim as i32)) { + check = check.register( + "Flatten", + TensorError::new(format!( + "The destination dimension ({D2}) must be large enough to accommodate the \ + flattening operation." + )), + ); + } + + check + } + + pub(crate) fn tri() -> Self { + let mut check = Self::Ok; + + if D < 2 { + check = check.register( + "Tri", + TensorError::new(format!( + "The input tensor must have at least 2 dimensions, got {D}" + )), + ); + } + + check + } + + pub(crate) fn squeeze(dim: usize, tensor_dims: &[usize]) -> Self { + let mut check = Self::Ok; + // This should actually be to check that the dimension to squeeze + // has a size of 1 + if tensor_dims[dim] != 1 { + check = check.register( + "Squeeze", + TensorError::new(format!( + "Can't squeeze dimension {dim} because its size is not 1", + )), + ); + } + + if dim >= tensor_dims.len() { + check = check.register( + "Squeeze", + TensorError::new(format!( + "Dimension index {dim} is out of bounds for tensor dimensions {tensor_dims:?}.", + )), + ); + } + + check + } + + pub(crate) fn squeeze_dims_input( + dim_indices: &[usize], + current_dims: &[usize], + ) -> Self { + let mut check = Self::Ok; + if dim_indices.len() >= current_dims.len() { + check = check.register( + "Squeeze", + TensorError::new("Attempted to squeeze too many dimensions!").details(format!( + "Got {} dims, tensor has {}", + dim_indices.len(), + current_dims.len() + )), + ); + } + + check + } + + pub(crate) fn squeeze_dims_len(new_dims_len: usize) -> Self { + let mut check = Self::Ok; + if new_dims_len == 0 { + // 0-dim tensor not supported + check = check.register( + "Squeeze", + TensorError::new( + "Resulting dimensions cannot be zero. To remove specific singleton dimensions while preserving at least one, use `squeeze_dims` instead.".to_string() + ), + ); + } + + if new_dims_len != D2 { + check = check.register( + "Squeeze", + TensorError::new(format!( + "Resulting dimensions {new_dims_len} do not match the required D2 size {D2}.", + )), + ); + } + + check + } + + pub(crate) fn unsqueeze() -> Self { + let mut check = Self::Ok; + if D2 < D1 { + check = check.register( + "Unsqueeze", + TensorError::new(format!( + "Can't unsqueeze smaller tensor, got dim {D2}, expected > {D1}", + )), + ); + } + + check + } + + pub(crate) fn unsqueeze_dim(dim: usize) -> Self { + let mut check = Self::Ok; + if D2 <= D1 { + check = check.register( + "Unsqueeze", + TensorError::new(format!( + "The unsqueezed rank must be greater than the input rank (D={D1}; D2={D2})", + )), + ); + } + + if dim > D1 { + check = check.register( + "Unsqueeze", + TensorError::new(format!( + "Can't unsqueeze at dimension {dim}, exceeds tensor dimensions (D={D1})", + )), + ); + } + + if dim >= D2 { + check = check.register( + "Unsqueeze", + TensorError::new(format!( + "Can't unsqueeze at dimension {dim}, exceeds output tensor dimensions (D2={D2})", + )), + ); + } + + check + } + + pub(crate) fn unsqueeze_dims(dim: isize) -> Self { + let mut check = Self::Ok; + let output_rank = D as isize; + //contains is right exclusive, so this is to spec + if !(-output_rank..output_rank).contains(&dim) { + check = check.register( + "Unsqueeze", + TensorError::new(format!( + "unsqueeze arg {dim} is out of range for the output tensor of rank {output_rank}", + )), + ); + } + check + } + + pub(crate) fn one_hot_tensor( + index_tensor: Tensor, + num_classes: usize, + ) -> Self { + let mut check = Self::Ok; + if index_tensor + .clone() + .greater_equal_elem(num_classes as i32) + .any() + .into_scalar::() + .to_bool() + { + check = check.register( + "One Hot", + TensorError::new(format!( + "Can't create a one hot tensor from ({index_tensor:?}) containing indexes greater or equal to the number of classes ({num_classes})", + )), + ); + } else if num_classes <= 1 { + check = check.register( + "One Hot", + TensorError::new("Can't create a one hot tensor with less than 2 classes"), + ) + } + check + } + + pub(crate) fn one_hot_tensor_rank() -> Self { + let mut check = Self::Ok; + if D + 1 != D2 { + check = check.register( + "One Hot", + TensorError::new( + "The one-hot tensor rank must correspond to the rank of the tensor + 1", + ) + .details(format!("Expected D2={}, got {D2}", D + 1)), + ); + } + check + } + + pub(crate) fn swap_dims(dim1: usize, dim2: usize) -> Self { + let mut check = Self::Ok; + + if dim1 > D || dim2 > D { + check = check.register( + "Swap Dims", + TensorError::new("The swap dimensions must be smaller than the tensor dimension") + .details(format!( + "Swap dims ({dim1}, {dim2}) on tensor with ({D}) dimensions." + )), + ); + } + + check + } + + pub(crate) fn permute(axes: [usize; D]) -> Self { + let check = Self::Ok; + + // Check if the axes are within the tensor dimensions + if let Some(axis) = axes.iter().find(|&x| *x >= D) { + return check.register( + "permute", + TensorError::new("The axes must be smaller than the tensor dimension.") + .details(format!("The '{axis}' axis is greater than {D} dimensions.")), + ); + } + + // Check if the axes are unique + let mut seen = [false; D]; + axes.iter().for_each(|&x| seen[x] = true); + if seen.iter().any(|&x| !x) { + return check.register( + "permute", + TensorError::new("The axes must be unique.") + .details(format!("The axes '{axes:?}' are not unique.")), + ); + } + + check + } + + pub(crate) fn flip(rank: usize, axes: &[usize]) -> Self { + let check = Self::Ok; + + // Check if the axes are within the tensor dimensions + if let Some(axis) = axes.iter().find(|&x| *x >= rank) { + return check.register( + "flip", + TensorError::new("The axes must be smaller than the tensor dimension.").details( + format!("The '{axis}' axis is greater than {rank} dimensions."), + ), + ); + } + + // Check if the axes are unique + let mut dedup = axes.to_vec(); + dedup.sort_unstable(); + dedup.dedup(); + if dedup.len() != axes.len() { + return check.register( + "flip", + TensorError::new("The axes must be unique.") + .details(format!("The axes '{axes:?}' are not unique.")), + ); + } + + check + } + + pub(crate) fn matmul(lhs: &Tensor, rhs: &Tensor) -> Self + where + K: BasicOps, + { + let mut check = Self::Ok; + + check = check.binary_ops_device("Matmul", &lhs.device(), &rhs.device()); + + if D < 2 { + return check; + } + + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + + let dim_lhs = shape_lhs[D - 1]; + let dim_rhs = shape_rhs[D - 2]; + + if dim_lhs != dim_rhs { + check = check.register( + "Matmul", + TensorError::new(format!( + "The inner dimension of matmul should be the same, but got {dim_lhs} and \ + {dim_rhs}." + )) + .details(format!( + "Lhs shape {:?}, rhs shape {:?}.", + shape_lhs, shape_rhs + )), + ); + } + + check + } + + pub(crate) fn cross( + lhs: &Tensor, + rhs: &Tensor, + dim: usize, + ) -> Self + where + K: BasicOps, + { + let mut check = Self::Ok; + + check = check.binary_ops_device("Cross", &lhs.device(), &rhs.device()); + + let shape_lhs = lhs.shape(); + let shape_rhs = rhs.shape(); + + if dim >= D { + check = check.register( + "Cross", + TensorError::new(format!( + "Dimension {dim} is out of bounds for tensors with {D} dimensions." + )), + ); + return check; + } + + let dim_size_lhs = shape_lhs[dim]; + let dim_size_rhs = shape_rhs[dim]; + + if dim_size_lhs != 3 || dim_size_rhs != 3 { + check = check.register( + "Cross", + TensorError::new(format!( + "Cross product requires dimension {dim} to have size 3, but got {dim_size_lhs} and {dim_size_rhs}." + )), + ); + } + + // Check broadcastability of other dimensions + for i in 0..D { + if i != dim { + let l = shape_lhs[i]; + let r = shape_rhs[i]; + if l != r && l != 1 && r != 1 { + check = check.register( + "Cross", + TensorError::new(format!( + "Tensors are not broadcastable along dimension {i}: {l} and {r}." + )), + ); + } + } + } + + check + } + + pub(crate) fn stack( + tensors: &[Tensor], + dim: usize, + ) -> Self { + let mut check = Self::Ok; + + if dim > D1 { + check = check.register( + "Stack", + TensorError::new( + "Can't stack tensors on a dim that exceeds the tensors dimension (inclusive)", + ) + .details(format!( + "Trying to concatenate tensors with {D1} dimensions on axis {dim}." + )), + ); + } + + if D1 == D2 { + check = check.register( + "Stack", + TensorError::new(format!( + "Can't stack tensors on existing dimension {dim}, the input and output ranks are the same (D={D1}; D2={D2}).\ + If you want to concatenate the tensors along the specified dimension ({dim}), use `Tensor::cat` instead.", + )), + ); + } + + if tensors.is_empty() { + return check.register( + "Stack", + TensorError::new("Can't stack an empty list of tensors."), + ); + } + + let shape_reference = tensors.first().unwrap().shape(); + + for tensor in tensors { + let shape = tensor.shape(); + + if shape_reference != shape { + return check.register( + "Stack", + TensorError::new("Can't stack tensors with different shapes").details(format!( + "Provided dimension ({dim}), tensors shapes: {:?}", + tensors.iter().map(Tensor::shape).collect::>() + )), + ); + } + } + + check + } + + pub(crate) fn cat(tensors: &[Tensor], dim: usize) -> Self { + let mut check = Self::Ok; + + if dim >= D { + check = check.register( + "Cat", + TensorError::new( + "Can't concatenate tensors on a dim that exceeds the tensors dimension", + ) + .details(format!( + "Trying to concatenate tensors with {D} dimensions on axis {dim}." + )), + ); + } + + if tensors.is_empty() { + return check.register( + "Cat", + TensorError::new("Can't concatenate an empty list of tensors."), + ); + } + + let mut shape_reference = tensors.first().unwrap().shape(); + shape_reference[dim] = 1; // We want to check every dims except the one where the + // concatenation happens. + + for tensor in tensors { + let mut shape = tensor.shape(); + shape[dim] = 1; // Ignore the concatenate dim. + + if shape_reference != shape { + return check.register( + "Cat", + TensorError::new( + "Can't concatenate tensors with different shapes, except for the provided \ + dimension", + ) + .details(format!( + "Provided dimension ({dim}), tensors shapes: {:?}", + tensors.iter().map(Tensor::shape).collect::>() + )), + ); + } + } + + check + } + + pub(crate) fn slice(shape: &Shape, slices: &[Slice]) -> Self { + let mut check = Self::Ok; + let n_dims_tensor = R; + let n_dims_slices = slices.len(); + + if n_dims_tensor < n_dims_slices { + check = check.register( + "Slice", + TensorError::new( + "The provided slices array has a higher number of dimensions than the current \ + tensor.", + ) + .details(format!( + "The slices array must be smaller or equal to the tensor number of \ + dimensions. Tensor number of dimensions: {n_dims_tensor}, slices array \ + length {n_dims_slices}." + )), + ); + } + + for (i, slice) in slices.iter().enumerate().take(R) { + let d_tensor = shape[i]; + + // Check the raw end value before conversion + if let Some(end) = slice.end + && end > 0 + && end as usize > d_tensor + { + check = check.register( + "Slice", + TensorError::new( + "The provided slice has a range that exceeds the current tensor \ + size.", + ) + .details(format!( + "The slice end index {} exceeds the size of the tensor ({}) at dimension {}. \ + Tensor shape {:?}.", + end, d_tensor, i, shape, + )), + ); + } + + // Empty slices (start >= end) are allowed and produce a tensor with size 0 + // in that dimension. This matches PyTorch behavior and is required for ONNX + // compatibility where dynamic slice ranges may become empty at runtime. + + if slice.step() == 0 { + check = check.register( + "Slice", + TensorError::new("The provided slice has a step of 0.").details(format!( + "The slice at dimension '{i}' has a step of 0. Step must be non-zero.", + )), + ); + } + } + + check + } + + pub(crate) fn slice_assign( + shape: &Shape, + shape_value: &Shape, + slices: &[crate::Slice], + ) -> Self { + let mut check = Self::Ok; + let n_dims_slices = slices.len(); + + if R < n_dims_slices { + check = check.register( + "Slice Assign", + TensorError::new( + "The provided slices array has a higher number of dimensions than the current \ + tensor.", + ) + .details(format!( + "The slices array must be smaller or equal to the tensor number of \ + dimensions. Tensor number of dimensions: {R}, slices array length {n_dims_slices}." + )), + ); + } + + for (i, slice) in slices.iter().enumerate().take(usize::min(R, n_dims_slices)) { + let d_tensor = shape[i]; + let d_tensor_value = shape_value[i]; + let range = slice.to_range(d_tensor); + + if range.end > d_tensor { + check = check.register( + "Range Assign", + TensorError::new( + "The provided slice has a range that exceeds the current tensor \ + size.", + ) + .details(format!( + "The range ({}..{}) exceeds the size of the tensor ({}) at dimension {}. \ + Current tensor shape {:?}, value tensor shape {:?}.", + range.start, range.end, d_tensor, i, shape, shape_value, + )), + ); + } + + // Calculate the number of elements selected with the given step + let num_elements = slice.output_size(d_tensor); + + if num_elements != d_tensor_value { + check = check.register( + "Slice Assign", + TensorError::new( + "The value tensor must match the amount of elements selected with the \ + slices array", + ) + .details(format!( + "The slice with range ({}..{}) and step {} selects {} elements but the value \ + tensor has {} elements at dimension {}. Current tensor shape {:?}, value tensor \ + shape {:?}.", + range.start, + range.end, + slice.step, + num_elements, + d_tensor_value, + i, + shape, + shape_value, + )), + ); + } + + // Note: Empty slices (start >= end with positive step) are handled at the API level + // by returning the original tensor unchanged, so we don't check for them here. + } + + check + } + + pub(crate) fn check_is_power_of_two(shape: &Shape, dim: usize) -> Self { + let mut check = Self::Ok; + let dim_size = shape[dim]; + + if !dim_size.is_power_of_two() { + check = check.register( + "Check Is Power of two", + TensorError::new("The provided dimension size must be a power of two.") + .details(format!("The length of dimension {dim} is {dim_size}.")), + ); + } + + check + } + + pub(crate) fn check_dim(dim: usize) -> Self { + let mut check = Self::Ok; + + if dim >= D { + check = check.register( + "Check Dim", + TensorError::new("The provided dimension exceeds the tensor dimensions.").details( + format!("Tensor has {D} dimensions, but the provided dimension is {dim}."), + ), + ); + } + + check + } + + pub(crate) fn gather(dim: usize, shape: &Shape, shape_indices: &Shape) -> Self { + Self::check_gather_scatter_indices::(Self::Ok, "Gather", dim, shape, shape_indices) + } + + pub(crate) fn scatter( + dim: usize, + shape: &Shape, + shape_indices: &Shape, + shape_value: &Shape, + ) -> Self { + let ops = "Scatter"; + let mut check = + Self::check_gather_scatter_indices::(Self::Ok, ops, dim, shape, shape_indices); + + if shape_indices != shape_value { + check = check.register( + ops, + TensorError::new( + "Indices tensor shape should be the same as the value tensor shape." + .to_string(), + ) + .details(format!( + "The shape differs: {:?} != {:?}", + shape_indices, shape_value + )), + ); + } + + check + } + + pub(crate) fn scatter_nd( + data_shape: &Shape, + indices_shape: &Shape, + values_shape: &Shape, + ) -> Self { + let ops = "ScatterNd"; + let mut check = Self::Ok; + + if M == 0 { + return check.register( + ops, + TensorError::new("Indices tensor must have rank >= 1".to_string()), + ); + } + + if indices_shape.num_elements() == 0 { + return check.register( + ops, + TensorError::new("Indices tensor must not be empty".to_string()), + ); + } + + let k = indices_shape[M - 1]; + + if k > D { + return check.register( + ops, + TensorError::new(format!( + "Last dimension of indices (K={k}) must be <= data rank (D={D})" + )), + ); + } + + let expected_dv = M - 1 + D - k; + if DV != expected_dv { + check = check.register( + ops, + TensorError::new(format!( + "Values rank DV={DV} does not match expected M-1+D-K = {expected_dv}" + )), + ); + } + + // Batch dims: first M-1 dims of values must equal first M-1 dims of indices + for i in 0..(M - 1) { + if values_shape[i] != indices_shape[i] { + check = check.register( + ops, + TensorError::new(format!( + "Batch dimension {i} mismatch: values={} vs indices={}", + values_shape[i], indices_shape[i] + )), + ); + } + } + + // Slice dims: last D-K dims of values must equal last D-K dims of data + for i in 0..(D - k) { + let val_idx = M - 1 + i; + let data_idx = k + i; + if val_idx < DV && data_idx < D && values_shape[val_idx] != data_shape[data_idx] { + check = check.register( + ops, + TensorError::new(format!( + "Slice dimension mismatch at values[{val_idx}]={} vs data[{data_idx}]={}", + values_shape[val_idx], data_shape[data_idx] + )), + ); + } + } + + check + } + + pub(crate) fn gather_nd( + indices_shape: &Shape, + ) -> Self { + let ops = "GatherNd"; + let mut check = Self::Ok; + + if M == 0 { + return check.register( + ops, + TensorError::new("Indices tensor must have rank >= 1".to_string()), + ); + } + + if indices_shape.num_elements() == 0 { + return check.register( + ops, + TensorError::new("Indices tensor must not be empty".to_string()), + ); + } + + let k = indices_shape[M - 1]; + + if k > D { + return check.register( + ops, + TensorError::new(format!( + "Last dimension of indices (K={k}) must be <= data rank (D={D})" + )), + ); + } + + let expected_dv = M - 1 + D - k; + if DV != expected_dv { + check = check.register( + ops, + TensorError::new(format!( + "Output rank DV={DV} does not match expected M-1+D-K = {expected_dv}" + )), + ); + } + + check + } + + pub(crate) fn select(dim: usize) -> Self { + Self::check_select_basic::(Self::Ok, "select", dim) + } + + pub(crate) fn take(dim: usize) -> Self { + let mut check = Self::check_select_basic::(Self::Ok, "Take", dim); + + // Calculate expected output dimensions + // DO = D - 1 + DI (remove 1 dim, add DI dims) + let expected_do = D + DI - 1; + if DO != expected_do { + check = check.register( + "Take", + TensorError::new("Output dimension mismatch").details(format!( + "Expected output dimension {} (D={} + DI={} - 1) but got DO={}", + expected_do, D, DI, DO + )), + ); + } + + check + } + + pub(crate) fn diag() -> Self { + let mut check = Self::Ok; + + if D < 2 { + check = check.register( + "Diag", + TensorError::new( + "Diagonal operations require + tensors with at least 2 dimensions.", + ) + .details(format!( + "Got tensor with {D} dimensions, + expected at least 2" + )), + ); + } + + if DO != D - 1 { + check = check.register( + "Diag", + TensorError::new("Output rank must be input rank minus 1 for diagonal") + .details(format!("Expected output rank {}, got {DO}", D - 1)), + ); + } + + check + } + + pub(crate) fn select_assign( + dim: usize, + shape_indices: &Shape, + shape_value: &Shape, + ) -> Self { + let mut check = Self::check_select_basic::(Self::Ok, "Select Assign", dim); + + if shape_value[dim] != shape_indices[0] { + check = check.register( + "Select Assign", + TensorError::new( + format!( + "Number of indices ({}) should be equal to value tensor dimensions {:?} on axis (dim={dim})", + shape_indices[0], + shape_value + ), + ) + ); + } + + check + } + + fn check_select_basic(mut check: Self, ops: &str, dim: usize) -> Self { + if dim > D { + check = check.register( + ops, + TensorError::new(format!( + "Can't index a tensor with ({D}) dimensions on axis ({dim})" + )), + ); + } + + check + } + fn check_gather_scatter_indices( + mut check: Self, + ops: &str, + dim: usize, + shape: &Shape, + shape_indices: &Shape, + ) -> Self { + if dim > D { + check = check.register( + ops, + TensorError::new(format!( + "Can't index a tensor with ({D}) dimensions on axis ({dim})" + )), + ); + } + + for i in 0..D { + if i == dim { + continue; + } + + let tensor_dim_i = shape[i]; + let indices_dim_i = shape_indices[i]; + + if tensor_dim_i != indices_dim_i { + check = check.register( + ops, + TensorError::new( + "The tensor shape should be the same as the index tensor shape." + .to_string(), + ) + .details(format!( + "The shape differs at dimension {i}: {tensor_dim_i} != {indices_dim_i}" + )), + ); + } + } + + check + } + + pub(crate) fn check_prelu_shape( + shape_tensor: &Shape, + shape_weight: &Shape, + ) -> Self { + let mut check = Self::Ok; + if shape_weight[0] == 1 { + check + } else if D >= 2 { + let channels = shape_tensor[1]; + let num_weights = shape_weight[0]; + if channels != num_weights { + check = check.register( + "PReLu", + TensorError::new( + "Number of channels in input tensor and number of weights must be equal", + ) + .details(format!( + "Got no. of channels: {channels}, no. of weights: {num_weights}", + )), + ); + return check; + } + check + } else { + check = check.register( + "PReLu", + TensorError::new( + "Number of channels in input tensor and number of weights must be equal", + ) + .details(format!( + "Got no. of channels: 1, no. of weights: {}", + shape_weight[0] + )), + ); + check + } + } + + /// Checks aggregate dimension such as mean and sum. + pub(crate) fn aggregate_dim(ops: &str, dim: usize) -> Self { + let mut check = Self::Ok; + + if dim > D { + check = check.register( + ops, + TensorError::new(format!( + "Can't aggregate a tensor with ({D}) dimensions on axis ({dim})" + )), + ); + } + + check + } + + pub(crate) fn sort_dim(ops: &str, dim: usize) -> Self { + let mut check = Self::Ok; + + if dim > D { + check = check.register( + ops, + TensorError::new(format!( + "Can't sort a tensor with ({D}) dimensions on axis ({dim})" + )), + ); + } + + check + } + + pub(crate) fn split( + tensor_dims: &[usize], + split_size: usize, + dim: usize, + ) -> Self { + let mut check = Self::Ok; + let op = "split"; + let tensor_rank = tensor_dims.len(); + + if dim >= tensor_rank { + check = check.register( + op, + TensorError::new("Given dimension is greater than or equal to the tensor rank.") + .details(format!("Tensor rank: '{D}', given dimension: '{dim}'")), + ); + } else { + let tensor_size = tensor_dims[dim]; + if split_size == 0 && tensor_size != 0 { + check = check.register( + op, + TensorError::new("split_size must be greater than 0 unless the tensor size along the dimension is 0.") + .details(format!("split_size: '{split_size}', tensor size along dim '{dim}': '{tensor_size}'.")), + ); + } + } + + check + } + + pub(crate) fn split_with_sizes( + tensor_dims: &[usize], + split_sizes: &[usize], + dim: usize, + ) -> Self { + let mut check = Self::Ok; + let op = "split_with_sizes"; + let tensor_rank = tensor_dims.len(); + + if dim >= tensor_rank { + check = check.register( + op, + TensorError::new("Given dimension is greater than or equal to the tensor rank.") + .details(format!("Tensor rank: '{D}', given dimension: '{dim}'.")), + ); + } else { + // Validate split_sizes add up to size of dimension to split along + let tensor_size = tensor_dims[dim]; + let total_split_size: usize = split_sizes.iter().sum(); + if total_split_size != tensor_size { + check = check.register( + op, + TensorError::new("The sum of split_sizes must equal the tensor size along the specified dimension.") + .details(format!("Sum of split_sizes: '{total_split_size}', tensor size along dim '{dim}': '{tensor_size}'.")), + ); + } + } + + check + } + + /// The goal is to minimize the cost of checks when there are no error, but it's way less + /// important when an error occurred, crafting a comprehensive error message is more important + /// than optimizing string manipulation. + fn register(self, ops: &str, error: TensorError) -> Self { + let errors = match self { + Self::Ok => vec![error], + Self::Failed(mut failed) => { + failed.errors.push(error); + failed.errors + } + }; + + Self::Failed(FailedTensorCheck { + ops: ops.to_string(), + errors, + }) + } + + /// Checks if shapes are compatible for element wise operations supporting broadcasting. + pub(crate) fn binary_ops_ew_shape( + self, + ops: &str, + lhs: &Shape, + rhs: &Shape, + ) -> Self { + let mut check = self; + + for i in 0..D { + let d_lhs = lhs[i]; + let d_rhs = rhs[i]; + + if d_lhs != d_rhs { + let is_broadcast = d_lhs == 1 || d_rhs == 1; + + if is_broadcast { + continue; + } + + check = check.register( + ops, + TensorError::new("The provided tensors have incompatible shapes.").details( + format!( + "Incompatible size at dimension '{}' => '{} != {}', which can't be \ + broadcasted. Lhs tensor shape {:?}, Rhs tensor shape {:?}.", + i, d_lhs, d_rhs, lhs, rhs, + ), + ), + ); + } + } + + check + } + + /// Checks if tensor devices are equal. + fn binary_ops_device( + self, + ops: &str, + lhs: &Device, + rhs: &Device, + ) -> Self { + match lhs != rhs { + true => self.register( + ops, + TensorError::new("The provided tensors are not on the same device.").details( + format!("Lhs tensor device {lhs:?}, Rhs tensor device {rhs:?}.",), + ), + ), + false => self, + } + } + + /// Checks if expand operation is possible for the given shapes. + pub(crate) fn expand( + ops: &str, + shape: &Shape, + to: &Shape, + ) -> Self { + let mut check = TensorCheck::Ok; + let max_dims = core::cmp::max(D1, D2); + + // Calculate the starting indices for each shape array, ensuring alignment from the right. + let start_index_shape = max_dims.saturating_sub(D1); + let start_index_to = max_dims.saturating_sub(D2); + + for i in 0..max_dims { + // Use 1 as the default dimension size for dimensions beyond the tensor's rank. + let d_shape = if i >= start_index_shape { + shape[i - start_index_shape] + } else { + 1 + }; + let d_to = if i >= start_index_to { + to[i - start_index_to] + } else { + 1 + }; + + if d_shape != d_to && d_shape != 1 && d_to != 1 { + // Register an incompatibility error. + check = check.register( + ops, + TensorError::new( + "The provided tensor can't be broadcasted to the target shape.", + ) + .details(format!( + "Incompatible size at dimension '{}' => '{} != {}', which can't be \ + broadcasted. Tensor shape {:?}, Target shape {:?}.", + max_dims - i - 1, + d_shape, + d_to, + shape, + to, + )), + ); + break; // Incompatibility found, no need to check further. + } + } + + check + } + + /// Checks if unfold operation is possible for the given shapes. + pub(crate) fn unfold( + ops: &str, + _shape: &Shape, + _dim: usize, + _size: usize, + _step: usize, + ) -> Self { + let mut check = TensorCheck::Ok; + + if D2 != D1 + 1 { + check = check.register( + ops, + TensorError::new("The unfold rank is incompatible with the input tensor rank.") + .details(format!( + "The output rank '{D2}' != the input rank + 1 '{D1}'.", + )), + ); + } + + check + } + + /// Checks if input is compatible with convolution weights. + pub(crate) fn conv( + ops: &str, + x: [usize; D1], + weight: [usize; D2], + groups: usize, + ) -> Self { + let mut check = TensorCheck::Ok; + let channels = x[1]; + let expected = weight[1] * groups; + if channels != expected { + check = check.register( + ops, + TensorError::new("Number of channels in input tensor and input channels of convolution must be equal.") + .details(format!("got: {channels}, expected: {expected}")), + ); + } + check + } + + /// Checks if input is compatible with transposed convolution weights. + pub fn conv_transpose( + ops: &str, + x: [usize; D1], + weight: [usize; D2], + ) -> Self { + let mut check = TensorCheck::Ok; + let channels = x[1]; + let expected = weight[0]; + if channels != expected { + check = check.register( + ops, + TensorError::new("Number of channels in input tensor and input channels of convolution must be equal.") + .details(format!("got: {channels}, expected: {expected}")), + ); + } + check + } + + /// Check the generic parameters for lu decomposition is valid. + pub fn lu_generic_param(ops: &str) -> Self { + let mut check = TensorCheck::Ok; + if D - 1 != D1 { + check = check.register( + ops, + TensorError::new( + "D - 1 = D1 must hold for the generic parameters of LU decomposition.", + ) + .details(format!("Got generic parameters D = {} and D1 = {}", D, D1)), + ); + } + check + } + + /// Check the input tensor for lu decomposition is valid. + pub fn lu_input_tensor(ops: &str, dims: &[usize], dtype: DType) -> Self { + let mut check = TensorCheck::Ok; + + if matches!(dtype, DType::QFloat(_)) { + check = check.register( + ops, + TensorError::new("The input tensor must have a real float dtype") + .details("Got an input tensor with a quantized float dtype".to_string()), + ); + } + + let n_dims = dims.len(); + if n_dims < 2 { + check = check.register( + ops, + TensorError::new( + "The input tensor for LU decomposition must have at least two dimensions.", + ) + .details(format!("Got input tensor with {} dimensions", n_dims)), + ); + } + + check + } + + /// Check if input tensor and generic parameters of `linalg::det()` are valid. + pub fn det( + dims: [usize; D], + dtype: DType, + ) -> Self { + let mut check = TensorCheck::Ok; + + if matches!(dtype, DType::QFloat(_)) { + check = check.register( + "det", + TensorError::new("The input tensor must have a real float dtype.") + .details("Got an input tensor with a quantized float dtype".to_string()), + ); + } + + if D1 != D - 1 { + check = check.register( + "det", + TensorError::new( + "D - 1 = D1 must hold for the generic parameters of the linalg::det function.", + ) + .details(format!("Got generic parameters D = {D} and D1 = {D1}")), + ); + } + + if D2 != D - 2 { + check = check.register( + "det", + TensorError::new("The output tensor rank must be less than input tensor rank by 2") + .details(format!( + "Got input tensor rank {D} and output tensor rank {D2}" + )), + ); + } + + if D < 3 { + check = check.register( + "det", + TensorError::new(format!( + "The input tensor must have at least 3 dimensions, got {D}" + )), + ); + } + + if dims[D - 1] != dims[D - 2] { + check = check.register( + "det", + TensorError::new("The last two dimensions of the input tensor must be equal") + .details(format!("Got input tensor with shape {:?}", dims)), + ); + } + + check + } +} + +pub(crate) struct FailedTensorCheck { + ops: String, + errors: Vec, +} + +impl FailedTensorCheck { + /// Format all the checks into a single message ready to be printed by a [panic](core::panic). + pub(crate) fn format(self) -> String { + self.errors.into_iter().enumerate().fold( + format!( + "=== Tensor Operation Error ===\n Operation: '{}'\n Reason:", + self.ops + ), + |accum, (number, error)| accum + error.format(number + 1).as_str(), + ) + "\n" + } +} + +struct TensorError { + description: String, + details: Option, +} + +impl TensorError { + pub(crate) fn new>(description: S) -> Self { + TensorError { + description: description.into(), + details: None, + } + } + + pub(crate) fn details>(mut self, details: S) -> Self { + self.details = Some(details.into()); + self + } + + fn format(self, number: usize) -> String { + let mut message = format!("\n {number}. "); + message += self.description.as_str(); + message += " "; + + if let Some(details) = self.details { + message += details.as_str(); + message += " "; + } + + message + } +} + +/// Module where we defined macros that can be used only in the project. +pub(crate) mod macros { + /// We use a macro for all checks, since the panic message file and line number will match the + /// function that does the check instead of a generic error.rs crate private unrelated file + /// and line number. + macro_rules! check { + ($check:expr) => { + if let TensorCheck::Failed(check) = $check { + core::panic!("{}", check.format()); + } + }; + } + pub(crate) use check; +} + +pub(crate) fn unwrap_shape_reshape(result: Result) -> Shape { + match result { + Ok(shape) => shape, + // `shape.reshape(new_shape)` should only return `MetadataError::Invalid`. + Err(burn_std::MetadataError::Invalid { reason }) => { + macros::check!({ + TensorCheck::Ok.register("Reshape", crate::check::TensorError::new(reason)) + }); + unreachable!() + } + Err(e) => panic!("{e:?}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use macros::check; + + #[test] + #[should_panic] + fn index_range_exceed_dimension() { + let slices = vec![Slice::from(0..2), Slice::from(0..4), Slice::from(1..8)]; + check!(TensorCheck::slice::<3>(&Shape::new([3, 5, 7]), &slices)); + } + + #[test] + #[should_panic] + fn index_range_exceed_number_of_dimensions() { + let slices = vec![Slice::from(0..1), Slice::from(0..1), Slice::from(0..1)]; + check!(TensorCheck::slice::<2>(&Shape::new([3, 5]), &slices)); + } + + #[test] + #[should_panic] + fn binary_ops_shapes_no_broadcast() { + check!(TensorCheck::binary_ops_ew_shape::<2>( + TensorCheck::Ok, + "TestOps", + &Shape::new([3, 5]), + &Shape::new([3, 6]) + )); + } + + #[test] + fn binary_ops_shapes_with_broadcast() { + check!(TensorCheck::binary_ops_ew_shape::<2>( + TensorCheck::Ok, + "Test", + &Shape::new([3, 5]), + &Shape::new([1, 5]) + )); + } + + #[test] + #[should_panic] + fn binary_ops_devices() { + check!(TensorCheck::binary_ops_device( + TensorCheck::Ok, + "Test", + &5, // We can pass anything that implements PartialEq as device + &8 + )); + } + + #[test] + #[should_panic] + fn movedim_args_out_of_bounds() { + check!(TensorCheck::movedim_args_usize::<3>(5)); + } + + #[test] + fn movedim_args_i32() { + check!(TensorCheck::movedim_args_i32::<3>(-3)); + } + + #[test] + #[should_panic] + fn movedim_args_too_negative() { + check!(TensorCheck::movedim_args_i32::<3>(-4)); + } + + #[test] + #[should_panic] + fn movedim_args_vec_out_of_bounds() { + check!(TensorCheck::movedim_args_vec::<3>(&vec![0, 1, 3])); + } + + #[test] + #[should_panic] + fn movedim_args_vec_duplicates() { + check!(TensorCheck::movedim_args_vec::<3>(&vec![0, 1, 1])); + } + + #[test] + #[should_panic] + fn movedim_args_length() { + check!(TensorCheck::movedim_args_length( + &vec![0, 1], + &vec![0, 1, 2] + )); + } + + #[test] + #[should_panic] + fn unsqueeze_dim_same_rank() { + check!(TensorCheck::unsqueeze_dim::<3, 3>(2)); + } +} diff --git a/crates/burn-tensor/src/tensor/api/extension.rs b/crates/burn-tensor/src/tensor/api/extension.rs new file mode 100644 index 0000000..be550b9 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/extension.rs @@ -0,0 +1,463 @@ +use burn_backend::{Backend, TensorMetadata}; +pub use burn_dispatch::DispatchTensor; +use burn_dispatch::{BackendTensor, DispatchKindConversion}; +use burn_std::DType; + +use crate::{ + Bool, Float, Int, Tensor, + kind::Basic, + ops::{BridgeKind, BridgeTensor, Kind}, +}; + +use alloc::{format, string::String}; + +impl Tensor +where + K: Basic, +{ + /// Converts the tensor into its bridge-layer representation. + /// + /// This is primarily intended for backend extensions, allowing custom operations + /// to be inserted between the high-level tensor API and the dispatch layer before + /// deferring to a concrete backend. + pub fn into_bridge(self) -> BridgeTensor { + self.primitive + } + + /// Reconstructs a tensor from its [`BridgeTensor`] bridge representation. + /// + /// This is the inverse of [`Tensor::into_bridge`] and is primarily intended + /// for backend extensions, to wrap the output of a custom operation back into + /// the high-level tensor API. + /// + /// # Panics + /// + /// Panics if the [`BridgeTensor`] variant does not match the tensor kind `K` + /// (e.g. passing an int [`BridgeTensor`] when `K` is [`Float`](crate::Float). + pub fn from_bridge(tensor: BridgeTensor) -> Self { + let dtype = tensor.dtype(); + match (tensor.kind(), K::KIND) { + (BridgeKind::Bool, Kind::Bool) if dtype.is_bool() => Self::new(tensor), + (BridgeKind::Int, Kind::Int) if dtype.is_int() || dtype.is_uint() => Self::new(tensor), + (BridgeKind::Float, Kind::Float) if dtype.is_float() => Self::new(tensor), + (BridgeKind::QFloat, Kind::Float) if matches!(dtype, DType::QFloat(_)) => { + Self::new(tensor) + } + (_, kind) => panic!("Expected kind {kind:?}, got dtype {dtype:?}"), + } + } + + /// Converts from a dispatch tensor into a tensor. + /// + /// # Panics + /// Panis if the dispatch dtype does not match the tensor kind `K`. + pub fn from_dispatch(tensor: DispatchTensor) -> Self { + match (tensor.dtype(), K::KIND) { + (DType::QFloat(_), Kind::Float) => Self::new(BridgeTensor::qfloat(tensor)), + (dtype, Kind::Float) if dtype.is_float() => Self::new(BridgeTensor::float(tensor)), + (dtype, Kind::Int) if dtype.is_int() || dtype.is_uint() => { + Self::new(BridgeTensor::int(tensor)) + } + (dtype, Kind::Bool) if dtype.is_bool() => Self::new(BridgeTensor::bool(tensor)), + (dtype, kind) => panic!("Expected kind {kind:?}, got dtype {dtype:?}"), + } + } + + /// Converts the tensor into a dispatch tensor. + pub fn into_dispatch(self) -> DispatchTensor { + self.primitive.into() + } + + /// Safely downcasts a [`Tensor`] into its low-level backend primitive. + /// + /// This is primarily intended for backend extensions where interfacing with the direct backend + /// primitive (e.g., `B::FloatTensorPrimitive` or `AutodiffTensor`) is necessary. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Extract the underlying CubeCL tensor primitive on the `Wgpu` backend + /// let cube_tensor = tensor.try_into_primitive::()?; + /// + /// // For an autodiff tensor, we can get the wrapped CubeCL tensor primitive on the `Autodiff` backend + /// let ad_tensor = tensor.try_into_primitive::>()?; + ///``` + /// + /// # Errors + /// + /// Returns a [`PrimitiveConversionError`] if the tensor does not currently live on the requested + /// backend `B` (including `Autodiff` mismatch). + pub fn try_into_primitive( + self, + ) -> Result<>::Primitive, PrimitiveConversionError> + where + K: BackendPrimitive, + DispatchTensor: DispatchKindConversion, + { + let dispatch = self.primitive.into(); + // `tensor.try_into_backend::>()` returns a `BackendTensor::Float(AutodiffTensor)` + // so it is automatically handled by the `try_into_primitive` impl for `Float` + let tensor = >::try_into_backend(dispatch) + .map_err(PrimitiveConversionError::BackendMismatch)?; + >::try_into_primitive(tensor) + } + + /// Reconstructs a high-level [`Tensor`] from a concrete backend primitive. + /// + /// This is the inverse of [`Tensor::try_into_primitive`]. + /// + /// # Panics + /// Panis if the tensor kind `K` does not match the tensor underlying primitive kind. + pub fn from_primitive(primitive: >::Primitive) -> Self + where + K: BackendPrimitive, + DispatchTensor: DispatchKindConversion, + { + let tensor = >::from_primitive(primitive); + let dispatch = >::from_backend(tensor); + Self::from_dispatch(dispatch) + } +} + +/// Error returned when a [`DispatchTensor`] cannot be converted to the requested concrete primitive type. +#[derive(Debug, PartialEq)] +pub enum PrimitiveConversionError { + /// The dispatch tensor's backend variant does not match the requested backend. + /// + /// For example, extracting a `Wgpu` primitive from a `Cuda` dispatch tensor. + BackendMismatch(String), + /// The tensor kind does not match the requested primitive kind. + /// + /// For example, extracting a float primitive from an int tensor. + KindMismatch(String), +} + +/// Trait to safely extract and wrap backend-specific primitives based on the high-level tensor kind. +/// +/// This trait functions as a type-level map linking frontend kinds (`Float`, `Int`, `Bool`) +/// to their corresponding backend-associated types (`B::FloatTensorPrimitive`, `B::IntTensorPrimitive`, etc.). +pub trait BackendPrimitive { + /// The backend tensor primitive. + type Primitive; + + /// Attempts to unpack the type-erased [`BackendTensor`] enum wrapper into the concrete + /// variant expected by this kind. + /// + /// # Errors + /// + /// Returns an error if there is a variant type mismatch (e.g., attempting to extract an + /// `Int` primitive out of a `BackendTensor::Float` enum variant). + fn try_into_primitive( + tensor: BackendTensor, + ) -> Result; + + /// Wraps a backend tensor primitive into its corresponding `BackendTensor` enum variant. + fn from_primitive(primitive: Self::Primitive) -> BackendTensor; +} + +impl BackendPrimitive for Float { + // NOTE: not implemented for QFloat + type Primitive = B::FloatTensorPrimitive; + + fn try_into_primitive( + tensor: BackendTensor, + ) -> Result { + match tensor { + BackendTensor::Float(t) => Ok(t), + other => Err(PrimitiveConversionError::KindMismatch(format!( + "Expected Float primitive, got variant: {}", + other.name() + ))), + } + } + + fn from_primitive(primitive: Self::Primitive) -> BackendTensor { + BackendTensor::Float(primitive) + } +} + +impl BackendPrimitive for Int { + type Primitive = B::IntTensorPrimitive; + + fn try_into_primitive( + tensor: BackendTensor, + ) -> Result { + match tensor { + BackendTensor::Int(t) => Ok(t), + other => Err(PrimitiveConversionError::KindMismatch(format!( + "Expected Int primitive, got variant: {}", + other.name() + ))), + } + } + + fn from_primitive(primitive: Self::Primitive) -> BackendTensor { + BackendTensor::Int(primitive) + } +} + +impl BackendPrimitive for Bool { + type Primitive = B::BoolTensorPrimitive; + + fn try_into_primitive( + tensor: BackendTensor, + ) -> Result { + match tensor { + BackendTensor::Bool(t) => Ok(t), + other => Err(PrimitiveConversionError::KindMismatch(format!( + "Expected Bool primitive, got variant: {}", + other.name() + ))), + } + } + + fn from_primitive(primitive: Self::Primitive) -> BackendTensor { + BackendTensor::Bool(primitive) + } +} + +#[cfg(test)] +mod tests { + use crate::{Bool, Int}; + + use super::*; + + type TestBackend = burn_dispatch::backends::Flex; + type TestAutodiffBackend = burn_dispatch::backends::Autodiff; + + // -- into_bridge / from_bridge roundtrip -- + + #[test] + fn float_tensor_bridge_roundtrip() { + let tensor = Tensor::<2>::zeros([2, 3], &Default::default()); + let shape = tensor.shape(); + let bridge = tensor.into_bridge(); + assert!(bridge.is_float()); + let tensor = Tensor::<2>::from_bridge(bridge); + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn int_tensor_bridge_roundtrip() { + let tensor = Tensor::<2, Int>::zeros([2, 3], &Default::default()); + let shape = tensor.shape(); + let bridge = tensor.into_bridge(); + assert!(bridge.is_int()); + let tensor = Tensor::<2, Int>::from_bridge(bridge); + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn bool_tensor_bridge_roundtrip() { + let tensor = Tensor::<2, Bool>::empty([2, 3], &Default::default()); + let shape = tensor.shape(); + let bridge = tensor.into_bridge(); + assert!(bridge.is_bool()); + let tensor = Tensor::<2, Bool>::from_bridge(bridge); + assert_eq!(tensor.shape(), shape); + } + + // -- from_bridge panics on kind mismatch -- + + #[test] + #[should_panic(expected = "Expected kind Float")] + fn from_bridge_int_as_float_panics() { + let bridge = Tensor::<2, Int>::zeros([2, 3], &Default::default()).into_bridge(); + let _tensor = Tensor::<2>::from_bridge(bridge); + } + + #[test] + #[should_panic(expected = "Expected kind Float")] + fn from_bridge_bool_as_float_panics() { + let bridge = Tensor::<2, Bool>::empty([2, 3], &Default::default()).into_bridge(); + let _tensor = Tensor::<2>::from_bridge(bridge); + } + + #[test] + #[should_panic(expected = "Expected kind Int")] + fn from_bridge_float_as_int_panics() { + let bridge = Tensor::<2>::zeros([2, 3], &Default::default()).into_bridge(); + let _tensor = Tensor::<2, Int>::from_bridge(bridge); + } + + #[test] + #[should_panic(expected = "Expected kind Bool")] + fn from_bridge_int_as_bool_panics() { + let bridge = Tensor::<2, Int>::zeros([2, 3], &Default::default()).into_bridge(); + let _tensor = Tensor::<2, Bool>::from_bridge(bridge); + } + + #[test] + #[should_panic(expected = "Expected kind Float")] + fn from_bridge_qfloat_variant_with_int_dtype_panics() { + // Construct a QFloat bridge tensor wrapping a non-qfloat dispatch tensor: + // kind tag says Float but dtype says otherwise. + let inner = Tensor::<2, Int>::zeros([2, 3], &Default::default()).into_dispatch(); + let bridge = BridgeTensor::qfloat(inner); + let _tensor = Tensor::<2>::from_bridge(bridge); + } + + // -- into_dispatch / from_dispatch roundtrip -- + + #[test] + fn float_primitive_roundtrip() { + let tensor = Tensor::<2>::zeros([2, 3], &Default::default()); + let shape = tensor.shape(); + let primitive = tensor.into_dispatch(); + let tensor = Tensor::<2>::from_dispatch(primitive); + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn int_primitive_roundtrip() { + let tensor = Tensor::<2, Int>::zeros([2, 3], &Default::default()); + let shape = tensor.shape(); + let primitive = tensor.into_dispatch(); + let tensor = Tensor::<2, Int>::from_dispatch(primitive); + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn bool_primitive_roundtrip() { + let tensor = Tensor::<2, Bool>::empty([2, 3], &Default::default()); + let shape = tensor.shape(); + let primitive = tensor.into_dispatch(); + let tensor = Tensor::<2, Bool>::from_dispatch(primitive); + assert_eq!(tensor.shape(), shape); + } + + // -- from_dispatch panics on dtype/kind mismatch -- + + #[test] + #[should_panic(expected = "Expected kind Float")] + fn from_dispatch_int_dtype_as_float_panics() { + let primitive = Tensor::<2, Int>::zeros([2, 3], &Default::default()).into_dispatch(); + let _tensor = Tensor::<2>::from_dispatch(primitive); + } + + #[test] + #[should_panic(expected = "Expected kind Int")] + fn from_dispatch_float_dtype_as_int_panics() { + let primitive = Tensor::<2>::zeros([2, 3], &Default::default()).into_dispatch(); + let _tensor = Tensor::<2, Int>::from_dispatch(primitive); + } + + #[test] + #[should_panic(expected = "Expected kind Bool")] + fn from_dispatch_float_dtype_as_bool_panics() { + let primitive = Tensor::<2>::zeros([2, 3], &Default::default()).into_dispatch(); + let _tensor = Tensor::<2, Bool>::from_dispatch(primitive); + } + + // -- try_into_primitive / from_primitive roundtrip -- + + #[test] + fn float_backend_primitive_roundtrip() { + let device = Default::default(); + let tensor = Tensor::<2>::zeros([2, 3], &device); + let shape = tensor.shape(); + + let primitive = tensor + .try_into_primitive::() + .expect("Failed to extract Float primitive"); + let tensor = Tensor::<2>::from_primitive::(primitive); + + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn int_backend_primitive_roundtrip() { + let device = Default::default(); + let tensor = Tensor::<2, Int>::zeros([2, 3], &device); + let shape = tensor.shape(); + + let primitive = tensor + .try_into_primitive::() + .expect("Failed to extract Int primitive"); + let tensor = Tensor::<2, Int>::from_primitive::(primitive); + + assert_eq!(tensor.shape(), shape); + } + + #[test] + fn bool_backend_primitive_roundtrip() { + let device = Default::default(); + let tensor = Tensor::<2, Bool>::empty([2, 3], &device); + let shape = tensor.shape(); + + let primitive = tensor + .try_into_primitive::() + .expect("Failed to extract Bool primitive"); + let tensor = Tensor::<2, Bool>::from_primitive::(primitive); + + assert_eq!(tensor.shape(), shape); + } + + #[cfg(feature = "autodiff")] + #[test] + fn autodiff_backend_primitive_roundtrip() { + let device = crate::Device::default().autodiff(); + let tensor = Tensor::<2, Float>::empty([2, 3], &device).require_grad(); + let require_grad = tensor.is_require_grad(); + + let primitive = tensor + .try_into_primitive::() + .expect("Failed to extract Autodiff primitive"); + let tensor = Tensor::<2, Float>::from_primitive::(primitive); + + assert_eq!(tensor.is_require_grad(), require_grad); + } + + // -- try_into_primitive panics on backend or kind mismatch -- + + #[cfg(feature = "autodiff")] + #[test] + fn try_into_primitive_backend_mismatch() { + let device = Default::default(); + let tensor = Tensor::<2>::zeros([2, 3], &device); + + let err = tensor + .try_into_primitive::() + .unwrap_err(); + + assert!(matches!(err, PrimitiveConversionError::BackendMismatch(_))); + assert!(format!("{err:?}").contains("Expected Autodiff tensor, got backend:")); + } + + #[test] + #[should_panic(expected = "Expected kind Float")] + fn try_into_primitive_kind_mismatch() { + let device = Default::default(); + let tensor = Tensor::<2, Int>::zeros([2, 3], &device); + + let primitive = tensor + .try_into_primitive::() + .expect("Failed to extract Int primitive"); + let _tensor = Tensor::<2, Float>::from_primitive::(primitive); + } + + // -- BackendPrimitive trait error handling on mismatched variants -- + + #[test] + fn try_into_primitive_float_with_int_variant_returns_err() { + let device = Default::default(); + + // Valid Int primitive + let int_tensor = Tensor::<2, Int>::zeros([2, 3], &device); + let int_primitive = int_tensor.try_into_primitive::().unwrap(); + + // Wrap it artificially into the Backend layer + let backend_tensor = BackendTensor::::Int(int_primitive); + + // Attempt to extract it as a Float primitive using the BackendPrimitive trait + let err = >::try_into_primitive(backend_tensor) + .unwrap_err(); + + assert_eq!( + err, + PrimitiveConversionError::KindMismatch( + "Expected Float primitive, got variant: Int".into() + ) + ); + } +} diff --git a/crates/burn-tensor/src/tensor/api/float.rs b/crates/burn-tensor/src/tensor/api/float.rs new file mode 100644 index 0000000..a6a7c41 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/float.rs @@ -0,0 +1,1276 @@ +use crate::AsIndex; +use crate::Cast; +use crate::Device; +use crate::Tensor; +use crate::cast::ToElement; +use crate::check; +use crate::check::TensorCheck; +use crate::kind::FloatMath; +use crate::ops::{BridgeKind, BridgeTensor}; +use crate::quantization::{QuantScheme, QuantizationParameters}; +use crate::tensor::stats; +use crate::tensor::{Distribution, TensorData}; +use crate::{Bool, Float, Int, TensorPrimitive}; +#[cfg(feature = "std")] +use burn_backend::AutodiffBackend; +use burn_backend::ElementConversion; +use burn_backend::Scalar; +use burn_backend::TensorMetadata; +#[cfg(feature = "std")] +use burn_backend::distributed::DistributedParamId; +use burn_backend::ops::ActivationOps; +use burn_backend::ops::FloatTensorOps; +use burn_backend::ops::GridSampleOptions; +use burn_backend::ops::QTensorOps; +use burn_backend::quantization::QuantizationParametersPrimitive; +use burn_dispatch::Dispatch; +use core::f32; + +/// Default RTOL value for `is_close` and `all_close`. +pub const DEFAULT_RTOL: f64 = 1e-5; + +/// Default ATOL value for `is_close` and `all_close`. +pub const DEFAULT_ATOL: f64 = 1e-8; + +impl Tensor { + /// Applies the [error function](https://en.wikipedia.org/wiki/Error_function) element wise. + /// + #[cfg_attr( + doc, + doc = r#" +$y_i = \text{erf}\(x_i\)$ + +The error function is defined as: + +$$\text{erf}\(x\) = \frac{2}{\sqrt{\pi}} \int_0^x e^{-t^2} dt$$ +"# + )] + #[cfg_attr(not(doc), doc = "`y_i = erf(x_i)`")] + pub fn erf(self) -> Self { + Self::new(erf_impl(self.primitive)) + } + + /// Applies [hypotenuse operation](https://en.wikipedia.org/wiki/Hypotenuse) element wise. + /// + #[cfg_attr(doc, doc = r#"$y_i = \sqrt{x_i^2 + y_i^2}$"#)] + #[cfg_attr(not(doc), doc = "`y_i = sqrt(x_i^2 + y_i^2)`")] + pub fn hypot(self, other: Self) -> Self { + Self::new(hypot_impl(self.primitive, other.primitive)) + } + + /// Applies [reciprocal operation](https://en.wikipedia.org/wiki/Multiplicative_inverse) + /// (or multiplicative inverse) element wise. + /// + #[cfg_attr(doc, doc = r#"$y_i = \frac{1}{x_i}$"#)] + #[cfg_attr(not(doc), doc = "`y_i = 1/x_i`")] + pub fn recip(self) -> Self { + Self::new(recip_impl(self.primitive)) + } + + /// Converts each of the elements of the input tensor from angles in degrees to radians. + /// + /// # Example + /// ```ignore + /// let tensor_in_radians = tensor.deg2rad(); + /// ``` + pub fn deg2rad(self) -> Self { + self.mul_scalar(f32::consts::PI / 180.0) + } + + /// Converts each of the elements of the input tensor from angles in radians to degrees. + /// + /// # Example + /// ```ignore + /// let tensor_in_degrees = tensor.rad2deg(); + /// ``` + pub fn rad2deg(self) -> Self { + self.mul_scalar(180.0 / f32::consts::PI) + } + + /// Applies element wise round operation. + /// + /// This function implements the [round half to even](https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even) + /// strategy, with halfway cases rounded to the nearest even integer value. + pub fn round(self) -> Self { + Self::new(round_impl(self.primitive)) + } + + /// Applies element wise floor operation. + pub fn floor(self) -> Self { + Self::new(floor_impl(self.primitive)) + } + + /// Applies element wise ceil operation. + pub fn ceil(self) -> Self { + Self::new(ceil_impl(self.primitive)) + } + + /// Create a tensor from floats (f32) on a given device. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let _ = Tensor::<1>::from_floats([1.0, 2.0], &device); + /// let _ = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0]], &device); + /// } + /// ``` + pub fn from_floats>(floats: A, device: &Device) -> Self { + Self::from_data(floats.into().convert::(), device) + } + + /// Returns a new tensor with the same shape and device as the current tensor and the data + /// cast to Integer. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let float_tensor = Tensor::<1>::from_floats([1.0, 2.0], &device); + /// let int_tensor = float_tensor.int(); + /// } + /// ``` + pub fn int(self) -> Tensor { + let device = self.device(); + Tensor::new(int_impl(self.primitive, device)) + } + + /// Returns a new tensor with the same shape, dtype, and device as the current tensor filled random + /// values sampled from the given distribution. + pub fn random_like(&self, distribution: Distribution) -> Self { + Self::new(random_like_impl(&self.primitive, distribution)) + } + + /// Calculate the variance along the given dimension. + pub fn var(self, dim: usize) -> Self { + stats::var(self, dim) + } + + /// Calculate the variance along the given dimension without applying the Bessel’s correction. + pub fn var_bias(self, dim: usize) -> Self { + stats::var_bias(self, dim) + } + + /// Calculate the variance along the given dimension and also returns the mean. + pub fn var_mean(self, dim: usize) -> (Self, Self) { + let mean = self.clone().mean_dim(dim); + let var = stats::var_with_mean(self, mean.clone(), dim); + (var, mean) + } + + /// Calculate the variance along the given dimension without applying the Bessel’s correction and also returns the mean. + pub fn var_mean_bias(self, dim: usize) -> (Self, Self) { + let mean = self.clone().mean_dim(dim); + let var = stats::var_with_mean_bias(self, mean.clone(), dim); + (var, mean) + } + + /// Returns the median value along the specified dimension. + /// + /// The median is not unique for input tensors with an even number of elements + /// in the reduced dimension. In this case, the lower of the two medians is returned, + /// following PyTorch's behavior. + /// + /// # Note + /// + /// The current implementation performs a full sort along the specified dimension, + /// which has O(nlog(n)) complexity. Additionally, most backends currently fall back + /// to CPU for the sort operation, which may result in slower performance compared + /// to native GPU operations. + /// + /// # Arguments + /// + /// - `dim` - The dimension along which to compute the median. + /// + /// # Returns + /// + /// - A tensor containing the median values along the specified dimension. + /// + /// # Example 1 + /// + /// ```ignore + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data( + /// [[1.0, 5.0, 3.0, 2.0], [8.0, 4.0, 6.0, 7.0]], + /// &device, + /// ); + /// + /// // Median along dimension 0: + /// // sorted columns are [1.0, 8.0], [4.0, 5.0], [3.0, 6.0], [2.0, 7.0] + /// let median = tensor.median(0); + /// // Result: [[1.0, 4.0, 3.0, 2.0]] + /// + /// // Median along dimension 1: + /// // sorted rows are [1.0, 2.0, 3.0, 5.0] and [4.0, 6.0, 7.0, 8.0] + /// let median = tensor.median(1); + /// // Result: [[2.0], [6.0]] + /// ``` + /// + /// # Example 2 + /// + /// The median across all elements can be calculated as follows: + /// + /// ```ignore + /// // D is the number of dimensions of the tensor + /// let flattened_tensor: Tensor<1> = tensor.flatten(0, D - 1); + /// + /// // Calculate median for dim 0 since the tensor has become 1 dimensional + /// let median = flattened_tensor.median(0); + /// // Result: [4.0] + /// ``` + pub fn median(self, dim: usize) -> Self { + // TODO: Allow backend specialization. Optimally, implement a median kernel for cubecl + // instead of leveraging a full sort to get the median. + stats::median(self, dim) + } + + /// Returns the median value along the specified dimension and its index. + /// + /// The median is not unique for input tensors with an even number of elements + /// in the reduced dimension. In this case, the lower of the two medians is returned, + /// following PyTorch's behavior. + /// + /// # Note + /// + /// The current implementation performs a full sort along the specified dimension, + /// which has O(nlog(n)) complexity. Additionally, most backends currently fall back + /// to CPU for the sort operation, which may result in slower performance compared + /// to native GPU operations. + /// + /// # Arguments + /// + /// - `dim` - The dimension along which to compute the median. + /// + /// # Returns + /// + /// A tuple containing: + /// - A tensor with the median values. + /// - A tensor with the indices of the median values in the original tensor. + /// + /// # Example + /// + /// ```ignore + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data( + /// [[1.0, 5.0, 3.0, 2.0], [8.0, 4.0, 6.0, 7.0]], + /// &device, + /// ); + /// + /// // Median along dimension 1: + /// // sorted rows are [1.0, 2.0, 3.0, 5.0] and [4.0, 6.0, 7.0, 8.0] + /// let (values, indices) = tensor.median_with_indices(1); + /// // values: [[2.0], [6.0]], indices: [[3], [2]] (position in the original tensor) + /// ``` + pub fn median_with_indices(self, dim: usize) -> (Self, Tensor) { + // TODO: Allow backend specialization. Optimally, implement a median kernel for cubecl + // instead of leveraging a full sort to get the median. + stats::median_with_indices(self, dim) + } + + /// Converts a tensor to the specified data type. + /// + /// Supports both within-kind casting (e.g., `FloatDType::F64`) and cross-kind casting + /// (e.g., `IntDType::I64` to produce an int tensor). + /// + /// This is a no-op when casting to the current dtype within the same kind. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, FloatDType, IntDType}; + /// + /// fn example() { + /// let device = Default::default(); + /// let float_tensor = Tensor::<1>::from_floats([1.0, 2.5], &device); + /// + /// // Within-kind cast (float to float) + /// let f64_tensor = float_tensor.clone().cast(FloatDType::F64); + /// + /// // Cross-kind cast (float to int) + /// let int_tensor = float_tensor.cast(IntDType::I64); + /// } + /// ``` + #[must_use] + pub fn cast>(self, dtype: T) -> Tensor { + T::cast(self, dtype) + } + + /// Detach the current tensor from the autodiff graph. + /// + /// This function does nothing when autodiff is not enabled. + /// This can be used in batchers or elsewhere to ensure that previous operations are not + /// considered in the autodiff graph. + pub fn detach(self) -> Self { + Self::new(detach_impl(self.primitive)) + } + + /// Mark the tensor to keep gradients during the backward pass. + /// + /// This function does nothing when autodiff is not enabled. + pub fn require_grad(self) -> Self { + self.set_require_grad(true) + } + + /// Returns true if the tensor requires gradients during the backward pass. + pub fn is_require_grad(&self) -> bool { + is_require_grad_impl(&self.primitive) + } + + /// Mark the tensor as tracked or untracked depending on the require_grad argument. + /// When tracked, the gradients will be available after the backward pass. + /// + /// This function does nothing when autodiff is not enabled. + pub fn set_require_grad(self, require_grad: bool) -> Self { + Self::new(set_require_grad_impl(self.primitive, require_grad)) + } + + /// Applies the relu function to the tensor. + pub(crate) fn relu(self) -> Self { + Self::new(relu_impl(self.primitive)) + } + + /// Calculate covaraince matrix between different entries alongside a given dimension. + /// + /// # Arguments + /// + /// * `size` - The size of the square matrix. + /// * `correction_factor` - Is usually 1 for samples and 0 for population. + pub fn cov(self, dim: usize, correction_factor: usize) -> Tensor { + let n = self.dims()[dim]; + let centered = (self.clone() - self.mean_dim(dim)).swap_dims(dim, 0); + centered + .clone() + .transpose() + .matmul(centered) + .div_scalar(n as f32 - correction_factor as f32) + } + + /// Convert the tensor to a lower precision data type based on the quantization scheme. + /// + /// # Arguments + /// + /// * `scheme` - The quantization scheme. + /// * `qparams` - The pre-computed quantization parameters. + /// + /// # Returns + /// + /// The quantized tensor. + pub fn quantize(self, scheme: &QuantScheme, qparams: QuantizationParameters) -> Tensor { + Tensor::new(quantize_impl( + self.primitive, + scheme, + qparams.scales.primitive, + )) + } + + /// Dynamically convert the tensor to a lower precision data type based on the quantization scheme. + /// + /// # Arguments + /// + /// * `scheme` - The quantization scheme. + /// + /// # Returns + /// + /// The quantized tensor. + /// + /// # Notes + /// This uses [min-max calibration](crate::quantization::Calibration::MinMax). + pub fn quantize_dynamic(self, scheme: &QuantScheme) -> Tensor { + Tensor::new(quantize_dynamic_impl(self.primitive, scheme)) + } + + /// Convert the tensor back to a higher precision data type. + /// + /// If the tensor is not quantized, its value is simply returned. + /// + /// # Returns + /// + /// The dequantized tensor. + pub fn dequantize(self) -> Tensor { + Tensor::new(dequantize_impl(self.primitive)) + } + + /// Checks element wise if the tensor is close to another tensor. + /// + /// The tolerance is defined by the following equation: + /// + /// ```text + /// abs(a - b) <= (atol + rtol * abs(b)) + /// + /// where `a` is the first tensor, `b` is the second tensor, `rtol` is the relative tolerance, + /// and `atol` is the absolute tolerance. + /// ``` + /// + /// # Arguments + /// + /// * `other` - The tensor to compare with. + /// * `rtol` - Optional relative tolerance. Default is 1e-5; see `DEFAULT_RTOL`. + /// * `atol` - Optional absolute tolerance. Default is 1e-8; see `DEFAULT_ATOL`. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensors. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor1.is_close(tensor2, None, None); + /// println!("{tensor}"); + /// // [[true, true, true], [true, true, true]] + /// } + /// ``` + pub fn is_close(self, other: Self, rtol: Option, atol: Option) -> Tensor { + let rtol = rtol.unwrap_or(DEFAULT_RTOL); + let atol = atol.unwrap_or(DEFAULT_ATOL); + + // check finite difference is close + let is_close_finite_val = self + .clone() + .sub(other.clone()) + .abs() + .lower_equal(other.clone().abs().mul_scalar(rtol).add_scalar(atol)) + .bool_and(self.clone().is_finite()) + .bool_and(other.clone().is_finite()); + + // check if both are infinite and have same sign + let inf_same_sign = self + .clone() + .is_finite() + .bool_not() + .bool_and(other.clone().is_finite().bool_not()) + .bool_and(self.equal(other)); + + is_close_finite_val.bool_or(inf_same_sign) + } + + /// Checks if all elements are close to another tensor. + /// + /// The tolerance is defined by the following equation: + /// + /// ```text + /// + /// abs(a - b) <= (atol + rtol * abs(b)) + /// + /// where `a` is the first tensor, `b` is the second tensor, `rtol` is the relative tolerance, + /// and `atol` is the absolute tolerance. + /// + /// ``` + /// + /// # Arguments + /// + /// * `other` - The tensor to compare with. + /// * `rtol` - Optional relative tolerance. Default is 1e-5; see `DEFAULT_RTOL`. + /// * `atol` - Optional absolute tolerance. Default is 1e-8; see `DEFAULT_ATOL`. + /// + /// # Returns + /// + /// A boolean scalar. + /// + /// # Remarks + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let result = tensor1.all_close(tensor2, None, None); + /// println!("{}", result); + /// // true + /// } + /// ``` + pub fn all_close(self, other: Self, rtol: Option, atol: Option) -> bool { + self.is_close(other, rtol, atol) + .all() + .into_scalar::() + .to_bool() + } + + /// Returns a new tensor with boolean elements indicating whether each element of the input is NaN. + /// + /// # Returns + /// + /// A boolean tensor where `true` indicates NaN and `false` indicates a non-NaN value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, f64::NAN, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.is_nan(); + /// println!("{tensor}"); + /// // [[false, true, false], [false, false, false]] + /// } + /// ``` + pub fn is_nan(self) -> Tensor { + Tensor::new(is_nan_impl(self.primitive)) + } + + /// Checks if the tensor contains any NaN values. + /// + /// # Returns + /// + /// A boolean tensor with a single element indicating whether the tensor contains any NaN values. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [f64::NAN, 9.0, 6.0]], &device); + /// let tensor = tensor.contains_nan(); + /// println!("{tensor}"); + /// // [true] + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.contains_nan(); + /// println!("{tensor}"); + /// // [false] + /// } + /// ``` + pub fn contains_nan(self) -> Tensor<1, Bool> { + self.is_nan().any() + } + + /// Returns a new tensor with boolean elements indicating whether each element of the input is infinite (either +INF or -INF). + /// + /// # Returns + /// + /// A boolean tensor where `true` indicates that the value is infinite + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, f64::INFINITY, 3.0], [f64::NAN, 9.0, 6.0]], &device); + /// let tensor = tensor.is_finite(); + /// println!("{tensor}"); + /// // [[false, true, false], [false, false, false]] + /// } + /// ``` + pub fn is_inf(self) -> Tensor { + Tensor::new(is_inf_impl(self.primitive)) + } + + /// Returns a new tensor with boolean elements indicating whether each element of the input is finite + /// + /// # Returns + /// + /// A boolean tensor where `true` indicates that the value is finite and `false` indicates + /// either INF, -INF or NAN + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Bool, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, f64::INFINITY, 3.0], [f64::NAN, 9.0, 6.0]], &device); + /// let tensor = tensor.is_finite(); + /// println!("{tensor}"); + /// // [[true, false, true], [false, true, true]] + /// } + /// ``` + pub fn is_finite(self) -> Tensor { + self.clone() + .is_nan() + .bool_not() + .bool_and(self.is_inf().bool_not()) + } + + /// Samples tensor as a two-dimensional spatial grid of (possibly multi-channel) values, + /// using the given locations in [-1, 1]. + /// + /// # Arguments + /// + /// * `grid` - A tensor of locations, with shape (N, H_out, W_out, 2). Values are [-1, 1]. + /// A [x = -1, y = -1] means top-left, and [x = 1, y = 1] means bottom-right + /// * `options` - Grid sampling options (mode, padding_mode, align_corners) + /// + /// # Returns + /// + /// A tensor with shape (N, C, H_out, W_out) + /// + /// # Example + /// + /// ```ignore + /// use burn_tensor::ops::{GridSampleOptions, GridSamplePaddingMode, InterpolateMode}; + /// + /// // Default options (bilinear, zeros padding, align_corners=false) + /// let output = tensor.grid_sample_2d(grid, GridSampleOptions::default()); + /// + /// // Custom options + /// let options = GridSampleOptions::new(InterpolateMode::Bilinear) + /// .with_padding_mode(GridSamplePaddingMode::Border) + /// .with_align_corners(true); + /// let output = tensor.grid_sample_2d(grid, options); + /// ``` + pub fn grid_sample_2d( + self, + grid: Tensor, + options: impl Into, + ) -> Tensor { + Tensor::new(grid_sample_2d_impl( + self.primitive, + grid.primitive, + options.into(), + )) + } + + /// Computes the cross product of `self` and another tensor along a given dimension. + /// + /// Both `self` and `other` **must have size 3** along the specified `dim`, + /// because the cross product is only defined in three-dimensional space. + /// + /// # Arguments + /// + /// * `other` - The other tensor to take the cross product with. + /// * `dim` - The dimension along which to compute the cross product. + /// + /// # Returns + /// + /// A tensor containing the cross product of `self` and `other` along `dim`. + pub fn cross(self, other: Tensor, dim: Dim) -> Tensor { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::cross(&self, &other, dim)); + Tensor::new(cross_impl(self.primitive, other.primitive, dim)) + } + + /// Applies element wise power operation with a float Tensor + /// + /// # Arguments + /// + /// * `other` - The tensor to apply the power operation with. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.powf(tensor2); + /// println!("{tensor}"); + /// // [[1.0, 8.0, 81.0], [5.0, 81.0, 216.0]] + /// } + /// ``` + pub fn powf(self, other: Self) -> Self { + Tensor::new(powf_impl(self.primitive, other.primitive)) + } + + /// Applies element wise power operation with a float scalar + /// + /// # Arguments + /// + /// * `other` - The scalar to apply the power operation with. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.powf_scalar(2.0); + /// println!("{tensor}"); + /// // [[1.0, 4.0, 9.0], [25.0, 81.0, 36.0]] + /// } + /// ``` + pub fn powf_scalar(self, other: E) -> Self { + let rhs = Scalar::new(other, &self.dtype()); + Tensor::new(powf_scalar_impl(self.primitive, rhs)) + } +} + +impl Tensor { + /// Draws samples from a categorical distribution defined by the last dimension + /// of the input tensor. + /// + /// The last dimension is treated as a (possibly unnormalized) set of weights + /// defining a categorical distribution over categories. All leading dimensions + /// are treated as batch dimensions. The method returns integer indices of the + /// sampled categories. + /// + /// # Arguments + /// + /// * `num_samples` - Number of samples to draw per distribution. Must be >= 1. + /// + /// # Panics + /// + /// Panics if `num_samples` is 0. + /// + /// # Note + /// + /// Distributions with all-zero weights produce undefined (NaN-based) sampling + /// results. Callers should ensure each distribution has at least one positive + /// weight. + /// + /// # Returns + /// + /// An integer tensor with the same shape as the input, except the last dimension + /// is replaced by `num_samples`, containing sampled category indices in + /// `[0, num_categories)`. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let probs = Tensor::<2>::from_floats( + /// [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + /// &device, + /// ); + /// let samples = probs.categorical(4); + /// // First row always samples index 1, second row always samples index 2 + /// println!("{samples}"); + /// } + /// ``` + pub fn categorical(self, num_samples: usize) -> Tensor { + assert!(num_samples > 0, "categorical: num_samples must be >= 1"); + + let shape = self.shape(); + let num_categories = shape[D - 1]; + let batch_size = (shape.num_elements() / num_categories).max(1); + let device = self.device(); + let dtype = self.dtype(); + + // Flatten leading dimensions into a single batch dimension: [batch, categories] + let flat: Tensor<2> = self.reshape([batch_size, num_categories]); + + // Normalize weights to probabilities + let sum = flat.clone().sum_dim(1); // [batch, 1] + let probs = flat / sum; + + // Cumulative sum along categories dimension + let cumsum = probs.cumsum(1); // [batch, categories] + + // Uniform random values for each sample + let uniform = Tensor::<2>::random( + [batch_size, num_samples], + Distribution::Uniform(0.0, 1.0), + (&device, dtype), + ); // [batch, num_samples] + + // Expand dimensions for broadcasting: + // cumsum: [batch, categories, 1] + // uniform: [batch, 1, num_samples] + let cumsum_3d: Tensor<3> = cumsum.unsqueeze_dim(2); + let uniform_3d: Tensor<3> = uniform.unsqueeze_dim(1); + + // Count categories where cumsum < uniform (inverse CDF) + let mask: Tensor<3, Bool> = cumsum_3d.lower(uniform_3d); + let indices: Tensor<2, Int> = mask.int().sum_dim(1).squeeze_dim::<2>(1); + + // Clamp to valid range to guard against floating-point imprecision in cumsum + let indices = indices.clamp(0, num_categories as i64 - 1); + + // Reshape back to [...leading_dims, num_samples] + let mut out_shape = shape; + out_shape[D - 1] = num_samples; + indices.reshape(out_shape) + } +} + +#[cfg(feature = "std")] +impl Tensor { + /// Returns true if the tensor is marked as distributed. + pub fn is_distributed(&self) -> bool { + is_distributed_impl(&self.primitive) + } + + /// Mark the tensor as distributed. + /// + /// This function does nothing when autodiff or distributed is not enabled. + pub fn set_distributed(self, param_id: DistributedParamId) -> Self { + Self::new(set_distributed_impl(self.primitive, param_id)) + } +} + +impl Tensor +where + K: FloatMath, +{ + /// Applies element wise square operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = x_i * x_i$"#)] + #[cfg_attr(not(doc), doc = "`y_i = x_i * x_i`")] + pub fn square(self) -> Self { + Self::new(K::square(self.primitive)) + } + + /// Applies element wise exponential operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = e^{x_i}$"#)] + #[cfg_attr(not(doc), doc = "`y = e^x`")] + pub fn exp(self) -> Self { + Self::new(K::exp(self.primitive)) + } + + /// Applies element wise natural logarithm of one plus the input tensor. + /// + #[cfg_attr(doc, doc = r#"$y_i = \log_e\(x_i + 1\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = log1p(x_i)`")] + pub fn log1p(self) -> Self { + Self::new(K::log1p(self.primitive)) + } + + /// Applies element wise natural log operation *ln*. + /// + #[cfg_attr(doc, doc = r#"$y_i = \log_e\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = log(x_i)`")] + pub fn log(self) -> Self { + Self::new(K::log(self.primitive)) + } + + /// Applies element wise square root operation. + /// + pub fn sqrt(self) -> Self { + Tensor::new(K::sqrt(self.primitive)) + } + /// Applies element wise cosine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \cos\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = cos(x_i)`")] + pub fn cos(self) -> Self { + Tensor::new(K::cos(self.primitive)) + } + + /// Applies element wise sine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \sin\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = sin(x_i)`")] + pub fn sin(self) -> Self { + Tensor::new(K::sin(self.primitive)) + } + + /// Applies element wise tangent operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \tan\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = tan(x_i)`")] + pub fn tan(self) -> Self { + Tensor::new(K::tan(self.primitive)) + } + + /// Applies element wise hyperbolic cosine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \cosh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = cosh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 2.0], &device); + /// println!("{}", tensor.cosh()); // [1.0, 1.5430, 3.7621] + /// } + /// ``` + pub fn cosh(self) -> Self { + Tensor::new(K::cosh(self.primitive)) + } + + /// Applies element wise hyperbolic sine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \sinh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = sinh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 2.0], &device); + /// println!("{}", tensor.sinh()); // [0.0, -1.1752, 3.6269] + /// } + /// ``` + pub fn sinh(self) -> Self { + Tensor::new(K::sinh(self.primitive)) + } + + /// Applies element wise hyperbolic tangent operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \tanh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = tanh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 2.0], &device); + /// println!("{}", tensor.tanh()); // [0.0, -0.7616, 0.9640] + /// } + /// ``` + pub fn tanh(self) -> Self { + Tensor::new(K::tanh(self.primitive)) + } + + /// Applies element wise inverse cosine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \acos\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = acos(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 1.0], &device); + /// println!("{}", tensor.acos()); // [1.5708, 3.1416, 0.0] + /// } + /// ``` + pub fn acos(self) -> Self { + Tensor::new(K::acos(self.primitive)) + } + + /// Applies element wise inverse hyperbolic cosine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \acosh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = acosh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([1.0, 2.0, 3.0], &device); + /// println!("{}", tensor.acosh()); // [0.0000, 1.3170, 1.7627] + /// } + /// ``` + pub fn acosh(self) -> Self { + Tensor::new(K::acosh(self.primitive)) + } + + /// Applies element wise inverse sine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \asin\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = asin(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 1.0], &device); + /// println!("{}", tensor.asin()); // [ 0.0000, -1.5708, 1.5708] + /// } + /// ``` + pub fn asin(self) -> Self { + Tensor::new(K::asin(self.primitive)) + } + + /// Applies element wise inverse hyperbolic sine operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \asinh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = asinh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 1.0], &device); + /// println!("{}", tensor.asinh()); // [ 0.0000, -0.8814, 0.8814] + /// } + /// ``` + pub fn asinh(self) -> Self { + Tensor::new(K::asinh(self.primitive)) + } + + /// Applies element wise inverse tangent operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \atan\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = atan(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -1.0, 2.0], &device); + /// println!("{}", tensor.atan()); // [ 0.0, -0.7854, 1.1071] + /// } + /// ``` + pub fn atan(self) -> Self { + Tensor::new(K::atan(self.primitive)) + } + + /// Applies element wise inverse hyperbolic tangent operation. + /// + #[cfg_attr(doc, doc = r#"$y_i = \atanh\(x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`y_i = atanh(x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let tensor = Tensor::<1>::from_data([0.0, -0.5, 0.5], &device); + /// println!("{}", tensor.atanh()); // [ 0.0, -0.5493, 0.5493] + /// } + /// ``` + pub fn atanh(self) -> Self { + Tensor::new(K::atanh(self.primitive)) + } + + /// Applies element wise inverse tangent operation using the signs of arguments to determine the correct quadrant. + /// + #[cfg_attr(doc, doc = r#"$z_i = \atan2\(y_i, x_i\)$"#)] + #[cfg_attr(not(doc), doc = "`z_i = atan2(y_i, x_i)`")] + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// let lhs = Tensor::<1>::from_data([-2.0, 2.0, -2.0], &device); + /// let rhs = Tensor::<1>::from_data([1.0, -1.0, -1.0], &device); + /// println!("{}", lhs.atan2(rhs)); // [-1.1071, 2.0344, -2.0344] + /// } + /// ``` + pub fn atan2(self, other: Self) -> Self { + Tensor::new(K::atan2(self.primitive, other.primitive)) + } +} + +// ========================================================================= +// Non-generic implementation helpers (outlined from the public generic API). +// See the crate-level docs for the rationale behind this pattern. +// ========================================================================= + +fn erf_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_erf(p.into_float())) +} + +fn recip_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_recip(p.into_float())) +} + +fn hypot_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_hypot(lhs.into_float(), rhs.into_float())) +} + +fn round_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_round(p.into_float())) +} + +fn floor_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_floor(p.into_float())) +} + +fn ceil_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_ceil(p.into_float())) +} + +fn int_impl(p: BridgeTensor, device: Device) -> BridgeTensor { + let out_dtype = device.settings().int_dtype; + BridgeTensor::int(Dispatch::float_into_int(p.into_float(), out_dtype)) +} + +fn random_like_impl(p: &BridgeTensor, distribution: Distribution) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_random( + p.shape(), + distribution, + &p.as_dispatch().device(), + p.dtype().into(), + )) +} + +fn detach_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_detach(p.into_float())) +} + +fn is_require_grad_impl(p: &BridgeTensor) -> bool { + let (kind, tensor) = p.as_parts(); + match kind { + BridgeKind::Float => Dispatch::float_is_require_grad(tensor), + BridgeKind::QFloat => Dispatch::q_is_require_grad(tensor), + _ => panic!("Should be Float primitive kind"), + } +} + +fn set_require_grad_impl(p: BridgeTensor, require_grad: bool) -> BridgeTensor { + let (kind, tensor) = p.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::float_set_require_grad(tensor, require_grad)) + } + BridgeKind::QFloat => { + BridgeTensor::qfloat(Dispatch::q_set_require_grad(tensor, require_grad)) + } + _ => panic!("Should be Float primitive kind"), + } +} + +fn relu_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::relu(p.into_float())) +} + +fn quantize_impl(p: BridgeTensor, scheme: &QuantScheme, scales: BridgeTensor) -> BridgeTensor { + BridgeTensor::qfloat(Dispatch::quantize( + p.into_float(), + scheme, + QuantizationParametersPrimitive { + scales: scales.into_float(), + }, + )) +} + +fn quantize_dynamic_impl(p: BridgeTensor, scheme: &QuantScheme) -> BridgeTensor { + BridgeTensor::qfloat(Dispatch::quantize_dynamic(p.into_float(), scheme)) +} + +fn dequantize_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(p.into_float()) +} + +fn is_nan_impl(p: BridgeTensor) -> BridgeTensor { + let bool_dtype = p.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::float_is_nan(p.into_float(), bool_dtype)) +} + +fn is_inf_impl(p: BridgeTensor) -> BridgeTensor { + let bool_dtype = p.device_settings().bool_dtype; + BridgeTensor::bool(Dispatch::float_is_inf(p.into_float(), bool_dtype)) +} + +fn grid_sample_2d_impl( + p: BridgeTensor, + grid: BridgeTensor, + options: GridSampleOptions, +) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_grid_sample_2d( + p.into_float(), + grid.into_float(), + options, + )) +} + +fn cross_impl(p: BridgeTensor, other: BridgeTensor, dim: usize) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_cross( + p.into_float(), + other.into_float(), + dim, + )) +} + +fn powf_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + let (lkind, lhs) = lhs.into_parts(); + let (rkind, rhs) = rhs.into_parts(); + match (lkind, rkind) { + (BridgeKind::Float, BridgeKind::Float) => { + BridgeTensor::float(Dispatch::float_powf(lhs, rhs)) + } + (BridgeKind::QFloat, BridgeKind::QFloat) => match Dispatch::q_powf(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + (BridgeKind::QFloat, BridgeKind::Float) => { + let dtype = rhs.dtype(); + BridgeTensor::float(Dispatch::float_powf( + Dispatch::dequantize(lhs, dtype.into()), + rhs, + )) + } + (BridgeKind::Float, BridgeKind::QFloat) => { + let dtype = lhs.dtype(); + BridgeTensor::float(Dispatch::float_powf( + lhs, + Dispatch::dequantize(rhs, dtype.into()), + )) + } + _ => panic!("Should be Float primitive kind"), + } +} + +fn powf_scalar_impl(p: BridgeTensor, rhs: Scalar) -> BridgeTensor { + let (kind, lhs) = p.into_parts(); + match kind { + BridgeKind::Float => BridgeTensor::float(Dispatch::float_powf_scalar(lhs, rhs)), + BridgeKind::QFloat => match Dispatch::q_powf_scalar(lhs, rhs) { + TensorPrimitive::Float(out) => BridgeTensor::float(out), + TensorPrimitive::QFloat(out) => BridgeTensor::qfloat(out), + }, + _ => panic!("Should be Float primitive kind"), + } +} + +#[cfg(feature = "std")] +fn is_distributed_impl(p: &BridgeTensor) -> bool { + let (kind, tensor) = p.as_parts(); + match kind { + BridgeKind::Float => Dispatch::is_distributed(tensor), + BridgeKind::QFloat => unimplemented!(), + _ => panic!("Should be Float primitive kind"), + } +} + +#[cfg(feature = "std")] +fn set_distributed_impl(p: BridgeTensor, param_id: DistributedParamId) -> BridgeTensor { + let (kind, tensor) = p.into_parts(); + match kind { + BridgeKind::Float => { + BridgeTensor::float(Dispatch::set_distributed_params(tensor, param_id)) + } + BridgeKind::QFloat => unimplemented!(), + _ => panic!("Should be Float primitive kind"), + } +} diff --git a/crates/burn-tensor/src/tensor/api/fmod.rs b/crates/burn-tensor/src/tensor/api/fmod.rs new file mode 100644 index 0000000..5efb44e --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/fmod.rs @@ -0,0 +1,106 @@ +use crate::{Float, Tensor}; + +impl Tensor { + /// Computes the floating-point remainder of dividing `self` by `other`. + /// + /// The result has the same sign as `self` and magnitude less than `other`. + /// This is equivalent to the IEEE 754 remainder operation. + /// + /// # Special Cases (IEEE 754 compliant) + /// + /// - If `self` is ±∞ and `other` is not NaN, NaN is returned + /// - If `other` is ±0 and `self` is not NaN, NaN is returned + /// - If `other` is ±∞ and `self` is finite, `self` is returned + /// - If either argument is NaN, NaN is returned + /// + /// # Arguments + /// + /// * `other` - The divisor tensor. Must have the same shape as `self`. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the floating-point remainder. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let dividend = Tensor::<1>::from_data([5.3, -5.3, 5.3, -5.3], &device); + /// let divisor = Tensor::<1>::from_data([2.0, 2.0, -2.0, -2.0], &device); + /// let result = dividend.fmod(divisor); + /// + /// // Result: [1.3, -1.3, 1.3, -1.3] + /// } + /// ``` + pub fn fmod(self, other: Self) -> Self { + // Normal case: fmod(x, y) = x - y * trunc(x / y) + let quotient = self.clone().div(other.clone()); + let truncated = quotient.trunc(); + let product = other.clone() * truncated.clone(); + + // When divisor is infinity and dividend is finite: + // - quotient is 0, truncated is 0 + // - but 0 * infinity = NaN, which is wrong + // We need to handle this case by replacing NaN with 0 when appropriate + + // Check if the product is NaN due to 0 * inf + let is_zero_times_inf = truncated.equal_elem(0.0).bool_and(other.is_inf()); + let zero_tensor = self.clone().mul_scalar(0.0); + let corrected_product = product.mask_where(is_zero_times_inf, zero_tensor); + + self - corrected_product + } + + /// Computes the floating-point remainder of dividing `self` by a scalar. + /// + /// The result has the same sign as `self` and magnitude less than the scalar. + /// + /// # Special Cases (IEEE 754 compliant) + /// + /// - If `self` is ±∞ and scalar is not NaN, NaN is returned + /// - If scalar is ±0 and `self` is not NaN, NaN is returned + /// - If scalar is ±∞ and `self` is finite, `self` is returned + /// - If either argument is NaN, NaN is returned + /// + /// # Arguments + /// + /// * `scalar` - The scalar divisor. + /// + /// # Returns + /// + /// A tensor with the same shape where each element is the floating-point remainder. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<1>::from_data([5.3, -5.3, 7.5, -7.5], &device); + /// let result = tensor.fmod_scalar(2.0); + /// + /// // Result: [1.3, -1.3, 1.5, -1.5] + /// } + /// ``` + pub fn fmod_scalar(self, scalar: f32) -> Self { + // Normal case: fmod(x, y) = x - y * trunc(x / y) + let quotient = self.clone().div_scalar(scalar); + let truncated = quotient.trunc(); + let product = truncated.mul_scalar(scalar); + + // Handle the special case where scalar is infinity + // When scalar is ±∞ and self is finite, quotient is 0, truncated is 0 + // but 0 * infinity = NaN, which is wrong - it should be 0 + if scalar.is_infinite() { + // For finite values, fmod(x, ±∞) = x + // For infinite values, fmod(±∞, ±∞) = NaN (which is handled by arithmetic) + return self; + } + + self - product + } +} diff --git a/crates/burn-tensor/src/tensor/api/graph.rs b/crates/burn-tensor/src/tensor/api/graph.rs new file mode 100644 index 0000000..7670d1f --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/graph.rs @@ -0,0 +1,153 @@ +use crate::Device; +use burn_backend::{Backend, BackendGraph}; +use burn_dispatch::Dispatch; + +/// A captured computation. +/// +/// [`capture`] records a closure once; [`replay`](Graph::replay) then re-runs it +/// as a single dispatch on backends with hardware graph support (CUDA/HIP — +/// collapsing hundreds of kernel launches into one), or simply re-executes the +/// closure everywhere else. The API is identical either way; only the speed +/// differs. +/// +/// The recorded graph replays against the exact device buffers used during +/// capture, so the closure must read its inputs from, and write its outputs to, +/// **stable** buffers held across the loop. Refresh those inputs in place with +/// [`Tensor::inplace`](crate::Tensor::inplace)-style writes before each replay. +/// +/// # Safety +/// +/// The graph has no explicit input/state/output signature: whatever buffers the +/// closure touched during capture are what every replay reads and writes, with +/// nothing tracking them afterwards. [`replay`](Graph::replay) is therefore +/// `unsafe` — see its safety contract. Safe, structured APIs can be layered on +/// top of this mechanism for specific workloads (e.g. a decode step with pinned +/// KV-cache and token buffers). +pub struct Graph { + device: Device, + output: T, + closure: F, + /// The captured hardware graph, or `None` to re-run the closure (a backend + /// without graph support, or a capture that failed). + hardware: Option>, +} + +/// Capture `closure` for repeated replay (see [`Graph`]). +/// +/// Runs `closure` twice: once to warm up (trigger autotuning and allocate every +/// buffer, so the capture itself needs no fresh device allocation — which is +/// illegal mid-capture), then once under capture. On a backend without graph +/// support the warmup/capture collapses to a single run and replay just re-runs +/// the closure. +pub fn capture(device: &Device, mut closure: F) -> Graph +where + F: FnMut() -> T, +{ + let dispatch = device.as_dispatch(); + // Prepare the allocator for capture, then warm up: this triggers all + // autotuning and allocates every buffer the capture will reuse. + // + // Several warmup iterations, not one: with fusion the first run builds and + // autotunes the fused optimization (a different execution path, allocating + // different scratch/metadata buffers than steady state). Only from the + // second run on does the closure take the cached fused path whose buffers + // the capture must reuse. Warming up a few times lets those steady-state + // allocations populate the persistent pool before capture opens — otherwise + // the capture run allocates them fresh, which faults mid-capture. + const WARMUP_ITERS: usize = 3; + let _ = Dispatch::graph_prepare(dispatch); + for _ in 0..WARMUP_ITERS { + // Hold the output alive across the sync. A lazy backend (fusion) elides + // a computation whose result is dropped before it drains — so dropping + // the warmup output would skip the very kernels (and allocations) the + // capture must reuse, and the capture run would then be the first to + // allocate them, faulting mid-capture. Keeping `out` until after `sync` + // forces the closure to actually execute and populate the pool. + let out = closure(); + let _ = Dispatch::sync(dispatch); + drop(out); + } + + let (hardware, output) = match Dispatch::graph_start_capture(dispatch) { + // Record the closure's launches into a graph. + Ok(()) => { + let output = closure(); + // A failed stop still resets the stream out of capture mode (the + // backend guarantees this even on error), so falling back to `None` — + // re-running the closure on replay — stays correct; we lose the + // graph, not stream health. + (Dispatch::graph_stop_capture(dispatch).ok(), output) + } + // No hardware graph support: fall back to re-running the closure. + Err(_) => (None, closure()), + }; + + Graph { + device: device.clone(), + output, + closure, + hardware, + } +} + +impl Graph +where + F: FnMut() -> T, +{ + /// Re-run the captured computation and return its output. + /// + /// On a captured graph this is one dispatch replaying the recorded launches + /// against their original buffers — so write fresh inputs into those + /// buffers first. On the fallback path it re-executes the closure. The + /// returned reference is the output produced during capture (whose buffer + /// the replay just overwrote), or the fresh output on the fallback path. + /// + /// # Safety + /// + /// On the hardware path this dispatches the recorded kernels against the raw + /// device buffers the closure touched during capture, with nothing checking + /// they are still valid. The caller must guarantee, until the replay's work + /// completes (e.g. it is followed by a read of the output or a device sync): + /// + /// - **Liveness** — every tensor the captured closure read or wrote still + /// exists. Dropping one frees its buffer for reuse by other allocations, + /// and a later replay would read or overwrite whatever now lives there. + /// Tensors owned by the closure (or by `self`, like the output) are kept + /// alive automatically; tensors the closure only borrowed must outlive + /// the replays. + /// - **No concurrent use** — no other stream or thread reads or writes a + /// tensor shared with the graph while the replay executes; the replay is + /// only ordered against work on its own capture stream. + /// - **Same-stream refreshes** — input refreshes and output reads are + /// issued on the stream the graph was captured on (the same device + /// thread/client), so they order correctly against the replay rather + /// than racing it with stale or torn data. + /// + /// On the fallback path (no hardware graph) this simply re-runs the closure + /// and is trivially safe. + pub unsafe fn replay(&mut self) -> &T { + match &self.hardware { + Some(graph) => { + // Safety: forwarded verbatim from this method's own contract. + unsafe { Dispatch::graph_replay(self.device.as_dispatch(), graph) } + .expect("graph replay should succeed"); + } + None => { + self.output = (self.closure)(); + } + } + &self.output + } + + /// The output tensor(s) the graph writes to — stable across replays on the + /// hardware path (the same buffer is overwritten each time). + pub fn output(&self) -> &T { + &self.output + } + + /// Whether this graph replays as a hardware dispatch (`true`) or by + /// re-running the closure (`false`). + pub fn is_hardware(&self) -> bool { + self.hardware.is_some() + } +} diff --git a/crates/burn-tensor/src/tensor/api/int.rs b/crates/burn-tensor/src/tensor/api/int.rs new file mode 100644 index 0000000..97184cb --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/int.rs @@ -0,0 +1,261 @@ +use burn_backend::{ElementConversion, Scalar, ops::IntTensorOps}; +use burn_dispatch::Dispatch; + +use crate::{ + Cast, Device, Float, Int, Shape, Tensor, TensorCreationOptions, TensorData, cartesian_grid, + ops::BridgeTensor, +}; + +use core::ops::Range; + +impl Tensor<1, Int> { + /// Returns a new integer tensor on the specified device. + /// + /// # Arguments + /// + /// * `range` - The range of values to generate. + /// * `device` - The device to create the tensor on. + pub fn arange(range: Range, options: impl Into) -> Self { + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + Tensor::new(arange_impl(range, opt.device, dtype)) + } + + /// Returns a new integer tensor on the specified device. + /// + /// # Arguments + /// + /// * `range` - The range of values to generate. + /// * `step` - The step between each value. + pub fn arange_step( + range: Range, + step: usize, + options: impl Into, + ) -> Self { + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + Tensor::new(arange_step_impl(range, step, opt.device, dtype)) + } +} + +impl Tensor { + /// Create a tensor from integers (i32), placing it on a given device. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Int}; + /// + /// fn example() { + /// let device = Default::default(); + /// let _x: Tensor<1, Int> = Tensor::from_ints([1, 2], &device); + /// let _y: Tensor<2, Int> = Tensor::from_ints([[1, 2], [3, 4]], &device); + /// } + /// ``` + pub fn from_ints>(ints: A, device: &Device) -> Self { + Self::from_data(ints.into().convert::(), device) + } + + /// Returns a new tensor with the same shape and device as the current tensor and the data + /// cast to Float. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let int_tensor = Tensor::<1, Int>::arange(0..5, &device); + /// let float_tensor = int_tensor.float(); + /// } + /// ``` + pub fn float(self) -> Tensor { + let device = self.device(); + Tensor::new(int_to_float_impl(self.primitive, device)) + } + + /// Generates a cartesian grid for the given tensor shape on the specified device. + /// The generated tensor is of dimension `D2 = D + 1`, where each element at dimension D contains the cartesian grid coordinates for that element. + /// + /// # Arguments + /// + /// * `shape` - The shape specifying the dimensions of the tensor. + /// * `device` - The device to create the tensor on. + /// + /// # Panics + /// + /// Panics if `D2` is not equal to `D+1`. + /// + /// # Examples + /// + /// ```rust + /// use burn_tensor::Int; + /// use burn_tensor::{Shape, Tensor}; + /// fn example() { + /// let device = Default::default(); + /// let result: Tensor<3, _> = Tensor::<2, Int>::cartesian_grid([2, 3], &device); + /// println!("{}", result); + /// } + /// ``` + pub fn cartesian_grid, const D2: usize>( + shape: S, + device: &Device, + ) -> Tensor { + cartesian_grid::(shape, device) + } + + /// Applies the bitwise logical and operation with each bit representing the integer. + pub fn bitwise_and(self, other: Self) -> Self { + Self::new(bitwise_and_impl(self.primitive, other.primitive)) + } + + /// Applies the bitwise logical or operation with another tensor. + pub fn bitwise_or(self, other: Self) -> Self { + Self::new(bitwise_or_impl(self.primitive, other.primitive)) + } + + /// Applies the bitwise logical xor operation with another tensor. + pub fn bitwise_xor(self, other: Self) -> Self { + Self::new(bitwise_xor_impl(self.primitive, other.primitive)) + } + + /// Applies the bitwise logical not operation. + pub fn bitwise_not(self) -> Self { + Self::new(bitwise_not_impl(self.primitive)) + } + + /// Applies the bitwise logical and operation with each bit in the scalar and the integers in the tensor. + pub fn bitwise_and_scalar(self, other: impl ElementConversion) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(bitwise_and_scalar_impl(self.primitive, other)) + } + + /// Applies the bitwise logical or operation with each bit in the scalar and the integers in the tensor. + pub fn bitwise_or_scalar(self, other: impl ElementConversion) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(bitwise_or_scalar_impl(self.primitive, other)) + } + + /// Applies bitwise logical xor operation with each bit in the scalar and the integers in the tensor. + pub fn bitwise_xor_scalar(self, other: impl ElementConversion) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(bitwise_xor_scalar_impl(self.primitive, other)) + } + + /// Applies the bitwise left shift operation with the integers in the tensor. + pub fn bitwise_left_shift(self, other: Self) -> Self { + Self::new(bitwise_left_shift_impl(self.primitive, other.primitive)) + } + + /// Applies the bitwise right shift operation with the integers in the tensor. + pub fn bitwise_right_shift(self, other: Self) -> Self { + Self::new(bitwise_right_shift_impl(self.primitive, other.primitive)) + } + + /// Applies the bitwise left shift operation with the scalar. + pub fn bitwise_left_shift_scalar(self, other: impl ElementConversion) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(bitwise_left_shift_scalar_impl(self.primitive, other)) + } + + /// Applies the bitwise right shift operation with the scalar. + pub fn bitwise_right_shift_scalar(self, other: impl ElementConversion) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(bitwise_right_shift_scalar_impl(self.primitive, other)) + } + + /// Converts a tensor to the specified data type. + /// + /// Supports both within-kind casting (e.g., `IntDType::I64`) and cross-kind casting + /// (e.g., `FloatDType::F32` to produce a float tensor). + /// + /// This is a no-op when casting to the current dtype within the same kind. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Int, IntDType, FloatDType}; + /// + /// fn example() { + /// let device = Default::default(); + /// let int_tensor = Tensor::<1, Int>::arange(0..5, &device); + /// + /// // Within-kind cast (int to int) + /// let i64_tensor = int_tensor.clone().cast(IntDType::I64); + /// + /// // Cross-kind cast (int to float) + /// let float_tensor = int_tensor.cast(FloatDType::F32); + /// } + /// ``` + #[must_use] + pub fn cast>(self, dtype: T) -> Tensor { + T::cast(self, dtype) + } +} + +// ========================================================================= +// Non-generic implementation helpers (outlined from the generic API). +// See the crate-level docs for the rationale behind this pattern. +// ========================================================================= + +fn arange_impl(range: Range, device: Device, dtype: burn_std::DType) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_arange( + range, + device.as_dispatch(), + dtype.into(), + )) +} + +fn arange_step_impl( + range: Range, + step: usize, + device: Device, + dtype: burn_std::DType, +) -> BridgeTensor { + BridgeTensor::int(Dispatch::int_arange_step( + range, + step, + device.as_dispatch(), + dtype.into(), + )) +} + +fn int_to_float_impl(p: BridgeTensor, device: Device) -> BridgeTensor { + let out_dtype = device.settings().float_dtype; + BridgeTensor::float(Dispatch::int_into_float(p.into(), out_dtype)) +} + +fn bitwise_and_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_and(lhs.into(), rhs.into())) +} +fn bitwise_or_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_or(lhs.into(), rhs.into())) +} +fn bitwise_xor_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_xor(lhs.into(), rhs.into())) +} +fn bitwise_not_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_not(p.into())) +} +fn bitwise_and_scalar_impl(p: BridgeTensor, other: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_and_scalar(p.into(), other)) +} +fn bitwise_or_scalar_impl(p: BridgeTensor, other: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_or_scalar(p.into(), other)) +} +fn bitwise_xor_scalar_impl(p: BridgeTensor, other: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_xor_scalar(p.into(), other)) +} +fn bitwise_left_shift_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_left_shift(lhs.into(), rhs.into())) +} +fn bitwise_right_shift_impl(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_right_shift(lhs.into(), rhs.into())) +} +fn bitwise_left_shift_scalar_impl(p: BridgeTensor, other: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_left_shift_scalar(p.into(), other)) +} +fn bitwise_right_shift_scalar_impl(p: BridgeTensor, other: Scalar) -> BridgeTensor { + BridgeTensor::int(Dispatch::bitwise_right_shift_scalar(p.into(), other)) +} diff --git a/crates/burn-tensor/src/tensor/api/mod.rs b/crates/burn-tensor/src/tensor/api/mod.rs new file mode 100644 index 0000000..d51528b --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/mod.rs @@ -0,0 +1,35 @@ +pub(crate) mod check; + +mod autodiff; +mod base; +mod bool; +mod cartesian_grid; +mod cast; +mod float; +mod fmod; +mod graph; +mod int; +mod numeric; +mod options; +mod orderable; +mod pad; +pub use pad::IntoPadding; +mod take; +mod transaction; + +mod trunc; + +#[cfg(feature = "autodiff")] +pub use autodiff::*; +pub use base::*; +pub use cartesian_grid::cartesian_grid; +pub use cast::*; +pub use float::{DEFAULT_ATOL, DEFAULT_RTOL}; +pub use graph::{Graph, capture}; +pub use options::*; +pub use transaction::*; + +#[cfg(feature = "extension")] +mod extension; +#[cfg(feature = "extension")] +pub use extension::*; diff --git a/crates/burn-tensor/src/tensor/api/numeric.rs b/crates/burn-tensor/src/tensor/api/numeric.rs new file mode 100644 index 0000000..d76ec3b --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/numeric.rs @@ -0,0 +1,1127 @@ +use burn_backend::Scalar; + +use crate::alloc::borrow::ToOwned; +use crate::kind::Numeric; +use alloc::vec::Vec; + +use crate::{ + AsIndex, Bool, Distribution, ElementConversion, Int, Shape, Tensor, check, check::TensorCheck, +}; +use crate::{Device, IndexingUpdateOp, TensorCreationOptions}; + +impl Tensor +where + K: Numeric, +{ + /// Applies element wise addition operation. + /// + /// `y = x2 + x1` + /// + /// # Arguments + /// + /// * `other` - The tensor to add. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1 + tensor2; + /// println!("{tensor}"); + /// // [[3.0, 1.0, 7.0], [6.0, 11.0, 9.0]] + /// } + /// ``` + #[allow(clippy::should_implement_trait)] + pub fn add(self, other: Self) -> Self { + check!(TensorCheck::binary_ops_ew("Add", &self, &other)); + Self::new(K::add(self.primitive, other.primitive)) + } + + /// Applies element wise addition operation with a scalar. + /// + /// `y = x + s` + /// + /// # Arguments + /// + /// * `other` - The scalar to add, element wise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let scalar = 2.0; + /// let tensor = tensor + scalar; + /// println!("{tensor}"); + /// // [[3.0, 0.0, 5.0], [7.0, 11.0, 8.0]] + /// } + /// ``` + pub fn add_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::add_scalar(self.primitive, other)) + } + + /// Applies element wise subtraction operation. + /// + /// `y = x2 - x1` + /// + /// # Arguments + /// + /// * `other` - The tensor to subtract. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1 - tensor2; + /// println!("{tensor}"); + /// // [[-1.0, -5.0, -1.0], [4.0, 7.0, 3.0]] + /// } + /// ``` + #[allow(clippy::should_implement_trait)] + pub fn sub(self, other: Self) -> Self { + check!(TensorCheck::binary_ops_ew("Sub", &self, &other)); + Self::new(K::sub(self.primitive, other.primitive)) + } + + /// Applies element wise subtraction operation with a scalar. + /// + /// `y = x - s` + /// + /// # Arguments + /// + /// * `other` - The scalar to subtract, element wise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let scalar = 2.0; + /// let tensor = tensor - scalar; + /// println!("{tensor}"); + /// // [[-1.0, -4.0, 1.0], [3.0, 7.0, 4.0]] + /// } + /// ``` + pub fn sub_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::sub_scalar(self.primitive, other)) + } + + /// Applies element wise division operation. + /// + /// `y = x2 / x1` + /// + /// # Arguments + /// + /// * `other` - The tensor to divide. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1 / tensor2; + /// println!("{tensor}"); + /// // [[0.5, -0.6666667, 0.75], [5.0, 4.5, 2.0]] + /// } + /// ``` + #[allow(clippy::should_implement_trait)] + pub fn div(self, other: Self) -> Self { + check!(TensorCheck::binary_ops_ew("Div", &self, &other)); + Self::new(K::div(self.primitive, other.primitive)) + } + + /// Applies element wise division operation with a scalar. + /// + /// `y = x / s` + /// + /// # Arguments + /// + /// * `other` - The scalar to divide, element wise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let scalar = 2.0; + /// let tensor = tensor / scalar; + /// println!("{tensor}"); + /// // [[0.5, -1.0, 1.5], [2.5, 4.5, 3.0]] + /// } + /// ``` + pub fn div_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::div_scalar(self.primitive, other)) + } + + /// Applies element wise the remainder operation with a scalar. + /// + /// `y = x2 % x1` + pub fn remainder(self, other: Self) -> Self { + Self::new(K::remainder(self.primitive, other.primitive)) + } + + /// Applies element wise the remainder operation with a scalar. + /// + /// `y = x % s` + /// + /// # Arguments + /// + /// * `other` - The scalar to divide, element wise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let scalar = 2.0; + /// let tensor = tensor1 % scalar; + /// println!("{tensor}"); + /// // [[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]] + /// } + /// ``` + pub fn remainder_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::remainder_scalar(self.primitive, other)) + } + + /// Applies element wise multiplication operation. + /// + /// `y = x2 * x1` + /// + /// # Arguments + /// + /// * `other` - The tensor to multiply. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1 * tensor2; + /// println!("{tensor}"); + /// // [[2.0, -6.0, 12.0], [5.0, 18.0, 18.0]] + /// } + /// ``` + #[allow(clippy::should_implement_trait)] + pub fn mul(self, other: Self) -> Self { + check!(TensorCheck::binary_ops_ew("Mul", &self, &other)); + Self::new(K::mul(self.primitive, other.primitive)) + } + + /// Applies element wise multiplication operation with a scalar. + /// + /// `y = x * s` + /// + /// # Arguments + /// + /// * `other` - The scalar to multiply, element wise. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let scalar = 2.0; + /// let tensor = tensor * scalar; + /// println!("{tensor}"); + /// // [[2.0, -4.0, 6.0], [10.0, 18.0, 12.0]] + /// } + /// ``` + pub fn mul_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::mul_scalar(self.primitive, other)) + } + + /// Switch sign of each element in the tensor. + /// + /// `y = -x` + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = -tensor; + /// println!("{tensor}"); + /// // [[-1.0, 2.0, -3.0], [-5.0, -9.0, -6.0]] + /// } + /// ``` + #[allow(clippy::should_implement_trait)] + pub fn neg(self) -> Self { + Self::new(K::neg(self.primitive)) + } + + /// Returns the signs of the elements of the input tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.sign(); + /// println!("{tensor}"); + /// // [[1.0, -1.0, 1.0], [1.0, 1.0, 1.0]] + /// } + /// ``` + pub fn sign(self) -> Self { + Self::new(K::sign(self.primitive)) + } + + /// Aggregate all elements in the tensor with the mean operation. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.mean(); + /// println!("{tensor}"); + /// // [3.6666667] + /// } + /// ``` + pub fn mean(self) -> Tensor<1, K> { + Tensor::new(K::mean(self.primitive)) + } + + /// Aggregate all elements in the tensor with the sum operation. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.sum(); + /// println!("{tensor}"); + /// // [22.0] + /// } + /// ``` + pub fn sum(self) -> Tensor<1, K> { + Tensor::new(K::sum(self.primitive)) + } + + /// Aggregate all elements along the given *dimension* or *axis* + /// in the tensor with the mean operation. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.clone().mean_dim(0); + /// println!("{tensor}"); + /// // [[3.0, 3.5, 4.5]] + /// let tensor = tensor.clone().mean_dim(1); + /// println!("{tensor}"); + /// // [[0.6666667], [6.6666665]] + /// } + /// ``` + pub fn mean_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Mean", dim)); + Self::new(K::mean_dim(self.primitive, dim)) + } + + /// Aggregate all elements along the given *axes* + /// in the tensor with the mean operation. + /// + /// # Arguments + /// + /// * `dims` - the dimensions to aggregate; supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[2.0, 4.0], [6.0, -4.0]], &device); + /// let tensor = tensor.clone().mean_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[2.0]] + /// } + /// ``` + pub fn mean_dims(self, dims: &[I]) -> Self { + dims.iter().fold(self, |tensor, &dim| tensor.mean_dim(dim)) + } + + /// Aggregate all elements along the given *dimension* or *axis* + /// in the tensor with the sum operation. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.clone().sum_dim(0); + /// println!("{tensor}"); + /// // [[6.0, 7.0, 9.0]] + /// let tensor = tensor.clone().sum_dim(1); + /// println!("{tensor}"); + /// // [[2.0], [20.0]] + /// } + /// ``` + pub fn sum_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Sum", dim)); + Self::new(K::sum_dim(self.primitive, dim)) + } + + /// Aggregate all elements along the given *axes* + /// in the tensor with the sum operation. + /// + /// # Arguments + /// + /// * `dims` - the dimensions to aggregate; supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.clone().sum_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[27]] + /// } + /// ``` + pub fn sum_dims(self, dims: &[I]) -> Self { + dims.iter().fold(self, |tensor, &dim| tensor.sum_dim(dim)) + } + + /// Aggregate and squeeze along the given dimensions. + /// + /// This is equivalent to ``tensor.sum_dims(dims).squeeze_dims(dims)`` + /// + /// # Arguments + /// + /// * `dims` - the dimensions to aggregate; supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::from_data([ + /// [[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], + /// [[9.0, 2.0, 5.0], [5.0, 7.0, 7.0]], + /// ], &device); + /// let tensor = tensor.clone().sum_dims_squeeze::<1, _>(&[0, 1]); + /// println!("{tensor}"); + /// // [20.0, 16.0, 21.0] + /// } + /// ``` + pub fn sum_dims_squeeze(self, dims: &[I]) -> Tensor { + // TODO: remove idims when squeeze_dims uses AsIndex. + let idims = dims + .iter() + .map(|&dim| (dim.expect_dim_index(D)) as isize) + .collect::>(); + self.sum_dims(dims).squeeze_dims::(&idims) + } + + /// Aggregate all elements in the tensor with the product operation. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.prod(); + /// println!("{tensor}"); + /// // [-1620.0] + /// } + /// ``` + pub fn prod(self) -> Tensor<1, K> { + Tensor::new(K::prod(self.primitive)) + } + + /// Aggregate all elements along the given *dimension* or *axis* + /// in the tensor with the product operation. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements, + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimension will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.clone().prod_dim(0); + /// println!("{tensor}"); + /// // [[5.0, -18.0, 18.0]] + /// let tensor = tensor.clone().prod_dim(1); + /// println!("{tensor}"); + /// // [[-6.0], [270.0]] + /// } + /// ``` + pub fn prod_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Prod", dim)); + Self::new(K::prod_dim(self.primitive, dim)) + } + + /// Aggregate all elements along the given *axes* + /// in the tensor with the prod operation. + /// + /// # Arguments + /// + /// * `dims` - the dimensions to aggregate, supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.clone().sum_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[-1620.0]] + /// } + /// ``` + pub fn prod_dims(self, dims: &[I]) -> Self { + dims.iter().fold(self, |tensor, &dim| tensor.prod_dim(dim)) + } + + /// Computes the cumulative sum of elements along the given *dimension* or *axis*. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to compute the cumulative sum. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + /// let result = tensor.clone().cumsum(0); + /// println!("{result}"); + /// // [[1.0, 2.0, 3.0], [5.0, 7.0, 9.0]] + /// let result = tensor.cumsum(1); + /// println!("{result}"); + /// // [[1.0, 3.0, 6.0], [4.0, 9.0, 15.0]] + /// } + /// ``` + pub fn cumsum(self, dim: usize) -> Self { + check!(TensorCheck::aggregate_dim::("CumSum", dim)); + Self::new(K::cumsum(self.primitive, dim)) + } + + /// Computes the cumulative product of elements along the given *dimension* or *axis*. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to compute the cumulative product. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + /// let result = tensor.clone().cumprod(0); + /// println!("{result}"); + /// // [[1.0, 2.0, 3.0], [4.0, 10.0, 18.0]] + /// let result = tensor.cumprod(1); + /// println!("{result}"); + /// // [[1.0, 2.0, 6.0], [4.0, 20.0, 120.0]] + /// } + /// ``` + pub fn cumprod(self, dim: usize) -> Self { + check!(TensorCheck::aggregate_dim::("CumProd", dim)); + Self::new(K::cumprod(self.primitive, dim)) + } + + /// Apply element wise absolute value operation. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints([[1, -2, 3], [4, -5, 6], [7, -8, 9]], &device); + /// let tensor = tensor.abs(); + /// println!("{tensor}"); + /// // [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + /// } + /// ``` + /// + /// # Notes + /// + /// For signed integer dtypes, this operation uses two's-complement wraparound semantics, similar to + /// `x.wrapping_abs()`. For example, `abs(i64::MIN) == i64::MIN`. + pub fn abs(self) -> Self { + Self::new(K::abs(self.primitive)) + } + + /// Returns the upper triangular part of a matrix (2-D tensor) or batch of matrices input, + /// the other elements of the result tensor out are set to 0. + /// + /// See also [`triu_mask`](Tensor::triu_mask). + /// + /// # Arguments + /// + /// * `diagonal` - The offset from the diagonal, where 0 means the diagonal, and positive values shift + /// towards the upper triangle. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints( + /// [ + /// [1, 2, 3], + /// [4, 5, 6], + /// [7, 8, 9] + /// ], + /// &device + /// ); + /// let tensor = tensor.triu(1); + /// println!("{tensor}"); + /// // [ + /// // [0, 2, 3], + /// // [0, 0, 6], + /// // [0, 0, 0] + /// // ] + /// } + /// ``` + pub fn triu(self, diagonal: i64) -> Self { + check!(TensorCheck::tri::<{ D }>()); + + // last two dimensions + let shape = &self.shape()[D - 2..].to_owned(); + + let mask = Tensor::<2, Bool>::triu_mask(shape, diagonal, &self.device()).unsqueeze(); + self.mask_fill(mask, 0) + } + + /// Returns the lower triangular part of a matrix (2-D tensor) or batch of matrices input, + /// the other elements of the result tensor out are set to 0. + /// + /// See also [`tril_mask`](Tensor::tril_mask). + /// + /// # Arguments + /// + /// * `diagonal` - The offset from the diagonal, where 0 means the diagonal, and positive values shift + /// towards the upper triangle. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints( + /// [ + /// [1, 2, 3], + /// [4, 5, 6], + /// [7, 8, 9] + /// ], + /// &device + /// ); + /// + /// let tensor = tensor.tril(-1); + /// println!("{tensor}"); + /// // [ + /// // [0, 0, 0], + /// // [4, 0, 0], + /// // [7, 8, 0] + /// // ] + /// } + /// ``` + pub fn tril(self, diagonal: i64) -> Self { + check!(TensorCheck::tri::<{ D }>()); + + // last two dimensions + let shape = &self.shape()[D - 2..].to_owned(); + let mask = Tensor::<2, Bool>::tril_mask(shape, diagonal, &self.device()).unsqueeze(); + + self.mask_fill(mask, 0) + } + + /// Applies element wise power operation with a integer Tensor + /// + /// # Arguments + /// + /// * `other` - The tensor to apply the power operation with. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, Int}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2, Int>::from_ints([[1, -2, 3], [5, 9, 6]], &device); + /// let tensor2 = Tensor::<2, Int>::from_ints([[2, 3, 4], [1, 2, 3]], &device); + /// let tensor = tensor1.powi(tensor2); + /// println!("{tensor}"); + /// // [[1, -8, 81], [5, 81, 216]] + /// } + /// ``` + pub fn powi(self, other: Self) -> Self { + Self::new(K::powi(self.primitive, other.primitive)) + } + + /// Applies element wise power operation with a integer scalar + /// + /// # Arguments + /// + /// * `other` - The scalar to apply the power operation with. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, Int}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints([[1, -2, 3], [5, 9, 6]], &device); + /// let tensor = tensor.powi_scalar(2); + /// println!("{tensor}"); + /// + /// // [[1, 4, 9], [25, 81, 36]] + /// let tensor = Tensor::<2>::from_data([[1.5, -2., 3.], [5., 9., 6.]], &device); + /// let tensor = tensor.powi_scalar(2); + /// println!("{tensor}"); + /// // [[2.25, 4., 9.], [25., 81., 36.]] + /// } + /// ``` + pub fn powi_scalar(self, other: E) -> Self { + let other = Scalar::new(other, &self.dtype()); + Self::new(K::powi_scalar(self.primitive, other)) + } + + /// Converts the tensor to a boolean tensor by checking if the elements are non-zero. + /// + /// # Returns + /// + /// A boolean tensor with the same shape as the input tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [0.0, 9.0, 6.0]], &device); + /// let tensor = tensor.bool(); + /// println!("{tensor}"); + /// // [ + /// // [true, true, true], + /// // [false, true, true] + /// // ] + /// } + pub fn bool(self) -> Tensor { + self.not_equal_elem(0) + } + + /// Create a random tensor of the given shape on the given device where each element is + /// sampled from the given distribution. + /// + /// See also [`random_like`](Tensor::random_like). + /// + /// # Arguments + /// + /// * `shape` - The shape of the tensor. + /// * `distribution` - The distribution to sample from. + /// * `device` - The device to create the tensor on. + /// + /// # Returns + /// + /// A new tensor with the given shape and elements sampled from the given distribution. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape, Distribution}; + /// + /// fn example() { + /// let device = Default::default(); + /// let distribution = Distribution::Uniform(0.0, 1.0); // Any random value between 0.0 and 1.0 + /// let tensor = Tensor::<2>::random(Shape::new([2, 3]), distribution, &device); + /// println!("{tensor}"); + /// // [ + /// // [0.08347523, 0.70498955, 0.60332155], + /// // [0.08173251, 0.18028641, 0.97942924] + /// // ] + /// } + /// ``` + pub fn random>( + shape: S, + distribution: Distribution, + options: impl Into, + ) -> Self { + // Use the given dtype when provided, otherwise default device dtype + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + Self::new(K::random(shape.into(), distribution, &opt.device, dtype)) + } + + /// Applies the matrix multiplication operation. + /// + /// ```math + /// C = AB + /// ``` + /// + /// Shapes of the form `[..., B, 1, K] @ [..., 1, K, N]` are reinterpreted as + /// `[..., 1, B, K] @ [..., 1, K, N]`, turning a batched vec-mat into a general + /// matmul, which is often faster. + pub fn matmul(self, other: Self) -> Self { + check!(TensorCheck::matmul(&self, &other)); + + if D >= 3 { + let batch_index = D - 3; + let vector_index = D - 2; + let lhs_dims = &self.shape()[batch_index..D]; + let rhs_dims = &other.shape()[batch_index..D]; + + if let ([_, 1, k1], [1, k2, _]) = (lhs_dims, rhs_dims) + && k1 == k2 + { + return Tensor::new(K::matmul( + self.swap_dims(batch_index, vector_index).primitive, + other.primitive, + )) + .swap_dims(batch_index, vector_index); + } + } + + Tensor::new(K::matmul(self.primitive, other.primitive)) + } +} + +impl Tensor<1, K> +where + K: Numeric, +{ + /// Calculates the dot product with another tensor. + /// + /// `y = x2.dot(x1)` + /// + /// # Arguments + /// + /// * `other` - The tensor to compute dot product with. + /// + /// # Notes + /// + /// Both tensors must have the same number of elements. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<1>::from_data([1.0, 2.0], &device); + /// let tensor2 = Tensor::<1>::from_data([-2.0, 3.0], &device); + /// let tensor = tensor1.dot(tensor2); + /// println!("{tensor}"); + /// // [4] + /// } + /// ``` + pub fn dot(self, other: Self) -> Self { + self.mul(other).sum() + } +} + +impl Tensor<2, K> +where + K: Numeric, +{ + /// Creates a new 2D tensor with ones on the diagonal and zeros elsewhere. + /// + /// # Arguments + /// + /// * `size` - The size of the square matrix. + pub fn eye(size: usize, device: &Device) -> Self { + let indices = Tensor::<1, Int>::arange(0..size as i64, device).unsqueeze::<2>(); + let ones = Self::ones([1, size], device); + let zeros = Self::zeros([size, size], device); + + zeros.scatter(0, indices, ones, IndexingUpdateOp::Add) + } +} + +// Tensor + tensor +impl core::ops::Add for Tensor { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self::add(self, rhs) + } +} + +// Tensor + scalar +impl core::ops::Add for Tensor { + type Output = Self; + + fn add(self, other: E) -> Self::Output { + Tensor::add_scalar(self, other) + } +} + +// Scalar + tensor +macro_rules! impl_tensor_scalar_add { + ($($t:ty),*) => { + $( + impl core::ops::Add> for $t + { + type Output = Tensor; + + fn add(self, tensor: Tensor) -> Self::Output { + Tensor::add_scalar(tensor, self) + } + } + )* + } +} +impl_tensor_scalar_add!(f32, f64, i32, i64, u32, u64); + +// Tensor - tensor +impl core::ops::Sub for Tensor { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Tensor::sub(self, rhs) + } +} + +// Tensor - scalar +impl core::ops::Sub for Tensor { + type Output = Self; + + fn sub(self, other: E) -> Self::Output { + Tensor::sub_scalar(self, other) + } +} + +// Scalar - tensor +macro_rules! impl_tensor_scalar_sub { + ($($t:ty),*) => { + $( + impl core::ops::Sub> for $t + { + type Output = Tensor; + + fn sub(self, tensor: Tensor) -> Self::Output { + Tensor::add_scalar(Tensor::neg(tensor), self) + } + } + )* + } +} +impl_tensor_scalar_sub!(f32, f64, i32, i64, u32, u64); + +// Tensor / tensor +impl core::ops::Div for Tensor { + type Output = Self; + + fn div(self, rhs: Self) -> Self::Output { + Tensor::div(self, rhs) + } +} + +// Tensor / scalar +impl core::ops::Div for Tensor { + type Output = Self; + + fn div(self, other: E) -> Self::Output { + Tensor::div_scalar(self, other) + } +} + +// Scalar / tensor (float only) +macro_rules! impl_tensor_scalar_div { + ($($t:ty),*) => { + $( + impl core::ops::Div> for $t + { + type Output = Tensor; + + fn div(self, tensor: Tensor) -> Self::Output { + tensor.recip().mul_scalar(self) + } + } + )* + } +} + +impl_tensor_scalar_div!(f32, f64); + +// Tensor % tensor. +impl core::ops::Rem for Tensor { + type Output = Self; + + fn rem(self, rhs: Self) -> Self::Output { + Tensor::remainder(self, rhs) + } +} + +// Tensor % scalar. +impl core::ops::Rem for Tensor { + type Output = Self; + + fn rem(self, other: E) -> Self::Output { + Tensor::remainder_scalar(self, other) + } +} + +// Tensor * tensor. +impl core::ops::Mul for Tensor { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + Tensor::mul(self, rhs) + } +} + +// Tensor * scalar. +impl core::ops::Mul for Tensor { + type Output = Self; + + fn mul(self, other: E) -> Self::Output { + Tensor::mul_scalar(self, other) + } +} + +macro_rules! impl_tensor_scalar_mul { + ($($t:ty),*) => { + $( + impl core::ops::Mul> for $t + { + type Output = Tensor; + + fn mul(self, other: Tensor) -> Self::Output { + Tensor::mul_scalar(other, self) + } + } + )* + } +} + +impl_tensor_scalar_mul!(f32, f64, i32, i64, u32, u64); + +impl core::ops::Neg for Tensor +where + K: Numeric, +{ + type Output = Self; + + fn neg(self) -> Self::Output { + Tensor::neg(self) + } +} diff --git a/crates/burn-tensor/src/tensor/api/options.rs b/crates/burn-tensor/src/tensor/api/options.rs new file mode 100644 index 0000000..21cdf94 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/options.rs @@ -0,0 +1,98 @@ +use burn_std::DType; + +use crate::{Device, bridge::BasicOps, ops::Kind}; + +/// Options for tensor creation. +/// +/// This struct allows specifying the `device` and overriding the data type when creating a tensor. +/// When the `dtype` is not specified, the [device's default settings](crate::DeviceSettings) is used. +#[derive(Debug, Clone)] +pub struct TensorCreationOptions { + /// Device where the tensor will be created. + pub device: Device, + /// Optional data type. + /// If `None`, the dtype will be inferred on creation from the [device settings](crate::DeviceSettings). + pub dtype: Option, +} + +impl Default for TensorCreationOptions { + /// Returns new options with the backend's default device. + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl TensorCreationOptions { + /// Create new options with a specific device. + /// + /// Data type will follow the [device settings](crate::DeviceSettings) on tensor creation. + pub fn new(device: Device) -> Self { + Self { + device, + dtype: None, + } + } + + /// Set the tensor creation data type. + pub fn with_dtype(mut self, dtype: DType) -> Self { + self.dtype = Some(dtype); + + self + } + + /// Set the tensor creation device. + pub fn with_device(mut self, device: Device) -> Self { + self.device = device; + + self + } + + /// Returns the tensor data type, or a provided default if not set. + /// + /// This is useful for cases where [`TensorCreationOptions`] may not have an explicit `dtype`. + pub fn dtype_or(&self, dtype: DType) -> DType { + self.dtype.unwrap_or(dtype) + } + + /// Returns the tensor data type, or the default from the [device settings](crate::DeviceSettings). + pub(crate) fn resolve_dtype(&self) -> DType { + let settings = self.device.settings(); + let default = match K::KIND { + Kind::Float => settings.float_dtype.into(), + Kind::Int => settings.int_dtype.into(), + Kind::Bool => settings.bool_dtype.into(), + }; + match K::KIND { + // `BoolStore` variants are backend-specific storage layouts rather than + // semantic types, and a requested variant (e.g. forced from a serialized + // snapshot by `burn-store`) may be unsupported on the target device, so + // always resolve bool tensors to the device's default bool storage. + Kind::Bool => default, + _ => self.dtype.unwrap_or(default), + } + } +} + +impl From<&Device> for TensorCreationOptions { + /// Convenience conversion from a reference to a device. + /// + /// Example: + /// ```rust + /// use burn_tensor::TensorCreationOptions; + /// use burn_tensor::Device; + /// + /// fn example(device: Device) { + /// let options: TensorCreationOptions = (&device).into(); + /// } + /// ``` + fn from(device: &Device) -> Self { + TensorCreationOptions::new(device.clone()) + } +} + +impl From<(&crate::Device, DType)> for TensorCreationOptions { + /// Convenience conversion for a specified `(&device, dtype)` tuple. + fn from(args: (&crate::Device, DType)) -> Self { + TensorCreationOptions::new(args.0.clone()).with_dtype(args.1) + } +} diff --git a/crates/burn-tensor/src/tensor/api/orderable.rs b/crates/burn-tensor/src/tensor/api/orderable.rs new file mode 100644 index 0000000..8c921e0 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/orderable.rs @@ -0,0 +1,1132 @@ +use burn_backend::{ElementConversion, Scalar}; +use burn_std::{AsIndex, IndexingUpdateOp}; + +use crate::kind::Ordered; +use crate::{Bool, Int, check}; +use crate::{Tensor, check::TensorCheck}; + +impl Tensor +where + K: Ordered, +{ + /// Sort the elements by value in ascending order along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Returns + /// + /// A new tensor with the elements sorted in ascending order along the given dimension. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let tensor = tensor.sort(0); + /// println!("{tensor}"); + /// // [[5.0, -2.0, 3.0], [12.0, 3.0, 6.0]] + /// let tensor = tensor.sort(1); + /// println!("{tensor}"); + /// // [[-2.0, 3.0, 12.0], [3.0, 5.0, 6.0]] + /// } + /// ``` + pub fn sort(self, dim: usize) -> Self { + check!(TensorCheck::sort_dim::("Sort", dim)); + Tensor::new(K::sort(self.primitive, dim, /*descending*/ false)) + } + + /// Sort the elements by value in descending order along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Returns + /// + /// A new tensor with the elements sorted in descending order along the given dimension. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let tensor = tensor.sort_descending(0); + /// println!("{tensor}"); + /// // [[12.0, 3.0, 6.0], [5.0, -2.0, 3.0]] + /// let tensor = tensor.sort_descending(1); + /// println!("{tensor}"); + /// // [[12.0, 3.0, -2.0], [6.0, 5.0, 3.0]] + /// } + /// ``` + pub fn sort_descending(self, dim: usize) -> Self { + check!(TensorCheck::sort_dim::("Sort", dim)); + Tensor::new(K::sort(self.primitive, dim, /*descending*/ true)) + } + + /// Sort the elements by value in ascending order along a given dimension. + /// Also returns the indices. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Returns + /// + /// A tuple containing the sorted tensor and the indices tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let (tensor, indices) = tensor.sort_with_indices(0); + /// println!("{tensor}"); + /// // [[5.0, -2.0, 3.0], [12.0, 3.0, 6.0]] + /// println!("{}", indices); + /// // [[1, 0, 0], [0, 1, 1]] + /// } + /// ``` + pub fn sort_with_indices(self, dim: usize) -> (Self, Tensor) { + check!(TensorCheck::sort_dim::("Sort_with_indices", dim)); + let (values, indices) = + K::sort_with_indices(self.primitive, dim, /*descending*/ false); + (Tensor::new(values), Tensor::new(indices)) + } + + /// Sort the elements by value in descending order along a given dimension. + /// Also returns the indices. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let (tensor, indices) = tensor.sort_descending_with_indices(0); + /// println!("{tensor}"); + /// // [[12.0, 3.0, 6.0], [5.0, -2.0, 3.0]] + /// println!("{}", indices); + /// // [[0, 1, 1], [1, 0, 0]] + /// } + /// ``` + pub fn sort_descending_with_indices(self, dim: usize) -> (Self, Tensor) { + check!(TensorCheck::sort_dim::("Sort_with_indices", dim)); + let (values, indices) = K::sort_with_indices(self.primitive, dim, /*descending*/ true); + (Tensor::new(values), Tensor::new(indices)) + } + + /// Returns the indices that sort the elements by value in ascending order along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let tensor = tensor.argsort(0); + /// println!("{tensor}"); + /// // [[1, 0, 0], [0, 1, 1]] + /// } + /// ``` + pub fn argsort(self, dim: usize) -> Tensor { + check!(TensorCheck::sort_dim::("Argsort", dim)); + Tensor::new(K::argsort(self.primitive, dim, /*descending*/ false)) + } + + /// Returns the indices that sort the elements by value in descending order along a given dimension. + /// + /// This sort is unstable (i.e., may reorder equal elements). + /// + /// # Arguments + /// + /// * `dim` - The dimension to sort along. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let tensor = tensor.argsort_descending(0); + /// println!("{tensor}"); + /// // [[0, 1, 1], [1, 0, 0]] + /// let tensor = tensor.argsort_descending(1); + /// println!("{tensor}"); + /// // [[0, 2, 1], [2, 0, 1]] + /// } + /// ``` + pub fn argsort_descending(self, dim: usize) -> Tensor { + check!(TensorCheck::sort_dim::("Argsort", dim)); + Tensor::new(K::argsort(self.primitive, dim, /*descending*/ true)) + } + + /// Returns the `k` largest elements of the given input tensor along a given dimension. + /// + /// # Arguments + /// + /// * `k` - The number of elements to return. + /// + /// # Returns + /// + /// A new tensor with the `k` largest elements along the given dimension. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let tensor = tensor.topk(2, 0); + /// println!("{tensor}"); + /// // [[12.0, 3.0, 6.0], [5.0, -2.0, 3.0]] + /// let tensor = tensor.topk(1, 1); + /// println!("{tensor}"); + /// // [[12.0], [6.0]] + /// } + /// ``` + pub fn topk(self, k: usize, dim: usize) -> Self { + assert!(self.shape()[dim] > k); + Tensor::new(K::topk(self.primitive, dim, k)) + } + + /// Returns the `k` largest elements of the given input tensor along a given dimension. + /// Also returns the indices. + /// + /// # Arguments + /// + /// * `k` - The number of elements to return. + /// * `dim` - The dimension to sort along. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// let (tensor, indices) = tensor.topk_with_indices(2, 0); + /// println!("{tensor}"); + /// // [[12.0, 3.0, 6.0], [5.0, -2.0, 3.0]] + /// println!("{}", indices); + /// // [[0, 1, 1], [1, 0, 0]] + /// let (tensor, indices) = tensor.topk_with_indices(1, 1); + /// println!("{tensor}"); + /// // [[12.0], [6.0]] + /// println!("{indices}"); + /// // [[0], [2]] + /// } + /// ``` + pub fn topk_with_indices(self, k: usize, dim: usize) -> (Self, Tensor) { + let k_indices = Tensor::arange(0..k as i64, &self.device()); + let (values, indices) = self.sort_descending_with_indices(dim); + ( + values.select(dim, k_indices.clone()), + indices.select(dim, k_indices), + ) + } + + /// Create a one hot tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example(){ + /// let device = Default::default(); + /// let indices: Tensor<1> = Tensor::from_floats([0.0, 1.0, 2.0, 3.0], &device); + /// let one_hot: Tensor<2> = indices.one_hot(4); + /// println!("{}", one_hot.to_data()); + /// // [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] + /// } + /// ``` + pub fn one_hot(self, num_classes: usize) -> Tensor { + check!(TensorCheck::one_hot_tensor(self.clone(), num_classes)); + self.one_hot_fill(num_classes, 1.0, 0.0, -1) + } + + /// Create a one-hot encoded tensor with configurable `num_classes`, `on_value`, `off_value`, and `axis` including high-ranked tensors. + /// + /// # Arguments + /// + /// * `num_classes`: The number of classes for the one-hot encoding, which defines the size of the one-hot dimension. + /// * `on_value`: The value to assign for active positions (corresponding to indices). + /// * `off_value`: The value to assign for inactive positions. + /// * `axis`: The axis along which the one-hot dimension is added. Supports negative indexing. + /// + /// # Returns + /// + /// A tensor with one additional dimension for the one-hot encoding, where active positions are filled with `on_value` and others with `off_value`. + /// + /// # Example + /// ```rust + /// use burn_tensor::{Tensor, Float}; + /// fn example() { + /// let device = Default::default(); + /// let indices: Tensor<2, Float> = Tensor::from_floats([[0., 2.], [1., -1.]], &device); + /// // One-hot encoding + /// let tensor: Tensor<3, Float> = indices.one_hot_fill(3, 5.0.into(), 0.0.into(), -1); + /// println!("{tensor}"); + /// // [[[5.0, 0.0, 0.0], + /// // [0.0, 0.0, 5.0]], + /// // [[0.0, 5.0, 0.0], + /// // [0.0, 0.0, 5.0]]] + /// } + /// ``` + pub fn one_hot_fill( + self, + num_classes: usize, + on_value: f32, + off_value: f32, + axis: i64, + ) -> Tensor { + check!(TensorCheck::one_hot_tensor_rank::()); + // Initialize shape from the current tensor dimensions and prepare for modification + let mut shape = self.shape(); + let device = self.device(); + let rank = self.dims().len(); + + // Adjust negative axis to a positive index + let axis = if axis < 0 { + axis + rank as i64 + 1 + } else { + axis + }; + + // Ensure axis is within valid range + if axis < 0 || axis > rank as i64 { + panic!("Axis out of range. Accepted range is [-r-1, r] where r = rank(indices)."); + } + // Convert the input tensor to integer indices + let indices: Tensor = Tensor::from_data(self.to_data().convert::(), &device); + // Insert the new dimension for the one-hot representation + shape.insert(axis as usize, num_classes); + // Adjust indices to valid range and handle invalid indices + let adjusted_indices = indices + .clone() + .mask_fill(self.clone().lower_elem(0), num_classes as i64) // Handle negative indices + .add(indices.clone().mask_fill(self.clone().greater_elem(0), 0)); // Handle positive indices + // Unsqueeze the indices tensor along the specified axis + let indices_unsqueezed: Tensor = adjusted_indices.unsqueeze_dim(axis as usize); + + // Initialize the output tensor with the off_value + let output = Tensor::full(shape.clone(), off_value, &device); + + // Prepare scatter tensor for on_value and off_value adjustments + let scatter_on_values = Tensor::full(indices_unsqueezed.shape(), on_value, &device) + - Tensor::full(indices_unsqueezed.shape(), off_value, &self.device()); + + // Scatter on_value at the appropriate indices to create the one-hot representation + output.scatter( + axis as usize, + indices_unsqueezed, + scatter_on_values, + IndexingUpdateOp::Add, + ) + } + + /// Applies element wise greater comparison and returns a boolean tensor. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.greater(tensor2); + /// println!("{tensor}"); + /// // [[false, false, false], [true, true, true]] + /// } + /// ``` + pub fn greater(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("Greater", &self, &other)); + Tensor::new(K::greater(self.primitive, other.primitive)) + } + + /// Applies element wise greater-equal comparison and returns a boolean tensor. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.greater_equal(tensor2); + /// println!("{tensor}"); + /// // [[true, false, false], [true, true, true]] + /// } + /// ``` + pub fn greater_equal(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("Greater_equal", &self, &other)); + Tensor::new(K::greater_equal(self.primitive, other.primitive)) + } + + /// Applies element wise lower comparison and returns a boolean tensor. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.lower(tensor2); + /// println!("{tensor}"); + /// // [[false, true, true], [false, false, false]] + /// } + /// ``` + pub fn lower(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("Lower", &self, &other)); + Tensor::new(K::lower(self.primitive, other.primitive)) + } + + /// Applies element wise lower-equal comparison and returns a boolean tensor. + /// + /// # Panics + /// + /// If the two tensors don't have the same shape. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[1.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.lower_equal(tensor2); + /// println!("{tensor}"); + /// // [[true, true, true], [false, false, false]] + /// } + /// ``` + pub fn lower_equal(self, other: Self) -> Tensor { + check!(TensorCheck::binary_ops_ew("Lower_equal", &self, &other)); + Tensor::new(K::lower_equal(self.primitive, other.primitive)) + } + + /// Applies greater than `other` comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.greater_elem(3.0); + /// println!("{tensor}"); + /// // [[false, false, true], [true, true, true]] + /// } + /// ``` + pub fn greater_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::greater_elem(self.primitive, other)) + } + + /// Applies greater-equal than `other` comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.greater_equal_elem(3.0); + /// println!("{tensor}"); + /// // [[false, false, true], [true, true, true]] + /// } + /// ``` + pub fn greater_equal_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::greater_equal_elem(self.primitive, other)) + } + + /// Applies lower than `other` comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.lower_elem(3.0); + /// println!("{tensor}"); + /// // [[true, true, false], [false, false, false]] + /// } + /// ``` + pub fn lower_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::lower_elem(self.primitive, other)) + } + + /// Applies lower-equal than `other` comparison and returns a boolean tensor. + /// + /// # Arguments + /// + /// * `other` - The element to compare. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.lower_equal_elem(3.0); + /// println!("{tensor}"); + /// // [[true, true, true], [false, false, false]] + /// } + /// ``` + pub fn lower_equal_elem(self, other: E) -> Tensor { + let other = Scalar::new(other, &self.dtype()); + Tensor::new(K::lower_equal_elem(self.primitive, other)) + } + + /// Applies the argmax function along the given dimension and returns an integer tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::ones(Shape::new([2, 3, 3]), &device); + /// let tensor = tensor.argmax(1); + /// println!("{:?}", tensor.shape()); + /// // Shape { dims: [2, 1, 3] } + /// } + /// ``` + pub fn argmax(self, dim: usize) -> Tensor { + Tensor::new(K::argmax(self.primitive, dim)) + } + + /// Applies the argtopk function along the given dimension and returns an integer tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::ones(Shape::new([2, 3, 3]), &device); + /// let tensor = tensor.argtopk(1, 2); + /// println!("{:?}", tensor.shape()); + /// } + /// ``` + pub fn argtopk(self, k: usize, dim: usize) -> Tensor { + assert!(self.shape()[dim] > k); + Tensor::new(K::argtopk(self.primitive, dim, k)) + } + + /// Find the maximum value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.max(); + /// println!("{tensor}"); + /// // [9.0] + /// } + /// ``` + pub fn max(self) -> Tensor<1, K> { + Tensor::new(K::max(self.primitive)) + } + + /// Find the maximum value along the given dimension. + /// + /// Also returns the indices. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let (tensor, index) = tensor.max_dim_with_indices(0); + /// // [[5.0, 9.0, 6.0]] + /// println!("{tensor}"); + /// // [[1, 1, 1]] + /// println!("{index}"); + /// } + /// ``` + pub fn max_dim_with_indices(self, dim: I) -> (Self, Tensor) { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Max", dim)); + + let (tensor, index) = K::max_dim_with_indices(self.primitive, dim); + + let tensor = Tensor::new(tensor); + let index = Tensor::new(index); + + (tensor, index) + } + + /// Find the maximum absolute value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -7.0, 3.0], [5.0, -1.0, 6.0]], &device); + /// let tensor = tensor.max_abs(); + /// println!("{tensor}"); + /// // [7.0] + /// } + /// ``` + pub fn max_abs(self) -> Tensor<1, K> { + Tensor::new(K::max_abs(self.primitive)) + } + + /// Finds the maximum pair wise values with another tensor. + /// + /// # Arguments + /// + /// * `other` - Other tensor to find maximum elements with + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensors containing the maximum value found + /// in the input tensors. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.max_pair(tensor2); + /// println!("{tensor}"); + /// // [[2.0, 3.0, 4.0], [5.0, 9.0, 6.0]] + /// } + /// ``` + pub fn max_pair(self, other: Self) -> Self { + let mask = self.clone().lower(other.clone()); + self.mask_where(mask, other) + } + + /// Find the maximum absolute value along the given dimension. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements, + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimension will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.max_dim(0); + /// println!("{tensor}"); + /// // [[5.0, 9.0, 6.0]] + /// } + /// ``` + pub fn max_abs_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("MaxAbs", dim)); + + Tensor::new(K::max_abs_dim(self.primitive, dim)) + } + + /// Find the maximum absolute value along the given dimensions. + /// + /// # Arguments + /// + /// * `dims` - The dimensions or axes along which to aggregate the elements, + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.max_abs_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[9.0]] + /// } + /// ``` + pub fn max_abs_dims(self, dims: &[I]) -> Self { + dims.iter() + .fold(self, |tensor, &dim| tensor.max_abs_dim(dim)) + } + + /// Applies the argmin function along the given dimension and returns an integer tensor. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<3>::ones(Shape::new([2, 3, 3]), &device); + /// let tensor = tensor.argmin(1); + /// println!("{:?}", tensor.shape()); + /// // Shape { dims: [2, 1, 3] } + /// } + /// ``` + pub fn argmin(self, dim: usize) -> Tensor { + Tensor::new(K::argmin(self.primitive, dim)) + } + + /// Find the minimum value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.min(); + /// println!("{tensor}"); + /// // [-2.0] + /// } + /// ``` + pub fn min(self) -> Tensor<1, K> { + Tensor::new(K::min(self.primitive)) + } + + /// Find the minimum value along the given dimension. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimension will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.min_dim(0); + /// println!("{tensor}"); + /// // [[1.0, -2.0, 3.0]] + /// } + /// ``` + pub fn min_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Min", dim)); + Tensor::new(K::min_dim(self.primitive, dim)) + } + + /// Find the minimum value along the given dimensions. + /// + /// # Arguments + /// + /// * `dims` - The dimensions or axes along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.min_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[-2.0]] + /// } + /// ``` + pub fn min_dims(self, dims: &[I]) -> Self { + dims.iter().fold(self, |tensor, &dim| tensor.min_dim(dim)) + } + + /// Find the minimum value along the given dimension. + /// + /// Also returns the indices. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[7.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let (tensor, index) = tensor.min_dim_with_indices(0); + /// println!("{tensor}"); + /// // [[5.0, -2.0, 3.0]] + /// println!("{}", index); + /// // [[1, 0, 0]] + /// } + /// ``` + pub fn min_dim_with_indices(self, dim: I) -> (Self, Tensor) { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Min", dim)); + + let (tensor, index) = K::min_dim_with_indices(self.primitive, dim); + + let tensor = Tensor::new(tensor); + let index = Tensor::new(index); + + (tensor, index) + } + + /// Finds the minimum pair wise values with another tensor. + /// + /// # Arguments + /// + /// * `other` - Other tensor to find minimum elements with + /// + /// # Returns + /// + /// A tensor with the same shape as the input tensors containing the minimum value found + /// between each element of the two source tensors. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor1 = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor2 = Tensor::<2>::from_data([[2.0, 3.0, 4.0], [1.0, 2.0, 3.0]], &device); + /// let tensor = tensor1.min_pair(tensor2); + /// println!("{tensor}"); + /// // [[1.0, -2.0, 3.0], [1.0, 2.0, 3.0]] + /// } + pub fn min_pair(self, other: Self) -> Self { + let mask = other.clone().lower(self.clone()); + self.mask_where(mask, other) + } + + /// Clamp element wise between the given min and max values. + /// + /// # Arguments + /// + /// * `min` - The minimum value. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped between the given min and max values. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints( + /// [ + /// [1, 2, 3], + /// [4, 5, 6], + /// [7, 8, 9] + /// ], + /// &device); + /// let tensor = tensor.clamp(2, 6); + /// println!("{tensor}"); + /// // [[2, 2, 3], [4, 5, 6], [6, 6, 6]] + /// } + /// ``` + pub fn clamp(self, min: E, max: E) -> Self { + let dtype = self.dtype(); + Self::new(K::clamp( + self.primitive, + Scalar::new(min, &dtype), + Scalar::new(max, &dtype), + )) + } + + /// Clamp element wise under a minimum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `min` - The minimum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped under the given min value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints( + /// [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + /// &device); + /// let tensor = tensor.clamp_min(4); + /// println!("{tensor}"); + /// // [[4, 4, 4], [4, 5, 6], [7, 8, 9]] + /// } + /// ``` + pub fn clamp_min(self, min: E) -> Self { + let min = Scalar::new(min, &self.dtype()); + Self::new(K::clamp_min(self.primitive, min)) + } + + /// Clamp element wise over a maximum value. + /// + /// # Arguments + /// + /// * `tensor` - The tensor to clamp. + /// * `max` - The maximum value. + /// + /// # Returns + /// + /// A new tensor with the values clamped over the given max value. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Int, Tensor}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2, Int>::from_ints( + /// [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + /// &device); + /// let tensor = tensor.clamp_max(5); + /// println!("{tensor}"); + /// // [[1, 2, 3], [4, 5, 5], [5, 5, 5]] + /// } + /// ``` + pub fn clamp_max(self, max: E) -> Self { + let max = Scalar::new(max, &self.dtype()); + Self::new(K::clamp_max(self.primitive, max)) + } + + /// Computes the cumulative minimum of elements along the given *dimension* or *axis*. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to compute the cumulative minimum. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[3.0, 5.0, 2.0], [4.0, 1.0, 6.0]], &device); + /// let result = tensor.clone().cummin(0); + /// println!("{result}"); + /// // [[3.0, 5.0, 2.0], [3.0, 1.0, 2.0]] + /// let result = tensor.cummin(1); + /// println!("{result}"); + /// // [[3.0, 3.0, 2.0], [4.0, 1.0, 1.0]] + /// } + /// ``` + pub fn cummin(self, dim: usize) -> Self { + check!(TensorCheck::aggregate_dim::("CumMin", dim)); + Self::new(K::cummin(self.primitive, dim)) + } + + /// Computes the cumulative maximum of elements along the given *dimension* or *axis*. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to compute the cumulative maximum. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[3.0, 1.0, 2.0], [4.0, 5.0, 2.0]], &device); + /// let result = tensor.clone().cummax(0); + /// println!("{result}"); + /// // [[3.0, 1.0, 2.0], [4.0, 5.0, 2.0]] + /// let result = tensor.cummax(1); + /// println!("{result}"); + /// // [[3.0, 3.0, 3.0], [4.0, 5.0, 5.0]] + /// } + /// ``` + pub fn cummax(self, dim: usize) -> Self { + check!(TensorCheck::aggregate_dim::("CumMax", dim)); + Self::new(K::cummax(self.primitive, dim)) + } + /// Find the maximum value along the given dimension. + /// + /// # Arguments + /// + /// * `dim` - The dimension or axis along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimension will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.max_dim(0); + /// println!("{tensor}"); + /// // [[5.0, 9.0, 6.0]] + /// } + /// ``` + pub fn max_dim(self, dim: I) -> Self { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::aggregate_dim::("Max", dim)); + Tensor::new(K::max_dim(self.primitive, dim)) + } + + /// Find the maximum value along the given dimensions. + /// + /// # Arguments + /// + /// * `dims` - The dimensions or axis along which to aggregate the elements; + /// supports negative indexing. + /// + /// # Returns + /// + /// The returned tensor will have the same rank, + /// but the aggregated dimensions will have size 1. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[1.0, -2.0, 3.0], [5.0, 9.0, 6.0]], &device); + /// let tensor = tensor.max_dims(&[0, 1]); + /// println!("{tensor}"); + /// // [[9.0]] + /// } + /// ``` + pub fn max_dims(self, dims: &[I]) -> Self { + dims.iter().fold(self, |tensor, &dim| tensor.max_dim(dim)) + } +} diff --git a/crates/burn-tensor/src/tensor/api/pad.rs b/crates/burn-tensor/src/tensor/api/pad.rs new file mode 100644 index 0000000..8ef2ea0 --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/pad.rs @@ -0,0 +1,345 @@ +use alloc::vec::Vec; +use core::ops::Range; + +use crate::{ElementConversion, Tensor, kind::Numeric, ops::PadMode}; + +/// Trait for types that can be used as padding specifications. +/// +/// Padding is specified as `(before, after)` pairs per dimension, returned as a +/// fixed-size array `[(usize, usize); D]`. If fewer pairs than dimensions are provided, +/// they apply to the **last** N dimensions (earlier dimensions are left unpadded). +pub trait IntoPadding { + /// Converts into a fixed-size array of `(before, after)` padding pairs. + fn into_padding(self) -> [(usize, usize); D]; +} + +impl IntoPadding for [(usize, usize); N] { + fn into_padding(self) -> [(usize, usize); D] { + assert!( + N <= D, + "Padding has {} pairs but tensor only has {} dimensions", + N, + D + ); + let mut result = [(0usize, 0usize); D]; + let offset = D - N; + for (i, pair) in self.into_iter().enumerate() { + result[offset + i] = pair; + } + result + } +} + +/// Backward-compatible: `(left, right, top, bottom)` maps to last 2 dimensions. +/// +/// Equivalent to `[(top, bottom), (left, right)]`. +impl IntoPadding for (usize, usize, usize, usize) { + fn into_padding(self) -> [(usize, usize); D] { + let (left, right, top, bottom) = self; + let mut result = [(0usize, 0usize); D]; + result[D - 2] = (top, bottom); + result[D - 1] = (left, right); + result + } +} + +impl IntoPadding for &[(usize, usize)] { + fn into_padding(self) -> [(usize, usize); D] { + assert!( + self.len() <= D, + "Padding has {} pairs but tensor only has {} dimensions", + self.len(), + D + ); + let mut result = [(0usize, 0usize); D]; + let offset = D - self.len(); + for (i, &pair) in self.iter().enumerate() { + result[offset + i] = pair; + } + result + } +} + +impl IntoPadding for Vec<(usize, usize)> { + fn into_padding(self) -> [(usize, usize); D] { + assert!( + self.len() <= D, + "Padding has {} pairs but tensor only has {} dimensions", + self.len(), + D + ); + let mut result = [(0usize, 0usize); D]; + let offset = D - self.len(); + for (i, pair) in self.into_iter().enumerate() { + result[offset + i] = pair; + } + result + } +} + +/// Helper to build a range array for slice_assign, selecting a portion of one dimension. +fn build_slice_ranges( + dims: [usize; D], + target_dim: usize, + start: usize, + len: usize, +) -> [Range; D] { + dims.iter() + .enumerate() + .map(|(i, &size)| { + if i == target_dim { + start..start + len + } else { + 0..size + } + }) + .collect::>>() + .try_into() + .unwrap() +} + +impl Tensor +where + K: Numeric, +{ + /// Pads the tensor using the specified padding mode. + /// + /// Padding is specified as `(before, after)` pairs. If fewer pairs than tensor dimensions + /// are provided, they apply to the **last** N dimensions (unspecified leading dimensions + /// are left unpadded). + /// + /// For backward compatibility, a `(left, right, top, bottom)` tuple is also accepted, + /// which pads the last two dimensions. + /// + /// # Arguments + /// + /// * `padding` - Padding specification. Accepts: + /// - `[(before, after); N]` fixed-size array of pairs (N <= D) + /// - `&[(before, after)]` slice of pairs per dimension + /// - `Vec<(before, after)>` vector of pairs + /// - `(left, right, top, bottom)` tuple for last-2-dim backward compatibility + /// * `mode` - The padding mode: `Constant(value)`, `Reflect`, or `Edge`. + /// + /// # Returns + /// + /// A new tensor with the specified padding applied. + /// + /// # Panics + /// + /// - Panics if more padding pairs are provided than tensor dimensions. + /// - `Reflect` mode panics if padding exceeds `dimension_size - 1`. + /// - `Edge` mode panics if padding is applied to a zero-sized dimension. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Shape}; + /// use burn_tensor::ops::PadMode; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<2>::from_data([[12.0, -2.0, 3.0], [5.0, 3.0, 6.0]], &device); + /// + /// // Constant padding with value 0.0 (backward-compatible tuple) + /// let padded = tensor.clone().pad((1, 1, 1, 1), PadMode::Constant(0.0)); + /// + /// // Pad arbitrary dimensions with slice of (before, after) pairs + /// let padded = tensor.clone().pad([(1, 1), (2, 2)], PadMode::Constant(0.0)); + /// + /// // Pad only the last dimension + /// let padded = tensor.pad([(1, 1)], PadMode::Reflect); + /// } + /// ``` + pub fn pad(self, padding: impl IntoPadding, mode: impl Into) -> Self { + let pairs = padding.into_padding(); + match mode.into() { + PadMode::Constant(value) => pad_constant(self, &pairs, value), + PadMode::Reflect => pad_reflect(self, &pairs), + PadMode::Edge => pad_edge(self, &pairs), + } + } +} + +/// Pad with a constant value. +fn pad_constant( + tensor: Tensor, + padding: &[(usize, usize); D], + value: E, +) -> Tensor +where + K: Numeric, + E: ElementConversion, +{ + let mut padded_dims: [usize; D] = tensor.dims(); + + for (i, &(before, after)) in padding.iter().enumerate() { + padded_dims[i] += before + after; + } + + let ranges: [Range; D] = padded_dims + .iter() + .enumerate() + .map(|(i, &dim)| { + let (before, after) = padding[i]; + before..dim - after + }) + .collect::>>() + .try_into() + .unwrap(); + + let padded_tensor = Tensor::full(padded_dims, value, &tensor.device()); + + padded_tensor.slice_assign(ranges, tensor) +} + +/// Pad using reflection at the boundaries (excluding edge values). +/// +/// For ONNX "reflect" mode: mirrors from index 1, not index 0. +/// Example: `[1, 2, 3, 4]` with left padding 2 becomes `[3, 2, 1, 2, 3, 4]` +fn pad_reflect( + tensor: Tensor, + padding: &[(usize, usize); D], +) -> Tensor +where + K: Numeric, +{ + let dims = tensor.dims(); + + for (i, &(before, after)) in padding.iter().enumerate() { + if before > 0 || after > 0 { + assert!( + before < dims[i] && after < dims[i], + "Reflect padding ({}, {}) must be less than dimension {} size ({})", + before, + after, + i, + dims[i] + ); + } + } + + let mut result = tensor; + + for (i, &(before, after)) in padding.iter().enumerate() { + if before > 0 || after > 0 { + result = pad_reflect_dim(result, i, before, after); + } + } + + result +} + +/// Helper to pad a single dimension using reflection. +fn pad_reflect_dim( + tensor: Tensor, + dim: usize, + pad_before: usize, + pad_after: usize, +) -> Tensor +where + K: Numeric, +{ + let dims = tensor.dims(); + let dim_size = dims[dim]; + + // Calculate output dimensions + let mut output_dims = dims; + output_dims[dim] += pad_before + pad_after; + + // Create output tensor and place original in the center + let output = Tensor::zeros(output_dims, &tensor.device()); + let original_range = build_slice_ranges(output_dims, dim, pad_before, dim_size); + let mut output = output.slice_assign(original_range, tensor.clone()); + + // Assign reflected "before" padding (e.g., top or left) + // Reflect excludes the edge, so we take indices [1..pad_before+1] and flip + if pad_before > 0 { + let before_slice = tensor.clone().narrow(dim, 1, pad_before); + let before_flipped = before_slice.flip([dim as isize]); + let before_range = build_slice_ranges(output_dims, dim, 0, pad_before); + output = output.slice_assign(before_range, before_flipped); + } + + // Assign reflected "after" padding (e.g., bottom or right) + // Take indices [dim_size - pad_after - 1..dim_size - 1] and flip + if pad_after > 0 { + let start = dim_size - pad_after - 1; + let after_slice = tensor.narrow(dim, start, pad_after); + let after_flipped = after_slice.flip([dim as isize]); + let after_range = build_slice_ranges(output_dims, dim, pad_before + dim_size, pad_after); + output = output.slice_assign(after_range, after_flipped); + } + + output +} + +/// Pad by replicating edge values. +/// +/// Example: `[1, 2, 3, 4]` with left padding 2 becomes `[1, 1, 1, 2, 3, 4]` +fn pad_edge(tensor: Tensor, padding: &[(usize, usize); D]) -> Tensor +where + K: Numeric, +{ + let dims = tensor.dims(); + + for (i, &(before, after)) in padding.iter().enumerate() { + if before > 0 || after > 0 { + assert!( + dims[i] > 0, + "Cannot apply edge padding to zero-sized dimension {}", + i + ); + } + } + + let mut result = tensor; + + for (i, &(before, after)) in padding.iter().enumerate() { + if before > 0 || after > 0 { + result = pad_edge_dim(result, i, before, after); + } + } + + result +} + +/// Helper to pad a single dimension by replicating edge values. +fn pad_edge_dim( + tensor: Tensor, + dim: usize, + pad_before: usize, + pad_after: usize, +) -> Tensor +where + K: Numeric, +{ + let dims = tensor.dims(); + let dim_size = dims[dim]; + + // Calculate output dimensions + let mut output_dims = dims; + output_dims[dim] += pad_before + pad_after; + + // Create output tensor and place original in the center + let output = Tensor::zeros(output_dims, &tensor.device()); + let original_range = build_slice_ranges(output_dims, dim, pad_before, dim_size); + let mut output = output.slice_assign(original_range, tensor.clone()); + + // Assign "before" padding by repeating the first element + if pad_before > 0 { + let first_slice = tensor.clone().narrow(dim, 0, 1); + let before_pad = first_slice.repeat_dim(dim, pad_before); + let before_range = build_slice_ranges(output_dims, dim, 0, pad_before); + output = output.slice_assign(before_range, before_pad); + } + + // Assign "after" padding by repeating the last element + if pad_after > 0 { + let last_slice = tensor.narrow(dim, dim_size - 1, 1); + let after_pad = last_slice.repeat_dim(dim, pad_after); + let after_range = build_slice_ranges(output_dims, dim, pad_before + dim_size, pad_after); + output = output.slice_assign(after_range, after_pad); + } + + output +} diff --git a/crates/burn-tensor/src/tensor/api/take.rs b/crates/burn-tensor/src/tensor/api/take.rs new file mode 100644 index 0000000..de33a4a --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/take.rs @@ -0,0 +1,96 @@ +use crate::{AsIndex, Int, Tensor, check, check::TensorCheck, kind::Basic}; +use alloc::vec::Vec; + +impl Tensor +where + K: Basic, +{ + /// Takes elements from the tensor along the given dimension using indices of any dimensionality. + /// + /// This behaves like numpy's take function. When indices is multi-dimensional, + /// the output shape will be: input.shape\[:dim\] + indices.shape + input.shape\[dim+1:\] + /// + /// # Arguments + /// + /// * `dim` - The dimension along which to select elements. Supports negative indexing. + /// * `indices` - The indices of elements to select. Can be any dimensionality. + /// Must be valid indices in the range [0, dim_size). + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::{Tensor, Int}; + /// + /// fn example() { + /// let device = Default::default(); + /// + /// // Example with 1D indices + /// let tensor = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device); + /// let indices = Tensor::<1, Int>::from_data([2, 0, 1], &device); + /// let result: Tensor<2> = tensor.clone().take::<1, 2>(-1, indices); // -1 refers to last dimension + /// println!("{result}"); + /// // [[3.0, 1.0, 2.0], [6.0, 4.0, 5.0]] + /// + /// // Example with 2D indices - output will have +1 dimension (2D -> 3D) + /// let indices_2d = Tensor::<2, Int>::from_data([[0, 2], [1, 0]], &device); + /// let result: Tensor<3> = tensor.take::<2, 3>(1, indices_2d); + /// println!("{result}"); + /// // [[[1.0, 3.0], [2.0, 1.0]], [[4.0, 6.0], [5.0, 4.0]]] + /// } + /// ``` + pub fn take( + self, + dim: impl AsIndex, + indices: Tensor, + ) -> Tensor { + let dim = dim.expect_dim_index(D); + check!(TensorCheck::take::(dim)); + + // Store the indices shape for reshaping later + let indices_shape = indices.shape(); + let indices_dims = indices_shape.clone(); + + // Flatten indices to 1D for processing + let indices_flat = indices.reshape([indices_shape.num_elements()]); + + // Perform the selection with the flattened indices + let selected = self.select(dim, indices_flat); + + // Build the output shape + // Output shape = input.shape[:dim] + indices.shape + input.shape[dim+1:] + let selected_shape = selected.shape(); + let mut new_shape = Vec::with_capacity(DO); + + // Add dimensions before the selected dimension + for i in 0..dim { + new_shape.push(selected_shape[i]); + } + + // Add all indices dimensions + for &idx_dim in indices_dims.iter() { + new_shape.push(idx_dim); + } + + // Add dimensions after the selected dimension + for i in (dim + 1)..D { + new_shape.push(selected_shape[i]); + } + + // Verify we have the correct number of dimensions + assert_eq!( + new_shape.len(), + DO, + "Internal error: shape calculation resulted in {} dims but expected {}", + new_shape.len(), + DO + ); + + // Convert to fixed-size array for reshape + let mut shape_array = [0; DO]; + for (i, &s) in new_shape.iter().enumerate() { + shape_array[i] = s; + } + + selected.reshape(shape_array) + } +} diff --git a/crates/burn-tensor/src/tensor/api/transaction.rs b/crates/burn-tensor/src/tensor/api/transaction.rs new file mode 100644 index 0000000..ddb31bb --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/transaction.rs @@ -0,0 +1,86 @@ +use super::Tensor; +use crate::{ExecutionError, TensorData}; +use alloc::vec::Vec; +use burn_backend::ops::TransactionPrimitive; +use burn_dispatch::Dispatch; + +/// A transaction can [read](Self::register) multiple tensors at once with a single operation improving +/// compute utilization with optimized laziness. +/// +/// # Example +/// +/// ```rust,ignore +/// let [output_data, loss_data, targets_data] = Transaction::default() +/// .register(output) +/// .register(loss) +/// .register(targets) +/// .execute() +/// .try_into() +/// .expect("Correct amount of tensor data"); +/// ``` +pub struct Transaction { + opaque: transaction_opaque::Opaque, +} + +burn_std::obfuscate!( + type: TransactionPrimitive, + module: transaction_opaque, + derives: [Send, Sync], +); + +impl Default for Transaction { + fn default() -> Self { + Self::from_op(TransactionPrimitive::::default()) + } +} + +impl Transaction { + /// Crate-internal constructor wrapping a dispatch-level transaction. + pub(crate) fn from_op(op: TransactionPrimitive) -> Self { + Self { + opaque: transaction_opaque::Opaque::new(op), + } + } + + /// Crate-internal mutable borrow of the underlying transaction primitive. + pub(crate) fn as_op_mut(&mut self) -> &mut TransactionPrimitive { + self.opaque.as_mut() + } + + /// Crate-internal owning extraction of the underlying transaction primitive. + pub(crate) fn into_op(self) -> TransactionPrimitive { + self.opaque.into_inner() + } + + /// Add a [tensor](Tensor) to the transaction to be read. + pub fn register( + mut self, + tensor: Tensor, + ) -> Self { + K::register_transaction(self.as_op_mut(), tensor.primitive); + self + } + + /// Executes the transaction synchronously and returns the [data](TensorData) in the same order + /// in which they were [registered](Self::register). + pub fn execute(self) -> Vec { + burn_std::future::block_on(self.execute_async()) + .expect("Error while reading data: use `try_execute` to handle error at runtime") + } + + /// Executes the transaction synchronously and returns the [data](TensorData) in the same + /// order in which they were [registered](Self::register). + /// + /// # Returns + /// + /// Any error that might have occurred since the last time the device was synchronized. + pub fn try_execute(self) -> Result, ExecutionError> { + burn_std::future::block_on(self.execute_async()) + } + + /// Executes the transaction asynchronously and returns the [data](TensorData) in the same order + /// in which they were [registered](Self::register). + pub async fn execute_async(self) -> Result, ExecutionError> { + self.into_op().execute_async().await + } +} diff --git a/crates/burn-tensor/src/tensor/api/trunc.rs b/crates/burn-tensor/src/tensor/api/trunc.rs new file mode 100644 index 0000000..139577f --- /dev/null +++ b/crates/burn-tensor/src/tensor/api/trunc.rs @@ -0,0 +1,43 @@ +use burn_backend::ops::FloatTensorOps; +use burn_dispatch::Dispatch; + +use crate::{Float, Tensor, ops::BridgeTensor}; + +impl Tensor { + /// Truncates the tensor element-wise, rounding toward zero. + /// + /// This function returns a new tensor with the same shape as the input tensor, + /// where each element is truncated toward zero. For positive values, this is + /// equivalent to floor, and for negative values, it's equivalent to ceil. + /// + /// # Special Cases (IEEE 754 compliant) + /// + /// - `trunc(±0)` returns ±0 (preserves sign of zero) + /// - `trunc(±∞)` returns ±∞ + /// - `trunc(NaN)` returns NaN + /// + /// # Returns + /// + /// A tensor with the same shape where each element has been truncated toward zero. + /// + /// # Example + /// + /// ```rust + /// use burn_tensor::Tensor; + /// + /// fn example() { + /// let device = Default::default(); + /// let tensor = Tensor::<1>::from_data([2.3, -1.7, 0.5, -0.5, 3.9], &device); + /// let truncated = tensor.trunc(); + /// + /// // Result: [2.0, -1.0, 0.0, -0.0, 3.0] + /// } + /// ``` + pub fn trunc(self) -> Self { + Self::new(trunc_impl(self.primitive)) + } +} + +fn trunc_impl(p: BridgeTensor) -> BridgeTensor { + BridgeTensor::float(Dispatch::float_trunc(p.into_float())) +} diff --git a/crates/burn-tensor/src/tensor/distributed.rs b/crates/burn-tensor/src/tensor/distributed.rs new file mode 100644 index 0000000..6c91476 --- /dev/null +++ b/crates/burn-tensor/src/tensor/distributed.rs @@ -0,0 +1,92 @@ +//! Distributed execution utilities. +//! +//! The core component of this module is [`DistributedContext`], which manages +//! the lifecycle of distributed synchronization clients. + +use alloc::vec::Vec; +use burn_backend::TensorMetadata; +use burn_backend::{DeviceOps, distributed::DistributedOps}; +use burn_dispatch::{Dispatch, DispatchTensor}; +pub use burn_std::distributed::*; + +use crate::{Device, Tensor, ops::BridgeTensor}; + +/// This structure acts as a resource handle for multi-device synchronization. +/// +/// Spawning this context automatically initializes the underlying distributed communication +/// servers, while dropping it guarantees a clean and safe teardown of all network resources. +#[derive(Debug)] +pub struct DistributedContext { + devices: Vec, +} + +impl DistributedContext { + /// Starts a distributed communication server for the provided devices. + /// + /// # Arguments + /// + /// * `devices` - The collection of compute devices participating in the distributed operations. + /// * `config` - Parameter aggregation settings, such as global reduction strategies (`Mean`, `Sum`, etc.). + pub fn init(devices: Vec, config: DistributedConfig) -> Self { + let dispatch_devices = devices + .iter() + .map(|d| d.as_dispatch().clone()) + .collect::>(); + Dispatch::start_communication_server(&dispatch_devices, config); + + Self { devices } + } +} + +impl Drop for DistributedContext { + fn drop(&mut self) { + if !self.devices.is_empty() { + Dispatch::close_communication_server(self.devices[0].as_dispatch()); + } + } +} + +/// A tensor handle used for a collective operation, that is not yet valid for use. +/// We must ensure collective operations are completed before accessing the underlying data. +#[derive(new, Clone)] +pub struct CollectiveTensor { + handle: DispatchTensor, +} + +impl CollectiveTensor { + /// Synchronizes the collective operation and returns a valid tensor handle. + pub fn resolve(self) -> Tensor { + Dispatch::sync_collective(&self.handle.device()); + Tensor::new(BridgeTensor::float(self.handle)) + } + + /// Returns the tensor handle without synchronizing. + /// + /// # Safety + /// + /// The caller must ensure that `sync_collective()` is called before + /// the returned handle is used in any computation. + pub unsafe fn assume_resolved(self) -> Tensor { + Tensor::new(BridgeTensor::float(self.handle)) + } +} + +/// Performs an all_reduce operation on the input tensor. +/// +/// # Arguments +/// - `input`: The input tensor. +/// - `op`: The aggregation operation. +/// - `device_ids`: The list of all devices with which to `all_reduce` +/// +/// # Returns +/// A [CollectiveTensor] containing the handle of the result. +pub fn all_reduce( + input: Tensor, + op: ReduceOperation, + device_ids: Vec, +) -> CollectiveTensor { + let device_ids = device_ids.iter().map(|d| d.as_dispatch().id()).collect(); + let collective = Dispatch::all_reduce(input.primitive.into_float(), op, device_ids); + // Safety: we call `assume_resolved` only to wrap it in `burn_tensor`'s [CollectiveTensor]. + CollectiveTensor::new(unsafe { collective.assume_resolved() }) +} diff --git a/crates/burn-tensor/src/tensor/grid/affine_grid.rs b/crates/burn-tensor/src/tensor/grid/affine_grid.rs new file mode 100644 index 0000000..382da64 --- /dev/null +++ b/crates/burn-tensor/src/tensor/grid/affine_grid.rs @@ -0,0 +1,59 @@ +use crate::ElementConversion; +use crate::s; +use crate::tensor::{Int, Tensor}; +use alloc::vec; + +/// Generate a tensor with homogeonous coordinates of each element's +/// transformed location +/// +/// +/// See: +/// - [torch.nn.functional.affine_grid](https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.affine_grid.html) +/// +/// * `transform` - Transformation with shape (batch_size, 2, 3) +/// * `dims` - dimensions as (batch_size, channels, height, width) +/// +/// # Returns +/// +/// Tensor with shape (batch_size, height, width, 2), where dim 2 is (x, y) +/// All coordinates are broadcast on the batch dim +pub fn affine_grid_2d(transform: Tensor<3>, dims: [usize; 4]) -> Tensor<4> { + let [batch_size, _c, height, width] = dims; + + let device = &transform.device(); + + let x = Tensor::<1, Int>::arange(0..width as i64, device) + .reshape([1, width]) + .expand([height, width]); + let y = Tensor::<1, Int>::arange(0..height as i64, device) + .reshape([height, 1]) + .expand([height, width]); + + // from ints (0..(width-1)) and (0..(height-1)), to (-1.0..1.0) + let x = x + .float() + .div_scalar(((width - 1) as f32 / 2.0).elem::()) + .sub_scalar((1_f32).elem::()); + let y = y + .float() + .div_scalar(((height - 1) as f32 / 2.0).elem::()) + .sub_scalar((1_f32).elem::()); + + // Broadcast to batch dimension + let x = x.unsqueeze_dim::<3>(0).expand([batch_size, height, width]); // [B, H, W] + let y = y.unsqueeze_dim::<3>(0).expand([batch_size, height, width]); // [B, H, W] + + // Apply affine transform + let a_11 = transform.clone().slice(s![.., 0, 0]); + let a_12 = transform.clone().slice(s![.., 0, 1]); + let trans_x = transform.clone().slice(s![.., 0, 2]); + + let a_21 = transform.clone().slice(s![.., 1, 0]); + let a_22 = transform.clone().slice(s![.., 1, 1]); + let trans_y = transform.slice(s![.., 1, 2]); + + let grid_x = a_11.mul(x.clone()).add(a_12.mul(y.clone())).add(trans_x); + let grid_y = a_21.mul(x).add(a_22.mul(y)).add(trans_y); + + Tensor::stack(vec![grid_x, grid_y], 3) +} diff --git a/crates/burn-tensor/src/tensor/grid/meshgrid.rs b/crates/burn-tensor/src/tensor/grid/meshgrid.rs new file mode 100644 index 0000000..affbb8e --- /dev/null +++ b/crates/burn-tensor/src/tensor/grid/meshgrid.rs @@ -0,0 +1,104 @@ +use crate::kind::Basic; +use crate::tensor::Tensor; +use crate::tensor::grid::{GridIndexing, GridOptions, GridSparsity, IndexPos}; +use alloc::vec::Vec; + +/// Return a collection of coordinate matrices for coordinate vectors. +/// +/// Takes N 1D tensors and returns N tensors where each tensor represents the coordinates +/// in one dimension across an N-dimensional grid. +/// +/// Based upon `options.sparse`, the generated coordinate tensors can either be `Sparse` or `Dense`: +/// * In `Sparse` mode, output tensors will have shape 1 everywhere except their cardinal dimension. +/// * In `Dense` mode, output tensors will be expanded to the full grid shape. +/// +/// Based upon `options.indexing`, the generated coordinate tensors will use either: +/// * `Matrix` indexing, where dimensions are in the same order as their cardinality. +/// * `Cartesian` indexing; where the first two dimensions are swapped. +/// +/// See: +/// - [numpy.meshgrid](https://numpy.org/doc/stable/reference/generated/numpy.meshgrid.html) +/// - [torch.meshgrid](https://pytorch.org/docs/stable/generated/torch.meshgrid.html) +/// +/// # Arguments +/// +/// * `tensors` - A slice of 1D tensors +/// * `options` - the options. +/// +/// # Returns +/// +/// A vector of N N-dimensional tensors representing the grid coordinates. +pub fn meshgrid(tensors: &[Tensor<1, K>; N], options: O) -> [Tensor; N] +where + K: Basic, + O: Into, +{ + let options = options.into(); + let swap_dims = options.indexing == GridIndexing::Cartesian && N > 1; + let dense = options.sparsity == GridSparsity::Dense; + + let grid_shape: [usize; N] = tensors + .iter() + .map(|t| t.dims()[0]) + .collect::>() + .try_into() + .unwrap(); + + tensors + .iter() + .enumerate() + .map(|(i, tensor)| { + let mut coord_tensor_shape = [1; N]; + coord_tensor_shape[i] = grid_shape[i]; + + // Reshape the tensor to have singleton dimensions in all but the i-th dimension + let mut tensor = tensor.clone().reshape(coord_tensor_shape); + + if dense { + tensor = tensor.expand(grid_shape); + } + if swap_dims { + tensor = tensor.swap_dims(0, 1); + } + + tensor + }) + .collect::>() + .try_into() + .unwrap() +} + +/// Return a coordinate matrix for a given set of 1D coordinate tensors. +/// +/// Equivalent to stacking a dense matrix `meshgrid`, +/// where the stack is along the first or last dimension. +/// +/// # Arguments +/// +/// * `tensors`: A slice of 1D tensors. +/// * `index_pos`: The position of the index in the output tensor. +/// +/// # Returns +/// +/// A tensor of either ``(N, ..., |T[i]|, ...)`` or ``(..., |T[i]|, ..., N)``, +/// of coordinates, indexed on the first or last dimension. +pub fn meshgrid_stack( + tensors: &[Tensor<1, K>; D], + index_pos: IndexPos, +) -> Tensor +where + K: Basic, +{ + assert_eq!(D2, D + 1, "D2 ({D2}) != D ({D}) + 1"); + + let xs: Vec> = meshgrid(tensors, GridOptions::default()) + .into_iter() + .collect(); + + let dim = match index_pos { + IndexPos::First => 0, + IndexPos::Last => D, + }; + + Tensor::stack(xs, dim) +} diff --git a/crates/burn-tensor/src/tensor/grid/mod.rs b/crates/burn-tensor/src/tensor/grid/mod.rs new file mode 100644 index 0000000..3750962 --- /dev/null +++ b/crates/burn-tensor/src/tensor/grid/mod.rs @@ -0,0 +1,68 @@ +mod affine_grid; +mod meshgrid; + +pub use meshgrid::*; + +pub use affine_grid::*; + +/// Enum to specify index cardinal layout. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridIndexing { + /// Dimensions are in the same order as the cardinality of the inputs. + /// Equivalent to "ij" indexing in NumPy and PyTorch. + #[default] + Matrix, + + /// The same as Matrix, but the first two dimensions are swapped. + /// Equivalent to "xy" indexing in NumPy and PyTorch. + Cartesian, +} + +/// Enum to specify grid sparsity mode. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridSparsity { + /// The grid is fully expanded to the full cartesian product shape. + #[default] + Dense, + + /// The grid is sparse, expanded only at the cardinal dimensions. + Sparse, +} + +/// Grid policy options. +#[derive(new, Default, Debug, Copy, Clone)] +pub struct GridOptions { + /// Indexing mode. + pub indexing: GridIndexing, + + /// Sparsity mode. + pub sparsity: GridSparsity, +} + +impl From for GridOptions { + fn from(value: GridIndexing) -> Self { + Self { + indexing: value, + ..Default::default() + } + } +} +impl From for GridOptions { + fn from(value: GridSparsity) -> Self { + Self { + sparsity: value, + ..Default::default() + } + } +} + +/// Enum to specify the index dimension position. +#[derive(Default, Debug, Copy, Clone)] +pub enum IndexPos { + /// The index is in the first dimension. + #[default] + First, + + /// The index is in the last dimension. + Last, +} diff --git a/crates/burn-tensor/src/tensor/kind.rs b/crates/burn-tensor/src/tensor/kind.rs new file mode 100644 index 0000000..fa50f56 --- /dev/null +++ b/crates/burn-tensor/src/tensor/kind.rs @@ -0,0 +1,33 @@ +// Sealed traits, limited to Bool, Float and Int types which implement the pub(crate) traits. +#![allow(private_bounds)] + +#[cfg(feature = "extension")] +pub use crate::bridge::BridgeTensor; + +pub use crate::bridge::{Bool, Float, Int, Kind, TensorKind}; + +/// The base trait for any tensor kind. +pub trait Basic: crate::ops::BasicOps {} +impl Basic for K {} + +/// Kinds that support numeric operations. +pub trait Numeric: Basic + crate::ops::Numeric {} +impl Numeric for K {} + +/// Kinds that support ordered operations. +pub trait Ordered: Numeric + crate::ops::Ordered {} +impl Ordered for K {} + +/// Kinds that support float math operations. +pub trait FloatMath: Numeric + crate::ops::FloatMathOps {} +impl FloatMath for K {} + +/// Kinds that support transaction operations. +pub trait Transaction: Basic + crate::ops::TransactionOp {} +impl Transaction for K {} + +/// Kinds that support autodiff operations. +// #[cfg(feature = "autodiff")] +pub trait Autodiff: Basic + crate::ops::BasicAutodiffOps {} +// #[cfg(feature = "autodiff")] +impl Autodiff for K {} diff --git a/crates/burn-tensor/src/tensor/linalg/cosine_similarity.rs b/crates/burn-tensor/src/tensor/linalg/cosine_similarity.rs new file mode 100644 index 0000000..4e09feb --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/cosine_similarity.rs @@ -0,0 +1,51 @@ +use burn_std::FloatDType; + +use crate::tensor::Tensor; + +use super::l2_norm; + +/// Computes the cosine similarity between two tensors along a specified dimension. +/// +/// Calculates the cosine of the angle between inputs as their dot product divided +/// by the product of their L2 norms. +/// +/// # Arguments +/// +/// * `x1` - First input tensor +/// * `x2` - Second input tensor +/// * `dim` - Dimension along which to compute the similarity +/// (negative indices allowed: -1 for last dimension) +/// * `eps` - Small value to avoid division by zero (default: dtype's smallest positive normal) +/// +/// # Returns +/// +/// Tensor containing the cosine similarity between x1 and x2 +pub fn cosine_similarity( + x1: Tensor, + x2: Tensor, + dim: i32, + eps: Option, +) -> Tensor { + let eps = eps.unwrap_or_else(|| { + x1.dtype() + .finfo() + .unwrap_or(FloatDType::F32.finfo()) + .min_positive + }); + + // Convert negative dimension to positive + let dim_idx = if dim < 0 { D as i32 + dim } else { dim } as usize; + + // Compute dot product: sum(x1 * x2) along the specified dimension + let dot_product = (x1.clone() * x2.clone()).sum_dim(dim_idx); + + // Compute L2 norms: ||x1|| and ||x2|| + let norm_x1 = l2_norm(x1, dim_idx); + let norm_x2 = l2_norm(x2, dim_idx); + + // Calculate the denominator (product of the norms) with epsilon to avoid division by zero + let denominator = norm_x1.clamp_min(eps) * norm_x2.clamp_min(eps); + + // Return the cosine similarity (dot product divided by the product of norms) + dot_product / denominator +} diff --git a/crates/burn-tensor/src/tensor/linalg/det.rs b/crates/burn-tensor/src/tensor/linalg/det.rs new file mode 100644 index 0000000..d49ca31 --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/det.rs @@ -0,0 +1,179 @@ +use crate::check::TensorCheck; +use crate::{Tensor, check, linalg}; +use burn_std::{DType, FloatDType}; +#[allow(unused_imports)] +use num_traits::float::Float; + +/// Computes the determinant on the last two dimensions of the input tensor. +/// +/// # Arguments +/// - `tensor` - The input tensor of shape `[..., N, N]`. +/// +/// # Returns +/// - The determinant tensor of shape `[...]` where its rank is less than the +/// input tensor's rank by two. +/// +/// # Generic Parameters +/// - `D`: The rank of the input tensor. +/// - `D1`: Must be set to `D - 1`. +/// - `D2`: Must be set to `D - 2`. +/// +/// # Panics +/// This function will panic if: +/// - The generic parameters do not satisfy `D - 1 == D1`. +/// - The generic parameters do not satisfy `D - 2 == D2`. +/// - The input tensor rank `D` is less than 3. +/// - The last two dimensions of the input tensor are not equal. +/// - The input is a quantized tensor with dtype `DType::QFloat`. +/// +/// # Performance Note +/// The determinant for 1 by 1, 2 by 2, and 3 by 3 matrices are computed using closed-form +/// expressions. For larger matrices (4 by 4 or larger), the determinant function relies on +/// the LU decomposition function under the hood,which is not fully optimized. It will not be +/// as fast as highly tuned specialized libraries, especially for very large matrices or large +/// batch sizes. +/// +/// # Numerical Behavior +/// - If the input tensors have types F16 or BF16, then they are internally upcast to +/// F32 to perform the computations and cast back to the original data type (F16 or BF16) +/// right before the function returns. +/// - In this case, if the determinant values fall outside of the original data type's +/// range, then the cast-back will underflow to zero. +/// +/// # Example +/// ```rust,ignore +/// use burn::tensor::Tensor; +/// use burn::tensor::linalg; +/// +/// fn example() { +/// let device = Default::default(); +/// let tensor = Tensor::<3>::from_data([[[4.0, 3.0], [6.0, 3.0]]], &device); +/// +/// // Compute determinant +/// let result = linalg::det::(tensor); +/// +/// // Expected Output: +/// // result: [-6.0] +/// } +/// +/// fn example2() { +/// let device = Default::default(); +/// let tensor = Tensor::<3>::from_data( +/// [ +/// [[1.0, 2.0], [3.0, 4.0]], // det = -2 +/// [[2.0, 0.0], [0.0, 3.0]], // det = 6 +/// [[5.0, 6.0], [7.0, 8.0]], // det = -2 +/// ], +/// &device, +/// ); +/// +/// // Compute determinant +/// let result = linalg::det::(tensor); +/// +/// // Expected Output: +/// // result: [-2.0, 6.0, -2.0] +/// } +/// ``` +pub fn det(mut tensor: Tensor) -> Tensor { + // Check whether input tensor has valid shape to compute determinant + let dims = tensor.dims(); + let original_dtype = tensor.dtype(); + check!(TensorCheck::det::(dims, original_dtype)); + + // Upcast f16 and bf16 to f32 + let needs_upcast = original_dtype == DType::F16 || original_dtype == DType::BF16; + let working_float_dtype: FloatDType; + if needs_upcast { + working_float_dtype = FloatDType::F32; + tensor = tensor.cast(working_float_dtype); + } else { + working_float_dtype = original_dtype.into() + }; + + // Compute determinant for base cases (1x1, 2x2, and 3x3 matrices) + let rank = D as isize; + if dims[D - 1] == 1 { + let det_tensor = tensor.squeeze_dims::(&[rank - 2, rank - 1]); + if needs_upcast { + return det_tensor.cast(original_dtype); + } + return det_tensor; + } else if dims[D - 1] == 2 { + let a = tensor.clone().slice_dim(D - 2, 0).slice_dim(D - 1, 0); + let b = tensor.clone().slice_dim(D - 2, 0).slice_dim(D - 1, 1); + let c = tensor.clone().slice_dim(D - 2, 1).slice_dim(D - 1, 0); + let d = tensor.clone().slice_dim(D - 2, 1).slice_dim(D - 1, 1); + let det_tensor = (a * d - b * c).squeeze_dims::(&[rank - 2, rank - 1]); + if needs_upcast { + return det_tensor.cast(original_dtype); + } + return det_tensor; + } else if dims[D - 1] == 3 { + let a = tensor.clone().slice_dim(D - 2, 0).slice_dim(D - 1, 0); + let b = tensor.clone().slice_dim(D - 2, 0).slice_dim(D - 1, 1); + let c = tensor.clone().slice_dim(D - 2, 0).slice_dim(D - 1, 2); + let d = tensor.clone().slice_dim(D - 2, 1).slice_dim(D - 1, 0); + let e = tensor.clone().slice_dim(D - 2, 1).slice_dim(D - 1, 1); + let f = tensor.clone().slice_dim(D - 2, 1).slice_dim(D - 1, 2); + let g = tensor.clone().slice_dim(D - 2, 2).slice_dim(D - 1, 0); + let h = tensor.clone().slice_dim(D - 2, 2).slice_dim(D - 1, 1); + let i = tensor.clone().slice_dim(D - 2, 2).slice_dim(D - 1, 2); + let det_tensor = (a * (e.clone() * i.clone() - f.clone() * h.clone()) + - b * (d.clone() * i - f * g.clone()) + + c * (d * h - e * g)) + .squeeze_dims::(&[rank - 2, rank - 1]); + if needs_upcast { + return det_tensor.cast(original_dtype); + } + return det_tensor; + } + + // Compute determinant for general case + // det(A) = det(P) * det(L) * det(U) + // det(A) = det(P) * 1 * det(U) + let (lu, pivots) = linalg::compute_lu_decomposition::(tensor.clone()); + + // Compute the determinant of P + let squeezed_pivots = pivots.squeeze_dim::(D - 1); + let n_pivots = squeezed_pivots.dims()[D1 - 1] as i64; + let range_1d: Tensor<1> = + Tensor::arange(0..n_pivots, &tensor.device()).cast(working_float_dtype); + let mut reshape_dims = [1; D1]; + reshape_dims[D1 - 1] = n_pivots; + let range = range_1d.reshape(reshape_dims); + let expand_dims: [usize; D1] = squeezed_pivots.dims(); + let batched_range_tensor = range.expand(expand_dims); + let n_row_swaps = squeezed_pivots + .not_equal(batched_range_tensor) + .int() + .sum_dim(D1 - 1); + let odd_mask = n_row_swaps.clone().remainder_scalar(2).equal_elem(1); + let p_det = n_row_swaps + .cast(working_float_dtype) + .ones_like() + .mask_fill(odd_mask, -1.0) + .squeeze_dim(D1 - 1); + + // Compute the determinant of U + let u_diag = linalg::diag::(lu); + let mut u_det = u_diag.clone().prod_dim(D1 - 1).squeeze_dim(D1 - 1); + let eps = tensor + .dtype() + .finfo() + .expect("The input tensor to linalg::det should have float dtype.") + .epsilon; + let n = dims[D - 1]; // The input tensor contains n by n matrices + let threshold = u_diag.clone().abs().max_dim(D1 - 1) * (n as f64).sqrt() * eps; + let near_zero = u_diag.abs().lower_equal(threshold); + let singular_mask = near_zero.any_dim(D1 - 1).squeeze_dim::(D1 - 1); + u_det = u_det.mask_fill(singular_mask, 0.0); + + let final_det = p_det * u_det; + + // Cast back to original dtypes + if needs_upcast { + final_det.cast(original_dtype) + } else { + final_det + } +} diff --git a/crates/burn-tensor/src/tensor/linalg/diag.rs b/crates/burn-tensor/src/tensor/linalg/diag.rs new file mode 100644 index 0000000..79adf72 --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/diag.rs @@ -0,0 +1,41 @@ +use crate::check; +use crate::check::TensorCheck; +use crate::kind::Basic; +use crate::tensor::{Int, Tensor}; + +/// Returns the diag of a matrix. +/// +/// For batched inputs, returns of each matrix in the batch independently. +/// +/// The diag operation extracts the diagonal elements of the last two dimensions, +/// treating them as the matrix dimensions, while preserving all leading batch dimensions. +/// +/// # Arguments +/// +/// * `tensor` - The input tensor with at least 2 dimensions. +/// +/// # Returns +/// A tensor of rank `D - 1`, where the last dimension contains the diagonal elements of the input. +pub fn diag(tensor: Tensor) -> Tensor +where + K: Basic, +{ + check!(TensorCheck::diag::()); + + let shape = tensor.shape(); + let rows = shape[D - 2]; + let cols = shape[D - 1]; + let diag_len = rows.min(cols); + let device = tensor.device(); + + // create the indices for the diag + let mut flat_shape = shape.clone(); + flat_shape[D - 2] = rows * cols; + flat_shape[D - 1] = 1; + let flat: Tensor = tensor.reshape(flat_shape); + + let range = Tensor::<1, Int>::arange(0..diag_len as i64, &device); + let step_tensor = Tensor::<1, Int>::from_data([cols as i64 + 1], &device); + let indices = range * step_tensor; + flat.take::<1, D>(D - 2, indices).squeeze_dim(D - 1) +} diff --git a/crates/burn-tensor/src/tensor/linalg/lu.rs b/crates/burn-tensor/src/tensor/linalg/lu.rs new file mode 100644 index 0000000..bb20953 --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/lu.rs @@ -0,0 +1,521 @@ +use crate::{Bool, Device, ElementConversion, Float, Int, Tensor, check, check::TensorCheck}; +use alloc::vec; +use alloc::vec::Vec; +use burn_std::{DType, FloatDType, IndexingUpdateOp, Slice}; + +/// Computes the LU decomposition of a square or rectangular matrix with partial pivoting. +/// +/// This function decomposes the input tensor A into three tensors P, L, and U +/// such that A = PLU. +/// +/// # Arguments +/// - `tensor` - The input tensor of shape `[..., n_rows, n_cols]`. +/// +/// # Returns +/// A tuple of three tensors `(P, L, U)`: +/// - `P` - The permutation tensor of shape `[..., n_rows, n_rows]`. +/// - `L` - The lower triangular tensor of shape `[..., n_rows, min(n_rows, n_cols)]` +/// with unit diagonal elements. +/// - `U` - The upper triangular tensor of shape `[..., min(n_rows, n_cols), n_cols]`. +/// +/// # Generic Parameters +/// +/// - `D`: The number of dimensions of the input tensor. +/// - `D1`: The number of dimensions of the 1D pivot tensor. Must be exactly `D - 1`. +/// +/// # Panics +/// This function will panic if the tensor checks fail: +/// - The input tensor has less than 2 dimensions (`D < 2`). +/// - The generic parameters do not satisfy `D - 1 == D1`. +/// - The input is a quantized tensor with dtype `DType::QFloat`. +/// +/// # Performance Note +/// The current implementation of LU decomposition is not fully optimized. It will not +/// be as fast as highly tuned specialized libraries, especially for very large +/// matrices or large batch sizes. +/// +/// # Numerical Behavior +/// - If the input tensor has dtype F16 or BF16, it is internally upcast to F32 +/// for the computation and cast back to the original dtype before returning. +/// - In this case, values in L and U that fall outside the original dtype's +/// representable range will saturate or underflow on cast-back. +/// +/// # Example +/// ```rust,ignore +/// use burn::tensor::Tensor; +/// use burn::backend::Flex; +/// use burn::tensor::linalg; +/// +/// fn example() { +/// let device = Default::default(); +/// let tensor = Tensor::<2>::from_data([[4.0, 3.0], [6.0, 3.0]], &device); +/// +/// // Compute P, L, U +/// let (p, l, u) = linalg::lu::< 2, 1>(tensor); +/// +/// // Expected Output: +/// // p: [[0.0, 1.0], +/// // [1.0, 0.0]] +/// // +/// // l: [[1.0, 0.0], +/// // [0.6666667, 1.0]] +/// // +/// // u: [[6.0, 3.0], +/// // [0.0, 1.0]] +/// } +/// ``` +pub fn lu( + mut tensor: Tensor, +) -> (Tensor, Tensor, Tensor) { + let dims = tensor.dims(); + let original_dtype = tensor.dtype(); + check!(TensorCheck::lu_generic_param::("linalg::lu")); + check!(TensorCheck::lu_input_tensor::( + "linalg::lu", + &dims, + original_dtype + )); + + let device = tensor.device(); + let n_rows = dims[D - 2]; + let n_cols = dims[D - 1]; + + // Upcast f16 and bf16 to f32 + let needs_upcast = original_dtype == DType::F16 || original_dtype == DType::BF16; + if needs_upcast { + tensor = tensor.cast(FloatDType::F32) + } + + let (lu_tensor, p_compact) = compute_lu_decomposition::(tensor); + + let u; + let temp_l; + if n_rows < n_cols { + temp_l = lu_tensor.clone().slice_dim(D - 1, 0..n_rows).tril(0); + u = lu_tensor.triu(0); + } else { + temp_l = lu_tensor.clone().tril(0); + u = lu_tensor.slice_dim(D - 2, 0..n_cols).triu(0); + } + let mask = Tensor::::diag_mask(temp_l.shape(), 0, &device).bool_not(); + let l = temp_l.mask_fill(mask, 1.0); + let p = construct_full_permutation_tensor(p_compact, n_rows, &device).transpose(); + + if needs_upcast { + ( + p.cast(original_dtype), + l.cast(original_dtype), + u.cast(original_dtype), + ) + } else { + (p, l, u) + } +} + +/// Dispatches the LU decomposition to either the block or standard algorithm based on +/// the size of the matrix. +pub(super) fn compute_lu_decomposition( + tensor: Tensor, +) -> (Tensor, Tensor) { + let device = tensor.device(); + let dims = tensor.dims(); + let n_rows = dims[D - 2]; + let n_cols = dims[D - 1]; + let size = n_rows.min(n_cols); + if size < 256 { + return standard_lu_with_partial_piv::(tensor, &device); + } + + block_lu_with_partial_piv::(tensor) +} + +/// Performs block LU decomposition with partial pivoting. +/// +/// This algorithm divides the matrix into blocks to maximize matrix-matrix multiplications (GEMM), +/// which are highly optimized on modern hardware, compared to vector-vector operations. +fn block_lu_with_partial_piv( + mut tensor: Tensor, +) -> (Tensor, Tensor) { + let device = tensor.device(); + let dims = tensor.dims(); + let n_rows = dims[D - 2]; + let n_cols = dims[D - 1]; + let piv_nums = n_rows.min(n_cols); + let dtype = tensor.dtype().into(); + let mut global_piv = create_permutation_tensor::(piv_nums, dims, dtype, &device); + let block_size = 128; + + // Computes the total number of blocks including incomplete blocks + // E.g., piv_nums = 100 & block_size = 32 -> n_blocks = 4 (not 3) where + // the 4th block is smaller than other blocks + let n_blocks = piv_nums.div_ceil(block_size); + let mut slices = vec![Slice::full(); D]; // For updating the original tensor in-place + + // k is the current block number + for block_k in 0..n_blocks { + // Determine the index range for the current block column + let k_start = block_k * block_size; + let k_end = (k_start + block_size).min(piv_nums); + let current_block_size = k_end - k_start; + + // Apply standard LU decomposition with partial pivoting to the current block column + let sub_tensor = tensor + .clone() + .slice_dim(D - 2, k_start..) + .slice_dim(D - 1, k_start..k_end); + let (block_column, local_piv) = standard_lu_with_partial_piv::(sub_tensor, &device); + slices[D - 2] = Slice::from(k_start..); + slices[D - 1] = Slice::from(k_start..k_end); + tensor = tensor.slice_assign(&slices, block_column); + + // Update `permutations` to global indices + global_piv = + update_permutations_to_global_idx(global_piv.clone(), local_piv.clone(), k_start); + + // Apply `local_piv` permutations to the left sub-tensor + if block_k != 0 { + let left_sub_tensor = tensor + .clone() + .slice_dim(D - 2, k_start..) + .slice_dim(D - 1, ..k_start); + let permutated_left_sub_tensor = + apply_permutations_to_tensor(left_sub_tensor, local_piv.clone(), &device); + slices[D - 2] = Slice::from(k_start..); + slices[D - 1] = Slice::from(..k_start); + tensor = tensor.slice_assign(&slices, permutated_left_sub_tensor); + } + + // Only update the right side if there are columns left + if k_end < n_cols { + // Apply `local_piv` permutations to the remaining right sub-tensor + let right_sub_tensor = tensor + .clone() + .slice_dim(D - 2, k_start..) + .slice_dim(D - 1, k_end..); + let permutated_right_sub_tensor = + apply_permutations_to_tensor(right_sub_tensor, local_piv, &device); + slices[D - 2] = Slice::from(k_start..); + slices[D - 1] = Slice::from(k_end..); + tensor = tensor.slice_assign(&slices, permutated_right_sub_tensor); + + // Update the cols to the right of the current diagonal block. + // Triangular solve for U blocks. + let diagonal_l_block = tensor + .clone() + .slice_dim(D - 2, k_start..k_end) + .slice_dim(D - 1, k_start..k_end); + let row_blocks = tensor + .clone() + .slice_dim(D - 2, k_start..k_end) + .slice_dim(D - 1, k_end..); + let updated_row_blocks = + solve_for_u_blocks(diagonal_l_block, row_blocks, current_block_size); + slices[D - 2] = Slice::from(k_start..k_end); + slices[D - 1] = Slice::from(k_end..); + tensor = tensor.slice_assign(&slices, updated_row_blocks.clone()); + + // Only update trailing A blocks if there are rows left below + if k_end < n_rows { + // Update the trailing A blocks + let trailing_a_blocks = tensor + .clone() + .slice_dim(D - 2, k_end..) + .slice_dim(D - 1, k_end..); + let l_col_blocks = tensor + .clone() + .slice_dim(D - 2, k_end..) + .slice_dim(D - 1, k_start..k_end); + let outer_prod = l_col_blocks.matmul(updated_row_blocks); + let new_trailing_a_blocks = trailing_a_blocks - outer_prod; + + // Overwrite part of the tensor with new_trailing_a_blocks + slices[D - 2] = Slice::from(k_end..); + slices[D - 1] = Slice::from(k_end..); + tensor = tensor.slice_assign(&slices, new_trailing_a_blocks); + } + } + } + + (tensor, global_piv) +} + +/// Performs standard LU decomposition (outer product LU) with partial pivoting. +/// +/// This is an iterative, unblocked algorithm that processes the matrix column by column. +fn standard_lu_with_partial_piv( + mut tensor: Tensor, + device: &Device, +) -> (Tensor, Tensor) { + let dims = tensor.dims(); + let n_rows = dims[D - 2]; + let n_cols = dims[D - 1]; + let piv_nums = n_rows.min(n_cols); + let dtype = tensor.dtype().into(); + let mut permutations = create_permutation_tensor::(piv_nums, dims, dtype, device); + + for k in 0..piv_nums { + // Find the index of the maximum absolute value in the k-th column (from row k downwards) + // Shape: [B1, ..., BN, 1, 1] + let max_row_indices = tensor + .clone() + .slice_dim(D - 2, k..) + .slice_dim(D - 1, k) + .abs() + .argmax(D - 2) + + (k as i64); + + // Swap current row (k-th row) with the row with maximum absolute value + tensor = swap_tensor_rows(tensor, max_row_indices.clone(), k); + // Store the max row index in the k-th entry of the permutations vector/tensor + permutations = update_permutations(permutations, max_row_indices, k, dtype); + + // If there are rows left under the k-th pivot + if k < n_rows - 1 { + // Update k-th column under the diagonal + tensor = update_kth_column(tensor, k); + + // If there still exists columns to right of the k-th pivot + if k < piv_nums - 1 { + tensor = update_trailing_submatrix::(tensor, k); + } + } + } + + (tensor, permutations) +} + +/// Constructs a full square permutation matrix \( P \) from a compact pivot tensor. +fn construct_full_permutation_tensor( + piv: Tensor, + n_rows: usize, + device: &Device, +) -> Tensor { + let dims = piv.dims(); + let identity_2d_uncasted: Tensor<2, Float> = Tensor::eye(n_rows, device); + let identity_2d = identity_2d_uncasted.cast(piv.dtype()); + + // Reshape the `identity` tensor from 2 dims to D dims + let mut reshape_dims = [1; D]; + reshape_dims[D - 2] = n_rows; + reshape_dims[D - 1] = n_rows; + let reshaped_identity = identity_2d.reshape(reshape_dims); + + // Expand the batch dimensions to match the original input tensor's shape + let mut expand_dims = [n_rows; D]; + expand_dims[..(D - 2)].copy_from_slice(&dims[..(D - 2)]); + let identity = reshaped_identity.expand(expand_dims); + + // Iterate through `piv` and apply rows swap to the `identity` tensor + // to construct the full permutation tensor + + apply_permutations_to_tensor(identity, piv, device) +} + +/// Initializes a permutation tensor representing the identity permutation `[0, 1, 2, ..., piv_nums - 1]`. +fn create_permutation_tensor( + piv_nums: usize, + dims: [usize; D], + dtype: FloatDType, + device: &Device, +) -> Tensor { + let piv = Tensor::arange(0..piv_nums as i64, device).cast(dtype); + + // Reshape the piv tensor from 1 dim to D dims + let mut reshape_dims = [1; D]; + reshape_dims[D - 2] = piv_nums; + let reshaped = piv.reshape(reshape_dims); + + // Expand the batch dimensions to match the original input tensor's shape + let mut expand_dims = [piv_nums; D]; + expand_dims[..(D - 2)].copy_from_slice(&dims[..(D - 2)]); + expand_dims[D - 1] = 1; + + reshaped.expand(expand_dims) +} + +/// Swaps the `k`-th row with the rows specified in `swap_target_row_tensor`. +fn swap_tensor_rows( + tensor: Tensor, + mut swap_target_row_tensor: Tensor, + k: usize, +) -> Tensor { + let mut expand_dims = tensor.dims(); + expand_dims[D - 2] = 1; + swap_target_row_tensor = swap_target_row_tensor.expand(expand_dims); + + let val_k = tensor.clone().slice_dim(D - 2, k); + let val_r = tensor.clone().gather(D - 2, swap_target_row_tensor.clone()); + + let mut slices = vec![Slice::full(); D]; + slices[D - 2] = Slice::from(k); + let tensor = tensor.slice_assign(&slices, val_r.clone()); + + let val_k_minus_r = val_k - val_r; + tensor.scatter( + D - 2, + swap_target_row_tensor, + val_k_minus_r, + IndexingUpdateOp::Add, + ) +} + +/// Updates the permutation tensor by recording the swap at step `k`. +fn update_permutations( + mut permutations: Tensor, + max_row_index_tensor: Tensor, + k: usize, + dtype: FloatDType, +) -> Tensor { + // Store the max row index in the k-th index of the permutations vector/tensor + let mut slices = vec![Slice::full(); D]; + slices[D - 2] = Slice::from(k); + let float_max_row_indices = max_row_index_tensor.cast(dtype); + permutations = permutations.slice_assign(&slices, float_max_row_indices); + + permutations +} + +/// Scales the `k`-th column below the diagonal by the pivot element A_{kk}. +fn update_kth_column(tensor: Tensor, k: usize) -> Tensor { + let a_kk = tensor.clone().slice_dim(D - 2, k).slice_dim(D - 1, k); + let a_rho_k = tensor.clone().slice_dim(D - 2, k + 1..).slice_dim(D - 1, k); + + // A singular matrix will have a pivot of exactly 0. + // Due to partial pivoting, if the pivot is 0, all elements below it are also 0. + // We replace 0 with 1.0 to avoid NaN when dividing 0.0 / 0.0. + let is_zero_mask = a_kk.clone().equal_elem(0.0); + let safe_a_kk = a_kk.mask_fill(is_zero_mask, 1.0); + let updated_column = a_rho_k / safe_a_kk; + + let mut slices = vec![Slice::full(); D]; + slices[D - 2] = Slice::from((k + 1)..); // Rows k+1 to the end + slices[D - 1] = Slice::from(k..(k + 1)); // Column k + + tensor.slice_assign(&slices, updated_column) +} + +/// Updates the trailing submatrix: A_{k+1:, k+1:} -= A_{k+1:, k} * A_{k, k+1:}. +fn update_trailing_submatrix( + tensor: Tensor, + k: usize, +) -> Tensor { + let a_rho_k = tensor.clone().slice_dim(D - 2, k + 1..).slice_dim(D - 1, k); + let a_k_rho = tensor.clone().slice_dim(D - 2, k).slice_dim(D - 1, k + 1..); + let outer_product = a_rho_k.matmul(a_k_rho); + + let a_rho_rho = tensor + .clone() + .slice_dim(D - 2, k + 1..) + .slice_dim(D - 1, k + 1..); + let updated_a_rho_rho = a_rho_rho - outer_product; + + let mut slices = vec![Slice::full(); D]; + slices[D - 2] = Slice::from((k + 1)..); // Rows k+1 to the end + slices[D - 1] = Slice::from((k + 1)..); // Cols k+1 to the end + tensor.slice_assign(&slices, updated_a_rho_rho) +} + +/// Shifts local pivot indices from a block factorization to global indices. +fn update_permutations_to_global_idx( + global_piv: Tensor, + local_piv: Tensor, + k_start: usize, +) -> Tensor { + let n = local_piv.dims()[D - 2]; + let mut slices = vec![Slice::full(); D]; + slices[D - 2] = Slice::from(k_start..(n + k_start)); + + let global_val = local_piv.add_scalar(k_start as f32); + + global_piv.slice_assign(&slices, global_val) +} + +/// Applies the permutations to the entire width of the tensor. +fn apply_permutations_to_tensor( + tensor: Tensor, + piv: Tensor, + device: &Device, +) -> Tensor { + let tensor_dims = tensor.dims(); + let n_rows = tensor_dims[D - 2]; + let n_pivots = piv.dims()[D - 2]; + let piv_data: Vec = piv.into_data().convert::().into_vec::().unwrap(); + + // Compute total batch size (product of all batch dimensions) + let batch_size: usize = tensor_dims[..D - 2].iter().product(); + if batch_size <= 1 { + // No batch dims (or batch size 1) + let mut perm: Vec = (0..n_rows as i64).collect(); + for (i, piv_val) in piv_data.iter().enumerate().take(n_pivots) { + let j = piv_val.elem::() as usize; + perm.swap(i, j); + } + let perm_tensor = Tensor::<1, Int>::from_data(&perm[..], device); + return tensor.select(D - 2, perm_tensor); + } + + // If input tensor has batch dimensions, then flatten batch dims, + // iterate, then reshape back. + // Reshape tensor: [b1, b2, ..., bN, rows, cols] -> [B, rows, cols] + let n_cols = tensor_dims[D - 1]; + let flat_tensor: Tensor<3> = tensor.reshape([batch_size, n_rows, n_cols]); + // Reshape pivot: [b1, b2, ..., bN, n_pivots, 1] -> [B * n_pivots] + let mut results: Vec> = Vec::with_capacity(batch_size); + for b in 0..batch_size { + // Build permutation for this batch element + let mut perm: Vec = (0..n_rows as i64).collect(); + let offset = b * n_pivots; + for i in 0..n_pivots { + let j = piv_data[offset + i].elem::() as usize; + perm.swap(i, j); + } + let perm_tensor = Tensor::<1, Int>::from_data(&perm[..], device); + + // Extract this batch element [1, rows, cols], select rows, collect + let batch_elem = flat_tensor.clone().slice_dim(0, b); // [1, rows, cols] + let permuted = batch_elem.select(1, perm_tensor); // [1, rows, cols] + results.push(permuted); + } + + // Concatenate along batch dim and reshape back to original shape + let concatenated: Tensor<3> = Tensor::cat(results, 0); // [B, rows, cols] + concatenated.reshape(tensor_dims) +} + +/// Solves for the U blocks using forward substitution. +/// +/// Solves the equation L_{kk} U_{k, k+1:} = A_{k, k+1:} for U_{k, k+1:}. +/// +/// # Arguments +/// - `diagonal_l_block`: The L block L_{k, k}, [k_start..k_end, k_start..k_end] +/// - `row_blocks`: The row blocks A_{k, k+1}, ..., A_{k, N}, [k_start..k_end, k_end..] +/// - `block_size`: The size of the current block +fn solve_for_u_blocks( + diagonal_l_block: Tensor, + mut a_row_blocks: Tensor, + block_size: usize, +) -> Tensor { + // The first row requires no computation since the first row of + // diagonal_l_block is [1, 0, 0, ..., 0] + let mut slices = vec![Slice::full(); D]; + + for i in 1..block_size { + // Shape of each matrix: 1 by i + let l_multipliers = diagonal_l_block + .clone() + .slice_dim(D - 2, i) + .slice_dim(D - 1, 0..i); + // Shape of each matrix: i by c where c is the number of cols in a_row_blocks + let u_computed = a_row_blocks.clone().slice_dim(D - 2, 0..i); + let prod = l_multipliers.matmul(u_computed); + + let current_rows = a_row_blocks.clone().slice_dim(D - 2, i); + let updated_rows = current_rows - prod; + + // Update the i-th row of a_row_blocks with the solved values of U blocks + slices[D - 2] = Slice::from(i); + a_row_blocks = a_row_blocks.slice_assign(&slices, updated_rows); + } + + a_row_blocks.clone() +} diff --git a/crates/burn-tensor/src/tensor/linalg/lu_decomposition.rs b/crates/burn-tensor/src/tensor/linalg/lu_decomposition.rs new file mode 100644 index 0000000..4b78ca5 --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/lu_decomposition.rs @@ -0,0 +1,78 @@ +use crate::{ + Int, cast::ToElement, check, check::TensorCheck, linalg::swap_slices, s, tensor::Tensor, +}; +/// Performs PLU decomposition of a square matrix. +/// +/// The function decomposes a given square matrix `A` into three matrices: a permutation vector `p`, +/// a lower triangular matrix `L`, and an upper triangular matrix `U`, such that `PA = LU`. +/// The permutation vector `p` represents the row swaps made during the decomposition process. +/// The lower triangular matrix `L` has ones on its diagonal and contains the multipliers used +/// during the elimination process below the diagonal. The upper triangular matrix `U` contains +/// the resulting upper triangular form of the matrix after the elimination process. +/// +/// # Arguments +/// * `tensor` - A square matrix to decompose, represented as a 2D tensor. +/// +/// # Returns +/// A tuple containing: +/// - A 2D tensor representing the combined `L` and `U` matrices. +/// - A 1D tensor representing the permutation vector `p`. +/// +/// # Panics and numerical issues +/// - The function will panic if the input matrix is singular or near-singular. +/// - The function will panic if the input matrix is not square. +/// # Performance note (synchronization / device transfers) +/// This function may involve multiple synchronizations and device transfers, especially +/// when determining pivot elements and performing row swaps. This can impact performance, +pub fn lu_decomposition(tensor: Tensor<2>) -> (Tensor<2>, Tensor<1, Int>) { + check!(TensorCheck::is_square::<2>( + "lu_decomposition", + &tensor.shape() + )); + let dims = tensor.shape().dims::<2>(); + let n = dims[0]; + + let mut permutations = Tensor::arange(0..n as i64, &tensor.device()); + let mut tensor = tensor; + + for k in 0..n { + // Find the pivot row + let p = tensor + .clone() + .slice(s![k.., k]) + .abs() + .argmax(0) + .into_scalar() + .to_usize() + + k; + let max = tensor.clone().slice(s![p, k]).abs(); + + // Avoid division by zero + let pivot = max.into_scalar(); + check!(TensorCheck::lu_decomposition_pivot(pivot)); + + if p != k { + tensor = swap_slices(tensor, s![k, ..], s![p, ..]); + permutations = swap_slices(permutations, s![k], s![p]); + } + + // Normalize k-th column under the diagonal + if k < n - 1 { + let a_kk = tensor.clone().slice(s![k, k]); + let column = tensor.clone().slice(s![(k + 1).., k]) / a_kk; + tensor = tensor.slice_assign(s![(k + 1).., k], column); + } + + // Update the trailing submatrix + for i in (k + 1)..n { + // a[i, k+1..] -= a[i, k] * a[k, k+1..] + let a_ik = tensor.clone().slice(s![i, k]); + let row_k = tensor.clone().slice(s![k, (k + 1)..]); + let update = a_ik * row_k; + let row_i = tensor.clone().slice(s![i, (k + 1)..]); + tensor = tensor.slice_assign(s![i, (k + 1)..], row_i - update); + } + } + + (tensor, permutations) +} diff --git a/crates/burn-tensor/src/tensor/linalg/matvec.rs b/crates/burn-tensor/src/tensor/linalg/matvec.rs new file mode 100644 index 0000000..128a03f --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/matvec.rs @@ -0,0 +1,60 @@ +use crate::{ + kind::Numeric, + tensor::{Shape, Tensor}, +}; + +/// Performs matrix-vector multiplication with optional batch dimensions. +/// +/// The `matrix` tensor is expected to have rank `DM` with the last two dimensions representing +/// the matrix rows and columns. The `vector` tensor should have rank `DV = DM - 1`, sharing +/// broadcast-compatible batch dimensions and matching the last dimension of the matrix. +/// +/// # Panics +/// +/// * If the matrix rank is lower than 2. +/// * If the vector rank isn't one less than the matrix rank. +/// * If batch dimensions differ between the operands. +/// * If the inner dimensions are incompatible for multiplication. +pub fn matvec( + matrix: Tensor, + vector: Tensor, +) -> Tensor +where + K: Numeric, +{ + assert!( + DM >= 2, + "matvec expects the matrix to be at least rank 2 (got {DM})" + ); + assert!( + DM == DV + 1, + "matvec expects the vector rank ({DV}) to be exactly one less than the matrix rank ({DM})", + ); + + let matrix_dims = matrix.shape().dims::(); + let vector_dims = vector.shape().dims::(); + + // Validate batch dimensions (all leading dimensions prior to the matrix axes). + let batch_rank = DM.saturating_sub(2); + if batch_rank > 0 { + let matrix_batch = Shape::from(&matrix_dims[..batch_rank]); + let vector_batch = Shape::from(&vector_dims[..batch_rank]); + + assert!( + matrix_batch.broadcast(&vector_batch).is_ok(), + "Batch dimensions are not broadcast-compatible: matrix {:?} vs vector {:?}", + &matrix_dims[..batch_rank], + &vector_dims[..batch_rank] + ); + } + + let matrix_inner = matrix_dims[DM - 1]; + let vector_inner = vector_dims[DV - 1]; + assert!( + matrix_inner == vector_inner, + "Inner dimension mismatch: matrix has {matrix_inner} columns but vector has {vector_inner} entries", + ); + + let vector_expanded = vector.unsqueeze_dim::(DV); + matrix.matmul(vector_expanded).squeeze_dim::(DM - 1) +} diff --git a/crates/burn-tensor/src/tensor/linalg/mod.rs b/crates/burn-tensor/src/tensor/linalg/mod.rs new file mode 100644 index 0000000..38cdd1b --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/mod.rs @@ -0,0 +1,17 @@ +mod cosine_similarity; +mod det; +mod diag; +mod lu; +mod matvec; +mod outer; +mod trace; +mod vector_norm; + +pub use cosine_similarity::*; +pub use det::*; +pub use diag::*; +pub use lu::*; +pub use matvec::*; +pub use outer::*; +pub use trace::*; +pub use vector_norm::*; diff --git a/crates/burn-tensor/src/tensor/linalg/outer.rs b/crates/burn-tensor/src/tensor/linalg/outer.rs new file mode 100644 index 0000000..f820f79 --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/outer.rs @@ -0,0 +1,75 @@ +use crate::tensor::Tensor; +use crate::{AsIndex, kind::Numeric}; + +/// Computes the outer product for the last columns of 2 tensors. +/// +/// See also: [`outer_dim`]. +/// +/// # Arguments +/// - `lhs`: the "row" tensor, with shape ``[..., i]``. +/// - `rhs`: the "col" tensor, with shape ``[..., j]``. +/// +/// # Returns +/// +/// A tensor of rank `R = D + 1`, where: +/// +/// `` +/// result[..., i, j] = lhs[..., i] * rhs[..., j] +/// `` +pub fn outer( + lhs: Tensor, + rhs: Tensor, +) -> Tensor +where + K: Numeric, +{ + outer_dim(lhs, rhs, -1) +} + +/// Computes the outer product along a specific dimension, broadcasting over others. +/// +/// For the given `dim`, computes the outer product of elements along that dimension, +/// expanding it into two dimensions of size ``M × N`` at positions ``(dim, dim + 1)``. +/// +/// # Arguments +/// +/// - `lhs`: left operand, the "row" tensor, with size `M` at dimension `dim`. +/// - `rhs`: right operand, the "col" tensor, with size `N` at dimension `dim`. +/// - `dim`: dimension to compute the outer product along (supports negative indexing). +/// +/// # Returns +/// +/// A tensor of rank `R = D + 1`, where: +/// +/// `` +/// result[..., i, j, ...] = lhs[..., i, ...] * rhs[..., j, ...] +/// `` +// +// Notes: +// - For large batched inputs, `x_col.matmul(y_row)` *might* be more performant +// than broadcasted elemwise multiply; benchmarking needed to confirm. +pub fn outer_dim( + lhs: Tensor, + rhs: Tensor, + dim: Dim, +) -> Tensor +where + K: Numeric, +{ + assert_eq!( + R, + D + 1, + "`outer` with D={D} expects R={} (got R={R})", + D + 1 + ); + let dim = dim.expect_dim_index(D); + + // (..., i, 1, ...) + let x = lhs.unsqueeze_dim::(dim + 1); + + // (..., 1, j, ...) + let y = rhs.unsqueeze_dim::(dim); + + // (..., i, j, ...) + x * y +} diff --git a/crates/burn-tensor/src/tensor/linalg/trace.rs b/crates/burn-tensor/src/tensor/linalg/trace.rs new file mode 100644 index 0000000..b51d02d --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/trace.rs @@ -0,0 +1,23 @@ +use super::diag; +use crate::tensor::Tensor; + +/// Computes the trace of a matrix. +/// +/// For batched inputs, computes the trace of each matrix in the batch independently. +/// +/// The trace operation sums the diagonal elements of the last two dimensions, +/// treating them as the matrix dimensions, while preserving all leading batch dimensions. +/// +/// # Arguments +/// +/// * `tensor` - The input tensor with at least 2 dimensions. +/// +/// # Returns +/// +/// A tensor of rank `D - 1`, where the last dimension contains the sum along the diagonals +/// of the input. +pub fn trace(tensor: Tensor) -> Tensor { + let diag_tensor = diag::(tensor); + + diag_tensor.sum_dim(DO - 1) +} diff --git a/crates/burn-tensor/src/tensor/linalg/vector_norm.rs b/crates/burn-tensor/src/tensor/linalg/vector_norm.rs new file mode 100644 index 0000000..18878eb --- /dev/null +++ b/crates/burn-tensor/src/tensor/linalg/vector_norm.rs @@ -0,0 +1,281 @@ +use crate::tensor::Tensor; +use crate::{ + ElementConversion, + kind::{Numeric, Ordered}, +}; +#[allow(unused_imports)] +use num_traits::float::Float; +/// Specifies the type of norm to compute. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Norm { + /// L0 norm (count of non-zero elements) + L0, + + /// L1 norm (sum of absolute values) + L1, + + /// L2 norm (Euclidean norm) + L2, + + /// L:INFINITY norm (maximum absolute value) + LInf, + + /// L:NEG_INFINITY norm (minimum absolute value) + LNegInf, + + /// Lp norm (generalized norm) + Lp(f64), +} + +impl Norm { + /// Get the exponent of the norm. + pub fn to_exponent(self) -> f64 { + use Norm::*; + match self { + L0 => 0.0, + L1 => 1.0, + L2 => 2.0, + LInf => f64::INFINITY, + LNegInf => f64::NEG_INFINITY, + Lp(p) => p, + } + } +} + +impl From for Norm { + fn from(value: u32) -> Self { + use Norm::*; + match value { + 0 => L0, + 1 => L1, + 2 => L2, + u32::MAX => LInf, + _ => Lp(value as f64), + } + } +} + +impl From for Norm { + fn from(value: i32) -> Self { + use Norm::*; + match value { + 0 => L0, + 1 => L1, + 2 => L2, + i32::MAX => LInf, + i32::MIN => LNegInf, + _ => Lp(value as f64), + } + } +} + +impl From for Norm { + fn from(value: f32) -> Self { + use Norm::*; + match value { + 0.0 => L0, + 1.0 => L1, + 2.0 => L2, + f32::INFINITY => LInf, + f32::NEG_INFINITY => LNegInf, + _ => Lp(value as f64), + } + } +} + +impl From for Norm { + fn from(value: f64) -> Self { + use Norm::*; + match value { + 0.0 => L0, + 1.0 => L1, + 2.0 => L2, + f64::INFINITY => LInf, + f64::NEG_INFINITY => LNegInf, + _ => Lp(value), + } + } +} + +/// Computes the vector norm of a tensor along a specified dimension. +/// +/// Generic dispatch wrapper over specialized / optimized norms. +/// +/// See: +/// - [torch.linalg.vector_norm](https://pytorch.org/docs/stable/generated/torch.linalg.vector_norm.html) +/// - [numpy.linalg.vector_norm](https://numpy.org/doc/stable/reference/generated/numpy.linalg.vector_norm.html) +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `norm` - The selected norm. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The vector norm of the input tensor. +pub fn vector_norm(x: Tensor, norm: impl Into, dim: usize) -> Tensor { + lp_norm(x, norm.into().to_exponent(), dim) +} + +/// Computes the general ``L(p)`` norm of a tensor along a specified dimension. +/// +/// Uses the specialized implementations for: +/// * 0.0 +/// * 1.0 +/// * 2.0 +/// * 2 * N for integral N, +/// * f64::INFINITY, +/// * f64::NEG_INFINITY, +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `p` - The exponent of the Lp norm. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The ``L(p)`` norm of the input tensor. +pub fn lp_norm(x: Tensor, p: f64, dim: usize) -> Tensor { + match p { + 0.0 => l0_norm(x, dim), + 1.0 => l1_norm(x, dim), + 2.0 => l2_norm(x, dim), + p if is_even_integer(p) => lp_signed_norm(x, p as u32, dim), + f64::INFINITY => max_abs_norm(x, dim), + f64::NEG_INFINITY => min_abs_norm(x, dim), + _ => lp_norm_base(x, p, dim), + } +} + +/// Normalize a tensor versus its `vector_norm`. +/// +/// Equivalent to ``x.clone() / vector_norm(x, norm, dim).clamp_min(eps)``. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `norm` - The selected norm. +/// * `dim` - The dimension to compute the norm over. +/// * `eps` - The epsilon for the norm. +/// +/// # Returns +/// +/// The normalized tensor. +pub fn vector_normalize( + x: Tensor, + norm: impl Into, + dim: usize, + eps: E, +) -> Tensor { + let norm = vector_norm(x.clone(), norm, dim).clamp_min(eps); + x / norm +} + +/// Computes the L0 norm of a tensor along a specified dimension. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The L0 norm of the input tensor. +pub fn l0_norm(x: Tensor, dim: usize) -> Tensor +where + K: Numeric, +{ + x.zeros_like() + .mask_fill(x.not_equal_elem(0), 1) + .sum_dim(dim) +} + +/// Computes the L1 norm of a tensor along a specified dimension. +/// +/// This is a convenience function that wraps `vector_norm` with `p = 1.0`. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The L1 norm of the input tensor. +pub fn l1_norm(x: Tensor, dim: usize) -> Tensor +where + K: Numeric, +{ + x.abs().sum_dim(dim) +} + +/// Computes the L2 norm of a tensor along a specified dimension. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The L2 norm of the input tensor. +pub fn l2_norm(x: Tensor, dim: usize) -> Tensor { + x.square().sum_dim(dim).sqrt() +} + +fn is_even_integer(x: f64) -> bool { + x.fract() == 0.0 && (x as i64) % 2 == 0 +} + +/// Computes ``L(2*n)`` for even integer ``n``. +/// +/// This lets us skip the abs. +fn lp_signed_norm(x: Tensor, p: u32, dim: usize) -> Tensor { + x.powi_scalar(p).sum_dim(dim).powf_scalar(1. / (p as f64)) +} + +/// Computes the general ``L(p)`` using the generalized method. +/// +/// This uses no specialized implementations and cannot handle: +/// * 0.0 +/// * f64::INFINITY, +/// * f64::NEG_INFINITY, +fn lp_norm_base(x: Tensor, p: f64, dim: usize) -> Tensor { + x.abs().powf_scalar(p).sum_dim(dim).powf_scalar(1. / p) +} + +/// Computes the L:INFINITY norm of a tensor along a specified dimension. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The L:INFINITY norm of the input tensor. +pub fn max_abs_norm(x: Tensor, dim: usize) -> Tensor +where + K: Ordered, +{ + x.max_abs_dim(dim) +} + +/// Computes the L:NEG_INFINITY norm of a tensor along a specified dimension. +/// +/// # Arguments +/// +/// * `x` - The input tensor. +/// * `dim` - The dimension to compute the norm over. +/// +/// # Returns +/// +/// The L:NEG_INFINITY norm of the input tensor. +pub fn min_abs_norm(x: Tensor, dim: usize) -> Tensor +where + K: Ordered, +{ + x.abs().min_dim(dim) +} diff --git a/crates/burn-tensor/src/tensor/loss/mod.rs b/crates/burn-tensor/src/tensor/loss/mod.rs new file mode 100644 index 0000000..8976e2c --- /dev/null +++ b/crates/burn-tensor/src/tensor/loss/mod.rs @@ -0,0 +1,22 @@ +use crate::{Tensor, activation}; + +/// Computes the log softmax cross entropy between logits and target probabilities. +/// +/// # Arguments +/// +/// * `logits` - The logits. +/// * `target_probs` - The target probabilities. +/// +/// # Returns +/// +/// The log softmax cross entropy. +pub fn cross_entropy_with_logits( + logits: Tensor, + target_probs: Tensor, +) -> Tensor<1> { + let tensor = activation::log_softmax(logits, D - 1); + let tensor = tensor.mul(target_probs); + let tensor = tensor.sum_dim(D - 1); + + tensor.mean().neg() +} diff --git a/crates/burn-tensor/src/tensor/mod.rs b/crates/burn-tensor/src/tensor/mod.rs new file mode 100644 index 0000000..2f0f209 --- /dev/null +++ b/crates/burn-tensor/src/tensor/mod.rs @@ -0,0 +1,56 @@ +pub(crate) mod stats; + +mod api; + +pub use api::*; + +// Re-exported types +pub use burn_std::{ + BoolDType, BoolStore, DType, DataError, FloatDType, IndexingUpdateOp, IntDType, TensorData, + Tolerance, distribution::*, element::*, indexing::*, s, shape::*, slice::*, +}; + +/// The tensor kind module. +pub mod kind; +pub use kind::{Bool, Float, Int}; + +/// The activation module. +pub mod activation; + +/// The container module. +pub mod container { + pub use burn_std::tensor::container::TensorContainer; +} + +/// The grid module. +pub mod grid; + +/// The linalg module. +pub mod linalg; + +/// The loss module. +pub mod loss; + +/// The neural network module. +pub mod module; + +/// The signal processing module. +pub mod signal; + +/// Operations on tensors module. +pub mod ops { + pub(crate) use crate::bridge::*; + pub use burn_std::ops::*; +} + +/// Tensor quantization module. +pub mod quantization; + +#[cfg(feature = "std")] +pub mod distributed; + +#[cfg(feature = "std")] +pub use report::*; + +#[cfg(feature = "std")] +mod report; diff --git a/crates/burn-tensor/src/tensor/module.rs b/crates/burn-tensor/src/tensor/module.rs new file mode 100644 index 0000000..6d99290 --- /dev/null +++ b/crates/burn-tensor/src/tensor/module.rs @@ -0,0 +1,671 @@ +use burn_backend::ops::ModuleOps; +use burn_dispatch::Dispatch; +use burn_std::{MatmulTransformAction, MatmulTransformAnalysis, MatmulTransformPolicy}; + +use crate::{ + Bool, DType, Int, Tensor, check, + check::TensorCheck, + ops::{ + AttentionModuleOptions, BridgeTensor, ConvOptions, ConvTransposeOptions, DeformConvOptions, + InterpolateOptions, PadMode, PaddedConvOptions, UnfoldOptions, + }, +}; + +/// Computes the [CTC loss](burn_backend::ops::ModuleOps::ctc_loss). +/// +/// # Arguments +/// +/// * `log_probs` - Log-probabilities of shape `[T, N, C]` +/// * `targets` - Target label indices of shape `[N, S]` +/// * `input_lengths` - Actual input sequence lengths per batch element `[N]` +/// * `target_lengths` - Actual target lengths per batch element `[N]` +/// * `blank` - Index of the blank label +/// +/// # Returns +/// +/// Per-sample loss of shape `[N]` +pub fn ctc_loss( + log_probs: Tensor<3>, + targets: Tensor<2, Int>, + input_lengths: Tensor<1, Int>, + target_lengths: Tensor<1, Int>, + blank: usize, +) -> Tensor<1> { + Tensor::new(BridgeTensor::float(Dispatch::ctc_loss( + log_probs.primitive.into_float(), + targets.primitive.into(), + input_lengths.primitive.into(), + target_lengths.primitive.into(), + blank, + ))) +} + +/// Applies the [embedding module](burn_backend::ops::ModuleOps::embedding). +pub fn embedding(weights: Tensor<2>, indices: Tensor<2, Int>) -> Tensor<3> { + Tensor::new(BridgeTensor::float(Dispatch::embedding( + weights.primitive.into_float(), + indices.primitive.into(), + ))) +} + +/// Applies a [1D convolution](burn_backend::ops::ModuleOps::conv1d). +/// +/// Accepts [`ConvOptions`] for symmetric padding, or [`PaddedConvOptions`] for +/// asymmetric padding. When asymmetric padding is specified, an explicit pad +/// operation is applied before the convolution backend op. +pub fn conv1d( + x: Tensor<3>, + weight: Tensor<3>, + bias: Option>, + options: impl Into>, +) -> Tensor<3> { + let padded_options = options.into(); + check!(TensorCheck::conv( + "conv1d", + x.dims(), + weight.dims(), + padded_options.options.groups, + )); + + if let Some(padding_end) = padded_options.padding_end { + let left = padded_options.options.padding[0]; + let right = padding_end[0]; + // For 1D (NCL format), pad the length dimension + let padded = x.pad((left, right, 0, 0), PadMode::Constant(0.0)); + let zero_options = ConvOptions::new( + padded_options.options.stride, + [0], + padded_options.options.dilation, + padded_options.options.groups, + ); + Tensor::new(BridgeTensor::float(Dispatch::conv1d( + padded.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + zero_options, + ))) + } else { + Tensor::new(BridgeTensor::float(Dispatch::conv1d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + padded_options.options, + ))) + } +} + +/// Applies a [2D convolution](burn_backend::ops::ModuleOps::conv2d). +/// +/// Accepts [`ConvOptions`] for symmetric padding, or [`PaddedConvOptions`] for +/// asymmetric padding. When asymmetric padding is specified, an explicit pad +/// operation is applied before the convolution backend op. +pub fn conv2d( + x: Tensor<4>, + weight: Tensor<4>, + bias: Option>, + options: impl Into>, +) -> Tensor<4> { + let padded_options = options.into(); + check!(TensorCheck::conv( + "conv2d", + x.dims(), + weight.dims(), + padded_options.options.groups, + )); + + if let Some(padding_end) = padded_options.padding_end { + let top = padded_options.options.padding[0]; + let left = padded_options.options.padding[1]; + let bottom = padding_end[0]; + let right = padding_end[1]; + // For 2D (NCHW format), pad height and width + let padded = x.pad((left, right, top, bottom), PadMode::Constant(0.0)); + let zero_options = ConvOptions::new( + padded_options.options.stride, + [0, 0], + padded_options.options.dilation, + padded_options.options.groups, + ); + Tensor::new(BridgeTensor::float(Dispatch::conv2d( + padded.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + zero_options, + ))) + } else { + Tensor::new(BridgeTensor::float(Dispatch::conv2d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + padded_options.options, + ))) + } +} + +/// Applies a [3D convolution](burn_backend::ops::ModuleOps::conv3d). +/// +/// Accepts [`ConvOptions`] for symmetric padding, or [`PaddedConvOptions`] for +/// asymmetric padding. Asymmetric 3D padding is not yet supported. +pub fn conv3d( + x: Tensor<5>, + weight: Tensor<5>, + bias: Option>, + options: impl Into>, +) -> Tensor<5> { + let padded_options = options.into(); + check!(TensorCheck::conv( + "conv3d", + x.dims(), + weight.dims(), + padded_options.options.groups, + )); + + if padded_options.is_asymmetric() { + panic!("Asymmetric padding is not yet supported for conv3d"); + } + + Tensor::new(BridgeTensor::float(Dispatch::conv3d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + padded_options.options, + ))) +} + +/// Applies a [Deformable 2D convolution](burn_backend::ops::ModuleOps::deform_conv2d). +pub fn deform_conv2d( + x: Tensor<4>, + offset: Tensor<4>, + weight: Tensor<4>, + mask: Option>, + bias: Option>, + options: DeformConvOptions<2>, +) -> Tensor<4> { + check!(TensorCheck::conv( + "deform_conv2d", + x.dims(), + weight.dims(), + options.weight_groups, + )); + Tensor::new(BridgeTensor::float(Dispatch::deform_conv2d( + x.primitive.into_float(), + offset.primitive.into_float(), + weight.primitive.into_float(), + mask.map(|m| m.primitive.into_float()), + bias.map(|b| b.primitive.into_float()), + options, + ))) +} + +/// Applies a [1D transposed convolution](burn_backend::ops::ModuleOps::conv_transpose1d). +pub fn conv_transpose1d( + x: Tensor<3>, + weight: Tensor<3>, + bias: Option>, + options: ConvTransposeOptions<1>, +) -> Tensor<3> { + check!(TensorCheck::conv_transpose( + "conv_transpose1d", + x.dims(), + weight.dims(), + )); + Tensor::new(BridgeTensor::float(Dispatch::conv_transpose1d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + options, + ))) +} + +/// Applies a [2D transposed convolution](burn_backend::ops::ModuleOps::conv_transpose2d). +pub fn conv_transpose2d( + x: Tensor<4>, + weight: Tensor<4>, + bias: Option>, + options: ConvTransposeOptions<2>, +) -> Tensor<4> { + check!(TensorCheck::conv_transpose( + "conv_transpose2d", + x.dims(), + weight.dims(), + )); + Tensor::new(BridgeTensor::float(Dispatch::conv_transpose2d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + options, + ))) +} + +/// Applies a 3D transposed convolution](burn_backend::ops::ModuleOps::conv_transpose3d). +pub fn conv_transpose3d( + x: Tensor<5>, + weight: Tensor<5>, + bias: Option>, + options: ConvTransposeOptions<3>, +) -> Tensor<5> { + check!(TensorCheck::conv_transpose( + "conv_transpose3d", + x.dims(), + weight.dims(), + )); + Tensor::new(BridgeTensor::float(Dispatch::conv_transpose3d( + x.primitive.into_float(), + weight.primitive.into_float(), + bias.map(|b| b.primitive.into_float()), + options, + ))) +} + +/// Applies a [4D to 3D unfold](burn_backend::ops::ModuleOps::unfold4d). +pub fn unfold4d(x: Tensor<4>, kernel_size: [usize; 2], options: UnfoldOptions) -> Tensor<3> { + Tensor::new(BridgeTensor::float(Dispatch::unfold4d( + x.primitive.into_float(), + kernel_size, + options, + ))) +} + +/// Applies a [1D max pooling](burn_backend::ops::ModuleOps::max_pool1d). +pub fn max_pool1d( + x: Tensor<3>, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, +) -> Tensor<3> { + Tensor::new(BridgeTensor::float(Dispatch::max_pool1d( + x.primitive.into_float(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ))) +} + +/// Applies a [2D max pooling](burn_backend::ops::ModuleOps::max_pool2d). +pub fn max_pool2d( + x: Tensor<4>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::max_pool2d( + x.primitive.into_float(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + ))) +} + +/// Applies a [2D avg pooling](burn_backend::ops::ModuleOps::avg_pool2d). +pub fn avg_pool2d( + x: Tensor<4>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::avg_pool2d( + x.primitive.into_float(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ))) +} + +/// Applies a [1D avg pooling](burn_backend::ops::ModuleOps::avg_pool1d). +pub fn avg_pool1d( + x: Tensor<3>, + kernel_size: usize, + stride: usize, + padding: usize, + count_include_pad: bool, + ceil_mode: bool, +) -> Tensor<3> { + Tensor::new(BridgeTensor::float(Dispatch::avg_pool1d( + x.primitive.into_float(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ))) +} + +/// Applies a [1D max pooling](burn_backend::ops::ModuleOps::max_pool1d). +pub fn max_pool1d_with_indices( + x: Tensor<3>, + kernel_size: usize, + stride: usize, + padding: usize, + dilation: usize, + ceil_mode: bool, +) -> (Tensor<3>, Tensor<3, Int>) { + let indices_dtype = x.device().settings().int_dtype; + let output = Dispatch::max_pool1d_with_indices( + x.primitive.into_float(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype, + ); + + ( + Tensor::new(BridgeTensor::float(output.output)), + Tensor::new(BridgeTensor::int(output.indices)), + ) +} + +/// Applies a [2D max pooling with indices](burn_backend::ops::ModuleOps::max_pool2d_with_indices). +pub fn max_pool2d_with_indices( + x: Tensor<4>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, +) -> (Tensor<4>, Tensor<4, Int>) { + let indices_dtype = x.device().settings().int_dtype; + let output = Dispatch::max_pool2d_with_indices( + x.primitive.into_float(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + indices_dtype, + ); + + ( + Tensor::new(BridgeTensor::float(output.output)), + Tensor::new(BridgeTensor::int(output.indices)), + ) +} + +/// Applies a [2D adaptive avg pooling](burn_backend::ops::ModuleOps::adaptive_avg_pool2d). +pub fn adaptive_avg_pool2d(x: Tensor<4>, output_size: [usize; 2]) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::adaptive_avg_pool2d( + x.primitive.into_float(), + output_size, + ))) +} + +/// Applies a [1D adaptive avg pooling](burn_backend::ops::ModuleOps::adaptive_avg_pool1d). +pub fn adaptive_avg_pool1d(x: Tensor<3>, output_size: usize) -> Tensor<3> { + Tensor::new(BridgeTensor::float(Dispatch::adaptive_avg_pool1d( + x.primitive.into_float(), + output_size, + ))) +} + +/// Applies a [2D interpolation](burn_backend::ops::ModuleOps::interpolate). +pub fn interpolate( + x: Tensor<4>, + output_size: [usize; 2], + options: InterpolateOptions, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::interpolate( + x.primitive.into_float(), + output_size, + options, + ))) +} + +/// Applies a linear transformation to the input tensor using the given weight and bias. +/// +/// ```math +/// y = x @ weight + [bias] +/// ``` +/// +/// # Arguments: +/// +/// - `input` is the input tensor, ``[..., d_input]``. +/// - `weight` is the weight tensor, ``[d_input, d_output]``. +/// - `bias` is the bias tensor (optional), ``[d_output]``. +/// +/// # Returns: +/// +/// The transformed tensor, ``[..., d_output]``. +/// +/// # Compatibility +/// +/// This function differs from PyTorch's ``torch.nn.functional.linear`` in that it does not +/// transpose the weight matrix. In PyTorch, the weight matrix is transposed before +/// multiplication: +/// +/// ```math +/// y = x @ weight^T + [bias] +/// ``` +pub fn linear( + input: Tensor, + weight: Tensor<2>, + bias: Option>, +) -> Tensor { + if D == 1 { + // Insert and remove an extra batch dimension for the batch matmul to work. + let input = input.unsqueeze::<2>(); + let output = linear(input, weight, bias); + return output.squeeze_dim(0); + } + + // A quantized weight must stay quantized: `linear_impl` converts its + // operands to float, which would dequantize (materialize) the whole weight + // matrix on every forward. Route through the quantized matmul instead, which + // streams the packed weight directly — but reuse the same batch-fold policy + // the float `linear` applies, so a decode-shaped call folds its batches into + // the rows for one `[rows, d_in] @ [d_in, d_out]` matmul rather than a + // broadcast batched matmul that re-reads the packed weight per batch. + if let DType::QFloat(_) = weight.dtype() { + let dims = input.dims(); + let analysis = MatmulTransformAnalysis::from_shapes(&input.shape(), &weight.shape()); + + let output = match MatmulTransformPolicy::default().action(&analysis) { + MatmulTransformAction::MergeBatches { rows } => { + let d_in = dims[D - 1]; + let d_out = weight.dims()[1]; + + let folded = input.reshape([rows, d_in]).matmul(weight); + + let mut out_dims = dims; + out_dims[D - 1] = d_out; + folded.reshape(out_dims) + } + MatmulTransformAction::Keep => input.matmul(weight.unsqueeze::()), + }; + + return match bias { + Some(bias) => output + bias.unsqueeze(), + None => output, + }; + } + + Tensor::new(linear_impl( + input.primitive, + weight.primitive, + bias.map(|b| b.primitive), + )) +} + +fn linear_impl( + input: BridgeTensor, + weight: BridgeTensor, + bias: Option, +) -> BridgeTensor { + BridgeTensor::float(Dispatch::linear( + input.into_float(), + weight.into_float(), + bias.map(|b| b.into_float()), + )) +} + +/// Computes scaled dot-product attention: softmax(QKᵗ * scale) · V, +/// where scale defaults to 1/sqrt(head_dim) (configurable via `options.scale`). +/// Optionally applies masking, additive bias, causal masking, and softcap. +/// +/// # Arguments +/// - `query`: Query tensor of shape `[batch_size, num_heads, seq_len_q, head_dim]` +/// - `key`: Key tensor of shape `[batch_size, num_heads, seq_len_k, head_dim]` +/// - `value`: Value tensor of shape `[batch_size, num_heads, seq_len_k, val_dim]` +/// - `mask`: Optional boolean mask of shape `[batch_size, num_heads, seq_len_q, seq_len_k]`, +/// where `true` indicates positions to mask (i.e. set to -inf before softmax). +/// - `attn_bias`: Optional float tensor of shape `[batch_size, num_heads, seq_len_q, seq_len_k]` +/// added to the attention scores before softmax (e.g. ALiBi, relative position biases). +/// - `options`: Additional attention options (custom scale, softcap, causal masking). +/// +/// # Returns +/// A tensor of shape `[batch_size, num_heads, seq_len_q, val_dim]` +/// representing the attended context per head. +/// +/// # Note +/// This implementation does not support dropout and is intended for inference or +/// use cases where dropout is not needed. +pub fn attention( + query: Tensor<4>, + key: Tensor<4>, + value: Tensor<4>, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::attention( + query.primitive.into_float(), + key.primitive.into_float(), + value.primitive.into_float(), + mask.map(|mask| mask.primitive.into()), + attn_bias.map(|bias| bias.primitive.into_float()), + options, + ))) +} + +/// Exports attention fallback to test backend's attention against. +pub fn attention_fallback( + query: Tensor<4>, + key: Tensor<4>, + value: Tensor<4>, + mask: Option>, + attn_bias: Option>, + options: AttentionModuleOptions, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float( + burn_backend::ops::attention::attention_fallback::( + query.primitive.into_float(), + key.primitive.into_float(), + value.primitive.into_float(), + mask.map(|mask| mask.primitive.into()), + attn_bias.map(|bias| bias.primitive.into_float()), + options, + ), + )) +} + +/// Calculate the [2D convolution](burn_backend::ops::ModuleOps::conv2d) backward pass, returning the gradient for `weight`. +pub fn conv2d_weight_backward( + x: Tensor<4>, + weight: Tensor<4>, + output_grad: Tensor<4>, + options: ConvOptions<2>, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::conv2d_weight_backward( + x.primitive.into_float(), + weight.primitive.into_float(), + output_grad.primitive.into_float(), + options, + ))) +} + +/// Backward pass for the [avg pooling 2d](ModuleOps::avg_pool2d) operation. +pub fn avg_pool2d_backward( + x: Tensor<4>, + grad: Tensor<4>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + count_include_pad: bool, + ceil_mode: bool, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float(Dispatch::avg_pool2d_backward( + x.primitive.into_float(), + grad.primitive.into_float(), + kernel_size, + stride, + padding, + count_include_pad, + ceil_mode, + ))) +} + +/// Backward pass for the [max pooling 2d](ModuleOps::max_pool2d_with_indices) operation. +#[allow(clippy::too_many_arguments)] +pub fn max_pool2d_with_indices_backward( + x: Tensor<4>, + kernel_size: [usize; 2], + stride: [usize; 2], + padding: [usize; 2], + dilation: [usize; 2], + ceil_mode: bool, + output_grad: Tensor<4>, + indices: Tensor<4, Int>, +) -> Tensor<4> { + Tensor::new(BridgeTensor::float( + Dispatch::max_pool2d_with_indices_backward( + x.primitive.into_float(), + kernel_size, + stride, + padding, + dilation, + ceil_mode, + output_grad.primitive.into_float(), + indices.primitive.into(), + ) + .x_grad, + )) +} + +/// Applies Layer Normalization over the last dimension of the input tensor. +/// +/// Computes `(x - mean) / sqrt(var + epsilon) * gamma + beta`, where `mean` and +/// (biased) `var` are reduced over the last axis. +/// +/// # Shapes +/// +/// - input: `[..., any, d_model]` +/// - output: `[..., any, d_model]` +pub fn layer_norm( + input: Tensor, + gamma: Tensor<1>, + beta: Option>, + epsilon: f64, +) -> Tensor { + Tensor::new(layer_norm_impl( + input.primitive, + gamma.primitive, + beta.map(|b| b.primitive), + epsilon, + )) +} + +fn layer_norm_impl( + input: BridgeTensor, + gamma: BridgeTensor, + beta: Option, + epsilon: f64, +) -> BridgeTensor { + BridgeTensor::float(Dispatch::layer_norm( + input.into_float(), + gamma.into_float(), + beta.map(|b| b.into_float()), + epsilon, + )) +} diff --git a/crates/burn-tensor/src/tensor/quantization.rs b/crates/burn-tensor/src/tensor/quantization.rs new file mode 100644 index 0000000..1edb42c --- /dev/null +++ b/crates/burn-tensor/src/tensor/quantization.rs @@ -0,0 +1,57 @@ +use crate::{ + Tensor, + ops::{BridgeKind, BridgeTensor}, +}; +use burn_backend::quantization; + +// User-facing quantization data types come from burn-std. +use burn_dispatch::Dispatch; +pub use burn_std::quantization::*; + +/// The tensor quantization parameters. +pub type QuantizationParameters = QParams>; + +/// The observed input calibration range. +#[derive(Clone, Debug)] +pub struct CalibrationRange { + /// Minimum observed value(s). + pub min: Tensor<1>, + /// Maximum observed value(s). + pub max: Tensor<1>, +} + +/// Compute the quantization range mapping. +pub fn compute_range( + scheme: &QuantScheme, + tensor: &Tensor, + calibration: &Calibration, +) -> CalibrationRange { + let (kind, inner) = tensor.primitive.as_parts(); + let (min, max) = match kind { + BridgeKind::Float => { + quantization::compute_range::(scheme, inner.clone(), calibration) + } + BridgeKind::QFloat => unreachable!(), + _ => panic!("Should be Float primitive kind"), + }; + + CalibrationRange { + min: Tensor::new(BridgeTensor::float(min)), + max: Tensor::new(BridgeTensor::float(max)), + } +} + +/// Compute the quantization parameters. +pub fn compute_q_params(scheme: &QuantScheme, range: CalibrationRange) -> QuantizationParameters { + let (min_kind, min) = range.min.primitive.into_parts(); + let (max_kind, max) = range.max.primitive.into_parts(); + match (min_kind, max_kind) { + (BridgeKind::Float, BridgeKind::Float) => { + let qparams = quantization::compute_q_params::(scheme, min, max); + QuantizationParameters { + scales: Tensor::new(BridgeTensor::float(qparams.scales)), + } + } + _ => unreachable!(), + } +} diff --git a/crates/burn-tensor/src/tensor/report.rs b/crates/burn-tensor/src/tensor/report.rs new file mode 100644 index 0000000..1d2f76f --- /dev/null +++ b/crates/burn-tensor/src/tensor/report.rs @@ -0,0 +1,105 @@ +use super::Tensor; + +use colored::*; + +/// Checks the closeness of two tensors and prints the results. +/// +/// Compares tensors by checking the absolute difference between each element. +/// Prints the percentage of elements within specified tolerances. +/// +/// # Arguments +/// +/// * `output` - The output tensor. +/// * `expected` - The expected tensor. +/// +/// # Example +/// +/// ```no_run +/// use burn_tensor::{check_closeness, Tensor}; +/// +/// fn example() { +/// let device = Default::default(); +/// let tensor1 = Tensor::<1>::from_floats( +/// [1.0, 2.0, 3.0, 4.0, 5.0, 6.001, 7.002, 8.003, 9.004, 10.1], +/// &device, +/// ); +/// let tensor2 = Tensor::<1>::from_floats( +/// [1.0, 2.0, 3.0, 4.000, 5.0, 6.0, 7.001, 8.002, 9.003, 10.004], +/// &device, +/// ); +/// check_closeness(&tensor1, &tensor2); +///} +/// ``` +/// +/// # Output +/// +/// ```text +/// Tensor Closeness Check Results: +/// =============================== +/// Epsilon: 1e-1 +/// Close elements: 10/10 (100.00%) +/// [PASS] All elements are within tolerance +/// +/// Epsilon: 1e-2 +/// Close elements: 10/10 (100.00%) +/// [PASS] All elements are within tolerance +/// +/// Epsilon: 1e-3 +/// Close elements: 9/10 (90.00%) +/// [WARN] Most elements are within tolerance +/// +/// Epsilon: 1e-4 +/// Close elements: 6/10 (60.00%) +/// [FAIL] Significant differences detected +/// +/// Epsilon: 1e-5 +/// Close elements: 5/10 (50.00%) +/// [FAIL] Significant differences detected +/// +/// Epsilon: 1e-6 +/// Close elements: 5/10 (50.00%) +/// [FAIL] Significant differences detected +/// +/// Epsilon: 1e-7 +/// Close elements: 5/10 (50.00%) +/// [FAIL] Significant differences detected +/// +/// Epsilon: 1e-8 +/// Close elements: 5/10 (50.00%) +/// [FAIL] Significant differences detected +/// +/// Closeness check complete. +/// ``` +pub fn check_closeness(output: &Tensor, expected: &Tensor) { + println!("{}", "Tensor Closeness Check Results:".bold()); + println!("==============================="); + + for epsilon in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8].iter() { + println!("{} {:e}", "Epsilon:".bold(), epsilon); + + let close = output + .clone() + .is_close(expected.clone(), Some(*epsilon), Some(*epsilon)); + let data = close.clone().into_data(); + let num_elements = data.num_elements(); + + // Count the number of elements that are close (true) + let count = data.iter::().filter(|x| *x).count(); + + let percentage = (count as f64 / num_elements as f64) * 100.0; + + println!(" Close elements: {count}/{num_elements} ({percentage:.2}%)"); + + if percentage == 100.0 { + println!(" {} All elements are within tolerance", "[PASS]".green()); + } else if percentage >= 90.0 { + println!(" {} Most elements are within tolerance", "[WARN]".yellow()); + } else { + println!(" {} Significant differences detected", "[FAIL]".red()); + } + + println!(); + } + + println!("{}", "Closeness check complete.".bold()); +} diff --git a/crates/burn-tensor/src/tensor/signal/blackman_window.rs b/crates/burn-tensor/src/tensor/signal/blackman_window.rs new file mode 100644 index 0000000..c142f05 --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/blackman_window.rs @@ -0,0 +1,92 @@ +use crate::{Float, Int, Tensor, TensorCreationOptions, check, check::TensorCheck}; + +/// Creates a 1D Blackman window tensor. +/// +#[cfg_attr( + doc, + doc = r#" +$$w_n = 0.42 - 0.5 \cos\left(\frac{2\pi n}{N}\right) + 0.08 \cos\left(\frac{4\pi n}{N}\right)$$ + +where $N$ = `size` when `periodic` is `true`, or $N$ = `size - 1` when `periodic` is `false`. +"# +)] +#[cfg_attr( + not(doc), + doc = "`w_n = 0.42 - 0.5 * cos(2πn / N) + 0.08 * cos(4πn / N)` where N = size (periodic) or N = size-1 (symmetric)" +)] +/// +/// # Arguments +/// - `size`: Size of the returned 1D window tensor. +/// - `periodic`: If `true`, the window is treated as periodic (i.e., `N = size`). +/// If `false`, the window is symmetric (i.e., `N = size - 1`). +/// - `options`: Controls the output device and optional dtype. Accepts: +/// - `&device` - uses the device's default float dtype +/// - `(&device, DType::F32)` - uses an explicit dtype +/// - `TensorCreationOptions` directly for full control. +/// +/// # Returns +/// - A 1D tensor of shape `[size]` containing the window. +/// +/// # Notes +/// - If `size == 0`, the function returns an empty tensor. +/// - If `size == 1`, the returned window contains a single value 1.0 which overrides the formula. +/// +/// # Panics +/// Panics if `size` exceeds `i64::MAX`. +/// +/// # Example +/// ```rust +/// use burn_tensor::{Device, DType, signal::blackman_window}; +/// +/// fn example() { +/// // Creating a window with default dtype +/// let device = Device::default(); +/// let window_tensor = blackman_window(5, true, &device); +/// // Output: [0.0, 0.20077015, 0.84922993, 0.8492298, 0.2007701] +/// +/// // Creating a window with explicit dtype. +/// // Note that this does not perform the computation at higher precision but it +/// // widens the storage of the returned tensor to F64. +/// let device = Device::default(); +/// let window_tensor_f64 = blackman_window(5, true, (&device, DType::F64)); +/// // Output: [0.0, 0.20077015, 0.84922993, 0.8492298, 0.2007701] +/// } +/// ``` +pub fn blackman_window( + size: usize, + periodic: bool, + options: impl Into, +) -> Tensor<1> { + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + let shape = [size]; + check!(TensorCheck::creation_ops::<1>("BlackmanWindow", &shape)); + + if size == 0 { + return Tensor::<1>::empty(shape, opt).cast(dtype); + } + + if size == 1 { + return Tensor::<1>::ones(shape, opt).cast(dtype); + } + + let size_i64 = i64::try_from(size).expect("BlackmanWindow size doesn't fit in i64 range."); + let denominator = if periodic { size } else { size - 1 }; + let angular_increment = (2.0 * core::f64::consts::PI) / denominator as f64; + let cos_val = Tensor::<1, Int>::arange(0..size_i64, &opt.device) + .float() + .mul_scalar(angular_increment) + .cos(); + + // Using the double angle property of cosine: cos(2θ) = 2cos^2(θ) - 1 + // w[n] = 0.42 - 0.5cos(2πn / N) + 0.08cos(4πn / N) + // w[n] = 0.42 - 0.5cos(2πn / N) + 0.08cos(2 * (2πn / N)) + // w[n] = 0.42 - 0.5cos(2πn / N) + 0.08(2cos^2(2πn / N) - 1) + // w[n] = 0.34 - 0.5cos(2πn / N) + 0.16cos^2(2πn / N) + let first_cos_term = cos_val.clone().mul_scalar(-0.5); + let second_cos_term = cos_val.powi_scalar(2).mul_scalar(0.16); + first_cos_term + .add(second_cos_term) + .add_scalar(0.34) + .cast(dtype) +} diff --git a/crates/burn-tensor/src/tensor/signal/fft.rs b/crates/burn-tensor/src/tensor/signal/fft.rs new file mode 100644 index 0000000..a48d365 --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/fft.rs @@ -0,0 +1,280 @@ +use alloc::vec; +use burn_backend::ops::ModuleOps; +use burn_dispatch::Dispatch; + +use crate::Tensor; +use crate::check; +use crate::check::TensorCheck; +use crate::ops::BridgeTensor; + +/// Computes the 1-dimensional discrete Fourier Transform of real-valued input. +/// +/// Since the input is real, the Hermitian symmetry is exploited, and only the +/// first non-redundant values are returned ($N/2 + 1$). +/// For now, the autodiff is not yet supported +/// +#[cfg_attr( + doc, + doc = r#" +The mathematical formulation for each element $k$ in the frequency domain is: + +$$X\[k\] = \sum_{n=0}^{N-1} x\[n\] \left\[ \cos\left(\frac{2\pi kn}{N}\right) - i \sin\left(\frac{2\pi kn}{N}\right) \right\]$$ + +where $N$ is the size of the signal along the specified dimension. +"# +)] +#[cfg_attr(not(doc), doc = r"X\[k\] = Σ x\[n\] * exp(-i*2πkn/N)")] +/// +/// # Arguments +/// +/// * `signal` - The input tensor containing the real-valued signal. +/// * `dim` - The dimension along which to take the FFT. +/// * `n` - Optional FFT length. When `None`, the signal must be a power of two along `dim`. +/// When `Some(n)`, `n` must also be a power of two; the signal is truncated or zero-padded +/// to length `n`. Non-power-of-two `n` is rejected with a panic (true arbitrary-size DFT +/// support via Bluestein's algorithm is tracked as a follow-up). +/// +/// # Returns +/// +/// A tuple containing: +/// 1. The real part of the spectrum. Output length along `dim` is `n / 2 + 1` (using `n` or +/// `signal_len` respectively). +/// 2. The imaginary part of the spectrum (same shape). +/// +/// # Example +/// +/// ```rust +/// use burn_tensor::Tensor; +/// +/// fn example() { +/// let device = Default::default(); +/// let signal = Tensor::<1>::from_floats([1.0, 2.0, 3.0, 4.0], &device); +/// let (real, imag) = burn_tensor::signal::rfft(signal, 0, None); +/// } +/// ``` +pub fn rfft( + signal: Tensor, + dim: usize, + n: Option, +) -> (Tensor, Tensor) { + check!(TensorCheck::check_dim::(dim)); + + match n { + None => check!(TensorCheck::check_is_power_of_two::( + &signal.shape(), + dim + )), + Some(n) => { + assert!(n >= 1, "rfft: n must be >= 1, got {n}"); + assert!( + n.is_power_of_two(), + "rfft: n must be a power of two, got {n}. True non-power-of-two \ + DFT support is tracked as a follow-up (Bluestein's algorithm)." + ); + } + } + + let (re, im) = rfft_impl(signal.primitive, dim, n); + (Tensor::new(re), Tensor::new(im)) +} + +fn rfft_impl(signal: BridgeTensor, dim: usize, n: Option) -> (BridgeTensor, BridgeTensor) { + let (re, im) = Dispatch::rfft(signal.into_float(), dim, n); + (BridgeTensor::float(re), BridgeTensor::float(im)) +} + +/// Computes the 1-dimensional inverse discrete Fourier Transform for real-valued signals. +/// +/// This function reconstructs the real-valued time-domain signal from the +/// first non-redundant values ($N/2 + 1$) of the frequency-domain spectrum. +/// For now, the autodiff is not yet supported. +/// +#[cfg_attr( + doc, + doc = r#" +The mathematical formulation for each element $n$ in the time domain is: + +$$x\[n\] = \frac{1}{N} \sum_{k=0}^{N-1} X\[k\] \left\[ \cos\left(\frac{2\pi kn}{N}\right) + i \sin\left(\frac{2\pi kn}{N}\right) \right\]$$ + +where $N$ is the size of the reconstructed signal. +"# +)] +#[cfg_attr(not(doc), doc = r"x\[n\] = (1/N) * Σ X\[k\] * exp(i*2πkn/N)")] +/// +/// # Arguments +/// +/// * `spectrum_re` - The real part of the spectrum. +/// * `spectrum_im` - The imaginary part of the spectrum. +/// * `dim` - The dimension along which to take the inverse FFT. +/// * `n` - Optional output signal length. When `None`, the reconstructed signal length +/// `2 * (size - 1)` must be a power of two. When `Some(n)`, `n` must also be a power of +/// two and the output has exactly `n` samples. Non-power-of-two `n` is rejected. +/// +/// # Returns +/// +/// The reconstructed real-valued signal. +/// +/// # Example +/// +/// ```rust +/// use burn_tensor::Tensor; +/// +/// fn example() { +/// let device = Default::default(); +/// let real = Tensor::<1>::from_floats([10.0, -2.0, 2.0], &device); +/// let imag = Tensor::<1>::from_floats([0.0, 2.0, 0.0], &device); +/// let signal = burn_tensor::signal::irfft(real, imag, 0, None); +/// } +/// ``` +pub fn irfft( + spectrum_re: Tensor, + spectrum_im: Tensor, + dim: usize, + n: Option, +) -> Tensor { + check!(TensorCheck::check_dim::(dim)); + + if let Some(n) = n { + assert!(n >= 1, "irfft: n must be >= 1, got {n}"); + assert!( + n.is_power_of_two(), + "irfft: n must be a power of two, got {n}. True non-power-of-two \ + DFT support is tracked as a follow-up (Bluestein's algorithm)." + ); + } + + Tensor::new(irfft_impl( + spectrum_re.primitive, + spectrum_im.primitive, + dim, + n, + )) +} + +fn irfft_impl( + spectrum_re: BridgeTensor, + spectrum_im: BridgeTensor, + dim: usize, + n: Option, +) -> BridgeTensor { + BridgeTensor::float(Dispatch::irfft( + spectrum_re.into_float(), + spectrum_im.into_float(), + dim, + n, + )) +} + +/// Computes the 1-dimensional discrete Fourier Transform of complex-valued input. +/// +/// Internally calls [`rfft`] on the real and imaginary parts separately, +/// extends each half-spectrum to the full `N`-bin spectrum via Hermitian +/// symmetry. +/// +/// Autodiff is not yet supported. +/// +#[cfg_attr( + doc, + doc = r#" + +Due to the linearity of the Fourier Transform, a complex-valued signal $x\[n\] = x_{re}\[n\] + i x_{im}\[n\]$ can be transformed by applying the FFT to its real and imaginary parts separately: + +$$ \text{FFT}(x\[n\]) = \text{FFT}(x_{re}\[n\]) + i \text{FFT}(x_{im}\[n\]) $$ + +Since $x_{re}\[n\]$ and $x_{im}\[n\]$ are purely real, their transforms can be computed efficiently using the real FFT ([`rfft`]). The full spectrum is then reconstructed by exploiting Hermitian symmetry. +"# +)] +#[cfg_attr(not(doc), doc = r"X\[k\] = Σ x\[n\] * exp(-i*2πkn/N)")] +/// +/// # Arguments +/// +/// * `signal_re` - The real part of the complex input signal. +/// * `signal_im` - The imaginary part of the complex input signal. Must have the +/// same shape as `signal_re`. +/// * `dim` - The dimension along which to take the FFT. +/// * `n` - Optional FFT length. When `None`, the signal must be a power of two +/// along `dim`. When `Some(n)`, `n` must also be a power of two; the signal is +/// truncated or zero-padded to length `n`. +/// +/// # Returns +/// +/// A tuple `(re, im)` representing the full complex spectrum, each with `n` +/// elements along `dim`. +/// +/// # Example +/// +/// ```rust +/// use burn_tensor::Tensor; +/// +/// fn example() { +/// let device = Default::default(); +/// let re = Tensor::<1>::from_floats([1.0, 0.0, -1.0, 0.0], &device); +/// let im = Tensor::<1>::from_floats([0.0, 1.0, 0.0, -1.0], &device); +/// let (spec_re, spec_im) = burn_tensor::signal::cfft(re, im, 0, None); +/// } +/// ``` +pub fn cfft( + signal_re: Tensor, + signal_im: Tensor, + dim: usize, + n: Option, +) -> (Tensor, Tensor) { + assert!( + signal_re.shape() == signal_im.shape(), + "cfft: signal_re and signal_im must have the same shape, \ + got {:?} and {:?}", + signal_re.shape(), + signal_im.shape(), + ); + + check!(TensorCheck::check_dim::(dim)); + let fft_size = n.unwrap_or(signal_re.dims()[dim]); + + // rfft validates power-of-two and n constraints internally + let (xr, xi) = rfft(signal_re, dim, n); + let (yr, yi) = rfft(signal_im, dim, n); + + // Extend half-spectra (N/2+1 bins) to full N-bin spectra via Hermitian symmetry + let (xr, xi) = hermitian_extend(xr, xi, dim, fft_size); + let (yr, yi) = hermitian_extend(yr, yi, dim, fft_size); + + // FFT(z) = FFT(x) + i·FFT(y) + // = (Xr + i·Xi) + i·(Yr + i·Yi) + // = (Xr - Yi) + i·(Xi + Yr) + (xr - yi, xi + yr) +} + +/// Extend a half-spectrum from [`rfft`] (`N/2 + 1` bins) to the full `N`-bin +/// spectrum using Hermitian symmetry: `X[k] = conj(X[N-k])` for `k > N/2`. +pub(super) fn hermitian_extend( + half_re: Tensor, + half_im: Tensor, + dim: usize, + full_len: usize, +) -> (Tensor, Tensor) { + let half_len = half_re.dims()[dim]; // N/2 + 1 + + // For N <= 2, the half-spectrum already covers all bins + if full_len <= half_len { + return (half_re, half_im); + } + + // Mirror bins: reverse of bins 1..N/2-1 (skipping the Nyquist bin), + // with conjugated imaginary part. This produces X[N/2+1], X[N/2+2], ..., X[N-1] + let mirror_len = full_len - half_len; // N/2 - 1 + let mirror_re = half_re + .clone() + .narrow(dim, 1, mirror_len) + .flip([dim as isize]); + let mirror_im = half_im + .clone() + .narrow(dim, 1, mirror_len) + .flip([dim as isize]) + .neg(); + + // Full spectrum = [half_spectrum, conjugate_mirror] + let full_re = Tensor::cat(vec![half_re, mirror_re], dim); + let full_im = Tensor::cat(vec![half_im, mirror_im], dim); + + (full_re, full_im) +} diff --git a/crates/burn-tensor/src/tensor/signal/hamming_window.rs b/crates/burn-tensor/src/tensor/signal/hamming_window.rs new file mode 100644 index 0000000..6987d94 --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/hamming_window.rs @@ -0,0 +1,67 @@ +use crate::{Float, Int, Tensor, TensorCreationOptions, check, check::TensorCheck}; + +/// Creates a 1D Hamming window. +/// +#[cfg_attr( + doc, + doc = r#" +$$w_n = \alpha - \beta \cos\left(\frac{2\pi n}{N}\right)$$ + +where $\alpha = 25/46$, $\beta = 1 - \alpha$, and $N$ = `size` when `periodic` is `true`, or $N$ = `size - 1` when `periodic` is `false`. +"# +)] +#[cfg_attr( + not(doc), + doc = "`w_n = 25/46 - 21/46 * cos(2πn/N)` where N = size (periodic) or N = size-1 (symmetric)" +)] +/// +/// # Notes +/// +/// - `size == 0` returns an empty tensor. +/// - `size == 1` returns `[1.0]` regardless of `periodic`. +/// +/// # Example +/// +/// ```rust +/// use burn_tensor::Device; +/// use burn_tensor::signal::hamming_window; +/// +/// fn example() { +/// let device = Default::default(); +/// let window = hamming_window(8, true, &device); +/// println!("{window}"); +/// } +/// ``` +pub fn hamming_window( + size: usize, + periodic: bool, + options: impl Into, +) -> Tensor<1> { + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + let shape = [size]; + check!(TensorCheck::creation_ops::<1>("HammingWindow", &shape)); + + if size == 0 { + return Tensor::<1>::empty(shape, opt).cast(dtype); + } + + if size == 1 { + return Tensor::<1>::ones(shape, opt).cast(dtype); + } + + let size_i64 = i64::try_from(size).expect("HammingWindow size doesn't fit in i64 range."); + let denominator = if periodic { size } else { size - 1 }; + let angular_increment = (2.0 * core::f64::consts::PI) / denominator as f64; + + let alpha = 25.0_f64 / 46.0_f64; + let beta = 1.0 - alpha; + + Tensor::<1, Int>::arange(0..size_i64, &opt.device) + .float() + .mul_scalar(angular_increment) + .cos() + .mul_scalar(-beta) + .add_scalar(alpha) + .cast(dtype) +} diff --git a/crates/burn-tensor/src/tensor/signal/hann_window.rs b/crates/burn-tensor/src/tensor/signal/hann_window.rs new file mode 100644 index 0000000..a6eac0b --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/hann_window.rs @@ -0,0 +1,64 @@ +use crate::{Float, Int, Tensor, TensorCreationOptions, check, check::TensorCheck}; + +/// Creates a 1D Hann window. +/// +#[cfg_attr( + doc, + doc = r#" +$$w_n = 0.5 - 0.5 \cos\left(\frac{2\pi n}{N}\right)$$ + +where $N$ = `size` when `periodic` is `true`, or $N$ = `size - 1` when `periodic` is `false`. +"# +)] +#[cfg_attr( + not(doc), + doc = "`w_n = 0.5 - 0.5 * cos(2πn/N)` where N = size (periodic) or N = size-1 (symmetric)" +)] +/// +/// # Notes +/// +/// - `size == 0` returns an empty tensor. +/// - `size == 1` returns `[1.0]` regardless of `periodic`. +/// +/// # Example +/// +/// ```rust +/// use burn_tensor::Device; +/// use burn_tensor::signal::hann_window; +/// +/// fn example() { +/// let device = Default::default(); +/// let window = hann_window(8, true, &device); +/// println!("{window}"); +/// } +/// ``` +pub fn hann_window( + size: usize, + periodic: bool, + options: impl Into, +) -> Tensor<1> { + let opt = options.into(); + let dtype = opt.resolve_dtype::(); + let shape = [size]; + check!(TensorCheck::creation_ops::<1>("HannWindow", &shape)); + + if size == 0 { + return Tensor::<1>::empty(shape, opt).cast(dtype); + } + + if size == 1 { + return Tensor::<1>::ones(shape, opt).cast(dtype); + } + + let size_i64 = i64::try_from(size).expect("HannWindow size doesn't fit in i64 range."); + let denominator = if periodic { size } else { size - 1 }; + let angular_increment = (2.0 * core::f64::consts::PI) / denominator as f64; + + Tensor::<1, Int>::arange(0..size_i64, &opt.device) + .float() + .mul_scalar(angular_increment) + .cos() + .mul_scalar(-0.5) + .add_scalar(0.5) + .cast(dtype) +} diff --git a/crates/burn-tensor/src/tensor/signal/mod.rs b/crates/burn-tensor/src/tensor/signal/mod.rs new file mode 100644 index 0000000..5cd8ac1 --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/mod.rs @@ -0,0 +1,11 @@ +mod blackman_window; +mod fft; +mod hamming_window; +mod hann_window; +mod stft; + +pub use blackman_window::*; +pub use fft::*; +pub use hamming_window::*; +pub use hann_window::*; +pub use stft::*; diff --git a/crates/burn-tensor/src/tensor/signal/stft.rs b/crates/burn-tensor/src/tensor/signal/stft.rs new file mode 100644 index 0000000..a29b67f --- /dev/null +++ b/crates/burn-tensor/src/tensor/signal/stft.rs @@ -0,0 +1,300 @@ +use alloc::vec; + +use crate::Tensor; +use crate::ops::PadMode; + +use super::{hermitian_extend, irfft, rfft}; + +/// Configuration shared by [`stft`] and [`istft`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StftOptions { + /// Size of each FFT frame (must be >= 1). + pub n_fft: usize, + /// Stride between successive frames (must be >= 1 and <= effective window length + /// so overlap-add can reconstruct the signal). + pub hop_length: usize, + /// Window length. If `Some(w)`, the window is center-padded to `n_fft`. Defaults to `n_fft`. + pub win_length: Option, + /// If `true`, the signal is reflect-padded by `n_fft / 2` on both sides before framing. + pub center: bool, + /// If `true` (typical for real input), output has `n_fft/2 + 1` frequency bins; otherwise + /// the full `n_fft` bins are returned. + pub onesided: bool, +} + +impl StftOptions { + /// Construct default options for the given FFT size, matching PyTorch defaults + /// (`hop_length = n_fft / 4`, `win_length = None`, `center = true`, `onesided = true`). + pub fn new(n_fft: usize) -> Self { + Self { + n_fft, + hop_length: (n_fft / 4).max(1), + win_length: None, + center: true, + onesided: true, + } + } + + fn effective_win_length(&self) -> usize { + self.win_length.unwrap_or(self.n_fft) + } + + fn assert_valid(&self, op: &'static str) { + let n_fft = self.n_fft; + let hop_length = self.hop_length; + assert!(n_fft >= 1, "{op}: n_fft must be >= 1, got {n_fft}"); + assert!( + n_fft.is_power_of_two(), + "{op}: n_fft must be a power of two, got {n_fft}. True non-power-of-two \ + DFT support is tracked as a follow-up (Bluestein's algorithm)." + ); + assert!( + hop_length >= 1, + "{op}: hop_length must be >= 1, got {hop_length}" + ); + let win_len = self.effective_win_length(); + assert!( + win_len >= 1, + "{op}: effective win_length must be >= 1, got {win_len}" + ); + assert!( + win_len <= n_fft, + "{op}: win_length ({win_len}) must be <= n_fft ({n_fft})" + ); + assert!( + hop_length <= win_len, + "{op}: hop_length ({hop_length}) must be <= effective win_length ({win_len}) \ + so the window/hop combination satisfies the COLA/NOLA condition required \ + for invertibility" + ); + } +} + +impl Default for StftOptions { + fn default() -> Self { + Self::new(400) + } +} + +/// Computes the Short-Time Fourier Transform (STFT). +/// +/// Splits the signal into overlapping windowed frames and computes the FFT on each. +/// +/// # Returns +/// +/// A tensor of shape `[batch, n_frames, n_freqs, 2]` where the last dimension holds +/// `[real, imaginary]`. +pub fn stft(signal: Tensor<2>, window: Option>, options: StftOptions) -> Tensor<4> { + options.assert_valid("stft"); + let n_fft = options.n_fft; + let hop_length = options.hop_length; + let center = options.center; + let onesided = options.onesided; + let win_len = options.effective_win_length(); + + let device = signal.device(); + + let window = match window { + Some(w) => { + assert_eq!( + w.dims()[0], + win_len, + "stft: window length ({}) must match effective win_length ({win_len})", + w.dims()[0], + ); + w + } + None => Tensor::ones([win_len], &device), + }; + + let window = pad_window_to_n_fft(window, win_len, n_fft); + + let [_, raw_sig_len] = signal.dims(); + if center { + assert!( + raw_sig_len > n_fft / 2, + "stft: signal length ({raw_sig_len}) must be > n_fft/2 ({}) for reflect pad with center=true", + n_fft / 2 + ); + } + + let signal = if center { + let pad_amount = n_fft / 2; + signal.pad([(0, 0), (pad_amount, pad_amount)], PadMode::Reflect) + } else { + signal + }; + + let [batch, sig_len] = signal.dims(); + assert!( + sig_len >= n_fft, + "stft: signal length ({sig_len}) must be >= n_fft ({n_fft}) after padding" + ); + + let n_frames = 1 + (sig_len - n_fft) / hop_length; + + // Extract overlapping frames: [batch, n_frames, n_fft] + let frames: Tensor<3> = signal.unfold(1, n_fft, hop_length); + + // Apply window (broadcast over batch and n_frames) + let window_3d: Tensor<3> = window.reshape([1, 1, n_fft]); + let windowed = frames.mul(window_3d); + + // Flatten to [batch * n_frames, n_fft] for rfft + let flat: Tensor<2> = windowed.reshape([batch * n_frames, n_fft]); + + // rfft returns n_fft/2 + 1 bins along dim=1 (n_fft is pow2). + let (re, im) = rfft(flat, 1, Some(n_fft)); + + let (re, im, n_freqs) = if onesided { + (re, im, n_fft / 2 + 1) + } else { + let (re_full, im_full) = hermitian_extend(re, im, 1, n_fft); + (re_full, im_full, n_fft) + }; + + // Reshape to [batch, n_frames, n_freqs] then stack real/imag + let re: Tensor<3> = re.reshape([batch, n_frames, n_freqs]); + let im: Tensor<3> = im.reshape([batch, n_frames, n_freqs]); + + Tensor::stack::<4>(vec![re, im], 3) +} + +/// Center-pad window from `win_len` to `n_fft` with zeros. +fn pad_window_to_n_fft(window: Tensor<1>, win_len: usize, n_fft: usize) -> Tensor<1> { + if win_len < n_fft { + let pad_left = (n_fft - win_len) / 2; + let pad_right = n_fft - win_len - pad_left; + window.pad([(pad_left, pad_right)], PadMode::Constant(0.0)) + } else { + window + } +} + +/// Computes the inverse Short-Time Fourier Transform (ISTFT). +/// +/// Reconstructs a time-domain signal from its STFT representation using overlap-add. +/// +/// # Arguments +/// +/// * `stft_matrix` - Complex STFT tensor of shape `[batch, n_frames, n_freqs, 2]`. +/// * `window` - Window tensor used in the forward STFT. Defaults to rectangular. +/// * `length` - Optional output signal length. If `None`, the length is inferred. +/// * `options` - STFT configuration (must match the forward STFT). +/// +/// # Returns +/// +/// A real-valued tensor of shape `[batch, signal_length]`. +pub fn istft( + stft_matrix: Tensor<4>, + window: Option>, + length: Option, + options: StftOptions, +) -> Tensor<2> { + options.assert_valid("istft"); + let n_fft = options.n_fft; + let hop_length = options.hop_length; + let center = options.center; + let onesided = options.onesided; + let [batch, n_frames, n_freqs_in, two] = stft_matrix.dims(); + assert_eq!( + two, 2, + "istft: last dimension of stft_matrix must be 2 (real, imag), got {two}" + ); + assert!( + n_frames >= 1, + "istft: stft_matrix must contain at least one frame, got n_frames=0" + ); + let expected_n_freqs = if onesided { n_fft / 2 + 1 } else { n_fft }; + assert_eq!( + n_freqs_in, expected_n_freqs, + "istft: n_freqs dimension ({n_freqs_in}) does not match expected for \ + n_fft={n_fft}, onesided={onesided} (expected {expected_n_freqs})" + ); + + let win_len = options.effective_win_length(); + let device = stft_matrix.device(); + + let window = match window { + Some(w) => { + assert_eq!( + w.dims()[0], + win_len, + "istft: window length ({}) must match effective win_length ({win_len})", + w.dims()[0], + ); + w + } + None => Tensor::ones([win_len], &device), + }; + let window = pad_window_to_n_fft(window, win_len, n_fft); + + // Split real and imaginary: each [batch, n_frames, n_freqs] + let re: Tensor<3> = stft_matrix.clone().narrow(3, 0, 1).squeeze_dim(3); + let im: Tensor<3> = stft_matrix.narrow(3, 1, 1).squeeze_dim(3); + + let re: Tensor<2> = re.reshape([batch * n_frames, n_freqs_in]); + let im: Tensor<2> = im.reshape([batch * n_frames, n_freqs_in]); + + // Extract onesided spectrum for irfft + let (re_half, im_half) = if onesided { + (re, im) + } else { + let half = n_fft / 2 + 1; + (re.narrow(1, 0, half), im.narrow(1, 0, half)) + }; + + let frames = irfft(re_half, im_half, 1, Some(n_fft)); + + // Reshape to [batch, n_frames, n_fft] + let frames: Tensor<3> = frames.reshape([batch, n_frames, n_fft]); + + // Apply window to each frame + let window_3d: Tensor<3> = window.reshape([1, 1, n_fft]); + let windowed = frames.mul(window_3d.clone()); + + let expected_len = n_fft + (n_frames - 1) * hop_length; + let mut output = Tensor::<2>::zeros([batch, expected_len], &device); + let mut window_sum = Tensor::<2>::zeros([batch, expected_len], &device); + + let window_1d: Tensor<1> = window_3d.clone().reshape([n_fft]); + let window_sq_1d: Tensor<1> = window_1d.clone().mul(window_1d); + + for f in 0..n_frames { + let start = f * hop_length; + let right_pad = expected_len - n_fft - start; + let frame: Tensor<2> = windowed.clone().narrow(1, f, 1).squeeze_dim(1); + let frame_full = frame.pad([(0, 0), (start, right_pad)], PadMode::Constant(0.0)); + output = output.add(frame_full); + + let win_full: Tensor<1> = window_sq_1d + .clone() + .pad([(start, right_pad)], PadMode::Constant(0.0)); + let win_full: Tensor<2> = win_full.unsqueeze_dim(0); + window_sum = window_sum.add(win_full); + } + + let window_sum = window_sum.clamp_min(1e-10); + output = output.div(window_sum); + + // Remove center padding + let output = if center { + let pad_amount = n_fft / 2; + let trimmed_len = expected_len - 2 * pad_amount; + output.narrow(1, pad_amount, trimmed_len) + } else { + output + }; + + match length { + Some(len) => { + let current_len = output.dims()[1]; + if len <= current_len { + output.narrow(1, 0, len) + } else { + output.pad([(0, 0), (0, len - current_len)], PadMode::Constant(0.0)) + } + } + None => output, + } +} diff --git a/crates/burn-tensor/src/tensor/stats/mod.rs b/crates/burn-tensor/src/tensor/stats/mod.rs new file mode 100644 index 0000000..16f130e --- /dev/null +++ b/crates/burn-tensor/src/tensor/stats/mod.rs @@ -0,0 +1,69 @@ +use crate::{Int, Tensor}; + +pub fn var(tensor: Tensor, dim: usize) -> Tensor { + let mean = tensor.clone().mean_dim(dim); + var_with_mean(tensor, mean, dim) +} + +pub fn var_with_mean(tensor: Tensor, mean: Tensor, dim: usize) -> Tensor { + let n = tensor.shape()[dim] - 1; + var_with_mean_n(tensor, mean, dim, n) +} + +pub fn var_bias(tensor: Tensor, dim: usize) -> Tensor { + let mean = tensor.clone().mean_dim(dim); + var_with_mean_bias(tensor, mean, dim) +} + +pub fn var_with_mean_bias( + tensor: Tensor, + mean: Tensor, + dim: usize, +) -> Tensor { + let n = tensor.shape()[dim]; + var_with_mean_n(tensor, mean, dim, n) +} + +pub fn var_with_mean_n( + tensor: Tensor, + mean: Tensor, + dim: usize, + n: usize, +) -> Tensor { + tensor.sub(mean).square().sum_dim(dim).div_scalar(n as f32) +} + +pub fn median(tensor: Tensor, dim: usize) -> Tensor { + let total_elem_numbers = tensor.dims()[dim]; + let sorted_tensor = tensor.sort(dim); + + // Following the PyTorch behavior: + // - Odd count: the median + // - Even count: the lower of the two median elements + // + // Example: + // - 5 elements: (5 - 1) / 2 = 4 / 2 = 2 + // - 4 elements: (4 - 1) / 2 = 3 / 2 = 1 + let median_index = (total_elem_numbers - 1) / 2; + sorted_tensor.narrow(dim, median_index, 1) +} + +pub fn median_with_indices( + tensor: Tensor, + dim: usize, +) -> (Tensor, Tensor) { + let total_elem_numbers = tensor.dims()[dim]; + let (sorted_tensor, indices) = tensor.sort_with_indices(dim); + + // Following the PyTorch behavior: + // - Odd count: the median + // - Even count: the lower of the two median elements + // + // Example: + // - 5 elements: (5 - 1) / 2 = 4 / 2 = 2 + // - 4 elements: (4 - 1) / 2 = 3 / 2 = 1 + let median_index = (total_elem_numbers - 1) / 2; + let median_values = sorted_tensor.narrow(dim, median_index, 1); + let median_indices = indices.narrow(dim, median_index, 1); + (median_values, median_indices) +} diff --git a/crates/burn-train/Cargo.toml b/crates/burn-train/Cargo.toml new file mode 100644 index 0000000..ba1d9c2 --- /dev/null +++ b/crates/burn-train/Cargo.toml @@ -0,0 +1,72 @@ +[package] +authors = ["nathanielsimard "] +categories = ["science"] +description = "Training crate for the Burn framework" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "tensor", "pytorch", "ndarray"] +license.workspace = true +name = "burn-train" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-train" +documentation = "https://docs.rs/burn-train" +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["sys-metrics", "tui", "rl"] +doc = ["default"] +vision = ["burn-nn", "burn-store/pytorch", "burn-std/network", "dirs"] +tracing = ["burn-core/tracing", "burn-optim/tracing"] + + +sys-metrics = ["nvml-wrapper", "sysinfo", "systemstat"] +tui = ["ratatui", "unicode-width"] +rl = ["burn-rl"] + +[dependencies] +burn-core = { workspace = true, features = [ + "dataset", + "std", + "flex", # RL loop +] } +burn-optim = { workspace = true, features = ["std"] } +burn-rl = { workspace = true, optional = true } +burn-nn = { workspace = true, optional = true, features = ["std"] } +burn-store = { workspace = true, optional = true, features = ["std"] } +burn-std = { workspace = true, default-features = false, features = ["std"] } +dirs = { workspace = true, optional = true } + +log = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } +tracing-core = { workspace = true } + +# System Metrics +nvml-wrapper = { workspace = true, optional = true } +sysinfo = { workspace = true, optional = true } +systemstat = { workspace = true, optional = true } + +# Text UI +ratatui = { workspace = true, optional = true, features = [ + "all-widgets", + "crossterm", +] } +unicode-width = { workspace = true, optional = true } + +# Utilities +derive-new = { workspace = true } +serde = { workspace = true, features = ["std", "derive"] } +async-channel = { workspace = true } +burn-flex = { workspace = true, features = ["default"] } +rstest.workspace = true +thiserror.workspace = true +rand.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[package.metadata.docs.rs] +features = ["doc"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/burn-train/LICENSE-APACHE b/crates/burn-train/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/burn-train/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/burn-train/LICENSE-MIT b/crates/burn-train/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/burn-train/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/burn-train/README.md b/crates/burn-train/README.md new file mode 100644 index 0000000..e693c68 --- /dev/null +++ b/crates/burn-train/README.md @@ -0,0 +1,6 @@ +# Burn Train + +This crate should be used with [burn](https://github.com/tracel-ai/burn). + +[![Current Crates.io Version](https://img.shields.io/crates/v/burn-train.svg)](https://crates.io/crates/burn-train) +[![license](https://shields.io/badge/license-MIT%2FApache--2.0-blue)](https://github.com/tracel-ai/burn-train/blob/master/README.md) diff --git a/crates/burn-train/src/checkpoint/async_checkpoint.rs b/crates/burn-train/src/checkpoint/async_checkpoint.rs new file mode 100644 index 0000000..cb3d1b5 --- /dev/null +++ b/crates/burn-train/src/checkpoint/async_checkpoint.rs @@ -0,0 +1,158 @@ +use super::{Checkpoint, Checkpointer, CheckpointerError}; +use crate::Interrupter; +use std::sync::mpsc; + +enum Message { + Restore( + usize, + mpsc::SyncSender>, + Option, + ), + Save(usize, R, Option), + Delete(usize, Option), + End, +} + +#[derive(new)] +struct CheckpointerThread { + checkpointer: C, + receiver: mpsc::Receiver>, +} + +impl CheckpointerThread +where + C: Checkpointer, + R: Checkpoint, +{ + fn run(self) { + for item in self.receiver.iter() { + match item { + Message::Restore(epoch, callback, interrupter) => { + let record = self.checkpointer.restore(epoch); + callback.send(record).unwrap_or_else(|err| { + interrupter.map_or_else( + || { + panic!( + "Error when sending response through callback channel: {err}" + ) + }, + |int| int.stop(Some(&err.to_string())), + ) + }); + } + Message::Save(epoch, state, interrupter) => { + self.checkpointer.save(epoch, state).unwrap_or_else(|err| { + interrupter.map_or_else( + || panic!("Error when saving the state: {err}"), + |int| int.stop(Some(&err.to_string())), + ) + }); + } + Message::Delete(epoch, interrupter) => { + self.checkpointer.delete(epoch).unwrap_or_else(|err| { + interrupter.map_or_else( + || panic!("Error when deleting the state: {err}"), + |int| int.stop(Some(&err.to_string())), + ) + }); + } + + Message::End => { + return; + } + }; + } + } +} + +/// Async checkpointer. +pub struct AsyncCheckpointer { + sender: mpsc::SyncSender>, + handler: Option>, + interrupter: Option, +} + +impl AsyncCheckpointer +where + R: Checkpoint, +{ + /// Create a new async checkpointer. + /// + /// # Arguments + /// + /// * `checkpointer` - The checkpointer. + /// + /// # Returns + /// + /// The async checkpointer. + pub fn new(checkpointer: C) -> Self + where + C: Checkpointer + Send + 'static, + { + // Only on checkpoint can be done in advance. + let (sender, receiver) = mpsc::sync_channel(0); + let thread = CheckpointerThread::new(checkpointer, receiver); + let handler = Some(std::thread::spawn(move || thread.run())); + + Self { + sender, + handler, + interrupter: None, + } + } + + /// Assign a handle used to interrupt training in case of checkpointing error. + pub fn with_interrupter(mut self, interrupter: Interrupter) -> Self { + self.interrupter = Some(interrupter); + self + } +} + +impl Checkpointer for AsyncCheckpointer +where + R: Checkpoint, +{ + fn save(&self, epoch: usize, record: R) -> Result<(), CheckpointerError> { + self.sender + .send(Message::Save(epoch, record, self.interrupter.clone())) + .expect("Can send message to checkpointer thread."); + + Ok(()) + } + + fn restore(&self, epoch: usize) -> Result { + let (sender, receiver) = mpsc::sync_channel(1); + self.sender + .send(Message::Restore(epoch, sender, self.interrupter.clone())) + .map_err(|e| CheckpointerError::Unknown(e.to_string()))?; + + if let Ok(record) = receiver.recv() { + return record; + }; + + Err(CheckpointerError::Unknown("Channel error.".to_string())) + } + + fn delete(&self, epoch: usize) -> Result<(), CheckpointerError> { + self.sender + .send(Message::Delete(epoch, self.interrupter.clone())) + .map_err(|e| CheckpointerError::Unknown(e.to_string()))?; + + Ok(()) + } +} + +impl Drop for AsyncCheckpointer { + fn drop(&mut self) { + self.sender + .send(Message::End) + .expect("Can send the end message to the checkpointer thread."); + let handler = self.handler.take(); + + if let Some(handler) = handler { + handler + .join() + .expect("The checkpointer thread should stop."); + } + } +} diff --git a/crates/burn-train/src/checkpoint/base.rs b/crates/burn-train/src/checkpoint/base.rs new file mode 100644 index 0000000..40d3d3c --- /dev/null +++ b/crates/burn-train/src/checkpoint/base.rs @@ -0,0 +1,123 @@ +use burn_core::store::{ModuleRecord, RecordError}; +use burn_optim::OptimizerRecord; +use burn_optim::lr_scheduler::LrSchedulerRecord; +use burn_std::Bytes; +use std::path::PathBuf; +use thiserror::Error; + +/// The error type for checkpointer. +#[derive(Error, Debug)] +pub enum CheckpointerError { + /// IO error. + #[error("I/O Error: `{0}`")] + IOError(std::io::Error), + + /// Record (burnpack) error. + #[error("Record error: `{0}`")] + Record(RecordError), + + /// Other errors. + #[error("Unknown error: `{0}`")] + Unknown(String), +} + +/// A record that can be saved to and loaded from a burnpack file. +/// +/// Implemented for the burnpack record types used during training: the module +/// ([`ModuleRecord`]), the optimizer ([`OptimizerRecord`]) and the learning rate scheduler +/// ([`LrSchedulerRecord`]). +/// +/// Records are device-free: a checkpoint is just file-backed bytes. Device placement is decided +/// when a record is applied (the module keeps its existing parameter device; optimizer state +/// migrates to each parameter's device on the next step), not when the checkpoint is loaded. +pub trait Checkpoint: Sized + Send + 'static { + /// Save the record to `path`. + fn save(self, path: PathBuf) -> Result<(), CheckpointerError>; + /// Load the record from `path`. + fn load(path: PathBuf) -> Result; + /// Creates a checkpoint from bytes + fn checkpoint_from_bytes(bytes: Bytes) -> Result; + /// Transforms a checkpoint into bytes + fn checkpoint_into_bytes(self) -> Result; +} + +/// A stateless record: nothing to save or load. +impl Checkpoint for () { + fn save(self, _path: PathBuf) -> Result<(), CheckpointerError> { + Ok(()) + } + fn load(_path: PathBuf) -> Result { + Ok(()) + } + fn checkpoint_from_bytes(_bytes: Bytes) -> Result { + Ok(()) + } + fn checkpoint_into_bytes(self) -> Result { + Ok(Bytes::from_bytes_vec(vec![0])) + } +} + +impl Checkpoint for ModuleRecord { + fn save(self, path: PathBuf) -> Result<(), CheckpointerError> { + ModuleRecord::save(self, path).map_err(CheckpointerError::Record) + } + fn load(path: PathBuf) -> Result { + ModuleRecord::load(path).map_err(CheckpointerError::Record) + } + fn checkpoint_into_bytes(self) -> Result { + self.into_bytes() + } + fn checkpoint_from_bytes(bytes: Bytes) -> Result { + ModuleRecord::from_bytes(bytes) + } +} + +impl Checkpoint for OptimizerRecord { + fn save(self, path: PathBuf) -> Result<(), CheckpointerError> { + OptimizerRecord::save(self, path).map_err(CheckpointerError::Record) + } + fn load(path: PathBuf) -> Result { + OptimizerRecord::load(path).map_err(CheckpointerError::Record) + } + fn checkpoint_from_bytes(bytes: Bytes) -> Result { + OptimizerRecord::from_bytes(bytes) + } + fn checkpoint_into_bytes(self) -> Result { + self.into_bytes() + } +} + +impl Checkpoint for LrSchedulerRecord { + fn save(self, path: PathBuf) -> Result<(), CheckpointerError> { + LrSchedulerRecord::save(self, path).map_err(CheckpointerError::Record) + } + fn load(path: PathBuf) -> Result { + LrSchedulerRecord::load(path).map_err(CheckpointerError::Record) + } + fn checkpoint_from_bytes(bytes: Bytes) -> Result { + LrSchedulerRecord::from_bytes(bytes) + } + fn checkpoint_into_bytes(self) -> Result { + self.into_bytes() + } +} + +/// The trait for checkpointer. +pub trait Checkpointer: Send + Sync +where + R: Checkpoint, +{ + /// Save the record. + /// + /// # Arguments + /// + /// * `epoch` - The epoch. + /// * `record` - The record. + fn save(&self, epoch: usize, record: R) -> Result<(), CheckpointerError>; + + /// Delete the checkpoint saved at the given epoch if present. + fn delete(&self, epoch: usize) -> Result<(), CheckpointerError>; + + /// Restore the record from the checkpoint saved at the given epoch. + fn restore(&self, epoch: usize) -> Result; +} diff --git a/crates/burn-train/src/checkpoint/file.rs b/crates/burn-train/src/checkpoint/file.rs new file mode 100644 index 0000000..da52ac2 --- /dev/null +++ b/crates/burn-train/src/checkpoint/file.rs @@ -0,0 +1,67 @@ +use std::path::{Path, PathBuf}; + +use super::{Checkpoint, Checkpointer, CheckpointerError}; + +/// The file checkpointer. +/// +/// Saves each record as a [burnpack](burn_core::store) file in the given directory. +pub struct FileCheckpointer { + directory: PathBuf, + name: String, +} + +impl FileCheckpointer { + /// Creates a new file checkpointer. + /// + /// # Arguments + /// + /// * `directory` - The directory to save the checkpoints. + /// * `name` - The name of the checkpoint. + pub fn new(directory: impl AsRef, name: &str) -> Self { + let directory = directory.as_ref(); + std::fs::create_dir_all(directory).ok(); + + Self { + directory: directory.to_path_buf(), + name: name.to_string(), + } + } + + fn path_for_epoch(&self, epoch: usize) -> PathBuf { + self.directory.join(format!("{}-{}.bpk", self.name, epoch)) + } +} + +impl Checkpointer for FileCheckpointer +where + R: Checkpoint, +{ + fn save(&self, epoch: usize, record: R) -> Result<(), CheckpointerError> { + let file_path = self.path_for_epoch(epoch); + log::trace!("Saving checkpoint {} to {}", epoch, file_path.display()); + + record.save(file_path) + } + + fn restore(&self, epoch: usize) -> Result { + let file_path = self.path_for_epoch(epoch); + log::info!( + "Restoring checkpoint {} from {}", + epoch, + file_path.display() + ); + + R::load(file_path) + } + + fn delete(&self, epoch: usize) -> Result<(), CheckpointerError> { + let file_path = self.path_for_epoch(epoch); + + if file_path.exists() { + log::trace!("Removing checkpoint {}", file_path.display()); + std::fs::remove_file(file_path).map_err(CheckpointerError::IOError)?; + } + + Ok(()) + } +} diff --git a/crates/burn-train/src/checkpoint/mod.rs b/crates/burn-train/src/checkpoint/mod.rs new file mode 100644 index 0000000..14b8f15 --- /dev/null +++ b/crates/burn-train/src/checkpoint/mod.rs @@ -0,0 +1,9 @@ +mod async_checkpoint; +mod base; +mod file; +mod strategy; + +pub use async_checkpoint::*; +pub use base::*; +pub use file::*; +pub use strategy::*; diff --git a/crates/burn-train/src/checkpoint/strategy/base.rs b/crates/burn-train/src/checkpoint/strategy/base.rs new file mode 100644 index 0000000..07d6213 --- /dev/null +++ b/crates/burn-train/src/checkpoint/strategy/base.rs @@ -0,0 +1,34 @@ +use std::ops::DerefMut; + +use crate::metric::store::EventStoreClient; + +/// Action to be taken by a [checkpointer](crate::checkpoint::Checkpointer). +#[derive(Clone, PartialEq, Debug)] +pub enum CheckpointingAction { + /// Delete the given epoch. + Delete(usize), + /// Save the current record. + Save, +} + +/// Define when checkpoint should be saved and deleted. +pub trait CheckpointingStrategy: Send { + /// Based on the epoch, determine if the checkpoint should be saved. + fn checkpointing( + &mut self, + epoch: usize, + collector: &EventStoreClient, + ) -> Vec; +} + +// We make dyn box implement the checkpointing strategy so that it can be used with generic, but +// still be dynamic. +impl CheckpointingStrategy for Box { + fn checkpointing( + &mut self, + epoch: usize, + collector: &EventStoreClient, + ) -> Vec { + self.deref_mut().checkpointing(epoch, collector) + } +} diff --git a/crates/burn-train/src/checkpoint/strategy/composed.rs b/crates/burn-train/src/checkpoint/strategy/composed.rs new file mode 100644 index 0000000..8029c9e --- /dev/null +++ b/crates/burn-train/src/checkpoint/strategy/composed.rs @@ -0,0 +1,146 @@ +use crate::metric::store::EventStoreClient; + +use super::{CheckpointingAction, CheckpointingStrategy}; +use std::collections::HashSet; + +/// Compose multiple checkpointing strategy and only delete checkpoints when both strategy flag an +/// epoch to be deleted. +pub struct ComposedCheckpointingStrategy { + strategies: Vec>, + deleted: Vec>, +} + +/// Help building a [checkpointing strategy](CheckpointingStrategy) by combining multiple ones. +#[derive(Default)] +pub struct ComposedCheckpointingStrategyBuilder { + strategies: Vec>, +} + +impl ComposedCheckpointingStrategyBuilder { + /// Add a new [checkpointing strategy](CheckpointingStrategy). + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, strategy: S) -> Self + where + S: CheckpointingStrategy + 'static, + { + self.strategies.push(Box::new(strategy)); + self + } + + /// Create a new [composed checkpointing strategy](ComposedCheckpointingStrategy). + pub fn build(self) -> ComposedCheckpointingStrategy { + ComposedCheckpointingStrategy::new(self.strategies) + } +} + +impl ComposedCheckpointingStrategy { + fn new(strategies: Vec>) -> Self { + Self { + deleted: strategies.iter().map(|_| HashSet::new()).collect(), + strategies, + } + } + /// Create a new builder which help compose multiple + /// [checkpointing strategies](CheckpointingStrategy). + pub fn builder() -> ComposedCheckpointingStrategyBuilder { + ComposedCheckpointingStrategyBuilder::default() + } +} + +impl CheckpointingStrategy for ComposedCheckpointingStrategy { + fn checkpointing( + &mut self, + epoch: usize, + collector: &EventStoreClient, + ) -> Vec { + let mut saved = false; + let mut actions = Vec::new(); + let mut epochs_to_check = Vec::new(); + + for (i, strategy) in self.strategies.iter_mut().enumerate() { + let actions = strategy.checkpointing(epoch, collector); + // We assume that the strategy would not want the current epoch to be saved. + // So we flag it as deleted. + if actions.is_empty() { + self.deleted + .get_mut(i) + .expect("As many 'deleted' as 'strategies'.") + .insert(epoch); + } + + for action in actions { + match action { + CheckpointingAction::Delete(epoch) => { + self.deleted + .get_mut(i) + .expect("As many 'deleted' as 'strategies'.") + .insert(epoch); + epochs_to_check.push(epoch); + } + CheckpointingAction::Save => saved = true, + } + } + } + + if saved { + actions.push(CheckpointingAction::Save); + } + + for epoch in epochs_to_check.into_iter() { + let mut num_true = 0; + for i in 0..self.strategies.len() { + if self + .deleted + .get(i) + .expect("Ad many 'deleted' as 'strategies'.") + .contains(&epoch) + { + num_true += 1; + } + } + + if num_true == self.strategies.len() { + actions.push(CheckpointingAction::Delete(epoch)); + + for i in 0..self.strategies.len() { + self.deleted + .get_mut(i) + .expect("As many 'deleted' as 'strategies'.") + .remove(&epoch); + } + } + } + + actions + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{checkpoint::KeepLastNCheckpoints, metric::store::LogEventStore}; + + #[test] + fn should_delete_when_both_deletes() { + let store = EventStoreClient::new(LogEventStore::default()); + let mut strategy = ComposedCheckpointingStrategy::builder() + .add(KeepLastNCheckpoints::new(1)) + .add(KeepLastNCheckpoints::new(2)) + .build(); + + assert_eq!( + vec![CheckpointingAction::Save], + strategy.checkpointing(1, &store) + ); + + assert_eq!( + vec![CheckpointingAction::Save], + strategy.checkpointing(2, &store) + ); + + assert_eq!( + vec![CheckpointingAction::Save, CheckpointingAction::Delete(1)], + strategy.checkpointing(3, &store) + ); + } +} diff --git a/crates/burn-train/src/checkpoint/strategy/lastn.rs b/crates/burn-train/src/checkpoint/strategy/lastn.rs new file mode 100644 index 0000000..6e8b46a --- /dev/null +++ b/crates/burn-train/src/checkpoint/strategy/lastn.rs @@ -0,0 +1,56 @@ +use super::CheckpointingStrategy; +use crate::{checkpoint::CheckpointingAction, metric::store::EventStoreClient}; + +/// Keep the last N checkpoints. +/// +/// Very useful when training, minimizing disk space while ensuring that the training can be +/// resumed even if something goes wrong. +#[derive(new)] +pub struct KeepLastNCheckpoints { + num_keep: usize, +} + +impl CheckpointingStrategy for KeepLastNCheckpoints { + fn checkpointing( + &mut self, + epoch: usize, + _store: &EventStoreClient, + ) -> Vec { + let mut actions = vec![CheckpointingAction::Save]; + + if let Some(epoch) = usize::checked_sub(epoch, self.num_keep) + && epoch > 0 + { + actions.push(CheckpointingAction::Delete(epoch)); + } + + actions + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::store::LogEventStore; + + #[test] + fn should_always_delete_lastn_epoch_if_higher_than_one() { + let mut strategy = KeepLastNCheckpoints::new(2); + let store = EventStoreClient::new(LogEventStore::default()); + + assert_eq!( + vec![CheckpointingAction::Save], + strategy.checkpointing(1, &store) + ); + + assert_eq!( + vec![CheckpointingAction::Save], + strategy.checkpointing(2, &store) + ); + + assert_eq!( + vec![CheckpointingAction::Save, CheckpointingAction::Delete(1)], + strategy.checkpointing(3, &store) + ); + } +} diff --git a/crates/burn-train/src/checkpoint/strategy/metric.rs b/crates/burn-train/src/checkpoint/strategy/metric.rs new file mode 100644 index 0000000..8769f83 --- /dev/null +++ b/crates/burn-train/src/checkpoint/strategy/metric.rs @@ -0,0 +1,143 @@ +use super::CheckpointingStrategy; +use crate::{ + checkpoint::CheckpointingAction, + metric::{ + Metric, MetricName, + store::{Aggregate, Direction, EventStoreClient, Split}, + }, +}; + +/// Keep the best checkpoint based on a metric. +pub struct MetricCheckpointingStrategy { + current: Option, + aggregate: Aggregate, + direction: Direction, + split: Split, + name: MetricName, +} + +impl MetricCheckpointingStrategy { + /// Create a new metric checkpointing strategy. + pub fn new(metric: &M, aggregate: Aggregate, direction: Direction, split: Split) -> Self + where + M: Metric, + { + Self { + current: None, + name: metric.name(), + aggregate, + direction, + split, + } + } +} + +impl CheckpointingStrategy for MetricCheckpointingStrategy { + fn checkpointing( + &mut self, + epoch: usize, + store: &EventStoreClient, + ) -> Vec { + let best_epoch = + match store.find_epoch(&self.name, self.aggregate, self.direction, &self.split) { + Some(epoch_best) => epoch_best, + None => epoch, + }; + + let mut actions = Vec::new(); + + if let Some(current) = self.current + && current != best_epoch + { + actions.push(CheckpointingAction::Delete(current)); + } + + if best_epoch == epoch { + actions.push(CheckpointingAction::Save); + } + + self.current = Some(best_epoch); + + actions + } +} + +#[cfg(test)] +mod tests { + use crate::{ + EventProcessorTraining, + logger::InMemoryMetricLogger, + metric::{ + LossMetric, + processor::{ + MetricsTraining, MinimalEventProcessor, + test_utils::{end_epoch, process_train}, + }, + store::LogEventStore, + }, + test_utils::start_epoch, + }; + + use super::*; + use std::sync::Arc; + + #[test] + fn always_keep_the_best_epoch() { + let loss = LossMetric::new(); + let mut store = LogEventStore::default(); + let mut strategy = MetricCheckpointingStrategy::new( + &loss, + Aggregate::Mean, + Direction::Lowest, + Split::Train, + ); + let mut metrics = MetricsTraining::::default(); + // Register an in memory logger. + store.register_logger(InMemoryMetricLogger::default()); + // Register the loss metric. + metrics.register_train_metric_numeric(loss); + let store = Arc::new(EventStoreClient::new(store)); + let mut processor = MinimalEventProcessor::new(metrics, store.clone()); + processor.process_train(crate::LearnerEvent::Start { + total_epochs: 0, + starting_epoch: 0, + }); + + // Two points for the first epoch. Mean 0.75 + let mut epoch = 1; + start_epoch(&mut processor, epoch, 2); + process_train(&mut processor, 1.0, epoch); + process_train(&mut processor, 0.5, epoch); + end_epoch(&mut processor, epoch); + + // Should save the current record. + assert_eq!( + vec![CheckpointingAction::Save], + strategy.checkpointing(epoch, &store) + ); + + // Two points for the second epoch. Mean 0.4 + epoch += 1; + start_epoch(&mut processor, epoch, 2); + process_train(&mut processor, 0.5, epoch); + process_train(&mut processor, 0.3, epoch); + end_epoch(&mut processor, epoch); + + // Should save the current record and delete the previous one. + assert_eq!( + vec![CheckpointingAction::Delete(1), CheckpointingAction::Save], + strategy.checkpointing(epoch, &store) + ); + + // Two points for the last epoch. Mean 2.0 + epoch += 1; + start_epoch(&mut processor, epoch, 2); + process_train(&mut processor, 1.0, epoch); + process_train(&mut processor, 3.0, epoch); + end_epoch(&mut processor, epoch); + + // Should not delete the previous record, since it's the best one, and should not save a + // new one. + assert!(strategy.checkpointing(epoch, &store).is_empty()); + } +} diff --git a/crates/burn-train/src/checkpoint/strategy/mod.rs b/crates/burn-train/src/checkpoint/strategy/mod.rs new file mode 100644 index 0000000..2915a71 --- /dev/null +++ b/crates/burn-train/src/checkpoint/strategy/mod.rs @@ -0,0 +1,9 @@ +mod base; +mod composed; +mod lastn; +mod metric; + +pub use base::*; +pub use composed::*; +pub use lastn::*; +pub use metric::*; diff --git a/crates/burn-train/src/components.rs b/crates/burn-train/src/components.rs new file mode 100644 index 0000000..5c49a34 --- /dev/null +++ b/crates/burn-train/src/components.rs @@ -0,0 +1,22 @@ +use crate::{InferenceStep, TrainStep}; +use burn_core::module::AutodiffModule; + +/// A single keyword trait for a Burn [module](burn_core::module::Module) used for learning. +pub trait LearnerModel: + TrainStep + InferenceStep + AutodiffModule + core::fmt::Display + 'static +{ +} + +impl LearnerModel for T where + T: TrainStep + InferenceStep + AutodiffModule + core::fmt::Display + 'static +{ +} + +/// Type for training input. +pub(crate) type TrainingModelInput = ::Input; +/// Type for inference input. +pub(crate) type InferenceModelInput = ::Input; +/// Type for training output. +pub(crate) type TrainingModelOutput = ::Output; +/// Type for inference output. +pub(crate) type InferenceModelOutput = ::Output; diff --git a/crates/burn-train/src/evaluator/base.rs b/crates/burn-train/src/evaluator/base.rs new file mode 100644 index 0000000..73476a3 --- /dev/null +++ b/crates/burn-train/src/evaluator/base.rs @@ -0,0 +1,93 @@ +use crate::{ + AsyncProcessorEvaluation, EvaluationItem, FullEventProcessorEvaluation, InferenceStep, + Interrupter, LearnerSummaryConfig, + evaluator::components::EvaluatorComponentTypes, + metric::processor::{EvaluatorEvent, EventProcessorEvaluation}, + renderer::{EvaluationName, MetricsRenderer}, +}; +use burn_core::{data::dataloader::DataLoader, module::Module}; +use std::sync::Arc; + +pub(crate) type TestInput = <::Model as InferenceStep>::Input; +pub(crate) type TestOutput = <::Model as InferenceStep>::Output; + +pub(crate) type TestLoader = Arc>>; + +/// Evaluates a model on a specific dataset. +pub struct Evaluator { + pub(crate) model: EC::Model, + pub(crate) interrupter: Interrupter, + pub(crate) event_processor: + AsyncProcessorEvaluation>>, + /// Config for creating a summary of the evaluation + pub summary: Option, +} + +impl Evaluator { + /// Run the evaluation on the given dataset. + /// + /// The data will be stored and displayed under the provided name. + pub fn eval( + self, + name: S, + dataloader: TestLoader, + ) -> Box { + self.eval_all([(name, dataloader)]) + } + + /// Run the evaluation on multiple named datasets sequentially. + /// + /// Prefer this over calling [`eval`](Self::eval) in a loop — the progress logger + /// receives the correct `total_tests` count and `end_test` is called between splits. + pub fn eval_all( + mut self, + splits: impl IntoIterator)>, + ) -> Box { + let splits: Vec<_> = splits.into_iter().collect(); + let total_tests = splits.len(); + + self.event_processor + .process_test(EvaluatorEvent::Start { total_tests }); + + for (name, dataloader) in splits { + let dataloader = dataloader.to_device(self.model.devices().first().unwrap()); + let name = EvaluationName::new(name); + let total_items = dataloader.num_items(); + let mut iterator = dataloader.iter(); + let mut iteration = 0; + + self.event_processor + .process_test(EvaluatorEvent::StartTest(name.clone(), total_items)); + + while let Some(item) = iterator.next() { + let progress = iterator.progress(); + iteration += 1; + + let item = self.model.step(item); + let item = EvaluationItem::new(item, progress, Some(iteration)); + + self.event_processor + .process_test(EvaluatorEvent::ProcessedItem(name.clone(), item)); + + if self.interrupter.should_stop() { + log::info!("Testing interrupted."); + break; + } + } + + self.event_processor.process_test(EvaluatorEvent::EndTest); + } + + let summary = self.summary.and_then(|summary| { + summary + .init() + .map(|summary| summary.with_model(self.model.to_string())) + .ok() + }); + + self.event_processor + .process_test(EvaluatorEvent::End(summary)); + + self.event_processor.renderer() + } +} diff --git a/crates/burn-train/src/evaluator/builder.rs b/crates/burn-train/src/evaluator/builder.rs new file mode 100644 index 0000000..1374f88 --- /dev/null +++ b/crates/burn-train/src/evaluator/builder.rs @@ -0,0 +1,231 @@ +use crate::{ + ApplicationLoggerInstaller, Evaluator, FileApplicationLoggerInstaller, InferenceStep, + Interrupter, LearnerSummaryConfig, TestOutput, + evaluator::components::{EvaluatorComponentTypes, EvaluatorComponentTypesMarker}, + logger::{EvaluationProgressLogger, FileMetricLogger}, + metric::{ + Adaptor, Metric, Numeric, + processor::{AsyncProcessorEvaluation, FullEventProcessorEvaluation, MetricsEvaluation}, + store::{EventStoreClient, LogEventStore}, + }, + renderer::{MetricsRenderer, default_renderer}, +}; +use burn_core::module::Module; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// Struct to configure and create an [evaluator](Evaluator). +/// +/// The generics components of the builder should probably not be set manually, as they are +/// optimized for Rust type inference. +pub struct EvaluatorBuilder { + tracing_logger: Option>, + event_store: LogEventStore, + summary_metrics: BTreeSet, + renderer: Option>, + interrupter: Interrupter, + metrics: MetricsEvaluation>, + directory: PathBuf, + summary: bool, + progress_logger: Option>, +} + +impl EvaluatorBuilder> +where + M: Module + InferenceStep + core::fmt::Display + 'static, +{ + /// Creates a new evaluator builder. + /// + /// # Arguments + /// + /// * `directory` - The directory to save the checkpoints. + pub fn new(directory: impl AsRef) -> Self { + let directory = directory.as_ref().to_path_buf(); + let log_file = directory.join("evaluation.log"); + + Self { + tracing_logger: Some(Box::new(FileApplicationLoggerInstaller::new(log_file))), + event_store: LogEventStore::default(), + summary_metrics: Default::default(), + renderer: None, + interrupter: Interrupter::new(), + summary: false, + metrics: MetricsEvaluation::default(), + directory, + progress_logger: None, + } + } +} + +impl EvaluatorBuilder { + /// Registers [numeric](crate::metric::Numeric) test [metrics](Metric). + pub fn metrics>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Registers text [metrics](Metric). + pub fn metrics_text>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// By default, Rust logs are captured and written into + /// `evaluation.log`. If disabled, standard Rust log handling + /// will apply. + pub fn with_application_logger( + mut self, + logger: Option>, + ) -> Self { + self.tracing_logger = logger; + self + } + + /// Register a [numeric](crate::metric::Numeric) test [metric](Metric). + pub fn metric_numeric(mut self, metric: Me) -> Self + where + Me: Metric + Numeric + 'static, + TestOutput: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_test_metric_numeric(metric); + self + } + + /// Register a text test [metric](Metric). + pub fn metric(mut self, metric: Me) -> Self + where + Me: Metric + 'static, + TestOutput: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_test_metric(metric); + self + } + + /// Replace the default CLI renderer with a custom one. + /// + /// # Arguments + /// + /// * `renderer` - The custom renderer. + pub fn renderer(mut self, renderer: Box) -> Self { + self.renderer = Some(renderer); + self + } + + /// Enable the evaluation summary report. + /// + /// The summary will be displayed at the end of `.eval()`. + pub fn summary(mut self) -> Self { + self.summary = true; + self + } + + /// Register a progress logger to track and store evaluation progress. + /// + /// # Example + /// ```ignore + /// // `MyEvaluationProgressLogger` is a user-defined type that implements + /// // `burn_train::logger::EvaluationProgressLogger`. + /// let evaluator = EvaluatorBuilder::new(...) + /// .with_progress_logger(MyEvaluationProgressLogger); + /// ``` + pub fn with_progress_logger(mut self, logger: PL) -> Self + where + PL: EvaluationProgressLogger + 'static, + { + self.progress_logger = Some(Box::new(logger)); + self + } + + /// Builds the evaluator. + #[allow(clippy::type_complexity)] + pub fn build(mut self, model: EC::Model) -> Evaluator { + let renderer = self + .renderer + .unwrap_or_else(|| default_renderer(self.interrupter.clone(), None)); + + self.event_store + .register_logger(FileMetricLogger::new_eval(self.directory.clone())); + let event_store = Arc::new(EventStoreClient::new(self.event_store)); + + let full_processor = FullEventProcessorEvaluation::new(self.metrics, renderer, event_store); + let full_processor = match self.progress_logger { + Some(logger) => full_processor.with_progress_logger(logger), + None => full_processor, + }; + let event_processor = AsyncProcessorEvaluation::new(full_processor); + + let summary = if self.summary { + Some(LearnerSummaryConfig { + directory: self.directory, + metrics: self.summary_metrics.into_iter().collect::>(), + }) + } else { + None + }; + + Evaluator { + model, + interrupter: self.interrupter, + event_processor, + summary, + } + } +} + +/// Trait to fake variadic generics. +pub trait EvalMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: EvaluatorBuilder) -> EvaluatorBuilder; +} + +/// Trait to fake variadic generics. +pub trait EvalTextMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: EvaluatorBuilder) -> EvaluatorBuilder; +} + +macro_rules! gen_tuple { + ($($M:ident),*) => { + impl<$($M,)* EC: EvaluatorComponentTypes> EvalTextMetricRegistration for ($($M,)*) + where + $(TestOutput: Adaptor<$M::Input>,)* + $($M: Metric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: EvaluatorBuilder, + ) -> EvaluatorBuilder { + let ($($M,)*) = self; + $(let builder = builder.metric($M);)* + builder + } + } + + impl<$($M,)* EC: EvaluatorComponentTypes> EvalMetricRegistration for ($($M,)*) + where + $(TestOutput: Adaptor<$M::Input>,)* + $($M: Metric + $crate::metric::Numeric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: EvaluatorBuilder, + ) -> EvaluatorBuilder { + let ($($M,)*) = self; + $(let builder = builder.metric_numeric($M);)* + builder + } + } + }; +} + +gen_tuple!(M1); +gen_tuple!(M1, M2); +gen_tuple!(M1, M2, M3); +gen_tuple!(M1, M2, M3, M4); +gen_tuple!(M1, M2, M3, M4, M5); +gen_tuple!(M1, M2, M3, M4, M5, M6); diff --git a/crates/burn-train/src/evaluator/components.rs b/crates/burn-train/src/evaluator/components.rs new file mode 100644 index 0000000..624a2f1 --- /dev/null +++ b/crates/burn-train/src/evaluator/components.rs @@ -0,0 +1,21 @@ +use crate::InferenceStep; +use burn_core::module::Module; +use std::marker::PhantomData; + +/// All components necessary to evaluate a model grouped in one trait. +pub trait EvaluatorComponentTypes { + /// The model to evaluate. + type Model: Module + InferenceStep + core::fmt::Display + 'static; +} + +/// A marker type used to provide [evaluation components](EvaluatorComponentTypes). +pub struct EvaluatorComponentTypesMarker { + _p: PhantomData, +} + +impl EvaluatorComponentTypes for EvaluatorComponentTypesMarker +where + M: Module + InferenceStep + core::fmt::Display + 'static, +{ + type Model = M; +} diff --git a/crates/burn-train/src/evaluator/mod.rs b/crates/burn-train/src/evaluator/mod.rs new file mode 100644 index 0000000..aa20339 --- /dev/null +++ b/crates/burn-train/src/evaluator/mod.rs @@ -0,0 +1,7 @@ +mod base; +mod builder; + +pub(crate) mod components; + +pub use base::*; +pub use builder::*; diff --git a/crates/burn-train/src/learner/application_logger.rs b/crates/burn-train/src/learner/application_logger.rs new file mode 100644 index 0000000..42f725c --- /dev/null +++ b/crates/burn-train/src/learner/application_logger.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; +use tracing_core::{Level, LevelFilter}; +use tracing_subscriber::filter::filter_fn; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{Layer, registry}; + +/// This trait is used to install an application logger. +pub trait ApplicationLoggerInstaller { + /// Install the application logger. + fn install(&self) -> Result<(), String>; +} + +/// This struct is used to install a local file application logger to output logs to a given file path. +pub struct FileApplicationLoggerInstaller { + path: PathBuf, +} + +impl FileApplicationLoggerInstaller { + /// Create a new file application logger. + pub fn new(path: impl AsRef) -> Self { + Self { + path: path.as_ref().to_path_buf(), + } + } +} + +impl ApplicationLoggerInstaller for FileApplicationLoggerInstaller { + fn install(&self) -> Result<(), String> { + let path = Path::new(&self.path); + let writer = tracing_appender::rolling::never( + path.parent().unwrap_or_else(|| Path::new(".")), + path.file_name().unwrap_or_else(|| { + panic!("The path '{}' to point to a file.", self.path.display()) + }), + ); + let layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(writer) + .with_filter(LevelFilter::INFO) + .with_filter(filter_fn(|m| { + if let Some(path) = m.module_path() { + // The wgpu crate is logging too much, so we skip `info` level. + if path.starts_with("wgpu") && *m.level() >= Level::INFO { + return false; + } + } + true + })); + + if registry().with(layer).try_init().is_err() { + return Err("Failed to install the file logger.".to_string()); + } + + let hook = std::panic::take_hook(); + let file_path = self.path.to_owned(); + + std::panic::set_hook(Box::new(move |info| { + log::error!("PANIC => {info}"); + eprintln!( + "=== PANIC ===\nA fatal error happened, you can check the experiment logs here => \ + '{}'\n=============", + file_path.display() + ); + hook(info); + })); + + Ok(()) + } +} diff --git a/crates/burn-train/src/learner/base.rs b/crates/burn-train/src/learner/base.rs new file mode 100644 index 0000000..cf6bc44 --- /dev/null +++ b/crates/burn-train/src/learner/base.rs @@ -0,0 +1,261 @@ +use crate::checkpoint::{ + AsyncCheckpointer, Checkpointer, CheckpointingAction, CheckpointingStrategy, +}; +use crate::metric::store::EventStoreClient; +use crate::{ + CloneEarlyStoppingStrategy, LearnerModel, TrainOutput, TrainStep, TrainingModelInput, + TrainingModelOutput, +}; +use burn_core::store::ModuleRecord; +use burn_core::tensor::Device; +use burn_optim::lr_scheduler::LrSchedulerRecord; +use burn_optim::lr_scheduler::module_lr_scheduler::{ModuleLearningRate, ModuleLrScheduler}; +use burn_optim::{GradientsParams, ModuleOptimizer, MultiGradientsParams, OptimizerRecord}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +/// Learner struct encapsulating all components necessary to train a Neural Network model. +pub struct Learner { + pub(crate) model: M, + optim: ModuleOptimizer, + lr_scheduler: ModuleLrScheduler, + lr_module: ModuleLearningRate, +} + +impl Clone for Learner { + fn clone(&self) -> Self { + Self { + model: self.model.clone(), + optim: self.optim.clone(), + lr_scheduler: self.lr_scheduler.clone(), + lr_module: self.lr_module.clone(), + } + } +} + +impl Learner { + /// Create a learner. + pub fn new( + model: M, + optim: ModuleOptimizer, + lr_scheduler: impl Into, + ) -> Self { + Self { + model, + optim, + lr_scheduler: lr_scheduler.into(), + lr_module: 0.0.into(), + } + } +} + +impl Learner { + /// Fork the learner's model to the given device. + pub fn fork(&mut self, device: &Device) { + self.model = self.model().fork(device); + } + + /// Returns the current model. + pub fn model(&self) -> M { + self.model.clone() + } + + /// Returns the current learning rate. + pub fn lr_current(&self) -> ModuleLearningRate { + self.lr_module.clone() + } + + /// Executes a step of the learning rate scheduler. + pub fn lr_step(&mut self) { + self.lr_module = self.lr_scheduler.step(); + } + + /// Runs a step of the model for training, which executes the forward and backward passes. + /// + /// # Arguments + /// + /// * `item` - The input for the model. + /// + /// # Returns + /// + /// The output containing the model output and the gradients. + pub fn train_step(&self, item: TrainingModelInput) -> TrainOutput> { + TrainStep::step(&self.model, item) + } + + /// Optimize the current module with the provided gradients and learning rate. + /// + /// # Arguments + /// + /// * `optim`: Optimizer used for learning. + /// * `lr`: The learning rate used for this step. + /// * `grads`: The gradients of each parameter in the current model. + pub fn optimizer_step(&mut self, grads: GradientsParams) { + self.model = self + .model() + .optimize(&mut self.optim, self.lr_module.clone(), grads); + } + + /// Optimize the current module with the provided gradients and learning rate. + /// + /// # Arguments + /// + /// * `optim`: Optimizer used for learning. + /// * `lr`: The learning rate used for this step. + /// * `grads`: Multiple gradients associated to each parameter in the current model. + pub fn optimizer_step_multi(&mut self, grads: MultiGradientsParams) { + self.model = self + .model() + .optimize_multi(&mut self.optim, self.lr_module.clone(), grads); + } + + /// Load the module state from a [record](ModuleRecord). + pub fn load_model(&mut self, record: ModuleRecord) { + self.model = self.model.clone().load_record(record); + } + + /// Load the state of the learner's optimizer from a [record](OptimizerRecord). + /// + /// No device is needed: the optimizer state is migrated to each parameter's device on the next + /// step (see [`ModuleOptimizer::load_record`](burn_optim::ModuleOptimizer::load_record)). + pub fn load_optim(&mut self, record: OptimizerRecord) { + self.optim = self.optim.clone().load_record(record); + } + + /// Load the state of the learner's scheduler from a [record](LrSchedulerRecord). + pub fn load_scheduler(&mut self, record: LrSchedulerRecord) { + self.lr_scheduler = self.lr_scheduler.clone().load_record(record); + } +} + +/// Used to create, delete, or load checkpoints of the training process. +pub struct LearningCheckpointer { + model: AsyncCheckpointer, + optim: AsyncCheckpointer, + lr_scheduler: AsyncCheckpointer, + strategy: Box, + _phantom: PhantomData, +} + +impl LearningCheckpointer { + /// Create a new learning checkpointer. + pub fn new( + model: AsyncCheckpointer, + optim: AsyncCheckpointer, + lr_scheduler: AsyncCheckpointer, + strategy: Box, + ) -> Self { + Self { + model, + optim, + lr_scheduler, + strategy, + _phantom: PhantomData, + } + } + + /// Create checkpoint for the training process. + pub fn checkpoint(&mut self, learner: &Learner, epoch: usize, store: &EventStoreClient) { + let actions = self.strategy.checkpointing(epoch, store); + + for action in actions { + match action { + CheckpointingAction::Delete(epoch) => { + self.model + .delete(epoch) + .expect("Can delete model checkpoint."); + self.optim + .delete(epoch) + .expect("Can delete optimizer checkpoint."); + self.lr_scheduler + .delete(epoch) + .expect("Can delete learning rate scheduler checkpoint."); + } + CheckpointingAction::Save => { + self.model + .save(epoch, learner.model.clone().into_record()) + .expect("Can save model checkpoint."); + self.optim + .save(epoch, learner.optim.to_record()) + .expect("Can save optimizer checkpoint."); + self.lr_scheduler + .save(epoch, learner.lr_scheduler.to_record()) + .expect("Can save learning rate scheduler checkpoint."); + } + } + } + } + + /// Load a training checkpoint. + /// + /// No device is taken: checkpoints are device-free burnpack records (file-backed bytes). On + /// load, the model keeps the device of the learner's existing parameters, and the optimizer + /// state is migrated to each parameter's device on the next step. The training device is fixed + /// earlier, when the learner's model is created/forked. + pub fn load_checkpoint(&self, mut learner: Learner, epoch: usize) -> Learner { + let record = self + .model + .restore(epoch) + .expect("Can load model checkpoint."); + learner.load_model(record); + + let record = self + .optim + .restore(epoch) + .expect("Can load optimizer checkpoint."); + learner.load_optim(record); + + let record = self + .lr_scheduler + .restore(epoch) + .expect("Can load learning rate scheduler checkpoint."); + learner.load_scheduler(record); + + learner + } +} + +/// Cloneable reference to an early stopping strategy +pub(crate) type EarlyStoppingStrategyRef = Box; + +#[derive(Clone, Default)] +/// A handle that allows aborting the training/evaluation process early. +pub struct Interrupter { + state: Arc, + message: Arc>>, +} + +impl Interrupter { + /// Create a new instance. + pub fn new() -> Self { + Self::default() + } + + /// Notify the learner that it should stop. + /// # Arguments + /// * `reason` - A string describing the reason the training was stopped. + pub fn stop(&self, reason: Option<&str>) { + self.state.store(true, Ordering::Relaxed); + reason.inspect(|r| { + let mut message = self.message.lock().unwrap(); + *message = Some(String::from(*r)); + }); + } + + /// Reset the interrupter. + pub fn reset(&self) { + self.state.store(false, Ordering::Relaxed); + } + + /// True if .stop() has been called. + pub fn should_stop(&self) -> bool { + self.state.load(Ordering::Relaxed) + } + + /// Get the message associated with the interrupt. + pub fn get_message(&self) -> Option { + let message = self.message.lock().unwrap(); + message.clone() + } +} diff --git a/crates/burn-train/src/learner/classification.rs b/crates/burn-train/src/learner/classification.rs new file mode 100644 index 0000000..011bb1e --- /dev/null +++ b/crates/burn-train/src/learner/classification.rs @@ -0,0 +1,147 @@ +use crate::metric::{ + AccuracyInput, Adaptor, ConfusionStatsInput, HammingScoreInput, LossInput, PerplexityInput, + TopKAccuracyInput, processor::ItemLazy, +}; +use burn_core::tensor::{Device, Int, Tensor, Transaction}; + +/// Simple classification output adapted for multiple metrics. +/// +/// Supported metrics: +/// - Accuracy +/// - AUROC +/// - TopKAccuracy +/// - Perplexity +/// - Precision (via ConfusionStatsInput) +/// - Recall (via ConfusionStatsInput) +/// - FBetaScore (via ConfusionStatsInput) +/// - Loss. +#[derive(new)] +pub struct ClassificationOutput { + /// The loss. + pub loss: Tensor<1>, + + /// The class logits or probabilities. Shape: \[batch_size, num_classes\]. + pub output: Tensor<2>, + + /// The ground truth class index for each sample. Shape: \[batch_size\]. + pub targets: Tensor<1, Int>, +} + +impl ItemLazy for ClassificationOutput { + fn sync(self) -> Self { + let [output, loss, targets] = Transaction::default() + .register(self.output) + .register(self.loss) + .register(self.targets) + .execute() + .try_into() + .expect("Correct amount of tensor data"); + + let device: Device = Device::flex(); + + ClassificationOutput { + output: Tensor::from_data(output, &device), + loss: Tensor::from_data(loss, &device), + targets: Tensor::from_data(targets, &device), + } + } +} + +impl Adaptor for ClassificationOutput { + fn adapt(&self) -> AccuracyInput { + AccuracyInput::new(self.output.clone(), self.targets.clone()) + } +} + +impl Adaptor for ClassificationOutput { + fn adapt(&self) -> LossInput { + LossInput::new(self.loss.clone()) + } +} + +impl Adaptor for ClassificationOutput { + fn adapt(&self) -> TopKAccuracyInput { + TopKAccuracyInput::new(self.output.clone(), self.targets.clone()) + } +} + +impl Adaptor for ClassificationOutput { + fn adapt(&self) -> PerplexityInput { + PerplexityInput::new(self.output.clone(), self.targets.clone()) + } +} + +impl Adaptor for ClassificationOutput { + fn adapt(&self) -> ConfusionStatsInput { + let [_, num_classes] = self.output.dims(); + if num_classes > 1 { + ConfusionStatsInput::new( + self.output.clone(), + self.targets.clone().one_hot(num_classes).bool(), + ) + } else { + ConfusionStatsInput::new( + self.output.clone(), + self.targets.clone().unsqueeze_dim(1).bool(), + ) + } + } +} + +/// Multi-label classification output adapted for multiple metrics. +/// +/// Supported metrics: +/// - HammingScore +/// - Precision (via ConfusionStatsInput) +/// - Recall (via ConfusionStatsInput) +/// - FBetaScore (via ConfusionStatsInput) +/// - Loss +#[derive(new)] +pub struct MultiLabelClassificationOutput { + /// The loss. + pub loss: Tensor<1>, + + /// The label logits or probabilities. Shape: \[batch_size, num_classes\]. + pub output: Tensor<2>, + + /// The ground truth labels. Shape: \[batch_size, num_classes\]. + pub targets: Tensor<2, Int>, +} + +impl ItemLazy for MultiLabelClassificationOutput { + fn sync(self) -> Self { + let [output, loss, targets] = Transaction::default() + .register(self.output) + .register(self.loss) + .register(self.targets) + .execute() + .try_into() + .expect("Correct amount of tensor data"); + + let device: Device = Device::flex(); + + MultiLabelClassificationOutput { + output: Tensor::from_data(output, &device), + loss: Tensor::from_data(loss, &device), + targets: Tensor::from_data(targets, &device), + } + } +} + +impl Adaptor for MultiLabelClassificationOutput { + fn adapt(&self) -> HammingScoreInput { + HammingScoreInput::new(self.output.clone(), self.targets.clone()) + } +} + +impl Adaptor for MultiLabelClassificationOutput { + fn adapt(&self) -> LossInput { + LossInput::new(self.loss.clone()) + } +} + +impl Adaptor for MultiLabelClassificationOutput { + fn adapt(&self) -> ConfusionStatsInput { + ConfusionStatsInput::new(self.output.clone(), self.targets.clone().bool()) + } +} diff --git a/crates/burn-train/src/learner/early_stopping.rs b/crates/burn-train/src/learner/early_stopping.rs new file mode 100644 index 0000000..3bb8aed --- /dev/null +++ b/crates/burn-train/src/learner/early_stopping.rs @@ -0,0 +1,301 @@ +use crate::metric::{ + Metric, MetricName, + store::{Aggregate, Direction, EventStoreClient, Split}, +}; + +/// The condition that [early stopping strategies](EarlyStoppingStrategy) should follow. +#[derive(Clone)] +pub enum StoppingCondition { + /// When no improvement has happened since the given number of epochs. + NoImprovementSince { + /// The number of epochs allowed to worsen before it gets better. + n_epochs: usize, + }, +} + +/// A strategy that checks if the training should be stopped. +pub trait EarlyStoppingStrategy: Send { + /// Update its current state and returns if the training should be stopped. + fn should_stop(&mut self, epoch: usize, store: &EventStoreClient) -> bool; +} + +/// A helper trait to provide type-erased cloning. +pub trait CloneEarlyStoppingStrategy: EarlyStoppingStrategy + Send { + /// Clone into a boxed trait object. + fn clone_box(&self) -> Box; +} + +/// Blanket-implement `CloneEarlyStoppingStrategy` for any `T` that +/// already implements your strategy + `Clone` + `Send` + `'static`. +impl CloneEarlyStoppingStrategy for T +where + T: EarlyStoppingStrategy + Clone + Send + 'static, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Now you can `impl Clone` for the boxed trait object. +impl Clone for Box { + fn clone(&self) -> Box { + self.clone_box() + } +} + +/// An [early stopping strategy](EarlyStoppingStrategy) based on a metrics collected +/// during training or validation. +#[derive(Clone)] +pub struct MetricEarlyStoppingStrategy { + condition: StoppingCondition, + metric_name: MetricName, + aggregate: Aggregate, + direction: Direction, + split: Split, + best_epoch: usize, + best_value: f64, + warmup_epochs: Option, +} + +impl EarlyStoppingStrategy for MetricEarlyStoppingStrategy { + fn should_stop(&mut self, epoch: usize, store: &EventStoreClient) -> bool { + let current_value = + match store.find_metric(&self.metric_name, epoch, self.aggregate, &self.split) { + Some(value) => value, + None => { + log::warn!("Can't find metric for early stopping."); + return false; + } + }; + + let is_best = match self.direction { + Direction::Lowest => current_value < self.best_value, + Direction::Highest => current_value > self.best_value, + }; + + if is_best { + log::info!( + "New best epoch found {} {}: {}", + epoch, + self.metric_name, + current_value + ); + self.best_value = current_value; + self.best_epoch = epoch; + return false; + } + + if let Some(warmup_epochs) = self.warmup_epochs + && epoch <= warmup_epochs + { + return false; + } + + match self.condition { + StoppingCondition::NoImprovementSince { n_epochs } => { + let should_stop = epoch - self.best_epoch >= n_epochs; + + if should_stop { + log::info!( + "Stopping training loop, no improvement since epoch {}, {}: {}, current \ + epoch {}, {}: {}", + self.best_epoch, + self.metric_name, + self.best_value, + epoch, + self.metric_name, + current_value + ); + } + + should_stop + } + } + } +} + +impl MetricEarlyStoppingStrategy { + /// Create a new [early stopping strategy](EarlyStoppingStrategy) based on a metrics collected + /// during training or validation. + /// + /// # Notes + /// + /// The metric should be registered for early stopping to work, otherwise no data is collected. + pub fn new( + metric: &Me, + aggregate: Aggregate, + direction: Direction, + split: Split, + condition: StoppingCondition, + ) -> Self { + let init_value = match direction { + Direction::Lowest => f64::MAX, + Direction::Highest => f64::MIN, + }; + + Self { + metric_name: metric.name(), + condition, + aggregate, + direction, + split, + best_epoch: 1, + best_value: init_value, + warmup_epochs: None, + } + } + + /// Get the warmup period. + /// + /// Early stopping will not trigger during the warmup epochs. + pub fn warmup_epochs(&self) -> Option { + self.warmup_epochs + } + + /// Set the warmup epochs. + /// + /// Early stopping will not trigger during the warmup epochs. + /// + /// # Arguments + /// - `warmup`: the number of warmup epochs, or None. + pub fn with_warmup_epochs(self, warmup: Option) -> Self { + Self { + warmup_epochs: warmup, + ..self + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::{ + EventProcessorTraining, + logger::InMemoryMetricLogger, + metric::{ + LossMetric, + processor::{ + MetricsTraining, MinimalEventProcessor, + test_utils::{end_epoch, process_train}, + }, + store::LogEventStore, + }, + test_utils::start_epoch, + }; + + use super::*; + + #[test] + fn never_early_stop_while_it_is_improving() { + test_early_stopping( + None, + 1, + &[ + (&[0.5, 0.3], false, "Should not stop first epoch"), + (&[0.4, 0.3], false, "Should not stop when improving"), + (&[0.3, 0.3], false, "Should not stop when improving"), + (&[0.2, 0.3], false, "Should not stop when improving"), + ], + ); + } + + #[test] + fn early_stop_when_no_improvement_since_two_epochs() { + test_early_stopping( + None, + 2, + &[ + (&[1.0, 0.5], false, "Should not stop first epoch"), + (&[0.5, 0.3], false, "Should not stop when improving"), + ( + &[1.0, 3.0], + false, + "Should not stop first time it gets worse", + ), + ( + &[1.0, 2.0], + true, + "Should stop since two following epochs didn't improve", + ), + ], + ); + } + + #[test] + fn early_stopping_with_warmup() { + test_early_stopping( + Some(3), + 2, + &[ + (&[1.0, 0.5], false, "Should not stop during warmup"), + (&[1.0, 0.5], false, "Should not stop during warmup"), + (&[1.0, 0.5], false, "Should not stop during warmup"), + ( + &[1.0, 0.5], + true, + "Should stop when not improving after warmup", + ), + ], + ) + } + + #[test] + fn early_stop_when_stays_equal() { + test_early_stopping( + None, + 2, + &[ + (&[0.5, 0.3], false, "Should not stop first epoch"), + ( + &[0.5, 0.3], + false, + "Should not stop first time it stars the same", + ), + ( + &[0.5, 0.3], + true, + "Should stop since two following epochs didn't improve", + ), + ], + ); + } + + fn test_early_stopping(warmup: Option, n_epochs: usize, data: &[(&[f64], bool, &str)]) { + let loss = LossMetric::new(); + let mut early_stopping = MetricEarlyStoppingStrategy::new( + &loss, + Aggregate::Mean, + Direction::Lowest, + Split::Train, + StoppingCondition::NoImprovementSince { n_epochs }, + ) + .with_warmup_epochs(warmup); + let mut store = LogEventStore::default(); + let mut metrics = MetricsTraining::::default(); + + store.register_logger(InMemoryMetricLogger::default()); + metrics.register_train_metric_numeric(loss); + + let store = Arc::new(EventStoreClient::new(store)); + let mut processor = MinimalEventProcessor::new(metrics, store.clone()); + + processor.process_train(crate::LearnerEvent::Start { + total_epochs: 0, + starting_epoch: 0, + }); + for (epoch, (points, should_start, comment)) in (1..).zip(data.iter()) { + start_epoch(&mut processor, epoch, points.len()); + for point in points.iter() { + process_train(&mut processor, *point, epoch); + } + end_epoch(&mut processor, epoch); + + assert_eq!( + *should_start, + early_stopping.should_stop(epoch, &store), + "{comment}" + ); + } + } +} diff --git a/crates/burn-train/src/learner/mod.rs b/crates/burn-train/src/learner/mod.rs new file mode 100644 index 0000000..bdbc573 --- /dev/null +++ b/crates/burn-train/src/learner/mod.rs @@ -0,0 +1,26 @@ +#[cfg(feature = "rl")] +mod rl; +#[cfg(feature = "rl")] +pub use rl::*; + +mod application_logger; +mod base; +mod classification; +mod early_stopping; +mod regression; +mod sequence; +mod sharder; +mod summary; +mod supervised; +mod train_val; + +pub use application_logger::*; +pub use base::*; +pub use classification::*; +pub use early_stopping::*; +pub use regression::*; +pub use sequence::*; +pub use sharder::*; +pub use summary::*; +pub use supervised::*; +pub use train_val::*; diff --git a/crates/burn-train/src/learner/regression.rs b/crates/burn-train/src/learner/regression.rs new file mode 100644 index 0000000..a70e8a8 --- /dev/null +++ b/crates/burn-train/src/learner/regression.rs @@ -0,0 +1,42 @@ +use crate::metric::processor::ItemLazy; +use crate::metric::{Adaptor, LossInput}; +use burn_core::tensor::{Device, Tensor, Transaction}; + +/// Regression output adapted for the loss metric. +#[derive(new)] +pub struct RegressionOutput { + /// The loss. + pub loss: Tensor<1>, + + /// The predicted values. Shape: \[batch_size, num_targets\]. + pub output: Tensor<2>, + + /// The ground truth values. Shape: \[batch_size, num_targets\]. + pub targets: Tensor<2>, +} + +impl Adaptor for RegressionOutput { + fn adapt(&self) -> LossInput { + LossInput::new(self.loss.clone()) + } +} + +impl ItemLazy for RegressionOutput { + fn sync(self) -> Self { + let [output, loss, targets] = Transaction::default() + .register(self.output) + .register(self.loss) + .register(self.targets) + .execute() + .try_into() + .expect("Correct amount of tensor data"); + + let device: Device = Device::flex(); + + RegressionOutput { + output: Tensor::from_data(output, &device), + loss: Tensor::from_data(loss, &device), + targets: Tensor::from_data(targets, &device), + } + } +} diff --git a/crates/burn-train/src/learner/rl/checkpointer.rs b/crates/burn-train/src/learner/rl/checkpointer.rs new file mode 100644 index 0000000..dbe40e8 --- /dev/null +++ b/crates/burn-train/src/learner/rl/checkpointer.rs @@ -0,0 +1,77 @@ +use burn_rl::{Policy, PolicyLearner, PolicyState}; + +use crate::RLAgentRecord; +use crate::{ + RLComponentsTypes, RLPolicyRecord, + checkpoint::Checkpointer, + checkpoint::{AsyncCheckpointer, Checkpoint, CheckpointingAction, CheckpointingStrategy}, + metric::store::EventStoreClient, +}; + +#[derive(new)] +/// Used to create, delete, or load checkpoints of the training process. +pub struct RLCheckpointer { + policy: AsyncCheckpointer>, + learning_agent: AsyncCheckpointer>, + strategy: Box, +} + +impl RLCheckpointer +where + RLPolicyRecord: Checkpoint, + RLAgentRecord: Checkpoint, +{ + /// Create checkpoint for the training process. + pub fn checkpoint( + &mut self, + policy: &RLC::PolicyState, + learning_agent: &RLC::LearningAgent, + epoch: usize, + store: &EventStoreClient, + ) { + let actions = self.strategy.checkpointing(epoch, store); + + for action in actions { + match action { + CheckpointingAction::Delete(epoch) => { + self.policy + .delete(epoch) + .expect("Can delete policy checkpoint."); + self.learning_agent + .delete(epoch) + .expect("Can delete learning agent checkpoint.") + } + CheckpointingAction::Save => { + self.policy + .save(epoch, policy.clone().into_record()) + .expect("Can save policy checkpoint."); + self.learning_agent + .save(epoch, learning_agent.record()) + .expect("Can save learning agent checkpoint."); + } + } + } + } + + /// Load a training checkpoint. + pub fn load_checkpoint( + &self, + learning_agent: RLC::LearningAgent, + epoch: usize, + ) -> RLC::LearningAgent { + let record = self + .policy + .restore(epoch) + .expect("Can load model checkpoint."); + let policy = learning_agent.policy().load_record(record); + + let record = self + .learning_agent + .restore(epoch) + .expect("Can load learning agent checkpoint."); + let mut learning_agent = learning_agent.load_record(record); + learning_agent.update_policy(policy); + + learning_agent + } +} diff --git a/crates/burn-train/src/learner/rl/components.rs b/crates/burn-train/src/learner/rl/components.rs new file mode 100644 index 0000000..5164e6e --- /dev/null +++ b/crates/burn-train/src/learner/rl/components.rs @@ -0,0 +1,110 @@ +use std::marker::PhantomData; + +use burn_rl::{ + Batchable, Environment, EnvironmentInit, Policy, PolicyLearner, PolicyState, ToAction, + ToObservation, +}; + +use crate::checkpoint::Checkpoint; +use crate::{AgentEvaluationEvent, AsyncProcessorTraining, ItemLazy, RLEvent}; + +/// All components used by the reinforcement learning paradigm, grouped in one trait. +pub trait RLComponentsTypes { + /// The learning environment. + type Env: Environment + 'static; + /// Specifies how to initialize the environment. + type EnvInit: EnvironmentInit + Send + 'static; + /// The type of the environment state. + type State: ToObservation<::Observation> + Clone + Send + 'static; + /// The type of the environment action. + type Action: From<::Action> + + ToAction<::Action> + + Clone + + Send + + 'static; + + /// The policy used to take actions in the environment. + type Policy: Policy< + Observation = Self::PolicyObs, + ActionDistribution = Self::PolicyAD, + Action = Self::PolicyAction, + ActionContext = Self::ActionContext, + PolicyState = Self::PolicyState, + > + Send + + 'static; + /// The policy's observation type. + type PolicyObs: Clone + Send + Batchable + 'static; + /// The policy's action distribution type. + type PolicyAD: Clone + Send + Batchable; + /// The policy's action type. + type PolicyAction: Clone + Send + Batchable; + /// Additional data as context for an agent's action. + type ActionContext: ItemLazy + Clone + Send + 'static; + /// The state of the parameterized policy. + type PolicyState: Clone + Send + PolicyState + 'static; + + /// The learning agent. + type LearningAgent: PolicyLearner< + TrainContext = Self::TrainingOutput, + InnerPolicy = Self::Policy, + Record: Checkpoint, + > + Send + + 'static; + /// The output data of a training step. + type TrainingOutput: ItemLazy + Clone + Send; +} + +/// Concrete type that implements the [RLComponentsTypes](RLComponentsTypes) trait. +pub struct RLComponentsMarker { + _env: PhantomData, + _env_init: PhantomData, + _agent: PhantomData, +} + +impl RLComponentsTypes for RLComponentsMarker +where + E: Environment + 'static, + EI: EnvironmentInit + Send + 'static, + A: PolicyLearner + Send + 'static, + ::Record: Checkpoint, + A::TrainContext: ItemLazy + Clone + Send, + A::InnerPolicy: Policy + Send, + ::Observation: Batchable + Clone + Send, + ::ActionDistribution: Batchable + Clone + Send, + ::Action: Batchable + Clone + Send, + ::ActionContext: ItemLazy + Clone + Send + 'static, + ::PolicyState: Clone + Send, + <::PolicyState as PolicyState>::Record: Checkpoint, + E::State: ToObservation<::Observation> + Clone + Send + 'static, + E::Action: From<::Action> + + ToAction<::Action> + + Clone + + Send + + 'static, +{ + type Env = E; + type EnvInit = EI; + type LearningAgent = A; + type Policy = A::InnerPolicy; + type PolicyObs = ::Observation; + type PolicyAD = ::ActionDistribution; + type PolicyAction = ::Action; + type ActionContext = ::ActionContext; + type PolicyState = ::PolicyState; + type TrainingOutput = A::TrainContext; + type State = E::State; + type Action = E::Action; +} + +pub(crate) type RlPolicy = + <::LearningAgent as PolicyLearner>::InnerPolicy; +/// The event processor type for reinforcement learning. +pub type RLEventProcessorType = AsyncProcessorTraining< + RLEvent<::TrainingOutput, ::ActionContext>, + AgentEvaluationEvent<::ActionContext>, +>; +/// The record of the policy. +pub type RLPolicyRecord = + <<::Policy as Policy>::PolicyState as PolicyState>::Record; +/// The record of the learning agent. +pub type RLAgentRecord = <::LearningAgent as PolicyLearner>::Record; diff --git a/crates/burn-train/src/learner/rl/env_runner/async_runner.rs b/crates/burn-train/src/learner/rl/env_runner/async_runner.rs new file mode 100644 index 0000000..93a2e62 --- /dev/null +++ b/crates/burn-train/src/learner/rl/env_runner/async_runner.rs @@ -0,0 +1,735 @@ +use rand::prelude::SliceRandom; +use std::{ + sync::mpsc::{Receiver, Sender}, + thread::spawn, +}; + +use burn_core::{Tensor, data::dataloader::Progress, tensor::Device}; +use burn_rl::Policy; +use burn_rl::Transition; +use burn_rl::{AsyncPolicy, Environment}; +use burn_rl::{EnvironmentInit, ToObservation}; + +use crate::{ + AgentEnvLoop, AgentEvaluationEvent, EpisodeSummary, EvaluationItem, EventProcessorTraining, + Interrupter, RLComponentsTypes, RLEvent, RLEventProcessorType, RLTimeStep, RLTrajectory, + RlPolicy, TimeStep, Trajectory, +}; + +enum RequestMessage { + Step(), + Episode(), +} + +/// Configuration for an async agent/environment loop. +pub struct AsyncAgentEnvLoopConfig { + /// If the loop is used for evaluation (as opposed to training). + pub eval: bool, + /// If the agent should take action deterministically. + pub deterministic: bool, + /// An arbitrary ID for the loop. + pub id: usize, +} + +/// An asynchronous agent/environement interface. +pub struct AgentEnvAsyncLoop { + eval: bool, + agent: AsyncPolicy>, + transition_receiver: Receiver>, + trajectory_receiver: Receiver>, + request_sender: Sender, + device: Device, +} + +impl AgentEnvAsyncLoop { + /// Create a new asynchronous runner. + /// + /// # Arguments + /// + /// * `env_init` - A function returning an environment instance. + /// * `agent` - An [AsyncPolicy](AsyncPolicy) taking actions in the loop. + /// * `config` - An [AsyncAgentEnvLoopConfig](AsyncAgentEnvLoopConfig). + /// * `transition_sender` - Optional sender for transitions if you want to drive the requests from outside of the loop instance. + /// * `trajectory_sender` - Optional sender for trajectories if you want to drive the requests from outside of the loop instance. + /// + /// # Returns + /// + /// An async Agent/Environement loop. + pub fn new( + env_init: RLC::EnvInit, + agent: AsyncPolicy>, + config: AsyncAgentEnvLoopConfig, + transition_device: &Device, + transition_sender: Option>>, + trajectory_sender: Option>>, + ) -> Self { + let (loop_transition_sender, transition_receiver) = std::sync::mpsc::channel(); + let (loop_trajectory_sender, trajectory_receiver) = std::sync::mpsc::channel(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let loop_transition_sender = transition_sender.unwrap_or(loop_transition_sender); + let loop_trajectory_sender = trajectory_sender.unwrap_or(loop_trajectory_sender); + + let device = transition_device.clone(); + let mut loop_agent = agent.clone().to_device(transition_device); + let eval = config.eval; + + let mut current_steps = vec![]; + let mut current_reward = 0.0; + let mut step_num = 0; + spawn(move || { + let mut env = env_init.init(); + env.reset(); + + let mut request_episode = false; + loop { + let state = env.state(); + let (action, context) = + loop_agent.action(state.clone().to_observation(&device), config.deterministic); + + let env_action = RLC::Action::from(action); + let step_result = env.step(env_action.clone()); + + current_reward += step_result.reward; + step_num += 1; + + let transition = Transition::new( + state.clone(), + step_result.next_state, + env_action, + Tensor::from_data([step_result.reward], &device), + Tensor::from_data( + [(step_result.done || step_result.truncated) as i32 as f64], + &device, + ), + ); + + if !request_episode { + loop_agent.decrement_agents(1); + let request = match request_receiver.recv() { + Ok(req) => req, + Err(err) => { + log::error!("Error in env runner : {}", err); + break; + } + }; + loop_agent.increment_agents(1); + + match request { + RequestMessage::Step() => (), + RequestMessage::Episode() => request_episode = true, + } + } + + let time_step = TimeStep { + env_id: config.id, + transition, + done: step_result.done, + ep_len: step_num, + cum_reward: current_reward, + action_context: context[0].clone(), + }; + current_steps.push(time_step.clone()); + + if !request_episode && let Err(err) = loop_transition_sender.send(time_step) { + log::error!("Error in env runner : {}", err); + break; + } + + if step_result.done || step_result.truncated { + if request_episode { + request_episode = false; + loop_trajectory_sender + .send(Trajectory { + timesteps: current_steps.clone(), + }) + .expect("Can send trajectory to main thread."); + } + current_steps.clear(); + + env.reset(); + current_reward = 0.; + step_num = 0; + } + } + }); + + Self { + eval, + agent, + transition_receiver, + trajectory_receiver, + request_sender, + device: transition_device.clone(), + } + } +} + +impl AgentEnvLoop for AgentEnvAsyncLoop +where + RLC: RLComponentsTypes, +{ + fn run_steps( + &mut self, + num_steps: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec> { + let mut items = vec![]; + for _ in 0..num_steps { + self.request_sender + .send(RequestMessage::Step()) + .expect("Can request transitions."); + let transition = self + .transition_receiver + .recv() + .expect("Can receive transitions."); + items.push(transition.clone()); + + if !self.eval { + progress.items_processed += 1; + processor.process_train(RLEvent::EnvStep(EvaluationItem::new( + transition.action_context, + progress.clone(), + None, + ))); + + if transition.done { + processor.process_train(RLEvent::EpisodeEnd(EvaluationItem::new( + EpisodeSummary { + episode_length: transition.ep_len, + cum_reward: transition.cum_reward, + }, + progress.clone(), + None, + ))); + } + } + + if interrupter.should_stop() { + break; + } + } + items + } + + fn run_episodes( + &mut self, + num_episodes: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + _progress: &mut Progress, + ) -> Vec> { + let mut items = vec![]; + self.agent.increment_agents(1); + for episode_num in 0..num_episodes { + self.request_sender + .send(RequestMessage::Episode()) + .expect("Can request episodes."); + let trajectory = self + .trajectory_receiver + .recv() + .expect("Main thread can receive trajectory."); + + for (i, step) in trajectory.timesteps.iter().enumerate() { + // TODO : clean this. + if self.eval { + processor.process_valid(AgentEvaluationEvent::EnvStep(EvaluationItem::new( + step.action_context.clone(), + Progress::new(i, i, Some("steps".to_string())), + None, + ))); + + if step.done { + processor.process_valid(AgentEvaluationEvent::EpisodeEnd( + EvaluationItem::new( + EpisodeSummary { + episode_length: step.ep_len, + cum_reward: step.cum_reward, + }, + Progress::new( + episode_num + 1, + num_episodes, + Some("episodes".to_string()), + ), + None, + ), + )); + } + } else { + processor.process_train(RLEvent::EnvStep(EvaluationItem::new( + step.action_context.clone(), + Progress::new(i, i, Some("steps".to_string())), + None, + ))); + + if step.done { + processor.process_train(RLEvent::EpisodeEnd(EvaluationItem::new( + EpisodeSummary { + episode_length: step.ep_len, + cum_reward: step.cum_reward, + }, + Progress::new( + episode_num + 1, + num_episodes, + Some("episodes".to_string()), + ), + None, + ))); + } + } + } + + items.push(trajectory); + if interrupter.should_stop() { + break; + } + } + self.agent.decrement_agents(1); + items + } + + fn update_policy(&mut self, update: RLC::PolicyState) { + self.agent.update(update); + self.agent.clone().to_device(&self.device); + } + + fn policy(&self) -> RLC::PolicyState { + self.agent.state() + } + + fn device(&self) -> Device { + self.device.clone() + } +} + +/// An asynchronous runner for multiple agent/environement interfaces. +pub struct MultiAgentEnvLoop { + num_envs: usize, + eval: bool, + agent: AsyncPolicy, + transition_receiver: Receiver>, + trajectory_receiver: Receiver>, + request_senders: Vec>, + device: Device, +} + +impl MultiAgentEnvLoop { + /// Create a new asynchronous runner for multiple agent/environement interfaces. + pub fn new( + num_envs: usize, + env_init: RLC::EnvInit, + agent: AsyncPolicy, + eval: bool, + deterministic: bool, + device: &Device, + ) -> Self { + let (transition_sender, transition_receiver) = std::sync::mpsc::channel(); + let (trajectory_sender, trajectory_receiver) = std::sync::mpsc::channel(); + let mut request_senders = vec![]; + + // Double batching : The environments are always one step ahead of requests. This allows inference for the first batch of steps. + agent.increment_agents(num_envs); + + for i in 0..num_envs { + let config = AsyncAgentEnvLoopConfig { + eval, + deterministic, + id: i, + }; + let runner = AgentEnvAsyncLoop::::new( + env_init.clone(), + agent.clone(), + config, + &device.clone(), + Some(transition_sender.clone()), + Some(trajectory_sender.clone()), + ); + request_senders.push(runner.request_sender.clone()); + } + + // Double batching : The environments are always one step ahead. + request_senders.iter().for_each(|s| { + s.send(RequestMessage::Step()) + .expect("Main thread can send step requests.") + }); + + Self { + num_envs, + eval, + agent: agent.clone(), + transition_receiver, + trajectory_receiver, + request_senders, + device: device.clone(), + } + } +} + +impl AgentEnvLoop for MultiAgentEnvLoop +where + RLC: RLComponentsTypes, +{ + fn run_steps( + &mut self, + num_steps: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec> { + let mut items = vec![]; + for _ in 0..num_steps { + let transition = self + .transition_receiver + .recv() + .expect("Can receive transitions."); + items.push(transition.clone()); + + self.request_senders[transition.env_id] + .send(RequestMessage::Step()) + .expect("Main thread can request steps."); + + if !self.eval { + progress.items_processed += 1; + processor.process_train(RLEvent::EnvStep(EvaluationItem::new( + transition.action_context, + progress.clone(), + None, + ))); + + if transition.done { + processor.process_train(RLEvent::EpisodeEnd(EvaluationItem::new( + EpisodeSummary { + episode_length: transition.ep_len, + cum_reward: transition.cum_reward, + }, + progress.clone(), + None, + ))); + } + } + + if interrupter.should_stop() { + break; + } + } + items + } + + fn update_policy(&mut self, update: RLC::PolicyState) { + self.agent.update(update); + self.agent.clone().to_device(&self.device); + } + + fn run_episodes( + &mut self, + num_episodes: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + _progress: &mut Progress, + ) -> Vec> { + // Send `num_episodes` initial requests. + let mut idx = vec![]; + if num_episodes < self.num_envs { + let mut rng = rand::rng(); + let mut vec: Vec = (0..self.num_envs).collect(); + vec.shuffle(&mut rng); + idx = vec.into_iter().take(num_episodes).collect(); + } else { + idx = (0..self.num_envs).collect(); + } + let num_requests = self.num_envs.min(num_episodes); + idx.into_iter().for_each(|i| { + self.request_senders[i] + .send(RequestMessage::Episode()) + .expect("Main thread can request steps."); + }); + + let mut items = vec![]; + for episode_num in 0..num_episodes { + let trajectory = self + .trajectory_receiver + .recv() + .expect("Can receive trajectory."); + items.push(trajectory.clone()); + if items.len() + num_requests <= num_episodes { + self.request_senders[trajectory.timesteps[0].env_id] + .send(RequestMessage::Episode()) + .expect("Main thread can request steps."); + } + for (i, step) in trajectory.timesteps.iter().enumerate() { + if self.eval { + processor.process_valid(AgentEvaluationEvent::EnvStep(EvaluationItem::new( + step.action_context.clone(), + Progress::new(i, i, Some("steps".to_string())), + None, + ))); + + if step.done { + processor.process_valid(AgentEvaluationEvent::EpisodeEnd( + EvaluationItem::new( + EpisodeSummary { + episode_length: step.ep_len, + cum_reward: step.cum_reward, + }, + Progress::new( + episode_num + 1, + num_episodes, + Some("episodes".to_string()), + ), + None, + ), + )); + } + } else { + processor.process_train(RLEvent::EnvStep(EvaluationItem::new( + step.action_context.clone(), + Progress::new(i, i, Some("steps".to_string())), + None, + ))); + + if step.done { + processor.process_train(RLEvent::EpisodeEnd(EvaluationItem::new( + EpisodeSummary { + episode_length: step.ep_len, + cum_reward: step.cum_reward, + }, + Progress::new( + episode_num + 1, + num_episodes, + Some("episodes".to_string()), + ), + None, + ))); + } + } + } + + if interrupter.should_stop() { + break; + } + } + + items + } + + fn policy(&self) -> RLC::PolicyState { + self.agent.state() + } + + fn device(&self) -> Device { + self.device.clone() + } +} + +#[cfg(test)] +#[allow(clippy::needless_range_loop)] +mod tests { + use burn_core::data::dataloader::Progress; + use burn_rl::AsyncPolicy; + + use crate::learner::rl::env_runner::async_runner::AsyncAgentEnvLoopConfig; + use crate::learner::rl::env_runner::base::AgentEnvLoop; + use crate::learner::tests::{MockPolicyState, MockProcessor}; + use crate::{ + AgentEnvAsyncLoop, + learner::tests::{MockEnvInit, MockPolicy, MockRLComponents}, + }; + use crate::{AsyncProcessorTraining, Interrupter, MultiAgentEnvLoop}; + + fn setup_async_loop( + state: usize, + eval: bool, + deterministic: bool, + ) -> AgentEnvAsyncLoop { + let env_init = MockEnvInit; + let agent = MockPolicy(state); + let config = AsyncAgentEnvLoopConfig { + eval, + deterministic, + id: 0, + }; + AgentEnvAsyncLoop::::new( + env_init, + AsyncPolicy::new(1, agent), + config, + &Default::default(), + None, + None, + ) + } + + fn setup_multi_loop( + num_envs: usize, + autobatch_size: usize, + state: usize, + eval: bool, + deterministic: bool, + ) -> MultiAgentEnvLoop { + let env_init = MockEnvInit; + let agent = MockPolicy(state); + MultiAgentEnvLoop::::new( + num_envs, + env_init, + AsyncPolicy::new(autobatch_size, agent), + eval, + deterministic, + &Default::default(), + ) + } + + #[test] + fn test_policy_async_loop() { + let runner = setup_async_loop(1000, false, false); + let policy_state = runner.policy(); + assert_eq!(policy_state.0, 1000); + } + + #[test] + fn test_update_policy_async_loop() { + let mut runner = setup_async_loop(0, false, false); + + runner.update_policy(MockPolicyState(1)); + assert_eq!(runner.policy().0, 1); + } + + #[test] + fn run_steps_returns_requested_number_async_loop() { + let mut runner = setup_async_loop(0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + let steps = runner.run_steps(1, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), 1); + let steps = runner.run_steps(8, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), 8); + } + + #[test] + fn run_episodes_returns_requested_number_async_loop() { + let mut runner = setup_async_loop(0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + let trajectories = runner.run_episodes(1, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), 1); + assert_ne!(trajectories[0].timesteps.len(), 0); + let trajectories = runner.run_episodes(8, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), 8); + for i in 0..8 { + assert_ne!(trajectories[i].timesteps.len(), 0); + } + } + + #[test] + fn test_policy_multi_loop() { + let runner = setup_multi_loop(4, 4, 1000, false, false); + let policy_state = runner.policy(); + assert_eq!(policy_state.0, 1000); + } + + #[test] + fn test_update_policy_multi_loop() { + let mut runner = setup_multi_loop(4, 4, 0, false, false); + + runner.update_policy(MockPolicyState(1)); + assert_eq!(runner.policy().0, 1); + } + + #[test] + fn run_steps_returns_requested_number_multi_loop() { + fn run_test(num_envs: usize, autobatch_size: usize) { + let mut runner = setup_multi_loop(num_envs, autobatch_size, 0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + // Kickstart tests by running some steps to make sure it's not a double batching edge case success. + let steps = runner.run_steps(8, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), 8); + + for i in 0..16 { + let steps = runner.run_steps(i, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), i); + } + } + + // num_envs == autobatch_size + run_test(1, 1); + run_test(4, 4); + // num_envs < autobatch_size + run_test(1, 2); + run_test(1, 3); + run_test(2, 3); + run_test(2, 4); + run_test(5, 19); + // num_envs > autobatch_size + run_test(2, 1); + run_test(8, 1); + run_test(3, 2); + run_test(8, 2); + run_test(8, 3); + run_test(8, 7); + } + + #[test] + fn run_episodes_returns_requested_number_multi_loop() { + fn run_test(num_envs: usize, autobatch_size: usize) { + let mut runner = setup_multi_loop(num_envs, autobatch_size, 0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + // Kickstart tests by running some episodes to make sure it's not a double batching edge case success. + let trajectories = runner.run_episodes(8, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), 8); + for j in 0..8 { + assert_ne!(trajectories[j].timesteps.len(), 0); + } + + for i in 0..16 { + let trajectories = + runner.run_episodes(i, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), i); + for j in 0..i { + assert_ne!(trajectories[j].timesteps.len(), 0); + } + } + } + + // num_envs == autobatch_size + run_test(1, 1); + run_test(4, 4); + // num_envs < autobatch_size + run_test(1, 2); + run_test(1, 3); + run_test(2, 3); + run_test(2, 4); + run_test(5, 19); + // num_envs > autobatch_size + run_test(2, 1); + run_test(8, 1); + run_test(3, 2); + run_test(8, 2); + run_test(8, 3); + run_test(8, 7); + } +} diff --git a/crates/burn-train/src/learner/rl/env_runner/base.rs b/crates/burn-train/src/learner/rl/env_runner/base.rs new file mode 100644 index 0000000..3c3ac4f --- /dev/null +++ b/crates/burn-train/src/learner/rl/env_runner/base.rs @@ -0,0 +1,355 @@ +use burn_core::Tensor; +use burn_core::data::dataloader::Progress; +use burn_core::tensor::Device; +use burn_rl::Policy; +use burn_rl::ToObservation; +use burn_rl::Transition; +use burn_rl::{Environment, EnvironmentInit}; + +use crate::RLEvent; +use crate::{ + AgentEvaluationEvent, EpisodeSummary, EvaluationItem, EventProcessorTraining, + RLEventProcessorType, +}; +use crate::{Interrupter, RLComponentsTypes}; + +/// A trajectory, i.e. a list of ordered [TimeStep](TimeStep). +#[derive(Clone, new)] +pub struct Trajectory { + /// A list of ordered [TimeStep](TimeStep)s. + pub timesteps: Vec>, +} + +/// A timestep debscribing an iteration of the state/decision process. +#[derive(Clone)] +pub struct TimeStep { + /// The environment id. + pub env_id: usize, + /// The [burn_rl::Transition](burn_rl::Transition). + pub transition: Transition, + /// True if the environment reaches a terminal state. + pub done: bool, + /// The running length of the current episode. + pub ep_len: usize, + /// The running cumulative reward. + pub cum_reward: f64, + /// The action's context for this timestep. + pub action_context: C, +} + +pub(crate) type RLTimeStep = TimeStep< + ::State, + ::Action, + ::ActionContext, +>; + +pub(crate) type RLTrajectory = Trajectory< + ::State, + ::Action, + ::ActionContext, +>; + +/// Trait for a structure that implements an agent/environement interface. +pub trait AgentEnvLoop { + /// Run a certain number of timesteps. + /// + /// # Arguments + /// + /// * `num_steps` - The number of time_steps to run. + /// * `processor` - An [crate::EventProcessorTraining](crate::EventProcessorTraining). + /// * `interrupter` - An [crate::Interrupter](crate::Interrupter). + /// * `num_steps` - The number of time_steps to run. + /// * `progress` - A mutable reference to the learning progress. + /// + /// # Returns + /// + /// A list of ordered timesteps. + fn run_steps( + &mut self, + num_steps: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec>; + /// Run a certain number of episodes. + /// + /// # Arguments + /// + /// * `num_episodes` - The number of episodes to run. + /// * `processor` - An [crate::EventProcessorTraining](crate::EventProcessorTraining). + /// * `interrupter` - An [crate::Interrupter](crate::Interrupter). + /// * `progress` - A mutable reference to the learning progress. + /// + /// # Returns + /// + /// A list of ordered timesteps. + fn run_episodes( + &mut self, + num_episodes: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec>; + /// Update the runner's agent. + fn update_policy(&mut self, update: RLC::PolicyState); + /// Get the state of the runner's agent. + fn policy(&self) -> RLC::PolicyState; + /// Returns the device on which the runner's agent runs. + fn device(&self) -> Device; +} + +/// A simple, synchronized agent/environement interface. +pub struct AgentEnvBaseLoop { + env: RLC::Env, + eval: bool, + agent: RLC::Policy, + deterministic: bool, + current_reward: f64, + run_num: usize, + step_num: usize, + device: Device, +} + +impl AgentEnvBaseLoop { + /// Create a new base runner. + pub fn new( + env_init: RLC::EnvInit, + agent: RLC::Policy, + eval: bool, + deterministic: bool, + device: &Device, + ) -> Self { + let agent = agent.to_device(device); + let mut env = env_init.init(); + env.reset(); + + Self { + env, + eval, + agent: agent.clone(), + deterministic, + current_reward: 0.0, + run_num: 0, + step_num: 0, + device: device.clone(), + } + } +} + +impl AgentEnvLoop for AgentEnvBaseLoop +where + RLC: RLComponentsTypes, +{ + fn run_steps( + &mut self, + num_steps: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec> { + let mut items = vec![]; + let device = Default::default(); + for _ in 0..num_steps { + let state = self.env.state(); + let (action, context) = self.agent.action( + state.clone().to_observation(&self.device), + self.deterministic, + ); + + let step_result = self.env.step(RLC::Action::from(action.clone())); + + self.current_reward += step_result.reward; + self.step_num += 1; + + let transition = Transition::new( + state.clone(), + step_result.next_state, + RLC::Action::from(action), + Tensor::from_data([step_result.reward], &device), + Tensor::from_data( + [(step_result.done || step_result.truncated) as i32 as f64], + &device, + ), + ); + items.push(TimeStep { + env_id: 0, + transition, + done: step_result.done, + ep_len: self.step_num, + cum_reward: self.current_reward, + action_context: context[0].clone(), + }); + + if !self.eval { + progress.items_processed += 1; + processor.process_train(RLEvent::EnvStep(EvaluationItem::new( + context[0].clone(), + progress.clone(), + None, + ))); + + if step_result.done { + processor.process_train(RLEvent::EpisodeEnd(EvaluationItem::new( + EpisodeSummary { + episode_length: self.step_num, + cum_reward: self.current_reward, + }, + progress.clone(), + None, + ))); + } + } + + if interrupter.should_stop() { + break; + } + + if step_result.done || step_result.truncated { + self.env.reset(); + self.current_reward = 0.; + self.step_num = 0; + self.run_num += 1; + } + } + items + } + + fn update_policy(&mut self, update: RLC::PolicyState) { + self.agent.update(update); + } + + fn run_episodes( + &mut self, + num_episodes: usize, + processor: &mut RLEventProcessorType, + interrupter: &Interrupter, + progress: &mut Progress, + ) -> Vec> { + self.env.reset(); + + let mut items = vec![]; + for ep in 0..num_episodes { + let mut steps = vec![]; + loop { + let step = self.run_steps(1, processor, interrupter, progress)[0].clone(); + steps.push(step.clone()); + + if self.eval { + processor.process_valid(AgentEvaluationEvent::EnvStep(EvaluationItem::new( + step.action_context.clone(), + Progress::new(steps.len() + 1, steps.len() + 1, Some("steps".to_string())), + None, + ))); + + if step.done { + processor.process_valid(AgentEvaluationEvent::EpisodeEnd( + EvaluationItem::new( + EpisodeSummary { + episode_length: step.ep_len, + cum_reward: step.cum_reward, + }, + Progress::new(ep + 1, num_episodes, Some("episodes".to_string())), + None, + ), + )); + } + } + + if interrupter.should_stop() || step.done { + break; + } + } + items.push(Trajectory::new(steps)); + + if interrupter.should_stop() { + break; + } + } + items + } + + fn policy(&self) -> RLC::PolicyState { + self.agent.state() + } + + fn device(&self) -> Device { + self.device.clone() + } +} + +#[cfg(test)] +#[allow(clippy::needless_range_loop)] +mod tests { + use crate::AsyncProcessorTraining; + + use crate::learner::tests::{ + MockEnvInit, MockPolicy, MockPolicyState, MockProcessor, MockRLComponents, + }; + + use super::*; + + fn setup(state: usize, eval: bool, deterministic: bool) -> AgentEnvBaseLoop { + let env_init = MockEnvInit; + let agent = MockPolicy(state); + AgentEnvBaseLoop::::new( + env_init, + agent, + eval, + deterministic, + &Default::default(), + ) + } + + #[test] + fn test_policy_returns_agent_state() { + let runner = setup(1000, false, false); + let policy_state = runner.policy(); + assert_eq!(policy_state.0, 1000); + } + + #[test] + fn test_update_policy() { + let mut runner = setup(0, false, false); + + runner.update_policy(MockPolicyState(1)); + assert_eq!(runner.policy().0, 1); + } + + #[test] + fn run_steps_returns_requested_number() { + let mut runner = setup(0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + let steps = runner.run_steps(1, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), 1); + let steps = runner.run_steps(8, &mut processor, &interrupter, &mut progress); + assert_eq!(steps.len(), 8); + } + + #[test] + fn run_episodes_returns_requested_number() { + let mut runner = setup(0, false, false); + let mut processor = AsyncProcessorTraining::new(MockProcessor); + let interrupter = Interrupter::new(); + let mut progress = Progress { + items_processed: 0, + items_total: 1, + unit: Some("steps".to_string()), + }; + + let trajectories = runner.run_episodes(1, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), 1); + assert_ne!(trajectories[0].timesteps.len(), 0); + let trajectories = runner.run_episodes(8, &mut processor, &interrupter, &mut progress); + assert_eq!(trajectories.len(), 8); + for i in 0..8 { + assert_ne!(trajectories[i].timesteps.len(), 0); + } + } +} diff --git a/crates/burn-train/src/learner/rl/env_runner/mod.rs b/crates/burn-train/src/learner/rl/env_runner/mod.rs new file mode 100644 index 0000000..8eacd49 --- /dev/null +++ b/crates/burn-train/src/learner/rl/env_runner/mod.rs @@ -0,0 +1,302 @@ +mod async_runner; +mod base; + +pub use async_runner::*; +pub use base::*; + +#[cfg(test)] +pub(crate) mod tests { + use burn_rl::{ + Batchable, Environment, EnvironmentInit, Policy, PolicyState, ToAction, ToObservation, + }; + + use crate::{ + AgentEvaluationEvent, EventProcessorTraining, ItemLazy, RLComponentsTypes, RLEvent, + }; + use burn_rl::{LearnerTransitionBatch, PolicyLearner, RLTrainOutput, StepResult}; + + /// Mock policy for testing + /// + /// Calling `forward()` with a [MockObservation](MockObservation) (list of f32) returns a [MockActionDistribution](MockActionDistribution) + /// containing a list of 0s of the same length as the observation. + /// + /// Calling `action()` with a [MockObservation](MockObservation) (list of f32) returns a [MockPolicyAction](MockPolicyAction) with a list of actions of the same length as the observation. + /// The actions are all 1 if the call is requested as deterministic, or else 0. + + #[derive(Clone)] + pub(crate) struct MockPolicy(pub usize); + + impl Policy for MockPolicy { + type Observation = MockObservation; + type ActionDistribution = MockActionDistribution; + type Action = MockPolicyAction; + type ActionContext = MockActionContext; + type PolicyState = MockPolicyState; + + fn forward(&mut self, obs: Self::Observation) -> Self::ActionDistribution { + let mut dists = vec![]; + for _ in obs.0 { + dists.push(MockActionDistribution(vec![0.])); + } + MockActionDistribution::batch(dists) + } + + fn action( + &mut self, + obs: Self::Observation, + deterministic: bool, + ) -> (Self::Action, Vec) { + let mut actions = vec![]; + let mut contexts = vec![]; + + for _ in obs.0 { + if deterministic { + actions.push(MockPolicyAction(vec![1])); + } else { + actions.push(MockPolicyAction(vec![0])); + } + contexts.push(MockActionContext); + } + + (MockPolicyAction::batch(actions), contexts) + } + + fn update(&mut self, update: Self::PolicyState) { + self.0 = update.0; + } + + fn state(&self) -> Self::PolicyState { + MockPolicyState(self.0) + } + + fn to_device(self, _device: &burn_core::prelude::Device) -> Self { + self + } + + fn load_record(self, _record: ::Record) -> Self { + self + } + } + + /// Mock observation for testing represented as a vector of f32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockObservation(pub Vec); + + /// Mock action for testing represented as a vector of i32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockPolicyAction(pub Vec); + + /// Mock action distribution for testing represented as a vector of i32. Can call `batch()` and `unbatch` on it. + #[derive(Clone)] + pub(crate) struct MockActionDistribution(Vec); + + #[derive(Clone)] + pub(crate) struct MockActionContext; + + /// Mock policy state for testing represented as an arbitrary `usize` that has no effect on the policy. + #[derive(Clone)] + pub(crate) struct MockPolicyState(pub usize); + + impl PolicyState for MockPolicyState { + type Record = (); + + fn into_record(self) -> Self::Record {} + + fn load_record(&self, _record: Self::Record) -> Self { + self.clone() + } + } + + impl Batchable for MockObservation { + fn batch(items: Vec) -> Self { + MockObservation(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + vec![MockObservation(self.0)] + } + } + + impl Batchable for MockPolicyAction { + fn batch(items: Vec) -> Self { + MockPolicyAction(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + let mut actions = vec![]; + for a in self.0 { + actions.push(MockPolicyAction(vec![a])); + } + actions + } + } + + impl Batchable for MockActionDistribution { + fn batch(items: Vec) -> Self { + MockActionDistribution(items.iter().flat_map(|m| m.0.clone()).collect()) + } + + fn unbatch(self) -> Vec { + let mut dists = vec![]; + for _ in self.0 { + dists.push(MockActionDistribution(vec![0.])); + } + dists + } + } + + /// Mock environment for testing + #[derive(Clone)] + pub(crate) struct MockEnv { + counter: usize, + } + + #[derive(Clone, Debug)] + pub(crate) struct MockState; + + #[derive(Clone, Debug)] + pub(crate) struct MockAction(pub i32); + + impl ToObservation for MockState { + fn to_observation(&self, _device: &burn_core::prelude::Device) -> MockObservation { + MockObservation(vec![0.]) + } + } + + impl From for MockAction { + fn from(value: MockPolicyAction) -> Self { + MockAction(value.0[0]) + } + } + + impl ToAction for MockAction { + fn to_action(&self, _device: &burn_core::prelude::Device) -> MockPolicyAction { + MockPolicyAction(vec![self.0]) + } + } + + impl ItemLazy for MockActionContext { + fn sync(self) -> Self { + self + } + } + + impl MockEnv { + fn new() -> Self { + Self { counter: 0 } + } + } + + impl Environment for MockEnv { + type State = MockState; + type Action = MockAction; + const MAX_STEPS: usize = 5; + + fn reset(&mut self) { + self.counter = 0; + } + + fn step(&mut self, _action: Self::Action) -> StepResult { + self.counter += 1; + let done = self.counter >= Self::MAX_STEPS; + + burn_rl::StepResult { + next_state: MockState, + reward: 1.0, + done, + truncated: false, + } + } + + fn state(&self) -> Self::State { + MockState + } + } + + /// Mock environment init for testing + #[derive(Clone)] + pub(crate) struct MockEnvInit; + + impl EnvironmentInit for MockEnvInit { + fn init(&self) -> MockEnv { + MockEnv::new() + } + } + + // Mock RLComponentsTypes for testing + pub(crate) struct MockRLComponents; + + impl RLComponentsTypes for MockRLComponents { + type Env = MockEnv; + type EnvInit = MockEnvInit; + type State = MockState; + type Action = MockAction; + type Policy = MockPolicy; + type PolicyObs = MockObservation; + type PolicyAD = MockActionDistribution; + type PolicyAction = MockPolicyAction; + type ActionContext = MockActionContext; + type PolicyState = MockPolicyState; + type LearningAgent = MockLearningAgent; + type TrainingOutput = (); + } + + // Mock learning agent for testing + #[derive(Clone)] + pub(crate) struct MockLearningAgent; + + impl PolicyLearner for MockLearningAgent { + type InnerPolicy = MockPolicy; + type TrainContext = (); + type Record = (); + + fn train( + &mut self, + _input: LearnerTransitionBatch, + ) -> RLTrainOutput::PolicyState> { + unimplemented!() + } + + fn policy(&self) -> Self::InnerPolicy { + unimplemented!() + } + + fn update_policy(&mut self, _update: Self::InnerPolicy) { + unimplemented!() + } + + fn record(&self) -> Self::Record { + unimplemented!() + } + + fn load_record(self, _record: Self::Record) -> Self { + unimplemented!() + } + + fn device(&self) -> burn_core::prelude::Device { + unimplemented!() + } + } + + // Mock event processor for testing + pub(crate) struct MockProcessor; + + impl + EventProcessorTraining< + RLEvent<(), MockActionContext>, + AgentEvaluationEvent, + > for MockProcessor + { + fn process_train(&mut self, _event: RLEvent<(), MockActionContext>) { + // Mock process train + } + + fn process_valid(&mut self, _event: AgentEvaluationEvent) { + // Mock process valid + } + + fn renderer(self) -> Box { + unimplemented!() + } + } +} diff --git a/crates/burn-train/src/learner/rl/mod.rs b/crates/burn-train/src/learner/rl/mod.rs new file mode 100644 index 0000000..34874dc --- /dev/null +++ b/crates/burn-train/src/learner/rl/mod.rs @@ -0,0 +1,15 @@ +mod checkpointer; +mod components; +mod env_runner; +mod off_policy; +mod output; +mod paradigm; +mod strategy; + +pub use checkpointer::*; +pub use components::*; +pub use env_runner::*; +pub use off_policy::*; +pub use output::*; +pub use paradigm::*; +pub use strategy::*; diff --git a/crates/burn-train/src/learner/rl/off_policy.rs b/crates/burn-train/src/learner/rl/off_policy.rs new file mode 100644 index 0000000..f480b7e --- /dev/null +++ b/crates/burn-train/src/learner/rl/off_policy.rs @@ -0,0 +1,190 @@ +use crate::{ + AgentEnvAsyncLoop, AgentEnvLoop, AsyncAgentEnvLoopConfig, EvaluationItem, + EventProcessorTraining, MultiAgentEnvLoop, RLComponents, RLComponentsTypes, RLEvent, + RLEventProcessorType, RLStrategy, +}; +use burn_core::{self as burn}; +use burn_core::{config::Config, data::dataloader::Progress}; +use burn_rl::{ + AsyncPolicy, Policy, PolicyLearner, SliceAccess, ToAction, ToObservation, TransitionBuffer, +}; + +/// Parameters of an on policy training with multi environments and double-batching. +#[derive(Config, Debug)] +pub struct OffPolicyConfig { + /// The number of environments to run simultaneously for experience collection. + #[config(default = 1)] + pub num_envs: usize, + /// Number of environment state to accumulate before running one step of inference with the policy. + /// Must be equal or less than the number of simultaneous environments. + #[config(default = 1)] + pub autobatch_size: usize, + /// Max number of transitions stored in the replay buffer. + #[config(default = 1024)] + pub replay_buffer_size: usize, + /// The number of steps to collect between each step of training. + #[config(default = 1)] + pub train_interval: usize, + /// Number of optimization steps done each `train_interval`. + #[config(default = 1)] + pub train_steps: usize, + /// The number of steps to collect between each evaluation. + #[config(default = 10_000)] + pub eval_interval: usize, + /// The number of episodes to run for each evaluation. + #[config(default = 1)] + pub eval_episodes: usize, + /// The number of transition to train on. + #[config(default = 32)] + pub train_batch_size: usize, + /// Number of steps to collect before starting to train. + #[config(default = 0)] + pub warmup_steps: usize, +} + +/// Off-policy reinforcement learning strategy with multi-env experience collection and double-batching. +pub struct OffPolicyStrategy { + config: OffPolicyConfig, +} +impl OffPolicyStrategy { + /// Create a new off-policy base strategy. + pub fn new(config: OffPolicyConfig) -> Self { + Self { config } + } +} + +impl RLStrategy for OffPolicyStrategy +where + RLC: RLComponentsTypes, + RLC::PolicyObs: SliceAccess, + RLC::PolicyAction: SliceAccess, +{ + fn train_loop( + &self, + training_components: RLComponents, + learner_agent: &mut RLC::LearningAgent, + starting_epoch: usize, + env_init: RLC::EnvInit, + ) -> (RLC::Policy, RLEventProcessorType) { + let mut event_processor = training_components.event_processor; + let mut checkpointer = training_components.checkpointer; + let num_steps_total = training_components.num_steps; + + let inference_device = training_components.inference_device; + let mut env_runner = MultiAgentEnvLoop::::new( + self.config.num_envs, + env_init.clone(), + AsyncPolicy::new( + self.config.num_envs.min(self.config.autobatch_size), + learner_agent.policy(), + ), + false, + false, + &inference_device, + ); + let runner_config = AsyncAgentEnvLoopConfig { + eval: true, + deterministic: true, + id: 0, + }; + let mut env_runner_valid = AgentEnvAsyncLoop::::new( + env_init, + AsyncPolicy::new(1, learner_agent.policy()), + runner_config, + &inference_device, + None, + None, + ); + + let mut transition_buffer = TransitionBuffer::::new( + self.config.replay_buffer_size, + &learner_agent.device(), + ); + + let mut valid_next = self.config.eval_interval + starting_epoch - 1; + let mut progress = Progress { + items_processed: starting_epoch, + items_total: num_steps_total, + unit: Some("steps".to_string()), + }; + + let mut intermediary_update: Option<::PolicyState> = None; + while progress.items_processed < num_steps_total { + if training_components.interrupter.should_stop() { + let reason = training_components + .interrupter + .get_message() + .unwrap_or(String::from("Reason unknown")); + log::info!("Training interrupted: {reason}"); + break; + } + + let previous_steps = progress.items_processed; + let items = env_runner.run_steps( + self.config.train_interval, + &mut event_processor, + &training_components.interrupter, + &mut progress, + ); + + for item in &items { + let t = &item.transition; + let state: RLC::PolicyObs = t.state.clone().to_observation(&env_runner.device()); + let next_state: RLC::PolicyObs = + t.next_state.clone().to_observation(&env_runner.device()); + let action: RLC::PolicyAction = t.action.clone().to_action(&env_runner.device()); + let reward = t.reward.to_data().to_vec::().unwrap()[0]; + let done = t.done.to_data().to_vec::().unwrap()[0] > 0.5; + transition_buffer.push(state, next_state, action, reward, done); + } + + if transition_buffer.len() >= self.config.train_batch_size + && progress.items_processed >= self.config.warmup_steps + { + if let Some(ref u) = intermediary_update { + env_runner.update_policy(u.clone()); + } + for _ in 0..self.config.train_steps { + let batch = transition_buffer.sample(self.config.train_batch_size); + let train_item = learner_agent.train(batch); + intermediary_update = Some(learner_agent.policy().state()); + + event_processor.process_train(RLEvent::TrainStep(EvaluationItem::new( + train_item.item, + progress.clone(), + None, + ))); + } + } + + if valid_next > previous_steps && valid_next <= progress.items_processed { + event_processor.process_valid(crate::AgentEvaluationEvent::Start( + self.config.eval_episodes, + )); + + env_runner_valid.update_policy(learner_agent.policy().state()); + env_runner_valid.run_episodes( + self.config.eval_episodes, + &mut event_processor, + &training_components.interrupter, + &mut progress, + ); + + if let Some(checkpointer) = &mut checkpointer { + checkpointer.checkpoint( + &env_runner.policy(), + learner_agent, + valid_next, + &training_components.event_store, + ); + } + + valid_next += self.config.eval_interval; + + event_processor.process_valid(crate::AgentEvaluationEvent::End); + } + } + + (learner_agent.policy(), event_processor) + } +} diff --git a/crates/burn-train/src/learner/rl/output.rs b/crates/burn-train/src/learner/rl/output.rs new file mode 100644 index 0000000..238f6c0 --- /dev/null +++ b/crates/burn-train/src/learner/rl/output.rs @@ -0,0 +1,30 @@ +use crate::{ + ItemLazy, + metric::{Adaptor, CumulativeRewardInput, EpisodeLengthInput}, +}; + +/// Summary of an episode. +pub struct EpisodeSummary { + /// The total length of the episode. + pub episode_length: usize, + /// The final cumulative reward. + pub cum_reward: f64, +} + +impl ItemLazy for EpisodeSummary { + fn sync(self) -> Self { + self + } +} + +impl Adaptor for EpisodeSummary { + fn adapt(&self) -> EpisodeLengthInput { + EpisodeLengthInput::new(self.episode_length as f64) + } +} + +impl Adaptor for EpisodeSummary { + fn adapt(&self) -> CumulativeRewardInput { + CumulativeRewardInput::new(self.cum_reward) + } +} diff --git a/crates/burn-train/src/learner/rl/paradigm.rs b/crates/burn-train/src/learner/rl/paradigm.rs new file mode 100644 index 0000000..4f7384e --- /dev/null +++ b/crates/burn-train/src/learner/rl/paradigm.rs @@ -0,0 +1,542 @@ +use crate::checkpoint::{ + AsyncCheckpointer, Checkpoint, CheckpointingStrategy, ComposedCheckpointingStrategy, + FileCheckpointer, KeepLastNCheckpoints, MetricCheckpointingStrategy, +}; +use crate::learner::base::Interrupter; +use crate::logger::{FileMetricLogger, MetricLogger}; +use crate::metric::store::{Aggregate, Direction, EventStoreClient, LogEventStore, Split}; +use crate::metric::{Adaptor, EpisodeLengthMetric, Metric, Numeric}; +use crate::renderer::{MetricsRenderer, default_renderer}; +use crate::{ + ApplicationLoggerInstaller, AsyncProcessorTraining, FileApplicationLoggerInstaller, ItemLazy, + LearnerSummaryConfig, OffPolicyConfig, OffPolicyStrategy, RLAgentRecord, RLCheckpointer, + RLComponents, RLComponentsMarker, RLComponentsTypes, RLEventProcessor, RLMetrics, + RLPolicyRecord, RLStrategy, +}; +use crate::{EpisodeSummary, RLStrategies}; +use burn_core::tensor::Device; +use burn_rl::{ + Batchable, Environment, EnvironmentInit, Policy, PolicyLearner, PolicyState, SliceAccess, + ToAction, ToObservation, +}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Structure to configure and launch reinforcement learning trainings. +pub struct RLTraining { + // Not that complex. Extracting into yet another type would only make it more confusing. + #[allow(clippy::type_complexity)] + checkpointers: Option<( + AsyncCheckpointer>, + AsyncCheckpointer>, + )>, + num_steps: usize, + checkpoint: Option, + directory: PathBuf, + grad_accumulation: Option, + renderer: Option>, + metrics: RLMetrics, + event_store: LogEventStore, + interrupter: Interrupter, + tracing_logger: Option>, + checkpointer_strategy: Box, + learning_strategy: RLStrategies, + // Use BTreeSet instead of HashSet for consistent (alphabetical) iteration order + summary_metrics: BTreeSet, + summary: bool, + env_initializer: RLC::EnvInit, + inference_device: Device, +} + +impl RLTraining> +where + E: Environment + 'static, + EI: EnvironmentInit + Send + 'static, + A: PolicyLearner + Send + 'static, + ::Record: Checkpoint, + A::TrainContext: ItemLazy + Clone + Send, + A::InnerPolicy: Policy + Send, + ::Observation: Batchable + Clone + Send, + ::ActionDistribution: Batchable + Clone + Send, + ::Action: Batchable + Clone + Send, + ::ActionContext: ItemLazy + Clone + Send + 'static, + ::PolicyState: Clone + Send, + <::PolicyState as PolicyState>::Record: Checkpoint, + E::State: ToObservation<::Observation> + Clone + Send + 'static, + E::Action: From<::Action> + + ToAction<::Action> + + Clone + + Send + + 'static, +{ + /// Creates a new runner for reinforcement learning. + /// + /// # Arguments + /// + /// * `directory` - The directory to save the checkpoints. + /// * `env_init` - Specifies how to initialize the environment. + pub fn new(directory: impl AsRef, env_initializer: EI) -> Self { + let directory = directory.as_ref().to_path_buf(); + let experiment_log_file = directory.join("experiment.log"); + Self { + num_steps: 1, + checkpoint: None, + checkpointers: None, + directory, + grad_accumulation: None, + metrics: RLMetrics::default(), + event_store: LogEventStore::default(), + renderer: None, + interrupter: Interrupter::new(), + tracing_logger: Some(Box::new(FileApplicationLoggerInstaller::new( + experiment_log_file, + ))), + checkpointer_strategy: Box::new( + ComposedCheckpointingStrategy::builder() + .add(KeepLastNCheckpoints::new(2)) + .add(MetricCheckpointingStrategy::new( + &EpisodeLengthMetric::new(), // default to evaluations' cumulative reward. + Aggregate::Mean, + Direction::Lowest, + Split::Valid, + )) + .build(), + ), + learning_strategy: RLStrategies::OffPolicyStrategy(OffPolicyConfig::new()), + summary_metrics: BTreeSet::new(), + summary: false, + env_initializer, + inference_device: Default::default(), + } + } +} + +impl RLTraining { + /// Replace the default learning strategy (Off Policy learning) with the provided one. + /// + /// # Arguments + /// + /// * `training_strategy` - The training strategy. + pub fn with_learning_strategy(mut self, learning_strategy: RLStrategies) -> Self { + self.learning_strategy = learning_strategy; + self + } + + /// Replace the default metric loggers with the provided ones. + /// + /// # Arguments + /// + /// * `logger` - The training logger. + pub fn with_metric_logger(mut self, logger: ML) -> Self + where + ML: MetricLogger + 'static, + { + self.event_store.register_logger(logger); + self + } + + /// Update the checkpointing_strategy. + pub fn with_checkpointing_strategy( + mut self, + strategy: CS, + ) -> Self { + self.checkpointer_strategy = Box::new(strategy); + self + } + + /// Replace the default CLI renderer with a custom one. + /// + /// # Arguments + /// + /// * `renderer` - The custom renderer. + pub fn renderer(mut self, renderer: MR) -> Self + where + MR: MetricsRenderer + 'static, + { + self.renderer = Some(Box::new(renderer)); + self + } + + /// Register numerical metrics for a training step of the agent. + pub fn metrics_train>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register textual metrics for a training step of the agent. + pub fn text_metrics_train>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register numerical metrics for each action of the agent. + pub fn metrics_agent>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register textual metrics for each action of the agent. + pub fn text_metrics_agent>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register numerical metrics for a completed episode. + pub fn metrics_episode>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register textual metrics for a completed episode. + pub fn text_metrics_episode>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register a textual metric for a training step. + pub fn text_metric_train(mut self, metric: Me) -> Self + where + RLC::TrainingOutput: Adaptor, + { + self.metrics.register_text_metric_train(metric); + self + } + + /// Register a [numeric](crate::metric::Numeric) [metric](Metric) for a training step. + pub fn metric_train(mut self, metric: Me) -> Self + where + Me: Metric + Numeric + 'static, + RLC::TrainingOutput: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_metric_train(metric); + self + } + + /// Register a textual metric for each action taken by the agent. + pub fn text_metric_agent(mut self, metric: Me) -> Self + where + RLC::ActionContext: Adaptor, + { + self.metrics.register_text_metric_agent(metric.clone()); + self.metrics.register_text_metric_agent_valid(metric); + self + } + + /// Register a [numeric](crate::metric::Numeric) [metric](Metric) for each action taken by the agent. + pub fn metric_agent(mut self, metric: Me) -> Self + where + Me: Metric + Numeric + 'static, + RLC::ActionContext: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_agent_metric(metric.clone()); + self.metrics.register_agent_metric_valid(metric); + self + } + + /// Register a textual metric for a completed episode. + pub fn text_metric_episode(mut self, metric: Me) -> Self + where + EpisodeSummary: Adaptor + 'static, + { + self.metrics.register_text_metric_episode(metric.clone()); + self.metrics.register_text_metric_episode_valid(metric); + self + } + + /// Register a [numeric](crate::metric::Numeric) [metric](Metric) for a completed episode. + pub fn metric_episode(mut self, metric: Me) -> Self + where + Me: Metric + Numeric + 'static, + EpisodeSummary: Adaptor + 'static, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_episode_metric(metric.clone()); + self.metrics.register_episode_metric_valid(metric); + self + } + + /// The number of environment steps to train for. + pub fn num_steps(mut self, num_steps: usize) -> Self { + self.num_steps = num_steps; + self + } + + /// The step from which the training must resume. + pub fn checkpoint(mut self, checkpoint: usize) -> Self { + self.checkpoint = Some(checkpoint); + self + } + + /// Provides a handle that can be used to interrupt training. + pub fn interrupter(&self) -> Interrupter { + self.interrupter.clone() + } + + /// Override the handle for stopping training with an externally provided handle + pub fn with_interrupter(mut self, interrupter: Interrupter) -> Self { + self.interrupter = interrupter; + self + } + + /// By default, Rust logs are captured and written into + /// `experiment.log`. If disabled, standard Rust log handling + /// will apply. + pub fn with_application_logger( + mut self, + logger: Option>, + ) -> Self { + self.tracing_logger = logger; + self + } + + /// Register a checkpointer that will save the environment runner's [policy](Policy) + /// and the [PolicyLearner](PolicyLearner) state to different files. + pub fn with_checkpointer(mut self) -> Self + where + RLPolicyRecord: Checkpoint, + RLAgentRecord: Checkpoint, + { + let checkpoint_dir = self.directory.join("checkpoint"); + let checkpointer_policy = FileCheckpointer::new(&checkpoint_dir, "policy"); + let checkpointer_learning = FileCheckpointer::new(&checkpoint_dir, "learning-agent"); + + self.checkpointers = Some(( + AsyncCheckpointer::new(checkpointer_policy), + AsyncCheckpointer::new(checkpointer_learning), + )); + + self + } + + /// The device on which to run inference during rollout collection and validation. + pub fn with_inference_device(mut self, device: Device) -> Self { + self.inference_device = device; + self + } + + /// Enable the training summary report. + /// + /// The summary will be displayed after `.launch()`, when the renderer is dropped. + pub fn summary(mut self) -> Self { + self.summary = true; + self + } + + /// Launch the training with the specified [PolicyLearner](PolicyLearner) on the specified environment. + pub fn launch(mut self, learner_agent: RLC::LearningAgent) -> RLResult + where + RLC::PolicyObs: SliceAccess, + RLC::PolicyAction: SliceAccess, + { + if self.tracing_logger.is_some() + && let Err(e) = self.tracing_logger.as_ref().unwrap().install() + { + log::warn!("Failed to install the experiment logger: {e}"); + } + let renderer = self + .renderer + .unwrap_or_else(|| default_renderer(self.interrupter.clone(), self.checkpoint)); + + if !self.event_store.has_loggers() { + self.event_store + .register_logger(FileMetricLogger::new(self.directory.clone())); + } + + let event_store = Arc::new(EventStoreClient::new(self.event_store)); + let event_processor = AsyncProcessorTraining::new(RLEventProcessor::new( + self.metrics, + renderer, + event_store.clone(), + )); + + let checkpointer = self.checkpointers.map(|(policy, learning_agent)| { + RLCheckpointer::new(policy, learning_agent, self.checkpointer_strategy) + }); + + let summary = if self.summary { + Some(LearnerSummaryConfig { + directory: self.directory, + metrics: self.summary_metrics.into_iter().collect::>(), + }) + } else { + None + }; + + let components = RLComponents:: { + checkpoint: self.checkpoint, + checkpointer, + interrupter: self.interrupter, + event_processor, + event_store, + num_steps: self.num_steps, + grad_accumulation: self.grad_accumulation, + summary, + inference_device: self.inference_device, + }; + + let mut learner_agent = learner_agent; + if let Some(checkpoint) = components.checkpoint + && let Some(checkpointer) = &components.checkpointer + { + learner_agent = checkpointer.load_checkpoint(learner_agent, checkpoint); + } + + match self.learning_strategy { + RLStrategies::OffPolicyStrategy(config) => { + let strategy = OffPolicyStrategy::new(config); + strategy.train(learner_agent, components, self.env_initializer) + } + RLStrategies::Custom(strategy) => { + strategy.train(learner_agent, components, self.env_initializer) + } + } + } +} + +/// The result of reinforcement learning, containing the final policy along with the [renderer](MetricsRenderer). +pub struct RLResult

{ + /// The learned policy. + pub policy: P, + /// The renderer that can be used for follow up training and evaluation. + pub renderer: Box, +} + +/// Trait to fake variadic generics for train step metrics. +pub trait AgentMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +/// Trait to fake variadic generics for train step text metrics. +pub trait AgentTextMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +/// Trait to fake variadic generics for env step metrics. +pub trait TrainMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +/// Trait to fake variadic generics for env step text metrics. +pub trait TrainTextMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +/// Trait to fake variadic generics for episode metrics. +pub trait EpisodeMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +/// Trait to fake variadic generics for episode text metrics. +pub trait EpisodeTextMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: RLTraining) -> RLTraining; +} + +macro_rules! gen_tuple { + ($($M:ident),*) => { + impl<$($M,)* RLC: RLComponentsTypes + 'static> TrainTextMetricRegistration for ($($M,)*) + where + $(RLC::TrainingOutput: Adaptor<$M::Input>,)* + $($M: Metric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.text_metric_train($M.clone());)* + builder + } + } + + impl<$($M,)* RLC: RLComponentsTypes + 'static> TrainMetricRegistration for ($($M,)*) + where + $(RLC::TrainingOutput: Adaptor<$M::Input>,)* + $($M: Metric + Numeric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.metric_train($M.clone());)* + builder + } + } + + impl<$($M,)* RLC: RLComponentsTypes + 'static> AgentTextMetricRegistration for ($($M,)*) + where + $(RLC::ActionContext: Adaptor<$M::Input>,)* + $($M: Metric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.text_metric_agent($M.clone());)* + builder + } + } + + impl<$($M,)* RLC: RLComponentsTypes + 'static> AgentMetricRegistration for ($($M,)*) + where + $(RLC::ActionContext: Adaptor<$M::Input>,)* + $($M: Metric + Numeric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.metric_agent($M.clone());)* + builder + } + } + + impl<$($M,)* RLC: RLComponentsTypes + 'static> EpisodeTextMetricRegistration for ($($M,)*) + where + $(EpisodeSummary: Adaptor<$M::Input> + 'static,)* + $($M: Metric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.text_metric_episode($M.clone());)* + builder + } + } + + impl<$($M,)* RLC: RLComponentsTypes + 'static> EpisodeMetricRegistration for ($($M,)*) + where + $(EpisodeSummary: Adaptor<$M::Input> + 'static,)* + $($M: Metric + Numeric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: RLTraining, + ) -> RLTraining { + let ($($M,)*) = self; + $(let builder = builder.metric_episode($M.clone());)* + builder + } + } + }; +} + +gen_tuple!(M1); +gen_tuple!(M1, M2); +gen_tuple!(M1, M2, M3); +gen_tuple!(M1, M2, M3, M4); +gen_tuple!(M1, M2, M3, M4, M5); +gen_tuple!(M1, M2, M3, M4, M5, M6); diff --git a/crates/burn-train/src/learner/rl/strategy.rs b/crates/burn-train/src/learner/rl/strategy.rs new file mode 100644 index 0000000..0f4a6a0 --- /dev/null +++ b/crates/burn-train/src/learner/rl/strategy.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use burn_core::tensor::Device; + +use crate::{ + Interrupter, LearnerSummaryConfig, OffPolicyConfig, RLCheckpointer, RLComponentsTypes, RLEvent, + RLEventProcessorType, RLResult, + metric::{processor::EventProcessorTraining, store::EventStoreClient}, +}; + +/// Struct to minimise parameters passed to [RLStrategy::train]. +pub struct RLComponents { + /// The total number of environment steps. + pub num_steps: usize, + /// The step number from which to continue the training. + pub checkpoint: Option, + /// A checkpointer used to load and save learning checkpoints. + pub checkpointer: Option>, + /// Enables gradients accumulation. + pub grad_accumulation: Option, + /// An [Interupter](Interrupter) that allows aborting the training/evaluation process early. + pub interrupter: Interrupter, + /// An [EventProcessor](crate::EventProcessorTraining) that processes events happening during training and evaluation. + pub event_processor: RLEventProcessorType, + /// A reference to an [EventStoreClient](EventStoreClient). + pub event_store: Arc, + /// Config for creating a summary of the learning + pub summary: Option, + /// Device used for running inference during environmment sampling or validation. + pub inference_device: Device, +} + +/// The strategy for reinforcement learning. +#[derive(Clone)] +pub enum RLStrategies { + /// Training on one device + OffPolicyStrategy(OffPolicyConfig), + /// Training using a custom learning strategy + Custom(CustomRLStrategy), +} + +/// A reference to an implementation of [RLStrategy]. +pub type CustomRLStrategy = Arc>; + +/// Provides the `fit` function for any learning strategy +pub trait RLStrategy { + /// Train the learner agent with this strategy. + fn train( + &self, + mut learner_agent: RLC::LearningAgent, + mut training_components: RLComponents, + env_init: RLC::EnvInit, + ) -> RLResult { + let starting_epoch = training_components.checkpoint.unwrap_or(0) + 1; + let summary_config = training_components.summary.clone(); + + // Event processor start training + training_components + .event_processor + .process_train(RLEvent::Start { + total_items: training_components.num_steps, + }); + + // Training loop + let (policy, mut event_processor) = self.train_loop( + training_components, + &mut learner_agent, + starting_epoch, + env_init, + ); + + let summary = summary_config.and_then(|summary| summary.init().ok()); + + // Signal training end. For the TUI renderer, this handles the exit & return to main screen. + // TODO: summary makes sense for RL? + event_processor.process_train(RLEvent::End(summary)); + + // let model = model.valid(); + let renderer = event_processor.renderer(); + + RLResult { policy, renderer } + } + + /// Training loop for this strategy + fn train_loop( + &self, + training_components: RLComponents, + learner_agent: &mut RLC::LearningAgent, + starting_epoch: usize, + env_init: RLC::EnvInit, + ) -> (RLC::Policy, RLEventProcessorType); +} diff --git a/crates/burn-train/src/learner/sequence.rs b/crates/burn-train/src/learner/sequence.rs new file mode 100644 index 0000000..cb15608 --- /dev/null +++ b/crates/burn-train/src/learner/sequence.rs @@ -0,0 +1,127 @@ +use crate::metric::{AccuracyInput, PerplexityInput, TopKAccuracyInput}; +use crate::metric::{Adaptor, CerInput, LossInput, WerInput, processor::ItemLazy}; +use burn_core::tensor::{Device, Int, Tensor, Transaction}; + +/// Sequence prediction output adapted for multiple metrics. +/// +/// Supported metrics: +/// - Accuracy +/// - TopKAccuracy +/// - Perplexity +/// - Loss +/// - CER +/// - WER +#[derive(new)] +pub struct SequenceOutput { + /// The loss. + pub loss: Tensor<1>, + + /// Raw logits. Shape: `[batch_size, seq_len, vocab_size]` + pub logits: Tensor<3>, + + /// Optional predicted token indices. Shape: `[batch_size, seq_length]`. + /// If not provided, predictions default to argmax of `logits` along the last dimension. + pub predictions: Option>, + + /// The target token indices. Shape: `[batch_size, seq_length]` + pub targets: Tensor<2, Int>, +} + +impl SequenceOutput { + fn predicted_tokens(&self) -> Tensor<2, Int> { + match &self.predictions { + Some(preds) => preds.clone(), + None => self.logits.clone().argmax(2).squeeze_dim::<2>(2), + } + } + + fn flat_logits(&self) -> Tensor<2> { + let [batch_size, seq_len, vocab_size] = self.logits.dims(); + self.logits + .clone() + .reshape([batch_size * seq_len, vocab_size]) + } + + fn flat_targets(&self) -> Tensor<1, Int> { + let [batch_size, seq_len] = self.targets.dims(); + self.targets.clone().reshape([batch_size * seq_len]) + } +} + +impl ItemLazy for SequenceOutput { + fn sync(self) -> Self { + let device: Device = Device::flex(); + + match self.predictions { + Some(preds) => { + let [logits, loss, targets, predictions] = Transaction::default() + .register(self.logits) + .register(self.loss) + .register(self.targets) + .register(preds) + .execute() + .try_into() + .expect("Correct amount of tensor data"); + + SequenceOutput { + logits: Tensor::from_data(logits, &device), + loss: Tensor::from_data(loss, &device), + targets: Tensor::from_data(targets, &device), + predictions: Some(Tensor::from_data(predictions, &device)), + } + } + None => { + let [logits, loss, targets] = Transaction::default() + .register(self.logits) + .register(self.loss) + .register(self.targets) + .execute() + .try_into() + .expect("Correct amount of tensor data"); + + SequenceOutput { + logits: Tensor::from_data(logits, &device), + loss: Tensor::from_data(loss, &device), + targets: Tensor::from_data(targets, &device), + predictions: None, + } + } + } + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> LossInput { + LossInput::new(self.loss.clone()) + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> CerInput { + CerInput::new(self.predicted_tokens(), self.targets.clone()) + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> WerInput { + WerInput::new(self.predicted_tokens(), self.targets.clone()) + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> AccuracyInput { + AccuracyInput::new(self.flat_logits(), self.flat_targets()) + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> TopKAccuracyInput { + TopKAccuracyInput::new(self.flat_logits(), self.flat_targets()) + } +} + +impl Adaptor for SequenceOutput { + fn adapt(&self) -> PerplexityInput { + PerplexityInput::new(self.flat_logits(), self.flat_targets()) + } +} diff --git a/crates/burn-train/src/learner/sharder.rs b/crates/burn-train/src/learner/sharder.rs new file mode 100644 index 0000000..329e525 --- /dev/null +++ b/crates/burn-train/src/learner/sharder.rs @@ -0,0 +1,24 @@ +use burn_core::{ + Tensor, + module::{ModuleMapper, Param}, +}; + +use crate::{Learner, LearnerModel}; + +/// Describes how the module is distributed across multiple devices. +pub struct ModuleSharder; + +impl ModuleMapper for ModuleSharder { + fn map_float(&mut self, param: Param>) -> Param> { + let (id, tensor, mapper) = param.consume(); + let tensor = tensor.set_distributed(id); + Param::from_mapped_value(id, tensor, mapper) + } +} + +impl Learner { + /// Mark the model as sharded across multiple devices. + pub fn grad_sharded(&mut self) { + self.model = self.model.clone().map(&mut ModuleSharder); + } +} diff --git a/crates/burn-train/src/learner/summary.rs b/crates/burn-train/src/learner/summary.rs new file mode 100644 index 0000000..478fbb0 --- /dev/null +++ b/crates/burn-train/src/learner/summary.rs @@ -0,0 +1,475 @@ +use core::cmp::Ordering; +use std::{ + collections::{HashMap, hash_map::Entry}, + fmt::Display, + path::{Path, PathBuf}, +}; + +use crate::{ + logger::FileMetricLogger, + metric::store::{Aggregate, EventStore, LogEventStore, Split}, +}; + +/// Contains the metric value at a given time. +#[derive(Debug)] +pub struct MetricEntry { + /// The step at which the metric was recorded (i.e., epoch). + pub step: usize, + /// The metric value. + pub value: f64, +} + +/// Contains the summary of recorded values for a given metric. +#[derive(Debug)] +pub struct MetricSummary { + /// The metric name. + pub name: String, + /// The metric entries. + pub entries: Vec, +} + +impl MetricSummary { + fn collect( + event_store: &mut E, + metric: &str, + split: &Split, + num_epochs: usize, + ) -> Option { + let entries = (1..=num_epochs) + .filter_map(|epoch| { + event_store + .find_metric(metric, epoch, Aggregate::Mean, split) + .map(|value| MetricEntry { step: epoch, value }) + }) + .collect::>(); + + if entries.is_empty() { + None + } else { + Some(Self { + name: metric.to_string(), + entries, + }) + } + } +} + +/// Contains the summary of recorded metrics for the training and validation steps. +pub struct SummaryMetrics { + /// Training metrics summary. + pub train: Vec, + /// Validation metrics summary. + pub valid: Vec, + /// Test metrics summary per test split tag. + /// + /// Each key corresponds to a `Split::Test(Some(tag))`. + /// The empty string represents `Split::Test(None)`. + pub test: HashMap>, +} + +/// Detailed training summary. +pub struct LearnerSummary { + /// The number of epochs completed. + pub epochs: usize, + /// The summary of recorded metrics during training. + pub metrics: SummaryMetrics, + /// The model name (only recorded within the learner). + pub(crate) model: Option, +} + +impl LearnerSummary { + /// Creates a new learner summary for the specified metrics. + /// + /// # Arguments + /// + /// * `directory` - The directory containing the training artifacts (checkpoints and logs). + /// * `metrics` - The list of metrics to collect for the summary. + pub fn new>(directory: impl AsRef, metrics: &[S]) -> Result { + let directory = directory.as_ref(); + if !directory.exists() { + return Err(format!( + "Artifact directory does not exist at: {}", + directory.display() + )); + } + + let mut event_store = LogEventStore::default(); + let train_split = Split::Train; + let valid_split = Split::Valid; + + let logger = FileMetricLogger::new(directory); + let test_split_root = logger.split_dir(&Split::Test(None)); + if !logger.split_exists(&train_split) + && !logger.split_exists(&valid_split) + && test_split_root.is_none() + { + return Err(format!( + "No training, validation or test artifacts found at: {}", + directory.display() + )); + } + + // Number of recorded epochs + let epochs = logger.epochs(); + + event_store.register_logger(logger); + + let train_summary = metrics + .iter() + .filter_map(|metric| { + MetricSummary::collect(&mut event_store, metric.as_ref(), &train_split, epochs) + }) + .collect::>(); + + let valid_summary = metrics + .iter() + .filter_map(|metric| { + MetricSummary::collect(&mut event_store, metric.as_ref(), &valid_split, epochs) + }) + .collect::>(); + + let test_summary = match test_split_root { + Some(root) => collect_test_split_metrics(root, metrics, &mut event_store, epochs), + None => Default::default(), + }; + + Ok(Self { + epochs, + metrics: SummaryMetrics { + train: train_summary, + valid: valid_summary, + test: test_summary, + }, + model: None, + }) + } + + pub(crate) fn with_model(mut self, name: String) -> Self { + self.model = Some(name); + self + } + + /// Merges another summary into this one, combining all metric entries. + pub(crate) fn merge(mut self, other: LearnerSummary) -> Self { + fn merge_metrics( + base: Vec, + incoming: Vec, + ) -> Vec { + let mut map: HashMap = + base.into_iter().map(|m| (m.name.clone(), m)).collect(); + + for metric in incoming { + match map.entry(metric.name.clone()) { + Entry::Occupied(mut entry) => { + entry.get_mut().entries.extend(metric.entries); + } + Entry::Vacant(entry) => { + entry.insert(metric); + } + } + } + map.into_values().collect() + } + + self.metrics.train = merge_metrics(self.metrics.train, other.metrics.train); + self.metrics.valid = merge_metrics(self.metrics.valid, other.metrics.valid); + + for (tag, metrics) in other.metrics.test { + match self.metrics.test.entry(tag) { + Entry::Occupied(mut entry) => { + let current = std::mem::take(entry.get_mut()); + let merged = merge_metrics(current, metrics); + *entry.get_mut() = merged; + } + Entry::Vacant(entry) => { + entry.insert(metrics); + } + } + } + + if self.model != other.model { + self.model = None; + } + + self + } +} + +fn collect_test_split_metrics, S: AsRef>( + root: P, + metrics: &[S], + event_store: &mut LogEventStore, + epochs: usize, +) -> HashMap> { + // Collect immediate child directories + let dirs = match std::fs::read_dir(root) { + Ok(entries) => entries + .filter_map(|entry| { + let entry = entry.ok()?; + let file_type = entry.file_type().ok()?; + if file_type.is_dir() { + Some(entry.file_name().to_string_lossy().to_string()) + } else { + None + } + }) + .collect::>(), + Err(_) => Vec::new(), + }; + + let mut map = HashMap::new(); + + if dirs.is_empty() { + return map; + } + + // Detect if all directories are epoch directories + let all_epochs = dirs.iter().all(FileMetricLogger::is_epoch_dir); + + if all_epochs { + // Single untagged test split + let split = Split::Test(None); + + let summaries = metrics + .iter() + .filter_map(|metric| { + MetricSummary::collect(event_store, metric.as_ref(), &split, epochs) + }) + .collect::>(); + + // Untagged marked with empty string + map.insert("".to_string(), summaries); + } else { + // Tagged splits + for tag in dirs { + let split = Split::Test(Some(tag.clone().into())); + + let summaries = metrics + .iter() + .filter_map(|metric| { + MetricSummary::collect(event_store, metric.as_ref(), &split, epochs) + }) + .collect::>(); + + map.insert(tag, summaries); + } + } + + map +} + +impl Display for LearnerSummary { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Compute the max length for each column + let mut max_split_len = 5; // "Train" + let mut max_metric_len = "Metric".len(); + for metric in self.metrics.train.iter() { + max_metric_len = max_metric_len.max(metric.name.len()); + } + for metric in self.metrics.valid.iter() { + max_metric_len = max_metric_len.max(metric.name.len()); + } + for (tag, metrics) in self.metrics.test.iter() { + let split_name = if tag.is_empty() { + "Test".to_string() + } else { + format!("Test ({tag})") + }; + + max_split_len = max_split_len.max(split_name.len()); + + for metric in metrics { + max_metric_len = max_metric_len.max(metric.name.len()); + } + } + + // Summary header + writeln!( + f, + "{:=>width_symbol$} Learner Summary {:=>width_symbol$}", + "", + "", + width_symbol = 24, + )?; + + if let Some(model) = &self.model { + writeln!(f, "Model:\n{model}")?; + } + writeln!(f, "Total Epochs: {epochs}\n\n", epochs = self.epochs)?; + + // Metrics table header + writeln!( + f, + "| {:width_split$}--|{:->width_metric$}--|----------|----------|----------|----------|", + "Split", + "Metric", + "", + "", + width_split = max_split_len, + width_metric = max_metric_len, + )?; + + // Table entries + fn cmp_f64(a: &f64, b: &f64) -> Ordering { + match (a.is_nan(), b.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + _ => a.partial_cmp(b).unwrap(), + } + } + + fn fmt_val(val: f64) -> String { + if val < 1e-2 { + // Use scientific notation for small values which would otherwise be truncated + format!("{val:<9.3e}") + } else { + format!("{val:<9.3}") + } + } + + let mut write_metrics_summary = + |metrics: &[MetricSummary], split: String| -> std::fmt::Result { + for metric in metrics.iter() { + if metric.entries.is_empty() { + continue; // skip metrics with no recorded values + } + + // Compute the min & max for each metric + let metric_min = metric + .entries + .iter() + .min_by(|a, b| cmp_f64(&a.value, &b.value)) + .unwrap(); + let metric_max = metric + .entries + .iter() + .max_by(|a, b| cmp_f64(&a.value, &b.value)) + .unwrap(); + + writeln!( + f, + "| {:, +} + +impl LearnerSummaryConfig { + /// Create the learning summary. + pub fn init(&self) -> Result { + LearnerSummary::new(&self.directory, &self.metrics[..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic = "Summary artifacts should exist"] + fn test_artifact_dir_should_exist() { + let dir = "/tmp/learner-summary-not-found"; + let _summary = LearnerSummary::new(dir, &["Loss"]).expect("Summary artifacts should exist"); + } + + #[test] + #[should_panic = "Summary artifacts should exist"] + fn test_train_valid_artifacts_should_exist() { + let dir = "/tmp/test-learner-summary-empty"; + std::fs::create_dir_all(dir).ok(); + let _summary = LearnerSummary::new(dir, &["Loss"]).expect("Summary artifacts should exist"); + } + + #[test] + fn test_summary_should_be_empty() { + let dir = Path::new("/tmp/test-learner-summary-empty-metrics"); + std::fs::create_dir_all(dir).unwrap(); + std::fs::create_dir_all(dir.join("train/epoch-1")).unwrap(); + std::fs::create_dir_all(dir.join("valid/epoch-1")).unwrap(); + let summary = LearnerSummary::new(dir.to_str().unwrap(), &["Loss"]) + .expect("Summary artifacts should exist"); + + assert_eq!(summary.epochs, 1); + + assert_eq!(summary.metrics.train.len(), 0); + assert_eq!(summary.metrics.valid.len(), 0); + + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn test_summary_should_be_collected() { + let dir = Path::new("/tmp/test-learner-summary"); + let train_dir = dir.join("train/epoch-1"); + let valid_dir = dir.join("valid/epoch-1"); + std::fs::create_dir_all(dir).unwrap(); + std::fs::create_dir_all(&train_dir).unwrap(); + std::fs::create_dir_all(&valid_dir).unwrap(); + + std::fs::write(train_dir.join("Loss.log"), "1.0\n2.0").expect("Unable to write file"); + std::fs::write(valid_dir.join("Loss.log"), "1.0").expect("Unable to write file"); + + let summary = LearnerSummary::new(dir.to_str().unwrap(), &["Loss"]) + .expect("Summary artifacts should exist"); + + assert_eq!(summary.epochs, 1); + + // Only Loss metric + assert_eq!(summary.metrics.train.len(), 1); + assert_eq!(summary.metrics.valid.len(), 1); + + // Aggregated train metric entries for 1 epoch + let train_metric = &summary.metrics.train[0]; + assert_eq!(train_metric.name, "Loss"); + assert_eq!(train_metric.entries.len(), 1); + let entry = &train_metric.entries[0]; + assert_eq!(entry.step, 1); // epoch = 1 + assert_eq!(entry.value, 1.5); // (1 + 2) / 2 + + // Aggregated valid metric entries for 1 epoch + let valid_metric = &summary.metrics.valid[0]; + assert_eq!(valid_metric.name, "Loss"); + assert_eq!(valid_metric.entries.len(), 1); + let entry = &valid_metric.entries[0]; + assert_eq!(entry.step, 1); // epoch = 1 + assert_eq!(entry.value, 1.0); + + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/burn-train/src/learner/supervised/mod.rs b/crates/burn-train/src/learner/supervised/mod.rs new file mode 100644 index 0000000..56b2f0f --- /dev/null +++ b/crates/burn-train/src/learner/supervised/mod.rs @@ -0,0 +1,7 @@ +mod paradigm; +mod step; +mod strategies; + +pub use paradigm::*; +pub use step::*; +pub use strategies::*; diff --git a/crates/burn-train/src/learner/supervised/paradigm.rs b/crates/burn-train/src/learner/supervised/paradigm.rs new file mode 100644 index 0000000..2ad5124 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/paradigm.rs @@ -0,0 +1,560 @@ +use crate::checkpoint::{ + AsyncCheckpointer, Checkpointer, CheckpointingStrategy, ComposedCheckpointingStrategy, + FileCheckpointer, KeepLastNCheckpoints, MetricCheckpointingStrategy, +}; +use crate::learner::EarlyStoppingStrategy; +use crate::learner::base::Interrupter; +use crate::logger::{FileMetricLogger, MetricLogger, TrainingProgressLogger}; +use crate::metric::processor::{ + AsyncProcessorTraining, FullEventProcessorTraining, MetricsTraining, +}; +use crate::metric::store::{Aggregate, Direction, EventStoreClient, LogEventStore, Split}; +use crate::metric::{Adaptor, LossMetric, Metric, Numeric}; +use crate::multi::MultiDeviceLearningStrategy; +use crate::renderer::{MetricsRenderer, default_renderer}; +use crate::single::SingleDeviceTrainingStrategy; +use crate::{ + ApplicationLoggerInstaller, EarlyStoppingStrategyRef, ExecutionStrategy, + FileApplicationLoggerInstaller, InferenceModelInput, InferenceModelOutput, InferenceStep, + LearnerEvent, LearnerModel, LearnerSummaryConfig, LearningCheckpointer, LearningResult, + TrainStep, TrainingComponents, TrainingModelInput, TrainingModelOutput, TrainingStrategy, +}; +use crate::{Learner, SupervisedLearningStrategy}; +use burn_core::data::dataloader::DataLoader; +use burn_core::store::ModuleRecord; +use burn_core::tensor::Device; +use burn_optim::OptimizerRecord; +use burn_optim::lr_scheduler::LrSchedulerRecord; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// A reference to the training split [DataLoader](DataLoader). +pub type TrainLoader = Arc>>; +/// A reference to the validation split [DataLoader](DataLoader). +pub type ValidLoader = Arc>>; +/// The event processor type for supervised learning. +pub type SupervisedTrainingEventProcessor = AsyncProcessorTraining< + LearnerEvent>, + LearnerEvent>, +>; + +/// Structure to configure and launch supervised learning trainings. +pub struct SupervisedTraining { + // Not that complex. Extracting into another type would only make it more confusing. + #[allow(clippy::type_complexity)] + checkpointers: Option<( + AsyncCheckpointer, + AsyncCheckpointer, + AsyncCheckpointer, + )>, + num_epochs: usize, + checkpoint: Option, + directory: PathBuf, + grad_accumulation: Option, + grad_checkpointing: bool, + renderer: Option>, + metrics: MetricsTraining, InferenceModelOutput>, + event_store: LogEventStore, + interrupter: Interrupter, + tracing_logger: Option>, + checkpointer_strategy: Box, + early_stopping: Option, + training_strategy: Option>, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + // Use BTreeSet instead of HashSet for consistent (alphabetical) iteration order + summary_metrics: BTreeSet, + summary: bool, + progress_logger: Option>, +} + +impl SupervisedTraining { + /// Creates a new runner for a supervised training. + /// + /// # Arguments + /// + /// * `directory` - The directory to save the checkpoints. + /// * `dataloader_train` - The dataloader for the training split. + /// * `dataloader_valid` - The dataloader for the validation split. + pub fn new( + directory: impl AsRef, + dataloader_train: Arc::Input>>, + dataloader_valid: Arc::Input>>, + ) -> Self { + let directory = directory.as_ref().to_path_buf(); + let experiment_log_file = directory.join("experiment.log"); + Self { + num_epochs: 1, + checkpoint: None, + checkpointers: None, + directory, + grad_accumulation: None, + grad_checkpointing: false, + metrics: MetricsTraining::default(), + event_store: LogEventStore::default(), + renderer: None, + interrupter: Interrupter::new(), + tracing_logger: Some(Box::new(FileApplicationLoggerInstaller::new( + experiment_log_file, + ))), + checkpointer_strategy: Box::new( + ComposedCheckpointingStrategy::builder() + .add(KeepLastNCheckpoints::new(2)) + .add(MetricCheckpointingStrategy::new( + &LossMetric::new(), // default to valid loss + Aggregate::Mean, + Direction::Lowest, + Split::Valid, + )) + .build(), + ), + early_stopping: None, + training_strategy: None, + summary_metrics: BTreeSet::new(), + summary: false, + dataloader_train, + dataloader_valid, + progress_logger: None, + } + } +} + +impl SupervisedTraining { + /// Replace the default training strategy (SingleDeviceTrainingStrategy) with the provided one. + /// + /// # Arguments + /// + /// * `training_strategy` - The training strategy. + pub fn with_training_strategy(mut self, training_strategy: TrainingStrategy) -> Self { + self.training_strategy = Some(training_strategy); + self + } + + /// Replace the default metric loggers with the provided ones. + /// + /// # Arguments + /// + /// * `logger` - The training logger. + pub fn with_metric_logger(mut self, logger: ML) -> Self + where + ML: MetricLogger + 'static, + { + self.event_store.register_logger(logger); + self + } + + /// Register a progress logger to track and store training progress. + /// + /// # Example + /// + /// ```ignore + /// // `MyTrainingProgressLogger` is a user-defined type that implements + /// // `burn_train::logger::TrainingProgressLogger`. + /// let learner = SupervisedTraining::new(...) + /// .with_progress_logger(MyTrainingProgressLogger); + /// ``` + pub fn with_progress_logger(mut self, logger: PL) -> Self + where + PL: TrainingProgressLogger + 'static, + { + self.progress_logger = Some(Box::new(logger)); + self + } + + /// Update the checkpointing_strategy. + pub fn with_checkpointing_strategy( + mut self, + strategy: CS, + ) -> Self { + self.checkpointer_strategy = Box::new(strategy); + self + } + + /// Replace the default CLI renderer with a custom one. + /// + /// # Arguments + /// + /// * `renderer` - The custom renderer. + pub fn renderer(mut self, renderer: MR) -> Self + where + MR: MetricsRenderer + 'static, + { + self.renderer = Some(Box::new(renderer)); + self + } + + /// Register all metrics as numeric for the training and validation set. + pub fn metrics>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register all metrics as text for the training and validation set. + pub fn metrics_text>(self, metrics: Me) -> Self { + metrics.register(self) + } + + /// Register a training metric. + pub fn metric_train(mut self, metric: Me) -> Self + where + TrainingModelOutput: Adaptor, + { + self.metrics.register_train_metric(metric); + self + } + + /// Register a validation metric. + pub fn metric_valid(mut self, metric: Me) -> Self + where + InferenceModelOutput: Adaptor, + { + self.metrics.register_valid_metric(metric); + self + } + + /// Enable gradients accumulation. + /// + /// # Notes + /// + /// When you enable gradients accumulation, the gradients object used by the optimizer will be + /// the sum of all gradients generated by each backward pass. It might be a good idea to + /// reduce the learning to compensate. + /// + /// The effect is similar to increasing the `batch size` and the `learning rate` by the `accumulation` + /// amount. + pub fn grads_accumulation(mut self, accumulation: usize) -> Self { + self.grad_accumulation = Some(accumulation); + self + } + + /// Enables autodiff checkpointing. + /// + /// # Notes + /// Gradient checkpointing recomputes activations during backpropagation for operations + /// marked as memory-bound, while compute-bound operations still cache their + /// output. This reduces peak memory usage at the cost of additional computation + /// for memory-bound ops. + pub fn gradient_checkpointing(mut self) -> Self { + self.grad_checkpointing = true; + self + } + + /// Register a [numeric](crate::metric::Numeric) training [metric](Metric). + pub fn metric_train_numeric(mut self, metric: Me) -> Self + where + Me: Metric + Numeric + 'static, + TrainingModelOutput: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_train_metric_numeric(metric); + self + } + + /// Register a [numeric](crate::metric::Numeric) validation [metric](Metric). + pub fn metric_valid_numeric(mut self, metric: Me) -> Self + where + InferenceModelOutput: Adaptor, + { + self.summary_metrics.insert(metric.name().to_string()); + self.metrics.register_valid_metric_numeric(metric); + self + } + + /// The number of epochs the training should last. + pub fn num_epochs(mut self, num_epochs: usize) -> Self { + self.num_epochs = num_epochs; + self + } + + /// The epoch from which the training must resume. + pub fn checkpoint(mut self, checkpoint: usize) -> Self { + self.checkpoint = Some(checkpoint); + self + } + + /// Provides a handle that can be used to interrupt training. + pub fn interrupter(&self) -> Interrupter { + self.interrupter.clone() + } + + /// Override the handle for stopping training with an externally provided handle + pub fn with_interrupter(mut self, interrupter: Interrupter) -> Self { + self.interrupter = interrupter; + self + } + + /// Register an [early stopping strategy](EarlyStoppingStrategy) to stop the training when the + /// conditions are meet. + pub fn early_stopping(mut self, strategy: Strategy) -> Self + where + Strategy: EarlyStoppingStrategy + Clone + Send + Sync + 'static, + { + self.early_stopping = Some(Box::new(strategy)); + self + } + + /// By default, Rust logs are captured and written into + /// `experiment.log`. If disabled, standard Rust log handling + /// will apply. + pub fn with_application_logger( + mut self, + logger: Option>, + ) -> Self { + self.tracing_logger = logger; + self + } + + /// Register a checkpointer that will save the [optimizer](burn_optim::ModuleOptimizer), the + /// [model](LearnerModel) and the [learning rate scheduler](burn_optim::lr_scheduler::module_lr_scheduler::ModuleLrScheduler) to separate burnpack files. + pub fn with_default_checkpointers(mut self) -> Self { + let checkpoint_dir = self.directory.join("checkpoint"); + let checkpointer_model = FileCheckpointer::new(&checkpoint_dir, "model"); + let checkpointer_optimizer = FileCheckpointer::new(&checkpoint_dir, "optim"); + let checkpointer_scheduler = FileCheckpointer::new(&checkpoint_dir, "scheduler"); + + self.checkpointers = Some(( + AsyncCheckpointer::new(checkpointer_model), + AsyncCheckpointer::new(checkpointer_optimizer), + AsyncCheckpointer::new(checkpointer_scheduler), + )); + + self + } + + /// Register your own checkpointers that will save the [optimizer](burn_optim::ModuleOptimizer), the + /// [model](LearnerModel) and the [learning rate scheduler](burn_optim::lr_scheduler::module_lr_scheduler::ModuleLrScheduler) to separate burnpack files. + pub fn with_custom_checkpointers( + mut self, + module_checkpointer: CM, + optimizer_checkpointer: CO, + lr_checkpointer: CL, + ) -> Self + where + CM: Checkpointer + 'static, + CO: Checkpointer + 'static, + CL: Checkpointer + 'static, + { + self.checkpointers = Some(( + AsyncCheckpointer::new(module_checkpointer), + AsyncCheckpointer::new(optimizer_checkpointer), + AsyncCheckpointer::new(lr_checkpointer), + )); + + self + } + + /// Enable the training summary report. + /// + /// The summary will be displayed after `.fit()`, when the renderer is dropped. + pub fn summary(mut self) -> Self { + self.summary = true; + self + } +} + +impl SupervisedTraining { + /// Launch this training with the given [Learner](Learner). + pub fn launch(mut self, learner: Learner) -> LearningResult { + if self.tracing_logger.is_some() + && let Err(e) = self.tracing_logger.as_ref().unwrap().install() + { + log::warn!("Failed to install the experiment logger: {e}"); + } + let renderer = self + .renderer + .unwrap_or_else(|| default_renderer(self.interrupter.clone(), self.checkpoint)); + + if !self.event_store.has_loggers() { + self.event_store + .register_logger(FileMetricLogger::new(self.directory.clone())); + } + + let event_store = Arc::new(EventStoreClient::new(self.event_store)); + let full_processor = + FullEventProcessorTraining::new(self.metrics, renderer, event_store.clone()); + let full_processor = match self.progress_logger { + Some(logger) => full_processor.with_progress_logger(logger), + None => full_processor, + }; + let event_processor = AsyncProcessorTraining::new(full_processor); + + let checkpointer = self.checkpointers.map(|(model, optim, scheduler)| { + LearningCheckpointer::new( + model.with_interrupter(self.interrupter.clone()), + optim.with_interrupter(self.interrupter.clone()), + scheduler.with_interrupter(self.interrupter.clone()), + self.checkpointer_strategy, + ) + }); + + let summary = if self.summary { + Some(LearnerSummaryConfig { + directory: self.directory, + metrics: self.summary_metrics.into_iter().collect::>(), + }) + } else { + None + }; + + let components = TrainingComponents { + checkpoint: self.checkpoint, + checkpointer, + interrupter: self.interrupter, + early_stopping: self.early_stopping, + event_processor, + event_store, + num_epochs: self.num_epochs, + grad_accumulation: self.grad_accumulation, + summary, + }; + + // Default to single device based on model + let training_strategy = self.training_strategy.unwrap_or(TrainingStrategy::Default( + ExecutionStrategy::SingleDevice(autodiff_device( + learner.model.devices()[0].clone(), + self.grad_checkpointing, + )), + )); + + let mut learner = learner; + if let Some(checkpoint) = components.checkpoint + && let Some(checkpointer) = &components.checkpointer + { + learner = checkpointer.load_checkpoint(learner, checkpoint); + } + + match training_strategy { + TrainingStrategy::Custom(learning_paradigm) => learning_paradigm.train( + learner, + self.dataloader_train, + self.dataloader_valid, + components, + ), + TrainingStrategy::Default(strategy) => match strategy { + ExecutionStrategy::SingleDevice(device) => { + let single_device = SingleDeviceTrainingStrategy::new(autodiff_device( + device, + self.grad_checkpointing, + )); + single_device.train( + learner, + self.dataloader_train, + self.dataloader_valid, + components, + ) + } + ExecutionStrategy::MultiDevice(devices, multi_device_optim) => { + let strategy: Box> = match devices.len() == 1 + { + true => Box::new(SingleDeviceTrainingStrategy::new(autodiff_device( + devices[0].clone(), + self.grad_checkpointing, + ))), + false => Box::new(MultiDeviceLearningStrategy::new( + devices + .into_iter() + .map(|d| autodiff_device(d, self.grad_checkpointing)) + .collect(), + multi_device_optim, + )), + }; + strategy.train( + learner, + self.dataloader_train, + self.dataloader_valid, + components, + ) + } + ExecutionStrategy::DistributedDataParallel { devices, context } => { + use crate::ddp::DdpTrainingStrategy; + + let ddp = DdpTrainingStrategy::new( + devices + .into_iter() + .map(|d| autodiff_device(d, self.grad_checkpointing)) + .collect(), + context, + ); + ddp.train( + learner, + self.dataloader_train, + self.dataloader_valid, + components, + ) + } + }, + } + } +} + +// Validate the autodiff device property and enable grad checkpointing. +fn autodiff_device(mut device: Device, grad_checkpointing: bool) -> Device { + if !device.is_autodiff() { + device = device.autodiff(); + } + + if grad_checkpointing { + device = device.gradient_checkpointing(); + } + + device +} + +/// Trait to fake variadic generics. +pub trait MetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: SupervisedTraining) -> SupervisedTraining; +} + +/// Trait to fake variadic generics. +pub trait TextMetricRegistration: Sized { + /// Register the metrics. + fn register(self, builder: SupervisedTraining) -> SupervisedTraining; +} + +macro_rules! gen_tuple { + ($($M:ident),*) => { + impl<$($M,)* M: LearnerModel> TextMetricRegistration for ($($M,)*) + where + $(TrainingModelOutput: Adaptor<$M::Input>,)* + $(InferenceModelOutput: Adaptor<$M::Input>,)* + $($M: Metric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: SupervisedTraining, + ) -> SupervisedTraining { + let ($($M,)*) = self; + $(let builder = builder.metric_train($M.clone());)* + $(let builder = builder.metric_valid($M);)* + builder + } + } + + impl<$($M,)* M: LearnerModel> MetricRegistration for ($($M,)*) + where + $(TrainingModelOutput: Adaptor<$M::Input>,)* + $(InferenceModelOutput: Adaptor<$M::Input>,)* + $($M: Metric + Numeric + 'static,)* + { + #[allow(non_snake_case)] + fn register( + self, + builder: SupervisedTraining, + ) -> SupervisedTraining { + let ($($M,)*) = self; + $(let builder = builder.metric_train_numeric($M.clone());)* + $(let builder = builder.metric_valid_numeric($M);)* + builder + } + } + }; +} + +gen_tuple!(M1); +gen_tuple!(M1, M2); +gen_tuple!(M1, M2, M3); +gen_tuple!(M1, M2, M3, M4); +gen_tuple!(M1, M2, M3, M4, M5); +gen_tuple!(M1, M2, M3, M4, M5, M6); diff --git a/crates/burn-train/src/learner/supervised/step/mod.rs b/crates/burn-train/src/learner/supervised/step/mod.rs new file mode 100644 index 0000000..066b064 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/step/mod.rs @@ -0,0 +1,2 @@ +/// The trainer module. +pub mod train; diff --git a/crates/burn-train/src/learner/supervised/step/train.rs b/crates/burn-train/src/learner/supervised/step/train.rs new file mode 100644 index 0000000..72e5e2e --- /dev/null +++ b/crates/burn-train/src/learner/supervised/step/train.rs @@ -0,0 +1,150 @@ +use crate::LearnerModel; +use crate::{TrainOutput, TrainStep, TrainingModelInput, TrainingModelOutput}; +use burn_core::data::dataloader::DataLoaderIterator; +use burn_core::data::dataloader::Progress; +use burn_core::tensor::Device; +use std::sync::mpsc::{Receiver, Sender}; +use std::thread::spawn; + +/// Multi devices train step. +pub struct MultiDevicesTrainStep { + workers: Vec>, + receiver: Receiver>>, +} + +struct Message { + item: TI, + model: M, +} + +struct Worker { + // Not that complex. Extracting into another type would only make it more confusing. + // #[allow(clippy::type_complexity)] + sender_input: Sender>>, + device: Device, + device_id: usize, +} + +impl Worker { + fn register(&self, item: TrainingModelInput, model: &M) { + let message = Message { + item, + model: model.clone(), + }; + self.sender_input.send(message).unwrap(); + } + + // Not that complex. Extracting into another type would only make it more confusing. + // #[allow(clippy::type_complexity)] + fn start( + &self, + sender_output: Sender>>, + receiver_input: Receiver>>, + ) { + let device = self.device.clone(); + let device_id = self.device_id; + + spawn(move || { + loop { + match receiver_input.recv() { + Ok(item) => { + let model = item.model.fork(&device); + let output = TrainStep::step(&model, item.item); + let item = MultiTrainOutput { output, device_id }; + + sender_output.send(item).unwrap(); + } + Err(_err) => { + log::info!("Closing thread on device {device:?}"); + break; + } + } + } + }); + } +} + +/// Multiple output items. +pub struct MultiTrainOutput { + /// The training output. + pub output: TrainOutput, + /// The worker/device on which the computing happened. + pub(crate) device_id: usize, +} + +impl MultiDevicesTrainStep { + /// Create a new multi devices train step. + /// + /// # Arguments + /// + /// * `devices` - Devices. + /// + /// # Returns + /// + /// MultiDevicesTrainStep instance. + pub fn new(devices: &[Device]) -> Self { + let (sender_output, receiver_output) = std::sync::mpsc::channel(); + let workers = devices + .iter() + .enumerate() + .map(|(device_id, device)| { + let (sender_input, receiver_input) = std::sync::mpsc::channel(); + let worker = Worker { + sender_input, + device: device.clone(), + device_id, + }; + + worker.start(sender_output.clone(), receiver_input); + worker + }) + .collect(); + + Self { + workers, + receiver: receiver_output, + } + } + + /// Collect outputs from workers for one step. + /// + /// # Arguments + /// + /// * `model` - Model. + /// * `dataloaders` - The data loader for each worker. + /// + /// # Returns + /// + /// Outputs. + pub fn step<'a>( + &self, + dataloaders: &mut [Box> + 'a>], + model: &M, + ) -> (Vec>>, Progress) { + let mut num_send = 0; + + let mut items_total = 0; + let mut items_processed = 0; + let unit: Option = Some("items".to_string()); + + for (i, worker) in self.workers.iter().enumerate() { + let dataloader = &mut dataloaders[i]; + if let Some(item) = dataloader.next() { + worker.register(item, model); + num_send += 1; + let progress = dataloader.progress(); + items_total += progress.items_total; + items_processed += progress.items_processed; + } + } + + let mut outputs = Vec::with_capacity(num_send); + + for _ in 0..num_send { + let output = self.receiver.recv().unwrap(); + outputs.push(output); + } + + (outputs, Progress::new(items_processed, items_total, unit)) + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/base.rs b/crates/burn-train/src/learner/supervised/strategies/base.rs new file mode 100644 index 0000000..7e09987 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/base.rs @@ -0,0 +1,172 @@ +use crate::{ + EarlyStoppingStrategyRef, Interrupter, Learner, LearnerModel, LearnerSummaryConfig, + LearningCheckpointer, LearningResult, SupervisedTrainingEventProcessor, TrainLoader, + ValidLoader, + metric::{ + processor::{EventProcessorTraining, LearnerEvent}, + store::EventStoreClient, + }, +}; +use burn_core::prelude::Device; +use burn_core::tensor::distributed::{DistributedConfig, DistributedContext}; +use std::sync::Arc; + +/// A reference to an implementation of SupervisedLearningStrategy. +pub type CustomLearningStrategy = Arc>; + +#[derive(Clone, Copy, Debug)] +/// Determine how the optimization is performed when training with multiple devices. +pub enum MultiDeviceOptim { + /// The optimization is done on an elected device. + OptimMainDevice, + /// The optimization is sharded across all devices. + OptimSharded, +} + +/// Describes where training runs. +pub enum ExecutionStrategy { + /// Training on one device + SingleDevice(Device), + /// Performs data-parallel distributed training where the optimization is + /// done on an elected master device. + MultiDevice(Vec, MultiDeviceOptim), + /// Training with input distributed across devices, each device has its own copy of the model. + /// Collective ops are used to sync the gradients after each pass. + DistributedDataParallel { + /// Devices on this node for the DDP + devices: Vec, + /// The distributed runtime. + context: DistributedContext, + }, +} + +impl ExecutionStrategy { + /// Returns the primary device responsible for coordination. + pub fn main_device(&self) -> &Device { + match self { + ExecutionStrategy::SingleDevice(device) => device, + ExecutionStrategy::MultiDevice(devices, _optim) => &devices[0], + ExecutionStrategy::DistributedDataParallel { + devices, + context: _, + } => &devices[0], + } + } + + /// Creates a strategy for a single device. + pub fn single(device: Device) -> Self { + Self::SingleDevice(device) + } + + /// Creates a multi-device strategy. + pub fn multi(devices: Vec, optim: MultiDeviceOptim) -> Self { + Self::MultiDevice(devices, optim) + } +} + +impl ExecutionStrategy { + /// Creates a distributed data parallel (DDP) strategy. + pub fn ddp(devices: Vec, config: DistributedConfig) -> Self { + let context = DistributedContext::init(devices.clone(), config); + Self::DistributedDataParallel { devices, context } + } +} + +/// How should the learner run the learning for the model +pub enum TrainingStrategy { + /// Default training loop with specified device strategy. + Default(ExecutionStrategy), + /// Training using a custom learning strategy + Custom(CustomLearningStrategy), +} + +impl From for TrainingStrategy { + fn from(value: ExecutionStrategy) -> Self { + Self::Default(value) + } +} + +impl Default for TrainingStrategy { + fn default() -> Self { + Self::Default(ExecutionStrategy::SingleDevice(Default::default())) + } +} + +/// Struct to minimise parameters passed to [SupervisedLearningStrategy::train]. +/// These components are used during training. +pub struct TrainingComponents { + /// The total number of epochs + pub num_epochs: usize, + /// The epoch number from which to continue the training. + pub checkpoint: Option, + /// A checkpointer used to load and save learner checkpoints. + pub checkpointer: Option>, + /// Enables gradients accumulation. + pub grad_accumulation: Option, + /// An [Interupter](Interrupter) that allows aborting the training/evaluation process early. + pub interrupter: Interrupter, + /// Cloneable reference to an early stopping strategy. + pub early_stopping: Option, + /// An [EventProcessor](crate::EventProcessorTraining) that processes events happening during training and validation. + pub event_processor: SupervisedTrainingEventProcessor, + /// A reference to an [EventStoreClient](EventStoreClient). + pub event_store: Arc, + /// Config for creating a summary of the learning + pub summary: Option, +} + +/// Provides the `fit` function for any learning strategy +pub trait SupervisedLearningStrategy { + /// Train the learner's model with this strategy. + fn train( + &self, + learner: Learner, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + mut training_components: TrainingComponents, + ) -> LearningResult { + let starting_epoch = training_components.checkpoint.unwrap_or(0) + 1; + let summary_config = training_components.summary.clone(); + + // Event processor start training + training_components + .event_processor + .process_train(LearnerEvent::Start { + total_epochs: training_components.num_epochs, + starting_epoch, + }); + // Training loop + let (model, mut event_processor) = self.fit( + training_components, + learner, + dataloader_train, + dataloader_valid, + starting_epoch, + ); + + let summary = summary_config.and_then(|summary| { + summary + .init() + .map(|summary| summary.with_model(model.to_string())) + .ok() + }); + + // Signal training end. For the TUI renderer, this handles the exit & return to main screen. + event_processor.process_train(LearnerEvent::End(summary)); + + let model = model.valid(); + let renderer = event_processor.renderer(); + + LearningResult:: { model, renderer } + } + + /// Training loop for this strategy + fn fit( + &self, + training_components: TrainingComponents, + learner: Learner, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + starting_epoch: usize, + ) -> (M, SupervisedTrainingEventProcessor); +} diff --git a/crates/burn-train/src/learner/supervised/strategies/ddp/README.md b/crates/burn-train/src/learner/supervised/strategies/ddp/README.md new file mode 100644 index 0000000..b38c9c7 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/ddp/README.md @@ -0,0 +1,17 @@ +## DDP +Distributed Data Parallel + +The DDP is a learning strategy that trains a replica of the model on each device. + +The DDP launches threads for each local device. Each thread on each node will run the model. +After the forward and backward passes, the gradients are synced between all peers on all nodes +with an `all-reduce` operation. + +While the DDP launches threads for each local device, it is the user's responsibility to launch the +DDP on each node, and assure the collective configuration matches. + +## Main device vs secondary devices + +The main device is responsible for validation, as well as event processing, which is used in the UI. + +The first device is chosen as the main device. diff --git a/crates/burn-train/src/learner/supervised/strategies/ddp/epoch.rs b/crates/burn-train/src/learner/supervised/strategies/ddp/epoch.rs new file mode 100644 index 0000000..f2120a9 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/ddp/epoch.rs @@ -0,0 +1,138 @@ +use burn_core::data::dataloader::Progress; +use burn_optim::GradientsAccumulator; +use std::sync::{Arc, Mutex}; + +use crate::SupervisedTrainingEventProcessor; +use crate::learner::base::Interrupter; +use crate::metric::processor::{EventProcessorTraining, LearnerEvent, TrainingItem}; +use crate::{InferenceStep, Learner, LearnerModel, TrainLoader, ValidLoader}; + +/// A validation epoch. +#[derive(new)] +pub struct DdpValidEpoch { + dataloader: ValidLoader, +} + +/// A training epoch. +#[derive(new)] +pub struct DdpTrainEpoch { + dataloader: TrainLoader, + grad_accumulation: Option, +} + +impl DdpValidEpoch { + /// Runs the validation epoch. + /// + /// # Arguments + /// + /// * `model` - The model to validate. + /// * `processor` - The event processor to use. + pub fn run( + &self, + model: &M, + global_progress: &Progress, + processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + ) { + let epoch = global_progress.items_processed; + log::info!("Executing validation step for epoch {}", epoch); + let model = model.valid(); + + let mut iterator = self.dataloader.iter(); + let mut iteration = 0; + + while let Some(item) = iterator.next() { + let progress = iterator.progress(); + iteration += 1; + + let item = InferenceStep::step(&model, item); + let item = TrainingItem::new(item, progress, Some(iteration), None); + + processor.process_valid(LearnerEvent::ProcessedItem(item)); + + if interrupter.should_stop() { + log::info!("Training interrupted."); + break; + } + } + } +} + +impl DdpTrainEpoch { + /// Runs the training epoch. + /// + /// # Arguments + /// + /// * `model` - The model to train. + /// * `optim` - The optimizer to use. + /// * `scheduler` - The learning rate scheduler to use. + /// * `processor` - The event processor to use. + /// + /// # Returns + /// + /// The trained model and the optimizer. + pub fn run( + &self, + learner: &mut Learner, + global_progress: &Progress, + processor: Arc>>, + interrupter: &Interrupter, + peer_count: usize, + ) { + let epoch = global_progress.items_processed; + log::info!("Executing training step for epoch {}", epoch,); + + let mut iterator = self.dataloader.iter(); + let mut iteration = 0; + let mut accumulator = GradientsAccumulator::new(); + let mut accumulation_current = 0; + + while let Some(item) = iterator.next() { + for _ in 0..peer_count { + iteration += 1; + learner.lr_step(); + } + log::info!("Iteration {iteration}"); + + let mut progress = iterator.progress(); + progress.items_processed *= peer_count; + progress.items_total *= peer_count; + + let item = learner.train_step(item); + + match self.grad_accumulation { + Some(accumulation) => { + accumulator.accumulate(&learner.model(), item.grads); + accumulation_current += 1; + + if accumulation <= accumulation_current { + let grads = accumulator.grads(); + + learner.optimizer_step(grads); + accumulation_current = 0; + } + } + None => { + learner.optimizer_step(item.grads); + } + } + + let item = TrainingItem::new( + item.item, + progress, + Some(iteration), + Some(learner.lr_current()), + ); + + { + let mut processor = processor.lock().unwrap(); + processor.process_train(LearnerEvent::ProcessedItem(item)); + } + + if interrupter.should_stop() { + log::info!("Training interrupted."); + break; + } + } + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/ddp/mod.rs b/crates/burn-train/src/learner/supervised/strategies/ddp/mod.rs new file mode 100644 index 0000000..1828753 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/ddp/mod.rs @@ -0,0 +1,5 @@ +mod epoch; +mod strategy; +mod worker; + +pub use strategy::*; diff --git a/crates/burn-train/src/learner/supervised/strategies/ddp/strategy.rs b/crates/burn-train/src/learner/supervised/strategies/ddp/strategy.rs new file mode 100644 index 0000000..bf90292 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/ddp/strategy.rs @@ -0,0 +1,146 @@ +use core::panic; +use std::sync::{Arc, Mutex}; + +use crate::ddp::worker::DdpWorker; +use crate::metric::store::EventStoreClient; +use crate::{ + EarlyStoppingStrategyRef, Interrupter, Learner, LearnerModel, SupervisedLearningStrategy, + SupervisedTrainingEventProcessor, TrainLoader, TrainingComponents, ValidLoader, +}; +use burn_core::data::dataloader::split::split_dataloader; +use burn_core::tensor::Device; +use burn_core::tensor::distributed::DistributedContext; + +#[derive(Clone)] +pub(crate) struct WorkerComponents { + /// The total number of epochs + pub num_epochs: usize, + /// Enables gradients accumulation. + pub grad_accumulation: Option, + /// An [Interupter](Interrupter) that allows aborting the training/evaluation process early. + pub interrupter: Interrupter, + /// Cloneable reference to an early stopping strategy. + pub early_stopping: Option, + /// A reference to an [EventStoreClient](EventStoreClient). + pub event_store: Arc, + /// The total number of items in the training dataset. + pub train_total_items: usize, + /// The total number of items in the validation dataset. + pub valid_total_items: usize, +} + +/// A training strategy for Distributed Data Parallel (DDP) training. +/// +/// This strategy manages multiple workers and coordinates cross-device +/// gradient synchronization using the provided [`DistributedContext`]. +pub struct DdpTrainingStrategy { + devices: Vec, + /// Kept alive to anchor the lifetime of the underlying distributed server. + /// Spawns communication servers on creation, automatically tears them down on drop. + _context: DistributedContext, +} + +impl DdpTrainingStrategy { + pub fn new(devices: Vec, context: DistributedContext) -> Self { + Self { + devices, + _context: context, + } + } +} + +impl SupervisedLearningStrategy for DdpTrainingStrategy { + fn fit( + &self, + training_components: TrainingComponents, + learner: Learner, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + starting_epoch: usize, + ) -> (M, SupervisedTrainingEventProcessor) { + // The reference model is always on the first device provided. + let main_device = self.devices.first().unwrap(); + let train_total_items = dataloader_train.num_items(); + let valid_total_items = dataloader_valid.num_items(); + // One worker per device, so we use a fixed device strategy + // for each (worker) data loader. This matches the expected device on the worker, so we + // don't have to move the data between devices. + let mut dataloaders_train = split_dataloader(dataloader_train, &self.devices); + let dataloader_valid = dataloader_valid.to_device(&main_device.clone().inner()); + + let main_device = self.devices[0].clone(); + let peer_count = self.devices.len(); + let event_processor = Arc::new(Mutex::new(training_components.event_processor)); + + let interrupter = training_components.interrupter; + let worker_components = WorkerComponents { + num_epochs: training_components.num_epochs, + grad_accumulation: training_components.grad_accumulation, + interrupter: interrupter.clone(), + early_stopping: training_components.early_stopping, + event_store: training_components.event_store, + train_total_items, + valid_total_items, + }; + + // Start worker for main device + // First training dataloader corresponds to main device + let main_handle = DdpWorker::::start( + main_device.clone(), + learner.clone(), + event_processor.clone(), + worker_components.clone(), + training_components.checkpointer, + dataloaders_train.remove(0), + Some(dataloader_valid), + starting_epoch, + peer_count, + true, + ); + + // Spawn other workers for the other devices, starting with peer id 1 + let mut secondary_workers = vec![]; + for device in &self.devices[1..] { + let handle = DdpWorker::::start( + device.clone(), + learner.clone(), + event_processor.clone(), + worker_components.clone(), + None, + dataloaders_train.remove(0), + None, + starting_epoch, + peer_count, + false, + ); + + secondary_workers.push(handle); + } + + // Wait for all devices to finish + for worker in secondary_workers { + worker + .join() + .expect("Distributed data parallel worker failed"); + } + + // Main worker had the event processor + let model = main_handle + .join() + .expect("Distributed data parallel main worker failed"); + + if interrupter.should_stop() { + let reason = interrupter + .get_message() + .unwrap_or(String::from("Reason unknown")); + log::info!("Training interrupted: {reason}"); + } + let Ok(event_processor) = Arc::try_unwrap(event_processor) else { + panic!("Event processor still held!"); + }; + let Ok(event_processor) = event_processor.into_inner() else { + panic!("Event processor lock poisoned"); + }; + (model, event_processor) + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/ddp/worker.rs b/crates/burn-train/src/learner/supervised/strategies/ddp/worker.rs new file mode 100644 index 0000000..be36300 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/ddp/worker.rs @@ -0,0 +1,142 @@ +use crate::ddp::epoch::{DdpTrainEpoch, DdpValidEpoch}; +use crate::ddp::strategy::WorkerComponents; +use crate::metric::processor::{EventProcessorTraining, LearnerEvent}; +use crate::single::TrainingLoop; +use crate::{ + Learner, LearnerModel, LearningCheckpointer, SupervisedTrainingEventProcessor, TrainLoader, + ValidLoader, +}; +use burn_core::tensor::Device; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +/// A worker runs the model, syncing gradients using collective operations. +/// Event processing and validation is optional too. +pub(crate) struct DdpWorker { + device: Device, + learner: Learner, + event_processor: Arc>>, + components: WorkerComponents, + checkpointer: Option>, + dataloader_train: TrainLoader, + dataloader_valid: Option>, + starting_epoch: usize, + peer_count: usize, + is_main: bool, +} + +impl DdpWorker { + /// Starts a worker that runs the model in a data distributed parallel + #[allow(clippy::too_many_arguments)] + pub fn start( + device: Device, + learner: Learner, + event_processor: Arc>>, + components: WorkerComponents, + checkpointer: Option>, + dataloader_train: TrainLoader, + dataloader_valid: Option>, + starting_epoch: usize, + peer_count: usize, + is_main: bool, + ) -> JoinHandle { + let worker = Self { + device, + learner, + event_processor, + components, + checkpointer, + dataloader_train, + dataloader_valid, + starting_epoch, + peer_count, + is_main, + }; + + std::thread::spawn(|| worker.fit()) + } + + /// Fits the model, + pub fn fit(mut self) -> M { + let num_epochs = self.components.num_epochs; + let interrupter = self.components.interrupter; + + // Changed the train epoch to keep the dataloaders + let epoch_train = DdpTrainEpoch::::new( + self.dataloader_train.clone(), + self.components.grad_accumulation, + ); + let epoch_valid = self + .dataloader_valid + .map(|dataloader| DdpValidEpoch::::new(dataloader)); + self.learner.fork(&self.device); + self.learner.grad_sharded(); + + for training_progress in TrainingLoop::new(self.starting_epoch, num_epochs) { + let epoch = training_progress.items_processed; + + if self.is_main { + self.event_processor + .lock() + .unwrap() + .process_train(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: self.components.train_total_items, + }); + } + + epoch_train.run( + &mut self.learner, + &training_progress, + self.event_processor.clone(), + &interrupter, + self.peer_count, + ); + + if self.is_main { + self.event_processor + .lock() + .unwrap() + .process_train(LearnerEvent::EndSplit(epoch)); + } + + if interrupter.should_stop() { + break; + } + + // Validation + if let Some(runner) = &epoch_valid { + { + self.event_processor + .lock() + .unwrap() + .process_valid(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: self.components.valid_total_items, + }); + } + let mut event_processor = self.event_processor.lock().unwrap(); + runner.run( + &self.learner.model(), + &training_progress, + &mut event_processor, + &interrupter, + ); + event_processor.process_valid(LearnerEvent::EndSplit(epoch)); + event_processor.process_train(LearnerEvent::EndEpoch(epoch)); + } + + if let Some(checkpointer) = &mut self.checkpointer { + checkpointer.checkpoint(&self.learner, epoch, &self.components.event_store); + } + + if let Some(early_stopping) = &mut self.components.early_stopping + && early_stopping.should_stop(epoch, &self.components.event_store) + { + break; + } + } + + self.learner.model() + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/mod.rs b/crates/burn-train/src/learner/supervised/strategies/mod.rs new file mode 100644 index 0000000..def5b8d --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/mod.rs @@ -0,0 +1,7 @@ +mod base; + +pub(crate) mod ddp; +pub(crate) mod multi; +pub(crate) mod single; + +pub use base::*; diff --git a/crates/burn-train/src/learner/supervised/strategies/multi/epoch.rs b/crates/burn-train/src/learner/supervised/strategies/multi/epoch.rs new file mode 100644 index 0000000..e6cc1fb --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/multi/epoch.rs @@ -0,0 +1,205 @@ +use crate::learner::base::Interrupter; +use crate::metric::processor::{EventProcessorTraining, LearnerEvent, TrainingItem}; +use crate::train::MultiDevicesTrainStep; +use crate::{ + Learner, LearnerModel, MultiDeviceOptim, SupervisedTrainingEventProcessor, TrainLoader, +}; +use burn_core::data::dataloader::Progress; +use burn_core::tensor::Device; +use burn_optim::GradientsAccumulator; +use burn_optim::MultiGradientsParams; + +/// A training epoch. +#[derive(new)] +pub struct MultiDeviceTrainEpoch { + dataloaders: Vec>, + grad_accumulation: Option, +} + +impl MultiDeviceTrainEpoch { + /// Runs the training epoch on multiple devices. + /// + /// # Arguments + /// + /// * `model` - The model to train. + /// * `optim` - The optimizer to use. + /// * `lr_scheduler` - The learning rate scheduler to use. + /// * `processor` - The event processor to use. + /// * `devices` - The devices to use. + /// + /// # Returns + /// + /// The trained model and the optimizer. + #[allow(clippy::too_many_arguments)] + pub fn run( + &self, + learner: &mut Learner, + global_progress: &Progress, + event_processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + devices: Vec, + strategy: MultiDeviceOptim, + ) { + match strategy { + MultiDeviceOptim::OptimMainDevice => self.run_optim_main( + learner, + global_progress, + event_processor, + interrupter, + devices, + ), + MultiDeviceOptim::OptimSharded => self.run_optim_distr( + learner, + global_progress, + event_processor, + interrupter, + devices, + ), + } + } + + fn run_optim_main( + &self, + learner: &mut Learner, + global_progress: &Progress, + event_processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + devices: Vec, + ) { + let epoch = global_progress.items_processed; + log::info!( + "Executing training step for epoch {} on devices {:?}", + epoch, + devices + ); + + let mut iterators = self + .dataloaders + .iter() + .map(|d| d.iter()) + .collect::>(); + let mut iteration = 0; + let mut accumulator = GradientsAccumulator::new(); + let mut accumulation_current = 0; + + let accumulation = self.grad_accumulation.unwrap_or(1); + let step = MultiDevicesTrainStep::::new(&devices); + + // The main device is always the first in the list. + let device_main = devices.first().expect("A minimum of one device.").clone(); + + loop { + let (items, progress) = step.step(iterators.as_mut_slice(), &learner.model()); + if items.is_empty() { + break; + } + + learner.lr_step(); + + let mut progress_items = Vec::with_capacity(items.len()); + for item in items.into_iter() { + let grads = item.output.grads.to_device(&device_main, &learner.model()); + accumulator.accumulate(&learner.model(), grads); + progress_items.push(item.output.item); + } + + accumulation_current += 1; + + if accumulation <= accumulation_current { + let grads = accumulator.grads(); + learner.optimizer_step(grads); + accumulation_current = 0; + } + + for item in progress_items { + iteration += 1; + let item = TrainingItem::new( + item, + progress.clone(), + Some(iteration), + Some(learner.lr_current()), + ); + + event_processor.process_train(LearnerEvent::ProcessedItem(item)); + } + + if interrupter.should_stop() { + break; + } + } + } + + fn run_optim_distr( + &self, + learner: &mut Learner, + global_progress: &Progress, + event_processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + devices: Vec, + ) { + let epoch = global_progress.items_processed; + log::info!( + "Executing training step for epoch {} on devices {:?}", + epoch, + devices + ); + + let mut iterators = self + .dataloaders + .iter() + .map(|d| d.iter()) + .collect::>(); + let mut iteration = 0; + let mut accumulators: Vec> = (0..devices.len()) + .map(|_| GradientsAccumulator::new()) + .collect(); + let mut accumulation_current = 0; + + let accumulation = self.grad_accumulation.unwrap_or(1); + let step = MultiDevicesTrainStep::::new(&devices); + + loop { + let (items, progress) = step.step(iterators.as_mut_slice(), &learner.model()); + if items.is_empty() { + break; + } + + learner.lr_step(); + + let mut progress_items = Vec::with_capacity(items.len()); + for item in items.into_iter() { + let accumulator = &mut accumulators[item.device_id]; + accumulator.accumulate(&learner.model(), item.output.grads); + progress_items.push(item.output.item); + } + + accumulation_current += 1; + + if accumulation <= accumulation_current { + let mut grads = MultiGradientsParams::default(); + for (device_id, accumulator) in accumulators.iter_mut().enumerate() { + let grad = accumulator.grads(); + grads.grads.push((grad, devices[device_id].clone())); + } + learner.optimizer_step_multi(grads); + accumulation_current = 0; + } + + for item in progress_items { + iteration += 1; + let item = TrainingItem::new( + item, + progress.clone(), + Some(iteration), + Some(learner.lr_current()), + ); + + event_processor.process_train(LearnerEvent::ProcessedItem(item)); + } + + if interrupter.should_stop() { + break; + } + } + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/multi/mod.rs b/crates/burn-train/src/learner/supervised/strategies/multi/mod.rs new file mode 100644 index 0000000..b141f9d --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/multi/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod epoch; +mod strategy; + +pub use strategy::*; diff --git a/crates/burn-train/src/learner/supervised/strategies/multi/strategy.rs b/crates/burn-train/src/learner/supervised/strategies/multi/strategy.rs new file mode 100644 index 0000000..c7b626d --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/multi/strategy.rs @@ -0,0 +1,109 @@ +use crate::{ + Learner, LearnerEvent, LearnerModel, MultiDeviceOptim, SupervisedLearningStrategy, + SupervisedTrainingEventProcessor, TrainLoader, TrainingComponents, ValidLoader, + metric::processor::EventProcessorTraining, + multi::epoch::MultiDeviceTrainEpoch, + single::{TrainingLoop, epoch::SingleDeviceValidEpoch}, +}; +use burn_core::{data::dataloader::split::split_dataloader, tensor::Device}; + +pub struct MultiDeviceLearningStrategy { + devices: Vec, + optim: MultiDeviceOptim, +} +impl MultiDeviceLearningStrategy { + pub fn new(devices: Vec, optim: MultiDeviceOptim) -> Self { + Self { devices, optim } + } +} + +impl SupervisedLearningStrategy for MultiDeviceLearningStrategy { + fn fit( + &self, + training_components: TrainingComponents, + mut learner: Learner, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + starting_epoch: usize, + ) -> (M, SupervisedTrainingEventProcessor) { + let main_device = self.devices.first().unwrap(); + + // `MultiDevicesTrainStep` has one worker per device, so we use a fixed device strategy + // for each (worker) data loader. This matches the expected device on the worker, so we + // don't have to move the data between devices. + let train_total_items = dataloader_train.num_items(); + let dataloader_train = split_dataloader(dataloader_train, &self.devices); + let dataloader_valid = dataloader_valid.to_device(&main_device.clone().inner()); + let valid_total_items = dataloader_valid.num_items(); + + learner.fork(main_device); + let mut event_processor = training_components.event_processor; + let mut checkpointer = training_components.checkpointer; + let mut early_stopping = training_components.early_stopping; + + let epoch_train = MultiDeviceTrainEpoch::::new( + dataloader_train.clone(), + training_components.grad_accumulation, + ); + let epoch_valid: SingleDeviceValidEpoch = + SingleDeviceValidEpoch::new(dataloader_valid.clone()); + + for training_progress in TrainingLoop::new(starting_epoch, training_components.num_epochs) { + let epoch = training_progress.items_processed; + + event_processor.process_train(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: train_total_items, + }); + epoch_train.run( + &mut learner, + &training_progress, + &mut event_processor, + &training_components.interrupter, + self.devices.to_vec(), + self.optim, + ); + event_processor.process_train(LearnerEvent::EndSplit(epoch)); + + if training_components.interrupter.should_stop() { + let reason = training_components + .interrupter + .get_message() + .unwrap_or(String::from("Reason unknown")); + log::info!("Training interrupted: {reason}"); + break; + } + + // After OptimSharded training, model parameters are scattered across + // devices. Fork back to main_device before single-device validation. + if matches!(self.optim, MultiDeviceOptim::OptimSharded) { + learner.fork(main_device); + } + + event_processor.process_valid(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: valid_total_items, + }); + epoch_valid.run( + &learner, + &training_progress, + &mut event_processor, + &training_components.interrupter, + ); + event_processor.process_valid(LearnerEvent::EndSplit(epoch)); + event_processor.process_train(LearnerEvent::EndEpoch(epoch)); + + if let Some(checkpointer) = &mut checkpointer { + checkpointer.checkpoint(&learner, epoch, &training_components.event_store); + } + + if let Some(early_stopping) = &mut early_stopping + && early_stopping.should_stop(epoch, &training_components.event_store) + { + break; + } + } + + (learner.model(), event_processor) + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/single/epoch.rs b/crates/burn-train/src/learner/supervised/strategies/single/epoch.rs new file mode 100644 index 0000000..127f767 --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/single/epoch.rs @@ -0,0 +1,126 @@ +use crate::learner::base::Interrupter; +use crate::metric::processor::{EventProcessorTraining, LearnerEvent, TrainingItem}; +use crate::{ + InferenceStep, Learner, LearnerModel, SupervisedTrainingEventProcessor, TrainLoader, + ValidLoader, +}; +use burn_core::data::dataloader::Progress; +use burn_optim::GradientsAccumulator; + +/// A validation epoch. +#[derive(new)] +pub struct SingleDeviceValidEpoch { + dataloader: ValidLoader, +} + +/// A training epoch. +#[derive(new)] +pub struct SingleDeviceTrainEpoch { + dataloader: TrainLoader, + grad_accumulation: Option, +} + +impl SingleDeviceValidEpoch { + /// Runs the validation epoch. + /// + /// # Arguments + /// + /// * `model` - The model to validate. + /// * `processor` - The event processor to use. + pub fn run( + &self, + learner: &Learner, + global_progress: &Progress, + processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + ) { + let epoch = global_progress.items_processed; + log::info!("Executing validation step for epoch {}", epoch); + let model = learner.model().valid(); + + let mut iterator = self.dataloader.iter(); + let mut iteration = 0; + + while let Some(item) = iterator.next() { + let progress = iterator.progress(); + iteration += 1; + + let item = InferenceStep::step(&model, item); + let item = TrainingItem::new(item, progress, Some(iteration), None); + + processor.process_valid(LearnerEvent::ProcessedItem(item)); + + if interrupter.should_stop() { + break; + } + } + } +} + +impl SingleDeviceTrainEpoch { + /// Runs the training epoch. + /// + /// # Arguments + /// + /// * `model` - The model to train. + /// * `optim` - The optimizer to use. + /// * `scheduler` - The learning rate scheduler to use. + /// * `processor` - The event processor to use. + /// + /// # Returns + /// + /// The trained model and the optimizer. + pub fn run( + &self, + learner: &mut Learner, + global_progress: &Progress, + processor: &mut SupervisedTrainingEventProcessor, + interrupter: &Interrupter, + ) { + let epoch = global_progress.items_processed; + log::info!("Executing training step for epoch {}", epoch,); + + // Single device / dataloader + let mut iterator = self.dataloader.iter(); + let mut iteration = 0; + let mut accumulator = GradientsAccumulator::new(); + let mut accumulation_current = 0; + + while let Some(item) = iterator.next() { + iteration += 1; + learner.lr_step(); + log::info!("Iteration {iteration}"); + + let progress = iterator.progress(); + let item = learner.train_step(item); + + match self.grad_accumulation { + Some(accumulation) => { + accumulator.accumulate(&learner.model(), item.grads); + accumulation_current += 1; + + if accumulation <= accumulation_current { + let grads = accumulator.grads(); + + learner.optimizer_step(grads); + accumulation_current = 0; + } + } + None => learner.optimizer_step(item.grads), + } + + let item = TrainingItem::new( + item.item, + progress, + Some(iteration), + Some(learner.lr_current()), + ); + + processor.process_train(LearnerEvent::ProcessedItem(item)); + + if interrupter.should_stop() { + break; + } + } + } +} diff --git a/crates/burn-train/src/learner/supervised/strategies/single/mod.rs b/crates/burn-train/src/learner/supervised/strategies/single/mod.rs new file mode 100644 index 0000000..b141f9d --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/single/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod epoch; +mod strategy; + +pub use strategy::*; diff --git a/crates/burn-train/src/learner/supervised/strategies/single/strategy.rs b/crates/burn-train/src/learner/supervised/strategies/single/strategy.rs new file mode 100644 index 0000000..e85561d --- /dev/null +++ b/crates/burn-train/src/learner/supervised/strategies/single/strategy.rs @@ -0,0 +1,118 @@ +use crate::{ + EventProcessorTraining, Learner, LearnerEvent, LearnerModel, SupervisedLearningStrategy, + SupervisedTrainingEventProcessor, TrainLoader, TrainingComponents, ValidLoader, + single::epoch::{SingleDeviceTrainEpoch, SingleDeviceValidEpoch}, +}; +use burn_core::{data::dataloader::Progress, tensor::Device}; + +/// Simplest learning strategy possible, with only a single devices doing both the training and +/// validation. +pub struct SingleDeviceTrainingStrategy { + device: Device, +} +impl SingleDeviceTrainingStrategy { + pub fn new(device: Device) -> Self { + Self { device } + } +} + +#[derive(new)] +pub(crate) struct TrainingLoop { + next_iteration: usize, + total_iteration: usize, +} + +/// An iterator that returns the progress of the training. +impl Iterator for TrainingLoop { + type Item = Progress; + + fn next(&mut self) -> Option { + if self.next_iteration > self.total_iteration { + return None; + } + + let progress = Progress { + items_processed: self.next_iteration, + items_total: self.total_iteration, + unit: Some("epochs".to_string()), + }; + + self.next_iteration += 1; + Some(progress) + } +} + +impl SupervisedLearningStrategy for SingleDeviceTrainingStrategy { + fn fit( + &self, + training_components: TrainingComponents, + mut learner: Learner, + dataloader_train: TrainLoader, + dataloader_valid: ValidLoader, + starting_epoch: usize, + ) -> (M, SupervisedTrainingEventProcessor) { + let dataloader_train = dataloader_train.to_device(&self.device); + let train_total_items = dataloader_train.num_items(); + let dataloader_valid = dataloader_valid.to_device(&self.device.clone().inner()); + let valid_total_items = dataloader_valid.num_items(); + learner.fork(&self.device); + let mut event_processor = training_components.event_processor; + let mut checkpointer = training_components.checkpointer; + let mut early_stopping = training_components.early_stopping; + + let epoch_train: SingleDeviceTrainEpoch = + SingleDeviceTrainEpoch::new(dataloader_train, training_components.grad_accumulation); + let epoch_valid: SingleDeviceValidEpoch = + SingleDeviceValidEpoch::new(dataloader_valid.clone()); + + for training_progress in TrainingLoop::new(starting_epoch, training_components.num_epochs) { + let epoch = training_progress.items_processed; + + event_processor.process_train(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: train_total_items, + }); + epoch_train.run( + &mut learner, + &training_progress, + &mut event_processor, + &training_components.interrupter, + ); + event_processor.process_train(LearnerEvent::EndSplit(epoch)); + + if training_components.interrupter.should_stop() { + let reason = training_components + .interrupter + .get_message() + .unwrap_or(String::from("Reason unknown")); + log::info!("Training interrupted: {reason}"); + break; + } + + event_processor.process_valid(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: valid_total_items, + }); + epoch_valid.run( + &learner, + &training_progress, + &mut event_processor, + &training_components.interrupter, + ); + event_processor.process_valid(LearnerEvent::EndSplit(epoch)); + event_processor.process_train(LearnerEvent::EndEpoch(epoch)); + + if let Some(checkpointer) = &mut checkpointer { + checkpointer.checkpoint(&learner, epoch, &training_components.event_store); + } + + if let Some(early_stopping) = &mut early_stopping + && early_stopping.should_stop(epoch, &training_components.event_store) + { + break; + } + } + + (learner.model(), event_processor) + } +} diff --git a/crates/burn-train/src/learner/train_val.rs b/crates/burn-train/src/learner/train_val.rs new file mode 100644 index 0000000..e588fc8 --- /dev/null +++ b/crates/burn-train/src/learner/train_val.rs @@ -0,0 +1,133 @@ +use crate::{ItemLazy, renderer::MetricsRenderer}; +use burn_core::{module::AutodiffModule, tensor::Gradients}; +use burn_optim::{ + GradientsParams, ModuleOptimizer, MultiGradientsParams, + lr_scheduler::module_lr_scheduler::ModuleLearningRate, +}; + +/// A training output. +pub struct TrainOutput { + /// The gradients. + pub grads: GradientsParams, + + /// The item. + pub item: TO, +} + +impl TrainOutput { + /// Creates a new training output. + /// + /// # Arguments + /// + /// * `module` - The module. + /// * `grads` - The gradients. + /// * `item` - The item. + /// + /// # Returns + /// + /// A new training output. + pub fn new(module: &M, grads: Gradients, item: TO) -> Self { + let grads = GradientsParams::from_grads(grads, module); + Self { grads, item } + } +} + +/// Trait to be implemented for models to be able to be trained. +/// +/// The [step](TrainStep::step) method needs to be manually implemented for all structs. +/// +/// The [optimize](TrainStep::optimize) method can be overridden if you want to control how the +/// optimizer is used to update the model. This can be useful if you want to call custom mutable +/// functions on your model (e.g., clipping the weights) before or after the optimizer is used. +/// +/// # Notes +/// +/// To be used with the [Learner](crate::Learner) struct, the struct which implements this trait must +/// also implement the [AutodiffModule] trait, which is done automatically with the +/// [Module](burn_core::module::Module) derive. +pub trait TrainStep { + /// Type of input for a step of the training stage. + type Input: Send + 'static; + /// Type of output for a step of the training stage. + type Output: ItemLazy + 'static; + /// Runs a step for training, which executes the forward and backward passes. + /// + /// # Arguments + /// + /// * `item` - The input for the model. + /// + /// # Returns + /// + /// The output containing the model output and the gradients. + fn step(&self, item: Self::Input) -> TrainOutput; + /// Optimize the current module with the provided gradients and learning rate. + /// + /// # Arguments + /// + /// * `optim`: Optimizer used for learning. + /// * `lr`: The learning rate used for this step. + /// * `grads`: The gradients of each parameter in the current model. + /// + /// # Returns + /// + /// The updated model. + fn optimize( + self, + optim: &mut ModuleOptimizer, + lr_module: ModuleLearningRate, + grads: GradientsParams, + ) -> Self + where + Self: AutodiffModule + Sized, + { + optim.step(lr_module, self, grads) + } + /// Optimize the current module with the provided gradients and learning rate. + /// + /// # Arguments + /// + /// * `optim`: Optimizer used for learning. + /// * `lr`: The learning rate used for this step. + /// * `grads`: Multiple gradients associated to each parameter in the current model. + /// + /// # Returns + /// + /// The updated model. + fn optimize_multi( + self, + optim: &mut ModuleOptimizer, + lr_module: ModuleLearningRate, + grads: MultiGradientsParams, + ) -> Self + where + Self: AutodiffModule + Sized, + { + optim.step_multi(lr_module, self, grads) + } +} + +/// Trait to be implemented for validating models. +pub trait InferenceStep { + /// Type of input for an inference step. + type Input: Send + 'static; + /// Type of output for an inference step. + type Output: ItemLazy + 'static; + /// Runs a validation step. + /// + /// # Arguments + /// + /// * `item` - The item to validate on. + /// + /// # Returns + /// + /// The validation output. + fn step(&self, item: Self::Input) -> Self::Output; +} + +/// The result of a training, containing the model along with the [renderer](MetricsRenderer). +pub struct LearningResult { + /// The model with the learned weights. + pub model: M, + /// The renderer that can be used for follow up training and evaluation. + pub renderer: Box, +} diff --git a/crates/burn-train/src/lib.rs b/crates/burn-train/src/lib.rs new file mode 100644 index 0000000..616dcaf --- /dev/null +++ b/crates/burn-train/src/lib.rs @@ -0,0 +1,112 @@ +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! A library for training neural networks using the burn crate. + +#[macro_use] +extern crate derive_new; + +/// The checkpoint module. +pub mod checkpoint; + +pub(crate) mod components; + +/// Renderer modules to display metrics and training information. +pub mod renderer; + +/// The logger module. +pub mod logger; + +/// The metric module. +pub mod metric; + +pub use metric::processor::*; + +mod learner; + +pub use learner::*; + +mod evaluator; + +pub use evaluator::*; + +pub use components::*; + +#[cfg(test)] +pub(crate) mod tests { + use burn_core::{prelude::Tensor, tensor::Bool}; + use std::default::Default; + + /// Probability of tp before adding errors + pub const THRESHOLD: f64 = 0.5; + + #[derive(Debug, Default)] + pub enum ClassificationType { + #[default] + Binary, + Multiclass, + Multilabel, + } + + /// Sample x Class shaped matrix for use in + /// classification metrics testing + pub fn dummy_classification_input( + classification_type: &ClassificationType, + ) -> (Tensor<2>, Tensor<2, Bool>) { + match classification_type { + ClassificationType::Binary => { + ( + Tensor::from_data([[0.3], [0.2], [0.7], [0.1], [0.55]], &Default::default()), + // targets + Tensor::from_data([[0], [1], [0], [0], [1]], &Default::default()), + // predictions @ threshold=0.5 + // [[0], [0], [1], [0], [1]] + ) + } + ClassificationType::Multiclass => { + ( + Tensor::from_data( + [ + [0.2, 0.8, 0.0], + [0.3, 0.6, 0.1], + [0.7, 0.25, 0.05], + [0.1, 0.15, 0.8], + [0.9, 0.03, 0.07], + ], + &Default::default(), + ), + Tensor::from_data( + // targets + [[0, 1, 0], [1, 0, 0], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + // predictions @ top_k=1 + // [[0, 1, 0], [0, 1, 0], [1, 0, 0], [0, 0, 1], [1, 0, 0]] + // predictions @ top_k=2 + // [[1, 1, 0], [1, 1, 0], [1, 1, 0], [0, 1, 1], [1, 0, 1]] + &Default::default(), + ), + ) + } + ClassificationType::Multilabel => { + ( + Tensor::from_data( + [ + [0.1, 0.7, 0.6], + [0.3, 0.9, 0.05], + [0.8, 0.9, 0.4], + [0.7, 0.5, 0.9], + [1.0, 0.3, 0.2], + ], + &Default::default(), + ), + // targets + Tensor::from_data( + [[1, 1, 0], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 0, 0]], + // predictions @ threshold=0.5 + // [[0, 1, 1], [0, 1, 0], [1, 1, 0], [1, 0, 1], [1, 0, 0]] + &Default::default(), + ), + ) + } + } + } +} diff --git a/crates/burn-train/src/logger/async_logger.rs b/crates/burn-train/src/logger/async_logger.rs new file mode 100644 index 0000000..c659098 --- /dev/null +++ b/crates/burn-train/src/logger/async_logger.rs @@ -0,0 +1,91 @@ +use super::Logger; +use std::sync::mpsc; + +enum Message { + Log(T), + End, + Sync(mpsc::Sender<()>), +} +/// Async logger. +pub struct AsyncLogger { + sender: mpsc::Sender>, + handler: Option>, +} + +#[derive(new)] +struct LoggerThread> { + logger: L, + receiver: mpsc::Receiver>, +} + +impl LoggerThread +where + L: Logger, +{ + fn run(mut self) { + for item in self.receiver.iter() { + match item { + Message::Log(item) => { + self.logger.log(item); + } + Message::End => { + return; + } + Message::Sync(callback) => { + callback + .send(()) + .expect("Can return result with the callback channel."); + } + } + } + } +} + +impl AsyncLogger { + /// Create a new async logger. + pub fn new(logger: L) -> Self + where + L: Logger + 'static, + { + let (sender, receiver) = mpsc::channel(); + let thread = LoggerThread::new(logger, receiver); + + let handler = Some(std::thread::spawn(move || thread.run())); + + Self { sender, handler } + } + + /// Sync the async logger. + pub(crate) fn sync(&self) { + let (sender, receiver) = mpsc::channel(); + + self.sender + .send(Message::Sync(sender)) + .expect("Can send message to logger thread."); + + receiver + .recv() + .expect("Should sync, otherwise the thread is dead."); + } +} + +impl Logger for AsyncLogger { + fn log(&mut self, item: T) { + self.sender + .send(Message::Log(item)) + .expect("Can log using the logger thread."); + } +} + +impl Drop for AsyncLogger { + fn drop(&mut self) { + self.sender + .send(Message::End) + .expect("Can send the end message to the logger thread."); + let handler = self.handler.take(); + + if let Some(handler) = handler { + handler.join().expect("The logger thread should stop."); + } + } +} diff --git a/crates/burn-train/src/logger/base.rs b/crates/burn-train/src/logger/base.rs new file mode 100644 index 0000000..5bb98ff --- /dev/null +++ b/crates/burn-train/src/logger/base.rs @@ -0,0 +1,9 @@ +/// The logger trait. +pub trait Logger: Send { + /// Logs an item. + /// + /// # Arguments + /// + /// * `item` - The item. + fn log(&mut self, item: T); +} diff --git a/crates/burn-train/src/logger/file.rs b/crates/burn-train/src/logger/file.rs new file mode 100644 index 0000000..c852c21 --- /dev/null +++ b/crates/burn-train/src/logger/file.rs @@ -0,0 +1,46 @@ +use super::Logger; +use std::{fs::File, io::Write, path::Path}; + +/// File logger. +pub struct FileLogger { + file: File, +} + +impl FileLogger { + /// Create a new file logger. + /// + /// # Arguments + /// + /// * `path` - The path. + /// + /// # Returns + /// + /// The file logger. + pub fn new(path: impl AsRef) -> Self { + let path = path.as_ref(); + let mut options = std::fs::File::options(); + let file = options + .write(true) + .truncate(true) + .create(true) + .open(path) + .unwrap_or_else(|err| { + panic!( + "Should be able to create the new file '{}': {}", + path.display(), + err + ) + }); + + Self { file } + } +} + +impl Logger for FileLogger +where + T: std::fmt::Display, +{ + fn log(&mut self, item: T) { + writeln!(&mut self.file, "{item}").expect("Can log an item."); + } +} diff --git a/crates/burn-train/src/logger/in_memory.rs b/crates/burn-train/src/logger/in_memory.rs new file mode 100644 index 0000000..31cf3f1 --- /dev/null +++ b/crates/burn-train/src/logger/in_memory.rs @@ -0,0 +1,16 @@ +use super::Logger; + +/// In memory logger. +#[derive(Default)] +pub struct InMemoryLogger { + pub(crate) values: Vec, +} + +impl Logger for InMemoryLogger +where + T: std::fmt::Display, +{ + fn log(&mut self, item: T) { + self.values.push(item.to_string()); + } +} diff --git a/crates/burn-train/src/logger/metric.rs b/crates/burn-train/src/logger/metric.rs new file mode 100644 index 0000000..22e59c4 --- /dev/null +++ b/crates/burn-train/src/logger/metric.rs @@ -0,0 +1,385 @@ +use super::{AsyncLogger, FileLogger, InMemoryLogger, Logger}; +use crate::metric::{ + MetricDefinition, MetricEntry, MetricId, NumericEntry, + store::{EpochSummary, MetricsUpdate, Split}, +}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +const EPOCH_PREFIX: &str = "epoch-"; + +/// Metric logger. +pub trait MetricLogger: Send { + /// Logs an item. + /// + /// # Arguments + /// + /// * `update` - Update information for all registered metrics. + /// * `epoch` - Current epoch. + /// * `split` - Current dataset split. + fn log(&mut self, update: MetricsUpdate, epoch: usize, split: &Split); + + /// Read the logs for an epoch. + fn read_numeric( + &mut self, + name: &str, + epoch: usize, + split: &Split, + ) -> Result, String>; + + /// Logs the metric definition information (name, description, unit, etc.) + fn log_metric_definition(&mut self, definition: MetricDefinition); + + /// Logs summary at the end of the epoch. + fn log_epoch_summary(&mut self, summary: EpochSummary); +} + +/// The file metric logger. +pub struct FileMetricLogger { + loggers: HashMap>, + directory: PathBuf, + metric_definitions: HashMap, + is_eval: bool, + last_epoch: Option, +} + +impl FileMetricLogger { + /// Create a new file metric logger. + /// + /// # Arguments + /// + /// * `directory` - The directory. + /// + /// # Returns + /// + /// The file metric logger. + pub fn new(directory: impl AsRef) -> Self { + Self { + loggers: HashMap::new(), + directory: directory.as_ref().to_path_buf(), + metric_definitions: HashMap::default(), + is_eval: false, + last_epoch: None, + } + } + + /// Create a new file metric logger. + /// + /// # Arguments + /// + /// * `directory` - The directory. + /// + /// # Returns + /// + /// The file metric logger. + pub fn new_eval(directory: impl AsRef) -> Self { + Self { + loggers: HashMap::new(), + directory: directory.as_ref().to_path_buf(), + metric_definitions: HashMap::default(), + is_eval: true, + last_epoch: None, + } + } + + pub(crate) fn split_exists(&self, split: &Split) -> bool { + self.split_dir(split).is_some() + } + + pub(crate) fn split_dir(&self, split: &Split) -> Option { + let split_path = match split { + Split::Test(Some(tag)) => self.directory.join(split.to_string()).join(tag.as_str()), + other => self.directory.join(other.to_string()), + }; + (split_path.exists() && split_path.is_dir()).then_some(split_path) + } + + pub(crate) fn is_epoch_dir>(dirname: P) -> bool { + dirname.as_ref().starts_with(EPOCH_PREFIX) + } + + /// Number of epochs recorded. + pub(crate) fn epochs(&self) -> usize { + if self.is_eval { + log::warn!("Number of epochs not available when testing."); + return 0; + } + + let mut max_epoch = 0; + + // with split + for path in fs::read_dir(&self.directory).unwrap() { + let path = path.unwrap(); + + if fs::metadata(path.path()).unwrap().is_dir() { + for split_path in fs::read_dir(path.path()).unwrap() { + let split_path = split_path.unwrap(); + + if fs::metadata(split_path.path()).unwrap().is_dir() { + let dir_name = split_path.file_name().into_string().unwrap(); + + if !dir_name.starts_with(EPOCH_PREFIX) { + continue; + } + + let epoch = dir_name.replace(EPOCH_PREFIX, "").parse::().ok(); + + if let Some(epoch) = epoch + && epoch > max_epoch + { + max_epoch = epoch; + } + } + } + } + } + + max_epoch + } + + fn train_directory(&self, epoch: usize, split: &Split) -> PathBuf { + let name = format!("{EPOCH_PREFIX}{epoch}"); + + match split { + Split::Train | Split::Valid | Split::Test(None) => { + self.directory.join(split.to_string()).join(name) + } + Split::Test(Some(tag)) => { + let tag = format_tag(tag); + self.directory.join(split.to_string()).join(tag).join(name) + } + } + } + + fn eval_directory(&self, split: &Split) -> PathBuf { + match split { + Split::Train | Split::Valid | Split::Test(None) => self.directory.clone(), + Split::Test(Some(tag)) => self.directory.join(split.to_string()).join(format_tag(tag)), + } + } + + fn file_path(&self, name: &str, epoch: Option, split: &Split) -> PathBuf { + let directory = match epoch { + Some(epoch) => self.train_directory(epoch, split), + None => self.eval_directory(split), + }; + let name = name.replace(' ', "_"); + let name = format!("{name}.log"); + directory.join(name) + } + + fn create_directory(&self, epoch: Option, split: &Split) { + let directory = match epoch { + Some(epoch) => self.train_directory(epoch, split), + None => self.eval_directory(split), + }; + std::fs::create_dir_all(directory).ok(); + } +} + +impl FileMetricLogger { + fn log_item(&mut self, item: &MetricEntry, epoch: Option, split: &Split) { + let name = &self.metric_definitions.get(&item.metric_id).unwrap().name; + let key = logger_key(name, split); + let value = &item.serialized_entry.serialized; + + let logger = match self.loggers.get_mut(&key) { + Some(val) => val, + None => { + self.create_directory(epoch, split); + + let file_path = self.file_path(name, epoch, split); + let logger = FileLogger::new(file_path); + let logger = AsyncLogger::new(logger); + + self.loggers.insert(key.clone(), logger); + self.loggers + .get_mut(&key) + .expect("Can get the previously saved logger.") + } + }; + + logger.log(value.clone()); + } +} + +fn format_tag(tag: &str) -> String { + tag.trim().replace(' ', "-").to_lowercase() +} + +impl MetricLogger for FileMetricLogger { + fn log(&mut self, update: MetricsUpdate, epoch: usize, split: &Split) { + if !self.is_eval && self.last_epoch != Some(epoch) { + self.loggers.clear(); + self.last_epoch = Some(epoch); + } + + let entries: Vec<_> = update + .entries + .iter() + .chain( + update + .entries_numeric + .iter() + .map(|numeric_update| &numeric_update.entry), + ) + .cloned() + .collect(); + + let epoch = if !self.is_eval { Some(epoch) } else { None }; + for item in entries.iter() { + self.log_item(item, epoch, split); + } + } + + fn read_numeric( + &mut self, + name: &str, + epoch: usize, + split: &Split, + ) -> Result, String> { + if let Some(value) = self.loggers.get(name) { + value.sync() + } + + let file_path = self.file_path(name, Some(epoch), split); + + let mut errors = false; + + let data = std::fs::read_to_string(file_path) + .unwrap_or_default() + .split('\n') + .filter_map(|value| { + if value.is_empty() { + None + } else { + match NumericEntry::deserialize(value) { + Ok(value) => Some(value), + Err(err) => { + log::error!("{err}"); + errors = true; + None + } + } + } + }) + .collect(); + + if errors { + Err("Parsing numeric entry errors".to_string()) + } else { + Ok(data) + } + } + + fn log_metric_definition(&mut self, definition: MetricDefinition) { + self.metric_definitions + .insert(definition.metric_id.clone(), definition); + } + + fn log_epoch_summary(&mut self, _summary: EpochSummary) { + if !self.is_eval { + self.loggers.clear(); + } + } +} + +fn logger_key(name: &str, split: &Split) -> String { + match split { + Split::Train | Split::Valid => format!("{name}_{split}"), + Split::Test(tag) => { + if let Some(t) = tag { + format!("{name}_{split}_{}", *t) + } else { + format!("{name}_{split}") + } + } + } +} + +/// In memory metric logger, useful when testing and debugging. +#[derive(Default)] +pub struct InMemoryMetricLogger { + values: HashMap>, + last_epoch: Option, + metric_definitions: HashMap, +} + +impl InMemoryMetricLogger { + /// Create a new in-memory metric logger. + pub fn new() -> Self { + Self::default() + } +} + +impl MetricLogger for InMemoryMetricLogger { + fn log(&mut self, update: MetricsUpdate, epoch: usize, split: &Split) { + if self.last_epoch != Some(epoch) { + self.values + .values_mut() + .for_each(|loggers| loggers.push(InMemoryLogger::default())); + self.last_epoch = Some(epoch); + } + + let entries: Vec<_> = update + .entries + .iter() + .chain( + update + .entries_numeric + .iter() + .map(|numeric_update| &numeric_update.entry), + ) + .cloned() + .collect(); + + for item in entries.iter() { + let name = &self.metric_definitions.get(&item.metric_id).unwrap().name; + let key = logger_key(name, split); + + if !self.values.contains_key(&key) { + self.values + .insert(key.to_string(), vec![InMemoryLogger::default()]); + } + + let values = self.values.get_mut(&key).unwrap(); + + values + .last_mut() + .unwrap() + .log(item.serialized_entry.serialized.clone()); + } + } + + fn read_numeric( + &mut self, + name: &str, + epoch: usize, + split: &Split, + ) -> Result, String> { + let key = logger_key(name, split); + let values = match self.values.get(&key) { + Some(values) => values, + None => return Ok(Vec::new()), + }; + + match values.get(epoch - 1) { + Some(logger) => Ok(logger + .values + .iter() + .filter_map(|value| NumericEntry::deserialize(value).ok()) + .collect()), + None => Ok(Vec::new()), + } + } + + fn log_metric_definition(&mut self, definition: MetricDefinition) { + self.metric_definitions + .insert(definition.metric_id.clone(), definition); + } + + fn log_epoch_summary(&mut self, _summary: EpochSummary) {} +} diff --git a/crates/burn-train/src/logger/mod.rs b/crates/burn-train/src/logger/mod.rs new file mode 100644 index 0000000..1d41ced --- /dev/null +++ b/crates/burn-train/src/logger/mod.rs @@ -0,0 +1,13 @@ +mod async_logger; +mod base; +mod file; +mod in_memory; +mod metric; +mod progress; + +pub use async_logger::*; +pub use base::*; +pub use file::*; +pub use in_memory::*; +pub use metric::*; +pub use progress::*; diff --git a/crates/burn-train/src/logger/progress.rs b/crates/burn-train/src/logger/progress.rs new file mode 100644 index 0000000..a2f2a8d --- /dev/null +++ b/crates/burn-train/src/logger/progress.rs @@ -0,0 +1,110 @@ +use burn_core::data::dataloader::Progress; + +/// Trait for logging training progress at each step and end of epoch. +/// +/// # Call sequence +/// +/// Implementors can expect the following sequence of calls for a complete training run: +/// +/// ```text +/// start(total_epochs, total_items) +/// for each epoch: +/// start_split("train", total_items_train) +/// update_split(items_processed) // called once per batch +/// ... +/// end_split() +/// start_split("valid", total_items_valid) +/// update_split(items_processed) // called once per batch +/// ... +/// end_split() +/// update_epoch(epoch) +/// end() +/// ``` +/// +/// `end()` is called whether training completes normally or is interrupted early. +/// +/// Implementors are responsible for tracking `total_items` and epoch state in order +/// to reconstruct the full progress picture when `update_split` is called. +pub trait TrainingProgressLogger: Send { + /// Called once at the start of training, providing the total number of epochs. + /// + /// The total number of items of the training can optionally be provided if it is known. + fn start(&mut self, total_epochs: usize, starting_epoch: usize, total_items: Option); + + /// Called at the end of each full epoch (after both train and valid splits complete). + fn update_epoch(&mut self, epoch: usize); + + /// Called at the start of a training split, providing the split name and total number of items. + fn start_split(&mut self, split: &str, total_items: usize); + + /// Log the progress of the current training step. + fn update_split(&mut self, items_processed: usize); + + /// Called at the end of a training split. + fn end_split(&mut self); + + /// Called at the end of training, whether it completed successfully or was interrupted. + fn end(&mut self); + + /// Log a custom named event that falls outside the standard training lifecycle callbacks. + fn log_event_training(&mut self, event: String); +} + +/// Trait for logging evaluation progress at each step and end of evaluation. +/// +/// # Call sequence +/// +/// Implementors can expect the following sequence of calls for a complete evaluation run: +/// +/// ```text +/// start_global_progress(total_tests) +/// for each test split: +/// start_test(name, total_items) +/// update_test_progress(items_processed) // called once per batch +/// ... +/// end_test() +/// end_global_progress() +/// ``` +/// +/// `end_global_progress()` is called whether evaluation completes normally or is interrupted early. +/// +/// Implementors are responsible for tracking `total_tests` and `total_items` (stored from +/// `start_global_progress` and `start_test`) to reconstruct the full progress picture. +pub trait EvaluationProgressLogger: Send { + /// Called once at the start of evaluation, providing the total number of test splits. + fn start_global_progress(&mut self, total_tests: usize); + + /// Called at the start of a test split, providing the split name and total number of items. + fn start_test(&mut self, name: &str, total_items: usize); + + /// Log the progress of the current test step. + fn update_test_progress(&mut self, items_processed: usize); + + /// Called at the end of a test split. + fn end_test(&mut self); + + /// Called at the end of evaluation. + fn end_global_progress(&mut self); + + /// Log a custom named event that falls outside the standard evaluation lifecycle callbacks. + fn log_event_evaluation(&mut self, event: String); +} + +/// Two-level progress snapshot combining run-level and phase-level tracking. +/// +/// `global_progress` spans the full training run (e.g., epochs completed out of total), +/// while `split_progress` tracks the current phase (e.g., batches within the current epoch). +#[derive(Debug, Clone)] +pub struct ProgressSnapshot { + /// Progress across the entire training run. + pub global: Progress, + /// Progress within the current phase (epoch or evaluation split). + pub split: Progress, +} + +impl ProgressSnapshot { + /// Create a new overall progress snapshot. + pub fn new(global: Progress, split: Progress) -> Self { + Self { global, split } + } +} diff --git a/crates/burn-train/src/metric/acc.rs b/crates/burn-train/src/metric/acc.rs new file mode 100644 index 0000000..68fc751 --- /dev/null +++ b/crates/burn-train/src/metric/acc.rs @@ -0,0 +1,151 @@ +use super::MetricMetadata; +use super::state::{FormatOptions, NumericMetricState}; +use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, SerializedEntry}; +use burn_core::tensor::{Int, Tensor}; + +/// The accuracy metric. +#[derive(Clone)] +pub struct AccuracyMetric { + name: MetricName, + state: NumericMetricState, + pad_token: Option, +} + +/// The [accuracy metric](AccuracyMetric) input type. +#[derive(new)] +pub struct AccuracyInput { + outputs: Tensor<2>, + targets: Tensor<1, Int>, +} + +impl Default for AccuracyMetric { + fn default() -> Self { + Self::new() + } +} + +impl AccuracyMetric { + /// Creates the metric. + pub fn new() -> Self { + Self { + name: MetricName::new("Accuracy".to_string()), + state: Default::default(), + pad_token: Default::default(), + } + } + + /// Sets the pad token. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +impl Metric for AccuracyMetric { + type Input = AccuracyInput; + + fn update(&mut self, input: &AccuracyInput, _metadata: &MetricMetadata) -> SerializedEntry { + let targets = input.targets.clone(); + let outputs = input.outputs.clone(); + + let [batch_size, _n_classes] = outputs.dims(); + + let outputs = outputs.argmax(1).reshape([batch_size]); + + let accuracy = match self.pad_token { + Some(pad_token) => { + let mask = targets.clone().equal_elem(pad_token as i64); + let matches = outputs.equal(targets).float().mask_fill(mask.clone(), 0); + let num_pad = mask.float().sum(); + + let acc = matches.sum() / (num_pad.neg() + batch_size as f32); + + acc.into_scalar::() + } + None => outputs.equal(targets).int().sum().into_scalar::() / batch_size as f64, + }; + + self.state.update( + 100.0 * accuracy, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + super::NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for AccuracyMetric { + fn value(&self) -> super::NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> super::NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_accuracy_without_padding() { + let device = Default::default(); + let mut metric = AccuracyMetric::new(); + let input = AccuracyInput::new( + Tensor::from_data( + [ + [0.0, 0.2, 0.8], // 2 + [1.0, 2.0, 0.5], // 1 + [0.4, 0.1, 0.2], // 0 + [0.6, 0.7, 0.2], // 1 + ], + &device, + ), + Tensor::from_data([2, 2, 1, 1], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } + + #[test] + fn test_accuracy_with_padding() { + let device = Default::default(); + let mut metric = AccuracyMetric::new().with_pad_token(3); + let input = AccuracyInput::new( + Tensor::from_data( + [ + [0.0, 0.2, 0.8, 0.0], // 2 + [1.0, 2.0, 0.5, 0.0], // 1 + [0.4, 0.1, 0.2, 0.0], // 0 + [0.6, 0.7, 0.2, 0.0], // 1 + [0.0, 0.1, 0.2, 5.0], // Predicted padding should not count + [0.0, 0.1, 0.2, 0.0], // Error on padding should not count + [0.6, 0.0, 0.2, 0.0], // Error on padding should not count + ], + &device, + ), + Tensor::from_data([2, 2, 1, 1, 3, 3, 3], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } +} diff --git a/crates/burn-train/src/metric/auc_pr.rs b/crates/burn-train/src/metric/auc_pr.rs new file mode 100644 index 0000000..e1744a1 --- /dev/null +++ b/crates/burn-train/src/metric/auc_pr.rs @@ -0,0 +1,310 @@ +use super::MetricMetadata; +use super::state::{FormatOptions, PredictionAccumulatorState}; +use crate::metric::{ + ClassReduction, ConfusionStatsInput, Metric, MetricAttributes, MetricName, Numeric, + NumericAggregation, NumericAttributes, SerializedEntry, +}; +use burn_core::tensor::{Int, Tensor}; +use std::sync::Arc; + +/// The Area Under the Precision-Recall Curve (AUC-PR). +/// +/// Computed as **Average Precision** — `AP = Σ (Rₙ − Rₙ₋₁) · Pₙ` — the +/// standard non-interpolated estimator of the area under the +/// precision-recall curve (equivalent to scikit-learn's +/// `average_precision_score`), not the (biased) trapezoidal integration. +/// +/// Supports binary, multiclass and multi-label classification through a +/// One-vs-Rest decomposition, aggregated with the configured +/// [class reduction](ClassReduction). +#[derive(Clone)] +pub struct AucPrMetric { + name: MetricName, + state: PredictionAccumulatorState, + class_reduction: ClassReduction, +} + +impl Default for AucPrMetric { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl AucPrMetric { + fn new(class_reduction: ClassReduction) -> Self { + let state = Default::default(); + let name = Arc::new(format!("AUC-PR [{:?}]", class_reduction)); + + Self { + state, + class_reduction, + name, + } + } + + /// AUC-PR metric for binary classification. + #[allow(dead_code)] + pub fn binary() -> Self { + Self::new(ClassReduction::default()) + } + + /// AUC-PR metric for multiclass classification. + /// + /// # Arguments + /// + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multiclass(class_reduction: ClassReduction) -> Self { + Self::new(class_reduction) + } + + /// AUC-PR metric for multi-label classification. + /// + /// # Arguments + /// + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multilabel(class_reduction: ClassReduction) -> Self { + Self::new(class_reduction) + } + + /// Per-column Average Precision via the step-wise estimator + /// `AP = (1/P) · Σ_{positives, score desc} (cumulative positives / rank)`. + /// + /// `scores` and `targets` are `[n, c]` (`targets` as 0./1.); a column + /// with no positive (`P = 0`) yields `NaN` (handled by the caller). + fn average_precision(scores: Tensor<2>, targets: Tensor<2>) -> Tensor<1> { + let [n, _c] = scores.dims(); + let device = scores.device(); + + let order = scores.argsort_descending(0); + let sorted_targets = targets.clone().gather(0, order); + + let tp = sorted_targets.clone().cumsum(0); + + let ranks = Tensor::<1, Int>::arange(1..n as i64 + 1, &device) + .float() + .reshape([n, 1]); + + let precision = tp / ranks; + + let p_total = targets.sum_dim(0); + let delta_recall = sorted_targets / p_total; + + (precision * delta_recall) + .sum_dim(0) + .squeeze_dims::<1>(&[0]) + } +} + +impl Metric for AucPrMetric { + type Input = ConfusionStatsInput; + + fn update( + &mut self, + input: &ConfusionStatsInput, + _metadata: &MetricMetadata, + ) -> SerializedEntry { + self.state + .accumulate(input.predictions.clone(), input.targets.clone()); + + // Recompute over the whole epoch: AP is rank-based, not a per-batch mean. + let (predictions, targets) = self.state.tensors(); + let [n, c] = predictions.dims(); + + let (scores, targets) = match self.class_reduction { + ClassReduction::Macro => (predictions, targets.float()), + ClassReduction::Micro => ( + predictions.reshape([n * c, 1]), + targets.float().reshape([n * c, 1]), + ), + }; + + let ap = Self::average_precision(scores, targets); + + let keep = ap + .clone() + .is_nan() + .bool_not() + .argwhere() + .squeeze_dim::<1>(1); + + let metric = if keep.dims()[0] == 0 { + log::warn!( + "AUC-PR is undefined (no class has positive samples in the epoch); reporting \ + 0.5 as a neutral fallback." + ); + 0.5 + } else { + ap.select(0, keep).mean().into_scalar() + }; + + self.state.update( + 100.0 * metric, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + aggregation: NumericAggregation::Last, + } + .into() + } +} + +impl Numeric for AucPrMetric { + fn value(&self) -> super::NumericEntry { + self.state.value() + } + + fn running_value(&self) -> super::NumericEntry { + self.state.value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::ClassReduction::{self, *}; + use burn_core::tensor::{TensorData, Tolerance}; + use rstest::rstest; + + /// Inputs and expected Average Precision computed with an independent + /// reference equivalent to scikit-learn's `average_precision_score` + /// (step-wise `AP = Σ (Rₙ−Rₙ₋₁)·Pₙ`). Scores are distinct so the + /// estimator is unambiguous and matches sklearn exactly. + #[derive(Clone, Copy)] + enum Data { + Binary, + Multiclass, + Multilabel, + } + + fn input(data: Data) -> ConfusionStatsInput { + let dev = Default::default(); + match data { + Data::Binary => ConfusionStatsInput::new( + Tensor::from_data([[0.63], [0.25], [0.71], [0.3], [0.07], [0.66]], &dev), + Tensor::from_data([[0], [1], [0], [0], [0], [0]], &dev), + ), + Data::Multiclass => ConfusionStatsInput::new( + Tensor::from_data( + [ + [0.45, 0.3, 0.36], + [0.83, 0.24, 0.09], + [0.19, 0.39, 0.29], + [0.3, 0.14, 0.46], + [0.73, 0.74, 0.16], + [0.43, 0.37, 0.88], + ], + &dev, + ), + Tensor::from_data( + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + ], + &dev, + ), + ), + Data::Multilabel => ConfusionStatsInput::new( + Tensor::from_data( + [ + [0.1, 0.73, 0.84], + [0.84, 0.74, 0.24], + [0.13, 0.54, 0.54], + [0.49, 0.48, 0.71], + [0.9, 0.17, 0.43], + [0.11, 0.29, 0.23], + ], + &dev, + ), + Tensor::from_data( + [ + [1, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [1, 0, 0], + [1, 1, 0], + ], + &dev, + ), + ), + } + } + + #[rstest] + // Binary is a single column -> Macro == Micro. + #[case::binary_macro(Data::Binary, Macro, 0.2)] + #[case::binary_micro(Data::Binary, Micro, 0.2)] + #[case::multiclass_macro(Data::Multiclass, Macro, 0.6319444444444444)] + #[case::multiclass_micro(Data::Multiclass, Micro, 0.379975579975580)] + #[case::multilabel_macro(Data::Multilabel, Macro, 0.5944444444444444)] + #[case::multilabel_micro(Data::Multilabel, Micro, 0.5918017848017848)] + fn test_auc_pr( + #[case] data: Data, + #[case] class_reduction: ClassReduction, + #[case] expected: f64, + ) { + let mut metric = AucPrMetric::new(class_reduction); + + let _entry = metric.update(&input(data), &MetricMetadata::fake()); + + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()); + } + + #[test] + fn test_auc_pr_accumulates_across_batches() { + let dev = Default::default(); + + // Whole dataset as a single batch. + let mut single = AucPrMetric::binary(); + single.update( + &ConfusionStatsInput::new( + Tensor::from_data([[0.9], [0.4], [0.8], [0.2], [0.6], [0.1]], &dev), + Tensor::from_data([[1], [0], [1], [0], [1], [0]], &dev), + ), + &MetricMetadata::fake(), + ); + + // Same dataset split across two batches. + let mut split = AucPrMetric::binary(); + split.update( + &ConfusionStatsInput::new( + Tensor::from_data([[0.9], [0.4], [0.8]], &dev), + Tensor::from_data([[1], [0], [1]], &dev), + ), + &MetricMetadata::fake(), + ); + split.update( + &ConfusionStatsInput::new( + Tensor::from_data([[0.2], [0.6], [0.1]], &dev), + Tensor::from_data([[0], [1], [0]], &dev), + ), + &MetricMetadata::fake(), + ); + + // Epoch value is independent of batching. + TensorData::from([split.value().current()]).assert_approx_eq::( + &TensorData::from([single.value().current()]), + Tolerance::default(), + ); + } +} diff --git a/crates/burn-train/src/metric/auroc.rs b/crates/burn-train/src/metric/auroc.rs new file mode 100644 index 0000000..ad1f9b0 --- /dev/null +++ b/crates/burn-train/src/metric/auroc.rs @@ -0,0 +1,335 @@ +use core::f64; + +use super::MetricMetadata; +use super::state::{FormatOptions, NumericMetricState}; +use crate::metric::{ + ClassReduction, ConfusionStatsInput, Metric, MetricName, Numeric, SerializedEntry, +}; +use burn_core::tensor::{Bool, Tensor}; +use std::sync::Arc; + +/// The Area Under the Receiver Operating Characteristic Curve (AUROC, also +/// referred to as [ROC AUC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic)). +/// +/// Supports binary, multiclass and multi-label classification through a +/// One-vs-Rest decomposition, aggregated with the configured +/// [class reduction](ClassReduction). +#[derive(Clone)] +pub struct AurocMetric { + name: MetricName, + state: NumericMetricState, + class_reduction: ClassReduction, +} + +impl Default for AurocMetric { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl AurocMetric { + fn new(class_reduction: ClassReduction) -> Self { + let state = Default::default(); + let name = Arc::new(format!("AUROC [{:?}]", class_reduction)); + + Self { + state, + class_reduction, + name, + } + } + + /// AUROC metric for binary classification. + #[allow(dead_code)] + pub fn binary() -> Self { + Self::new(ClassReduction::default()) + } + + /// AUROC metric for multiclass classification. + /// + /// # Arguments + /// + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multiclass(class_reduction: ClassReduction) -> Self { + Self::new(class_reduction) + } + + /// AUROC metric for multi-label classification. + /// + /// # Arguments + /// + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multilabel(class_reduction: ClassReduction) -> Self { + Self::new(class_reduction) + } + + fn pairwise_auc(scores: Tensor<2>, targets: Tensor<2>) -> Tensor<1> { + let [n, c] = scores.dims(); + + let si = scores.clone().reshape([n, 1, c]); + let sj = scores.reshape([1, n, c]); + + let yi = targets.clone().reshape([n, 1, c]); + let yj = targets.reshape([1, n, c]); + + let valid: Tensor<3> = yi * (1.0 - yj); + + let reduce = |t: Tensor<3>| t.sum_dim(0).sum_dim(1).squeeze_dims::<1>(&[0, 1]); + + let num_pairs = reduce(valid.clone()); + let correct_pairs = reduce(si.clone().greater(sj.clone()).float() * valid.clone()); + let tied_pairs = reduce(si.equal(sj).float() * valid); + + (correct_pairs + 0.5 * tied_pairs) / num_pairs + } + + fn compute_auc(&self, predictions: &Tensor<2>, targets: &Tensor<2, Bool>) -> f64 { + let [n, c] = predictions.dims(); + + let (scores, targets) = match self.class_reduction { + ClassReduction::Macro => (predictions.clone(), targets.clone().float()), + ClassReduction::Micro => ( + predictions.clone().reshape([n * c, 1]), + targets.clone().float().reshape([n * c, 1]), + ), + }; + + let auc = Self::pairwise_auc(scores, targets); + + let keep = auc + .clone() + .is_nan() + .bool_not() + .argwhere() + .squeeze_dim::<1>(1); + + if keep.dims()[0] == 0 { + log::warn!( + "AUROC is undefined (no class has both positive and negative samples in the \ + batch); reporting 0.5 (chance level)." + ); + return 0.5; + } + + auc.select(0, keep).mean().into_scalar() + } +} + +impl Metric for AurocMetric { + type Input = ConfusionStatsInput; + + fn update( + &mut self, + input: &ConfusionStatsInput, + _metadata: &MetricMetadata, + ) -> SerializedEntry { + let [sample_size, _] = input.predictions.dims(); + + let metric = self.compute_auc(&input.predictions, &input.targets); + + self.state.update( + 100.0 * metric, + sample_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } +} + +impl Numeric for AurocMetric { + fn value(&self) -> super::NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> super::NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::ClassReduction::{self, *}; + use burn_core::tensor::{TensorData, Tolerance}; + use rstest::rstest; + + /// Inputs and expected AUROC computed with an independent reference + /// equivalent to scikit-learn's `roc_auc_score` (Mann-Whitney U: + /// `(#pos>neg + 0.5·ties) / (P·N)`, One-vs-Rest, macro/micro). Scores + /// are distinct so the statistic is unambiguous and matches sklearn. + #[derive(Clone, Copy)] + enum Data { + Binary, + Multiclass, + Multilabel, + } + + fn input(data: Data) -> ConfusionStatsInput { + let dev = Default::default(); + match data { + Data::Binary => ConfusionStatsInput::new( + Tensor::from_data([[0.34], [0.64], [0.12], [0.19], [0.53], [0.38]], &dev), + Tensor::from_data([[0], [0], [0], [0], [1], [1]], &dev), + ), + Data::Multiclass => ConfusionStatsInput::new( + Tensor::from_data( + [ + [0.79, 0.41, 0.16], + [0.25, 0.93, 0.78], + [0.61, 0.09, 0.21], + [0.9, 0.31, 0.33], + [0.16, 0.82, 0.57], + [0.57, 0.18, 0.63], + ], + &dev, + ), + Tensor::from_data( + [ + [1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + ], + &dev, + ), + ), + Data::Multilabel => ConfusionStatsInput::new( + Tensor::from_data( + [ + [0.11, 0.57, 0.9], + [0.13, 0.66, 0.37], + [0.71, 0.85, 0.6], + [0.29, 0.69, 0.49], + [0.68, 0.45, 0.25], + [0.33, 0.36, 0.31], + ], + &dev, + ), + Tensor::from_data( + [ + [1, 1, 1], + [0, 0, 1], + [0, 1, 0], + [1, 1, 0], + [0, 1, 1], + [1, 1, 1], + ], + &dev, + ), + ), + } + } + + #[rstest] + // Binary is a single column -> Macro == Micro. + #[case::binary_macro(Data::Binary, Macro, 0.75)] + #[case::binary_micro(Data::Binary, Micro, 0.75)] + #[case::multiclass_macro(Data::Multiclass, Macro, 0.5666666666666667)] + #[case::multiclass_micro(Data::Multiclass, Micro, 0.6458333333333333)] + #[case::multilabel_macro(Data::Multilabel, Macro, 0.2907407407407407)] + #[case::multilabel_micro(Data::Multilabel, Micro, 0.3611111111111111)] + fn test_auroc( + #[case] data: Data, + #[case] class_reduction: ClassReduction, + #[case] expected: f64, + ) { + let mut metric = AurocMetric::new(class_reduction); + + let _entry = metric.update(&input(data), &MetricMetadata::fake()); + + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()); + } + + #[rstest] + #[case::macro_reduction(Macro)] + #[case::micro_reduction(Micro)] + fn test_auroc_perfect_separation(#[case] class_reduction: ClassReduction) { + let device = Default::default(); + let mut metric = AurocMetric::new(class_reduction); + + let input = ConfusionStatsInput::new( + Tensor::from_data([[0.0, 1.0], [1.0, 0.0], [1.0, 0.0], [0.0, 1.0]], &device), + Tensor::from_data([[0, 1], [1, 0], [1, 0], [0, 1]], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(metric.value().current(), 100.0); + } + + #[rstest] + #[case::macro_reduction(Macro)] + #[case::micro_reduction(Micro)] + fn test_auroc_chance_level(#[case] class_reduction: ClassReduction) { + let device = Default::default(); + let mut metric = AurocMetric::new(class_reduction); + + // All scores tied -> every pair is a tie -> AUROC = 0.5. + let input = ConfusionStatsInput::new( + Tensor::from_data([[0.5, 0.5], [0.5, 0.5], [0.5, 0.5], [0.5, 0.5]], &device), + Tensor::from_data([[0, 1], [1, 0], [1, 0], [0, 1]], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(metric.value().current(), 50.0); + } + + #[test] + fn test_auroc_macro_drops_degenerate_class() { + let device = Default::default(); + let mut metric = AurocMetric::new(Macro); + + // Class 2 never appears (column all-negative) -> its AUROC is undefined + // and must be dropped, leaving the two well-separated classes at 1.0. + let input = ConfusionStatsInput::new( + Tensor::from_data( + [ + [0.9, 0.1, 0.0], + [0.2, 0.8, 0.0], + [0.7, 0.3, 0.0], + [0.1, 0.6, 0.0], + ], + &device, + ), + Tensor::from_data([[1, 0, 0], [0, 1, 0], [1, 0, 0], [0, 1, 0]], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(metric.value().current(), 100.0); + } + + #[test] + fn test_auroc_all_degenerate_is_chance() { + let device = Default::default(); + let mut metric = AurocMetric::binary(); + + // Only positives -> no valid pair in any column -> undefined -> + // reported as chance level (0.5). + let input = ConfusionStatsInput::new( + Tensor::from_data([[0.9], [0.8], [0.7], [0.6]], &device), + Tensor::from_data([[1], [1], [1], [1]], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(metric.value().current(), 50.0); + } + + #[test] + fn test_auroc_reduction_changes_name() { + let macro_metric = AurocMetric::new(Macro); + let micro_metric = AurocMetric::new(Micro); + + assert_ne!(macro_metric.name(), micro_metric.name()); + } +} diff --git a/crates/burn-train/src/metric/base.rs b/crates/burn-train/src/metric/base.rs new file mode 100644 index 0000000..064399c --- /dev/null +++ b/crates/burn-train/src/metric/base.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +use burn_core::data::dataloader::Progress; +use burn_optim::lr_scheduler::module_lr_scheduler::ModuleLearningRate; + +/// Metric metadata that can be used when computing metrics. +pub struct MetricMetadata { + /// The current progress. + pub progress: Progress, + + /// The current iteration. + pub iteration: Option, + + /// The current learning rate. + pub lr: Option, +} + +impl MetricMetadata { + /// Fake metric metadata + #[cfg(test)] + pub fn fake() -> Self { + Self { + progress: Progress { + items_processed: 1, + items_total: 1, + unit: Some("items".to_string()), + }, + iteration: Some(0), + lr: None, + } + } +} + +/// Metric id that can be used to compare metrics and retrieve entries of the same metric. +/// For now we take the name as id to make sure that the same metric has the same id across different runs. +#[derive(Debug, Clone, new, PartialEq, Eq, Hash)] +pub struct MetricId { + /// The metric id. + id: Arc, +} + +/// Metric attributes define the properties intrinsic to different types of metric. +#[derive(Clone, Debug)] +pub enum MetricAttributes { + /// Numeric attributes. + Numeric(NumericAttributes), + /// No attributes. + None, +} + +/// Definition of a metric. +#[derive(Clone, Debug)] +pub struct MetricDefinition { + /// The metric's id. + pub metric_id: MetricId, + /// The name of the metric. + pub name: String, + /// The description of the metric. + pub description: Option, + /// The attributes of the metric. + pub attributes: MetricAttributes, +} + +impl MetricDefinition { + /// Create a new metric definition given the metric and a unique id. + pub fn new(metric_id: MetricId, metric: &Me) -> Self { + Self { + metric_id, + name: metric.name().to_string(), + description: metric.description(), + attributes: metric.attributes(), + } + } +} + +/// Metric trait. +/// +/// # Notes +/// +/// Implementations should define their own input type only used by the metric. +/// This is important since some conflict may happen when the model output is adapted for each +/// metric's input type. +pub trait Metric: Send + Sync + Clone { + /// The input type of the metric. + type Input; + + /// The parameterized name of the metric. + /// + /// This should be unique, so avoid using short generic names, prefer using the long name. + /// + /// For a metric that can exist at different parameters (e.g., top-k accuracy for different + /// values of k), the name should be unique for each instance. + fn name(&self) -> MetricName; + + /// A short description of the metric. + fn description(&self) -> Option { + None + } + + /// Attributes of the metric. + /// + /// By default, metrics have no attributes. + fn attributes(&self) -> MetricAttributes { + MetricAttributes::None + } + + /// Update the metric state and returns the current metric entry. + fn update(&mut self, item: &Self::Input, metadata: &MetricMetadata) -> SerializedEntry; + + /// Clear the metric state. + fn clear(&mut self); +} + +/// Type used to store metric names efficiently. +pub type MetricName = Arc; + +/// Adaptor are used to transform types so that they can be used by metrics. +/// +/// This should be implemented by a model's output type for all [metric inputs](Metric::Input) that are +/// registered with the specific learning paradigm (i.e. [SupervisedTraining](crate::SupervisedTraining)). +pub trait Adaptor { + /// Adapt the type to be passed to a [metric](Metric). + fn adapt(&self) -> T; +} + +impl Adaptor<()> for T { + fn adapt(&self) {} +} + +/// How a numeric metric's per-batch values are reduced into an epoch value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NumericAggregation { + /// Sample-weighted mean of the per-batch values. + #[default] + Mean, + /// Last logged value, already computed over the whole epoch. + Last, +} + +/// Attributes that describe intrinsic properties of a numeric metric. +#[derive(Clone, Debug)] +pub struct NumericAttributes { + /// Optional unit (e.g. "%", "ms", "pixels") + pub unit: Option, + /// Whether larger values are better (true) or smaller are better (false). + pub higher_is_better: bool, + /// How per-batch values are reduced into the epoch value. + pub aggregation: NumericAggregation, +} + +impl From for MetricAttributes { + fn from(attr: NumericAttributes) -> Self { + MetricAttributes::Numeric(attr) + } +} + +impl Default for NumericAttributes { + fn default() -> Self { + Self { + unit: None, + higher_is_better: true, + aggregation: NumericAggregation::default(), + } + } +} + +/// Declare a metric to be numeric. +/// +/// This is useful to plot the values of a metric during training. +pub trait Numeric { + /// Returns the numeric value of the metric. + fn value(&self) -> NumericEntry; + /// Returns the current aggregated value of the metric over the global step (epoch). + fn running_value(&self) -> NumericEntry; +} + +/// Serialized form of a metric entry. +#[derive(Debug, Clone, new)] +pub struct SerializedEntry { + /// The string to be displayed. + pub formatted: String, + /// The string to be saved. + pub serialized: String, +} + +/// Data type that contains the current state of a metric at a given time. +#[derive(Debug, Clone)] +pub struct MetricEntry { + /// Id of the entry's metric. + pub metric_id: MetricId, + /// The serialized form of the entry. + pub serialized_entry: SerializedEntry, +} + +impl MetricEntry { + /// Create a new metric. + pub fn new(metric_id: MetricId, serialized_entry: SerializedEntry) -> Self { + Self { + metric_id, + serialized_entry, + } + } +} + +/// Numeric metric entry. +#[derive(Debug, Clone)] +pub enum NumericEntry { + /// Single numeric value. + Value(f64), + /// Aggregated numeric (value, number of elements). + Aggregated { + /// The aggregated value of all entries. + aggregated_value: f64, + /// The number of entries present in the aggregated value. + count: usize, + }, +} + +impl NumericEntry { + /// Gets the current aggregated value of the metric. + pub fn current(&self) -> f64 { + match self { + NumericEntry::Value(val) => *val, + NumericEntry::Aggregated { + aggregated_value, .. + } => *aggregated_value, + } + } + + /// Returns a String representing the NumericEntry + pub fn serialize(&self) -> String { + match self { + Self::Value(v) => v.to_string(), + Self::Aggregated { + aggregated_value, + count, + } => format!("{aggregated_value},{count}"), + } + } + + /// De-serializes a string representing a NumericEntry and returns a Result containing the corresponding NumericEntry. + pub fn deserialize(entry: &str) -> Result { + // Check for comma separated values + let values = entry.split(',').collect::>(); + let num_values = values.len(); + + if num_values == 1 { + // Numeric value + match values[0].parse::() { + Ok(value) => Ok(NumericEntry::Value(value)), + Err(err) => Err(err.to_string()), + } + } else if num_values == 2 { + // Aggregated numeric (value, number of elements) + let (value, numel) = (values[0], values[1]); + match value.parse::() { + Ok(value) => match numel.parse::() { + Ok(numel) => Ok(NumericEntry::Aggregated { + aggregated_value: value, + count: numel, + }), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } + } else { + Err("Invalid number of values for numeric entry".to_string()) + } + } + + /// Compare this numeric metric's value with another one using the specified direction. + pub fn better_than(&self, other: &NumericEntry, higher_is_better: bool) -> bool { + (self.current() > other.current()) == higher_is_better + } +} + +/// Format a float with the given precision. Will use scientific notation if necessary. +pub fn format_float(float: f64, precision: usize) -> String { + let scientific_notation_threshold = 0.1_f64.powf(precision as f64 - 1.0); + + match scientific_notation_threshold >= float { + true => format!("{float:.precision$e}"), + false => format!("{float:.precision$}"), + } +} diff --git a/crates/burn-train/src/metric/bleu.rs b/crates/burn-train/src/metric/bleu.rs new file mode 100644 index 0000000..f919a50 --- /dev/null +++ b/crates/burn-train/src/metric/bleu.rs @@ -0,0 +1,538 @@ +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{ + Metric, MetricAttributes, MetricName, Numeric, NumericAttributes, NumericEntry, +}; +use burn_core::tensor::{Int, Tensor}; +use std::collections::HashMap; +use std::sync::Arc; + +/// Smoothing method for BLEU score computation. +/// +/// Sentence-level BLEU often produces zero scores when higher-order n-grams +/// have no matches. Smoothing techniques address this by assigning small +/// non-zero values to zero-count precisions. +/// +/// # References +/// +/// Chen & Cherry, "A Systematic Comparison of Smoothing Techniques for +/// Sentence-Level BLEU", WMT 2014. +#[derive(Clone, Debug, Default)] +pub enum BleuSmoothing { + /// No smoothing. Zero precision at any n-gram order produces a zero + /// overall score (standard corpus-level BLEU behaviour). + #[default] + None, + + /// Add a small constant (`epsilon`, default 0.1) to zero-count + /// precisions. Corresponds to method 1 in Chen & Cherry (2014). + AddEpsilon(f64), + + /// Exponential decay: for each n-gram order with zero matches, double a + /// running multiplier `k` (starting at 1 and doubling on every zero) and + /// replace the precision with `1 / (k * total_n)`. Corresponds to + /// method 3 in Chen & Cherry (2014) and the default smoothing in + /// SacreBLEU for sentence-level BLEU. + Exponential, +} + +/// Computes the BLEU (Bilingual Evaluation Understudy) score between predicted +/// and reference token sequences. +/// +/// BLEU measures the quality of machine-translated text by comparing n-gram +/// overlap between the candidate (prediction) and reference sequences. The +/// score combines modified n-gram precision for n = 1..max_n with a brevity +/// penalty that discourages overly short translations. +/// +/// The metric operates on integer token IDs (not raw text), matching the +/// convention used by [`CharErrorRate`](super::CharErrorRate) and +/// [`WordErrorRate`](super::WordErrorRate). +/// +/// # Batch-level scoring +/// +/// Within each batch the metric accumulates n-gram counts across all +/// sentences and computes a single corpus-style BLEU score, following the +/// same pattern CER/WER use for edit-distance aggregation. +/// +/// Epoch-level (running) aggregation averages these batch scores, which is +/// slightly inaccurate compared to true corpus BLEU. Correct corpus-level +/// accumulation would require a custom metric state; a TODO is left for +/// future work. +/// +/// # References +/// +/// Papineni et al., "BLEU: a Method for Automatic Evaluation of Machine +/// Translation", ACL 2002. +/// +/// Chen & Cherry, "A Systematic Comparison of Smoothing Techniques for +/// Sentence-Level BLEU", WMT 2014. +#[derive(Clone)] +pub struct BleuScore { + name: MetricName, + state: NumericMetricState, + max_n: usize, + pad_token: Option, + smoothing: BleuSmoothing, +} + +/// The [BLEU score metric](BleuScore) input type. +#[derive(new)] +pub struct BleuInput { + /// The predicted token sequences (2-D tensor of token indices). + pub outputs: Tensor<2, Int>, + /// The reference token sequences (2-D tensor of token indices). + pub targets: Tensor<2, Int>, +} + +impl Default for BleuScore { + fn default() -> Self { + Self::with_max_n(4) + } +} + +impl BleuScore { + /// Creates a BLEU metric with the given maximum n-gram order. + /// + /// Common values: 1 (BLEU-1), 2 (BLEU-2), 4 (BLEU-4). + /// + /// # Panics + /// + /// Panics if `max_n` is zero. + pub fn with_max_n(max_n: usize) -> Self { + assert!(max_n >= 1, "max_n must be at least 1"); + Self { + name: Arc::new(format!("BLEU-{max_n}")), + state: NumericMetricState::default(), + max_n, + pad_token: None, + smoothing: BleuSmoothing::default(), + } + } + + /// Creates a BLEU-4 metric (the most common configuration). + pub fn new() -> Self { + Self::default() + } + + /// Sets the pad token index. Tokens matching this value are stripped from + /// the right of each sequence before scoring. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } + + /// Sets the smoothing method for handling zero-count n-gram precisions. + /// + /// Smoothing is recommended when evaluating short sentences where + /// higher-order n-gram matches are sparse. + pub fn with_smoothing(mut self, smoothing: BleuSmoothing) -> Self { + self.smoothing = smoothing; + self + } +} + +/// Extracts n-grams of order `n` from a slice and returns their counts. +fn ngram_counts(tokens: &[i32], n: usize) -> HashMap, usize> { + let mut counts = HashMap::new(); + if tokens.len() >= n { + for window in tokens.windows(n) { + *counts.entry(window.to_vec()).or_insert(0) += 1; + } + } + counts +} + +/// Computes corpus-style BLEU score from accumulated n-gram statistics. +/// +/// `clipped_counts[n]` and `total_counts[n]` hold the clipped and total +/// n-gram counts for order `n+1` across all sentences. +/// `candidate_len` and `reference_len` are the total token counts. +/// +/// Returns a value in [0, 100]. +fn corpus_bleu( + clipped_counts: &[usize], + total_counts: &[usize], + candidate_len: usize, + reference_len: usize, + max_n: usize, + smoothing: &BleuSmoothing, +) -> f64 { + if candidate_len == 0 { + return 0.0; + } + + // Brevity penalty + let bp = if candidate_len < reference_len { + (1.0 - reference_len as f64 / candidate_len as f64).exp() + } else { + 1.0 + }; + + // Modified n-gram precisions (log-space) + let mut log_avg = 0.0; + let mut counted_orders = 0; + // Stateful multiplier for exponential smoothing (Chen & Cherry 2014, method 3; + // also used by SacreBLEU). Doubles on every n-gram order with zero matches. + let mut smooth_mult = 1.0_f64; + + for n in 0..max_n { + let total = total_counts[n]; + let clipped = clipped_counts[n]; + + if total == 0 { + // Candidate has no n-grams of this order (too short). + // Standard BLEU: this order contributes 0 to the geometric mean, + // which collapses the entire score to 0. + return 0.0; + } + + let precision = if clipped == 0 { + // Apply smoothing to zero-match precisions. + match smoothing { + BleuSmoothing::None => return 0.0, + BleuSmoothing::AddEpsilon(eps) => *eps / total as f64, + BleuSmoothing::Exponential => { + smooth_mult *= 2.0; + 1.0 / (smooth_mult * total as f64) + } + } + } else { + clipped as f64 / total as f64 + }; + + log_avg += precision.ln(); + counted_orders += 1; + } + + if counted_orders == 0 { + return 0.0; + } + + let score = bp * (log_avg / counted_orders as f64).exp(); + score * 100.0 +} + +impl Metric for BleuScore { + type Input = BleuInput; + + fn update(&mut self, input: &BleuInput, _metadata: &MetricMetadata) -> SerializedEntry { + let outputs = &input.outputs; + let targets = &input.targets; + let [batch_size, seq_len] = targets.dims(); + + let outputs_data = outputs.to_data().iter::().collect::>(); + let targets_data = targets.to_data().iter::().collect::>(); + + let pad_token = self.pad_token.map(|p| p as i32); + + // Accumulate n-gram counts across the batch (corpus-style). + let mut clipped_counts = vec![0usize; self.max_n]; + let mut total_counts = vec![0usize; self.max_n]; + let mut total_candidate_len = 0usize; + let mut total_reference_len = 0usize; + + for i in 0..batch_size { + let start = i * seq_len; + let end = (i + 1) * seq_len; + + let output_seq = &outputs_data[start..end]; + let target_seq = &targets_data[start..end]; + + // Strip right-padding if configured. + let output_seq = match pad_token { + Some(pad) => { + let len = output_seq + .iter() + .position(|&x| x == pad) + .unwrap_or(output_seq.len()); + &output_seq[..len] + } + None => output_seq, + }; + let target_seq = match pad_token { + Some(pad) => { + let len = target_seq + .iter() + .position(|&x| x == pad) + .unwrap_or(target_seq.len()); + &target_seq[..len] + } + None => target_seq, + }; + + total_candidate_len += output_seq.len(); + total_reference_len += target_seq.len(); + + for n in 1..=self.max_n { + let cand_ngrams = ngram_counts(output_seq, n); + let ref_ngrams = ngram_counts(target_seq, n); + + for (ngram, &count) in &cand_ngrams { + let ref_count = ref_ngrams.get(ngram).copied().unwrap_or(0); + clipped_counts[n - 1] += count.min(ref_count); + total_counts[n - 1] += count; + } + } + } + + let value = corpus_bleu( + &clipped_counts, + &total_counts, + total_candidate_len, + total_reference_len, + self.max_n, + &self.smoothing, + ); + + // TODO: Epoch-level aggregation averages batch BLEU scores, which is + // slightly inaccurate compared to true corpus BLEU. Correct + // accumulation would require a custom metric state that tracks raw + // n-gram counts across batches. + self.state.update( + value, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset(); + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for BleuScore { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Perfect match => BLEU = 100 %. + #[test] + fn test_bleu_perfect_match() { + let device = Default::default(); + let mut metric = BleuScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + assert!((metric.value().current() - 100.0).abs() < 1e-6); + } + + /// Completely different sequences => BLEU = 0 %. + #[test] + fn test_bleu_no_match() { + let device = Default::default(); + let mut metric = BleuScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[6, 7, 8, 9, 10]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + assert_eq!(0.0, metric.value().current()); + } + + /// Partial overlap with BLEU-1 (unigram precision only). + #[test] + fn test_bleu1_partial_match() { + let device = Default::default(); + let mut metric = BleuScore::with_max_n(1); + + // 3 out of 5 unigrams match, same length so BP = 1 + let preds = Tensor::from_data([[1, 2, 3, 6, 7]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + // BLEU-1 = 3/5 * 100 = 60.0 + assert!((metric.value().current() - 60.0).abs() < 1e-6); + } + + /// Brevity penalty applied when candidate is shorter than reference. + #[test] + fn test_bleu_brevity_penalty() { + let device = Default::default(); + let pad = 0_i64; + let mut metric = BleuScore::with_max_n(1).with_pad_token(pad as usize); + + // Candidate has 3 tokens, reference has 5 tokens. + // Unigram precision = 3/3 = 1.0, BP = exp(1 - 5/3) ~= 0.5134 + let preds = Tensor::from_data([[1, 2, 3, pad, pad]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + let expected = 100.0 * (1.0 - 5.0 / 3.0_f64).exp(); + assert!((metric.value().current() - expected).abs() < 0.1); + } + + /// With padding, padding tokens should be stripped. + #[test] + fn test_bleu_with_padding() { + let device = Default::default(); + let pad = 99_i64; + let mut metric = BleuScore::new().with_pad_token(pad as usize); + + // Same non-pad content => should be 100% + let preds = Tensor::from_data([[1, 2, 3, 4, 5, pad, pad]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5, pad, pad]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + assert!((metric.value().current() - 100.0).abs() < 1e-6); + } + + /// Batch of two sequences: corpus-style accumulation. + #[test] + fn test_bleu_batch_corpus_style() { + let device = Default::default(); + let mut metric = BleuScore::with_max_n(1); + + // Sequence 1: perfect match [1,2,3,4,5] + // Sequence 2: no match [6,7,8,9,10] vs [11,12,13,14,15] + // Corpus unigram: clipped = 5, total = 10, precision = 0.5 + // BP: candidate_len = 10, ref_len = 10, BP = 1.0 + // BLEU-1 = 50.0 + let preds = Tensor::from_data([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5], [11, 12, 13, 14, 15]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + assert!((metric.value().current() - 50.0).abs() < 1e-6); + } + + /// `clear()` must reset the running statistics. + #[test] + fn test_clear_resets_state() { + let device = Default::default(); + let mut metric = BleuScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + assert!(metric.value().current() > 0.0); + + metric.clear(); + assert!(metric.value().current().is_nan()); + } + + /// BLEU-2 on a partial match verifies bigram counting. + #[test] + fn test_bleu2_bigrams() { + let device = Default::default(); + let mut metric = BleuScore::with_max_n(2); + + // Candidate: [1, 2, 3, 4] Reference: [1, 2, 5, 6] + // Unigrams: candidate {1,2,3,4}, reference {1,2,5,6} + // matches: 1,2 => clipped 2/4 + // Bigrams: candidate {(1,2),(2,3),(3,4)}, reference {(1,2),(2,5),(5,6)} + // matches: (1,2) => clipped 1/3 + // BP = 1.0 (same length) + // BLEU-2 = exp((ln(2/4) + ln(1/3)) / 2) * 100 + let preds = Tensor::from_data([[1, 2, 3, 4]], &device); + let tgts = Tensor::from_data([[1, 2, 5, 6]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + + let expected = 100.0 * ((0.5_f64.ln() + (1.0 / 3.0_f64).ln()) / 2.0).exp(); + assert!((metric.value().current() - expected).abs() < 0.1); + } + + /// BLEU with custom name reflects max_n. + #[test] + fn test_bleu_custom_name() { + let metric = BleuScore::with_max_n(2); + assert_eq!(*metric.name(), "BLEU-2"); + } + + /// Default name is BLEU-4. + #[test] + fn test_bleu_default_name() { + let metric = BleuScore::new(); + assert_eq!(*metric.name(), "BLEU-4"); + } + + /// Short candidate with BLEU-4 returns 0 without smoothing + /// (too few tokens for 4-grams). + #[test] + fn test_bleu_short_candidate_no_smoothing() { + let device = Default::default(); + let pad = 0_i64; + let mut metric = BleuScore::new().with_pad_token(pad as usize); + + // Only 3 tokens — no 4-grams exist, score must be 0. + let preds = Tensor::from_data([[1, 2, 3, pad, pad]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + assert_eq!(0.0, metric.value().current()); + } + + /// Exponential smoothing produces a non-zero score for short candidates. + #[test] + fn test_bleu_exponential_smoothing() { + let device = Default::default(); + let mut metric = BleuScore::with_max_n(2).with_smoothing(BleuSmoothing::Exponential); + + // Unigrams: {1,3,5,7,9} vs {1,2,3,4,5} — clipped = 2/5 + // Bigrams: {(1,3),(3,5),(5,7),(7,9)} vs {(1,2),(2,3),(3,4),(4,5)} — clipped = 0/4 + let preds = Tensor::from_data([[1, 3, 5, 7, 9]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + // Verify without smoothing this is 0. + let mut metric_no_smooth = BleuScore::with_max_n(2); + metric_no_smooth.update( + &BleuInput::new(preds.clone(), tgts.clone()), + &MetricMetadata::fake(), + ); + assert_eq!(0.0, metric_no_smooth.value().current()); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + assert!( + metric.value().current() > 0.0, + "smoothing should produce non-zero score" + ); + } + + /// Add-epsilon smoothing produces a non-zero score for zero-precision orders. + #[test] + fn test_bleu_add_epsilon_smoothing() { + let device = Default::default(); + let mut metric = BleuScore::with_max_n(2).with_smoothing(BleuSmoothing::AddEpsilon(0.1)); + + // Unigrams: {1,3,5,7,9} vs {1,2,3,4,5} — clipped 2, total 5 + // Bigrams: {(1,3),(3,5),(5,7),(7,9)} vs {(1,2),(2,3),(3,4),(4,5)} — clipped 0, total 4 + let preds = Tensor::from_data([[1, 3, 5, 7, 9]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&BleuInput::new(preds, tgts), &MetricMetadata::fake()); + assert!( + metric.value().current() > 0.0, + "epsilon smoothing should produce non-zero score" + ); + } +} diff --git a/crates/burn-train/src/metric/cer.rs b/crates/burn-train/src/metric/cer.rs new file mode 100644 index 0000000..79a0bb5 --- /dev/null +++ b/crates/burn-train/src/metric/cer.rs @@ -0,0 +1,241 @@ +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, NumericEntry}; +use burn_core::tensor::{Int, Tensor}; +use std::sync::Arc; + +/// Computes the edit distance (Levenshtein distance) between two sequences of integers. +/// +/// The edit distance is defined as the minimum number of single-element edits (insertions, +/// deletions, or substitutions) required to change one sequence into the other. This +/// implementation is optimized for space, using only two rows of the dynamic programming table. +/// +pub(crate) fn edit_distance(reference: &[i32], prediction: &[i32]) -> usize { + let mut prev = (0..=prediction.len()).collect::>(); + let mut curr = vec![0; prediction.len() + 1]; + + for (i, &r) in reference.iter().enumerate() { + curr[0] = i + 1; + for (j, &p) in prediction.iter().enumerate() { + curr[j + 1] = if r == p { + prev[j] // no operation needed + } else { + 1 + prev[j].min(prev[j + 1]).min(curr[j]) // substitution, insertion, deletion + }; + } + core::mem::swap(&mut prev, &mut curr); + } + prev[prediction.len()] +} + +/// Character error rate (CER) is defined as the edit distance (e.g. Levenshtein distance) between the predicted +/// and reference character sequences, divided by the total number of characters in the reference. +/// This metric is commonly used in tasks such as speech recognition, OCR, or text generation +/// to quantify how closely the predicted output matches the ground truth at a character level. +/// +#[derive(Clone)] +pub struct CharErrorRate { + name: MetricName, + state: NumericMetricState, + pad_token: Option, +} + +/// The [character error rate metric](CharErrorRate) input type. +#[derive(new)] +pub struct CerInput { + /// The predicted token sequences (as a 2-D tensor of token indices). + pub outputs: Tensor<2, Int>, + /// The target token sequences (as a 2-D tensor of token indices). + pub targets: Tensor<2, Int>, +} + +impl Default for CharErrorRate { + fn default() -> Self { + Self::new() + } +} + +impl CharErrorRate { + /// Creates the metric. + pub fn new() -> Self { + Self { + name: Arc::new("CER".to_string()), + state: NumericMetricState::default(), + pad_token: None, + } + } + + /// Sets the pad token. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +/// The [character error rate metric](CharErrorRate) implementation. +impl Metric for CharErrorRate { + type Input = CerInput; + + fn update(&mut self, input: &CerInput, _metadata: &MetricMetadata) -> SerializedEntry { + let outputs = &input.outputs; + let targets = &input.targets; + let [batch_size, seq_len] = targets.dims(); + + let (output_lengths, target_lengths) = if let Some(pad) = self.pad_token { + // Create boolean masks for non-padding tokens. + let output_mask = outputs.clone().not_equal_elem(pad as i64); + let target_mask = targets.clone().not_equal_elem(pad as i64); + + let output_lengths_tensor = output_mask.int().sum_dim(1); + let target_lengths_tensor = target_mask.int().sum_dim(1); + + ( + output_lengths_tensor + .into_data() + .convert::() + .to_vec() + .unwrap(), + target_lengths_tensor + .into_data() + .convert::() + .to_vec() + .unwrap(), + ) + } else { + // If there's no padding, all sequences have the full length. + ( + vec![seq_len as i32; batch_size], + vec![seq_len as i32; batch_size], + ) + }; + + let outputs_data = outputs.to_data().convert::().to_vec().unwrap(); + let targets_data = targets.to_data().convert::().to_vec().unwrap(); + + let total_edit_distance: usize = (0..batch_size) + .map(|i| { + let start = i * seq_len; + + // Get pre-calculated lengths for the current sequence. + let output_len = output_lengths[i] as usize; + let target_len = target_lengths[i] as usize; + + let output_seq_slice = &outputs_data[start..(start + output_len)]; + let target_seq_slice = &targets_data[start..(start + target_len)]; + + edit_distance(target_seq_slice, output_seq_slice) + }) + .sum(); + + let total_target_length = target_lengths.iter().map(|&x| x as f64).sum::(); + + let value = if total_target_length > 0.0 { + 100.0 * total_edit_distance as f64 / total_target_length + } else { + 0.0 + }; + + self.state.update( + value, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset(); + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + super::NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for CharErrorRate { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Perfect match ⇒ CER = 0 %. + #[test] + fn test_cer_without_padding() { + let device = Default::default(); + let mut metric = CharErrorRate::new(); + + // Batch size = 2, sequence length = 2 + let preds = Tensor::from_data([[1, 2], [3, 4]], &device); + let tgts = Tensor::from_data([[1, 2], [3, 4]], &device); + + metric.update(&CerInput::new(preds, tgts), &MetricMetadata::fake()); + + assert_eq!(0.0, metric.value().current()); + } + + /// Two edits in four target tokens ⇒ 50 %. + #[test] + fn test_cer_without_padding_two_errors() { + let device = Default::default(); + let mut metric = CharErrorRate::new(); + + // One substitution in each sequence. + let preds = Tensor::from_data([[1, 2], [3, 5]], &device); + let tgts = Tensor::from_data([[1, 3], [3, 4]], &device); + + metric.update(&CerInput::new(preds, tgts), &MetricMetadata::fake()); + + // 2 edits / 4 tokens = 50 % + assert_eq!(50.0, metric.value().current()); + } + + /// Same scenario as above, but with right-padding (token 9) ignored. + #[test] + fn test_cer_with_padding() { + let device = Default::default(); + let pad = 9_i64; + let mut metric = CharErrorRate::new().with_pad_token(pad as usize); + + // Each row has three columns, last one is the pad token. + let preds = Tensor::from_data([[1, 2, pad], [3, 5, pad]], &device); + let tgts = Tensor::from_data([[1, 3, pad], [3, 4, pad]], &device); + + metric.update(&CerInput::new(preds, tgts), &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } + + /// `clear()` must reset the running statistics to zero. + #[test] + fn test_clear_resets_state() { + let device = Default::default(); + let mut metric = CharErrorRate::new(); + + let preds = Tensor::from_data([[1, 2]], &device); + let tgts = Tensor::from_data([[1, 3]], &device); // one error + + metric.update( + &CerInput::new(preds.clone(), tgts.clone()), + &MetricMetadata::fake(), + ); + assert!(metric.value().current() > 0.0); + + metric.clear(); + assert!(metric.value().current().is_nan()); + } +} diff --git a/crates/burn-train/src/metric/classification.rs b/crates/burn-train/src/metric/classification.rs new file mode 100644 index 0000000..6d66c63 --- /dev/null +++ b/crates/burn-train/src/metric/classification.rs @@ -0,0 +1,33 @@ +use std::num::NonZeroUsize; + +/// Necessary data for classification metrics. +#[derive(Default, Debug, Clone)] +pub struct ClassificationMetricConfig { + pub decision_rule: DecisionRule, + pub class_reduction: ClassReduction, +} + +/// The prediction decision rule for classification metrics. +#[derive(Debug, Clone)] +pub enum DecisionRule { + /// Consider a class predicted if its probability exceeds the threshold. + Threshold(f64), + /// Consider a class predicted correctly if it is within the top k predicted classes based on scores. + TopK(NonZeroUsize), +} + +impl Default for DecisionRule { + fn default() -> Self { + Self::Threshold(0.5) + } +} + +/// The reduction strategy for classification metrics. +#[derive(Copy, Clone, Default, Debug)] +pub enum ClassReduction { + /// Computes the statistics over all classes before averaging + Micro, + /// Computes the statistics independently for each class before averaging + #[default] + Macro, +} diff --git a/crates/burn-train/src/metric/confusion_stats.rs b/crates/burn-train/src/metric/confusion_stats.rs new file mode 100644 index 0000000..214ddc1 --- /dev/null +++ b/crates/burn-train/src/metric/confusion_stats.rs @@ -0,0 +1,340 @@ +use super::classification::{ClassReduction, ClassificationMetricConfig, DecisionRule}; +use burn_core::{ + prelude::{Bool, Int, Tensor}, + tensor::IndexingUpdateOp, +}; +use std::fmt::{self, Debug}; + +/// Input for confusion statistics error types. +#[derive(new, Debug, Clone)] +pub struct ConfusionStatsInput { + /// Sample x Class Non thresholded normalized predictions. + pub predictions: Tensor<2>, + /// Sample x Class one-hot encoded target. + pub targets: Tensor<2, Bool>, +} + +impl From for (Tensor<2>, Tensor<2, Bool>) { + fn from(input: ConfusionStatsInput) -> Self { + (input.predictions, input.targets) + } +} + +impl From<(Tensor<2>, Tensor<2, Bool>)> for ConfusionStatsInput { + fn from(value: (Tensor<2>, Tensor<2, Bool>)) -> Self { + Self::new(value.0, value.1) + } +} + +#[derive(Clone)] +pub struct ConfusionStats { + confusion_classes: Tensor<2, Int>, + class_reduction: ClassReduction, +} + +impl Debug for ConfusionStats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let to_vec = |tensor_data: Tensor<1>| { + tensor_data + .to_data() + .to_vec::() + .expect("A vector representation of the input Tensor is expected") + }; + let ratio_of_support_vec = + |metric: Tensor<1>| to_vec(self.clone().ratio_of_support(metric)); + f.debug_struct("ConfusionStats") + .field("tp", &ratio_of_support_vec(self.clone().true_positive())) + .field("fp", &ratio_of_support_vec(self.clone().false_positive())) + .field("tn", &ratio_of_support_vec(self.clone().true_negative())) + .field("fn", &ratio_of_support_vec(self.clone().false_negative())) + .field("support", &to_vec(self.clone().support())) + .finish() + } +} + +impl ConfusionStats { + /// Expects `predictions` to be normalized. + pub fn new(input: &ConfusionStatsInput, config: &ClassificationMetricConfig) -> Self { + let prediction_mask = match config.decision_rule { + DecisionRule::Threshold(threshold) => input.predictions.clone().greater_elem(threshold), + DecisionRule::TopK(top_k) => { + let mask = input.predictions.zeros_like(); + let indexes = + input + .predictions + .clone() + .argsort_descending(1) + .narrow(1, 0, top_k.get()); + let values = indexes.ones_like().float(); + mask.scatter(1, indexes, values, IndexingUpdateOp::Add) + .bool() + } + }; + Self { + confusion_classes: prediction_mask.int() + input.targets.clone().int() * 2, + class_reduction: config.class_reduction, + } + } + + /// sum over samples + fn aggregate(sample_class_mask: Tensor<2, Bool>, class_reduction: ClassReduction) -> Tensor<1> { + use ClassReduction::{Macro, Micro}; + match class_reduction { + Micro => sample_class_mask.float().sum(), + Macro => sample_class_mask.float().sum_dim(0).squeeze_dim(0), + } + } + + pub fn true_positive(self) -> Tensor<1> { + Self::aggregate(self.confusion_classes.equal_elem(3), self.class_reduction) + } + + pub fn true_negative(self) -> Tensor<1> { + Self::aggregate(self.confusion_classes.equal_elem(0), self.class_reduction) + } + + pub fn false_positive(self) -> Tensor<1> { + Self::aggregate(self.confusion_classes.equal_elem(1), self.class_reduction) + } + + pub fn false_negative(self) -> Tensor<1> { + Self::aggregate(self.confusion_classes.equal_elem(2), self.class_reduction) + } + + pub fn positive(self) -> Tensor<1> { + self.clone().true_positive() + self.false_negative() + } + + pub fn negative(self) -> Tensor<1> { + self.clone().true_negative() + self.false_positive() + } + + pub fn predicted_positive(self) -> Tensor<1> { + self.clone().true_positive() + self.false_positive() + } + + pub fn support(self) -> Tensor<1> { + self.clone().positive() + self.negative() + } + + pub fn ratio_of_support(self, metric: Tensor<1>) -> Tensor<1> { + metric / self.clone().support() + } +} + +#[cfg(test)] +mod tests { + use super::{ConfusionStats, ConfusionStatsInput}; + use crate::{ + metric::classification::{ClassReduction, ClassificationMetricConfig, DecisionRule}, + tests::{ClassificationType, THRESHOLD, dummy_classification_input}, + }; + use burn_core::prelude::TensorData; + use rstest::{fixture, rstest}; + use std::num::NonZeroUsize; + + fn top_k_config( + top_k: NonZeroUsize, + class_reduction: ClassReduction, + ) -> ClassificationMetricConfig { + ClassificationMetricConfig { + decision_rule: DecisionRule::TopK(top_k), + class_reduction, + } + } + #[fixture] + #[once] + fn top_k_config_k1_micro() -> ClassificationMetricConfig { + top_k_config(NonZeroUsize::new(1).unwrap(), ClassReduction::Micro) + } + + #[fixture] + #[once] + fn top_k_config_k1_macro() -> ClassificationMetricConfig { + top_k_config(NonZeroUsize::new(1).unwrap(), ClassReduction::Macro) + } + #[fixture] + #[once] + fn top_k_config_k2_micro() -> ClassificationMetricConfig { + top_k_config(NonZeroUsize::new(2).unwrap(), ClassReduction::Micro) + } + #[fixture] + #[once] + fn top_k_config_k2_macro() -> ClassificationMetricConfig { + top_k_config(NonZeroUsize::new(2).unwrap(), ClassReduction::Macro) + } + + fn threshold_config( + threshold: f64, + class_reduction: ClassReduction, + ) -> ClassificationMetricConfig { + ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + class_reduction, + } + } + #[fixture] + #[once] + fn threshold_config_micro() -> ClassificationMetricConfig { + threshold_config(THRESHOLD, ClassReduction::Micro) + } + #[fixture] + #[once] + fn threshold_config_macro() -> ClassificationMetricConfig { + threshold_config(THRESHOLD, ClassReduction::Macro) + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [1].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [3].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [1, 1, 1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [4].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [2, 1, 1].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [5].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [2, 2, 1].into())] + fn test_true_positive( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .true_positive() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [2].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [2].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [8].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [2, 3, 3].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [4].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [1, 1, 2].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [3].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [0, 2, 1].into())] + fn test_true_negative( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .true_negative() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [1].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [2].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [1, 1, 0].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [6].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [2, 3, 1].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [3].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [1, 1, 1].into())] + fn test_false_positive( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .false_positive() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [1].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [2].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [1, 0, 1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [1].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [0, 0, 1].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [4].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [2, 0, 2].into())] + fn test_false_negatives( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .false_negative() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [2].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [2].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [5].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [2, 1, 2].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [5].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [2, 1, 2].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [9].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [4, 2, 3].into())] + fn test_positive( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .positive() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [3].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [3].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [10].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [3, 4, 3].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [10].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [3, 4, 3].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [6].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [1, 3, 2].into())] + fn test_negative( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .negative() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } + + #[rstest] + #[case::binary_micro(ClassificationType::Binary, threshold_config_micro(), [2].into())] + #[case::binary_macro(ClassificationType::Binary, threshold_config_macro(), [2].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k1_micro(), [5].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k1_macro(), [2, 2, 1].into())] + #[case::multiclass_micro(ClassificationType::Multiclass, top_k_config_k2_micro(), [10].into())] + #[case::multiclass_macro(ClassificationType::Multiclass, top_k_config_k2_macro(), [4, 4, 2].into())] + #[case::multilabel_micro(ClassificationType::Multilabel, threshold_config_micro(), [8].into())] + #[case::multilabel_macro(ClassificationType::Multilabel, threshold_config_macro(), [3, 3, 2].into())] + fn test_predicted_positive( + #[case] classification_type: ClassificationType, + #[case] config: ClassificationMetricConfig, + #[case] expected: Vec, + ) { + let input: ConfusionStatsInput = dummy_classification_input(&classification_type).into(); + ConfusionStats::new(&input, &config) + .predicted_positive() + .int() + .into_data() + .assert_eq(&TensorData::from(expected.as_slice()), false); + } +} diff --git a/crates/burn-train/src/metric/cpu_temp.rs b/crates/burn-train/src/metric/cpu_temp.rs new file mode 100644 index 0000000..ddc2e65 --- /dev/null +++ b/crates/burn-train/src/metric/cpu_temp.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +/// CPU Temperature metric +use super::MetricMetadata; +use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, NumericEntry, SerializedEntry}; +use systemstat::{Platform, System}; + +/// CPU Temperature in celsius degrees +#[derive(Clone)] +pub struct CpuTemperature { + name: MetricName, + temp_celsius: f32, + sys: Arc, +} + +impl CpuTemperature { + /// Creates a new CPU temp metric + pub fn new() -> Self { + let name = Arc::new("CPU Temperature".to_string()); + + Self { + name, + temp_celsius: 0., + sys: Arc::new(System::new()), + } + } +} + +impl Default for CpuTemperature { + fn default() -> Self { + CpuTemperature::new() + } +} + +impl Metric for CpuTemperature { + type Input = (); + + fn update(&mut self, _item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + match self.sys.cpu_temp() { + Ok(temp) => self.temp_celsius = temp, + Err(_) => self.temp_celsius = f32::NAN, + } + + let formatted = match self.temp_celsius.is_nan() { + true => format!("{}: NaN °C", self.name()), + false => format!("{}: {:.2} °C", self.name(), self.temp_celsius), + }; + let raw = format!("{:.2}", self.temp_celsius); + + SerializedEntry::new(formatted, raw) + } + + fn clear(&mut self) {} + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + super::NumericAttributes { + unit: Some("°C".to_string()), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for CpuTemperature { + fn value(&self) -> NumericEntry { + NumericEntry::Value(self.temp_celsius as f64) + } + + fn running_value(&self) -> NumericEntry { + NumericEntry::Value(self.temp_celsius as f64) + } +} diff --git a/crates/burn-train/src/metric/cpu_use.rs b/crates/burn-train/src/metric/cpu_use.rs new file mode 100644 index 0000000..693a4fb --- /dev/null +++ b/crates/burn-train/src/metric/cpu_use.rs @@ -0,0 +1,104 @@ +use super::MetricMetadata; +use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, NumericEntry, SerializedEntry}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use sysinfo::{CpuRefreshKind, RefreshKind, System}; + +/// General CPU Usage metric +pub struct CpuUse { + name: MetricName, + last_refresh: Instant, + refresh_frequency: Duration, + sys: System, + current: f64, +} + +impl Clone for CpuUse { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + last_refresh: self.last_refresh, + refresh_frequency: self.refresh_frequency, + sys: System::new(), + current: self.current, + } + } +} + +impl CpuUse { + /// Creates a new CPU metric + pub fn new() -> Self { + let mut sys = System::new(); + let current = Self::refresh(&mut sys); + let name = "CPU Usage".to_string(); + + Self { + name: Arc::new(name), + last_refresh: Instant::now(), + refresh_frequency: Duration::from_millis(200), + sys, + current, + } + } + + fn refresh(sys: &mut System) -> f64 { + sys.refresh_specifics( + RefreshKind::nothing().with_cpu(CpuRefreshKind::nothing().with_cpu_usage()), + ); + + let cpus = sys.cpus(); + let num_cpus = cpus.len(); + let use_percentage = cpus.iter().fold(0.0, |acc, cpu| acc + cpu.cpu_usage()) as f64; + + use_percentage / num_cpus as f64 + } +} + +impl Default for CpuUse { + fn default() -> Self { + CpuUse::new() + } +} + +impl Metric for CpuUse { + type Input = (); + + fn update(&mut self, _item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + if self.last_refresh.elapsed() >= self.refresh_frequency { + self.current = Self::refresh(&mut self.sys); + self.last_refresh = Instant::now(); + } + + let formatted = format!("{}: {:.2} %", self.name(), self.current); + let raw = format!("{:.2}", self.current); + + SerializedEntry::new(formatted, raw) + } + + fn clear(&mut self) {} + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + super::NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for CpuUse { + fn value(&self) -> NumericEntry { + NumericEntry::Value(self.current) + } + + fn running_value(&self) -> NumericEntry { + NumericEntry::Value(self.current) + } +} diff --git a/crates/burn-train/src/metric/cuda.rs b/crates/burn-train/src/metric/cuda.rs new file mode 100644 index 0000000..3bbcf08 --- /dev/null +++ b/crates/burn-train/src/metric/cuda.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use super::MetricMetadata; +use crate::metric::{Metric, MetricName, SerializedEntry}; +use nvml_wrapper::Nvml; + +/// Track basic cuda infos. +#[derive(Clone)] +pub struct CudaMetric { + name: MetricName, + nvml: Arc>, +} + +impl CudaMetric { + /// Creates a new metric for CUDA. + pub fn new() -> Self { + Self { + name: Arc::new("Cuda".to_string()), + nvml: Arc::new(Nvml::init().map(Some).unwrap_or_else(|err| { + log::warn!("Unable to initialize CUDA Metric: {err}"); + None + })), + } + } +} + +impl Default for CudaMetric { + fn default() -> Self { + Self::new() + } +} + +impl Metric for CudaMetric { + type Input = (); + + fn update(&mut self, _item: &(), _metadata: &MetricMetadata) -> SerializedEntry { + let not_available = + || SerializedEntry::new("Unavailable".to_string(), "Unavailable".to_string()); + + let available = |nvml: &Nvml| { + let mut formatted = String::new(); + let mut raw_running = String::new(); + + let device_count = match nvml.device_count() { + Ok(val) => val, + Err(err) => { + log::warn!("Unable to get the number of cuda devices: {err}"); + return not_available(); + } + }; + + for index in 0..device_count { + let device = match nvml.device_by_index(index) { + Ok(val) => val, + Err(err) => { + log::warn!("Unable to get device {index}: {err}"); + return not_available(); + } + }; + let memory_info = match device.memory_info() { + Ok(info) => info, + Err(err) => { + log::warn!("Unable to get memory info from device {index}: {err}"); + return not_available(); + } + }; + + let used_gb = memory_info.used as f64 * 1e-9; + let total_gb = memory_info.total as f64 * 1e-9; + + let memory_info_formatted = format!("{used_gb:.2}/{total_gb:.2} Gb"); + let memory_info_raw = format!("{used_gb}/{total_gb}"); + + formatted = format!("{formatted} GPU #{index} - Memory {memory_info_formatted}"); + raw_running = format!("{memory_info_raw} "); + + let utilization_rates = match device.utilization_rates() { + Ok(rate) => rate, + Err(err) => { + log::warn!("Unable to get utilization rates from device {index}: {err}"); + return not_available(); + } + }; + let utilization_rate_formatted = format!("{}%", utilization_rates.gpu); + formatted = format!("{formatted} - Usage {utilization_rate_formatted}"); + + // Power is the currency for perf/W. NVML reports milliwatts. + if let Ok(power_mw) = device.power_usage() { + let power_w = power_mw as f64 / 1000.0; + formatted = format!("{formatted} - Power {power_w:.1} W"); + } + } + + SerializedEntry::new(formatted, raw_running) + }; + + match self.nvml.as_ref() { + Some(nvml) => available(nvml), + None => not_available(), + } + } + + fn clear(&mut self) {} + + fn name(&self) -> MetricName { + self.name.clone() + } +} diff --git a/crates/burn-train/src/metric/fbetascore.rs b/crates/burn-train/src/metric/fbetascore.rs new file mode 100644 index 0000000..1602206 --- /dev/null +++ b/crates/burn-train/src/metric/fbetascore.rs @@ -0,0 +1,245 @@ +use crate::metric::{MetricName, Numeric}; + +use super::{ + Metric, MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, SerializedEntry, + classification::{ClassReduction, ClassificationMetricConfig, DecisionRule}, + confusion_stats::{ConfusionStats, ConfusionStatsInput}, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::prelude::Tensor; +use std::{num::NonZeroUsize, sync::Arc}; + +/// The [F-beta score](https://en.wikipedia.org/wiki/F-score) metric. +/// +/// The `beta` parameter represents the ratio of recall importance to precision importance. +/// `beta > 1` gives more weight to recall, while `beta < 1` favors precision. +#[derive(Clone)] +pub struct FBetaScoreMetric { + name: MetricName, + state: NumericMetricState, + config: ClassificationMetricConfig, + beta: f64, +} + +impl Default for FBetaScoreMetric { + fn default() -> Self { + Self::new(Default::default(), Default::default()) + } +} + +impl FBetaScoreMetric { + #[allow(dead_code)] + fn new(config: ClassificationMetricConfig, beta: f64) -> Self { + let name = Arc::new(format!( + "FBetaScore ({}) @ {:?} [{:?}]", + beta, config.decision_rule, config.class_reduction + )); + Self { + name, + config, + beta, + state: Default::default(), + } + } + + /// F-beta score metric for binary classification. + /// + /// # Arguments + /// + /// * `beta` - Positive real factor to weight recall's importance. + /// * `threshold` - The threshold to transform a probability into a binary prediction. + #[allow(dead_code)] + pub fn binary(beta: f64, threshold: f64) -> Self { + Self::new( + ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + // binary classification results are the same independently of class_reduction + ..Default::default() + }, + beta, + ) + } + + /// F-beta score metric for multiclass classification. + /// + /// # Arguments + /// + /// * `beta` - Positive real factor to weight recall's importance. + /// * `top_k` - The number of highest predictions considered to find the correct label (typically `1`). + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multiclass(beta: f64, top_k: usize, class_reduction: ClassReduction) -> Self { + Self::new( + ClassificationMetricConfig { + decision_rule: DecisionRule::TopK( + NonZeroUsize::new(top_k).expect("top_k must be non-zero"), + ), + class_reduction, + }, + beta, + ) + } + + /// F-beta score metric for multi-label classification. + /// + /// # Arguments + /// + /// * `beta` - Positive real factor to weight recall's importance. + /// * `threshold` - The threshold to transform a probability into a binary prediction. + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multilabel(beta: f64, threshold: f64, class_reduction: ClassReduction) -> Self { + Self::new( + ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + class_reduction, + }, + beta, + ) + } + + fn class_average(&self, mut aggregated_metric: Tensor<1>) -> f64 { + use ClassReduction::{Macro, Micro}; + let avg_tensor = match self.config.class_reduction { + Micro => aggregated_metric, + Macro => { + if aggregated_metric.clone().contains_nan().any().into_scalar() { + let nan_mask = aggregated_metric.clone().is_nan(); + aggregated_metric = aggregated_metric + .clone() + .select(0, nan_mask.bool_not().argwhere().squeeze_dim(1)) + } + aggregated_metric.mean() + } + }; + avg_tensor.into_scalar() + } +} + +impl Metric for FBetaScoreMetric { + type Input = ConfusionStatsInput; + + fn update(&mut self, input: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let [sample_size, _] = input.predictions.dims(); + + let cf_stats = ConfusionStats::new(input, &self.config); + let scaled_true_positive = cf_stats.clone().true_positive() * (1.0 + self.beta.powi(2)); + let metric = self.class_average( + scaled_true_positive.clone() + / (scaled_true_positive + + cf_stats.clone().false_negative() * self.beta.powi(2) + + cf_stats.false_positive()), + ); + + self.state.update( + 100.0 * metric, + sample_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for FBetaScoreMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::{ + ClassReduction::{self, *}, + FBetaScoreMetric, Metric, MetricMetadata, + }; + use crate::metric::Numeric; + use crate::tests::{ClassificationType, THRESHOLD, dummy_classification_input}; + use burn_core::tensor::TensorData; + use burn_core::tensor::Tolerance; + use rstest::rstest; + + #[rstest] + #[case::binary_b1(1.0, THRESHOLD, 0.5)] + #[case::binary_b2(2.0, THRESHOLD, 0.5)] + fn test_binary_fscore(#[case] beta: f64, #[case] threshold: f64, #[case] expected: f64) { + let input = dummy_classification_input(&ClassificationType::Binary).into(); + let mut metric = FBetaScoreMetric::binary(beta, threshold); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multiclass_b1_micro_k1(1.0, Micro, 1, 3.0/5.0)] + #[case::multiclass_b1_micro_k2(1.0, Micro, 2, 2.0/(5.0/4.0 + 10.0/4.0))] + #[case::multiclass_b1_macro_k1(1.0, Macro, 1, (0.5 + 2.0/(1.0 + 2.0) + 2.0/(2.0 + 1.0))/3.0)] + #[case::multiclass_b1_macro_k2(1.0, Macro, 2, (2.0/(1.0 + 2.0) + 2.0/(1.0 + 4.0) + 0.5)/3.0)] + #[case::multiclass_b2_micro_k1(2.0, Micro, 1, 3.0/5.0)] + #[case::multiclass_b2_micro_k2(2.0, Micro, 2, 5.0*4.0/(4.0*5.0 + 10.0))] + #[case::multiclass_b2_macro_k1(2.0, Macro, 1, (0.5 + 5.0/(4.0 + 2.0) + 5.0/(8.0 + 1.0))/3.0)] + #[case::multiclass_b2_macro_k2(2.0, Macro, 2, (5.0/(4.0 + 2.0) + 5.0/(4.0 + 4.0) + 0.5)/3.0)] + fn test_multiclass_fscore( + #[case] beta: f64, + #[case] class_reduction: ClassReduction, + #[case] top_k: usize, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multiclass).into(); + let mut metric = FBetaScoreMetric::multiclass(beta, top_k, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multilabel_micro(1.0, Micro, THRESHOLD, 2.0/(9.0/5.0 + 8.0/5.0))] + #[case::multilabel_macro(1.0, Macro, THRESHOLD, (2.0/(2.0 + 3.0/2.0) + 2.0/(1.0 + 3.0/2.0) + 2.0/(3.0+2.0))/3.0)] + #[case::multilabel_micro(2.0, Micro, THRESHOLD, 5.0/(4.0*9.0/5.0 + 8.0/5.0))] + #[case::multilabel_macro(2.0, Macro, THRESHOLD, (5.0/(8.0 + 3.0/2.0) + 5.0/(4.0 + 3.0/2.0) + 5.0/(12.0+2.0))/3.0)] + fn test_multilabel_fscore( + #[case] beta: f64, + #[case] class_reduction: ClassReduction, + #[case] threshold: f64, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multilabel).into(); + let mut metric = FBetaScoreMetric::multilabel(beta, threshold, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[test] + fn test_parameterized_unique_name() { + let metric_a = FBetaScoreMetric::multiclass(0.5, 1, ClassReduction::Macro); + let metric_b = FBetaScoreMetric::multiclass(0.5, 2, ClassReduction::Macro); + let metric_c = FBetaScoreMetric::multiclass(0.5, 1, ClassReduction::Macro); + + assert_ne!(metric_a.name(), metric_b.name()); + assert_eq!(metric_a.name(), metric_c.name()); + + let metric_a = FBetaScoreMetric::binary(0.5, 0.5); + let metric_b = FBetaScoreMetric::binary(0.75, 0.5); + assert_ne!(metric_a.name(), metric_b.name()); + } +} diff --git a/crates/burn-train/src/metric/hamming.rs b/crates/burn-train/src/metric/hamming.rs new file mode 100644 index 0000000..31a472b --- /dev/null +++ b/crates/burn-train/src/metric/hamming.rs @@ -0,0 +1,190 @@ +use std::sync::Arc; + +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{ + Metric, MetricAttributes, MetricName, Numeric, NumericAttributes, NumericEntry, +}; +use burn_core::tensor::{Int, Tensor, activation::sigmoid}; + +/// The hamming score, sometimes referred to as multi-label or label-based accuracy. +#[derive(Clone)] +pub struct HammingScore { + name: MetricName, + state: NumericMetricState, + threshold: f32, + sigmoid: bool, +} + +/// The [hamming score](HammingScore) input type. +#[derive(new)] +pub struct HammingScoreInput { + outputs: Tensor<2>, + targets: Tensor<2, Int>, +} + +impl HammingScore { + /// Creates the metric. + pub fn new() -> Self { + Self::default() + } + + fn update_name(&mut self) { + self.name = Arc::new(format!("Hamming Score @ Threshold({})", self.threshold)); + } + + /// Sets the threshold. + pub fn with_threshold(mut self, threshold: f32) -> Self { + self.threshold = threshold; + self.update_name(); + self + } + + /// Sets the sigmoid activation function usage. + pub fn with_sigmoid(mut self, sigmoid: bool) -> Self { + self.sigmoid = sigmoid; + self.update_name(); + self + } +} + +impl Default for HammingScore { + /// Creates a new metric instance with default values. + fn default() -> Self { + let threshold = 0.5; + let name = Arc::new(format!("Hamming Score @ Threshold({})", threshold)); + + Self { + name, + state: NumericMetricState::default(), + threshold, + sigmoid: false, + } + } +} + +impl Metric for HammingScore { + type Input = HammingScoreInput; + + fn update(&mut self, input: &HammingScoreInput, _metadata: &MetricMetadata) -> SerializedEntry { + let [batch_size, _n_classes] = input.outputs.dims(); + + let targets = input.targets.clone(); + + let mut outputs = input.outputs.clone(); + + if self.sigmoid { + outputs = sigmoid(outputs); + } + + let score = outputs + .greater_elem(self.threshold) + .equal(targets.bool()) + .float() + .mean() + .into_scalar::(); + + self.state.update( + 100.0 * score, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for HammingScore { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hamming_score() { + let device = Default::default(); + let mut metric = HammingScore::new(); + + let x = Tensor::from_data( + [ + [0.32, 0.52, 0.38, 0.68, 0.61], // with x > 0.5: [0, 1, 0, 1, 1] + [0.43, 0.31, 0.21, 0.63, 0.53], // [0, 0, 0, 1, 1] + [0.44, 0.25, 0.71, 0.39, 0.73], // [0, 0, 1, 0, 1] + [0.49, 0.37, 0.68, 0.39, 0.31], // [0, 0, 1, 0, 0] + ], + &device, + ); + let y = Tensor::from_data( + [ + [0, 1, 0, 1, 1], + [0, 0, 0, 1, 1], + [0, 0, 1, 0, 1], + [0, 0, 1, 0, 0], + ], + &device, + ); + + let _entry = metric.update( + &HammingScoreInput::new(x.clone(), y.clone()), + &MetricMetadata::fake(), + ); + assert_eq!(100.0, metric.value().current()); + + // Invert all targets: y = (1 - y) + let y = y.neg().add_scalar(1); + let _entry = metric.update( + &HammingScoreInput::new(x.clone(), y), // invert targets (1 - y) + &MetricMetadata::fake(), + ); + assert_eq!(0.0, metric.value().current()); + + // Invert 5 target values -> 1 - (5/20) = 0.75 + let y = Tensor::from_data( + [ + [0, 1, 1, 0, 1], + [0, 0, 0, 0, 1], + [0, 0, 0, 0, 1], + [0, 1, 1, 0, 0], + ], + &device, + ); + let _entry = metric.update( + &HammingScoreInput::new(x, y), // invert targets (1 - y) + &MetricMetadata::fake(), + ); + assert_eq!(75.0, metric.value().current()); + } + + #[test] + fn test_parameterized_unique_name() { + let metric_a = HammingScore::new().with_threshold(0.5); + let metric_b = HammingScore::new().with_threshold(0.75); + let metric_c = HammingScore::new().with_threshold(0.5); + + assert_ne!(metric_a.name(), metric_b.name()); + assert_eq!(metric_a.name(), metric_c.name()); + } +} diff --git a/crates/burn-train/src/metric/iteration.rs b/crates/burn-train/src/metric/iteration.rs new file mode 100644 index 0000000..eec8cb9 --- /dev/null +++ b/crates/burn-train/src/metric/iteration.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use super::MetricMetadata; +use super::SerializedEntry; +use super::state::FormatOptions; +use super::state::NumericMetricState; +use crate::metric::MetricName; +use crate::metric::Numeric; +use crate::metric::{Metric, MetricAttributes, NumericAttributes, NumericEntry}; + +/// The loss metric. +#[derive(Clone)] +pub struct IterationSpeedMetric { + name: MetricName, + state: NumericMetricState, + instant: Option, +} + +impl Default for IterationSpeedMetric { + fn default() -> Self { + Self::new() + } +} + +impl IterationSpeedMetric { + /// Create the metric. + pub fn new() -> Self { + Self { + name: Arc::new("Iteration Speed".to_string()), + state: Default::default(), + instant: Default::default(), + } + } +} + +impl Metric for IterationSpeedMetric { + type Input = (); + + fn update(&mut self, _: &Self::Input, metadata: &MetricMetadata) -> SerializedEntry { + let raw = match self.instant { + Some(val) => { + // If iteration is not logged, compute the speed over the number of items processed. + // 1 iteration should equal 1 item when iteration is not logged. + metadata + .iteration + .unwrap_or(metadata.progress.items_processed) as f64 + / val.elapsed().as_secs_f64() + } + None => { + self.instant = Some(std::time::Instant::now()); + 0.0 + } + }; + + self.state.update( + raw, + 1, + FormatOptions::new(self.name()) + .unit("iter/sec") + .precision(2), + ) + } + + fn clear(&mut self) { + self.instant = None; + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("iter/sec".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for IterationSpeedMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/learning_rate.rs b/crates/burn-train/src/metric/learning_rate.rs new file mode 100644 index 0000000..5809e83 --- /dev/null +++ b/crates/burn-train/src/metric/learning_rate.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use super::{ + MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, + state::{FormatOptions, NumericMetricState}, +}; +use crate::metric::{Metric, MetricName, Numeric, SerializedEntry}; + +/// Track the learning rate across iterations. +#[derive(Clone)] +pub struct LearningRateMetric { + name: MetricName, + state: NumericMetricState, +} + +impl LearningRateMetric { + /// Creates a new learning rate metric. + pub fn new() -> Self { + Self { + name: Arc::new("Learning Rate".to_string()), + state: NumericMetricState::new(), + } + } +} + +impl Default for LearningRateMetric { + fn default() -> Self { + Self::new() + } +} + +impl Metric for LearningRateMetric { + type Input = (); + + fn update(&mut self, _item: &(), metadata: &MetricMetadata) -> SerializedEntry { + // TODO: We only log the default learning rate. Yet another motivation to introduce metric groups. + let lr = metadata.lr.as_ref().map(|val| val.base()).unwrap_or(0.0); + + self.state + .update(lr, 1, FormatOptions::new(self.name()).precision(2)) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for LearningRateMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/loss.rs b/crates/burn-train/src/metric/loss.rs new file mode 100644 index 0000000..a0b2ef4 --- /dev/null +++ b/crates/burn-train/src/metric/loss.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use super::MetricMetadata; +use super::SerializedEntry; +use super::state::FormatOptions; +use super::state::NumericMetricState; +use crate::metric::MetricName; +use crate::metric::{Metric, MetricAttributes, Numeric, NumericAttributes, NumericEntry}; +use burn_core::tensor::Tensor; + +/// The loss metric. +#[derive(Clone)] +pub struct LossMetric { + name: Arc, + state: NumericMetricState, +} + +/// The [loss metric](LossMetric) input type. +#[derive(new)] +pub struct LossInput { + tensor: Tensor<1>, +} + +impl Default for LossMetric { + fn default() -> Self { + Self::new() + } +} + +impl LossMetric { + /// Create the metric. + pub fn new() -> Self { + Self { + name: Arc::new("Loss".to_string()), + state: NumericMetricState::default(), + } + } +} + +impl Metric for LossMetric { + type Input = LossInput; + + fn update(&mut self, loss: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let [batch_size] = loss.tensor.dims(); + let loss = loss + .tensor + .clone() + .mean() + .into_data() + .iter::() + .next() + .unwrap(); + + self.state.update( + loss, + batch_size, + FormatOptions::new(self.name()).precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for LossMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/memory_use.rs b/crates/burn-train/src/metric/memory_use.rs new file mode 100644 index 0000000..8b4b781 --- /dev/null +++ b/crates/burn-train/src/metric/memory_use.rs @@ -0,0 +1,112 @@ +/// RAM use metric +use super::{MetricAttributes, MetricMetadata, NumericAttributes}; +use crate::metric::{Metric, Numeric, NumericEntry, SerializedEntry}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use sysinfo::System; + +/// Memory information +pub struct CpuMemory { + name: Arc, + last_refresh: Instant, + refresh_frequency: Duration, + sys: System, + ram_bytes_total: u64, + ram_bytes_used: u64, +} + +impl Clone for CpuMemory { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + last_refresh: self.last_refresh, + refresh_frequency: self.refresh_frequency, + sys: System::new(), + ram_bytes_total: self.ram_bytes_total, + ram_bytes_used: self.ram_bytes_used, + } + } +} + +impl CpuMemory { + /// Creates a new memory metric + pub fn new() -> Self { + let mut metric = Self { + name: Arc::new("CPU Memory".into()), + last_refresh: Instant::now(), + refresh_frequency: Duration::from_millis(200), + sys: System::new(), + ram_bytes_total: 0, + ram_bytes_used: 0, + }; + metric.refresh(); + metric + } + + fn refresh(&mut self) { + self.sys.refresh_memory(); + self.last_refresh = Instant::now(); + + // bytes of RAM available + self.ram_bytes_total = self.sys.total_memory(); + + // bytes of RAM in use + self.ram_bytes_used = self.sys.used_memory(); + } +} + +impl Default for CpuMemory { + fn default() -> Self { + CpuMemory::new() + } +} + +impl Metric for CpuMemory { + type Input = (); + + fn update(&mut self, _item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + if self.last_refresh.elapsed() >= self.refresh_frequency { + self.refresh(); + } + + let raw = bytes2gb(self.ram_bytes_used); + let formatted = format!( + "RAM Used: {:.2} / {:.2} Gb", + raw, + bytes2gb(self.ram_bytes_total), + ); + + SerializedEntry::new(formatted, raw.to_string()) + } + + fn clear(&mut self) {} + + fn name(&self) -> Arc { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("Gb".to_string()), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for CpuMemory { + fn value(&self) -> NumericEntry { + NumericEntry::Value(bytes2gb(self.ram_bytes_used)) + } + + fn running_value(&self) -> NumericEntry { + NumericEntry::Value(bytes2gb(self.ram_bytes_used)) + } +} + +fn bytes2gb(bytes: u64) -> f64 { + bytes as f64 / 1e9 +} diff --git a/crates/burn-train/src/metric/mod.rs b/crates/burn-train/src/metric/mod.rs new file mode 100644 index 0000000..ca8c574 --- /dev/null +++ b/crates/burn-train/src/metric/mod.rs @@ -0,0 +1,77 @@ +/// State module. +pub mod state; +/// Module responsible to save and exposes data collected during training. +pub mod store; +/// Metrics module for vision tasks. +#[cfg(feature = "vision")] +pub mod vision; + +//Metrics for reinforcement learning. +#[cfg(feature = "rl")] +mod rl; +#[cfg(feature = "rl")] +pub use rl::*; + +// System metrics +#[cfg(feature = "sys-metrics")] +mod cpu_temp; +#[cfg(feature = "sys-metrics")] +mod cpu_use; +#[cfg(feature = "sys-metrics")] +mod cuda; +#[cfg(feature = "sys-metrics")] +mod memory_use; +#[cfg(feature = "sys-metrics")] +pub use cpu_temp::*; +#[cfg(feature = "sys-metrics")] +pub use cpu_use::*; +#[cfg(feature = "sys-metrics")] +pub use cuda::*; +#[cfg(feature = "sys-metrics")] +pub use memory_use::*; + +// Training metrics +mod acc; +mod auc_pr; +mod auroc; +mod base; +mod bleu; +mod cer; +mod confusion_stats; +mod fbetascore; +mod hamming; +mod iteration; +mod learning_rate; +mod loss; +mod perplexity; +mod precision; +mod recall; +mod rouge; +mod top_k_acc; +mod wer; + +pub use acc::*; +pub use auc_pr::*; +pub use auroc::*; +pub use base::*; +pub use bleu::*; +pub use cer::*; +pub use confusion_stats::ConfusionStatsInput; +pub use fbetascore::*; +pub use hamming::*; +pub use iteration::*; +pub use learning_rate::*; +pub use loss::*; +pub use perplexity::*; +pub use precision::*; +pub use recall::*; +pub use rouge::*; +pub use top_k_acc::*; +pub use wer::*; + +pub(crate) mod classification; +pub(crate) mod processor; + +pub use crate::metric::classification::ClassReduction; +// Expose `ItemLazy` so it can be implemented for custom types +pub use processor::ItemLazy; diff --git a/crates/burn-train/src/metric/perplexity.rs b/crates/burn-train/src/metric/perplexity.rs new file mode 100644 index 0000000..78ac96a --- /dev/null +++ b/crates/burn-train/src/metric/perplexity.rs @@ -0,0 +1,429 @@ +use super::state::FormatOptions; +use super::{MetricMetadata, NumericEntry, SerializedEntry, format_float}; +use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, NumericAttributes}; +use burn_core::tensor::{Int, Tensor}; + +/// Custom state for perplexity metric that correctly accumulates negative log-likelihood. +/// +/// Unlike other metrics that can be averaged, perplexity requires special handling: +/// - Accumulate total negative log-likelihood across all tokens +/// - Accumulate total number of effective tokens +/// - Compute perplexity as exp(total_nll / total_tokens) only at the end +#[derive(Clone)] +struct PerplexityState { + /// Sum of negative log-likelihood across all tokens + sum_nll: f64, + /// Total number of effective tokens (excluding padding) + total_tokens: usize, + /// Current batch perplexity (for display purposes) + current: f64, +} + +impl PerplexityState { + fn new() -> Self { + Self { + sum_nll: 0.0, + total_tokens: 0, + current: f64::NAN, + } + } + + fn reset(&mut self) { + self.sum_nll = 0.0; + self.total_tokens = 0; + self.current = f64::NAN; + } + + /// Update state with negative log-likelihood and token count from current batch + fn update( + &mut self, + sum_log_prob: f64, + effective_tokens: usize, + format: FormatOptions, + ) -> SerializedEntry { + // sum_log_prob is already the sum of log probabilities (negative values) + // We need to negate it to get negative log-likelihood + let batch_nll = -sum_log_prob; + + // Accumulate across batches + self.sum_nll += batch_nll; + self.total_tokens += effective_tokens; + + // Compute current batch perplexity for display + let batch_perplexity = if effective_tokens > 0 { + (batch_nll / effective_tokens as f64).exp() + } else { + f64::INFINITY + }; + self.current = batch_perplexity; + + // Compute running epoch perplexity + let epoch_perplexity = if self.total_tokens > 0 { + (self.sum_nll / self.total_tokens as f64).exp() + } else { + f64::INFINITY + }; + + // Format for display + let (formatted_current, formatted_running) = match format.precision_value() { + Some(precision) => ( + format_float(batch_perplexity, precision), + format_float(epoch_perplexity, precision), + ), + None => (format!("{batch_perplexity}"), format!("{epoch_perplexity}")), + }; + + let formatted = match format.unit_value() { + Some(unit) => { + format!("epoch {formatted_running} {unit} - batch {formatted_current} {unit}") + } + None => format!("epoch {formatted_running} - batch {formatted_current}"), + }; + + // Serialize the state for aggregation + let serialized = NumericEntry::Aggregated { + aggregated_value: epoch_perplexity, + count: self.total_tokens, + } + .serialize(); + + SerializedEntry::new(formatted, serialized) + } + + fn value(&self) -> NumericEntry { + let perplexity = if self.total_tokens > 0 { + (self.sum_nll / self.total_tokens as f64).exp() + } else { + f64::INFINITY + }; + + NumericEntry::Aggregated { + aggregated_value: perplexity, + count: self.total_tokens, + } + } + + fn running_value(&self) -> NumericEntry { + self.value() + } +} + +/// The perplexity metric. +/// +/// Perplexity is a measure of how well a probability distribution or probability model +/// predicts a sample. It's commonly used to evaluate language models. A lower perplexity +/// indicates that the model is more confident in its predictions. +/// +/// Mathematically, perplexity is defined as the exponentiation of the cross-entropy loss: +/// PPL = exp(H(p, q)) = exp(-1/N * Σ log(p(x_i))) +/// +/// where: +/// - H(p, q) is the cross-entropy between the true distribution p and predicted distribution q +/// - N is the number of tokens +/// - p(x_i) is the predicted probability of the i-th token +/// +/// # Aggregation +/// Unlike other metrics, perplexity cannot be simply averaged across batches. +/// This implementation correctly accumulates the total negative log-likelihood and +/// total token count across batches, then computes perplexity as exp(total_nll / total_tokens). +#[derive(Clone)] +pub struct PerplexityMetric { + name: MetricName, + state: PerplexityState, + pad_token: Option, +} + +/// The [perplexity metric](PerplexityMetric) input type. +#[derive(new)] +pub struct PerplexityInput { + /// Logits tensor of shape [batch_size * sequence_length, vocab_size] + outputs: Tensor<2>, + /// Target tokens tensor of shape [batch_size * sequence_length] + targets: Tensor<1, Int>, +} + +impl Default for PerplexityMetric { + fn default() -> Self { + Self::new() + } +} + +impl PerplexityMetric { + /// Creates the metric. + pub fn new() -> Self { + Self { + name: MetricName::new("Perplexity".to_string()), + state: PerplexityState::new(), + pad_token: Default::default(), + } + } + + /// Sets the pad token to exclude from perplexity calculation. + /// + /// When a pad token is set, predictions for padding tokens are masked out + /// and do not contribute to the perplexity calculation. This is important + /// for variable-length sequences where padding is used. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +impl Metric for PerplexityMetric { + type Input = PerplexityInput; + + fn update(&mut self, input: &PerplexityInput, _metadata: &MetricMetadata) -> SerializedEntry { + let targets = input.targets.clone(); + let outputs = input.outputs.clone(); + + let [total_tokens, _vocab_size] = outputs.dims(); + + // Convert logits to log probabilities using log_softmax for numerical stability + let log_probs = burn_core::tensor::activation::log_softmax(outputs, 1); + + // Gather the log probabilities for the target tokens + let target_log_probs = log_probs + .gather(1, targets.clone().unsqueeze_dim(1)) + .squeeze_dim(1); + + let (sum_log_prob, effective_tokens) = match self.pad_token { + Some(pad_token) => { + // Create a mask for non-padding tokens + let mask = targets.clone().not_equal_elem(pad_token as i64); + + // Apply mask to log probabilities (set padding log probs to 0) + let masked_log_probs = target_log_probs.mask_fill(mask.clone().bool_not(), 0.0); + + // Sum the log probabilities and count effective tokens + let sum_log_prob = masked_log_probs.sum().into_scalar::(); + let effective_tokens = mask.int().sum().into_scalar::() as usize; + + (sum_log_prob, effective_tokens) + } + None => { + // No padding, use all tokens + let sum_log_prob = target_log_probs.sum().into_scalar::(); + (sum_log_prob, total_tokens) + } + }; + + // Pass the sum_log_prob and effective_tokens to the state + // The state will handle the correct accumulation and perplexity calculation + self.state.update( + sum_log_prob, + effective_tokens, + FormatOptions::new(self.name()).precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for PerplexityMetric { + fn value(&self) -> NumericEntry { + self.state.value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_perplexity_perfect_prediction() { + let device = Default::default(); + let mut metric = PerplexityMetric::new(); + + // Perfect prediction: target is always the highest probability class + let input = PerplexityInput::new( + Tensor::from_data( + [ + [10.0, 0.0, 0.0], // Very confident prediction for class 0 + [0.0, 10.0, 0.0], // Very confident prediction for class 1 + [0.0, 0.0, 10.0], // Very confident prediction for class 2 + ], + &device, + ), + Tensor::from_data([0, 1, 2], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + let perplexity = metric.value().current(); + + // Perfect predictions should result in very low perplexity (close to 1.0) + assert!( + perplexity < 1.1, + "Perfect predictions should have low perplexity, got {}", + perplexity + ); + } + + #[test] + fn test_perplexity_uniform_prediction() { + let device = Default::default(); + let mut metric = PerplexityMetric::new(); + + // Uniform prediction: all classes have equal probability + let input = PerplexityInput::new( + Tensor::from_data( + [ + [0.0, 0.0, 0.0], // Uniform distribution (after softmax) + [0.0, 0.0, 0.0], // Uniform distribution (after softmax) + [0.0, 0.0, 0.0], // Uniform distribution (after softmax) + ], + &device, + ), + Tensor::from_data([0, 1, 2], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + let perplexity = metric.value().current(); + + // Uniform distribution over 3 classes should have perplexity ≈ 3.0 + assert!( + (perplexity - 3.0).abs() < 0.1, + "Uniform distribution perplexity should be ~3.0, got {}", + perplexity + ); + } + + #[test] + fn test_perplexity_with_padding() { + let device = Default::default(); + let mut metric = PerplexityMetric::new().with_pad_token(3); + + let input = PerplexityInput::new( + Tensor::from_data( + [ + [10.0, 0.0, 0.0, 0.0], // Good prediction for class 0 + [0.0, 10.0, 0.0, 0.0], // Good prediction for class 1 + [0.0, 0.0, 0.0, 1.0], // This is padding - should be ignored + [0.0, 0.0, 0.0, 1.0], // This is padding - should be ignored + ], + &device, + ), + Tensor::from_data([0, 1, 3, 3], &device), // 3 is pad token + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + let perplexity = metric.value().current(); + + // Should only consider the first two predictions, both of which are confident + assert!( + perplexity < 1.1, + "Good predictions with padding should have low perplexity, got {}", + perplexity + ); + } + + #[test] + fn test_perplexity_wrong_prediction() { + let device = Default::default(); + let mut metric = PerplexityMetric::new(); + + // Wrong predictions: target class has very low probability + let input = PerplexityInput::new( + Tensor::from_data( + [ + [0.0, 10.0, 0.0], // Predicts class 1, but target is 0 + [10.0, 0.0, 0.0], // Predicts class 0, but target is 1 + [0.0, 0.0, 10.0], // Predicts class 2, but target is 0 + ], + &device, + ), + Tensor::from_data([0, 1, 0], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + let perplexity = metric.value().current(); + + // Wrong predictions should result in high perplexity + assert!( + perplexity > 10.0, + "Wrong predictions should have high perplexity, got {}", + perplexity + ); + } + + #[test] + fn test_perplexity_multi_batch_aggregation() { + let device = Default::default(); + let mut metric = PerplexityMetric::new(); + + // First batch: 2 tokens with uniform distribution (log_prob ≈ -1.0986 each) + let input1 = PerplexityInput::new( + Tensor::from_data( + [ + [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986) + [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986) + ], + &device, + ), + Tensor::from_data([0, 1], &device), + ); + + // Second batch: 1 token with uniform distribution + let input2 = PerplexityInput::new( + Tensor::from_data( + [ + [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986) + ], + &device, + ), + Tensor::from_data([2], &device), + ); + + // Update with both batches + let _entry1 = metric.update(&input1, &MetricMetadata::fake()); + let _entry2 = metric.update(&input2, &MetricMetadata::fake()); + + let aggregated_perplexity = metric.value().current(); + + // For uniform distribution over 3 classes: log_prob ≈ -log(3) ≈ -1.0986 + // Total negative log-likelihood: 3 * 1.0986 ≈ 3.2958 + // Total tokens: 3 + // Expected perplexity: exp(3.2958 / 3) = exp(1.0986) ≈ 3.0 + assert!( + (aggregated_perplexity - 3.0).abs() < 0.1, + "Multi-batch aggregated perplexity should be ~3.0, got {}", + aggregated_perplexity + ); + + // Compare with single batch containing all data + let mut single_batch_metric = PerplexityMetric::new(); + let single_input = PerplexityInput::new( + Tensor::from_data([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], &device), + Tensor::from_data([0, 1, 2], &device), + ); + + let _single_entry = single_batch_metric.update(&single_input, &MetricMetadata::fake()); + let single_batch_perplexity = single_batch_metric.value().current(); + + // Multi-batch and single-batch should give the same result + assert!( + (aggregated_perplexity - single_batch_perplexity).abs() < 0.01, + "Multi-batch ({}) and single-batch ({}) perplexity should match", + aggregated_perplexity, + single_batch_perplexity + ); + } +} diff --git a/crates/burn-train/src/metric/precision.rs b/crates/burn-train/src/metric/precision.rs new file mode 100644 index 0000000..6eef8d4 --- /dev/null +++ b/crates/burn-train/src/metric/precision.rs @@ -0,0 +1,217 @@ +use crate::metric::{MetricName, Numeric}; + +use super::{ + Metric, MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, SerializedEntry, + classification::{ClassReduction, ClassificationMetricConfig, DecisionRule}, + confusion_stats::{ConfusionStats, ConfusionStatsInput}, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::prelude::Tensor; +use std::{num::NonZeroUsize, sync::Arc}; + +/// The Precision Metric +#[derive(Clone)] +pub struct PrecisionMetric { + name: MetricName, + state: NumericMetricState, + config: ClassificationMetricConfig, +} + +impl Default for PrecisionMetric { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl PrecisionMetric { + fn new(config: ClassificationMetricConfig) -> Self { + let state = Default::default(); + let name = Arc::new(format!( + "Precision @ {:?} [{:?}]", + config.decision_rule, config.class_reduction + )); + + Self { + state, + config, + name, + } + } + /// Precision metric for binary classification. + /// + /// # Arguments + /// + /// * `threshold` - The threshold to transform a probability into a binary prediction. + #[allow(dead_code)] + pub fn binary(threshold: f64) -> Self { + Self::new(ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + // binary classification results are the same independently of class_reduction + ..Default::default() + }) + } + + /// Precision metric for multiclass classification. + /// + /// # Arguments + /// + /// * `top_k` - The number of highest predictions considered to find the correct label (typically `1`). + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multiclass(top_k: usize, class_reduction: ClassReduction) -> Self { + Self::new(ClassificationMetricConfig { + decision_rule: DecisionRule::TopK( + NonZeroUsize::new(top_k).expect("top_k must be non-zero"), + ), + class_reduction, + }) + } + + /// Precision metric for multi-label classification. + /// + /// # Arguments + /// + /// * `threshold` - The threshold to transform a probability into a binary value. + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multilabel(threshold: f64, class_reduction: ClassReduction) -> Self { + Self { + config: ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + class_reduction, + }, + ..Default::default() + } + } + + fn class_average(&self, mut aggregated_metric: Tensor<1>) -> f64 { + use ClassReduction::{Macro, Micro}; + let avg_tensor = match self.config.class_reduction { + Micro => aggregated_metric, + Macro => { + if aggregated_metric.clone().contains_nan().any().into_scalar() { + let nan_mask = aggregated_metric.clone().is_nan(); + aggregated_metric = aggregated_metric + .clone() + .select(0, nan_mask.bool_not().argwhere().squeeze_dim(1)) + } + aggregated_metric.mean() + } + }; + avg_tensor.into_scalar() + } +} + +impl Metric for PrecisionMetric { + type Input = ConfusionStatsInput; + + fn update(&mut self, input: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let [sample_size, _] = input.predictions.dims(); + + let cf_stats = ConfusionStats::new(input, &self.config); + let metric = + self.class_average(cf_stats.clone().true_positive() / cf_stats.predicted_positive()); + + self.state.update( + 100.0 * metric, + sample_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for PrecisionMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::{ + ClassReduction::{self, *}, + Metric, MetricMetadata, PrecisionMetric, + }; + use crate::metric::Numeric; + use crate::tests::{ClassificationType, THRESHOLD, dummy_classification_input}; + use burn_core::tensor::TensorData; + use burn_core::tensor::Tolerance; + use rstest::rstest; + + #[rstest] + #[case::binary(THRESHOLD, 0.5)] + fn test_binary_precision(#[case] threshold: f64, #[case] expected: f64) { + let input = dummy_classification_input(&ClassificationType::Binary).into(); + let mut metric = PrecisionMetric::binary(threshold); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multiclass_micro_k1(Micro, 1, 3.0/5.0)] + #[case::multiclass_micro_k2(Micro, 2, 4.0/10.0)] + #[case::multiclass_macro_k1(Macro, 1, (0.5 + 0.5 + 1.0)/3.0)] + #[case::multiclass_macro_k2(Macro, 2, (0.5 + 1.0/4.0 + 0.5)/3.0)] + fn test_multiclass_precision( + #[case] class_reduction: ClassReduction, + #[case] top_k: usize, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multiclass).into(); + let mut metric = PrecisionMetric::multiclass(top_k, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multilabel_micro(Micro, THRESHOLD, 5.0/8.0)] + #[case::multilabel_macro(Macro, THRESHOLD, (2.0/3.0 + 2.0/3.0 + 0.5)/3.0)] + fn test_multilabel_precision( + #[case] class_reduction: ClassReduction, + #[case] threshold: f64, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multilabel).into(); + let mut metric = PrecisionMetric::multilabel(threshold, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[test] + fn test_parameterized_unique_name() { + let metric_a = PrecisionMetric::multiclass(1, ClassReduction::Macro); + let metric_b = PrecisionMetric::multiclass(2, ClassReduction::Macro); + let metric_c = PrecisionMetric::multiclass(1, ClassReduction::Macro); + + assert_ne!(metric_a.name(), metric_b.name()); + assert_eq!(metric_a.name(), metric_c.name()); + + let metric_a = PrecisionMetric::binary(0.5); + let metric_b = PrecisionMetric::binary(0.75); + assert_ne!(metric_a.name(), metric_b.name()); + } +} diff --git a/crates/burn-train/src/metric/processor/async_wrapper.rs b/crates/burn-train/src/metric/processor/async_wrapper.rs new file mode 100644 index 0000000..4802c1c --- /dev/null +++ b/crates/burn-train/src/metric/processor/async_wrapper.rs @@ -0,0 +1,142 @@ +use crate::metric::processor::{EvaluatorEvent, EventProcessorEvaluation}; + +use super::EventProcessorTraining; +use async_channel::{Receiver, Sender}; + +/// Event processor for the training process. +pub struct AsyncProcessorTraining { + sender: Sender>, +} + +/// Event processor for the model evaluation. +pub struct AsyncProcessorEvaluation { + sender: Sender>, +} + +struct WorkerTraining> { + processor: P, + rec: Receiver>, +} + +struct WorkerEvaluation { + processor: P, + rec: Receiver>, +} + +impl + 'static> + WorkerTraining +{ + pub fn start(processor: P, rec: Receiver>) { + let mut worker = Self { processor, rec }; + std::thread::Builder::new() + .name("train-worker".into()) + .spawn(move || { + while let Ok(msg) = worker.rec.recv_blocking() { + match msg { + Message::Train(event) => worker.processor.process_train(event), + Message::Valid(event) => worker.processor.process_valid(event), + Message::Renderer(callback) => { + callback.send_blocking(worker.processor.renderer()).unwrap(); + return; + } + } + } + }) + .unwrap(); + } +} +impl WorkerEvaluation

{ + pub fn start(processor: P, rec: Receiver>) { + let mut worker = Self { processor, rec }; + + std::thread::Builder::new() + .name("evel-worker".into()) + .spawn(move || { + while let Ok(event) = worker.rec.recv_blocking() { + match event { + EvalMessage::Test(event) => worker.processor.process_test(event), + EvalMessage::Renderer(sender) => { + sender.send_blocking(worker.processor.renderer()).unwrap(); + return; + } + } + } + }) + .unwrap(); + } +} + +impl AsyncProcessorTraining { + /// Create an event processor for training. + pub fn new + 'static>(processor: P) -> Self { + let (sender, rec) = async_channel::bounded(1); + + WorkerTraining::start(processor, rec); + + Self { sender } + } +} + +impl AsyncProcessorEvaluation

{ + /// Create an event processor for model evaluation. + pub fn new(processor: P) -> Self { + let (sender, rec) = async_channel::bounded(1); + + WorkerEvaluation::start(processor, rec); + + Self { sender } + } +} + +enum Message { + Train(EventTrain), + Valid(EventValid), + Renderer(Sender>), +} + +enum EvalMessage { + Test(EvaluatorEvent), + Renderer(Sender>), +} + +impl EventProcessorTraining for AsyncProcessorTraining { + fn process_train(&mut self, event: ET) { + self.sender.send_blocking(Message::Train(event)).unwrap(); + } + + fn process_valid(&mut self, event: EV) { + self.sender.send_blocking(Message::Valid(event)).unwrap(); + } + + fn renderer(self) -> Box { + let (sender, rec) = async_channel::bounded(1); + self.sender + .send_blocking(Message::Renderer(sender)) + .unwrap(); + + match rec.recv_blocking() { + Ok(value) => value, + Err(err) => panic!("{err:?}"), + } + } +} + +impl EventProcessorEvaluation for AsyncProcessorEvaluation

{ + type ItemTest = P::ItemTest; + + fn process_test(&mut self, event: EvaluatorEvent) { + self.sender.send_blocking(EvalMessage::Test(event)).unwrap(); + } + + fn renderer(self) -> Box { + let (sender, rec) = async_channel::bounded(1); + self.sender + .send_blocking(EvalMessage::Renderer(sender)) + .unwrap(); + + match rec.recv_blocking() { + Ok(value) => value, + Err(err) => panic!("{err:?}"), + } + } +} diff --git a/crates/burn-train/src/metric/processor/base.rs b/crates/burn-train/src/metric/processor/base.rs new file mode 100644 index 0000000..66098da --- /dev/null +++ b/crates/burn-train/src/metric/processor/base.rs @@ -0,0 +1,134 @@ +use burn_core::data::dataloader::Progress; +use burn_optim::lr_scheduler::module_lr_scheduler::ModuleLearningRate; + +use crate::{ + LearnerSummary, + renderer::{EvaluationName, MetricsRenderer}, +}; + +/// Event happening during the training/validation process. +pub enum LearnerEvent { + /// Signal the start of the process (e.g., training start). + Start { + /// The total number of training epochs. + total_epochs: usize, + /// The starting epoch. + starting_epoch: usize, + }, + /// Signal that an item have been processed. + ProcessedItem(TrainingItem), + /// Signal the start of a split. + StartSplit { + /// The epoch number. + epoch_number: usize, + /// The total number of items to be processed during this split. + total_items: usize, + }, + /// Signal the end of a split, carrying the current epoch number. + EndSplit(usize), + /// Signal the end of a full epoch. + EndEpoch(usize), + /// Signal the end of the process (e.g., training end). + End(Option), +} + +/// Event happening during the evaluation process. +pub enum EvaluatorEvent { + /// Signal the start of the process (e.g., evaluation start) + Start { + /// The total number of items to evaluate. + total_tests: usize, + }, + /// Signal the start of a test split, carrying the split name and total number of items. + StartTest(EvaluationName, usize), + /// Signal that an item have been processed. + ProcessedItem(EvaluationName, EvaluationItem), + /// Signal the end of a single test split. + EndTest, + /// Signal the end of the process (e.g., evaluation end). + End(Option), +} + +/// Items that are lazy are not ready to be processed by metrics. +/// +/// We want to sync them on a different thread to avoid blocking training. +pub trait ItemLazy: Send { + /// Sync the item. + fn sync(self) -> Self; +} + +/// Process events happening during training and validation. +pub trait EventProcessorTraining: Send { + /// Collect a training event. + fn process_train(&mut self, event: TrainEvent); + /// Collect a validation event. + fn process_valid(&mut self, event: ValidEvent); + /// Returns the renderer used for training. + fn renderer(self) -> Box; +} + +/// Process events happening during evaluation. +pub trait EventProcessorEvaluation: Send { + /// The test item. + type ItemTest: ItemLazy; + + /// Collect a test event. + fn process_test(&mut self, event: EvaluatorEvent); + + /// Returns the renderer used for evaluation. + fn renderer(self) -> Box; +} + +/// A learner item. +#[derive(new)] +pub struct TrainingItem { + /// The item. + pub item: T, + + /// The progress. + pub progress: Progress, + + /// The iteration, if it it different from the items processed. + pub iteration: Option, + + /// The learning rate for a module's parameters. + pub lr: Option, +} + +impl ItemLazy for TrainingItem { + fn sync(self) -> Self { + TrainingItem { + item: self.item.sync(), + progress: self.progress, + iteration: self.iteration, + lr: self.lr, + } + } +} + +/// An evaluation item. +#[derive(new)] +pub struct EvaluationItem { + /// The item. + pub item: T, + + /// The progress. + pub progress: Progress, + + /// The iteration, if it it different from the items processed. + pub iteration: Option, +} + +impl ItemLazy for EvaluationItem { + fn sync(self) -> Self { + EvaluationItem { + item: self.item.sync(), + progress: self.progress, + iteration: self.iteration, + } + } +} + +impl ItemLazy for () { + fn sync(self) -> Self {} +} diff --git a/crates/burn-train/src/metric/processor/full.rs b/crates/burn-train/src/metric/processor/full.rs new file mode 100644 index 0000000..d259c18 --- /dev/null +++ b/crates/burn-train/src/metric/processor/full.rs @@ -0,0 +1,333 @@ +use super::{EventProcessorTraining, ItemLazy, LearnerEvent, MetricsTraining}; +use crate::logger::{EvaluationProgressLogger, TrainingProgressLogger}; +use crate::metric::MetricMetadata; +use crate::metric::processor::{EvaluatorEvent, EventProcessorEvaluation, MetricsEvaluation}; +use crate::metric::store::{EpochSummary, EventStoreClient, Split}; +use crate::renderer::{MetricState, MetricsRenderer}; +use std::sync::Arc; + +/// An [event processor](EventProcessorTraining) that handles: +/// - Computing and storing metrics in an [event store](crate::metric::store::EventStore). +/// - Render metrics using a [metrics renderer](MetricsRenderer). +pub struct FullEventProcessorTraining { + metrics: MetricsTraining, + renderer: Box, + store: Arc, + progress_logger: Option>, + current_epoch: usize, + total_epochs: usize, +} + +/// An [event processor](EventProcessorEvaluation) that handles: +/// - Computing and storing metrics in an [event store](crate::metric::store::EventStore). +/// - Render metrics using a [metrics renderer](MetricsRenderer). +pub struct FullEventProcessorEvaluation { + metrics: MetricsEvaluation, + renderer: Box, + store: Arc, + progress_logger: Option>, + total_tests: usize, + current_test: usize, +} + +impl FullEventProcessorTraining { + pub(crate) fn new( + metrics: MetricsTraining, + renderer: Box, + store: Arc, + ) -> Self { + Self { + metrics, + renderer, + store, + progress_logger: None, + current_epoch: 1, + total_epochs: 0, + } + } + + pub(crate) fn with_progress_logger(mut self, logger: Box) -> Self { + self.progress_logger = Some(logger); + self + } +} + +impl FullEventProcessorEvaluation { + pub(crate) fn new( + metrics: MetricsEvaluation, + renderer: Box, + store: Arc, + ) -> Self { + Self { + metrics, + renderer, + store, + progress_logger: None, + total_tests: 0, + current_test: 0, + } + } + + pub(crate) fn with_progress_logger( + mut self, + logger: Box, + ) -> Self { + self.progress_logger = Some(logger); + self + } +} + +impl EventProcessorEvaluation for FullEventProcessorEvaluation { + type ItemTest = T; + + fn process_test(&mut self, event: EvaluatorEvent) { + match event { + EvaluatorEvent::Start { total_tests } => { + let definitions = self.metrics.metric_definitions(); + self.store + .add_event_train(crate::metric::store::Event::MetricsInit( + definitions.clone(), + )); + definitions + .iter() + .for_each(|definition| self.renderer.register_metric(definition.clone())); + self.total_tests = total_tests; + self.current_test = 0; + if let Some(logger) = &mut self.progress_logger { + logger.start_global_progress(total_tests); + } + self.renderer.start_global_progress(total_tests); + } + EvaluatorEvent::StartTest(name, total_items) => { + self.current_test += 1; + self.renderer.start_test(name.as_str(), total_items); + if let Some(logger) = &mut self.progress_logger { + logger.start_test(name.as_str(), total_items); + } + } + EvaluatorEvent::ProcessedItem(name, item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_test(&item, &metadata); + + self.store.add_event_test( + crate::metric::store::Event::MetricsUpdate(update.clone()), + name.name.clone(), + ); + + update.entries.into_iter().for_each(|entry| { + self.renderer + .update_test(name.clone(), MetricState::Generic(entry)) + }); + + update + .entries_numeric + .into_iter() + .for_each(|numeric_update| { + self.renderer.update_test( + name.clone(), + MetricState::Numeric( + numeric_update.entry, + numeric_update.numeric_entry, + ), + ) + }); + + if let Some(logger) = &mut self.progress_logger { + logger.update_test_progress(item.progress.items_processed); + logger.log_event_evaluation("Iteration".to_string()); + } + self.renderer + .update_test_progress(item.progress.items_processed); + self.renderer.log_event_evaluation("Iteration".to_string()); + } + EvaluatorEvent::EndTest => { + if let Some(logger) = &mut self.progress_logger { + logger.end_test(); + } + self.renderer.end_test(); + } + EvaluatorEvent::End(summary) => { + if let Some(logger) = &mut self.progress_logger { + logger.end_global_progress(); + } + self.renderer.end_global_progress(); + self.renderer.on_test_end(summary).ok(); + } + } + } + + fn renderer(self) -> Box { + self.renderer + } +} + +impl EventProcessorTraining, LearnerEvent> + for FullEventProcessorTraining +{ + fn process_train(&mut self, event: LearnerEvent) { + match event { + LearnerEvent::Start { + total_epochs, + starting_epoch, + } => { + self.total_epochs = total_epochs; + self.current_epoch = 1; + let definitions = self.metrics.metric_definitions(); + self.store + .add_event_train(crate::metric::store::Event::MetricsInit( + definitions.clone(), + )); + definitions + .iter() + .for_each(|definition| self.renderer.register_metric(definition.clone())); + if let Some(logger) = &mut self.progress_logger { + logger.start(total_epochs, starting_epoch, None); + } + self.renderer.start(total_epochs, starting_epoch, None); + } + LearnerEvent::StartSplit { + epoch_number, + total_items, + } => { + self.store + .add_event_train(crate::metric::store::Event::StartSplit(epoch_number)); + self.renderer.start_split(Split::Train.into(), total_items); + if let Some(logger) = &mut self.progress_logger { + logger.start_split(Split::Train.into(), total_items); + } + } + LearnerEvent::ProcessedItem(item) => { + let item = item.sync(); + let metadata = MetricMetadata { + progress: item.progress.clone(), + iteration: item.iteration, + lr: item.lr.clone(), + }; + + let update = self.metrics.update_train(&item, &metadata); + + self.store + .add_event_train(crate::metric::store::Event::MetricsUpdate(update.clone())); + + update + .entries + .into_iter() + .for_each(|entry| self.renderer.update_train(MetricState::Generic(entry))); + + update + .entries_numeric + .into_iter() + .for_each(|numeric_update| { + self.renderer.update_train(MetricState::Numeric( + numeric_update.entry, + numeric_update.numeric_entry, + )) + }); + + if let Some(logger) = &mut self.progress_logger { + logger.update_split(item.progress.items_processed); + logger.log_event_training("Iteration".to_string()); + } + self.renderer.update_split(item.progress.items_processed); + self.renderer.log_event_training("Iteration".to_string()); + } + LearnerEvent::EndSplit(epoch) => { + self.store + .add_event_train(crate::metric::store::Event::EndEpoch(EpochSummary::new( + epoch, + Split::Train, + ))); + if let Some(logger) = &mut self.progress_logger { + logger.end_split(); + } + self.renderer.end_split(); + self.metrics.end_epoch_train(); + } + LearnerEvent::EndEpoch(epoch) => { + self.current_epoch = epoch + 1; + if let Some(logger) = &mut self.progress_logger { + logger.update_epoch(epoch); + } + self.renderer.update_epoch(epoch) + } + LearnerEvent::End(summary) => { + if let Some(logger) = &mut self.progress_logger { + logger.end(); + } + self.renderer.end(); + self.renderer.on_train_end(summary).ok(); + } + } + } + + fn process_valid(&mut self, event: LearnerEvent) { + match event { + LearnerEvent::Start { .. } => {} // no-op: valid has no separate start event + LearnerEvent::StartSplit { + epoch_number, + total_items, + } => { + self.store + .add_event_valid(crate::metric::store::Event::StartSplit(epoch_number)); + if let Some(logger) = &mut self.progress_logger { + logger.start_split(Split::Valid.into(), total_items); + } + self.renderer.start_split(Split::Valid.into(), total_items); + } + LearnerEvent::ProcessedItem(item) => { + let item = item.sync(); + let metadata = MetricMetadata { + progress: item.progress.clone(), + iteration: item.iteration, + lr: item.lr.clone(), + }; + + let update = self.metrics.update_valid(&item, &metadata); + + self.store + .add_event_valid(crate::metric::store::Event::MetricsUpdate(update.clone())); + + update + .entries + .into_iter() + .for_each(|entry| self.renderer.update_valid(MetricState::Generic(entry))); + + update + .entries_numeric + .into_iter() + .for_each(|numeric_update| { + self.renderer.update_valid(MetricState::Numeric( + numeric_update.entry, + numeric_update.numeric_entry, + )) + }); + + if let Some(logger) = &mut self.progress_logger { + logger.update_split(item.progress.items_processed); + logger.log_event_training("Iteration".to_string()); + } + self.renderer.update_split(item.progress.items_processed); + self.renderer.log_event_training("Iteration".to_string()); + } + LearnerEvent::EndSplit(epoch) => { + self.store + .add_event_valid(crate::metric::store::Event::EndEpoch(EpochSummary::new( + epoch, + Split::Valid, + ))); + if let Some(logger) = &mut self.progress_logger { + logger.end_split(); + } + self.renderer.end_split(); + self.metrics.end_epoch_valid(); + } + LearnerEvent::EndEpoch(_) => {} // update_epoch is handled in process_train(EndEpoch) + LearnerEvent::End(_) => {} // no-op + } + } + fn renderer(self) -> Box { + self.renderer + } +} diff --git a/crates/burn-train/src/metric/processor/metrics.rs b/crates/burn-train/src/metric/processor/metrics.rs new file mode 100644 index 0000000..2f4fe57 --- /dev/null +++ b/crates/burn-train/src/metric/processor/metrics.rs @@ -0,0 +1,309 @@ +use std::collections::HashMap; + +use super::{ItemLazy, TrainingItem}; +use crate::{ + EvaluationItem, + metric::{ + Adaptor, Metric, MetricDefinition, MetricEntry, MetricId, MetricMetadata, Numeric, + store::{MetricsUpdate, NumericMetricUpdate}, + }, +}; + +pub(crate) struct MetricsTraining { + train: Vec>>, + valid: Vec>>, + train_numeric: Vec>>, + valid_numeric: Vec>>, + metric_definitions: HashMap, +} + +pub(crate) struct MetricsEvaluation { + test: Vec>>, + test_numeric: Vec>>, + metric_definitions: HashMap, +} + +impl Default for MetricsEvaluation { + fn default() -> Self { + Self { + test: Default::default(), + test_numeric: Default::default(), + metric_definitions: HashMap::default(), + } + } +} + +impl Default for MetricsTraining { + fn default() -> Self { + Self { + train: Vec::default(), + valid: Vec::default(), + train_numeric: Vec::default(), + valid_numeric: Vec::default(), + metric_definitions: HashMap::default(), + } + } +} + +impl MetricsEvaluation { + /// Register a testing metric. + pub(crate) fn register_test_metric(&mut self, metric: Me) + where + T: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.test.push(Box::new(metric)) + } + + /// Register a numeric testing metric. + pub(crate) fn register_test_metric_numeric( + &mut self, + metric: Me, + ) where + T: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.test_numeric.push(Box::new(metric)) + } + + fn register_definition(&mut self, metric: &MetricWrapper) { + self.metric_definitions.insert( + metric.id.clone(), + MetricDefinition::new(metric.id.clone(), &metric.metric), + ); + } + + /// Get metric definitions. + pub(crate) fn metric_definitions(&mut self) -> Vec { + self.metric_definitions.values().cloned().collect() + } + + /// Update the testing information from the testing item. + pub(crate) fn update_test( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.test.len()); + let mut entries_numeric = Vec::with_capacity(self.test_numeric.len()); + + for metric in self.test.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.test_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } +} + +impl MetricsTraining { + /// Register a training metric. + pub(crate) fn register_train_metric(&mut self, metric: Me) + where + T: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.train.push(Box::new(metric)) + } + + /// Register a validation metric. + pub(crate) fn register_valid_metric(&mut self, metric: Me) + where + V: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.valid.push(Box::new(metric)) + } + + /// Register a numeric training metric. + pub(crate) fn register_train_metric_numeric( + &mut self, + metric: Me, + ) where + T: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.train_numeric.push(Box::new(metric)) + } + + /// Register a numeric validation metric. + pub(crate) fn register_valid_metric_numeric(&mut self, metric: Me) + where + V: Adaptor + 'static, + Me: Metric + Numeric + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.valid_numeric.push(Box::new(metric)) + } + + fn register_definition(&mut self, metric: &MetricWrapper) { + self.metric_definitions.insert( + metric.id.clone(), + MetricDefinition::new(metric.id.clone(), &metric.metric), + ); + } + + /// Get metric definitions for all splits + pub(crate) fn metric_definitions(&mut self) -> Vec { + self.metric_definitions.values().cloned().collect() + } + + /// Update the training information from the training item. + pub(crate) fn update_train( + &mut self, + item: &TrainingItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.train.len()); + let mut entries_numeric = Vec::with_capacity(self.train_numeric.len()); + + for metric in self.train.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.train_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Update the training information from the validation item. + pub(crate) fn update_valid( + &mut self, + item: &TrainingItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.valid.len()); + let mut entries_numeric = Vec::with_capacity(self.valid_numeric.len()); + + for metric in self.valid.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.valid_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Signal the end of a training epoch. + pub(crate) fn end_epoch_train(&mut self) { + for metric in self.train.iter_mut() { + metric.clear(); + } + for metric in self.train_numeric.iter_mut() { + metric.clear(); + } + } + + /// Signal the end of a validation epoch. + pub(crate) fn end_epoch_valid(&mut self) { + for metric in self.valid.iter_mut() { + metric.clear(); + } + for metric in self.valid_numeric.iter_mut() { + metric.clear(); + } + } +} + +impl From<&TrainingItem> for MetricMetadata { + fn from(item: &TrainingItem) -> Self { + Self { + progress: item.progress.clone(), + iteration: item.iteration, + lr: item.lr.clone(), + } + } +} + +impl From<&EvaluationItem> for MetricMetadata { + fn from(item: &EvaluationItem) -> Self { + Self { + progress: item.progress.clone(), + iteration: item.iteration, + lr: None, + } + } +} + +pub(crate) trait NumericMetricUpdater: Send + Sync { + fn update(&mut self, item: &T, metadata: &MetricMetadata) -> NumericMetricUpdate; + fn clear(&mut self); +} + +pub(crate) trait MetricUpdater: Send + Sync { + fn update(&mut self, item: &T, metadata: &MetricMetadata) -> MetricEntry; + fn clear(&mut self); +} + +pub(crate) struct MetricWrapper { + pub id: MetricId, + pub metric: M, +} + +impl MetricWrapper { + pub fn new(metric: M) -> Self { + Self { + id: MetricId::new(metric.name()), + metric, + } + } +} + +impl NumericMetricUpdater for MetricWrapper +where + T: 'static, + M: Metric + Numeric + 'static, + T: Adaptor, +{ + fn update(&mut self, item: &T, metadata: &MetricMetadata) -> NumericMetricUpdate { + let serialized_entry = self.metric.update(&item.adapt(), metadata); + let update = MetricEntry::new(self.id.clone(), serialized_entry); + let numeric = self.metric.value(); + let running = self.metric.running_value(); + + NumericMetricUpdate { + entry: update, + numeric_entry: numeric, + running_entry: running, + } + } + + fn clear(&mut self) { + self.metric.clear() + } +} + +impl MetricUpdater for MetricWrapper +where + T: 'static, + M: Metric + 'static, + T: Adaptor, +{ + fn update(&mut self, item: &T, metadata: &MetricMetadata) -> MetricEntry { + let serialized_entry = self.metric.update(&item.adapt(), metadata); + MetricEntry::new(self.id.clone(), serialized_entry) + } + + fn clear(&mut self) { + self.metric.clear() + } +} diff --git a/crates/burn-train/src/metric/processor/minimal.rs b/crates/burn-train/src/metric/processor/minimal.rs new file mode 100644 index 0000000..5060d89 --- /dev/null +++ b/crates/burn-train/src/metric/processor/minimal.rs @@ -0,0 +1,141 @@ +use super::{EventProcessorTraining, ItemLazy, LearnerEvent, MetricsTraining}; +use crate::{ + logger::TrainingProgressLogger, + metric::store::{EpochSummary, EventStoreClient, Split}, + renderer::cli::CliMetricsRenderer, +}; +use std::sync::Arc; + +/// An [event processor](EventProcessor) that handles: +/// - Computing and storing metrics in an [event store](crate::metric::store::EventStore). +/// - Optionally logging training progress via a [TrainingProgressLogger]. +#[allow(dead_code)] +pub(crate) struct MinimalEventProcessor { + metrics: MetricsTraining, + store: Arc, + progress_logger: Option>, +} + +#[allow(dead_code)] +impl MinimalEventProcessor { + pub(crate) fn new(metrics: MetricsTraining, store: Arc) -> Self { + Self { + metrics, + store, + progress_logger: None, + } + } + + pub(crate) fn with_progress_logger(mut self, logger: Box) -> Self { + self.progress_logger = Some(logger); + self + } +} + +impl EventProcessorTraining, LearnerEvent> + for MinimalEventProcessor +{ + fn process_train(&mut self, event: LearnerEvent) { + match event { + LearnerEvent::Start { + total_epochs, + starting_epoch, + } => { + let definitions = self.metrics.metric_definitions(); + self.store + .add_event_train(crate::metric::store::Event::MetricsInit(definitions)); + if let Some(logger) = &mut self.progress_logger { + logger.start(total_epochs, starting_epoch, None); + } + } + LearnerEvent::StartSplit { + epoch_number, + total_items, + } => { + self.store + .add_event_train(crate::metric::store::Event::StartSplit(epoch_number)); + if let Some(logger) = &mut self.progress_logger { + logger.start_split(Split::Train.into(), total_items); + } + } + LearnerEvent::ProcessedItem(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_train(&item, &metadata); + + self.store + .add_event_train(crate::metric::store::Event::MetricsUpdate(update)); + if let Some(logger) = &mut self.progress_logger { + logger.update_split(item.progress.items_processed); + } + } + LearnerEvent::EndSplit(epoch) => { + self.metrics.end_epoch_train(); + self.store + .add_event_train(crate::metric::store::Event::EndEpoch(EpochSummary::new( + epoch, + Split::Train, + ))); + if let Some(logger) = &mut self.progress_logger { + logger.end_split(); + } + } + LearnerEvent::EndEpoch(epoch) => { + if let Some(logger) = &mut self.progress_logger { + logger.update_epoch(epoch); + } + } + LearnerEvent::End(_summary) => { + if let Some(logger) = &mut self.progress_logger { + logger.end(); + } + } + } + } + + fn process_valid(&mut self, event: LearnerEvent) { + match event { + LearnerEvent::Start { .. } => {} // no-op + LearnerEvent::StartSplit { + epoch_number, + total_items, + } => { + self.store + .add_event_valid(crate::metric::store::Event::StartSplit(epoch_number)); + if let Some(logger) = &mut self.progress_logger { + logger.start_split(Split::Valid.into(), total_items); + } + } + LearnerEvent::ProcessedItem(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_valid(&item, &metadata); + + self.store + .add_event_valid(crate::metric::store::Event::MetricsUpdate(update)); + if let Some(logger) = &mut self.progress_logger { + logger.update_split(item.progress.items_processed); + } + } + LearnerEvent::EndSplit(epoch) => { + self.metrics.end_epoch_valid(); + self.store + .add_event_valid(crate::metric::store::Event::EndEpoch(EpochSummary::new( + epoch, + Split::Valid, + ))); + if let Some(logger) = &mut self.progress_logger { + logger.end_split(); + } + } + LearnerEvent::EndEpoch(_) => {} // update_epoch handled in process_train(EndEpoch) + LearnerEvent::End(_) => {} // no-op: End is only emitted on process_train + } + } + fn renderer(self) -> Box { + // TODO: Check for another default. + Box::new(CliMetricsRenderer::new()) + } +} diff --git a/crates/burn-train/src/metric/processor/mod.rs b/crates/burn-train/src/metric/processor/mod.rs new file mode 100644 index 0000000..860aa74 --- /dev/null +++ b/crates/burn-train/src/metric/processor/mod.rs @@ -0,0 +1,85 @@ +mod async_wrapper; +mod base; +mod full; +mod metrics; +mod minimal; +#[cfg(feature = "rl")] +mod rl_metrics; +#[cfg(feature = "rl")] +mod rl_processor; + +pub use base::*; +pub(crate) use full::*; +pub(crate) use metrics::*; +#[cfg(feature = "rl")] +pub(crate) use rl_metrics::*; +#[cfg(feature = "rl")] +pub use rl_processor::*; + +#[cfg(test)] +pub(crate) use minimal::*; + +pub use async_wrapper::{AsyncProcessorEvaluation, AsyncProcessorTraining}; + +#[cfg(test)] +pub(crate) mod test_utils { + use crate::metric::{ + Adaptor, LossInput, + processor::{EventProcessorTraining, LearnerEvent, MinimalEventProcessor, TrainingItem}, + }; + use burn_core::tensor::Tensor; + + use super::ItemLazy; + + impl ItemLazy for f64 { + fn sync(self) -> Self { + self + } + } + + impl Adaptor for f64 { + fn adapt(&self) -> LossInput { + LossInput::new(Tensor::from_data([*self], &Default::default())) + } + } + + pub(crate) fn process_train( + processor: &mut MinimalEventProcessor, + value: f64, + epoch: usize, + ) { + let dummy_progress = burn_core::data::dataloader::Progress { + items_processed: epoch, + items_total: 3, + unit: Some("items".to_string()), + }; + let dummy_iteration = Some(1); + + processor.process_train(LearnerEvent::ProcessedItem(TrainingItem::new( + value, + dummy_progress, + dummy_iteration, + None, + ))); + } + + pub(crate) fn start_epoch( + processor: &mut MinimalEventProcessor, + epoch: usize, + num_items: usize, + ) { + processor.process_train(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: num_items, + }); + processor.process_valid(LearnerEvent::StartSplit { + epoch_number: epoch, + total_items: num_items, + }); + } + + pub(crate) fn end_epoch(processor: &mut MinimalEventProcessor, epoch: usize) { + processor.process_train(LearnerEvent::EndSplit(epoch)); + processor.process_valid(LearnerEvent::EndSplit(epoch)); + } +} diff --git a/crates/burn-train/src/metric/processor/rl_metrics.rs b/crates/burn-train/src/metric/processor/rl_metrics.rs new file mode 100644 index 0000000..c3c3c3e --- /dev/null +++ b/crates/burn-train/src/metric/processor/rl_metrics.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; + +use crate::{ + EpisodeSummary, EvaluationItem, ItemLazy, MetricUpdater, MetricWrapper, NumericMetricUpdater, + metric::{ + Adaptor, Metric, MetricDefinition, MetricId, MetricMetadata, Numeric, store::MetricsUpdate, + }, +}; + +pub(crate) struct RLMetrics { + train_step: Vec>>, + env_step: Vec>>, + env_step_valid: Vec>>, + episode_end: Vec>>, + episode_end_valid: Vec>>, + + train_step_numeric: Vec>>, + env_step_numeric: Vec>>, + env_step_valid_numeric: Vec>>, + episode_end_numeric: Vec>>, + episode_end_valid_numeric: Vec>>, + + metric_definitions: HashMap, +} + +impl Default for RLMetrics { + fn default() -> Self { + Self { + train_step: Vec::default(), + env_step: Vec::default(), + env_step_valid: Vec::default(), + episode_end: Vec::default(), + episode_end_valid: Vec::default(), + train_step_numeric: Vec::default(), + env_step_numeric: Vec::default(), + env_step_valid_numeric: Vec::default(), + episode_end_numeric: Vec::default(), + episode_end_valid_numeric: Vec::default(), + metric_definitions: HashMap::default(), + } + } +} + +impl RLMetrics { + /// Register a training metric. + pub(crate) fn register_text_metric_agent(&mut self, metric: Me) + where + ES: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.env_step.push(Box::new(metric)) + } + + /// Register a training metric. + pub(crate) fn register_agent_metric(&mut self, metric: Me) + where + ES: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.env_step_numeric.push(Box::new(metric)) + } + + /// Register a training metric. + pub(crate) fn register_text_metric_train(&mut self, metric: Me) + where + TS: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.train_step.push(Box::new(metric)) + } + + /// Register a training metric. + pub(crate) fn register_metric_train(&mut self, metric: Me) + where + TS: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.train_step_numeric.push(Box::new(metric)) + } + + /// Register a validation env-step metric. + pub(crate) fn register_text_metric_agent_valid(&mut self, metric: Me) + where + ES: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.env_step_valid.push(Box::new(metric)) + } + + /// Register a validation env-step numeric metric. + pub(crate) fn register_agent_metric_valid(&mut self, metric: Me) + where + ES: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.env_step_valid_numeric.push(Box::new(metric)) + } + + /// Register an episode-end metric. + pub(crate) fn register_text_metric_episode(&mut self, metric: Me) + where + EpisodeSummary: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.episode_end.push(Box::new(metric)) + } + + /// Register an episode-end numeric metric. + pub(crate) fn register_episode_metric(&mut self, metric: Me) + where + EpisodeSummary: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.episode_end_numeric.push(Box::new(metric)) + } + + /// Register an episode-end metric for validation. + pub(crate) fn register_text_metric_episode_valid(&mut self, metric: Me) + where + EpisodeSummary: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.episode_end_valid.push(Box::new(metric)) + } + + /// Register an episode-end numeric metric for validation. + pub(crate) fn register_episode_metric_valid( + &mut self, + metric: Me, + ) where + EpisodeSummary: Adaptor + 'static, + { + let metric = MetricWrapper::new(metric); + self.register_definition(&metric); + self.episode_end_valid_numeric.push(Box::new(metric)) + } + + fn register_definition(&mut self, metric: &MetricWrapper) { + self.metric_definitions.insert( + metric.id.clone(), + MetricDefinition::new(metric.id.clone(), &metric.metric), + ); + } + + /// Get metric definitions for all splits + pub(crate) fn metric_definitions(&mut self) -> Vec { + self.metric_definitions.values().cloned().collect() + } + + /// Update the training information from the training item. + pub(crate) fn update_train_step( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.train_step.len()); + let mut entries_numeric = Vec::with_capacity(self.train_step_numeric.len()); + + for metric in self.train_step.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.train_step_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Update the env-step metrics from an environment step item. + pub(crate) fn update_env_step( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.env_step.len()); + let mut entries_numeric = Vec::with_capacity(self.env_step_numeric.len()); + + for metric in self.env_step.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.env_step_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Update the env-step metrics for validation from an environment step item. + pub(crate) fn update_env_step_valid( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.env_step_valid.len()); + let mut entries_numeric = Vec::with_capacity(self.env_step_valid_numeric.len()); + + for metric in self.env_step_valid.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.env_step_valid_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Update the episode-end metrics from an episode summary. + pub(crate) fn update_episode_end( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.episode_end.len()); + let mut entries_numeric = Vec::with_capacity(self.episode_end_numeric.len()); + + for metric in self.episode_end.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.episode_end_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } + + /// Update the episode-end metrics for validation from an episode summary. + pub(crate) fn update_episode_end_valid( + &mut self, + item: &EvaluationItem, + metadata: &MetricMetadata, + ) -> MetricsUpdate { + let mut entries = Vec::with_capacity(self.episode_end_valid.len()); + let mut entries_numeric = Vec::with_capacity(self.episode_end_valid_numeric.len()); + + for metric in self.episode_end_valid.iter_mut() { + let state = metric.update(&item.item, metadata); + entries.push(state); + } + + for metric in self.episode_end_valid_numeric.iter_mut() { + let numeric_update = metric.update(&item.item, metadata); + entries_numeric.push(numeric_update); + } + + MetricsUpdate::new(entries, entries_numeric) + } +} diff --git a/crates/burn-train/src/metric/processor/rl_processor.rs b/crates/burn-train/src/metric/processor/rl_processor.rs new file mode 100644 index 0000000..acc8c7f --- /dev/null +++ b/crates/burn-train/src/metric/processor/rl_processor.rs @@ -0,0 +1,215 @@ +use std::sync::Arc; + +use crate::{ + EpisodeSummary, EvaluationItem, EventProcessorTraining, ItemLazy, LearnerSummary, RLMetrics, + logger::TrainingProgressLogger, + metric::store::{Event, EventStoreClient, MetricsUpdate}, + renderer::{MetricState, MetricsRenderer}, +}; + +/// Event happening during reinforcement learning. +pub enum RLEvent { + /// Signal the start of the process (e.g., learning starts). + Start { + /// The total number of items to process during training (e.g., total number of environment steps). + total_items: usize, + }, + /// Signal an agent's training step. + TrainStep(EvaluationItem), + /// Signal a timestep of the agent-environment interface. + EnvStep(EvaluationItem), + /// Signal an episode end. + EpisodeEnd(EvaluationItem), + /// Signal the end of the process (e.g., learning ends). + End(Option), +} + +/// Event happening during evaluation of a reinforcement learning's agent. +pub enum AgentEvaluationEvent { + /// Signal the start of the process (e.g., training start) + Start(usize), + /// Signal a timestep of the agent-environment interface. + EnvStep(EvaluationItem), + /// Signal an episode end. + EpisodeEnd(EvaluationItem), + /// Signal the end of the process (e.g., training end). + End, +} + +/// An [event processor](EventProcessorTraining) that handles: +/// - Computing and storing metrics in an [event store](crate::metric::store::EventStore). +/// - Render metrics using a [metrics renderer](MetricsRenderer). +pub struct RLEventProcessor { + metrics: RLMetrics, + renderer: Box, + store: Arc, + training_progress_logger: Option>, +} + +impl RLEventProcessor { + pub(crate) fn new( + metrics: RLMetrics, + renderer: Box, + store: Arc, + ) -> Self { + Self { + metrics, + renderer, + store, + training_progress_logger: None, + } + } + + fn process_update_train(&mut self, update: MetricsUpdate) { + self.store + .add_event_train(crate::metric::store::Event::MetricsUpdate(update.clone())); + + update + .entries + .into_iter() + .for_each(|entry| self.renderer.update_train(MetricState::Generic(entry))); + + update + .entries_numeric + .into_iter() + .for_each(|numeric_update| { + self.renderer.update_train(MetricState::Numeric( + numeric_update.entry, + numeric_update.numeric_entry, + )) + }); + } + + fn process_update_valid(&mut self, update: MetricsUpdate) { + self.store + .add_event_valid(crate::metric::store::Event::MetricsUpdate(update.clone())); + + update + .entries + .into_iter() + .for_each(|entry| self.renderer.update_valid(MetricState::Generic(entry))); + + update + .entries_numeric + .into_iter() + .for_each(|numeric_update| { + self.renderer.update_valid(MetricState::Numeric( + numeric_update.entry, + numeric_update.numeric_entry, + )) + }); + } +} + +impl EventProcessorTraining, AgentEvaluationEvent> + for RLEventProcessor +{ + fn process_train(&mut self, event: RLEvent) { + match event { + RLEvent::Start { total_items } => { + let definitions = self.metrics.metric_definitions(); + self.store + .add_event_train(Event::MetricsInit(definitions.clone())); + definitions + .iter() + .for_each(|definition| self.renderer.register_metric(definition.clone())); + if let Some(logger) = &mut self.training_progress_logger { + logger.start(0, 0, Some(total_items)); + } + self.renderer.start(0, 0, Some(total_items)); + } + RLEvent::TrainStep(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_train_step(&item, &metadata); + self.process_update_train(update); + + if let Some(logger) = &mut self.training_progress_logger { + logger.log_event_training("TrainStep".to_string()); + } + self.renderer.log_event_training("TrainStep".to_string()); + } + RLEvent::EnvStep(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_env_step(&item, &metadata); + self.process_update_train(update); + + if let Some(logger) = &mut self.training_progress_logger { + logger.update_split(item.progress.items_processed); + logger.log_event_training("EnvStep".to_string()); + } + self.renderer.update_split(item.progress.items_processed); + self.renderer.log_event_training("EnvStep".to_string()); + } + RLEvent::EpisodeEnd(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_episode_end(&item, &metadata); + self.process_update_train(update); + + if let Some(logger) = &mut self.training_progress_logger { + logger.log_event_training("EpisodeEnd".to_string()); + } + self.renderer.log_event_training("EpisodeEnd".to_string()); + } + RLEvent::End(learner_summary) => { + if let Some(logger) = &mut self.training_progress_logger { + logger.end(); + } + self.renderer.end(); + self.renderer.on_train_end(learner_summary).ok(); + } + } + } + + fn process_valid(&mut self, event: AgentEvaluationEvent) { + match event { + AgentEvaluationEvent::Start(num_episodes) => { + if let Some(logger) = &mut self.training_progress_logger { + logger.start_split("valid", num_episodes); + } + self.renderer.start_split("valid", num_episodes); + } + AgentEvaluationEvent::EnvStep(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_env_step_valid(&item, &metadata); + self.process_update_valid(update); + + if let Some(logger) = &mut self.training_progress_logger { + logger.log_event_training("EnvStep".to_string()); + } + self.renderer.log_event_training("EnvStep".to_string()); + } + AgentEvaluationEvent::EpisodeEnd(item) => { + let item = item.sync(); + let metadata = (&item).into(); + + let update = self.metrics.update_episode_end_valid(&item, &metadata); + self.process_update_valid(update); + + if let Some(logger) = &mut self.training_progress_logger { + logger.update_split(item.progress.items_processed); + logger.log_event_training("EpisodeEnd".to_string()); + } + self.renderer.update_split(item.progress.items_processed); + self.renderer.log_event_training("EpisodeEnd".to_string()); + } + AgentEvaluationEvent::End => { + if let Some(logger) = &mut self.training_progress_logger { + logger.end_split(); + } + self.renderer.end_split(); + } + } + } + + fn renderer(self) -> Box { + self.renderer + } +} diff --git a/crates/burn-train/src/metric/recall.rs b/crates/burn-train/src/metric/recall.rs new file mode 100644 index 0000000..0cd1fe0 --- /dev/null +++ b/crates/burn-train/src/metric/recall.rs @@ -0,0 +1,212 @@ +use crate::metric::{MetricName, Numeric}; + +use super::{ + Metric, MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, SerializedEntry, + classification::{ClassReduction, ClassificationMetricConfig, DecisionRule}, + confusion_stats::{ConfusionStats, ConfusionStatsInput}, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::prelude::Tensor; +use std::{num::NonZeroUsize, sync::Arc}; + +///The Recall Metric +#[derive(Clone)] +pub struct RecallMetric { + name: MetricName, + state: NumericMetricState, + config: ClassificationMetricConfig, +} + +impl Default for RecallMetric { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl RecallMetric { + fn new(config: ClassificationMetricConfig) -> Self { + let state = Default::default(); + let name = Arc::new(format!( + "Recall @ {:?} [{:?}]", + config.decision_rule, config.class_reduction + )); + + Self { + state, + config, + name, + } + } + /// Recall metric for binary classification. + /// + /// # Arguments + /// + /// * `threshold` - The threshold to transform a probability into a binary prediction. + #[allow(dead_code)] + pub fn binary(threshold: f64) -> Self { + Self::new(ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + // binary classification results are the same independently of class_reduction + ..Default::default() + }) + } + + /// Recall metric for multiclass classification. + /// + /// # Arguments + /// + /// * `top_k` - The number of highest predictions considered to find the correct label (typically `1`). + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multiclass(top_k: usize, class_reduction: ClassReduction) -> Self { + Self::new(ClassificationMetricConfig { + decision_rule: DecisionRule::TopK( + NonZeroUsize::new(top_k).expect("top_k must be non-zero"), + ), + class_reduction, + }) + } + + /// Recall metric for multi-label classification. + /// + /// # Arguments + /// + /// * `threshold` - The threshold to transform a probability into a binary prediction. + /// * `class_reduction` - [Class reduction](ClassReduction) type. + #[allow(dead_code)] + pub fn multilabel(threshold: f64, class_reduction: ClassReduction) -> Self { + Self::new(ClassificationMetricConfig { + decision_rule: DecisionRule::Threshold(threshold), + class_reduction, + }) + } + + fn class_average(&self, mut aggregated_metric: Tensor<1>) -> f64 { + use ClassReduction::{Macro, Micro}; + let avg_tensor = match self.config.class_reduction { + Micro => aggregated_metric, + Macro => { + if aggregated_metric.clone().contains_nan().any().into_scalar() { + let nan_mask = aggregated_metric.clone().is_nan(); + aggregated_metric = aggregated_metric + .clone() + .select(0, nan_mask.bool_not().argwhere().squeeze_dim(1)) + } + aggregated_metric.mean() + } + }; + avg_tensor.into_scalar() + } +} + +impl Metric for RecallMetric { + type Input = ConfusionStatsInput; + + fn update(&mut self, input: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let [sample_size, _] = input.predictions.dims(); + + let cf_stats = ConfusionStats::new(input, &self.config); + let metric = self.class_average(cf_stats.clone().true_positive() / cf_stats.positive()); + + self.state.update( + 100.0 * metric, + sample_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for RecallMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::{ + ClassReduction::{self, *}, + Metric, MetricMetadata, RecallMetric, + }; + use crate::metric::Numeric; + use crate::tests::{ClassificationType, THRESHOLD, dummy_classification_input}; + use burn_core::tensor::{TensorData, Tolerance}; + use rstest::rstest; + + #[rstest] + #[case::binary(THRESHOLD, 0.5)] + fn test_binary_recall(#[case] threshold: f64, #[case] expected: f64) { + let input = dummy_classification_input(&ClassificationType::Binary).into(); + let mut metric = RecallMetric::binary(threshold); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multiclass_micro_k1(Micro, 1, 3.0/5.0)] + #[case::multiclass_micro_k2(Micro, 2, 4.0/5.0)] + #[case::multiclass_macro_k1(Macro, 1, (0.5 + 1.0 + 0.5)/3.0)] + #[case::multiclass_macro_k2(Macro, 2, (1.0 + 1.0 + 0.5)/3.0)] + fn test_multiclass_recall( + #[case] class_reduction: ClassReduction, + #[case] top_k: usize, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multiclass).into(); + let mut metric = RecallMetric::multiclass(top_k, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[rstest] + #[case::multilabel_micro(Micro, THRESHOLD, 5.0/9.0)] + #[case::multilabel_macro(Macro, THRESHOLD, (0.5 + 1.0 + 1.0/3.0)/3.0)] + fn test_multilabel_recall( + #[case] class_reduction: ClassReduction, + #[case] threshold: f64, + #[case] expected: f64, + ) { + let input = dummy_classification_input(&ClassificationType::Multilabel).into(); + let mut metric = RecallMetric::multilabel(threshold, class_reduction); + let _entry = metric.update(&input, &MetricMetadata::fake()); + TensorData::from([metric.value().current()]) + .assert_approx_eq::(&TensorData::from([expected * 100.0]), Tolerance::default()) + } + + #[test] + fn test_parameterized_unique_name() { + let metric_a = RecallMetric::multiclass(1, ClassReduction::Macro); + let metric_b = RecallMetric::multiclass(2, ClassReduction::Macro); + let metric_c = RecallMetric::multiclass(1, ClassReduction::Macro); + + assert_ne!(metric_a.name(), metric_b.name()); + assert_eq!(metric_a.name(), metric_c.name()); + + let metric_a = RecallMetric::binary(0.5); + let metric_b = RecallMetric::binary(0.75); + assert_ne!(metric_a.name(), metric_b.name()); + } +} diff --git a/crates/burn-train/src/metric/rl/cum_reward.rs b/crates/burn-train/src/metric/rl/cum_reward.rs new file mode 100644 index 0000000..b20ed96 --- /dev/null +++ b/crates/burn-train/src/metric/rl/cum_reward.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use super::super::{ + MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, + state::{FormatOptions, NumericMetricState}, +}; +use crate::metric::{Metric, MetricName, Numeric, SerializedEntry}; + +/// Metric for the cumulative reward of the last completed episode. +#[derive(Clone)] +pub struct CumulativeRewardMetric { + name: MetricName, + state: NumericMetricState, +} + +impl CumulativeRewardMetric { + /// Creates a new episode length metric. + pub fn new() -> Self { + Self { + name: Arc::new("Cum. Reward".to_string()), + state: NumericMetricState::new(), + } + } +} + +impl Default for CumulativeRewardMetric { + fn default() -> Self { + Self::new() + } +} + +/// The [CumulativeRewardMetric](CumulativeRewardMetric) input type. +#[derive(new)] +pub struct CumulativeRewardInput { + cum_reward: f64, +} + +impl Metric for CumulativeRewardMetric { + type Input = CumulativeRewardInput; + + fn update( + &mut self, + item: &CumulativeRewardInput, + _metadata: &MetricMetadata, + ) -> SerializedEntry { + self.state.update( + item.cum_reward, + 1, + FormatOptions::new(self.name()).precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for CumulativeRewardMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/rl/ep_len.rs b/crates/burn-train/src/metric/rl/ep_len.rs new file mode 100644 index 0000000..177d03e --- /dev/null +++ b/crates/burn-train/src/metric/rl/ep_len.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use super::super::{ + MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, + state::{FormatOptions, NumericMetricState}, +}; +use crate::metric::{Metric, MetricName, Numeric, SerializedEntry}; + +/// Metric for the length of the last completed episode. +#[derive(Clone)] +pub struct EpisodeLengthMetric { + name: MetricName, + state: NumericMetricState, +} + +impl EpisodeLengthMetric { + /// Creates a new episode length metric. + pub fn new() -> Self { + Self { + name: Arc::new("Episode length".to_string()), + state: NumericMetricState::new(), + } + } +} + +impl Default for EpisodeLengthMetric { + fn default() -> Self { + Self::new() + } +} + +/// The [EpisodeLengthMetric](EpisodeLengthMetric) input type. +#[derive(new)] +pub struct EpisodeLengthInput { + ep_len: f64, +} + +impl Metric for EpisodeLengthMetric { + type Input = EpisodeLengthInput; + + fn update(&mut self, item: &EpisodeLengthInput, _metadata: &MetricMetadata) -> SerializedEntry { + self.state + .update(item.ep_len, 1, FormatOptions::new(self.name()).precision(0)) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some(String::from("steps")), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for EpisodeLengthMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/rl/exploration_rate.rs b/crates/burn-train/src/metric/rl/exploration_rate.rs new file mode 100644 index 0000000..a57a4ec --- /dev/null +++ b/crates/burn-train/src/metric/rl/exploration_rate.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use super::super::{ + MetricAttributes, MetricMetadata, NumericAttributes, NumericEntry, + state::{FormatOptions, NumericMetricState}, +}; +use crate::metric::{Metric, MetricName, Numeric, SerializedEntry}; + +/// Metric for the length of the last completed episode. +#[derive(Clone)] +pub struct ExplorationRateMetric { + name: MetricName, + state: NumericMetricState, +} + +impl ExplorationRateMetric { + /// Creates a new episode length metric. + pub fn new() -> Self { + Self { + name: Arc::new("Exploration rate".to_string()), + state: NumericMetricState::new(), + } + } +} + +impl Default for ExplorationRateMetric { + fn default() -> Self { + Self::new() + } +} + +/// The [ExplorationRateMetric](ExplorationRateMetric) input type. +#[derive(new)] +pub struct ExplorationRateInput { + exploration_rate: f64, +} + +impl Metric for ExplorationRateMetric { + type Input = ExplorationRateInput; + + fn update( + &mut self, + item: &ExplorationRateInput, + _metadata: &MetricMetadata, + ) -> SerializedEntry { + self.state.update( + item.exploration_rate, + 1, + FormatOptions::new(self.name()).precision(3), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some(String::from("%")), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for ExplorationRateMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} diff --git a/crates/burn-train/src/metric/rl/mod.rs b/crates/burn-train/src/metric/rl/mod.rs new file mode 100644 index 0000000..f2a2d22 --- /dev/null +++ b/crates/burn-train/src/metric/rl/mod.rs @@ -0,0 +1,7 @@ +mod cum_reward; +mod ep_len; +mod exploration_rate; + +pub use cum_reward::*; +pub use ep_len::*; +pub use exploration_rate::*; diff --git a/crates/burn-train/src/metric/rouge.rs b/crates/burn-train/src/metric/rouge.rs new file mode 100644 index 0000000..f970573 --- /dev/null +++ b/crates/burn-train/src/metric/rouge.rs @@ -0,0 +1,282 @@ +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{ + Metric, MetricAttributes, MetricName, Numeric, NumericAttributes, NumericEntry, +}; +use burn_core::tensor::{Int, Tensor}; +use std::sync::Arc; + +fn lcs_length(a: &[i32], b: &[i32]) -> usize { + let (shorter, longer) = if a.len() <= b.len() { (a, b) } else { (b, a) }; + let mut prev = vec![0usize; shorter.len() + 1]; + let mut curr = vec![0usize; shorter.len() + 1]; + + for &x in longer { + for (j, &y) in shorter.iter().enumerate() { + if x == y { + curr[j + 1] = prev[j] + 1; + } else { + curr[j + 1] = curr[j].max(prev[j + 1]); + } + } + core::mem::swap(&mut prev, &mut curr); + } + prev[shorter.len()] +} + +/// ROUGE-L metric based on longest common subsequence. +#[derive(Clone)] +pub struct RougeLScore { + name: MetricName, + state: NumericMetricState, + pad_token: Option, +} + +/// Input for [RougeLScore]. +#[derive(new)] +pub struct RougeLInput { + /// Predicted token sequences. + pub outputs: Tensor<2, Int>, + /// Reference token sequences. + pub targets: Tensor<2, Int>, +} + +impl Default for RougeLScore { + fn default() -> Self { + Self::new() + } +} + +impl RougeLScore { + /// Creates a new ROUGE-L metric. + pub fn new() -> Self { + Self { + name: Arc::new("ROUGE-L".to_string()), + state: NumericMetricState::default(), + pad_token: None, + } + } + + /// Sets the pad token index. Tokens matching this value are stripped + /// from the right of each sequence before scoring. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +impl Metric for RougeLScore { + type Input = RougeLInput; + + fn update(&mut self, input: &RougeLInput, _metadata: &MetricMetadata) -> SerializedEntry { + let outputs = &input.outputs; + let targets = &input.targets; + let [batch_size, seq_len] = targets.dims(); + + let outputs_data = outputs.to_data().iter::().collect::>(); + let targets_data = targets.to_data().iter::().collect::>(); + + let pad_token = self.pad_token.map(|p| p as i32); + + let mut total_f1 = 0.0_f64; + + for i in 0..batch_size { + let start = i * seq_len; + let end = (i + 1) * seq_len; + + let output_seq = &outputs_data[start..end]; + let target_seq = &targets_data[start..end]; + + let output_seq = match pad_token { + Some(pad) => { + let len = output_seq + .iter() + .position(|&x| x == pad) + .unwrap_or(output_seq.len()); + &output_seq[..len] + } + None => output_seq, + }; + let target_seq = match pad_token { + Some(pad) => { + let len = target_seq + .iter() + .position(|&x| x == pad) + .unwrap_or(target_seq.len()); + &target_seq[..len] + } + None => target_seq, + }; + + let lcs_len = lcs_length(target_seq, output_seq) as f64; + let ref_len = target_seq.len() as f64; + let cand_len = output_seq.len() as f64; + + if ref_len == 0.0 && cand_len == 0.0 { + total_f1 += 100.0; + continue; + } + + if ref_len == 0.0 || cand_len == 0.0 { + continue; + } + + let precision = lcs_len / cand_len; + let recall = lcs_len / ref_len; + + let f1 = if precision + recall > 0.0 { + 2.0 * precision * recall / (precision + recall) + } else { + 0.0 + }; + + total_f1 += f1 * 100.0; + } + + let value = total_f1 / batch_size as f64; + + self.state.update( + value, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset(); + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for RougeLScore { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::tensor::TensorData; + + #[test] + fn test_rouge_l_perfect_match() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert!((metric.value().current() - 100.0).abs() < 1e-6); + } + + #[test] + fn test_rouge_l_no_match() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[6, 7, 8, 9, 10]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert_eq!(0.0, metric.value().current()); + } + + #[test] + fn test_rouge_l_partial_match() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::from_data([[1, 3, 5, 7, 9]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + + let expected = 60.0; + assert!((metric.value().current() - expected).abs() < 1e-6); + } + + #[test] + fn test_rouge_l_shorter_candidate() { + let device = Default::default(); + let pad = 99_i64; + let mut metric = RougeLScore::new().with_pad_token(pad as usize); + + let preds = Tensor::from_data([[1, 2, 3, pad, pad]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + + let expected = 75.0; + assert!((metric.value().current() - expected).abs() < 1e-6); + } + + #[test] + fn test_rouge_l_with_padding() { + let device = Default::default(); + let pad = 99_i64; + let mut metric = RougeLScore::new().with_pad_token(pad as usize); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5, pad, pad]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5, pad, pad]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert!((metric.value().current() - 100.0).abs() < 1e-6); + } + + #[test] + fn test_rouge_l_batch() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5], [11, 12, 13, 14, 15]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert!((metric.value().current() - 50.0).abs() < 1e-6); + } + + #[test] + fn test_rouge_l_both_empty() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::<2, Int>::from_data(TensorData::from([[0i32; 0]; 1]), &device); + let tgts = Tensor::<2, Int>::from_data(TensorData::from([[0i32; 0]; 1]), &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert!((metric.value().current() - 100.0).abs() < 1e-6); + } + + #[test] + fn test_clear_resets_state() { + let device = Default::default(); + let mut metric = RougeLScore::new(); + + let preds = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + let tgts = Tensor::from_data([[1, 2, 3, 4, 5]], &device); + + metric.update(&RougeLInput::new(preds, tgts), &MetricMetadata::fake()); + assert!(metric.value().current() > 0.0); + + metric.clear(); + assert!(metric.value().current().is_nan()); + } +} diff --git a/crates/burn-train/src/metric/state.rs b/crates/burn-train/src/metric/state.rs new file mode 100644 index 0000000..4353359 --- /dev/null +++ b/crates/burn-train/src/metric/state.rs @@ -0,0 +1,219 @@ +use std::sync::Arc; + +use burn_core::tensor::{Bool, Tensor}; + +use crate::metric::{MetricName, NumericEntry, SerializedEntry, format_float}; + +/// Useful utility to implement numeric metrics. +/// +/// # Notes +/// +/// The numeric metric store values inside floats. +/// Even if some metric are integers, their mean are floats. +#[derive(Clone)] +pub struct NumericMetricState { + sum: f64, + count: usize, + current: f64, + current_count: usize, +} +/// Accumulates raw predictions and targets across batches. +/// +/// Used by rank-based metrics (AUROC, AUC-PR) that must recompute over the +/// whole epoch. Buffers are freed on [`reset`](Self::reset). +#[derive(Clone)] +pub struct PredictionAccumulatorState { + predictions: Vec>, + targets: Vec>, + current: f64, +} + +/// Formatting options for the [numeric metric state](NumericMetricState). +pub struct FormatOptions { + name: Arc, + unit: Option, + precision: Option, +} + +impl PredictionAccumulatorState { + /// Create a new [prediction accumulator state](PredictionAccumulatorState). + pub fn new() -> Self { + Self { + predictions: vec![], + targets: vec![], + current: f64::NAN, + } + } + + /// Accumulate a batch of predictions and targets. + pub fn accumulate(&mut self, preds: Tensor<2>, targets: Tensor<2, Bool>) { + self.predictions.push(preds); + self.targets.push(targets); + } + + /// All accumulated predictions and targets, concatenated along the samples. + pub fn tensors(&self) -> (Tensor<2>, Tensor<2, Bool>) { + ( + Tensor::cat(self.predictions.clone(), 0), + Tensor::cat(self.targets.clone(), 0), + ) + } + + /// Record the value computed over the accumulated set and return the entry + /// to log. Metrics using this state must declare + /// [`NumericAggregation::Last`](crate::metric::NumericAggregation). + pub fn update(&mut self, value: f64, format: FormatOptions) -> SerializedEntry { + self.current = value; + + let serialized = NumericEntry::Value(value).serialize(); + + let formatted_value = match format.precision { + Some(precision) => format_float(value, precision), + None => format!("{value}"), + }; + let formatted = match format.unit { + Some(unit) => format!("epoch {formatted_value} {unit}"), + None => format!("epoch {formatted_value}"), + }; + + SerializedEntry::new(formatted, serialized) + } + + /// Current value (computed over the accumulated set). + pub fn value(&self) -> NumericEntry { + NumericEntry::Value(self.current) + } + + /// Reset the state, freeing the accumulated tensors. + pub fn reset(&mut self) { + self.predictions.clear(); + self.targets.clear(); + self.current = f64::NAN; + } +} + +impl Default for PredictionAccumulatorState { + fn default() -> Self { + Self::new() + } +} + +impl FormatOptions { + /// Create the [formatting options](FormatOptions) with a name. + pub fn new(name: MetricName) -> Self { + Self { + name: name.clone(), + unit: None, + precision: None, + } + } + + /// Specify the metric unit. + pub fn unit(mut self, unit: &str) -> Self { + self.unit = Some(unit.to_string()); + self + } + + /// Specify the floating point precision. + pub fn precision(mut self, precision: usize) -> Self { + self.precision = Some(precision); + self + } + + /// Get the metric name. + pub fn name(&self) -> &Arc { + &self.name + } + + /// Get the metric unit. + pub fn unit_value(&self) -> &Option { + &self.unit + } + + /// Get the precision. + pub fn precision_value(&self) -> Option { + self.precision + } +} + +impl NumericMetricState { + /// Create a new [numeric metric state](NumericMetricState). + pub fn new() -> Self { + Self { + sum: 0.0, + count: 0, + current: f64::NAN, + current_count: 0, + } + } + + /// Reset the state. + pub fn reset(&mut self) { + self.sum = 0.0; + self.count = 0; + self.current = f64::NAN; + self.current_count = 0; + } + + /// Update the state. + pub fn update( + &mut self, + value: f64, + batch_size: usize, + format: FormatOptions, + ) -> SerializedEntry { + self.sum += value * batch_size as f64; + self.count += batch_size; + self.current = value; + self.current_count = batch_size; + + let value_current = value; + let value_running = self.sum / self.count as f64; + // Numeric metric state is an aggregated value + let serialized = NumericEntry::Aggregated { + aggregated_value: value_current, + count: batch_size, + } + .serialize(); + + let (formatted_current, formatted_running) = match format.precision { + Some(precision) => ( + format_float(value_current, precision), + format_float(value_running, precision), + ), + None => (format!("{value_current}"), format!("{value_running}")), + }; + + // TODO: naming inconsistent with RL. + let formatted = match format.unit { + Some(unit) => { + format!("epoch {formatted_running} {unit} - batch {formatted_current} {unit}") + } + None => format!("epoch {formatted_running} - batch {formatted_current}"), + }; + + SerializedEntry::new(formatted, serialized) + } + + /// Get the numeric value. + pub fn current_value(&self) -> NumericEntry { + NumericEntry::Aggregated { + aggregated_value: self.current, + count: self.current_count, + } + } + + /// Get the running aggregated value. + pub fn running_value(&self) -> NumericEntry { + NumericEntry::Aggregated { + aggregated_value: self.sum / self.count as f64, + count: self.count, + } + } +} + +impl Default for NumericMetricState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/burn-train/src/metric/store/aggregate.rs b/crates/burn-train/src/metric/store/aggregate.rs new file mode 100644 index 0000000..051bef7 --- /dev/null +++ b/crates/burn-train/src/metric/store/aggregate.rs @@ -0,0 +1,312 @@ +use crate::{ + logger::MetricLogger, + metric::{MetricAttributes, MetricDefinition, NumericAggregation, NumericEntry, store::Split}, +}; +use std::collections::HashMap; + +use super::{Aggregate, Direction}; + +/// Type that can be used to fetch and use numeric metric aggregates. +#[derive(Default, Debug)] +pub(crate) struct NumericMetricsAggregate { + value_for_each_epoch: HashMap, + aggregations: HashMap, +} + +#[derive(new, Hash, PartialEq, Eq, Debug)] +struct Key { + name: String, + epoch: usize, + split: Split, + aggregate: Aggregate, +} + +impl NumericMetricsAggregate { + /// Record each numeric metric's epoch-aggregation strategy from its definition. + pub(crate) fn register_definitions(&mut self, definitions: &[MetricDefinition]) { + for def in definitions { + if let MetricAttributes::Numeric(numeric) = &def.attributes { + self.aggregations + .insert(def.name.clone(), numeric.aggregation); + } + } + } + + pub(crate) fn aggregate( + &mut self, + name: &str, + epoch: usize, + split: &Split, + aggregate: Aggregate, + loggers: &mut [Box], + ) -> Option { + let key = Key::new(name.to_string(), epoch, split.clone(), aggregate); + + if let Some(value) = self.value_for_each_epoch.get(&key) { + return Some(*value); + } + + // How this metric reduces its per-batch points (defaults to a weighted mean). + let aggregation = self.aggregations.get(name).copied().unwrap_or_default(); + + let points = || { + let mut errors = Vec::new(); + for logger in loggers { + match logger.read_numeric(name, epoch, split) { + Ok(points) => return Ok(points), + Err(err) => errors.push(err), + }; + } + + Err(errors.join(" ")) + }; + + let points = points().expect("Can read values"); + + if points.is_empty() { + return None; + } + + let value = match aggregation { + // Already computed over the whole epoch; do not average. + NumericAggregation::Last => points.last().expect("Points are not empty").current(), + // Weighted by the *actual* number of points since mini-batches may differ in size. + NumericAggregation::Mean => { + let (sum, num_points) = points + .into_iter() + .map(|entry| match entry { + NumericEntry::Value(v) => (v, 1), + // Right now the mean is the only aggregate available, so we can assume that the sum + // of an entry corresponds to (value * number of elements) + NumericEntry::Aggregated { + aggregated_value, + count, + } => (aggregated_value * count as f64, count), + }) + .reduce(|(acc_v, acc_n), (v, n)| (acc_v + v, acc_n + n)) + .unwrap(); + match aggregate { + Aggregate::Mean => sum / num_points as f64, + } + } + }; + + self.value_for_each_epoch.insert(key, value); + Some(value) + } + + pub(crate) fn find_epoch( + &mut self, + name: &str, + split: &Split, + aggregate: Aggregate, + direction: Direction, + loggers: &mut [Box], + ) -> Option { + let mut data = Vec::new(); + let mut current_epoch = 1; + + while let Some(value) = self.aggregate(name, current_epoch, split, aggregate, loggers) { + data.push(value); + current_epoch += 1; + } + + if data.is_empty() { + return None; + } + + let mut current_value = match &direction { + Direction::Lowest => f64::MAX, + Direction::Highest => f64::MIN, + }; + + for (i, value) in data.into_iter().enumerate() { + match &direction { + Direction::Lowest => { + if value < current_value { + current_value = value; + current_epoch = i + 1; + } + } + Direction::Highest => { + if value > current_value { + current_value = value; + current_epoch = i + 1; + } + } + } + } + + Some(current_epoch) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::{ + logger::{FileMetricLogger, InMemoryMetricLogger}, + metric::{MetricDefinition, MetricEntry, MetricId, SerializedEntry, store::MetricsUpdate}, + }; + + use super::*; + + struct TestLogger { + logger: FileMetricLogger, + epoch: usize, + } + const NAME: &str = "test-logger"; + + impl TestLogger { + fn new() -> Self { + Self { + logger: FileMetricLogger::new("/tmp"), + epoch: 1, + } + } + fn log(&mut self, num: f64) { + let entry = MetricEntry::new( + MetricId::new(Arc::new(NAME.into())), + SerializedEntry::new(num.to_string(), num.to_string()), + ); + let entries = Vec::from([entry]); + let metrics_update = MetricsUpdate::new(entries, vec![]); + self.logger.log(metrics_update, self.epoch, &Split::Train); + } + fn log_definition(&mut self) { + let definition = MetricDefinition { + metric_id: MetricId::new(Arc::new(NAME.into())), + name: NAME.into(), + attributes: crate::metric::MetricAttributes::None, + description: None, + }; + self.logger.log_metric_definition(definition); + } + fn new_epoch(&mut self) { + self.epoch += 1; + } + } + + #[test] + fn should_find_epoch() { + let mut logger = TestLogger::new(); + let mut aggregate = NumericMetricsAggregate::default(); + logger.log_definition(); + + logger.log(500.); // Epoch 1 + logger.log(1000.); // Epoch 1 + logger.new_epoch(); + logger.log(200.); // Epoch 2 + logger.log(1000.); // Epoch 2 + logger.new_epoch(); + logger.log(10000.); // Epoch 3 + + let value = aggregate + .find_epoch( + NAME, + &Split::Train, + Aggregate::Mean, + Direction::Lowest, + &mut [Box::new(logger.logger)], + ) + .unwrap(); + + assert_eq!(value, 2); + } + + #[test] + fn should_aggregate_numeric_entry() { + let mut logger = InMemoryMetricLogger::default(); + let mut aggregate = NumericMetricsAggregate::default(); + let metric_name = Arc::new("Loss".to_string()); + let metric_id = MetricId::new(metric_name.clone()); + let definition = MetricDefinition { + metric_id: metric_id.clone(), + name: metric_name.to_string(), + attributes: crate::metric::MetricAttributes::None, + description: None, + }; + logger.log_metric_definition(definition); + + // Epoch 1 + let loss_1 = 0.5; + let loss_2 = 1.25; // (1.5 + 1.0) / 2 = 2.5 / 2 + let entry = MetricEntry::new( + metric_id.clone(), + SerializedEntry::new(loss_1.to_string(), NumericEntry::Value(loss_1).serialize()), + ); + let entries = Vec::from([entry]); + let metrics_update = MetricsUpdate::new(entries, vec![]); + logger.log(metrics_update, 1, &Split::Train); + let entry = MetricEntry::new( + metric_id.clone(), + SerializedEntry::new( + loss_2.to_string(), + NumericEntry::Aggregated { + aggregated_value: loss_2, + count: 2, + } + .serialize(), + ), + ); + let entries = Vec::from([entry]); + let metrics_update = MetricsUpdate::new(entries, vec![]); + logger.log(metrics_update, 1, &Split::Train); + + let value = aggregate + .aggregate( + &metric_name, + 1, + &Split::Train, + Aggregate::Mean, + &mut [Box::new(logger)], + ) + .unwrap(); + + // Average should be (0.5 + 1.25 * 2) / 3 = 1.0, not (0.5 + 1.25) / 2 = 0.875 + assert_eq!(value, 1.0); + } + + #[test] + fn should_take_last_value_for_last_aggregation() { + let mut logger = InMemoryMetricLogger::default(); + let mut aggregate = NumericMetricsAggregate::default(); + let metric_name = Arc::new("AUROC".to_string()); + let metric_id = MetricId::new(metric_name.clone()); + let definition = MetricDefinition { + metric_id: metric_id.clone(), + name: metric_name.to_string(), + attributes: crate::metric::NumericAttributes { + unit: None, + higher_is_better: true, + aggregation: NumericAggregation::Last, + } + .into(), + description: None, + }; + logger.log_metric_definition(definition.clone()); + aggregate.register_definitions(&[definition]); + + for value in [0.6, 0.7, 0.85] { + let entry = MetricEntry::new( + metric_id.clone(), + SerializedEntry::new(value.to_string(), NumericEntry::Value(value).serialize()), + ); + logger.log(MetricsUpdate::new(vec![entry], vec![]), 1, &Split::Train); + } + + let value = aggregate + .aggregate( + &metric_name, + 1, + &Split::Train, + Aggregate::Mean, + &mut [Box::new(logger)], + ) + .unwrap(); + + // Last point, not the mean (~0.717). + assert_eq!(value, 0.85); + } +} diff --git a/crates/burn-train/src/metric/store/base.rs b/crates/burn-train/src/metric/store/base.rs new file mode 100644 index 0000000..6f18fe5 --- /dev/null +++ b/crates/burn-train/src/metric/store/base.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use crate::metric::{MetricDefinition, MetricEntry, NumericEntry}; + +/// Event happening during the training/validation process. +pub enum Event { + /// Signal the start of an epoch with the wpoch number. + StartSplit(usize), + /// Signal the initialization of the metrics + MetricsInit(Vec), + /// Signal that metrics have been updated. + MetricsUpdate(MetricsUpdate), + /// Signal the end of an epoch. + EndEpoch(EpochSummary), +} + +/// Contains all metric information. +#[derive(new, Clone, Debug)] +pub struct NumericMetricUpdate { + /// Generic metric information. + pub entry: MetricEntry, + /// The numeric information. + pub numeric_entry: NumericEntry, + /// Numeric value averaged over the global step (epoch). + pub running_entry: NumericEntry, +} + +/// Contains all metric information. +#[derive(new, Clone, Debug)] +pub struct MetricsUpdate { + /// Metrics information related to non-numeric metrics. + pub entries: Vec, + /// Metrics information related to numeric metrics. + pub entries_numeric: Vec, +} + +/// Summary information about a given epoch +#[derive(new, Clone, Debug)] +pub struct EpochSummary { + /// Epoch number. + pub epoch_number: usize, + /// Dataset split (train, valid, test). + pub split: Split, +} + +/// Defines how training and validation events are collected and searched. +/// +/// This trait also exposes methods that uses the collected data to compute useful information. +pub trait EventStore: Send { + /// Collect a training/validation event. + fn add_event(&mut self, event: Event, split: Split); + + /// Find the epoch following the given criteria from the collected data. + fn find_epoch( + &mut self, + name: &str, + aggregate: Aggregate, + direction: Direction, + split: &Split, + ) -> Option; + + /// Find the metric value for the current epoch following the given criteria. + fn find_metric( + &mut self, + name: &str, + epoch: usize, + aggregate: Aggregate, + split: &Split, + ) -> Option; +} + +#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug)] +/// How to aggregate the metric. +pub enum Aggregate { + /// Compute the average. + Mean, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +/// The split to use. +pub enum Split { + /// The training split. + Train, + /// The validation split. + Valid, + /// The testing split, which might be tagged. + Test(Option>), +} + +impl std::fmt::Display for Split { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Split::Train => write!(f, "train"), + Split::Valid => write!(f, "valid"), + Split::Test(_) => write!(f, "test"), + } + } +} + +impl From for &'static str { + fn from(value: Split) -> Self { + match value { + Split::Train => "train", + Split::Valid => "valid", + Split::Test(_) => "test", + } + } +} + +#[derive(Copy, Clone)] +/// The direction of the query. +pub enum Direction { + /// Lower is better. + Lowest, + /// Higher is better. + Highest, +} diff --git a/crates/burn-train/src/metric/store/client.rs b/crates/burn-train/src/metric/store/client.rs new file mode 100644 index 0000000..3135cf4 --- /dev/null +++ b/crates/burn-train/src/metric/store/client.rs @@ -0,0 +1,171 @@ +use super::EventStore; +use super::{Aggregate, Direction, Event, Split}; +use std::sync::Arc; +use std::{sync::mpsc, thread::JoinHandle}; + +/// Type that allows to communicate with an [event store](EventStore). +pub struct EventStoreClient { + sender: mpsc::Sender, + handler: Option>, +} + +impl EventStoreClient { + /// Create a new [event store](EventStore) client. + pub(crate) fn new(store: C) -> Self + where + C: EventStore + 'static, + { + let (sender, receiver) = mpsc::channel(); + let thread = WorkerThread::new(store, receiver); + + let handler = std::thread::spawn(move || thread.run()); + let handler = Some(handler); + + Self { sender, handler } + } +} + +impl EventStoreClient { + /// Add a training event to the [event store](EventStore). + pub(crate) fn add_event_train(&self, event: Event) { + self.sender + .send(Message::OnEventTrain(event)) + .expect("Can send event to event store thread."); + } + + /// Add a validation event to the [event store](EventStore). + pub(crate) fn add_event_valid(&self, event: Event) { + self.sender + .send(Message::OnEventValid(event)) + .expect("Can send event to event store thread."); + } + + /// Add a testing event to the [event store](EventStore). + pub(crate) fn add_event_test(&self, event: Event, tag: Arc) { + self.sender + .send(Message::OnEventTest(event, tag)) + .expect("Can send event to event store thread."); + } + + /// Find the epoch following the given criteria from the collected data. + pub fn find_epoch( + &self, + name: &str, + aggregate: Aggregate, + direction: Direction, + split: &Split, + ) -> Option { + let (sender, receiver) = mpsc::sync_channel(1); + self.sender + .send(Message::FindEpoch( + name.to_string(), + aggregate, + direction, + split.clone(), + sender, + )) + .expect("Can send event to event store thread."); + + match receiver.recv() { + Ok(value) => value, + Err(err) => panic!("Event store thread crashed: {err:?}"), + } + } + + /// Find the metric value for the current epoch following the given criteria. + pub fn find_metric( + &self, + name: &str, + epoch: usize, + aggregate: Aggregate, + split: &Split, + ) -> Option { + let (sender, receiver) = mpsc::sync_channel(1); + self.sender + .send(Message::FindMetric( + name.to_string(), + epoch, + aggregate, + split.clone(), + sender, + )) + .expect("Can send event to event store thread."); + + match receiver.recv() { + Ok(value) => value, + Err(err) => panic!("Event store thread crashed: {err:?}"), + } + } +} + +#[derive(new)] +struct WorkerThread { + store: S, + receiver: mpsc::Receiver, +} + +impl WorkerThread +where + C: EventStore, +{ + fn run(mut self) { + for item in self.receiver.iter() { + match item { + Message::End => { + return; + } + Message::FindEpoch(name, aggregate, direction, split, callback) => { + let response = self.store.find_epoch(&name, aggregate, direction, &split); + callback + .send(response) + .expect("Can send response using callback channel."); + } + Message::FindMetric(name, epoch, aggregate, split, callback) => { + let response = self.store.find_metric(&name, epoch, aggregate, &split); + callback + .send(response) + .expect("Can send response using callback channel."); + } + Message::OnEventTrain(event) => self.store.add_event(event, Split::Train), + Message::OnEventValid(event) => self.store.add_event(event, Split::Valid), + Message::OnEventTest(event, tag) => { + self.store.add_event(event, Split::Test(Some(tag))) + } + } + } + } +} + +enum Message { + OnEventTest(Event, Arc), + OnEventTrain(Event), + OnEventValid(Event), + End, + FindEpoch( + String, + Aggregate, + Direction, + Split, + mpsc::SyncSender>, + ), + FindMetric( + String, + usize, + Aggregate, + Split, + mpsc::SyncSender>, + ), +} + +impl Drop for EventStoreClient { + fn drop(&mut self) { + self.sender + .send(Message::End) + .expect("Can send the end message to the event store thread."); + let handler = self.handler.take(); + + if let Some(handler) = handler { + handler.join().expect("The event store thread should stop."); + } + } +} diff --git a/crates/burn-train/src/metric/store/log.rs b/crates/burn-train/src/metric/store/log.rs new file mode 100644 index 0000000..153cc1f --- /dev/null +++ b/crates/burn-train/src/metric/store/log.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; + +use super::{Aggregate, Direction, Event, EventStore, Split, aggregate::NumericMetricsAggregate}; +use crate::logger::MetricLogger; + +#[derive(Default)] +pub(crate) struct LogEventStore { + loggers: Vec>, + aggregate: NumericMetricsAggregate, + epochs: HashMap, +} + +impl EventStore for LogEventStore { + fn add_event(&mut self, event: Event, split: Split) { + let epoch = *self.epochs.entry(split.clone()).or_insert(1); + + match event { + Event::StartSplit(epoch) => { + self.epochs.insert(split, epoch); + } + Event::MetricsInit(definitions) => { + self.aggregate.register_definitions(&definitions); + definitions.iter().for_each(|def| { + self.loggers + .iter_mut() + .for_each(|logger| logger.log_metric_definition(def.clone())); + }); + } + Event::MetricsUpdate(update) => { + self.loggers + .iter_mut() + .for_each(|logger| logger.log(update.clone(), epoch, &split)); + } + Event::EndEpoch(summary) => { + self.loggers + .iter_mut() + .for_each(|logger| logger.log_epoch_summary(summary.clone())); + } + } + } + + fn find_epoch( + &mut self, + name: &str, + aggregate: Aggregate, + direction: Direction, + split: &Split, + ) -> Option { + self.aggregate + .find_epoch(name, split, aggregate, direction, &mut self.loggers) + } + + fn find_metric( + &mut self, + name: &str, + epoch: usize, + aggregate: Aggregate, + split: &Split, + ) -> Option { + self.aggregate + .aggregate(name, epoch, split, aggregate, &mut self.loggers) + } +} + +impl LogEventStore { + /// Register a logger for metrics. + pub(crate) fn register_logger(&mut self, logger: ML) { + self.loggers.push(Box::new(logger)); + } + + /// Returns whether any loggers are registered. + pub(crate) fn has_loggers(&self) -> bool { + !self.loggers.is_empty() + } +} diff --git a/crates/burn-train/src/metric/store/mod.rs b/crates/burn-train/src/metric/store/mod.rs new file mode 100644 index 0000000..b86c0f4 --- /dev/null +++ b/crates/burn-train/src/metric/store/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod aggregate; + +mod base; +mod client; +mod log; + +pub(crate) use self::log::*; +pub use base::*; +pub use client::*; diff --git a/crates/burn-train/src/metric/top_k_acc.rs b/crates/burn-train/src/metric/top_k_acc.rs new file mode 100644 index 0000000..bfd7252 --- /dev/null +++ b/crates/burn-train/src/metric/top_k_acc.rs @@ -0,0 +1,176 @@ +use std::sync::Arc; + +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{ + Metric, MetricAttributes, MetricName, Numeric, NumericAttributes, NumericEntry, +}; +use burn_core::tensor::{Int, Tensor}; + +/// The Top-K accuracy metric. +/// +/// For K=1, this is equivalent to the [accuracy metric](`super::acc::AccuracyMetric`). +#[derive(Default, Clone)] +pub struct TopKAccuracyMetric { + name: Arc, + k: usize, + state: NumericMetricState, + /// If specified, targets equal to this value will be considered padding and will not count + /// towards the metric + pad_token: Option, +} + +/// The [top-k accuracy metric](TopKAccuracyMetric) input type. +#[derive(new)] +pub struct TopKAccuracyInput { + /// The outputs (batch_size, num_classes) + outputs: Tensor<2>, + /// The labels (batch_size) + targets: Tensor<1, Int>, +} + +impl TopKAccuracyMetric { + /// Creates the metric. + pub fn new(k: usize) -> Self { + Self { + name: Arc::new(format!("Top-K Accuracy @ TopK({})", k)), + k, + ..Default::default() + } + } + + /// Sets the pad token. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +impl Metric for TopKAccuracyMetric { + type Input = TopKAccuracyInput; + + fn update(&mut self, input: &TopKAccuracyInput, _metadata: &MetricMetadata) -> SerializedEntry { + let [batch_size, _n_classes] = input.outputs.dims(); + + let targets = input.targets.clone(); + + let outputs = input + .outputs + .clone() + .argsort_descending(1) + .narrow(1, 0, self.k) + .reshape([batch_size, self.k]); + + let (targets, num_pad) = match self.pad_token { + Some(pad_token) => { + // we ignore the samples where the target is equal to the pad token + let mask = targets.clone().equal_elem(pad_token as i64); + let num_pad = mask.clone().int().sum().into_scalar::(); + (targets.clone().mask_fill(mask, -1_i64), num_pad) + } + None => (targets.clone(), 0_f64), + }; + + let accuracy = targets + .reshape([batch_size, 1]) + .repeat_dim(1, self.k) + .equal(outputs) + .int() + .sum() + .into_scalar::() + / (batch_size as f64 - num_pad); + + self.state.update( + 100.0 * accuracy, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn clear(&mut self) { + self.state.reset() + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for TopKAccuracyMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_accuracy_without_padding() { + let device = Default::default(); + let mut metric = TopKAccuracyMetric::new(2); + let input = TopKAccuracyInput::new( + Tensor::from_data( + [ + [0.0, 0.2, 0.8], // 2, 1 + [1.0, 2.0, 0.5], // 1, 0 + [0.4, 0.1, 0.2], // 0, 2 + [0.6, 0.7, 0.2], // 1, 0 + ], + &device, + ), + Tensor::from_data([2, 2, 1, 1], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } + + #[test] + fn test_accuracy_with_padding() { + let device = Default::default(); + let mut metric = TopKAccuracyMetric::new(2).with_pad_token(3); + let input = TopKAccuracyInput::new( + Tensor::from_data( + [ + [0.0, 0.2, 0.8, 0.0], // 2, 1 + [1.0, 2.0, 0.5, 0.0], // 1, 0 + [0.4, 0.1, 0.2, 0.0], // 0, 2 + [0.6, 0.7, 0.2, 0.0], // 1, 0 + [0.0, 0.1, 0.2, 5.0], // Predicted padding should not count + [0.0, 0.1, 0.2, 0.0], // Error on padding should not count + [0.6, 0.0, 0.2, 0.0], // Error on padding should not count + ], + &device, + ), + Tensor::from_data([2, 2, 1, 1, 3, 3, 3], &device), + ); + + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } + + #[test] + fn test_parameterized_unique_name() { + let metric_a = TopKAccuracyMetric::new(2); + let metric_b = TopKAccuracyMetric::new(1); + let metric_c = TopKAccuracyMetric::new(2); + + assert_ne!(metric_a.name(), metric_b.name()); + assert_eq!(metric_a.name(), metric_c.name()); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/calibrators.rs b/crates/burn-train/src/metric/vision/afine/calibrators.rs new file mode 100644 index 0000000..38a725d --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/calibrators.rs @@ -0,0 +1,246 @@ +//! Calibrators, adapter, and final-score mapping for A-FINE. +//! +//! Four small modules sit between the heads and the final per-sample +//! quality score: +//! +//! - [`NrCalibrator`] — logistic mapping of the naturalness head's raw +//! output into `(-2, 2)`. Two learnable scalars. +//! - [`FrCalibratorWithLimit`] — logistic mapping of the fidelity head's +//! raw output into `(-2, 2)`, with `yita3` clamped to `[0.05, 0.95]` +//! and `yita4` to `[0.01, 0.70]` on every forward. +//! - [`AfineAdapter`] — `D = exp(softplus(k) * (N_ref - N_dis)) * N_dis + F`. +//! Single learnable scalar `k`. +//! - [`scale_finalscore`] — fixed logistic into `(0, 100)` with the +//! paper-reported constants. +//! +//! All three calibrators implement the same logistic shape: +//! `out = (yita1 - yita2) * sigmoid((x - yita3) / (|yita4| + eps)) + yita2`. +//! This is the algebraic equivalent of PyIQA's two-branch +//! `if exp_pow >= 10` formulation, rewritten as a single expression so +//! it batches correctly. PyIQA's branch only works on 0-D scalar +//! tensors. + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Module, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::{sigmoid, softplus}; + +const NR_YITA1: f64 = 2.0; +const NR_YITA2: f64 = -2.0; +const NR_YITA3_INIT: f32 = 4.9592; +const NR_YITA4_INIT: f32 = 21.5968; + +const FR_YITA1: f64 = 2.0; +const FR_YITA2: f64 = -2.0; +const FR_YITA3_INIT: f32 = 0.5; +const FR_YITA4_INIT: f32 = 0.15; +const FR_YITA3_MIN: f32 = 0.05; +const FR_YITA3_MAX: f32 = 0.95; +const FR_YITA4_MIN: f32 = 0.01; +const FR_YITA4_MAX: f32 = 0.70; + +const ADAPTER_K_INIT: f32 = 5.0; + +const SCALE_YITA1: f64 = 100.0; +const SCALE_YITA2: f64 = 0.0; +const SCALE_YITA3: f64 = -1.971_0; +const SCALE_YITA4: f64 = -2.373_4; + +/// Numerical-stability epsilon in the logistic denominator. Matches +/// PyIQA exactly; do not change without coordinating a parity-test +/// re-capture. +const EPS: f64 = 1e-10; + +/// Apply `(yita1 - yita2) * sigmoid((x - yita3) / (|yita4| + eps)) + yita2` +/// element-wise, broadcasting the 1-D scalar parameters over the input. +fn logistic_calibrate( + x: Tensor<2>, + yita3: Tensor<1>, + yita4_abs: Tensor<1>, + yita1: f64, + yita2: f64, +) -> Tensor<2> { + let yita3 = yita3.reshape([1, 1]); + let denom = yita4_abs.reshape([1, 1]).add_scalar(EPS); + let inner = (x - yita3) / denom; + sigmoid(inner).mul_scalar(yita1 - yita2).add_scalar(yita2) +} + +/// Configuration for [`NrCalibrator`]. +#[derive(Config, Debug)] +pub(crate) struct NrCalibratorConfig {} + +impl NrCalibratorConfig { + pub(crate) fn init(&self, device: &Device) -> NrCalibrator { + NrCalibrator { + yita3: Param::from_tensor(Tensor::from_floats([NR_YITA3_INIT], device)), + yita4: Param::from_tensor(Tensor::from_floats([NR_YITA4_INIT], device)), + } + } +} + +/// Naturalness logistic calibrator. Maps `[B, 1]` into `(-2, 2)`. +#[derive(Module, Debug)] +pub(crate) struct NrCalibrator { + pub(crate) yita3: Param>, + pub(crate) yita4: Param>, +} + +impl NrCalibrator { + pub(crate) fn forward(&self, x: Tensor<2>) -> Tensor<2> { + logistic_calibrate( + x, + self.yita3.val(), + self.yita4.val().abs(), + NR_YITA1, + NR_YITA2, + ) + } +} + +/// Configuration for [`FrCalibratorWithLimit`]. +#[derive(Config, Debug)] +pub(crate) struct FrCalibratorWithLimitConfig {} + +impl FrCalibratorWithLimitConfig { + pub(crate) fn init(&self, device: &Device) -> FrCalibratorWithLimit { + FrCalibratorWithLimit { + yita3: Param::from_tensor(Tensor::from_floats([FR_YITA3_INIT], device)), + yita4: Param::from_tensor(Tensor::from_floats([FR_YITA4_INIT], device)), + } + } +} + +/// Fidelity logistic calibrator with on-forward clamping of `yita3` and +/// `yita4`. PyIQA clamps the values used in the formula on every call; +/// the stored parameter is unchanged. +#[derive(Module, Debug)] +pub(crate) struct FrCalibratorWithLimit { + pub(crate) yita3: Param>, + pub(crate) yita4: Param>, +} + +impl FrCalibratorWithLimit { + pub(crate) fn forward(&self, x: Tensor<2>) -> Tensor<2> { + // Match PyIQA semantics exactly: clamp first, then abs. The + // clamp range is positive so the abs is a no-op for in-range + // values, but for an out-of-range checkpoint or a parameter + // that drifts negative during training the order matters. + let yita3 = self.yita3.val().clamp(FR_YITA3_MIN, FR_YITA3_MAX); + let yita4 = self.yita4.val().clamp(FR_YITA4_MIN, FR_YITA4_MAX); + logistic_calibrate(x, yita3, yita4.abs(), FR_YITA1, FR_YITA2) + } +} + +/// Configuration for [`AfineAdapter`]. +#[derive(Config, Debug)] +pub(crate) struct AfineAdapterConfig {} + +impl AfineAdapterConfig { + pub(crate) fn init(&self, device: &Device) -> AfineAdapter { + AfineAdapter { + k: Param::from_tensor(Tensor::from_floats([ADAPTER_K_INIT], device)), + } + } +} + +/// Fuses the calibrated naturalness and fidelity scores into a single +/// raw `D` value. +/// +/// `D = exp(softplus(k) * (N_ref - N_dis)) * N_dis + F`. The `softplus` +/// wrapper enforces `k > 0` without constraining the stored parameter. +#[derive(Module, Debug)] +pub(crate) struct AfineAdapter { + pub(crate) k: Param>, +} + +impl AfineAdapter { + pub(crate) fn forward( + &self, + x_nr: Tensor<2>, + ref_nr: Tensor<2>, + xref_fr: Tensor<2>, + ) -> Tensor<2> { + let k_pos = softplus(self.k.val(), 1.0).reshape([1, 1]); + let weight = (k_pos * (ref_nr - x_nr.clone())).exp(); + weight * x_nr + xref_fr + } +} + +/// Map a raw adapter score into `(0, 100)` via a fixed 4-parameter +/// logistic. Constants are the paper-reported defaults. +pub(crate) fn scale_finalscore(score: Tensor<2>) -> Tensor<2> { + let denom = SCALE_YITA4.abs() + EPS; + let inner = score.sub_scalar(SCALE_YITA3).div_scalar(denom); + sigmoid(inner) + .mul_scalar(SCALE_YITA1 - SCALE_YITA2) + .add_scalar(SCALE_YITA2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nr_calibrator_maps_to_bounded_range() { + let device = Default::default(); + let calibrator = NrCalibratorConfig::new().init(&device); + + let extremes = Tensor::<2>::from_floats([[-1000.0], [0.0], [1000.0]], &device); + let out = calibrator.forward(extremes); + let values = out.into_data().to_vec::().unwrap(); + + for v in &values { + assert!(*v >= -2.0 && *v <= 2.0, "out-of-range value: {v}"); + } + // Monotonic increasing. + assert!(values[0] < values[1]); + assert!(values[1] < values[2]); + } + + #[test] + fn fr_calibrator_clamp_does_not_panic() { + let device = Default::default(); + let calibrator = FrCalibratorWithLimitConfig::new().init(&device); + + let input = Tensor::<2>::from_floats([[0.5], [1.5], [-0.5]], &device); + let out = calibrator.forward(input); + + assert_eq!(out.dims(), [3, 1]); + } + + #[test] + fn adapter_forward_propagates_shape() { + let device = Default::default(); + let adapter = AfineAdapterConfig::new().init(&device); + + let nr_dis = Tensor::<2>::from_floats([[0.5], [-0.3]], &device); + let nr_ref = Tensor::<2>::from_floats([[0.7], [-0.1]], &device); + let fr = Tensor::<2>::from_floats([[0.2], [0.4]], &device); + + let out = adapter.forward(nr_dis, nr_ref, fr); + assert_eq!(out.dims(), [2, 1]); + } + + #[test] + fn scale_finalscore_maps_to_0_100_range() { + let device = Default::default(); + + let scores = Tensor::<2>::from_floats([[-1000.0], [-1.971], [1000.0]], &device); + let out = scale_finalscore(scores); + let values = out.into_data().to_vec::().unwrap(); + + assert!(values[0] >= 0.0 && values[0] <= 100.0); + assert!(values[2] >= 0.0 && values[2] <= 100.0); + // At yita3 = -1.971 the sigmoid argument is 0, so the output is + // 100 * 0.5 = 50. + assert!( + (values[1] - 50.0).abs() < 0.5, + "midpoint should be ~50, got {}", + values[1] + ); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/clip_attention.rs b/crates/burn-train/src/metric/vision/afine/clip_attention.rs new file mode 100644 index 0000000..4b36b3d --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/clip_attention.rs @@ -0,0 +1,134 @@ +//! Self-attention block matching PyTorch's `nn.MultiheadAttention` wire +//! format. +//! +//! PyTorch stores Q/K/V as a single fused `in_proj_weight` of shape +//! `[3 * d_model, d_model]` and `in_proj_bias` of shape `[3 * d_model]`. +//! Burn's [`burn_nn::attention::MultiHeadAttention`] uses three separate +//! Linear layers, so the CLIP checkpoint cannot map to it without +//! pre-splitting the weights at load time. +//! +//! This module keeps the fused layout: a single `qkv_proj` Linear of +//! shape `(d_model -> 3 * d_model)` and a `chunk(3, -1)` at forward, +//! giving a one-to-one mapping with the checkpoint. +//! +//! No attention mask is supported. CLIP's image encoder runs +//! unconditional self-attention; the text encoder (which uses a causal +//! mask) is not ported. + +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::softmax; +use burn_nn::{Linear, LinearConfig}; + +/// Configuration for [`ClipQkvAttention`]. +#[derive(Config, Debug)] +pub(crate) struct ClipQkvAttentionConfig { + /// Embedding dimension. Must be divisible by `n_heads`. + pub d_model: usize, + /// Number of attention heads. + pub n_heads: usize, +} + +impl ClipQkvAttentionConfig { + /// Initialize a [`ClipQkvAttention`] block. + pub(crate) fn init(&self, device: &Device) -> ClipQkvAttention { + assert_eq!( + self.d_model % self.n_heads, + 0, + "d_model ({}) must be divisible by n_heads ({})", + self.d_model, + self.n_heads + ); + let head_dim = self.d_model / self.n_heads; + ClipQkvAttention { + qkv_proj: LinearConfig::new(self.d_model, 3 * self.d_model) + .with_bias(true) + .init(device), + out_proj: LinearConfig::new(self.d_model, self.d_model) + .with_bias(true) + .init(device), + d_model: self.d_model, + n_heads: self.n_heads, + head_dim, + } + } +} + +/// Self-attention with a fused QKV projection, matching CLIP's checkpoint +/// layout one-to-one. +#[derive(Module, Debug)] +pub(crate) struct ClipQkvAttention { + /// Fused projection `d_model -> 3 * d_model` for Q, K, V. + pub(crate) qkv_proj: Linear, + /// Output projection `d_model -> d_model`. + pub(crate) out_proj: Linear, + /// Embedding dimension. + pub(crate) d_model: usize, + /// Number of attention heads. + pub(crate) n_heads: usize, + /// Per-head dimension (`d_model / n_heads`). + pub(crate) head_dim: usize, +} + +impl ClipQkvAttention { + /// Apply self-attention. Input and output shape: `[batch, seq, d_model]`. + pub(crate) fn forward(&self, x: Tensor<3>) -> Tensor<3> { + let [batch, seq, _] = x.dims(); + + let qkv = self.qkv_proj.forward(x); + let mut chunks = qkv.chunk(3, 2); + let value = chunks.remove(2); + let key = chunks.remove(1); + let query = chunks.remove(0); + + let to_heads = |t: Tensor<3>| { + t.reshape([batch, seq, self.n_heads, self.head_dim]) + .swap_dims(1, 2) + }; + let query = to_heads(query); + let key = to_heads(key); + let value = to_heads(value); + + let scale = (self.head_dim as f32).sqrt(); + let scores = query.matmul(key.transpose()).div_scalar(scale); + let weights = softmax(scores, 3); + + let context = weights + .matmul(value) + .swap_dims(1, 2) + .reshape([batch, seq, self.d_model]); + self.out_proj.forward(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Distribution; + + #[test] + fn clip_qkv_attention_preserves_shape() { + let device = Default::default(); + let attn = ClipQkvAttentionConfig::new(768, 12).init(&device); + + let input = Tensor::<3>::random([1, 50, 768], Distribution::Default, &device); + let output = attn.forward(input); + + assert_eq!(output.dims(), [1, 50, 768]); + } + + #[test] + fn clip_qkv_attention_handles_batch() { + let device = Default::default(); + let attn = ClipQkvAttentionConfig::new(64, 4).init(&device); + + let input = Tensor::<3>::random([3, 16, 64], Distribution::Default, &device); + let output = attn.forward(input); + + assert_eq!(output.dims(), [3, 16, 64]); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/clip_vit.rs b/crates/burn-train/src/metric/vision/afine/clip_vit.rs new file mode 100644 index 0000000..8f4b35c --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/clip_vit.rs @@ -0,0 +1,362 @@ +//! CLIP ViT-B/32 image encoder. +//! +//! Structurally a port of OpenAI CLIP's `VisionTransformer` minus the +//! text-side and joint-embedding projection. A-FINE consumes the +//! per-layer patch features (twelve `[batch, num_patches, 768]` tensors), +//! not the final CLIP embedding, so [`Self::forward_with_features`] is +//! the entry point used by the metric. [`Self::forward`] returns just the +//! `ln_post`-normalized class-token embedding and is provided for +//! completeness. +//! +//! Layout: NLD (`[batch, seq, embed]`) end-to-end. The PyTorch reference +//! permutes to LND (`[seq, batch, embed]`) inside the transformer stack +//! and back again before `ln_post`; attention is invariant to that swap, +//! so we stay in NLD throughout. + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Initializer, Module, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_nn::conv::{Conv2d, Conv2dConfig}; +use burn_nn::{LayerNorm, LayerNormConfig, Linear, LinearConfig}; + +use super::clip_attention::{ClipQkvAttention, ClipQkvAttentionConfig}; +use super::quick_gelu::QuickGelu; + +/// Configuration for [`ClipVisualEncoder`]. +#[derive(Config, Debug)] +pub struct ClipVisualEncoderConfig { + /// Input image channels. Defaults to 3 (RGB). + #[config(default = "3")] + pub in_channels: usize, + /// Token embedding dimension. Defaults to 768 (CLIP ViT-B/32). + #[config(default = "768")] + pub embed_dim: usize, + /// Patch (and conv stride) size. Defaults to 32 (CLIP ViT-B/32). + #[config(default = "32")] + pub patch_size: usize, + /// Number of transformer blocks. Defaults to 12. + #[config(default = "12")] + pub num_layers: usize, + /// Number of attention heads per block. Defaults to 12. + #[config(default = "12")] + pub num_heads: usize, + /// Hidden dimension of the per-block MLP. Defaults to 3072 (4 * 768). + #[config(default = "3072")] + pub mlp_dim: usize, + /// Expected input resolution. Defaults to 256. Must be a multiple of `patch_size`. + #[config(default = "256")] + pub image_size: usize, +} + +impl ClipVisualEncoderConfig { + /// Initialize a [`ClipVisualEncoder`] with random weights. + pub fn init(&self, device: &Device) -> ClipVisualEncoder { + assert_eq!( + self.image_size % self.patch_size, + 0, + "image_size ({}) must be a multiple of patch_size ({})", + self.image_size, + self.patch_size + ); + + let num_patches = (self.image_size / self.patch_size).pow(2); + let seq_len = num_patches + 1; // +1 for the prepended CLS token. + + let patch_embed = Conv2dConfig::new( + [self.in_channels, self.embed_dim], + [self.patch_size, self.patch_size], + ) + .with_stride([self.patch_size, self.patch_size]) + .with_bias(false) + .init(device); + + // Standard transformer init magnitude. Final values get overwritten + // when pretrained weights load; this just keeps random-init forward + // passes well-conditioned for tests. + let init = Initializer::Normal { + mean: 0.0, + std: 0.02, + }; + let class_token = init.init([self.embed_dim], device); + let positional_embedding = init.init([seq_len, self.embed_dim], device); + + let blocks = (0..self.num_layers) + .map(|_| { + TransformerBlockConfig::new(self.embed_dim, self.num_heads, self.mlp_dim) + .init(device) + }) + .collect(); + + ClipVisualEncoder { + patch_embed, + class_token, + positional_embedding, + ln_pre: LayerNormConfig::new(self.embed_dim).init(device), + blocks, + ln_post: LayerNormConfig::new(self.embed_dim).init(device), + embed_dim: self.embed_dim, + patch_size: self.patch_size, + image_size: self.image_size, + } + } +} + +/// Output of [`ClipVisualEncoder::forward_with_features`]. +/// +/// `features` are the per-block patch tokens A-FINE consumes. `cls` is +/// the post-`ln_post` class-token embedding, present only when the +/// caller requests it; the metric path skips the slice + LayerNorm to +/// avoid wasted work. +#[derive(Debug)] +pub struct ClipOutput { + /// Per-block patch tokens, length `num_layers`, each + /// `[batch, num_patches, embed_dim]`. + pub features: Vec>, + /// Post-`ln_post` class-token embedding, `[batch, embed_dim]`. `Some` + /// when `forward_with_features` is called with `return_cls = true`. + pub cls: Option>, +} + +/// CLIP ViT-B/32 image encoder. +/// +/// Consumes a normalized RGB image and returns either the class-token +/// embedding ([`Self::forward`]) or the per-layer patch-token features +/// used by A-FINE ([`Self::forward_with_features`]). +#[derive(Module, Debug)] +pub struct ClipVisualEncoder { + /// Patch-embedding convolution: `(in_channels, embed_dim)`, kernel and + /// stride both `patch_size`, no bias (matches CLIP). + pub(crate) patch_embed: Conv2d, + /// Learnable class token, shape `[embed_dim]`. Stored 1-D to match the + /// CLIP checkpoint key `class_embedding`; reshaped to `[1, 1, embed]` + /// at forward time. + pub(crate) class_token: Param>, + /// Learnable positional embedding, shape `[seq_len, embed_dim]`. + pub(crate) positional_embedding: Param>, + /// Pre-stack LayerNorm. + pub(crate) ln_pre: LayerNorm, + /// Stack of transformer blocks. + pub(crate) blocks: Vec, + /// Post-stack LayerNorm applied to the class token only. + pub(crate) ln_post: LayerNorm, + + pub(crate) embed_dim: usize, + pub(crate) patch_size: usize, + pub(crate) image_size: usize, +} + +impl ClipVisualEncoder { + /// Encode an image to its class-token embedding. + /// + /// # Shapes + /// - input: `[batch, in_channels, height, width]` + /// - output: `[batch, embed_dim]` + pub fn forward(&self, image: Tensor<4>) -> Tensor<2> { + self.forward_with_features(image, true) + .cls + .expect("cls requested") + } + + /// Encode an image and return the patch-token features after each + /// transformer block. The class token is stripped from each level. + /// The post-`ln_post` cls embedding is computed only when + /// `return_cls` is true; A-FINE itself does not use it. + /// + /// # Shapes + /// - input: `[batch, in_channels, height, width]` + /// - features: `Vec` of length `num_layers`, each + /// `[batch, num_patches, embed_dim]` + /// - cls (when present): `[batch, embed_dim]` + pub fn forward_with_features(&self, image: Tensor<4>, return_cls: bool) -> ClipOutput { + let [batch, _, height, width] = image.dims(); + assert_eq!( + height % self.patch_size, + 0, + "image height ({}) must be a multiple of patch_size ({})", + height, + self.patch_size + ); + assert_eq!( + width % self.patch_size, + 0, + "image width ({}) must be a multiple of patch_size ({})", + width, + self.patch_size + ); + + let embed = self.embed_dim; + + // Patch-embed: [B, C, H, W] -> [B, embed, H/p, W/p]. + let x = self.patch_embed.forward(image); + let [_, _, h_out, w_out] = x.dims(); + let num_patches = h_out * w_out; + let seq_len = num_patches + 1; + + // Flatten spatial axes, then put the patch axis before the channel + // axis: [B, embed, N] -> [B, N, embed]. + let x = x.reshape([batch, embed, num_patches]).swap_dims(1, 2); + + // Prepend the CLS token. Reshape [embed] -> [1, 1, embed], then + // broadcast-expand to [B, 1, embed] before concatenating along the + // sequence axis. + let cls = self + .class_token + .val() + .reshape([1, 1, embed]) + .expand([batch, 1, embed]); + let x = Tensor::cat(vec![cls, x], 1); + + // Add positional embedding. The pos-embed param is initialised to + // match `seq_len` for the configured image_size; runtime mismatches + // are caught here. Bicubic resize of a checkpointed pos-embed (e.g. + // 50 -> 65 when loading a 224-trained checkpoint at 256) is handled + // in the weights loader, not here. + let pos_seq = self.positional_embedding.dims()[0]; + assert_eq!( + pos_seq, seq_len, + "positional_embedding length {} does not match runtime sequence length {}", + pos_seq, seq_len + ); + let pos = self.positional_embedding.val().reshape([1, seq_len, embed]); + let x = x + pos; + + let mut x = self.ln_pre.forward(x); + + // Run the transformer stack, collecting patch features (CLS + // dropped) after each block. A-FINE consumes all twelve. + let mut features = Vec::with_capacity(self.blocks.len()); + for block in &self.blocks { + x = block.forward(x); + let patch_features = x.clone().slice([0..batch, 1..seq_len, 0..embed]); + features.push(patch_features); + } + + // ln_post is applied only to the class-token output. Skip both + // the slice and the LayerNorm when the caller doesn't need it. + let cls = return_cls.then(|| { + let cls = x.slice([0..batch, 0..1, 0..embed]).reshape([batch, embed]); + self.ln_post.forward(cls) + }); + + ClipOutput { features, cls } + } +} + +/// Configuration for a single transformer block. +#[derive(Config, Debug)] +pub(crate) struct TransformerBlockConfig { + pub d_model: usize, + pub n_heads: usize, + pub mlp_dim: usize, +} + +impl TransformerBlockConfig { + pub(crate) fn init(&self, device: &Device) -> TransformerBlock { + TransformerBlock { + ln_1: LayerNormConfig::new(self.d_model).init(device), + attn: ClipQkvAttentionConfig::new(self.d_model, self.n_heads).init(device), + ln_2: LayerNormConfig::new(self.d_model).init(device), + mlp: TransformerMlpConfig::new(self.d_model, self.mlp_dim).init(device), + } + } +} + +/// Pre-norm transformer block: `x = x + attn(ln_1(x))`, then +/// `x = x + mlp(ln_2(x))`. Matches CLIP's encoder block exactly. +#[derive(Module, Debug)] +pub(crate) struct TransformerBlock { + pub(crate) ln_1: LayerNorm, + pub(crate) attn: ClipQkvAttention, + pub(crate) ln_2: LayerNorm, + pub(crate) mlp: TransformerMlp, +} + +impl TransformerBlock { + pub(crate) fn forward(&self, x: Tensor<3>) -> Tensor<3> { + let attn_out = self.attn.forward(self.ln_1.forward(x.clone())); + let x = x + attn_out; + let mlp_out = self.mlp.forward(self.ln_2.forward(x.clone())); + x + mlp_out + } +} + +/// Configuration for [`TransformerMlp`]. +#[derive(Config, Debug)] +pub(crate) struct TransformerMlpConfig { + pub d_model: usize, + pub mlp_dim: usize, +} + +impl TransformerMlpConfig { + pub(crate) fn init(&self, device: &Device) -> TransformerMlp { + TransformerMlp { + c_fc: LinearConfig::new(self.d_model, self.mlp_dim) + .with_bias(true) + .init(device), + activation: QuickGelu, + c_proj: LinearConfig::new(self.mlp_dim, self.d_model) + .with_bias(true) + .init(device), + } + } +} + +/// Two-layer MLP with [`QuickGelu`] in between. CLIP's `c_fc` and `c_proj` +/// names are preserved so the checkpoint maps without remapping. +#[derive(Module, Debug)] +pub(crate) struct TransformerMlp { + pub(crate) c_fc: Linear, + pub(crate) activation: QuickGelu, + pub(crate) c_proj: Linear, +} + +impl TransformerMlp { + pub(crate) fn forward(&self, x: Tensor<3>) -> Tensor<3> { + let x = self.c_fc.forward(x); + let x = self.activation.forward(x); + self.c_proj.forward(x) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Distribution; + + #[test] + fn clip_visual_encoder_forward_shape() { + let device = Default::default(); + let encoder = ClipVisualEncoderConfig::new() + .with_image_size(64) + .with_num_layers(2) + .init(&device); + + let image = Tensor::<4>::random([1, 3, 64, 64], Distribution::Default, &device); + let cls = encoder.forward(image); + + assert_eq!(cls.dims(), [1, 768]); + } + + #[test] + fn clip_visual_encoder_forward_with_features_shape() { + let device = Default::default(); + let encoder = ClipVisualEncoderConfig::new() + .with_image_size(64) + .with_num_layers(3) + .init(&device); + + let image = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let ClipOutput { features, cls } = encoder.forward_with_features(image, true); + let cls = cls.expect("cls requested"); + + assert_eq!(cls.dims(), [2, 768]); + assert_eq!(features.len(), 3); + // 64x64 image at patch_size=32 -> 2x2 = 4 patches. + for level in &features { + assert_eq!(level.dims(), [2, 4, 768]); + } + } +} diff --git a/crates/burn-train/src/metric/vision/afine/heads.rs b/crates/burn-train/src/metric/vision/afine/heads.rs new file mode 100644 index 0000000..43eb55d --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/heads.rs @@ -0,0 +1,377 @@ +//! Naturalness and fidelity heads. +//! +//! Both heads consume the 12 per-block CLIP feature maps plus the raw +//! RGB image (re-introduced as a "level 0" feature). The heads' `mean` +//! and `std` buffers are CLIP's normalization constants, used here as +//! the **inverse** of preprocessing — they de-normalize the input back +//! into pixel space before computing raw-RGB statistics. +//! +//! - [`AfineQHead`] — naturalness, single-image, returns `[B, 1]`. +//! - [`AfineDHead`] — fidelity, two-image, returns `[B, 1]`. +//! +//! The `chns` tuple has 13 entries: `[3, 768, 768, ..., 768]`. The +//! leading 3 is the raw image; the remaining twelve 768s are the CLIP +//! transformer outputs after each block. + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Module, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::{relu, softplus}; +use burn_nn::{Gelu, Linear, LinearConfig}; + +/// CLIP RGB normalization mean (per-channel). Used as a de-normalization +/// constant inside the heads. +const CLIP_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73]; +/// CLIP RGB normalization std. +const CLIP_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11]; + +/// Per-level channel counts: raw RGB (3) plus 12 CLIP layers (768 each). +const CHNS: [usize; 13] = [ + 3, 768, 768, 768, 768, 768, 768, 768, 768, 768, 768, 768, 768, +]; +const NUM_LEVELS: usize = 13; + +/// Hidden dim of the shared per-CLIP-level projection (`proj_feat`). +const Q_HIDDEN_DIM: usize = 128; +/// Output dim of the first MLP layer in `proj_head`. +const Q_PROJ_HEAD_OUT: usize = 768; +/// Input dim of `proj_head`: 6 (raw, k=0) + 128 * 12 (projected, k=1..=12). +const Q_PROJ_HEAD_IN: usize = 6 + Q_HIDDEN_DIM * 12; + +/// Sum of `CHNS`. `alpha` and `beta` are length `D_CHNS_SUM` along the +/// last axis. +const D_CHNS_SUM: usize = 9219; + +/// Numerical-stability epsilon used in the SSIM-like ratios. **Must be +/// 1e-10**, not the larger 1e-6 that DISTS uses; the feature magnitudes +/// after CLIP + ReLU are unit-scale-ish, so DISTS's eps would overwhelm +/// the signal. +const EPS: f64 = 1e-10; + +fn build_clip_mean_std(device: &Device) -> (Tensor<4>, Tensor<4>) { + let mean = Tensor::from_floats( + [[[[CLIP_MEAN[0]]], [[CLIP_MEAN[1]]], [[CLIP_MEAN[2]]]]], + device, + ); + let std = Tensor::from_floats( + [[[[CLIP_STD[0]]], [[CLIP_STD[1]]], [[CLIP_STD[2]]]]], + device, + ); + (mean, std) +} + +/// Mean and biased variance over the token axis, returned as a 2-D +/// tensor `[B, 2 * channels]` (mean followed by var, both flattened). +fn level_mean_var(feat: Tensor<3>) -> (Tensor<3>, Tensor<3>, Tensor<2>) { + let mean = feat.clone().mean_dim(1); + let centered = feat - mean.clone(); + let var = centered.powi_scalar(2).mean_dim(1); + let descriptor = Tensor::cat( + vec![ + mean.clone().flatten::<2>(1, 2), + var.clone().flatten::<2>(1, 2), + ], + 1, + ); + (mean, var, descriptor) +} + +/// Configuration for [`AfineQHead`]. +#[derive(Config, Debug)] +pub struct AfineQHeadConfig {} + +impl AfineQHeadConfig { + /// Initialize the naturalness head with random weights. + pub fn init(&self, device: &Device) -> AfineQHead { + let (mean, std) = build_clip_mean_std(device); + AfineQHead { + mean, + std, + proj_feat: LinearConfig::new(2 * 768, Q_HIDDEN_DIM) + .with_bias(true) + .init(device), + proj_head_fc1: LinearConfig::new(Q_PROJ_HEAD_IN, Q_PROJ_HEAD_OUT) + .with_bias(true) + .init(device), + proj_head_fc2: LinearConfig::new(Q_PROJ_HEAD_OUT, 1) + .with_bias(true) + .init(device), + activation: Gelu::new(), + } + } +} + +/// A-FINE naturalness head. Per-level mean+variance over CLIP tokens, +/// shared projection on the 12 CLIP levels, then a small MLP to a +/// scalar. The level-0 raw-image descriptor (`[B, 6]`) is concatenated +/// directly without going through `proj_feat`. +/// +/// Activation is the erf-based [`burn_nn::Gelu`], not the QuickGELU used +/// elsewhere in this crate. PyIQA's reference is explicit on this. +#[derive(Module, Debug)] +pub struct AfineQHead { + pub(crate) mean: Tensor<4>, + pub(crate) std: Tensor<4>, + pub(crate) proj_feat: Linear, + pub(crate) proj_head_fc1: Linear, + pub(crate) proj_head_fc2: Linear, + pub(crate) activation: Gelu, +} + +impl AfineQHead { + /// Compute the naturalness score for one image plus its CLIP + /// features. + /// + /// # Shapes + /// - `image`: `[B, 3, H, W]`, **already CLIP-normalized**. + /// - `clip_features`: 12 levels of `[B, num_patches, 768]`. + /// - returns: `[B, 1]`. + pub fn forward(&self, image: Tensor<4>, clip_features: &[Tensor<3>]) -> Tensor<2> { + assert_eq!( + clip_features.len(), + 12, + "AfineQHead expects 12 CLIP feature maps, got {}", + clip_features.len() + ); + let [batch, channels, height, width] = image.dims(); + + // De-normalize: x = x * std + mean. + let img = image * self.std.clone() + self.mean.clone(); + + // [B, 3, H, W] -> [B, H*W, 3]: token-major raw-image features. + let img_feat = img + .reshape([batch, channels, height * width]) + .swap_dims(1, 2); + + let mut level_descriptors: Vec> = Vec::with_capacity(NUM_LEVELS); + + // Level 0: raw image, no projection. + let (_, _, raw_descriptor) = level_mean_var(img_feat); + level_descriptors.push(raw_descriptor); + + // Levels 1..=12: ReLU'd CLIP features, then shared `proj_feat`. + for h in clip_features { + let activated = relu(h.clone()); + let (_, _, descriptor) = level_mean_var(activated); + level_descriptors.push(self.proj_feat.forward(descriptor)); + } + + let concat_all = Tensor::cat(level_descriptors, 1); + let hidden = self + .activation + .forward(self.proj_head_fc1.forward(concat_all)); + self.proj_head_fc2.forward(hidden) + } +} + +/// Configuration for [`AfineDHead`]. +#[derive(Config, Debug)] +pub struct AfineDHeadConfig {} + +impl AfineDHeadConfig { + /// Initialize the fidelity head with random weights. + pub fn init(&self, device: &Device) -> AfineDHead { + let (mean, std) = build_clip_mean_std(device); + // PyIQA initializes alpha/beta with `.normal_(0.1, 0.01)`. That + // distribution doesn't really matter under random init — values + // are always overwritten by the checkpoint at `init_pretrained` + // time — but we mirror the magnitude here so the random-init + // forward stays well-conditioned. + let alpha = Tensor::random( + [1, 1, D_CHNS_SUM], + burn::tensor::Distribution::Normal(0.1, 0.01), + device, + ); + let beta = Tensor::random( + [1, 1, D_CHNS_SUM], + burn::tensor::Distribution::Normal(0.1, 0.01), + device, + ); + AfineDHead { + mean, + std, + alpha: Param::from_tensor(alpha), + beta: Param::from_tensor(beta), + } + } +} + +/// A-FINE fidelity head. SSIM-like luminance and contrast statistics +/// across 13 levels, weighted by globally-normalized softplus(alpha) and +/// softplus(beta). +#[derive(Module, Debug)] +pub struct AfineDHead { + pub(crate) mean: Tensor<4>, + pub(crate) std: Tensor<4>, + /// Luminance weights, shape `[1, 1, sum(chns)] = [1, 1, 9219]`. + pub(crate) alpha: Param>, + /// Contrast weights, same shape as `alpha`. + pub(crate) beta: Param>, +} + +impl AfineDHead { + /// Compute the fidelity score between distorted and reference + /// images. + /// + /// # Shapes + /// - `distorted`, `reference`: `[B, 3, H, W]`, both CLIP-normalized. + /// - `feat_dis`, `feat_ref`: 12 levels of `[B, num_patches, 768]`. + /// - returns: `[B, 1]`. + pub fn forward( + &self, + distorted: Tensor<4>, + reference: Tensor<4>, + feat_dis: &[Tensor<3>], + feat_ref: &[Tensor<3>], + ) -> Tensor<2> { + assert_eq!(feat_dis.len(), 12); + assert_eq!(feat_ref.len(), 12); + let [batch, channels, height, width] = distorted.dims(); + + // De-normalize both images and lay them out as token sequences. + let raw_x = (distorted * self.std.clone() + self.mean.clone()) + .reshape([batch, channels, height * width]) + .swap_dims(1, 2); + let raw_y = (reference * self.std.clone() + self.mean.clone()) + .reshape([batch, channels, height * width]) + .swap_dims(1, 2); + + // 13-entry feature lists: raw RGB at level 0, ReLU'd CLIP + // features at levels 1..=12. + let mut feat_x: Vec> = Vec::with_capacity(NUM_LEVELS); + let mut feat_y: Vec> = Vec::with_capacity(NUM_LEVELS); + feat_x.push(raw_x); + feat_y.push(raw_y); + for h in feat_dis { + feat_x.push(relu(h.clone())); + } + for h in feat_ref { + feat_y.push(relu(h.clone())); + } + + // Global softplus normalization. `alpha_/w_sum + beta_/w_sum` + // sums to ~1 across all 13 levels and both terms. We keep + // `w_sum` as a tensor and broadcast-divide so the autodiff graph + // stays intact for any future training-mode caller. + let alpha_sp = softplus(self.alpha.val(), 1.0); + let beta_sp = softplus(self.beta.val(), 1.0); + let w_sum = (alpha_sp.clone().sum() + beta_sp.clone().sum()) + .add_scalar(EPS) + .reshape([1, 1, 1]); + let alpha_norm = alpha_sp / w_sum.clone(); + let beta_norm = beta_sp / w_sum; + + let mut dist1: Option> = None; + let mut dist2: Option> = None; + let mut offset: usize = 0; + + for k in 0..NUM_LEVELS { + let cn = CHNS[k]; + let alpha_k = alpha_norm.clone().slice([0..1, 0..1, offset..offset + cn]); + let beta_k = beta_norm.clone().slice([0..1, 0..1, offset..offset + cn]); + + let xm = feat_x[k].clone().mean_dim(1); // [B, 1, cn] + let ym = feat_y[k].clone().mean_dim(1); + + // S1 (luminance): (2 * x_mean * y_mean + eps) + // / (x_mean^2 + y_mean^2 + eps). + let s1_num = (xm.clone() * ym.clone()).mul_scalar(2.0).add_scalar(EPS); + let s1_den = xm.clone().powi_scalar(2) + ym.clone().powi_scalar(2); + let s1 = s1_num / s1_den.add_scalar(EPS); + let term1 = (alpha_k * s1).sum_dim(2); // [B, 1, 1] + dist1 = Some(match dist1 { + None => term1, + Some(d) => d + term1, + }); + + // S2 (contrast/structure): + // var_x = mean((x - x_mean)^2) (biased), + // var_y = mean((y - y_mean)^2), + // cov_xy = mean(x*y) - x_mean * y_mean, + // S2 = (2 * cov_xy + eps) / (var_x + var_y + eps). + let xc = feat_x[k].clone() - xm.clone(); + let yc = feat_y[k].clone() - ym.clone(); + let x_var = xc.powi_scalar(2).mean_dim(1); + let y_var = yc.powi_scalar(2).mean_dim(1); + let xy_mean = (feat_x[k].clone() * feat_y[k].clone()).mean_dim(1); + let xy_cov = xy_mean - xm * ym; + let s2_num = xy_cov.mul_scalar(2.0).add_scalar(EPS); + let s2_den = (x_var + y_var).add_scalar(EPS); + let s2 = s2_num / s2_den; + let term2 = (beta_k * s2).sum_dim(2); + dist2 = Some(match dist2 { + None => term2, + Some(d) => d + term2, + }); + + offset += cn; + } + + let total = dist1.unwrap() + dist2.unwrap(); // [B, 1, 1] + let score = total.ones_like() - total; // 1 - (dist1 + dist2) + score.squeeze_dim::<2>(2) // [B, 1] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Distribution; + + #[test] + fn afine_q_head_forward_shape() { + let device = Default::default(); + let head = AfineQHeadConfig::new().init(&device); + + let image = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let features: Vec<_> = (0..12) + .map(|_| Tensor::<3>::random([2, 4, 768], Distribution::Default, &device)) + .collect(); + + let out = head.forward(image, &features); + assert_eq!(out.dims(), [2, 1]); + } + + #[test] + fn afine_d_head_forward_shape() { + let device = Default::default(); + let head = AfineDHeadConfig::new().init(&device); + + let dis = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let reference = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let feat_dis: Vec<_> = (0..12) + .map(|_| Tensor::<3>::random([2, 4, 768], Distribution::Default, &device)) + .collect(); + let feat_ref: Vec<_> = (0..12) + .map(|_| Tensor::<3>::random([2, 4, 768], Distribution::Default, &device)) + .collect(); + + let out = head.forward(dis, reference, &feat_dis, &feat_ref); + assert_eq!(out.dims(), [2, 1]); + } + + #[test] + fn afine_d_head_identical_inputs_unit_score() { + // When dis == ref and CLIP features match, S1 and S2 should both + // be ≈ 1, so dist1 + dist2 ≈ 1 and the final score 1 - (...) ≈ 0. + // Random init alpha/beta sum to 1 so this is a property-test + // sanity check on the global normalization. + let device = Default::default(); + let head = AfineDHeadConfig::new().init(&device); + + let image = Tensor::<4>::random([1, 3, 32, 32], Distribution::Default, &device); + let features: Vec<_> = (0..12) + .map(|_| Tensor::<3>::random([1, 1, 768], Distribution::Default, &device)) + .collect(); + + let out = head.forward(image.clone(), image, &features.clone(), &features); + let value = out.into_data().to_vec::().unwrap()[0]; + assert!( + value.abs() < 0.1, + "fidelity head on identical inputs should yield ~0, got {value}" + ); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/metric.rs b/crates/burn-train/src/metric/vision/afine/metric.rs new file mode 100644 index 0000000..2239713 --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/metric.rs @@ -0,0 +1,361 @@ +//! A-FINE metric: top-level module, configuration, end-to-end forward +//! pass, `ModuleDisplay`, and property tests. + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; + +use super::calibrators::{ + AfineAdapter, AfineAdapterConfig, FrCalibratorWithLimit, FrCalibratorWithLimitConfig, + NrCalibrator, NrCalibratorConfig, scale_finalscore, +}; +use super::clip_vit::{ClipVisualEncoder, ClipVisualEncoderConfig}; +use super::heads::{AfineDHead, AfineDHeadConfig, AfineQHead, AfineQHeadConfig}; + +/// CLIP RGB normalization mean (per-channel). Applied at the top of +/// `forward` when `normalize_input` is true. +const CLIP_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73]; +/// CLIP RGB normalization std. +const CLIP_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11]; + +/// Configuration for the A-FINE metric. +#[derive(Config, Debug)] +pub struct AfineConfig { + /// Expected input resolution. Must be a multiple of 32. Defaults to 256. + #[config(default = "256")] + pub image_size: usize, + + /// Apply CLIP RGB normalization inside the forward pass. Set to false + /// if the caller has already normalized the input. Defaults to true. + #[config(default = "true")] + pub normalize_input: bool, +} + +impl AfineConfig { + /// Initialize an A-FINE module with default (random) weights. + /// + /// All six learnable submodules are initialized fresh; useful for + /// shape/property tests but produces meaningless quality scores + /// until [`Self::init_pretrained`] is called. + pub fn init(&self, device: &Device) -> Afine { + assert_eq!( + self.image_size % 32, + 0, + "A-FINE image_size must be a multiple of 32, got {}", + self.image_size + ); + + Afine { + clip_visual: ClipVisualEncoderConfig::new() + .with_image_size(self.image_size) + .init(device), + qhead: AfineQHeadConfig::new().init(device), + dhead: AfineDHeadConfig::new().init(device), + nr_calibrator: NrCalibratorConfig::new().init(device), + fr_calibrator: FrCalibratorWithLimitConfig::new().init(device), + adapter: AfineAdapterConfig::new().init(device), + image_size: self.image_size, + normalize_input: self.normalize_input, + } + } + + /// Initialize an A-FINE module and load pretrained PyIQA weights. + /// + /// Downloads `afine.pth` (~600 MB) from the PyIQA Hugging Face + /// mirror on first call, caches it under + /// `~/.cache/burn-dataset/afine/`, and loads all six shards into the + /// matching submodules. + pub fn init_pretrained(&self, device: &Device) -> Afine { + let afine = self.init(device); + super::weights::load_pretrained_weights(afine) + } +} + +/// A-FINE (Adaptive Fidelity-Naturalness Evaluator) full-reference image +/// quality metric built on CLIP ViT-B/32 features. +/// +/// `forward(distorted, reference)` returns a per-sample quality score in +/// `(0, 100)` — higher means worse perceptual quality. The metric is +/// **not symmetric**: `forward(a, b) != forward(b, a)` in general, +/// because the adapter term `exp(k * (N_ref - N_dis))` weights the two +/// inputs differently. +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Afine { + pub(crate) clip_visual: ClipVisualEncoder, + pub(crate) qhead: AfineQHead, + pub(crate) dhead: AfineDHead, + pub(crate) nr_calibrator: NrCalibrator, + pub(crate) fr_calibrator: FrCalibratorWithLimit, + pub(crate) adapter: AfineAdapter, + + pub(crate) image_size: usize, + pub(crate) normalize_input: bool, +} + +impl Afine { + /// Compute the A-FINE quality score. + /// + /// # Shapes + /// - `distorted`, `reference`: `[batch, 3, H, W]` with `H` and `W` + /// both multiples of 32. Values in `[0, 1]` (RGB) when + /// `normalize_input` is true; already-normalized when false. + /// - returns: `[batch]` per-sample score in `(0, 100)`. + pub fn forward(&self, distorted: Tensor<4>, reference: Tensor<4>) -> Tensor<1> { + let [_, _, height, width] = distorted.dims(); + assert_eq!( + height % 32, + 0, + "A-FINE input height ({height}) must be a multiple of 32" + ); + assert_eq!( + width % 32, + 0, + "A-FINE input width ({width}) must be a multiple of 32" + ); + + let device = distorted.device(); + let (dis_norm, ref_norm) = if self.normalize_input { + let mean = Tensor::<4>::from_floats( + [[[[CLIP_MEAN[0]]], [[CLIP_MEAN[1]]], [[CLIP_MEAN[2]]]]], + &device, + ); + let std = Tensor::<4>::from_floats( + [[[[CLIP_STD[0]]], [[CLIP_STD[1]]], [[CLIP_STD[2]]]]], + &device, + ); + ( + (distorted - mean.clone()) / std.clone(), + (reference - mean) / std, + ) + } else { + (distorted, reference) + }; + + // CLIP gives us the 12 per-block patch-feature maps the heads + // consume; the class-token vector is unused by A-FINE so we ask + // the encoder to skip it. + let feat_dis = self + .clip_visual + .forward_with_features(dis_norm.clone(), false) + .features; + let feat_ref = self + .clip_visual + .forward_with_features(ref_norm.clone(), false) + .features; + + let natural_dis = self.qhead.forward(dis_norm.clone(), &feat_dis); + let natural_ref = self.qhead.forward(ref_norm.clone(), &feat_ref); + let natural_dis_scaled = self.nr_calibrator.forward(natural_dis); + let natural_ref_scaled = self.nr_calibrator.forward(natural_ref); + + let fidelity = self.dhead.forward(dis_norm, ref_norm, &feat_dis, &feat_ref); + let fidelity_scaled = self.fr_calibrator.forward(fidelity); + + let d = self + .adapter + .forward(natural_dis_scaled, natural_ref_scaled, fidelity_scaled); + let score = scale_finalscore(d); // [B, 1] + score.squeeze_dim::<1>(1) // [B] + } +} + +impl ModuleDisplay for Afine { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("backbone", &"CLIP ViT-B/32".to_string()) + .add("image_size", &self.image_size.to_string()) + .add("normalize_input", &self.normalize_input.to_string()) + .optional() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn::tensor::Distribution; + + fn small_metric() -> Afine { + // A small image_size keeps tests fast — 12-layer CLIP at the + // default 256x256 takes a noticeable fraction of a second per + // forward. + let device = Default::default(); + AfineConfig::new().with_image_size(64).init(&device) + } + + #[test] + fn afine_forward_shape() { + let device = Default::default(); + let metric = small_metric(); + let dis = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let reference = Tensor::<4>::random([2, 3, 64, 64], Distribution::Default, &device); + let score = metric.forward(dis, reference); + assert_eq!(score.dims(), [2]); + } + + #[test] + fn afine_batch_processing() { + let device = Default::default(); + let metric = small_metric(); + let dis = Tensor::<4>::random([4, 3, 64, 64], Distribution::Default, &device); + let reference = Tensor::<4>::random([4, 3, 64, 64], Distribution::Default, &device); + let score = metric.forward(dis, reference); + let values = score.into_data().to_vec::().unwrap(); + assert_eq!(values.len(), 4); + for v in values { + assert!(v.is_finite(), "A-FINE produced non-finite value: {v}"); + } + } + + #[test] + fn afine_finite_on_constant_inputs() { + // Guards against the c1=c2=1e-10 epsilon failing to keep the + // SSIM ratios finite when feature variance is zero. + let device = Default::default(); + let metric = small_metric(); + let zeros = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let ones = Tensor::<4>::ones([1, 3, 64, 64], &device); + let score = metric.forward(zeros, ones); + let value = score.into_data().to_vec::().unwrap()[0]; + assert!(value.is_finite(), "got non-finite value {value}"); + } + + #[test] + fn afine_asymmetry() { + // D(a, b) != D(b, a) — the adapter weights N_dis by an + // exponential of (N_ref - N_dis), so swapping inputs flips the + // sign in the exponent. + let device = Default::default(); + let metric = small_metric(); + let a = Tensor::<4>::random([1, 3, 64, 64], Distribution::Default, &device); + let b = Tensor::<4>::random([1, 3, 64, 64], Distribution::Default, &device); + let forward = metric + .forward(a.clone(), b.clone()) + .into_data() + .to_vec::() + .unwrap()[0]; + let reverse = metric.forward(b, a).into_data().to_vec::().unwrap()[0]; + assert!( + (forward - reverse).abs() > 1e-6, + "expected asymmetric output, got fwd={forward}, rev={reverse}" + ); + } + + #[test] + fn afine_image_size_must_be_multiple_of_32() { + let result = std::panic::catch_unwind(|| { + AfineConfig::new() + .with_image_size(33) + .init(&Default::default()); + }); + assert!(result.is_err(), "expected init to panic on bad image_size"); + } + + #[test] + fn display_afine() { + let metric = small_metric(); + let formatted = format!("{metric}"); + assert!( + formatted.contains("CLIP ViT-B/32"), + "expected backbone name in display output: {formatted}" + ); + } + + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_afine_pretrained_parity() { + // Numerical parity against PyIQA on a deterministic input pair. + // Catches silent QuickGELU-vs-GELU swaps, fused-QKV transpose- + // direction bugs, channel-order mistakes, `c1=c2=1e-10` epsilon + // drift, and any partially loaded shard — all of which the + // property tests miss. + // + // Expected value captured 2026-04-28 via + // `Notes/capture_afine_fixtures.py` (torch=2.11.0, + // pyiqa=0.1.15.post2). Input pair: `arange/(N-1)` vs `arange/N` + // reshaped to `[1, 3, 224, 224]`. Re-capture if the upstream + // PyIQA checkpoint or normalization conventions change. + const D_EXPECTED: f32 = 43.15711594_f32; + + let device = Default::default(); + let metric = AfineConfig::new() + .with_image_size(224) + .init_pretrained(&device); + + let total: i64 = 1 * 3 * 224 * 224; + let dis = Tensor::<1, burn::tensor::Int>::arange(0..total, &device) + .float() + .div_scalar((total - 1) as f64) + .reshape([1, 3, 224, 224]); + let reference = Tensor::<1, burn::tensor::Int>::arange(0..total, &device) + .float() + .div_scalar(total as f64) + .reshape([1, 3, 224, 224]); + + let value = metric + .forward(dis, reference) + .into_data() + .to_vec::() + .unwrap()[0]; + + // Tolerance: relative 5e-3 covers fp32 attention drift across + // 12 transformer layers. Absolute floor of 5e-3 prevents a + // captured value near zero from forcing absurd precision. In + // practice the burn output matches the captured PyIQA scalar to + // within ~5e-5 absolute, well inside this budget. + let tolerance = D_EXPECTED.abs() * 5e-3 + 5e-3; + assert!( + (value - D_EXPECTED).abs() < tolerance, + "expected {D_EXPECTED}, got {value} (tolerance {tolerance})" + ); + } + + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_afine_pretrained() { + // Loads the real PyIQA checkpoint (~600 MB on first run, cached + // afterwards) and verifies the forward pass produces a finite + // score that meaningfully differs from the random-init metric. + // The differs-from-random check guards against a silent + // `allow_partial(true)` skip — if a regex remap is wrong the + // weights stay at random init and the property tests still + // pass, but the pretrained output should land in a very + // different region of the score range. + let device = Default::default(); + let dis = Tensor::<4>::random([1, 3, 256, 256], Distribution::Default, &device); + let reference = Tensor::<4>::random([1, 3, 256, 256], Distribution::Default, &device); + + let random_metric = AfineConfig::new().init(&device); + let random_score = random_metric + .forward(dis.clone(), reference.clone()) + .into_data() + .to_vec::() + .unwrap()[0]; + + let pretrained_metric = AfineConfig::new().init_pretrained(&device); + let pretrained_score = pretrained_metric + .forward(dis, reference) + .into_data() + .to_vec::() + .unwrap()[0]; + + assert!( + pretrained_score.is_finite(), + "pretrained A-FINE produced non-finite output: {pretrained_score}" + ); + assert!( + (pretrained_score - random_score).abs() > 1e-3, + "pretrained output ({pretrained_score}) too close to random-init ({random_score}) — \ + a shard may have failed to load silently" + ); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/mod.rs b/crates/burn-train/src/metric/vision/afine/mod.rs new file mode 100644 index 0000000..dab543d --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/mod.rs @@ -0,0 +1,19 @@ +//! A-FINE (Adaptive Fidelity-Naturalness Evaluator) metric. +//! +//! Full-reference perceptual image quality metric that combines a naturalness +//! branch and a fidelity branch over CLIP ViT-B/32 features. +//! +//! Reference: "A Novel Fidelity-Naturalness Evaluator for Image Quality Assessment" +//! + +mod calibrators; +mod clip_attention; +mod clip_vit; +mod heads; +mod metric; +mod quick_gelu; +mod weights; + +pub use clip_vit::{ClipOutput, ClipVisualEncoder, ClipVisualEncoderConfig}; +pub use heads::{AfineDHead, AfineDHeadConfig, AfineQHead, AfineQHeadConfig}; +pub use metric::{Afine, AfineConfig}; diff --git a/crates/burn-train/src/metric/vision/afine/quick_gelu.rs b/crates/burn-train/src/metric/vision/afine/quick_gelu.rs new file mode 100644 index 0000000..22ef68a --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/quick_gelu.rs @@ -0,0 +1,65 @@ +//! QuickGELU activation used by OpenAI's CLIP. +//! +//! Defined as `x * sigmoid(1.702 * x)`. Distinct from the erf-based +//! [`burn_nn::Gelu`]: CLIP was trained with QuickGELU and substituting +//! standard GELU silently shifts every transformer-block MLP output. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Tensor; +use burn::tensor::activation::sigmoid; + +/// Approximation coefficient. Must be exactly 1.702; CLIP weights are +/// trained with this value and changing it breaks numerical parity. +const COEFFICIENT: f64 = 1.702; + +/// QuickGELU activation: `x * sigmoid(1.702 * x)`. +#[derive(Module, Debug, Default)] +pub(crate) struct QuickGelu; + +impl QuickGelu { + /// Apply the activation element-wise. Shape-preserving. + pub(crate) fn forward(&self, x: Tensor) -> Tensor { + let scaled = x.clone().mul_scalar(COEFFICIENT); + x * sigmoid(scaled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::tensor::Tolerance; + + type FT = f32; + + #[test] + fn quick_gelu_matches_formula() { + let device = Default::default(); + let activation = QuickGelu; + + let input = Tensor::<2>::from_floats([[-1.0, 0.0, 1.0, 2.0]], &device); + let output = activation.forward(input); + + // Hand-computed: x * sigmoid(1.702 * x). + // sigmoid(-1.702) = 1 / (1 + exp(1.702)) ≈ 0.154160 + // sigmoid( 1.702) ≈ 0.845840 + // sigmoid( 3.404) ≈ 0.967812 + let expected = Tensor::<2>::from_floats([[-0.154_160, 0.0, 0.845_840, 1.935_624]], &device); + + output + .into_data() + .assert_approx_eq::(&expected.into_data(), Tolerance::default()); + } + + #[test] + fn quick_gelu_preserves_shape() { + let device = Default::default(); + let activation = QuickGelu; + + let input = Tensor::<3>::zeros([2, 5, 8], &device); + let output = activation.forward(input); + + assert_eq!(output.dims(), [2, 5, 8]); + } +} diff --git a/crates/burn-train/src/metric/vision/afine/weights.rs b/crates/burn-train/src/metric/vision/afine/weights.rs new file mode 100644 index 0000000..ceb8b9d --- /dev/null +++ b/crates/burn-train/src/metric/vision/afine/weights.rs @@ -0,0 +1,257 @@ +//! Pretrained weights loading for A-FINE. +//! +//! `afine.pth` is published by the PyIQA project on Hugging Face and +//! bundles all six A-FINE shards into a single file: a fine-tuned CLIP +//! ViT-B/32 (`finetuned_clip`), the naturalness head (`natural`), the +//! fidelity head (`fidelity`), the two per-head logistic calibrators +//! (`natural_scale`, `fidelity_scale`), and the adapter (`adapter`). +//! Each shard is loaded into the corresponding submodule of [`Afine`] +//! via a separate [`PytorchStore`] with `with_top_level_key`. +//! +//! The OpenAI CLIP `ViT-B-32.pt` checkpoint is **not** used. That file +//! is a TorchScript archive that `burn-store` cannot read, and PyIQA's +//! `finetuned_clip` shard is the variant A-FINE was actually trained +//! with — substituting stock CLIP would silently produce wrong scores. +//! +//! ## Hosting +//! +//! The URL points at the PyIQA author's personal HF account, which is +//! the canonical source for these weights. There is no organization- +//! hosted mirror anywhere (HF orgs, Zenodo, figshare, ModelScope, OSF, +//! Kaggle, GitHub releases — all checked). The hosting question is +//! flagged in the PR description for the maintainer; if a different +//! mirror is preferred, swap the URL constant below. + +use burn_core as burn; + +use burn::module::Param; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_std::network::downloader::download_file_as_bytes; +use burn_store::pytorch::PytorchReader; +use burn_store::{ModuleSnapshot, PytorchStore}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use super::calibrators::{AfineAdapter, FrCalibratorWithLimit, NrCalibrator}; +use super::metric::Afine; + +/// Source URL for `afine.pth` on Hugging Face. +const AFINE_URL: &str = + "https://huggingface.co/chaofengc/IQA-PyTorch-Weights/resolve/main/afine.pth"; + +/// Cached filename inside `~/.cache/burn-dataset/afine/`. +const CACHE_FILENAME: &str = "afine.pth"; + +fn get_cache_dir() -> PathBuf { + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset") + .join("afine"); + if !cache_dir.exists() { + create_dir_all(&cache_dir).expect("Failed to create cache directory"); + } + cache_dir +} + +fn download_if_needed(url: &str, cache_path: &PathBuf, message: &str) { + if !cache_path.exists() { + let bytes = download_file_as_bytes(url, message); + let mut file = File::create(cache_path).expect("Failed to create cache file"); + file.write_all(&bytes).expect("Failed to write weights"); + } +} + +/// Download `afine.pth` (if not cached) and load all six shards into +/// the matching submodules of an `Afine` previously produced by +/// `AfineConfig::init`. +/// +/// Errors during loading are logged via `log::warn!` and the function +/// returns the module unconditionally — `allow_partial(true)` plus +/// per-shard regex remapping mean unmapped or unknown checkpoint keys +/// are silently dropped, matching the behaviour of LPIPS, DISTS, and +/// FID's pretrained loaders. +pub(crate) fn load_pretrained_weights(mut afine: Afine) -> Afine { + let cache_dir = get_cache_dir(); + let cache_path = cache_dir.join(CACHE_FILENAME); + download_if_needed(AFINE_URL, &cache_path, "Downloading A-FINE weights..."); + + afine.clip_visual = load_clip_shard(afine.clip_visual, &cache_path); + afine.qhead = load_qhead_shard(afine.qhead, &cache_path); + afine.dhead = load_simple_shard(afine.dhead, &cache_path, "fidelity", "fidelity head"); + + // The calibrator and adapter shards store yita3, yita4, and k as + // 0-D scalar tensors (`shape=()`). Burn's `Param>` of + // length 1 expects shape `(1,)`, and PytorchStore drops keys whose + // shape doesn't match the destination — silently leaving the + // params at random init. Load these manually via PytorchReader so + // the 0-D scalars become 1-element 1-D tensors. + let device = afine.adapter.k.val().device(); + afine.nr_calibrator = load_nr_calibrator_scalars(&cache_path, &device); + afine.fr_calibrator = load_fr_calibrator_scalars(&cache_path, &device); + afine.adapter = load_adapter_scalar(&cache_path, &device); + + afine +} + +/// Read a 0-D scalar value from a checkpoint shard, returning it as an +/// `f32`. Logs a warning and returns `None` on failure so the caller can +/// fall back to a default. +fn read_scalar_value>( + cache_path: P, + top_level_key: &str, + field_name: &str, +) -> Option { + let reader = PytorchReader::with_top_level_key(cache_path.as_ref(), top_level_key) + .map_err(|e| log::warn!("Failed to open shard '{top_level_key}': {e:?}")) + .ok()?; + let snapshot = reader.get(field_name)?; + let data = snapshot + .to_data() + .map_err(|e| log::warn!("Failed to read '{top_level_key}.{field_name}' tensor data: {e:?}")) + .ok()?; + let values = data + .to_vec::() + .map_err(|e| log::warn!("Failed to convert '{top_level_key}.{field_name}' to f32: {e:?}")) + .ok()?; + values.first().copied() +} + +fn scalar_param(value: f32, device: &Device) -> Param> { + Param::from_tensor(Tensor::from_floats([value], device)) +} + +fn load_nr_calibrator_scalars(cache_path: &PathBuf, device: &Device) -> NrCalibrator { + // PyIQA defaults if reading fails — preserves random-init fallback. + const NR_YITA3_FALLBACK: f32 = 4.9592; + const NR_YITA4_FALLBACK: f32 = 21.5968; + let yita3 = + read_scalar_value(cache_path, "natural_scale", "yita3").unwrap_or(NR_YITA3_FALLBACK); + let yita4 = + read_scalar_value(cache_path, "natural_scale", "yita4").unwrap_or(NR_YITA4_FALLBACK); + NrCalibrator { + yita3: scalar_param(yita3, device), + yita4: scalar_param(yita4, device), + } +} + +fn load_fr_calibrator_scalars(cache_path: &PathBuf, device: &Device) -> FrCalibratorWithLimit { + const FR_YITA3_FALLBACK: f32 = 0.5; + const FR_YITA4_FALLBACK: f32 = 0.15; + let yita3 = + read_scalar_value(cache_path, "fidelity_scale", "yita3").unwrap_or(FR_YITA3_FALLBACK); + let yita4 = + read_scalar_value(cache_path, "fidelity_scale", "yita4").unwrap_or(FR_YITA4_FALLBACK); + FrCalibratorWithLimit { + yita3: scalar_param(yita3, device), + yita4: scalar_param(yita4, device), + } +} + +fn load_adapter_scalar(cache_path: &PathBuf, device: &Device) -> AfineAdapter { + const K_FALLBACK: f32 = 5.0; + let k = read_scalar_value(cache_path, "adapter", "k").unwrap_or(K_FALLBACK); + AfineAdapter { + k: scalar_param(k, device), + } +} + +/// Load the `finetuned_clip` shard into the visual encoder. +/// +/// Key-remap rules strip the `visual.` prefix used in the CLIP state +/// dict and rename the few fields where the burn module diverges from +/// PyTorch's CLIP layout: `conv1` → `patch_embed`, +/// `class_embedding` → `class_token`, `transformer.resblocks` → `blocks`, +/// and the fused `attn.in_proj_*` → `attn.qkv_proj.*`. The +/// `weight`/`bias` ↔ `gamma`/`beta` rename for LayerNorm is handled +/// automatically by `PyTorchToBurnAdapter`. +/// +/// `with_regex(r"^visual\.")` filters the load to only the visual +/// encoder; the CLIP text encoder (~150 keys) is dropped at the filter +/// stage. `visual.proj` (the unused joint-embedding projection) has +/// no corresponding burn field and is silently dropped via +/// `allow_partial(true)`. +fn load_clip_shard( + mut clip: super::clip_vit::ClipVisualEncoder, + cache_path: &PathBuf, +) -> super::clip_vit::ClipVisualEncoder { + let mut store = PytorchStore::from_file(cache_path) + .with_top_level_key("finetuned_clip") + .allow_partial(true) + .skip_enum_variants(true) + // No `with_regex` filter: PathFilter matches against burn-side + // destination paths, not the source PyTorch keys, so a regex on + // `^visual\.` rejects every remapped key. Text-encoder keys + // (token_embedding, transformer.resblocks.*) have no + // corresponding burn fields and are dropped by + // `allow_partial(true)`, which is the same end result. + // + // Special case: the text encoder also has a top-level + // `positional_embedding` key (shape `[77, 512]`) which collides + // with the visual one after our `^visual\.positional_embedding$` + // rename targets `positional_embedding`. Without this first + // rule, HashMap iteration order decides which one wins, giving + // a flaky load. Rename the text encoder's first to a key that + // matches no burn field. + .with_key_remapping(r"^positional_embedding$", "_text_positional_embedding_drop") + .with_key_remapping(r"^visual\.conv1\.", "patch_embed.") + .with_key_remapping(r"^visual\.class_embedding$", "class_token") + .with_key_remapping(r"^visual\.positional_embedding$", "positional_embedding") + .with_key_remapping(r"^visual\.ln_pre\.", "ln_pre.") + .with_key_remapping(r"^visual\.ln_post\.", "ln_post.") + .with_key_remapping(r"^visual\.transformer\.resblocks\.", "blocks.") + .with_key_remapping(r"\.attn\.in_proj_weight$", ".attn.qkv_proj.weight") + .with_key_remapping(r"\.attn\.in_proj_bias$", ".attn.qkv_proj.bias"); + if let Err(e) = clip.load_from(&mut store) { + log::warn!( + "Some CLIP visual encoder weights could not be loaded: {:?}", + e + ); + } + clip +} + +/// Load the `natural` shard into the naturalness head. +/// +/// PyIQA stores the proj_head as a `nn.Sequential` with index-2 GELU, +/// so the FC layers come out as `proj_head.0.*` and `proj_head.2.*`. +/// We named them explicitly to avoid the GELU-index gap, hence the +/// remap. +fn load_qhead_shard( + mut qhead: super::heads::AfineQHead, + cache_path: &PathBuf, +) -> super::heads::AfineQHead { + let mut store = PytorchStore::from_file(cache_path) + .with_top_level_key("natural") + .allow_partial(true) + .skip_enum_variants(true) + .with_key_remapping(r"^proj_head\.0\.", "proj_head_fc1.") + .with_key_remapping(r"^proj_head\.2\.", "proj_head_fc2."); + if let Err(e) = qhead.load_from(&mut store) { + log::warn!("Some naturalness head weights could not be loaded: {:?}", e); + } + qhead +} + +/// Load any shard whose keys map directly to burn-side field names. +/// Used for the fidelity head, both calibrators, and the adapter. +fn load_simple_shard( + mut module: M, + cache_path: &PathBuf, + top_level_key: &'static str, + description: &str, +) -> M +where + M: ModuleSnapshot, +{ + let mut store = PytorchStore::from_file(cache_path) + .with_top_level_key(top_level_key) + .allow_partial(true) + .skip_enum_variants(true); + if let Err(e) = module.load_from(&mut store) { + log::warn!("Some {} weights could not be loaded: {:?}", description, e); + } + module +} diff --git a/crates/burn-train/src/metric/vision/dice.rs b/crates/burn-train/src/metric/vision/dice.rs new file mode 100644 index 0000000..19f910a --- /dev/null +++ b/crates/burn-train/src/metric/vision/dice.rs @@ -0,0 +1,341 @@ +use crate::metric::{MetricAttributes, MetricName, SerializedEntry}; + +use super::super::{ + Metric, MetricMetadata, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::{ + prelude::Tensor, + tensor::{ElementConversion, Int, s}, +}; + +/// Input type for the [DiceMetric]. +/// +/// # Type Parameters +/// - `D`: Number of dimensions. Should be more than, or equal to 3 (default 4). +pub struct DiceInput { + /// Model outputs (predictions), as a tensor. + outputs: Tensor, + /// Ground truth targets, as a tensor. + targets: Tensor, +} + +impl DiceInput { + /// Creates a new DiceInput with the given outputs and targets. + /// + /// Inputs are expected to have the dimensions `[B, C, ...]` + /// where `B` is the batch size, `C` is the number of classes, + /// and `...` represents additional dimensions (e.g., height, width for images). + /// + /// If `C` is more than 1, the first class (index 0) is considered the background. + /// Additionally, one-hot encoding is the responsibility of the caller. + /// + /// # Arguments + /// - `outputs`: The model outputs as a tensor. + /// - `targets`: The ground truth targets as a tensor. + /// + /// # Returns + /// A new instance of `DiceInput`. + /// + /// # Panics + /// - If `D` is less than 3. + /// - If `outputs` and `targets` do not have the same dimensions. + /// - If `outputs` or `targets` do not have exactly `D` dimensions. + /// - If `outputs` and `targets` do not have the same shape. + pub fn new(outputs: Tensor, targets: Tensor) -> Self { + assert!(D >= 3, "DiceInput requires at least 3 dimensions."); + assert!( + outputs.dims() == targets.dims(), + "Outputs and targets must have the same dimensions. Got {:?} and {:?}", + outputs.dims(), + targets.dims() + ); + Self { outputs, targets } + } +} + +/// Configuration for the [DiceMetric]. +#[derive(Debug, Clone, Copy)] +pub struct DiceMetricConfig { + /// Epsilon value to avoid division by zero. + pub epsilon: f64, + /// Whether to include the background class in the metric calculation. + /// The background is assumed to be the first class (index 0). + /// if `true`, will panic if there are fewer than 2 classes. + pub include_background: bool, +} + +impl Default for DiceMetricConfig { + fn default() -> Self { + Self { + epsilon: 1e-7, + include_background: false, + } + } +} + +/// The Dice-Sorenson coefficient (DSC) for evaluating overlap between two binary masks. +/// The DSC is defined as: +/// `DSC = 2 * (|X ∩ Y|) / (|X| + |Y|)` +/// where `X` is the model output and `Y` is the ground truth target. +/// +/// # Type Parameters +/// - `D`: Number of dimensions. Should be more than, or equal to 3 (default 4). +#[derive(Default, Clone)] +pub struct DiceMetric { + name: MetricName, + /// Internal state for numeric metric aggregation. + state: NumericMetricState, + /// Configuration for the metric. + config: DiceMetricConfig, +} + +impl DiceMetric { + /// Creates a new Dice metric instance with default config. + pub fn new() -> Self { + Self::with_config(DiceMetricConfig::default()) + } + + /// Creates a new Dice metric with a custom config. + pub fn with_config(config: DiceMetricConfig) -> Self { + let name = MetricName::new(format!("{D}D Dice Metric")); + assert!(D >= 3, "DiceMetric requires at least 3 dimensions."); + Self { + name, + config, + ..Default::default() + } + } +} + +impl Metric for DiceMetric { + type Input = DiceInput; + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn update(&mut self, item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + // Dice coefficient: 2 * (|X ∩ Y|) / (|X| + |Y|) + if item.outputs.dims() != item.targets.dims() { + panic!( + "Outputs and targets must have the same dimensions. Got {:?} and {:?}", + item.outputs.dims(), + item.targets.dims() + ); + } + + let dims = item.outputs.dims(); + let batch_size = dims[0]; + let n_classes = dims[1]; + + let mut outputs = item.outputs.clone(); + let mut targets = item.targets.clone(); + + if !self.config.include_background && n_classes > 1 { + // If not including background, we can ignore the first class + outputs = outputs.slice(s![.., 1..]); + targets = targets.slice(s![.., 1..]); + } else if self.config.include_background && n_classes < 2 { + // If including background, we need at least 2 classes + panic!("Dice metric requires at least 2 classes when including background."); + } + + let intersection = (outputs.clone() * targets.clone()).sum(); + let outputs_sum = outputs.sum(); + let targets_sum = targets.sum(); + + // Convert to f64 + let intersection_val = intersection.into_scalar::(); + let outputs_sum_val = outputs_sum.into_scalar::(); + let targets_sum_val = targets_sum.into_scalar::(); + + // Use epsilon from config + let epsilon = self.config.epsilon; + let dice = + (2.0 * intersection_val + epsilon) / (outputs_sum_val + targets_sum_val + epsilon); + + self.state.update( + dice, + batch_size, + FormatOptions::new(self.name()).precision(4), + ) + } + + /// Clears the metric state. + fn clear(&mut self) { + self.state.reset(); + } + + fn attributes(&self) -> MetricAttributes { + crate::metric::NumericAttributes { + unit: None, + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl crate::metric::Numeric for DiceMetric { + fn value(&self) -> crate::metric::NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> crate::metric::NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::Numeric; + use burn_core::tensor::{Shape, Tensor}; + + #[test] + fn test_dice_perfect_overlap() { + let device = Default::default(); + let mut metric = DiceMetric::<4>::new(); + let input = DiceInput::new( + Tensor::from_data([[[[1, 0], [1, 0]]]], &device), + Tensor::from_data([[[[1, 0], [1, 0]]]], &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!((metric.value().current() - 1.0).abs() < 1e-6); + } + + #[test] + fn test_dice_no_overlap() { + let device = Default::default(); + let mut metric = DiceMetric::<4>::new(); + let input = DiceInput::new( + Tensor::from_data([[[[1, 0], [1, 0]]]], &device), + Tensor::from_data([[[[0, 1], [0, 1]]]], &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!(metric.value().current() < 1e-6); + } + + #[test] + fn test_dice_partial_overlap() { + let device = Default::default(); + let mut metric = DiceMetric::<4>::new(); + let input = DiceInput::new( + Tensor::from_data([[[[1, 1], [0, 0]]]], &device), + Tensor::from_data([[[[1, 0], [1, 0]]]], &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + // intersection = 1, sum = 2+2=4, dice = 2*1/4 = 0.5 + assert!((metric.value().current() - 0.5).abs() < 1e-6); + } + + #[test] + fn test_dice_empty_masks() { + let device = Default::default(); + let mut metric = DiceMetric::<4>::new(); + let input = DiceInput::new( + Tensor::from_data([[[[0, 0], [0, 0]]]], &device), + Tensor::from_data([[[[0, 0], [0, 0]]]], &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!((metric.value().current() - 1.0).abs() < 1e-6); + } + + #[test] + fn test_dice_no_background() { + let device = Default::default(); + let mut metric = DiceMetric::<4>::new(); + let input = DiceInput::new( + Tensor::ones(Shape::new([1, 1, 2, 2]), &device), + Tensor::ones(Shape::new([1, 1, 2, 2]), &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!((metric.value().current() - 1.0).abs() < 1e-6); + } + + #[test] + fn test_dice_with_background() { + let device = Default::default(); + let config = DiceMetricConfig { + epsilon: 1e-7, + include_background: true, + }; + let mut metric = DiceMetric::<4>::with_config(config); + let input = DiceInput::new( + Tensor::ones(Shape::new([1, 2, 2, 2]), &device), + Tensor::ones(Shape::new([1, 2, 2, 2]), &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!((metric.value().current() - 1.0).abs() < 1e-6); + } + + #[test] + fn test_dice_ignored_background() { + let device = Default::default(); + let config = DiceMetricConfig { + epsilon: 1e-7, + include_background: false, + }; + let mut metric = DiceMetric::<4>::with_config(config); + let input = DiceInput::new( + Tensor::ones(Shape::new([1, 2, 2, 2]), &device), + Tensor::ones(Shape::new([1, 2, 2, 2]), &device), + ); + let _entry = metric.update(&input, &MetricMetadata::fake()); + assert!((metric.value().current() - 1.0).abs() < 1e-6); + } + + #[test] + #[should_panic(expected = "DiceInput requires at least 3 dimensions.")] + fn test_invalid_input_dimensions() { + let device = Default::default(); + // D = 2, should panic + let _ = DiceInput::<2>::new( + Tensor::from_data([[0.0, 0.0]], &device), + Tensor::from_data([[0.0, 0.0]], &device), + ); + } + + #[test] + #[should_panic( + expected = "Outputs and targets must have the same dimensions. Got [1, 1, 2, 2] and [1, 1, 2, 3]" + )] + fn test_mismatched_shape() { + let device = Default::default(); + // shapes differ + let _ = DiceInput::<4>::new( + Tensor::from_data([[[[0.0; 2]; 2]; 1]; 1], &device), + Tensor::from_data([[[[0.0; 3]; 2]; 1]; 1], &device), + ); + } + + #[test] + #[should_panic(expected = "Dice metric requires at least 2 classes when including background.")] + fn test_include_background_panic() { + let device = Default::default(); + let config = DiceMetricConfig { + epsilon: 1e-7, + include_background: true, + }; + let mut metric = DiceMetric::<4>::with_config(config); + let input = DiceInput::new( + Tensor::from_data([[[[1.0; 2]; 1]; 1]; 1], &device), + Tensor::from_data([[[[1.0; 2]; 1]; 1]; 1], &device), + ); + // n_classes = 2, should not panic + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let config = DiceMetricConfig { + epsilon: 1e-7, + include_background: true, + }; + let mut metric = DiceMetric::<4>::with_config(config); + let input = DiceInput::new( + Tensor::from_data([[[[1.0; 1]; 1]; 1]; 1], &device), + Tensor::from_data([[[[1.0; 1]; 1]; 1]; 1], &device), + ); + // n_classes = 1, should panic + let _entry = metric.update(&input, &MetricMetadata::fake()); + } +} diff --git a/crates/burn-train/src/metric/vision/dists/l2pool.rs b/crates/burn-train/src/metric/vision/dists/l2pool.rs new file mode 100644 index 0000000..794b941 --- /dev/null +++ b/crates/burn-train/src/metric/vision/dists/l2pool.rs @@ -0,0 +1,160 @@ +//! L2 Pooling layer for DISTS. +//! +//! L2 Pooling applies a Hanning window filter and computes the L2 norm +//! across the pooling window. This is used in DISTS instead of MaxPooling. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_nn::PaddingConfig2d; +use burn_nn::conv::{Conv2d, Conv2dConfig}; + +/// L2 Pooling layer configuration. +#[derive(Debug, Clone)] +pub struct L2Pool2dConfig { + /// Kernel size for pooling + pub kernel_size: usize, + /// Stride for pooling + pub stride: usize, + /// Padding for pooling + pub padding: usize, +} + +impl Default for L2Pool2dConfig { + fn default() -> Self { + Self { + kernel_size: 5, + stride: 2, + padding: 2, + } + } +} + +impl L2Pool2dConfig { + /// Create a new L2Pool2d configuration. + #[allow(dead_code)] + pub fn new(kernel_size: usize, stride: usize, padding: usize) -> Self { + Self { + kernel_size, + stride, + padding, + } + } + + /// Initialize the L2Pool2d layer. + pub fn init(&self, channels: usize, device: &Device) -> L2Pool2d { + L2Pool2d::new( + channels, + self.kernel_size, + self.stride, + self.padding, + device, + ) + } +} + +/// L2 Pooling layer. +/// +/// Applies a 2D Hanning window filter followed by L2 normalization. +/// This provides smoother downsampling compared to MaxPooling. +#[derive(Module, Debug)] +pub struct L2Pool2d { + /// Depthwise convolution with Hanning kernel + conv: Conv2d, +} + +impl L2Pool2d { + /// Create a new L2Pool2d layer with Hanning window kernel. + pub fn new( + channels: usize, + kernel_size: usize, + stride: usize, + padding: usize, + device: &Device, + ) -> Self { + // Create Hanning kernel + let kernel = Self::create_hanning_kernel(channels, kernel_size, device); + + // Create depthwise convolution (groups = channels) + let mut conv = Conv2dConfig::new([channels, channels], [kernel_size, kernel_size]) + .with_stride([stride, stride]) + .with_padding(PaddingConfig2d::Explicit( + padding, padding, padding, padding, + )) + .with_groups(channels) + .with_bias(false) + .init(device); + + // Set the kernel weights to Hanning window + conv.weight = burn::module::Param::from_tensor(kernel); + + Self { conv } + } + + /// Create a Hanning kernel for depthwise convolution. + /// Output shape: [channels, 1, kernel_size, kernel_size] + fn create_hanning_kernel(channels: usize, kernel_size: usize, device: &Device) -> Tensor<4> { + // Create 1D Hanning window + let mut hanning_1d = Vec::with_capacity(kernel_size); + for i in 0..kernel_size { + let n = i as f32; + let n_minus_1 = (kernel_size - 1) as f32; + let value = if n_minus_1 == 0.0 { + 1.0 + } else { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * n / n_minus_1).cos()) + }; + hanning_1d.push(value); + } + + // Create 2D Hanning window by outer product + let mut hanning_2d = Vec::with_capacity(kernel_size * kernel_size); + let mut sum = 0.0; + for i in 0..kernel_size { + for j in 0..kernel_size { + let value = hanning_1d[i] * hanning_1d[j]; + hanning_2d.push(value); + sum += value; + } + } + + // Normalize + for v in hanning_2d.iter_mut() { + *v /= sum; + } + + // Create tensor of shape [1, 1, kernel_size, kernel_size] + let kernel_single = Tensor::<1>::from_floats(hanning_2d.as_slice(), device).reshape([ + 1, + 1, + kernel_size, + kernel_size, + ]); + + // Expand to [channels, 1, kernel_size, kernel_size] + kernel_single.repeat_dim(0, channels) + } + + /// Apply L2 pooling to the input tensor. + /// + /// # Arguments + /// + /// * `x` - Input tensor of shape `[batch, channels, height, width]` + /// + /// # Returns + /// + /// Pooled tensor with reduced spatial dimensions. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + // Square the input + let x_sq = x.clone().mul(x); + + // Apply depthwise convolution with Hanning kernel + let pooled = self.conv.forward(x_sq); + + // Take square root for L2 norm + // Add small epsilon to avoid sqrt of negative numbers due to numerical errors + pooled.clamp_min(1e-10).sqrt() + } +} diff --git a/crates/burn-train/src/metric/vision/dists/metric.rs b/crates/burn-train/src/metric/vision/dists/metric.rs new file mode 100644 index 0000000..23b6e29 --- /dev/null +++ b/crates/burn-train/src/metric/vision/dists/metric.rs @@ -0,0 +1,487 @@ +//! DISTS (Deep Image Structure and Texture Similarity) metric. +//! +//! DISTS is a full-reference image quality assessment metric that combines +//! structure and texture similarity using deep features from VGG16. +//! +//! Reference: "Image Quality Assessment: Unifying Structure and Texture Similarity" +//! https://arxiv.org/abs/2004.07728 + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay, Param}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_nn::loss::Reduction; + +use super::vgg16_l2pool::Vgg16L2PoolExtractor; + +/// Channel counts for each stage: [input, stage1, stage2, stage3, stage4, stage5] +const CHANNELS: [usize; 6] = [3, 64, 128, 256, 512, 512]; + +/// Small constant for numerical stability in structure similarity. +const C1: f32 = 1e-6; + +/// Small constant for numerical stability in texture similarity. +const C2: f32 = 1e-6; + +/// ImageNet normalization constants. +const IMAGENET_MEAN: [f32; 3] = [0.485, 0.456, 0.406]; +const IMAGENET_STD: [f32; 3] = [0.229, 0.224, 0.225]; + +/// Image normalizer with pre-initialized mean and std tensors. +/// +/// This struct holds the mean and std tensors for normalization, +/// avoiding the need to create them on each forward pass. +#[derive(Module, Debug)] +pub struct Normalizer { + /// Mean tensor of shape [1, 3, 1, 1] for broadcasting. + pub mean: Tensor<4>, + /// Std tensor of shape [1, 3, 1, 1] for broadcasting. + pub std: Tensor<4>, +} + +impl Normalizer { + /// Create a new ImageNet normalizer. + pub fn imagenet(device: &Device) -> Self { + // Shape: [1, 3, 1, 1] for broadcasting over [batch, channels, height, width] + let mean = Tensor::from_floats( + [[ + [[IMAGENET_MEAN[0]]], + [[IMAGENET_MEAN[1]]], + [[IMAGENET_MEAN[2]]], + ]], + device, + ); + let std = Tensor::from_floats( + [[ + [[IMAGENET_STD[0]]], + [[IMAGENET_STD[1]]], + [[IMAGENET_STD[2]]], + ]], + device, + ); + Self { mean, std } + } + + /// Normalize a tensor: (x - mean) / std + pub fn normalize(&self, x: Tensor<4>) -> Tensor<4> { + x.sub(self.mean.clone()).div(self.std.clone()) + } +} + +/// Configuration for DISTS metric. +#[derive(Config, Debug)] +pub struct DistsConfig { + /// Whether to apply ImageNet normalization to input images. + #[config(default = true)] + pub normalize: bool, +} + +impl DistsConfig { + /// Initialize a DISTS module with default weights. + pub fn init(&self, device: &Device) -> Dists { + let total_channels: usize = CHANNELS.iter().sum(); + + // Initialize alpha and beta with constant value 0.1 for all channels + let alpha_data: Vec = (0..total_channels).map(|_| 0.1).collect(); + let beta_data: Vec = (0..total_channels).map(|_| 0.1).collect(); + + let normalizer = if self.normalize { + Some(Normalizer::imagenet(device)) + } else { + None + }; + + Dists { + extractor: Vgg16L2PoolExtractor::new(device), + alpha: Param::from_tensor(Tensor::from_floats(alpha_data.as_slice(), device)), + beta: Param::from_tensor(Tensor::from_floats(beta_data.as_slice(), device)), + normalizer, + } + } + + /// Initialize a DISTS module with pretrained weights. + pub fn init_pretrained(&self, device: &Device) -> Dists { + let dists = self.init(device); + super::weights::load_pretrained_weights(dists) + } +} + +/// DISTS (Deep Image Structure and Texture Similarity) metric. +/// +/// Computes perceptual similarity between two images by combining +/// structure similarity (based on spatial means) and texture similarity +/// (based on variances and covariances) across VGG16 feature maps. +/// +/// # Example +/// +/// ```ignore +/// use burn_train::metric::vision::{DistsConfig, Reduction}; +/// +/// let device = Default::default(); +/// let dists = DistsConfig::new().init_pretrained(&device); +/// +/// let img1: Tensor<4> = /* [batch, 3, H, W] */; +/// let img2: Tensor<4> = /* [batch, 3, H, W] */; +/// +/// let distance = dists.forward(img1, img2, Reduction::Mean); +/// ``` +#[derive(Module, Debug)] +#[module(custom_display)] +pub struct Dists { + /// VGG16 feature extractor with L2 pooling + pub(crate) extractor: Vgg16L2PoolExtractor, + /// Learned weights for structure similarity (per channel) + pub(crate) alpha: Param>, + /// Learned weights for texture similarity (per channel) + pub(crate) beta: Param>, + /// Optional normalizer for input preprocessing + pub(crate) normalizer: Option, +} + +impl ModuleDisplay for Dists { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + content + .add("backbone", &"VGG16-L2Pool".to_string()) + .add("normalize", &self.normalizer.is_some().to_string()) + .optional() + } +} + +impl Dists { + /// Compute DISTS distance with reduction. + /// + /// # Arguments + /// + /// * `input` - First image tensor of shape `[batch, 3, H, W]` + /// * `target` - Second image tensor of shape `[batch, 3, H, W]` + /// * `reduction` - How to reduce the output (Mean, Sum, or Auto) + /// + /// # Returns + /// + /// Scalar tensor of shape `[1]`. + pub fn forward(&self, input: Tensor<4>, target: Tensor<4>, reduction: Reduction) -> Tensor<1> { + let distance = self.forward_no_reduction(input, target); + + match reduction { + Reduction::Mean | Reduction::Auto | Reduction::BatchMean => distance.mean(), + Reduction::Sum => distance.sum(), + } + } + + /// Compute DISTS distance without reduction. + /// + /// # Arguments + /// + /// * `input` - First image tensor of shape `[batch, 3, H, W]` + /// * `target` - Second image tensor of shape `[batch, 3, H, W]` + /// + /// # Returns + /// + /// Per-sample distance tensor of shape `[batch]`. + pub fn forward_no_reduction(&self, input: Tensor<4>, target: Tensor<4>) -> Tensor<1> { + let [batch, _, _, _] = input.dims(); + + // Preprocess inputs + let (input, target) = self.preprocess(input, target); + + // Extract features from both images + let feats_x = self.extractor.forward(input); + let feats_y = self.extractor.forward(target); + + // Get alpha and beta weights + let alpha = self.alpha.val(); + let beta = self.beta.val(); + + // Compute weighted sum of alpha and beta for normalization + let alpha_sum = alpha.clone().sum(); + let beta_sum = beta.clone().sum(); + + let device = feats_x[0].device(); + + // Initialize accumulators + let mut structure_dist = Tensor::<1>::zeros([batch], &device); + let mut texture_dist = Tensor::<1>::zeros([batch], &device); + + let mut channel_offset = 0; + + // Compute similarity for each stage + for (feat_x, feat_y) in feats_x.iter().zip(feats_y.iter()) { + let [_b, c, _h, _w] = feat_x.dims(); + + // Get alpha and beta for this stage + let alpha_stage = alpha.clone().narrow(0, channel_offset, c); + let beta_stage = beta.clone().narrow(0, channel_offset, c); + + // Compute structure and texture similarity for this stage + let (s_dist, t_dist) = self.compute_stage_similarity( + feat_x.clone(), + feat_y.clone(), + alpha_stage, + beta_stage, + ); + + structure_dist = structure_dist.add(s_dist); + texture_dist = texture_dist.add(t_dist); + + channel_offset += c; + } + + // Normalize by sum of weights + structure_dist = structure_dist.div(alpha_sum); + texture_dist = texture_dist.div(beta_sum); + + // DISTS = 1 - (structure_similarity + texture_similarity) + // Since we computed distances (1 - similarity), we return the sum + structure_dist.add(texture_dist) + } + + /// Compute structure and texture similarity for a single stage. + fn compute_stage_similarity( + &self, + feat_x: Tensor<4>, + feat_y: Tensor<4>, + alpha: Tensor<1>, + beta: Tensor<1>, + ) -> (Tensor<1>, Tensor<1>) { + let [batch, channels, height, width] = feat_x.dims(); + let device = feat_x.device(); + + // Reshape to [batch, channels, H*W] for easier computation + let x = feat_x.reshape([batch, channels, height * width]); + let y = feat_y.reshape([batch, channels, height * width]); + + // Compute means: [batch, channels] (squeeze after mean_dim to remove the reduced dimension) + let mean_x = x.clone().mean_dim(2).squeeze_dim::<2>(2); + let mean_y = y.clone().mean_dim(2).squeeze_dim::<2>(2); + + // Compute structure similarity: (2*mean_x*mean_y + c1) / (mean_x^2 + mean_y^2 + c1) + let c1 = Tensor::<2>::full([batch, channels], C1, &device); + let structure_sim = mean_x + .clone() + .mul(mean_y.clone()) + .mul_scalar(2.0) + .add(c1.clone()) + .div( + mean_x + .clone() + .mul(mean_x.clone()) + .add(mean_y.clone().mul(mean_y.clone())) + .add(c1), + ); + + // Compute variances and covariance + // var_x = E[x^2] - E[x]^2, clamped at 0 for numerical stability + let var_x = x + .clone() + .mul(x.clone()) + .mean_dim(2) + .squeeze_dim::<2>(2) + .sub(mean_x.clone().mul(mean_x.clone())) + .clamp_min(0.0); + let var_y = y + .clone() + .mul(y.clone()) + .mean_dim(2) + .squeeze_dim::<2>(2) + .sub(mean_y.clone().mul(mean_y.clone())) + .clamp_min(0.0); + + // cov_xy = E[xy] - E[x]E[y] + let cov_xy = x + .mul(y) + .mean_dim(2) + .squeeze_dim::<2>(2) + .sub(mean_x.clone().mul(mean_y.clone())); + + // Compute texture similarity: (2*cov_xy + c2) / (var_x + var_y + c2) + let c2 = Tensor::<2>::full([batch, channels], C2, &device); + let texture_sim = cov_xy + .mul_scalar(2.0) + .add(c2.clone()) + .div(var_x.add(var_y).add(c2)); + + // Convert similarity to distance: 1 - similarity + let structure_dist = Tensor::<2>::ones([batch, channels], &device).sub(structure_sim); + let texture_dist = Tensor::<2>::ones([batch, channels], &device).sub(texture_sim); + + // Apply weights: [batch, channels] * [channels] -> [batch, channels] + // Then sum over channels -> [batch] + let weighted_structure = structure_dist + .mul(alpha.unsqueeze_dim::<2>(0)) + .sum_dim(1) + .squeeze_dim::<1>(1); + let weighted_texture = texture_dist + .mul(beta.unsqueeze_dim::<2>(0)) + .sum_dim(1) + .squeeze_dim::<1>(1); + + (weighted_structure, weighted_texture) + } + + /// Preprocess input images using the configured normalizer. + fn preprocess(&self, input: Tensor<4>, target: Tensor<4>) -> (Tensor<4>, Tensor<4>) { + match &self.normalizer { + Some(normalizer) => { + let input = normalizer.normalize(input); + let target = normalizer.normalize(target); + (input, target) + } + None => (input, target), + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::tensor::{TensorData, Tolerance}; + + type FT = f32; + + #[test] + fn test_dists_identical_images_zero_distance() { + let device = Default::default(); + // Use random image instead of constant to avoid numerical edge cases + let image = Tensor::<4>::random( + [1, 3, 64, 64], + burn_core::tensor::Distribution::Uniform(0.0, 1.0), + &device, + ); + + let dists = DistsConfig::new().init(&device); + let distance = dists.forward(image.clone(), image, Reduction::Mean); + + let expected = TensorData::from([0.0]); + distance + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + #[test] + fn test_dists_different_images_nonzero_distance() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + + let dists = DistsConfig::new().init(&device); + let distance = dists.forward(image1, image2, Reduction::Mean); + + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() > 1e-6, + "DISTS should be != 0 for different images" + ); + } + + #[test] + fn test_dists_symmetry() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([1, 3, 32, 32], &device); + + let dists = DistsConfig::new().init(&device); + let distance_forward = dists.forward(image1.clone(), image2.clone(), Reduction::Mean); + let distance_reverse = dists.forward(image2, image1, Reduction::Mean); + + distance_forward + .into_data() + .assert_approx_eq::(&distance_reverse.into_data(), Tolerance::default()); + } + + #[test] + fn test_dists_batch_processing() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([2, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([2, 3, 32, 32], &device); + + let dists = DistsConfig::new().init(&device); + let distance = dists.forward(image1, image2, Reduction::Mean); + + assert_eq!(distance.dims(), [1]); + } + + #[test] + fn test_dists_no_reduction() { + let device = Default::default(); + + let batch_size = 4; + let image1 = Tensor::<4>::zeros([batch_size, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([batch_size, 3, 32, 32], &device); + + let dists = DistsConfig::new().init(&device); + let distance = dists.forward_no_reduction(image1, image2); + + assert_eq!(distance.dims(), [batch_size]); + } + + #[test] + fn display_dists() { + let device = Default::default(); + let dists = DistsConfig::new().init(&device); + + let display_str = format!("{dists}"); + assert!(display_str.contains("Dists")); + assert!(display_str.contains("VGG16-L2Pool")); + } + + // ========================================================================= + // Pretrained Weights Tests (requires network) + // ========================================================================= + + /// Test DISTS pretrained weights download and loading. + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_dists_pretrained() { + let device = Default::default(); + + let dists = DistsConfig::new().init_pretrained(&device); + + // Test with identical images - should be ~0 + // Use random image to avoid numerical edge cases with constant images + let image = Tensor::<4>::random( + [1, 3, 64, 64], + burn_core::tensor::Distribution::Uniform(0.0, 1.0), + &device, + ); + let distance = dists.forward(image.clone(), image, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() < 1e-5, + "Pretrained DISTS should be ~0 for identical images, got {}", + distance_value + ); + + // Test with different images - should be positive + let image1 = Tensor::<4>::random( + [1, 3, 64, 64], + burn_core::tensor::Distribution::Uniform(0.0, 0.3), + &device, + ); + let image2 = Tensor::<4>::random( + [1, 3, 64, 64], + burn_core::tensor::Distribution::Uniform(0.7, 1.0), + &device, + ); + let distance = dists.forward(image1, image2, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value > 0.0, + "Pretrained DISTS should be > 0 for different images" + ); + } +} diff --git a/crates/burn-train/src/metric/vision/dists/mod.rs b/crates/burn-train/src/metric/vision/dists/mod.rs new file mode 100644 index 0000000..4c031ea --- /dev/null +++ b/crates/burn-train/src/metric/vision/dists/mod.rs @@ -0,0 +1,14 @@ +//! DISTS (Deep Image Structure and Texture Similarity) metric. +//! +//! This module implements DISTS, a full-reference image quality assessment metric +//! that combines structure and texture similarity using deep features. +//! +//! Reference: "Image Quality Assessment: Unifying Structure and Texture Similarity" +//! https://arxiv.org/abs/2004.07728 + +mod l2pool; +mod metric; +mod vgg16_l2pool; +mod weights; + +pub use metric::{Dists, DistsConfig}; diff --git a/crates/burn-train/src/metric/vision/dists/vgg16_l2pool.rs b/crates/burn-train/src/metric/vision/dists/vgg16_l2pool.rs new file mode 100644 index 0000000..3429de6 --- /dev/null +++ b/crates/burn-train/src/metric/vision/dists/vgg16_l2pool.rs @@ -0,0 +1,169 @@ +//! VGG16 feature extractor with L2 Pooling for DISTS. +//! +//! This module implements the VGG16 backbone used in DISTS, +//! with L2Pooling replacing MaxPooling for smoother feature extraction. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::relu; +use burn_nn::PaddingConfig2d; +use burn_nn::conv::{Conv2d, Conv2dConfig}; + +use super::l2pool::{L2Pool2d, L2Pool2dConfig}; + +/// VGG16 feature extractor with L2 Pooling for DISTS. +/// +/// Extracts features from 5 stages of VGG16, using L2Pooling +/// instead of MaxPooling for smoother downsampling. +/// +/// Output channels per stage: [64, 128, 256, 512, 512] +#[derive(Module, Debug)] +pub struct Vgg16L2PoolExtractor { + // Stage 1: 2 conv layers, 64 channels + pub(crate) conv1_1: Conv2d, + pub(crate) conv1_2: Conv2d, + pub(crate) pool1: L2Pool2d, + + // Stage 2: 2 conv layers, 128 channels + pub(crate) conv2_1: Conv2d, + pub(crate) conv2_2: Conv2d, + pub(crate) pool2: L2Pool2d, + + // Stage 3: 3 conv layers, 256 channels + pub(crate) conv3_1: Conv2d, + pub(crate) conv3_2: Conv2d, + pub(crate) conv3_3: Conv2d, + pub(crate) pool3: L2Pool2d, + + // Stage 4: 3 conv layers, 512 channels + pub(crate) conv4_1: Conv2d, + pub(crate) conv4_2: Conv2d, + pub(crate) conv4_3: Conv2d, + pub(crate) pool4: L2Pool2d, + + // Stage 5: 3 conv layers, 512 channels + pub(crate) conv5_1: Conv2d, + pub(crate) conv5_2: Conv2d, + pub(crate) conv5_3: Conv2d, +} + +impl Vgg16L2PoolExtractor { + /// Create a new VGG16 feature extractor with L2 Pooling. + pub fn new(device: &Device) -> Self { + let pool_config = L2Pool2dConfig::default(); + + Self { + // Stage 1 + conv1_1: Conv2dConfig::new([3, 64], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv1_2: Conv2dConfig::new([64, 64], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + pool1: pool_config.init(64, device), + + // Stage 2 + conv2_1: Conv2dConfig::new([64, 128], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv2_2: Conv2dConfig::new([128, 128], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + pool2: pool_config.init(128, device), + + // Stage 3 + conv3_1: Conv2dConfig::new([128, 256], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv3_2: Conv2dConfig::new([256, 256], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv3_3: Conv2dConfig::new([256, 256], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + pool3: pool_config.init(256, device), + + // Stage 4 + conv4_1: Conv2dConfig::new([256, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv4_2: Conv2dConfig::new([512, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv4_3: Conv2dConfig::new([512, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + pool4: pool_config.init(512, device), + + // Stage 5 + conv5_1: Conv2dConfig::new([512, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv5_2: Conv2dConfig::new([512, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + conv5_3: Conv2dConfig::new([512, 512], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .init(device), + } + } + + /// Extract features from all 5 stages. + /// + /// # Arguments + /// + /// * `x` - Input tensor of shape `[batch, 3, height, width]` + /// + /// # Returns + /// + /// Vector of 6 feature tensors: + /// - Stage 0: Input image [batch, 3, H, W] + /// - Stage 1: After conv1 [batch, 64, H/2, W/2] + /// - Stage 2: After conv2 [batch, 128, H/4, W/4] + /// - Stage 3: After conv3 [batch, 256, H/8, W/8] + /// - Stage 4: After conv4 [batch, 512, H/16, W/16] + /// - Stage 5: After conv5 [batch, 512, H/32, W/32] + pub fn forward(&self, x: Tensor<4>) -> Vec> { + let mut features = Vec::with_capacity(6); + + // Stage 0: Input image + features.push(x.clone()); + + // Stage 1 + let x = relu(self.conv1_1.forward(x)); + let x = relu(self.conv1_2.forward(x)); + features.push(x.clone()); + let x = self.pool1.forward(x); + + // Stage 2 + let x = relu(self.conv2_1.forward(x)); + let x = relu(self.conv2_2.forward(x)); + features.push(x.clone()); + let x = self.pool2.forward(x); + + // Stage 3 + let x = relu(self.conv3_1.forward(x)); + let x = relu(self.conv3_2.forward(x)); + let x = relu(self.conv3_3.forward(x)); + features.push(x.clone()); + let x = self.pool3.forward(x); + + // Stage 4 + let x = relu(self.conv4_1.forward(x)); + let x = relu(self.conv4_2.forward(x)); + let x = relu(self.conv4_3.forward(x)); + features.push(x.clone()); + let x = self.pool4.forward(x); + + // Stage 5 + let x = relu(self.conv5_1.forward(x)); + let x = relu(self.conv5_2.forward(x)); + let x = relu(self.conv5_3.forward(x)); + features.push(x); + + features + } +} diff --git a/crates/burn-train/src/metric/vision/dists/weights.rs b/crates/burn-train/src/metric/vision/dists/weights.rs new file mode 100644 index 0000000..1c32297 --- /dev/null +++ b/crates/burn-train/src/metric/vision/dists/weights.rs @@ -0,0 +1,125 @@ +//! Pretrained weights loading for DISTS. + +use burn_std::network::downloader::download_file_as_bytes; +use burn_store::{ModuleSnapshot, PytorchStore}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::PathBuf; + +use super::metric::Dists; + +/// URL for pretrained DISTS alpha/beta weights from the official repository. +/// Reference: https://github.com/dingkeyan93/DISTS +const DISTS_WEIGHTS_URL: &str = + "https://github.com/dingkeyan93/DISTS/raw/master/DISTS_pytorch/weights.pt"; + +/// URL for ImageNet pretrained VGG16 backbone weights from PyTorch. +const VGG16_IMAGENET_URL: &str = "https://download.pytorch.org/models/vgg16-397923af.pth"; + +/// Get the cache directory for DISTS weights. +fn get_cache_dir() -> PathBuf { + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset") + .join("dists"); + + if !cache_dir.exists() { + create_dir_all(&cache_dir).expect("Failed to create cache directory"); + } + + cache_dir +} + +/// Download file if not cached. +fn download_if_needed(url: &str, cache_path: &PathBuf, message: &str) { + if !cache_path.exists() { + let bytes = download_file_as_bytes(url, message); + let mut file = File::create(cache_path).expect("Failed to create cache file"); + file.write_all(&bytes).expect("Failed to write weights"); + } +} + +/// Download and load pretrained weights into a DISTS module. +/// +/// This loads both: +/// 1. ImageNet pretrained VGG16 backbone weights +/// 2. DISTS trained alpha/beta weights +/// +/// Weights are cached in the user's cache directory to avoid re-downloading. +/// +/// # Arguments +/// +/// * `dists` - The DISTS module to load weights into. +/// +/// # Returns +/// +/// The DISTS module with loaded pretrained weights. +pub fn load_pretrained_weights(mut dists: Dists) -> Dists { + let cache_dir = get_cache_dir(); + + // Step 1: Download and load VGG16 ImageNet backbone weights + let vgg_cache_path = cache_dir.join("vgg16_backbone.pth"); + download_if_needed( + VGG16_IMAGENET_URL, + &vgg_cache_path, + "Downloading VGG16 ImageNet weights for DISTS...", + ); + + // Step 2: Download DISTS alpha/beta weights + let dists_cache_path = cache_dir.join("dists_weights.pt"); + download_if_needed( + DISTS_WEIGHTS_URL, + &dists_cache_path, + "Downloading DISTS alpha/beta weights...", + ); + + // Load VGG16 backbone weights first + dists = load_vgg16_backbone_weights(dists, &vgg_cache_path); + + // Then load DISTS alpha/beta weights + dists = load_dists_weights(dists, &dists_cache_path); + + dists +} + +/// Load VGG16 ImageNet pretrained backbone weights. +fn load_vgg16_backbone_weights(mut dists: Dists, cache_path: &PathBuf) -> Dists { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + .skip_enum_variants(true) + // VGG16 features.X -> extractor.convY_Z + .with_key_remapping(r"^features\.0\.", "extractor.conv1_1.") + .with_key_remapping(r"^features\.2\.", "extractor.conv1_2.") + .with_key_remapping(r"^features\.5\.", "extractor.conv2_1.") + .with_key_remapping(r"^features\.7\.", "extractor.conv2_2.") + .with_key_remapping(r"^features\.10\.", "extractor.conv3_1.") + .with_key_remapping(r"^features\.12\.", "extractor.conv3_2.") + .with_key_remapping(r"^features\.14\.", "extractor.conv3_3.") + .with_key_remapping(r"^features\.17\.", "extractor.conv4_1.") + .with_key_remapping(r"^features\.19\.", "extractor.conv4_2.") + .with_key_remapping(r"^features\.21\.", "extractor.conv4_3.") + .with_key_remapping(r"^features\.24\.", "extractor.conv5_1.") + .with_key_remapping(r"^features\.26\.", "extractor.conv5_2.") + .with_key_remapping(r"^features\.28\.", "extractor.conv5_3."); + + let result = dists.load_from(&mut store); + if let Err(e) = result { + log::warn!("Some VGG16 backbone weights could not be loaded: {:?}", e); + } + + dists +} + +/// Load DISTS trained alpha/beta weights. +fn load_dists_weights(mut dists: Dists, cache_path: &PathBuf) -> Dists { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + .skip_enum_variants(true); + + let result = dists.load_from(&mut store); + if let Err(e) = result { + log::warn!("Some DISTS weights could not be loaded: {:?}", e); + } + + dists +} diff --git a/crates/burn-train/src/metric/vision/fid/inception.rs b/crates/burn-train/src/metric/vision/fid/inception.rs new file mode 100644 index 0000000..d4a0ea5 --- /dev/null +++ b/crates/burn-train/src/metric/vision/fid/inception.rs @@ -0,0 +1,447 @@ +//! InceptionV3 feature extractor for FID (pytorch-fid variant with TF-ported weights). +//! +//! Reference: + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::relu; +use burn::tensor::ops::{InterpolateMode, InterpolateOptions}; +use burn_nn::conv::{Conv2d, Conv2dConfig}; +use burn_nn::{BatchNorm, BatchNormConfig, PaddingConfig2d}; + +/// Conv2d + BatchNorm + ReLU building block. +#[derive(Module, Debug)] +pub struct BasicConv2d { + conv: Conv2d, + bn: BatchNorm, +} + +impl BasicConv2d { + pub fn new(conv_config: Conv2dConfig, device: &Device) -> Self { + let out_channels = conv_config.channels[1]; + Self { + conv: conv_config.with_bias(false).init(device), + bn: BatchNormConfig::new(out_channels) + .with_epsilon(0.001) + .init(device), + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + relu(self.bn.forward(self.conv.forward(x))) + } +} + +#[derive(Module, Debug)] +pub struct InceptionA { + branch1x1: BasicConv2d, + branch5x5_1: BasicConv2d, + branch5x5_2: BasicConv2d, + branch3x3dbl_1: BasicConv2d, + branch3x3dbl_2: BasicConv2d, + branch3x3dbl_3: BasicConv2d, + branch_pool: BasicConv2d, +} + +impl InceptionA { + pub fn new(in_channels: usize, pool_features: usize, device: &Device) -> Self { + Self { + branch1x1: BasicConv2d::new(Conv2dConfig::new([in_channels, 64], [1, 1]), device), + branch5x5_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 48], [1, 1]), device), + branch5x5_2: BasicConv2d::new( + Conv2dConfig::new([48, 64], [5, 5]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)), + device, + ), + branch3x3dbl_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 64], [1, 1]), device), + branch3x3dbl_2: BasicConv2d::new( + Conv2dConfig::new([64, 96], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)), + device, + ), + branch3x3dbl_3: BasicConv2d::new( + Conv2dConfig::new([96, 96], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)), + device, + ), + branch_pool: BasicConv2d::new( + Conv2dConfig::new([in_channels, pool_features], [1, 1]), + device, + ), + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let branch1x1 = self.branch1x1.forward(x.clone()); + + let branch5x5 = self.branch5x5_1.forward(x.clone()); + let branch5x5 = self.branch5x5_2.forward(branch5x5); + + let branch3x3dbl = self.branch3x3dbl_1.forward(x.clone()); + let branch3x3dbl = self.branch3x3dbl_2.forward(branch3x3dbl); + let branch3x3dbl = self.branch3x3dbl_3.forward(branch3x3dbl); + + let branch_pool = + burn_core::tensor::module::avg_pool2d(x, [3, 3], [1, 1], [1, 1], false, false); + let branch_pool = self.branch_pool.forward(branch_pool); + + Tensor::cat(vec![branch1x1, branch5x5, branch3x3dbl, branch_pool], 1) + } +} + +#[derive(Module, Debug)] +pub struct InceptionB { + branch3x3: BasicConv2d, + branch3x3dbl_1: BasicConv2d, + branch3x3dbl_2: BasicConv2d, + branch3x3dbl_3: BasicConv2d, +} + +impl InceptionB { + pub fn new(in_channels: usize, device: &Device) -> Self { + Self { + branch3x3: BasicConv2d::new( + Conv2dConfig::new([in_channels, 384], [3, 3]).with_stride([2, 2]), + device, + ), + branch3x3dbl_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 64], [1, 1]), device), + branch3x3dbl_2: BasicConv2d::new( + Conv2dConfig::new([64, 96], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)), + device, + ), + branch3x3dbl_3: BasicConv2d::new( + Conv2dConfig::new([96, 96], [3, 3]).with_stride([2, 2]), + device, + ), + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let branch3x3 = self.branch3x3.forward(x.clone()); + + let branch3x3dbl = self.branch3x3dbl_1.forward(x.clone()); + let branch3x3dbl = self.branch3x3dbl_2.forward(branch3x3dbl); + let branch3x3dbl = self.branch3x3dbl_3.forward(branch3x3dbl); + + let branch_pool = + burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], false); + + Tensor::cat(vec![branch3x3, branch3x3dbl, branch_pool], 1) + } +} + +#[derive(Module, Debug)] +pub struct InceptionC { + branch1x1: BasicConv2d, + branch7x7_1: BasicConv2d, + branch7x7_2: BasicConv2d, + branch7x7_3: BasicConv2d, + branch7x7dbl_1: BasicConv2d, + branch7x7dbl_2: BasicConv2d, + branch7x7dbl_3: BasicConv2d, + branch7x7dbl_4: BasicConv2d, + branch7x7dbl_5: BasicConv2d, + branch_pool: BasicConv2d, +} + +impl InceptionC { + pub fn new(in_channels: usize, channels_7x7: usize, device: &Device) -> Self { + let c7 = channels_7x7; + Self { + branch1x1: BasicConv2d::new(Conv2dConfig::new([in_channels, 192], [1, 1]), device), + branch7x7_1: BasicConv2d::new(Conv2dConfig::new([in_channels, c7], [1, 1]), device), + branch7x7_2: BasicConv2d::new( + Conv2dConfig::new([c7, c7], [1, 7]) + .with_padding(PaddingConfig2d::Explicit(0, 3, 0, 3)), + device, + ), + branch7x7_3: BasicConv2d::new( + Conv2dConfig::new([c7, 192], [7, 1]) + .with_padding(PaddingConfig2d::Explicit(3, 0, 3, 0)), + device, + ), + branch7x7dbl_1: BasicConv2d::new(Conv2dConfig::new([in_channels, c7], [1, 1]), device), + branch7x7dbl_2: BasicConv2d::new( + Conv2dConfig::new([c7, c7], [7, 1]) + .with_padding(PaddingConfig2d::Explicit(3, 0, 3, 0)), + device, + ), + branch7x7dbl_3: BasicConv2d::new( + Conv2dConfig::new([c7, c7], [1, 7]) + .with_padding(PaddingConfig2d::Explicit(0, 3, 0, 3)), + device, + ), + branch7x7dbl_4: BasicConv2d::new( + Conv2dConfig::new([c7, c7], [7, 1]) + .with_padding(PaddingConfig2d::Explicit(3, 0, 3, 0)), + device, + ), + branch7x7dbl_5: BasicConv2d::new( + Conv2dConfig::new([c7, 192], [1, 7]) + .with_padding(PaddingConfig2d::Explicit(0, 3, 0, 3)), + device, + ), + branch_pool: BasicConv2d::new(Conv2dConfig::new([in_channels, 192], [1, 1]), device), + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let branch1x1 = self.branch1x1.forward(x.clone()); + + let branch7x7 = self.branch7x7_1.forward(x.clone()); + let branch7x7 = self.branch7x7_2.forward(branch7x7); + let branch7x7 = self.branch7x7_3.forward(branch7x7); + + let branch7x7dbl = self.branch7x7dbl_1.forward(x.clone()); + let branch7x7dbl = self.branch7x7dbl_2.forward(branch7x7dbl); + let branch7x7dbl = self.branch7x7dbl_3.forward(branch7x7dbl); + let branch7x7dbl = self.branch7x7dbl_4.forward(branch7x7dbl); + let branch7x7dbl = self.branch7x7dbl_5.forward(branch7x7dbl); + + let branch_pool = + burn_core::tensor::module::avg_pool2d(x, [3, 3], [1, 1], [1, 1], false, false); + let branch_pool = self.branch_pool.forward(branch_pool); + + Tensor::cat(vec![branch1x1, branch7x7, branch7x7dbl, branch_pool], 1) + } +} + +#[derive(Module, Debug)] +pub struct InceptionD { + branch3x3_1: BasicConv2d, + branch3x3_2: BasicConv2d, + branch7x7x3_1: BasicConv2d, + branch7x7x3_2: BasicConv2d, + branch7x7x3_3: BasicConv2d, + branch7x7x3_4: BasicConv2d, +} + +impl InceptionD { + pub fn new(in_channels: usize, device: &Device) -> Self { + Self { + branch3x3_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 192], [1, 1]), device), + branch3x3_2: BasicConv2d::new( + Conv2dConfig::new([192, 320], [3, 3]).with_stride([2, 2]), + device, + ), + branch7x7x3_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 192], [1, 1]), device), + branch7x7x3_2: BasicConv2d::new( + Conv2dConfig::new([192, 192], [1, 7]) + .with_padding(PaddingConfig2d::Explicit(0, 3, 0, 3)), + device, + ), + branch7x7x3_3: BasicConv2d::new( + Conv2dConfig::new([192, 192], [7, 1]) + .with_padding(PaddingConfig2d::Explicit(3, 0, 3, 0)), + device, + ), + branch7x7x3_4: BasicConv2d::new( + Conv2dConfig::new([192, 192], [3, 3]).with_stride([2, 2]), + device, + ), + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let branch3x3 = self.branch3x3_1.forward(x.clone()); + let branch3x3 = self.branch3x3_2.forward(branch3x3); + + let branch7x7x3 = self.branch7x7x3_1.forward(x.clone()); + let branch7x7x3 = self.branch7x7x3_2.forward(branch7x7x3); + let branch7x7x3 = self.branch7x7x3_3.forward(branch7x7x3); + let branch7x7x3 = self.branch7x7x3_4.forward(branch7x7x3); + + let branch_pool = + burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], false); + + Tensor::cat(vec![branch3x3, branch7x7x3, branch_pool], 1) + } +} + +#[derive(Module, Debug)] +pub struct InceptionE { + branch1x1: BasicConv2d, + branch3x3_1: BasicConv2d, + branch3x3_2a: BasicConv2d, + branch3x3_2b: BasicConv2d, + branch3x3dbl_1: BasicConv2d, + branch3x3dbl_2: BasicConv2d, + branch3x3dbl_3a: BasicConv2d, + branch3x3dbl_3b: BasicConv2d, + branch_pool: BasicConv2d, + #[module(skip)] + use_max_pool: bool, +} + +impl InceptionE { + pub fn new(in_channels: usize, use_max_pool: bool, device: &Device) -> Self { + Self { + branch1x1: BasicConv2d::new(Conv2dConfig::new([in_channels, 320], [1, 1]), device), + branch3x3_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 384], [1, 1]), device), + branch3x3_2a: BasicConv2d::new( + Conv2dConfig::new([384, 384], [1, 3]) + .with_padding(PaddingConfig2d::Explicit(0, 1, 0, 1)), + device, + ), + branch3x3_2b: BasicConv2d::new( + Conv2dConfig::new([384, 384], [3, 1]) + .with_padding(PaddingConfig2d::Explicit(1, 0, 1, 0)), + device, + ), + branch3x3dbl_1: BasicConv2d::new(Conv2dConfig::new([in_channels, 448], [1, 1]), device), + branch3x3dbl_2: BasicConv2d::new( + Conv2dConfig::new([448, 384], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)), + device, + ), + branch3x3dbl_3a: BasicConv2d::new( + Conv2dConfig::new([384, 384], [1, 3]) + .with_padding(PaddingConfig2d::Explicit(0, 1, 0, 1)), + device, + ), + branch3x3dbl_3b: BasicConv2d::new( + Conv2dConfig::new([384, 384], [3, 1]) + .with_padding(PaddingConfig2d::Explicit(1, 0, 1, 0)), + device, + ), + branch_pool: BasicConv2d::new(Conv2dConfig::new([in_channels, 192], [1, 1]), device), + use_max_pool, + } + } + + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let branch1x1 = self.branch1x1.forward(x.clone()); + + let branch3x3 = self.branch3x3_1.forward(x.clone()); + let branch3x3_a = self.branch3x3_2a.forward(branch3x3.clone()); + let branch3x3_b = self.branch3x3_2b.forward(branch3x3); + let branch3x3 = Tensor::cat(vec![branch3x3_a, branch3x3_b], 1); + + let branch3x3dbl = self.branch3x3dbl_1.forward(x.clone()); + let branch3x3dbl = self.branch3x3dbl_2.forward(branch3x3dbl); + let branch3x3dbl_a = self.branch3x3dbl_3a.forward(branch3x3dbl.clone()); + let branch3x3dbl_b = self.branch3x3dbl_3b.forward(branch3x3dbl); + let branch3x3dbl = Tensor::cat(vec![branch3x3dbl_a, branch3x3dbl_b], 1); + + let branch_pool = if self.use_max_pool { + burn_core::tensor::module::max_pool2d(x, [3, 3], [1, 1], [1, 1], [1, 1], false) + } else { + burn_core::tensor::module::avg_pool2d(x, [3, 3], [1, 1], [1, 1], false, false) + }; + let branch_pool = self.branch_pool.forward(branch_pool); + + Tensor::cat(vec![branch1x1, branch3x3, branch3x3dbl, branch_pool], 1) + } +} + +/// InceptionV3 feature extractor for FID computation. +/// +/// Outputs a 2048-dimensional feature vector per image, matching the +/// pytorch-fid variant (TF-ported weights). +#[derive(Module, Debug)] +pub struct InceptionV3FeatureExtractor { + // Stem + conv2d_1a: BasicConv2d, + conv2d_2a: BasicConv2d, + conv2d_2b: BasicConv2d, + conv2d_3b: BasicConv2d, + conv2d_4a: BasicConv2d, + // Inception blocks + mixed_5b: InceptionA, + mixed_5c: InceptionA, + mixed_5d: InceptionA, + mixed_6a: InceptionB, + mixed_6b: InceptionC, + mixed_6c: InceptionC, + mixed_6d: InceptionC, + mixed_6e: InceptionC, + mixed_7a: InceptionD, + mixed_7b: InceptionE, + mixed_7c: InceptionE, +} + +impl InceptionV3FeatureExtractor { + /// Creates a new feature extractor with random weights. + pub fn new(device: &Device) -> Self { + Self { + // Stem: 3 -> 32 -> 32 -> 64 -> 80 -> 192 + conv2d_1a: BasicConv2d::new( + Conv2dConfig::new([3, 32], [3, 3]).with_stride([2, 2]), + device, + ), + conv2d_2a: BasicConv2d::new(Conv2dConfig::new([32, 32], [3, 3]), device), + conv2d_2b: BasicConv2d::new( + Conv2dConfig::new([32, 64], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)), + device, + ), + conv2d_3b: BasicConv2d::new(Conv2dConfig::new([64, 80], [1, 1]), device), + conv2d_4a: BasicConv2d::new(Conv2dConfig::new([80, 192], [3, 3]), device), + mixed_5b: InceptionA::new(192, 32, device), + mixed_5c: InceptionA::new(256, 64, device), + mixed_5d: InceptionA::new(288, 64, device), + mixed_6a: InceptionB::new(288, device), + mixed_6b: InceptionC::new(768, 128, device), + mixed_6c: InceptionC::new(768, 160, device), + mixed_6d: InceptionC::new(768, 160, device), + mixed_6e: InceptionC::new(768, 192, device), + mixed_7a: InceptionD::new(768, device), + mixed_7b: InceptionE::new(1280, false, device), + mixed_7c: InceptionE::new(2048, true, device), + } + } + + /// Extract 2048-dim features. Input is resized to 299x299 via bilinear + /// interpolation to match the pytorch-fid reference. + pub fn forward(&self, x: Tensor<4>) -> Tensor<2> { + let [batch, _, h, w] = x.dims(); + + let x = if h != 299 || w != 299 { + burn_core::tensor::module::interpolate( + x, + [299, 299], + InterpolateOptions::new(InterpolateMode::Bilinear), + ) + } else { + x + }; + + // Stem + let x = self.conv2d_1a.forward(x); + let x = self.conv2d_2a.forward(x); + let x = self.conv2d_2b.forward(x); + let x = burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], false); + let x = self.conv2d_3b.forward(x); + let x = self.conv2d_4a.forward(x); + let x = burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], false); + + // InceptionA + let x = self.mixed_5b.forward(x); + let x = self.mixed_5c.forward(x); + let x = self.mixed_5d.forward(x); + + // InceptionB (reduction) + let x = self.mixed_6a.forward(x); + + // InceptionC + let x = self.mixed_6b.forward(x); + let x = self.mixed_6c.forward(x); + let x = self.mixed_6d.forward(x); + let x = self.mixed_6e.forward(x); + + // InceptionD (reduction) + let x = self.mixed_7a.forward(x); + + // InceptionE + let x = self.mixed_7b.forward(x); + let x = self.mixed_7c.forward(x); + + // Global average pool -> [N, 2048] + let x = burn_core::tensor::module::adaptive_avg_pool2d(x, [1, 1]); + x.reshape([batch, 2048]) + } +} diff --git a/crates/burn-train/src/metric/vision/fid/metric.rs b/crates/burn-train/src/metric/vision/fid/metric.rs new file mode 100644 index 0000000..cfec4e0 --- /dev/null +++ b/crates/burn-train/src/metric/vision/fid/metric.rs @@ -0,0 +1,345 @@ +use burn_core as burn; + +use burn::config::Config; +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::linalg; + +use super::inception::InceptionV3FeatureExtractor; + +const IMAGENET_MEAN: [f32; 3] = [0.485, 0.456, 0.406]; +const IMAGENET_STD: [f32; 3] = [0.229, 0.224, 0.225]; +const EPS: f64 = 1e-6; + +/// Configuration for [Fid]. +/// +/// ```ignore +/// let fid = FidConfig::new().init_pretrained(&device); +/// let score = fid.forward(real_images, generated_images); +/// ``` +#[derive(Config, Debug)] +pub struct FidConfig { + /// Normalize input images from [0,1] to ImageNet range. + #[config(default = true)] + pub normalize: bool, + + /// Number of Newton-Schulz iterations for matrix square root. + #[config(default = 50)] + pub num_iterations: usize, +} + +impl FidConfig { + /// Initialize with pretrained InceptionV3 weights from pytorch-fid. + pub fn init_pretrained(&self, device: &Device) -> Fid { + let fid = self.init(device); + super::weights::load_pretrained_weights(fid) + } + + /// Initialize with random weights. + pub fn init(&self, device: &Device) -> Fid { + Fid { + extractor: InceptionV3FeatureExtractor::new(device), + normalize: self.normalize, + num_iterations: self.num_iterations, + } + } +} + +/// Frechet Inception Distance metric. +/// +/// Computes the Frechet distance between feature distributions of real and +/// generated images using an InceptionV3 feature extractor. +/// +/// ```ignore +/// let fid = FidConfig::new().init_pretrained(&device); +/// let feats_real = fid.extract_features(real_images); +/// let feats_gen = fid.extract_features(generated_images); +/// let score = fid.compute_fid(feats_real, feats_gen); +/// ``` +#[derive(Module, Debug)] +pub struct Fid { + extractor: InceptionV3FeatureExtractor, + normalize: bool, + num_iterations: usize, +} + +impl Fid { + /// Extract 2048-dim InceptionV3 features from images of shape `[batch, 3, H, W]`. + pub fn extract_features(&self, images: Tensor<4>) -> Tensor<2> { + let images = if self.normalize { + imagenet_normalize(images) + } else { + images + }; + self.extractor.forward(images) + } + + /// Compute FID from pre-extracted feature tensors of shape `[N, 2048]`. + pub fn compute_fid(&self, features_real: Tensor<2>, features_gen: Tensor<2>) -> Tensor<1> { + let (mu1, sigma1) = compute_statistics(features_real); + let (mu2, sigma2) = compute_statistics(features_gen); + frechet_distance(mu1, sigma1, mu2, sigma2, self.num_iterations) + } + + /// Compute FID end-to-end from image tensors of shape `[N, 3, H, W]` in [0,1]. + pub fn forward(&self, images_real: Tensor<4>, images_gen: Tensor<4>) -> Tensor<1> { + let features_real = self.extract_features(images_real); + let features_gen = self.extract_features(images_gen); + self.compute_fid(features_real, features_gen) + } +} + +fn imagenet_normalize(x: Tensor<4>) -> Tensor<4> { + let device = x.device(); + + let mean = Tensor::<1>::from_floats(IMAGENET_MEAN, &device).reshape([1, 3, 1, 1]); + let std = Tensor::<1>::from_floats(IMAGENET_STD, &device).reshape([1, 3, 1, 1]); + + x.sub(mean).div(std) +} + +/// Mean vector `[D]` and unbiased covariance matrix `[D, D]` from feature rows `[N, D]`. +fn compute_statistics(features: Tensor<2>) -> (Tensor<1>, Tensor<2>) { + let [n, d] = features.dims(); + let n_f = n as f64; + + let mean = features.clone().mean_dim(0).squeeze_dim::<1>(0); + let centered = features.sub(mean.clone().unsqueeze_dim::<2>(0).expand([n, d])); + + let cov = centered + .clone() + .transpose() + .matmul(centered) + .div_scalar(n_f - 1.0); + + (mean, cov) +} + +/// Newton-Schulz iteration for the square root of a symmetric PD matrix. +/// Input must be symmetric positive-definite for convergence. +fn matrix_sqrt_newton_schulz(a: Tensor<2>, num_iterations: usize) -> Tensor<2> { + let [d, _] = a.dims(); + let device = a.device(); + + // Clamp to avoid division by near-zero norms (also avoids a GPU sync). + let norm_a = a.clone().mul(a.clone()).sum().sqrt().clamp_min(EPS); + + let identity = Tensor::<2>::eye(d, &device); + let mut y = a.div(norm_a.clone().unsqueeze_dim::<2>(0).expand([d, d])); + let mut z = identity.clone(); + let three_i = identity.clone().mul_scalar(3.0); + + for _ in 0..num_iterations { + let t = three_i + .clone() + .sub(z.clone().matmul(y.clone())) + .mul_scalar(0.5); + y = y.matmul(t.clone()); + z = t.matmul(z); + } + + let sqrt_norm = norm_a.sqrt().unsqueeze_dim::<2>(0).expand([d, d]); + y.mul(sqrt_norm) +} + +/// Frechet distance between two multivariate Gaussians. +/// +/// Uses the symmetric form (S @ sigma2 @ S where S = sqrtm(sigma1)) so that +/// Newton-Schulz converges — the naive sqrtm(sigma1 @ sigma2) is non-symmetric. +fn frechet_distance( + mu1: Tensor<1>, + sigma1: Tensor<2>, + mu2: Tensor<1>, + sigma2: Tensor<2>, + num_iterations: usize, +) -> Tensor<1> { + let [d, _] = sigma1.dims(); + let device = sigma1.device(); + + let diff = mu1.sub(mu2); + let mean_term = diff.clone().mul(diff).sum(); + + // Small regularization (eps · I) scaled to the average variance for numerical + // stability with near-singular covariances. Done entirely with tensor ops to + // avoid forcing a GPU sync. + let tr_sum = linalg::trace::<2, 1>(sigma1.clone()).add(linalg::trace::<2, 1>(sigma2.clone())); + let avg_variance = tr_sum.div_scalar(2.0 * d as f64).clamp_min(EPS); + let reg = Tensor::<2>::eye(d, &device).mul(avg_variance.mul_scalar(EPS).unsqueeze_dim::<2>(0)); + let sigma1 = sigma1.add(reg.clone()); + let sigma2 = sigma2.add(reg); + + let sqrt_sigma1 = matrix_sqrt_newton_schulz(sigma1.clone(), num_iterations); + let m = sqrt_sigma1 + .clone() + .matmul(sigma2.clone()) + .matmul(sqrt_sigma1); + let sqrt_m = matrix_sqrt_newton_schulz(m, num_iterations); + + let cov_term = sigma1.add(sigma2).sub(sqrt_m.mul_scalar(2.0)); + let trace = linalg::trace::<2, 1>(cov_term); + + mean_term.add(trace).reshape([1]) +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::tensor::{TensorData, Tolerance}; + + type FT = f32; + + #[test] + fn test_newton_schulz_identity() { + let device = Default::default(); + let identity = Tensor::<2>::eye(3, &device); + let sqrt_i = matrix_sqrt_newton_schulz(identity.clone(), 50); + + sqrt_i + .into_data() + .assert_approx_eq::(&identity.into_data(), Tolerance::relative(1e-4)); + } + + #[test] + fn test_newton_schulz_diagonal() { + // sqrt(diag(4, 9, 16)) should be diag(2, 3, 4) + let device = Default::default(); + let a = Tensor::<2>::from_floats( + [[4.0, 0.0, 0.0], [0.0, 9.0, 0.0], [0.0, 0.0, 16.0]], + &device, + ); + let expected = + Tensor::<2>::from_floats([[2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0]], &device); + + let sqrt_a = matrix_sqrt_newton_schulz(a, 50); + + sqrt_a + .into_data() + .assert_approx_eq::(&expected.into_data(), Tolerance::relative(1e-3)); + } + + #[test] + fn test_compute_statistics() { + // [[1,2],[3,4],[5,6]] -> mean [3,4], centered [[-2,-2],[0,0],[2,2]] + // cov = [[8,8],[8,8]] / 2 = [[4,4],[4,4]] + let device = Default::default(); + let features = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device); + + let (mean, cov) = compute_statistics(features); + + mean.into_data() + .assert_approx_eq::(&TensorData::from([3.0_f32, 4.0]), Tolerance::default()); + cov.into_data().assert_approx_eq::( + &TensorData::from([[4.0_f32, 4.0], [4.0, 4.0]]), + Tolerance::default(), + ); + } + + #[test] + fn test_fid_same_features_is_zero() { + let device = Default::default(); + let features = Tensor::<2>::from_floats( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 1.0], + ], + &device, + ); + + let (mu, sigma) = compute_statistics(features.clone()); + let fid = frechet_distance(mu.clone(), sigma.clone(), mu, sigma, 50); + + assert!(fid.into_data().to_vec::().unwrap()[0].abs() < 0.1); + } + + #[test] + fn test_fid_shifted_mean() { + // Shift one distribution by [2, 0, 0] — FID should be ~||shift||² = 4.0 + let device = Default::default(); + + // Handcrafted feature rows with some spread + let base = Tensor::<2>::from_floats( + [ + [-0.3, 0.1, 0.4], + [0.2, -0.5, 0.1], + [0.5, 0.3, -0.2], + [-0.1, 0.4, -0.3], + [0.0, -0.2, 0.5], + [0.3, 0.0, -0.4], + [-0.4, 0.2, 0.3], + [0.1, -0.1, 0.0], + [0.4, 0.5, -0.1], + [-0.2, -0.3, 0.2], + ], + &device, + ); + + // Same data but shifted by [2, 0, 0] + let shift = Tensor::<2>::from_floats([[2.0, 0.0, 0.0]], &device).expand([10, 3]); + let shifted = base.clone().add(shift); + + let (mu1, sigma1) = compute_statistics(base); + let (mu2, sigma2) = compute_statistics(shifted); + let fid_val = frechet_distance(mu1, sigma1, mu2, sigma2, 50) + .into_data() + .to_vec::() + .unwrap()[0]; + + assert!((fid_val - 4.0).abs() < 0.5); + } + + #[test] + fn test_fid_symmetry() { + let device = Default::default(); + let features1 = + Tensor::<2>::from_floats([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5]], &device); + let features2 = + Tensor::<2>::from_floats([[2.0, 1.0], [1.0, 2.0], [2.0, 2.0], [1.5, 1.5]], &device); + + let (mu1, sigma1) = compute_statistics(features1.clone()); + let (mu2, sigma2) = compute_statistics(features2.clone()); + + let fid_forward = + frechet_distance(mu1.clone(), sigma1.clone(), mu2.clone(), sigma2.clone(), 50); + let fid_reverse = frechet_distance(mu2, sigma2, mu1, sigma1, 50); + + fid_forward + .into_data() + .assert_approx_eq::(&fid_reverse.into_data(), Tolerance::relative(1e-3)); + } + + #[test] + fn test_inception_output_shape() { + let device = Default::default(); + let extractor = InceptionV3FeatureExtractor::new(&device); + let input = Tensor::<4>::zeros([1, 3, 299, 299], &device); + assert_eq!(extractor.forward(input).dims(), [1, 2048]); + } + + #[test] + fn test_fid_extract_features_shape() { + let device = Default::default(); + let fid = FidConfig::new().init(&device); + let images = Tensor::<4>::zeros([2, 3, 299, 299], &device); + assert_eq!(fid.extract_features(images).dims(), [2, 2048]); + } + + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_fid_pretrained_features() { + let device = Default::default(); + let fid = FidConfig::new().init_pretrained(&device); + + let images = Tensor::<4>::ones([1, 3, 299, 299], &device).mul_scalar(0.5); + let features = fid.extract_features(images); + + assert_eq!(features.dims(), [1, 2048]); + let feat_data = features.into_data().to_vec::().unwrap(); + assert!(feat_data.iter().all(|v| v.is_finite())); + let norm: f32 = feat_data.iter().map(|v| v * v).sum(); + assert!(norm > 0.0); + } +} diff --git a/crates/burn-train/src/metric/vision/fid/mod.rs b/crates/burn-train/src/metric/vision/fid/mod.rs new file mode 100644 index 0000000..80ad173 --- /dev/null +++ b/crates/burn-train/src/metric/vision/fid/mod.rs @@ -0,0 +1,13 @@ +//! Frechet Inception Distance (FID) metric. +//! +//! Measures the distance between distributions of generated and real images +//! using InceptionV3 features. Lower FID = higher quality and diversity. +//! +//! Reference: + +mod inception; +mod metric; +mod weights; + +pub use inception::InceptionV3FeatureExtractor; +pub use metric::{Fid, FidConfig}; diff --git a/crates/burn-train/src/metric/vision/fid/weights.rs b/crates/burn-train/src/metric/vision/fid/weights.rs new file mode 100644 index 0000000..5ce06e2 --- /dev/null +++ b/crates/burn-train/src/metric/vision/fid/weights.rs @@ -0,0 +1,70 @@ +use burn_std::network::downloader::download_file_as_bytes; +use burn_store::{ModuleSnapshot, PytorchStore}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::PathBuf; + +use super::metric::Fid; + +const INCEPTION_WEIGHTS_URL: &str = "https://github.com/mseitzer/pytorch-fid/releases/download/fid_weights/pt-inception-2015-12-05-6726825d.pth"; + +fn get_cache_dir() -> PathBuf { + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset") + .join("fid"); + + if !cache_dir.exists() { + create_dir_all(&cache_dir).expect("Failed to create cache directory"); + } + + cache_dir +} + +fn download_if_needed(url: &str, cache_path: &PathBuf, message: &str) { + if !cache_path.exists() { + let bytes = download_file_as_bytes(url, message); + let mut file = File::create(cache_path).expect("Failed to create cache file"); + file.write_all(&bytes).expect("Failed to write weights"); + } +} + +/// Download and load pretrained pytorch-fid InceptionV3 weights. +/// +/// Weights are cached in `~/.cache/burn-dataset/fid/`. +pub fn load_pretrained_weights(mut fid: Fid) -> Fid { + let cache_dir = get_cache_dir(); + let cache_path = cache_dir.join("pt-inception-2015-12-05-6726825d.pth"); + + download_if_needed( + INCEPTION_WEIGHTS_URL, + &cache_path, + "Downloading InceptionV3 weights for FID...", + ); + + // PyTorch keys use "Conv2d_1a_3x3.*" etc, our model nests them under "extractor.*" + let mut store = PytorchStore::from_file(&cache_path) + .allow_partial(true) + .with_key_remapping(r"^Conv2d_1a_3x3\.", "extractor.conv2d_1a.") + .with_key_remapping(r"^Conv2d_2a_3x3\.", "extractor.conv2d_2a.") + .with_key_remapping(r"^Conv2d_2b_3x3\.", "extractor.conv2d_2b.") + .with_key_remapping(r"^Conv2d_3b_1x1\.", "extractor.conv2d_3b.") + .with_key_remapping(r"^Conv2d_4a_3x3\.", "extractor.conv2d_4a.") + .with_key_remapping(r"^Mixed_5b\.", "extractor.mixed_5b.") + .with_key_remapping(r"^Mixed_5c\.", "extractor.mixed_5c.") + .with_key_remapping(r"^Mixed_5d\.", "extractor.mixed_5d.") + .with_key_remapping(r"^Mixed_6a\.", "extractor.mixed_6a.") + .with_key_remapping(r"^Mixed_6b\.", "extractor.mixed_6b.") + .with_key_remapping(r"^Mixed_6c\.", "extractor.mixed_6c.") + .with_key_remapping(r"^Mixed_6d\.", "extractor.mixed_6d.") + .with_key_remapping(r"^Mixed_6e\.", "extractor.mixed_6e.") + .with_key_remapping(r"^Mixed_7a\.", "extractor.mixed_7a.") + .with_key_remapping(r"^Mixed_7b\.", "extractor.mixed_7b.") + .with_key_remapping(r"^Mixed_7c\.", "extractor.mixed_7c."); + + if let Err(e) = fid.load_from(&mut store) { + log::warn!("Some InceptionV3 weights could not be loaded: {:?}", e); + } + + fid +} diff --git a/crates/burn-train/src/metric/vision/lpips/alexnet.rs b/crates/burn-train/src/metric/vision/lpips/alexnet.rs new file mode 100644 index 0000000..908a210 --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/alexnet.rs @@ -0,0 +1,100 @@ +//! AlexNet feature extractor for LPIPS. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::relu; +use burn_nn::PaddingConfig2d; +use burn_nn::conv::{Conv2d, Conv2dConfig}; + +/// AlexNet feature extractor for LPIPS. +/// +/// Extracts features from 5 layers: +/// - conv1: 64 channels (after ReLU) +/// - conv2: 192 channels (after ReLU) +/// - conv3: 384 channels (after ReLU) +/// - conv4: 256 channels (after ReLU) +/// - conv5: 256 channels (after ReLU) +#[derive(Module, Debug)] +pub struct AlexFeatureExtractor { + /// Conv1: 3 -> 64, kernel 11x11, stride 4, padding 2 + conv1: Conv2d, + /// Conv2: 64 -> 192, kernel 5x5, stride 1, padding 2 + conv2: Conv2d, + /// Conv3: 192 -> 384, kernel 3x3, stride 1, padding 1 + conv3: Conv2d, + /// Conv4: 384 -> 256, kernel 3x3, stride 1, padding 1 + conv4: Conv2d, + /// Conv5: 256 -> 256, kernel 3x3, stride 1, padding 1 + conv5: Conv2d, +} + +impl AlexFeatureExtractor { + /// Create a new AlexNet feature extractor. + pub fn new(device: &Device) -> Self { + Self { + // Conv1: 3 -> 64, 11x11, stride 4, padding 2 + conv1: Conv2dConfig::new([3, 64], [11, 11]) + .with_stride([4, 4]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)) + .with_bias(true) + .init(device), + // Conv2: 64 -> 192, 5x5, stride 1, padding 2 + conv2: Conv2dConfig::new([64, 192], [5, 5]) + .with_padding(PaddingConfig2d::Explicit(2, 2, 2, 2)) + .with_bias(true) + .init(device), + // Conv3: 192 -> 384, 3x3, stride 1, padding 1 + conv3: Conv2dConfig::new([192, 384], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .with_bias(true) + .init(device), + // Conv4: 384 -> 256, 3x3, stride 1, padding 1 + conv4: Conv2dConfig::new([384, 256], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .with_bias(true) + .init(device), + // Conv5: 256 -> 256, 3x3, stride 1, padding 1 + conv5: Conv2dConfig::new([256, 256], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .with_bias(true) + .init(device), + } + } + + /// Extract features from 5 AlexNet layers. + pub fn forward(&self, x: Tensor<4>) -> Vec> { + let mut features = Vec::with_capacity(5); + + // Slice 1: Conv1 + ReLU + let x = relu(self.conv1.forward(x)); + features.push(x.clone()); + + // Slice 2: MaxPool + Conv2 + ReLU + let x = max_pool2d_alex(x); + let x = relu(self.conv2.forward(x)); + features.push(x.clone()); + + // Slice 3: MaxPool + Conv3 + ReLU + let x = max_pool2d_alex(x); + let x = relu(self.conv3.forward(x)); + features.push(x.clone()); + + // Slice 4: Conv4 + ReLU (no pooling) + let x = relu(self.conv4.forward(x)); + features.push(x.clone()); + + // Slice 5: Conv5 + ReLU (no pooling) + let x = relu(self.conv5.forward(x)); + features.push(x); + + features + } +} + +/// 3x3 max pooling with stride 2 (for AlexNet). +fn max_pool2d_alex(x: Tensor<4>) -> Tensor<4> { + burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], false) +} diff --git a/crates/burn-train/src/metric/vision/lpips/metric.rs b/crates/burn-train/src/metric/vision/lpips/metric.rs new file mode 100644 index 0000000..712fceb --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/metric.rs @@ -0,0 +1,790 @@ +//! LPIPS (Learned Perceptual Image Patch Similarity) metric module. +//! +//! LPIPS measures perceptual similarity between images using deep features. +//! Supports VGG16, AlexNet, and SqueezeNet as backbone networks. +//! +//! Reference: "The Unreasonable Effectiveness of Deep Features as a Perceptual Metric" +//! + +use burn_core as burn; + +use burn::config::Config; +use burn::module::{Content, DisplaySettings, Module, ModuleDisplay}; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn_nn::conv::{Conv2d, Conv2dConfig}; +use burn_nn::loss::Reduction; + +use super::alexnet::AlexFeatureExtractor; +use super::squeezenet::SqueezeFeatureExtractor; +use super::vgg::VggFeatureExtractor; + +/// Network type for LPIPS. +#[derive(Config, Debug, Copy, PartialEq, Eq)] +pub enum LpipsNet { + /// VGG16 network (default) + Vgg, + /// AlexNet network + Alex, + /// SqueezeNet network + Squeeze, +} + +/// Configuration for [Lpips](Lpips) metric module. +/// +/// # Example +/// +/// ```ignore +/// use burn_train::metric::vision::{LpipsConfig, LpipsNet}; +/// +/// // VGG (default) +/// let lpips_vgg = LpipsConfig::new().init(&device); +/// +/// // AlexNet +/// let lpips_alex = LpipsConfig::new() +/// .with_net(LpipsNet::Alex) +/// .init(&device); +/// +/// // SqueezeNet +/// let lpips_squeeze = LpipsConfig::new() +/// .with_net(LpipsNet::Squeeze) +/// .init(&device); +/// ``` +#[derive(Config, Debug)] +pub struct LpipsConfig { + /// Network type for feature extraction. + #[config(default = "LpipsNet::Vgg")] + pub net: LpipsNet, + + /// Whether to normalize input images to [-1, 1] range. + /// Set to true if input is in [0, 1] range. + #[config(default = true)] + pub normalize: bool, +} + +impl LpipsConfig { + /// Initialize a new [Lpips](Lpips) module with pretrained weights. + /// + /// Downloads and loads official LPIPS pretrained weights from the + /// PerceptualSimilarity repository. + /// + /// # Arguments + /// + /// * `device` - Device to create the module on. + /// + /// # Returns + /// + /// A new LPIPS module with pretrained weights loaded. + /// + /// # Example + /// + /// ```ignore + /// use burn_train::metric::vision::{LpipsConfig, LpipsNet}; + /// + /// let lpips = LpipsConfig::new() + /// .with_net(LpipsNet::Vgg) + /// .init_pretrained(&device); + /// ``` + pub fn init_pretrained(&self, device: &Device) -> Lpips { + let lpips = self.init(device); + super::weights::load_pretrained_weights(lpips, self.net) + } + + /// Initialize a new [Lpips](Lpips) module with random weights. + /// + /// # Arguments + /// + /// * `device` - Device to create the module on. + /// + /// # Returns + /// + /// A new LPIPS module with random weights. Use `init_pretrained` for accurate results. + pub fn init(&self, device: &Device) -> Lpips { + match self.net { + LpipsNet::Vgg => { + // Channel sizes for VGG16: [64, 128, 256, 512, 512] + Lpips::Vgg(LpipsVgg { + extractor: VggFeatureExtractor::new(device), + lin0: Conv2dConfig::new([64, 1], [1, 1]) + .with_bias(false) + .init(device), + lin1: Conv2dConfig::new([128, 1], [1, 1]) + .with_bias(false) + .init(device), + lin2: Conv2dConfig::new([256, 1], [1, 1]) + .with_bias(false) + .init(device), + lin3: Conv2dConfig::new([512, 1], [1, 1]) + .with_bias(false) + .init(device), + lin4: Conv2dConfig::new([512, 1], [1, 1]) + .with_bias(false) + .init(device), + normalize: self.normalize, + }) + } + LpipsNet::Alex => { + // Channel sizes for AlexNet: [64, 192, 384, 256, 256] + Lpips::Alex(LpipsAlex { + extractor: AlexFeatureExtractor::new(device), + lin0: Conv2dConfig::new([64, 1], [1, 1]) + .with_bias(false) + .init(device), + lin1: Conv2dConfig::new([192, 1], [1, 1]) + .with_bias(false) + .init(device), + lin2: Conv2dConfig::new([384, 1], [1, 1]) + .with_bias(false) + .init(device), + lin3: Conv2dConfig::new([256, 1], [1, 1]) + .with_bias(false) + .init(device), + lin4: Conv2dConfig::new([256, 1], [1, 1]) + .with_bias(false) + .init(device), + normalize: self.normalize, + }) + } + LpipsNet::Squeeze => { + // Channel sizes for SqueezeNet: [64, 128, 256, 384, 384, 512, 512] + Lpips::Squeeze(LpipsSqueeze { + extractor: SqueezeFeatureExtractor::new(device), + lin0: Conv2dConfig::new([64, 1], [1, 1]) + .with_bias(false) + .init(device), + lin1: Conv2dConfig::new([128, 1], [1, 1]) + .with_bias(false) + .init(device), + lin2: Conv2dConfig::new([256, 1], [1, 1]) + .with_bias(false) + .init(device), + lin3: Conv2dConfig::new([384, 1], [1, 1]) + .with_bias(false) + .init(device), + lin4: Conv2dConfig::new([384, 1], [1, 1]) + .with_bias(false) + .init(device), + lin5: Conv2dConfig::new([512, 1], [1, 1]) + .with_bias(false) + .init(device), + lin6: Conv2dConfig::new([512, 1], [1, 1]) + .with_bias(false) + .init(device), + normalize: self.normalize, + }) + } + } + } +} + +/// LPIPS (Learned Perceptual Image Patch Similarity) metric module. +/// +/// Computes perceptual distance between two images using deep features. +/// Supports VGG16, AlexNet, and SqueezeNet as backbone networks. +/// +/// # Example +/// +/// ```ignore +/// use burn_train::metric::vision::{LpipsConfig, LpipsNet, Reduction}; +/// +/// let device = Default::default(); +/// let lpips = LpipsConfig::new().init(&device); +/// +/// let img1: Tensor<4> = /* [batch, 3, H, W] */; +/// let img2: Tensor<4> = /* [batch, 3, H, W] */; +/// +/// // Compute LPIPS distance +/// let distance = lpips.forward(img1, img2, Reduction::Mean); +/// ``` +#[derive(Module, Debug)] +#[allow(clippy::large_enum_variant)] +#[module(custom_display)] +pub enum Lpips { + /// VGG16 backbone (5 feature layers) + Vgg(LpipsVgg), + /// AlexNet backbone (5 feature layers) + Alex(LpipsAlex), + /// SqueezeNet backbone (7 feature layers) + Squeeze(LpipsSqueeze), +} + +/// LPIPS with VGG16 backbone. +#[derive(Module, Debug)] +pub struct LpipsVgg { + /// VGG feature extractor + pub(crate) extractor: VggFeatureExtractor, + /// Linear layers for each feature level + pub(crate) lin0: Conv2d, + pub(crate) lin1: Conv2d, + pub(crate) lin2: Conv2d, + pub(crate) lin3: Conv2d, + pub(crate) lin4: Conv2d, + /// Whether to normalize input + pub(crate) normalize: bool, +} + +/// LPIPS with AlexNet backbone. +#[derive(Module, Debug)] +pub struct LpipsAlex { + /// AlexNet feature extractor + pub(crate) extractor: AlexFeatureExtractor, + /// Linear layers for each feature level + pub(crate) lin0: Conv2d, + pub(crate) lin1: Conv2d, + pub(crate) lin2: Conv2d, + pub(crate) lin3: Conv2d, + pub(crate) lin4: Conv2d, + /// Whether to normalize input + pub(crate) normalize: bool, +} + +/// LPIPS with SqueezeNet backbone. +#[derive(Module, Debug)] +pub struct LpipsSqueeze { + /// SqueezeNet feature extractor + pub(crate) extractor: SqueezeFeatureExtractor, + /// Linear layers for each feature level + pub(crate) lin0: Conv2d, + pub(crate) lin1: Conv2d, + pub(crate) lin2: Conv2d, + pub(crate) lin3: Conv2d, + pub(crate) lin4: Conv2d, + pub(crate) lin5: Conv2d, + pub(crate) lin6: Conv2d, + /// Whether to normalize input + pub(crate) normalize: bool, +} + +impl LpipsVgg { + /// Compute LPIPS distance without reduction using VGG backbone. + pub fn forward_no_reduction(&self, input: Tensor<4>, target: Tensor<4>) -> Tensor<1> { + // Preprocess inputs + let (input, target) = preprocess_inputs(input, target, self.normalize); + + // Extract features from both images + let feats0 = self.extractor.forward(input); + let feats1 = self.extractor.forward(target); + + // Compute distance for each layer using stack + sum + let layer_distances: Vec> = vec![ + compute_layer_distance(&feats0[0], &feats1[0], &self.lin0).unsqueeze_dim(1), + compute_layer_distance(&feats0[1], &feats1[1], &self.lin1).unsqueeze_dim(1), + compute_layer_distance(&feats0[2], &feats1[2], &self.lin2).unsqueeze_dim(1), + compute_layer_distance(&feats0[3], &feats1[3], &self.lin3).unsqueeze_dim(1), + compute_layer_distance(&feats0[4], &feats1[4], &self.lin4).unsqueeze_dim(1), + ]; + + Tensor::cat(layer_distances, 1) + .sum_dim(1) + .squeeze_dim::<1>(1) + } +} + +impl LpipsAlex { + /// Compute LPIPS distance without reduction using AlexNet backbone. + pub fn forward_no_reduction(&self, input: Tensor<4>, target: Tensor<4>) -> Tensor<1> { + // Preprocess inputs + let (input, target) = preprocess_inputs(input, target, self.normalize); + + // Extract features from both images + let feats0 = self.extractor.forward(input); + let feats1 = self.extractor.forward(target); + + // Compute distance for each layer using stack + sum + let layer_distances: Vec> = vec![ + compute_layer_distance(&feats0[0], &feats1[0], &self.lin0).unsqueeze_dim(1), + compute_layer_distance(&feats0[1], &feats1[1], &self.lin1).unsqueeze_dim(1), + compute_layer_distance(&feats0[2], &feats1[2], &self.lin2).unsqueeze_dim(1), + compute_layer_distance(&feats0[3], &feats1[3], &self.lin3).unsqueeze_dim(1), + compute_layer_distance(&feats0[4], &feats1[4], &self.lin4).unsqueeze_dim(1), + ]; + + Tensor::cat(layer_distances, 1) + .sum_dim(1) + .squeeze_dim::<1>(1) + } +} + +impl LpipsSqueeze { + /// Compute LPIPS distance without reduction using SqueezeNet backbone. + pub fn forward_no_reduction(&self, input: Tensor<4>, target: Tensor<4>) -> Tensor<1> { + // Preprocess inputs + let (input, target) = preprocess_inputs(input, target, self.normalize); + + // Extract features from both images + let feats0 = self.extractor.forward(input); + let feats1 = self.extractor.forward(target); + + // Compute distance for each layer using stack + sum (7 layers for SqueezeNet) + let layer_distances: Vec> = vec![ + compute_layer_distance(&feats0[0], &feats1[0], &self.lin0).unsqueeze_dim(1), + compute_layer_distance(&feats0[1], &feats1[1], &self.lin1).unsqueeze_dim(1), + compute_layer_distance(&feats0[2], &feats1[2], &self.lin2).unsqueeze_dim(1), + compute_layer_distance(&feats0[3], &feats1[3], &self.lin3).unsqueeze_dim(1), + compute_layer_distance(&feats0[4], &feats1[4], &self.lin4).unsqueeze_dim(1), + compute_layer_distance(&feats0[5], &feats1[5], &self.lin5).unsqueeze_dim(1), + compute_layer_distance(&feats0[6], &feats1[6], &self.lin6).unsqueeze_dim(1), + ]; + + Tensor::cat(layer_distances, 1) + .sum_dim(1) + .squeeze_dim::<1>(1) + } +} + +impl ModuleDisplay for Lpips { + fn custom_settings(&self) -> Option { + DisplaySettings::new() + .with_new_line_after_attribute(false) + .optional() + } + + fn custom_content(&self, content: Content) -> Option { + let (net_name, normalize) = match self { + Lpips::Vgg(inner) => ("Vgg", inner.normalize), + Lpips::Alex(inner) => ("Alex", inner.normalize), + Lpips::Squeeze(inner) => ("Squeeze", inner.normalize), + }; + content + .add("net", &net_name.to_string()) + .add("normalize", &normalize.to_string()) + .optional() + } +} + +impl Lpips { + /// Compute LPIPS distance with reduction. + /// + /// # Arguments + /// + /// * `input` - First image tensor of shape `[batch, 3, H, W]` + /// * `target` - Second image tensor of shape `[batch, 3, H, W]` + /// * `reduction` - How to reduce the output (Mean, Sum, or Auto) + /// + /// # Returns + /// + /// Scalar tensor of shape `[1]`. + /// + /// # Shapes + /// + /// - input: `[batch, 3, H, W]` + /// - target: `[batch, 3, H, W]` + /// - output: `[1]` + pub fn forward(&self, input: Tensor<4>, target: Tensor<4>, reduction: Reduction) -> Tensor<1> { + let distance = self.forward_no_reduction(input, target); + + match reduction { + Reduction::Mean | Reduction::Auto | Reduction::BatchMean => distance.mean(), + Reduction::Sum => distance.sum(), + } + } + + /// Compute LPIPS distance without reduction. + /// + /// # Arguments + /// + /// * `input` - First image tensor of shape `[batch, 3, H, W]` + /// * `target` - Second image tensor of shape `[batch, 3, H, W]` + /// + /// # Returns + /// + /// Per-sample distance tensor of shape `[batch]`. + /// + /// # Shapes + /// + /// - input: `[batch, 3, H, W]` + /// - target: `[batch, 3, H, W]` + /// - output: `[batch]` + pub fn forward_no_reduction(&self, input: Tensor<4>, target: Tensor<4>) -> Tensor<1> { + match self { + Lpips::Vgg(inner) => inner.forward_no_reduction(input, target), + Lpips::Alex(inner) => inner.forward_no_reduction(input, target), + Lpips::Squeeze(inner) => inner.forward_no_reduction(input, target), + } + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Normalize tensor to unit norm along channel dimension. +fn normalize_tensor(x: Tensor<4>) -> Tensor<4> { + let norm = x.clone().mul(x.clone()).sum_dim(1).sqrt().clamp_min(1e-10); + x.div(norm) +} + +/// Apply ImageNet normalization used by PyTorch lpips. +/// shift = [-.030, -.088, -.188], scale = [.458, .448, .450] +/// output = (input - shift) / scale +fn scaling_layer(x: Tensor<4>) -> Tensor<4> { + let device = x.device(); + let [batch, _, h, w] = x.dims(); + + // Create shift and scale tensors [1, 3, 1, 1] and broadcast + let shift = Tensor::<2>::from_floats([[-0.030], [-0.088], [-0.188]], &device) + .reshape([1, 3, 1, 1]) + .expand([batch, 3, h, w]); + let scale = Tensor::<2>::from_floats([[0.458], [0.448], [0.450]], &device) + .reshape([1, 3, 1, 1]) + .expand([batch, 3, h, w]); + + x.sub(shift).div(scale) +} + +/// Compute normalized L2 distance for a single layer. +fn compute_layer_distance(feat0: &Tensor<4>, feat1: &Tensor<4>, lin: &Conv2d) -> Tensor<1> { + // Normalize features (unit norm along channel dimension) + let feat0_norm = normalize_tensor(feat0.clone()); + let feat1_norm = normalize_tensor(feat1.clone()); + + // Compute squared difference + let diff = feat0_norm.sub(feat1_norm); + let diff_sq = diff.clone().mul(diff); + + // Apply linear layer (learned weights) + // Shape: [batch, C, H, W] -> [batch, 1, H, W] + let weighted = lin.forward(diff_sq); + + // Spatial average: compute mean over C, H, W dimensions + // Shape: [batch, 1, H, W] -> [batch] + let [batch, c, h, w] = weighted.dims(); + + // Reshape to [batch, c*h*w] then take mean over last dimension + weighted + .reshape([batch, c * h * w]) + .mean_dim(1) + .squeeze_dim::<1>(1) +} + +/// Preprocess input images for LPIPS computation. +fn preprocess_inputs( + input: Tensor<4>, + target: Tensor<4>, + normalize: bool, +) -> (Tensor<4>, Tensor<4>) { + // Normalize to [-1, 1] if needed + let (input, target) = if normalize { + ( + input.mul_scalar(2.0).sub_scalar(1.0), + target.mul_scalar(2.0).sub_scalar(1.0), + ) + } else { + (input, target) + }; + + // Apply ImageNet normalization (same as PyTorch lpips scaling_layer) + (scaling_layer(input), scaling_layer(target)) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::tensor::{TensorData, Tolerance}; + + type FT = f32; + + // ========================================================================= + // Basic Functionality Tests + // ========================================================================= + + /// Identical images should have LPIPS distance of 0. + #[test] + fn test_lpips_identical_images_zero_distance() { + let device = Default::default(); + let image = Tensor::<4>::ones([1, 3, 32, 32], &device); + + let lpips = LpipsConfig::new().init(&device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + + // Identical images → distance = 0 + let expected = TensorData::from([0.0]); + distance + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + /// Different images should have LPIPS distance != 0. + /// Note: With random weights, distance can be negative, so we only check != 0. + /// Non-negativity is tested with pretrained weights. + #[test] + fn test_lpips_different_images_nonzero_distance() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([1, 3, 32, 32], &device); + + let lpips = LpipsConfig::new().init(&device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() > 1e-6, + "LPIPS should be != 0 for different images" + ); + } + + /// Test symmetry: LPIPS(a, b) == LPIPS(b, a). + #[test] + fn test_lpips_symmetry() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([1, 3, 32, 32], &device); + + let lpips = LpipsConfig::new().init(&device); + let distance_forward = lpips.forward(image1.clone(), image2.clone(), Reduction::Mean); + let distance_reverse = lpips.forward(image2, image1, Reduction::Mean); + + distance_forward + .into_data() + .assert_approx_eq::(&distance_reverse.into_data(), Tolerance::default()); + } + + // ========================================================================= + // Reduction Tests + // ========================================================================= + + #[test] + fn test_lpips_forward_mean_reduction() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([2, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([2, 3, 32, 32], &device); + + let lpips = LpipsConfig::new().init(&device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + + assert_eq!(distance.dims(), [1]); + } + + #[test] + fn test_lpips_forward_no_reduction() { + let device = Default::default(); + + let batch_size = 4; + let image1 = Tensor::<4>::zeros([batch_size, 3, 32, 32], &device); + let image2 = Tensor::<4>::ones([batch_size, 3, 32, 32], &device); + + let lpips = LpipsConfig::new().init(&device); + let distance = lpips.forward_no_reduction(image1, image2); + + assert_eq!(distance.dims(), [batch_size]); + } + + // ========================================================================= + // AlexNet Tests + // ========================================================================= + + /// Test AlexNet LPIPS with identical images. + #[test] + fn test_lpips_alex_identical_images_zero_distance() { + let device = Default::default(); + let image = Tensor::<4>::ones([1, 3, 64, 64], &device); + + let lpips = LpipsConfig::new().with_net(LpipsNet::Alex).init(&device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + + let expected = TensorData::from([0.0]); + distance + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + /// Test AlexNet LPIPS with different images produces non-zero distance. + #[test] + fn test_lpips_alex_different_images_nonzero_distance() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + + let lpips = LpipsConfig::new().with_net(LpipsNet::Alex).init(&device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + // Note: With random weights, non-negativity is not guaranteed. + // We only check that different images produce a non-zero distance. + assert!( + distance_value.abs() > 1e-6, + "LPIPS (Alex) should be != 0 for different images" + ); + } + + // ========================================================================= + // SqueezeNet Tests + // ========================================================================= + + /// Test SqueezeNet LPIPS with identical images. + #[test] + fn test_lpips_squeeze_identical_images_zero_distance() { + let device = Default::default(); + let image = Tensor::<4>::ones([1, 3, 64, 64], &device); + + let lpips = LpipsConfig::new().with_net(LpipsNet::Squeeze).init(&device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + + let expected = TensorData::from([0.0]); + distance + .into_data() + .assert_approx_eq::(&expected, Tolerance::default()); + } + + /// Test SqueezeNet LPIPS with different images produces non-zero distance. + #[test] + fn test_lpips_squeeze_different_images_nonzero_distance() { + let device = Default::default(); + + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + + let lpips = LpipsConfig::new().with_net(LpipsNet::Squeeze).init(&device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + // Note: With random weights, non-negativity is not guaranteed. + // We only check that different images produce a non-zero distance. + assert!( + distance_value.abs() > 1e-6, + "LPIPS (Squeeze) should be != 0 for different images" + ); + } + + // ========================================================================= + // Display Tests + // ========================================================================= + + #[test] + fn display_vgg() { + let device = Default::default(); + let lpips = LpipsConfig::new().init(&device); + + let display_str = format!("{lpips}"); + assert!(display_str.contains("Lpips")); + assert!(display_str.contains("Vgg")); + } + + #[test] + fn display_alex() { + let device = Default::default(); + let lpips = LpipsConfig::new().with_net(LpipsNet::Alex).init(&device); + + let display_str = format!("{lpips}"); + assert!(display_str.contains("Lpips")); + assert!(display_str.contains("Alex")); + } + + #[test] + fn display_squeeze() { + let device = Default::default(); + let lpips = LpipsConfig::new().with_net(LpipsNet::Squeeze).init(&device); + + let display_str = format!("{lpips}"); + assert!(display_str.contains("Lpips")); + assert!(display_str.contains("Squeeze")); + } + + // ========================================================================= + // Pretrained Weights Tests (requires network) + // ========================================================================= + + /// Test VGG pretrained weights download and loading. + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_lpips_pretrained_vgg() { + let device = Default::default(); + + // This will download ~60MB of weights + let lpips = LpipsConfig::new() + .with_net(LpipsNet::Vgg) + .init_pretrained(&device); + + // Test with identical images - should be 0 + let image = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() < 1e-5, + "Pretrained LPIPS (VGG) should be ~0 for identical images, got {}", + distance_value + ); + + // Test with different images - should be positive + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value > 0.0, + "Pretrained LPIPS (VGG) should be > 0 for different images, got {}", + distance_value + ); + } + + /// Test AlexNet pretrained weights download and loading. + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_lpips_pretrained_alex() { + let device = Default::default(); + + let lpips = LpipsConfig::new() + .with_net(LpipsNet::Alex) + .init_pretrained(&device); + + // Test with identical images + let image = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() < 1e-5, + "Pretrained LPIPS (Alex) should be ~0 for identical images, got {}", + distance_value + ); + + // Test with different images + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value > 0.0, + "Pretrained LPIPS (Alex) should be > 0 for different images" + ); + } + + /// Test SqueezeNet pretrained weights download and loading. + #[test] + #[ignore = "downloads pre-trained weights"] + fn test_lpips_pretrained_squeeze() { + let device = Default::default(); + + let lpips = LpipsConfig::new() + .with_net(LpipsNet::Squeeze) + .init_pretrained(&device); + + // Test with identical images + let image = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image.clone(), image, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value.abs() < 1e-5, + "Pretrained LPIPS (Squeeze) should be ~0 for identical images, got {}", + distance_value + ); + + // Test with different images + let image1 = Tensor::<4>::zeros([1, 3, 64, 64], &device); + let image2 = Tensor::<4>::ones([1, 3, 64, 64], &device); + let distance = lpips.forward(image1, image2, Reduction::Mean); + let distance_value = distance.into_data().to_vec::().unwrap()[0]; + assert!( + distance_value > 0.0, + "Pretrained LPIPS (Squeeze) should be > 0 for different images, got {}", + distance_value + ); + } +} diff --git a/crates/burn-train/src/metric/vision/lpips/mod.rs b/crates/burn-train/src/metric/vision/lpips/mod.rs new file mode 100644 index 0000000..9e5e6a5 --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/mod.rs @@ -0,0 +1,21 @@ +//! LPIPS (Learned Perceptual Image Patch Similarity) metric module. +//! +//! LPIPS measures perceptual similarity between images using deep features. +//! Supports VGG16, AlexNet, and SqueezeNet as backbone networks. +//! +//! Reference: "The Unreasonable Effectiveness of Deep Features as a Perceptual Metric" +//! + +mod alexnet; +mod metric; +mod squeezenet; +mod vgg; +mod weights; + +pub use metric::{Lpips, LpipsAlex, LpipsConfig, LpipsNet, LpipsSqueeze, LpipsVgg}; +pub use weights::{get_backbone_weights_url, get_lpips_weights_url, load_pretrained_weights}; + +// Re-export feature extractors for advanced use cases +pub use alexnet::AlexFeatureExtractor; +pub use squeezenet::{FireModule, SqueezeFeatureExtractor}; +pub use vgg::VggFeatureExtractor; diff --git a/crates/burn-train/src/metric/vision/lpips/squeezenet.rs b/crates/burn-train/src/metric/vision/lpips/squeezenet.rs new file mode 100644 index 0000000..7dde042 --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/squeezenet.rs @@ -0,0 +1,157 @@ +//! SqueezeNet feature extractor for LPIPS. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::relu; +use burn_nn::PaddingConfig2d; +use burn_nn::conv::{Conv2d, Conv2dConfig}; + +/// Fire module for SqueezeNet. +/// +/// A fire module consists of: +/// - Squeeze layer: 1x1 conv to reduce channels +/// - Expand layers: parallel 1x1 and 3x3 convs, concatenated +#[derive(Module, Debug)] +pub struct FireModule { + /// Squeeze layer: 1x1 conv + squeeze: Conv2d, + /// Expand 1x1 conv + expand1x1: Conv2d, + /// Expand 3x3 conv + expand3x3: Conv2d, +} + +impl FireModule { + /// Create a new Fire module. + pub fn new( + in_channels: usize, + squeeze_channels: usize, + expand1x1_channels: usize, + expand3x3_channels: usize, + device: &Device, + ) -> Self { + Self { + squeeze: Conv2dConfig::new([in_channels, squeeze_channels], [1, 1]) + .with_bias(true) + .init(device), + expand1x1: Conv2dConfig::new([squeeze_channels, expand1x1_channels], [1, 1]) + .with_bias(true) + .init(device), + expand3x3: Conv2dConfig::new([squeeze_channels, expand3x3_channels], [3, 3]) + .with_padding(PaddingConfig2d::Explicit(1, 1, 1, 1)) + .with_bias(true) + .init(device), + } + } + + /// Forward pass through fire module. + pub fn forward(&self, x: Tensor<4>) -> Tensor<4> { + let squeezed = relu(self.squeeze.forward(x)); + let e1 = relu(self.expand1x1.forward(squeezed.clone())); + let e3 = relu(self.expand3x3.forward(squeezed)); + // Concatenate along channel dimension + Tensor::cat(vec![e1, e3], 1) + } +} + +/// SqueezeNet 1.1 feature extractor for LPIPS. +/// +/// Extracts features from 7 layers: +/// - After conv1+relu: 64 channels +/// - After fire1+fire2: 128 channels +/// - After fire3+fire4: 256 channels +/// - After fire5: 384 channels +/// - After fire6: 384 channels +/// - After fire7: 512 channels +/// - After fire8: 512 channels +#[derive(Module, Debug)] +pub struct SqueezeFeatureExtractor { + /// Conv1: 3 -> 64, kernel 3x3, stride 2 + conv1: Conv2d, + /// Fire1: 64 -> 128 (squeeze=16, expand=64+64) + fire1: FireModule, + /// Fire2: 128 -> 128 (squeeze=16, expand=64+64) + fire2: FireModule, + /// Fire3: 128 -> 256 (squeeze=32, expand=128+128) + fire3: FireModule, + /// Fire4: 256 -> 256 (squeeze=32, expand=128+128) + fire4: FireModule, + /// Fire5: 256 -> 384 (squeeze=48, expand=192+192) + fire5: FireModule, + /// Fire6: 384 -> 384 (squeeze=48, expand=192+192) + fire6: FireModule, + /// Fire7: 384 -> 512 (squeeze=64, expand=256+256) + fire7: FireModule, + /// Fire8: 512 -> 512 (squeeze=64, expand=256+256) + fire8: FireModule, +} + +impl SqueezeFeatureExtractor { + /// Create a new SqueezeNet feature extractor. + pub fn new(device: &Device) -> Self { + Self { + // Conv1: 3 -> 64, 3x3, stride 2 + conv1: Conv2dConfig::new([3, 64], [3, 3]) + .with_stride([2, 2]) + .with_bias(true) + .init(device), + // Fire modules (SqueezeNet 1.1 configuration) + fire1: FireModule::new(64, 16, 64, 64, device), // -> 128 + fire2: FireModule::new(128, 16, 64, 64, device), // -> 128 + fire3: FireModule::new(128, 32, 128, 128, device), // -> 256 + fire4: FireModule::new(256, 32, 128, 128, device), // -> 256 + fire5: FireModule::new(256, 48, 192, 192, device), // -> 384 + fire6: FireModule::new(384, 48, 192, 192, device), // -> 384 + fire7: FireModule::new(384, 64, 256, 256, device), // -> 512 + fire8: FireModule::new(512, 64, 256, 256, device), // -> 512 + } + } + + /// Extract features from 7 SqueezeNet layers. + pub fn forward(&self, x: Tensor<4>) -> Vec> { + let mut features = Vec::with_capacity(7); + + // Slice 1: Conv1 + ReLU (64 channels) + let x = relu(self.conv1.forward(x)); + features.push(x.clone()); + + // Slice 2: MaxPool + Fire1 + Fire2 (128 channels) + let x = max_pool2d_squeeze(x); + let x = self.fire1.forward(x); + let x = self.fire2.forward(x); + features.push(x.clone()); + + // Slice 3: MaxPool + Fire3 + Fire4 (256 channels) + let x = max_pool2d_squeeze(x); + let x = self.fire3.forward(x); + let x = self.fire4.forward(x); + features.push(x.clone()); + + // Slice 4: MaxPool + Fire5 (384 channels) + let x = max_pool2d_squeeze(x); + let x = self.fire5.forward(x); + features.push(x.clone()); + + // Slice 5: Fire6 (384 channels) + let x = self.fire6.forward(x); + features.push(x.clone()); + + // Slice 6: Fire7 (512 channels) + let x = self.fire7.forward(x); + features.push(x.clone()); + + // Slice 7: Fire8 (512 channels) + let x = self.fire8.forward(x); + features.push(x); + + features + } +} + +/// 3x3 max pooling with stride 2, ceil mode (for SqueezeNet). +fn max_pool2d_squeeze(x: Tensor<4>) -> Tensor<4> { + burn_core::tensor::module::max_pool2d(x, [3, 3], [2, 2], [0, 0], [1, 1], true) +} diff --git a/crates/burn-train/src/metric/vision/lpips/vgg.rs b/crates/burn-train/src/metric/vision/lpips/vgg.rs new file mode 100644 index 0000000..0d3d503 --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/vgg.rs @@ -0,0 +1,116 @@ +//! VGG16 feature extractor for LPIPS. + +use burn_core as burn; + +use burn::module::Module; +use burn::tensor::Device; +use burn::tensor::Tensor; +use burn::tensor::activation::relu; +use burn_nn::PaddingConfig2d; +use burn_nn::conv::{Conv2d, Conv2dConfig}; + +/// VGG16 feature extractor for LPIPS. +/// +/// Extracts features from 5 layers: +/// - conv1_2: 64 channels +/// - conv2_2: 128 channels +/// - conv3_3: 256 channels +/// - conv4_3: 512 channels +/// - conv5_3: 512 channels +#[derive(Module, Debug)] +pub struct VggFeatureExtractor { + // Block 1 + conv1_1: Conv2d, + conv1_2: Conv2d, + // Block 2 + conv2_1: Conv2d, + conv2_2: Conv2d, + // Block 3 + conv3_1: Conv2d, + conv3_2: Conv2d, + conv3_3: Conv2d, + // Block 4 + conv4_1: Conv2d, + conv4_2: Conv2d, + conv4_3: Conv2d, + // Block 5 + conv5_1: Conv2d, + conv5_2: Conv2d, + conv5_3: Conv2d, +} + +impl VggFeatureExtractor { + /// Create a new VGG16 feature extractor. + pub fn new(device: &Device) -> Self { + let conv_config = |in_ch, out_ch| { + Conv2dConfig::new([in_ch, out_ch], [3, 3]) + .with_padding(PaddingConfig2d::Same) + .with_bias(true) + }; + + Self { + // Block 1: 3 -> 64 + conv1_1: conv_config(3, 64).init(device), + conv1_2: conv_config(64, 64).init(device), + // Block 2: 64 -> 128 + conv2_1: conv_config(64, 128).init(device), + conv2_2: conv_config(128, 128).init(device), + // Block 3: 128 -> 256 + conv3_1: conv_config(128, 256).init(device), + conv3_2: conv_config(256, 256).init(device), + conv3_3: conv_config(256, 256).init(device), + // Block 4: 256 -> 512 + conv4_1: conv_config(256, 512).init(device), + conv4_2: conv_config(512, 512).init(device), + conv4_3: conv_config(512, 512).init(device), + // Block 5: 512 -> 512 + conv5_1: conv_config(512, 512).init(device), + conv5_2: conv_config(512, 512).init(device), + conv5_3: conv_config(512, 512).init(device), + } + } + + /// Extract features from 5 VGG layers. + pub fn forward(&self, x: Tensor<4>) -> Vec> { + let mut features = Vec::with_capacity(5); + + // Block 1 + let x = relu(self.conv1_1.forward(x)); + let x = relu(self.conv1_2.forward(x)); + features.push(x.clone()); + let x = max_pool2d(x); + + // Block 2 + let x = relu(self.conv2_1.forward(x)); + let x = relu(self.conv2_2.forward(x)); + features.push(x.clone()); + let x = max_pool2d(x); + + // Block 3 + let x = relu(self.conv3_1.forward(x)); + let x = relu(self.conv3_2.forward(x)); + let x = relu(self.conv3_3.forward(x)); + features.push(x.clone()); + let x = max_pool2d(x); + + // Block 4 + let x = relu(self.conv4_1.forward(x)); + let x = relu(self.conv4_2.forward(x)); + let x = relu(self.conv4_3.forward(x)); + features.push(x.clone()); + let x = max_pool2d(x); + + // Block 5 + let x = relu(self.conv5_1.forward(x)); + let x = relu(self.conv5_2.forward(x)); + let x = relu(self.conv5_3.forward(x)); + features.push(x); + + features + } +} + +/// 2x2 max pooling with stride 2. +fn max_pool2d(x: Tensor<4>) -> Tensor<4> { + burn_core::tensor::module::max_pool2d(x, [2, 2], [2, 2], [0, 0], [1, 1], false) +} diff --git a/crates/burn-train/src/metric/vision/lpips/weights.rs b/crates/burn-train/src/metric/vision/lpips/weights.rs new file mode 100644 index 0000000..6dfc1dc --- /dev/null +++ b/crates/burn-train/src/metric/vision/lpips/weights.rs @@ -0,0 +1,225 @@ +//! Pretrained weights loading for LPIPS. + +use burn_std::network::downloader::download_file_as_bytes; +use burn_store::{ModuleSnapshot, PytorchStore}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::PathBuf; + +use super::metric::{Lpips, LpipsNet}; + +/// URLs for pretrained LPIPS linear layer weights from the official repository. +/// Reference: https://github.com/richzhang/PerceptualSimilarity +const LPIPS_VGG_URL: &str = + "https://github.com/richzhang/PerceptualSimilarity/raw/master/lpips/weights/v0.1/vgg.pth"; +const LPIPS_ALEX_URL: &str = + "https://github.com/richzhang/PerceptualSimilarity/raw/master/lpips/weights/v0.1/alex.pth"; +const LPIPS_SQUEEZE_URL: &str = + "https://github.com/richzhang/PerceptualSimilarity/raw/master/lpips/weights/v0.1/squeeze.pth"; + +/// URLs for ImageNet pretrained backbone weights from PyTorch. +const VGG16_IMAGENET_URL: &str = "https://download.pytorch.org/models/vgg16-397923af.pth"; +const ALEXNET_IMAGENET_URL: &str = "https://download.pytorch.org/models/alexnet-owt-4df8aa71.pth"; +const SQUEEZENET_IMAGENET_URL: &str = + "https://download.pytorch.org/models/squeezenet1_1-f364aa15.pth"; + +/// Get the download URL for LPIPS linear layer weights. +pub fn get_lpips_weights_url(net: LpipsNet) -> &'static str { + match net { + LpipsNet::Vgg => LPIPS_VGG_URL, + LpipsNet::Alex => LPIPS_ALEX_URL, + LpipsNet::Squeeze => LPIPS_SQUEEZE_URL, + } +} + +/// Get the download URL for backbone ImageNet weights. +pub fn get_backbone_weights_url(net: LpipsNet) -> &'static str { + match net { + LpipsNet::Vgg => VGG16_IMAGENET_URL, + LpipsNet::Alex => ALEXNET_IMAGENET_URL, + LpipsNet::Squeeze => SQUEEZENET_IMAGENET_URL, + } +} + +/// Get the cache directory for LPIPS weights. +fn get_cache_dir() -> PathBuf { + let cache_dir = dirs::cache_dir() + .expect("Could not get cache directory") + .join("burn-dataset") + .join("lpips"); + + if !cache_dir.exists() { + create_dir_all(&cache_dir).expect("Failed to create cache directory"); + } + + cache_dir +} + +/// Download file if not cached and return the cache path. +fn download_if_needed(url: &str, cache_path: &PathBuf, message: &str) { + if !cache_path.exists() { + let bytes = download_file_as_bytes(url, message); + let mut file = File::create(cache_path).expect("Failed to create cache file"); + file.write_all(&bytes).expect("Failed to write weights"); + } +} + +/// Download and load pretrained weights into an LPIPS module. +/// +/// This loads both: +/// 1. ImageNet pretrained backbone weights (VGG16/AlexNet/SqueezeNet) +/// 2. LPIPS trained linear layer weights +/// +/// Weights are cached in the user's cache directory to avoid re-downloading. +/// +/// # Arguments +/// +/// * `lpips` - The LPIPS module to load weights into. +/// * `net` - The network type (determines which weights to download). +/// +/// # Returns +/// +/// The LPIPS module with loaded pretrained weights. +pub fn load_pretrained_weights(mut lpips: Lpips, net: LpipsNet) -> Lpips { + let cache_dir = get_cache_dir(); + + // Step 1: Load backbone ImageNet weights + let backbone_url = get_backbone_weights_url(net); + let backbone_cache_path = cache_dir.join(format!("{:?}_backbone.pth", net).to_lowercase()); + let backbone_message = match net { + LpipsNet::Vgg => "Downloading VGG16 ImageNet weights...", + LpipsNet::Alex => "Downloading AlexNet ImageNet weights...", + LpipsNet::Squeeze => "Downloading SqueezeNet ImageNet weights...", + }; + download_if_needed(backbone_url, &backbone_cache_path, backbone_message); + + // Step 2: Load LPIPS linear layer weights + let lpips_url = get_lpips_weights_url(net); + let lpips_cache_path = cache_dir.join(format!("{:?}_lpips.pth", net).to_lowercase()); + let lpips_message = match net { + LpipsNet::Vgg => "Downloading LPIPS VGG weights...", + LpipsNet::Alex => "Downloading LPIPS AlexNet weights...", + LpipsNet::Squeeze => "Downloading LPIPS SqueezeNet weights...", + }; + download_if_needed(lpips_url, &lpips_cache_path, lpips_message); + + // Load backbone weights first + lpips = load_backbone_weights(lpips, &backbone_cache_path); + + // Then load LPIPS linear layer weights + lpips = load_lpips_weights(lpips, &lpips_cache_path); + + lpips +} + +/// Load ImageNet pretrained backbone weights. +fn load_backbone_weights(lpips: Lpips, cache_path: &PathBuf) -> Lpips { + // Load directly into the inner struct to avoid enum variant issues + match lpips { + Lpips::Vgg(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + // VGG16 features.X -> extractor.convY_Z + .with_key_remapping(r"^features\.0\.", "extractor.conv1_1.") + .with_key_remapping(r"^features\.2\.", "extractor.conv1_2.") + .with_key_remapping(r"^features\.5\.", "extractor.conv2_1.") + .with_key_remapping(r"^features\.7\.", "extractor.conv2_2.") + .with_key_remapping(r"^features\.10\.", "extractor.conv3_1.") + .with_key_remapping(r"^features\.12\.", "extractor.conv3_2.") + .with_key_remapping(r"^features\.14\.", "extractor.conv3_3.") + .with_key_remapping(r"^features\.17\.", "extractor.conv4_1.") + .with_key_remapping(r"^features\.19\.", "extractor.conv4_2.") + .with_key_remapping(r"^features\.21\.", "extractor.conv4_3.") + .with_key_remapping(r"^features\.24\.", "extractor.conv5_1.") + .with_key_remapping(r"^features\.26\.", "extractor.conv5_2.") + .with_key_remapping(r"^features\.28\.", "extractor.conv5_3."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!("Some VGG backbone weights could not be loaded: {:?}", e); + } + Lpips::Vgg(inner) + } + Lpips::Alex(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + // AlexNet features.X -> extractor.convY + .with_key_remapping(r"^features\.0\.", "extractor.conv1.") + .with_key_remapping(r"^features\.3\.", "extractor.conv2.") + .with_key_remapping(r"^features\.6\.", "extractor.conv3.") + .with_key_remapping(r"^features\.8\.", "extractor.conv4.") + .with_key_remapping(r"^features\.10\.", "extractor.conv5."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!("Some AlexNet backbone weights could not be loaded: {:?}", e); + } + Lpips::Alex(inner) + } + Lpips::Squeeze(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + // SqueezeNet features.X -> extractor.* + .with_key_remapping(r"^features\.0\.", "extractor.conv1.") + .with_key_remapping(r"^features\.3\.", "extractor.fire1.") + .with_key_remapping(r"^features\.4\.", "extractor.fire2.") + .with_key_remapping(r"^features\.6\.", "extractor.fire3.") + .with_key_remapping(r"^features\.7\.", "extractor.fire4.") + .with_key_remapping(r"^features\.9\.", "extractor.fire5.") + .with_key_remapping(r"^features\.10\.", "extractor.fire6.") + .with_key_remapping(r"^features\.11\.", "extractor.fire7.") + .with_key_remapping(r"^features\.12\.", "extractor.fire8."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!( + "Some SqueezeNet backbone weights could not be loaded: {:?}", + e + ); + } + Lpips::Squeeze(inner) + } + } +} + +/// Load LPIPS trained linear layer weights. +fn load_lpips_weights(lpips: Lpips, cache_path: &PathBuf) -> Lpips { + // Load directly into the inner struct to avoid enum variant issues + match lpips { + Lpips::Vgg(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + .with_key_remapping(r"^lin0\.model\.1\.", "lin0.") + .with_key_remapping(r"^lin1\.model\.1\.", "lin1.") + .with_key_remapping(r"^lin2\.model\.1\.", "lin2.") + .with_key_remapping(r"^lin3\.model\.1\.", "lin3.") + .with_key_remapping(r"^lin4\.model\.1\.", "lin4."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!("Some VGG LPIPS weights could not be loaded: {:?}", e); + } + Lpips::Vgg(inner) + } + Lpips::Alex(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + .with_key_remapping(r"^lin0\.model\.1\.", "lin0.") + .with_key_remapping(r"^lin1\.model\.1\.", "lin1.") + .with_key_remapping(r"^lin2\.model\.1\.", "lin2.") + .with_key_remapping(r"^lin3\.model\.1\.", "lin3.") + .with_key_remapping(r"^lin4\.model\.1\.", "lin4."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!("Some AlexNet LPIPS weights could not be loaded: {:?}", e); + } + Lpips::Alex(inner) + } + Lpips::Squeeze(mut inner) => { + let mut store = PytorchStore::from_file(cache_path) + .allow_partial(true) + .with_key_remapping(r"^lin0\.model\.1\.", "lin0.") + .with_key_remapping(r"^lin1\.model\.1\.", "lin1.") + .with_key_remapping(r"^lin2\.model\.1\.", "lin2.") + .with_key_remapping(r"^lin3\.model\.1\.", "lin3.") + .with_key_remapping(r"^lin4\.model\.1\.", "lin4.") + .with_key_remapping(r"^lin5\.model\.1\.", "lin5.") + .with_key_remapping(r"^lin6\.model\.1\.", "lin6."); + if let Err(e) = inner.load_from(&mut store) { + log::warn!("Some SqueezeNet LPIPS weights could not be loaded: {:?}", e); + } + Lpips::Squeeze(inner) + } + } +} diff --git a/crates/burn-train/src/metric/vision/mod.rs b/crates/burn-train/src/metric/vision/mod.rs new file mode 100644 index 0000000..9fbd425 --- /dev/null +++ b/crates/burn-train/src/metric/vision/mod.rs @@ -0,0 +1,17 @@ +mod afine; +mod dice; +mod dists; +mod fid; +mod lpips; +mod ms_ssim; +mod psnr; +mod ssim; + +pub use afine::*; +pub use dice::*; +pub use dists::*; +pub use fid::*; +pub use lpips::*; +pub use ms_ssim::*; +pub use psnr::*; +pub use ssim::*; diff --git a/crates/burn-train/src/metric/vision/ms_ssim.rs b/crates/burn-train/src/metric/vision/ms_ssim.rs new file mode 100644 index 0000000..518503f --- /dev/null +++ b/crates/burn-train/src/metric/vision/ms_ssim.rs @@ -0,0 +1,863 @@ +use crate::metric::{ + Metric, MetricAttributes, MetricMetadata, MetricName, Numeric, NumericAttributes, NumericEntry, + SerializedEntry, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::{ + prelude::{Device, Int, Tensor}, + tensor::{ + ElementConversion, + module::{avg_pool2d, conv2d}, + ops::{ConvOptions, PadMode}, + }, +}; + +/// Input type for the [MsSsimMetric]. +/// +/// Both tensors must have shape `[N, C, H, W]`: +/// - `N`: Batch size +/// - `C`: Number of channels (1 for grayscale, 3 for RGB, etc.) +/// - `H`: Height +/// - `W`: Width +/// +/// # Important +/// The image dimensions must be sufficiently large to accommodate the multi-scale +/// computation. Specifically, for the default 5 scales used by Burn, the image dimensions +/// should be at least `kernel_size * 2^(scales-1)` (e.g., 11 × 2^4 = 11 * 16 = 176 for default kernel size). +/// If your images are smaller, reduce the kernel size or number of scales. +/// +/// # Example +/// ```rust,ignore +/// // Create input for RGB images +/// let outputs: Tensor<4> = /* tensor */; +/// let targets: Tensor<4> = /* tensor */; +/// let input = MsSsimInput::new(outputs, targets); +/// ``` +pub struct MsSsimInput { + /// Model outputs with shape [N, C, H, W]. + outputs: Tensor<4>, + /// Ground truth targets with shape [N, C, H, W]. + targets: Tensor<4>, +} + +impl MsSsimInput { + /// Creates a new MsSsimInput with the given outputs and targets. + /// + /// # Arguments + /// - `outputs`: The model output images with shape [N, C, H, W]. + /// - `targets`: The ground truth images with shape [N, C, H, W]. + /// + /// # Returns + /// A new instance of `MsSsimInput`. + /// + /// # Panics + /// - If `outputs` and `targets` do not have the same shape. + pub fn new(outputs: Tensor<4>, targets: Tensor<4>) -> Self { + assert!( + outputs.dims() == targets.dims(), + "Shape mismatch: outputs {:?} targets {:?}", + outputs.dims(), + targets.dims() + ); + Self { outputs, targets } + } +} + +/// Configuration for the [MsSsimMetric]. +#[derive(Debug, Clone)] +pub struct MsSsimMetricConfig { + /// A parameter of SSIM used to stabilize the luminance comparison. + /// Default is 0.01. + pub k1: f32, + /// A parameter of SSIM used to stabilize the contrast comparison. + /// Default is 0.03. + pub k2: f32, + /// The range of the pixel values in images which can be computed as following: + /// `let pixel_range = max_pixel_val - min_pixel_val;` + /// where `max_pixel_val` is the maximum possible pixel value and `min_pixel_val` + /// is the minimum possible pixel value. + /// + /// - For normalized images in range [0, 1], it should be set to `1.0 - 0.0 = 1.0` + /// - For normalized images in range [-1, 1], it should be set to `1.0 - (-1.0) = 2.0` + /// - For 8-bit images in range [0, 255], it should be set to `255.0 - 0.0 = 255.0` + pub pixel_range: f32, + /// The MS-SSIM metric involves applying convolution to the input tensors using a Gaussian kernel. + /// This is the kernel size of the Gaussian kernel. Default is 11. + pub kernel_size: usize, + /// The MS-SSIM metric involves applying convolution to the input tensors using a Gaussian kernel. + /// This is the standard deviation of the Gaussian kernel. Default is 1.5. + pub sigma: f32, + /// The number of channels in the input images (e.g., 1 for grayscale, 3 for RGB). + /// This is used to create the appropriate convolution kernels. Default is 3. + pub channels: usize, + /// The weights/betas for each scale in the MS-SSIM computation. + /// The length of this vector determines the number of scales. + /// Default is \[0.0448, 0.2856, 0.3001, 0.2363, 0.1333\] (5 scales). + pub betas: Vec, +} + +impl MsSsimMetricConfig { + /// Creates a configuration with the specified data range and default parameters. + /// + /// # Default parameters + /// - k1: 0.01 + /// - k2: 0.03 + /// - kernel_size: 11 + /// - sigma: 1.5 + /// - channels: 3 + /// + /// # Panics + /// - If `pixel_range` is not positive. + /// + /// # Example + /// ```rust,ignore + /// // For normalized RGB images [0, 1] + /// let config1 = MsSsimMetricConfig::new(1.0); + /// + /// // For 8-bit images [0, 255] + /// let config2 = MsSsimMetricConfig::new(255.0); + /// + /// // For grayscale with custom kernel + /// let config3 = MsSsimMetricConfig::new(1.0) + /// .with_channels(1) + /// .with_kernel_size(7); + /// ``` + pub fn new(pixel_range: f32) -> Self { + assert!(pixel_range > 0.0, "pixel_range must be positive"); + Self { + k1: 0.01, + k2: 0.03, + pixel_range, + kernel_size: 11, + sigma: 1.5, + channels: 3, + betas: vec![0.0448, 0.2856, 0.3001, 0.2363, 0.1333], + } + } + + /// Sets custom values for the k1 and k2 parameters of MS-SSIM which are + /// used for numerical stability. + /// + /// # Default values + /// - k1: 0.01 + /// - k2: 0.03 + /// + /// # Panics + /// - If `k1` or `k2` is not positive. + pub fn with_k1_k2(mut self, k1: f32, k2: f32) -> Self { + assert!(k1 > 0.0, "k1 must be positive"); + assert!(k2 > 0.0, "k2 must be positive"); + self.k1 = k1; + self.k2 = k2; + self + } + + /// Sets a custom kernel size for the Gaussian kernel used in MS-SSIM. The + /// kernel size must be a positive odd number. + /// + /// # Default value + /// - kernel_size: 11 + /// + /// # Panics + /// - If `kernel_size` is not a positive odd number. + pub fn with_kernel_size(mut self, kernel_size: usize) -> Self { + assert!( + kernel_size > 0 && kernel_size % 2 == 1, + "kernel_size must be positive and an odd number" + ); + self.kernel_size = kernel_size; + self + } + + /// Sets a custom sigma (standard deviation) for the Gaussian kernel used in MS-SSIM. + /// + /// # Default value + /// - sigma: 1.5 + /// + /// # Panics + /// - If `sigma` is not positive. + pub fn with_sigma(mut self, sigma: f32) -> Self { + assert!(sigma > 0.0, "sigma must be a positive number"); + self.sigma = sigma; + self + } + + /// Sets the number of channels for the input images. + /// + /// This affects the shape of the pre-computed convolution kernels. + /// Change this if working with grayscale (1) or multispectral images (>3). + /// + /// # Default value + /// - channels: 3 + /// + /// # Panics + /// - If `channels` is 0. + pub fn with_channels(mut self, channels: usize) -> Self { + assert!(channels > 0, "channels must be a positive number"); + self.channels = channels; + self + } + + /// Sets custom betas for the scales. The length of the betas vector + /// determines the number of scales used in the MS-SSIM computation. + /// If you want to make different parameter settings comparable, the betas + /// vector should sum to 1 as per the original paper. However, note + /// that this is not a strict requirement. + /// + /// # Default value + /// - betas: `[0.0448, 0.2856, 0.3001, 0.2363, 0.1333]` (5 scales) + /// + /// # Panics + /// - If `betas` is empty. + /// - If not all values in `betas` are positive. + pub fn with_betas(mut self, betas: Vec) -> Self { + assert!(!betas.is_empty(), "betas vector cannot be empty"); + + assert!( + betas.iter().all(|&b| b >= 0.0), + "All beta values must be non-negative" + ); + + self.betas = betas; + self + } +} + +/// Multi-Scale Structural Similarity Index (MS-SSIM) metric for image quality assessment. +/// +/// MS-SSIM extends the single-scale [SSIM](crate::metric::vision::SsimMetric) by computing +/// the index at multiple resolutions (scales) and combining them using weighted averaging. +/// This approach better correlates with human visual perception, especially for +/// high-resolution images where fine details and texture variations are important. +/// +/// # Algorithm Overview +/// +/// MS-SSIM computes structural similarity across M scales (M=5 in Burn): +/// +/// 1. **Contrast** and **Structure** components are computed at every scale +/// 2. **Luminance** is computed only at the coarsest (last) scale +/// 3. Between scales, images are downsampled by a factor of 2 using average pooling +/// +/// The final metric is computed as: +/// ```text +/// MS-SSIM = L_M^{α_M} × ∏_{j=1}^M (C_j^{β_j} × S_j^{γ_j}) +/// ``` +/// +/// Where: +/// - `L_M` is luminance at the last scale (M) +/// - `C_j` is contrast at scale j: `(2σ_xσ_y + C2) / (σ_x² + σ_y² + C2)` +/// - `S_j` is structure at scale j: `(σ_xy + C3) / (σ_xσ_y + C3)` +/// - `α_M, β_j, γ_j` are weights from Wang et al. (\[0.0448, 0.2856, 0.3001, 0.2363, 0.1333\]) +/// +/// # Notes +/// +/// - This implementation uses separable Gaussian convolution for efficiency (reduces complexity from O(K^2) to O(2K) per pixel) +/// - Gaussian kernels are pre-computed during initialization to avoid redundant computation +/// - The metric requires images to be large enough to survive the downsampling operations +/// +/// # Value Range +/// +/// MS-SSIM values typically range from 0 to 1, where: +/// - 1.0 indicates perfect structural similarity (identical images) +/// - 0.0 indicates no structural similarity +/// - Values are usually positive due to the stability constants (C1, C2, C3) +/// +/// # References +/// +/// [Multi-scale Structural Similarity for Image Quality Assessment](https://www.cns.nyu.edu/pub/eero/wang03b.pdf) +#[derive(Clone)] +pub struct MsSsimMetric { + name: MetricName, + /// Internal state for numeric metric aggregation. + state: NumericMetricState, + /// Configuration for the metric. + config: MsSsimMetricConfig, + /// Pre-computed horizontal Gaussian kernel with shape [C, 1, 1, K] + horizontal_kernel: Tensor<4>, + /// Pre-computed vertical Gaussian kernel with shape [C, 1, K, 1] + vertical_kernel: Tensor<4>, +} + +impl MsSsimMetric { + /// Creates a new MS-SSIM metric with the given configuration. + /// + /// # Arguments + /// - `config`: Configuration for the metric (data range, kernel size, etc.) + /// - `device`: Device to place the Gaussian kernels on + /// + /// # Note + /// The default metric name format is "MS-SSIM (pr={}, k={}, σ={})" + /// where pr is the pixel range, k is the kernel size, and σ is the + /// standard deviation. + /// + /// # Example + /// ```ignore + /// let config = MsSsimMetricConfig::new(1.0).with_channels(1); // Grayscale + /// let metric = MsSsimMetric::new(config, &device); + /// ``` + pub fn new(config: MsSsimMetricConfig, device: &Device) -> Self { + let kernel = Self::create_1d_gaussian_kernel(&config, device); + let size = config.kernel_size; + + // Create horizontal kernel: shape [C, 1, 1, K] for depthwise conv + let horizontal_kernel = kernel + .clone() + .reshape([1, 1, 1, size]) + .repeat_dim(0, config.channels); + + // Create vertical kernel: shape [C, 1, K, 1] for depthwise conv + let vertical_kernel = kernel + .reshape([1, 1, size, 1]) + .repeat_dim(0, config.channels); + + Self { + name: MetricName::new(format!( + "MS-SSIM (pr={}, k={}, σ={})", + config.pixel_range, config.kernel_size, config.sigma + )), + state: NumericMetricState::default(), + config, + horizontal_kernel, + vertical_kernel, + } + } + + /// Overrides the default metric name. + /// + /// # Example + /// ```ignore + /// let metric = MsSsimMetric::new(config, &device) + /// .with_name("Custom MS-SSIM Name"); + /// ``` + pub fn with_name(mut self, name: &str) -> Self { + self.name = MetricName::new(name.to_string()); + self + } + + /// Creates a normalized 1D Gaussian kernel as a tensor where the kernel values sum to 1.0. + fn create_1d_gaussian_kernel(config: &MsSsimMetricConfig, device: &Device) -> Tensor<1> { + let size = config.kernel_size as i64; + let sigma = config.sigma; + let center = (size / 2) as f32; + + let one_to_size_tensor = Tensor::<1, Int>::arange(0..size, device).float(); + let x_vals = one_to_size_tensor.sub_scalar(center); + + // Gaussian: exp(-x² / 2σ²) + let x_squared = x_vals.clone().mul(x_vals); + let x_squared_div_2_sigma_squared = x_squared.div_scalar(2.0 * sigma * sigma); + let unnormalized_kernel = x_squared_div_2_sigma_squared.neg().exp(); + let kernel_vals_sum = unnormalized_kernel.clone().sum(); + unnormalized_kernel.div(kernel_vals_sum) + } + + /// Applies separable Gaussian convolution using pre-computed kernels. + /// + /// Performs two 1D convolutions (horizontal then vertical) which is + /// computationally cheaper than a single 2D convolution. + /// + /// # Arguments + /// - `input`: Tensor of shape [N, C, H, W] + fn gaussian_separable_conv(&self, input: Tensor<4>) -> Tensor<4> { + let padding = self.config.kernel_size / 2; + let h_kernel = self.horizontal_kernel.clone(); + let v_kernel = self.vertical_kernel.clone(); + + // Apply reflect padding to all 4 sides of the input tensor before convolution + // Format: (left, right, top, bottom) + let padded_input = input.pad((padding, padding, padding, padding), PadMode::Reflect); + + let h_conv_options = ConvOptions::new([1, 1], [0, 0], [1, 1], self.config.channels); + let v_conv_options = ConvOptions::new([1, 1], [0, 0], [1, 1], self.config.channels); + + let input_after_h_conv = conv2d(padded_input, h_kernel, None, h_conv_options); + conv2d(input_after_h_conv, v_kernel, None, v_conv_options) + } +} + +impl Metric for MsSsimMetric { + type Input = MsSsimInput; + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn update(&mut self, item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let dims = item.outputs.dims(); + let scales = self.config.betas.len(); + + assert_eq!( + dims[1], self.config.channels, + "Input has {} channels but metric was configured for {}", + dims[1], self.config.channels + ); + + // Verify minimum size for the given number of scales + // After (scales - 1) downsamples, size is original / 2^(scales-1) + // We need kernel_size at that scale + let downsample_ops_num = scales.saturating_sub(1) as u32; + let min_size = self.config.kernel_size * (2usize.pow(downsample_ops_num)); + let h = dims[2]; + let w = dims[3]; + assert!( + h >= min_size && w >= min_size, + "Image dimensions (H={}, W={}) must be at least {} to support {} scales of MS-SSIM \ + with kernel_size={}. Either increase image size, reduce kernel_size, or reduce the number of scales (betas).", + h, + w, + min_size, + scales, + self.config.kernel_size + ); + + let mut x = item.outputs.clone(); + let mut y = item.targets.clone(); + let betas = &self.config.betas; + + // Compute c1 = (k1 * L)^2 and c2 = (k2 * L)^2, c3 = c2/2 + let c1 = (self.config.k1 * self.config.pixel_range).powi(2); + let c2 = (self.config.k2 * self.config.pixel_range).powi(2); + + // Initialize accumulator to 1 for update via multiplication + // Shape: [N, C] + let batch_size = dims[0]; + let channels = dims[1]; + let mut ms_ssim_tensor = Tensor::<2>::ones([batch_size, channels], &item.outputs.device()); + + for (j, beta_j) in betas.iter().enumerate() { + // Compute mu_x and mu_y + let mu_x = self.gaussian_separable_conv(x.clone()); + let mu_y = self.gaussian_separable_conv(y.clone()); + let square_of_mu_x = mu_x.clone() * mu_x.clone(); + let square_of_mu_y = mu_y.clone() * mu_y.clone(); + + // Var(X) = E(X^2) - E(X)^2 + let mu_of_x_squared = self.gaussian_separable_conv(x.clone() * x.clone()); + let mu_of_y_squared = self.gaussian_separable_conv(y.clone() * y.clone()); + let var_x = (mu_of_x_squared - square_of_mu_x.clone()).clamp_min(0.0); + let var_y = (mu_of_y_squared - square_of_mu_y.clone()).clamp_min(0.0); + + // Cov(X, Y) = E(XY) - E(X)E(Y) + let mu_of_xy = self.gaussian_separable_conv(x.clone() * y.clone()); + let cov_xy = mu_of_xy - (mu_x.clone() * mu_y.clone()); + + // Compute cs_map = (2σxy + C2) / (σx² + σy² + C2) + // This is mathematically equivalent to c(x,y) * s(x,y) when C3 = C2 / 2 + let contrast_structure = (cov_xy * 2.0 + c2) / (var_x + var_y + c2); + + // Include luminance at the last scale + if j == betas.len() - 1 { + // Compute l(x, y) = (2μxμy + C1) / (μx² + μy² + C1) + let luminance: Tensor<4> = + (2 * mu_x * mu_y + c1) / (square_of_mu_x + square_of_mu_y + c1); + let ssim = luminance * contrast_structure; + let ssim_spatial_mean = ssim.mean_dims(&[2, 3]).reshape([batch_size, channels]); + // Clamp to avoid negative values before raising to power (prevents NaNs) + let ssim_mean_clamped = ssim_spatial_mean.clamp_min(0.0); + ms_ssim_tensor = ms_ssim_tensor * ssim_mean_clamped.powf_scalar(*beta_j); + } else { + let contrast_structure_spatial_mean = contrast_structure + .mean_dims(&[2, 3]) + .reshape([batch_size, channels]); + // Clamp to avoid negative values before raising to power (prevents NaNs) + let c_s_mean_clamped = contrast_structure_spatial_mean.clamp_min(0.0); + ms_ssim_tensor = ms_ssim_tensor * c_s_mean_clamped.powf_scalar(*beta_j); + + x = avg_pool2d(x, [2, 2], [2, 2], [0, 0], false, false); + y = avg_pool2d(y, [2, 2], [2, 2], [0, 0], false, false); + } + } + + let ms_ssim_per_image = ms_ssim_tensor.mean_dim(1); + let avg_ms_ssim = ms_ssim_per_image.mean().into_scalar::(); + + self.state.update( + avg_ms_ssim, + batch_size, + FormatOptions::new(self.name()).precision(4), + ) + } + + /// Clears the metric state. + fn clear(&mut self) { + self.state.reset(); + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for MsSsimMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::Numeric; + use burn_core::tensor::Distribution; + + fn test_config() -> MsSsimMetricConfig { + // Use small kernel and single channel for testing + // With kernel_size=3, we need images >= 3*16=48 + MsSsimMetricConfig::new(1.0) + .with_kernel_size(3) + .with_sigma(1.0) + .with_channels(1) + } + + #[test] + fn test_ms_ssim_perfect_similarity() { + // Identical images should give MS-SSIM = 1.0 + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + [[[ + [0.5_f32; 64]; 64 // 64x64 constant image + ]]], + &device, + ); + let targets = outputs.clone(); + + let mut metric = MsSsimMetric::new(test_config(), &device); + let input = MsSsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ms_ssim = metric.value().current(); + assert!( + ms_ssim > 0.99, + "MS-SSIM for identical images should be 1.0, got {}", + ms_ssim + ); + } + + #[test] + fn test_ms_ssim_completely_different() { + // Black vs white images should give very low MS-SSIM (close to 0.0) + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 256, 256], &device); + let targets = Tensor::<4>::ones([1, 1, 256, 256], &device); + + let mut metric = MsSsimMetric::new(test_config(), &device); + let input = MsSsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ms_ssim = metric.value().current(); + assert!( + (ms_ssim - 0.3).abs() < 0.01, + "MS-SSIM for black vs white should be low (around 0.3), got {}", + ms_ssim + ); + } + + #[test] + fn test_ms_ssim_similar_images() { + // Small perturbation should give high MS-SSIM (close to 1.0) + let device = Default::default(); + let outputs = Tensor::<4>::full([1, 1, 64, 64], 0.5, &device); + let targets = Tensor::<4>::full([1, 1, 64, 64], 0.52, &device); + + let mut metric = MsSsimMetric::new(test_config(), &device); + let input = MsSsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ms_ssim = metric.value().current(); + assert!( + ms_ssim > 0.95, + "MS-SSIM for very similar images should be close to 1.0, got {}", + ms_ssim + ); + } + + #[test] + fn test_ms_ssim_batch_averaging() { + let device = Default::default(); + // Batch of 2: one identical, one different + let outputs = Tensor::<4>::from_data( + [ + [[[0.5_f32; 64]; 64]], // Image 1: constant 0.5 + [[[0.0_f32; 64]; 64]], // Image 2: constant 0.0 (black) + ], + &device, + ); + let targets = Tensor::<4>::from_data( + [ + [[[0.5_f32; 64]; 64]], // Image 1: identical + [[[1.0_f32; 64]; 64]], // Image 2: white (opposite) + ], + &device, + ); + + let mut metric = MsSsimMetric::new(test_config(), &device); + let input = MsSsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ms_ssim = metric.value().current(); + // Average of ~1.0 and ~0.292 should be around 0.64 + assert!( + (ms_ssim - 0.64).abs() < 0.02, + "Average MS-SSIM should be around 0.64, got {}", + ms_ssim + ); + } + + #[test] + fn test_ms_ssim_multichannel() { + let device = Default::default(); + // Test with 3 channels (RGB) + let config = MsSsimMetricConfig::new(1.0) + .with_kernel_size(3) + .with_sigma(1.0) + .with_channels(3); + + let outputs = Tensor::<4>::random([2, 3, 64, 64], Distribution::Uniform(0.0, 1.0), &device); + let targets = outputs.clone(); + + let mut metric = MsSsimMetric::new(config, &device); + let input = MsSsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ms_ssim = metric.value().current(); + assert!( + ms_ssim > 0.99, + "MS-SSIM for identical RGB images should be 1.0, got {}", + ms_ssim + ); + } + + #[test] + fn test_ms_ssim_running_average() { + let device = Default::default(); + let mut metric = MsSsimMetric::new(test_config(), &device); + + // First update: identical (1.0) + let img1 = Tensor::<4>::full([1, 1, 64, 64], 0.5, &device); + let input1 = MsSsimInput::new(img1.clone(), img1); + metric.update(&input1, &MetricMetadata::fake()); + + assert!( + metric.value().current() > 0.99, + "First update should be approximately 1.0" + ); + + // Second update: different (~0.29) + let black = Tensor::<4>::zeros([1, 1, 64, 64], &device); + let white = Tensor::<4>::ones([1, 1, 64, 64], &device); + let input2 = MsSsimInput::new(black, white); + metric.update(&input2, &MetricMetadata::fake()); + + let running = metric.running_value().current(); + assert!( + (running - 0.64).abs() < 0.02, + "Running average should be approximately 0.64, got {}", + running + ); + } + + #[test] + fn test_ms_ssim_single_scale_small_image() { + let device = Default::default(); + // Default 5 scales with kernel_size=11 requires a 176x176 image. + // With a single scale, the minimum required size drops to + // just 11x11 (kernel_size * 2^0). + let config = MsSsimMetricConfig::new(1.0) + .with_channels(1) + .with_betas(vec![1.0]); // 1 scale + + let mut metric = MsSsimMetric::new(config, &device); + + // Create a 16x16 image. This would normally panic with 5 scales, + // but should succeed with 1 scale. + let outputs = Tensor::<4>::zeros([1, 1, 16, 16], &device); + let targets = outputs.clone(); + let input = MsSsimInput::new(outputs, targets); + + // This should not panic + let _ = metric.update(&input, &MetricMetadata::fake()); + + // Identical images should still yield ~1.0 + let ms_ssim = metric.value().current(); + assert!( + ms_ssim > 0.99, + "1-scale MS-SSIM for identical images should be 1.0, got {}", + ms_ssim + ); + } + + #[test] + fn test_ssim_symmetry() { + // MS-SSIM(x, y) should equal MS-SSIM(y, x) + // Symmetry is one of the mathematical properties of MS-SSIM + let device = Default::default(); + let config = MsSsimMetricConfig::new(1.0) + .with_kernel_size(3) + .with_sigma(1.0) + .with_channels(3); + + let img1 = Tensor::<4>::random([2, 3, 64, 64], Distribution::Uniform(0.0, 1.0), &device); + let img2 = Tensor::<4>::random([2, 3, 64, 64], Distribution::Uniform(0.0, 1.0), &device); + + let mut metric1 = MsSsimMetric::new(config.clone(), &device); + let input1 = MsSsimInput::new(img1.clone(), img2.clone()); + let _entry = metric1.update(&input1, &MetricMetadata::fake()); + let ms_ssim1 = metric1.value().current(); + + let mut metric2 = MsSsimMetric::new(config, &device); + let input2 = MsSsimInput::new(img2, img1); + let _entry = metric2.update(&input2, &MetricMetadata::fake()); + let ms_ssim2 = metric2.value().current(); + + assert!( + (ms_ssim1 - ms_ssim2).abs() < 0.001, + "MS-SSIM should be symmetric: MS-SSIM(x,y)={} vs MS-SSIM(y,x)={}", + ms_ssim1, + ms_ssim2 + ); + } + + #[test] + fn test_ms_ssim_clear() { + let device = Default::default(); + let mut metric = MsSsimMetric::new(test_config(), &device); + + let img = Tensor::<4>::full([1, 1, 64, 64], 0.5, &device); + let input = MsSsimInput::new(img.clone(), img); + metric.update(&input, &MetricMetadata::fake()); + + assert!(metric.value().current() > 0.99); + + metric.clear(); + assert!(metric.running_value().current().is_nan()); + } + + #[test] + fn test_ms_ssim_custom_name() { + let device = Default::default(); + let config = MsSsimMetricConfig::new(1.0); + let metric = MsSsimMetric::new(config, &device).with_name("CustomMS-SSIM"); + assert_eq!(metric.name().to_string(), "CustomMS-SSIM"); + } + + #[test] + fn test_ms_ssim_default_name() { + let device = Default::default(); + let config = MsSsimMetricConfig::new(255.0); + let metric = MsSsimMetric::new(config, &device); + assert_eq!(metric.name().to_string(), "MS-SSIM (pr=255, k=11, σ=1.5)"); + } + + #[test] + fn test_ms_ssim_attributes() { + let device = Default::default(); + let config = MsSsimMetricConfig::new(1.0); + let metric = MsSsimMetric::new(config, &device); + + match metric.attributes() { + MetricAttributes::Numeric(attrs) => { + assert!(attrs.higher_is_better); + assert_eq!(attrs.unit, None); + } + _ => panic!("Expected numeric attributes"), + } + } + + #[test] + #[should_panic(expected = "Shape mismatch")] + fn test_ms_ssim_shape_mismatch() { + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 64, 64], &device); + let targets = Tensor::<4>::zeros([1, 1, 32, 32], &device); + let _ = MsSsimInput::new(outputs, targets); + } + + #[test] + #[should_panic(expected = "k1 must be positive")] + fn test_ms_ssim_negative_k1() { + let _ = MsSsimMetricConfig::new(1.0).with_k1_k2(-0.01, 0.03); + } + + #[test] + #[should_panic(expected = "k2 must be positive")] + fn test_ms_ssim_negative_k2() { + let _ = MsSsimMetricConfig::new(1.0).with_k1_k2(0.01, -0.03); + } + + #[test] + #[should_panic(expected = "pixel_range must be positive")] + fn test_ms_ssim_negative_data_range() { + let _ = MsSsimMetricConfig::new(-1.0); + } + + #[test] + #[should_panic(expected = "pixel_range must be positive")] + fn test_ms_ssim_zero_data_range() { + let _ = MsSsimMetricConfig::new(0.0); + } + + #[test] + #[should_panic(expected = "kernel_size must be positive and an odd number")] + fn test_ms_ssim_even_kernel_size() { + let _ = MsSsimMetricConfig::new(1.0).with_kernel_size(10); + } + + #[test] + #[should_panic(expected = "kernel_size must be positive and an odd number")] + fn test_ms_ssim_zero_kernel_size() { + let _ = MsSsimMetricConfig::new(1.0).with_kernel_size(0); + } + + #[test] + #[should_panic(expected = "sigma must be a positive number")] + fn test_ms_ssim_negative_sigma() { + let _ = MsSsimMetricConfig::new(1.0).with_sigma(-1.5); + } + + #[test] + #[should_panic(expected = "sigma must be a positive number")] + fn test_ms_ssim_zero_sigma() { + let _ = MsSsimMetricConfig::new(1.0).with_sigma(0.0); + } + + #[test] + #[should_panic(expected = "channels must be a positive number")] + fn test_ms_ssim_zero_channels() { + let _ = MsSsimMetricConfig::new(1.0).with_channels(0); + } + + #[test] + #[should_panic(expected = "betas vector cannot be empty")] + fn test_ms_ssim_empty_betas() { + let _ = MsSsimMetricConfig::new(1.0).with_betas(vec![]); + } + + #[test] + #[should_panic(expected = "All beta values must be non-negative")] + fn test_ms_ssim_negative_betas() { + let _ = MsSsimMetricConfig::new(1.0).with_betas(vec![0.3, 0.3, -0.1, 0.5]); + } + + #[test] + #[should_panic(expected = "Image dimensions")] + fn test_ms_ssim_image_too_small() { + let device = Default::default(); + // 3 scales with kernel_size=11 requires 44x44 minimum (11 * 2^2) + let config = MsSsimMetricConfig::new(1.0).with_betas(vec![0.5, 0.3, 0.2]); + let mut metric = MsSsimMetric::new(config, &device); + + let outputs = Tensor::<4>::zeros([1, 3, 32, 32], &device); // Too small (32 < 44) + let targets = outputs.clone(); + let input = MsSsimInput::new(outputs, targets); + let _ = metric.update(&input, &MetricMetadata::fake()); + } +} diff --git a/crates/burn-train/src/metric/vision/psnr.rs b/crates/burn-train/src/metric/vision/psnr.rs new file mode 100644 index 0000000..86a4b2e --- /dev/null +++ b/crates/burn-train/src/metric/vision/psnr.rs @@ -0,0 +1,590 @@ +use crate::metric::{ + Metric, MetricAttributes, MetricMetadata, MetricName, Numeric, NumericAttributes, NumericEntry, + SerializedEntry, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::{prelude::Tensor, tensor::ElementConversion}; +use std::f64::consts::LN_10; + +/// Input type for the [PsnrMetric]. +/// +/// Both tensors must have shape `[N, C, H, W]`: +/// - `N`: Batch size +/// - `C`: Number of channels (1 for grayscale, 3 for RGB, etc.) +/// - `H`: Height +/// - `W`: Width +pub struct PsnrInput { + /// Model output (predictions/reconstructions) images with shape `[N, C, H, W]`. + outputs: Tensor<4>, + /// Ground truth images with shape `[N, C, H, W]`. + targets: Tensor<4>, +} + +impl PsnrInput { + /// Creates a new PsnrInput with the given outputs and targets. + /// + /// Inputs are expected to have the dimensions `[N, C, H, W]` + /// where `N` is the batch size, `C` is the number of channels, + /// `H` is the height of the image, and `W` is the width of the image. + /// + /// # Arguments + /// - `outputs`: The model output images with shape `[N, C, H, W]`. + /// - `targets`: The ground truth images with shape `[N, C, H, W]`. + /// + /// # Returns + /// A new instance of `PsnrInput`. + /// + /// # Panics + /// - If `outputs` and `targets` do not have the same shape. + pub fn new(outputs: Tensor<4>, targets: Tensor<4>) -> Self { + assert!( + outputs.dims() == targets.dims(), + "Shape mismatch: outputs {:?}, targets {:?}", + outputs.dims(), + targets.dims() + ); + Self { outputs, targets } + } +} + +/// Configuration for the [PsnrMetric]. +#[derive(Debug, Clone, Copy)] +pub struct PsnrMetricConfig { + /// Maximum possible pixel value. + /// - Use `1.0` for normalized images in range \[0, 1\] + /// - Use `255.0` for 8-bit images in range \[0, 255\] + pub max_pixel_val: f64, + /// Epsilon value for numerical stability when MSE is very small or zero. + /// + /// When MSE falls below this threshold, it is clamped to `epsilon`, + /// resulting in a maximum PSNR of approximately `10 * log10(max_pixel_val² / epsilon)` dB. + /// + /// Default is `1e-10`, which yields ~100 dB for perfect reconstruction with `max_pixel_val = 1.0`. + pub epsilon: f64, +} + +impl PsnrMetricConfig { + /// Creates a configuration with the specified maximum pixel value. + /// + /// # Example + /// ```ignore + /// // Normalized images [0, 1] + /// let config = PsnrMetricConfig::new(1.0); + /// + /// // 8-bit images [0, 255] + /// let config = PsnrMetricConfig::new(255.0); + /// // Also set a custom epsilon value + /// let config = PsnrMetricConfig::new(255.0).with_epsilon(1e-8); + /// ``` + pub fn new(max_pixel_val: f64) -> Self { + assert!(max_pixel_val > 0.0, "max_pixel_val must be positive"); + Self { + max_pixel_val, + epsilon: 1e-10, + } + } + + /// Sets a custom epsilon for numerical stability near zero MSE + pub fn with_epsilon(mut self, epsilon: f64) -> Self { + assert!(epsilon > 0.0, "epsilon must be positive"); + self.epsilon = epsilon; + self + } +} + +/// The peak signal-to-noise ratio (PSNR) metric for image quality assessment. +/// +/// PSNR is commonly used to measure the quality of reconstructed images +/// compared to the original. Higher values (in dB) indicate better quality. +/// +/// # Formula +/// ```text +/// PSNR = 10 * log10(MAX^2 / MSE) +/// ``` +/// where MAX is the maximum possible pixel value and MSE is the mean squared error. +/// +/// # Note +/// - PSNR is computed for each image first, and then it is averaged across all the images in the batch. +/// - For perfect reconstruction (MSE = 0), the MSE is clamped to `epsilon` to avoid division by zero, +/// yielding a maximum PSNR of `10 * log10(MAX^2 / epsilon)` dB. +#[derive(Clone)] +pub struct PsnrMetric { + name: MetricName, + /// Internal state for numeric metric aggregation. + state: NumericMetricState, + /// Configuration for the metric. + config: PsnrMetricConfig, +} + +impl PsnrMetric { + /// Creates a new PSNR metric with the given configuration. + /// + /// # Example + /// ```ignore + /// let config = PsnrMetricConfig::new(1.0); + /// let metric = PsnrMetric::new(config); + /// ``` + pub fn new(config: PsnrMetricConfig) -> Self { + Self { + name: MetricName::new(format!("PSNR@{}", config.max_pixel_val)), + state: NumericMetricState::default(), + config, + } + } + + /// Overrides the default metric name which is `PSNR@{max_pixel_val}`. + /// + /// Examples names: + /// - `PSNR@1.0` + /// - `PSNR@255.0` + /// + /// Use this method to provide a custom name. + pub fn with_name(mut self, name: &str) -> Self { + self.name = MetricName::new(name.to_string()); + self + } +} + +impl Metric for PsnrMetric { + type Input = PsnrInput; + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn update(&mut self, item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let dims = item.outputs.dims(); + let batch_size = dims[0]; + let outputs = item.outputs.clone(); + let targets = item.targets.clone(); + + // Compute per-image MSE by reducing over all dimensions except batch (dims 1, 2, 3) + // Resulting shape: [N, 1, 1, 1] + let diff = outputs.sub(targets); + let mse_per_image = diff.powi_scalar(2).mean_dims(&[1, 2, 3]); + // Flatten to shape: [N] + let mse_flat = mse_per_image.flatten::<1>(0, 3); + // Clamp MSE to avoid division by 0 in the expression (MAX^2 / MSE) + let mse_clamped = mse_flat.clamp_min(self.config.epsilon); + let max_squared = self.config.max_pixel_val * self.config.max_pixel_val; + + // Compute PSNR for each image and accumulate + // PSNR value in dB (using the change of base formula): + // 10 * log10(MAX^2 / MSE) = 10 * ln(MAX^2 / MSE) / ln(10) + // = ln(MAX^2 / MSE) * (10 / ln(10)) + let psnr_per_image = mse_clamped + .recip() + .mul_scalar(max_squared) + .log() + .mul_scalar(10.0 / LN_10); + let avg_psnr = psnr_per_image.mean().into_scalar::(); + + self.state.update( + avg_psnr, + batch_size, + FormatOptions::new(self.name()).unit("dB").precision(2), + ) + } + + /// Clears the metric state. + fn clear(&mut self) { + self.state.reset(); + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("dB".to_string()), + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for PsnrMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metric::Numeric; + use burn_core::tensor::TensorData; + + #[test] + fn test_psnr_perfect_reconstruction() { + // When outputs exactly match targets, PSNR should be very high + // (limited by epsilon clamping to ~100 dB with default epsilon=1e-10) + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([[[[1.0_f32, 0.5], [0.25, 0.75]]]]), + &device, + ); + let targets = outputs.clone(); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + // With epsilon = 1e-10 and max=1.0: + // PSNR = 10 * log10(1.0 / 1e-10) = 100 dB + let psnr = metric.value().current(); + assert!( + psnr >= 99.0, + "PSNR for perfect reconstruction should be ~100 dB, got {} dB", + psnr + ); + } + + #[test] + fn test_psnr_constant_error() { + // Constant error of 0.1 across all pixels + // MSE = 0.01, PSNR = 10 * log10(1.0 / 0.01) = 20 dB + let device = Default::default(); + let outputs = + Tensor::<4>::from_data(TensorData::from([[[[0.1_f32, 0.1], [0.1, 0.1]]]]), &device); + let targets = + Tensor::<4>::from_data(TensorData::from([[[[0.0_f32, 0.0], [0.0, 0.0]]]]), &device); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + assert!( + (psnr - 20.0).abs() < 0.01, + "Expected PSNR ~20 dB, got {} dB", + psnr + ); + } + + #[test] + fn test_psnr_varying_error() { + // Errors: 0.1, 0.2, 0.3, 0.4 → squared: 0.01, 0.04, 0.09, 0.16 + // MSE = 0.075, PSNR = 10 * log10(1.0 / 0.075) ≈ 11.249 dB + let device = Default::default(); + let outputs = + Tensor::<4>::from_data(TensorData::from([[[[0.1_f32, 0.2], [0.3, 0.4]]]]), &device); + let targets = + Tensor::<4>::from_data(TensorData::from([[[[0.0_f32, 0.0], [0.0, 0.0]]]]), &device); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 10.0 * (1.0_f64 / 0.075).log10(); + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{:.3} dB, got {} dB", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_max_pixel_255() { + // Test with 8-bit image range [0, 255] + // Error = 10 everywhere, MSE = 100 + // PSNR = 10 * log10(255^2 / 100) ≈ 28.13 dB + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([[[[10.0_f32, 10.0], [10.0, 10.0]]]]), + &device, + ); + let targets = + Tensor::<4>::from_data(TensorData::from([[[[0.0_f32, 0.0], [0.0, 0.0]]]]), &device); + + let config = PsnrMetricConfig::new(255.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 10.0 * (255.0_f64 * 255.0 / 100.0).log10(); + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{:.3} dB, got {} dB", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_batch_averaging() { + // Batch of 2 images with different MSEs + // Image 1: error 0.1 → MSE = 0.01 → PSNR = 20 dB + // Image 2: error 0.01 → MSE = 0.0001 → PSNR = 40 dB + // Average PSNR = 30 dB + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([ + [[[0.1_f32, 0.1], [0.1, 0.1]]], + [[[0.01_f32, 0.01], [0.01, 0.01]]], + ]), + &device, + ); + let targets = Tensor::<4>::from_data( + TensorData::from([ + [[[0.0_f32, 0.0], [0.0, 0.0]]], + [[[0.0_f32, 0.0], [0.0, 0.0]]], + ]), + &device, + ); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 30.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected average PSNR ~{} dB, got {} dB", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_multichannel() { + // Test with 3 channels (RGB-like) + // All channels have constant error 0.1 → MSE = 0.01 → PSNR = 20 dB + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([[ + [[0.1_f32, 0.1], [0.1, 0.1]], + [[0.1_f32, 0.1], [0.1, 0.1]], + [[0.1_f32, 0.1], [0.1, 0.1]], + ]]), + &device, + ); + let targets = Tensor::<4>::zeros([1, 3, 2, 2], &device); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 20.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{} dB, got {} dB", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_running_average() { + // Test running average across multiple updates + let device = Default::default(); + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + + // First update: error 0.1 → MSE = 0.01 → PSNR = 20 dB + let outputs1 = + Tensor::<4>::from_data(TensorData::from([[[[0.1_f32, 0.1], [0.1, 0.1]]]]), &device); + let targets1 = Tensor::<4>::zeros([1, 1, 2, 2], &device); + let input1 = PsnrInput::new(outputs1, targets1); + let _entry = metric.update(&input1, &MetricMetadata::fake()); + + let psnr1 = metric.value().current(); + let expected_psnr1 = 20.0; + assert!( + (psnr1 - expected_psnr1).abs() < 0.01, + "First update PSNR should be ~{} dB, got {} dB", + expected_psnr1, + psnr1 + ); + + // Second update: error 0.01 → MSE = 0.0001 → PSNR = 40 dB + let outputs2 = Tensor::<4>::from_data( + TensorData::from([[[[0.01_f32, 0.01], [0.01, 0.01]]]]), + &device, + ); + let targets2 = Tensor::<4>::zeros([1, 1, 2, 2], &device); + let input2 = PsnrInput::new(outputs2, targets2); + let _entry = metric.update(&input2, &MetricMetadata::fake()); + + // Running average: (20 + 40) / 2 = 30 dB + let running_avg_psnr = metric.running_value().current(); + let expected_running_avg_psnr = 30.0; + assert!( + (running_avg_psnr - expected_running_avg_psnr).abs() < 0.01, + "Running average should be ~{} dB, got {} dB", + expected_running_avg_psnr, + running_avg_psnr + ); + } + + #[test] + fn test_psnr_clear() { + // Error 0.1 → MSE = 0.01 → PSNR = 20 dB + let device = Default::default(); + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + + let outputs = + Tensor::<4>::from_data(TensorData::from([[[[0.1_f32, 0.1], [0.1, 0.1]]]]), &device); + let targets = Tensor::<4>::zeros([1, 1, 2, 2], &device); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 20.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{} dB, got {} dB", + expected_psnr, + psnr + ); + + // Clear and verify reset + metric.clear(); + let psnr = metric.running_value().current(); + assert!(psnr.is_nan(), "Expected NaN after clear, got {} dB", psnr) + } + + #[test] + fn test_psnr_custom_name() { + let config = PsnrMetricConfig::new(1.0); + let metric = PsnrMetric::new(config).with_name("CustomPSNR"); + + assert_eq!(metric.name().to_string(), "CustomPSNR"); + } + + #[test] + fn test_psnr_custom_epsilon() { + let device = Default::default(); + // With a larger epsilon, perfect reconstruction gives lower PSNR + let config = PsnrMetricConfig::new(1.0).with_epsilon(0.01); + let mut metric = PsnrMetric::new(config); + + let outputs = + Tensor::<4>::from_data(TensorData::from([[[[0.5_f32, 0.5], [0.5, 0.5]]]]), &device); + let targets = outputs.clone(); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + // With epsilon = 0.01, PSNR = 10 * log10(1.0 / 0.01) = 20 dB + let psnr = metric.value().current(); + let expected_psnr = 20.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{} dB with epsilon=0.01, got {}", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_negative_errors() { + // Test that negative differences (target > output) work correctly + let device = Default::default(); + let outputs = + Tensor::<4>::from_data(TensorData::from([[[[0.0_f32, 0.0], [0.0, 0.0]]]]), &device); + let targets = + Tensor::<4>::from_data(TensorData::from([[[[0.1_f32, 0.1], [0.1, 0.1]]]]), &device); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + // Same MSE as positive errors (0.01), so PSNR = 20 dB + let psnr = metric.value().current(); + let expected_psnr = 20.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{} dB, got {}", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_large_batch() { + // Test with a larger batch to verify batch dimension handling + let device = Default::default(); + let batch_size = 8; + + // All images have constant error 0.1 → MSE = 0.01 → PSNR = 20 dB + let outputs = Tensor::<4>::full([batch_size, 3, 4, 4], 0.1, &device); + let targets = Tensor::<4>::zeros([batch_size, 3, 4, 4], &device); + + let config = PsnrMetricConfig::new(1.0); + let mut metric = PsnrMetric::new(config); + let input = PsnrInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let psnr = metric.value().current(); + let expected_psnr = 20.0; + assert!( + (psnr - expected_psnr).abs() < 0.01, + "Expected PSNR ~{} dB, got {}", + expected_psnr, + psnr + ); + } + + #[test] + fn test_psnr_attributes() { + let config = PsnrMetricConfig::new(1.0); + let metric = PsnrMetric::new(config); + let attrs = metric.attributes(); + + match attrs { + MetricAttributes::Numeric(numeric_attrs) => { + assert_eq!(numeric_attrs.unit, Some("dB".to_string())); + assert!(numeric_attrs.higher_is_better); + } + _ => panic!("Expected numeric attributes"), + } + } + + #[test] + #[should_panic(expected = "Shape mismatch")] + fn test_psnr_shape_mismatch() { + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 2, 2], &device); + let targets = Tensor::<4>::zeros([1, 1, 3, 3], &device); + + let _ = PsnrInput::new(outputs, targets); + } + + #[test] + #[should_panic(expected = "max_pixel_val must be positive")] + fn test_psnr_negative_max_pixel_val() { + let _ = PsnrMetricConfig::new(-1.0); + } + + #[test] + #[should_panic(expected = "max_pixel_val must be positive")] + fn test_psnr_zero_max_pixel_val() { + let _ = PsnrMetricConfig::new(0.0); + } + + #[test] + #[should_panic(expected = "epsilon must be positive")] + fn test_psnr_negative_epsilon() { + let _ = PsnrMetricConfig::new(1.0).with_epsilon(-1e-10); + } + + #[test] + #[should_panic(expected = "epsilon must be positive")] + fn test_psnr_zero_epsilon() { + let _ = PsnrMetricConfig::new(1.0).with_epsilon(0.0); + } +} diff --git a/crates/burn-train/src/metric/vision/ssim.rs b/crates/burn-train/src/metric/vision/ssim.rs new file mode 100644 index 0000000..0b38fbb --- /dev/null +++ b/crates/burn-train/src/metric/vision/ssim.rs @@ -0,0 +1,872 @@ +use crate::metric::{ + Metric, MetricAttributes, MetricMetadata, MetricName, Numeric, NumericAttributes, NumericEntry, + SerializedEntry, + state::{FormatOptions, NumericMetricState}, +}; +use burn_core::{ + prelude::{Device, Tensor}, + tensor::{ElementConversion, module::conv2d, ops::ConvOptions}, +}; + +/// Input type for the [SsimMetric]. +/// +/// Both tensors must have shape `[N, C, H, W]`: +/// - `N`: Batch size +/// - `C`: Number of channels (1 for grayscale, 3 for RGB, etc.) +/// - `H`: Height +/// - `W`: Width +pub struct SsimInput { + /// Model output (predictions/reconstructions) images with shape [N, C, H, W]. + outputs: Tensor<4>, + /// Ground truth images with shape [N, C, H, W]. + targets: Tensor<4>, +} + +impl SsimInput { + /// Creates a new SsimInput with the given outputs and targets. + /// + /// Inputs are expected to have the dimensions `[N, C, H, W]` + /// where `N` is the batch size, `C` is the number of channels, + /// `H` is the height of the image, and `W` is the width of the image. + /// + /// # Arguments + /// - `outputs`: The model output images with shape [N, C, H, W]. + /// - `targets`: The ground truth images with shape [N, C, H, W]. + /// + /// # Returns + /// A new instance of `SsimInput`. + /// + /// # Panics + /// - If `outputs` and `targets` do not have the same shape. + pub fn new(outputs: Tensor<4>, targets: Tensor<4>) -> Self { + assert!( + outputs.dims() == targets.dims(), + "Shape mismatch: outputs {:?}, targets {:?}", + outputs.dims(), + targets.dims() + ); + Self { outputs, targets } + } +} + +/// Configuration for the [SsimMetric]. +#[derive(Debug, Clone, Copy)] +pub struct SsimMetricConfig { + /// The range of the pixel values in images which can be computed as following: + /// `let pixel_range = max_pixel_val - min_pixel_val;` + /// where `max_pixel_val` is the maximum possible pixel value and `min_pixel_val` + /// is the minimum possible pixel value. + /// + /// - For normalized images in range [0, 1], it should be set to `1.0 - 0.0 = 1.0` + /// - For normalized images in range [-1, 1], it should be set to `1.0 - (-1.0) = 2.0` + /// - For 8-bit images in range [0, 255], it should be set to `255.0 - 0.0 = 255.0` + pub pixel_range: f32, + /// A parameter of SSIM used to stabilize the luminance comparison. + /// Default is 0.01. + pub k1: f32, + /// A parameter of SSIM used to stabilize the contrast comparison. + /// Default is 0.03. + pub k2: f32, + /// The SSIM metric involves applying convolution to the input tensors using a Gaussian kernel. + /// This is the kernel size of the Gaussian kernel. Default is 11. + pub kernel_size: usize, + /// The SSIM metric involves applying convolution to the input tensors using a Gaussian kernel. + /// This is the standard deviation of the Gaussian kernel. Default is 1.5. + pub sigma: f32, +} + +impl SsimMetricConfig { + /// Creates a configuration with the specified data range and default parameters. + /// + /// # Default parameters + /// - k1: 0.01 + /// - k2: 0.03 + /// - kernel_size: 11 + /// - sigma: 1.5 + /// + /// # Panics + /// - If `pixel_range` is not positive. + /// + /// # Example + /// ```ignore + /// // Normalized images [0, 1] + /// let config1 = SsimMetricConfig::new(1.0); + /// + /// // 8-bit images [0, 255] + /// let config2 = SsimMetricConfig::new(255.0); + /// + /// // Also set custom values for k1 and k2 + /// let config3 = SsimMetricConfig::new(1.0).with_k1_k2(0.015, 0.025); + /// + /// // Also set a custom value for window size + /// config3.with_kernel_size(13); + /// ``` + pub fn new(pixel_range: f32) -> Self { + assert!(pixel_range > 0.0, "pixel_range must be positive"); + Self { + pixel_range: pixel_range, + k1: 0.01, + k2: 0.03, + kernel_size: 11, + sigma: 1.5, + } + } + + /// Sets a custom value for the k1 and k2 parameters of SSIM which are + /// used for numerical stability. + /// + /// # Default values + /// - k1: 0.01 + /// - k2: 0.03 + /// + /// # Panics + /// - If `k1` or `k2` is not positive. + pub fn with_k1_k2(mut self, k1: f32, k2: f32) -> Self { + assert!(k1 > 0.0, "k1 must be positive"); + assert!(k2 > 0.0, "k2 must be positive"); + self.k1 = k1; + self.k2 = k2; + self + } + + /// Sets a custom window size for the Gaussian kernel used in SSIM. The + /// window size must be a positive odd number. + /// + /// # Default value + /// - kernel_size: 11 + /// + /// # Panics + /// - If `kernel_size` is not a positive odd number. + pub fn with_kernel_size(mut self, kernel_size: usize) -> Self { + assert!( + kernel_size > 0 && kernel_size % 2 == 1, + "kernel_size must be positive and an odd number" + ); + self.kernel_size = kernel_size; + self + } + + /// Sets a custom sigma (standard deviation) for the Gaussian kernel used in SSIM. + /// + /// # Default value + /// - sigma: 1.5 + /// + /// # Panics + /// - If `sigma` is not positive. + pub fn with_sigma(mut self, sigma: f32) -> Self { + assert!(sigma > 0.0, "sigma must be positive"); + self.sigma = sigma; + self + } +} + +/// The SSIM (structural similarity index measure) metric for image quality assessment. +/// +/// SSIM measures the perceived quality of images by comparing luminance, +/// contrast, and structure. Values range from -1 to 1, where 1 indicates +/// perfect structural similarity. +/// +/// # Formula +/// ```text +/// SSIM(x, y) = (2μxμy + C1)(2σxy + C2) / (μx² + μy² + C1)(σx² + σy² + C2) +/// ``` +/// +/// # Note +/// - This implementation uses separable Gaussian convolution for efficiency. Instead of a +/// single 2D convolution with a K by K kernel, it applies two 1D convolutions (horizontal +/// then vertical). This reduces the computational complexity from O(K^2) to O(2K) per pixel. +/// - SSIM is computed for each image first, and then it is averaged across all the images in the batch. +#[derive(Clone)] +pub struct SsimMetric { + name: MetricName, + /// Internal state for numeric metric aggregation. + state: NumericMetricState, + /// Configuration for the metric. + config: SsimMetricConfig, +} + +impl SsimMetric { + /// Creates a new SSIM metric with the given configuration. + /// + /// # Note + /// The metric name format is "SSIM (dr={}, w={}, σ={})" + /// where dr is the data range, w is the window size, sigma is the + /// standard deviation. For example, the metric name might be + /// "SSIM (dr=1.0, w=11, σ=1.5)". + /// + /// # Example + /// ```ignore + /// let ssim_config = SsimMetricConfig::new(1.0); + /// let ssim_metric = SsimMetric::new(ssim_config); + /// ``` + pub fn new(config: SsimMetricConfig) -> Self { + Self { + name: MetricName::new(format!( + "SSIM (dr={}, w={}, σ={})", + config.pixel_range, config.kernel_size, config.sigma, + )), + state: NumericMetricState::default(), + config, + } + } + + /// Overrides the default metric name which is "SSIM". + pub fn with_name(mut self, name: &str) -> Self { + self.name = MetricName::new(name.to_string()); + self + } + + /// Creates a 1D Gaussian kernel as a tensor. + /// + /// Returns a normalized kernel where all values sum to 1. + /// The returned kernel will be reshaped by the `gaussian_conv_separable` + /// associated function later. + fn create_1d_gaussian_kernel(&self) -> Vec { + let size = self.config.kernel_size; + let sigma = self.config.sigma; + let center = (size / 2) as f32; + + let mut kernel = vec![0.0f32; size]; + let mut sum = 0.0f32; + + for (i, v) in kernel.iter_mut().enumerate() { + let x = i as f32 - center; + let value = (-(x * x) / (2.0 * sigma * sigma)).exp(); + *v = value; + sum += value; + } + + // Normalize so values sum to 1 + for v in kernel.iter_mut() { + *v /= sum; + } + + kernel + } + + /// Applies separable convolution using two 1D Gaussian kernels. + /// + /// # Arguments + /// - `inputs`: Tensor of shape [N, C, H, W] + /// - `kernel_1d`: The 1D Gaussian kernel values + /// - `channels`: Number of channels for depthwise convolution. + fn gaussian_conv_separable( + &self, + input: Tensor<4>, + kernel_1d: &[f32], + channels: usize, + device: &Device, + ) -> Tensor<4> { + let size = self.config.kernel_size; + let padding = size / 2; + + // Create horizontal kernel: shape [C, 1, 1, K] + let horizontal_kernel = Tensor::<1>::from_floats(kernel_1d, device) + .reshape([1, 1, 1, size]) // [1, 1, 1, K] + .repeat_dim(0, channels); // [C, 1, 1, K] + + let vertical_kernel = Tensor::<1>::from_floats(kernel_1d, device) + .reshape([1, 1, size, 1]) // [1, 1, K, 1] + .repeat_dim(0, channels); // [C, 1, K, 1] + + // Apply horizontal convolution + let horizontal_conv_options = ConvOptions::new([1, 1], [0, padding], [1, 1], channels); + let input_after_horizontal_conv = + conv2d(input, horizontal_kernel, None, horizontal_conv_options); + + // Apply vertical convolution + let vertical_conv_options = ConvOptions::new([1, 1], [padding, 0], [1, 1], channels); + conv2d( + input_after_horizontal_conv, + vertical_kernel, + None, + vertical_conv_options, + ) + } +} + +impl Metric for SsimMetric { + type Input = SsimInput; + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn update(&mut self, item: &Self::Input, _metadata: &MetricMetadata) -> SerializedEntry { + let dims = item.outputs.dims(); + let batch_size = dims[0]; + let channels = dims[1]; + let device = item.outputs.device(); + + let img_height = dims[2]; + let img_width = dims[3]; + assert!( + img_height >= self.config.kernel_size && img_width >= self.config.kernel_size, + "Image dimensions (H={}, W={}) must be >= kernel_size ({})", + img_height, + img_width, + self.config.kernel_size + ); + + // Constants in SSIM formula used for numerical stability + let c1 = (self.config.k1 * self.config.pixel_range).powi(2); + let c2 = (self.config.k2 * self.config.pixel_range).powi(2); + + // Create 1D Gaussian kernel to apply separable convolutions twice (horizontally and vertically) + let kernel_1d = self.create_1d_gaussian_kernel(); + + // Compute mu_x and mu_y, their product and squares + let x = item.outputs.clone(); + let y = item.targets.clone(); + let mu_x = self.gaussian_conv_separable(x.clone(), &kernel_1d, channels, &device); + let mu_y = self.gaussian_conv_separable(y.clone(), &kernel_1d, channels, &device); + let mu_x_mu_y = mu_x.clone() * mu_y.clone(); + let square_of_mu_x = mu_x.clone() * mu_x.clone(); + let square_of_mu_y = mu_y.clone() * mu_y.clone(); + + // Compute var_x, var_y (which are the same as (sigma_x)^2 and (sigma_y)^2): + // Var(X) = E[X^2] - E[X]^2 + // var_x = mu_of_x_squared - (mu_x * mu_x) + let mu_of_x_squared = + self.gaussian_conv_separable(x.clone() * x.clone(), &kernel_1d, channels, &device); + let mu_of_y_squared = + self.gaussian_conv_separable(y.clone() * y.clone(), &kernel_1d, channels, &device); + let var_x = (mu_of_x_squared - square_of_mu_x.clone()).clamp_min(0.0); + let var_y = (mu_of_y_squared - square_of_mu_y.clone()).clamp_min(0.0); + + // Compute the sample covariance of x and y: sigma_xy + // Cov(X, Y) = E[XY] - E[X]E[Y] + // sigma_xy = mu_xy - (mu_x * mu_y) + let mu_xy = self.gaussian_conv_separable(x * y, &kernel_1d, channels, &device); + let sigma_xy = mu_xy - mu_x_mu_y.clone(); + + // Compute SSIM: + // SSIM(x, y) = (2μxμy + C1)(2σxy + C2) / (μx² + μy² + C1)(σx² + σy² + C2) + let numerator = (mu_x_mu_y.mul_scalar(2.0_f32) + c1) * (sigma_xy.mul_scalar(2.0_f32) + c2); + let denominator = (square_of_mu_x + square_of_mu_y + c1) * (var_x + var_y + c2); + let ssim_tensor = numerator / denominator; + + // Average SSIM across all dimensions to get a single scalar value + let ssim_per_image = ssim_tensor.mean_dims(&[1, 2, 3]); + let avg_ssim = ssim_per_image.mean().into_scalar::(); + + self.state.update( + avg_ssim, + batch_size, + FormatOptions::new(self.name()).precision(4), + ) + } + + /// Clears the metric state. + fn clear(&mut self) { + self.state.reset(); + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: None, + higher_is_better: true, + ..Default::default() + } + .into() + } +} + +impl Numeric for SsimMetric { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +#[allow(clippy::manual_range_contains)] +mod tests { + use super::*; + use crate::metric::Numeric; + use burn_core::tensor::{Distribution, Shape, TensorData}; + + fn test_config() -> SsimMetricConfig { + SsimMetricConfig::new(1.0) + .with_kernel_size(3) + .with_sigma(1.0) + } + + #[test] + fn test_ssim_perfect_similarity() { + // When outputs exactly match targets, SSIM should be 1.0 + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([[[ + [0.1_f32, 0.2, 0.3, 0.4], + [0.5, 0.6, 0.7, 0.8], + [0.2, 0.3, 0.4, 0.5], + [0.6, 0.7, 0.8, 0.9], + ]]]), + &device, + ); + let targets = outputs.clone(); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "SSIM for identical images should be 1.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_completely_different() { + // Constant black vs constant white + // With constant images: SSIM = (2*mu_x*mu_y + C1) / (mu_x^2 + mu_y^2 + C1) + // For x=0, y=1 with C1=(0.01)^2=0.0001: SSIM ≈ 0.0001 / (1 + 0.00001) = 0.00009999 + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 4, 4], &device); + let targets = Tensor::<4>::ones([1, 1, 4, 4], &device); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + ssim < 0.0001, + "SSIM for black vs white images should be very low, got {}", + ssim + ); + } + + #[test] + fn test_ssim_similar_images() { + // Small perturbation should give high SSIM + let device = Default::default(); + let outputs = Tensor::<4>::full([1, 1, 4, 4], 0.5, &device); + let targets = Tensor::<4>::full([1, 1, 4, 4], 0.51, &device); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + ssim > 0.99, + "SSIM for very similar images should be close to 1.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_batch_averaging() { + // Batch of 2 images: + // Image 1: identical (SSIM = 1.0) + // Image 2: black vs white (SSIM ≈ 0) + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([ + [[ + [0.5_f32, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + ]], + [[ + [0.0_f32, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + ]], + ]), + &device, + ); + let targets = Tensor::<4>::from_data( + TensorData::from([ + [[ + [0.5_f32, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + ]], + [[ + [1.0_f32, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + ]], + ]), + &device, + ); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + // Average of ~1.0 and ~0.0 should be around 0.5 + assert!( + ssim > 0.49 && ssim < 0.51, + "Average SSIM should be around 0.5, got {}", + ssim + ); + } + + #[test] + fn test_ssim_multichannel() { + // Test with 3 channels (e.g., RGB) + let device = Default::default(); + let outputs = Tensor::<4>::from_data( + TensorData::from([[ + [ + [0.5_f32, 0.6, 0.7, 0.8], + [0.4, 0.5, 0.6, 0.7], + [0.3, 0.4, 0.5, 0.6], + [0.2, 0.3, 0.4, 0.5], + ], + [ + [0.3_f32, 0.4, 0.5, 0.6], + [0.2, 0.3, 0.4, 0.5], + [0.1, 0.2, 0.3, 0.4], + [0.0, 0.1, 0.2, 0.3], + ], + [ + [0.7_f32, 0.8, 0.9, 1.0], + [0.6, 0.7, 0.8, 0.9], + [0.5, 0.6, 0.7, 0.8], + [0.4, 0.5, 0.6, 0.7], + ], + ]]), + &device, + ); + let targets = outputs.clone(); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "SSIM for identical RGB images should be 1.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_symmetry() { + // SSIM(x, y) should equal SSIM(y, x) + // Symmetry is one of the mathematical properties of SSIM + let device = Default::default(); + let img1 = Tensor::<4>::from_data( + TensorData::from([[[ + [0.1_f32, 0.2, 0.3, 0.4], + [0.5, 0.6, 0.7, 0.8], + [0.2, 0.3, 0.4, 0.5], + [0.6, 0.7, 0.8, 0.9], + ]]]), + &device, + ); + let img2 = Tensor::<4>::from_data( + TensorData::from([[[ + [0.2_f32, 0.3, 0.4, 0.5], + [0.6, 0.7, 0.8, 0.9], + [0.3, 0.4, 0.5, 0.6], + [0.7, 0.8, 0.9, 1.0], + ]]]), + &device, + ); + + let config = test_config(); + + let mut metric1 = SsimMetric::new(config); + let input1 = SsimInput::new(img1.clone(), img2.clone()); + let _entry = metric1.update(&input1, &MetricMetadata::fake()); + let ssim1 = metric1.value().current(); + + let mut metric2 = SsimMetric::new(config); + let input2 = SsimInput::new(img2, img1); + let _entry = metric2.update(&input2, &MetricMetadata::fake()); + let ssim2 = metric2.value().current(); + + assert!( + (ssim1 - ssim2).abs() < 0.001, + "SSIM should be symmetric: SSIM(x,y)={} vs SSIM(y,x)={}", + ssim1, + ssim2 + ); + } + + #[test] + fn test_ssim_range() { + // SSIM values should be in [-1, 1] range + let device = Default::default(); + let shape = Shape::new([1, 1, 11, 11]); + let distribution = Distribution::Uniform(0.0, 1.0); + let outputs = Tensor::<4>::random(shape.clone(), distribution, &device); + let targets = Tensor::<4>::random(shape, distribution, &device); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + ssim >= -1.0 && ssim <= 1.0, + "SSIM should be in range [-1, 1], got {}", + ssim + ); + } + + #[test] + fn test_ssim_running_average() { + let device = Default::default(); + let mut metric = SsimMetric::new(test_config()); + + // First update: identical images (SSIM = 1.0) + let outputs1 = Tensor::<4>::from_data( + TensorData::from([[[ + [0.5_f32, 0.6, 0.7, 0.8], + [0.4, 0.5, 0.6, 0.7], + [0.3, 0.4, 0.5, 0.6], + [0.2, 0.3, 0.4, 0.5], + ]]]), + &device, + ); + let targets1 = outputs1.clone(); + let input1 = SsimInput::new(outputs1, targets1); + let _entry = metric.update(&input1, &MetricMetadata::fake()); + + let ssim1 = metric.value().current(); + assert!( + (ssim1 - 1.0).abs() < 0.001, + "First update SSIM should be ~1.0, got {}", + ssim1 + ); + + // Second update: very different images (SSIM close to 0) + let outputs2 = Tensor::<4>::zeros([1, 1, 4, 4], &device); + let targets2 = Tensor::<4>::ones([1, 1, 4, 4], &device); + let input2 = SsimInput::new(outputs2, targets2); + let _entry = metric.update(&input2, &MetricMetadata::fake()); + + // Running average should be around 0.5 + let running_avg = metric.running_value().current(); + assert!( + running_avg > 0.49 && running_avg < 0.51, + "Running average should be around 0.5, got {}", + running_avg + ); + } + + #[test] + fn test_ssim_clear() { + let device = Default::default(); + let mut metric = SsimMetric::new(test_config()); + + let outputs = Tensor::<4>::from_data( + TensorData::from([[[ + [0.5_f32, 0.6, 0.7, 0.8], + [0.4, 0.5, 0.6, 0.7], + [0.3, 0.4, 0.5, 0.6], + [0.2, 0.3, 0.4, 0.5], + ]]]), + &device, + ); + let targets = outputs.clone(); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "Expected SSIM ~1.0, got {}", + ssim + ); + + // Clear and verify reset + metric.clear(); + let ssim = metric.running_value().current(); + assert!(ssim.is_nan(), "Expected NaN after clear, got {}", ssim); + } + + #[test] + fn test_ssim_custom_name() { + let config = SsimMetricConfig::new(1.0); + let metric = SsimMetric::new(config).with_name("CustomSSIM"); + assert_eq!(metric.name().to_string(), "CustomSSIM"); + + let metric = SsimMetric::new(test_config()); + assert_eq!(metric.name().to_string(), "SSIM (dr=1, w=3, σ=1)"); + + let config = SsimMetricConfig::new(255.0); + let metric = SsimMetric::new(config); + assert_eq!(metric.name().to_string(), "SSIM (dr=255, w=11, σ=1.5)"); + } + + #[test] + fn test_ssim_pixel_range_255() { + // Test with 8-bit image range [0, 255] + let device = Default::default(); + let shape = Shape::new([1, 1, 10, 10]); + let distribution = Distribution::Uniform(0.0, 255.0); + let outputs = Tensor::<4>::random(shape.clone(), distribution, &device); + let targets = outputs.clone(); + + let config = SsimMetricConfig::new(255.0).with_kernel_size(3); + let mut metric = SsimMetric::new(config); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "SSIM for identical 8-bit images should be 1.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_large_batch() { + let device = Default::default(); + let shape = Shape::new([20, 3, 30, 30]); + let distribution = Distribution::Uniform(0.0, 1.0); + let outputs = Tensor::<4>::random(shape, distribution, &device); + let targets = outputs.clone(); + + let mut metric = SsimMetric::new(test_config()); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "SSIM for identical batch should be 1.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_default_kernel_size() { + // Test with default kernel_size=11, need images >= 11x11 + let device = Default::default(); + let shape = Shape::new([1, 1, 1080, 1920]); + let distribution = Distribution::Uniform(0.0, 1.0); + let outputs = Tensor::<4>::random(shape, distribution, &device); + let targets = outputs.clone(); + + let config = SsimMetricConfig::new(1.0); // default kernel_size=11 + let mut metric = SsimMetric::new(config); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + + let ssim = metric.value().current(); + assert!( + (ssim - 1.0).abs() < 0.001, + "SSIM with default window size should work and SSIM should be ~0.0, got {}", + ssim + ); + } + + #[test] + fn test_ssim_attributes() { + let config = SsimMetricConfig::new(1.0); + let metric = SsimMetric::new(config); + let attrs = metric.attributes(); + + match attrs { + MetricAttributes::Numeric(numeric_attrs) => { + assert_eq!(numeric_attrs.unit, None); + assert!(numeric_attrs.higher_is_better); + } + _ => panic!("Expected numeric attributes"), + } + } + + #[test] + #[should_panic(expected = "Shape mismatch")] + fn test_ssim_shape_mismatch() { + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 4, 4], &device); + let targets = Tensor::<4>::zeros([1, 1, 5, 5], &device); + + let _ = SsimInput::new(outputs, targets); + } + + #[test] + #[should_panic(expected = "Image dimensions (H=4, W=4) must be >= kernel_size (11)")] + fn test_ssim_image_too_small() { + let device = Default::default(); + let outputs = Tensor::<4>::zeros([1, 1, 4, 4], &device); + let targets = outputs.clone(); + + // Default kernel_size=11, but image is only 4x4 + let config = SsimMetricConfig::new(1.0); + let mut metric = SsimMetric::new(config); + let input = SsimInput::new(outputs, targets); + let _entry = metric.update(&input, &MetricMetadata::fake()); + } + + #[test] + fn test_ssim_valid_k1_k2() { + let config = SsimMetricConfig::new(1.0).with_k1_k2(0.015, 0.035); + assert!( + config.k1 == 0.015 && config.k2 == 0.035, + "Expected k1=0.015 and k2=0.035, got k1={} and k2={}", + config.k1, + config.k2 + ); + } + + #[test] + #[should_panic(expected = "pixel_range must be positive")] + fn test_ssim_negative_pixel_range() { + let _ = SsimMetricConfig::new(-1.0); + } + + #[test] + #[should_panic(expected = "pixel_range must be positive")] + fn test_ssim_zero_pixel_range() { + let _ = SsimMetricConfig::new(0.0); + } + + #[test] + #[should_panic(expected = "k1 must be positive")] + fn test_ssim_negative_k1() { + let _ = SsimMetricConfig::new(1.0).with_k1_k2(-0.01, 0.03); + } + + #[test] + #[should_panic(expected = "k2 must be positive")] + fn test_ssim_negative_k2() { + let _ = SsimMetricConfig::new(1.0).with_k1_k2(0.01, -0.03); + } + + #[test] + #[should_panic(expected = "kernel_size must be positive and an odd number")] + fn test_ssim_even_kernel_size() { + let _ = SsimMetricConfig::new(1.0).with_kernel_size(10); + } + + #[test] + #[should_panic(expected = "kernel_size must be positive and an odd number")] + fn test_ssim_zero_kernel_size() { + let _ = SsimMetricConfig::new(1.0).with_kernel_size(0); + } + + #[test] + #[should_panic(expected = "sigma must be positive")] + fn test_ssim_negative_sigma() { + let _ = SsimMetricConfig::new(1.0).with_sigma(-1.5); + } + + #[test] + #[should_panic(expected = "sigma must be positive")] + fn test_ssim_zero_sigma() { + let _ = SsimMetricConfig::new(1.0).with_sigma(0.0); + } +} diff --git a/crates/burn-train/src/metric/wer.rs b/crates/burn-train/src/metric/wer.rs new file mode 100644 index 0000000..79fc051 --- /dev/null +++ b/crates/burn-train/src/metric/wer.rs @@ -0,0 +1,226 @@ +use super::cer::edit_distance; +use super::state::{FormatOptions, NumericMetricState}; +use super::{MetricMetadata, SerializedEntry}; +use crate::metric::{ + Metric, MetricAttributes, MetricName, Numeric, NumericAttributes, NumericEntry, +}; +use burn_core::tensor::{Int, Tensor}; +use std::sync::Arc; + +// The edit_distance function remains the same as it calculates the Levenshtein distance +// between two sequences. The "units" within the sequences will now be treated as words. +/// The word error rate (WER) metric, similar to the CER, is defined as the edit distance (e.g. Levenshtein distance) between the predicted +/// and reference word sequences, divided by the total number of words in the reference. Here, the "units" within the sequences are words. +/// +#[derive(Clone)] +pub struct WordErrorRate { + name: MetricName, + state: NumericMetricState, + pad_token: Option, +} + +/// The [word error rate metric](WordErrorRate) input type. +#[derive(new)] +pub struct WerInput { + /// The predicted token sequences (as a 2-D tensor of token indices). + pub outputs: Tensor<2, Int>, + /// The target token sequences (as a 2-D tensor of token indices). + pub targets: Tensor<2, Int>, +} +impl Default for WordErrorRate { + fn default() -> Self { + Self::new() + } +} + +impl WordErrorRate { + /// Creates the metric. + pub fn new() -> Self { + Self { + name: Arc::new("WER".to_string()), + state: NumericMetricState::default(), + pad_token: None, + } + } + + /// Sets the pad token. + pub fn with_pad_token(mut self, index: usize) -> Self { + self.pad_token = Some(index); + self + } +} + +impl Metric for WordErrorRate { + type Input = WerInput; + + fn update(&mut self, input: &WerInput, _metadata: &MetricMetadata) -> SerializedEntry { + let outputs = input.outputs.clone(); + let targets = input.targets.clone(); + let [batch_size, seq_len] = targets.dims(); + + let outputs_data = outputs + .to_data() + .convert::() + .to_vec() + .expect("Failed to convert outputs to Vec"); + let targets_data = targets + .to_data() + .convert::() + .to_vec() + .expect("Failed to convert targets to Vec"); + + let pad_token = self.pad_token.map(|p| p as i32); + + let mut total_edit_distance = 0.0; + let mut total_target_length = 0.0; + + // Process each sequence in the batch + for i in 0..batch_size { + let start = i * seq_len; + let end = (i + 1) * seq_len; + let output_seq = &outputs_data[start..end]; + let target_seq = &targets_data[start..end]; + + // Handle padding and map elements to i32. + // These sequences now represent "words" (token IDs). + let (ed, target_len) = match pad_token { + Some(pad) => { + let output_seq_no_pad = output_seq + .iter() + .take_while(|&&x| x != pad) + .copied() + .collect::>(); + + let target_seq_no_pad = target_seq + .iter() + .take_while(|&&x| x != pad) + .copied() + .collect::>(); + + ( + edit_distance(&target_seq_no_pad, &output_seq_no_pad), + target_seq_no_pad.len(), + ) + } + None => (edit_distance(target_seq, output_seq), target_seq.len()), + }; + + total_edit_distance += ed as f64; + total_target_length += target_len as f64; + } + + // Compute current WER value as a percentage + let value = if total_target_length > 0.0 { + 100.0 * total_edit_distance / total_target_length + } else { + 0.0 + }; + + self.state.update( + value, + batch_size, + FormatOptions::new(self.name()).unit("%").precision(2), + ) + } + + fn name(&self) -> MetricName { + self.name.clone() + } + + fn clear(&mut self) { + self.state.reset(); + } + + fn attributes(&self) -> MetricAttributes { + NumericAttributes { + unit: Some("%".to_string()), + higher_is_better: false, + ..Default::default() + } + .into() + } +} + +impl Numeric for WordErrorRate { + fn value(&self) -> NumericEntry { + self.state.current_value() + } + + fn running_value(&self) -> NumericEntry { + self.state.running_value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Perfect match => WER = 0 %. + #[test] + fn test_wer_without_padding() { + let device = Default::default(); + let mut metric = WordErrorRate::new(); + + // Batch size = 2, sequence length = 2 + let preds = Tensor::from_data([[1, 2], [3, 4]], &device); + let tgts = Tensor::from_data([[1, 2], [3, 4]], &device); + + metric.update(&WerInput::new(preds, tgts), &MetricMetadata::fake()); + + assert_eq!(0.0, metric.value().current()); + } + + /// Two word edits in four target words => 50 %. + #[test] + fn test_wer_without_padding_two_errors() { + let device = Default::default(); + let mut metric = WordErrorRate::new(); + + // One substitution in each sequence. + // Sequence 1: target [1, 3], pred [1, 2] -> 1 error (3 vs 2) + // Sequence 2: target [3, 4], pred [3, 5] -> 1 error (4 vs 5) + let preds = Tensor::from_data([[1, 2], [3, 5]], &device); + let tgts = Tensor::from_data([[1, 3], [3, 4]], &device); + + metric.update(&WerInput::new(preds, tgts), &MetricMetadata::fake()); + + // Total errors = 2, Total target words = 4. WER = (2/4) * 100 = 50 % + assert_eq!(50.0, metric.value().current()); + } + + /// Same scenario as above, but with right-padding (token 9) ignored. + #[test] + fn test_wer_with_padding() { + let device = Default::default(); + let pad = 9_i64; + let mut metric = WordErrorRate::new().with_pad_token(pad as usize); + + // Each row has three columns, last one is the pad token. + // Target sequences after removing pad: [1, 3] and [3, 4] (total length 4) + // Predicted sequences after removing pad: [1, 2] and [3, 5] + let preds = Tensor::from_data([[1, 2, pad], [3, 5, pad]], &device); + let tgts = Tensor::from_data([[1, 3, pad], [3, 4, pad]], &device); + + metric.update(&WerInput::new(preds, tgts), &MetricMetadata::fake()); + assert_eq!(50.0, metric.value().current()); + } + + /// `clear()` must reset the running statistics to NaN. + #[test] + fn test_clear_resets_state() { + let device = Default::default(); + let mut metric = WordErrorRate::new(); + + let preds = Tensor::from_data([[1, 2]], &device); + let tgts = Tensor::from_data([[1, 3]], &device); // one error + + metric.update( + &WerInput::new(preds.clone(), tgts.clone()), + &MetricMetadata::fake(), + ); + assert!(metric.value().current() > 0.0); + + metric.clear(); + assert!(metric.value().current().is_nan()); + } +} diff --git a/crates/burn-train/src/renderer/base.rs b/crates/burn-train/src/renderer/base.rs new file mode 100644 index 0000000..4294bbe --- /dev/null +++ b/crates/burn-train/src/renderer/base.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use crate::{ + LearnerSummary, + logger::{EvaluationProgressLogger, TrainingProgressLogger}, + metric::{MetricDefinition, MetricEntry, NumericEntry}, +}; + +/// Trait for rendering metrics. +pub trait MetricsRendererTraining: Send + Sync { + /// Updates the training metric state. + /// + /// # Arguments + /// + /// * `state` - The metric state. + fn update_train(&mut self, state: MetricState); + + /// Updates the validation metric state. + /// + /// # Arguments + /// + /// * `state` - The metric state. + fn update_valid(&mut self, state: MetricState); + + /// Callback method invoked when training ends, whether it + /// completed successfully or was interrupted. + /// + /// # Returns + /// + /// A result indicating whether the end-of-training actions were successful. + fn on_train_end( + &mut self, + summary: Option, + ) -> Result<(), Box> { + default_summary_action(summary); + Ok(()) + } +} + +/// A renderer that can be used for both training and evaluation. +pub trait MetricsRenderer: + MetricsRendererEvaluation + + MetricsRendererTraining + + TrainingProgressLogger + + EvaluationProgressLogger +{ + /// Keep the renderer from automatically closing, requiring manual action to close it. + fn manual_close(&mut self); + /// Register a new metric. + fn register_metric(&mut self, definition: MetricDefinition); +} + +#[derive(Clone)] +/// The name of an evaluation. +/// +/// This is going to group metrics together for easier analysis. +pub struct EvaluationName { + pub(crate) name: Arc, +} + +impl EvaluationName { + /// Creates a new evaluation name. + pub fn new(s: S) -> Self { + Self { + name: Arc::new(format!("{s}")), + } + } + + /// Returns the evaluation name. + pub fn as_str(&self) -> &str { + &self.name + } +} + +impl core::fmt::Display for EvaluationName { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.name) + } +} + +/// Trait for rendering metrics. +pub trait MetricsRendererEvaluation: Send + Sync { + /// Updates the testing metric state. + /// + /// # Arguments + /// + /// * `state` - The metric state. + fn update_test(&mut self, name: EvaluationName, state: MetricState); + + /// Callback method invoked when testing ends, whether it + /// completed successfully or was interrupted. + /// + /// # Returns + /// + /// A result indicating whether the end-of-testing actions were successful. + fn on_test_end( + &mut self, + summary: Option, + ) -> Result<(), Box> { + default_summary_action(summary); + Ok(()) + } +} + +/// The state of a metric. +#[derive(Debug)] +pub enum MetricState { + /// A generic metric. + Generic(MetricEntry), + /// A numeric metric. + Numeric(MetricEntry, NumericEntry), +} + +fn default_summary_action(summary: Option) { + if let Some(summary) = summary { + println!("{summary}"); + } +} diff --git a/crates/burn-train/src/renderer/cli.rs b/crates/burn-train/src/renderer/cli.rs new file mode 100644 index 0000000..5d997fc --- /dev/null +++ b/crates/burn-train/src/renderer/cli.rs @@ -0,0 +1,112 @@ +use burn_core::data::dataloader::Progress; + +use crate::{ + logger::{EvaluationProgressLogger, ProgressSnapshot, TrainingProgressLogger}, + renderer::{MetricState, MetricsRenderer, MetricsRendererEvaluation, MetricsRendererTraining}, +}; + +/// A simple renderer for when the cli feature is not enabled. +pub struct CliMetricsRenderer { + training_progress: ProgressSnapshot, + eval_progress: ProgressSnapshot, +} + +#[allow(clippy::new_without_default)] +impl CliMetricsRenderer { + /// Create a new instance. + pub fn new() -> Self { + let init = Progress::new(0, 0, Some(String::new())); + Self { + training_progress: ProgressSnapshot::new(init.clone(), init.clone()), + eval_progress: ProgressSnapshot::new(init.clone(), init), + } + } +} + +impl MetricsRendererTraining for CliMetricsRenderer { + fn update_train(&mut self, _state: MetricState) {} + + fn update_valid(&mut self, _state: MetricState) {} +} + +impl TrainingProgressLogger for CliMetricsRenderer { + fn start(&mut self, total_epochs: usize, starting_epoch: usize, total_items: Option) { + self.training_progress.global = + Progress::new(starting_epoch, total_epochs, Some("epochs".to_string())); + if let Some(items) = total_items { + self.training_progress.split = Progress::new(0, items, Some("items".to_string())); + } + println!("Starting training for {total_epochs} epochs."); + } + + fn start_split(&mut self, split_name: &str, total_items: usize) { + self.training_progress.split = Progress::new(0, total_items, Some("items".to_string())); + println!("Starting split '{split_name}' with {total_items} items."); + } + + fn update_split(&mut self, items_processed: usize) { + let total = self.training_progress.split.items_total; + let unit = self.training_progress.split.unit.clone(); + self.training_progress.split = Progress::new(items_processed, total, unit); + + // For RL: global_progress.items_total == 0 means no epoch concept; mirror split. + if self.training_progress.global.items_total == 0 { + self.training_progress.global = self.training_progress.split.clone(); + } + println!("{:?}", self.training_progress); + } + + fn update_epoch(&mut self, epoch: usize) { + let total = self.training_progress.global.items_total; + let unit = self.training_progress.global.unit.clone(); + self.training_progress.global = Progress::new(epoch + 1, total, unit); + } + + fn end_split(&mut self) { + println!("Split ended."); + } + + fn end(&mut self) { + println!("Training ended."); + } + + fn log_event_training(&mut self, _event: String) {} +} + +impl EvaluationProgressLogger for CliMetricsRenderer { + fn start_global_progress(&mut self, total_tests: usize) { + self.eval_progress.global = Progress::new(0, total_tests, Some("tests".to_string())); + println!("Starting evaluation with {total_tests} test(s)."); + } + + fn start_test(&mut self, name: &str, total_items: usize) { + let current = self.eval_progress.global.items_processed + 1; + let total = self.eval_progress.global.items_total; + self.eval_progress.global = Progress::new(current, total, Some("tests".to_string())); + self.eval_progress.split = Progress::new(0, total_items, Some("items".to_string())); + println!("Starting test '{name}' with {total_items} items."); + } + + fn update_test_progress(&mut self, items_processed: usize) { + let total = self.eval_progress.split.items_total; + let unit = self.eval_progress.split.unit.clone(); + self.eval_progress.split = Progress::new(items_processed, total, unit); + println!("{:?}", self.eval_progress); + } + + fn end_test(&mut self) {} + + fn end_global_progress(&mut self) {} + + fn log_event_evaluation(&mut self, _event: String) {} +} + +impl MetricsRendererEvaluation for CliMetricsRenderer { + fn update_test(&mut self, _name: super::EvaluationName, _state: MetricState) {} +} + +impl MetricsRenderer for CliMetricsRenderer { + fn manual_close(&mut self) {} + + fn register_metric(&mut self, _definition: crate::metric::MetricDefinition) {} +} diff --git a/crates/burn-train/src/renderer/mod.rs b/crates/burn-train/src/renderer/mod.rs new file mode 100644 index 0000000..6e441fa --- /dev/null +++ b/crates/burn-train/src/renderer/mod.rs @@ -0,0 +1,34 @@ +#[cfg(feature = "tui")] +use std::io::IsTerminal; + +mod base; +pub use base::*; + +pub(crate) mod cli; + +pub use cli::*; + +/// The tui renderer +#[cfg(feature = "tui")] +pub mod tui; +use crate::Interrupter; + +/// Return the default metrics renderer. +/// +/// This can be either: +/// - `TuiMetricsRenderer`, when the `tui` feature is enabled and `stdout` is +/// a terminal, or +/// - `CliMetricsRenderer`, when the `tui` feature is not enabled, or `stdout` +/// is not a terminal. +#[allow(unused_variables)] +pub(crate) fn default_renderer( + interuptor: Interrupter, + checkpoint: Option, +) -> Box { + #[cfg(feature = "tui")] + if std::io::stdout().is_terminal() { + return Box::new(tui::TuiMetricsRendererWrapper::new(interuptor, checkpoint)); + } + + Box::new(CliMetricsRenderer::new()) +} diff --git a/crates/burn-train/src/renderer/tui/base.rs b/crates/burn-train/src/renderer/tui/base.rs new file mode 100644 index 0000000..12e40e3 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/base.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use super::{ + ControlsView, NumericMetricView, ProgressBarView, StatusView, TerminalFrame, TextMetricView, +}; +use ratatui::{ + prelude::{Constraint, Direction, Layout, Rect}, + style::Color, +}; + +#[derive(new)] +pub(crate) struct MetricsView<'a> { + metric_numeric: NumericMetricView<'a>, + metric_text: TextMetricView<'a>, + progress: ProgressBarView, + controls: ControlsView, + status: StatusView, +} + +impl MetricsView<'_> { + pub(crate) fn render(self, frame: &mut TerminalFrame<'_>, size: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(16), Constraint::Max(4)].as_ref()) + .split(size); + let size_other = chunks[0]; + let size_progress = chunks[1]; + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(38), Constraint::Percentage(62)].as_ref()) + .split(size_other); + let size_other = chunks[0]; + let size_metric_numeric = chunks[1]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Max(5), Constraint::Min(6), Constraint::Max(6)].as_ref()) + .split(size_other); + let size_controls = chunks[0]; + let size_metric_text = chunks[1]; + let size_status = chunks[2]; + + self.metric_numeric.render(frame, size_metric_numeric); + self.metric_text.render(frame, size_metric_text); + self.controls.render(frame, size_controls); + self.progress.render(frame, size_progress); + self.status.render(frame, size_status); + } +} + +#[derive(Hash, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum TuiSplit { + Train, + Valid, + Test, +} + +#[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum TuiGroup { + Default, + Named(Arc), +} + +#[derive(new, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TuiTag { + pub(crate) split: TuiSplit, + pub(crate) group: TuiGroup, +} + +impl core::fmt::Display for TuiTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.group { + TuiGroup::Default => f.write_fmt(format_args!("{}", self.split)), + TuiGroup::Named(group) => f.write_fmt(format_args!("{} - {}", self.split, group)), + } + } +} +impl core::fmt::Display for TuiGroup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TuiGroup::Default => f.write_str(""), + TuiGroup::Named(group) => f.write_fmt(format_args!("{group} ")), + } + } +} + +impl core::fmt::Display for TuiSplit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TuiSplit::Train => f.write_str("Train"), + TuiSplit::Valid => f.write_str("Valid"), + TuiSplit::Test => f.write_str("Test"), + } + } +} + +impl TuiSplit { + pub(crate) fn color(&self) -> Color { + match self { + TuiSplit::Train => Color::LightRed, + TuiSplit::Valid => Color::LightBlue, + TuiSplit::Test => Color::LightGreen, + } + } +} diff --git a/crates/burn-train/src/renderer/tui/controls.rs b/crates/burn-train/src/renderer/tui/controls.rs new file mode 100644 index 0000000..e487780 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/controls.rs @@ -0,0 +1,46 @@ +use super::TerminalFrame; +use ratatui::{ + prelude::{Alignment, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +/// Controls view. +pub(crate) struct ControlsView; + +impl ControlsView { + /// Render the view. + pub(crate) fn render(self, frame: &mut TerminalFrame<'_>, size: Rect) { + let lines = vec![ + vec![ + Span::from(" Quit : ").yellow().bold(), + Span::from("q ").bold(), + Span::from(" Stop the training.").italic(), + ], + vec![ + Span::from(" Plots Metrics : ").yellow().bold(), + Span::from("⬅ ➡").bold(), + Span::from(" Switch between metrics.").italic(), + ], + vec![ + Span::from(" Plots Type : ").yellow().bold(), + Span::from("⬆ ⬇").bold(), + Span::from(" Switch between types.").italic(), + ], + ]; + let paragraph = Paragraph::new(lines.into_iter().map(Line::from).collect::>()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::Gray)) + .block( + Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Gray)) + .title_alignment(Alignment::Left) + .title("Controls"), + ); + + frame.render_widget(paragraph, size); + } +} diff --git a/crates/burn-train/src/renderer/tui/full_history.rs b/crates/burn-train/src/renderer/tui/full_history.rs new file mode 100644 index 0000000..437c5ce --- /dev/null +++ b/crates/burn-train/src/renderer/tui/full_history.rs @@ -0,0 +1,295 @@ +use super::PlotAxes; +use crate::{ + metric::NumericEntry, + renderer::tui::{TuiSplit, TuiTag}, +}; +use ratatui::{ + style::{Color, Style}, + symbols, + widgets::{Bar, Dataset, GraphType}, +}; +use std::collections::BTreeMap; + +/// A plot that shows the full history at a reduced resolution. +pub(crate) struct FullHistoryPlot { + pub(crate) axes: PlotAxes, + points: BTreeMap, + max_samples: usize, + max_samples_ratio: BTreeMap, + next_x_state: usize, +} + +struct FullHistoryPoints { + min_x: f64, + max_x: f64, + min_y: f64, + max_y: f64, + avg_sum: f64, + avg_counter: f64, + points: Vec<(f64, f64)>, + max_samples: usize, + step_size: usize, +} + +impl FullHistoryPlot { + /// Create a new history plot. + pub(crate) fn new(max_samples: usize) -> Self { + Self { + points: BTreeMap::default(), + axes: PlotAxes::default(), + max_samples, + max_samples_ratio: BTreeMap::default(), + next_x_state: 0, + } + } + + /// Update the maximum amount of sample to display for the validation points. + /// + /// This is necessary if we want the validation line to have the same point density as the + /// training line. + pub(crate) fn update_max_sample(&mut self, split: TuiSplit, ratio: f64) { + self.max_samples_ratio.insert(split, ratio); + + self.points + .iter_mut() + .filter(|(tag, _)| tag.split == split) + .for_each(|(_, points)| { + points.max_samples = (self.max_samples as f64 * ratio) as usize; + }); + } + + /// Register a training data point. + pub(crate) fn push(&mut self, tag: TuiTag, data: NumericEntry) { + let x_current = self.next_x(); + let points = match self.points.get_mut(&tag) { + Some(val) => val, + None => { + let max_samples = self + .max_samples_ratio + .get(&tag.split) + .map(|ratio| (*ratio * self.max_samples as f64) as usize) + .unwrap_or(self.max_samples); + self.points + .insert(tag.clone(), FullHistoryPoints::new(max_samples)); + self.points.get_mut(&tag).unwrap() + } + }; + + points.push((x_current, data)); + + self.update_bounds(); + } + + pub(crate) fn datasets(&self) -> Vec> { + let mut datasets = Vec::with_capacity(2); + + for (tag, points) in self.points.iter() { + datasets.push(points.dataset(format!("{tag}"), tag.split.color())); + } + + datasets + } + + pub(crate) fn bars(&self, max: u64, bar_width: &mut usize) -> Vec> { + let mut bars = Vec::new(); + + for (tag, points) in self.points.iter() { + if let Some((bar, width)) = points.bar(tag, max) { + *bar_width = usize::max(*bar_width, width); + bars.push(bar); + } + } + + bars + } + + fn next_x(&mut self) -> f64 { + let value = self.next_x_state; + self.next_x_state += 1; + value as f64 + } + + fn update_bounds(&mut self) { + let (mut x_min, mut x_max) = (f64::MAX, f64::MIN); + let (mut y_min, mut y_max) = (f64::MAX, f64::MIN); + + for points in self.points.values() { + x_min = f64::min(x_min, points.min_x); + x_max = f64::max(x_max, points.max_x); + y_min = f64::min(y_min, points.min_y); + y_max = f64::max(y_max, points.max_y); + } + + self.axes.update_bounds((x_min, x_max), (y_min, y_max)); + } +} + +impl FullHistoryPoints { + fn new(max_samples: usize) -> Self { + Self { + min_x: 0., + max_x: 0., + min_y: f64::MAX, + max_y: f64::MIN, + avg_sum: 0.0, + avg_counter: 0.0, + points: Vec::with_capacity(max_samples), + max_samples, + step_size: 1, + } + } + + fn push(&mut self, (x, y): (f64, NumericEntry)) { + if !(x as usize).is_multiple_of(self.step_size) { + return; + } + + let y = match y { + NumericEntry::Value(val) => { + self.avg_sum += val; + self.avg_counter += 1.0; + val + } + NumericEntry::Aggregated { + aggregated_value, + count, + } => { + self.avg_sum += aggregated_value * count as f64; + self.avg_counter += count as f64; + aggregated_value + } + }; + + if x > self.max_x { + self.max_x = x; + } + if x < self.min_x { + self.min_x = x; + } + if y > self.max_y { + self.max_y = y; + } + if y < self.min_y { + self.min_y = y + } + + self.points.push((x, y)); + + if self.points.len() > self.max_samples { + self.resize(); + } + } + + /// We keep only half the points and we double the step size. + /// + /// This ensure that we have the same amount of points across the X axis. + fn resize(&mut self) { + let mut points = Vec::with_capacity(self.max_samples / 2); + let mut max_x = f64::MIN; + let mut max_y = f64::MIN; + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + + for (i, (x, y)) in self.points.drain(0..self.points.len()).enumerate() { + if i % 2 == 0 { + if x > max_x { + max_x = x; + } + if x < min_x { + min_x = x; + } + if y > max_y { + max_y = y; + } + if y < min_y { + min_y = y; + } + + points.push((x, y)); + } + } + + self.points = points; + self.step_size *= 2; + + self.min_x = min_x; + self.max_x = max_x; + self.min_y = min_y; + self.max_y = max_y; + } + + fn dataset<'a>(&'a self, name: String, color: Color) -> Dataset<'a> { + Dataset::default() + .name(name) + .marker(symbols::Marker::Braille) + .style(Style::default().fg(color).bold()) + .graph_type(GraphType::Line) + .data(&self.points) + } + + fn bar<'a>(&'a self, tag: &TuiTag, max: u64) -> Option<(Bar<'a>, usize)> { + if self.avg_sum == 0.0 { + return None; + } + + let label = format!("{tag}"); + let width = usize::max(label.len(), 7); // 7 min width + + let factor = max as f64; + + let avg = self.avg_sum / self.avg_counter; + + Some(( + Bar::default() + .value((avg * factor) as u64) + .style(tag.split.color()) + .text_value(format!("{:.2}", avg)) + .label(label), + width, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::tui::{TuiGroup, TuiSplit}; + + #[test] + fn test_points() { + let mut chart = FullHistoryPlot::new(10); + let tag_train = TuiTag::new(TuiSplit::Train, TuiGroup::Default); + let tag_valid = TuiTag::new(TuiSplit::Valid, TuiGroup::Default); + chart.update_max_sample(tag_valid.split, 0.6); + + for i in 0..100 { + chart.push(tag_train.clone(), NumericEntry::Value(i as f64)); + } + for i in 0..60 { + chart.push(tag_valid.clone(), NumericEntry::Value(i as f64)); + } + + let expected_train = vec![ + (0.0, 0.0), + (16.0, 16.0), + (32.0, 32.0), + (48.0, 48.0), + (64.0, 64.0), + (80.0, 80.0), + (96.0, 96.0), + ]; + + let expected_valid = vec![(100.0, 0.0), (116.0, 16.0), (128.0, 28.0), (144.0, 44.0)]; + + assert_eq!( + chart.points.get(&tag_train).unwrap().points, + expected_train, + "Expected train data points" + ); + assert_eq!( + chart.points.get(&tag_valid).unwrap().points, + expected_valid, + "Expected valid data points" + ); + } +} diff --git a/crates/burn-train/src/renderer/tui/metric_numeric.rs b/crates/burn-train/src/renderer/tui/metric_numeric.rs new file mode 100644 index 0000000..5344ffc --- /dev/null +++ b/crates/burn-train/src/renderer/tui/metric_numeric.rs @@ -0,0 +1,819 @@ +use crate::{ + logger::ProgressSnapshot, + metric::{MetricName, NumericEntry}, + renderer::tui::TuiTag, +}; + +use super::{FullHistoryPlot, RecentHistoryPlot, TerminalFrame, TuiSplit}; +use ratatui::{ + crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind, + }, + prelude::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::Line, + widgets::{ + Axis, BarChart, BarGroup, Block, Borders, Chart, LegendPosition, Padding, Paragraph, Tabs, + Widget, + }, +}; +use std::collections::BTreeMap; +use unicode_width::UnicodeWidthStr; + +/// 1 cell of padding on each side of a tab title, matching ratatui's default `Tabs` widget. +const TAB_PADDING: u16 = 2; +/// 1-cell `│` divider between adjacent tabs in ratatui's default `Tabs` widget. +const TAB_DIVIDER: u16 = 1; + +/// 1000 seems to be required to see some improvement. +const MAX_NUM_SAMPLES_RECENT: usize = 1000; +/// 250 seems to be the right resolution when plotting all history. +/// Otherwise, there is too much points and the lines arent't smooth enough. +const MAX_NUM_SAMPLES_FULL: usize = 250; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ChevronSide { + Left, + Right, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum HoverTarget { + Tab(usize), + Chevron(ChevronSide), +} + +/// Hit-test geometry and hover state for the tab strip, populated by `render_tab_strip` +/// on every frame and consumed by `on_mouse_event`. +#[derive(Default)] +pub(crate) struct TabStripState { + hovered: Option, + tab_rects: Vec, + chevron_left: Option, + chevron_right: Option, +} + +/// Numeric metrics state that handles creating plots. +#[derive(Default)] +pub(crate) struct NumericMetricsState { + data: BTreeMap, + names: Vec, + selected: usize, + kind: PlotKind, + num_samples_train: Option, + num_samples_valid: Option, + num_samples_test: Option, + epoch: usize, + strip: TabStripState, +} + +/// The kind of plot to display. +#[derive(Default, Clone, Copy)] +pub(crate) enum PlotKind { + /// Display the full history of the metric with reduced resolution. + #[default] + Full, + /// Display only the recent history of the metric, but with more resolution. + Recent, + Summary, +} + +impl NumericMetricsState { + /// Register a new training value for the metric with the given name. + pub(crate) fn push(&mut self, tag: TuiTag, name: MetricName, data: NumericEntry) { + if let Some((recent, full)) = self.data.get_mut(name.as_ref()) { + recent.push(tag.clone(), data.current()); + full.push(tag, data); + } else { + let mut recent = RecentHistoryPlot::new(MAX_NUM_SAMPLES_RECENT); + let mut full = FullHistoryPlot::new(MAX_NUM_SAMPLES_FULL); + + recent.push(tag.clone(), data.current()); + full.push(tag, data); + + self.names.push(name.clone()); + self.data.insert(name, (recent, full)); + } + } + + /// Update the state with the training progress. + pub(crate) fn update_progress_train(&mut self, progress: &ProgressSnapshot) { + self.epoch = progress.global.items_processed; + + if self.num_samples_train.is_some() { + return; + } + + self.num_samples_train = Some(progress.split.items_total); + } + + /// Update the state with the validation progress. + pub(crate) fn update_progress_valid(&mut self, progress: &ProgressSnapshot) { + if self.num_samples_valid.is_some() { + return; + } + + // If num_samples_train is None, keep the default max_samples for validation. + if let Some(num_sample_train) = self.num_samples_train { + for (_recent, full) in self.data.values_mut() { + let ratio = progress.split.items_total as f64 / num_sample_train as f64; + full.update_max_sample(TuiSplit::Valid, ratio); + } + } + + self.epoch = progress.global.items_processed; + self.num_samples_valid = Some(progress.split.items_total); + } + + /// Update the state with the testing progress. + pub(crate) fn update_progress_test(&mut self, progress: &ProgressSnapshot) { + if self.num_samples_test.is_some() { + return; + } + + if let Some(num_sample_train) = self.num_samples_train { + for (_recent, full) in self.data.values_mut() { + let ratio = progress.split.items_total as f64 / num_sample_train as f64; + full.update_max_sample(TuiSplit::Test, ratio); + } + } + + self.num_samples_test = Some(progress.split.items_total); + } + + /// Create a view to display the numeric metrics. + pub(crate) fn view(&mut self) -> NumericMetricView<'_> { + if self.names.is_empty() { + return NumericMetricView::None; + } + match self.kind { + PlotKind::Summary => { + let chart = Self::bar_chart(&self.names, &self.data, self.selected); + NumericMetricView::BarPlots { + titles: &self.names, + selected: self.selected, + chart, + strip: &mut self.strip, + } + } + kind => { + let chart = Self::line_chart(&self.names, &self.data, self.selected, kind); + NumericMetricView::LinePlots { + titles: &self.names, + selected: self.selected, + chart, + kind, + strip: &mut self.strip, + } + } + } + } + + /// Handle the current event. Returns `true` when visible state changed and a + /// redraw is warranted, `false` for events that produced no observable change + /// (so the caller can skip an unnecessary redraw). + pub(crate) fn on_event(&mut self, event: &Event) -> bool { + match event { + Event::Key(key) => self.on_key_event(key), + Event::Mouse(mouse) => self.on_mouse_event(mouse), + _ => false, + } + } + + fn on_key_event(&mut self, key: &KeyEvent) -> bool { + match key.kind { + KeyEventKind::Release | KeyEventKind::Repeat => (), + #[cfg(target_os = "windows")] // Fix the double toggle on Windows. + KeyEventKind::Press => return false, + #[cfg(not(target_os = "windows"))] + KeyEventKind::Press => (), + } + match key.code { + KeyCode::Right => { + self.next_metric(); + true + } + KeyCode::Left => { + self.previous_metric(); + true + } + KeyCode::Up | KeyCode::Down => { + self.switch_kind(); + true + } + _ => false, + } + } + + fn on_mouse_event(&mut self, mouse: &MouseEvent) -> bool { + let pos = Position::new(mouse.column, mouse.row); + let target = hover_target_at(&self.strip, pos); + match mouse.kind { + MouseEventKind::Moved => { + if self.strip.hovered == target { + false + } else { + self.strip.hovered = target; + true + } + } + MouseEventKind::Down(MouseButton::Left) => match target { + Some(HoverTarget::Tab(idx)) => { + self.selected = idx; + true + } + Some(HoverTarget::Chevron(ChevronSide::Left)) => { + self.previous_metric(); + true + } + Some(HoverTarget::Chevron(ChevronSide::Right)) => { + self.next_metric(); + true + } + None => false, + }, + _ => false, + } + } + + pub(crate) fn select_by_name(&mut self, name: &MetricName) { + if let Some(idx) = self.names.iter().position(|n| n == name) { + self.selected = idx; + } + } + + fn switch_kind(&mut self) { + self.kind = match self.kind { + PlotKind::Full => PlotKind::Recent, + PlotKind::Recent => PlotKind::Summary, + PlotKind::Summary => PlotKind::Full, + }; + } + + fn next_metric(&mut self) { + let len = self.data.len(); + if len == 0 { + return; + } + self.selected = (self.selected + 1) % len; + } + + fn previous_metric(&mut self) { + let len = self.data.len(); + if len == 0 { + return; + } + if self.selected > 0 { + self.selected -= 1; + } else { + self.selected = len - 1; + } + } + + fn line_chart<'a>( + names: &'a [MetricName], + data: &'a BTreeMap, + selected: usize, + kind: PlotKind, + ) -> Chart<'a> { + let name = names.get(selected).unwrap(); + let (recent, full) = data.get(name).unwrap(); + + let (datasets, axes) = match kind { + PlotKind::Full => (full.datasets(), &full.axes), + PlotKind::Recent => (recent.datasets(), &recent.axes), + _ => unreachable!(), + }; + + Chart::<'a>::new(datasets) + .block(Block::default()) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::DarkGray)) + .labels(axes.labels_x.clone().into_iter().map(|s| s.bold())) + .bounds(axes.bounds_x), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::DarkGray)) + .labels(axes.labels_y.clone().into_iter().map(|s| s.bold())) + .bounds(axes.bounds_y), + ) + .legend_position(Some(LegendPosition::Right)) + } + + fn bar_chart<'a>( + names: &'a [MetricName], + data: &'a BTreeMap, + selected: usize, + ) -> BarChart<'a> { + let name = names.get(selected).unwrap(); + let (_recent, full) = data.get(name).unwrap(); + let mut bar_width = 0; + let bars = full.bars(100, &mut bar_width); + + let data = BarGroup::default().bars(&bars); + BarChart::default() + .block(Block::default().padding(Padding::new(2, 2, 2, 0))) + .bar_width(bar_width as u16) + .bar_gap(2) + .data(data) + } +} + +#[allow(clippy::large_enum_variant)] +pub(crate) enum NumericMetricView<'a> { + LinePlots { + titles: &'a [MetricName], + selected: usize, + chart: Chart<'a>, + kind: PlotKind, + strip: &'a mut TabStripState, + }, + BarPlots { + titles: &'a [MetricName], + selected: usize, + chart: BarChart<'a>, + strip: &'a mut TabStripState, + }, + None, +} + +impl NumericMetricView<'_> { + pub(crate) fn render(self, frame: &mut TerminalFrame<'_>, size: Rect) { + match self { + Self::LinePlots { + titles, + selected, + chart, + kind, + strip, + } => { + let plot_title = match kind { + PlotKind::Full => "Full History", + PlotKind::Recent => "Recent History", + _ => unreachable!(), + }; + render_plot_panel( + frame, size, "Plots", plot_title, titles, selected, strip, chart, + ); + } + Self::BarPlots { + titles, + selected, + chart, + strip, + } => { + render_plot_panel( + frame, size, "Summary", "Summary", titles, selected, strip, chart, + ); + } + Self::None => {} + } + } +} + +/// Draw the bordered plot panel: tab strip on top, centered plot title, then the chart. +#[allow(clippy::too_many_arguments)] +fn render_plot_panel( + frame: &mut TerminalFrame<'_>, + size: Rect, + block_title: &str, + plot_title: &str, + titles: &[MetricName], + selected: usize, + strip: &mut TabStripState, + chart: W, +) { + let block = Block::default() + .borders(Borders::ALL) + .title(block_title) + .title_alignment(Alignment::Left); + let inner = block.inner(size); + frame.render_widget(block, size); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + render_tab_strip(frame, chunks[0], titles, selected, strip); + let title = Paragraph::new(Line::from(plot_title.bold())).alignment(Alignment::Center); + frame.render_widget(title, chunks[1]); + frame.render_widget(chart, chunks[2]); +} + +/// Render the metric tabs in `area`, scrolling horizontally so the `selected` tab is always +/// visible. A `‹` / `›` indicator is drawn in a reserved cell on each side when tabs are +/// hidden off that edge. The hovered tab gets an extra underline, a hovered chevron gets a +/// brighter foreground. Hit-test rects for the visible tabs and the two chevrons are written +/// back into `strip` so `on_mouse_event` can route clicks. +fn render_tab_strip( + frame: &mut TerminalFrame<'_>, + area: Rect, + titles: &[MetricName], + selected: usize, + strip: &mut TabStripState, +) { + strip.tab_rects.clear(); + strip.tab_rects.resize(titles.len(), Rect::default()); + strip.chevron_left = None; + strip.chevron_right = None; + + if titles.is_empty() || area.width == 0 { + return; + } + + let titles_str: Vec = titles.iter().map(|t| t.to_string()).collect(); + let widths: Vec = titles_str.iter().map(|s| tab_cell_width(s)).collect(); + + let inner_width = area.width.saturating_sub(2); + let (start, end) = visible_tab_window(&widths, selected, inner_width); + + if start > 0 { + let left = Rect { width: 1, ..area }; + let color = chevron_color(strip.hovered, ChevronSide::Left); + frame.render_widget(Paragraph::new("‹").style(Style::default().fg(color)), left); + strip.chevron_left = Some(left); + } + if end < titles.len() { + let right = Rect { + x: area.x + area.width - 1, + width: 1, + ..area + }; + let color = chevron_color(strip.hovered, ChevronSide::Right); + frame.render_widget(Paragraph::new("›").style(Style::default().fg(color)), right); + strip.chevron_right = Some(right); + } + + let tabs_area = Rect { + x: area.x + 1, + width: inner_width, + ..area + }; + + // Hit-test rects mirror the on-screen layout of the visible window. Tabs scrolled + // off either edge keep the zero-sized default from `resize` above so the mouse test + // misses them. + let mut x = tabs_area.x; + let tabs_end = tabs_area.x.saturating_add(tabs_area.width); + for (i, &w) in (start..end).zip(&widths[start..end]) { + let remaining = tabs_end.saturating_sub(x); + strip.tab_rects[i] = Rect { + x: x.min(tabs_end), + y: tabs_area.y, + width: w.min(remaining), + height: tabs_area.height, + }; + x = x.saturating_add(w).saturating_add(TAB_DIVIDER); + } + + let tabs = Tabs::new(titles_str[start..end].iter().enumerate().map(|(local, s)| { + let span = s.clone().yellow(); + let span = if strip.hovered == Some(HoverTarget::Tab(start + local)) { + span.underlined() + } else { + span + }; + Line::from(vec![span]) + })) + .select(selected - start) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + .fg(Color::LightYellow), + ); + frame.render_widget(tabs, tabs_area); +} + +fn chevron_color(hovered: Option, side: ChevronSide) -> Color { + if hovered == Some(HoverTarget::Chevron(side)) { + Color::Gray + } else { + Color::DarkGray + } +} + +fn hover_target_at(strip: &TabStripState, pos: Position) -> Option { + if strip.chevron_left.is_some_and(|r| r.contains(pos)) { + return Some(HoverTarget::Chevron(ChevronSide::Left)); + } + if strip.chevron_right.is_some_and(|r| r.contains(pos)) { + return Some(HoverTarget::Chevron(ChevronSide::Right)); + } + strip + .tab_rects + .iter() + .position(|r| r.contains(pos)) + .map(HoverTarget::Tab) +} + +/// Cells consumed by one tab. Title display width plus ratatui's default padding. +fn tab_cell_width(title: &str) -> u16 { + u16::try_from(UnicodeWidthStr::width(title) + TAB_PADDING as usize).unwrap_or(u16::MAX) +} + +/// Pick the `[start, end)` slice of `widths` to render so the tab at `selected` is visible +/// inside `available` cells. The selected tab is pinned as far right as fits. `end` is then +/// grown rightward as far as the remaining space allows. Always returns +/// `start <= selected < end` when `widths` is non-empty. If a single tab exceeds +/// `available`, clipping is delegated to ratatui's `Tabs`. +fn visible_tab_window(widths: &[u16], selected: usize, available: u16) -> (usize, usize) { + if widths.is_empty() { + return (0, 0); + } + let selected = selected.min(widths.len() - 1); + let available = available as u32; + let divider = TAB_DIVIDER as u32; + + // Width of titles[start..=selected] including dividers between them. Maintained + // incrementally so the windowing loop is O(N) over the full title list. + let mut width: u32 = + widths[..=selected].iter().map(|&w| w as u32).sum::() + selected as u32 * divider; + + let mut start = 0; + while width > available && start < selected { + width -= widths[start] as u32 + divider; + start += 1; + } + + let mut end = selected + 1; + while end < widths.len() { + let next = width + widths[end] as u32 + divider; + if next > available { + break; + } + width = next; + end += 1; + } + + (start, end) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyModifiers, MouseEvent}; + use std::sync::Arc; + + fn name(s: &str) -> MetricName { + Arc::new(s.to_string()) + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { + Event::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) + } + + #[test] + fn metric_navigation_on_empty_state_is_a_no_op() { + // Pressing Right or Left before any metric has been recorded must not + // panic. Previously this triggered `(0 + 1) % 0` and `0usize - 1` on + // the empty `data` map. + let mut state = NumericMetricsState::default(); + + state.next_metric(); + state.previous_metric(); + + assert_eq!(state.selected, 0); + assert!(state.data.is_empty()); + } + + #[test] + fn select_by_name_sets_index_on_match_and_no_ops_otherwise() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc"), name("lr")], + ..NumericMetricsState::default() + }; + + state.select_by_name(&name("acc")); + assert_eq!(state.selected, 1); + + state.select_by_name(&name("missing")); + assert_eq!(state.selected, 1); + } + + fn strip_with_tabs(tabs: Vec) -> TabStripState { + TabStripState { + tab_rects: tabs, + ..TabStripState::default() + } + } + + #[test] + fn mouse_click_on_tab_selects_it() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc")], + strip: strip_with_tabs(vec![Rect::new(0, 0, 6, 2), Rect::new(7, 0, 5, 2)]), + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 8, 0)); + + assert_eq!(state.selected, 1); + } + + #[test] + fn mouse_click_outside_tabs_does_not_change_selection() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc")], + selected: 0, + strip: strip_with_tabs(vec![Rect::new(0, 0, 6, 2), Rect::new(7, 0, 5, 2)]), + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 50, 50)); + + assert_eq!(state.selected, 0); + } + + #[test] + fn mouse_moved_updates_hovered() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc")], + strip: strip_with_tabs(vec![Rect::new(0, 0, 6, 2), Rect::new(7, 0, 5, 2)]), + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Moved, 2, 0)); + assert_eq!(state.strip.hovered, Some(HoverTarget::Tab(0))); + + state.on_event(&mouse(MouseEventKind::Moved, 50, 50)); + assert_eq!(state.strip.hovered, None); + } + + #[test] + fn mouse_click_on_left_chevron_goes_to_previous_metric() { + // Three metrics with the second one selected. A click on the left chevron should + // wrap-decrement the selection just like KeyCode::Left does. + let mut state = NumericMetricsState { + names: vec![name("a"), name("b"), name("c")], + data: BTreeMap::from_iter([ + ( + name("a"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ( + name("b"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ( + name("c"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ]), + selected: 1, + strip: TabStripState { + chevron_left: Some(Rect::new(0, 0, 1, 2)), + ..TabStripState::default() + }, + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 0, 0)); + assert_eq!(state.selected, 0); + } + + #[test] + fn mouse_click_on_right_chevron_goes_to_next_metric() { + let mut state = NumericMetricsState { + names: vec![name("a"), name("b"), name("c")], + data: BTreeMap::from_iter([ + ( + name("a"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ( + name("b"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ( + name("c"), + (RecentHistoryPlot::new(1), FullHistoryPlot::new(1)), + ), + ]), + selected: 1, + strip: TabStripState { + chevron_right: Some(Rect::new(20, 0, 1, 2)), + ..TabStripState::default() + }, + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 20, 0)); + assert_eq!(state.selected, 2); + } + + #[test] + fn mouse_moved_over_chevron_updates_hover_target() { + let mut state = NumericMetricsState { + strip: TabStripState { + chevron_left: Some(Rect::new(0, 0, 1, 2)), + chevron_right: Some(Rect::new(20, 0, 1, 2)), + ..TabStripState::default() + }, + ..NumericMetricsState::default() + }; + + state.on_event(&mouse(MouseEventKind::Moved, 0, 0)); + assert_eq!( + state.strip.hovered, + Some(HoverTarget::Chevron(ChevronSide::Left)) + ); + + state.on_event(&mouse(MouseEventKind::Moved, 20, 0)); + assert_eq!( + state.strip.hovered, + Some(HoverTarget::Chevron(ChevronSide::Right)) + ); + } + + #[test] + fn on_event_returns_false_for_mouse_move_that_does_not_change_hover() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc")], + strip: TabStripState { + tab_rects: vec![Rect::new(0, 0, 6, 2), Rect::new(7, 0, 5, 2)], + ..TabStripState::default() + }, + ..NumericMetricsState::default() + }; + + // First move onto tab 0: hover changes from None to Some(Tab(0)). + assert!(state.on_event(&mouse(MouseEventKind::Moved, 2, 0))); + // Second move still on tab 0: no change, no redraw needed. + assert!(!state.on_event(&mouse(MouseEventKind::Moved, 3, 0))); + // Move off any tab: hover changes back to None. + assert!(state.on_event(&mouse(MouseEventKind::Moved, 50, 50))); + // Move outside again: still None, no change. + assert!(!state.on_event(&mouse(MouseEventKind::Moved, 60, 60))); + } + + #[test] + fn on_event_returns_true_for_tab_click_false_for_missed_click() { + let mut state = NumericMetricsState { + names: vec![name("loss"), name("acc")], + strip: TabStripState { + tab_rects: vec![Rect::new(0, 0, 6, 2), Rect::new(7, 0, 5, 2)], + ..TabStripState::default() + }, + ..NumericMetricsState::default() + }; + + assert!(state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 8, 0))); + assert!(!state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 50, 50))); + } + + fn cells(titles: &[&str]) -> Vec { + titles.iter().copied().map(tab_cell_width).collect() + } + + #[test] + fn tab_cell_width_includes_padding() { + assert_eq!(tab_cell_width("Loss"), 4 + TAB_PADDING); + assert_eq!(tab_cell_width(""), TAB_PADDING); + } + + #[test] + fn visible_window_is_empty_when_no_tabs() { + assert_eq!(visible_tab_window(&[], 0, 80), (0, 0)); + } + + #[test] + fn visible_window_returns_full_range_when_everything_fits() { + let widths = cells(&["Loss", "Acc", "F1"]); + assert_eq!(visible_tab_window(&widths, 1, 80), (0, widths.len())); + } + + #[test] + fn visible_window_pins_selected_to_right_edge_when_growing_from_left() { + // Each tab cell is 2 + TAB_PADDING = 4. One divider between = 5 per added tab. + // available = 9 means exactly two tabs fit ([start..=selected] = 4 + 1 + 4 = 9). + let widths = cells(&["AA", "BB", "CC", "DD", "EE"]); + assert_eq!(visible_tab_window(&widths, 3, 9), (2, 4)); + } + + #[test] + fn visible_window_does_not_panic_when_single_tab_is_wider_than_available() { + let widths = cells(&["this title is much wider than the cell", "B"]); + let (start, end) = visible_tab_window(&widths, 0, 4); + assert_eq!(start, 0); + assert!(end >= 1); + } + + #[test] + fn visible_window_clamps_out_of_range_selected() { + let widths = cells(&["A", "B", "C"]); + let (start, end) = visible_tab_window(&widths, 99, 80); + assert!(start <= 2 && 2 < end); + } +} diff --git a/crates/burn-train/src/renderer/tui/metric_text.rs b/crates/burn-train/src/renderer/tui/metric_text.rs new file mode 100644 index 0000000..1a016a0 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/metric_text.rs @@ -0,0 +1,290 @@ +use super::TerminalFrame; +use crate::{ + metric::{MetricEntry, MetricName}, + renderer::tui::{TuiGroup, TuiSplit}, +}; +use ratatui::{ + crossterm::event::{Event, MouseButton, MouseEventKind}, + prelude::{Alignment, Position, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; + +/// Hit-test geometry and hover state for the metrics pane, populated by +/// `TextMetricView::render` on every frame and consumed by `on_event`. +#[derive(Default)] +pub(crate) struct TextHitState { + hovered: Option, + rect: Option, + header_rows: Vec<(MetricName, Range)>, +} + +#[derive(Default)] +pub(crate) struct TextMetricsState { + data: BTreeMap, + names: Vec, + pane: TextHitState, +} + +/// What a mouse event meant for the left pane. Drives both selection routing +/// (the `Clicked` arm carries the metric name to switch to) and redraw gating +/// (anything other than `Ignored` should cause a redraw). +pub(crate) enum TextEventOutcome { + Clicked(MetricName), + HoverChanged, + Ignored, +} + +struct MetricGroup { + groups: BTreeMap, +} + +impl MetricGroup { + fn new(group: TuiGroup, metric: MetricSplits) -> Self { + Self { + groups: BTreeMap::from_iter(Some((group, metric))), + } + } + fn update(&mut self, split: TuiSplit, group: TuiGroup, metric: MetricEntry) { + match self.groups.get_mut(&group) { + Some(value) => value.update(split, metric), + None => { + let value = MetricSplits::new(split, metric); + + self.groups.insert(group, value); + } + } + } +} + +struct MetricSplits { + splits: BTreeMap, +} + +impl MetricSplits { + fn new(split: TuiSplit, metric: MetricEntry) -> Self { + Self { + splits: BTreeMap::from_iter(Some((split, metric))), + } + } + + fn update(&mut self, split: TuiSplit, metric: MetricEntry) { + self.splits.insert(split, metric); + } +} + +impl TextMetricsState { + pub(crate) fn update( + &mut self, + split: TuiSplit, + group: TuiGroup, + metric: MetricEntry, + name: Arc, + ) { + if let Some(existing) = self.data.get_mut(name.as_ref()) { + existing.update(split, group, metric); + } else { + let key = name.clone(); + let value = MetricSplits::new(split, metric); + + self.names.push(key.clone()); + self.data + .insert(key.to_string(), MetricGroup::new(group, value)); + } + } + + pub(crate) fn view(&mut self) -> TextMetricView<'_> { + TextMetricView::new(&self.names, &self.data, &mut self.pane) + } + + /// Updates hover state and reports what the event meant for the left pane. + pub(crate) fn on_event(&mut self, event: &Event) -> TextEventOutcome { + let Event::Mouse(mouse) = event else { + return TextEventOutcome::Ignored; + }; + let pos = Position::new(mouse.column, mouse.row); + let hit = if self.pane.rect.is_some_and(|pane| pane.contains(pos)) { + self.pane + .header_rows + .iter() + .find(|(_, rows)| rows.contains(&pos.y)) + .map(|(name, _)| name.clone()) + } else { + None + }; + + match mouse.kind { + MouseEventKind::Moved => { + if self.pane.hovered == hit { + TextEventOutcome::Ignored + } else { + self.pane.hovered = hit; + TextEventOutcome::HoverChanged + } + } + MouseEventKind::Down(MouseButton::Left) => match hit { + Some(name) => TextEventOutcome::Clicked(name), + None => TextEventOutcome::Ignored, + }, + _ => TextEventOutcome::Ignored, + } + } +} + +pub(crate) struct TextMetricView<'a> { + lines: Vec>>, + /// Index into `lines` of each metric's header row, in display order. + header_line_indices: Vec<(MetricName, usize)>, + pane: &'a mut TextHitState, +} + +impl<'a> TextMetricView<'a> { + fn new( + names: &[MetricName], + data: &BTreeMap, + pane: &'a mut TextHitState, + ) -> Self { + let mut lines = Vec::with_capacity(names.len() * 4); + let mut header_line_indices = Vec::with_capacity(names.len()); + + let hovered = pane.hovered.as_ref(); + let start_line = |title: &str, is_hovered: bool| { + let span = Span::from(format!(" {title} ")).bold().yellow(); + let span = if is_hovered { span.underlined() } else { span }; + vec![span] + }; + let format_line = |group: &TuiGroup, split: &TuiSplit, formatted: &str| { + vec![ + Span::from(format!(" {group}{split} ")).bold(), + Span::from(formatted.to_string()).italic(), + ] + }; + + for name in names { + let is_hovered = hovered.is_some_and(|h| h.as_ref() == name.as_ref()); + header_line_indices.push((name.clone(), lines.len())); + lines.push(start_line(name, is_hovered)); + + let entry = data.get(name.as_ref()).unwrap(); + + for (name, group) in entry.groups.iter() { + for (split, entry) in group.splits.iter() { + lines.push(format_line(name, split, &entry.serialized_entry.formatted)); + } + } + + lines.push(vec![Span::from("")]); + } + + Self { + lines, + header_line_indices, + pane, + } + } + + pub(crate) fn render(self, frame: &mut TerminalFrame<'_>, size: Rect) { + let Self { + lines, + header_line_indices, + pane, + } = self; + + // Skip the 1-cell top border. Header lines longer than the pane will + // wrap and misplace the hit zone, accepted as a known limitation. + let text_origin_y = size.y.saturating_add(1); + pane.rect = Some(size); + pane.header_rows = header_line_indices + .into_iter() + .map(|(name, line_idx)| { + let row = text_origin_y.saturating_add(line_idx as u16); + (name, row..row.saturating_add(1)) + }) + .collect(); + + let paragraph = Paragraph::new(lines.into_iter().map(Line::from).collect::>()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title("Metrics")) + .style(Style::default().fg(Color::Gray)); + + frame.render_widget(paragraph, size); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyModifiers, MouseEvent}; + + fn name(s: &str) -> MetricName { + Arc::new(s.to_string()) + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { + Event::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) + } + + fn state_with(headers: Vec<(MetricName, Range)>) -> TextMetricsState { + TextMetricsState { + pane: TextHitState { + rect: Some(Rect::new(0, 0, 20, 10)), + header_rows: headers, + ..TextHitState::default() + }, + ..TextMetricsState::default() + } + } + + #[test] + fn click_on_header_row_returns_clicked() { + let mut state = state_with(vec![(name("Loss"), 1..2), (name("Accuracy"), 5..6)]); + + let outcome = state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 3, 5)); + + match outcome { + TextEventOutcome::Clicked(n) => assert_eq!(n.as_str(), "Accuracy"), + _ => panic!("expected Clicked"), + } + } + + #[test] + fn click_off_any_header_returns_ignored() { + let mut state = state_with(vec![(name("Loss"), 1..2)]); + + let on_data_row = state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 3, 3)); + let outside_pane = state.on_event(&mouse(MouseEventKind::Down(MouseButton::Left), 50, 50)); + + assert!(matches!(on_data_row, TextEventOutcome::Ignored)); + assert!(matches!(outside_pane, TextEventOutcome::Ignored)); + } + + #[test] + fn moved_event_signals_hover_change_only_when_target_changes() { + let mut state = state_with(vec![(name("Loss"), 1..2)]); + + // First move onto Loss: hover changes from None to Some(Loss). + let r1 = state.on_event(&mouse(MouseEventKind::Moved, 3, 1)); + assert!(matches!(r1, TextEventOutcome::HoverChanged)); + assert_eq!( + state.pane.hovered.as_deref().map(|s| s.as_str()), + Some("Loss") + ); + + // Second move still on Loss: no change, ignored (no redraw needed). + let r2 = state.on_event(&mouse(MouseEventKind::Moved, 4, 1)); + assert!(matches!(r2, TextEventOutcome::Ignored)); + + // Move off the header: hover changes to None. + let r3 = state.on_event(&mouse(MouseEventKind::Moved, 3, 8)); + assert!(matches!(r3, TextEventOutcome::HoverChanged)); + assert!(state.pane.hovered.is_none()); + } +} diff --git a/crates/burn-train/src/renderer/tui/mod.rs b/crates/burn-train/src/renderer/tui/mod.rs new file mode 100644 index 0000000..b69d0c5 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/mod.rs @@ -0,0 +1,23 @@ +mod base; +mod controls; +mod full_history; +mod metric_numeric; +mod metric_text; +mod plot_utils; +mod popup; +mod progress; +mod recent_history; +mod renderer; +mod status; + +pub(crate) use base::*; +pub(crate) use controls::*; +pub(crate) use full_history::*; +pub(crate) use metric_numeric::*; +pub(crate) use metric_text::*; +pub(crate) use plot_utils::*; +pub(crate) use popup::*; +pub(crate) use progress::*; +pub(crate) use recent_history::*; +pub use renderer::*; +pub(crate) use status::*; diff --git a/crates/burn-train/src/renderer/tui/plot_utils.rs b/crates/burn-train/src/renderer/tui/plot_utils.rs new file mode 100644 index 0000000..7a8a6d4 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/plot_utils.rs @@ -0,0 +1,37 @@ +use crate::metric::format_float; + +const AXIS_TITLE_PRECISION: usize = 2; + +/// The data describing both X and Y axes. +pub(crate) struct PlotAxes { + pub(crate) labels_x: Vec, + pub(crate) labels_y: Vec, + pub(crate) bounds_x: [f64; 2], + pub(crate) bounds_y: [f64; 2], +} + +impl Default for PlotAxes { + fn default() -> Self { + Self { + bounds_x: [f64::MAX, f64::MIN], + bounds_y: [f64::MAX, f64::MIN], + labels_x: Vec::new(), + labels_y: Vec::new(), + } + } +} + +impl PlotAxes { + /// Update the bounds based on the min max of each X and Y axes with both train and valid data. + pub(crate) fn update_bounds(&mut self, (x_min, x_max): (f64, f64), (y_min, y_max): (f64, f64)) { + self.bounds_x = [x_min, x_max]; + self.bounds_y = [y_min, y_max]; + + // We know x are integers. + self.labels_x = vec![format!("{x_min}"), format!("{x_max}")]; + self.labels_y = vec![ + format_float(y_min, AXIS_TITLE_PRECISION), + format_float(y_max, AXIS_TITLE_PRECISION), + ]; + } +} diff --git a/crates/burn-train/src/renderer/tui/popup.rs b/crates/burn-train/src/renderer/tui/popup.rs new file mode 100644 index 0000000..e3092c5 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/popup.rs @@ -0,0 +1,146 @@ +use ratatui::{ + crossterm::event::{Event, KeyCode}, + prelude::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use super::TerminalFrame; + +/// Popup callback function. +pub(crate) trait CallbackFn: Send + Sync { + /// Call the function and return if the popup state should be reset. + fn call(&self) -> bool; +} + +/// Popup callback. +pub(crate) struct Callback { + title: String, + description: String, + trigger: char, + callback: Box, +} + +impl Callback { + /// Create a new popup. + pub(crate) fn new(title: T, description: D, trigger: char, callback: C) -> Self + where + T: Into, + D: Into, + C: CallbackFn + 'static, + { + Self { + title: title.into(), + description: description.into(), + trigger, + callback: Box::new(callback), + } + } +} + +/// Popup state. +pub(crate) enum PopupState { + Empty, + Full(String, Vec), +} + +impl PopupState { + /// If the popup is empty. + pub(crate) fn is_empty(&self) -> bool { + matches!(&self, PopupState::Empty) + } + /// Handle popup events. Returns `true` when the popup state changed and a + /// redraw is warranted, `false` for events that left the popup untouched. + pub(crate) fn on_event(&mut self, event: &Event) -> bool { + let mut reset = false; + + match self { + PopupState::Empty => {} + PopupState::Full(_, callbacks) => { + for callback in callbacks.iter() { + if let Event::Key(key) = event + && let KeyCode::Char(key) = &key.code + && &callback.trigger == key + && callback.callback.call() + { + reset = true; + } + } + } + }; + + if reset { + *self = Self::Empty; + } + reset + } + /// Create the popup view. + pub(crate) fn view(&self) -> Option> { + match self { + PopupState::Empty => None, + PopupState::Full(title, callbacks) => Some(PopupView::new(title, callbacks)), + } + } +} + +#[derive(new)] +pub(crate) struct PopupView<'a> { + title: &'a String, + callbacks: &'a [Callback], +} + +impl<'a> PopupView<'a> { + /// Render the view. + pub(crate) fn render<'b>(&'a self, frame: &mut TerminalFrame<'b>, size: Rect) { + let lines = self + .callbacks + .iter() + .flat_map(|callback| { + vec![ + Line::from(vec![ + Span::from(format!("[{}] ", callback.trigger)).bold(), + Span::from(format!("{} ", callback.title)).yellow().bold(), + ]), + Line::from(Span::from("")), + Line::from(Span::from(callback.description.to_string()).italic()), + Line::from(Span::from("")), + ] + }) + .collect::>(); + + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::Gray)) + .block( + Block::default() + .borders(Borders::ALL) + .title_alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)) + .title(Span::styled( + self.title, + Style::default().add_modifier(Modifier::BOLD), + )), + ); + + let area = centered_percent(20, size, Direction::Horizontal); + let area = centered_percent(20, area, Direction::Vertical); + + frame.render_widget(paragraph, area); + } +} + +/// The percent represents the amount of space that will be taken by each side. +fn centered_percent(percent: u16, size: Rect, direction: Direction) -> Rect { + let center = 100 - (percent * 2); + + Layout::default() + .direction(direction) + .constraints([ + Constraint::Percentage(percent), + Constraint::Percentage(center), + Constraint::Percentage(percent), + ]) + .split(size)[1] +} diff --git a/crates/burn-train/src/renderer/tui/progress.rs b/crates/burn-train/src/renderer/tui/progress.rs new file mode 100644 index 0000000..f9ff19e --- /dev/null +++ b/crates/burn-train/src/renderer/tui/progress.rs @@ -0,0 +1,296 @@ +use super::TerminalFrame; +use crate::{logger::ProgressSnapshot, renderer::tui::TuiSplit}; +use ratatui::{ + prelude::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph}, +}; +use std::time::{Duration, Instant}; + +/// Simple progress bar for the training. +/// +/// We currently ignore the time taken for the validation part. +pub(crate) struct ProgressBarState { + progress_total: f64, // Progress for total execution. + progress_task: f64, // Progress for current task. + split: TuiSplit, + starting_epoch: usize, + estimate: ProgressEstimate, +} + +const MINUTE: u64 = 60; +const HOUR: u64 = 60 * 60; +const DAY: u64 = 24 * 60 * 60; + +impl ProgressBarState { + pub fn new(checkpoint: Option) -> Self { + Self { + progress_total: 0.0, + progress_task: 0.0, + split: TuiSplit::Train, + estimate: ProgressEstimate::new(), + starting_epoch: checkpoint.unwrap_or(0), + } + } + + /// Update the training progress. + pub(crate) fn update_train(&mut self, progress: &ProgressSnapshot) { + self.progress_total = calculate_progress(progress, 0, 0); + self.progress_task = + progress.split.items_processed as f64 / progress.split.items_total as f64; + self.estimate.update(progress, self.starting_epoch); + self.split = TuiSplit::Train; + } + + /// Update the validation progress. + pub(crate) fn update_valid(&mut self, progress: &ProgressSnapshot) { + // We don't use the validation for the total progress yet. + self.progress_task = + progress.split.items_processed as f64 / progress.split.items_total as f64; + self.split = TuiSplit::Valid; + } + + /// Update the testing progress. + pub(crate) fn update_test(&mut self, progress: &ProgressSnapshot) { + // We don't use the testing for the total progress yet. + self.progress_task = + progress.split.items_processed as f64 / progress.split.items_total as f64; + self.split = TuiSplit::Test; + } + + /// Create a view for the current progress. + pub(crate) fn view(&self) -> ProgressBarView { + const NO_ETA: &str = "---"; + + let eta = match self.estimate.secs() { + Some(eta) => format_eta(eta), + None => NO_ETA.to_string(), + }; + ProgressBarView::new( + self.progress_total, + self.progress_task, + self.split.color(), + eta, + ) + } +} + +#[derive(new)] +pub(crate) struct ProgressBarView { + progress: f64, + progress_task: f64, + color_task: Color, + eta: String, +} + +impl ProgressBarView { + /// Render the view. + pub(crate) fn render(self, frame: &mut TerminalFrame<'_>, size: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title("Progress") + .title_alignment(Alignment::Left); + let size_new = block.inner(size); + frame.render_widget(block, size); + let size = size_new; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) + .split(size); + + let size_task = chunks[0]; + let size_total = chunks[1]; + + let calculate_size = |size: Rect| { + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Length(1), // Empty space + Constraint::Min(0), + Constraint::Length(self.eta.len() as u16 + 4), + ] + .as_ref(), + ) + .split(size) + }; + + let chunks = calculate_size(size_total); + let size_gauge_total = chunks[1]; + let size_eta = chunks[2]; + let chunks = calculate_size(size_task); + let size_gauge_task = chunks[1]; + + let progress_total = Gauge::default() + .gauge_style(Style::default().fg(Color::Yellow)) + .ratio(self.progress.min(1.0)); + let progress_task = Gauge::default() + .gauge_style(Style::default().fg(self.color_task)) + .ratio(self.progress_task.min(1.0)); + + let eta = Paragraph::new(Line::from(vec![ + Span::from(" ("), + Span::from(self.eta).italic(), + Span::from(") "), + ])); + + frame.render_widget(progress_task, size_gauge_task); + frame.render_widget(progress_total, size_gauge_total); + frame.render_widget(eta, size_eta); + } +} + +struct ProgressEstimate { + started: Instant, + started_after_warmup: Option, + warmup_num_items: usize, + progress: f64, +} + +impl ProgressEstimate { + fn new() -> Self { + Self { + started: Instant::now(), + started_after_warmup: None, + warmup_num_items: 0, + progress: 0.0, + } + } + + fn secs(&self) -> Option { + let eta = self.started_after_warmup?.elapsed(); + + let total_estimated = (eta.as_secs() as f64) / self.progress; + + if total_estimated.is_normal() { + let remaining = 1.0 - self.progress; + let eta = (total_estimated * remaining) as u64; + Some(eta) + } else { + None + } + } + + fn update(&mut self, progress: &ProgressSnapshot, starting_epoch: usize) { + if self.started_after_warmup.is_some() { + self.progress = calculate_progress(progress, starting_epoch, self.warmup_num_items); + return; + } + + const WARMUP_NUM_ITERATION: usize = 10; + + // When the training has started since 30 seconds. + if self.started.elapsed() > Duration::from_secs(30) { + self.init(progress, starting_epoch); + return; + } + + // When the training has started since at least 10 seconds and completed 10 iterations. + if progress.split.items_processed >= WARMUP_NUM_ITERATION + && self.started.elapsed() > Duration::from_secs(10) + { + self.init(progress, starting_epoch); + } + } + + fn init(&mut self, progress: &ProgressSnapshot, starting_epoch: usize) { + let epoch = progress.global.items_processed - starting_epoch; + let local = &progress.split; + + let epoch_items = (epoch - 1) * local.items_total; + let iteration_items = local.items_processed; + self.warmup_num_items = epoch_items + iteration_items; + + self.started_after_warmup = Some(Instant::now()); + self.progress = calculate_progress(progress, starting_epoch, self.warmup_num_items); + } +} + +fn calculate_progress( + progress: &ProgressSnapshot, + starting_epoch: usize, + ignore_num_items: usize, +) -> f64 { + let epoch_total = progress.global.items_total - starting_epoch; + let epoch = progress.global.items_processed - starting_epoch; + let local = &progress.split; + + let total_items = local.items_total * epoch_total; + let epoch_items = (epoch - 1) * local.items_total; + let iteration_items = local.items_processed; + let num_items = epoch_items + iteration_items - ignore_num_items; + + num_items as f64 / total_items as f64 +} + +fn format_eta(eta_secs: u64) -> String { + let seconds = eta_secs % 60; + let minutes = eta_secs / MINUTE % 60; + let hours = eta_secs / HOUR % 24; + let days = eta_secs / DAY; + + if days > 1 { + format!("{days} days") + } else if days == 1 { + "1 day".to_string() + } else if hours > 1 { + format!("{hours} hours") + } else if hours == 1 { + "1 hour".to_string() + } else if minutes > 1 { + format!("{minutes} mins") + } else if minutes == 1 { + "1 min".to_string() + } else if seconds > 1 { + format!("{seconds} secs") + } else { + "1 sec".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use burn_core::data::dataloader::Progress; + + #[test] + fn test_format_eta() { + assert_eq!("55 secs", format_eta(55), "Less than 1 minutes"); + assert_eq!("1 min", format_eta(61), "More than 1 minutes"); + assert_eq!("2 mins", format_eta(2 * 61), "More than 2 minutes"); + assert_eq!("1 hour", format_eta(3601), "More than 1 hour"); + assert_eq!("2 hours", format_eta(2 * 3601), "More than 2 hour"); + assert_eq!("1 day", format_eta(24 * 3601), "More than 1 day"); + assert_eq!("2 days", format_eta(48 * 3601), "More than 2 day"); + } + + #[test] + fn calculate_progress_for_eta() { + let progress = ProgressSnapshot::new( + Progress::new(9, 10, Some("epochs".to_string())), + Progress::new(5, 10, Some("items".to_string())), + ); + + let starting_epoch = 8; + let result = calculate_progress(&progress, starting_epoch, 0); + + // Two epochs remaining while the first is half done. + assert_eq!(0.25, result); + } + + #[test] + fn calculate_progress_for_eta_with_warmup() { + let progress = ProgressSnapshot::new( + Progress::new(9, 10, Some("epochs".to_string())), + Progress::new(110, 1000, Some("items".to_string())), + ); + + let starting_epoch = 8; + let result = calculate_progress(&progress, starting_epoch, 10); + + // Two epochs remaining while the first is 11% done, minus 10 warmup items. + assert_eq!(0.05, result); + } +} diff --git a/crates/burn-train/src/renderer/tui/recent_history.rs b/crates/burn-train/src/renderer/tui/recent_history.rs new file mode 100644 index 0000000..909aff5 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/recent_history.rs @@ -0,0 +1,249 @@ +use super::PlotAxes; +use crate::renderer::tui::TuiTag; +use ratatui::{ + style::{Color, Style}, + symbols, + widgets::{Dataset, GraphType}, +}; +use std::collections::BTreeMap; + +const FACTOR_BEFORE_RESIZE: usize = 2; + +/// A plot that shows the recent history at full resolution. +pub(crate) struct RecentHistoryPlot { + pub(crate) axes: PlotAxes, + points: BTreeMap, + max_samples: usize, +} + +struct RecentHistoryPoints { + min_x: f64, + max_x: f64, + min_y: f64, + max_y: f64, + cursor: usize, + points: Vec<(f64, f64)>, + max_samples: usize, + factor_before_resize: usize, +} + +impl RecentHistoryPlot { + pub(crate) fn new(max_samples: usize) -> Self { + Self { + axes: PlotAxes::default(), + points: BTreeMap::default(), + max_samples, + } + } + + pub(crate) fn push(&mut self, tag: TuiTag, data: f64) { + if !self.points.contains_key(&tag) { + self.points + .insert(tag.clone(), RecentHistoryPoints::new(self.max_samples)); + } + + let (x_min, x_current) = self.point_x(); + + for (s, entry) in self.points.iter_mut() { + if s == &tag { + entry.push((x_current, data)); + } + entry.update_cursor(x_min); + } + + self.update_bounds(); + } + + pub(crate) fn datasets(&self) -> Vec> { + let mut datasets = Vec::new(); + + for (tag, points) in self.points.iter() { + datasets.push(points.dataset(format!("{tag}"), tag.split.color())); + } + + datasets + } + + fn point_x(&mut self) -> (f64, f64) { + let mut x_current = f64::MIN; + let mut x_min = f64::MAX; + + for point in self.points.values() { + x_current = f64::max(x_current, point.max_x); + x_min = f64::min(x_min, point.min_x); + } + + if x_current - x_min >= self.max_samples as f64 { + x_min += 1.0; + } + + (x_min, x_current + 1.0) + } + + fn update_bounds(&mut self) { + let (mut x_min, mut x_max) = (f64::MAX, f64::MIN); + let (mut y_min, mut y_max) = (f64::MAX, f64::MIN); + + for points in self.points.values() { + x_min = f64::min(x_min, points.min_x); + x_max = f64::max(x_max, points.max_x); + y_min = f64::min(y_min, points.min_y); + y_max = f64::max(y_max, points.max_y); + } + + self.axes.update_bounds((x_min, x_max), (y_min, y_max)); + } +} + +impl RecentHistoryPoints { + fn new(max_samples: usize) -> Self { + let factor_before_resize = FACTOR_BEFORE_RESIZE; + + Self { + min_x: 0., + max_x: 0., + min_y: f64::MAX, + max_y: f64::MIN, + points: Vec::with_capacity(factor_before_resize * max_samples), + cursor: 0, + max_samples, + factor_before_resize, + } + } + + fn push(&mut self, (x, y): (f64, f64)) { + if x > self.max_x { + self.max_x = x; + } + if x < self.min_x { + self.min_x = x; + } + if y > self.max_y { + self.max_y = y; + } + if y < self.min_y { + self.min_y = y + } + self.points.push((x, y)); + } + + fn update_cursor(&mut self, min_x: f64) { + if self.min_x >= min_x { + return; + } + self.min_x = min_x; + + let mut update_y_max = false; + let mut update_y_min = false; + + while let Some((x, y)) = self.points.get(self.cursor) { + if *x >= self.min_x { + break; + } + + if *y == self.max_y { + update_y_max = true + } + if *y == self.min_y { + update_y_min = true; + } + + self.cursor += 1; + } + + if update_y_max { + self.max_y = self.calculate_max_y(); + } + + if update_y_min { + self.min_y = self.calculate_min_y(); + } + + if self.points.len() >= self.max_samples * self.factor_before_resize { + self.resize(); + } + } + + fn slice(&self) -> &[(f64, f64)] { + &self.points[self.cursor..self.points.len()] + } + + fn calculate_max_y(&self) -> f64 { + let mut max_y = f64::MIN; + + for (_x, y) in self.slice() { + max_y = f64::max(max_y, *y); + } + + max_y + } + + fn calculate_min_y(&self) -> f64 { + let mut min_y = f64::MAX; + + for (_x, y) in self.slice() { + if *y < min_y { + min_y = *y; + } + } + + min_y + } + + fn resize(&mut self) { + let mut points = Vec::with_capacity(self.max_samples * self.factor_before_resize); + + for i in self.cursor..self.points.len() { + points.push(self.points[i]); + } + + self.points = points; + self.cursor = 0; + } + + fn dataset<'a>(&'a self, name: String, color: Color) -> Dataset<'a> { + let data = &self.points[self.cursor..self.points.len()]; + + Dataset::default() + .name(name) + .marker(symbols::Marker::Braille) + .style(Style::default().fg(color).bold()) + .graph_type(GraphType::Scatter) + .data(data) + } +} + +#[cfg(test)] +mod tests { + use crate::renderer::tui::{TuiGroup, TuiSplit}; + + use super::*; + + #[test] + fn test_push_update_bounds_max_y() { + let mut chart = RecentHistoryPlot::new(2); + let tag = TuiTag::new(TuiSplit::Train, TuiGroup::Default); + + chart.push(tag.clone(), 15.0); + chart.push(tag.clone(), 10.0); + chart.push(tag.clone(), 14.0); + + assert_eq!(chart.axes.bounds_y[1], 15.); + chart.push(tag, 10.0); + assert_eq!(chart.axes.bounds_y[1], 14.); + } + + #[test] + fn test_push_update_bounds_min_y() { + let mut chart = RecentHistoryPlot::new(2); + let tag = TuiTag::new(TuiSplit::Train, TuiGroup::Default); + + chart.push(tag.clone(), 5.0); + chart.push(tag.clone(), 10.0); + chart.push(tag.clone(), 14.0); + + assert_eq!(chart.axes.bounds_y[0], 5.); + chart.push(tag, 10.0); + assert_eq!(chart.axes.bounds_y[0], 10.); + } +} diff --git a/crates/burn-train/src/renderer/tui/renderer.rs b/crates/burn-train/src/renderer/tui/renderer.rs new file mode 100644 index 0000000..ca1c9d9 --- /dev/null +++ b/crates/burn-train/src/renderer/tui/renderer.rs @@ -0,0 +1,640 @@ +use crate::logger::{EvaluationProgressLogger, ProgressSnapshot, TrainingProgressLogger}; +use crate::metric::{MetricDefinition, MetricId}; +use crate::renderer::tui::TuiSplit; +use crate::renderer::{EvaluationName, MetricState, MetricsRenderer, MetricsRendererEvaluation}; +use crate::renderer::{MetricsRendererTraining, tui::NumericMetricsState}; +use crate::{Interrupter, LearnerSummary}; +use burn_core::data::dataloader::Progress; +use ratatui::{ + Terminal, + crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + }, + prelude::*, +}; +use std::collections::HashMap; +use std::panic::{set_hook, take_hook}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::JoinHandle; +use std::{ + error::Error, + io::{self, Stdout}, + time::{Duration, Instant}, +}; + +use super::{ + Callback, CallbackFn, ControlsView, MetricsView, PopupState, ProgressBarState, StatusState, + TextEventOutcome, TextMetricsState, TuiGroup, TuiTag, +}; + +/// The current terminal backend. +pub(crate) type TerminalBackend = CrosstermBackend; +/// The current terminal frame. +pub(crate) type TerminalFrame<'a> = ratatui::Frame<'a>; + +type PanicHook = Box) + 'static + Sync + Send>; + +const MAX_REFRESH_RATE_MILLIS: u64 = 100; + +enum TuiRendererEvent { + MetricRegistration(MetricDefinition), + MetricsUpdate((TuiSplit, TuiGroup, MetricState)), + StatusUpdateTrain((TuiSplit, ProgressSnapshot)), + StatusUpdateTest(ProgressSnapshot), + ProcessEnd { + summary: Option, + /// Interrupter reset. + reset: bool, + }, + CounterUpdate(String), + SplitEnd, + ManualClose, + Close, + Persistent, +} + +/// The terminal UI metrics renderer. +pub struct TuiMetricsRendererWrapper { + sender: mpsc::Sender, + interrupter: Interrupter, + handle_join: Option>, + kill_signal: Arc>>, + current_split: TuiSplit, + training_progress: ProgressSnapshot, + eval_progress: ProgressSnapshot, +} + +impl TuiMetricsRendererWrapper { + /// Create a new terminal UI renderer. + pub fn new(interrupter: Interrupter, checkpoint: Option) -> Self { + let (sender, receiver) = mpsc::channel(); + let (kill_signal_sender, kill_signal_receiver) = mpsc::channel(); + + let interrupter_clone = interrupter.clone(); + let handle_join = std::thread::Builder::new() + .name("train-renderer".into()) + .spawn(move || { + let mut renderer = + TuiMetricsRenderer::new(interrupter_clone, checkpoint, kill_signal_sender); + + let tick_rate = Duration::from_millis(MAX_REFRESH_RATE_MILLIS); + loop { + let remaining_time = tick_rate.saturating_sub(renderer.last_update.elapsed()); + match receiver.recv_timeout(remaining_time) { + Ok(event) => renderer.handle_event(event), + Err(mpsc::RecvTimeoutError::Timeout) => (), + Err(mpsc::RecvTimeoutError::Disconnected) => { + log::error!("Renderer thread disconnected."); + break; + } + } + + // Render + if renderer.last_update.elapsed() >= tick_rate + && let Err(err) = renderer.render() + { + log::error!("Render error: {err}"); + break; + } + + if (renderer.manual_close && renderer.interrupter.should_stop()) + || renderer.close + { + break; + } + } + }) + .unwrap(); + + let init = Progress::new(0, 0, None); + Self { + sender, + interrupter, + handle_join: Some(handle_join), + kill_signal: Arc::new(Mutex::new(kill_signal_receiver)), + current_split: TuiSplit::Train, + training_progress: ProgressSnapshot::new(init.clone(), init.clone()), + eval_progress: ProgressSnapshot::new(init.clone(), init), + } + } + + fn send_event(&self, event: TuiRendererEvent) { + if self.kill_signal.lock().unwrap().try_recv().is_ok() { + panic!("Killing training from user input.") + } + if let Err(e) = self.sender.send(event) { + log::warn!("Failed to send TUI event: {e}"); + } + } + + /// Set the renderer to persistent mode. + pub fn persistent(self) -> Self { + self.send_event(TuiRendererEvent::Persistent); + self + } +} + +struct TuiMetricsRenderer { + terminal: Terminal, + last_update: std::time::Instant, + progress: ProgressBarState, + metric_definitions: HashMap, + metrics_numeric: NumericMetricsState, + metrics_text: TextMetricsState, + status: StatusState, + interrupter: Interrupter, + popup: PopupState, + previous_panic_hook: Option>, + persistent: bool, + manual_close: bool, + close: bool, + summary: Option, + kill_signal: Sender<()>, +} + +impl MetricsRendererEvaluation for TuiMetricsRendererWrapper { + fn update_test(&mut self, name: EvaluationName, state: MetricState) { + self.send_event(TuiRendererEvent::MetricsUpdate(( + TuiSplit::Test, + TuiGroup::Named(name.name), + state, + ))); + } + + fn on_test_end(&mut self, summary: Option) -> Result<(), Box> { + // Update the summary + self.send_event(TuiRendererEvent::ProcessEnd { + summary, + reset: false, + }); + Ok(()) + } +} + +impl MetricsRenderer for TuiMetricsRendererWrapper { + fn manual_close(&mut self) { + self.send_event(TuiRendererEvent::ManualClose); + let _ = self.handle_join.take().unwrap().join(); + } + + fn register_metric(&mut self, definition: MetricDefinition) { + self.send_event(TuiRendererEvent::MetricRegistration(definition)); + } +} + +impl MetricsRendererTraining for TuiMetricsRendererWrapper { + fn update_train(&mut self, state: MetricState) { + self.send_event(TuiRendererEvent::MetricsUpdate(( + TuiSplit::Train, + TuiGroup::Default, + state, + ))); + } + + fn update_valid(&mut self, state: MetricState) { + self.send_event(TuiRendererEvent::MetricsUpdate(( + TuiSplit::Valid, + TuiGroup::Default, + state, + ))); + } + + fn on_train_end(&mut self, summary: Option) -> Result<(), Box> { + // Reset for following steps. + self.interrupter.reset(); + // Update the summary + self.send_event(TuiRendererEvent::ProcessEnd { + summary, + reset: true, + }); + Ok(()) + } +} + +impl TrainingProgressLogger for TuiMetricsRendererWrapper { + fn start(&mut self, total_epochs: usize, starting_epoch: usize, total_items: Option) { + self.training_progress.global = + Progress::new(starting_epoch, total_epochs, Some("epochs".to_string())); + if let Some(items) = total_items { + self.training_progress.split = Progress::new(0, items, Some("items".to_string())); + } + } + + fn update_epoch(&mut self, epoch: usize) { + let total = self.training_progress.global.items_total; + let unit = self.training_progress.global.unit.clone(); + self.training_progress.global = Progress::new(epoch + 1, total, unit); + } + + fn start_split(&mut self, split: &str, total_items: usize) { + self.training_progress.split = Progress::new(0, total_items, Some("items".to_string())); + self.current_split = if split == "train" { + TuiSplit::Train + } else { + TuiSplit::Valid + }; + } + + fn update_split(&mut self, items_processed: usize) { + let total = self.training_progress.split.items_total; + let unit = self.training_progress.split.unit.clone(); + self.training_progress.split = Progress::new(items_processed, total, unit); + if self.training_progress.global.items_total == 0 { + self.training_progress.global = self.training_progress.split.clone(); + } + self.send_event(TuiRendererEvent::StatusUpdateTrain(( + self.current_split, + self.training_progress.clone(), + ))); + } + + fn end_split(&mut self) { + self.send_event(TuiRendererEvent::SplitEnd); + self.current_split = TuiSplit::Train; + } + + fn end(&mut self) {} + + fn log_event_training(&mut self, event: String) { + self.send_event(TuiRendererEvent::CounterUpdate(event)); + } +} + +impl EvaluationProgressLogger for TuiMetricsRendererWrapper { + fn start_global_progress(&mut self, total_tests: usize) { + self.eval_progress.global = Progress::new(0, total_tests, Some("tests".to_string())); + } + + fn start_test(&mut self, _name: &str, total_items: usize) { + let current = self.eval_progress.global.items_processed + 1; + let total = self.eval_progress.global.items_total; + self.eval_progress.global = Progress::new(current, total, Some("tests".to_string())); + self.eval_progress.split = Progress::new(0, total_items, Some("items".to_string())); + } + + fn update_test_progress(&mut self, items_processed: usize) { + let total = self.eval_progress.split.items_total; + let unit = self.eval_progress.split.unit.clone(); + self.eval_progress.split = Progress::new(items_processed, total, unit); + self.send_event(TuiRendererEvent::StatusUpdateTest( + self.eval_progress.clone(), + )); + } + + fn end_test(&mut self) { + self.send_event(TuiRendererEvent::SplitEnd); + } + + fn end_global_progress(&mut self) {} + + fn log_event_evaluation(&mut self, event: String) { + self.send_event(TuiRendererEvent::CounterUpdate(event)); + } +} + +impl Drop for TuiMetricsRendererWrapper { + fn drop(&mut self) { + if !std::thread::panicking() { + self.send_event(TuiRendererEvent::Close); + if let Some(handle) = self.handle_join.take() { + let _ = handle.join(); + } + } + } +} + +impl TuiMetricsRenderer { + fn update_metric(&mut self, split: TuiSplit, group: TuiGroup, state: MetricState) { + match state { + MetricState::Generic(entry) => { + let name = self + .metric_definitions + .get(&entry.metric_id) + .unwrap() + .name + .clone() + .into(); + self.metrics_text.update(split, group, entry, name); + } + MetricState::Numeric(entry, value) => { + let name: Arc = self + .metric_definitions + .get(&entry.metric_id) + .unwrap() + .name + .clone() + .into(); + self.metrics_numeric + .push(TuiTag::new(split, group.clone()), name.clone(), value); + self.metrics_text.update(split, group, entry, name); + } + }; + } + + pub fn new( + interrupter: Interrupter, + checkpoint: Option, + kill_signal: Sender<()>, + ) -> Self { + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap(); + enable_raw_mode().unwrap(); + let terminal = Terminal::new(CrosstermBackend::new(stdout)).unwrap(); + + // Reset the terminal to raw mode on panic before running the panic handler + // This prevents that the panic message is not visible for the user. + let previous_panic_hook = Arc::new(take_hook()); + set_hook(Box::new({ + let previous_panic_hook = previous_panic_hook.clone(); + move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen); + previous_panic_hook(panic_info); + } + })); + + Self { + terminal, + last_update: Instant::now(), + progress: ProgressBarState::new(checkpoint), + metric_definitions: HashMap::default(), + metrics_numeric: NumericMetricsState::default(), + metrics_text: TextMetricsState::default(), + status: StatusState::default(), + interrupter, + popup: PopupState::Empty, + previous_panic_hook: Some(previous_panic_hook), + persistent: false, + manual_close: false, + close: false, + summary: None, + kill_signal, + } + } + + fn handle_event(&mut self, event: TuiRendererEvent) { + match event { + TuiRendererEvent::MetricRegistration(definition) => { + self.metric_definitions + .insert(definition.metric_id.clone(), definition); + } + TuiRendererEvent::MetricsUpdate((split, group, state)) => { + self.update_metric(split, group, state); + } + TuiRendererEvent::StatusUpdateTrain((split, item)) => match split { + TuiSplit::Train => { + self.progress.update_train(&item); + self.metrics_numeric.update_progress_train(&item); + self.status.update_train(&item); + } + TuiSplit::Valid => { + self.progress.update_valid(&item); + self.metrics_numeric.update_progress_valid(&item); + self.status.update_valid(&item); + } + _ => (), + }, + TuiRendererEvent::StatusUpdateTest(item) => { + self.progress.update_test(&item); + self.metrics_numeric.update_progress_test(&item); + self.status.update_test(&item); + } + TuiRendererEvent::ProcessEnd { summary, reset } => { + match (self.summary.take(), summary) { + (None, Some(summary)) => { + self.summary = Some(summary); + } + (Some(current), Some(other)) => self.summary = Some(current.merge(other)), + (_, _) => { /* nothing to update */ } + } + + if reset { + self.interrupter.reset(); + } + } + TuiRendererEvent::CounterUpdate(event) => { + self.status.update_counter(event); + } + TuiRendererEvent::SplitEnd => { + self.status.reset_counters(); + } + TuiRendererEvent::ManualClose => self.manual_close = true, + TuiRendererEvent::Persistent => self.persistent = true, + TuiRendererEvent::Close => self.close = true, + } + } + + fn render(&mut self) -> Result<(), Box> { + self.draw()?; + self.handle_user_input()?; + + self.last_update = Instant::now(); + + Ok(()) + } + + fn draw(&mut self) -> Result<(), Box> { + self.terminal.draw(|frame| { + let size = frame.area(); + + match self.popup.view() { + Some(view) => view.render(frame, size), + None => { + let view = MetricsView::new( + self.metrics_numeric.view(), + self.metrics_text.view(), + self.progress.view(), + ControlsView, + self.status.view(), + ); + + view.render(frame, size); + } + }; + })?; + + Ok(()) + } + + /// Dispatch a single user event to the popup / numeric / text components. + /// Returns `true` when something visible changed and a redraw is warranted. + /// The training loop ignores this (it is tick-gated); the post-training loop + /// uses it to skip redraws on inert events like mouse jitter. + fn dispatch_user_event(&mut self, event: &Event) -> bool { + let mut redraw = self.popup.on_event(event); + if self.popup.is_empty() { + redraw |= self.metrics_numeric.on_event(event); + redraw |= match self.metrics_text.on_event(event) { + TextEventOutcome::Clicked(name) => { + self.metrics_numeric.select_by_name(&name); + true + } + TextEventOutcome::HoverChanged => true, + TextEventOutcome::Ignored => false, + }; + } + redraw + } + + fn handle_user_input(&mut self) -> Result<(), Box> { + while event::poll(Duration::from_secs(0))? { + let event = event::read()?; + let _ = self.dispatch_user_event(&event); + + if self.popup.is_empty() + && let Event::Key(key) = event + && let KeyCode::Char('q') = key.code + { + self.popup = PopupState::Full( + "Quit".to_string(), + vec![ + Callback::new( + "Stop the training.", + "Stop the training immediately. This will break from the \ + training loop, but any remaining code after the loop will be \ + executed.", + 's', + QuitPopupAccept(self.interrupter.clone()), + ), + Callback::new( + "Stop the training immediately.", + "Kill the program. This will create a panic! which will make \ + the current training fails. Any code following the training \ + won't be executed.", + 'k', + KillPopupAccept(self.kill_signal.clone()), + ), + Callback::new( + "Cancel", + "Cancel the action, continue the training.", + 'c', + PopupCancel, + ), + ], + ); + } + } + + Ok(()) + } + + fn handle_post_training(&mut self) -> Result<(), Box> { + self.popup = PopupState::Full( + "Training is done".to_string(), + vec![Callback::new( + "Training Done", + "Press 'x' to close this popup. Press 'q' to exit the application after the \ + popup is closed.", + 'x', + PopupCancel, + )], + ); + + self.draw().ok(); + + loop { + if let Ok(true) = event::poll(Duration::from_millis(MAX_REFRESH_RATE_MILLIS)) { + match event::read() { + Ok(event @ Event::Key(key)) => { + let redraw = self.dispatch_user_event(&event); + if self.popup.is_empty() + && let KeyCode::Char('q') = key.code + { + break; + } + if redraw { + self.draw().ok(); + } + } + + Ok(event @ Event::Mouse(_)) => { + if self.dispatch_user_event(&event) { + self.draw().ok(); + } + } + + Ok(Event::Resize(..)) => { + self.draw().ok(); + } + Err(err) => { + eprintln!("Error reading event: {err}"); + break; + } + _ => continue, + } + } + } + Ok(()) + } + + // Reset the terminal back to raw mode. + fn reset(&mut self) -> Result<(), Box> { + // If previous panic hook has already been re-instated, then the terminal was already reset. + if self.previous_panic_hook.is_some() { + if self.persistent + && let Err(err) = self.handle_post_training() + { + eprintln!("Error in post-training handling: {err}"); + } + + disable_raw_mode()?; + execute!( + self.terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + )?; + self.terminal.show_cursor()?; + + // Reinstall the previous panic hook + let _ = take_hook(); + if let Some(previous_panic_hook) = + Arc::into_inner(self.previous_panic_hook.take().unwrap()) + { + set_hook(previous_panic_hook); + } + } + Ok(()) + } +} + +struct QuitPopupAccept(Interrupter); +struct KillPopupAccept(Sender<()>); +struct PopupCancel; + +impl CallbackFn for KillPopupAccept { + fn call(&self) -> bool { + self.0.send(()).unwrap(); + panic!("Killing training from user input."); + } +} + +impl CallbackFn for QuitPopupAccept { + fn call(&self) -> bool { + self.0.stop(Some("Stopping training from user input.")); + true + } +} + +impl CallbackFn for PopupCancel { + fn call(&self) -> bool { + true + } +} + +impl Drop for TuiMetricsRenderer { + fn drop(&mut self) { + // Reset the terminal back to raw mode. This can be skipped during + // panicking because the panic hook has already reset the terminal + if !std::thread::panicking() { + self.reset().unwrap(); + + if let Some(summary) = &self.summary { + println!("{summary}"); + log::info!("{summary}"); + } + } + } +} diff --git a/crates/burn-train/src/renderer/tui/status.rs b/crates/burn-train/src/renderer/tui/status.rs new file mode 100644 index 0000000..6e3ed9f --- /dev/null +++ b/crates/burn-train/src/renderer/tui/status.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; + +use crate::logger::ProgressSnapshot; + +use super::TerminalFrame; +use ratatui::{ + prelude::{Alignment, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +/// Show the training status with various information. +pub(crate) struct StatusState { + progress: Option, + mode: Mode, + event_counters: BTreeMap, +} + +enum Mode { + Valid, + Train, + Evaluation, +} + +impl Default for StatusState { + fn default() -> Self { + Self { + progress: None, + mode: Mode::Train, + event_counters: BTreeMap::new(), + } + } +} + +impl StatusState { + /// Update the training information. + pub(crate) fn update_train(&mut self, progress: &ProgressSnapshot) { + self.progress = Some(progress.clone()); + self.mode = Mode::Train; + } + /// Update the validation information. + pub(crate) fn update_valid(&mut self, progress: &ProgressSnapshot) { + self.progress = Some(progress.clone()); + self.mode = Mode::Valid; + } + /// Update the testing information. + pub(crate) fn update_test(&mut self, progress: &ProgressSnapshot) { + self.progress = Some(progress.clone()); + self.mode = Mode::Evaluation; + } + /// Update counters from a progress event. + pub(crate) fn update_counter(&mut self, event: String) { + *self.event_counters.entry(event).or_insert(0) += 1; + } + + /// Reset all counters at the end of a split. + pub(crate) fn reset_counters(&mut self) { + for val in self.event_counters.values_mut() { + *val = 0; + } + } + + /// Create a view. + pub(crate) fn view(&self) -> StatusView { + StatusView::new(self.progress.as_ref(), &self.mode, &self.event_counters) + } +} + +pub(crate) struct StatusView { + lines: Vec>>, +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} + +impl StatusView { + fn new( + progress: Option<&ProgressSnapshot>, + mode: &Mode, + event_counters: &BTreeMap, + ) -> Self { + let title = |title: &str| Span::from(format!(" {title} ")).bold().yellow(); + let value = |value: String| Span::from(value).italic(); + let mode_str = match mode { + Mode::Valid => "Validating", + Mode::Train => "Training", + Mode::Evaluation => "Evaluation", + }; + + let width = progress + .map(|p| { + p.global + .unit + .as_deref() + .map_or(0, |s| s.len()) + .max(p.split.unit.as_deref().map_or(0, |s| s.len())) + }) + .unwrap_or(0) + .max("Mode".len()) + .max(event_counters.keys().map(|k| k.len()).max().unwrap_or(0)); + + let mut lines = vec![vec![ + title(&format!("{: , size: Rect) { + let paragraph = Paragraph::new(self.lines.into_iter().map(Line::from).collect::>()) + .alignment(Alignment::Left) + .block(Block::default().borders(Borders::ALL).title("Status")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::Gray)); + + frame.render_widget(paragraph, size); + } +} diff --git a/crates/burn-train/tests/checkpoint.rs b/crates/burn-train/tests/checkpoint.rs new file mode 100644 index 0000000..fb80179 --- /dev/null +++ b/crates/burn-train/tests/checkpoint.rs @@ -0,0 +1,261 @@ +//! Integration tests for checkpoint saving/loading in a minimal supervised training loop. + +mod common; +use std::fs; + +use common::*; + +use burn_core::tensor::Device; +use burn_train::{ + SupervisedTraining, checkpoint::KeepLastNCheckpoints, logger::InMemoryMetricLogger, + metric::LossMetric, +}; + +#[test] +fn checkpoint_saves_bpk_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + let dir_path = dir.path().to_path_buf(); + let checkpoint_dir = dir_path.join("checkpoint"); + + let device = Device::flex().autodiff(); + let (dl_train, dl_valid) = make_dataloaders(); + + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(3) + .with_default_checkpointers() + .with_checkpointing_strategy(KeepLastNCheckpoints::new(3)) + .with_metric_logger(InMemoryMetricLogger::new()) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + + for epoch in 1..=3 { + let model_path = checkpoint_dir.join(format!("model-{epoch}.bpk")); + assert!( + model_path.exists(), + "expected checkpoint file: {}", + model_path.display() + ); + } +} + +/// Training can resume from a saved checkpoint: `SupervisedTraining::checkpoint(epoch)` loads +/// the model/optimizer/scheduler and continues from the next epoch. +#[test] +fn checkpoint_resume_continues_training() { + let dir = tempfile::tempdir().expect("create temp dir"); + let dir_path = dir.path().to_path_buf(); + + let device = Device::flex().autodiff(); + + { + let (dl_train, dl_valid) = make_dataloaders(); + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(2) + .with_default_checkpointers() + .with_checkpointing_strategy(KeepLastNCheckpoints::new(2)) + .with_metric_logger(InMemoryMetricLogger::new()) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + } + + let ckpt = dir_path.join("checkpoint").join("model-2.bpk"); + assert!(ckpt.exists(), "epoch-2 checkpoint must exist before resume"); + + // Resume from epoch 2 and train up to epoch 4. + { + let (dl_train, dl_valid) = make_dataloaders(); + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(4) + .checkpoint(2) + .with_default_checkpointers() + .with_checkpointing_strategy(KeepLastNCheckpoints::new(4)) + .with_metric_logger(InMemoryMetricLogger::new()) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + } + + for epoch in 1..=4 { + let path = dir_path + .join("checkpoint") + .join(format!("model-{epoch}.bpk")); + assert!( + path.exists(), + "expected checkpoint file for epoch {epoch}: {}", + path.display() + ); + } +} + +#[test] +fn checkpoint_restores_model_weights() { + let dir = tempfile::tempdir().expect("create temp dir"); + let dir_path = dir.path().to_path_buf(); + + let device = Device::flex().autodiff(); + let (dl_train, dl_valid) = make_dataloaders(); + + let result = SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(1) + .with_default_checkpointers() + .with_checkpointing_strategy(KeepLastNCheckpoints::new(1)) + .with_metric_logger(InMemoryMetricLogger::new()) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + + let trained_weights = result + .model + .weight + .val() + .into_data() + .to_vec::() + .unwrap(); + + // Load the checkpoint into a fresh learner + use burn_train::{ + LearningCheckpointer, + checkpoint::{AsyncCheckpointer, FileCheckpointer}, + }; + + let ckpt_dir = dir_path.join("checkpoint"); + let checkpointer = LearningCheckpointer::new( + AsyncCheckpointer::new(FileCheckpointer::new(&ckpt_dir, "model")), + AsyncCheckpointer::new(FileCheckpointer::new(&ckpt_dir, "optim")), + AsyncCheckpointer::new(FileCheckpointer::new(&ckpt_dir, "scheduler")), + Box::new(KeepLastNCheckpoints::new(1)), + ); + + let fresh = make_learner(&device); + let restored = checkpointer.load_checkpoint(fresh, 1); + + let restored_weights = restored + .model() + .weight + .val() + .into_data() + .to_vec::() + .unwrap(); + + assert_eq!( + trained_weights, restored_weights, + "restored weights must match the saved checkpoint" + ); +} + +#[test] +fn file_metric_logger_creates_log_directories() { + let dir = tempfile::tempdir().expect("create temp dir"); + let dir_path = dir.path().to_path_buf(); + + let device = Device::flex().autodiff(); + let (dl_train, dl_valid) = make_dataloaders(); + + use burn_train::logger::FileMetricLogger; + + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(2) + .with_metric_logger(FileMetricLogger::new(&dir_path)) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + + for epoch in 1..=2 { + let train_dir = dir_path.join("train").join(format!("epoch-{epoch}")); + let valid_dir = dir_path.join("valid").join(format!("epoch-{epoch}")); + + assert!( + train_dir.exists(), + "missing train log dir for epoch {epoch}: {}", + train_dir.display() + ); + assert!( + valid_dir.exists(), + "missing valid log dir for epoch {epoch}: {}", + valid_dir.display() + ); + } +} + +#[test] +fn file_metric_logger_resumes_logging_at_checkpoint() { + let dir = tempfile::tempdir().expect("create temp dir"); + let dir_path = dir.path().to_path_buf(); + + let device = Device::flex().autodiff(); + let (dl_train, dl_valid) = make_dataloaders(); + + use burn_train::logger::FileMetricLogger; + + { + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(1) + .with_metric_logger(FileMetricLogger::new(&dir_path)) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_default_checkpointers() + .with_application_logger(None) + .launch(make_learner(&device)); + } + + let loss_file_train = dir_path.join("train").join("epoch-1/Loss.log"); + let loss_file_valid = dir_path.join("valid").join("epoch-1/Loss.log"); + + let content_train_expected = fs::read(loss_file_train).expect("Cannot read file."); + let content_valid_expected = fs::read(loss_file_valid).expect("Cannot read file."); + + // Resume from epoch 1 and train up to epoch 2. + { + let (dl_train, dl_valid) = make_dataloaders(); + SupervisedTraining::new(&dir_path, dl_train, dl_valid) + .num_epochs(2) + .checkpoint(1) + .with_default_checkpointers() + .with_metric_logger(FileMetricLogger::new(&dir_path)) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(make_learner(&device)); + } + + for epoch in 1..=2 { + let train_dir = dir_path.join("train").join(format!("epoch-{epoch}")); + let valid_dir = dir_path.join("valid").join(format!("epoch-{epoch}")); + + assert!( + train_dir.exists(), + "missing train log dir for epoch {epoch}: {}", + train_dir.display() + ); + assert!( + valid_dir.exists(), + "missing valid log dir for epoch {epoch}: {}", + valid_dir.display() + ); + + // First epoch's logs remain unchanged. + if epoch == 1 { + let loss_file_train = train_dir.join("Loss.log"); + let loss_file_valid = valid_dir.join("Loss.log"); + + let content_train = fs::read(loss_file_train).expect("Cannot read file."); + let content_valid = fs::read(loss_file_valid).expect("Cannot read file."); + + assert_eq!( + content_train, content_train_expected, + "First epoch's logs should remain unchanged", + ); + assert_eq!( + content_valid, content_valid_expected, + "First epoch's logs should remain unchanged", + ); + } + } +} diff --git a/crates/burn-train/tests/common/mod.rs b/crates/burn-train/tests/common/mod.rs new file mode 100644 index 0000000..b1f9768 --- /dev/null +++ b/crates/burn-train/tests/common/mod.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use burn_core as burn; + +use burn_core::{ + data::{ + dataloader::{DataLoaderBuilder, batcher::Batcher}, + dataset::InMemDataset, + }, + module::{Module, Param}, + tensor::{Device, Distribution, Tensor}, +}; +use burn_optim::{ModuleOptimizer, SgdConfig, lr_scheduler::constant::ConstantLr}; +use burn_train::{InferenceStep, Learner, RegressionOutput, TrainOutput, TrainStep}; + +// Minimal toy model +#[derive(Module, Debug)] +pub struct ToyModel { + pub weight: Param>, +} + +impl ToyModel { + #[allow(unused)] + pub fn new(device: &Device) -> Self { + Self { + weight: Param::from_tensor(Tensor::random([1, 2], Distribution::Default, device)), + } + } +} + +#[derive(Clone, Debug)] +pub struct DummyBatch { + target: Tensor<2>, +} + +pub struct DummyBatcher; + +impl Batcher<(), DummyBatch> for DummyBatcher { + fn batch(&self, _items: Vec<()>, device: &Device) -> DummyBatch { + DummyBatch { + target: Tensor::zeros([1, 2], device), + } + } +} + +impl TrainStep for ToyModel { + type Input = DummyBatch; + type Output = RegressionOutput; + + fn step(&self, item: DummyBatch) -> TrainOutput { + let output = self.weight.val(); + let loss = output + .clone() + .sub(item.target.clone()) + .powi_scalar(2) + .mean(); + let regression = RegressionOutput::new(loss.clone(), output, item.target); + TrainOutput::new(self, loss.backward(), regression) + } +} + +impl InferenceStep for ToyModel { + type Input = DummyBatch; + type Output = RegressionOutput; + + fn step(&self, item: DummyBatch) -> RegressionOutput { + let output = self.weight.val(); + let loss = output + .clone() + .sub(item.target.clone()) + .powi_scalar(2) + .mean(); + RegressionOutput::new(loss, output, item.target) + } +} + +#[allow(unused)] +pub type ToyLearner = Learner; + +// Two-parameter model: `frozen` is a named sub-module so the optimizer mapper +// builds path "frozen" for its weight, making it targetable by ParamGroup::from_predicate. +#[allow(unused)] +#[derive(Module, Debug)] +pub struct FrozenLayer { + pub weight: Param>, +} + +impl FrozenLayer { + #[allow(unused)] + pub fn new(device: &Device) -> Self { + Self { + weight: Param::from_tensor(Tensor::random([1, 2], Distribution::Default, device)), + } + } +} + +#[allow(unused)] +#[derive(Module, Debug)] +pub struct TwoLayerModel { + pub frozen: FrozenLayer, + pub active: Param>, +} + +impl TwoLayerModel { + #[allow(unused)] + pub fn new(device: &Device) -> Self { + Self { + frozen: FrozenLayer::new(device), + active: Param::from_tensor(Tensor::random([1, 2], Distribution::Default, device)), + } + } +} + +impl TrainStep for TwoLayerModel { + type Input = DummyBatch; + type Output = RegressionOutput; + + fn step(&self, item: DummyBatch) -> TrainOutput { + let output = self.frozen.weight.val() + self.active.val(); + let loss = output + .clone() + .sub(item.target.clone()) + .powi_scalar(2) + .mean(); + let regression = RegressionOutput::new(loss.clone(), output, item.target); + TrainOutput::new(self, loss.backward(), regression) + } +} + +impl InferenceStep for TwoLayerModel { + type Input = DummyBatch; + type Output = RegressionOutput; + + fn step(&self, item: DummyBatch) -> RegressionOutput { + let output = self.frozen.weight.val() + self.active.val(); + let loss = output + .clone() + .sub(item.target.clone()) + .powi_scalar(2) + .mean(); + RegressionOutput::new(loss, output, item.target) + } +} + +pub fn make_dataloaders() -> ( + Arc>, + Arc>, +) { + let dl_train = DataLoaderBuilder::new(DummyBatcher) + .batch_size(2) + .build(InMemDataset::new(vec![(); 4])); + let dl_valid = DataLoaderBuilder::new(DummyBatcher) + .batch_size(2) + .build(InMemDataset::new(vec![(); 4])); + (dl_train, dl_valid) +} + +#[allow(unused)] +pub fn make_learner(device: &Device) -> ToyLearner { + let model = ToyModel::new(device); + let optim: ModuleOptimizer = SgdConfig::new().init(); + let scheduler = ConstantLr::new(1e-3); + Learner::new(model, optim, scheduler) +} diff --git a/crates/burn-train/tests/lr_policy.rs b/crates/burn-train/tests/lr_policy.rs new file mode 100644 index 0000000..42b3f61 --- /dev/null +++ b/crates/burn-train/tests/lr_policy.rs @@ -0,0 +1,70 @@ +//! Integration tests verifying that LrPolicyScheduler correctly freezes parameter groups. + +mod common; + +use common::*; + +use burn_core::{module::ParamGroup, tensor::Device}; +use burn_optim::{SgdConfig, lr_scheduler::module_lr_scheduler::ModuleLrSchedulerConfig}; +use burn_train::{Learner, SupervisedTraining, logger::InMemoryMetricLogger, metric::LossMetric}; + +/// Test the integration of [LrPolicyScheduler](burn_optim::lr_scheduler::policy::LrPolicyScheduler) in burn-train. +/// A parameter group with LR=0 must not change after training, while parameters using +/// the default LR must change (given a non-zero gradient). +#[test] +fn frozen_group_param_unchanged_after_training() { + let device = Device::flex().autodiff(); + let model = TwoLayerModel::new(&device); + + let before_frozen = model + .frozen + .weight + .val() + .into_data() + .to_vec::() + .unwrap(); + let before_active = model.active.val().into_data().to_vec::().unwrap(); + + let optim = SgdConfig::new().init(); + + let scheduler = ModuleLrSchedulerConfig::new(1e-2.into()) + .with_group(ParamGroup::from_predicate("frozen"), 0.0_f64) + .init() + .unwrap(); + + let (dl_train, dl_valid) = make_dataloaders(); + + let dir = tempfile::tempdir().unwrap(); + let result = SupervisedTraining::new(dir.path(), dl_train, dl_valid) + .num_epochs(1) + .with_metric_logger(InMemoryMetricLogger::new()) + .metric_train_numeric(LossMetric::new()) + .metric_valid_numeric(LossMetric::new()) + .with_application_logger(None) + .launch(Learner::new(model, optim, scheduler)); + + let after_frozen = result + .model + .frozen + .weight + .val() + .into_data() + .to_vec::() + .unwrap(); + let after_active = result + .model + .active + .val() + .into_data() + .to_vec::() + .unwrap(); + + assert_eq!( + before_frozen, after_frozen, + "frozen param (LR=0) must not change after training" + ); + assert_ne!( + before_active, after_active, + "active param (LR=1e-2) must change after training" + ); +} diff --git a/crates/burn-vision/Cargo.toml b/crates/burn-vision/Cargo.toml new file mode 100644 index 0000000..8273914 --- /dev/null +++ b/crates/burn-vision/Cargo.toml @@ -0,0 +1,75 @@ +[package] +authors = [ + "nathanielsimard ", + "wingertge ", +] +categories = ["science"] +description = "Vision processing operations for burn tensors" +documentation = "https://docs.rs/burn-vision" +edition.workspace = true +keywords = ["deep-learning", "machine-learning", "gpu"] +license.workspace = true +name = "burn-vision" +readme.workspace = true +repository = "https://github.com/tracel-ai/burn/tree/main/crates/burn-vision" +version.workspace = true + +[lints] +workspace = true + + +[features] +default = ["burn-core/default", "flex", "cubecl-backend", "fusion", "std"] +std = ["aligned-vec/std", "burn-core/std", "burn-store?/std"] +tracing = [ + "burn-cubecl?/tracing", + "burn-fusion?/tracing", + "burn-ir/tracing", + "burn-core/tracing", + "cubecl/tracing", +] + +cubecl-backend = ["cubecl", "burn-cubecl"] +fusion = ["burn-fusion", "burn-core/fusion"] + +cuda = ["cubecl-backend", "burn-core/cuda"] +rocm = ["cubecl-backend", "burn-core/rocm"] +wgpu = ["cubecl-backend", "burn-core/wgpu"] +vulkan = ["cubecl-backend", "burn-core/vulkan"] +webgpu = ["cubecl-backend", "burn-core/webgpu"] +metal = ["cubecl-backend", "burn-core/metal"] +cpu = ["cubecl-backend", "burn-core/cpu"] +flex = ["burn-core/flex"] +tch = ["burn-core/tch"] + +# TODO: move somewhere else +loss = ["std", "burn-store/pytorch", "burn-core/network", "dirs"] + +[dependencies] +burn-cubecl = { workspace = true, optional = true, features = ["default"]} +burn-fusion = { workspace = true, optional = true, features = ["default"]} +burn-ir = { workspace = true, features = ["default"]} +burn-core = { workspace = true, features = [ + "extension", +] } + +# TODO: requires burn-backend. The `extension` feature should expose this from `burn-core`. + +aligned-vec = { version = "0.6", default-features = false } +bon = { workspace = true } + +# For loss functions requiring pretrained models (e.g., Gram Matrix Loss) +burn-store = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } + +bytemuck = { workspace = true } +cubecl = { workspace = true, optional = true } +derive-new = { workspace = true } +half = { workspace = true } +image = { version = "0.25" } +macerator = { workspace = true } +ndarray = { workspace = true } +num-traits = { workspace = true } +paste = { workspace = true } +serde = { workspace = true } + diff --git a/crates/burn-vision/src/backends/cpu/base.rs b/crates/burn-vision/src/backends/cpu/base.rs new file mode 100644 index 0000000..c295360 --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/base.rs @@ -0,0 +1,42 @@ +pub trait MinMax { + fn min(self, other: Self) -> Self; + fn max(self, other: Self) -> Self; +} + +macro_rules! impl_minmax { + ($ty: ty) => { + impl MinMax for $ty { + fn min(self, other: Self) -> Self { + Ord::min(self, other) + } + fn max(self, other: Self) -> Self { + Ord::max(self, other) + } + } + }; + ($($ty: ty),*) => { + $(impl_minmax!($ty);)* + } +} + +impl_minmax!(u8, i8, u16, i16, u32, i32, u64, i64); + +impl MinMax for f32 { + fn min(self, other: Self) -> Self { + self.min(other) + } + + fn max(self, other: Self) -> Self { + self.max(other) + } +} + +impl MinMax for f64 { + fn min(self, other: Self) -> Self { + self.min(other) + } + + fn max(self, other: Self) -> Self { + self.max(other) + } +} diff --git a/crates/burn-vision/src/backends/cpu/connected_components.rs b/crates/burn-vision/src/backends/cpu/connected_components.rs new file mode 100644 index 0000000..8aac8ac --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components.rs @@ -0,0 +1,249 @@ +use std::{cmp::Ordering, marker::PhantomData}; + +use alloc::vec::Vec; +use burn_core::backend::{ + Backend, TensorMetadata, tensor::{BoolTensor, Device} +}; +use burn_core::tensor::{ + Element, ElementConversion, ElementLimits, ElementOrdered, IntDType, Shape, TensorData, + read_sync, +}; +use ndarray::Array2; + +use crate::{ + ConnectedStatsOptions, ConnectedStatsPrimitive, Connectivity, dispatch_bool_dtype, + dispatch_int_dtype, +}; + +mod spaghetti; +mod spaghetti_4c; + +pub fn connected_components( + img: BoolTensor, + connectivity: Connectivity, + out_dtype: IntDType, +) -> TensorData { + let img = read_sync(B::bool_into_data(img)).expect("Should read data."); + dispatch_bool_dtype!(img.dtype.into(), |B| { + dispatch_int_dtype!(out_dtype, |I| run::>( + img, + connectivity, + NoOp::default + ) + .0) + }) +} + +pub fn connected_components_with_stats( + img: BoolTensor, + connectivity: Connectivity, + _options: ConnectedStatsOptions, + out_dtype: IntDType, +) -> (TensorData, ConnectedStatsPrimitive) { + let device = &img.device(); + let img = read_sync(B::bool_into_data(img)).expect("Should read data."); + dispatch_bool_dtype!(img.dtype.into(), |BT| { + dispatch_int_dtype!(out_dtype, |I| { + let (labels, stats) = + run::>(img, connectivity, ConnectedStatsOp::default); + let stats = finalize_stats::(device, stats); + (labels, stats) + }) + }) +} + +fn run>( + img: TensorData, + connectivity: Connectivity, + stats: impl Fn() -> Stats, +) -> (TensorData, Stats) { + let [height, width] = img.shape.dims(); + let img = img.into_vec::().unwrap(); + + let mut stats = stats(); + + let out = match connectivity { + Connectivity::Four => { + spaghetti_4c::process::>(img, height, width, &mut stats) + } + Connectivity::Eight => { + // SAFETY: This is validated by `TensorData` + let img = unsafe { Array2::from_shape_vec_unchecked((height, width), img) }; + spaghetti::process::>(img, &mut stats) + } + }; + + let (data, _) = out.into_raw_vec_and_offset(); + let labels = TensorData::new(data, Shape::new([height, width])); + (labels, stats) +} + +pub trait Solver { + type Label: ElementOrdered; + + fn init(max_labels: usize) -> Self; + /// Hack to get around mutable borrow limitations on methods + fn merge(label_1: Self::Label, label_2: Self::Label, solver: &mut Self) -> Self::Label; + fn new_label(&mut self) -> Self::Label; + fn flatten(&mut self) -> Self::Label; + fn get_label(&self, i_label: Self::Label) -> Self::Label; +} + +pub(crate) struct UnionFind { + labels: Vec, +} + +impl Solver for UnionFind { + type Label = I; + + fn init(max_labels: usize) -> Self { + let mut labels = Vec::with_capacity(max_labels); + labels.push(0.elem()); + Self { labels } + } + + fn merge(mut label_1: I, mut label_2: I, solver: &mut Self) -> I { + use Ordering::Less; + + while matches!(solver.labels[label_1.to_usize()].cmp(&label_1), Less) { + label_1 = solver.labels[label_1.to_usize()]; + } + + while matches!(solver.labels[label_2.to_usize()].cmp(&label_2), Less) { + label_2 = solver.labels[label_2.to_usize()]; + } + + if matches!(label_1.cmp(&label_2), Less) { + solver.labels[label_2.to_usize()] = label_1; + label_1 + } else { + solver.labels[label_1.to_usize()] = label_2; + label_2 + } + } + + fn new_label(&mut self) -> I { + let len = I::from_elem(self.labels.len()); + self.labels.push(len); + len + } + + fn flatten(&mut self) -> I { + let mut k = 1; + for i in 1..self.labels.len() { + if matches!(self.labels[i].cmp(&I::from_elem(i)), Ordering::Less) { + self.labels[i] = self.labels[self.labels[i].to_usize()]; + } else { + self.labels[i] = k.elem(); + k += 1; + } + } + k.elem() + } + + fn get_label(&self, i_label: I) -> I { + self.labels[i_label.to_usize()] + } +} + +pub trait StatsOp { + type Label; + + fn init(&mut self, num_labels: usize); + fn update(&mut self, row: usize, column: usize, label: Self::Label); + fn finish(&mut self); +} + +#[derive(Default)] +struct NoOp { + _i: PhantomData, +} + +impl StatsOp for NoOp { + type Label = I; // placeholder still required + + fn init(&mut self, _num_labels: usize) {} + + fn update(&mut self, _row: usize, _column: usize, _label: Self::Label) {} + + fn finish(&mut self) {} +} + +#[derive(Default, Debug)] +struct ConnectedStatsOp { + pub area: Vec, + pub left: Vec, + pub top: Vec, + pub right: Vec, + pub bottom: Vec, +} + +impl StatsOp for ConnectedStatsOp { + type Label = I; + + fn init(&mut self, num_labels: usize) { + self.area = vec![0.elem(); num_labels]; + self.left = vec![I::MAX; num_labels]; + self.top = vec![I::MAX; num_labels]; + self.right = vec![0.elem(); num_labels]; + self.bottom = vec![0.elem(); num_labels]; + } + + fn update(&mut self, row: usize, column: usize, label: I) { + let l = label.to_usize(); + unsafe { + *self.area.get_unchecked_mut(l) = + I::from_elem((*self.area.get_unchecked(l)).to_usize() + 1); + *self.left.get_unchecked_mut(l) = + I::from_elem((*self.left.get_unchecked(l)).to_usize().min(column)); + *self.top.get_unchecked_mut(l) = + I::from_elem((*self.top.get_unchecked(l)).to_usize().min(row)); + *self.right.get_unchecked_mut(l) = + I::from_elem((*self.right.get_unchecked(l)).to_usize().max(column)); + *self.bottom.get_unchecked_mut(l) = + I::from_elem((*self.bottom.get_unchecked(l)).to_usize().max(row)); + } + } + + fn finish(&mut self) { + // Background shouldn't have stats + self.area[0] = 0.elem(); + self.left[0] = 0.elem(); + self.right[0] = 0.elem(); + self.top[0] = 0.elem(); + self.bottom[0] = 0.elem(); + } +} + +fn finalize_stats( + device: &Device, + stats: ConnectedStatsOp, +) -> ConnectedStatsPrimitive { + let labels = stats.area.len(); + + let into_prim = |data: Vec| { + let data = TensorData::new(data, Shape::new([labels])); + B::int_from_data(data, device) + }; + + let max_label = { + let data = TensorData::new(vec![I::from_elem(labels - 1)], Shape::new([1])); + B::int_from_data(data, device) + }; + + ConnectedStatsPrimitive { + area: into_prim(stats.area), + left: into_prim(stats.left), + top: into_prim(stats.top), + right: into_prim(stats.right), + bottom: into_prim(stats.bottom), + max_label, + } +} + +pub fn max_labels(h: usize, w: usize, conn: Connectivity) -> usize { + match conn { + Connectivity::Four => (h * w).div_ceil(2) + 1, + Connectivity::Eight => h.div_ceil(2) * w.div_ceil(2) + 1, + } +} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_center_line_forest_code.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_center_line_forest_code.rs new file mode 100644 index 0000000..6b6a796 --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_center_line_forest_code.rs @@ -0,0 +1,1954 @@ +no_analyze!{{ +use centerLabels::*;let mut label = entry; +while let Some(next) = (|label| -> Option { match label { + NODE_1=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_2); + } + else { + return Some(NODE_3); + } + } + NODE_3=> { + if (*img_row01.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_2); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(cl_tree_1); + } + } + NODE_4=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_5); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_3); + } + } + } + NODE_6=> { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_2); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_1); + } + } + NODE_2=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + return Some(NODE_4); + } + } + NODE_7=> { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_5); + } + } + NODE_5=> { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + } + NODE_8=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + else { + return Some(NODE_9); + } + } + NODE_10=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_11); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_8); + } + else { + return Some(NODE_11); + } + } + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_12); + } + else { + return Some(NODE_12); + } + } + } + } + NODE_11=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_4); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_3); + } + } + NODE_13=> { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(cl_tree_11); + } + } + NODE_9=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + } + else { + return Some(NODE_11); + } + } + NODE_12=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_10); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_9); + } + } + NODE_14=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_4); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_10); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_9); + } + } + } + } + NODE_15=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_13); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_5); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_7); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(cl_tree_3); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_3); + } + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_10); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(cl_tree_9); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_9); + } + } + } + } + } + NODE_16=> { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_8); + } + else { + return Some(NODE_2); + } + } + else { + return Some(NODE_17); + } + } + else { + return Some(NODE_1); + } + } + NODE_18=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + NODE_19=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + return Some(NODE_20); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_8); + } + } + NODE_21=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + if (*img_row11.add((c + 2) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_3); + } + } + } + else { + return Some(NODE_3); + } + } + NODE_22=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + } + NODE_23=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + } + NODE_24=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + NODE_17=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_7); + } + } + NODE_25=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_18); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_8); + } + } + NODE_20=> { + if (*img_row12.add((c + 1) as usize)).to_bool() { + return Some(NODE_26); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + NODE_27=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + NODE_28=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_22); + } + else { + return Some(NODE_19); + } + } + NODE_26=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + } + NODE_29=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_8); + } + } + NODE_30=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_8); + } + } + NODE_31=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_23); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_19); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_12); + } + } + } + NODE_32=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_33); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_34); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_35); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_3); + } + } + } + NODE_36=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + return Some(NODE_33); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_34); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_35); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_3); + } + } + } + NODE_37=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_18); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_8); + } + } + NODE_33=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + return Some(NODE_26); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(cl_tree_5); + } + } + NODE_38=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + return Some(NODE_22); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + NODE_39=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_10); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_10); + } + } + NODE_35=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_4); + } + } + NODE_40=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + return Some(NODE_23); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + NODE_34=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_5); + } + } +cl_tree_0 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_0); } else { return Some(cl_break_1_0); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_14); + } + else { + return Some(NODE_6); + } +} +cl_tree_1 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_1); } else { return Some(cl_break_1_1); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_15); + } + else { + return Some(NODE_6); + } +} +cl_tree_2 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_2); } else { return Some(cl_break_1_2); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_10); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_8); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_1); + } + } +} +cl_tree_3 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_3); } else { return Some(cl_break_1_3); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_29); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_12); + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + else { + return Some(NODE_29); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_21); + } + } +} +cl_tree_4 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_3); } else { return Some(cl_break_1_4); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_27); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_25); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_12); + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_24); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + else { + return Some(NODE_25); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_21); + } + } +} +cl_tree_5 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_3); } else { return Some(cl_break_1_5); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_30); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_12); + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + return Some(NODE_30); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_6); + } + else { + if (*img_row11.add((c + 2) as usize)).to_bool() { + return Some(NODE_5); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + } + } + else { + return Some(NODE_3); + } + } + } +} +cl_tree_6 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_3); } else { return Some(cl_break_1_6); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_31); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_28); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_1); + } + } +} +cl_tree_7 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_4); } else { return Some(cl_break_1_7); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_10); + } + else { + return Some(NODE_15); + } + } + else { + return Some(NODE_16); + } +} +cl_tree_8 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_3); } else { return Some(cl_break_1_8); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_27); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_37); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_12); + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_24); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + else { + return Some(NODE_37); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + } + else { + return Some(NODE_21); + } + } +} +cl_tree_9 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_5); } else { return Some(cl_break_1_9); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_9); + } + else { + return Some(NODE_12); + } + } + } + else { + return Some(NODE_14); + } + } + else { + return Some(NODE_16); + } +} +cl_tree_10 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_6); } else { return Some(cl_break_1_10); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_40); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_36); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_39); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_9); + } + } + } + } + else { + return Some(NODE_14); + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_38); + } + else { + return Some(NODE_36); + } + } + else { + return Some(NODE_2); + } + } + else { + return Some(NODE_17); + } + } + else { + return Some(NODE_1); + } + } +} +cl_tree_11 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_7); } else { return Some(cl_break_1_11); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_31); + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_31); + } + else { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_11); + } + else { + return Some(NODE_13); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_5); + } + else { + return Some(NODE_7); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(cl_tree_3); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_10); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(cl_tree_9); + } + } + } + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_28); + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_22); + } + else { + if (*img_row11.add((c + 2) as usize)).to_bool() { + return Some(NODE_20); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(cl_tree_4); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_3); + } + } + } + } + else { + return Some(NODE_2); + } + } + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + else { + if (*img_row00.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_7); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(cl_tree_7); + } + } + } + } + else { + return Some(NODE_1); + } + } +} +cl_tree_12 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(cl_break_0_8); } else { return Some(cl_break_1_12); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_40); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_11); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_32); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_39); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_10); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(cl_tree_9); + } + } + } + } + else { + return Some(NODE_14); + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_38); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(cl_tree_6); + } + } + else { + return Some(NODE_32); + } + } + else { + return Some(NODE_2); + } + } + else { + return Some(NODE_17); + } + } + else { + return Some(NODE_1); + } + } +} + NODE_41=> { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + } + else { + return Some(NODE_42); + } + } + NODE_43=> { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + NODE_42=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_44=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + NODE_45=> { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + NODE_46=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + NODE_47=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + NODE_48=> { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } +cl_break_0_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_47); + } + else { + return Some(NODE_48); + } + return None;} +cl_break_0_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_44); + } + else { + return Some(NODE_48); + } + return None;} +cl_break_0_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_41); + } + else { + return Some(NODE_43); + } + return None;} +cl_break_0_3 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + return Some(NODE_43); + } + return None;} +cl_break_0_4 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_41); + } + else { + return Some(NODE_44); + } + } + else { + return Some(NODE_45); + } + return None;} +cl_break_0_5 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_42); + } + else { + return Some(NODE_47); + } + } + else { + return Some(NODE_45); + } + return None;} +cl_break_0_6 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_46); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_47); + } + } + else { + return Some(NODE_45); + } + return None;} +cl_break_0_7 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row00.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + return None;} +cl_break_0_8 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_46); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_47); + } + } + else { + return Some(NODE_45); + } + return None;} + NODE_49=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + NODE_50=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_49); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_51=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_52); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_52=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + } + NODE_53=> { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_54); + } + else { + return Some(NODE_55); + } + } + else { + return Some(NODE_56); + } + } + NODE_55=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + NODE_54=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_57); + } + else { + return Some(NODE_58); + } + } + NODE_59=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + else { + return Some(NODE_60); + } + } + NODE_61=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_62=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + return Some(NODE_63); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_49); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + } + else { + return Some(NODE_58); + } + } + NODE_63=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + return Some(NODE_52); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + NODE_64=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_65); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_65=> { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_49); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + NODE_66=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_63); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_65); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + } + else { + return Some(NODE_58); + } + } + NODE_67=> { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_58); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + else { + return Some(NODE_56); + } + } + NODE_56=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_58); + } + else { + return Some(NODE_60); + } + } + NODE_58=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + NODE_68=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver), *img_labels_row12.add((c - 2) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + } + else { + return Some(NODE_69); + } + } + } + NODE_70=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_71); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + } + NODE_57=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + return Some(NODE_69); + } + } + NODE_60=> { + if (*img_row01.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + NODE_71=> { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + } + } + NODE_69=> { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } +cl_break_1_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_58); + } + else { + return Some(NODE_67); + } + return None;} +cl_break_1_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_70); + } + else { + return Some(NODE_67); + } + return None;} +cl_break_1_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_68); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_57); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_56); + } + } + return None;} +cl_break_1_3 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_61); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_61); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_59); + } + } + return None;} +cl_break_1_4 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_50); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_50); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_59); + } + } + return None;} +cl_break_1_5 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + return Some(NODE_60); + } + } + } + return None;} +cl_break_1_6 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_51); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_51); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_56); + } + } + return None;} +cl_break_1_7 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_68); + } + else { + return Some(NODE_70); + } + } + else { + return Some(NODE_53); + } + return None;} +cl_break_1_8 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_64); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_64); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_59); + } + } + return None;} +cl_break_1_9 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_54); + } + else { + return Some(NODE_53); + } + return None;} +cl_break_1_10 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_62); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_62); + } + else { + return Some(NODE_55); + } + } + else { + return Some(NODE_56); + } + } + return None;} +cl_break_1_11 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_51); + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_51); + } + else { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + return Some(NODE_71); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + } + } + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_51); + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_51); + } + else { + return Some(NODE_58); + } + } + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row00.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + } + else { + return Some(NODE_56); + } + } + return None;} +cl_break_1_12 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_66); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_66); + } + else { + return Some(NODE_55); + } + } + else { + return Some(NODE_56); + } + } + return None;} + }; None})(label) +{ +label = next; +} +}} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_first_line_forest_code.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_first_line_forest_code.rs new file mode 100644 index 0000000..6524b28 --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_first_line_forest_code.rs @@ -0,0 +1,223 @@ +no_analyze!{{ +use firstLabels::*;let mut label = entry; +while let Some(next) = (|label| -> Option { match label { + NODE_72=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_2); + } + } + NODE_73=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(fl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(fl_tree_2); + } + } + NODE_74=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_1); + } + else { + if (*img_row01.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(fl_tree_0); + } + } + } +fl_tree_0 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(fl_break_0_0); } else { return Some(fl_break_1_0); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_72); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + return Some(NODE_72); + } + else { + return Some(NODE_74); + } + } +} +fl_tree_1 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(fl_break_0_1); } else { return Some(fl_break_1_1); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_73); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + return Some(NODE_73); + } + else { + return Some(NODE_74); + } + } +} +fl_tree_2 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(fl_break_0_2); } else { return Some(fl_break_1_2); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + return Some(NODE_73); + } + else { + return Some(NODE_72); + } + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(fl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_1); + } + } + else { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(fl_tree_2); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(fl_tree_2); + } + } + } + else { + return Some(NODE_74); + } + } +} + NODE_75=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } +fl_break_0_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + return None;} +fl_break_0_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + return None;} +fl_break_0_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_75); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + return Some(NODE_75); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + return None;} + NODE_76=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + if (*img_row01.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + } + NODE_77=> { + if (*img_row01.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } +fl_break_1_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + return Some(NODE_76); + } + } + return None;} +fl_break_1_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + return Some(NODE_76); + } + } + return None;} +fl_break_1_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_77); + } + else { + if (*img_row01.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_77); + } + else { + return Some(NODE_77); + } + } + else { + return Some(NODE_76); + } + } + return None;} +fl_ => {}, + }; None})(label) +{ +label = next; +} +}} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_forest_labels.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_forest_labels.rs new file mode 100644 index 0000000..6c994fc --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_forest_labels.rs @@ -0,0 +1,191 @@ +/// Workaround for rust-analyzer bug that causes invalid errors on the `include!`. +macro_rules! no_analyze { + ($tokens:tt) => { + $tokens + }; +} + +pub(crate) use no_analyze; + +#[allow(non_snake_case, non_camel_case_types, unused)] +pub enum centerLabels { + NODE_1, + NODE_2, + NODE_3, + NODE_4, + NODE_5, + NODE_6, + NODE_7, + NODE_8, + NODE_9, + NODE_10, + NODE_11, + NODE_12, + NODE_13, + NODE_14, + NODE_15, + NODE_16, + NODE_17, + NODE_18, + NODE_19, + NODE_20, + NODE_21, + NODE_22, + NODE_23, + NODE_24, + NODE_25, + NODE_26, + NODE_27, + NODE_28, + NODE_29, + NODE_30, + NODE_31, + NODE_32, + NODE_33, + NODE_34, + NODE_35, + NODE_36, + NODE_37, + NODE_38, + NODE_39, + NODE_40, + NODE_41, + NODE_42, + NODE_43, + NODE_44, + NODE_45, + NODE_46, + NODE_47, + NODE_48, + NODE_49, + NODE_50, + NODE_51, + NODE_52, + NODE_53, + NODE_54, + NODE_55, + NODE_56, + NODE_57, + NODE_58, + NODE_59, + NODE_60, + NODE_61, + NODE_62, + NODE_63, + NODE_64, + NODE_65, + NODE_66, + NODE_67, + NODE_68, + NODE_69, + NODE_70, + NODE_71, + cl_tree_0, + cl_tree_1, + cl_tree_2, + cl_tree_3, + cl_tree_4, + cl_tree_5, + cl_tree_6, + cl_tree_7, + cl_tree_8, + cl_tree_9, + cl_tree_10, + cl_tree_11, + cl_tree_12, + cl_break_0_0, + cl_break_0_1, + cl_break_0_2, + cl_break_0_3, + cl_break_0_4, + cl_break_0_5, + cl_break_0_6, + cl_break_0_7, + cl_break_0_8, + cl_break_1_0, + cl_break_1_1, + cl_break_1_2, + cl_break_1_3, + cl_break_1_4, + cl_break_1_5, + cl_break_1_6, + cl_break_1_7, + cl_break_1_8, + cl_break_1_9, + cl_break_1_10, + cl_break_1_11, + cl_break_1_12, +} + +#[allow(non_snake_case, non_camel_case_types, unused)] +pub enum firstLabels { + NODE_72, + NODE_73, + NODE_74, + NODE_75, + NODE_76, + NODE_77, + fl_tree_0, + fl_tree_1, + fl_tree_2, + fl_break_0_0, + fl_break_0_1, + fl_break_0_2, + fl_break_1_0, + fl_break_1_1, + fl_break_1_2, + fl_, +} + +#[allow(non_snake_case, non_camel_case_types, unused)] +pub enum lastLabels { + NODE_78, + NODE_79, + NODE_80, + NODE_81, + NODE_82, + NODE_83, + NODE_84, + NODE_85, + NODE_86, + NODE_87, + NODE_88, + NODE_89, + NODE_90, + NODE_91, + NODE_92, + ll_tree_0, + ll_tree_1, + ll_tree_2, + ll_tree_3, + ll_tree_4, + ll_tree_5, + ll_tree_6, + ll_tree_7, + ll_break_0_0, + ll_break_0_1, + ll_break_0_2, + ll_break_0_3, + ll_break_1_0, + ll_break_1_1, + ll_break_1_2, + ll_break_1_3, + ll_break_1_4, + ll_break_1_5, + ll_break_1_6, + ll_break_1_7, + ll_, +} + +#[allow(non_snake_case, non_camel_case_types, unused)] +pub enum singleLabels { + NODE_93, + NODE_94, + sl_tree_0, + sl_tree_1, + sl_break_0_0, + sl_break_0_1, + sl_break_1_0, + sl_break_1_1, + sl_, +} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_last_line_forest_code.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_last_line_forest_code.rs new file mode 100644 index 0000000..9187a4c --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_last_line_forest_code.rs @@ -0,0 +1,787 @@ +no_analyze!{{ +use lastLabels::*;let mut label = entry; +while let Some(next) = (|label| -> Option { match label { + NODE_78=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(ll_tree_4); + } + } + NODE_79=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(ll_tree_6); + } + } + NODE_80=> { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(ll_tree_6); + } + } + NODE_81=> { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_82); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_3); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(ll_tree_2); + } + } + } + NODE_83=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_5); + } + else { + if (*img_row11.add((c + 2) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(ll_tree_2); + } + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(ll_tree_1); + } + } + NODE_84=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_5); + } + else { + return Some(NODE_81); + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(ll_tree_1); + } + } + NODE_82=> { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_4); + } + } + NODE_85=> { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c + 2) as usize), *img_labels_row12.add((c - 2) as usize), solver); + return Some(ll_tree_4); + } + } + NODE_86=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_6); + } + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_7); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } +ll_tree_0 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_0); } else { return Some(ll_break_1_0); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_81); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_0); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(ll_tree_0); + } + } + } + } + else { + return Some(NODE_84); + } +} +ll_tree_1 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_1); } else { return Some(ll_break_1_1); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_80); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_82); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_85); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_3); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(ll_tree_2); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(ll_tree_2); + } + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_0); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(ll_tree_0); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(ll_tree_0); + } + } + } + } + } + else { + return Some(NODE_84); + } +} +ll_tree_2 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_2); } else { return Some(ll_break_1_2); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_6); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_7); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } + else { + return Some(NODE_83); + } +} +ll_tree_3 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_2); } else { return Some(ll_break_1_3); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_79); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_6); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_78); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_7); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } + else { + return Some(NODE_83); + } +} +ll_tree_4 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_2); } else { return Some(ll_break_1_4); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c + 2) as usize); + return Some(ll_tree_4); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_7); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_5); + } + else { + if (*img_row11.add((c + 2) as usize)).to_bool() { + return Some(NODE_82); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_3); + } + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(ll_tree_1); + } + } +} +ll_tree_5 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_2); } else { return Some(ll_break_1_5); } } + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_86); + } + else { + return Some(NODE_84); + } +} +ll_tree_6 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_3); } else { return Some(ll_break_1_6); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_86); + } + else { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_6); + } + else { + return Some(NODE_80); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + return Some(NODE_82); + } + else { + return Some(NODE_85); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_3); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(ll_tree_2); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + return Some(ll_tree_0); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } + } + } + else { + return Some(NODE_84); + } +} +ll_tree_7 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(ll_break_0_2); } else { return Some(ll_break_1_7); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_79); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_6); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + return Some(ll_tree_6); + } + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 2) as usize)).to_bool() { + if (*img_row12.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_78); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c + 2) as usize), solver); + return Some(ll_tree_4); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_7); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(ll_tree_0); + } + } + } + else { + return Some(NODE_83); + } +} +ll_break_0_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} +ll_break_0_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} +ll_break_0_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} +ll_break_0_3 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} + NODE_87=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + return Some(NODE_88); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + NODE_88=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + NODE_89=> { + if (*img_row12.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + } + } + NODE_90=> { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + NODE_91=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + NODE_92=> { + if (*img_row12.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row12.add((c) as usize), *img_labels_row12.add((c - 2) as usize), solver); + } + } +ll_break_1_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_88); + } + else { + return Some(NODE_87); + } + return None;} +ll_break_1_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + return Some(NODE_92); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + if (*img_row11.add((c - 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + } + } + } + else { + return Some(NODE_87); + } + return None;} +ll_break_1_2 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_91); + } + return None;} +ll_break_1_3 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + return Some(NODE_89); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_91); + } + return None;} +ll_break_1_4 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } + return None;} +ll_break_1_5 => { + if (*img_row00.add((c) as usize)).to_bool() { + return Some(NODE_90); + } + else { + return Some(NODE_87); + } + return None;} +ll_break_1_6 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c - 1) as usize)).to_bool() { + return Some(NODE_90); + } + else { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + return Some(NODE_92); + } + } + else { + if (*img_row11.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c) as usize); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row12.add((c - 2) as usize); + } + } + } + } + else { + return Some(NODE_87); + } + return None;} +ll_break_1_7 => { + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row11.add((c + 1) as usize)).to_bool() { + if (*img_row12.add((c) as usize)).to_bool() { + if (*img_row11.add((c - 2) as usize)).to_bool() { + return Some(NODE_89); + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = LabelsSolver::merge(*img_labels_row00.add((c - 2) as usize), *img_labels_row12.add((c) as usize), solver); + } + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + } + else { + return Some(NODE_91); + } + return None;} +ll_ => {}, + }; None})(label) +{ +label = next; +} +}} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_single_line_forest_code.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_single_line_forest_code.rs new file mode 100644 index 0000000..bc36ac7 --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/Spaghetti_single_line_forest_code.rs @@ -0,0 +1,91 @@ +no_analyze!{{ +use singleLabels::*;let mut label = entry; +while let Some(next) = (|label| -> Option { match label { + NODE_93=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(sl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + return Some(sl_tree_0); + } + } +sl_tree_0 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(sl_break_0_0); } else { return Some(sl_break_1_0); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(sl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = solver.new_label(); + return Some(sl_tree_0); + } + } + else { + return Some(NODE_93); + } +} +sl_tree_1 => { +if ({c+=2; c}) >= w - 2 { if c > w - 2 { return Some(sl_break_0_1); } else { return Some(sl_break_1_1); } } + if (*img_row00.add((c) as usize)).to_bool() { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(sl_tree_1); + } + else { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + return Some(sl_tree_0); + } + } + else { + return Some(NODE_93); + } +} +sl_break_0_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} +sl_break_0_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + return None;} + NODE_94=> { + if (*img_row00.add((c + 1) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + *img_labels_row00.add(c as usize) = 0.elem(); + } + } +sl_break_1_0 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = solver.new_label(); + } + else { + return Some(NODE_94); + } + return None;} +sl_break_1_1 => { + if (*img_row00.add((c) as usize)).to_bool() { + *img_labels_row00.add(c as usize) = *img_labels_row00.add((c - 2) as usize); + } + else { + return Some(NODE_94); + } + return None;} +sl_ => {}, + }; None})(label) +{ +label = next; +} +}} diff --git a/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/mod.rs b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/mod.rs new file mode 100644 index 0000000..f8db796 --- /dev/null +++ b/crates/burn-vision/src/backends/cpu/connected_components/spaghetti/mod.rs @@ -0,0 +1,273 @@ +//! Spaghetti algorithm for connected component labeling +//! F. Bolelli, S. Allegretti, L. Baraldi, and C. Grana, +//! "Spaghetti Labeling: Directed Acyclic Graphs for Block-Based Bonnected Components Labeling," +//! IEEE Transactions on Image Processing, vol. 29, no. 1, pp. 1999-2012, 2019. +//! +//! Decision forests are generated using a modified [GRAPHGEN](https://github.com/wingertge/GRAPHGEN) +//! as described in +//! +//! F. Bolelli, S. Allegretti, C. Grana. +//! "One DAG to Rule Them All." +//! IEEE Transactions on Pattern Analysis and Machine Intelligence, 2021 + +#![allow( + unreachable_code, + clippy::collapsible_else_if, + clippy::if_same_then_else +)] + +use std::cmp::Ordering; + +use burn_core::tensor::{Element, ElementComparison, ElementConversion, cast::ToElement}; +use ndarray::{Array2, Axis, s}; + +#[allow(non_snake_case)] +mod Spaghetti_forest_labels; +pub(crate) use Spaghetti_forest_labels::*; + +use crate::Connectivity; + +use super::{Solver, StatsOp, max_labels}; + +pub fn process( + img_arr: Array2, + stats: &mut impl StatsOp

{ + // Only the inner policy's parameters are persisted; the exploration `step` counter resets on + // load (it is part of the exploration schedule, not the learned parameters). + type Record = ::Record; + + fn into_record(self) -> Self::Record { + self.inner_state.into_record() + } + + fn load_record(&self, record: Self::Record) -> Self { + Self { + inner_state: self.inner_state.load_record(record), + step: self.step, + } + } +} + +#[derive(Clone, Debug)] +pub struct EpsilonGreedyPolicy { + inner_policy: P, + eps_start: f64, + eps_end: f64, + eps_decay: f64, + step: usize, +} + +impl EpsilonGreedyPolicy

{ + pub fn new(inner_policy: P, eps_start: f64, eps_end: f64, eps_decay: f64) -> Self { + Self { + inner_policy, + eps_start, + eps_end, + eps_decay, + step: 0, + } + } + + pub fn inner_policy(&self) -> P { + self.inner_policy.clone() + } + + pub fn set_inner_policy(&mut self, policy: P) { + self.inner_policy = policy; + } + + fn get_threshold(&self) -> f64 { + self.eps_end + + (self.eps_start - self.eps_end) * f64::exp(-(self.step as f64) / self.eps_decay) + } + + fn step(&mut self) -> f64 { + let thresh = self.get_threshold(); + self.step += 1; + thresh + } +} + +impl

Policy for EpsilonGreedyPolicy

+where + P: Policy, Action = DiscreteActionTensor<2>>, +{ + type ActionContext = EpsilonGreedyPolicyOutput; + type PolicyState = EpsilonGreedyPolicyState

; + + type Observation = P::Observation; + type ActionDistribution = DiscreteLogitsTensor<2>; + type Action = DiscreteActionTensor<2>; + + fn forward(&mut self, states: Self::Observation) -> Self::ActionDistribution { + self.inner_policy.forward(states) + } + + fn action( + &mut self, + states: Self::Observation, + deterministic: bool, + ) -> (Self::Action, Vec) { + let logits = self.inner_policy.forward(states).logits; + let greedy_actions = logits.argmax(1); + let greedy_actions = greedy_actions.split(1, 0); + + let mut contexts = vec![]; + let mut actions = vec![]; + for a in greedy_actions { + let threshold = self.step(); + let threshold = if deterministic { 0.0 } else { threshold }; + contexts.push(EpsilonGreedyPolicyOutput { epsilon: threshold }); + if random::() > threshold { + actions.push(a.clone().float().inner()); + } else { + actions + .push(Tensor::<1>::from_floats([random_range(0..2)], &a.device()).unsqueeze()); + } + } + + let output = Tensor::cat(actions, 0); + (DiscreteActionTensor { actions: output }, contexts) + } + + fn update(&mut self, update: Self::PolicyState) { + // Note : updating an epsilon greedy policy doesn't change the step. + self.inner_policy.update(update.inner_state); + } + + fn state(&self) -> Self::PolicyState { + EpsilonGreedyPolicyState { + inner_state: self.inner_policy.state(), + step: self.step, + } + } + + fn to_device(self, device: &Device) -> Self { + let mut policy = self.clone(); + let inner = policy.inner_policy().to_device(device); + policy.set_inner_policy(inner); + policy + } + + fn load_record(self, record: ::Record) -> Self { + let state = self.state().load_record(record); + let inner_policy = self + .inner_policy + .load_record(state.inner_state.into_record()); + EpsilonGreedyPolicy { + inner_policy, + eps_start: self.eps_start, + eps_end: self.eps_end, + eps_decay: self.eps_decay, + step: state.step, + } + } +} diff --git a/examples/guide/Cargo.toml b/examples/guide/Cargo.toml new file mode 100644 index 0000000..6797d66 --- /dev/null +++ b/examples/guide/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = ["nathanielsimard "] +edition.workspace = true +license.workspace = true +name = "guide" +publish = false +version.workspace = true + +[lints] +workspace = true + +[features] +default = ["burn/default", "burn/tui"] +# Opt-in for macOS users with the Vulkan SDK installed (enabled by default on other platforms) +vulkan = ["burn/vulkan"] + +[dependencies] +burn = { workspace = true, features = [ + "wgpu", + "train", + "vision", +] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +burn = { workspace = true, features = ["default", "vulkan"] } + +log = { workspace = true } diff --git a/examples/guide/README.md b/examples/guide/README.md new file mode 100644 index 0000000..6e107e6 --- /dev/null +++ b/examples/guide/README.md @@ -0,0 +1,24 @@ +# Basic Workflow: From Training to Inference + +This example corresponds to the [book's guide](https://burn.dev/books/burn/basic-workflow/). + +## Example Usage + + +### Training + +```sh +cargo run --bin train --release +``` + +### Inference + +```sh +cargo run --bin infer --release +``` + +### Print the model + +```sh +cargo run --bin print --release +``` diff --git a/examples/guide/examples/guide.rs b/examples/guide/examples/guide.rs new file mode 100644 index 0000000..bbac496 --- /dev/null +++ b/examples/guide/examples/guide.rs @@ -0,0 +1,16 @@ +// +// Note: If you are following the Burn Book guide this file can be ignored. +// +// This example file is added only for convenience and consistency so that +// the guide example can be executed like any other examples using: +// +// cargo run --release --example guide +// +use std::process::Command; + +fn main() { + Command::new("cargo") + .args(["run", "--bin", "train", "--release"]) + .status() + .expect("guide example should run"); +} diff --git a/examples/guide/src/bin/infer.rs b/examples/guide/src/bin/infer.rs new file mode 100644 index 0000000..95f5da6 --- /dev/null +++ b/examples/guide/src/bin/infer.rs @@ -0,0 +1,19 @@ +#![recursion_limit = "131"] +use burn::{data::dataset::Dataset, prelude::*}; +use guide::inference; + +fn main() { + let device = Device::wgpu(DeviceKind::DefaultDevice); + + // All the training artifacts are saved in this directory + let artifact_dir = "/tmp/guide"; + + // Infer the model + inference::infer( + artifact_dir, + device, + burn::data::dataset::vision::MnistDataset::test() + .get(42) + .unwrap(), + ); +} diff --git a/examples/guide/src/bin/print.rs b/examples/guide/src/bin/print.rs new file mode 100644 index 0000000..4fac526 --- /dev/null +++ b/examples/guide/src/bin/print.rs @@ -0,0 +1,9 @@ +use burn::prelude::*; +use guide::model::ModelConfig; + +fn main() { + let device = Device::wgpu(DeviceKind::DefaultDevice); + let model = ModelConfig::new(10, 512).init(&device); + + println!("{model}"); +} diff --git a/examples/guide/src/bin/train.rs b/examples/guide/src/bin/train.rs new file mode 100644 index 0000000..0688238 --- /dev/null +++ b/examples/guide/src/bin/train.rs @@ -0,0 +1,31 @@ +#![recursion_limit = "131"] +use burn::{data::dataset::Dataset, optim::AdamConfig, prelude::*}; +use guide::{ + inference, + model::ModelConfig, + training::{self, TrainingConfig}, +}; + +fn main() { + // Create a default Wgpu-backed device. + let device = Device::wgpu(DeviceKind::DefaultDevice); + + // All the training artifacts will be saved in this directory + let artifact_dir = "target/guide"; + + // Train the model + training::train( + artifact_dir, + TrainingConfig::new(ModelConfig::new(10, 512), AdamConfig::new()), + device.clone(), + ); + + // Infer the model + inference::infer( + artifact_dir, + device, + burn::data::dataset::vision::MnistDataset::test() + .get(42) + .unwrap(), + ); +} diff --git a/examples/guide/src/data.rs b/examples/guide/src/data.rs new file mode 100644 index 0000000..a162c26 --- /dev/null +++ b/examples/guide/src/data.rs @@ -0,0 +1,38 @@ +use burn::{ + data::{dataloader::batcher::Batcher, dataset::vision::MnistItem}, + prelude::*, +}; + +#[derive(Clone, Default)] +pub struct MnistBatcher {} + +#[derive(Clone, Debug)] +pub struct MnistBatch { + pub images: Tensor<3>, + pub targets: Tensor<1, Int>, +} + +impl Batcher for MnistBatcher { + fn batch(&self, items: Vec, device: &Device) -> MnistBatch { + let images = items + .iter() + .map(|item| TensorData::from(item.image)) + .map(|data| Tensor::<2>::from_data(data, device)) + .map(|tensor| tensor.reshape([1, 28, 28])) + // Normalize: scale between [0,1] and make the mean=0 and std=1 + // values mean=0.1307,std=0.3081 are from the PyTorch MNIST example + // https://github.com/pytorch/examples/blob/54f4572509891883a947411fd7239237dd2a39c3/mnist/main.py#L122 + .map(|tensor| ((tensor / 255) - 0.1307) / 0.3081) + .collect(); + + let targets = items + .iter() + .map(|item| Tensor::<1, Int>::from_data([item.label as i64], device)) + .collect(); + + let images = Tensor::cat(images, 0); + let targets = Tensor::cat(targets, 0); + + MnistBatch { images, targets } + } +} diff --git a/examples/guide/src/inference.rs b/examples/guide/src/inference.rs new file mode 100644 index 0000000..d63aa46 --- /dev/null +++ b/examples/guide/src/inference.rs @@ -0,0 +1,24 @@ +use crate::{data::MnistBatcher, training::TrainingConfig}; +use burn::{ + data::{dataloader::batcher::Batcher, dataset::vision::MnistItem}, + prelude::*, + store::ModuleRecord, +}; + +pub fn infer(artifact_dir: &str, device: impl Into, item: MnistItem) { + let device = device.into(); + let config = TrainingConfig::load(format!("{artifact_dir}/config.json")) + .expect("Config should exist for the model; run train first"); + let record = ModuleRecord::load(format!("{artifact_dir}/model")) + .expect("Trained model should exist; run train first"); + + let model = config.model.init(&device).load_record(record); + + let label = item.label; + let batcher = MnistBatcher::default(); + let batch = batcher.batch(vec![item], &device); + let output = model.forward(batch.images); + let predicted: u8 = output.argmax(1).flatten::<1>(0, 1).into_scalar(); + + println!("Predicted {predicted} Expected {label}"); +} diff --git a/examples/guide/src/lib.rs b/examples/guide/src/lib.rs new file mode 100644 index 0000000..a56bdbe --- /dev/null +++ b/examples/guide/src/lib.rs @@ -0,0 +1,10 @@ +// +// Note: If you are following the Burn Book guide this file can be ignored. +// +// This lib.rs file is added only for convenience so that the code in this +// guide can be reused. +// +pub mod data; +pub mod inference; +pub mod model; +pub mod training; diff --git a/examples/guide/src/model.rs b/examples/guide/src/model.rs new file mode 100644 index 0000000..adaba2a --- /dev/null +++ b/examples/guide/src/model.rs @@ -0,0 +1,68 @@ +use burn::{ + nn::{ + Dropout, DropoutConfig, Linear, LinearConfig, Relu, + conv::{Conv2d, Conv2dConfig}, + pool::{AdaptiveAvgPool2d, AdaptiveAvgPool2dConfig}, + }, + prelude::*, +}; + +#[derive(Module, Debug)] +pub struct Model { + conv1: Conv2d, + conv2: Conv2d, + pool: AdaptiveAvgPool2d, + dropout: Dropout, + linear1: Linear, + linear2: Linear, + activation: Relu, +} + +#[derive(Config, Debug)] +pub struct ModelConfig { + num_classes: usize, + hidden_size: usize, + #[config(default = "0.5")] + dropout: f64, +} + +impl ModelConfig { + /// Returns the initialized model. + pub fn init(&self, device: &Device) -> Model { + Model { + conv1: Conv2dConfig::new([1, 8], [3, 3]).init(device), + conv2: Conv2dConfig::new([8, 16], [3, 3]).init(device), + pool: AdaptiveAvgPool2dConfig::new([8, 8]).init(), + activation: Relu::new(), + linear1: LinearConfig::new(16 * 8 * 8, self.hidden_size).init(device), + linear2: LinearConfig::new(self.hidden_size, self.num_classes).init(device), + dropout: DropoutConfig::new(self.dropout).init(), + } + } +} + +impl Model { + /// # Shapes + /// - Images [batch_size, height, width] + /// - Output [batch_size, class_prob] + pub fn forward(&self, images: Tensor<3>) -> Tensor<2> { + let [batch_size, height, width] = images.dims(); + + // Create a channel. + let x = images.reshape([batch_size, 1, height, width]); + + let x = self.conv1.forward(x); // [batch_size, 8, _, _] + let x = self.dropout.forward(x); + let x = self.conv2.forward(x); // [batch_size, 16, _, _] + let x = self.dropout.forward(x); + let x = self.activation.forward(x); + + let x = self.pool.forward(x); // [batch_size, 16, 8, 8] + let x = x.reshape([batch_size, 16 * 8 * 8]); + let x = self.linear1.forward(x); + let x = self.dropout.forward(x); + let x = self.activation.forward(x); + + self.linear2.forward(x) // [batch_size, num_classes] + } +} diff --git a/examples/guide/src/training.rs b/examples/guide/src/training.rs new file mode 100644 index 0000000..37c8599 --- /dev/null +++ b/examples/guide/src/training.rs @@ -0,0 +1,116 @@ +use std::path::PathBuf; + +use crate::{ + data::{MnistBatch, MnistBatcher}, + model::{Model, ModelConfig}, +}; +use burn::{ + data::{dataloader::DataLoaderBuilder, dataset::vision::MnistDataset}, + nn::loss::CrossEntropyLossConfig, + optim::AdamConfig, + prelude::*, + train::{ + ClassificationOutput, InferenceStep, Learner, SupervisedTraining, TrainOutput, TrainStep, + metric::{AccuracyMetric, LossMetric}, + }, +}; + +impl Model { + pub fn forward_classification( + &self, + images: Tensor<3>, + targets: Tensor<1, Int>, + ) -> ClassificationOutput { + let output = self.forward(images); + let loss = CrossEntropyLossConfig::new() + .init(&output.device()) + .forward(output.clone(), targets.clone()); + + ClassificationOutput::new(loss, output, targets) + } +} + +impl TrainStep for Model { + type Input = MnistBatch; + type Output = ClassificationOutput; + + fn step(&self, batch: MnistBatch) -> TrainOutput { + let item = self.forward_classification(batch.images, batch.targets); + + TrainOutput::new(self, item.loss.backward(), item) + } +} + +impl InferenceStep for Model { + type Input = MnistBatch; + type Output = ClassificationOutput; + + fn step(&self, batch: MnistBatch) -> ClassificationOutput { + self.forward_classification(batch.images, batch.targets) + } +} + +#[derive(Config, Debug)] +pub struct TrainingConfig { + pub model: ModelConfig, + pub optimizer: AdamConfig, + #[config(default = 10)] + pub num_epochs: usize, + #[config(default = 64)] + pub batch_size: usize, + #[config(default = 4)] + pub num_workers: usize, + #[config(default = 42)] + pub seed: u64, + #[config(default = 1.0e-4)] + pub learning_rate: f64, +} + +fn create_artifact_dir(artifact_dir: &str) { + std::fs::remove_file(PathBuf::from(artifact_dir).join("experiment.log")).ok(); + std::fs::create_dir_all(artifact_dir).ok(); +} + +pub fn train(artifact_dir: &str, config: TrainingConfig, device: impl Into) { + create_artifact_dir(artifact_dir); + config + .save(format!("{artifact_dir}/config.json")) + .expect("Config should be saved successfully"); + + let device = device.into(); + device.seed(config.seed); + let autodiff_device = device.clone().autodiff(); + + let batcher = MnistBatcher::default(); + + let dataloader_train = DataLoaderBuilder::new(batcher.clone()) + .batch_size(config.batch_size) + .shuffle(config.seed) + .num_workers(config.num_workers) + .build(MnistDataset::train()); + + let dataloader_test = DataLoaderBuilder::new(batcher) + .batch_size(config.batch_size) + .shuffle(config.seed) + .num_workers(config.num_workers) + .build(MnistDataset::test()); + + let training = SupervisedTraining::new(artifact_dir, dataloader_train, dataloader_test) + .metrics((AccuracyMetric::new(), LossMetric::new())) + .with_default_checkpointers() + .num_epochs(config.num_epochs) + .summary(); + + let model = config.model.init(&autodiff_device); + let result = training.launch(Learner::new( + model, + config.optimizer.init(), + config.learning_rate, + )); + + result + .model + .into_record() + .save(format!("{artifact_dir}/model")) + .expect("Trained model should be saved successfully"); +} diff --git a/examples/import-model-weights/Cargo.toml b/examples/import-model-weights/Cargo.toml new file mode 100644 index 0000000..f9e388f --- /dev/null +++ b/examples/import-model-weights/Cargo.toml @@ -0,0 +1,26 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +edition.workspace = true +license = "MIT OR Apache-2.0" +name = "import-model-weights" +publish = false +version.workspace = true + +[lints] +workspace = true + +[dependencies] + +burn = { workspace = true, features = [ + "default", + "flex", + "dataset", + "vision", +] } + +burn-store = { workspace = true, features = [ + "std", + "pytorch", + "safetensors", + "burnpack", +] } diff --git a/examples/import-model-weights/README.md b/examples/import-model-weights/README.md new file mode 100644 index 0000000..710c8bd --- /dev/null +++ b/examples/import-model-weights/README.md @@ -0,0 +1,110 @@ +# Import Model Weights + +This crate provides examples for importing model weights from different formats to Burn. + +## Examples + +### PyTorch + +Imports weights from a PyTorch `.pt` file using `burn-store`. + +```bash +cargo run --bin pytorch -- +``` + +Example: +```bash +cargo run --bin pytorch -- 15 + +Loading PyTorch model weights from file: weights/mnist.pt +Image index: 15 +Success! +Predicted: 5 +Actual: 5 +See the image online, click the link below: +https://huggingface.co/datasets/ylecun/mnist/viewer/mnist/test?row=15 +``` + +### Safetensors + +Imports weights from a Safetensors file using `burn-store`. + +```bash +cargo run --bin safetensors -- +``` + +Example: +```bash +cargo run --bin safetensors -- 42 + +Loading Safetensors model weights from file: weights/mnist.safetensors +Image index: 42 +Success! +Predicted: 4 +Actual: 4 +See the image online, click the link below: +https://huggingface.co/datasets/ylecun/mnist/viewer/mnist/test?row=42 +``` + +### Convert + +Converts between different weight formats (PyTorch or Safetensors) to Burn's native Burnpack format. + +```bash +cargo run --bin convert -- +``` + +Where: +- ``: Either `pytorch` or `safetensors` +- ``: Path to save the converted model file + +Example with PyTorch: +```bash +cargo run --bin convert -- pytorch /tmp/burn-convert + +Loading PyTorch weights from 'weights/mnist.pt'... +Saving model to '/tmp/burn-convert/mnist.bpk'... +Model successfully saved to '/tmp/burn-convert/mnist.bpk'. +``` + +Example with Safetensors: +```bash +cargo run --bin convert -- safetensors /tmp/burn-convert + +Loading Safetensors weights from 'weights/mnist.safetensors'... +Saving model to '/tmp/burn-convert/mnist.bpk'... +Model successfully saved to '/tmp/burn-convert/mnist.bpk'. +``` + +### Burnpack + +Demonstrates loading and using a model from Burn's native Burnpack format. + +```bash +cargo run --bin burnpack -- +``` + +Where: +- ``: Index of the MNIST test image to classify +- ``: Path to the model file (without extension) + +Example: +```bash +cargo run --bin burnpack -- 35 /tmp/burn-convert/mnist + +Loading model weights from file: /tmp/burn-convert/mnist.bpk +Image index: 35 +Success! +Predicted: 2 +Actual: 2 +See the image online, click the link below: +https://huggingface.co/datasets/ylecun/mnist/viewer/mnist/test?row=35 +``` + +## Workflow + +A typical workflow using these examples: + +1. Start with pre-trained weights in either PyTorch or Safetensors format +2. Use the `convert` example to convert to Burn's native Burnpack format +3. Load and use the converted model with the `burnpack` example diff --git a/examples/import-model-weights/src/bin/burnpack.rs b/examples/import-model-weights/src/bin/burnpack.rs new file mode 100644 index 0000000..d653b4c --- /dev/null +++ b/examples/import-model-weights/src/bin/burnpack.rs @@ -0,0 +1,34 @@ +use std::env; +use std::path::Path; + +use burn_store::{BurnpackStore, ModuleSnapshot}; +use import_model_weights::{Model, infer}; + +pub fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 3 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + + let model_path_str = &args[2]; + let model_path = Path::new(model_path_str); + println!( + "Loading model weights from file: {}.bpk", + model_path.display() + ); + + // Initialize a model with default weights + let device = Default::default(); + let mut model = Model::init(&device); + + // Load the model from the Burnpack file + let mut store = BurnpackStore::from_file(model_path); + model + .load_from(&mut store) + .expect("Failed to load model from Burnpack file"); + + // Infer using the loaded model + infer(model); +} diff --git a/examples/import-model-weights/src/bin/convert.rs b/examples/import-model-weights/src/bin/convert.rs new file mode 100644 index 0000000..eeb5a19 --- /dev/null +++ b/examples/import-model-weights/src/bin/convert.rs @@ -0,0 +1,80 @@ +use std::{env, path::Path, process}; + +use burn_store::{ + BurnpackStore, ModuleSnapshot, PyTorchToBurnAdapter, PytorchStore, SafetensorsStore, +}; +use import_model_weights::Model; + +// Path constants +const PYTORCH_WEIGHTS_PATH: &str = "weights/mnist.pt"; +const SAFETENSORS_WEIGHTS_PATH: &str = "weights/mnist.safetensors"; +const MODEL_OUTPUT_NAME: &str = "mnist"; + +pub fn main() { + let args: Vec = env::args().collect(); + + // Check argument count + if args.len() < 3 { + eprintln!( + "Usage: {} ", + args[0] + ); + process::exit(1); + } + + // Get weight format and output directory from arguments + let weight_format = args[1].as_str(); + let output_directory = Path::new(&args[2]); + + // Use the default device (CPU) + let device = Default::default(); + + // Initialize a model with default weights + let mut model = Model::init(&device); + + // Load the model weights based on the specified format + match weight_format { + "pytorch" => { + println!("Loading PyTorch weights from '{PYTORCH_WEIGHTS_PATH}'..."); + let mut store = PytorchStore::from_file(PYTORCH_WEIGHTS_PATH); + model.load_from(&mut store).unwrap_or_else(|e| { + panic!("Failed to load PyTorch model weights from '{PYTORCH_WEIGHTS_PATH}': {e}") + }); + } + "safetensors" => { + println!("Loading Safetensors weights from '{SAFETENSORS_WEIGHTS_PATH}'..."); + let mut store = SafetensorsStore::from_file(SAFETENSORS_WEIGHTS_PATH) + .with_from_adapter(PyTorchToBurnAdapter); + model.load_from(&mut store).unwrap_or_else(|e| { + panic!( + "Failed to load Safetensors model weights from '{SAFETENSORS_WEIGHTS_PATH}': {e}" + ) + }); + } + _ => { + eprintln!( + "Error: Unsupported weight format '{weight_format}'. Please use 'pytorch' or 'safetensors'." + ); + process::exit(1); + } + }; + + // Define the output path for the Burn model file + let output_file_path = output_directory.join(MODEL_OUTPUT_NAME); + + println!("Saving model to '{}.bpk'...", output_file_path.display()); + + // Save the model using BurnpackStore + let mut store = BurnpackStore::from_file(&output_file_path).overwrite(true); + model.save_into(&mut store).unwrap_or_else(|e| { + panic!( + "Failed to save model to '{}.bpk': {e}", + output_file_path.display() + ) + }); + + println!( + "Model successfully saved to '{}.bpk'.", + output_file_path.display() + ); +} diff --git a/examples/import-model-weights/src/bin/pytorch.rs b/examples/import-model-weights/src/bin/pytorch.rs new file mode 100644 index 0000000..251a79a --- /dev/null +++ b/examples/import-model-weights/src/bin/pytorch.rs @@ -0,0 +1,22 @@ +use burn_store::{ModuleSnapshot, PytorchStore}; + +use import_model_weights::{Model, infer}; + +const WEIGHTS_FILE: &str = "weights/mnist.pt"; + +pub fn main() { + println!("Loading PyTorch model weights from file: {WEIGHTS_FILE}"); + + // Initialize a model with default weights + let device = Default::default(); + let mut model = Model::init(&device); + + // Load PyTorch weights into the model + let mut store = PytorchStore::from_file(WEIGHTS_FILE); + model + .load_from(&mut store) + .expect("Failed to load PyTorch model weights"); + + // Infer using the loaded model + infer(model); +} diff --git a/examples/import-model-weights/src/bin/safetensors.rs b/examples/import-model-weights/src/bin/safetensors.rs new file mode 100644 index 0000000..175355a --- /dev/null +++ b/examples/import-model-weights/src/bin/safetensors.rs @@ -0,0 +1,23 @@ +use burn_store::{ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore}; + +use import_model_weights::{Model, infer}; + +const WEIGHTS_FILE: &str = "weights/mnist.safetensors"; + +pub fn main() { + println!("Loading Safetensors model weights from file: {WEIGHTS_FILE}"); + + // Initialize a model with default weights + let device = Default::default(); + let mut model = Model::init(&device); + + // Load Safetensors weights into the model (using PyTorch adapter since weights were exported from PyTorch) + let mut store = + SafetensorsStore::from_file(WEIGHTS_FILE).with_from_adapter(PyTorchToBurnAdapter); + model + .load_from(&mut store) + .expect("Failed to load Safetensors model weights"); + + // Infer using the loaded model + infer(model); +} diff --git a/examples/import-model-weights/src/inference.rs b/examples/import-model-weights/src/inference.rs new file mode 100644 index 0000000..75589ec --- /dev/null +++ b/examples/import-model-weights/src/inference.rs @@ -0,0 +1,56 @@ +use burn::prelude::*; + +use std::env::args; + +use burn::data::dataloader::Dataset; +use burn::data::dataset::vision::MnistDataset; + +use crate::model::Model; + +const IMAGE_INX: usize = 42; // <- Change this to test a different image + +pub fn infer(model: Model) { + // Get image index argument (first) from command line + + let image_index = if let Some(image_index) = args().nth(1) { + println!("Image index: {image_index}"); + image_index + .parse::() + .expect("Failed to parse image index") + } else { + println!("No image index provided; Using default image index: {IMAGE_INX}"); + IMAGE_INX + }; + + assert!(image_index < 10000, "Image index must be less than 10000"); + + // Get device from the model + let device = model.devices().into_iter().next().unwrap_or_default(); + + // Load the MNIST dataset and get an item + let dataset = MnistDataset::test(); + let item = dataset.get(image_index).unwrap(); + + // Create a tensor from the image data + let image_data = item.image.iter().copied().flatten().collect::>(); + let mut input = + Tensor::<1>::from_floats(image_data.as_slice(), &device).reshape([1, 1, 28, 28]); + + // Normalize the input + input = ((input / 255) - 0.1307) / 0.3081; + + // Run the model on the input + let output = model.forward(input); + + // Get the index of the maximum value + let arg_max: u8 = output.argmax(1).into_scalar(); + + // Check if the index matches the label + assert!(arg_max == item.label); + + println!("Success!"); + println!("Predicted: {arg_max}"); + println!("Actual: {}", item.label); + println!("See the image online, click the link below:"); + println!("https://huggingface.co/datasets/ylecun/mnist/viewer/mnist/test?row={image_index}"); +} diff --git a/examples/import-model-weights/src/lib.rs b/examples/import-model-weights/src/lib.rs new file mode 100644 index 0000000..f6b8dda --- /dev/null +++ b/examples/import-model-weights/src/lib.rs @@ -0,0 +1,5 @@ +pub mod inference; +pub mod model; + +pub use inference::infer; +pub use model::Model; diff --git a/examples/import-model-weights/src/model.rs b/examples/import-model-weights/src/model.rs new file mode 100644 index 0000000..fec8356 --- /dev/null +++ b/examples/import-model-weights/src/model.rs @@ -0,0 +1,57 @@ +use burn::{ + nn::{ + BatchNorm, BatchNormConfig, Linear, LinearConfig, + conv::{Conv2d, Conv2dConfig}, + }, + prelude::*, + tensor::activation::{log_softmax, relu}, +}; + +#[derive(Module, Debug)] +pub struct Model { + conv1: Conv2d, + conv2: Conv2d, + conv3: Conv2d, + norm1: BatchNorm, + fc1: Linear, + fc2: Linear, + norm2: BatchNorm, +} + +impl Model { + pub fn init(device: &Device) -> Self { + let conv1 = Conv2dConfig::new([1, 8], [3, 3]).init(device); + let conv2 = Conv2dConfig::new([8, 16], [3, 3]).init(device); + let conv3 = Conv2dConfig::new([16, 24], [3, 3]).init(device); + let norm1 = BatchNormConfig::new(24).init(device); + let fc1 = LinearConfig::new(11616, 32).init(device); + let fc2 = LinearConfig::new(32, 10).init(device); + let norm2 = BatchNormConfig::new(10).init(device); + + Self { + conv1, + conv2, + conv3, + norm1, + fc1, + fc2, + norm2, + } + } + + pub fn forward(&self, input1: Tensor<4>) -> Tensor<2> { + let conv1_out1 = self.conv1.forward(input1); + let relu1_out1 = relu(conv1_out1); + let conv2_out1 = self.conv2.forward(relu1_out1); + let relu2_out1 = relu(conv2_out1); + let conv3_out1 = self.conv3.forward(relu2_out1); + let relu3_out1 = relu(conv3_out1); + let norm1_out1 = self.norm1.forward(relu3_out1); + let flatten1_out1 = norm1_out1.flatten(1, 3); + let fc1_out1 = self.fc1.forward(flatten1_out1); + let relu4_out1 = relu(fc1_out1); + let fc2_out1 = self.fc2.forward(relu4_out1); + let norm2_out1 = self.norm2.forward(fc2_out1); + log_softmax(norm2_out1, 1) + } +} diff --git a/examples/import-model-weights/weights/mnist.pt b/examples/import-model-weights/weights/mnist.pt new file mode 100644 index 0000000000000000000000000000000000000000..96225ff2ab74e56194e18d449ec5c7405a0095e4 GIT binary patch literal 1513258 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%0AEc4bA9deu?df@vL`W#Qex2#J*OGj}^ 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%0A`iUhH!@=Ie{_yt2fg9dN@urMmCRpskJDQjL=?a1 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¥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

  • iL>9+$9vLo&PExgcZp(ui3-cPAIoQ=B*E|I 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 z yxMSWyVVm$2 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
    b*CM7HOZ_)vtjn8Yw%uM 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%0DP_?%lw@=VR)fa29X|pwR??VwDjB}vh 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: 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
    ^TG(FHY{=dim!+y!IV1Q&c}4< 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

    vNCeh~j*l=i7`P1Fqw;v?u1S}b8$cD^h}M!d+~D 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

    fHE}o??ZdF={VtLALo?jz?BQUCEbWxywb1C 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∋#yIJj0(VU0 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 z z>B>vu53R_CxTAh(8hDTjU7QO&4`tw-_j%;!pF*Qz6})Tp4kLQ_s35>}ibK`0ODm0P zU9lv_N4}^93GUS1};83+}4C!Qn$As6}7Ei+*Vk8&qT!ZQAhQ zcLgrJno7;>JaEf{&A3F!0za-PrbCerX~@Pd^siN5{>Blw?Yay$uA%69VHpdMQfC&g zteES5X(p?}RU4VK;5nrs{BWxQ&7~Si>zpX;IVK9C_FKr2w<6U3{vsUe429Q0weU+) z6n4&O0g>DWV7Xq@L#>}oU$7ev&Co!5Rc*HXeLE37uEvS{Dg<#mKe{gAiy+I(1{TyC z;LrRG7(TODu$TJAD>+dCl@ikUwE7jbe6x}tC3*_8JMY1ERSnK%O**D=M?mJzU1*UV zM{`;eU}B9t7yM&&^`=|%=`F=}nmYA7EZILFcAMRT8FXvD7G$--ub7;o^Px|*XTGLoi&A>F!_M9 z1{sboAA?`k&8G{tpNGQXv-q|w8xjkS!dKrk^0#CX7>Rg+<7pfGw6=@BN&d!Yg?34LP_Plo+>S&v>aR+9yD3?xf_mbe2 zY8OJ8j1re}IFHG?5^S32ezwLn8>#ktdPU&~GagUzh?5$)&A233zmmxAjE6AkqbAs=nqln0a8OKb z1A*lRFJrD8?Jm{j+az8`k2*V?KCggY8=Hb;U>uuKBgf7Ly#V>U3e=0tv^Rq*6CkIN$Hn(Z$UaR(&0di#~dy#-?`unp0QF-{)FruK)*G7up8%g3iMGxtGXRldZ6Hnh(0iKC%uU zR|ajr)Uf=i12m^pV}f!5YA*kQEv0+>%*p+`_0m+$F^mpcmT%zTrP1t1};DDy|4Jf0y8JN)NG=_rn{?F}NmDU^?G) zn#u-CCYITn*;67>-0#)o7UjbX-rt7UHW(xr7t*6COJ!BgcO zB&W+j)Q|$^>@oRzpUl0q^1HGdzj*TDreNpO?~b3@$t7@N72!!S$AUtg7-X zmfW@FRvlQ4n?t?uU$z>j{4$kP4~=DapXXuvC0AUhD>&y5`@n(BXEe4h3(GeZK<+U` z%%=rtta||^AM0{AibOeuffiK$V1uCoZP>Y%AGq_p67T1wP<+%U$v$sUfvX$zuy4U= zwxO7y!+jn0X{;wZl)3^<+eJz5vKX{4l*F#1THF$!Pq=*BKJ1FGA>N;-q5dU8&Nb>| z(sxZRDflnkQegbnKt&k6`zcNVA(B&`Vx^h;9y`vR#jUb{<08+{*cXV?k~P`%?iH+c zoI1;W-9mmIn1|CWtMP_#KAdq7gHYp3bYe<0YIvn%M2j-Z5>kSQCq1;L^d(K~N`+gi z*)NvUK;;v5SQ#bkE@Qf(1ju4#6I9T+Gg2vqE2eu)6ak5Ju_j+AB8~NdKHFD zc!cxVUa0D9CoY1_s^kzGVp^U55lk1Je>Kd z13d?m;ZpH1yj@yn6)5D0c1vAoil;W!>lb2QCT37m`~|BYC*dc-^WC*98-62Hs%K=z3{TKJh}jMIvFOe^w)Wp@ChxqKRnEE!ft}@0 zv*aq=ZMYBK17+4SFU;V8$|G3h|JgeEsxvy&{^qq^bj4=%&*b#<(WI?)Il1i`4UNw( zfQ;q@uIBP=&b5Y*9jbF+XTfQ_QFaz>9V_r^a}a+Z$^-7b*@Y%=cf-YmM|e|G2sW{9eihoR;S9xx~U_RTsgmNSnJV-vAS~SaQvF#@vfXg3LXcS~MFuju-Q*&~&aH zo)@m?X)3?L?&k?OZc!Zmu2_qHgMu7@sX`nZtHG68eG+Q6|ZzL_;uBv zT)1=_pX{xKp_7GJXB`I5T7ea|A-$b-hvasO^FC(OZ~cw_%7KK|k@qACHO;FYlynlS-ip)dx^XMGXO zcUIunWOFo*7Tj}YJI<~BgxC5%I$Kd#|7H7Q2lwF*0 z8!kNS=N}6Hg;KMe(JNAjIY`$Ca>(;AYQ}f+jQ<$tz(ZUt2%i6OP>LoFPbHp%147O%{#36N!H|Wr2oC9>Bi}t2t_jLhAV_ z^W+ff&r&3BmE|$vqXtW|T+PsblAvG2*{77_Y)Q5_^E<1+wh9%&Mzu#|$ul!<*myFw zm9=ABvcQ|gHyG*s5)6r!;-nUSh3yK4=(W!cm7PZ*@!MbOy1O3(efGer*T?B`!~6Jk zPaAbVN$~pnY=Fxdysqufpd&pKNJ}!B=A6KZR>|mL*oUJW|Iq!K`H*_Yj<0B$iT&j< z;3?|_1>!%cZ0TkAV)7kN?aYVE>N{{>)mU~hF&w-lgW;0^Q#X_xjoEAJ=r~<9W^mya zF>01#&+LEV>$~E_vHLgaeld+19Q?y;+P;n+nD-2REOdib*Jf~-U_=+K*Wm7Jn8U@R zHMr`x7tdm!4=ObVqO$i|+%l^bgS3{huX8JKqFb8v$1$tem1)Abyi5`NdpuA${0kAO zu%rha{@~imI$}{$i|aN-;Hi6-v@}wmiN*R;>7(_Kwc-aU9`}&Qij1L0?pvY(Z#=ia z{xx|Y{G0!Adm&C5euSA-C$TMI7uNW_pgPGO7=N%D8gESG&d^_^b3qswEXjoK;M)+o zSB}h_{sS7k@?clC7XR56J-GRGE_Zw=8Rg4;X+qOYnEPcRJ}T>nn79#GTPKZ)U2~yj zr3DI!f2DGv8N9RKy&*byDszjE0S#SwJXW8CWt}O2tKULT^=y_U`1{9pEX4}lb2upe z7sJQzM~^r$EIfG*6`RFa;CT(45GV^(@d9s_^9nF?UkwQeyo9O7d?cQ?s71+5{++5I z9Dmyarnf$!o*wJ4WP%WElRpZt+9Y`slkL&pW+U%YWj3}Y50X2)4`lCRdF&l3LD>g- zz;i!?2Lv9bCw}yiY3-J1u4h}lD`W{gG4i4cRzf&4IS6JwnM;0aHWKO0iWt%@-~&w? z!$15a0kco6M|EB*ZjZKyy=f7G9#7x}%o)#DUN!}!8^nRdgyCc724dv219zU)738ao z#>p!SabbTbxhxcly$&&;)7eUt4fN6Hgb$jiWT0q_7Umw{qJDn^6rJ2a zxuc`ee8NsVo%D*TwSOYl?-b((FT~RyCE(%Amvoy?48cJOt;pVM$qiQy)`M6`W<{n%>{}U-0 z7lM;)eQ5XCJ;ZgR5vacl!-sF?0q!!Yf4JnA^bp!VtKRv{G==)y0asCDrojSo|%M};Op;nr$2 z&~w8@&vL2My*7%Iz3_Q{6WItVxHb9$|Df9eJadDCVbcr9)xAfp;{i~qqz!`!qU?Rk z_-ZHbWNeJzC#WmXp+#kbzFP!&e@%^PPRPUI{kKr$%O3XQM;TU^<>O?9Vq(&Dhs@mM zMCOc~!?F2V5RuwU&Os%A{kDzPo_3aGtz|NVM@uu!A~{w*_8ron6WIp03CwTA2yb_$ zquWRj`SxaaRgK3asQG&c(rm|Kq0kw)5+T8A0&3u=`z&zoTMQqLC6S2IOQ`yT$5!l` z#+@G%!*i2&V4ruCfW6Nx^5{)1^5-_fC)w#_(a&fQk&5N3FSUR*3q2ue(PzjL?E&|@ zPX#{3f;0G0N+YLiV%?g_bk!vj@GI`YH^V`&e=VPG++;?MoS4bgC#)iEDg?jxhQhRx zESiXhI3U5G#9NB0dn;3Glil1Yt7u4E{G4CjZVexI)MCz=Y*M+S74CmN0#v3Q8n@LT zw=oZY_QY6(~!s(30zxJMj}YWmD><~av~cDD?s^0F~oRiJWSrH#+5HH;O;u;LtK^y z7H^miS3ms&XWwkz8u1x8L9+{dZapQ+e#K}N(hqaLXj@jkaST^qdJZ>S=tCFf*% z-t)1%$Xn3=M@V1BPf9yy!@FB=aL$uiaBJy3$egFd3o=|r?z+Cffv6G~W4ss!*D0`a z*#X$`QWl2quEdrFM(A2INInO3qu%9l%*`_l*Xuc<#b`dX^e+QHl_=DcCTJ7zk8mx! zd1S(D=oR$D#MIxg)2g1|G!TqG-#)^BJyCS8%_97RpK#jaEzIrM8s@AV%pQg(!rG@P zxNvPazjmYqOEQ;3hgKS12QyZCzZzq%zogMZmR!m7Scs4tqTYZ0p}2V+{gz?NX2*S| zu7xECf^&4h1r=6$Cm#bAY(X`%XJp51OZ;|oE$*242_#poz$5$CF`3J!(SLy~S66Dv zUA|RH4~#vH&#rC8?0+V#>fRwn3)*q(+E*}F=_h>EmV_c=%N{3L^CeH&l1q^#I8eWg z&8~?>UAsNJOr`j0w_-7l&ObpS`%OXI(-)Ex;;{SKcIvgNk7)P#QV!EmZty)3d2okv zOCs@7rU|8+v)f_bzUrOc^8V2?x*OaSKl$I@F8kD zq+mmkK3=wIvmE`+A1A2@X2n+R*tKjmi0}h2s#A~h0@gsjY%2apO~mu3r=ib?IF4Pv z9;E^^LAq=|rYh;dugTYO_3JfQGp+!Cto(-eS^_Y^S`^neOOv$+l*vcm^L)2)iD2#X zh}Sfu5wderF#G2X`fSSr%nXjjwFc`k!Z{yBN}^%#RXUm1DFzGVr=yw22i~q@F8Cp> z6h|M<=Plgt4U4p*XrNXJiClAlsAYS>i60CbtBzw>>~4BA#u3~u9D#>6Tk*5hPE7L8 zfjtfq^g}j5k2N!KThS6|lwAWK-4eh(-5D>TJ$46HQ!D-)kfOJc*EUE*o?SsY?2Kx5 z6G8ZqAgg!RC{&Ktrw6LF!T+f>NItlMLg^8}`r2qv(o~*@{~k0;ZsCb7Yr?fYSy&Jo zCD2_?goDoIyu_YZ0oEPO_YqqOKT<_l)zp(Pt1pY!yS$ld>72yh9v^wi!4jn7?OuF! z>NIwxWuWiCWHcX%pZL?!q?;R@a}JPxvv{c)>uP+TRf+C z$3^3V{AL{LE#UuylNc&yL4(?Y>CZPQxZc|V@|;cRa>;Bo9Jq?s1-miQB#{>V&9zpF z)q}*wB6`867_SUoKqKEMte4h?{0aZ)B-s~uZu@+cewd8~UzMp-458Nrn(oJY_QK&_ zRm|`SApVVAbjHe2tSnZF-PrM*7cA(bw+GJS!-**cA97uKmTTqwjgd$vx*Fc_o?!~AnXOw#)UDp&>6 z>g`6X%xgVq7345xFDt|Crxuc3(hYQFLk`+0h_h6MjnsNGpZ93y1K1I=jw{uvr8(=Z zKw4fE|86^iA6vuG#K4vEN6NtIR|(86+z7!7>|yTZB>u#-CKPvWu=bzk3XTc7_|-;< zZV=u9_0Q@sh~$xR+IL_^krPw;lSTWs{YG}gkgeEh!+#?E3SBLV@#(Hr#P@kNE<9w< z2JVV*yAEH#@ZUeEw@VVSZzzR-^OC{dHjkeFG8!W7lffZco{glKvd;^Hc$SxL(Wfyf z*!Uv_I?k)$PIU)}wDE_kJ%>=|*muxMVMN~22+hnE;zcpRxtl+hi@O*~Z3SAWhMUgZ zxI-V{CDTOy(Hm6ej5^miWj2*g$^gHCBsgCE6b1&{DesFn)H)m?54T>U5_8+Z?PDg~ z*40I&;c(P$mS*d$*RnP5Cb8?kl$hpGM<#UFny$GsialT2lD*7@L@q0QFTZ^5Yw$(UMTM!eY`+%w_yQ#f#$v*%QaegyCaJ5m!A;) z%IT%jLVI|!UnQI7z_FO4#ZFzA4lAc`VQ*G- zp?c{{Y%>#J)2sOyJ#{R*r813eNfbtt$Y#ERlRh+?MOAMJdiH zrPi&*SUJ0l>Ig4G-c@0CVrK$6{t&E#*@l?n;>V_co&w+Byv0TJs%)LZYw-8FP0XLg zVD9OyWUU}u$m^*Af6xHow_*p*+ct(1TKx(GE!1J+eOU&x(y@4cJnmDf#+LkC?BDIn zCcepGB36%K^gI_*C$E4v59N>=DZ#wr@CrV8@Ejf51X$)z4uABP)4%FlSW$EoTjuFR zm`^6GT&d1X8_MDLWn~O8F<|RM3rJ1#7`{r5E6kZL$}Ju>1B)te(7u^QT>H`zn5B~h zvvpG8r9m)iomRudWo=M8|0nefzkvZ=dr9c4K2lh%%XlfHFh)BRjRacj0W$#xa)gm( z0gR0HEWlj`J7~MlTRLrE01Y(b@x4_vt11&G2j<1%#$Ea7Hc*c%{@g^5t_4sXb^*;> zqA>oMCHYtw%^%+OkoWB1C^#eFY0sa&hxzyjyyZLdQV z=dlLX`3Ip0U5L|j{dpatuDEDI8eCkei#N~Rp($q4oP=uxtZ3EZN%eSzmS@Z;jw3u0(|TG4OC5-!aNj!^{6gpMQ!Hn=|~s>Y(9xueg?YI63~>L0*P&zP@8iB<}R5_hkyToN4a%m@w_)QPi-1R%o`%f zsbzc+%=lJ4`@zq!n#SxObdyy*n(S<@4E`H0hEZ0wNJZTR=dNJEVz!Ya}L9z z*Oi#IB1Mo*X@mjVACR}{7e2^VVBci-v*7hk;;k8*^sk82@hLG(9175(tYX|F%D0`0;OD{>sL=KkL6GocXduk zT9rHOGX(s7&cv+I0Ao7m691@6bjH{3xUzZ)Ogs7>W(}xv=a!uVK_oP1q*Y3tT|&V4 z)J_g`LrA#DN+@w?hMpzTG;?V?2Ho!?U-sUmp{M-VJ^AOb-YWqd_(k+bPAM(VnhuUO zCKz7*2*)@JFvQuVI3@ETxh>*|)xo7$sx1a_VKd2v55xGPI0fa5REVyvGE1`_!mlB! z%=+<4=6l?fS;Wh;$5vU`COaRijwIoc2_>+ktOL`x{t{q^%ILSqmPvpTyAoJ{QN7bllCP+Nx5)O*!(FSDdZ@`_T7W%#B7?7vS(RYCm)2Xzkt0sH_Rr{-` zHS-!wb3X?ya5$& zkej*{_Wn*p?+@pwe4hzKZkz}X=?<88Du!HgKLrZEMaeF?B098T2zN&UbJZ|nZ?k5w zCsO;^sXT8)*In>op)`7I&4QwFDr9yeqgF108HI`pw?;&eadNMpmoi&|#OM0s{cSTz znQjWS=v{^Qq?6U9<~N9^-x1iOy#zcai*Q5ZZxa2QpJ3#74%{j)p-=kglc&1rkhLwHYw>0P-)VDKfD-ddFe zfz_XRu5oJxI`mq|++78lc@>yvbPFGPkHMLK7is;KJ7k|rB0hMXPedlkz`7F&;CtpJ z#-Au4Zl6}*qC*MjQzgyb@0BEz%7oZn>AdRpz#42U8i%tdp2f@W7Z6+NI*9zc6zzt6 z$kU74c%v?Up~iM)n3~)|k9b^#q>x~$(5lZ_Z4f-mu@dZ=w;B8SgJM!zIW|qMgSH}s zp^dF%@1a;2m%A3m>(0U*o=4zOrY?A{?yQdgc#Isc?#0-G6Ig3K6*}}?_(l>L=q>w! zd<Q)Mbsu9Rb{UiCD(#g=+Kx`CN+PsL2x)!-3#mN!#a8C6xL)2zf#ux-%|u%9Fa{&CZ* zt=CT#`16~APkFnq?CO6SJGro}eqHz$w8qi7H3y+@RgJ;Dk{PyZM=7eSu zJI6qrq%H8CkU5AY*{AW=7YU5eT4@a)ITRH)^QRluK=Qd-(onevi@oo{hT3ub9iam5 zR*)8_QTz~e&U!%84oR;5l9a%|QU&jKUqolQDBKnJp8uqvjlXN3K68rq#l6yxQFLM$ zHJI&#!}fgqUg`&X`*u^^AI12pmSTck0cjjQ1m7eVgWbebayvGZ>1(LGce^Ao*I?$ANKWsGet!)1>hK<%<3yDpl72ElHS z^i7Rj)|P+--$am_v=Qh3s^#7GnuVVPKC2?P)yeFI$Dvlv6lbJXfp6ao;7u~dzn$&Q z+-fu*{0x&LMsr1H#ks|iZ+M{|x$t4998M<>QL@E=o!U@_t?^lKLzGVxzkkMD_b}_( zHAMn_STwyS^^DXH`N7Mcd}8rz0!}0jI8|B)h0m`8+(PSPmk)d}WWXFzqkBwB6|WmdN&nc@dm zcJ0+;ymq4>kA|jTRE9B&x^a-ET=BQAL`P01YdY6BJOkb(%X0S{Od()-Al&=7lT&>B zn%+C|hs=8>@ak0C%YW-oN>o$A=nq3lL5@WzIII>E_?hI7$eTbsxJ23R9C@ynNfIZ8iFnY#H7A%{IkJ^l3$Fsw{P3k+D-0&3Q(-IFZ zMptkpxd8GnBl&wS%n{(TXK?Ve3xw#;2428d)Zx@vS3WejkGK-D7>4_tVkJQYadN*Q=C{vAK1u0t@7iB7c5F(^1_ObTHWnyItjA z%H24up2bk~w>NYe_LHL)A+R=979_WQfP4Dq=%Pqblze!PJf0$r8T@Gcdr^d^DK15( z>#rv9lTH#*>_?~X5?oM>2A9ol;CT}vCVr)y-*x%|9NsRN^OF^1>wRNT*bqos(_CQO zJ1rK`S47teFh33TCOoiCz}eZIfUZ+Vk(nzQnj2BrzC8q$(F(X-?;y6m6$BaGFfJ_| z_lAh^kB*BcNw+=8)CUbXW%CI%IR6SAF6E+^fR8%KItHtxKVr*y0VXug9c0g!VrzFe zTHVygW{-00Sdl;#?6R=7oZtajVJ=Ligl2Ta;YR;HqUcjhhIhQ6L%YVHP=X%oIc^+lZqm8BST6_=2N=oIW5MPEJ|=M?85xv_%bVo*)S#nj#Rmjb%Tw$ z|7RP?{TXFU+=^<6;p2R!@m2vE{g5sa!YZ^<-HS&j1rVM4YV7&F2hmNJn`e5olUdTs z3cYkUNE++fNIHB+3HH8ZM)t76<|BQn&;{oi`Q#H2CB95doCDckbI)V9tqr*A-2=3< z*n>*psc`UuGajq7Jzcv^1S5;xv72)_+BuIW2P=))u@2FMHaQ?lecg(){?LM~N`EMy zaRLjoTA=E9D{3dN#*wR2Kr>#6ob5G==>B;NmuM=JK*vY8a^zr^)$GTHW-4Q~`)O!@ zfbGTVYm)E@Pgs2>YrFiFy2d7)zhW2~UHtE;C(He*7>~S=AysZZVMZ8?8ar zDsumaoK1z3x4KiuR?dZ=*u`X=&L-Rr*4X=?fYob$ixUR8kxBLMnU{@#CPa)zuiP*} zoDW6ai$J4rKWvOjLfsMaR7C5KOyYoQW?+39^XgzK>E~pJD(*D1y=f1wANdSb%uHBC zuNqW7)_}!lw&LUh?mM(p@WEPBbPW-~{8`%IonVLaB>8ydf+|%!aV6GetYJ)22IG4Z zO?J+L-OTOwak#i)7qj@ZE?Z)6#Fi_TFdlhAHh9$_v^1Avqr>dkw`M)rw|&jnCk}n^ z$g>*eSh^CMR430qwh4#$Lm~Kwsv6YzU9vr?R0@#++LV`S449}kfY@*!H0`l~(Hpnp z<{yGFbntep9B#m>Xna78HJo-&+Bh;KsFJyPO#%+{%_-~ox|r}`D?GeY1EpT^=xJHT zxUpf78{tG6C*QyUAx&KSmVjQN6QydWOSOw00(~wJ3v+&k`}#w1%8Dl-=YLYP{$>se zJa3WfNA90K*{>XuT&H3geF&ExeGa)78!^z?k$I=?3r}zbNqTQb^2(1w>)pXPF+P~A z3QGd@@EUw$(u_jOJMeuuH)lLT$fhZAI!=Wy?4a4%c+VpO?KO?r#4=|#aG53RB;JS~ zg&VQ$#tz(^$DrP{0T}zl73QU0!V~sd*!E!tqo5zpWE!fW+vV#(56NQmW;HXfrsqm} zUiyu!9I^#m?#AN6wo+&?X$0?yRpx`fDSgud`3M!eqD@WkDsz~@DZ=I-gs~x6Z z&xWGIJ0%AKeA$-RVu&f+AQ4qWu{pD1A=OV8hEC?z19r;;CzbOU=6Dq^R_8#wAKIP@ zafSj%D+nE=3t9S^V1MpEI9Txs6zA@MDf*`&f=z~jz5$RanaW-rrGw+{FF;|^ICjmv z3KIW)0&bmg080|CqL1EowEs(y?S19|vu{HSQ?h(9b0+Q(m^i$K3%B1g3&*LVUGhb8 zU`!nDY>pr#N}0-w9?v#jyDJgB7BH#HoLJ+m6pY&+Lh9K)q;v&$-HbF{jI_%l{c{qz zyb)8#uW-X7Hae`~u*+D{s~C%8_P~qa3s5jMNyOG?i7GEPk!_(0Y_GKAjM0ccMU_PZ zVaUoW(Cy|c7|F%_+`M$$_B>s*gIhbFq%av@EvjcOuhnL6S489OA2*`+aBh81n;*^= zi_uTipKY-6$Ae8ZXm6xQ%&vMej{^%~n`b&Cx`o2}O}{hL9W#uGxCVWuIboUdZ8Wvs zN{sAJLIK-{YWL3twI{n^iP}SQ(>xV+O@0sIH^cGzsvdY=uo-p_)Wz3^Z<*(FGjQ!b zH7K*T0P8A8h~m}~`+2v)rCCC%wmlp&JbYpD!&V56iGpVdS740B4Y-%S9M6st;)Gse zFdmQtCX5dD3y&kNy%S-&XCtoiyM?jN2GsPNks!(NhjW=nAb_aDs+rl4A25*GGEK}F z9p3`fcSR^+e!+-66}TkK0lObwf>kA9#9_`e(bL;|VQjQSWOKA9j&OelV-yUieR<&! zZ7P;jU+zy$YCi-YCP{2}G+vQ-iEGIC-22R$hEP$?yGXdRuP07?#9ceULY*?7M3ass+Q&{P)#7^O^#r<-~0$g@rtkxMBFxfe`^>p#Sn;O=Zd_Y1JEU^E75j$)@&xY@i;k;k3C zJVl=&gQ(0ZRVw>k2G9~c%DDLk^i+z3)CyZlOU%vFZEpf=LkAFYdN8UpZj+J;J4yW; zWgHzDgvrAuOI`#HBlAjwI(4~BR{x$Uu;h}*xbn^UW1K8kNB&rYRUGcP^tNE;&I62x zL1V$85<{>XcMsaRbv-d<*I<8I9?rflfU9o?Fy0#Nj5t=0c|Pp{xIT-4-3dzA$C7|i zU?lbl^njaY3Ak4^kx?kMVY8kP>d(a5&iuCX4{C3Eaqe}L|L5OO>HIoQw#$(7?`vD5 zj}Pry8cx^wEu?if{Y0Ofx0g=ZKbZEUBk0o9D0;%W)pTHVGMy3^MIX82Pq(LRpnW{I z(UKWy^dEJ zZ~sNBy+b%_KP{SXKSB6)gE9?LXlf-iH#fJjG`Fy@u(q(Vva~QY9c#(GBINEC=GNvm z7FOm~)*W8He`)>m7yUT74rf0r)n?E1AN!)4K+sV>O2T!#{c-!ETWhjxiNA{jpWOrv z{Wwg}Q8Ydwo%>13f{(v=URa3l(xCaiL9%dZ+)l+oasoj|>A{0{e}+4cmQa@LQ@!}b zAdcuLd{U_L$?V@!yRxLT(49I*f7wpRkX?*QYyI+39aX+jQ(3FhGP`^pk+(b{W({7JVlr)yPLz74xlH%A(RLi+r?Q z2DOq!r3DiC=&BOFRjxhoWB8v#qFn~HkwvA`*ZFbiafWBLOSMWTp7Y6Tl{~UbtxBJA z@JW{{9@(W*rB4R5 zD&6Vx$z>WmvP*qRcjo*k5^8=TKMoPHp&;GE4*1fmaF60XqyM|_cX8;}ZS+S%U%~h@ xtMl>Er+sXP&MdcCYiW1xIgaZl1%eBG|9;N(qTC3i)X^0Sbhul`=l}lfe*sr#w*>$I literal 0 HcmV?d00001 diff --git a/examples/import-model-weights/weights/mnist.safetensors b/examples/import-model-weights/weights/mnist.safetensors new file mode 100644 index 0000000000000000000000000000000000000000..95814d4536ca6d74f461cf2b9cbc4a3ea8320dcc GIT binary patch literal 1509296 zcmbrkc{tVG_cu zupwmqidEjhtNneptO(xhy*kimjfTZ8jWxkLf_yYAH0)=XXy|Ed@%R4MFHbK$jWyoE z-YYix`fl+F-Xi$NP|wV3x87e^#{Yk@%=C3Y-u9mm8>Z~FBANyPXchzKbEn@|MH^-K&6 z|LZKq2LHrGKwYruev0_a9IAFEkT@piB*C8Vi)N`)?olCt>|3 z_5EKEe?d(*{>!TVcHPY(8#b)n;J0GEkN1ZE-p>C!NCDJekS2dS-TxhOtM}&r_lN?d zzYtB0jQ`hp|4DxT9X$caUtlx-w*7z6`~RrbnF5Hvq5cjJe?$F06Q0q3Hk6q&1VQ3o zdYNJNzlFd5g8V-U@4rPf`x|lQ-+uKs$$t{we+MQ2`HP-_pV#i)6aV#Dp!#>*|F7@Z z`H#VVNR}!HD*^N3L&}CMB9t^2MFR4fOiTp`-I&VVSnk6f)S9#JbM&}nGtRQ*whOp^ zO=kj&XK-I@6}ZY{eb`sMl&HTsO#?n|;O_YcvawHOSo|+%&@=0T4Ot7RSYs`h9HGp~ z-ym$&>sVIuZlUj6@ ztvzvz_!|2maPc-C<}!}Zx)t6*;0md)7PR>-6Zc5{pUZlS>p zMLZ!V&(b=jS@DSJ%qJs?X^RZ!o;U?Vt@q5@h`%?eIm{|pS{BJ zZ|HKXPX%(h;m^3rS`BthspUrAVk2pyw(c>_ZuNw`dKHW)GY6aP)fgXkgDT%yi-+BgKz`PEeBE!) z#GU7{!`D?<&8qKu}gq0N?RABDV#WLjiy&K8QEr*6hq(DKhD z79X6B6CM{~<|3_njSW8NFQtoxP0K)HNiELPFXDN5f55JFI%Gk2EF>(IrjuRwLVcMv zm-Ao>R~bBmdvI?Dr@d~)z+2b)ctihC*@>Z8dwJgKixR%1`F;?t|7O=;SP9+ zbits>CUChciHk;5;R?y?s3lWOgwOTk1zRO9C()d&YSd#@%ab7I`~i#1tU#KWJ{b;) zCgQt>-;m?JjJ!zIf%Mlsd`=<=Pq`Ekl@msI`*S8lZBxQ^u5EburvbOwq=p|C6T%uQ zZDGx-F#gjerYuIuAEzltvIX_W@U6o-Y+msag#91E+WV!TKI08^It4-3avM(FWDk9= zo<-gFnZfhRx3Kq=GW!^#z<9iabf~)shue`NFI>qU$8td znfba)a64HN9;+^3Xprsg^DjJ_(z2#kd(3Q8>1x z4SDP5V8YvEkS?9T*6r2g?Ksv1d+pqrI4W|n-Zosk2ym_z8KCbmjja_G;Q}swhi!8r z*_QBqoR{qa^ij27dVOWEH*p{HY8b+cT@&DGt1QlGxeSJ@__*t`0kb$O#oZjYAFr)9 zVp-QjA<;J)LrlVHYrgozA-efn|2cl0>#j4e6F z?0u{-PTpLF_XE>;o)28`bh8iC%$m$ezkflwDe5RA*F^QZN3%2^9Zs>Yp3hnL@YYT2 zgw5Gc@MBmC7`2FSR%L1Usk0u<`VZsBx*C!iPUDECxAfSq^*B*G8S56BvhA-g zp@`lIe6`vZ?ZiAei}h#l$kmh3b6Nsgi=|k#-6YoT%F)O(OW_8`V-m{Kc<}=(A!hI; zzenjbq-o8gQEgsm4m)@i3yN@2b_M+{pTXYUddF+a{7fE+h_I$_!?@ZvjvV{?0V)EA zb3UT#_^?j|p51Jxn=M*!<@{ARjLU?T_l)4Zb{~BxJcz3beOXW00f<{V63@+3WO+i{ zQM`N}oT;kDoJrQ~$BYYP{ZxOj&s zkrZoPhil1vvM;xfKmTA8Z0~vm7goPRr?R6sBe|U7s0+9vUWo11E`zmmW2ty*E^hqn z&)Ljshbsjwl&56QJy@C#4_1%i{D1fH)*bzX%Og~{2}^5D-*Xita)g-vi8FKF;df-n@C3$OZ#jPU0$ljCVxzLTT;DzNd&SS_Pg7%5APZA9f z^01cF{i&rG^bpM~r!t$ILNfc?a(3ycDDW;WL8tPIc>CS~Dl~2e@6GFRtU_To8g?3D zW9t&OBh-aIO4tK8Y;DK#QhiqR;SIFkeTui=8*^G)>Y?oPXf`ZG0w*S1!$iMfEVKJI z`XAnagAcUXXbE2&SS|;uch8`q$a?0Z`xzD$N-+bo8Mw{*J80fsf!4Qg!t@Vo`RjYz zpdjl6C|w%QrL3B1aqmgCx%#_ml-KRWjHV*;Sm`;rBiN5xjYR{i{cx!v8-z2?BH4Kf zb1pvxr>?Q|nza;n>6ZdzcFS>F7cT?th!eP2-ivIx6N_1n&(Unn6xRPsnc0-Cr60mY zu{&ZGXV}{TaWV>AT|gWcFfEMPCf=bh_R5g#NyhB@&NT3mzluiM7s$~IYoWZpjcne# znJihnny0I2!Rb2{K&8S-NEum3wcHZXVOAsjnP(xmCzp{-xA`PO=81)lcr4wbmxCi_ z$it&ufo!Ah5@xaDD^8b8geQ&{$kuTcX!BGITZ>KD9k*eu?8aT%CX&h@-XB9xQcaw; zqz!CGXmMw>%-NKiT4v>wg5agUD?3xvN~CrbO@)c(i^~fa4}l;Fiy+0L%1L4&I&Mnf^Y)^U=uw;TA8P9ekaJiVCxUn!TKyktApI zPLH{(FF`T4E@VO%(5EO8C!h@T*)GYMAFw6a*PXcgBeLk};t))lvI|y?R%L154zb(w z7qC}nuA*3*5KM}fMbpned7i3TSZ$p`LyJF>0UJH+x?K!x=_I&VU_(PszQqqRf1I$UqHHYBgwMmx^mN7;`%x$((KoWbEv?3I_`iZ7PJkZBqQ zN6Em?DPuV$MGvx?Hp3l}g^(uf2Cw9t!;oix>=}^8diUK+;}GMP)ufh{sdAhMcC>NUvNz+;2n$`1GsrVY~@5y zCw47X9tuG5=U4EKNDAI}(_qIwZ^ZA3OVQYIfC`-{$0ny+E??(bX}W;*cL#p23H*McwcuX_T+OPUKqxuFyLxSM@y+=8R_%;}&G+fp0Bl-hEc+l-|ot4xz!s~U^Goj1q}{a<+D z@gw>o`WEK+$7A{6CXD)>Np(|In8PAtuEErpi)~bc)WUeU)i$3__wWaok6}2j_$s|0 z@EG{XrTmgxuj_Z*Z^oYi)7h|a1!mu*!+eTH!rS~i;CjClhwTvt6^Tfat@{ebO-dtg zH;utBZ&F}e?+T_Sb`u|N`-YOE7QhqX5pX5Sh{!$H;6^r%jj_Z5hknb_g-&oqV#Vj*n&GSNM@l zcQ7bclx_ZC%TBt@#UgiSzWR=8j4tiMm35L(f8U8Y2x6GKuO*i;J__C})Mef|YoV|G zEL|k_8BBZ(D5qr1zRcf%FSQEbVah}nx3dm2ZRX*c;vDF3y9%lIuhZ)y);M#V3R=D@ zLfP0U%x|4Db8E?B*HSD{=Eq!Us5?UC4@6)^lm>+493bx>Wm%s#ffKNpYgFk^eC0NbM(1;Z}SWlP!t zzmH3XAAii4erpg6q^?I7XE{hTKMOAJW^uDWS3scoXs-R6A$Q%^A9~kZMUO}^7B~;+ zM)y7VcCrXFn;Sy*9-B%1;=_oXq%gOVT62|=-ylmfAEY1KLI2DZc;tOLP8|7_cH56* zgRR;8tQRdXU}J)g9erq<_nka`EsgIR`^eb^F5DX@UG7D)oY? z2kdIVakCJ2GxIerczgy^Kd#_PXT`yk$t|dUSd?W4dlDO)EBIWVkD-^69=K&)Cc0m zJ^{ZJQ!X0sQ4?PT^|F-|#oAGS|hk8*{c+^p3cY~$S z99XHK-kR5M!|l2C6x^?i;^+O>N$C|CPN6gt$7n5NZwHd-t5r>KiXTHJT~p;I*<|1* z@#|RDD$jeErp^vdj)RBH$m7#gJEb9hBzQfuT$ZX*v^w&DwlSl&(geLKe#A27|U| z8N3fw;|iY+p~;HVGh>7@wfVhIU3{v(h$Hj1Qr_&Xw3@Re~dWr(n=v7SN(T%(L&o zv#-bF@@He2%7%52Ra^vDr|Ytwj*m#4s_?920NZqJ4@NHD#e(`J*!6oWX8sWpW?*%e z2!C+F=^ymb{^|mJCw`JtL<(x(d@XFMa%2x2PBK-iP}I3?%EEN#uyM5)QRH$WE?N7G zo_@LuW%g_*yZ+p_IGMeOuDF~AiBHaeu8|Db+fL+!zdLbB`Jcdj-(K8a;ez1}(I9p| z9n`;>aXXZ|peJ338?L?_Ou(F#KYWSOakKeBeIuCoQ!O@N+Qe^6S%hh`w3y;-TfF>P zn(M5|f?vf~!PYX5+BF8ioOOd}s2Gi6+N0T;B`@*S!XdbN6gY>h*XY`RmXsg&vuIXS zWA*pX;p(t5*iq~N_izz<$tUqNuIrNC5e1Z$)WaCDR#5_nqVQrL zPBB_WdyeIi=iBx{p1(0z*p8unW(m0KP%E95mkuuCY2=`y0yZ0eMnC0g+)c}JFn!m8 zJuyY-xF{X0x2r>Y!zOyIUz4p!4F@UXW|AaUgyP*((f^Yn#B;$|v|g5s_jKZqlDEU1 z7VW&IP)W{FMF|cb4>!N#6G{#R#nGGV6L8xw7u4a#<3QUn+`iNRpOy7tpv*%)ucR91 zD$ik^ueAg$7=iu5lWYWR}3*l40Om z><~aCVH{XJnW52`I$#H!9_m9Z)v18cSYD+lU9*2lkI5Dh@Mb4bYqQ*@}kNN~|ersut-ygnc)kHSGV zef9ky)>({fuLoh5K2cKLM;%@&;4FPZf$z+qX+uLaL@5Shr${hMH)CEyPCDFIIfLqJ zOrcov0Vp{Af{a4AN7ElS)1^@fz#IFX9#Rd*D>Emtw7v7eVaX3T`q>P3yy)QBuZlt4 z$4g#ZNwp#qyVc00z@x6o1cinu06Sh*+=y`~> z{@PqjvoPDhQ(*i2`0!&*A^&pJXeJa9O;x5H!-Ox7_=+;}hIzH* zQqWaeka`t%`AWgk6$gmvJ|&v!brpU~{(yotlfILL}KUShpS&X;@GjfNaKs+nEwSa_{bAM{p-Yg78eH-*5@HkXhtnz zIkeb8u`W9qzNfT6^$QDp_jVmDugQZg;ve~AmD6CZtTRhR~xxkr@M2=bcBk;%{$X?iQGY9+* zUPM_vagxa@guxix?6etWZtOyF z4`tTq{0xJGEx@w$0q@%64)U#E7fO6b(qqqR`8{JsaU+Hw!%AI4@G}S}-HDa_W=jjG zKDYuDTnhL@A3`APg93MdTQgbuup58PDS%v=>0ojEHf(4a$wZ7r&^hV_hIzdtVS=8E ztZOnd+i_55zY*s>b>#7!9#OqTPLN7Wsr0GZ`f}3;xV5(j>lz~9(wPCWQ(KH)9)A{R zKl+JZ+(p>MsSGEM7lmK?Q_)r>4@T~)fe}aix!mF`tbHfMvQi}3@ZYI$GQk)K%=?=dxgI}zoFQXykYIbyjY_Dm9H0&!vCNqP2KQ-xjA<>A90 z4{4%ZG;`^kBglsJn5V@!lC<5~@*BS^@@#uK9C~2J>pY$TaYm;>u_&21tBRq8mkaOXTtAHZMfjsWuOOqAI1vji zRctYQMD<-_fJtt}K<_8D|$ULswzT$zg?L%eNXqnZ1MRk$zz z7QP=fi=Fxw0O0O|%VP}iLt7~%Dkjp~qnha}Rcl;+RGaI$d>KBPT}S`G)imETjco8c zf&J$^2w4+~I}=6Nmi-eTR<4iU&}>J$_V0As;$F199moc67ve~t7=B0b5h`|mKbfNF z!fAYYkG}V&W56>TXgQdQdtw!!?`aAZw-DmaWjux*f7+>CYzoNjRK$|ac4TV&D>~J; z1V2UT;=J)@Oip1FSVMYr%EapuIT?6sSf&CzQTN&a)gZYz$ zMq#j>47$Q&{P{Q>l#&YQCcAQaZCn;4&v*gV`Z{#1k0@lFv&S+kTTED%%Xe9o1sZjg zSWz#<>CYK}j6b8f71?I&O@lOC7w_dYv<~3Mn~Q;tD~9)vdUzI#V`=H458y7p4BLlk zv$f*lth4qfD!jC&Wp@489UTHG9|Lef?=5`m@c~0-EyHqN2j*Lz#Lw+F`6p}4aq78J zd@Z-1B*q>G={KENw3lO-6=YcI4|BHrlqy?0U4sca)@*n#60eTqXe4F8COYI`<(@ID z$Rh+_o3&u<<#TAJxfi-0x5J)_cjWm;Z=9EtgO=L^;MLf3_^^$SxsYPM{mm_lZ}W`F z^~oCBiK{(F7sg(d1x+CA?BQ4>uym@cRY% zL9S6aXrG-x&fcvCgFQQm!Y6sMZgnoS9E*Te=lkJti#m73q6Vk7{vpaeC$MH$4W#u+AJZ+&B^a@)wgG2QKsXE}sdU*h3nas!KHZ*_d`_Hv~G`k**9Q zO1vM!Zl}f2(IDspPPM}qA~kf|qDLs+z5!2n%*B@*?!vC+ImCbDTD*E`B4?=C3$KUo z29HhFoMopGNA)C;=>NeZv+m*Yo&5s;EyXU2!*o6N;(XsSJajk~m;W+G$xeh*LZi4q z^GVzd$xG&=MJ(`A%}7o~bv6~+-T{jqzlNhrBdOb!1njDFV0E{naNAy0_Wo`p)}3gf zZmk_Oapy9!cG()x?%adQbL#O)MhY*YMfy1@%MNpBo2%#r1i;In-@v+j01^f z~IU<+Bhj9u~nBaUBBQJK5DLPfqs8kLUvIfXZ{ioPylZowXo|ENn zjyR{vi1Y^~^X@I_Bp%O{u+C{MNutwOsB#3#IhW$-VbY+p&K(z>Pa#V!Phig+F_t4W zigVS^0l!aqP&;A~q{Rrsr{+np@US=-Jxqe0j6bx2`Ld!Jftdd_2TE7I#=LR<^xUVZ z;IVZG2d=)ugO7GWaK#T)I<$~V?VSY5TTY=$-F~#$6HYUxDPi}G2KqHcpQF*6;n?T3 zg8Oe4SDsu!EG*0EFC!IhP$34EN?AiioC0@=x^RXszrbU^J{rG%IPEMs!fQ;e!>>J> zc(eQ@FL$9Wth|;-1E=l;SCdp+Gcy*C%q*ue`YJft>lqyxF1Rj~b8sX7CN|nF;13*+ zgYsT0X1RSF821S7l-Uk!sq|v>sn3P5C3bLeT0gZv%fp#z_F(G%hTixhfvz3HxR*=n zNQ}Xza|t&cS!!w`vvEDZKA$^?N-n3cY5z=m`#4gcdGgG>^)Wsk=8UR~q*>{U;oRd* zGE69L6iYlm8*Uk{#ch{k;75lF=e;u#!=iJbt#ulH6B9?>JEfS{F_IOmK0-~B@1cRA z7FAKXiHvT3mS-UHsf(<#Tux`D zsB*I76zRn0lVNPpRoMNo9zSl)!i5Wlv9ygVFw~uctR*AauV^#2Aj?9PLcY;oj$tVu9K=|wTPFY+CR@OQH}tupM% zxYu~}Up&Z}99RlzcNIWo5?BCLB%qS-Xe(ib$j`~BF*SOGLTFV(u3jhgIKP1 zn96y-f=dH;$c0!DkaHiRy@koR)5>%6&ExA=xf3Kx@Tl7#wRuKRND)IrjHJFXkESlUqf%o*fHC^82}>{4jd- zLky}9tOnm5lcDC830ya*0lKW6Z(Okg&cC(B0*7z>mReDGcfSytW?HdvLQ>H9-39$J zM9^tcAq35gg6gFUSy|Q!*5==fmuipW{9)x-R(A+bP42Jv7z)J1<&hY4u$Rt#Z4OIS z17KZ@9JnUy0;ekQ&FA;9W63u{=-`PjJ&IF>yaX}p%V}d6>RpH_hCN|;bL9X98QqSGE1 zw7!^*{x)VfLOd83Lj+kFcA5lFIRwEYyzA`-KT+@TuDJiDC=#XLM5U(_t$nVb#lm5* zprngdUAs*^)q^oAW+NU;SdF<8j8SRBAv{?>9lxGA0w#{uxL`^YNStfKJk6C2)!_FHAIaHfb{s3m7Y{jiq)=VUw#0;G4-9bK)#j**-+)9qZuj$rj_^2_4{Voa=#;dbZ)%@dQ1d zji8QV6ZzxzIbd_YApS-Q;m}wU7`k!+59qJO`;}s->2!j&RIS7Dil4~JIxWoItxT)4 zqA=xN7}#6KNnbg#y zwT%}>PT$Jp%QJA%tvc)xIb7fHx|NQqP3Cn;O=eH^wxRcM6_k7SpNozBycjnCe_!NA&IUdbZ+&S%&4wS5Vh*8$4Jy1-ZLR>hJaK;kqoU;q6;p zcyxu4@9jshV}-R~wqgq$603n+`$-%|`gd$FunpuJc`8Bxv!+cBua5 z&hiDlpKXzH5Zzq?QxivWX5R;4!iEHp(%a4de!&i`j(vmKI_LP$J>1d0s1ioIoFmhJ zlwykhADCL~NjAUNXMa3x>NU^bp+lo%G56{c&gD@GF}NVf<}4KBrXSl1^FFrVtGu;b zk&iYFlHHG$?WtHV_6qOEd$AK`m%&VI9E|aCfcgh&*y14%W??ey;o^44n_2@(PIKYW z`7zwhL*DS<;~A(JZwKE;9zy<=DqgdqmR52IKtl zn{fG$GG|$wO>In%SiJp^jQZcop?lPRa$vj=lYjV`91ggHR|2G&Z*v!h)>{%Gt^ES? zcVM@i4&s30M>_WRcbIxg8@ytqaM;IIoMCr^-WdE&N8MinT^1_bBn<`b^X4v|a*Q87 zcDjnU3zqQS3wrHF=YHTju`#Ut1IOf~_OZc-3t>!M9nP^hM-8uJV@6LyW!it$}D|fDVn(m>Wra7f<2^pr~JH0(u;Yx zN9YU0bhNA{nWsaNlHI#d1uJht3LS|!!7@+1M@5BK?>-(_Ob##sIV zpC+(-cn5Un??dU+d|*P!wCz$9>h$E(^~qJZE%7MkO^ZW=!4i7?o&$dl-GxSG_sI2S z^=NUjfpo6v!m#xjR3&OLBnXG&tsRcX%mnx{G9R0L z?D65bV%*`?j(a2yVbbXkTJq!yseLL-W#s)}$<`z=TPDK)nRy-F4HwL+_DA7V+tYYe z_8FaePzUd)KEw5s>%ix=GSCJksM+R;4dTK)r@$;U4=qOVwgk9$E)lY7Hqk=eF4Fl) z6P(nWpm=f!npR3+O0@~>nRpqtdZa;Beuw(-@J#IVW-Ehcv@h0DjYX+cCLy?7&q&U#l# zBu{jbro#<(g06bc4}q9B$B(=`dlogr($HNq2MaW}ksB}7$=9J+%vqqq zliqn5LdUPgu&cEYbWaCncZ<`wWt`>?)jEwZ?C{7$u)TYjWE7fc}$mWIZQgXujHNFo{vVp)|e7) zh%xftV5w_Hz0&x1SgxssF{+Vhac?~B+15b+s87Yy!hUc_vXIJK-6YWeJHu{8D<_HpuHU$P$$_9 zsrgS}?6QmeF??|@_=^OW@h1j*V~+Bvj|thfYytY4N22{sF>HLO%QiebM$HQ&>zzm4 z#kcQ9f_SYRjS_J|)oH76g6RlwJ@12>B@ysxFqYe)BZ0mX$HM$cj3?$0j#tm0#t5Zz zH0#MncvW~37MJS5Dpyrl-uIp!C_9cSsqQpx>U@~JTa1YQHpMCHGT}_(Yb*{N1!YaY z;AN`;+fbr{L-Z1E3vR*$hcTqB_fzIdQ9fM)qzUExGF;aZ=H^L1AcL*@!G`ylNNc#_bXj?BR%bI@)LDW>ch+E=FOMyB3u8as zY}u4gBbZa34(n7`#g+Fjp~jpRa&2or313x@e=JR4MzkEhtdeC5Trlu(R%b~KSrPR))IwZe8J2P z!-l7EaLA*JG(YVi`(&m;_>>fKRQ(l<`Vr215jqPEzu4f}$n(@OJSet0$?{EY9xXtg!CWR_~h4^v&kX;9I$r`w221hic#&C}_ zh1tyjJuYcoBj${HM@3=-*~+|YDCR51mIy_lS7j82?U~DzMy9dLpQEUPstjli3%N#twwOry4riyP#p}G5m2bjvlx81%XEt zaAsgBy-IAsYM5Y7w(TQE9MTcYlJCcj4@N=5K4&aj*H3!&m7z&wA8Lxt#YoY~h)>3_ zpgAXSe?b8Am}fvYEcC*m1s~|LiKlS=##EG-1h^GZfm^@zQ=x59uxouJy?rzT^?#ed zxM?D6oM;AfYS1u0Qs_jCgX-y;dGe(E!YMHSs>ptAZo%Qc?}*#pU--o65FR?|g(E-T z$EZDu(6%lXJ8wJV?_s5IUV#UF5kk1^`z?OgzBv4`q6Y#3^FegSSNd^`HIB1tBC($g zNbbdK^7`OiaOi$Y+xA4G>+TBvmv4{Z_7z$DG^znx|3vVF2F}64eWv(1=rY)9iNmr? zJ+|p*I0&0;z=gYKuzoIvMN1m9AKMj}XUI-Cchm-7xnleJHaEs z2BQ|3yQ&EOrh?d_@W3c5Y_Kut(hD{rA<91maY7shHyA#@~9v9L24{1!i<&A%37V&6fO80^KpoE!UeZ!+8J zQHy8e1F`mSHo96~gToRG`I{e+&*g!HOEG2-Q}x--9&>i={0R(=OUFg?CNp=5OrpI+ zf~6gmW-E7ZBXLP7JkjTB+=ZXtPM9VehiT7k`NOs9RT1nGP$^vo^YaeBHu zTOMfvZ=)iiO=AQrsg0(O>V;VNA|sYmsm3;_?;xgApAbVq&;RAzV33_FiJldEKsreZ zN^c}%h_pQEXelQS>DPD*RL;Tf#4fB_Ym5fA2GHp>8g!RWz%PMUEap2l(1Zy#nEluX z_1i9Dk6?!E!K;~YE zDQHQ#zQAugBLpdaK8`pnhWUyXXwp_hZWcz9Su*>G^po#=D;Hf1dYg&&7Rf?hv@+O7 z*a~U{dkotUPKe#SyH8tfy3W7 z;fVA{q=#yf38mXn&y3OgpSpNu0-Ke6<4t3AOYq~mdOGDk58edL$JeJa@zRtuJX56# z4`39vI+2VEhFRct31c{DdJ+dNkEG`d9-&J`I4gP8jI<;LLnZ2vpT3lRFSBH(L5*N% zafLRCMdAGZ-!vq_9wzNw#I3x^N9$&NmX+y-H%mfr{#*^V)g~H~JsRL)tTIdqPlj=| zpNZ1Qm2h!RHnyL<$FDtAirMiDmne<_S=~F-+oBK0MupMAtwBBv{y| zM0z$3kui@N>7Q$hajB*|OnkHsBDa@7!)L*a%GwEBNzyNvHs=qC*DU3gPw2p`zMoXz z;}p#^i-gkrXzHvQg+#lH>V3_@v8&BttcDc?UbNx2jJ`+eeE*Qsd!u1V*=E}O?Kl0Z zs>aRnyZ}XeBw>>9LOhu+fgz!~z}tR=N{$VKC$b-5S&}K+(366m^$Gmd`I5M^F&g#f z*rMA{Mc(xK1NbSrnw0JqhVZlU-0Fm4FnczPNqRM5#V!o1goh6`Ww~_4dYU8ac%7@#=+VJDV2<-ncoLLl4V=f~Pu+siBC>qmc+qs|sbD3#a`jl&;f%wZlo z=WzumipN1*hYBl_-NhR4E-FNf!vz5*cq%QBX=EJ7Hx`LF?3*D|54Q$|qsip)JvC0f z_7PfLl7q7A5s;R14z&k|v*V8xnDXIL{?wq<6m2KDeDIRWoRNt;w78l*fDXKNva>znZ@=j%*_>D1&Ee`3uVb|I2Y#VbkLp+5<_1f)#CK-gTXPdyt~Ndfk7tW_&je*__9pE4#y{XM-T}Q6V2M z?M8EUAHZ{V{ycQ21oi4N5Y1|qToz^1!(D3*T}ujr&elwa>U~XW#zcY8d@+1e(Ms+Gix{ z)9jb;(W=>uP807S61N)Yp!7Y0w2pUlS$R6mJ9C0s?ozf-v5!ZuFXw36={WrJB8B`} zW&~FTH}PFT!8H2g8|;%+7A+cokG4kdr#ed`_?q`2=+SFr?=4ft*FL_>`x>ffb!j}` zUp`;laqFN*Y(sbf81T&#mALn%1l)PRoEp1chE$;;HyLj!!s$qN+U3!NuW|I~cQ?Ag zZGosY-dq&ny_4HTScs0j*AlHx@#S;p7GU!Ed2q(9f=`#L;tywc(ZWR;G`dVuGj!l{MnXNSm>@I@^|&%l5@XtQm@8kPmc!6!G=UXFpV+~CHuWeM!3PTl7H39 zqN+wIIC|Cu+9{cUId?aTt`4u_`t{-reQY*wf0oSS2X<293Pqe}^@%+G5-iRNP(H-X zNThaPB)Z)77ae^Vio3njaOg5ik+IV_YS^4bHOg*tuZhz3*3;YhHfL2nc|eA07G9=L zW^3SzN)3^!(M4JyCGHNy4Dc&o@AIB%57}I4dHZKUL-?3ePWJdbnvTi8hh}a*+-C1( zbeuAZhJ=fFO<*1B{tKoPNe&y>^#}wCPWJ7F8un#T_i6mrr*z$UMSG)t%2Y~z8v47x z#^g`i`1C!0xxH^QpEE;1Z@a$2Ow>v?*HyYz zbrMetbisXF1Ux7&5+*&J!euuob#cwZpK!EM6Ys6l7j>@<Rd|jaEur&*ll&d?q3Kq^B$LTBU_^UBg)2B5jdk;|S5;^G(!N zCzxw=e?}v@Wqf)04yvr9VITJ3Dl|s@p}H;6RC8$$JQ;PA2dAs@#+Xyw)Uu!NH!tGW zFpB$ctA%%Jntc4;YPx5HguS0QqrB3xh$a-o)0<5v`045-US1?cpLeN4aLVC7@J4WhNzRRqZz2wZ>MKh6!J|IKJvaN zduVT?hNu;dMYTB%JZs`AQMGyo-*D(Hx06^Z+OHoVx|MQ5L|^pqC%eRbm|R`HReGc- za@uwNsE0sSpMeOTwebz*fxIL>kRXUyY9A1JRPW)x8m?i~ar57q_`m#UlP=h!m9# zKZZ~C-s5%WrimmzYKZK8qo`!19rtosPGeu$@Z_))+&I652K-IJHCgWHe%ai9gjgF^ zJ}uVhEkB~-;A?z-Q$2cqloBOgucUj2o#x9Up7D+G?`c|F zL;9`i6`k^SHSZnKPA47>qAgqe?E7f{v*igPIX$1Wp9oMYgyz zEP&Q;PUBwQC+W&r7jehNeWF8yrE#b2AbZpE8uk}vF6M9T4)e2N;Z*pZ@MW);(jT+` zk!zjj__WjzMDuA}g0<55f6J*ViG+ZUkC^%|e{}a!5#5%p;T^&ExzY7v^4u_v=88J$ zyM`QkLCcRb*^lr=_cVU|R0#RDGod(1RaCFAjNd<8#aHWH;mug7-$hRg*zZjuQfyd;JDU$vz-e-%)_ z*+%x4uU??iu#iT-)1+r!#L;6pcc{YkTf8_rlUu+=Hha+}uF$!J?{QHV4H>ZH2YOH8 zq$CsW(f^k!eNwYu<}$%v=DwY~gu_5Z={zb^D{+B1GVCzppUilc)qWcm59UP$-1rjeJ=K)~e;x=C5rzG%EX zU!E#QKO{fq4z8bgMz^%+nOCv6hg-{sf6T_MVjp3#xdAmYu*KzjFLUWZ36zDuqogjJ zUJ}K?H3t*>8I8B-Zm$~fJ#7&Oj=dOU%^+*#V_wm$CUP78k``$j@QGt=M3UR|`RIxWE`3KwRKH%; z{^P$odSRw2%Ny4x_GkiW>d2F5n5k>;^hVPDS6CLcl9IqjX|s4+S`~)q42I8JYf+=Q zk>~#{go^ADw7(*s>pv5}iC zK2m=^SSV6b2H@2wa-;n5Dmln%k=Zy&+Pb}GzP8cRM- z?M98h%g}MB6lynk+vVxoz}!iRti0?DUi|WxDUCM3-6zsXi)$K0n;L@9-5b_M#$rS1 zWd7o}6Ar{20D0eH@YAps8eI90XpK4#E6auK%^4NORjL83Vo6}yc@T=Vn(jXtta;OY zrjS~RGk(k=9`_PhX5UZpHE{+gYuP7OI=Yh~b?hN*&OTqpX z4d6Ia{7kR?L+Yj=ncV9F2dAsUun{UO_uy(_aJ?d_`X_^7VLo{OZ527ZH;A0m566G; zH&KvSgMD9G*y6e)kay~lV8xL_rue2CHx{mDlRIT;QQdwJx*E~-$8SNzl6$Z%%m|_# zuCPDf53o}mR4CJ;1WP*oSZe)Lymg`p^L{Gur|kvE=Zo{72}V5Z`6Wz>8_zw}rt-du z!MwWTHMl6(L2rZ}^=qYcbMXL_g-@fZ#lsVw-PFUxoiT~>4c*(*N zT)t9?!=78XeCSm&YE=shdxN+*da!t>ip20)3(#}#MtHi?k@&p*0n?W>z>ZC})O4Gn z_(PCF(ky4<^6KGex+{eiO{L$uY1}^b-$5ENfQC%?@ z6tW2#XF1^0Yj0V_W=%w?=a7)BMWJgrIIhTnv#!x7W0fl0o+j?P1Q)Pm%fslS)s6X& zrtq>PMRCUb7IuaJ@<>Vkey^Q4tJjPN#@)sonSONZYDB-hL1;6`1#a$L1Cu%y!)E*C z#C-A%bb3&O7p-hi{bD$-v8uqVz2%5|MsTe~R{YR0!lN?A@aR=4+`-%yb(Y-4x7tJb zT%R#~ZQNph#7KfDy)}fpBpm+Ju%bWf%xJQ1A;|U+95?p~IyF?IR>wIk88d?qIxoke zRfEf)G2u%O&f?DBW^;v2ZRl8=f!gM#d}Y)up4-rkW=qDg)|;cCvZ0t5d{U+*?@s_O zRG?9wLdYE($@;zr3-T81M0xvURM$zxTj7m(!%c&`DuwXN2gY%sVjp@Oe}(6NUB`97 zMJ(`<6UmO=OumY?LArfB{Bu*MNnL>;qo4%u+sD9yyD=ayTSrP?&xFF(Y;u0?YeC|^ zNI{xlExLp!qU}Z(3~{(FSoB$lPq%Av|HWtdf+Ah$UH=5)9(E8jS7iP_ve_ZSH()NI z4C^WrU`&|?UADyqeA6P}ABlw?Xx)4mXPt;AI~1Cs&qjy* z6{^#9zX~CbsYCD>AsjioiHvQPw|xVWgRDR zcUdEDJbDY~M9c8Jvp;ayx#c+S*(H)*p@cTIN!aY00r!7NLsS1oa%TDw_^VS2*Csc^ zu!~YuVUZKqR+_-V&1u40jmJp4^mO=C`0(cX3OAI`OG5b_)5MX5LE?FG9N&@Vg))a+ z(KuKGt8{*__utiNpwe0Jcs-bus$Umc@9SVa%BjMHp;MWqp8%%s&lYDL(&65-wZ#5! zGDKfniK81Q;?U_u7;ImI?GHEL&7ZgNtKc`=-M$uL%|}$5KN0b(IUOYS z&`&D&lhb=|GAWa%LNl}f@T*EMnl#>q@M<&L?Y zd3a@m7fSrPgZV9unAI~9CvP;sSlB6m(9gcfk`JhF*nTvUxDW2FWtNC&Z|K4^*YSAR29Dq^ms^E(aP3 z9rP*K>k_z^bV!hYJ%RLObr6T?S7Cck28`<&3pszk!dm}PFz=m+)UW?+C-Z9=WHf8j zqq~3L>$^H!U;Y?+sEf1G<1Eno-EKG=|Ctnzx-OL1d&RCIX$X7V@|Fy9@rFqw)xl}x zTu_=72Hy&j;e50WEc(3xZQ7H`MT2BJ{oDf(@Z=L&P&tgJDP-{LPp6`1KrM>D9@zQH zAKe0n+MQ}y4v`~fG2O8`(B-ZSW8gRWs9FOf6Td^l7J0h(-FdPfw4alG=38(6}e~l%4|$6u|6Hi^QKz~RdlBjONSVqwAYybjJ3z2LrHipQx)51II@Oz zP1q}1LIRTiK>3!>#L1~0jtnhi>Jks&>YQ3~yd@W8Z&hRMFD+gjN-<`bEe5_^2*(=_ zSFy`;9)F{YQbfY6l{)F)|vFclN;n3c~kl`JBE}#&&Q?`Ek1pN3z}Vt0_7l| zlBFjz$eM|lNV~o*ls%TEViv?|VTLlTHvdNAD3Rm=& z(WZU7X+u^FxXWF`1eHa-28rB@^2cHA? zV3+S?7?j@w)65s)zLZ@!cwvA*vFaSC%>tGmr^^R^E};95rwVqior81xG-!YBE6BN2 z0}C#S$kdyWu-(iX239(eZC7(i&z>wu8aaj@vK)!&X|b?Z{RxTi4#OR9e+r)^529UJ z?;!M+GnUS~1P+h)(sy-Q@X+EudmW`m_w0KQbCxW{qDf2Glk+j4750fFqYg~+P$T>2 zcR`kwH>8a_&&*G5V4*@^D6R^JBaX*N$(`F!n37B0of;08k5A)KHRI_+=MD7U`Q>1; z#~Uu4s)O-&CHaR7If5BcmGIU_l{D&w!eaMFO& zoVJJDP7x?1jlh7}v-vvjWyDV6F%+8YNB>9-DslWbn{spwY<-uEW2GlTSV}UahXfJr z-<@QKh9wlNv4S}pS3yr`5PkUbGn5m5x_%&j%E;_M1hL0<^4mg8FZu_6izMOLX>m5Z zDTOrts3O&0b}_l~Ny7S`G$?wm2sRG8$Tp8{Fh4j9icdvBs?le5{f7a#t18pBh0AFD zn;EofcrQ^H(goSl`(dArE{#esr*pluSpH%wK|*H*mRm%Sl&ZN?rh4 z-U61sPr>uSQ5gRw7g|O~i2Ed4$;5Um)ZBL&{6)?1!lahiSI?#?9@FSgTW!q9Hln`a z>QrXW8Cc?)ip_tG==;Yb@q_Inm|I6-&~#r?-ae^RqdyUToL(UKaO(&0HC`n+zWuo% zKScl!-daHMY9S1}Fqx{p5kKqGLSVR+CDondOg9_$3lm)4Lbbb4oOdsU<)7uC%X21F zE=oX$$wP&&P9Gy#OM~nbtMp)sLnrGt z656&2w&ZRB(*v*Z^KM(J9lekkOj}PU*R=_L{ZtZ$Wgdr~ofDwbI{-#G4I=M5Bxr>s-1w9WEKnZ&Q-)ICxxG;I;~f|*eE^QydbqO0 z2oKlafU@WJp}8Om&K-$^lRu6Due&M;@R3UI)z(%KTwb!YjeO{kc$|EJ3>tsAK-RuTXV%4@@hJC8MU?6fS#ki>%Vf6118O z1NUo^WNN<$Y?yD&4o)p$y-y9maEc0kP}vDnKnkI!t>(sC?aei$#zJi%7GCgIQe z4a{-#M|S(_R(SjG6y6B7WTPZ!f7&LV;pn^zaT1W zSCORXS~OlM%kA5wxv$e&es_ZcAKz-mwR0qR`A{R=`wrL?}B;-(aUIAKM%*0$3c^cjJ1N1%5LqPChtiNVOJag8;qRM_^f6M^n zohOntiRrj$jU-pRP>E_O7X1E2b-v$Mlh2%coELsHLyZhmm|C8KRdKJFnz9~zYkLK8 z{tHNtc!z6k3u6^SdP!Pd5sd#~3YSv0+R22*2=K&8Y;aZO)vB%NGK=9_+enoCq{yc( zS%`rtmYC`0OG5t0^YccgJnQQdq4M+T0v(+w_GP~@6n$_8%}*ylDt{y$a%2#Vbeqp! z+^Q73v|lYOoS8r>oc);e!y)KawqGz-WX8vC{nD&WVZ|JZ@@5NI2H z7E~i2Ld0?->KOC^o<32cGVY7$pu{_nx;F+2gJVLN z8Pf4m8q_#!8V#HK6k-}xSXsa(*w`pZow|C-#55}ust<;fp9(KY@+2}{di8UPy{-fps2490C14h(LTAP}=d(&P0c651X37p6aW1mKhrU{q5 zsE74?h`cAwTx8@irMf_9ry}kxK0iXMq=wUbdsG1fmFTDuo1i=NDi}yK607 zF8Vu!u1<5HD$h28yr%`IhqvOH1|7^jcAczRph8X8L_tT%ZowawN>u*z5O;3dMBIZq znUr=TT>8(5+KrZ@zlVPyMqeE1c>a_e=+~i=YOUa&^BckjC7s!B9it z5Pxsmi`9OWFn_T=82?v;Gc!D3!mNL|Ip#HPoPLMt8DAsChaba(*m|O)^avIyiy)yw z8y?AL!jgcG@avHwl(HRoc>fgCFI48Wk1KK5BL%YfmkIq-sw{LYD#uWxWz0A!QFxtz3aI-oaEPGI(F{zeZ_5Vx0Xr3o)4(@?B$DJYY{90f|5}==<$Mpw~ z=6PN^c=f3*@mw5*+irJa-I1e$wPlJz?Zmm*Z6gO}b{cpiWGEb69|ukaSHRDFEsSoy z3EPxIi9wSew7%Xk<>Xd9@R6E;Z{7aj=M5?Ntuq3ReGAZabs(y0PT}zS9=ggu#V>dM zU~KwuVsv#UI{fovx8$e6fAwPTZ9@bM%95t5hCC&Y??-@M?|K-XeTviwud)TTeoRKM zfc=~pkB?1npiAF_Qg?$XFhTf=39aO~dSC~R+LnjebEWwcvEF*vJsD?DG$2JyFG2nA z2N)DLk~kQr3BB5`ks#my*zb=ASwxpN?yTO=Dw59NGGpKeew@KxttBAqlql%4{)j@k z!?Tb>}LF|^%C>M8cJUMVceM*gq~eyIO6AB;-L{I9MDRDZEr?Wol}Z5 zDyxUgvAP6K-vS^oe=3P;mtys2HPDR_w)LS4epZ}^=62ig?z-dXBUprGCYF5nd}}^w zl_g&+nT=`U+{AtzIle$x$gb%ehuv$6AU>A~rmo%&X|;dJi(S{?uUQr7Y#0VZuK#6+ z<_^Yy1-^Ljk}C5$Y0dPmDDa|C6YiSo%%^`);`j0warsDXjz5?1)h85qyqCN16bZ9) zNXdhbr77%3;3Xzf*b1lOn} z(AAOT%fx7$Db~Acl+}5Le+yoG@VqqCb1PmnegDSq_O6SM2ILGpb`sm$EF#KtojZ_bZkR!z3} zRm}Q~$P|Lhn-@^u_MP06i4ktPvySyG3B}*8Ge~XgZ5B6s23hB5gl?wRn3=AHYfHxn zp9Cz!!lz;Q;fEG;-YNdp&2?rfHhsd^vwE0*R1QmjAR$;UJ*%`yP=MwdDp0TMiSvF| zvCy7CoV4&4Ga2%YbcbfKl_PV>;3ZC_8k^=YoZ!d$(@vLuDKUUhIVHjA%4)&KbMlAMiy6;^Pew^Vzw>uNL|;98TQVO` z$rfRul@zZuvgbEj)cB;dTwIrJik9WWxxVQ?+*&w6oZWhe%^J0&<>Y%dt2Z2dn%@fg zmdRj*mj#UfzOuC2Plah&JA;IeEWGL3NJdFlvlPwuIK!+Ai@cTipDmjF>m*CmwQs~| z?L6GE=QYZS8AL<3kLN`4JuaT`ig?!kVGFYS@Y1F<;lO5342X&$cAwKq<(~f*9xM3; z&(75dd>-kcwdWmn`^{dQmj(Rh4;|iZ8I1EM-$KR6LF}ux1}?NKDYY0{i!ECeab)sv zwAnC$Y-{U78kfzUM@>SD+oN%U>^9-Ugcan`y1`^p*9NG(Jc(S3mf$AeYfw*NJU_hm zE&3k#i37Px99^~eLO}xl+VxK``q>BYd?h}^gzF&tVF$=I^kLtkN;d!19$Y2&i&@{1 z5k%fLLB%39oc$;j=8c<2Odnjpz=ccr)leHAK6VSQ`fS5}EYo?0o;+XXKb>Ff&cT3! z?@V?;0lxYuTd z{teHz6CRQq$fwwcaXa5g{-{;JYyRwJO@T@9YIz4-dUh6O$O{G0Vb*9?bOnPH{h8SV zu~s8B6F*D}!tyVpxku3`UMkjYw?u^E54pRjR#A)Hn=-L@$t>P=eJHo--N;8SI*)oS zZ{Vw_i_q)CsOfZ0a{mRuly*;iUnjxBN1bHEP6aJ*Uu4Y>+68lE%kjkhD4Z|O!b!de zz`M@!e9AaAe(d2Eu9mP&+$%iHrz&jY|E9;Fi(U|5hc^BF#gt~Gb;6%722@dLIliTe zP#aalHdIHkL>o^uFjj|NV+WX(atbH!8HGvTRZ*wYOmJbu9E`U}Mfol>ZvHHiZ>lrk z^{->_dRIQ^AMv3ZP6SYsG(y3I(AjOy%rmc-=p-w^$vHi2MYRu3yWoXqoje7npT8i? zY!>b-c*3TXYT%mKdw8WR6U#qk;Qjk5yld}69ClZH&gHfVj@{+({p~wwxH5|B_C>-g z`NzybV0FEbyU~52mY%G!Nbm@`Im2L_-VERIS~0Auf9(Kn`&nW-hYp+5NkT7E}CrT z$I&b-aV4>e(}s{;c5u08HDsDA!-Vj0#Ae(T@@Zrc#4fzd_P>9~eC33~bnRJ~`RRtB zztKoXy$%RnmW^vKHAm> zkJ9fF|FRr%GA;v_D!GyEk@IIJX8cZF*`uPqn5Dj@5J@eA3%VcWb5#;e><2RPF2GjZwoN$Wh!oNZo#PiRrp5nBWiC}#e%>-lD$j(u3LEH zl~O&sj;oEtA+uL->Kvgxj`~!!V>8W+c>_K6>BMB&5DNL(;N++X<==;bromqLMPoo& z-0k@j=mBNZ9PKJj^s`G{W4V>ICqFSIoX?)>&uLc_SDSbSPad#D2U#u7AGhK?mq2vf z5zB{ZTJY9fGoH9kpSSduWBJ63?8BVlu<+qEkQ^icy~IlJH(CS2`g%4X)aRQ%?BI87 z4)Wbv`}l}!E`0GNQy%OrzAIybD?hR=m>WL`;emb&d6dgje6>os zQSSL@7OrU+K>hpOsC;NI_8+Q3I~nnv0^g185-V*O3z!Gnh5&ePF^45jY~btLA#CV> zi}C0JG5>L+A2;1Mi~9=p^4n^zT;j4Z?=yAdBRbnLWm7EvXu2)9aUu}51O(uNX%+Z& z{COn*tnuu+zieLg7g8~SL-{{{P+#-|_TBmk54YZdLGN{-adr=Cr7h%d#jJ*1jV3%c zJ`5i?>vI1hO}?(XsMKT7OLoLnN-+9gpRn9H08bnq!1bMyys1eNM;zHnT-IrT?9Xf9 z^(-8;A8&wS)fil4brQ>}4`Sr>RQ!GREmQMXAmawb5f>uJM7TG&lM#pe$&c11Y5 zEP4lPG%v#BAA8}o?G0uc{g-JvtbwN;wP3V6pKNN{4;^OG?E0_U75Hu&k{*x-MH zsBf}h)O`@%sM!fdWkryf=Y&SY9)Z^FOI;=h%0dPE!y#Crs8okSqc$;xo`$GIN z+nFzZScU~tjv_mA1g`Z+)98XC(0K0$O(7a!8dSi#dXJDTOJ=ZrEp`}tc@>s6Jis!K zb}{2rqqOdEIWv|^f!3mlv{*_7>1tiE4ywxY^UblnIRTa~@P|ve;tcn&vGndDf2fwd z1qsv%{rta@s0~ZecUTDSx?{qvU(0ZFnIU{oqza!}|C{~PJ&U1h-ax_1)pX1#Puk`< z0#=&HFohZKS-5!Dvn(zH*_%2vFxQC=-!~03kFB8oP7`sGUXZZ=$~9CyGXn1y8uOJk z*1YYmB*w2y!+3|m@U_4Mau(^)dq#mY^i4J`cn`F?P@mqsKbt6jo`=0=>jYk%{^a7U z6yei)9X8g@oV@e#AfqITao#U62Y6I4j;wCLWQ!8)BywPT@*51R`j4dKjspcrS$ciD zSYOpmqTV0;Xr%N;`X#4?)D2chvbCSK#elcADX~nGW(LP8yVucZx2ch9$H|!N> z%^vH1#%9et^c*u8UQPJ}D}OA5IR?G(Y{5&2`VdB|tK(?FhJqqR@`b9vv}kL zzCTl8zUp`K#3%rFKAUKF^T7~|tC)nxu1!EQBM%m(`V#Ggi@AE}Y_2zC1V4~y&2NgC zHuGC4-xM^SPgGCB+z0=dN-yBCnmxGp^J81*F{hz;Faw3TgCVdb1brSSgZ0z_TWg8& z=-*wyy47b2KKLo3+URSzBS?d<9#G@E9vN}7RRLW8-YlLc)+);nB;oI!hExa#pzG!& zl>7aOjK@soyDp49IBN%&4q3uPjZ!cWa{_i~Pe7$A3Ff}8lN_6O2!rk&Mf0Jz@%y~v zyuinqd(Cvi04QG)tiUCh1UJ-OW%EHueV!DXS9q<=#w2x~GR|4A7>1x(ROV?&$Z@3&AHanh7+Itw-Dt~Al9uKaYFGIs971Cg{ zg}qzjYBx6JEgRje#9ewEd074#)?Ia-j0o%%uhCyw<3(%GY!6|TCd*)T{cAARUJZ*@ zPJ^^3fpGQ6O4t!R61@Ial06?1h@+<|Jfo9Y_MGeJeex+9m~Z0+*&F$lICDN&tZ5AQ z65-7Csrd8zb?h#5E4@~rg@3|F!tF&045Vg5bi6B=Za+mlY`DP2@-Ol5-pC&2NW!VQ zOMtM9e3w@cFRfn6t=i0a+9C_SZPYtF_WdS%+H*qS*mPZ3_c29G zdz68aEv2k&xE}OyL*}qy3v^2S6&@d~2)Ez<5u9F~kLNQd^5kMw?k#nMYah?#o0Ch# zEQoAAVn-e?OxVpIHjm@c;=3r`i|=)sktV=zuS4+6uGd0`K|>+QYbmUvBjM3Q8JJ@8 zPFN=qC1_EQAPYlZqU8Kxd|a9v&k1tjG8IvLPUI;bsv5&uzE(aI07-zo{6N2&50wWa*oaq%4{4?K9iz?kPuFeMWdR>Db#QsMgd z`ADNJ0%1m{Y4hN@Q%u;@4|&nH*zQG#(4Ss`CBa;ezCE%5d-Rer94F4col? z$U&j)s!47!$_`jQy6KcsmQ%`~EuRmmdQ-*Mkh?A>w zV+9qGm4a0LA6WZv6dwK&S!!J>NqznhI!u2w-MDT9UAc8WdGx7|rOb@O6@JUfiG3IB z4p~RRfOZ0C<~;@_G2goU&W+Lu<%C@Jabkn{bMi;^7|83MhA#bLG9mvr@pLVPu~Oo* z{m*hLd*nRL@N%Up?Q#&==_Lr$l!fg43vg_j9JQJ924s{D!<9opaMI5Xvj2V{Z2@5= zagVzyFZY#9Cc#fe&VWzfIxwW*AC0$g;Fp=&L~ zeZL(Iuutqej^ASnO~nIbd31ol@Wv_O*of$4o~CPUb)ya32Mw;e`} zI81t5b@0{UIFcj%fmpt}Ck(v%4p-l;r;9aOpy$gg(p=#S?=DEgCfgUpw5XOGj<^n@K}zJ5 zWh>m9_z6}yrx2BWnw0NY02iI3;C@MY$)^}YT6#vTA9qH=t)4gtkJF%v%N~)f-N+zaxqb1=-e8c!3&{i2ZZ;+@({1vYs}l2!Zc!0=uGIBQNM?^A9- z(Wh)UA~6Q4!~I}t;$va4UM$ORt0$+&dNJSZ3iAA}E*-YQgxbv|bkS0GIB|J4m5i~V z4I?JNlo89piq^33LMuoV zJv*};kEU0#Xa@-pgLt5IVlp%*+ktnDEsYuaS?ESgNx57g=$|kn?qxc(Lna@Z<04>F znJXB3-i7`r_lbyp6BxN>;n3XCtm4LDOtOf=rt&1T%ge)sZYzY&=cM6^Qjy*Foz4(u z)68s}6|v{PacozYwfL-91Kl&i!Fh%=gg>%^^-JCgtOqv3Gvhqr1lx6jK~f{w*gNgJ}f4F58gh0<`+e=j@_te?DLKBq^v>=b!Y%)X62Imv zr1arJU~AOi*oA#UgKJY@@UFQ+JEIb|Z)_VoS`x%kmMCFX;S18kNzTn%1ugEfAXzCvkDgxwWM(3HkaeD@7$~xv-lg#2asxcED}oaLP2zmO zB)Hv_4!_r{gPx57zTI*JFXmkVmt_+0FXA+*pk>T<>M_vToCA5M6QKWYvDi;cfiRGv zC%Ue|3`>Lut3>d5*;u&L#XuNrLPOO;=&4&$wEO!zc%P6CN1OD}(*BV^J%hl2=^~hN zznL`sm&R_?P?+(rk^DPpK_~a5kb`meL0ai8{P!XRUa85_voFkP{4fc+O+1?`n!g6; z;YjU|Or~WUbZDGi7C`7ffyYmE)-yj38pXNlUx$XHmsSb8@S4L8r8+X>P!-J7nGOag zZK>1H2)eo5m{w0Khm4K|^ud-bG^JrEbv-nTwoewbD-X=0vgan#duPN9M9o>);w&Js zThawmQI72Gn~69kJ%)7jy(P+jeIQAki`u5w0_OWNsek1qN{mla_i}x3Ro0?WdryPz zwwX}7BLZwnRcPLlQsO1&54t%@=;#`T`>JZ$u+U$)M9T~JT=O8E+gnJ(T`~7aN`jv3 zQl#@Y{(_XH+O%*?KK=SMf@+r?h8rLD)4E5wL@#L`ocMDV-mUy5sI03jz5M4A-ZnPF zc-t(LX|BeoA$q*BVGkatiY2|4DyTcj2ignt=&#Iokan^KBzQgew0N>(pJV8E$$e~+ zw-Y_(V?L=>s{lLjw1=4zT~6 z&XLRF+_A077r~Isp;UBpfGzi#Ok#w~=vmh{LiZI0v_Tk820p*T|31mm1-D00L;HM8 zNV|hy^K&t6>_fXt%4vern@5vhQp<#{-_8mgUz-qVSsQV8q5zcdZJ~$9&7r$KPRElP zK6uamG<~p8ooc9FhhLXk$y9@3T;e}B63TUH)^{cLsb)GBCIq18?lUAk_^U8u#bB`O zT!2dhD%nE+E2QrJ8kp;y31{bSq$3s`qxRw~&PnqIsP0%uwL?bHv|SvA3{yq-?NYp9 zZz1ZjLwdo>LgezW~MIvh6ZHxW+t=uguT_)nz66;Jo#m@Av%=8k*-*ClxA&Q`Xg*bmcuxclH`%P6m^Y~&~do?*XN=2wvq=T0g)3CN` z29@tW4c#mDknxZ61rshT1_jx1cwW^K4~6KWP45+q?t6~Im(^pwn3-%eEggc=HR$JW z7GQkJ8FskdBeL=~xVAQjY--&v_F(eK%v+wg#Oa+t<7EN)*ZY}#>|O@8pB#bKo@9Ui zWwOK5!^q)Dd$8uxYgCAs$`?F4hELlRzlOR7K=#ZPc&1vz?wV<$SFRBp@I8%trg^gG<6H54k1Ai~ zw;%5%oX0`3dHCtub&R}F1)q<0pvio{w>aGj57j{ zrxMVyZ902oQNhxWt`u}nAB*J2B2;@e48Jv}k?Zl_VcfgkQuk+GuztQSxXm%4w;sO3 z^iK*9JG2)jc2>ju*d6fs*I>KL7u0Z_n!PZkVt%Rgk%PiB17C&92F-=#+ckt)XFjr~ z%cbOi@=$QqQiTy0t_cpu&l59I#-R3gac)=4Y}{yfM$C>XV~bwrVb3&09DQyc>W=ca z3-3RIy^|gbM!Bc5BldG3^VKExh_?_y(SO8ygFH*k$`-7&u3-|R*5UiD7nx>Fve4Ls z;`^~D(P3jMy0ttZx4IXj^4*IVS-%9GE%VXzO&P8lGy}IT4=^o^58KYD9n0A zs-hF&MobV`R(Fx-m$tCghgQI@#$1?^)&No)8^Pn+aC&9jW*Ycw2;Hc!hRa3B^6Q(*m;<1Dl&oqaAY zCfeQg&@rhHKDha{c!RCyKVHUT=Fv5HCgv=tbR`L64h0JO#wG~eTKeFmUMESrkw*V#=uG2sXrnmX zqD2vDUlf&yLJ2){pHULYl8O|vOOY*vNZMDlX^|vLd&p91?lUM$rBxK7A`vQy*P8Us zw>~|;o|$=O&YW|d>%U;wEw*K7B8y43a3yXG`&e@ruj;M{Z}&;Db+6N4#XG_Cb8su| z+U6@T#wqw&JQ&;OZ^LIjT72c#82F{NmAt+#f?tPDaMHpajB$$;dtsOZyLDFzyY0Un z?6td!Y`QzbPSc$@?_~-oc9#O{pvb6l=m67+@Hyg)qV|@bLX?)${ksY}T=t^tpx)>hEWyQlqiKCFbUKtitj$#*VTgCePwPcfa zL`^Rcvt0Dp zPoiM3+nvDv43c2ax#z&V^a*Ul1q)d4Seo`cmm*cX3B9o-QP`!N1+z_`GGcx&Y0lXR zY?t_SHtw=JYtxm^EC37LtiOg*??leJGR>$D)Kn@aet-18C3Q@RwzYtTr1xV>Y+9 zC=dtJr?FZ8WY|Ntp-_5%JBB-(u=A2`u;G^aY|N$@AzvbrTHZa!9a_~)J7j|(b9OR( zH%o_6`+ic_qv}{Ec+;(Zm4e4`EB5CjZNZ0Y!9LT~gxbfqVQ%Iz_KdJo;VvGF+NO5! zvq6cyETty+y;li5=S=SQb!q;FdoA3Een}XaXYl^+FEUs*#PnKZGDX)Wvn6}Cu%}By z*)Q!avu~OPa)HK|A!vgXo3&M*t?E%ABhwedryE?UBX36D+b_YWcLJx{tBN%W2N_e9 z8pt?3g6$JLM}PYm7&>7FOy_$hF;tr!{zI0nsN`YnoB6PC{Y19livk<{Hk$gn6tjn9 zlGw0DV z7Fg2w&tkuo7@pa{;M6CC9gvTyI9kKv%jG8YZCqX z;6AO}7KW|0GjP}Loe=ePf#52xBAnhtjB`pvt%c9w>M>O)N;pZ6j!?mb6IPJ#IaA2b zFhTpTt+;riH~A5fiZ9o6|6gbt}jz!xtY(i>St2Qr6~$)1Wty6-Ed zdn^E35{4^e^XPz&BbR8{0DHVPLEQlsyhk4;1zWse;G;TjOFc?=50r6h=5n~f#~EvP znUlj-hv3-QV5UaqJ!hK!tz@aVET8DMlU8a=3clhD?2J!FSGn0Rn6eFi=RJWZrK8!D z{Z7!~?@59iF4CM1HP{}!5EMj3@ast(z4ttbe&5zX?Onuh@@h5soz_J{Y7zvGr!)q% zSTSZg9^jx+fYp@;;8j&MY#I8?+&YvA)6;mcUa=Wgk2?rCtM`-pf0SVk(SwpJV&JPK zkI$wtkngq@YpShLD=radcEsVfS83#J#VxdYHx&Y}1B~zdMPGVc1ahDULek{HDE%<; z{+9&b+Cs^nqEW;*OA36-Q>cQ%T3GdM6|6ZogWSCsMiTsD;7r{KGW};L$>KiTPdZ~IH5o*yIYi832b^-<1TM$b;I$~9 zc-Du))-jvtI~f8El@<`zv;n3dCBLICkQHm~!16{gq~8x9@4eU(p@7J(W0v*;mtBz0AERO>p`KW)a`_)m!p z^PWTOHl)&b-m0MR`45pSmVve>owNH>3_0(XZ?x3m4~bvrM3?Gyalyx`IN!E^bTCsN zM-ENmHi}=NC&m>}iHBum%pX6}uJekv-74fpTYJ#N2`h;4FTc{m`a1>Ie;6@yVM(|i zCDXpGA*N+=ILv1_7&*w|=L;#Mn>4}5=G$czFV|FQ#{A0Js^urs-44(E-h=NBb z?H*>!c+|vNSe0F+zfK0023v=fwx_)pt+{xeCOuce(TbI{X#QrhrT;nCGgTQCdK92R z$(&S42=D*zTWQ#1Eg~ANje7@*sq0@`GS_e=ahk-Cs1_eGG&`5(_iv?4{U%DCbr@C2 z@ihC~42;kbu3etRic3vo^0aEY{pAMiRuZSh;f7$OGM=lS0i2uOJ5o7s5jmbVNP6Fo zf+<^rY2+Mz;%6y|Dsep|>ys4S8r4k90!!(Q;eCu+=wB}7@M3bxB#mtB{YHb=7Sn9= zG1xQjA8l92w7B}=B=z>tApdnl5R0SLwEEN=8oDKybnkght(ouCTICM)>yPG^gu38H z=V|0y=m_Yy*TGTNEM0Z;H~HkJkGVgOkY|zwjNgPG%(%j_DDBiwoMV4e_}5M@wP@qz zl@}OQ%@5>ubr7TeT^+aPKcFwx*OAMOw$z@e!rKfFx;-g?+={7YmYjV=nk=6XNI5qV;IjZ)E_Dtd}+Dw8t-R4L1 z`s7GPx2uWaDmtlYX<_NFLmz3~^-}KbvR0B-mr9>4+(1t(jUfqVo2dRdJ38;E9B~zv zr6XgCsN0wjvadiHKib)IuY~&2=&LS%3`?N>CdJg#=@4CT_aV6?(ZWbpZKqEUPGBze z3{WzznyQ>kXTEKHM|Edf(MX!kbS7`aMGdx`OY%Qv@Vz*_qn$?574I^A&e=3~*lbj3 zO(pjvM&XOQ`)H!03~oN@P6CVG(au2+QF1{#EfRgEUE_t)vr8IB-K=0FhbK_m;#%so zJDPSJ)+P@HW>Z)8W@);&B>a@qB||sti2uqL%T*h;cI>m6x>ILX4$>OFYr0=YMlTC~E5ZIYW{baI5G5z7hY(gk!$;sl(9uY~G zn2l|@xpYylGOkS&hfUjxN!XQO`eEUDVtu@UCO9=vHR%GTwf`eGX5wBt^7UE9z08qh ze>{rLdQs#Zt)u%c$e^763Oa4@DW~-5JIOTB0I?Nos6?qSNB{JY=~(L^nlech9WQPn zKP<+Q|4fqU*uHRDaCkH-rKsU7**B%`ir2}%o2#ha?JM-f_T9u$av?47IK?bWSHYU& z!@zp^5ypRADS2VGgSbX(z?+;abo7?lc%e_Sls$NfF^QZ+jP|Nwb@NTS)U=Ge>=Os2 zgU3ne5-lvtX(iqkkLi>HeT;IgGAw!Q%ZyMGm;>uv#y~=s8g6*R%;~-%9M7-h<+?bE zlT0wT;xi+)=nmaeXiS!G{XyfN%fXbI6543KUi7bVEfI||00l09Y<)AFJ{=SXSLxlf zdx{J9$n6{X{6`5_3GaZ__o}!@N7G5ZK?q3yT8$5bhhe+$eZ(E_P}wI>iTkoBawk-UoUW!SXqfcVF7_?Gx+;~sejwUH`k4dn9^FPr z=3X)@UqsyHzH!YPm7z>Cv2^XlQB0S`LRzJHhE`No6XQrik4DSVbgOb^!Q2+Qq&SBh zzx{&D-{wNzP5W13zivJC9L_+m+)GmDUqN0k)Wi?{FX$>qMV#`fm!?h-gKf5QxRLWC zHt(vrEAQlBn(aYyTs)RECyb-b$_?Dl+iTF{$}*ggBhTjtB6cPnMe%#G{7cJz{NkUF z&q{^djn;fz-7tXl<4TC*az)O0alR1LH-+jSJOzCV{t(T7vvL0gPpngY#W>Y0K$bs- z84fE+Q`j9QK%)rT#3XsWVS4=6nO*qmznT2$@^Snnbq9WilNP_P^a4sx3dGTOtoVJd z0lZbd6rVG!71wQd!i=R#m~%4`mDN(XH~$P_Uw^yczi@*Y(H}Y0M{iO2@(c+JBU4(zzZEGQNc%fd2p>52_kOP_H^HiuDz zwp(G4qc!sUfW9eTWMRR7YS88Nzs$wy@v^vik^)TJ6G_nJJ2!sE zci`WwWT)Of2`|<}(zIc#h|&T_jFbD1*{9ko^3fFPYdaPG@nL}#GqU5aDA);m%nMPe z!BMDvF41A9Z-Gxfh3w?zcxXXArc@d8dWkF9GHYkHH+&)MlFI4;OAFac)(a8){8$ z=Vr{^j(==+A*dWK)JqEL!O-^#dSA+Q8&!5_p?kBPsSFJDVD`NTymZ(AavTc*U(bad zZc6atL^mG(_#gV5d4tV+!%;F=kuTobfrFD?((LEzJj?&&rj6f9bs{3LXORSdCW=DX zV?#Fj+DFhkpblqto+L?MTkz95ahRd`4Eo1%aA&Ow?zeb{eL4c~O{Vc@ELr|y$^b^p zzJfv9{o&+KPe=W3vl+(ino1Vwdo}GvjfOSq=XE z-3rHjDof?14q}tnEr^wjWMh7AVrOf4y->{NpH7jfq3aTuAiYWM?vsJJ9w~J zfo~z_=~wbet((Ri$rAngQ~`%wBbYghCW3oNhQK8+z_h(${Hci@_*T<{_i&5nBi1hA z_GjFLnxJ_$SF^CWM|rT!FaD`+7x7p71ISaZt@wd*3p9M>L=7* z*pF}aZ-P+Q(-3<24elQ`o?kl<%zw_^%R71}@ab0wzt6H8=k0oc-V(2{C^nilIGU3s z?Vs_zzObJr-VSc{bKpY16P@?c%;LOx1(AEB2peUr$0hyneP;d-cuU!X4*LZ zZihBMC3ra>>Uoq;ekJ4`Y&7N79feHKd!u--R7ZYAIfs%}gQ(zchQsHkLYXjQJ+VoF zjem0;&fH9=y$AI`vnQU2qoKgQo`dA@D9EaONMEX?;fIbYEKN9#!^=LBlTvAzrMQD{ z5V8()%4PXg>M{7=v;!D@;4IxzpbDySme8${K=e;uB0ot4?9W#N{`W4p%yp2U1ZDV9 zdm6UIEW~76YhLPb7+;>Tm;d-S19QAu>FN?|_;##>E|D_FGF35>DjS1OtfhI;u`L+# zW+U(OArV(8)Y5s*i$Gz~KG2qZO@cI&X&uu`emxxIj%ld!Otj$m^8U_62>aa2;xCh* z%BOL4{{=2z?v*H@eiC@TttW>L`{GgVGd{5i!lX`XlDFbDX@LDqzze1mhlz zgxrSl@cH#~;@G_dBC-z>kF;Vs`7;Y%I}(XuqBwJ*V?JKJ6-#qN;;FCe73yJ^$bDWj z7JR3v(H!r|bk@a88h)M9TOYRzJ+($g*DnGteaVLCQ755pZWw9hw?UjyAE}#SOXN(; zL0;kyDUdQ_>H=CwQ%F1cIry1e3;Irol?fBb-)9PRpU}lS11PVu1{XOua+0ZE$;5OI zm}-$lR*!gM@pqXdzW8;9o-Fu}KH`p}Yvlx5wZMz4EttaIh>?M7&m2f=eIQijYQu=- zmJmK`9%H_L1ASb6i_6<`nobf6z>VeOacb2!PTENp$|N;G<;h9dGGi6^2+w}slUev} zi8cPEon(pN>{%6^1b>{SLHzVC`0eZ{Jl~91UCTG1V&%hSmcOU(l<(35w|sGW#Cg$4 zlkId^jt0JL_rMHuDLe#5sMn&v-|YO1O^=F5vT$#>zNLVC9I4B0a`+38zfIw_@j7<< z0x$O8b_2F>#30Dr|3v47iRh|NT6pa5X?%TeGF9AageG;)xTARhC)6Zk)TK7u*Kfg( ztz636#6G7>Vy03}j}p*WF9*>#$FWPljAK>gBiQ=C-t4{yvaDtkWlk#2p}&qy5o-G= z933OWcQigl<*S>q#(6aIV!b%zKN3br-y_*ZI($uiJ@6~5>C)yM_~OHRe5B@$j~}lg zzRMS}JGN=DHhn7W*-OIARxu4{k0Nx_!X9i|6oy+8V{q$%Fsk-=DqhvvfSUPIsI#q| z?AS7r-#$l`W~XOE`5|*ULra<8rZbFhboIfpL<2JmUV`4;^CUBJBB(?=VZE6jSLttr zX+wp$uAʔ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

    ob(geu%=8l@r8)( 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⋘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

    0l7&K|?~6~U-r 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

    EX;rnIno&G=!E-B^Uyme6Kox_(fE-B^V*hz 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

    N6nd7ao!1= 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

    r+6 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 literal 0 HcmV?d00001 diff --git a/examples/import-model-weights/weights/mnist_train_export.py b/examples/import-model-weights/weights/mnist_train_export.py new file mode 100755 index 0000000..38f22f6 --- /dev/null +++ b/examples/import-model-weights/weights/mnist_train_export.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 + +# Originally copied and modified from: https://github.com/pytorch/examples/blob/main/mnist/main.py +# under the following license: BSD-3-Clause license + +from __future__ import print_function +import argparse +from safetensors.torch import save_file +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.optim as optim +from torchvision import datasets, transforms +from torch.optim.lr_scheduler import StepLR + + +class Net(nn.Module): + def __init__(self): + super(Net, self).__init__() + self.conv1 = nn.Conv2d(1, 8, 3) + self.conv2 = nn.Conv2d(8, 16, 3) + self.conv3 = nn.Conv2d(16, 24, 3) + self.norm1 = nn.BatchNorm2d(24) + self.dropout1 = nn.Dropout(0.3) + self.fc1 = nn.Linear(24 * 22 * 22, 32) + self.fc2 = nn.Linear(32, 10) + self.norm2 = nn.BatchNorm1d(10) + + def forward(self, x): + x = self.conv1(x) + x = F.relu(x) + x = self.conv2(x) + x = F.relu(x) + x = self.conv3(x) + x = F.relu(x) + x = self.norm1(x) + x = torch.flatten(x, 1) + x = self.fc1(x) + x = F.relu(x) + x = self.dropout1(x) + x = self.fc2(x) + x = self.norm2(x) + output = F.log_softmax(x, dim=1) + return output + + +def train(args, model, device, train_loader, optimizer, epoch): + model.train() + for batch_idx, (data, target) in enumerate(train_loader): + data, target = data.to(device), target.to(device) + optimizer.zero_grad() + output = model(data) + loss = F.nll_loss(output, target) + loss.backward() + optimizer.step() + if batch_idx % args.log_interval == 0: + print( + "Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}".format( + epoch, + batch_idx * len(data), + len(train_loader.dataset), + 100.0 * batch_idx / len(train_loader), + loss.item(), + ) + ) + if args.dry_run: + break + + +def test(model, device, test_loader): + model.eval() + test_loss = 0 + correct = 0 + with torch.no_grad(): + for data, target in test_loader: + data, target = data.to(device), target.to(device) + output = model(data) + # sum up batch loss + test_loss += F.nll_loss(output, target, reduction="sum").item() + # get the index of the max log-probability + pred = output.argmax(dim=1, keepdim=True) + correct += pred.eq(target.view_as(pred)).sum().item() + + test_loss /= len(test_loader.dataset) + + print( + "\nTest set: Average loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n".format( + test_loss, + correct, + len(test_loader.dataset), + 100.0 * correct / len(test_loader.dataset), + ) + ) + + +def main(): + # Training settings + parser = argparse.ArgumentParser(description="PyTorch MNIST Example") + parser.add_argument( + "--batch-size", + type=int, + default=64, + metavar="N", + help="input batch size for training (default: 64)", + ) + parser.add_argument( + "--test-batch-size", + type=int, + default=1000, + metavar="N", + help="input batch size for testing (default: 1000)", + ) + parser.add_argument( + "--epochs", + type=int, + default=8, + metavar="N", + help="number of epochs to train (default: 14)", + ) + parser.add_argument( + "--lr", + type=float, + default=1.0, + metavar="LR", + help="learning rate (default: 1.0)", + ) + parser.add_argument( + "--gamma", + type=float, + default=0.7, + metavar="M", + help="Learning rate step gamma (default: 0.7)", + ) + parser.add_argument( + "--no-cuda", action="store_true", default=False, help="disables CUDA training" + ) + parser.add_argument( + "--no-mps", + action="store_true", + default=False, + help="disables macOS GPU training", + ) + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="quickly check a single pass", + ) + parser.add_argument( + "--seed", type=int, default=1, metavar="S", help="random seed (default: 1)" + ) + parser.add_argument( + "--log-interval", + type=int, + default=10, + metavar="N", + help="how many batches to wait before logging training status", + ) + parser.add_argument( + "--save-model", + action="store_true", + default=True, + help="For Saving the current Model", + ) + parser.add_argument( + "--export-onnx", + action="store_true", + default=False, + help="For Saving the current Model in ONNX format", + ) + args = parser.parse_args() + use_cuda = not args.no_cuda and torch.cuda.is_available() + use_mps = not args.no_mps and torch.backends.mps.is_available() + + torch.manual_seed(args.seed) + + if use_cuda: + device = torch.device("cuda") + elif use_mps: + device = torch.device("mps") + print("using MPS") + else: + device = torch.device("cpu") + + train_kwargs = {"batch_size": args.batch_size} + test_kwargs = {"batch_size": args.test_batch_size} + if use_cuda: + cuda_kwargs = {"num_workers": 1, "pin_memory": True, "shuffle": True} + train_kwargs.update(cuda_kwargs) + test_kwargs.update(cuda_kwargs) + + transform = transforms.Compose( + [transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))] + ) + dataset1 = datasets.MNIST( + "/tmp/mnist-data", train=True, download=True, transform=transform + ) + dataset2 = datasets.MNIST("/tmp/mnist-data", train=False, transform=transform) + train_loader = torch.utils.data.DataLoader(dataset1, **train_kwargs) + test_loader = torch.utils.data.DataLoader(dataset2, **test_kwargs) + + model = Net().to(device) + optimizer = optim.Adadelta(model.parameters(), lr=args.lr) + + scheduler = StepLR(optimizer, step_size=1, gamma=args.gamma) + for epoch in range(1, args.epochs + 1): + train(args, model, device, train_loader, optimizer, epoch) + test(model, device, test_loader) + scheduler.step() + + if args.save_model: + torch.save(model.state_dict(), "mnist.pt") + save_file(model.state_dict(), "mnist.safetensors") + + if args.export_onnx: + dummy_input = torch.randn(1, 1, 28, 28, device=device) + torch.onnx.export( + model, dummy_input, "mnist.onnx", verbose=True, opset_version=16 + ) + + +if __name__ == "__main__": + main() diff --git a/examples/mnist-inference-web/Cargo.toml b/examples/mnist-inference-web/Cargo.toml new file mode 100644 index 0000000..379a7cf --- /dev/null +++ b/examples/mnist-inference-web/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ["Dilshod Tadjibaev (@antimora)"] +edition.workspace = true +license = "MIT OR Apache-2.0" +name = "mnist-inference-web" +publish = false +version.workspace = true + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["flex"] + +flex = ["burn/flex"] +wgpu = ["burn/wgpu"] + +[dependencies] +burn = { workspace = true, features = ["extension"]} +serde = { workspace = true } +console_error_panic_hook = { workspace = true } + +# Wasm dependencies +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" diff --git a/examples/mnist-inference-web/README.md b/examples/mnist-inference-web/README.md new file mode 100644 index 0000000..3fe742a --- /dev/null +++ b/examples/mnist-inference-web/README.md @@ -0,0 +1,92 @@ +# MNIST Inference on Web + +[![Live Demo](https://img.shields.io/badge/live-demo-brightgreen)](https://burn.dev/demo) + +This crate demonstrates how to run an MNIST-trained model in the browser for inference. + +## Running + +1. Build + + ```shell + ./build-for-web.sh {backend} + ``` + + The backend can either be `flex` or `wgpu`. Note that `wgpu` only works for browsers with support for WebGPU. + +2. Run the server + + ```shell + ./run-server.sh + ``` + +3. Open the [`http://localhost:8000/`](http://localhost:8000/) in the browser. + +## Design + +The inference components of `burn` with the `flex` backend can be built with `#![no_std]`. This +makes it possible to build and run the model with the `wasm32-unknown-unknown` target without a +special system library, such as [WASI](https://wasi.dev/). (See [Cargo.toml](./Cargo.toml) on how to +include burn dependencies without `std`). + +For this demo, we use trained parameters (`model.bpk`, in the burnpack format) and model +(`model.rs`) from the +[`burn` MNIST example](https://github.com/tracel-ai/burn/tree/main/examples/mnist). + +The inference API for JavaScript is exposed with the help of +[`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen)'s library and tools. + +JavaScript (`index.js`) is used to transform hand-drawn digits to a format that the inference API +accepts. The transformation includes image cropping, scaling down, and converting it to grayscale +values. + +## Model + +Layers: + +1. Input Image (28,28, 1ch) +2. `Conv2d`(3x3, 64ch), `BatchNorm2d`, `Gelu`, `MaxPool`(2x2) +3. `Conv2d`(3x3, 16ch), `BatchNorm2d`, `Gelu`, `MaxPool`(2x2) +4. `Linear`(1600, 128), `Relu` +4. `Linear`(128, 128), `Relu` +5. `Linear`(128, 10) +6. Softmax Output + +The total number of parameters is 260,810. + +The model is trained with 18 epochs and the final test accuracy is 95.83%. + +Random transformations are used for data augmentation. + +The training and hyper parameter information in can be found in +[`burn` MNIST example](https://github.com/tracel-ai/burn/tree/main/examples/mnist). + +## Comparison + +The main differentiating factor of this example's approach (compiling rust model into wasm) and +other popular tools, such as [TensorFlow.js](https://www.tensorflow.org/js), +[ONNX Runtime JS](https://onnxruntime.ai/docs/tutorials/web/) and +[TVM Web](https://github.com/apache/tvm/tree/main/web) is the absence of runtime code. The rust +compiler optimizes and includes only used `burn` routines. 1,509,747 bytes out of Wasm's 1,866,491 +byte file is the model's parameters. The rest of 356,744 bytes contain all the code (including +`burn`'s `nn` components, the data deserialization library, and math operations). + +## Future Improvements + +There are several planned enhancements in place: + +- [#1271](https://github.com/rust-ndarray/ndarray/issues/1271) - + [WASM SIMD](https://github.com/WebAssembly/simd/blob/master/proposals/simd/SIMD.md) support in + NDArray that can speed up computation on CPU. + +## Acknowledgements + +Two online MNIST demos inspired and helped build this demo: +[MNIST Draw](https://mco-mnist-draw-rwpxka3zaa-ue.a.run.app/) by Marc (@mco-gh) and +[MNIST Web Demo](https://ufal.mff.cuni.cz/~straka/courses/npfl129/2223/demos/mnist_web.html) (no +code was copied but helped tremendously with an implementation approach). + +## Resources + +1. [Rust 🦀 and WebAssembly](https://rustwasm.github.io/docs/book/) +2. [wasm-bindgen](https://rustwasm.github.io/wasm-bindgen/) diff --git a/examples/mnist-inference-web/build-for-web.sh b/examples/mnist-inference-web/build-for-web.sh new file mode 100755 index 0000000..eebcef0 --- /dev/null +++ b/examples/mnist-inference-web/build-for-web.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# Add wasm32 target for compiler. +rustup target add wasm32-unknown-unknown + +if ! command -v wasm-pack &> /dev/null +then + echo "wasm-pack could not be found. Installing ..." + cargo install wasm-pack +fi + +# Set optimization flags +RUSTFLAGS="-C embed-bitcode=yes -C codegen-units=1 -C opt-level=3 --cfg web_sys_unstable_apis" + +# Run wasm pack tool to build JS wrapper files and copy wasm to pkg directory. +mkdir -p pkg +wasm-pack build --out-dir pkg --release --target web --no-typescript --no-default-features --features $1 + diff --git a/examples/mnist-inference-web/index.html b/examples/mnist-inference-web/index.html new file mode 100644 index 0000000..ed8f2d3 --- /dev/null +++ b/examples/mnist-inference-web/index.html @@ -0,0 +1,156 @@ + + + + + + Burn MNIST Inference Web Demo + + + + + + + + + + + + +

    Burn MNIST Inference Demo

    + + + + + + + + + + + + + + + + + +
    Draw a digit hereCropped and scaledProbability result
    + + + + + + +
    + +
    + + + + diff --git a/examples/mnist-inference-web/index.js b/examples/mnist-inference-web/index.js new file mode 100644 index 0000000..9896ff5 --- /dev/null +++ b/examples/mnist-inference-web/index.js @@ -0,0 +1,175 @@ +/** + * + * This demo is part of Burn project: https://github.com/tracel-ai/burn + * + * Released under a dual license: + * https://github.com/tracel-ai/burn/blob/main/LICENSE-MIT + * https://github.com/tracel-ai/burn/blob/main/LICENSE-APACHE + * + */ + +/** + * Auto crops the image, scales to 28x28 pixel image, and returns as grayscale image. + * @param {object} mainContext - The 2d context of the source canvas. + * @param {object} cropContext - The 2d context of an intermediate hidden canvas. + * @param {object} scaledContext - The 2d context of the destination 28x28 canvas. + */ +export function cropScaleGetImageData(mainContext, cropContext, scaledContext) { + + const cropEl = cropContext.canvas; + + // Get the auto-cropped image data and put into the intermediate/hidden canvas + cropContext.fillStyle = "rgba(255, 255, 255, 255)"; // white non-transparent color + cropContext.fillRect(0, 0, cropEl.width, cropEl.height); + cropContext.save(); + const [w, h, croppedImage] = cropImageFromCanvas(mainContext); + cropEl.width = Math.max(w, h) * 1.2; + cropEl.height = Math.max(w, h) * 1.2; + const leftPadding = (cropEl.width - w) / 2; + const topPadding = (cropEl.height - h) / 2; + cropContext.putImageData(croppedImage, leftPadding, topPadding); + + // Copy image data to scale 28x28 canvas + scaledContext.save(); + scaledContext.clearRect(0, 0, scaledContext.canvas.height, scaledContext.canvas.width); + scaledContext.fillStyle = "rgba(255, 255, 255, 255)"; // white non-transparent color + scaledContext.fillRect(0, 0, cropEl.width, cropEl.height); + scaledContext.scale(28.0 / cropContext.canvas.width, 28.0 / cropContext.canvas.height); + scaledContext.drawImage(cropEl, 0, 0); + + // Extract image data and convert into single value (greyscale) array + const data = rgba2gray(scaledContext.getImageData(0, 0, 28, 28).data); + scaledContext.restore(); + + return data; +} + +/** + * Converts RGBA image data from canvas to grayscale (0 is white & 255 is black). + * @param {int[]} - Image data. + */ +export function rgba2gray(data) { + let converted = new Float32Array(data.length / 4); + + // Data is stored as [r0,g0,b0,a0, ... r[n],g[n],b[n],a[n]] where n is number of pixels. + for (let i = 0; i < data.length; i += 4) { + let r = 255 - data[i]; // red + let g = 255 - data[i + 1]; // green + let b = 255 - data[i + 2]; // blue + let a = 255 - data[i + 3]; // alpha + + // Use RGB grayscale coefficients (https://imagej.nih.gov/ij/docs/menus/image.html) + let y = 0.299 * r + 0.587 * g + 0.114 * b; + converted[i / 4] = y; // 4 times fewer data points but the same number of pixels. + } + return converted; +} + +/** + * Auto crops a canvas images and returns its image data. + * @param {object} ctx - canvas 2d context. + * src: https://stackoverflow.com/a/22267731 + */ +export function cropImageFromCanvas(ctx) { + let canvas = ctx.canvas, + w = canvas.width, + h = canvas.height, + pix = { x: [], y: [] }, + imageData = ctx.getImageData(0, 0, canvas.width, canvas.height), + x, + y, + index; + for (y = 0; y < h; y++) { + for (x = 0; x < w; x++) { + index = (y * w + x) * 4; + + let r = imageData.data[index]; + let g = imageData.data[index + 1]; + let b = imageData.data[index + 2]; + // On some browsers the canvas has a grey border which prevents cropping if we do min != 255 + if (Math.min(r, g, b) < 240) { + pix.x.push(x); + pix.y.push(y); + } + } + } + pix.x.sort(function (a, b) { + return a - b; + }); + pix.y.sort(function (a, b) { + return a - b; + }); + let n = pix.x.length - 1; + w = 1 + pix.x[n] - pix.x[0]; + h = 1 + pix.y[n] - pix.y[0]; + return [w, h, ctx.getImageData(pix.x[0], pix.y[0], w, h, { willReadFrequently: true })]; +} + +/** + * Truncates number to a given decimal position + * @param {number} num - Number to truncate. + * @param {number} fixed - Decimal positions. + * src: https://stackoverflow.com/a/11818658 + */ +export function toFixed(num, fixed) { + const re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?'); + return num.toString().match(re)[0]; +} + +/** + * Looks up element by an id. + * @param {string} - Element id. + */ +export function $(id) { + return document.getElementById(id); +} + +/** + * Helper function that builds a chart using Chart.js library. + * @param {object} chartEl - Chart canvas element. + * + * NOTE: Assumes chart.js is loaded into the global. + */ +export function chartConfigBuilder(chartEl) { + Chart.register(ChartDataLabels); + return new Chart(chartEl, { + plugins: [ChartDataLabels], + type: "bar", + data: { + labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], + datasets: [ + { + data: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + borderWidth: 0, + fill: true, + backgroundColor: "#247ABF", + }, + ], + }, + options: { + responsive: false, + maintainAspectRatio: false, + animation: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: true, + }, + datalabels: { + color: "white", + formatter: function (value, context) { + return toFixed(value, 2); + }, + }, + }, + scales: { + y: { + beginAtZero: true, + max: 1.0, + }, + }, + }, + }); +} \ No newline at end of file diff --git a/examples/mnist-inference-web/model.bpk b/examples/mnist-inference-web/model.bpk new file mode 100644 index 0000000000000000000000000000000000000000..0eb0e55979678d38bfa8c800555aa7076b08698e GIT binary patch literal 1044776 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
    @PgRk(EtbMqY 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
    hK}5<;yQPtgw@{ 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%0AwiVK%n%VNUGa#e3hbz4ja-t@l zcy*U68S-c~&XQb6zYSF+6Q(4PU8AazZ!ZLG;rClYP7d$hae(FZC&BCZ6w%{hLLOsk z89J$4W^D%&@UZ4sKB0Fv*gdO&MuCAO(W%7MH)}K3qkE}(>uKy7nTmSq>15;ZhhXmi zRXkW`KYrU}3>IZy*unO2xHNbae=tRh8_4XS^&@Qf0+%>a^WT4X>sk_wda#{Z7iq)K zX9M_z2R1aQ^9$KJaU0M3r%0_{Os3B)2GW*xC>5 z^NS>G-?Li{iIZqtbG>o>VS#hOsp_hMG;1MfecU zWPB3zZZje;KkkB_Z~bVsC=IwqCG;APqe4dt>U|0H_70?Vew?_x{EZL#KQJG`-y{8W zF-;jdlRBs9ak>7*V#x+g8n$o%J$z?3q$KKcyXt{-rJp9|^sRuLo+EgE>`naL+b3RE z(tv^k!`bpjzG(YN;2$4{^D&w^nE1_(^YjF43@_xj!4m>%C&SFvfv9ZEa9Zd)^w*e$ z$DAeknyzxJ9bH1c3_1$!OJYEO-DrN{vm`yDD#P8_P4>d;6sEYuL)_NiVEJtmojogn z&GK80AKvcdwDk<}q0*e5FP$%GYcm z*77nmx8WB`*;S*OO$h(JDVR2kf4~Wwi!kMKC_b;e&U~d#I?sej90gvxzsvy6yt4Vz zoBBMjoASsi4;o!fc+0B+fE%`u_tssoe!T`YQLLh`JKtjYgkktQJP!IT$G{YCp@8Hm zI7T-Or6)J9qGMy!VMci@J~h+jSpmmk`>+stsPY?bD=deqI31oTlZyL4PT=o8f2)7f zu1ML{G7PkKLdBxL;CrIdGZ9I5udFyHxL(7=W3_~z%W5lnylM!BK9r+g**D>ozc-aT+YiAfg--FGF``VB6D<4Q z1irQY9b4-090zR6ACCkgz`Ye9()6=;Q>ZxR+Z1rOnKkHCEFkmVV^{Y=~A7M7>%#m%eB zN%1lv@AhpeZ^+HX08c6E{LzuuX{dtwi`@|SeI}eXnnkN0-vM7`IezV122a&>g{PTP zJU@o;9OGsDnUOGmhz)=%*^+d-Z1p6!_zG(Ju@^9iqoNG3R zRgB2QlbS}9$5A?Nk-$pt7>Isf?+^i}f)%Ge;dF^9P`oA(ZeRAJS1;Yerq63wnc`Pb zaHTT8*=~omL;_ZYd&1aB2jHA=#7DgNik@e(dB>f@Slahe)HY})4RIO(hoW+M#hC5P z*z^*5i4A#qaRyJborNl5CN}>voU5!`!ZJ!$L+#ZJ*g5|-T8~erDaQuWcinRE^?8uE zSeRGXr4Qlv3&MDB>Pm4%#BsbjdM9Qc&V&^T`6xN(7p{9am70c!!P&c`p*TuJ-Q0j{ zvx8{%?l}afM6xjB)A+a1kw4FhtS5QPXyR5)D)0f=D7ByXKSk#qkJbCe@v=vQkWn@X zNfF6&?pvgk5v4*>H04`_l$K3qW=TT!Y?9(R*M%ZQN+F|ET1rEEDgDmxKmYNI(1tj|RxNGM&V6bA~G$cQM-()i7mV195Pxhg*^* zr2AYE`MKYUEL)PqlCV`YcJpRB@#}jCDqc%uc_xsb7fc)!I@m*(^}yI?DaqwLG~%ya z$%Tn6WKU%o@A~s)xU4b@iuwl`m+8&y-L#*?O`6M8HW?Gc^NT6bI030gPry2k_42^$ z8aSMo0nwdFbWQjZ*mZIvRS4M3+Ramf!kG@ZXr30__`Q)y{uE9OQ}*@qhDC_Q^MzWl+X7wcZawdNF} z@ShZ$yx}|%R5)J0Zfy#&Kj02e{&1ENUE}67ActinCGx( zJ}9qRi#C7FNoBn=Nu8ue8*X=l=_hY|93;v{|0yF<=~7HU&uj1$;#%w_Pl$i+F~;p} z9{WWu8wN*~Lg~;bbp5)+tXVgSD6gDH7jLB)CcTh!)-HnJxAm}k+=odL(M5x%VmAB7 zXZCqZ0Z;jJE@VvoO}~*tv zb0JUMS7Fq=+rZ`bV1>OO6SP;GW)?1mpC5iP7LGTF$&5dcWHwyCC-@)7Z7YJ~*FTWU z+-|46oO2f(9f7b|X`E;}+1x4o5=nGlPMkzad53iWux6gtP`TWgHG0&{F>UO@_~?6> zrc=&KwN!`NeYq$Z%;oLf!U=!C37zgGL0nM`wsNz#`z_oVx4C7AJTHa%!Ffr_R{!H(^VsDAtps8w2p-Xf_)Y4R<2`8AE)yuY8gxzw>) zD|XOgv6CRL_zSX!a=~&%GMS>Yl`Qt34uu!*)4#_GXkihs>lfc*2MW_TR^?M(`G=28 zNZ}CqDKW-`_7uW^@JEttZ$fud1>kjXS=HTA{8WxZSTmLcxBu;@H?G@(owW(Md2Im( z&YepqW6uGw*_|h@~{hy$j7spC}fa8@9jB;X%%TC^_me8ezJ_4k;Y-k14$TgxW&0`3ZSng zoauS8o~^v)4OuCx=$o2ej(u<&ci#@7BHPa6hZ!^Bw~GN)emaj@e@&)(cIxml=L&n= ztd!nj%9t+CJfdxz4)Qleu)#-*UAi$4t_x0}=I^7~=f>_>qM}6o-R6UqmL6(28o<6= z+ga&lVUYZ9Gd2Cl!=wTyG#VGi!V{8oYX5YM6gHy|CU>y6HqC|)Q>);m|5~=#C5OFn zP8ZL6c+vcL6_600$&VWL!pyWFHr6+chTNs7TUHAv6IS5qdmQ&G+5)eaZHFMkb`bu? zAkle*)3#c$tE?}QlE164y2b$#SRb&QS_wZF^B~N41eSAW^8M@_xcJ`!-1_<>I4lh% zaq~a0qFd5w_MR7z@^Lws_oy*KaV2!A+FKCJoP~ctk}-Ar0B=<%=|pD@x-HYLC}HT)%>rzb~ZMos4LrofY^s<)9%qD_d_7#acIv zqgYNNwLCWiw-=3&tcDEIspWx7OeE>{l2{Bg<(LrFZM2$uw~%T73s=>xN!|9pL@RSC z3ia$_9q!gZKu;V#+?mgl_T_wL&+2H}#8j&BrU~z_iNvvz4-kCdG8|-ri2VF%SnzfQ z_G~;vi0}z8hSgZRB!+Yj+LQQ$vDDzl0BKB(z$}R}bjgpT7ROYmURDRu4Vwr*b&_e_ zBo{dF<_xvC{+sNN*?^A|r0GvHhMa1aL5thRv8l(N9x|Q@Z3BLIX!#dx{m_EaoAl@! zz8Qv%I<7Uu+_T-jTzr zjuJ$wQJ-&o@d~f5ArhvT-XoKff|$$;ESdS|J!q*sL@Vj@@ZJ3mu%j0+rfmbQx2eF_ z*CXiRzhjJ~*$IC5*;u?StAtBtbKED(E;{S13$(6B7OWkoRA_9P2_HYnnUNhy%i<3JW~RibyjH}R&nz5zXnnN;a9C1dNjnQY?* ze5bbv#I2@7_ecvUE0mDqO2>J-KWfv?w|%U2${VI}RT9~LEtav+7@`w4{shMr11wgY zWVduj!4v;In9!9;sZS3t&wD9NZutdM|5|Xa_LpS8%{;jETb`MwXHGVtD`6BrG6%h6 zN%whE*wrUC|SVuos_SE)7%#RX*0Q+Ceog4&S;d^TWvBj(aPQD-7ET?d`UrP1a}J&F2~0kft|u1`OF z5w9ym<19Z(>N@2Dd*!1u@p;aJ9CI<)S8vMuV`>TK@@|^XzU{*Xw4||@`yJ`(%0^Q6 zU5@_hnM4IT8Lapt&M}`SGmZnw*#9LQue!=p=d3W=sw+r;huDJMk_>!e*v0-@kx6nt zpMc7pZPe`XYlxh zBde!Y!`RV_%$dZgT)(G@&Hn3&7W_4^X<-#{Y`9C<1{MV>o0@kEAn=?D={lBX5J7H z=HmMa)R}ueSe?v;7rbmBPkYD)t#O{zw=`n*{3|=*-C;U;yDFG$i6Eh^SKyAyS~&Eg zl$@NnpLgMRFeBeKi@Q^IlS@$?a`#gl%(lA9Q%bJnm!C|8ce~B`ccL@d>4PHFvqFa2 zI6HtksL^Xx=is2q2fq4)J>*?0%V3P`kJahY!cl+*P%-yFiZY_ZWgW z)okYSs^wVecA5;fo5D{`O-hwcfbiOUQn*H!D%Cdd{Wp3-{kj*>J*a~=1vd1z*kwk$ zzzcE?5+XB#^vj({Dz&f!o*VpN@8%oA+?Xtql6#Y{@2Es)>?tB&$G3py*hJbiSPgFk z?h=dJ!n8sx8zyy(k}<<+97m~#rsqV0hvF;fNK0e?y)(hxhhLM)C-rb{#w)gD+r+w( z&Bk=%f8EeNuZ(fBvqZbDHs~q?>{6M#M6Oep-upR;?`GIdLY~I) z1tao7Xgr6~D?F4e3`BAyjf`xaN5uKaz5@qz59r!!$~-QOP_6Yt0WuJXM^5Wj&oUR1Po(L1GU|7uI+I;?eRhK z=E-<{mTNE&&gqd~dy*a8+)5hTEx`P^A+ggB!YI50H%`aE73Wa)wdw-u89uNvl3R0zS!=UIj=*wBo7oO{h#nzc*Hur2(ovRDt zwh{DD<}hRQqLk_BdQ0r3W)a?)0*XB1yw_HL>FR>}P_^$Ht823yIITSH9Ja@|PAh1S z$6q*pqK>z@AfH*eWeGdj#j(w#TrsG#4OFw|QrUbfC!~0e^InflW^S;x`Yjuz;efB=BB1fDD`qSW!0*O(sElu* zI?5ia4OU{0p$S#|FCHcKHc`#Br_rQwF$jFNz@5Io;BH~K74ova(76hg0T=e zrF4Ky&zp%WQi4I|B?adv;pB|uHBzLN1uKs^l69t=P@sJxP4i=LLU%RqV{0DCxm8Y^ z611VsQ4#zOT&1PvYoNunk@+|1h{l%=1p14B#_rQM2KBJ`t#DU;aV6rO-pA_zwjhhf0fa6^E~qFlr1YNZN--L1Y!2i z<(T%l6mCYB(LTd7a3Ps{{!h(9?Z8Wn@}G}H$mk9&zbpf0`-N$Im<~^FTN0N28V0Rr zmh3T(8S~E7lDdbFkVzMkVVg$@6SDs*n4Zi5o~9?MUvd)nheVJnUI_p0;v9U|>45l4 zma2R?2>IGkSUp>e?zUrb%LaGwGV*6%s!QYK>PG4>xq~s8@sJJf2`4%ebE){8emt_= ziF3Z5Hs7Z|M{KJp_W<9c# zT-gs-`Wf#D!+d;R3+Y|icwHovV_+xK4XeT-d1o^T9&4lyK51}%(q=HN5GS%i4rqEq z#-hr$0(NPa(Dy$+(pP`v$=q94LGRQx7!psQ#R+v}=v^Z*viBzE>Q~b+hqd|hY{Y}ObyeSHSsyS3r;*8SvAQxyIz=p{)Ojqt2b z0GkR`Ij?&&?6!6x7vu)nw}Elw+ zt_X>yTRJ-7X^tJMSTO_cYqgNdr}4OS#TDSioaX0(^f<4yxL?~RbMXARH9C!6Ck zs*_Y<9t8eagYUvO(5anaWa->Du#RbEMZ0H^rB_@qe#ryou;@W1bnQAeFZD1Q|O zj+}#5`A(7sKiL;*#?0~LMN~?p9zQ*~4c)N?prl-ZKH@;N=X(*qHf5?Kr%xYC=F(`- z=?&t&T0~Jf4GO{~sPUjL9zMPbH(K5x_O=^o)e=>F`Z*i*d|O5n%2P2Yg~Vv`yDi%Y$j%c z&Wy^d5FUK`1CJ+^vAx?@;$p80I5{pBd`?aPQQJ)X{{0zQyw(MJrT&o2nu$;?IEjoI ztiq}g57H7KNM_{4@rsS?=2M!isF4BdCZ@%)^k

    GYzpv=mu0eS3#(S72Z^x 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 z∈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


    hz*$rsrX-hz@(i^aY38u7N#SL}Y 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%0DMoH=VmZB5gK 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

    #l*F(!U7QzvmCQ4k zPR3Sg5StN>-=gn_4B0>}#FD8Z_XK{6 z-WBL+vxYUkZdfa4gK4!-;O+_*PP#kDyfLo(?r5F|ba*5f^?H4G`(PSz|ipy5ZFNn}?Jy=0I;Z63^Kw(U?Nx4Nv@ zeTRyZ2E-Du-F?5>Ae&=CO=^B&+556Sh__#DGl-Ivj%-wv&RbKRZX$dHNH9`NoI1G<7WA z(N0>Q^UCwdpic+bP2Pjy9*fG{#8FEU53+|8c(@+$d!#&G! zE34UG4@=2$mmzi%*ZJoJ>~POx9@uEz0dlyU2p+!=3O@+hP;jL@OzU#)`j@Fm3V+Gpr_ zH5E@MW+Ij!pe^lDRD$d2=XLGD*cTU(f6W}1ShQ1*NOj!gyc*rEo6>h*R-mD-30f9w zfZgLHuvS`3`yNg}@;wZT=O-{L6TInXb1|%VK9L3u6k(xG7~Y=V1jqK&c)mdH!Vw zMow^GR^40#ofT7PmW?4%@R^A(G9~EY7dGf%Fo*hZ48dR{FLKg<0(B_5gmrOVu&?nE z;qP{Y?dL*p#ow#^X3eFfc(W>knKkF3Nk>|)$%G9D(iVqm_LI34-Fy2jv-|*srUPf- zd%HXJ_ZLI`*Lmo1BcDdvaZanE8+-wiD)MlP7f!a=M;ezM#{F%jOk=P+J@7=FPOl24 z-!zY-?ay*{g27@4481{Ic6YK?tM}4_-_2n#vVph=Zo$Y664bP_hiTN%Vf>riNNxTw zGmv?oO?y|v@gtwH$4*p2<}4v>R@n(T%S=%v=^gQlh#{G$l`(um6#F=|ib+yY!|Ac} zab30>SWfG}AbmH~6?VeyQ_ENvsiSb>sXbHRZp8#%JjZq?e}JmNXj(S%1fEP#$4

    LzR}%|7H!t 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 Z2_IT9S)uOq1Z()mD8tr`7 z2~&3ZKx_UqtdO3Ef_3|e?&2EQrrk&u@AybU{sz(F;-hfy)ET&WC7Jed?9x`OV|!4uJeThz+`yd7ETX*{y7a}z zFqC?H7BjeBM;w^K$kZnlUo^i$Jxs#hs zjvm5*4WaCsY~^OlN#f`z@ewQ!3F3lh6(Dr16pM^kf!%Z=s?jKgUXrI^MMXTbOKz0R zd9OlcUZl{vr(a-bOp69@4THOZTu-X+85YH?<`|tXiBHIS47{BO)guD9`{{X{vFswL zOfG_AIbTYw2AbZsc#@nia%kMnu>+#kFwN|G@N~UG<0h7a4*w#3&CN&LWPXy0Ge%Ts zTQcqho3k6h2>LUQ1jM5w1%3(*BMR7oIeNmKBv;@OY(6n(GeOmOPNj6 zyXYpnMq=RUg-eIpaDw?Rrr<#~BXcwsE9!oc$l)-Q5R!pG{RoV^HJ5;aINSL-5xiQ| z;O}Aq`bMe5z3MooBo4H@p2% zIc-~PNbfp6BW*Pv?Eb}$%$it#`g=kN6`12m8vj(l?5B31^!yI&Kj%ox6JzKCZi#nc zSQCEFRHi1!3USseFXZLiB#RXea=hEixXde^ew!xHJW;U{Zx=L^X(J!`GanYS1(B!m zF~@w+wYTJUg|WE2-4_#zKjCjbWn!hJj;Y}}u;p_$ta&hi7j9i;3-fQ&JLXoXrnH(l z%JC>~iOixk{~e~S*2}4pSR~Hv{zcnYy(HI@L!kZsRs18X4o$)D$W0|{vUg}bFYm$< ztUqvxpSp4n_LMwe<^HXr%wHZ|sk)SYx5x*jwk)f795;eO-m&i zJoV0rw|B7)tle}8G&UyS`;1K}cl!&Owpx>|*_1)&Zx5wiu3Udm)CxThR6>(i0hQ2G z$5Z!P*q^c<)YEW3y*Dri24Cc%{qc90r1KUYO&*4u_oH#9<2-(irUPy;iHEHb|Do&= zIrI(KK_4m}M(>fGcLw?LyBw=M28p~i$8M|iirPfS#=Pj(aOsB~@=RkAvHGV;k0Pc7&56yiF;BMh9{JU`} zxm=M#9xz|o>e{O`=xrRWab(Hw8^a{-%1atO>P(*<_y7umiL9Y_A9T0HK=!yW_In@!Ko#8uQRbH+PH41(sH7f# zEtKH7*)!4hMhl7w)e*VrB%q1j)S|OId#!&3>?oJ`jJwpZj1LS@(g*{4m@=jV#5f zu7NT3u4}_!eU7Elkb@4QLXh`)mv9qirVjL_+u!=5xODWp5OzL54JbG@T3G>;UbJsN?v}v7p1o#&8h~WBi_{7DjU6(cjI@*H8?dc301F!W9@FC=5@Q)@DsY;688)B%>SK> zby>-1_cD%M_r(g`OY-PHu@*-8fh6^8O~v0CzI08*C~MskhC<_w_`t~lZ7bFhc|B*+ z;@V53C&@xk9wH!%?Y_Ju;Nd>`qJ z&8D~9ws3rzLONM{9yL~urL#CjTzj0y9AJTbkJ#0wQM^=0-U$|kM6z(`gTMQE+RHc^jvdSTYcE;%E zvX^}FX{TGb-Y&G|^Ubr|z-n^}ERVBA`|c*zV$V6cjGLP@M5=&*jTv=RlfVTjvarTD zo}oLe zyvy_)cLHVOL@HX|#O3lkm|HEaB;lP5nduhEWKCU&%aw$w|4$X%^C%Mb&CFzaHtl9F z$hH!(1?nLA$b)QXk;ashk0Ceb3|XeCiT#(hp?-=ccyb*5MYA991D^W91vUm1vV@_E zzn^n$Mxtb_6PvZak{r4lN=sIhk@oNtxU;+ktfl%$wJzr;THC{1eXo6=>hO z&n`F-Px96TP&u7pxI{C_#gm?}HC`Hb1uLWKd1s=0tR1EvZza)Ans{xaGq#&i>2OV)&o;vT)cO^PFZ7EvH9wGwg5@Bn!5cS&94%gZB zc%P?C)b>o^b?0$?zT9xuDMEpDHFsbiZFvUbXCv_3mwIwL=?~MfU@J`y-4A(nYeCvp z5`r&sdC3Ef&?lln-gped^cx4!ukJM6VL~y@ql8>MD1raBJ>z|sM0~$hj-H=9#I>UA zd76b?%+o*%UInw59qqRys`_tWcgzmLI!vJYLm@cp$Q!_q6L3qQBpc_MLvq(`#(N(o z({BO7Z0Isss_H38mrmS?bM4Ne^H(oaJ-CCnT_G6nT5*|flUdY0ay6H|+=FR#OW75s zQ*rfVWp=230hM~c2j(OkXP>@{5z^VdvTlvqd%-4gjD8lmLa zxFPoT-yl{g2Jq;!8C0(qqg5Ij)c3*qn8PJXexs`;luEtu!(%R zGZVj0P{IDdMXcWIR&J*nNMrJ5;e;uIG}|kS$j0WfviC<}^w3WdbH)fOwtQuDWtX6m z1eb+te@UKkHlVj%X*3U1X@SBJWE`1@@xS|+pI;TAe}5#6aGrqQT$_oP-%H@-w2@C5 zciGLyl<`nX3xD^zE+(-qjMOD;gQ^-&lvr&+_K)hLygO%69jkS3w2 z5A0o*hK3b?+0N|QI9GKFxwBgbQ?0T|;fxb_a8QiaoH>oF|nVnBmj(@bl?t#6IMovCZQb&z(m1 zh`c9PL{Gv%6X)FRe@-fg3b6P`8c$iqjUH2GLJi(M_Y~H4MNsE=?fj`H+L?6YIb@m!w-fqVgii0)f!FvK)?|4< zMwOnR9p&6PHt7fPTC@*G>aB1~{2SiNsfmzx&lYo4LP)B35^wQ$HJqhB1&vkQ=r5H! z#Gt2z^F%%(jnY$T_U~2#y8xRhPLR1p>14Qcq=A#72NXLcOVLt*7~OOKa& zc(?R5JD+2S<8T~xdcbE7dj!#ZU1v18QU#Jum2flT2b8n~gG=#Q<`BnU>ol8>QNc;L zBPa=$p2UWZ>*%Y`2&XJGbwl&+mpqY2g7f zPcwvxl#ryIG0)IzhB_6tFJ|JdmowSoyRg?IjhV9gBponnXJ5DGu|aReK;ZCaR;Fc? zV?$-}cJ{`zO9FQ=*RYT%O8&>c?Hhnf!!c_^68eaw36VxPVd%h3UYJ0`^Sr ze7fX9KIhKSqLcdbad~ng`-KaXkx0NTL zW6@!ZZg(OlZoh<}Sv%R;JKytjA8}`ry)Q}SA}MYsa0crCIl;!$=V?yX1S)wZ6V;#z zpiUB#s(YatA6=V3*Hv^ z96d&g|Gb9@2d#*7-DcvvNQ@qM#j)o2$52VIk;vFz$Dun;^pmYBiT}6(LNq2ehj@z7 zdyaPST%(F?i&#L@jSWco-LnMN%!9~x`cQJ^2TU1iXV}gxa?oHEhH2E&*Q27ieYK(G zghgkllI0HiLAMg(R!+c;K0B$%D+l7Am4mKb7Bo4=6t~dXwyk7Ld+6}%_4118*_2fE9W*pG2?#K?9Xkus=-_L_K(Z)Q&qmQ>Sc z!#pZ$DTW5J@oZkeZFp)nKvr8iK)JFen_gbS{x^7o*559{vF^R7Aa#dtd+Ht8*Dr!< z)@R|YeKgkf2B52>2-B^wgMVP#Yu>Lz98bh(7y`F**@SXmn77P{IPd1s)+A@Te?6Do z8mmC@ze;H5GXT=N4&XO1L6KDx=)!_I*mq3_-2=v1=b$s>`@$8Zw{0Iv+=+s9M|9BV z%Z9J;qu;5XAS@{QvXJf1MXPA=x|dR-z^ zJ4T!2bv}dp_sfaNk^v%b83t*mR?wTqzro;`3@$WM$4P5j!12TlayO-rU9cyVW~MHL z%v(ytx^5WG|F{9Cer`ik0fe1h=}gt^8}!jeBdeZ)4R~hHQNB>UHw9C_ke-!H};Z(B|fe{369bZe6j5R zh}8SDa6?Iy$z@iy7&$917>wlYrKoL#`PmkB9Xqz!8I@U-5a1nEg&N#!0o_M;b7kM_aKtDniW zxh*8W^%0vIx*C>gnB$b~$)H*|5xmdLL7x3G)IT0c^OVO#AZijSw-wry= z8{p5{uapbT5L z&!E*~ktD!QkzG76oBH1qXy!kgMs14@fbrlU=~8i^>Pwp-q4_`D^;ZR6%&fzDr7*6S z9EO_{X3&Mrs?B3Z_R>=;O7Sb_fUKAzfM1=f;lH1m828bhK4{V**STIpSX>hxxjF|% z67KPRZg}G593gaRf60;oS@Lh|1kBdR#t(M!;L+8@K4>O%G|q}tE>_3Gg?y$x>mez7 zdy!ufn1b#ZeZbC0L(|=^R9)U1jfURvW{fpcx96|fyYa!0IbIAu+uYz16Gzo+DL!7l zotE_`Fw3(lN!nUzn#O%EA9C8q>Q9iyY5R2P{>%d;v-K!mczOqp81_OTmyuf*T!0lJ zzUKR8Ev9CH1!TeXbXeg!7ZOG+P}FZ8u6*^A30^lH4_1AJX&RpNm2)k7^ujy%BP0*+ z-FT=1g|xf3h4oNQAWEMEx%_-A!j&G#RFtOLzl3@EBTvZgqbKp+gI^?bMl!j&V<*^( zL{pUwQIxlT8ppQ?#>SUwRKu}`d2alKs8AiO`sBbd3sMdD}Y>O(-UvfrjM!<}UW6nJDJv@Yq%}8>Z~sY`S7{42eGWlC1H3gqpgE;PlEI zJ2q5-@3eI8E*MDz+x411npY5+tAdE7f)q_w;doLSjlbLtt+S-)5xr5y`srL&hTGN4 zf5?SS7azLt>q*?2VZ>Hme9L>&CP-gB^n^7#-{OB6WyE8PI~y`6NHuD=)0f94kom7p zlGdnnGU0hM`D%Co_o>F=_A?aiOIDDXSE6vc%0zlrMT*W8Z6+V(h2X&5JABbXS(2}J zj_!}IWfk8V(lFO&d?5!LkW>=FfbqK|XW%=jzOouguq$=4ZzcPBeQ>hYIruZG4NvAb zlj^G1_#!-+FeyD$s9=z|{x|_&6V1TTG9SejIdPx!DeRWVUYKHZnSB`>NB!qLv+9wa z33IO$vA?4ZVb4e>?GKnwd>-$i_VKS^T5T$t%+mwz(L!10A%5v4Eqa{u`~3MUK{FGs zL;m6O(Dm#dWs>*OPyX9!hNB{yT)ao(cXYtlG$ZhyGK3AaXXxOJQPQ2|PS^WAAn#0$ z;M*dO8G5aZ>U`S-#HE5vtT98My9ap3!Ugb-btBZ2w-8sok9g*K65Ux}4K6)AEZ%N~ zeCu?~xPKF#>}X;YKjxz569H<=<#H^=HsfljgVqZ?YSWTM1XV7OaWyS+Xyq2-xLqC} zEmOk>VJq3yWBT;%qImMFT^`o`ux?i49Al+FQkgRqduhfm37lSKKx?&l=+YcbE$*L!!C}ZkQ zC$C*b(_GY9+na`Kkid{(z0mz8XTsW(mkoS@NtS7dLCUtES^1L zRo7ofbAC5$cM_+G>%H*xZg+_HOd$y*8jIgu5WYoV@lMCyU@cPBq>{PvSTKn)cQ@CA%c9Djd~mE$xYj4qua=i>v(vX75Rn~N4*U+Y9oR*2)uz%o>N z62Zd#F-BJ>8$WCH!d4OP%)I;roG{Twh1G&IYR|hSt-F!<_<9P6#CoAecL%E%D2Ed4 zBJ@-fqBqX2Co!DEebvf2STQ~h{O98AQTfZTIPDGbE({}Ij>ND}7fqzq=5loT63#us zF(g$aJ~7$@1w>ETmkEBg5KZD*%d&*VNI?zV~bL607h9He?ucm9tl<0>$eZ*(PiTz_i z$l$$$46EbG&wnq9gYq5VQGJcX53I(wei6JS!AscO*;VB8wxyuhdyPFEB!snn#f+}N zDt6f|FVdx+z&bR#k=KQZ%+$9n;JfWHG(T5C{|G)&>vg0{eqZ2g1)qnK8ek2DGT5I9 z--+q^vpAkCPB~BrR7+Umm{cmMf22q5CK^ze;9Yp8s0LWiD#;@7DzUdgmj_4DqJMRU0^?RyjG&b)IMIhJo;F7hcqbDAYM# z%7*HiVvlGyxtZ)no@JLZX8VTV5`<&xyhN%W@Q}@zIu*{<9;f>g6zTfQm&uW=3ZA(9 zb;8eijEiR zMCP|P7>{R@wqFv2AF+X146&e`T?Gyq)39@j4|t0IVlBq?x%+b%1kIa((Q4b+OC3V3IoDowhz2tM3D2My(l>~HgfZ2iw7viIpP*y<_hq&HW2k?h0OaQ9&@k%4?Izjqb=|LFjse} z!WM}{#-maaoQ=Xj_Ud7x*EOGIuKUnf) z2o`x91Kj(O)clHuAN6x^)p8xEQB#GODPlNlkOwyYJ!DjUI-Y$viT7h&9piaJlWJX< zfC3@vaNV&9t*+~mtZ%QOS5Tg$nzh2!ASIgKpG=MzOHip?H>Ugn$F%X2p_h#R1D9Q5 zbl0v$cw}=0HnM+s>i@imp-ms`?&J96A%9_XO$;GdbBWS(VSIQzndIe^()Nx2QCaTX z`m(eW{`fk>CRC#4*A$zt*+z0Jrn7K&DUiDAo8-OEbslpvl9{#9fOoF9m9?-Of?ZS` zj|$o_Y0Y);xJ?D_MGMepzKS^aUI9d^uHtqS@i=%qg|uqh;-K6PW;nV5My4oZK=~=y z{puWUD%*^LAuHkC%mgBN!G|r-O#q?OEzH8j+z0JZC{3v?hj-aptT}i7n0e(e(Ft0O zt9qt#T`X5FlakJuypp3I8uwx6E{0r9=lJy{Ur2>p9n-#}lC)kF#8R7UFn`l1#3_i7 zvET}@^qYbnX&X83!CriH(F8X-FC{WPf;44|4PH);rqV{vRI(?Vy=C{B7iyturL3<* z{|b8%gMpd2iSwJxa9>B4m>9xQe<_&p%#|3YG?JNT4kjy`%S%l|S-l7?SV zf_3s0;0STpr|d+$jOLIhmZ#w6*e#R~Xn;v;bBV~l6qL3VMnR_{>e8Z5tmQ?xGt{?6 zXW##D^SXYn8`H}^YEz|`-xb5+ZJWp;nMt@cPY;*+RIu{`&%>7OdKmGV58BUjXx(Fi zH^n?KYBG0CyRsX%D{4dSuqcdLE#=)hu>>C{e!_E(={RY0HlBDAf*04+f~-{lMPpN( zJU9%t!ilgeN0J&oETjn~)8KB93OYTl#10b+aGEa)PAeXgLl?EEc|$wQ`?8AuDysp{ z0$=)eX)8pRXR-@BtjQOjjnv@Q5@-_CV77)IV*9!_;5)IO@GwZUS)Mx3E8PKB^ZTaq z_b)TRrd%)5T$G0%6PIJVfGCY>s0BltZiaH1?Qasl@$WfT{ByAzAJ6pBZ|dhKBU`UMi>G8@{{8cwqc{HZ^GGFqW3E&qA~rr%AXEshZuEn3{H?zJpm z!>$nyT+O253(aV?>LN(KzLn~#JQ z1()3=rF11*Jj4O0PCN#y%vxfqc?#W+ae20H-ejSu0j*5P!|l#*~~g#@FW-LCTYOQm)+scg>toV~sMN za@C?+1I2Kq^jY*Z3Z(gtuhDvg8(~Xlz^=t}=uee1_~TVAPS$@yM8~u6kCHeJRmrkH z6eH6ZY# zUn|OLbA0#qvd>l|aFi(g+oI?Ivd51_jufz5JBJ2W7HxkN>5D{a}@g*UQ7dBjE zb)2;6qb_;UF1VEZaFn7h9#PmPra(#tUN9>Etzh?-_wgkD#SmrBE5xbB5H(B-SmsDF zn?L6ij28>z((i_JO|%(Yc(EFI50-(9?rU&)WJsnj_h6MnBFSrEbG#pyhIdu@KC(egciV?7~cs}%|20@AKFqtLLgZm99 zb2C@*bLupFAw&0Re`7a^B(aYKu92JG7C5MyKsj<0E=3MD4nswjaPie3HrI$WIq2OR|@2jJTSl`Ry=Z`&j(ur z3dzliEb?Y+62IlsANKf`Z^Y7Ep3FE^3C=I4z%iwA$lK^jgBZ&GYyt$dB|ZAxjT;kgY$t zJNd4+#AsqJo2+aF!3TFk%tw8AoTNmraLl`i;V8%p<=8tTwd@RwEikV<6X@b-vZ5w~ z)Q`&G*vTZuMrR4VDyIjX0tw{DWLG>8CPlZH3*x1|cf{lFNql-a6w2imau5y|e05_5 zR_K?}5v?=Sq3|;^V&aV5qThK9lm77Ey%EFrx&`b>(L(mpWrkiqpMcvo$fImv1Ux(# z3VuR8`00)kxxaN0IcZW3F{>5OX=XVzI?u(YvlE)u-%Gl z3iRXFLjL{FLl`%_4*Z@vL+#G}j|#>aQ?VoA+9X zNiIZlE(l^!NzF# zZ2Eys4%|zp8kP{vCq0n%>^wY=eBI>IGY%ag6S1Vs8V3bbV8W0zG0u`la9;@RHj_x# z>e{Axu2qH{CcDps}z7klMyOoqa>1OnH?`ABQzaq;yu3e^%1M%K` z2@bc15y^KF;Nty>T;Y1f-nQYCw@!*R;vCST@6>5Z|5WtRk*6s|FS#CyJ$bA$oz^JW z!JhCFAbjpKv1zTxIrf=k!`yuImI{LL*^}Y;*c@oSn*tXDl3-3@E_L!s#j-`_P}3R? zM|1hybJh*b`_*yri`i&-I1clzwvauRFQD)e$NCxG0{82G5@}6IMrXp$BWK8{t|v;J;dpp&A7ZBPC{E-2F8_%eU?JxnkFI|RdHO9w#5RuHRBqWg{N+i_p`TgH{ zy?UM3InTN7>;8P+@75<=7FZm&E?Gk_Eq9_fx1VHgsdX{zu~T^N`Wu|xv=h4u%`sU) z%4Wx1A7=7h59;Z&1>9=PVf}#u()v&kM&ShU&q-k?yEvk6!VH|$@`F^5cS7CZDem>H_)VcN|@FkK^kTW z&`^gfu=Q#ibgfQc&8-iD;ffaGl=cFu?d#$0@N0HTcQbw6JQec04XBdLMY>`6U8+Sk zV8ihwa=9moE>ZSH*Gqrs-$~*ioTH9eOV+ai%~kaf-;1`=&+&xqJ1{>fM>RA>@k+pT zrq|^%ME>~Awv2><`IIC!?pPjOaQY2y^0$W01zdMAu8-M0Zzg^2u>iw5XW=O4wUIJ$ zn=iZ33Cvd#9(#i?)2RZ(p1vsmWhoSwQ~J|H1N*04z~^cd?*{K@BpO)q zeUB$D%BsPK4vT2ISsYByISDJbSM#^6h$5OkmJRCziy5_rgkE3d22Za(ftIpqc=3XG z!(>4Q-E)%Qo#j&A_UW#4Ueh;_EL?*HQg6u_%^wim(THL56DZzkrcvKgiQ0&8gKk?d zsrxKSH-b(Qz70 zR&e=~WWk$cS)nBArO%`?mIe5%wu2g*q(bxQr%-U=9e9l9f_kns1nh3b&r1dI{aVLIXjI}~wFnXvRmZk*cg{B7eX!PKDI>Zd z4BeV5;Ol2ujJa41*(w?&K24JN<<_H5PAJ(eYl35CPvOtom7pg-pBhB!z*7ZoW3QNi z-W@7r*AvbS&o7{V#NWZ{L@zM2j<7oS-jNZ%mXFm(`~c@qqbGwMaZ>LdoSxweJxvc_ zd#n+d&(A~o@a;IqS%Cz3)w0k2>SDpFN92GMWiLJW3O5f)qqx;$x@PA^2oov8rgmYJ zvdP4fm3$P7tc2J1{aL0~klqZ-f#)Ibfm9x2#Ha!-HOs@y&V@8=G=cixE{2kg0u86K z3_z4~hc7qsR-NhP(Iy9iCLy5nb$F5;=2hl&p>sO)At)Xjac%@jOkIKh3d=}v$3M8db0uKNRpx+Q69j}E#g2c<>~*OhWJ;kURurDZdC_y| zju&g$S?A_)TrLTGsPLFk{hdPwCQjko{1ri`ga3#T6JZsuaRBR-Pct`$PEZwTFLK8{ zleOs;ris29Z29#UxYYYTIl`SiNJ5ZUeI*!Tq zdn)_>Fh&+fkxlPXVf@K?!i?`@1(Fp=o=H1r5S>W6Qi z)DqV_hB)_m7R$e9Mw%UlNdH(&-O+wS;yIX3rmmDBiaAs8(MMUR%m^j>Ip_55SIelt zY$MQnZOZC!d*z)?Vkkd9lw7%23IWy1T+Zh%*e85tr6&F&&w@A?tigEwf>07r+ew8n- zbTA|4f|4X|#RgpXkMsVvs8aKPxp*{OflP4U&Q@5aL;jjeymuxS(BIAx!mW73uoYXRXZp| z8ZcjCoT+6%F&W&P1A6L&@4(%i|J1Rjw>h6H9NLAQl$q$kgp|Orw=8{eQk?Z>b4< z)IAU4ILx~oa~q^XcaY-6pI9>^6TGAEgw|XRT1>{w{{aJH|=1TMBDn zC&qQrE))H!O5|wbBc`<=6YPW6;ODE6ct7*ec98@YW}o&xv+gO5A5fPf!e%MPj7UCG5By4w>g8(MZ@8f~rfYwq!88 zb(sVsD$49i`FZsl-+Ds!BtBf{*v=QZ%*=yKEqd+JPCjpZgckraXxi&3sF87(Xykg~ z{&P$Ck8I|X^5{xlt#~lUyE#hY7AoLi*KRa=_yo*y#h8n+i6}J6WzFO#b2-Ev#H4W= z6`EQEN(*f1;anYzFiwG=y_KNk{~y@h-av!vE`m#N9mx;)k4F6!!)s>eS#=!go*PTc(fzO}Cz*$-h184Yv``MT5{52u$ z`8leX-29%b8ecqF<9Xl!D5L-VWgyUu0{?S{UrnRqvz)jPk+dY{4r{Icy zo7WTnFXkZRe3A{AFoB;Wl?`6!-ve(Aw{Nw#Bx+Z5!9p{GmiY<7A|-Q>>N?3rXFOni zl{gk1ir};nS8$iO!k5nUrCW@PNU22#KVp|A@85~fq<-&DnB?2Rs2N7H=0ET7vy_i9 z0XH4t=ZtRXyHE~)y$|7BvoYB5_GkVX!^E_;}c61!NeCTB(M2C zIT@43A)T|)<6R*;{b)1@>3M*^_a3nBctP4$n=wDEzp*0wD&X1jn?zdVBnTgS4%!zt zG31mp`FzlyX&_lVb)SemE1!c_s0}@3JfCj$>IOB5g)lu^ktQDEoXLH& z>D({65cFU%SvLD2c`)m9{ovMXyeu6B^62Lka&6mHR2k1Ef8EnC=Cv~^b~ML%r8#W= zs@Z_5jf~P>4ZIsu0r}@Q()4>B>)_lWJNwQ z7HE6pDqG34zWde}aL* zTX=Zj69j)sgVE)3sJ-+o2`oCw-o14kh!4jpvnXXxR?VhgOlQC@?J?-ft0JE!pM;sS z?BV^^D86r432}Wc#00JSk1RaL12?N!@?v!?Z{TAQpBX?>65d2MuCHewd7S|_J0&Vq zmR!#ZZ-cbc!u0%YV`laDPyDW1`pgp_dGc;3j;#z3XO@&2!2-Q2}po@Cz)B|DlEc)G`3 z@OncINJf4IRziym&3nvzwK&Ph?l54_?%xYm*TPtvYFkE!rAg_9!7o>HQZ1(D6cg^vp<8OFeFQ4N+T4saE_#_-2z5#Iy%HVvBDsNAJGm#lc zCv{=sbiYA8s2uqV-D?L}`HE3km2QkqoYQl&XA4t)DG=tBdcgPJm&hF5YTg>VA7nds z7M#5A1hYM-i8(x7TJH^zeBu^6o*!!EDoYsuk!vI) zSr8>NzCyC-AF?XxthJIs1dtlexB8yz1qPNd-|J?P5}ZXoo)d-&UtM(mev8%GZON?H z-4DA2^O;AF~5nEoSU(m2{P(o=Z`tlLfa}P)b2lC6zBDd zx7a|ef~R7m?LvBG`bF58smChMT#Sm3ezJ?J@^QOE8PS?~pUFG+ha}Gvz%5?YU>m!f zdMfF_(Q_JXlwmqpY5!!u=bqzo)Mo6gfnm1amE|98UPku(R{*B5^T@2hF!H|NknQ;X zja^%RhUw%l0cn>zOvvRexZp_#N#=6AUl&!e56CfkdVDHo%^n4j0j`&!l*;a({u&|< z5lqOw0tPxB040j7eW3%b7@0%|fBV6f6PsYMqdU2l6v+r?b&{Itli+syN!Ts8kbE{S zgB5pwLSCRWi8q)-+4@?>yJI>Tbew_RzvM`d%`J9v9@mQ)y9%3CRI2A6*=hBKo!BA-?jhN5cJXfV+_Z@~eBuOHAA8H(z0u3AQdM9Dwsycu z`>C`+r4_FF4Kbp1*N9fr2EM`=h1$3ly8F{nR*giHl2$)Q(c?EK~Vu!ZZFO-OoA@~%V@-$nWC*L@Mh zc}6EGoF|KSIyciS`CDibpFoNanUd_YVyH6o3j(;0T%PX@*5g(*?9&&<&KJRuyM|+k z*0!-KdCs(bqr+bWq3Bi~ z%$R(TR9FRIe`hNz|NV#cZ{hRIuW6e>B5VoI#d$9Iv^@k|2A}X94r=k$|60;jhl=Tc zKHel#t_qYjHe>i~W7g6mgA~>0aGazzs2CcB;Q#c|$apVPD6<5cI9JA=H=)q@`5dz` zdns(BhoQWd%cr-F64Mb4v^iP<2mF_k<6l+qYx_2G$t((i0VBP+nDx`PrQ`-p`G&((1TAQbkI2##>ZTG(yfa9TGmc}-@K%B710ymF3VQZ}ku9b5cYBF{BR2=e z{boxh%Hza+2u9=Ey2;jN=v@KCxYTY7|J*@$)`73hbmOck(;Y=%w4j=20} zDC_=m4#bb&#&5s3(L<`7gUU|@x0uC|6~fzaUAq!Us@$* zu75K~zHMB}$gyVBrcDmkr{IqB>>aANtQ!=K(hR=$qzi5PH+)PAuZz(+CT*&T^*a+*& z9+LQfA4#<3D(vNYhbOb{(#|2F29@n6sI~7yR?&s)XDv^`tIfVx7jq6@*(C8EEZKv? zN|lfjItrr$b#O(t0oN*;({*Og$)@AIxY3s5Y`#8EZuH9IkNp#IP{fR8s)#_ny)+}a zX*-Sm?>Koq{+IVmRG1395abxcbMRDUB(-imidVChu!+vc+^A8o5?n`h^XFLGOcTMc zZRgPeW5Aspgp;>jP=1?m!={F0wB%Kgxq5qOlddSbXrAV>d|9;Ql_vjvMH2te!){Q1 zq=`QYiy_uMhP=)>0z)lvbOqOE3s}bSZx=77OHWUuqMo+2EVzVLy^3NghizfJ)Q)6q zlZUG>gK?qG4st3!5$`_ptlz|OPgY$offqJ>6q=U;>fS0i@;(6m6$JC_n|dL4Mgh~F zB#q+}CV;+R4G2yu28C6*IK8YJdot3fZ(I`o_6*{G;y4c;D-Gb~u^2LO_$pEFI?K&X zPDDG_jzrg)kl$m&WO~naQu*uwoV#E{D_)%1yLNR0;TVc&V?o+XYMT~@$7Vibtsd>dn~Hnj*1=v-@LZ2CCkW%na0TddoXFnw zqNt3HP`S?^Hh5^^jdw-hVpj^mUaoj+NjCb#o~1JT^iU$k2H)NE2TN-;BD?x89Cn;c zG~|s*uVxP9{HZ31*JDZlazo;KRFs~&sR}v&nbFf8hLj7kan58Rcq@|)Vk$YXZ*vnn zP@_r(Vhf38%1Lsr?jX4+`Il$CIGsPRCJDVY>LL2id9pUK6YjbQ;>3D2`kXyXJgUsd z!Xu8pdZXY$F>YZ9zk-l6+n_2RFyhq2%hIk{?EJ zvtcXlUc3}m7mb5v^?&r|3J(-G`Ufhqs&TpK7uKQgHCa0rh5f?^f!!mI9a8$FpMMvw z79Hm~lUJblPZE2cH9$dW3IyBSg|%1jlB}?eq^Nr<+Yx08;`;;f{`aq}|Mz#W?Y;-u zcrg;DV6PmUFlI>5f2kpjoC5h)QkW)_#(JerYMr?fs zvwF@$`FIOEa(e_Ut}Nx1yqAS-hTM6w^c5V@Frg(X@))y00>{J+xQ^37>Tc&uRO^p2 z^|b&u9hGUKoe>l7>kY0e`oPrE3j#)JA${DM`<{OUN5Sp5pxTYQpIAdLaG&+cop;#% zUwzQcQJ!`UMZwnRN1*KO3nn8*3nXivvND4o;jrL!{uS=oeq!$#a`xvI6i(%QHSYkL z&AGb=Zf{kq;R#B8*>rtF02WHcfPKw#@M)EUm)3H)Yj+KbdoD!(y^eT6;~Qu@S#$i} zMZ|5y1r7XIHpJ;Jr0zWfJiSmfjv8lHGiIb`r!d|6Zx@U>C!xWu1zb-_2IFpiXCs%3 z;>~Bbpo`}UN{xN&c=|f36mXWgVy*ZlS#L z7%tbvDJptU=vW5@32$I^lMoK)Wk7dyKAf$+N%XeH(I-;GRKj6TEK)#B1mwoi$y;1_*?%nJ7vmM8kQ4|I)j5?bRm*%@~8u! z339ain*vp|OJrXO&%@rU8dS2WleGzW#U5FCn@lv=N#nIOkssDb!|{Rv;-QJXc}q#-pWQs~rRSL9MVVZ7-+~Hp`P5ZH>q(32Xa1}M zCrD*Df!1ZqD06=guQ|Y)wi^8<{_$R%f8Gq{=ZO8lY%(o83_kq0M20ku zv-cW;ao6dWkZ#iebL)LLF6L2EXf6bLMtc~gz{6nETE*;I_YEo}lE8dUFfF$cM)zZ~ zoJ;l^G(J)wYl~Fbs)BU7FRG7OEZq;qkIs_{nQL&6RKZ2Z{Y0AUS9&-cgX`<%Ve6`Y z5Nv;%{rD<|e6%fsu7o5!mI8aYCESOGm$>ha?jn+BF#xMd-$Uq`iCEW^$aN*T?$qPc-27jG zEjwbc>gzT-+aiiMa_6a!oM)teReVi#`_kNP0h%kh*8NV8IKZFJ9~7 z{E1hlKf%S}F0WtRLfgQ3rXfS0RdO-)PJl~K^zMw)j+gzo( z7DsTOx*_e5SI37Z$H?YsjyTm%kvn%BLmgp%W_x=gT>aR@d^-a0rXL5Amn+C!oW%N?L3yPU`d8$P~?guqf&$5S_``*8c+&*Lxr8A(I(4+7)r zfwL4kz(GU}_oS9HPo+-K`P}`Hs`e+M_1KPc3s+&7*B0=(Fa^C1tt7g#^1NFIrm)|a zULZ*>-^o1%Ke!$QTQFQkzTX*#C%1k>QtUH+P~$wDBJ~W8IWD3`9`1BerZ5)R z2GHEE-9%H~7*(K{Cf=BY@jKOMO-cbKpVOt$BZXKWl1xGm|7KDw^RYcx5C_!u!IIs7 zVRk|khAp3oKWbL<4uUP`V>pT-5*vunwjg>`cRx?k@h$ITWf5LnRQjRWZ!Ej;(@Y4(!~^!X3=A~Jt2hzHz>h^Pw$!N z19!=l6K7zoc?WyX+Kiq!lFNos&QB@zl{>?Ta18qh=ELJoGQEyFOWk-t{&NtAV=|4* z^synbQP~H-8~F0yy*z?$!cpYNgJQBy_Y*uHzRjGk?Sp+Sb!b{s0DsN~!jccMwD9qD zR_nJb)I5)1gCD%(vYH#2K*3{V)tq&p`uRDTRwh7&eAQ`VZY=wtbeOMDTfq!E4w5nd z7ViKpO(6zt>+{5fKe)YRpc&Nqmu#x7L)N~a0agXWdOVbwV*0C zk7%noqr^x#iS7Eyte!RrdKXB;-+wdkVOk2g_qZ93Kl?|7*&_TjDh9pA0@xVnjoAjb z+159w*tM@Xw?cIqs@yAPWp0LUm%T;jTjlSTKKNnq4cH=@4pI+2a$k!yb$CgB3d$oQ*9_A^HCR^usK zW^G3WBQLSDp6&&R<}z>&%0zTV3+(D}BHpcEn9{(ruxZLQ@aV9DpUho;XR-ud;_|l+ zmu|uA_pf0v(UA#A^?+ly2iW8zHmIQg6pk8N!GL}VTW9ViR+b7z43tWKz3(?N;ff7)@~vC%2yhre*hHlLqRNS z2)aFgkQo;&fk~E!MuRB!zu~7uQp=I~x8geKX-bE&xnum6LM!sRW9pjW3Czqjz}U>h5xxv{F)ap_bjQ zV-3Qy#&}nQ@|ia;DUo-QV0Da2;Pet%crUI8{zv~ZHF+~>gV}4QtA7OuCgd|QAFLTW z`(sdWG6ABdY0|VTDKIR00DFzn7~7sexOZz7Tqw6CK4#jqJ?J_AR!=9Z@LQau{qkiR zT0piDx9^P;Y}` zRingt+@1ZiH4#=8q=WdL3+&hZO(ari3;f%iiVGG*kUs)p^ik_xytl-aY|3e1jpIJR zmOrBG>V<>gJbo0!FDlZanm6!vOa}aIW$4mt2J}J3Ji7WkA$}4~q;ut8_D+yAnz-kH z8B+k#7pHO@Umr|+{eX-wsO6jFT>t|&UC{p}Pfs2;B`RY(_&p>GR39fZ{hrsYzuiT! zIe&$fFWL=rzCU0`%+5hk6|)jt^!UHq5bc{Gg1-!;O*@@_W$l?y3$wj!3953M5=ys$_-nx1(*0k;$< z0nbSpP35+cHRjLZbdU&*co)lP#+ehXgWuVD)s67E4M1nID*iqsk8H9YUQ2F))YE(D z#h69hd+-4Q#eQI(&5JwvA`jc{O-%*t?DN@z`1YJ z)_w*1luKl?sU+>3p9E4IXLaNP$7BB3#r*RAMdU8(LgMI2*udq2?oK$zw{ywDG4)v3 zv4!)8zs<(_%?mJPp>2E_fnC2nKI(=D9?le?F z{kIfitE~^$oG&qz>h|=TH-g2Za_aG=7$rHEU0}N;wpmxu&eOR#cc}&5$!54-cs#0o zp@c&-GKV1QR!|`zC zoeS2uOJTc#8ohLWBC+PsVTZ?YOm9}CXF^_(x$ZVtmnuNBRtw?`&vtfk?`3kS!GO>(d+ag!3|3_aU_*;I z^f*zRzgmRZxpO;caU9!*A1_Ibl?LwE!?9^%9O1XZbk_dyeR3!HC^`1o8^;z~VguFT z|DKqRI+;m$?1%W@>k~ifQjW zh}G%Oh>Ocb+T|oc|23x3!|Q}FRy&4PrEWmAkv~wU>ImQB5;6SvF^+BE2Ni3xiBg;k zR<^!@D~np`@_mWmJ3#^uD77;(NwQSw?+Z4yES$6(UT3}qwBXGyQ|jzf$^GA|*lKRa zIP7)B&y{n@=a6Nztg)ByUcO_}cb;Nm#;4=`m9uepYajo$LmCcQ#bR7%2YcYu3|tf^ zgDvHc*dl#HczRNwW&{H}y6Q05B>#rj>Xl8#K5w9_7iVMf)E_W+k`UW=KLAbc3c<_C z_lT6SKZq*G(5`xS)SOG1U7@$(@YgZI%ZdiQl{%PuCkprWZl*<@#iVq828iA|0VN4% zSeFCcP?>*Ti_Ut#43lp* z&?(|6_|>qE|KxfC7}`&7*m7K#H4!qR^6DIx;M84M>2!kqQNEqlO>AMKXKAt!AHgb~ z?_s;DI)l~QqDk6!QOFYHe%sBRacD^!`}n>tU46Ql1g_jcr)$Kp zQ`C==&8H-=uV0XJunOTV-yR~|wHTj2O`wXo_Ox-thK>Xi2i2p@8Mzt|*>ep1gF4yEZI9Te4|cN0y^BEW(K=F)!si9Q`vc!@TqS=i zjF^RuB_RJy8Sf1x@Vdg($?nD=64P%p5==jn}gx^sz?syjRw9kOV>JYXq zg7QWx9e-yG1+`j1g~P{XR6kfR0W5pZeyJ-F{=MbfqGV54glH2)BUy?<)S zP*XPOA5nn6tu2tQzm-f__k+otyo*(9>8zjYcMXDmUWT^Dt?-oVOKLqVB&wwzRPCD< z*O3~8cQ1>faZ57&CGdc8PuE9g@GA4@b}YHAR>;T|)+nbj`&2(d(Bnx|V1XXBw9{jJNd#;+*8y!JNy=<) zKm>b(eLE|a3VeCbs;&3tFP%NW+GMPQ7lz9)GV42gaQ_7AR%(fYistC|zLGre_`?SD zoFN78`Rt`>`yhI%A59Ge2ueJNBKM-|%f==_llTOpFX>9Cz*IV{ILg!7)6ASYkp?zi z32c{JJqc;pL-bl4aQ25cq$910Okz(l6*KM-dm0E;oOA9w39Mf?B?BB!nn6U4BGF$S z3gHJ_Y08Q%#5|y${F*L;t|!*eh=gTKgKZkwV(iGi?8qU}C)Sc_sxRSQ!YN|;_7;pZ zRIq}nPgu!sJXkBy1M$AvxMB5IjuSrw0gG;sUr+BcA{KX`idoC7bmn+OVr4Lo-^0GO zJOHU#m)XzHA3^38J# z!_!djxfH{0o}j62lTqAT1JY%hc)aq9Y}KMunALk7ES>eSVFkx`(BVOcF~{YbZbuX^ z?xJ)5P6e5J)%=GMZ{SjN0({~6BV|#E%!i>(Z1)pWQqXmZ*zMM$nte^=o#6`F>68xs zN3vmf%XRpZ@)Bpc2vYqeC6sSEfy*JA!>@<|B6Xt~j83^=oP`nzZIs5>ciwXMFM`Ys zT~k_m*NTl2djiK28)A&mDXp|e*N zM9Bdo^PY1n-{*)D>2E|I>bgJ7{ljSg2?!;RR>tY;P_2h+i-(1uwZ=rVg%+`@lxE`hZB!brjzrPxE=taM|Mvz>JK;t}cP64$ z>so4^CxOPcGf1xyz*(K|aML{!8ixDX*t3dI=HE?TI<`Un4{H7!A~p(!aO!o$IGvwt1TaOf5ekUemum4sl)mC)A3UGBO)>HE>y^sQNNnw8~ zT=z(UH)%W2W>p#~Yzd`5oCyDlPZ-&5!SOv~t6{RoT=MrR1KS&EAQiKzUjya>bFDo^~QcE|eK>O@NjCvT*&d zBw0TeMxCderU_OhG-5!4Jnd2=VvalT(Aa*Uo4Vkw-XvVAKN)*NA2R3b!of-R6fWzT zNOzf8(If|Hs287vZ-ufTI&UX4bp8kkHW|}f4+W{XA9ubIQ-o-mg(@bh_|WPmQ^R*e zRm}?0@nsn-kS(WYo7>s#Yjc4nM&pEwJE42?PFQd4L@d8XW1`pwyuU$}-Ma7vyjdyE z9BM^;xo#IR;`|v4bw;ec3>J~F$Clh&6pve9r_yUnJ7H$*FSz&Q6drgIMGU8} z!&xF6&-=mI*%i!rLn$kEq!gT!gZQM&ls zZ&pV+if;RR3pAe{CV$K|a9NTqwOzOrt<;9WS@}5YBX^dHeIF;cCPcAK$>OwATaZ{8 zA0~@TZONo%&!Apf8@}1*bNMumskFNj9v4RN+S1;!xo+ZI{==V|n6G2Pd{SW7+jHo; za50TsJq=mW3VzDP_pCp=24-*aCU<*7nThHb$+jSAy7<*WND}d_UvIdT>K|DNn(6X1 z>&YTU*ZMQ;{#pxmhO@~c!)GLURRF#4uaX&aOQHf{iSX-SDh&P`W$FhW^MsY+K!S6C zfA?XDsn%a|BbkAg^Lk{_e$Ip8@(P6R?SRF;d|1;n8w_-nslLudR+(^K3w3Q&h^}Wg zZ<>HPXZKKxy+x!ttCY50u7;B61z>tbmNn2i#8}^KBWF#&v#Ei)g#Tm-=^GS*h}kcI zE{q4Q7az#%ib3*8QG#|?Z?qO;7qQa8^P&ILYC3GQ56|p~;O3FpVDT@KdLEX-%#=3X z&E6#Tq;(ae>GGBh++%?51NUIZkQX^z7lHSyjY&Q;%3gO!Bi6fi(wbt1hA3Inm0<_r z^dT#vP{+ggUk^y#>;^Jhc@GGer(j&E3z=0tn-%Z-%`vTI>F4v0;nTA>%!~kSsGdBR zzBiSCRiSYt*7GWx7XFV+Fk1#IW@uBl=_ereMIHalBVmmBxC0x{U4g%aGBj%SOf=t7 z#iY7bGjb)_Ah2g5)%v%c2rRH9MuV$ynZ-poL}W4SMhAo`wlEo69VT06~p>w zO(8u=N)U9wi;i5DClWDhKxN`Eos%1OQ<|EAAiOm}y&n=`IKT2LH+vsxbWni*pC zH?=Tn&GL96wFthu0sLDL%)DEl#(3`Yz$4n0@J*xv8Xv}zA+crr?o>JQMC%C57aRke zsSDu&SD8PWyXkD*>2$iNJFWDJfYoQc$@nB~+?ucl&tXd@l}1R!H(IBY+w3lI0oKu+i=Q)HIK z9Mm}v?R<3-5~V~FV;f=q`z;V-#AT*FZlkH6!r)`G08XNkR4?)?v(ScP!9}OQt69&% ze!?VJd`6d8R&StNxxBrys~A~j;fz(IP0Vi-0nW>jKx<7i;p^F*{4K4&S*dmD*xl}d zi=Bz=oA_E>1OJuePekiJ)NcDtLfkI1wuOt>`1^D4&Bz$5ZzMq-W;Me8FCJv^<}ld$@DA}EpNSKe zpJBG93DPzh4Ybdn21_?668`4H%(IZ0px(2LrVb^5^#xJRTbaO?@MY?YN7JlF^@Pzc zU5R=vTEk8aEn=vO8R(pwhCgzbQAG=1Otr|sasd~t5D9_Jx-mRccXOEcXe~4Pa6j9> z`7mf$#xg7JmqY%;b&Oa>J~m`1p|GMg(o%6;b5VoU_L_cgj%%|OBY;%(^>}(T&?jQ=TyS zO-Udh?S&_jli2p9SFN2~zLMQj_rN^%5Xs+bLe}c$^BS{WGb`(U@P36GplaV2nB|rg#5wn&b(1Xp z_b(RyN6~rsWA(mqTndHAOd>==$jE%oeG{oj6b%hcWTiw)%MKaI$|xnNh%zFcb6qkM zX-hN_8qz?ezSZyi{sAxQdG2$c>-v1&@3NHjG;*3Z{F*C*(dFA%N8S0Lx4{t|9Cpze zZbEq2yMY|Zl&0-<(sY~2T8^D_8H#rV!nudXNb|oYM*D3NQykt4pJKm**se=-^ZE6( z%S@6d(8zfNzDlumk2jKY+F{^ke40GEdy8GfeYYN64rTAFNq~33GBRgo6Z`C~5*FDf z5T#4qHL59oVEbYZ{6AB%xqKr@PMweH2Rir`((T}y0Ytnt1C&|~aQb6OQc;qR-1OxP@g`2bDzX|>7n#oN4%ud1V3KBAnUBmsJ-efT&htCBb_Iqscj9>u~-lI zo&hG!1ce`-g@+?9sB+*wY_ih@|3PWII;$3Ha`v+ecDwT)|8&OXhC^IN+?KwIpTNAx z4n^kf8ki_4$JTSby17%HLg_M5Vw4z6ZZ?{LuDk)njXi>UF7Cu*;4NRstQFJWE`ax? z+#D&hh-A0=fX4ZGXd$!&ay(P;Y;7Bbh2ZAy^hJ_FC$vcLoyo$R z4J`KFfV-_GXu2bU%afd<#o{99wSE>gPdm;Y{x}8hZaa&*wJNlHAPUPold$kp65q1v z75CoM0y1w7Qi*5BVX<)-LRL2YFZn4j?SeGv-EmT&c>tyi@_ECZ< zsOTy}zI@BZfX{QWbx5DK&le_f{qaz%R*5C?X>56>JT|BPB8p$qh?g|y6#FB?E{acN z6OU!nxTl%8^wAT3PsI{0Kk6pZ&RJSii%BC&l`}BySvl!sM49lSGf*^NfX1FqB%8u-Qh}pW zD1CDrEqrgnt;C&dOkp>4H~fXHhBE$h*L2vqZ72M=w16!9wU+f3|3r=&g|QxouE5pW zAxO-thsImR@Y3l8DV$_YjXzul6V{ix$LD-Yi^CZ)UO8JLJO#vREnwNFRM;i%iXwq$ ziF}#}J3K54c6>Ryg8z^ekj;bxt5#Em6`?Si62qUZ@{67EN|!l4{ADVU?`M_#{l zfvu&-;VakaZ0tV4h`%g=w!P)m(LYN_#l>QnS~r{K9=$_!-#z2F?uBse^#ag0ljry} z4v@M>5jIqKf{)-gaNS=9r`E`GchbeE=3znW{uJ}<7yidw-(|--j9b%VooC=d%4)(N z@`v@H(9)fcMY zYSBFdp={5y7>0M%0mM8x*3=>q8mlmeJpC91cRpTa*H)-wX!$o*TQ{A%w<@rXxn{W8 zu!o)Z$fA11UPCmcbowN5EkxABeFgxf`>F9NMpreuf@A)A{32 zw&^wB|62!t0kjgA7eUyeT|-1TZo?)0Ip~XR&|EzcuASOK>xSOK_-1`LZqvaG>!kAP z!)8F?r3>I`b&Fj$>H%JD&&h|BVEX2k9&v~>qR-;@VDe&VnwBxl8~uKspBf>;+E{ZO z(!ZYQln}`GiRN70#_6y(Ba{AXYGCpCklYU^Ie}xFHPl8=^V4P$&2;j)yfr!e~IaBGGWm=M}ed zS+N6}Z2G$#a4TPHIXc6VjS}u7Csyf^ev1s0pQS|~X!q^-d^^bGezEOhPSGB?a5s+(NJD_vRK^RX{ zLECqinBy41>#Hz_L-(@Ts)lG*zdDAD)PzE0avhOKSA$VlfrsbSfzuvo*wZwDwzN!! zwFe`KaF+r2na+hn$Ie>58ePhcMEQ`VHzL>&=|Q-5Bp&ziKfv{X3hbla;xz2qX3p zk+xCiMzo?~nr?xGcyPaK7)? zQm27kzZh5N&*Y_q&&0ExzuA+fitzQiD1KG$Acb}*V5T=nbannh+u8M4++#t5&uoKT zmKpG>?IFjZ4ub@94LFiufJ24*n4M2`u;!_A&7I2&VXM|$@+~t6mt`&G?Rw(Moqg7% z{`E(mVW%iYrpbVuCrwyXFKAzr1`A%^9}r^dnOVsn=EqPY#M$o4P(;=ib<_>HvYD;w#e*X zf;Bhh(kdMRyg9Xmyfd4Ff(CI=@b@$mJJS)(_e+A?RY9DroC`t4v6##Lfl*m`%##>r zoI-f`!_b!Ic#nh5GCQ`;NR1>!l|VpX3Q6nn!N_+j#9#^54;~&i5Qf0YnTPV~rTu)hMh!sDk3`tW&Xl|PtsTm!Gn)cNglsOTkR=;N7 zaQT2({d(vzy;p57;!3~Q){$3_W6)Vm2S2boVa_5?>OA}&%$*)WiJch!QSKz`YUNOj z^CVndaS8lKKeG>4R$|ao4O()0Hg%MYgR!t?*c_@tOZk@|Sl*g;_T-S7lO=TH$S#h1 zdJbEUXE3F;Q?SHxGA4gp&6-Vf!0A3M{KExB>{$njW$U&Rt9)}3cj_W^_n*RBs`IFW z;2(0Lvy@$9+fE$!tRY8MTf$v)2A5XG;?Vim#Ch=o7^w>-%wr8Iv&aJjYMw)&WhM-3 zE7An{BoYv}l;nS!jTzn=Soh#L&)u{izAinE!qfbiytXCetDp{j)+|7-wrWzRj$MRZ zG6@9}rr`oRfBf{qh2{+O@i&xArhY!1WXE_WV;&#RC|tQj4^8jlkKD6{Y1@v$;tOxc zdvy(ZJ31TpEHmJ8nNqkrz>W1S_QC#V;>j1G(`3(GLQA-FAaL(lrhWV?&^2-N^r{lrHo|qVU-z^A=+8dc^^w)Q zKTPCSg@CuYE_CqQ9VgkGI+ggJa8jeO5I zi9PBO_~3&P<4b2ym61Xy^AMw7fB#|wPlh1><{OaRzm=@elE>f@IbNvsNp98JNI_)n2n*}I*8$te;}i^|bxg?`?m7jElW zTf6(@?0aeId4tRA#&FJ^HXC}^(3o0uuz>MeKosNSVg_!<(80v1(q}KUc{8D$D3k48=X0$3e#BCA0ID9yJa+2M2ohQpZ!7*wIrA z%?A18yK^M%IJl9xd8J_?Vlnt8vb&8FrXqgf%>7V`beB`UY=1Z`N#%a! zk!wHZqmPXySvD{c14F;^Pglt@yW>^VSy5jQxGMSGzf07ojd&F$Pc6L$wXLOc-My!g(ps#omi4mQSeOxy3;NxYxP=T&qtB6ce9p@iC&;Ih{I*(FEnCFG{Ohtzh3NEyP(VuG} z_R41P-{r)<5mSY!r7S76p9|F=GC@6RBX6L{06VpkK&C7PuS5`5T_y@HSB$czZC@B! z*KC@?e*lwyE&vNPCA=6l4>#XchqFryxu54kQ|F0bMNlaGoEV1R@4MpUva@{s|7Y>% zD$-byHuA@R7S){=1w>8=wbRFef)89U=JKJj`7EaJNldUB^&AtVDN<8$$$kl3?~_5` z*>y(WeJ_NxK4-h#s$h*;3=X0$_WSN&H8LKvxBY~PPP;g!3Y9^vZXLNa`63qXKf%~0 zaXW$#S-RtGKeONF3n_Zv0)CuBA>+y#eEw-GHdj3Z!I|+qk=71ad)tm)cIajdk5uj1dw0`>=5u*U7(iojV&9@cNd?w5$I(Vc$O? zf)8ED%qgevcJ2i*bJ#&E!aGUXw@+|cI~}CeoZ!EmR*;Z10-L9N=I(b6w1e}JBuLl5 z-yPhHY>F#=@sZoDXSk8Q7j8mh-EuO=LygPvox|?xH>CRnvPt^O*kY+@=%eQ#)?XED z<5v?!jq9-E#wxsM?aO>A-h_`t9RZgr{%{ze_liVD@Po-cz_7Ey#STL3;5B~1|Ge+ z%vM%BAx9AX4iY>Z2D^Gr@T( z(oJr}O4%Hzzn#zO=jD-vLYBwV&csh%N_5OGiEK@H!`|^YN%CbxS-PMM%$EC;9n((2 zY;LyDd%}X173?J$v!6mb$F=I*Z$fRJ%%pyFHx@m5#cX;Pg+gIdp=fOtw1jdV+2K}3 z)W{zum29L=B?34)kq0pcr_yJ)6|kj)kRByRE+5cPV>j0uhw`|rv9}?+;NT!x%Q42& z_w2-xK2grMmrLi%t>h(LnM-3fh|n)0O;CDx5pS=9EQaXy5M7t!?A8q$v~_U-{Cldw zyBgulmMAQvyMzuAwMD1!eUAz|s&^YEsuK2Q$Zu9aMv7EE5=Zls++6mR0d+nsD)Su^O}m_-Jc$c+hT+Rk zpytny)89U+*pnH<{@IZX`pVN7pGOm^6#I>lJ|~CImOh5D-d^x_l_0hW4@sT1F5~W0 z2@igr29M&K{6AU!aO$HrJTO~^((BjZ3hu7EaEl8}SRn?9?{i4KlO7v7Q-}T;Izy{F z`ygk2diBR%aXb+m1zUDkv5HEk&~45gc;dGa7gVM~$M#vYPc5GuGwWuLy>ds_`XyX{ z^#XZ$ISEFY7-svo1$0T)N=&qO#gAF~boq5PBJy;Utkl0q)H%md*($EPcaTRn=E}lj z_K*Pg z4Wc-xVT%@KG4QuIm8^*qro+18)KPmGowQ^cS$qkhe3B6zEV)d~B)-;+e2+#;&QJSn z-bJ|NrAB|BY{VHG>-kF$J%p>VSBRUQ6}@K~Ms6!mdQ#{JbK&1_X3t_@kkiYfjoX)! zS|2W3yi%CE_Xx602lCi9(|$64X#tt4D~JE;!q|V@-hOVi02Lkz!2tiC#AF{qfp4C` zKdy{z$!{R~^=h0XcoQm~PXLe2V(_Ojm=*ld2-YFy*j6_UCRIn02}f0F|An>0SNIgG z{ay|KGu2@Hlz|xZZYR7UX)Fu64<|D&z_E@;tbNZRbYKTa_w>Io+93{uhbKeFmqg$_ z+5`LD;z<4$cm6gvK1{6t4hqp9K_Ie=dAvG^H)KB!TYC0@eZX04eDaa`=A}QikV&i0LF1Sj=L1Hl&W}fv?Fw|fN|e>peNN_>zGdPr%)pGLJW#8*EF>6#i( zgVt@bij{{0ecO5DOcP&xw^vkl0V zZ8yl~|Mwr~-DjLT7lSKL04rQCu~W~Lk`J7V zp&Gv3dz8u3m4cPm+j#06pRPMO6t;@1&?cuDV7z4^5&HB5?*Hc4N_x#qQpOv$!J6S| zR7o(~#D%Hz{SBBt=tFpxA#}RLNpyPKK(5W=`d=FUWN`CAX!v*s0?+=195qdL@7z&# z;_D?K;-y7DOg>CZKA$1-vlVIow#9f-ZVuVn7gbYSItF@)38W!WhDx=J63E<5{WrU! z!kJmPB4s{(f9e2Z;<=D)3yh%lw;nLNtchx=4UcGpyaFip2Ff zb8+=y{-T%1$%er@Z0Ars*8B)1R5=ZTYI=z6$yPS(;Y4QotyI$d^fP>}L#B6Y9?jo> z4GtJ*kV?}D^k1 zh&;~q`~8+Yq?;UO>RyD>u5TJNx-*zqd}(Ep{{_;e_F6decQhQ?J_-U#+I;%(7FCK% z2e~GeoGUuYo)mt?bmXli%JavWE9cf@mTNSWnI6PD5?SCs=@H|2Yd3Cg6~f@9-^j@I zW3*9A6$i=+!AMqyskB~(EA(Ue#%d9pm~k!mI_MY27NcO>*R0NZ}tc#@lMWtaz5MzYejA|-kv{+2bY0ar!WsSUuO}) zjVG}DW(O(Rc9UonE@CoQXrX4OFZ7Jb;*SH~)V1z8h#Ox5$@5={|GhuVF%u=yD)|^T zyM70|nZKaIa290Bgo4zT6pkzD%zAAaVN&=lM9Ey6Y@hm-UH2!Q*y@}m+MItNEIx~z z8QY85B2%g6A1_|zMH@2k%@z7Le20jt5y<21fgi!@Q15*+cDWp9Nny*3!BBni38(I+vIA2*)qZkJrjo&)%h@- zvc##EonMZ#fn8qpau6%>V9Q2O}Ek@k|8=YVqgtk4^{m2$KE7gdBVoZF-~{Kn*Zs~WUrGZ>TVF1#^#c6Y6YiZ= z5r1eNBcTm^DtR}KjenvBXGG*t~ik|$2CuC4zqRW`*|%jotXm0)w5ZzSNrJu<-%0D zWRRV(J)UvYvBCKbYB*MRz8*Fcd2n_Wf&B*Q{j=#C=qBr!g_Vg#uO1@ z)G3|ACf*iIM0J=`6YNpCPlkp_-(s#X(Umz%lpxtoadV6j^=* z22Z_U+hx*upo7C%adh$7T(;(D3~ttb4z}0lGv99|u)lPE z5YGcr*mN@j%x$(~^AvNs%4ZplY&wCH1{UxO?_Gk5Pd7oXViPng>O!TVG-yl<2WwAx z&e3$1UFzEi0ed@%a`}JIhXy>^s@e3{iq-7Ou~KNUIZI@2CE~EqMfSp}g~;pE2YJyy zWPs0c3=&hId|n4guV`SqMYAjhqHJl^>R2LtI1YDy>tVhW)xki!IcwXYgFoXd;7-L< z`qlq58B;)bX_7_tHs>;nCKwR0!Zc#`f=_0RN~8TNHIRFMgbcll#C@L0xXXPey{oOy zdX@uG>aK?EL2pU!>H*%0ye-t>&mghJi)8T?L$ZP6L+p>SCQ{qm`R|8MkyF>xNPc=b ztDS$GS^fAb)Vr%-s#h)wb$5c-^eS+_xg1_Sj-<@1r_8xCI#5+=#(f0JS~gnByijql7SBjhM2Dpd?8ZTly%;;4wD4Fl8YC=F;#`vc;?H3F}Ws& z(f2W+;TK~;$1MVlWe*bdgeus!;5xakrAT{!1W}7Af5|+Qqt<>KnfdZ3nNuI?>Eu9X zxID>^RrxIg8-4Z>Ygri_S(L;szZMDWxx96MtTxTkT}m|^(jbbPX>6U9g>RzIvorb= znB?^au(6#7JNs_3sk$1lKS>*xC{ff>I6=(Rk-eWK*Shbcs6*?e45FGw9$ zO=KLOrjRwi?O3NEijYw2hr?iYz$xQxsb-c|5Ub|d0%5A!DUpCm^)*Uq5? ziP}d$Ga%IH1~Z(QKoWo1LSWz+lW_h%ipcq3VES)TA#;%}uZ+Td;(JN{!I^j@wg&hc ze!+Xar)1Uk$F^)DCv#8VfLJV)dE%I<3Y-FHz+VwAoS)I zly~nU#^c-Rj@Pg7ZD$b)n=z3&TK$=^^=JUYPnk@_^k{s$(U|d_7fI!hZy_tD@#h-^ncv2eqXT5jPJm49E`et28K`)# z3(|ky0`DoGpeN)%;yR(5_~xaf($`rq7EnN|ZpWfs5XV6=vxZO`(b@+Fk08;Zg7ecv zljcerx>#=wC~eNe^d-i$d3hEo{=O1-&yun#ZtUXL??2!f$3NbEU^kv;X5q)e<23AV z8U4O%ZS55m23+57Aw$b+$jZkVWYo0`WQFI@19PuXx%yygbTor5&RU8E+X`U*rAbx+ zKbuHK)iIL!FPHbH^btU$Bh8LzhWkIKvuTDda9U8+O5v(EJQ`BO`g;!iMYol#a=V0T zmv>h%Rl6p`YxScrzDdw(hQNHdHKl?57u*Ur7ja&|=vp>LISRj+7z0sW0?)Ph{7H$U z(5<%t>SF}ymLthzW3@Uda8Q!1qE8yljjl+Fmhju z?q3p3i`F$T4mxi5)z^ybjXreRteBljN*h%#T$)!2gm=TQ$Cb4L9Ve892kva-ukJ zPaET7DyVD^@~=NAWPdC=NviZK@x*IxmUeC*75}9Rj|0xoap9Hp??29WxI>jtAqGPp ziNW~wIW#%jg(~lpu08X@0p#^6@nK9bZ2c<wU}Pp)GRK-;z28eBUun_e!f>>j zqmAOLKhqZpqiAUbdG`^e6Jm+TvJp_0I1D=`E6}v3 zB5apEw<{{~CuNPsSaIn+(;y>546an+f{|l<4fEeMDP48Y2C>-ewTh;T zJ#dOCPJ2P(q%2_NgGsdEM-!OcEaGXXhvVzHLFDsYaq=qK2u_E;fb~bduy_Aj!MoX) zA*)OY71s=a>X175@!W|$csQ2)Ix{1tcaG9?b$DfsVgEeU}h2zq>tv>wWa{QbVDzih@ob;QLfkVfNxsW>vh#Kc6Q!4}q=*S5|NksFWiH3v0?N3m{SEW@h#I~3 zQV+}Jzw+N%ZNzqNT6=Zp1WNu2F_lS~eEZYtxH`0u^|;c)?m00XiV_=Gy_nT#mNbbP zk2=D-kelSaf-bs5>!H|q5!Z{KjNcs}vKp7VnEG;Ec)sL1tvAbs`rH%D@201Cl(GCGhEn?AuqbN<0)x8XOI0hYmvY54U^Vj zP82fLz~}b@T7CE-WbOO~Tgsl|g9-k4Xj%}{5sWY_u$(W%aTFV;5k|N38>#%ThBhv` zN>w+ zDzWUQnL~sqE7Y1AufURMDGWTLjZXvR@u8q1&pUJ)bu*lVzq_tM!U_j`#krRJRK?ie zGgr{u9)fDWj8Mwi8q?O#XQiCKGq0u#p~5#eY~g;Vk5MPs%bz&V`o0ICzx6LW;Bg*I zXXZ1)&$;aRH*Z{?H2`abf5GgyYv?#z5EBBmnFgyvWQ@7NPEb2QlEg%DQ?#MwS8>Hd9iSGmRv@<-X|)qly}y%O}=nWqo{ zUV#4ADCMDm&#uIgH*NNGZ{`>zj+)}E^HOBezVmSJRxa~*tdO4}lh5XQgmS&@7rg#y zGoj#mDE*_ZO-)r9G->IAv##gqev1nzu+|sHT?Uw4n$fWOy#&-to`<_DH0WcAIrMvo zJhM|b7m^-G;e~-u@SI1`>fT-Wt1JOZXHK(2N8Yh69%=Z?AsL=Lmc>?6Bl<0fbD0Rp z;Tq_G5wlpFm^2&miwtSz=T~^N89_r2vCbMuP`YyT)1 zA4|I7&D9dr%zizMhe)h;48{Eh`er3BxGUk0mWzv3HuGEMP{~nXLDV5-|opWw(SAwA}-Eg$%DcW20vAmc6G2<765&Q0d zuwV|9HOGU|u}`qnEtJ_~Ce6QfHjUk?@Pw4caPG^Lb`p9!jJ@fph5wSou~@)@kRd^-kbY%0dB&54Hy?V*taIjki_Kx! zJIS7P%5MiB^(ed=kjeK6IZWfN7qSH+W~gE;N4rWJ$@!(lKx3}*X1%!&CbPK=(MFDm z6}}pu{kEZ>eqJJJDM}n8a4r?sNuVhcxo4x`9>TrF5YNL?amKt5_|uukzLJZkH)fq7 zh5iogYqwZdmg^cTZuDkgz6IvWw(|O4H43}M7ANb_?4iF zs($NGvil*8qf*%NfCaxt2XN!hJ)md#gYWQJ3(v@_VTav9j2zxemY0{4$pv!s>vml_ zB_@E`8dgVCKIWkRLl3s$zg$LqVJnnX`{VR`dgScvbEsH%orLQDWqaqG!NT`upflAS zGtz>%hi*sB^)0Kg!_*y4JU9o!lvDN)MQyd z5w&~H<}Ed%l5PS-LFz5Dx#<^c9~?n)Ko66-Oq{oOKYQ=cH!}L@I5BP0!N}eoa;*Oj z{(0?7_7_J`*C>uny>KLIaitx<%Mj656J%VhfTe)#Px>>w27wR+q#KZ zzFG`@XCB9{<>r)kTnbmWFeFLClF08*03t2S?fa$Svf@Iz-d&z*=oH~9i%O_#DT3bZ zW0p0S;$Uz8ep+*K1QhtOD4f~P&F{>x`{ENY>6Jyb4i8$*n}%4QhmG8xQ%!yj4Gl~J z+e7x4;}^r87-}M^k>U_hn@TmUw$a`Y7pQr712!1GB>EqW(ZSjqpYBwG1Fx&enm5;R zXC;?O-XTECav!jzduM=baVCFd%rDmUbR;+LilF+BH{;X?Tpm08EC0=N4|d`njyX2^ zg4Io~fp<3yD5KU)Q}UFUVs7WXUb6&0&e#bv(6l`?I5BZNr@ zI>}< zDO5ke2^VvG9BWq%wrc_B)LapUrFqM#ZE!ce8l8`Ojw;~jD?yrWJ(CeLwZzbQVz4s% zIDLOc6Svj%!#BSzv|_xRX&T?h^}C+4(lRw5-YN*&c{QYH-#v1#eFCw3ISA?#4l+md zrs2yY?vSI}2>y4)@!5Ys*G8Yk$r3>jo!JA*f8AIMvWj20AqNYeQ=&u;VBCp*%ZIHu zz$`QcrYTy|o0_q>zH}-v|G6BEIL5PU+a~gKwk}O}(#I4$fvW_Pv7Pf3*N5BiRD);3 zK^141wI_v6PnCrXGLi0(SH-VCk3!k@yKHz>JiN`Fz}Pv2qonh6T>4E1dpciZfm=TM zEj|lsn_^(TdLgJ*M8Ge83T!xa0j$^7!{JY7$QloZME@Se(@NZX+K2`mjlIVU5SJpt z#|5F!bt5`*tlr;6iS*jgR{S8Xh|6`eNr%FDaCBOS-CK>xuaQG=&2bVF*m#zhs2^hn z3!;goo&()qB8p3`yJ78u4bXm1h?LA*1>JY@h|?7{cr)aO>u0WoNTz_Q+w7uFF>&nv zC2{!tiUBm&C4icCG1%Td%8X4{W7e4%;=wX;oO-JnJZEYUR-=;(lG~On@d;Hm( z^G?!wLunkhNMH?Pa+&$59q{v2DNkP75Feg;&aS=TOJh>Y@t~j*t|tq5+haE2?woD7 zWc>*yVxk_23|xS>w=QN37nXN7Xx|H6b5IgnuFMl+Y>+tQz~MJWHV${n{yndGoU+pDvrHIP04Se8bx%~1g=I;V!kiHU03~m*&?b?Sp-**7r zCY47!wZdt|uj%YosTkND83QE&GI;-T81wdH6vlY2At`3}Vc9kb?A83j{#))x3l^Rw z@0&T#T@=FZ+aa_|=oCH`oQA(KgE$2z!j-^Mj%_G|LxkJmMXjPoKJ7>QcZ1~h{8W;? zdLAxcz(bv>c{O^HRMxca5znOw37lxtY z$z;0yk{n((NaOOUNla#@JM!Nzkkj7902e-S8h zH<-%aX~y5*Do}E=GhV*E3~vRzpatYK%4Mx*7b^;IjB9O_{H}#t&$-c`!P$7H={?zZ z_am%4R7yX6-3;1tVfcHsB(@#BO`dX&tO;B{tH_~|{pwW!vKL+ue+?`AR~Sqe9COCO z9k+2Cmv4(y3&w2+j^gXRu~>5^0faKHvBq~-W1@K)FKO%~j@W{OLv6hUc@HnP5-K>L#meVFnTzH7fhd)+=pZqEU#dgn45tJuY# zFq#AzQi?cyOoq<-T!qQY|FC^CRp{?~m*K@&IF#NzNfre*umYx&h&9J9b(uaH!QwRx zwIo7^MKU!K_2%cF+Db9|D%eaG#hI>SAlj~qcjkQK7{?d!;MGI?hVsKGl~@G0#{!)G zdrNbi)|0iaK`=pk4lXKZsG4pjZtD!d{GfJd-d70)Eo*SFc_PLcMZh~9Z8~V!fGV83 zyv$3AwoPfl6Yt{j^W!^=jJ-OIw3x}de_3$JLieRyFPfJn?wf=-i8HS->iAXeB5D@3nH4$ zM3J`+m%r=8{!t!>_@(_BfNRHcVAFZDCxCu0g?@de|{-1{FKDhAtSXVbl9U z&?j~(UO!ZVL#-!~-*pb3H>nfr1PQXJHj00x(g)0{!lC`>BUH@u0mrB7@$tnw^w&ov zA~1)??UAbS+SYvJ{K_b(>wv2cU8mn;E%21!E_hbghIt=n;jw^dQl=6B4xyhxg}Fi| zZ;-5&j4fc*=5|AbvJk4J9b*}38&>0HDAvyEW{)-`(S6w;;H z7%#@A;O)r&s82`#Wbmus&E&Gv@5!in1XveX!kN2Sn7q&p)10f|(K}^k>woV_q){9h zQ8q+9=iB6Z_yY2yWF_tRk_;i!tFb958}y8Fq2`VRCVqSbHp+GMi~2(1_%_^fwcj6@ z%iXv43}wMv`3ySSyq@vt{epp8Heh+U7Fqsz8VK}n#wRCq*l980w7ItrcE&EFZ$HaW zk+-RM#Q!`9jvLXW$rDgrI16W=T|s0hBw%0l0O{49 zkE5RTj6BD8YVwk)y)P1qf2XIiQIWTa!5Tpt7ZC-Pw@=bfhYd03z;s*^U4UAj1?gL@ zY4ly98J2ulfNwVy;K0vmwIj1FKszX#iP9e+*?;*^n>rbvl%8VWoYx_tPEF8ww+;dn zuELk56k1cUiZ^$WC+EnHrf=?)f`nc#JNoY^?3ntGN-d3JEFL&RPjm=oZF+0zBdQ8# z=1rmopKZZzhaKA9)U2&AE@SP^xUvVPC^_aB^7=RD`Wuj~DOy<{H2 z)3iw3;U@!#LagVK(>RYai4H$B=&f@p|b&HGElc9PQZG zM8||VLAd82X8fp}6wUU)mn|je@W6uW|2SYTQEJPr*;UUg$Nonf=L{pUzah-TKMuA{ zoXy?N7vb}Qt6X*96nb>;1gCpQ*!LyLQFrG-%9r-WwaY5VZSy70*d&OS9FCz`q=A2T zT}M>#hY-EDcw)W|tu5Qg!sBHd{6^m8T;K_2dTYZSg-h&xwKR(oW`+wpM=;s`WXkSQ z#A$uqP#CbE^(F*rJ1u=eu=OpMU8e$@qsG;>Fn0LIcz|9&yp>&pgBy3 zhTUqVueDMXU9^Z>slP>-OMb(k%eP7X?ONvFaHCG?-X1bu-3@)~uJA@$CumokP$Ue9 zg@;V{4X)(3Mip@*sAN%tXwXdI+@Z8zxQjTGf2kI`*D)K$hFr$q zy<1t!qG8OocL#iZwT{id;z;^cWn7q1AK1QDriSv}EIoFQ&`mPHlcu|2#VP}8(HFc^ zGoE0nojP0eUmq?pQWVub`O4MZsHV?P1s6ftX>uEMi`(5#I9ah&WVO8){Y;hmgg#q$d8DVu`wbK{|YOA(GYudB8S} zX#mN0B|_NN7&zQ^2h)`mq)Ye6S9dkrSBwHkE(Pd#XTSU{3L z_1Kcim*^OtO7=JI^6zGDCrjDWtTH-?op~Zhuj1F!h3&W5QH2Ryz?#MUZ0mDaB)B-LFn~P&d9|DjtiK6f-cPo-ayi#HO#E( z1#OWZ@6h>iIdv`>$u?w4k(1?f2p^OJr%t@bsg1Fy_0yUZY>xx}N#fp*TE@KJPbM~2 zPdK~Xq6WnSlxi(a7sr>9b-$FrfdNW1c}16geBvYa|3GCGp)0%j04MX?iAqu(*fOJq z>_SU3=&m|~6;hJ8YRN|``!@qW)y=}G1r3 zg+&l$ZPOsjuAT6{Ya0Kmy$R%I$Klr}VkB)NQ__5>z#=qLceha+i_nO~e z!#<${eQ*Vval(`FjiGSWX#+N1ZGjR0>5%z>C0nEZnU%G4@KGMuNm5`+Fgv#LBse%uJ7t42fcf=Vj`dyO+qj+nt;kCg(G_$)FxFEAFiFQp}ah$aT@sw#hDb`=T;+|Iu!6dr*w$gQ}V1@Vi+2=p*|Obd@c5zX1Qe@gUW{^K`myHFxdq zTTXYU7fRng1Ld1u^X}Wl;a^q|e7GVD>uVJ7%0ypUc0vVf{`k`vv1iu>O`-{M4Qc(Qd&sX{jZJ zDes0oheV)SCgZs>Wbg1SjdDZ2RrE5E1#vLyoRzGVe>U_v$@ zWWAB5+Nt6hV=p>z-i>#y-bEUAJJ{u8g5UkHF%?w%QCiDnKC7b+zRx>Ml`Bdy^ZZmS z2~5Y2$q%6U-$C4Vcn^O7Qd!0|&4z{F2IG>Xa=t%yKR)+E4Vp!XIF&1K?!L&{v1oer^WaadM zf95tCk7WNwjkjL-tZEjaS8<+4)BfpY|4C}ozm6AIHDY}0J1(jG9hPlg z%-#EEUw>=VLR=^#qKA)GV@CIG9D8jAR$6_cQr%>(e9<~`KV8d}FIa->Nd`L_Zer)v zR_?K7C-tofqFX=QxD~e#@F%J!K(3w%i<&$Qf0|nf^O!*mx`}7-__}Lk(-6!u93!Z6 zoC$fq5WFgSy}Y5oQoi3|ic)JQf|iU7t;&(dB*oPXp6=kH#eHzS{0JI7dnlXnausY+ z&crtb5_G^t8RL~F^TE-?{S5kp#_FBmeSttM>jxwqTZPRI7r6y%-Ki(a@Bh0?;jsg- zICL4M|Jg^Ye{QC4Pj0{{m03crwGNjJ)fN1@Jz%p^=({dhZeJZXk=-$tz^lCrtpq*fU&UiuAQJl#M^2j0V_sf$G$hHYXq(nir%gAgp8S&5MfvDD}J ziw~Rq6(tk1spR&ddOa~I&=(2Mlk>I|sXv1%C;tJq{~h{XM(96l!74B9Mi>9j{3x5( zaR1OKGWcGBI~+6krXM|ETk{W1?<}N;iMPOh^ig~!_R0RTZ7v`DHyhOyra(u;V47*- z13uq{4EdZ%sF~zLzbq&5;;XYOMEcy1%X*Qu^AdCoyP6WxqQi_Lef9DfsPN1 zWiiSU6qxNouVTINSzeCdT(pAIzctV)PlB8s3V2QN2Htq80uC?tK^eKLT>5qoZ>{(i z=lxa3fCW2Q)w>wF{yPM!_>a{3HIGT1aH8dc-`+hVlZ*Ddh>rr(SwfyOzcfXQ&JRog z-uwZs7o4VFOq6Jl{Sx}&xXMm(dj!{$mrE;k{$WsRA!T1qf^BC%*DDr30hLEJ^rBCU z9yf>6l@Kw!x1$D+DGg;s6$@#I{uxsk19Hav=P{Yx*YtT;TV2!CYak zKl9#Q`uOY~Obbw;?I+TOJ7yiK-}ykFXUt-<$(CGW_C%D+Nx>=Sm$Hq6zoX^f78oVF z4)xlEJFn_Ge!pKMu6X26e9A+NjGSO!nPbT$^R+?Yiwix=tYl|1X22e^nQX|1<6KF7 z0i4Q_XfWruL$#16-#2A7UbP=iS__uLm%~5FNyUhsE!in}%R|UL`XQADI5FFbA}%35 z2dvPauITxX)?jX`=FlTeN zT%@lf%W=PNH*Ql+r|4}xqU^+2y!+pN$QNZn`007HZrpK_Y>q{{@kcQ@&W(muKV{XQ zhtWo7$%eXP!j5bC7-qNEl%9VnWU$+T85(NY)fm3Ve~aJYGuI;g_+u)nxXV)cgFEPC z*9|F24a`*HKdOBhLCI(HaemKH>S?}(Yuws7QM5ZHSxlid^~%JJR%CCSivjag=;1O) zOxgaPajPF-)Mx{eh*oK+k-1Ml{ma?2MOmcgtUMQ&0mkO zI5nVQO0F%tG)0B#TGp^?@!fbgWFDJy`VRIe%w+ZKEBa4Oplu0KLY8I_TM!}qt)z@t zXSg(*?wlob?ADO)+FfjQ@{opKp2I11;#3%Ccb%k%E&=P&oft6uFzKwi&C#G}3aC1S z;kR|@n@%2^vw`Q2mUxo6yDDWL+y}?~WLeM_9jg5@gJNa%So82A2$xM__pMTCgSRqe z9y|wYa|hFWRtTfsdokk=-t_Cakj2lxkNfAX7CN`Wt~+HrWF@PSr}kFdmgIwNqBP3A zQ^V|94B5FYeKh`$EFRof$87&5kw)QBCbLeB-=(peKRVYIH3L=XWJg&2?J$4r*Oa04 zDsD7%lmhcy`vqnt{Xq*y7it>Uh}JV!+Ksg5&_`-2-2HHnEDOGqkNJEe%>b5MxEREQ zb-#MG2c_1jJj91$&M#>u|8O*BZL?rIRvZ(!$KNS;kU789dJ66o zlcyQ`mO{u(fbj$I;7m8LHN2h5RE-)QFBW)|59`TgP#2y}p99_(krXBw!H>ZT@Owl& zeVgvY??}4E|GYVZz20~Nwoa)dmxd##vGowB4jjY*`64c3qaIyv7vYUZn!MDQ87ym8 zDHY6*r44!&_BL|_ugWGTwtR{=yX7#NO1uhS+^A$)5?uuf83M08AQE0$%CeJFKXJB) z%OP}FDusl0il+KELcoOc_^M|zKX5dPrPV&hx;JXfBKkXbYDx&MT$Rk`)o!)VxE{&Z zwP%Q)+&V<;_6q!sVMIVG#huq z^PF)KmGO zAvkbMyguT_pg1)Db^%+?tY@PhhjM8rUQoe1M~K-nn|!i;*(%{TYI)#yUaes+99`oM zsd*nL@_;c_o>infRlh`cCb-eJ{z{PAG6Nh6+quTzAxz2UJ~N6-#n8lfq4&enEg>s7 z-^qh*dA%1j#1_I$p`%khGYQ2F_OL*xt7hrn&V9H)9vB|q;Hdh z?R#)eVsJE2mFCuF@uf4nIJLV!VbjbfI6-$Twfm_F&-z&`X{kOpe32|J5^~K~-yFqF z<1C=Vt{x-5E}+%?cKj{yIczIJvBfzSJpGxliaDVmSo^)1m$nb_@!!lXodD?F1FQ@tMCx^ep?kGw=ACCMUJ$+ zQkMr+t(_3vJ%{_gL- zX>6tfI~JZySA3PQ>yv6jx!hO?n?1O}$xx3OJZ~o_#U8xB#hoOZQt|EUY;b??Qn)Okf8*(OwVqSaV_dSa- zN5`pQ%0mP8XZc|$4jkJsrEDDa%v;A)M5pb2-|S$y9_vu9vKiN|zRgmr7?skv;hQ@_mTtO`;oXb_h&? z1o#oQk(NC%Y*2iniE+d3v!&8?WVP-kIoXY1w|A(rKaFXS5SYxYe!PN%tGwBBhr?7D zCobA}S>O%ml(9PbO5y)hBFem@+)&-8L&BX1wBL*-$G7<)z93s*O=!^4*i5wfub&g& z5o&+a*@XsMzNYOvP78bYxwOM68#l(q+2;!RrCrg>*qVdgC?;ermM{8`E!uaH>cx+t z>CQ-Y^=J-VxK?9d?;FTGO5USLw}Pc-XtRv%z~swxXmyAf=r+qU-G6n^{xlZ9JsrT` zgKg>9o6aKgOX^_eDsVzLTHd-w&^zrt>bjF?QwG zp6G_N4x)NDUgCCZ&!OXE7+5_OXZpr_p#8;duKTkxWFJ(=_REX7Ib+(n<3YDz zq_hR-=m$ckgcoJha{RIS?HKsegqia<>~|y@!{+*EzU1v)P)JrLHt`eRJLe1T+xP=_ zIDf|l5*PSY``4nvhAH@cD^VuEX?;TGZF#jHP}raqN>vIJ;AgHic_5hg)lD=UFXi z-L;xFFFDC+8SO&xy|?Up=EveLelI(-!JW6Be}`Y#9Ry~V??A-;aGIw17QE&SCh7M7 z@Y^-vj&gb<4t<&llS6l5Vq7P0c789eylMmsCtl#sI!LmI>EqzXom$kfTETY>F^7~T z&tUd%Tkgw;c$CPg=Pqcr@;X<`;C4Z=@LE5knY;{0{n*VrgjmGb&j0rXKlZ`)T-N=6SF=EP)4QhO=``lUUNn zatO~-iWRqx#9t39(KX#2?|%^7mzf+5{cxM>%8_FJS3hz;1Mj1ezc@|f8!@J3 zHwAL{u|2yMLoS%{k&}nREmh=|T!&I@jUr7rIvD2{pX1FMR*;*dGDY?r0J{Wnru?c9 zeLDBz?X`MXk)MTf6O(bUdn0C6`0{A3?8d^~zfOVYsFOK{+hJSBCwkn*OhxY0uaq8qD4`UAiDXPf_WA&c@j zkIs3VnZ*d^WB6M1z4p0CFFuy7KmCv&C^<*v$u-=Ss!Fb}ROrX|sqsx_D>%2TRq#OS z3nV7ZqJRsYAoJ!1=xj0&{Y_~Qe*d=H(NjwW6J#dA5YoOBw8sOxsJ|844HJGWfHTADF)eBe#n3c(=e(QSR_@JQCmg~^&%YuMLtEbIP$E99R}%JW z^7!*z0-E2v464UXI60|@F#dilPI^(mze(A|RtL-{znrgp>5eKs+Q@0A^? zS+;iJZseYrv+4`4Iip8s;7$l<-{PUmpLsKu8L7z83zAQDXwEzyB3Fz9o?O z-7{e6RSD~)GWmqj8@Ly5j`C|}#zUarShh{zVOVJ{#f5L)3q50H{^xfS+*AAkGu+nF z{1*$+;qH5UT&sxYW;%5B&=i#Tu!KeB)$l6bUFbaqA=&c@^HLC8Xb-wY(-Lh+Ci6IF z+uCa%{>Op7S}4-}0CA?!WykWI^+}^G7k7^?#u{!jpUm*`jbHfotQV9vmBQ^rF_?BM2#aGIFlSK_g{pX??biF8*#UXF zuuzo(w+KwoI3W}laS3R0^4fhK<$Pm^(J_etEk;Ao32ie{$MW{aChE|n1aS&qxr4HZt%w!$nBW3wdgLv35 z7ijnKB1ny2D9i{3nG}A07?t>pVyOwIaANZkbX>ZKdls>sl+8kcn;_3!F86^+ zG4pX}&<8H_r!r)TG}z9ZU!w1I(VU^b8256FFR6C%aLm314i(MDZ7UvfJ`Fiw7X5+G znRkWXveAi&-?L-=KdPC^{7h(V31zd)jzQU7Uzlv3#8kVYd7W1eaO{SeXsQ{{ZOxv` zXC%%N7{hbWV%9HC_30#(={iq4N4isT=O2+WxUlIn3ozoHn4rU$k8w}`V`GD>=;|m9 zy5D&PXZM`r)=Qg^mMlYAp?efP-GC)dxWq3$l*{`IJDux3-gG?GpDve0P{o0Jy!gL7 z3|N)UrQh6v+g#i5R$L@C^(fQV^7X82gDjrEn#LEe&cf5fk|6xM25u4M(#VyK$a{B-danzwzS?AfY1@RQ=Atthj3M2dAN6i1j5f=P(q=U2t3cCv#cFV5-cU`%P zCtjA}%PV8(#|tkQ?IA(iT#vA7?b7;~fpTo2e{55J6UwBO(%wsN`IK~f{+9n)G!*;E z*``L4*}qKm>TnT056TYXi@eF}!yXtKUce?btYK9l>zTyD3GDmS2#mHB@)ECAS*rFv zF3_j|!)i9ug&T`0_E;BAaXE{f!v^F3pkrH`18GI&Xy`2~;dM&}k}6SRW^$!gKG>v^0ynL1gOhIy-mjQRQtn&vgH-{ZIHbhhsM>QI zm19u5HyAma1NhT!5<4O`oeyn|(3m16sOFUh$W#uz)XmFwK zCwI}-*P5vwkmKu8`uX82gnazPHdOPi<~o(sF|RaH;C*f7Q~ZWQf!sLEl+Wbn%wNM! zSWbe(=HI+~xfgz|eF=XA-q3=|5%l9*F{?bk5}gFL>+Ij(MG_&gb*YJG=(Q-A+wkrZ zH@4sx_x$=j&NSLoIGYb;6{<_2T~3|erGVhK&qwDYFYrv(LmXE1xc=6Qn;51b$-P`9 z&Rnx?D8FYMjhdW}?oQ`nXWwQ_6B~{Ww-YgJ=U;FwdDLvRg^C8f>3wUTCcN*YH$&*yHzgrk-GtuJ`gHC`HRuE}{`$h1 zoIIT1`o6W{$F>Xbvr>!yoG-?zewoqBwi;No%#55oO7Wz)K0Mod6notxc}syGeJ$4* zt?!J%iT_5j(v}-&Vg8v{xYvvIBeOu`zXSY5Lv2)*XoNa}IlW?$BSw}+W3gR1UwFwF zB8&HcWx^xeHDe=;x$6nNHJ30_JiT^MM;?5R@}{;6;w`orRR_@*ewfi5V#F3HQ%BR!d;PE~D-Q+_!Lv|$Wn|2zb2FrtGnjTqX>Y=8p zHC1d*6%C|TLEpO|tX<#8xm^L+8^+;*r)N04@)PgZ*pZ3e9h`1%hws(b;o=v=`PR!r zX!jmYwD*SsXL`+;yCL)z%jzt-mUpXI)wp%scY!f|KQI9fZaIz#p%obC-Ghe9H1WxQ z|3G{DD_F=H;FYQrOCBw7i9Yv(>{)x(7o!KsS%86epTm9GCy1+MFnrcB_?;(k(fjUk z3$~XDXVo52`*lO+eDfe`3YkLZDQTQk?|Z z%ujHGdydXvmuAG`pKhl8jeyeK*7Uo!74!nHaq-tLLG_mTD4TYbkKn`EzZQXyoFL0` ztzEJA^I>e@ukxpIj-&VHOgQXW0>Qyspy0uNw70&C;+e%U=Mo{LaGEa1(A zzrbosxTejaB%UwM|2zE|RbH>a8)A`Eu(h6_bWV!haBhd#m(na@Uz5;jxq}JACa@+2 zSB!M?q{Ly@>sQAQB33kl*1L@2%4!X1+S*y*`1cefr-{;QCGp&p|DOeBj-s^p?E z8Lg`J!y(x-eD|<>7?CAmU$8b8=Y5G2Wmu}QpnJul^6~rmskdg6$JCSP`g=W`GWZ93 z^-{S2=`MUVqySB%htLA2SJhnyH^~-R?F~-eyU%t1#l+ z)Bb`%MFecQW`Mg|5?F?$48y6i7@Dg@(XCm)j~vNrBbSj=k2+*XYt#KgSJuBX9xvaI z6|M4XMl3X-M<;i~npcTXB`|+(oX=tsqwd0!wfoV(XfWk$l*VfpG@$ccHNV_`EEykI z#orwj2emgEu|U!h|KyHfF^5c0E@U^ZoYaX%E7#LNya7$UdIN8mR(wiK~yF9h}$ zfY*>Ge0lOYk$y1Y)gyY;->gH&XeX9`WT>{Q7(CB4z|iCgEYc!h6qh-JeQ^t*yy6E~ zBWc5Lmb7A>>N&XXo6wJRxX$~y?WT~?5n!Suut8^rv0S;i(7HI1O_|mYfZ#?uJN<4RiCN_E8u4 z3jc-dwdNVTaA7$O9G%J6q-}%$%7@ZsgUu8=#~)fQF64H(SHP2bYSa;^$7Jj#GUFLr zL3kqbdPfZL>f66?D0Mryoz$o00m3=h&5|Tc$|$D29PB0ggng(XAAI=&g>_|8!jNngws2h{9-b=C-4xCN?%O<> z=z9$O>^p>7=LyxkWx=8^j6cwrhO=D;`1$8u$Z+OrQOV@vSo}V_KJQ&X{craR7&W2` zY;z*`X5W$Q(-T{0z9&hqi_-9G*k_27>lJC=QAHW91@%5AVg6MusG46yALGt~ZvJU9 zn!S)xwngGXwo>F{dK0r(&c^p$B7tus#s;}1vyEToib`S>Sa@j^t9iAVZ4VMyCNhc? zU;UfL?C8XK&n2(2u04IPQ=H=SnT z<;UrF*-r9T{)t=V9@$SgXh!u}>YPO4crw^7&ejwsLCU;rl=UUt_Uj0V1?aNK-IMXi zJy|mJum;IMYqGum4rYDb$WF;+VN7`~`MYcIV|x|3J4XXC{*i+HBo8H$+b|S2eyoKZ zzFwFRzLB&CS&`$mEwuEI6E~KNqW&Qp`CXZ>`7xigKp_Z6%E{P2(h-G688u)KEv)KDd!EotDD>yqff%?c{q**(REm_rKf9KhH zF!6tmwocZravV}*R)iCoyhiukSV`@rRR5JgP-g?hSaV4k#(`G0dG z-&LDPala9rP|}C(4{cb^xRcnvHy^%+r*QQ++}`kr;BpLo41Jz^Snu#lAU$;>+td(* zzwT<&Iv-uKu2iK%mt)~nS{=Qw9)azRDrCYnjOItgmdp5%kO7GkKyd))V_0~ek6#b@U%MO$=$Z+B$rU&;W+ z6k6beFAtdGgQK`_mm*uYbC}@EIm^vX4CUL-HS%vBy~5{>x{!BdFnAVcfS-{&6P;D( z4C|tp_eeKfc0m_!B&AZlogd_S^uuqzHs0z)9m?I>MN@>km`;N~zcN|qH%_YM&)YsG zk6oQYnAw=!f_nD6Sd${f_fxk*1@%-dWG6F@Vdh3#HaRzgfq4kLJElNXUxtI*jM&H3TVVBa2^RHsrhVO3eQY#3iWY-@hJWDYe0dy4kei$Z5gV9Y^opM&RWKcR1k-2z1sR7pH{_tXVVaN{E6@ zNd_b=wpg}JDMY-F6uM4_aP!kSY}xTG?BuMK)Ma#nTs4IG=O!M563l4x=Q&JIPKzEn zT2s*TiCFKsla(&}i6VTxOOs-(=XQ=KREwC<3HyHSGnr zz#W+HVjt>TxROn-Q)0Sjo^w`JDwyE68+OkmFneAoI&VLT;&TRJ+_BqeG4~gj%u>;G zT@V_7)})upiXa~v46`klv6DXUxV-(@#1(mLeYQ~%5LM{Y%~x$=;zQ` zcMm#0XoAb*QqcAIO~}$Nfv3mS(JFfaUMxEM@i zSr;*E!$t1=lz*6O$kC_Foy>S#0JVKIrd`fgnQvn@i+Z3+8oJRqp{|_o*)y0x$ zs&uM}v$hi=7=mkcY~A5?>gePS-j3RZ`Y9VRVuX-|OzXspT`A=8 z)Pb&x1q)6nPj*sO0#_cWV`lBU+0mlO_(rh{hs<|^_cmMDj&sqZetsdz57lNS4kDb~ zYE6F~|B56oo#n@ri?BONpP5(>hWsOujHAaWvuPqZhe$e%{I`eumm$y2UMUi|5GJ^( zq8j2(nvo3q%+0cVh(kvofUOBiOv`3G)_(m3)z+SvCwUorlPuXqpM#kDtrDu2dqS>? zKmK>{gMD*cA_Q)jf&XS55dD3Yf^Ww@fKDw)r&Lmy9y+TnG zDmev~s(qm2hZR{-kq?{q;0F77VLa`)a{&s9WLS5~A^y#of1Hn;dPCdIa!^i+p)rRA z_WDvS7^QRsr+18IOIA)3+=E}Zg~346-q^E{6;e3TUkZ)Q+SmxX%=qYYyjGJjxwl$U z0a?+e8!=2K*^AZN>_cO{hiGDY4pL{!u$|^cB$sQ&88j?mg*Q&Z88>5owD(DnNS=va zI231f`C`cL1^mV~FL-FY8_v!!vQPVa(EeU{FdM4=0koI*iTd4bNKNF4qbG0Re~u2N zp2>4@&qE#;KR1F&%Y{9rw+`#89*sknA7MelyrQeB3A_!C!UldmKUd=~x3lFFjmXQ# z3l`7eeb`IPD_crB5mMy)>?@vDoWx&FDq^bLEBQp3VVu^;Vv}6`5?h`Rphlq+*doi0Bx1M#qIYXybFCzQl zlQ8_JHx(=&%~r(3a}RGFBZW_6=*`V%v{liN795$#jwGr=!R3p!yLT;T^DI+ndTl+< zTQLuxtsf62!JfSO;4rYiv71|4yNR0~v=nxz?qI%gLm5UDvO`-Zl3b}S94W|VCyM7{ z@BR+V|D#Usu>&}1c`a(r_9E#SgXys3dyv=s4!<+r(>9AK%%FWHixQSrixf9tr|^N?f0-@Vfip*NjyfrI0b!oLz(CP zPdL^`C_~zajtfplbjJv5pQRZS5si(`1$;|{Uab($XO~c*#{mUb_sjOi}3n<2(0s2!Ma=a!z;cXg5@GeL-_(e z-Q)_jnvr-daS_(IzYx80A3}xIU$Fm_IxSdlVBa2l4)^dLlx8v#f2TUa<>WDF=Xw|? zC8y)0v~f)C@hAKBo%%4`FM}NGu5sZ>ne5bzS>Tf^&srj$KReMA zWBbyGnTHC@<3pUs1XVV!--F3L@W-9DE8%>R1Q|W3MN5B2-e9dZvkVBq{w3M?9eil{ z!VKo4vO^zX?y;{@`i* zLu{2%IUL>YNb4)&IGeV0;ObkCBLtS{!?cfhcAYJgZE&I%=~fuuJCZKXxRJZ&%O6nM}o019W7pyKg0`f^o?gpg`@)+A}H)fWrNKc2x z)BI19D8x>R9qoRGzna(BzmllnKe=AyQv2h{EWQq==|1Oj=4`?Gih0nheUZjk*5SYh zYx?2)4hrImSd5>ZXrH_hy&HCsyS&MqO8a|YV!#}Bqj5e)*zALjh9)>QellHnrvitq z@6eDr8Q8R>9%9agLG5W5&Mef6JMttS4-RhTZ6hT~sZbtEKPFO#$uRzWbv3mtv_eH& z8PXHFqS1k^`1g4RbCvwSE&Ddlet(BGXHwY9>1bTC8&foxHMcy5lXDgETRY=cJ}ig$ z{W%yhZxZd?R!L3nVOX>G7?hMRwtuZx%dYwj$1r=%NYe8@h~@Xsp`5_zQ*j#0xH;dc;_fugC(Z+Y z{AUEHS_K9#6>)_Nun&_jz|O?a{QI7FxS}&2Z@iAf`?b3%eV4RY%oog`vd#u zDU#v6X1Hefd(z&_8Q6R>37otCVezIIfq%`BU_&5-tLL!iiEv_#naaLrd2tG*W2i4J z8qMBmLXC-tDuv!lOIr}Qew0PYwX)vO zTTg>feH=EIj=d(dNp)O=-|4P%$dQdv!a@74Pm8xT zLD9AIoUd#(J#p&g@+S+sK$|m|6*`%9AOC}&>>nU_oxlqvKVf#FHBDW12>Z`pfJ<{m z^4aPyxfI@?Nxyo^Wh~x}uWG09Gp+*4sMv}gFUf_c=MQ7xp%8la`#k86UB*JU9Kglz zUf^D-1mXE1cs7(W?JwmRLgp|Nl-N6jl8Z0Ew}?j3{M@y0ukZ~$m@|e2-r5Z{3F~RX z>Eu3|@S+9fx?;a7%N7=-+j5SpKR4EeA$2YCO-S?yf48(> zHo4AEr)OW^g7X(^8a$E5M!_laY_B%iD86aJb1WXX$jcue zC-BXCi2SwTAD6-R!$EpG+<=LsMv(mOI?NG0h1a(iQ_RiP{G2=Q?2CPk*(IAt_^ILw zzA-l9zj)5&?)F4O)hugfy0TdCsDH!nQY@-ApnG9OaP{CR{C!@YnR+bci(j3=1p?bK*kuWNK2gJ) z8U{=^E0THog@Jri6R&K;D;{?}k~ zT73sUA6BEYRYjbm%MGl3VNS*VY3$Zo@dmfZ8SrGk zJ@=gFdB0z;8%$r;Ip*)7gLpJ*GAc&B136-he*||xMd2&>{rU%!dSwc`F+i7dM4iI+ znI0I?*aN!9``}3BJytP60nWbk!W#j;tcHv;ndcIPb~n<j&myc>)MvGx9>KfDwVMyDVvRt$R* zQrT*qQgHgW5gx7&f&%|8hRIhYQngjMF6aUAm>_`GBaQ~-_q^47_IdC!gyWEiL+bhtvLSfur=L8L!O|a(M0Dd{K3=-=?(WK-g zIqQ;2!w$9Z#m|(oJ%=~pJL>xo3fZ6&_o_*)0= zeFu9{X{bMaLXXpn+T8Az%Vb>c9|h7` z%h-!dqnTy8bZby2Nl|A>TG1JBo3BAT-&msCHYeJ-$(Zgs+QR!1M?r009K=ffU^>1| zqn5>Tc+ysy%F0)fr{DwuBj@SDuSqa@IF;W0^NS20GN2NP=GelyTlUYt4w*+qiSEJ} z3=4FIH?mUT8_zk~EuInjQXSf?*5jzAA5PVgXMQ~10xREhyD7_^I8-{5Jm{ChgMq(z zyBwnV>}*p;OkWlAC8D8j?`F2yR24G4LO^r=WXzixL1Le?FtYy$dqsLasu;fmi$%SR zZ_gC^+}oN~aJ}Bl>?k;2XNtOsyFeg!F2QaaEHyq%@5bN5WydYx?#sKpXOUBBB|@sYg4?evInMzb2%F;1dGfx5@M5$x6*&IrF~~%^X4zGUx4$o*oxEH+@AG& z?Q)#3Mi2XX$4T__9jua)B7Q7ugO{&6$S*w?sMO(Rg%_KkZHWMgjxNGrYa_JZ6a<5{ zcZk7K1A1~}4o$qW9YjeSxjWGjuPC~bWgGG!)xDSa9%yBF8VhL2*nX^=F@-v-Utzxu z%ixvgV>Isg4;VcTMQQauNv9HoiPk_99yUm8D^b6t3c zL|P!>%ogwSgbmiJ;3#59g8JmhzK#m0T+)nP50V&pj*}$g5d)`woZ`z!X0qFiPD0qU z7f|xb9U42gG5aT;W)DX6lJ1AH?DkPzJkdIbo^k=QXjK4ST}+5~JLgyp{9;?R`X%OU z&SwHS4%kRkHtCIi4pN3ez=UYQNakbK>GdEy?hXR}upHbtp+jVpdhy!Rd3@`WH%PMV zQfi_#o$~ibgP7uDG@iF3l3{CVUC z>*cnRqD(o-Ni7E71wY9XS#4z0M|hK-s^bEfNAhZKVeOy8PQ-808$tx35>x zb!Bnz;qrz0kX|Wze$!&G;OWqZTSlSON1hngi!my`SD5hn+4#rw9wcn?MWOcf_~?uU zO|$pm?qn9!JAEo@hpXVv()WakoeG7a>9G0-j~%vIfMi-GtbDwXzPYiVM$6uVpl?NB zW%Gr6KJX93i|#U->&M}<^coJBalT5f7}O8izI5j(VKiz;CgA5Cvg4s0b(>`1ahA`!jY&EBKRnr zL{A8Zt{owCpXWt<6TcT{9A6BPX@WH4n+RMHlw}wCOA)t-IyTp#3TsD2;Ht1G4eoS; zka^B15$Zuc2Bg69T5obPECRRu(}mQ<>G0vKB0YM?1c&U?(c3nb=&q`V+^)uov~+)Nr0j3vAdq3jdm1p;6;73JiRuf+8JkZvGiI+072) z2Q0|xtt#|SW<433^uR77iO&Sf@Muw-H}lMC8=bqu0MvMDJgWu5)aBtx+TQaK29ke( z^M8eG{?;toHgJMg-q0c#W{-zYc$2CFZio#6DEIC(l+M>6ddA1mJ}sQ&TOFYXgncpf zhBls!QlR=33xBCs6C;5tvi08&!+hyaevsT}i^_C3NW~3AW}OHygaL5XKMcz`=Km$r@irxLP!y3D%RR(wF!oz9VfhG^KCq-@w@4`tG^alEIc0Tu0Y!9P-%~n6D>;-u+J4 z{VD~_XPaPo*n7s0%ZvVsQKE|{T!+6!wzx}*#Ubw-JSx8p52|PKvj47vfQj<R~r&ufZpWfYkY`VdeN%%xjjz!NqIgz620+Qp&_n zyF^U=-0|y-i}g;AXOI;(nY6J=9lzMUdPX&)qgQHwuQ|u>?6s}_< zvel`Ia|ao?R0Dp7sx&l2ly2xe426%^z$(X45*d039&^OVr-fXGCwdO+8thM7WHRAO z=@d{22i>;l%3-EEVO`&9~y&Fv*8Zj;uOQ^=>+>NRVonQyhnD z7jYhei(PcJzaz)TP~#jXv#BG8-#)(W7;b(w1>GYX=-SI_=$4bk`z>olRMX#rg3T(9 zYt=^1*XK~L*Q-&qTZ#<6yn&J7WpLp@G_Bb1nR%TrL~IillZ3-I$W>y9=6O+mL2y3m zG^x|q>F=0N?`*N7IuoQH-{YN}*oE;Y#mV*TXN*}|D83ac!l~l(&`|O;DPOgfJYwAG z<_j)duQCmd$5hx+nFy3U=z{wD2a$V*kwb@*FfryNsc%xoT|d7-lyVszTcN}sbo)#a z!t_u%SiWKNYg4MwU`x(!^5xiIXK{_jXQsoq7_)oqV9M<&_P+(9VXwJ6>Fp4yFDTc- zn>Pd-Tn^T=E5&Wdn0^Iwx6cIMKKXzHXaA!AgE-R5kEd&+MeGl3?k0S<7P7%uo9f== zm|hoU}xVuASJ>`1V8nvDr%0RKyul9S1-?Q-n5u5kwKg zwKRcDCerI2Kt#)!8i;ca1-Z3#9wfI7IK7O<) zrfRc8xP4q6c`2id;Zvr^qh>|IXa8eLpNlm#-TlDaz54<^`(Lm}hOW?gx?^zn zg&nRg5N3N`$kD55Ohf9}U(s?s!r9~xO)HO)7 z?Td(a$v<+S=@pqY{2DDrHdEJcL!{QDiH_`3rW42VX<$VFSn2;E>gt|M*3T!n`zzNI zd9{w-*D4?$np2K-Q>BaT2dHdc9F9$!PhT1i!vc>#;CMY1#!ig! z&qhka=~hE9Xj(+~I^|OTO`@21AOT)@#(>`uhlckJrex8YHZ=B*fWd+qNSbq(mTMhn zUu1b>W91;c?+l=OYikfpyGiGZP+-?Tp)b_}P_=s#(;(GNO9wgbQNUNC(uP#8rJp8- z&&Ov16>QKxK{zx>ux^Dw!;6^>)G~b*JyAc%F{YiVO_&1SVmiovqd2oLx54P7HOjnKt~F#7&3)#H2_BL*i?efBkIxl=)do-1 zb4>n%3L>rOOpHI|lWW7JP&K!Lb{*eMeV*Qf!48=Q>$9&>OXdu%-r+!1v&+b`eT(U0 zKN)i3c`iDS`BDLXDhZtNoAA#`Ab;gJqn+$YmHiHp?el(44S6Vii+x@ffYCB>c=Gvg?6>hnS)UMQ!H^KY z&*B!XeSVmZZQerKd$<|x#pz_)B=H9HI!d3{9{}5F<@KrU#5%uyWl9eRd%i z=4#uJm)xG=z@Bol?1CRXc=aaTQ#g(N^2QdXgvdAaygo)e*7eapa}rU!?USw8*8KQhLhg}QlUg$WUj~2#lNC4ed~1kWB)Vwv-2Ghzbb0K_?9Kj zXwavhe@W0%l?nE#+6(b%m;rv#Pp$PWIE@yQD)Gkc61qwx60iB{@J3q;iI3wL$hY05 zANQ%!C{0C_*pmc>dM8-T5IuB$c%L@k?q?e6PILVBd?N5Znz@#B7j`9aS8} zL&lRxeBb~Cnmj<8uc@rO_*q!BKnq)6mDni%J^?OTbyTE0mG*LX8KlY$I!b)vwabd0 zE6Abe+zqItR~o(6ssc5wxj6r&9d#79ghr--ehiv{zL`?&4(2s=iHIPqvLbq3CTMpc zht9ho)ZixcmzFm#r`4zq-Zv6>d!%~6g};N2jhi(*>yjW^Xa2!}4aXLV6mA$wypHjW zcTul5oHl>lPxLyr(KV}WsCUc*GR3Q@?)>%&%ya}!H_}aGwk>7$f1iwVbnmnM^TO%o z;9%4&Tu!xX8qg!^G@W8_jX4z93%|H-ckWzdAJ3Z($D3C&W-2lbwDAMudM%NTyeYwd z5i>9(A&oi;grR`pD}MhPK1q9BPPN=uFhZFwG}3t!-T6J3W)$pUQ$vNZ;gV6q$D=n1 zn`@3&l`C+nRX1b3JdJkvq?6TU!Q|yGReXN@H21cPVtDkQFXjGnSCf#m*Kn41($;{A&8%a%C}Cv(&3__-4J?h(LT@B2(9 zL`0)a4Fa0!3$P(DgFX#d-`8^~j|!jZfihJ3b`7Or+79a>&}J zw@}A(E>>$_qIFfG4JVTY?Og+aTaLz{xHKjWzmRjHBgrn!+hC03*J8Y zhvHY}G7=FoxU=UaeD=)e_WP%)Ovo~LG}npbXr{1RM_1yRd)u*3UjY@LH}JN-@rCVw z9>L_tm25x9b5olWg{s$b`Q=~Zxjb|avy@{Ibe}HZ&5pmxiU~X*OKekVa_(Oc3r2FN z>=&H9T?kRK)3H8&DLFss5mg&%gMDE>bglVp>S;Ov5vSc@4^IyMu1^KU&(f^_x>L*( zh(N6~su*mXOha#1V0HC!ZuU}$BcID~kslXuog9X~dZFaA+6{K8w-;N~zKl6wpF?yj zQsK=0V`O&3N^sIC$ILe~G1*}^d+7djTK3M4<9U2ze*7`S;yoGs+7G}N-Tw=&KaNDZ zAsHh3{1pTrt@$VDxueI>Bom726IpE769T^gS3Xdp{7<{0gFcP8!D3r0}yW z5?3vK;u#VFQ=SYk^ZH(swfa(6yKXnchx%h`<0AH`)Eu()S39_mnn2EG85rEr&EF(J z(EIW&X6LtddQiFs6BGBq-SuL4^{_boGW`$#wDke_F5HaCoFB*G=^%4u#!5O%bLm=i!8DF%Nkqt2YKJa$?+F+`A9P{*OD%?(b%1)a*rAft%mH{F_t0b{Fh z@a`+}EMO_4CNmpem>nlRs;XeIrX4bD%rU^(8npU0lCbbiD8GtZY+vysTCqyZR6{M? zfZXqay(Q`X#3R1H4Fj|=bd$nc(s$q>2>9G57F=)QUV9BZ zH$afmai{ug^5HheV1c>3WuhSn$J>7EEIJFtkbM#ePomO2%cA#^#D1C{=o zc!TeT(|Ox)xF(lAE{kU!#kT^3M(F=vIf!=zk*P!bK$c^y$mA)*OS3^Tu(Ocd4b-EP z5aD1?FwE=hLMH`XSoqolZ%e;m^@>@LTvJH)9U0NQ7@%;lLT8`6lF2ROGz1O?uz z(;rullm4bAcEKS@Q1Ez3L`7Aop1v0TV2{zD?m6Vg@Fh(A`H3~Ewn1(L3O&8T9IL^c zCOV|k9^41Jd3X8SLXfX@#0(EHlybvyc7dHe`y!M(=MwJ2fz@B2!66sYIL@?w=O2># zX#`4R&!eVBG`mL8&z*@Sw;$!zKWbms#E!44Mi_T2O4HA*$hG80!EIC7d6H|PC zBZ$P5E+$gt$xK*+DV4UaLtSeV+Rt$p?mjC9&kj+t)4&5=_H%dn{%ef0PYH2qa6{dt zgmkp{vvrd>@8^aHe72*W8J7^ClZ8xadYLwS%&?&1-)*R*!EJWUy-VcPaxCtAUes&# zkDwu3b|*9XJ@AGTP-OTIGtVOyEn3GR_5L1m`S)99b8Iw-Z0sffad(>bD$Y51l*--})snLw{B*QWWir_-S-PqKIIDKNa(OWr!$v%hW% zV65;bu(=ye3_9)DMIyB%{a6Gpn@Px)gjHIoI5cfqgkEE&I(LdVT_ zlL(;%nBusP8p$@|XV)tBeu6IXy1Wjx_JyEKcMnm_+r*Af@T0*!S>(rtO>EE86uO$@ zOLV*JVOj)}$q)B=Ak=S<8mko>F80i$U#3g}0Y((H-b{lKIE?2=IrH_NDF}DmWYdzf zX~pwo^t+u!9k{;g_r+$U_*g#VX@|n~Ra?=2s0H>AbGrMp6tz1y4aE6{v_! zL|fMm*i;}zRmD*yKN6Nsr|DfA@J2{4lR8=e>i_A}6IWe1=9?iM zN`43r=Oz-{wVEiccAKKf5wO**X9igr%sDMdWlq0hI^<@fg>DpSys{9VZGFu8ITz89 zZQRUju^gP4>j^74kMC>VUT9yCg>NOEVf!LuJnVM{o?S^~uQMNT{`+{$G3$gkoBLT= zejZfl4xz|pAzbmxkeVDj24APd!SoaFNb;MNb`HExn0=@KtQ(r({*HgP(eg>W?gVeV zAeBSg6rAbf(EwPWFGxFUCo~uuU5AYd56S39FRK1$3VZoWgPmMSDf8${GyCg83#%jk z9R`HV(0)uE?!HCRdC`!(?~Z||VX`ntvhj#TDNg4cs(yJBQPT1{*>T^3I+iQK&mXZg ze%Vt}ziATZS=C~qa|>y*#4T_&5yQCjIe20B8D9H-C+fM&6m@*mF?LTLzWVJ9&JW+R z#oGc|gj3x4t4~D!_|sgIU$Fe*LG)W*PoCDg;zFkr@b>R-_^j}Y=r~*?lG;Pejt_b? zcCQGAwrn8!S0bVK@gzDs{W0^tRT^^68Q@xHF*@O4EXgTI29dKX(5_7iYAzJO#@XU1 z^fwRJ|H^}rAUSxu_A*)HC#o?&PfG&tuTq{xGY1;MNVRadpvOsTR<}O z18~AFJ{k9E0P&nu-hWTk>F~=``eth;r0}8$;j)KP9y&s)7~raW5ZvP*jdS#55EUJk4pG_$C~Ua7UBEv8w2k* z9No#Tmpy0w48DJDW7~H;WG`%C$=}o{o{3X391Qyer?`&J+!Jf@$0JFYY*P=r1-Lsn zo5AYKorE{@k3lthF<+jl@qo|Mm_@1A z0bc&hD7rY9J1=M3fb4Zch)~!E8Fw{lW#Ae1?3EO7=B^2XR!`x*WHb$bwi<_jxiTGf z3RF2Qk69I{P4eYcF>P%bm!aiYPIXG~vT`y^@owa^$E4BZ0oQrbf61#-Ig4;@CV9>E zh)%TF@&>JKU}dg5=WKKY-Lq|^#P%g}*$Fi@#ZYI~mUqIVtXWUShh?#2d z`BMp{_IdWKkcKTiYF-PUvo%0?_B$f@(3E~UVg;2$va}-QmS z=f^bEb&`kf6c4x^X-hQoR?>re>REwF+e!E+18+|7p_+3L_2yp1br*L6=x#)}V+LrV z;l?QR?_??;JZ6Olvmn6AjWo51&q4uFDfo)2kTD(M{yw%rYVrd--s87U6=*Sl9**;hnPC6G!V0y z3E!{h!>Nr&(CF?(#@Yg`D^ABTKtXx!Ic~5!}d~hj!tR)O7%qa-wkhmvg{%WNE|1W@2>YE6kYkgp{Z% zvl_L{ko$EzCM@J;#d39UKFW-`cf`TR*UIFDe+aR7e+zcvU66rC0mWkbOOlveTO`;Q;b-t0^K-64`m)q!sD~`*q^pWU@|60;};mQ zi5v7WVSOHX|1K8dTm@*^J$Jk?o#Q9KR21a)0+KuL@s?U_rQyBVP@A0$%Jmzm$JQ0~ zE3LjUDH`Vq4&{?x&s5TOZVu+RWD=vb1yKFo7dM73g6fc!ct0)$9M`wjdn>3l)Eqkp z+WxJKL7xI!yIKPLQ-jf2G6w&4`NF}RCbIK^F-iXU37+&M5+Q|iuw~OIT#PAzd#OiQ zWzSh8sHBaY`=Ek*E?fk|voSQFS3w@fF}0_5`+h<$PaOZ~`p^gRI-oJ}I0&v|H;Bd_l0qE2@tEfO^4Tz?jLZ^lw6o|T-A$9K5Vbk(CYJ;V+zv>mbNfeK#E=)*s!9Z~6CIUIF1BL4rlj1tYH z$FuUVwc#9%{lIxc)kUdX=Ukj1G!dWt`~`XwNfs^6~u4x7{j+BL6L@`F;+$%FAN9L*h~ISsy**c7V*- zu$ZLmoQprRgzR4leFMp18z_2Z0dl-caOKtu#v&k!Gf7>NqW7n#TFqv^-bRaEV43Vu7py=#{0W7j(qT<)|HBvz(kILDY(loBD49U0`m zqF-S4I27%|x8t_bf9TOvg}v|9Fp*Rf501SSSe^nu?R%LETWi=cnVX=M^PV12O+u4y z9(@xwmw4UoBEJrDxk<@|fUfq;O*cXIKkjXksm67DGfxmRZlLLPr^1-@_|nyq?3LKz=YdCCKiC&j@Q-wR|$XfWKho)1^Hmb02`6Yyk8Fxh#l zi)UV@jIKRmyyb@G#M|IB*PT?xRGB)a#zqJBT$_qgF*Q)NZaxm~a%SeLSHYGHAJL{*k(CC1yjBD?xn?jcL<=f|1&F)6Iz7Mr z3EQ^fI`qU^Vczvuy!w`Ct}~fHMwk?UQAL?Tb@37)DF`R4Us3mrV{0l`DmO+;h*~$gzOVzUui8s z#|7M-S*(>YY&Peoy~yAe{!Q%DBx4%7*B&OvHj<*{iKJ_SGDiQ=L?K&S9R7WW*!l56 zt8f5JPZp5+#d8_qA6$R1hRZ>Ie+gmBOsS;jI60t^!hF(N4oY{n!NH@GXw;}H{Shll zhtt%t+1Ls-|2~Grd9L{Ft~uxiYhsIP47@Y%<(mc&ST=nj3XR7QkDIrdu;uC?-MX0( zP?W|P&i(gv@-uLAa3aqALSUaBiR#MBvAIG8*C##!FXMHfu;m2H%RK}4I0vZLR5`Q= z+5!iQWjL0Q1pUr+wY-w#xK6-Ss{P85B3X}=db7K}F5zWMMe^P%t$;r*9IN}UGPF3c zrbA6g?m>My@75<;nP-XViHH}O7+pR@(SdwQ-J{UiPX6H z6$~}_z%wsdk|iYqO8aN>JGqSa6OJEnq;ChVZir%pk()0>`QgF7qmVdW&F=3hfm+kyECiQK=sjlG!XgUfL>-+xO9*qP-+ zPscpuTJ6}pR-c@mwu{R&?}D!898z=$77KBKAMyC7!zt5?)bwrNDs( znNNioUMoTP>VNdon=549?sRxeFG69lEvb&4OjcGkG1Gs9!;*3f(6JwZ^udFq??W0b z=*`48e!Eb3+eYNkLULpWK>I^Y^xVXutvC*lH7f00W0P`fX`!N>?%hq=&v%R)>{%;mFG?1Xx0L!AADxNte3%cUt^d> zwvuFI+C7r+WFoGeXAKv9l3?rQ_t3+!ZI@aOvGE!c=ypMtou#6Jz0!A~ByN(Od-^73 zvdar%F;|+FPw9aj8#YpgjF6G4JCGg8!sv5H@O#|`Rs$pC%*l7~&d(0l7z(3udIY;b zhkNg|6V@Qc6$aF8>7v)?*~Y?I?B2H=dy}5yzCY)nsYi>pYKv3*(5>uQ@l4`y_A1
  • *WpY1v4wf74qGMB1QLm~=B<*^G#<}R?+csCc=xIU9dz|r7 zt_ddH89~KAGlaQv4$SlW|E~MW!Mb!MSeF?~M&dtHSrYJg2vRRG*z*#;KDQiiDjCtL z^_lOOy_|As@Jk76w|1@GyFOvp{JX4RAD;DnlD_cs)&`uQtvV@wq2DQA?3nlcb!7F?j6``Jp+1GP1(A#4>_T zEPE}LK+TFAd;Inoyp~d9?(d7mW}nhf>&+be7W4o$Hecsjww=Pjr-w0L-~?V*?8F%} z7%m>)EU?)s_+N*|^Cp+;`47|OXzJF_qI%W2D5-v*>r1`Jb!cm{*2xht$s-T{lbB3T zr~JhV`F6}x5TR4xGE%d7i}O|OXrtj-{P4t|N-ybvYH&4P`nyf&m3z~Bh$5e1PGWKz z&B}buVCApPpxvWHpM2EFasLn6Y_|}gK!XaEi{M=8b?YvHjq=kb64hnSpxH%^-e=4~ zr-H|*q-#M2t4Gpb;a;ySegrS&<+#`=;aSVIgX0<*x*#ibo_0lI-(*cZ|4fz+Z!*HP z)xY`Zo=RNYrbx&2X43(MaqMYGs`!E3E(}RL1N9|h-n3yP8^7m;=$d30KD+!GEH&I1 zzj7`OSTT_13g3b2w>U_75(On)gD7dwNZLMf8Jw`s6qQU3gujP{Zbhc>cYhd85g6YZ z{qZ>VO(%Mfy$0JhwV+*x4tsdhlok{N?>uKPE@?`~lMkljismeSbEE{xB+Ih-jXOZY zSB%hS%UcU-E_oN=S%dHd1rIhgZTSL$% zW(e&0lq|~MrBA|}4y0*mb2XCt`HS;Iteu~af%tp3(C$|`Hdvg*V8fBLwmp#o zH+7ocJqKeX4}-N$5*DB8z{{nld#2&!26M8MPog7#v{4-J z8#d4V#cSB)bGE)~naXn~e1GjQY+5ID*{uqpOc_W{@ZfBgHNc{yKk>x)Q@p(9QT$ci z!r$zD!UrGSL~i%?lI(DKf!kj#(v5NA9`3G$`~Nob&$r&?3=;C`ZD=|uC?|+C^L@FZ z?+eJMw-HoiBE_*OE3q`=HUDDwc}z6cXMeu`7MT2tcO7Q~nM;yzSI%xOdxH)w5T7KyKfe6cPITGKyK64&BD!Xy(nLwBFjPjTk8|<#f?{ra?P9)4r-J|d zZ5o^6ufp$seGD;X0-G9mg>z{Zk+k}6K5Rrhb-bzMM;7%9&lXMoO}inLJsHfNi|+IH z7d+(bw#m}Yr&pkB`wAK`y^Q7z8-|~ETXVK^Z7Kh?D zRGLNszh|G-@bRLdW{cjw% zDlWTY}uDM=-jwpm7mA|tbGsWiFgc@(K=$w+9b^ev;E68W9q zAApN<&pn^#dB0yT!_r@j@Ox9Zc!$em$0rfpoe3N}#1v!R8Q1Rn70(`iq0g2%nz9SD z-tkk<#e%^18;ntUDukCEW0kdf;fCTP&=c`Nmn%}Z%WeW)liLOxL}l^5yg$`V<(4m2 zCwRI$wMf(Yv+T0vTe0tHIfPV?F;X>CaW%(^KVTh+r|w-MB?r^E*sD7h70Cen5TSuP zFGGvPRc2rr_pJY8E$!0#Kny?V;U$Jg+k-cf&82zdf}RR?{#K?^s}mr}U4_2aO5htV z_N8)h_2l}}4w5S4jf~hQvaEdq7Dt^}qY<^B0lij>%HG zw@e66GD{I3KZoTlmqBv=4wCSz3SLfn#Hf^?C3Ui*I7ieTTwhlb#{wk`ldhpf?^eRa zAahK3zJ(EN)x%fPhOBf;4myXWGSZyuxzjI;e%n$>vdSy?x7(|s`NA9~SLXry&q1UP zvP5vW=o71F7lbO7VKm~l5w$3o4^ueSTLpgw5LGFBtLlR)o3f}H$AP_QJqtS1JIR)X zd*OZ9H#i`+kq$rRIw%fdkd)xhF1bBIPHEJ0*Sah7N?8Vl;<#-6lUNi?N+Vgb_#{48 z6X+L9W|yfKN~>N2yRrY+>19tqYMmu`2=;?;a~;+S&Ow0_>3H5)o0!jzIFx)B0-qKUx>hU;xBU^r*@}CpT~Q~D z9kK$8oKqOG^&EZYfwW{+2zD(@!orZfJ!zSISs^1XAK(SLEuG|se*leA zRiMJ%Cm>@}7Z$ucOWNj$keY^1XmVMN@~iaVN#`XprlpJ*AMXXX{4z2sp^b^Xf|g<; zDd^_TR5g98$ylUtCAYaTTV7O2b#l@61($NMfrw4wXq_V0=dmduw(&eKRu?R%uAHszJuAuj?G-om3+^Np4W^s1hHYG9_*^N7&Hf0(d5B z291Wvr2lgSt>m87pRCw|{yC*kkP!n8UX!psdm;Qw_(k@7NTPXWBCut)J{F&u16>o|FTHy5r8<x^8r^o!4%G ztKnkucZV(I+hq{LsRC3j+mz}L)WYk@=b&3+DJY(+B04J9*mRAjR>ZlX zg4t>_&MIz60I}zqG%Ic^34D}EKIe6lf60b$%+rhv${ zOW4A@^3ZR(2+I@Y`1wQjF#QC_Z#{1SYj6`N<;y|Q*%9*g#6)Z~I7~O?2%+D5VZ=`> zf!w;tn9aM+aly~B=WEQNBCdkHC4UQf=N*X1=3}_4))GC!%GntTiu7S`bd9?rcRfc> zgud!Hwrac;9=Daix1hOj$vcb~{tAK4^|nj{Kg;5Ce=2#fhwyG@Bk$2J ze*jt2ciM(LQ>X^h@Q^@_0UZ7%op{ozg-6nF|oM>onwFUdY(>?juRV zjif+&59}J*&)hnH4v$?Grw%!hV6(P~Bxc=%OW8i8hjS5U9lXh$ol6*p4u*MT{FcmG zGYtoX{6H#Lny+!O7OK2jVCB46n6>IBR85Wn?eqco@Y)8oR>tyte$M255Prp2$2)?< z^*6A&MF17b;&AgBiZ2#_1?!25*k39|?}x^cUSBI5*qO|(n@|aLpG0V?`*+5&wht_~ zE#!AkodmZZTmOg850#~ z{WT-jvpJo;%N3?ecIjd93`Hb7DVWeGih*BZ$pMa$_0r55+D>GEhVXIoiZWXmW>RQb zg&&5_+k$38=iz&;5&e9d<2Fk^V)8=`U{Am~a;C-!wa<-`WQT3Ine&GFa6+x632W%8 zSPOos;&Bw?oD3TBPilRxE1_2ZYCLwxf~dRfVwXH0W0||YBqJV(K;$yAyKopBY$PGt zwgA=@G&3`EqiVhSI0mz_3wz>W09*3GheVI~(X^it92+`QZ$9&K+ znN<62GDEJp-vLox2uV7mMTdp%Hv(KqiU*hca2s|DB5*7su>>2O*G#+8X%JVBqL+1N5FYO)jGOF1CytkK@z!2=Q4#}RYL}6U$pQTNHIK+* zpG~y;+bdT0zXmLjOQAEb&&2V&azH3YbcrU?-fs9>O6c>8)Rn*n9}p* zQgq3DBN*hBlE*1oP?C9q{1I$tbK4W>S(i1q*UAze3>whusj0N`j0at~^Z9-<$`UCBdE`@MdbPs zb&~g`5*A-==UFytewls)0<%n4XKmE)Wp z>w=<45v(d@VBeSoj2dr7$vqM5pR{N&6L~@}H}-SB&mFLZA1*^62%pMlqV-V@Uc>NNKB1KO-;MY3jW zB}Y#O>@n06ThwX^mnI~|+?FWuO zs^$w>M5E^rAE()#pfTSvLBn!Cxn%hiru^6kdQ;92`6SMb(;q^6VzS`TNC4UesA6oc z5R^x(#O&`s*^z*JOp%Gf)z6FRpEVot>bXo_X15PXn6-=+Y=2E$c{wPXGY299S=#GOP>ewu9gdv{q=(SIlsB-Q!3^iT>G`fChOnT5qzTyYxI zi$pDVe>x4S{c704<$0Yhg~PI?r#L_2efHJ+)i7Xog8o{*klhy+k9!8|iTh!{+q&pzoZ^KeWC9mLFb$vuCE!8;V`zC@V=1>}FwI zmm(gyAcjYpVqodRqtwLJjyY8~3`$3*v;8kC;AvtRY}K5AJGHnoQKb!a{20W3ba6w_ zNk)xZYw0@YVw#Y&n;LFQqfZv^CX-%OkbI|3I<{v6I6dtnkKY`?>M~W!xtk?GGo*zP zTJVM`4K=}Ec7Y_d?k}oeTaJq-ax;giO+>@GA3CqhgXv@6Sf|1Wx+U`-k;zDaI_KwX zKxY>_VaIBaGMz~7$yAY^c}3us@(lwm2N=oG8?ZG#661_?=%VolaJPIBJS$7Z$(IBz zowme5iR1%T(W8hjo`0D}SVTdjjRjq8rVbaMUWQZAnK;x~hw>dpuM+hRKTFs3=qp)Au#=6u1s^hr_lPp-9$687~gbDlHW_$V20^>6j%BQ4pzF*vCoE* z!vSQ6>tDPiVo)AHjAk|{u4Xo&d{Rj+0-mLfr{?1Co#<$@b}?3x%d4U6d$jm z-!-*}#n?2AF?vK2XO@C@{#@qF^i_1h;bypN8i|(S_aSE98dgtC6y^5l(N5pnd~MFn zE4cMP=+UyHAD8H&Ktv1kam{i%-$IePnoKB&m9 z@k_=W?LMmMAV-}zua*4yGC2ONkUZo1pekVy;-!v zDV|EbyA3-t15j~)32Zu)haWNyb3MM3aNEKZRz#d7leofKp^gS=8%!Yk%C3?r_wB)E zg&K3dI+wVSt8hLx7ng;);_II&eBlk7==%8O*gE0|>e0^d{;xC^R4^FyrVKw#oyIg< zXVM!%f|gTtrLo2MCwN;*Vg^aU@$JDNT>PW<;pJ)Iy~Z0}_c5$X@^exyexBVZe4WhL zw4Is%m+QZOETx}M&cy4}&%^LzJ=!RtLOuNQVZ2?PJ?52z)_)4g8T~9)Wt#$V6G$Kq z{|b4n&7O?)qtkd_?FRjEio%<1W^|vNAv6x8f@d9>b;)wL+RUTJ z-|odn2aAaQ7Z()KXL;LwOK9`-X3+O5q!}gsMAagOPM&p>QF{9hGKSs2@~A!f21elL z$XAWk8jdb5IYu!G6$e#53amYF~LiXTF#o<(rHN+{kl|V5|@Ued<~MPgxP18Py6#ML(#t$rFA|{9VHHEa158BmUkfmob zI0vdFJhwPV1b1|>|75Q*-Dd@HrQk*s;jP9_V*_|SvXU6+-6mIi!{{CVz4Wn9Eli## zjEjoPVah`T_QB$G<|R|WBs7X6d*m$~7ySztJ>=anX;hSn z#JNAu;^CEi!uzzAemUz+rMDF0?!yhBVwgh@_FrXwht0tvPkVNibToAs;rg)2Goeji ziJUs_&OMhZL0^Cs`WwkHc{gLIwEYzl8x@C5+lpz8TpHbQQJUjRsM2oxLS!=5pmxa^ zO#aYE%70Z+RgX+|Ps|+nlD`Wta}fWVHPM)|(j2u9Xuy!iZp`@`3BU69lHFTnkjt7< z_br2LLgpDN(;kbR!L{&;JFgrtDrUdbDA0y29WWss=t?ElBmUdFZ8=O5_C}V9UEeR*}@P=IUW!owSh|Q9n)1 z#Y>=3Y!|k^8)IFC)`1z9$BOv2mKMJ+hNMG_NTkDZd?oRKJ!pRsVtaDw8u=I^!@o%6 ze`dfbePxty-Oi+VJcO2z`=EIKJP+@1_qa`)$;GFeX=Z#lRrsI*2F!qi>L?MN{ZTmOoTJ z*#ye7m!t8TPGa?)^Ou!OVKV0&WA@#QK*K2~h={ij)1P2Um#mAW9-%Af(xt)3f0M<; zDIUVH4GI`EmW9e(mrL49hc?u;fX{dnw&%~s+j}=tL4`!rZgs<{*Lt}9kRR>y&kuY3bEQji9 z5n|}LRNwLD&+G2{ivarFP{X-RX@ph zJ$anDGVxae(leRRwXTs4w(MmhHZYi*ZNhY& zRV5?B=Ws^FQFe|RH*+qlW4tAE_-7uk#9tZ}j4?lo{rlVl?_DXw57mOS9$@nRKJl&l7t_*8H?%DV{?;-*syBHrzEV=6#(RbF zx}zpGkD^71Z4JMBV=J7xwuPAycN(U>J&x5ER^U>-pP(mng_gg_N8@cu)Ga8Ft_?oP z*6*+4J`9UMSiv3pmz8rIcxTwBUPXLd!Xdtf;}RRHl!s`$!QTw6r1~xT~ne)SGd1Iy}6oj{COeh zRyKpnm?5r~PXg~1Dwve{2tLDQwl9fuR-OI?%~e_!SJ$S2vfOTXB_D%DOg)tC8H9E( zj%|=pPl~6<@i%T2C3~zm=b>FS%(Hw6^{ytk(>M;I@-7j50~ef7?TlT4iHt~>3{7r8 z*yuNbhAi>{-(7-`uwRa*99M#n>uxyhrU>|4nbhWD)9TefLcXI;ogEo;!&JJ zSM?+P%yhwbl1w{&BrwxyIa!jJ4S~-Gu-ihN=BH-Umh4h|ZV|~od|67F6b56e_fz}z z=4A0&V_X{14VbnNi*yH}bfOh55|Bc(kS!=@J(&g>rDEK0C)Laxgyj}{$*YB7WTPj? zaUnX6JYWU-RGI31YO%G)5#gIE$Z0x}YQ0K7^OU|o- z|FdLnpJ`4{5AG&IKfjW=iT7E->9$n-_ghG^&Sgs-Kk=_{-1YEjGjaQXI1c}{zzSB8 z7G){2ZLUATsHd1Yde0a<4E4c%$dA-hbd}SFuBv{ z$0|Ox*c^LDW_?yh7 z7yc+xWiuNpabhj?Ry~L(PuZgj{Q~wO?VQj2FMf-@NrXOSzF{w3B6g*@O4#{oXI+ z%RX_Eb;gPGSgxh(yVp?n1wViZoxrr+I|7Y&N@!B0FFRt}MUDI9@tW}gR-ktn6pm!V z(ui#I9eXvOanD9yUTyG+m z91YQ>0+yOoq0oZv+}=ad+>3~rj5%^QEG8pY28+$DarQJteBcwxJIE%`r;HKKTwy{B z7EQw1f=P7i^rG6s>Na$K-60bCJcTTsC`mp0a^XmDDu&d?VCumkIKr*S8&Wpk2EEQbcsnAP1OtV%6eOC$0OUZ!Ku8O$h>P8gJ97-FM9+P^U@1U7H&>z5 z)fj42Ist#Tm(UA_H2|^Exa#LS(y+mpuKuqXhCTCG)kRO?=JVI!aO)0ZJ@pO|YO#j! zIc79I?&cn3f|lyLhp$m2R*HYxHZX(D7tNgQpd^Eq~s-gN;Ltk6botA zdU@)tQ_p^xeI4cxNZ_>`Z94W~9DY0ufX!RPXq#{kyG4-O`Py=vFj5Ax8JurQtQZzF z`BU{fvx(XDNX9?)Bq=*BYFV_2bN$SlOg+ZGgV8JrWLBg?EKiec9ptjGm2=q4Gx^ZM zPUPNy9OGNc9I?9;I?%je4|31LObyegE z=fuk|yp2tAsTjGi4)r(grXGi9qf(0{z5BF~JuP1h^F9|a=O&6$`3{CCXi;MLqKnCi z;Pw^M_+S04JGnVT#{+|Xu@{R(q_pqQ>^WC zC35k5Fzy#y!ye7w35so! z^y91LST!2W&a(bUe>)hWx0^O|JK-KktOTeu$pn8vNuc5uIQ;Sp^PM*ieufsf?@JRC zvb!1WM;B5!RfPKz#nGwDiDp?x)-=SWka}m&c}} zu7MV9`|yf%)%Bq2&L?P-=>f3t{j57%UYSm zUQ6j4zi67Ea|u`O%Ovj}4}hXV8eWnRfU>X6*bw{!Ubw_j^(b*7ykJYMkzRO9W;*E>Y^p-^3sbjCeaQ{2v6Z(=w{>y`7 zcUW-#`MgHIJH}wifA^_39+wO%V$5yZFVYlY}`O^gj_=1UvuE@7GJ73 z77YT6iW$cF3u@>vwADRL1 z=eXX#OuinCSac7%S8?Z>1x75IYcqcy%|Vl;3-N{6a@Z(0j~X=QavZx*VmE0Dj=hjT zfeC6DR#`>vfA6rE5_|x|52@nkJI`VFOgnPq*90cv%{-hbw2JaGUP1Wg3exfK1mDP} z3m-r`G2@<{9-TQxYi^lgrCk&&~8hr_j7x zt8i$@6xWS4f)>|h%TAG{V-bDKCs|GSsVzY_oU?5bP$dbVP(g-J!D1_gjeo_tUOz?)LMf` zMr>sU2MQrC@eh9q?8Z-9=3qvRG%TMp9}oQ30&|xhri<=H(NHNrUd(K6-?7G3aQ&oMq60e;qV7IW9K)(Hej&+<1HlP}rnlaXPR}2`u zbLSO3w!?8J&dd00GA%f71Oc0Wf_k_g&`T{a_;M!OB$C10(m99Iimvc~U*Yy}vs6jd z-o-2xRe<>mp1=kZvwC{bO8QS zeF^{mSkdCX8T?)}* zPcV4B(neQ9ZFIWsgwqGV!c^T)TvlfJl{+eW6O%PYuU6+g>v%!>`#)85PvZ zxWx|AE9@G1X$)RA8)Y4@L#IzHeZG4ZopR6*HxBsFC;9$hl>3>#KzsuYw(Q|t1Ub}G zF%^T;bBX=?O{l+JifRuk;e$uL&q?=*|r%S|LY}^kM}Zp7t}DIe;W1c z=p?tT_A`s)<#F5vaQ&lf=!&-_d6S!X|4lU`^S$M%c|{QaLi7@HbFVfx-#<%&FK@=r zZ{2|BeI6P*H&KZzCdglQ0j~Bq5yzhW?B6pEWa2|n^w`nD6iU7&Q7#u~?)N_+d|-em zJxjt%Y4#}p#)*y;H=d3!rl$qoJT7XCf)o@HV!$H;%*0AZnFww22RjG{RT2eSD^0T+88W&oyF#) zA0#DTSKvCuV@dFqtIM>=G0RRM6K>=DkNKGRK&38Q!j7^eoVD8e zly^>gJslacX6_BB()rsQV486$;aM0{wQaA-JvSw)t@Q%Tlva_0-%asoZw&c(=>+^S zKLveK^-!#GhRo6zMZ?|=^rriK*p{`5iiaMeGH!ivVZScU2xYOUY7hz+U56i+GqH5& z0SyZkpt=4ZaZ;2yE&uFKrjA@Co6lQ8{)}lD*j52XLJRO)c`EMzqeONjh+*!%M~uE> z33Z-%9Tv@e!#thz0rJ}?)Bj|n;br$RT30up@|sQ(m)U#JMOO(HXYQjr)ZEZ3AQ_$Y zo-xBUW{}KvidNL`XZBT|#XHesY|o_>UeI9&oG2KBE2s3Kk&O`^d^^gfx*DKL!h34D z{ysbW_84irCPm7Q1i*gjeEgPJ1uvZBz%u(PktkGv&b!m`!ZJ_`qvnUOGSVLBdBoWvo zQKz#|5YIl<#vYfWw7_sSG#Bh*cKtP>Dq`EQc+ixw9iK^@=uKuUXahF8OVpjd@&re? z?7N20G&HyMhUVH5`ZTeXRg2)0bMJPst#cp2#-lydETxi^*Akkzy^`}T*Fx*~Fsr?> zhVT4FiDsE6VuNWLt9vt%k{^S$T^ph}Ud1A?tDZ}>#Rf>pqIo3Kx1grM=#mvjV;MS)aDQ)udl@zQVq+lWfR^Wb6?8 z1aF6Q!DH|NRQNe&|v?02jX9$P1rsZhUar7 z$$Rd#*<0@->!(&jNUaU&-z9?$YPq1FbqG8cxzXdr`|#;bPb&7e01}nm*w>XgR79f? zCt(kh64u4UY?(lY^VgBjX_L@2lj|FlYGZ<<8+I1GAOo$GNWb4rZx&qa_y(Zg@hvSM1wNuH&V&Xg;EV~EwvC3p(`D14K zesh%0=_SIveY{;el+f1l4|_YijJ)yZdZ#bN(WWww$+%*Ng-(B&RB1nY@JRvT2Z-R` z;j^$MaW0+n>Idl`If+Hd=ZRJMDEFS9Ol~w~vfq|^V#iqW}*p zQZ%OqT%*~WMhRrp+8Ns(za>kS)v*_|X5-^^=b(N396#(Emx1wDAf+Z7n8Z;ZNK&e0 z=W%D=Tfv6p-3e8ktWpS5xaZD=Hrm8GZ3%OBWExlvMKH6Wh{#>vM}7sA@h8nxh4L6J zI__*pdn+!IT_ujVDtZdF$?@dLyX-(KeOV~c+69#w;l#<)8H-j+()G&?A^G`M=40kN z_{jNx6yy3J#&tFrmq=1FMV(UxN@L>hUyQw!0i)L`#@1BqLy;s+x;E(>c=@;T4+(R= zsg+YmEl&+UifqS`Hh{JmPhP z4gS{*BHuH}&x6Ow$%c|J z86xi6;XSLJv{EdBiTHeuWH|^@*}W-Pk(X3^L;f&E4=;o;>7O8zc7k?2zrf7(t0ysc zV@ah*5UsRmV7>2~QJ7`HU&Wh9XAR}DWBzV@&xryM@lpd@g4Uv;ryX}DUPzi$CZgld zUgqWMK=@D4h{%8Dk%&)g@nygg{-qo~+;f>q1FfHtEh~g;=AX*~f5Q}>LhNtC>la~` z9n)hgo3;^;Io7bfD;D~tR#;++czj;{(&PPImM7?`ei-N zf9$}1APe!XRwbw(y@<=dDieoizoBx5FlMS)!WrdCJTw@>{#a>91_jzJf+cQ{+YYH1 zZCnJ3tGvOptdx1bq7=%F@3TL4a=wD{_he2%p82hZQDl`>I#euG2CcApu#h_giV7LR z8iiw!yStL?I-N(=E%q{j3s&RJ>=cq2yPj4wiqNBbozQoUDt*#qhbn*8<9=L5XKc7kZ!T2H{~%giu1!Eg#PxjJvm;^SyB`c6-W>(y8I-UjCA zbXbPo8d9Xc0!L^+lSxeHa@~}ATbgRGUS}|7#kugJc<3|_bgoaOZW+zM+m_1Y7|Bwx zuv1htI3D}umFj#|h0spb3pGpXd8;fUuui|1ZvNl~rCsH8@5eIsbbCIWeR&k$7$UfC z=!Gxbe2?v~VqX?cqRHuBS-FBg>~5g~-n5wA=sx`?Px-1eXttb!fL=3NIxvGQJu=FF zVLL<{@2p2_J!f=0xSY(sA506n%5es*#;wvfh;g9;eq5YHKC5SuuuYQ0XW1t#ELekP z%~AOKyAX}8)+SG`hT~y(aSqL-N)~$c!jS9=_O87wOgq~Q6Z1Mqz~QHi`j|g1R+9r6 z-&)ALz6*PG^|0LSJ%4AHDjAbE$2H6Lp-kZ}Y}vG$F8w<|?A;!*Qu#aSs9g@bn9F9C zEa_$*>~yCRx1*WEhb=I!Ux|eLIRIN;&nNu3iuiK)6q7S~3XSgKxX#>;@}-71<>k*K zhjr%A=eIV(s@tJd&jUbzj{&#GcL!@n!{OGYLO%mLvV z;7kc99l^q|+%uw6;*4JFw}J1m$*?0X0seF-fMS^m-Q|4?@84I0S2HKnW<8aJxUesb zRLgvv#%RL|nVFVPu5#S3foW_G*A3TAyu!ZmsUp_$3+fd9)`3LoZT8Q#4&I4t(sn3%@IV6ZvZzxLza^UhDW1GZjUeo>j$r{@jhP zax{>J=FNt*9&a!)l_Z8%?W(z3_cv>eT(lR9lz`_=X(?#pF9A8 z`vkD!tSd|MXF#p=X}sV*OrHNc$EbKbA_ta#q@S}w;hK0fsNCabcn1!X(7p-$ofD=q zCO!;R9+ALl9sO*!{XSej_YGOC7f%1P_Q%wuWVjsih`aV?VwGnBcs4u3o0C6k* z`SvaG*5rH>C%sV0|0oE_+Qa3jEV63&6VCdSz&a~`AwA2@nKYdT%(nBhskHtPh*usY zhippugKyNqpz9WVuychK9nS1;S7*A^L;&w>&L9E295zlko!tMUM^)WZ(fYt?NWKz4 zj>cRj4;9|>>NSHnuWuPBe;Wh!O?4!exy7BIkCV?2vr%zwHhZ|l0JoQz!P~%fjEe0P zvfsfCaweqn$A6ok7U!`HQdk0+H)hZ+L!NMRY9z>vheJs72;7$2gq3;roa^;4BpSz1 zN4+Zc#9e{f@zMa+OYQ_{-ztEJJ`++~X9Q==JNfT+wllj^HE{nvX>4?UNIvA)VDe^F zth;|6dKR|h@5y59PX*3tQtU~zZGXewtcz@ov>v@J(+#bc9>JV+bK=y4#AP4C*8h%T zT;LgK?7w8TF=Y-EPEW%0Bb+BMa~^z9d%=odTY@Q&2s>)aSgEs;bb{ssvg+72962!^ z_lEnD?~AyfJ3|mBEF6Q17vG?|*%50?bcvs#8P*&MU>l0!AyH427W@l>Up2}!3rg84 z^HxUFF#>W7FA?R`Y<}5Z5h(q#64j4#nUZ!Ls+yM$DWHefs;VKhe-m^cKE-u3Lf}@p z1<8)cKw(D>`c&gIJ`w#6F3vY$Yhw`G%*N65hnGm#_etPT;7bjpx=4kM7S>xCa=m0F zyi+?H>%A?hVys_n)s%;viqs1{s_zqPMLjYoYl+?$7ozqW7P=L*iJayPdT5C2CEhp$ zU;peU5|M>WGIy_$xsgf^&aG$r=SZUcP7ykZ>k4|FN`y0~voJxw6V8r~K&OT|8y)M9 zs~=q>WKIVJ-`@{rnn!W>=WdX6-@)P-=L02KDoK0|vT=*x9a5{~bJl zF5g9%cWEI|)mP2fC%h*gwp2s+e;F_>JO-cdj%OaHKO~*&wv*%GhoJnySJHQ65_z(_ zfXo%U1zT5Lhp{~zhyUpU=n%LC9mWgjuF-4|s49Z}>d`1tr_B^=HZ!54BABt$kFI+& z8Dj#6dC^0|q~i26w0e4)?f(Bgaq21V`$Z%FwhXiSSr&6>M-e&bu#*NbVf2oUF!-LC zO)Z`ZV+2`A=g&%Fu1J-V$tz7!SHlUOO5eo0$AihYaZ2Y{cfizp0r2^0Hq(%O0c4Xq z8Qty;c;t~Qn(J|&Ur(me+G;;cd_57zR2E^y_l*qM(oGT!-a<>%H`Z0WhD5OyjOE~L zY}SZmlu|#DB`E?lrfL@P96Nw>?G9179oncUmr7jLbwRSm5F>O?pYwoD$JIUgl#J# z=z+RO-kttPs&)Dhz1GykcRL!7ljppFDlQkIIJ+HI%6G!gpn76(If96R9oB|Op zAN!Zv0ocacVc+Ql=C7_k5sdszLK_vxOYK6eEbxG+L&{8IEz6TIRG{6z?TI~?S)SgO zP7XZx!S0U|_(Mnnk4{$tgOEk^hW=C5^3HXz|0RRBM3%C8QcdjdTczZiTSjfFg8}@w z)=sP|PZBFdZSrtJ4w#*;A?fRF=%r&)u;T*fZ>^7mPc84cnGwPbTM_iJngZ7~(jn_} zAvBz8A=?8c<4S)mEEu@TpTRk&H&yxJJ=;*o#a1#Fn+pN4rnv0q94?b$$ntkyWp1W+ zkagv+$QONX2C@4(xwE>9yqLF+OqG&`kXRAiSSSp?w4^!K$$55>o;t*AJ3~VCr$Wxe zXFS=tsw8`6IFu-!##!Yv@wuH6ykM80_o@+?*Lt6ukzOGyyp}LW7r!F;Y#)T{7ZQ2x z&+L&<11f$>ikMzJ%Id~s5gYp_gjPg@Yw0??wDAI59==9mpRZusZ?-Xk9sA+^uV;|-_6AsTXNKwF zW27|1n|`R=kHyJiMAY;Y6jq3GUG-)3mQFj?EYQRL#avfcP>AeVDMB9>M&b((a}sP6 z!9VjSi0S*Siw)5I?*W9U5mv23F_UPf9HC8X@6G=z}nJ{OTFrKm&+m4=d1DM|y8 zy`_>A4bdRUc&>A3OGP^xG)PO@8gF^;Kf%Z6x$f&a=lA=5OG)r`p|CGX(vsO1;VYRy z+vkClQdf$J1uwAst#j3#c|C;%XAi)lQ`dN}mba2`7|AwRZf7~Ed34)1MOgW?N@Ogq zC4*a4blR$dd{2BqTfg2w&evF3Sx>65iiCsal}PJ{2w(b)B|VJ@dh7Q@7?io6HDq~_ zwoWyTX*-K*uOrFt(JJgctN<=A>_d~!j;jv5RLdIm7T~W-edwj4dR&vGu1iPbk_Nt^b01GjJkRWm(&^cA1vbS#N4T><8RymC zhTzeDl%rn?*3yv>ad0o@kDO1@6Lt!v)3Y%Db_V+)m&Hu~d9VpF--MkfUO`HHZ)Vqj zGS;11$d`EE$6NC&vFG!4x+Zs?f16$-%{y$&R%x!nm)T#rLVX?0l(Av=Ym!OzSqZAE ztPm{ali8hBqiL^Joz%s39C@amN&jy_xN-W3 z9%KsNBgs`eo#ew(NOPGUY20fTF6>wjigCN4K0N^a*Zl_D$=;L_XG~8Nmr`_}MKo}Z z9_zX?l2orA0P|bjRQBvMMl3U=?N@*WlosOBH3G3NZ}NasOm3DrUorIv?wEI&76;tJ zDf6Q7VbIyC*8|qFQJ);yrn(b+*q{EG?O6!%gGQ707z63@ds-N+T+Sg#3GPR9qQmVr z42_xz8$>o!RM1%bxM_$G=w8OPdVQ?gyrM7LoU@Sr^S=%9>Q|^h&WTkCKTxGMizVi+ zVP;LIh1Z9w@rjBKC0rDnZT~HTwGD%W`-{bEd10$?;*%eiyp_jZt$l1_t!4S`7yXHM zEeFq$_xb#B524jdWNO*l<3kmZK^%LKdQ6-M*0C#aqxT8HGR+sSzTGArJSK-Md#{)1 zp4m#}CS~Zl<18sdZ)I&;LBAWUScdgTrLJxIe1v zjb z9@A-4yfuYHsZp5bN1V3N8v4r)X31y!!X2G`csPCpWkfgNpmdQxW@(FyPA8%2`^Q+b zP!p=xOrkAi%W>;dUrgJe%|-`0@h#q)@n%ehkhgv-8oLb@yR05m=H|h2l3#+AR{-mG zX(9z})n+MrGvFP*29vH5zV!l!KD!3vPlw-vtz4ThB{&;KDA)7RAr1I-ixIssUBpI< zY*xLEv2?0a56wEyLx4pRrb*-J)bvFVx=N9@yxKzYV>P5Frk@t#EbLj6`)j-l`0*ATzkeJq?^y>efA{lhnG!z#fjw;uj%Mpe ztR~e93??bw#5Z~h5d3q6&>5^t@*M|3+I(6_u-BoX#|oLWQJlLyMx^CEXvOL_=+HGn zkhDV8QB%?wJBv+OzKSYde!zS2ryyW`Dt;KS5TE{zV6py&f}YDDsrKX?SgvQr6}<++ zYMuE)wU~z+s~~cXrzz2BR}I=1UWI>`sETJp3Au-ge)G~Ov`V$*f8?Cm7{#4z)Tnwc zer%{350v7uM*r?;{q4N7I($g;o^WD$EaOrLcnsexvNfPb!!hu)fvsQlnDH@8efmA3h8`{b3l-Ax{z3?GV)2Fvij(pr8f z(}jD_lu%AY8o%h%SBl()KAw=LkecV{(JlT~-h*|)8DQTIR>GKL8sy%m9+&1TkkSux zT&MLHww+O==JJjIn0Jj2-nJPF?)C*R9LjZV)F`!wv()ma3BsRdIJ4RiZq|vJ z#o8v`ABK@-@DSJ`@)m}4j6*kt9QIm#r}`G3BoED365GJ_l)T0fYL8@rlA9Nw`ZkPK zpF0j8M_Xdr`kNS;xfsvCJ4Q*cfK~_Z!wsvpWABZ`gD#GuAv3E5ZKrs_Zey{NX0UEY$3)G?%oXYo99^ zELSK!cdrZ9kJl!zJ{N?BN)vi+F&`V<`ru2a+qlm9Fxsh$+zGuO81}0ZN6)bYi=POS z@7#i|He*@TJ&_A7`v6v!2cv_b1?fD!%U^#fC7lSjvKaNRNKxe?EBN^&F@ zx_;q-LHA)~({>uznJ>s?#|Wx&8|iPYHp=WiOUvf}!gg&l$dis{l@F$}g;#<}=D%dB z7w2ws5>4nm-Ni#EN8)3ZsiY9o2Y+YGfED@CsPxta_fHgE;62YvI_~wNBUj=D^^Ynn z|M+3BRcz)(dt-3VZV8Te*~`hW3|CBn5j&2lrB74ZrgX7qB@9<9iR&)>ZhW(F=VAV%~wEYjG(iML?7%>KSyTst% z?5)symK`13xmd%U$~5UH8R(a+acu(~t}qRp+@^cT0Ve{u}o8`1}NC|`zM zi=|?uu{U{5+zyg{@4+GPo_L5Z7ol*cK-%Nkk8VdAyGxm5oi1Qa zk9SaU&WssWT;}Ijq~PC1eeMt2uyJ4*O*QX_=d#AI5!+QLLNOHEk7dAm&wBj5GZD65 zmd6QwZU~PCXu*D=NLad3jtbTf0^NaUAm+kq>V zIa$nG*bG;uq=GcHh+o=#6h^<91ZyAchu=Bfbh7yrv*~QLo@%K~8fsGf_-Q9?HR{C{ zK7PZmw>ZPMv$FL1v0EVO+GxaXnu_b#wp2wZ%vs z=~ABVn2Gw~Zz20g9i1NG!qh+5qQ{3v_(bd;XC5`9L?wIZI9C8=M-pN6a9cjBaW8h? z4#Kskda^XtE9iH|AH#BT`1VFS7=2j^TY4&zgWgiQbn67TPEf;%GL?W8espr{9<1~G zC@lDQ6CQ2-iKsH4T)YWm+i=uYb#)JV*HGTq*piI?a1--OMaL4`;g7 z+GO2Vg^hTW!GCm*r83r+Qfm>+N2KHRJQ25~CQ#C9bZ3TD7M!*S~LvZ}3GroHMf=51D$mTu1 zjPYX?S;O}#_};M`g?QMlKp1>0xd!Qh$Ws7IF- zp+zZ7zIYd(-dIQ-$KQcUbP5hzH;7$bUklpDTXB;27|IJQ#l6u-Q9krH;vij2P@XMl zEmUB|b5rq^$1b?>Z?N!Q+AcYyrobM>2Sdup2#DD*;-9m(n(dKCH)hirP~-T z+S~`@_ad@-M+n|j8^X*d8-c9N3jAEGLi*d(**?{i_-t@E6djLZut`hEXpf}n>)WyM z!9Sd>N`l@pUHqCS-fYun)6T|a>{WOwJnpMbw+sZjKSJzWg$;)+LX~WzF#qJIoAmmK|$1ah!%67a|2e7^kFKQks$9Gj4P7MA$hFIQT6dS?(n4R__0^J!mM8Ia{gcpOk$~y8H*m((H5du2*`IYr80Gd$(5mPS zaVb~n&V45kX2~hkbVL56!%(zD-Kf3wh7p6sKAHC_~MN~0h`7g&{=1o<8D(;FPIi5=I^sm7~ zw^;mA)|)it%d(RO`d_wC zy!IyM*LtyK?rnm`-3#!@I*Uq1d7;Ah6}b0fPr zC$9ElFYP9RzD+4^F%+3ihdzqD`W&>apU1ym>B2{qr!nW{HLU!wPRw_%hhme17vPkX z)Rjc8Ut%~~-4xij@GelhwtOQQZ-Nq98HiDgUU z(N9K;YqU&YlBtR4Y2rv#o*L4$+zg28H;hdxna}rZ+KX%69ssKk`s~E7(L5mEQ1Uxi zWOsILqRHiLIK?^@EZ56cE-*=gm9n4FY`Htcn8|>zc&90wuL{#9sIclMLn-=6C|*d2 z6`2O=B+hN~aXjZRg&LL94DODfO{Y(@V1{UJWGIt@OxSMOGNz#I=b0XEQxNxKqU-2Pt{vrha6z&nma=bD!|{eJY0|P@ocGgUoi0Tf z=V5@ScXxud!X8SS!QrS<4|tjqCUz5gP@2CP_4=9yH}ZQh=ZR*tAWFjP@04@XLJ9N= z>LGZgZsk=|?5KO(wyN5#O5Eb|Bz%#wM|dAJ02c?8itpXsLcN7G{rM+h9lw*{kKeE`TsQV%&h)osBTK$mvqq3>W1{+JwIGBCXVu&JSoa- zFx|7^tZG)K*pbtQMy1m*Wy^V7d{7Q%PAiV`A+H(FPkGp0D<1e&w#h({o_=_Ma z+3`%ey9pLOaHGN{&!9%e3zNs6femeM;LqPI7B$A77Zj^e#m#nH z=JObTSYH?3yi1T+SWJh_iw)pTk}T~tyoJ_#b5U!_DLmBv9YbB`h%U=LY)p5$;NW{7 z7yenq#>W?KgJ~TNAbsL zVgE;{p`VZaH_u_FY$rQAb~v2FUX&Hl2jsuVQ^1y8;PR;fouM zaT3&Wm6`X<-7vR11uZ2K`ji$)DK(RL$5m&_dM$S8pcq#i47Hxwsmpe6?7+tzZczJp zAAWFD!^|bB;OD+ys9mYU+Qtl{x6cVew}|gT>k1)a!zdbj(1*JS8!2AQpOH zlP3?PDFq%-HgXRA?UBScRA{iH<)`>NqgDts2w^f0AD~TP23;Dx5I!lFt&_>;y`;;d^8On!LHTz%>=-v>q1*m0ZOgk3{ES&Oy3xemsBrZVp+E7{xZM_eZ(p zc>JVvQJOs`f@u!3U=vCQpxd%bymsmmoIf`n^_MEZ#}PjG%fJb2{|578T}}9V=v}l= zD?xUCtu`?DO|J9%p#q;$a zmXr683ek7(#aLAm{SIEu77aDU;w#Fe@X8ADOg=^)JOjb=Cg8W9%9x^=LFUSXxMBM> zVZZThjQm-Ri|0Lp;=Z!Xd4wj*au3C}DTefNnJv3@r3UA{XK>8<6xV)#O?+$hV9_^Z zsY3UKw5MziEuQcf&iiSwUx!nm!AzYkfB!?;&*vA8u9}aD?dmK&rYCJN>(4Yc_|nTa zpRrG*n5%M9!Jp<+aJ$MFT$JzweqD^geFtvAj2b1tl0C{Y$Xyy#xw`ME>phef(J%fKPQJ(e|$g zEp|@CZ%dzpu7e3veZL=+^s0sY?0x9Irjq)lPNrcKqHxK@Pq13WlI1^*6U-)h@j$KX zSn%s0=1;wa>#MI-`P5#8=#jU=!!VG`4#VmJbJ(!pHMG?1 zlyr3IV?J%S*jo${&)0>=(OCT7R^3*nz3bM|+UOMO9Un;ddiY?oxq#{y^VqRXJ4OHA zC9G*Wgn8-3l>9swJT`|hpQ31_nU=zz@^sK^D#3yFYsg@~GsbGCV4-CV|8Aqo7YBut zW`lF(+>u(m*j%8|UN_Q=9P|M zAKX{`?&gb3n_YNpiZ`{dy#$_ne&M^18>P9bhjD#`4SrNOKt4lDCp|Z+#^|Vlv}9*C z(>)MFx6DK7X><_$da@7XJj`KwX*m8@y_Ampd?<1jCu8NQtAfV6zc_oqZC2jj8D3rU zK|L`SGS4Xohs@7_>8&~}?$r+r*HNREIve_u63E_YDpKaZOgyo06Dg*|VBo-uT-j$g z_BoM)zo(`$*Uq7|=<6l!t69NAN+0p#?rV`AeH7-eX~vwWlb~_hmR4U6VZl#k(WZwd zDBi*oS1DBCtPchFGscy#6wf&SNCnz4%?LV0*3iI3x%7IUBi-(tist$@tZSYeEBTkr zowlb_r=A9R$D3eSk1*UTp0#_eMzRx!wbaW)Vvnv}WJcdSX=PFX%XRHR8eU(yJ#WY6A@9L=hY7Q& z&66e%%SVm7Zo=lbe!{dbQY_r}5AUv9&q9u-3y%+GM)y0mKU?2>BB;C zH{K
    J?ZwOm8H15v{IO}m7%9x1Tzmpm&PA4ie4KZKVe>+pX2B03V4js+F@Qk~Et z6sT=Ohq_YPHZh-FX;&@zPN`tzeGKy^EazdRm+;4uowR$_09ZEi9^8HCO1qw~!#gUc z*|i)y9Cu3<%jRvu3C}%*`OVGz)ULPu``sue*YN?*zur%Z)xPX!sRF4iSj(ryRAPeG z8VoJk%4gc-;n;|H+F3COR5RsBoFdbseWqw%atJN!`?0~PC!|){F66RzFdlq20hGS4 zW*TB9?D_G3xXbhnZ?BVqi2IYo{Y3(-nP9|@=oHhc{}$lf#$Glze&^Dk>BbarJVpAw z^sf-Qz=QQ{{c8Q}07c}8_2Qyv+E z;j!H~_Q`Ovex8iZB9~-G{!W%^kWBM8T!atxjx2ghe_XtF6IF)n!R0-SS(8PzAF*m~x-i_rfMw(+Q~9GP^juU+ z*;!ibTH{dW)uS7Rtr=g{^h)d(-eH0H5Y@~XKRWY2VBy~+aN`sZ9UZs`H>I>S*_KLu8%w}L^hBy4gyirrB+S;fjQ zR<*GLF7-0Qvp*9du-uV}sxLlxV+3llNl-Cy1ddx4k0aL_!NfPfdV8&B7305wy1O!c z^NAERg5)VpHWR|^{-NyaV>mdx6-Vxfg7MX{cqw(A@ZU2#*zqnGi!V4*h51F?|GPK4 zYqp54^ogemZ?Pvhu0hiE>KWwuu0=PI0hpC{MtJ{y82t>Z|AD*|$^!xNhzP zjy#hqYwCg7yI6Q2&eO)sI>CPCT}Jm_P0*(+S^Bhdme66j9#vj1p)Ngnd=-9;|6SOd z-Fa@xZi)TuWAh1G3f`e?^AVgg8BR8^FiNaLhG(hkgPtn;H|R(>_V&wTW&m`eK;_K zW;{9%i=2!p8wox;?i6M$)q;cmD{1IES@aX{OEX&rv42hZICEh?Djb`E3okKdFJ`J` zMu)L}3r-*MPD0Iv2#$$7x*~8Jd_~ylDgha9XbpE0-wVZn-@^pQ1hVM}J zYq>pC2q0z)5}Dz4XFT_J9NN@Rpex3EQ0d4TTzO?O!-l@(r9TK>UXO&g_7}t)@;%hk z&Jqg0M#BMxCb%PC3%lYq!TRo6==I~903Rlzjmk^z{u*$WTLGTzn1~OnPKb<&6zY3F z6sP`Fq145)&~w8$c5>Y;umpIE0=2Ip>1 zg*v??Ru=JFXzoq~jZLvkJ1ia_G?syaPAQD|t;hnjM@sE`cZl1=5vkkhjCv#nt+V5_BfTo<*DX*XJ&@m01G6_+(UAgw|mt>NNw-q<-T*M&u%MRchgA(-fzS~ zK0{G^j0w$`Jxm&z!E`2B?8vS7EjmN4<7>}8U^H$#`To_VKkOra)V7}i*t`)gy-POBCw{tTdBU_=`u6wt$SJLX6~ z!-JY7v|3GxHm>@EZsH8)N$GElU)3u7I_!x@QhZ>_;}lji&Xk^s+5Jy_htmh6>tIm0 z9t-N;VyZ?XSt-32{G;Re+om-bw>=(}y`M7on+#4E2jWxz7}~HjA6u60gmV@f@Ih;x zWZO~;I(D-GHm)7P9^btLVIs3h`kEyUD!&c^A09wn+yfiE?onn7i@eN2{*n*3SA8op5UG-o>y%03~JU5)? z2qgoEclQ?EaQ;(CCsB0x?oYJdzSRVi?uU@t*mpSPNflEsIE-eYukhP|Z0fMHqus-I zW74|@{1o@J8z&AjW_`qYq3 z4C*a3drgAgUt~yS@o!imI)j-+t)fHwY^XG$ z6(5D(z)uO|*h0noc;DkF)fsIO*|s}b%;z*%viiKx>UWae%KE@(URWpHaM+tH&gFr< zLWS^Mc{t5D-dmVdt__NFlv&MQ9p+c9Pk*|Ppoxzzcgv0920?8?5;)`SePMWi*-DH% z5k|9~-;&0kipGe6nS8HqGHY>?VG3*P(QE$&VUO~es%@s8tf<#VygXKk?aH|%`DZi; zj}4i^l=j3?=#BKsRc#*RBUz7A74uo_s)M9D!zJ;ErM&iP#P?+j1!?oxF@v?=U4jxS}@ntY1ZbjrfEoCx7X=4*<0xtA3V9LI)5>WKNfe$+Qp!G;%| zz?a|L>6O|9X=(gbP*&#9ygxB7k@;TQ{^&hw^QLh4R;_;=R9sY zsw5;7?-I_XzQjw;ArSE{RVeyoK+CSb0H2SV6k{99YJ3yfVci%yV(^St%u}GU%MEzi zVkEtq=EkxrUO~jT`Aj_JnW5q&GVVKuT_5ToOdah?fz#{oh2t!|<$^rhJ>45pcTRy%3pH5ziyiE{@ocv8ZU^j> zTMAb8t}JWiQ_Q*^hO=%(Vv4;Im|U*JFO@~)V%&tj3q{UE>rf`sy9=aIKd@}jO1x}y z1XD`f?X7@|@U@DG=yyN>4iakOY-2TZD=G7;DQ~!E`n+maOe_ zAI2s3vH3CgH9l#GgpzAKIlh76%w6L0d=aZT{ivIs5?KE$05 zooB*wL(IAD%DWrW(Z6;MU41Ig!i`Sg!3~MvRBuWW|I@I{XCnJ2>}LB{=9A~TQ*5Mi zJh&Zb#hD#TsJ?auxr{N94$zhgc*d5ystusD--cjpAqS?7{zBxx8|eLIB7Hox0H1`~ zKuB2!rl&p-oKF->H>bMdZ%HktcZkZv?S|N9G7(y)`h%#a%|po6FTzFp2boqI2?&1{ag*4X!Z!peRe{cv#sct z?@u}6Z~xPc09N9o40lfLl{B7-L)GRyq5W?lYrAGf=bRqES*Kx)tvia2JNrY^qJh|O zwm+z@QKl(J4lq|+sda6`R!k18=GX4%OAQZ&q4KmL@Mq(B$osTM&_A{e^sc?Y@|S*G zJ)j9fKPFbW{2s(|2OYqR0pYmg?HQ1jkqX&&%&^<4nwr~;$pNPNJLd4dyGx(7!BY*tOs=KRkXW8=GUm zwuJaWvgoznGw&)36gz`OQ=g)1)ig=&v^L?L=pr~hCYlB(7jeZ)mUO^25^nkKhZ*_? zuvu|8-f~F8?E9Bt;?9MbmzxAPW@_P%CS|tCsth`RoRiKwmjiQM)4?-akKQ>e@-M3{ z^D{0j;yp_Iop>BX-=~$Z$cOo{A#10+LD-J!r&k!mOC z2&rRhq!8zV6|PD+&MiTj64wXHFYg7l>Gjb0pBw1&LolVY43a-w6`0tsKKE~saBO!j zln@{jjrqm}4AX`gWDGnTNUO;h~sz`wQQ;;Xl|}G!^VW7*llX zJz;uL0FzO!TgUFPMCUHczZ?6W-8Y3b)VMZj9zZyTc8C;H5|wN zi=5E8F$?D1EP>dhKX~%`4X*ao90R}iU`z65z-7dYcE7yYY&`w-WQqqAlp!PfFy7dD5Mvx`QSP{>uy&3)da1Sxzf&Xe&KgZz zd4hARt-HB%^b`IvRYur-e5vp-`7VaM^hHmw8Lj$JvmmwBD1}@az$EQ=vrSaqCB}xic`RbK< z{KS=Jetz6Q=+mBnFZaBVJkcQ`>GKRw*O$kNgBdvdZwR)V6rdngK<%IBu=cVN6z(_+ z51Yl^@uwWjyK8`>=JtVj`{($==e^L+FdS6>mg6dw$$06kEp7^X!Ylj^V8E!`{A=ia zpZBZ`ksm2Giy<)<~AQXOHPO>GhnwLC*e~cd+y*9 zjawc_AU|+B9iDa(WtQv$^>6KXUri2LKd%#+#}$0TGtm*{a|O@(ah#^~4=AieP*^@p zvc4h#OG^8qlJ;xKrNqPhOh8{ed`+A0Rk3ADzvQ4+0-^Jq%4+&rM zYN2Le3{I1|EntcbtZ;7S^S^%sg|y?6{XQr7ktvAoO%x)d-o;;RIr}m)6(s;PJ zt(+eSb>&wNX21o=#iuiK@TpoWMm`IN2Ae1zYj{qw+p;e%%bmz0H?Bj&#oc_*(|73c z_A!oMeu7`M4n~^29VhnJ7w>{kqVT@cH-#jf9<{Bwr*7w^eKXfMK%KKpKbX7L= zg&M_Yi)XZ_IRDU}4>H9W_{V1vc8ji9ld}_`E#|Fc|J|AR!0;QSW%^>`)^I#)vJEYd z*1_XX%OPZprBuQ6q406r0z49K%8GAD`CaK)XcDvjURp_T_?76E%^n~fkvM>gLdeEMHm32vuNqt8TzRA9q}GL5BM5t>l(Z za&Xn+9_;$6|KRANA>{sT6t1cn%f6i(1Ro-Az`gcrA^3R^R!td+M>YrWPPc2^LN^re zO)+8vMR!8y=8-t-k|7ozSPMth_wuTQRIGoz21{u;mThnbzZLPAr|_It>Xh@54ck%b zn<0#Hio%1343NKUmXJvY+DfbO;JD>b9{L!|zT5HRvl8*(zd|8V2A9sxvIy07Yzm4P3|LJj|z{wTD zRtDeK)!!98MCqek``HH8u@*qS7@^7}KVM_u->p(lHEryp6_QdnUlVTxInA_YCV}GjP*I zHFA9rAtv_AA=*+6YZqMsX;vnmvA+wizORJ?^GvZ-bsJ1IU4Yvh?D3zOGyDpC2D_Ek z2tnr=8fQM_FAl!q13m9!gXbu8o|6gucL=DGWt*Lm7JBErr z%Cm;FrN>QdGdjc_pUJX_;YN!gO};!x+6ZtUvQp_;xfzsGVaWcG_K7 zy!mv&u^ibZ%yfHta!I6{evHOLI3o5XMi@!SBz_K;hj0nswDq zD1T=O!S&l{)QwNlW5N@@#z-AL<@^O3F%P)^?IIl8Fq_`CX5pvjL9F57bm_cdeX%rn zIsV?%pZ3KDklhR?bZ}0C?ssF!P@#h>j&R2KzWGp?GX)b3zVclpvLj&0#E-%(pET63EJt(m z|Db+LZ+dAESCzB5IEO~*;PHuc@KFkmDLKxDQi`Q|sd8Q9u{+1|c8dRX*y8~|s`-EpZH{rT#4^dR3=^BIidX0nB%@2JC-(VMv{u(I{C z6gU0DSwRkT!}G3W>h~Qu(kup?N+(0e(udr7WI2EEt%)lOQfx?`Km|cLuVPSLk97;3GLb(&S`CiSz!h=^1AgX1#MKq(PqY}EK)q*{(CNt~Lnpm*; z3YRnfCrk`7q7$i3@Hbosg9=;tbYEHWuWsZDkq2>Mye2ki{6QVZeHh!-n|(jl%zeuT zQ$Rb%!K2)HNm2{UHOSx>`^JEOz#C!Sk#D#t@dQpj(*>5lAND-S$hwETpEG};U<`0U&84N@_(m{xZTDQ)J^C7rGdy12|NM?NsYj?r{aGr zd_XmOBCKqz6tv}y(b0J>KG2MoXwUTnhpYuK4>E;6@Jtwn#yG}xAbCec0CnXGQ*Pgc z-*Kt@>N`!LJ4=Q=&>tjS8vm3Jb63X_Up2wGbpzV$w`QetblDJbF8ld~DGf6Z$LeH1 zAz*+M%hU$b>I2#Q=4UT_WxbcJ?A4zqDai6sK~$AuGMYjTx1#2nL%3DX52jt{Pi^x~ zgYvK(o;ZI5?Kb-?Q7^K^AvJHr{l-nY>>fwgR}N&`ME~HlpW1>feBjS!u7(j|u{ius z7(1}C7iDM5(*o~_-12uA*8aOCWPLT|E&T_vOQrWf*0KSY=Uk?s4-2s^ZUWRVW$ePe zd-$B6t9m`Q5~}=@DMlnSFLPLg50~`e4ma=P%Dc@xP&*d4{+Wy>d1KM`aUQHvmSL9? z@-WL}Fsa^qjXdip$eygTE_peczny2qUcH$G@$TC6xyKtkc_fK#^SFm2ba!&kt8GHX z9c?lXH)nFEKSSuz58QjU*v0z$Q<$@PHfp5BaZmRqxKPy2FUc3f8ucD@XQMnl>@sE+ zt((}$w5Jl8RVTq*btZW!9uY)Z5Z_k1jM_HWNcZ$;6Q2FtjtlQ9@mMo+R^zI|zV$f; z8!A8I85<2cw@rhM_|Z?eG}sX9!k1xizu}bVwHqDubKueeMac!Pz1Y7n2~-wFRJkAB zLK*Un;N80id)*i(O>x=IrzM;b7OBo+J1pWs&czXH*QN@MH#_m+fyH!O>@&h{1KhJn zg>o9*$lIb;%uJ+$?tfZ%ysv@qrr4QT{OQ1(d&=O0$n{vfaXIg@Y($@+yKv_4T(l}J zfJxHhnBo(I3v>^_o1e*GYrlkD*(l{xpSDPk8@$CjwOq0c3dKV|&hiJ2ZY-oO8|{jd zsT^T|VG447Cq!P2ZBf_dN{ z9P=rfO_BYCNsqi?ME@4t?e-eJ9&%^$w=(b}`@u`rw}QUwby#iv6j!DeL#cZ&rZ6ai zvfUO4k>@8*?mInWREEwNz^=8hs4<)8s zErSKDOO&dQ-$*Gg`4lK~0hg|o5xot0=sSB4_GvmVEZQNSo5kDUhFfK2kKl>qA5ti2 zPZrEpQmUx-ItIi*vy^ zL5>11$y4bYCt*$R6`-ss6>_#a;ZrAFo~^J~dS~Phocm-6C8@j9fXW9Fj|>^gOF73j zYpmz1B4S~-v<7CFf5CgBMzi?pi*!DCk@dRP@95M()Y9H5WNw(lxOM_etQimEym#Un zFVWq+W539@8&Vz1rtw$9HsaI%eZb@2OK?j_rXY%>)-mD#Q*@qzRK9N;J0segl=N#!ONxfJO(82%Hc8nk3Fo;kibN!7p|mu#v`f?f`M>eftNWaD-`Dm1 zem=PV(;1Q9CL=0IO~)w7=?Ko-m`(9^csSFSefPGc?-vF#-1HXWHB8t`|M@KGZ7;7L z!;5m~#j-_{Vn}_M=1$O^PC|QDST#a)DF4GaU!E=fgAwJ>WL#kyojhSFP6* zI1K{%beRm=DBi%jzZPV&VgznF*CjTvHKo6Kop52q2X5RdH5OnSjaJI(P_Uu^FIU#_ z8_x&9`Ej{$be}!m@kv3a^-uBY@H(y__b-~TP^>;bj@1b}jiqY~ai-E>(p&b6*LpjE zD&G2vGkWy!`>`5WQnUs?UJ-ccBewFIe;?op+z;Q5ETQAWZa~PqK#;7RLTRIqU~j`v z_A6lk9uT}OhqR?|abzIB%sLbmh5X-zsg~3(8OUt3lo>w>(4B<)L*NYZcrUOirfXAL zfUsYv65|?E;y23GlXS{`_Mur zSQGY$yP#K#I|bH2QuT$ZdbKk+)T^79@08>Kq4bleT7Gh-sHXj#e?AJ_>`bDKf>%mA)? z${bJ;-YP@#R#_YW_{r@Q`n!j?m*6%#6!wqJ5&sxA8@`>h!Gdr1`S`0xK>Pa-ti5AK zlKT(fw=3ni>bfO=K}wP?Ip|_imkcdDs6rDBF2P0Pxol;7J?Oqq#sR)(;dQMKKF>Cx z%}UczwZsjJC+@-T$!1l5YI1Oez9kKfy@(mfO4u3P!8gs>h@rR(AAAr)-3(LgH+sk) z7P7-(=R>*YpI(UG7#-)!YaijIlVR{kXE5Ko|0X!^lShB29R5nea+tN}BAyEwA+Tcx zk>;IOkT7f~jkx2E%uEux4xWXIC1bFB(j@-(bwl#flfXNQ^>|D1EEYG)fsV2sXHc33 zv$`ihltP@q6ivd?DjilpVWOrGHF7`!Ls4*cxvLEQQmtcUAj&wV=@>wFCp zdMAIxkjw3ymH0P*>{T$t#FfE> zu>1VK?Ox0&(VfeTtU$E|nOyIaCf;bTJ-hwR0Mn|Zu{F+~S{_HUv3I<=^OG)c4(4TO ze}4$%#H7>hLxJGAbtRYwOR(~m&tURRMevF)r4K81P^5npelFBt1M@FpdiHZ%EkBTc z1Xmu5y+YpW0 zi_U?f&u4TVRgc>i>EXTVcbrr68@_A8Q@$bW8p_W=bnUjU@_um$Sk4u^{jwI$R4;=5 z_;OJi8B*d_d2${%kj}T2;B$Tx*;l67+~vN)xeHQEN#Jt59)1~jFV`Y2xo10Pg6 z5(;b~zdP~JmA?%Pg*=}BKt_o{MSOHw3cO0G!p$G^;m*-5Fm7KbZ@%d>4)cg)8#a9f zCCOTDd6_a+4JSi4F034IaCY%;pVT;xG!qy z5Lcl|p0XT`8hs9qDa)|=!(LPkuIu4e_k83{L-u2r;KT{7t`&d%e1!g+aYl4%?HdRR zZH5U?&ZEZwAtUnu;jeA3==q%c==MB|>xo|o66tzOd-N8rF)kmzDqIwD(^nzTQj^wc z_u|HLo>XmF3dK4i+9g|nkDAsA4n)8)!_M(JeF1oXL6*&zYDJteVm3`jIlA}6NcavD zJnxS#VgRjUkDV{Vv8V3%L~KGCOT&PB{*@v?BP3XOR$|7mgcyO~`*x8sWj7e6Hrp!>P+aC^rJ*nCfimC6guln<-P)58hghW*E$ zy3XTzCM#9H9+Cv(Pbso8>&G}MJ&c<<*-MOl&$tpz38pM`q2Hb@=C?f1$4{@vp^DjO zj1sSB)eoI0dSL=Q-IPMEkN=~So8vfJb8ArQY{2M03X~Upfj)n<#&L}F~VmlB;Q_T3)`zP@E-uxTdSu~do9m=~uZ@bi+-n~2-_sVjwC)6aI3h)RWo>Z8Fq_;2aO8uaA4JG z9{UJ)We8c(uxz(@aUNbl#EJcd{{Cb;D*th2z@xvT!r6Li}A$4(bd0?Nz%EU)-*X`KKo%C zkNp~V;Y;E~nstQd;`a^kRtse_erMp{Ez0CY2z=u z)V|1LkM}54?;``YzjP=Ky7!JNeQZl>cDz6-rIlQ9l#j3jo65XYTH%Le8u-TT256KgTUAHddYf z5yN<|1N`ex?rgiLKs<1rA2xbKvm-HH6xEi==2hqmocQy6zIO{AX-a1oqZ?o*9A&jH z+hAkeX8{GNWg;Gq7Wd936=47&gPslj$E$r4cs>&AL1m)`6jKtrm48npV_1g2%1+~f z#{=o@{;7EV%{ba`cNHgBNR!I-DtIty2b$e=;-$N)@MqFl2x$L?PV)xSskdXuZmd0X ztXW0!g%en`1>=MD%F)Go8A%FG*1zWl;jsyM(7!Z;cU`VRch^PnchV96ZQp^mqRS$S zL$!QX{V=R=+>8ldN`-#U5wZURQ{1b29)qif!iRnfiV)4lBvXd>60<3D*gjY;_XVek zt>CPs5-QiJvYY7>=}oEyUTBr7GT*d-9m?Fo;=Oax<=%W47@Ub`V=O7Gz>*tzz7Oqp z1mnCTD&nSVxoF=V!A4QQvLZNgAc+PMx2+kXp8{XnXiosJsI z_4r5YR^!xv*Dx|g0q?KTVf>A6sA^FO4l(8|Fg}^nTe}kai)Czfn@W;o>@k61KY|T8 zs=>VxW~dVBS0R`#bDok1SWvnW^tYO^Kd=X6z8R2sqZvC`ejTgdBb+-S$2FRyg4~a6 zD9E@iy0`iom%3{T#GRVQOHX==D>jX!pARO|-s)uW=f2lC{oN<-_@z1?7d4|*y$)V) zO~%Oba%`ED!Pj=&gOXuxsTFG`9z@MA(!8GSb$H(R z3a>Azh1YkxxG#I{Ksr$HQ#YT&W{I&h^jQJupI(A9hCPOY0>C?3|6$_`A>WzQk9u|u zqIaV$Fx$Y09FHvE%bKmZ=rsq)R^tw5-W$#9s*+Qx z@Dbv)al+G=IN|zk?uPs#ZdklGlV4he%BS*Se)$$|ao;s4nwZJIzU_}G&3$Ov_Y=CV z{Dj<}@^q^<-^MOyFH_9Ff_(Tmd~oh3s`f00qjMj?yO*o@-rjYn@iZHfo!|3=8+6h2 zM+oed;8;Sh1mEi$0%bod`FjHSa%`zDJ>2R^X@66=$`(T!{@sUDxO@#wWVC5=@-7Uz zn1HbX-_csG2CZzT)Ah7PBv-!zH!rEfHTSi6%QHn>(~4`)dGj#Wbc?WmEf$_oMy7D8eDhNQk?bhtu_ZI4XQ$*?|vatQC1N<;afX#=xxoh5A zao+P(imw&ijbEy`Mzu0%s(;HbTGhyT3S3O_{aOqw3&fAYyzi>zDQ?8C(P(|~F8}Py zMovc0myI-f#+5m@py5D*yXxs^Jzx}-M-9RZyK6Kx<(F7}y*#}hRES?gfLu;Y;kWe6 zL8j`?+l{M5%i08ZAN>!Lmp-E_R@rDWBguNklMS?8uAH_Xu7p*CTJT-!WZL`q4X3i* znq0j}VB>4kA4@m7J*Eqr-8`!3!FyafB24J*Y0j}{G}Y9N=Lx};jO;m_(e8SB@xTYL-SQ4$V1`-NVIpU_z}2w+DyjAm6uWf(();E zUsoOv>~v+>Pk-X5mUQvQtR3{@$rDuGc!XV-X-A2kI5JpaLUYC#aB&AlP|CIzbcjsn z`Cl`@OPB|)U$TP+8STUW*3HK2D?32{q!HOX_h!zoIN%{dde#)@`?J!oOu$wMVuw--kk5c2(*Q8dk43-M_%j~;m zWH>*MtNo?{h0Z*^+jIqYEa~Ou?eS)LE(W4+eH>2HTaFGxx8ai^R_u{vJZJV);1HC| z!82)lt!@63B(470Y_47vyW~Hd6i&&qhG!1^pBFh)e&7J@(|4!K_z{fP+EMR_49IXT z=gP7|$*?6XvpE5`>YgR1 zp%d9?T1xpF+qlV()(OI~CxX|OIGL&*&~B0<8N-Ww!-S`_QsGur@t^zH=o`Y5Q!;wN zbPD=!2~7~(K(#{m)bM*DXXg5xO9X}LbHNWFY-b>N)DgK43J1#v#&~!ALe{w|6{V&L z9a`hvRG&ut`!ib8wBCeMKuaCI|0&C+Ul_%@l$2=Eh|6?8<^=Bc4Fso{4V+$p7F|E( z2dm2FlWIUDOpPuVYi+(rAGQH|l2!@NmL{{dm&SP8DPFh@;RS zZt<~1uKjm7?W)cO&M^TLKLn6?i8>pkRl!cPOx8W+2IU@l33izkxM%-OdhsL^WPQtN zi|`&RUC{+&#!7IB)(`0T;$XhIU^3W_UCXAx1!n4ZAFfUr#^RptC9|nAEOe_LWmng* z5`n4tr#2TX>QzzU%mTJGO=;TcSCg4X;aR5Iu#q06+(b)<+vK)4o{V&Kaq80!nz2le zNxs~S^;7@Sx#pu(F{Zd`PWT&)8Jx((b5*PN<7DUDtpI|weZ+OM4#Aa~ayeyp&-1R;e)!B1VKNvK1;G;=`>$j~4rhK@D zsTaFp>#11LS9TNJ_PhjB>3;E&@G+#CWkP>U8%0(Yig><1h7_vkw-y$b~k2`le!bFpNJH`_TPj)w(WEJ*bSZ{_I5{7p8}>jl7Y;dj~J~)|%3G#Z0oUZxeluU?s#TGEQ+tU_^~rv@AQN0+Vm?vC5%yv4Br9S}cK5oa%bEFPfT2Y!D) z;irIg;HIsP26iRQ+f&hmUAR|(&0z7`LedTfALTN$2A<*rt(?sTtceQd8$%i z&b4*I994@A*~&5V9Y!EBF2Q30yIbea3JhJBggr(+bYQj^e?ThNXnYtR(N26Pxd2BN zPlYF%gU~eLGiZ$=nEx^k7bu01w5;IRm>h)@pSg)D3@xeAFox51yv_{A^+4qKE24w( zXZfoN^CfYBvx+`Z6N>^^!Dx?&E&z8A7|dZilcS!2Q*x2Uq8t6I_1 z@+NL=0;qYKfQk*XA@Q>wJKTF;U?D3`o(X9=)pHL)ZFBUSn?r_S~|< z7J&uV?0$~#F?@qlVlofE|83h60=`A-d0=F3tHO z9yVeWb?|2}FnJT!E{VnsDMF85W-*07cjxzQ(^eqq2bzVv_voqHAxQ7Y?TTUHcqS~!Tt-&lx+WgBpgfdq-B>ER{iL>yg|2AX$c zAVSYtmbd6zmDnOa407gjS4%^e5ue z!Umf1N=N8HpMj~9{_pg@i9??@iWj(VX1PNhK~`{=rXG(6laEQFHq;TnTDqM0+=ZM` z=0w_vsa&?eGJ4<`2>)(<<)xf=!@f%$V(l!!d80OqY?qzo&V^)ywfX}r`fnmiJXuVG zoOvEz4q$u6rs6XdSF-Z~Di`kHZR2KuMtTy?UpJr|eyOGO_uK=q@ zN;IVSs#rBzg%6uLiA{hk(U`<-WdFSl#%Niv3(Kom)S$zB8$S`Xd;ekTxB%uQHIQQe zone_@w5Vsy4q92diB4WwO~1aW(zo$PxrIN(-0GWysqmu;F7h~ycYStaZ|yutPW_BJ zuKD=N^bUj_8^YYeN}%(G9j(_8omv zJ&HJz-wPA=N`v*~XV|8t$sR2W0=1i^*c-fxThSXNsx7uA=RtvNriKz$m{?Ft#c8TH zd(T~ec$IehyymKlkK?|n?pXTNll($@A?5LLKF75OJ61Sj!bDkeyk!Nq`cAMSs}m&3 zPXQahcib3rC8m%uNi-?d78U<8fpKkt5m%|IBHNHPw#Sf7iWPHnilF14lPIsH80TJD z#x4rZfmIf)BbfUX zb6RWH0_N&%^i}5@2Aa%Zxib>5BJKcl^!)%EN*{A>Np38Ab_;el?BQDWXE3*cidfJ+ ziM06a#w*aQOzs2lJ_i$g(5_awRQE0x)^LBSV z*s(xpC_wXdy_Rxj(|YtmF(MRUFeBc zV5;wg?`ZGS+|KvHeE#PH^55!#s#4|Dvv@NN4ZaB74@#Mo!ety0HR8g_4z59Q|UhFOi$ zRCh3*78EjpJLoBNtaVUwxF+e}hyk_tHQc%5Kr>XFNy%E^XoQ^Pw{CpFZk2?QWZOwn z4tC|+6~#U#3J)#bWQ$a{T!g|et#}IE*+>IB>KODK8-lL!mFHHm z5$6}7BQ&s0xe>f=XE?KZk;F~}s^Y(w^;myv5x+ZHy?TX>H0e!u7hV7H2Xr<5u`6y5 zsUT(zYxaAF1!-O6vwo_;-Eb$5-4?LOCY_WYxS(qMcJ@0^1imN5Ohxh{Hv4?ZjB_XY+W{#H>6XofHRvP;HP=Y>`3b_3J|#ZeQq5#Ilpu{e#K|F?>m#L zOB11KgeCK^MVREA$PGNPheC=&nCztg==S9*F0)`UToPu%+x0&|w0!`BiUi_p$HVXX z046cE4t$)YX_|&P4ex$V72h^c`k-5)0QoH1?>L^cwSb1OD}VGS014DkNePR>2bDrj3dlG zZN&`U$g^>oB{Whlk5Us9@L7o>t()LX)^^(#?Tr>P6xP<(~Xx^67P_Slt*M-@|jTboX#SwmDg9q9b z@tAHsj;=Q@1I>vE^x@u6(5O^pXSTJ#fWB;;??o88K!ToB*kf;ZEDcz`2TYqfQR_}2 z?C+b75lbVHvNCZ|Yym&PIzjX+>m;ALV-fTS9v0bfHF)>?Fu9&c!gZ5n$UJN;r>JMi z9g2y?1#`2hM)1uvzB+~%=h?ER*@Gxw(LvY+DU;<<3+|rH9lp!5fY<-yz`viQ!I!OH zh{X$*bH$GX#edd*f*)VIV8eya;ExWZ)v8FjubtW3$x-0nyB%!8%yDmnEz{km3^OWy z(BW2~=xZl1ho3^1J0=ixTspX$2?-F}a0LJ8UqO?@L3lg%Bx=9bq{?Y~VfD6P$lO1U z)r<;<1z|=kNPh&msOTc_iga7|BDXfe-DY2j6SKObNEO38$ylj}yK+7b+fz;1$Q^U| z@54mkXW0h{JGVpE*PU3~eGVrMlmU%@(GXE{6wC*N!A6f+*fDtw-yVJfUzEzw(;2n+ znt8%v770J+_|qQ=$; zyJFb6&|=t*Qsf|8i!zekyuOP%rJs-h&++E$<2+Lkt-jBB8cpYntBgq7a0&k5+Nv7H zeE?sNF7DtNIm!vqC7GyLUfHtM1|k@8AQ^bu##Y?{)ZlFNEB_5yP-KpWxc> zHu0vATy#I4i!jUquN&S#Np*RaJA4k5sXs#dk+h zK;u3|c1g>HM%cu&Z%sFl3}@q}M-Q;SIS>RnBwG_Z2IQ6uMd{i)-X<$ST*9XbO$jX2lI9xVWkYvnFmtztBQ{YxsV)J$WTB zHaUw=Stq;;D}af@y~_5T;CVP_O{(7W>FU>5@d*8P-c5MNEPB`tGSAgf$_w$#jwb$I ztvwVKn$k=2M68)I7BZadxLsqasVL+bs0~wLZVKt(JAX2!bpC?56341aoMlj=;1#F1 zwGl40AIG|bv(VtZ!0c;^fEcStc+$NO)K7JBH>cWCL$34xcVL{~>J6Aw9mz`z9;Ou= zukg@m2fkw`LE)zhkR{Bb2i%gUD1~vDS$Y6wBqxCUiE!9;X*DgmtbvREJVT?C^WnN= zlxWnm9M~)LKHmi8!j7(cc&cd|G<=={J0G;sXww{wyL|-fYdbJm_Y&s(w}`LEPeJ2< zl5DJ9JU$Lh!YQ$YbQqxuluPFejOCwqS3?C5qPtCkT5q8;o;S0R5l3lR*EC`4!7vuMqZs2-g2h*^ za%%ECRBg$}|AKzvRFxq1BJdRDYL=kt_0?2*Z8B!~CWuE`Wr{t7+}f`Vj@-vxoOozJ zI%;u}Sp7X&bop}-j32ohRSZk<;QK*r@mdKCUEL$NY!%pI6?JxL>S%Pa98AsaPEf3W z56f-dqv3XWEE45nUcMEk9i0g84z|GB@Atq*V3&m5&4c)&WAuFXDAXJ_0VC@rao$HE zv9od_8|HkD+vR*0-RA?_Cv_Y6!X|uK9!y5&x8RgQA%C;x8?F~Aq0G2La4>u;FZZ5k z^dEOlV$nz#=(mY9MHA@v`Uvio%3022StKhATLfc`lE_cTMSP(^fg0N#X=U9dn6k8x zx6e3@nTZ6_GaYDoL|&zItu*`lu>=1;N~W{o|5)bgbJXsmLXOMUFw@DZtlV%14EWUn z$tNtZX4x0euQ9KvBVbcTBvE6E=Btb*E{^N><1@b!e9aK673OIA4#W=ESv z^9BA^(Al^6DRDS!9A&{BA2yA=)mKw*)ozw4tIx8HtnPyWyDz@uW4!0Jg6jq|P z!wB-L6qptFk_B!8a$D`vFmFc`-?Gh#&j<)&!N(7i(XlnuY*@sCMb5(6xPULyt!+Yg zKF5R0-r>8tb4<2-53K>7wiulyz3p?cJh>Dt*BE0;^i0n8#%gde5||U!fAN#09PNM1 zQAC{y`RX3P;Sr0tOXCkw&ie_dm~s~q%>;zB8lg|y6s8?=fO^7$ae(I|UZMTFz?*5t z!>4Xwy2(7O`kSyD(B69%KSr!Jgxx*>GCW?GZ(>S(%JbRL@R zN3rI%H>j^6uoHXkgQ>9Zm0F`jH;bInQ%Zu1nZA%6)B1uMqi2CmS|_^KjVDpkAqv`& z2{DfZ-n?dqD8w<1nG1IgmuqS`<;XxbJnAVlq^PsKa~9y&YhU ztH5)UY)mvRq>K+`%=~#hz52R?3dZ+|7rOJpY^x6Q21T*H9Y(@C!k@O=Pl62}XTu3? zRTkNN9p1iuNGk=mX~OX-pwc6B%XMo|FIo#8w#B2tjb$)FM}``I9Kn>$syJg;9_INU z7mYp@0=#X{rlqfBDD1bu!@X{iC>kfm1!sr;J~6^D3XX zwg#Qgc3{ENP;Q#wNIzTs9X}-OfD;KnFeDean|a4kira_t>uR{J92w9~kOE_HW)tSs z;R?}os9tiM7P^{|g_H}tzPf+~q#DCb**Nt4>`3Ng-SP0&ZthUV5S)HlFnbLnZg=u9 zdTfw|FCQpU(0=3>zZ(X{M;!3O`fRLl= z5zSrvS7n_q^b4MO)`QGYMNBno5)G^Qz;92N<1NI+qP-9D_(@Y{+e8KiQA5U6zP2u( z6WLoK6Ijyz@AjgV_C;*mKNxOqa>BPSM^%~@hv7V5J#l{tgIf{PXu6yigtvZyfMasl zBlH7Jo~85C3ZCNFBd<{NqApbRC@IX-*R_`7%h{ z=5h@hqhrD8);u`Hr@)Zmb9kM`C^R{#$41}?$nNn%@6Qw1ndN&h(4Y|KJhDJLuVrk9 znkGtaspIc-N5Z}xAzVhFI?M}H;XQk8=vT*5B>7gj<&r2mkvWKY3i-qb({IBa5pyF(Ux1R!A)s%8+!ohle)5Y#_*HR` z9;}Pyj+dUoL*q@@$)p)@z9L+J&WN&z=^|I{6EStiR3uXo zJe{)Gnd=aRUoRp5P}u^cL6x z!N3K~ zIj1rI6fZR@g{)(m;YayZczV?nudSFv8E4c;*T0o>z4?SYeob4nbmec{Y*qrhx4BS& zkik;%v1aiO^B_0FkIo;rfO^kroVlW!Pu(EGGQo!%tgD5Y|BWDrt>;0m_a4qU@KN0G zPn*pWFe1KxuJV%AniStJaL1+s-E%mEJ1aEUE`i6Yrre8OAHH&ryas^xmSi0D#RKxc zmO`uTFkE)BU*M+;{LqoZ;bO)Ms+Jvzi=SPEj#-oN_4HP9^1Q`q+_=tJ=#8Z1?L`zK z6ALmiTI>}+6B=D?amJ-5cw^!kD)VfBsKG9j^4}xa`*S|bsF{T8C&@68gDOT;C8Ml^ zD_h?(jB+HN;mMh4TwUsJbnPL!aP}h}=(^0S8;qkXcOOCg&J#Gi zA_Rx683IXjv!Nl#m^pos5=$Jo3TCC_xk~+6q<%!`?+1s7D+G6*Mc_!9>$i%npK$}7 z_5|RJruSHI+ZG1D8X~IiUkA&SWVxQV2{QLP2I*Rq>=+f|{ zTKe={oz7UU5HDI9!hN4TK-hQL@KZ$}p<}&zwbsZ2EYO`qZ7mzf>XSCjaSBBDiYDP+ zP=N+>M^jJ#2~1C4%(9=v;q;G;8~Bm&7UhI*^sl1bJ|4_8YBB2PQ2fwcip`?&U^V>` zh~*l1MPqYzd=A>|{`d+E#i_J+npE|E%_hth4WUswMfh!24B4mn)6b`dIB$wX^%B)( zlyH0{g*fSvvtA_gTJw^P+aAvy+h2orPr|_WTLEZmhSA%{t=zxt zc~-hG4o62JwvsnnXQRr@WmmH-utMp>!R+Udfh_LdS-jTePFg=-;HNTaTKznO0t5RX z;B6|ltZU%pUUH;%IE%b&CX?EH8RmK#=;7ONs(JB(w`sk{62`v5-CF{2U&%2d$(iB< z0%z}=p>NzR^7?uf{q;`pH)@(Vafc7q4+}t_ zvUD8aa+h~Yl;+F!Hu3wS594ofm(7%s69gXqJnqydA?L9eIctS;D6zU7XWjmYhQ_Jf z;<)QH`{8l=yuKCp-IhRo_$HRuf5wmA24FZPmi7G@&DQ!UW1IO27$zqHgOA43`qmy) z!+)ZsWpmlWp>kMI)Mit%LY^)xxd2N-Hh^(Z7ONOz1-_-=gJ0)P}s50Kc)*A+x)kX1z{NbQ--Hf*$I+m57gD;-2vlW{Hlv&=_j})a}1z*f!p-%oJjf~CaAL>qJm0ES! zpB9BG6GLH^;P~<$^oeg=Eh})9JfPqWaM#U3k>iT+#atcQkdX%Lb_Ol6(x81a-g3&{ z9`GaWBSF?fjS8oS<0A9Be9NOYAwhN)#E&n*$TMnU?aD@2hJvfbOp)0*)be?j*ZJ>1 z*I{VI9lm^9CuTm*!n*$!;{B7eNTdD+nxyxOmJYp*CVyS2V}BtyJZj_2VveE5+6$n1 zW*M*F`$v2_+YS@In+fNA0DtgM6ZpND3$kEDqDXa;e;QhKIKrQoaWo>0b5cA%NN}4Q zq>0L8_rRv#5jIZV0jTl!yGULwTHJs3i%9#_8F;4g2>OQa#{uU9$Ts{K?`su@{_oRp z)yIoC%waf<+53{{{2E?fVIQu&b`%gEcQ;clZb_J!^~^ zTW{mTXY<(k6L}abxDOBYKZYS2_rOfP8s!?MF#VV5oZ`SpcGuG#?lqqg2Nj;e@^ob( zQ+^tkIdSM%W6i2y4D|i9Sro7z;j<1Ba6G zc;`jV7=3vGg{&)rC!Luv!{Zm0AGIZ$Z2}GT!Uk5|o=-}iQmi~I5XT4n#i7GoaeSx& z9B_{!*}X<2Jw=m!I5CkKeQ$z(^BFK&{u6q)xd^cs4n* zO6tHTTqv+VKTb}7A73Vs-^NZ}M(F=Rs!051ofr4)q&>|WCpZIY4aqGn5q1b&S-Xzy z^sc8B^rda9Zm*T#?K~q<^dK2lYd%ENUDvo#SL&dDwF@^3Hknu^NoyG}g6(MxC_Tg{+{r6+NHQWQqFYVy^> ztm@5Ae{sV1iPTzdz$}(-#=rimn38jt`?X)-Kj*me3T55A;#>pP=z2r^VeDj->RJTz z#ans#=VNhfcs5DZOO+&J?h)>||Wlx1vL?YN2b+VeJC zG0G9>(SlYhP0|oaEIfxiL{`W8T8aLyU z>>%7?D?z(k1F5)wA|y;phOQ^WXjP0M`8KQ;WYgu}D8g81l8sm11;95Q#a#fe_pvKN7KH#G*_!$H<_05Ic_(R_)VYegx8C!|F z=BrcbjtaqP^akq}PJ)v3P&VY8ET_A15Xl{MXOUJ}l(gzFpL=f=Poi>^T;7e|KA(6W zEl;+8kBwL!1l~_36RRD+ATE>h#1wtu|LX1Mw-3+ZLvjYO5lftLg3AjWt=T0$Hn|;q zFGcX)JCg(xMUALwb2$3j8{tNww=v4l4JKT(fwYVxu(L&!{M^Vy7XZ-qW@HlR_zA^+#wc&2fv9LwIf^VhHS@g+HR_}E5ZE9!D=%-wll z%zuJ^Lc{LMPEX7bT%b4F!||ivO?dc2!HI+Sa|h!C%YS1AzE9X#J_X|epUKo zac9qGI-K)?uWcwLHyzqg?;$e_FZku?+qH8$9_!-_yO5zq*weqI(&(lhQ z$H9O9MnfN6W#@vQV|nFr{I%SK^>yFDao)3qF4aSk`j48W&N zcVV?@8<%;Wyar8_x^cY5o5(vAFiO-`XsEhIff_w<2XadC-CTw3$$LD$?h+?hRZk4 zWNxqB>C~J;nx>G z0#%nVX&-^jcefPFJx8<6W~123fEn04rJU^xy;7CsJA!u1(#FCWx9GmsOxFAIC8u`H zjeJ)4(YNN_&mr z)kF!p9Z`tKs|V5A0o(CN&j32RAdU6^7e}F9o7iZzzd${#aKJ9LKo@bd~kKQ#<5S?hvR&p6_@$zi(R4_J9Oksp0ZicUoMpnBt9 z9GF+WE;}yRip&dcO6D{@8o!`zJ9zqUl|XNm899zH{eOV&;0Sh-Y{-*5vfTu zL9p~$@v0L-Kg%!=d?t9Z1WbUjHGxoG=T5mS4EJZ}bAq;kmw&gDJ96p?+_yZ=lJ4Ed zJSlbLF3qF?if40{38BVh{IVX0;73fw)n!7-@Nl_b+Df=hN4B^d08nR z^y>P~>FrZys}Gpquanm??z$zs@UP|{6nZel*G1rIepYnjz7_iHBAR*Q9o+wJ!t^d} zCa&)!oj5W8&&*X~e#UEPkhKq+vq=?nwydWh?+xhnWk2)q98No*WV2)8Gll-63$EK+ z!TD&fqj~2pQVx@(j}l&xwRFfjF-!hReVKYpsG?(!vb8G5(^50U-PfOrQvnolTf)?@UU+@##vnW0(p<@Xx6DA zG}1f^`-4M)>umt(qwj&GR?ve@nUH26qD|u$qIK*kQ1R-ivI~@?>C>K~?Bi}|`T80* zeK|ocQmgo?#9ua>nleDuWgxrVd4x6k8;N2ZAHWn%3rfAKO)J{7xD(TNF{!lm7#r0J zLn5vM4pOG+@mKMNu`0d}8N{4Ts+jKlXso+2iwt(B(w2gFdKbS=6!7v6-)OAI7Gx}k zsO{lZdv0CFC%cy8v#cmq(BX$Oy=18OrVMM@EQ5}JMzgepi)el}m|L}KA}HAh(8G9N zI7rg$w6!E&7}igFUT>j}hm6gh7D0xkne0?^JGkx6 zMomSky(|kGWv%F6^b0&+{0*uMBVp-`dpO`xCiLbAoEoZvmR)MX-!$wz8px&5Jv=Sk z4?jr#1J^Zk=&p1*oDgz}|EYG7|M$I=H%o%1TXmy(4Z`pjKqAL8uwM2xxYU+f@7WeY zr~eFKzT9xCJ-?FiEgCH6hapYgR12Z~GJHvPBMjbMC-T(Z$r69b;)L%b;aA@pG})F% zqo!qH+ky{Oui6%}rp34ML3|^8xBG@>>VDj`f&S>Rs+AA)u1AA3NsjBii=(ecK|^9X zn;Nql&V_~0m@fwGO++bo#4nmL_a2xCSE2lBqxk66Zq8BvFq#@0p{V6Bg{t|pWeEzf zZqaAX?_W09rkatQn;qS+%VdeO#*21t^kL6uj;GF+8{$Ck%iO-$+jul?8GlDw3*VUx z+;@zGHIJLJAjK+R~4SD%6Aax7+Wld$O*$E)tbX5G|nLBs%k`%^?8pKsM zrjUQtb?8d|1`GCh^CfROaavUv`Pr=kb7du}UMBq3s|COxjT3yt{4r>9WAFbcI`4n3 z-ZzemWE3HlvQmf;Ns9NmZWU1>m9%Kkq#-RWSy>S>%Ls*#(vZsgT$hFlNz(eLM5Td< zh9-T__aE@Xalg;w+}HJbJ)eA}q69TaUJ-pM~RmhjdGzVrQ+t7)p7!2dd1 z4$=uzQ0e6sDm9!U{H+7@ealPSUT6SAvS+i-v}dB;xDI}$3UEb>Eosy#XPUjhli$=c zk{R7nrYmcW1!vB9!P}F{o_N&aOA`q?ck&4Tb#^`_HlGFW!vU=PlP1$wIt>yX=lO7- zD`fG?h$=cAXv)Ev+>$SL?0eP;nn#ltQuc@A)N- zar9v}^r-JPZ_-|YEjv!2E9b|#*RJF@zd3@J zS_MX9YLeB?HCu7Uwael^eZG9p@j!akJdo^Xyufy~`828d6^yKn$Cwlu+6hJNPL6PaH5T8R42Wd;2|^MkhQ# zZ*_wH7aO@b9mNnU^FVlKo3MoqBLvr27G6=D0)NX+@e`iQ^7YdMHeq9QxmZFIa(*wR z&w?fB>D71;y}m+E#zymc{kdQwD>$n&Gw`I!PjG&2j@vXkU~bYGyt4Qn$P2vPjltKU zDMksqZ*)P7<8|!TkS7=?%*_+)!T;fFe&tXp_9^fa9!|)lOY#~_wImAOofVwkL+qh* z@L$2(cN!8SeYihsMG2Tr4L5HETb2^yX!g}duSF- ziSfam<7C)m>ZA<$J0-- z)+eKw-_004sx+1^zdMdcL+i1q2kLmt_x_1eDw@(kR z;C>Q21wV!5Y&ZT!qVSCOR6+a}Ez)pOqT_pBL2^zJH10eE7pD>a-Wf^fCk>;uJ?8wu zj$za#(N2;=>o81rEnPla4=T$OxEfO@?#Y!2*y&m!bUh#N@|G&pFy$M4`KLo+euvn@ zH6?T+_ar`^9wa{TXecY6@`Ae3KEq0>WZpxV%auP?pqmRgde=Rl2Hr5F_U9TbefwnQ z^(&f^ewkx}Of@|PDJ)G^V}B!e^U~{H@DB$JWTkq0>7M^)ibqL-)1gnw#oItuDh<3o zw&1Jf0_P~fin};x6TP<)(=fXR(zdH4ok%ZofP8OB`!|y@pLeL}VFgFtxiQmCCKXnfvG&bj`IruJ| zipL*e#;rIqDJ+ABlO|DNNF7$ytB87Z9LZt!Wqz4#Ap3EXQ32P)Jsvd=N6VE^&duBC z{$@Ek2<#}sW>+{Ul3|VMEtI7?iG_um@-zy7t-&Ms)US0cr*;^*{cXU$B}Od% z@$iZZdm7Oi{&41h_pm1J2`Hz;QMz}Fc*(PaxK`GNERWid`;ITLu&NTawJ(E3jSINY ztE!yAb>cmL&u68#j+3ZB1^VZ|gSv%JA!%y?`dLP^zq^B}?dTiG@b*Qya^xN?y~Cae zZ1A0<3~5+fB>S=EA!`VT!H;#bAPjUxx3nGUQ@}lL*$@P$aN&0{VxhH%*s|iPuPuA& zs>`-b|Ati=_i0a-JG*Q44ogz+l5dA3?Jl>XGn^|^*@FCk%kb%uGu*1p&h$OtA7t(iXBBg{(x}sd5A;zR4qJ_=W$ny66qTU; zv~z6u38}{}eq^cglYcN1kU@^$B7p9Wz*vxlib-R}jU7to$5xHpb zHjhS5?Z$-WQ1V@@$X;pR#)ZWVB(?WH_}cs%Zw3fVH;F}japz^|(!YgYH{1bJxl?fC zOD*wZvvJ(&{ zDQiBQgE8G>@N30p_CnxMvLejH&?O>RqM7<^P*1L>mx8d#*d&SmvU+LY9+y$H3eEs)$q)&DtJ@60}t3P z0CkgpT*MkZifssk`5yzo=JzjrFffR7xmm{^rvio-X5o}$L~pZXnErNkn06=+Tvwif z<6Bz!{acQ5TdTG}>Z59|&RMua-Lk>xwx?L1bBfPV?S^(CKatty$<~ENp_|%3daF4B zzQu7gXl)Ub$_{6uH%TmJmMwGCF6U3zZ=s(ag}6*Pj_sOs4DJl|Vv?4hDdnX)OIa=r zJ^zN%sZ&J1uf)^5m4$pxMHTb%sKYkFWzjx;1*=)qk8^M;txHD!noXm)qJAe_a9shi zwYD%dsgQOjHE_3g2a8H1j4Au19z8N01V+6=rYb&yx*VUeUk#a}lxLB2R==G;Ut-C2 zq)w#1<_9>+$emrliD>0pFEDwoi|_mhW;gOYX!|8=;hprK*ecsx6sF)SWXVtQdHM5T z#8(m-ap1)IGGH;6_&tci@{{Qs24joon}8S4rLoR{DYmgU93N+ggP@% z!+$%xa9D;b?Na}ZhZg7J_UmQj`RNsW*&4;v8UsncW)xM&&tR*Ix^Q}?2y90>qIQZJ z40#tVFnpcREM5b1Dl_rGe>J$kuO6qqI7GX)xzLb`%~&~~r^XyB%niweXNn;>J4*-W z?LCL_&XLsbHJv85`O)8VBUthdL#DGVkE$&c=+UDPro7n(XPD2R?3RJ}_R%SJ^s2eF z=MzcLinxSM1Gm$|!XThWO?=UM!AHJAi>d+^Q;FdLzTWH_ep{u&kN+$OdXX#GqmzjO z|1kq6_)n&oG=bHh>Cb#hXR-$}&zRu^G3&T_l^g4nBx>5Wij>ZIuxDGlG49I*W-i@8 zANR|#O-Vvm@NNt0(O^2)X-7%tLSe=Q6CV>$g%F2sA&<7jQ>1v=?rMMJMdm*_n4*@v#95D>*)W%re2uW5XA7O4=j;=fbpvj({zbM`p;06 zB1Wb`RN4)AB7Fzt-Vf*2-EP68xZ>)mR=Hi+B5eV^NJMdwjQmd+{h5^BW>*B}`{Y zH`=f`B@#ICW1O|{G{xKWl2=zIoBlTqBEP?e4mZTTD;d52Va~cQcY=;xGYph`2#Y)Y z1ixb+>FQ?K+rki8J{Ut}p;=CP68@<976GKY+zK8bLS7De}D~t zC}~zW&DU+Wu0I?}S~q{;jz62|x4Y2!epU_@%RFe*ERLlbO(Tz*`@DOo8#{Pv5qN)h zqG3<7(RGSIsCMe2lQEb1B8N+qBq=y~E>^Oq^DV4#y3K1Jen<)Z z7wE_Gb*xTHnNn>c86A7i_3fH3bc1v%Cf+#*P>|20G6(V5K6glSoeH`n3}98d0q|!> zKWAJO!v33tXxFY%ad=ZATuk3YUh9l`?wuTce07_j*?oun0v14&eFwb0c82pPQp6|b zLE@jM4B5!Om1I_(z`b?c!}mnI#(ln$G~8L64L3KVDfx0X)9z`qzHeixwW|hay_-%& zg?$*;oQ7W;%4o{3!`OBCiol?(q2{QyV&!HX_GAzbCV6p8XK)K?OpC(4Vl^t%*iXSN zE&Pig(P)``3n#kv(VQ1DFy;&5y4)>ne(LZ9l$=$I|jg_{dA?=b-xHcD})SGIO83g>HIE zQ|==fwo`cugk(#j!{3p(?9Dn}>4+oyxP3d^GA@Ir6XV%vyGHK!hQahJssQJOx56sz zHg0r?HpHGUgQW>)v3hzr_G>irKU;?4O3O}O&vy$~TAYUS-21sNCV;$CmRNK>70hGy zf>Gr=+&Ahl`JNaBU5Ozmu8YJs2g0!D$z}GVbraqmE>AnYDX?)l;Ve)tfu0o!dEXRM zkQZ`tZ*2?V)d+pm&mBlZo+qICTs?@4UPB4n?}D~}E zcr{}XYv@!Xlb9lAY_gUf%BP~WqXHzGM}nb{-??(A0e5Pl_2SS}NS-In_Gu=O#*}W{ z!S9CHV;@#H-${w zre}_r5Ts1I1>VZarWfehav6*Jl~|wGErD^c76u%T=dG-dVa%#A0te8QVtoaE)y6qA za;-AvSEi9k&rE3k_lNhM?@zUs!|0bm9^`adP{A#K($SOPzsxv?kyWB zcu>EFIli4(4Q0+R;Q8N6 z=#dl$3*ry5Rb!g5LRyMVKk^xw#c!Oi{2bHoMBwq`@z}CVjy+b|hg)_TupRz?F?xOm zioVt1kGOZBxY?8~_YQ;BfFR~Ecq=)g9rJx_z?%xBca{1bGI1|Nu)L2zVr@{ynN072{i|^s95m6IR^uN$MemfKJsSXjud~U9m5RVafX&XJ8<(B zTJJi>S;Tw6L4~u>8XHNsD>T{Pi;DDnxePNjILW>L9fiT+JNVT;;ohWAiP!#cvy~#bx5K`0Lwt|&yN3({{f}BS?WrZ(I%7CiQzw#=c`1Y!b~)*6ah(t1Rpd zL;U5I3IhY)aiGd40<(OUjIB;9Li!{#-;vH-SM~O=km-izW;~(XVH(eWn(ym$% zx$!!6>NkS2&O`WCnoM2;bLrKI;cVTTw-`J#6VAtO6i*G`z+M^8=La;Z-~iKnrZ^`K z_P)ByTgkiN(m*R(xV{!X?37_L%hzzN6AoZcfga9TFq4;vOrZCc3e+J(+=QVPXc;n) zou9iA-kiP54{r?N&F;tI-$*AG-Y|~>U+>3NzmJ3Ze+^uRx;^>tX~9kJ?bw8^MmQs_ z6hBQBxB?p|vkzIP@t)l{u0-V=XfzJSvGD`oc|ajwIeKY%;^&Lpzs3Ey%i53?EltBc z@t-jghoN+@sZgd7-qDvjQElaMW>|g|{%OnL?D9g<9hqiSZkYjACv&-I^+L|z$5NcO zwG=j2DYKU${%|l!9VT2phM&@;*~|F@`4`7snDfdqUY)jxC~!CYY21Job9uZp{x&}S z63t)#>cVdxlK~miHnOj$zVrW$(Pe$R%^_ri4IZsDhL}HpAf>Ptw72DA(tyD%c4{pC z3pxyL!*|nrr<42~8x`#A5}1m+yvQWy5NV{QqRH|$UMu(yh7_ft>e?zuJD$R6O!5#o zy7#$rH%jQt?>zYLc`?>MJq#uPo}&A24fdK-6U{HQM;S9iI&mTr-siQTw{AZSh*qX! z&Q;tM&M~+%AeBz#Yy0TPrbCg>lN9woon3cQ)`(YD+o+TIg37i3L9ygzM&l^ey zZJk-?s5ah4>luFiVM2w?LQZPPC5#TwpghZOuyFll_;=(G1!vU?-YZ$E^^RjX)5pO! z>r!sUT`Qb8^_n>0^LJ=e8bQl^^SH!>B*ASU+}GpRfcd(wFy=}xH~N!5H#9<-wVa8= zo7bOXmXM2^dv`Bx|&aJO$l4Ob=b zSUcfQ|2^2TSC^e!8-&M`?x2T6Fy;IhOMkn&xjV1&z)`VI)X@12Gwrn~-DLvTC0l_( zW&<&0yDzN}zEPe@Rf4sHIXiW0IE@ng8+p0|!EpW&@psE>qPelfq6Mou_%h>@pc-h- z4GUdBr>^P<-vSx@z{Cjp{i~5oO5|w&j`z6bKf#mg6DpFv_!N{z_kvN&4*V>#W@k@0 zfKA3b7&Gj(sC{M#`7Arn)FS)c9~q_vurxsvl4aw%wTd`{_zRhMzd^z{q$(wOPD=! zFpFOD0Pc$CLrIxEUTVJ#t{;?`>8x;8I$$KI9Qg_o2R@6cxBUl0XYHe?x3k%yXh}A! ze*pb^Ajf76^`z8$`ZWH82e=o!<;~vM(+$BdBFVK|`9?0q1cQrEe6b7zR$oCG4_%fj zJ)EtP6FN;Lb=-dIR6Lo3yn>7`Sl%1Kvd0FIh_e^+3l?H=u~O5&CKBEb3Q1>83| zk@V{ZB4sL*xn&Q?DYoFUlj<}!trl`Je{z2Xexuj+kN9Ke4Qzi@g~x(|VB^@&<;#TG zak1zTSAIYN_l9PY+U58BsC~;tDD+*uOIDM?Y-Jc|Fp6FT+(6~!fug}OZvlgiA<#B6u3iT@ZXIX?(+dklyefG?~?lIJglL4*eY2t^MiZCKJ z7*F@;qh{7n_)u~SlF}~1aQ|HEH|!Vp$So7%JN83Pu0JT{1VI0oD1KDL9T@fH8seR0 zuwsjl<9&FBzdSV%dt|lfoVyvzKWGFG|1^W$J_&3Vy0^bJgpx_+PRPx9$8X$gi&abN zu>>UXg~wH*(V@I?wa};Y*Jrb*mf?h`wb-&Xg_RV1;tH=9;^*m|7;rurL+_fA`G{~_ zqqht-2dmH}6^r!{?eneRGpIYuO>JnRJog>uN{# zF74v-mp`CR(h+B@lVCo!$6&VA71$t-g2;}8{Kn`kEKaTC0^*BByDry3#@}Q-?jgkz zg>Q}2Bf4CKx-69rxQ&YhMxa{PU2gr%GpM_(oWHhF2~+xpLiW1>lqwg)P(xBS1^>SP))3J(pGrqDT3WcgzZHjm83gN7Y|_sjWR z$&$pon~>E?8`|w&%&92Z;M-nF7$y(!wrMy&YL_#1$}7>tPHFCD)@y#I!CU_0?)Ti} zt^xRLPdprK8B3Qns<>LMaE$0*MGI^4!Q4w=o@Fo=dvYDt&%TM_(_~0f+MAoRc`!|= zYsagyGwD@C8=UCRpcHt`Uo+Ih^0KqwQgnxZ@@^uYxinkc^eKz~7S+!?$iKz#_gXaX z-Cb-nJS$FVnn?R?Mp59U7;97U7;v3Jnb?>ino|9!MdTl63 z`ftJ3<)=7q+f|%2){*uZE#fD*tK+rBNzm|15eN0&hJ?A%pqE_^Z~Y{2eVMU%YkL+x zG@OF{HqQLabGk70c?Xwb`49he#0Xv5`(mx(R`}Ucjo!SQz~6OsB=4PyH1(>T_>92q zw4LEeH7iV6`{#F}*#c+k?@|G0y7M64p{~iE=>_peH;ke`)832Al%`@(r7iz&`gh*f z_!;l9or7B%!i-Wog8wSx4b$dia5q#w;--BZUWv@-CyjT(C5N;iMQJuAZ<+_bT4UL; zEAGs4hzPG-t`og1sKGS{0zo#S9u;5x<#nX|_+V>Y_I~9vJ}^d%bB6|ldH82sVxNfT z7w6#Y(oeknwkYn+l4qEwFG;4(YP8AX9;zMN#XG!q#F;mxxq}|+{GVEZW4Lw}>D;@( zKi3MPDZS|^mv#d5LhZ_n`F;ou8b&=nvta$uK@{Q@NJlrBGf%yR>}yRKm{zS9ewQZr zPO%#EW)8-{Cu`}v<#z3dl2`z=xPbQcK`TPCruEyFLnq%GpD3?6wi`w=xHwJ$nTs z4ja+J&+$0#&rY^?-dX}&jirwFgxvZV=Gpv) zJ7yTqC;z;Oa=SC(Qgk*M-H&HhEw?y*$!ykUau^M6f5#%DILdNd0M-j1ij~!Z(BH|E zRBQyE)pJSIEPjE;t+OfemnADd?ap@JN}{71^K$Fo#9XQ0ksfophROZ31p6AY=M~poh!`~ISELS27+0i*JF!^#U#y?F1 z_c5L{G%cE+Se1${!Eyp$^9g-@^BohT6mf`qh*)Vx1dSASj%H(~q4KkSe&&o|e4lB_ z&E}G6NoXMDn_WV4rx7&TTawuJwc4+W=LRuVRwxA8;JH0-Hwe6|m%nG(_VZPD!^C*%$66PhsAE z{FDpT+;k+Z2hLRAI33fKE`aO@XXYyO+)UJiv2|}MJJb;j)HnbKE}c&e`)eU@LASX2 z?H81v9)nM^a$)X-9Pt~qXZ(=b8+aaU*`=e4SlVhimctw%aNH(Z|Ku4Ldhj>jwc#+$ z|Fw#p5Hh~|QtL(cA6Rk&uBqa#xY10yQkX5-8lc-?2T&iUM!EV@I5_PFHoGOj_5LI> zkX(f~rN81oLvLEXB9z~^I++vCBKG=gd-;jg3TXJfo^MU+#NhSUA;xkLYg^XL^F#jN2!0MAU}Um2K(p<(C6Yb4$YSlS}Y$dOcJv%mj;A9eV5%M&2&d>5Y** zYYaaGiV^PDD*av2T;xcvd^}KBw3>}J&*Pr${KAh*3&9b#@t|oQ2U*!lq?*K7*=Y+( zu-t&baXz59#Rtq3blDW2lN>#CB0J3z*e>u}v*-Df;yE)~FgG5*3YosQuC3hM@@Qfw z8!Q}|jZaqJvh>T{N6^$!ccQ+qhOpJfKaUrff*lwF+mVk=yGDuMU$ z^rPt`Ug5f$4Iq1^5~KZ{sA1V>kyP7scC+q`bs61;@RX-wyYDO6j~%z5#WNm@tEZk=Yv5=G2>`}8c z#kR@7bcHx*TQ-PP_Lwp)wcEUL`~mKK;tOcna10__C(#AXYJBXpn~f11blqA%@#qw3 zx_eO!I}aX*`D4bS?*39Zpe})fOI~5Ft`^!ai{NK%563LeiSRkvn?l~~6WBjr!6uI| zc))A!VEk1)H{B49xsPNM2K?rk%_FYwk`8tD-^Ev})R?oEF%`}=pz9m_#D8L*@?xnL zep2WJzUYfH-N`zJS+X9ib74Gh-+mH<)MDUaS1NX;&I9NCGCcBjD3^xsz)viP6`JXs z<0mWZeVq?qx?9D6T^o68(+^NvbrJeoQc*fb3R zZtEV2FEy*sh+#uuidzHLY_k`^+g4GO(JD6RydxY}SOejw?_==zNbb<539RnElrUrO zgQ>yqu(b6Tzq-awv~@)r?sY7K?(eI~bG#Y--2END_1Vzb)Z4=T@hO~^x5O`7(=q9; z;EC;)Vp6BvX!SiMuC_P}q*^X=_Vus%8CG5pw$2x<*9>5mOC;%YABu+mB~bqA2-AYj z;th@UU^iZsjoclHCPzv*E^a?}r>2NIwnW&&K8a&%AGE--Ez{_#`d;2;R2Ek5XobKV zIpFKF8zR0Ar4qAJQLWEbNH^f<^YJjDi*o|{XRTrZLO((J^L?v&O(QZokb>8H*Rfca zB>eLELHTtVefG?6EsWEY;0`TF#5VnlT(;3UPJ3<$DjS{0Nt@@>e~QB??(qxsOOM9R zl_xQzL7M$E>=h;F8PhxIr(E@7O?;IgXZ`Bw1ZupYfi@%LDcjuuUY3cuwxjV}ZlxC} zG?@$Dz2y*?$Uw#86%PIvk6NP_K>o!FDA3g4HcywL{htEx+#Fzoir#XCODtLCdKKn6 zzLVQM@GYOe(vUS-F2k4VSNJi4<0Zo~fm>D~37b~kgtKjHanjHm{Giu>flD0FecBB; z@vsk8$L@j=mQOhwv(R$m_D3-2i3R-7R^(m#t3W+N7A~Bb%LZo_;;O9qEURA+Z@sjF z;HahI{1rV2ueC9zb`@=f32ga5ZG0#tIR9?UV3Pe3Y|puwbaJYYIaR)a<25Fb%~)^d zySoP;I(afz??q&EssnbdSW91IV&T@qt7x?4}CuB_9Xd z9-30ym&xGSG=j;zd%uFo~VB^}zJGv7#@w8(HoPYo>4W7Nf16;^eAM z@Q~a|iyD4_=GP+9w>ix64X4pICmohBypW{B5t5&t$NiHtt(pULX}i5J>+_c6?&E0A zYS~({u!+Js=L*0+dy^<`Wgwfc9?IhP4yTaeS^TxfN#q@4z#qs|gg1?1J8njKig*BaD*UZ(pIvWZ<`pMM`(sU8x96aOp1Z)Y7=+`~=FpC! zUf35g1MY6tXGgMyOxmj~XgD5$zXBwv$=Hapire^>)g73t+K5lROSn%ujE)!tqgH%6 z`o$OG(3&D%S;)z?$H#${z#XYNyqw*pA2ZX3k!RO8E~NehjP|d_)6L4vY5^}E z>Zd>rY&WlCY=ROl-(Y0eNhp1LN~HKunZC`KM0x=`S)$WTlwDsXED?S{8y zo7j}vL^gZdM7-i=E$o#X*eNS%+Ph%`tuyDjjp0@BEa?#4mcGk_)K*FkSdCY!H}DC^ z=5n(FR&jHL9DDxqHPq)64EMScVC240u>6TCBuspUuO}D@p~xz}D#wCG7Yw5@vI6(x zZ!>OtI{+eLFg^23f;JQE7Yld+FXpkP>!;iH~6fv zV_Au)KrB&+WR`is`H1KOL#b8-*DBe@J~Qw1}2e$59i?7SbOh>weZZhi-! z9A)8atPdA)m6m*rKHYlDk7;6Te=N

    Bse2$mmyNI#s*yr`r&^THG)G9iYMt^ZtRnb^~;oT%e|V z1L?z<&-`xAjK3&&a=0cVvA zr=svsexAx=TyQ%T_WKL&-v9K`u;KuYx-yyVEhyvccOJx!ZLKicJDBCRNP?xTIa&RV zV_^%@5!PyWkGx*VY&I4#o$8JD7Cm1>U$;2jW&-1J&{{oc;L;w_S3ewVCs17Nrqwy(iKZ zH7~k=WkDGG8CS%;H=0a)?6fdYaBZ55KLQUN$M8PdDrDVjj0UaIT=#YdX!3o7DGS)KDb=EFRuh@y`2FZ%drYK0r3?RA%5zpbBG~#IN8T~C2!(4N zx``&1zr4JVB|d#8n$={=^|kTb=^Og!JM0s;?qv^t#FhAd=RVx9bR3)6-V8_2J%PB6 zG%yIzWMSWQxVHu}u+!x;_h6wKosbu@UaBwn^sC7%cFG=bS*Q%R_8cz1STadm2`zB> z{10Bk%aV!4{KoseinLF*79I`i;$95chA?{=`_F1HZoeJL?`hn^Z=5Pm_rotzh3NqH z(C{&QTyKg=er3Y^FO24&Md7YdBKX7|m`=tXZe8hZKGZ@uTTx2*fb(G|)(gk!dYkyB zlRA4hatq8lo5&sbmC0$VsL~@<2hi77;vWPL6#R>AT>RsmnCpC;S3ma~&a6ETH9hh$ zd0#L87YBYeCbg@{@XNQJ|2I~T7R`== zz2hn|W5@+)b+6~e{jKmw*jq(hO2;F%(!$-b8`7@obF1C`U}k$J*Sv5cw{31adI`_U zrtvAr4OGP3F_x@vb`8YT7QxHzLhj7i0Z_=bSljlgQh`DY=lx4?PJB{eD}TzN>w-u) z5b+$m*+dpQcK}{D42J%N`eLoHdA?hm}$RtW;!G^hK_`o}9%x3yRy4g|1 z8SGumw(D8YlWBq6tbe1q(8ZTobwVsRLS%v`&z;3}^6G?I{+LvLgA0n3q!?W@E-LQ| zT$z6bvWMtV@P7^1JJyFwA9WfgnNOs>pLOBwmkTt1ZUjEp-@-CtcQYt5V+YMw2oA-? z{N1w$s3hts^HWL3-2%T|rN98L{Bh-5JaW17ubug{ebV&IFOunfl|ge(j(ab-*XQ5L z<^~BFQOBEez<7c&r25=uO6!Yoa&RCQU=)KJpWfpAZpzUC&k%OIyNx#{Y?k;|PbBK@iumh6`=i!jt zXf`pq1M_y(q3p-u>}}LG)_?sW&R3WOHd4y8Hq8xln1vvr|RzjPBvnHpnvPVT<8N!6H;rpDJ{} zAA?JF0uJui;5H1<#c4q?;z%RH%Gy#I6eoP=pBq3%hHKc5*eE{rP!`_3dJ#WK_e175 zJDS;|!r;b5{)d^6Kk6vKe>)0zlLLWtHN=ozi=8RVop6g!UgG+@@=Btt`kUHKAC;9B;YGiZaN$pk9-V^q?qsA0Hbabdc#|B=U zddj`On}U-UN5dxfM9ivug*51k*g9`Lg|CtXtAQGLM*9jR84bp`Pu;LTelzX19?F6a z--RIl6PnlxT@$N0AYJ#DTmMt&E=E+L`cc61L9orwC@J8??2 zGXH(`FFxZkaGv{}u-d*Hmdo|w$;;=#|4WB;;G(I_>Dpl4(%BP~O4h;wz@< zCrhf;$t-JatN8g!dHhqD#O5D91aX&l!QvIJr25>9PU^fazc)yec6>d7hwE**fA_v{ zbzw8uhyG%2FF%_CTP@klIWjEvMlL>17IRj|yfMgc5P$959*F*YQIt1v73ca)onG75 zifrWbxr$aqrS0col3}&bo#2>Z**co~S&hY~{e@hmZ)l~pi7rk&BM$zQ0X7jG{12ae zIH5BPBw{Ae9*+(N7AEhq<` zp6$Vr((9Q-tR4C{g~Lgev#`J`8vivVLA7O{c>Sv%xcdD;oF;o5Q?m8wp6L&~#4PAn z_7y(qe4==h%^Oqf5N#S9wFN0xuUo~imwzetnG7r3%h5s~r~MCrLdM7c98$R+I=Hddwc$+}-fkpCT~b)ALNw>PkLM^pKy&Dyk#8B?+R8NN`Z zjf)%bm|wHW5}qe+g?Eoq7`Yxt4~WBhRZkQx9*Xg*R%}*U8V>HLrZ>Mt*tXvYmI|J| zS6YKG(S8gi-spoRpQf%>EnddTmaV*!a@EgI24tJ2(eUYQe315B zf$5gYT|e{_b;FGDNbEG;`br`ek~nlJU=(wx7eWN5u;lA`U~gu?cFCu6 z+g8N!t@FI$a^gf%_}B#}jv!>fA+|_~@r@~`@m9lMkUcV#;tPa(d#*9NdMKWgUjGLF z)+NGn`&?XgBb$HzGf&`59^{*PXFykf6_yROVp%IL;K7Os=r{Zz_-V{!;qgkS@oOr& zWp`sI|4%H~QD~+70`}6-2PbDp(YRYO>{MGC{7YQ{UlP-qe(49akzy=h%2r;6CZWE1 zHyTfUib?jm)N^n)t&ULQS1j(tQS+?HA#vxmO9^GLNNmAgbv420KwUEOogm`cdA>T_ z6z3fZ#)IQc=xJXroDRAH!(C6Xn-R@$PUzf!z#(+XM2kwtIk5Uag*Zw#KKiNAH~~WC&+NWU3uqWtq^s-;k4u2m`g0`<%z?Wu)D=43vaUz{3n5 z_B*nfTRE5b^lSyjrv<o{!Oc^#}8^Kjp?BhYup5LJsRthV<|p~u2LEMV(E zD(%!`kCj_++#f|+cXU0u)|tSTp&BfB)?d86-~#PG;*TbC1O_rIg=4EU*ek!0q_m?7 z@&gaRxCJ92Tv3PoUp$6ON34!IFt+zOoW}ux4=7c1y#Nd4))r=d;>mTC1&#Amq-R2oWXHN} ztn{tqEf+{r#5y}-PxP6si!<#it%O&Bm+@z*GGw)Gu3vmE!YM)31A&;fOh`&@R0 z&r*2`hu4_kwX__P{dYuE@9#nqC%=fx_734EEiPr_BuB6V>04=pw-|uc(f$zBz76E%w+8cr#-0KhQ)`%WCl2qSPdMe&{*wK2r>h?}fhUg>rZm@f#NH z&k=ZN8!0@YlD^Hc!O8vUm_0KEOaw2Y!^9Hiw4HgU8SIklGslpJS2n6Xi-KiQN2zH?5va5$F_%~E zbYFfGBb#t?NSX)(8kVAFYXSJ4oyW$-h4N#=7twFEDAIX3h(7J~$NrT4ENWp9J2`nX z4xMO3o}okOtc8{J#3iGM&fAdDdrkN#%c}^L_4H zN<&6VTcjl_RAz&0*~$tPGD0b-l=rzVA0*MBj0UM_XegzD*7tn>fcFpY$KyQCx$o6Sl z67;B16C4se_!dUqI3uP(+jv)#$zdpWp2G>MTnJxXRCFvFg~0xY@W z%dY+DOkX59bB?V{SjTmSuN$?qZMIy;&_fC3EJ{Er>lnLx*~iQoyr1q*Lia?$QA};7Kcdg0tZ0qBKRt2wX)&;@cgD@0=0x^- zJ;$vTr@IbpAZLr_kvk&pOsC)vB2QDu<*w;?<=960;;0$j_EnOmiki};dgqD#oSP83 zIfoY`p8y{9p|s+?BUT?AfTDM+P-fnASlnue2Y=jSl#MRI@-su^&(JnB95?|twDc)Y zM~E6{mN6@rEaTO`awYvz7W95?Erk46hf#lJuyw8!N@Q5To6qmyRK5c0-FL<`=LmGS z*Mo;d0mnZVqn2ScuPELP%n}9g=dN8;_xJ$TJ(+=e_oQK{D}iJ?9iY41KEPUB2i;4O zSg*fXpllpQy!V0SJUPK9qIw=PUZN4ApZ`XC$^SZWeE_w{t1vLf}KZhp<&2RqSSjG1XWJ*8v26aRbwyv`0h#Y(zhf9&TcqeB8#|| zce0Nb`CXT6T!T67!X)!wDR*wT&YMzL!1qHQI9PSC$5)nu_kna0t$!ANUyTIv-~xPd z=p{b_pOFc%Rm_f09%RH^nSHIinNpd(ifVSf)WW(!tzL)q+5^V9D z1f}@0x)+y#gRlVK>4hY5+;NER_%Z~>dlcc*=tfu^#&Nw(1nCg}BDwjwfVFqoL1ZnH zK!AIv3E$SIS)Yx0|D^2MzrVZL#l0tpqntFkC2)iIACSec4rA1J(}S@>DGb|w3JRp| zK=KDYY&Md@@IE=b?r;uzUW|gQR~qlblWman?iO5fX(NX3r=wHebh28Wg;}B#h)kRw z4c+?!f*p?YZXZ3v|Kptx+ME6ohyDr7qG^UuZ=^{~xV(tng$2;{BMy3FVoC3L9o!mk z%X8BB!FqS}l28#Nn5+1m=$r6~)CqgSNCdEIa--x=b_c|YyTMkD{vmd+nLkaQqF0>@ zwlv)$f&V(l#5@_=TdfEQ(RQ#YD2Ny(YT@7ZX!xhwLmWmGAZ<7datyiba6cu#kEXyw zOG>xSvckQI7nt>x+UQ#<0E&}d^Bkrg0K3QG@Z9Jkx$Sj^C~!G?2lr{b=H0*e=ELK# z(OC$Tej2dtU(a%x$v}cPdFUu`4d!c2r?uQ3zd2+U9DN%~+)oIT2@YfIq~WiuhPn+i zah@Byvura=URJ>Pr=2FBU6X+t{32h^Yr*xCpF!n8GCZ9$l{E^HrB9b!<=pirxLK|+ zSxOqM&#dN?#F{erZ@2+mqv{~?f*K@V(x8yuLH;xKVohfLV5)r{kw;1Tq;%MX#8l6N z)$$JX@7!w8$t!~7CnsUAF%U{BIAD)`NT*L?CnZuZ{-8cX~z?};M* z6BQ(ixiS2kQ}_^dK^kQ;EHUHQLbj)I7Jdrr0K>Ktjxiwti(iD(=y@9K1MBz9_kG;F z!LN@jDH!H=Dg;BuWe4otas!f0mqC-09uZrp&ZKXaW3II<2b&>#kkU?o$j#0mFwT-t zr_Zpg%YwAF`NE&@UU1D5goubFGI(;4b*EZ5i97m$gvceb2Jgps_1~Sykf9oQty;mz zebj-f-y!(p`7|=*XHLEv&qDRj#@P6{8MJlRzNBx-OdJ zAM9Z2S4fby^oQi9>I&v>79Wy957)HZz`jU^k=?4yEURb9=-uVywefPY`N3>ZKK=xr zjO^oh6Y?;i%=PsCn}yGGZ}61vCor!Hy5`;|^{3>B(2j*5Rue)8|Gu^k zp8o@`6^P;T0z!p0^?|6DArX9ahTS!^k=bx{F^SWUf?S_)_!>KtYQ`+W%#3=F3}txY zA);j3peNHSJCRDQt{y60sonRV&}`>@*y>sE!4 zC&m|Xd(k<(yKABKwfKW%_Af;Y3VcJm2MIZ!_knEhm`kPAet}%I2S%UihYRhx^s}u$ z$QGaE@#a;+;D>N@pId^rb86u~#ZrtJ`3Mn;>UeeUR4g+srLSguhl$smz|7tSj+#d? z!L~J|mo1mCW5k~EIy2R<^=BgO3*_Ok(E%8{%Yz34$4LK3DE?U(Mczg|fIW@6 zbt6&B=r65PY>)F5?Cln&s@3v!g1y|mVMZb|^RgbbC}|;?u9ozYGDAW#T`)_t=gUi_RtdFT3H|c`ny-bOKzOrGz4?V$}7+LPoYi6Q=H{ zX7Bjl1JO&%Fzowr@_U;<@iMN2&VQm<^@;0w3;c!ptDBe;M;GA@Zr&ClaDYCZDFyXC z7SKO1h6(2ukh^l>+)lk4KSck;(NsIU(0u|OR^8wmpU7plexHG_8WPx_qVp;9PK>O2 zmd#G};p2$@R%)aCmHeD1LG`xjQpfWa9wnOB7^KKN?ZN$sx4`9kOBWk-cf^n5L!KcyFX`0JQT4Zq+r`oD2sDTizz2!JDoc??>m% zWb`<(gQy0o)y;8Kq=&4gv$C;`7~sLfWvd2o!PRQ!alvW!Qe*>ROw>VbcOGbH`(fGD zo1l9>1|&}?q0f$7ayB`G{d!i@!-iWzRfl^wvzl4 zK#a9^!i0@#^vJh2`a#f;9$e~2Q>Q#(!ks&qBXjj}r-U2X8^+CKHt=XB z$6B=b7Rb4J63|OL4}Gl`VSnp1TD?!WPW)FQP9IFhVdX~q}w(Xpo5nN zwKg(hT+S@vShfXp&CoE4d>q2K2ToXg>E%u*jZz&401l3jBRM;6&>s9BRFeU7fZlKev$oLpqPV z-z-=ccWWV7cr0fJH_XQcTMQXSyOV$UeJDCzP(&N<{xR*zN~~*EX7c|C!ngaOxOMjr zID2d&t%y8|8-L4Et83dJ;6n(RdQ*ys{fcI{-VMR_9s2l;gE0=b@#yspik!q>XKpEjrbTU5M_R-IvtyZiXJnyiYaSzPZ_8v0e`d!873hwZbFA69Z?Mr! z9nZ+E1;~#8=}X)$J5k8`b>l&1WKb9tN5wF_-xW69oP;w+lOgk(BQd%^mAx@x82)=a zja9i5fi=4`;n1h)VCwRSUwrQc=c}n=_|2){Gu0AAUWpKqVs7WO?>e(uH;-NAa+%rt z%M4o-W!SgsI+#%5LH;-|g|5T~81?PqtIwQGYTjQW(VxPZDIC*kjm%|M?aDJ=N-sC3 z?Y{$)j#h(Pt}uOBbd6js&}XV{#aW-p=iIr~J>n zVaISa3ng5}eu+8^3Mk>s4F&As`yH&1L=rp|9U(U-k1&mkr_sxgRghIo0fmnz_*KtO zTIYZL0yAW8GjsMh!6<({`@kXux|%D+dhFW`P`Ns2xx?Ro|?FJ z+W;(>AxjIs?q-#$szB30lqR%rJ;oRjYd!0AU|}7LEdx*3yE>fH#dQmN_t0yywx|R{ zdn?&l%L~caL~&X^EP^MjX0s;e=AlT?33&8(MeW|YRQOn%OWeLZV4Us?qSfKA%s5Y* zrp~sbi-*kd$DwriqVEO2AMA%f`6K*?#;1s?88^4Q_ZwO^yo3|4iufWYDu_;y1xC$K zrUk|8X~pedOls387;SDK|9%u;;H1s0qV--V*0qL+i~G3V9}=<4i@@~D3`Qk!I=%Z_ z1}DqU<0Vz3L%7>Qc12%-ljPhvp|85di(L9xG;9QBlw-CIznNX{O6aD?^65RZ}3mW(lv`eLynfyDJ z6@7IM676=8ZHj&D+|nT&cp6U^@6Q0$3tjB4A4}OjXf8f%46F+72 zq4!0Jx>s{nVeTIu(Hbv<%p2jjXZ`hxssv-(18yt?F!AjVP?oBxJsg7Huw@ zh)vuseX$P53)FuOwUq#`dQ)#aoy6c8E{_#y+e*Doa`POEf2@ttcbKyFJXVE$!MR&9 zsiKEuosvR6M1IahKkrXqvGgce@gf1!vU8xZ^e8kw6|TD%Ac`RM4$R(E!RL35l-)0e zo8S8~o73)MR6rYcJ5rLx?YE6hJs~Zq2BLoaz=6{i=(F(_>ESqpPA?AQ zl81y%Or;gBWjR(j{I>S{c>%jU2AF9v*O(>?E8JinNE=T`(aW6Jdwl;P+@+t6SBCeI z7~WH|Q_lwPo2%kmF1r$^lZWzw=JdAbTAaX0*3OKJ;dlO;YNLPRJKO10N0x^v)}7Ro zrt6PhBYV{|;Q|q*lQvb;l0OFgJQb6=wbpm&*iCWB7XAdy?)^-|pS9#oz7$CsIfqB2 z?CB2&**c4?Z8-dI4rsjJj^*13UKVH|1Ia77{rf}|dUBrrmmmWxJrjv-yE~kJ8_B=a ztAtH@C$OV(MqQY&RGoi)GoexYXx^tzxI5Vs)t-qW^JhD#2Y6ARcM;g+mBnViO2w*- zTSPp-n{3oQiw?Rir2SDYiQ)S2TCu)t?B{sa;$H)P>ra953l6khcM9gpUIX*Ytt9Sv zHjL*y0}UBZy4$H6*XJc-aBmb&=W=YCkukVw+G4CMN&#lACB8{%!0CR7o%gQ7>itu3 zByx<9N00u!T+BQ8UKQ8XWU;3%T41QC9Lhzh(SbAf;NYPh zwdQj-v3Ct_Q~f&`C=?TnnAr{2GWHLWI->3c4F z;usZ#NnFR;DIZwI&;4=*w7D+BQ0! zYI{4>?R9+28(5x5b)E-+|Lq*i>wH6&9EyeQGw)#L`hfVl;OpYq-%tI6Hhxo`)7hc;qz==!gxGQi2 z8*Nv+y7-$+D(zzjO)t#I6MMl;6Z&^e`G7 z(1K)cr{PW&iDCF|T(|1sJJ1Gt|5**L%*n$&|GdCc{61-y%3*_>zY$;CyZ6V|+GZeAH%q^*0WJ7nf0w0ww%1wuvys1#GLt6bRGU1bbJT zqe-X@vAo(&ZnW(snvt)0wm0Qyx9t-W)*eLSRTJo)Z)wmRm<5T;RzdMUmW`5%Bfffu z_)(;WEQ|}LKVD|w*$wJMO#2h-H+~hhM*``cUpGkO^eMP{Qa>5{PapmL%J6KlA>0o7 z20K4xurd!evTAGg!|W9uBzLV7JJ-vBUfnE77Jj%&hw{~#&biX`kd`J@iD_nid}HCV zST1S5CdoFqTp>U17=z!`bdJ?v1)Cv;?r03hw9u>MKQU9XYnCePeZLspBrNF73?GzB z3&q0RwIoJ8mqtnG&@0pHd65_iejXF4%l*|XJ$e(03%3xH$TBkK`HJ`kzrtY6SIm>i zscinCcE;L^an*J=lvz-6(zH>|*0GBs$r|1N2}^#Y8ItDtpXHM=cZ z0o<1;z)M~~(-)mePrY7BPb%!B8pWf`j46_=(G)A}viM3Gyv^{s+D=p(%I7^&QDF|N zU8MJG+;~w5sboDjKXCD7A>#TiP+T`amF=H`*QqWzQK&%GOy%kN$3mdBtf_X+zl&t` z4IieNC{ocCSBR&mAnfOUm-2c#*&lzLSnL&|BaVBS^v-EG6mWwroz=-J6wQwFc)T~_Bh@!{qnloeL2+BU^<17b2Kjvh zSuTSqd}{|^Mmiermp^8Ow0F`)tLuU8TwANaze=|5I*F#cB2ZxDF*$4=4g5?Y{92fd zbHcAe)9;&XR6!m4ptv3y_N|~D(JOF=DT zWlT7YEmB}-9{t0Ph2$_5l5@~-W;3}Jb(P4Qz|0f+?nR*0j7H}mKHXFvv@ixG$GzVxTLQC;xyAH0D} zvoUA?8akG)iSlFpjQx>|5U%_Z=ef0$xBFAb!3G8F+h4`ZwoRsW|3zbq=|x;(`w`<@ z^YQjuEy~{UqpS8>VRP~Ya4YB~8C#kl%+;2gL22O(J4^5jeuJfami8uzQ^$xV%+z?m zZm)U_4u8V%c(NB+czP2y={MlM$^SwAl7(RSBNKHqQ+UaVOKDHT7j&JQh<;0?h{mUG z2rVx}tszr{W`xkghU7%_X3#Xe1>;RTn%1}t!x^&;p-jdR97c~_^){YrmaR8F{g>2$$8el2}hE?S&ghiwEkY6F>$oR;DfP8G zLsz!CV}GeL_Kb_*{e@z6abH)!-L5C_bLj1GOjjda#@T5N0U;7GKbtZ;gXEs7iZAO`z`}rioR~@c-$WbpJPmTqk0G6uP znQ7{$;lNNOX0-Z|iw3Tk!b*{ZO=qdq(=&9Wdo6o%au(kF&h_2DPrKZtCL)<4<_%X55uG~Wpc9mHTivZ7q1|&mzUv4EC06wr^qr$OSoWAL`upG0rzA=(G^ zQLK6?o-(`$Ru9c-Lt8E;{5Vhd2zJ4kZV5^>G!ZLl2D58I@M`QbH2fih73MRL^}Yet zWk@&f8Nlvv9SEOMi0-?;5plES7~7**m$c*+nbypQVZJNIez^lTPMd?fO)y<7)k2qT z`^27?pNu93o2a*1E_Hdoo9=qpjywCNqVc8)kX+@8xie2gF}DY3f4hcrhMXaT4nINp zwlvCj7}KqPr0QC8wbAp#SCF4KxvnX20Z#s#04+UzXrMm_vSojxut*_Uuxlcm^Nq(Y zX%SlP>xqshD`~WkB0ODUP7B*isf7Pis=DzKx#oR^s&5$wTWevPhLU~=?cvTkpL5Wy z_%c4Wvt}o#J|aJSK9I@`U;0gydrnS^63usN*dP9m?eN^qyer;H7oTZ@E6N$PZ4ThH zxoK!Q=P!u-=6r%zxn5Lf3o(kl0nU{R=(Cp{bU6U?LUM?at7&3CS;b<$qB(Xk(?IH+ zLS5i>*}9<&E8?~`3Tzg};3@tmQm1DF`BG*ygQ_oQz`XdAyf1RqxU6G28~AQL%u&yy+UbWtpn4&lbWwnP zGE_l7*UzLw*@tQ1i#M!Yjv1PA{e!m_SLyqIk?`blAik@*L0+HB0m_Vk$J+=zV*8!l zHo|pJA5O-gv*vW5Mm^1Hf? z>}#;6H;;`FpDZo%%`OXd$^`L%+&bF3ER;C%Rq)E6Z8$oznd5E<;WsH0GJSm_JX&gv zo`UT}hht~n_YJ{)F;cWfuK~@pJlVE0Vwm2Pfen{ytq+wiL(Poe93LPCvY7iAugP_5 z?8?9{n%f&lzb3(5jVRY{OFgI(HR$+6WF;oju_t3}daFOxXsVqEKt;oSb0uscwW?zgkUO|um+w5FJh7ac&K@L%Lu>Uokj^9my%?T=># zljx0C{#aLk2j&IoW8M;L-j$34Byn`7^|Y%NZ0-wN8oqm+jhN*Xg+Q zr5p3W?G-ax-iFOu-S|z-h;dz*Ngw<8qvP&X#6B*Zc%2*};-Tft6s4frb+;v$Yd*eM z6yU`kYZ+ti=xw4JVgc;LmyehjH6`$j?}hf9Zmx5oPu7xh)SV|lpFW*S#A-Yt?~NI3 zT+C3D)@Xbv7D$zi66rN=#%27)72|}~(4|-EATj?noxLs!A|HE#zvDT2X#Z)bygY*5 zquk!c{{{(I?88o-{vAyArcjO5TUci54xAYnhwQ)PT2Bmw+$1gNb1~yw(95w&VFB$K zO{5-Qf5FPh4b)9(gp7~uf|W1S>tctcv0{*iZNHxp_V8QYA}+J?*KIBy3VcHZ=a}Nm z!}a?(4)g}N-+fk#()psP?P+^2i+!h=q9-|x&6B}C!ewFV%$ zPz2oFG|}Xf2K%&lgem{j56Y4AaaLs*nR@&t@iblsJ>wJ5H$wLR+!In$Uq#OTQUxcK z>GU^umv9@6CuepKS@m!%g04;b=nIaMyLZoSCYNJInp-R5o6cx5BDM_n_smCSTRG^M zyAIzi313H1I-Wy(%D$2~Vj^t4YJIU%qG{mR6!xIKcte=&njPpqVHq=W{LsQ&|I zv8U=C>=CbGP3E|Q<$vv&_2YC^@O>uXqaEaMS)@q!a{RpO1H8;>Cckax z!u>U~KxU^W>K&6sOwHt%PV~lpDM|4E{DEr9esGCd-KALjppLqIX?I+%?QAwLxk%>^zRYYb@YoaF# zIPOpOcC4FMMy7OUGHrt_IC;C{nkzGDTNJQ8GZr%%HxkjTY=AfjY=9>r_C(Ip1n_SX zHmawx-#a%EaqZg}YUxeaUi*)cquJM`4iWuTJw!4KR}1c@}R@q9fcceoJs9Jomo9s%#C$H+N2>#TU*s7ng-e zGP*F%A7S!6W}v{OH>}}1PbQUPWiTbxqw@n~Y?8_PXi*4k*-vxs zOW>iIQ>o#CY|`LbNU9u)q3pR39xTbG3#JZOA9Z>O#ocR}TfDtE<+lttX7s`k*0B3v zjG3nQ6-Khv(XS$xX8#vU{c01@P;D~pD%nG-vwX2fCKtMT?vubBA>`@CpS<`p4fN0D z>6mXUje>S5n7Jt*^v#OE%`X^>jdtP?$G&i>iy=!3-Pp8->DU?k3G!|@LWt&NxWe_c zSNjLBtL9C&?v@XxiA)9X3JdA2MGCmg%NmsYgK1jG4xab^Dl`%hsS9mkxlp7 zNbBKKko;v8?DrAG=w-_I@cb3nJT;fY{hcCxIjXcLB$hM}&OyoDN;V-QO>l&J_SP?u zwNX1h3GYP}qh*gfb((b-CY}Dy6d$X?4%Iq(F~YLWM0PHW>>MVml*4HBS9xq}l)$EQ zq8Rx10T~aHz_e$kD3fMYcd&UTwRGiCyRu5S*j^0sd;Wm(OhJ5qEF93|JSdw2Ph;97 zbn&>t%eAe+U;7rDP$nDvP zDX_F^WtTc1A|2OCKwwl0`m5aNsOK8|yl6W8{B;lR)9e73oKm{laEwd{O2TdOckoil zR7_|sCR*Ptsr}TCMC08@)+o0H`dubr(-SYc#_}Z2I{%pLxbhd6r{~GNk%xF6f6=Fl zB%LS<4lw>0mtOh|VfFh^C;tT1NY@{HAsLbw6 zxQpj{7GZlaH%E2Pg>4VF0kOM^#}sO*>+FNfrJqW5LR;*~tf{gn)3k+3J(z&wZ3pqi z)_S%`yCBOjYdic64J9)K?$r0UGwdx)c+2Soo!$B%kZXs6_cLxM6e#c(Ed z{47e<4(&tPS5t7wPJ+k#KM<8SQmFrIC8~6Z+8nDpkLS3vf-#qq5)ss=%O>uHeeW;P z$l#-RchhAis{JRdo4K79>qg<OE`;{0Vak5DH5V!n=F@PV5W5&^8>rxtHus%49#eq@YySGt{^tOiC}Tsa<;G3N~MVM&q?VpvLSO zHr`*}fP~{WV*Q^ncIevHeGQxgXG$gLfn*DeojsYZVK?D+`?ECGVUWDID+oDBad>sH zA6jJJr<~^ikGhD|g}>_JFV<=$4HZqq#bG*X4rt>0JmI=22fJXq@k44hX+9Qk8RMR{ zGwRwFy5XYJOL6|jcnrBS2!FpTZK@WW29fVBROCI!hdgis>XgIbp>!FB#QH<4 z=~g`aY#H6He-gjD#-iM#_sqi-OX$nli2h0vbnci0EIpZVE|jgTZEqx!z5rBxR|pNF%q)sbrG9dqHmg7UC--ilu5Q#5t;fE*P7Fhnj!T*4~qt zKdem0<(`97Mk@VUdjdrc61v5b%MA?J(i;mCF^FTsi7ep0{TCmCct;7Y$HO?%nNH1( zC1JZ3$0NFD#`;81>gbV2j-NTtI`E$1?ZOmxnut7KaxjAKIgv)p6UV{kY#Hwt$G$15 z`45%*R`9NA5<2_RYnDVXDAP3$Odl(eoGu}zU1yw3(CG!TVw_}+w?SR4BbwO6qU!Au zrf|H07xZHgM7FduU;b%=_tZSnxxtndKh;DI&U{S1ARqKj`s3c0BAB&SlURis(N1k1 zqs;YW^oADFpBqKdM^lkR%e~?~+{)$GrxX8X)Q^n6l^`+}#iZ;`@DI+Sve96F)^y7o1``3N>kV`#P|z%VQR;ehJmbGhoZ1S*%Q! z7VaI?g3!5_!0o@&v~rUh8CX7pKDyUPrfr}xeB9pp3n(Tg@cdBceK zz9OZ!)X{dtfzA49iORu0VB>~zlGfTuX31V8y&pY*+mW&%mD-#mQ5H+*UxzxMJwUA& zbw+n_MF_ROZS3Qu8ub_Th<^8(qYZUh^m z->_=C3Q@7(051Q^hZYb{xs?&Ubr^@!JZ zdj^INsL;CM1lVWq!TEI=dWXC7C+`y_^6O&ZZT~KOTPcTqLrZDF@85j6CU@ran<9Al zwvwG*&h=fKUW1wZOCoA0O$G8myoAFu6Wiu|g`>T97?xa<{dFX1vq|CO z;qNt(bbdx2Sg6PlDWe9E7F&)J$|Oi|-emN1m_ZNn-V%#7JWRUt4nl_$*q!St`A6kB zKeF;p^ic04Q@-9KNc56_KU2>Ain%D~|hkbF@EepJHOAJywGvN8nJaC$L1d`m2k%6qO zwDJA|bgzLJ^Bv4~2>tRaz~ z1rwdDh)Kdc3^q`LGONctuN|{#@s88b8gz^vv*H*7;XdqUxy3ZmS^*!9+hM__FnA%O z4KCj%^7V5BX_xYKT;64qIj_gC1c;eB z9?F9U)QNb=yQ>3|}X=K5MCb;<|m1>yTf*PHOCf5=`bFveu z@jAkGZp|Y<_d4JQ)d(^#p@S`Mn}>@mW#N~0GHnl9h~DV~U$q~xm?wc`5Os}>xG zoPa|>@2arYUzQTXp$fL~hzdkJa=|jY9VDOXp#QikmnA=msk?08*?0)uaX$^}0yT({ zs0<|N-Jo{iD(EOZ4-Xg#B8{tHrOdc*&t=PiQu?K1vyVGXyZ>9NXGrX zu~UCeg^hVFj6XMfJ$&m98~0NITw;ajx`X|Usv)5tj+c_wA^_1t9pvo2571NLhJAko zh}`XN_`F^VcLyet{|q?K#*NF6saydz%Hg!ET?WrdIpH3zJAc1y0Uo_NiTcK zh*s}nUD$qNxkZz`T+qV%{!WP7Lzlp&{5236uRvGtoC~58R$^PoA`ClG%5Di1q}?CR zvT+B0GRrCt5L#1DJyy)Z9RlmIX`(8^{M~$qqFl(+9DpNXI_Q4(0UTNUoiQ13Wq!!G z^EF(qlPXz74A&9I3S(j18J~(T%%m{FLktI^Ee&$H>C3yH@nI!(H{|4G`Pg;A<{=<}he?WeR6cdRV)u1WoMM?%_ z@%A+hr^`FA!I0YiB-AP-Hc;f3ePn?rHiQ0X9U>z?h zPxqcX!CciAL}TazZ+S%;?{^>EhjL+H9e}cRH2c@;8ML2oVxF`Nl9ku1NmBe3man-K z*H$cNl%-`MuT>eMN;07HggES&6AiC3k|0RD3^pEK%UrcGr0Y9FAf`eD$%c)@ETtZ@ zm$Kyk2*<7Z`HqbG*@KU{J!yGkix;ZJ>7a%kd6M2nlDA!D6Cb*gk1sv~Q^>O6MYo{f zM?537A(>8kyB?A<_A&=v%%*QI^+I=jAAedy5Q%K-WMUWIr{{*(&47nyBX!G=&lqHDE`ItweowhOaq zt$a1H8Fw_%6dHXmLf5JDQ)L1;#_?L~%r8fYj?+`<6FW&nT)Ua=CpD;sx(E#V9H$ito5=!A zeQK$jS35Cin2n!ff_qyM(S>J7k1UU(ZC$MlHPywv>OCYfXD-g1(*+70f|z2EO{$zz z$+CS))b*+iN}qqv;@vDbpXSB&HfIyj%2H^!t-(4a_mS@jo;d$Z752bm_WpZGjsaay z$~fQYgaCbv`QF8za*>AuyO(gh+6M}rIAPERaUv>o1kH|pgISr;OyG)#B$(uoD3$As zx#Lv;>BTgio0q)dI^6uH(lEL$5tJJ)Q`gU1iEM5M(Yk6t$K223NX0IwGDu^dDLuiG z;xKjcsJgMXRliDykZyU*Y!OyCc*hrBEK!>KlO_k5O=CiOry$5F`D zl7e8)(Qz+WtZt!p1Poo@#n#Rk1XIo!pJH|ZgT&q0{XSg2)d87>lcrMHd%ZBL*MzFg z_(L`)I?~dLkI;5`3xsp~dq=NkF0UI!7Lp`#F1iRb;(Cagb0$qM6eOR<<7k3fE_XIK z1S^cJ_>ZT=uuX>VV1uOs-S%S*Xq_)7TQ};XbqnVNak#>)m=FlvpXBL0%_!mu)qKl1 zPYhek;;tF?bl7_e2x+T8Ot&9={xhHcYm0&1KO9-phyb{qe-n%rydV<&ejsUinBL-i zg&D^gDp+%t2HoYdIJOz=e)o0QJ!-?g7FVSAlE=uEBRh%Te^-crY&ank52A>S3N`GE zfzV%v=;c-m((~MoWL2aP^SK&$t79>!PTdYc7I8dLXBl|UE+;YL1t8ZSjoS*PiTC?9 z-o?mVMufK$Rc6gZMTLhj@%cjNi%`JXza_}~G_Y;%@{-(S-3iuyC~R4Acm8g%H|9TnusQWs{2#S7Flzt1}EsH6+#9ixM< zkI=zS>DYB-9&>y^id78ChFVb`EO`8k)avaAIrl80VKW&&SD1opz-}__(?7OuVh`V_ z6iASG6qKCG!)TKt^qN1J?j2lCh5ks<_Bab}wl_vX&#Z;3>=_*R)&Q@)TgXHIHB{Dd z7&c5cq|dHO;v?CMAf3Q<%TB!l$CU{U#bf6Al7mR4fJIoh_zeB0AX*IjHSa^``DPfn@QE?s9f~SSl-+P$ zm`xe@Mz%5<^wXwt{@OQIG=Ax9N-q;8ByKt4enDJxqm}EU71KH8>G-q46|4T_(AH%k zuszieiausiF~jpjZ)}*9=j1c@1i4Ixff3#C`%JCn^2tI;?d_WURuP~dpP|3qlf>=|%oS2ou9e}(ro zDzzpLjCq+gO{i{q7$!Dj5&D~cYe;Kh33;~mq9kpIjT?ezev!Ow@v&vI-V@{8=xY0Mv*q1N{i}y z`Rk4lG!Jm5Lrv4r-#QaDdcVW#Lkp;SSRhf5olQksPr;$rT8xJ7Uv|9gC2aoj6FQoI zlT~xoX{_vPUU%UvDzf=H)Vz(tq_TWAKeS*6$$dEc_~ivoy2hu zbclyAKNtp=<_NzVT5{}+X9`V!r%fi^k?c;wQP@@#je|n#*x5~US=s7Xw&Ly{4nKJ_ zlbSc&x2Vmc!i~k?HU2nGxueVXPvCGtso-LpsKmzo+6?YL-0`+GqSes>!cg%Tm(GTw z`P=(wp%#p?lc$3HNg=#&=PQnW8%BN>>uKPUcMdKmFLD(M`>;m41mZuBWrLpQutzVY zE1zll(o;!icFN-@ZmmzInBtxEM=_2r{^v%YJ}QFviVYnPeTg2;K6D$BAkO0l+z#GC zi*#gINY_@T*G0U=yqA3b6dfAyUSR#U?&kU*wK?QU6_AT*40|$V3N100qRs$g`s%um zu?tEhv5B!qtCIP?{slDrbSSQ?P^G7dI#hcik?&0jhLaM63sYj5hKwwo=$E6Go$2^> zekZOHWl+6u7!~<`hI@^2#3snFe=f^u{mSE*_xcwmOwC~FgKp6AiXGsPw4J4QA{I$U zGpj$3@yHBYn(8R{UBX|%ZHc#h!LPem_t}N6*QnAwxodFLLyz`uccJ3z8f3IgpBaZ; zz*m|RI30t3xU^P@5`T|I75Bk3utAX=6k<`orUbKRc|hq)XSQ(VS6F&K9Rqr<@(s%e zVxGw!j1lI|R~F<5=jKw}GD()HTVA9OF0VzOdz)c;`+VHfeg;3TO=OErq^o{B6*6Zx z^f64!7Z#)xvuz&ve5&hHu~Ga;Hvj7qPI0OdZ#m@%M*cj^FSLKdJ4B4;`ZZp0BP9Jv zwlfaR1|B4?a}@QQUx}9UMpMOAF^rTE?l$h{K*nP~h;Qu@&V=inPTnr+&^9Ffu~w|L z?jO{cA7UOEllW~bhCySi6@80|rfD>sy(n>I1F9vdcJ(kgZ(I&rn$N(^>ziny=WDLn zL5WhoT!piznz4S}ax}Q-gU6fKLx1ORy7_xIzpBukKF!V-Tn_uN>1qO-cm4uolxmQf zZyLo8TMpvn1}?E;C;lDx0UOU4P>p`Ic+>|Y`jsI|Q#v2Onoqa5{2_XD_SRnJba67) zt$NNc8LY$Zxe0f>U9(x+bYYJlhHUwWWH>YonYzJSKG&^`=M!uo)^#_1k?4hwS2Kma z%T;K<_6qF2>#*WOBj}u>J>r!Or2LtY&DwBWnIv?G0G8L@B(ix;b?442sTGq zV$|+IR1#E)&r4>3+<-!SGF^k(yq=-R>1hvj0Q12z@*Z?D&AhmBXUmi4;zAgK~ zV<(U z?kp;SLn;5DV#7a}Xt4vc{`(B3@$)IJA%H>#Y~wO?Qz3at99oB{u!d*j*)R>@dj_v2 z8KrCD6%Q*=%T$_LwGt_A;5$65IRp(18d3Gkf0%OnAn!ZQk{-4H!mh{9#0nk5Sjwm= z_;zTJuy?MbXWz5LTQ2%w-@RbItJDE~J^T3!^@s7?OTk^4v>!`D9ND?Q2y3245lN0? z(&xKy^sF-8U0{!{ShE@frxn1#w4=1lwFuN=6sc*$Ll!b2o{`-Jp7U6Oy5p?4%X39c zZvIShO(w^<9}Y~|N5JK6^D$hu9*tJDQAdV8i6&%ohiB;F!QN3^>;9*3*=0KJ|D(^{ zTGxdmA6*9VjI-G0k-@c@t>7bG#GqM=4OzJE#om<`IN5F@F47LgxG+Ul|5WJMejLLp zG7GpTk6plBFOJ-OmSEe7{@DHQP=z~JZC5;%H>}3sZM{< zO?Xos-X!8OQj2j%KpBO%+LFdJMOeIhAZu8d1$h~=!nv6X1=pE2ho-ycdY9TqIrxe2t|l@U(Q zfTlIy_|LDhaM!G(Ofp~uCbKbYw(A~p{(4xLTpp#N-=o=ul1U)hF-?3Qc%1Mk6}HJ6 zGTl|JFnix#9Jo~(=Jf*Vk1c?YekZVJ{aQ-4c?>_#2Z-l(M_{awO{p_jz*U@9WBS*f z!0_ou@xq!Jc=q~noV{!`6a*e;!-7XR`Cu&AB?eK3d5!=$dAC}&(6C7{P0H&G0&BOi%<|u2 zI1{6T&J8xg8)yqy>dgV2nwwC(DxI2dByk~0C-FhMA5BwoLDx+T`8^KeOEF8?-_#Or zZT1!DJ*CN_`pxLj&p%`kKAiKeS<2i#&psCU}cHG7jsXy#6vw-__v3WX<=U#SNLEnnLqf9bH|Uz3r_@& zzwa~(*YAW!mzsDb^WTtSv7N8g{Oxd8=%6>InL{3~U^O?E3j9U|ZtEO<($#+n>*qP* zrS5XRXpaXy`*w}>OON3TI)|_fjX?gT<#qOXQYu{Zso_VT&;X0cHCSpDCkmR@iu=l~ z>A(HQ;K0!krck>Ss^0aYidG~St$2l(G#f%oM>e5qNFlCW^Fdr1aGV8XYLdmWEL?N? z0H=5UF$~Xm3oxx6i%aa;_OCa1{gIbw)#?+ljDF$!cgNWEsEsr`pci9p-0_2i92M#s z!HSia_`SBRT=d9E*zT1?wZdC<{k|9+ROJMJK2D{JetuYU_&>2hqZVjYcu@IQEB>0v zezq=j6qoT#?HM@AbHtL$dl2dHxnMfcB7pT*4hEikBWTSs zJGvpv6;B10!q&n6(e~OtAtPc%*XE8PPa)?~^6LWrOx;B`XP@BZwiJxgxlb!y&q7vU z1EqRigtb+MpgUcaH9Kh2AK7++OSF#VR3(7Lu_ShH%6!h}o*Yee|H3c*yq&MC&j!i$ z@A<_&tq`m~11|kdV{ulGdGmi>+^RbsG^Z$(bJFDT&%I{8VrM7Xz1)T04@shO%K^H$ zajnpE+l7(yleve>>QT3K2a{hXDL%DJ7PQs~3?eOgIy7iEr!R1451g3F{=^1ikpBam zF0!DREi>T8f+iTQ?T^xL^l6LkA=dghOi-asgy1=eoN;>{ypzvnuN87}TYDVco%;bJ z`c!b;+X}(8wUD-}7Q(IJMX3HEmfViW(UBv~ptAiF4`0UO+l{Z$AX8u%8Eim@avn`T zWmGENcA+swCXglc<6eq89MEYao;g2|P9MGu4gFVfN|YiDexLD5907iOhYU4Eqt_LxJ}XkZ0&As{WNs_PIAu>dh(|5 zlZ9V#vTLot0yL)v`&`J(JH!urs3q4htbo&D)h_LhHPpUKtW#t4(u0vaV`gG zW&Lip^J5ab;F->hGmG%ZCVwGgX~FhkA?F|FA#kc?tFk_RLbVrZI9&fHyl60EZzgNu zo1HSOVa5+^acbr#z#QTJsLghkRC3A>N|-M2u;or3*uD*h>a-(Z(N@EZ*X5$cg)USM zpO0L}1Zb>{#uv$U*!?>LE&n>x)CrSNWv4BE{w1)&-=yH-6(Z3*BMGWE?-G~9ei2{Z zKd*9QvI_3a??=)6R5Lj9c9^X^v|$9RISCX6?I(gB5&8^5Y0@c&9gx z*_@5PgLVtul}Vy|m*cVKz88Iz^akIx8$kAQDhD$OxIpg2Vjtx4A6D&XGiJlTb%Sm%E`Ah{yaY@yo|vF!7fX<~+;U_f0%(l2aEt z?!z33;mamXTtr3jA{(5XqMu3$rRGyG2whgrU}mS z(I%^jRorvIpH{2e&gXqy!OF)LVO^OrukLz*og8e#b{aiE+gOf!yQI~OyXfF%eElqCU zs&Sj*dU&;J8fpH@qdTA6xG4$3nd9Yw;Tu0=+xTd%+TPbeYFPtFM6IG-r!}bj=Q4b$ zbc)lB&>>szP|7z7!HybF?oV5y_{;YMPz%_=^@R*1$4fSJN62+f(JH3D0;WRscRH!B zPT(Rk_Tt}uMXP9^&R($p{3ht>gI z{I`lHD877`ztlO1Q(15TwXVm|QfUivw@+mn!=7<>Z4EiSPXoYsxg_7WVIUq4F?|qkr;SB=Yrf4FyxYxllyWb#HH;XU#_D2iT9sJxVW48Ws3K@>O zD^@Ez!&?ZPgYF1b_H5uNE?w`2S1?^Tv&3V6$r$itbNh*)Kw|;*$sM z%}WxG4t&Lz-j`+LKOLdn=eNV9)EW3O#Z++H2O}#nVQsldV3jeNl>H`?-_;)=vp$|4 zKMdhJB%P^e$yHGJYl0fNwxq6nj?dgE%WwFRMZZoAW4GG{m!bay@n;Vs@s%MvDIg@C z;DQ`!%sUF`*bg&QWih8D3yUKsfs??Dm}V|dZ$F-(w?-B$)Wwv%s^vj~?PPw#tm$vG z5_uTRLgg*ZaIDS_ExgxrF8yVslrRu}{z}8wYG-ia$Q(=)X4vhMr0H=|H>7FVvYXf6 zLffbJVxz3pY}%!*=%_G+z9%$cUZDhDemji!99|*5X01UB6wDp=yCuSeR!KI{ubsPT z_Z9M|KgS`_ZCEw;I%gTr(b=B2U~?&+J9lJ1N^bp$y6e)hK~jkZWesB6-hL33nV5;e zFd6Mi_TY_W-C$Mn4`;q!NVfhC=%ZytYu3fmLaz#tJbseflD!bm+A=)7O`q*|b_H#R z0Eo`E=X}~vv6r(1?@mA!?{#GuNs7v0MD%$+=hhCqk&})$zSP5r*ip=^_ZtpcF2$m& zd$|{j#?ZKf&0v{hL}go=ao|r4`hL!zsXb{YPfH(4?dC`_jKN(Ad+v*KEnW=w0r%_- zF8+xzT~BNlow%jXPiV1b%LDQ#^N$SMdRWA@87WffzTJ>K*@<2FZUC=-n$zRyPcgzO zh_iPuqN_5`!MRG4o(2Z-%hNsZ-``QVXv!L1<-q{{ozS(&JHi9TMX|nE2U4*iSdkfm z(h_6IEYS**bdPdz`_iBzxR2i$+KiLqPFa`X z_n@QL)Fdzva#Hc-1vgUDPDMxSyIl9{iS+MND&CTgp?f6*AZA)5Xxb)o*;(BV*X$#? zV$m2&I@qtsuKRIGJvfCO%PYSmbbd8&f82?hux0q{JEKLp+UzGvUBX2 zVd;HtO8Yo?HPMTS#}1;625MN*9f{6iYnhg@r+CQ31+=DBmU5ca*v9x!%Du6XJPvl-;;9-xcUi*B<_0`F*$*_nA~(eegvAhgZ0@{DQGU4rOW_pQL^j&pzwn&-*vQ zvd6~k>)tbAT0$XF>PxN||$3D(q4O>nN zzah6IQT&=QGu?A|%}@)SpJZU5#~h|r=*Y#7n80o-%%K?1m6UeY5$>Pa0~ub;R6Ho2 z-Pn{!x9&*d2c=wkl=2n#-!&k`u6Smt^Gs|#Bbj0)rn9ljiUgk4O|*&2J(22sYBNv!rmG}=k-gmNW0 zID7sUL~at%@iS@6BKJGrHDnSst33hvGDR`>`ZSt2Wbwu3G3ecLn2-GTiX88zl4(%1 z$aYW#j=IwcG37sS_k}Cm&0FT|+DsLC9TkgL_CMv7e9GxYpB@X;ZGpDzaV+87BzohU zBywJL02~``VfxxXw0!+q_B>088UjbKuMan{^i|F1TJy(YPw{MO{d5L)*ZhHwsq!KOdVN|s-o4R`lY&9Oh3_A)S=*xRN+E|7o4i@2pJ{`<_`~-XQ^5Bl@ z6O?RR%P!bUv#i)W8owZzew96uKN(wI08P0TVtta8a(?t z23{l{r%#V3vlG)@Xu_{=7_edi$pwxh!>41YDyEqIo~lXb1&?swa79jN}x*9Q)w9EBO^*)2(9Qp9Y^UE-q;2n_aX zYII3+D4TfeFuHW-QjuyORzI7=tF24ro*a7*F{bk%FkOohc57m0WEKv0It+yeCsDya zN$mY^4>Dm3?H;q9EpK&ZTLZiB`j-k+7t90EIL#Jk&GM#x(aIk3z z>s7sknUd3SPSrA;*7O5z*>;hkNfv6$kHVoRgPGYaE&Sjj7O$wT=fB@Qk2TgeKzE%O znx-UBQML}Z-?0wv{wjb!`Tu7)>|=}DZ6Tv?2;28+ICJowjy}c-l(1|Np0;%5A{*uL z*w!sfZ|5Gkt}MxdFYLiP-J9vV_h5dNs2hKOoq+*ErATL=J-iqy&m2p%SlO(Z^j-73 z$U9{``$v&{O^6?zz8x<*Ak%9P}Leg6lWNC z(#9SqX#O+;qzXU5)lqKb;e3-HrJj%aA9HE9$0+)8Y6Y2$pTKTC>lXh#v5B${S+meJ zIv6<`1UGa#ycpxfTCUC#EjN=Ri^6fJ`eF{d@>T;+l?vWK+kE(Oy$cTqdy3B8OXqK@ zoPz41CNxrOH0di$;y)y>hs`%in2GZ*xT2y;`*X7?Ys@orO*()x9o1o2ku(eTaKy;L zF8FDMBDvzLZq?4;!b(kXvdTXLvdjW6r43`KiO$iKs*P;6_qqi05r?%Yr+U zQyk0sd{D&qPG{1-lPGhq8D00Z2EX7v%rC*9$~S#CDCiBMpOZ&3-zYb7UQth5qZ9bV z(Qoigwi@fMI>&!l6GL`ftoZ)y6O~TG6jAT_H@GY8b(=dIMPKzf5{-WYar1q7ybz6d zZ~C)S=Tq^iXp_ivUXVli!vHo`Di9`w>f^GH(R6kEZ0<&`7qw5=2dUwbq}4N8xX0P> z8zc(3P{n2%`@I3Pu4z%w%(YZBjx8kvSCs;LK;F3A)W*R|BIH&X?RIZ*) z@(V9gR&+618w7(|21WwKSEUu6~1zy?OV7efUli2FYT;K0@P+nLGmzPXq@ynj^ zuTtJ{2JJPK9l6$!6s3<1J`-s5zz}8A!;cC+1L3*fG&>avc1ajJs}}8-&7|c|M88Lqg`b zWbQ!HTP07U{#dXKlQe)m$6;1wD6O4)l3#mn8pAGgc6#=}D#c+w%rLwkTSbYu-N~1X zmaikR-&o9D7mAA}jR(6s7h$&G+Nk%u4nr)m=~$#Xtq$G=L!unm%Gi}?I%Xx>c=QST z@h|-Rh-j?I%>$hPX(|}s!P$2tgWeaxQL9=VdS#wM5cSGzIYRbt#e--DKThzmp+hlvX#=04q9u$2qWLrKTUpN1Z32^iFj-X2rXKYQSia^Y z7UyvEtmy!ft`C@7sL|9GMH1Pahnv%NDAi;Sl+29A(Q+rze#&%aDp8EV{*zgX=@o7s z8MDb7hH#fDhKir5vaJePU|`!UUTU%ehx;`1NBeX5YR;FGx>xY-rboH#4Pz-Lcsz}+ z(4(FyE*SOiF~7BFA9grt(;=b%m|A)b^=Dq?HWuhnxzm4a@)A{cXuv_zpLK<0ACMxw z@A(j3U_&n#5^dHCKy&-Un77Xf7Fn!kYm~Gw#b01N2@JlYj!syAJf2@4Qx8|$o}+xK zz#pnq65h$@c)13_i!wl+9o-OzhV5P0R1riM!jIFhmjlSjWCDI2S%w}O32_#Z%P! z@h58by#RIX53swTj_>MOjMAG^xFt99v3>JatbSh1uR4^YFy@b4Z>0sN&Y0l^vT}bMaK-B zQJX1xX?z6JW>26$$@1`~`Y^Xr)sf-@gbwYBIbx&K5cr(49-iB3k&VSA{@s!raIcrR ztt$`?M(VL7x7T=UgdH9{yA|($Fk-D?pV8&eIt)xd4DL%gO#SzQmp-;j{8T{!hijk0 zypvu0_IO><8{=S{_TdjK%d-%F_xZqQj@pJg_U`1ox{DKi+XW?7$^3^|PvNJ|NSr8L zh&!WG;8n^gytY9Be>IM#i)E2~hRPvWcXts8LFT{P-rJyAxoX(tTh$!BIA9emi`vfVhy=Q|G2{wGQfjKR96hZs34(yMW zVe!MO$*Q&qTPd1%u_K2T{sp7srtUF#iW(?5d` z?u13tA7b^?R!G`uLI-Ag<8$t!Xqc}Wmv!?LrfSECM_$!NPoujyO5+n2DQ(0Im4)K8 z;vFbBk9jYzC1S7W^Kj#RByL~l!xwzk2G;-$m@+<5q%~{-zw){t_%9ILKw)W+n_tWY zJkkQIaA}xxLxMez8$&@qckzeMS+Q9=0!8+d*Yfv%Rr2kM?V{5^D)^G4x52wE9U}zp zj(4aDr#GZqJT)qr&)M9BGuz)|)1230x9ORpt6wg|`*E{S2usP>gHyW3pnr)sWe>lIp;OZc&9(wRV>NMw**8(;atk&`c0RsSZsGR34x?l53;0+eH}Y-y zZSIr#JJ_~yI5*PpG5m|zPU~KJp#13+bbHqZYMa|&?T#Yxcda%ZnNip@c^0_7 zFXWYu%%uCl3i!B3@pnExLIruN${(cHfN={K z@vlR?aNcG~fn6NT*ImBK6&coJ|CqC)HAzP}6VW*QAm5oPX&o01SDZ|*He>S26-$vlt-<|yZ!ZQB3og)NRALE{7 zcH&jNa#&II5^OvS&?@UJXgV*$VeM{MF`*Wx&i944W&^<2ejk{7PG!x>W+*i~lRwlH zg(rrJFjrfORvyXYQw3qj%-S4Swk`qZq?p0>+EE<)XM)$P&cZ9B6+({X3U}}Dc=r75 z60x5?;UN3tSh-pa<*$_Bxeeo(`p<5-ZPI`>Yk!CuWxRQ*#v{07)jx6X-0>*ew8Y_y zVF;Ug2*vxmRq6WLTzFb5!%WBjaQLgG%p5G;V3EpNZe)!K=(Nnj|569DRhr+#Uv2Mk zHCK1pFP!m^w_3Z0zfpdW?=u=rn=bx`T9;N}pkfzr%!6*6dx=XGU&5&)au~8d4m%Sb zVZ}&Aa*BzC7{No*pSc_N-u;M^JJ!QpA-_>ORhjAT98R739Z)D2ixEj{9O_PngW1lz zxH6#zKd9t$;!1&=vGF7>jCQ0Xsgu0VXbYzO1vu^AcIb`x0@Kew68+ry04jae*}YVM z{;Qsd(bEt7t(tgzdGwOFT$n4)eI3LNJ+H%mylBV`TGG!oE&PENkA`E-+`q6zH5oV1I~XY$CT^ivA@`a!jA6d-6W24(Q)Sl=F%~A`75|i3Pr3eEgpLWH|UH-X?(tc zHHmKR;CyDAF_@9g)hTY~-v4ZZ#gcpJ=@mN`v{eU3KD^BLYTpFyZ@R4Z9N`+Nb#SrC znVAj9!}f{_2!8V#kM^9ygJDGu;(Nk;%4n<@MB~`vTd&b$Rxcc0CyisY%eaj?f4QY~ zH&N008<#(|57rs{z^q+=G2nCy>bR-V_-z9z;>=n$VlxjH^z6_m)R1^36F4!+h@Z1x zigp%mLFFMO=pr<-7@#oo&v>mZ{+hw@K`iwwZ|AuY+~^^KjW1 zBX}?^6GeZzq2cWfNbc~#eMWj>Zyyt`vA!7}hV6pWdpARG<|*;|c|#~Cdkrh+p}ckn`BrxJyyUM*7WwKMEt^or=)KJh6&@sd)+(X9#Y&yE(XeQjU<_9ZC@=s?+)|dDQ!xS|+Pg7$W`y!d%Za<1Ym2BCcS&!kEpEC`k1e7>5gqFo> zQ@5!;#{BsXyW6}lXw3ku*VJMjxrR9XPz}E%;vd&k5{<4$KjDAPgJ^%(S%JYE3XSD; z;sf7W`N^JkbmgrY3|8f6Yq|{SjESS>NK^4+k7jt_`-s1$7y$_dejvO47&!k`VtvAQ z^m(673p285SdTRJrQ}lL>NqB~!V;uoG+6nL14)X_e}J{zL{e67!&a#RJ1B3*$4+nf zw<|uNVVEgRx+2eh*qDQj@?{pFGK9oO24YpM3Ps&pL5qAZqS@BXOnhM;ye>$@nSn>e zlbgy=Ys&;w6))h#iF+8EQ3!eq8o7?K=UMxSxaP-NbYw~|FIB2eKPQf0{A*qAl>JX^+rJN3Rv*T7 zShMMKVrT?6gU{&R%mOTvaO|c>yhlkSB>b3*N0PNkXJsev`s^Bfs(8&={+kJ*=QS`# zIT#n@N3ig&OVDbJ;yuPn;Nc{&3INE;H4VVb5FcB$Q?|QRq4*v7;$%-H9RQ2%~VY zn>N7Y+Hqp98*Qj*F^&Z;truq~Ht|n z{4pOE`y9(c6Bzh)f-4^Y8K3<=L`|6K#9Y*bVp!^Z;UHct}u55vEtCPg0 zGBTKEsE>+fQmiR!9P{v>&W^`l=Up!cRdfq{B->ayu$)uH$CSrYsFgE&zI6b<-PjW6 zF6Zdvmr=CTu?9};j3uisOA5%*p~HeZYTarRd=R5To;jTehjY+$Zw3Csl^CP>o9{S! z4UcY+VBwqJ<2!?X+_tSv;NBx_Kj+TxyJ%0%#cd*aAzQz(xe)jM4d?GIm8XE6$3de% z7>E8g$A5Fjvpb{u@UK=i+WSsp;b#??LF; z_gfMh{p*~lyv>h}H4b8NOS2&;@;hH!Atssq+dg1PIclKTIn;=seeEsWW6SyB zj<=zBem`G*Oqz;I0%-P`#o{Ak2NqVK0d>RYgJX^b{U>e_#b1~~N8Il6jqh}5GZEV~ z>i}9SD^vVFEz+)C4B~^6S;?LtdU+)SuT4&(Mt4T?i^kKeHL1*Q>}FUqNtb?ndB8yW%Sx$FJ7+wjV|{_v%Ykpx2ryu;+zi%4(ugVmUT{;MVSld$xu2oZagK6#-R3e zGfKTOkW5~6bAkTTMVUM7@SNI7__pl@y(p5RlJlK>pHn=O`nsKMp5)Jua5v@G`u5|2 zS6^Z7Q6Dxj>>*y2e}@hgqbi@S988M_`k~cz718lOG1!`Ii)UpHP{hgMm^oC);W=0H zwh!Nnu6FlgM7KA#=#^9sR}^w~-+Qp#JCy_+Npd1y{HN(o%u`u?XB@NZFCJTWFcvjT|zPvD} z_4prDi!#BB_n~LjS8%jr2oCNz2>o`&@NULiVC{|EoQVy*OK&F3`Ef)%EnH3L#cA>e zzAKr9skY!3aDk%od|b9COU&QjK?Q$Ca>m7#T2EFQ&jt>ZY|fz9|lxE7tIx-da17;hAd`C}f^Y@S7&uqW@qV6_Uoe8UZ0 z*Dqlig-5uQ!N*Z_btn2uy~2_w5Bc!79I98UQmo-}8mFtywUaXiNf;M(`o}l48EiXbzMu?eSyp5wWS;W3LUnV6mMF&C5_G6bB_=I zVZz&~oaB3tj7HJ5jilLrj0z{-#Fw#q@p_0247M-i2dpZ=0gneUOSM+0mAwj9t0(h! z=lYV<$o0If-gZ!vQ^SH!&qTwcXHu~N&@;27O2y-$_({@)6$p&|kW3 zWPt{?+i099m*fVf;D*%)L8>GgjF&^>9J;8yq`Fm}%DS1gZD$xh(=KQOf@)R~Ims6chKL zQnv;wt@mNea+Y(dUH|aicR#jv?R)$=!~~4D>9H2+7`%A#4*Ckro%}@`naZskyqr?N z7vGKp@8Alw7F^nk4-mfCuF8ta%fMEpnjg7nAo2Hh3pwIYiqu#_AD$1SyJiX$zIr7; z{iP%x;)6(W%xK<1Z8RFWp5~cG7Sv@w0W2}xm%`lKAnYCsx@nJOky&|1y zw^X2cTP9uaGNS<==Ww@TBP1>Hf~1tusHD(Ko~cPxJx z&%$??aV1(~>3IAwHYRxw*K^2}8IJR(7K>;mrxjcg`e*}K%lOm$aSf32@21FWUITdA z=2Dii1m3*uiaOi}ym%}d^RhN#&4~e36M_$j?}dNoQZ9_7L3@2zruh->$AUzdC#l3D zY+7*S`Alf<5Ln(a*0kb`KJ3oYpnG*sMf(rT!v`aeLzV0=_~#}ABPM+U|FRMIN3$Mv zhM9swW799L7iu<#Hof;K`kIDil>Vpp$uPqYh-A8Ha z)?n72Qw#wOO}*hsn{u1E;w?N&n$=5V586tEo`+ zFuDG?L3*F|QcL(lC~TCcs1XUYAyME{-8cx1&#v+>8q?|SzW^wG(TLj@M=>8hhq`Yo zJNg_Fq1I$?@>d;#Zu_s}h&jJ#kJm&T<2|0f@w1su;uUN;Ekqg)r3dR?A1(3fw6vuuexcBL1*P~leHKwKYl5A(wDP=fziU7NQOp@ zIZU(FM^n|Rhn)ZQ@2Kk($DTRml-PMbh^qpRx<)1?aC@R!yK(vTQQ&8{ z51j_4!mrj{6#nZAnjcw>e;!@NyI%wc?S)g~yy|9J{(TBb>}}=)Z<|qWN;h|G;X^dG zD}h~uOX#NZI$G5tDeMdaTDAH0$*JEcPal+AF*}M zaaoOVxs%`U4!&2tTKuX|jcR4dJU5Nj72GQ>CJ^WS;P2}H<<?uwE3zH;lFU!iSI5H*xPhV5sYF%g@%goHG1 zSM`5rUd`BpdVplvR)pgej9L@xcpKl`pnPD9f16#s?0@J?{LSRs@w zGsoS7fTdX(ei@ zh(}NN>FD?13Xc24)BDICF5PP|Rr%MUU7w`T@1MrU2|ayxu7O*oznX@sd9#)xX>LQH z3-dm0Mdtm*sPyd^4OumYy|X<-rQ_=%VaWxwiN8SaXZ!Mx*2;j4j{~~rEyqPW7SUCM zzqmR5D!M4kvo%~i9ay`G8>uPe_uYQ*z8!ziyU&*9J@3LFdN<+2mv%fY-w6@=QdsT( z7+>p;hn?Hi@Oxh@_cVB$;Q70N&O48>m9`%$GG2e;PTx5WNdslbxz>@%e2o#cdbL#s z`WJw!&mn&P!coNgAH_%NlQDbubaJpyq(|JfxCJDH$nJk|GV2G$mvcQC31mRD_c9p8G~aMQJLM(NO3cN=q8Q^ZVal zulGIYdG7nVKA$;y%!;5KcE;y%X6tGzGH0PL_BY64VkJLj>{(ZWCeAOx{T&WgNT>oKCHhu z3Whh;(W`Sm@^d{}wJk%;iXStG%$8;*$EO+^40K`1K5g>*!v$Dop^GnWdy@2w&G?Q@ zrSI43qu}5IxM5)i#R~)>)$lZl`p=o@KiSCfjc+o|LT}ka@ty3!??bRAVh;58&p|Vf zezKr@5;N(%Ijxg<%Xm2PacJn!Fcz&P<3t(1&xEDgScee`K)9$e1s_jSRzyV0=7I}XGT?<50fe}V82$0k;dq-tVo zuztgJwsmg|?)#vD9YZ{rQoR#T{kRK)^VRT=%K&>%Xa_#qv5oyT-Go*d#S+7oA4EZR z0Wpkx!nWVGM}NoV_-tn$$387a!yEbVAu^Ii9hax0o6AX5+Iq6rxdL4L<;B^n6=av_!P->!!D9-IEikYUyW+I0^FTpzwP)yILxl=2GL7u zSbO9uPVc@?p1iAp{ej2m!VpQ|X>9}jtDiAZ;JK~)ynYfaa^B{f+;hlD;ama3QrMW{ zM5kQHhI9K{*+m+%_|;X0UR14ObiPGn3OAF`?>P^HJ)4*r58H|PH-3`x$cD0qoY}vB zU&FUg&Y&IK3l8~TA>`j>IMH{PxhdO#`nK=LXXz94;^~KEpw^DYsx4tvPp(5zdK@*u z1iw}NX1-qHm;}oCOp|B>GsNZfZko*Ca?>gG6*rSfgOLg?x#x}RUO(a3`BmfsxyyXL za0F9CcfjuEN+LHy5bv*+!M->?Jg<3;S=V|Lev!Xqqw*S*d7cC7JuVWN!h9_5$OD;) zJ_b3C`fGo6I`7#QXpKCIsWO#p+=x7!aM_72`&*G#SEHZCLf*K2HQufmur)5+OzB>C zoRl0$B8wDoo?a3f7^b7ZGJ;+K*HP74u3>s!9je^ePpunXuq6^#VC^#vDz-xhcdxsM zZW@$Me|-@e&B{^xbuKgOTNbvav3RCDf$RRSCRu!^=-fq$jC8vVZr_qZbd~(+w%z;< ztN5fi?#cjMy0r$UEWb;hPFagyg>PYOTP&&1YbLVyt#Q;qgN~>A;kr$ZQ$BmtKi)f}gN+=1gH4IUe5A?Gm7!$)l(DcVL~#4$dX?m)@-FCv_DZyE3sH zt@8}&mz7G?Df<~N-X4rkcAUlSLC$DCV+HNMa1Z|8KZY*;lDIwp3V9`wO!K05(udDe zZG#uyWOuCm$oiNbrjllg_|f1p{g2DWh7CR@Yu{&p;M-o76`e`D?xsORpNZXgi#JwI zM4<1xFckkDgCo@nP!xgKed`bva$bZBWVGn(@%`vZQI(l(Lc+mFC=$2Z1pi4_$|{Y=us4`JnslW;#Zj^*bQ z#O8}vaPo6&svA^^LfMwo$GZ^I@+V-Hi#S@I5VxCUe;hqm=i`A&VJyo#Lzg6!;qJ=a z^v8$SFn1c4^EJMM+RD0UqJ9+>9o4aOF@eGxr%@~O4fFoyHjX!6K-j(B^2BlOS<5+Q3fW^qwGerE;4#9pIEMs#Q6s-Y2Lq7vWI(CE{v;#(4ibm z&3;RshRne8Zk43{RVZd2Y(Y#DpndaSfNGEtd38ybI(YBEEw8Il>PIBiJ{Uwx`{VJ9 z-#JqCSPJ!WgRm`p1O;5$=%JWb#F$FiogHiEne^Wxi_!*R~_V#jY9ta`9WG$=2tEP|S za+qP6Z#ESzLUwgIr>M~16EuD(0T;K8p`+7PTHwcd`{G_v6{Q5`Ys*envcUn{Mw(#b z>vO1If@D`f6l^2Wbne$kQunNz7@oDdwkD{On5@VmH+TuDlZFCP5_k({ zi|JEM=YG;~O`71xICQDe>G;`IziqPu0zB75fEPb5R|z&W#jw+oa*}s13j~0TWL7W zRj9#DeuZ@EEj{!X=qE>unsNQxF|uRgE63JU!$mt)(8Fp7oiZ1a@JXDv-S<8l;1NlR z+;zBfUlv##U4qNFu6M}ZE>@t4>k1yRz&CSi=+|Nj0cWm4yK*MxXAJ?rp$zGD;L#K3 zN8qn%3)J|mrNd^$%oV8%#QSX>H2pb*zJF_ZxgDGz?ol^c@JO31=loem7P?}%wJy4c zPosfMGyZilq90;SaHMDxKHO7;0c<9HGRqb>9Fe9jHg||QuZH=4>LT-Sk0eSIT9A8< zO^{QOg*WO8vfvTVxhcDUKOG|KT=nUp$2zVui4w zu$ZXDg>ZhBK74gopB4l+5_w5+x^HGD(Uh7^vrjApsdqtiN!46BUtSSj^E)?uEk0|z z^VGRJwmL#(Gp4(WFAgmm3B$n~^F*?c#uuUSv>h86K^ z-!q)teZ38w@)=zAvxc7W5+I|K-`eDzh(t{blS&xT^o8lApL6#@`oLuiaM^iF)xyKys%;y=3C+R zCvWhUaSiGBE2okP?{Mp`W_XeA!zw2CYt)K`fmQEq&CectIh1+dzWOzp)0o_cKUMyP-VSkPORqG4F2oKzm{& zZk|#`U;3AV^v_KCSpPN{jKp)ht;38ya~FOp8Q}R+emwoM9A9Z4B)8l4a;~)x*w}gA z_WP;^a+}-HPPxu`2yaL>2u-`gJ7cWF`?E6`_!cHY>6QpOO;s3#X5>)6x8d}=krnv3 z+c)&|xYF1(Ybf)&zz%2#<6no1_|2&prPj`*P2Y~9EL*}RA1H)U&ju*+5JYL+Z**nb zQT*6s$~k(48?42g@cXrF)R?jg((T&eXHGUTvKGVmSH5IYMj;xf{$h58&4s@b4^X0$ zzkxPg$KLu(8t`l~-Qz5W&cf!@_kb{5Zwx@blrpCD*A`gku(_cucNQ6u^~8-f?-6ZV zcw3!P;Z2~NokO4iN&oT`j|n%x1>+Ai`E@Vs3yCI*%bJKpxDoE%eTIe$n4xCw8Q84y zgz=iLOBFl=c^MwHH2k$1E-Xy~$$@B+U%8ZuZkj{iyUN2d`W=s3FQj|)r{hkK6xyNM z#L~TT-2GFkp(jL?rbKAi)d{^utCVhBu`z%t^u5e_8U(UB;o-D+)CGkE7tl?jNoZ(L zLq50q;U!US7Jk?pE?lXD`5ZH$^sf@`TXdPY_GmJLQtNox8`EI7Z6i*)%<*+}T1na^ zbE0&o1NMq)cBWD-ygy1esq68>uqwF*jSk5mu64ytLu#Gtkd6C4#{lD&Mkh2R(IcJUOGcUY z*d&l&O=%6X!~Zd?)Oir7{|Kv>X``W`BXjPG9yZl;JF&5oXz#2>!`3Na$*g>E&JAMJ z@|^9)f9hfL@-7sJ)@j(|$NdJmPR|X`JhD?~88#@L#+fo!xFcx;?j4!iU_6#bX38z) z*?ql=+OJkLyeTnh_~@u&=j~QRv$u@1cf^Ltyvvz=)c7lk^|Zix5MC%@_z+AU5V!aa;&gO}k_KykX>O^Ar zEonS3w+t6$%hHteP0(`h0Dg6ug*N)35L%Xwuk^&R{c#{n<#>aP2Y{nwZ5i!ZY|*G)9} zoURUOeAzhl#(J_iz=r$2^3j}&JK>1xIgn_&2Rd_%QSHnjPN9GKK=J9)&6-5LT8ziV{1!M zcwGi@uzJd*MZAWKx}A{qAc!Wl8dJA(LGXmz`|C)B6P}YhU2#MJ#ci|^o_K(pK{DwZ z)uI8`gK*S@!Gwx2(j21=16&_^+g%k#^v4&b@{I&0t~E!#;UYMo*$Mw{oW$nd9_W$h zu|u;$dB>Ni;f7IHTD9&VBYC!vh>Pt(-koU(Cv?GK#y|F&g9|+0k-#+HlOkD+E^dGR zh`iq1M?Qb%xZB2+cMUH-95eMjXrEZG^lZn@zR*rs1>iYSe*w#8<`Z+$HlxNLk4g@cx+vQJ#sEPq@iY((@UX{rf zF43Vp#e=Z^t2grg`^9uh%ER`Wi@fg5_RLm`45%uucYQgHAg=Oy3$1vWKF(4AlQpm*$CY~QVm z4~lHjvGfqQdiY}7MFvB}m%z0_DVUeRIgQ4p;mnQ6%u=y9Jmac~9_A+a#j%6!;T)DB z5659W$1Q1oIs+sBisJdpa`bkw3mBgFqDD7T@PyxN(y_1z9=cy(oHXO0dej1fB5u&- z{&G01mINoRPepajOenA_!rP5I8UANxq=WZ}_sc`x_WXv!P(w3eno}R;W7cAn=sB3D z!)05!jQyHB!Sw101}u&_*^F#+Wv_0FBag?DfVouwOE*uky-{aIzg~O_hk^=Gb^Z%f zS#^@sjeI2QFN_j_s(40-dqyO>M38Ah5%lD6rtRqBV4OPL5l1yyaQtY6iym|WhR>m@ zdri=tcyr!wF>?L;J?6!Y6I9H&l-;(c9zqw@*pB{24 zirJox%fk1IKCzz{SwXK4$4g242>)#NaCr`3b~yBqe6vncRxH`@arHZC7Km>W5X1}P>1@2(8Bl$62gvv+d4z-c7VZU^oAa21UX8e;yFCHQ;&MFX&p|v5OE{h9tw!C4w<7Y6JW|rNx-Ft%23^g;+^1Zr1&>j66R7 zi`FK51h@Mac@fFxkP_~S{ERRSy^+oy%TY(I1b^t4cO*aK#IYhy91K0(N#}-1RNq{U z91Dl;7=#CrW$a z!=eT3ht45VYe!J~NETjKwq~=tr@^+=Hrmn}LPV|3!`)NyL}OwjeJ-0wgw(jR=u|Ia zflDxKx)vV29ZYw=+ysxsPf*q2r%ar(2Y#$SL1mqP^D-(NX!s0{iDGh)c{nx~dKSl^ z%3*%HDNAJ=mhBhh=DXSS)Y)mU;p9wg2TStunkj@%n*rysi#GEWJt-=I#@3$d?~oMEAm=jg5i@(a*XH zC1F27Le+?yJJ%E6_=T`Jw41(rX@s+zIbL_fE#`RVG}5ZvNDq&vf~V$7ur-Ll+_kew zbsB}uKk5)rz^-O61ub88LZ@^r)p@@hum70AllLEhSkvPmQld-Vw)>&_w?6tz`y`gH zc!fs#Ga0?%NG7He=|+wl_w&F7=w9%Pae5U<2P!r3>dN;JFV@5UCV3b(!F3p9^ieTw zDx{=WL4_N~PmsFCtk^G2w+<$wsAUjVdalFy>Ib0O_9gS@H1yMXMy-^yVegXdaB}u-rg^p;m5ETp7mv14 zouD2J+#tt$$a?^gvXjfG&d0t70?7C+CWp@oU|-Q?ngao}R!n;h}%&crnTh+9&)V)%^J?%Yw-&{cFGg$nI_cloEjuQ7Nk+) zY1o^!8hOjF5c!_-O!bSuaQ&DWg#Yblq=+*^!W)1J2qHJ_wOzA#g8i{Rg?2tXLv{?7 zLF7FVEc#)AwW;^WrO%-lcsrLNiuahMFoVur@R<2TF9B|q#XDy6Kxjb+@Nal-yLsL3 z`pcpjBx%w*{QciH5M66WCkeZw;66RjJE4WTUCY?W%f76RWfYTfjt2pki(qK>Qrcwv z079=MLCIe}qEQq#>?J`Ob4OYpoHzT-FX%{JE%cSeIJ!m4eo_6*ScF1EkKh;2pTcWnZ0r>4_^5TsP4Q zUZr1Pt8zCmzMGREFGhy0UN4E?2CSiJwk8Jp3n9&4NE_QdK%`ZHDY@Ipp4`(${&ezz z$>s_+dYTs*n01m^91VszyNA%>o`t*QvuwuS8PihJ5ZkkSf$XQ?45qKSnP?R@fPC9s z!hcklYka76+8RQoZQ_Q#Pj#gAlyJ97uS1B6H_qr;P9@H|$Hj9<+l1}RUN(u^5I zJ9V5X+*Jg?Sc1>S1V|kJ%9iIEk<=q+6hg*;R<=o@KI&^@+2ouu*|~XPlSC`%5|?;t4;Rs0~BhJ3nHx)C^?Z-$Fz$AO0~Bggp{H zPP1c57V^1*) ztwza;h**#aEP$W~jZ8v7KE3c&8uec7fR&GO@WtB%Z17r1@69a*@u%Wc`_5VN*vptb z&_{86(P4<=dacjjoS<{&C9{9t8=%}JW0ZQdoK6`1$2f1;LnRCNiQS=VFp~O>Xr4XK z-uB3*N_(?l-8CIp`89$1%sLGE9cIL)>JNc>QLHR&WxvnP!Jza+SfOe}CudcF*vDfK zxuTIhzjYd#Hk~B1S|w=7R6hE!@&i~+-43_+U$pIVdBJ#3oP)c&HM#js6+Y(rAd*)a z*z3Jdm?IynpkDVAV22?3cTIxtwZU}XU?P5TA-Kw1oO%{+r;CERp_o4kS1afS3Ut5P5`ZVJv{<;7geR|S#uLRN8C6G{Ezgv#1dbc3)mDaoxS8{@7(wUj2l*Gz$N zr8BTl7gr$#;9Wb&zEY@yW5d+Ya2+s*-zHkPp0bK z&G1Ghl~^b32EY57G$Xij&BC6}}D zc>HquMpB$)R)yfK(~{ub;0^taE#Ps#fcb26npXJ4qHf_#6laC(Op-o8oSYccc81`* z{uiwG=U6aTROE90r*WWy!Psv(FxOj^K0d5S_LZKbQ|`&)>$WdM*tCNrr&}_8@(K_@ z;!xb*x0ou5?0>KuP6E-lG^{XPgX*Gov*`%dDf?Mcks z_The$1Q77|gH`L!k$J~G(cVW1;urjdTOUrb<)=KTd{+_^r{_wBIc@?^x0AQbav8a( z#z(%)>;j{Ix@7UcL}Isg1jT z=?d*+V`wMy?%W-!&1G{P9RZaF zeYI0?Fy$u=%&sRT`l&Rh;y4t|+YI(HFY(2_N4R3cQScOv1inM_@Fb6)J@9alyyo&T zVS3?c-*JK}rhkUOJ4rb7A{)Ql;Y0t&{AbYup|mh%37?f%SY_@72v}yUym1jNNdoYu%@vw$@4;)Y+YX^{_FC`dr z)ftvW72~2~PGs+DKWuTUX0spfCi545Bp&0jbmEo)Tx(s0I!iUF;fe=fpRI|jyk3&| zC#K;y>nRw%^C21hCG;W!_zwETl@`nOicxKIS~%%UWPHRzMq?L})3C6h;Q!r3bvAF9uV z+N^%ZvL0xw@lX`_G~!@vx)sy)Q`R;zei>Z#Tm;$ZPRj?LvfE4|cn7Eyetvy|ysWql z`(A{Awq-EBoTdwVR~@TgGPDs__a@l*s3U@Pl7!o&2>F3+L?_r^k_^74<}1- z=W25itbTls+&gMQ`Cm-Jakdi0dS=@!l&*wHi{xn3Xcn<-okT~PQ;B}20REjD0h8AM zv(a$Q!Zn9p!K_Tqx8&&q2fus-0l(Yu^ub|xkXFxT|D3Qbu-y%nNfYGba#Ir9vVc6k zr-X9%>p=ZZAn~wM#SQIBbn8-XH_{b_s`i!`pMM*^sW{S+kHR?VfCLS(Ge zf}*PSAk>{qe2h|{aYBj=CLhByt-#o=%|q#`Qrnm7weSn)fU%#d&lq-`1-|7I^+(Dl zk^T2X8;pwRVE7ctED3W5?1wpWGyE{pOT$_!YAIa{~E~eqpICIN*kc@vz zx4C3-jYx&Ou@(K}k3Kq_qu!yKs4BnXh14X#!^YlvjF<^3RYrK*_$03_*a%!oDoA<4 zOms}%i|==cQPZJ5A~AUd%?XQ#=!#>syDgMe&7Mu1FO1alADqm1C~49htixqH6kDECI&nWy1+Uf1*_V zo@B-|BqI#LX`44n+LrKqpE^*1|M&?n0APc~bXX>mh~G?>v6p|$0>52}^;wrPh`LZX z+oW_Fs*4%?^QRkjTdaW4#nUO9ok%}^>4ijx6rOP-mx<0qvPzwsX_SSsbKd?VO1>xX zgtajFy=6IA4m#pIsVvf_We7{T^WZiiPg-hn1Rv(6(_>w8q0L8*s$Mvcr{_7t-tKc` z%5?*Dd^!i@uK#4RPQ_8@L4OGRc7^1{d*amlN8oYTNu1}i14K_p;lu0%rYR|eD6fA9 zB40P6>wkMe@KzJvage8SOGN2Zfnb`cw1Esw=bR{lXP94)d)ThZ65H$XrZnsDLH0(6 z7+r1IL9R5Nw6z+u!Ie|^sP$+mhP&3np^2Ad`myy)dh7@CWgr*^e(~dctrO_N-I1!T z(n0jv9f!?MR!f97cI;-O+fTD9&oto4^4+9($pX0YzK$#%NT909 zo8fbD8WAy4qrOF6v?swG=Bmu41@nJH+v%5>x7vl~+i;$jhM(}hNQx4>%{=s($}A8o zg%AHN!_lkHSmlYIj5N1DUS`xwPKW5j;0!}9Q^0vu@MZ%*_Y-`2Zj$cdL-K^VCw9S*$xMv(ky zhkC2c>QwVhEY|NchF))u4ZG$L-jQ@c;p6desN@^m_pKt@V^O5HXbFu-n+Lbvp26r@ z#aJQzl+`aGq|bZ<+gw3Fw?>}s*?bEwrACwU25$7X`c^jkj~uIz%tzmU+72ErKgjwA zZ9q;sgF@SFvT|Y`jgYS*_pRLU{)8~)yDq>6sCyu)rZb;XIF8l5Meyf*Fkw4`>BTf9 zJT|3{9MBiEU7{ZVPTv)1vios7ldcKVxx0~FMlVd!yGd*&C{=hHL3v#7=(b-pWKH=8 z(`-yxk9Rt7ZAuzWYu6(}3-W12jVDQoQ^B9*1w_cO2YwgU!>li*@a^nVwtVh+reT)_ zZZ-olBS;(kT)4T<=e6LyCYyxS`NKaO&RLK!oqW3R5$8BQ1YXTB2+R3m=Fdm0U*-y1 zsc=uM7F9C;5y+k0#MV{_@z!>4H3;btOZ{)EQ2CDyNLtO7HU2KwdDEwD5g zWJPa@;$%C0!pkdwlZTq%QCv1%pX-f3EBuEVW?;Y$>JvSC?kJX*H9*anGIL=`E^%oNp-U$$sL<4G zxUi?1^p7TBUQ+_v+8Clh-%@&I{R<)?1$gHDVyf70i5qst;IuWsUd!e9{pY)&zA_Yo z)lNbjFQz`=^C7xPavtvZJA#KC0-(D9EjdRKQ4Mf(?~qH z@lC;R&Qo#p)nwb>wcEgW#UaSdxXMnIgV-tvyN#4IDBHRznt}R;5ICd}h4+4n zkVTU`;nTTWxM@`yyjr8e&d5k0on|dI$-@KewlMBu-4aGJ^m>_}o5q;uX6L~0#a;H7 zVg@ih9GBT^H>7tjLfwiAR$Z~5U^9SV_&V_Y=L<5MjBi#do~?v_9g$=^3-eZaOjju^zuX7v}m=S75fN4-8)W zNW$P09`aU()UA>rpD_!C-U+ko~?MiVPr1gsHUU|_=>=PzW zrW9(+Yp|*=8N-AoBN?~>hI_YSpTkqqo6!cNi-&G1`Kow7^a28!*oC;W4q@GrY*)9!Kx^uvV->= zhRJl5KVUT~gyoD8rrL~?*c+2+yJ{jdC@GWY+Q%U?d>rkYWN7SC7a|sA&iv#2OdpCO zV5?vo*^h--9ls8r226ov>GkB=TU|;?<%GR+i(8Ucqs~lTar@&vH=rrU}tJ zL>aF_Kd3rlP8XQQz(3b^l4$UP_d)9_C^;Ch6V(l5*>-PkK3hS4mxt=erV7?$Hh@$%P{QpUC#!~i$Ye! z2ckNyirF@GGA1}g(N(($wNq(?i#{y%Ejhy|wkN>V`goN6FP+|9CQbf&Y0=`0V`RN{ zF*fy|C+VvN(8xRiTjeA%FXS|NzpsY!IZ0!%LJ$;O&xS+Gw-U7$Zye%%CznhYpz4|& zw&21xX2*Cou|E*bYn$|#WXrnYS>aPq_QV!87%5}glTsVA8~k*=Ez;5X8D!!Cfi*Ao zkP-6HW}2WH3JP)_wC-cLQ~x@3ESW=%>c@bW_71-OWWZAR7~T%6U@j`ja(lw_RJl`~ zm=!2t-CiH+#_hE{>xV$ROM*^S=%WR$CUnL^XBIAe0fig8=$EN8AwZ7eORMiBzgvtr z&TwN-dluq*^8zS1B7{q~ZU@tHIdqZ@fLQsN*uv!sFC3JiCZ=`NPF(@J#>=5Be#t zVFGE-f0Krd8O)xPNZ2u{4Q+MKGj&5*?4PJ4(3_f!yKeS#^R-dthrw&GP&1H$gyJXS&xC=^}w-V*)i1A#{Ik46U zl{2`rb-WU@xkH<}zLh}nZB@LR$Kx=#k>g}G*R!=AN#IxEL(d!p`l#p;`*B7vy-~G* zzTa&^Vf_Kn*mRR@QZS;2`A*pW;+U-^e3n$|SsRQc3gG&95xo6+h`knCN_JJ}!m~(2 zYUbWfjF0G0df+4XJ}Dy~Ep}sxxeAPl#pCaw`@|ud(8u<_;A)ctImI6d-=t(%KbacB zH>ghQqppG@*XLh)stGjQHp5r$v-E3fhVX2boik&DZO-$z?lf zWR?h+$DQCca{iLk`B7|+aWR>DwV&xWj=*oKgX|OoZ|rhaqh7aC$#XHz%ljz^=4Sl| z1Dmdb_YIrM;YXQ zH`H%_NvX)3r|bx((3iu1$c*J5n5e_q;9tlwBm`X9s$zAjouUVoLp9|5V{0;7y&VdC zW~0KwQ#d(O1K&hPV`DMJHSYrM?D*olMN9KOPleOzHD9j(8%0=Knd=odjTtFW+i_q=uT_E{c5$!on zN7da%5)i0B&K0@9&~jxw-l9#mI!cqD=e5Xib}3#Q*M^;a#~4q)SW1oZVE^${e7*%p zXv=$=v;GtfeQ+A8Mr7z(iyvUu%EhDqszJcoO8k;^6KX#Aa(Sd!dS0f3F}7ZT^}pia z{9q{Q{|=zA`wq&LUZ*|=<%ot`$=@%9_&cBhdV>nE-H6LhM?WQNUR@y}DY;lRz_7wU zpELDp;;e0P46H&wc%Wc_hjmU9*;CpuB(WM>Z100ndn@RK=E9|UZRC}VHLmq+BX+Ms z;kc15JZQ~i(;Ktcf)iV@-W-8le2JZ*5Je;`4})mF4jx>yiroAx%AGMfK&LGovkyAa z-$KjbTY(ALBJ!80>qYHHZ{VyU#|on8D;#LuAF`1$16C=hYrcCTVULAmpkn z`|FM_CTpa#*Ine$hPo4lzy^q`h-8nYg@Ai$IorTxa3{>pu-cW$q-9kauHC_9><)B7 z+E5^@{jwAme)WL8BeLib_7tLA^)Qc`!;y{v##gDG**iOh3|+eejeiUn-w`1i#xF|` zwCF>?k_4jlNRNj7(jYmPVp;kAL}DSYP1v&*Wcm+9+;!s%tlr9X%riT^h z-J01)Qbofs!hVF|&nRYw{(H%@4&WoHE0!LBTEV4{6I3D`Jzlcp=Q}<}zqxDn_*5W=*^Pz^BTM z*poKMlwXN~DFf;_jmxQcNjd|+@e?*#;3;e`-vs*306K>SI8Q(rN%s|?|I`h!#O48U zoU@H5R^-6&6syU-ejzFltVyLkiondw9tx`M=#sZNWUXy9RUIs2YW)sE|4d6VW5zGI zv}ps0E)XL{_kwWyuD`t53l|ZkT2X9GK1OV{j)IX&EIW925jPL{Nbc^a1$cb}gkCj) zcuxoCq&PxmY>g&!rhaCEzMSC+_pdHKb`i6)+k4D+(9n37V5hhu|oy#!g zf~fLy@;f$^>CN2-FRxLWSj~0k@|xN1i@8KqXo55yHwNn(5x8`-4>4p4m3k8mbqQ%O zajTa3zc<&|$C!Qy)+2M2dtvfMBjmd8V2~IC7og64T%k$J&K|CyDkxCrF>j7t(CAX64 z?mf}W89 zu)iB_!2?MTY|pv}HTUhPEax-mf06|iFN0`}=wWPJW=>CR9p?3g=K!o<$sE$0M3$Ma zgv*;^;o*t|96MNp?IGgmm+b&fyWTNNe<9(GR&gG>O70wa6tV?$nXvSmY{Zy7UYkC| z&Rw&gm}Lmi-@{(OguP(*O>PESXhrJjaV#$#AfNA?BE13&*xYx1tnC~HG&!EZw7;r_ zi^3yhfVF2vrOr_=?hJJBZUqSxzRoP7l+Aoy%XmqMkQDU~#PGTyT7>?AMB6*4WFk(h zcfAL#1#j4eMr*O+*GGs6&EQ>9%(K05aS1ZL%W-5rh0+#5T^36AolPX+c7-}TV`eA z@a8P$!n+pIzw!im?8Y(KE(w#S^jIb`dk49ASqAM)PT-ltQ}Icc8_tv!qnhi~;clic zWCcdh?w7k^-T`IY>pY2ytIwlOyT3Bd-755ljysW)tfbTF55ln$NXEX$ z5TVQsG$405ldXCP9ux)RBBKQSmnBTs$N54~r!eSMl+o0-m!LtLVe17aTRr1SDb8Ab zYlD|u#^{#9ecOu0pJceakQ`Ir$3EWD2Vz?z*&V&rytS*7F{WmWFnjgcycbtFro|HC z@4#vJ&aHwSmMT;!eIxle=LJz|38!XxVdSH&7i0D8B3f)1BmeFH2O|YaaH;hIE$Ul_ z7DHdbU11J3@7hH(9*LlNi9Ga9z7M}rqp)&3h_+9bp^;WzY^(H2*s0ycil&`oRTjsR z%U>u8W_`8wSnUlANHAL`ctq!5Ii^mzMjqyO6S2-BR;o=AcXJ*Uy{sAtv3~%?Gm4=1 zsR`C<+`*V#%kWbARq}bk3vxT-CmG#W1bY?r3Da$jTZ={L=!gv5<6DaB+&fr>L;mbX zxdcqzqQ%zB%HYS)2r6#m&xWsZ2YtOPl2E&ksvbUs`^su){a;%!arLB^4R=#*6L0)a zRFm_cdNCpegkJHnCvW$=(N8he( zIDH8_&&pACJxyr5zn6=)kU^TAkC` zgBLebyGpKq{5Fc5RZ?x(xpyN9)W+ifC^`>ED&IGZqa?C7No6M@$~y1!C<-Mb6-7hC zC@C#1B`bSnM5U13P((QIeJd?R8BsK-P+x_Vw8!uL{RfV7-tpY`b$vb=nb#@RI*B)f za+vG+#w2(o5mOe2!Sb?qaN6quEgO!9uaW||=#)NQ)b~KAvs&0;`iRVnm_W`7Ssm^m7}G3L+o*H1!(BZp++e=d^tf8ycZ%x zejPeMm}F_r8@L9JuF`{Q))}pVZ#32DRaB?h z0(-F2(+)G18DYTYImB02ia9;`EDcsPh6(SA$g{w?ywGQxVej;RjOPzOwr$c37?^XH za}2x$S&?YmQ@sPvsZ1bAZBcZ^ODDSf@&{1;bBd8Kt|8ejqSmW6oPrPi#-v5ll%$); zTfa45LZ)#2SOHBH-Z}vr?*3KH&75b|JGgq|ut5&dp=NM$@*?8-O_Hx?77b>*=A!Ps z6-?%_YPqjS!zA zus7DD{9UK0O^q$8b}HeW(EG%pONuun&h>_0c!NlpBmQ^Dl{)=r$Z=(?xvsPmT`yC? zD!i%Y=Z@&oLk@eP-^7NRP3Yqj0Rc2W#Jvk??4crqv2@9H6&i9@m-O3n4hn%t+&N@Q zQ}1Vkw`?L^kf(zOjgOFw7-{Y>m4aC}r{kyV=6G`4h6y)QJ?0aAA;W)$o)=Ai0zK$Lmk-|jBN^;dinN_gZ4S6wf#65F2Xhqr5sjVwX zV%I3?%W|SWwti!;TSsu5)_-t;$!B9VhM3cob9{?Q=flix#pV)8vk8G~7 zLVJ#T{ij76UomH>zTi=k>lQ=a2SihH*PLd!C)E9O3#NwCmg9QWLMr>>DgXZaHFTlc z3B2eRL!W8fC!?p*iNKj?`1GI_+-GroCle*&xyhW=?&ADq%Ppbj@iV5G9A*xXpRjU@ zGv595fk_P1C2d@9N+xIn{#?9}FCHO`9Q_kcF|Wn~<4S zgpTcZ>7|L`)cV;vHovhC?#;Jh3-?5_OM^C&n2r-LIPe~3R4S29LpPZ4WgHC0o)OO_ z(NOX$nVYAyuuID);<^iAG^oXsu9(YWkc%nRa4ezoJW6Teta$RJBNIcDTd3-aQ1bKT zZF2JOC3f`w5{Ny;G4m%lV2r>inrI(|-Bvktl>;*Q0VRlP-E8AbJ9ztaKJtGw6K48p zV2^#JRh!*VRPP+dtXHC@@fS#`Toc*yG!!Z##q|E~G-{*j3~DBYxbM;}X5L0~ z>?pg4A8t#cf7SyyzV0QF`d$e^^=D{`(J{&!k*#+_ea!3-gDLy>;nhv^K}y1%)No8Z zjRmJr*Vr7ojcvJ|#SeD#YBf4=s~`8K+gSfqwL?>~mE3Tg2b2FPTd$5Uq(KInG|MlO z$@?VA%{3!o)NX*hDig(wASF`x(ws(lgu?WP$&`J#hnO7r0LkZ{(|@A27?~tac5itP zLRNBg*@HQZ{nB$Z+awz;txl7Krcw6q<*mej%4ayPrAY@SD^p|l{kU+U08KJ*qn4h{ zurt&U9}cQ99>?CYjoNL%zZ%Hw3(dfqRy!*EE(@b->LBK39`18&gJjV=SlQms{QG+f z%`dD%v9M&+R_7S`1Gnk)qVqUwb0jJaaZYW8Ian`jiEl*(*aVxqkZSvweB=5MyWUSh z?}ELwgoeV43Nt$YYa_1C66RRwHe0fj6I@>@N z13V(Q@8%<{&s+*xtyf@Mz!w^*Z&LrMQH|NwzY$Z9nOMtACTLRpi&1dgMHBuULydu4 zs=#GCo?N!X%!)!#$n^#pJ&vujp%{|4FF@1G2Gsp-Lan6BaoX%;%$s!*H@Zflew$m4qH(z8((Qp&Ay_R6k|MG(z_ZKvKkaH!4#1a41ML5N3AKm$eLX(#} znIKlpxJi~1x0rTJiQwapK92QtE{4w5kHjS-XTdmoI{p6Z3O@O%M{l(sA~}WGj8*M! zaBaBC2!DKtp*Qo<^S_JixK%MZUX@K7J6tjL0HM8ZfORi;c)>!_`eN(_xb^cdsmXCA z^W&{pughAnMMWQeX@s%aeF>BuFK6Q%mVubE6wKhRG4@C_t|cPa{lg7cD7WC6$1*tO za1ZR%5k&39b?hH$Me27=6UDt1P-oRP-j`f`nnuOp!?FTgpL`JS3oj+IS!q-%v;=jH z457ktFZJF0n+e(ziSdJ0SoB3^Q|_>&}ScgNQ}cv_FHu?*H`X`G7>nuIG z&Vig0?_keMO{DrcR`fyG1OC!#Cpe{%MmwGf&_szkIJ0XjG{)t!GiIzK`9H&OeYimV z{K6Ofzr}?h_sAWelyF@*|78D&>)s1RA^7tuw^F26b!gqLh^DSIdNwtE9zerh2I@i5FO0q7L z5V?KfFiIOBYJ4Brc1{-`aPKi&=V=nBJI=)BX*Jn%)f)ZjT_UDj35tc2Xir)Kt>SAV zo|NQVV^TzRqdlsAjDy6YBIxAy6B}gYX^eU~f98}oaJQhDO=&P@zl=*U^P1BclSW@| z9vh5X8DbUWSWF~U+{o(qw_FzKE19Aug;ggwo};rEmiauRv&Ju@df6Lx*Wy47`nL&o z1lCdYycTBfk9asewv0A-WimeeyU?!iggHCcoIcY!PQE|?NuJkgQh{M7)X=qMTbfcGoGB@YYD$i#_BKt5;pay*9 zjBt9KE`(;p!Nhtec(eF9Yr8^|4UPwVUFU^Qs+EyCT|)yYM`mbx4tq%8G=T}viOr}j zX)efQ=dHO5k@|V$<8N&&zG;FMJnpyoaflJ}4W{k$%czZ0I0@dd4Qnn0k)rLPG6+8o8X;Vf7L^pU#5W^Xj1c;W+R2oe?r~p-lLq_6OyIrpGMw_sh+eUr1OG+nQq$O5WLMV;oNwt( z>rGRr6vv?26dR3!Etyz7`xuSv$|Y~Wl_lHWg7E}t;&9?56ZcDyE;{W%j0N53l9NSD zX{9mx-c7|_5w=)8@gU6+F<^%}Eiv_I6VEwF5Cvnp$)ChBaIUU~;4P})ef$!7JBAUbr`sGc}$l;-v}MC*H%gp*?i^ zVs|q2aT}}=D~-YDWP?o1{bE}As$rZ2Hf^~G83R&>jM9LIL(JUQaq%j)i# zMxMCI&?8e~Y44)}ux{`|v8qkPB{zXS>AnwgzvVD-vnNe{Wel08_krE<&^nbxOYq^_ zdY+4s9t!u@gDdtM3E8tSOa{C*;Eq@NzeslE(P*|U0P?1 z{MAl`J-Gn(_&88A!#sQ%z5_#>?a_OAGR?Do&nQkq_MX~A`ev|^)fiF0%Q32?K_Q1W z&r@ZWSWUP78m~eQb<5GoM#3~o`xm?lOra073~>FNO^_SV0P04knEvWSxFMg&%>#h` z-kb%Z`ST&!Lj`}|E}%$$gH6xvk+-^N9V_-bZOyjQ2Zv2v3ZfSuyQ9|+^`6TqUU19>^Zb8qYShp$N7afxtZab zg_wOFiT#a0w8~Az%Z>T)`s*9)iZaD}si!T5&P~Hw0U^XI}f7ZLNF&JLMM;rG;+ zIPm2foa5YHyh##R*g6#|D>tF$&EL>)mwW$H4#MlH)wHtcD41L^r}Ip9;`SaT4038C zDHfKrH8lwIlteKvT!(dh=0G0ow1oEXVBEC+0emph1{G0ta{ldB`oQ!lR0`HX@VoiE zAK$0Yu6B83d#j=C+Dy1}+K<{Vdj|z2jf};y#gJoskk#C=8(m+flF}dYxb(vznJFxymIwKmgmo(l?VW)a|Fp6qv z7+F3U{g$0YUGa3%x0FZAlJ1i4d!CWHV>NuO3+2p?cfnYdJqtF@kfpDUC7^fnOqf43 z0b4TX(+e8&C^x{tS3A?t@WWAD;!}u*O{T2U&^nAJYvJ6HdEjaGkSqyH!AtQ^;k=h9 zW($o&eNaAAo|b_tY?M$p{xf@V^+tMS!Wm4HO=1r7Wocn4P}dj2jEjIi?9$|%DxIyI zOIs9waXFC_jTdmT{sF>Q&Bnir5{Zxw$926i7o+`elh~j=q)@?~>V8{*=Pvd^LGNGk zXtaXtUMGaL%87XEI3Mpc?x)e`Rp`IgR}=6u<+uVF+&5GVNF#mT3|>pMrvInt>zjqy(<;nS7D_1_9OGOCBdX0j+$ zT~4CPOlZl99?r8;1XQMy97vTT-TFpkVbLgb%E?y;YO&YlWOB*HE-bBlUV8Sjc!6ZR5DlL^netS)XHSN0W z{jzdYK2iX$^%h}keJ5j_8%xh!;&>RohsYL(K6df$-FQv^0`@l-!mktlG&xtC#$A$S z{g&Uw%swTqe_Bl6wmX3ShFX+g9f=Yv(=p6pI^Nlqk27jwnT5qpB!f|g026hrd5653 z11_j_XsWo`mb`q+656Tn@)i@H_b}cL?7;dBJ>HwHVGB*&D+enprjlJPoCjfvDRo;m ziEMVD@Jwkg_2N2hzf-(m=+k$e#qXQg>MxF-mXqt#5^g}pDtG$qNDGzrRbkaNuYmGA z&d1k?WM76FUoY)D^{|L!yLN@)s^9T&z)~Acp8W;Yf-JB~y2Ip)MAE))1B@}8O&vq_ zvt0#eVJz%7Y#n^h`=^$`)MjfjsW<@IOG3cns~AMp`$E3`9SAm>#XRetM$d$AM)x1G z_-=SArnLM7_oVY=+nbeed0Glv{DNin_bi493(c^gXBj#lF-05Dz_HiySRrUi)c@`w z2Yq8m+@K)7s@=!t44h;hDkMPv9$79|Igy_E`A&#pjjxw?e9JA%fGSqM=mNP6~0ui9cX^yq(x+&%t)OoJon^gu3}n;F}|dCtM`) z?EQ=AvQHTmudla z^z-xW%rM6-^f@9;R~<1Y7WM0LtfzSKh8G=2_~Z*O3%)Fts+r6`P^kA-{R^kH?n z01~HMTD)NzRXSsh@#kWQX?PE5U-uR!1{uNe`p3BO(|I`F%egdk#4&5=8QEU-knXy+ z4J9KV4~idC@J!th^trf0 z#N=(nbVLt7vE02lw;utvAH&Kcz8v$0w5XxtxCHTtb??oJZbR8;PI~{uGJ8Yr8u@mpO$^ z+eBe+qYU~@b)-Yd6Tn+L823$8hyEQy%tOv65%FUxewpgXv9%fJt zZIwgcIdSyMuTRhz)Hwz-#Eso zDF{KLsXR3-GDq{m#|++SXT6Neq4vu?5_BaS4}Xs$|K`r2?2-piGiptG|3=Xaqee3E zc_-WtQ^mp`4~W@SRkZLoqs2QJ;(S8>*3s0Md3Jmk~9)p2a%`4 zAY_;&CqD^OT=I<3iCqPG509ZNy8*-O7Gv{5hHmor!wrApQF~%8R%^$DBj-4pkTyti zGcw`gzkmFmpJQOYPZ5l41jvp%a(JMdV+p&@!vC zr&A4uJ5|s`-l5KQ-wIZ)RE(Sb$`EbcR;o4kF;S|FfHSj{afQAMYz&!-!E?mx<%>;F z^QsSH{-A+aX~KB7zLPIvI*;q2^KU6IYOEsj0;ci5{5f8?@M|h2c!<#z#`~G|RyX!Zm=_DFYOnHeHT=Zy)P7;yq&cne&g*2r48GF8M7QP(5Of_aV z(Q~gm;DHDq&dOYcb5e=0^$9X-e~xlJm>OsoPo@U13P^%=6vTYEM%SFM1qmf%?0z7M z*XOo?XL|~snrMQ-c`+(&Dlo`lh`6R31slxEFqh6M&w z(QS`Cq2`<5$zDg4s91+yPrb29O__*Y5ypY-)g|bsU_tC>0w;w%$ zXVwLPWYcn1{r&`of8!D9*qe&7Zk{M(%Yu4rI#&MOhHG4aEXzJZM(=p?nqEbcm&{f= z<|KhOy(t(sZ!=UjE~bgc1j!}!QFgS^0Kc5eB8F0NRMobGjBxyQWwk6^Vtx#Tu8E@2 z!*J9w2}JvM+lbYd$#mpTKDu*mkJ>`k?!BHfpR_GGtzAPB2g%> zSVJb=g)iXM~P zv?ykK$$ePeY)r`Z4A{_IM89@({FXLT;O*67w#8p3x1@t`kCrwmQq!Ux0W1-GHim2d za9xOVMMQe6hd5-qQr`Mg@U<7p}=^A5tqybjN zC7|#?1&oX^Sf!GH1;(#<^&b|p)3ztz&r@}*&Bkc|N0IoAXwjXb6EUGmlp1eZ$sW9~$PVQdk-p`pFf4Z(I<7ClgI$T> z_Wlf;nDUO*ssZ-%j8s@du_M_9wo%X*|?*$ znVW4s^l#&xO=>1B!>{1+4(`lnA4NpwO~xrD*O=>@Sz>)n4MpbsWMBVwWt*eM&}oSg zxw)D9KRDeX55kl27VEYhHwIhKp8v4WIWh7vQ`&UScJO^T8Ur0lr1E>iOle@Vl zXdn_pmS`K&Phl=>!*y%U8(hgZ+hzrs`-d5|*>lj?_!?`b>Pioo6thpI6xrZ6TDX~x zG1pEf!P+VNNbda(BA508dZ%^cf3FRg{E9HJt6hXrkB7*JoIQBIihuR?U_t@P70kDIWZpNhM$3iqaxEP%t^Wl$~5Z?JNjB^hNqx%hEl;7=z&0_~x zciU-X^tmc5t)7DG8*R~IvJVopAAFBdQZ>3jYnRX6!EgcfEDPD)?+) zPi!A;z)xw3MC;{uE;pD3B?q{!i&i(}EenUOXS2x=BtoF11U0CegXYo={J6DO;MVdE zNM9e1JBQuKivv#BtvH2RcPgX#7dLoovXE|#YJ$v4KPG+EP54k&NgU6};H83lWK`n{ z=Z=x5bDjUdtV~VJ0$D1q(S-%WN0^mAdU@3fF|6g42xd4S8JlzD;o|MrE}~is ze)6GQlk5M9VD5HJvg(T`=3Esgw?;M-=4&5pXPvQnX%viTaG6OzX_7SS4EfGwd>Zr! ztMl26bp4ef>-N?%9Vu5J`ojqj-QNOdDrZ5)*BN9--B0G!9tU_3e;&JX#8Ia!53=^Y zhQit6#L@jJySU>b$==ArgNy!yATvYsxlxCK?_R*|J6plR*+s+4$@`4eWb~3g3O1QTn9eki6DUg}6gN-1`isf@mCco~Gh!PNI`E8ra@Yb*1%s(|F%Q;mC?x_t_O^ z)G)<@ROXBF7u`96wojgb{=yj2JXi-UTkFW457X=2?q4M1evsOKUkvn??Hb{Oaz*x@XVB>bN858O0b zAgm*cyxe7j=0Re(tjP;}h#D#Oe2*cP2l@9duAs;AmV@H99-fh|J8oS6ik%njOfI-4 z(|?yTiTt=3vv({P22X`D1G}Z@DZ^W}GLkX0=CCfVFEN8DgDlK97bc!#r_p124WzHB zV%G)O4Ro*k} zoj4zeMWP<2>)a+`L4V=os|Ro`*cUb3PQdvab3o{o8?p303ybxgIi9l)_8slzc5E9V z<=u7WiQZ!p_oWCbPR!@_kQS^-cOkU@^a3TlU(kCk9Y#tR66?Pol0;8KDFl+@>l~MD zJ(mgA*bGr$#hA_yuUP3HH$Ypjg9Hm*uWOyCjki7S!PMQG=&fB`N!L~p{q3t^*PlA# z_nBiMZw{qhnV0&A-Lg z`XA^2c2FW5#|iycT?8E$A9kvr5_Y~5!?U@HSQmgWAbSL7ymEt+ZuhwC;zdYG)hA^KW^J}F@7 z+$nHX(TNm)7ocq|I?N-HwAxEgC*bMqb~2vdNTikuQM2TDzQt|{@++*1<9@EB`&J4< z)L0=oyRRRhRhB5VC}QPpCkQ)S0=~}6=+P}VaDn|{wq|)6)lRD+ZoA!SlA{l3m|Q30 zv%i6U!Wi%H);K6pjw0ul?jn+Y0T5qZ1v67lW4>7|oE+m3frlQP%TNV+(+*S5u9rzR^fEstEx6q0u>{KJ9M#k_j+KYIHD&kmsG5q@*O4s?lBkCo8 zc*g1vh|B^jtZbV}@?H#r!GD6ZCH^b(p7Ti9W<)i4kOlxu8zr3uyhz66eww z6o}4%-!AvqaLEwDo306!=JISvxj80ncg6hFD-iziI=_*dKX2jsR?&U3uprO{cmLCb zn|F=qlt68qy{V7=@M{vPeHN^jPPqsLPZopR{J&(~)FR?HD+TgT9wkTBXTspjDAFYJ zhm~+EgYej7GAT%k-Zr0$w(rWwTSH0kh#O^VTeVP|$MrTeo|C4k<= z5#o(4B$lqmZ&j@YH2V7CcO=LfBsO1xTivDe27-nP!8 zk^KYY$ld~0O4W=m?+VA2IVZ`^gEP?Z`~v#0+7EI_Oz=A!7ucDZGew0!7FMwx! zZr;zm(X`td&Vs!f?6hA&hmbmr9E5m9CcPWRx@~(7?@DH5Ocapz2GaHU_ z^YCSl1EJ9zn6^vSjE2+>W^SrJc-o#K!DhDz=<494fAg_=!&2(6R}P_^OS#|83ufI} zhyOaX&}xMcF5=76sppp9*_V4cr?V$rf7FX8$4|gBw)f%b6&HTOxARPzr8g+|NYGYA z0W$cz8DmvS_~#UzfSpo||DV&~_zBq2_Y#!Dl%VWtAT^Owq`#JPEqsCJ{EAl}nJQ&1 ztTYe+$8`;4uay^pr$$unmMR%YJ_T0{k3sUKc$g?{NZS|5f~V*$vcgK94ysmx`GFDk z+o}NcTGIxbB}PcVcLjJ*o(*@3X3{A)l8A`+4N^ZoM($76U_m^9R@#5#uH&6_*Y6P+ zdr`uUo_)l~JXFB5J7XZ9%i=)tqqtB8_uqzZ9Z$!Jh`wY;`BZC zbV?c7lB7z0l(j+8>aYB}f~Sb)a1{CO(!y4&7BftW1mW|8Fj|Vy7JYDJ z#bfB-9!&~r9+RMtD(uhd73_+eQ)!Zk4QcH)OLi6FA-8sD z+uTE5ST=)w@;j*7cnnT@KV{GCSHyFZISyc_0KKQH1edD!!pYrk=y6$@jyd0jE%KpI zKXDfQlKGJ|?U__BQ07k}C-gvdhA#dV3Bx&lOX#YK9FWy#NW^hP@QWRWS63~eqobE! zkta-*Z_UTAisB@H{1b1qT!4-l*D)*9+_ChvC02fU2+MEm$C0*&M9TCr*B=XK%l2zh z&F5nf_}c}YGpg{X@Hw_3C5MI`HDXiw#vBKCKlN-irZ==Tp!9wc`LJO%$&fulO(G*` z;`$_BR{kMgK3v5OGn|On{3cBJZ3(^BMwIDxC7u(Ww9+jZ-_#b9dxiDth9q0CLQRL5WCs-~V%cjSy z!f$2{RL4{jwpUIk*MIVfT|ofz*!Ld%>`$l1G`OsFi50&6IGMzl>X0*f+0=1y2V7Xy z%akl_f&Ei2qv?E6d}LzHUjKHShU)uM;{j`Uq@}NrfgmG~xZO1k70!Kx=+y;iT;Aut-^es{fn- zw|Wx5wI>I1-lpL4kZM>wPY!xH>jr;;D}ytyF`w+lIX-m_Q&J>An+LS8ZR!Bt`XNHq z7wmwMla;v7qz-GiZisE!5i)PRGwqPCglkIQmx-X~V3g!kseb#5D zTt$c!jjqELxSDjjEH!(-#K9It-YtSBL>PxtexS&ofaqC!5MK*B#kH51Bo8*8y zr!_%%nKzv+_?(%sP>k-|BSZ?Y5XdJ7G(FkJcsc1nK@39_Y!h+d+C6+CHlhB(A_L0$ z8B!Cm{mj8)0~(>C%JC_068bL!Og~M>-@@7K_y-3h!&%g1OE&rWM2tKg7og66j8L=I z9E+YE#elSCW^N&mZnN1zQ+})i?Ysw|lz*DMshx-+cA3muooQt2=R@fEjKIp&-9-CN zJBUsD&GSeLb6^r zZ3l9;F6+kkr(nurWBSrDiun2VfI?ax#D7Rf&5v&&sZ5vJ{HnvOx?r|@VI)l`oL{fk zZcoKLPve(2YVh=UAI#Huz>vN2q$Dtl<1jTs_pvc%pGPkF)+mB+*)eK=^gQ-SykKH3 zuAzy2d!ScW0(Qqwr_ma9P{-SXDi@A3Qm1+7FS3+WZ(2(>pHZnl-MXJHwM?YNZ+_sc zdlgvaH$&YuD|s`0qYxR^kN4txv&uz zCVhw2;mz>#cMJyZ=B~Qq;aFz&mraO~qsnW9=yj7`%sthQH^1Jd22E#Kn~1sexoQzI zbMCTJzOTffqG_g{jQ$H zw5N9!%@Hrcii|)aX!Hqtx?d6DwGs4l_!`=J$d|VLTL<$Mqrgd50;O+7QTBi;4U}m` zmjoRu%yl-NoRh+$Gh;+8zykk!FIN9y#!g(B{FG#DsU{(_Gs(315-8Q)1!J@eUkUY6 z`8VSAlI7QN$J}1JQr*H@eE%}mwS#ji3r2$by+9~eIZHymcj7kQ3DWPgfli*9i6iY6 zjA7CkRqh(a#W(I!Q!dAUOjVVfU+@|9)V)yX?-$ng5X+YiokP0QACQ{*FVKHx5U)F~ zuogE`sQ=IAJUQYl06Q*!V3UJ1@lp0w@?G>F#A%eFoah6qYNcba#i5fVmYg9@tEKCI zCmo~@mR+Nx?%eEo!y5cAJP4ZkF?eRSJ4lL{;wBLx(&qG$NUq4CQ~ICd>ujzIQnUh9 zcO>GvAyuk2t%WgKcNu?L&nD|XJcj)zim}0AFRE!NKxkhxX+9E0oj$+9!k2RORRdzy z2lx5oj;2ELP4P7Po2Y=OL*$jp>z=2MU`oqxF=rK~&PknJNT zazuhU2-l!}i)y{?`9#e_V7%Ee%MtYxFvdqgDV5eV-$+}(KZtpEW z;Dv^D_ESE+(3(N46c^G|*$epipDWFu`Wl<_dWdD`AXzDzMJGCJp?9tM%z~CeQsZxg z^*#plN=+HfcX7bGdyUAToe*>R?hKN2TM0sZR6)Z?tbXuuJ)AxNmYeG>#_;vk#42JD zJhYgN{O)HkbXU`Q-jQ~6vAF>=q)*fA+*0~>%{}Oz`<40baERMQd(aK(w@Gi>Y!pwk z!$|c)s>t`okHcY1^t)JEd|)+5UyG*o!xq^7N}c3xumzbWXB0c;2ji0%Jihz@+Glc| zk5V(Vjb`xSMg!}q{vYtk=t-jA7>kW>YMFWvz<{6kVU1fVGz51-7zu?~+c`K>b(|FG zNimZCiJYq-l^&3ZCDQLcgR)KlVyGbfNxR{_`xrbbTY!0QGRRGm#|BzlgdcjoxaaLx zc$v}-It7Zj-%G^$yr&d3_0AyilS-MpTThYsGS(<69**L1WnlJYgqi!bou)t9g#SEi z(T;zLz8y&f@e>Ph=J)`pjc~W;&OP|WnE_|2P70>)re!%?{@y(e7(Y9<kR`nN{;`Z?{vemg2&T?))W4(5?d=<@1 z`lL$Ma_2zT!2;<2CQVB3CDUfs4vW7`N7tNXBxvy#PETh4$+s;4KS4VFDoJMaoxnt}n7k0^?ZYZg!U@~^tp}mM1EjE|~r}djj zS57iYrP{Jn<03#Xm*aAT_S2nl`^lB9eMCTPHoI)WG}v-81q7cEJk862ohRj3M!tgH zSY8Q|k+Fz-evqunPUsBHfNWP~Rz&6|40LMa=6w!u^i&8rpcz2rW~WiRU%hbifd#OG zLs0fwj|tURfp1BEn9^*8ImO}7P~?O;A)2&c{RB9AOMzzoU}3tW4Ko~i8#=mf!&<`_ zTv5T$nIU_Kg6Rp8Jt&Sv8N%$Y>1p)Sqetvjp_91aMh&_5elk7rK89!?na%F=mL$W0 z1MDlKKfEu}>ZoWWN(Dp}=>9i{S^ec5j2t6OQ`bCW_4DFM?&C-{bXO4!+KO{|Q(Yf~g=ea&~MgC?svebg4 zP0j$*b~iHidNXr(-7?HCuK?;U#5M+9CrA3-aqmh6s_Xj?!;ZcI?fPw~IK>-8T{1~g zWj!(DoYjTHM&z;51w6c+5|OGZnkC0M+$A!}@So>oQHc@e+}=fEFZ2SN{SJPXbU^v* z6l*Q3I(Ka@*+K4m zyRZR6lHBw25o*^`a!yGOBPOmV&Z-LZ+>6Qe*N){_DK`eN`nJyaB1j3Xc)84B=O!2m zzXr4Qs$o%@Fc!yL!hpD7vfOqF`(v^+TM&F0Pvz#4@2BUWV1WQCduNj?4{s7*Jz08h zfa9LZC8F@m`($P07XJ8hN^Vsf($u^yIOzBYRo>pm?(X&Ud*Cs4yr!Q_8E+)dK6k*? zqZuT;AQiU#@FGTk9}yLgP7=g*bADBtVt@JslGS;DY+tE?S^@vTgEdR=l=Dyc96g&n zA7vr3;Q(0`vAov%bS;ENK7nNui#Y$u3P!xm3}vTpCVAV`zDv~DAZZ)Vb4zYNj6Qyn+? zhO*=Fz0A7i!`Sgp1G^ z>q~&g+lc47JkHzum6>|S8k{{Wun65II_z${ zC+OO*fdv<`ScxnljurEje7`DMd+vBGI;N#D8|L36!q(ei=jwGhv|=iFh7b(#O~B+k z;`OVX9O-(`rOeL9q4d(jg&3k(LT;bpSW(N*!Hnq|=7wh~+8on3Y>fiJ@mH(HE_vak^t<=G81Ux;-`WNrNr-mphkippBabS}i zHL%D=r9NR<4(@CYCQn^lXe)0sPSf}UpZ7>HTV_aM&E0dvN_b+ux@!VzjF{7Z*^{jU zw%LM1NhuD>eP!DGy@+DTbGRDEpkd8@VlF+ED7#vr5T-!u@iuy?C;!Hu~@@G_5nF*^z2aRD@^(tY%O6xsLbkgNp-IAp7u_xJt5FleFMwqL&D3~NAxhuvXT{Qs z=$b2IB#Jr4Htq0(wU*&zQbZi_oteQpWGu%#13^e0{s?K02z$dN0L1ndQTGvjt|u=< z*Kk}?$-leEl;}ua%TPF`EXYPzksRKYOGY%)PMr20OeWs#$I$-L5?K5$7@q##OScQ! z659BN@tGDwWHbK9(3!B+*hOKuRGOorQKi{XN=n0d*OsV=k||P#P~l4?ghG>4XrL4- zp{Qt(lsbEDWJ&{}RE8*#kVwXo@BItsI_K=Y)_$J*9z|qkJP;gxl*A2$kH+s`&LQ{U z9UR&6Mo`h7$h7#5>A95sM5byQ7LK|loHjBHJ~e5x=`G1ny~G(|*E@7-J_g3c52{oR zwb@{;HW`{7j%Ow>Vu#u-K_)YuJo)z;=G*GQO8ZpczAuCIUT-L<6@ZeW0~9EqBAbJD z;)uL$Y<{6SQSrJ4^L_+Cu(t`@KYtAwFRzXn8oZ;bV*$%=QbgrTvjzUCaagF~PTNJ@ znfC{V@^xFeFbe^kXqIG|^OR`rR~dn?^-WHirLY>ycF?LENgDb6yqHxAx4`cuP1TOZ zD_UELXo)fB959PpeQXE#uWIN1mTzQ@bK8+w9f8=xT{x(I1>An_kZ$x2zgW)4 zbB9Nud+2gDNkW?i{%{51to7W?(xsf?`gl0Ij^{8KT43K5d1yVBhs!UGBKLcbp?7@{ zm7Y^ZpUmHd+s@YD)=8S|;#4m(;pJ@RIQs+(wB`9I&*gZ&!zmilEzLUe&Iv-5jmf_i zCb+!l5PaP53nvG@#MV9~#C1u+q_+)_rLc(_i93)*ImPht${L~O^HVsW5XFXD1HjO3 zyl|CrKB|2fNt{<*!d1Hy@u=D#(6Z0BW&Hl+i-HXEE>5BE)&=4|gNtBQ-ozP7h=bqw z$K1CmN3hJxgZrf|o#>;%_|uH4qb$E7c#M-M3Z^x9mL!2RxDnF z_Z5kXU=`1ZnHD~i%%wMk|Nc0@w0J$tSr*GY3=DWywm8%5NF>4*6P%Hkj`|iRcyH1c zT>ob|HJ#^=m0u3PPS;qxkl8QnSBM9bcN1avC3lEE(#o6$OEEexmF?R66*@0|M%#z+ zcr9WII2{_pZ5+74IlaeD6qZhzhU6dEav{}I8ZC~GV}0m-Xk6Y6WqrV zL$}!i+nv3_?vM^xqQ6S;P{xsKaJt2PZIB^j*J_egwFlX%ZUZ*1dN{H(Ax@_z9TR3~E2vQ3- z>?w4>Th?Du*(8u=b`}WV%0J|u@bCY(FZFUxtxH&FqM=>K8*OI!EQZ@W-Wi_RsgtPb zgy$VE!ZC8))Nj#ODiSk5w>q4LMMkD9KmDoj(kmNw+wPlSDVaz#w!CF-`wCFZ)m@O4 zZ4IGjaTua%h9bfDvFZZi4YL58z1trh{H&N?^#t}vXMoP;yD}3u`?72mCs=uF1e5HL zCDubccg0;BS05V1ZqJ{K!c$>%_enkByrd)8vUwzKsM&?Fl_!MC-BFx{Kpqb9SvOlV zTh@GEju}f$XWE^=X<(Tq_c&3X+%#M)a6uIqHy8&S#m$w_NQW z*uV6jjn6L$kBVZHc0Q-0sz~+^%)p9{TQp(q9AxpLBtDnHLTwv1GiC>Szbb|eYu>~8 zcXMs4`$N%#zpGgFwBw-FV){GNo*Akn;wd)+cCR?wF7W1MTs+c+>j+I@*S7^=;6hV2 z`PMx$aasoF&G%J*CHO#9N+IZ0c(P`vX~G)823|yfzjFp%+pB~ z>UlojhD3Fy+Tz6a`i8MBakaQ%g%^G+A4^_j&17++Pidm-Yy7chG+Fld8SL?MVkv(Q z!|y-A%-U3wS#G=l+c*g{59#E(*Pi0?>o3Ed@KW4*V-@Q#7)Le+%9FOGQfv=hz*NgS z(4r%Wtunic@|*WSNRJxP7Ov&24$Xo+V_Y!%r3h1UZbzke$AxbunUmig|M3}>M09q} z$Gx3VAQ$i*PvvTnC5|$r{KyHev&Nd6vRIM5N=iUKPyW5)%N*Q(xq-})+Q%t7O0hqs zC-M7}hg_jvDs-M%#Xc_afY)lp{Cx5ftaev|@ftt+Dj*CObn39gb?M~YKosjTDZ(oO zG1Q_q0gqUvv*qr<_Qv?2K+KyPZ#9{y?Hb9%c@E+urB`q)$%}bKh4Y!L2`u8mVXA1j z9)nk!GZBwS&gJO^a8}a7{vTm@WTrEB%JdG*dO4RVG{>UV&S2G2H2E`gIy~M?asHK3 z3~5j!milI_aBmv6?^od(jjVBl#&^0-PMU4_IS!XR9mI6g!;rXt4dhJF$C?H0*jUv9 zQqc$4lZTq*(_1CBP{xv-xcme@%#~vTD-9I>m_QeO)nY!nrD!6Y#MT9u34UifvLEIx zxM1ZW632ILCYVbRw=e}(vG^98J1PqG}VkWNu*Hs1&G}FdjcY(c(EZ z1R6fgr>9=oa^PeS&c98G%=9(%#s%KPaO4lCpb^3T4f&vsYXpu={vzl(9ZcL7wBcGu zYjR9lj9u_B0%do?biRDVbR%)*^g$O(Qle38-Zk{z^cU|G+R(exC*pyBMKH0U(N=bk z9J9%G!DanvBwckF*4~;y(i8sB`l9L3-!P6$jn-rT?w`ZJlJ`{~FCS%F&3r|W_o;bG)*_Tqgu z+6SLRXG_7&8EkY`aQH0=~R zFDzmg4i00kq6P`L69yBGiP7QqKq##IihaFl^jVP>&a1wRx=+Q~8?6f%9ry-kc$mS8 zJ308og7+ppPf#AkNeY;cHtFH2X>`ewA_}!CQ23u+tXqtl4V!`Ry0{<2{%8onK6Q!b+*v z;7j2$>ma-zy9?yc-o=h#3AVRXz!g82CadEQ(X+2|(fI8$Y#LXC9&yB&kL;O6=ca(PR7!P;mw1cvmB5J>tVSk#h;g94GU?1wtJG?DGUfK{d_3~iW zH$zzRSAp!0`v~jm_u#RmAArp{gKdW^@c4o-GO$k+);%#`Cng5s?<^ec=<# zx2%AZyQea}*Wn;}N(%zEYO-mY-62u42L=zTu)#_XNIm`?a;{e5{V9aCy_kjXW+<=^ z+e+=+I%V0NZ2ta|9LG~K)luP!2ov2gk6picMG!vu6mUJ4z=Nj3$}_J}ou6O-8u>%( z{#87Wb_WzZE<#CvCEAxT6>`h!X!p5#aC*6ri5pQ&>&nN%csDL~=4qU3RRrUb;sh~P zZM1B1CHhKi#g`X9!>z@Pdy}{x#CvY@e0CLf*sKhfEEFfEWqnldt~uMx_bHWhF5s$N zI>O4e(d79D6{dO85$$begT%8VEUPCA&q<#I=k0IcvzrDqcbQdwe{(9(x!!=xqKAslkrhja;-+!@JeA6F4Lq6XS~a*8Z9<&E3CGPlhWfr1WaVUSwn*NL<;|Ff z`;|DTPcX;VJ<)jU`%@e&(q$51Tj?vFMR0W9VQx&VHG0;>vjE9hD%YIJ?Ax9SvzAr} zt_E2Uxwvnj-x^IE?taAKFlYK=CJKzggW2MbJLo3|IkH7f3`D(VL1gGVbiRF-Y;Ui? z1W9wI7JpV)W;r0VS?o$Kdziou{fih@Vgy-v7F3?^w`I;xMkyIPvd`>0rq~5D`>+=D zDdagAk##h4R~NSCCh@F=JNRtlWKtr2l{TyxLw@w1;0~MWv9DK>A^Tb~?%5HJF4yMM zO*OgDlr;yAy@G_QL__iP%XSE$9 z>$@W{bubz=MAC4}*I?@5c?C+dR>AejfW<6+19K}JAlUOTs1@nq@fkg^N^dTv zUW#Ccc1$1_HfXXv8^W3N;zPu_QH*y-nNf4yO!h$}556r>g4NF#WBZMGn$YCSw6}*s z)JqxW(5S%bQ%~Wz)VCm(avD>cr*K`2_g0-*jtawCL_t>(>X%mI!RAQ%Wt9`Zm);8Y z(Go1`-d$=|{Q$=3y0agdCuq~qSGcEE&bug;S?Cf|=C}Pmn7#657su(b;5`+vl#}7l z%lCNvj6D~klR-T6e!<%vBXLr*2s^wZ5+d$Uv~kQJm)DQtgwmx@5iP-Y5FOdFJzeOq zU?f?XC&QA;V%bD9ZA{`l-ZT4t0vo9T+CBqZw!twhKB&Zg_-%#}*P>DLbsa3XF~@F^ zB<8EwiOtvL*%}i~__DVK#{~<}?*2E31JwSN9P{UA5z{19wtIUX^GS6jS9t!a+6@PAud-lCi|4>o{c3K-f3L8eXtMl2 z_jzt{FBp(|Zkz?u0pDLx-J6de)C)nRh@y33C8%`hvvuG6*^rMorYT1u8S>!j&pX>^kw`+0?{ zZBeA^-#yrpF$@HsZMX{x2e=s%Ga+$99xOa&MW>!lWYbH$Y00JuM174sT-)3$T<$ zTv#+)y!nbSs(2>KPf;PN)>RmH@Uu|t>;cRjH=c1W9+;wg4`j_-A;H3#f2Sw_-#A@1 z@vt(7sry*(uQYC2%OqG&yU_08Y9?PV&6fYnM)|O8PN&6*yt2G3Y&WmLim!R}>gO-? zr|lUWl~jx?&VI#htwp%RbQ94!dltO)k6`!9ER?uq0CB}Pxt17vX69~!t4_^HjMBuU4|6+$4I$AdbYhjbinBqu8w0?NHi2oh@6Rj!Dr*O!{&G&6py? zwww*)=T!k(FR>xU@f}p@O$AHvD`RUz+~^&B9auHhhCQDxAa4ay?7wp!oZ6T+(3xDq z#`nz@zDtrshqe5Brs@c~!DxVL*&V>aPkjJp(b#r=EV(!0FBQwwW6j2@OeaKE=LKux}sZ_*|gvzAw~zmpT~iT+dEtzv1*P(}>|D zGdq=#7`8R(7nT-<;Ah`OxVQBLmOU8D92@6D$L$&Ht3WH}o-k2xL12u%wvsIQPX=y$&5@OlN1@EAR9bS%lr)TR zh1i5p^y-^JHZ}{nH`?d0;9CYYdhE^Ve$r>N#C6E#2`}N|&;S;FZGtY2?{xnSWENNJ zah+Q;sLr~8%_T{6RsVF>#B;lL){VjPaj{%6TS;ynIg3lp<=M=sl6XTtl$&9>lNENm zvW{yKOi#%J>bCqtZYtkZ={N-^Qi{;^Z3ZXb#giAhTx*&bGM973I~@XJ0~I0HfTOC&pCA`Ry(a`4>yBKQ%z0l$pcjJtSN zUE2M-R7?Ag;AlxCot|O=%2$SjQ(M1rmt{I}pR6)QshPor+sok9VhP@rb(x;=8BLU` z$D`P^B3N{P49|L7C%kI+47q%NoZjDtG_Q#6N!R7stv6_l##!82qZLpFQTQiQf+*b> zLu|`$Q8^t4ZbV@&p7`m&Zf!ja(XpyT*PeIXh8U6lCnHf>@g-Hgk${mcHo)KJsO@kq z9SBju>i9P_WP=5CesqM^S#{th`w@gZbNq6nE_ijHLqA-|T`rhMtY;A3fm{ML-%oL| zAMZe#TP<9Oju4kwQO#A@;ZoY^*>*^k#|9nySG;Al_~zQxWWo0uEH71t@uYG8OA(%D;Rnu z#eSu#v$6N{1c}k^kh-J|h}w13FMjzgveVVIq7h&2R zlK6V$E3gywg>sn|y8;p3IsVt06jnyki#MKQN<$n9YRhn@(q+uHQN%|XGNf-Hhi0WA z-cz`P9)IuQS<7g+X}+9k7D}W8R$9!rp`4H6oMzP4D+t`sD2;FBk;gN=Ol_hlI!H+Aexx@M%q`Wo>Qyc^sdS)&|)9>Uu!FNwY zh|G~@y5nX!LSc^J1de5UqT}GPW-5rR9trO^jltatt{9|o97L)&V!(JaSm!j4=n36X zaq2Sm%{I77X}27#=F|s!I6K64XDZ>Q{a7uMCyYDeh zpflwJIdJ}?AXBxK9!x$$!kvOSr>R9$PyY>^JHG|YQhrkTd8^Ub#{?I?uqVahvD^Y& z#tQ51vV^I}=vp`bD!T{#ecNq4X{fMg(_=q!TAPho-5gbRMBM@7*4pB>!G|zv^*s1> zQ-i2H-h@U^M0uBDFY(&Hn$#|9;ttjtVe;EaG+PCQkr*o|<@_7QRFkbe7(s5XmS@(^Q<$gvR9JI3Q)plKg4)WF3}QaU3T)S62`3HXUC!AeCBiv1g{9^mR-`r)r+U1bL;>H z)8{zDm(ODRY~&uE)*%whc?N!x3bQLM6tsofk$!DIaCsz#Wq*FbCkK62$$}usMI3#; z$32UrY2WZ z{hoe0yb&+ASwi;t$;@cnEnM8Oi_2a*haN8+iA|&B@%P2Q0!yC?uAo5zUz`g8pN$i+ zF>S4ITeJ`d-VD%3G5;_qGLTCXQ)DG=kL_M8GltsbCm>NolSs83fT7VRXjk)T=(u|p zy^f!N!LJAC*ssg*mCO-rKJ3rckJygBp-WL}?sqP&R}(I5EPzUfa;hK*!9ve$?&9YT ztPgn)ji!p&{yKq9VOwBEyAKzj??abQ`GTGWvaqLVJk9Fbgw;Eikm6QL-Z_^EZ(?rZ z5sd`f@LKZ8#U!6)<{9vMm4nFZ^$Y6T%BmC3a#VzGtCXI>|*M4R#`faN`|hYd%OzhEjLwocKJ9QR`rJ;bU%xjx`~wcKEs`S z<{3yFc~rlYyjk`Dg6V$l@$Nm`T%F78WuP&W2>AvZZ^fbTy#OjS_&GfPy&&AmnY=H$ z1fg}ZtVcnd)$U&-1kXD9@#tAPYmOWp&^-a$&%cCtLqqo9!8__ZU619TnNGZ|3UJB< zb#iw&1I}38!h(cCocKwFOu}O{T1|~ek9ZG%_f5g>voc)Rrio-;e-PZ$kfwh(sNwCM zM>#3~LVPhC!Hq4Q#g3PKKtF{V+yiMK8Z!$vNX5f#;}BTjkq`TB@eZ8MN*qkzh>Idx z@c8zAunJfN*S{DL(I`Xa{(BDlara==Y~Gn9s?$ji6*&{jE8*1V?hCwpQJp;HI}qza z?;#x{AP#ySWWC%eoV#WQi(fRBjWEigkETASg+4DaS-up%U)XP_9(V!E>`OWGRi5nP ztFM@1F&W2Pzl$z@e)!|F2r24P7v^`q#|rHe^y0#)=#ZAeb?otB;(U*GuhK#;!g)X1 znj9j-uLI%c^-#`3#E_|;;%6UcbN1U|hTwe3Ch9pH4xP`GS??=7Hp@H@BacduibYe| z4ZBYK`5_K(U;aZiuiU}QC%s5z-D!;9D1{x}xj1_L9sX@+Et9Uz!pcb;eI%KRriap* zpeP$GdZ&=L`!u-WFfSbL8it9n<48fsGuZL?hrpuxC0Of6;GD^3>|E_+ymWmBIjz%! zhfLb5PPP}qw-be6$meFnFG;fdS*aL4UYWQbs-=-T($PICR^WC$1#;}X@UvkVwNE~Q zM@|dleH|v<`B_cmVz_?GOiCLnA(47Ah)%F>T29XjngCX{)!*q zm^MXNI+y}g{R(W8bpvfPm0=I_eAo-M99(*(7LQHb$xeK7fWSu?c*p$=9JTVtgDRP9 z-x+g()b>W$H$jWJmKAej^IO2VZV1+hAr^=Q3v&LO#QaC>qAPR`kyX#eGufiEV6;b( zMH-$2Pvf~<=izP8WO1CFUy=-SM*?Ewa=Q8ZA z;Tdy2E1dlw=>Fzs(Xs1U!>!MDD;G4u;FuUrM&TNMIPFP}N8Lf~EBfRL&m+d^0qAmG zjV$qyB1>Y$na>V;C^F8l-Dofh4V))(Zow1C(aocXxAG=b3R1$*X&<=G=P5$n?KW(Z zrw^QYo(refI^cq}M+IvomAGQf2Gm~ZLvD(^$CG9e=uox~q%Mu2-fg9@uQQF7?lR&& zUoWN0x*k)_x%=71TO*;pMi1>aIl}$a6f{duU~da&lj_+Q?am*_=NePw*gEZ-&{mR- z((Bc^hv$C4`hlgmE9NZd>7;U=Ny#v7-UcGV>9IMB{2pR$I zamAoKdIHr@bLBga7wFl)tBKq+Ibw1B2x{JL<7}_{k&TyfnCu5#a%`Xx|LEn?U%baU zhu^jCNtnsIAQE6=2qDkkk7Ng3G~nOZGlJOxJolvKF#etyf#$@T9S;b{W&CG&w3b5> zoR0NLLV8c~r(kJj9Q{4zDL7wNAVbOtaC?3b>z~K>-1`rp`u-OJmAYlTGhrQ;$;}}> zn|UUCr3#Da{Y5wRB;d184~5+PimDi&vn1m^LE)-AFu&R)*rFF84A~gz6MQn$z~0>S^ORU zxcNcVzZJ0UjWf7xe~WBa1qx0!a8u@-g0VN|ljlr>=qjkQy>}a^a+3^c$^yRE+sXS4 z%OUz>41O%xj8A{>#>-Fmd&{>73K_|8=lmNXy)zD1?O4UeY`FjfeD+2@)P^STdxncf zJVz@2H6{nV<_cMy2K|7{x&P{eRmJk%u0a$<=J@k#v1l2{XnJl zlNDTEZWH$pR?_a_Fj_1lN;Il2f$lqTrZlXC>x0F~y}oxe$9OW$=+$MNiBa5rtFtIq z>jSD?GNj}DPr7NsdW?P;OCQ@T;JAD9g;hRFQNO{JYYmFV_uHr7?QbvOo7f*neo>2J zT~S0k0J%HILfF~YJd5#VJowM}j3Os0xyXyNpgcGdjdWbtnYez`@-(2OO7hIT=QSs# zr4LoDYX$XNQ?OznkX`(gj=2N8+d^q9StghS@gJl~ZLcNY?Kp=OlhVGyw&XZ1~1AT*fG`WTT>Aam(k*(FN{l zIHO`eiX140-QT7&k1^+IWPca^N+gI;rX8F*&?Hdp-vSTxB}p)Zz@t`cLGf&9_B`$a zZC`1C4nux$%SQ!kN_Jptr3hKQ#RMg$y?|T(JO_XcR7gNI6L-aU!;TB=f1Yr9r)G@q~gXOxQ%&O7~5y zMf;i1*?${JtNx14n@h3(+*aa0JBXIm9ES3(rkD_%W@n~Wf=Y$jOiywjXewO99c5ii zSLOm{bnQX&lE6nhh>vS%HNhs_7? z@gJZsItADMyTrY79nU7joCJ@RE_i%m4Y#CKi?sU+aI0-Z|*mlSQ zUdL{KS742Sr*QPfL~N}QK#b#32(BL^JQ)`WX@8axse&~2=$Aj<>E1?`HN_HZGu{QM z&od8tcQKKfq3Bbig7SYe{llN&TXqN&1gt0ChIg_3kOo;F5`o*v zL4iNdm~)i}i6$gwu{T#8xor74SbCp;wp=+)b(6z=9q+lJhPQa|!BL@8i~$XJ zzY3QgUdaNi9R+^Rbui_1f?!@1-;Ya4g`Ih)K|SCVbXm=ymlUGth6U=(udt zqPO(IYCa1P7mbI$hT~U-#~ACB2>YJ&Qn{klY)YI0iRv1`thO~nnkJz~f^^uPuxw~N z{E(W(o+SP^8p%ZSRFvw|qVJkAaPsIz*dNf1gS!jhlGaBG$q#Ukb0kQebY;qpquAyZ z`+<2*<@biobo{b*;p0b(g+oWSu&3Y8g4EYXJij@{=AEAmd!;vu$gQg6LUu%wHf2}A z=fHg0deRu*be*8{@-%5dKq2O+S&_+o12m!T52x`Hh(cR4#M%dwk5&<+ULis7^V)ot znNtLo!I{)$(QoXXmyJf*qgm*YKy-PfN~ZIgCe70fX{atwR_HWokL4&lrj`Z>uF7HrJ0)1~dBao=|^z zrNFJ25FdvOY+rbg73UQTlUL^9d%w5TBHdJIue64f6n#Y-?47yV#IZ2V=@U1!^%=hx z6D50%e{z3wU%-N&GpVb~9sH5=3@!g|hnm`$-7gvs2bTK5s7Cq zwAilSi&(~gSA>%a-@u0@w&1IAE!DM%{{>DkS*U`e|^ODNPPDg5qaoxc_P zm#2?%&u>8PyKN}ls7L3GHWv1uw#0NhPi#(4f)48gux0TEv`{j(lUJKaADfONlS>{@ zyWJPm#xD*J@TENWe*UBhl-ZD*O0sIa9A_K`pa3y4Bj0&CsyN zkiF?RcQ)^5+5d^NiI66GB57bVo8RxmKgE&@8^CntcVUT!49S`MS@u!L4pgPPoxQ;IpGvV#Yv9#fFA=8#Bpgya^nalR+c+KfBh=wYVeG>h+UP;%kV?!F3 zx@|VR+#y0L?w>&qX;Jc5zYtviJ-~By>rqLq5#uL+hW0`kjI7ec;jQr)sFaLT8~38r zD86OIuQS_zhCs-JV=VN1B59%jxK4j1IAftL&^qCU+-Etkwv5D+cGdXO$%Z^{JxDIr zt22)JK#uAGxBil(}ycJBZ8v`K| zPvCHlI^GYkM~(B#z)zdc%)NLdG($OhXN(tK+jSFnwVpzh8nm?|TLI11ssH7q12F>7Tjq^)~EG zk{!>tQ)Dj+>fq_9eEd4@JuOLyWS?hjfXA|?%y2aCwJdPwhTi50S4hqU?f(ob1- z^rZ$a54K~cuZ}~5vHYO;+zzCJ-_w&zeb798BI}WnCtZ!nSm-v9Z5ZndYkj}l?cS`8 z5uza&ZW;r*QYUcm>;Q%or(kfVAz9!vh`R=t5=}8L&hFkPyFWED+&axAET*Ltu5aq( z`rUsDX1E>2?RShJMCmSf_S;%U%Ma3-XHGzYq#o7Svl>@Eu!s9Ao^nn1FVmYDI*`0K z7rt6ma!OyG;|Ie6v=Fxiqg|2YVT~0|Q=J2AO1_~2&y+7KuR|TfIL_vP9&tRD2`}Bh z3SS+Zk98Pr3%*zX_h6nE(^iozOH?7LO}8fU#K?+WePcJMTaoh#Kj_wC#^^e1H{;?olTH zOh%Gt`y_CMU?gL%2AJ_zfpmEj?4RR`uN_Qqg`qf|7&{UyG@qk&>s0Xes1dw9%=0Zq z&HyJTBVnJQ0@rMako1dYM!7+T5Hw%3>RoV zo6Ef{Yvb+>Kjqx^?ZrIVS;CsllUUvPKlr_igN#lcY@6&rE@az*x8!S-xHp2FZ63|& zrk6Nn-gPczt0PW+z6{r2v>|U+e?sFNG4>&dXa0?r!vleVa5_!{DnqKcJ+2GjxRyN8 z{}PAVfqJA76lnF$5>_L>9}{y9)B0O-WX3^r)I3lI3Yxszr6BC#v^<^K9`3PQC=b)~IKhv0V zkV;7L_YBh({Hq{f-dVa#ZW-?hl=Y$FzC-lt*l(zPb0RA~Z-YDB?eXj99dPA4&j2)= z4N1l0xx()Xn5`=ctET^gMcebCyg&g(f7fHq8FL~NtB8707r1~~4OmkyA+Q|&!8P_I zfyJJ=pl-R2liFwrUiYln-c%j(;^P_;z4H&8`;Y?5XBe^l*5M?{R+?FjyhF^X)RcQ5om>!nv7i*Y6!FX67E^>}jOKYZ|Q1^3FrfL6r&V=*@a zpG(YO3$jzWh7F>eqOvq8;orZfSC_)?Bo$P?K7(kw?1!a60nE%l6kfgg|0!oQDKz&CAgCcAnxmATQ5;S==miuwt>>pM-jYV{yy ztmuIk)9o<6GKFW7Td~{MXJWbFH~m=u9PTQ$!Q9DnNm$o-JY#E0$n;QVn?H)BO}&E` zZa33MiOKY}%0=PQ8*z+^J;cmKbC}1^xt#U+$N0Cl0Y5rCz)dQj@d^1#&CZIlyq?3{ zjsKFs=lD4GB&Y&ERV-yYy)}ee?pQD*r(Cw``vZG?9@tlqwLvqhGzXgaba`9Kj6p#Xw`SF3K$_#+LnZFjiKD zY#n<7{3c$*PGW$oQxmxhx&N_MUDt8euVhSpsLHa(mx4etn^WVtzOL*LRs8mzdt}e& zs}~&=JouFlZH7W9&NN3W4JlS57!v#)?*_`2t@Ngt0y}ViI_f>0PqNaiu-Qyb@ZrsF z`b;?p)m|qH*0tQ_w$}E6*7`Hm-`nzAh~?`uY+Q<(SMw?^nY{kt|T%6h)o$x3Jm0{m}nZm+kPCA(L0O z({)Umjm&lAcK=SKH9tl%r9+YUaoZNCNYEgg{sl7k^kzuP8^gR?Qb9VVi~2;%vCbcQ zB-*DRBVH|nB`FK>jbQ+|d#1tmAY zv85rKFf9|b=Mt!|;ThHzu>q~K9pUiAaV%m-KP~y_!#)Y~Q8#oG)^%G!tMgRm>Z8Ss z?p?(PJ}Df(>Z0idI@EQRE<15pkvkma&$~dM!HFZX>{{e@d>v+vqy4wz7yf_DdTBa? z0TuS*x+ru_nTQFCWQk%E{~bT8K+Z(YW(Eh1Aas_t?SsSlg5ecQ@$Ul(2+HNTxe=Wx z$(rz<;}{%!HJ3I$AH@Y2WMNj=e)1!th=~s?F()3mV1M-#m*$v^Ibxzr&On!)QTZvn z+#i8oywhpJ*J51N9Eo=W2s;=r$~t1B(OlXAKS@Ngrlc@(sX!|@9I^?5q%k`I-sUgq%11rJk@^z@;v-W+vjNXBbz)-oo6b@$xCWtFOIVd8ys+$u%lS5kJU z;U93tytkrhKQkL0i^(^%P?PWR9C4JS31iNnaJ~qi?|F*Fi^id}#bhS6@<04*Wlnmz zcpBU~z!eSℑ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

  • F(VJcl7hhyzU3f4#{7(e~X0|}} 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

    )J-)QG{|J?l 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

  • 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

    +where + P: Policy + Send + 'static, +{ + type ActionContext = P::ActionContext; + type PolicyState = P::PolicyState; + + type Observation = P::Observation; + type ActionDistribution = P::ActionDistribution; + type Action = P::Action; + + fn forward(&mut self, states: Self::Observation) -> Self::ActionDistribution { + let (action_sender, action_receiver) = std::sync::mpsc::channel(); + let item = ForwardItem { + sender: action_sender, + inference_state: states, + }; + self.inference_state_sender + .send(InferenceMessage::ForwardMessage(item)) + .expect("Should be able to send message to inference_server"); + action_receiver + .recv() + .expect("AsyncPolicy should receive queued probabilities.") + } + + fn action( + &mut self, + states: Self::Observation, + deterministic: bool, + ) -> (Self::Action, Vec) { + let (action_sender, action_receiver) = std::sync::mpsc::channel(); + let item = ActionItem { + sender: action_sender, + inference_state: states, + deterministic, + }; + self.inference_state_sender + .send(InferenceMessage::ActionMessage(item)) + .expect("should be able to send message to inference_server."); + let action = action_receiver + .recv() + .expect("AsyncPolicy should receive queued actions."); + (action.action, action.context) + } + + fn update(&mut self, update: Self::PolicyState) { + self.inference_state_sender + .send(InferenceMessage::PolicyUpdate(update)) + .expect("AsyncPolicy should be able to send policy state.") + } + + fn state(&self) -> Self::PolicyState { + let (sender, receiver) = mpsc::channel(); + self.inference_state_sender + .send(InferenceMessage::PolicyRequest(sender)) + .expect("should be able to send message to inference_server."); + receiver + .recv() + .expect("AsyncPolicy should be able to receive policy state.") + } + + fn to_device(self, device: &Device) -> Self { + self.inference_state_sender + .send(InferenceMessage::ToDevice(device.clone())) + .expect("AsyncPolicy should be able to send policy state."); + self + } + + fn load_record(self, _record: ::Record) -> Self { + unimplemented!( + "Not implemented yet. Please load the record on the inner policy before creating an async policy." + ) + } +} + +#[cfg(test)] +#[allow(clippy::needless_range_loop)] +mod tests { + use std::thread::JoinHandle; + use std::time::Duration; + + use crate::tests::{MockAction, MockObservation, MockPolicy}; + + use super::*; + + #[test] + fn test_multiple_actions_before_flush() { + fn launch_thread(policy: &AsyncPolicy, handles: &mut Vec>) { + let mut thread_policy = policy.clone(); + let handle = spawn(move || { + thread_policy.action(MockObservation(vec![0.]), false); + }); + handles.push(handle); + } + + let policy = AsyncPolicy::new(8, MockPolicy::new()); + policy.increment_agents(1000); + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + + for _ in 0..6 { + launch_thread(&policy, &mut handles); + } + std::thread::sleep(Duration::from_millis(10)); + for i in 0..7 { + assert!(!handles[i].is_finished()); + } + + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + for i in 0..8 { + assert!(handles[i].is_finished()); + } + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + } + + #[test] + fn test_multiple_forward_before_flush() { + fn launch_thread(policy: &AsyncPolicy, handles: &mut Vec>) { + let mut thread_policy = policy.clone(); + let handle = spawn(move || { + thread_policy.forward(MockObservation(vec![0.])); + }); + handles.push(handle); + } + + let policy = AsyncPolicy::new(8, MockPolicy::new()); + policy.increment_agents(1000); + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + + for _ in 0..6 { + launch_thread(&policy, &mut handles); + } + std::thread::sleep(Duration::from_millis(10)); + for i in 0..7 { + assert!(!handles[i].is_finished()); + } + + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + for i in 0..8 { + assert!(handles[i].is_finished()); + } + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + } + + #[test] + fn test_async_policy_deterministic_behaviour() { + fn launch_thread( + policy: &AsyncPolicy, + handles: &mut Vec>, + deterministic: bool, + ) { + let mut thread_policy = policy.clone(); + let handle = spawn(move || { + let (action, _) = thread_policy.action(MockObservation(vec![0.]), deterministic); + action + }); + handles.push(handle); + } + + let policy = AsyncPolicy::new(2, MockPolicy::new()); + policy.increment_agents(1000); + + let mut handles = vec![]; + launch_thread(&policy, &mut handles, true); + launch_thread(&policy, &mut handles, false); + for _ in 0..2 { + let action = handles.pop().unwrap().join().unwrap(); + assert_eq!(action.0, vec![0]); + } + + let mut handles = vec![]; + launch_thread(&policy, &mut handles, true); + launch_thread(&policy, &mut handles, true); + for _ in 0..2 { + let action = handles.pop().unwrap().join().unwrap(); + assert_eq!(action.0, vec![1]); + } + } + + #[test] + fn flush_when_running_agents_smaller_than_autobatch_size() { + fn launch_thread(policy: &AsyncPolicy, handles: &mut Vec>) { + let mut thread_policy = policy.clone(); + let handle = spawn(move || { + thread_policy.action(MockObservation(vec![0.]), false); + }); + handles.push(handle); + } + + let policy = AsyncPolicy::new(8, MockPolicy::new()); + policy.increment_agents(3); + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + assert!(!handles[1].is_finished()); + + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + for i in 0..3 { + assert!(handles[i].is_finished()); + } + + let mut handles = vec![]; + launch_thread(&policy, &mut handles); + launch_thread(&policy, &mut handles); + std::thread::sleep(Duration::from_millis(10)); + assert!(!handles[0].is_finished()); + assert!(!handles[1].is_finished()); + + policy.decrement_agents(1); + std::thread::sleep(Duration::from_millis(10)); + assert!(handles[0].is_finished()); + assert!(handles[1].is_finished()); + } +} diff --git a/crates/burn-rl/src/policy/base.rs b/crates/burn-rl/src/policy/base.rs new file mode 100644 index 0000000..dd16c08 --- /dev/null +++ b/crates/burn-rl/src/policy/base.rs @@ -0,0 +1,122 @@ +use derive_new::new; + +use burn_core::tensor::Device; + +use crate::TransitionBatch; + +/// An action along with additional context about the decision. +#[derive(Clone, new)] +pub struct ActionContext { + /// The context. + pub context: C, + /// The action. + pub action: A, +} + +/// The state of a policy. +pub trait PolicyState { + /// The type of the record. + type Record; + + /// Convert the state to a record. + fn into_record(self) -> Self::Record; + /// Load the state from a record. + fn load_record(&self, record: Self::Record) -> Self; +} + +/// Defines how an environment's state is converted to a policy's observation. +pub trait ToObservation { + /// Convert an environment's state to a policy's observation, moving it to the given device if needed. + fn to_observation(&self, device: &Device) -> O; +} + +/// Defines how an environment's action is converted to a policy's action. +pub trait ToAction { + /// Convert an environment's action to a policy's action, moving it to the given device if needed. + fn to_action(&self, device: &Device) -> A; +} + +/// Trait for a RL policy. +pub trait Policy: Clone { + /// The observation given as input to the policy. + type Observation; + /// The action distribution parameters defining how the action will be sampled. + type ActionDistribution; + /// The action. + type Action; + + /// Additional context on the policy's decision. + type ActionContext; + /// The current parameterization of the policy. + type PolicyState: PolicyState; + + /// Produces the action distribution from a batch of observations. + fn forward(&mut self, obs: Self::Observation) -> Self::ActionDistribution; + /// Gives the action from a batch of observations. + fn action( + &mut self, + obs: Self::Observation, + deterministic: bool, + ) -> (Self::Action, Vec); + + /// Update the policy's parameters. + fn update(&mut self, update: Self::PolicyState); + /// Returns the current parameterization. + fn state(&self) -> Self::PolicyState; + + /// Loads the policy on the given device. + fn to_device(self, device: &Device) -> Self; + /// Loads the policy parameters from a record. + fn load_record(self, record: ::Record) -> Self; +} + +/// Trait for a type that can be batched and unbatched (split). +pub trait Batchable: Sized { + /// Create a batch from a list of items. + fn batch(value: Vec) -> Self; + /// Create a list from batched items. + fn unbatch(self) -> Vec; +} + +/// A training output. +pub struct RLTrainOutput { + /// The policy. + pub policy: P, + /// The item. + pub item: TO, +} + +/// Batched transitions for a PolicyLearner. +pub type LearnerTransitionBatch